特征金字塔Transformer(FPT)复现:多方向全局注意力融合实战

发布时间:2026/10/31 11:44:31
特征金字塔Transformer(FPT)复现:多方向全局注意力融合实战

特征金字塔这个概念,做视觉检测的朋友应该都不陌生。早年大家用图像金字塔硬怼多尺度,算力吃得厉害;后来FPN出来,用一层层下采样加横向连接的方式把语义和分辨率捏在一起,算是给多尺度检测定了个基调。但FPN有个老毛病一直被人念叨——它本质上还是单向的、局部的融合,高层语义往下传的时候,低层那些细粒度的位置信息其实是被稀释掉的。ECCV 2020上有个工作叫FPT,全称Feature Pyramid Transformer,思路挺直接:既然Transformer在序列建模上这么能打,那能不能把特征金字塔的融合也做成一种"注意力式的全局交互"?我最近把这篇的代码翻出来跑了一遍,也踩了不少坑,今天就把这套东西从设计思路到实操落地完整捋一遍,顺便聊聊它到底解决了什么问题、在什么场景下值得用、以及复现时那些文档里不会写的细节。

这篇文章适合谁看?如果你做过检测、分割这类密集预测任务,对FPN、PANet、BiFPN这些结构有基本认知,想搞清楚Transformer怎么塞进金字塔融合里,那这篇能给你省不少时间。如果你只是想找个即插即用的模块提升mAP,我也会在实操部分把接入方式和调参经验讲清楚。核心关键词就三个:多方向融合、特征金字塔、全局注意力,后面所有内容都围绕它们展开。

1. 先搞清楚FPT到底在解决什么问题

1.1 传统特征金字塔融合的三个硬伤

要理解FPT为什么这么设计,得先把FPN系那套东西的局限掰开看。我总结下来是三个层面的问题,这也是我在实际项目里反复被坑的地方。

第一个是感受野的局部性。FPN的横向连接本质上是1x1卷积做通道对齐,然后逐元素相加,融合范围就局限在相邻两层之间。一个高层特征图上的像素,它融合的低层信息只来自空间上对应的那一小块区域。可现实里一个"车"的语义可能需要结合它周围"路""人"的上下文才能判断准确,这种跨区域的依赖,局部融合根本抓不到。

第二个是语义与分辨率的矛盾。高层特征语义强但分辨率低,低层特征分辨率高但语义弱。FPN用上采样把高层拉大再相加,这个过程中上采样的插值会引入模糊,而且高层那些强语义在往下传的时候,每经过一层就被稀释一次。我做小目标检测的时候特别明显,最底层的高分辨率特征拿到的语义信息已经所剩无几,导致小目标召回上不去。

第三个是融合方向的单一性。标准FPN是自顶向下的单向传递,后来PANet加了自底向上的一条路,BiFPN做了加权双向,但说到底还是在"相邻层、固定方向"这个框架里打转。层与层之间的交互是预先定义死的,网络没法自己学"我这一层到底该多看哪一层、看哪个位置"。

这里插一句我的理解:FPN的融合像是一条流水线,信息按固定工序往下走;而FPT想做的更像是一个会议室,每一层都能直接跟其他所有层对话,谁跟谁相关由注意力自己决定。

1.2 FPT的核心命题:把融合变成全局注意力

FPT的出发点就是把上面这三个问题一次性解决。它的核心命题可以概括成一句话:用Transformer的自注意力机制,在特征金字塔的层内和层间同时做全局的信息交互。

具体来说,它设计了三种不同的"融合方向",对应三种Transformer模块:

  • SFT(Self Feature Transformer):层内自融合,在同一层特征图内部做全局注意力,让每个位置都能感知到同层其他所有位置的信息,解决感受野局部性问题。
  • GFT(Ground Feature Transformer):自底向上的融合,让高层特征去"查询"低层特征,把低层的高分辨率细节拉上来,缓解语义稀释。
  • TFT(Top Feature Transformer):自顶向下的融合,让低层特征去"查询"高层特征,把高层的强语义注入到低层。

