o2o-plagiarism-ai/app/engine/metadata_eval.py
hbyang 0472ae1f8a 2단계 고도화 선행 작업: 군집화·요약·평가환경·데이터 스펙 (데이터 수령 전)
컴북스 데이터 수령 전 가능한 작업을 선행 구현. 데이터 도착 즉시
학습·검증에 착수할 수 있도록 알고리즘·평가 하니스·문서를 준비.

알고리즘/파이프라인:
- 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>
2026-06-18 13:16:07 +09:00

102 lines
3.4 KiB
Python

"""콘텐츠 요소(메타데이터) 추출 F1 평가 (계획서 성능지표 No.3, p.24).
계획서 평가방식:
KLUE 데이터셋의 NER(개체명 인식) 데이터를 활용하여 학습/테스트,
KLUE Leaderboard 등재 baseline 대비 상대 평가. 목표 F1 83 이상(top5).
본 모듈은 정답 라벨(gold) 세트가 주어졌을 때 추출기 출력과의 집합 단위
precision/recall/F1 을 계산한다. 요소 유형(characters/motifs/keywords/genre)
별로 분리 측정 + 마이크로 평균.
gold 라벨은 다음 두 경로 중 하나로 공급:
① 컴북스 1단계 '콘텐츠 구성요소 정의' 라벨 (article 단위)
② KLUE NER 공개 데이터의 PS(인물) 태그 → characters 평가 (scripts 에서 변환)
"""
from __future__ import annotations
from dataclasses import dataclass
def _norm(s: str) -> str:
return s.strip().lower()
@dataclass
class PRF:
precision: float
recall: float
f1: float
tp: int
fp: int
fn: int
def as_dict(self) -> dict:
return {
"precision": round(self.precision, 4),
"recall": round(self.recall, 4),
"f1": round(self.f1, 4),
"tp": self.tp, "fp": self.fp, "fn": self.fn,
}
def _prf_from_counts(tp: int, fp: int, fn: int) -> PRF:
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
return PRF(precision, recall, f1, tp, fp, fn)
def set_prf(predicted: list[str], gold: list[str]) -> PRF:
"""단일 문서 한 요소 유형의 집합 단위 PRF."""
p = {_norm(x) for x in predicted if x and x.strip()}
g = {_norm(x) for x in gold if x and x.strip()}
tp = len(p & g)
fp = len(p - g)
fn = len(g - p)
return _prf_from_counts(tp, fp, fn)
# 평가 대상 요소 유형 (genre 는 단일값이라 별도 처리)
_LIST_FIELDS = ("characters", "motifs", "keywords")
def evaluate_extraction(predictions: list[dict], golds: list[dict]) -> dict[str, dict]:
"""예측/정답 메타데이터 리스트 → 요소 유형별 + 마이크로 평균 F1.
predictions / golds 각 원소는 {"characters": [...], "motifs": [...],
"keywords": [...], "genre": "..."} 형식.
"""
if len(predictions) != len(golds):
raise ValueError("predictions/golds length mismatch")
per_field_counts = {f: [0, 0, 0] for f in _LIST_FIELDS} # tp, fp, fn
genre_tp = genre_total = 0
for pred, gold in zip(predictions, golds):
for f in _LIST_FIELDS:
r = set_prf(pred.get(f, []) or [], gold.get(f, []) or [])
per_field_counts[f][0] += r.tp
per_field_counts[f][1] += r.fp
per_field_counts[f][2] += r.fn
if gold.get("genre"):
genre_total += 1
if _norm(str(pred.get("genre") or "")) == _norm(str(gold["genre"])):
genre_tp += 1
result: dict[str, dict] = {}
micro = [0, 0, 0]
for f in _LIST_FIELDS:
tp, fp, fn = per_field_counts[f]
result[f] = _prf_from_counts(tp, fp, fn).as_dict()
micro[0] += tp
micro[1] += fp
micro[2] += fn
result["micro_avg"] = _prf_from_counts(*micro).as_dict()
result["genre_accuracy"] = {
"accuracy": round(genre_tp / genre_total, 4) if genre_total else None,
"correct": genre_tp, "total": genre_total,
}
return result