☰
PyTorch损失函数详解:BCELoss与BCEWithLogitsLoss的对比与应用
2026/10/2 18:42:04 网站建设 项目流程

先说结论:如果你在 PyTorch 里做二分类、多标签分类或者类似的任务,几乎绕不开BCELoss和BCEWithLogitsLoss这两个名字。很多初学者包括我自己刚入门的时候,都踩过“为什么我用了 BCELoss 就报错”“为什么训练出来 loss 是 nan”“为什么两个函数算出来的结果不一样”这类坑。这篇博客我就把这两个函数从原理到实现、从参数到坑点一次性讲透。

先说清楚它们各自能干什么:BCELoss是“输入已经过 sigmoid 的概率值”之后计算二分类交叉熵;BCEWithLogitsLoss则是把 sigmoid 层和 BCELoss 合并成一个操作,直接吃模型的原始 logits 输出。后者在数值稳定性和工程实现上都更推荐。

这篇文章适合谁看?刚接触 PyTorch 的初学者,正在做图像分类、文本分类、多标签任务的同学,以及那些想知道“为什么官方文档推荐用 BCEWithLogitsLoss”但没搞懂深层原因的人。

1. 先搞清楚 BCE 到底在计算什么

1.1 从交叉熵到二元交叉熵

在聊代码之前,我们先从数学上把这两个函数的身世捋清楚。深度学习里的分类问题,最常用的损失函数是交叉熵(Cross Entropy)。它衡量的是模型预测的概率分布和真实标签分布之间的差异。多分类任务里,我们用的是 categorical cross entropy,对应 PyTorch 里的CrossEntropyLoss。

而二分类任务,也就是标签只有 0 和 1 的情况,交叉熵会退化成一种更简洁的形式,这就是二元交叉熵(Binary Cross Entropy)。它的公式长这样:

[ L = -\frac{1}{N} \sum_{i=1}^{N} \left[ y_i \cdot \log(p_i) + (1 - y_i) \cdot \log(1 - p_i) \right] ]

其中 (y_i) 是真实标签(0 或 1),(p_i) 是模型预测为正类(标签为 1)的概率。单个样本的 loss 可以拆开看:

  • 当 (y=1) 时,loss 是 (-\log(p)),预测概率 (p) 越接近 1,loss 越小;
  • 当 (y=0) 时,loss 是 (-\log(1-p)),预测概率 (p) 越接近 0,loss 越小。

这个公式的直觉特别简单:模型对真实标签越自信,loss 越低;越不自信甚至完全搞反,loss 越高。本质上它是在做“最大似然估计”,让模型在训练数据上的似然概率最大化。

1.2 为什么二分类不能用 MSE

你可能会问,回归任务用均方误差(MSE)用得好好的,分类任务为什么非要换成交叉熵?我用一个例子说明。

假设一个二分类模型,对某个样本输出 sigmoid 后的概率是 0.9,真实标签是 1。如果使用 MSE,计算出的梯度会很小,因为预测值已经比较接近目标了。但问题在于,当模型完全分错时——比如预测 0.1,真实标签是 1——MSE 的梯度在某些情况下依然不够大,因为 sigmoid 函数在两端饱和,梯度趋近于 0,这就导致模型学习速度极慢。

而交叉熵配合 sigmoid 时,梯度里会天然消掉 sigmoid 的导数项,从而让误差越大、梯度越大,模型学得越快。这就是“梯度消失”问题在损失函数层面的一个解法。后面我们会看到,BCEWithLogitsLoss在数学上正是通过合并 sigmoid 和交叉熵,把这个优势发挥到极致。

这里先埋一个伏笔:当你看到很多人说“BCEWithLogitsLoss比BCELoss数值更稳定”时,本质上就是因为它在计算过程中规避了 sigmoid 函数在两端饱和导致的精度损失问题,后面会详细展开。

2. BCELoss:最朴素的二分类损失

2.1 手动实现一个 BCELoss

nn.BCELoss是 PyTorch 里最直接的实现。它要求输入是一个已经经过 sigmoid 激活的概率值,范围在 (0, 1) 之间,并且输入和 target 的 shape 必须一致。我们先用 Numpy 手动实现一遍,帮助你建立直觉:

import numpy as np def binary_cross_entropy(y_true, y_pred, eps=1e-7): # y_pred 是 sigmoid 之后的概率值 y_pred = np.clip(y_pred, eps, 1 - eps) # 防止 log(0) 导致 nan loss = -np.mean( y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred) ) return loss y_true = np.array([1, 0, 1, 0]) y_pred = np.array([0.9, 0.1, 0.8, 0.3]) print(binary_cross_entropy(y_true, y_pred))

这个脚本输出约等于 0.25。为什么需要eps做裁剪?因为如果某一项预测概率正好是 0 或 1,log(0)会直接变成负无穷,计算结果就是 nan。这是所有实现 CE 类损失函数都必须处理的细节,PyTorch 内部同样有对应的保护机制。

2.2 BCELoss 的使用姿势

在 PyTorch 中,BCELoss最简单的用法如下:

import torch import torch.nn as nn loss_fn = nn.BCELoss() # 模拟一个 batch,共 4 个样本,每个样本只有一个输出节点 logits = torch.tensor([[0.9], [0.1], [0.8], [0.3]], dtype=torch.float32) probs = torch.sigmoid(logits) # 必须是 sigmoid 之后的概率 targets = torch.tensor([[1.], [0.], [1.], [0.]], dtype=torch.float32) loss = loss_fn(probs, targets) print(loss.item())

请注意 target 的数据类型是torch.float32,不是torch.long。很多初学者在这里栽跟头:把标签定义成了整数型,然后直接丢给BCELoss,就会遇到奇怪的报错。

2.3 输入 shape、dtype 和 device 的硬性要求

BCELoss对输入输出的要求非常“苛刻”,总结起来有四点:

第一,input和target的 shape 必须完全一致。要么都是(N,),要么都是(N, C),要么都是(N, C, H, W),总之 broadcast 的情况虽然偶尔能跑通,但结果往往不是你想要的,建议一开始就保持一致。

第二,target必须是浮点类型,即 0.0 和 1.0,而不是整型 0 和 1。这一点和CrossEntropyLoss完全不同——后者要求 target 是torch.long类型的类别索引,而BCELoss的 target 本质上是“概率值”,虽然实践中只有 0 和 1 两种取值。

第三,input必须经过 sigmoid 激活,取值范围要在 0 到 1 之间,严格来说不能包含 0 和 1。如果你直接把 logits 丢进来,loss 大概率会算出一个奇怪的值,而且训练会非常不稳定。

第四,input和target要在同一个 device 上。GPU 训练时尤其容易忽略这一点,一个在 CPU 一个在 GPU 会直接报错。

注意:nn.BCELoss的默认 reduction 是mean,也就是对整个 batch 所有元素求平均。你可以通过reduction='sum'改成求和,或用reduction='none'得到每个样本单独的结果。这三个模式我们在第 4 章统一实验对比。

3. BCEWithLogitsLoss:为什么官方推荐它的底层逻辑

3.1 一句话理解它做了什么事

BCEWithLogitsLoss从名字就能看出来:它把Sigmoid层和BCELoss合并成了一个操作。也就是说,你用这个函数时,模型的最后一层不需要额外加 sigmoid 激活函数,直接把原始 logits 丢进去就行了:

loss_fn = nn.BCEWithLogitsLoss() logits = torch.tensor([[2.0], [-2.0], [1.5], [-0.8]]) # 不需要过 sigmoid targets = torch.tensor([[1.], [0.], [1.], [0.]]) loss = loss_fn(logits, targets) print(loss.item())

同样的 logits,如果你先用torch.sigmoid(logits),再喂给nn.BCELoss(),得到的结果在数学上应该完全一致。这就会引出一个很自然的疑问:既然结果一样,为什么要多此一举融合起来?

3.2 数值稳定性的数学原理

这个问题的关键在于“数值稳定性”。我们先看原始的 BCE 公式:

[ L = -\left[ y \cdot \log(\sigma(x)) + (1 - y) \cdot \log(1 - \sigma(x)) \right] ]

