在前面的章节里,我们已经知道了神经网络可以看成一个带参数的函数。给定输入 \(x\) ,模型会经过前向传播得到预测结果,再通过损失函数衡量预测和真实标签之间的差距。训练的目标,就是不断调整模型参数,让损失越来越小。
如果用 \(\theta\) 表示模型里的所有可学习参数,用 \(L(\theta)\) 表示当前参数下的损失,那么训练神经网络本质上就是在解一个优化问题:
\[
\min_\theta L(\theta)
\]
这句话看起来很简单,但它背后有一个非常关键的问题:
我们怎么知道参数应该往哪个方向更新?
自动微分可以帮我们计算梯度。对于参数 \(\theta\) ,梯度 \(\nabla_\theta L(\theta)\) 可以告诉我们,如果稍微改变参数,损失会如何变化。但是,知道梯度还不等于知道完整的训练过程。梯度只是告诉我们局部方向,真正训练模型时,我们还需要决定:
参数应该沿着哪个方向移动;
每次应该移动多远;
应该用多少数据来估计这个方向。
这一节我们先从最基础的梯度下降开始,看清楚“根据梯度更新参数”这件事的基本逻辑。然后我们会进一步讨论,为什么在深度学习里更常见的不是完整的梯度下降,而是随机梯度下降,也就是我们常说的 SGD (Stochastic Gradient Descent) (Bottou 2012 ) 。
from collections.abc import Iterable
from typing import override
import dnnlpy
import dnnlpy.optim as dopt
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch import Tensor
plt.rc('figure' , dpi= 100 )
dnnlpy.set_matplotlib_format('svg' )
print ('PyTorch version:' , torch.__version__)
PyTorch version: 2.12.1+cpu
4.1.1 梯度告诉我们什么?
先考虑一个最简单的情况:只有一个参数 \(\theta\) ,损失函数是 \(L(\theta)\) 。
如果我们站在当前参数位置 \(\theta_t\) ,梯度
\[
\frac{dL}{d\theta}
\]
表示损失函数在这个位置的变化方向。
如果梯度是正的,说明当 \(\theta\) 增大时,损失会增大。为了让损失变小,我们应该让 \(\theta\) 往小的方向走。而如果梯度是负的,说明当 \(\theta\) 增大时,损失会减小。为了让损失变小,我们应该让 \(\theta\) 往大的方向走。
所以不管梯度是正还是负,想让损失下降,一个自然的选择就是沿着梯度的反方向更新参数:
\[
\theta_{t+1} = \theta_t - \eta \frac{dL}{d\theta}
\]
这里的 \(\eta\) 是学习率(learning rate) ,用来控制每一步走多远。
如果参数不止一个,而是一整个向量,那么更新规则可以写成:
\[
\theta_{t+1} = \theta_t - \eta \nabla_\theta L(\theta_t)
\]
这就是最基本的梯度下降(Gradient Descent) 。
直观来说,梯度下降就像在山坡上往低处走。当前位置的梯度告诉我们山坡最陡的上升方向,而负梯度方向就是最陡的下降方向。我们每次沿着负梯度方向走一小步,希望一步一步走到损失较小的位置。
当然,学习率 \(\eta\) 的选择也非常重要。如果学习率太小,每一步走得很谨慎,训练会很慢。损失虽然可能稳定下降,但需要很多次迭代才能接近较好的参数。但如果学习率太大,每一步可能跨得太远,甚至直接越过最低点。更严重时,参数会在损失曲面上来回震荡,损失不降反升,训练变得不稳定。因此,学习率是优化过程中最重要的超参数之一。
我们可以把学习率理解成模型对梯度的信任程度。学习率越大,模型越激进,相信当前梯度方向可以走很远;学习率越小,模型越保守,只沿着当前方向挪动一点点。
在后面的章节里,我们会看到,很多优化器的核心思想其实都和学习率有关。比如 Adagrad、RMSprop 和 Adam 会为不同参数自动调整学习率;学习率调度器(Learning Rate Scheduler)则会让全局学习率在训练过程中发生变化。但在最基础的梯度下降里,学习率就是一个固定的手动设置的数。
4.1.2 用一个简单例子观察梯度下降
为了更直观地理解梯度下降,我们先不考虑神经网络,而是看一个简单函数:
\[
L(\theta) = 0.1 (x - 2)^2 + 2.0 (y + 1)^2
\]
这个函数的最小值显然出现在 \((x, y) = (2, -1)\) 。如果我们从一个较远的位置开始,比如 \((x, y) = (-5, 2)\) ,梯度下降应该一步一步把参数推向 \((2, -1)\) 。
先定义损失函数:
def loss_fn(theta: Tensor) -> Tensor:
x, y = theta[0 ], theta[1 ]
return 0.1 * (x - 2 ) ** 2 + 2.0 * (y + 1 ) ** 2
然后实现一个简单的梯度下降:
class SimpleSGD(dopt.Optimizer):
def __init__ (self , params: Iterable[Tensor], lr: float = 1e-3 ):
super ().__init__ (params)
self .lr = lr
@override
@torch.no_grad ()
def step(self ):
for p in self .params:
if p.grad is None :
continue
p.sub_(p.grad, alpha= self .lr)
运行梯度下降,并观察参数的更新轨迹:
theta = torch.tensor([- 5.0 , 2.0 ], requires_grad= True )
optimizer = SimpleSGD([theta], lr= 0.4 )
history = dopt.run_optimizer(optimizer, loss_fn, steps= 40 )
print ('Final theta:' , history[- 1 ])
print ('Final loss:' , loss_fn(history[- 1 ]).item())
Final theta: tensor([ 1.7508, -1.0000])
Final loss: 0.0062118638306856155
我们可以把参数更新轨迹画出来:
x = torch.linspace(- 6.5 , 5.5 , 200 )
y = torch.linspace(- 3.5 , 2.5 , 200 )
X, Y = torch.meshgrid(x, y, indexing= 'ij' )
Z = 0.1 * (X - 2 ) ** 2 + 2.0 * (Y + 1 ) ** 2
fig = plt.figure(1 )
ax = fig.add_subplot(1 , 1 , 1 )
ax.contourf(X, Y, Z, levels= 25 , cmap= 'YlGnBu' )
ax.plot(
history[:, 0 ],
history[:, 1 ],
color= '#EC705B' ,
marker= 'o' ,
markersize= 3 ,
markerfacecolor= '#C0392B' ,
markeredgecolor= 'white' ,
markeredgewidth= 0.5 ,
)
ax.set_xlabel(r' $ \t heta_1 $ ' )
ax.set_ylabel(r' $ \t heta_2 $ ' )
ax.set_title('SGD on a Simple Quadratic Function' )
plt.show()
从图中可以看到,参数一开始离最优点较远,梯度较大,所以每一步移动也比较明显。随着参数逐渐接近 \((2, -1)\) ,梯度越来越小,更新步长也自然变小。这体现了梯度下降的一个基本特点:即使学习率固定,实际更新量也会受到梯度大小的影响。远离最低点时,梯度通常较大,参数移动较快;接近最低点时,梯度变小,参数移动变慢。
4.1.3 从单个样本到整个数据集
上面的例子里,损失函数只有一个简单公式。但在真实的监督学习任务中,我们通常有一个训练集:
\[
\mathcal{D} = \{(x_1, y_1), (x_2, y_2), \dots, (x_N, y_N)\}
\]
模型参数为 \(\theta\) ,对于每个样本 \((x_i, y_i)\) ,模型都会产生一个损失:
\[
\ell_i(\theta) = \ell(f_\theta(x_i), y_i)
\]
整个训练集上的平均损失可以写成:
\[
L(\theta) = \frac{1}{N} \sum_{i=1}^N \ell_i(\theta)
\]
如果我们想严格按照梯度下降更新参数,就需要计算整个训练集的平均损失,再对它求梯度:
\[
\nabla_\theta L(\theta) = \frac{1}{N} \sum_{i=1}^N \nabla_\theta \ell_i(\theta)
\]
然后更新参数:
\[
\theta_{t+1} = \theta_t - \eta \nabla_\theta L(\theta_t)
\]
这通常被称为批量梯度下降(Batch Gradient Descent) ,也就是每次更新都使用整个训练集。
这个做法在数学上很干净。因为我们每次使用的确实是整个训练目标的梯度,方向比较稳定,不会受到单个样本噪声的影响。但问题也很明显:当训练集很大时,每更新一次参数都要遍历全部数据,代价太高了。
想象一下,如果我们有几百万张图片,每次只为了更新一步参数,就要把所有图片都前向传播和反向传播一遍,那么训练会非常慢。更重要的是,在深度学习里,我们通常需要更新成千上万步参数。如果每一步都使用完整训练集,计算成本会非常夸张。
所以,批量梯度下降虽然概念简单,但并不是现代深度学习训练中最常用的形式。
4.1.4 随机梯度下降:不用每次都看完整数据集
既然每次用完整训练集太贵,一个自然的问题就是:
能不能每次只看一部分数据,也大致知道参数该往哪里走?
这就是随机梯度下降的核心思想。
最极端的做法是每次只随机抽取一个样本 \((x_i, y_i)\) ,用这个样本的损失梯度近似整个训练集的梯度:
\[
\nabla_\theta L(\theta) \approx \nabla_\theta \ell_i(\theta)
\]
于是参数更新变成:
\[
\theta_{t+1} = \theta_t - \eta \nabla_\theta \ell_i(\theta_t)
\]
这就是最原始意义上的随机梯度下降(Stochastic Gradient Descent) 。
它的好处是每次更新都非常便宜,因为只需要计算一个样本的梯度。缺点是方向会很杂乱。单个样本不一定能代表整个训练集,所以每一步的方向可能会偏,有时甚至会暂时让整体损失变大。但从长期来看,如果样本是随机抽取的,这些杂乱的更新方向会围绕真实梯度方向波动。模型不再每一步都精确地朝着整体损失下降最快的方向走,而是在噪声中逐渐靠近较好的区域。
这种噪声并不完全是坏事。在深度学习的非凸损失曲面中,适当的随机性有时反而可以帮助模型跳出某些狭窄或不好的区域。不过,噪声太大也会让训练不稳定。因此,实践中我们很少真的每次只用一个样本,而是使用 mini-batch。
4.1.5 Mini-batch SGD:深度学习里的默认训练方式
在现代深度学习中,我们通常说的 SGD,很多时候指的是 Mini-batch SGD 。也就是说,每次不是使用一个样本,也不是使用整个训练集,而是随机抽取一小批样本:
\[
\mathcal{B} = \{(x_i, y_i)\}_{i \in B}
\]
然后计算这个 mini-batch 上的平均损失:
\[
L_{\mathcal{B}}(\theta) = \frac{1}{|\mathcal{B}|} \sum_{i \in B} \ell_i(\theta)
\]
再用它的梯度来更新参数:
\[
\theta_{t+1} = \theta_t - \eta \nabla_\theta L_{\mathcal{B}}(\theta_t)
\]
Mini-batch SGD 介于完整 batch gradient descent 和单样本 SGD 之间。如果 batch size 很大,梯度估计更稳定,但每一步计算成本更高,而且更新次数变少;如果 batch size 很小,每一步计算更便宜,更新更频繁,但梯度噪声更大,训练可能更抖。因此,mini-batch 实际上是在计算效率和梯度估计质量之间做折中。
从实现角度看,PyTorch 中常见的训练循环其实就是 mini-batch SGD 的形式:
model = nn.Linear(1 , 1 )
optimizer = optim.SGD(model.parameters(), lr= 0.1 )
x = torch.randn(32 , 1 )
y = 2 * x + 1
pred = model(x)
loss = F.mse_loss(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print ('Mini-batch loss:' , loss.item())
Mini-batch loss: 1.8850107192993164
这里的 x 和 y 就是一个 mini-batch。loss.backward() 计算的是当前 mini-batch 上的梯度,并存储在模型参数的 .grad 属性里;而optimizer.step() 则根据这些梯度更新参数。
也就是说,PyTorch 里的这三行代码:
optimizer.zero_grad()
loss.backward()
optimizer.step()
背后对应的就是:
\[
\theta_{t+1} = \theta_t - \eta \nabla_\theta L_{\mathcal{B}}(\theta_t)
\]
4.1.6 为什么训练时要打乱数据?
如果我们每次都使用 mini-batch,那么还有一个问题:这些 mini-batch 应该怎么取?
通常做法是在每个 epoch 开始时打乱训练数据,然后按顺序切成一个个 mini-batch。这样可以避免模型总是看到固定顺序的数据。
如果数据不打乱,mini-batch 之间可能带有很强的偏差。比如一个分类数据集里,前一部分全是猫,后一部分全是狗。如果不打乱数据,模型在前几步只看到猫,后几步只看到狗,梯度方向就会非常不稳定。打乱数据的目的,是让每个 mini-batch 尽量像整个训练集的一个小型随机样本。这样 mini-batch 梯度虽然仍然有噪声,但它至少不会长期偏向某一类数据。
这也是为什么在 PyTorch 的 DataLoader 中,训练集通常会设置 shuffle=True 的原因。
dataloader = utils.DataLoader(
dataset,
batch_size= 32 ,
shuffle= True ,
)
而验证集和测试集通常不需要 shuffle=True。因为验证和测试阶段不更新参数,只需要稳定地评估模型性能,数据顺序并不影响最终平均指标。
4.1.7 SGD 中的 Weight Decay
前面我们讨论的 SGD 只关心一件事:让训练损失下降。也就是说,参数更新完全由当前 mini-batch 上的梯度决定:
\[
\theta_{t+1} = \theta_t - \eta g_t
\]
其中,\(g_t = \nabla_\theta L_{\mathcal{B}}(\theta_t)\) 。但是在训练神经网络时,我们通常希望模型不仅能在训练集上表现好,还能在未见过的数据上泛化得好。为了提高泛化能力,我们经常会使用一些正则化手段,来约束模型的复杂度。
一个常见做法是在损失函数里额外加入一个惩罚项,让模型倾向于使用更小的权重:
\[
L_{\text{reg}}(\theta) = L(\theta) + \frac{\lambda}{2}\|\theta\|_2^2
\]
这里的 \(\lambda\) 是正则化强度,控制我们有多希望参数变小。\(\|\theta\|_2^2\) 表示参数平方和。这个项越大,说明参数整体越大;加入它之后,优化器不仅要降低原来的训练损失,也要避免参数无限变大。
这通常被称为 L2 regularization 。而在 SGD 里,它也经常被叫做 weight decay 。
这个名字来自它对参数更新的影响。因为:
\[
\nabla_\theta \frac{\lambda}{2}\|\theta\|_2^2 = \lambda \theta
\]
所以加入 L2 正则项之后,梯度变成:
\[
g_t + \lambda \theta_t
\]
于是 SGD 的更新规则变成:
\[
\theta_{t+1} = \theta_t - \eta (g_t + \lambda \theta_t)
\]
把它展开一下:
\[
\theta_{t+1} = (1 - \eta \lambda)\theta_t - \eta g_t
\]
这一步很重要。除了正常的梯度更新 \(-\eta g_t\) 之外,参数本身还会被乘上一个小于 1 的系数 \((1 - \eta\lambda)\) 。也就是说,每一步更新都会让权重稍微向 0 衰减一点,所以它被称为 weight decay。
直观来说,普通 SGD 只问:
为了降低当前 batch 的损失,参数应该怎么动?
而带 weight decay 的 SGD 还会额外问:
在降低损失的同时,能不能让参数别变得太大?
所以 weight decay 可以看成一种约束模型复杂度的手段。它不会直接减少模型参数个数,而是让模型更倾向于使用较小的权重。
在最基础的 SGD 中,实现 weight decay 非常简单。我们只需要在更新参数前,把 \(\lambda \theta\) 加到梯度上:
class SimpleSGDWithWeightDecay(dopt.Optimizer):
def __init__ (
self ,
params: Iterable[Tensor],
lr: float = 1e-3 ,
weight_decay: float = 0.0 ,
):
super ().__init__ (params)
self .lr = lr
self .weight_decay = weight_decay
@override
@torch.no_grad ()
def step(self ):
for p in self .params:
if p.grad is None :
continue
grad = p.grad
if self .weight_decay > 0 :
grad = grad.add(p, alpha= self .weight_decay)
p.sub_(grad, alpha= self .lr)
注意,这里的 weight_decay 并不是直接从参数里减去一个固定值,而是按照参数当前的大小成比例地衰减。参数越大,衰减项也越大;参数越接近 0,衰减项也越小。
我们可以用同一个简单二次函数看一下它的效果:
theta1 = torch.tensor([- 5.0 , 2.0 ], requires_grad= True )
theta2 = torch.tensor([- 5.0 , 2.0 ], requires_grad= True )
optimizer1 = SimpleSGD([theta1], lr= 0.4 )
optimizer2 = SimpleSGDWithWeightDecay([theta2], lr= 0.4 , weight_decay= 0.2 )
history_no_decay = dopt.run_optimizer(optimizer1, loss_fn, steps= 40 )
history_with_decay = dopt.run_optimizer(optimizer2, loss_fn, steps= 40 )
print ('Without weight decay:' )
print ('Final theta:' , history_no_decay[- 1 ])
print ('Final loss:' , loss_fn(history_no_decay[- 1 ]).item())
print ('With weight decay:' )
print ('Final theta:' , history_with_decay[- 1 ])
print ('Final loss:' , loss_fn(history_with_decay[- 1 ]).item())
Without weight decay:
Final theta: tensor([ 1.7508, -1.0000])
Final loss: 0.0062118638306856155
With weight decay:
Final theta: tensor([ 0.9944, -0.9524])
Final loss: 0.10566135495901108
画出参数更新轨迹:
fig = plt.figure(2 )
ax = fig.add_subplot(1 , 1 , 1 )
h1 = ax.plot(history_no_decay, color= 'royalblue' )
h2 = ax.plot(history_with_decay, color= 'coral' )
h3 = ax.axhline(2 , linestyle= '--' , color= 'gray' , linewidth= 1 , zorder= 0 )
h4 = ax.axhline(- 1 , linestyle= '--' , color= 'gray' , linewidth= 1 , zorder= 0 )
ax.set_xlabel('Step' )
ax.set_ylabel(r' $ \t heta $ ' )
labels = ['Without weight decay' , 'With weight decay' , 'Optimal point' ]
ax.legend([h1[0 ], h2[0 ], h3], labels)
ax.set_title('SGD with Weight Decay' )
plt.show()
在没有 weight decay 时,参数会逐渐靠近原始损失函数的最优点 \((2, -1)\) 。加入 weight decay 之后,参数会受到额外的拉回 0 的力量,所以最后 \(x\) 停在一个比 2 更小的位置,\(y\) 停在一个比 -1 更大的位置。这并不是优化器出了问题,而是因为我们优化的目标已经变了。加入 weight decay 之后,优化器不再只最小化
\[
L(\theta) = 0.1 (x - 2)^2 + 2.0 (y + 1)^2
\]
而是在最小化
\[
L(\theta) = 0.1 (x - 2)^2 + 2.0 (y + 1)^2 + \frac{\lambda}{2}(x^2 + y^2)
\]
因此,新的最优点会在原始最优点和 0 之间。
在 PyTorch 里,optim.SGD 也提供了 weight_decay 参数:
model = nn.Linear(1 , 1 )
optimizer = optim.SGD(
model.parameters(),
lr= 0.1 ,
weight_decay= 1e-4 ,
)
不过需要注意,通常我们不会对所有参数都使用 weight decay。比如 Linear 的 bias、LayerNorm 的 scale 和 shift 参数常常不做 weight decay,因为这些参数不是普通线性层或卷积层的权重,对它们衰减不一定有帮助。实际项目中,经常会通过参数组给不同参数设置不同的 weight_decay。这部分我们在 PyTorch 优化器的章节里曾经提过。
4.1.8 SGD 的优点和问题
到这里,我们可以总结一下 SGD 为什么重要。
首先,SGD 让大规模训练变得可行。模型不需要每次更新都遍历完整训练集,而是可以用 mini-batch 梯度近似整体梯度。这极大降低了每一步更新的成本。
其次,SGD 的更新规则非常简单:
\[
\theta_{t+1} = \theta_t - \eta g_t
\]
其中,\(g_t\) 是当前 mini-batch 上的梯度。正因为它简单,我们才能更清楚地看到后续优化器到底改进了什么。Momentum 会修改更新方向,让参数更新具有惯性;Adagrad、RMSprop 和 Adam 会修改不同参数的有效学习率;AdamW 则会重新处理权重衰减和梯度更新之间的关系。
但是,SGD 本身也有明显问题。
第一个问题是更新方向容易抖动。Mini-batch 梯度只是整体梯度的一个估计,不同 batch 之间可能差异很大,因此参数更新轨迹会比较杂乱。
第二个问题是所有参数共享同一个学习率。无论某个参数梯度经常很大,还是另一个参数梯度长期很小,基础版本的 SGD 都用同一个 \(\eta\) 控制它们的更新。这在复杂神经网络中可能不够灵活。
第三个问题是 SGD 不会记住过去的梯度。每一步更新只看当前 mini-batch 的梯度,过去的更新方向不会直接影响当前更新。这会导致参数在某些方向上来回震荡,尤其是在损失曲面形状很狭长的时候。
这些问题正是后续优化器要解决的。
Momentum 会问:既然当前梯度带有噪声,我们能不能参考过去一段时间的更新方向?
Adagrad 和 RMSprop 会问:既然不同参数的梯度尺度不同,我们能不能给每个参数分配不同的学习率?
Adam 会问:能不能同时结合 momentum 的方向平滑和 RMSprop 的自适应缩放?
所以,SGD 不只是一个基础优化器,它也是理解后面所有优化器的起点。
4.1.9 本章小结
这一节我们从最基本的问题出发:有了梯度之后,参数应该如何更新?
梯度告诉我们损失上升最快的方向,因此负梯度方向就是局部下降最快的方向。梯度下降的核心思想就是沿着负梯度方向更新参数,并用学习率控制每一步走多远。
对于完整训练集,批量梯度下降每次使用所有样本来计算梯度,方向稳定,但计算代价很高。随机梯度下降则用随机样本或 mini-batch 的梯度近似整体梯度,大幅降低了每一步的计算成本,也引入了适度的随机性。
在现代深度学习中,我们通常使用 mini-batch SGD。它在计算效率和梯度稳定性之间取得折中,是神经网络训练的基本形式。PyTorch 中常见的 zero_grad()、backward() 和 step() 训练流程,本质上就是在 mini-batch 上计算梯度并更新参数。
此外,我们还介绍了 SGD 中的 weight decay。它可以理解为在损失函数中加入 L2 正则项,让参数在降低训练损失的同时也倾向于保持较小的规模。对于基础 SGD 来说,L2 regularization 和 weight decay 是等价的。但对于 Adam 这类自适应优化器,二者会产生差异,因此后面讲 AdamW 时还会重新回到这个问题。
不过,基础 SGD 也有局限:它的更新方向容易受 mini-batch 噪声影响,所有参数共享同一个学习率,并且不会记住过去的梯度方向。下一节我们会看到,momentum 正是从这些问题出发,对 SGD 做出的第一个重要改进。