ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch交叉熵损失函数:多分类与二分类的选型与实战

PyTorch交叉熵损失函数:多分类与二分类的选型与实战

前阵子帮一个朋友调试二分类模型,他把之前多分类图像分类的代码拿过来,输出层从10个神经元改成1个,其余一概没动,结果训练了一整天,loss曲线跟心电图一样起伏,acc却原地踏步。我一看代码就明白了:损失函数用的还是nn.CrossEntropyLoss。这不是他一个人会犯的错,很多刚接触PyTorch的朋友,都会在这里栽跟头——多分类交叉熵和二分类交叉熵,表面看起来差不多,但一个是Softmax语义,一个是Sigmoid语义,背后的数学逻辑、标签格式、输出层设计、数值稳定性处理,完全是两套思路。

这篇文章就围绕PyTorch里这两个损失函数展开:先讲清楚交叉熵在分类任务中到底在算什么账;再拆解多分类交叉熵和二分类交叉熵在数学上的分野;然后分别给出图像分类和二分任务的完整实现链路;最后把我踩过的坑以及从loss曲线读训练状态的经验一并分享出来。无论你是刚学深度学习的新手,还是被分类任务折磨过的老手,这篇文章都应该能帮你把这些"看似简单"的细节彻底理顺。

1. 先搞明白交叉熵在算账:信息量、概率分布与损失值的关系

1.1 交叉熵不是"函数名",而是两个分布之间的"距离感"

很多人把交叉熵当成一个普通的、用来算误差的函数,跟MSE(均方误差)放在同一个层级理解。这么想也不能说错,但它会掩盖一个重要事实:交叉熵本质上是在度量"两个概率分布之间的差异程度"。这里说的"两个分布",一个是模型预测出来的概率分布,一个是真实标签构成的分布。

举个例子,一个三分类图像分类任务,某张图片的真实类别是"猫",那真实标签分布就是[1, 0, 0],意思是"猫的概率是100%,狗和马的概率是0"。模型输出经过Softmax之后,可能得到[0.6, 0.3, 0.1],意思是"有60%把握是猫,30%是狗,10%是马"。交叉熵要衡量的,就是[1, 0, 0]和[0.6, 0.3, 0.1]这两个分布有多"不像"。

从信息论的角度看,交叉熵的公式是H(p, q) = -Σ p(x) * log(q(x)),其中 p 是真实分布,q 是预测分布。注意这里只对真实分布中概率不为0的类别取log(q(x)),所以实际上,交叉熵只关心"真实类别对应的那个预测概率"。如果一个样本的真实类别是猫(对应索引0),那损失就是-1 * log(0.6),约等于0.51。如果模型对猫只给出了0.1的概率,损失就变成-log(0.1),约等于2.30。损失越小,说明真实类别被分配的概率越高,模型越"自信且正确"。

1.2 为什么MSE在分类任务里不好使:梯度与概率语义的双重问题

有人会问:我不用交叉熵,用MSE做分类行不行?理论上可以,但实践中非常不舒服,原因有两个。

第一是梯度问题。MSE对输出的梯度是2 * (q - p),在Softmax的饱和区,比如当输出概率接近0或1时,Softmax函数本身的导数趋近于0,这会让梯度变得非常小,训练速度慢得让人抓狂。而交叉熵配Softmax,在数学上正好消掉了Softmax分母带来的梯度衰减项,梯度只跟预测概率和真实标签的差值有关,即使预测概率接近1,梯度仍然有合理的尺度,不会卡死。

第二是概率语义的问题。MSE把[1, 0, 0]和[0.6, 0.3, 0.1]当普通向量来算欧氏距离,它对"0.6 vs 0.4"和"0.55 vs 0.45"这两种情况给出同样的数值反悔,但在分类任务里,0.6的信心和0.55的信心差距不大,这种细微差别不该被放大,交叉熵用log函数天然处理了这种非线性关系。

2. 多分类与二分类的数学分界点:Softmax的竞争逻辑与Sigmoid的独立逻辑

2.1 Softmax:让所有类别抢一个"总分"

Softmax做的事情,说白了就是把模型的原始输出(logits)变成一组和为1的概率。公式是softmax(z)_i = exp(z_i) / Σ_j exp(z_j),它的关键特性是"竞争性":一个类别的概率升高,其他类别的概率必然会降低。因为它分母是所有类别的指数和,整个分布的概率总和永远是1。

这种特性天然适合"互斥"的分类任务——一张图片只能是猫、狗、马中的一种,不能同时是猫和马。在PyTorch里,如果你用nn.CrossEntropyLoss,模型最后一层不需要手动接Softmax,因为CrossEntropyLoss内部已经做了LogSoftmax加NLLLoss的融合操作。如果模型最后一层是nn.Linear(num_classes)输出10个logits,这10个logits直接喂给CrossEntropyLoss就好,它会自动处理Softmax和负对数似然的计算。

