"""
=============================================================================
第 5 讲 · 代码 4：正则化、提前停止与交叉验证
=============================================================================

对应讲义：lectures/lecture05_mle.md 第 6、7 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
数据少、选项多时，最大似然会过拟合：某个选项碰巧全胜，它的分数就被推到无穷大（第 2 讲第 4.2 节）。
L2 正则化把分数往 0 拉；可是拉多少？这个文件回答三个问题：

    演示 1：正则化强度 λ 的扫描 —— 训练集上越小越好，测试集上有一个最好的 λ（偏差–方差权衡）。
            L2 正则化 = 高斯先验下的最大后验估计（MAP），最好的 λ 和"真实分数的分散程度"有关。
    演示 2：提前停止 —— 不加正则化，从 0 开始做梯度上升，走到一半就停下，效果和正则化差不多。
    演示 3：交叉验证选 λ —— 5 折交叉验证选出的 λ 接近最好的 λ。
    演示 4：一个陷阱 —— 用交叉验证"选"完以后，再把交叉验证的分数当作最终成绩报告，会过于乐观。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_regularization_cv.py        # 约 1 秒

需要 numpy。只依赖本目录的 bt_numpy.py。
"""

import numpy as np

from bt_numpy import fit_gd, fit_newton, kfold, log_loss, objective, pad, predict, print_header, simulate_bt

M = 60
LAMBDAS = [1e-5, 3e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1]


def make_problem(rng, n_train, sd=1.0, n_test=40000):
    """
    造一个"选项多、数据少"的问题：60 个选项，真实分数 ~ N(0, sd²)；训练集 n_train 条，测试集 n_test 条。

    【返回】(真实分数, 训练数据 (i, j, y), 测试数据 (i, j, y))
    """
    true_s = rng.normal(0, sd, size=M)
    true_s -= true_s.mean()
    return true_s, simulate_bt(true_s, n_train, rng), simulate_bt(true_s, n_test, rng)


def test_loss(s, test):
    """测试集上的平均对数损失。"""
    i, j, y = test
    return log_loss(predict(s, i, j), y)


def rmse(a, b):
    """均方根误差（两者都先平移到平均值为 0）。"""
    return float(np.sqrt(np.mean(((a - a.mean()) - (b - b.mean())) ** 2)))


# =============================================================================
# 演示 1：λ 扫描
# =============================================================================
def demo_lambda_sweep(rng):
    print_header("演示 1：正则化强度 λ 的扫描 —— 偏差与方差的权衡")
    true_s, train, test = make_problem(rng, 300)
    i, j, y = train
    winner = np.where(y == 1, i, j)
    loser = np.where(y == 1, j, i)
    never_lost = np.setdiff1d(winner, loser)
    print(f"60 个选项、只有 300 条训练比较（平均每个选项 10 场）。其中 {len(never_lost)} 个选项一场都没输过。")
    print(f"真实分数的标准差 1.0；按“高斯先验”的推导，最好的 λ 大约是 1/(N·σ²) = 1/(300 × 1) ≈ {1 / 300:.4f}。\n")
    print(f"  {pad('λ', 8)} {pad('训练损失', 9, 'right')} {pad('测试损失', 9, 'right')} {pad('和真实分数的误差', 16, 'right')} "
          f"{pad('最大的分数', 10, 'right')}")
    for lam in LAMBDAS:
        s, _ = fit_newton(i, j, y, M, l2=lam)
        tr = -objective(s, i, j, y, 0.0)
        print(f"  {lam:<8g} {tr:>9.3f} {test_loss(s, test):>9.3f} {rmse(s, true_s):>16.3f} {s.max():>10.2f}")
    print(f"  {pad('（真实分数）', 8)} {-objective(true_s, i, j, y, 0.0):>9.3f} {test_loss(true_s, test):>9.3f} {0.0:>16.3f} {true_s.max():>10.2f}")
    print("""
观察：
  · λ 很小（几乎不正则化）：训练损失最低，测试损失却最高 —— 全胜的选项分数被推得很高（“最大的分数”一栏），
    模型对训练数据里的偶然结果过于自信。这是**过拟合**（方差大）。
  · λ 很大：所有分数被压向 0，模型什么都学不到，训练和测试损失都高。这是**欠拟合**（偏差大）。
  · 中间有一个最好的 λ，大约在 1/(N σ²) 附近 —— 这不是巧合：
    L2 正则化 ⇔ 给每个分数一个高斯先验 N(0, τ²)，最大后验估计（MAP）的目标是
        Σ log P(数据 | s) − Σ s² / (2τ²)  =  N · [平均对数似然 − (λ/2)‖s‖²]，λ = 1/(N τ²)。
    先验的方差 τ² 取真实分数的方差时最合适。第 6 讲会把这个想法推到底：不只要一个“最可能的”分数，
    而要整个后验分布。
  · 注意测试损失的下限是“真实分数”那一行：数据本身有噪声，再好的模型也到不了 0。""")
    return true_s, train, test


