""" auto_label_from_new_data_20260504.py ===================================== 담당: 경이 (kyeongyi) 작성일: 2026-05-04 브랜치: feature/kyeongyi-classification 목적: 윤정님이 새로 추가한 대용량 데이터(5,560행)에는 'is_todo' 정보만 있고 '일정/준비물/제출/비용/건강·안전/기타' 라벨이 없다. 이 스크립트는 키워드 규칙과 슬롯(amount, due_date, action_hint)을 이용해 자동으로 카테고리를 부여하고, 기존 v3.csv와 합쳐 v4 학습 데이터를 생성한다. 왜 '자동 라벨링'을 썼나? 1. 새 데이터 5,560행을 사람이 수작업으로 라벨링하면 수십 시간 걸림. 2. 기존 라벨 데이터(v3.csv, 142행)만으로는 KcELECTRA 파인튜닝이 부족 (BERT 계열은 최소 클래스당 50~100개, 전체 300~1000개 이상 필요). 3. 키워드 기반 자동 라벨링 → 대량 데이터 확보 → KcELECTRA 재학습 → 성능 역전. 검증 방법: - 기존 라벨이 있는 v3.csv를 이 규칙으로 다시 분류 → 규칙 정확도 확인. - 규칙 정확도가 75% 이상이면 자동 라벨 데이터를 신뢰할 수 있다고 판단. 실행: cd model/classification python scripts/auto_label_from_new_data_20260504.py """ import json import re import csv import random from pathlib import Path from collections import Counter random.seed(42) # 재현성 보장 — 누가 실행해도 동일한 분할·샘플 순서 # ────────────────────────────────────────────────────────────────── # 1. 경로 설정 # ────────────────────────────────────────────────────────────────── # 이 파일의 위치: model/classification/scripts/ # _BASE → model/classification/ _BASE = Path(__file__).parent.parent DATA_DIR = _BASE / "data" EXTRACT_BASE = _BASE.parent.parent / "model" / "extraction" # 윤정님 모델 폴더 # 입력 파일 (윤정님이 추가한 새 데이터) SRC_TEST_DATA = EXTRACT_BASE / "data" / "train" / "test_data.jsonl" SRC_PREDICT_OUT = EXTRACT_BASE / "data" / "processed" / "predict_output_testset.jsonl" # 기존 경이님 라벨 데이터 SRC_V3_CSV = DATA_DIR / "notice_sample_v3.csv" # 출력 파일 OUT_CSV = DATA_DIR / "notice_sample_v4_20260504.csv" # 6가지 분류 카테고리 LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] # ────────────────────────────────────────────────────────────────── # 2. 카테고리별 키워드 규칙 # ────────────────────────────────────────────────────────────────── # 설계 원칙: # (a) 우선순위 순서로 검사 — 먼저 매칭된 카테고리를 반환하고 중단. # (b) 각 카테고리에서 가장 '강한' 신호(오탐 없는 단어)를 선택. # (c) 한국어는 조사·어미 변형이 많으므로 어근만 사용 (예: "납부" → "납부해", "납부하여" 모두 포함). # (d) 숫자 패턴은 정규표현식 사용 (예: "20,000원", "3만원" 등 다양한 금액 표현). # # 왜 이 순서인가? # 비용 > 준비물 > 제출 > 건강·안전 > 일정 > 기타 # → "3만 원을 5월 15일까지 납부" 같은 문장은 '비용'이 더 강한 신호. # → '일정'은 날짜 표현이 많아 오탐 위험이 있어 후순위. # → '기타'는 규칙 없이 나머지를 다 받는 쓰레기통 역할. RULES: list[tuple[str, list[str]]] = [ # ── 비용 ── # 금액·납부 관련 표현이 명확하게 있는 경우. # r"\d[\d,]*\s*원" : "65,000원", "3만원"은 이 정규식으로 포착. ("비용", [ "납부", # "납부해 주세요", "납부하여" "입금", # "계좌로 입금" "계좌이체", # "계좌이체해 주세요" "급식비", "참가비", "수강료", "교재비", "버스비", "이용료", "수업료", "구입비", "지원금", "면제", # "급식비 면제 신청" "선납", # "선납해 주세요" r"\d[\d,]*\s*원", # 숫자+원 (20,000원, 3만원 등) ]), # ── 준비물 ── # 챙겨야 할 물건·복장 관련. # "마스크를" 처럼 뒤에 조사가 붙는 경우도 어근으로 포착. ("준비물", [ "지참", # "지참하시기 바랍니다" "챙겨", # "챙겨주시기 바랍니다" "챙기", # "챙기시기 바랍니다" "준비해", # "준비해 주세요" "준비물", # "학습 준비물" "가져오", # "가져오세요" "착용", # "착용하여 등교" "신고 오", # "운동화를 신고 오세요" "복장", "도시락", "우산", "수영복", "실내화", "방한", # "방한용품" "앞치마", "고무장갑", "배낭", "여벌", # "여벌 옷" "스케치북", "색연필", ]), # ── 제출 ── # 서류·동의서·설문 등 무언가를 학교에 내야 하는 경우. ("제출", [ "제출", # "제출해 주세요", "제출하여" "동의서", # "현장체험학습 동의서" "신청서", # "급식 신청서" "설문", # "온라인 설문에 응답" "서류를", "작성하여", # "작성하여 제출" "응답해", # "설문에 응답해 주세요" "기재", # "기재해 주세요" "접수", "첨부", "등록", # "회원 등록" "신청해", # "신청해 주세요" "기한 내 신청", "아이 편에", # "아이 편에 보내주시기" "담임선생님께 보내", ]), # ── 건강·안전 ── # 신체 관련, 안전사고 예방, 감염병 관련. ("건강·안전", [ "발열", "기침", "증상", "예방접종", "백신", "감염", "방역", "소독", "위생", "자가진단", "결석", "질병", "코로나", "독감", "알레르기", "안전사고", "교통안전", "헬멧", "안전벨트", "온열", # "온열 질환" "응급", "선별진료", "PCR", "진단키트", "확진", "수분 보충", ]), # ── 일정 ── # 날짜·시간·행사 관련. 비용/준비물/제출 보다 후순위인 이유: # "3만 원을 5월 15일까지 납부" → 날짜가 있어도 '비용'이 정답. # 날짜 패턴은 오탐이 많아 '강한' 단어와 함께 쓸 때만 신뢰. ("일정", [ r"\d+월\s*\d+일", # "5월 20일", "3 월 7 일" r"\d{4}\.\s*\d+\.\s*\d+", # "2025.05.20" r"\d+\.\s*\d+\.\s*\([월화수목금토일]\)", # "5.20.(화)" "오전", # "오전 9시" "오후", "~까지", # "3월 20일~까지" "부터 ", # "3월부터 " "기간", "주간", "방학", "개학", "졸업식", "입학식", "운동회", "수학여행", "체험학습", "현장학습", "학부모 총회", "공개수업", "학예발표", "체육대회", "생태체험", "시작합니다", "출발합니다", "진행됩니다", "실시됩니다", "열립니다", "개최됩니다", ]), ] # RULES에 없는 문장 → "기타"로 분류 # ────────────────────────────────────────────────────────────────── # 3. 정규표현식 여부 판별 헬퍼 # ────────────────────────────────────────────────────────────────── def _is_regex(pattern: str) -> bool: """문자열 안에 정규표현식 특수문자가 있으면 True.""" # \d, ^, $, |, ?, *, +, (, ), [, ], {, } 중 하나라도 있으면 regex regex_chars = r"\d^$.|?*+()[]{}".replace(".", r"\.") return bool(re.search(r"[\\^$.|?*+()\[\]{}]|\\d", pattern)) # ────────────────────────────────────────────────────────────────── # 4. 핵심 분류 함수 (규칙 기반) # ────────────────────────────────────────────────────────────────── def label_by_keywords(text: str) -> str | None: """ 텍스트에 키워드·패턴이 포함되면 해당 카테고리를 반환. RULES 리스트 순서대로 검사 — 첫 매칭에서 즉시 반환. 없으면 None 반환 (→ 슬롯 기반 분류로 넘어감). """ for category, patterns in RULES: for pat in patterns: if _is_regex(pat): if re.search(pat, text): return category else: if pat in text: return category return None def label_by_slots( action_hint: str | None, amount: str | None, due_date: str | None, ) -> str | None: """ 윤정님 추출 모델이 뽑은 슬롯 정보를 이용한 보조 분류. 키워드 규칙이 None을 반환했을 때만 호출. 논리: - amount 있음 → 비용 관련 문장일 가능성이 높다 - action_hint = "제출"/"신청" → 제출 카테고리 - action_hint = "준비"/"지참" → 준비물 카테고리 - due_date 있음 + 비용/제출 키워드 없음 → 일정 관련 """ if amount: return "비용" if action_hint in ("제출", "신청", "작성"): return "제출" if action_hint in ("준비", "지참", "착용"): return "준비물" if due_date: # 날짜가 있으나 다른 강한 신호가 없으면 일정 return "일정" return None def classify( text: str, action_hint: str | None = None, amount: str | None = None, due_date: str | None = None, ) -> str | None: """ 최종 분류 함수. 1단계: 키워드 규칙 (빠르고 명확) 2단계: 슬롯 기반 (윤정님 추출 정보 활용) 모두 실패하면 None → 이 문장은 학습 데이터에서 제외 (노이즈 방지). '기타'를 일부러 규칙에 넣지 않은 이유: 규칙으로 잡히지 않는 문장이 전부 기타가 되면 기타 데이터가 너무 많아지고, 오탐(실제론 일정인데 기타로 잡힌 것)도 섞임. → 기타는 별도 limit을 두지 않고 "나머지"로만 수집. """ label = label_by_keywords(text) if label: return label label = label_by_slots(action_hint, amount, due_date) if label: return label return None # 애매한 문장은 버린다 # ────────────────────────────────────────────────────────────────── # 5. 데이터 로드 함수 # ────────────────────────────────────────────────────────────────── def load_test_data() -> list[dict]: """ test_data.jsonl : {text, is_todo} is_todo = True인 행만 사용. 이유: False인 행은 학교 공지 중 '할 일이 없는' 순수 안내 문장. 분류 모델의 목적(학부모가 해야 할 행동 분류)에 맞지 않아 제외. """ rows = [] with open(SRC_TEST_DATA, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue d = json.loads(line) if d.get("is_todo") is True: rows.append({ "text": d["text"], "action_hint": None, # 이 파일엔 슬롯 정보 없음 "amount": None, "due_date": None, }) return rows def load_predict_output() -> list[dict]: """ predict_output_testset.jsonl : {text, source, due_date, amount, confidence, action_hint, true_is_todo} 필터 기준: - true_is_todo = True : 명확하게 할 일인 문장 - confidence >= 0.7 : 윤정님 모델이 확신하는 문장 이 두 조건 중 하나라도 만족하면 사용. 슬롯(amount, due_date, action_hint)은 자동 라벨링 2단계(label_by_slots)에 활용. """ rows = [] with open(SRC_PREDICT_OUT, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue d = json.loads(line) is_todo = d.get("true_is_todo") is True high_conf = d.get("confidence", 0) >= 0.7 if is_todo or high_conf: rows.append({ "text": d["text"], "action_hint": d.get("action_hint"), "amount": d.get("amount"), "due_date": d.get("due_date"), }) return rows def load_v3_csv() -> list[tuple[str, str]]: """ 기존에 경이님이 직접 라벨링한 notice_sample_v3.csv 로드. 컬럼: text, category """ rows = [] with open(SRC_V3_CSV, encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for row in reader: text = row.get("text", "").strip() cat = row.get("category", "").strip() if text and cat in LABELS: rows.append((text, cat)) return rows # ────────────────────────────────────────────────────────────────── # 6. 규칙 검증 함수 — 기존 라벨 데이터로 규칙 정확도 측정 # ────────────────────────────────────────────────────────────────── def validate_rules(v3_rows: list[tuple[str, str]]) -> float: """ v3.csv는 경이님이 직접 라벨링한 '정답 데이터'다. 이 정답 데이터에 자동 라벨링 규칙을 적용해 몇 %나 맞히는지 확인. 목표: 75% 이상 → 자동 라벨 데이터를 신뢰할 수 있다고 판단. 낮으면 규칙을 수정해야 함. """ correct = 0 total = 0 errors = [] for text, true_cat in v3_rows: pred = classify(text) if pred is None: pred = "기타" # 규칙 미매칭 → 기타로 간주 total += 1 if pred == true_cat: correct += 1 else: errors.append((text[:40], true_cat, pred)) accuracy = correct / total if total > 0 else 0.0 print("\n[규칙 검증] v3.csv 기준 자동 라벨링 정확도") print(f" 정답: {correct}/{total} = {accuracy:.1%}") if errors: print(f" 오분류 예시 (상위 10개):") for txt, true, pred in errors[:10]: print(f" [{true}→{pred}] {txt}") if accuracy >= 0.75: print(" [OK] 규칙 신뢰도 충분 (75% 이상) -> 자동 라벨 데이터 채택") else: print(" [경고] 규칙 신뢰도 부족 -- 규칙을 추가/수정할 것을 권장") return accuracy # ────────────────────────────────────────────────────────────────── # 7. 라벨링 + 필터링 # ────────────────────────────────────────────────────────────────── MIN_TEXT_LEN = 10 # 10글자 미만 단편 문장은 노이즈 가능성이 높아 제외 def label_all(rows: list[dict]) -> list[tuple[str, str]]: """ rows 각각에 classify()를 적용해 (text, category) 쌍 반환. - MIN_TEXT_LEN 미만 → 제외 - classify() 결과가 None → 제외 (애매한 문장) """ labeled: list[tuple[str, str]] = [] for r in rows: text = r["text"].strip() if len(text) < MIN_TEXT_LEN: continue cat = classify(text, r.get("action_hint"), r.get("amount"), r.get("due_date")) if cat: labeled.append((text, cat)) else: # 규칙·슬롯 미매칭 문장은 '기타'로 넣되, 별도 카운터로 제한 labeled.append((text, "기타")) return labeled MAX_NEW_PER_LABEL = 120 # 새 데이터에서 카테고리당 최대 수집 수 # 이유: 너무 많으면 노이즈가 늘어나고, 클래스 불균형도 발생. # 기존 v3(약 25/카테고리) + 신규(최대 120/카테고리) → 전체 약 900개 목표. def balance_new_data( labeled: list[tuple[str, str]], max_per: int, ) -> list[tuple[str, str]]: """ 카테고리별로 max_per 개까지만 샘플링. random.shuffle(seed=42)로 무작위 선택 → 재현 가능. 왜 균형이 필요한가? KcELECTRA 파인튜닝 시 특정 클래스 데이터가 너무 많으면 모델이 그 클래스로 편향됨 (기존 문제와 동일한 class collapse 현상). """ buckets: dict[str, list[str]] = {l: [] for l in LABELS} for text, cat in labeled: if cat in buckets: buckets[cat].append(text) result: list[tuple[str, str]] = [] for cat, texts in buckets.items(): random.shuffle(texts) for t in texts[:max_per]: result.append((t, cat)) return result # ────────────────────────────────────────────────────────────────── # 8. 중복 제거 # ────────────────────────────────────────────────────────────────── def remove_duplicates(rows: list[tuple[str, str]]) -> list[tuple[str, str]]: """ 동일 텍스트가 두 번 이상 나타나면 첫 번째만 유지. 왜 필요한가? test_data.jsonl과 predict_output_testset.jsonl이 같은 원본에서 파생됐기 때문에 중복 문장이 존재할 수 있음. 중복이 있으면 test 세트에도 같은 문장이 들어가 평가가 부풀려짐. """ seen: set[str] = set() out: list[tuple[str, str]] = [] for text, cat in rows: if text not in seen: seen.add(text) out.append((text, cat)) return out # ────────────────────────────────────────────────────────────────── # 9. 저장 # ────────────────────────────────────────────────────────────────── def save_csv(rows: list[tuple[str, str]], path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8-sig", newline="") as f: # quoting=QUOTE_ALL : 쉼표·줄바꿈 포함 텍스트도 안전하게 저장 writer = csv.writer(f, quoting=csv.QUOTE_ALL) writer.writerow(["text", "category"]) for text, cat in rows: writer.writerow([text, cat]) print(f"\n[저장] {path} ({len(rows)}행)") # ────────────────────────────────────────────────────────────────── # 10. 메인 실행 # ────────────────────────────────────────────────────────────────── def main() -> None: print("=" * 60) print(" 자동 라벨링 파이프라인 시작 (2026-05-04)") print("=" * 60) # Step A: 기존 라벨 데이터 로드 v3_rows = load_v3_csv() print(f"\n[A] 기존 v3.csv: {len(v3_rows)}개") cnt_v3 = Counter(cat for _, cat in v3_rows) for l in LABELS: print(f" {l}: {cnt_v3.get(l, 0)}개") # Step B: 규칙 검증 (v3.csv 기준) rule_acc = validate_rules(v3_rows) # Step C: 새 데이터 로드 print("\n[C] 새 데이터 로드") test_rows = load_test_data() predict_rows = load_predict_output() print(f" test_data.jsonl (is_todo=True): {len(test_rows):,}개") print(f" predict_output (필터 후): {len(predict_rows):,}개") # Step D: 자동 라벨링 print("\n[D] 자동 라벨링 중...") labeled_test = label_all(test_rows) labeled_predict = label_all(predict_rows) all_new = labeled_test + labeled_predict print(f" 라벨링 완료: {len(all_new):,}개") cnt_new = Counter(cat for _, cat in all_new) for l in LABELS: print(f" {l}: {cnt_new.get(l, 0)}개") # Step E: 균형 조정 (카테고리당 최대 MAX_NEW_PER_LABEL개) balanced = balance_new_data(all_new, MAX_NEW_PER_LABEL) print(f"\n[E] 균형 조정 후: {len(balanced)}개") cnt_bal = Counter(cat for _, cat in balanced) for l in LABELS: print(f" {l}: {cnt_bal.get(l, 0)}개") # Step F: 기존 v3 + 새 데이터 병합 → 중복 제거 combined = v3_rows + balanced combined = remove_duplicates(combined) random.shuffle(combined) # 파일 내 순서 무작위화 print(f"\n[F] 최종 병합 (중복 제거 후): {len(combined)}개") cnt_final = Counter(cat for _, cat in combined) for l in LABELS: print(f" {l}: {cnt_final.get(l, 0)}개") # Step G: 저장 save_csv(combined, OUT_CSV) # 최종 요약 print("\n" + "=" * 60) print(" 완료 요약") print("=" * 60) print(f" 기존 v3.csv: {len(v3_rows):>4}개") print(f" 새 데이터 (균형 후): {len(balanced):>4}개") print(f" 최종 v4 (중복 제거): {len(combined):>4}개") print(f" 규칙 정확도: {rule_acc:.1%}") print(f" 출력: {OUT_CSV}") print() print("다음 단계:") print(" python scripts/split_dataset.py --data v4_20260504 --force") print(" python src/classifier_simple.py (베이스라인 재학습)") print(" Colab에서 notebooks/03_train_kcelectra_v2_20260504.ipynb 실행") if __name__ == "__main__": main()