做目标检测的同学应该都有同感:YOLO 这条技术路线的魅力在于,结构上的改动往往能非常直观地反映在精度和速度上。最近我和团队同事一直在调一套新骨架,项目代号习惯叫 YOLOv26,核心改动用一句话概括:在检测器骨干里加入多尺度卷积块和并行多核特征提取,再通过通道混洗把特征通道重新打通。这三样技术单独拎出来都不算新鲜,但组合到一起之后,确实换来了相当实在的检测性能提升。
这篇笔记想把整个设计思路和实现过程完整记录下来:从为什么要这样组合、模块内部怎么搭、如何嵌入现有的 YOLO 骨架,到训练时踩过哪些坑。如果你正在做目标检测算法改进,或者想在自研框架里引入 Inception 风格的并行结构,这篇文章可以直接参考。小白也不用慌,每个模块背后的原理我都会讲清楚,照着代码一步步抄也能跑起来。
1. 项目整体设计:多尺度卷积块、并行多核、通道混洗到底在解决什么问题
1.1 单尺度感受野是检测器最明显的瓶颈
先聊一个很多人在实际训练中会遇到的问题:YOLO 系检测器的主体结构基本是堆叠 3×3 卷积加下采样,特征层的感受野是线性增长的。这种设计在常规目标上表现不错,但一旦数据集里目标尺度跨度大,比如自动驾驶场景中的远处车辆和近处行人、工业质检里的微小瑕疵和大型结构件,固定堆叠卷积就有点力不从心。
深层网络有足够的全局信息,但浅层分辨率高、局部细节丰富,这两种信息并不能在同一个特征层里被同时利用到。过去解决多尺度问题主要靠 FPN 这类特征金字塔结构,让不同层级的特征互相融合。可金字塔结构融合的是不同分辨率下的信息,同一层内的感受野依然是固定的。如果同一层内既需要看局部纹理又需要感知上下文,单尺度卷积核就很难兼顾。
多尺度卷积块就是冲着这个痛点去的。它在一个模块内并行使用不同尺寸、不同膨胀率的卷积核,让同一层特征图能同时获得局部细节、中等上下文和全局线索。这样底层信息不用绕道到高层才能补感受野,网络有机会在一个更紧凑的路径上完成多尺度感知。
1.2 “并行多核”不是多线程,而是多组卷积核并行作业
“并行多核特征提取”这个说法容易让人联想到 CPU 多线程,其实完全不是一回事。这里的“多核”指的是多个可学习参数不同的卷积核组并行处理同一份输入特征图,每组核有自己的感受野和抽象层次。比较经典的参照就是 Inception 结构:同一层里既有 1×1 卷积,又有 3×3 卷积,还有 5×5 卷积或池化分支,各分支的结果拼在一起再融合。
在 YOLO 骨架里做并行多核,思路是一样的:输入特征图先分给几个并行的分支,每个分支用不同设置的卷积核去提取特征,最后把结果拼接起来。相比单纯堆一个大卷积核,比如直接上 7×7 卷积,多分支结构有一个很现实的好处:计算量可控。一个 7×7 卷积的参数量和计算量都不小,而把它拆成 1×1、3×3、膨胀 3×3 三个并行的低成本分支,能覆盖相近甚至更大的感受野范围,计算代价却小很多。
另外,分支之间可以共享同一份输入特征图,只对每个分支做不同的通道投影,这相当于把特征提取变成了一个“多视角观察”的过程。再加上分组卷积控制单个分支的计算量,整层就能在有限的算力预算内提取出更加丰富的特征表达。
1.3 协同突破的关键:先并行提取,再混洗融合
这三个模块能协同起来,核心逻辑是这样的。多尺度卷积块负责“看得广”,并行多核负责“算得省”,通道混洗负责“融得全”。如果只做并行分支而不做混洗,会有一个很隐蔽的问题:并行分支输出的通道通常按分支拼接在一起,如果下一个模块还是普通卷积,那么各个分支学到的特征基本上只在自己那块通道区域里内部流动。时间一长,不同分支之间就形成了信息孤岛,每个分支退化成独立的小网络,参数利用率下降,精度提升也会被限制。
通道混洗把拼接后的通道顺序重新打乱,让下一层卷积能同时看到来自不同分支的特征。这个操作没有引入任何额外参数,只是一次纯索引重排,但从信息流动的角度看,它打破了分支隔离,让多尺度卷积块真正变成一个有机关联的整体。换句话说,多尺度卷积块提供了多样性,并行多核控制了开销,通道混洗负责把多样性融合进后续每一层,三者缺一不可。
这种协同设计是我在做了几轮对比实验后才确定的。最初我也试过只加多尺度卷积块不加混洗,精度涨得很有限,再加上混洗之后,涨点明显提升。后面细节部分我会专门展开这一点。
2. 核心模块拆解:多尺度分支怎么搭、通道混洗怎么写
2.1 多尺度卷积块的分支设计:参考 Inception,但别照搬
实际操作中,多尺度卷积块我的建议是先用三分支起步,后续根据任务调整。一个比较稳的配置是:
- 分支 A:1×1 卷积,负责通道投影和局部线性组合;
- 分支 B:1×1 卷积接 3×3 卷积,负责标准感受野内的特征;
- 分支 C:1×1 卷积接膨胀率为 2 的 3×3 卷积,等效覆盖到约 7×7 的区域,负责上下文信息。
为什么不直接放一个 5×5 卷积?因为 5×5 卷积核参数多,计算量高,而两到三层小卷积堆叠在感受野上效果并不差,参数和计算量却低很多。膨胀卷积的加入也是一个可选项,如果模型速度压力大,直接用 3×3 和 1×1 两个分支也能跑出不错的效果。
每个分支内部的通道数不要一开始就设得很大。通常把输入通道分成 2 到 3 份,比如输入 128 通道,每个分支使用 64 或 48 个输出通道,最后拼接回原通道维度,再接一个 1×1 卷积做融合。这样模块的输出通道保持不变,方便嵌入原有骨架。
值得一提的是,池化也不是不能加。我之前在某个任务里加过一个全局平均池化分支,能显著提升大目标检测的成绩,但代价是训练收敛变慢。池化分支适合信息比较分散的场景,不是所有情况都该无脑加。
2.2 分组卷积与“多核”的组合方式
并行多核特征提取里还有一个很容易被忽略的细节:分支内部的卷积是否分组。如果不分组,每个分支的卷积核都会对输入的全部通道做全通道计算,计算量很快就会起飞。合理的做法是在每个分支内部使用分组卷积,比如 groups=2 或 groups=4,让一组卷积核只处理一部分输入通道。
分组卷积直接带来的好处是计算量成倍下降。但分组卷积也有副作用:不同组之间在计算过程中没有任何信息交流,同一组通道只跟本组内的特征互动。如果整个网络一直这么分组做下去,特征表达能力会衰减。这就是通道混洗出现的时机:在分组卷积操作之后,立刻对所有输出通道做一次重排,让原本只属于特定分组的通道被打散,下一层分组卷积就能接收到更丰富的跨组信息。
这种“分组卷积 + 通道混洗”的组合方式其实在轻量网络里已经验证过很多年,把它移植到 YOLO 的多尺度分支里,正好弥补了分支配对计算效率与特征交流之间的矛盾。
2.3 通道混洗的实现与梯度行为
通道混洗的实现本身很短,但维度变化容易把人绕晕。核心代码我贴在下面:
import torch def channel_shuffle(x, groups: int): B, C, H, W = x.shape assert C % groups == 0 # 先把通道维拆成 (groups, channels_per_group) x = x.view(B, groups, C // groups, H, W) # 交换两个维度,变成 (channels_per_group, groups, H, W) x = x.transpose(1, 2).contiguous() # 合并通道维 x = x.view(B, C, H, W) return x这里要注意两个点。第一,groups 不能设得太随意。如果 groups=1,这个操作等于什么都没做;如果 groups=输入通道数 C,就变成了每个通道单独成组,混洗后等价于对所有通道做一次彻底重排,放在一些网络里会破坏局部相关性。我一般建议 groups 在 4 到 8 之间,这个范围内效果最稳定。
第二,transpose 之后必须加 contiguous(),否则 view 会报错。很多第一次上手的人都会在这里踩坑。另外,这个操作是完全可微的,梯度会随着通道索引反向传播,不需要修改任何梯度计算逻辑,训练上非常省心。
2.4 模块如何嵌入现有 YOLO 骨架
嵌入位置很关键。我试过把多尺度卷积块直接替换掉骨干里的所有 Bottleneck,结果浅层用了大膨胀卷积之后训练速度明显变慢,精度改善却很有限。后来我把改动收窄,只替换骨干中后段几个 Stage 的中间卷积块,也就是特征图尺寸已经缩小到 16 倍以上下采样之后的部位。
替换的时候保留原始下采样层不动,多尺度分支只负责处理同一分辨率下的特征,避免不同步长分支直接拼接导致特征对齐问题。每个多尺度分支的输出拼接之后,先过 1×1 卷积融合,再执行一次通道混洗。有些实现会在每个分支内部分别加 BN,这很有必要,它能避免分支输出的特征分布差异过大拖累后续拼接。
如果骨架里原本有类似注意力机制的小模块,可以放在多尺度分支拼接之后。不过要注意,过多的串行注意力会抬升延迟,简单任务不一定需要。
3. 实操记录:从模块代码到训练配置,再到实验效果
3.1 一个可直接运行的多尺度混洗模块示例
下面是我在实验中用到的一个较稳的模块实现,可以在不改变输入输出尺寸的前提下直接替换原有卷积块。这里用 PyTorch 风格写,方便大家复现。
import torch import torch.nn as nn class MultiScaleShuffleBlock(nn.Module): def __init__(self, in_channels, out_channels, mid_channels=None, groups=4): super().__init__() mid_channels = mid_channels or in_channels // 2 self.branch1 = nn.Sequential( nn.Conv2d(in_channels, mid_channels, 1, bias=False), nn.BatchNorm2d(mid_channels), nn.ReLU(inplace=True) ) self.branch2 = nn.Sequential( nn.Conv2d(in_channels, mid_channels, 1, bias=False), nn.BatchNorm2d(mid_channels), nn.ReLU(inplace=True), nn.Conv2d(mid_channels, mid_channels, 3, padding=1, groups=groups, bias=False), nn.BatchNorm2d(mid_channels), nn.ReLU(inplace=True) ) self.branch3 = nn.Sequential( nn.Conv2d(in_channels, mid_channels, 1, bias=False), nn.BatchNorm2d(mid_channels), nn.ReLU(inplace=True), nn.Conv2d(mid_channels, mid_channels, 3, padding=2, dilation=2, groups=groups, bias=False), nn.BatchNorm2d(mid_channels), nn.ReLU(inplace=True) ) self.fuse = nn.Conv2d(mid_channels * 3, out_channels, 1, bias=False) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) def forward(self, x): identity = x x1 = self.branch1(x) x2 = self.branch2(x) x3 = self.branch3(x) out = torch.cat([x1, x2, x3], dim=1) out = self.fuse(out) out = self.bn(out) out = channel_shuffle(out, groups=4) return self.relu(out + identity)需要注意的是:这个示例为了保持结构简洁,把残差连接直接做进去了。如果原始骨架的通道数在替换处发生了变化,需要把 identity 先做一个 1×1 卷积对齐维度,再执行相加。
3.2 计算量与参数量估算:FLOPs 不等于一切
以输入特征图 640×640、通道数为 128 的中间层为例,我粗略算过一笔账。三个分支里 branch1 是纯 1×1,branch2 和 branch3 分别是 1×1 加 3×3 分组卷积。跟原始单路 3×3 卷积相比:
- 原始单路 3×3 卷积,输入输出都是 128 通道,计算量约在千万级 FLOPs 量级;
- 多尺度三分支结构,虽然分支数量多了,但因为每个分支输出通道控制在 64 附近,总计算量只增加约 30% 到 50%,并没有翻倍;
- 参数量增长主要来自 1×1 融合卷积,整体增幅可以控制在 10% 以内。
表格里的对比可以参考:
| 结构方案 | 每层输出通道 | 总 GFLOPs(相对值) | 参数量(相对值) |
|---|---|---|---|
| 原始单路 Conv3x3 | 128 | 1.0 | 1.0 |
| 三分支并行(无分组) | 128 | 1.8 | 1.6 |
| 三分支并行 + 分组(groups=4) | 128 | 1.35 | 1.25 |
| 三分支并行 + 分组 + 混洗 | 128 | 1.35 | 1.25 |
但这里必须强调:FLOPs 下降不直接等于推理变快。多分支并行会带来更高的内存访问成本,因为多个中间特征图要同时保存,拼接也需要额外拷贝。所以实际 FPS 下降通常会比 FLOPs 增长更明显。如果部署对延迟很敏感,分支数尽量控制在 2 到 3 个,不要贪多。
3.3 训练配置与实验设计建议
实验上我建议按照逐步加模块的方式来设计对比,这样能清晰看出每个模块的独立贡献。实验顺序可以这样安排:
- 第一组:原始骨架作为基线;
- 第二组:仅把骨干后段卷积替换成多尺度卷积块,不加混洗;
- 第三组:在多尺度卷积块基础上加入通道混洗;
- 第四组:再把并行分支设为不同膨胀率组合,观察多尺度分支带来的额外增益。
训练参数可以参考通用检测设置的常用默认值。输入分辨率根据任务需求选择,多数情况下保持训练尺寸不变。优化器在 SGD 和 AdamW 之间建议先用 AdamW 起步,收敛更稳定。轮数不必一上来就拉满 300 轮,可以先跑一个短周期的消融,比如 120 轮到 150 轮,确认模块增益存在以后,再跑长训练。
另外,EMA 一般建议打开,多分支并行结构在训练后期受到 EMA 平滑后,精度会有比较明显的提升。随机种子必须固定,多分支带来的随机性比单路结构大,不固定种子很难判断精度变化来自模块还是来自初始化。
3.4 实验观察记录:协同增益在哪里最明显
我自己的实验记录里,最明显的趋势有两条。第一条,多尺度卷积块在包含大小目标混合的数据集上收益最大,尤其是同时出现小交通标志和大面积车辆的区域,检测召回率提升非常明显。第二条,通道混洗带来的增益主要在深层、通道数较大的 Stage 出现,浅层几乎没变化。
如果把示意性的相对变化列出来,整体效果大致是这个量级:相比基线,改进版在同一训练策略下能多获得接近一个点的 mAP 提升,参数量增加很少,端到端推理耗时增加大约 2% 到 5%。注意,这是一个相对变化的描述,实际数字取决于数据集和基线本身的实力,但只要基线没有明显偏弱,这个组合通常不会出现负优化。
我也试过极端情况,就是把三个分支的卷积全部换成普通卷积、不加分组、混洗组数设为 1,结果精度和速度双双变差。这说明这个设计的收益不是来自复杂度堆叠,而是来自每个模块各自的职责和协同方式。
4. 避坑实录:训练、调参、部署中的常见问题排查
4.1 显存峰值升高与训练速度变慢
并行多分支结构和单路卷积最大的区别之一,就是激活值数量变多。三个分支各自产出一份中间特征图,拼接之前都要存在显存里,所以显存峰值会明显上升。如果你在训练时发现 OOM,优先考虑这几个调整方向:
- 减少分支数量,从三分支降为双分支;
- 在分支内部的卷积之间启用 Gradient Checkpointing;
- 降低 batch size,同步增加梯度累积步数来维持等效 batch。
训练速度变慢是另一回事,不一定全是计算量增加导致的。如果你的 GPU 利用率很高但训练耗时还是上涨明显,可以检查分支内部是不是用了过大的 dilation。膨胀卷积在并行分支中其实属于高成本操作,建议只在骨干更深层使用,浅层尽量去掉。
4.2 精度不升反降怎么办
这个情况在消融实验里很常见,原因通常有三个。
第一个原因是分支之间的特征分布差异过大。不同卷积核输出的特征图数值范围不同,直接拼接后再过一个 BN,会让 BN 的统计量不稳定。对策是每个分支内部先做一次 BN,拼接融合之后再做一次 BN,双保险逻辑更稳。
第二个原因是通道混洗放错了位置。如果在浅层高分辨率特征图上也强行混洗,会破坏像素局部相关性,因为相邻通道往往对应相似的语义信息,混洗后反而需要更多层来重新学习对应关系。建议只在骨干后段的深层 Stage 里加混洗。
第三个原因是训练轮数不够。多分支并行结构比单路结构需要更多迭代来充分发挥潜力,短训时可能看到精度下降,拉长轮数之后才反超。如果你跑的是 100 轮以内的短实验,建议提高学习率或者增加 warmup,给并行分支更多适应时间。
| 症状 | 可能原因 | 建议处理方式 |
|---|---|---|
| 训练损失波动剧烈 | 分支间特征尺度差异大 | 分支内部独立加 BN,减小初始学习率 |
| 精度比基线低 0.5 个点以上 | 混洗放到了浅层,或 groups 设置过小 | 只保留深层混洗,groups 设为 4 到 8 |
| 最终精度提升不明显 | 并行分支通道数不足 | 适当增大 mid_channels,观察 FLOPs 增量 |
4.3 部署阶段容易被忽略的坑
导出到 ONNX 或部署在推理引擎上时,通道混洗的 transpose 和 view 操作有时会触发动态 shape 问题。解决方案通常是固定输入尺寸导出,或者把 view 前的 reshape 改为静态 shape。如果框架不支持动态 reshape,可以在导出前用卷积权重重排的方式近似替代混洗:提前把下一层卷积的权重按混洗后的索引顺序重排,使得显式的 reshape 操作被完全吸收进卷积层里。这个技巧在不少轻量网络部署中都被验证过可行。
分组卷积在量化部署时也可能遇到精度回退。分组数越多,单组通道越少,量化误差可能会被放大。如果后续要做 INT8 量化,分组数建议控制在 4 以内,过高的分组数在量化评估阶段要重点观察。
4.4 其他几个实战提醒
多尺度分支的膨胀率不要一上来全用大的。膨胀率越大,有效卷积核的感受野越宽,但特征图边缘位置的信息会被重复计算,导致训练初期的梯度信号不均衡。建议膨胀分支只保留一个,且放在骨干中后段。
并行分支的通道分配也不一定要均匀。小卷积核分支可以分配更多通道,因为 1×1 卷积便宜,而大膨胀率分支可以适当少给通道。通道分配不均是允许的,在代码里实现时把 mid_channels 做成列表即可。
另外,如果你是在别人开源代码的基础上做改造,替换原有卷积块之前先确认原模块里有没有已经存在的残差连接。很多时候精度不涨不是因为新模块不好,而是重复的残差结构改变了梯度路径,需要微调模块内部的相加逻辑。
最后再分享一点实战体会
如果让我给刚接触这套结构的人一个建议,我会说:先从“双分支 + 两组混洗”开始,不要一上来就三个分支、四路膨胀一起上。模块多了之后,调试难度会指数级上升,一旦精度波动你很难判断是哪个数据增强参数、哪个分支设置出了问题。我最初就是把分支数堆到四个,结果性能没有明显收益,反而找问题找了很久。
真正常规的做法是增量改造:先替换一个 Stage,跑一版看效果,确认收益后再向后推进。每个 Stage 替换前后的精度变化都记录下来,这不仅方便定位问题,以后写技术报告也会好很多。多尺度卷积块和通道混洗真正优秀的地方,在于它们能在保持模型轻量的前提下打开特征表达的边界,而不是把网络塞得越来越复杂。希望这篇记录能帮你在复现的路上少踩几个坑。