"""
=============================================================================
前置讲 P3 · 代码 03：试了 20 个提示之后 —— 多重比较与"在开发集上过拟合"
=============================================================================

对应讲义：lectures/prereq3_statistics_for_prompts.md §5

【这个脚本要回答的问题】
    提示工程的日常是：写 20 个版本，在一组开发题上各跑一遍，挑最好的那个。
    1. 如果这 20 个版本其实一样好，挑出来的"最好的"会在新题上表现如何？（演示 1：赢家诅咒）
    2. 把 20 个版本都和基线比一遍，至少冤枉出一个"显著提升"的概率有多大？怎样校正？（演示 2）
    3. 如果其中真有一个更好的版本，小开发集能把它挑出来吗？（演示 3）

【怎么读输出】
    模拟世界和 02 号脚本相同（stats_kit.ItemBank）。每个"版本"在每道题上有自己的手感，
    但除非特别说明，它们的真实准确率都是 70%。每种设置重复 500 次。

【运行】python3 03_multiple_comparisons.py     （约 10 秒）
【依赖】只用 Python 标准库；import 同目录的 stats_kit.py 和第 1 讲的 console.py。
"""

import math
import os
import random
import sys

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
from console import pct, print_header, print_observations, print_table  # noqa: E402
from stats_kit import ItemBank, bonferroni, holm, mcnemar_exact  # noqa: E402

BANK = ItemBank()
T70 = BANK.theta_for(0.70)
REPS = 500


def mean(xs):
    return math.fsum(xs) / len(xs)


def demo1_winners_curse(rng):
    """演示 1：赢家诅咒。"""
    print_header("演示 1：20 个其实一样好的版本，挑开发集上最好的那个——它在新题上表现如何？")
    rows, res = [], {}
    for n_dev in [50, 100, 400]:
        win_dev, win_test, rand_dev = [], [], []
        for _ in range(REPS):
            dev = BANK.draw_items(n_dev, rng)
            scores = [mean(BANK.greedy(T70, dev, rng)) for _ in range(20)]
            win_dev.append(max(scores))
            rand_dev.append(scores[0])
            test = BANK.draw_items(2000, rng)
            win_test.append(mean(BANK.greedy(T70, test, rng)))     # 被选中的版本，在全新的 2000 道题上
        res[n_dev] = (mean(win_dev), mean(win_test))
        rows.append([n_dev, pct(mean(rand_dev), 1), pct(mean(win_dev), 1), pct(mean(win_test), 1),
                     f"+{100 * (mean(win_dev) - mean(win_test)):.1f} 个百分点"])
    print(f"20 个版本的真实准确率都是 70%；每种开发集大小重复 {REPS} 次")
    print_table(["开发集题数", "随便一个版本的开发集准确率", "最好的版本的开发集准确率", "它在新的 2000 道题上",
                 "开发集上的虚高"], rows)
    print_observations([
        f"20 个版本一模一样好，可在 100 道题的开发集上，'最好的那个'平均有 {res[100][0]:.1%}——比真实水平高了 "
        f"{100 * (res[100][0] - 0.7):.1f} 个百分点。一换到新题上，它就'掉回'了 70%。这叫**赢家诅咒**（winner's curse）："
        "挑最大值这个动作本身，就把运气选了进来。",
        f"开发集越小，虚高越严重（50 道题时 +{100 * (res[50][0] - res[50][1]):.1f}）；400 道题时虚高也还有 "
        f"+{100 * (res[400][0] - res[400][1]):.1f} 个百分点。",
        "这就是'在开发集上过拟合提示'：你反复修改提示、每次都看开发集分数，最后留下的版本有一部分好成绩来自运气。"
        "**挑选用的开发集和报告用的测试集必须分开**，测试集只在最后用一次。第 10 讲的自动提示优化会把这个问题放大几百倍。",
    ])


