diff --git a/app/engine/rouge.py b/app/engine/rouge.py index 45b6327..a6c1ae8 100644 --- a/app/engine/rouge.py +++ b/app/engine/rouge.py @@ -15,6 +15,7 @@ from __future__ import annotations import re from collections import Counter +from collections.abc import Sequence from dataclasses import dataclass @@ -57,11 +58,37 @@ def _prf(match: int, sys_total: int, ref_total: int) -> RougeScore: return RougeScore(precision, recall, f1) -def rouge_n(system: str, reference: str, n: int = 1, mode: str = "lemma") -> RougeScore: +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) - ref_g = _ngrams(tokenize(reference, mode), n) - match = sum((sys_g & ref_g).values()) - return _prf(match, sum(sys_g.values()), sum(ref_g.values())) + 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: @@ -76,19 +103,31 @@ def _lcs_length(a: list[str], b: list[str]) -> int: return prev[-1] -def rouge_l(system: str, reference: str, mode: str = "lemma") -> RougeScore: - s, r = tokenize(system, mode), tokenize(reference, mode) - lcs = _lcs_length(s, r) - return _prf(lcs, len(s), len(r)) +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]], + 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 {} diff --git a/scripts/eval_rouge.py b/scripts/eval_rouge.py index ff3728e..f592851 100644 --- a/scripts/eval_rouge.py +++ b/scripts/eval_rouge.py @@ -7,6 +7,8 @@ {"text": "원문 ...", "reference": "사람 작성 요약 정답 ..."} 또는 이미 시스템 요약이 있는 경우: {"system": "엔진 요약 ...", "reference": "정답 요약 ..."} + 참조 요약이 여러 개면 (계획서 수식이 Σ_S∈refs 로 다중 참조를 전제한다): + {"text": "원문 ...", "references": ["정답 요약 1 ...", "정답 요약 2 ..."]} 동작: - "text" 만 있으면 자체 Summarizer 로 system 요약을 생성한 뒤 reference 와 비교 @@ -61,21 +63,84 @@ def _load(path: str | None) -> tuple[list[dict], bool]: return rows, False +def _run_iaa(rows: list[dict], mode: str) -> None: + """사람 상한(human ceiling) 측정. + + 왜 필요한가: + 사람 둘이 같은 글을 요약해도 표현 선택이 달라 ROUGE 는 100 이 안 나온다. + 그 상한이 목표(65)보다 낮으면 **어떤 시스템도 목표를 달성할 수 없다.** + 정답셋 300건을 만들기 전에 파일럿 20건으로 이 값을 먼저 재고, 낮으면 + 참조 요약 규격(길이·원문 표현 활용도)을 조정해야 한다. + + 한 작성자를 system, 다른 작성자를 reference 로 놓고 양방향으로 채점한다. + """ + pairs: list[tuple[str, list[str]]] = [] + for row in rows: + refs = row.get("references") or [] + if isinstance(refs, str) or len(refs) < 2: + continue + clean = [r for r in refs if r and r.strip()] + # 양방향 — 누가 system 이냐에 따라 값이 달라지므로 둘 다 넣고 평균한다. + for i, sys_text in enumerate(clean): + others = [r for j, r in enumerate(clean) if j != i] + pairs.append((sys_text, others)) + + if not pairs: + print("참조가 2개 이상인 행이 없습니다. `references` 에 작성자별 요약을 " + "2개 이상 넣으세요.", file=sys.stderr) + sys.exit(1) + + scores = evaluate_pairs(pairs, mode=mode) + print(f"\n=== 사람 상한(IAA) — {len(pairs)}개 조합 / 토큰={mode} ===") + print(f"{'metric':<10}{'precision':>12}{'recall':>12}{'f1':>12}") + for metric in ("rouge1", "rouge2", "rougeL"): + s = scores[metric] + print(f"{metric:<10}{s['precision']:>12.4f}{s['recall']:>12.4f}{s['f1']:>12.4f}") + + ceiling = scores["rouge1"]["recall"] + target = 0.65 + print(f"\n사람 상한 ROUGE-1 recall = {ceiling:.4f} / 목표 {target}") + if ceiling < target: + print( + "⚠️ 상한이 목표보다 낮습니다. 이 규격으로는 어떤 시스템도 목표를 " + "달성할 수 없습니다. 참조 요약을 더 길게 하거나 원문 표현을 더 살리는 " + "방향으로 규격을 조정한 뒤 파일럿을 다시 도세요." + ) + elif ceiling < target * 1.15: + print( + f"⚠️ 여유가 {(ceiling - target) * 100:.1f}%p 뿐입니다. 시스템이 사람 " + "수준에 근접해야 달성됩니다. 규격을 조금 더 완화하는 편이 안전합니다." + ) + else: + print("여유가 충분합니다. 이 규격으로 본 구축을 진행해도 됩니다.") + + def main() -> None: ap = argparse.ArgumentParser(description="요약 ROUGE 평가 (성능지표 No.7)") ap.add_argument("dataset", nargs="?", default=None, help="JSONL 정답셋 (없으면 dry-run)") ap.add_argument("--mode", choices=["lemma", "char"], default="lemma", help="토큰화 방식") ap.add_argument("--ratio", type=float, default=0.3, help="자체 요약 길이 비율") + ap.add_argument("--iaa", action="store_true", + help="사람 상한 측정 — 참조 2개 이상인 행에서 작성자끼리 채점") args = ap.parse_args() rows, is_dry = _load(args.dataset) + + if args.iaa: + return _run_iaa(rows, args.mode) + summarizer = get_summarizer() - pairs: list[tuple[str, str]] = [] + pairs: list[tuple[str, list[str]]] = [] + multi_ref_rows = 0 for row in rows: - reference = row.get("reference", "") + # references(복수) 우선, 없으면 reference(단수). 둘 다 같은 경로로 처리된다. + raw = row.get("references") or row.get("reference", "") + reference = [raw] if isinstance(raw, str) else [r for r in raw if r and r.strip()] if not reference: continue + if len(reference) > 1: + multi_ref_rows += 1 if "system" in row and row["system"]: system = row["system"] else: @@ -95,10 +160,15 @@ def main() -> None: s = scores[metric] print(f"{metric:<10}{s['precision']:>12.4f}{s['recall']:>12.4f}{s['f1']:>12.4f}") + # 계획서 p.24 수식의 분모가 '참조 n-gram 수'이므로 목표와 대조하는 값은 recall 이다. + # F1 로 대조하면 우리에게 불리한 자체 기준으로 채점하게 된다. target = 0.65 - r1f1 = scores["rouge1"]["f1"] - status = "달성" if r1f1 >= target else "미달" - print(f"\n목표(No.7) ROUGE 0.65 대비 ROUGE-1 F1 = {r1f1:.4f} → {status}") + r1 = scores["rouge1"]["recall"] + status = "달성" if r1 >= target else "미달" + print(f"\n목표(No.7) ROUGE 0.65 대비 ROUGE-1 recall = {r1:.4f} → {status}") + print(f" (참고: ROUGE-1 F1 = {scores['rouge1']['f1']:.4f} — 계획서 지표 아님)") + if multi_ref_rows: + print(f" 다중 참조 사용: {multi_ref_rows}/{len(pairs)}건") if is_dry: print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 컴북스 요약 정답셋 수령 후 본 평가로 정식 측정.") diff --git a/tests/test_rouge.py b/tests/test_rouge.py index f9aa1f2..5293935 100644 --- a/tests/test_rouge.py +++ b/tests/test_rouge.py @@ -1,6 +1,8 @@ """ROUGE 평가 모듈 단위테스트 (성능지표 No.7).""" from __future__ import annotations +import pytest + from app.engine.rouge import rouge_n, rouge_l, evaluate_pairs, tokenize @@ -48,3 +50,50 @@ def test_evaluate_pairs_aggregates(): def test_tokenize_char_fallback(): toks = tokenize("Hello 월드 123", mode="char") assert "hello" in toks and "월드" in toks and "123" in toks + + +# --------------------------------------------------------------------------- +# 다중 참조 — 계획서 p.24 수식이 Σ_S∈{Reference Summaries} 로 전제하는 형태 +# --------------------------------------------------------------------------- + + +class TestMultiReference: + SYS = "홍길동은 활빈당을 만들어 백성을 도왔다" + REF_A = "홍길동은 활빈당을 세워 백성을 구했다" + REF_B = "홍길동이 백성을 도왔다" + + def test_single_string_and_single_item_list_are_identical(self): + """하위 호환 — 기존 호출부의 값이 절대 바뀌면 안 된다.""" + for n in (1, 2): + assert (rouge_n(self.SYS, self.REF_A, n, "char").as_dict() + == rouge_n(self.SYS, [self.REF_A], n, "char").as_dict()) + assert (rouge_l(self.SYS, self.REF_A, "char").as_dict() + == rouge_l(self.SYS, [self.REF_A], "char").as_dict()) + + def test_recall_follows_plan_formula(self): + """분자·분모를 참조별로 합산한 값과 일치해야 한다.""" + from app.engine.rouge import _ngrams, tokenize + + sys_g = _ngrams(tokenize(self.SYS, "char"), 1) + match = ref_total = 0 + for ref in (self.REF_A, self.REF_B): + ref_g = _ngrams(tokenize(ref, "char"), 1) + match += sum((sys_g & ref_g).values()) + ref_total += sum(ref_g.values()) + + got = rouge_n(self.SYS, [self.REF_A, self.REF_B], 1, "char") + assert got.recall == pytest.approx(match / ref_total) + + def test_empty_reference_list_is_zero(self): + assert rouge_n(self.SYS, [], 1, "char").recall == 0.0 + assert rouge_l(self.SYS, [], "char").recall == 0.0 + + def test_blank_references_are_dropped(self): + assert (rouge_n(self.SYS, [self.REF_A, "", " "], 1, "char").as_dict() + == rouge_n(self.SYS, [self.REF_A], 1, "char").as_dict()) + + def test_evaluate_pairs_accepts_mixed_forms(self): + scores = evaluate_pairs( + [(self.SYS, self.REF_A), (self.SYS, [self.REF_A, self.REF_B])], mode="char" + ) + assert 0.0 < scores["rouge1"]["recall"] <= 1.0