资讯详情

递归特征金字塔RFP改进YOLO11,解决小目标漏检

📅 2026/10/17 7:54:16 | 华诺云谱 👁 阅读
递归特征金字塔RFP改进YOLO11,解决小目标漏检
做目标检测的同学大多有过这种经历模型在简单场景下表现还行一遇到小目标密集、遮挡严重的场景就漏检得一塌糊涂。我这次下手的方向不是检测头而是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的灵感来源RFPRecursive Feature Pyramid最早在中尺度特征融合的讨论里出现过核心逻辑其实很朴素借鉴RNN的隐状态更新机制。RNN在时间步上展开每个时间步的输入都包含当前时刻观测和上一时刻的隐状态从而让网络“记住”之前的信息。RFP则把这种思路搬到空间特征上——每次迭代里模型不仅看原始图像还看上一轮FPN输出的特征图当作一种“先验理解”。这种反馈不是简单地把特征图加回去而是让Backbone的每一阶段都能感知到“当前对整个图像的解读”。于是下一轮迭代Backbone提取特征时就不是盲人摸象而是带着全局视野重新审视细节。这个过程有点像两遍阅读第一遍先扫整体第二遍带着重点去看局部神经网络的“注意力”在迭代中自动调整。需要注意RFP与常规的FPN改进比如BiFPN有本质区别。BiFPN加的是跨尺度连接信息仍然只走一遍而RFP加的是跨时间步连接同一组特征被反复处理多次。这也是“递归”一词的来源——结构在时间上重复出现形成闭环。2.2 整体闭环架构我用一个简化图来说明RFP在YOLO11里的顺序执行流程第一遍前向原始图像进入Backbone得到多尺度特征B1B1送入FPN得到多尺度金字塔特征F1将F1的各个尺度分别经过反馈变换比如经由RFB模块处理得到反馈特征R1第二遍前向原始图像再次进入Backbone但Backbone的每个阶段不再只接收上一阶段的输出还额外接收R1中对应尺度的反馈。由此得到新的Backbone特征B2再送入FPN得到F2如果设置迭代次数K2则F2直接送给检测头如果K2就重复步骤2-3。关键在Backbone的每个阶段反馈特征和当前阶段提取的特征如何合并。我采用的方法是先把反馈特征调整到和当前特征相同分辨率做一个1x1卷积把通道数对齐然后相加。这个设计不会给网络增加太多参数却能让Backbone“看到”全局上下文。2.3 反馈连接的关键组件感受野增强模块RFB直接原样把FPN输出送回去效果其实一般问题出在感受野上。FPN输出的特征图虽然语义强但局部空间关系并不精细。为了放大反馈信息的作用我在反馈路径上插入了一个RFBReceptive Field Block模块。RFB的思路借鉴了人类视觉系统的多尺度感受野。它用一组不同膨胀率的空洞卷积并联每个分支的膨胀率不同常见比如1、3、5然后拼接或相加最终模拟出类似“中心-周围”的感受野结构。在RFP里RFB的作用是让反馈的特征既保留局部细节又覆盖更广的上下文范围这样Backbone下一轮迭代就能在更合理的尺度上修正自己的特征。一个容易忽视的点是RFB放在哪里。我试过把RFB直接放在FPN输出之后也试过放在Backbone每个阶段入口处。实测下来放在反馈路径上、在进入Backbone之前做特征增强效果更稳。因为这样不会干扰FPN内部结构改动面最小也方便后期把RFB换成别的特征增强模块做对比。3. 在YOLO11上落地RFP修改步骤与核心代码3.1 工程准备与代码结构梳理动手之前先明确一点这里的YOLO11指的是某开源目标检测代码库中的网络定义结构上包含CSPStage组成的Backbone和FPNPAN风格的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, feedbackNone): 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, sizef.shape[-2:], modebilinear, align_cornersFalse) 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, paddingd, dilationd), nn.BatchNorm2d(mid), nn.SiLU(), nn.Conv2d(mid, mid, 3, paddingd, dilationd), 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, dim1) out self.fuse(out) return self.shortcut(x) out这里每个分支的mid通道等于out_channels除以分支数。比如输入输出都是256通道三个分支每个分支中间是84或85通道拼接后总通道恰好是256。空洞率用[1,3,5]感受野覆盖范围从接近常规卷积到比较大区域够用。接下来是整体检测模型的forward修改。原YOLO11检测模型forward大致是x经过backbone得到xsxs经过neck得到outsouts送入检测头。我改成循环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, feedbackfeedback) 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_norm5强压一下。第二步是在反馈特征相加之前加一个可学习的门控系数初始设为0。这样训练初期反馈路径几乎没有信号网络基本等于原版YOLO11稳定后再让门控系数慢慢增大把反馈信息逐步引进来。我是用一个带sigmoid的标量实现门控的self.gate nn.Parameter(torch.zeros(1)) ... f f self.gate * fb这招非常管用后续的收敛明显稳定也让我确认了问题就是梯度放大而不是代码错误。4.2 显存消耗激增与我的缓解方案K2时显存大约是原版的2.2倍而不是简单的2倍。原因是反馈路径上Backbone、Neck、RFB的中间激活都参与梯度计算被保留在显存里。如果K3显存直接接近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比K2还低了0.8个点。分析后认为是过拟合加梯度信号退化K3时后两轮迭代的梯度相互纠缠模型开始“记忆”训练集特征泛化力反而下降。另一个经典问题是直接只用最终FPN输出算检测损失中间迭代产生的特征缺少监督。尤其在K2时第一轮的FPN输出是完全没被“纠正”过的相当于白白加深了网络深度但没人指导它该怎么走。我试过对每一轮迭代的FPN输出都计算辅助检测损失权重设为0.5整体指标又抬回去一点。这个做法和深层监督的思路一致能让每一轮迭代都有明确目标。所以我的建议是在RFP的K次迭代中对每次迭代的输出都接一个辅助检测头并让辅助头的loss以较低权重加入总损失。等到训练后期可以再把辅助权重降为0只用最终输出做微调。5. 初版实验效果与下一步计划5.1 小规模数据集上的对比结果我在一个自制的车辆与行人混合数据集上跑了对比实验图片尺寸640x640训练集8000张验证集2000张。原版YOLO11在验证集上的mAP0.5是68.4%加上RFPK2、共享权重、带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在提取特征时“知道”自己最终要表达什么。就好比写初稿和改稿的区别——初稿再烂有了明确的修改方向第二轮总会更准。这个方向我会继续做下去后面有新结论了再来更新。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