做分类模型的时候,几乎每个新人都会在同一个地方纠结半天:nn.CrossEntropyLoss()和nn.NLLLoss()到底有什么区别?我经常在代码审查里看到这类历史遗留代码——上一版用 NLLLoss,下一版改成 CrossEntropyLoss,问起来理由,回答往往是“反正都能做分类”。作为被这个问题问过很多次的人,我今天直接把这个事讲透:这两个不是两套完全不同的损失,而是“半成品”和“成品”的关系。这篇文章会从公式、Pytorch API 的源码行为、手算验证到常见报错,完整拆一遍。刚入门的同学看完能自己判断该用哪个;用了一段时间但没深究过的,也能补上最后一块拼图。
1. 从数学本质看清交叉熵:NLL 到底是什么
要搞清楚两个 API 的区别,先得回到数学层面。很多人学交叉熵的时候只记住了公式,却不知道自己每天都在算的东西本质上是什么。
1.1 交叉熵的标准公式与“化简”
多分类交叉熵的标准形式是:
L = -Σ y_i * log(p_i)
其中 y 是真实标签的概率分布,p 是模型预测的概率分布。但在图像分类这种任务里,真实标签往往是 one-hot 的,比如一只猫的标签是[0, 1, 0],只有猫那一类是 1,其余都是 0。one-hot 意味着求和式里大部分项都是 0,交叉熵就直接化简成:
L = -log(p_y)
也就是:取预测分布中真实类别那一维的概率,然后求负对数。如果模型认为这是猫的概率是 0.9,损失就是-log(0.9) ≈ 0.105;如果模型认为这是猫的概率只有 0.1,损失就是-log(0.1) ≈ 2.303。概率越高,损失越小,这就是分类任务里交叉熵的核心直觉。
1.2 负对数似然:换个角度看损失
NLL 的全称是 Negative Log Likelihood,负对数似然。在统计里,似然函数衡量的是“当前参数下,观察到这批样本的可能性有多大”。放到分类任务里,每个样本的似然就是模型给真实标签的概率p_y,所以负对数似然就是:
L_NLL = -log(p_y)
看到没有?在 one-hot 标签的前提下,这跟化简后的交叉熵一模一样。所以很多教材会说“二者等价”,但更准确的说法是:分类任务中用的交叉熵,实际计算的就是负对数似然。NLLLoss 这个名字保留的是统计学视角,而 CrossEntropyLoss 这个名字保留的是信息论视角。
1.3 这俩为什么会被说成“等价”
关键在于 Pytorch 的 API 设计里,NLLLoss 接收的不是概率p_y,而是对数概率log(p_y)。为什么?因为-log(p_y)本质上就是-log_p_y,如果你直接把log(p_y)传进去,NLLLoss 只需要取对应类别的值再取负号就行,省掉了一次log运算。这就引出了最重要的一个结论:NLLLoss 的输入必须是 log 概率,而不是普通概率,更不是原始 logits。
我这里直接给一个生活化的类比:softmax 是把“各家的评分”转成“中奖概率”,而log_softmax是在转成概率后又取了个对数。CrossEntropyLoss 是“一条龙服务”,你把原始评分给它,它自己做完 softmax、log、取负、平均;NLLLoss 则是“半成品加工”,你必须先把原始评分变成 log 概率再交给它,它只负责取对应位置、取负、平均。这个认知一旦建立,后面所有的坑都能避免。
2. Pytorch API 的“包装”关系:一句话记住区别
2.1 核心结论:CrossEntropyLoss = LogSoftmax + NLLLoss
Pytorch 的文档里写得很直白:CrossEntropyLoss 就是把LogSoftmax和NLLLoss拼在了一起。这句话是所有问题的钥匙。
看源码的话,cross_entropy函数内部做的事情极其简单:
# Pytorch 中 cross_entropy 的核心逻辑(伪代码) def cross_entropy(input, target): log_probs = log_softmax(input, dim=1) return nll_loss(log_probs, target)也就是说,nn.CrossEntropyLoss(logits, label)等于你先对 logits 做一次log_softmax,得到 log 概率,然后把它喂给nn.NLLLoss()。所以从 API 使用者的角度看:
- CrossEntropyLoss:输入原始 logits,只管喂,内部自动处理。
- NLLLoss:输入 log 概率,需要你在模型最后一层加
LogSoftmax,或者手动调用torch.log_softmax()。
我见过不少项目把模型最后一层写成LogSoftmax,然后训练时却还在用CrossEntropyLoss,这等于你把 log 概率喂给了 CrossEntropyLoss,它会再对已经取过 log 的值做一次 log_softmax,输出的损失曲线通常就是一个“看似正常但数值异常”的状态。后面我在排查章节专门讲这个。
2.2 NLLLoss 的输入形态要求与典型误区
NLLLoss 的输入必须是(N, C)或(N, C, d1, d2, ...)形状的 log 概率,同时要求每个位置的值都是负数(因为概率小于等于 1,log 后最多为 0)。如果传入的值是正数、有正有负,那大概率你传的是原始 logits,计算出来的 loss 会是一个完全没意义的结果。
举个最简单的不正确写法:
import torch import torch.nn as nn logits = torch.tensor([[2.0, 1.0, 0.1], [0.5, 1.8, 2.2]]) target = torch.tensor([0, 2]) # 错误示范:NLLLoss 直接接收 logits criterion = nn.NLLLoss() wrong_loss = criterion(logits, target) print(wrong_loss)这个结果会是多少?拿第一行[2.0, 1.0, 0.1]和标签0来说,NLLLoss 会取出logits[0, 0] = 2.0,取负后是-2.0;第二行标签2,取出2.2,取负后是-2.2,平均一下得到-2.1。一个负的损失值,看起来好像模型“学到了什么”,实际上完全是在乱算。
2.3 手算验证:两种写法 Loss 完全一致
为了把等价性刻进脑子里,建议你亲自跑一遍这个对比代码:
import torch import torch.nn as nn # 3 分类任务,2 个样本 logits = torch.tensor([[2.0, 1.0, 0.1], [0.5, 1.8, 2.2]]) target = torch.tensor([0, 2]) # 写法一:直接用 CrossEntropyLoss ce_loss = nn.CrossEntropyLoss() loss_ce = ce_loss(logits, target) # 写法二:手动 LogSoftmax + NLLLoss log_probs = nn.LogSoftmax(dim=1)(logits) nll_loss = nn.NLLLoss() loss_nll = nll_loss(log_probs, target) # 写法三:完全不借助 nn 模块,手算 log_probs_manual = torch.log_softmax(logits, dim=1) loss_manual = -log_probs_manual[range(2), target].mean() print(loss_ce.item(), loss_nll.item(), loss_manual.item()) # 输出应为 0.5165... 0.5165... 0.5165...三次输出的数值完全一致(可能有浮点尾差)。第一种写法是最省事的,第二种写法拆开了内部流程,第三种写法是纯手算。这三种写法等价这件事,就是整个 API 关系的全部本质。
3. 数值稳定性与梯度:为什么 CrossEntropyLoss 更省心
不少新人会问:既然LogSoftmax + NLLLoss和CrossEntropyLoss数学上等价,那我是不是随便用都行?理论上没毛病,但实践里CrossEntropyLoss之所以被推荐,是因为它在数值稳定性和梯度计算上有实打实的优势。
3.1 softmax 的指数爆炸问题
softmax 的公式是:
p_i = exp(z_i) / Σ_j exp(z_j)
问题出在exp上。如果某个 logit 是 1000,exp(1000)在计算机里直接就是无穷大inf,你要么得到一个 NaN,要么得到一堆无效的占位值。做分类任务时,模型训练初期权重很大,最后一层输出 1000 以上的 logits 非常常见,直接用原始 softmax 很容易炸。
解决办法通常是对 logits 做个平移:每个 logit 减去最大值后再 exp。因为 exp 函数是单调递增的,减去一个常数不会改变 softmax 的最终分布:
softmax(z) = softmax(z - max(z))
3.2 log_softmax 的稳定性设计
LogSoftmax本身就已经内置了这种平移处理,而且它内部的实现是直接计算:
log_softmax(z_i) = z_i - max(z) - log(Σ_j exp(z_j - max(z)))
这个公式避免了先算exp(z)再算log的中间溢出。而 CrossEntropyLoss 内部调用的就是这种稳定的log_softmax实现。如果你自己在外面写了softmax -> log -> NLLLoss,很容易踩到另一个坑:当 softmax 输出的概率p非常接近 0 时,log(p)会变成-inf,损失直接变成 NaN,而且梯度也没法传播。
我在实际项目里专门复现过这个现象:一个三层网络,不用任何初始化技巧,手动softmax后取log,训练到某个 batch 突然 loss 变 NaN。但换成同样的结构、同样的超参数,用CrossEntropyLoss,整个训练过程安安稳稳。这不是玄学,就是数值实现的差距。
3.3 从反向传播看“融合实现”的优势
如果你用的是LogSoftmax + NLLLoss,反向传播会先算 NLLLoss 对 log 概率的梯度,再经过 LogSoftmax 的雅可比矩阵,链式法则自动完成,理论上没问题。但 CrossEntropyLoss 在底层往往被融合成一个算子,梯度直接算出来是:
∂L / ∂z_i = p_i - y_i
也就是 softmax 输出减去 one-hot 标签。这个形式非常干净,既省内存(不用保存中间的概率矩阵),又避免了多个算子的数值误差累积。很多新框架甚至直接在 GPU 上做融合 kernel,速度和解空间都有优势。
所以我的建议很明确:除非你有特殊理由要自己控制 log_softmax 这一步,否则多分类任务默认用CrossEntropyLoss是更稳的选择。
4. 实操细节与参数配置:正确用对这两个损失
4.1 target 的 dtype 与 shape 规范
CrossEntropyLoss 和 NLLLoss 对 target 的要求是一样的:必须是整数类型的索引,不能是 one-hot,不能是浮点数。
logits = torch.randn(4, 10) target = torch.tensor([1, 3, 5, 7]) # 正确 target_bad = torch.tensor([1.0, 3.0, 5.0, 7.0]) # 错误,浮点类型 criterion = nn.CrossEntropyLoss() criterion(logits, target_bad) # 会抛错:Expected scalar type Long but found Float如果你的标签是 one-hot 格式,需要先取回类别索引:
one_hot = torch.tensor([[0, 1, 0], [1, 0, 0]]) target = one_hot.argmax(dim=1) # tensor([1, 0])对于图像分割这类高维任务,输入 logits 的形状是(N, C, H, W),target 的形状是(N, H, W),每个像素位置存一个类别索引。这两个损失函数都天然支持这种高维输入,不需要自己手动 reshape。
4.2 reduction、class_weight 和 ignore_index 的用法
这两个损失函数都支持reduction参数,常用三个值:
| reduction 参数 | 行为 |
|---|---|
'mean' | 默认值,所有有效样本的 loss 求平均 |
'sum' | 所有有效样本的 loss 求和 |
'none' | 返回每个样本(或每个像素)的 loss,方便自己后处理 |
我调试模型时,经常把 reduction 设成'none',专门看看是哪几个样本的 loss 特别大,再结合标签检查是不是数据标注有问题。
class_weight参数用于处理类别不平衡。比如一个有 10 类的数据集,第 0 类样本特别少,你可以给这个类一个更大的权重,压过其他类。参数是一个长度为类别数的 Tensor:
weights = torch.tensor([2.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) criterion = nn.CrossEntropyLoss(weight=weights)注意权重是乘在单个样本 loss 上的,reduction='mean'时求的是加权平均。类别 i 的权重越大,梯度中该类样本的贡献越大。
ignore_index在分割任务里几乎是刚需。比如语义分割的标注里,边界、忽略区域常常标成 255,训练时你不想让这些像素影响模型,就可以:
criterion = nn.CrossEntropyLoss(ignore_index=255)这样 target 中值为 255 的位置不参与 loss 计算,也不产生梯度。同样,NLLLoss 也支持这个参数。
4.3 什么场景该用 NLLLoss 而不是 CrossEntropyLoss
尽管 CrossEntropyLoss 是更省心的选择,但 NLLLoss 并不是完全没用。一个典型场景是:你的模型最后一层已经做了LogSoftmax,并且你不想动模型结构。比如某些现成的预训练分类网络,输出层自带LogSoftmax,这时候你训练时配 NLLLoss 就是正确的。
另一个场景是知识蒸馏、对比学习这类自定义损失。你希望手动控制温度缩放:先把 logits 除以温度 T,再算 log_softmax,然后用 NLLLoss 配合软标签。这时候 CrossEntropyLoss 反而不好直接替代,因为它的输入被固定成原始 logits,无法插入温度缩放这一步。
还有一个实用技巧:很多人在训练时需要从模型输出里拿到概率,用于置信度筛选或可视化。推荐的做法是模型最后一层保持输出 logits,训练用 CrossEntropyLoss;推理时再在外面做torch.softmax(logits, dim=1)。这样训练与推理职责分离,既稳定又灵活。
5. 常见报错与排查速查:新人避坑实录
5.1 报错:Expected scalar type Long but found Float
这是 CrossEntropyLoss 和 NLLLoss 最常遇到的报错,十有八九是 target 没有转 long。
# 错误 target = torch.tensor([1.0, 2.0, 0.0]) # 正确 target = target.long() # 或者从别的流程转过来 target = torch.tensor([1, 2, 0])建议在数据加载的collate_fn或Dataset的__getitem__里就直接把标签统一转成torch.long,一劳永逸。
5.2 loss 为负且还在不断下降
如果训练一开始 loss 就是负数,而且越来越小,那基本可以断定你把原始 logits 喂给了 NLLLoss。因为原始 logits 有正有负,NLLLoss 取负之后平均,就会出现负损失。看到这个现象,先检查模型输出层有没有LogSoftmax,再检查损失函数名字是不是用错了。
正确的第一轮 loss 应该是一个正数,数值大概在log(C)附近(C 是类别数),比如 10 分类的随机初始化模型,loss 大概率在 2.3 左右。如果第一轮 loss 明显低于这个值,就要警惕是否写错了。
5.3 传入 one-hot 标签报错或 Loss 怪异
有人习惯把标签做成 one-hot 再喂给损失函数,这在某些别的框架里是常规操作,但在 Pytorch 这两个损失里不适用。CrossEntropyLoss 期望的是每个样本一个类别索引,不是概率分布。如果硬传 one-hot,轻则维度报错,重则 loss 数值怪到没法解释。
解决方案除了argmax之外,还可以考虑torch.nn.functional.binary_cross_entropy_with_logits这类真正支持标签分布的二分类损失。如果是多标签分类,则不要把问题套到 CrossEntropyLoss 上,改用 BCE 系列。
5.4 训练中途出现 NaN
NaN 的原因很多,但如果你用了手动softmax -> log -> NLLLoss,第一怀疑对象就是log(0)。当 softmax 输出概率极小时,log 得负无穷,反向传播后梯度就是 NaN。即使单次没触发,一旦某个类别因为训练不充分导致输出概率为 0,整个训练就会崩。
排查思路是:先看是 loss 直接变成 NaN,还是梯度爆炸后一步步变成 NaN。可以在每个 batch 后检查一下输出值域:
if torch.isnan(logits).any(): print("logits contain NaN")如果确认是手动 softmax 导致的,换用log_softmax或直接换 CrossEntropyLoss 基本能解决。如果换完还 NaN,再去看学习率是否过大、数据里是否有异常值。
5.5 顺带一提:BCEWithLogitsLoss 的同款设计
理解了 CrossEntropyLoss 和 NLLLoss 的关系以后,再看二分类损失会豁然开朗。nn.BCEWithLogitsLoss其实也是“融合版”,它内部做了Sigmoid + BCELoss。如果你用的是nn.BCELoss,就必须自己在模型输出层加Sigmoid。这与CrossEntropyLoss内置LogSoftmax、NLLLoss需要外部传入 log 概率是同一套设计思路。
所以记住 Pytorch 命名里“WithLogits”的含义:带这个后缀的损失,会替你处理概率转换,你只管给原始 logits。不带这个后缀的损失,要求你先把输出转成它期望的概率形式。这个规律一旦掌握,以后看到任何损失函数都不会再怕。
结尾
这篇文章从公式、API 设计、数值稳定性和常见报错四个角度把 NLLLoss 与 CrossEntropyLoss 的关系拆开讲了一遍。我个人带新人时的经验是:训练代码里默认用 CrossEntropyLoss,模型输出保持原生 logits,推理时再 softmax;只有在阅读别人代码看到 NLLLoss 时,才去确认模型最后一层是不是写了个 LogSoftmax。改模型结构时,我也会同步看一眼损失函数是否匹配,不然 NLLLoss 配对 logits 或 CrossEntropyLoss 配对 log 概率,训练曲线会非常迷惑。
最后分享一个小技巧:如果你在调试一个新模型,可以把损失函数临时设成reduction='none',把每个样本的 loss 打印出来。相比只看一个均值,你能立刻发现是某个类别整体学不好,还是少数样本标签有问题。这个排查思路帮我省过非常多的时间。