万物 | 炼器 从零手搓工业级旋转目标检测网络 · 卷2 —— 计算图、梯度与反向传播(五)

万物 | 炼器 从零手搓工业级旋转目标检测网络.卷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.backboneself.head,而是把检测模型的每一层都"摊开"写出来了。但是总体上,他们还是可以划分为 BackboneNeckHead 三个部分。

我们也按照BackboneNeckHead三个部分来说明 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方法中,我们会在down3down4down5三个位置"截胡",用临时变量把中间特征图 P3、P4、P5 保存下来。

2、FPN Neck:自顶向下融合

检测模型的结构中多了一个Neck颈部,之所以称其为模型的"颈部",是因为模型的Backbone像身体,负责从图像里提取不同层级的特征;Head像头,负责做最终预测并输出结果;在BackboneHead中间的部分,通常实现多层特征整理、融合并将处理后的结果传给头部,所以就形象的管这个部分叫"颈部",也就是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 核心算子锻造

相关推荐
湘美书院--湘美谈教育1 小时前
湘美书院随笔:AI时代的生活经济学
大数据·人工智能·安全·自动化·生活
桃西西呀1 小时前
文件监控 Agent 为什么总在关键时刻掉链子
人工智能·llm·agent
lucas_AI1 小时前
微软给 AI 立规矩:不许反抗关机、不许自己加戏、不许装成「人」
人工智能
Joy T1 小时前
Spring AI 2.0 进阶入门:Workflow、Routing、Task State 与可控 Agent
开发语言·人工智能·workflow·routing·springai·orchestrator·evaluator
YangYang9YangYan1 小时前
2026 校招市场数据分析 JD 拆解,SQL 要求、工具与面试考点
数据库·人工智能·数据分析
myaifas1 小时前
智能体可视化设计用哪家好
人工智能·ai·ai编程
AI 编程助手GPT1 小时前
Python 备份 SQLite:为什么复制了 .db,恢复后还是少数据?
人工智能·python·ai·chatgpt
AiNightVision1 小时前
AI-ISP微光全彩夜视技术深度解析:如何在0.001Lux下实现全彩成像
人工智能·计算机视觉·车载系统·自动驾驶·无人机·智能家居·智能硬件
AI程序员1 小时前
多开几个 Agent,为什么反而更难把活干好?---- 从 Claude Code、Codex 到 DeepSeek Harness,拆解多 Agent 的收益、成本与运行机制。
人工智能