diff --git a/docs/TEST_PLAN_2026_PHASE2.md b/docs/TEST_PLAN_2026_PHASE2.md index cfe409d..04a3ed7 100644 --- a/docs/TEST_PLAN_2026_PHASE2.md +++ b/docs/TEST_PLAN_2026_PHASE2.md @@ -3,7 +3,7 @@ > 작성: 에이아이오투오 / 2026-09-16 > 서식: 구미전자정보기술원 `성능평가확인서_출판과제_1029` 동일 구성 > 선행 시험: `GERIR.CE.2511-02.009` (2025-11-19, 3개 항목 PASS) -> **범위: 요약 성능(성능지표 #7) 제외 — 2개 항목** +> **범위: 3개 항목.** 요약 성능(#7)은 정답셋 미구축으로 서식과 절차만 확정한 상태다. > > 성적서 서식 그대로 옮겨 적을 수 있도록 구성했다. 각 절의 번호·표 구성은 > 원본 서식과 일치하며, 수치와 데이터만 2-1년차 측정값으로 교체하였다. @@ -18,7 +18,7 @@ | 주소 | (13453) 경기도 성남시 수정구 금토로 32 ㈜KT 판교빌딩 East 504호 ~ 505호 | | 시험품 | 출판콘텐츠 분석 및 공유 기술용 AI 모델 | | 성적서 용도 | 출판환경 변화 대응을 위한 생성형 AI 기반 출판 콘텐츠 분석 및 공유 플랫폼 기술 개발과제 성능 평가 | -| 시험 항목 | **2개 항목** | +| 시험 항목 | **3개 항목** | | 시험 장소 | 에이아이오투오 판교 연구소 | | 시험 결과 | 붙임(시험결과) 참조 | @@ -47,7 +47,7 @@ ### 2.1 시험 규격 - **2.1.1 시험규격** : 시험절차서 기준 -- **2.1.2 시험항목** : 시험절차서 **2개 항목** +- **2.1.2 시험항목** : 시험절차서 **3개 항목** ### 2.2 시험 장비 정보 @@ -70,7 +70,7 @@ | 구분 | 시스템 사양 | 운영 SW 및 시험도구 | 구현 모듈 | |---|---|---|---| | 시험 PC | CPU : Apple M3 pro
RAM : 18GB | SSH 터미널 | 없음 | -| GPU서버 | GPU : RTX 3090 24G × 2
CPU : Intel i9-12900KS (24core)
RAM : 125GB | Ubuntu 24.0.2
Pytorch 2.8
Python 3.9
Cuda 12.2 | 메타 데이터 추출 모듈,
표절 여부 판별 모듈 | +| GPU서버 | GPU : RTX 3090 24G × 2
CPU : Intel i9-12900KS (24core)
RAM : 125GB | Ubuntu 24.0.2
Pytorch 2.8
Python 3.9
Cuda 12.2 | 메타 데이터 추출 모듈,
표절 여부 판별 모듈,
요약 모듈 | #### 2.2.3 평가방법 @@ -78,6 +78,7 @@ |---|---|---|---| | 1 | 메타 데이터 추출
(F1 – score) | KLUE 데이터셋의 NER 데이터셋을 활용하여, 21,008개의 학습 데이터로 개발된 모델을 학습시키고, 5,000개의 테스트 데이터를 활용하여 개발된 모델에 대한 상대 평가 진행 | 과제 성능지표 3번에 해당 | | 2 | 표절 여부 판별 정밀도
(precision) | 자체 제작된 실제 표절 글과, 표절이 아닌 글을 Classification하여, precision 계산
(시험 데이터 1,000건 = 표절 500건 + 비표절 500건. 모델이 학습하지 않은 작성자의 자료로 구성) | 과제 성능지표 4번에 해당 | +| 3 | 요약 성능
(N-gram ROUGE score) | 자체 제작된 요약 데이터셋 300건을, 요약 모델에 테스트하여 N-gram ROUGE score(ROUGE-1 recall) 계산 | 과제 성능지표 7번에 해당 | --- @@ -89,6 +90,7 @@ |---|---|---|---| | 메타 데이터 추출(F1-score) | 83% | **83.77** | 83.77% 달성 | | 표절 여부 판별 정밀도(Precision) | 97% | **98.40** | 98.40% 달성 | +| 요약 성능 (N-gram ROUGE score) | 65% | (미측정) | 정답셋 구축 후 측정 | ### 3.2 항목별 시험결과 @@ -590,6 +592,174 @@ f1 = 2 * precision * recall / (precision + recall) if (precision + recall 실제 운영 조건을 반영하고 있음을 확인하였다. 완전복제·문장재배열·축약은 미탐지 0건이며, 어휘치환 150건 중 6건만 놓쳤다. +--- + +## 3.2.3 요약 성능 + +> **미측정 항목** — 요약 정답셋이 구축되지 않아 결과를 채울 수 없다. +> 아래는 서식과 시현 절차를 확정해 둔 것이며, 정답셋 수령 후 같은 명령으로 +> 수치가 채워진다. 접수 전 결정이 필요한 사항은 3.2.3.4 항에 정리했다. + +### 3.2.3.1 시험방법 + +``` +① 자체 제작된 요약 데이터셋 준비 +② 요약 모델에 학습되지 않은 데이터 300건에 대해 모델 요약 진행 +③ 생성된 결과물에 대한 N-gram ROUGE score(ROUGE-1 recall) 측정 +``` + +> **전 차수 대비 수정 두 곳** +> - ② **1,000건 → 300건**. 사람이 작성한 요약 정답이 필요한 항목이라 1,000건은 +> 구축 비용이 과다하다. 통계적 신뢰구간을 확보하면서 현실적인 규모로 300건을 잡는다. +> - ③ **지표 정의 명시**. 계획서 p.24 수식의 분모가 참조 n-gram 수이므로 +> 해당 지표는 **recall** 이다. 전 차수 코드는 f1 을 반환했다. 어느 값을 +> 보고하는지 성적서에 남긴다. + +### 3.2.3.2 시험결과 + +| 시험항목 | 목표치 | 결과 | 비고 | +|---|---|---|---| +| 요약 성능 (N-gram ROUGE score) | 65% | (미측정) | 정답셋 구축 후 측정 | + +### 3.2.3.3 성능 평가 과정 + +#### 1. 데이터 준비 + +```bash +docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \ + python scripts/show_summary_samples.py data/eval/summary.jsonl \ + --head 2 --tail 2 --max-chars 300 --with-reference +``` + +출력 형식: + +```json +1. 데이터 준비 + { + "index": 1, + "original": "(원문 …)", + "reference": "(사람 작성 요약 정답 …)", + }, + { + "index": 2, + "original": "(원문 …)", + "reference": "(사람 작성 요약 정답 …)", + }, +... + { + "index": 300, + "original": "(원문 …)", + "reference": "(사람 작성 요약 정답 …)", + }, + +총 데이터 300건 +``` + +#### 2. 요약 생성 + +```python +# scripts/eval_rouge.py — system 요약이 없으면 자체 Summarizer 로 생성한다. +summarizer = get_summarizer() + +pairs = [] +for row in rows: + # 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 "system" in row and row["system"]: + system = row["system"] + else: + system = summarizer.summarize(row.get("text", ""), ratio=args.ratio).final + pairs.append((system, reference)) +``` + +#### 3. n-gram ROUGE 스코어 측정 + +```python +# app/engine/rouge.py +def _prf(match: int, sys_total: int, ref_total: int) -> RougeScore: + precision = match / sys_total if sys_total else 0.0 + recall = match / ref_total if ref_total else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 + return RougeScore(precision, recall, f1) + + +def evaluate_pairs(pairs, mode: str = "lemma") -> dict: + """(system, reference) 페어 → 코퍼스 평균 ROUGE-1/2/L. + + **목표와 대조하는 값은 recall** 이다(계획서 수식의 분모가 참조 n-gram 수). + """ + acc = {"rouge1": [], "rouge2": [], "rougeL": []} + for system, reference in pairs: + acc["rouge1"].append(rouge_n(system, reference, 1, mode)) + acc["rouge2"].append(rouge_n(system, reference, 2, mode)) + acc["rougeL"].append(rouge_l(system, reference, mode)) + ... +``` + +실행: + +```bash +docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \ + python scripts/eval_rouge.py data/eval/summary.jsonl \ + --out data/eval/summary_scorecard.json +``` + +#### 4. 결과 + +```json +{ + "summary_performance": { + "model": "O2O Summarizer", + "test_dataset": { + "total_samples": 300, + "multi_reference_samples": 0, + "source": "data/eval/summary.jsonl" + }, + "rouge_scores": { + "rouge1": { "precision": 0.0, "recall": 0.0, "f1": 0.0 }, + "rouge2": { "precision": 0.0, "recall": 0.0, "f1": 0.0 }, + "rougeL": { "precision": 0.0, "recall": 0.0, "f1": 0.0 } + }, + "reported_metric": "rouge1_recall", + "performance_metrics": { + "n_gram_rouge_score": 0.0, + "target": 0.65, + "achieved": false + }, + "settings": { "tokenization": "lemma", "summary_ratio": 0.3 }, + "interpretation": { + "n_gram_rouge_score": "참조 요약의 1-gram 중 0.0% 를 시스템 요약이 담아냄", + "note": "계획서 수식 분모가 참조 n-gram 수이므로 recall 을 보고한다." + } + } +} +``` + +``` +최종 결과 : 평균 수치 __% +``` + +> 위 JSON 은 **형식 예시**이며 수치는 전부 자리표시자다. 정답셋 투입 후 +> 실제 값으로 대체한다. + +### 3.2.3.4 접수 전 결정 필요 사항 + +| 항목 | 내용 | +|---|---| +| **정답셋 미구축** | 파일럿 20건·본 구축 300건 모두 미완. 이 항목의 유일한 선행 조건이다 | +| **사람 상한 확인** | 본 구축 전 파일럿 20건으로 사람 간 일치도(IAA)를 측정해 상한을 먼저 본다(`eval_rouge.py --iaa`). 상한이 65 미만이면 300건 구축 전에 규격 조정을 협의해야 한다 | +| **건수 변경** | 계획서·전 차수는 1,000건. 300건으로 줄이려면 시험기관 협의 필요 | +| **지표 정의** | 계획서는 recall 수식, 전 차수 코드는 f1 반환. 정의를 확정해야 한다 | +| **전 차수 방식 승계 불가** | 전 차수 로그에 `optimal_60_modification` / `success_rate 0.15` 기록이 있다. 목표치에 맞춰 요약문을 수정한 것으로 보여 그대로 승계하기 어렵다 | +| **시험 환경** | 계획서가 No.7 에 A100 을 명시. 본 서버(RTX 3090)로 가능한지 별도 협의 필요 | + +> **내부 A/B 벤치는 성적서용이 아니다** — `reports/summary_bench.json`, +> `summary_bench_v2.json` 은 참조가 gpt-4o 산출물(은 기준)이고 30건 규모다. +> 파일 자체에 "성적서용이 아님" 이 기록돼 있다. 사람 작성 정답셋과는 다르다. + --- --- diff --git a/scripts/eval_rouge.py b/scripts/eval_rouge.py index f592851..5ec7bc2 100644 --- a/scripts/eval_rouge.py +++ b/scripts/eval_rouge.py @@ -115,11 +115,53 @@ def _run_iaa(rows: list[dict], mode: str) -> None: print("여유가 충분합니다. 이 규격으로 본 구축을 진행해도 됩니다.") +def build_scorecard(scores: dict, count: int, mode: str, ratio: float, + multi_ref_rows: int, dataset: str | None) -> dict: + """성적서 "4. 결과" 게재 형식으로 정리한다. + + 보고 대상은 **ROUGE-1 recall** 이다. 계획서 p.24 수식의 분모가 참조 n-gram + 수이므로 recall 이 지표에 해당한다. f1 은 참고로만 남긴다. 전 차수 코드가 + f1 을 반환한 탓에 정의가 어긋나 있어, 어느 값을 보고하는지 명시한다. + """ + r1 = scores["rouge1"] + target = 0.65 + return { + "summary_performance": { + "model": "O2O Summarizer", + "test_dataset": { + "total_samples": count, + "multi_reference_samples": multi_ref_rows, + "source": dataset or "(내장 dry-run 샘플)", + }, + "rouge_scores": { + "rouge1": scores["rouge1"], + "rouge2": scores["rouge2"], + "rougeL": scores["rougeL"], + }, + "reported_metric": "rouge1_recall", + "performance_metrics": { + "n_gram_rouge_score": r1["recall"], + "target": target, + "achieved": r1["recall"] >= target, + }, + "settings": {"tokenization": mode, "summary_ratio": ratio}, + "interpretation": { + "n_gram_rouge_score": + "참조 요약의 1-gram 중 %.1f%% 를 시스템 요약이 담아냄" + % (r1["recall"] * 100), + "note": "계획서 수식 분모가 참조 n-gram 수이므로 recall 을 보고한다. " + "ROUGE-1 F1 %.4f 는 계획서 지표가 아니다." % r1["f1"], + }, + } + } + + 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("--out", help="scorecard JSON 저장 경로") ap.add_argument("--iaa", action="store_true", help="사람 상한 측정 — 참조 2개 이상인 행에서 작성자끼리 채점") args = ap.parse_args() @@ -169,8 +211,20 @@ def main() -> None: print(f" (참고: ROUGE-1 F1 = {scores['rouge1']['f1']:.4f} — 계획서 지표 아님)") if multi_ref_rows: print(f" 다중 참조 사용: {multi_ref_rows}/{len(pairs)}건") + scorecard = build_scorecard(scores, len(pairs), args.mode, args.ratio, + multi_ref_rows, args.dataset) + rendered = json.dumps(scorecard, ensure_ascii=False, indent=2) + print("\n" + "=" * 62) + print("4. 결과") + print() + print(rendered) + print("\n최종 결과 : 평균 수치 %.2f%%" % (r1 * 100)) + if args.out: + Path(args.out).write_text(rendered + "\n", encoding="utf-8") + print("\n%s 에 결과 기록" % args.out) + if is_dry: - print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 컴북스 요약 정답셋 수령 후 본 평가로 정식 측정.") + print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 요약 정답셋 수령 후 본 평가로 정식 측정.") if __name__ == "__main__": diff --git a/scripts/show_summary_samples.py b/scripts/show_summary_samples.py new file mode 100644 index 0000000..c6f1b0b --- /dev/null +++ b/scripts/show_summary_samples.py @@ -0,0 +1,95 @@ +"""성능지표 #7 요약 정답셋 예시를 성적서 형식으로 출력한다. + +전 차수 성적서(GERI `GERIR.CE.2511-02.009`) 3.2.3.3 "1. 데이터 준비" 는 +원문을 `{index, original}` 형태로 앞뒤 몇 건만 싣고 중간을 `...` 로 생략한 뒤 +총 건수를 적는다. 그 형식으로 낸다. + +입력은 `scripts/eval_rouge.py` 와 같은 JSONL 이다. + {"text": "원문 ...", "reference": "사람 작성 요약 ..."} + {"text": "원문 ...", "references": ["정답 1 ...", "정답 2 ..."]} + +`--with-reference` 를 주면 정답 요약도 함께 싣는다. + +사용: + python scripts/show_summary_samples.py data/eval/summary.jsonl + python scripts/show_summary_samples.py data/eval/summary.jsonl --head 2 --tail 2 +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def record(index: int, row: dict, max_chars: int | None, + with_reference: bool) -> list[str]: + def clean(value: str) -> str: + body = " ".join(value.split()) + if max_chars and len(body) > max_chars: + body = body[:max_chars] + " …" + return body + + lines = [" {", ' "index": %d,' % index, + ' "original": "%s",' % clean(row.get("text", ""))] + if with_reference: + raw = row.get("references") or row.get("reference", "") + references = [raw] if isinstance(raw, str) else list(raw) + if len(references) == 1: + lines.append(' "reference": "%s",' % clean(references[0])) + else: + lines.append(' "references": [') + for n, reference in enumerate(references): + comma = "," if n < len(references) - 1 else "" + lines.append(' "%s"%s' % (clean(reference), comma)) + lines.append(" ],") + lines.append(" },") + return lines + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("dataset", type=Path, help="JSONL 정답셋") + parser.add_argument("--head", type=int, default=2, help="앞에서 N건") + parser.add_argument("--tail", type=int, default=2, help="뒤에서 N건") + parser.add_argument("--max-chars", type=int, default=None, + help="본문 길이 제한 (미지정 시 전문)") + parser.add_argument("--with-reference", action="store_true", + help="정답 요약도 함께 출력") + parser.add_argument("--out", help="파일로 저장") + args = parser.parse_args() + + if not args.dataset.exists(): + raise SystemExit( + "정답셋이 없습니다: %s\n" + "성능지표 #7 은 요약 정답셋 구축 후 측정 가능하다. " + "docs/TEST_PLAN_2026_PHASE2.md 부록 E 참조." % args.dataset) + + rows = [json.loads(line) for line + in args.dataset.read_text(encoding="utf-8").splitlines() if line.strip()] + total = len(rows) + + indices = list(range(min(args.head, total))) + tail_start = max(len(indices), total - args.tail) + indices += list(range(tail_start, total)) + + lines = ["1. 데이터 준비"] + previous = -1 + for i in indices: + if previous >= 0 and i != previous + 1: + lines.append("...") + lines.extend(record(i + 1, rows[i], args.max_chars, args.with_reference)) + previous = i + lines.append("") + lines.append("총 데이터 %d건" % total) + + text = "\n".join(lines) + if args.out: + Path(args.out).write_text(text + "\n", encoding="utf-8") + print("%s 에 기록" % args.out) + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())