"""
=============================================================================
第 3 讲 · 代码 5：训练神经网络的实用技巧 —— 初始化、优化器、学习率、正则化
=============================================================================

对应讲义：lectures/lecture03_neural_networks_backprop.md 第 8 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
能写出前向和反向传播，离"把网络训练好"还差得很远。这个文件用小实验演示几个最重要的实践经验：

    演示 1：参数初始化 —— 全零初始化为什么不行？初始值太小、太大会怎样？Xavier / He 初始化
    演示 2：优化器 —— SGD、带动量的 SGD、Adam、AdamW
    演示 3：学习率 —— 太小、合适、太大
    演示 4：过拟合与正则化 —— L2（权重衰减）和 dropout；训练模式 vs 评估模式

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 05_training_tricks.py

需要 numpy 和 PyTorch。运行时间约 1~2 分钟（CPU）。
"""

import importlib

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

mlp_np = importlib.import_module("03_numpy_mlp")       # 复用螺旋数据集


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


def spiral_tensors(n_per_class=100, noise=0.2, seed=0):
    """生成螺旋数据并转成 PyTorch 张量。返回 (X: float32 (N, 2), y: long (N,))。"""
    X, y = mlp_np.make_spiral(n_per_class=n_per_class, noise=noise, seed=seed)
    return torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.long)


def make_mlp(sizes, activation=nn.ReLU, dropout=0.0):
    """
    按层大小列表搭一个多层感知机，例如 sizes = [2, 100, 3]。

    【参数】
        sizes      : list[int]，每层的宽度（第一个是输入维度，最后一个是类别数）
        activation : 激活函数的类（nn.ReLU、nn.Tanh……）
        dropout    : float，每个隐藏层之后的 dropout 概率（0 表示不用）

    【返回】nn.Sequential

    【Python 小知识】
        nn.Sequential(层1, 层2, ...)：把若干层串起来，输入依次经过每一层。
        *layers：把列表"展开"成多个参数传进去。
    """
    layers = []
    for i in range(len(sizes) - 2):
        layers += [nn.Linear(sizes[i], sizes[i + 1]), activation()]
        if dropout > 0:
            layers.append(nn.Dropout(dropout))
    layers.append(nn.Linear(sizes[-2], sizes[-1]))
    return nn.Sequential(*layers)


def fit(model, X, y, optimizer, steps):
    """
    全批量训练若干步，返回每一步的损失列表。

    【边界】
        - 损失变成 nan 或 inf（学习率太大时）会提前停止，后面的步数用 nan 填充。
    """
    losses = []
    for _ in range(steps):
        optimizer.zero_grad()
        loss = F.cross_entropy(model(X), y)
        if not torch.isfinite(loss):
            losses += [float("nan")] * (steps - len(losses))
            break
        loss.backward()
        optimizer.step()
        losses.append(loss.item())
    return losses


@torch.no_grad()
def accuracy(model, X, y):
    """在评估模式下计算准确率（会关闭 dropout）。"""
    model.eval()
    acc = (model(X).argmax(dim=1) == y).float().mean().item()
    model.train()
    return acc


