계획서 p.24 수식의 분모가 참조 n-gram 수이므로 지표는 recall 인데
eval_rouge.py 는 ROUGE-1 F1 을 목표 0.65 와 대조하고 있었다. 통상 시스템
요약이 참조보다 길면 recall > F1 이므로 우리에게 불리한 자체 기준으로
채점해 온 셈이다(내장 샘플에서 recall 0.5778 vs F1 0.5539).
같은 수식이 Σ_S∈{Reference Summaries} 로 다중 참조를 전제하는데 rouge_n /
rouge_l 은 참조를 문자열 하나만 받았다. 이제 문자열도 리스트도 받는다.
참조가 1개면 기존과 완전히 같은 값이 나오며 이를 테스트로 고정했다.
--iaa 모드를 추가했다. 사람 둘이 같은 글을 요약해도 ROUGE 는 100 이 안
나오고, 그 상한이 65 보다 낮으면 어떤 시스템도 목표를 달성할 수 없다.
정답셋 300건을 만들기 전에 파일럿 20건으로 상한을 먼저 재기 위한 것이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
5.2 KiB
Python
149 lines
5.2 KiB
Python
"""N-gram ROUGE 점수 (계획서 성능지표 No.7, p.24 수식).
|
|
|
|
계획서 평가수식:
|
|
ROUGE-N = Σ_S∈ref Σ_gram_n Count_match(gram_n) / Σ_S∈ref Σ_gram_n Count(gram_n)
|
|
|
|
즉 레퍼런스 n-gram 기준 재현율(recall) 형태. 본 모듈은 No.7 평가를 위해
|
|
ROUGE-1 / ROUGE-2 / ROUGE-L 을 자체 구현한다 (외부 라이브러리 의존 0).
|
|
|
|
토큰화는 두 가지 지원:
|
|
- "lemma": kiwi 형태소 기본형 (한국어 어미 변화에 강건, 권장)
|
|
- "char" : 공백/문자 기준 단순 토큰 (의존성 없이 동작)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections import Counter
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
|
|
|
|
def _char_tokens(text: str) -> list[str]:
|
|
return re.findall(r"[가-힣A-Za-z0-9]+", text.lower())
|
|
|
|
|
|
def tokenize(text: str, mode: str = "lemma") -> list[str]:
|
|
if mode == "lemma":
|
|
try:
|
|
from app.engine.structural import extract_lemmas
|
|
toks = extract_lemmas(text)
|
|
if toks:
|
|
return toks
|
|
except Exception:
|
|
pass
|
|
return _char_tokens(text)
|
|
|
|
|
|
def _ngrams(tokens: list[str], n: int) -> Counter:
|
|
if len(tokens) < n:
|
|
return Counter()
|
|
return Counter(tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1))
|
|
|
|
|
|
@dataclass
|
|
class RougeScore:
|
|
precision: float
|
|
recall: float
|
|
f1: float
|
|
|
|
def as_dict(self) -> dict[str, float]:
|
|
return {"precision": round(self.precision, 4), "recall": round(self.recall, 4), "f1": round(self.f1, 4)}
|
|
|
|
|
|
def _prf(match: int, sys_total: int, ref_total: int) -> RougeScore:
|
|
precision = match / sys_total if sys_total else 0.0
|
|
recall = match / ref_total if ref_total else 0.0
|
|
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
|
|
return RougeScore(precision, recall, f1)
|
|
|
|
|
|
def _as_references(reference: str | Sequence[str]) -> list[str]:
|
|
"""참조를 항상 리스트로 다룬다. 문자열 하나도 참조 1개짜리 리스트다."""
|
|
if isinstance(reference, str):
|
|
return [reference]
|
|
return [r for r in reference if r and r.strip()]
|
|
|
|
|
|
def rouge_n(
|
|
system: str, reference: str | Sequence[str], n: int = 1, mode: str = "lemma"
|
|
) -> RougeScore:
|
|
"""계획서 수식 그대로의 ROUGE-N. 참조는 1개도, 여러 개도 받는다.
|
|
|
|
분자 = Σ_S∈refs Σ_gram Count_match(gram)
|
|
분모 = Σ_S∈refs Σ_gram Count(gram)
|
|
|
|
참조가 1개면 기존 단일 참조 계산과 완전히 같은 값이 나온다.
|
|
"""
|
|
refs = _as_references(reference)
|
|
if not refs:
|
|
return RougeScore(0.0, 0.0, 0.0)
|
|
sys_g = _ngrams(tokenize(system, mode), n)
|
|
sys_total = sum(sys_g.values())
|
|
match = ref_total = 0
|
|
for ref in refs:
|
|
ref_g = _ngrams(tokenize(ref, mode), n)
|
|
match += sum((sys_g & ref_g).values())
|
|
ref_total += sum(ref_g.values())
|
|
# 분모의 recall 은 계획서 수식 그대로다. precision 은 수식에 정의가 없어
|
|
# 참조 수만큼 시스템 n-gram 을 세는 관행(pooled)을 따르며, f1 은 그 파생값이다.
|
|
# **보고 대상은 recall** 이다.
|
|
return _prf(match, sys_total * len(refs), ref_total)
|
|
|
|
|
|
def _lcs_length(a: list[str], b: list[str]) -> int:
|
|
if not a or not b:
|
|
return 0
|
|
prev = [0] * (len(b) + 1)
|
|
for x in a:
|
|
cur = [0] * (len(b) + 1)
|
|
for j, y in enumerate(b, 1):
|
|
cur[j] = prev[j - 1] + 1 if x == y else max(prev[j], cur[j - 1])
|
|
prev = cur
|
|
return prev[-1]
|
|
|
|
|
|
def rouge_l(
|
|
system: str, reference: str | Sequence[str], mode: str = "lemma"
|
|
) -> RougeScore:
|
|
"""ROUGE-L. ROUGE-N 과 같은 방식으로 참조를 합산한다."""
|
|
refs = _as_references(reference)
|
|
if not refs:
|
|
return RougeScore(0.0, 0.0, 0.0)
|
|
s_tok = tokenize(system, mode)
|
|
lcs = ref_total = 0
|
|
for ref in refs:
|
|
r_tok = tokenize(ref, mode)
|
|
lcs += _lcs_length(s_tok, r_tok)
|
|
ref_total += len(r_tok)
|
|
return _prf(lcs, len(s_tok) * len(refs), ref_total)
|
|
|
|
|
|
def evaluate_pairs(
|
|
pairs: list[tuple[str, str | Sequence[str]]],
|
|
mode: str = "lemma",
|
|
) -> dict[str, dict[str, float]]:
|
|
"""(system, reference) 페어 리스트 → 코퍼스 평균 ROUGE-1/2/L.
|
|
|
|
reference 자리에 문자열 하나 또는 참조 여러 개의 리스트를 넣을 수 있다.
|
|
계획서 No.2-1년차 목표: N-gram ROUGE 65점 (gpt-4o 줄글 요약 64 대비).
|
|
**목표와 대조하는 값은 recall** 이다(계획서 수식의 분모가 참조 n-gram 수).
|
|
"""
|
|
if not pairs:
|
|
return {}
|
|
acc = {"rouge1": [], "rouge2": [], "rougeL": []}
|
|
for system, reference in pairs:
|
|
acc["rouge1"].append(rouge_n(system, reference, 1, mode))
|
|
acc["rouge2"].append(rouge_n(system, reference, 2, mode))
|
|
acc["rougeL"].append(rouge_l(system, reference, mode))
|
|
|
|
def avg(scores: list[RougeScore]) -> dict[str, float]:
|
|
k = len(scores)
|
|
return {
|
|
"precision": round(sum(s.precision for s in scores) / k, 4),
|
|
"recall": round(sum(s.recall for s in scores) / k, 4),
|
|
"f1": round(sum(s.f1 for s in scores) / k, 4),
|
|
}
|
|
|
|
return {metric: avg(scores) for metric, scores in acc.items()}
|