File size: 9,755 Bytes
7f105c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
"""
베이스라인 분류기: TF-IDF + Logistic Regression
=================================================
담당: 경이
역할: 라벨 데이터(notice_sample_v3.csv + notices_labeled_v2.csv)로 학습한
      6-class 텍스트 분류기.
      GPU 불필요. CPU에서 수십 ms 이내 추론 가능.
      KcELECTRA 파인튜닝과 성능 비교할 베이스라인.

사용법:
    python classifier_simple.py          # 학습 + 저장
    python classifier_simple.py --eval   # 저장된 모델 평가
"""

import pickle
import argparse
from pathlib import Path

import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report, confusion_matrix

# 경로 기준: model/classification/
_BASE = Path(__file__).parent.parent
DATA_CSV    = _BASE / "data" / "notice_sample_v3.csv"
LEGACY_CSV  = _BASE / "data" / "notices_labeled_v2.csv"   # 기존 라벨 데이터
SPLIT_CSV   = _BASE / "data" / "split_v1.csv"
MODEL_PKL   = _BASE / "checkpoints" / "simple_tfidf_logreg.pkl"

LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"]

# 기존 CSV는 컬럼명이 'original_text'이므로 통일 처리
_TEXT_COLS = ["text", "original_text", "sentence"]


def _normalize_df(df: pd.DataFrame) -> pd.DataFrame:
    """컬럼명이 다른 여러 데이터 소스를 text/category 형식으로 통일."""
    for col in _TEXT_COLS:
        if col in df.columns and "text" not in df.columns:
            df = df.rename(columns={col: "text"})
            break
    return df


# ─────────────────────────────────────────────────────────────
# 데이터 로드
# ─────────────────────────────────────────────────────────────
def load_data(split: str = "train") -> tuple[list[str], list[str]]:
    """split_v1.csv → 없으면 원본 CSV (+ legacy CSV 병합)에서 텍스트·라벨 로드.

    split: "train" | "val" | "test" | "all"
    """
    if SPLIT_CSV.exists():
        df = pd.read_csv(SPLIT_CSV)
        df = _normalize_df(df)
        if split != "all":
            df = df[df["split"] == split]
    else:
        frames = [pd.read_csv(DATA_CSV)]
        if LEGACY_CSV.exists():
            legacy = pd.read_csv(LEGACY_CSV)
            legacy = _normalize_df(legacy)
            frames.append(legacy)
        df = pd.concat(frames, ignore_index=True)
        df = _normalize_df(df)

    df = df.dropna(subset=["text", "category"])
    df = df[df["category"].isin(LABELS)]
    return df["text"].tolist(), df["category"].tolist()


# ─────────────────────────────────────────────────────────────
# 파이프라인 정의
# ─────────────────────────────────────────────────────────────
def build_pipeline() -> Pipeline:
    """TF-IDF + Logistic Regression 파이프라인.

    TF-IDF 파라미터:
      - analyzer="char_wb": 한국어는 글자 단위 n-gram이 어절 단위보다 OOV((Out-of-Vocabulary)에 강함 : 단어 분리 없이 글자 단위 n-gram 사용 → 형태소 분석기 불필요, 미등록어(OOV)에 강함
      - 글자 n-gram은 조사/어미가 달라도 어근 조각이 겹치기 때문에 자연스럽게 대응
      - 글자 조각(n-gram)으로 쪼개면 처음 보는 표현도 익숙한 조각들로 분해되어 의미를 파악할 수 있다.
      - ngram_range=(2, 4): 2~4글자 조합으로 어미/조사 같은 형태소 정보 간접 포착
      - max_features=30000: 메모리·속도 균형
      - sublinear_tf=True: 빈도를 log(1+tf)로 변환해 특정 단어의 과도한 영향 억제
    LogReg 파라미터:
      - C=1.0: 기본 정규화 (오버피팅 방지) / C=1.0 — 정규화 강도
      - 정규화의 필요성: 모델이 학습 데이터에 너무 딱 맞게 학습되면 새로운 데이터에서 성능이 떨어집니다 (오버피팅).
      - C는 정규화의 반대 개념입니다.
      - C 값이 작을수록  →  정규화 강함  →  가중치를 강하게 억제  →  단순한 모델 
      - C 값이 클수록   →  정규화 약함  →  가중치를 자유롭게 키움  →  복잡한 모델
      - max_iter=1000: 수렴 보장
      - 초기 가중치 → 예측 → 오차 계산 → 가중치 조정 → 예측 → 오차 계산 → ...
        (1번째 iter)                        (2번째 iter)
      - class_weight="balanced": 클래스 불균형 대응
      - solver="lbfgs": 다중 클래스에 적합한 최적화 알고리즘
    """
    return Pipeline([
        ("tfidf", TfidfVectorizer(
            analyzer="char_wb",
            ngram_range=(2, 4),
            max_features=30_000,
            sublinear_tf=True,
        )),
        ("clf", LogisticRegression(
            C=1.0,
            max_iter=1000,
            class_weight="balanced",
            random_state=42,
            solver="lbfgs",
        )),
    ])


