☰
Adam优化器深度解析:原理、调参与选型实战
2026/10/27 14:40:16 网站建设 项目流程

只要做过几天深度学习,你一定对这样一行代码再熟悉不过:

optimizer = optim.adam(model_params, lr=cur_lr)

它是 PyTorch 教程里出镜率最高的语句之一,也是无数论文复现实验的起点。说 Adam 是整个深度学习生态里最“大”的默认项,一点不过分——从图像分类到语言模型,从 GAN 到强化学习,到处都能看到它的身影。但“大”和“美”从来是两回事:这些年关于“Adam 是不是被高估了”“自适应学习率到底有没有让泛化变差”的争论,从来没停过。这篇东西我就围绕 optimizer 这个话题,把 Adam 的来龙去脉、数学机制、实际训练中的坑、以及选型和调参经验一次讲透。适合正在复现论文、训练自己的模型、或者在优化器选型上纠结的读者。看完你至少能回答一个问题:下一个项目里,我到底该不该继续写optim.adam(...)。

1. 为什么满世界都是 optim.adam:从 SGD 到自适应学习率的演化逻辑

1.1 在 Adam 之前:被学习率折磨的日子

很多人现在学深度学习,一上来就用 Adam,根本不知道在它出现之前,训练一个模型有多折腾。

经典的 SGD 更新极其简单:θ -= lr * g,就是沿着梯度方向走一步。但这个简单更新有个致命问题:梯度在不同维度上的尺度差异可能非常夸张。拿一个稀疏特征举例,一个出现频率很高的特征,它的梯度可能非常稳定;而一个只在很少样本里出现的特征,梯度要么是 0,要么是很大的值。用同一个学习率去更新这两个维度,结果一定是一边没吃饱、一边撑得慌。

更麻烦的是,损失函数的“地形”在不同方向上是弯曲程度不同的——有的方向陡峭,有的方向平缓。学习率太小,陡峭方向走得慢;学习率太大,又在峡谷两侧反复横跳。所以经典 SGD 时代,调 lr 是训练里最核心也最痛苦的工作,要靠经验、靠肉眼观察 loss 曲线,甚至靠玄学。Momentum(动量)的出现解决了方向漂移和局部震荡的问题,但它本质上是把历史梯度“平均”了一下,仍然没有解决每个参数该用多大步长的问题。

1.2 自适应学习率的三次关键演进

真正让“每个参数拥有独立学习率”这个想法落地的,是三个里程碑式的工作。

第一个是 AdaGrad。它统计每个参数历史梯度的平方和,更新的时候用它去缩放学习率。历史梯度大的方向,学习率自动变小;历史梯度小的方向,学习率相对变大。这个思路在当时很惊艳,但缺陷同样明显:平方和一直累加不衰减,导致学习率单调下降,训练到中后期几乎就学不动了。

第二个是 RMSProp。它把 AdaGrad 的“累加全部历史平方梯度”改成“滑动平均”,用指数加权移动平均来估计二阶动量。这样学习率不会一路衰减到 0,能持续保持自适应能力。RMSProp 已经相当能打了,但它没有引入动量,方向上的抖动仍然存在。

第三个就是 Adam。它把 RMSProp 的二阶动量估计和 Momentum 的一阶动量估计合到了一起,同时做了偏差校正。也就是说,Adam 既知道“历史梯度的平均方向”,也知道“每个方向上梯度的剧烈程度”,然后两者结合,给每个参数算出一个既平滑又自适应大小的更新量。

1.3 Adam 的三个“省心”点,决定了它的统治地位

Adam 能成为事实默认,靠的不是什么高深理论,而是三个非常实际的优点。

第一,它对学习率的敏感度大幅降低。因为每个维度的更新量都被二阶动量做了归一化,lr 从 1e-3 变成 3e-3,通常不会导致训练直接爆炸,这在 SGD 时代是不可想象的。

第二,收敛速度确实快。尤其对于 NLP、Transformer 这类模型,Adam 在前期和中期能把 loss 压得非常快,省下的训练时间非常可观——对做实验的人来说,时间就是命。

第三,默认超参数很能打。Kingma 和 Ba 在论文里给出的默认值betas=(0.9, 0.999)、eps=1e-8,配合lr=1e-3,在大多数任务上都能跑出一个不算差的结果。这个“开箱即用的鲁棒性”在工业界太重要了,大家自然愿意选它。

