컴북스 데이터 수령 전 가능한 작업을 선행 구현. 데이터 도착 즉시 학습·검증에 착수할 수 있도록 알고리즘·평가 하니스·문서를 준비. 알고리즘/파이프라인: - 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>
102 lines
3.4 KiB
Python
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
|