ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度学习优化器选型与调优实战:从AdamW到学习率策略

深度学习优化器选型与调优实战:从AdamW到学习率策略

1. 项目缘起:为什么我要专门折腾一遍优化器选型

Model-Optimizer这个项目名听起来挺唬人,翻译过来其实就是在深度学习训练里做优化器选型和调优的一整套方案。我之所以把它当成一个正经项目而不是随便换个优化器跑跑,是因为之前真被坑惨过:同一套代码、同一个网络结构,在A任务上20个epoch就能收敛,换到B任务之后验证集损失一直上下乱跳,把数据预处理和模型结构都复查了一遍也没找到问题,最后抱着试试看的心态从Adam换成了SGD+Momentum,结果五轮之内loss曲线就老实了。

那段时间我翻了不少资料,发现关于优化器的讨论有个很典型的现象:大家要么说"无脑用Adam",要么说"调好了SGD最强",但很少有人讲清楚为什么。这次项目我把常见优化器在图像分类、文本分类和生成式任务上各跑了一遍,记录了收敛速度、峰值显存、最终指标和调参过程中的各种意外,沉淀成了下面这套选型逻辑。本文面向的读者是:已经跑通过基本训练流程、想让训练更稳更快的中级炼丹师,以及想搞清楚优化器背后原理、而不是只会调包的同学。

需要提前说明的是,文章里的结论来自我手头的任务规模和计算资源,单卡或双卡、参数量在千万到亿这个量级的场景,跟大规模分布式预训练的情况会有差异。但选型的思考路径是通用的,这也是我觉得比具体参数更值得分享的部分。

2. 先弄懂优化器的内在逻辑,再谈选择

2.1 梯度下降家族的进化路线

所有优化器本质上都在做同一件事:根据损失函数对参数的梯度,决定下一步往哪个方向走、走多远。最简单的SGD更新公式是:

param = param - lr * grad

这里的学习率就是步长。它的问题也很直观:整个更新过程只依赖当前这一时刻的梯度,对梯度方向的估计方差很大,容易在陡峭区域来回震荡。于是有了Momentum,也就是动量项,维护一个梯度指数的滑动平均,让参数更新方向更多继承历史趋势,而不是被某一步的噪声带着跑。动量系数通常取0.9,代码里对应SGD的momentum参数。

再往后出现的是自适应学习率方法。AdaGrad把每个参数的历史梯度平方累积起来,学习率除以这个累积值的平方根,让更新频繁的参数自然放慢脚步。但AdaGrad有个致命弱点:累积值是只增不减的,训练到后期学习率会被压得极小,几乎学不动。RMSProp做了改进,把累积改成指数滑动平均,这样学习率不会单调递减到零。

Adam本质上是Momentum和RMSProp的合体:一阶动量负责平滑梯度方向,二阶动量负责调节每个参数的学习节奏。它收敛快、对学习率不敏感,这也是大多数新手第一选择的原因。但"对学习率不敏感"这句话是有代价的,后面我会专门展开。

2.2 Adam的"放大镜效应"与权重衰减的本质

Adam的更新公式写成大白话就是:

m = beta1 * m + (1 - beta1) * grad # 梯度均值 v = beta2 * v + (1 - beta2) * grad^2 # 梯度平方均值 update = m / (sqrt(v) + epsilon) param = param - lr * update

beta1通常取0.9,beta2取0.999,epsilon取1e-8。这里有个值得注意的地方:update的值其实是对梯度方向做了一次"归一化",它的量级大约在正负几之间,跟原始梯度的绝对大小关系不大。换句话说,Adam给每个参数戴了一副放大镜,梯度小的方向被放大,梯度大的方向被缩小,让训练在崎岖的损失地形上也能稳步前进。

但放大镜也有副作用。当某个参数的梯度长期非常小时,v也会很小,此时update会被放大到很大的值,相当于在几乎平的区域突然迈一大步,可能直接跳出好区域。这就引出了权重衰减的必要性:给参数本身一个向零收缩的力,避免参数值飘得太远。传统上我们习惯用L2正则实现,也就是在loss里加上lambda * ||param||^2,求梯度时多出一项2 * lambda * param。

2.3 AdamW为什么能顺手解决正则耦合问题

上面提到的L2正则方式在SGD里一切正常,但在Adam里有个隐患。Adam的更新幅度是经过二阶动量归一化的,L2正则项带来的梯度分量同样会被归一化处理。当某个参数的v比较小时,正则项的存在感会被放大;当v很大时,正则项几乎不起作用。这就导致正则效果与优化器内部状态耦合在一起,正则强度变得不可控。

