☰
PyTorch交叉熵损失函数详解:CrossEntropyLoss与BCEWithLogitsLoss实战
2026/10/20 3:10:33 网站建设 项目流程

做图像分类或者二分类任务时,PyTorch的CrossEntropyLoss和BCEWithLogitsLoss基本上是绕不开的两个损失函数。我第一次用PyTorch时就是在这两个上面栽了跟头:模型最后一层到底要不要加Softmax?标签到底用整数还是用One-Hot?为什么明明训练集准确率不错,验证集loss却飘得离谱?后来把交叉熵的原理和PyTorch的内部实现彻底过了一遍,才算把分类任务的整个链路理顺。

这篇文章我会从多分类交叉熵和二分类交叉熵两个场景展开,把原理、代码、参数、常见问题一次说清楚。无论你是刚上手PyTorch,还是已经在做图像分类、二分类、多标签任务,都可以对照着排查自己的代码。

1. 分类任务的底层逻辑:为什么交叉熵是标配

1.1 分类模型输出的本质是概率分布

图像分类任务中,模型最后一层通常输出K个实数,这K个实数叫logits,本质上是对每一类的“打分”。打分本身不是概率,需要经过Softmax转成和为1的概率分布。二分类任务也可以看作K=2的多分类,只不过常用单输出加Sigmoid的形式。

交叉熵做的事情,就是衡量模型输出的概率分布和真实标签分布之间的差异。真实标签分布通常是一个One-Hot分布:正确类概率为1,其他类为0。模型预测越接近这个分布,loss越低。可以理解为“真实答案”是晴天,模型如果一开始死活报“降雨概率80%”,交叉熵就会给出一个很大的惩罚值,逼着模型往正确答案靠拢。

很多初学者会问:直接用均方误差MSE行不行?行,但效果很差。MSE假设标签是一个连续回归值,而分类标签是离散类别。更致命的是,MSE配合Softmax会出现严重的梯度饱和:当模型已经输出接近0或1的极端概率时,Softmax曲线平坦,反向传播梯度趋近于0,模型几乎学不动。交叉熵针对概率分布的差异做优化,梯度更直接,这也是它成为分类任务标配的原因。

1.2 从信息论角度理解交叉熵

交叉熵的公式是:

H(p, q) = -Σ p_i log q_i

其中p是真实分布,q是模型预测分布。最小化交叉熵,其实等价于最小化KL散度:

KL(p || q) = Σ p_i log(p_i / q_i)

因为H(p, q) = H(p) + KL(p || q),其中H(p)是真实分布的熵,对模型来说是个常数。所以优化交叉熵和优化KL散度结果一样。分类任务中真实分布p大部分位置都是0,公式会进一步简化为:

Loss = -log(q_y)

也就是说,交叉熵只关心正确答案对应的模型概率。如果模型给正确类打了极低的概率,loss就很大;如果给正确类概率接近1,loss接近0。

这个性质非常实用。训练过程中我们经常打印loss,看到一个epoch后loss从2.3降到1.5,说明模型正在慢慢提升正确类的预测概率。如果loss始终在某个值附近不降,先不要急着加模型复杂度,反而要回头检查数据标签和模型输出是否对齐。

1.3 自己动手算一次交叉熵

光看公式容易飘,举一个具体例子。假设三分类任务的logits是[2.0, 1.0, 0.1],真实类别是第1类(索引从0开始,也就是中间那个)。模型输出会先经过Softmax:

softmax(2.0) = e^2 / (e^2 + e^1 + e^0.1) ≈ 7.389 / 11.212 ≈ 0.659
softmax(1.0) ≈ 2.718 / 11.212 ≈ 0.242
softmax(0.1) ≈ 1.105 / 11.212 ≈ 0.099

真实标签是第1类,loss就是 -log(0.242) ≈ 1.418。用PyTorch验证一下:

import torch import torch.nn.functional as F logits = torch.tensor([[2.0, 1.0, 0.1]]) target = torch.tensor([1]) loss = F.cross_entropy(logits, target) print(loss) # tensor(1.4178)