其中 (\sigma(x) = \frac{1}{1 + e^{-x}}) 是 sigmoid 函数,(x) 是 logits。

问题出在哪?当 (x) 是一个很大的负数时,比如 (-100),(\sigma(x)) 会非常接近 0。在计算机里,这可能会被舍入成精确的 0。随后 (\log(0)) 就变成了负无穷,再乘上系数,loss 就变成了 nan。反过来,当 (x) 是一个很大的正数比如 100 时,(\sigma(x)) 会非常接近 1,1 - sigma(x)可能被舍入成 0,同样会导致 nan。

BCEWithLogitsLoss在实现上不是先算 sigmoid 再取 log,而是直接做了一个数学恒等变形。这里的关键步骤是:

[ \log(1 - \sigma(x)) = \log\left(1 - \frac{1}{1 + e^{-x}}\right) = \log\left(\frac{e^{-x}}{1 + e^{-x}}\right) ]

再配合 LogSumExp 技巧,最终 PyTorch 内部会使用类似以下稳定形式计算:

[ \max(x, 0) - x \cdot y + \log(1 + \exp(-|x|)) ]

这个公式在 (x) 极大或极小时都不会出现中间变量被舍入为 0 的情况,因此数值上是稳定的。这个设计思想是所有现代深度学习框架的通用做法,你可以理解成它牺牲了一点点公式的“直观性”,换取了计算过程中的稳定性。

3.3 手工验证两者等价

我们用一个小实验验证BCELoss配合sigmoid和BCEWithLogitsLoss的结果一致:

import torch import torch.nn as nn torch.manual_seed(42) logits = torch.randn(8, 1) * 10 # 故意用较大的值,容易触发数值问题 targets = torch.randint(0, 2, (8, 1)).float() # 方法1:BCEWithLogitsLoss 直接吃 logits loss_fn_1 = nn.BCEWithLogitsLoss() loss1 = loss_fn_1(logits, targets) # 方法2:手动 sigmoid 后接 BCELoss probs = torch.sigmoid(logits) loss_fn_2 = nn.BCELoss() loss2 = loss_fn_2(probs, targets) print(f"BCEWithLogitsLoss: {loss1.item():.10f}") print(f"Sigmoid + BCELoss: {loss2.item():.10f}") print(f"差异: {abs(loss1.item() - loss2.item()):.2e}")

在我本机的运行结果里,两者在小数值上几乎完全一致,差异大概在 (10^{-8}) 量级;但如果 logits 的绝对值特别大,手写 sigmoid 后再算 BCELoss 的那条路径会有更高概率出现 nan。

提示:这也是为什么很多开源代码在最后一层不加nn.Sigmoid(),而是直接用nn.BCEWithLogitsLoss。除了数值稳定,训练结束后做推理时才临时加 sigmoid,也是为了让训练/推理的解耦更干净——训练时模型输出 logits,推理时再激活,逻辑更清晰。

3.4 与多标签分类的关系

讲到这里,顺带提一个高频场景:多标签分类。比如一张图片里同时有“人”“车”“树”三个标签,每个标签都是独立的二分类问题。这时候模型的输出头是多个节点,每个节点代表一个标签是否出现。

在这种情况下,PyTorch 官方的推荐做法依然是用BCEWithLogitsLoss,因为它的内部实现会自动对每个输出节点独立计算二分类交叉熵,然后求平均。这就是为什么你会看到很多目标检测、多标签分类的项目里都在用它。

4. 参数细节与实操对照实验

4.1 两个函数的完整参数对比

BCELoss和BCEWithLogitsLoss在参数层面有很多相似之处,但有一个关键差异。先看这组对照表:

参数BCELossBCEWithLogitsLoss作用
weight支持支持对每个样本/通道的 loss 加权
size_average已弃用已弃用老版本控制是否求平均
reduce已弃用已弃用老版本控制是否降维
reduction支持支持mean/sum/none
pos_weight不支持支持正样本加权,处理类别不平衡

注意看最后一行的pos_weight,这个参数只有BCEWithLogitsLoss才有。它专门用来解决正负样本数量不平衡的问题,公式变为:

[ L = -\left[ pos_weight \cdot y \cdot \log(\sigma(x)) + (1 - y) \cdot \log(1 - \sigma(x)) \right] ]

直白地说,当正样本太少时,把pos_weight设置成大于 1 的数,相当于人为放大了正样本预测错误的惩罚,让模型更重视正样本的学习。举个例子:一个数据集里正样本占 10%,负样本占 90%,那么你设置pos_weight=9就是一个非常常见的初始化选择,它尽量让正负样本的累积损失贡献接近 1:1。

4.2 实操演示:三个 reduction 模式的差异

我写一段代码,把三种reduction模式的结果完整打印出来,方便你直观理解:

import torch import torch.nn as nn logits = torch.tensor([[1.5], [-0.5], [2.0], [-1.0]]) targets = torch.tensor([[1.], [0.], [1.], [0.]]) # none: 返回每个样本各自的 loss loss_none = nn.BCEWithLogitsLoss(reduction='none')(logits, targets) print("reduction='none':") print(loss_none) # mean: 所有样本 loss 的均值 loss_mean = nn.BCEWithLogitsLoss(reduction='mean')(logits, targets) print(f"reduction='mean': {loss_mean.item():.4f}") # sum: 所有样本 loss 的求和 loss_sum = nn.BCEWithLogitsLoss(reduction='sum')(logits, targets) print(f"reduction='sum': {loss_sum.item():.4f}") # 验证 mean 等于 none 求平均 print(f"验证: none.mean() = {loss_none.mean().item():.4f}") print(f"验证: none.sum() = {loss_none.sum().item():.4f}")

前向传播时默认用mean,计算 loss 用于反向传播。验证 loss 是否合理时,我会用reduction='none'逐样本检查,特别适合在调试时找出“哪些样本让模型非常困惑”。

4.3 完整训练循环中的正确用法

把理论放在一边,我们看一个更接近实战的代码片段——用它可以跑通一个最简单的二分类训练循环:

import torch import torch.nn as nn import torch.optim as optim # 定义一个极简的模型,最后一层没有 sigmoid model = nn.Sequential( nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 1) # 输出 logits ) loss_fn = nn.BCEWithLogitsLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) # 造一点假数据 x = torch.randn(16, 10) y = torch.randint(0, 2, (16, 1)).float() for epoch in range(3): optimizer.zero_grad() logits = model(x) loss = loss_fn(logits, y) loss.backward() optimizer.step() print(f"Epoch {epoch+1}, Loss: {loss.item():.6f}")

在这个训练循环里,模型最后一层没有任何激活函数,输出直接是 logits。这非常关键——千万不要在模型里加了nn.Sigmoid(),然后又用BCEWithLogitsLoss,那就是“双重 sigmoid”,会让模型极难收敛。反过来,如果你用的是nn.BCELoss,那模型最后一层就必须输出 sigmoid 之后的结果。

注意:推理阶段如果你需要用 0-1 之间的概率做阈值判断,记得对 logits 手动torch.sigmoid()。此时不需要保留 sigmoid 的梯度,放在torch.no_grad()环境里执行即可。

4.4 处理正负样本不平衡时的实操

样本不平衡在业务场景里太常见了,比如点击率预测、异常检测、医疗图像中的罕见病症识别。用BCEWithLogitsLoss的pos_weight参数,是成本最低的解法:

# 假设训练集中正样本 500 个,负样本 4500 个 pos_weight = torch.tensor([4500.0 / 500.0]) # 9.0 loss_fn = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

注意pos_weight的 shape 要能够 broadcast 到输出维度。对于单标签二分类,一般是一个长度为 1 的 Tensor;对于多标签分类,可以传一个与标签数量相同的 Tensor,为每个标签单独设置权重。

设置pos_weight之后,正样本的分类错误会被放大,相当于你人为告诉模型:“正样本很稀有,看到了就必须抓住。”实际使用中,pos_weight不需要完全严格等于负正样本比,也可以把它当超参去调。

5. 踩坑记录与排查技巧

5.1 最常见的四个报错和解决方案

这一章都是实打实的经验教训,我按踩坑频率排序。

