ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深度学习训练loss暴增排查:优化器、BN统计量与数值溢出

深度学习训练loss暴增排查:优化器、BN统计量与数值溢出 模型训练到后期我见过最折磨人的画面就是loss曲线已经贴着地板走完大半程你甚至已经开始盘算报告里写什么指标了结果某个step的日志突然跳出一个比初始值还高的loss数字后面再跟一个inf整个训练直接白干。我第一次遇到时以为代码写错了后来才发现这类现象在深度学习模型训练里非常普遍而且“快收敛时的暴增”恰恰是最难查的——因为问题大概率不在loss函数本身而是藏在优化器、数据管线、统计量状态或者数值精度里。这篇文章我就把这类现象从现象到根因、从排查到修复完整拆一遍。不管你是用PyTorch、TensorFlow还是跟着yolo11、roberta中文预训练模型、efficientnetv2这类现成框架做微调和训练只要你在训练过程中碰到过loss突然飙升、收敛后震荡、甚至inf/NaN的情况这篇内容应该能帮你省下至少一个通宵。1. 先把“loss突然暴增”这件事拆清楚1.1 我见过的几类暴增现场这类问题出现时看起来都叫“loss暴增”但实际形态完全不同。我把它们分成三类排查方向完全不一样。第一类是瞬发型某一个step的loss突然变成几十甚至几百下一个step又自己恢复正常曲线看起来像一根尖刺。这类问题多半是数据管线或单batch异常导致的比如有个损坏样本、某张图像的标签错得离谱、某个batch里混入了mask全为0的padding序列甚至是focal loss在遇到极端困难样本时梯度被指数放大。它出现的频率可能很低但收敛期模型已经非常稳定任何一个异常样本都会带来明显的loss尖刺。第二类是持续飙升型loss跳上去之后不回来或者短暂下降又开始新一轮攀升整体指标直接垮掉。这类往往和优化器状态、学习率调度、模型权重被污染有关是所有人最怕的一种。因为它不像瞬发型那样“忍一下过去”它会彻底毁掉已经收敛好的模型而且往往是不可逆的。第三类是定时突发型loss每隔固定步数或固定epoch就“抽风”一次像有节律一样。这类大多和训练逻辑有关——比如scheduler的触发时机错了比如每次eval前后模型在train/eval模式之间切换导致BN统计量异常比如epoch结束时shuffle buffer重建导致数据顺序突变。它是最有规律可查的也是相对最容易修复的。1.2 先别急着改代码判断问题属于哪一层很多人遇到loss暴涨的第一反应是调低学习率或者改loss函数我建议你忍住。因为在你还没搞清楚问题在哪一层之前任何改动都是盲目的。我自己的习惯是先回答三个问题。第一暴增的那个step是一个异常点还是一个趋势的开始。把曲线拉宽到前后几百个step看如果只有孤立的单步尖峰优先怀疑数据如果尖峰之后loss整体抬高优先怀疑模型被污染。第二这个问题能不能在固定seed下稳定复现。如果两次复现分别在不同的step暴增多半是数据增强、Dropout这类随机因素在特定阶段触发的如果能稳定复现到同一个step那就可以很精确地定位当时发生了什么。第三暴增时有没有伴随inf或NaN。有数值溢出和没有数值溢出的排查路径是两条完全不同的路后面我会专门展开。这一步看起来简单但90%的人会跳过。我见过太多人一上来就把学习率从1e-4改成1e-6结果问题依旧白白浪费一天时间。先把问题分类后面才有方向。2. 四个最容易被忽略的“罪魁祸首”2.1 优化器与学习率调度的“关键时刻”快收敛阶段模型的参数已经处在loss曲面上的一个低洼区域这时候任何一个偏大的更新步长都可能把权重踢出去。很多人以为学习率一路在下降就没问题但实际上scheduler在step和epoch之间的切换经常出问题。我举一个真实场景你用cosine schedule with warmup训练一个分类模型warmup阶段学习率从0线性升到1e-4然后在cosine曲线上衰减。如果scheduler是按batch触发而不是按epoch触发那么在warmup结束的那一下学习率的上升斜率会突然变成下降斜率这个拐点本身就是一次“冲击”。Q如果按理说拐点是平滑的为什么会暴增问题往往出在更隐蔽的地方。比如你用了AdamWweight decay是直接作用在参数上的在收敛期它和学习率产生耦合再比如某些框架里scheduler在warmup阶段的迭代次数计算错误导致实际学习率和记录中的数值对不上。你有几次你以为scheduler在降低学习率实际它在某一步重置回了初始值反正我被坑过。我的建议是在训练脚本里每隔固定步数把optimizer.param_groups里的学习率打出来和曲线tick对照。不要相信scheduler内部的计算直接看实际生效的值。另外如果你用了梯度裁剪注意裁剪阈值和loss scale的交互——在收敛期梯度范数本来就小clip阈值设得太高形同虚设设得太低又会把正常梯度也裁掉导致loss波动。2.2 数据排列、shuffle与增强策略的“全局影响”第二个容易被遗漏的是数据层面。收敛期的模型对数据的微小变化极其敏感因为它的正确率已经很高了任何一小撮“它没见过”的数据都会造成异常的loss。这里有一个非常典型的场景。YOLO系列训练自己的模型时会开mosaic、mixup这类强数据增强训练到后期使用者会习惯性地关掉增强或者降低增强强度让模型收敛得更干净。但如果你在某个epoch中途直接关掉mosaic前后两个epoch的数据分布会发生剧烈变化——模型在上一秒还在被“拼接后的迷宫图”训练下一秒突然看到整张干净原图激活分布瞬间紊乱表现出来就是loss暴增。同样的问题也出现在shuffle上。如果你的数据集loader在每个epoch结束后重新shuffle而相邻两个epoch的最后一个batch和第一个batch恰好是几个特别难的样本收敛期就会周期性出现一个尖峰。用更大的shuffle buffer能缓解但根治方法是检查这些尖峰对应的到底是哪些样本把异常样本清洗掉。还有一个坑分布式训练里多卡数据并行时每个epoch的shuffle可能因为随机种子不同导致各卡数据分布不一致。比如rank 0分到了大量正样本rank 1分到了大量难负样本它们计算出的梯度方向就不一致AllReduce之后的平均梯度可能指向一个“两边不讨好”的方向loss就会异常升高。2.3 归一化层统计量的“状态撕裂”第三类问题我个人觉得是最隐蔽、也最值得写一节的模型里的BN层统计量被撕裂。在做目标检测、图像分类这类带BatchNorm的模型时训练到快收敛时系统会自动保存一个“最优模型”。保存时你很可能做了这样一个操作先model.eval()在验证集上算指标然后保存模型。问题在于——如果你在eval之后忘记切回model.train()模型里的BN层就会在错误状态下继续跑训练数据running_mean和running_var被验证集数据污染。这个污染是渐进式的刚开始的前几个batch可能只是loss小幅波动因为BN统计量还在被训练数据重新拉回但如果持续几百个step没发现BN的running_mean/var会彻底错乱loss会越跳越高最终看起来像模型“崩溃”了。这种问题的可怕之处在于它和模型本身的权重没有半点关系你只靠调learning rate永远修不好。另外现在很多框架支持EMA指数移动平均权重。EMA在训练后期可以显著提升泛化能力但它也有一个副作用EMA是对权重的平均而BN层在训练时是实时更新running统计量的。如果你保存的是EMA权重还原后直接跑验证或继续训练EMA权重和BN统计量之间是不同步的loss就会暴增。解决办法是在保存EMA权重后让模型切到train模式在训练集上重新跑几个epoch的“预热”让BN统计量重新适配EMA权重。2.4 数值溢出inf和NaN不一定从loss开始最后一大类是数值稳定性问题。快收敛阶段loss值本身很小比如0.01的量级这会导致某些中间激活值和梯度的量级也变得很小。在单精度FP32下还好一旦你启用了混合精度训练AMP/FP16问题就成倍放大——因为FP16能表示的最小正常数大约只有6e-5小于这个数的值会被直接underflow成0而大于65504的值会变成inf。具体到loss上有一个非常典型的操作会暴雷你用的某些loss函数内部有除以极小值的逻辑比如focal loss计算(1-p)^γ时如果p已经非常接近1也就是模型预测置信度已经很高这个幂运算结果会小到FP16完全表示不了一旦underflow成0再取log就会得到-infloss直接变NaN。很多人在这一步第一反应是“模型发散”其实模型只是在收敛期表现得“太好”了。我还遇到过更隐蔽的情况。某个模型在训练后期loss突然出现NaN但回看日志NaN出现前的几千个step一切正常。后来反复加日志才定位到问题出在attention层输入特征的数值范围在某个batch里突然变大经过softmax之后出现NaN。在FP16下softmax负责聚合的那个分母如果求和超过65504就会溢出一旦溢出后续所有梯度都变成NaN。这类问题靠调大batch size、减少学习率都没用正解是给attention计算加log-sum-exp稳定性处理或者把计算张量切回FP32。3. 从发现问题到修复一套完整的实操流程3.1 先把“犯罪现场”固定下来遇到loss暴增时第一优先级的动作不是定位问题而是保留现场。这一步很多人会漏掉因为潜意识里觉得赶紧找到bug才是正事。但我的经验是如果你不在暴增发生的那一刻保存好各种状态后面排查时你连“当时模型是什么参数、优化器走到了哪一步”都说不清楚定位工作会变得极其痛苦。所以我在训练脚本里fix seed的前提下只要发现有loss异常就会断言抛出异常并自动保存一份完整的debug快照。快照里至少要包含这几样东西当前step的模型权重、optimizer的state_dict、scheduler的last_epoch触发异常那一个batch的输入数据和标签单独保存成文件模型最后一层输出的logits、loss计算前的中间变量。保存这些信息之后配合固定的随机种子就能在调试模式下复现“炸掉”的那一步。没有这个现场你后面所有分析都是在猜。3.2 定位“第一根稻草”的调试代码保存现场之后下一步是找到第一个出现异常数值的位置。如果你用PyTorch最快的定位手段是torch.autograd.set_detect_anomaly(True)它会在反向传播出现NaN时直接报错并给出当时的栈信息。但这个方法在训练后期用起来很慢而且只能定位到出现NaN的op不一定能告诉你“为什么”。我自己的调试方式是给模型的关键层挂hook逐层打印输出张量的mean/std/max/min。代码很简单核心思路是这样def debug_print(name): def hook(module, input, output): if isinstance(output, torch.Tensor): print(f{name}: mean{output.mean():.6f}, std{output.std():.6f}, fmax{output.max():.6f}, min{output.min():.6f}) return hook for name, module in model.named_modules(): if isinstance(module, (torch.nn.Linear, torch.nn.Conv2d, torch.nn.LayerNorm)): module.register_forward_hook(debug_print(name))挂上hook之后配合之前保存的异常batch数据重新前向一次很快就能看出数值是从哪一层开始跑偏的。比如前几层的输出还很正常到某个Linear层max直接变成1e8那问题就定位在这一层及其之前的计算链路。加一句定位到爆炸层之后重点看它的上游而不是它本身。一层线性层的输出范围是由输入和权重共同决定的输入如果来自上一个Transformer block的LayerNorm那上游任何一层激活值范围变化都会传导过来。这也是为什么我倾向于说“第一根稻草”而不说是“元凶”。3.3 修复方案的优先级与组合拳定位之后修复不能只做一步就完事要按优先级打组合拳。我按成功率从高到低排序第一回滚到最近一个好的checkpoint。如果loss暴增发生在第80个epoch第78个epoch的权重还是好的优先回滚。不要试图在已经污染的权重上继续训练哪怕你修复了bug污染后的权重也会影响后续收敛。第二把学习率降一到两个数量级。不管暴增原因是优化器调度还是数据在回滚后先用小学习率跑几百step观察loss能不能回到暴增前的水平。如果小学习率下曲线稳定那就说明原学习率在收敛期确实过大需要调整scheduler。第三检查模型状态开关。确认model.train()和model.eval()有没有被正确切换EMA权重和BN统计量是否匹配。我遇到过很多次问题就出在一次eval之后的漏网之鱼。第四开启梯度裁剪并选择合适的阈值。梯度裁剪通常放在optimizer.step()前阈值可以按历史梯度的P99来设置而不是拍脑袋定1.0。第五将混合精度切回FP32做对照实验。如果FP32下一切正常那就说明是FP16的数值问题考虑给loss scaler调参或针对敏感计算部分强制用FP32。下面这个表格基本可以当速查用了怀疑方向快速验证方法推荐修复动作数据异常找到暴增step对应的batch单独跑一遍前向清洗脏数据修正标签增强shuffle学习率/Scheduler打印每个step实际learning rate修正调度触发方式降低初始lrBN统计量污染检查eval/train切换对比running_mean在eval后恢复train状态重建BN统计量EMA权重不同步保存EMA权重后单独验证恢复train模式重新run一个epoch校准BNFP16数值溢出切FP32复现看是否消失修正loss数值计算调整scaler局部用FP323.4 在训练循环里植入自动监控最后这一步是我建议每个人都加到训练脚本里的在训练循环里加一个简单的loss监控逻辑一旦loss超过某个阈值或者出现NaN/Inf就自动做premature stop而不是继续硬跑下去。这样不仅省算力还能保证在论坛里问问题的时候你能提供给别人的是一个“干净的事故现场”而不是一段已经跑到NaN之后几千step的脏日志。if not math.isfinite(loss.item()): print(f[DEBUG] step {step} lossNaN/Inf, saving checkpoint...) torch.save({ step: step, model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict() if scheduler else None, }, debug_crash.pt) raise SystemExit(1)这个监控逻辑本身花不了10分钟但对后面定位问题帮助巨大。4. 从训练策略层面避免“临门一脚”翻车4.1 EMA与BN统计量两个必须同步的“角色”刚才提过EMA和BN的问题这里我想多写一段因为它在实际项目里实在太常见了。用EMA做模型参数的指数滑动平均本质上是让模型权重变得更平缓、更泛化但BN层里的running_mean和running_var是按普通权重实时累积的。当你把EMA权重复制回模型时如果只是把它当作初始化权重去预测或继续训练整个模型就处在一种“权重是历史的统计量是当前的”错位状态。这种错位在训练后期几乎必然导致loss飙升。我建议的做法是每次要保存EMA模型前先把EMA权重复制进模型然后用训练集的一小部分数据重新跑一遍前向将BN的running统计量更新到与EMA权重匹配再保存。这个操作有时也叫“BN重估”或“统计量重对齐”。如果只是自己用也可以在训练中不用EMA直接用普通模型权重。EMA带来的收敛稳定性和泛化提升在有些任务上很香但在小规模数据集上它带来的管理成本其实远超收益两相权衡你可以自己决定。4.2 分布式训练、多卡与批量大小调整的隐藏坑导致快收敛期loss暴增的另一个工程因素是分布式训练环境。单卡上跑得好好的程序上了多卡之后突然在后期崩溃这种事一点都不罕见。问题通常出在批量大小变化带来的学习率适配。如果你从单卡batch 32切到4卡batch 128那么同等学习率下梯度更新的“等效步长”变大了。如果不按比例提高学习率模型收敛速度会变慢但如果提高学习率时不注意上限快收敛阶段一个异常batch就会把参数踢飞。更好的策略是先用单卡或者较小batch做一轮调试确认曲线稳定后再上多卡并做一次短期的稳定性验证。另外多卡训练时BN层默认每个卡单独维护统计量跨卡同步需要额外的SyncBN。如果模型对batch内的统计量很敏感各卡的数据分布又不一样那么统计量之间会产生“打架”到训练后期就会以loss波动或暴增的形式显现出来。用SyncBN或者GlobalBatchNorm可以缓解。4.3 损失函数与正则化的“收敛期摩擦”最后这条说的是loss函数本身和正则化之间的相互作用。focal loss这类不平衡损失在模型刚开始训练时因为预测概率低(1-p)^γ能很好地提升困难样本的权重但当模型收敛、绝大多数样本预测概率都贴近1时困难样本的权重会被放大到非常夸张的程度几个离群样本的梯度就可能盖过整个batch的梯度。所以在快收敛时focal loss的γ参数反而要回调或者逐步退化成普通的交叉熵。logit adjustment loss也有类似的摩擦它会给类别先验的logit加偏置让模型在训练时不过度依赖先验。收敛期如果模型已经把训练集做得非常好这个偏置会导致训练loss和验证准确率之间出现“剪刀差”看起来loss下不去或者突然反弹但其实模型在验证集上并没有变差。区分“loss暴增”和“指标准确率反向走”是调试这类问题的重要前提。正则化也是同一类。权重衰减、标签平滑、dropout这些手段在收敛期的“副作用”会被放大标签平滑相当于给正确标签的置信度设了一个上限它会阻止训练loss降到极小值如果你在某个epoch之后突然调高了它的强度那loss一定会在下一个epoch有一个明显的跳升。这是预期行为不是bug但需要你区分清楚。5. 三个真实翻车案例复盘5.1 BERT微调scheduler触发时机货不对板我第一次被loss暴增坑得最惨的就是一个文本分类任务。用roberta中文预训练模型做微调前面20个epoch曲线非常漂亮loss从4.2一路降到0.3眼看要收敛了。第21个epoch刚开始loss直接跳到7.8然后在6.0附近震荡完全下不来。当时第一反应是代码被改坏了但回滚也没用。后来在训练脚本里把每个step的实际学习率打印出来才发现我的scheduler是按step触发的而warmup用的是一个按epoch计算的逻辑。warmup结束后scheduler以为还在warmup中间把学习率从1e-4重新拉回了0附近然后又猛弹回去导致一个强烈的学习率尖峰。修复方式很简单把scheduler统一成按step触发并把warmup的步数换算成真实的step数。但这一个bug我印象中折腾了两天。5.2 YOLO系列训练eval切换污染了BN统计量另一个案例是yolo11训练自己的检测模型。现象非常规律每个epoch结束后的val阶段模型都会做一次eval然后继续训练。前面几个epoch完全正常到第60个epoch左右每次eval结束后的第一个训练steploss都会出现一个尖峰然后慢慢降下来下一轮eval又重复。整体loss曲线看起来像梳子一样。原因是模型在eval之后没有正确切回train模式。BN的running_mean和running_var在这期间接收了验证集的统计信息和训练集的统计信息发生混叠。训练前几个epoch时BN统计量更新得快污染很快被冲掉到了后期BN统计量几乎不再变化污染就会持续很久表现为每次尖峰之后需要几十个step才能恢复。修复方式是保证每一次eval之后显式调用model.train()并在训练循环里验证一下模式切换是否生效。检测这种事情只需要在eval之后打印一行model.training就够了。5.3 ViT情感识别混合精度下的NaN从注意力层开始最后一个案例是人脸情感识别模型用ViT/efficientnetv2这类结构训练时开了AMP混合精度。loss在收敛期突然变成NaN并且只要出现NaN就不再恢复。我把loss重新换成FP32跑完全正常因此判断是16位浮点下的数值溢出。加上log之后发现NaN不是从loss函数开始的而是从Transformer的注意力矩阵开始的。某个batch里Query和Key的点积结果特别大经过softmax时在FP16下分母求和溢出成infNaN就一路传回所有相关梯度。修复方式也简单在注意力分数上乘上缩放因子之后对注意力分数做一个clamp限制最大绝对值或者干脆把注意力计算这部分的输入转回FP32。从那之后我在写Transformer类模型时都会刻意检查softmax的输入范围不能因为整体用了AMP就以为所有计算都安全。这三个案例有一个共同点loss暴增都是结果不是原因。生成问题和解决问题之间真正困难的是找到那个藏在工程细节里的“第一次偏离”。踩过这么多次坑之后我最大的体会是快收敛阶段出现loss暴增大概率不是模型理论上的问题而是工程实现上的问题。与其在loss函数上反复试不如先把“数据、优化器、数值、状态”这四层筛一遍。最后再分享一个小技巧训练脚本里从一开始就同时记录train_loss和val_loss、lr、weight norm这四个量前面两个看趋势后面两个负责在出事时帮你快速锁定期怪范围。这个习惯帮我省下的调试时间绝对比写它花的10分钟多得多。
返回列表