小物体为何在深层特征里消失?FPN 的自顶向下路径与横向连接
从深层语义与浅层定位的冲突出发,手算特征金字塔融合,追踪张量形状,并用 PyTorch 2.13 与 torchvision 0.26 实现可调试 FPN。
上一篇看到,下采样让卷积网络用更低成本获得更大感受野,却可能在深层特征中抹掉小物体和精确位置。只保留浅层也不行:浅层虽然网格细,却还在响应边缘、纹理等局部模式,未必知道这些模式属于“行人”还是“路灯”。
本文只解决这个冲突:特征金字塔网络(Feature Pyramid Network, FPN)怎样用自顶向下路径(Top-down Pathway)把深层语义送回高分辨率,用横向连接(Lateral Connection)补回对应位置的浅层细节,并让每一层都得到通道数统一、可供下游预测的特征。
01 为什么直接选择某一层总会吃亏?#
设骨干网络(Backbone)每经过一次步幅 2 下采样,就输出一个阶段特征。以输入 [N,3,256,256] 为例:
| 阶段 | 相对输入步幅 | 张量形状示例 | 优势 | 缺口 |
|---|---|---|---|---|
| C2 | 4 | [N,256,64,64] | 网格细、定位较精确 | 语义弱、背景响应多 |
| C3 | 8 | [N,512,32,32] | 开始组合局部部件 | 小目标仍容易混入背景 |
| C4 | 16 | [N,1024,16,16] | 语义与位置较折中 | 极小目标只剩少数单元 |
| C5 | 32 | [N,2048,8,8] | 感受野大、类别语义强 | 空间粗,细小结构可能消失 |
若一个行人在原图中高 12 像素,那么在步幅 4 的 C2 上约覆盖 3 个单元,在步幅 32 的 C5 上只覆盖 个单元。C5 可能知道“这里像人”,却难以给出边界;C2 看见几条边缘,却未必知道它们应组成什么。
传统图像金字塔(Image Pyramid)会把原图缩放成多种尺寸,再分别运行特征提取器:
图像 256² ── Backbone ──► 强语义特征(高计算)
图像 128² ── Backbone ──► 强语义特征(再算一次)
图像 64² ── Backbone ──► 强语义特征(再算一次)textFPN 不重复跑整套骨干网络,而是复用骨干网络已经自然形成的多尺度层次。
02 FPN 的两条信息流怎样相遇?#
FPN 有三类操作:1×1 横向投影、上采样与逐元素相加、3×3 输出卷积。
自底向上的骨干网络 自顶向下路径 输出金字塔
C2 [N, 256,64,64] ──1×1──► L2 ──(+)◄── up(M3) ──► M2 ──3×3──► P2 [N,D,64,64]
│ ▲
▼ │
C3 [N, 512,32,32] ──1×1──► L3 ──(+)◄── up(M4) ──► M3 ──3×3──► P3 [N,D,32,32]
│ ▲
▼ │
C4 [N,1024,16,16] ──1×1──► L4 ──(+)◄── up(M5) ──► M4 ──3×3──► P4 [N,D,16,16]
│ ▲
▼ │
C5 [N,2048, 8, 8] ──1×1───────────────► M5 ──3×3──► P5 [N,D, 8, 8]
骨干:分辨率逐层降低、语义增强 FPN:深层语义逐层回流 D 通常固定为 256text记 为骨干第 层, 为 FPN 的统一通道数。横向投影 是 1×1 卷积:
最深层先取 ;其余层递推为:
最后用 3×3 卷积 得到输出:
横向路径把“这个位置原来有什么”交给融合点;自顶向下路径把“更大区域整体像什么”交给同一个融合点。二者必须空间尺寸和通道数都一致,才能逐元素相加。
03 1×1 卷积为什么不是可有可无?#
C2 到 C5 的通道数常为 256、512、1024、2048,不能直接相加。1×1 卷积在每个空间位置独立完成通道线性变换:
其中 ,输入位置 [n,:,i,j] 的 维向量被投影为 维,不改变 。它有两个作用:
- 把不同阶段统一到同一通道维度 ,使加法合法;
- 学习每个阶段哪些通道应该进入同一融合坐标系,而不是机械截断或补零。
04 用五个数手算一次自顶向下融合#
先把通道数简化为 。设最深层投影后只有一个空间值:
上一层的横向投影为:
最近邻上采样(Nearest-neighbor Upsampling)把 的值复制到目标尺寸:
逐元素相加得到:
这里的 3 是深层上下文,告诉所有四个细位置“这一片区域整体具有某种语义”; 仍区分四个位置的局部证据。相加不会增加空间信息:若小物体在 C4 之前已经完全消失,FPN 不能从常量上采样中把它凭空重建出来。
随后 3×3 卷积在 上融合邻域并生成 。它既让每个输出位置看到相邻融合结果,也减弱最近邻复制形成的块状边界;它不是第二次跨尺度融合。
05 为什么用相加,而不是拼接?#
设两个输入都是 [N,D,H,W]:
| 融合方式 | 融合后形状 | 后续参数/显存 | 信息路径 |
|---|---|---|---|
| 相加 | [N,D,H,W] | 较低 | 两路落在同一通道,梯度直接分流 |
| 拼接 | [N,2D,H,W] | 后续卷积约更昂贵 | 保留两路身份,再由卷积决定如何混 |
| 加权和 | [N,D,H,W] | 多少量融合权重 | 可学习各尺度贡献,但需约束稳定性 |
原始 FPN 选择相加,计算简单且所有金字塔输出可共享同样的下游头。它隐含一个假设:横向 1×1 投影已把两路特征变换到可相加的表示空间。若任务需要保留来源身份,拼接或显式加权可能更合适,但那已经是另一种融合设计。
反向传播时,若 ,则:
同一上游梯度分别进入横向路径和更深层路径;更深的 还会接收多个较浅输出经上采样链汇集的梯度。
06 完整前向伪代码#
输入:按分辨率从高到低排列的 C2...CL
for l = 2...L:
lateral[l] = Conv1x1_l(C[l]) # [N,Cl,Hl,Wl] -> [N,D,Hl,Wl]
inner[L] = lateral[L]
output[L] = Conv3x3_L(inner[L])
for l = L-1...2: # 从深到浅
top_down = Resize(inner[l+1], size=(Hl,Wl))
assert top_down.shape == lateral[l].shape
inner[l] = lateral[l] + top_down
output[l] = Conv3x3_l(inner[l])
返回:P2...PL,顺序仍为高分辨率到低分辨率text注意上采样的是尚未经过输出 3×3 卷积的 inner[l+1],而不是随手取 P[l+1]。两种写法形状相同,计算图和参数语义却不同。
07 不调用 FPN 封装,先写出 PyTorch 本体#
下面实现接收一个有序字典,特征必须按“最高分辨率、最浅阶段”到“最低分辨率、最深阶段”排列:
from collections import OrderedDict
import torch
import torch.nn.functional as F
from torch import nn
class TinyFPN(nn.Module):
def __init__(self, in_channels: list[int], out_channels: int):
super().__init__()
assert len(in_channels) >= 2 and out_channels > 0
self.in_channels = in_channels
self.lateral = nn.ModuleList([
nn.Conv2d(c, out_channels, kernel_size=1)
for c in in_channels
])
self.output = nn.ModuleList([
nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
for _ in in_channels
])
def forward(
self,
features: "OrderedDict[str, torch.Tensor]",
) -> "OrderedDict[str, torch.Tensor]":
names = list(features.keys())
xs = list(features.values())
assert len(xs) == len(self.in_channels)
for i, (x, expected_c) in enumerate(zip(xs, self.in_channels)):
assert x.ndim == 4 and x.shape[1] == expected_c
if i > 0:
assert x.shape[-2] <= xs[i - 1].shape[-2]
assert x.shape[-1] <= xs[i - 1].shape[-1]
lateral = [conv(x) for conv, x in zip(self.lateral, xs)]
results: list[torch.Tensor | None] = [None] * len(xs)
inner = lateral[-1]
results[-1] = self.output[-1](inner)
for i in range(len(xs) - 2, -1, -1):
top_down = F.interpolate(
inner,
size=lateral[i].shape[-2:],
mode="nearest",
)
assert top_down.shape == lateral[i].shape
inner = lateral[i] + top_down
results[i] = self.output[i](inner)
assert all(result is not None for result in results)
return OrderedDict(zip(names, results)) # type: ignore[arg-type]
features = OrderedDict([
("c3", torch.randn(2, 64, 31, 33)),
("c4", torch.randn(2, 128, 16, 17)),
("c5", torch.randn(2, 256, 8, 9)),
])
fpn = TinyFPN([64, 128, 256], out_channels=96)
pyramid = fpn(features)
assert pyramid["c3"].shape == (2, 96, 31, 33)
assert pyramid["c4"].shape == (2, 96, 16, 17)
assert pyramid["c5"].shape == (2, 96, 8, 9)python示例故意使用奇数尺寸。若写成 scale_factor=2,[8,9] 只能先变成 [16,18],无法与 [16,17] 相加;指定 size=lateral.shape[-2:] 才以横向分支为对齐真值。
mode="nearest" 不会创造插值混合值,且与经典 FPN 的简单设计一致。若改为双线性插值,要显式理解 align_corners 的几何语义,并对边界框、掩码或关键点做像素对齐测试。PyTorch 2.13 当前官方 interpolate ↗ 接受 4D NCHW 张量,并提醒部分 CUDA 上采样反向可能非确定。
08 训练时每层怎样收到监督?#
FPN 只生成多尺度表示,不定义任务损失。检测、分割或关键点头会消费 。下面用最小二分类稠密头展示梯度数据流;真实任务需要按目标尺寸分配层级并构造合法标签。
import torch
import torch.nn.functional as F
from torch import nn
head = nn.Conv2d(96, 1, kernel_size=1) # 所有 P 层通道相同,可共享头
optimizer = torch.optim.AdamW(
list(fpn.parameters()) + list(head.parameters()),
lr=1e-3,
)
logits = {name: head(feature) for name, feature in pyramid.items()}
targets = {
name: torch.zeros_like(level_logits)
for name, level_logits in logits.items()
}
targets["c3"][:, :, 10:13, 12:15] = 1.0 # 仅作形状完整的玩具标签
loss_by_level = {
name: F.binary_cross_entropy_with_logits(logits[name], targets[name])
for name in logits
}
loss = torch.stack(list(loss_by_level.values())).mean()
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
assert fpn.lateral[0].weight.grad is not None
assert fpn.lateral[-1].weight.grad is not None
assert torch.isfinite(loss)python完整训练流是:
image -> backbone -> {C2...C5} -> FPN -> {P2...P5} -> shared/task heads
│
target -> level assignment -> per-level targets -> losses ┘
│
loss.backward() -> head -> every P level -> lateral + top-down -> backbonetext若把每层损失简单求平均,大尺寸 P2 因位置更多并不一定贡献更多,但正负样本比例、有效掩码和任务损失尺度仍可能严重失衡。必须逐层记录正样本数、损失和梯度范数,而不是只看总损失。
09 用 torchvision 0.26 当前官方 API 落地#
torchvision 当前官方 FeaturePyramidNetwork ↗ 接收按深度递增排列的有序特征字典;也就是浅层高分辨率在前、深层低分辨率在后。in_channels_list 必须与这个顺序逐一对应:
from collections import OrderedDict
import torch
from torchvision.ops import FeaturePyramidNetwork
features = OrderedDict([
("c3", torch.rand(2, 64, 64, 64)),
("c4", torch.rand(2, 128, 32, 32)),
("c5", torch.rand(2, 256, 16, 16)),
])
fpn = FeaturePyramidNetwork(
in_channels_list=[64, 128, 256],
out_channels=96,
extra_blocks=None,
norm_layer=None,
)
outputs = fpn(features)
assert list(outputs) == ["c3", "c4", "c5"]
assert [x.shape for x in outputs.values()] == [
torch.Size([2, 96, 64, 64]),
torch.Size([2, 96, 32, 32]),
torch.Size([2, 96, 16, 16]),
]python当前接口返回的结果按最高分辨率优先排列;extra_blocks 可在标准金字塔之外生成额外层,norm_layer 可指定卷积后的归一化模块。两者都应由下游架构需求驱动,不要为了“更完整”盲目开启。
10 一条最短的 FPN 调试路径#
- 打印每个 C 层。 对每层记录名字、
[N,C,H,W]、相对输入步幅和有限值比例;先确认骨干本身没有错。 - 检查顺序与通道表。
names、实际x.shape[1]与in_channels_list必须逐项一致,空间尺寸应随深度非增。 - 用目标尺寸上采样。 对奇数高宽测试所有融合点,逐次断言
top_down.shape == lateral.shape。 - 分开观察三种张量。 保存
lateral、top_down和相加后的inner;若一条支路幅度大几个数量级,加法会近似只剩强支路。 - 逐层检查梯度。 对每个输出单独反向,记录各横向卷积和骨干阶段的梯度范数,确认深浅路径都能收到信号。
- 画跨尺度一致性。 对同一物体在 P2–P5 上可视化激活热点;热点应随步幅缩放落在对应位置,而不是系统性偏一格。
- 做最小过拟合。 用 2–4 张带单个目标的图训练到接近零损失;做不到时先查标签层级、坐标缩放与有效掩码。
一个实用的支路幅度诊断为:
表示横向分支主导, 表示自顶向下分支主导。理想区间依赖任务;这个比值用于发现异常,不是要求所有层都等于 1。
11 最常见的“形状对了,语义却错了”#
- 层级顺序反了。 通道表若也被错误地一起反转,网络甚至可能正常运行,却把低分辨率当成浅层开始融合。
- 把
scale_factor=2当成尺寸真值。 奇数输入、不同补边和向下取整会累积一像素错位。 - 融合前坐标系不一致。 不同裁剪、补边或
align_corners设置会让两个相同形状的张量代表不同原图位置。 - 误以为上采样恢复细节。 最近邻或双线性只能重排已有深层值;真正的高分辨率信息来自横向连接。
- 直接相加原始 C 层。 通道不匹配会报错;即使通道碰巧相同,也缺少可学习的语义对齐。
- 输出层没有独立卷积。 只返回
inner不是必然错误,但已经偏离经典 FPN;应记录变体,而不是仍声称实现完全相同。 - 每层都复制一个独立预测头。 这增加参数且失去统一表示的好处;是否共享应由任务和消融实验决定。
- 只看总体检测指标。 FPN 的目标与尺度相关,应至少分小、中、大目标或按面积分桶报告性能。
12 它会在哪里失败?#
- 细节在最浅输入层前已经丢失。 若骨干的 stem 过早以大步幅下采样,C2 也没有小目标证据,横向连接无物可补。
- 跨层语义鸿沟太大。 一个
1×1线性投影未必足以让极浅纹理与极深语义直接相加;更强融合也会增加成本和优化难度。 - 定位坐标被预处理破坏。 图像 resize、裁剪、padding 与标签变换稍有不一致,多尺度热点都会系统偏移。
- 高分辨率输出耗尽显存。 P2 的元素数通常是 P5 的 64 倍;提高 或输入尺寸时,激活和预测头成本迅速增长。
- 固定等权相加不适合所有样本。 某些任务需要按尺度、位置或通道自适应选择来源;普通 FPN 没有显式学习融合权重。
- 分布外尺度没有被训练覆盖。 有金字塔不等于天然尺度不变;极端大小目标仍可能落在所有训练层级之外。
13 与相近结构的边界#
| 结构 | 信息路径 | 主要目标 | 与 FPN 的关键区别 |
|---|---|---|---|
| 图像金字塔 | 多尺度原图分别提特征 | 显式覆盖输入尺度 | 重复骨干计算 |
| 残差连接 | 同一阶段的恒等/投影捷径 | 改善深层优化与信息路径 | 不专门构造多尺度输出 |
| U-Net | 编码器浅层拼接到解码器 | 恢复像素级密集输出 | 常用拼接和完整解码,仅末端输出也常见 |
| FPN | 自顶向下 + 横向相加 | 各尺度都获得强语义特征 | 输出多层统一通道金字塔 |
| PANet | 再增加自底向上的路径增强 | 缩短低层定位到高层的路径 | 在 FPN 后继续双向聚合 |
| BiFPN | 重复双向路径并学习融合权重 | 高效、可缩放的多尺度融合 | 删除部分单输入节点并做加权融合 |
FPN 也不是目标检测器本身。它不产生类别、边界框或掩码,不定义目标该去哪个层,也不执行非极大值抑制(Non-maximum Suppression, NMS);它只是让下游头获得语义更一致的多尺度输入。
14 今天真正需要记住什么?#
- 骨干浅层保留高分辨率定位,深层拥有更强语义;只选一层会在小目标细节与上下文之间取舍。
- FPN 用
1×1横向投影统一通道,再把深层特征按目标尺寸上采样并逐元素相加,最后以3×3卷积生成各层输出。 - 与 的空间尺寸相同,但统一为 个通道;上采样传递语义,横向连接提供真实高分辨率证据。
- 工程中最危险的不是明显的通道报错,而是字典顺序、奇数尺寸和坐标约定造成的静默错位。
- FPN 不能恢复已经丢失的信息,也不自动解决层级分配、损失平衡、显存和分布外尺度问题。
15 思考题与小练习#
- 输入
[N=2,3,255,257]的骨干输出分别是C3=[2,64,32,33]、C4=[2,128,16,17]、C5=[2,256,8,9]。令 ,写出每个横向权重、M和P的形状;解释为什么连续两次scale_factor=2不能安全对齐 C3。 - 把手算例中的 改为
[[-2]],重算 。再假设损失对 每个元素的梯度都是 1,写出传给 与上采样结果的梯度;最近邻复制反向后, 的梯度是多少? - 在
TinyFPN中分别单独对pyramid["c3"].mean()、pyramid["c5"].mean()反向,记录三组横向卷积的梯度范数。哪些参数在两次实验中收到梯度?这揭示了自顶向下路径怎样改变监督范围?
相关工作#
- Lin et al. (2017), Feature Pyramid Networks for Object Detection ↗:提出用自顶向下路径和横向连接构造语义一致的多尺度特征。
- Ronneberger, Fischer & Brox (2015), U-Net ↗:以编码器—解码器和高分辨率跳接推动生物医学图像分割。
- Liu et al. (2018), Path Aggregation Network ↗:在 FPN 上增加自底向上路径,缩短定位信息传播距离。
- Tan, Pang & Le (2020), EfficientDet ↗:提出可重复、带学习权重的双向特征金字塔 BiFPN。
- torchvision 0.26: FeaturePyramidNetwork ↗:当前输入顺序、通道参数、额外层与返回顺序的官方接口说明。
16 下一篇预告#
FPN 已让每个尺度都有较强语义,但下游仍要决定“一个 12 像素目标该由 P2 还是 P3 负责”。下一篇将研究目标尺寸如何映射到金字塔层级,解释跨层重复预测为何产生,以及训练标签与推理结果怎样保持同一尺度契约。