# =============================================================================
# 演示 2：提前停止
# =============================================================================
def demo_early_stopping(true_s, train, test):
    print_header("演示 2：提前停止 —— 不加正则化，走到一半就停")
    i, j, y = train
    print("从 s = 0 开始做梯度上升（不加正则化，步长 1/L），记录每走一段之后的测试损失：\n")
    print(f"  {pad('步数', 8, 'right')} {pad('训练损失', 9, 'right')} {pad('测试损失', 9, 'right')}")
    for steps in [1, 5, 20, 50, 100, 300, 1000, 5000]:
        s, _ = fit_gd(i, j, y, M, l2=0.0, max_iters=steps, tol=0.0)
        print(f"  {steps:>8d} {-objective(s, i, j, y, 0.0):>9.3f} {test_loss(s, test):>9.3f}")
    print("""
观察：
  · 训练损失一直在降；测试损失先降后升 —— 走得太远就开始拟合噪声。
  · 在测试损失最低的地方（这里是第 5 步左右）停下，效果和最好的 L2 正则化差不多。原因：从 0 出发的梯度上升，前几步主要学到
    “数据最确定的方向”（曲率大的方向），后面才慢慢去拟合不确定的方向；提前停止相当于没让它走完。
  · 提前停止不用调 λ，但要一个验证集来决定“什么时候停”。训练神经网络奖励模型时（第 6 讲），
    提前停止是最常用的正则化手段之一 —— 很多奖励模型只训练一个周期（epoch）就停。""")


# =============================================================================
# 演示 3：交叉验证选 λ
# =============================================================================
def cv_loss(train, lam, rng, k=5):
    """k 折交叉验证：轮流用 k−1 份训练、1 份验证，返回 k 个验证损失的平均。"""
    i, j, y = train
    losses = []
    for tr, va in kfold(len(y), k, rng):
        s, _ = fit_newton(i[tr], j[tr], y[tr], M, l2=lam)
        losses.append(log_loss(predict(s, i[va], j[va]), y[va]))
    return float(np.mean(losses)), float(np.std(losses))


def demo_cross_validation(rng):
    print_header("演示 3：5 折交叉验证选 λ")
    true_s, train, test = make_problem(rng, 300)
    i, j, y = train
    print("同样的设定（60 个选项、300 条训练比较）。对每个 λ 算 5 折交叉验证的平均验证损失，再和“真正的”测试损失比较：\n")
    print(f"  {pad('λ', 8)} {pad('交叉验证损失', 13, 'right')} {pad('（各折的标准差）', 16, 'right')} {pad('测试损失', 9, 'right')}")
    rows = []
    for lam in LAMBDAS:
        cv, sd = cv_loss(train, lam, rng)
        s, _ = fit_newton(i, j, y, M, l2=lam)
        t = test_loss(s, test)
        rows.append((lam, cv, t))
        print(f"  {lam:<8g} {cv:>13.3f} {sd:>16.3f} {t:>9.3f}")
    pick = min(rows, key=lambda r: r[1])
    best = min(rows, key=lambda r: r[2])
    print(f"\n  交叉验证选出 λ = {pick[0]:g}（测试损失 {pick[2]:.3f}）；事后看测试集最好的是 λ = {best[0]:g}（{best[2]:.3f}）。")
    print("""
观察：
  · 交叉验证损失和测试损失的“形状”很像，选出的 λ 和事后最好的那个相邻，测试损失只差一点。它只用训练数据，
    没有偷看测试集。
  · 各折之间的标准差不小：只有 300 条数据时，交叉验证本身也有噪声。差别小于一个标准差的几个 λ，其实分不出好坏。
  · 划分的单位：这里每条比较是独立的，所以按条划分。如果数据有分组（同一次排序、同一个提示词、同一个标注员），
    要按组划分（第 2 讲第 3.5、5.7 节），否则交叉验证会过于乐观。""")


