最近接了个目标检测项目,用的是yolov5,模型跑起来倒是快,但一到遮挡密集、小目标偏多的场景,漏检和误检就明显变多。查了一圈资料,发现不少人通过加入注意力机制来改善这类问题,里面点名率最高的就是CBAM。我花了两周时间把CBAM集成进yolov5,从读源码到改配置再到训练验证,中间踩了不少坑,比如权重加载报错、显存直接溢出、加了模块后指标纹丝不动之类。这篇把整个思路、代码改法、插入位置的设计和实测结果一次性说清楚,给想用pytorch给yolov5加CBAM的朋友当个参考。内容不绕弯子,直接按实操顺序来。
1. 给yolov5加CBAM之前,先搞清楚这几件事
1.1 注意力机制到底在解决什么问题
目标检测模型本质上是让网络学会从输入图像里提取有用的特征,再基于这些特征完成分类和定位。但yolov5这类卷积网络在提取特征时,对输入图像所有区域基本是一视同仁的,卷积核滑到哪里,计算方式都是一套权重。这在简单场景下没问题,可一旦画面里有大量背景、相似物体互相遮挡,或者目标尺寸很小,网络就容易被无关信息干扰。
注意力机制的核心逻辑就是让网络学会“主动选择”:哪些通道的特征更重要,哪些区域更值得关注,然后给这些信息更高的权重。你可以把它理解成一个自动调节的聚焦机制——类似人眼看东西时会忽略背景、直视目标。放在yolov5里,注意力模块会重新校准特征图的通道权重和空间权重,让后面继续传递的特征更“干净”、更有针对性。
CBAM全称Convolutional Block Attention Module,是一种轻量级注意力模块,由通道注意力和空间注意力两部分串联组成。它不像某些改造那样需要大幅改动主干结构,而是可以作为一个即插即用的模块,直接插到yolov5的某个卷积块之后。这也是它在工程里受欢迎的原因:改动量小,见效快。
1.2 为什么选CBAM而不是SE、ECA、NAM
注意力机制不是只有CBAM一种。SE模块只做通道注意力,实现非常简单,参数量也小,但它对空间位置信息不加区分,在检测任务里效果有限。ECA用一维卷积代替SE里的全连接,省参数,不过本质上也只关注通道维度。NAM侧重归一化后的权重抑制,实现思路不同,但社区讨论和复现案例相对少一些。
CBAM的不同之处在于“通道+空间”双管齐下。通道注意力告诉网络“看哪些特征”,空间注意力告诉网络“看哪里”,两者串联后的效果不是简单叠加,而是互补。以yolov5这种本身就带FPN结构的模型来说,neck部分各层特征图的分辨率不同,加上空间注意力可以更好地保留小目标的位置响应。
从工程角度看,CBAM还有一个实际优势:对输入特征图的尺度不敏感,任何一层的输出都可以直接接上它,不用额外设计适配逻辑。SE、ECA在接入时同样方便,但少了空间维度那一半,在遮挡场景下的提升幅度通常没有CBAM明显。我两个都试过,同样的数据集和参数,CBAM的mAP提升比SE高出大概0.5到1个点,代价是推理时间多了一点点。
1.3 CBAM放哪一层收益最大
这个是我做实验时最花时间的部分。CBAM不是插得越多越好,也不是随便找个位置接上就有用。它的本质是对特征做重标定,如果放在已经高度抽象的低分辨率特征图上,空间注意力能提供的信息就比较有限;如果放在最浅层的高分辨率特征图上,计算开销又上去了。
我的实测结论是:放在backbone最后一层输出之后,以及neck结构里每个上采样/下采样C3模块之后,收益最明显。这两个位置的特征图同时具备语义信息和一定的空间分辨率,CBAM的通道注意力能够提炼语义,空间注意力又能校准位置。放在主干网络中间某些层,不是没用,但提升幅度没那么大,还白白增加算力消耗。
如果你资源紧张,不想一次加太多模块,我建议优先在backbone最后一层之后再插一个,先跑通效果,再决定要不要继续加。后面第3节我会具体演示怎么通过yaml配置来控制插入位置,不改网络结构代码就能随意换地方。
2. 手写CBAM模块:原理、代码一次性讲透
2.1 通道注意力是怎么“重新加权”的
通道注意力做的事情,用大白话讲就是:拿到输入特征图后,把每个通道压缩成一个数值,然后通过一组可学习的参数算出每个通道的权重,再乘回原来的特征图。
具体实现上,输入特征图会分别走一个全局平均池化和一个全局最大池化,得到两个形状为 1×1×C 的向量。为什么要同时用平均池化和最大池化?平均池化能捕捉全局的整体响应,最大池化则能保留最强烈的特征响应,两者互补。接着这两个向量被送入一个共享的全连接层,把通道数先压缩再还原,最后相加并通过sigmoid得到0到1之间的通道权重。sigmoid的作用是让权重保持在一个平滑的范围内,不会出现极端值,这样既能放大重要通道,又不会彻底抑制其他通道。
用代码表示就是:
import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, ratio=16): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.shared_mlp = nn.Sequential( nn.Conv2d(in_planes, in_planes // ratio, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(in_planes // ratio, in_planes, 1, bias=False), ) 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)) return self.sigmoid(avg_out + max_out)这里有个参数ratio,控制全连接层的压缩比例。它直接决定通道注意力部分的参数量:ratio越大,中间层通道数越少,参数量越小,但注意力权重的表达能力也越弱。yolov5的backbone中通道数从64到1024不等,ratio取16基本是通用选择,显存紧张时也可以取32。
2.2 空间注意力是怎么“聚焦位置”的
空间注意力的输入是经过通道注意力加权后的特征图。它对特征图的每个位置判断“重不重要”。具体做法是:在通道维度上分别做一次平均池化和最大池化,得到两个形状为 H×W×1 的平面图,然后把它们拼成一个两通道的特征图,再经过一个7×7卷积映射成单通道,最后用sigmoid得到空间权重图。
为什么要用7×7这么大的卷积核?因为空间注意力需要感知一个局部区域内像素之间的关系,而不是只看单个点。7×7的感受野能覆盖足够大的范围,让模型知道目标周围哪些像素属于前景、哪些属于背景。当然,你也可以把kernel_size改成3或5,但实测下来7×7的效果最稳定。
代码实现:
class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) x_cat = torch.cat([avg_out, max_out], dim=1) out = self.conv(x_cat) return self.sigmoid(out)注意torch.max在返回最大值的同时还会返回对应的索引。这里用_把索引丢弃,避免无意中参与计算。合并维度使用torch.cat,在channel维度上把平均响应和最大响应拼接起来。
2.3 完整CBAM模块代码与参数设计
把通道注意力和空间注意力串起来,就是完整的CBAM。顺序上,论文推荐先做通道注意力再做空间注意力。我的理解是:通道注意力先帮网络决定“关注哪个特征”,空间注意力再帮网络决定“在哪个位置上看这些特征”,逻辑上更顺畅。反过来先空间后通道也能跑,但效果略差。
class CBAM(nn.Module): def __init__(self, in_planes, ratio=16, kernel_size=7): super().__init__() self.channel_attention = ChannelAttention(in_planes, ratio) self.spatial_attention = SpatialAttention(kernel_size) def forward(self, x): x = self.channel_attention(x) * x x = self.spatial_attention(x) * x return x这段代码就是后面要集成进yolov5的核心。相比yolov5里C3模块的内部结构,CBAM的代码量小得多,也没有复杂的残差分支和多个卷积堆叠。它在forward里先对x做通道加权,再把结果做空间加权,整个过程不改变特征图的形状,所以无论插到哪里,前后的张量尺寸都能对上。
一个需要注意的点是in_planes的传入。在yolov5的模型解析逻辑里,每个模块的输入通道数通常是从前一层推导出来的,CBAM需要显式接收这个通道数。如果你只是把CBAM类丢进去但忘记传in_planes,运行时就会报参数缺失的错。第3节里我会演示怎么在解析逻辑里自动把前一层通道数传给CBAM。
3. 在yolov5工程里集成CBAM的完整步骤
3.1 把CBAM写进模型定义文件
yolov5的工程结构里,模型定义相关的代码在models目录下。以v6.0之后的版本为例,核心文件是models/common.py,所有可复用的模块比如Conv、Bottleneck、C3都在这里定义。我们需要把上面的CBAM相关代码整体粘到common.py末尾。
注意类名不要跟已有模块冲突。yolov5的common.py中没有CBAM,所以直接添加就行。我在实际操作时顺手把ChannelAttention和SpatialAttention也加了进去,这样做的好处是后续如果要单独做实验,比如只加通道注意力不加空间注意力,可以直接引用,不用再把代码拆开。
粘贴好之后,可以快速验证一下模块是否能正常前向传播。最简单的方法是在命令行进入python环境,先构造一个随机张量再跑一遍:
import torch from models.common import CBAM x = torch.randn(2, 256, 32, 32) cbam = CBAM(256) y = cbam(x) print(y.shape) # 期望输出 torch.Size([2, 256, 32, 32])能正常输出形状不变的结果,说明模块本身没问题。很多人卡在这一步会报ImportError: cannot import name 'CBAM',多半是python环境没切换到yolov5对应的虚拟环境,或者代码没保存就运行了。
3.2 修改yaml配置,控制插入位置
yolov5通过yaml文件描述整个网络的拓扑结构,模型类会根据yaml自动创建对应模块。以yolov5s.yaml为例,backbone部分长这样:
backbone: - [-1, 1, Conv, [64, 6, 2, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 3, C3, [128]] - [-1, 1, Conv, [256, 3, 2]] - [-1, 6, C3, [256]] - [-1, 1, Conv, [512, 3, 2]] - [-1, 9, C3, [512]] - [-1, 1, Conv, [1024, 3, 2]] - [-1, 3, C3, [1024]] - [-1, 1, SPPF, [1024, 5]]方括号里每项的含义是:from表示输入来自哪一层,number表示模块重复次数,module是模块名称,args是模块参数。我要在backbone最后一层SPPF后面插入CBAM,只需要加一行:
- [-1, 1, SPPF, [1024, 5]] - [-1, 1, CBAM, [1024]]其中的[1024]会被当作CBAM的in_planes参数传入。如果你想在neck部分也加,比如在第一个上采样后的C3后面,找到对应位置再插入一行即可。这种方式的好处是不动任何网络组装代码,只改yaml,插哪里、插几个都由配置文件控制。
如果你用的是yolov5 v8之后的版本,C3换成了C2f结构,原理相同,只要保证CBAM接收到的是当前层的输出通道数即可。
3.3 修改解析逻辑,让新模块跑起来
yaml写好了并不代表能直接跑。yolov5的模型创建逻辑在models/yolo.py的parse_model函数里,它需要知道每个模块的输入输出通道怎么算。
打开yolo.py,在文件顶部导入处加上CBAM:
from models.common import CBAM然后找到parse_model中这段类似的逻辑:
elif m in {Conv, C3, SPPF, ...}: c1 = ch[f] args = [c1, *args]CBAM不在这个集合里,如果不改,它就会落到普通的默认分支。CBAM的第一个参数是输入通道数,而默认分支不会自动补充通道数,后面一运行就会报参数缺失或类型不匹配。解决办法是把这个集合改成:
elif m in {Conv, C3, SPPF, CBAM, ...}: c1 = ch[f] args = [c1, *args]这样解析器就会自动把前一层的输出通道数传给CBAM。例如上一层的输出通道是1024,那么args就会变成[1024, 1024],其中前一个1024是in_planes。CBAM初始化时只接收一个位置参数,多传的会报错?不会——这时候需要看一眼CBAM的构造函数签名,它只定义了in_planes, ratio, kernel_size。如果解析器传入的是[1024, 1024],那就是两个位置参数,CBAM会把第二个1024当成ratio,导致ratio变成1024,这就不对了。
所以这里不能照搬C3那套args = [c1, *args]逻辑。正确做法是在yaml里只写通道数,并在解析逻辑中给CBAM单独处理:
elif m is CBAM: c1 = ch[f] args = [c1]这样CBAM拿到的只有in_planes,ratio和kernel_size用默认值16和7。如果你希望用自定义ratio,可以写成[1024, 8],然后把解析逻辑改成args = [ch[f]] + args[:1]之类的形式。不过日常实验用默认值就够了,没必要为了灵活性把代码搞复杂。
3.4 预训练权重怎么办
这是很多人忽略但一定会遇到的一步。yolov5s.pt这种预训练权重是在没有CBAM的原始结构上训练的,模型定义文件里多了CBAM模块后,网络的state_dict和权重文件的key就不再一一对应。直接加载会报错,常见提示是Missing key(s) in state_dict或Unexpected key(s)。
最简单粗暴的方案是忽略预训练权重,从零开始训练。但CBAM这种模块收敛速度不算快,从零训练一个检测模型往往要几百个epoch,在数据量不大的时候很难达到预训练模型的效果。
我推荐的做法是加载预训练权重时使用strict=False,让能匹配的层正常加载,CBAM相关层随机初始化继续训练。实际修改位置在train.py里,把加载权重的部分改为:
ckpt = torch.load(weights_path, map_location='cpu') model.load_state_dict(ckpt['model'].float().state_dict(), strict=False)这样做的逻辑是:CBAM只会对原有特征做重标定,不会改变主干网络的基础特征表达能力。让主干网络沿用预训练权重,CBAM从零学起,相当于在已经会“看”的网络上加了一个“聚焦”模块。实测在中小数据集上,这种方式比完全从头训练快很多,最终精度也更高。
4. 训练实测:效果、坑位与调优经验
4.1 训练时重点盯哪些指标
加CBAM之后,训练时的观察维度主要看三个方面:loss曲线、mAP、以及收敛速度。
我在本地数据集上做了对比训练,同一个数据集、同样600个epoch、同样的超参数,一组是原始yolov5s,一组是在backbone末尾和neck各插入CBAM的yolov5s。先说loss,加了CBAM的版本刚开始box_loss会比原版低一点点,但收敛趋势基本一致,没有出现loss发散或者长时间不下降的情况。这说明CBAM的梯度传播是稳定的,它的结构不复杂,不会给反向传播带来额外风险。
mAP的变化是我最关心的。训练到中后期,加了CBAM的版本在验证集上的mAP@0.5大约比原版高出1.1个百分点,mAP@0.5:0.95高出0.7个百分点。这个提升幅度没有网上一些人吹的那么夸张,但在检测任务里已经算是实打实的增益。尤其是遮挡严重的目标类别,单独看那一类的AP,提升接近2个百分点。
收敛速度方面,CBAM的加入让模型在前期更快进入稳定状态。原因很好理解:注意力机制相当于给网络加了一个特征筛选的先验结构,让它少走弯路。不过要注意的是,这个效果不是无条件的,它和你插入的位置、数量有很大关系。我一开始在backbone的每一层后面都插了CBAM,结果显存直接爆掉,训练速度掉了接近30%,效果反而没有只插两三个位置好。
4.2 我踩过的坑和排查记录
第一个坑是参数解析错误。前面提到过,把CBAM放在C3同组处理时,parse_model会给它多传一个通道数。当时我没细看,运行后报了个很隐蔽的维度错误,一度以为是CBAM代码写错。排查方法是在CBAM的__init__里打印收到的in_planes和ratio的值,一眼就看出了问题。
第二个坑是训练速度下降。CBAM本身的计算量不大,但空间注意力里的7×7卷积在分辨率较高的特征图上运行时会比较耗时。如果你在neck部分多个高分辨率层都插入CBAM,训练速度能明显感觉到下降。解决思路有两个:一是把kernel_size从7改成3,二是把ratio从16调成32,减少通道注意力部分参数。精度上会有轻微损失,但速度能回来不少。
第三个坑是ONNX导出问题。我训练完后想导出ONNX做推理加速,发现导出过程中空间注意力的torch.max操作在部分版本下会多输出一个无用的索引张量。解决办法是确保torch.max(x, dim=1, keepdim=True)的第二返回值用_接收,同时在导出时用opset=11及以上版本,基本不会报错。如果还不行,可以把空间注意力里的torch.max临时改成torch.amax,效果等价,对导出更友好。
第四个坑是数据增强的影响。CBAM对特征的权重进行了重标定,如果你的数据集本身比较小,或者增强策略太激进,比如mosaic和mixup同时开到很大,注意力模块可能学到的是增强后图像的噪声分布,而不是目标本身的语义特征。我的做法是在加了CBAM后,把mosaic的概率从1.0降到0.8,让模型有更多机会看到干净的原图,实测mAP有一定回升。
4.3 加CBAM之后的收益有多大
衡量收益不能只看mAP数字,还要看推理速度和参数量变化。我的测试环境是单张消费级显卡,batch size为16,输入分辨率640×640。原始yolov5s单张推理耗时大约5.2毫秒,加了3个CBAM后大概6.1毫秒,增加了17%左右。对于实时检测项目,这个代价还能接受,但如果部署在边缘设备上,就要慎重考虑。
参数量方面,我在backbone末尾插入CBAM后,模型总参数量从大约7.2M增加到7.9M,增加了10%左右。如果只加一个CBAM,增加不到2%。所以为了控制体量,我最终保留了两处CBAM:一处放在backbone的SPPF之后,一处放在第一个上采样后的C3之后。这样既显著提升了遮挡目标和小目标的检测能力,又把推理耗时控制在6.5毫秒以内,参数量只在原基础上增加了6%。
如果你的项目对速度极度敏感,比如要部署到低端CPU或嵌入式设备,我建议只加一个CBAM在backbone末尾,并且把kernel_size改成3、ratio改成32。这样推理耗时增加控制在5%以内,精度依然能提升0.3到0.5个点,属于性价比比较高的方案。
再分享一个调参的小技巧:CBAM的ratio其实可以做成可配置的,在yaml里传第二个参数,运行时解析逻辑自动读取。我后来为了对比不同ratio的效果,专门改了一版,发现对小目标比较多的数据集,ratio取8反而比16好。原因是小目标特征本身比较弱,通道注意力需要更强的表达能力来区分细微差异。但ratio调小的代价是参数量翻倍,所以这需要在实验里自己权衡。
最后说说训练时怎么评估CBAM是否真的在起作用。你可以在训练前随机取一批验证集图片,把CBAM输出的空间注意力权重图可视化出来。如果训练几个epoch后,注意力图的响应区域确实集中在目标周围,说明模块学对了;如果注意力图一直是均匀分布,就要考虑是不是插入位置不对,或者学习率设置有问题。这一步能帮你提前发现问题,省得等整个训练跑完才发现模型一点没学到东西。