这里有个常见的误解:很多人以为CrossEntropyLoss = 先Softmax + 再算交叉熵。对,但PyTorch做了融合优化,不推荐你手动调用Softmax后再算交叉熵,原因后面第5章会详细讲,核心是数值稳定性和计算效率。

2.2 Sigmoid:每个类别独立拿自己的"对/错"判断

Sigmoid函数把单个输出压到(0, 1)区间,公式是σ(z) = 1 / (1 + exp(-z))。它跟Softmax最大的区别是:没有归一化约束。每个输出节点独立地表示"这件事是正类的概率",各节点之间互不影响。

这个语义适合什么场景?首先是标准的"是/否"二分类——一张图片是"包含缺陷"还是"正常",模型的输出层只有一个神经元,sigmoid把logit压到0到1之间,这个值就是"包含缺陷"的概率。其次是"多标签"分类——一张图片里同时有猫又有狗,模型可以为"猫"输出0.9,为"狗"输出0.8,两者互不拖累。如果用Softmax做多标签,总概率只能分给不同标签,一个标签涨另一个就得跌,这不是我们要的语义。

2.3 这个区别如何决定你的损失函数选型

现在可以做一个清晰的对比表:

对比维度多分类(互斥)二分类 / 多标签(独立)
输出层神经元数等于类别数 C二分类为1(或2),多标签为标签数K
输出层激活函数无(CrossEntropyLoss内部做LogSoftmax)无(BCEWithLogitsLoss内部做Sigmoid)
标签格式长整型类别索引,如2浮点型 0.0 / 1.0,形状与输出对齐
损失函数nn.CrossEntropyLossnn.BCEWithLogitsLoss(推荐)
数学逻辑Softmax归一化,所有类别竞争总分Sigmoid独立判断,每个类别单独打分

这张表是全文最重要的一张表。每次你在动手写分类代码前,先想清楚"我的任务里,类别之间是互斥的还是独立的",然后照着这张表选,基本不会出错。

3. 多分类图像分类实战:CrossEntropyLoss的正确打开方式

3.1 模型输出层与标签编码的一致性

我们用图像分类举个完整例子。假设有一个10类开源图像数据集,每张图片尺寸3×32×32,标签是 0~9 的整数。这个任务的本质是"互斥分类",所以模型输出层应该是10个神经元。

很多人在这里犯的第一个错:把标签做成one-hot向量。nn.CrossEntropyLoss接受的标签是类别的整数索引,不是one-hot。一个形状为(batch, 10)的输出,和一批形状为(batch,)的长整型标签,直接丢给CrossEntropyLoss就好。代码上是这样:

import torch import torch.nn as nn model = SimpleCNN(num_classes=10) # 最后一层输出维度为10 criterion = nn.CrossEntropyLoss() images, labels = next(iter(train_loader)) # labels 形状: (batch,),dtype=torch.long outputs = model(images) # 形状: (batch, 10),全是logits loss = criterion(outputs, labels) # 直接算,不需要手动Softmax

这里不需要对outputs做任何额外处理,PyTorch会自动把它送到log_softmax再算负对数似然。

3.2 PyTorch中两种调用方式与一个隐藏细节

nn.CrossEntropyLoss和F.cross_entropy本质上是同一个东西,前者是模块化封装,后者是函数式调用,训练循环里用哪个都行。但有一个隐藏细节值得注意:CrossEntropyLoss有个ignore_index参数,默认是-100,意味着忽略标签为-100的样本。这个参数在序列标注、目标检测的辅助头里非常常用,但图像分类里你可能永远用不到,知道有这个东西就好。

另一个细节是label_smoothing参数。从PyTorch 1.10之后CrossEntropyLoss原生支持label_smoothing,设置为0.1时,真实标签不再是硬性的[1,0,0,...],而是[0.9, 0.1/9, 0.1/9, ...]。它强制模型不要对训练集过于自信,能起到一定的正则化作用,在ImageNet这种大数据集上配合得当可以让验证集准确率有小幅提升。

3.3 一个完整的多分类训练循环与loss曲线解读

下面给一个可以直接跑的完整训练循环,数据集假设已经做好了DataLoader:

def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, dim=1) correct += (predicted == labels).sum().item() total += images.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy

我一般在每个epoch结束后打印avg_loss和accuracy。如果第一个epoch结束,10分类任务的loss从2.3附近降到1.6左右,acc从10%涨到30%以上,说明模型在正常学习。如果loss一直不降,先别急着加学习率,回头检查数据预处理、标签对齐、模型前向传播有没有bug。

