☰
递归特征金字塔RFP改进YOLO11,解决小目标漏检
2026/10/17 7:54:16 网站建设 项目流程

做目标检测的同学大多有过这种经历:模型在简单场景下表现还行,一遇到小目标密集、遮挡严重的场景就漏检得一塌糊涂。我这次下手的方向不是检测头,而是YOLO11的Neck——具体做法是用递归特征金字塔RFP,把FPN的输出再次反馈回Backbone,形成闭环,让特征表示在多次迭代里不断精化。这套思路听起来不复杂,但落地时坑不少,效果也有点意思。这篇先记录整体设计和第一版实现,后续再做消融实验的详细对比。

1. 为什么要动Neck:从FPN的局限说起

1.1 多尺度特征融合在YOLO11里的角色

YOLO11作为一阶段检测器,主体结构大致分三块:Backbone负责提特征,Neck负责把不同尺度的特征揉到一起,检测头在融合后的特征图上输出框和类别。Neck在YOLO11里主要干的事,就是把Backbone里不同层级的输出——比如浅层的P3、深层的P5——通过上采样、下采样、拼接或逐元素相加,整合成几组尺度更均衡的特征图。

很多同学会忽略一个细节:Neck不只是在“融合”,它实际上决定了检测头能看到什么。如果Neck传给小目标分支的特征图里缺少足够的语义信息,后面检测头再怎么调参,也很难把遮挡目标和小目标救回来。所以改Neck,性价比往往比改骨干网络更高,至少改动范围可控、训练成本相对低。

YOLO11原版Neck基于PAN-FPN的结构,在自顶向下的路径之外又加了一条自底向上的路径,让浅层细节信息和深层语义信息能双向流动。相比早期YOLO只用FPN,这已经是很大的进步。但双向流动也只发生在“一次前向”内部——信息从输入走到输出,路径上的每个节点只被访问一次。这个限制,才是RFP真正想打破的东西。

1.2 FPN单向传播的隐藏问题

我最初用YOLO11跑一个交通场景数据集时,发现一个规律:模型对30像素以下的小目标特别不友好,尤其是远处被部分遮挡的行人,基本就靠形状猜。原因其实很典型——FPN的融合是从顶层往底层单向传语义信息的,浅层特征图虽然空间分辨率高,细节丰富,但接收到的语义信息有限。举个例子,一个模糊的行人轮廓,在P3层看来可能只是一团纹理,但在P5层因为感受野大、语义抽象度高,能认出是“人”。FPN把P5的信息往下传,但只传了一次,P3层对这个人到底是个“站着的行人”还是“电动车”的概率判断仍然不够强。

还有个更本质的问题:FPN融合后的输出,永远不会再回头修正Backbone的中间特征。如果Backbone在早期阶段提取特征时,就漏掉了某些关键上下文,后面再怎么融合也补不回来。换句话说,整个网络的信息流是开环的——前向计算的误差没有反馈通路。

这就让我想到递归神经网络里反复迭代的状态更新。既然人可以盯着同一张图反复看,越看越确定;那网络为什么不能把特征图送回去,再“看”一次、两次呢?RFP的核心卖点就是这个:把FPN的输出反馈给Backbone,让Backbone在下一轮迭代中带着上一轮的“全局理解”重新提取特征,然后再次送入FPN,反复几次之后,特征自然会被精化。

2. RFP核心思想:把输出喂回去,让特征再“过一遍脑子”

2.1 从循环神经网络到反馈连接:RFP的灵感来源

RFP(Recursive Feature Pyramid)最早在中尺度特征融合的讨论里出现过,核心逻辑其实很朴素:借鉴RNN的隐状态更新机制。RNN在时间步上展开,每个时间步的输入都包含当前时刻观测和上一时刻的隐状态,从而让网络“记住”之前的信息。RFP则把这种思路搬到空间特征上——每次迭代里,模型不仅看原始图像,还看上一轮FPN输出的特征图,当作一种“先验理解”。

这种反馈不是简单地把特征图加回去,而是让Backbone的每一阶段都能感知到“当前对整个图像的解读”。于是下一轮迭代,Backbone提取特征时就不是盲人摸象,而是带着全局视野重新审视细节。这个过程有点像两遍阅读:第一遍先扫整体,第二遍带着重点去看局部,神经网络的“注意力”在迭代中自动调整。

需要注意,RFP与常规的FPN改进(比如BiFPN)有本质区别。BiFPN加的是跨尺度连接,信息仍然只走一遍;而RFP加的是跨时间步连接,同一组特征被反复处理多次。这也是“递归”一词的来源——结构在时间上重复出现,形成闭环。

