在这里插入图片描述

文章目录


📖 课前导读

为什么损失函数是深度学习的“指挥棒”?

你搭建了一个神经网络,前向传播得到了预测值。但你如何知道这个预测“好”还是“坏”?损失函数(Loss Function)就是答案——它是一个数学函数,输入是模型预测值y_pred和真实标签y_true,输出一个标量数值,表示预测与真实之间的“差距”。训练的目标就是最小化这个损失

损失函数的选择直接决定了模型的学习方向。选错了损失,即使网络再深、数据再多,模型也学不到你想要的规律。例如,用MSE做分类任务,模型会被迫去拟合概率值的精确位置,却忽略了类别间的相对顺序;用交叉熵做回归,模型会认为接近真实值的预测和远距离预测的惩罚力度相似,导致收敛变慢。

💡 类比:把训练比作蒙眼下山,模型是登山者,损失函数是脚下的坡度感知——告诉你哪个方向是下坡(梯度下降),而且损失的数值大小告诉你“这里有多陡”。一个好的损失函数就像精度极高的海拔仪,能引导登山者以最快的路径找到山谷最低点。

学完这一课,你将能够:

  • ✅ 为回归任务选择MSE、L1或SmoothL1损失,并理解它们对异常值的敏感度差异
  • ✅ 掌握分类任务中的交叉熵损失原理,了解它为什么比MSE更适合分类
  • ✅ 处理多标签分类、二分类、多分类的损失选择
  • ✅ 编写自定义损失函数(如加权损失、Focal Loss)
  • ✅ 在多任务学习中合理组合多个损失,设置动态权重
  • ✅ 调试训练中的损失异常:不下降、振荡、NaN等问题

一、知识原理:损失函数的数学基础

1.1 损失函数与风险最小化

从统计学习理论的角度,我们希望在数据分布P(x,y)上最小化期望风险(Expected Risk):R(f) = E[L(f(x), y)]。但由于真实分布未知,我们通过最小化经验风险(Empirical Risk)来近似:R_emp(f) = (1/N) * Σ L(f(x_i), y_i)

因此,损失函数L决定了模型学习的归纳偏置——它会引导模型偏向某种特定的解。

1.2 损失函数的理想特性

特性说明
非负性L(y_pred, y_true) ≥ 0,且仅当预测完全正确时为0
对称性(可选)对于某些任务,过高和过低估惩罚应对称
鲁棒性对异常值不应过于敏感(如Huber损失)
可微性几乎处处可微,以便反向传播
与评价指标对齐最小化损失应能最大化下游指标(如准确率、AUC)

1.3 回归损失 vs 分类损失的核心区别

  • 回归任务:输出连续值,预测值与真实值之间的距离有明确物理意义,损失常基于距离度量(绝对差、平方差)。
  • 分类任务:输出离散类别,模型通常输出类别的概率,损失应惩罚“错误类别的概率过高”,常用交叉熵等基于信息论或概率距离的度量。

二、环境搭建与准备

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import matplotlib.pyplot as plt
import numpy as np

print(f"PyTorch版本: {torch.__version__}")

# 设置随机种子
torch.manual_seed(42)
np.random.seed(42)

# 用于可视化损失曲线的数据
x_plot = np.linspace(-3, 3, 100)

三、代码实战:回归损失函数

3.1 MSE Loss(均方误差)

MSE计算预测值与真实值之差的平方,再取平均(或求和)。

MSE = (1/N) * Σ (y_pred_i - y_true_i)^2

# PyTorch中的MSE损失
mse_loss = nn.MSELoss()  # 默认 reduction='mean'
# 也可用 reduction='sum' 或 'none'(返回每个元素的损失)

y_true = torch.tensor([1.0, 2.0, 3.0])
y_pred = torch.tensor([1.5, 2.0, 2.5])

loss = mse_loss(y_pred, y_true)
print(f"MSE Loss: {loss.item():.4f}")  # ((0.5^2 + 0^2 + (-0.5)^2)/3 = (0.25+0+0.25)/3=0.1667

# 手动验证
manual_mse = ((y_pred - y_true) ** 2).mean()
print(f"手动计算: {manual_mse.item():.4f}")