这三个模块拼起来,就构成了一个"多方向融合"的完整金字塔。你会发现,它跟FPN最大的区别在于:融合不再是固定的相加,而是通过Query-Key-Value的注意力计算,让网络自己学权重、学对应关系。这就是标题里"多方向融合"的真正含义——不是简单的双向,而是层内、向上、向下三个维度的全局交互。

1.3 为什么是Transformer而不是别的

你可能会问,全局建模又不是只有Transformer能做,非局部模块(Non-local)、空洞卷积、甚至大核卷积都能扩大感受野,为什么非得用Transformer?

我自己的理解是两点。第一,注意力的加权方式是内容自适应的。Non-local其实也是注意力,但FPT把它做成了标准Transformer的QKV形式,并且针对金字塔的层间融合做了专门改造——比如GFT里Query来自高层、Key/Value来自低层,这种"跨层查询"的设计是卷积和非局部模块很难自然表达的。第二,计算复杂度的可控性。全图自注意力是O(N²),高分辨率特征图上根本跑不动。FPT在SFT里用了空间降采样来压缩Key/Value的数量,在GFT/TFT里也是让Query保持原分辨率、Key/Value降采样,这样复杂度就压下来了。这个设计取舍很关键,后面实操部分我会详细讲它怎么影响显存和速度。

2. 三个Transformer模块的细节拆解

2.1 SFT:层内全局建模怎么省算力

SFT是三个模块里最基础的一个,作用是在单层特征图内部做全局上下文聚合。它的结构其实很标准:输入特征图经过三个1x1卷积分别得到Q、K、V,然后做标准的缩放点积注意力。

但问题来了,如果特征图是HxW,那Q、K、V都是HxW个token,注意力矩阵就是(HxW)²,一张80x80的P3特征图就是6400个token,矩阵规模四千多万,显存直接爆炸。FPT的处理方式是对K和V做空间降采样——用步长卷积把K、V的空间尺寸缩小到原来的1/s(论文里s通常取2或4),Q保持原分辨率。这样注意力矩阵就变成(HxW)x(HW/s²),复杂度降了一个数量级。

这个设计背后的逻辑我得说清楚:Query代表"我要查询什么",必须保留每个位置的精度;Key/Value代表"我能提供什么信息",做一定程度的聚合不会损失太多,反而起到了类似池化的平滑作用。这跟很多高效注意力工作的思路是一致的,但FPT把它用在了金字塔融合的场景里,算是恰到好处。

实操上有个细节要注意:降采样用的卷积核大小和步长要匹配,我一开始随手写了个kernel=3、stride=2,结果输出尺寸对不上,后来改成kernel=2、stride=2或者kernel=3、stride=2加padding才对齐。这种低级错误在复现时特别容易犯,建议先把每层的输入输出尺寸在纸上画一遍。

2.2 GFT:自底向上的跨层查询

GFT解决的是"高层语义强但细节丢"的问题。它的做法是:Query来自高层特征,Key和Value来自低层特征。这样高层每个位置都在问低层:"你那边哪些位置的信息对我有用?"然后把低层对应位置的信息加权聚合上来。

这里有个空间对齐的坑。高层特征分辨率低,低层分辨率高,做注意力之前必须把两者的空间尺寸对齐。FPT的做法是把低层的K/V降采样到和高层Query一样的尺寸,或者把Query上采样。我实测下来,把K/V降采样更省算力,而且效果不差。具体实现时,低层特征先过一个步长卷积把尺寸压到高层的大小,再做QKV计算。

GFT的价值在小目标上体现得最明显。我拿一个遥感图像的小目标检测任务试过,加了GFT之后,最底层特征图上的小目标响应明显变强了,因为高层那些"这是辆车"的语义通过注意力直接注入到了低层的高分辨率位置上,不再需要经过FPN那种逐层稀释的传递。

2.3 TFT:自顶向下的语义注入

TFT跟GFT正好反过来:Query来自低层,Key/Value来自高层。低层每个位置去问高层:"我这个地方应该是什么语义?"然后把高层的语义信息聚合下来。

