"""
=============================================================================
第 8 讲 · 代码 2：调试神经网络的工具箱
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
神经网络的 bug 很阴险：代码不报错，模型也在"训练"，只是效果差一点 —— 你很难知道是 bug、超参数不好，
还是任务本身就难。这个文件故意制造几个**常见的 bug**，演示用什么方法能发现它们：

    演示 1：先在一小批数据上过拟合（overfit one batch）
            正确的代码 vs 忘了 zero_grad、softmax 做了两次、学习率太大 / 太小
    演示 2：能在一小批上过拟合，不代表没有 bug —— 标签和输入错位
    演示 3：评估时的 bug —— 忘了 model.eval()、填充位置参与了平均
    演示 4：读学习曲线 —— 欠拟合、正常、过拟合（以及早停）
    演示 5：监控梯度范数
    演示 6：数据越多越好吗？—— 训练集大小与验证准确率

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 02_debugging_toolkit.py

需要 PyTorch，约 1~2 分钟。
"""

import os

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

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

from classifiers import (BagOfWordsClassifier, GRUClassifier, encode_batch, evaluate, grad_norm, 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)


def ascii_curves(series, height=10, width=None, symbols="ox*+"):
    """
    把几条曲线画成字符图（纵轴 = 数值，横轴 = 轮次）。

    【参数】
        series : dict，名字 → 数值列表（长度相同）
        height : 图的行数
        symbols: 每条曲线用的符号；两条曲线重合的地方用 '#'

    【返回】list[str]，每个元素是一行
    """
    names = list(series)
    vals = np.array([series[n] for n in names])
    lo, hi = vals.min(), vals.max()
    T = vals.shape[1]
    grid = [[" "] * T for _ in range(height)]
    for k, n in enumerate(names):
        for t, v in enumerate(series[n]):
            r = int(round((hi - v) / (hi - lo + 1e-12) * (height - 1)))
            grid[r][t] = "#" if grid[r][t] not in (" ", symbols[k]) else symbols[k]
    lines = []
    for r, row in enumerate(grid):
        label = hi - (hi - lo) * r / (height - 1)
        lines.append(f"      {label:6.3f} |" + "".join(row))
    lines.append("             +" + "-" * T)
    lines.append("              " + "   ".join(f"{symbols[k]} = {n}" for k, n in enumerate(names)))
    return lines


# =============================================================================
# 演示 1：在一小批数据上过拟合
# =============================================================================
class DoubleSoftmax(nn.Module):
    """bug：模型最后多做了一次 softmax，输出的是概率，而 F.cross_entropy 会**再**做一次 softmax。"""

    def __init__(self, base):
        super().__init__()
        self.base = base

    def forward(self, X):
        return torch.softmax(self.base(X), dim=-1)


def overfit_small_batch(variant, X, y, vocab_size, steps=300, lr=1e-2):
    """
    在一小批数据（32 条）上训练很多步，返回损失的轨迹。

    【参数】
        variant : "正确"、"忘了 zero_grad"、"softmax 做了两次"、"学习率太大"、"学习率太小"
        X, y    : 一小批数据
        steps   : 训练步数
        lr      : 学习率（"学习率太大 / 太小" 会改掉它）

    【返回】list[float]，每一步的损失

    【思路】
        一个正确的模型 + 正确的训练代码，在 32 条数据上一定能把训练损失降到接近 0（它有足够的参数把它们"背"下来）。
        如果做不到，几乎可以肯定是代码有 bug —— 这是最便宜、最有效的第一项检查（Karpathy 的"训练神经网络的秘诀"）。
    """
    set_seed(0)
    model = GRUClassifier(vocab_size)
    if variant == "softmax 做了两次":
        model = DoubleSoftmax(model)
    if variant == "学习率太大":
        lr = 1.0
    if variant == "学习率太小":
        lr = 1e-6
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    losses = []
    for _ in range(steps):
        loss = F.cross_entropy(model(X), y)
        if variant != "忘了 zero_grad":
            opt.zero_grad()          # 【Python 小知识】PyTorch 默认把新梯度**累加**到 .grad 上，每步之前必须清零
        loss.backward()
        opt.step()
        losses.append(loss.item())
    return losses


def demo_overfit(train, vocab):
    print_header("演示 1：先在一小批数据（32 条）上过拟合")
    X = encode_batch([w for w, _ in train[:32]], vocab)
    y = torch.tensor([l for _, l in train[:32]])
    checkpoints = [0, 10, 50, 100, 200, 299]
    print(f"\n    {'':<16s}" + "".join(f"{'第' + str(s) + '步':>9s}" for s in checkpoints))
    for variant in ["正确", "忘了 zero_grad", "softmax 做了两次", "学习率太大", "学习率太小"]:
        losses = overfit_small_batch(variant, X, y, len(vocab))
        print(f"    {variant:<14s}" + "".join(f"{losses[s]:>10.3f}" for s in checkpoints))
    print(f"\n    （瞎猜的损失是 ln 2 = {np.log(2):.3f}；两类时 softmax 输出在 [0, 1] 之间再做一次 softmax，"
          f"损失最低只能到 ln(1 + e⁻¹) = {np.log(1 + np.exp(-1)):.3f}）")
    print("""
  观察：
    - 正确的代码：损失很快降到接近 0 —— 模型把 32 条数据"背"下来了。
    - 忘了 zero_grad：梯度一步步累加，相当于越来越大的、方向混乱的更新，损失忽高忽低，降不下去。
    - softmax 做了两次：模型输出的已经是概率（0~1），F.cross_entropy 又做了一次 softmax，
      两类之间的 logit 差最多是 1，损失永远降不到 0.313 以下。这个 bug 很常见，因为代码不会报错。
    - 学习率太大（1.0）：损失先暴涨（第 10 步到了 9 以上），之后又降到 0 —— 在 32 条数据上，
      Adam 最终还是把它们背下来了，所以过拟合测试**发现不了**学习率太大的问题；
      但在完整数据上，太大的学习率会让验证准确率明显变差（演示 5）。
    - 学习率太小（1e-6）：300 步之后损失几乎没动。
  结论：**任何新模型、新代码，先在一小批数据上过拟合。** 做不到就先别调别的，一定有 bug。""")


# =============================================================================
# 演示 2：标签错位
# =============================================================================
def demo_label_bug(train, dev, vocab):
    print_header("演示 2：能过拟合一小批，不代表没有 bug —— 标签和输入错位")
    rng = np.random.default_rng(0)
    perm = rng.permutation(len(train))
    # bug：打乱了评论的顺序，却忘了同时打乱标签（真实代码里常见于自己写的数据加载）
    shuffled = [(train[perm[i]][0], train[i][1]) for i in range(len(train))]
    for name, data in [("正确的数据", train), ("标签和输入错位", shuffled)]:
        set_seed(0)
        m = BagOfWordsClassifier(len(vocab))
        small = train_classifier(m, data[:32], vocab, epochs=100, lr=1e-2)
        set_seed(0)
        m = BagOfWordsClassifier(len(vocab))
        full = train_classifier(m, data, vocab, dev=dev, epochs=10, lr=1e-2)
        print(f"\n    {name}：在 32 条上训练 100 轮后的损失 {small['train_loss'][-1]:.3f}；"
              f"在全部 {len(data)} 条上训练后：训练损失 {full['train_loss'][-1]:.3f}，验证准确率 {full['dev_acc'][-1]:.1%}")
    print("""
  观察：标签错位之后，模型照样能把 32 条数据"背"下来（随便什么标签都能背），过拟合测试发现不了这个 bug。
    但在全部数据上，训练损失降不下去（接近瞎猜的 0.693），验证准确率和瞎猜差不多 ——
    输入和标签之间已经没有关系了。
  结论：除了过拟合测试，还要**亲眼看几条送进模型的数据**（解码回文字、和标签一起打印出来），
    并检查训练损失能不能在完整数据上正常下降。""")


# =============================================================================
# 演示 3：评估时的 bug
# =============================================================================
@torch.no_grad()
def accuracy_no_eval(model, data, vocab, batch_size=256):
    """bug：评估时忘了 model.eval()，dropout 仍然在随机丢弃神经元。"""
    correct = 0
    for X, y in make_batches(data, vocab, batch_size, shuffle=False):
        correct += (model(X).argmax(-1) == y).sum().item()
    return correct / len(data)


def demo_eval_bugs(train, dev, vocab):
    print_header("演示 3：评估时的 bug")
    set_seed(0)
    m = GRUClassifier(len(vocab), dropout=0.5)
    train_classifier(m, train, vocab, epochs=8, lr=3e-3)
    proper = evaluate(m, dev, vocab)[0]
    m.train()
    noisy = [accuracy_no_eval(m, dev, vocab) for _ in range(3)]
    print(f"\n  (a) 带 dropout（p = 0.5）的模型")
    print(f"      正确评估（model.eval()）：{proper:.1%}")
    print(f"      忘了 model.eval()，连续评估 3 次：{', '.join(f'{a:.1%}' for a in noisy)}")

    print(f"\n  (b) 词袋模型的平均：分母里包含了填充位置")
    short = min(dev, key=lambda t: len(t[0]))[0]
    longest = max(dev, key=lambda t: len(t[0]))[0]
    for mask in [True, False]:
        set_seed(0)
        m = BagOfWordsClassifier(len(vocab), mask_padding=mask)
        train_classifier(m, train, vocab, epochs=10, lr=1e-2)
        res = [evaluate(m, dev, vocab, batch_size=bs) for bs in [1, 32, 1000]]
        m.eval()
        with torch.no_grad():
            p_alone = torch.softmax(m(encode_batch([short], vocab)), -1)[0, 1].item()
            p_batch = torch.softmax(m(encode_batch([short, longest], vocab)), -1)[0, 1].item()
        m.train()
        name = "正确（只平均真实的词）" if mask else "bug（填充也参与平均）"
        print(f"      {name}")
        print(f"        评估时批大小 1 / 32 / 1000：准确率 " + " / ".join(f"{a:.1%}" for a, _ in res)
              + "；平均损失 " + " / ".join(f"{l:.3f}" for _, l in res))
        print(f"        \"{' '.join(short)}\" 的 P(正面)：单独评估 {p_alone:.3f}，和最长的评论放在一批里 {p_batch:.3f}")
    print("""
  观察：
    - 忘了 model.eval()：同一个模型、同一份数据，每次评估结果都不一样，而且偏低。
      （BatchNorm 层在训练 / 评估模式下的行为也不同，同样要注意。）
    - 填充参与了平均：同一条评论，单独评估和跟一条长评论放在一起评估，得到的概率不一样 ——
      和长评论放在一批时，短评论被填充了很多 0，平均向量被"稀释"了。平均损失也随批大小变化。
      有意思的是准确率几乎没变（向量整体缩小时，两个类别的打分谁大谁小基本不变），
      **只看准确率，这个 bug 完全发现不了**。正确的实现无论批大小是多少，结果都完全相同。
  结论：评估结果应该是确定的、和批大小无关的。用不同的批大小评估两次，是一个很便宜的检查。""")


# =============================================================================
# 演示 4：学习曲线
# =============================================================================
def demo_learning_curves(train, dev, vocab):
    print_header("演示 4：读学习曲线")
    settings = [
        ("欠拟合：词袋模型（看不到词序），2000 条", lambda: BagOfWordsClassifier(len(vocab), emb=8, hidden=8), train, 20),
        ("正常：GRU，2000 条", lambda: GRUClassifier(len(vocab)), train, 20),
        ("过拟合：大 GRU（256 维），只有 100 条", lambda: GRUClassifier(len(vocab), emb=64, hidden=256), train[:100], 40),
    ]
    for name, make, data, epochs in settings:
        set_seed(0)
        h = train_classifier(make(), data, vocab, dev=dev, epochs=epochs, lr=3e-3)
        best = int(np.argmin(h["dev_loss"]))
        print(f"\n  {name}")
        for line in ascii_curves({"训练损失": h["train_loss"], "验证损失": h["dev_loss"]}, height=8):
            print(line)
        print(f"      最后一轮：训练损失 {h['train_loss'][-1]:.3f}，验证损失 {h['dev_loss'][-1]:.3f}，"
              f"验证准确率 {h['dev_acc'][-1]:.1%}；验证损失最低在第 {best + 1} 轮（准确率 {h['dev_acc'][best]:.1%}）")
    print("""
  观察（横轴是训练轮次）：
    - 欠拟合：训练损失和验证损失都停在比较高的地方，而且两者很接近 —— 模型的能力不够（这里是看不到词序）。
      办法：换更强的模型、更好的特征，而不是更多的正则化。
    - 正常：两条曲线一起下降，验证损失略高于训练损失。
    - 过拟合：训练损失降到接近 0，验证损失降到某个点之后反而上升 —— 模型在"背"训练数据。
      办法：更多数据、更强的正则化（dropout、权重衰减）、更小的模型，或者**早停**（early stopping）：
      在验证损失最低的那一轮停下来，保存那时的模型。""")