2.2 整体闭环架构

我用一个简化图来说明RFP在YOLO11里的顺序执行流程:

  • 第一遍前向:原始图像进入Backbone,得到多尺度特征B1;B1送入FPN,得到多尺度金字塔特征F1;
  • 将F1的各个尺度分别经过反馈变换(比如经由RFB模块处理),得到反馈特征R1;
  • 第二遍前向:原始图像再次进入Backbone,但Backbone的每个阶段不再只接收上一阶段的输出,还额外接收R1中对应尺度的反馈。由此得到新的Backbone特征B2,再送入FPN,得到F2;
  • 如果设置迭代次数K=2,则F2直接送给检测头;如果K>2,就重复步骤2-3。

关键在Backbone的每个阶段,反馈特征和当前阶段提取的特征如何合并。我采用的方法是:先把反馈特征调整到和当前特征相同分辨率,做一个1x1卷积把通道数对齐,然后相加。这个设计不会给网络增加太多参数,却能让Backbone“看到”全局上下文。

2.3 反馈连接的关键组件:感受野增强模块RFB

直接原样把FPN输出送回去,效果其实一般,问题出在感受野上。FPN输出的特征图虽然语义强,但局部空间关系并不精细。为了放大反馈信息的作用,我在反馈路径上插入了一个RFB(Receptive Field Block)模块。

RFB的思路借鉴了人类视觉系统的多尺度感受野。它用一组不同膨胀率的空洞卷积并联,每个分支的膨胀率不同(常见比如1、3、5),然后拼接或相加,最终模拟出类似“中心-周围”的感受野结构。在RFP里,RFB的作用是让反馈的特征既保留局部细节,又覆盖更广的上下文范围,这样Backbone下一轮迭代就能在更合理的尺度上修正自己的特征。

一个容易忽视的点是RFB放在哪里。我试过把RFB直接放在FPN输出之后,也试过放在Backbone每个阶段入口处。实测下来,放在反馈路径上、在进入Backbone之前做特征增强,效果更稳。因为这样不会干扰FPN内部结构,改动面最小,也方便后期把RFB换成别的特征增强模块做对比。

3. 在YOLO11上落地RFP:修改步骤与核心代码

3.1 工程准备与代码结构梳理

动手之前先明确一点:这里的YOLO11指的是某开源目标检测代码库中的网络定义,结构上包含CSPStage组成的Backbone和FPN+PAN风格的Neck。项目代码里模型定义文件一般拆成backbone、neck、head三部分。我的改法尽量不破坏原有接口,只新增反馈路径。

环境上我直接用PyTorch 2.x,显卡是单张消费级卡,显存24G。如果你的卡只有12G,建议先把Batch Size降下来,后面会讲原因。

建议先跑通原版YOLO11的推理,确认权重能正常加载,再开始改。这样出了问题能排查是代码问题还是环境问题。改完代码后,先小批量跑一个forward和backward,确保反向传播能通。很多人在这一步就会卡住,因为反馈回路把梯度路径拉长了,中间任何一处resize或concat没对齐都会报错。

3.2 给Backbone加上反馈入口

原版Backbone的forward输入通常是单张图像,输出多尺度特征列表。我需要让它接受一个可选参数feedback_dict,里面包含来自上一轮迭代的反馈特征。

核心改造思路是在代码层面把这些反馈特征列表作为额外输入传入Backbone。每个Stage在运算时,除了接收上一个Stage的输出,还要接收对应尺度的feedback。由于反馈特征和stage内部特征尺寸可能不同,需要先做一个对齐处理。

下面是我在模型定义文件里加的开关性代码,不影响原结构使用时的情况:

class BackboneWithFeedback(nn.Module): def __init__(self, base_backbone): super().__init__() self.base_backbone = base_backbone # 每个需融合反馈的阶段,配一个对齐层(1x1 conv + BN) self.align_layers = nn.ModuleList() for out_ch in base_backbone.out_channels: self.align_layers.append( nn.Sequential( nn.Conv2d(out_ch, out_ch, 1), nn.BatchNorm2d(out_ch), nn.SiLU() ) ) def forward(self, x, feedback=None): features = [] # 假设 base_backbone 内部是一个按顺序执行的 stage 列表 f = x for i, stage in enumerate(self.base_backbone.stages): f = stage(f) if feedback is not None and i < len(feedback): fb = feedback[i] # 反馈特征尺寸可能与 f 不同,需要插值对齐 if fb.shape[-2:] != f.shape[-2:]: fb = F.interpolate(fb, size=f.shape[-2:], mode='bilinear', align_corners=False) fb = self.align_layers[i](fb) f = f + fb features.append(f) return features