所以你去看今天的代码,Adam 只花了一行就取代了当年调参工程师大半个工作日的工作量。说它是“大”优化器,名副其实。

2. Adam 的核心机制拆解:动量、二阶动量与偏差校正到底在做什么

2.1 先建立一个直觉:下山的两种策略

理解 Adam 最好用的方法,是想象你在雾天里下山。你看不到全貌,只能靠脚底的坡度判断方向。

经典 SGD 像是一个只会看脚下的人:哪里最陡就往哪里迈一步,步子大小固定。遇到一个坑,他可能一头扎进去出不来了。

Momentum 像是给这个人加了“惯性”:如果前面一段路一直在向下,他会保持一个下山的方向,遇到小坑也能借着惯性冲出去。这就是一阶动量——记录“历史梯度的指数平均”,抑制方向抖动。

RMSProp 的思路完全不同——它像是一个能感受“地形平缓程度”的人。走在一个坡度变化剧烈的乱石坡上时,他每步都小心地迈小步;走在平缓的草地上时,他敢大步流星。这就是二阶动量——记录“历史梯度平方的指数平均”,估计每个方向上地形的陡峭程度。

Adam 就是给一个同时拥有“惯性”和“地形感知”的人。它用一阶动量定方向,用二阶动量定步长。

2.2 公式逐项拆解

Adam 的完整更新过程可以分成三部分。先把一阶动量服务好:

m_t = β1 * m_{t-1} + (1 - β1) * g_t

这就是 Momentum 的指数滑动平均。β1 一般取 0.9,表示当前梯度在历史动量里只占 10% 的权重,大部分来自历史方向。

然后估计二阶动量:

v_t = β2 * v_{t-1} + (1 - β2) * g_t^2

β2 一般取 0.999,滑动窗口更长,对梯度尺度的估计更稳定。它对元素级梯度做了平方,所以能反映每个维度上梯度振幅的大小。

接着是偏差校正,这一步很多讲解会跳过,但它恰恰是 Adam 初始阶段不“翻车”的关键:

m_hat_t = m_t / (1 - β1^t) v_hat_t = v_t / (1 - β2^t)

为什么要校正?因为 m 和 v 的初始值都是 0。在第一步的时候,m_1 = 0.9 * 0 + 0.1 * g_1 = 0.1 * g_1,比真实梯度小了 10 倍;v_1 = 0.001 * g_1^2,更是小得离谱。如果不做校正,第一步更新就会被一个严重偏小的二阶动量放大成极大的步长,训练直接就炸了。1 - β^t这个分母在 t 很小时远小于 1,乘上去相当于把偏小的估计拉回真实量级。随着 t 增大,分母逐渐趋近于 1,校正的影响就慢慢消失了。

最后一步合体更新:

θ_t = θ_{t-1} - lr * m_hat_t / (sqrt(v_hat_t) + ε)

分子是平滑后的方向,分母是梯度的均方根。结果就是:梯度历史平均很大的维度,步长被压小;梯度历史平均很小的维度,步长被放大。ε 的作用是防止分母为 0,一般取 1e-8。

2.3 从公式看行为:为什么它对 lr 不敏感

从这个更新式你能直观看到一个特性:更新量的量级主要由m_hat / sqrt(v_hat)决定,而不是由梯度本身的绝对大小决定。这个比值本质上是一个无量纲化的东西,大致反映了“梯度的信噪比”——如果一阶动量比二阶动量的平方根大,说明梯度方向一致,可以放心多走一点;如果两者差不多,说明梯度方向很不稳定,要走谨慎些。

这也是为什么 Adam 对学习率的敏感度远低于 SGD:它已经自动把每个维度的梯度尺度归一化到了大致相同的范围。你只需要给定一个全局步长,剩下的尺度问题让适配机制去解决。但同样因为这种归一化,Adam 步长的缩小只能靠外部的学习率调度,它自己不会像 SGD 那样自动衰减——这一点是理解它所有“坏毛病”的起点,后面讲的很多坑都源于此。

3. 美丽的玫瑰有刺:Adam 在实际训练里的四个大坑

3.1 泛化差与 Sharp Minima 之争

Adam 在训练集上收敛快,但很多人发现它在验证集上的表现不如 SGD——尤其在图像分类这样的大任务上,差距可以相当明显。