4. 二分类交叉熵的落地路径:BCEWithLogitsLoss与二分类模型的边界

4.1 二分类的Sigmoid输出与标签形状的精确对齐

二分类任务在PyTorch里有两种常见的模型写法。第一种是输出层1个神经元,用Sigmoid把值压到0~1之间,这个值就是"正类"的概率;第二种是输出层2个神经元,接Softmax,相当于把二分类当成一个特殊的互斥多分类来搞。两种做法都能用,但语义和损失函数完全不同。

我推荐第一种:输出层1个神经元 +BCEWithLogitsLoss。原因有三:模型参数更少、数值稳定性更好、语义更直接。但要注意一个容易错的地方——标签的形状。如果模型输出是(batch, 1),那么标签也必须是(batch, 1),而不是(batch,)。PyTorch的广播机制在某些情况下能糊弄过去让代码不报错,但语义不对,loss的计算结果可能跟你想的差很多。稳妥起见,用labels.unsqueeze(1)把标签从(batch,)变成(batch, 1):

import torch.nn.functional as F outputs = model(images) # 形状: (batch, 1),logits labels = labels.unsqueeze(1).float() # 形状: (batch, 1),数值为 0.0 或 1.0 loss = F.binary_cross_entropy_with_logits(outputs, labels)

注意:labels必须是浮点型,0.0表示负类,1.0表示正类。如果你不小心把标签还是torch.long,会直接报类型错误。

4.2 BCELoss与BCEWithLogitsLoss:选错一个就等着梯度爆炸

这两个名字只差一个WithLogits,但用法天差地别。

  • BCELoss:要求输入已经是经过Sigmoid的概率值。也就是说,模型前向传播要手动加torch.sigmoid。
  • BCEWithLogitsLoss:要求输入是未经过Sigmoid的logits,它在内部做了Sigmoid加BCE的融合计算。

为什么推荐后者?主要是数值稳定性。BCELoss要先对logits做Sigmoid,得到概率p,然后算log(p)。如果logits是一个非常小的负数,Sigmoid后p接近0,log(p)会趋于负无穷,算出来的loss直接是nan。BCEWithLogitsLoss在内部通过数学变换,把log(sigmoid(z))转换成z - log(1 + exp(z))这种形式,用某些分段函数和logaddexp技巧避免溢出。这就是"同样功能但更稳"的底层逻辑。

如果你自己用BCELoss手动接了Sigmoid,一旦遇到极端logits,轻则loss变成inf,重则整个训练断掉。我自己就遇到过:一个二分类场景负样本特别多,模型很容易输出接近0的概率,然后log(0)直接nan,排查了半天才发现是损失函数选型的问题。

4.3 二分类怎么扩展到多标签:只需要扩大输出维度

二分类的Sigmoid + BCE方案有个天然优势——它可以无缝扩展到多标签分类。假设一张图片可能同时包含"猫""狗""马"三种属性,模型输出层从1个神经元改成3个,标签从(batch, 1)变成(batch, 3),每列独立做二分类,损失函数仍然用BCEWithLogitsLoss,唯一的差别是把pos_weight按每个标签单独设置。

这种"独立判断每个标签"的思路,跟Softmax的"所有标签抢总分"是本质对立的。遇到多标签任务,别想着用Softmax硬搞,直接用BCE逐个击破,模型能力更强,调试也更直观。

5. 训练中高频踩坑点:Logits直达、reduction参数与类别不平衡

5.1 从Logits还是从概率算:数值稳定性的底层逻辑

一个很经典的问题:我在模型最后一层手动加了Softmax,然后用F.nll_loss或者torch.log加F.binary_cross_entropy来算损失,结果和直接用CrossEntropyLoss/BCEWithLogitsLoss有什么区别?

从数学上,两者等价;从数值稳定性上,天差地别。Softmax公式里有exp(z),如果某个logits很大,比如z=1000,exp(1000)直接溢出为inf。PyTorch的log_softmax内部会做减去最大值的归一化,log_softmax(z) = z - max(z) - log(Σexp(z - max(z))),这才不会溢出。同理,BCEWithLogitsLoss内部对Sigmoid的log也做了特殊处理。所以永远别自己手写"Softmax + Log + 交叉熵"的朴素版本,直接掉用经过数值稳定性优化的官方实现。

5.2 reduction参数的选择:mean、sum、none分别什么时候用

CrossEntropyLoss和BCEWithLogitsLoss都有reduction参数,默认是'mean',也就是对一个batch内所有样本的loss取平均。这个默认值对绝大多数场景是正确的,但有三个场景需要改:

  1. 想知道每个样本具体的loss值,用于分析异常样本时,用reduction='none'。这会返回一个形状相同的逐样本loss张量,方便你找出哪些样本让模型最"困惑"。
  2. 在梯度累积(gradient accumulation)场景下,为保持总梯度尺度一致,可以用reduction='mean',但在accumulation step时记得除以累积步数。
  3. reduction='sum'用的少,只在自定义loss归一化逻辑时需要。

