2118 lines
90 KiB
Python
2118 lines
90 KiB
Python
"""
|
|
Markdown report generator for FRANKENSTALLM 3B evaluation pipeline.
|
|
|
|
Generates comprehensive evaluation reports with sections for:
|
|
- Perplexity metrics across datasets
|
|
- Calibration statistics
|
|
- Token NLL distribution
|
|
- Generation quality samples
|
|
- Repetition parameter search results
|
|
- Standard benchmark results (lm-eval) — Korean + English
|
|
- 0-shot vs 5-shot comparison
|
|
- Comparison with reference models
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
import json
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _fmt_seconds(seconds: float) -> str:
|
|
"""Format seconds into a human-readable duration string."""
|
|
m, s = divmod(int(seconds), 60)
|
|
h, m = divmod(m, 60)
|
|
if h:
|
|
return f"{h}h {m}m {s}s"
|
|
if m:
|
|
return f"{m}m {s}s"
|
|
return f"{s}s"
|
|
|
|
|
|
# =========================================================================
|
|
# Normalization helpers — map GPU-label keys to logical sections
|
|
# =========================================================================
|
|
|
|
def _normalize_phase1_results(raw: dict) -> dict:
|
|
"""Convert GPU-labelled phase1_results into logical sections.
|
|
|
|
Returns dict with keys: perplexity, calibration, token_nll, generation, repetition.
|
|
"""
|
|
normalized: Dict[str, Any] = {
|
|
"perplexity": {},
|
|
"calibration": {},
|
|
"token_nll": {},
|
|
"generation": {},
|
|
"repetition": {},
|
|
}
|
|
|
|
for label, data in raw.items():
|
|
if not isinstance(data, (dict, list)):
|
|
continue
|
|
|
|
if "PPL" in label:
|
|
# PPL entries: single dict or list of dicts
|
|
if isinstance(data, dict) and "ppl" in data:
|
|
name = data.get("name", label)
|
|
normalized["perplexity"][name] = data
|
|
elif isinstance(data, list):
|
|
for item in data:
|
|
if isinstance(item, dict) and "ppl" in item:
|
|
name = item.get("name", f"unknown_{len(normalized['perplexity'])}")
|
|
normalized["perplexity"][name] = item
|
|
elif isinstance(data, dict) and "error" in data:
|
|
# Task failed — skip
|
|
pass
|
|
elif "Calibration" in label:
|
|
if isinstance(data, dict):
|
|
if "calibration" in data:
|
|
normalized["calibration"] = data["calibration"]
|
|
if "token_nll" in data:
|
|
normalized["token_nll"] = data["token_nll"]
|
|
elif "Generation" in label:
|
|
if isinstance(data, dict):
|
|
normalized["generation"] = data
|
|
elif "Repetition" in label:
|
|
if isinstance(data, dict):
|
|
normalized["repetition"] = data
|
|
|
|
return normalized
|
|
|
|
|
|
def _normalize_phase2_results(raw: dict) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
"""Convert GPU-labelled phase2_results into flat task dicts for 0-shot and 5-shot.
|
|
|
|
Returns (zero_shot_metrics, five_shot_metrics) where each is:
|
|
{"kobest_boolq": {"acc,none": 0.50, ...}, "haerae": {...}, ...}
|
|
"""
|
|
zero_shot: Dict[str, Any] = {}
|
|
five_shot: Dict[str, Any] = {}
|
|
|
|
for label, data in raw.items():
|
|
if label == "5shot":
|
|
# Recurse into 5-shot sub-dict
|
|
if isinstance(data, dict):
|
|
for sub_label, sub_data in data.items():
|
|
if isinstance(sub_data, dict) and "per_task_metrics" in sub_data:
|
|
for task_name, metrics in sub_data["per_task_metrics"].items():
|
|
five_shot[task_name] = metrics
|
|
continue
|
|
|
|
if isinstance(data, dict) and "per_task_metrics" in data:
|
|
for task_name, metrics in data["per_task_metrics"].items():
|
|
zero_shot[task_name] = metrics
|
|
|
|
return zero_shot, five_shot
|
|
|
|
|
|
def _get_acc(metrics: dict, prefer_norm: bool = False) -> Optional[float]:
|
|
"""Extract accuracy from lm-eval metrics dict."""
|
|
if prefer_norm and "acc_norm,none" in metrics:
|
|
val = metrics["acc_norm,none"]
|
|
if isinstance(val, (int, float)):
|
|
return float(val)
|
|
if "acc,none" in metrics:
|
|
val = metrics["acc,none"]
|
|
if isinstance(val, (int, float)):
|
|
return float(val)
|
|
return None
|
|
|
|
|
|
def _fmt_pct(val: Optional[float]) -> str:
|
|
"""Format as percentage string or N/A."""
|
|
if val is None:
|
|
return "N/A"
|
|
return f"{val * 100:.2f}%"
|
|
|
|
|
|
def _fmt_f(val, decimals: int = 4) -> str:
|
|
"""Format float or return N/A."""
|
|
if isinstance(val, (int, float)):
|
|
return f"{val:.{decimals}f}"
|
|
return str(val) if val is not None else "N/A"
|
|
|
|
|
|
# =========================================================================
|
|
# Main report generator
|
|
# =========================================================================
|
|
|
|
def generate_report(
|
|
phase1_results: dict,
|
|
phase2_results: dict,
|
|
generation_samples: list,
|
|
output_dir: Path,
|
|
checkpoint_name: str = "checkpoint-0057000",
|
|
total_elapsed_sec: float = 0.0,
|
|
) -> str:
|
|
"""Generate a comprehensive markdown evaluation report.
|
|
|
|
Handles the GPU-labelled key structure from full_eval_pipeline.py
|
|
and produces multiple report files.
|
|
"""
|
|
output_dir = Path(output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
reports_dir = output_dir / "reports"
|
|
reports_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Normalize data
|
|
p1 = _normalize_phase1_results(phase1_results)
|
|
zero_shot, five_shot = _normalize_phase2_results(phase2_results)
|
|
|
|
eval_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
# ===== Generate individual reports =====
|
|
ppl_report = _generate_perplexity_report(p1["perplexity"])
|
|
cal_report = _generate_calibration_report(p1["calibration"], p1["token_nll"])
|
|
gen_report = _generate_generation_report(p1["generation"], generation_samples)
|
|
bench_report = _generate_benchmark_report(zero_shot, five_shot, p1["repetition"])
|
|
exec_summary = _generate_executive_summary(
|
|
p1, zero_shot, five_shot, checkpoint_name, eval_datetime, total_elapsed_sec,
|
|
)
|
|
|
|
# Write individual reports
|
|
(reports_dir / "00_executive_summary.md").write_text(exec_summary, encoding="utf-8")
|
|
(reports_dir / "01_perplexity_report.md").write_text(ppl_report, encoding="utf-8")
|
|
(reports_dir / "02_calibration_report.md").write_text(cal_report, encoding="utf-8")
|
|
(reports_dir / "03_generation_quality.md").write_text(gen_report, encoding="utf-8")
|
|
(reports_dir / "04_benchmark_report.md").write_text(bench_report, encoding="utf-8")
|
|
|
|
# Combined full report
|
|
full_report = "\n\n---\n\n".join([
|
|
exec_summary, ppl_report, cal_report, gen_report, bench_report,
|
|
])
|
|
|
|
report_path = output_dir / "full_eval_report.md"
|
|
report_path.write_text(full_report, encoding="utf-8")
|
|
|
|
return full_report
|
|
|
|
|
|
# =========================================================================
|
|
# Individual report sections
|
|
# =========================================================================
|
|
|
|
def _generate_executive_summary(
|
|
p1: dict,
|
|
zero_shot: dict,
|
|
five_shot: dict,
|
|
checkpoint_name: str,
|
|
eval_datetime: str,
|
|
total_elapsed_sec: float,
|
|
) -> str:
|
|
lines = [
|
|
"# FRANKENSTALLM 3B 종합 평가 리포트\n",
|
|
f"- **모델**: FRANKENSTALLM 3B",
|
|
f"- **체크포인트**: {checkpoint_name}",
|
|
f"- **평가 일시**: {eval_datetime}",
|
|
f"- **총 소요 시간**: {total_elapsed_sec:.1f}초\n",
|
|
"## Executive Summary\n",
|
|
]
|
|
|
|
# Main PPL
|
|
main_ppl = "N/A"
|
|
ppl_data = p1.get("perplexity", {})
|
|
for name in ["3b", "3b_val"]:
|
|
if name in ppl_data and isinstance(ppl_data[name], dict):
|
|
main_ppl = _fmt_f(ppl_data[name].get("ppl"))
|
|
break
|
|
|
|
# KoBEST average
|
|
kobest_tasks = ["kobest_boolq", "kobest_copa", "kobest_hellaswag",
|
|
"kobest_sentineg", "kobest_wic"]
|
|
kobest_accs = []
|
|
for t in kobest_tasks:
|
|
if t in zero_shot:
|
|
a = _get_acc(zero_shot[t])
|
|
if a is not None:
|
|
kobest_accs.append(a)
|
|
kobest_avg = _fmt_pct(sum(kobest_accs) / len(kobest_accs)) if kobest_accs else "N/A"
|
|
|
|
# MMLU-KO — prefer group-level weighted average from lm-eval
|
|
mmlu_ko_avg = "N/A"
|
|
mmlu_ko_count = 0
|
|
if "global_mmlu_ko" in zero_shot:
|
|
a = _get_acc(zero_shot["global_mmlu_ko"])
|
|
if a is not None:
|
|
mmlu_ko_avg = _fmt_pct(a)
|
|
# Count subtasks for display
|
|
mmlu_ko_count = sum(
|
|
1 for t in zero_shot
|
|
if t.startswith("global_mmlu_ko_") and _get_acc(zero_shot[t]) is not None
|
|
)
|
|
if mmlu_ko_count == 0:
|
|
mmlu_ko_count = 1 # group-level only
|
|
else:
|
|
# Fallback: average subtask-level metrics
|
|
mmlu_ko_accs = []
|
|
for t, m in zero_shot.items():
|
|
if t.startswith("global_mmlu_ko_"):
|
|
a = _get_acc(m)
|
|
if a is not None:
|
|
mmlu_ko_accs.append(a)
|
|
if mmlu_ko_accs:
|
|
mmlu_ko_avg = _fmt_pct(sum(mmlu_ko_accs) / len(mmlu_ko_accs))
|
|
mmlu_ko_count = len(mmlu_ko_accs)
|
|
|
|
# MMLU-EN — exclude group-level keys to avoid double-counting
|
|
_MMLU_EN_GROUPS = {"mmlu", "mmlu_humanities", "mmlu_social_sciences", "mmlu_stem", "mmlu_other"}
|
|
mmlu_en_accs = []
|
|
for t, m in zero_shot.items():
|
|
if (t.startswith("mmlu_") or t == "mmlu") and t not in _MMLU_EN_GROUPS:
|
|
a = _get_acc(m)
|
|
if a is not None:
|
|
mmlu_en_accs.append(a)
|
|
if not mmlu_en_accs:
|
|
# Fallback to group-level if no subtasks
|
|
for t in _MMLU_EN_GROUPS:
|
|
if t in zero_shot:
|
|
a = _get_acc(zero_shot[t])
|
|
if a is not None:
|
|
mmlu_en_accs.append(a)
|
|
mmlu_en_avg = _fmt_pct(sum(mmlu_en_accs) / len(mmlu_en_accs)) if mmlu_en_accs else "N/A"
|
|
|
|
# HAE-RAE
|
|
haerae_acc = "N/A"
|
|
if "haerae" in zero_shot:
|
|
a = _get_acc(zero_shot["haerae"])
|
|
if a is not None:
|
|
haerae_acc = _fmt_pct(a)
|
|
|
|
# English benchmarks
|
|
en_benchmarks = {}
|
|
for t in ["hellaswag", "arc_easy", "arc_challenge", "winogrande", "piqa"]:
|
|
if t in zero_shot:
|
|
a = _get_acc(zero_shot[t], prefer_norm=(t in ["hellaswag", "arc_challenge"]))
|
|
if a is not None:
|
|
en_benchmarks[t] = a
|
|
|
|
# Top-1 accuracy
|
|
top1 = _fmt_f(p1.get("calibration", {}).get("top1_accuracy"))
|
|
|
|
lines.append("| 메트릭 | 값 |")
|
|
lines.append("|--------|-----|")
|
|
lines.append(f"| 주요 PPL (3b_val) | {main_ppl} |")
|
|
lines.append(f"| MMLU-KO 평균 ({mmlu_ko_count}과목) | {mmlu_ko_avg} |")
|
|
lines.append(f"| MMLU-EN 평균 | {mmlu_en_avg} |")
|
|
lines.append(f"| KoBEST 평균 ({len(kobest_accs)}태스크) | {kobest_avg} |")
|
|
lines.append(f"| HAE-RAE | {haerae_acc} |")
|
|
for t, a in en_benchmarks.items():
|
|
lines.append(f"| {t} (0-shot) | {_fmt_pct(a)} |")
|
|
lines.append(f"| Top-1 정확도 (Calibration) | {top1} |")
|
|
lines.append("")
|
|
|
|
# Reference comparison
|
|
lines.append("## 참고 모델 비교\n")
|
|
lines.append("| 모델 | 파라미터 | MMLU-KO | MMLU-EN | KoBEST 평균 | PPL |")
|
|
lines.append("|------|---------|---------|---------|------------|-----|")
|
|
lines.append(f"| **FRANKENSTALLM 3B** | 3B | {mmlu_ko_avg} | {mmlu_en_avg} | {kobest_avg} | {main_ppl} |")
|
|
lines.append("| Llama-3.2-3B | 3B | ~42% | ~58% | ~55% | — |")
|
|
lines.append("| Qwen2.5-3B | 3B | ~48% | ~65% | ~60% | — |")
|
|
lines.append("| EXAONE-3.5-2.4B | 2.4B | ~35% | ~50% | ~50% | — |")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _generate_perplexity_report(ppl_data: dict) -> str:
|
|
lines = ["# Perplexity 평가\n"]
|
|
|
|
if not ppl_data:
|
|
lines.append("데이터 없음\n")
|
|
return "\n".join(lines)
|
|
|
|
rows = []
|
|
for name, metrics in ppl_data.items():
|
|
if isinstance(metrics, dict) and "ppl" in metrics:
|
|
rows.append({
|
|
"name": name,
|
|
"ppl": metrics.get("ppl"),
|
|
"bits": metrics.get("bits_per_token"),
|
|
"n_tokens": metrics.get("n_tokens"),
|
|
"n_eval": metrics.get("n_eval_tokens"),
|
|
"elapsed": metrics.get("elapsed_sec"),
|
|
})
|
|
|
|
rows.sort(key=lambda x: x["ppl"] if isinstance(x["ppl"], (int, float)) else float("inf"),
|
|
reverse=True)
|
|
|
|
lines.append("| 데이터셋 | PPL | Bits/Token | 전체 토큰 | 평가 토큰 | 소요 시간 |")
|
|
lines.append("|---------|-----|-----------|---------|---------|---------|")
|
|
for r in rows:
|
|
lines.append(
|
|
f"| {r['name']} | {_fmt_f(r['ppl'])} | {_fmt_f(r['bits'])} | "
|
|
f"{r['n_tokens']:,} | {r['n_eval']:,} | {_fmt_f(r['elapsed'], 1)}s |"
|
|
if isinstance(r['n_tokens'], (int, float)) and isinstance(r['n_eval'], (int, float))
|
|
else f"| {r['name']} | {_fmt_f(r['ppl'])} | {_fmt_f(r['bits'])} | "
|
|
f"{r['n_tokens']} | {r['n_eval']} | {_fmt_f(r['elapsed'], 1)}s |"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _generate_calibration_report(cal_data: dict, nll_data: dict) -> str:
|
|
lines = ["# Calibration 및 Token NLL 분석\n"]
|
|
|
|
# Calibration
|
|
lines.append("## Calibration 결과\n")
|
|
if cal_data:
|
|
lines.append("| 메트릭 | 값 |")
|
|
lines.append("|--------|-----|")
|
|
metrics_map = {
|
|
"top1_accuracy": "Top-1 Accuracy",
|
|
"top5_accuracy": "Top-5 Accuracy",
|
|
"top10_accuracy": "Top-10 Accuracy",
|
|
"mean_correct_prob": "Mean Correct Prob",
|
|
"mean_entropy": "Mean Entropy",
|
|
}
|
|
for key, label in metrics_map.items():
|
|
lines.append(f"| {label} | {_fmt_f(cal_data.get(key))} |")
|
|
lines.append("")
|
|
else:
|
|
lines.append("데이터 없음\n")
|
|
|
|
# Token NLL
|
|
lines.append("## Token NLL 분포\n")
|
|
if nll_data:
|
|
# Keys may be "mean"/"std" or "nll_mean"/"nll_std"
|
|
stats_map = [
|
|
(["nll_mean", "mean"], "평균"),
|
|
(["nll_std", "std"], "표준편차"),
|
|
(["nll_median", "median"], "중앙값"),
|
|
(["nll_min", "min"], "최솟값"),
|
|
(["nll_max", "max"], "최댓값"),
|
|
]
|
|
lines.append("| 통계 | 값 |")
|
|
lines.append("|------|-----|")
|
|
for candidates, label in stats_map:
|
|
val = None
|
|
for c in candidates:
|
|
if c in nll_data:
|
|
val = nll_data[c]
|
|
break
|
|
lines.append(f"| {label} | {_fmt_f(val)} |")
|
|
lines.append("")
|
|
|
|
# Percentiles: "nll_percentiles" (dict) or "percentiles" (dict)
|
|
pct_data = nll_data.get("nll_percentiles", nll_data.get("percentiles"))
|
|
if pct_data and isinstance(pct_data, dict):
|
|
lines.append("### Percentiles\n")
|
|
lines.append("| Percentile | 값 |")
|
|
lines.append("|------------|-----|")
|
|
for pct, value in pct_data.items():
|
|
lines.append(f"| {pct}th | {_fmt_f(value)} |")
|
|
lines.append("")
|
|
|
|
# High loss: "high_loss_fractions" (dict) or flat "high_loss_fraction_N" keys
|
|
hlf = nll_data.get("high_loss_fractions")
|
|
if hlf and isinstance(hlf, dict):
|
|
lines.append("### 고손실 토큰 비율\n")
|
|
lines.append("| 임계값 | 비율 |")
|
|
lines.append("|--------|-----|")
|
|
for threshold, fraction in hlf.items():
|
|
lines.append(f"| NLL > {threshold} | {_fmt_f(fraction)} |")
|
|
lines.append("")
|
|
else:
|
|
# Check flat keys: high_loss_fraction_5, high_loss_fraction_10, ...
|
|
hlf_flat = {k.replace("high_loss_fraction_", ""): v
|
|
for k, v in nll_data.items()
|
|
if k.startswith("high_loss_fraction_")}
|
|
if hlf_flat:
|
|
lines.append("### 고손실 토큰 비율\n")
|
|
lines.append("| 임계값 | 비율 |")
|
|
lines.append("|--------|-----|")
|
|
for threshold, fraction in sorted(hlf_flat.items()):
|
|
lines.append(f"| NLL > {threshold} | {_fmt_f(fraction)} |")
|
|
lines.append("")
|
|
else:
|
|
lines.append("데이터 없음\n")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _generate_generation_report(gen_data: dict, samples: list) -> str:
|
|
lines = ["# 생성 품질 분석\n"]
|
|
|
|
if gen_data and "summary" in gen_data:
|
|
lines.append("## 요약 통계\n")
|
|
lines.append("| 메트릭 | 값 |")
|
|
lines.append("|--------|-----|")
|
|
for key, value in gen_data["summary"].items():
|
|
display = key.replace("_", " ").title()
|
|
lines.append(f"| {display} | {_fmt_f(value)} |")
|
|
lines.append("")
|
|
|
|
if samples:
|
|
lines.append("## 생성 샘플 (Greedy)\n")
|
|
for i, sample in enumerate(samples[:5], 1):
|
|
if isinstance(sample, dict):
|
|
prompt = sample.get("prompt", "")
|
|
generated = sample.get("generated_text", "")
|
|
if len(generated) > 300:
|
|
generated = generated[:300] + "..."
|
|
lines.append(f"### 샘플 {i}\n")
|
|
lines.append(f"**Prompt**: {prompt}\n")
|
|
lines.append(f"**Generated**: {generated}\n")
|
|
lines.append("")
|
|
elif not gen_data:
|
|
lines.append("데이터 없음\n")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _generate_benchmark_report(
|
|
zero_shot: dict,
|
|
five_shot: dict,
|
|
repetition: dict,
|
|
) -> str:
|
|
lines = ["# 표준 벤치마크 결과\n"]
|
|
|
|
if not zero_shot and not five_shot:
|
|
lines.append("데이터 없음\n")
|
|
return "\n".join(lines)
|
|
|
|
# --- Korean Benchmarks ---
|
|
lines.append("## 한국어 벤치마크\n")
|
|
|
|
# KoBEST
|
|
kobest_names = ["kobest_boolq", "kobest_copa", "kobest_hellaswag",
|
|
"kobest_sentineg", "kobest_wic"]
|
|
kobest_0 = {t: zero_shot[t] for t in kobest_names if t in zero_shot}
|
|
if kobest_0:
|
|
lines.append("### KoBEST (0-shot)\n")
|
|
lines.append("| 태스크 | Accuracy | F1 |")
|
|
lines.append("|--------|----------|-----|")
|
|
for t in kobest_names:
|
|
if t in kobest_0:
|
|
m = kobest_0[t]
|
|
acc = _fmt_pct(_get_acc(m))
|
|
f1 = _fmt_f(m.get("f1,none"))
|
|
lines.append(f"| {t} | {acc} | {f1} |")
|
|
kobest_accs = [_get_acc(kobest_0[t]) for t in kobest_names
|
|
if t in kobest_0 and _get_acc(kobest_0[t]) is not None]
|
|
if kobest_accs:
|
|
lines.append(f"| **평균** | **{_fmt_pct(sum(kobest_accs)/len(kobest_accs))}** | |")
|
|
lines.append("")
|
|
|
|
# HAE-RAE
|
|
if "haerae" in zero_shot:
|
|
lines.append("### HAE-RAE (0-shot)\n")
|
|
m = zero_shot["haerae"]
|
|
lines.append(f"- Accuracy: {_fmt_pct(_get_acc(m))}")
|
|
# Check for sub-tasks
|
|
haerae_subs = {t: zero_shot[t] for t in zero_shot if t.startswith("haerae_") and t != "haerae"}
|
|
if haerae_subs:
|
|
lines.append("\n| 서브태스크 | Accuracy |")
|
|
lines.append("|-----------|----------|")
|
|
for t, sm in sorted(haerae_subs.items()):
|
|
lines.append(f"| {t} | {_fmt_pct(_get_acc(sm))} |")
|
|
lines.append("")
|
|
|
|
# MMLU-KO
|
|
mmlu_ko_tasks = {t: zero_shot[t] for t in zero_shot
|
|
if t.startswith("global_mmlu_ko") and t != "global_mmlu_ko"}
|
|
if mmlu_ko_tasks or "global_mmlu_ko" in zero_shot:
|
|
lines.append("### MMLU-KO (0-shot)\n")
|
|
if mmlu_ko_tasks:
|
|
lines.append(f"평가된 과목 수: **{len(mmlu_ko_tasks)}**\n")
|
|
accs = [(t, _get_acc(m)) for t, m in sorted(mmlu_ko_tasks.items())
|
|
if _get_acc(m) is not None]
|
|
if accs:
|
|
# Prefer group-level weighted average from lm-eval
|
|
group_acc = _get_acc(zero_shot["global_mmlu_ko"]) if "global_mmlu_ko" in zero_shot else None
|
|
avg_acc = group_acc if group_acc is not None else sum(a for _, a in accs) / len(accs)
|
|
lines.append(f"전체 평균: **{_fmt_pct(avg_acc)}**\n")
|
|
|
|
# Top 10
|
|
accs_sorted = sorted(accs, key=lambda x: x[1], reverse=True)
|
|
lines.append("**상위 10개 과목**:\n")
|
|
lines.append("| 과목 | Accuracy |")
|
|
lines.append("|------|----------|")
|
|
for t, a in accs_sorted[:10]:
|
|
subject = t.replace("global_mmlu_ko_", "")
|
|
lines.append(f"| {subject} | {_fmt_pct(a)} |")
|
|
lines.append("")
|
|
|
|
lines.append("**하위 10개 과목**:\n")
|
|
lines.append("| 과목 | Accuracy |")
|
|
lines.append("|------|----------|")
|
|
for t, a in accs_sorted[-10:]:
|
|
subject = t.replace("global_mmlu_ko_", "")
|
|
lines.append(f"| {subject} | {_fmt_pct(a)} |")
|
|
lines.append("")
|
|
elif "global_mmlu_ko" in zero_shot:
|
|
a = _get_acc(zero_shot["global_mmlu_ko"])
|
|
lines.append(f"전체 정확도: {_fmt_pct(a)}\n")
|
|
|
|
# --- English Benchmarks ---
|
|
lines.append("## 영어 벤치마크\n")
|
|
|
|
en_tasks = ["hellaswag", "arc_easy", "arc_challenge", "winogrande", "piqa"]
|
|
en_found = {t: zero_shot[t] for t in en_tasks if t in zero_shot}
|
|
if en_found:
|
|
lines.append("### 주요 벤치마크 (0-shot)\n")
|
|
lines.append("| 태스크 | Accuracy | Acc (norm) |")
|
|
lines.append("|--------|----------|-----------|")
|
|
for t in en_tasks:
|
|
if t in en_found:
|
|
m = en_found[t]
|
|
acc = _fmt_pct(_get_acc(m))
|
|
acc_norm = _fmt_pct(_get_acc(m, prefer_norm=True) if "acc_norm,none" in m else None)
|
|
lines.append(f"| {t} | {acc} | {acc_norm} |")
|
|
lines.append("")
|
|
|
|
# MMLU-EN
|
|
mmlu_en_tasks = {t: zero_shot[t] for t in zero_shot
|
|
if (t.startswith("mmlu_") or t == "mmlu") and not t.startswith("mmlu_ko")}
|
|
if mmlu_en_tasks:
|
|
lines.append("### MMLU-EN (0-shot)\n")
|
|
# Filter out the group-level "mmlu" if sub-tasks exist
|
|
subtasks = {t: m for t, m in mmlu_en_tasks.items() if t != "mmlu"}
|
|
if subtasks:
|
|
lines.append(f"평가된 과목 수: **{len(subtasks)}**\n")
|
|
accs = [(t, _get_acc(m)) for t, m in sorted(subtasks.items())
|
|
if _get_acc(m) is not None]
|
|
if accs:
|
|
avg_acc = sum(a for _, a in accs) / len(accs)
|
|
lines.append(f"전체 평균: **{_fmt_pct(avg_acc)}**\n")
|
|
|
|
accs_sorted = sorted(accs, key=lambda x: x[1], reverse=True)
|
|
lines.append("**상위 10개 과목**:\n")
|
|
lines.append("| 과목 | Accuracy |")
|
|
lines.append("|------|----------|")
|
|
for t, a in accs_sorted[:10]:
|
|
subject = t.replace("mmlu_", "")
|
|
lines.append(f"| {subject} | {_fmt_pct(a)} |")
|
|
lines.append("")
|
|
|
|
lines.append("**하위 10개 과목**:\n")
|
|
lines.append("| 과목 | Accuracy |")
|
|
lines.append("|------|----------|")
|
|
for t, a in accs_sorted[-10:]:
|
|
subject = t.replace("mmlu_", "")
|
|
lines.append(f"| {subject} | {_fmt_pct(a)} |")
|
|
lines.append("")
|
|
elif "mmlu" in mmlu_en_tasks:
|
|
a = _get_acc(mmlu_en_tasks["mmlu"])
|
|
lines.append(f"전체 정확도: {_fmt_pct(a)}\n")
|
|
|
|
# --- 0-shot vs 5-shot Comparison ---
|
|
if five_shot:
|
|
lines.append("## 0-shot vs 5-shot 비교\n")
|
|
|
|
# Collect all tasks that have both 0-shot and 5-shot results
|
|
common_tasks = sorted(set(zero_shot.keys()) & set(five_shot.keys()))
|
|
if common_tasks:
|
|
lines.append("| 태스크 | 0-shot Acc | 5-shot Acc | 변화 |")
|
|
lines.append("|--------|-----------|-----------|------|")
|
|
for t in common_tasks:
|
|
a0 = _get_acc(zero_shot[t])
|
|
a5 = _get_acc(five_shot[t])
|
|
if a0 is not None and a5 is not None:
|
|
diff = a5 - a0
|
|
sign = "+" if diff >= 0 else ""
|
|
lines.append(
|
|
f"| {t} | {_fmt_pct(a0)} | {_fmt_pct(a5)} | {sign}{diff*100:.2f}pp |"
|
|
)
|
|
else:
|
|
lines.append(f"| {t} | {_fmt_pct(a0)} | {_fmt_pct(a5)} | — |")
|
|
lines.append("")
|
|
|
|
# Summary
|
|
diffs = []
|
|
for t in common_tasks:
|
|
a0 = _get_acc(zero_shot[t])
|
|
a5 = _get_acc(five_shot[t])
|
|
if a0 is not None and a5 is not None:
|
|
diffs.append(a5 - a0)
|
|
if diffs:
|
|
avg_diff = sum(diffs) / len(diffs)
|
|
improved = sum(1 for d in diffs if d > 0)
|
|
degraded = sum(1 for d in diffs if d < 0)
|
|
lines.append(
|
|
f"평균 변화: {'+' if avg_diff >= 0 else ''}{avg_diff*100:.2f}pp | "
|
|
f"개선: {improved} | 하락: {degraded} | 동일: {len(diffs) - improved - degraded}\n"
|
|
)
|
|
|
|
# --- Repetition ---
|
|
if repetition and repetition.get("grid_results"):
|
|
lines.append("## Repetition 파라미터 검색\n")
|
|
rep_data = repetition["grid_results"]
|
|
rep_rows = []
|
|
# grid_results can be a list of dicts or a dict of dicts
|
|
items = rep_data.items() if isinstance(rep_data, dict) else enumerate(rep_data)
|
|
for key, metrics in items:
|
|
if isinstance(metrics, dict):
|
|
rep_rows.append({
|
|
"config": metrics.get("params", str(key)),
|
|
"temp": metrics.get("temperature"),
|
|
"rep_pen": metrics.get("repetition_penalty"),
|
|
"3gram": metrics.get("avg_3gram_rep", metrics.get("3gram_repetition", float("inf"))),
|
|
"4gram": metrics.get("avg_4gram_rep", metrics.get("4gram_repetition")),
|
|
"eos_rate": metrics.get("eos_rate"),
|
|
"avg_tokens": metrics.get("avg_tokens"),
|
|
})
|
|
rep_rows.sort(key=lambda x: x["3gram"] if isinstance(x["3gram"], (int, float)) else float("inf"))
|
|
|
|
lines.append("| 설정 | Temp | Rep Pen | 3-gram | 4-gram | EOS Rate | Avg Tokens |")
|
|
lines.append("|------|------|---------|--------|--------|----------|-----------|")
|
|
for i, r in enumerate(rep_rows):
|
|
marker = " **← best**" if i == 0 else ""
|
|
lines.append(
|
|
f"| {r['config']} | {_fmt_f(r['temp'], 2)} | {_fmt_f(r['rep_pen'], 2)} | "
|
|
f"{_fmt_f(r['3gram'])} | {_fmt_f(r['4gram'])} | "
|
|
f"{_fmt_f(r['eos_rate'])} | {_fmt_f(r['avg_tokens'], 1)} |{marker}"
|
|
)
|
|
lines.append("")
|
|
|
|
lines.append("---\n")
|
|
lines.append("*이 리포트는 자동으로 생성되었습니다.*")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# =========================================================================
|
|
# Base vs SFT Comparison Report
|
|
# =========================================================================
|
|
|
|
# Base model reference values (from 3b_reeval_20260305_1451)
|
|
_BASE_PPL_REFERENCE = {
|
|
"3b_val": 5.2263,
|
|
"3b": 5.2263,
|
|
"korean_c4_val": 5.7173,
|
|
"korean_c4": 5.7173,
|
|
"hplt_ko_val": 2.4028,
|
|
"hplt_ko": 2.4028,
|
|
"cc100_ko_val": 21.782,
|
|
"cc100_ko": 21.782,
|
|
"korean_val": 9.6505,
|
|
"korean": 9.6505,
|
|
}
|
|
|
|
_BASE_BENCH_REFERENCE = {
|
|
"kobest_boolq": 0.5028,
|
|
"kobest_copa": 0.4930,
|
|
"kobest_hellaswag": 0.2160,
|
|
"kobest_sentineg": 0.4861,
|
|
"kobest_wic": 0.4865,
|
|
"haerae": 0.1971,
|
|
"global_mmlu_ko": 0.2275,
|
|
"hellaswag": 0.2600,
|
|
"arc_easy": 0.2563,
|
|
"arc_challenge": 0.2167,
|
|
"winogrande": 0.5059,
|
|
"piqa": 0.5250,
|
|
}
|
|
|
|
_BASE_GEN_REFERENCE = {
|
|
"greedy_3gram_rep": 0.6099,
|
|
"greedy_4gram_rep": 0.5702,
|
|
"greedy_eos_rate": 0.0,
|
|
}
|
|
|
|
_BASE_CALIB_REFERENCE = {
|
|
"top1_accuracy": 0.6875,
|
|
"top5_accuracy": 0.8164,
|
|
"top10_accuracy": 0.8593,
|
|
"mean_entropy": 1.5682,
|
|
}
|
|
|
|
_BASE_NLL_REFERENCE = {
|
|
"nll_mean": 1.5561,
|
|
"high_loss_fraction_5": 0.1086,
|
|
}
|
|
|
|
# =========================================================================
|
|
# Threshold Justification
|
|
# =========================================================================
|
|
# PPL forgetting 15%: Kirkpatrick et al. (2017) continual learning 기준 10-20%
|
|
# KoBEST avg 55%: Random baseline ~40%, Llama 3.2 1B ~52%, Qwen 2.5 3B ~58%
|
|
# MMLU-KO 30%: Random 25%, Llama 3.2 3B ~35%
|
|
# Greedy 3-gram rep <5%: 인간 한국어 텍스트 256토큰 기준 1-3%, Base 모델 61%
|
|
# EOS rate >90%: 챗 모델은 응답을 끝내야 함, 일부 장문 허용
|
|
# Calibration top1 65%: Base 68.75%, SFT로 인한 소폭 하락 허용
|
|
# Distinct-2 >70%: Li et al. (2016), 다양성 보장 최소선
|
|
# =========================================================================
|
|
|
|
_SFT_TARGETS = {
|
|
# 생성 품질
|
|
"greedy_3gram_rep_max": 0.05,
|
|
"eos_rate_min": 0.90,
|
|
"sampled_eos_min": 0.50,
|
|
"distinct_2_min": 0.70,
|
|
# 지식 보존
|
|
"ppl_forgetting_max_pct": 15.0,
|
|
# 한국어 벤치마크
|
|
"kobest_avg_min": 0.55,
|
|
"haerae_min": 0.25,
|
|
"mmlu_ko_min": 0.30,
|
|
# 칼리브레이션
|
|
"top1_accuracy_min": 0.65,
|
|
# 영어 유지
|
|
"hellaswag_min": 0.25,
|
|
"arc_easy_min": 0.25,
|
|
"arc_challenge_min": 0.21,
|
|
"winogrande_min": 0.49,
|
|
"piqa_min": 0.51,
|
|
"mmlu_en_avg_min": 0.25,
|
|
}
|
|
|
|
_REFERENCE_MODELS = {
|
|
"Llama 3.2 1B": {"kobest_avg": 0.52, "mmlu_ko": 0.28, "mmlu_en": 0.32},
|
|
"Llama 3.2 3B": {"kobest_avg": 0.56, "mmlu_ko": 0.35, "mmlu_en": 0.55},
|
|
"Qwen 2.5 3B": {"kobest_avg": 0.58, "mmlu_ko": 0.42, "mmlu_en": 0.58},
|
|
}
|
|
|
|
|
|
def _compute_orpo_score(sft_p1, sft_zero, base_p1, base_zero):
|
|
"""ORPO 필요성 정량 판정 (0-100점).
|
|
|
|
Returns:
|
|
dict with keys: total_score, dimension_scores, decision, confidence, orpo_gain_estimate
|
|
"""
|
|
dimensions = {}
|
|
missing = 0
|
|
total_dims = 7
|
|
|
|
# Dim 1: PPL Forgetting (25 pts)
|
|
max_forgetting = _get_max_forgetting(sft_p1, base_p1)
|
|
if max_forgetting is not None:
|
|
threshold = _SFT_TARGETS["ppl_forgetting_max_pct"]
|
|
score = 25 * max(0, 1 - max_forgetting / threshold)
|
|
dimensions["ppl_forgetting"] = {
|
|
"score": round(score, 1), "weight": 25,
|
|
"current": round(max_forgetting, 1), "threshold": f"<{threshold}%",
|
|
"status": "PASS" if max_forgetting < threshold else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["ppl_forgetting"] = {"score": 0, "weight": 25, "current": "N/A", "threshold": "<15%", "status": "N/A"}
|
|
|
|
# Dim 2: Greedy 반복률 (20 pts)
|
|
rep_rate = _get_greedy_3gram_rep(sft_p1)
|
|
if rep_rate is not None:
|
|
threshold = _SFT_TARGETS["greedy_3gram_rep_max"]
|
|
score = 20 * max(0, 1 - rep_rate / threshold)
|
|
dimensions["greedy_rep"] = {
|
|
"score": round(score, 1), "weight": 20,
|
|
"current": f"{rep_rate:.1%}", "threshold": f"<{threshold:.0%}",
|
|
"status": "PASS" if rep_rate < threshold else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["greedy_rep"] = {"score": 0, "weight": 20, "current": "N/A", "threshold": "<5%", "status": "N/A"}
|
|
|
|
# Dim 3: EOS 종료율 (10 pts)
|
|
eos_rate = sft_p1.get("generation", {}).get("summary", {}).get("greedy_eos_rate")
|
|
if eos_rate is not None:
|
|
threshold = _SFT_TARGETS["eos_rate_min"]
|
|
score = 10 * min(eos_rate / threshold, 1)
|
|
dimensions["eos_rate"] = {
|
|
"score": round(score, 1), "weight": 10,
|
|
"current": f"{eos_rate:.0%}", "threshold": f">{threshold:.0%}",
|
|
"status": "PASS" if eos_rate >= threshold else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["eos_rate"] = {"score": 0, "weight": 10, "current": "N/A", "threshold": ">90%", "status": "N/A"}
|
|
|
|
# Dim 4: KoBEST 평균 (20 pts)
|
|
kobest_avg = _get_kobest_avg(sft_zero)
|
|
if kobest_avg is not None:
|
|
threshold = _SFT_TARGETS["kobest_avg_min"]
|
|
score = 20 * min(kobest_avg / threshold, 1)
|
|
dimensions["kobest_avg"] = {
|
|
"score": round(score, 1), "weight": 20,
|
|
"current": f"{kobest_avg:.1%}", "threshold": f">{threshold:.0%}",
|
|
"status": "PASS" if kobest_avg >= threshold else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["kobest_avg"] = {"score": 0, "weight": 20, "current": "N/A", "threshold": ">55%", "status": "N/A"}
|
|
|
|
# Dim 5: Calibration (10 pts)
|
|
top1 = sft_p1.get("calibration", {}).get("top1_accuracy")
|
|
if top1 is not None:
|
|
threshold = _SFT_TARGETS["top1_accuracy_min"]
|
|
score = 10 * min(top1 / threshold, 1)
|
|
dimensions["calibration"] = {
|
|
"score": round(score, 1), "weight": 10,
|
|
"current": f"{top1:.1%}", "threshold": f">={threshold:.0%}",
|
|
"status": "PASS" if top1 >= threshold else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["calibration"] = {"score": 0, "weight": 10, "current": "N/A", "threshold": ">=65%", "status": "N/A"}
|
|
|
|
# Dim 6: 다양성 distinct-2 (10 pts)
|
|
distinct_2 = sft_p1.get("generation", {}).get("summary", {}).get("greedy_avg_distinct_2")
|
|
if distinct_2 is not None:
|
|
threshold = _SFT_TARGETS["distinct_2_min"]
|
|
score = 10 * min(distinct_2 / threshold, 1)
|
|
dimensions["diversity"] = {
|
|
"score": round(score, 1), "weight": 10,
|
|
"current": f"{distinct_2:.0%}", "threshold": f">{threshold:.0%}",
|
|
"status": "PASS" if distinct_2 >= threshold else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["diversity"] = {"score": 0, "weight": 10, "current": "N/A", "threshold": ">70%", "status": "N/A"}
|
|
|
|
# Dim 7: 영어 유지 (5 pts)
|
|
en_tasks = {
|
|
"hellaswag": _SFT_TARGETS["hellaswag_min"],
|
|
"arc_easy": _SFT_TARGETS["arc_easy_min"],
|
|
"arc_challenge": _SFT_TARGETS["arc_challenge_min"],
|
|
"winogrande": _SFT_TARGETS["winogrande_min"],
|
|
"piqa": _SFT_TARGETS["piqa_min"],
|
|
}
|
|
en_all_pass = True
|
|
en_count = 0
|
|
for t, threshold in en_tasks.items():
|
|
a = _get_acc(sft_zero.get(t, {})) if t in sft_zero else None
|
|
if a is not None:
|
|
en_count += 1
|
|
if a < threshold:
|
|
en_all_pass = False
|
|
if en_count > 0:
|
|
score = 5.0 if en_all_pass else 0.0
|
|
dimensions["english"] = {
|
|
"score": score, "weight": 5,
|
|
"current": "전부 통과" if en_all_pass else "일부 미달",
|
|
"threshold": "—", "status": "PASS" if en_all_pass else "FAIL",
|
|
}
|
|
else:
|
|
missing += 1
|
|
dimensions["english"] = {"score": 0, "weight": 5, "current": "N/A", "threshold": "—", "status": "N/A"}
|
|
|
|
total_score = sum(d["score"] for d in dimensions.values())
|
|
confidence = round(1.0 - (missing / total_dims), 2)
|
|
|
|
if missing >= 2:
|
|
logger.warning("ORPO score has %d/%d missing dimensions — confidence %.0f%%", missing, total_dims, confidence * 100)
|
|
|
|
# ORPO gain estimate: dimensions that ORPO can improve
|
|
orpo_improvable = 0.0
|
|
if rep_rate is not None and rep_rate >= _SFT_TARGETS["greedy_3gram_rep_max"]:
|
|
orpo_improvable += 20.0 # repetition
|
|
if eos_rate is not None and eos_rate < _SFT_TARGETS["eos_rate_min"]:
|
|
orpo_improvable += 10.0 # eos
|
|
if distinct_2 is not None and distinct_2 < _SFT_TARGETS["distinct_2_min"]:
|
|
orpo_improvable += 5.0 # partial diversity improvement
|
|
|
|
# Decision
|
|
forgetting_ok = max_forgetting is not None and max_forgetting < _SFT_TARGETS["ppl_forgetting_max_pct"]
|
|
if total_score >= 80:
|
|
decision = "DEPLOY"
|
|
elif total_score >= 40 and forgetting_ok:
|
|
decision = "ORPO"
|
|
else:
|
|
decision = "SFT_RETRY"
|
|
|
|
return {
|
|
"total_score": round(total_score, 1),
|
|
"dimensions": dimensions,
|
|
"decision": decision,
|
|
"confidence": confidence,
|
|
"orpo_gain_estimate": round(orpo_improvable, 1),
|
|
}
|
|
|
|
|
|
def generate_comparison_report(
|
|
base_results_dir: Path,
|
|
sft_phase1_results: dict,
|
|
sft_phase2_results: dict,
|
|
output_path: Path,
|
|
sft_output_dir: Optional[Path] = None,
|
|
total_elapsed_sec: float = 0.0,
|
|
) -> Path:
|
|
"""Generate a comprehensive Base vs SFT comparison report.
|
|
|
|
Args:
|
|
base_results_dir: Directory containing Base model's phase1/phase2_results.json
|
|
sft_phase1_results: SFT Phase 1 results dict
|
|
sft_phase2_results: SFT Phase 2 results dict
|
|
output_path: Where to write the markdown report
|
|
sft_output_dir: SFT eval outputs directory (for linking)
|
|
total_elapsed_sec: Total pipeline elapsed time
|
|
|
|
Returns:
|
|
Path to the generated report
|
|
"""
|
|
base_results_dir = Path(base_results_dir)
|
|
output_path = Path(output_path)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Load Base results
|
|
base_p1 = {}
|
|
base_p2 = {}
|
|
p1_file = base_results_dir / "phase1_results.json"
|
|
p2_file = base_results_dir / "phase2_results.json"
|
|
if p1_file.exists():
|
|
with open(p1_file, encoding="utf-8") as f:
|
|
base_p1 = json.load(f)
|
|
if p2_file.exists():
|
|
with open(p2_file, encoding="utf-8") as f:
|
|
base_p2 = json.load(f)
|
|
|
|
# Normalize both
|
|
sft_p1 = _normalize_phase1_results(sft_phase1_results)
|
|
base_p1_norm = _normalize_phase1_results(base_p1)
|
|
sft_zero, sft_five = _normalize_phase2_results(sft_phase2_results)
|
|
base_zero, base_five = _normalize_phase2_results(base_p2)
|
|
|
|
eval_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
lines = []
|
|
|
|
# === Header ===
|
|
lines.append("# FRANKENSTALLM 3B SFT 모델 다면적 종합 평가 보고서\n")
|
|
lines.append(f"- **평가 일시**: {eval_datetime}")
|
|
lines.append(f"- **SFT 체크포인트**: checkpoint-best (val_loss=1.8851, step 25500)")
|
|
lines.append(f"- **Base 참조 결과**: 3b_reeval_20260305_1451")
|
|
lines.append(f"- **총 소요 시간**: {_fmt_seconds(total_elapsed_sec)}")
|
|
if sft_output_dir:
|
|
lines.append(f"- **결과 디렉토리**: {sft_output_dir}")
|
|
lines.append("")
|
|
|
|
# === 1. Executive Summary ===
|
|
lines.append("## 1. Executive Summary\n")
|
|
verdicts = _compute_verdicts(sft_p1, sft_zero, base_p1_norm, base_zero)
|
|
lines.append("| 평가 차원 | 결과 | 상세 |")
|
|
lines.append("|----------|------|------|")
|
|
for dim_name, verdict, detail in verdicts:
|
|
icon = "PASS" if verdict else "FAIL"
|
|
lines.append(f"| {dim_name} | **{icon}** | {detail} |")
|
|
lines.append("")
|
|
|
|
pass_count = sum(1 for _, v, _ in verdicts if v)
|
|
total_dims = len(verdicts)
|
|
lines.append(f"**종합**: {pass_count}/{total_dims} 차원 통과\n")
|
|
|
|
# ORPO verdict — quantitative scoring
|
|
rep_rate = _get_greedy_3gram_rep(sft_p1)
|
|
kobest_avg = _get_kobest_avg(sft_zero)
|
|
max_forgetting = _get_max_forgetting(sft_p1, base_p1_norm)
|
|
|
|
lines.append("### ORPO 판정 (정량 스코어)\n")
|
|
orpo_result = _compute_orpo_score(sft_p1, sft_zero, base_p1_norm, base_zero)
|
|
|
|
lines.append(f"**결정**: {orpo_result['decision']} (확신도: {orpo_result['confidence']:.0%})\n")
|
|
lines.append(f"**정량 스코어**: {orpo_result['total_score']}/100\n")
|
|
|
|
lines.append("| 차원 | 점수 | /가중치 | 현재값 | 기준 | 상태 |")
|
|
lines.append("|------|------|--------|--------|------|------|")
|
|
dim_names = {
|
|
"ppl_forgetting": "PPL Forgetting",
|
|
"greedy_rep": "Greedy 반복률",
|
|
"eos_rate": "EOS 종료율",
|
|
"kobest_avg": "KoBEST 평균",
|
|
"calibration": "Calibration",
|
|
"diversity": "다양성",
|
|
"english": "영어 유지",
|
|
}
|
|
for key, label in dim_names.items():
|
|
d = orpo_result["dimensions"].get(key, {})
|
|
lines.append(
|
|
f"| {label} | {d.get('score', 0)} | /{d.get('weight', 0)} | "
|
|
f"{d.get('current', 'N/A')} | {d.get('threshold', '—')} | {d.get('status', 'N/A')} |"
|
|
)
|
|
lines.append("")
|
|
|
|
if orpo_result["orpo_gain_estimate"] > 0:
|
|
lines.append(f"**ORPO 기대 이득**: +{orpo_result['orpo_gain_estimate']}점 "
|
|
f"(반복률/EOS/다양성 개선 기대, PPL/벤치 변화 없음)\n")
|
|
|
|
# Reference model comparison
|
|
lines.append("**참조 모델 비교**:\n")
|
|
for model_name, ref in _REFERENCE_MODELS.items():
|
|
lines.append(f"- {model_name}: KoBEST={ref['kobest_avg']:.0%}, MMLU-KO={ref['mmlu_ko']:.0%}")
|
|
lines.append("")
|
|
|
|
# Decision explanation
|
|
if orpo_result["decision"] == "DEPLOY":
|
|
lines.append("**→ Phase 4: GGUF + Ollama 배포** (스코어 ≥80, 모든 핵심 조건 충족)\n")
|
|
elif orpo_result["decision"] == "ORPO":
|
|
lines.append("**→ Phase 3: ORPO** (스코어 40-79, 지식 보존 양호, 생성 개선 필요)\n")
|
|
else:
|
|
lines.append("**→ SFT 재시도** (스코어 <40 또는 심각한 forgetting)\n")
|
|
|
|
# === 2. PPL Comparison ===
|
|
lines.append("## 2. Perplexity 비교 (지식 보존)\n")
|
|
lines.append("| 데이터셋 | Base PPL | SFT PPL | 변화 | Forgetting % | 판정 |")
|
|
lines.append("|---------|---------|---------|------|-------------|------|")
|
|
|
|
sft_ppl = sft_p1.get("perplexity", {})
|
|
base_ppl = base_p1_norm.get("perplexity", {})
|
|
|
|
# Merge all dataset names
|
|
all_ppl_names = sorted(set(list(sft_ppl.keys()) + list(base_ppl.keys())))
|
|
forgetting_values = []
|
|
for name in all_ppl_names:
|
|
sft_val = sft_ppl.get(name, {}).get("ppl") if isinstance(sft_ppl.get(name), dict) else None
|
|
base_val = base_ppl.get(name, {}).get("ppl") if isinstance(base_ppl.get(name), dict) else None
|
|
# Try reference table if base results not available
|
|
if base_val is None:
|
|
base_val = _BASE_PPL_REFERENCE.get(name)
|
|
|
|
if sft_val is not None and base_val is not None:
|
|
forgetting = (sft_val - base_val) / base_val * 100
|
|
forgetting_values.append(forgetting)
|
|
verdict = "PASS" if forgetting < _SFT_TARGETS["ppl_forgetting_max_pct"] else "FAIL"
|
|
lines.append(
|
|
f"| {name} | {base_val:.4f} | {sft_val:.4f} | "
|
|
f"{'+' if sft_val >= base_val else ''}{sft_val - base_val:.4f} | "
|
|
f"{forgetting:+.1f}% | {verdict} |"
|
|
)
|
|
elif sft_val is not None:
|
|
lines.append(f"| {name} | — | {sft_val:.4f} | — | — | — |")
|
|
elif base_val is not None:
|
|
lines.append(f"| {name} | {base_val:.4f} | — | — | — | — |")
|
|
|
|
if forgetting_values:
|
|
avg_forgetting = sum(forgetting_values) / len(forgetting_values)
|
|
max_f = max(forgetting_values)
|
|
lines.append("")
|
|
lines.append(f"**평균 Forgetting**: {avg_forgetting:+.1f}% | **최대**: {max_f:+.1f}% | "
|
|
f"**판정**: {'PASS' if max_f < _SFT_TARGETS['ppl_forgetting_max_pct'] else 'FAIL'} (임계값 {_SFT_TARGETS['ppl_forgetting_max_pct']}%)")
|
|
lines.append("")
|
|
|
|
# === 3. Generation Quality ===
|
|
lines.append("## 3. 생성 품질 비교\n")
|
|
sft_gen = sft_p1.get("generation", {})
|
|
if not sft_gen:
|
|
logger.warning("Generation results missing from SFT Phase 1")
|
|
sft_summary = sft_gen.get("summary", {})
|
|
|
|
lines.append("| 지표 | Base | SFT | 목표 | 판정 |")
|
|
lines.append("|------|------|-----|------|------|")
|
|
|
|
greedy_3gram = sft_summary.get("greedy_avg_3gram_rep")
|
|
greedy_4gram = sft_summary.get("greedy_avg_4gram_rep")
|
|
eos_rate = sft_summary.get("greedy_eos_rate")
|
|
|
|
rep_threshold = _SFT_TARGETS["greedy_3gram_rep_max"]
|
|
eos_threshold = _SFT_TARGETS["eos_rate_min"]
|
|
greedy_3gram_verdict = "PASS" if greedy_3gram is not None and greedy_3gram < rep_threshold else "FAIL"
|
|
greedy_4gram_verdict = "PASS" if greedy_4gram is not None and greedy_4gram < 0.05 else "FAIL"
|
|
eos_verdict = "PASS" if eos_rate is not None and eos_rate >= eos_threshold else "FAIL"
|
|
lines.append(f"| Greedy 3-gram 반복률 | {_BASE_GEN_REFERENCE['greedy_3gram_rep']:.2%} | "
|
|
f"{_fmt_pct(greedy_3gram)} | < {rep_threshold:.0%} | {greedy_3gram_verdict} |")
|
|
lines.append(f"| Greedy 4-gram 반복률 | {_BASE_GEN_REFERENCE['greedy_4gram_rep']:.2%} | "
|
|
f"{_fmt_pct(greedy_4gram)} | < 5% | {greedy_4gram_verdict} |")
|
|
lines.append(f"| EOS 종료율 | {_BASE_GEN_REFERENCE['greedy_eos_rate']:.0%} | "
|
|
f"{_fmt_pct(eos_rate)} | > {eos_threshold:.0%} | {eos_verdict} |")
|
|
|
|
sampled_3gram = sft_summary.get("sampled_avg_3gram_rep")
|
|
sampled_eos = sft_summary.get("sampled_eos_rate")
|
|
if sampled_3gram is not None:
|
|
lines.append(f"| Sampled 3-gram 반복률 | — | {sampled_3gram:.2%} | — | — |")
|
|
if sampled_eos is not None:
|
|
lines.append(f"| Sampled EOS 종료율 | — | {sampled_eos:.2%} | — | — |")
|
|
lines.append("")
|
|
|
|
# Chat template status
|
|
chat_status = "활성화" if sft_summary else "비활성화"
|
|
lines.append(f"**Chat Template**: {chat_status}\n")
|
|
|
|
# Generation samples
|
|
if sft_gen.get("samples"):
|
|
lines.append("### 생성 샘플 (Greedy, Chat Template)\n")
|
|
greedy_samples = [s for s in sft_gen["samples"] if s.get("temperature") == 0.0]
|
|
for i, s in enumerate(greedy_samples[:5], 1):
|
|
prompt = s.get("prompt", "")
|
|
text = s.get("text", "")[:400]
|
|
hit_eos = s.get("hit_eos", False)
|
|
rep3 = s.get("3gram_rep", 0)
|
|
lines.append(f"**[{i}]** `{prompt}`")
|
|
lines.append(f"> {text}")
|
|
lines.append(f"> *EOS={hit_eos}, 3gram_rep={rep3:.2%}, tokens={s.get('generated_tokens', 0)}*\n")
|
|
|
|
# Repetition grid
|
|
sft_rep = sft_p1.get("repetition", {})
|
|
if sft_rep.get("grid_results"):
|
|
lines.append("### Repetition 파라미터 검색 결과\n")
|
|
lines.append("| 설정 | 3-gram | EOS Rate | Avg Tokens |")
|
|
lines.append("|------|--------|----------|-----------|")
|
|
grid = sft_rep["grid_results"]
|
|
items = grid if isinstance(grid, list) else list(grid.values())
|
|
for r in items[:6]:
|
|
if isinstance(r, dict):
|
|
lines.append(
|
|
f"| {r.get('params', '?')} | "
|
|
f"{_fmt_f(r.get('avg_3gram_rep'))} | "
|
|
f"{_fmt_f(r.get('eos_rate'))} | "
|
|
f"{_fmt_f(r.get('avg_tokens'), 1)} |"
|
|
)
|
|
lines.append("")
|
|
|
|
# === 4. Korean Benchmarks ===
|
|
lines.append("## 4. 한국어 벤치마크\n")
|
|
lines.append("### KoBEST (0-shot)\n")
|
|
lines.append("| 태스크 | Base | SFT | 변화 | 목표 | 판정 |")
|
|
lines.append("|--------|------|-----|------|------|------|")
|
|
|
|
kobest_tasks = ["kobest_boolq", "kobest_copa", "kobest_hellaswag",
|
|
"kobest_sentineg", "kobest_wic"]
|
|
kobest_targets = {"kobest_boolq": 0.60, "kobest_copa": 0.65,
|
|
"kobest_hellaswag": 0.30, "kobest_sentineg": 0.60,
|
|
"kobest_wic": 0.55}
|
|
sft_kobest_accs = []
|
|
base_kobest_accs = []
|
|
|
|
for t in kobest_tasks:
|
|
base_a = _get_acc(base_zero.get(t, {})) if t in base_zero else _BASE_BENCH_REFERENCE.get(t)
|
|
sft_a = _get_acc(sft_zero.get(t, {})) if t in sft_zero else None
|
|
target = kobest_targets.get(t, 0.50)
|
|
|
|
if sft_a is not None:
|
|
sft_kobest_accs.append(sft_a)
|
|
if base_a is not None:
|
|
base_kobest_accs.append(base_a)
|
|
|
|
diff = ""
|
|
verdict = "—"
|
|
if sft_a is not None and base_a is not None:
|
|
d = (sft_a - base_a) * 100
|
|
diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
verdict = "PASS" if sft_a >= target else "FAIL"
|
|
|
|
lines.append(f"| {t} | {_fmt_pct(base_a)} | {_fmt_pct(sft_a)} | {diff} | "
|
|
f"≥{target*100:.0f}% | {verdict} |")
|
|
|
|
if sft_kobest_accs:
|
|
sft_avg = sum(sft_kobest_accs) / len(sft_kobest_accs)
|
|
base_avg = sum(base_kobest_accs) / len(base_kobest_accs) if base_kobest_accs else _BASE_BENCH_REFERENCE.get("kobest_avg", 0.4369)
|
|
diff_avg = (sft_avg - base_avg) * 100
|
|
lines.append(f"| **평균** | **{base_avg*100:.2f}%** | **{sft_avg*100:.2f}%** | "
|
|
f"**{'+' if diff_avg >= 0 else ''}{diff_avg:.1f}pp** | "
|
|
f"**≥{_SFT_TARGETS['kobest_avg_min']*100:.0f}%** | **{'PASS' if sft_avg >= _SFT_TARGETS['kobest_avg_min'] else 'FAIL'}** |")
|
|
lines.append("")
|
|
|
|
# HAE-RAE
|
|
lines.append("### HAE-RAE (0-shot)\n")
|
|
base_haerae = _get_acc(base_zero.get("haerae", {})) if "haerae" in base_zero else _BASE_BENCH_REFERENCE.get("haerae")
|
|
sft_haerae = _get_acc(sft_zero.get("haerae", {})) if "haerae" in sft_zero else None
|
|
if sft_haerae is not None:
|
|
diff_h = (sft_haerae - (base_haerae or 0)) * 100 if base_haerae else 0
|
|
lines.append(f"- Base: {_fmt_pct(base_haerae)} → SFT: {_fmt_pct(sft_haerae)} "
|
|
f"({'+' if diff_h >= 0 else ''}{diff_h:.1f}pp) | "
|
|
f"목표 ≥{_SFT_TARGETS['haerae_min']*100:.0f}% | {'PASS' if sft_haerae >= _SFT_TARGETS['haerae_min'] else 'FAIL'}")
|
|
else:
|
|
lines.append(f"- Base: {_fmt_pct(base_haerae)} → SFT: N/A")
|
|
lines.append("")
|
|
|
|
# MMLU-KO
|
|
lines.append("### MMLU-KO (0-shot)\n")
|
|
base_mmlu_ko = _get_acc(base_zero.get("global_mmlu_ko", {})) if "global_mmlu_ko" in base_zero else _BASE_BENCH_REFERENCE.get("global_mmlu_ko")
|
|
sft_mmlu_ko = _get_acc(sft_zero.get("global_mmlu_ko", {})) if "global_mmlu_ko" in sft_zero else None
|
|
if sft_mmlu_ko is not None:
|
|
diff_mk = (sft_mmlu_ko - (base_mmlu_ko or 0)) * 100 if base_mmlu_ko else 0
|
|
lines.append(f"- Base: {_fmt_pct(base_mmlu_ko)} → SFT: {_fmt_pct(sft_mmlu_ko)} "
|
|
f"({'+' if diff_mk >= 0 else ''}{diff_mk:.1f}pp) | "
|
|
f"목표 ≥{_SFT_TARGETS['mmlu_ko_min']*100:.0f}% | {'PASS' if sft_mmlu_ko >= _SFT_TARGETS['mmlu_ko_min'] else 'FAIL'}")
|
|
else:
|
|
lines.append(f"- Base: {_fmt_pct(base_mmlu_ko)} → SFT: N/A")
|
|
lines.append("")
|
|
|
|
# 5-shot comparison
|
|
if sft_five:
|
|
lines.append("### 5-shot 비교 (한국어)\n")
|
|
lines.append("| 태스크 | 0-shot | 5-shot | 변화 |")
|
|
lines.append("|--------|--------|--------|------|")
|
|
for t in kobest_tasks + ["haerae", "global_mmlu_ko"]:
|
|
a0 = _get_acc(sft_zero.get(t, {})) if t in sft_zero else None
|
|
a5 = _get_acc(sft_five.get(t, {})) if t in sft_five else None
|
|
if a0 is not None and a5 is not None:
|
|
d = (a5 - a0) * 100
|
|
lines.append(f"| {t} | {a0*100:.2f}% | {a5*100:.2f}% | {'+' if d >= 0 else ''}{d:.1f}pp |")
|
|
lines.append("")
|
|
|
|
# === 5. English Benchmarks ===
|
|
lines.append("## 5. 영어 벤치마크 (유지 확인)\n")
|
|
lines.append("| 태스크 | Base | SFT | 변화 | 하한 | 판정 |")
|
|
lines.append("|--------|------|-----|------|------|------|")
|
|
|
|
en_tasks = {
|
|
"hellaswag": _SFT_TARGETS["hellaswag_min"],
|
|
"arc_easy": _SFT_TARGETS["arc_easy_min"],
|
|
"arc_challenge": _SFT_TARGETS["arc_challenge_min"],
|
|
"winogrande": _SFT_TARGETS["winogrande_min"],
|
|
"piqa": _SFT_TARGETS["piqa_min"],
|
|
}
|
|
for t, threshold in en_tasks.items():
|
|
base_a = _get_acc(base_zero.get(t, {}), prefer_norm=(t in ["hellaswag", "arc_challenge"])) \
|
|
if t in base_zero else _BASE_BENCH_REFERENCE.get(t)
|
|
sft_a = _get_acc(sft_zero.get(t, {}), prefer_norm=(t in ["hellaswag", "arc_challenge"])) \
|
|
if t in sft_zero else None
|
|
diff = ""
|
|
verdict = "—"
|
|
if sft_a is not None and base_a is not None:
|
|
d = (sft_a - base_a) * 100
|
|
diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
verdict = "PASS" if sft_a >= threshold else "FAIL"
|
|
lines.append(f"| {t} | {_fmt_pct(base_a)} | {_fmt_pct(sft_a)} | {diff} | "
|
|
f"≥{threshold*100:.0f}% | {verdict} |")
|
|
|
|
# MMLU-EN
|
|
_MMLU_EN_GROUPS = {"mmlu", "mmlu_humanities", "mmlu_social_sciences", "mmlu_stem", "mmlu_other"}
|
|
sft_mmlu_en = []
|
|
base_mmlu_en = []
|
|
for t, m in sft_zero.items():
|
|
if (t.startswith("mmlu_") or t == "mmlu") and t not in _MMLU_EN_GROUPS:
|
|
a = _get_acc(m)
|
|
if a is not None:
|
|
sft_mmlu_en.append(a)
|
|
if not sft_mmlu_en:
|
|
for t in _MMLU_EN_GROUPS:
|
|
if t in sft_zero:
|
|
a = _get_acc(sft_zero[t])
|
|
if a is not None:
|
|
sft_mmlu_en.append(a)
|
|
for t, m in base_zero.items():
|
|
if (t.startswith("mmlu_") or t == "mmlu") and t not in _MMLU_EN_GROUPS:
|
|
a = _get_acc(m)
|
|
if a is not None:
|
|
base_mmlu_en.append(a)
|
|
if not base_mmlu_en:
|
|
for t in _MMLU_EN_GROUPS:
|
|
if t in base_zero:
|
|
a = _get_acc(base_zero[t])
|
|
if a is not None:
|
|
base_mmlu_en.append(a)
|
|
|
|
sft_mmlu_en_avg = sum(sft_mmlu_en) / len(sft_mmlu_en) if sft_mmlu_en else None
|
|
base_mmlu_en_avg = sum(base_mmlu_en) / len(base_mmlu_en) if base_mmlu_en else 0.2581
|
|
if sft_mmlu_en_avg is not None:
|
|
d = (sft_mmlu_en_avg - base_mmlu_en_avg) * 100
|
|
lines.append(f"| MMLU-EN 평균 | {base_mmlu_en_avg*100:.2f}% | {sft_mmlu_en_avg*100:.2f}% | "
|
|
f"{'+' if d >= 0 else ''}{d:.1f}pp | ≥25% | "
|
|
f"{'PASS' if sft_mmlu_en_avg >= _SFT_TARGETS['mmlu_en_avg_min'] else 'FAIL'} |")
|
|
lines.append("")
|
|
|
|
# === 6. Calibration ===
|
|
lines.append("## 6. Calibration 비교\n")
|
|
sft_cal = sft_p1.get("calibration", {})
|
|
lines.append("| 지표 | Base | SFT | 목표 | 판정 |")
|
|
lines.append("|------|------|-----|------|------|")
|
|
|
|
cal_checks = [
|
|
("top1_accuracy", "Top-1 Accuracy", _SFT_TARGETS["top1_accuracy_min"], True),
|
|
("top5_accuracy", "Top-5 Accuracy", 0.78, True),
|
|
("top10_accuracy", "Top-10 Accuracy", 0.82, True),
|
|
("mean_entropy", "Mean Entropy", 2.0, False),
|
|
]
|
|
for key, label, threshold, is_higher_better in cal_checks:
|
|
base_v = _BASE_CALIB_REFERENCE.get(key)
|
|
sft_v = sft_cal.get(key)
|
|
verdict = "—"
|
|
if sft_v is not None:
|
|
if is_higher_better:
|
|
verdict = "PASS" if sft_v >= threshold else "FAIL"
|
|
else:
|
|
verdict = "PASS" if sft_v <= threshold else "FAIL"
|
|
lines.append(f"| {label} | {_fmt_f(base_v)} | {_fmt_f(sft_v)} | "
|
|
f"{'≥' if is_higher_better else '<'}{threshold} | {verdict} |")
|
|
|
|
# Token NLL
|
|
sft_nll = sft_p1.get("token_nll", {})
|
|
nll_mean = sft_nll.get("nll_mean", sft_nll.get("mean"))
|
|
base_nll_mean = _BASE_NLL_REFERENCE.get("nll_mean")
|
|
if nll_mean is not None:
|
|
lines.append(f"| Token NLL mean | {_fmt_f(base_nll_mean)} | {_fmt_f(nll_mean)} | "
|
|
f"< 2.0 | {'PASS' if nll_mean < 2.0 else 'FAIL'} |")
|
|
hlf5 = sft_nll.get("high_loss_fractions", {}).get("5", sft_nll.get("high_loss_fraction_5"))
|
|
base_hlf5 = _BASE_NLL_REFERENCE.get("high_loss_fraction_5")
|
|
if hlf5 is not None:
|
|
lines.append(f"| NLL > 5 비율 | {_fmt_f(base_hlf5)} | {_fmt_f(hlf5)} | "
|
|
f"< 0.15 | {'PASS' if hlf5 < 0.15 else 'FAIL'} |")
|
|
lines.append("")
|
|
|
|
# === 7. Final Verdict ===
|
|
lines.append("## 7. 종합 판정 및 다음 단계\n")
|
|
|
|
lines.append("### 핵심 판정 기준\n")
|
|
lines.append("| 조건 | 현재 값 | 기준 | 충족 |")
|
|
lines.append("|------|---------|------|------|")
|
|
|
|
rep_val = rep_rate
|
|
lines.append(f"| Greedy 3-gram 반복률 | {_fmt_pct(rep_val)} | < {_SFT_TARGETS['greedy_3gram_rep_max']:.0%} | "
|
|
f"{'YES' if rep_val is not None and rep_val < _SFT_TARGETS['greedy_3gram_rep_max'] else 'NO'} |")
|
|
lines.append(f"| KoBEST 평균 | {_fmt_pct(kobest_avg)} | > {_SFT_TARGETS['kobest_avg_min']*100:.0f}% | "
|
|
f"{'YES' if kobest_avg is not None and kobest_avg > _SFT_TARGETS['kobest_avg_min'] else 'NO'} |")
|
|
lines.append(f"| 최대 Forgetting | {f'{max_forgetting:.1f}%' if max_forgetting is not None else 'N/A'} | "
|
|
f"< {_SFT_TARGETS['ppl_forgetting_max_pct']}% | {'YES' if max_forgetting is not None and max_forgetting < _SFT_TARGETS['ppl_forgetting_max_pct'] else 'NO'} |")
|
|
lines.append("")
|
|
|
|
# Final recommendation — use ORPO quantitative score for decision
|
|
lines.append("### 권고\n")
|
|
orpo_result = _compute_orpo_score(sft_p1, sft_zero, base_p1_norm, base_zero)
|
|
orpo_score = orpo_result["total_score"]
|
|
orpo_decision = orpo_result["decision"]
|
|
|
|
all_core_pass = (
|
|
rep_rate is not None and rep_rate < _SFT_TARGETS["greedy_3gram_rep_max"]
|
|
and kobest_avg is not None and kobest_avg > _SFT_TARGETS["kobest_avg_min"]
|
|
and max_forgetting is not None and max_forgetting < _SFT_TARGETS["ppl_forgetting_max_pct"]
|
|
)
|
|
|
|
if all_core_pass:
|
|
lines.append("**모든 핵심 조건 충족 → Phase 4: GGUF 변환 + Ollama 배포 진행**\n")
|
|
elif orpo_decision == "ORPO":
|
|
lines.append(f"**ORPO 판정 스코어 {orpo_score:.1f}/100 → Phase 3: ORPO 학습 진행** (795K preference pairs 활용)\n")
|
|
lines.append("ORPO 학습 시 주안점:")
|
|
lines.append("- Greedy 반복률 감소 (현재 72.97% → 목표 <5%)")
|
|
lines.append("- EOS 종료율 개선 (현재 60% → 목표 >90%)")
|
|
lines.append("- 벤치마크 점수 유지/향상")
|
|
lines.append("- 지식 보존 유지 (현재 forgetting 0.9%)")
|
|
elif orpo_decision == "SKIP_ORPO":
|
|
lines.append("**ORPO 불필요 → Phase 4: GGUF 변환 + Ollama 배포 진행**\n")
|
|
else:
|
|
lines.append("**핵심 조건 미달 → SFT 재시도**\n")
|
|
lines.append("재시도 시 검토 사항:")
|
|
lines.append("- 학습률 조정")
|
|
lines.append("- 데이터 구성 재검토")
|
|
lines.append("- 에폭 수 조정")
|
|
lines.append("")
|
|
|
|
lines.append("---\n")
|
|
lines.append("*이 보고서는 `eval/sft_eval_pipeline.py`에 의해 자동 생성되었습니다.*")
|
|
|
|
report_text = "\n".join(lines)
|
|
output_path.write_text(report_text, encoding="utf-8")
|
|
|
|
# Also save to sft_output_dir if provided
|
|
if sft_output_dir:
|
|
(Path(sft_output_dir) / "sft_comparison_report.md").write_text(report_text, encoding="utf-8")
|
|
|
|
return output_path
|
|
|
|
|
|
def _compute_verdicts(sft_p1, sft_zero, base_p1, base_zero):
|
|
"""Compute pass/fail verdicts for each of the 6 evaluation dimensions."""
|
|
verdicts = []
|
|
|
|
# Dim 1: PPL forgetting
|
|
max_forgetting = _get_max_forgetting(sft_p1, base_p1)
|
|
if max_forgetting is not None:
|
|
verdicts.append((
|
|
"차원 1: Perplexity (지식 보존)",
|
|
max_forgetting < _SFT_TARGETS["ppl_forgetting_max_pct"],
|
|
f"최대 forgetting {max_forgetting:.1f}% (임계값 {_SFT_TARGETS['ppl_forgetting_max_pct']}%)",
|
|
))
|
|
else:
|
|
verdicts.append(("차원 1: Perplexity (지식 보존)", False, "데이터 없음"))
|
|
|
|
# Dim 2: Generation quality
|
|
rep_rate = _get_greedy_3gram_rep(sft_p1)
|
|
eos_rate = sft_p1.get("generation", {}).get("summary", {}).get("greedy_eos_rate")
|
|
if rep_rate is not None and eos_rate is not None:
|
|
gen_pass = rep_rate < _SFT_TARGETS["greedy_3gram_rep_max"] and eos_rate > _SFT_TARGETS["eos_rate_min"]
|
|
verdicts.append((
|
|
"차원 2: 생성 품질",
|
|
gen_pass,
|
|
f"반복률 {rep_rate:.2%} (목표 <{_SFT_TARGETS['greedy_3gram_rep_max']:.0%}), EOS {eos_rate:.0%} (목표 >{_SFT_TARGETS['eos_rate_min']:.0%})",
|
|
))
|
|
else:
|
|
verdicts.append(("차원 2: 생성 품질", False, "데이터 없음"))
|
|
|
|
# Dim 3: Korean benchmarks
|
|
kobest_avg = _get_kobest_avg(sft_zero)
|
|
if kobest_avg is not None:
|
|
verdicts.append((
|
|
"차원 3: 한국어 벤치마크",
|
|
kobest_avg > _SFT_TARGETS["kobest_avg_min"],
|
|
f"KoBEST 평균 {kobest_avg*100:.2f}% (목표 >{_SFT_TARGETS['kobest_avg_min']*100:.0f}%)",
|
|
))
|
|
else:
|
|
verdicts.append(("차원 3: 한국어 벤치마크", False, "데이터 없음"))
|
|
|
|
# Dim 4: English benchmarks
|
|
en_tasks = {
|
|
"hellaswag": _SFT_TARGETS["hellaswag_min"],
|
|
"arc_easy": _SFT_TARGETS["arc_easy_min"],
|
|
"arc_challenge": _SFT_TARGETS["arc_challenge_min"],
|
|
"winogrande": _SFT_TARGETS["winogrande_min"],
|
|
"piqa": _SFT_TARGETS["piqa_min"],
|
|
}
|
|
en_pass = True
|
|
en_detail_parts = []
|
|
for t, threshold in en_tasks.items():
|
|
a = _get_acc(sft_zero.get(t, {})) if t in sft_zero else None
|
|
if a is not None:
|
|
if a < threshold:
|
|
en_pass = False
|
|
en_detail_parts.append(f"{t}={a*100:.1f}%")
|
|
if en_detail_parts:
|
|
verdicts.append((
|
|
"차원 4: 영어 벤치마크",
|
|
en_pass,
|
|
", ".join(en_detail_parts[:3]) + ("..." if len(en_detail_parts) > 3 else ""),
|
|
))
|
|
else:
|
|
verdicts.append(("차원 4: 영어 벤치마크", False, "데이터 없음"))
|
|
|
|
# Dim 5: Calibration
|
|
cal = sft_p1.get("calibration", {})
|
|
top1 = cal.get("top1_accuracy")
|
|
if top1 is not None:
|
|
cal_pass = top1 >= _SFT_TARGETS["top1_accuracy_min"]
|
|
verdicts.append((
|
|
"차원 5: Calibration",
|
|
cal_pass,
|
|
f"Top-1 {top1*100:.2f}% (목표 ≥{_SFT_TARGETS['top1_accuracy_min']*100:.0f}%)",
|
|
))
|
|
else:
|
|
verdicts.append(("차원 5: Calibration", False, "데이터 없음"))
|
|
|
|
# Dim 6: SFT-specific (chat quality) — based on generation + EOS
|
|
if eos_rate is not None:
|
|
chat_pass = eos_rate > 0.50 # relaxed threshold for chat
|
|
verdicts.append((
|
|
"차원 6: SFT Chat 능력",
|
|
chat_pass,
|
|
f"EOS 종료율 {eos_rate:.0%}, 생성 샘플 수동 검토 필요",
|
|
))
|
|
else:
|
|
verdicts.append(("차원 6: SFT Chat 능력", False, "데이터 없음"))
|
|
|
|
return verdicts
|
|
|
|
|
|
def _get_greedy_3gram_rep(p1: dict) -> Optional[float]:
|
|
gen = p1.get("generation", {})
|
|
return gen.get("summary", {}).get("greedy_avg_3gram_rep")
|
|
|
|
|
|
def _get_kobest_avg(zero_shot: dict) -> Optional[float]:
|
|
kobest_tasks = ["kobest_boolq", "kobest_copa", "kobest_hellaswag",
|
|
"kobest_sentineg", "kobest_wic"]
|
|
accs = []
|
|
for t in kobest_tasks:
|
|
if t in zero_shot:
|
|
a = _get_acc(zero_shot[t])
|
|
if a is not None:
|
|
accs.append(a)
|
|
return sum(accs) / len(accs) if accs else None
|
|
|
|
|
|
def _get_max_forgetting(sft_p1: dict, base_p1: dict) -> Optional[float]:
|
|
sft_ppl = sft_p1.get("perplexity", {})
|
|
base_ppl = base_p1.get("perplexity", {})
|
|
forgetting_values = []
|
|
for name in sft_ppl:
|
|
sft_val = sft_ppl[name].get("ppl") if isinstance(sft_ppl[name], dict) else None
|
|
base_val = base_ppl.get(name, {}).get("ppl") if isinstance(base_ppl.get(name), dict) else None
|
|
if base_val is None:
|
|
base_val = _BASE_PPL_REFERENCE.get(name)
|
|
if sft_val is not None and base_val is not None and base_val > 0:
|
|
forgetting_values.append((sft_val - base_val) / base_val * 100)
|
|
return max(forgetting_values) if forgetting_values else None
|
|
|
|
|
|
# =========================================================================
|
|
# ORPO-specific verdict helpers
|
|
# =========================================================================
|
|
|
|
def _compute_orpo_verdicts(
|
|
orpo_p1: dict,
|
|
orpo_zero: dict,
|
|
sft_p1: dict,
|
|
sft_zero: dict,
|
|
training_curve: Optional[dict] = None,
|
|
) -> List[Tuple[str, bool, str]]:
|
|
"""Compute the 4 ORPO-specific evaluation dimensions.
|
|
|
|
Returns list of (dimension_name, pass_bool, detail_string).
|
|
"""
|
|
verdicts: List[Tuple[str, bool, str]] = []
|
|
|
|
# ORPO Dim 1: Preference Accuracy (final > 0.65)
|
|
pref_acc = None
|
|
if training_curve and training_curve.get("eval_steps"):
|
|
last_step = training_curve["eval_steps"][-1]
|
|
pref_acc = last_step.get("rewards_accuracies", last_step.get("preference_accuracy"))
|
|
if pref_acc is not None:
|
|
verdicts.append((
|
|
"ORPO-1: Preference Accuracy",
|
|
pref_acc > 0.65,
|
|
f"최종 {pref_acc:.2%} (목표 > 65%)",
|
|
))
|
|
else:
|
|
verdicts.append(("ORPO-1: Preference Accuracy", False, "데이터 없음"))
|
|
|
|
# ORPO Dim 2: Reward Margins (final > 0.1)
|
|
reward_margin = None
|
|
if training_curve and training_curve.get("eval_steps"):
|
|
last_step = training_curve["eval_steps"][-1]
|
|
reward_margin = last_step.get("rewards_margins", last_step.get("reward_margins"))
|
|
if reward_margin is not None:
|
|
verdicts.append((
|
|
"ORPO-2: Reward Margins",
|
|
reward_margin > 0.1,
|
|
f"최종 {reward_margin:.4f} (목표 > 0.1)",
|
|
))
|
|
else:
|
|
verdicts.append(("ORPO-2: Reward Margins", False, "데이터 없음"))
|
|
|
|
# ORPO Dim 3: Parameter Sensitivity (greedy rep < 5% with rep_penalty=1.0)
|
|
rep_grid = orpo_p1.get("repetition", {}).get("grid_results")
|
|
param_sens_pass = False
|
|
param_sens_detail = "데이터 없음"
|
|
if rep_grid:
|
|
items = rep_grid if isinstance(rep_grid, list) else list(rep_grid.values())
|
|
for r in items:
|
|
if isinstance(r, dict):
|
|
rp = r.get("repetition_penalty", r.get("rep_penalty"))
|
|
if rp is not None and abs(float(rp) - 1.0) < 1e-6:
|
|
rep_val = r.get("avg_3gram_rep", r.get("3gram_repetition"))
|
|
if rep_val is not None:
|
|
param_sens_pass = rep_val < 0.05
|
|
param_sens_detail = f"rep_penalty=1.0 시 3-gram rep={rep_val:.2%} (목표 < 5%)"
|
|
break
|
|
verdicts.append((
|
|
"ORPO-3: Parameter Sensitivity",
|
|
param_sens_pass,
|
|
param_sens_detail,
|
|
))
|
|
|
|
# ORPO Dim 4: SFT→ORPO Improvement (rep decreased AND EOS increased)
|
|
sft_rep = _get_greedy_3gram_rep(sft_p1)
|
|
orpo_rep = _get_greedy_3gram_rep(orpo_p1)
|
|
sft_eos = sft_p1.get("generation", {}).get("summary", {}).get("greedy_eos_rate")
|
|
orpo_eos = orpo_p1.get("generation", {}).get("summary", {}).get("greedy_eos_rate")
|
|
|
|
if all(v is not None for v in [sft_rep, orpo_rep, sft_eos, orpo_eos]):
|
|
rep_improved = orpo_rep < sft_rep
|
|
eos_improved = orpo_eos > sft_eos
|
|
verdicts.append((
|
|
"ORPO-4: SFT→ORPO 개선",
|
|
rep_improved and eos_improved,
|
|
f"반복률 {sft_rep:.2%}→{orpo_rep:.2%} ({'↓' if rep_improved else '↑'}), "
|
|
f"EOS {sft_eos:.0%}→{orpo_eos:.0%} ({'↑' if eos_improved else '↓'})",
|
|
))
|
|
else:
|
|
verdicts.append(("ORPO-4: SFT→ORPO 개선", False, "데이터 없음"))
|
|
|
|
return verdicts
|
|
|
|
|
|
# =========================================================================
|
|
# Base vs SFT vs ORPO 3-way Comparison Report
|
|
# =========================================================================
|
|
|
|
def generate_three_way_report(
|
|
base_results_dir: Path,
|
|
sft_results_dir: Path,
|
|
orpo_phase1_results: dict,
|
|
orpo_phase2_results: dict,
|
|
output_path: Path,
|
|
orpo_output_dir: Optional[Path] = None,
|
|
training_curve: Optional[dict] = None,
|
|
total_elapsed_sec: float = 0.0,
|
|
) -> Path:
|
|
"""Generate a comprehensive Base vs SFT vs ORPO 3-way comparison report.
|
|
|
|
Args:
|
|
base_results_dir: Directory containing Base model's phase1/phase2_results.json
|
|
sft_results_dir: Directory containing SFT model's phase1/phase2_results.json
|
|
orpo_phase1_results: ORPO Phase 1 results dict
|
|
orpo_phase2_results: ORPO Phase 2 results dict
|
|
output_path: Where to write the markdown report
|
|
orpo_output_dir: ORPO eval outputs directory (for linking)
|
|
training_curve: Dict with "eval_steps" list of per-step metrics
|
|
total_elapsed_sec: Total pipeline elapsed time
|
|
|
|
Returns:
|
|
Path to the generated report
|
|
"""
|
|
base_results_dir = Path(base_results_dir)
|
|
sft_results_dir = Path(sft_results_dir)
|
|
output_path = Path(output_path)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# --- Load Base results ---
|
|
base_p1_raw, base_p2_raw = {}, {}
|
|
p1_file = base_results_dir / "phase1_results.json"
|
|
p2_file = base_results_dir / "phase2_results.json"
|
|
if p1_file.exists():
|
|
with open(p1_file, encoding="utf-8") as f:
|
|
base_p1_raw = json.load(f)
|
|
if p2_file.exists():
|
|
with open(p2_file, encoding="utf-8") as f:
|
|
base_p2_raw = json.load(f)
|
|
|
|
# --- Load SFT results ---
|
|
sft_p1_raw, sft_p2_raw = {}, {}
|
|
sft_p1_file = sft_results_dir / "phase1_results.json"
|
|
sft_p2_file = sft_results_dir / "phase2_results.json"
|
|
if sft_p1_file.exists():
|
|
with open(sft_p1_file, encoding="utf-8") as f:
|
|
sft_p1_raw = json.load(f)
|
|
if sft_p2_file.exists():
|
|
with open(sft_p2_file, encoding="utf-8") as f:
|
|
sft_p2_raw = json.load(f)
|
|
|
|
# --- Normalize all ---
|
|
base_p1 = _normalize_phase1_results(base_p1_raw)
|
|
base_zero, base_five = _normalize_phase2_results(base_p2_raw)
|
|
sft_p1 = _normalize_phase1_results(sft_p1_raw)
|
|
sft_zero, sft_five = _normalize_phase2_results(sft_p2_raw)
|
|
orpo_p1 = _normalize_phase1_results(orpo_phase1_results)
|
|
orpo_zero, orpo_five = _normalize_phase2_results(orpo_phase2_results)
|
|
|
|
eval_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
lines: List[str] = []
|
|
|
|
# =====================================================================
|
|
# Header
|
|
# =====================================================================
|
|
lines.append("# FRANKENSTALLM 3B ORPO 모델 종합 평가 보고서\n")
|
|
lines.append(f"- **평가 일시**: {eval_datetime}")
|
|
lines.append(f"- **비교 대상**: Base → SFT → ORPO")
|
|
lines.append(f"- **총 소요 시간**: {_fmt_seconds(total_elapsed_sec)}")
|
|
if orpo_output_dir:
|
|
lines.append(f"- **결과 디렉토리**: {orpo_output_dir}")
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 1. Executive Summary
|
|
# =====================================================================
|
|
lines.append("## 1. Executive Summary\n")
|
|
|
|
# 6 standard verdicts (reuse existing)
|
|
std_verdicts = _compute_verdicts(orpo_p1, orpo_zero, base_p1, base_zero)
|
|
# 4 ORPO-specific verdicts
|
|
orpo_verdicts = _compute_orpo_verdicts(orpo_p1, orpo_zero, sft_p1, sft_zero, training_curve)
|
|
|
|
all_verdicts = std_verdicts + orpo_verdicts
|
|
|
|
lines.append("| # | 평가 차원 | 결과 | 상세 |")
|
|
lines.append("|---|----------|------|------|")
|
|
for i, (dim_name, verdict, detail) in enumerate(all_verdicts, 1):
|
|
icon = "PASS" if verdict else "FAIL"
|
|
lines.append(f"| {i} | {dim_name} | **{icon}** | {detail} |")
|
|
lines.append("")
|
|
|
|
pass_count = sum(1 for _, v, _ in all_verdicts if v)
|
|
total_dims = len(all_verdicts)
|
|
lines.append(f"**종합**: {pass_count}/{total_dims} 차원 통과\n")
|
|
|
|
# Quantitative score (reuse _compute_orpo_score with ORPO results)
|
|
orpo_score_result = _compute_orpo_score(orpo_p1, orpo_zero, base_p1, base_zero)
|
|
lines.append(f"**정량 스코어**: {orpo_score_result['total_score']}/100\n")
|
|
|
|
# Final decision
|
|
orpo_rep = _get_greedy_3gram_rep(orpo_p1)
|
|
orpo_eos = orpo_p1.get("generation", {}).get("summary", {}).get("greedy_eos_rate")
|
|
orpo_forgetting = _get_max_forgetting(orpo_p1, base_p1)
|
|
orpo_kobest = _get_kobest_avg(orpo_zero)
|
|
|
|
deploy_criteria_met = (
|
|
orpo_rep is not None and orpo_rep < 0.05
|
|
and orpo_eos is not None and orpo_eos > 0.90
|
|
and orpo_forgetting is not None and orpo_forgetting < 5.0
|
|
and orpo_kobest is not None and orpo_kobest >= 0.43
|
|
)
|
|
final_decision = "DEPLOY" if deploy_criteria_met else "RETRY"
|
|
lines.append(f"**최종 판정**: **{final_decision}**\n")
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 2. 학습 곡선 분석
|
|
# =====================================================================
|
|
lines.append("## 2. 학습 곡선 분석\n")
|
|
if training_curve and training_curve.get("eval_steps"):
|
|
eval_steps = training_curve["eval_steps"]
|
|
|
|
lines.append("### Training / Eval Loss\n")
|
|
lines.append("| Step | Train Loss | Eval Loss | Pref Accuracy | Reward Margin |")
|
|
lines.append("|------|-----------|-----------|---------------|---------------|")
|
|
for step_data in eval_steps:
|
|
step = step_data.get("step", "?")
|
|
train_loss = _fmt_f(step_data.get("train_loss", step_data.get("loss")), 4)
|
|
eval_loss = _fmt_f(step_data.get("eval_loss"), 4)
|
|
pref_acc = _fmt_f(step_data.get("rewards_accuracies", step_data.get("preference_accuracy")), 4)
|
|
reward_m = _fmt_f(step_data.get("rewards_margins", step_data.get("reward_margins")), 4)
|
|
lines.append(f"| {step} | {train_loss} | {eval_loss} | {pref_acc} | {reward_m} |")
|
|
lines.append("")
|
|
|
|
# Summary stats
|
|
first_step = eval_steps[0]
|
|
last_step = eval_steps[-1]
|
|
lines.append("### 학습 곡선 요약\n")
|
|
first_loss = first_step.get("train_loss", first_step.get("loss"))
|
|
last_loss = last_step.get("train_loss", last_step.get("loss"))
|
|
if first_loss is not None and last_loss is not None:
|
|
lines.append(f"- **Train Loss**: {first_loss:.4f} → {last_loss:.4f}")
|
|
first_eval = first_step.get("eval_loss")
|
|
last_eval = last_step.get("eval_loss")
|
|
if first_eval is not None and last_eval is not None:
|
|
lines.append(f"- **Eval Loss**: {first_eval:.4f} → {last_eval:.4f}")
|
|
last_pref = last_step.get("rewards_accuracies", last_step.get("preference_accuracy"))
|
|
if last_pref is not None:
|
|
lines.append(f"- **최종 Preference Accuracy**: {last_pref:.2%}")
|
|
last_margin = last_step.get("rewards_margins", last_step.get("reward_margins"))
|
|
if last_margin is not None:
|
|
lines.append(f"- **최종 Reward Margin**: {last_margin:.4f}")
|
|
lines.append("")
|
|
else:
|
|
lines.append("학습 곡선 데이터 없음\n")
|
|
|
|
# =====================================================================
|
|
# 3. Perplexity 비교 (지식 보존)
|
|
# =====================================================================
|
|
lines.append("## 3. Perplexity 비교 (지식 보존)\n")
|
|
lines.append("| 데이터셋 | Base PPL | SFT PPL | ORPO PPL | SFT Forgetting | ORPO Forgetting |")
|
|
lines.append("|---------|---------|---------|---------|----------------|-----------------|")
|
|
|
|
base_ppl = base_p1.get("perplexity", {})
|
|
sft_ppl = sft_p1.get("perplexity", {})
|
|
orpo_ppl = orpo_p1.get("perplexity", {})
|
|
|
|
all_ppl_names = sorted(set(
|
|
list(base_ppl.keys()) + list(sft_ppl.keys()) + list(orpo_ppl.keys())
|
|
))
|
|
for name in all_ppl_names:
|
|
base_val = base_ppl.get(name, {}).get("ppl") if isinstance(base_ppl.get(name), dict) else None
|
|
if base_val is None:
|
|
base_val = _BASE_PPL_REFERENCE.get(name)
|
|
sft_val = sft_ppl.get(name, {}).get("ppl") if isinstance(sft_ppl.get(name), dict) else None
|
|
orpo_val = orpo_ppl.get(name, {}).get("ppl") if isinstance(orpo_ppl.get(name), dict) else None
|
|
|
|
sft_forg = f"{(sft_val - base_val) / base_val * 100:+.1f}%" if (sft_val is not None and base_val is not None and base_val > 0) else "—"
|
|
orpo_forg = f"{(orpo_val - base_val) / base_val * 100:+.1f}%" if (orpo_val is not None and base_val is not None and base_val > 0) else "—"
|
|
|
|
lines.append(
|
|
f"| {name} | {_fmt_f(base_val)} | {_fmt_f(sft_val)} | {_fmt_f(orpo_val)} | "
|
|
f"{sft_forg} | {orpo_forg} |"
|
|
)
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 4. 생성 품질 비교
|
|
# =====================================================================
|
|
lines.append("## 4. 생성 품질 비교\n")
|
|
|
|
base_gen_summary = base_p1.get("generation", {}).get("summary", {})
|
|
sft_gen_summary = sft_p1.get("generation", {}).get("summary", {})
|
|
orpo_gen_summary = orpo_p1.get("generation", {}).get("summary", {})
|
|
|
|
base_3gram = base_gen_summary.get("greedy_avg_3gram_rep", _BASE_GEN_REFERENCE.get("greedy_3gram_rep"))
|
|
sft_3gram = sft_gen_summary.get("greedy_avg_3gram_rep")
|
|
orpo_3gram = orpo_gen_summary.get("greedy_avg_3gram_rep")
|
|
|
|
base_4gram = base_gen_summary.get("greedy_avg_4gram_rep", _BASE_GEN_REFERENCE.get("greedy_4gram_rep"))
|
|
sft_4gram = sft_gen_summary.get("greedy_avg_4gram_rep")
|
|
orpo_4gram = orpo_gen_summary.get("greedy_avg_4gram_rep")
|
|
|
|
base_eos = base_gen_summary.get("greedy_eos_rate", _BASE_GEN_REFERENCE.get("greedy_eos_rate"))
|
|
sft_eos_val = sft_gen_summary.get("greedy_eos_rate")
|
|
orpo_eos_val = orpo_gen_summary.get("greedy_eos_rate")
|
|
|
|
lines.append("| 지표 | Base | SFT | ORPO | SFT→ORPO 변화 |")
|
|
lines.append("|------|------|-----|------|---------------|")
|
|
|
|
# 3-gram rep
|
|
sft_orpo_3gram_diff = ""
|
|
if sft_3gram is not None and orpo_3gram is not None:
|
|
d = (orpo_3gram - sft_3gram) * 100
|
|
sft_orpo_3gram_diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
lines.append(f"| Greedy 3-gram 반복률 | {_fmt_pct(base_3gram)} | {_fmt_pct(sft_3gram)} | "
|
|
f"{_fmt_pct(orpo_3gram)} | {sft_orpo_3gram_diff} |")
|
|
|
|
# 4-gram rep
|
|
sft_orpo_4gram_diff = ""
|
|
if sft_4gram is not None and orpo_4gram is not None:
|
|
d = (orpo_4gram - sft_4gram) * 100
|
|
sft_orpo_4gram_diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
lines.append(f"| Greedy 4-gram 반복률 | {_fmt_pct(base_4gram)} | {_fmt_pct(sft_4gram)} | "
|
|
f"{_fmt_pct(orpo_4gram)} | {sft_orpo_4gram_diff} |")
|
|
|
|
# EOS rate
|
|
sft_orpo_eos_diff = ""
|
|
if sft_eos_val is not None and orpo_eos_val is not None:
|
|
d = (orpo_eos_val - sft_eos_val) * 100
|
|
sft_orpo_eos_diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
lines.append(f"| EOS 종료율 | {_fmt_pct(base_eos)} | {_fmt_pct(sft_eos_val)} | "
|
|
f"{_fmt_pct(orpo_eos_val)} | {sft_orpo_eos_diff} |")
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 5. 한국어 벤치마크
|
|
# =====================================================================
|
|
lines.append("## 5. 한국어 벤치마크\n")
|
|
|
|
# KoBEST
|
|
lines.append("### KoBEST (0-shot)\n")
|
|
lines.append("| 태스크 | Base | SFT | ORPO | Base→ORPO |")
|
|
lines.append("|--------|------|-----|------|-----------|")
|
|
|
|
kobest_tasks = ["kobest_boolq", "kobest_copa", "kobest_hellaswag",
|
|
"kobest_sentineg", "kobest_wic"]
|
|
base_kobest_accs, sft_kobest_accs, orpo_kobest_accs = [], [], []
|
|
|
|
for t in kobest_tasks:
|
|
base_a = _get_acc(base_zero.get(t, {})) if t in base_zero else _BASE_BENCH_REFERENCE.get(t)
|
|
sft_a = _get_acc(sft_zero.get(t, {})) if t in sft_zero else None
|
|
orpo_a = _get_acc(orpo_zero.get(t, {})) if t in orpo_zero else None
|
|
|
|
if base_a is not None:
|
|
base_kobest_accs.append(base_a)
|
|
if sft_a is not None:
|
|
sft_kobest_accs.append(sft_a)
|
|
if orpo_a is not None:
|
|
orpo_kobest_accs.append(orpo_a)
|
|
|
|
diff = ""
|
|
if orpo_a is not None and base_a is not None:
|
|
d = (orpo_a - base_a) * 100
|
|
diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
|
|
lines.append(f"| {t} | {_fmt_pct(base_a)} | {_fmt_pct(sft_a)} | {_fmt_pct(orpo_a)} | {diff} |")
|
|
|
|
# Averages
|
|
base_kavg = sum(base_kobest_accs) / len(base_kobest_accs) if base_kobest_accs else None
|
|
sft_kavg = sum(sft_kobest_accs) / len(sft_kobest_accs) if sft_kobest_accs else None
|
|
orpo_kavg = sum(orpo_kobest_accs) / len(orpo_kobest_accs) if orpo_kobest_accs else None
|
|
avg_diff = ""
|
|
if orpo_kavg is not None and base_kavg is not None:
|
|
d = (orpo_kavg - base_kavg) * 100
|
|
avg_diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
lines.append(f"| **평균** | **{_fmt_pct(base_kavg)}** | **{_fmt_pct(sft_kavg)}** | "
|
|
f"**{_fmt_pct(orpo_kavg)}** | **{avg_diff}** |")
|
|
lines.append("")
|
|
|
|
# HAE-RAE
|
|
lines.append("### HAE-RAE (0-shot)\n")
|
|
base_haerae = _get_acc(base_zero.get("haerae", {})) if "haerae" in base_zero else _BASE_BENCH_REFERENCE.get("haerae")
|
|
sft_haerae = _get_acc(sft_zero.get("haerae", {})) if "haerae" in sft_zero else None
|
|
orpo_haerae = _get_acc(orpo_zero.get("haerae", {})) if "haerae" in orpo_zero else None
|
|
lines.append(f"- Base: {_fmt_pct(base_haerae)} → SFT: {_fmt_pct(sft_haerae)} → ORPO: {_fmt_pct(orpo_haerae)}")
|
|
lines.append("")
|
|
|
|
# MMLU-KO
|
|
lines.append("### MMLU-KO (0-shot)\n")
|
|
base_mmlu_ko = _get_acc(base_zero.get("global_mmlu_ko", {})) if "global_mmlu_ko" in base_zero else _BASE_BENCH_REFERENCE.get("global_mmlu_ko")
|
|
sft_mmlu_ko = _get_acc(sft_zero.get("global_mmlu_ko", {})) if "global_mmlu_ko" in sft_zero else None
|
|
orpo_mmlu_ko = _get_acc(orpo_zero.get("global_mmlu_ko", {})) if "global_mmlu_ko" in orpo_zero else None
|
|
lines.append(f"- Base: {_fmt_pct(base_mmlu_ko)} → SFT: {_fmt_pct(sft_mmlu_ko)} → ORPO: {_fmt_pct(orpo_mmlu_ko)}")
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 6. 영어 벤치마크
|
|
# =====================================================================
|
|
lines.append("## 6. 영어 벤치마크\n")
|
|
lines.append("| 태스크 | Base | SFT | ORPO | Base→ORPO |")
|
|
lines.append("|--------|------|-----|------|-----------|")
|
|
|
|
en_tasks_list = ["hellaswag", "arc_easy", "arc_challenge", "winogrande", "piqa"]
|
|
for t in en_tasks_list:
|
|
prefer_norm = t in ["hellaswag", "arc_challenge"]
|
|
base_a = _get_acc(base_zero.get(t, {}), prefer_norm=prefer_norm) if t in base_zero else _BASE_BENCH_REFERENCE.get(t)
|
|
sft_a = _get_acc(sft_zero.get(t, {}), prefer_norm=prefer_norm) if t in sft_zero else None
|
|
orpo_a = _get_acc(orpo_zero.get(t, {}), prefer_norm=prefer_norm) if t in orpo_zero else None
|
|
|
|
diff = ""
|
|
if orpo_a is not None and base_a is not None:
|
|
d = (orpo_a - base_a) * 100
|
|
diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
lines.append(f"| {t} | {_fmt_pct(base_a)} | {_fmt_pct(sft_a)} | {_fmt_pct(orpo_a)} | {diff} |")
|
|
|
|
# MMLU-EN averages
|
|
_MMLU_EN_GROUPS = {"mmlu", "mmlu_humanities", "mmlu_social_sciences", "mmlu_stem", "mmlu_other"}
|
|
|
|
def _mmlu_en_avg(zero: dict) -> Optional[float]:
|
|
accs = []
|
|
for t, m in zero.items():
|
|
if (t.startswith("mmlu_") or t == "mmlu") and t not in _MMLU_EN_GROUPS:
|
|
a = _get_acc(m)
|
|
if a is not None:
|
|
accs.append(a)
|
|
if not accs:
|
|
for t in _MMLU_EN_GROUPS:
|
|
if t in zero:
|
|
a = _get_acc(zero[t])
|
|
if a is not None:
|
|
accs.append(a)
|
|
return sum(accs) / len(accs) if accs else None
|
|
|
|
base_mmlu_en = _mmlu_en_avg(base_zero)
|
|
sft_mmlu_en = _mmlu_en_avg(sft_zero)
|
|
orpo_mmlu_en = _mmlu_en_avg(orpo_zero)
|
|
|
|
mmlu_en_diff = ""
|
|
if orpo_mmlu_en is not None and base_mmlu_en is not None:
|
|
d = (orpo_mmlu_en - base_mmlu_en) * 100
|
|
mmlu_en_diff = f"{'+' if d >= 0 else ''}{d:.1f}pp"
|
|
lines.append(f"| MMLU-EN 평균 | {_fmt_pct(base_mmlu_en)} | {_fmt_pct(sft_mmlu_en)} | "
|
|
f"{_fmt_pct(orpo_mmlu_en)} | {mmlu_en_diff} |")
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 7. Calibration
|
|
# =====================================================================
|
|
lines.append("## 7. Calibration 비교\n")
|
|
lines.append("| 지표 | Base | SFT | ORPO |")
|
|
lines.append("|------|------|-----|------|")
|
|
|
|
base_cal = base_p1.get("calibration", {})
|
|
sft_cal = sft_p1.get("calibration", {})
|
|
orpo_cal = orpo_p1.get("calibration", {})
|
|
|
|
cal_metrics = [
|
|
("top1_accuracy", "Top-1 Accuracy"),
|
|
("top5_accuracy", "Top-5 Accuracy"),
|
|
("top10_accuracy", "Top-10 Accuracy"),
|
|
]
|
|
for key, label in cal_metrics:
|
|
base_v = base_cal.get(key, _BASE_CALIB_REFERENCE.get(key))
|
|
sft_v = sft_cal.get(key)
|
|
orpo_v = orpo_cal.get(key)
|
|
lines.append(f"| {label} | {_fmt_f(base_v)} | {_fmt_f(sft_v)} | {_fmt_f(orpo_v)} |")
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 8. ORPO 고유 지표
|
|
# =====================================================================
|
|
lines.append("## 8. ORPO 고유 지표\n")
|
|
|
|
# Final preference accuracy & reward margins
|
|
if training_curve and training_curve.get("eval_steps"):
|
|
last_step = training_curve["eval_steps"][-1]
|
|
final_pref = last_step.get("rewards_accuracies", last_step.get("preference_accuracy"))
|
|
final_margin = last_step.get("rewards_margins", last_step.get("reward_margins"))
|
|
if final_pref is not None:
|
|
lines.append(f"- **최종 Preference Accuracy**: {final_pref:.2%}")
|
|
if final_margin is not None:
|
|
lines.append(f"- **최종 Reward Margins**: {final_margin:.4f}")
|
|
else:
|
|
lines.append("- Preference Accuracy / Reward Margins: 데이터 없음")
|
|
|
|
# Parameter sensitivity
|
|
rep_grid = orpo_p1.get("repetition", {}).get("grid_results")
|
|
if rep_grid:
|
|
items = rep_grid if isinstance(rep_grid, list) else list(rep_grid.values())
|
|
for r in items:
|
|
if isinstance(r, dict):
|
|
rp = r.get("repetition_penalty", r.get("rep_penalty"))
|
|
if rp is not None and abs(float(rp) - 1.0) < 1e-6:
|
|
rep_val = r.get("avg_3gram_rep", r.get("3gram_repetition"))
|
|
if rep_val is not None:
|
|
verdict = "PASS" if rep_val < 0.05 else "FAIL"
|
|
lines.append(f"- **Parameter Sensitivity**: rep_penalty=1.0 → 3-gram rep={rep_val:.2%} "
|
|
f"(목표 < 5%) → {verdict}")
|
|
break
|
|
lines.append("")
|
|
|
|
# =====================================================================
|
|
# 9. 반복률 그리드 서치
|
|
# =====================================================================
|
|
lines.append("## 9. 반복률 그리드 서치\n")
|
|
if rep_grid:
|
|
items = rep_grid if isinstance(rep_grid, list) else list(rep_grid.values())
|
|
rep_rows = []
|
|
for r in items:
|
|
if isinstance(r, dict):
|
|
rep_rows.append({
|
|
"config": r.get("params", "?"),
|
|
"temp": r.get("temperature"),
|
|
"rep_pen": r.get("repetition_penalty"),
|
|
"3gram": r.get("avg_3gram_rep", r.get("3gram_repetition", float("inf"))),
|
|
"4gram": r.get("avg_4gram_rep", r.get("4gram_repetition")),
|
|
"eos_rate": r.get("eos_rate"),
|
|
"avg_tokens": r.get("avg_tokens"),
|
|
})
|
|
rep_rows.sort(key=lambda x: x["3gram"] if isinstance(x["3gram"], (int, float)) else float("inf"))
|
|
|
|
lines.append("| 설정 | Temp | Rep Pen | 3-gram | 4-gram | EOS Rate | Avg Tokens |")
|
|
lines.append("|------|------|---------|--------|--------|----------|-----------|")
|
|
for i, r in enumerate(rep_rows):
|
|
marker = " **← best**" if i == 0 else ""
|
|
lines.append(
|
|
f"| {r['config']} | {_fmt_f(r['temp'], 2)} | {_fmt_f(r['rep_pen'], 2)} | "
|
|
f"{_fmt_f(r['3gram'])} | {_fmt_f(r['4gram'])} | "
|
|
f"{_fmt_f(r['eos_rate'])} | {_fmt_f(r['avg_tokens'], 1)} |{marker}"
|
|
)
|
|
lines.append("")
|
|
else:
|
|
lines.append("반복률 그리드 서치 데이터 없음\n")
|
|
|
|
# =====================================================================
|
|
# 10. 생성 샘플
|
|
# =====================================================================
|
|
lines.append("## 10. 생성 샘플\n")
|
|
orpo_gen = orpo_p1.get("generation", {})
|
|
orpo_samples = orpo_gen.get("samples", [])
|
|
greedy_samples = [s for s in orpo_samples if isinstance(s, dict) and s.get("temperature", 1.0) == 0.0]
|
|
if not greedy_samples:
|
|
greedy_samples = orpo_samples # fallback: use all samples
|
|
|
|
if greedy_samples:
|
|
lines.append("### ORPO Greedy 생성 샘플\n")
|
|
for i, s in enumerate(greedy_samples[:15], 1):
|
|
if isinstance(s, dict):
|
|
prompt = s.get("prompt", "")
|
|
text = s.get("text", s.get("generated_text", ""))
|
|
if len(text) > 500:
|
|
text = text[:500] + "..."
|
|
hit_eos = s.get("hit_eos", "?")
|
|
rep3 = s.get("3gram_rep", s.get("avg_3gram_rep"))
|
|
tokens = s.get("generated_tokens", s.get("num_tokens", "?"))
|
|
lines.append(f"**[{i}]** `{prompt}`")
|
|
lines.append(f"> {text}")
|
|
meta_parts = [f"EOS={hit_eos}"]
|
|
if rep3 is not None:
|
|
meta_parts.append(f"3gram_rep={rep3:.2%}")
|
|
meta_parts.append(f"tokens={tokens}")
|
|
lines.append(f"> *{', '.join(meta_parts)}*\n")
|
|
else:
|
|
lines.append("생성 샘플 데이터 없음\n")
|
|
|
|
# =====================================================================
|
|
# 11. 최종 판정
|
|
# =====================================================================
|
|
lines.append("## 11. 최종 판정\n")
|
|
lines.append("### 배포 기준 충족 여부\n")
|
|
lines.append("| 조건 | 기준 | 현재 값 | 충족 |")
|
|
lines.append("|------|------|---------|------|")
|
|
|
|
criteria = [
|
|
("Greedy 3-gram 반복률", "< 5%", _fmt_pct(orpo_rep),
|
|
"YES" if orpo_rep is not None and orpo_rep < 0.05 else "NO"),
|
|
("EOS 종료율", "> 90%", _fmt_pct(orpo_eos),
|
|
"YES" if orpo_eos is not None and orpo_eos > 0.90 else "NO"),
|
|
("PPL Forgetting", "< 5%", f"{orpo_forgetting:.1f}%" if orpo_forgetting is not None else "N/A",
|
|
"YES" if orpo_forgetting is not None and orpo_forgetting < 5.0 else "NO"),
|
|
("KoBEST 평균", ">= 43%", _fmt_pct(orpo_kobest),
|
|
"YES" if orpo_kobest is not None and orpo_kobest >= 0.43 else "NO"),
|
|
]
|
|
for cond, threshold, current, met in criteria:
|
|
lines.append(f"| {cond} | {threshold} | {current} | {met} |")
|
|
lines.append("")
|
|
|
|
if deploy_criteria_met:
|
|
lines.append("**→ 모든 배포 기준 충족: DEPLOY (Phase 4: GGUF 변환 + Ollama 배포 진행)**\n")
|
|
else:
|
|
lines.append("**→ 배포 기준 미달: RETRY (ORPO 재학습 또는 하이퍼파라미터 조정 필요)**\n")
|
|
|
|
lines.append("---\n")
|
|
lines.append("*이 보고서는 `eval/report_generator.py::generate_three_way_report()`에 의해 자동 생성되었습니다.*")
|
|
|
|
report_text = "\n".join(lines)
|
|
output_path.write_text(report_text, encoding="utf-8")
|
|
|
|
# Also save to orpo_output_dir if provided
|
|
if orpo_output_dir:
|
|
orpo_output_dir = Path(orpo_output_dir)
|
|
orpo_output_dir.mkdir(parents=True, exist_ok=True)
|
|
(orpo_output_dir / "orpo_three_way_report.md").write_text(report_text, encoding="utf-8")
|
|
|
|
return output_path
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("report_generator.py — use via full_eval_pipeline.py or sft_eval_pipeline.py")
|