ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Adam 优化器全解析:动量原理、手写实现与 AdamW 调参避坑

Adam 优化器全解析:动量原理、手写实现与 AdamW 调参避坑 1. 先把 Adam 放回梯度下降的家族谱系里Adam 优化器Adaptive Moment Estimation自适应矩估计是 2014 年由 Kingma 和 Ba 提出的一阶梯度优化算法2015 年在 ICLR 上正式发表。它在深度学习圈的地位有点像螺丝刀在工具箱里的地位不是万能的但拿起来就能用绝大多数情况下不会让你失望。从 CNN 图像分类到 Transformer 大模型预训练从推荐系统的 embedding 层到强化学习的策略网络Adam 及其变体 AdamW 长期霸占默认优化器的位置。这篇内容我打算把 Adam 从理论直觉、公式推导、超参数含义到 numpy 手写实现、与 PyTorch 逐位对照、AdamW 差异、以及实际调参踩坑完整过一遍适合刚接触优化器原理的同学也适合已经天天用optimizer Adam(...)但说不清eps到底在防什么的老手。1.1 SGD 的三个老大难问题最朴素的随机梯度下降做的事情非常直接算出当前 batch 的梯度用统一的学习率乘一下反方向走一步。数学上只有一个超参数要管逻辑上没有任何需要解释的隐藏状态。但真正跑训练的时候这套方法有三个绕不过去的问题。第一个问题是所有参数共用一个学习率。稀疏特征对应的 embedding 行可能几天才被激活一次用同样的学习率更新它要么更新得太少学不动要么更新太猛直接发散。文本任务和推荐任务里这个问题特别尖锐因为词表动辄几十万每个词的出现频率能差好几个数量级。第二个问题是梯度尺度差异极大。同一层里某些方向的梯度可能是另外一些方向的上百倍SGD 在这种病态的曲率下会疯狂走 Z 字形收敛速度慢得让人怀疑人生。第三个问题是梯度噪声单个 batch 估出来的方向并不等于真实的下降方向SGD 容易被噪声推离最优路径在鞍点附近来回震荡。传统解法有三个手调学习率衰减、加 Momentum 做梯度平滑、上二阶方法牛顿法、L-BFGS用 Hessian 信息做预条件。前两个治标不治本第三个在参数量上亿时计算代价完全不可接受。Adam 走的是第四条路用历史梯度的一阶矩做方向平滑用二阶矩给每个参数单独缩放步长成本几乎和 SGD 一样但具备了一定的二阶方法的近似能力。1.2 一阶动量与二阶动量的生活化理解动量这个词在优化器里被用得很随意我把两个动量的含义拆开说清楚。一阶动量 $m_t$ 就是梯度的指数移动平均可以理解成一个装了阻尼的滚球。每次更新的方向不是当前梯度而是过去若干步梯度的加权叠加越近的梯度权重越大。这样做的效果是如果最近几步方向大体一致动量会累积起来加速前进如果方向来回晃正负相消实际位移变小等于自动降噪。这和 SGDMomentum 的思路一样区别只在于 Adam 把它和后面的自适应缩放绑在了一起。二阶动量 $v_t$ 是梯度平方的指数移动平均。它不关心方向只关心每个参数维度上梯度的活跃程度。梯度平方均值大的维度说明这个方向变化剧烈应该把步长收一收梯度平方均值小的维度说明变化平缓可以大胆多走一点。这就是自适应学习率的来源——最终每个参数的等效步长是 $lr \cdot m_t / \sqrt{v_t}$分子是方向分母是尺度两者相除得到的是一个被归一化的更新量。有个很妙的性质值得单独点出来Adam 对梯度的整体缩放不敏感。如果把 loss 乘上一个常数 $c$梯度变成 $cg$一阶动量变成 $cm$二阶动量变成 $c^2v$$cm / \sqrt{c^2 v} m/\sqrt{v}$比值完全不变。这意味着你换一个 loss 的归一化方式比如 batch 大小变了导致 loss 均值变化Adam 的行为几乎不受影响。这是它相对于 SGD 最实际的优势之一也是为什么很多人换了模型结构、换了数据规模之后懒得重新调 lr直接把 Adam 拿来用还能跑得动。提示这个缩放不变性有个副作用——你没法通过调整 loss 的量级来间接影响 Adam 的学习率。想让 Adam 走快点只能调lr本身别指望改 loss 权重有用。2. 公式逐项拆解每一步在干什么Adam 的完整伪代码只有 5 行循环体但每一行都有明确的设计意图。我按论文原始记号写一遍再逐项解释。t ← t 1 g_t ← ∇_θ f_t(θ_{t-1}) # 当前 batch 的梯度 m_t ← β1 · m_{t-1} (1 - β1) · g_t # 一阶矩 v_t ← β2 · v_{t-1} (1 - β2) · g_t² # 二阶矩 m̂_t ← m_t / (1 - β1^t) # 一阶矩偏差校正 v̂_t ← v_t / (1 - β2^t) # 二阶矩偏差校正 θ_t ← θ_{t-1} - lr · m̂_t / (√v̂_t ε)看下来会发现真正需要动脑子的只有两件事指数移动平均的加权系数怎么来的以及凭什么要除那个 $(1-\beta^t)$。2.1 指数移动平均的权重分布把 $m_t$ 递推式展开能看清楚它到底在加权哪些历史梯度。假设 $m_0 0$$$m_t (1-\beta_1)\sum_{i1}^{t}\beta_1^{t-i}g_i$$第 $i$ 步梯度前面的系数是 $(1-\beta_1)\beta_1^{t-i}$这是一个几何衰减序列。当 $\beta_1 0.9$ 时当前梯度的权重是 0.1前一步是 0.09前三步是 0.0729十步之前的权重只剩 0.0349。所有系数加起来是 $1-\beta_1^t$不是 1这就是偏差的来源。有效窗口长度大概可以估成 $1/(1-\beta_1)$。$\beta_1 0.9$ 对应大约 10 步$\beta_2 0.999$ 对应大约 1000 步。这也是 Adam 默认值的经验来源一阶矩需要跟得紧一些10 步的窗口二阶矩需要看得远一些1000 步因为方差估计需要更多样本才稳定。注意$v_t$ 估计的是梯度平方的期望也就是这个方向的能量有多大。窗口太短它会跟着最近几步的噪声一起抖学习率就会乱跳窗口太长遇到真实的曲率变化又跟不上。β20.999 这个值在图像和文本任务上验证了十来年改动之前最好有明确的实验理由。2.2 偏差校正是怎么回事如果梯度序列是平稳的$g_i$ 的期望处处等于 $E[g]$那么上面那个展开式的期望是$$E[m_t] E[g]\cdot(1-\beta_1)\sum_{i1}^{t}\beta_1^{t-i} E[g]\cdot(1-\beta_1^t)$$也就是说$m_t$ 系统性地小于真实的梯度期望缩水比例恰好是 $(1-\beta_1^t)$。训练早期 $t$ 很小的时候缩水最严重$t 1$ 时 $m_1 (1-\beta_1)g_1 0.1g_1$只有真实值的十分之一。如果不修正前几十步的更新量会小得可怜模型起步迟缓。除以 $(1-\beta_1^t)$ 之后$m_t$ 就是 $E[g]$ 的无偏估计了。同理$v_t$ 的期望是 $E[g^2]\cdot(1-\beta_2^t)$除以 $(1-\beta_2^t)$ 得到 $E[g^2]$ 的无偏估计。这里顺便说明一个常见误解偏差校正的作用在后期会自然消失因为 $\beta_2^t$ 随 $t$ 增长指数趋近 0$1-\beta_2^t$ 趋近 1除以 1 等于没除。很多人以为这个校正在整个训练过程中都在起作用其实 $\beta_2 0.999$ 时$t 10000$ 步之后 $\beta_2^t \approx 4.5\times10^{-5}$已经完全可以忽略。那为什么后面还要一直算这个除法因为它是免费的一次幂运算加一次除法相对于整个反向传播可以忽略不计留着比写个 if 判断干净。2.3 手动算三步把直觉变成数字光看公式容易飘我们拿 $\beta_1 0.9$、$\beta_2 0.999$、$\varepsilon 10^{-8}$ 手动走三步观察更新量的变化。假设某个参数维度上的梯度依次是 $g_1 0.1$、$g_2 0.2$、$g_3 0.05$。步数 t$g_t$$m_t$$\hat m_t$$v_t$$\hat v_t$$\sqrt{\hat v_t}$$\hat m/\sqrt{\hat v}$10.10.010.11.0e-50.010.11.00020.20.0290.152634.999e-50.025010.158140.96530.050.03110.114765.244e-50.017500.132280.868第一行值得仔细看。$\hat m_1 0.1$ 正好等于 $g_1$$\sqrt{\hat v_1} \sqrt{0.01} 0.1$ 也正好等于 $|g_1|$两者相除等于 1。也就是说第一步的更新量恰好等于 lr方向等于梯度的符号方向。这是偏差校正和二阶归一化联合作用的结果跟梯度本身的大小完全无关。再看第二行梯度从 0.1 跳到 0.2翻了一倍但更新系数只有 0.965比第一步还略小。为什么因为二阶动量 $v$ 已经被这次大梯度拉起来了分母变大抵消了分子的增长。第三行梯度回落到 0.05只有第一步的一半但更新系数仍有 0.868。这就是自适应的本质更新幅度被限制在 lr 附近的一个窄区间里波动梯度大不会走得太猛梯度小也不会走得太慢。这个性质带来的直接体验是Adam 在训练早期非常稳loss 曲线下降平滑几乎不会出现 SGD 那种前几步直接爆掉的情况。代价是它对真实曲率的响应也被钝化了接近最优点时不容易自己收敛得很精细。3. 超参数逐个过一遍Adam 总共四个超参数lr、betas、eps、还有 AdamW 多出来的weight_decay。前三个的默认值十年没变过但这不代表默认值总是对的。3.1 学习率唯一必须认真调的那个lr是 Adam 里唯一需要认真搜索的超参数默认值 1e-3 是论文在 MNIST 和 CIFAR 上试出来的经验值放到今天的大模型上明显偏大。现在 Transformer 预训练普遍用 1e-4 到 3e-4配合 warmup微调阶段常用 1e-5 到 2e-5小规模全连接网络和 GAN 生成器会用 1e-4 甚至 2e-4。判断 lr 是否合适的信号很直观如果 loss 在前几百步内出现剧烈抖动或者直接 NaN说明太大如果 loss 单调下降但下降速度肉眼可见地慢而且训练后期还有明显下降空间说明太小。有一点跟 SGD 不一样Adam 的 lr 上限比较软。因为每次更新被 $\hat m/\sqrt{\hat v}$ 归一化到接近 ±1 的量级实际参数位移大概是 lr 的量级不会因为梯度突然变大而爆掉。所以你在 Adam 上把 lr 从 1e-3 提到 3e-3往往只是让 loss 曲线变粗糙不至于立刻崩。但要注意这在训练早期不成立因为看 2.3 节的推导第一步的位移恰好是 lr如果 lr 设成 0.1 就会一步跳出参数空间。提示如果发现训练 loss 曲线在前期有几次明显的跳变先检查 warmup 有没有开。Transformer 类模型前 1%~5% 的步数做线性 warmup 几乎是标配原因是早期 $v_t$ 样本太少、估计噪声极大直接用大 lr 会走得很乱。3.2 beta1、beta2、eps 的取值逻辑betas的默认值是 (0.9, 0.999)。$\beta_1$ 控制一阶动量的记忆长度调小的效果是更贴近当前梯度、响应更快、噪声更大调大的效果是更平滑但更迟钝。实践中很少动 $\beta_1$因为 0.9 这个值从 1990 年代的动量法沿用至今在各种任务上都表现稳定除非你在做强化学习这种奖励信号极不平稳的场景有人会把它降到 0.5 让策略更新更灵敏。$\beta_2$ 是这四个超参数里最值得关注的一个。0.999 的窗口是 1000 步在 batch size 很大的场景下比如 8K 甚至更大梯度本身噪声很小二阶矩的慢窗口反而让学习率对曲率变化响应太迟这时把 $\beta_2$ 降到 0.95 或 0.98 往往有明显收益。相反在小 batch、高噪声的任务上0.999 甚至 0.9999 的平滑更有利。这个观察在 GPT 系列模型的训练配置里能看到很多大模型训练用的是 $\beta_2 0.95$。eps是最容易被忽略的一个。它的作用是防止分母为零标准默认值是 1e-8。但在混合精度训练里这个值必须重新考虑fp16 的最小正规数大约是 $6\times10^{-5}$$10^{-8}$ 在 fp16 里直接下溢成 0如果二阶动量存在 fp16 中分母会变成 $\sqrt{0}10^{-8}$ 一样的问题除出来的结果毫无意义。所以主流框架在 AMP 模式下会把优化器状态提升到 fp32或者把 eps 提到 1e-4。你在阅读别人代码时看到eps1e-8和eps1e-4并存多半就是这个原因。4. 从零手写 Adamnumpy 实现与 PyTorch 逐位对照看懂公式和能手写出来之间隔着一道沟我把这道沟填掉。先声明这部分代码用途是理解算法不是生产环境替代 PyTorch。生产环境永远优先用框架自带的优化器因为里面有融合算子、状态字典管理、分布式支持这些你不想自己实现的东西。4.1 标量版先看清每次更新用最原始的写法一次只处理一个标量参数把所有中间量都打印出来。这样能直接和 2.3 节的手算结果对上。import math class ScalarAdam: def __init__(self, theta, lr1e-2, beta10.9, beta20.999, eps1e-8): self.theta theta self.lr lr self.beta1 beta1 self.beta2 beta2 self.eps eps self.m 0.0 self.v 0.0 self.t 0 def step(self, grad, verboseFalse): self.t 1 self.m self.beta1 * self.m (1 - self.beta1) * grad self.v self.beta2 * self.v (1 - self.beta2) * grad * grad # 偏差校正注意 t 是从 1 开始的 m_hat self.m / (1 - self.beta1 ** self.t) v_hat self.v / (1 - self.beta2 ** self.t) self.theta - self.lr * m_hat / (math.sqrt(v_hat) self.eps) if verbose: print(ft{self.t} g{grad:.4f} m{self.m:.6f} fmhat{m_hat:.6f} v{self.v:.3e} vhat{v_hat:.6f} fstep_coef{m_hat / math.sqrt(v_hat):.4f}) return self.theta跑一遍 2.3 节的数据opt ScalarAdam(theta0.0, lr0.01) for g in [0.1, 0.2, 0.05]: opt.step(g, verboseTrue)输出应该和表格里的数字一致第一步step_coef1.0第二步约 0.965第三步约 0.868。如果你的输出对不上八成是把t的起始值写成了 0导致第一步算出来是 $1-\beta^0 0$ 除零或者把偏差校正的幂写成了t-1。4.2 向量化版与工程化封装标量版加个循环就能处理向量参数但 Python 层的 for 循环在参数多的时候慢得离谱要改成数组整体运算。同时要把参数和优化器状态分开管理方便后面做状态保存和加载。import numpy as np class Param: 最简参数容器只保留 data 和 grad 两个字段。 def __init__(self, data): self.data np.asarray(data, dtypenp.float64) self.grad np.zeros_like(self.data) class ManualAdam: def __init__(self, params, lr1e-2, betas(0.9, 0.999), eps1e-8): self.params list(params) self.lr lr self.beta1, self.beta2 betas self.eps eps self.t 0 # 每个参数维护独立的 m 和 v形状与参数完全一致 self.m [np.zeros_like(p.data) for p in self.params] self.v [np.zeros_like(p.data) for p in self.params] def zero_grad(self): for p in self.params: p.grad np.zeros_like(p.data) def step(self): self.t 1 # 偏差校正因子提前算好避免在循环里重复做幂运算 bc1 1.0 - self.beta1 ** self.t bc2 1.0 - self.beta2 ** self.t for i, p in enumerate(self.params): g p.grad self.m[i] self.beta1 * self.m[i] (1.0 - self.beta1) * g self.v[i] self.beta2 * self.v[i] (1.0 - self.beta2) * g * g m_hat self.m[i] / bc1 v_hat self.v[i] / bc2 # 注意 eps 放在 sqrt 外面这是论文原版的写法 p.data - self.lr * m_hat / (np.sqrt(v_hat) self.eps)这里有三个细节容易写错我一个一个说。第一偏差校正因子要放在参数循环外面算它是标量跟参数无关放里面是浪费。第二eps 加在 sqrt 外面不是加在 $v$ 里面再开方。这两种写法在数值上有区别PyTorch 的实现也是加在外面。第三$m$ 和 $v$ 必须逐个参数独立维护不能共用一个全局标量否则自适应就退化成全局学习率缩放了这个错误我在新手代码里见过不止一次。注意self.m[i] ...这种写法每次都在创建新数组。参数量大时改成self.m[i][...] ...原地赋值能省掉一半的内存分配开销在 embedding 这种单个参数就有上亿元素的场景下差别非常明显。4.3 与 torch.optim.Adam 对齐验证写完自己的实现最该做的事是拿它和 PyTorch 对一遍。用同样的初始参数、同样的梯度序列、同样的超参数看最终参数差多少。正常应该在 1e-12 到 1e-10 量级如果有 1e-6 以上的偏差说明某一步的细节写错了。import numpy as np import torch np.random.seed(42) D 16 w_init np.random.randn(D) # 构造一段固定的梯度序列保证两边完全一致 grads [np.random.randn(D) for _ in range(50)] # --- numpy 版本 --- p Param(w_init.copy()) opt ManualAdam([p], lr1e-3, betas(0.9, 0.999), eps1e-8) for g in grads: p.grad g opt.step() # --- torch 版本 --- w_t torch.tensor(w_init.copy(), dtypetorch.float64, requires_gradTrue) opt_t torch.optim.Adam([w_t], lr1e-3, betas(0.9, 0.999), eps1e-8, weight_decay0.0, amsgradFalse) for g in grads: opt_t.zero_grad() w_t.grad torch.tensor(g, dtypetorch.float64) opt_t.step() diff np.abs(p.data - w_t.detach().numpy()).max() print(max abs diff:, diff) # 预期量级 1e-15 左右跑出来差异在机器精度范围内就说明你的实现和框架等价。这个验证方法我建议形成习惯任何自己写的优化器或者损失函数先做数值对齐再拿去做实验。不然训练不收敛的时候你根本分不清是算法实现的问题还是模型结构的问题。4.4 完整训练脚本与收敛曲线光验证单步还不够实际训练要把它接到一个真实的反向传播里。下面用最简单的线性回归练手重点是看整个闭环怎么搭。import numpy as np np.random.seed(0) N, D 512, 20 X np.random.randn(N, D) w_true np.random.randn(D) y X w_true 0.1 * np.random.randn(N) w Param(np.zeros(D)) b Param(np.zeros(1)) opt ManualAdam([w, b], lr1e-2) losses [] for t in range(1, 1001): # 前向y_hat X w b pred X w.data b.data loss np.mean((pred - y) ** 2) losses.append(loss) # 反向手推 MSE 的梯度 n X.shape[0] dout 2.0 * (pred - y) / n # 形状 (N,) w.grad X.T dout # 形状 (D,) b.grad np.array([dout.sum()]) # 形状 (1,) opt.step() print(final loss:, losses[-1]) print(weight err:, np.abs(w.data - w_true).max())线性回归是个好起点因为它有解析解 $w^* (X^TX)^{-1}X^Ty$你能直接算出最优 loss 做参照。如果 Adam 跑到最后 loss 比解析解高出一大截说明你的更新写错了如果只差一点点说明没跑够步数或者 lr 偏小。有个观察很有意思这个例子里 Adam 用 lr1e-2 收敛得很好而换成纯 SGD 用同样的 lr 会震荡得厉害。原因还是 2.3 节说的Adam 的等效步长被归一化限制在 lr 附近对特征的尺度差异不敏感。这也是为什么在很多特征量级不统一的表格数据任务上Adam 的开箱即用体验比 SGD 好太多。5. AdamW 到底改了哪一行如果只看过 Adam 的论文第一次接触 AdamW 会懵不是已经有weight_decay参数了吗为什么还要单独搞一个答案藏在正则项的实现位置上。5.1 L2 正则在自适应优化器里为什么会变味经典 L2 正则的做法是在 loss 上加一项 $\frac{\lambda}{2}|\theta|^2$对应到梯度上就是每个参数的梯度多一项 $\lambda\theta$。在 SGD 里这么做效果等价于每次更新时把参数往原点拉一个小量 $\eta\lambda\theta$也就是权重衰减两者严格等价。到 Adam 这里这个等价性被破坏了。因为 $\lambda\theta$ 这一项进入了梯度也就会进入一阶矩 $m$ 和二阶矩 $v$ 的统计。某些参数梯度很大$v$ 很大分母 $\sqrt{v}$ 也大正则项被这个分母除小了实际衰减效果被削弱另一些参数梯度很小$v$ 很小正则项反而被放大。结果是该被惩罚的大参数没被惩罚够不该被惩罚的参数反而被压得很小。这个现象在 AdamW 论文里被明确指出并且给出了实验证据。AdamW 的做法是把权重衰减从梯度里拆出来直接作用在参数更新上$$\theta_t \theta_{t-1} - lr\cdot\left(\frac{\hat m_t}{\sqrt{\hat v_t}\varepsilon} \lambda\theta_{t-1}\right)$$这样权重衰减就完全绕开了一阶矩和二阶矩的统计等效于对每个参数做固定比例的收缩跟 SGD 里的行为一致。用起来的感觉就是AdamW 的 weight decay 是真的在做正则化而 Adam 的 weight decay 更像是一种耦合在自适应学习率里的扰动。5.2 两种写法的代码差异把两者的差异落到代码上就是一行位置的区别。# 方案 AAdam L2 正则等价于 torch.optim.Adam(weight_decaywd) def step_adam_l2(p, state, lr, betas, eps, wd, t): b1, b2 betas m, v state # 关键正则梯度混进原始梯度里 g p.grad wd * p.data m b1 * m (1 - b1) * g v b2 * v (1 - b2) * g * g m_hat m / (1 - b1 ** t) v_hat v / (1 - b2 ** t) p.data - lr * m_hat / (np.sqrt(v_hat) eps) return m, v # 方案 BAdamW解耦权重衰减 def step_adamw(p, state, lr, betas, eps, wd, t): b1, b2 betas m, v state # 关键正则项完全不参与矩的统计 g p.grad m b1 * m (1 - b1) * g v b2 * v (1 - b2) * g * g m_hat m / (1 - b1 ** t) v_hat v / (1 - b2 ** t) # 权重衰减直接作用在参数上等效于 (1 - lr*wd) 的乘性收缩 p.data - lr * (m_hat / (np.sqrt(v_hat) eps) wd * p.data) return m, v方案 B 里那行p.data - lr * (adaptive wd * p.data)展开后包含 $-lr\cdot wd\cdot\theta$ 这一项意思是每步把参数缩小 $lr\cdot wd$ 的比例。当 lr1e-4、wd0.1 时每步收缩万分之一个单位一万步下来收缩一半左右量级是合理的。5.3 该用哪个怎么定权重衰减系数我的建议很直接新项目一律用 AdamW除非你有明确的理由回到 Adam。Transformer 系列模型BERT、GPT、ViT的官方实现清一色用 AdamW推荐系统和多模态模型近几年的配置也在往这个方向靠。差异在中小规模任务上可能看不出来但在大模型长训练周期里权重衰减的行为差异会显著影响泛化性能。权重衰减系数怎么定跟 lr 是强相关的。常见组合是lr1e-3, wd1e-2或者lr1e-4, wd1e-1。可以看到 lr 越小wd 往往越大因为每次收缩的比例是 $lr\cdot wd$两者乘积才是真正起作用的量。有一个经验做法是先把 wd 设为 0 训练一轮拿到基线然后逐步加上 0.01、0.05、0.1观察验证集指标。如果加上去验证集立刻变差说明模型欠拟合不需要正则如果加上去训练集和验证集的差距缩小了说明有效如果加上去训练也变差了说明衰减过头了。提示偏置项和归一化层的参数bias、LayerNorm 的 gamma/beta通常不做权重衰减。这不是玄学是因为这些参数的理想值本来就在 0 附近惩罚它们没有正则效果反而损害表达能力。PyTorch 里需要通过参数分组手动排除很多人的代码直接optimizer AdamW(model.parameters(), ...)其实默认是全都衰减了效果会打折。6. 踩坑实录与排查清单前面都是应该怎么做这一节说实际会出什么岔子。6.1 常见问题速查表现象可能原因排查动作loss 前几十步就 NaNlr 太大 / 没开 warmup / 数据有 inf把 lr 降 10 倍检查输入数据的 min/maxloss 长时间在同一个值附近抖lr 太小 / eps 太大压制了更新打印参数更新量的范数看是否接近 0loss 下降但验证集不降过拟合 / weight decay 太小加大 wd加 dropout检查数据泄漏训练早期 loss 突然跳高又恢复warmup 缺失或 beta2 太大加线性 warmup试 beta20.98混合精度下训练发散优化器状态被存成 fp16确认参数和优化器状态保持 fp32参数完全不更新梯度没回传 / 参数没注册进优化器检查requires_grad和参数分组训练后期 loss 卡住不动lr 没有衰减 / 陷入平坦区加 cosine 或 step 衰减复现结果和论文差很多eps、wd、warmup 细节不一致逐项对齐官方配置别只看 lr6.2 混合精度、大 batch、长尾任务里的三个坑第一个坑是混合精度下的 eps。开了 AMP 之后如果优化器状态被存成 fp16$v_t$ 里的梯度平方很容易下溢。比如某个参数梯度稳定在 1e-3平方是 1e-6fp16 还能表示但如果梯度偶尔掉到 1e-4平方就是 1e-8在 fp16 里直接归零之后 $\sqrt{v}$ 算出来是 0除法变成 $m/\varepsilon$更新量瞬间放大好几个数量级。规避方法很简单确保模型参数和优化器状态都以 fp32 存储PyTorch 的 AMP 在你模型本身是 fp32 时是自动这么做的但如果你的模型一半参数是 fp16麻烦就来了。另外有个稳妥做法是把 eps 提到 1e-4 甚至 1e-3牺牲一点精度换取数值安全。第二个坑是大 batch 下 beta2 的默认值不再合适。当 batch size 从 32 提到 4096梯度估计的噪声降低了两个数量级此时 $\beta_20.999$ 那个 1000 步的窗口就显得过于迟钝。直觉上噪声小了就该用更短的窗口更快响应曲率变化所以大 batch 训练常配 $\beta_20.95$ 或 0.98。这个调整在小 batch 上做了反而有害因为窗口太短会让学习率跟着噪声剧烈波动。第三个坑是长尾分布任务里的学习率失衡。在推荐和搜索场景里热门 item 的 embedding 梯度又大又频繁冷门 item 的梯度稀疏而小。虽然 Adam 的自适应性缓解了这个问题但热门 item 的 $v$ 累积得很快$\sqrt{v}$ 大实际更新量被压小导致热门 item 学得反而比冷门 item 慢热门特征欠拟合。有些团队会用自适应调节的 $\beta_2$或者对不同频次的参数分组设置不同的 lr这类工程技巧在公开论文里很少写但线上效果差别很大。6.3 收敛异常时的排查顺序我自己的排查顺序是这样的从便宜到昂贵从常见到罕见。确认梯度本身正确。用一个固定的小 batch手动算一遍 loss 和梯度跟自动求导的结果对比。梯度错了后面全是白搭。单个 batch 能不能过拟合。把 batch size 调到 2~4看模型能不能把这个小样本的 loss 压到接近 0。压不下去说明模型容量或学习率有问题。打印更新量范数。每步记录lr * m_hat / (sqrt(v_hat) eps)的 L2 范数看它是不是在合理区间。一般在参数范数的 1e-4 到 1e-2 之间比较健康太小说明学不动太大说明要炸。检查 lr 和 warmup 的搭配。warmup 步数太少会让早期更新剧烈太多则浪费训练预算。经验值是总步数的 1% 到 5%。对比一个已知能跑的配置。把超参数全部换成默认值lr1e-3betas(0.9,0.999)eps1e-8无 wd如果这都跑不起来问题一定在数据或模型不在优化器。这个顺序的好处是每一步都能排除一大片可能性不会让你在几十个超参数里瞎试。踩过几次坑之后你会发现所谓优化器调参大部分时间根本不是在调优化器而是在找数据管道和模型实现里的 bug。7. 什么时候不该用 AdamAdam 是默认选项但默认不等于最优。有几个场景我会主动换回 SGD 或者别的方案。7.1 SGD Momentum 仍有优势的场景图像分类是 SGD 的传统地盘。ResNet 在 ImageNet 上的经典配置是 SGD Momentumlr 从 0.1 开始按 step 衰减weight decay 1e-4。这个组合在同等训练预算下最终精度通常比 Adam 高 0.5 到 1 个百分点原因普遍认为是 Adam 的自适应缩放让每个参数的有效步长被归一化反而限制了模型去找那些需要大跨度更新的解。换句话说Adam 收敛快但 SGD 收敛到的解往往泛化更好这个现象在 CIFAR 和 ImageNet 上被反复验证过。另一个场景是生成模型。GAN 的判别器和生成器对更新幅度的敏感度不一样Adam 的统一自适应处理会让两者的平衡难以维持。很多 GAN 训练技巧比如 TTUR让判别器学习率是生成器的两倍其实就是在补救这个问题。还有风格迁移、超分辨率这类对像素级细节敏感的任务SGD 系优化器有时能给出更锐利的结果。7.2 学习率调度与 Adam 的搭配Adam 和 lr 调度的组合有个特点它对调度策略的敏感度低于 SGD。SGD 在 ImageNet 上如果不上 step decay精度会掉好几个点Adam 用恒定 lr 也能跑出差不多的结果只是最后一段收敛得不够精细。几个常见的搭配按使用频率排序cosine 退火是现在最流行的从初始 lr 平滑降到接近 0配合 warmup 使用Transformer 训练基本标配线性衰减在 BERT 之后的很多工作中成为标准step decay在经典 CNN 训练里还在用每隔几十个 epoch 降一次OneCycle是先升后降的单周期策略适合训练预算固定的场景对小数据集特别友好。有个细节值得注意Adam 配合 cosine 衰减时最后的 lr 不要真的降到 0一般降到初始值的 1% 或者一个固定下限比如 1e-6。降到 0 之后参数完全不动了如果恰好停在一个不好的位置就没有挽回余地留一点余量让它继续微调。7.3 后续变体值得关注的点Adam 之后出现了不少改进版本挑几个有实际价值的说一下。AMSGrad解决的是原版 Adam 的一个理论缺陷$v_t$ 在短时间内可能变大也可能变小变小时分母变小学习率反而上升破坏了收敛性保证。AMSGrad 用 $v_t^{\max} \max(v_{t-1}^{\max}, v_t)$ 保证分母单调不减。PyTorch 里通过amsgradTrue开启实测在部分任务上更稳代价是多存一份 $v$ 的最大值。RAdam针对训练早期的方差问题用一个整流项动态调整偏差校正的强度理论上不需要 warmup 就能稳定训练。实际用下来它确实对 warmup 的依赖降低了但在已经调好 warmup 的配置下效果和 AdamW 差别不大。Lion是 2023 年谷歌提出的用符号函数代替除法做更新只保留一阶动量内存占用减少一半。在大规模训练上有不错的效率优势但超参数对 lr 和 wd 的敏感度比 AdamW 高迁移时需要重新搜。Adafactor的做法是不要完整的 $v$ 矩阵而是用两个低秩向量做近似把优化器状态从 $O(d^2)$ 降到 $O(d)$。在显存吃紧的大模型训练里非常有用T5 就是用它训出来的。代价是收敛行为比 AdamW 稍微难预测一点。这些变体的定位不是谁取代谁而是在什么约束下选什么。显存紧张看 Adafactor 或 Lion训练不稳定看 AMSGrad 或 RAdam精度优先且有充足预算就看 SGD 调得好不好。最后分享一个我在做实验时的习惯任何优化器的更换都要先固定其他所有变量跑一次完整对照包括随机种子、数据顺序、学习率调度、训练步数。我见过太多次换了 AdamW 精度涨了 0.3 个点的结论最后发现是因为换优化器的时候顺手把随机种子也改了。优化器的效果往往比种子带来的波动还小不做严格对照你得到的结论可能只是巧合。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进