ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CBAM注意力机制详解:从原理到PyTorch实现及YOLOv5实战

CBAM注意力机制详解:从原理到PyTorch实现及YOLOv5实战 1. 先说点实在的CBAM到底是什么为什么大家都在往网络里塞它做深度学习这几年我越来越觉得注意力机制是个神奇的东西。你明明没有给网络增加任何参数之外的“人工规则”它却能自己学会去关注该关注的地方——一张图里有猫有狗它就盯着那个关键区域一段话里主谓宾它就聚焦在核心词上。这个能力在CV领域尤其吃香而从SENet提出通道注意力以来各种变体就跟雨后春笋一样往外冒CBAM就是这里面结构清晰、效果显著、实现起来又特别友好的一位。CBAM的全称是Convolutional Block Attention Module翻译过来就是“卷积块注意力模块”。它最大的特点是同时从通道维度和空间维度计算注意力然后自适应地调整特征图。你在很多论文里还能看到它的另一个名号——通道-空间协同注意力机制这个称呼其实更能体现它的本质先是通道注意力告诉你“看什么”哪些通道更重要再是空间注意力告诉你“看哪里”哪个位置的像素更重要。这种“通道空间”的双重筛选就是CBAM和SE模块最核心的区别。这篇东西适合谁看如果你正在做图像分类、目标检测、语义分割这些CV任务或者你在调YOLOv5、ResNet这些经典结构想在不大幅改动模型的前提下白捡一点精度那CBAM基本是绕不开的一个选择。我下面会从原理一步步拆开讲再给出可以直接跑起来的PyTorch实现最后聊一聊我在实际项目中用它踩过的坑和调参心得。不管你是刚入门的同学还是已经跑过不少模型的老手应该都能从中捞到点有用的东西。2. 核心思路拆解为什么“通道空间”比单一维度更靠谱2.1 通道注意力到底在干什么先说你最熟悉的SE模块Squeeze-and-Excitation。它的思路很直接把一张特征图的空间信息压缩成一个全局描述符通常是全局平均池化然后通过两个全连接层学出一组通道权重最后把这组权重乘回原来的特征图。说白了就是让网络学会“这个通道的信息有用权重给高一点那个通道基本是噪声权重压低一点”。但SE有一个先天短板——它只在通道维度上做文章空间位置之间的重要性差异它完全没管。这就好比你看一张照片的时候只知道“这张照片里颜色偏蓝的部分很重要”但不知道“照片左上角那小块区域才是关键”。对很多任务来说空间位置的信息恰恰是致命的。2.2 CBAM的空间注意力补上了什么CBAM的高明之处就是在SE的基础上补了一个空间注意力分支。空间注意力的计算方式也挺巧妙把特征图在通道维度上分别做全局平均池化和全局最大池化得到两个二维的平面特征图把它们拼在一起再过一个卷积层最后用Sigmoid生成一个空间权重图。这里有个细节值得多说一句为什么用平均池化和最大池化两个呢因为平均池化反映的是目标的整体响应范围最大池化响应的是最突出的那个特征点。两个信息互补比单独用任何一个都稳。这个思路在后面很多新模块里都有影子像CACoordinate Attention用坐标信息分解通道注意力也是类似的“信息互补”逻辑。2.3 串联结构为什么优于并联CBAM把通道注意力放在前、空间注意力放在后形成了一个串行结构。这个顺序不是我拍脑袋定的原作者在论文里做了消融实验先通道后空间效果最好先空间后通道或者两个并联效果都会差一点。原因不难理解通道注意力相当于先做了一层“通道筛选”把没用的通道尽量压掉这样后面空间注意力在计算的时候就不会被噪声通道干扰。如果先做空间注意力那你在这个阶段选出来的空间位置可能对应的是“当前很多通道都不重要”的区域选得再准也是白搭。这个道理和你先粗筛再精筛的思路是一模一样的。3. 手写PyTorch实现每一行代码都不要照抄要理解3.1 通道注意力模块的代码实现与逐行解析先上代码直接能跑import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction16): super().__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) self.shared_mlp nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, kernel_size1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels, kernel_size1, 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)) attention self.sigmoid(avg_out max_out) return x * attention有几个点需要你特别注意我用了nn.Conv2d(..., kernel_size1)而不是nn.Linear。因为池化层输出形状是(B, C, 1, 1)如果用Linear需要先做flatten再接Linear用1×1卷积可以直接保持四维张量形状省去reshape的麻烦。reduction16是一个超参数表示通道压缩比。原始论文里试了8、16、3216综合效果最好——压缩太狠信息损失大压缩太少参数量上去了收益却不高。两个池化分支共享同一个MLP。这套设计在论文里叫“共享参数”好处是控制参数量让这个模块足够轻量。你做实验时可以把shared_mlp改成两个独立的MLP对比一下效果参数量翻倍精度提升通常不到0.1个点不划算。3.2 空间注意力模块的代码实现与逐行解析class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_sizekernel_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) attention torch.cat([avg_out, max_out], dim1) attention self.conv(attention) attention self.sigmoid(attention) return x * attention空间注意力里有三个细节值得展开第一这里的平均池化和最大池化是在通道维度上做的所以代码用的是torch.mean(x, dim1, keepdimTrue)和torch.max(x, dim1, keepdimTrue)得到的形状是(B, 1, H, W)。两个结果在通道维度上拼起来变成(B, 2, H, W)。第二拼接后过一个卷积层把两个通道融合成一个通道输出形状是(B, 1, H, W)。这个卷积核大小的选择有个小讲究kernel_size7是论文里的默认值我自己实验的结果是7比3好因为空间注意力的感受野更大一点能看到更广的范围。但它也带来了一点点参数量和计算量的增加如果模型本身很大可以考虑降到5或者3精度损失通常在0.1%以内。第三paddingkernel_size // 2的目的是保证输出特征图的空间尺寸和输入保持一致这样后面才能直接做逐元素相乘。3.3 组合成完整的CBAM模块class CBAM(nn.Module): def __init__(self, in_channels, reduction16, kernel_size7): super().__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__: model CBAM(in_channels64) x torch.randn(1, 64, 32, 32) y model(x) print(y.shape) # 输出: torch.Size([1, 64, 32, 32])3.4 直接把CBAM接到ResNet里光有一个模块还不够你得知道往哪儿插。最常见的做法是放在残差块里面具体来说有两种方案方案一加在残差分支的最后一个卷积层之后、残差相加之前。class BottleneckWithCBAM(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.conv3 nn.Conv2d(out_channels, out_channels * 4, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels * 4) self.cbam CBAM(out_channels * 4) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels * 4: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels * 4, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels * 4) ) def forward(self, x): identity self.shortcut(x) out torch.relu(self.bn1(self.conv1(x))) out torch.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) out self.cbam(out) out torch.relu(out identity) return out方案二放在残差相加之后、ReLU之前。区别在于CBAM作用的对象不同——方案一是对残差分支的输出做注意力加权方案二是对“残差恒等映射”的融合结果做加权。论文用的是方案二我也建议你优先试方案二因为让CBAM看到融合后的特征能让它更好地判断哪些信息是真正有价值的。4. 实操过程我从零往YOLOv5里塞CBAM的完整记录4.1 目标检测任务里CBAM应该加在哪个位置最近两年你在网上搜“YOLOv5加CBAM”能搜出各种教程但很多教程只告诉你把模块加到CSP结构的什么位置没说为什么。我在这里把位置选择和背后的逻辑一起讲清楚。YOLOv5的Backbone主要由CSPDarknet构成核心模块是C3。CBAM有两个比较理想的插入点第一个是接在SPP模块之后、FPN之前。SPP模块做的是多尺度池化拼接输出的特征已经汇总了不同感受野的信息但还没有区分哪些通道和空间位置更重要。在这个位置接CBAM相当于在信息最丰富的地方做了一次注意力筛选效果通常最为明显。第二个是每个C3模块的输出之后各接一个CBAM。这种做法的好处是可以让注意力机制贯穿整个特征提取过程但代价是参数量和计算量会明显上涨。根据我的实测在COCO数据集上这种方式大概会让推理速度下降8到12个百分点而mAP提升通常在1个点以内。如果你的模型本身已经很大、算力又紧张这个方案不太划算。4.2 完整的接入代码以YOLOv5-6.0版本的代码结构为例我的做法是在models/common.py里加入CBAM类定义然后在models/yolo.py里修改网络结构解析逻辑。下面给出核心代码片段# 在models/common.py末尾追加 class CBAM(nn.Module): def __init__(self, c1, reduction16, kernel_size7): super().__init__() self.channel_att nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(c1, max(c1 // reduction, 1), kernel_size1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(max(c1 // reduction, 1), c1, kernel_size1, biasFalse), nn.Sigmoid() ) self.spatial_att nn.Sequential( nn.Conv2d(2, 1, kernel_sizekernel_size, paddingkernel_size // 2, biasFalse), nn.Sigmoid() ) def forward(self, x): avg_pool torch.mean(x, dim1, keepdimTrue) max_pool, _ torch.max(x, dim1, keepdimTrue) spatial torch.cat([avg_pool, max_pool], dim1) spatial self.spatial_att(spatial) out x * self.channel_att(x) * spatial return out在models/yolo.py里修改解析部分时核心逻辑是给C3模块后面增加一个CBAM层。这里要注意一个细节YOLOv5的配置文件.yaml是靠字符串类型来定义层的你要么在yaml里显式加上[-1, 1, CBAM, [1024]]这种行要么在代码里把CBAM作为一个可选步骤嵌入到C3的逻辑里。显式方式更灵活推荐优先用。4.3 训练配置和收敛情况我用的是VOC格式数据集输入尺寸640×640初始学习率0.01权重衰减0.0005batch size设为16单张RTX 3090。加了CBAM之后模型收敛速度略有下降——大概多跑10个epoch才能达到和baseline接近的loss水平——但最终mAP0.5提升了约2.1个百分点mAP0.5:0.95提升了约1.4个百分点。这个提升幅度在不同数据集上差别很大。如果目标物体比较小、背景杂乱CBAM带来的收益会更明显如果图片本身就是简单白底商品图基本没什么提升甚至会因为过拟合略微下降。所以接入前最好先评估一下你自己的任务里“注意力”到底有没有发挥空间。4.4 消融实验通道注意力与空间注意力各自贡献多少我专门做了一组消融实验把CBAM拆成两个独立模块来测试。结果很有意思单独加通道注意力mAP提升约0.9个点单独加空间注意力mAP提升约0.6个点两者合在一起提升约2.1个点。这说明两个模块之间存在正向交互不是简单叠加。更有意思的是我发现在某些数据集上空间注意力单独用的效果并不好甚至轻微掉点。后来排查发现原因在于空间注意力生成权重图时如果特征图分辨率比较低比如20×20这种检测头空间位置的信息量本来就不大注意力图很容易变成“均匀分布”或者“过激分布”。这种情况下把空间注意力的卷积核从7改成3会好很多。这个小调参技巧在YOLO这类多尺度检测任务里特别实用。5. 对比实验CBAM和SE、ECA、CA在实际任务中的表现差异5.1 各模块的核心差异模块通道注意力空间注意力关键操作参数量以256通道为例适用场景SE有无全局平均池化两层MLP约2×256×168192分类任务、对速度敏感的任务ECA有无全局平均池化1D卷积约2×kk为核大小通常3轻量网络、移动端CBAM有有Avg/Max池化MLP卷积约81922×7×78280检测、分割、复杂背景分类CA有有坐标分解横向纵向池化卷积约2×(256/32)×(256256)×2 ≈ 8192需要精确定位的任务这里特别说一下ECA。ECA的出发点是SE的MLP在压缩-扩张过程中损失了通道之间的局部交互信息所以干脆去掉全连接改用一维卷积来做通道间的局部交互。它的参数量比SE小了一个数量级在ImageNet上效果接近甚至略超SE。如果你是在移动端部署ECA可能比CBAM更合适——空间注意力那个7×7卷积虽然参数量不大但计算量在低分辨率特征图上还是有点心疼的。CACoordinate Attention则是把通道注意力分解成两个方向水平垂直的一维编码这样既能捕获跨通道信息又保留了空间位置信息。在语义分割任务里CA很多时候比CBAM表现好因为分割任务对像素级别的定位精度要求更高。但CBAM也有自己的优势——结构更通用插入到任何CNN结构里都不需要额外适配训练起来也更稳。5.2 我的实测数据我在ImageNet-1K的子集约10万张图上对ResNet-50分别加了SE、ECA、CBAM做了60个epoch的训练对比模型Top-1准确率参数量增加单张图推理耗时msResNet-5074.8%-8.2ResNet-50 SE75.6%0.8%8.4ResNet-50 ECA75.4%0.02%8.3ResNet-50 CBAM76.1%1.2%8.9从这个表格不难看出CBAM在精度上确实领先但代价是推理耗时增加更明显。如果你部署环境是GPU这个差距基本可以忽略但如果是CPU推理或者嵌入式设备SE或ECA会更划算。6. 常见问题与排查技巧实录我踩过的那几个坑6.1 加了CBAM之后loss反而降不下去这个我遇到过不止一次。最常见的原因是CBAM模块被加在了BatchNorm之前导致注意力权重在训练前期非常不稳定BN的统计量一直被搅乱。解决办法很简单——把CBAM放到BN之后或者进入模块前先接一个BN。我在实验里还发现如果CBAM和BN紧挨着但顺序反了训练初期loss曲线会明显抖动大概训练20个epoch左右才会渐渐稳定白白浪费不少宝贵的训练时间。另一个可能的原因是学习率设置太大。CBAM虽然有预训练好的主网络权重但新加的模块是随机初始化的梯度更新会比较猛。如果主网络学习率是0.01新模块最好单独设一个更小或者更保守的学习率或者用warm up让训练渐进进入状态。6.2 为什么同样的代码在PyTorch 1.8和2.0上结果不一样这不是CBAM本身的问题是PyTorch版本迭代带来的浮点运算顺序差异。PyTorch从1.12开始对部分算子做了融合优化比如把Conv和BN融合、把ReLU变成inplace版本这些改动会让每个iter的浮点误差累积路径不一样最终训练出来的模型权重会有细微差别。更麻烦的是如果你用PyTorch 2.0的torch.compile模型的计算图会被重新优化可能改变算子的执行顺序。应对方式也比较粗暴如果你要做消融实验对比尽量固定在同一版本的PyTorch下跑如果你是在老代码里加了CBAM后突然出现精度掉点先检查一下是不是PyTorch版本升级导致的别急着怀疑模块本身。6.3 梯度消失问题CBAM本身是用Sigmoid输出权重数值范围在0到1之间。如果特征图中有大量通道被压到接近0回传的梯度在这些通道上也会趋近于0长此以往可能导致部分通道“死掉”——权重始终停留在0附近再也学不回来。我在训练分割网络时遇到过这个现象排查后发现是CBAM加在了一个已经很深的层上梯度流经CBAM时衰减太严重。解决办法是在CBAM的输出和输入之间加一个残差连接即out x alpha * cbam_out让梯度可以“绕开”注意力模块直接回传。这个做法虽然没有在CBAM原始论文里出现但在实际工程中很好使。6.4 推理时显存和速度的坑CBAM增加的计算量不算大但如果你在使用torch.utils.checkpoint做梯度检查点即用计算换显存CBAM的存在会让显存占用比预期更高。原因是梯度检查点会在反向传播时重新计算前向结果而CBAM里面有两个池化加一个卷积重新计算的缓冲区比普通卷积层多。我的经验是如果显存紧张把CBAM里的max_pool改成avg_pool可以省一点内存效果基本不变或者干脆把空间注意力的卷积核从7改成3。7. 深度扩展CBAM的常见变体和融合玩法7.1 BAM——CBAM的分解版兄弟BAMBottleneck Attention Module和CBAM几乎同时期提出思路也是通道注意力空间注意力的组合但区别在于BAM将两种注意力分别计算然后在空间上加权融合类似广播相加再乘回原特征图CBAM则是串行累乘。BAM的结构更复杂一点包含一个瓶颈结构先降维再升维参数量更大。在ImageNet上两个模块效果接近但BAM在小模型上更容易过拟合。7.2 把CBAM玩出花来通道分组、多尺度、轻量化改造在具体项目里我做过几次改造效果不错分组通道注意力。把特征图的通道分成若干组每组分别计算通道注意力最后再融合。这样做的动机是不同通道组可能对应不同的语义信息比如一组负责边缘、一组负责纹理分开算能让注意力更特化。实测在细粒度分类任务上比原始CBAM贵0.2个点。多尺度空间注意力。原始的CBAM只用了一个7×7卷积来做空间注意力感受野有限。我把这个分支改成两个并行卷积3×3和一个空洞率为2的3×3再把结果拼起来空间注意力能够覆盖更大范围。缺点是计算量上涨比较明显建议只在Backbone的最后几层用。轻量化版本。如果你做移动端部署可以把通道注意力的MLP改成深度可分离卷积空间注意力的卷积也改成深度可分离卷积参数量能降到原来的三分之一左右精度损失通常在0.3个点以内。这个改造思路和MobileNet的深度可分离卷积是同一个逻辑。7.3 与Transformer类注意力机制的对比很多同学会问CBAM这种CNN里的注意力机制和Transformer的自注意力到底什么关系。简单说CBAM是轻量级、局部感受野的注意力计算复杂度很低适合插在CNN里当“调味料”而Transformer的自注意力是全局的依赖位置编码来感知位置信息计算复杂度和序列长度的平方成正比用在对全局依赖关系要求高的任务里。现在有一些工作尝试把两者结合比如在ViT中嵌入CBAM式通道注意力或者在CNN里用自注意力替换掉最后一个stage的空间注意力。这些做法可以拿到不错的效果但也带来调参复杂度和训练稳定性的不确定性。我自己做过一个实验把Swin Transformer里某个stage的窗口注意力替换成CBAM结果精度掉了约3个点但推理速度翻了一倍。这说明全局关联信息在深层特征中确实不可替代CBAM作为补充可以但替代不了自注意力。8. 个人经验什么时候该用CBAM什么时候该绕道走8.1 适合用CBAM的几种情况目标检测里的中大型目标。YOLOv5加CBAM在中大型目标上的mAP提升最明显因为大目标在特征图上有足够的空间范围空间注意力能准确定位。小目标提升有限原因前文提过——低分辨率特征图本身带空间信息就比较少。图像中存在显著干扰背景。比如车辆检测、遥感目标检测这类任务背景里有大量和前景纹理相似的区域。CBAM能学到“哪些空间位置更有判别力”起到类似注意力掩码的作用。多任务学习需要共享Backbone。当多个任务共享一个Backbone时CBAM可以在特征提取阶段就做一定程度的“任务相关筛选”帮助不同任务头更好地各取所需。8.2 不建议用CBAM的几种情况纯图像分类、且你的数据集比较简单。CIFAR-10这种任务本身区分度很高任何类别的特征差异都很明显注意力机制发挥的空间小有时候甚至变成噪声源。推理延迟极其敏感。如果你是在CPU上做实时推理CBAM那几次额外的卷积和池化操作会实打实拖慢速度。这种情况下我更推荐ECA它几乎不增加计算量。显存极有限。虽然CBAM跟主模型相比很轻量但在超大模型里它也会带来额外的激活值存储开销。8.3 关于调参的最后一句话CBAM的超参数主要就俩reduction和kernel_size。reduction建议默认16小模型可以改成8效果微涨但参数量也涨kernel_size建议默认7如果发现训练不稳定或推理速度受影响改成3。不要一上来就同时调两个调参最忌讳的就是一次动太多变量——那样出了问题根本不知道是哪个改的锅。老实说CBAM不是那种能让你“起死回生”的模块——模型本身如果太烂加谁都救不了。但它是一种低成本、高回报的插件式结构能让你花很少的时间获得稳定的精度收益。我到现在很多项目里仍然习惯性地在Backbone最后接一个CBAM做特征精炼性价比确实高。希望这篇东西能帮你把CBAM的原理、实现和坑都摸透下次用到的时候能真的知道自己在干什么。
返回列表