我翻了下这几年调过的模型训练记录,发现一个问题:很多人把“Model-Optimizer”默认等同于PyTorch里那个optimizer = torch.optim.Adam(...),然后loss不降就换优化器,过拟合就加weight decay,一顿操作猛如虎,最后训练还是不稳定。其实“模型优化”这件事远不止选一个优化器,它是一整套围绕“怎么让模型在有限算力和数据下更快收敛、更稳落地”的组合拳。
这篇文章算是我对自己做模型训练调优的一次完整复盘,面向的是正在训练深度学习模型、但总觉得“差一口气”的工程师和算法同学。内容不绕弯子,直接讲清楚四件事:优化器到底怎么选、学习率策略为什么比优化器本身更关键、训练过程中有哪些不牺牲精度却能省显存和时间的降本方案、以及那些真正让训练翻车的隐蔽问题怎么排查。读完之后你至少能搭出一套中等规模模型训练的基础配置,并且知道每个参数为什么要这么设。
1. 优化器选型思路:先搞清楚你要解决的是“速度”还是“精度”
1.1 优化器家族梳理:每个名字背后都有明确的适用边界
我们日常见到的优化器,基本逃不开这几类:经典SGD系、自适应学习率系(Adam系)、以及近两年在LLM场景重新火起来的Lion、Sophia这类“新玩家”。
SGD加上Momentum,在CV领域依旧是打榜利器。它的特点是收敛过程平滑、泛化能力好,但训练速度慢,对学习率极其敏感。我早期在ResNet上做实验时就发现,SGD配一个不对的初始学习率,前50个epoch几乎原地踏步,但如果配对了,效果往往能压过同等条件下的Adam。
Adam系的本质是在SGD基础上给每个参数加了自适应学习率,通过一阶矩和二阶矩的估计来调节每一步的更新幅度。好处是收敛快、对初始学习率不那么挑剔,坏处是容易在后期出现“震荡”和泛化性能退化。这也是为什么Momentum SGD在某些任务上最终精度反而更高——因为Adam的更新步长在小梯度区域会被二阶矩放大,导致后期微调时过冲。
AdamW纠正了Adam权重衰减的实现方式,把weight decay从梯度项里剥出来,单独做解耦。这个改动看起来只是数学上的等价变换,但实际使用时,AdamW在预训练大模型和Transformer类结构上几乎是标配,因为它让正则项不再被Adam的自适应机制“吸收”,正则效果更可控。
至于Lion和Sophia这类新优化器,我个人的看法是:它们在某些大模型训练中确实把吞吐和效率提上去了,但还远没到“替代AdamW”的地步。Lion的更新规则非常激进,对超参数更敏感,训练小模型时一个不留意就会发散。
1.2 按任务类型做选型,而不是按个人偏好做选型
我在实际项目里的选型逻辑是这样的:
| 任务类型 | 首选优化器 | 备选优化器 | 选型原因 |
|---|---|---|---|
| CV分类/检测/分割 | SGD(Momentum) | AdamW | 收敛结果泛化性更好,方便做细致调参 |
| NLP文本分类/序列标注 | AdamW | SGD | 训练速度快,配合warmup效果好 |
| Transformer预训练/大模型微调 | AdamW | — | 权重衰减解耦,对超参数鲁棒性最好 |
| 生成式模型/对抗训练 | Adam(或AdamW) | RMSProp | 动态平衡生成器与判别器的训练速度 |
| 强化学习策略网络 | Adam | — | 自动适应学习率,对稀疏reward更友好 |
| 知识蒸馏/Lora微调 | AdamW | — | 小学习率场景下稳定,配合低精度训练更稳 |
这里有个容易忽略的点:选优化器不是选一个名字,而是选一套默认行为。AdamW默认的betas=(0.9, 0.999)适合大多数场景,但如果你的batch size特别小,二阶梯度的噪声会变大,可以考虑把beta2降到0.95;如果训练特别长,可以调高beta2到0.9995让二阶矩估计更平滑。这些都是“默认配置不够用时才动”的微调项,不建议一开始就纠结。
1.3 我常用的三个默认组合:可以直接当模板抄
如果是刚起步的小项目,我在大部分情况下会直接采用下面这套“保守但不踩坑”的配置:
- 中小规模CV任务:
SGD(lr=0.1, momentum=0.9, weight_decay=1e-4),配合cosine学习率衰减和BatchNorm的默认初始化。 - 中小规模NLP任务:
AdamW(lr=2e-5, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01),配合线性warmup+线性衰减。 - 大规模预训练:
AdamW(lr=3e-4, betas=(0.9, 0.95), eps=1e-8, weight_decay=0.1),配合梯度裁剪到1.0,这一步几乎是必选项。
注意:上面的学习率是在batch size 512左右的经验值。如果你的batch size翻倍,学习率也建议跟着翻倍,这个逻辑后面会展开讲。
2. 学习率策略:真正决定模型能不能收敛的“隐形优化器”
2.1 为什么warmup能避免“开局崩盘”?
很多人不理解warmup存在的意义:既然学习率太小训练慢,那为什么不一开始就用大学习率?
我用一个具象的例子解释一下。模型刚初始化时,各层的梯度统计量是非常不稳定的,尤其是有LayerNorm或BatchNorm的网络,此时Adam的二阶矩估计会被早期少数几个极端梯度污染。如果一开始就用大学习率,相当于在一个凹凸不平的雪坡上直接往下冲,前几步就在一个错误方向滑出去很远,后面再想扳回来就难了。
warmup的作用是给模型一个“先站稳再跑”的机会:前若干个step(几千到几万步不等)让学习率从接近于0线性升到设定值,让梯度的均值、方差先稳定下来,同时让权重的分布先到一个相对合理的“盆地”,再去加大步伐。
我在实际训练中观察到的规律是:warmup步数约占总训练步数的3%~5%时性价比最高。比如总共训练50000步,warmup给1500到2500步就够;如果短跑式训练只跑10000步,warmup直接给500步。
2.2 cosine衰减为什么比step衰减更省心
学习率衰减本质上是在做“先全局搜索,后局部微调”的平衡。传统step衰减像是每过一段就“降档”:比如每30个epoch学习率除以10,这种方式如果你忘记在正确节点降学习率,模型就会在最优解附近一直打转。cosine衰减是连续且平滑的,从峰值一路降到近乎为0,不会出现“断崖”,省掉了反复试“什么时候降学习率”的麻烦。
这里给一个可以直接参考的PyTorch实现:
from torch.optim import AdamW from torch.optim.lr_scheduler import SequentialLR, LinearLR, CosineAnnealingLR optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) total_steps = 50000 warmup_steps = 2000 scheduler = SequentialLR( optimizer, schedulers=[ LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=warmup_steps), CosineAnnealingLR(optimizer, T_max=total_steps - warmup_steps) ], milestones=[warmup_steps] )这段代码的逻辑是:前2000步线性warmup,之后按cosine曲线衰减。实际跑下来,这种组合在几类任务里都表现得非常稳定,基本不需要再额外盯学习率的调整时机。
2.3 大batch场景下的学习率缩放规则
训练时如果从batch size 256升到1024,学习率不能保持不变。原因在于:batch变大后,梯度噪声减小,每一次step的梯度方向都更“可信”,所以可以用更大的步长。线性缩放规则(Linear Scaling Rule)给出的建议是:batch size翻多少倍,学习率就乘多少倍。
但这个规则有上限。我实测下来,batch size放大到原来的4倍以上时,单纯线性放大学习率容易导致早期震荡,更好的办法是只放大2倍,剩下的靠延长训练步数补回来。如果你的显存只够小batch,但你又想要大batch的稳定梯度,那可以使用梯度累积来模拟大batch效果,这在第3节里会详细说。
顺带提一个细节:学习率缩放和warmup步数也需要一起调整。大batch场景下,建议把warmup步数适当延长,因为初期梯度的不稳定在大学习率下会被放大,warmup扮演的“缓冲”角色更重要。
3. 训练降本优化:省显存、提吞吐的几条成熟路线
3.1 混合精度不是简单打开一个开关
英伟达的Apex和PyTorch原生amp,本质上都是做同一件事:用FP16计算前向和反向,用FP32保存一份权重副本用于更新。好处是显存大约能省一半,训练速度在支持Tensor Core的GPU上能提升30%~80%。
很多人踩过的坑是:一开混合精度训练,loss的数值看起来“跳来跳去”,甚至直接变成NaN。这通常不是因为你的模型有问题,而是因为FP16能表示的数值范围有限,某些参数或梯度的绝对值太小,在FP16下直接变成0。解决办法不是关掉混合精度,而是用torch.cuda.amp.GradScaler动态调整loss的缩放因子,让梯度在FP16范围内尽量不溢出。
还有一点,混合精度对某些层是“不友好”的,比如BatchNorm层,它统计均值方差时更希望在FP32下进行。PyTorch的amp内部会自动把这些层切换回FP32,所以你不需要手动处理,但你心里要有个底:混合精度省显存,但不是所有层都在FP16下算。
从实际反馈来看,AMP在我自己的训练流程里几乎成了固定配置,尤其是用3090/4090这种24GB显存的卡时,不开启AMP基本就要砍batch size,而砍batch size又会影响BN统计和优化器更新质量。
3.2 梯度累积:小显存模拟大batch的土办法
梯度累积的逻辑很简单:不更新权重,把多个batch的梯度累加在一起,攒够了再更新一次。用公式表达就是:在accumulation_steps个小batch之后,总梯度≈一个大batch的梯度。
在代码里实现时,一个常见的错误是忘记把loss除以accumulation_steps。如果你的优化器需要对梯度做归一化处理(比如SGD的weight decay和Adam的二阶矩估计),那么累积后的梯度数值必须等价于“等同batch size”的梯度。否则,累积梯度数值偏大,等效于无形中把学习率放大accumulation_steps倍。
我习惯的实现方式是:
accumulation_steps = 4 scaler = torch.cuda.amp.GradScaler() for step, (inputs, labels) in enumerate(dataloader): with torch.cuda.amp.autocast(): loss = model(inputs, labels) / accumulation_steps scaler.scale(loss).backward() if (step + 1) % accumulation_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这样累积4个step的梯度,权重更新一次,等效于用一个大4倍的batch,但显存占用不变。我自己的习惯是,梯度累积的步数不超过8,因为超过8之后,BatchNorm的统计特性会和小batch训练产生明显偏差,收敛曲线会变“毛糙”。
3.3 梯度裁剪、EMA和权重平均:几个被低估的“稳定器”
先说梯度裁剪。很多人只在训练Transformer时开梯度裁剪,其实在RNN、GNN这类梯度在时间或空间维度上传播路径较深的模型中,梯度裁剪几乎“保命”。我一般设置为max_grad_norm=1.0,如果loss曲线频繁出现尖峰,再适当下调到0.5甚至0.3。
EMA(指数移动平均)是对模型权重做“软集成”:每隔若干步把当前权重和前一版的移动平均按比例融合,最终拿到的模型往往比训练末期直接保存的权重更平滑、更抗噪声。它在半监督学习、目标检测这类器任务里提升尤其明显,代码实现不复杂:
ema_decay = 0.999 ema_model.load_state_dict(model.state_dict()) for name, param in model.named_parameters(): ema_model.state_dict()[name].mul_(ema_decay).add_(param.detach(), alpha=1 - ema_decay)关键是EMA的decay参数。0.999适合长训练,0.99适合训练步数少但更新幅度大的场景。如果你发现EMA后的模型在验证集上不如原始权重,大概率是decay值设得太小,让EMA把太多“早期坏记忆”留下来了。
3.4 显存实在不够时的两个兜底方案
一个方案是gradient_checkpointing,用“计算换显存”。启用之后,训练过程中不会保留每层激活值,而是在反向传播需要时重新算一遍。代价是前向多跑一遍,训练时间会增加约20%~30%,但显存可以省掉一半。Transformers库里的model.gradient_checkpointing_enable()就可以直接开启。
另一个更偏工程的方案是“层冻结”(layer freezing)。大模型微调时,前几层学的是通用特征,对下游任务贡献有限。与其让所有层都参与反向传播消耗显存,不如把前N层全部冻结,只训练后面几层和分类头。这样显存占用直接降一个台阶,缺点是如果下游数据和预训练数据分布差异很大,冻结层可能会限制模型表达能力。我的经验是:先全量训练几个epoch看loss能不能降,如果loss降到一定程度卡住了,再去尝试冻结层。不要一上来就把层冻住,那样很可能是在给自己埋坑。
4. 常见问题与排查技巧:训练翻车现场的经验沉淀
4.1 问题速查表:从现象到解决方案
我把训练过程中最常遇到的问题整理成了一个速查表,按“先看loss曲线、再看梯度、最后看权重分布”的顺序排查:
| 常见现象 | 可能原因 | 推荐检查顺序 | 解决方案 |
|---|---|---|---|
| loss完全不降 | 学习率过小/初始化有问题 | 1. 看梯度值 2. 打印每层权重标准差 | 调大学习率,检查初始化分布;用warmup时观察前几步 |
| loss开始下降,但很快发散 | 学习率过大 | 1. 看loss曲线上是否有尖峰 2. 看梯度是否爆炸 | 降低学习率,开启梯度裁剪;缩小warmup的start_factor |
| loss震荡但不下探 | 学习率衰减时机/幅度不合适 | 1. 检查当前学习率数值 2. 看验证集精度是否在提升 | 改用cosine衰减;适当增大weight decay |
| 训练集loss低,验证集loss高 | 过拟合/正则不足 | 1. 看训练和验证曲线差距 2. 检查weight decay是否太小 | 加大weight decay,增加dropout或数据增强;考虑EMA |
| 一开混合精度就NaN | FP16溢出 | 1. 查看scaler状态 2. 定位首个出现NaN的层 | 检查是否有大数值logits出现在loss里;使用amp.GradScaler;必要时对loss做clip |
| 显存不够但任务必须跑 | 激活值/优化器状态占显存 | 1. 看nvidia-smi显存占用 | 开AMP、开梯度检查点、减小batch并配合梯度累积 |
4.2 一个印象深刻的分布式训练踩坑:loss的精度陷阱
有一次我在多卡训练时遇到一个诡异问题:单卡环境loss正常,多卡DDP一开,loss在几千步后突然跳高。查了很久,最后定位到问题出在all_reduce对梯度的平均逻辑上。
DDP默认行为是对梯度做平均,这个行为本身没问题。但当时代码里同时加了梯度累积,我在累积过程中对loss做了“除法”处理,然后再叠加DDP的平均操作,梯度被多除了一次,等效学习率被缩小了N倍,导致收敛变慢,最终表现为loss在后期“跳高”到另一个平台。这类问题隐蔽性极高,从代码表面看每一条逻辑都对。
排查这类问题时,我的经验是:先关掉所有“高级功能”(AMP、梯度累积、梯度裁剪、EMA),用最小配置跑通一条小数据,逐项恢复功能,定位到具体是哪一步破坏了数值。这个过程虽然看起来“笨”,但在复杂工程场景下反而是最可靠的。
4.3 调试时的三个土办法:打印、小样本、固定随机种子
第一招是打印参数更新量。在训练step中,每隔几步打印模型某一层权重的更新量标准差。如果更新量趋近于0或突然变成NaN,说明优化器或梯度那边出了问题。
for name, param in model.named_parameters(): if param.grad is not None: grad_norm = param.grad.norm().item() if grad_norm > 100 or grad_norm != grad_norm: print(f"step {step}, {name} grad norm: {grad_norm}")第二招是“小样本过拟合测试”:拿几十条样本,不带任何数据增强,固定随机种子,让模型训练到过拟合。如果在几十条样本上都达不到近乎100%的训练精度,说明网络结构或优化器配置存在问题。
第三招是固定随机种子后对比不同优化器在同一个小任务上的表现。这个方法能快速筛选出“优化器配置本身有问题”还是“任务难度太大”的干扰项,减少调参时的变量扰动。
5. 一个可以直接抄的“中等规模模型训练默认配置”总结
写到这里,我觉得单独给出一套“我现在做项目时默认用的配置”会更实用。这套配置不是一个完美的方案,但它在绝大多数场景下不会出错。以PyTorch为例:
optimizer = AdamW( model.parameters(), lr=3e-4, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.1 ) # warmup + cosine scheduler total_steps = len(train_loader) * epochs warmup_steps = int(total_steps * 0.03) optimizer.zero_grad() for epoch in range(epochs): for batch in train_loader: with torch.cuda.amp.autocast(): loss = model(batch) / accumulation_steps scaler.scale(loss).backward() if (step + 1) % accumulation_steps == 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad()这套配置在NLP微调、分类任务、检测任务上都跑通过不少次。如果你要拿去换到自己的场景,建议只改两个地方:一个是学习率(小batch调小,大batch调大),另一个是weight decay(CV任务一般0.01~0.05就够,预训练大模型才用到0.1)。
最后再分享一个个人经验:如果你进入一个项目,发现模型练出来的效果不稳定,先不要急着怀疑优化器选错了,而是先确认每个trial的随机种子是否固定了。很多项目调参调不动,是因为每次跑出来的结果波动太大,种子一变结果就变,本质上是“评估噪声”掩盖了“优化改进”。先把随机种子定死、把评估指标波动压下来,再去调优化器和学习率,才是更高效的做法。