前五课里你已经把零件备齐了:会用 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() 之前 | 用的是上一步的旧梯度更新 | 能动但方向滞后、收敛慢/乱 |
核心:纯 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,不是概率。
- 为什么不要再手动 softmax:否则就成了"对 softmax 的结果再 softmax"——双重 softmax,分布被压平,梯度信号变弱,模型学得慢甚至学不动。
- 顺带还赚了数值稳定:
log_softmax内部用了 log-sum-exp 技巧(就是上面的"减最大值"),比"先 softmax 再取 log"更不容易上溢/下溢。把这一步交给框架,正是它的价值。 - 记忆口诀:模型最后一层输出 logits 就停手,softmax 交给 loss(训练时)或交给推理时的
argmax(预测时 argmax 不受单调 softmax 影响,连 softmax 都不必算)。
参考: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,要在验证集上看真实水平。验证和训练必须分离,两个开关缺一不可:
model.eval():切换到评估模式。它影响 Dropout(评估时关掉随机丢弃,用全部神经元)和 BatchNorm(评估时用训练期累积的全局均值/方差,而非当前 batch 的统计量)。忘了它,验证指标会因为这些层的"训练态行为"而不稳、偏低。torch.no_grad():告诉 autograd "这段别建计算图"。验证只看指标、不更新参数,建图纯属浪费——no_grad省显存、还跑得更快。
两者正交:eval() 管"层的行为模式",no_grad() 管"要不要建图"。验证时两个都要开;训练时记得用 model.train() 切回来。
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))
调度器、梯度裁剪、梯度累积(点到为止)
这三个是训练循环上常见的"附加件",先认识它们解决什么问题,细节后面课程再展开。
- 学习率调度器(scheduler):训练中动态调整
lr(比如逐渐衰减)。关键是scheduler.step()的时机:多数按 epoch 衰减的调度器每 epoch 末调用一次;但 warmup(开头几百步把 lr 从小慢慢升上来)一般每个 step 调用。放错位置,lr 曲线会快/慢几十倍,是常见隐蔽 bug。 - 梯度裁剪
clip_grad_norm_:在backward()之后、step()之前,把梯度的整体范数限制在某个上限内,防止偶发的超大梯度(梯度爆炸)把参数一脚踹飞。RNN/Transformer 里常用。 - 梯度累积(gradient accumulation):显存装不下大 batch 时,跑几个小 batch、不清零地让梯度累加,攒够再
step()一次——等效于一个大 batch。这正是".grad默认累加"这一设计派上用场的地方:平时它是坑,这里是特性。
# 梯度裁剪:夹在 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 改一个数自己试。
动手练习
- 默写不看稿。关掉本页,在纸上或编辑器里写出五步骨架的五行代码(PyTorch 版),并在每行旁注一句"漏了会怎样"。写完回来对照本课第一张表。
- 把玩具数据换成 XOR。用四个点
X=[[0,0],[0,1],[1,0],[1,1]]、y=[0,1,1,0]替换核心代码里的两团高斯(其余不动,epoch 调到 400、lr 调到 0.5)。验证:线性不可分的 XOR,单层做不到,但你这个带 ReLU 隐藏层的两层 MLP 能把 loss 压到很低、四个点全分对。这说明隐藏层带来了非线性表达力。 - 给手写版加一行"验证"。在两团高斯上另外采 40 个点当验证集,每 8 个 epoch 在验证集上算一次准确率并打印。体会"训练指标"和"验证指标"是两回事——即便这个玩具任务上它们会很接近。
掌握自检
- 我能不看资料默写五步骨架,并说出漏掉 zero_grad / backward / step 各自的症状。
- 我能说清为什么
step()必须在backward()之后,颠倒会怎样。 - 我写的 NumPy 训练循环真能跑,loss 单调下降、准确率上升(不是抄答案,是自己改得动)。
- 我知道用
CrossEntropyLoss时模型末层输出 logits、不加 softmax,并能解释双重 softmax 的害处。 - 我能说出验证时为什么同时要
model.eval()和torch.no_grad(),以及两者各管什么。 - 我知道 warmup 调度器一般每 step 调
scheduler.step(),并知道梯度裁剪、梯度累积各解决什么问题。
可以先放过的点
第一遍只要拿下两件事:(1) 五步骨架能默写,(2) 纯 NumPy 训练循环你能跑通并看懂 loss 为什么降。这两条是阶段 0 的硬指标,其余都能往后放:
- 调度器、梯度裁剪、梯度累积——现在知道"它们存在、各解决什么问题"就够。等你训练真实模型遇到 lr 不对、梯度爆炸、显存不够时再回来,那时一看就懂。
- BatchNorm 的训练/评估差异细节——等学到含 BN 的网络(后续模块)再深究,这课只需记住"
eval()会改 Dropout/BN 的行为"。 - 手写反向传播的每一步求导——如果第 4 课的链式法则还没完全消化,这里允许你先把反向那几行当"黑盒"用,重点先放在循环结构上,回头再补导数推导。