☰
CrossEntropyLoss与NLLLoss区别详解:从公式到PyTorch实现
2026/10/14 4:14:11 网站建设 项目流程

做分类模型的时候,几乎每个新人都会在同一个地方纠结半天: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 打印出来。相比只看一个均值,你能立刻发现是某个类别整体学不好,还是少数样本标签有问题。这个排查思路帮我省过非常多的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询