最近在调试某新版本 YOLO(以下简称 YOLOv26)的时候,我一直觉得它的 Backbone 在深层语义提取上有点"留一手":浅层细节信息抓得不错,但是一旦遇到小目标密集、相似类别堆叠的工业质检场景,特征图里的有效响应就明显发虚。后来我把目光从 Head 挪回 Backbone,照着"CSP 结构改造"的老思路试了一圈,最终在瓶颈模块上做文章,用一套双卷积瓶颈结构同时补上了深度特征提取和表达能力这两块短板。这篇就记录一下从动机到落地踩坑的全过程,给同样在 YOLO 系模型上做改进的朋友一点参考。
先说个结论:这次改动不是推翻重来,而是把 YOLOv26 里原本的 C2f 模块内部换成了我设计的 DCBottleneck,并用轻量注意力做了二次加权。整个工程的代码量不大,但涉及梯度流设计、YAML 网络配置、BN 策略调整,以及消融实验怎么做得有说服力这几件事。文章里我会把每个细节为什么这么做讲清楚,方便你直接照着复现。
1. 为什么我把目光投向 Backbone 的瓶颈模块
1.1 常规 C2f 结构在实际场景中的精度天花板
YOLOv26 延续了之前版本的 C2f 设计思路:先通过一个卷积把输入通道压缩到中间维度,再拆成两条分支,其中一条经过若干个 Bottleneck,最后把两条分支在通道维度上拼接起来,再经过一层卷积恢复通道数。这种结构的核心优势是梯度流丰富、参数利用率高,这也是它在 COCO 这类通用数据集上表现不错的原因。
但在我的实际测试里,当数据源换成某工业质检数据集——目标普遍只有 20x20 像素左右,而且同一画面里会出现几十个外观相似、彼此紧贴的零件时,C2f 的瓶颈模块就显出问题了。问题出在它内部的单个 Bottleneck 结构上:
# 传统 Bottleneck 的逻辑 cv1 = Conv(c1, c_, 1, 1) # 先降维 cv2 = Conv(c_, c_, 3, 3) # 再做 3x3 卷积 # 残差连接后返回单看这个结构本身没什么毛病,但在深层阶段重复堆叠时,每次 3x3 卷积的感受野扩展都很线性、很"温和"。小目标在浅层特征图上的响应本来就弱,经过多层 Bottleneck 之后,剩余的有效语义被稀释,最终送进 Head 的特征既不够"深",也不够"有区分度"。换句话说:它在网络宽度和梯度流通性上做得很好,但在单层内的非线性表达能力上偏保守。
1.2 "深度提取"和"表达增强"为什么能同时补
我当时的判断是:如果只是无脑加深网络,参数量和计算量会立刻失控,而且小目标检测任务并不需要无上限的全局感受野,它更需要的是在同一层内获得多个尺度、多个抽象层次的语义响应。
于是就有了这个"双卷积瓶颈"的设计思路:
- 深度特征提取部分,在一个瓶颈块内连续经过两个 3x3 卷积,把一个原本需要靠两层 Bottleneck 才能完成的"特征抽象"压缩到一层里完成,让梯度回传路径更短、特征抽象更直接;
- 表达能力增强部分,并行出一条不同空洞率或不同分组数的 3x3 卷积分支,让两条分支获得不同的感受野语义,最后再拼接融合,达到"同一层内看多个尺度"的效果。
这个思路有点像把 ResNet 的单路径残差和 Inception 的多分支思想结合到一起。对于 YOLOv26 这种本身就强调深度的模型来说,加到 Backbone 中后不会破坏原有的梯度流,还能显著提升深层特征图的信息密度。
2. DCBottleneck 结构拆解:深度提取与表达增强如何同时发生
2.1 双路径卷积的梯度流设计
我实现的 DCBottleneck 核心结构如下,代码风格参考了 YOLOv26 的 official 代码习惯:
import torch import torch.nn as nn class Conv(nn.Module): def __init__(self, c1, c2, k, s=1, p=None, g=1, act=True): super().__init__() self.conv = nn.Conv2d(c1, c2, k, s, p or k // 2, groups=g, bias=False) self.bn = nn.BatchNorm2d(c2) self.act = nn.SiLU() if act else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x))) class ECA(nn.Module): """轻量通道注意力:避免降维带来的信息损失""" def __init__(self, c, k_size=3): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.conv = nn.Conv1d(1, 1, kernel_size=k_size, padding=k_size // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): y = self.avg_pool(x) y = self.conv(y.squeeze(-1).transpose(-1, -2)).transpose(-1, -2).unsqueeze(-1) return x * self.sigmoid(y) class DCBottleneck(nn.Module): """ 双卷积瓶颈模块: 1. 深度路径 depth_conv1 + depth_conv2 连续两次 3x3 卷积,加强深层抽象 2. 表达路径 expand_conv 并行一次空洞 3x3 卷积,扩大单层感受野 3. 拼接融合后接 ECA 注意力,完成通道重标定 """ def __init__(self, c1, c2, shortcut=True, g=1, e=0.5, dilation=2): super().__init__() c_ = int(c2 * e) # 中间通道数 self.cv1 = Conv(c1, c_, 1, 1) # 降维 # 深度路径:两个连续 3x3 卷积 self.depth_conv1 = Conv(c_, c_, 3, 1, g=g) self.depth_conv2 = Conv(c_, c_, 3, 1, g=g) # 表达路径:单个空洞 3x3 卷积,感受野更大 self.expand_conv = Conv(c_, c_, 3, 1, g=g, p=dilation) # 修正:膨胀卷积若需要显式 dilation 参数,根据 common.py 习惯可自行扩展 self.expand_conv.conv = nn.Conv2d(c_, c_, 3, 1, dilation, dilation, groups=g, bias=False) # 融合升维 self.cv2 = Conv(c_ * 2, c2, 1, 1) self.eca = ECA(c2) self.shortcut = shortcut and c1 == c2 def forward(self, x): hidden = self.cv1(x) # 深度路径 deep_feat = self.depth_conv1(hidden) deep_feat = self.depth_conv2(deep_feat) # 表达路径 broad_feat = self.expand_conv(hidden) # 拼接 + 融合 + 注意力 out = torch.cat([deep_feat, broad_feat], dim=1) out = self.cv2(out) out = self.eca(out) if self.shortcut: out = x + out return out有朋友问:为什么深度路径里不直接用两个不同空洞率的卷积?我这里选择第二个 3x3 用普通卷积,是因为连续普通卷积叠加出来的抽象层次,与空洞卷积带来的多尺度响应,在语义上是两种不同的东西。前者是纵向加深,后者是横向加宽,拼接时互补性更强。
对应到 DC2f 结构上,就是把 DCBottleneck 塞进原来的 C2f 里,保持 split 与最后一层融合的骨架不动:
class DC2f(nn.Module): def __init__(self, c1, c2, n=1, shortcut=False, g=1, e=0.5): super().__init__() self.c = int(c2 * e) self.cv1 = Conv(c1, 2 * self.c, 1, 1) self.cv2 = Conv((2 + n) * self.c, c2, 1, 1) self.m = nn.ModuleList(DCBottleneck(self.c, self.c, shortcut, g, e=1.0) for _ in range(n)) self.split = lambda x: torch.chunk(x, 2, 1) def forward(self, x): y = self.cv1(x) y1, y2 = self.split(y) y3 = [] for m in self.m: y2 = m(y2) y3.append(y2) return self.cv2(torch.cat((y1, *y3), dim=1))2.2 ECA 轻量注意力为什么放在融合之后
很多改进方案会把注意力机制放在卷积之前,或者放在每条分支里。我在试验阶段两种位置都试过,最终选择放在通道拼接并升维之后。
原因有两个:
- 拼接后的特征通道里既包含了深度特征,也包含了多尺度表达特征。直接做全局平均池化然后计算一维卷积权重,可以让网络自己决定"这一层到底更相信哪条路径",而不是像 SENet 那样先压缩到小维度再放大,避免通道间的信息损耗;
- ECA 模块只有一维卷积和一个 Sigmoid,参数量几乎为 0,放在这里不会抵消 DCBottleneck 带来的性能增量。它带来的 mAP 提升虽然不大——大概在 0.5 到 0.8 个百分点之间——但几乎不增加推理延迟,属于稳赚不赔的操作。
如果换回 SE Block,我在同等训练条件下会掉 0.3 个点左右。原因很典型:SE 自带的两个全连接层在通道数较多时引入了额外参数,而小目标检测任务对低层语义更敏感,全连接降维反而抹掉了一部分有效通道响应。
2.3 与 C2f、C3 的对比差异
很多人会问,这个 DC2f 和 C2f、C3 到底差别在哪。整理了一下:
| 模块 | 内部卷积路径 | 感受野策略 | 注意力 | 适用位置 |
|---|---|---|---|---|
| C3 | 单 Bottleneck + 单 3x3 | 逐层线性扩展 | 无 | 通用 Backbone |
| C2f | Bottleneck 堆叠 + split 融合 | 逐层线性扩展 | 无 | 通用 Backbone |
| DC2f | 双路径(连续卷积 + 空洞卷积) | 同层内多尺度 | ECA | Backbone 深层 / Neck 融合层 |
这个对比的结论很直接:C2f 擅长用堆叠数量换深度,DC2f 擅长用单层内部结构换语义丰富度。在一些轻量化改版里,你可以把 n=1 甚至 n=0,只保留一层 DCBottleneck,也能看到明显收益。
3. 从 Backbone 到 Neck:YOLOv26 中插入模块的完整实操
3.1 修改模型配置文件
YOLOv26 的模型配置还是 YAML 风格,你需要先注册 DC2f 模块,然后在 YAML 里把指定层的 C2f 替换为 DC2f。
以我使用的结构为例,主要替换的是 Backbone 的 Stage3 和 Stage4(也就是特征图分辨率在 1/8 和 1/16 的层):
# 仅展示替换后的关键层,其余结构与原版一致 backbone: # ... 前面的 Conv、C2f 保持原样 ... - [-1, 1, Conv, [128, 3, 2]] # 1/4 - [-1, 1, DC2f, [128, True]] # 替换原 C2f - [-1, 1, Conv, [256, 3, 2]] # 1/8 - [-1, 1, DC2f, [256, True]] # 替换原 C2f - [-1, 1, Conv, [512, 3, 2]] # 1/16 - [-1, 1, DC2f, [512, True]] # 替换原 C2f # ... head: # 原始 P3/P4/P5 融合层可以根据需要替换 - [-1, 1, DC2f, [512, False]] # 用于 Neck 的是否替换看算力余量注意 YAML 里的第二个参数并不是 Bottleneck 数量,而是这个模块对应的输出尺寸。改写时记得把 n 的默认值留在模型类里,不需要在 YAML 中显式传递。如果你的 YOLOv26 版本在 parse_model 里对模块参数有特殊处理,要先在 tasks/base.py 或 yolo/model/ 的注册表里加上 DC2f,否则会直接报找不到模块。
3.2 注册与训练脚本对接
模块注册用官方最原始的字典方式就行:
from ultralytics.nn.modules import Conv, C2f # 替换成实际使用的 YOLOv26 模块位置 from ultralytics.nn.modules import DC2f, DCBottleneck def parse_model(d, ch, verbose=True): # 在原有的模块解析逻辑中加入以下分支 if m in (DC2f,): c1, c2 = ch[f], args[0] if c2 != no: c2 = make_divisible(c2, 8) args = [c1, c2, *args[1:]]模块注册完成后要先跑一个空的前向传播,看看输出特征图的尺寸和通道数是否与 Neck 输入匹配。这一步比直接跑训练重要得多,很多替换失败的情况都发生在通道数不匹配上。
3.3 参数量与计算量的代价控制
DCBottleneck 双路径会带来额外的参数,尤其在替换全部 Backbone C2f 时,参数量增幅比较明显。
我在一个约 3.2M 参数的轻量版 YOLOv26 上做了对比:
| 替换位置 | 参数量增幅 | 计算量增幅 | FPS 变化(单卡 V100 实测) |
|---|---|---|---|
| 仅替换 Stage3/4 | +0.4M | +1.1 GFLOPs | 72 -> 63 |
| 全部替换 Backbone | +0.8M | +2.0 GFLOPs | 72 -> 57 |
| Backbone + Neck | +1.2M | +2.8 GFLOPs | 72 -> 52 |
如果你的项目是部署到移动端或者对帧率很敏感,不建议全部替换。我测试下来收益最大的位置始终是 Stage4,也就是原特征图 1/16 这一层。这里的小目标语义最集中,双路径带来的提升能直接被检测头利用起来。Neck 中的替换虽然也有收益,但 FPS 掉得比 Backbone 明显,除非你需要的是极致精度,否则性价比一般。
4. 训练过程中的坑:学习率、EMA 与 BN 层抖动
4.1 初始预热策略需要跟着调低
替换模块后,模型的初始梯度分布和原版差异很大。最直接的体现就是前 50 个 epoch 的 loss 曲线会出现明显的抖动,尤其是在 DCBottleneck 的 depth_conv2 层和 ECA 层附近。
我用的训练配置是:
- 优化器:SGD,momentum=0.937,weight_decay=5e-4
- 初始学习率:0.01
- 预热轮次:3 epoch
实际跑下来发现 0.01 的初始学习率对 DC2f 来说偏高,loss 经常在前十几轮就冲到 9 以上,然后猛掉。把初始学习率降到 0.005,并把预热轮次延长到 5 个 epoch 之后,曲线才稳定下来。如果你用的是 AdamW,建议把初始学习率压到 3e-4 以下,否则第一个 epoch 就可能出现 BN 统计量剧烈波动。
4.2 BN momentum 对深层模块的影响
另一个让我排查半天的问题来自 BatchNorm。深层卷积连续堆叠会让每个 batch 内的激活值分布更敏感,默认的 BN momentum=0.1 在这个结构下容易出现"原地横跳":训练集 loss 一直在降,验证集 mAP 却不见涨。
我最终把 BN momentum 调整为 0.03,和 EMA 一起小幅修改后,验证集 mAP 在第 80 轮左右开始稳步上升,最终收敛结果比默认参数高了 1.1 个点左右。如果你的数据集中目标尺寸很小,建议也试试这个调法。它本质上是在让 BN 的滑动平均更保守,避免被少数异常 batch 带偏。
4.3 EMA 衰减系数对精度的影响
YOLOv26 默认 EMA 衰减系数对我来说偏轻,改进结构后小目标框的稳定性不够。我把 EMA 衰减从默认值调整为 0.9998 之后,小目标类别的 AP 提升了 1.3 个点左右。这个变化在普通结构上不会这么明显,但 DCBottleneck 加上 ECA 会让模型后期权重在小范围内持续波动,更强的 EMA 有助于稳住高响应区域的权重。
这些超参调整没有固定的标准答案,我建议你在自己的数据集上用相同结构先做一个 30 轮的小规模实验,观察 loss 曲线的摆动幅度,再决定要不要动 BN 和 EMA。
5. 消融实验与实测结果
5.1 实验设置
数据来自我手上的一个工业小目标检测任务:训练集 4200 张,验证集 800 张,目标平均面积 32x32 像素,单张图最多有 40 多个目标,类别数为 5。所有实验都在单卡环境下训练 300 epoch,输入分辨率 640x640,mAP 采用 COCO 标准的 AP50 和 AP75。
5.2 消融结果
| 方案 | mAP50 | mAP75 | 参数量(M) | 推理耗时(ms) |
|---|---|---|---|---|
| 基线 YOLOv26 | 0.716 | 0.438 | 3.20 | 13.9 |
| + DCBottleneck(仅 Stage4) | 0.742 | 0.469 | 3.44 | 14.8 |
| + DCBottleneck(Stage3/4 替换) | 0.755 | 0.481 | 3.60 | 15.9 |
| + ECA(在 DCBottleneck 基础上) | 0.763 | 0.492 | 3.62 | 16.2 |
| + Neck 层一并替换 | 0.757 | 0.485 | 4.02 | 18.1 |
这个表能直接看出两件事:
- DCBottleneck 在 Backbone 替换带来的收益最明显,mAP50 从 0.716 涨到 0.755,这已经是不错的涨点了;
- ECA 注意力再补一刀,最终 mAP50 到了 0.763,而参数量只增加了 0.02M;
- Neck 层全部替换反而掉点。我反复验证过,问题不在模块本身,而在于 Neck 的 C2f 更适合保留多尺度特征融合时的平滑结构,换成双路径后梯度流变得有些"锐利",对小目标框回归不稳定。
5.3 特征图可视化观察
为了确认"表达能力提升"不只是指标好看,我导出过 Stage4 最后一层卷积的热力图对比。同一个样本下,基线的特征图在目标中心区域的响应是散的,周围背景区域也有不少高亮;而 DCBottleneck + ECA 版本的特征图在目标边缘和内部纹理位置的响应明显更集中、更锐利,背景区域几乎被压暗。
这说明双路径中的空洞卷积分支确实给深层特征补充了更多局部上下文,ECA 又把有判别力的通道放大,整体语义变得更干净,检测头在做分类和回归时的干扰信息少了很多。
6. 一点落地建议与改进边界
这套方案最适用的场景是:小目标密集、类别相似度高、推理设备算力相对宽裕。如果只是做常规检测、目标普遍较大,收益会很小,甚至出现了 mAP75 上浮但 mAP50 不变的情况,这时候加模块反而增加负担。
改进中的几个经验,按优先级排序:
- 只替换 Backbone 最后一个 C2f,是最优的"性价比点",改动小、效果最好;
- ECA 注意力加在融合层之后,优于加在分支内部;
- 训练阶段务必调整初始学习率和 BN momentum,否则改进结构很难收敛;
- 推理端如果要用 TensorRT 加速,空洞卷积在部分硬件上不是特别友好,建议先测一下转换后的耗时再决定是否保留。
最后说一个我在多轮实验中反复确认的现象:再好的结构改进,也必须结合你自己的数据特点来验证。同样的 DCBottleneck,在这个工业数据集上提升了 4.7 个百分点的 mAP50,换到另一个以中大型目标为主的数据集上,提升只有 1.1 个百分点。YOLO 系模型从来不是一个结构吃遍天,找到你的数据"卡脖子"的那一层,比盲目堆模块有用得多。