我的经验是:训练时保持默认'mean'就好,调试时用'none'打印一批loss分布,看看是不是有少数样本贡献了大部分loss。

5.3 类别不平衡:用pos_weight而不是手动放大loss

二分类任务里经常会遇到正负样本比例悬殊的情况,比如缺陷检测,正常样本几千张,缺陷样本几十张。这时直接训练,模型会把所有样本都预测为负类,因为这样loss最小,acc还能高达95%以上——假象。

对策之一是给BCEWithLogitsLoss传入pos_weight参数。它本质上是在正类loss项前面放大系数,控制模型对少数类的学习力度。具体值是负样本数除以正样本数,比如正常样本980,缺陷样本20,那pos_weight = 980/20 = 49:

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

多分类场景则可以用torch.nn.CrossEntropyLoss(weight=class_weights),类别权重向量形状为(C,)。记住一个原则:用权重参数调节类别失衡,而不是简单地把少数类样本复制N遍——复制样本会改变batch内的梯度分布,还容易过拟合。

5.4 验证模型是否"真在学":打印预测分布

我调试一个新手项目时,习惯在训练开始后的头几个batch打印模型的预测分布。代码如下:

outputs = model(images) probs = torch.softmax(outputs, dim=1) # 多分类 print(probs[:5]) # 看看前5个样本的预测概率分布

如果是多分类任务,理想的初始状态是每个样本的概率都大约均匀分布,比如10个类别都在0.1左右。如果打印出来全是接近0或1的极端值,说明模型输出层可能有问题,比如初始化异常、logits值过大。如果是二分类任务,打印Sigmoid后的值,如果全在0.5附近,说明模型还没"见世面",正常;如果一开始就极度自信,几乎全0或全1,那几乎可以断定特征提取出了问题。

6. 从损失值读训练状态:初始loss估算与调参方向

6.1 初始loss应该在哪个量级:用信息论做估算

很多人训练半天,loss不降就开始焦虑,但他根本没想过"初始loss应该大概是多少"。

如果模型是随机初始化的,在10分类任务里,第一层特征几乎没学什么,Softmax输出会近似于均匀分布,每个类别概率大约0.1,交叉熵约为ln(10) ≈ 2.30。所以你看到训练刚开始,loss在2.3附近是正常的。如果低于这个值很多,比如只有0.8,说明模型已经有了强烈的先验分类倾向,要么数据预处理有问题,要么模型结构有问题。同理,二分类随机初始化的loss应该接近ln(2) ≈ 0.693。如果初始loss远远小于这个值,警惕模型在"偷懒"。

6.2 三类典型loss曲线的解读与对应策略

曲线特征可能原因调试建议
loss缓慢下降但acc不动学习率太小 / 特征提取能力不足增大学习率;换更强骨干;检查数据增强
loss持续波动且不降学习率太大 / 损失函数选错降低学习率;核对标签与损失函数匹配
训练loss降、验证loss反弹过拟合加正则化;加数据增强;引入label smoothing
loss直接nan数值溢出 / 梯度爆炸检查是否误用了BCELoss+手工Sigmoid;降低学习率;检查数据是否含NaN

6.3 我个人在分类项目中的一点调试体会

我做过的分类项目多了之后,发现一个规律:80%的分类任务"loss不降"问题,最后都能归因到数据标签错误或者损失函数与标签格式不匹配。模型本身的反而是小问题。

举个例子:有一次二分类任务,验证集acc卡在88%上不去,怎么调都不行。后来打印了一些预测错误的样本,发现"正样本"里有不少其实是包含多种缺陷的复合图片,但标注只标了其中一种标签。这类"标注不干净"的问题,靠调超参数永远无法解决。所以我现在养成了一个习惯:一旦loss曲线表现异常,先花时间去看预测错误的样本,而不是急着调学习率、改网络结构。这是线性成本最低的调试手段。

最后再分享一个习惯:我在训练循环里除了打印loss,还记录每个epoch结束时的验证集loss和准确率,存成列表,训练完直接画一条曲线。盯着曲线跟盯着实时日志完全是两种体验。曲线能让你一眼看出过拟合开始的位置、瓶颈期在哪、什么时候该停。这个项目的完整代码骨架,我在网上开源了一套简化版,核心逻辑跟我上面写的完全一致,唯一多的是一个参数解析器和一个TensorBoard hook。大家在自己的项目里,不用照抄我的代码,只要把损失函数、输出层设计、标签格式这三者对齐了,无论多复杂的数据集都能少走一半弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进