这个问题的主流解释和“Sharp Minima / Flat Minima”有关。SGD 因为梯度噪声大,倾向于在损失曲面里“逛”到比较平坦的极小值区域;而平坦极小值的泛化性通常更好,因为参数的微小扰动不会让 loss 剧烈变化。Adam 因为做了梯度归一化,收敛过程更“顺滑”,反而容易陷进尖锐的极小值——在训练集上是极小值,但换到验证集,一个扰动就崩了。

不过这几年也有人在为 Adam 翻案:如果给 Adam 配一个足够长的训练时间和精心设计的学习率调度,它和 SGD 之间的泛化差距其实会大幅缩小。也就是说,差距可能不完全是优化器的问题,而是“默认配置下 Adam 只训了很短时间就停下了”的问题。但这个争论侧面说明了一件事:Adam 不是免费的午餐,它把泛化风险藏在了“快”的背后。

3.2 收敛后期的震荡:它的步长不会自己缩小

我在第二章节末尾特意提了一句:Adam 的步长由信噪比决定,梯度尺度本身不直接参与。这就带来一个现象——训练后期,当模型已经接近最优解时,梯度虽然整体变小了,但每个维度上的m_hat / sqrt(v_hat)比值不一定跟着变小。噪杂的小梯度,只要方向一致,依旧会产生不小的更新量,于是 loss 在最优值附近来回震荡,迟迟不收敛。

我见过不少新手拿着 Adam 默认参数一口气训了几百个 epoch,loss 从中期开始就一直在小范围抖动,他们以为是模型容量或数据问题,实际上只是学习率压根没衰减。

解决办法不是没有,核心思路是给外部学习率加调度。StepLR 可以应付简单任务,但更推荐用 CosineAnnealingLR:它让学习率在训练周期内按照余弦曲线平滑地从初始值下降到接近 0,训练后期步长自然变小,loss 就稳稳地落在最优区域里。

3.3 对 Weight Decay 的处理不正确:AdamW 为什么更好

这是 Adam 最隐蔽的一个缺陷,也是 AdamW 出现的原因。

经典 SGD 里,L2 正则化(在 loss 上加权重的平方和)和 weight decay(每一步把权重乘一个小于 1 的系数)是等价的。但在 Adam 里,这个等价性被打破了。因为 Adam 的更新会先算梯度,再把 L2 正则产生的梯度放到二阶动量里做归一化——一个大权重方向的 L2 梯度会被自适应缩放,正则在“惩罚大权重”这件事上的力度就不稳定了。

Ilya Loshchilov 和 Frank Hutter 在 2017 年提出 AdamW,主张把 weight decay 从梯度的计算里解耦出来:权重更新的时候直接减去一个和梯度无关的固定比例项。这样一个大权重不会被二阶动量的缩放“保护”住,正则化效果更稳定,实际训练里泛化也更好。

现在训练 Transformer 类模型,AdamW 基本是绝对主流,默认配置已经很少有人在用原版 Adam 了。从 PyTorch 代码的角度说,就是下面两行的区别:

optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01) # 而不是 optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=0.01)

3.4 混合精度训练下的数值地狱:eps 太小会出大事

最后一个坑来自数值计算。Adam 默认eps=1e-8,这个值在 fp32 精度下没什么问题,但一旦切到混合精度训练(fp16/bf16),问题就来了。

混合精度下梯度会被缩放,梯度平方后数值可能小到超出 fp16 的表示范围直接变成 0。如果二阶动量 v 下溢为 0,更新公式里的分母就是sqrt(0) + epsilon,整个更新就变成一个巨大的值——通常表现为 loss 突然变成 NaN 或者模型权重爆炸。

我自己在训练一个中小型 BERT 类模型时就踩过这个坑,排查到最后,不是数据问题、不是学习率问题,就是eps太小。把eps从1e-8调到1e-6之后,训练立刻稳了下来。这个经验后来成了我的固定配置:只要开了 AMP,Adam 家族优化器的eps我都至少放到1e-6。

4. 优化器选型对比:SGD、Adam、AdamW、LAMB 各自的主场

4.1 一张表看明白主流优化器的差异