函数形式F.mse_loss(y_pred, y_true, reduction='mean')

优缺点

  • ✅ 处处可导,优化平滑
  • ✅ 对大误差惩罚力度大(平方放大),有助于快速减小大偏差
  • ❌ 对异常值极其敏感(一个大误差会使损失飙升,模型会拼命拟合异常点)
  • ❌ 梯度被误差值本身缩放,大误差时梯度大,小误差时梯度接近0——收敛后期更新缓慢

适用场景:当误差服从高斯分布时,MSE是最大似然估计下的最优选择,常用于普通回归任务且数据无显著异常值。

3.2 L1 Loss(平均绝对误差,MAE)

L1 = (1/N) * Σ |y_pred_i - y_true_i|

l1_loss = nn.L1Loss()  # reduction='mean'
loss_l1 = l1_loss(y_pred, y_true)
print(f"L1 Loss: {loss_l1.item():.4f}")  # (0.5+0+0.5)/3 = 0.3333

# 函数式: F.l1_loss

优缺点

  • ✅ 对异常值更鲁棒(梯度恒定,不会像MSE那样被异常值主导)
  • ✅ 在0点处不可导(但次梯度可处理)
  • ❌ 梯度恒定,当预测接近真实值时,更新步长不变,可能不易收敛到精确值
  • ❌ 大误差时梯度仍是±1,不像MSE那样能快速拉回

适用场景:数据中存在较多异常值(如工业传感器噪声),或任务本身不在意极度精确(如目标检测中的边界框回归,常使用Smooth L1)。

3.3 Smooth L1 Loss(Huber损失的简化版)

结合MSE和L1的优点:在误差较小时为平方(平滑可导),误差较大时转为线性(鲁棒)。

SmoothL1(x) = 0.5 * x^2 if |x| < 1 else |x| - 0.5,其中 x = y_pred - y_true

smooth_l1 = nn.SmoothL1Loss()  # 默认 beta=1.0(即阈值1)
loss_smooth = smooth_l1(y_pred, y_true)
print(f"Smooth L1 Loss: {loss_smooth.item():.4f}")

# 手动验证:误差为0.5时,0.5*0.5^2=0.125;0和-0.5类似,平均为0.0833?等一下,计算有误
# 正确计算:误差 e = [-0.5, 0, -0.5](预测-真值);SmoothL1(e): |e|<1 => 0.5*e^2,即0.125, 0, 0.125;平均值0.08333

优势:比MSE鲁棒,比L1在0点光滑。Faster R-CNN等目标检测模型中使用Smooth L1回归边界框。

3.4 回归损失可视化对比

errors = np.linspace(-3, 3, 200)
mse_vals = errors ** 2
l1_vals = np.abs(errors)
smooth_l1_vals = np.where(np.abs(errors) < 1, 0.5 * errors ** 2, np.abs(errors) - 0.5)

plt.figure(figsize=(10, 6))
plt.plot(errors, mse_vals, label='MSE', linewidth=2)
plt.plot(errors, l1_vals, label='L1 (MAE)', linewidth=2)
plt.plot(errors, smooth_l1_vals, label='Smooth L1 (beta=1)', linewidth=2)
plt.xlabel('Prediction Error (y_pred - y_true)')
plt.ylabel('Loss')
plt.title('回归损失函数对比')
plt.grid(True, alpha=0.3)
plt.legend()
plt.show()

从图中可见:MSE在误差大时急剧上升(过度惩罚异常值);L1线性增长;Smooth L1在误差小的时候像MSE(光滑),大的时候像L1(线性)。

3.5 回归损失的选择建议

情况推荐损失理由
数据干净,误差近似高斯MSE统计最优,收敛快
数据有异常值(长尾噪声)L1 或 Smooth L1不被离群点绑架
目标检测边界框回归Smooth L1平衡精度与鲁棒性,且实现简单
深度学习任意回归任务(不确定)Smooth L1安全起点,结合两者优点

四、代码实战:分类损失函数

4.1 CrossEntropyLoss(交叉熵损失)