这个手动计算的过程能帮你理解:交叉熵并不是惩罚所有预测概率,而是只惩罚正确答案概率的对数。但其它类概率通过Softmax的归一化也会间接影响loss。反向传播时,正确类logits的梯度是(p_y - 1),错误类logits的梯度是p_j,所以模型会同时拉高正确类分数、压低错误类分数。

2. 多分类交叉熵实现图像分类:完整流程拆解

2.1 数据加载与标签格式:One-Hot并不是必须的

做图像分类时,数据加载最常用的是torchvision.datasets.ImageFolder。本地目录按类别分文件夹即可:

data/train/ class0/ 001.jpg 002.jpg class1/ 001.jpg class2/

然后用ImageFolder读取:

from torch.utils.data import DataLoader from torchvision import transforms, datasets transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) dataset = datasets.ImageFolder(root='data/train', transform=transform) loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)

ImageFolder会按照文件夹名称的字母顺序自动映射类别索引,比如class0对应0,class1对应1。DataLoader返回的labels是整数张量,shape为[B],每个元素取值范围在[0, K-1],数据类型是long。

这里有个最常见的误区:以为CrossEntropyLoss必须配合One-Hot编码。实际上PyTorch的nn.CrossEntropyLoss直接接受稀疏标签,PyTorch内部会通过索引从预测概率中取出正确类对应的概率,然后计算-log。如果你手动转成One-Hot,反而需要额外的编码步骤,而且类型转换稍不注意就会报错。所以除非有特殊需求,直接用整数标签就好。要转成long类型,通常数据集返回的就是long,不需要额外处理。

2.2 模型搭建:最后一层不要提前Softmax

一个简单的图像分类CNN可以是这样的:

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) ) self.classifier = nn.Linear(32, num_classes) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)

注意最后一层线性层输出的是num_classes维度的logits,不要在外面包Softmax。CrossEntropyLoss内部会先做LogSoftmax,再做负对数似然。如果你手动Softmax后再传给CrossEntropyLoss,相当于对Softmax结果再做一次Softmax,数值不稳定,训练信号被破坏。这种问题在初学阶段非常隐蔽,loss不会报错,但训练效果会很差。

如果你已经用了torch.nn.LogSoftmax作为最后一层,那么损失函数应该用torch.nn.NLLLoss,而不是CrossEntropyLoss。记住这个等价关系:CrossEntropyLoss = LogSoftmax + NLLLoss。很多人把这两组混用,导致loss数值奇怪。

2.3 训练循环:监控loss和准确率

训练循环的写法比较固定,但有几个细节值得注意:

import torch.optim as optim model = SimpleCNN(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) for epoch in range(30): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in loader: outputs = model(inputs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) correct += preds.eq(labels).sum().item() total += inputs.size(0) epoch_loss = running_loss / total epoch_acc = correct / total print(f'epoch {epoch} loss {epoch_loss:.4f} acc {epoch_acc:.4f}')

这里有几个容易忽略的点。每个batch训练前必须调用optimizer.zero_grad(),否则梯度会累积。loss.item()是提取标量,用于记录日志,不要直接loss.backward()后再调一次,那样会重复计算。准确率通过outputs.argmax(dim=1)得到预测类别索引,再与labels比较。

验证时切换到model.eval(),并包裹with torch.no_grad()。eval模式会关闭Dropout和BatchNorm的统计更新,避免验证指标失真。如果你在训练时用了BatchNorm,验证时忘记切换,模型输出会不稳定,准确率也可能乱跳。

2.4 用极端logits验证损失是否正确

训练前最值得做的一步,是用一个固定输入验证损失函数是否用对。比如batch size为1,K=10类,全零logits下,每个类的概率都是1/10,loss应该是log(10)≈2.3026。写几行代码:

import torch import torch.nn as nn criterion = nn.CrossEntropyLoss() logits = torch.zeros(1, 10) target = torch.tensor([3]) print(criterion(logits, target)) # tensor(2.3026)

如果结果不是2.3026,说明loss定义或标签类型出了问题。这步虽然简单,但能帮你把数据、模型、loss串在一起验证,比直接跑完整训练快得多。