def demo2_fwer(rng):
    """演示 2：族错误率与校正。"""
    print_header("演示 2：20 个版本都和基线比一次——至少冤枉出一个'显著提升'的概率")
    n = 200
    any_raw = any_bon = any_holm = 0
    total_raw = 0
    for _ in range(REPS):
        dev = BANK.draw_items(n, rng)
        base = BANK.greedy(T70, dev, rng)
        pvals = []
        for _ in range(20):
            v = BANK.greedy(T70, dev, rng)
            n01, n10, p = mcnemar_exact(base, v)
            pvals.append(p if n01 > n10 else 1.0)              # 只关心"比基线好"的方向（单侧地数）
        raw = [p < 0.05 for p in pvals]
        any_raw += any(raw)
        total_raw += sum(raw)
        any_bon += any(bonferroni(pvals))
        any_holm += any(holm(pvals))
    print(f"基线和 20 个版本的真实准确率都是 70%；同一组 {n} 道题；每个版本和基线做一次 McNemar 检验；重复 {REPS} 次")
    print_table(["做法", "至少有一个'显著优于基线'的比例"], [
        ["不校正（每个检验都用 p < 0.05）", pct(any_raw, REPS)],
        ["Bonferroni（p < 0.05 / 20）", pct(any_bon, REPS)],
        ["Holm（逐步校正）", pct(any_holm, REPS)],
    ])
    print(f"  不校正时，平均每次实验'发现' {total_raw / REPS:.2f} 个显著优于基线的版本——它们全都是假的")
    print_observations([
        f"每个检验单独看，冤枉的概率都不超过 5%；可 20 个检验里**至少一个**被冤枉的概率要大得多（{pct(any_raw, REPS)}）。"
        "试的版本越多，'总能找到一个显著的'就越确定——这就是多重比较问题。",
        "Bonferroni 把门槛降到 α/20，Holm 是它的逐步改进版：两者都把'至少一个假阳性'的概率控制在 5% 以内。",
        "实用的建议：事先写下**你要比较哪几个**（而不是事后从 20 个里挑一个显著的报告），比较了几个就校正几个；"
        "或者干脆把挑选和检验分开——在开发集上挑，在独立的测试集上只检验挑出来的那一个。",
    ])


def demo3_true_winner(rng):
    """演示 3：真有一个更好的版本时。"""
    print_header("演示 3：20 个版本里真有一个好 5 个百分点——开发集能把它挑出来吗？")
    t75 = BANK.theta_for(0.75)
    rows = []
    for n_dev in [100, 400, 1000]:
        picked = detected = false_disc = 0
        for _ in range(REPS):
            dev = BANK.draw_items(n_dev, rng)
            base = BANK.greedy(T70, dev, rng)
            outs = [BANK.greedy(t75 if i == 0 else T70, dev, rng) for i in range(20)]   # 第 0 个是真正更好的
            scores = [sum(o) for o in outs]
            picked += max(range(20), key=lambda i: (scores[i], -i)) == 0
            pvals = []
            for o in outs:
                n01, n10, p = mcnemar_exact(base, o)
                pvals.append(p if n01 > n10 else 1.0)
            sig = holm(pvals)
            detected += sig[0]
            false_disc += sum(sig[1:])
        rows.append([n_dev, pct(picked, REPS), pct(detected, REPS), f"{false_disc / REPS:.2f}"])
    print(f"1 个版本真实准确率 75%，其余 19 个和基线一样是 70%；重复 {REPS} 次")
    print_table(["开发集题数", "开发集第一名正是那个更好的版本", "Holm 校正后检测出它", "平均每次的假发现个数"], rows)
    print_observations([
        "100 道题时，开发集第一名有相当大的概率**不是**真正更好的那个——19 个运气好的普通版本里，总有一个能冲到前面。",
        f"题目多了，真正的差别才压得过运气。但'挑得出来'和'证明得了'是两回事：400 道题时它有 {rows[1][1]} 的概率排第一，"
        f"可经过 Holm 校正后只有 {rows[1][2]} 的概率被检验认定为显著优于基线；1000 道题时才分别达到 {rows[2][1]} 和 {rows[2][2]}。"
        "假发现始终接近 0——校正在保护你，代价是需要更多的题。",
        "这给出一条实用的经验：**候选越多，开发集要越大**。一次试 20 个版本，就要准备好比只比较 2 个版本多得多的题。",
    ])


def main():
    rng = random.Random(30)
    demo1_winners_curse(rng)
    demo2_fwer(rng)
    demo3_true_winner(rng)


if __name__ == "__main__":
    main()
