schoolbridge / model /classification /scripts /auto_label_from_new_data_20260504.py
tsKim
feat: schoolbridge spaces deploy (extract-text endpoint added)
7f105c8
"""
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()