这个模块解决的是低层特征"有位置没语义"的问题。在分割任务里特别有用,因为分割要求每个像素都有准确的类别,低层特征光有边缘和纹理不够,得有高层告诉它"这块纹理属于哪个物体"。

TFT和GFT合起来,就形成了一个双向的跨层交互。但跟PANet那种简单的双向相加不同,FPT的双向是注意力驱动的,每一层、每个位置拿到的融合权重都是动态算出来的。这也是它比BiFPN这类加权融合更进一步的地方——BiFPN的权重是层级的标量,FPT的权重是位置级、内容级的。

2.4 三个模块怎么串起来

FPT的整体结构是:先把骨干网络输出的多层特征各自过一遍SFT做层内增强,然后并行的GFT和TFT做跨层交互,最后把三路结果融合输出。这里有个设计选择值得说:GFT和TFT是并行还是串行?论文里是并行然后相加/拼接,我试过串行(先GFT再TFT),效果差别不大但串行会慢一些,因为多了一次完整的注意力计算。所以复现时建议按论文的并行结构来。

模块Query来源Key/Value来源解决的问题计算特点
SFT本层本层(降采样)层内感受野局部复杂度O(N·N/s²)
GFT高层低层(降采样)高层细节丢失跨层对齐后计算
TFT低层高层低层语义不足跨层对齐后计算

这张表建议复现前先记牢,因为三个模块的QKV来源搞混是新手最容易犯的错,一旦搞反,模型不但不涨点还可能掉点。

3. 动手复现:从环境到跑通

3.1 环境准备与依赖

我这套代码是在PyTorch 1.9 + CUDA 11.1上跑的,显卡用的单卡24G。FPT本身不依赖什么特殊库,核心就是torch和torchvision。如果你要用现成的检测框架接入,MMDetection或者Detectron2都行,我选的是自己搭一个轻量训练脚本,方便调试每个模块。

依赖清单大概是这样:

torch==1.9.0 torchvision==0.10.0 numpy opencv-python tqdm

显存方面要提前算一笔账。假设骨干用ResNet50,输入800x800,P3到P7五层特征,SFT在每层都做,GFT/TFT在相邻层之间做。最吃显存的是P3那层的SFT,因为分辨率最高。我实测batch size=2的时候显存占用大概18G,batch size=4就OOM了。如果你的卡小,建议先把SFT的降采样率调大(s=4),或者只在P4以上做SFT,P3跳过。

3.2 SFT模块的代码实现

先看SFT的核心代码,我把关键注释都写进去了:

import torch import torch.nn as nn import torch.nn.functional as F class SFT(nn.Module): def __init__(self, channels, reduction=2): super().__init__() self.reduction = reduction # Q、K、V的投影 self.q_conv = nn.Conv2d(channels, channels, 1) self.k_conv = nn.Conv2d(channels, channels, 1) self.v_conv = nn.Conv2d(channels, channels, 1) # K/V的空间降采样 self.kv_down = nn.Conv2d(channels, channels, kernel_size=reduction, stride=reduction) self.out_conv = nn.Conv2d(channels, channels, 1) self.norm = nn.GroupNorm(32, channels) def forward(self, x): b, c, h, w = x.shape q = self.q_conv(x).flatten(2).transpose(1, 2) # B, N, C k = self.kv_down(self.k_conv(x)).flatten(2).transpose(1, 2) v = self.kv_down(self.v_conv(x)).flatten(2).transpose(1, 2) # 缩放点积注意力 attn = torch.bmm(q, k.transpose(1, 2)) / (c ** 0.5) attn = F.softmax(attn, dim=-1) out = torch.bmm(attn, v) # B, N, C out = out.transpose(1, 2).reshape(b, c, h, w) out = self.out_conv(out) return self.norm(out + x) # 残差连接

这段代码有几个点值得展开。第一,kv_down用的是kernel和stride都等于reduction的卷积,这样输出尺寸正好是输入的1/reduction,不会出现尺寸对不齐的问题。第二,注意力缩放用的是c ** 0.5,这里c是通道数,标准Transformer用的是head_dim的平方根,单头情况下就是通道数,没问题。第三,残差连接加GroupNorm,这个组合在视觉Transformer里很常见,能稳住训练。

