在PyTorch里做分类任务,交叉熵损失函数基本是默认选项。多分类和二分类的场景虽然都叫“交叉熵”,但实现方式、代码写法、容易踩的坑完全不一样。我平时在群里看到最多的提问就是“为什么我的损失不降”“为什么CrossEntropyLoss报错”,归根结底是对交叉熵的原理和接口细节没吃透。这篇文章就用图像分类(多分类)和二分类任务两个具体场景,把交叉熵的底层逻辑、PyTorch实现、调参心得和排查技巧一次说清楚,适合刚入门深度学习、以及用PyTorch写分类模型但总出各种奇怪问题的朋友。
1. 交叉熵的底层逻辑(为什么分类任务非它不可)
1.1 从信息量到交叉熵
要真正理解nn.CrossEntropyLoss,不能把它当成黑盒。先讲信息论里那个稍微绕脑的概念怎么落到损失函数上。
一件事发生的概率越小,带来的信息量就越大。比如“明天太阳从东边升起”这句话信息量几乎为零,因为概率为1;“明天某个冷门球队爆冷夺冠”信息量就很大,因为概率低。放在分类场景里,真实标签分布p是确定的(某样本属于第3类,那第3类概率是1,其他是0),模型预测分布q是模型输出的概率估计。交叉熵的数学形式是:
H(p, q) = -Σ p(x) log(q(x))
它度量的是“用q这个分布去描述真实分布p”所需要的平均编码长度。交叉熵越小,说明q和p越接近。因此我们把交叉熵当作损失函数,优化器的目标就是不断减小它,让模型预测概率逼近真实标签的分布。
1.2 为什么分类损失首选交叉熵而不是MSE
我刚入门时也踩过这个坑:用均方误差(MSE)去训练一个图像分类模型,结果模型预测概率总是模模糊糊,loss降不下去。原因在分类问题的输出端通常接softmax(多分类)或sigmoid(二分类),这两类函数在输入很大或很小时候曲线都非常平坦,梯度趋近于0。
交叉熵和softmax/sigmoid的组合非常有意思。推导之后你会发现,交叉熵对logits的梯度形式是(p_pred - p_true),也就是“预测概率减真实概率”。这意味着预测错得越离谱,梯度就越大,模型更新越快,不存在梯度饱和的问题。而MSE的梯度里还额外乘了一个激活函数导数项,当激活函数饱和时,无论预测和真实差距多大,梯度都接近0。这就是分类任务里交叉熵不可替代的原因。
1.3 logits到底是什么
模型最后一层通常在输出每个类别的“分数”,PyTorch里叫logits,可以是任意实数。要让这些分数变成概率,多分类需要softmax归一化,二分类需要sigmoid压缩到0~1之间。nn.CrossEntropyLoss和nn.BCEWithLogitsLoss都设计成直接接收logits,内部自动做log_softmax或sigmoid,这样的组合数值稳定性更好,也省去手动转换的麻烦。
提示:如果先在模型内部手动调用
F.softmax得到概率,再传给CrossEntropyLoss,相当于重复套了一次softmax。这不会报错,但会导致梯度和数值精度都出现问题,后面第2章我会展开说。
2. 多分类交叉熵:图像分类的标配方案
2.1 CrossEntropyLoss的设计逻辑
PyTorch里的nn.CrossEntropyLoss实际上做了两个连续操作:对输入logits先进行log_softmax,再计算负对数似然(NLL Loss)。把两步合并,是为了防止中间结果出现精度溢出。比如某个logit是1000000,直接softmax再取log会因为精度限制变成NaN,合并后才能保持稳定。
这个函数有一个跟很多框架不一样的约定:target必须是LongTensor类型的类别索引,而不是one-hot向量。比如一张图属于第3类,target就是一个标量3,不是[0,0,1,0,0]。新手最容易在这里报错,把one-hot或者整数张量传给loss后,要么报“Expected dtype long”,要么loss形状计算异常。
另外,模型的输出维度必须是[batch_size, num_classes],target是[batch_size],二者不需要维度完全一致,PyTorch会自动根据类别索引去取对应的log_softmax值。
2.2 一个可以直接跑通的图像分类训练段
我拿一个模拟图像数据来演示。假设输入是3通道的32x32图片,我们要做10分类。用一个简化版卷积网络,流程和真实图像分类任务完全一致。
import torch import torch.nn as nn import torch.optim as optim class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) # 输入32x32,经过两次池化后是8x8,所以展平特征维度是64*8*8 self.classifier = nn.Linear(64 * 8 * 8, num_classes) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x) # 输出logits model = SimpleCNN(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) # 模拟训练循环,这里只跑100步,看loss下降趋势 for step in range(100): inputs = torch.randn(16, 3, 32, 32) targets = torch.randint(0, 10, (16,), dtype=torch.long) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() if step % 20 == 0: print(f"step {step}, loss {loss.item():.4f}")这段代码输出是logits,shape为[16, 10],target是[16],类型为long。只要这两点对,交叉熵部分就不会出问题。很多实际项目里,预训练模型的分类头也是这种结构,只是特征提取部分换成了更深层的ResNet或Vision Transformer。
2.3 关键参数与实操注意
nn.CrossEntropyLoss有几个参数,在日常项目中非常有用:
| 参数 | 作用 | 我常用的场景 |
|---|---|---|
| weight | 给每个类别一个权重,应对样本不均衡 | 医疗图像病变成分极少的场景 |
| ignore_index | 忽略某个类别索引不参与loss计算 | 语义分割中忽略标签为255的边界像素 |
| label_smoothing | 对one-hot标签做平滑,防止过拟合 | 样本量不大时效果明显 |
weight的设置在图像分类中很实用。假设第0类样本特别少,第1类特别多,可以设置weight = 类别频率倒数的归一化版本。不过要注意,加了weight之后loss的量级会变化,可能需要对学习率重新调,否则会出现收敛过慢或震荡。
label_smoothing我试过很多次,它把硬标签从0和1变成比如0.05和0.95,强迫模型不要对训练样本过于自信,从而提升泛化。在某个模拟项目里,把label_smoothing从0改成0.1后,测试集准确率提升了一个百分点左右,代价是训练集准确率略微下降,这是正常现象,别慌。
3. 二分类交叉熵:二分类任务的正确打开方式
3.1 BCEWithLogitsLoss vs BCELoss
二分类在PyTorch里有两个相似的损失函数:nn.BCELoss和nn.BCEWithLogitsLoss。最具迷惑性的一点是,两者的输入要求完全不同。用一张表说清:
| 损失函数 | 输入要求 | 内置sigmoid | 推荐程度 |
|---|---|---|---|
| BCELoss | 值域必须在[0,1],即已通过sigmoid的概率 | 不内置 | 低,容易数值不稳定 |
| BCEWithLogitsLoss | 任意实数,即模型原始logits | 内置 | 高,日常训练首选 |
我有段时间习惯先自己算sigmoid再传给BCELoss,后来在某个模拟数据集上发现训练初期频繁出现loss为NaN。排查了很久,发现是极端logits使sigmoid输出太接近0,然后编译器在处理log(0)的时候精度崩了。而BCEWithLogitsLoss内部把sigmoid和log合并成稳定计算的数学形式,彻底避免了这个坑。
所以二分类任务里,我强烈建议只用BCEWithLogitsLoss,别给自己找麻烦。
3.2 二分类任务的代码细节
二分类输出可以是两种形状:一种是[batch_size, 1],一种是[batch_size]。我推荐统一用[batch_size, 1],看起来干净,也不容易在计算指标时出错。
import torch import torch.nn as nn import torch.optim as optim model = nn.Linear(32, 1) # 最后一层输出单个logit criterion = nn.BCEWithLogitsLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) for step in range(100): inputs = torch.randn(16, 32) targets = torch.randint(0, 2, (16, 1), dtype=torch.float) optimizer.zero_grad() outputs = model(inputs) # shape [16, 1] loss = criterion(outputs, targets) loss.backward() optimizer.step() if step % 20 == 0: print(f"step {step}, loss {loss.item():.4f}")这里有几个必须留神的地方。第一,targets的类型不能是long,必须是float,因为二分类交叉熵内部做的是二进制交叉熵计算,不存在类别索引取值的操作。第二,outputs和targets形状要一致或可以广播,建议直接用相同形状,省得某些PyTorch版本出现莫名其妙的广播错误。第三,预测阶段要用torch.sigmoid(outputs)把logit转成概率,再和0.5比较,或者直接判断logit是否大于0,因为sigmoid和阈值0.5的组合等价于logit>0,但为了代码可读性我还是会显式sigmoid。
在真实的二分类业务场景里,比如判断一张图片是否包含缺陷,模型要输出一个0~1的缺陷概率。训练集标签是0或1,模型每个batch输出一个标量logit,训练用BCEWithLogitsLoss,推理时用sigmoid压缩后取阈值。这种任务的难点反而不在loss,而在阈值怎么选,但那是后话。
3.3 多分类和二分类的统一视角
从数学上讲,二分类其实可以看成多分类的特例:类别数为2,所以softmax退化为sigmoid,交叉熵的求和相应地变成单项。反过来,多分类问题可以拆成多个二分类头做多标签分类。比如一张图片同时判断“是否包含猫”“是否包含狗”“是否包含鸟”,每个头都输出一个logit,三个头各自用BCEWithLogitsLoss,然后求平均作为总loss。这种做法的好处是每个类别独立建模,类别之间没有互斥假设,比多分类柔和很多。
我在做一个模拟项目时,需要在图像里同时检测多个物体是否存在,就用了多个二分类头。如果只用多分类softmax,模型会强迫每张图只属于一个标签,跟真实场景完全不符。所以理解两类损失的本质,才能在业务里灵活选用。
4. 训练中的常见问题与排查技巧
4.1 loss一直不降,卡在某个固定值
这个问题我见得太多了。二分类任务里,如果loss卡在0.693附近,说明模型输出概率接近0.5,也就是模型完全没有学到任何有效特征。多分类任务里,如果类别数是C,loss卡在log(C)附近,比如10分类卡在2.3026左右,说明模型输出接近均匀分布,跟瞎猜差不多。
排查顺序我先看两件事:一是数据是否归一化,比如图像像素除以255;二是学习率是否太大。某个模拟项目里把学习率设为0.1,loss震荡不降,降到1e-3后才稳定收敛。回头想,这是Adam对学习率比较敏感的表现,虽然Adam自适应调整步长,但初始学习率过大时照样原地踏步。
如果数据没问题、学习率也正常,就要怀疑模型容量是否足够。特征提取网络太浅,或者全连接层太少,模型学不动,loss就会长期维持高位。
4.2 loss变成NaN
交叉熵的数值稳定性虽然优化得不错,但NaN还是可能出现。常见原因有三个:输入数据本身包含NaN;梯度爆炸导致参数变成NaN;前面提到的重复套softmax或sigmoid导致中间过程精度溢出。
排查技巧是先检查数据里有没有异常值,再打印梯度看是不是NaN:print([p.grad for p in model.parameters()])。如果梯度正常但loss是NaN,八成是loss计算过程中的数值问题。调小学习率或者给输入加epsilon也很难根治,最有效的还是恢复到自己更可控的损失函数组合上,比如确认模型输出端没有额外接激活函数。
4.3 训练模型时评估阶段的低级错误
训练时模型的Dropout和BatchNorm是按训练模式跑,推理时要切成eval模式。我见过有人在验证集上用model.train()跑,结果Dropout随机丢弃神经元,验证指标忽高忽低,还以为是loss写错了。
另一个低级错误是计算准确率时把逻辑搞混。多分类任务里,如果模型直接返回logits,取pred = outputs.argmax(dim=1)就是预测类别,不需要先过softmax,因为softmax是单调函数,只会压缩整体数值范围,不改变类别间的大小关系。二分类任务里,如果模型返回logit,判断时用sigmoid输出概率再和0.5比较;如果你想偷懒,也可以直接看logit大于0还是小于0,结果完全等价。但为了给团队其他人看得懂,我会保留显式的sigmoid步骤。
4.4 我在实际调参时积累的几条经验
第一,记录每个step的loss时务必用loss.item(),不要在打印里直接引用loss张量。loss持有完整计算图,每个epoch结束如果不释放,显存会越积越多,最后OOM。loss.item()则只把数值取出来,不保留计算图,我每次写训练脚本都会检查打印语句没顺手把loss挂住。
第二,类别极端不平衡时,先别急着上复杂的Focal Loss,把CrossEntropyLoss或BCEWithLogitsLoss自带的weight参数用好已经能解决大部分问题。在多分类里,我计算每个类别样本数比例的倒数,然后归一化,效果立竿见影。二分类里同理,给少数类更高的权重即可。加了weight之后,损失函数量级会变,学习率建议相应调低。
第三,训练图像分类模型时,我习惯把验证集loss和训练集loss一起记录。如果训练集loss持续下降但验证集loss开始反弹,说明过拟合出现了,这时候优先调整数据增强和dropout比例,而不是急着改loss函数。交叉熵本身不是过拟合的原因,只是过拟合的现象会在loss上体现得比较明显。
最后再分享一个小技巧。交叉熵也好,BCE也罢,它们的输入都是模型原始的logits,不是经过概率变换后的值。每次写模型定义时,我会把最后一层单独裸写不带激活函数,训练时把logits直接丢给loss,推理时单独再做sigmoid或softmax。这个习惯帮我省了无数debug时间,也让网络结构更清晰,强烈推荐你也这样约定好,不然下次你回忆某个网络到底有没有套激活函数时,真的会想半天。