前两节我们从梯度下降讲到 SGD,又进一步引入了 momentum。
SGD 的基本更新规则是:
\[
\theta_{t+1} = \theta_t - \eta g_t
\]
Momentum 在这个基础上加入了过去梯度方向的累计:
\[
\begin{aligned}
v_t &= \beta v_{t-1} + g_t \\
\theta_{t+1} &= \theta_t - \eta v_t
\end{aligned}
\]
Momentum 解决的是方向问题:当前 mini-batch 的梯度可能很杂乱,所以我们不要只相信当前梯度,而是参考过去一段时间的趋势。因此,momentum 可以让梯度下降更有惯性,能加速训练并减少震荡。
但是这里还有另一个问题没有解决:
所有参数仍然共享同一个学习率。
在基础 SGD 和 momentum 中,学习率 \(\eta\) 是一个全局标量。无论某个参数的梯度经常很大,还是另一个参数的梯度长期很小,它们都使用同一个学习率。
这在简单模型里问题不大,但在神经网络中,不同参数的梯度尺度可能差异很大。有些参数每一步都会得到比较大的梯度,有些参数只有偶尔才会被更新。如果所有参数都用同一个学习率,就会出现一个矛盾:
学习率设得太大,梯度大的参数可能更新过猛;
学习率设得太小,梯度小或者稀疏更新的参数又学得太慢。
Adagrad (Duchi et al. 2011 ) 要解决的正是这个问题。它的想法非常直接:
不要让所有参数共享同一个学习率,而是根据每个参数过去的梯度大小,为每个参数自动调整有效学习率。
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.3.1 为什么一个学习率可能不够?
先考虑一个二维参数:
\[
\theta = (\theta_1, \theta_2)
\]
假设在训练过程中,\(\theta_1\) 的梯度经常很大,而 \(\theta_2\) 的梯度经常很小。如果我们使用普通 SGD:
\[
\theta_{t+1} = \theta_t - \eta g_t
\]
那么两个参数使用的是同一个学习率 \(\eta\) 。
对于 \(\theta_1\) 来说,因为梯度本来就大,再乘上同一个学习率,更新量可能太大,容易震荡。而对于 \(\theta_2\) 来说,因为梯度本来就小,再乘上同一个学习率,更新量可能太小,学习很慢。这说明问题不一定出在梯度方向本身,而是出在不同参数的梯度尺度不一样。一个全局学习率很难同时照顾所有参数。
更典型的例子来自稀疏特征。
假设我们在训练一个带有词向量的模型。常见词出现得很频繁,对应的 embedding 会被频繁更新;罕见词出现得很少,对应的 embedding 只有在少数 batch 中才会被更新。如果所有 embedding 参数都使用同一个学习率,那么常见词的参数会被更新很多次,而罕见词的参数可能很久才动一次。
所以,直觉上,我们可能希望:
经常被更新的参数,后面走得更谨慎一些;
很少被更新的参数,出现时可以走得相对大一些。
这就是 Adagrad 的核心动机。
4.3.2 Adagrad 的核心想法
Adagrad 的全称是 Adaptive Gradient Algorithm (Duchi et al. 2011 ) 。它的核心是为每个参数维护一个历史梯度平方和。
假设第 \(t\) 步的梯度是:
\[
g_t = \nabla_\theta L_t(\theta_t)
\]
Adagrad 会维护一个和参数形状相同的变量 \(s_t\) :
\[
s_t = s_{t-1} + g_t^2
\]
这里的平方是逐元素平方。如果 \(\theta\) 是一个向量,那么 \(g_t^2\) 也是逐元素平方。
然后 Adagrad 用 \(s_t\) 来缩放当前梯度:
\[
\theta_{t+1} = \theta_t - \eta \frac{g_t}{\sqrt{s_t}+\epsilon}
\]
其中,\(\epsilon\) 是一个很小的常数,用来避免分母为 0。
这个公式看起来比 SGD 复杂一些,但直觉非常简单。
对于某个参数,如果它过去经常出现较大的梯度,那么对应的 \(s_t\) 会变大。分母变大以后,这个参数的有效更新步长就会变小。对于另一个参数,如果它过去很少被更新,或者梯度一直很小,那么对应的 \(s_t\) 比较小。分母较小,这个参数的有效学习率就相对更大。
也就是说,Adagrad 不再给所有参数使用同一个有效学习率,而是给每个参数一个自己的缩放因子:
\[
\frac{\eta}{\sqrt{s_t}+\epsilon}
\]
所以 Adagrad 可以理解成:
根据每个参数过去累计的梯度大小,自动降低那些经常被大幅更新的参数的学习率。
4.3.3 从有效学习率看 Adagrad
为了看得更清楚,我们把 Adagrad 的更新公式拆开。
SGD 的更新可以写成:
\[
\Delta \theta_t = - \eta g_t
\]
Adagrad 的更新可以写成:
\[
\Delta \theta_t = - \frac{\eta}{\sqrt{s_t}+\epsilon} g_t
\]
这里:
\[
\eta_t = \frac{\eta}{\sqrt{s_t}+\epsilon}
\]
可以看成每个参数在第 \(t\) 步的有效学习率 。
由于 \(s_t\) 是历史梯度平方的累计和:
\[
s_t = \sum_{i=1}^{t} g_i^2
\]
所以 \(s_t\) 只会越来越大,不会变小。因此有效学习率 \(\eta_t\) 会随着训练不断变小。
这正是 Adagrad 的特点:
它会自动降低频繁更新参数的学习率;
它可以让稀疏参数在出现时保留相对较大的更新;
但是训练越久,所有参数的有效学习率都会逐渐下降。
前两个特点是优点,最后一个特点则会成为它的主要问题。
在训练早期,Adagrad 的自适应缩放很有帮助。它能快速抑制那些梯度很大的方向,让训练更稳定。但在训练后期,如果 \(s_t\) 已经累积得很大,那么分母会变得很大,有效学习率可能变得非常小。参数几乎不再移动,训练可能过早停下来。
这也是为什么后面的 RMSprop 和 Adadelta 会继续修改 Adagrad:它们想保留按参数自适应缩放的思想,但不希望历史梯度平方和无限累积。这样反而会让训练在后期变得过于保守。
4.3.4 Adagrad 与 SGD 的轨迹对比
我们先用一个简单的二维函数来观察 Adagrad 的行为:
\[
L(x, y) = 0.1 (x - 2)^2 + 2.0 (y + 1)^2
\]
这个函数在 \(y\) 方向更陡,在 \(x\) 方向更平缓。普通 SGD 在这种狭长损失曲面上,可能需要比较小的学习率才能稳定。如果学习率稍大,陡峭方向容易震荡。
Adagrad 会根据每个方向过去的梯度大小调整步长。由于 \(y\) 方向梯度经常较大,所以它的有效学习率会更快变小。\(x\) 方向梯度较小,所以它会保留相对更大的有效学习率。
先定义损失函数:
def loss_fn(theta: Tensor) -> Tensor:
x, y = theta[0 ], theta[1 ]
return 0.1 * (x - 2 ) ** 2 + 2.0 * (y + 1 ) ** 2
然后实现 Adagrad:
class Adagrad(dopt.Optimizer):
def __init__ (
self ,
params: Iterable[Tensor],
lr: float = 1e-2 ,
lr_decay: float = 0.0 ,
weight_decay: float = 0.0 ,
initial_accumulator_value: float = 0.0 ,
eps: float = 1e-10 ,
):
super ().__init__ (params)
self .lr = lr
self .lr_decay = lr_decay
self .weight_decay = weight_decay
self .initial_accumulator_value = initial_accumulator_value
self .eps = eps
self .step_count = 0
self .sum_of_sq_grads = [
torch.full_like(p, initial_accumulator_value) for p in self .params
]
self .effective_lrs = []
@override
@torch.no_grad ()
def step(self ):
self .step_count += 1
clr = self .lr / (1 + (self .step_count - 1 ) * self .lr_decay)
for p, s in zip (self .params, self .sum_of_sq_grads, strict= True ):
if p.grad is None :
continue
grad = p.grad
if self .weight_decay > 0 :
grad = grad.add(p, alpha= self .weight_decay)
s.add_(grad.square())
p.sub_(clr / (s.sqrt() + self .eps) * grad)
self .effective_lrs.append(self .get_effective_lr())
@torch.no_grad ()
def get_effective_lr(self ) -> list [Tensor]:
effective_lr = []
for s in self .sum_of_sq_grads:
lr = self .lr / (s.sqrt() + self .eps).clone()
effective_lr.append(lr)
return effective_lr
运行两种优化方法,并画出它们在参数平面上的轨迹:
theta1 = torch.tensor([- 5.0 , 2.0 ], requires_grad= True )
theta2 = torch.tensor([- 5.0 , 2.0 ], requires_grad= True )
optimizer1 = dopt.SGD([theta1], lr= 0.4 )
optimizer2 = Adagrad([theta2], lr= 1.2 )
sgd_history = dopt.run_optimizer(optimizer1, loss_fn, steps= 40 )
adagrad_history = dopt.run_optimizer(optimizer2, loss_fn, steps= 40 )
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(
sgd_history[:, 0 ],
sgd_history[:, 1 ],
color= '#EC705B' ,
marker= 'o' ,
markersize= 3 ,
markerfacecolor= '#C0392B' ,
markeredgecolor= 'white' ,
markeredgewidth= 0.5 ,
)
ax.plot(
adagrad_history[:, 0 ],
adagrad_history[:, 1 ],
color= '#4B96EB' ,
marker= 'o' ,
markersize= 3 ,
markerfacecolor= '#2A74C8' ,
markeredgecolor= 'white' ,
markeredgewidth= 0.5 ,
)
ax.set_xlabel(r' $ \t heta_1 $ ' )
ax.set_ylabel(r' $ \t heta_2 $ ' )
ax.legend(['SGD' , 'Adagrad' ])
ax.set_title('SGD vs. Adagrad Trajectory' )
plt.show()
可以看到,SGD 的轨迹在 \(y\) 方向震荡较大,而 Adagrad 的轨迹更快地收敛到较优点。这是因为 Adagrad 自动降低了 \(y\) 方向的有效学习率,让更新变得更谨慎。但是,在训练后期,Adagrad 的更新步长变得非常小,参数几乎不再移动。
4.3.5 Adagrad 有效学习率的可视化
Adagrad 最值得关注的不是参数更新公式本身,而是它产生的有效学习率。
我们可以把上面例子中两个参数的有效学习率画出来:
lr_history = torch.stack([lr[0 ] for lr in optimizer2.effective_lrs])
steps = torch.arange(1 , lr_history.size(0 ) + 1 )
fig = plt.figure(2 , figsize= (6 , 4 ))
ax = fig.add_subplot(1 , 1 , 1 )
ax.plot(steps, lr_history[:, 0 ], marker= 'o' , markersize= 3 )
ax.plot(steps, lr_history[:, 1 ], marker= 'o' , markersize= 3 )
ax.set_xlabel('Step' )
ax.set_ylabel('Effective learning rate' )
ax.legend([r' $ \t heta_1 $ ' , r' $ \t heta_2 $ ' ])
ax.set_title('Effective Learning Rates in Adagrad' )
plt.show()
从图中可以看到,不同参数的有效学习率并不相同。因为每个参数都有自己的历史梯度平方和,所以它们会被不同程度地缩放。同时,有效学习率整体上会随训练逐渐下降。原因很简单:
\[
s_t = s_{t-1} + g_t^2
\]
只要梯度不是严格为 0,\(s_t\) 就会继续增加。分母越大,有效学习率越小。这也说明 Adagrad 的自适应是有代价的:它通过不断累积历史梯度来让更新变得保守,但这个累积过程不会遗忘过去。
4.3.6 Adagrad 为什么适合稀疏特征?
Adagrad 最经典的优势之一,是它适合处理稀疏特征。
所谓稀疏特征,就是每次训练时只有一小部分参数会被更新。例如在自然语言处理中,如果模型有一个很大的 embedding 表,那么一个 mini-batch 里只会出现其中一小部分 token。只有这些 token 对应的 embedding 会得到梯度,其他 token 的 embedding 梯度为 0。
对于频繁出现的 token,它们的 embedding 会经常被更新。Adagrad 会逐渐增大这些参数对应的 \(s_t\) ,从而降低它们的有效学习率;对于很少出现的 token,它们的 embedding 很少得到非零梯度。对应的 \(s_t\) 增长较慢,所以一旦它们出现,仍然可以保留相对较大的有效学习率。
这就带来一个很自然的效果:
频繁出现的参数越学越谨慎,罕见但重要的参数不会因为全局学习率太小而完全学不动。
这也是 Adagrad 在一些稀疏特征任务中很有吸引力的原因。
不过,对于现代深度神经网络,尤其是需要长时间训练的大模型,Adagrad 的学习率持续衰减问题会比较明显。训练到后期后,\(s_t\) 可能已经很大,导致更新步长过小。
因此,Adagrad 更像是自适应学习率方法的起点,而不是现代深度学习中最常用的默认优化器。
4.3.7 Adagrad 和 SGD 与 Momentum 的关系
到这里,我们可以把 SGD、momentum 和 Adagrad 放在一起比较。
SGD 的问题是每一步只看当前梯度,所有参数共享同一个学习率。
Momentum 改进的是第一部分。它不再只看当前梯度,而是累计过去梯度方向,让更新更有惯性:
\[
v_t = \beta v_{t-1} + g_t
\]
Adagrad 改进的是第二部分。它仍然使用当前梯度方向,但会根据每个参数过去的梯度大小调整有效学习率:
\[
\begin{aligned}
s_t &= s_{t-1} + g_t^2 \\
\theta_{t+1} &= \theta_t - \eta \frac{g_t}{\sqrt{s_t}+\epsilon}
\end{aligned}
\]
所以可以这样理解:
Momentum 关注的是方向的平滑和加速 ;
Adagrad 关注的是不同参数的步长缩放 。
这两条思路后面会在 Adam 中重新汇合。Adam 一方面像 momentum 一样维护梯度的一阶动量,另一方面又像 RMSprop 一样维护梯度平方的滑动平均,用来做自适应缩放。
不过,在讲 Adam 之前,我们先来讲讲 RMSprop。它会从 Adagrad 的主要问题出发:
既然历史梯度平方和一直累积会让学习率越来越小,那能不能让优化器忘掉太久以前的梯度?
4.3.8 本章小结
这一节我们从 SGD 和 momentum 仍然共享全局学习率的问题出发,引出了 Adagrad。
Adagrad 的核心思想是为每个参数维护历史梯度平方和:
\[
s_t = s_{t-1} + g_t^2
\]
然后用这个累计量缩放当前梯度:
\[
\theta_{t+1} = \theta_t - \eta \frac{g_t}{\sqrt{s_t}+\epsilon}
\]
这样,过去梯度较大的参数会获得更小的有效学习率;过去梯度较小或者很少被更新的参数,则会保留相对较大的有效学习率。
Adagrad 的优点是直观且有效,尤其适合稀疏特征场景。它第一次非常清楚地体现了一个重要思想:优化器不一定要给所有参数使用同一个学习率。但是,Adagrad 也有一个明显问题:历史梯度平方和只会增加,不会减少。因此有效学习率会不断下降,训练后期可能变得过于保守。
下一节我们会看到,RMSprop 和 Adadelta 正是沿着这个问题继续改进。它们保留了 Adagrad 的自适应缩放思想,但把“累计所有历史梯度平方”改成了“最近梯度平方的滑动平均”。
References
Duchi, John, Elad Hazan, and Yoram Singer. 2011.
“Adaptive Subgradient Methods for Online Learning and Stochastic Optimization.” Journal of Machine Learning Research 12 (61): 2121–59.
http://jmlr.org/papers/v12/duchi11a.html .