o2o-plagiarism-ai/scripts/tune_summarizer.py
hbyang 8ae52b0a82 feat: add summary (No.7) consensus and grounded generation trials
요약 성능지표(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>
2026-09-28 10:02:04 +09:00

92 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""사람 참조 요약으로 추출 전략을 고르고, 잠금 테스트셋에서 한 번만 측정한다.
선택(검증)과 최종 보고(테스트)를 분리해, 65점에 맞춰 전 평가 요약문을 수정하는
전 차수식 접근을 방지한다. source_group 단위로 분리하므로 같은 작성자의 문장이
튜닝·시험 양쪽에 섞이지 않는다.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.engine.rouge import evaluate_pairs
from app.engine.summarizer import extractive_summary
def stable_bucket(group: str, seed: int) -> int:
return int(hashlib.sha256((str(seed) + ":" + group).encode()).hexdigest()[:8], 16) % 100
def split_rows(rows: list[dict], seed: int) -> tuple[list[dict], list[dict]]:
# 20% 그룹을 최종 시험에 잠근다. 나머지에서만 전략을 고른다.
test = [row for row in rows if stable_bucket(row.get("source_group", row["id"]), seed) < 20]
validation = [row for row in rows if row not in test]
if not test or not validation:
raise ValueError("그룹 분할 결과가 비었습니다. source_group을 확인하세요.")
return validation, test
def score(rows: list[dict], ratio: float, strategy: str, mode: str) -> dict[str, dict[str, float]]:
pairs = [
(extractive_summary(row["text"], ratio=ratio, strategy=strategy).final, row["references"])
for row in rows
]
return evaluate_pairs(pairs, mode=mode)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("dataset", type=Path, help="export_summary_annotations.py 출력 JSONL")
parser.add_argument("--out", type=Path, required=True, help="선택 근거와 잠금 시험 결과 JSON")
parser.add_argument("--seed", type=int, default=20260916)
parser.add_argument("--mode", choices=["lemma", "char"], default="lemma")
parser.add_argument("--ratios", type=float, nargs="+", default=[0.25, 0.30, 0.35],
help="사전 등록 가능한 요약 비율 후보(권장 25~35%%)")
args = parser.parse_args()
if any(not 0 < ratio <= 0.35 for ratio in args.ratios):
parser.error("요약 비율은 0보다 크고 0.35 이하여야 합니다")
rows = [json.loads(line) for line in args.dataset.read_text(encoding="utf-8").splitlines() if line.strip()]
if len(rows) < 20:
parser.error("최소 20건 이상의 완료된 사람 참조 요약이 필요합니다")
validation, test = split_rows(rows, args.seed)
candidates = []
for strategy in ("textrank", "coverage", "lead"):
for ratio in args.ratios:
metrics = score(validation, ratio, strategy, args.mode)
candidates.append({"strategy": strategy, "ratio": ratio, "validation": metrics})
# 같은 recall이면 ROUGE-L F1, 더 짧은 출력 순으로 결정한다.
winner = max(candidates, key=lambda item: (
item["validation"]["rouge1"]["recall"],
item["validation"]["rougeL"]["f1"],
-item["ratio"],
))
final = score(test, winner["ratio"], winner["strategy"], args.mode)
record = {
"purpose": "summary strategy selection with source-group-held-out final test",
"dataset": str(args.dataset), "seed": args.seed, "tokenization": args.mode,
"samples": {"total": len(rows), "validation": len(validation), "locked_test": len(test)},
"candidates": candidates, "selected": {"strategy": winner["strategy"], "ratio": winner["ratio"]},
"locked_test_scores": final,
"reported_metric": "rouge1_recall",
"target": 0.65,
"achieved": final["rouge1"]["recall"] >= 0.65,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("선택: %s / ratio=%.2f" % (winner["strategy"], winner["ratio"]))
print("잠금 테스트 ROUGE-1 recall: %.4f (%s)" %
(final["rouge1"]["recall"], "달성" if record["achieved"] else "미달"))
print("근거 저장: %s" % args.out)
return 0
if __name__ == "__main__":
raise SystemExit(main())