AdamW做的改动是把权重衰减从梯度计算里拆出来,直接在对参数做更新时单独减去一个衰减项:

param = param - lr * update - lr * weight_decay * param

这样权重衰减不受二阶动量影响,是真正的"解耦"。实践中AdamW在绝大多数任务上的泛化能力都优于传统Adam+L2,特别是在视觉Transformer和预训练语言模型微调场景里,差距非常明显。所以我的默认配置从很久以前就换成了AdamW,而不是裸Adam。

3. 主流优化器横向对比:我在三个任务上的实测结果

3.1 测试设置说明

为了不让结论停留在感觉层面,我设计了三个差异足够大的任务:

  • 任务A:CIFAR-10图像分类,ResNet-18,batch size 128,训练100个epoch
  • 任务B:中文评论情感二分类,BERT-mini微调,batch size 32,训练10个epoch
  • 任务C:自编码器重构任务,输入是高维稀疏特征,属于典型的非凸优化问题

对比的优化器包括:SGD+Momentum、Adam、AdamW、LAMB、Adafactor、Lion。学习率都按照各优化器的常见默认值附近用网格搜索选了一组最优的,其他超参保持默认。比如Adam系选lr=1e-3,SGD选lr=0.05加上momentum=0.9,Lion选lr=3e-4这样的量级。

3.2 结果总览

优化器任务A收敛epoch(达标精度)任务B最终F1任务C最终重构Loss峰值显存(任务B)备注
SGD+Momentum220.7781.620.98GB前10个epoch较慢,后期稳
Adam110.7831.331.12GB前期快但最终精度略逊
AdamW100.7911.301.12GB综合表现最均衡
LAMB120.7891.351.28GB大batch下有优势,小batch不明显
Adafactor150.7811.520.86GB显存最省,但收敛慢一些
Lion90.7921.281.12GB收敛最快,对学习率更敏感

值得说明的是,"收敛epoch"是我按"达到目标精度的最早epoch"统计的,不是只看loss降低速度。这个区别很关键:有些优化器前期loss掉得飞快,但到后期泛化精度上不去;有些则前期慢吞吞,过了某个拐点之后一路稳涨。

3.3 针对不同场景的解读

从结果里能读出三个实用的结论。

第一,AdamW在中小规模任务上确实是最省心的选择,无论是收敛速度还是最终指标都排在前列。它的适用范围非常广,我甚至建议团队里的新人统一用AdamW作为起步配置,等训练稳定之后再考虑要不要换。

第二,SGD+Momentum并没有被时代淘汰,它的优势在于最终泛化能力经常超过Adam系,前提是训练时间足够长、学习率调度得当。我在任务A里把SGD的训练拉长到150个epoch后,准确率反超了AdamW约0.6个百分点。所以如果你的算力充裕、训练周期不敏感,SGD老将仍有一战之力。

第三,LAMB和Adafactor在单卡小batch场景下并没有显示出明显优势。LAMB是为大规模并行训练设计的,它把每个layer的梯度范数做了归一化,让不同层的学习步长更均衡;小batch下这种做法的收益有限。Adafactor省显存是真的,但收敛慢也是真的,它的二阶矩估计做了近似分解,精度有所牺牲。如果你训练长序列模型被显存卡脖子,可以考虑它;否则还是以AdamW为准。

4. 调参不只是调学习率:配套策略才是大头

4.1 warmup与学习率退火的正确组合方式

优化器选对了,训练就顺一大半,但配套的调度策略同样能决定成败。我见过很多同学只调初始学习率,对warmup和退火完全不上心,结果模型表现起伏很大。

warmup的做法是让学习率从0或一个很小的值线性增长到目标值,通常持续到总步数的5%~10%。它的作用有两个:一是避免训练早期参数还没稳定时,较大学习率直接把模型冲到陡峭的坏区域;二是对基于自适应学习率的优化器来说,warmup能让一阶动量、二阶动量先初始化得比较合理,避免早期v值太小导致更新步子过大。

退火策略里我最常用的是余弦退火。它的特点是前中期保持较高学习率加速探索,后期逐步降到接近0,让参数收敛到局部最优点。测算下来,在100个epoch的任务里,固定学习率跑到60个epoch之后再手动降学习率,最终精度通常比全程固定学习率高出1~2个百分点;而从头到尾用余弦退火,又能比"手动降两次"再高出0.3~0.5个百分点。

实现上可以直接组合PyTorch的调度器。另外我习惯先把warmup封装成一个自定义调度器,跟CosineAnnealingLR串起来用:

import math import torch from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR, CosineAnnealingLR class WarmupCosineScheduler: def __init__(self, optimizer, warmup_steps, total_steps, min_lr_ratio=0.01): self.optimizer = optimizer self.warmup_steps = warmup_steps self.total_steps = total_steps self.min_lr_ratio = min_lr_ratio self.base_lrs = [group["lr"] for group in optimizer.param_groups] def get_lr(self, step): if step < self.warmup_steps: ratio = (step + 1) / (self.warmup_steps + 1) return [base * ratio for base in self.base_lrs] progress = (step - self.warmup_steps) / max(1, self.total_steps - self.warmup_steps) ratio = 0.5 * (1 + math.cos(math.pi * progress)) ratio = self.min_lr_ratio + (1 - self.min_lr_ratio) * ratio return [base * ratio for base in self.base_lrs] def step(self, step): lrs = self.get_lr(step) for param_group, lr in zip(self.optimizer.param_groups, lrs): param_group["lr"] = lr

用的时候只需要在每次迭代里传入当前step即可。这个调度器在视觉和NLP任务上我都验证过,稳定性很好,而且代码量也不大。

4.2 梯度裁剪与梯度累积在不同任务下的取舍

梯度裁剪的原理很简单:如果梯度的整体范数超过阈值,就按比例缩放回阈值内,防止梯度爆炸。但它不是每个任务都需要的。我的经验是:在NLP任务特别是Transformer类模型里,梯度裁剪是必选项,clip_value取1.0或5.0效果都不错;在CNN图像分类任务里,正常初始化下梯度很少爆炸,加梯度裁剪反而可能拖慢收敛。

实现时注意裁剪的是梯度范数,不是学习率。在PyTorch里:

from torch.nn.utils import clip_grad_norm_ clip_grad_norm_(model.parameters(), max_norm=1.0)

要放在backward之后、optimizer.step之前。如果你同时用了混合精度,裁剪要基于缩放后的梯度还是未缩放的梯度,这个问题也经常有人弄混,我后面单独讲。

梯度累积是在显存不足时增大有效batch size的手段。它做的是:连续多个step只backward不step,积累足够梯度后再一次性更新。这里有个很关键的细节:梯度累积之后,Adam等自适应优化器内部维护的动量状态不会自动除以累积步数,所以有效的batch size变大了,但学习率是否需要跟着调,取决于你的模型和任务。我的经验是:如果从原生batch size 32换到累积4步等效batch size 128,学习率可以先不动跑几十个step看loss趋势,有需要再微调,不要一上来就线性放大学习率。大幅线性缩放只适合配合warmup的早段,中后期容易直接炸掉。

4.3 混合精度训练对优化器的影响

混合精度训练目前基本是标配了,PyTorch的torch.cuda.amp(新版是torch.amp)用起来也不复杂。但优化器相关的坑不少,而且都比较隐蔽。

最典型的坑是epsilon。Adam系的epsilon默认是1e-8,但在FP16精度下,数值表示范围有限,1e-8的epsilon几乎不顶用。实践中的做法是调大到1e-6甚至1e-4,能显著提升混合精度训练的稳定性。我实测下来,FP16下把epsilon改成1e-4之后,之前偶发的loss尖峰基本消失。

另一个坑是梯度裁剪与Loss Scaling的配合。AMP下梯度是经过缩放因子scale过的,直观看梯度范数会偏大。如果直接对缩放后的梯度做clip,阈值判断是失真的。PyTorch推荐的做法是先把scale关掉再clip:使用GradScaler时,正确的顺序是scaler.unscale_(optimizer)之后再做clip_grad_norm_,最后scaler.step(optimizer)。我见过不少人在原地改了一行clip_grad_norm_,训练时不时NAN,就是这个原因。

from torch.cuda.amp import GradScaler scaler = GradScaler() # 训练循环内 scaler.scale(loss).backward() # 注意:要么先unscale再clip,要么直接用scaler的unscale方法 scaler.unscale_(optimizer) clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update()

这样处理之后,梯度裁剪的判断基准才是未缩放的真实梯度范数。

5. 踩坑清单:那些让loss曲线原地爆炸的细节

5.1 权重衰减不是越大越好

AdamW的weight_decay默认值是0.01还是0.05,取决于你用的框架版本。很多人看别人设置了0.05就照抄,但这个问题真的不能一刀切。在小模型或小数据集上,weight_decay过大容易让模型欠拟合,loss曲线会表现出一种很奇怪的现象:训练loss能降,但验证集指标上不去,而且模型权重普遍偏小。

我自己的快速排查方法是看模型权重的L2范数。如果训练中期模型权重的均方根在0.01以下,说明衰减过强,建议降到原来的1/10再试。以BERT-mini微调为例,0.01的weight_decay是安全的起点,0.05在短训练轮次里性能会明显下降。反之在ImageNet级别的大规模视觉任务上,0.05甚至更高的weight_decay反而能提升泛化。

