PyTorch06-PyTorch进阶训练技巧

六、PyTorch进阶训练技巧

6.1 自定义损失函数

尽管PyTorch官方针对主流模型为用户提供了很多内置损失函数,但随着应用场景的变化与自定义模型的出现,那些内置损失函数可能并不适合,于是产生了一些针对非通用模型的损失函数。有些损失函数是针对某特定场景使用,可以直接拿来使用或修改使用,有些场景或自定义模型甚至找不到现成或逼近的损失函数可用,此时就需要自定义损失函数。

本章节学习的内容包括:如何自定义损失函数。

6.1.1 以函数形式定义损失函数

损失函数其实就是用来计算模型输出数据与真实数据之间的差距的,所以计算损失的过程就是一个函数执行的过程。 所以理论上来说,可以通过定义一个函数的形式来自定义损失函数。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

def my_loss(output, target):
"""
最简单的损失函数示例:求取两个张量的均方误差,通常也称为 L2 损失(L2 Loss)
"""
# 求差->平方->求均值
loss = torch.mean((output - target)**2)
return loss

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
8
import 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\):代表预测区域与真实区域没有任何重合
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
2
3
4
5
6
7
8
9
10
11
12
import torch

def dice_loss(input, target, smooth=1e-5):
# input: 模型预测概率 (N, C, H, W)
# target: 真实标签 (N, C, H, W)

# 将 Tensor 展平计算全局重叠
intersection = torch.sum(input * target)
cardinality = torch.sum(input) + torch.sum(target)

dsc = (2.0 * intersection + smooth) / (cardinality + smooth)
return 1.0 - dsc
6.1.2.2.4 为什么分割任务中非常喜欢用 Dice Loss?

普通的交叉熵损失(Cross Entropy Loss)是对所有像素一视同仁地计算误差。如果在医学图像中,病灶(前景目标)只占全图的 1%,背景占了 99%(严重的前背景极度不平衡),模型即使把所有像素都预测为背景,准确率也能达到 99%,但这种模型是无效的。

Dice Loss 关注的是前景与真实区域的交叉重叠比例,它天然消除了背景像素占绝大多数带来的影响,能够极大地改善类别不平衡(Class Imbalance)问题。

6.1.2.3 DiceBCELoss

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
78
79
80
81
82
83
84
85
86
87
88
import torch
import torch.nn as nn
import torch.nn.functional as F


class DiceBCELoss(nn.Module):
"""二分类或多标签分割的 BCE + Dice 组合损失。

inputs: 模型的原始输出 logits,形状为 (N, C, ...),不要预先经过 sigmoid。
targets: 与 inputs 形状相同、元素为 0 或 1 的标签。

BCE 逐像素衡量预测与标签的差异;Dice 关注预测区域和真实区域的重叠。
将两者相加常用于前景像素很少的二分类分割任务。
"""

def __init__(self, smooth=1e-6, bce_weight=1.0, dice_weight=1.0, reduction="mean"):
super().__init__()
if reduction not in {"mean", "sum", "none"}:
raise ValueError("reduction 必须是 'mean'、'sum' 或 'none'。")
self.smooth = smooth
self.bce_weight = bce_weight
self.dice_weight = dice_weight
self.reduction = reduction

def forward(self, inputs, targets):
# 模型输出与标签必须逐元素对应,例如都为 (N, 1, H, W)。
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) 两维张量。")

# 标签常是 int64 的 0/1 张量,计算前需转为与 logits 相同的浮点类型和设备。
targets = targets.to(device=inputs.device, dtype=inputs.dtype)

# 1. BCE 部分:直接处理 logits,数值上比 sigmoid 后再算 BCE 更稳定。
# reduction='none' 保留每个元素的损失,以便和“每样本、每通道”的 Dice 对齐。
bce = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none")

# 2. Dice 部分:sigmoid 将 logits 转换为 [0, 1] 的前景预测概率。
probabilities = torch.sigmoid(inputs)
# 保留 batch 维 N 与通道维 C;只在 H、W 等空间维求和。
spatial_dims = tuple(range(2, inputs.ndim))
if spatial_dims:
bce = bce.mean(dim=spatial_dims) # 形状:(N, C)
intersection = (probabilities * targets).sum(dim=spatial_dims)
cardinality = probabilities.sum(dim=spatial_dims) + targets.sum(dim=spatial_dims)
else:
# 兼容普通二分类张量 (N, C),此时每个元素本身就是一个计算单位。
intersection = probabilities * targets
cardinality = probabilities + targets

