"""
=============================================================================
CS486 · 第 1 讲 · 代码 04：我们有多确定？——测试集大小、随机种子与"试了很多次"
=============================================================================

【这个脚本要回答的问题】（讲义 §8.3）
    代码 01–03 里我们说了很多"模型 A 是 x%、模型 B 是 y%"。这些数字有多可靠？
    这门研讨课要求你提出并评测新方法，下面四个问题在写任何一句"我们的方法更好"之前都要回答：

    演示 1：**测试集够大吗？** 同一个模型、同一种题，只评测 25 / 100 / 1000 道时，
            准确率的估计会散布多开？Wilson 置信区间有多宽？
    演示 2：**换一个随机种子，结论还在吗？** 同一个配置训练 3 次（只改种子）：
            分布内都是满分，分布外的探针呢？
    演示 3：**怎样比较两个系统？** 在同一组题上，配对检验（McNemar）vs 不配对的两比例检验；
            "同一个方法的两个种子"之间的差别显著吗？——这告诉我们比较方法时必须跨种子。
    演示 4：**你试了多少次？** 一个纯模拟：20 个"提示词变体"和基线其实一样好，
            在 200 道题上挑出最好的那个，它看起来好多少？多重比较校正能挡住多少假阳性？

【运行】
    python3 04_how_sure_are_we.py           # 第一次约 1.5 分钟（训练 2 个新种子；若没有 cot_L2 缓存再加约 40 秒）
    python3 04_how_sure_are_we.py --quick   # 冒烟测试，几秒钟，数字没有意义

【依赖】PyTorch + 本目录的 hop_world.py、tiny_reasoner.py、research_kit.py、console.py。
"""

import argparse
import math
import random
import time

import hop_world as hw
import tiny_reasoner as tr
from console import pct, print_header, print_observations, print_table
from research_kit import (holm_adjust, mcnemar_exact, mde_two_proportions, paired_bootstrap_diff,
                          seed_summary, two_proportion_p, wilson_interval)

SEEDS = (("cot_L2", 0), ("cot_L2_seed1", 1), ("cot_L2_seed2", 2))
# 分布外探针：(名字, n, k, 干扰陈述数)。都来自代码 03 里准确率明显低于 100% 的条件
PROBES = (("长题面 n=12, k=3", 12, 3, 0),
          ("干扰 n=8+4, k=3", 8, 3, 4),
          ("长题面 n=10, k=5", 10, 5, 0))


def correct_vector(model, n, k, nd, count, seed):
    """在一个评测集上逐题的对错（0/1 列表）。同样的参数永远得到同一组题，所以不同模型的结果可以配对。"""
    probs = hw.make_eval_set(n, k, count, n_distract=nd, seed=seed)
    return [int(r["correct"]) for r in tr.evaluate(model, probs, "cot")]


# ---------------------------------------------------------------------------
# 演示 1：测试集大小
# ---------------------------------------------------------------------------
def demo1_test_size(model, big):
    name, n, k, nd = PROBES[0]
    print_header(f"演示 1：测试集大小——cot_L2 在探针'{name}'上，先评测 {big} 道，再从中随机抽小测试集")
    full = correct_vector(model, n, k, nd, big, seed=11)
    p_full = sum(full) / len(full)
    lo, hi = wilson_interval(sum(full), len(full))
    print(f"{big} 道题的准确率 = {pct(p_full)}，Wilson 95% 区间 [{pct(lo)}, {pct(hi)}]（下面把它当作'真值'）\n")
    rng = random.Random(0)
    rows, out = [], {}
    for size in (25, 50, 100, 200, 500):
        if size > big:
            continue
        ests, widths = [], []
        for _ in range(1000):
            sub = rng.sample(full, size)
            c = sum(sub)
            ests.append(c / size)
            a, b = wilson_interval(c, size)
            widths.append(b - a)
        ests.sort()
        off5 = sum(abs(e - p_full) > 0.05 for e in ests) / len(ests)
        out[size] = (ests[25], ests[974], sum(widths) / len(widths), off5)
        rows.append([size, f"{pct(ests[25])} – {pct(ests[974])}", pct(sum(widths) / len(widths)), pct(off5)])
    print_table(["测试集大小", "1000 次抽样里 95% 的估计落在", "Wilson 区间平均宽度", "估计偏离真值 > 5 个点"],
                rows, [10, 28, 20, 22])
    return p_full, out


