1. 损失函数这部分需要理解什么
如果不看损失函数,你很难判断一个训练好的模型到底在做什么、做对了多少。它是整个训练过程里唯一告诉模型“你错在哪儿、错得有多离谱”的信号来源。PyTorch 教程写到第 8 篇,终于轮到损失函数,基本上是绕不开的坎了。这一篇先讲基础款,覆盖回归任务的 L1Loss、MSELoss、SmoothL1Loss,分类任务的 CrossEntropyLoss、NLLLoss、BCELoss、BCEWithLogitsLoss,把公式含义、梯度行为、PyTorch 的 API 约定全部过一遍。
很多人学损失函数时踩的坑往往不在数学上,而在 API 用法上。比如 CrossEntropyLoss 内部到底包不包含 Softmax?BCELoss 应该传概率还是传 logits?为什么有时候 loss 是负数?这些问题如果不搞清楚,代码会跑,但模型不会收敛,甚至你都不知道自己在算什么。
这个内容适合谁来看?一类是刚入门 PyTorch、准备自己搭模型训练的开发者,另一类是会用现成代码但从来没仔细看过损失函数源码的人。看完你应该能把“损失函数”从一句术语变成你手头真正能调的工具。
1.1 训练循环里的位置
一个最基础的 PyTorch 训练循环大概是这个样子的:
for inputs, targets in dataloader: outputs = model(inputs) # 前向传播 loss = criterion(outputs, targets) # 计算损失 optimizer.zero_grad() loss.backward() # 反向传播 optimizer.step() # 更新参数很多教程讲到这里就过去了,但我想让新手停下来看清楚一件事:模型输出 outputs 是预测,targets 是标准答案,criterion 负责把两者变成一个数值。这个数值越小,说明预测和标准答案越接近;反向传播时,梯度从这个数值出发,一路回传到每个参数上,告诉每个参数“往哪个方向调整,损失才能下降”。
用生活中的比喻就是:模型是个学生,损失函数是批改试卷的老师。老师不光是打个分,还要在每道错题旁边写上“你这步偏左了”或者“这步该乘大一点”,学生才能照着改。没有老师,学生练多少次都不知道自己错哪。
损失函数在深度学习里就是这样一个“必要的老师”。它必须满足两个基本条件:第一,它要能反映出预测和真实标签之间的差异,差异越大数值越大,这个叫“度量性”;第二,它必须可导,导出来的梯度要有意义,否则反向传播直接卡住。
1.2 PyTorch 损失函数的设计约定
PyTorch 里所有损失函数都继承自nn.Module,所以你会发现nn.L1Loss()和nn.Linear(10, 3)的用法结构完全一致,都要先实例化,然后像函数一样调用。
criterion = nn.MSELoss() loss = criterion(pred, target)这里有两个约定新手必须记住:
- 几乎所有损失函数的前两个参数都是
input和target。input是模型的预测输出,target是标准标签。传反了代码未必报错,但计算结果完全没意义。 - 默认返回的是一个 batch 里所有样本损失的平均值,不是总和,也不是单个样本的损失。后面要批大小不同,对比 loss 才有意义。
每个损失函数还有一个共同的reduction参数,取值为三个:
'mean':返回所有样本损失的平均值,默认值'sum':返回所有样本损失的总和'none':返回每个样本的损失,输出形状和输入一致
reduction='none'在实际调试里非常有用。比如样本不均衡的时候,你想看看是不是某些特定样本把 loss 拉高了,或者想做困难样本挖掘,这时候逐样本的 loss 才是你需要的东西。
2. 回归任务的三个基础损失:L1、MSE 与 SmoothL1
回归任务的目标是预测一个连续数值,比如房价、温度、框的坐标。它的标签不是类别索引,而是一个或多个实数。PyTorch 里回归任务最常用的就是这三个损失,它们的公式差别很小,但行为差别很大。
2.1 L1Loss 与平均绝对误差
L1Loss 的公式是:
loss = |x - y|其中x是预测值,y是目标值。整个 batch 的损失就是所有样本绝对误差的平均值,所以它也叫 MAE(Mean Absolute Error)。
L1Loss 的梯度非常有意思:不管误差多大,梯度都是常数 1 或者 -1。这意味着它对离群值不敏感,某个样本错得特别离谱,它的梯度也不会比其他样本大太多。
这种特性带来一个实际的优点:训练过程稳定,不容易因为个别坏样本导致参数剧烈震荡。但缺点同样明显,当预测值和目标值非常接近的时候,梯度依然是 ±1,模型会在最优解附近来回跳动,不容易精确定位到最优值。
用个直观的例子理解:L1 像是让你闭眼摸墙上的钉子,你离钉子再远,每次调整的脚步大小都一样;当你已经摸到钉子旁边了,这一步跨过去又跨回来,很难正好停在钉子上。
实际使用中,L1Loss 更适合那些存在明显异常值的回归任务。比如工业传感器数据偶尔会产生坏点,如果这些坏点参与训练,L2 类的损失会被它们带偏,而 L1 的鲁棒性就好很多。
2.2 MSELoss 与均方误差
MSELoss 的公式是:
loss = (x - y)^2它也叫 L2 损失、均方误差。它的梯度是2 * (x - y),也就是说,误差越大,梯度越大,参数调整的步子也越大。这带来两个直接结果:
- 收敛速度通常比 L1 快,因为离最优解远的时候梯度大,能更快拉回来
- 对离群点极其敏感,一个误差特别大的样本,它的梯度会支配整个 batch 的更新
这也是为什么 MSE 对数据质量要求更高。如果你的标签里有噪声特别大的样本,MSE 会把大量“注意力”放在拟合这些坏样本上,导致整体模型变差。
另外注意一点,MSE 在误差小于 1 的时候,平方会让误差变得更小。比如误差 0.5,平方后变成 0.25。这意味着它对“已经比较准”的预测惩罚很轻,这也是它收敛后期容易精调的原因。但反过来说,如果误差大于 1,比如 3,平方后变成 9,惩罚会成倍放大。SSD 目标检测算法在早期版本里对坐标回归使用 SmoothL1Loss,就是因为原来的 MSE 对大误差太敏感,训练容易发散。
2.3 SmoothL1Loss 与分段思想
SmoothL1Loss 的公式是分段函数:
当 |x - y| < 1 时:loss = 0.5 * (x - y)^2 当 |x - y| >= 1 时:loss = |x - y| - 0.5它把 L1 和 MSE 的优点结合起来了。误差大的时候,梯度的绝对值恒定为 1,不会出现 MSE 那种梯度爆炸;误差小的时候,梯度从 1 平滑地降到 0,弥补了 L1 在最优解附近来回震荡的问题。
历史上有段时间 SmoothL1Loss 的阈值被写成固定的 1.0,后来 PyTorch 新版本给nn.SmoothL1Loss()加了一个beta参数,默认还是 1.0,但你可以调。比如nn.SmoothL1Loss(beta=0.5)就表示分段点从 1.0 改成 0.5。分段点越小,函数在接近最优解的区域越窄,大部分区域表现得更像 L1;分段点越大,越接近 MSE。
我在实际项目中通常把这个 beta 当成一个可尝试的超参数。目标检测类任务的坐标回归常常从beta=1.0起步,如果训练后期 loss 一直降不下来,可以试试把 beta 调小,让模型在小误差区域用更接近 MSE 的梯度去精调。
2.4 回归损失怎么选
用一张表把这三个损失放在一起对比:
| 损失 | 公式 | 梯度行为 | 对异常值 | 收敛特点 | 典型场景 |
|---|---|---|---|---|---|
| L1Loss | |x-y| | 恒为 ±1 | 不敏感 | 稳定但后期震荡 | 标签有噪点、鲁棒性优先 |
| MSELoss | (x-y)^2 | 随误差增大而增大 | 极度敏感 | 前期快、后期容易跑偏 | 数据干净、误差优先 |
| SmoothL1Loss | 分段函数 | 大误差恒定,小误差线性衰减 | 较稳健 | 综合表现好 | 目标检测坐标回归 |
如果你只记住一句话,那就是:L1 稳,MSE 快,SmoothL1 是两者的折中。刚上手时优先试 SmoothL1Loss 往往是比较稳妥的选择,因为它的行为最可控,既不会因为异常点把模型拉翻,又不会在收敛后期干瞪眼。
3. 分类任务的交叉熵损失家族
分类任务的损失函数比回归复杂一些,因为模型的输出通常是一个向量,向量里每个分量对应一个类别的“分数”。这个分数不是概率,它叫 logits。要把 logits 变成概率,还需要经过 Softmax 或 Sigmoid。损失函数设计的核心就在这一步。
3.1 CrossEntropyLoss 是怎么工作的
nn.CrossEntropyLoss()是 PyTorch 里用得最多的分类损失。它的完整计算过程包含三步:
- 对 logits 做 Softmax,把每个类别的输出转成概率分布
- 对概率取 log(自然对数)
- 按照真实标签 index,取出对应类别的负对数概率,求平均
三步合并,形成最终公式:
loss = -log( softmax(logits)[target_index] )为什么要取负号?因为概率最大是 1,log(1) 等于 0,负号之后最小值是 0;概率越小,log 的负数越大,loss 就越大。这是符合直觉的:预测概率越大,损失越小。
PyTorch 文档里有句话非常重要:CrossEntropyLoss 的 input 应该是原始的 logits,不需要自己手动做 Softmax。内部已经做了对数 Softmax 的计算。这个设计主要是为了数值稳定性。直接计算-log(softmax(x))在 x 特别大的时候会先算出exp(x)导致溢出,而 PyTorch 底层用LogSoftmax直接计算,可以规避这个问题。
刚开始学的时候,很多同学都会写出这样的代码:
prob = torch.softmax(logits, dim=1) loss_fn = nn.CrossEntropyLoss() loss = loss_fn(prob, target) # 错误做法这个写法是错的。它相当于对已经做了一次 Softmax 的结果又做了一次 Softmax,两次非线性变换叠加,梯度信息被严重破坏,模型很难收敛。即便能训练,你也等于自己把数值稳定性那部分优势给丢了。正确做法是直接传 logits。
再看 target 的形状。CrossEntropyLoss 支持两种 target 格式:
- 类别索引,形状是
(batch,),每个值是一个 0 到 类别数-1 的整数 - 类别概率分布,形状和 logits 一样,每一行是一个 softmax 后的概率分布,用于标签平滑、知识蒸馏这类场景
新手常常搞混第二个格式。比如做图像分类,标签是[3, 0, 1]这样的整数;做多标签分类时又有多个 1,这时用的不是 CrossEntropyLoss,而是后面要讲的BCEWithLogitsLoss。
3.2 NLLLoss 与 LogSoftmax 的搭配
nn.NLLLoss()的全称是 Negative Log Likelihood Loss,负对数似然损失。它做的事情很简单:把输入的第target个位置的值取出来,加个负号,求平均。
但它有一个前提:输入必须是 log 概率,也就是经过log_softmax之后的值,不能直接传原始 logits,也不能传普通概率。原因很好理解,普通概率没有取 log,NLLLoss 只做了“取负号”,它的数学基础要求输入的是对数形式。
CrossEntropyLoss 其实就是LogSoftmax + NLLLoss的合并版。PyTorch 之所以拆出 NLLLoss,是为了给用户更大的自由度。比如你想用自定义的 softmax 变体,或者想自己控制 log 概率的计算,就可以手动组合。
手动组合的写法如下:
log_probs = torch.log_softmax(logits, dim=1) loss_fn = nn.NLLLoss() loss = loss_fn(log_probs, target)这种写法和直接调nn.CrossEntropyLoss()在数学上是等价的,但 CrossEntropyLoss 在数值稳定性上做了专门优化,常规场景推荐直接用它。NLLLoss 更适合那些需要显式拿到 log 概率的场合,比如你要可视化每个类别的对数概率,或者要在中间插入自己设计的概率变换。
3.3 BCELoss 与 BCEWithLogitsLoss
二分类是分类问题里的特殊情况,输出只需要一个概率值或者一个 logits。多标签分类的情形也类似,可以把每个类别看成独立的二分类。
nn.BCELoss()的公式基于二分类交叉熵:
loss = -y * log(p) - (1 - y) * log(1 - p)其中p是一个 0 到 1 之间的概率,y是标签 0 或 1。有个细节要注意:BCELoss 要求target必须与input形状相同,且target的数据类型通常是浮点型。很多人用整数标签 0 和 1 直接喂进去,结果报错说RuntimeError: result type Float can't be cast to the desired output type Long,就是这个类型没转对。
转一下就行:
p = torch.sigmoid(logits) target = target.float() loss_fn = nn.BCELoss() loss = loss_fn(p, target)但我觉得日常项目里直接用nn.BCEWithLogitsLoss()更省心。它把Sigmoid + BCELoss合并在一起,输入直接传 logits,同样是为了数值稳定性。拆开计算时,如果p非常接近 0 或 1,log(p)会变成-inf,反向传播直接 NaN。合并计算时,PyTorch 内部用了更稳定的数学变换,可以避免这种情况。
多标签分类也应该用 BCEWithLogitsLoss,而不是 CrossEntropyLoss。道理很简单:多标签的每个位置都是独立的 0-1 判断,多类别场景是“多个位置里只能选一个”,两者的概率假设不一样,损失函数自然不能混用。
criterion = nn.BCEWithLogitsLoss() logits = model(inputs) # 形状 (batch, num_classes) target = labels.float() # 同样形状,值为 0 或 1 loss = criterion(logits, target)如果把多标签任务错误地套用 CrossEntropyLoss,模型会学着在类别之间做 softmax 归一化,迫使所有类别概率加起来等于 1,这显然不符合多标签的语义,模型表达力直接被打折扣。我在实际项目里见到的“多标签分类效果差”问题,大约有一半是损失函数选错导致的。
4. 损失函数怎么选、怎么调
损失函数的选择不是一个从网上抄来的固定答案,它要和你的数据分布、任务定义对齐。
4.1 看任务类型选损失
先把最常见的选型整理成一张速查表:
| 任务类型 | 标签格式 | 推荐损失 | 注意事项 |
|---|---|---|---|
| 二分类 | 0 / 1,形状 (batch,1) | BCEWithLogitsLoss | target 要 float |
| 多分类 | 类别索引,形状 (batch,) | CrossEntropyLoss | 直接传 logits |
| 多标签 | 0 / 1 矩阵,形状 (batch, n) | BCEWithLogitsLoss | 每个类别独立判 |
| 回归(无异常值) | 连续值 | MSELoss | 注意异常点影响 |
| 回归(有异常值) | 连续值 | SmoothL1Loss | beta 可调 |
| 回归(坐标框) | 连续值向量 | SmoothL1Loss | 目标检测常用 |
这张表基本覆盖了大多数深度学习入门任务。判断逻辑不复杂,先看标签是离散的还是连续的,再看离散标签是单标还是多标。单标多分类用 CrossEntropyLoss,多标或多分类输出概率分布用 BCEWithLogitsLoss。
4.2 类别不均衡时干扰优化方向
分类任务经常遇到类别不平衡,比如二分类正样本只占 5%。如果不做处理,模型会倾向于把所有样本都预测成负样本,因为这样整体 loss 反而更低。此时有两个常见手段:
CrossEntropyLoss 里可以给每个类别一个权重,weight参数。权重越大,对应类别的梯度信号越强。常用的做法是把权重设成类别样本数的反比,比如负样本 1000 张、正样本 100 张,那正样本的权重设成 10,负样本权重设成 1。
BCEWithLogitsLoss 里对应的是pos_weight参数。它的含义是“正样本的 loss 相对负样本放大多少倍”,不是每个类别的绝对权重,而是正负样本之间的比值。如果你希望正样本的召回率更高,可以把pos_weight设置成负样本数除以正样本数。
注意,weight和pos_weight不是越高越好。权重过高会导致模型过度偏向少数类,准确率下降。我自己的习惯是先把权重设置成样本比的平方根,再看验证集表现微调,而不是一上来就按精确反比。
4.3 loss 数值小不等于模型好
新手最容易犯的一个错误:拿 loss 的绝对数值来判断模型好坏。不同损失函数、不同 batch 大小、不同任务,loss 的合理区间完全不一样。
CrossEntropyLoss 的返回值可以粗略理解为“平均负对数概率”,数值通常在 0 到 log(类别数) 之间。类别数越多,随机初始状态的 loss 越高。如果你拿一个 1000 类任务的 loss 和 10 类任务的 loss 直接比大小,什么也看不出来。
MSELoss 的尺度跟标签的数值范围强相关。如果房价标签是“万元”为单位,预测差 5 万,MSE 是 25;如果换成“元”为单位,同样的误差变成 50000 的平方。同一个模型,换单位后 loss 数值完全不同,但模型实质上一点没变。
正确做法是定义业务指标来评估模型,比如分类准确率、回归的 R 平方值、F1 分数。Loss 的作用应该是用来观察训练状态,比如是否还在下降、是否出现震荡、是否过拟合,而不是拿它跨任务做对比。
5. 常见问题与训练现场实录
这一节把我在平时训练和带人入门时遇到的典型问题整理出来,你有八成概率也会碰到。
5.1 把 Softmax 之后再传给 CrossEntropyLoss
有位同学第一次做图像分类,按教程写了模型,但自己加了一步model = nn.Sequential(model, nn.Softmax(dim=1)),再把输出传给 CrossEntropyLoss。结果训练两天准确率一直卡在 20% 左右。原因就是上面说的双重 Softmax,梯度被压得极其平缓,模型根本学不动。传 logits 是硬性要求,别画蛇添足。
5.2 标签类型报错
用 BCEWithLogitsLoss 时,报错信息我看过无数次:
RuntimeError: result type Float can't be cast to the desired output type Long原因就是标签用torch.LongTensor存的,而 BCEWithLogitsLoss 期望浮点类型。二分类的 0/1 标签和 CrossEntropyLoss 的类别索引从语义上都是整数,但只要换到 BCE 系列,就必须转成float()。训练循环里统一用target = target.float()能少踩很多坑。
3. loss 出现 NaN 怎么办
NaN 出现的原因通常是梯度爆炸或者 log 里出现 0。排查顺序应该是:
- 打印 loss 本身,如果是 NaN,查输入数据有没有 NaN、标签有没有越界
- 看学习率是不是太大,尤其是用 MSE 这类梯度随误差增大的损失时
- 看模型是否有归一化层,梯度在深层网络里累积放大,容易出现指数级增长
- 如果是自己实现了损失函数,检查 log 的输入是否可能出现 0
用 BCEWithLogitsLoss 而不是自己写sigmoid加log的组合,本身就能规避一类 NaN 问题。
4. SmoothL1Loss 的 beta 调成多大合适
网上很多旧博客还停留在“SmoothL1 分段点固定为 1”的说法。新版 PyTorch 加了beta参数后,等于给了你一个调节 L1 和 L2 行为比例的手柄。beta 越小,函数在接近 0 的区间越窄,整体越像 L1;beta 越大,越像 MSE。我平时调试时起点是beta=1.0,如果训练后期摇摆不定,就试着降到0.5或0.3,让模型在最优解附近获得更平滑的梯度。
5. 多卡训练时的损失重复打印误差
用torch.nn.DataParallel或分布式训练时,终端打印的 loss 是所有卡的平均值还是第一张卡的单独值,很多人会产生误解。默认情况下模型被并行复制后,每张卡各自计算自己的 loss,如果你在代码里直接打印loss.item(),打印的是当前进程所在那张卡的 loss,不是全局平均。负责任的做法是把所有卡的 loss 汇总后求平均,或者用分布式框架提供的 all-reduce 机制统计。否则你在 8 卡机器上看损失下降曲线,可能看到的是某一张卡上比较极端的数据。
我个人在实际操作中的体会是,每次新接手一个训练任务,先别急着写模型,把损失函数和标签的映射关系写清楚,包括标签类型、形状、范围、是否转 float、logits 是否需要裁剪,这些边角问题才是让整个训练流程顺利跑起来的隐形门槛。损失函数这部分内容还有很多“进阶款”,比如处理难样本的 Focal Loss、处理分割不平衡的 Dice Loss、处理对比学习的 InfoNCE 等,但只要你把本篇这几个基础损失背后的梯度行为和数值稳定性逻辑吃透,后面那些复杂损失对你来说都只是换了个公式、多加了几个超参数而已。