Spaces:
Running
Running
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")
|