把 YOLOv8 的主干换成 EfficientNetV2,这件事听起来只是改一行配置,真做起来会发现牵一发动全身。我在优化某图像处理 Demo 时,因为模型体积和推理帧率卡得死,原版 CSPDarknet 那套 C2f 堆叠在端侧怎么调都不够轻,于是把 EfficientNetV2 的主干拆出来重接,中间还做了一轮均质化设计——把送给 FPN 的几层特征在通道尺度上重新对齐,让上下采样路径不再被“一头粗一头细”的维度差拖慢。这个项目跑通之后,检测精度没掉,模型体量下来一截,帧率也有实打实的提升。这篇就把主干重构的思路、代码实现和调试经验完整记录下来,适合对 YOLOv8 内部结构有一定了解、想在不动检测头的前提下优化骨干部分的同学参考。
1. 为什么要换主干:EfficientNetV2 的三板斧
1.1 不是换 ResNet50,先看清 YOLOv8 原主干的瓶颈
很多优化新手一提到换主干就想到 ResNet、MobileNet,忽略了 YOLOv8 原生的 CSPDarknet 其实已经相当紧凑。它的问题主要不在层数深,而在于 C2f 模块里反复使用标准卷积做特征融合,计算密度高、参数利用率一般,尤其在需要部署到中低端设备时,瓶颈就很明显。
EfficientNetV2 不一样。它本身就是冲着“分类精度和训练效率同时优化”来的,核心设计一个是 MBConv 的深度可分离结构,一个是用 Fused-MBConv 把早期阶段的深度卷积替换成普通卷积,从而在浅层用稠密计算换取信息保留,在深层用深度可分离计算换取参数压缩。这种“前密后疏”的节奏,恰好适合检测任务里“浅层出边界、深层出语义”的需求,比整条路全用 depthwise 更稳。
另外,EfficientNetV2 对 GPU 的算子友好度比第一代高。老一代 EfficientNet 大量使用深度卷积,在 GPU 上容易变成访存瓶颈,EfficientNetV2 特意把早期几层改成 Fused-MBConv,就是在工程实现层面对速度做了妥协。这一点在我实测时非常明显,同样的 batch 下,重构后的主干比原主干更容易吃满算力。
1.2 Fused-MBConv 和 MBConv 在主干里各管什么
Fused-MBConv 的结构可以理解成“把两个小卷积融合成一个大卷积”。它先做通道扩展,再用一个 stride 可能为 2 的普通 3×3 卷积去同时完成空间下采样和通道变换,整个过程没有 depthwise。因为浅层特征图分辨率高、通道少,用普通卷积不会带来太大参数压力,反而能保留更多细粒度信息,这对后续检测头判断小目标特别重要。
MBConv 则是经典的深度可分离路径:先用 1×1 扩展通道,再逐通道做 3×3 卷积,然后压缩通道。这一段的优势是参数和计算量成倍下降,适合放在深层高语义区域。深层特征图已经很小,空间细节不那么关键,模型更需要的是把语义抽象出来,这时候用 MBConv 非常划算。
所以一个常见的重构策略是:浅层 P3 对应 Fused-MBConv,中层 P4 做混合过渡,深层 P5 用 MBConv。这样既能享受浅层细节,又能让深层参数可控,整个骨干的“性格”会比较平衡。
1.3 SE 模块与 DropPath 是原主干缺失的调节阀
EfficientNetV2 在每个 MBConv 里都塞了一个 Squeeze-and-Excitation 模块,本质就是一个小型的通道注意力:先全局池化把空间信息压成一个点,再通过两个 1×1 卷积学出各通道的重要性,最后乘回原特征图。这个模块在 YOLOv8 的默认主干里没有,换上之后我发现它对低对比度目标的召回率提升有帮助,相当于给模型加了一个“自动调节亮度权重”的旋钮。
还有 DropPath,它是在残差分支上随机丢一整条路径,而不是像 Dropout 那样丢单个神经元。这个机制让深层网络在训练时更抗过拟合。但要注意,DropPath 概率设高会导致训练初期特征不稳,我会在训练策略部分细说。
2. 均质化设计:重构前最重要的思路
2.1 均质化到底在均什么
我把这次的均质化设计总结成一句话:让 backbone 送到 FPN 的三层特征,在通道数量、下采样节奏和计算量上都保持相对一致的比例,避免某一层特征“身板太单薄”。原版 EfficientNetV2 是给分类设计的,最后只会输出一个 7×7 左右的全局特征。做检测时我们要截取多个 stage,如果直接照抄原配置,很可能会遇到这样的情况:第一层输出通道特别小,第二层突然变大,FPN 上下采样时不得不频繁用 1×1 卷积调整维度,白白增加参数和延迟。
均质化并不是把通道数强行拉成完全一样,那样既浪费计算也不符合特征金字塔的层次逻辑。我用的方案是让三个输出 stage 的通道数保持 1:2:3 或者 1:2:4 这种平滑递进关系,并且保证相邻 stage 之间做通道变换时只经过一层轻量卷积。这样 FPN 的 lateral connection 不需要反复升维降维,梯度回传也更顺畅。
2.2 P3/P4/P5 怎么选:裁剪 stage 与通道对齐
在输入 640×640 的前提下,检测网络通常需要 1/8、1/16、1/32 三档特征图。EfficientNetV2 原网络从 stem 开始逐步下采样,如果直接取后几层,下采样节奏不一定恰好落在目标尺度上。所以要重新规划:stem 先用两个 stride 为 2 的卷积把分辨率降到 1/4,再通过三个残差 stage 依次降到 1/8、1/16、1/32,分别作为 P3、P4、P5。
通道对齐的做法是,先根据目标算力选定最深层输出通道,比如 384,然后往上一级给它配一半的宽 192,再上一级配 128,形成一个 128:192:384 的均质比例。这个数字不需要是 2 的整次幂,只要让 PANet 内部各层计算负载均匀即可。如果设备更抠内存,可以用 96:160:320;如果追求精度,可以整体放大到 160:320:640。
2.3 为什么不让 EfficientNetV2 最后的分类头参与检测
原版 EfficientNetV2 在主干最后还挂着若干层 MBConv 和一个全局池化层,这些是为分类服务的。目标检测只需要高层特征金字塔,如果把最后那个超大通道的 7×7 层也硬接进 PANet,看起来是“用了更强的语义”,实际上会让 P5 特征过于抽象,小目标细节几乎损失殆尽,同时这层网络还特别重,拖慢整个模型。
所以这次重构只保留了中间三档 stage,原网络中比 P5 更深的 MBConv 层全部砍掉。你可能会担心语义不够强,但实际上 PANet 本身自带高层信息回流路径,主干深到 1/32 分辨率已经完全够检测头用了。砍掉多余层级,换来的是更快的训练和推理,这笔账非常划算。
3. 核心代码实现:手写 EfficientV2 检测主干
3.1 基础模块代码:SE、Fused-MBConv 与 MBConv
先说最基础的卷积块。为了简洁,我把卷积、BN、SiLU 封装成一个类,后面所有模块都复用它。这里所有代码都基于 PyTorch 风格编写,迁移到其他深度学习框架时思路完全一致。
import torch import torch.nn as nn class Conv(nn.Module): def __init__(self, c_in, c_out, k, s=1, groups=1, act=True): super().__init__() self.conv = nn.Conv2d(c_in, c_out, k, s, k // 2, groups=groups, bias=False) self.bn = nn.BatchNorm2d(c_out) self.act = nn.SiLU(inplace=True) if act else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x)))然后是 SE 模块。两个 1×1 卷积之间夹一个 SiLU 激活,最后用 Sigmoid 输出权重,再乘回输入特征。中间通道数通常取输入通道的 1/4,这样不会带来太多额外参数。
class SE(nn.Module): def __init__(self, c_in, se_ratio=0.25): super().__init__() mid = max(1, int(c_in * se_ratio)) self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Conv2d(c_in, mid, 1, bias=True), nn.SiLU(inplace=True), nn.Conv2d(mid, c_in, 1, bias=True), nn.Sigmoid(), ) def forward(self, x): return x * self.fc(self.pool(x))Fused-MBConv 的逻辑是:先 1×1 扩展通道(扩展比大于 1 时),再用普通 3×3 卷积做空间变换和可选的下采样,过 SE,最后 1×1 投影回输出通道。残差分支在下采样时有条件启用,分批进入标准实现。
class FusedMBConv(nn.Module): def __init__(self, c_in, c_out, k=3, s=1, expand_ratio=4, drop_path_rate=0.0): super().__init__() mid = c_in * expand_ratio self.use_res = (s == 1 and c_in == c_out) layers = [] if expand_ratio > 1: layers.append(Conv(c_in, mid, 1, 1)) layers.append(Conv(mid, mid, k, s, groups=1)) self.conv = nn.Sequential(*layers) self.se = SE(mid) self.project = nn.Sequential( nn.Conv2d(mid, c_out, 1, bias=False), nn.BatchNorm2d(c_out), ) self.act = nn.SiLU(inplace=True) self.drop_path = DropPath(drop_path_rate) def forward(self, x): y = self.project(self.se(self.conv(x))) y = self.act(y) if self.use_res: y = self.drop_path(y) + x return yMBConv 的区别在于把空间变换阶段换成了 depthwise 卷积,也就是 groups 等于通道数。它省参数的效果立竿见影,但需要配合 SE 来弥补逐通道卷积不看通道间关系的弱点。
class MBConv(nn.Module): def __init__(self, c_in, c_out, k=3, s=1, expand_ratio=4, drop_path_rate=0.0): super().__init__() mid = c_in * expand_ratio self.use_res = (s == 1 and c_in == c_out) layers = [] if expand_ratio > 1: layers.append(Conv(c_in, mid, 1, 1)) layers.append(Conv(mid, mid, k, s, groups=mid)) self.conv = nn.Sequential(*layers) self.se = SE(mid) self.project = nn.Sequential( nn.Conv2d(mid, c_out, 1, bias=False), nn.BatchNorm2d(c_out), ) self.act = nn.SiLU(inplace=True) self.drop_path = DropPath(drop_path_rate) def forward(self, x): y = self.project(self.se(self.conv(x))) y = self.act(y) if self.use_res: y = self.drop_path(y) + x return y其中 DropPath 你可以直接用开源工具库里的实现,也可以自己写一个简化版:训练时以概率丢掉残差分支,推理时恒等返回。注意 DropPath 只在残差分支存在时才有意义。
3.2 组装多尺度输出:EfficientV2Backbone
这一步的关键是 stage 内部要把第一个 block 的 stride 设为 2,后面所有 block 保持 stride 为 1。我采用的配置是 stem 输出 64 通道,然后三个 stage 分别输出 128、192、384 通道,正好对应 1/8、1/16、1/32 三档特征。
class EfficientV2Backbone(nn.Module): def __init__( self, widths=(128, 192, 384), depths=(2, 3, 4), expand_ratio=4, drop_path_rate=0.1, ): super().__init__() self.stem = nn.Sequential( Conv(3, 32, 3, 2), Conv(32, 64, 3, 2), ) c_in = 64 self.stage1 = self._make_stage( c_in, widths[0], depths[0], stride=2, block="fused", expand_ratio=expand_ratio ) self.stage2 = self._make_stage( widths[0], widths[1], depths[1], stride=2, block="fused", expand_ratio=expand_ratio ) self.stage3 = self._make_stage( widths[1], widths[2], depths[2], stride=2, block="mb", expand_ratio=expand_ratio ) self._init_weights() def _make_stage(self, c_in, c_out, depth, stride, block, expand_ratio): blocks = [] for i in range(depth): s = stride if i == 0 else 1 c_cur = c_in if i == 0 else c_out if block == "fused": blocks.append(FusedMBConv(c_cur, c_out, 3, s, expand_ratio)) else: blocks.append(MBConv(c_cur, c_out, 3, s, expand_ratio)) return nn.Sequential(*blocks) def forward(self, x): x = self.stem(x) p3 = self.stage1(x) p4 = self.stage2(p3) p5 = self.stage3(p4) return p3, p4, p5最后一层用 MBConv 而不是 Fused-MBConv,是因为 P5 分辨率已经降到 20×20,普通卷积也不贵,但 depthwise 更适合长期承担高层特征提取。你也可以把 P4 也换成 MBConv,完全看算力盘子。
3.3 接入 YOLOv8 的 yaml 配置
不同版本的 YOLOv8 实现框架在模型注册方式上有点差异,但思路是一致的:先把自定义 Backbone 类注册进模块表,然后在网络配置文件里把原来的 backbone 段落替换成一个节点,输出多尺度特征,后面 head 照常接收 p3、p4、p5。
以我常用的改进方式为例,yaml 大致长这样:
# my_yolov8_e2v.yaml nc: 80 backbone: - [-1, 1, EfficientV2Backbone, [128, 192, 384]] head: # 以下为 PANet 与 Detect 头,具体节点需与框架版本匹配 - [-1, 1, nn.Upsample, [None, 2, "nearest"]] ...如果框架的 parse_model 不认自定义类,需要去 nn/tasks.py 里把 EfficientV2Backbone 加进MODEL_REGISTRY或对应的自定义模块映射表。这个步骤各家实现不同,但原理都一样:让构建器能根据字符串实例化这个类。
接入后建议先做一次单张前向,打印所有中间 tensor 的尺寸,确认 p3、p4、p5 的分辨率分别是输入的 1/8、1/16、1/32。这一步能省下后续大量排查时间。
4. 训练调参与验证:不只是换一个 Backbone 而已
4.1 分辨率、EMA 与预训练权重:别让预训练白费
EfficientNetV2 原生的预训练权重是针对分类任务在 224×224 或 300×300 分辨率下练出来的。检测任务直接用 640×640 输入时,stem 和浅层卷积的感受野预期会发生偏移,所以我的做法是:加载预训练权重时丢弃最后的分类层,同时把卷积层的初始化保留下来,前几个 epoch 先用较低学习率热热身,让网络把“分类特征”逐步转成“检测特征”。
EMA 最好从一开始就打开。EfficientNetV2 的 Bneck 结构配合 EMA 权重平滑后,验证集的 mAP 波动会明显减小。我试过不开 EMA 跑同样的实验,后期 loss 曲线毛刺非常多,最后几十个 epoch 反复震荡;开了 EMA 之后,模型收敛路径稳定很多。
4.2 Loss 与标签分配:YOLOv8 默认配置要不要改
YOLOv8 的默认配置是 Distribution Focal Loss 加 CIoU,标签分配策略也已经很成熟。换主干不换检测头的情况下,这两个部分基本不用动。唯一要考虑的是主干输出的特征图通道数变化后,是否影响损失权重。实际测试下来,只要 P3/P4/P5 的通道比例不过分夸张,默认 loss 权重可以直接迁移。
如果你把均质化后的通道数做得特别极端,比如 P3 只有 32 通道、P5 却有 512 通道,那 P3 的特征表达力会明显不足,小目标 loss 就压不下去。这时要么提升 P3 通道数,要么在 loss 里给 P3 分支加权重。我的建议始终是回到设计阶段,把通道比例调均衡,而不是在 loss 上调参掩盖结构问题。
4.3 数据增强节奏:Mosaic 不是越多越好
EfficientNetV2 本身在训练分类模型时讲究渐进式学习,先小分辨率后大分辨率,数据增强强度也是从弱到强。借鉴这个思路,我在检测训练里做了两段式增强:前 30 个 epoch 只开基础的平移、翻转和 HSV 扰动,让主干先适应检测任务;从第 30 个 epoch 开始再逐步加入 Mosaic 和 MixUp。
很多人一上来就全开 Mosaic,结果发现 loss 很难降下去,问题就出在主干刚开始学习检测特征时,被过度强烈的合成图像干扰了。Mosaic 确实能扩大有效 batch 的多样性,但它的收益是在模型有一定基础之后才会放大。具体在哪个 epoch 切换,取决于数据集大小,我一般看前 20 个 epoch 的 mAP 曲线是否已经平稳,再决定是否开启。
5. 常见问题与排查实录
5.1 特征图尺度对不上:backbone 输出怎么都对不上 Neck
最常踩的坑就是 forward 出来的特征图分辨率不是 1/8、1/16、1/32。这通常不是代码写错,而是 stem 的下采样倍数和 stage 的 stride 设计没算清楚。比如 stem 如果没有降到 1/4,那么后面三个 stride=2 的 stage 总共只能降到 1/16,P3 实际变成了 1/8 但 P5 只有 1/16,整个金字塔少了一档。
排查方法是固定输入尺寸,在 backbone 的 forward 里临时打印每个输出层的 shape。我习惯写一个 debug 函数,把输入设为 640×640,看 p3 是不是 80×80、p4 是不是 40×40、p5 是不是 20×20。只要这一步对了,后面 Neck 的接法基本不会出大问题。
5.2 Loss 飙升或训练初期直接崩掉
换了 EfficientNetV2 主干之后,训练初期 loss 偶尔会出现冲击,甚至直接变 NaN。这种情况我遇到两回,一次是 DropPath 概率给到 0.3,训练早期的残差路径被随机丢弃太多,梯度信号大幅度震荡;另一次是 BN 的 momentum 跟预训练权重不一致,导致前几轮 batch 统计量剧烈变化。
解决起来不难:DropPath 从 0.05 起步,等模型稳定后再在某个 epoch 提到目标值;BN 的 momentum 保持框架默认,不要手动改小。如果 loss 已经 NaN,先把学习率降到原来的 1/10,验证数据增强里有没有极端裁剪导致 gt 标注失效,比如目标被切到只剩 1% 像素,这种样本在 Mosaic 全开时容易出现。
5.3 参数变少、FPS 却不升反降
我最初也遇到一个反直觉的情况:重构后的主干参数比原来少,GFLOPs 也低,但端侧推理帧率没有提升,甚至下降。排了半天发现原因是 MBConv 的 depthwise 卷积在部分硬件上的算子优化不够好,大量小卷积串行执行反而拉高了访存延迟。
解决办法有两个方向。一是把浅层和 P4 层全部换成 Fused-MBConv,减少 depthwise 的占比,只保留最深一档使用 MBConv;二是在推理阶段打开训练框架的 channels_last 内存格式和算子融合选项。如果部署环境支持,还可以对 BN 做卷积融合,能省掉不少逐层边界开销。
6. 实测效果与个人经验总结
6.1 设计对比实验时最容易被忽略的两个变量
评估主干重构效果时,很多人只对比了 mAP,忽略了两个关键变量:训练轮数和数据增强强度。EfficientNetV2 风格的主干收敛特性跟 CSPDarknet 不一样,前者初期收敛慢但后期潜力足,后者往往很快就到平台期。如果两者都只训同样固定轮数,很容易得出“换主干反而掉点”的错误结论。
我在某个中等规模检测数据集上做了对比,YOLOv8s 原始结构的参数量和计算量都偏高,但小目标表现稳定;切换到均质化主干的版本后,模型体积下降约两成,训练到同平台期所需轮数多了 10% 左右,最终 mAP 基本持平,推理速度在特定部署环境下有接近 30% 的提升。这个数字只是参考,具体效果高度依赖数据集分布和算子优化情况。
另一件容易被忽略的事是输入分辨率的对齐。主干下采样节奏不同,模型对分辨率变化的敏感度也不同,换主干后至少要在 512、640、768 三档分辨率上各跑一遍,才能判断到底是主干带来的收益,还是仅仅因为分辨率起了变化。
6.2 我的几条心得与可扩展方向
做完整轮重构,我最想强调的还是均质化的价值,它不只是一个简单的通道对齐操作,更是重新审视 FPGA 计算负载分布的机会。EfficientNetV2 的结构本身带有浓重的 NAS 搜索痕迹,直接搬过来会显得很“碎”,但把它裁成均质的三段式检测主干后,整个模型在计算布局上清爽了很多,后续做结构化剪枝也更好下手。
如果未来继续扩展,我会考虑在 P3 和 P4 之间加一层轻量特征融合模块,把小目标的上下文信息提前聚合一次,再喂给 PANet。也可以在均质化基础上做自动搜索,用很小的算力把每档通道数和 depth 做成可搜索维度。相比直接换更重的检测头,这条方向对产业落地要友好得多。