news 2026/10/16 19:09:20

PyTorch损失函数实战指南:回归与分类的选型与调试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch损失函数实战指南:回归与分类的选型与调试

最近在整理自己的训练脚本,发现一个容易被人忽视的事实:网络结构决定模型的表达能力,而损失函数决定了模型会被推向哪个方向。同样的网络,把损失函数从 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 和维度报错,在这一步就能直接拦住。

损失函数的取舍不是越花哨越好,关键是搞清楚你的数据长什么样、模型输出是什么、梯度会如何流动。这个系列的第一篇先讲到这里,后续我会继续写距离度量、分布匹配类损失,以及自定义损失函数的实现细节。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 19:09:20

拷贝构造函数详解:从深拷贝陷阱到移动语义的现代C++实践

拷贝构造函数这东西,平时写代码可能不太在意,一旦踩坑,程序崩得莫名其妙,连排查都要花上大半天。我见过太多人因为它半夜抓狂,也见过不少老手在处理对象复制时翻车。这篇文章就把对象复制底层的门道彻底说透&#xff0…

作者头像 李华
网站建设 2026/10/16 19:09:20

工业AI检测落地:从单工位突破到全产线覆盖的工程实践

1. 那句"全产线覆盖"到底杀死了什么我做过好几个工业AI检测项目,从服装面料瑕疵检测到3C结构件外观检,也跟不少同行聊过他们折在哪儿。聊到最后发现一个特别扎心的规律:技术方案本身没大毛病,模型也能跑,dem…

作者头像 李华
网站建设 2026/10/16 19:09:21

存储系统从原理到工程实践:Cache映射、替换算法与虚拟内存

1. 从整体视角理解存储系统存储系统大概是计算机组成原理里最“接地气”又最“让人头大”的一章。说它接地气,是因为你买的手机、电脑,宣传页上写的“128GB存储”“16GB内存”,本质上都属于这个章节讨论的范畴;说它让人头大&#…

作者头像 李华
网站建设 2026/10/16 19:09:24

LR050N03TL4 N 沟道 MOSFET|龙夏电子 Long‑Tek

一、产品整体概述LR050N03TL4 是沟槽工艺 N‑MOS 功率管,TO‑252 封装;Vds 耐压 30V;Tc25℃连续漏极电流 Id77A;Vgs10V 时典型导通电阻 Rds (on)4.4mΩ;脉冲峰值电流 IDM308A;最大耗散功率 Pd54W&#xff1…

作者头像 李华
网站建设 2026/10/16 19:09:25

工控AI落地实战:OPC UA、MCP与数字孪生如何重塑PLC与SCADA

1. 工控AI的底层逻辑:为什么2026-2030是关键窗口期干了十几年自动化,从继电器柜到DCS,再到如今满车间的PLC和SCADA,我最大的感受是:工控这行从来不缺数据,缺的是把数据用起来的手段。过去我们做项目&#x…

作者头像 李华
网站建设 2026/10/16 19:09:27

Halcon许可证2026年更新:三重绑定机制与产线级运维指南

1. Halcon许可证更新不是“点一下就完事”的小事,而是视觉系统稳定运行的生命线Halcon作为工业机器视觉领域事实上的标准开发平台,它的许可证机制从来就不是简单的“激活码输入”流程。2026年3月这次更新,表面看只是时间戳的变更,…

作者头像 李华