136 lines
5.6 KiB
Python
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()
|