第一次同时面对 NLLLoss 和 CrossEntropyLoss 时,我一度以为它们是为不同任务设计的两个函数。查了一圈资料,有人直接说“CrossEntropyLoss 就是 NLLLoss 套了个 LogSoftmax”,也有人着急地在模型里加一个 nn.Softmax 再接 CrossEntropyLoss,结果训了半天不收敛。这篇文章只讲一件事:把这两个名字从数学定义、源码实现、代码实测到常见报错,一条线讲透。看完你会明白,二者在单标签分类任务里计算上几乎等价,但在“输入形态”上有严格区别;你也会知道什么时候该用哪个、哪些坑千万别踩。它适合刚上手 PyTorch、正准备搭第一个分类模型的新人,也适合已经跑通但心里没底的开发者。
先说结论,方便你带着答案去看后面推导:如果你想在分类任务里少想点事,模型最后一层不接任何激活,直接输出 logits,然后交给 CrossEntropyLoss,这是最常见的做法;如果你想把对数概率拿去做解码、手动加权或者调试,那就用 LogSoftmax + NLLLoss。下面我从最底层的数学关系开始拆。
1. 为什么这两个函数经常把新人绕晕
1.1 从一段常见的“代码分歧”说起
我在很多入门群的提问里见过同一种场景:A 同学在模型最后一层加了nn.LogSoftmax(dim=1),后面接nn.NLLLoss();B 同学什么都不加,直接把网络输出丢给nn.CrossEntropyLoss()。两个人都在做三分类,跑出来准确率差不多,于是开始怀疑人生:到底谁才是对的?
答案是两个人都对,因为这两个函数本来就是在做同一件事。举个具体例子:假设网络最后一层对某个样本输出了 logits = [2.0, 1.0, 0.1],真实类别是第 0 类。先算 softmax 概率:exp(2.0)=7.39,exp(1.0)=2.72,exp(0.1)=1.11,分母约 11.22,所以概率约 [0.659, 0.242, 0.099]。CrossEntropyLoss 计算的是 -log(0.659),约等于 0.417。
NLLLoss 呢?它要求输入是对数概率,也就是 log_softmax 的结果:[ln 0.659, ln 0.242, ln 0.099] = [-0.417, -1.419, -2.317]。然后它只做一件事:取真实类别 0 对应的值,再取负,也就是 0.417。一模一样。所以区别不在于“最终算出来的数字”,而在于“谁帮你做了 softmax 取对数这一步”。CrossEntropyLoss 把它藏进了自己肚子,NLLLoss 把这一步留给了你。
1.2 交叉熵和负对数似然到底在算什么
很多教材上来就抛公式,容易把人吓退。我用大白话解释一遍。交叉熵衡量的是两个概率分布之间的差异:H(p, q) = -Σ p_i log q_i。在分类场景里,真实分布 p 通常是一个 one-hot 向量,只有真实类别那一项是 1,其余全是 0。交叉熵一下子就退化成“真实类别对应预测概率的负对数”,也就是 -log q_c。模型训练的目标,就是让正确类别的预测概率越来越大,这个负对数越来越小。
负对数似然的说法也殊途同归。给定一个输入 x,模型给出一个概率分布,样本真实类别是 c,那这个样本的“似然”就是 P(y=c|x),也就是模型给正确类别分配的概率。取负对数得到 -log P(y=c|x),最小化它。所以“最大似然估计”和“最小化交叉熵”,在分类任务里是同一个目标,只是从不同角度切入。
现在你能明白为什么 NLLLoss 要求输入是 log 概率而不是普通概率了。第一,模型最后一层输出的是 logits,它没有被约束在 0 到 1 之间,直接取对数概率是最自然的下游操作;第二,数值稳定性。多个概率连乘很容易下溢成 0,0.99 的一万次方几乎等于 0,而对数空间把乘法变成加法,任何数量级的数值都能安全处理。你可以这么理解:概率是“分数”,log 概率是“分数的对数”,阅卷时直接看分数容易受量级影响,换成对数之后,小的差别也能看得清楚。
2. 拉源码看实现:CrossEntropyLoss内部到底做了什么
2.1 等价拆解:CrossEntropyLoss = LogSoftmax + NLLLoss
PyTorch 官方文档在描述 CrossEntropyLoss 时写得很直白:这个类把 LogSoftmax 和 NLLLoss 组合在了一起。展开看公式:
CrossEntropyLoss 对一个样本的计算是:
loss = -log( exp(x_c) / Σ_j exp(x_j) )
把对数展开:
loss = -x_c + log Σ_j exp(x_j)
现在看 NLLLoss 接在 LogSoftmax 后面会发生什么。LogSoftmax 的输出是:
log_softmax(x)_c = x_c - log Σ_j exp(x_j)
NLLLoss 对这个数取负,得到:
-(x_c - log Σ_j exp(x_j)) = -x_c + log Σ_j exp(x_j)
两个式子在数学上完全相等。所以 CrossEntropyLoss 只是把 LogSoftmax 和 NLLLoss 这两步合并成了一个类,内部仍然走的是同样的计算路径。这里有一个很多新人容易想歪的点:CrossEntropyLoss 内部并不是“先对 logits 做 softmax 得到概率,再取对数”,它直接实现了 log_softmax 的稳定数学形式,相当于把两步并成一步,效率和稳定性都更好。
2.2 NLLLoss 的源码逻辑:只索引,不计算激活
NLLLoss 的英文全称是 Negative Log Likelihood Loss,名字里带着“Log”,但它自己并不做 log 运算。它默认你传进来的就已经是 log 概率了,自己只负责“按 target 索引取值、取负、聚合”。逻辑可以用下面这段伪代码表示:
# 简化版 NLLLoss 计算逻辑:按 target 索引取 log_prob 并取负 loss_per_sample = -log_prob.gather(1, target.view(-1, 1)).squeeze(1) # 之后按 reduction 做 mean / sum也就是说,如果你把普通概率 p 传给 NLLLoss,它计算的是 -p[target],而不是 -log p[target]。这两者的优化目标不一样,数值尺度也不一样,训练出来的模型自然不是“负对数似然”意义的模型。
所以正确的 NLLLoss 用法必须带一个 LogSoftmax 层,或者直接把你手工算好的 log_prob 传进去。这里还要强调一个实现细节:PyTorch 的torch.log_softmax是数值稳定的,它会先减去输入的最大值,再计算 exp、取 log,避免 log(0) 出现无穷。如果你在代码里写成torch.log(torch.softmax(x, dim=1)),表面上看等价,实际遇到极端 logits 时很容易产生 NaN。
| 对比维度 | CrossEntropyLoss | NLLLoss |
|---|---|---|
| 输入 | 原始 logits | log 概率(log_softmax 的输出) |
| 内部是否做 log_softmax | 内置 | 不做 |
| 典型用法 | crit(logits, target) | crit(nn.LogSoftmax(dim=1)(logits), target) |
| 设计意图 | 开箱即用的多分类损失 | 与 log 概率灵活组合 |
| label_smoothing | 支持 | 不支持 |
3. 用代码实测等价关系:三种写法训练效果完全一样
3.1 同一份 logits,三种写法输出一致
理论说完了,上代码验证。拿一份随机 logits 和随机 target,分别用三种写法计算损失:
import torch import torch.nn as nn torch.manual_seed(0) logits = torch.randn(4, 5, requires_grad=True) # batch=4,5类 target = torch.tensor([2, 0, 4, 1]) # 写法一:直接用 CrossEntropyLoss criterion_ce = nn.CrossEntropyLoss() loss_ce = criterion_ce(logits, target) # 写法二:LogSoftmax + NLLLoss log_softmax = nn.LogSoftmax(dim=1) criterion_nll = nn.NLLLoss() loss_nll = criterion_nll(log_softmax(logits), target) # 写法三:手动实现等价公式 log_probs = log_softmax(logits) loss_manual = -log_probs.gather(1, target.view(-1, 1)).mean() print(f"CrossEntropyLoss: {loss_ce.item():.6f}") print(f"LogSoftmax + NLLLoss: {loss_nll.item():.6f}") print(f"Manual: {loss_manual.item():.6f}")运行结果三条都落在同一个数值上,差异只在 1e-7 量级。这是数学恒等带来的必然结果,不是 demo 取巧。你可以多换几组随机种子,结论不变。看到这里,你应该放心了:无论你写成 CrossEntropyLoss 还是 LogSoftmax + NLLLoss,在单标签分类问题上都等价。
3.2 梯度层面完全一致
损失数值一样的下一步,是梯度也要一样。我把同一份初始化复制成两份,分别走两条计算路径,然后对比对 logits 的梯度:
logits1 = torch.randn(4, 5, requires_grad=True) logits2 = logits1.detach().clone().requires_grad_(True) criterion_ce(logits1, target).backward() criterion_nll(log_softmax(logits2), target).backward() print((logits1.grad - logits2.grad).abs().max())输出也是一个接近 0 的极小值。这说明反向传播路径也完全一致。因此在实际训练里,如果两个模型只是损失函数写法不同、其余结构相同,那么每一步参数更新在数值上都会完全重合,不存在“某个写法收敛更快”的玄学。真正的差异只在于:你传给模型的是什么,模型最后一层输出的是什么。
那什么时候值得用组合写法?当你还需要对数概率本身的时候。比如做带 log 空间的解码、计算困惑度(perplexity)、把多个损失在对数空间叠加,这些场景里你可以显式拿到 log_prob,而不是把它藏在损失函数内部。没有这些需求时,CrossEntropyLoss 一步到位更省心。
4. 参数与输入形态:二分类、多标签、序列标注里的应用细节
4.1 target 必须是类别索引,不是 one-hot 向量
CrossEntropyLoss 和 NLLLoss 的 target 都是 long 类型的整数,取值范围是 0 到 C-1,千万别传 one-hot 向量。原因有两方面。第一,分类任务的类别数往往很大,假设词典大小是一万,一个样本的 one-hot 向量就是一万维,里面只有一个 1,极度浪费显存;第二,PyTorch 底层实现按索引直接读取,效率远高于对 one-hot 做乘加。
如果你手里的标签正好是 one-hot,先转回索引:labels = one_hot.argmax(dim=1),并确保它是.long()。经常有新手在这一步报错:RuntimeError: expected scalar type Long but found Float,这就是 target 类型不对。解决方法就是加一个.long()或转换数据类型。
4.2 reduction、weight、ignore_index 各自的用处
三个常用参数要分开理解。reduction控制损失如何聚合,默认是'mean',适合绝大多数训练场景;'sum'适合需要精确控制总损失尺度的场景;'none'会返回每个样本或每个位置的损失,主要用于调试、给特定样本重新加权,或者自定义聚合方式。我自己的习惯是调试时先用'none'看逐样本 loss 分布,比只看平均更容易定位异常样本。
weight参数用于类别不平衡。比如类别 0 有 800 个样本、类别 1 只有 200 个,可以给类别 1 更高的权重。但这里有个隐藏很深的坑:当reduction='mean'且指定了weight时,PyTorch 的分母不是样本总数,而是权重总和。举个例子,类别 0 有 80 个样本、权重 1,类别 1 有 20 个样本、权重 5,那么加权损失的均值是加权和除以 80×1 + 20×5 = 180,而不是除以 100。不理解这一点,换 weight 后看到 loss 曲线突然升高会误以为自己写错了。
ignore_index是序列标注场景的常客。句子 padding 出来的位置不参与学习,直接传ignore_index=0或对应的 padding id,被忽略的位置既不计入分子,也不计入分母。它和weight、reduction可以组合使用,实际项目中非常实用。
另外,CrossEntropyLoss 还有一个 NLLLoss 没有的label_smoothing参数。它把硬标签 0/1 改成类似 [0.9, 0.033, 0.033] 的平滑分布,能防止模型对训练样本过于自信,提升泛化能力。新手阶段可以先不了解,但需要知道:如果以后要在 CrossEntropyLoss 和 NLLLoss 之间切换,后者默认不支持标签平滑。
4.3 二分类也别硬用 CrossEntropyLoss(除非输出两个维度)
二分类有两条路:一是网络输出 (N, 2),用 CrossEntropyLoss,target 为 0 或 1,此时模型输出两个 logits,softmax 之后正类概率是第二个值;二是网络输出 (N, 1),用 BCEWithLogitsLoss,内部做 sigmoid,target 也是 0 或 1。
很多新人以为二分类就是“一个输出”,于是把最后一层维度改成 1,再传给 CrossEntropyLoss,结果报 target 维度对不上。原因是 CrossEntropyLoss 的语义是“从 C 个类里选一个”,二分类只是 C=2 的特例,它仍然要求每个样本有 C 个 logits。如果你的网络结构是“单输出”,请老老实实用 BCE 系列,不要硬套 CrossEntropy。
4.4 多维输出(比如语义分割)时的维度规则
图像语义分割时,模型输出通常是 (N, C, H, W),target 是 (N, H, W),每个像素位置的值是类别索引。CrossEntropyLoss 和 NLLLoss 都支持这种带非 batch 维度的形式,会逐像素计算损失再聚合。代码示例:
logits = torch.randn(2, 5, 16, 16) # 2张图,5类,16x16 target = torch.randint(0, 5, (2, 16, 16)) # 每个像素的类别索引 loss = nn.CrossEntropyLoss()(logits, target)这里 target 不需要变成 (N, C, H, W),也不需要 one-hot,仍然是索引形态。PyTorch 把 H 和 W 上的每个位置都当成一个“样本位置”,每个位置单独计算分类损失,最后按reduction聚合。理解了这一点,你就不会在分割任务里把 target 维度搞错了。
4.5 多标签分类:换个思路
如果一个样本可以同时属于多个类别,比如图片里同时出现猫和狗,那 softmax 的互斥假设就不再成立。此时应该使用nn.BCEWithLogitsLoss():模型输出每个类别的独立 logit,内部用 sigmoid 逐类判断概率,每个输出维度各自算一个二分类交叉熵。
CrossEntropyLoss 的公式要求所有类别的输出经 softmax 后加和为 1,天然不适合“多选多”。强行用会导致模型在所有类别之间竞争,最后只会输出最显著的那一个类别。所以一听到“多标签”,第一反应就应该是 BCE,而不是交叉熵系列。
5. 新人最容易踩的坑(我自己的踩坑记录)
5.1 在 CrossEntropyLoss 前面自己加了 Softmax
我自己刚开始也干过这事。很多教程说“分类任务最后要 softmax”,于是我自然地在模型里写了x = torch.softmax(x, dim=1),然后把 x 传给 CrossEntropyLoss。结果跑出来的 loss 一开始很小,后面不降甚至上升,准确率始终上不去。
原因现在很清楚了:CrossEntropyLoss 内部已经包含了 log_softmax。你对 logits 先 softmax 一次,它再对这个概率做 log_softmax,等于连续套了两次非线性变换,而且这个变换并不是恒等的,模型的优化目标已经严重偏离正确的交叉熵。检查方法也很简单:拿一份 logits,分别比较CrossEntropyLoss()(x, target)和CrossEntropyLoss()(torch.softmax(x, dim=1), target),两个数字差很多,说明你加错了。
5.2 给 NLLLoss 传入 softmax 结果而不是 log_softmax 结果
NLLLoss 本身不带对数运算,它只负责“取第 target 个位置的相反数”。如果你传入的是概率 p,它计算的是 -p[target],而不是 -log p[target]。
这会导致什么现象?训练时模型同样会让正确类别的概率变大,但梯度方向不再是负对数似然的梯度,数值尺度也与 log 概率完全不同,模型的其他部分在对数空间工作,这里却在概率空间算损失,整体优化行为会很奇怪。我见过有人训练出 loss 为负数的模型,还觉得“负 loss 是不是更好”。不是的,只是因为根本没在算负对数似然。
正确的 NLLLoss 使用一定带 LogSoftmax:
self.log_probs = nn.LogSoftmax(dim=1) # 前向时 logits = self.encoder(x) log_probs = self.log_probs(logits) loss = self.criterion(log_probs, target)5.3 target 维度搞错,loss 不降反升
有一次处理视觉多分类,标签读取出来是 (N, 1) 的 long 张量,我没 squeeze 直接丢给 CrossEntropyLoss。结果有时不报错,因为 PyTorch 允许 target 带非 batch 维,它会尝试把 (N, 1) 中的 1 当作额外的空间维度,和输入 (N, C) 对不上时,有的版本直接报维度错误,有的版本会静默算出奇怪的损失,最后训练曲线乱成一团。
处理方式很简单:
labels = labels.squeeze(1) # (N, 1) -> (N)一定要让 target 的维度与输入的非 batch 维度对齐。最常见的对齐关系是:input (N, C) -> target (N);input (N, C, H, W) -> target (N, H, W)。写代码前先打印一下四张量的 shape,这类问题十秒就能避免。
5.4 二分类里混用 BCE 和 CrossEntropy
二分类任务里如果用了 BCEWithLogitsLoss,但网络输出是 (N, 2),会报 target 形状错误;反过来如果输出是 (N, 1) 却用 CrossEntropyLoss,同样报维度问题。两者解决的是不同性质的问题:CrossEntropyLoss 是多类互斥分类,BCE 是二值独立判断。
这里分享一个快速判断技巧:你的 label 是否可同时取多个 1?如果可以,用 BCE;如果每个样本只能属于一个类,用 CrossEntropy。多标签任务用 BCE,单标签多分类用 CrossEntropy,这句话记住基本不会错。
5.5 手动写交叉熵时用了 log(softmax())
调试代码时,为了看清楚每一步数值,我也习惯手写交叉熵。一开始写的是:
torch.log(torch.softmax(logits, dim=1))这在很多情况下能算出正确数字,但一旦 logits 里出现比较大的正数,softmax 的结果会非常接近 0,log(0) 直接变成 -inf,梯度立刻变成 NaN。稳妥写法永远是:
torch.log_softmax(logits, dim=1)log_softmax内部用 log-sum-exp 的稳定形式,先减去最大值再计算指数和对数,浮点层面不会爆。这也是为什么 NLLLoss 的经典组合里一定要配 LogSoftmax,而不是配 softmax 再自己取 log。
6. 等价性之外的差异:训练表现与我的选型经验
6.1 训练曲线逐 epoch 完全一致
我在一个模拟项目 X 上跑过一组对照:同一套数据、同一个模型结构,只改损失函数写法,一组用 CrossEntropyLoss,一组用 LogSoftmax + NLLLoss,每个 epoch 的 loss 和 accuracy 打印出来几乎逐行一致,区别仅在浮点舍入。这说明从优化角度看,这两个写法就是同一个目标函数。
所以如果有人告诉你“我从 CrossEntropyLoss 换到 NLLLoss 之后准确率涨了”,那大概率是因为同时改了别的:比如输入从 logits 换成了 log_softmax、加了类别权重、改了随机种子,或者重训时数据顺序变了。不要把它归因于损失函数符号本身。
6.2 不同任务下的选型参考
我用一个表格总结一下,方便你直接抄:
| 任务类型 | 推荐写法 | 理由 |
|---|---|---|
| 单标签多分类 | CrossEntropyLoss | 输入 logits 一步到位,支持 label_smoothing |
| 需要对数概率做后续解码 | LogSoftmax + NLLLoss | 显式拿到 log_prob,便于搜索、拼接、调试 |
| 二分类单输出 | BCEWithLogitsLoss | 单 logit + sigmoid |
| 多标签分类 | BCEWithLogitsLoss | 各类独立判断,不做互斥 |
| 序列标注 / 带 padding | CrossEntropyLoss + ignore_index | 自动忽略无效位置,避免 padding 干扰 |
| 类别极不平衡 | CrossEntropyLoss / NLLLoss + weight | 直接加权,注意 mean 归一化行为 |
简单说,默认用 CrossEntropyLoss,除非你需要显式的对数概率或做多标签任务。需要显式 log_prob 时,用 LogSoftmax 加 NLLLoss;多标签任务用 BCE。这三句话覆盖了绝大多数实际场景。
6.3 最后想分享的个人体会
调试分类模型时,我习惯先把reduction='none'打开,看每个样本的 loss 分布,而不是只看 mean。如果某个类别的样本 loss 始终特别高,那就是类别不平衡或特征没分开,这个定位效率远高于对着平均 loss 猜原因。
另外,不要在这两个函数的选择上纠结太久。数据质量、模型结构、学习率、正则化,这些对最终效果的影响远大于损失函数写法。只要你在单标签分类里选了 CrossEntropyLoss,并且没有在前面对它手动加 softmax,就已经在正确的轨道上了。真正需要花时间研究的,是数据里那些 loss 始终很高的样本到底为什么学不会。