3. 二分类交叉熵:BCEWithLogitsLoss与单输出Sigmoid

3.1 二分类的两种建模方式对比

二分类任务有两种常见建模方式。

第一种是把二分类当成K=2的多分类,模型输出两个节点,损失函数仍然用CrossEntropyLoss。这种方式在类别是互斥的时候完全可行,比如猫和狗、男和女。第二种是模型输出单个节点,经过Sigmoid得到[0,1]的概率,然后用BCEWithLogitsLoss计算损失。第二种方式更常用,原因是它可以平滑扩展成多标签分类,并且方便通过pos_weight处理正负样本不平衡。

两种方式的对比表格:

建模方式模型输出shape标签shape损失函数多标签支持
二分类当作多分类[B, 2][B],longCrossEntropyLoss不支持,类别互斥
单输出加Sigmoid[B, 1][B, 1],floatBCEWithLogitsLoss支持,每输出独立

需要澄清的是,很多业务场景里的“二分类”本质是“是/否”判断,单输出模型天然更符合直觉。如果只是二选一,两种方式理论上是等价的,因为两个logits的差值可以转换成单个logit。但从工程扩展性考虑,我建议直接用BCEWithLogitsLoss,以后加多标签任务不用修改模型结构太多。

3.2 BCEWithLogitsLoss的代码细节

BCEWithLogitsLoss将Sigmoid和二元交叉熵合并在了一起。模型输出的logits直接传入损失函数,PyTorch内部进行计算,数值稳定性更好。一个最简单的二分类模型:

import torch.nn as nn class BinaryClassifier(nn.Module): def __init__(self, in_features): super().__init__() self.net = nn.Sequential( nn.Linear(in_features, 64), nn.ReLU(), nn.Linear(64, 1) ) def forward(self, x): return self.net(x) model = BinaryClassifier(in_features=128) criterion = nn.BCEWithLogitsLoss()

训练时特别要注意标签的shape和dtype。如果DataLoader返回的标签是0/1整数,通常shape是[B],而模型输出是[B,1]。直接把[B]和[B,1]传入BCEWithLogitsLoss,PyTorch会通过广播机制把[B]扩展成[B,B],计算结果变成每行重复,相当于每个样本被计算了B次,虽然一般不报错,但梯度完全错了。所以训练前务必做一次reshape:

labels = labels.float().view(-1, 1) loss = criterion(logits, labels)

推理时,先用Sigmoid把logits转成概率,然后以0.5为阈值得到预测类别:

probs = torch.sigmoid(logits) preds = (probs > 0.5).float()

不要在训练时对logits手动做Sigmoid再传给BCEWithLogitsLoss,否则内部会再做一次Sigmoid,梯度会通过两层非线性传播,训练极不稳定。

3.3 pos_weight:二分类不平衡的救星

二分类任务经常遇到正样本极少的情况,比如点击率预测、异常检测、医疗筛查。假设正样本只有100个,负样本900个,模型只需要把所有样本预测为负就能拿到90%准确率,但业务上毫无价值。这时可以用pos_weight提高正样本loss的权重:

pos_weight = torch.tensor([9.0]) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

pos_weight的含义是正样本loss项乘以该系数。公式可理解为:

loss = -[pos_weight * y * log σ(x) + (1-y) * log(1-σ(x))]

如果正负样本比例是1:9,pos_weight设为9左右,相当于把正样本的贡献放大到和负样本一个量级。这比简单地对所有样本加权更精确,因为它只影响正样本那一项。如果类别不平衡严重,还可以继续配合Focal Loss等更高级的方法。

4. 参数细节与数值稳定性:隐蔽但致命的坑

4.1 CrossEntropyLoss的可选参数逐个说

CrossEntropyLoss有四个高使用频率的参数:weight、ignore_index、label_smoothing、reduction。

weight是一个长度为K的Tensor,按类别权重缩放loss,适用于多分类不平衡。比如类别0样本少,可以给类别0更大的权重。计算权重最简单的方式是统计每个类别的样本数,然后取倒数,再归一化:

class_counts = [100, 1000, 900] total = sum(class_counts) weights = [total / c for c in class_counts] class_weights = torch.tensor(weights, dtype=torch.float) criterion = nn.CrossEntropyLoss(weight=class_weights)

注意weight是在loss聚合前作用的,只影响每个样本的loss值,不改变标签的编码方式。

ignore_index告诉CrossEntropyLoss忽略某个标签值,常见于序列预测中padding标签。比如语言模型里把padding位置设为-100,对应的loss完全不参与梯度计算,这样模型不会被无数个padding位置带偏。

label_smoothing是防止过拟合的利器。默认值0,表示使用硬One-Hot标签。如果设为0.1,真实标签分布不再是0和1,而是:

正确类概率 1 - ε + ε/K,其他类概率 ε/K

这样模型不会过度相信训练标签,有助于提高泛化能力。图像分类训练中我常用0.1左右的平滑值,验证集指标通常会稳定一些。

4.2 BCEWithLogitsLoss的pos_weight与weight的区别

BCEWithLogitsLoss也有一个weight参数,但它和pos_weight不同。weight可以对所有样本的loss做缩放,适合不同batch或不同任务之间的损失权重调整;pos_weight专门针对正样本项做缩放,适合类别不平衡。

举个例子,多标签任务有5个标签,每个标签的正样本占比不同,pos_weight可以是一个长度为5的Tensor,分别指定每个标签的权重:

pos_weight = torch.tensor([1.0, 5.0, 2.0, 8.0, 1.5]) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

如果正样本极少,pos_weight设置过大,loss数值会明显变大,这时候学习率可能需要适当调低,否则容易震荡。我一般先根据正负样本比设置一个初始值,然后观察验证集F1分数微调。

4.3 为什么不要手工用torch.log(F.softmax(x))

新手比较容易写出这样的代码:

probs = F.softmax(logits, dim=1) loss = -torch.log(probs).gather(1, target.unsqueeze(1)).mean()

结果也能跑,甚至在数值小的测试数据上结果可能一样。但问题在于数值稳定性。Softmax里包含exp(x_i),当logits出现大数值时,exp可能溢出到inf,导致NaN。PyTorch的CrossEntropyLoss内部用的是LogSoftmax,计算公式是x_i - logsumexp(x),不会先算exp再求log,所以更稳定。

BCEWithLogitsLoss同样如此。Sigmoid函数在x特别负或特别正时会接近0或1,再取对数会出现0乘负无穷的问题。PyTorch内部对Sigmoid和log做了合并处理,避免NaN。

所以我的习惯是:损失函数用PyTorch提供的融合版本,不要自己拼。如果你非要手动拼,至少用更稳健的形式:

log_probs = F.log_softmax(logits, dim=1) loss = F.nll_loss(log_probs, target)

这和CrossEntropyLoss是等价的,但依然推荐直接使用CrossEntropyLoss,代码更简洁,意图更清楚。

5. 常见问题与排查技巧实录

5.1 loss变成NaN或始终不降

loss变成NaN,最常见的原因是学习率过大。Adam的学习率默认是1e-3,对于很多分类任务够用,但遇到小batch、复杂数据时还是可能爆炸。先做三步排查。第一步,打印模型的原始logits,看是否出现极大值;第二步,打印梯度,确认反向传播没有NaN;第三步,把学习率降到1e-4或者1e-5,看loss是否恢复。

如果loss始终不降,输出又正常,优先检查标签是否匹配。多分类标签必须是0到K-1范围内的整数,如果某个标签等于K,CrossEntropyLoss会直接报错,但如果标签映射错了但数值合法,loss就会乱。建议从数据集里抽出一小部分样本,统计标签分布,再核对ImageFolder的class_to_idx映射。

另外,数据归一化也很重要。图像数据如果不做Normalize,像素值范围0到255,输入模型后数值很大,初始loss可能高但不至于NaN,训练却容易震荡。用ImageNet的均值和标准差做归一化是最常见的做法。

5.2 二分类概率全在0.5附近

模型输出所有样本概率都在0.5附近,说明模型没学到有效特征,或者最后的分类头没有足够的梯度信号。常见原因有特征归一化不足、模型容量太小、正负样本失衡且没有用pos_weight。