优化器核心机制典型场景lr 敏感性显存开销
SGD + Momentum固定步长 + 动量方向CV 大规模分类、需要极致泛化高,需要精心调度低,只多一份动量
Adam一阶动量 + 二阶动量 + 偏差校正NLP、GAN、RL、快速迭代验证低,默认 1e-3 可跑高,多两份状态
AdamWAdam 的解耦 weight decayTransformer、预训练语言模型低高
LAMBLayer-wise 自适应大 batch 训练超大规模预训练、万级 batch中高
AdaFactor矩阵分解近似二阶动量显存受限的 Transformer中极低

显存开销这一列可以展开说一下。Adam 需要为每个参数维护 m 和 v 两份状态,如果模型有 1 亿参数,光优化器状态就用掉 2 乘以 1 亿乘以 4 字节,等于额外的 800 MB,这个开销对很多实验环境来说并不小。SGD 只有一份动量,AdaFactor 通过低秩分解把状态压缩到接近 O(n) 量级,这两者的显存优势在超大模型上非常明显。

4.2 我的选型决策流程

把项目拿来,我通常会按下面这套流程决定用哪个优化器:

如果任务在 CV 领域、数据集干净、训练周期充裕,而且我非常在意最终泛化精度,那我会选 SGD + Momentum,配上 CosineAnnealing 和合适的长训练周期。这个组合在 ImageNet 这类任务上至今仍是性能标杆之一。

如果任务是 NLP、Transformer、GAN、扩散模型或者强化学习,选 AdamW。它收敛快、对超参数宽容,同时在 weight decay 上比原版 Adam 更合理。很多开源代码里写的“Adam”实际也是指 AdamW,这一点看具体实现。

如果模型大到了单卡显存被优化器状态吃干净、连一个 batch 都塞不进去,我会考虑 AdaFactor。它能省下一大截显存,代价是收敛速度稍慢、行为不如 Adam 稳定,需要多花点精力调。

如果是在超大 batch size(例如 4096 甚至更大)下做预训练,LAMB 的价值就体现出来了。它对每一层单独计算学习率的缩放,能在超大 batch 下保持稳定的更新量,让大规模并行真正“吃满”。

4.3 几个容易被忽略的判断依据

除了任务类型,还有两个细节会影响选型。

第一个是梯度稀疏度。如果模型里有大量 embeddings 或者稀疏特征,Adam 系列利用二阶动量对稀疏维度自动放大的能力是压倒性的优势,这时不要犹豫。

第二个是对训练中断的容忍度。SGD 的训练曲线通常更平滑,中途停了拿到的 checkpoint 还能继续用;Adam 前期快、中期容易出现看起来像“过拟合”的抖动,其实只是调度问题。如果你需要的是一个“挂上去就可以不管”的训练任务,Adam 系配合 warmup + 衰减调度,反而是更省心的选择,因为它的 lr 一不小心设大了也不至于像 SGD 那样直接 NAN。

5. 我的实战配置手册:让 Adam 系列优化器发挥出真正实力

5.1 学习率区间与线性缩放规则

拿到一个新任务,我从来不会直接沿用“经典论文里的 lr”。我的习惯是先在固定步数内做一个小规模 lr 扫描:在[1e-4, 3e-4, 1e-3, 3e-3]四个值里各跑几百步,看训练 loss 的下降斜率。loss 下降太慢,说明 lr 偏小;loss 震荡上升,说明 lr 偏大;总有一两个值能给你一个平稳下降的曲线。

有一个值得时刻记住的规则是:当你把 batch size 翻倍时,lr 也应该相应翻倍。这里的直觉是——batch size 翻倍后,每个 step 的梯度噪声更小,方向估计更准,理应有信心走出更大的步子。但注意,lr 放大之后,warmup 的步数也要跟着拉长,否则初始阶段大 lr 加二阶动量未稳定,很容易直接起飞。

5.2 Betas、eps、weight decay 的具体调节建议

betas参数大多数情况下用默认的(0.9, 0.999)就足够。不过有一个值得掌握的技巧:如果你想在长训练后半段更“细致地”微调,可以把β2从 0.999 减小到 0.995 或 0.99。β2 决定二阶动量对历史梯度的记忆长度,调小它意味着把历史窗口缩短,让优化器更快地对最近的梯度尺度变化作出反应。转录类模型的训练中,我发现β2=0.995配合 cosine 调度,能稍微改善收敛速度。反过来,在梯度信噪比很低的 GAN 训练场景,把β2调大到 0.9999 可以让训练更稳。

