☰
优化器配置管理:终结训练不收敛与复现玄学
2026/11/3 12:23:43 网站建设 项目流程

“同样一份数据、同一个网络结构,运维同事在复现的时候loss就是降不下去,训练集F1到了0.82就开始原地打转。所有人第一反应是检查数据清洗、检查标签对齐、检查数据增强,结果问题根本不在数据流上——而是优化器配置不一致。”

这是过去大半年里我反复遇到的一个典型场景。Model-Optimizer这个名字,最早并不是某个开源库的代号,而是我为了结束这种“看运气调参”状态,把手头所有训练脚本里和优化器相关的部分统一抽出来做成的一个配置管理模块。它本质上不是一个新优化算法,而是把优化器选型、学习率调度、权重衰减、梯度裁剪、优化器状态保存这些环节收拢成一套可复现的机制。这篇文章会把里里外外的东西都摊开讲:底层原理、接入方式、踩坑记录,以及我后来沉淀出来的选型决策表。如果你也经常被“训练不收敛”折磨,或者希望在换人、换卡之后还能稳定复现实验结果,这篇文章应该能帮你省下不少时间。

1. “参数都一样,效果差三里地”:优化器配置为什么值得单独管理

1.1 一次让我怀疑人生的复现失败

先说说那次具体的事。模型是一个多模态分类模型,离线F1做到0.87,线上效果也正常。结果同组同事拿着同一份代码、同一批数据去复现训练,跑了一周之后F1只有0.82。两个人的环境唯一区别,就是他用的CUDA版本更高,所以我让他升了PyTorch之后再跑一次。结果依然不对。

后来我们一起逐行对参数,终于发现了问题:他在学习率调度部分,把warmup_steps从配置里读取时,因为键名写错,默认值落到了0。也就是说整个训练没有任何warmup,模型在Adam的二阶矩估计还没稳定之前就开始大步更新,前几百步权重就被推到奇怪的位置,后面怎么都拉不回来。

更让我警惕的是,这种问题不会报错。loss看起来在降,各项指标看起来“合理但不理想”,你不会下意识怀疑是优化器配置问题。从那次之后,每启动一个新的训练任务,我第一件事就是固定好优化器的完整配置,把warmup、decay、weight decay、beta、clip norm全部放进一份显式配置里,Model-Optimizer这个模块就是那时候长出来的。

1.2 Model-Optimizer的定位:不是新算法,而是控制训练收敛的配置中枢

我理解的Model-Optimizer不是要去发明第10086个自适应优化器,而是解决一个更实际的问题:优化算法和超参在训练链路中太容易被“悄悄改掉”了。模型训练里,真正影响收敛上限的可控变量,往往就集中在这几处:

  • 优化器选型(SGD、Adam、AdamW,以及它们对应的beta、eps)
  • 学习率调度(初始LR、warmup步数、衰减策略、最终LR)
  • 权重衰减的参数范围(哪些层参与、衰减系数多少)
  • 梯度裁剪阈值以及执行裁剪的位置
  • 优化器状态(state_dict)的保存、恢复与校验

这些变量有一个共同点:单独看每一个都“不重要”,组合起来却能左右一个实验是收敛到0.87还是卡在0.82。Model-Optimizer做的事情就是把它们全部收拢到一份结构化配置,然后由一个固定的初始化函数负责组装优化器和调度器,任何一次实验都用同一个入口。

这样带来的直接好处就是,团队里任何一个人都能在训练启动前,花两分钟把优化器配置完整地打印stderr,确认“这次训练到底用的什么超参”,而不是靠翻几十行代码去猜。后面我会给出完整可用的实现方案。

2. 优化器底层的几个决定性参数,以及别踩着的那几个坑

2.1 动量与一阶/二阶矩:SGD、Adam到底在做什么

要理解为什么优化器配置能造成这么大的差异,绕不开三个算法家族的更新逻辑。

最基础的SGD只使用当前梯度,w -= lr * grad。这是理论上收敛性质最干净的方法,但对病态条件数(ill-conditioned)非常敏感,如果梯度的不同维度尺度差异大,直接SGD会走得很慢。加动量之后,更新方向变成历史动量的平滑结果:

m = beta * m + grad w -= lr * m

这里beta控制在多大程度上相信历史梯度方向,典型值是0.9。动量带来的直接效果是:在梯度方向频繁变化的维度上自动减速,在方向一致的维度上加速。图像分类这类任务里,配合一组好的超参,SGD+Momentum至今仍然能打。

