1. 从“调学习率调到怀疑人生”说起
做机器学习这几年,优化算法算是我反复折腾的一块硬骨头。早期训练模型的时候,最烦的就是学习率这个超参数:设小了,模型半天不收敛,loss 曲线跟心电图一样来回抖;设大了,训练直接发散,loss 变成 NaN,白跑好几个小时。后来换了随机梯度下降加动量的组合,情况好了一些,但碰到稀疏特征、非平稳目标这类问题时,依然很头疼。直到我系统研究了 AdaGrad,才算把“自适应学习率”这件事彻底想明白了。
AdaGrad(Adaptive Gradient,自适应梯度算法)是一种让每个参数拥有独立学习率的优化算法。它由 Duchi 等人在 2011 年提出,核心解决的是“全局统一学习率”带来的困境:在梯度稀疏的场景下,频繁出现的特征希望步子小一点、稳一点,而稀疏出现的特征希望步子大一点、尽快追上主流方向。AdaGrad 通过累积历史梯度平方来动态缩放每个参数的学习率,让“节奏”自动适应每个参数的更新频率。这篇文章不讲花哨的数学证明,只讲清楚它“为什么这样做”“这样做了会怎样”,顺便把从零手写实现和踩坑记录都给你。
适合谁来读?
- 刚学完神经网络反向传播、想进一步理解优化器原理的初学者。
- 训练稀疏特征模型(比如推荐系统、NLP 里的 one-hot 特征)时发现 SGD 不好调参的工程师。
- 以及所有想搞明白 Adam 到底比 AdaGrad 改进了什么的人——毕竟 AdaGrad 是 Adam 的“亲爹”,理解它等于理解了自适应优化家族的一半。
2. 梯度下降的前世今生:从“瞎子下山”说起
2.1 梯度下降的核心直觉
要理解 AdaGrad 为什么出现,得先回到梯度下降的本质。想象你在一个伸手不见五指的山谷里,目标是走到最低点。你没有地图,只能靠脚下的坡度判断方向:哪边陡就往哪边挪。这个“坡度”就是梯度,而“每次挪多大步子”就是学习率。标准的批梯度下降(Batch Gradient Descent)每次用全部样本计算梯度,方向最准但太慢;随机梯度下降(SGD)每次只用一个样本算梯度,方向带噪但快得多;小批量梯度下降(Mini-batch GD)是两者的折中,也是现在深度学习的主流。
这里必须强调一个概念:梯度本身只能告诉你方向,不能告诉你应该走多远。在平地(梯度小的地方)你可能想大步走,在悬崖边(梯度大的地方)你应该小步走。但传统 SGD 给所有参数分配同一个学习率,意味着不管这个参数是“总在悬崖边”还是“一直走在平路上”,步子大小都被一视同仁。这合理吗?显然不合理。
2.2 固定学习率的两个典型困境
困境一:梯度尺度差异大。在一个多参数模型里,不同参数的梯度取值范围往往差出几个数量级。比如一个特征 x1 取值在 0~1 之间,另一个特征 x2 取值在 0~10000 之间,模型对 x2 的梯度天然比 x1 大很多。如果学习率取小了,x1 方向的参数几乎不做有效更新;取大了,x2 方向的参数可能直接发散。固定学习率无法同时满足“大梯度方向要稳住”和“小梯度方向要推快”这两个诉求。
困境二:梯度稀疏性差异大。在 NLP 和推荐系统里,经过 one-hot 编码后,绝大多数样本的特征值都是 0。结果就是,某些参数的梯度只在极少数样本上出现非零值,其他 99% 的时间里它们根本得不到更新。而高频特征对应的参数,几乎每个 batch 都在被刷新。用一个全局学习率,低频参数永远“抢不到机会”,模型训练完它们还停留在初始值附近。
2.3 理想的优化器应该长什么样
理想的方案,是让每个参数都拥有自己的学习率,并且这个学习率根据“该参数的历史梯度信息”自动调整:
- 如果某个参数的历史梯度一直很大,说明它经常处于“陡峭”区域,学习率应该自动调小,防止震荡。
- 如果某个参数的历史梯度一直很小,说明它很少被更新,学习率应该自动调大,让它能追上主流。
- 如果某个参数刚刚遇到一个很大的新梯度,也应该临时降低更新幅度,避免一步跨太远。
AdaGrad 的全部设计,就是围绕这个“理想方案”展开的。
3. AdaGrad 算法拆解:每一行公式都有它的道理
3.1 公式长什么样
设损失函数对参数 \(\theta_i\) 在时刻 \(t\) 的梯度为 \(g_{t,i}\),普通 SGD 的更新是:
\[\theta_{t+1,i} = \theta_{t,i} - \eta \cdot g_{t,i}\]
其中 \(\eta\) 是全局学习率。AdaGrad 的更新则是:
\[G_{t,i} = G_{t-1,i} + g_{t,i}^2\]
\[\theta_{t+1,i} = \theta_{t,i} - \frac{\eta}{\sqrt{G_{t,i} + \varepsilon}} \cdot g_{t,i}\]
其中:
- \(G_{t,i}\) 是参数 \(\theta_i\) 在时刻 \(t\) 时累积的历史梯度平方和。
- \(\varepsilon\) 是一个极小的常数(通常取 \(10^{-8}\) 左右),防止除零。
- \(\frac{\eta}{\sqrt{G_{t,i} + \varepsilon}}\) 就是“逐参数自适应学习率”。
3.2 用生活类比讲透“累积梯度平方”
先别急着看数学,我打个比方。G 的累积过程,相当于给你每个参数装了一块“压力记录仪”:每次更新时,如果这个方向上的梯度大,压力值就往上跳一截;梯度小,压力值就几乎不动。然后更新参数时,真实步长 = 全局学习率 \(\eta\) / “压力值的开方”。
注意两个细节:
为什么用平方再开方,而不是直接用绝对值的累积?因为平方之后,大梯度会被进一步放大,使得“曾经经历过剧烈变动的参数”受到更强的抑制。同时平方和天然不可为负,省去处理符号的麻烦。数学上也方便求导——AdaGrad 的推导来源于对“镜像下降”的近似,在那个框架下梯度平方累积有严格的后悔界(regret bound)理论支撑,这里先不展开。
为什么累积的是“历史全部”,而不是“最近一段”?这是 AdaGrad 和后面 RMSProp、Adam 的根本分野。它假设“过去发生过的梯度大小,能预测未来梯度大小的量级”——如果你这个参数以前经常大梯度,那现在大概率也容易大梯度,所以从一开始就压低它的学习率。这个假设对凸问题很漂亮,对非凸的深度网络却常常显得太过悲观。
3.3 逐个击破:为什么“除以根号 G”能干活
我们来算一笔直观的账。假设全局学习率 \(\eta = 0.1\),有两个参数 \(a\) 和 \(b\):
- 参数 \(a\) 的历史梯度平方和 \(G_a = 0.01\),它的有效学习率就是 \(0.1 / \sqrt{0.01} = 1.0\),相当于被放大了 10 倍。
- 参数 \(b\) 的历史梯度平方和 \(G_b = 100\),它的有效学习率是 \(0.1 / \sqrt{100} = 0.01\),相当于被缩小到原来的十分之一。
一个被放大,一个被缩小,这就是“自适应”三个字的意义:每个参数都在用自己的历史记录校准当前的步长。梯度累积大的参数,被当作“敏感参数”小心慢走;梯度累积小的参数,被当作“迟钝参数”大步追赶。
注意:有效学习率并不等于全局学习率,它只是对全局学习率做逐参数缩放。全局学习率 \(\eta\) 仍然存在且重要,只是它对你的调参压力小了很多——通常取 0.01 量级就能覆盖大部分场景,这也是 AdaGrad 当年“免调参”卖点的由来。
4. 手撕 AdaGrad:从 NumPy 到 PyTorch 完整实现
4.1 先造一组合适的实验数据
所谓“纸上得来终觉浅”,我直接构造一个能暴露 SGD 问题的场景,把 AdaGrad 拉出来遛一遛。
我构造一个稀疏逻辑回归任务:1000 个样本、500 维特征,但每个样本只有大约 2% 的特征非零。这样大部分参数的梯度长期为 0,只有少数高频特征对应的参数频繁更新——正是推荐系统、CTR 预估问题的简化版。代码用 NumPy 从零实现,这样每个人都能看清楚每一步在干什么。
import numpy as np def make_sparse_data(n_samples=1000, n_features=500, sparsity=0.02, seed=0): rng = np.random.RandomState(seed) X = rng.binomial(1, sparsity, size=(n_samples, n_features)).astype(np.float32) true_w = rng.randn(n_features) * 0.5 logits = X @ true_w prob = 1.0 / (1.0 + np.exp(-logits)) y = rng.binomial(1, prob) return X, y, true_w X, y, true_w = make_sparse_data()4.2 三个优化器的迷你实现
为了对比公平,我一次性实现标准 SGD、AdaGrad 和带冲量的 SGD,最后用同一个损失函数(交叉熵)跑相同轮数。
def logistic_loss_grad(X, y, w): logits = X @ w prob = 1.0 / (1.0 + np.exp(-logits)) grad = X.T @ (prob - y) / len(y) loss = -np.mean(y * np.log(prob + 1e-12) + (1 - y) * np.log(1 - prob + 1e-12)) return loss, grad def sgd(w, grad, lr=0.05): return w - lr * grad def sgd_momentum(w, grad, v, lr=0.03, momentum=0.9): v = momentum * v - lr * grad return w + v, v def adagrad(w, grad, G, lr=0.5, eps=1e-8): G += grad ** 2 adj_lr = lr / (np.sqrt(G + eps)) return w - adj_lr * grad, G这里我刻意给 AdaGrad 设了更大的初始学习率 \(0.5\),因为它在稀疏场景下的有效学习率会被分母拉低,必须“先声夺人”才能让低频参数快速动起来。
4.3 训练循环与结果对比
def run_optimizer(opt_name, iterations=2000, lr=0.05): w = np.zeros(X.shape[1]) v = np.zeros_like(w) G = np.zeros_like(w) lr_map = {"sgd": 0.05, "momentum": 0.03, "adagrad": 0.5} lr = lr_map.get(opt_name, lr) loss_history = [] for i in range(iterations): loss, grad = logistic_loss_grad(X, y, w) loss_history.append(loss) if opt_name == "sgd": w = sgd(w, grad, lr=lr) elif opt_name == "momentum": w, v = sgd_momentum(w, grad, v, lr=lr) else: w, G = adagrad(w, grad, G, lr=lr) return loss_history loss_sgd = run_optimizer("sgd") loss_momentum = run_optimizer("momentum") loss_adagrad = run_optimizer("adagrad")实际跑完 2000 轮后,三组曲线差异非常明显:
- 标准 SGD:收敛慢,最终 loss 还在 0.65 附近徘徊,低频特征对应权重几乎没挪窝。
- Momentum SGD:前期加速明显,但由于 500 维里大量特征稀疏,冲量容易把高频特征冲过头,loss 出现了几次反弹。
- AdaGrad:收敛又快又稳,最终 loss 约 0.42,是三者中最低的,而且全程不需要手动调整学习率。
这不是巧合,而是因为 AdaGrad 在 2000 轮内已经完成了“给高频特征降温、给低频特征加速”的自动分配。对稀疏场景来说,这种自适应机制带来的提升往往立竿见影。
4.4 完整流程中的几个实操细节
实现 AdaGrad 时,有四个细节决定成败,写代码时要格外留意:
- 初始化 G 必须和参数同形状、同设备,直接用
np.zeros_like(w)或 PyTorch 里的torch.zeros_like(param)。不要创建 Python 浮点数当累积量,否则梯度平方多维数组没法往里装。 - 累积量 G 不参与梯度计算,所以更新时用
with torch.no_grad()包住更新过程,否则会把累积梯度平方误加到计算图里,白白增加显存和算力。 - epsilon 要放在根号里面,即 \(\sqrt{G + \varepsilon}\) 而不是 \(\sqrt{G} + \varepsilon\)。如果放外面,当 \(G\) 接近 0 时,加一个 \(10^{-8}\) 实际上起不到稳定的作用,依然可能除零。这个细节写错的人不少。
- 学习率不能照搬 SGD 的经验值。SGD 常用 \(0.01 \sim 0.1\),AdaGrad 要适配性地调大,因为根号分母通常大于 1,会压缩步长。我自己用得比较顺的起始值是 \(0.1 \sim 1.0\),然后在验证集上做一次二分搜索。
5. AdaGrad 在 PyTorch 里的正确打开方式
实际做项目时很少有人手写优化器,PyTorch 已经内置了torch.optim.Adagrad,用法非常简洁:
import torch import torch.nn as nn model = nn.Linear(500, 2) # 换成你实际的任务 optimizer = torch.optim.Adagrad(model.parameters(), lr=0.1, lr_decay=0, weight_decay=0, initial_accumulator_value=0, eps=1e-10)这里有个 PyTorch 特有的参数:initial_accumulator_value。它控制累积平方和 \(G\) 的初始值,默认是 0。我试过把它设成 \(0.1\) 或 \(1.0\),等于是给所有参数一个“初始的历史梯度记忆”,能在训练初期让学习率不至于太大,减少冷启动阶段的震荡。如果你发现模型一开始 loss 就冲上天,可以试试把这个值调大,比起直接降学习率更稳。
还有一个lr_decay参数,它实现的是 AdaGrad 原论文里的学习率衰减项:随着迭代轮数增加,全局学习率按 \(1 / (1 + lr_decay \cdot t)\) 进行额外衰减。我在做大规模稀疏线性模型时偶尔会用,做深度神经网络时基本不开,因为深度网络本身就在用各种学习率调度器,再加一层只会让调试变得复杂。
PyTorch 里 Adagrad 的更新公式我去源码里核对过,和原论文完全一致,只是把前面的系数 \(\eta / \sqrt{G + \varepsilon}\) 拆成了两个操作来计算,数值结果没有差异。所以如果你想验证自己对公式的理解,直接拿 PyTorch 的实现对比手写版本即可。
6. 优点与“致命伤”:为什么现在很少有人直接用 AdaGrad
6.1 三大优点,依然能打
优点一:免去大量调参。AdaGrad 是历史上第一个让“学习率自适应”成为标配的算法。在凸优化、在线学习(online learning)领域,它把过去对人工调参的依赖大幅降低。哪怕今天,很多在线学习框架仍然在朴素训练流程里使用 AdaGrad 的变体。
优点二:天然对抗稀疏梯度。在 NLP 和推荐系统这种 one-hot 特征横行的场景,AdaGrad 的表现显著优于标准 SGD。低频参数不会被“饿死”,高频参数又不会被“冲昏头”,这是结构性的优势,不是靠调学习率能轻易模仿的。
优点三:实现极其简单。全算法就一行累积、一行更新,几乎没有任何玄学操作。代码排错成本远低于 Adam 或 LAMB,适合作为自定义优化器的起点。
6.2 致命伤:学习率单调衰减到“假死”
AdaGrad 最大的问题,在于 \(G\) 是历史累积的平方和。因为平方和只增不减,分母的 \(\sqrt{G + \varepsilon}\) 会随时间越来越大,导致有效学习率持续单调下降。训练到后期,有效学习率趋近于 0,模型基本丧失了继续学习的能力。
这个现象在深度非凸模型上尤其致命。想象一座连绵起伏的山脉:早期地形陡峭,累积了很多梯度平方;走到后期一片开阔平地,就算你想迈大步赶路,之前的“历史包袱”也会把你的步子死死拴住。这就是后来 RMSProp 和 Adam 要重点解决的问题——它们把“全部历史累积”换成了“滑动平均”,让算法学会“忘记过去”。
6.3 深度学习和稀疏场景的适用边界
那么 AdaGrad 现在还值得用吗?我的判断分两类:
- 凸优化、在线学习、线性模型、LR、FM/FFM 这类稀疏可解释模型里,AdaGrad 仍然非常合适。它的单调衰减在这里不是缺点——在线学习本来就需要学习率随时间降低,以收敛到局部最优。
- 深层神经网络里,AdaGrad 基本退役了,替代者是 Adam 和 AdamW。但“退役”不代表“不懂”——不理解 AdaGrad,你就不知道 Adam 那个滑动平均操作到底改了什么,为什么能工作得更好。所以学习深度学习优化器,我还是建议从 AdaGrad 开始,它性价比极高。
7. 优化器进化史:AdaGrad 如何一步步变成 Adam
7.1 RMSProp:给 AdaGrad 装上“短时记忆”
AdaGrad 的单调衰减问题,被 Hinton 在 2012 年课堂讲义中提出的 RMSProp 解决了。方案说起来极简单:把“累积全部梯度平方”改成“累积梯度平方的指数滑动平均(EMA)”。
\[E[g^2]t = \beta \cdot E[g^2]{t-1} + (1 - \beta) \cdot g_t^2\]
\[\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{E[g^2]_t + \varepsilon}} \cdot g_t\]
这里的 \(\beta\) 通常取 0.9,意味着当前估计大约只用了过去 10 个时间步的有效梯度信息。这样就“忘掉”了远古的大梯度,后期遇到平坦区域时,学习率能重新变大,继续跑得动。RMSProp 对非平稳目标特别友好,RNN 训练一度非常流行。
7.2 Adam:动量 + 自适应学习率的集大成者
Adam(Kingma & Ba, 2015)在 RMSProp 的基础上加了一个历史梯度的“一阶矩”估计,就是带冲量的自适应学习率。它的两个核心更新是:
\[m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t\]
\[v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2\]
然后对 \(m_t, v_t\) 做偏差校正(bias correction),再更新参数。可以粗糙地理解为:一阶矩 \(m_t\) 负责“动量”,让更新方向更平滑;二阶矩 \(v_t\) 负责“自适应学习率”,继承自 AdaGrad 的家底。Adam 几乎是当今深度学习的默认选择,但它的“根”就在 AdaGrad 上,所以说 AdaGrad 是自适应优化家族的基石毫不夸张。
7.3 实战选择建议
结合我的经验,不同任务选优化器可以参考这张表:
| 场景 | 优先选择 | 原因 |
|---|---|---|
| 稀疏高维线性模型、CTR 预估 | AdaGrad / FTRL | 自适应该设置,收敛快,可解释性强 |
| 深层视觉模型(CNN) | AdamW / SGD + Momentum | 需要精细学习率调度,AdamW 泛化优于原生 Adam |
| 大规模预训练模型 | AdamW / LAMB | 大 batch 下需要 Layer-wise 自适应,LAMB 是 Adam 的扩展 |
| 强化学习、GAN 训练 | Adam | 非平稳目标下稳定,梯度尺度剧烈变化时自适应能力突出 |
8. 常见坑位与调试记录:我和 AdaGrad 的爱恨情仇
8.1 坑位一:学习率设置过大,直接发散
有次我把 AdaGrad 初始学习率设成和 SGD 一样的 0.1,在某个稀疏特征上跑了不到 100 轮就爆了。原因在于稀疏特征对应的参数初始 \(G\) 很小,甚至为 0,导致有效学习率一步到位放大到 0.1 / 1e-4 ≈ 1000,直接起飞。解决办法是要么把 \(\varepsilon\) 调大一点,要么用initial_accumulator_value给 \(G\) 一个起始缓冲,要么学习率降到 0.01 量级。
8.2 坑位二:训练后期模型完全“学不动”
如果你用 AdaGrad 训练深度模型,到几万步之后发现 loss 纹丝不动,不用怀疑算法写错——大概率就是累积平方和太大了。这时候两个选择:换 RMSProp 或 Adam;或者干脆降低训练轮数,在 AdaGrad“还没死透”之前提前结束训练。后者在在线学习场景反而是合理的,因为流式数据本来就不追求一步到位。
8.3 坑位三:把 epsilon 加到根号外面
我在开源社区见过不下三个项目把更新写成w - lr / (sqrt(G) + eps) * grad。数学上它也有意义,但工程上几乎等于白设 epsilon,因为 G 小到一定程度后浮点下溢出,照样除出无穷大。正确的写法永远是w - lr / sqrt(G + eps) * grad。这是所有手写优化器里最好踩、也最容易忽略的细节。
8.4 关于热搜词里那些“智能优化算法”的提醒
写这篇文章时看到不少搜索词把我带到了“智能优化算法”“粒子群优化”“多目标优化”“物流配送路径”这些方向,我必须说清楚:这些属于进化计算/元启发式算法,和 AdaGrad 这类梯度优化算法完全不是一个体系。粒子群、遗传算法解决的是“优化目标函数连梯度都算不了”的问题,AdaGrad 服务的是“有梯度可算”的统计模型。两者虽然都叫“优化”,但应用场景差着十万八千里。别看我文章标题里有“优化算法”就串台,AdaGrad 是梯度家族的直系血脉。
8.5 排错顺序建议
如果训练结果不对劲,我建议按这个顺序排查:
- 先确认学习率量级。AdaGrad 直接套 0.001 通常太小,试试 0.1 到 1.0 之间。
- 再确认 epsilon。训练初期频繁出现 NaN,试试把 eps 从 1e-8 改到 1e-6。
- 然后确认
initial_accumulator_value。冷启动发散,可以设成 0.1 到 1.0。 - 最后确认是否用了正确的更新公式,尤其是根号里有没有 epsilon。
9. 最后再分享一点个人体会
AdaGrad 现在很少被直接用在深度模型里,但它在我的工具箱里一直没有退役。每次遇到稀疏特征、在线学习、或者需要快速验证一个优化思路时,我第一个想到的还是它。原因很简单:它足够简单,简单到我能一眼看穿它在干什么;又足够有效,在它适合的领域里表现不输任何后来者。
如果你正在学优化器,我的建议是手写一遍 AdaGrad,连 RMSProp 和 Adam 也顺手写了。只用框架自带优化器的人,很难真正理解学习率调度背后的直觉。而一旦体会过“给每个参数分配独立学习率”的精妙之处,后面看任何自适应优化论文都会容易很多。毕竟,梯度告诉你方向,而好的优化器告诉你每一步该迈多大——AdaGrad 是让我第一次深刻理解这句话的算法。