dice = (2 * intersection + self.smooth) / (cardinality + self.smooth)
dice_loss = 1 - dice # 形状:(N, C)

# 可通过 bce_weight、dice_weight 调整两部分在总损失中的相对影响。
loss = self.bce_weight * bce + self.dice_weight * dice_loss

if self.reduction == "mean":
return loss.mean() # 常用:返回一个标量,供 loss.backward() 使用。
if self.reduction == "sum":
return loss.sum()
return loss # 形状:(N, C),供调用方自行加权或分析。


# ==================== 使用方法:二分类分割 ====================
torch.manual_seed(42)
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# N=2:两张图像;C=1:每个像素只有前景/背景两种可能;H、W:图像尺寸。
# 用 randn() 模拟模型的 logits,它可以为任意实数;requires_grad=True 用于验证反向传播。
logits = torch.randn(2, 1, 64, 64, device=device, requires_grad=True)
# 真实标签必须与 logits 形状一致,且元素为 0 或 1。
targets = torch.randint(0, 2, (2, 1, 64, 64), device=device)

criterion = DiceBCELoss(bce_weight=1.0, dice_weight=1.0)
loss = criterion(logits, targets)
loss.backward()

print("运行设备:", device)
print(f"BCE + Dice 损失:{loss.item():.6f}")
print("logits 是否获得梯度:", logits.grad is not None)

# 注意:多类别互斥分割(一个像素只能属于 C 个类别中的一个)通常使用 CrossEntropyLoss,
# 并需要 softmax 版本的 Dice;不能直接使用此处基于 sigmoid 的二分类/多标签实现。

6.1.2.4 IoULoss

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
import torch
import torch.nn as nn


class IoULoss(nn.Module):
"""二分类或多标签分割的 IoU(Jaccard)损失。

inputs 是形状为 (N, C, ...) 的 logits;targets 与其形状相同,且元素为 0 或 1。
IoU = 交集 / 并集,损失定义为 1 - IoU。每个样本、每个通道独立计算后再规约。
"""

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 转成概率。targets 转为同设备、同浮点类型,便于后续相乘和求和。
probabilities = torch.sigmoid(inputs)
targets = targets.to(device=inputs.device, dtype=probabilities.dtype)

# 对每个样本、每个通道,在空间维(如 H、W)独立计算 IoU。
spatial_dims = tuple(range(2, inputs.ndim))
if spatial_dims:
intersection = (probabilities * targets).sum(dim=spatial_dims)
# 并集 = 预测区域 + 标签区域 - 重叠区域。
union = (probabilities + targets - probabilities * targets).sum(dim=spatial_dims)
else:
# 兼容普通二分类张量 (N, C)。
intersection = probabilities * targets
union = probabilities + targets - probabilities * targets

# smooth 避免预测与标签均为空时出现 0/0;其值应远小于正常的区域面积。
iou = (intersection + self.smooth) / (union + self.smooth)
loss = 1 - iou

if self.reduction == "mean":
return loss.mean()
if self.reduction == "sum":
return loss.sum()
return loss # 形状:(N, C)


# ==================== 使用方法:二分类分割 ====================
torch.manual_seed(42)
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# 模拟模型输出 logits 与二值分割标签。输入不是概率,因此可以取任意实数。
logits = torch.randn(2, 1, 64, 64, device=device, requires_grad=True)
targets = torch.randint(0, 2, (2, 1, 64, 64), device=device)

criterion = IoULoss()
loss = criterion(logits, targets)
loss.backward()

print("运行设备:", device)
print(f"IoU 损失:{loss.item():.6f}")
print("logits 是否获得梯度:", logits.grad is not None)

# 注意:IoU 损失衡量区域重叠,对小目标分割很有帮助;实践中也常与 BCE 损失组合使用,
# 以同时提供逐像素监督和整体区域重叠监督。

6.1.2.5 FocalLoss

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
78
79
80
81
82
import torch
import torch.nn as nn
import torch.nn.functional as F


class FocalLoss(nn.Module):
"""用于二分类或多标签任务的 Binary Focal Loss。

inputs: 模型输出的 logits,形状为 (N, C, ...),不要预先经过 sigmoid。
targets: 与 inputs 形状相同、元素为 0 或 1 的标签。

gamma 会降低“容易分类样本”的损失权重;alpha 用于平衡正、负样本。
它适合前景很少的分割任务或正负样本不均衡的多标签任务。
"""

