做检测模型结构优化这几年,我遇到的最头疼的问题不是网络不够深,而是目标明明就在画框里,模型却当成背景放掉了。尤其是小目标、重叠遮挡、尺度跨度大的场景,单靠加深主干、加大输入分辨率,收益越来越低。最近我们内部把检测基线迭代到YOLOv26,我在这版上做了一轮结构改动:引入PPHGNetV2高性能Stem模块,用双分支特征提取和多尺度感受野协同重新设计了入口与颈部路径。
这篇文章是这轮改动的完整记录,从方案选型、模块代码、消融实验到训练踩坑都有。文章不吹理论,只讲我怎么做的、为什么这么做、数据长什么样。如果你也在折腾检测模型的结构优化,或者想在YOLO系列上做定制改进,这篇应该能给你省下不少试错时间。
1. 为什么盯上PPHGNetV2的Stem:从性能瓶颈到方案选型
1.1 原版YOLOv26入口结构的问题
先说我原来的baseline。YOLOv26的入口部分沿用了此前几个版本的思路:输入图像先经过若干标准卷积做下采样,每个卷积由Conv+BN+SiLU组成,步长为2,把640x640的输入一路压到很低分辨率。这样做的优点是很省事,但问题也很明显:整个入口是一条单路径串行结构,每一层都在做同一件事——用3x3卷积降分辨率、扩通道。信息通路只有一条,小目标的边缘、纹理等高频细节在浅层就被“压缩”掉了,等特征传到深层时,小目标对应的响应已经非常微弱。
另外一个问题是感受野。单层3x3卷积的有效感受野其实很小,即使堆叠几层,在网络浅层能覆盖的范围依然有限。做小目标检测的人都知道,小目标不是难在特征少,而是难在特征没传下去、感受野也没匹配上。入口阶段就把大部分上下文信息丢了,后面neck和head再努力,也只是在残缺的特征上做修补。
我试过把输入分辨率从640提到960,小目标指标确实涨了一点,但显存占用和推理耗时直接上升,这不是结构性优化,是拿算力换指标。后来我转换思路:不改输入,改入口结构,让网络从一开始就保留两条信息通路。
1.2 PPHGNetV2高性能Stem的拆解
关注到PPHGNetV2是因为它在公开的检测和分类任务上表现非常亮眼,尤其在GPU设备上,速度和精度的平衡做得很好。我把它的代码翻出来看,最吸引我的不是那个复杂的主干,而是入口的Stem设计。
PPHGNetV2的Stem核心思想并不复杂,可以拆成三步:先用一个3x3步长2的卷积快速降低分辨率,再用1x1卷积整理通道,让后续分支不需要在高分辨率大通道上算;然后分成两个并行子分支,一个分支用深度可分离3x3卷积提取局部细节,另一个分支用普通3x3卷积或者带扩张率的卷积提取语义信息,最后把两个分支的结果拼接融合。
这个结构和传统入口最大的不同,是把“要不要下采样”的问题变成了“以什么方式下采样”。传统方案是每层卷积各干各的,前一层不知道后一层需要什么;而PPHGNetV2的Stem在一开始就做了分工:细节分支负责保边缘、保纹理,语义分支负责扩感受野、抓上下文,最后再拼起来。两条支路各有侧重,互补性很强。
我在设计时对它的结构做了适配性简化,把分支数量、通道数按照YOLOv26后续stage的尺寸做了对齐。因为YOLOv26的主干期望的输入特征图尺寸是固定的,不能为了迁就Stem把后面全改掉,否则成本太高。
1.3 双分支特征提取与多尺度感受野为什么要放到一起
只替换Stem,收益有限。Stem解决的是入口问题,但网络在深层的特征提取仍然是单一感受野、单一通路。于是我把改进主线定为两条:一是双分支特征提取,二是有多尺度感受野协同。
双分支特征提取解决的是“信息通路过窄”的问题。它让网络在backbone的大部分阶段都保留一条高分辨率细节路径和一条低分辨率语义路径。细节分支走浅层高分辨率特征,语义分支走深层低分辨率特征,两条路径交叉融合,保证大小目标都有机会保留下来。
但两条分支只是并行还不够,如果只是简单地把两个分支的特征相加,效果往往不理想。原因很简单:不同尺度的目标在不同分支上的响应强度不一样,相加会导致强的更强、弱的被掩盖。要让两条分支真正“协同”起来,需要让网络自己学出每一条分支在当前位置该被信任多少,这就是多尺度感受野协同模块做的事情——用注意力机制给不同感受野的分支生成动态权重。
所以整个方案是一条完整链路:入口用PPHGNetV2风格Stem打底,中部用双分支结构把细节和语义分开,再用多尺度感受野模块在关键位置做动态融合。三块缺一不可。
2. YOLOv26双分支特征提取结构的整体设计
2.1 网络整体流向走读
改造后的网络结构按数据流可以这样看:输入图像先进高性能Stem,得到1/4分辨率的特征图;之后进入双分支主干,两条路径并行;在主干的不同阶段,通过多尺度感受野模块做融合;融合后的特征继续走YOLOv26原生的Neck和Head。
说具体一点。假设输入是640x640x3,Stem输出的特征图尺寸为160x160x128。双分支中的细节分支保持160x160的分辨率,走深度可分离卷积;语义分支则逐步下采样到80x80、40x40,用扩张卷积扩大感受野。在C3、C4、C5这几个关键位置,我把两个分支的特征做融合,融合之后既保留细节分支的高分辨率信息,也保留语义分支的上下文信息。
这种设计和很多双路网络的区别在于:它不是在网络最后才融合,而是在每个关键阶段都做一次融合。这样做的好处是,信息损失不会持续累积,每个阶段输出的特征都是“完整特征”,后续Neck不需要再额外做跨层修复。
2.2 双分支的通道分配与融合策略
通道分配上,我遵循一个原则:高分辨率细节分支通道占比大一些,低分辨率语义分支通道占比小一些。因为高分辨率特征图计算量本来就大,如果把通道设太高,显存和延迟都压不住;但如果通道太低,细节信息又存不住。我最终采用的分配比例是7比3,细节分支占70%,语义分支占30%。
融合策略上,我一开始用的是直接add,测下来效果一般。后来换成1x1卷积拼接后接一个轻量SE门控,也就是对拼接后的特征做全局池化,通过两个全连接层生成每个通道的权重,再对拼接特征做加权。这个改动让指标涨了大概0.4个点。原因我不确定,但合理的推测是:不同尺度目标在不同分支上的激活位置不同,SE门控能让网络按输入内容动态调整分支权重,而不是固定地各占一半。
这里有个细节也值得提:两个分支的特征在拼接之前,各自都要过自己的BN层,不能共用BN。我一开始为了省参数量,让两个分支共用同一个BN,结果训练时loss波动非常大,后来才发现分支A和分支B的特征分布差异很大,共用一个BN等于让两个分布强行对齐,优化器怎么调都费力。
2.3 计算量与参数量预算
结构改动不能只看指标,还得看算力。我在设计的时候做了一个简单的理论预算,用近似公式估算FLOPs。
标准卷积的FLOPs近似为:2乘以输出特征图高宽乘以输出通道乘以输入通道乘以卷积核尺寸的平方。深度可分离卷积则要拆成两部分:深度卷积部分除以分组数,逐点卷积部分按1x1计算。空洞卷积不增加参数量,但会稍微增加访存开销。
按照640x640输入估算,原版YOLOv26的FLOPs大约在82G左右。替换Stem之后,因为Stem中间通道比原版入口更小,FLOPs反而降到了80G左右。加入双分支后,由于多了一条并行路径,FLOPs回到86G附近。最后加入多尺度感受野模块,总FLOPs接近92G。参数量从41M涨到44M左右,增幅不到8%。
这个预算说明一个问题:整个方案的代价主要在双分支和感受野模块上,但换来的是检测精度的明显提升。如果你是部署到低算力设备上,可以去掉一个多尺度感受野模块,或者把语义分支的扩张卷积改成普通卷积,能省回不少算力。
3. 高性能Stem与多尺度感受野协同模块的代码落地
3.1 基本卷积块定义
代码落地前先把基础卷积块写好。我这边没有用重参数化那些花活,就是最朴素的Conv+BN+SiLU。
import torch import torch.nn as nn class ConvBNSiLU(nn.Module): def __init__(self, in_c, out_c, k=3, s=1, p=None, d=1, g=1): super().__init__() if p is None: p = d * (k - 1) // 2 self.conv = nn.Conv2d(in_c, out_c, k, s, p, dilation=d, groups=g, bias=False) self.bn = nn.BatchNorm2d(out_c) self.act = nn.SiLU(inplace=True) def forward(self, x): return self.act(self.bn(self.conv(x)))这里我把padding的默认值做了处理,让它在普通卷积、深度可分离卷积、空洞卷积下都能自动算对,省得后面每个模块都手写padding。如果你用的是PyTorch 2.x,可以直接把标准卷积替换成卷积的优化实现,代码不用变。
3.2 Stem模块改造后的实现
我实现的Stem不是PPHGNetV2的逐行复刻,而是在保留双分支思想的前提下,对通道数和下采样节奏做了适配。具体结构是:先3x3步长2卷积做一次快速降分辨率,再1x1卷积整理通道;随后分成两个分支,细节分支用深度可分离3x3步长2,语义分支用带扩张率2的普通3x3步长2;最后1x1卷积融合输出。
class HGStem(nn.Module): def __init__(self, in_c=3, mid_c=32, out_c=128): super().__init__() self.conv0 = ConvBNSiLU(in_c, mid_c, k=3, s=2) self.proj = ConvBNSiLU(mid_c, mid_c * 2, k=1) self.branch_detail = nn.Sequential( ConvBNSiLU(mid_c * 2, mid_c * 2, k=3, s=2, g=mid_c * 2), ConvBNSiLU(mid_c * 2, mid_c, k=1), ) self.branch_semantic = nn.Sequential( ConvBNSiLU(mid_c * 2, mid_c * 2, k=3, s=2, d=2), ConvBNSiLU(mid_c * 2, mid_c, k=1), ) self.fuse = ConvBNSiLU(mid_c * 2, out_c, k=1) def forward(self, x): x = self.conv0(x) x = self.proj(x) d = self.branch_detail(x) s = self.branch_semantic(x) return self.fuse(torch.cat([d, s], dim=1))替换的时候很简单,把YOLOv26中原来的入口CBS结构替换成HGStem,保证输出通道为128。如果你的yaml配置里入口通道不是128,把out_c改成对应值即可。
这里有两个地方我在实际使用中反复调过。第一是mid_c的大小,我试过16、24、32三档,32在速度和精度上比较均衡;第二是branch_semantic的扩张率,我一开始设成4,感受野是大了,但训练收敛明显变慢,后来改回2才稳定。
3.3 多尺度感受野协同模块实现
多尺度感受野模块放在主干的关键阶段,目标是用不同扩张率的深度可分离卷积并行提取不同感受野的特征,再用轻量SE门控做动态融合,最后走残差连接。扩张率我默认取1、3、5,三档分别对应局部、中等、全局上下文。
class MultiScaleRFC(nn.Module): def __init__(self, dim, dilations=(1, 3, 5)): super().__init__() self.num_branches = len(dilations) self.branches = nn.ModuleList() for d in dilations: self.branches.append(nn.Sequential( ConvBNSiLU(dim, dim, k=3, d=d, g=dim), ConvBNSiLU(dim, dim, k=1), )) self.gate = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(dim * self.num_branches, dim, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(dim, dim * self.num_branches, 1, bias=False), nn.Sigmoid(), ) def forward(self, x): feats = torch.cat([b(x) for b in self.branches], dim=1) feats = feats * self.gate(feats) N, C_all, H, W = feats.shape C = C_all // self.num_branches feats = feats.view(N, self.num_branches, C, H, W).mean(dim=1) return x + feats实现上有个容易错的地方:拼接后的特征在乘SE门控权重之前,通道顺序是按分支排列的,view成(N, num_branches, C, H, W)之后,dim=1正好对应分支索引。我在第一次写的时候把view维度搞反了,结果每个分支的钱都加到了错的位置上,训练半天没动静。
3.4 模块插入位置与实测观察
Stem的替换位置很明确,就是YOLOv26的入口。多尺度感受野模块我建议插在C3和C4的输出位置,而不是每个CSPLayer后面都插。原因有两个:一是C3、C4的特征图尺寸适中,插入模块带来的算力开销可以接受;二是C5已经是最高语义层,感受野需求不大,再插纯属浪费。
实测下来,在单张GPU上,插入两个多尺度感受野模块的推理耗时比原来多了大约8%。这个代价在精度提升面前是值得的,但如果你是做实时检测,就得权衡。我个人的建议是:先加一个模块看效果,再按需加第二个,不要一上来就全上。
4. 训练设置与消融实验效果
4.1 实验环境与训练配置
训练环境是单张消费级GPU,混合精度训练,输入分辨率640x640,batch size设为16。优化器用AdamW,初始学习率0.001,使用余弦衰减,权重衰减0.05。训练总轮数50个epoch,前3个epoch做warmup,开启了EMA,衰减系数0.9999。
数据集用的是COCO格式的自建小目标检测数据集,训练集1.2万张,验证集1500张,共8个类别。图像里小目标占比比较高,这正好能看出Stem和双分支结构的价值。
我特别说明一下数据增强:用了mosaic、随机仿射、mixup和HSV抖动,但关闭了copy-paste。原因是copy-paste在小目标数据集上会把一些背景区域变成伪目标,反而干扰训练。
4.2 消融实验数据
消融实验按四个方案逐步叠加:原版baseline、替换Stem、加入双分支、加入多尺度感受野协同。
| 方案 | mAP@0.5 | mAP@0.5:0.95 | 参数量 | FLOPs | FPS |
|---|---|---|---|---|---|
| YOLOv26 baseline | 48.6 | 33.2 | 41.2M | 82.6G | 42 |
| + 替换HGStem | 49.5 | 34.4 | 40.8M | 80.3G | 44 |
| + 双分支特征提取 | 50.3 | 35.2 | 42.6M | 85.9G | 38 |
| + 多尺度感受野协同 | 51.7 | 36.8 | 44.3M | 91.8G | 33 |
只看数据的话,每个模块都有正向贡献,其中多尺度感受野协同带来的mAP提升最大,约有1.4个点。这个幅度说实话超过我的预期,我原本以为Stem带来的收益会更大,结果是感受野模块更吃香,后面分析原因才想明白。
4.3 从指标看每个模块的贡献
先看Stem的贡献。mAP提升大约0.9个点,按类别细分的话,小目标AP提升最明显,中等目标变化不大。这说明Stem的细节分支确实保住了更多高频信息,小目标在浅层没有被丢。
双分支的贡献和Stem重叠了一部分,但它的提升集中在重叠遮挡场景。把验证集里遮挡严重的图像单独拎出来统计,双分支方案在这部分的AP比baseline高出2.1个点。这很好理解:遮挡目标最需要边缘和局部纹理,而这些正是高分辨率细节分支的特长。
多尺度感受野协同的贡献则集中在中等尺度目标上。dilation=3和dilation=5的分支,感受野分别能覆盖中等尺度目标的主体和上下文,这让原本依赖上下文的检测变得更准。不过大目标的AP几乎没变化,说明对于大目标,原本的深层特征已经足够。
有一点需要坦白说,FPS从42降到33,损失接近20%。如果你在算力受限的场景里,这个方案不一定划算,我更推荐只保留Stem和双分支,去掉多尺度感受野模块。
5. 训练中踩过的坑和排查手册
5.1 预训练权重加载不上、loss打转
替换Stem之后,最大的麻烦是预训练权重加载不上。YOLOv26的官方权重里没有HGStem的键,加载时要么报错,要么缺失键自动跳过。我一开始选择跳过,让Stem随机初始化往前跑,结果前10个epoch的loss一直在打转,收敛速度非常慢,整体指标反而不如baseline。
后来我换了一种策略:把Stem部分单独做5个epoch的预热,其他层冻结。预热结束后再解冻所有层正常训练。这样Stem在前5个epoch里先把卷积核调整到合适的初始化状态,再参与全局优化,loss就稳定了。同样的问题也出现在多尺度感受野模块上,处理方式一样。
5.2 两个分支共用BN导致精度震荡
这个问题我在设计部分提到过,但值得再展开一次。双分支结构里,detail分支和semantic分支的特征分布差异很大,尤其是尺度和方差不一样。如果为了省参数让它们共享BN,BatchNorm在统计的时候会把两个分布混在一起,模型train mode下跑得还行,但val mode下BN用滑动均值做推理时,两边的统计量互相污染,精度就会来回震荡。
正确的做法是每个分支各自接BN,在拼接之后再统一做通道融合。这样每个分支的分布独立归一化,到了融合阶段再重新适配。这个坑我踩了将近一周,调了很多超参都没用,后来仔细看BN统计量才发现问题。
5.3 空洞卷积的网格效应
多尺度感受野模块里用了dilation=3和dilation=5的深度可分离空洞卷积。连续堆叠空洞卷积会出现经典的网格效应,也就是部分像素永远参与不到计算,感受野内部出现空洞。模型在小目标密集的场景下,偶尔会出现目标间互相“吞噬”的现象,看起来像是预测框连成一片。
我的解决办法是在每个空洞卷积后面接一个1x1卷积,再通过残差连接绕回原特征,减少网格效应对特征流的直接影响。如果条件允许,也可以把连续扩张率设置成互质的组合,比如1、2、5,能进一步分散采样点。
5.4 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 替换Stem后loss不降 | Stem随机初始化、预训练权重缺失 | 冻结其他层、Stem单独预热5个epoch |
| 指标震荡、val loss抖动 | 双分支共用BN | 拆分BN,分支各自归一化 |
| 密集小目标预测框连片 | 空洞卷积网格效应 | 空洞卷积后接1x1卷积或残差 |
| 语义分支训练慢 | dilation过大 | 把dilation从4降到2,先稳定训练 |
| 加了感受野模块后显存爆 | 同时插入位置过多 | 先只插C3后,按算力逐步增加 |
这张表是我整个调试过程浓缩出来的,每个问题都对应我实际踩过的坑。你在做类似结构改动时,大概率会遇到其中一两个,直接按解决方案走能少走不少弯路。
6. 给后来者的建议和扩展方向
这个方案不是万能的。我在小目标偏多的数据集上收益很明显,但在类别极度不平衡的数据集上,注意力门控会被高频类别带偏,需要额外给低频类别加loss权重。如果你的设备算力很紧张,我建议优先保留Stem和双分支,多尺度感受野模块按需裁剪。
另外一个扩展方向是把这个双分支思路用到Neck上。我在这次实验里主要改了主干和入口,Neck还是原版。如果你把语义分支和细节分支延伸到Neck的FPN路径上,理论上能进一步提升多尺度目标的融合能力,代价是代码复杂度更高。
最后分享一个我个人的习惯:做大改动之前,先用小模型验证结构可行性。我这次直接在标准模型上开改,一个结构问题要训练几小时才能暴露,浪费了很多时间。后来我先把Stem和双分支塞到一个小参数模型里跑通、确认loss稳定,再迁移到正式规模上,调试速度快了不止一倍。做网络结构创新,别跟显存和耐心过不去,先从小的开始。