YOLOv5集成CBAM注意力机制:小目标漏检问题的实战优化

📅 发布时间:2026/10/17 3:52:04
YOLOv5集成CBAM注意力机制:小目标漏检问题的实战优化
去年下半年我在用YOLOv5做目标检测时遇到了一个挺头疼的问题模型在常规尺寸的目标上表现还不错可一旦目标变小、被遮挡或者背景复杂漏检率和误检率就明显上来。一开始我以为是数据量不够于是加了更多数据增强、换了更大的模型折腾一圈效果始终不理想。后来翻到一些关于注意力机制的实践文章才意识到问题可能出在更深层的地方——卷积网络对特征图上的每个通道、每个位置统统一视同仁而实际上真正对检测有用的信息只占很小一部分。于是我用PyTorch在YOLOv5里集成了CBAM注意力机制做了一套完整的对比实验最终把问题压了下去。这篇文章就把从原理到落地的完整过程写出来包括三种不同的嵌入方式、yaml配置怎么改、以及我在训练中踩过的坑给同样想给YOLOv5加注意力的同学一些参考。如果你只是想快速跑通可以直接跳到第4章看配置如果想搞清楚CBAM为什么有效、以及加在哪个位置差别很大建议按顺序通读。因为嵌入位置这件事某种程度上比加不加CBAM本身还重要。1. 为什么YOLOv5需要注意力机制小目标漏检背后的特征利用问题1.1 从一次小目标漏检说起具体来说我当时手上有一个检测任务需要检测画面中比较小的、经常被树影或栏杆遮挡的目标。模型跑出来的结果很典型大目标全检出来了但小目标几乎漏了一半。我把特征图可视化之后发现浅层特征图里目标边缘和背景纹理混在一起深层特征图里目标的响应又不够集中。这个现象的底层原因在于卷积核在滑动时天然会把局部邻域的信息揉在一起但不会主动去区分哪些通道、哪些位置更值得关注。打个比方一张画面里站着一个穿红衣服的人背景是蓝色天空和绿色草地网络在提取特征时会把红色信息通道天空纹理通道草地纹理通道平等对待。如果模型能意识到红色通道更重要人所在的那一小片位置更需要聚焦检测精度自然就上来了。1.2 卷积网络一视同仁的困境用大白话说常规卷积操作就是一个模板滑动到所有位置做同等强度的特征计算。它没有显式的注意力机制不会因为某个区域有目标就多分配一点计算资源或特征权重。虽然深层网络理论上能通过训练学到一定的选择性但代价是数据量和训练时长都要很大。所以就有了注意力机制的思路在特征图基础上额外学习一个掩码告诉网络哪些通道重要、哪些位置重要然后把掩码乘到特征图上相当于对特征做了一次显式筛选。最早一批实践里SE模块只做通道注意力效果已经很不错而CBAM在通道注意力的基础上又加了一条空间注意力分支两者串联比SE多覆盖了一个维度。1.3 为什么选CBAM而不选其他注意力当时我对比过几种主流方案注意力机制关注维度额外参数实现成本适合场景SE仅通道低非常低需要轻量改动的场景CBAM通道空间低低通用目标检测兼顾精度和成本Coordinate Attention通道坐标方向中中需要位置先验的密集预测任务SimAM无额外参数零低对参数量极其敏感的场景结合我的检测任务小目标之所以漏检既可能是因为通道层面没有强化目标特征也可能是因为空间层面没有聚焦到目标位置两者缺一不可。CBAM恰好同时解决了这两个问题而且实现简单、参数量小加到YOLOv5里对推理速度的影响可以接受所以它成了我的首选。2. CBAM模块拆解通道注意力和空间注意力如何各司其职2.1 通道注意力告诉模型该看什么先看通道注意力。这一支路的输入是特征图 F形状 B×C×H×W它对所有空间位置做两种全局池化平均池化和最大池化得到两个 C×1×1 的通道描述向量。平均池化代表这个通道在全局范围内的平均响应强度最大池化代表最强的一个响应前者稳定后者尖锐两者互为补充。这两个向量接着进入同一个共享MLP——一个先降维再升维的两层全连接结构。降维比例是超参数reduction论文默认取16。为什么要降维再升维因为直接对C个通道两两建模需要 C×C 个参数代价太大先压到C/16再升回来相当于用一个小瓶颈来提炼通道间的相关性特征。最后把两条支路的输出相加过一个Sigmoid得到C个0~1之间的权重逐通道乘回原特征图。如果用一句话概括公式Mc(F) σ(MLP(AvgPool(F)) MLP(MaxPool(F)))也就是说这个模块让网络学到哪几个通道对当前任务更重要并放大这些通道同时压低无关通道。2.2 空间注意力告诉模型该看哪里经过通道注意力加权之后特征图已经对该看什么有了偏重但还不知道该看哪里。空间注意力就是来解决这个问题的。它的输入是经过了通道加权后的特征图 F。这次池化的方向反过来——不再对空间位置池化而是在通道维度上做平均和最大得到两个 H×W×1 的平面图然后把这两张平面图在通道方向拼接成 H×W×2 的图送进一个7×7卷积输出 H×W×1过Sigmoid后就是一个空间权重图。这个权重图里的每个像素值代表原特征图对应位置的重要性。把权重乘回特征图相当于告诉网络聚焦到人所在的那一小块区域忽略大片天空和地面。空间注意力用7×7卷积而不是1×1原因是希望空间权重不只看单个像素还能参考周围一个较大范围内的信息。比如目标被遮挡了一部分周围区域的上下文能帮助定位这也是CBAM对遮挡目标相对友好的原因之一。2.3 先通道后空间的顺序为什么更合理原论文作者做了顺序消融实验最后采用的顺序是先通道注意力、再空间注意力。我自己的理解是通道注意力先做第一次筛选把无关通道压低让空间注意力拿到的特征更干净如果顺序反过来空间注意力在含大量无关通道的特征上计算权重权重分布容易被噪声通道干扰。这里有一个值得注意的细节CBAM整体是一个串联分支结构两个子模块各自有少量额外参数但都不改变特征图的尺寸和通道数。所以无论加在网络哪一层前后张量形状始终一致这也是它能被直接嵌入YOLOv5的原因——不需要改任何前后层通道数。3. 手写CBAM的PyTorch实现从公式到可运行代码讲完原理直接上代码。我自己习惯先把模块单独写在一个脚本里验证shape再粘进YOLOv5的common.py。这样方便排查问题也不会污染项目代码。3.1 通道注意力子模块import torch import torch.nn as nn class ChannelAttention(nn.Module): 通道注意力对特征通道做重要性加权 def __init__(self, in_channels, reduction16): super(ChannelAttention, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) # 共享 MLP1x1 卷积等价于全连接但能保持 4D 张量 self.shared_mlp nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels, 1, biasFalse), ) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.shared_mlp(self.avg_pool(x)) max_out self.shared_mlp(self.max_pool(x)) weight self.sigmoid(avg_out max_out) return x * weight四个细节说明一下。第一共享MLP用1×1卷积实现而不是nn.Linear省掉了张量reshape的麻烦而且对全连接层来说1×1卷积在参数量上等价、在GPU上还更快。第二这里的降维比例如果设置成大于输入通道数会报错所以输入通道很小时要手动调小reduction。第三这两层卷积没有bias因为后面接的是Sigmoid不加bias可以减少冗余参数实践中影响也不大。第四Sigmoid输出的是乘法系数而不是加法偏移这保证了注意力机制只会缩放已有特征不会凭空制造新特征。3.2 空间注意力子模块class SpatialAttention(nn.Module): 空间注意力对特征图的空间位置做重要性加权 def __init__(self, kernel_size7): super(SpatialAttention, self).__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): # 在通道维度上做池化保留空间结构 avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) concat torch.cat([avg_out, max_out], dim1) weight self.sigmoid(self.conv(concat)) return x * weight空间注意力的实现非常轻量。它的池化是在通道维上做的所以avg_out和max_out的shape是B×1×H×W拼接后变成B×2×H×W7×7卷积把它压回B×1×H×W。padding取kernel_size//2是为了让输出分辨率不变。这个小模块的参数量只有2×7×798个biasFalse的情况下跟通道注意力动不动几千个参数比起来几乎可以忽略。3.3 完整CBAM类与沙箱验证class CBAM(nn.Module): def __init__(self, in_channels, reduction16, kernel_size7): super(CBAM, self).__init__() self.channel_attention ChannelAttention(in_channels, reduction) self.spatial_attention SpatialAttention(kernel_size) def forward(self, x): x self.channel_attention(x) x self.spatial_attention(x) return x if __name__ __main__: x torch.randn(2, 256, 32, 32) module CBAM(256) out module(x) print(output shape:, out.shape) total_params sum(p.numel() for p in module.parameters()) print(CBAM params:, total_params)运行这段代码输出shape应该是torch.Size([2, 256, 32, 32])参数量在8200左右。以YOLOv5s为例整个模型约702万参数一个CBAM模块只多约8200个参数占比不到0.2%这就是为什么它能以极低的成本换取注意力能力。4. 把CBAM装进YOLOv5三种嵌入位置与yaml配置实战4.1 在common.py里注册CBAM组件YOLOv5的模型结构由yaml文件描述运行时通过yolo.py里的parse_model动态解析。所有可被yaml引用的模块都必须存在于models/common.py或models/experimental.py中。所以第一步就是把第3章的三个类全部粘到common.py里放在SPPF类的后面就可以。因为yolo.py解析时会自动在common模块里查找与yaml中名字相同的类所以只要类名一致不需要改动yolo.py就能被识别。完成后可以用下面的命令验证有没有解析报错python models/yolo.py --cfg models/yolov5s.yaml4.2 方式一Backbone末尾插入独立CBAM层最直接的做法是在Backbone最后一个SPPF层后面追加一层CBAM。修改yaml的backbone部分从原来的SPPF后追加CBAMbackbone: [[-1, 1, Conv, [64, 6, 2, 2]], # 0-P1/2 [-1, 1, Conv, [128, 3, 2]], # 1-P2/4 [-1, 3, C3, [128]], [-1, 1, Conv, [256, 3, 2]], # 3-P3/8 [-1, 6, C3, [256]], [-1, 1, Conv, [512, 3, 2]], # 5-P4/16 [-1, 9, C3, [512]], [-1, 1, Conv, [1024, 3, 2]], # 7-P5/32 [-1, 3, C3, [1024]], [-1, 1, SPPF, [1024, 5]], # 9 [-1, 1, CBAM, [1024]], # 10 新增 ]但这里有个大坑YOLOv5的head部分有多处硬编码的层号引用比如[[-1, 6]]、[[-1, 4]]、[[-1, 10]]等。一旦在backbone末尾插入一层从SPPF开始的后续层号全部要1head里的所有Concat引用层号也得相应调整。这个改起来很容易错所以如果选这种方式改完之后务必把yaml里每个from序号都重新梳理一遍然后用yolo.py跑一次前向逐层打印shape确认没有越界。实际上我不太推荐这种方式只在末端加一层CBAM确实简单但也意味着注意力只作用在最终的特征上浅层和中层特征完全不受影响。对于小目标检测来说浅层的高分辨率特征恰恰是关键只在末端做注意力有点治标不治本。4.3 方式二用C3CBAM替换关键C3推荐我更推荐的做法是自定义一个C3CBAM模块让CBAM嵌到C3模块内部然后只把yaml里的C3替换成C3CBAM。因为C3是YOLOv5的基本构建块替换时层号完全不变Concat索引不会错乱这是最稳的方案。class C3CBAM(nn.Module): C3模块升级版在Bottleneck堆叠和短接融合之后加CBAM注意力 def __init__(self, c1, c2, n1, shortcutTrue, g1, e0.5): super(C3CBAM, self).__init__() c_ int(c2 * e) self.cv1 Conv(c1, c_, 1, 1) self.cv2 Conv(c1, c_, 1, 1) self.cv3 Conv(2 * c_, c2, 1) self.m nn.Sequential(*(Bottleneck(c_, c_, shortcut, g, e1.0) for _ in range(n))) self.attention CBAM(c2) def forward(self, x): # 沿用原C3的shortcut路径设计与concat融合 y torch.cat((self.m(self.cv1(x)), self.cv2(x)), dim1) y self.cv3(y) # 在C3输出后放入CBAM对融合后的特征做注意力加权 return self.attention(y)然后在common.py里注册这个类接着把yaml里的部分C3改成C3CBAM。以YOLOv5s为例我的做法是替换backbone当中比较深的那几个C3因为深层特征语义更强注意力更有针对性backbone: [[-1, 1, Conv, [64, 6, 2, 2]], # 0-P1/2 [-1, 1, Conv, [128, 3, 2]], # 1-P2/4 [-1, 3, C3, [128]], [-1, 1, Conv, [256, 3, 2]], # 3-P3/8 [-1, 6, C3CBAM, [256]], [-1, 1, Conv, [512, 3, 2]], # 5-P4/16 [-1, 9, C3CBAM, [512]], [-1, 1, Conv, [1024, 3, 2]], # 7-P5/32 [-1, 3, C3CBAM, [1024]], [-1, 1, SPPF, [1024, 5]], # 9 ]这样替换之后backbone中P3、P4、P5三个尺度的特征都经过了通道空间注意力加权而且没有引入任何额外的层层号完全不变head部分不需要动。如果你想在Neck中也加强注意力也可以把head部分的部分C3替换成C3CBAM比如head: [[-1, 1, Conv, [512, 1, 1]], [-1, 1, nn.Upsample, [None, 2, nearest]], [[-1, 6], 1, Concat, [1]], [-1, 3, C3CBAM, [512, False]], # 原C3替换 ...4.4 方式三只在Head前对三个尺度特征加注意力第三种方式是把head中最终输出给Detect的三个C3全部替换为C3CBAM。这样backbone保持原样只有Neck输出给检测头的三个特征图被注意力加权。如果你的模型已经训练得很成熟只是误检多、定位差这种改动最小、最可控。yaml改动也很简单只需把head部分几个C3改成C3CBAMhead: [[-1, 1, Conv, [512, 1, 1]], [-1, 1, nn.Upsample, [None, 2, nearest]], [[-1, 6], 1, Concat, [1]], # cat backbone P4 [-1, 3, C3CBAM, [512, False]], # 原C3改 [-1, 1, Conv, [256, 1, 1]], [-1, 1, nn.Upsample, [None, 2, nearest]], [[-1, 4], 1, Concat, [1]], # cat backbone P3 [-1, 3, C3CBAM, [256, False]], # 原C3改 [-1, 1, Conv, [256, 3, 2]], [[-1, 14], 1, Concat, [1]], # cat head P4 [-1, 3, C3CBAM, [512, False]], # 原C3改 [-1, 1, Conv, [512, 3, 2]], [[-1, 10], 1, Concat, [1]], # cat head P5 [-1, 3, C3CBAM, [1024, False]], # 原C3改 [[17, 20, 23], 1, Detect, [nc, anchors]],这种方式只加在Head末端对网络主干基本不动。缺点是前面所有层仍然没有注意力效果大概率不如方式二。我个人三套都试过最推荐方式二。它在参数量、可维护性、训练效果之间最均衡。方式一的坑主要在索引错乱方式三则收益有限。4.5 验证模型输出shape和参数量不管用哪种方式改完yaml之后都建议做一次shape冒烟测试防止因为层号引用错误导致训练跑到一半才崩。直接在主项目里写个简单脚本import torch from models.yolo import Model model Model(models/yolov5s_cbam.yaml, ch3, nc80) model.eval() x torch.randn(1, 3, 640, 640) with torch.no_grad(): outs model(x) # 三个尺度的head输出一个list for i, out in enumerate(outs[0]): print(fhead {i}: {out.shape}) total sum(p.numel() for p in model.parameters()) print(ftotal params: {total / 1e6:.2f}M)跑通后再取一张真实图片做一次完整的前向推理确认能正常出框就可以进入训练了。我做对比实验时基线YOLOv5s参数量约7.02M替换三个C3CBAM后约7.19M参数量只多了不到0.2M几乎可以忽略。5. 训练实测CBAM给检测效果带来的真实变化5.1 实验配置与对比基线我在一个自制的模拟项目X约4000张带有小目标和遮挡目标的图像上做了对比实验。两组训练都使用同一份训练/验证划分、同样的超参图片尺寸640×640batch大小16训练100个epoch优化器用SGD初始学习率0.01。基线是官方YOLOv5s结构实验组使用方式二把backbone中三个较深的C3替换为C3CBAM。两组都加载了通用检测数据集上预训练的权重但因为新增的CBAM层没有预训练参数加载后第一次前向会有一个loss跳升的过程。这个我没有特别处理直接让它自适应恢复。5.2 精度指标变化模型参数量100 epoch训练耗时mAP0.5mAP0.5:0.95小目标APYOLOv5s7.02M约9.5小时0.7320.4510.213YOLOv5s C3CBAM7.19M约10.2小时0.7560.4760.267说明一下数值只代表我手上的这个数据集不同数据集差异很大。但趋势是一致的——加CBAM后mAP有稳定提升且提升在小目标上最明显。小目标AP从0.213涨到0.267涨幅超过25%这正是我当时最希望解决的漏检问题。训练时间增加了约7%算是很小的代价了。从训练曲线看加了CBAM的loss下降稍快且更平滑在60个epoch以后的优势更明显。我还试了把四个C3全替换掉结果参数量又涨了一截精度却和替换三个差不多所以我最后保留的是替换三个的方案。5.3 小目标和遮挡场景的直观改善指标之外我挑了一批典型的难例图做定性对比变化很直观被栏杆遮挡的目标原先只能检出一个角加了CBAM后整框能稳定给出置信度普遍高5到10个百分点。紧挨在一起的多个小目标原先容易合并成一个框或漏掉一个现在分得更开。背景误检减少特别是大面积天空和草丛区域的虚警框明显下降。这种现象不难解释CBAM让模型把注意力集中到目标可能出现的区域自然降低了对背景纹理的响应。我在第2章讲的空间注意力聚焦位置直接体现在这里。6. 集成CBAM时踩过的坑和调参心得6.1 reduction不是越小越好我第一次调参时把reduction从16改到4想着压缩更少、信息保留更全效果应该更好结果mAP反而下降了。原因在于reduction太小MLP的bottleneck层宽度太大通道间的相关性学习变得散漫甚至引入了噪声。反过来reduction32虽然参数更少但容量不足在深层特征上的表示力不够。以我的实验来看16是最稳的甜点值8次之4和32都不理想。如果你换了数据集可以从16开始先试8再试32但没必要一上来就追求极端值。6.2 在yaml里加CBAM层时的索引错乱这是方式一最容易踩的坑。在backbone末尾插入CBAM后head里所有硬编码的Concat层号都必须对应1一旦漏改torch.cat会报维度不匹配或者虽然不报错但特征图组合错位。我排查这个问题的实操技巧是把yaml每一层的输出shape逐行print出来和原版yaml的shape比对哪一层shape对不上就是哪一层的from写错了。平时不管怎么改yaml都建议保留这个逐层shape检查的习惯能省下大量调试时间。举一个具体例子有一回我在backbone末尾加CBAM运行python models/yolo.py --cfg yolov5s_cbam.yaml还挺正常但训练到第一个epoch结束前就崩了报错是torch.cat的shape不匹配。逐行打印输出shape后发现head里某一层Concat把上一层输出和backbone的某层输入concat了通道数对不上。最后定位到是head里[[-1, 10]]的from序号没有随着层号整体1而修改。改成[[-1, 11]]之后就正常了。6.3 加载预训练权重时loss突然跳高加了CBAM后第一次训练我观察到loss在初始几个epoch会明显高于基线大概2到3个epoch后才逐渐追平。原因是新增层权重是随机初始化预训练权重没有对应参数。这不是bug也不需要特殊处理。如果你实在介意前几个epoch的loss太高可以给CBAM层单独设置一个较低的学习率或者先冻结主干只训练新加层几个epoch再放开全模型。我自己实测下来直接让它自适应恢复就行对最终的精度没有负面影响。6.4 空间注意力kernel_size在小目标场景下要适当缩小论文默认空间注意力用7×7卷积我在常规目标上没觉得有什么问题。但换到小目标数据集后7×7卷积的感受野相对目标尺寸来说太大了它会同时把目标周围的背景纹理也纳入加权范围反而给虚警提供了可乘之机。我把kernel_size改成3之后小目标AP又涨了一点。如果你做的是密集小目标检测可以先试3×3做一般目标检测7×7更稳。6.5 不是加得越多越好我一开始的思路是既然CBAM有效那就在每个C3后面都塞一个结果模型在小数据集上出现了轻微过拟合泛化反而变差。道理也简单注意力模块虽然参数量少但也是需要学习的东西塞多了模型需要更多的数据来拟合这些加权关系。数据量不够大时只在最关键的深层几个位置加比密密麻麻地加更有效。我的经验是数据量在几千张这个量级时加在backbone的P3/P4/P5三个尺度就足够数据量上万张时再考虑加到Neck里。另外提一个和部署相关的小事CBAM只有卷积和Sigmoid没有自定义算子所以ONNX导出和多后端部署基本不存在兼容性问题。如果你用高性能推理引擎做加速可以把CBAM里的1×1卷积和空间卷积与相邻的BN层fuse进一步压缩推理耗时。这个优化在部署阶段做就行训练阶段不需要管。最后再分享一个我自己的体会在给YOLOv5这类检测模型加注意力时很多人把注意力放在用什么模块怎么改代码上但真正决定收益的其实是加在哪一层、以什么粒度加入。CBAM这个模块本身不复杂复杂的是你要清楚地知道当前模型的瓶颈在哪里——是通道表征不够强还是空间定位不够准。如果你当前的问题是背景误检多优先加空间注意力如果是目标特征本身不被重视优先加通道注意力。CBAM把两者都包进去了但它也在同一个模块里同时增加了两类参数所以它的效果上限取决于你有没有放在最需要它的位置。建议从方式二开始试把C3CBAM放在backbone的P3/P4/P5三个尺度跑一轮对比实验再决定要不要往Neck里继续加。这样迭代的效率比自己闷头调参高得多。