nn.CrossEntropyLoss()大概是所有 PyTorch 训练脚本里出现频率最高的一个类,也是被误解得最厉害的一个。我记得第一次认真读别人的训练代码时,看到模型的最后一层没有接 softmax,直接把输出丢进CrossEntropyLoss,当时的第一反应是"这不是漏了一步吗"——课本里明明写的是先 softmax 再算交叉熵。后来才明白,这个类内部已经把两步合并了,而且合并的方式不是简单的串联,而是做了数值上的变形。这件事看着很小,但它背后牵出来的一连串问题,包括输入该是什么形状、target 该是什么类型、为什么 loss 一开始应该接近 ln(C)、为什么类别不平衡时加了 weight 之后 loss 曲线形状会变,才是真正决定你训练能不能跑通的东西。这篇内容面向的是已经能跑通一个分类网络、但想搞清楚这个 loss 到底在干什么的人,也适合正在被各种维度报错和 NaN 折磨的读者。我会从它的数学构成讲起,一路讲到参数怎么用、报错怎么查、以及什么时候该换成别的损失函数,尽量把每一步"为什么这么做"都说清楚。
1. 拆开看:这个类内部到底做了几步运算
1.1 它不是你课本上那个"先 softmax 再算交叉熵"的字面实现
教科书里的交叉熵写法是先对 logits 做 softmax 得到概率 p,再取目标类别的概率 p_y,最后算 -log(p_y)。如果照着这个思路在代码里写两层,中间那一步 softmax 会产出一组概率值,这组值本身是有精度损失的。当 logits 里出现一个明显大的数时,exp()的结果会迅速膨胀,比如 logits 里有 100,exp(100)大约是 2.7e43,虽然还能表示,但几个这样的数相加之后就接近溢出边界了。更麻烦的是,如果 logits 里有一个特别大的正值和一个特别小的负值,softmax 之后小类别的概率会被压成 0,取 log 就得到负无穷。
nn.CrossEntropyLoss()内部走的是另一条路:先把 logits 平移,减去这一行里的最大值,然后再做 exp 和 log,最后用 log 概率去取目标位置。数学上结果完全等价,工程上避免了溢出。这就是为什么它要求你传入的是原始 logits,而不是已经 softmax 过的概率。你传 logits,它自己去做那个平移;你传概率,它就等于对概率又做了一次 log_softmax,整个损失的尺度就乱了。
所以第一件必须记住的事是:模型的最后一层不要加 softmax。这不是风格问题,是正确性问题。加了 softmax 之后代码不会报错,loss 照样能算出来,只是会偏小、梯度会变弱,模型学得特别慢,而你大概率会误以为是学习率没调好。
1.2 LogSoftmax 加 NLLLoss 的等价写法
PyTorch 里有一个更"原教旨"的组合,叫nn.NLLLoss(),它只负责"按 target 索引取出对应的 log 概率再取负号求平均",前面那一步 log_softmax 需要你自己做。nn.CrossEntropyLoss()本质上是nn.LogSoftmax()和nn.NLLLoss()的封装,把两步合成一个算子,减少中间张量的内存占用,也让数值处理集中在一处。
这个等价关系非常有用,因为你在排查问题的时候可以把它拆开验证:
import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(0) logits = torch.randn(4, 5) # 4 个样本,5 个类别 target = torch.tensor([0, 3, 2, 4]) ce = nn.CrossEntropyLoss()(logits, target) manual = F.nll_loss(F.log_softmax(logits, dim=1), target) print(ce.item(), manual.item()) # 两个值应当完全一致把这两行打印出来对比,是确认自己理解没有偏差最快的办法。我在带新人的时候通常会让对方先跑一遍这个对照,比讲十分钟公式管用。
1.3 为什么梯度形式反而更简单
从求导的角度看,交叉熵配合 softmax 有一个非常漂亮的性质:对 logits 求导的结果就是softmax(logits) - one_hot(target)。也就是说,梯度只跟预测分布和真实标签的差有关,跟 log 那一层没有关系——log 的导数恰好把 softmax 里的分母项抵消掉了。这也是为什么把两步合并之后,反向传播的计算量并没有增加,反而少了一次中间结果的存储。
在reduction='mean'且不带 weight 的情况下,实际梯度还要再除以 batch size N。这一点在做梯度累积或者自定义缩放的时候要注意:如果你手动把 batch 拆成几份分别算 loss 再相加,得到的梯度量级会和不拆分时不一致,通常需要除以累积步数。
理解了这个梯度形式,很多现象就顺理成章了。比如训练初期模型输出接近均匀分布,此时softmax每一项约等于 1/C,梯度大小大致和1/C同量级,所以类别数越多,初始阶段每个参数的更新幅度就越小,这也是为什么 C 很大的任务往往需要更大的学习率或者更长的预热。
2. 输入契约:形状、类型、target 编码方式
2.1 二维输入与高维输入的形状约定
这个类对输入形状的要求属于"规则简单但报错不友好"的典型。基本规则是:输入的第一个维度是 batch,第二个维度是类别数 C,后面的维度都是空间维度。target 的形状则是把输入里的类别维去掉之后剩下的部分。
| 输入形状 | 场景 | target 形状 | target 元素取值范围 |
|---|---|---|---|
| (N, C) | 普通分类 | (N,) | 0 到 C-1 |
| (C,) | 单样本无 batch | () 标量 | 0 到 C-1 |
| (N, C, d1) | 一维序列逐位置分类 | (N, d1) | 0 到 C-1 |
| (N, C, H, W) | 语义分割 | (N, H, W) | 0 到 C-1 或 ignore_index |
这张表值得贴在显示器边上。最常见的翻车场景是:输入是 (N, C, 1, 1),target 是 (N,),代码直接报维度不匹配。原因是模型输出保留了 1×1 的空间维,而 target 没有。处理办法要么在模型里加一个flatten(1)或者squeeze,要么把 targetunsqueeze(-1).unsqueeze(-1),我更推荐前者,因为让模型输出干净的 (N, C) 更符合直觉,也省得后面接别的模块时再改一次。
分割任务的坑则反过来:输入是 (N, C, H, W),target 是 (N, H, W),很多人在数据加载阶段把 mask 做成了 (N, 1, H, W),于是报错。这类错误信息通常长这样:Expected target size [N, H, W], got [N, 1, H, W]。看到这个就把 mask 的通道维去掉即可。
2.2 target 必须是类别索引,不能是 one-hot
这是新手最容易卡住的一点。nn.CrossEntropyLoss()的 target 参数类型是LongTensor,里面存的是类别编号,不是概率分布,也不是 one-hot 向量。如果你手上是 one-hot 编码的标签,需要先转成索引:
one_hot = torch.tensor([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) target = one_hot.argmax(dim=1) # tensor([0, 1, 2])如果直接把 one-hot 传进去,会得到一个看起来很奇怪但很好定位的报错,大意是 target 的数据类型应该是 Long 而拿到的是 Float,或者维度对不上。这两种报错其实指向同一件事:你在用错编码。
还有一种情况是标签存储成了 float,比如从 CSV 读进来默认是 float64。这时候需要显式.long(),光靠.to(device)是不会改类型的。我见过有项目因为这个,在 GPU 上训练了几分钟才发现 loss 一直是 nan,排查了半天以为是混合精度的问题。
另外要注意 target 的取值范围必须落在[0, C-1]内,越界会触发Target X is out of bounds for 5 classes这类报错。分割任务里如果 mask 标注时用了 255 表示忽略区域,而你忘了设置ignore_index=255,就会直接撞上这个错误。
2.3 一张报错对照表
排查这类问题的时候,报错信息本身信息量其实很大,只是需要翻译一下。我整理了一张对照表,基本覆盖了日常会遇到的情况:
| 报错关键字 | 真实原因 | 处理方式 |
|---|---|---|
expected scalar type Long but found Float | target 没转成整型 | target.long() |
Target X is out of bounds for Y classes | 标签越界,或未设 ignore_index | 检查标签范围或补 ignore_index |
Expected target size [...], got [...] | 空间维度没对齐 | 去掉 target 多余通道或 flatten 输出 |
Expected input batch_size (...) to match target batch_size (...) | 输入被错误 reshape,batch 维错位 | 检查模型里最后一次 view/reshape |
| 输出全是 nan | logits 本身有 nan 或 inf | 检查前面层,而不是 loss |
最后一行那条尤其值得强调:CrossEntropyLoss本身几乎不会产生 nan,它出现 nan 基本意味着传进来的 logits 已经有 nan 了。把 logits 打出来看有没有异常值是排查 nan 的第一步,而不是去改 loss 的参数。
3. 四个参数不只是可选项:weight、ignore_index、reduction、label_smoothing
3.1 weight 怎么用,以及它在 mean 模式下改变的分母
类别不平衡是分类任务的老问题。假设一个 100 类的数据集,头部类别占了 60% 的样本,尾部类别只有几十条。直接训练的结果是模型倾向于全predict头部类,整体准确率看着还行,但尾部类别的召回率惨不忍睹。这时候就可以给weight传一个长度为 C 的张量,每个位置是该类别的权重。
权重的常见取法有两种:一种是按频率取倒数再归一化,一种是取频率的平方根倒数。前者补偿力度大,容易导致训练震荡;后者更温和,实践里我更常用。代码大致是:
counts = torch.bincount(train_labels, minlength=num_classes).float() weight = (counts.sum() / counts).pow(0.5) weight = weight / weight.mean() # 让权重均值为 1,方便沿用原学习率 criterion = nn.CrossEntropyLoss(weight=weight.to(device))这里有个非常容易被忽略的细节:当reduction='mean'且传入 weight 时,PyTorch 的归一化分母不是 batch size,而是当前 batch 内所有样本权重之和。也就是说损失值是按"加权平均"算的,而不是"加权求和再除以 N"。后果是:如果一个小 batch 里恰好全是低权重类别的样本,loss 数值会比预期小很多,你会看到 loss 曲线出现剧烈的锯齿。
知道这一点之后,很多现象就解释得通了。比如加了 weight 之后发现 loss 从 2.3 掉到了 0.8,别急着高兴,这可能只是归一化方式变了,模型并没有突然变强。想比较前后效果,最好看验证集上的指标,而不是训练 loss 的绝对值。
3.2 ignore_index 在分割和 padding 场景里的作用
ignore_index的默认值是 -100,这个数字不是随便选的,历史原因大概跟早期的实现约定有关。它的语义是:target 中等于这个值的所有位置,既不参与损失计算,也不参与均值分母的统计。
语义分割里几乎所有项目都会用到它。数据标注时,边界区域常常无法确定归属,通常用 255 或者 0 标记为忽略。这时设置ignore_index=255,这部分的 loss 就完全被排除,不会给梯度带来噪声。序列任务里做 padding 时同理,把补齐位置设成 -100 或者指定的值,可以避免模型被大量无意义的位置带偏。
有一个细节值得留意:如果你既用了ignore_index又用了weight,那么被忽略的位置权重也不会计入分母。这一点从"既不参与计算也不参与统计"的语义出发是自洽的,但写代码时容易忘。
还有个小坑:如果你的标签体系里恰好有一个合法类别的编号是 -100 或者 255,那就得换个数字,否则会被误当成忽略位。我在一个标签从 0 编号到 256 的项目里见过这个问题,最后是把 ignore_index 改成了一个不可能出现的负数解决。
3.3 reduction 的三种模式决定了拿到的张量长什么样
reduction有三个取值:'none'返回逐样本的损失,形状和 target 一致;'sum'返回所有损失的求和;'mean'返回平均。默认是'mean',这也是绝大多数场景下的选择。
什么时候需要'none'?当你想自己做加权、做筛选、或者做难例挖掘的时候。比如想实现一个简单的 focal 式加权,思路是先拿到每个样本的交叉熵,再乘上一个跟预测置信度相关的系数:
per_sample = F.cross_entropy(logits, target, reduction='none') p_t = torch.exp(-per_sample) # 目标类的预测概率 modulated = ((1 - p_t) ** 2) * per_sample loss = modulated.mean()这段代码的本质是把交叉熵拆开再重组。需要注意的是,这种情况下梯度缩放和默认行为不同,学习率可能需要相应调整。
什么时候用'sum'?一种典型场景是你要跨不同 batch 累积梯度,并且希望最终结果等价于大 batch 训练。这时候要用'sum'除以总样本数,而不是每个 batch 各自用'mean'再平均——后者在 batch 大小不一致时会引入偏差。
3.4 label_smoothing:一个便宜但有代价的正则
label_smoothing是较新版本加入的参数,取值在 0 到 1 之间。它把原本 one-hot 的标签软化:正确类别的目标概率变成 1-eps,其余 C-1 个类别各分到 eps/(C-1)。直观效果是模型不再被要求把正确类别的概率推到 1,避免了"过度自信"。
它带来的收益在中小数据集上比较明显,特别是标注质量一般、存在若干难分样本的时候。代价是模型输出的置信度普遍偏低,如果你后面要用概率做阈值判断或者做置信度过滤,会发现阈值需要重新标定。
另一个副作用是它和weight的交互不完全正交。软化的标签会把概率质量分摊到所有类别上,包括那些你特意降低权重的类别,所以两者同时使用时效果不一定叠加。
我自己的经验是:数据干净、类别均衡时,label_smoothing取 0.05 到 0.1 是个不错的起点;数据噪声大时可以适当加大,但超过 0.2 之后模型会明显欠拟合,训练 loss 下降变慢,需要配合更长的训练轮数。
4. 我踩过的坑:从 loss 曲线异常到 nan 的完整排查链路
4.1 loss 一开始就不对劲,先算一下 ln(C)
这是我最常用的一个快速体检方法。模型初始化之后、还没有学到任何东西的时候,输出应该接近随机,此时的交叉熵大约等于 ln(C)。C=10 时约 2.30,C=100 时约 4.61,C=1000 时约 6.91。这个数值可以当作一个基准线。
拿到一段训练日志,先看第一个 epoch 的 loss 落在哪里,然后对照下表判断:
| 观测现象 | 大概率原因 |
|---|---|
| 初始 loss 明显大于 ln(C) | 学习率过大,或数据未归一化,logits 数值异常 |
| 初始 loss 明显小于 ln(C),比如只有 0.3 | 模型里重复做了 softmax,或标签泄漏 |
| loss 一直贴着 ln(C) 不动 | 梯度没传回去,或 logits 被 detach,或学习率小到无效 |
| loss 快速掉到 0 | 数据泄漏,或标签与输入直接相关 |
"初始 loss 小于 ln(C)"这一条我特别想说,因为它最难发现。有一次一个图像分类任务,10 个类别,初始 loss 打印出来是 0.42,当时还以为是初始化做得好。后来检查模型定义,发现最后一层后面挂了一个nn.Softmax(dim=1),是复制别人的代码时带过来的。去掉之后初始 loss 回到了 2.28,训练曲线立刻正常了。这个 bug 的危害在于它完全静默:不报错、不崩溃、loss 还在下降,只是模型永远学不到该有的精度。
4.2 手写 softmax 之后又传给 CrossEntropyLoss
这是上一个问题的推广版本,也是社区里被问得最多的一个问题之一。表现形式有两种:一是在模型 forward 的末尾写了 softmax,二是在计算 loss 之前手动对输出做了一次归一化。
排查的方法很简单,在算 loss 之前打印 logits 的几个统计量:
print(logits.min().item(), logits.max().item(), logits.sum(dim=1)[:5])健康的 logits 应该有正有负,每一行的和不为 1。如果发现某一行所有值都落在 [0, 1] 之间且加起来等于 1,那就是被 softmax 过了,直接往上找哪里加的。这个检查我已经养成习惯,在新项目里跑第一次的时候一定会做一遍。
4.3 混合精度下的类型问题
用torch.cuda.amp训练时,autocast 会对cross_entropy做特殊处理,把输入转成 fp32 再算,所以这条路上出问题的概率不大。但如果你用的是reduction='none'然后自己做加权,那一步的中间结果可能落在 fp16 上,累加几十个样本之后精度就不够了。表现是 loss 数值抖动很大,或者小 batch 时出现 inf。
处理办法是把中间计算显式转成 fp32:
with torch.autocast(device_type='cuda', dtype=torch.float16): logits = model(x) per_sample = F.cross_entropy(logits.float(), target, reduction='none') loss = (per_sample * sample_weight.float()).mean()多一次.float()的显存开销可以接受,换来的是数值上不会出岔子。这一点在分割任务里尤其重要,因为分割的 target 元素数量是 N×H×W,动辄几十万,fp16 累加很容易失真。
4.4 标签越界和 ignore_index 的默认值
前面提过一次,但值得单独再讲,因为它的表现很有迷惑性。默认ignore_index=-100,意味着如果你的标签里出现了 -100,这个位置会被静默忽略。有些数据处理流程会用 -1 表示无效标签,这时候不会触发忽略(因为不是 -100),而是会报越界错误。反过来,如果你用 -100 作为无效标记,那它会被自动忽略——这可能是你想要的,也可能是你完全没意识到的。
我的做法是:永远显式写出ignore_index的值,哪怕它和默认值一样。这行代码本身就是一种文档,能省掉后来接手的人(包括半年后的自己)的一次困惑。同理,reduction我也习惯写出来,虽然默认就是'mean'。
5. 选型对照:什么时候该换掉它
5.1 单标签多分类与多标签是两套体系
nn.CrossEntropyLoss()的隐含前提是"每个样本只属于一个类别",它内部的 softmax 会把所有类别的概率归一化到和为 1。如果你的场景是一张图里可能同时出现多个标签,比如一张照片里既有猫又有沙发,那这个前提就不成立了。硬用交叉熵的结果是各个类别互相竞争概率质量,一个类别概率升高会压低其他类别,模型学出来的东西跟你的意图完全对不上。
多标签场景应该用nn.BCEWithLogitsLoss(),它对每个类别独立做 sigmoid 加二元交叉熵,类别之间不竞争。两者的输入形状要求也不同:BCEWithLogitsLoss要求输入和 target 形状完全一致,target 是 0/1 的浮点张量。
这里还有一个常见的误区:有人觉得多标签任务里各类别正负样本比例不同,想给每个类别配不同权重。BCEWithLogitsLoss的pos_weight参数就是干这个的,它只作用在正样本上,跟CrossEntropyLoss的weight语义不一样,不能混用。
5.2 二分类到底用哪个
二分类有两条路都能走通,很多人纠结选哪条。
第一条路是把输出设成 2 维,用CrossEntropyLoss。第二条路是输出设成 1 维,用BCEWithLogitsLoss(配pos_weight处理不平衡)。
| 对比项 | CrossEntropyLoss(2 维输出) | BCEWithLogitsLoss(1 维输出) |
|---|---|---|
| 输出维度 | 2 | 1 |
| 类别不平衡处理 | weight配长度 2 的张量 | pos_weight标量 |
| 多标签扩展 | 不支持 | 天然支持 |
| 概率解释 | softmax 归一化 | 独立 sigmoid |
两条路在数学上几乎等价,实际选择更多看工程习惯。如果这个任务未来有扩展成多标签的可能,直接用BCEWithLogitsLoss省得后面重构。如果就是纯粹的二分类且会长期保持不变,两者随便挑一个,别混着用就行。混用的典型错误是把 1 维输出直接喂给CrossEntropyLoss,会得到一个关于类别数的报错,或者更隐蔽地,把 2 维输出喂给BCEWithLogitsLoss,形状对不上直接报错。
5.3 需要 soft target 或自定义权重时怎么办
有些场景下 target 本身就是一个概率分布,比如知识蒸馏里学生要拟合教师的输出,或者标注本身是多人投票得到的软标签。这时候CrossEntropyLoss用不了,因为它只接受类别索引。
解决方式是手写。核心就两行:
log_probs = F.log_softmax(student_logits, dim=1) loss = -(soft_target * log_probs).sum(dim=1).mean()这段代码的每一步都值得理解:先做 log_softmax 得到对数概率,再跟软标签按位相乘,求和取负。它跟标准的交叉熵在 target 是 one-hot 时完全等价,只是把"按索引取一个值"换成了"和整个分布做内积",适用面更宽。
温度缩放的蒸馏里,还需要把 logits 除以温度 T 再算:
T = 4.0 soft_loss = -(soft_target * F.log_softmax(student_logits / T, dim=1)).sum(1).mean() * (T ** 2)末尾乘 T² 是为了让梯度量级跟标准交叉熵对齐,这个细节在很多蒸馏实现里会被漏掉,漏掉之后学生损失和教师损失的加权系数就得重新调。
6. 手写一遍再对照,是搞懂它最快的办法
6.1 一个和官方行为对齐的实现
理解了原理之后,最好的验证方式是自己写一个版本,然后在各种参数组合下跟官方结果对比。下面这一版覆盖了 weight、ignore_index、reduction 和 label_smoothing:
import torch import torch.nn.functional as F def my_ce(logits, target, weight=None, ignore_index=-100, reduction='mean', label_smoothing=0.0): # logits: (N, C) target: (N,) max_v = logits.max(dim=1, keepdim=True).values shifted = logits - max_v # 防溢出平移 log_probs = shifted - torch.log(torch.exp(shifted).sum(dim=1, keepdim=True)) valid = target != ignore_index safe_target = target.clone() safe_target[~valid] = 0 # 避免越界索引 nll = -log_probs.gather(1, safe_target.unsqueeze(1)).squeeze(1) if label_smoothing > 0: smooth = -log_probs.mean(dim=1) nll = (1 - label_smoothing) * nll + label_smoothing * smooth if weight is not None: w = weight[safe_target] * valid else: w = valid.float() loss = nll * w if reduction == 'none': return loss if reduction == 'sum': return loss.sum() return loss.sum() / w.sum().clamp(min=1e-8)这段代码里有几个刻意保留的细节值得说明。safe_target那一步是为了处理忽略位——如果直接用越界或被忽略的索引去 gather,会取到错误的 log 概率,虽然最后会被权重乘成 0,但中间可能产生 nan。w.sum().clamp(min=1e-8)是为了防止整个 batch 全被忽略时除零。这两处都是实际写代码时会踩到的边角情况。
6.2 对照验证要覆盖哪些组合
写完之后不要只测一个默认情况,至少要跑下面这几组:
torch.manual_seed(42) logits = torch.randn(8, 6, requires_grad=True) target = torch.randint(0, 6, (8,)) target[3] = -100 # 制造忽略位 weight = torch.rand(6) + 0.5 for ls in [0.0, 0.1]: for red in ['none', 'sum', 'mean']: a = F.cross_entropy(logits, target, reduction=red, label_smoothing=ls, ignore_index=-100) b = my_ce(logits.detach(), target, reduction=red, label_smoothing=ls, ignore_index=-100) print(ls, red, (a - b).abs().max().item()) a = F.cross_entropy(logits, target, weight=weight, ignore_index=-100) b = my_ce(logits.detach(), target, weight=weight, ignore_index=-100) print('weight', (a - b).abs().item())如果所有差值都在 1e-6 量级以内,说明实现基本对齐了。这里之所以用logits.detach(),是为了排除自动求导图带来的干扰,纯粹比对前向数值。如果你想连反向也验一遍,可以分别对两个版本调backward(),比较logits.grad,正常情况下差异也应该在这个量级。
我建议每个接触这个 loss 的人都做一次这个练习。做完之后你会发现,之前那些"为什么加了 weight 之后 loss 变小"、"为什么 ignore_index 会影响均值"的疑问,都变成了代码里一目了然的一行除法。
6.3 工程上的几个取舍
自己实现一遍是学习手段,生产环境当然还是用官方的。官方实现在 C++ 层做了融合,前向和反向都是单算子完成,中间不落地完整的 log_probs 张量。在分类头输出维度很大的场景下,这个内存差异非常明显。比如一个 C=50000 的词表分类头,batch 128,如果手动算 log_softmax,中间张量就是 128×50000×4 字节,约 25MB,前向反向加起来占用会翻好几倍。
另一个工程细节是chunk。如果你的类别数特别大,显存吃紧,可以把 logits 沿 batch 维切块,逐块算 loss 再用reduction='sum'累加,最后除以总样本数。这样峰值显存会显著下降,代价是多了几次 kernel 启动。
还有一个容易被忽略的点:分类头前面加Dropout是有效的,但不要加在 loss 前面。有些人会在算 loss 之前对 logits 做 dropout,觉得这样能增加鲁棒性,实际上等于给监督信号加噪声,训练会变得很难收敛。
最后再分享一个我自己的检查习惯。每次接手一个新项目或者改完数据管线,我会在第一个 batch 上做三件事:打印 logits 的 min/max 和行和、打印 target 的唯一值和 dtype、手动算一遍初始 loss 看是不是接近 ln(C)。这三步花不到两分钟,但能拦住我遇到的绝大多数"训练不收敛"问题。真正难的从来不是调参,而是那些静默发生的、不会报错的错误。