"""
=============================================================================
第 8 讲 · 代码 4：随机种子、方差与显著性检验
=============================================================================

对应讲义：lectures/lecture08_practical_methods.md 第 7、8 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
你改了一点模型，准确率从 85.1% 变成了 85.9%。这是真的改进，还是运气？
神经网络的训练有很多随机性：参数初始化、数据顺序、dropout……同样的代码换一个随机种子，结果就不一样。

    演示 1：同一个模型、10 个随机种子 —— 结果的波动有多大？
    演示 2：比较两种设置：只跑一次会得出什么结论？跑 10 次呢？（置换检验）
    演示 3：比较两个**已经训练好的**模型：配对 bootstrap 检验
    演示 4：可复现性 —— 同样的种子，结果是否完全一样？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 04_seeds_and_significance.py

需要 PyTorch，约 1 分钟（要训练 20 个小模型）。
"""

import os

os.environ.setdefault("OMP_NUM_THREADS", "1")
import time

import numpy as np
import torch

from classifiers import GRUClassifier, make_batches, set_seed, train_classifier
from toy_reviews import Vocab, make_dataset

torch.set_num_threads(1)


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 70)
    print(f"  {title}")
    print("=" * 70)


@torch.no_grad()
def predictions(model, data, vocab):
    """返回 0/1 数组：模型在每个样本上是否预测正确。"""
    model.eval()
    right = []
    for X, y in make_batches(data, vocab, 256, shuffle=False):
        right.append((model(X).argmax(-1) == y).numpy())
    model.train()
    return np.concatenate(right).astype(float)


def train_one(cfg, train, vocab, seed):
    """
    用随机种子 seed 训练一个 GRU 分类器（seed 同时决定初始化和数据顺序）。

    【参数】cfg：dict，含 hidden、dropout、lr、epochs
    【返回】训练好的模型
    """
    set_seed(seed)
    model = GRUClassifier(len(vocab), hidden=cfg["hidden"], dropout=cfg["dropout"])
    train_classifier(model, train, vocab, epochs=cfg["epochs"], lr=cfg["lr"], seed=seed)
    return model


def permutation_test(a, b, n_perm=10000, seed=0):
    """
    置换检验：两组结果（比如两种设置各跑 10 个种子的准确率）的均值之差是不是"随机就能出现"的？

    【思路】（讲义第 7.3 节）
        零假设：两种设置其实没有区别，a 和 b 来自同一个分布。
        如果零假设成立，那么把 a、b 混在一起、随机重新分成两组，得到的均值之差应该和真实的差"差不多大"。
        重复很多次，看有多大比例的随机分组，均值之差（的绝对值）不小于真实的差 —— 这个比例就是 p 值。
        p 值很小（比如 < 0.05），说明"没有区别"这个假设很难解释我们看到的差距。

    【参数】a, b：两组数；n_perm：随机分组的次数
    【返回】(均值之差 mean(b) - mean(a), 双侧 p 值)

    【边界】p 值不是"零假设为真的概率"，也不说明差距有多大、有没有实际意义。
    """
    rng = np.random.default_rng(seed)
    a, b = np.asarray(a), np.asarray(b)
    observed = b.mean() - a.mean()
    pooled = np.concatenate([a, b])
    count = 0
    for _ in range(n_perm):
        rng.shuffle(pooled)
        diff = pooled[len(a):].mean() - pooled[:len(a)].mean()
        count += abs(diff) >= abs(observed) - 1e-12
    return observed, count / n_perm


