1. 从一次诡异的 loss=0.693 说起
多标签分类任务里,BCEWithLogitsLoss大概是出镜率最高、也最容易被用错的一个损失函数。我见过太多次类似的场景:模型结构写得没问题,数据管道也跑通了,训练日志里 loss 稳稳地停在 0.693 附近,一条直线,怎么调学习率都不动。0.693 正好是 ln2,也就是模型对所有类别都输出 0 时二元交叉熵的值——这个数字基本等于在喊:"我的梯度没有有效传回去。" 往回翻代码,十有八九是logits 与标签的形状对不上,或者标签的 dtype、取值范围出了岔子。
这篇内容就围绕这件事展开:BCEWithLogitsLoss在多标签分类里到底在算什么、logits 和 target 的形状必须满足什么约束、为什么很多人写的形状"看起来对"但语义是错的、以及在实际训练中怎么用一套固定的检查流程把这些坑挡在训练开始之前。不管你是刚接触多标签任务、还在纠结要不要在模型最后加Sigmoid,还是已经写过几个项目但总在 loss 不降的时候抓瞎,下面这些内容应该都能对得上号。
需要说明的是,PyTorch 官方文档对这个函数的描述其实相当克制,只有寥寥几行公式和一个pos_weight的说明,真正让人栽跟头的形状语义、dtype 约束、广播行为,文档里几乎没提。我下面讲的内容,一部分来自源码阅读,一部分来自实际项目里反复踩出来的经验,涉及具体参数取值的地方会说明推导过程,你可以直接对照自己的代码核验。
2. BCEWithLogitsLoss 在多标签任务里到底在算什么
2.1 逐元素语义:它天生就不是"一个样本一个 loss"
理解这个函数最关键的一步,是搞清楚它的计算粒度。BCEWithLogitsLoss是逐元素计算的,不是逐样本。假设你的输入 logits 形状是(N, C),N 是 batch size,C 是类别数,那么它会产出N × C个独立的二元交叉熵值,然后根据reduction参数把这些值聚合起来。
这一点和多分类任务里用的CrossEntropyLoss有本质区别。CrossEntropyLoss的输入是(N, C),但它内部先做 softmax,把 C 个 logits 归一化成概率分布,再取正确类别的负对数。也就是说 C 个类别之间是互斥竞争关系,一个样本只有一个正确答案。而BCEWithLogitsLoss对每个(n, c)位置单独算一次 sigmoid,每个位置独立判断"是/不是",C 个类别之间没有任何约束关系,可以全是 1,也可以全是 0。
这个差异决定了标签的构造方式。多分类任务里标签是(N,)的 long 张量,取值为类别索引;多标签任务里标签必须是和 logits 同形状的浮点张量,取值为 0 或 1(也允许 0 到 1 之间的软标签,这点后面会讲)。如果你把多分类那套(N,)的索引标签直接喂给BCEWithLogitsLoss,PyTorch 会直接报形状不匹配的错,这算是比较幸运的情况;更麻烦的是形状凑巧能对上但语义错了,那种情况不报错,只是模型学不到东西。
数学形式写出来是这样,对单个元素:
l = -[ y · log(σ(x)) + (1 - y) · log(1 - σ(x)) ]其中x是 logit,σ是 sigmoid,y是目标值。加上weight和pos_weight之后变成:
l = -w · [ p · y · log(σ(x)) + (1 - y) · log(1 - σ(x)) ]w是逐元素的权重(形状和 input 一致),p是正样本权重(形状为(C,),按类别广播)。这两个参数的区别很多人搞混:weight调的是"这个位置整体多重要",pos_weight调的是"这个位置上正样本比负样本重要多少倍"。多标签任务里 99% 的情况你需要的是pos_weight,不是weight。
2.2 为什么它要求 target 必须和 input 同形状
F.binary_cross_entropy_with_logits内部第一步就会检查target.size() == input.size(),不等就直接抛ValueError。这个检查看起来严格,其实是保护你。
我见过一种很典型的写法:模型输出(N, C),标签因为从某个 CSV 里读出来变成了(N,),然后作者顺手做了个target.unsqueeze(1),形状变成(N, 1)。这时候如果 C 恰好是 1,能跑通;C 大于 1,报错。但如果作者又用了target.expand(N, C)或者repeat,形状对上了,语义却彻底错了——所有类别共用同一个标签值,模型被训练成"这 C 个类别要么全正要么全负"。这种错误不会报错,只会让 loss 卡住,或者准确率永远在基线附近晃。
还有一种更隐蔽的情况:标签是从多标签的 one-hot 编码转过来的,但转换时用了argmax。argmax会把多标签压成单标签,一个原本有 3 个正类的样本变成只有 1 个正类。形状是对的,值是错的。这种问题只能靠打印几个样本的标签和原始数据对照才能发现。
我在实际项目里养成了一个习惯:在 Dataset 的__getitem__里断言一次形状和 dtype,代价可以忽略,但能把这类问题在数据加载阶段就拦下来。
import torch def check_batch(logits, targets, num_classes): assert logits.dim() == 2 and logits.size(1) == num_classes, \ f"logits shape {tuple(logits.shape)} 与 num_classes={num_classes} 不符" assert logits.shape == targets.shape, \ f"形状不匹配: logits {tuple(logits.shape)} vs target {tuple(targets.shape)}" assert targets.dtype == logits.dtype, \ f"dtype 不匹配: logits {logits.dtype} vs target {targets.dtype}" assert torch.all((targets >= 0) & (targets <= 1)), \ "target 存在 [0,1] 之外的值"这四行断言,覆盖了我遇到的八成形状和数值问题。
2.3 常见形状组合一览
为了让这件事更直观,把多标签任务里几种典型形状列出来对照一下。假设 batch size 是 32,类别数是 20:
| 场景 | logits 形状 | target 形状 | 是否可用 | 说明 |
|---|---|---|---|---|
| 标准二维多标签 | (32, 20) | (32, 20) | 可用 | 最常见,推荐 |
| 单类别二分类 | (32, 1) | (32, 1) | 可用 | 等价于 (32,) 但更统一 |
| 标签被压成一维 | (32, 20) | (32,) | 报错 | size 检查会拦下 |
| 标签 expand 后 | (32, 20) | (32, 20) | 能跑但错 | 所有类别共用同一标签 |
| 标签是 long 索引 | (32, 20) | (32,) long | 报错 | 应为 float 且同形状 |
| 图像分割 | (32, 1, 256, 256) | (32, 1, 256, 256) | 可用 | 逐像素独立判断 |
| 序列标注 | (32, 128, 20) | (32, 128, 20) | 可用 | 逐位置逐标签判断 |
表格里第四行值得多说一句。expand出来的张量形状对了,但所有列共享同一个底层数据,BCEWithLogitsLoss会照单全收,一个错误都不报。等训练跑完发现模型对所有类别的输出几乎一样,才会回头怀疑到这里。
3. 不加 Sigmoid 而用 BCEWithLogitsLoss 的真实原因
3.1 log-sum-exp 技巧解决了什么
先说结论:模型最后一层不要加Sigmoid,直接把线性层输出喂给BCEWithLogitsLoss。这不是风格问题,是数值稳定性问题。
朴素实现是先把 logit 过 sigmoid 得到概率p,再算-y·log(p) - (1-y)·log(1-p)。当 logit 是一个很大的负数,比如 -50,σ(-50)在 float32 下会直接下溢成 0,log(0)得到-inf,反向传播时-inf相关的梯度是 NaN。训练跑着跑着 loss 变成 NaN,往往就是从这一步开始的。
BCEWithLogitsLoss内部用的是等价但数值稳定的形式:
loss = max(x, 0) - x·y + log(1 + exp(-|x|))推导一下:把log σ(x)展开,σ(x) = 1/(1+e^{-x}),所以log σ(x) = -log(1+e^{-x})。当 x 很大时e^{-x}接近 0,log(1+e^{-x})好算;当 x 很小时e^{-x}会溢出,所以要用log(1+e^{-x}) = -x + log(1+e^{x}),即写成-x + log(1+e^{-|x|})的形式。合并两种情况就是上面的式子,log(1+e^{-|x|})里的指数永远是非正的,不会溢出。
sigmoid + BCELoss的组合把这两步拆开了,中间结果p会被存成 float32 再传给log,下溢就发生在这一步。而BCEWithLogitsLoss把这个中间量在计算图里消掉了,只在最后一步才需要概率。
3.2 梯度形式为什么更干净
从梯度角度看得更清楚。对BCEWithLogitsLoss,损失对 logit 的偏导是:
∂l/∂x = σ(x) - y非常干净,取值永远在[-1, 1]之间,不会爆炸。而如果是sigmoid → BCELoss分开写,反向传播要经过 sigmoid 的导数σ(x)(1-σ(x)),这个值在 x 很大或很小时接近 0,会进一步缩小梯度,加重梯度消失。
这里还有一个常被忽略的细节:如果你在模型里加了Sigmoid,同时又用BCEWithLogitsLoss,那等于做了两次 sigmoid。因为BCEWithLogitsLoss内部会再做一次。结果等价于对σ(σ(x))做计算,输出范围被压得更窄,梯度也小得可怜。这个错误极难从代码里一眼看出来,因为形状完全正确,只有在检查模型定义时才会发现多了一层。
3.3 实测对比:极端 logits 下的行为差异
我做过一个简单的对照实验,用同一批 logits(包含 ±30 的极端值),比较两种写法的表现:
import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(0) logits = torch.tensor([[-30.0, -5.0, 0.0, 5.0, 30.0]], requires_grad=True) target = torch.tensor([[0.0, 0.0, 1.0, 1.0, 1.0]]) # 写法 A: sigmoid + BCELoss logits_a = logits.clone().detach().requires_grad_(True) p = torch.sigmoid(logits_a) loss_a = F.binary_cross_entropy(p.clamp(1e-7, 1 - 1e-7), target) loss_a.backward() print("A loss:", loss_a.item(), "grad:", logits_a.grad) # 写法 B: BCEWithLogitsLoss logits_b = logits.clone().detach().requires_grad_(True) loss_b = F.binary_cross_entropy_with_logits(logits_b, target) loss_b.backward() print("B loss:", loss_b.item(), "grad:", logits_b.grad)写法 A 里我特意加了clamp,因为不加的话log(0)会直接产出inf,整个 loss 变成inf,backward 之后梯度是 NaN。加了 clamp 之后能跑,但 clamp 本身会截断梯度——在边界上梯度恒为 0,等于告诉模型"这个位置不用学了"。而写法 B 不需要任何 clamp,loss 是有限值,梯度就是标准的σ(x) - y。
注意:如果你在代码里见到
torch.sigmoid(...)之后跟F.binary_cross_entropy(...),并且中间还夹了一个clamp,那基本可以确定这段代码是从某个早期教程抄来的。改成binary_cross_entropy_with_logits直接一行,少一次 sigmoid,少一次 clamp,数值还更稳。
3.4 AMP 混合精度下的一个额外提醒
用自动混合精度训练时,BCEWithLogitsLoss在 autocast 区域里会自动以 float32 计算,这是 PyTorch 有意为之的——这个 loss 对精度比较敏感,半精度下log(1+exp(-|x|))的精度损失会累积。所以一般不需要手动干预。但如果你的模型在 autocast 之外单独调用这个 loss,或者用了自定义的 autocast 策略,就要留意 logits 的 dtype 是不是被意外转成了 half。可以用logits.float()在进 loss 之前强转一次,代价很小。
4. logits 与 target 的形状对齐规则
4.1 二维是标准形态,但不是唯一形态
(N, C)是最常见也最推荐的形态。模型输出(N, C),标签也是(N, C),一个位置对应一个类别,一一对应,没有歧义。
有些实现会把 logits 写成(N, C, 1)或(N, 1, C),只要标签跟着一样,也能跑。但我不推荐这种写法,因为一旦后面接了什么 view、flatten、或者换成别的损失函数,就会开始打架。更好的做法是在模型最后一层之后统一squeeze到(N, C)。
还有一类特殊情况是 N=1 或者 C=1 的时候。如果 batch 是 1,形状变成(1, C),某些取消了一维的写法会把它压成(C,),这时形状检查会报错,但很多人图省事用squeeze()而不是squeeze(0),把类别维度也压掉了。这是个高频错误,尤其在没有明确 batch 维度的简易脚本里。永远用squeeze(dim)指定维度,不要用无参数的squeeze(),这是我吃了好几次亏之后定下的规矩。
4.2 多维输出:分割、序列标注、注意力池化
多标签不只是分类任务。图像分割里,每个像素是一个独立的二分类问题,logits 形状是(N, C, H, W),标签也是(N, C, H, W)。序列标注里,每个时间步、每个标签独立判断,形状是(N, L, C)。这两种情况下BCEWithLogitsLoss一样能用,因为它就是逐元素的。
但要注意reduction='mean'的语义会随之变化。mean是对所有元素求平均,所以分割任务里 loss 的量纲和分类任务不一样,不能直接对比。如果不同 batch 的有效像素数量差异很大(比如 padding 很多),mean会让 loss 被 padding 区域稀释。这时候要么用reduction='none'自己按 mask 加权求和再除有效元素数,要么在标签和 logits 上都把 padding 区域屏蔽掉。
# 序列标注里按 mask 加权 loss_elem = F.binary_cross_entropy_with_logits( logits, targets, reduction='none' ) # (N, L, C) mask = mask.unsqueeze(-1).expand_as(loss_elem).float() loss = (loss_elem * mask).sum() / mask.sum().clamp(min=1)这段代码看着简单,但mask.sum()要加clamp(min=1),否则某个 batch 全是 padding 时会除零,loss 变成 NaN,然后污染所有参数。这种 NaN 特别难查,因为它是偶发的,只在特定的数据分布下出现。
4.3 target 的 dtype 与取值范围陷阱
这是我最想强调的一部分。BCEWithLogitsLoss的 target 必须是浮点类型,且和 input 的 dtype 一致。常见的两个错误:
第一个,target 是torch.long。这通常发生在从标签文件读数据时用了torch.tensor([1, 0, 1]),PyTorch 推断成 long。前向传播不会立刻报错(内部会做类型提升),但反向传播时会抛出一个很奇怪的错:RuntimeError: result type Float can't be cast to the desired output type Long。这个报错信息完全不提 target 的 dtype 问题,第一次遇到基本靠搜索。解决方法是target = target.float()或者target.to(logits.dtype)。
第二个,target 取值超出[0, 1]。这通常来自标签的编码方式,比如从{-1, 1}编码来的,或者标签文件里直接存了类别 ID(0, 1, 2, 3……)。BCEWithLogitsLoss不会检查取值范围,它照算不误,只是算出来的值没有意义。更麻烦的是这种错误不会报错,只会让 loss 曲线看起来很怪——比如一开始就很低,然后缓慢变化,因为损失函数在算一些根本不对应标签语义的东西。
我一般的做法是在数据管道最后加一步显式归一化:
targets = (targets > 0).float() # 把任意正数归一到 1注意这个写法会把-1也变成 0,如果你用的是{-1, 1}编码,那正好合适;但如果是多类别 ID 编码,这个操作就完全错了。所以这一步之前必须确认标签的原始编码方式,不能想当然。
顺带说一句软标签。BCEWithLogitsLoss支持 0 到 1 之间的连续值作为 target,这在知识蒸馏、标签平滑里很有用。但要注意标签平滑在多标签任务里的常见写法是target = target * (1 - eps) + 0.5 * eps,把 0 和 1 都往 0.5 靠;而不是多分类任务里的(1-eps) + eps/C。这两种写法混用是另一个常见错误。
5. pos_weight 与 reduction 的取舍
5.1 pos_weight 怎么算才不算错
多标签任务几乎必然遇到类别不平衡。20 个类别里,有的类别 50% 样本都是正,有的只有 0.5%。这时候如果不加权,模型会倾向于把稀有类别全预测成负,因为这样 loss 最低。
pos_weight就是解决这个的。按 PyTorch 文档的定义,pos_weight是正样本的权重,形状为(C,),会按类别广播到(N, C)。加权的效果是:正样本的 loss 项乘上这个系数,负样本项不变。
最常见的取值是neg_count / pos_count,也就是负样本数除以正样本数。这个比例在第 c 类上意味着:为了让这一类的正负样本对 loss 的贡献相等,正样本需要被放大多少倍。
# 假设 labels 是 (num_samples, num_classes) 的 float 张量 pos_counts = labels.sum(dim=0).clamp(min=1) neg_counts = labels.size(0) - pos_counts pos_weight = (neg_counts / pos_counts).to(device)clamp(min=1)是防止某个类别完全没有正样本时除零。但要注意,如果某个类别真的一个正样本都没有,那pos_weight设成很大也没用,模型学不会它。这种情况更应该考虑删掉这个类别,或者去补数据。
pos_weight的常见误用有三种。第一种,和weight同时用,两个权重相乘,效果被放大了好几倍,loss 直接爆掉。第二种,pos_weight只算了训练集的一部分,比如每个 batch 内单独算,导致不同 batch 的权重分布不一致,训练不稳定。正确做法是在整个训练集上算一次,固定下来。第三种,取值过大,比如比例到 1000 甚至更高,这时候大部分梯度都集中在稀有类别的正样本上,模型容易在这些类别上过拟合,其他类别学不动。我的经验是把比例开个方,或者设一个上限(比如 50),效果往往比原始比例更稳。
5.2 reduction 参数与梯度累积的配合
reduction有三个选项:'mean'(默认)、'sum'、'none'。
'mean'的语义是对所有元素取平均,包括 batch 维和类别维。这意味着不同 batch size 下 loss 的量纲不同。如果你做了梯度累积,比如累积 4 个 batch 再更新一次,那么总的梯度其实是 4 个 batch 的平均值的和,相当于把有效学习率放大了 4 倍。正确的做法是每个 batch 的 loss 除以累积步数,或者干脆用'sum'然后统一除以总元素数。
'sum'在需要自己做加权时很有用。比如前面提到的 mask 场景,或者不同样本有不同权重的情况。用'none'拿到逐元素 loss 再自己聚合,灵活度最高,但要注意处理时的形状。
还有一个容易忽略的点:reduction='mean'在空输入(比如 N=0,或者全部被 mask 掉)时会返回 NaN,因为除以了 0。虽然正常训练不会出现空 batch,但在多卡训练、数据分片不均的时候是有可能的。稳妥的做法是在分布式场景下用reduction='sum'配合手动计数。
5.3 多卡训练下的一个细节
用 DistributedDataParallel 时,每个进程各自计算 loss,梯度在 all-reduce 时取平均。这本身没问题,但如果各个 GPU 上的正样本比例差异很大(比如按类别分了 shard),pos_weight还是应该用全局统计量算,而不是每个进程单独算。后者会让不同进程的梯度尺度不一致,收敛会变慢。
6. 一套完整的训练链路与验证方法
6.1 把形状检查写成单元测试
与其在训练跑挂了之后回溯,不如把检查写成小测试,改模型改数据时跑一遍。下面这个片段覆盖了大部分常见错误:
import torch import torch.nn as nn import torch.nn.functional as F class MultiLabelHead(nn.Module): def __init__(self, in_dim, num_classes): super().__init__() self.fc = nn.Linear(in_dim, num_classes) def forward(self, x): return self.fc(x) # 注意:不加 sigmoid def test_loss_shapes(): N, C, D = 8, 20, 128 model = MultiLabelHead(D, C) x = torch.randn(N, D) logits = model(x) assert logits.shape == (N, C) targets = (torch.rand(N, C) > 0.5).float() loss = F.binary_cross_entropy_with_logits(logits, targets) assert loss.dim() == 0 and torch.isfinite(loss) # 验证梯度存在且有限 loss.backward() grad = model.fc.weight.grad assert grad is not None and torch.isfinite(grad).all() test_loss_shapes()这个测试我建议一直保留在项目里。它的价值不只是验证形状,更是防止有人在重构时往模型最后加了一个nn.Sigmoid()——那种改动会让训练慢慢变差,但不会报错,发现成本极高。
6.2 从 logits 到预测的推理链路
训练时用BCEWithLogitsLoss(不加 sigmoid),推理时必须手动做 sigmoid 再阈值化,这条链路要显式写清楚:
@torch.no_grad() def predict(model, x, threshold=0.5): model.eval() logits = model(x) probs = torch.sigmoid(logits) return (probs > threshold).float(), probs阈值 0.5 不是金科玉律。如果不同类别的最优阈值差异很大(这在类别严重不平衡时很常见),按类别搜索阈值能明显提升 F1。做法是在验证集上对每个类别扫一遍 0.1 到 0.9,选 F1 最高的那个。这个操作成本不高,但对最终指标的影响经常比调模型结构还大。
另外,评估指标也要用对。多标签任务里accuracy几乎没意义,因为大部分位置都是负样本,全预测负也能有很高的准确率。应该用 per-class 的 precision/recall/F1、macro-F1、或者 mAP。看指标的时候要分门别类看,不要只看平均值——平均值会掩盖稀有类别的糟糕表现。
6.3 一条可以直接抄的检查清单
训练前把下面这些过一遍,能省掉大量调试时间。
| 检查项 | 期望状态 | 常见错误 |
|---|---|---|
| 模型最后一层 | 无 Sigmoid | 多加了nn.Sigmoid() |
| logits 形状 | (N, C) | 被 squeeze 成 (C,) |
| target 形状 | 与 logits 完全一致 | 少一维或 expand 出来的 |
| target dtype | 与 logits 相同(float) | 是 long 导致反向报错 |
| target 取值 | 全部在 [0, 1] | 含 -1 或类别 ID |
| loss 初始值 | 接近 ln2 ≈ 0.693 | 明显偏离说明标签有问题 |
| pos_weight 形状 | (C,) | 写成了 (N, C) |
| 推理链路 | 手动 sigmoid | 忘了或重复 sigmoid |
loss 初始值这一条特别有用。如果模型初始化正常、标签平衡,那么训练第一步的 loss 应该接近 0.693。如果一开始就是 0.3 或者 2.0,说明标签分布严重偏斜或者取值范围有问题,这时候继续训练是浪费时间。
7. loss 不降时的排查顺序
7.1 先怀疑数据,再怀疑模型
loss 卡在 0.693 的时候,绝大多数人的第一反应是调学习率、换优化器、加层数。这些操作通常没有用,因为问题在前面。
我的排查顺序是这样的。第一步,打印一个 batch 的标签,看正类比例。如果某个 batch 里全 0,那正常;如果连续十几个 batch 都接近全 0,说明数据采样有问题。第二步,用随机初始化的模型输出 logits,检查均值和方差;如果 logits 全在 0 附近,sigmoid 后都是 0.5,loss 就接近 0.693,这是正常的起点,需要看它是否在几步之后开始下降。第三步,用一个极小的子集(比如 8 个样本)过拟合一下,正常情况下几十步内 loss 应该降到很低;如果降不下去,那标签或者形状基本可以确定有问题。
这个"先过拟合小数据集"的测试是我觉得性价比最高的调试手段。它把数据量、batch 分布、学习率调度这些干扰因素全部去掉,只暴露最核心的问题。任何多标签模型,如果在 8 个样本上过拟合不了,那一定是哪里写错了。
7.2 几个反直觉的现象
有几个现象我遇到过不止一次,值得单独说。
第一个,loss 在下降但指标不动。这通常是因为标签极度不平衡,模型学会了"全部预测负"这个平凡解,loss 缓慢下降是因为负样本的置信度在提高,但 precision 和 recall 都是 0。解决办法是加pos_weight,或者换用 focal loss 这类对易分类样本降权的损失。
第二个,训练 loss 正常但验证 loss 突然飙升。除了过拟合,还要检查验证集的标签处理是不是和训练集一致。我见过一次是验证集的 target 忘了转 float,因为某个分支走的是不同的代码路径,导致验证 loss 一直在算错的东西,只是没报错。
第三个,不同类别之间的 loss 差异巨大。打印 per-class loss 就能看出来。通常是因为某些类别在训练集里只出现几次,模型根本没学到。这种情况要么补数据,要么在评估时把这些类别单独处理,不要混进平均值里。
7.3 我个人的一点习惯
写多标签任务的代码,我现在会把"损失函数 + 形状检查 + 推理转换"这三件事封装在一个小模块里,模型只负责输出 logits,损失和推理都在外面。这样做的好处是,模型可以随便换,loss 和推理链路始终保持一致,不会出现"训练和推理用了两套 sigmoid 逻辑"这种低级错误。
另外,不管项目多小,我都会在训练脚本开头跑一次前面那个test_loss_shapes。十秒钟的事,但它拦下的问题比我事后调试省下的时间多得多。BCEWithLogitsLoss这个函数本身很简单,麻烦的永远是围绕它的形状、dtype 和编码约定——把这些约定写死成测试,比记在脑子里可靠。