注意几个细节:

  • 对齐层用的是1x1卷积加BN,作用是让反馈特征与当前特征分布尽量一致。迭代过程中BN统计量会变化,训练初期尤其容易震荡。
  • 反馈特征的尺寸对齐我选择了插值而不是stride卷积,因为插值不引入额外参数,而且梯度更容易回传。
  • 这里的反馈特征列表顺序要和Backbone输出顺序一致,即从浅到深。

3.3 实现RFB模块与K次迭代闭环

RFB模块实现不复杂,我采用的是多分支空洞卷积并联。每个分支使用相同输入,但空洞率不同,最后拼接减半通道再加残差连接。

class RFB(nn.Module): def __init__(self, in_channels, out_channels, dilation_list=[1, 3, 5]): super().__init__() self.branches = nn.ModuleList() mid = out_channels // len(dilation_list) for d in dilation_list: branch = nn.Sequential( nn.Conv2d(in_channels, mid, 3, padding=d, dilation=d), nn.BatchNorm2d(mid), nn.SiLU(), nn.Conv2d(mid, mid, 3, padding=d, dilation=d), nn.BatchNorm2d(mid), nn.SiLU() ) self.branches.append(branch) self.fuse = nn.Conv2d(out_channels, out_channels, 1) self.shortcut = nn.Conv2d(in_channels, out_channels, 1) if in_channels != out_channels else nn.Identity() def forward(self, x): outs = [branch(x) for branch in self.branches] out = torch.cat(outs, dim=1) out = self.fuse(out) return self.shortcut(x) + out

这里每个分支的mid通道等于out_channels除以分支数。比如输入输出都是256通道,三个分支,每个分支中间是84或85通道,拼接后总通道恰好是256。空洞率用[1,3,5],感受野覆盖范围从接近常规卷积到比较大区域,够用。

接下来是整体检测模型的forward修改。原YOLO11检测模型forward大致是:x经过backbone得到xs,xs经过neck得到outs,outs送入检测头。我改成循环K次的方式:

def forward(self, x): # 第一次前向,无反馈 features = self.backbone(x) fpn_outs = self.neck(features) feedback = None for _ in range(self.num_recursions - 1): # 对FPN输出做RFB增强,作为反馈 feedback = [] for i, fpn_out in enumerate(fpn_outs): rfb_out = self.rfb_modules[i](fpn_out) feedback.append(rfb_out) # 带着反馈再次跑backbone和neck features = self.backbone(x, feedback=feedback) fpn_outs = self.neck(features) return self.detector(fpn_outs)

第一次前向得到的fpn_outs不走检测头,只用来生成反馈。所以整个模型的实际前向次数是K次,K默认我取2。虽然运行时间翻倍,但特征精化带来的收益在目标检测场景下值这个代价。

3.4 训练配置与超参数调整建议

K次迭代直接导致训练显存和计算量成倍增长。我最初的配置是batch size 16,原版跑得流畅,加上RFP后直接OOM。降到8才能跑,显存占用依然很高。后来我用了两项优化:

第一,反馈路径上的RFB模块使用梯度检查点,也就是把中间激活值丢弃,反向传播时重新计算。这能显著降低显存峰值,代价是训练速度慢约15%。第二,将K次迭代中的后K-1次Backbone和Neck插入到torch.utils.checkpoint.checkpoint中,进一步减少激活缓存。

对于学习率,反馈回路放大了梯度方差,直接沿用原版的学习率容易发散。我把初始学习率从0.01降到0.007,同时开启warmup从0.001线性升到0.007,前300个iteration只让反馈层和RFB更新,冻结Backbone主体,等反馈特征稳定后再解冻全部。这个技巧帮我避开了前期BN震荡。

此外,RNN式迭代有个经典问题:梯度在K次循环中相乘,容易爆炸或衰减。我加了全局梯度裁剪,max_norm设为10。如果想省事,也可以在每个反馈连接处加一个可学习的缩放系数,初始化为0.1,让网络自己决定反馈强度。不过我的实验里先用了固定全量相加。

4. 实战中遇到的坑:梯度、显存与收敛问题

4.1 反馈回路带来的梯度放大效应

第一次训练时,loss在前200个iteration就像过山车,甚至出现NaN。排查半天,定位到问题出在反馈连接处的梯度传播。想象一下:特征从Backbone传到FPN,再传回Backbone,再传回FPN,梯度在这个过程中要流经两条长路径。如果两条路径梯度方向相反,就容易相互抵消,导致某些层的梯度接近零;如果方向相同,又会成倍放大,导致更新步长过大。

