训练模型
这一章讲的是怎么把"下山"走好。上一章我们有了损失函数这张"地形图",这一章的任务就是想办法走到地图的最低点——也就是找到让损失最小的那组参数。学完这一章,你会搞懂梯度下降、随机梯度下降(SGD)、动量(Momentum)和 Adam 这些天天听到的优化器到底在做什么,还会学会盯着损失曲线判断训练是否健康。
下山:梯度下降
第 5 章的损失函数 $L(\phi)$ 可以画成一张以参数为坐标的"地形图":每个参数组合对应一个损失值,把损失当成海拔,就得到一个坑坑洼洼的曲面。训练的目标,就是找到海拔最低的那个点:
$$\hat{\phi} = \arg\min_{\phi} L(\phi)$$怎么找?最朴素的办法是梯度下降(Gradient Descent)。站在当前点,用数学算出"哪个方向上升最快",那就是梯度;我们反着走,也就是沿最陡的下坡方向迈一小步。每走一步,就重新测一次坡,再走一步,直到坡变平、走不动为止。写成公式:
$$\phi \leftarrow \phi - \alpha \cdot \frac{\partial L(\phi)}{\partial \phi}$$这里的 $\alpha$ 叫学习率(learning rate),就是一步迈多大。步子太小,走得磨磨蹭蹭,半天到不了山脚;步子太大,可能一脚踩空、冲过头,在山谷两边来回震荡,甚至越走越高、直接"滚出地图"(损失爆炸)。学习率是训练里第一个要小心拧的旋钮。
**学习率太大**:损失震荡甚至越来越大,训练直接失败。如果看到损失曲线像心电图一样乱跳,第一反应就是把学习率调小。
还有个现实问题:神经网络的"地形"不是光滑的大碗,而是有无数局部最小值(小坑)和鞍点(像马鞍一样,一个方向向下、另一个方向向上的平缓地带)。在这些地方梯度都是零,算法会以为到"底"了,其实可能只是卡在了半山腰。
想象你蒙着眼睛下山:每走一步,先用脚探一探哪个方向最陡、朝下,就往那个方向迈一步,走完再探。这就是梯度下降。步长(学习率)就是迈步大小——步子太大容易冲进沟里爬不出来,步子太小天黑了还困在山顶。而"局部最小值"就像是路上碰到的浅坑,蒙着眼的话你可能觉得"脚底平了,到平地了",其实离真正的山脚还远着呢。
随机梯度下降:一次只看一小批
前面说的梯度下降有个致命缺点:每一步算梯度都要把全部训练样本过一遍,数据一多就慢得离谱。改进思路很朴素——每次只看一小部分数据。随机梯度下降(Stochastic Gradient Descent,SGD)在每一步随机抽出一小批样本(叫小批量,minibatch,或简称批次),只用这批样本估算梯度就更新参数:
$$\phi_{t+1} \leftarrow \phi_t - \alpha \cdot \sum_{i \in \mathcal{B}_t} \frac{\partial \ell_i(\phi_t)}{\partial \phi}$$其中 $\mathcal{B}_t$ 是第 $t$ 步抽到的批次(一批样本的下标集合),$\ell_i$ 是第 $i$ 个样本的损失。把训练数据全部过一遍(每个样本都被用到一次)叫做一个周期(epoch)。
有人会担心:用一小批估算的梯度不准啊?其实不准确反而是优点。这个"噪声"让 SGD 的路径歪歪扭扭,但也让它可能从浅坑里"颠"出来,跳到另一个更低的山谷;而老老实实的全量梯度下降一旦进坑就再也出不来。再加上每步计算便宜,SGD 就成了训练神经网络的事实标配。
动量:给小球加个惯性
SGD 还有个毛病:路径歪歪扭扭,在狭窄的山谷里会来回震荡,走得很慢。改进思路来自物理直觉——动量(Momentum)。想象一个球从山坡滚下:它有惯性,方向不会突变;遇到小坑,凭惯性能直接冲过去。算法里我们维护一个"速度" $m$,每步把上一步的速度和当前梯度混合起来,再用这个速度更新参数:
$$m_{t+1} \leftarrow \beta \cdot m_t + (1-\beta) \sum_{i \in \mathcal{B}_t} \frac{\partial \ell_i(\phi_t)}{\partial \phi}, \qquad \phi_{t+1} \leftarrow \phi_t - \alpha \cdot m_{t+1}$$系数 $\beta$(常取 0.9)决定"记住多少过去的方向":如果梯度方向老是变来变去,新旧方向互相抵消,路径就被抹平了,震荡大大减小;如果梯度一直朝着同一个方向(比如一路下坡),惯性会让有效步长变大,下山更快。
梯度下降——看完全部数据,走最陡下坡;SGD——只看一小批,快,还能颠出浅坑;动量——像带惯性的球,平滑路径、加速下山。下一节的 Adam 则是把这些思路全揉在一起,还顺手解决了"步长怎么定"的问题。
Adam:自动给每个参数配步长
固定步长的梯度下降有个尴尬:地形陡的方向,同样一步会迈得太大(震荡);地形缓的方向,又迈得太小(磨蹭)。理想情况是给每个参数单独配一个合适的步长。Adam(Adaptive Moment Estimation,自适应矩估计)正是这么做的:它同时维护两个统计量——梯度的滑动平均 $m$(代表方向)和平方梯度的滑动平均 $v$(代表尺度),然后用"方向除以尺度"的方式更新:
$$\phi_{t+1} \leftarrow \phi_t - \alpha \cdot \frac{m_{t+1}}{\sqrt{v_{t+1}} + \epsilon}$$$\epsilon$ 是个极小的常数,防止除数为零。直观地说:哪个方向梯度一直很大,就自动把步子调小一点;哪个方向梯度一直很小,就放大步子。因为训练初期滑动平均还不准,Adam 还会做一个偏差校正。它的好处是对学习率不敏感:一个默认的学习率往往就能跑得不错,不用费心设计复杂的学习率方案。所以 Adam 成了如今最常用的默认优化器。
用 PyTorch 训练时,换优化器只是换一行代码的事:
import torch
model = torch.nn.Linear(1, 1) # 一个线性模型 y = w*x + b
loss_fn = torch.nn.MSELoss() # 均方误差损失
x = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
# 换优化器只需改这一行:
# SGD 带动量(惯性) vs Adam(自适应步长)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(200):
optimizer.zero_grad() # 清零上一次的梯度
loss = loss_fn(model(x), y) # 前向:预测并算损失
loss.backward() # 反向:算梯度
optimizer.step() # 优化器按规则更新参数
监控训练:看损失曲线,学会早停
训练的时候,最好把每个周期结束时的损失值画成曲线,一边训一边看,这比盯着日志里的数字直观得多。正常的曲线是一路下降、最后变平。如果曲线乱跳或上升,多半是学习率太大;如果下降得极慢,多半是学习率太小(或者模型该换更复杂的了)。
还要留一个心眼:训练损失在降,不代表模型真的变好了。如果验证集(没参与训练的数据)上的损失先降后升,而训练损失还在降,说明模型开始把训练数据的细节背下来了——过拟合的苗头。这时候最省事的对策就是早停(Early Stopping):一旦发现验证损失连续几个周期不再下降(甚至回升),就立刻停手,把之前验证损失最低的那份参数拿出来用。它像是训练里的一颗"刹车",帮你恰好停在泛化最好的时刻。