大概每个训练过深度学习模型的人,都见过loss不收敛、震荡甚至突然变nan的场面。我最早被这种场面折磨到半夜,是在跑一个图像分割模型的时候——前20个epoch曲线掉得挺漂亮,突然某一step之后loss变成了nan,整个训练直接崩掉。后来查了一整晚,发现真正的问题并不在神经网络参数,而在loss函数实现和输入数据里的一个极小值。这篇文章把日常训练中与loss相关的麻烦按症状分成三类来盘:loss不收敛、loss震荡、loss出现nan。每类都会给出从现象判断、原因定位到修复手段的完整思路。不管你是刚入门的同学,还是已经带过大模型训练的老手,只要遭遇过这种“训练崩了但报错又没头没尾”的情况,都应该能从里面找到能直接抄作业的排查路径。
1. 先把三个症状分清楚:不收敛、震荡、nan不是一回事
很多人一看到loss不对劲,就急着调学习率、换优化器,甚至怀疑是模型结构设计的锅。我建议先别急着动手,先把问题定性。同样是“看起来不对”,不收敛、震荡、nan背后的数学机制和排查方向差别很大,混在一起处理多半会越调越乱。
下面这张表是我平时帮同事看训练日志时的第一判断依据:
| 症状 | 典型表现 | 直观定性 | 第一排查方向 |
|---|---|---|---|
| 不收敛 | loss长期处于高位,下降极慢或不降;可能停在某个平台期 | 模型没有学到有效信息 | 数据质量、模型结构、loss实现、学习率过小 |
| 震荡 | loss上下反复跳动,噪声很大,可能伴随轻微下降趋势 | 梯度更新方向不一致或数值不稳 | 学习率、batch size、噪声样本、多loss权重 |
| nan / inf | loss突然变成nan或inf,或一开始就是nan | 计算链路里出现了数值断裂 | 梯度爆炸、除零、log(0)、坏数据、fp16溢出 |
先说“不收敛”。它最典型的样子是loss曲线从头到尾横向走,或者下降十几步就趴在一个明显偏高的位置不再动。很多情况下train loss和val loss都不理想,说明模型确实没学会。这事最迷惑人的一点是:它不会让你立刻看到程序报错,训练循环还在跑,一切看起来都“正常”,于是你可能会在参数上反复绕圈。
再说“震荡”。严格意义上震荡不等于不收敛,因为有些模型loss是震荡着震荡着收敛的,后面val指标还在稳步上升。真正的异常震荡有两个特征,一是波动幅度远超正常噪声,二是波动没有随时间衰减的趋势。再加上偶发尖峰,比如某一步loss从0.4突然跳到5.0又恢复,那基本可以断定某个环节出了问题。
最后是“nan”。nan的可怕在于它会彻底杀死训练,而且往往没有任何Python异常,因为NaN在浮点运算里是合法的值。你只会发现loss突然变成了nan,然后参数更新也变成nan,后续所有batch的loss都再也不会恢复。定位nan时,有个前置问题必须马上回答:是从第一个step就nan,还是训练到中途某一步突然nan。这两个方向几乎指向完全不同的原因,前者多半是初始化、数据或loss实现问题,后者多半是梯度爆炸或者异常样本。我见过不少人在这一步就卡住了,因为对着同一个nan曲线用错排查方案,白白浪费半天。
所以,拿到一张异常loss曲线时,先对着这张表定性,再决定往哪条路走。
2. 不收敛:从数据管线到模型梯度的四级排查
不收敛的问题通常不是单一原因,但我习惯按“数据→模型与loss→优化器→训练管线”的顺序排查,也叫从外到内。这个顺序有讲究:数据处理和loss实现是代码里最好查的部分,查完能排除大部分“低级错误”,剩下的精力再花在模型和调参上。
2.1 数据与预处理:很多不收敛其实是数据在使坏
先看数据。我自己的经验是一半以上的不收敛问题,数据都脱不了干系,但很多人一上来就怪模型。
需要检查的几个点:
- 标签有没有明显错误。比如图像分类里类别编号对不上,目标检测的框坐标有负数或超出图像边界。这种样本会让模型非常困惑。
- 特征分布有没有标准化。如果模型输入是向量,某个特征数值在1附近,另一个在1e6量级,MSE loss会被大量级特征完全主导,学习率对小特征来说要么太大要么太小。
- 有没有统计过类别分布。类别严重不均衡时,模型容易直接学成“全预测多数类”,loss平台期很高,看起来就是不收敛。
- 训练集和验证集是否分布漂移。比如训练集全是白天光线,验证集全在夜间,val loss怎么都不会好。
- 数据里有没有空值、全零、损坏文件。这类坏样本经常在某个epoch被抽中,把一个batch的loss拉爆。
实操建议:写一个脚本,对输入张量打印min、max、mean、std,对标签统计分布,再随机抽10个样本人工看一眼。这个动作成本极低,但能过滤掉一大批雷。
2.2 模型与loss实现:先用随机权重算一次初始loss
我排查不收敛问题时,做的第一个数值检查是“初始loss是否合理”。怎么判断?拿一个batch的数据,用随机初始化的模型跑一次前向,不更新参数,打印loss数值。
如果是C分类的分类任务,初始loss应该接近 -log(1/C)。比如10分类,初始loss大约在2.3附近(自然对数)。如果你的模型用了softmax + CrossEntropyLoss,初始值距离这个值很远,说明loss实现、数据标签或者模型输出维度多半有问题。
这个信号太重要了,因为很多人遇到“loss不降”,其实是从一开始loss就是错的。常见问题包括:
- 标签按one-hot做输入,但模型最后一层输出的是raw logits,没走softmax。
- label和logits维度不一致,广播后算出来的loss看着能跑,实则完全不对。
- 自定义loss里某个操作被detach了,梯度传不回去,loss看起来在下降,模型参数却纹丝不动。
接下来做一个“小样本过拟合实验”:只拿32个或64个样本,训练几百步,看train loss能不能降到接近0。如果连这么少的数据都学不进去,基本可以断定是模型结构或loss实现有bug。如果小样本能过拟合,但换到全量数据就不收敛,那问题大概率在数据分布、batch构造或训练策略上。
这个实验我每次都会做,因为它能一刀切开“模型能力问题”和“数据/训练问题”,省掉大量猜测时间。
2.3 优化器与学习率:背锅侠,但确实经常是真凶
学习率是“不收敛”这个话题里被讨论最多的参数,确实很多时候就是它的问题,但它不是唯一问题。判断学习率合不合适,最朴素的办法是拿几个典型的学习率做扫描:
- 1e-1、1e-2、1e-3、1e-4、1e-5,各自只跑200到500步,记录loss变化。
- 如果loss一路发散或剧烈震荡,学习率过大。
- 如果loss下降极慢,几乎看不出来,学习率过小。
- 如果loss能稳定下降,再用这个量级附近的参数跑正式训练。
需要提醒的是,Adam和SGD对学习率的敏感程度完全不同。Adam本身有自适应步长,学习率设在1e-3附近通常能跑出一个不错的baseline;SGD则要配合momentum,典型起始学习率在0.01到0.1之间,还要规划好lr schedule。很多“不收敛”其实是拿SGD用了一个只适合Adam的小学习率,导致训练半天loss一点都不动。
还有一个容易忽略的点:weight decay不能设太大。有时候你在分类任务上把weight decay拉到0.1,模型欠拟合,loss卡在一个不低的位置。递减实验时,可以单独把weight decay设成0跑一遍,对比loss曲线的差异。
优化器状态也值得看一眼。如果你从checkpoint恢复训练,但只加载了model权重,optimizer的momentum和scheduler的step没恢复,学习率可能突然回到初始值,曲线一下就乱套了。
2.4 训练管线与隐蔽bug:shuffle被关了都不知道
训练管线里的bug最让人头大,因为它们不报错,只是让结果悄悄变差。我列几个自己踩过或帮人查过的典型:
- dataloader忘记设shuffle=True,每个epoch都在按同一顺序喂数据,模型学到的是数据顺序,而不是特征规律,loss后期基本不降。
- 误把模型切成了eval()模式,BN、Dropout全部失去作用,训练过程变得很奇怪。
- 训练时忘了model.train(),或者有某个模块的requires_grad被意外设为False。
- 把没有打乱的数据划分成了train和val,两者分布完全不一样,val loss长期不降。
- resume训练时丢了scheduler的last_epoch,学习率从起始位置重新开始,后段训练看不到效果。
其中shuffle这个坑我印象最深。那是我帮一个同事排查“模型不收敛”的经历,他的训练loss前几个epoch还降一点,后面就完全平了。我随手看了一眼dataloader的配置,发现shuffle=False,而且他的数据集恰好按类别顺序排列。模型在每个batch里只能看到同一类样本,梯度更新方向被严重带偏。换成shuffle=True之后,同样参数下loss很快就正常下降了。这种隐蔽问题,只看loss曲线很难想到。
所以排查不收敛时,不要只盯着模型和优化器,把整个训练管线从头到尾读一遍,尤其注意数据加载、shuffle、eval/train切换、resume这几个位置。低级bug往往比高级玄学更常见。
3. 震荡:除了学习率,还有一批“隐形凶手”
loss震荡这个事,很多人第一反应是学习率太大。对,学习率确实是最常见的原因,但远不是全部。我在实际训练里发现,震荡往往是一组因素共同造成的,只调学习率可能压下去,但模型的最终指标也跟着变差。
3.1 先看震荡长什么样:不同形态指向完全不同的问题
我习惯把震荡分成四种形态,处理动作完全不同:
| 震荡形态 | 曲线特征 | 主要嫌疑 | 处理思路 |
|---|---|---|---|
| 初期噪声型 | 前几百步波动大,后期波动明显减小 | 随机梯度本身噪声,正常现象 | 不需要处理 |
| 全程大振幅 | 从头到尾都在剧烈跳动,下降趋势很弱 | 学习率过大、batch过小、样本方差大 | 调lr、加batch或梯度累积 |
| 周期波浪型 | loss像波浪一样起伏,有固定周期 | scheduler、数据顺序、定期eval干扰 | 排查周期来源 |
| 偶发尖峰型 | 大部分时间正常,偶尔一个batch突然飙升 | 坏数据、梯度爆炸、数值溢出 | 定位具体异常step和样本 |
“周期波浪型”比较有意思。如果你每个epoch都做一次验证,val loss那条曲线很容易出现周期起伏,因为train loss和val loss交替波动是正常的。但如果你看到的是train loss本身按固定周期上下,那就要怀疑是不是数据没有shuffle、scheduler的lr在每个epoch结尾骤变,或者日志统计窗口刚好和数据顺序撞上了。
“偶发尖峰型”则要格外警惕。一个batch的loss突然从0.5跑到8,后面又回到0.5,这种通常意味着某个batch里混入了夸张的坏样本,或者梯度的某个维度在这一步爆炸了。如果这种尖峰出现多次,即便不直接造成nan,长期看也会把模型参数推到不稳定的地方。
3.2 比学习率更值得怀疑的几个因素
我先说一个反直觉的点:很多时候你把学习率调低了,震荡确实是减轻了,但与此同时模型也学不动了。这种“牺牲收敛速度换平滑曲线”的做法,底层原因可能根本不是学习率。
batch size太小是高频原因。随机梯度的方差和batch size成反比,batch越小,每次更新的方向越像布朗运动。一个很典型的现场是:单卡跑小batch(比如8或16),loss曲线抖得像心电图;换到32或64之后,平滑程度明显好了。如果你硬件不允许调大batch,可以用梯度累积,等效增大batch size。
label噪声同样会让loss剧烈震荡。少数标注错误样本的loss非常大,它在梯度里占的权重远远超过正常样本,于是那个step的更新方向被带偏。对这种问题,适度的label smoothing能明显缓解震荡,比单纯调小学习率有效得多。
样本难度方差大也是隐形凶手。拿目标检测举例,有些图片里有几百个目标,有的只有两个,每个batch统计出来的loss天然会忽大忽小。这不是训练坏了,而是loss统计口径没考虑样本复杂度。要判断是不是这个原因,可以看相同epoch下,去掉那些特别大的loss样本后曲线是否平滑。
多loss累加时的权重也时常被忽略。很多模型是主loss加几个辅助loss,辅助loss如果权重给得过高,它的高频波动会污染整体曲线。我之前见过一个模型,主要任务收敛挺好,辅助任务loss权重设成了1.0,结果总loss里辅助loss的噪声把主干梯度完全掩盖了,整体震荡得很厉害。把辅助loss权重降到0.1之后,总曲线立刻正常。
3.3 让震荡变可控的调试动作
面对震荡,我建议按下面几个动作依次操作,每一步都观察曲线变化再决定是否继续:
- 先把原始loss做EMA平滑,再画曲线。batch级别的噪声会干扰判断,EMA之后的趋势才是真正的收敛趋势。很多人看着原始曲线慌了,其实EMA之后趋势很清楚。
- 记录梯度的范数(grad norm),每个step打一次。如果震荡和grad norm的方差高度相关,说明问题在梯度估计上,优先调batch和学习率。
- 加学习率warmup。尤其对transformer和深层模型,前几百步的warmup能压住早期严重震荡。这不算调参玄学,而是让模型先从“小步慢走”开始,避免前期梯度方向乱撞。
- 尝试调大batch size或用梯度累积。等batch size变大后,噪声降低,很多震荡自然消失。
- 看val指标,而不是只看loss。如果val metric在稳步上升,只是train loss有点抖,完全不用管。训练的目标是最终指标,不是让loss曲线好看。
这里要强调一句:千万不要为了“消震荡”而乱调参。震荡有可能是正常随机梯度噪声,只要整体趋势向下、最终指标在变好,就让它去震。强行把学习率压到极低,换来一条很平的loss曲线,但模型学得又慢又差,得不偿失。
4. nan:先在计算图上找出数值链路断点
loss变nan是我认为唯一需要“抢救式处理”的问题,因为一旦出现nan,之后的训练基本作废,必须先修复再继续。好在nan的成因比不收敛更收敛,它几乎总是数值计算链路某处断了。
4.1 nan在数学上到底是怎么来的
nan不是凭空出现的,它一定来自某种非法数值操作。我把常见的来源列成一张表,排查时对号入座:
| 数学原因 | 触发场景 | 典型修复 |
|---|---|---|
| 梯度爆炸 | 深层网络、RNN/LSTM、长链反向传播 | 梯度裁剪、降低lr、增加残差连接 |
| 除零 | 自定义Dice loss / IoU loss分母为0 | 分母加eps,对输入做clamp |
| log(0) | 概率预测值被钳到0,CE取对数得-inf | 概率加小常数或clip到[1e-7, 1-1e-7] |
| 溢出 | exp大数、fp16训练、sigmoid极端输入 | 先关AMP或调整loss scale |
| 坏数据 | 输入特征或标签里本来就有nan / inf | 数据清洗、加载时过滤非法值 |
| 初始化不良 | 激活输出过饱和、梯度消失 | 换Xavier/Kaiming初始化,加深LN/BN |
最常见的组合拳是:某个step梯度爆炸,参数瞬间变成很大的值,下一层输入直接溢出成inf,再经过一次除法或log就变成nan。这种nan一旦出现,就再也回不去了,所以定位时最好能抓住“第一次出现的瞬间”。
4.2 定位nan的标准动作:先把出问题的位置压到最小范围
定位nan要尽快缩小范围,我的顺序如下:
首先,打开框架自带的数值检查开关。PyTorch可以在代码开头加一行:
torch.autograd.set_detect_anomaly(True)TensorFlow则用:
tf.debugging.enable_check_numerics()开启之后,程序会在反向传播遇到非法值的第一时间抛出异常和堆栈,直接告诉你是哪个算子出了问题。这一步往往能省掉一半排查时间,可惜很多同学不知道有这个开关。
其次,回答“一开始就nan”还是“中途某一步突然nan”。前者不需要等训练日志,直接在第一个step打断点看forward输出;后者需要定位是哪个batch触发的,处理方法是在训练循环里每N步记录一下batch index和loss值。
再次,分开前向和后向。在loss.backward()之前打印loss值,如果loss本身已经是nan,那就看forward路径;如果loss正常、backward之后参数变成nan,那就是反向传播中梯度爆了。这个二元判断能进一步缩小范围。
最后,在forward里逐段打印中间张量的min/max。从输入开始,按顺序打印每一层输出的数值范围,第一个出现nan或inf的位置就是断裂点。实际遇到的情况中,断裂点不一定在模型最深处,反而常在loss函数里,比如某个sqrt的输入因为前一层输出越界变成了负数。
4.3 修复手段按优先级尝试:从低成本到高成本
定位到原因后,修复手段要分优先级,不要一上来就重构模型。
第一优先,过滤坏数据。如果输入里查出了nan或inf,把对应样本剔除或替换,这是最彻底的办法。数据清洗优先级永远高于任何训练技巧。
第二优先,修正loss实现。在log、sqrt、除法等脆弱操作里加一个安全范围,比如:
loss = -torch.log(torch.clamp(pred, 1e-7, 1 - 1e-7))又比如Dice loss的分母加一个eps:
dice = (2 * intersection + 1e-5) / (union + 1e-5)这个1e-5不是随便填的,它保证分母永远不会为0,同时不会对正常量级的数值产生可感知的影响。
第三优先,降低学习率。如果nan出现在训练中段,且之前几十步都正常,降低lr到原来的1/10常常能躲开那个导致梯度过大的step。这个方法成本极低,可以马上验证。
第四优先,梯度裁剪。如果确认是梯度爆炸,在backward之后、optimizer.step之前加一条:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)max_norm可以按经验在0.5到10之间调。它的作用是限制参数更新的最大步长,不让单步更新把模型推到nan区域。
第五优先,处理混合精度问题。如果你开了AMP,fp16的数值范围有限,很容易在loss缩放系数或者梯度累加时溢出。建议先关掉AMP,用纯fp32跑一遍确认是否复现;如果fp32正常而fp16出nan,问题就在amp的loss scale策略,可以调整初始化scale或增加动态scale的更新频率来解决。
4.4 一个分割模型的nan排查案例
讲一个印象最深的实际案例。那次是训练一个医学图像分割模型,前10个epoch的loss下降很平稳,到第11个epoch的某个step,loss突然变nan。我打开set_detect_anomaly(True)之后,异常栈指向了自定义的Dice loss反向传播。
逐层检查后发现,模型最后一层输出的是raw logits,没有经过sigmoid。Dice loss计算时,公式里有一步对分子取平方再开根号,也就是sqrt操作。logits里有部分像素的数值是负数,经过一系列运算后被开方,负数的平方根在浮点数里直接产生nan。也就是说,这个nan并不在模型深处,而藏在loss函数里一个看起来很不起眼的开方操作上。
修复方法是两件事叠加:最后一层补上sigmoid,把输出约束到(0,1)区间;同时在loss内部对预测值做clamp,防止极端情况再次出现。改完重新训练,整个训练过程再不出现nan。
这个案例给我的启发是:遇到nan不要一头扎进模型结构里找原因,先看看loss函数里有没有“天然容易产生非法值”的操作。很多自定义loss写的时候很顺手,但log、sqrt、除法都是高危操作,数值稍微越界就当场爆炸。
5. 一套够用的排查清单和一些长期有效的训练习惯
最后给一份可以直接照抄的排查清单。我平时遇到loss异常,基本按这个顺序走,大部分问题都能在十到二十分钟内定位。
5.1 十分钟快速排查清单
- 固定随机种子,保证问题可复现。
- 用小样本集(32到64个)做一个过拟合实验,判断模型本身能不能学。
- 用随机权重打印初始loss,和理论值对比。
- 开启detect_anomaly或check_numerics,抓第一个异常位置。
- 打印输入数据的min/max/mean/std,确认没有坏数据。
- 在forward里逐层打印中间张量,找到数值出现inf或nan的具体层。
- 在loss.backward()前后分别打印loss和grad norm,区分前向问题还是反向问题。
- 如果指向优化相关,按1e-1、1e-2、1e-3、1e-4、1e-5扫描学习率。
- 确认batch size、shuffle、train/eval模式、resume状态没有问题。
- 加上梯度裁剪和EMA平滑loss,继续观察曲线。
这套清单覆盖了从数据到模型再到训练策略的完整链路,且每一级都能快速验证。不建议跳级,因为很多问题看起来像模型问题,实际原因是数据问题;反过来也一样。
5.2 让下次排查更快的一些训练习惯
除了上面这些一次性排查手段,我更想强调几个能长期省时间的习惯。
第一,从训练流程搭好的第一天起,就把loss曲线、val metric、学习率、grad norm这四个量全部记录到同一个日志系统里。很多人只记录loss和metric,出了问题才发现没有grad norm日志,没法判断“是否梯度爆炸导致震荡”。多记一个量,复盘时就能少猜一大半。
第二,训练刚启动时不要直接挑战大模型、大数据集。先用小模型、小数据跑通一条完整管线,确认loss能下降、val能评估、checkpoint能保存,再逐步放大。这个习惯可以避免把“管线bug”和“模型不收敛”混在一起,排查难度会小很多。
第三,任何调参动作之前,先固定随机种子并复现当前结果。否则你改了一行代码,训练结果变化了,你根本不知道是代码改动导致的,还是纯粹运气好。固定seed之后,每次改动都能形成明确对照。
第四,设置一个“每隔N步打印中间张量min/max”的钩子函数,作为常规日志的一部分。不需要每步都开,但训练早期开着它跑几百步,可以帮你积累一份“正常数值范围”的基线。后面再出现nan时,和这份基线一比,立刻就能看出是哪一层数值失控。
这些习惯不能直接解决某个specific bug,但能让整个排查过程从“到处猜”变成“照单查”。我在实际项目中收获非常大,尤其是grad norm日志,几乎每次排查异常loss都会用到。
最后说一点个人体会。模型训练里遇到的loss异常,绝大多数不是玄学,而是代码和数据里某个可以被定位的具体问题。真正难的不是修复,而是别在错误的排查方向上浪费太多时间。先把症状分类,再按链路逐级定位,再合理选择修复手段,这套流程比任何单一的“调参技巧”都更可靠。下次再看到不收敛、震荡或nan时,不妨按这个思路走一遍,大概率能省下你大半个晚上。