CBAM注意力机制详解:通道与空间双维度提升CNN性能
1. CBAM是什么从“只看通道”到“通道空间”两步走做视觉识别这些年注意力机制几乎是我调模型绕不开的“标配组件”。不管是分类、检测还是分割只要在主干网络里塞一个轻量级的注意力模块常常能白捡一两个点的精度。而这一堆注意力机制里CBAMConvolutional Block Attention Module应该算是最容易上手、也最容易讲明白的一个。CBAM是ECCV 2018年的工作作者是Sanghyun Woo等人一句话概括它的核心思路对特征图分别学习通道维度的权重和空间维度的权重然后把这两个权重依次乘回原来的特征图上让网络知道“看什么”和“看哪里”。这个思路听起来简单但它的意义不小。CBAM之前比较流行的是SENet的SE模块SE只做通道注意力把每个特征通道压成一个标量再通过全连接层学出一组权重对特征进行重标定。CBAM在SE的基础上往前走了一步不仅告诉网络“哪个通道重要”还告诉网络“在哪个空间位置上重要”。相当于SE只帮你把灯调亮CBAM则是一盏可以指哪打哪的射灯。适合谁看这篇文章如果你刚接触注意力机制想弄明白CBAM到底怎么算、代码怎么落地或者你已经用过SE想看看CBAM多出来的空间注意力到底有什么价值又或者你在训练时加了CBAM发现效果不稳想排查原因——这些内容应该都能帮到你。我会尽量避开论文里那些绕来绕去的公式表达用大白话把计算过程拆开再附上可以直接抄走的PyTorch实现。1.1 注意力机制到底在做什么理解CBAM之前先想一个问题卷积网络提取出来的特征图在某一层可能长成[B, C, H, W]这个样子B是batch sizeC是通道数H和W是特征图的高和宽。C个通道可以理解成C种特征的响应图有的通道对“边缘”敏感有的通道对“纹理”敏感有的通道对“颜色”敏感。但问题是这些通道并不都是一样重要的。举一个例子一张图里有一只猫趴在沙发上背景是磨砂皮质的沙发面。网络提取到的特征图里负责猫的轮廓、耳朵、胡须的通道应该被突出而沙发纹理的通道反而可能造成干扰。SE模块做的事就是让网络根据全局信息自动给这C个通道打分重要的通道权重给高一点不重要的通道权重压低。这个打分过程叫通道注意力。但通道注意力有个天然的盲区它把每个通道整个压成了一个标量这个标量表达的是“这个通道整体重不重要”却丢了“重要信息具体落在哪个位置”。还是那只猫如果猫在图片右下角那对于“猫头”这个通道来说右下角区域的响应应该被放大左上角区域的响应应该被抑制。空间注意力要解决的就是这个问题。CBAM就是把这两件事串起来先算通道权重再算空间权重两者相乘得到的注意力机制最终作用在原始特征图上。这种串行设计还有一个好处先经过通道调整特征图里的噪声通道已经被压低再做空间判断时结果会更干净。1.2 从SENet到CBAM缺的是“空间”这一步SE模块是CV领域把注意力机制引入主干网络比较早也比较成功的一个工作。SE的思路很直接对特征图做全局平均池化把每个通道压成1x1的数值然后经过两个全连接层先降维再升维和sigmoid得到一组[0, 1]之间的通道权重最后把这组权重乘回原特征图。SE有效但少了空间维度的建模能力。我当时在项目里用SE时有个直观感受它对“物体大、语义清晰”的任务提升比较明显但对那种需要精确空间定位的任务比如检测小目标、分割细长物体提升就很有限。原因就是空间信息的丢失。CBAM等于是在SE的旁边补了一个空间注意力分支。论文里也专门做了消融实验单独加通道注意力、单独加空间注意力、两个一起加结果显示两个一起加的效果最好。而且CBAM的姿态很明确我不是要替代SE而是把SE当作自己的通道注意力子模块再扩展出一个空间注意力子模块形成一套完整的CBAM注意力机制。2. 通道注意力和空间注意力的完整计算流程2.1 通道注意力全局池化加共享MLP通道注意力的输入是一张特征图F形状为[C, H, W]。第一步对F分别做全局平均池化和全局最大池化得到两个长度为C的向量。为什么要做两次池化而不是像SE那样只做平均池化我后面会专门说这里的核心是平均池化能保留全局上下文最大池化能抓住最显著的特征两者互补。得到两个C维向量之后把它们分别送入一个共享的全连接网络MLP。这个MLP的结构是固定的第一层把C降成C/rr是reduction ratio论文里默认取16然后过一个ReLU第二层再升回C。这里有个细节两层全连接是共享权重的也就是说平均池化得到的向量和最大池化得到的向量走的是同一个MLP。MLP输出的两个C维向量做完逐元素相加再过sigmoid得到一组通道权重Mc形状是[C, 1, 1]。最后把Mc乘到原特征图F上相当于每个通道乘了一个0到1之间的系数。这就是通道注意力的输出F。整个过程用生活化的说法就是先用“全体打分”和“尖子生打分”两种方式各统计一遍每个通道的重要性然后取一个综合意见最终得到一个通道维度的加权表。2.2 空间注意力跨通道池化加感受野更大的卷积通道注意力完成之后得到了一张新的特征图F形状同样是[C, H, W]。空间注意力要做的是在H和W这个平面上找出哪些位置值得关注。具体操作分两步。第一步对F在通道维度上做池化——对每个空间位置把所有通道的值取平均得到一张[H, W]的响应图再取最大值得到另一张[H, W]的响应图。注意这里通道维度上的全局平均池化和最大池化和前面通道注意力的池化方向不同前者是沿着空间尺寸池化后者是沿着通道维度池化方向一定不要搞混。第二步把得到的两张[H, W]特征图在通道维度上拼起来得到一个形状为[2, H, W]的结果。然后过一个kernel size为7的卷积层输出通道为1得到一张形状为[H, W]的空间权重图。这里用7x7而不是3x3是为了获得更大的感受野让网络能结合更大范围的上下文来判断某个位置是否重要。最后过sigmoid得到空间权重Ms。把这个空间权重Ms乘回FCBAM的输出F就得到了。空间注意力这个流程用类比说更像是在地图上画热力图先统计“平均热度”和“最高热度”两种指标再用一个能覆盖较大范围的分析器进行综合判断最终标出“热区”。2.3 平均池化和最大池化为什么要成对出现这是CBAM里一个容易被忽视、但特别值得挖掘的设计。在通道注意力里SE只用了平均池化CBAM用了平均加最大在空间注意力里同样也是平均加最大成对出现。论文里专门做过消融实验把这四种情况列了一个表。平均池化倾向于保留特征的全局语义信息因为它求的是所有值的均值不会因为几个特别大的值而失真。最大池化则相反它只保留响应最强的那个位置相当于在说“这个特征在图里出现过一次就算数”。一个有全局视野一个够敏锐结合起来往往比单独用任何一个都稳。我在实际使用中的经验也印证了这一点。早期我在一个细粒度分类任务里用过一个简化版CBAM把空间注意力里的最大池化去掉了只保留平均池化结果是训练收敛更快但最终精度比完整版少了约0.5个百分点。后来把最大池化加回去精度又回来了。这类差异很难用理论完全解释清楚但实验数据摆在那里照着做基本不会错。3. 即插即用的CBAM集成方式与PyTorch代码3.1 模块接线顺序通道在前、空间在后CBAM里两个子模块的顺序是固定的先通道注意力再空间注意力。这个顺序是论文作者通过实验验证过的。可以先从直觉上理解一下通道注意力在做全局调整相当于把有价值的通道拉高、没价值的通道压低经过这一轮调整之后特征图中的噪声已经少了很多此时再做空间注意力更容易聚焦到有意义的区域。反过来如果先做空间注意力空间权重会在充满噪声通道的特征图基础上计算判断质量自然要打折扣。如果要用一句话记住CBAM的流程就是F Mc(F) * F接着 F Ms(F) * F。两个权重都经过sigmoid取值范围在0到1之间所以CBAM本身不会改变特征的尺度范围它只是对不同通道、不同位置的特征进行重标定。这个特性让CBAM在集成到现有网络时基本不需要对原模型的输入输出结构做任何改动。3.2 完整PyTorch实现下面这份代码是我在项目里一直在用的版本结构上算是CBAM论文的PyTorch忠实实现。我把注释写详细一点方便你直接抄。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, reduction16): super(ChannelAttention, self).__init__() # 两个池化全局平均和全局最大输出都是 [B, C, 1, 1] self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) # 共享 MLP先降维到 C/rReLU再升维回 C self.shared_mlp nn.Sequential( nn.Conv2d(in_planes, in_planes // reduction, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_planes // reduction, in_planes, 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)) # 两个分支相加后过 sigmoid得到通道权重 scale self.sigmoid(avg_out max_out) return x * scale class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super(SpatialAttention, self).__init__() # 输出通道固定为1输入固定为2平均池化结果和最大池化结果在通道维拼接 self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): # 在通道维上做平均池化和最大池化保留 spatial 维度 avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) # 拼成 [B, 2, H, W] out torch.cat([avg_out, max_out], dim1) out self.conv(out) scale self.sigmoid(out) return x * scale class CBAM(nn.Module): def __init__(self, in_planes, reduction16, kernel_size7): super(CBAM, self).__init__() self.channel_attention ChannelAttention(in_planes, reduction) self.spatial_attention SpatialAttention(kernel_size) def forward(self, x): x self.channel_attention(x) x self.spatial_attention(x) return x这段代码有几个地方值得注意。第一MLP用的是1x1卷积而不是全连接层效果上等价但好处是输入输出都保留了[B, C, 1, 1]的形状调用起来更省心。第二空间注意力的卷积用了7x7padding设为3保证输出尺寸和输入一致。第三两个子模块都没有改变输入特征的通道数、高和宽这正是即插即用的关键。如果你的输入特征图尺寸比较特殊比如H和W只有4甚至更小7x7卷积的padding为3时仍然可以保持尺寸不变但感受野会覆盖整个图此时可以酌情把kernel size改成5或3。代码里kernel_size这个参数就是为此留的默认7可以根据你的特征图分辨率调整。3.3 两种接入主干网络的方式把CBAM接入现有模型我见过两种比较主流的做法。第一种是塞进基础Block内。以ResNet为例在两个卷积和BN之后、残差相加之前插入CBAM。下面是一个我常用的BasicBlock写法方便理解CBAM和残差结构的相处方式。class BasicBlockCBAM(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super(BasicBlockCBAM, self).__init__() self.conv1 nn.Conv2d(inplanes, planes, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(planes, planes, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.downsample downsample self.stride stride # 在第二个卷积和BN之后、残差相加之前做注意力 self.cbam CBAM(planes) def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.cbam(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out第二种做法是直接放在整层输出之后比如在ResNet的layer2、layer3、layer4后面各接一个CBAM。这种做法适合你对原有block代码不想做太多改动的情况直接在forward里插入模块就行。两种接入方式的效果差异论文里也做过实验。结论是CBAM放在block内部、最后一个卷积之后的效果最好。我自己的经验也倾向于第一种放在block内部的CBAM融入网络更深能跟着每一层的梯度一起优化而不是作为一个“外挂”被单独训练。当然放在block外部也不是不能用只不过调参时可能要更小心一些。4. 训练中的超参数、消融实验与落地思考4.1 reduction ratio和kernel size怎么选先看channel attention里的reduction ratio r。这个参数控制MLP中间隐藏层的宽度决定通道注意力分支的计算量。r越大中间层越窄参数越少但表达能力也会下降r越小参数越多效果可能更好但有过拟合和显存压力。论文默认r16这也是跨数据集比较通用的一个值。你在做高分辨率或大通道数模型时可以把r调成8试试效果。但要注意如果降维后的通道数小到只有个位数比如in_planes是32r8之后就是4信息损失会很明显此时建议r16。如果in_planes本身就很小小于64甚至可以考虑r4。总的来说保证中间隐藏层不少于8个通道是一个比较稳妥的经验值。再看spatial attention里的kernel size。论文对kernel size3、5、7做过对比最后7效果最好。原因是更大的卷积核能覆盖更大的上下文范围对空间位置的判断更准确。但如果你的特征图分辨率很小比如H和W只有7或8那7x7卷积的kernel会覆盖几乎整张图效果就会打折扣此时把kernel size降到3或5反而更稳。4.2 论文里的实验结果能涨多少CBAM论文在ImageNet-1K和MS COCO上都做了大量实验。把ResNet-50当作骨干网络时加入CBAM后top-1准确率从76.85%左右提升到了78.09%左右比只加SE模块的77.21%还要再高一些。在MS COCO的目标检测和实例分割任务上加入CBAM之后检测的box AP和分割的mask AP也都有约1个点的提升。这个提升幅度放在现在的视角看不算惊艳但在2018年那个时间点用这样一个轻量级模块白捡一两个点吸引力非常大。更重要的一点是CBAM的参数量和计算量增量非常小。以ResNet-50为例整个模型增加的计算量不大FLOPS增幅在个位数百分比以内这对训练和部署都比较友好。不过我要提醒一句论文里的涨幅和数据集的分布、任务类型高度相关。你自己在业务数据上复现的时候可能涨得更多也可能涨得很少。我在做一个小规模数据集分类任务时CBAM带来的提升确实超过了2个百分点但在另一个噪声特别大的工业检测任务里涨幅不到0.3。数据本身的质量和难度对注意力模块能发挥多大作用影响非常大。4.3 训练过程中的几个坑踩过几次坑之后我整理了几条比较重要的实践经验。第一个坑是初始学习率的配合。CBAM引入了新参数尤其是channel attention里的MLP如果初始学习率开太大很容易在训练初期震荡。我建议在原有模型训练方案的基础上把初始学习率适当调低一点或者给CBAM分支设置一个较小的学习率倍数。如果用PyTorch的话可以对带cbam字段的参数的lr multiplier做特殊处理。第二个坑是BN层的顺序。CBAM一般插在BN之后这一点很重要。如果插在BN之前池化统计出的均值方差会被BN重新归一化相当于白加了注意力效果会不稳定。我用log记录过两种位置下的训练曲线发现放在BN之后时损失下降更平滑。第三个坑是reduction ratio不是越小越好。r4虽然参数多表达能力强但在小数据集上非常容易过拟合。我见过有人把r从16改成4结果训练集精度涨了、验证集精度掉了。注意力模块的本质是给特征加权重如果这个权重学得太“用力”就会记住训练集里的噪声泛化性反而变差。5. 常见问题与排查实录5.1 维度对不上、程序直接报错这个问题最常发生在channel attention的实现里。如果in_planes不能被reduction整除那in_planes // reduction算出来是整数除法的结果比如3 // 16 0中间卷积的输出通道就变成0立刻报错。解决方案有两种要么保证in_planes能被reduction整除要么改用in_planes // reduction后加上一个max操作保证至少有1个通道。我在代码里习惯写成hidden_planes max(in_planes // reduction, 8)这样不管in_planes是几隐藏层至少有8个通道既避免报错也保证一定的表达能力。空间注意力里最常见的报错是输入通道不是2。比如有的同学从torch.mean(x, dim1, keepdimTrue)改成了torch.mean(x, dim0, keepdimTrue)后面拼接完变成了一条而不是两条Conv2d(2, 1, ...)就会报维度不匹配。这种错误信息其实很明确看到Expected 4-dimensional input或者Given groups1一类的提示先检查一下池化的dim和结果shape。5.2 加上CBAM反而掉点了这可能是最让人头疼的问题。我排查这类问题的顺序是这样的。第一步先确认CBAM插入的位置。前面说过插在block内部、最后一个卷积之后是标准做法。如果你用的是“外挂”式接法注意多个CBAM串联时前面CBAM输出的特征图尺度是否和后面模型层期望的输入匹配。第二步检查reduction ratio是否太小。r小意味着MLP中间层宽参数多在小数据集上容易过拟合。先改回r16试试。第三步看学习率。CBAM引入的新参数对学习率敏感如果分类层的学习率本来就比较大CBAM模块大概率会震荡。把整体学习率降到原来的0.5倍或0.1倍训练三轮看loss曲线是一个比较快的验证方法。第四步检查数据增强。注意力机制对输入分布的扰动比较敏感如果你的数据增强策略特别激进比如随机裁剪的尺度变化很大CBAM训练不稳定也是可能的。建议用标准增强先跑通再逐步加难度。5.3 部署时速度、内存如何取舍CBAM的参数量和计算量都很小但在部署到移动端或边缘设备时还是有几个点值得考虑。空间注意力里的7x7卷积是计算量的主要来源之一如果不差那一点点精度把kernel size从7改成3速度会快不少。对于视频流处理场景这个改动尤其明显。另一个容易忽略的地方是MLP里的两个1x1卷积。在通道数很大的模型里比如最后几层通道数是2048MLP的计算量也会被放大。如果你在部署时发现延时超标可以考虑只在层数较浅、通道数较小的位置加CBAM比如只在layer2和layer3用layer4不加。毕竟CBAM对高分辨率特征图的收益通常更大后面几层特征图太小空间注意力的作用本来就有限。最后说一个我在实际项目中养成的习惯每次接入CBAM之前先单独验证一下两个子模块的输出形状。写一段简单的测试代码随机生成一个[B, C, H, W]的张量过一遍CBAM打印输出形状确认无误后再接进主网络。看起来是多此一举但真的能省掉后面大量Debug时间。# 验证代码 x torch.randn(2, 128, 32, 32) cbam CBAM(128) y cbam(x) print(y.shape) # 期望输出 torch.Size([2, 128, 32, 32])这段代码跑通之后你再把它往ResNet或检测头里放心里就有底了。CBAM这个模块从原理到代码难度并不高。它最聪明的地方是把通道和空间两个维度上的注意力分开处理然后又有机地串在一起。只要你理解了我的思路在多尺度、小目标、细粒度分类这些场景里也可以尝试拓展。希望这篇白话解读能让你在自己的项目里更快地用起来。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →