从零写训练循环(核心)

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:从零训练循环(核心)——五步骨架,阶段0 硬指标,后续所有模型共用

前五课里你已经把零件备齐了:会用 NumPy 向量化算 forward,会用 PyTorch 张量管理设备与 dtype,亲手写过 autograd 算反向,还从零搭了迷你 nn.Module。这一课把这些零件拼成一台会学习的机器——训练循环(training loop)。它是整个模块的核心、也是阶段 0 的硬指标:你要能不看资料默写出"五步骨架",并真正写出一个 loss 单调下降、准确率上升的循环。后面 4 课(数据加载、GPU 混合精度、实验管理、HuggingFace)都只是给这台机器换更好的零件,骨架不变。

读完这一课,你将能够

  • 默写训练循环五步骨架(zero_grad → forward → loss → backward → step),并说清漏掉或颠倒任一步的具体后果。
  • 用纯 NumPy 从零写出一个两层 MLP 的完整训练循环,让 loss 真的下降、准确率真的上升。
  • 解释为什么用 CrossEntropyLoss 时模型最后一层不要手动加 softmax。
  • 正确分离训练与验证:验证时用 model.eval() + torch.no_grad(),并说出各自的作用。
  • 说清 scheduler.step() 该每 step 还是每 epoch 调用,以及梯度裁剪、梯度累积分别解决什么问题。

五步骨架:训练循环的心脏

不管模型多复杂、数据多大,每个训练步(一个 batch)都在重复同样的五步。把它当肌肉记忆背下来:

optimizer.zero_grad()   # 1. 清空上一步累积的梯度
output = model(x)        # 2. forward:算预测
loss = loss_fn(output, y)# 3. 算损失(一个标量)
loss.backward()          # 4. backward:反向传播,填充每个参数的 .grad
optimizer.step()         # 5. 用梯度更新参数

机制直觉:把参数想成一堆旋钮。forward 把旋钮当前的设定跑出一个预测,loss 量化"错得多离谱",backward 算出"每个旋钮往哪拧、拧多少能让 loss 变小"(即梯度),step 真正去拧旋钮。zero_grad 是因为 PyTorch 的 .grad 默认是累加的——不清零,这一步的梯度会叠在上一步残留之上。

每一步漏了或顺序错了,后果都很具体、且常常"不报错只是学不动",最坑:

错误后果症状
漏 zero_grad()梯度跨 batch 累加,步子越来越乱loss 震荡 / 发散,不收敛
漏 backward().grad 还是 None / 旧值,没新梯度loss 纹丝不动
漏 step()算了梯度却不更新参数loss 纹丝不动
step() 在 backward() 之前用的是上一步的旧梯度更新能动但方向滞后、收敛慢/乱
训练循环五步骨架闭环图 每个 batch 重复这一整圈 zero_grad() 清空上一步梯度 ⚠ 漏了 → 梯度累加震荡 forward 算预测 output = model(x) loss 量化错得多离谱 (标量) backward() 反向传播,填每个参数的 .grad ⚠ 漏了 → 没新梯度, loss 不动 step() 用梯度拧旋钮 (更新参数) ⚠ 漏了 → 不更新, loss 不动 下一个 batch 循环往复 顺序陷阱:step 排在 backward 之前 → 用旧梯度,更新方向滞后一步
训练循环五步骨架闭环图

核心:纯 NumPy 从零写训练循环

先不靠任何框架,把五步骨架彻底吃透。下面是一个两层 MLP(结构 (2)→(16)→(2),沿用上一课 nn.Module 的思路)在"两团高斯"二分类玩具数据上的完整可跑训练循环。forward 手写、反向用解析梯度(复用模块 1 的反向传播和第 4 课的链式法则),更新用最朴素的 SGD。注释里标出了五步骨架对应的位置。

import numpy as np
rng = np.random.default_rng(0)