# =============================================================================
# 演示 1：初始化
# =============================================================================
def demo_initialization():
    """演示 1：初始化对深层网络中激活值的影响。"""
    print_header("演示 1：参数初始化")

    # (a) 全零 / 全相同的初始化：对称性无法打破
    X, y = spiral_tensors()
    for name, value in [("全零", 0.0), ("全部等于 0.5 的", 0.5)]:
        net = make_mlp([2, 4, 3], activation=nn.Tanh)
        for prm in net.parameters():
            nn.init.constant_(prm, value)
        fit(net, X, y, torch.optim.SGD(net.parameters(), lr=1.0), 200)
        print(f"\n  (a) {name}初始化，训练 200 步后第一层的权重（4 个隐藏单元，每行一个），训练准确率 {accuracy(net, X, y):.1%}：")
        for row in net[0].weight.detach():
            print("        " + "  ".join(f"{v:+.4f}" for v in row.tolist()))
    print("""
      → 全零：隐藏层输出全是 tanh(0) = 0，输出层权重也是 0，传回第一层的梯度恰好为 0，第一层根本不动。
        全部相同：所有隐藏单元始终一模一样——同样的输入、同样的参数 → 同样的梯度 → 同样的更新。
        网络再宽，实际上也只有"一个"隐藏单元。必须用随机数打破对称性。""")

    # (b) 深层 tanh 网络中各层激活值的标准差
    print("\n  (b) 10 层、每层 256 个单元的 tanh 网络，输入 512 个随机样本，各层激活值的标准差：")
    torch.manual_seed(0)
    x0 = torch.randn(512, 256)
    schemes = {
        "N(0, 0.01²)": lambda n_in: 0.01,
        "N(0, 1)": lambda n_in: 1.0,
        "Xavier：N(0, 1/n_in)": lambda n_in: (1.0 / n_in) ** 0.5,
    }
    for name, std_fn in schemes.items():
        h, stds, sat = x0, [], []
        for _ in range(10):
            W = torch.randn(256, 256) * std_fn(256)
            h = torch.tanh(h @ W)
            stds.append(h.std().item())
            sat.append((h.abs() > 0.99).float().mean().item())
        print(f"    {name:<22s}第 1/4/7/10 层：" + " ".join(f"{stds[i]:.4f}" for i in [0, 3, 6, 9]) +
              f"   （第 10 层 |h| > 0.99 的比例 {sat[-1]:.0%}）")
    print("""
      → 初始值太小：每过一层信号缩小一截，到深层几乎全是 0，梯度也随之消失；
        初始值太大：tanh 全部饱和在 ±1，局部梯度 1 - tanh² ≈ 0，同样学不动；
        Xavier 初始化让信号在 10 层之后仍处于合理范围（讲义第 8.1 节有推导）。
        它仍然在缓慢变小，因为 tanh 会把值往 0 压（|tanh(x)| < |x|），推导中"线性"的假设只是近似。""")

    # (c) ReLU + He 初始化
    print("  (c) 同样的网络换成 ReLU，各层激活值的标准差：")
    for name, std in [("Xavier：N(0, 1/n_in)", (1 / 256) ** 0.5), ("He：N(0, 2/n_in)", (2 / 256) ** 0.5)]:
        h, stds = x0, []
        torch.manual_seed(0)
        for _ in range(10):
            h = torch.relu(h @ (torch.randn(256, 256) * std))
            stds.append(h.std().item())
        print(f"    {name:<22s}第 1/4/7/10 层：" + " ".join(f"{stds[i]:.4f}" for i in [0, 3, 6, 9]))
    print("      → ReLU 把一半的值砍成 0，方差每层减半；He 初始化把方差乘 2 补回来。")


# =============================================================================
# 演示 2：优化器
# =============================================================================
def demo_optimizers():
    """演示 2：不同优化器的收敛速度。"""
    print_header("演示 2：优化器（螺旋数据，2 → 100 → 3 的 ReLU 网络，全批量 500 步）")
    X, y = spiral_tensors()
    configs = [
        ("SGD，lr = 0.1", lambda p: torch.optim.SGD(p, lr=0.1)),
        ("SGD + 动量 0.9，lr = 0.1", lambda p: torch.optim.SGD(p, lr=0.1, momentum=0.9)),
        ("Adam，lr = 0.01", lambda p: torch.optim.Adam(p, lr=0.01)),
        ("AdamW，lr = 0.01", lambda p: torch.optim.AdamW(p, lr=0.01, weight_decay=0.01)),
    ]
    print(f"\n    {'优化器':<26s}{'第 50 步':>9s}{'第 100 步':>10s}{'第 200 步':>10s}{'第 500 步':>10s}{'准确率':>8s}")
    for name, make_opt in configs:
        torch.manual_seed(0)
        net = make_mlp([2, 100, 3])
        losses = fit(net, X, y, make_opt(net.parameters()), 500)
        print(f"    {name:<26s}" + "".join(f"{losses[i]:>10.3f}" for i in [49, 99, 199, 499]) + f"{accuracy(net, X, y):>8.1%}")
    print("""
  观察（讲义第 8.3 节）：
    - 普通 SGD 步子均匀，在"山谷"里来回震荡，收敛慢。
    - 动量：把之前的更新方向累积起来，像一个滚下山的球，方向一致的分量越走越快，震荡的分量互相抵消。
    - Adam：给每个参数一个自适应的学习率（梯度一直很大的参数步子变小，很小的参数步子变大），
      再加上动量。它对学习率不那么敏感，是训练 NLP 模型最常用的优化器。
    - AdamW：把权重衰减（L2 正则）从梯度里分离出来单独做，是今天训练 Transformer 的标准选择。""")


