做深度学习这几年,我几乎每个项目都要面对同一个选择题:优化器用哪个?早期我随手就是SGD,后来换成Adam,再后来几乎所有新项目都默认AdamW。这个从SGD到AdamW的演进,不是简单换一个API接口那么简单,背后是优化理论、训练稳定性、泛化性能的一整套认知升级。这篇文章我想把这条线完整讲清楚:每个优化器解决了什么问题、又引入了什么新代价、为什么AdamW能成为当前很多任务的事实标准,以及在实际项目里怎么选、怎么调、怎么避坑。适合正在入门深度学习、被各种优化器名字绕晕的同学,也适合已经用框架跑了一两年模型、想弄清楚"我到底在用什么"的工程师。
1. 优化器到底在做什么:先把"梯度下降"这件事聊透
1.1 损失函数是一座山,参数是你在山上的位置
很多人一开始接触优化器,只记住了一句话:"沿着梯度的反方向更新参数。"没错,这句话是对的,但它只回答了"方向"这一个问题。真正训练一个模型,优化器要做的事情远不止这些:学习率怎么变、历史梯度怎么用、每个参数是不是该一视同仁、正则项以什么形式参与更新……这些小决策累积起来,直接决定你的模型是三天三夜不收敛,还是早上跑上就能顺利出结果。
我习惯用一个类比:损失函数就是一片凹凸不平的山地,模型参数就是你在山坡上的坐标,梯度告诉你哪边是下坡。优化器就是你下山的策略。
SGD是最原始的策略:看见坡就往低处走一步。它管用,但很笨。山谷里来回震荡、平地上磨磨蹭蹭、遇到鞍点原地转圈,都是家常便饭。后来大家给这个"下山的人"加了惯性(Momentum)、加了"视野"(NAG)、加了"不同地形不同步幅"(AdaGrad/RMSProp),最后Adam干脆把几套装备全部穿上,成了很长一段时间里最省心的选择。而AdamW,则是把其中一套装备"修正确"了。
1.2 为什么"只沿梯度走"远远不够
只沿梯度走,有三个问题绕不开。
第一,梯度是局部的。你站在山坡上,只能感知脚下这一小片区域是陡是缓,看不到整座山的走势。在平坦区域,梯度几乎为零,SGD会走得非常慢,相当于你在平地上用蚂蚁步挪。第二,梯度是有噪声的。小批量采样带来的梯度波动,会让参数在最优解附近来回抖动,既不精确又浪费时间。第三,不同参数对损失的影响尺度差别很大。有些参数梯度大、该小步走,有些参数梯度小、该大步走,如果所有参数共用同一个学习率,总有人被照顾过头或者压根没被照顾到。
这些痛点是逐个被解决的。我们按时间线看一遍,你会发现AdamW不是凭空冒出来的,它前面每一步都是铺垫。
2. 经典优化器演进:从SGD到Adam的每一步
2.1 SGD:最朴素的起点
随机梯度下降,公式极其简单:
θ_t+1 = θ_t - η * g_t
其中g_t是当前小批量算出来的梯度,η是学习率。深究起来,SGD这个名字在深度学习里通常还包含"每次只用一小批数据算梯度"这层含义,所以它天然有随机性,也正是这个随机性让模型有机会跳出尖锐的局部极小点。
SGD的优点是好实现、鲁棒、对学习率的要求相对宽容,很多研究论文里的基线模型至今还在用它。缺点也明显:收敛慢、容易落入局部最优、在病态曲率(也就是损失函数等高线是狭长椭圆)附近会来回震荡。我记得早期训练图像分类模型,SGD动辄要跑一二百个epoch才见成效,换到现在很多人根本等不起。
2.2 Momentum与NAG:给下山装上惯性
Momentum的思路一句话说完:让更新方向不只取决于当前梯度,还叠加历史速度。
v_t = γ * v_t-1 + η * g_t θ_t+1 = θ_t - v_t
这个v_t就是"动量",γ通常取0.9。你可以把它想象成球滚下山:球不只看当前坡度,还带着之前积累的速度。好处有两个,一是越过局部小坑和噪声扰动,二是加速收敛。在实际调参中,SGD+Momentum是长期以来的"黄金组合",尤其是配合余弦退火学习率,在图像分类任务上效果稳定到令人放心。
NAG(Nesterov Accelerated Gradient)是在Momentum上做的小改进:先按当前速度"探一步",再在探到的地方算梯度。相当于球不是闷头滚,而是先跳起来看一眼前面的坡再落下去。它的更新公式稍复杂,但思路就是"看得更远一点"。工程上NAG用得不如Momentum广,但它的思想为后来更多"前瞻性"方法开了路。
2.3 AdaGrad与RMSProp:自适应学习率的探索
SGD系优化器所有参数共用同一个学习率,这个设定在多参数神经网络里其实很粗鲁。AdaGrad的贡献就是打破了这个局面:每个参数拥有自己的学习率,并且学习率会随着累计梯度平方和增大而自动缩小。
r_t = r_t-1 + g_t² θ_t+1 = θ_t - η / (√r_t + ε) * g_t
这样一来,经常出现大梯度的参数会快速降低步长,不常出现的参数则保持较大步长。但AdaGrad有个硬伤:r_t持续累加,学习率衰减得太快,跑到后面基本学不动了。RMSProp的改进是把"累加"换成"指数滑动平均",让历史梯度的作用随步数指数衰减:
r_t = ρ * r_t-1 + (1-ρ) * g_t²
这样就避免了单调衰减问题。RMSProp在RNN和很多非凸问题上表现优秀,它给后续优化器留下了一个重要部件:二阶矩估计。
2.4 Adam:把Momentum和RMSProp结合
Adam全称Adaptive Moment Estimation,核心是把Momentum的一阶矩和RMSProp的二阶矩合并起来:
m_t = β1 * m_t-1 + (1-β1) * g_t v_t = β2 * v_t-1 + (1-β2) * g_t² m_hat_t = m_t / (1-β1^t) v_hat_t = v_t / (1-β2^t) θ_t+1 = θ_t - η * m_hat_t / (√v_hat_t + ε)
β1、β2默认分别是0.9和0.999。m_hat和v_hat是偏差矫正项,解决训练初期m、v从零开始导致估计偏小的问题。
Adam的最大功劳是"省心"。它对学习率的敏感度比SGD低很多,一套默认参数在很多任务上都能跑出像样的结果。这使得它在NLP、GAN、强化学习等领域的普及率极高。但是Adam也有一个长期被吐槽的问题:泛化性能通常不如SGD+Momentum。很多实验都观察到,Adam训练出来的模型在训练集上收敛更快,测试集效果却常常差一口气。这个现象背后的原因,直到AdamW出现才被讲清楚。
3. AdamW:为什么说它是一次"拨乱反正"
3.1 Adam的隐藏问题:泛化差距是怎么来的
Adam收敛快但泛化差,一度被归咎于"自适应方法本身的缺陷"。当时很多人给出的经验法则是:图像任务用SGD+Momentum,文本任务用Adam。这个经验法则流传很广,但并没能解释为什么。后来原论文作者在AdamW那篇工作里指出了另一个具体嫌疑:Adam早期实现里用的权重衰减方式,跟L2正则化混为一谈了。
在PyTorch等框架里,torch.optim.Adam的weight_decay参数名很容易让人以为它就是权重衰减,但实现上它走的是L2正则化的路子——把衰减项加进梯度里。问题就出在这里。
3.2 权重衰减和L2正则其实是两回事
数学上看,两者好像只是同一个操作的不同写法。L2正则是在损失函数后面加一项 λ/2 * ‖θ‖²,求导之后梯度变成 g_t + λ * θ_t,更新公式为:
θ_t+1 = θ_t - η * (g_t + λ * θ_t)
而权重衰减是在参数更新后单独减去一小块:θ_t+1 = θ_t - η * g_t - η * λ * θ_t。
对SGD而言,把λ重新缩放一下,两者完全等价。很多人(包括早期框架)就默认了"L2正则等于权重衰减"。但对Adam这类自适应优化器,这种默认是错的。
为什么?因为Adam的更新规则里,梯度g_t会被√v_t归一化。你加到梯度里的λ * θ_t,同样会先被归一化再更新。而v_t是历史梯度平方的滑动平均,不同参数的v_t差异很大,导致实际衰减强度完全不一致:某些参数被衰减得多,某些被衰减得少。换句话说,L2正则项在Adam里被"搅浑"了,失去了稳定控制权重规模的作用。
3.3 AdamW的更新规则与解耦思想
AdamW的修正非常直接:把权重衰减从梯度里抽出来,不经过m_hat和v_hat的归一化,直接施加在参数上:
m_t = β1 * m_t-1 + (1-β1) * g_t v_t = β2 * v_t-1 + (1-β2) * g_t² m_hat_t = m_t / (1-β1^t) v_hat_t = v_t / (1-β2^t) θ_t+1 = θ_t - η * (m_hat_t / (√v_hat_t + ε) + λ * θ_t)
注意最后的λ * θ_t这一项,它在归一化之外,所以对每个参数,单位学习率下衰减比例恒定。这就是"解耦"——自适应学习率只管梯度方向,权重衰减独立完成正则化任务。从实现角度看只是挪了一个项的位置,但效果和原理都完全不同。
AdamW对超参数也更友好。因为衰减强度不再被v_t扭曲,你设置的λ基本能按照预期生效,调参时直觉更准。实验普遍表明,AdamW配合合适的调度器,能明显缩小甚至反超SGD+Momentum在泛化上的差距,同时保留Adam收敛快的优点。
3.4 为什么解耦之后效果更好
我用一句话总结:解耦让正则化回归regularization的本意。
当权重衰减独立于梯度归一化之外时,它就像一个恒定的"质量监控器",不管梯度多大、v_t多大、当前训练到第几步,它都稳定地让参数向零收缩一点。这种稳定性在长训练周期尤其重要——大模型动辄训练几十万步,如果衰减强度随v_t波动,最终权重分布很容易乱掉。
另外AdamW还能配合更大的weight_decay值。有些实验里,AdamW把weight_decay调到0.05甚至0.1,效果明显优于Adam在0.01附近的表现。原因也很好理解:过去Adam的weight_decay被归一化"稀释"了,要调到能感知的强度往往又过头;解耦之后,强度调节从"盲人摸象"变成了"指哪打哪"。
4. 实际选型与参数调优:不只看精度
4.1 不同任务场景的优化器选择
先给一张我在实际项目里常用的选择表,这是踩了无数坑之后沉淀出来的经验,不是教科书上的标准答案:
| 场景 | 首选优化器 | 备选 | 理由 |
|---|---|---|---|
| 图像分类(CNN迁移学习) | SGD + Momentum | AdamW | SGD配合余弦退火泛化极稳,迁移学习尤其好用 |
| 图像分割/检测 | AdamW | SGD + Momentum | 模型大、训练慢,AdamW能省时间且精度不掉 |
| 自然语言处理(Transformer系) | AdamW | Adam | 预训练和微调几乎默认AdamW |
| GAN训练 | Adam/AdamW | RMSProp | 判别器和生成器都需要稳定快速更新,自适应学习率是关键 |
| 强化学习 | Adam | AdamW | 奖励分布变化大,Adam调试最省心 |
| 大规模分布式预训练 | AdamW + 多种调度策略 | AdaFactor(超大模型省显存版) | 大模型训得久,权重衰减独立稳定是关键 |
你会发现AdamW的适用面非常广。我的习惯是:除非已经有充分的对比实验证明SGD系更好,否则新项目一律先用AdamW跑基线,再去考虑其他选择。
4.2 学习率和关键超参数怎么设
AdamW的默认超参数在多数任务是:lr=1e-3(或者小模型用1e-4),β1=0.9,β2=0.999,eps=1e-8,weight_decay=0.01。
但默认值只是起点,我整理几个实际调高收益的点:
- 学习率:Transformer类任务通常从5e-5到3e-4之间试;图像任务从1e-4到3e-3之间试。学习率比模型结构更敏感,一次改大10倍往往直接发散。
- weight_decay:从0.01起步,如果模型过拟合严重,可以逐步增大到0.05、0.1。注意AdamW里这个值和L2强度是线性对应的,不是玄学。
- β2:对长训练任务,β2=0.95甚至0.9有时比0.999更好,因为二阶矩估计更"新鲜",但代价是训练初期更不稳定。大规模预训练时有人用0.95配合长warmup,效果不错。
- eps:默认1e-8一般够用,混合精度下我偶尔会调大到1e-6或1e-7,防止数值不稳定。
4.3 配合调度器:warmup与余弦退火
优化器只是发动机,调度器是油门曲线。我再强调一次:在很多任务里,调度器对最终效果的影响不比换优化器小。
Warmup的意义在于,训练初期参数远离最优解,自适应优化器对梯度的方差估计不可靠。先用小学习率跑几千步,让m和v的统计量稳定下来,再切换到目标学习率,能明显减少早期震荡。Transformer类模型基本是warmup的忠实用户,比如前2000步线性升到目标学习率。
余弦退火则是另一种思路:逐步降低学习率到接近零,让参数在后期足够精细地逼近最优解。我在图像分类任务上的经验是,SGD+Momentum+余弦退火经常比固定学习率高出好几个点。AdamW同样能受益,搭配warmup+余弦退火几乎成了目前大模型训练的标准配置。
5. 从零实现一个AdamW
5.1 核心代码与逐步讲解
手写优化器不是为了造轮子,而是为了彻底理解更新机制。我用Python和PyTorch风格写一个最小实现,足够你用在一个简单模型上:
import torch class AdamW: def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01): self.params = list(params) self.lr = lr self.beta1, self.beta2 = betas self.eps = eps self.weight_decay = weight_decay self.m = [torch.zeros_like(p) for p in self.params] self.v = [torch.zeros_like(p) for p in self.params] self.t = 0 def step(self): self.t += 1 for i, p in enumerate(self.params): if p.grad is None: continue grad = p.grad.data # 第一步:更新一阶矩和二阶矩估计 self.m[i].mul_(self.beta1).add_(grad, alpha=1 - self.beta1) self.v[i].mul_(self.beta2).addcmul_(grad, grad, value=1 - self.beta2) # 第二步:偏差矫正 m_hat = self.m[i] / (1 - self.beta1 ** self.t) v_hat = self.v[i] / (1 - self.beta2 ** self.t) # 第三步:解耦权重衰减 p.data.mul_(1 - self.lr * self.weight_decay) # 第四步:自适应学习率更新 update = m_hat / (v_hat.sqrt() + self.eps) p.data.add_(update, alpha=-self.lr)有几个细节值得说明:mul_是原地乘法,add_是原地加法,addcmul_可以同时完成v_t = β2 * v_t-1 + (1-β2) * grad²。权重衰减放在更新前,参数被均匀收缩,这就是"解耦"的落地点。注意我没有做Tensor.copy_之类的操作,因为这里直接用原地操作更新参数data,避免破坏autograd的图结构。
实际训练里,你几乎不会用这个自己写的优化器,因为成熟框架里的实现做了大量工程优化(融合kernel、梯度裁剪、L2处理等)。但如果你能做到"看公式就知道框架里每一步在做什么",调参时的直觉会完全不一样。
5.2 验证实现正确性的方法
写完一个优化器,先用debug模式验证数值别直接开训练。
方法很简单:构造一个极小网络,固定随机种子,分别用你的实现和框架自带的优化器各跑几步,比较每一步的梯度、参数差值。数值完全一致才是对的,差一点点(比如浮点误差)也能接受,差异太大说明公式或者实现有问题。
一个常见坑是正则项位置。如果你把weight_decay加到梯度里再套用自适应归一化,那就变成了"L2正则版Adam",而不是AdamW。虽然很少人在手写时犯这个错,但不少框架文档把概念混在一起讲,导致很多人以为自己用的是AdamW,看更新公式才发现其实是L2。
5.3 手写优化器的常见坑
第一个坑是状态初始化。m和v一定要在创建优化器时初始化为零张量,并且对应参数的分型和设备保持一致。漏了某层参数,梯度更新时会直接报错或者静默缺失更新。
第二个坑是参数分组。框架允许你给不同层设置不同超参数(比如主干网络小学习率、新加的分类头大学习率)。手写实现时如果没做参数分组,这个常见需求就无法支持。换到框架层面,我建议用param_groups管理不同模块的learning rate,这在迁移学习里几乎是必备操作。
第三个坑是混合精度下的精度控制。FP16下梯度很小,v_t的数值范围可能溢出。框架会在内部把优化器状态保持在FP32,一个张量copy到FP16,一个保持FP32,这样精度损失才可控。手写实现里你至少要知道这个机制,否则在混合精度训练下会出现"优化器学不动"的灵异事件。
6. 避坑经验与问答实录
6.1 模型不收敛,先查这些
训练loss居高不下或者震荡,别急着换优化器,先按下面这个优先级排查:
- 数值问题:输入有没有归一化,loss是不是NaN,学习率是不是大得离谱。
- 梯度问题:梯度裁剪设没设?很多RNN和Transformer项目不设梯度裁剪直接爆炸。
- 优化器状态:AdamW的warmup做了吗?没有warmup在训练初期特别容易飞。
- 调度器:学习率是不是一上来就很大?很多框架默认调度器会把你的lr设置覆盖掉。
- 最后才是换优化器:如果以上都没问题,再对比SGD/Momentum和AdamW各自的表现。
老实说,我遇到的大部分不收敛案例,问题出在输入归一化和学习率上,优化器本身往往很无辜。所以定位问题要系统,不要一上来就"换一个优化器试试",那样很可能把真正的问题掩盖掉。
6.2 混合精度训练下的优化器注意点
混合精度训练现在几乎是标配。它带来的问题主要是优化器状态精度:FP32的m和v如果被错误地转换到FP16,数值会衰减得很厉害,模型几乎停止学习。
正确做法是:梯度用FP16计算,但优化器参数(m、v)始终用FP32保存。PyTorch AMP会自动把优化器状态留在FP32,但如果你用了自定义优化器或者分布式封装,一定要检查参数是不是被pass到FP32。另外一个常见问题是loss scaling:梯度很小的时候,Adam的二阶矩估计会非常小,eps可能需要相应调整。我在FP16训练时习惯把eps从1e-8调到1e-6,实测更稳。
6.3 关于随机种子与梯度裁剪的细节
优化器本身与随机种子无关,但种子影响参数初始化和数据shuffle,会直接影响你复现实验结果。任何对比实验,建议先固定所有种子,包括Python、NumPy、PyTorch的随机种子,甚至设置torch.set_deterministic(True)保证CUDA层面的确定性,否则你比较SGD和AdamW的差异时,可能把随机噪声当成性能差异。
梯度裁剪的值,我一般从max_norm=1.0开始试。对Transformer类任务,1.0很常见;对GAN,0.1到1.0之间都可以。裁剪不是越大越好,如果每次都被裁剪到上限,说明学习率可能偏大,先降学习率再分析。
6.4 我个人的实操体会
从SGD到AdamW这条演进线,我花了很长一段时间才算真正消化。前几年我总觉得优化器只是"换个名字的API调用",跑不出来就换一个碰运气。后来在几个需要精细调优的项目里,我不得不逐个分析每个超参数的敏感度,才意识到优化器是整个训练过程的"底层操作系统",调度器、衰减、自适应机制全都在这一层互相耦合。
现在我个人的惯性做法是:新项目默认AdamW,配合一个合理的warmup和余弦退火;如果项目有成熟的SGD+Momentum经验(尤其是图像分类),我会认真对比两者,而不是迷信"新方法一定更好"。AdamW不是银弹,它也会在某些任务上比SGD差,但它把优化器的行为解释得更干净、可预测性更强,这在调试大型模型时价值极高。
最后再说一个容易被忽略的小技巧:无论你用什么优化器,都在项目里保留完整的实验日志,包括每个超参数、调度器曲线、每轮loss和验证指标。调优化器本质上是在高维空间里做搜索,没有日志你连自己试过什么都记不住,所谓的手感就永远建立不起来。