# =============================================================================
# 演示 4：选完再报告的乐观偏差
# =============================================================================
def demo_selection_bias(rng, reps=30):
    print_header("演示 4：用交叉验证“挑完”以后，交叉验证的分数就不能再当作最终成绩")
    many = list(np.geomspace(1e-4, 1e-1, 12))
    fixed_gap, selected_gap = [], []
    for _ in range(reps):
        true_s, train, test = make_problem(rng, 150, n_test=20000)
        i, j, y = train
        # (a) 事先固定 λ = 0.003，不做挑选
        cv_fixed = cv_loss(train, 3e-3, rng, k=5)[0]
        s, _ = fit_newton(i, j, y, M, l2=3e-3)
        fixed_gap.append(cv_fixed - test_loss(s, test))
        # (b) 在 12 个候选里挑交叉验证损失最低的
        scores = [(lam, cv_loss(train, lam, rng, k=5)[0]) for lam in many]
        lam, cv = min(scores, key=lambda r: r[1])
        s, _ = fit_newton(i, j, y, M, l2=lam)
        selected_gap.append(cv - test_loss(s, test))
    print(f"60 个选项、只有 150 条训练比较。重复 {reps} 次实验，比较“交叉验证损失 − 真正的测试损失”：\n")
    print(f"  {pad('做法', 34)} {pad('平均差值', 9, 'right')}  {pad('交叉验证偏乐观的实验比例', 24, 'right')}")
    for name, gaps in [("事先固定 λ = 0.003（不挑）", fixed_gap), ("在 12 个 λ 里挑交叉验证最好的", selected_gap)]:
        g = np.array(gaps)
        print(f"  {pad(name, 34)} {g.mean():>+9.3f}  {np.mean(g < 0):>24.0%}")
    print(f"\n  “挑选”这一步让交叉验证的分数平均乐观了 {np.mean(fixed_gap) - np.mean(selected_gap):.3f}。")
    print("""
观察：
  · 不做挑选时，交叉验证损失平均**高于**测试损失（偏悲观）：每一折只用 4/5 的数据训练，模型比用全部数据训练的差一点。
  · 从 12 个候选里挑出“交叉验证损失最低”的那个，很可能挑中的是“碰巧被低估得最多”的那个 —— 挑选把分数往乐观的方向推，
    推的幅度超过了原来的悲观偏差。这和“考了 12 次挑最高分”是一回事；候选越多、数据越少，推得越厉害。
  · 正确的做法：要么另留一个从没参与过选择的测试集，最后只用一次；要么用“嵌套交叉验证”
    （外层估计性能，内层选超参数；Cawley & Talbot 2010）。
  · 在论文和项目里，这是最常见的评估错误之一：在测试集上反复调参，最后报告测试集上的分数。
    第 1 讲第 7.3 节的“预分析计划”就是为了防止这种“分岔小径”。""")


def main():
    rng = np.random.default_rng(2004)
    true_s, train, test = demo_lambda_sweep(rng)
    demo_early_stopping(true_s, train, test)
    demo_cross_validation(rng)
    demo_selection_bias(rng)


if __name__ == "__main__":
    main()