使用前提:多分类问题,模型最后一层输出未归一化的logits(形状 [batch_size, num_classes]),且标签为类别索引(形状 [batch_size])。

数学原理:CrossEntropy = -log(softmax(logits)[class])。实质上是LogSoftmax + NLLLoss的组合,比手动做softmax再负对数更数值稳定。

# 示例:3分类问题,batch_size=2
logits = torch.tensor([[2.0, 1.0, 0.1],
                       [0.5, 2.0, 0.3]])  # 形状[2,3]
labels = torch.tensor([0, 1])              # 第0个样本真实类别0,第1个真实类别1

ce_loss = nn.CrossEntropyLoss()
loss = ce_loss(logits, labels)
print(f"CrossEntropyLoss: {loss.item():.4f}")

# 手动模拟过程
softmax_probs = F.softmax(logits, dim=1)   # 转为概率
log_softmax = torch.log(softmax_probs)     # log概率
nll = -log_softmax[range(len(labels)), labels]  # 取真实类别的负对数
manual_loss = nll.mean()
print(f"手动计算损失: {manual_loss.item():.4f}")

关键参数

  • weight:类别权重(用于不平衡数据),例如weight=torch.tensor([0.2, 0.8, 0.5])
  • reduction'mean'(默认)、'sum''none'
  • label_smoothing:标签平滑,让模型不极度自信,增强泛化(PyTorch 1.10+)
# 使用类别权重
class_weights = torch.tensor([1.0, 2.0, 1.5])  # 类别1权重更高,惩罚更多
weighted_ce = nn.CrossEntropyLoss(weight=class_weights)
loss_w = weighted_ce(logits, labels)
print(f"加权交叉熵: {loss_w.item():.4f}")

4.2 NLLLoss(负对数似然损失)

通常与LogSoftmax配合,相当于CrossEntropy拆成两步:LogSoftmax + NLLLoss。但直接使用CrossEntropyLoss更简单,除非你需要单独输出log-probabilities。

log_softmax = nn.LogSoftmax(dim=1)
nll_loss = nn.NLLLoss()

log_probs = log_softmax(logits)
loss_nll = nll_loss(log_probs, labels)
print(f"NLLLoss结果: {loss_nll.item():.4f}")  # 应与CE相同

4.3 BCEWithLogitsLoss(二分类交叉熵)

用于二分类问题(标签为0/1),模型输出一个值(未经过Sigmoid)。数值上等价于 Sigmoid + BCELoss,但更稳定。

BCEWithLogitsLoss适用于标签形状为 [batch_size] 或者 [batch_size, 1],输出为 [batch_size] 或 [batch_size, 1]。

# 二分类示例
logits_binary = torch.tensor([2.0, -1.0, 0.5])    # 未sigmoid
labels_binary = torch.tensor([1.0, 0.0, 1.0])     # 真实标签

bce_loss = nn.BCEWithLogitsLoss()
loss_bce = bce_loss(logits_binary, labels_binary)
print(f"BCEWithLogitsLoss: {loss_bce.item():.4f}")

# 手动验证
probs = torch.sigmoid(logits_binary)
manual_bce = -(labels_binary * torch.log(probs) + (1-labels_binary) * torch.log(1-probs)).mean()
print(f"手动计算: {manual_bce.item():.4f}")

参数:同样支持 pos_weight(正样本权重,处理类别不平衡)和 weight

4.4 BCELoss(不推荐单独使用)

需要手动对模型输出做Sigmoid,数值稳定性差(容易产生log(0))。所以一般使用BCEWithLogitsLoss

# 不推荐
sigmoid = nn.Sigmoid()
bce = nn.BCELoss()
probs = sigmoid(logits_binary)
loss = bce(probs, labels_binary)  # 和上面结果一致,但需要额外步骤

4.5 多标签分类损失

多标签分类:每个样本可能同时属于多个类别(如一张图同时包含猫、狗、鸟)。输出层为 num_classes个logits,每个类别独立使用Sigmoid,损失采用BCEWithLogitsLoss,标签形状与输出相同,值为0/1。