5.2 epsilon太小:一个被忽视的隐性杀手

我遇到过一次很诡异的故障:用Adam训练生成模型,loss在3000步时突然出现周期性的尖峰,每隔几步就跳高一个量级,然后又回落。排查了很久,最后发现是epsilon默认值1e-8在FP16下太接近精度下限,导致二阶动量v中某些极小值被截断成0,update变成除以0附近的数,参数瞬间被推出稳定区域。把epsilon改成1e-6之后,尖峰彻底消失。

顺便说一句,如果你用的是Adafactor,它内部会自动处理二阶矩的数值稳定性,所以这个问题在Adafactor上不明显。这也是显存不够时换用Adafactor的另一个好处。

5.3 分组参数:正则和衰减的精细化控制

很多人不知道优化器的param_groups可以按不同规则分组。我常用的做法是:把embedding层和head分开设置不同的weight_decay。embedding矩阵通常比较稀疏,衰减太强会把低频词的向量压扁;head层则相反,容易过拟合,衰减强一点更安全。

实现方式很简单:

no_decay = ["bias", "LayerNorm.bias", "LayerNorm.weight"] optimizer_grouped_parameters = [ { "params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], "weight_decay": 0.05, }, { "params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], "weight_decay": 0.0, }, ] optimizer = AdamW(optimizer_grouped_parameters, lr=2e-5)

模型里的bias和LayerNorm参数通常不需要weight_decay,因为它们对尺度不敏感,强行衰减反而限制表达能力。这个做法在Hugging Face的官方微调脚本里也是标准配置,足以说明它的普适性。

6. 把选型沉淀成一套可复用的工具代码

6.1 优化器工厂函数

上面这些经验如果停留在笔记层面,每次换项目都要重新踩一遍坑,效率太低。这次项目尾声,我把Model-Optimizer整理成了一个小工具包,核心是一个工厂函数,根据任务类型和环境自动返回配置好的优化器、调度器和必要的训练辅助对象。

工厂函数的设计思路是输入一个简单的配置字典,输出组装好的组件。比如:

def build_optimizer(model, config): if config["optimizer"] == "adamw": return AdamW( model.parameters(), lr=config["lr"], betas=(config["beta1"], config["beta2"]), eps=config["eps"], weight_decay=config["weight_decay"], ) elif config["optimizer"] == "lion": from lion_pytorch import Lion return Lion( model.parameters(), lr=config["lr"], betas=(config["beta1"], config["beta2"]), weight_decay=config["weight_decay"], ) # 其他优化器分支省略

默认配置我倾向于这样定:中小规模CV任务用AdamW,lr=1e-3,weight_decay=0.05;NLP微调用AdamW,lr=2e-5,weight_decay=0.01;生成模型任务用Lion,lr=3e-4,weight_decay=0.02。这些配置不算最优解,但都是一个能跑得很稳的起点。

6.2 把训练辅助逻辑统一收口

除了优化器本身,调度器、梯度裁剪、混合精度这些逻辑如果散落在训练代码里,不仅难以维护,也不容易做横向对比实验。我建议把整个"优化器+调度器+梯度裁剪"的封装收敛到一个训练配置类里,每次起实验只需要改配置文件,不用动训练主循环。

这一步的收益我在对比三组实验时体会特别明显。改造之前,我至少要改四个地方的代码才能完整换一套优化方案;改造之后,所有与优化相关的参数集中在同一个字典里,一次启动就能跑出可复现的结果。而且因为训练循环本身没有改动,实验对比的变量更干净,排除了自己不小心改坏代码的因素。

6.3 关于自动超参搜索的一点个人看法

最后提一下超参搜索。现在有很多自动调参工具,比如Optuna、Ray Tune,确实能省不少事。但我个人的经验是:在单卡或小规模实验环境下,手动安排好学习率、warmup步数、weight_decay这三个核心变量,效果不输全自动搜索,还省资源。自动搜索更适合你有明确的搜索空间、且单次训练时间在10分钟以内的快速迭代场景。如果一次训练要跑几个小时,我建议还是先用小规模的子集跑手动对比,锁定大致范围后再上全量,这样效率最高。

这些工具代码我现在每周都在用。无论是给新人做基线实验,还是探索新任务,都能先跑一套稳定的方案再针对性调整。训练优化这件事没有一劳永逸的银弹,但把经验固化下来、把可复用的链条搭好,至少能让每一次新尝试都站在前一次踩坑的肩膀上。

RELATED READING

延伸阅读

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