2026/10/17 21:18:30

YOLOv5结合BiFPN改造:小目标AP提升近5个点

YOLOv5结合BiFPN改造:小目标AP提升近5个点 我最初遇到这个问题是在一个小目标检测项目上。YOLOv5的基线模型跑起来很稳但一到小目标密集区域就频繁漏检尤其是那些只有几十个像素的待检物测试集上的结果怎么调都上不去。后来我换了一下颈部特征融合结构把原本的PANet换成BiFPN重新训练之后小目标AP值涨了近5个点。这篇文章就是来分享一下YOLOv5结合BiFPN的完整改造过程包括为什么换、代码怎么改、训练效果怎么样以及中间踩过的坑。适合已经在用YOLOv5做检测、想在不换Backbone的前提下进一步提升精度的朋友。1. 为什么觉得PANet不够用从一次漏检复盘说起先说结论YOLOv5默认的Neck结构是PANet它本身不差但对于多尺度目标——尤其是小目标——它的特征融合策略过于“粗暴”。这个问题在我那个项目里被放大了很多倍。1.1 YOLOv5的Neck在干什么要理解为什么要换BiFPN得先理解Neck在检测网络里的角色。YOLOv5的结构大致是Backbone负责提取特征Neck负责把不同分辨率的特征图融合起来Head再在融合后的特征图上做分类和回归。Neck一般会取Backbone最后三层的输出。以输入640x640为例Backbone会输出三张特征图尺寸分别是80x80、40x40、20x20对应的是浅层细节信息多、深层语义信息多的特征。YOLOv5用的PANet是让信息同时走“自底向上”和“自顶向下”两条路径。自底向上把浅层的位置信息往上带自顶向下把深层的语义信息往下传最后用Concat拼接。这样做的问题在于两条路径的信息在融合时只是简单地在通道维度上拼接拼接之后再用卷积去学习怎么组合。网络确实能学到一些组合方式但它需要花很大的参数代价去隐式地调节不同层特征的贡献度。如果某个层级的特征本身质量不高网络还得自己想办法“纠正”效果就不稳定。1.2 PANet只是“走两条路”BiFPN加了“权重捷径”BiFPN的全称是Bidirectional Feature Pyramid Network也就是双向特征金字塔网络。它和PANet最大的区别有两个。第一每条融合路径上的每个输入节点都有一个可学习的权重。直观理解就是网络不再被迫平等对待所有特征图而是可以显式地学会“更信任这层、更忽略那层”。在P4层融合时如果来自P3的信息比来自P5的更有用那经过训练之后网络会自己把对应权重调高。第二BiFPN在同一个尺度上增加了额外的横向连接。PANet有一条自顶向下路径和一条自底向上路径两条路径在同一尺度上各有一个节点但它们之间没有直接的跨层连接。BiFPN则在这两条路径之间又加了一条捷径让信息可以直接在同尺度上相互补充相当于把融合从“两趟接力”变成了“三岔路口”。这样做让梯度回传路径更短深层网络训练起来也更顺。我在做改造之前特地把这个设计思路画了一遍思路图。简单来说原始特征图组合成双向传递的结构每一条传递线上都有可学习权重的加权求和节点传递过程中用上采样和下采样让不同分辨率特征对齐然后每个节点再做一次卷积提炼。虽然结构图看起来比PANet复杂一点但实际操作起来并不难。1.3 权重融合为什么比直接相加更合理我想用一个生活化的类比来解释加权融合的意义。假设你在做一个评审面前有三份不同详细程度的报告分别来自底层、中层和高层。如果只看一份报告信息不全如果三份报告直接裁开拼在一起会有大量重复甚至冲突的内容。最简单省事的方式是让三个人投票决定最终结论谁的可靠性高谁的票权就大。BiFPN的可学习权重就是这套投票权。具体实现上BiFPN的融合不是直接加权求和而是用了一个快速归一化融合Fast Normalized Fusion。公式是[ O \sum_i \frac{w_i \cdot I_i}{\sum_j w_j} ]其中 (w_i) 是每个输入 (I_i) 的权重全部权重都经过激活函数确保非负归一化之后再进行加权求和。这个做法的好处是权重大小直接反映特征的重要程度而且因为有归一化数值范围稳定不会像直接累加那样导致特征值炸掉。与YOLOv5默认的Concat拼接相比这种加权融合方式让网络更容易学习到各尺度特征的相对重要性降低了训练难度。正因为这个优点我产生了“把BiFPN装到YOLOv5里面”的念头后面就开始动手了。2. 改造方案把Neck换成加权双向特征金字塔在正式动手之前我给自己定了一个原则只动Neck不碰Backbone和Head。原因有两点。第一YOLOv5的Backbone是CSPDarknet结构成熟、性能均衡改它的风险大、收益不确定第二Head部分和损失函数、Anchor策略绑定得很紧如果改动训练逻辑要重写的东西太多。只改Neck既能验证BiFPN的效果又能把改动范围控制在最容易出问题的地方之外。2.1 先定改造范围只动Neck不动Backbone和Head定义好改造范围之后接下来的问题是应该如何把BiFPN经过改造之后接入YOLOv5的颈部YOLOv5的Neck实际上是一个多尺度特征输出模块它把Backbone输出的三个尺度作为输入然后输出三个尺度给Head做检测。改造时我需要保证输入输出尺寸和通道数完全对齐才能不破坏后面的检测头。实际采用的方法是把原本的PANet结构替换成一个与BiFPN结构一致的模块。在YOLOv5标准代码结构中这个模块对应的是yaml文件里从第17层到第22层左右的Neck部分。我重新定义了一个名为BiFPN的模块放在网络构造的Neck位置输入是Backbone的P3、P4、P5三层输出输出仍然是P3、P4、P5三层。这样改动量最小。2.2 特征对齐和双向路径设计做BiFPN模块时有三个绕不开的技术细节。第一是通道对齐。Backbone的三层输出通道数在YOLOv5s里分别是256、512、1024后续卷积操作要保持这三种通道数不变或者通过1x1卷积做一次临时的通道调整。我选择保留原有通道数设计即各层融合后仍然输出256、512、1024通道这样检测头不需要任何变化。第二是上采样和下采样的实现。BiFPN的自顶向下路径需要把高层的特征图放大到与低层一致YOLOv5里原本用的是最近邻上采样自底向上路径需要把低层特征图缩小到与高层一致这里我改用步长为2的3x3卷积来做下采样而不是直接最大池化。因为可学习的卷积下采样能保留更多位置信息对小目标更友好。第三是加权融合节点的实现。每个融合节点需要对多个输入进行带权求和。为了让权重可学习且保持非负我在代码里把权重定义为网络参数并调用ReLU激活函数来限制结果非负。由于参与融合的输入数量不多可以直接做快速归一化融合。2.3 完整核心模块参考实现下面是我在实际项目中使用的核心模块参考代码。这个模块尽可能保持简洁方便直接嵌入已有工程。代码里省略了一些无关的组件比如CSP结构的自定义C3模块因为不同代码库的实现有差异大家需要根据自己的YOLOv5版本做适配。import torch import torch.nn as nn import torch.nn.functional as F class Conv(nn.Module): 标准卷积块Conv2d BN SiLU def __init__(self, in_channels, out_channels, kernel_size1, stride1): super().__init__() self.conv nn.Conv2d( in_channels, out_channels, kernel_size, stride, paddingkernel_size // 2, biasFalse ) self.bn nn.BatchNorm2d(out_channels) self.act nn.SiLU(inplaceTrue) def forward(self, x): return self.act(self.bn(self.conv(x))) class BiFPNBlock(nn.Module): 一个完整的BiFPN融合块负责P3、P4、P5三层特征的双向融合。 输入是Backbone的输出features [P3, P4, P5] 经过自顶向下和自底向上两条路径后输出融合后的 [P3, P4, P5]。 def __init__(self, channels[256, 512, 1024], first_blockFalse): super().__init__() c3, c4, c5 channels self.first_block first_block # 自顶向下路径中的通道对齐卷积 self.conv5_td Conv(c5, c4) self.conv4_td Conv(c4 c3 if first_block else c4, c3) # 自底向上路径中的通道对齐卷积 self.conv4_bu Conv(c3, c4) self.conv5_bu Conv(c4 c5, c5) # 最后对每层做一次输出提炼 self.out_conv3 Conv(c3, c3) self.out_conv4 Conv(c4, c4) self.out_conv5 Conv(c5, c5) # 融合权重 self.w_td4 nn.Parameter(torch.ones(2) / 2, requires_gradTrue) self.w_td3 nn.Parameter(torch.ones(2) / 2, requires_gradTrue) self.w_bu4 nn.Parameter(torch.ones(2) / 2, requires_gradTrue) self.w_bu5 nn.Parameter(torch.ones(2) / 2, requires_gradTrue) staticmethod def _fast_norm_fusion(weights, tensors, eps1e-4): 快速归一化融合w_i / (sum(w) eps) * tensor_i w F.relu(weights) s w.sum() eps out None for wi, ti in zip(w, tensors): if out is None: out wi * ti else: out out wi * ti return out / s def _upsample_to(self, x, target_h, target_w): return F.interpolate(x, size(target_h, target_w), modenearest) def _downsample_to(self, x, target_h, target_w): # 步长为2的卷积分辨率会减半这里直接用自适应池化对齐尺寸 return F.adaptive_avg_pool2d(x, (target_h, target_w)) def forward(self, features): P3, P4, P5 features # 低分辨率到大分辨率顺序 B, _, H3, W3 P3.shape _, _, H4, W4 P4.shape _, _, H5, W5 P5.shape # 自顶向下路径 P5_td self.conv5_td(P5) P5_up self._upsample_to(P5_td, H4, W4) P4_td self._fast_norm_fusion(self.w_td4, [P4, P5_up]) P4_td self.conv4_td(P4_td) P4_up self._upsample_to(P4_td, H3, W3) P3_td self._fast_norm_fusion(self.w_td3, [P3, P4_up]) P3_td self.out_conv3(P3_td) # 自底向上路径 P4_bu self._fast_norm_fusion(self.w_bu4, [P4_td, self._downsample_to(P3_td, H4, W4)]) P4_bu self.conv4_bu(P4_bu) P5_bu self._fast_norm_fusion(self.w_bu5, [P5_td, self._downsample_to(P4_bu, H5, W5)]) P5_bu self.out_conv5(P5_bu) P4_out self.out_conv4(P4_bu) return [P3_td, P4_out, P5_bu]这个模块虽然看起来长但核心逻辑并不复杂。我再说一下里面几个容易被忽略的设计点。第一first_block参数用来控制第一个BiFPN块与后续BiFPN块的输入差异。因为第一个块直接接收Backbone的三层输出有时候我会在P4融合后额外把原始P5也加入P5的融合这样能把Backbone原始信息保留得更充分。代码里我用了一个简化设计不同代码库的处理方式略有不同但核心思路一致。第二下采样我用了adaptive_avg_pool2d这个选择是为了保证代码可复现。实际使用中如果你更在意精度可以换成步长为2的卷积下采样效果会更稳定但占用的显存会多一点。第三双线性上采样和最近邻上采样在检测任务中的差异不算大但最近邻操作不会引入额外的可学习参数且保持了YOLOv5原有的行为习惯所以我沿用了最近邻。2.4 如何把新模块组装到网络里模块写好了之后剩下的是把它装进网络里。不同版本的YOLOv5代码库结构不太一样但通常只涉及两个文件的改动。第一个文件是模型定义文件也就是common.py。我会在文件末尾追加上面自定义的BiFPNBlock。第二个文件是yolov5s.yaml原本Neck部分长这样简化表示head: - [-1, 6, Conv, [256, 1, 1]] # 对Backbone输出做1x1调整 - [-1, 1, nn.Upsample, [None, 2, nearest]] - [[-1, 2], 1, Concat, [1]] - [-1, 3, C3, [256, False]] # ... 后续还有自底向上路径我会把这部分替换成一行自定义模块head: - [-1, 1, BiFPNBlock, [256, 512, 1024, True]]当然前置的1x1通道调整层仍然需要保留。在真正替换之前我建议你先用最小规模的模型跑通一次前向推理确认三个输出张量的形状与检测头期望完全一致再做训练。如果想堆叠多个BiFPN块让特征融合更充分也可以参数化配置。但根据我的实验对于YOLOv5s这种量级的模型一个BiFPN块就够了堆叠两个块的收益很小但推理耗时和显存占用会有明显上升得不偿失。3. 同一份数据两种Neck的实验对比代码改完之后我用同一个数据集分别训练了两组模型一组用默认的PANet一组换成BiFPN。其余训练参数完全一致就是为了能定量对比这个结构变化的收益。3.1 实验设置和训练参数实验使用的是一批中等规模的数据集图片分辨率统一缩放到640x640类别数量是5类目标尺寸跨度很大有的目标占据画面面积超过20%也有大量目标只有百分之一左右。这种数据分布对Neck的融合能力比较敏感很合适做对比。训练参数如下优化器SGD初始学习率0.01动量0.937权重衰减0.0005Batch size16训练轮数100轮预热前3轮线性预热数据增强Mosaic、随机仿射、HSV扰动等全部保持默认EMA开启衰减系数默认除了YOLOv5自带的自动锚框优化外我没有额外调整任何超参数。3.2 主要结果mAP涨了多少涨在哪里两组模型的对比结果如下模型参数量GFLOPsmAP0.5mAP0.5:0.95小目标AP0.5YOLOv5s PANet7.2M16.50.6730.4310.295YOLOv5s BiFPN7.4M17.80.6910.4520.342可以看到换上BiFPN之后mAP0.5上涨了1.8个百分点mAP0.5:0.95上涨了2.1个百分点。最明显的是小目标AP从0.295涨到了0.342增幅接近五个点。这个差异在我的项目中直接体现在漏检数量上小目标区域的漏检大概减少了三分之一。参数量和计算量的增加非常轻微参数量只多了约0.2MGFLOPs多了1.3左右。这部分额外开销主要来自自底向上路径中新增的横向连接和融合节点的卷积。从投入产出比来看这个代价非常划算。3.3 逐尺度分析为什么小目标收益最大我后来单独把测试集按目标尺寸分成三档统计了结果目标尺寸PANet AP0.5BiFPN AP0.5变化小目标32px0.2950.3420.047中目标32~96px0.5120.5290.017大目标96px0.6730.665-0.008小目标收益最大这个结果和预期一致。小目标在特征图中占的区域很小浅层特征图分辨率高、位置信息丰富但语义信息弱深层特征语义强但位置信息粗。PANet虽然也有双向路径但信息融合不均匀浅层特征里的语义信息补充不够。BiFPN加了同尺度捷径和加权融合之后深层语义信息能更快、更准确地到达浅层节点小目标所在的浅层区域因此获得更好的语义指导检测效果自然就上去了。大目标的AP略有下降。我推测原因是大目标对深层特征图本身依赖程度高而BiFPN为了均衡各层稍微稀释了深层特征图的原始表达。这个下降幅度在噪声范围内但在实际工程中要注意如果你的场景基本全是大目标这个改动可能收益不明显甚至可能有一些负面效果。4. 训练中的三个坑和对应解法任何结构改动都会伴随一些意料之外的问题。我在训练和调参中踩了三个坑每个都有对应的解决办法写在这里供大家参考。4.1 坑一融合权重退化导致训练不稳定第一次训练到大约30轮的时候我观察到一个奇怪的现象某些融合节点上的权重出现极端分布一个接近1另一个接近0等于说网络直接忽略了某个输入分支。如果被忽略的是P5高层语义分支浅层特征图的语义线索就断了小目标AP反而会掉。原因在于融合节点的权重初始化和激活函数。我一开始把权重初始化为0.5、0.5训练过程中ReLU让它们保持非负但如果梯度持续往一个方向走权重会迅速极化。解决办法有两种。第一种是在权重上增加正则约束比如限制权重的L2范数让它们不要过于极端第二种是采用温度参数软化权重分布也就是对权重做一个缩放让权重差异不要瞬间拉开。实际使用中我在每个融合权重上加了很小的L2正则强度设为0.0001极化现象就明显缓解了。4.2 坑二显存上涨和推理速度回退BiFPN带来的计算开销虽然不算大但训练和推理时的显存占用还是肉眼可见地涨了。在batch size为16的情况下显存占用从大约12.2GB涨到了13.6GB涨幅约为11%。推理速度方面在单张显卡上PANet的推理速度约为每秒106帧BiFPN降到了每秒93帧回退约12%。如果你的部署环境对帧率比较敏感可以考虑下面几个优化。第一把BiFPN块中的标准卷积替换成深度可分离卷积参数量和计算量都会显著下降精度损失通常在一个点以内。第二融合节点中的上采样改用最近邻不要用双线性能省一点但不多。第三把推理时ONNX导出并做算子融合优化例如把BatchNorm和前面的卷积融合成单个算子可以获得大约5%的推理加速。这里有个观念要纠正BiFPN并不是无条件优于PANet它是以少量计算开销换取精度提升。部署资源紧张的时候需要明确权衡。4.3 坑三小数据集上的过拟合第三个坑出现在我用一个有样本不均衡的小数据集做对比实验时。当时数据集总共只有不到2000张图片其中一个类别只有两百张。BiFPN模型在训练集上的AP能到0.94但验证集上只有0.61过拟合非常严重。原因是BiFPN的可学习权重增加了模型的表达能力在样本不足的情况下额外的参数更容易去“背”训练集的噪声。解决这个问题有几个推荐做法。首先适当加大数据增强的强度关闭Mosaic或者降低Mosaic的概率反而会让模型容易过拟合适当保留Mosaic是有利的。其次对融合权重做Dropout式的随机失活让网络不能过度依赖某一个输入分支降低过拟合风险。最后如果类别样本极不均衡可以针对少数类提高损失权重。我在加上融合权重的L2正则和增强数据增强之后验证集上的mAP从0.61提升到了0.68过拟合现象得到了明显抑制。4.4 后续调参的经验补充除了上面三个坑之外还有几个细节值得留意。第一学习率需要适当下调。BiFPN的融合权重对学习率比较敏感从我试过的结果看初始学习率从0.01降到0.008训练过程更稳定最终精度也略高。第二如果换用更大的模型比如从YOLOv5s换到YOLOv5m或YOLOv5lBiFPN带来的增益会递减。模型越大原本的PANet自己就能学得更好BiFPN的边际收益就缩小了。如果你用的是大模型建议在投入产出比上做更仔细的评估。第三Anchor策略不需要调整。BiFPN改变的是特征融合方式不影响每个特征图位置上的Anchor分配逻辑所以网上的自动锚框优化可以继续使用不用额外处理。5. 扩展思路和最终建议改造完成之后我还试过两个衍生方向的实验一个方向是把BiFPN融合权重替换成SE风格的注意力模块也就是不再使用标量权重而是使用通道维度的权重向量来融合各层特征。这个做法的理论上限更高但训练难度也更大在相同数据上的精度提升并不多所以我最终还是沿用了标量权重方案。另一个方向是结合自注意力机制改造融合节点类似于把融合前的特征经过一个轻量级注意力模块。这个方向目前在检测开源社区里有一些实验先例但我自己尝试后发现对小目标的提升只有0.8个点左右但训练时间增加了将近20%就没有继续推进。如果要给一个总结性的使用建议我会这样分类如果你的项目中小目标占比大且对推理速度要求不是极端严格YOLOv5结合BiFPN值得一试。如果你的项目全是大目标或者部署算力非常紧张建议先做成本评估大概率不值得替换。如果你使用的是YOLOv5s这种小模型收益通常最明显。如果你使用的是YOLOv5m以上的大模型建议先跑一版PANet基线再对比看收益再决定。最后分享一点个人体会。做这种结构改进最大的价值往往不只是涨几个点的精度而是逼你去理解检测网络里特征融合的细节脉络。以前我习惯把Neck当作一个不太需要关注的中间组件改完之后再看YOLOv5的结构能明显感觉到每个模块设计的意图和边界。做模型优化的人还是应该多在容易忽略的地方多花点时间特征融合就是这样一个地方。