"""
=============================================================================
第 8 讲 · 代码 3：超参数搜索 —— 网格搜索 vs 随机搜索
=============================================================================

对应讲义：lectures/lecture08_practical_methods.md 第 6 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
学习率、隐藏层大小、dropout、权重衰减……每个模型都有一堆"超参数"，它们不是训练出来的，要我们自己选。
怎样在有限的计算预算里选到好的超参数？

    演示 1：为什么随机搜索常常比网格搜索好（Bergstra & Bengio 2012 的经典论证）
    演示 2：学习率要在**对数尺度**上搜索
    演示 3：真实的搜索：GRU 情感分类器，4 个超参数，网格搜索 16 次 vs 随机搜索 16 次
    演示 4：赢家的诅咒 —— "最好的验证准确率"会偏乐观

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 03_hyperparameter_search.py

需要 PyTorch，约 1~2 分钟（演示 3 要训练 32 个小模型）。
"""

import os

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

import numpy as np
import torch

from classifiers import GRUClassifier, evaluate, 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)


# =============================================================================
# 演示 1：网格 vs 随机（人造的目标函数）
# =============================================================================
def toy_objective(x, y):
    """
    一个人造的"验证准确率"：两个超参数 x、y 都在 [0, 1] 之间。
    **只有 x 重要**（在 x = 0.73 附近有一个窄峰），y 几乎没有影响。

    【NLP 联系】真实情况往往如此：几个超参数里，通常只有一两个（比如学习率）真正重要，
    但我们事先不知道是哪几个。
    """
    return 0.6 + 0.3 * np.exp(-((x - 0.73) ** 2) / 0.005) + 0.01 * np.sin(6 * y)


def demo_grid_vs_random():
    print_header("演示 1：为什么随机搜索常常比网格搜索好？")
    rng = np.random.default_rng(0)
    print("""
  人造的目标函数 f(x, y)：只有 x 重要（在 x = 0.73 附近有一个窄峰，峰值 0.91），y 几乎没有影响。
  同样的预算下，比较网格搜索（每个维度均匀取几个值）和随机搜索（在整个区域里随机取点）。""")
    print(f"\n    {'预算':<8s}{'网格：试过几个不同的 x':>20s}{'网格找到的最好值':>16s}{'随机：找到的最好值（1000 次实验的平均）':>34s}")
    for k in [3, 4, 5]:
        grid = np.linspace(0.1, 0.9, k)
        best_grid = max(toy_objective(x, y) for x, y in itertools.product(grid, grid))
        n = k * k
        best_rand = np.mean([toy_objective(*rng.random((2, n))).max() for _ in range(1000)])
        print(f"    {n:<10d}{k:>16d}{best_grid:>20.3f}{best_rand:>26.3f}")
    print("""
  观察：
    - 网格搜索 9 次，其实只试了 3 个不同的 x —— 另外 6 次都"浪费"在了不重要的 y 上；
      随机搜索 9 次，就试了 9 个不同的 x。当只有少数超参数重要时，随机搜索在重要的维度上"覆盖"得更密。
    - 网格的结果还取决于运气：峰正好落在网格点附近才能找到，否则怎么加预算都可能错过（试试改网格的范围）。
    - 随机搜索还有一个实际的好处：可以随时停下、随时加预算；网格搜索要么跑完、要么白跑。""")


# =============================================================================
# 演示 2：对数尺度
# =============================================================================
def demo_log_scale():
    print_header("演示 2：学习率要在对数尺度上搜索")
    rng = np.random.default_rng(0)
    lo, hi = 1e-5, 1.0
    uniform = rng.uniform(lo, hi, 100000)
    log_uniform = 10 ** rng.uniform(np.log10(lo), np.log10(hi), 100000)
    good = (1e-3, 1e-1)
    print(f"\n    在 [{lo:g}, {hi:g}] 里抽 10 万个学习率，落在\"好的区间\" [{good[0]:g}, {good[1]:g}] 里的比例：")
    for name, x in [("均匀抽样", uniform), ("对数均匀抽样", log_uniform)]:
        inside = ((x >= good[0]) & (x <= good[1])).mean()
        print(f"      {name:<8s}{inside:>8.1%}    （小于 1e-3 的比例 {(x < 1e-3).mean():.2%}）")
    print("""
  观察：在 1e-5 到 1 之间均匀抽样，绝大多数样本都大于 0.1，小于 1e-3 的几乎一个都没有 ——
    但学习率从 1e-5 到 1e-4 和从 0.1 到 1 是同样"大"的变化（都是 10 倍）。
    学习率、权重衰减、正则化强度这类超参数，影响的是"数量级"，应该在对数尺度上均匀抽样：
    先抽 u ~ Uniform(-5, 0)，再令 lr = 10^u。
    而 dropout 概率（0~0.5）、层数这类超参数，在普通尺度上抽样就可以。""")


# =============================================================================
# 演示 3、4：真实的搜索
# =============================================================================
def run_trial(cfg, train, dev, test, vocab, seed=0):
    """
    用一组超参数训练一个 GRU 分类器。

    【参数】
        cfg : dict，含 lr、hidden、dropout、weight_decay
        train, dev, test, vocab : 数据和词表
        seed : 随机种子

    【返回】(验证准确率, 测试准确率)。验证准确率取所有轮次里最高的一轮（相当于早停），
            测试准确率是**那一轮**的模型在测试集上的结果。

    【边界】测试准确率只用于最后的报告，**选超参数时绝对不能看它**（演示 4、05 号文件）。
    """
    set_seed(seed)
    model = GRUClassifier(len(vocab), hidden=cfg["hidden"], dropout=cfg["dropout"])
    best_dev, best_state = -1.0, None
    for epoch in range(10):
        train_classifier(model, train, vocab, epochs=1, lr=cfg["lr"], weight_decay=cfg["weight_decay"], seed=epoch)
        acc = evaluate(model, dev, vocab)[0]
        if acc > best_dev:
            best_dev = acc
            best_state = {k: v.clone() for k, v in model.state_dict().items()}
    model.load_state_dict(best_state)
    return best_dev, evaluate(model, test, vocab)[0]


