万物 | 炼器 从零手搓工业级旋转目标检测网络.卷2 ------ 计算图、梯度与反向传播(二)
2.5 MultiscaleDetector 与模型训练
现在,可以完整地写出 ch02/main.py 的全部代码了。我们按模块分段讲解,引导你亲手敲出每一行。
2.5.1 多尺度虚拟数据集
在2.4节中,我们详细说明了模型会输出三个尺度的预测张量:
out3: B, 10, 32, 32
out4: B, 10, 16, 16
out5: B, 10, 8, 8
回顾第1卷,当时 SimpleFakeDataset只生成了一个尺度的标签 [C, 32, 32] ,因为第一章中的模型只输出一个尺度的预测结果。现在,很显然我们需要三个尺度的标签,以便于分别和三个不同尺度的预测结果来计算loss。
如果你有过目标检测模型训练经验,这时候也许会有一个疑问:为什么在真实模型训练的时候,或者说在构建真实的数据集时,我并没有对图片进行三个尺度的标注,都是只标注一次就可以了,那现在为什么要构建三个尺度的标签呢?难道真实的目标检测模型不是多尺度的?
真实的目标检测模型当然大概率是多尺度的,构建真实的数据集也确实只需要标注一次,这里面的奥秘在于,真实数据集中的多尺度标签不是人工标出来的,而是训练时由专门的算法自动生成的。训练程序会根据原始标注,把目标分配给不同尺度的预测位置,比如:小目标可能交给高分辨率的 P3,中目标可能交给 P4,大目标可能交给 P5。YOLO实现中,设计了专门的标签分配算法,比如后面会涉及到的 Task-Aligned Assigner。
但是目前我们还没有必要这么复杂,直接做一个"虚拟数据集",生成三份和模型输出形状一致的"虚拟标签",让训练循环先跑起来。
以下是调整后的 SimpleFakeDataset 的代码,注意类名我们这里调整为 MultiscaleFakeDataset,请在IDE中输入以下代码,注意代码间的缩进:
python
import os
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
class MultiscaleFakeDataset(Dataset):
def __init__(self, num_samples=100,
img_size=256, num_classes=2):
super().__init__()
self.images = []
self.targets = []
channels = num_classes + 8
前几行和第1章几乎一样:继承 Dataset,准备图像和标签列表,计算输出通道数(2类 + 8属性 = 10通道)。
python
# 3个尺度的网格大小
grid_sizes = [
img_size // 8, # 32 (P3, stride=8)
img_size // 16, # 16 (P4, stride=16)
img_size // 32, # 8 (P5, stride=32)
]
这是关键的新增部分。三个网格大小分别对应三个下采样倍率(第1卷中固定是一个: grid_size = img_size // 8),保存在 grid_sizes 列表中。img_size=256 时:
-
P3:256 ÷ 8 = 32
-
P4:256 ÷ 16 = 16
-
P5:256 ÷ 32 = 8
python
for _ in range(num_samples):
img = torch.randn(3, img_size, img_size)
target_list = [torch.randn(channels, gs, gs)
for gs in grid_sizes]
self.images.append(img)
self.targets.append(target_list)
每个样本的标签列表self.targets中不再是一个张量,而是包含三个张量:(10, 32, 32), (10, 16, 16), (10, 8, 8)。
python
def __len__(self):
return len(self.images)
def __getitem__(self, idx):
return self.images[idx], self.targets[idx]
getitem 返回的 targets 是一个列表。
2.5.2 自定义 collate_fn:处理嵌套列表
也许有人会问:"为什么不直接用 DataLoader 的默认 collate?"
因为默认的 collate_fn 期望每个样本的标签是单个张量,它会尝试用 torch.stack 把所有标签堆叠成一个 batch,第一章中我们就使用了 DataLoader 默认的 collate,因为那时每个样本的标签列表中就是单个张量。但现在,标签列表中含有三个不同大小的张量,直接 stack 可能会报错。
我们来自定义打包逻辑,请在IDE中输入以下代码,注意,collate_fn 是一个独立的方法,不是MultiscaleFakeDataset 类的一部分:
python
def collate_fn(batch):
images = torch.stack([item[0] for item in batch])
targets = [
torch.stack([item[1][scale] for item in batch])
for scale in range(3)
]
return images, targets
让我们一步步拆解:
打包图像:
python
images = torch.stack([item[0] for item in batch])
所有图像形状相同 3, 256, 256,直接 stack 成 B, 3, 256, 256,这里和第1卷一样。
打包多尺度标签:
python
targets = [
torch.stack([item[1][scale] for item in batch])
for scale in range(3)
]
这个嵌套列表推导需要仔细理解:
-
外层循环
for scale in range(3):遍历3个尺度(P3, P4, P5) -
内层
item\[1\]\[scale\]:取每个样本的第 scale个标签张量 -
torch.stack(\[...\]):把同一尺度的所有标签堆叠成一个 batch
最终 targets 是一个长度为 3 的列表:
python
targets[0].shape # [B, 10, 32, 32] ← P3 尺度的一个 batch
targets[1].shape # [B, 10, 16, 16] ← P4 尺度的一个 batch
targets[2].shape # [B, 10, 8, 8] ← P5 尺度的一个 batch
使用自定义 collate_fn 时,需要在创建 DataLoader 时显式传入:
python
train_loader = DataLoader(
train_dataset,
batch_size=4,
shuffle=True,
collate_fn=collate_fn # 关键!
)
2.5.3 MultiscaleDetector 模型
现在来到核心部分------在这里我们把第1卷中的检测模型 SimpleDetector 改造成多尺度检测模型 MultiscaleDetector,注意类名的变化。
在本卷的 MultiscaleDetector中,因为要把P3、P4、P5分别保存出来,同时又便于理解,我们没有像第1卷那样在MultiscaleDetector中定义 self.backbone 和 self.head,而是把检测模型的每一层都"摊开"写出来了。但是总体上,他们还是可以划分为 Backbone 、 Neck 和 Head 三个部分。
我们也按照Backbone、Neck和Head三个部分来说明 init 方法。请在IDE中输入以下代码,注意代码间的缩进。
1、Backbone:五层下采样
python
class MultiscaleDetector(nn.Module):
def __init__(self, num_classes=2):
super().__init__()
self.out_channels = num_classes + 8
# Backbone: 5层下采样
self.down1 = nn.Sequential(
nn.Conv2d(3, 16, 3, stride=2, padding=1),
nn.ReLU()
)
self.down2 = nn.Sequential(
nn.Conv2d(16, 32, 3, stride=2, padding=1),
nn.ReLU()
)
前两层和第1卷的 backbone 相同------将输入从 B, 3, 256, 256 下采样到 B, 32, 64, 64,通道数从3增加为32,空间尺度从256 x 256缩小到64 x 64。
ini
self.down3 = nn.Sequential(
nn.Conv2d(32, 32, 3, stride=2, padding=1),
nn.ReLU()
) # → P3: [B, 32, 32, 32]
self.down4 = nn.Sequential(
nn.Conv2d(32, 64, 3, stride=2, padding=1),
nn.ReLU()
) # → P4: [B, 64, 16, 16]
self.down5 = nn.Sequential(
nn.Conv2d(64, 128, 3, stride=2, padding=1),
nn.ReLU()
) # → P5: [B, 128, 8, 8]
相比第1卷多了两层下采样。在稍后实现的 forward方法中,我们会在down3、down4、down5三个位置"截胡",用临时变量把中间特征图 P3、P4、P5 保存下来。
2、FPN Neck:自顶向下融合
检测模型的结构中多了一个Neck颈部,之所以称其为模型的"颈部",是因为模型的Backbone像身体,负责从图像里提取不同层级的特征;Head像头,负责做最终预测并输出结果;在Backbone和Head中间的部分,通常实现多层特征整理、融合并将处理后的结果传给头部,所以就形象的管这个部分叫"颈部",也就是Neck,FPN就是一个典型的模型颈部结构。
ini
# 1×1 卷积:将P5的128通道降到64,对齐P4
self.lateral5 = nn.Conv2d(128, 64, kernel_size=1)
# 3×3 卷积:融合上采样的P5和原始P4
self.fpn_conv4 = nn.Sequential(
nn.Conv2d(128, 64, 3, padding=1),
nn.ReLU()
)
这里 fpn_conv4 的输入通道是 128。因为 cat(\[up5, p4\]) 把 64 通道的 up5 和 64 通道的 p4 拼接成了 128 通道。
ini
# 1×1 卷积:将F4的64通道降到32,对齐P3
self.lateral4 = nn.Conv2d(64, 32, kernel_size=1)
# 3×3 卷积:融合上采样的F4和原始P3
self.fpn_conv3 = nn.Sequential(
nn.Conv2d(64, 32, 3, padding=1),
nn.ReLU()
)
同样,fpn_conv3 的输入 64 = 32(up4)+ 32(p3)。
3、多尺度检测头
ini
# 三个独立的 1×1 检测头
self.head3 = nn.Conv2d(
32, self.out_channels, kernel_size=1
)
self.head4 = nn.Conv2d(
64, self.out_channels, kernel_size=1
)
self.head5 = nn.Conv2d(
128, self.out_channels, kernel_size=1
)
# 上采样器
self.upsample = nn.Upsample(
scale_factor=2, mode='nearest'
)
注意三个检测头的输入通道数不同(32、64、128),但输出通道数相同(out_channels = 10)。这是因为每个尺度虽然特征维度不同,但最终的检测输出格式是统一的。
4、前向传播
追踪向前传播的每一步维度变化:
ini
def forward(self, x):
# Backbone
x = self.down1(x) # [B, 16, 128, 128]
x = self.down2(x) # [B, 32, 64, 64]
p3 = self.down3(x) # [B, 32, 32, 32]
p4 = self.down4(p3) # [B, 64, 16, 16]
p5 = self.down5(p4) # [B,128, 8, 8]
从 down3 开始,我们不再覆盖 x,而是用新变量 p3、p4、p5 保存中间结果。这些中间结果是 FPN 的"原料"。
less
# FPN: P5 → F4
up5 = self.upsample(self.lateral5(p5))
# lateral5: [B,128,8,8] → [B,64,8,8]
# upsample: [B,64,8,8] → [B,64,16,16]
f4 = self.fpn_conv4(
torch.cat([up5, p4], 1)
)
# cat: [B,64,16,16]+[B,64,16,16]→[B,128,16,16]
# fpn_conv4: [B,128,16,16] → [B,64,16,16]
P5 的语义信息通过 1×1 卷积 + 上采样,与 P4 拼接后融合得到F4。为了区别原始的P4特征层,我们管融合了P5信息的新的P4层叫F4。
less
# FPN: F4 → F3
up4 = self.upsample(self.lateral4(f4))
# lateral4: [B,64,16,16] → [B,32,16,16]
# upsample: [B,32,16,16] → [B,32,32,32]
f3 = self.fpn_conv3(
torch.cat([up4, p3], 1)
)
# cat: [B,32,32,32]+[B,32,32,32]→[B,64,32,32]
# fpn_conv3: [B,64,32,32] → [B,32,32,32]
同样的操作再做一次:F4 的信息传递给 P3,融合成 F3。
ini
# 三个检测头
out3 = self.head3(f3) # [B, 10, 32, 32]
out4 = self.head4(f4) # [B, 10, 16, 16]
out5 = self.head5(p5) # [B, 10, 8, 8]
return [out3, out4, out5]
注意:head5 直接作用于 P5(未经 FPN 融合),因为 P5 是最深层,没有"更深层"的语义可以融合给它。
2.5.4 验证函数的更新
验证函数 evaluate 也需要相应更新:
python
def evaluate(model, val_dataloader, criterion, device):
model.eval()
total_loss = 0.0
with torch.no_grad():
for images, targets in val_dataloader:
images = images.to(device)
targets = [t.to(device) for t in targets]
outputs = model(images)
loss = sum(
criterion(out, tgt)
for out, tgt in zip(outputs, targets)
)
total_loss += loss.item()
avg_loss = total_loss / len(val_dataloader)
mock_map = 1.0 / (avg_loss + 1e-6)
return avg_loss, mock_map
和第1卷的区别:
-
targets 变成了列表,需要逐个
.to(device) -
Loss 计算从单次变成了三次求和。
2.5.5 多尺度 Loss 与训练循环
新的训练循环和之前相比,核心区别在于 Loss 的计算方式。第1卷是单尺度 Loss:
python
# 第1卷:
loss = criterion(predictions, targets)
本卷是多尺度 Loss 求和:
python
# 第2卷按:
loss = sum(
criterion(out, tgt)
for out, tgt in zip(outputs, targets)
)
这里的 zip是python的内置方法,其作用是将两个列表中对应位置的元素打包成一个个元组,zip(outputs, targets) 将三个尺度的输出和标签一一配对,注意,zip方法只负责按位置配对,它不会理解哪个是 P3、哪个是 P4。所以 outputs 和 targets 的顺序必须提前约定好并保持一致,否则就会出现out3和P4配对的错误。正确的配对结果如下:
out3 ↔ targets0 → MSE Loss (P3尺度)
out4 ↔ targets1 → MSE Loss (P4尺度)
out5 ↔ targets2 → MSE Loss (P5尺度)
正确配对后的数据依次送给criterion方法计算对应尺度的loss,再通过sum方法,将三个尺度的loss相加,得到最后总的loss,调用 loss.backward() 后,PyTorch会沿着计算图自动反向传播,把训练信号传回检测头、FPN 和 Backbone。谁参与了loss的计算,谁就会收到梯度,如果某个参数参与了多个尺度分支,收到的梯度会自动累加。只要前向传播的路径是连通的,PyTorch就会根据计算图把梯度送到该去的地方。
FPN 中的上采样和 Concat 让不同尺度的特征发生融合,因此多个尺度的loss 可以共同训练整个网络。这也是多尺度检测比单尺度检测更强的原因之一。完整的训练循环如下,请在你的IDE中输入以下代码:
python
def train_model():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"计算设备: {device}")
img_size = 256
num_classes = 2
batch_size = 4
num_epochs = 20
save_dir = "weights"
os.makedirs(save_dir, exist_ok=True)
# 数据准备(使用自定义 collate_fn 处理多尺度标签)
train_dataset = MultiscaleFakeDataset(num_samples=80, img_size=img_size, num_classes=num_classes)
val_dataset = MultiscaleFakeDataset(num_samples=20, img_size=img_size, num_classes=num_classes)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn)
model = MultiscaleDetector(num_classes=num_classes).to(device)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
best_mock_map = 0.0
print("-" * 60)
# 打印模型结构概览
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
# 张量维度验证
dummy = torch.randn(1, 3, img_size, img_size).to(device)
outs = model(dummy)
print("各尺度输出形状:")
for i, o in enumerate(outs):
stride = img_size // o.shape[-1]
print(f" P{i+3}: {list(o.shape)} (stride={stride})")
print("-" * 60)
for epoch in range(num_epochs):
model.train()
train_total_loss = 0.0
for images, targets in train_loader:
images = images.to(device)
targets = [t.to(device) for t in targets]
optimizer.zero_grad()
outputs = model(images)
loss = sum(
criterion(out, tgt)
for out, tgt in zip(outputs, targets)
)
loss.backward()
optimizer.step()
train_total_loss += loss.item()
train_avg_loss = train_total_loss / len(train_loader)
val_avg_loss, val_mock_map = evaluate(model, val_loader, criterion, device)
print(f"Epoch [{epoch+1:2d}/{num_epochs}] "
f"| Train Loss: {train_avg_loss:.4f} "
f"| Val Loss: {val_avg_loss:.4f} "
f"| Val mAP(mock): {val_mock_map:.4f}")
torch.save(model.state_dict(), os.path.join(save_dir, "last.pt"))
if val_mock_map > best_mock_map:
best_mock_map = val_mock_map
print(f" --> 更优模型 (mAP: {best_mock_map:.4f}),已保存至 best.pt")
torch.save(model.state_dict(), os.path.join(save_dir, "best.pt"))
注意 targets = \[t.to(device) for t in targets\] 这一行------因为 targets 是一个列表(包含三个张量),不能直接 .to(device),必须逐个转移。
在 train_model() 中加入了一段维度验证代码 ------ 这是良好的工程实践:
python
# 张量维度验证
dummy = torch.randn(1, 3, img_size, img_size).to(device)
outs = model(dummy)
print("各尺度输出形状:")
for i, o in enumerate(outs):
stride = img_size // o.shape[-1]
print(f" P{i+3}: {list(o.shape)} (stride={stride})")
这段代码用一个虚拟输入跑一次前向传播,打印出每个尺度的输出形状和步长。如果某一层的通道数或空间尺寸不对,这里立刻就能发现。运行后你会看到,各尺度输出形状:
P3: 1, 10, 32, 32 (stride &= 8)
P4: 1, 10, 16, 16 (stride &= 16)
P5: 1, 10, 8, 8 (stride &= 32)
如果数字和预期不符,说明模型定义有误,需要回头检查。
代码中同时输出了新版本模型的参数量:
python
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
p.numel() 返回一个参数张量中的元素总数,对所有参数求和即为总参数量。本卷的 MultiscaleDetector 有21万多个参数------比第1卷的 SimpleDetector 多了不少,但和真正的 YOLO(几百万参数)相比仍然微不足道。
总结
第二卷到这里基本就结束了,让我们回顾本卷走过的路:
1、理论收获
-
计算图(DAG)------
PyTorch在前向传播时默默记录每一步运算,形成一张有向无环图。 -
反向传播(Backpropagation)------
loss.backward()沿计算图逆流而上,通过链式法则计算每个参数的梯度。 -
梯度下降------
optimizer.step()让每个参数沿梯度反方向迈出一步,逐步缩小 Loss。 -
张量维度 `B, C, H, W`------Batch × 通道 × 高 × 宽,深度学习数据的标准格式。
-
FPN 动机------大小目标需要不同尺度的特征图,FPN 让高层语义自顶向下流动。
2、代码收获
对比第1卷的改动清单:
| 改动点 | 第1章 | 第2章 |
|---|---|---|
| Backbone | 3层下采样 | 5层下采样(P3/P4/P5) |
| Neck | 无 | FPN(上采样 + Concat + Conv) |
| 检测头 | 1个 head | 3个 head(head3/4/5) |
| 标签 | 单张量 `C, H, W` | 列表 `(C,32,32), (C,16,16), (C,8,8)` |
| collate_fn | 默认 | 自定义(按尺度分别 stack) |
| Loss | 单次 MSE | 3次 MSE 求和 |
| 返回值 | 单张量 | 列表 `out3, out4, out5` |
前瞻:第3卷的挑战
本卷的 MultiscaleDetector 已经具备了 FPN 的基本结构,和第1卷相比,它已经像样了很多。
但一个"象样了很多的检测网络",距离一个"成熟的工业级检测网络",中间还有很长的路。比如:
网络继续加深以后,梯度会不会越来越难传?2.1.4节的关键洞察部分就提到了这个问题。
网络中的卷积层应该怎样组织?
有没有比简单堆叠 Conv2d 更合理的特征提取结构?
从下一卷开始,我们不再完全闭门造车。在已经掌握目标检测网络基本骨架之后,我们会选择一个成熟且广泛应用的开源旋转目标检测模型------YOLO11 OBB------作为后续持续升级的参考蓝图。
第3卷,我们将引入 YOLO 的核心算子------Conv + BatchNorm + SiLU------来缓解"梯度消失"的问题。此外,我们还会引入 C3k2(带残差连接的模块)和 SPPF(空间金字塔池化)------它们为追责令开辟了"高速公路"和"全局视野窗口",是模型性能的关键所在。
*烧火棍已升级为初级法器。下一步,我们要为它注入真正的仙家底蕴------Conv+BN+SiLU 铁三角。
且看第3卷:移花接木 ── YOLO 核心算子锻造。