4.3 Adagrad:自适应学习率的起点

Author

jshn9515

Published

2026-05-29

Modified

2026-06-05

前两节我们从梯度下降讲到 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'$\theta_1$')
ax.set_ylabel(r'$\theta_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'$\theta_1$', r'$\theta_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.

Reuse