注意:flatten(2)之后是(B, C, N)的形状,要transpose(1,2)变成(B, N, C)才能做bmm。我第一次写的时候忘了转置,报了个维度不匹配的错,查了半天。

3.3 GFT和TFT的实现要点

GFT和TFT的结构类似,区别只在QKV的来源。我把它俩写成一个通用类,用参数控制方向:

class CrossLayerTransformer(nn.Module): def __init__(self, channels, direction='up', reduction=2): super().__init__() self.direction = direction self.q_conv = nn.Conv2d(channels, channels, 1) self.k_conv = nn.Conv2d(channels, channels, 1) self.v_conv = nn.Conv2d(channels, channels, 1) self.out_conv = nn.Conv2d(channels, channels, 1) self.norm = nn.GroupNorm(32, channels) self.reduction = reduction def forward(self, low_feat, high_feat): # 统一到high_feat的尺寸做注意力 if self.direction == 'up': # GFT: Q来自高层 q_src, kv_src = high_feat, low_feat else: # TFT: Q来自低层 q_src, kv_src = low_feat, high_feat # 把kv_src对齐到q_src的尺寸 kv_src = F.interpolate(kv_src, size=q_src.shape[-2:], mode='bilinear', align_corners=False) b, c, h, w = q_src.shape q = self.q_conv(q_src).flatten(2).transpose(1, 2) k = self.k_conv(kv_src).flatten(2).transpose(1, 2) v = self.v_conv(kv_src).flatten(2).transpose(1, 2) attn = torch.bmm(q, k.transpose(1, 2)) / (c ** 0.5) attn = F.softmax(attn, dim=-1) out = torch.bmm(attn, v).transpose(1, 2).reshape(b, c, h, w) out = self.out_conv(out) # 输出对齐回原层尺寸 if self.direction == 'up': return self.norm(out + high_feat) else: out = F.interpolate(out, size=low_feat.shape[-2:], mode='bilinear', align_corners=False) return self.norm(out + low_feat)

这里的关键是尺寸对齐。GFT里Q来自高层(小尺寸),K/V来自低层(大尺寸),我先把低层插值到高层尺寸再做注意力,算完直接残差加回高层。TFT里Q来自低层(大尺寸),K/V来自高层(小尺寸),注意力算完得到的是低层尺寸的输出,直接残差加回低层。注意TFT里如果Q和K/V尺寸不一致,也要先对齐,我这里为了代码简洁统一用interpolate处理,实际部署时可以考虑用步长卷积降采样,速度更快。

3.4 整体金字塔的组装

把三个模块组装成完整的FPT,逻辑是:对每一层先做SFT,然后相邻层之间做GFT和TFT,最后把三路输出融合。融合方式论文用的是相加,我试过concat+1x1卷积,涨点不明显但参数多了,所以还是推荐相加。

class FPT(nn.Module): def __init__(self, channels=256, num_levels=5): super().__init__() self.sfts = nn.ModuleList([SFT(channels) for _ in range(num_levels)]) self.gfts = nn.ModuleList([CrossLayerTransformer(channels, 'up') for _ in range(num_levels - 1)]) self.tfts = nn.ModuleList([CrossLayerTransformer(channels, 'down') for _ in range(num_levels - 1)]) self.fuse_convs = nn.ModuleList([nn.Conv2d(channels, channels, 1) for _ in range(num_levels)]) def forward(self, feats): # feats: list of [P3, P4, P5, P6, P7] sft_feats = [sft(f) for sft, f in zip(self.sfts, feats)] out = [] for i in range(len(feats)): cur = sft_feats[i] # 自底向上:低层i+1的信息给高层i if i < len(feats) - 1: cur = cur + self.gfts[i](sft_feats[i+1], sft_feats[i]) # 自顶向下:高层i-1的信息给低层i if i > 0: cur = cur + self.tfts[i-1](sft_feats[i], sft_feats[i-1]) out.append(self.fuse_convs[i](cur)) return out

