요약 성능지표(No.7, ROUGE 65점) 실험 일습을 커밋한다. - app/engine/summary_consensus.py: 후보 5개 생성 후 합의 선택(select_consensus). 문자/어절 2-gram 가중 합의로 고르며, 유효 후보가 없으면 valid=False 를 낸다. - app/engine/summary_grounded.py: 근거 추출 후 압축하는 2단계 생성. - summarizer.py: 추출 전략 3종(textrank/coverage/lead). coverage 는 MMR 로 중복 문장을 눌러 문서 전체를 넓게 담는다. 기본값은 textrank 로 유지한다. 스크립트는 생성·평가·검증을 분리했다. verify_* 는 API 호출 없이 저장된 산출물만 재계산하는 독립 검증기라 지표를 공용 함수로 합치지 않는다. 합치면 검증이 성립하지 않는다. tune_summarizer 는 사람 검수 참조(build_summary_annotation_packet -> export_summary_annotations)를 입력으로 받아 선택과 최종 보고를 분리한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 lines
7.1 KiB
Python
152 lines
7.1 KiB
Python
"""요약기 A/B 내부 벤치마크 — 추출 요약 vs LLM 추상 요약.
|
|
|
|
성능지표 #7 은 사람 참조 요약 대비 ROUGE recall 로 측정해야 한다. 아직 정답셋이
|
|
없어 본 평가는 할 수 없다. 이 스크립트는 그 전에 "LLM 훅을 켜면 수치가 오르는가"
|
|
만 판단하기 위한 내부 비교다.
|
|
|
|
참조 : 별도 모델(gpt-4o) 이 작성한 요약 — 은(silver) 기준
|
|
시스템 : ① TextRank 추출 요약 ② LLM 추상 요약
|
|
|
|
**편향 주의** — 참조가 LLM 산출물이므로 LLM 추상 요약 쪽에 유리하다. 두 방식의
|
|
상대 격차를 보는 용도이며, 이 수치를 성적서에 쓰면 안 된다.
|
|
|
|
입력은 전기(傳記) 본문을 쓴다. 전 차수 요약 시험과 같은 계열 자료이고 출판물이라
|
|
개인 자서전을 외부 API 로 보내지 않는다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import glob
|
|
import json
|
|
import random
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
def lemmas(text: str) -> list[str]:
|
|
"""형태소 원형 열. 조사·어미 변형을 흡수해 어절 단위보다 느슨하게 맞춘다."""
|
|
from app.engine.structural import extract_lemmas
|
|
return extract_lemmas(text)
|
|
|
|
|
|
def ngrams(tokens: list[str], n: int) -> Counter:
|
|
return Counter(tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
|
|
|
|
|
|
def rouge_recall(reference: str, hypothesis: str, n: int = 2) -> float:
|
|
"""계획서 수식 기준 — 분모가 참조 n-gram 수인 recall."""
|
|
ref = ngrams(reference.split(), n)
|
|
hyp = ngrams(hypothesis.split(), n)
|
|
total = sum(ref.values())
|
|
if not total:
|
|
return 0.0
|
|
overlap = sum(min(count, hyp.get(gram, 0)) for gram, count in ref.items())
|
|
return overlap / total
|
|
|
|
|
|
def load_passages(pattern: str, count: int, seed: int) -> list[str]:
|
|
passages = []
|
|
for path in sorted(glob.glob(pattern)):
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
for row in data.get("results", []):
|
|
text = (row.get("source_text") or "").strip()
|
|
if 400 <= len(text) <= 3000:
|
|
passages.append(text)
|
|
random.Random(seed).shuffle(passages)
|
|
return passages[:count]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--glob", default="/w/ai_publish/data_output4/*.json")
|
|
parser.add_argument("--count", type=int, default=30)
|
|
parser.add_argument("--seed", type=int, default=20260916)
|
|
parser.add_argument("--ref-model", default="gpt-4o")
|
|
parser.add_argument("--sys-model", default="gpt-4o-mini")
|
|
parser.add_argument("--multi-ref", type=int, default=1,
|
|
help="참조 개수. 2 이상이면 다중 참조 중 최고값으로 채점 (계획서 전제)")
|
|
parser.add_argument("--length-matched", action="store_true",
|
|
help="시스템 요약 길이를 참조 규격(원문의 25~35%)에 맞춘다")
|
|
parser.add_argument("--out", type=Path, default=Path("reports/summary_bench.json"))
|
|
args = parser.parse_args()
|
|
|
|
from openai import OpenAI
|
|
from app.core.config import get_settings
|
|
from app.engine.summarizer import Summarizer
|
|
|
|
get_settings.cache_clear()
|
|
settings = get_settings()
|
|
client = OpenAI(api_key=settings.openai_api_key)
|
|
summarizer = Summarizer(settings)
|
|
|
|
passages = load_passages(args.glob, args.count, args.seed)
|
|
print("본문 %d건 (전기)" % len(passages))
|
|
if not passages:
|
|
parser.error("본문을 찾지 못했습니다. --glob 확인")
|
|
|
|
def ask(model: str, prompt: str, text: str) -> str:
|
|
response = client.chat.completions.create(
|
|
model=model, temperature=0.2,
|
|
messages=[{"role": "system", "content": "당신은 한국어 요약 전문가입니다. 원문에 없는 사실을 만들지 마십시오."},
|
|
{"role": "user", "content": prompt + "\n\n" + text}])
|
|
return (response.choices[0].message.content or "").strip()
|
|
|
|
REF_PROMPT = ("다음 글을 한국어 줄글로 요약하십시오. 원문 분량의 25~35% 길이로, "
|
|
"핵심 인물·사건·시간·장소·인과를 보존하고 목록이나 제목은 쓰지 마십시오.")
|
|
# recall 은 참조를 얼마나 덮었는지를 재므로, 시스템 요약이 참조보다 지나치게
|
|
# 짧으면 구조적으로 손해를 본다. 길이 규격을 참조와 맞춘다.
|
|
SYS_PROMPT = (REF_PROMPT if args.length_matched else
|
|
"다음 글을 한국어 줄글로 간결하게 요약하십시오. 원문에 없는 내용을 넣지 마십시오.")
|
|
|
|
rows = []
|
|
for index, text in enumerate(passages, start=1):
|
|
# 다중 참조: 계획서가 전제하는 방식. 참조마다 채점해 최고값을 취한다.
|
|
references = [ask(args.ref_model, REF_PROMPT, text) for _ in range(args.multi_ref)]
|
|
extractive = summarizer.summarize(text, ratio=0.3, use_abstractive=False).final
|
|
abstractive = ask(args.sys_model, SYS_PROMPT, text)
|
|
row = {"index": index,
|
|
"reference_len": sum(len(r) for r in references) // len(references),
|
|
"system_len": len(abstractive)}
|
|
for label, system in (("extractive", extractive), ("abstractive", abstractive)):
|
|
for n in (1, 2):
|
|
row["%s_r%d" % (label, n)] = max(
|
|
rouge_recall(reference, system, n) for reference in references)
|
|
row["%s_r%d_lemma" % (label, n)] = max(
|
|
rouge_recall(" ".join(lemmas(reference)), " ".join(lemmas(system)), n)
|
|
for reference in references)
|
|
rows.append(row)
|
|
if index % 10 == 0:
|
|
print(" 진행 %d/%d" % (index, len(passages)))
|
|
|
|
def mean(key: str) -> float:
|
|
return sum(r[key] for r in rows) / len(rows)
|
|
|
|
print("\n" + "=" * 62)
|
|
print("ROUGE recall — 지표 정의별 (은 기준 참조 대비, 내부 비교용)")
|
|
print(" %-22s %-12s %-12s %s" % ("정의", "① 추출", "② LLM 추상", "차이"))
|
|
for label, key in (("ROUGE-1 (어절)", "r1"), ("ROUGE-2 (어절)", "r2"),
|
|
("ROUGE-1 (형태소)", "r1_lemma"), ("ROUGE-2 (형태소)", "r2_lemma")):
|
|
a, b = mean("extractive_" + key), mean("abstractive_" + key)
|
|
print(" %-22s %-12.4f %-12.4f %+.4f" % (label, a, b, b - a))
|
|
extractive_score = mean("extractive_r2")
|
|
abstractive_score = mean("abstractive_r2")
|
|
print("\n※ 참조가 LLM 산출물이라 ②에 유리한 편향이 있습니다. 성적서용 수치가 아닙니다.")
|
|
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
args.out.write_text(json.dumps({
|
|
"note": "내부 A/B. 참조는 %s 산출물(은 기준)이며 성적서용이 아님." % args.ref_model,
|
|
"count": len(rows), "ref_model": args.ref_model, "sys_model": args.sys_model,
|
|
"extractive": extractive_score, "abstractive": abstractive_score,
|
|
"rows": rows,
|
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print("%s 에 기록" % args.out)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|