解决方式分两步。第一步是梯度裁剪,先用max_norm=5强压一下。第二步是在反馈特征相加之前加一个可学习的门控系数,初始设为0。这样训练初期反馈路径几乎没有信号,网络基本等于原版YOLO11,稳定后再让门控系数慢慢增大,把反馈信息逐步引进来。我是用一个带sigmoid的标量实现门控的:

self.gate = nn.Parameter(torch.zeros(1)) ... f = f + self.gate * fb

这招非常管用,后续的收敛明显稳定,也让我确认了问题就是梯度放大而不是代码错误。

4.2 显存消耗激增与我的缓解方案

K=2时,显存大约是原版的2.2倍,而不是简单的2倍。原因是反馈路径上Backbone、Neck、RFB的中间激活都参与梯度计算,被保留在显存里。如果K=3,显存直接接近3倍,我24G的卡只能在batch size 4的情况下跑。

缓解手段我给三个建议:

  • 使用梯度检查点,把后一次迭代的Backbone和Neck包进checkpoint。这在代码上改动很小,显存能降30%左右。
  • 减少反馈路径上的特征图数量。我不需要把FPN的全部输出都反馈回去,只反馈P3、P4、P5这三条主分支,P6或更浅层的部分可以直接砍掉。
  • 如果硬件确实紧张,可以考虑共享权重——K次迭代使用同一组Backbone、Neck权重,而不是各自独立。严格来说,RFP原始做法是带独立权重的循环,但共享权重能大幅减少参数和激活量。首次实验建议共享,先把精度验证通了再解耦。

我第一版就用了共享权重,训练速度接近原版的1.8倍(因为前向次数加倍),精度收益仍然在,没有被共享权重明显拖累。

4.3 K取值与辅助损失:精度不升反降是怎么回事

迭代次数K不是越大越好。有一次我把K设为3,结果mAP比K=2还低了0.8个点。分析后认为是过拟合加梯度信号退化:K=3时,后两轮迭代的梯度相互纠缠,模型开始“记忆”训练集特征,泛化力反而下降。

另一个经典问题是,直接只用最终FPN输出算检测损失,中间迭代产生的特征缺少监督。尤其在K=2时,第一轮的FPN输出是完全没被“纠正”过的,相当于白白加深了网络深度但没人指导它该怎么走。我试过对每一轮迭代的FPN输出都计算辅助检测损失,权重设为0.5,整体指标又抬回去一点。这个做法和深层监督的思路一致,能让每一轮迭代都有明确目标。

所以我的建议是:在RFP的K次迭代中,对每次迭代的输出都接一个辅助检测头,并让辅助头的loss以较低权重加入总损失。等到训练后期,可以再把辅助权重降为0,只用最终输出做微调。

5. 初版实验效果与下一步计划

5.1 小规模数据集上的对比结果

我在一个自制的车辆与行人混合数据集上跑了对比实验,图片尺寸640x640,训练集8000张,验证集2000张。原版YOLO11在验证集上的mAP@0.5是68.4%,加上RFP(K=2、共享权重、带RFB反馈)后提升到70.5%,绝对值涨了2.1个百分点。提升最明显的是小目标类别,单独统计面积小于32x32的目标,AP从41.2%涨到45.6%。

不过参数量和计算量都有上涨:参数量比原版多了约12%,GFLOPs从约80变成约165(两次前向加反馈模块)。如果你的场景对推理速度敏感,可以考虑只对Neck的深层分支做第二次迭代,或者把第二次迭代中的Backbone换成浅层结构,速度能拉回来不少。

5.2 后续实验方向

这个系列的第一篇先到这里,接下来我准备做三件事:

一是完整消融实验。把RFB、门控、辅助损失、K取值每一项都单独开掉,看各自贡献多少,避免把工程技巧和算法本身的收益混在一起。

二是尝试在反馈路径上引入可变形卷积,替代固定膨胀率的RFB。既然反馈是动态的,对齐的特征区域也应该根据内容动态调整,理论上更符合递归精化的直觉。

三是把RFP和检测头解耦,只保留RFP输出的最终特征,在解码阶段尝试不同的分配策略,比如用Feedback来指导正样本选取。我怀疑递归特征带来的全局上下文,对样本匹配也有帮助,这个想法还没验证,但值得试试。

我个人在实际操作中的体会是,反馈连接最容易被低估的价值,不是多几次前向,而是让Backbone在提取特征时“知道”自己最终要表达什么。就好比写初稿和改稿的区别——初稿再烂,有了明确的修改方向,第二轮总会更准。这个方向我会继续做下去,后面有新结论了再来更新。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询