处理思路是:先做一个快速实验,把训练数据缩减到几十张图,如果模型过拟合这一小批数据,说明数据读取和模型结构没问题,问题出在完整数据集的分布或训练策略上。如果连小数据都学不好,优先检查标签是否颠倒,比如模型要预测的是猫,但标签把猫标成了0,狗标成了1,一开始模型学得混乱。

我在实际项目里还遇到过一类情况:最后一层线性层初始化偏置太大,导致初始logits接近正无穷,Sigmoid输出全是1,概率自然不是0.5但同样不健康。这类问题可以通过打印验证集第一批logits的均值、方差来判断,分布太极端就考虑加BatchNorm或减小初始化范围。

5.3 类别不平衡导致准确率虚高

多分类或二分类中,如果某个类别占90%以上,准确率会显得很高,但实际作用不大。比如木制品缺陷检测中正常样本占98%,模型只要预测“正常”,准确率就是98%,但一个缺陷都检不出来。

处理这个问题,首先在指标上不要只盯accuracy,要看F1、召回率、精确率、混淆矩阵。其次是loss层面的处理,多分类用weight,二分类用pos_weight。还有一个技巧是在验证阶段按类别分别统计准确率,如果某个小类准确率特别低,说明模型对它的特征没学到,这时再考虑增加该类别的样本或做数据增强。

5.4 推理时到底要不要加Softmax

训练时CrossEntropyLoss内部已经做了LogSoftmax,所以模型输出的是logits。推理阶段,如果你想拿到并展示各类别概率,需要自己调用torch.softmax(outputs, dim=1),然后取最大值对应的索引作为预测类别。如果你只用argmax,指标计算和模型部署逻辑可以不用Softmax,因为argmax在单调变换后结果不变。

二分类同理,模型输出是logits,要展示概率必须加Sigmoid。但判断正负类时直接比较logits是否大于0和比较Sigmoid概率是否大于0.5是等价的,所以很多服务端部署为了减少计算量,会省略Sigmoid。

6. 从loss设计看模型调优:一次完整的经验复盘

6.1 从loss曲线上能读到什么

训练loss曲线上能看到很多信息。如果训练loss下降、验证loss同步下降,说明模型正常收敛。如果训练loss下降但验证loss先降后升,是典型的过拟合。如果训练loss和验证loss都降得很慢,可能是学习率太小或模型容量不足。如果loss曲线上下剧烈震荡,通常学习率过大或batch_size太小。

交叉熵loss的数值本身也有基线参考。多分类K个类,随机初始化时loss接近log(K)。二分类随机初始化接近log(2)。所以看到初始loss在2.3(10分类),不要慌,说明输出分布接近均匀。一个epoch后loss不降反升,再检查数据。

6.2 交叉熵之外的进阶选择

当交叉熵无法解决难例问题或者样本极度不均衡时,可以考虑Focal Loss。Focal Loss是在交叉熵基础上增加了调制因子(1 - p_t)^γ,让模型重点关注难样本、降低易样本的loss贡献。二分类和多分类都有对应版本,但实现时要小心梯度。另一个实用技巧是温度缩放temperature scaling,在推理时把logits除以一个标量再Softmax,可以在不重新训练的情况下改善概率校准。

不过我的经验是:先别急着上Focal Loss或自定义loss,先把交叉熵和BCE的每个参数吃透。很多时候卡住不动不是因为loss不够高级,而是标签、shape、归一化、学习率这些基础环节出了问题。把这些排查完,再考虑花式损失函数更稳妥。

最后分享一个习惯:每次新建分类项目,我会先写一个最小脚本,用随机数据验证模型能完成一个batch的前向、反向、参数更新。然后打印第一轮loss,确认数值符合理论基线。接着训练50步,观察loss是否能降到比基线明显更低。这个小流程大概10分钟,但只要过了这关,后面调参基本不会跑偏。交叉熵本身不复杂,复杂的是整个训练链路里的细节,把这层窗户纸捅破,后面也就顺畅了。

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

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

立即咨询