# 多标签示例:3个类别,2个样本
logits_multilabel = torch.tensor([[2.0, 0.5, -1.0],
                                   [1.0, -0.5, 0.8]])
# 标签:样本1有类别0和2,样本2只有类别1
labels_multilabel = torch.tensor([[1.0, 0.0, 1.0],
                                  [0.0, 1.0, 0.0]])

bce_loss_multilabel = nn.BCEWithLogitsLoss()
loss_ml = bce_loss_multilabel(logits_multilabel, labels_multilabel)
print(f"多标签损失: {loss_ml.item():.4f}")

4.6 分类损失API速查表

任务推荐损失模型最后一层标签形式说明
二分类BCEWithLogitsLoss1个神经元(无激活)0/1浮点或布尔数值稳定
多分类(单标签)CrossEntropyLoss类别数个神经元(无Softmax)类别索引整数最常用
多分类(单标签),需要log-probLogSoftmax + NLLLoss类别数个神经元类别索引用于某些生成任务
多标签分类BCEWithLogitsLoss类别数个神经元多热向量(0/1)每个类别独立二分类
多分类(标签平滑)CrossEntropyLoss 设置 label_smoothing同上同上提升泛化

五、高级损失函数与自定义损失

5.1 自定义损失函数:两种方式

方式一:继承nn.Module(推荐用于带参数或复杂逻辑的损失)

class WeightedMSELoss(nn.Module):
    """加权MSE损失,对每个样本的loss乘以权重"""
    def __init__(self, weight=None):
        super().__init__()
        self.weight = weight  # 可以是标量或与目标同形状的tensor
    
    def forward(self, pred, target):
        loss = (pred - target) ** 2
        if self.weight is not None:
            loss = loss * self.weight.to(loss.device)
        return loss.mean()

# 使用
w_mse = WeightedMSELoss(weight=torch.tensor([0.5, 1.0, 2.0]))
y_pred = torch.tensor([1.0, 2.0, 3.0])
y_true = torch.tensor([1.2, 1.8, 3.1])
loss = w_mse(y_pred, y_true)
print(loss)

方式二:简单的函数形式

def my_smooth_l1_loss(pred, target, beta=1.0):
    diff = pred - target
    abs_diff = torch.abs(diff)
    loss = torch.where(abs_diff < beta, 0.5 * diff ** 2 / beta, abs_diff - 0.5 * beta)
    return loss.mean()

# 在训练循环中直接使用
loss = my_smooth_l1_loss(output, target)

5.2 处理类别不平衡:Focal Loss

Focal Loss由RetinaNet提出,专门解决类别极不平衡的目标检测问题。它在交叉熵的基础上增加了调制因子(1 - p_t)^γ,使模型更关注难分类的样本。

公式:FL(p_t) = -α_t * (1 - p_t)^γ * log(p_t)

class FocalLoss(nn.Module):
    def __init__(self, alpha=0.25, gamma=2.0, reduction='mean'):
        super().__init__()
        self.alpha = alpha
        self.gamma = gamma
        self.reduction = reduction
    
    def forward(self, logits, targets):
        # logits: [batch, num_classes], targets: [batch]
        ce_loss = F.cross_entropy(logits, targets, reduction='none')
        pt = torch.exp(-ce_loss)  # 模型对正确类别的预测概率
        focal_weight = (1 - pt) ** self.gamma
        if self.alpha is not None:
            alpha_t = self.alpha * (targets == 1).float() + (1 - self.alpha) * (targets != 1).float()
            focal_weight = alpha_t * focal_weight
        loss = focal_weight * ce_loss
        if self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        else:
            return loss

# 使用示例
focal_loss = FocalLoss(alpha=0.25, gamma=2)
logits = torch.randn(4, 10)
labels = torch.randint(0, 10, (4,))
loss_focal = focal_loss(logits, labels)

5.3 标签平滑(Label Smoothing)

标签平滑是一种正则化技术,防止模型对训练标签过于自信(过拟合)。它将独热标签的1替换为1-ε,其他0替换为ε/(K-1)