第一个坑:target类型用成torch.long。报错信息一般是这样的:RuntimeError: result type Float can't be cast to the desired output type Long。解决办法很简单,对标签做.float()转换。我见到过有人把.float()写在模型输出上,这不对,应该写在 target 上。

第二个坑:shape 不匹配。假设你的模型输出是(batch_size, 1),target 却是(batch_size,),在部分 PyTorch 版本里可能会自动广播但不报错,算出来的 loss 数值却是错的。建议在 loss 前加一行断言:

assert logits.shape == targets.shape, f"shape mismatch: {logits.shape} vs {targets.shape}"

第三个坑:模型里已经包含了 sigmoid,然后又用BCEWithLogitsLoss。症状是 loss 一直在下降但永远下不到一个理想范围,或者收敛非常慢。解决办法是检查模型最后一层,去掉nn.Sigmoid()。

第四个坑:使用未经torchvision.transforms归一化处理的数据,训练一开始 loss 就变成 nan。这不是损失函数本身的问题,而是输入数据里可能包含 NaN 或者极大值,导致 logits 发散,进而触发数值不稳定。排查技巧是在 loss 计算后加一个torch.isnan(loss)断言,快速定位出问题的是前向传播还是反向传播。

5.2 如何判断你的 loss 是否正常

很多初学者看到 loss 在 0.7 左右起伏,就以为模型学崩了。我提供一个快速判断基线:对于二分类问题,随机初始化模型的 loss 大约在ln(2) ≈ 0.693附近。如果你的模型训练刚开始 loss 远低于 0.693,说明初始化就偏向某类,反而要检查一下是不是样本不均衡或者初始化有问题。

在训练过程中,如果 loss 在 0.3 以下稳步下降,说明模型在学习;如果 loss 直接跳到 nan 或无穷大,往往是因为学习率过大导致梯度爆炸。这时候可以先降低学习率,然后再考虑用clip_grad_norm_这类梯度裁剪手段。

一个很实用的小技巧:在训练集上抽样几百条数据,用reduction='none'逐条查看 loss,把那些 loss 特别高的样本打印出来。这个习惯帮我解决过很多“看起来 loss 正常但准确率差”的疑难杂症。

5.3 logits 与 probability 混淆的深水区

最后分享一个我在多标签分类任务里经常遇到的问题。有人会把BCEWithLogitsLoss的输入误认为是“概率”,于是提前对模型输出做了torch.softmax(dim=1)。这里有两个错误:

  • 多标签分类的每个类别是独立二分类,类别之间互斥概率和为 1 的假设不成立,应该用 sigmoid,而不是 softmax;
  • BCEWithLogitsLoss内部已经有 sigmoid,你只需要确保输入是未激活的 logits 即可。

如果你非要在模型里加 sigmoid,那就改用BCELoss。这两种配置在数学上是等价的,但在数值稳定性和速度上,BCEWithLogitsLoss更优。

提示:写自定义网络时,我习惯用命名来区分变量。模型输出命名为logits,激活后命名为probs。这个小习惯,能省下大量排查“到底传进去的是不是概率”的时间。

写在最后的一个小技巧

根据我个人的使用经验,如果你正在搭建一个新项目,二分类或多标签分类的损失函数可以直接无脑选nn.BCEWithLogitsLoss,模型最后一层不加 sigmoid,target 记得.float()。这个组合覆盖了 95% 的常见场景,训练稳定、代码简洁、不容易踩数值坑。

另外补充一点:你可能会在某些古老的教程里看到nn.Sigmoid()加nn.BCELoss()的写法,这本身没有错,只是从 2020 年之后的 PyTorch 版本实践来看,BCEWithLogitsLoss已经成为社区主流。如果你在维护旧代码,看到这种写法也不用急着改,只要模型能正常收敛,两种方案都可以接受。

以后遇到任何关于二分类损失函数“算出来是 nan”“预测概率全在 0.5 附近”“正负样本不收敛”的问题,优先检查三件事:logits 有没有重复 sigmoid、target 是不是 float 类型、pos_weight有没有设置。这三步排查完,90% 的坑都能被填平。

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

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

立即咨询