컴북스 데이터 수령 전 가능한 작업을 선행 구현. 데이터 도착 즉시 학습·검증에 착수할 수 있도록 알고리즘·평가 하니스·문서를 준비. 알고리즘/파이프라인: - engine/clustering.py: 군집화 기반 부분 표절(요소 교체) 판별, detect 응답에 partial_signal 필드 노출 (계획서 2단계 표절검출 고도화) - engine/summarizer.py + POST /v1/summary: TextRank 추출적 요약 + 통합(LLM) 골격 (과제2 ② 스토리 요약/분석) - engine/preference.py + scripts/build_preference_dataset.py: Human Feedback 선호학습(DPO) 데이터 파이프라인 골격 평가 하니스 (정답셋 도착 즉시 측정): - engine/rouge.py + scripts/eval_rouge.py: 요약 ROUGE (지표 No.7) - engine/metadata_eval.py + scripts/eval_metadata_f1.py: 메타 추출 F1 (지표 No.3, KLUE NER 호환) - engine/case_coverage.py + scripts/analyze_case_coverage.py: 39 케이스 갭 분석 → 컴북스 데이터 요청 목록 (정밀도 97% 근거) 문서: - docs/DATA_REQUEST_SPEC.md: 요청 데이터 스펙 + 요약 정답셋/HF 라벨 가이드 - docs/INTEGRATION_INTERFACE.md: 2단계 통합 인터페이스 - docs/SW_COPYRIGHT_REGISTRATION.md: SW 저작권 등록 준비 - docs/PHASE2_PROGRESS.md, docs/CASE_COVERAGE.md 테스트 31 → 67건 전부 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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()
|