"""
=============================================================================
CS486 · 第 2 讲 · 代码 03：调参预算与"A/A 测试"——同一个方法，为什么能"赢"自己？
=============================================================================

【这个脚本要回答的问题】（讲义 §3.3）
    "我们的方法比基线高 8 个点。"——如果新方法调了 12 组超参数、基线只用了论文里的默认值（或者调了 2 组），
    这个差距里有多少是方法本身，有多少是调参预算？Melis et al.（2018）发现，把 LSTM 基线认真调参之后，
    它打败了好几个"更先进"的语言模型架构；Dodge et al.（2019）建议报告"期望最大验证性能随调参预算的曲线"。

    我们做一个 **A/A 测试**：只有**一个**方法（2 层思维链模型，1000 步），从同一个超参数空间里随机抽
    24 组配置真实地训练（学习率、warmup、权重衰减、随机种子）。任何"方法 A 比方法 B 好"的结论都是假的。

    演示 1：24 次试验的结果——分布内准确率、分布外"开发集"和"测试集"准确率（n = 12 的长题面）。
    演示 2：期望最大开发集准确率随预算 k 的曲线（Dodge et al. 2019）。
    演示 3：A/A 测试——把 24 次试验随机分给"基线"和"新方法"，预算不同时，"新方法"平均赢多少？
    演示 4：在开发集上选、在测试集上报告 vs 直接在测试集上选。
    演示 5：开发集很小时（10 / 20 / 50 道题），在上面选出的"最好配置"的开发分比它的真实水平高多少——"赢家的诅咒"。

【运行】
    python3 03_tuning_budget.py           # 第一次约 1.5 分钟（6 个进程并行跑 24 次训练），之后约 1 秒
    python3 03_tuning_budget.py --quick   # 冒烟测试：4 次试验、每次 60 步，不写结果文件
    结果记录在 results/tuning_trials.jsonl（exp_kit.ExperimentLog）；删掉它就会重跑。中途中断后再运行，
    只会补跑缺少的试验（断点续跑）。

【依赖】PyTorch + 本目录的 exp_kit.py、method_lab.py + 第 1 讲的模块。
"""

import argparse
import os
import random
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))

import exp_kit as ek                                                        # noqa: E402
import method_lab as ml                                                     # noqa: E402
from console import pct, print_header, print_observations, print_table     # noqa: E402

LOG_PATH = os.path.join(HERE, "results", "tuning_trials.jsonl")
N_TRIALS = 24


def get_trials(quick):
    """
    取得 N_TRIALS 次试验的结果：日志里已有的直接读，缺的并行补跑并写进日志。

    【思路】这是"断点续跑"的标准写法：配置先确定下来（固定种子的随机搜索），每个配置有唯一指纹；
        日志里有这个指纹就跳过。这样中途中断、加几个配置、换一台机器继续跑，都不会重复或丢失。
    【返回】按 trial 编号排序的结果字典列表
    """
    if quick:
        cfgs = [dict(c, steps=60) for c in ml.sample_trial_configs(4)]
        return ek.run_parallel(ml.run_trial, cfgs, workers=2)
    cfgs = ml.sample_trial_configs(N_TRIALS)
    log = ek.ExperimentLog(LOG_PATH)
    done = {r["id"]: r["result"] for r in log.load()}
    todo = [c for c in cfgs if ek.config_id(c) not in done]
    if todo:
        print(f"  需要训练 {len(todo)} 个配置（日志里已有 {len(cfgs) - len(todo)} 个），并行运行 …", flush=True)
        t0 = time.perf_counter()
        for cfg, res in zip(todo, ek.run_parallel(ml.run_trial, todo)):
            log.append(cfg, res)
            done[ek.config_id(cfg)] = res
        print(f"  ⏱ 训练用时 {time.perf_counter() - t0:.0f} 秒（墙钟）", flush=True)
    else:
        print(f"  从 results/tuning_trials.jsonl 读取 {len(cfgs)} 次试验的结果", flush=True)
    return sorted((done[ek.config_id(c)] for c in cfgs), key=lambda r: r["trial"])


def demo1_trials(trials):
    print_header(f"演示 1：同一个方法的 {len(trials)} 次随机搜索试验（按开发集准确率排序）")
    rows = [[t["trial"], f"{t['lr']:.4f}", t["warmup"], t["weight_decay"], pct(t["in_dist"]), pct(t["dev"]), pct(t["test"])]
            for t in sorted(trials, key=lambda t: -t["dev"])]
    print_table(["试验", "学习率", "warmup", "权重衰减", "分布内", "开发集", "测试集"], rows,
                [6, 8, 8, 10, 8, 8, 8])
    print("（分布内：n = 8、k = 1–7，210 道；开发集 / 测试集：n = 12、k = 3，各 200 道，同分布不同题。）")


