o2o-plagiarism-ai/scripts/eval_metadata_f1.py

136 lines
5.6 KiB
Python

#!/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()