# ---------------------------------------------------------------------------
# 演示 2：随机种子
# ---------------------------------------------------------------------------
def demo2_seeds(models, count):
    print_header("演示 2：同一个配置，3 个随机种子——分布内 vs 分布外（每个探针 " + str(count) + " 道题，同一组题）")
    vecs, ind_accs = {}, []
    rows = []
    for name, seed in SEEDS:
        model = models[name]
        ind = []
        for k in range(1, 8):
            ind += correct_vector(model, 8, k, 0, count // 3, seed=12)
        ind_accs.append(sum(ind) / len(ind))
        row = [f"种子 {seed}（{name}）", pct(sum(ind) / len(ind))]
        for pname, n, k, nd in PROBES:
            v = correct_vector(model, n, k, nd, count, seed=13)
            vecs[(name, pname)] = v
            row.append(pct(sum(v) / len(v)))
        rows.append(row)
    summary = ["均值 ± 标准差", ""]
    spreads = {}
    for pname, *_ in PROBES:
        accs = [sum(vecs[(name, pname)]) / count for name, _ in SEEDS]
        s = seed_summary(accs)
        spreads[pname] = s
        summary.append(f"{100 * s['mean']:.1f} ± {100 * s['sd']:.1f}")
    rows.append(summary)
    print_table(["模型", "分布内 n=8,k=1–7"] + [p[0] for p in PROBES], rows, [24, 16, 18, 18, 18])
    return vecs, spreads, ind_accs


# ---------------------------------------------------------------------------
# 演示 3：配对 vs 不配对
# ---------------------------------------------------------------------------
def demo3_paired(vecs, count):
    print_header("演示 3：比较两个系统——同一组题上的配对检验 vs 不配对检验（对象：同一个方法的不同种子）")
    # (a) 先用一对差别小的种子，把 McNemar 检验的四类题数摊开来看
    pname = PROBES[0][0]
    (na, sa), (nb, sb) = SEEDS[0], SEEDS[1]
    a, b = vecs[(na, pname)], vecs[(nb, pname)]
    mc = mcnemar_exact(a, b)
    print(f"(a) 探针'{pname}'上，种子 {sa} = {pct(sum(a) / count)}，种子 {sb} = {pct(sum(b) / count)}：")
    rows = [
        ["两个都对", sum(1 for x, y in zip(a, b) if x and y)],
        ["两个都错", sum(1 for x, y in zip(a, b) if not x and not y)],
        [f"只有种子 {sa} 对（n10）", mc["n10"]],
        [f"只有种子 {sb} 对（n01）", mc["n01"]],
    ]
    print_table(["逐题对错的四类", "题数"], rows, [26, 8])
    print("    McNemar 只看意见不一致的 n10 + n01 道题：如果两者一样好，它们应该各占一半。\n")

    # (b) 所有种子对、所有探针：差、配对 p、不配对 p、配对自助法区间
    print("(b) 3 对种子 × 3 个分布外探针（每格同一组 " + str(count) + " 道题）：")
    rows, out = [], []
    for pname, *_ in PROBES:
        for i in range(len(SEEDS)):
            for j in range(i + 1, len(SEEDS)):
                (na, sa), (nb, sb) = SEEDS[i], SEEDS[j]
                a, b = vecs[(na, pname)], vecs[(nb, pname)]
                mc = mcnemar_exact(a, b)
                diff, lo, hi = paired_bootstrap_diff(a, b, seed=1)
                p_un = two_proportion_p(sum(a), count, sum(b), count)
                out.append({"probe": pname, "pair": (sa, sb), "diff": diff, "p_paired": mc["p"], "p_unpaired": p_un})
                rows.append([pname, f"{sa} vs {sb}", f"{100 * diff:+.1f}", f"[{100 * lo:+.1f}, {100 * hi:+.1f}]",
                             f"{mc['p']:.2g}", f"{p_un:.2g}"])
    print_table(["探针", "种子对", "差（点）", "配对自助法 95% 区间", "配对 p（McNemar）", "不配对 p"], rows,
                [18, 8, 9, 20, 18, 10], ["<", "<", ">", ">", ">", ">"])
    return out


# ---------------------------------------------------------------------------
# 演示 4：多重比较（纯模拟）
# ---------------------------------------------------------------------------
def simulate_forking_paths(n_questions=200, n_variants=20, reps=400, seed=0):
    """
    模拟"试了 20 个提示词、报告最好的那个"。

    【思路】
        构造一个有 n_questions 道题的基准；每道题有自己的难度：答对概率 p_i = sigmoid(a_i)，
        a_i ~ N(1.2, 1.5)（平均准确率约 70%，有的题几乎必对、有的几乎必错——真实基准就是这样）。
        基线和 20 个变体**真实能力完全相同**（每道题的 p_i 都一样），只有"这一次运行"的随机性不同
        （例如采样温度带来的随机性）。所以任何"提升"都是噪声。
        每次重复：算出每个变体相对基线的差，挑出最好的那个；做 McNemar 检验；再对 20 个 p 值做 Holm 校正。

    【参数】n_questions：题数；n_variants：变体数；reps：重复次数；seed：随机种子
    【返回】dict：最好变体的平均"提升"、其 95 分位，以及"未校正显著"和"Holm 校正后显著"的比例
    【边界】这是一个理想化的模型：真实的提示词变体之间、变体和基线之间往往有更强的相关性。
    """
    rng = random.Random(seed)
    gains, sig_raw, sig_holm = [], 0, 0
    for _ in range(reps):
        p = [1 / (1 + math.exp(-rng.gauss(1.2, 1.5))) for _ in range(n_questions)]
        base = [int(rng.random() < pi) for pi in p]
        best_gain, best_p, pvals = -1.0, 1.0, []
        for _v in range(n_variants):
            var = [int(rng.random() < pi) for pi in p]
            g = (sum(var) - sum(base)) / n_questions
            pv = mcnemar_exact(var, base)["p"] if g > 0 else 1.0     # 只关心"变体更好"的方向
            pvals.append(pv)
            if g > best_gain:
                best_gain, best_p = g, pv
        gains.append(best_gain)
        sig_raw += best_p < 0.05
        sig_holm += min(holm_adjust(pvals)) < 0.05
    gains.sort()
    return {"mean_gain": sum(gains) / reps, "p95_gain": gains[int(0.95 * reps)],
            "sig_raw": sig_raw / reps, "sig_holm": sig_holm / reps, "reps": reps}


def demo4_forking(quick):
    print_header("演示 4：试了 20 个变体，报告最好的那个（纯模拟：变体和基线的真实能力完全相同）")
    sim = simulate_forking_paths(reps=40 if quick else 400)
    rows = [
        ["最好变体相对基线的平均'提升'", f"{100 * sim['mean_gain']:.1f} 个点"],
        ["最好变体的'提升'（95 分位）", f"{100 * sim['p95_gain']:.1f} 个点"],
        ["最好变体单独检验 p < 0.05 的比例", pct(sim["sig_raw"])],
        ["20 个检验做 Holm 校正后仍显著的比例", pct(sim["sig_holm"])],
        ["（参考）200 题、70% 基线时不配对检验的最小可检测差", f"{100 * mde_two_proportions(200, 0.7):.1f} 个点"],
    ]
    print_table([f"{sim['reps']} 次重复，每次 200 道题、20 个变体", "结果"], rows, [52, 14], ["<", ">"])
    return sim


def main():
    ap = argparse.ArgumentParser(description="我们有多确定？")
    ap.add_argument("--quick", action="store_true", help="冒烟测试：每个模型只训练 60 步")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 30 if args.quick else 300
    big = 200 if args.quick else 2000

    print_header("准备：同一个思维链配置（2 层，2000 步），3 个随机种子")
    models = {name: tr.get_model(name, quick=args.quick)[0] for name, _ in SEEDS}

    p_full, sizes = demo1_test_size(models["cot_L2"], big)
    vecs, spreads, ind_accs = demo2_seeds(models, count)
    cmp = demo3_paired(vecs, count)
    n_sig = sum(c["p_paired"] < 0.05 for c in cmp)
    n_more = sum(c["p_paired"] <= c["p_unpaired"] for c in cmp)
    va, vb = vecs[(SEEDS[0][0], PROBES[0][0])], vecs[(SEEDS[1][0], PROBES[0][0])]
    both_wrong = sum(1 for x, y in zip(va, vb) if not x and not y)
    disagree = sum(1 for x, y in zip(va, vb) if x != y)
    sim = demo4_forking(args.quick)

    if args.quick:
        print("\n（--quick 冒烟测试：模型只训练了 60 步，上面的数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    s25, s100, s500 = sizes[25], sizes[100], sizes[500]
    widest = max(spreads, key=lambda k: spreads[k]["sd"])
    print_observations([
        f"演示 1：真实准确率约 {pct(p_full)} 时，25 道题的估计在 {pct(s25[0])}–{pct(s25[1])} 之间晃动，"
        f"100 道题仍有 {pct(s100[3])} 的概率偏离 5 个点以上；500 道题时区间宽度约 {pct(s500[2])}。"
        "很多 LLM 推理基准只有几十到几百道题（例如 AIME 每年 30 道），小于几个点的差别在单次评测里基本是噪声。",
        f"演示 2：3 个种子在分布内都是 {pct(min(ind_accs))} 以上，在分布外探针上却差别很大——'{widest}'上的标准差是 "
        f"{100 * spreads[widest]['sd']:.1f} 个点（{pct(spreads[widest]['min'])}–{pct(spreads[widest]['max'])}）。"
        "分布内准确率完全相同的模型，泛化行为可以很不一样（McCoy et al. 2020 在 BERT 上的发现）。",
        f"演示 3：{n_sig}/{len(cmp)} 个'同一方法、不同种子'的比较在配对检验下 p < 0.05，最大的差是 "
        f"{100 * max(abs(c['diff']) for c in cmp):.1f} 个点——如果把其中一个种子叫作'新方法'，你会得出一个高度'显著'的错误结论。"
        "显著性检验只考虑了'测试题的抽样噪声'，没有考虑'训练的随机性'。比较两个方法时，必须每个方法跑多个种子，"
        "把种子之间的差别当作噪声的下限（或者把种子当作随机效应来建模）。"
        f"另外，这里配对检验并没有比不配对的灵敏多少（只有 {n_more}/{len(cmp)} 行 p 值更小）：两个种子的错误相关性不强"
        f"（(a) 里两个都错的只有 {both_wrong} 道，意见不一致的有 {disagree} 道）。配对检验的优势在两个系统的错误高度相关时最大，"
        "例如比较同一个模型的两种解码方式（第 4 讲）。",
        f"演示 4：20 个和基线一样好的变体里挑最好的，平均能'提升' {100 * sim['mean_gain']:.1f} 个点，"
        f"{pct(sim['sig_raw'])} 的情况下单独检验还'显著'；Holm 校正后只剩 {pct(sim['sig_holm'])}。"
        "在同一个测试集上反复调参、挑最好的结果报告，就是在测试集上做训练。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