def demo2_expected_max(trials):
    print_header("演示 2：期望最大开发集准确率 vs 调参预算 k（Dodge et al. 2019）")
    dev = [t["dev"] for t in trials]
    rows, out = [], {}
    for k in (1, 2, 4, 8, 12, 16, 24):
        m, sd = ek.expected_max(dev, k), ek.expected_max_sd(dev, k)
        out[k] = m
        rows.append([k, pct(m), f"± {100 * sd:.1f}"])
    print_table(["预算 k（试验次数）", "期望最大开发集准确率", "标准差（点）"], rows, [18, 22, 14])
    return out


def demo3_aa(trials, reps=4000):
    print_header(f"演示 3：A/A 测试——把试验随机分给'基线'和'新方法'（{reps} 次随机分配；两者其实是同一个方法）")
    rng = random.Random(0)
    rows, out = [], {}
    for b, m in ((2, 12), (6, 6), (12, 2), (1, 1)):
        if b + m > len(trials):          # 【边界】试验不够分（--quick 只有 4 次）时跳过这一行
            continue
        gaps, gaps_test = [], []
        for _ in range(reps):
            pick = rng.sample(trials, b + m)
            base, new = pick[:b], pick[b:]
            best_b = max(base, key=lambda t: t["dev"])
            best_m = max(new, key=lambda t: t["dev"])
            gaps.append(best_m["dev"] - best_b["dev"])
            gaps_test.append(best_m["test"] - best_b["test"])
        mean_gap = sum(gaps) / reps
        win5 = sum(g >= 0.05 for g in gaps) / reps
        out[(b, m)] = (mean_gap, win5, sum(gaps_test) / reps)
        rows.append([f"基线 {b} 次 vs 新方法 {m} 次", f"{100 * mean_gap:+.1f}", pct(win5),
                     f"{100 * sum(gaps_test) / reps:+.1f}"])
    print_table(["调参预算", "'新方法'平均领先（开发集，点）", "领先 ≥ 5 个点的比例", "测试集上平均领先（点）"],
                rows, [24, 28, 20, 22])
    return out


def demo4_winners_curse(trials):
    print_header("演示 4：在哪里选、在哪里报告——赢家的诅咒")
    best_dev = max(trials, key=lambda t: t["dev"])
    best_test = max(trials, key=lambda t: t["test"])
    n = len(trials)
    mean_dev = sum(t["dev"] for t in trials) / n
    mean_test = sum(t["test"] for t in trials) / n
    cov = sum((t["dev"] - mean_dev) * (t["test"] - mean_test) for t in trials) / (n - 1)
    sd_d = (sum((t["dev"] - mean_dev) ** 2 for t in trials) / (n - 1)) ** 0.5
    sd_t = (sum((t["test"] - mean_test) ** 2 for t in trials) / (n - 1)) ** 0.5
    corr = cov / (sd_d * sd_t)
    rows = [
        ["开发集上最好的试验：开发集准确率", f"试验 {best_dev['trial']}", pct(best_dev["dev"])],
        ["同一个试验：测试集准确率（诚实的报告）", f"试验 {best_dev['trial']}", pct(best_dev["test"])],
        ["直接在测试集上挑最好的（不诚实的报告）", f"试验 {best_test['trial']}", pct(best_test["test"])],
        ["24 次试验的测试集平均", "-", pct(mean_test)],
    ]
    print_table(["报告方式", "试验", "准确率"], rows, [40, 10, 10], ["<", "<", ">"])
    print(f"开发集和测试集准确率的相关系数（24 次试验）= {corr:.3f}")
    return {"best_dev": best_dev, "best_test": best_test, "mean_test": mean_test, "corr": corr}


