最近在整理自己的训练脚本,发现一个容易被人忽视的事实:网络结构决定模型的表达能力,而损失函数决定了模型会被推向哪个方向。同样的网络,把损失函数从 MSE 换成 SmoothL1,或者从 CrossEntropyLoss 换成带组合的形式,收敛速度和最终指标都可能出现明显变化。这篇笔记是 PyTorch 深度学习系列里和损失函数相关的第一篇,我把回归和分类里几个最常用的损失函数从头到尾梳理了一遍,包括背后的原理、调用方式、常见参数,以及我自己在调试过程中踩过的坑。内容适合两类读者:一类是刚写完第一个训练循环、想知道这些 loss 到底怎么选的新手;另一类是在调参过程中发现 loss 不下降或者出现 NaN,需要回头检查损失函数的同学。
这篇笔记覆盖范围限定在基础场景:回归的 L1Loss / MSELoss / SmoothL1Loss,分类的 CrossEntropyLoss / NLLLoss / BCELoss / BCEWithLogitsLoss。不涉及自定义损失函数、度量学习里的对比损失这些进阶内容,那些我打算放到这个系列后续再写。先把地基打牢。
1. 先搞懂损失函数在训练循环里到底站在哪个位置
1.1 损失函数不只是"算一个数"
很多人把损失函数当成训练循环里的一个普通计算节点,觉得 loss 算出来显示一下就行。但实际上,loss 是反向传播的起点。它的值会被 backward() 用来计算对所有可训练参数的梯度,然后再由优化器更新参数。也就是说,损失函数的设计直接决定了梯度从哪里来、往哪里去。
举一个最直观的例子:如果某个损失函数对参数全部求导都是 0,那梯度就永远为 0,模型完全不会更新。PyTorch 中损失函数的输入通常是模型的输出(比如 logits)和真实标签(target),输出结果默认是一个标量。这个标量之所以必须是标量,是因为 loss.backward() 要求从标量出发计算梯度。你当然可以让 loss 返回一个向量,但那样 backward 就需要传一个同形状的 grad_tensor 作为权重,反而多此一举。
我一开始没有意识到的一点是:损失函数的设计不只影响"loss 数值大小",更影响"梯度分布"。同一个 batch 里,如果某个样本的误差大,它在损失函数里占的比重可能是指数级上升的,后面第二节会以 MSE 为例具体展开。
1.2 PyTorch 两种调用风格:类模块和函数式接口
PyTorch 的损失函数通常提供两种等价形式:
- 类形式:继承自
nn.Module,比如nn.MSELoss(),需要先实例化再调用。 - 函数式形式:放在
torch.nn.functional命名空间下,比如F.mse_loss(input, target),直接传参数调用即可。
两者底层算法一致,区别在调用习惯和使用场景。我在训练循环里更喜欢类形式,因为可以把所有损失函数写在一个配置字典里,换 loss 只改一行代码;而在写自定义损失函数,或者在一个 loss 里组合多部分计算时,函数式会更灵活,因为不需要维护模块实例。
代码上两者长这样:
import torch import torch.nn as nn import torch.nn.functional as F pred = torch.tensor([2.0, 0.5, -1.0], requires_grad=True) target = torch.tensor([1.5, 0.8, -0.2]) # 类形式 mse_loss = nn.MSELoss() loss1 = mse_loss(pred, target) # 函数式形式 loss2 = F.mse_loss(pred, target) print(loss1, loss2)它们的输出完全一致。实际项目里没有必要纠结用哪一种,保持团队代码风格统一就行。只是提醒一点:如果你把损失函数放进nn.Sequential里,或者打算在自定义nn.Module中管理它,那么应该选类形式;如果只是在一个函数里临时算一下,函数式更轻量。
1.3 reduction 参数到底改了什么
绝大多数 PyTorch 损失函数都带一个reduction参数,取值是'mean'、'sum'、'none'。很多教程只是简单提了一句"用来指定输出形式",但实际使用中这个参数有几个容易被忽略的坑。
看一个简单例子:预测值[2.0, 0.5],目标值[1.5, 0.8],逐元素误差是[0.5, 0.3],MSE 逐元素是[0.25, 0.09]。
reduction='mean':返回(0.25 + 0.09) / 2 = 0.17。reduction='sum':返回0.25 + 0.09 = 0.34。reduction='none':返回[0.25, 0.09],保持张量形状。
代码验证:
import torch.nn.functional as F pred = torch.tensor([2.0, 0.5]) target = torch.tensor([1.5, 0.8]) print(F.mse_loss(pred, target, reduction='mean')) # tensor(0.1700) print(F.mse_loss(pred, target, reduction='sum')) # tensor(0.3400) print(F.mse_loss(pred, target, reduction='none')) # tensor([0.2500, 0.0900])reduction='none'看起来是"退化了",但它很有用。比如我想知道一个 batch 里哪些样本误差特别大,就可以用'none'拿到逐样本误差,然后排序、可视化、筛选困难样本。我的习惯是:训练时用'mean',方便观察平均 loss;分析时单独开一段代码用'none'。
这里还有一个比较隐蔽的点:reduction='mean'时,loss 值是所有元素误差的均值,所以 batch size 变化会影响 loss 的绝对数值范围。如果你一边增量训练(batch size 越调越大),一边用同一套学习率,很可能会发现 loss 曲线整体变化,这不是模型变差,而是 loss 计算尺度变了。不同 batch size 之间对比训练曲线时,建议保持一致,或者用每个样本的平均误差来衡量。
2. 回归任务三件套:L1Loss、MSELoss 与 SmoothL1Loss
2.1 三种损失的核心公式与梯度性格
回归任务里最常碰到的就是这三个。它们的公式如下:
- L1Loss(MAE):
L = |pred - target|,误差的绝对值。 - MSELoss(L2):
L = (pred - target)^2,误差的平方。 - SmoothL1Loss:当单点误差绝对值小于某个阈值 beta(PyTorch 默认 beta = 1)时,用二次函数
0.5 * x^2;否则用线性函数|x| - 0.5。
如果只看公式,可以总结出它们的"梯度性格"。
L1Loss 的导数是常数(严格来说在 0 处不可导,PyTorch 会按次梯度处理,返回 0),无论误差多大,梯度大小基本不变。它的好处是鲁棒,个别离群点不会把梯度拉得非常大;缺点是接近最优解时梯度不会变小,模型会在这个点附近来回震荡,收敛不够精细。
MSELoss 的导数是2 * (pred - target),误差越大梯度越大,误差越小梯度越小。它的优势是收敛速度快,尤其在初始误差大的阶段;劣势是一旦数据里有个别特别离谱的离群点,梯度会被那些样本主导,整个模型的优化方向被带偏。
SmoothL1Loss 是两者的折中。误差小的地方用了二次项,梯度会随误差缩小而变小,保证收敛精度;误差大的地方切成线性,梯度上限被限制住,避免离群样本主导。它在 PyTorch 里还有一个beta参数表示分段点,默认值为 1。
我写一段代码对比三种损失在相同输入下的数值,顺便看一眼梯度:
import torch import torch.nn as nn pred = torch.tensor([2.0, 0.5, -1.0], requires_grad=True) target = torch.tensor([1.5, 0.8, -0.2]) l1 = nn.L1Loss()(pred, target) mse = nn.MSELoss()(pred, target) smooth = nn.SmoothL1Loss()(pred, target) print(f"L1: {l1.item():.4f}") print(f"MSE: {mse.item():.4f}") print(f"SmoothL1: {smooth.item():.4f}") # 手动对第一个元素求导看看 l1.backward(retain_graph=True) print(pred.grad) # L1 的梯度,恒定为 ±1从这个例子里可以看到,即使只换一个损失函数,梯度特征也完全不同。这就是我一直强调的:不要只盯着 loss 数值看,要看它给模型带来的梯度行为。
2.2 MSE 对离群点敏感的原因和场景取舍
MSE 对离群点敏感,本质上是因为平方项放大了大误差的影响。假设一个 batch 里有两个样本,一个误差是 1,另一个是 10。MSE 下平方后是 1 和 100,梯度贡献分别是 2 和 20,后者是前者的 10 倍。也就是说,离群样本几乎主导了整个梯度方向。
实际项目里,这种离群点经常是标注错误、传感器瞬时噪声、或者是极端罕见事件。如果用它来训练回归模型,结果往往是模型为了迁就那些离群点,把整体预测偏移了。我处理过一个回归任务,数据里大概有 5% 的样本因为采集设备问题产生了比较大的噪声,用 MSE 训练时 loss 一直降不下去,换了 SmoothL1 之后很快稳定下来,验证集误差反而更小。
但这不代表 MSE 一无是处。如果你的数据质量很好、噪声小,或者你希望模型对"大误差"非常敏感,MSE 依然是一个很好的选择。很多任务里,MSE 收敛快、梯度光滑,特别适合作为默认起点。关键是:先看你的数据长什么样,再决定用哪个损失函数。
2.3 SmoothL1Loss 为什么在检测任务里常见
做过目标检测的朋友应该很眼熟,很多检测框架的框回归分支默认用的就是 SmoothL1Loss。框坐标的回归问题里有一个典型特征:标注框本身存在少量误差,而且可能出现被遮挡导致预测框突然偏差很大的情况。
如果框坐标误差普遍较小,MSE 的二次项表现很好;但一旦有个别框完全预测错位,MSE 的平方梯度会非常大,把整个训练扰动得厉害。SmoothL1 把大误差的梯度限制为常数,就能防止单一坏样本破坏训练过程。这正好对应了前面说的"梯度上限限制"的价值。
关于beta参数,默认值 1 在大多数场景下够用。如果你的数据误差非常小、希望模型在小误差区间更精细地逼近,可以考虑适当调低beta,让二次区间更窄,但这需要重新尝试和验证。我在实际项目里一般先保持默认,只有当模型已经拟合得很好、想继续压缩精度时才去动它。
3. 分类任务基石:CrossEntropyLoss 把两步压缩成一步
3.1 公式拆解:从 softmax 到负对数似然
多分类任务最常用的损失是 CrossEntropyLoss。它的标准流程是:模型输出 logits(未归一化的分数),先过 softmax 转成概率分布,再计算负对数似然。写成公式就是:
p_i = exp(z_i) / sum_j exp(z_j) loss = -log(p_target)其中p_target是真实类别对应的 softmax 概率。用文字解释:如果模型给真实类别的概率接近 1,那么-log(p)接近 0,损失很小;如果模型给真实类别的概率很低,比如 0.01,-log(0.01)约等于 4.6,损失就非常大。这种对"犯错"的惩罚,会迫使模型不断提升对正确类别的置信度。
为什么用负对数而不是直接用1 - p_target?因为对数操作对概率的变化更敏感。概率从 0.5 到 0.9,1-p变化了 0.4,而-log(p)变化了约 0.81;概率从 0.9 到 0.99,1-p只变化了 0.09,但-log(p)变化了约 1.19。也就是说,负对数损失给了"从勉强正确到非常确信"这个过程更大的学习信号,训练起来更有推动力。
3.2 不要手动加 softmax:logits 直接进
这是新手最容易踩的坑之一。CrossEntropyLoss 内部实际上做了LogSoftmax + NLLLoss的组合。LogSoftmax 是先把 logits 变成概率的 log 形式,不直接输出概率,而是输出概率的对数,目的就是为了数值稳定。
也就是说,CrossEntropyLoss 期望的输入是 logits,不是经过 softmax 的概率。如果你在模型最后一层先加了 softmax,再把输出传给 CrossEntropyLoss,那就等于做了两次 softmax(损失函数内部还会再做一次 log_softmax),模型很难收敛。
我在调试时见过一个案例:某同学写了一个分类网络,最后一层是F.softmax,然后损失函数用CrossEntropyLoss,训练了很久 loss 都不怎么下降。帮他把最后一层的 softmax 去掉后,训练立刻正常了。原因就是重复归一化让梯度的数值被压到了非常小的范围。
验证 CrossEntropyLoss 和 LogSoftmax+NLLLoss 等价,可以用一段很短的代码:
import torch import torch.nn as nn import torch.nn.functional as F logits = torch.randn(4, 10) # 模拟4个样本、10个类别 targets = torch.tensor([1, 5, 3, 9]) # 直接使用 CrossEntropyLoss ce_loss = nn.CrossEntropyLoss()(logits, targets) # 手动组合 manual_loss = nn.NLLLoss()(F.log_softmax(logits, dim=-1), targets) print(ce_loss.item(), manual_loss.item()) # 两个值完全一致这段代码建议你自己跑一遍,理解了就再也不会忘了 CrossEntropyLoss 的输入格式。
3.3 常用参数:target 格式、ignore_index 和 weight
分类任务里,CrossEntropyLoss 的 target 默认是稀疏标签,也就是整数索引,不是 one-hot 向量。比如 10 分类问题,对每一个样本,target 是一个 0 到 9 之间的整数。PyTorch 内部会用这个整数去索引 logits 对应位置。如果你的数据集给的是 one-hot 标签,需要先用argmax转成索引,或者自己实现一个接受 one-hot 的损失函数。
ignore_index参数用来忽略某个类别的计算,最常见于序列任务中 padding 的位置。比如处理文本时,长度不足的句子被 pad 到同一个长度,这些 pad 位置参与计算没有任何意义,也不应该反向传播梯度。把ignore_index设为 pad token 对应的类别编号,那些位置的损失就会被跳过。
weight参数用于类别不平衡。比如二分类中负样本是正样本的 9 倍,你可以给正样本更高的权重。但注意:weight默认与reduction='mean'配合时,计算的是加权平均,不是简单平均。也就是说总 loss 会被正负样本的权重都缩放一遍,这会影响梯度的整体尺度,进而等效于改变了学习率。所以调整weight时,往往也需要同步调整学习率,这一点我会在第五节的踩坑案例里详细说。
4. 二分类和多标签:BCELoss 和 BCEWithLogitsLoss 的分工
4.1 两个版本:一个要概率,一个要 logits
二分类任务里有两种最常用的交叉熵变体:BCELoss 和 BCEWithLogitsLoss。它们公式本质上一样,区别在于输入形式。
- BCELoss:期望输入是概率值 p,范围在 0 到 1 之间。所以模型最后一层要加 Sigmoid,得到概率后再交给它。
- BCEWithLogitsLoss:期望输入是 logits(未归一化分数),内部自己完成 Sigmoid 和交叉熵计算。
数学公式都是标准的二分类交叉熵:
loss = -[y * log(p) + (1 - y) * log(1 - p)]其中 p 是 Sigmoid 后的概率(BCELoss 直接拿外面的 p 用,BCEWithLogitsLoss 先对 logit 做 Sigmoid 再代入)。
如果你已经掌握 CrossEntropyLoss 的套路,这里的原则是一样的:把激活函数交给损失函数内部处理,不要在最后一个输出层重复加 Sigmoid。使用 BCEWithLogitsLoss,模型最后一层保持线性输出 logits,这是更推荐的做法。
写代码验证前向层面两者等价:
import torch import torch.nn as nn logit = torch.tensor([[2.0]]) target = torch.tensor([[1.0]]) # 手动 Sigmoid + BCELoss p = torch.sigmoid(logit) bce = nn.BCELoss()(p, target) # 直接 BCEWithLogitsLoss bce_logits = nn.BCEWithLogitsLoss()(logit, target) print(bce.item(), bce_logits.item()) # 结果一致4.2 数值稳定性差异:训练早期 NaN 从哪来
很多人遇到过这种情况:训练到一半 loss 变成 NaN,重启后刚开始还正常,过一会儿又爆了。如果网络输出层用的是手工 Sigmoid 再接 BCELoss,那 NaN 的来源很大概率是数值下溢。
早期训练时 logits 可能变得很大,比如一个样本的 logit 是 20,Sigmoid 后 p 约等于 0.9999999979,此时1 - p大约只有 2e-9。在 float32 精度下,接近下限的浮点计算很容易产生不稳定的结果;更极端的情况是 logit 达到 40 以上,1 - p可能直接下溢为 0,然后log(0)就是负无穷,损失直接变成 NaN。
BCEWithLogitsLoss 的处理思路是:把 Sigmoid 和 log 合并起来,针对 logits 直接做数值稳定的计算,避免构造出1 - p这种极小数。实际项目里,我遇到过某同事手写 Sigmoid 版 BCE,训练在两百多个 step 后 loss 变 NaN;换成 BCEWithLogitsLoss 后,同样的网络和数据,训练跑完几个 epoch 都没再出现问题。如果你的二分类模型经常 NaN,先检查一下是不是这里。
4.3 多标签任务里的输出层设计
二分类是单标签,多标签任务则是每个样本可能拥有多个标签,每个标签独立二分类。这时候应该用 BCE 系列,而不是 CrossEntropy。
原因很简单:CrossEntropyLoss 的 Softmax 会强制所有类别的概率和为 1,隐含假设"每个样本只能属于一个类别"。而多标签任务里,一个样本同时属于 label 0 和 label 2,概率总和并不需要等于 1。BCE 是对每个标签独立判断,互不影响。
对应模型设计是:输出层输出 N 个 logits(N 是标签数量),每个 logit 对应一个标签的置信度。训练时用 BCEWithLogitsLoss,每个逻辑输出与对应 0/1 标签计算二分类交叉熵,再对所有标签取平均。
这里有一个非常容易踩的小坑:BCEWithLogitsLoss 的 target 需要是浮点类型,也就是 0.0 和 1.0,而不是整数 0 和 1。PyTorch 内部期望它是浮点张量,如果数据加载时标签是 long 类型,直接传入会报错。我一般习惯在数据预处理时显式调用.float()转换一次,省得训练到一半才发现问题。
5. 换过损失函数之后,我踩到的三个坑
5.1 回归项目从 MSE 换 SmoothL1 后,重新认识了"异常值"
先讲第一个案例。我手上的一个回归任务,目标变量本身比较平稳,但数据采集偶尔会出现瞬时跳变,形成几个离群样本。最开始我用了 MSE,训练曲线呈现一个特征:loss 整体居高不下,而且每隔一段时间会被个别 batch 拉高很多。一开始我以为是学习率太大,不断调小学习率,但效果有限。
后来把 MSE 换成 SmoothL1Loss,同样的学习率,loss 很快下降,验证集的指标也变好了。回头分析原因,MSE 的平方项让离群样本的梯度远远大于正常样本,导致模型不断被少数几个异常样本牵着走。而 SmoothL1 在大误差区是线性梯度,每个样本对更新的影响就温和很多。
这件事给我的教训是:当你发现回归模型怎么调学习率都不理想时,先别急着怪优化器,停下来看数据里有没有极端样本,再考虑换一种损失函数。损失函数的选型本质上是对数据分布的适配,不是越复杂越好。
5.2 类别不平衡:不是加个 weight 就万事大吉
分类任务里,正负样本比例悬殊是很常见的情况。比如二分类,负样本 : 正样本 = 9 : 1。多数人的第一反应是给 CrossEntropyLoss 加weight,把正样本权重设成 9。
我在测试中加入weight = [1, 9]后,发现训练过程反而变得不稳定,验证集准确率也时高时低。后来仔细看了文档和梯度,才反应过来:weight和reduction='mean'结合时,loss 是加权平均。正样本权重大,不仅让正样本的梯度变大,还整体改变了 loss 的平均尺度,等于间接改变了学习率。原本 1e-3 的学习率在加权后可能相当于原来的好几倍,自然会震荡。
正确的做法有几个方向:
- 调整
weight后,同步降低学习率,直到训练曲线平稳。 - 用
reduction='sum'并手动除以权重之和,控制 loss 的尺度。 - 不调
weight,改用采样策略,比如对少数类做过采样。
实际操作中,我习惯先小幅调整权重(比如正样本设 3 而不是 9),观察训练稳定性,再逐步加大。不要一上来就按样本比例的倒数设置,那往往会让模型过拟合少数类。
5.3 最后一层的激活函数和损失函数重复了怎么办
这个坑我在答疑时碰到过好几次。某同学写了一个二分类网络,输出层先接Sigmoid,然后损失函数用BCEWithLogitsLoss;或者是输出层接Softmax,损失函数用CrossEntropyLoss。两种做法都等于做了两次归一化。
双重归一化带来的问题是:模型在”输出概率”还是“logits”之间自我打架,梯度被压缩得非常小,loss 下降极其缓慢,甚至看起来像没在训练。更麻烦的是,这种问题不会报错,代码能跑,loss 指数图上看起来也正常,只是收敛速度极慢。
我的统一习惯是:所有涉及分类的模型,输出层一律不接激活函数,直接输出 logits,把激活交给损失函数处理。推理阶段需要概率输出时,再单独对 logits 做sigmoid或softmax。这个习惯让训练代码和推理代码分离得非常干净,也减少了很多排查成本。
最后再分享一个花不了多少时间的小技巧:我习惯在训练脚本里用一个字典集中管理所有损失函数的配置,比如名字、构造参数、对应的输出范围。换损失函数的时候只需要改一行配置,不用到处找代码。新任务开始训练前,我还会先取一小批数据在 CPU 上手动过一遍模型前向和损失计算,确认输入 shape、dtype、数值范围都符合预期,再正式丢到 GPU 上训练。很多 NaN 和维度报错,在这一步就能直接拦住。
损失函数的取舍不是越花哨越好,关键是搞清楚你的数据长什么样、模型输出是什么、梯度会如何流动。这个系列的第一篇先讲到这里,后续我会继续写距离度量、分布匹配类损失,以及自定义损失函数的实现细节。