eps的经验值是:fp32 下用默认1e-8没什么问题;但做了混合精度,或者使用 bf16,建议直接改成1e-6或1e-7。你的模型规模很大、梯度分布很窄时,即使 fp32,也可以把 eps 调到1e-6,代价是几乎可以忽略的精度损失,却能显著提升数值稳定性。

weight_decay在 AdamW 里的合理区间大约是[0.001, 0.1]。我一般从小模型直接上 0.01,大模型如果遇到 loss 不平滑则往下调到 0.001。我的经验是:权重衰减调大了模型会欠拟合,调小了过拟合部分会很严重,0.01 对大多数 Transformer 结构来说是个不错的中位数起点。

5.3 如何判断优化器工作正常:两个关键监控指标

很多人只看 loss 曲线,但优化器的运行状态有几个更深层的信号更值得看。

第一个是梯度范数grad_norm。训练前期它应该保持在一个不算小的量级并缓缓下降;如果它快速崩到 0,说明梯度消失;如果它突然冲高,说明 lr 太大或数据里有异常样本——不要等到 loss 变成 NaN 才反应过来。

第二个是更新范数与参数范数的比值。从训练过程里取一步更新量update = optimizer.param_groups[0]['lr'] * m_hat / (sqrt(v_hat) + eps),算它的范数,再和对应参数范数比较。一个经验法则是:这个比值应该稳定在1e-3到1e-2之间。如果远超这个范围,说明更新量太大,参数在剧烈跳动;如果远小于这个范围,说明优化器没有有效推进,lr 可能太小或者梯度出了问题。

这两个指标怎么实际接入 PyTorch?可以在每一步反向传播后加上:

total_grad_norm = 0.0 for p in model.parameters(): if p.grad is not None: total_grad_norm += p.grad.detach().norm().item() ** 2 total_grad_norm = total_grad_norm ** 0.5 total_update_norm = 0.0 for p in model.parameters(): if p.grad is not None: step = optimizer.param_groups[0]["lr"] * p.grad / (p.grad.norm() + 1e-8) total_update_norm += step.norm().item() ** 2 total_update_norm = total_update_norm ** 0.5

我推荐把这两行做成一个训练日志的小工具,每个 step 都输出一次。训练中途瞄一眼,比盯着 playing loss 曲线盲猜靠谱得多。

5.4 一个可以直接套用的完整训练配置示例

最后给一个我在中小型 GPT 类模型上常用的完整配置,作为本文的落地参考:

import torch from torch.optim import AdamW from torch.optim.lr_scheduler import OneCycleLR optimizer = AdamW( model.parameters(), lr=2e-4, # 小型数据集建议从 2e-4 起步,大语料可以往上走 betas=(0.9, 0.995), eps=1e-6, # 混合精度训练必须调大 weight_decay=0.01, ) total_steps = len(train_loader) * epochs scheduler = OneCycleLR( optimizer, max_lr=2e-4, total_steps=total_steps, pct_start=0.05, # 前 5% 步数做 warmup anneal_strategy="cos", )

这个配置的意图很明确:AdamW 保证 weight decay 解耦,eps=1e-6保证混合精度下的数值安全,β2=0.995让二阶动量对训练中期变化更敏感,OneCycleLR 先快速上升到峰值再余弦下降到接近 0——前 5% 的 warmup 让二阶动量从零开始有个缓冲期,后半段的余弦衰减省去手动调衰减步数的烦恼。

我自己踩过几次坑之后,现在的默认工作流基本固定成了这样:先确认精度模式和显存预算,再选优化器;用上述的更新范数指标验证 lr 方向;最后在上线大训练任务前跑一个小规模 lr 扫描确认没有“隐藏的爆炸点”。这套流程说不上惊艳,但它让我少了很多陪 NaN 到深夜的日子。

优化器这个东西,从来没有真正“大而美”的银弹。Adam 系列用它的易用性换走了你的调参痛苦,但也把泛化、稳定性和数值安全的账单藏在了一个optim.adam(...)下面。真正靠谱的做法,是每次写这行代码的时候,都多想一层:我的模型结构是什么,我的精度设置是什么,我的训练调度是什么。想清楚这三件事,那个写着optim.adam(model_params, lr=cur_lr)的“大块头”,才真正有可能成为你项目里那个又大又美的存在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询