def demo5_small_dev(trials, reps=4000):
    print_header(f"演示 5：开发集很小时，赢家的诅咒有多大？（从 200 道开发题里随机抽 m 道当开发集，重复 {reps} 次）")
    rng = random.Random(1)
    n_items = len(trials[0]["dev_vec"])
    best_possible = max(t["test"] for t in trials)
    rows, out = [], {}
    for m in (10, 20, 50, 200):
        opt, test_sel = [], []
        for _ in range(reps):
            items = rng.sample(range(n_items), m)
            scores = [sum(t["dev_vec"][i] for i in items) / m for t in trials]
            j = max(range(len(trials)), key=lambda q: scores[q])     # 并列时取编号最小的
            opt.append(scores[j] - trials[j]["test"])
            test_sel.append(trials[j]["test"])
        out[m] = (sum(opt) / reps, sum(test_sel) / reps)
        rows.append([m, f"{100 * sum(opt) / reps:+.1f}", pct(sum(test_sel) / reps)])
    print_table(["开发集大小 m", "选中配置：开发分 − 测试分（点）", "选中配置的测试集准确率（平均）"], rows, [14, 30, 30])
    print(f"（24 个配置里测试集最好的是 {pct(best_possible)}；m = 200 时每次抽到的是同一组题，没有随机性。）")
    return out


def main():
    ap = argparse.ArgumentParser(description="调参预算与 A/A 测试")
    ap.add_argument("--quick", action="store_true", help="冒烟测试：4 次试验，每次 60 步")
    args = ap.parse_args()
    t0 = time.perf_counter()

    print_header("准备：从同一个超参数空间随机抽配置，真实训练")
    print("超参数空间：学习率 ~ 对数均匀 [2e-3, 8e-3]；warmup ∈ {50, 200}；权重衰减 ∈ {0, 0.01, 0.1}；随机种子随机。")
    trials = get_trials(args.quick)
    demo1_trials(trials)
    em = demo2_expected_max(trials)
    aa = demo3_aa(trials, reps=200 if args.quick else 4000)
    wc = demo4_winners_curse(trials)
    sd = demo5_small_dev(trials, reps=200 if args.quick else 4000)

    if args.quick:
        print("\n（--quick 冒烟测试：模型只训练了 60 步，上面的数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    devs = sorted(t["dev"] for t in trials)
    print_observations([
        f"24 次试验在分布内的最低准确率是 {pct(min(t['in_dist'] for t in trials))}——分布内的指标已经饱和，区分不出任何配置；"
        f"分布外的开发集准确率却从 {pct(devs[0])} 到 {pct(devs[-1])} 不等。选评测指标时要先确认它**能区分**你关心的差别。",
        f"演示 2：只给 1 次试验时，期望的开发集准确率是 {pct(em[1])}；给 12 次时是 {pct(em[12])}——"
        f"同一个方法，仅仅因为调参次数不同，'报告的分数'就差了 {100 * (em[12] - em[1]):.1f} 个点。",
        f"演示 3：基线调 2 次、新方法调 12 次时，'新方法'在开发集上平均领先 {100 * aa[(2, 12)][0]:+.1f} 个点，"
        f"{pct(aa[(2, 12)][1])} 的情况下领先 5 个点以上——可两者是同一个方法。预算相同（6 vs 6）时平均领先 "
        f"{100 * aa[(6, 6)][0]:+.1f}。比较方法时，要么给相同的调参预算，要么报告整条期望最大值曲线。",
        f"演示 3 的最后一列：把各自选出的配置拿到测试集上，'新方法'仍然领先 {100 * aa[(2, 12)][2]:+.1f} 个点。"
        "这个领先**是真的**、能复现——多调几次参确实找到了更好的配置；但它来自调参预算，不来自'新方法'的想法。"
        "这正是它危险的地方：换一套测试题也查不出来，只有对等的调参预算才能把它和方法本身的贡献分开。",
        f"演示 4：开发集上最好的试验，开发集 {pct(wc['best_dev']['dev'])}、测试集 {pct(wc['best_dev']['test'])}；"
        f"直接在测试集上挑会报告 {pct(wc['best_test']['test'])}。开发集和测试集的相关系数高达 {wc['corr']:.3f}："
        "这里配置之间的真实差别远大于 200 道题的抽样噪声，所以'在哪里选'几乎没有影响。",
        f"演示 5：开发集变小时情况就不同了。只用 10 / 20 / 50 道开发题选配置时，选中配置的开发分平均比它的测试分高 "
        f"{100 * sd[10][0]:+.1f} / {100 * sd[20][0]:+.1f} / {100 * sd[50][0]:+.1f} 个点（赢家的诅咒），"
        f"选中配置的测试准确率也从 {pct(sd[200][1])}（m = 200）降到 {pct(sd[10][1])}（m = 10）。"
        "很多推理基准只有几十道题——在上面反复挑选，报告的分数会系统性地偏高。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