def demo_real_search(train, dev, test, vocab):
    print_header("演示 3：GRU 情感分类器的超参数搜索（训练集 500 条，每种搜索 16 次）")
    grid_values = {"lr": [3e-4, 1e-2], "hidden": [32, 128], "dropout": [0.0, 0.4], "weight_decay": [1e-4, 1e-2]}
    grid = [dict(zip(grid_values, v)) for v in itertools.product(*grid_values.values())]
    rng = np.random.default_rng(1)
    random_cfgs = [{"lr": float(10 ** rng.uniform(-4, -1.5)),              # 对数尺度：1e-4 ~ 3e-2
                    "hidden": int(2 ** rng.uniform(4, 7.5)),                 # 对数尺度：16 ~ 181
                    "dropout": float(rng.uniform(0, 0.5)),
                    "weight_decay": float(10 ** rng.uniform(-5, -1))} for _ in range(16)]
    t0 = time.perf_counter()
    results = {}
    for name, cfgs in [("网格搜索", grid), ("随机搜索", random_cfgs)]:
        results[name] = [(cfg, *run_trial(cfg, train, dev, test, vocab)) for cfg in cfgs]
    print(f"\n  （32 次训练用时 {time.perf_counter() - t0:.0f} 秒）")
    for name, res in results.items():
        lrs = sorted({round(c["lr"], 6) for c, _, _ in res})
        best_cfg, best_dev, best_test = max(res, key=lambda r: r[1])
        print(f"\n  {name}：试过 {len(lrs)} 个不同的学习率")
        print(f"    验证准确率：最好 {best_dev:.1%}，中位数 {np.median([d for _, d, _ in res]):.1%}，最差 {min(d for _, d, _ in res):.1%}")
        print(f"    最好的一组：lr = {best_cfg['lr']:.1e}，hidden = {best_cfg['hidden']}，dropout = {best_cfg['dropout']:.2f}，"
              f"weight_decay = {best_cfg['weight_decay']:.1e}")
        print(f"    它在测试集上：{best_test:.1%}")
    res = results["随机搜索"]
    print(f"\n  随机搜索的 16 次试验，按学习率排序（看看哪个超参数最重要）：")
    print(f"    {'lr':>9s}{'hidden':>8s}{'dropout':>9s}{'wd':>9s}{'验证准确率':>10s}")
    for cfg, d, _ in sorted(res, key=lambda r: r[0]["lr"]):
        print(f"    {cfg['lr']:>9.1e}{cfg['hidden']:>8d}{cfg['dropout']:>9.2f}{cfg['weight_decay']:>9.1e}{d:>12.1%}")
    logs = np.log10([c["lr"] for c, _, _ in res])
    devs = np.array([d for _, d, _ in res])
    print(f"\n    验证准确率和 log10(学习率) 的相关系数：{np.corrcoef(logs, devs)[0, 1]:.2f}；"
          f"和 dropout 的相关系数：{np.corrcoef([c['dropout'] for c, _, _ in res], devs)[0, 1]:.2f}")
    print("""
  观察（具体数字以你运行的结果为准）：
    - 网格搜索 16 次只试了 2 个学习率；随机搜索 16 次试了 16 个。
    - 按学习率排序后很容易看出：学习率是最重要的超参数（太小的学习率在 10 轮内根本学不好），
      其他超参数的影响小得多。这种"先粗搜、看哪个重要，再在重要的维度上细搜"（coarse-to-fine）是常用的策略。
    - 每次试验都有随机性（初始化、数据顺序），两组结果相差一两个百分点时，不一定说明哪组超参数真的更好（04 号文件）。""")
    return results


def demo_winners_curse(results):
    print_header("演示 4：赢家的诅咒 —— 最好的验证准确率会偏乐观")
    print()
    for name, res in results.items():
        devs = np.array([d for _, d, _ in res])
        tests = np.array([t for _, _, t in res])
        best = int(np.argmax(devs))
        print(f"    {name}：最好的验证准确率 {devs[best]:.1%} → 同一个模型的测试准确率 {tests[best]:.1%}"
              f"（16 次试验的验证 − 测试平均差 {np.mean(devs - tests):+.1%}）")
    print("""
  观察（具体数字以你运行的结果为准）：
    - 我们从 16 次试验里挑了验证准确率**最高**的那个。它之所以最高，一部分是因为超参数真的好，
      另一部分可能是因为它在这个特定的验证集上"运气好"。换到测试集上，运气的部分就没有了。
    - 在这里这个偏差很小（不到一两个百分点）：验证集有 1000 条，只比较了 16 组超参数。
      **验证集越小、比较的次数越多，偏差就越大** —— 05 号文件演示 2 用一个极端的例子量化这件事。
    - 所以：**选超参数用验证集，报告结果用测试集**，测试集只在最后用一次。
      如果你用测试集来选超参数，报告的测试准确率同样会偏乐观。
    - 这里每个模型的"早停轮次"也是在验证集上选的，同样会让验证准确率偏高一点。""")


def main():
    demo_grid_vs_random()
    demo_log_scale()
    train = make_dataset(500, seed=0)
    dev = make_dataset(1000, seed=1)
    test = make_dataset(2000, seed=2)
    results = demo_real_search(train, dev, test, Vocab())
    demo_winners_curse(results)


if __name__ == "__main__":
    main()