# =============================================================================
# 演示 5：梯度范数
# =============================================================================
def demo_grad_norm(train, dev, vocab):
    print_header("演示 5：监控梯度范数")
    print(f"\n    {'设置':<28s}{'中位数':>8s}{'90% 分位数':>11s}{'最大值':>8s}{'验证准确率':>10s}")
    for name, lr, clip in [("学习率 3e-3", 3e-3, None), ("学习率 1e-1", 1e-1, None), ("学习率 1e-1 + 梯度裁剪 1.0", 1e-1, 1.0)]:
        set_seed(0)
        h = train_classifier(GRUClassifier(len(vocab)), train, vocab, dev=dev, epochs=5, lr=lr, clip=clip)
        g = np.array(h["grad_norm"])
        med = np.median(g)
        print(f"    {name:<24s}{med:>10.3f}{np.quantile(g, 0.9):>11.3f}{g.max():>10.2f}{h['dev_acc'][-1]:>12.1%}")
    print("""
  观察：
    - 把每一步的梯度范数记下来（train_classifier 里的 grad_norm），是发现训练不稳定的好办法。
      学习率太大时，梯度范数整体更大、波动更大，验证准确率也明显更差。
    - 在这里梯度裁剪几乎没有帮助：问题不是偶尔出现的个别大梯度（那正是裁剪擅长处理的，第 6 讲），
      而是学习率本身太大。学习率不合适时，首先应该调学习率。
    - 实际项目里，一般把训练损失、验证指标、梯度范数、学习率都画成曲线（TensorBoard、Weights & Biases 等工具），
      训练时随时看一眼。""")


# =============================================================================
# 演示 6：数据量
# =============================================================================
def demo_data_size(dev, vocab):
    print_header("演示 6：训练集大小与验证准确率")
    sizes = [50, 100, 200, 500, 1000, 2000, 5000]
    big = make_dataset(5000, seed=10)
    print(f"\n    {'训练条数':<8s}" + "".join(f"{n:>8d}" for n in sizes))
    for name, make in [("词袋", lambda: BagOfWordsClassifier(len(vocab))), ("GRU", lambda: GRUClassifier(len(vocab)))]:
        accs = []
        for n in sizes:
            set_seed(0)
            epochs = max(5, min(40, 20000 // n))          # 数据少时多训练几轮，保证总步数相近
            h = train_classifier(make(), big[:n], vocab, dev=dev, epochs=epochs, lr=3e-3)
            accs.append(max(h["dev_acc"]))                # 相当于早停：取验证准确率最高的一轮
        print(f"    {name:<10s}" + "".join(f"{a:>8.1%}" for a in accs))
    print("""
  观察：
    - 词袋模型的曲线很"典型"：每多一倍数据提高一点，越往后提高越少，最后停在 90% 以下 ——
      它看不到词序，再多的数据也学不会否定。
    - GRU 在每个数据量上都更好，但它的曲线并不平滑：50~200 条时停在 82% 左右，500 条以后才快速提高。
      真实的学习曲线也常常这样，所以要多测几个点，不要只用两个点连一条直线去外推。
    - 画出这样的曲线，可以帮你判断：再去标注一倍的数据值不值得？换一个模型值不值得？
    - 第 9 讲会看到另一条路：先在大量**没有标签**的文本上预训练，可以让少量标注数据发挥大得多的作用。""")


def main():
    train = make_dataset(2000, seed=0)
    dev = make_dataset(1000, seed=1)
    vocab = Vocab()
    t0 = time.perf_counter()
    demo_overfit(train, vocab)
    demo_label_bug(train, dev, vocab)
    demo_eval_bugs(train, dev, vocab)
    demo_learning_curves(train, dev, vocab)
    demo_grad_norm(train, dev, vocab)
    demo_data_size(dev, vocab)
    print(f"\n  （总用时 {time.perf_counter() - t0:.0f} 秒）")


if __name__ == "__main__":
    main()
