#!/usr/bin/env python3 """콘텐츠 요소(메타데이터) 추출 F1 평가 — 계획서 성능지표 No.3. 데이터(요소 정답 라벨)가 들어오면 즉시 측정할 수 있도록 평가환경을 선구축한다. 입력 형식 (JSONL, 한 줄당 한 건): {"text": "원문 ...", "characters": ["홍길동"], "motifs": ["복수"], "keywords": ["활빈당"], "genre": "역사"} 동작: - 현 추출기(RuleExtractor / OpenAIExtractor)로 text 에서 요소 예측 - 정답 라벨과 집합 단위 F1 (요소 유형별 + 마이크로 평균) - 정답셋 없으면 내장 dry-run 샘플로 파이프라인 검증 - --klue 옵션: KLUE NER 공개 데이터(PS 태그)로 인물 추출 F1 평가 (datasets 필요) 사용: python -m scripts.eval_metadata_f1 # dry-run python -m scripts.eval_metadata_f1 data/eval/meta.jsonl # 정답셋 평가 python -m scripts.eval_metadata_f1 --klue --limit 500 # KLUE NER 평가 """ from __future__ import annotations import argparse import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.engine.extractor import get_extractor # noqa: E402 from app.engine.metadata_eval import evaluate_extraction # noqa: E402 _DRY_RUN = [ { "text": "홍길동은 활빈당을 만들어 탐관오리의 재물을 빼앗아 백성에게 나누어 주었다. 복수를 꿈꿨다.", "characters": ["홍길동"], "motifs": ["복수"], "keywords": ["활빈당", "탐관오리", "재물", "백성"], "genre": "역사", }, { "text": "어린왕자는 별을 떠나 여행하며 여우를 만나 우정을 배웠다.", "characters": ["어린왕자", "여우"], "motifs": ["여행", "우정"], "keywords": ["별", "여우"], "genre": "에세이", }, ] def _load(path: str | None) -> tuple[list[dict], bool]: if path is None: return _DRY_RUN, True p = Path(path) if not p.exists(): print(f"[warn] 파일 없음: {path} → 내장 dry-run 샘플로 진행", file=sys.stderr) return _DRY_RUN, True rows = [json.loads(line) for line in p.read_text(encoding="utf-8").splitlines() if line.strip()] return rows, False def _load_klue(limit: int) -> tuple[list[dict], bool]: """KLUE NER 공개 데이터 → 인물(PS) 정답으로 변환. datasets 미설치 시 dry-run.""" try: from datasets import load_dataset except Exception: print("[warn] `datasets` 미설치 → `pip install datasets` 후 --klue 사용 가능. dry-run 진행.", file=sys.stderr) return _DRY_RUN, True ds = load_dataset("klue", "ner", split=f"validation[:{limit}]") rows: list[dict] = [] for ex in ds: tokens, tags = ex["tokens"], ex["ner_tags"] names = ex["ner_tags"] # placeholder; 실제 PS 추출은 라벨맵 기반 text = "".join(tokens) # BIO → PS 엔티티 복원 (라벨 스킴은 데이터셋 버전에 맞춰 조정) persons, cur = [], "" label_names = ds.features["ner_tags"].feature.names for tok, tag in zip(tokens, tags): name = label_names[tag] if name.endswith("PS") and name.startswith("B"): if cur: persons.append(cur) cur = tok elif name.endswith("PS") and name.startswith("I"): cur += tok else: if cur: persons.append(cur) cur = "" if cur: persons.append(cur) rows.append({"text": text, "characters": persons, "motifs": [], "keywords": [], "genre": None}) return rows, False def main() -> None: ap = argparse.ArgumentParser(description="메타데이터 추출 F1 평가 (성능지표 No.3)") ap.add_argument("dataset", nargs="?", default=None, help="JSONL 정답셋 (없으면 dry-run)") ap.add_argument("--klue", action="store_true", help="KLUE NER 공개 데이터로 인물 F1 평가") ap.add_argument("--limit", type=int, default=200, help="KLUE 평가 샘플 수") args = ap.parse_args() if args.klue: rows, is_dry = _load_klue(args.limit) else: rows, is_dry = _load(args.dataset) extractor = get_extractor() predictions = [] for row in rows: elem = extractor.extract(row.get("text", "")) predictions.append({ "characters": elem.characters, "motifs": elem.motifs, "keywords": elem.keywords, "genre": elem.genre, }) scores = evaluate_extraction(predictions, rows) src = "DRY-RUN (내장 샘플)" if is_dry else ("KLUE NER" if args.klue else f"{args.dataset}") print(f"\n=== 메타데이터 추출 F1 — {src} ({len(rows)}건) ===") print(f"{'field':<14}{'precision':>12}{'recall':>12}{'f1':>12}") for field in ("characters", "motifs", "keywords", "micro_avg"): s = scores[field] print(f"{field:<14}{s['precision']:>12.4f}{s['recall']:>12.4f}{s['f1']:>12.4f}") ga = scores["genre_accuracy"] if ga["total"]: print(f"\n장르 정확도: {ga['accuracy']:.4f} ({ga['correct']}/{ga['total']})") micro_f1 = scores["micro_avg"]["f1"] status = "달성" if micro_f1 >= 0.83 else "미달" print(f"\n목표(No.3) F1 0.83 대비 마이크로 F1 = {micro_f1:.4f} → {status}") if is_dry: print("\n※ dry-run 수치. 컴북스 요소 정답 라벨 / KLUE NER 수령 후 정식 측정.") print(" 현 추출기는 룰 기반 폴백 → 1단계 sLLM(KLUE NER 파인튜닝) 교체 시 향상 예정.") if __name__ == "__main__": main()