fix: 요약 ROUGE를 계획서 수식에 맞춤 (F1 → recall, 다중 참조)
계획서 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>
This commit is contained in:
parent
dc6c0f5d0f
commit
3da7a0a5f2
@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@ -57,11 +58,37 @@ def _prf(match: int, sys_total: int, ref_total: int) -> RougeScore:
|
|||||||
return RougeScore(precision, recall, f1)
|
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)
|
sys_g = _ngrams(tokenize(system, mode), n)
|
||||||
ref_g = _ngrams(tokenize(reference, mode), n)
|
sys_total = sum(sys_g.values())
|
||||||
match = sum((sys_g & ref_g).values())
|
match = ref_total = 0
|
||||||
return _prf(match, sum(sys_g.values()), sum(ref_g.values()))
|
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:
|
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]
|
return prev[-1]
|
||||||
|
|
||||||
|
|
||||||
def rouge_l(system: str, reference: str, mode: str = "lemma") -> RougeScore:
|
def rouge_l(
|
||||||
s, r = tokenize(system, mode), tokenize(reference, mode)
|
system: str, reference: str | Sequence[str], mode: str = "lemma"
|
||||||
lcs = _lcs_length(s, r)
|
) -> RougeScore:
|
||||||
return _prf(lcs, len(s), len(r))
|
"""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(
|
def evaluate_pairs(
|
||||||
pairs: list[tuple[str, str]],
|
pairs: list[tuple[str, str | Sequence[str]]],
|
||||||
mode: str = "lemma",
|
mode: str = "lemma",
|
||||||
) -> dict[str, dict[str, float]]:
|
) -> dict[str, dict[str, float]]:
|
||||||
"""(system, reference) 페어 리스트 → 코퍼스 평균 ROUGE-1/2/L.
|
"""(system, reference) 페어 리스트 → 코퍼스 평균 ROUGE-1/2/L.
|
||||||
|
|
||||||
|
reference 자리에 문자열 하나 또는 참조 여러 개의 리스트를 넣을 수 있다.
|
||||||
계획서 No.2-1년차 목표: N-gram ROUGE 65점 (gpt-4o 줄글 요약 64 대비).
|
계획서 No.2-1년차 목표: N-gram ROUGE 65점 (gpt-4o 줄글 요약 64 대비).
|
||||||
|
**목표와 대조하는 값은 recall** 이다(계획서 수식의 분모가 참조 n-gram 수).
|
||||||
"""
|
"""
|
||||||
if not pairs:
|
if not pairs:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
{"text": "원문 ...", "reference": "사람 작성 요약 정답 ..."}
|
{"text": "원문 ...", "reference": "사람 작성 요약 정답 ..."}
|
||||||
또는 이미 시스템 요약이 있는 경우:
|
또는 이미 시스템 요약이 있는 경우:
|
||||||
{"system": "엔진 요약 ...", "reference": "정답 요약 ..."}
|
{"system": "엔진 요약 ...", "reference": "정답 요약 ..."}
|
||||||
|
참조 요약이 여러 개면 (계획서 수식이 Σ_S∈refs 로 다중 참조를 전제한다):
|
||||||
|
{"text": "원문 ...", "references": ["정답 요약 1 ...", "정답 요약 2 ..."]}
|
||||||
|
|
||||||
동작:
|
동작:
|
||||||
- "text" 만 있으면 자체 Summarizer 로 system 요약을 생성한 뒤 reference 와 비교
|
- "text" 만 있으면 자체 Summarizer 로 system 요약을 생성한 뒤 reference 와 비교
|
||||||
@ -61,21 +63,84 @@ def _load(path: str | None) -> tuple[list[dict], bool]:
|
|||||||
return rows, False
|
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:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description="요약 ROUGE 평가 (성능지표 No.7)")
|
ap = argparse.ArgumentParser(description="요약 ROUGE 평가 (성능지표 No.7)")
|
||||||
ap.add_argument("dataset", nargs="?", default=None, help="JSONL 정답셋 (없으면 dry-run)")
|
ap.add_argument("dataset", nargs="?", default=None, help="JSONL 정답셋 (없으면 dry-run)")
|
||||||
ap.add_argument("--mode", choices=["lemma", "char"], default="lemma", help="토큰화 방식")
|
ap.add_argument("--mode", choices=["lemma", "char"], default="lemma", help="토큰화 방식")
|
||||||
ap.add_argument("--ratio", type=float, default=0.3, help="자체 요약 길이 비율")
|
ap.add_argument("--ratio", type=float, default=0.3, help="자체 요약 길이 비율")
|
||||||
|
ap.add_argument("--iaa", action="store_true",
|
||||||
|
help="사람 상한 측정 — 참조 2개 이상인 행에서 작성자끼리 채점")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
rows, is_dry = _load(args.dataset)
|
rows, is_dry = _load(args.dataset)
|
||||||
|
|
||||||
|
if args.iaa:
|
||||||
|
return _run_iaa(rows, args.mode)
|
||||||
|
|
||||||
summarizer = get_summarizer()
|
summarizer = get_summarizer()
|
||||||
|
|
||||||
pairs: list[tuple[str, str]] = []
|
pairs: list[tuple[str, list[str]]] = []
|
||||||
|
multi_ref_rows = 0
|
||||||
for row in rows:
|
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:
|
if not reference:
|
||||||
continue
|
continue
|
||||||
|
if len(reference) > 1:
|
||||||
|
multi_ref_rows += 1
|
||||||
if "system" in row and row["system"]:
|
if "system" in row and row["system"]:
|
||||||
system = row["system"]
|
system = row["system"]
|
||||||
else:
|
else:
|
||||||
@ -95,10 +160,15 @@ def main() -> None:
|
|||||||
s = scores[metric]
|
s = scores[metric]
|
||||||
print(f"{metric:<10}{s['precision']:>12.4f}{s['recall']:>12.4f}{s['f1']:>12.4f}")
|
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
|
target = 0.65
|
||||||
r1f1 = scores["rouge1"]["f1"]
|
r1 = scores["rouge1"]["recall"]
|
||||||
status = "달성" if r1f1 >= target else "미달"
|
status = "달성" if r1 >= target else "미달"
|
||||||
print(f"\n목표(No.7) ROUGE 0.65 대비 ROUGE-1 F1 = {r1f1:.4f} → {status}")
|
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:
|
if is_dry:
|
||||||
print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 컴북스 요약 정답셋 수령 후 본 평가로 정식 측정.")
|
print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 컴북스 요약 정답셋 수령 후 본 평가로 정식 측정.")
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
"""ROUGE 평가 모듈 단위테스트 (성능지표 No.7)."""
|
"""ROUGE 평가 모듈 단위테스트 (성능지표 No.7)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.engine.rouge import rouge_n, rouge_l, evaluate_pairs, tokenize
|
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():
|
def test_tokenize_char_fallback():
|
||||||
toks = tokenize("Hello 월드 123", mode="char")
|
toks = tokenize("Hello 월드 123", mode="char")
|
||||||
assert "hello" in toks and "월드" in toks and "123" in toks
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user