# PyTorch内置(1.10+)
ce_smooth = nn.CrossEntropyLoss(label_smoothing=0.1)
loss_smooth = ce_smooth(logits, labels)
print(f"标签平滑后损失: {loss_smooth.item():.4f}")

# 手动实现(用于老版本)
def label_smoothing_loss(logits, targets, epsilon=0.1):
    K = logits.size(1)
    log_probs = F.log_softmax(logits, dim=1)
    with torch.no_grad():
        smooth_targets = torch.zeros_like(log_probs).fill_(epsilon / (K - 1))
        smooth_targets.scatter_(1, targets.unsqueeze(1), 1 - epsilon)
    return -(smooth_targets * log_probs).sum(dim=1).mean()

5.4 多任务损失组合

当模型需要同时优化多个目标(例如同时做分类和回归),可以组合多个损失:total_loss = λ1 * loss1 + λ2 * loss2

如何选择权重λ?常见方法:

  • 手动设置:根据任务重要性固定权重。
  • 动态加权:根据损失的大小自动调整(如loss1 / (loss1 + loss2)归一化)。
  • 不确定性加权:学习每个任务的不确定性(方差),详见论文Multi-Task Learning Using Uncertainty to Weigh Losses for Scene Geometry and Semantics
class MultiTaskLoss(nn.Module):
    def __init__(self, num_tasks):
        super().__init__()
        self.log_vars = nn.Parameter(torch.zeros(num_tasks))  # 可学习方差的对数
    
    def forward(self, losses):
        # losses: list/tuple of task losses
        weighted_loss = 0
        for i, loss in enumerate(losses):
            precision = torch.exp(-self.log_vars[i])  # 1/方差
            weighted_loss += precision * loss + self.log_vars[i]
        return weighted_loss

# 使用示例
mtl = MultiTaskLoss(num_tasks=2)
loss1 = torch.tensor(0.5, requires_grad=True)
loss2 = torch.tensor(1.2, requires_grad=True)
total = mtl([loss1, loss2])
total.backward()  # 自动学习log_vars

六、训练中损失异常排查

6.1 损失不下降的可能原因

现象可能原因解决方案
损失震荡不降学习率过大降低学习率或使用学习率衰减
损失先降后升过拟合增加正则化(Dropout, weight decay)
损失下降极慢学习率过小 / 梯度消失提高学习率,检查激活函数
损失为NaN梯度爆炸 / 除以0 / log(0)梯度裁剪,检查数据归一化,使用稳定的损失函数(如BCEWithLogits)
损失负值某些损失定义允许负值(如余弦相似度)或代码错误检查损失公式,确认是否合理

6.2 调试技巧

  • 打印损失值:每个batch记录,观察趋势。
  • 监控梯度范数:使用torch.nn.utils.clip_grad_norm_前打印norm。
  • 减小学习率进行测试:如果损失变为NaN,尝试极低学习率看是否正常,排除爆炸。
  • 检查标签范围:分类标签是否超出类别数,回归标签是否数值过大。
  • 检查数据预处理:是否归一化,是否存在缺失值。
# 梯度监控示例
def log_grad_norm(model):
    total_norm = 0
    for p in model.parameters():
        if p.grad is not None:
            param_norm = p.grad.data.norm(2)
            total_norm += param_norm.item() ** 2
    total_norm = total_norm ** 0.5
    print(f"梯度总范数: {total_norm:.4f}")

七、难点解析:常见问题与陷阱

7.1 将未归一化的logits传入CrossEntropyLoss前手动Softmax

错误示例:

# 错误:CrossEntropyLoss内部已经包含Softmax
outputs = torch.softmax(model(x), dim=1)
loss = nn.CrossEntropyLoss()(outputs, labels)   # 相当于做了两次softmax

后果:损失值不可解释,梯度扭曲,模型难以收敛。

7.2 回归任务使用了分类损失

例如用交叉熵做房价预测。交叉熵要求输出和为1,且期望的概率分布,而房价是连续值,两者完全不兼容。必须使用回归损失。

7.3 在二分类中使用CrossEntropyLoss

二分类也可以用CrossEntropyLoss,但需要输出2个神经元(分别对应类别0和1),标签为0/1。通常更高效的做法是BCEWithLogitsLoss,只需输出1个神经元。