def __init__(self, alpha=0.25, gamma=2.0, reduction="mean"):
super().__init__()
if alpha is not None and not 0 <= alpha <= 1:
raise ValueError("alpha 必须位于 [0, 1],或设为 None 以不使用类别平衡。")
if gamma < 0:
raise ValueError("gamma 必须大于或等于 0。")
if reduction not in {"mean", "sum", "none"}:
raise ValueError("reduction 必须是 'mean'、'sum' 或 'none'。")
self.alpha = alpha
self.gamma = gamma
self.reduction = reduction

def forward(self, inputs, targets):
if inputs.shape != targets.shape:
raise ValueError(
"inputs 与 targets 的形状必须相同,"
f"当前分别为 {tuple(inputs.shape)}{tuple(targets.shape)}。"
)

targets = targets.to(device=inputs.device, dtype=inputs.dtype)

# BCEWithLogits 的实现直接处理 logits,数值上比 sigmoid 后再算 BCE 更稳定。
bce = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none")
probabilities = torch.sigmoid(inputs)

# p_t 表示“模型给真实类别分配的概率”:
# 标签为 1 时取 p;标签为 0 时取 (1 - p)。p_t 越接近 1,样本越容易。
p_t = probabilities * targets + (1 - probabilities) * (1 - targets)
focal_weight = (1 - p_t).pow(self.gamma)

# alpha 为正类设置 alpha,为负类设置 (1 - alpha)。
# alpha=None 时不进行正负类别平衡,仅使用 gamma 的困难样本聚焦作用。
if self.alpha is not None:
alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets)
focal_weight = alpha_t * focal_weight

loss = focal_weight * bce

if self.reduction == "mean":
return loss.mean()
if self.reduction == "sum":
return loss.sum()
return loss # 与 inputs 相同形状,供调用方自行按样本或像素加权。


# ==================== 使用方法:类别不平衡的二分类分割 ====================
torch.manual_seed(42)
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# 用较少的正类像素模拟“前景稀少”的分割场景;logits 是模型原始输出。
logits = torch.randn(2, 1, 64, 64, device=device, requires_grad=True)
targets = (torch.rand(2, 1, 64, 64, device=device) < 0.1).long()

# gamma=2 是常用起点;alpha=0.25 表示正类基础权重为 0.25、负类为 0.75。
# alpha 的具体值应结合任务中正负样本比例和验证集结果调节,而非机械固定。
criterion = FocalLoss(alpha=0.25, gamma=2.0)
loss = criterion(logits, targets)
loss.backward()

print("运行设备:", device)
print(f"正类像素比例:{targets.float().mean().item():.4f}")
print(f"Focal Loss:{loss.item():.6f}")
print("logits 是否获得梯度:", logits.grad is not None)

# 注意:多类别互斥分类/分割(每个样本或像素只能属于一个类别)需要 softmax 版本的 Focal Loss,
# 通常以 (N, C, ...) logits 和类别索引标签作为输入,不能直接套用本二分类版本。

6.1.3 为什么常用“类”的形式

既然定义一个普通函数就行,那什么情况下需要把 Loss 写成继承 nn.Module 的类呢?主要有以下 3 个场景:

  • 损失函数包含状态或超参数(Hyperparameters) 比如之前写的 DiceLoss(smooth=1.0)CombinedLoss(alpha=0.5),如果写成类,可以在 __init__ 中把 smoothalpha 保存为成员变量,调用时只需传 criterion(output, target),接口更加统一规范。

  • 损失函数包含“可学习参数”(Learnable Parameters) 有些高级损失函数(如某些自适应损失、感知损失、带有可训练权重系数的多任务 Loss),Loss 内部自己也带有需要随训练更新的 nn.Parameter。此时必须使用 nn.Module,这样 modelloss 的参数才能一起被传给优化器(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 的类。

6.2 动态调整学习率

6.3 模型微调-torchvision

6.3 模型微调 - timm

6.4 半精度训练

6.5 数据增强-imgaug

6.6 使用argparse进行调参


PyTorch06-PyTorch进阶训练技巧
https://jiangsanyin.github.io/2026/09/21/PyTorch06-PyTorch进阶训练技巧/
作者
sanyinjiang
发布于
2026年9月21日
许可协议