"""2戦略の paired difference 分析(fixed vs structure など)。 なぜ paired か(Anthropic "Adding Error Bars to Evals" 2024): - 同じ質問セットを両戦略に流しているので、質問ごとの差分を取ると 「質問の難易度由来の分散」が丸ごと消え、「戦略の差」だけが残る。 - 集計平均の比較(0.707 vs 0.662 など)より遥かに感度が高い。n=33でも使える。 やること: - scores_YYYYMMDD.json(evaluation.main の出力)を読む - 2戦略を id で突き合わせ、per-question の差分 d = metric(B) - metric(A) を出す - 改善 / 悪化 / 不変の件数、平均差、平均差の bootstrap 95%CI、 Wilcoxon 符号順位検定 / 符号検定の p値を報告する - 差が大きく動いた質問を明細で出す(どの質問が効いたか) 使い方: uv run python experiments/paired_diff.py # 既定 scores_今日.json, fixed→structure, recall uv run python experiments/paired_diff.py --scores data/eval/scores_20260713.json \ --a fixed --b structure --metric recall """ from __future__ import annotations import argparse import json from pathlib import Path import numpy as np from scipy import stats from music_rag import config METRIC_KEYS = { "recall": "recall", "strict_hit": "strict_hit", # bool → 0/1 "mrr": "reciprocal_rank", } def _index_by_id(result: dict) -> dict[str, dict]: return {r["id"]: r for r in result["per_question"]} def paired_diff(scores: dict, a: str, b: str, metric: str) -> dict: if a not in scores or b not in scores: raise SystemExit(f"戦略 {a} / {b} が scores に無い(あるのは {list(scores)})") key = METRIC_KEYS[metric] ra, rb = _index_by_id(scores[a]), _index_by_id(scores[b]) ids = [i for i in ra if i in rb] # 両方にある質問だけ rows = [] for i in ids: va = float(ra[i][key]) vb = float(rb[i][key]) rows.append({ "id": i, "a": va, "b": vb, "d": vb - va, "match_type": ra[i].get("match_type"), "source": ra[i].get("source"), "question": ra[i].get("question", "")[:40], }) d = np.array([r["d"] for r in rows]) n = len(d) improved = int((d > 0).sum()) worsened = int((d < 0).sum()) unchanged = int((d == 0).sum()) # 平均差の bootstrap 95%CI(差分を復元抽出) rng = np.random.default_rng(42) boot = np.array([ rng.choice(d, size=n, replace=True).mean() for _ in range(10000) ]) ci = (float(np.percentile(boot, 2.5)), float(np.percentile(boot, 97.5))) # 検定: 変化のあった質問での符号順位検定(Wilcoxon)。 # 全部tieだと検定不能なので、その場合は符号検定にフォールバック。 nonzero = d[d != 0] if len(nonzero) == 0: test_name, pval = "変化なし(検定不能)", 1.0 else: try: _, pval = stats.wilcoxon(d[d != 0]) test_name = "Wilcoxon 符号順位検定" except ValueError: # 符号検定(二項検定)にフォールバック k = improved pval = stats.binomtest(k, improved + worsened, 0.5).pvalue test_name = "符号検定(二項)" return { "a": a, "b": b, "metric": metric, "n": n, "mean_a": float(np.mean([r["a"] for r in rows])), "mean_b": float(np.mean([r["b"] for r in rows])), "mean_diff": float(d.mean()), "ci95": ci, "improved": improved, "worsened": worsened, "unchanged": unchanged, "test_name": test_name, "pval": float(pval), "rows": rows, } def print_report(res: dict) -> None: a, b, m = res["a"], res["b"], res["metric"] print(f"=== paired diff: {a} → {b} / metric={m} (n={res['n']}) ===\n") print(f" mean({a}) = {res['mean_a']:.4f}") print(f" mean({b}) = {res['mean_b']:.4f}") print(f" 平均差 (b - a) = {res['mean_diff']:+.4f}") lo, hi = res["ci95"] crosses = lo <= 0 <= hi print(f" 95%CI(平均差) = [{lo:+.4f}, {hi:+.4f}] " f"{'← 0をまたぐ(有意でない)' if crosses else '← 0を含まない(有意)'}") print(f" 改善 {res['improved']} / 悪化 {res['worsened']} / 不変 {res['unchanged']}") print(f" {res['test_name']}: p = {res['pval']:.4f}\n") # 動いた質問の明細(|d|>0 のみ、差の大きい順) moved = sorted([r for r in res["rows"] if r["d"] != 0], key=lambda r: -abs(r["d"])) if moved: print(" ── 差が出た質問(b-a)──") for r in moved: arrow = "↑" if r["d"] > 0 else "↓" print(f" {arrow} {r['d']:+.3f} [{r['match_type']:6s}/{r['source'][:6]}] " f"{r['id']:12s} {r['question']}") def main() -> None: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--scores", type=Path, default=config.EVAL_DIR / "scores_20260713.json") p.add_argument("--a", default="fixed", help="基準戦略") p.add_argument("--b", default="structure", help="比較戦略") p.add_argument("--metric", default="recall", choices=list(METRIC_KEYS)) p.add_argument("--all-metrics", action="store_true", help="recall / strict_hit / mrr を全部出す") args = p.parse_args() scores = json.loads(args.scores.read_text(encoding="utf-8")) metrics = list(METRIC_KEYS) if args.all_metrics else [args.metric] for i, m in enumerate(metrics): res = paired_diff(scores, args.a, args.b, m) print_report(res) if i < len(metrics) - 1: print() if __name__ == "__main__": main()