PyTorch06-PyTorch进阶训练技巧
六、PyTorch进阶训练技巧
6.1 自定义损失函数
尽管PyTorch官方针对主流模型为用户提供了很多内置损失函数,但随着应用场景的变化与自定义模型的出现,那些内置损失函数可能并不适合,于是产生了一些针对非通用模型的损失函数。有些损失函数是针对某特定场景使用,可以直接拿来使用或修改使用,有些场景或自定义模型甚至找不到现成或逼近的损失函数可用,此时就需要自定义损失函数。
本章节学习的内容包括:如何自定义损失函数。
6.1.1 以函数形式定义损失函数
损失函数其实就是用来计算模型输出数据与真实数据之间的差距的,所以计算损失的过程就是一个函数执行的过程。 所以理论上来说,可以通过定义一个函数的形式来自定义损失函数。
1 | |
6.1.2 以类形式定义损失函数
6.1.2.1 类形式定义Loss介绍
虽然以普通函数方式定义损失函数简单、快速,但实践中仍是常以类形式自定义损失。通过查看PyTorch源码可以发现,内置的损失函数的有的继承自torch.nn.modules.loss._Loss,有的继承自torch.nn.modules.loss._WeightedLoss
,甚至还有很多继承自torch.nn.Module,其实就三者之间也存在继承关系,本质这些内置的损失函数仍是继承自torch.nn.Module。
三者之间的继承关系如下。 1
2
3
4
5
6
7
8import torch.nn as nn
# 导入这两个底层抽象基类
from torch.nn.modules.loss import _Loss, _WeightedLoss
# 验证继承关系
print(issubclass(_Loss, nn.Module)) # 输出: True
print(issubclass(_WeightedLoss, _Loss)) # 输出: True
类名开头有下划线_在 Python 中代表这是 PyTorch
内部使用的私有 / 保护基类,官方通常不建议普通用户在自定义 Loss
时直接继承它们,但了解它们对于理解 PyTorch
源码极其有帮助。就是说,_Loss 和 _WeightedLoss
这两个抽象基类是给PyTorhc内置的损失函数使用的,一般不面向用户直接使用,用户要自定义损失函数还是得直接继承自
torch.nn.Module,这也表示我们将此类自定义损失函数当作神经网络的一层来对待。
以下以DiceLoss讲述以类的尬啊自定义损失函数 及使用。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77# 10-自定义损失函数DiceLoss2.py
import torch
from torch import nn
class DiceLoss(nn.Module):
"""用于二分类或多标签分割的 Dice 损失。
inputs: 模型直接输出的 logits,形状为 (N, C, ...),不应预先经过 sigmoid。
targets: 与 inputs 形状相同、元素为 0 或 1 的分割标签。
每个样本、每个通道分别计算 Dice,再根据 reduction 返回损失,
不会将整个 batch 的样本混在一起计算一个全局 Dice。
"""
def __init__(self, smooth=1e-6, reduction="mean"):
super().__init__()
if reduction not in {"mean", "sum", "none"}:
raise ValueError("reduction 必须是 'mean'、'sum' 或 'none'。")
self.smooth = smooth
self.reduction = reduction
def forward(self, inputs, targets):
if inputs.shape != targets.shape:
raise ValueError(
"inputs 与 targets 的形状必须相同,"
f"当前分别为 {tuple(inputs.shape)} 和 {tuple(targets.shape)}。"
)
if inputs.ndim < 2:
raise ValueError("inputs 与 targets 至少应为 (N, C) 两维张量。")
# 模型应输出 logits;这里统一进行 sigmoid,得到范围为 [0, 1] 的预测概率。
probabilities = torch.sigmoid(inputs)
# 标签通常是整型 0/1 张量,转换为与概率相同的 dtype 和设备后再参与计算。
targets = targets.to(device=inputs.device, dtype=probabilities.dtype)
# 除 batch 维 N 外,沿通道与所有空间维求和。
# 对 (N, C, H, W) 输入, dice 的形状为 (N, C)。
reduce_dims = tuple(range(2, inputs.ndim))
# 默认sum方法的keepdim参数为False,不会保留对应的维度,而是直接消除对应维度即每次减少一个维度
# 从第2维开始消起,直到最后一维。最后结果的形状是: (targets.size(0), targets.size(1))
intersection = (probabilities * targets).sum(dim=reduce_dims)
cardinality = probabilities.sum(dim=reduce_dims) + targets.sum(dim=reduce_dims)
dice = (2 * intersection + self.smooth) / (cardinality + self.smooth)
loss = 1 - dice
if self.reduction == "mean":
return loss.mean()
if self.reduction == "sum":
return loss.sum()
return loss # 形状为 (N, C),可用于自行设置各样本/类别权重。
# ==================== 使用方法:二分类分割 ====================
torch.manual_seed(42) # 使本示例每次运行的随机输入与输出可复现。
# N=2 表示 batch_size;C=1 表示每个像素只有“前景/背景”两个取值。
# inputs 是模型的原始输出 logits,因此使用 torch.randn(),而非范围 [0, 1] 的随机概率。
shape = (2, 1, 4, 4)
inputs = torch.randn(*shape, requires_grad=True)
# 分割标签必须与 logits 形状一致,并且元素为 0 或 1。
targets = torch.randint(0, 2, size=shape)
criterion = DiceLoss()
loss = criterion(inputs, targets)
# DiceLoss 的输出是标量,可直接反向传播;这里仅验证梯度能够正常计算。
loss.backward()
print(f"模拟的预测数据inputs:{inputs}")
print(f"\n模拟的真实数据targets:{targets}")
print(f"\n经过DiceLoss损失函数计算得到的损失值为:{loss}")
print("每个输入元素是否都获得梯度:", inputs.grad is not None)
# 注意:多类别互斥分割(每个像素仅属于 C 个类别之一)通常使用 softmax + one-hot 标签,
# 或将 DiceLoss 与 CrossEntropyLoss 组合;不能直接将本二分类/多标签版本用于该场景。
6.1.2.2 Dice Loss损失函数
Dice 相似系数(Dice Similarity Coefficient,简称 DSC) 是图像分割(如医学图像分割 UNet)领域中最核心的评价指标之一。 基于该系数构造的损失函数被称为 Dice Loss。
6.1.2.2.1 公式及各项符号的含义
公式: \[DSC = \frac{2\vert{}X \cap Y\vert{}}{\vert{}X\vert{} + \vert{}Y\vert{}}\]
- \(X\):通常代表模型预测的分割区域(Prediction Mask)。
- \(Y\):通常代表真实的标签区域(Ground Truth Mask)。
- \(\vert{}X \cap Y\vert{}\)(分子):\(X\) 与 \(Y\) 的交集大小(即模型预测正确且属于真实目标的像素/前景数量)。乘以 \(2\) 是为了进行归一化平衡。
- \(\vert{}X\vert{} + \vert{}Y\vert{}\)(分母):\(X\) 的像素元素总数与 \(Y\) 的像素元素总数直接相加。
- \(DSC\)
的取值范围:在 \(0\)
到 \(1\) 之间。
- \(DSC =
1\):代表预测区域与真实区域完全重合(完美分割)。
- \(DSC = 0\):代表预测区域与真实区域没有任何重合。
- \(DSC =
1\):代表预测区域与真实区域完全重合(完美分割)。
6.1.2.2.2 从 DSC 指标到 Dice Loss
神经网络训练时需要最小化 Loss(损失越小越好),而 DSC 是越大越好。因此,在代码中定义 Dice Loss 时,通常采用以下两种转化方式之一: \[\text{Dice Loss} = 1 - DSC = 1 - \frac{2\vert{}X \cap Y\vert{}}{\vert{}X\vert{} + \vert{}Y\vert{}}\] 或者使用负数形式: \[\text{Dice Loss} = -DSC\] 方式一是最常用形式。这样,当分割效果越好(\(DSC \to 1\))时,\(\text{Dice Loss}\) 就越接近 \(0\)。
6.1.2.2.3 在 PyTorch 代码中如何表达?
在连续值的神经网络输出中(例如 Sigmoid 输出的概率值 \(p \in [0, 1]\)),集合操作转换如下: -
交集 \(\vert{}X \cap
Y\vert{}\) \(\rightarrow\)
逐元素相乘并求和:torch.sum(input * target) -
各自大小 \(\vert{}X\vert{} +
\vert{}Y\vert{}\) \(\rightarrow\)
逐元素求和:torch.sum(input) + torch.sum(target)
并且在分母加上微小的平滑项 epsilon(如
1e-5),防止分母为 0:
1 | |
6.1.2.2.4 为什么分割任务中非常喜欢用 Dice Loss?
普通的交叉熵损失(Cross Entropy Loss)是对所有像素一视同仁地计算误差。如果在医学图像中,病灶(前景目标)只占全图的 1%,背景占了 99%(严重的前背景极度不平衡),模型即使把所有像素都预测为背景,准确率也能达到 99%,但这种模型是无效的。
Dice Loss 关注的是前景与真实区域的交叉重叠比例,它天然消除了背景像素占绝大多数带来的影响,能够极大地改善类别不平衡(Class Imbalance)问题。
6.1.2.3 DiceBCELoss
1 | |
6.1.2.4 IoULoss
1 | |
6.1.2.5 FocalLoss
1 | |
6.1.3 为什么常用“类”的形式
既然定义一个普通函数就行,那什么情况下需要把 Loss 写成继承
nn.Module 的类呢?主要有以下 3 个场景:
损失函数包含状态或超参数(Hyperparameters) 比如之前写的
DiceLoss(smooth=1.0)或CombinedLoss(alpha=0.5),如果写成类,可以在__init__中把smooth或alpha保存为成员变量,调用时只需传criterion(output, target),接口更加统一规范。损失函数包含“可学习参数”(Learnable Parameters) 有些高级损失函数(如某些自适应损失、感知损失、带有可训练权重系数的多任务 Loss),Loss 内部自己也带有需要随训练更新的
nn.Parameter。此时必须使用nn.Module,这样model和loss的参数才能一起被传给优化器(Optimizer):1
optimizer = torch.optim.Adam(list(model.parameters()) + list(criterion.parameters()))兼容 PyTorch 标准生态管道 许多高级训练框架(如 PyTorch Lightning、HuggingFace
Trainer)内部要求criterion必须是一个nn.Module实例,方便统一进行.to(device)设备迁移或数据并行(DataParallel)。
大致的经验就是: - 临时测试、无状态计算、简单数学组合 \(\rightarrow\) 用函数形式
def my_loss(...)(简洁、高效、写起来最快)。 -
模块化封装、包含可调参数/可学习权重、需要对接通用训练框架 \(\rightarrow\) 用继承 nn.Module
的类。