7.4 损失函数的reduction参数导致batch统计错误

在分布式训练或需要梯度累加时,reduction='mean'在不同batch大小下会改变损失的尺度。最好统一使用reduction='sum'配合手工除以总样本数。

7.5 自定义损失中未正确处理设备和数据类型

自定义损失内部使用时,需要将权重等移动到与输入相同的设备。可使用self.weight.to(pred.device)


八、课后总结

回归损失对比表

损失公式对异常值鲁棒光滑性适用场景
MSE平方差✅ 处处光滑高斯噪声,无异常值
L1绝对差❌ 0点不可导有异常值
Smooth L1分段目标检测,稳健回归

分类损失对比表

任务推荐损失内部操作最后激活标签形式
多分类(单标签)CrossEntropyLossLogSoftmax + NLLLoss整数索引
二分类BCEWithLogitsLossSigmoid + BCELoss0/1浮点
多标签分类BCEWithLogitsLoss每个类别Sigmoid多热向量
带标签平滑CrossEntropyLoss(label_smoothing)同上整数索引

检查清单

  • 知道为回归任务选择MSE或L1/SmoothL1的依据
  • 能正确使用CrossEntropyLoss,不在之前加Softmax
  • 理解BCEWithLogitsLoss与BCELoss的区别
  • 会为多标签任务配置损失
  • 能够编写简单的自定义损失函数
  • 了解多任务损失组合的常用策略
  • 掌握训练中损失异常的排查方法

九、课后作业

作业1:回归损失性能对比

构造一个包含异常值的回归数据集(例如在正态分布数据中加入10%的离群点)。用同一个简单线性模型,分别使用MSE、L1和SmoothL1损失训练,观察模型对异常值的拟合程度,并分析哪个损失效果最好。

作业2:交叉熵与MSE分类对比

在MNIST上训练一个简单网络,分别使用CrossEntropyLoss和MSELoss(需要将标签转为one-hot,输出层使用Softmax)。对比两者的收敛速度和最终准确率。解释为什么MSE不适合分类。

作业3:自定义损失实现

实现一个HuberLoss类(与SmoothL1Loss类似但可调节delta参数)。在回归任务中比较HuberLoss(delta=1)与SmoothL1Loss的数值差异。

作业4:类别不平衡实验

创建一个类别严重不平衡的二分类数据集(正负比1:100)。使用普通的BCEWithLogitsLoss,然后使用带有pos_weight参数的BCEWithLogitsLoss(例如pos_weight=100),对比模型在少数类上的召回率。

作业5:多任务损失组合

假设模型需要同时输出分类(3类)和回归(单个值)。定义总损失为loss_cls + λ * loss_reg。实验不同的λ值(0.1, 1.0, 10.0),观察两个任务的性能变化。尝试使用不确定性加权方法自动学习λ。


十、下一课预告

第9课我们将学习优化器原理与主流优化器实战调参,内容包括:

  • 梯度下降的变体:SGD、Momentum、Nesterov
  • 自适应学习率优化器:AdaGrad、RMSprop、Adam、AdamW
  • 优化器参数详解(学习率、动量、权重衰减)
  • 优化器选择策略和实战调参建议
  • 学习率与优化器的配合

学完第9课,你将能根据任务特点选择合适的优化器,并调出更好更稳定的收敛效果。


附录:PyTorch损失函数API速查

损失类函数式用途
nn.MSELossF.mse_loss回归,平方差
nn.L1LossF.l1_loss回归,绝对差
nn.SmoothL1LossF.smooth_l1_loss稳健回归
nn.CrossEntropyLossF.cross_entropy多分类(单标签)
nn.BCEWithLogitsLossF.binary_cross_entropy_with_logits二分类/多标签
nn.BCELossF.binary_cross_entropy二分类(需手动sigmoid)
nn.NLLLossF.nll_loss配合LogSoftmax
nn.KLDivLossF.kl_divKL散度,用于蒸馏
nn.CosineEmbeddingLoss-相似度学习

🔗《精讲25课|PyTorch 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~

更多推荐