ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度学习模型训练调优:优化器选型、学习率策略与降本方案全解析

深度学习模型训练调优:优化器选型、学习率策略与降本方案全解析 我翻了下这几年调过的模型训练记录发现一个问题很多人把“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文本分类/序列标注AdamWSGD训练速度快配合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(lr0.1, momentum0.9, weight_decay1e-4)配合cosine学习率衰减和BatchNorm的默认初始化。中小规模NLP任务AdamW(lr2e-5, betas(0.9, 0.999), eps1e-8, weight_decay0.01)配合线性warmup线性衰减。大规模预训练AdamW(lr3e-4, betas(0.9, 0.95), eps1e-8, weight_decay0.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(), lr3e-4, weight_decay0.1) total_steps 50000 warmup_steps 2000 scheduler SequentialLR( optimizer, schedulers[ LinearLR(optimizer, start_factor0.01, end_factor1.0, total_iterswarmup_steps), CosineAnnealingLR(optimizer, T_maxtotal_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_norm1.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(), alpha1 - 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_factorloss震荡但不下探学习率衰减时机/幅度不合适1. 检查当前学习率数值 2. 看验证集精度是否在提升改用cosine衰减适当增大weight decay训练集loss低验证集loss高过拟合/正则不足1. 看训练和验证曲线差距 2. 检查weight decay是否太小加大weight decay增加dropout或数据增强考虑EMA一开混合精度就NaNFP16溢出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(fstep {step}, {name} grad norm: {grad_norm})第二招是“小样本过拟合测试”拿几十条样本不带任何数据增强固定随机种子让模型训练到过拟合。如果在几十条样本上都达不到近乎100%的训练精度说明网络结构或优化器配置存在问题。第三招是固定随机种子后对比不同优化器在同一个小任务上的表现。这个方法能快速筛选出“优化器配置本身有问题”还是“任务难度太大”的干扰项减少调参时的变量扰动。5. 一个可以直接抄的“中等规模模型训练默认配置”总结写到这里我觉得单独给出一套“我现在做项目时默认用的配置”会更实用。这套配置不是一个完美的方案但它在绝大多数场景下不会出错。以PyTorch为例optimizer AdamW( model.parameters(), lr3e-4, betas(0.9, 0.999), eps1e-8, weight_decay0.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_norm1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad()这套配置在NLP微调、分类任务、检测任务上都跑通过不少次。如果你要拿去换到自己的场景建议只改两个地方一个是学习率小batch调小大batch调大另一个是weight decayCV任务一般0.01~0.05就够预训练大模型才用到0.1。最后再分享一个个人经验如果你进入一个项目发现模型练出来的效果不稳定先不要急着怀疑优化器选错了而是先确认每个trial的随机种子是否固定了。很多项目调参调不动是因为每次跑出来的结果波动太大种子一变结果就变本质上是“评估噪声”掩盖了“优化改进”。先把随机种子定死、把评估指标波动压下来再去调优化器和学习率才是更高效的做法。
RELATED READING

延伸阅读

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