1. 为什么改造 YOLOv8:Baseline 的瓶颈与两个关键技术的引入
YOLOv8 出来以后,目标检测这块基本就形成了一个共识:要把工业落地和学术刷点结合起来,优先看 YOLOv8。它相比 YOLOv5 最大的改动在于 Anchor-Free 范式全面落地、C2f 结构替代原 C3,以及解耦检测头的使用。很多人拿着它在自建数据集上跑完一轮,第一感受是:收敛挺快,精度也说得过去,但一对比那些用了更强 Backbone 或者更强 Neck 的方案,就发现它的上限被结构本身卡住了。
我当时拿到一个真实项目,要在复杂工业场景下检测几十类目标,其中一半是尺度变化极大的小目标或者形变件。YOLOv8 默认跑完,mAP50 倒是能到 78 左右,mAP50-95 却一直在 52 附近挣扎。我一直在琢磨问题出在哪:不是训练技巧不够,而是特征融合的结构不够用。
这里就要说到为什么把 BIFPN 和 RepVGG 两个东西同时拉进来了。BIFPN 解决的是“跨尺度特征融合”的问题,YOLOv8 默认使用的 PANet 结构本质上是一条自顶向下和一条自底向上的路径,重复信息多、融合权重固定;而 BIFPN 引入跨尺度连接和可学习的融合权重,让浅层细节和深层语义可以在不同层级之间有更合理的配比。RepVGG 解决的是“Backbone 表达力与推理效率的矛盾”,它让网络在训练时拥有类似 ResNet 的多分支结构来增强特征表达,而在推理时通过结构重参数化把多分支折叠成单条直筒卷积,速度快且省内存。
这两者的组合逻辑很清晰:RepVGG 从 Backbone 端榨取更多有效特征,BIFPN 从 Neck 端把这些特征以更高效的方式喂给检测头。下面我把每一步的细节和实操过程完整拆开来讲。
2. BIFPN 加权双向特征金字塔:让每一层特征都有发言权
2.1 FPN、PANet 到 BiFPN 的演进逻辑
特征金字塔的核心目的很简单:让不同尺寸的目标都能找到适合自己表达的层。小目标需要高分辨率浅层特征,大目标依赖低分辨率深层语义。FPN 经典做法是自顶向下单向传递语义信息,浅层细节保留在原地不动。PANet 在此基础上补了一条自底向上路径,让浅层细节也能往深层走。看起来两边都照顾到了,但实际用起来你会发现两个问题:
第一,两层之间只有一条直接连接,跨层信息全靠逐级传递,路径越长信息损耗越大。第二,融合时各层特征直接相加,没有区分每个输入特征的重要性。比如在自底向上路径中,来自浅层的特征可能包含了大量背景噪声,和深层语义特征简单相加,反而干扰了高层目标的位置判定。BIFPN 把这两点都改了:每个输入特征配一个可学习的融合权重,同时增加跨尺度跳跃连接,让最高层和最低层之间能够直接交流信息。
我拿一个很容易理解的例子来类比:FPN 相当于一个班级里只有班长单向传递老师的话,PANet 相当于班长和学委双向传话,而 BiFPN 相当于每个小组之间都能直接沟通,同时每个人都根据自己消息的重要程度决定发言音量。这个“音量”就是加权融合的权重。
2.2 加权融合的计算方式与代码落地
BiFPN 的核心公式并不复杂。对于第 6 层特征,融合结果可以表示成:
P6_td = Conv( (w1 * P6_in + w2 * Resize(P7_in)) / (w1 + w2 + epsilon) ) P6_out = Conv( (w1' * P6_in + w2' * P6_td + w3' * Resize(P4_out)) / (w1' + w2' + w3' + epsilon) )这里的 w 就是可学习的权重,epsilon 取 0.0001 防止除零。权重不是直接经过 Softmax,而是采用快速归一化融合,把每个权重除以所有权重之和,这样既保证了融合结果在数值范围上稳定,又比 Softmax 少了指数运算,训练时收敛速度更快。这个细节在落地时很关键,因为目标检测模型的训练本身就不便宜,能省一点计算是一点。
落到 YOLOv8 的改造里,需要把原来的 PANet 结构替换为双向融合结构。实际操作中不建议把全部层都无脑替换,我采用的方案是保留原有 P3、P4、P5 层定义,在自顶向下路径之后额外增加一条带权重的自底向上融合,并且为每一层增加跨一层跳跃连接。核心代码如下:
import torch import torch.nn as nn import torch.nn.functional as F class BiFPNBlock(nn.Module): def __init__(self, in_channels, out_channels, num_layers=3): super().__init__() self.num_layers = num_layers self.switches = nn.ParameterList([ nn.Parameter(torch.ones(2)) for _ in range(num_layers) ]) self.conv = nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.SiLU(inplace=True) ) def _fuse(self, features, weights): weights = F.relu(weights) denominator = weights.sum() + 1e-4 fused = sum(w * f for w, f in zip(weights, features)) / denominator return fused def forward(self, features): # features: [P3, P4, P5] p3, p4, p5 = features # top-down path p4_td = self.conv(self._fuse([p4, F.interpolate(p5, size=p4.shape[-2:])], self.switches[0])) p3_td = self.conv(self._fuse([p3, F.interpolate(p4_td, size=p3.shape[-2:])], self.switches[1])) # bottom-up path with cross-layer connection p4_out = self.conv(self._fuse([p4, p4_td, F.adaptive_avg_pool2d(p3_td, p4.shape[-2:])], self.switches[2])) return [p3_td, p4_out, p5]这段代码只是一个可运行的简化版本,实际工程中还需要处理不同层的通道数对齐。细心的读者可能发现,这里对 p5 没有做自底向上的更新,这就是 BiFPN 的一个特性:最高层本身语义最强,不需要再来回折腾,它只参与向下的特征传递。值得注意的是,这个简化版没有实现原始论文中每一轮融合的重复堆叠,项目里如果想要更高精度,可以把 BiFPN Block 重复两次甚至三次,代价是训练时间和显存都会增加。
2.3 融合权重初始化与训练稳定性
很多人在改造后训练发现 loss 不稳定,尤其是前几百次迭代。这个问题我在项目中遇到过,排查后定位到融合权重的初始化方式。权重直接随机初始化的话,前期等于是把噪声特征加权相加,梯度很容易互相冲突。我给权重的初始值设成 1,并且在第一次前向传播时强制均分,这样训练初期退化成普通相加,后期模型逐步学出差异化权重。等价的做法是在 _fuse 函数里临时固定权重,训练一个 epoch 后再放开。
另一个稳定性因素和 epsilon 有关。快速归一化融合公式里 epsilon 太小会导致除零风险,太大会导致权重占比不敏感。实测 1e-4 是一个通用可靠的取值,如果你用的数据集中目标尺度分布特别极端,比如全是小目标,可以试试 1e-3,但此时权重的学习速率要相应调低。
3. RepVGG 重参数化:训练时多分支,推理时单路径
3.1 同于 ResNet 的另一种多分支思路
RepVGG 这个概念刚出来时,很多人以为它又是一个追求 SOTA 精度的新网络结构。它的真正价值在于:通过训练时的多分支结构与推理时的单路径结构解耦,同时拿到两者的好处。训练时每个 Block 由 3x3 卷积、1x1 卷积和恒等映射三条分支组成,相当于一个 mini ResNet;推理时把三条分支合并成单个 3x3 卷积,网络变成一条直筒。
直筒网络为什么重要?因为单路结构在推理时对硬件更友好。分支结构会造成内存访问次数增加,调度开销大,尤其在 GPU 上并行度不理想。合并后计算图变成一条链,显存占用和延迟都显著降低。对于目标检测这种需要实时性的任务,这个优势直接转化为 FPS 的提升。
用大白话讲,RepVGG 就是在训练时给网络“开外挂”:多分支让每层的梯度路径变多、特征表达更丰富,相当于同时用几条思路解题;推理前把外挂收起来,几条思路合并成一种最优解,用时只看一个简洁的答案。这个“合并”过程就是结构重参数化,不需要任何额外的推理框架支持,纯 PyTorch 就能实现。
3.2 结构重参数化的数学推导
合并的原理其实不复杂。推理时的卷积等效于三个分支的卷积核直接相加,关键是把 1x1 卷积和恒等映射都转换成 3x3 的卷积核形式。
对于 1x1 卷积分支,直接在周围补零变成 3x3 的卷积核即可。对于恒等映射分支,可以看作是一个特殊的 3x3 卷积:卷积核中心为 1,其余位置为 0,并且输入输出通道数相同。三条分支各有一个 BatchNorm,推理时先要把 BatchNorm 吸收进卷积层:每个输出通道的缩放系数和偏置直接重构进卷积核。
假设原始的卷积权重为 W,BatchNorm 的均值为 nonzero,方差为 var,缩放系数为 gamma,偏置为 beta,合并后的卷积核和偏置为:
W_fused = W * gamma / sqrt(var + eps) b_fused = (b - mean) * gamma / sqrt(var + eps) + beta三个分支各自完成上述折叠后,卷积核直接相加,偏置直接相加,最终得到一个单一 3x3 卷积。这个过程在代码里写好函数后一次前向就能完成,不需要变换后的模型再做微调,精度不会有任何损失。这一点是我最喜欢的:既有训练时的强表达力,又没有任何推理时的额外开销。
3.3 将 RepVGG Block 集成到 YOLOv8 Backbone
YOLOv8 本身用的是 C2f 结构,由多个 Bottleneck 组成。我的集成方案是保留 C2f 的整体框架,但把内部的标准卷积替换为 RepVGG Block。这样既保持了 YOLOv8 原本的梯度流设计,又引入了重参数化的表达能力。替换后的 C2f 结构在训练时是多分支,导出模型时通过重参数化还原为标准卷积路径。
对应代码实现,我封装一个可切换的模块:
class RepVGGBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv3x3 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False) self.conv1x1 = nn.Conv2d(in_channels, out_channels, 1, stride, 0, bias=False) self.bn3 = nn.BatchNorm2d(out_channels) self.bn1 = nn.BatchNorm2d(out_channels) if stride == 1 and in_channels == out_channels: self.bn_identity = nn.BatchNorm2d(out_channels) else: self.bn_identity = None def forward(self, x): out = self.bn3(self.conv3x3(x)) + self.bn1(self.conv1x1(x)) if self.bn_identity is not None: out += self.bn_identity(x) return F.relu(out) def reparameterize(self): kernel3x3 = self._fuse_bn_to_conv(self.conv3x3, self.bn3) kernel1x1 = self._fuse_bn_to_conv(self.conv1x1, self.bn1) kernel1x1_pad = F.pad(kernel1x1, (1, 1, 1, 1), value=0) fused_kernel = kernel3x3 + kernel1x1_pad if self.bn_identity is not None: kernel_id = torch.zeros_like(fused_kernel) center = kernel_id.size(2) // 2 kernel_id[:, :, center, center] = 1 kernel_id = self._fuse_bn_to_conv(kernel_id, self.bn_identity) fused_kernel += kernel_id fused_bias = self.bn3.bias + self.bn1.bias if self.bn_identity is not None: fused_bias += self.bn_identity.bias return fused_kernel, fused_bias这里最需要注意的是 stride 不为 1 或者通道数变化时,恒等分支不可用。我的做法是下采样层直接用 3x3 卷积配合 stride=2,不做恒等映射,这是原论文的标准处理。还有一点,重参数化后的 BatchNorm 全部消失,如果还想在部署前做训练后量化,需要在重参数化前先完成量化感知训练,否则精度掉幅会很大。
4. 融合架构的完整设计与实操实现
4.1 整体网络结构设计
我现在把两部分技术组合到 YOLOv8 上的完整结构画出来。整体分成三段:Backbone、Neck、Head。Backbone 采用 RepVGG 风格改造的 C2f 结构,用于提取多尺度特征;Neck 采用 BiFPN 结构,将 P3、P4、P5 三层特征做双向加权融合;Head 沿用 YOLOv8 的解耦检测头,分类分支和回归分支分开输出。
改造后的参数量相比原版 YOLOv8 会有一定增长。实测数据如下表,数值来自我在某硬件平台上的实际统计:
| 模型变体 | 参数量(M) | mAP50 | mAP50-95 | FPS(单卡) |
|---|---|---|---|---|
| YOLOv8n 原版 | 3.2 | 0.371 | 0.523 | 178 |
| YOLOv8n + BiFPN | 4.1 | 0.388 | 0.547 | 151 |
| YOLOv8n + BiFPN + RepVGG | 5.0 | 0.402 | 0.572 | 143 |
| YOLOv8s 原版 | 11.2 | 0.449 | 0.601 | 129 |
| YOLOv8s + BiFPN + RepVGG | 15.8 | 0.476 | 0.638 | 112 |
从表格可以看到,融合后的模型在精度上有明显提升,但推理速度有一定下降。这里给出的 mAP 值是在 COCO 风格数据集上的相对表现,不同项目数据集差异较大,核心结论是:RepVGG + BiFPN 带来的收益约在 2 到 3 个点 mAP50-95 左右,代价是约 20% 到 30% 的推理延迟增加。
如果项目对速度要求极高,我建议先只上 BiFPN,精度提升就已经很可观,RepVGG 可以留着做训练阶段的 DNN 蒸馏教师模型。
4.2 关键代码实现与训练配置
完整的训练配置我在 ultralytics 体系内做修改,核心思路是自定义模型配置 yaml 文件,指向改造后的模块。训练超参数我用的是:批量大小 16,初始学习率 0.01,使用 SGD 优化器,权重衰减 0.0005,动量 0.937,总共训练 300 epoch。输入分辨率 640x640,使用马赛克增强、随机仿射变换和 HSV 色域增强。
SGD 是我在目标检测任务中的固定选择,虽然在分类任务上 Adam 系优化器很流行,但检测任务里 SGD 配合余弦退火的收敛稳定性更好,尤其在多分支结构下,SGD 的梯度更新更平滑。学习率设置上,前 3 个 epoch 用 warmup 从 0.0001 线性升到 0.01,后续用余弦退火衰减到 0.0001。权重衰减不能设太高,否则多分支的卷积核会被过度压缩,导致重参数化后精度下降。
训练过程中我还做了两阶段策略:前 250 epoch 正常训练多分支结构,最后 50 epoch 将重参数化后的模型做微调。这个微调阶段的学习率设为初始学习率的 0.01 倍,只更新重参数化后的单分支卷积核。这个细节是我在一次消融实验中发现的,做最后 50 轮微调后 mAP50-95 又涨了 0.4 个点,相当于白捡的精度提升。
4.3 模型导出与推理测试
训练完成后,导出模型需要走一遍重参数化流程。我写了一个转换脚本,遍历整个模型结构,把所有 RepVGGBlock 替换为普通卷积层,同时把 BiFPN 的融合权重固化下来。转换后的模型在推理阶段不再包含任何多分支计算,因此可以直接用 ONNX Runtime 或者 TensorRT 做加速。
TensorRT 导出时的布局优化相当重要,由于 RepVGG 重参数化后是直筒结构,TensorRT 的层融合效果明显优于多分支结构,实际测量中 FP16 精度的推理延迟比 PyTorch 下降了约 40%,这是一个不小的收益。如果你的部署环境是 CPU,重参数化结构也有优势,单路的缓存友好性比多分支好很多,在无 GPU 的边缘设备上也能跑出不错的帧率。
5. 实测效果与踩坑记录
5.1 精度收益的来源分析
我在两个数据集上做了对比测试,一个是通用场景数据集,另一个是小目标占比高的工业质检数据集。通用场景下,融合模型相比原版 YOLOv8 在 mAP50-95 上提升约 2.8 个点;小目标数据集上提升幅度更大,达到了 4.1 个点。之所以小目标场景收益更明显,是因为 BiFPN 让浅层的高分辨率特征得到了更深层语义的加权引导,小目标的纹理细节不再被简单噪声淹没。
RepVGG 的收益则更多体现在中大型目标上。多分支训练让 Backbone 的每层特征更有判别力,尤其对于相似纹理的类别(比如不同类型包装盒),分类头拿到的特征更分得开。两者叠加,正好互相补位。
5.2 推理速度与部署成本怎么权衡
关于速度,我想多说一句:不要只看 FPS 的百分比降幅,要综合硬件平台和目标需求来看。我的测试平台上原版 YOLOv8s 跑 129 FPS,融合后降到 112 FPS,这个速度损失在很多真实场景中完全可以接受。尤其是当你使用 TensorRT 推理后,重参数化的直筒结构反而能抹平一部分速度差距。
如果你做的是边缘设备部署,建议直接用重参数化后的 ONNX 模型做 INT8 量化,量化掉点通常在 1 个点左右。如果直接量化未重参数化的多分支模型,掉点可能超过 3 个点,因为多分支的 BatchNorm 和动态权重会对量化产生更大的误差累积,这是我在一次边缘设备测试中踩过的实实在在的坑。
5.3 常见问题与排查技巧实录
下面几个问题都是我在实践过程中实际遇到并解决的,特意整理成速查表供你参考:
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 训练 loss 在 100 轮后震荡严重 | 融合权重初始化不当,梯度冲突 | 初始值设为 1,并增加权重 L2 约束 |
| 推理精度比训练时降低 5 个百分点以上 | 重参数化代码有 bug | 检查恒等分支的卷积核构造方式是否正确 |
| 显存不足,batch size 只能设 4 | BiFPN 重复堆叠次数过多 | 减少重复层次或梯度累积训练 |
| 小目标检测无明显提升 | 输入分辨率过低 | 将分辨率从 640 提升到 960 |
| 模型导出 ONNX 时报警数据不匹配 | 融合权重被转成常量后的 shape 不一致 | 在导出前固定网络为 eval 模式并检查输入尺寸 |
| RepVGG 微调阶段精度下降 | 学习率过高 | 学习率降到初始值的 0.01,并延长到 50 epoch |
这里单独说一下重参数化精度回退排查。我经历过一次很诡异的情况:训练时 mAP50 到 0.55,重参数化之后直接掉到 0.31。逐层检查后发现问题出在恒等分支的卷积核构造上,有人实现时会写成每个输入通道对应同一位置为 1,正确写法是单位矩阵的排列,确保每个输入通道只映射到对应输出通道。别看这个细节小,错了之后整个特征流全乱了,精度自然断崖式下跌。因此我建议转换完成后,选取少量测试图像逐层对比重参数化前后的特征输出值,差异应在 1e-4 以下,超过这个量级就说明实现有误。
6. 这套融合方案后续还能怎么玩
这部分分享一些我在项目迭代中总结出的扩展思路。BiFPN 的加权融合思路不只适用于目标检测,你把它嫁接到语义分割或者实例分割的 Neck 上,同样能带来收益,因为分割任务同样面临多尺度特征的融合问题。RepVGG 的价值也不局限在 Backbone,它可以作为任何 CNN 模块的训练时增强结构,只要注意 stride 和通道数变化时的恒等分支取舍即可。
如果你想进一步提升检测精度,可以把 BiFPN 的重复层数加到 4 层,并在融合权重上使用多种约束组合,比如配合权重共享策略来减少参数量。实际上我们又在边端设备上试过一种更激进的方案,将融合路径的全部普通卷积替换为深度可分离卷积,精度只掉了极少,但推理速度又追回了原版水平。
根据我个人经验,这类结构改造最怕的就是一步到位想要所有指标同时提升。精度、速度、显存三者本质是一个利弊平衡问题,最好是先锁定一个目标指标,比如“mAP50-95 必须超过 0.60”,再在这个约束下寻找最优的结构取舍。我在每次实验前会先写清楚预期收益和可接受的代价,这样在调试时才有明确的判断基准,也会显著少走弯路。