#!/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())