很多人用了几百次nn.CrossEntropyLoss(),但真被问到“它内部到底做了什么、为什么这么算”时,还是容易卡壳。这个问题我在某次技术交流时被现场问过,当时虽然能说出“softmax+log+NLLLoss”这几个词,但被追问“每一步的具体形状变化、数值流向、weight参数加在哪个环节”时,还是明显感觉到自己理解得不扎实。后来我花了半天时间,把公式、源码、手算过程彻底对了一遍,才发现这个函数里藏了不少容易被忽视的细节。
这篇文章就是把那次复盘的结果整理出来,不绕弯子,直接带你从头算一遍nn.CrossEntropyLoss()的完整过程。无论是刚开始学PyTorch的新手,还是想彻底搞懂损失函数原理的进阶选手,这篇文章都能让你从“会用”变成“真懂”,以后再遇到自定义损失函数、调试loss异常的情况,你也能更快定位问题。
1. 交叉熵损失到底在算什么
1.1 从信息论到分类任务
要用明白nn.CrossEntropyLoss(),先得明白交叉熵本身。交叉熵(Cross Entropy)来自信息论,它衡量的是两个概率分布之间的差异。在分类任务中,我们把模型输出的概率分布记为 q,把真实标签的分布记为 p,交叉熵的公式是:
H(p, q) = -Σ p(x) * log(q(x))这个公式单独看可能有点抽象,但结合分类任务就很好理解。比如三分类问题,某个样本的真实类别是第 2 类,那么 p 分布就是[0, 1, 0]—— 这是一种 one-hot 编码。此时公式里的 Σ 只需要累加真实类别对应的那一项,因为 p 在其它位置上都是 0。所以公式可以化简成:
H(p, q) = -1 * log(q[真实类别])这一下就清楚了:交叉熵损失的本质,就是让模型在真实类别上输出的概率尽量大,也就是让-log(q[真实类别])尽量小。举个例子,如果模型给真实类别的概率是 0.9,那损失就是-log(0.9) ≈ 0.105;如果只有 0.1,损失就是-log(0.1) ≈ 2.303。概率越大,loss 越小,方向完全符合我们的直觉。
1.2 为什么分类任务更适合用交叉熵而不是MSE
很多人刚接触时都会问:为什么分类不能用均方误差(MSE)?我在实际对比过两者的差异后,感受特别深。
MSE 的梯度在输出接近饱和区时会变得很小。比如 softmax 输出的概率接近 0 或 1 时,MSE 的梯度趋近于零,模型参数几乎不更新,学习速度会变得非常慢。而交叉熵配合 softmax,它的梯度形式是q - p,也就是“预测概率减去真实分布”。当预测完全错误时,梯度幅度大;接近正确时,梯度幅度小。这种梯度特性天然适配分类任务的优化需求。
另外从数值角度来说,交叉熵直接衡量“概率分布之间的差异”,而 MSE 当作回归误差来处理分类概率,实际上是在用不合适的目标函数做约束。所以在 PyTorch 里做分类任务时,nn.CrossEntropyLoss()几乎是最优默认选项。
2. nn.CrossEntropyLoss()内部完整计算链
2.1 从logits到概率:Softmax做了什么
在 PyTorch 的默认用法中,传给nn.CrossEntropyLoss()的输入不是概率,而是模型的 logits 输出,也就是最后一层全连接没有经过激活函数的原始数值。网上很多错误教程会先对 logits 做 softmax 再传给损失函数,这其实是个大坑,后面我会单独说。
先看 softmax 这一步。对于一个向量z = [z1, z2, ..., zK],softmax 的第 i 个输出是:
softmax(z)_i = exp(z_i) / Σ_j exp(z_j)它的作用是把任意实数向量压缩成一组和为 1 的正数,模拟“概率”的语义。比如 logits 是[2.0, 1.0, 0.5],先算exp(2.0)≈7.389、exp(1.0)≈2.718、exp(0.5)≈1.649,三个数的和约是 11.756,那么概率就是[7.389/11.756, 2.718/11.756, 1.649/11.756] ≈ [0.6285, 0.2312, 0.1403]。
这里有个数值稳定性的细节:如果 logits 数值很大,比如 1000,exp(1000)会直接溢出变成inf。PyTorch 内部会自动做一次平移处理,具体方法我会在第 5 部分详细讲,但你现在只需要知道,使用 API 时不需要手动预处理,PyTorch 已经处理得足够稳妥。
2.2 log之后的关键一步:NLLLoss的本质
softmax 得到概率之后,接下来要做的就是对真实类别对应的概率取负对数。但 PyTorch 并没有把这个过程拆开在CrossEntropyLoss里逐步执行,而是用了一条等价链路:
nn.CrossEntropyLoss() 等价于 nn.LogSoftmax(dim=1) + nn.NLLLoss()为什么要这么拆?因为直接把 log_softmax 作为整体来计算,比“先 softmax 再 log”在数值上更稳定。LogSoftmax 的公式是:
log_softmax(z)_i = z_i - log(Σ_j exp(z_j))它在数学上等于log(softmax(z)_i),但计算过程中避免了对极小数取对数可能产生的精度问题。NLLLoss 全称是 Negative Log Likelihood Loss(负对数似然损失),它做的事情非常简单:取出向量中真实类别位置上的 log 概率,加上负号,最后对所有样本取平均。
所以用一句话概括nn.CrossEntropyLoss()的计算过程:对 logits 做 log_softmax,然后取出每个样本真实类别位置的值,取负号,最后求平均。
2.3 等价关系验证:代码证明不只一种写法
我实际在代码里验证过下面三种写法的输出完全一致:
import torch import torch.nn as nn import torch.nn.functional as F logits = torch.tensor([[2.0, 1.0, 0.5], [0.1, 3.0, 0.2]], requires_grad=True) target = torch.tensor([0, 1]) criterion = nn.CrossEntropyLoss() loss1 = criterion(logits, target) # 等价写法1:LogSoftmax + NLLLoss loss2 = nn.NLLLoss()(nn.LogSoftmax(dim=1)(logits), target) # 等价写法2:函数式接口 loss3 = F.cross_entropy(logits, target) print(f"CrossEntropyLoss: {loss1.item():.6f}") print(f"LogSoftmax+NLLLoss: {loss2.item():.6f}") print(f"F.cross_entropy: {loss3.item():.6f}")运行结果三个值完全一致,都是0.286987附近。自己跑一遍这段代码,比看十遍文档都管用,能直观感受到它们在计算路径上是等价的。
3. 手把手推一遍实际张量
3.1 构造模型输出与真实标签
为了把计算过程彻底说透,我构造一个最简单的例子。假设是一个三分类任务,batch size 为 2,也就是说一次输入两个样本。模型的 logits 输出如下:
logits = tensor([[2.0, 1.0, 0.5], [0.1, 3.0, 0.2]]) target = tensor([0, 1])第一个样本的真实类别是第 0 类,第二个样本的真实类别是第 1 类。我们现在的任务是:把nn.CrossEntropyLoss()在这个输入上的计算结果,一步步用手算复现出来。
3.2 从手算到代码验证:逐步拆解每个数值
第一步,对第一行的[2.0, 1.0, 0.5]做 softmax:
exp(2.0) = 7.389exp(1.0) = 2.718exp(0.5) = 1.649- 分母之和 = 11.756
- 概率 =
[0.6285, 0.2312, 0.1403]
真实类别是第 0 类,对应概率 0.6285,那么第一个样本的 loss 就是-log(0.6285) ≈ 0.4644。
第二步,对第二行[0.1, 3.0, 0.2]做同样的处理:
exp(0.1) = 1.105exp(3.0) = 20.086exp(0.2) = 1.221- 分母之和 = 22.412
- 概率 =
[0.0493, 0.8963, 0.0544]
真实类别是第 1 类,对应概率 0.8963,loss 为-log(0.8963) ≈ 0.1095。
第三步,对两个样本的 loss 取平均:
final_loss = (0.4644 + 0.1095) / 2 = 0.2870把刚才那段等价代码跑一遍,输出确实是0.286987左右,和手算结果之间的微小差距完全来自浮点数精度。这说明整个链路你已经彻底掌握了。
3.3 一个容易忽略的维度细节
在做上面计算时,nn.CrossEntropyLoss()的输入形状是[batch_size, num_classes],而 target 的形状是[batch_size],里面存的是每个样本的类别索引,不是 one-hot 向量。这一点很重要,因为在 PyTorch 里还有别的损失函数,比如nn.BCEWithLogitsLoss(),它要求 target 是 one-hot 编码或者多标签形式。如果你把 one-hot 的 target 直接传给nn.CrossEntropyLoss(),它会当成索引去取值,可能出现索引越界,或者语义完全错乱。
正确理解是:nn.CrossEntropyLoss()的 target 就是类别索引,它的取值范围在[0, num_classes - 1]之间。上面例子中第一个样本的 target=0,就是告诉损失函数:“请取出 softmax 概率向量中下标为 0 的那个值”。
4. 影响最终损失的参数不像表面那么简单
4.1 weight参数:它加在哪里,为什么需要归一化
nn.CrossEntropyLoss()的weight参数用来处理样本不平衡问题。比如一个二分类任务,负样本有 10000 个,正样本只有 100 个,如果不加权,模型会倾向于把所有样本都预测为负类。传入weight后,每个样本的 loss 会乘以一个系数。
关键细节是:weight 是在取负对数之后、求平均之前乘上的,不是乘在 softmax 之前的 logits 上。看下面的等价关系:
# 带weight的CrossEntropyLoss criterion = nn.CrossEntropyLoss(weight=torch.tensor([0.2, 0.8])) loss = criterion(logits, target) # 等价的手写过程 weights = torch.tensor([0.2, 0.8]) # 每个样本先算-log_softmax值,再乘对应类别的权重,最后求平均这里的语义是:类别 0 的样本对总损失的贡献被调低到 0.2 倍,类别 1 的样本贡献被放大到 0.8 倍。这样模型会更多地关注类别 1 的样本。实际使用时要注意,如果 weight 的数值相差特别大,最终 loss 的绝对数值可能变得很大,影响学习率设置,通常做法是让 weight 的平均值接近 1。
4.2 ignore_index:让某些类别彻底闭嘴
ignore_index参数在分割任务中使用频率很高。语义分割时,图像中有些像素的标注是无效的,或者是想让模型忽略的背景,就可以设置ignore_index把这些像素排除在损失计算之外。
这个参数的实际效果是:在计算每个样本的 loss 之前,先把 target 等于ignore_index的样本直接跳过,不参与最后求平均的分母。举个具体例子,如果 batch 里有 4 个样本,其中 1 个的 target 等于ignore_index,那么最终 loss 就是剩下 3 个样本 loss 的平均值,而不是除以 4。
我在实际项目里踩过一个坑:设置ignore_index后,如果 batch 中所有样本的 target 恰好都等于ignore_index,PyTorch 会返回nan。这种情况虽然罕见,但在小 batch size 或者极端数据分布下确实会发生,需要做好防护。
4.3 reduction参数:从none到mean再到sum
reduction参数控制最终输出的聚合方式,可选值有none、mean、sum。默认是mean,也就是对所有样本的 loss 取平均。sum就是直接求和。none则不做任何聚合,每个样本的 loss 都会保留。
这个参数在调试时非常有用。我第一次用reduction='none'就是为了找到数据集中表现特别差的样本。做法很简单:
criterion = nn.CrossEntropyLoss(reduction='none') losses = criterion(logits, target) # 形状是[batch_size]拿到每个样本的 loss 后,你可以按大小排序,快速定位模型最难学的那批样本,检查它们的标注是否合理或者特征是否具有代表性。这在数据质量排查中是很好用的一个手段。
4.4 label_smoothing:很小的改动,带来不小的变化
PyTorch 1.10 以上的版本中,nn.CrossEntropyLoss()增加了label_smoothing参数。它的原理不再把真实类别当成 one-hot 的硬标签,而是对标签分布做了软化。假设类别数是 K,label_smoothing 参数是 ε,那么目标分布从“真实类别为 1,其余为 0”,变成“真实类别为 1-ε+ε/K,其余类别为 ε/K”。
这个改动有什么实际意义?我在实验中发现,硬标签容易让模型过度自信,也就是输出的概率分布非常极端,接近 90% 甚至 99% 的置信度都压在某一类上,这会带来过拟合。而 label_smoothing 强行给了非真实类别一点概率空间,模型就不需要再死磕“把真实类别概率推到 1”这个极限目标,训练过程会更平滑,泛化能力往往也更好。
需要注意,开启 label_smoothing 之后,loss 的绝对数值会比不开启时略大,因为目标分布不再是标准的 one-hot,多出来的那部分 ε/K 会让-log(q_)多出一项。这不代表模型训练得更差,只是数值尺度变了,所以对比实验时要在相同的设置下比较。
5. 实践中绕不开的坑与排查心得
5.1 千万注意:不要把softmax结果喂给CrossEntropyLoss
这个坑我见过太多次了。很多教程会教“先 softmax 再交叉熵”,但在 PyTorch 里这么做的结果就是模型训练效果奇差,loss 迟迟不降。原因在于nn.CrossEntropyLoss()内部已经包含了 log_softmax,如果你提前做了 softmax,相当于做了两次 softmax。
我做个小实验来说明影响。假设 logits 是[10.0, 3.0, 0.5],第一次 softmax 后概率约是[0.998, 0.001, 0.000],再把这组概率当作输入传进CrossEntropyLoss,它内部又会做一次 softmax,得到的结果和原来完全不同,相当于对概率又做了一次非线性压缩,会把本来已经很小但关键的梯度信息进一步磨平。所以直接用模型的原始 logits,别做额外的激活处理。
5.2 target维度写错,报错信息看不懂
用nn.CrossEntropyLoss()时常见的报错之一出现在 target 的 shape 和 dtype 上。target 必须是整数类型,通常是torch.long,如果传了torch.float类型的标签,直接会报 dtype 错误。
另外,如果是二维输入[batch, num_classes],target 必须是一维[batch]。我在某个项目里处理分割任务时,target 是[batch, H, W],logits 是[batch, num_classes, H, W],这种场景nn.CrossEntropyLoss()会自动把 H 和 W 展平,对每个像素独立计算损失。这个行为初学者容易理解不了,但只要你明白“它就是对每个位置独立做交叉熵然后平均”,一切就解释得通了。
5.3 带weight时梯度反向传播的表现差异
如果你用了weight参数,要留意它不只是改变了 loss 的数值,还改变了梯度的大小。因为 loss 乘以权重 w 之后,梯度也会等比例放大 w 倍。在多分类不平衡场景下,如果正类权重设为 2.0,负类设为 0.5,正类样本的梯度整体变大,模型的更新方向会更偏向正类。这其实是我们想要的效果,但要注意学习率的适配,w 过大会让优化过程震荡。
我常用的做法是:先不加 weight 跑一个基线,记录 loss 的量级,然后加 weight 后把学习率适当调低 20%-30%,保证训练稳定性。
5.4 logits数值过大时的数值稳定性隐患
前面提到nn.CrossEntropyLoss()内部有数值稳定化处理,它其实是把每个 logits 向量减去该向量中的最大值后再做 exp。这个操作数学上不会改变 softmax 的结果,但能防止指数运算溢出。不过如果 logits 整体数值分布特别极端,比如某些类别的 logit 达到 100 以上,即使不溢出,softmax 出来的概率也会非常接近 one-hot,梯度极小,训练基本停滞。
这种情况在带权重初始化或大学习率训练时偶尔会遇到。排查方法也很简单,打印每轮迭代中 logits 的均值、最大值和标准差,如果发现某类 logit 持续快速增大,就需要考虑梯度裁剪或者调整初始化方式。
6. 一次手动实现CrossEntropyLoss的小实验
为了加深理解,我建议你像我一样,尝试只用基础张量操作手写一个 CrossEntropyLoss,然后和 PyTorch 内置版本对拍。下面这段代码就是我实验时用的,它完全不依赖 PyTorch 的损失函数模块:
import torch def manual_cross_entropy(logits, target): # 1. 数值稳定化的log_softmax shifted = logits - logits.max(dim=1, keepdim=True).values exp_logits = torch.exp(shifted) log_probs = shifted - torch.log(exp_logits.sum(dim=1, keepdim=True)) # 2. 取出真实类别位置的log概率 batch_size = logits.shape[0] selected = log_probs[range(batch_size), target] # 3. 取负后求平均 return -selected.mean() logits = torch.tensor([[2.0, 1.0, 0.5], [0.1, 3.0, 0.2]], requires_grad=True) target = torch.tensor([0, 1]) manual_loss = manual_cross_entropy(logits, target) builtin_loss = nn.CrossEntropyLoss()(logits, target) print(f"手动实现: {manual_loss.item():.6f}") print(f"内置函数: {builtin_loss.item():.6f}")这一步做完,你对nn.CrossEntropyLoss()的理解就完全捡不回去了。手动实现比单纯阅读源码有效得多,因为你被迫把每一步的张量形状、数值流向、聚合方式都过一遍。从那次实验之后,我再也没有在 CrossEntropyLoss 的用法上犹豫过。
最后分享一个个人建议:想熟练掌握任何损失函数,最好的办法就是“拆分+手写+对拍”三步走。先拆解官方实现的计算链路,再不依赖高层 API 手写一遍,最后和官方输出对比。用这个方法搞懂 CrossEntropyLoss 之后,你再去看 DiceLoss、FocalLoss,也会觉得轻松很多。