# ─────────────────────────────────────────────────────────────
# 학습
# ─────────────────────────────────────────────────────────────
def train() -> Pipeline:
    texts, labels = load_data("train")
    if not texts:
        texts, labels = load_data("all")

    pipe = build_pipeline()
    pipe.fit(texts, labels)

    MODEL_PKL.parent.mkdir(parents=True, exist_ok=True)
    with open(MODEL_PKL, "wb") as f:
        pickle.dump(pipe, f)
    print(f"[simple] 모델 저장 완료: {MODEL_PKL}")
    print(f"[simple] 학습 데이터 수: {len(texts)}개")
    return pipe


# ─────────────────────────────────────────────────────────────
# 로드 (캐시)
# ─────────────────────────────────────────────────────────────
_pipeline: Pipeline | None = None


def load_pipeline() -> Pipeline:
    global _pipeline
    if _pipeline is not None:
        return _pipeline

    if not MODEL_PKL.exists():
        print("[simple] 저장된 모델 없음 → 학습 시작")
        _pipeline = train()
    else:
        with open(MODEL_PKL, "rb") as f:
            _pipeline = pickle.load(f)
    return _pipeline


# ─────────────────────────────────────────────────────────────
# 추론
# ─────────────────────────────────────────────────────────────
def predict_simple(text: str) -> dict:
    """텍스트 → 카테고리 + 신뢰도(각 클래스 확률).

    반환:
        {
            "category": str,          # 예측 라벨
            "confidence": float,      # 예측 클래스 확률
            "probs": dict[str, float] # 전체 클래스 확률 (explain 용)
        }
    """
    pipe = load_pipeline()
    proba = pipe.predict_proba([text])[0]
    classes = pipe.classes_

    idx = proba.argmax()
    return {
        "category":   classes[idx],
        "confidence": float(proba[idx]),
        "probs":      {c: float(p) for c, p in zip(classes, proba)},
    }


# ─────────────────────────────────────────────────────────────
# 평가
# ─────────────────────────────────────────────────────────────
def evaluate(split: str = "test") -> dict:
    texts, true_labels = load_data(split)
    if not texts:
        texts, true_labels = load_data("all")

    pipe = load_pipeline()
    pred_labels = pipe.predict(texts)

    report = classification_report(
        true_labels, pred_labels,
        labels=LABELS,
        output_dict=True,
        zero_division=0,
    )
    cm = confusion_matrix(true_labels, pred_labels, labels=LABELS)

    print("\n[simple] 분류 리포트")
    print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0))
    print("[simple] Confusion Matrix")
    print(cm)

    return {"report": report, "confusion_matrix": cm.tolist(), "model": "simple"}


# ─────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--eval", action="store_true", help="저장된 모델 평가")
    args = parser.parse_args()

    if args.eval:
        evaluate()
    else:
        train()
        evaluate("test")