
1. 从“调学习率调到怀疑人生”说起做机器学习这几年优化算法算是我反复折腾的一块硬骨头。早期训练模型的时候最烦的就是学习率这个超参数设小了模型半天不收敛loss 曲线跟心电图一样来回抖设大了训练直接发散loss 变成 NaN白跑好几个小时。后来换了随机梯度下降加动量的组合情况好了一些但碰到稀疏特征、非平稳目标这类问题时依然很头疼。直到我系统研究了 AdaGrad才算把“自适应学习率”这件事彻底想明白了。AdaGradAdaptive 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_{t1,i} \theta_{t,i} - \eta \cdot g_{t,i}\]其中 \(\eta\) 是全局学习率。AdaGrad 的更新则是\[G_{t,i} G_{t-1,i} g_{t,i}^2\]\[\theta_{t1,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_samples1000, n_features500, sparsity0.02, seed0): 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, lr0.05): return w - lr * grad def sgd_momentum(w, grad, v, lr0.03, momentum0.9): v momentum * v - lr * grad return w v, v def adagrad(w, grad, G, lr0.5, eps1e-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, iterations2000, lr0.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, lrlr) elif opt_name momentum: w, v sgd_momentum(w, grad, v, lrlr) else: w, G adagrad(w, grad, G, lrlr) 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(), lr0.1, lr_decay0, weight_decay0, initial_accumulator_value0, eps1e-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. 优点与“致命伤”为什么现在很少有人直接用 AdaGrad6.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 如何一步步变成 Adam7.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_{t1} \theta_t - \frac{\eta}{\sqrt{E[g^2]_t \varepsilon}} \cdot g_t\]这里的 \(\beta\) 通常取 0.9意味着当前估计大约只用了过去 10 个时间步的有效梯度信息。这样就“忘掉”了远古的大梯度后期遇到平坦区域时学习率能重新变大继续跑得动。RMSProp 对非平稳目标特别友好RNN 训练一度非常流行。7.2 Adam动量 自适应学习率的集大成者AdamKingma 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自适应该设置收敛快可解释性强深层视觉模型CNNAdamW / 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 是让我第一次深刻理解这句话的算法。