# 玩具二分类:两团高斯。X (N,2), y (N,) in {0,1}
N = 200
Xa = rng.normal([-2,-2], 1.0, (N//2, 2))
Xb = rng.normal([ 2, 2], 1.0, (N//2, 2))
X = np.vstack([Xa, Xb]); y = np.array([0]*(N//2) + [1]*(N//2))

H = 16                                  # 两层 MLP: (2)->(16)->(2)
W1 = rng.normal(0,0.5,(2,H)); b1 = np.zeros(H)
W2 = rng.normal(0,0.5,(H,2)); b2 = np.zeros(2)

def softmax(z):
    z = z - z.max(1, keepdims=True)     # 减最大值,数值稳定
    e = np.exp(z); return e / e.sum(1, keepdims=True)

lr = 0.1
# 注:这里每个 epoch 的 dW 都是重新赋值算出来的(不累加),
#     所以不需要显式 zero_grad;PyTorch 的 .grad 默认累加才要清零。
for epoch in range(1, 41):
    z1 = X @ W1 + b1; a1 = np.maximum(0, z1)   # 2.forward (ReLU)
    logits = a1 @ W2 + b2                       #   注意:不手动 softmax
    p = softmax(logits)
    loss = -np.log(p[np.arange(N), y] + 1e-12).mean()   # 3.loss=交叉熵
    dlogits = p.copy(); dlogits[np.arange(N), y] -= 1; dlogits /= N  # 4.backward
    dW2 = a1.T @ dlogits; db2 = dlogits.sum(0)
    dz1 = (dlogits @ W2.T) * (z1 > 0)
    dW1 = X.T @ dz1;      db1 = dz1.sum(0)
    W2 -= lr*dW2; b2 -= lr*db2; W1 -= lr*dW1; b1 -= lr*db1   # 5.step (SGD)
    if epoch % 8 == 0 or epoch == 1:
        print(f"epoch {epoch:2d}  loss {loss:.4f}  acc {(p.argmax(1)==y).mean():.3f}")

跑出来 loss 从 ~1.72 一路降到 ~0.02,这就是"会学习"的样子。要留意的是:因为这份"两团高斯"数据本身极易分,准确率早早就饱和了——大约第 8 个 epoch 起 acc 就稳定在 1.0,后面的二十多个 epoch loss 仍在从 ~0.07 继续磨到 ~0.02,但 acc 已经不再变化。所以别误以为 acc 在缓慢爬升:先到 1.0 的是 acc,继续下降的是 loss。这里没显式写 zero_grad,是因为每个 epoch 的 dW 都是重新赋值算出来的,本身不累加;PyTorch 里 .grad 默认累加,才需要主动清零(见下一节对照)。注意 softmax 里先减最大值——这是数值稳定的标准手法,避免 exp 溢出。

CrossEntropyLoss 吃 logits,别手动 softmax

上面的 NumPy 版我们手写了 softmax 再算交叉熵。换到 PyTorch,nn.CrossEntropyLoss 内部已经 = log_softmax + NLL(负对数似然),所以它要的输入是原始 logits,不是概率。

参考:PyTorch 等价训练循环

同一个任务用真实 PyTorch 写,逐行能和上面的手写版对应起来。这就是你后面会反复写的标准模板:

import torch, torch.nn as nn

model = nn.Sequential(nn.Linear(2,16), nn.ReLU(), nn.Linear(16,2))  # 不加 Softmax!
loss_fn = nn.CrossEntropyLoss()                 # 内部 = log_softmax + NLL
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

X = torch.tensor(X, dtype=torch.float32)        # 接住上面的玩具数据
y = torch.tensor(y, dtype=torch.long)           # 类别标签是整数,dtype=long

for epoch in range(40):
    optimizer.zero_grad()        # 1. 清梯度(PyTorch 的 .grad 默认累加)
    logits = model(X)            # 2. forward → 直接出 logits
    loss = loss_fn(logits, y)    # 3. loss(喂 logits,不喂概率)
    loss.backward()              # 4. backward → 自动填 .grad
    optimizer.step()             # 5. step → 更新参数
    if epoch % 8 == 0:
        acc = (logits.argmax(1) == y).float().mean()
        print(f"epoch {epoch:2d}  loss {loss.item():.4f}  acc {acc:.3f}")

对照看:手写版的 dW = ... 整段,在 PyTorch 里就是 loss.backward() 一行(autograd 替你做);手写版的 W -= lr*dW,就是 optimizer.step()。框架没变魔法,只是把你已经会的反向传播自动化了。

epoch / batch 双层循环,训练与验证分离

真实训练有两层循环:外层 epoch(把整个数据集过一遍叫一个 epoch),内层 batch(每次只喂一小批数据,五步骨架走一遍)。下一课的 DataLoader 负责切 batch,这课先理解骨架在哪。

更要紧的是:训练完一个 epoch,要在验证集上看真实水平。验证和训练必须分离,两个开关缺一不可:

两者正交:eval() 管"层的行为模式",no_grad() 管"要不要建图"。验证时两个都要开;训练时记得用 model.train() 切回来。

训练 vs 验证:两条路径的对比 训练 model.train() 一个 batch 进入模型 建立计算图 (autograd) backward 填梯度 optimizer 更新参数 拧动 Dropout 随机丢弃 BN 用当前 batch 统计量 验证 model.eval() + torch.no_grad() 一个 batch 进入模型 不建图 不算梯度 不更新参数 锁住 只读出指标 准确率 Dropout 关闭 / BN 用全局统计量 省显存、更快 eval() 管层的行为模式,no_grad() 管要不要建图;两者正交、验证时都要开。
训练 vs 验证:两条路径的对比
for epoch in range(num_epochs):
    model.train()                          # 训练模式:Dropout/BN 用训练态行为
    for xb, yb in train_loader:            # 内层:batch 循环
        optimizer.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        optimizer.step()

    model.eval()                           # 切到评估模式
    with torch.no_grad():                  # 不建图、省显存
        correct = 0
        for xb, yb in val_loader:
            correct += (model(xb).argmax(1) == yb).sum().item()
        print("val acc:", correct / len(val_set))

调度器、梯度裁剪、梯度累积(点到为止)

这三个是训练循环上常见的"附加件",先认识它们解决什么问题,细节后面课程再展开。

# 梯度裁剪:夹在 backward 和 step 之间
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

# warmup 调度器:每个 step 调用(注意位置)
for xb, yb in train_loader:
    optimizer.zero_grad(); loss = loss_fn(model(xb), yb)
    loss.backward(); optimizer.step()
    scheduler.step()        # warmup:每 step;普通衰减:移到 epoch 末

调一调,观察现象

微任务 1:把学习率 lr 从 0.1 调到 5.0,看 loss 轨迹怎么变。预期现象:lr=5.0 时 loss 不再平稳下降,而是第 1~2 个 epoch 就暴涨到 ~13(步子太大,一脚把权重踹过了头),随后才慢慢塌回。为什么:步子太大,越过最低点冲到对面更高处。注意一个反直觉的细节:这份"两团高斯"数据离得很远、本就极易分,所以即便大步乱踹,ReLU+softmax 最终还是能把两团完全分开,logits 被推到极大、softmax 饱和、loss 反而塌回 ≈0(甚至因 exp 下溢打印出 -0.0000)。也就是说:在这种太好分的玩具数据上,lr=5 看得到剧烈震荡,却演示不出"稳定发散到 nan"——真要看到彻底发散,得换更难的数据或更大的模型。关键是把 print 放进内层循环逐 epoch 打,才能亲眼看见中途那次暴涨;只打"最终 loss"会把全过程吞掉,两行都接近 0,什么现象都看不到。

import numpy as np
rng = np.random.default_rng(0)
N=200
X=np.vstack([rng.normal([-2,-2],1,(N//2,2)), rng.normal([2,2],1,(N//2,2))])
y=np.array([0]*(N//2)+[1]*(N//2))
H=16
W1=rng.normal(0,0.5,(2,H));b1=np.zeros(H);W2=rng.normal(0,0.5,(H,2));b2=np.zeros(2)
def sm(z): z=z-z.max(1,keepdims=True);e=np.exp(z);return e/e.sum(1,keepdims=True)
for lr in [0.1, 5.0]:                       # 改这一行对比
    w1,b_1,w2,b_2 = W1.copy(),b1.copy(),W2.copy(),b2.copy()
    print(f"=== lr={lr} ===")
    for ep in range(1, 9):
        z1=X@w1+b_1;a1=np.maximum(0,z1);p=sm(a1@w2+b_2)
        loss=-np.log(p[np.arange(N),y]+1e-12).mean()
        d=p.copy();d[np.arange(N),y]-=1;d/=N
        dz1=(d@w2.T)*(z1>0)
        w2-=lr*(a1.T@d);b_2-=lr*d.sum(0);w1-=lr*(X.T@dz1);b_1-=lr*dz1.sum(0)
        print(f"  ep {ep:2d}  loss {loss:.4f}")   # 逐 epoch 打印,才看得见暴涨

跑出来 lr=0.1 是 1.7250 → 0.3658 → 0.1688 → … 一路平稳下降;lr=5.0 则是 1.7250 → 13.5111 → 10.2235 → 0.1382 → … → -0.0000——第 2 个 epoch 暴涨到 13.5,再侥幸塌回。这就是"步子太大"的真实样子。

微任务 2:把 zero_grad 漏掉,看梯度跨 step 累加的效果。预期现象(要诚实):在这个极易分的玩具任务上,即便不清零,loss 也照样平稳单调下降,未必震荡或发散。下面的 NumPy 模拟里 gW1 一直累加,结果 loss 从 ep3 的 0.1143 一路降到 ep15 的 0.0135——甚至比基线还快。为什么不炸:这里只对 W1 做累加,而每步的梯度增量本身在快速衰减,累加后等效于一个"偏大但方向仍大致正确"的步长,在这份太好分的数据上照样收敛。但这不代表漏 zero_grad 没事:更新方向已经被历史梯度污染、步长被悄悄放大,换到更难的任务或更大的模型上,这种污染就会暴露成真正的震荡/发散。换句话说,"漏 zero_grad → 发散"是会发生的典型故障,只是这个玩具数据太宽容、掩盖了它。

import numpy as np
rng=np.random.default_rng(0)
N=200
X=np.vstack([rng.normal([-2,-2],1,(N//2,2)),rng.normal([2,2],1,(N//2,2))])
y=np.array([0]*(N//2)+[1]*(N//2))
H=16
W1=rng.normal(0,0.5,(2,H));b1=np.zeros(H);W2=rng.normal(0,0.5,(H,2));b2=np.zeros(2)
def sm(z):z=z-z.max(1,keepdims=True);e=np.exp(z);return e/e.sum(1,keepdims=True)
gW1=np.zeros_like(W1)  # 模拟一个会累加的 .grad 缓冲
for ep in range(1,16):
    z1=X@W1+b1;a1=np.maximum(0,z1);p=sm(a1@W2+b2)
    loss=-np.log(p[np.arange(N),y]+1e-12).mean()
    d=p.copy();d[np.arange(N),y]-=1;d/=N;dz1=(d@W2.T)*(z1>0)
    gW1 += X.T@dz1        # 忘了清零:梯度一直累加!
    W1-=0.1*gW1           # 用被污染的梯度更新 W1
    W2-=0.1*(a1.T@d);b2-=0.1*d.sum(0);b1-=0.1*dz1.sum(0)
    if ep%3==0: print(f"ep {ep:2d} loss {loss:.4f}")

微任务 3:把隐藏层宽度 H 从 16 改成 2,再改成 64。预期现象(以 loss 为准):H=2 容量不足,loss 降得最慢、末值最高(~0.16),acc 也略低;H=16 已经足够,loss ~0.02、acc 1.0;H=64 的 loss 降得更低一点(~0.01)。为什么:更宽的隐藏层表达能力更强,但也更吃算力——这是容量与成本的权衡。不过别盯着 acc 找"越宽越好"的规律:这份数据太好分,H=16 起 acc 就都接近满分,H=64 的 acc(~0.995)反而可能因玩具数据的噪声略低于 H=16 的 1.0,提升的边际很小。这种简单任务上加宽收益不明显,主要看 loss,而非笼统的"更稳"。把本节核心代码的 H=16 改一个数自己试。

动手练习

  1. 默写不看稿。关掉本页,在纸上或编辑器里写出五步骨架的五行代码(PyTorch 版),并在每行旁注一句"漏了会怎样"。写完回来对照本课第一张表。
  2. 把玩具数据换成 XOR。用四个点 X=[[0,0],[0,1],[1,0],[1,1]]、y=[0,1,1,0] 替换核心代码里的两团高斯(其余不动,epoch 调到 400、lr 调到 0.5)。验证:线性不可分的 XOR,单层做不到,但你这个带 ReLU 隐藏层的两层 MLP 能把 loss 压到很低、四个点全分对。这说明隐藏层带来了非线性表达力。
  3. 给手写版加一行"验证"。在两团高斯上另外采 40 个点当验证集,每 8 个 epoch 在验证集上算一次准确率并打印。体会"训练指标"和"验证指标"是两回事——即便这个玩具任务上它们会很接近。

掌握自检