这段组装代码我调了好几版才跑通。最开始GFT的参数顺序搞反了,把高层当成了低层传进去,结果尺寸对不上直接报错。后来理清楚了:GFT是"低层信息往高层走",所以第一个参数是低层、第二个是高层,函数内部再根据direction决定谁是Q。建议你复现时也先把数据流向在纸上画一遍,比debug快得多。

4. 训练调参与踩坑实录

4.1 学习率和初始化怎么设

FPT里全是注意力模块,训练稳定性比纯卷积结构要敏感。我一开始直接沿用骨干的学习率(0.02),结果loss前几百步震荡得厉害,后来把FPT部分的学习率降到骨干的十分之一(0.002),配合warmup才稳住。

初始化方面,注意力里的QKV投影建议用较小的初始化方差,我用的trunc_normal_(std=0.02),跟ViT那套一致。输出卷积和GroupNorm用默认初始化就行。还有个细节:残差连接的那个分支如果初始化太大,早期训练容易把原始特征冲掉,所以out_conv的权重我特意乘了个0.1的缩放,实测收敛更平滑。

4.2 显存和速度的权衡

前面提过P3层SFT最吃显存。我做了个对比实验,在800x800输入、ResNet50骨干下测了不同配置的显存和FPS:

配置显存占用前向FPSmAP变化
基线FPN12G28基准
FPT全开(s=2)18G16+2.1
FPT全开(s=4)15G21+1.8
仅P4以上做SFT14G23+1.6

从这张表能看出来,降采样率从2调到4,显存省了3G、速度快了30%,mAP只掉了0.3,性价比很高。如果卡紧张,我建议直接上s=4,或者P3层跳过SFT只做跨层融合,这样能在可接受的精度损失下把模型跑起来。

这里有个反直觉的点:我原以为SFT是涨点主力,结果消融发现GFT和TFT的贡献加起来比SFT还大。所以如果资源实在有限,优先保跨层融合,SFT可以砍。

4.3 常见问题速查

复现过程中踩的坑我整理成表,方便你对照排查:

现象可能原因解决办法
训练loss震荡不收敛FPT学习率过高降到骨干的1/10,加warmup
显存OOMP3层SFT分辨率太高降采样率调大或跳过P3的SFT
尺寸不匹配报错QKV来源搞反核对GFT/TFT的Q来源方向
涨点不明显融合方式或初始化问题检查残差缩放,试concat融合
推理速度太慢全图注意力token太多增大K/V降采样率,或用局部窗口

还有一个我踩过的坑:在TFT里做interpolate对齐时,align_corners参数如果设成True,小尺寸特征上采样会有轻微的位置偏移,检测框会整体偏一点。统一设成False就正常了。这种细节文档里不会写,但实际影响不小。

4.4 什么场景值得上FPT

最后聊聊适用性。FPT不是万金油,它的优势场景很明确:需要强全局上下文、且对多尺度敏感的任务。比如小目标密集的遥感检测、需要像素级语义的分割、以及场景理解这类要结合大范围上下文的活。我在一个密集小目标的数据集上实测,FPT比BiFPN的mAP高了将近2个点,主要涨在小目标类别上。

但如果是实时性要求高的场景,比如移动端或者高帧率视频检测,FPT的注意力开销就不划算了,这时候BiFPN或者更轻的融合结构更合适。另外如果你的数据里目标尺度比较单一、上下文依赖不强,FPT的收益也会打折扣。我的经验是:先跑基线,看小目标和遮挡目标的召回是不是瓶颈,如果是,再考虑上FPT,别一上来就堆模块。

这套东西我前后折腾了大概两周,从读论文到跑通再到调出稳定涨点,中间返工了好几次。最大的体会是:Transformer类模块接进检测框架,难点从来不在模块本身,而在尺寸对齐、学习率匹配和显存控制这些工程细节上。把这几块理顺了,剩下的就是按部就班地消融验证。如果你也在做多尺度融合相关的活,FPT这套多方向注意力的思路值得花时间吃透,哪怕最后不用它,它对"融合到底该怎么设计"这个问题的回答,也能给你不少启发。