# =============================================================================
# 演示 3：学习率
# =============================================================================
def demo_learning_rate():
    """演示 3：学习率太小、合适、太大。"""
    print_header("演示 3：学习率（SGD，同样的网络，全批量 300 步）")
    X, y = spiral_tensors()
    print(f"\n    {'学习率':<10s}{'第 10 步':>10s}{'第 100 步':>10s}{'第 300 步':>10s}")
    for lr in [0.001, 0.1, 1.0, 50.0]:
        torch.manual_seed(0)
        net = make_mlp([2, 100, 3])
        losses = fit(net, X, y, torch.optim.SGD(net.parameters(), lr=lr), 300)
        print(f"    {lr:<10g}" + "".join(f"{losses[i]:>10.3f}" for i in [9, 99, 299]))
    print("""
  观察：学习率太小（0.001），300 步后损失几乎没动；太大（50），损失震荡甚至发散。
    实践中常用的办法：先找一个"刚好不发散"的学习率，再稍微调小一点；训练过程中逐渐衰减学习率。""")


# =============================================================================
# 演示 4：过拟合与正则化
# =============================================================================
def demo_regularization():
    """演示 4：小数据 + 大模型 → 过拟合；L2 和 dropout 能缓解。"""
    print_header("演示 4：过拟合与正则化")

    # dropout 在训练模式和评估模式下的行为
    torch.manual_seed(0)
    drop = nn.Dropout(p=0.5)
    x = torch.ones(1, 8)
    print(f"\n  nn.Dropout(0.5) 作用在全 1 向量上：")
    print(f"    训练模式：{drop(x).tolist()[0]}")
    drop.eval()
    print(f"    评估模式：{drop(x).tolist()[0]}")
    print("    → 训练时随机把一半的值置 0，留下的值乘以 1/(1-p) = 2，保证期望不变（'反向 dropout'）；")
    print("      评估时什么都不做。所以测试前一定要调用 model.eval()，训练时再调回 model.train()。")

    Xtr, ytr_clean = spiral_tensors(n_per_class=30, noise=0.2, seed=1)    # 只有 90 个训练点
    g = torch.Generator().manual_seed(5)
    resample = torch.rand(len(ytr_clean), generator=g) < 0.25             # 25% 的点重新随机抽一个标签
    ytr = ytr_clean.clone()
    ytr[resample] = torch.randint(0, 3, (int(resample.sum()),), generator=g)
    n_wrong = int((ytr != ytr_clean).sum())
    Xte, yte = spiral_tensors(n_per_class=300, noise=0.2, seed=2)         # 900 个测试点，标签干净
    print(f"\n  训练集只有 {len(Xtr)} 个点，其中 {n_wrong} 个的标签是错的（标签噪声）；测试集 {len(Xte)} 个点，标签干净。")
    print("  模型 2 → 200 → 200 → 3（约 4 万个参数），AdamW（lr = 0.01）训练 2000 步")
    configs = [("不加正则", 0.0, 0.0), ("权重衰减 0.1", 0.0, 0.1), ("权重衰减 1.0", 0.0, 1.0),
               ("权重衰减 3.0", 0.0, 3.0), ("dropout 0.5", 0.5, 0.0), ("dropout 0.5 + 权重衰减 1.0", 0.5, 1.0)]
    print(f"\n    {'设置':<28s}{'训练准确率':>10s}{'测试准确率':>10s}{'差距':>8s}")
    for name, p_drop, wd in configs:
        accs = []
        for seed in [0, 1, 2]:
            torch.manual_seed(seed)
            net = make_mlp([2, 200, 200, 3], dropout=p_drop)
            opt = torch.optim.AdamW(net.parameters(), lr=0.01, weight_decay=wd)
            fit(net, Xtr, ytr, opt, 2000)
            accs.append((accuracy(net, Xtr, ytr), accuracy(net, Xte, yte)))
        tr, te = np.mean(accs, axis=0)
        print(f"    {name:<28s}{tr:>10.1%}{te:>10.1%}{tr - te:>8.1%}")
    print("""
  说明：每个设置跑 3 个随机种子取平均。权重衰减用 AdamW 的 weight_decay 实现（和 L2 正则作用类似）。
  观察：
    - 不加正则时，大模型连错误的标签也"背"了下来：训练准确率接近 100%，测试准确率却低得多 —— 过拟合。
    - 正则化之后，训练准确率**下降**了（模型不再硬记那些错标签），测试准确率反而**上升**。
      这正是正则化的目的：用一点训练误差，换更好的泛化。
      （"差距"出现负数，是因为训练集的标签有错、测试集的标签是干净的：不去硬记错标签的模型，
        在训练集上反而"显得"更差。）
    - 权重衰减要足够强才有明显作用（AdamW 每步把权重乘以 1 - lr·wd，lr = 0.01 时 wd = 0.1 的效果很弱）；
      dropout 让网络不能依赖任何单个神经元，相当于训练了很多个子网络的平均。""")


if __name__ == "__main__":
    demo_initialization()
    demo_optimizers()
    demo_learning_rate()
    demo_regularization()