def paired_bootstrap(right_a, right_b, n_boot=10000, seed=0):
    """
    配对 bootstrap 检验（Koehn 2004；Berg-Kirkpatrick 等 2012）：在**同一个测试集**上比较两个模型。

    【思路】（讲义第 7.4 节）
        从测试集里有放回地抽同样多的样本（两个模型用**同一组**抽样 —— 这就是"配对"），
        算一次两个模型的准确率之差；重复很多次。看有多大比例的抽样里，B 并不比 A 好。

    【参数】right_a, right_b：0/1 数组，两个模型在每个测试样本上是否正确
    【返回】(准确率之差 B - A, B 不优于 A 的抽样比例（近似单侧 p 值）, 差值的 95% 置信区间)
    """
    rng = np.random.default_rng(seed)
    d = np.asarray(right_b) - np.asarray(right_a)          # 每个样本上：+1 = 只有 B 对，-1 = 只有 A 对，0 = 一样
    idx = rng.integers(0, len(d), size=(n_boot, len(d)))
    diffs = d[idx].mean(axis=1)
    return d.mean(), float((diffs <= 0).mean()), tuple(np.quantile(diffs, [0.025, 0.975]))


def main():
    train = make_dataset(500, seed=0)
    test = make_dataset(2000, seed=2)
    vocab = Vocab()
    cfg_a = {"hidden": 64, "dropout": 0.0, "lr": 3e-3, "epochs": 10}
    cfg_b = {"hidden": 64, "dropout": 0.3, "lr": 3e-3, "epochs": 10}
    seeds = list(range(10))
    t0 = time.perf_counter()
    models_a = [train_one(cfg_a, train, vocab, s) for s in seeds]
    models_b = [train_one(cfg_b, train, vocab, s) for s in seeds]
    right_a = [predictions(m, test, vocab) for m in models_a]
    right_b = [predictions(m, test, vocab) for m in models_b]
    acc_a = np.array([r.mean() for r in right_a])
    acc_b = np.array([r.mean() for r in right_b])
    print(f"  （训练 20 个模型用时 {time.perf_counter() - t0:.0f} 秒）")

    # ---------------- 演示 1 ----------------
    print_header("演示 1：同一个模型、10 个随机种子（GRU，训练集 500 条，测试集 2000 条）")
    print(f"\n    各个种子的测试准确率：{', '.join(f'{a:.1%}' for a in acc_a)}")
    print(f"    平均 {acc_a.mean():.1%}，标准差 {acc_a.std(ddof=1):.1%}，最低 {acc_a.min():.1%}，最高 {acc_a.max():.1%}")
    sem = acc_a.std(ddof=1) / np.sqrt(len(acc_a))
    print(f"    均值的 95% 置信区间 ≈ 平均 ± 2.26 × 标准差 / √10 = [{acc_a.mean() - 2.26 * sem:.1%}, {acc_a.mean() + 2.26 * sem:.1%}]")
    print("""
  观察：代码、数据、超参数完全相同，只换了随机种子，测试准确率就能差好几个百分点。
    作为对比，2000 条的测试集本身的抽样误差，标准差只有 √(p(1-p)/n) ≈ 0.8% 左右（01 号文件演示 4） ——
    这里**训练的随机性**比测试集的随机性还大。
    所以论文和报告里应该写"平均 ± 标准差（n 个种子）"，而不是只报一次运行的结果，更不能只报最好的那次。
    （2.26 是自由度为 9 的 t 分布的 97.5% 分位数；种子越多，这个数越接近 1.96。）""")

    # ---------------- 演示 2 ----------------
    print_header("演示 2：dropout 0 vs dropout 0.3 —— 哪个更好？")
    print(f"\n    {'种子':<6s}" + "".join(f"{s:>7d}" for s in seeds))
    print(f"    {'A: 0':<6s}" + "".join(f"{a:>7.1%}" for a in acc_a))
    print(f"    {'B: 0.3':<6s}" + "".join(f"{b:>7.1%}" for b in acc_b))
    wins = int((acc_b > acc_a).sum())
    print(f"\n    如果只用一个种子做实验：10 个种子里，有 {wins} 个会让你得出\"B 更好\"的结论，{10 - wins} 个会得出\"A 更好（或一样）\"。")
    diff, p = permutation_test(acc_a, acc_b)
    print(f"    10 个种子的平均：A {acc_a.mean():.1%}，B {acc_b.mean():.1%}，差 {diff:+.1%}；置换检验 p = {p:.3f}")
    print("""
  观察（具体数字以你运行的结果为准）：
    - 只跑一次（一个种子），结论可能取决于运气。（在这里 dropout 0.3 平均更差，
      可能是因为只训练了 10 轮、训练集又不大，dropout 拖慢了学习；训练更久时结论可能不同。）
    - 多跑几个种子，再用置换检验之类的方法问："这么大的差距，如果两种设置其实一样好，随机出现的可能性有多大？"
      p 值小（常用 0.05 为界），才比较有把握说其中一个更好；p 值大，只能说"没有足够的证据"，不能说"两者一样好"。
    - 同时尝试很多种改进、只报告 p < 0.05 的那些，同样会制造假的"显著"结果（多重比较问题）。""")

    # ---------------- 演示 3 ----------------
    print_header("演示 3：配对 bootstrap —— 在同一个测试集上比较两个训练好的模型")
    print()
    gap = acc_b - acc_a
    picks = [0, int(np.argmax(gap)), int(np.argmin(gap))]       # 种子 0、对 B 最有利的种子、对 B 最不利的种子
    for i, j in [(k, k) for k in picks]:
        d, p, (lo, hi) = paired_bootstrap(right_a[i], right_b[j])
        only_a = int(((right_a[i] == 1) & (right_b[j] == 0)).sum())
        only_b = int(((right_a[i] == 0) & (right_b[j] == 1)).sum())
        print(f"    种子 {i} 的 A vs 种子 {j} 的 B：B − A = {d:+.1%}，95% 置信区间 [{lo:+.1%}, {hi:+.1%}]，"
              f"B 不优于 A 的比例 {p:.3f}；只有 A 对 {only_a} 条，只有 B 对 {only_b} 条")
    print("""
  观察：
    - 配对 bootstrap 回答的问题是："对于**这两个已经训练好的模型**，在这种测试数据上，B 是否比 A 好？"
      两个模型在大部分样本上表现一样，差别只来自"一个对、一个错"的那些样本，配对比较只看这部分，
      所以比分别算两个置信区间、看它们是否重叠灵敏得多。
    - 但它**不考虑训练的随机性**：换一个种子重新训练，结论可能就变了（比较上面几行：
      同样是"设置 A vs 设置 B"，有的种子差距很明显，有的种子几乎没有差别）。
      要回答"设置 B 是否比设置 A 好"，需要演示 2 那样跑多个种子。两种检验回答的是不同的问题。""")

    # ---------------- 演示 4 ----------------
    print_header("演示 4：可复现性")
    m1 = train_one(cfg_a, train, vocab, seed=0)
    m2 = train_one(cfg_a, train, vocab, seed=0)
    same = all(torch.equal(p1, p2) for p1, p2 in zip(m1.parameters(), m2.parameters()))
    print(f"\n    同样的种子训练两次：参数完全相同？{same}；测试准确率 {predictions(m1, test, vocab).mean():.2%} 和 "
          f"{predictions(m2, test, vocab).mean():.2%}")
    set_seed(0)
    m3 = GRUClassifier(len(vocab))
    train_classifier(m3, train, vocab, epochs=10, lr=3e-3, seed=123)          # 初始化相同，只改变数据顺序
    print(f"    初始化相同、只改变数据顺序：测试准确率 {predictions(m3, test, vocab).mean():.2%}")
    print("""
  观察：
    - 在 CPU 上固定所有随机种子（set_seed），同样的代码可以得到**逐位相同**的结果。
    - 只改变数据的顺序，结果就不同了 —— 随机性的来源不止参数初始化。
    - 真实项目里还要记录：代码版本（git commit）、所有超参数、数据版本、库的版本、硬件。
      GPU 上有些运算本身是非确定性的，完全复现需要额外的设置（见 PyTorch 文档的 Reproducibility 一节）。""")


if __name__ == "__main__":
    main()