Adam则在梯度的一阶矩和二阶矩上分别做了EMA:

m = beta1 * m + (1 - beta1) * grad v = beta2 * v + (1 - beta2) * grad^2 w -= lr * m / (sqrt(v) + eps)

关键区别在于v:它统计的是“这个参数历史上梯度有多激烈”。用m / sqrt(v)做更新,相当于在参数尺度差异很大时自动做归一化。这就是为什么Adam对初始学习率不敏感、上手快,也是为什么它收敛到的极值点往往比SGD“更尖锐”,泛化性在部分任务上略差。

理解这个区别之后,再回头看那些beta参数就不只是“调了有反应但不知道啥用”的旋钮了:beta1控制一阶动量时长,beta2控制二阶矩的窗口。当beta2=0.999时,二阶矩估计大约会看最近1000步的梯度历史,这时候如果总训练步数只有2000步,前面一半的v估计都会被历史污染。这也是为什么短训练任务里,把beta2调小到0.99甚至0.98经常能见到收益。

2.2 weight decay必须解耦:AdamW和Adam不是差一个参数名

这是Model-Optimizer里我最强调的一个点:L2正则和weight decay,在SGD里是等价的,在Adam里完全不同。

如果直接用Adam + L2正则,等价于往loss里加0.5 * lambda * ||w||^2,梯度变成g + lambda * w。但Adam更新时,这个lambda * w会和梯度一起被除以sqrt(v) + eps。也就是说,一个权重如果历史梯度很大、二阶矩很大,它的L2惩罚会被自动缩小;反之,一个几乎不更新的权重,weight decay反而会被放大。这导致名义上的权重衰减系数在Adam里对不同参数产生了完全不同的实际效果,训练中很难控制过拟合强度。

AdamW把weight decay从梯度中解耦,更新变成:

w -= lr * lambda * w w -= lr * m / (sqrt(v) + eps)

也就是无论历史梯度如何,每一轮都对权重做一次“按比例缩小”。这一步看起来微乎其微,实际上决定了模型在多轮训练后的权重范数分布,也直接决定验证集指标能不能稳住。

所以我的选择非常固定:能用AdamW的地方不用Adam,除非在做对比实验时故意保留Adam作为baseline。如果你之前一直用PyTorch的torch.optim.Adam配合weight_decay参数,我现在建议你换成torch.optim.AdamW,然后把weight_decay只当作解耦后的衰减系数来看,配合正确的参数分组使用。

2.3 warmup、余弦退火和梯度裁剪:它们和优化器是同一个系统

曾经我也把warmup当成“玄学”,后来才知道它背后的逻辑很实在。Adam开始阶段,二阶矩v是从0开始累计的,sqrt(v)非常小,导致前几步的更新会被异常放大。warmup通过让学习率从很小的值线性爬升,相当于给优化器一个缓冲期,让一阶矩和二阶矩先估个大概,再进入正常更新幅度。这就是为什么warmup_steps不是一种风格偏好,而是Adam系优化器稳定启动的必要条件。

余弦退火则是另一个“不调白不调”的东西。单纯固定学习率训练,后期容易在极小值附近来回震荡;余弦退火把学习率从峰值平滑降到接近0,相当于在训练后段让优化器“收窄步子”去落位。和warmup配合起来的完整形式是:

lr = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * (step - warmup_steps) / (total_steps - warmup_steps)))

梯度裁剪更像是安全网。当某个batch出现极端样本或异常梯度时,如果不裁剪,一次更新就可能把权重推出正常区域;裁剪到固定范数之后,优化器最多只走“一步大但不过分大”的更新,训练稳定性会显著提升。

这三者和优化器是同一个闭环系统,单独调任何一个都很难发挥到位。Model-Optimizer这套东西,就是把它们绑在一起管理,调一处,其他部分跟着联动校验。

3. 把Model-Optimizer接进训练管线:一份完整可落地的配置方案

3.1 用配置字典描述优化器

我习惯把优化器配置定义成一份普通字典,这样不管是存JSON、YAML还是写进实验管理平台都方便。下面是一个示例配置,覆盖了我前面讲到的所有关键项:

optim_cfg = { "name": "adamw", "lr": 3e-4, "lr_min": 1e-5, "betas": (0.9, 0.999), "eps": 1e-8, "weight_decay_default": 0.01, "weight_decay_exempt": 0.0, # 对bias、LayerNorm、embedding等使用 "warmup_steps": 500, "total_steps": 30000, "scheduler": "cosine", "clip_grad_norm": 1.0, "grad_accum_steps": 4, }

接下来,我只需要一个函数把这些配置转成真正的优化器和调度器对象:

def build_optimizer(model, optim_cfg): no_decay = ["bias", "LayerNorm.weight", "layer_norm.weight"] decay_params = [] no_decay_params = [] for n, p in model.named_parameters(): if not p.requires_grad: continue if any(k in n for k in no_decay): no_decay_params.append(p) else: decay_params.append(p) groups = [ {"params": decay_params, "weight_decay": optim_cfg["weight_decay_default"]}, {"params": no_decay_params, "weight_decay": optim_cfg["weight_decay_exempt"]}, ] if optim_cfg["name"] == "adamw": optimizer = torch.optim.AdamW(groups, lr=optim_cfg["lr"], betas=tuple(optim_cfg["betas"]), eps=optim_cfg["eps"]) elif optim_cfg["name"] == "sgd": optimizer = torch.optim.SGD(groups, lr=optim_cfg["lr"], momentum=0.9) else: raise ValueError(f"unsupported optimizer: {optim_cfg['name']}") scheduler = get_cosine_schedule_with_warmup( optimizer=optimizer, num_warmup_steps=optim_cfg["warmup_steps"], num_training_steps=optim_cfg["total_steps"], ) return optimizer, scheduler

这段代码里面有两个容易出错的细节。第一,requires_grad=False的参数必须在build_optimizer之前就设好,否则优化器会把冻结层参数也收进去,更新时虽然参数梯度为None不会更新,但状态字典里会留着无效状态,给后续checkpoint恢复带来隐患。第二,判定“哪些层不加weight decay”时,LayerNorm.weight这类参数名一定要覆盖全,不同框架里叫法不一样,我一律用"LayerNorm.weight"和"layer_norm.weight"双保险。

3.2 参数组:让不同层拥有不同学习率

参数组是优化器最有用的能力之一,但很多人直到写大模型微调脚本的时候才被迫接触到。我的看法是:只要模型内部结构存在“底座特征提取”和“头部任务预测”的明显分层,分层学习率就值得试。

典型做法如下:

groups = [ {"params": backbone.parameters(), "lr": 1e-4, "weight_decay": 0.01}, {"params": head.parameters(), "lr": 5e-4, "weight_decay": 0.01}, ] optimizer = torch.optim.AdamW(groups)

这里的关键不是“头部lr必须比底座大”,而是“不同层用不同更新幅度”。完全冻结backbone来只训head时,一个参数组就够;如果用LoRA冻结全模型、只训练新增的low-rank参数,那么保持单一组也是合理的,因为模型本身的参数尺度没有参与更新。

如果你打算做分层学习率,还需要注意学习率调度器的作用方式。调度器默认是给所有参数组都按照同一个比例缩放lr,这没有问题,但如果你在某个step手动改了某个参数组的lr,调度器下一次step时可能会把手动改的值覆盖掉。我的经验是:分层学习率只设置在初始值上,后续统一交给调度器缩放,不要中途手动修改。

3.3 和AMP、梯度累积、checkpoint的配合

现在很少有训练任务不用混合精度的。使用AMP时,一个最容易搞错的点是梯度裁剪的位置。

PyTorch的torch.cuda.amp.GradScaler会自动把梯度乘一个scale因子,如果先调用nn.utils.clip_grad_norm_再更新优化器,裁剪作用在放大后的梯度上,等于没裁剪对。正确流程是先反缩放梯度,再做梯度裁剪,最后再optimizer.step()。Model-Optimizer里把这个封装成统一函数,避免每个训练脚本都重写一遍:

def optimizer_step(optimizer, scaler, model, clip_grad_norm=None): scaler.unscale_(optimizer) if clip_grad_norm is not None: nn.utils.clip_grad_norm_(model.parameters(), clip_grad_norm) scaler.step(optimizer) scaler.update()

梯度累积则是另一个放大配置作用的场景。你可能会写:

for i, batch in enumerate(dataloader): loss = loss / accum_steps scaler.scale(loss).backward() if ((i + 1) % accum_steps == 0): optimizer_step(...) optimizer.zero_grad()

这么做没问题,但要注意total_steps和warmup步数的换算:total_steps要以“实际执行optimizer.step()的次数”为准,而不是dataloader的迭代次数。否则warmup实际生效的步数会比配置预期长accum_steps倍,学习率调度的节奏整体漂移。

checkpoint的保存,Model-Optimizer固定保存四样:optimizer.state_dict()、scheduler.state_dict()、scaler.state_dict()、当前全局step。恢复训练时,四样一起恢复,缺一个都不行。尤其是scaler.state_dict(),如果训练过程中已经因为梯度溢出调整过scale,不恢复的话,新手跑30分钟后大概率会突然loss爆掉。

4. 三次真实踩坑与完整排查链路

4.1 前200步loss直接爆掉:查学习率和beta2

有一次在NLP任务上调一个文本分类模型,训练刚跑到第200步,loss直接从4.3跳到NaN。第一次遇到这种情况,很多人会先怀疑输入数据有问题,但数据是跑过pipeline清洗的,肉眼检查也没发现异常。于是我们按这个顺序排查:

  1. 打印每个batch的label分布,确认没有异常值。
  2. 检查模型输出层有没有除以0的风险,确认无。
  3. 检查学习率,工作日志里写着1e-3,但那个任务以前用的是5e-5。
  4. 把lr降到5e-5,重新跑,loss稳定下降。

后来我把这个案例记录下来,反思是:lr=1e-3对BERT类模型本来就偏大,配上前面的warmup设置不合理,更新幅度把参数推出正常区域。第二步,就算不换lr,把beta2从0.999改成0.98,也能明显缓解前期震荡,因为二阶矩窗口变短后,前期sqrt(v)不会小到离谱,单步更新幅度就不会被放得特别大。

这里给一个我的参考做法:对于总步数小于1万步的微调任务,beta2不要默认0.999,改成0.99或0.98更稳;对于预训练级别的大规模任务,beta2=0.999才合理。这是Model-Optimizer里少数几个我按任务复杂度设置默认值的参数。

4.2 训练loss下降但验证集跟心电图一样:查weight decay和warmup

另一个印象深刻的case是文本生成任务,训练loss曲线非常漂亮,一直往下,但验证集loss每两三个epoch就突然冲高一次,整体呈锯齿状。团队一度怀疑是验证集泄露或者batch size太小导致的噪声,被我否了——噪声不会造成那么规律的周期冲击。

后来逐项排除,怀疑落在优化器配置上。对比之前稳定的版本,发现新版本里weight_decay被设成了0.0,导致模型权重范数随着训练逐渐变大,泛化边界越来越差;同时验证集波动周期和LR峰值周期几乎同步。

定位之后,我们做了三个调整:把AdamW的weight_decay从0恢复为0.01;把warmup从1000步延长到总步数的10%;在验证集评测前同步一次scheduler状态,确保验证时看到的loss不是在lr峰值附近的震荡点。三个改动同时生效后,验证集曲线立刻平滑下来,最终指标也涨了1.2个点。

这里有个值得说透的点:warmup不只是“训练初期要用”,它还会影响训练中后期模型的稳定程度。如果warmup过短,模型初期就被推到loss landscape的边缘区域,后面每次学习率周期性上浮,都会触发一次较大的loss反弹。

4.3 换机器后结果对不上:优化器状态和随机种子

有段时间我们在分布式环境上切换GPU型号,从A100换成H800之后,同一份代码、同一个数据集,跑出的AUC差了0.006。这个差异不大,但对一个要写进报告的实验来说,解释起来很麻烦。

一开始怀疑是cudnn benchmark导致卷积算法选择不同,关掉之后差异小了一些,但依然在。最后查到的时候发现:训练脚本在model.load_state_dict(checkpoint)之后重新初始化了优化器,然后继续训练。也就是说,之前的优化器动量、二阶矩、warmup状态全部丢失了。恢复出来的模型权重没问题,但优化器在最后几个step用的学习率曲线也错位了。模型参数一样,优化器状态不一样,后续训练行为完全回不来。

这个坑的教训非常直接:所有与复现相关的代码里,优化器状态和模型权重必须成对保存和成对恢复。Model-Optimizer现在会在build_optimizer生成优化器时打印一条摘要,包括当前参数组数量、每组参数个数、每组学习率和weight decay,确保在换了机器、换了人、换了环境之后,启动日志先自己交代清楚。

5. 我的优化器选型决策表和默认调参顺序

5.1 不同任务该用哪类优化器

被问得最多的一个问题是“到底用SGD还是AdamW还是LAMB”,我给不出一个放诸四海皆准的答案,但沉淀出的决策表足够覆盖大多数实际任务。这里分享出来:

任务类型常用优化器默认lrweight_decaywarmup备注
CV分类/检测(小数据)SGD+Momentum(0.9)0.01~0.15e-4可选配合step/cosine调度,泛化通常更好
CV分类/检测(大数据)AdamW1e-4~3e-40.01~0.055%-10%训练更省心,收敛更快
NLP微调(BERT类)AdamW2e-5~5e-50.0110%小lr,小weight decay,严格warmup
大模型LoRA/prefix微调AdamW1e-4~3e-40.015%注意只更新adapter参数,不要动底模
大规模预训练AdamW/LAMB1e-4~2e-40.012000步以上需要关注batch size与lr的线性缩放
GAN训练Adam1e-4~2e-40可选generator和discriminator分参数组,beta1常调为0.5
稀疏特征/Embedding模型AdamW/Adagrad3e-40.011000步以上特别注意embedding的weight decay是否要豁免
RL/PPO类AdamW3e-40.01可短配合clip_grad_norm=1.0基本是刚需

表中看起来CV还在推荐SGD,但我的真实使用频率已经偏向AdamW,尤其是在模型结构复杂、没有时间精细调参时,AdamW的上限通常已经足够,而且省下的调试时间非常可观。只有在做极小规模实验、希望用最经典设置验证模型逻辑正确性时,我才会特意用SGD跑一遍。

5.2 稳定复现的调参顺序

调参不是拿起lr就乱试。我现在的固定顺序是:

  1. 先固定优化器类型和基础超参,用一份默认配置跑通完整流程,确认训练不崩、指标有正反馈。
  2. 调整学习率峰值。用一个小范围搜索,比如lr在1e-4/3e-4/1e-3三档之间,优先找到“训练不震荡且loss下降最快”的下限,再往回退一半当做保守初始值。
  3. 设置warmup。第一次先用总步数的10%,观察前10%步数内loss是否明显回落;如果不回落,说明warmup步数可能过长或初始lr过低。
  4. 调整weight decay。在验证集出现过拟合征兆之前,保持默认0.01即可;如果看到训练和验证差距拉大,再按0.02、0.05往上加。
  5. 最后调beta2。仅在训练早期loss震荡明显、或者总步数明显偏短时调整,不要当作常规旋钮。
  6. 所有实验记录都保存完整的optim_cfg,模型权重、优化器state、scheduler state、scaler state一并存档。

这套顺序看起来保守,但它保证的是:你永远知道当前卡在哪一类问题,而不是盲目往一个随机超参组合里撞。Model-Optimizer里我甚至会把每一步的尝试结果和config hash一起存下来,方便过两个月之后回答“当时的0.88是怎么跑出来的”。

5.3 最后还要叮嘱的三个细节

第一个细节:优化器初始化之前,确认模型的requires_grad状态已经全部设置完毕。常见写法是model.freeze()之后才build_optimizer(model, cfg),顺序反了会导致冻结参数也被eval模式接管,但优化器里还残留不更新状态。

第二个细节:eval()和train()切换时优化器状态不受影响,但num_batches_tracked这类BatchNorm变量会。如果模型里有BatchNorm且训练途中频繁切换eval来做验证,请在训练模式下重新前向一个batch再继续,否则BatchNorm的running统计会滞后。

第三个细节:优化器配置里尽量不要使用“绝对步数写死”的方式,优先用比例计算。我把warmup_steps写成int(0.1 * total_steps),把clip_grad_norm写成浮点比例时,换数据规模、换batch size时才不会把一套合理配置弄失效。

说到底,Model-Optimizer这套东西并没有发明新的训练理论,它只是把那些“明明决定了实验成败、却总被人忽略”的优化器细节,变成了一套任何人拿来就能用、用完还能复现的机制。我至今仍保留着一个习惯:每次训练任务启动前,把优化器配置原文打印到日志里,让自己清清楚楚看到这次实验是在什么条件下开始的。这个习惯,是从那次0.82和0.87的差异之后养成的。

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

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

立即咨询