ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CBAM注意力机制:通道与空间注意力模块全面解析

CBAM注意力机制:通道与空间注意力模块全面解析 大概2018年那会儿图像分类网络的性能拼到了一个阶段后大家开始琢磨除了把网络做得更深、更宽还有没有别的路可以走注意力机制就是在这个背景下被推上舞台的。CBAM全称Convolutional Block Attention Module卷积块注意力模块就是其中一个不是靠加深而是靠聚焦取胜的经典方案。哪怕到今天CBAM依然是目标检测、语义分割、图像分类里低成本提分的首选插件之一相关热搜词像cbam注意力机制cbam通道、空间权重也说明大家对它的关注度一直没降过。这篇博文我打算把它彻底讲透CBAM到底在做什么两个子模块为什么这样设计公式和PyTorch代码怎么一一对应论文里的结论在实际项目里到底灵不灵以及我踩过的坑。尽量不用术语砸人用大白话把原理和实操都交代清楚。CBAM做的事情用一句话概括就是拿到一张中间特征图先通过通道注意力模块告诉网络看什么哪个通道重要再通过空间注意力模块告诉网络看哪里哪个位置重要依次给特征重新分配权重。整个过程不改变特征图尺寸只调整每个通道和每个位置的响应强度所以可以非常方便地插入到现有CNN骨干网络的任意位置。不管你是刚入门注意力机制的初学者还是想在项目里通过加模块提点的工程师又或者是想搞清楚注意力内部实现细节的研究生这篇文章应该都能给你点参考。1. 为什么CNN需要注意力从特征图的平等困境说起1.1 卷积网络的两个天然短板先想一个问题一个普通的卷积层对输入特征图做了什么它用一个共享权重的卷积核在整张特征图上滑动计算。也就是说对于一张H×W的特征图卷积核默认把每个通道、每个位置的权重都一视同仁。人眼看东西不是这样人眼扫到一张合影时会不自觉地先盯住里面的人脸再扫到其他区域。相比之下原始卷积操作像是一个没有视觉重点的扫描仪在有用和无用的信息上花一样的力气。这就带来两个直接的短板。第一个短板在通道维度。假设输入特征图有C个通道不同通道往往对应着不同类型的语义模式有的通道在响应边缘有的通道在响应纹理有的通道在响应某个目标部件。但是对后续层来说它并不知道哪些通道更值得信任也没有机制去放大那些信息量大的通道。特征图里有些通道可能基本就是噪声但网络依然会把它们一视同仁地传给下一层。第二个短板在空间维度。图像里的目标往往只占画面的一部分背景、遮挡、无关物体占据了大量空间。普通卷积在计算时并不会特别关心当前位置是不是目标区域所以模型在做分类或者定位的时候容易被大面积的无意义背景信息带偏。说白了CNN从设计之初就缺少一种选择性重点关注的机制。1.2 从SE模块到CBAM一条清晰的演进路径说到注意力机制肯定绕不开SE模块Squeeze-and-Excitation Network。SE的做法是从通道维度入手对特征图做全局平均池化压缩成一个通道描述向量然后用两个全连接层学习每个通道的权重最后把权重乘回特征图。SE注意力在2017年的ImageNet分类上刷了一波存在感关键是用很小的计算量换来了可观的精度提升。但SE只做了通道维度的重标定空间维度它完全不碰。CBAM的思路比SE更进一步既然特征图天然有通道和空间两个维度那为什么只在一个维度上做注意力呢两个维度都做效果应该更好。而且CBAM的通道注意力子模块在前人的基础上也做了改进后面细说。从整体结构上看CBAM是一个轻量级的即插即用模块输入输出形状完全一致。它内部由两个子模块串联而成先过通道注意力再过空间注意力。论文还专门做了实验验证了这个顺序确实比反着来效果好。这类消融结论挺重要因为在工程中我们经常面临模块该怎么排列的选择没有实验依据就只能瞎猜。2. 通道注意力模块先决定看什么再谈其他的2.1 通道维度上到底藏着什么信息在卷积神经网络中每一层卷积的输出特征图可以理解成对输入图像的一组解释视角。有的通道负责提取水平边缘有的通道负责提取颜色斑点有的通道负责响应某个特定物体的部件。信息是以通道为单位组织的。问题在于并不是每个通道对当前任务都同样重要。比如在ImageNet预训练模型里有些通道对纹理和背景高度敏感但在你的数据集上这些通道可能帮倒忙。通道注意力模块的作用就是自适应地学习一组权重每个通道乘一个系数重要的通道放大不重要的通道压低。这相当于给特征图加了一个通道级别的音量调节旋钮。2.2 最大池化与平均池化为什么要同时用SE模块做通道注意力时用的压缩方式是全局平均池化。平均池化的特点是均匀地考虑所有像素它反映的是每个通道的总体统计信息。但问题在于平均池化容易忽略那些只在小区域内激活、但语义很强的特征。CBAM的改进点就在这里它同时使用全局平均池化和全局最大池化来压缩空间信息。最大池化提取的是每个通道最强烈的那个响应值这个值往往对应着最具辨识度的特征。两者结合既能感知整体分布又能抓住显著峰值。论文里的实验也证实了同时使用两种池化比只用其中任何一种效果都好。用一个生活化的类比来说评价一篇文章的价值时平均池化是看整体平均质量最大池化是看最精彩那个段落。两个维度都有参考价值加在一起会过滤掉单一视角的偏颇。2.3 通道注意力的完整计算路径具体流程可以拆成四步输入特征图 F形状是 C×H×W。对F在空间维度H×W做全局平均池化得到一个长度为C的向量记为 F_avg。对F在空间维度做全局最大池化得到另一个长度为C的向量记为 F_max。把 F_avg 和 F_max 分别送进同一个共享的MLP。这个MLP是窄腰结构第一层把C维压缩到C/r维中间接ReLU激活第二层再恢复到C维。压缩比r论文中默认取16。MLP输出两个C维向量然后逐元素相加。相加结果过Sigmoid函数得到通道注意力权重 Mc形状为C×1×1。把 Mc 乘到原始特征图的每个通道上得到通道重标定后的特征图 F。公式写出来是Mc(F) σ(MLP(AvgPool(F)) MLP(MaxPool(F)))其中σ表示SigmoidMLP的两个层共享参数。这里共享的意思是两个池化结果走的是同一套权重不是各自一套这样既控制了参数量也让两个分支在统一的标准下融合。为什么用窄腰MLP而不直接学一个C维权重向量一方面是为了控制参数量C/r这个瓶颈结构大大减少了要学习的参数量另一方面这个瓶颈结构相当于把通道信息先压缩再展开迫使网络学到通道之间更本质的关联模式。3. 空间注意力模块补齐看哪里这块关键拼图3.1 空间注意力补上了通道注意力的盲区通道注意力解决了不同通道哪个更值得信的问题但完全没有触碰另一个维度特征图不同空间位置哪个更重要。举个典型的场景一副图像主体是一只猫周围是草地。经过卷积后猫的位置和草地的位置在不同空间位置上都有各自的特征响应。如果只做通道注意力等于给猫类通道整体加了权重但背景区域上其他通道的响应依然可能干扰后续分类器。这时就需要空间注意力登场在H×W平面上生成一张权重图目标是区域的权重高背景区域的权重低然后让网络把注意力花在目标区域上。空间注意力的输入是经过通道注意力重标定后的特征图 F。它对F在通道维度上做两次池化一次取平均值一次取最大值得到两张 H×W 的二维特征图。这两张图一张代表所有通道在该位置的平均响应一张代表所有通道在该位置的最强响应。然后把它们沿通道方向拼接起来输入到一个卷积层中输出一张 H×W 的空间权重图经过Sigmoid归一化后乘回原特征图。公式如下Ms(F) σ(f_7×7([AvgPool(F); MaxPool(F)]))这里的 f_7×7 代表一个7×7的卷积层输入是拼接后的2×H×W特征输出是1×H×W。需要特别提醒的是这里的平均池化和最大池化都是在通道维度上做的和通道注意力里的空间维度池化刚好方向相反别绕晕。3.2 两个池化结果拼接后为什么用7×7卷积空间注意力模块里有个容易被忽略但很关键的细节为什么用7×7的大卷积核而不是常见的1×1或3×3原因是空间注意力本质上是想判断哪个区域重要而区域重要性通常需要借助周围上下文信息来确认。一个孤零零的像素点往前看很难判断它属于前景还是背景但如果能看到这个像素周围一大片区域的模式判断就会准确很多。7×7卷积的每个输出点都聚合了周边7×7范围内的信息感受野更大能利用更丰富的空间上下文。论文里做了对比实验kernel size设为7时效果最优这背后就是感受野的功劳。为什么把平均池化和最大池化拼接起来因为这两个信息是互补的。平均池化图反映每个位置的整体激活强度边界模糊但稳定最大池化图则保留每个位置最显著的那个通道响应能捕捉到更锐利的目标边缘。两张图拼在一起卷积层等于同时看到了整体热度和最大热点。信息越互补学出来的空间权重越精准。空间注意力输出一张和原特征图同高同宽的权重图会让特征图在每个局部位置都乘一个系数。经过这一层特征图的前景区域响应被增强背景区域被压制整个网络往后的计算就更有针对性了。4. 从公式到张量PyTorch实现CBAM的每一行代码都讲清楚4.1 通道注意力子模块的代码实现本节可以边读代码边对照上面提到的公式。我直接用PyTorch写一个简洁版。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction16): super(ChannelAttention, self).__init__() hidden max(in_channels // reduction, 1) self.mlp nn.Sequential( nn.Linear(in_channels, hidden, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(hidden, in_channels, biasFalse), ) self.sigmoid nn.Sigmoid() def forward(self, x): # x: (B, C, H, W) b, c, h, w x.size() avg_pool x.mean(dim(2, 3)) # (B, C) max_pool x.max(dim2).values.max(dim2).values # (B, C) avg_out self.mlp(avg_pool) # (B, C) max_out self.mlp(max_pool) # (B, C) weight self.sigmoid(avg_out max_out) # (B, C) weight weight.view(b, c, 1, 1) # (B, C, 1, 1) return x * weight代码里有两个细节值得展开。第一x.max(dim2).values.max(dim2).values是为了在H和W两个维度上连续取最大值得到 (B, C) 的向量。也可以用F.adaptive_max_pool2d(x, output_size1)实现后者更贴近论文的原版写法但结果一样。第二MLP内部激活函数用的是ReLU并且只在中间层加。这是有意为之如果两头都加激活函数会限制最后权重表达的连续范围。Sigmoid放在最后的目的是把权重映射到0到1之间这样乘回去就是软性缩放而不是硬性丢弃某个通道。4.2 空间注意力子模块的代码实现空间注意力模块写完也不长。class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super(SpatialAttention, self).__init__() assert kernel_size in (3, 5, 7), kernel size must be 3, 5, or 7 padding kernel_size // 2 self.conv nn.Conv2d(2, 1, kernel_size, paddingpadding, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): # x: (B, C, H, W) avg_pool torch.mean(x, dim1, keepdimTrue) # (B, 1, H, W) max_pool, _ torch.max(x, dim1, keepdimTrue) # (B, 1, H, W) concat torch.cat([avg_pool, max_pool], dim1) # (B, 2, H, W) weight self.sigmoid(self.conv(concat)) # (B, 1, H, W) return x * weight这里最需要注意的是torch.max(x, dim1, keepdimTrue)返回的是一个tuple第二个元素是最大值对应的索引我们用不到直接用占位符_丢掉。拼在一起的时刻是在通道维度上torch.cat([avg_pool, max_pool], dim1)得到的是2个通道的二维特征图然后喂给单层卷积。空间注意力子模块里没有用ReLU只有一个卷积加一个Sigmoid。因为这里只是一次空间模式的提取不同于通道注意力那样的特征变换流程不需要复杂的非线性映射。在测试时7×7卷积的kernel size是可以调的我用3×3和5×5试过效果确实不如7×7和论文结论一致。4.3 把两个模块串起来并嵌入ResNet串起来更简单定义一个CBAM类forward里先过通道注意力再过空间注意力就完事了。class CBAM(nn.Module): def __init__(self, in_channels, reduction16, spatial_kernel_size7): super(CBAM, self).__init__() self.channel_attention ChannelAttention(in_channels, reduction) self.spatial_attention SpatialAttention(spatial_kernel_size) def forward(self, x): x self.channel_attention(x) x self.spatial_attention(x) return x使用这个模块的时候我习惯在ResNet的BasicBlock或者Bottleneck的残差相加之前插入。举个例子在BasicBlock里修改class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1, downsampleNone): super(BasicBlock, self).__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, stride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, 3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.downsample downsample self.cbam CBAM(out_channels) 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) if self.downsample is not None: identity self.downsample(x) out self.cbam(out) out identity out self.relu(out) return out为什么放在残差相加之前而不是之后我的理解是残差分支经过CBAM重标定后特征质量更高和恒等映射相加时能把有效信号带出去如果放在相加之后相当于对已经叠加了原始信号的结果再做注意力此时直接路径的信息会被权重改变反而可能破坏梯度的恒等性质。PyTorch官方视觉库的一些实现也采用这个位置实践下来效果更稳定。5. 论文里的结论和我在复现时观察到的现象5.1 消融实验最有价值的信息CBAM原论文ECCV 2018在ImageNet-1K上做了一系列消融实验这些结论对我们自己选型很有参考价值。先看单独使用通道注意力或空间注意力的情况。单独加通道注意力相当于SE的改良版能提升模型准确率单独加空间注意力也能提升但幅度略小。这说明两个维度各自都在提供有效信息但又都不全面。当两个模块串联一起用时提升最大超过了任意单一模块。再看子模块顺序。论文比较了通道注意力在前、空间注意力在后和空间注意力在前、通道注意力在后两种排列结论是前者更优。我的理解是通道注意力相当于先做一次全局性的通道选择把有用的特征通道筛选出来之后再在空间上精确定位逻辑上更顺反过来先做空间定位时信息还没有经过通道筛选空间权重容易受到无效通道的干扰。CBAM和SE的对比更有意思。CBAM的通道注意力本身就是在SE基础上的改进因此公平对比应该是SE和CBAM整体对比。在相同骨干网络和训练设置下CBAM的top-1准确率提升比SE高出不少。论文报告里ResNet-50加CBAM之后在ImageNet上的top-1错误率大约降低了1.5个百分点这个幅度在图像分类任务里已经相当可观。5.2 计算开销与性能提升的性价比我复现CBAM时最关心的就是它到底增加了多少计算负担。直接说结论几乎可以忽略不计。在ResNet-50上单个BasicBlock加一个CBAM增加的参数量主要来自通道注意力的MLP和空间注意力的7×7卷积。假设输入是256个通道reduction取16MLP两个Linear层的参数量大约是第一层256×16 16 4112第二层16×256 256 4352 空间注意力7×7卷积2×7×7×1 1 99 合计每个模块增加的参数量大概在8500左右相比原本一个BasicBlock动辄几十万的参数量占比非常小。FLOPs层面两个子模块的池化和逐元素乘法计算量都很低7×7卷积作用在2通道的特征上计算量也完全可以忽略。实际在单张GPU上测试CBAM的推理耗时增加大约在1%-3%之间具体取决于特征图大小和通道数。需要提醒的是计算开销低不等于它不需要调参。论文里的reduction默认16但在不同任务和数据规模上我试过8和32结果差异还挺明显后面专门讲。6. 接入真实项目前你需要知道的调参细节和坑6.1 CBAM该插在哪一层这是我在实际项目中踩得最久的一个坑。CBAM虽然号称即插即用但插的位置不同效果天差地别。早期我习惯把CBAM加在骨干网络每个stage的最后一个Block之后认为这样可以让注意力作用于该stage的输出。后来对比实验发现这样做提升有限甚至在深层stage还有负优化。后来改成在每个BasicBlock的残差分支拼接前插入就是上面代码里的位置效果才稳定起来。原因是残差分支经过CBAM重标定后再与恒等映射相加既保留原始信息又突出重点特征梯度流动也更顺。如果是检测或分割任务我还会在FPN融合后的特征图上再接一层CBAM。FPN从不同层聚合了多尺度特征但每个尺度的重要性并不一致这时候加一个CBAM做特征重标定通常能带来1-2个点的mAP提升。这一点在项目里屡试不爽。6.2 训练细节与参数调整reduction值的设置要结合网络宽度来定。对大网络如ResNet-50及以上reduction取16是安全选择对轻量网络如MobileNet、ShuffleNet中间隐藏层本身就很窄reduction还取16的话MLP的隐藏层可能被压得太小学不到有效的通道关系我建议取8甚至4保证瓶颈层至少有几个神经元。学习率上也有一点需要注意。加CBAM之后模型总收敛速度会比原来稍慢一些这不是坏事是注意力模块在训练早期还在摸索哪些通道和区域重要。但如果你发现loss下降明显变慢可以考虑给CBAM模块单独设一个更高的学习率倍数比如主干的1.2到1.5倍。我在一个检测项目里这么干过收敛速度和不加模块时基本持平最终精度还高了0.8个点。另一个容易被忽略的是权重初始化。PyTorch的Linear和Conv2d默认初始化方式对CBAM基本够用但如果你的任务数据量很小建议把CBAM里的Sigmoid分支初始化为偏向不改变特征的状态比如让MLP最后一层的权重和偏置初始化为0。这样模块在训练初期基本是恒等映射再逐步学习注意力可以避免小数据下训练不稳定。实现也不难自定义初始化函数对最后一层执行nn.init.zeros_()就行。6.3 易踩的坑集合整理几个我遇到过的实际问题给大家省点时间。第一输入输出形状不匹配。CBAM要求输入是四维张量(B, C, H, W)。有些分类网络在global pooling之前接入的是序列特征或者已经拉平的特征这时候要先reshape再进CBAM否则形状就崩了。第二空间注意力里的max操作在C部署和TensorRT转换时偶尔会出兼容问题。PyTorch转ONNX时torch.max(x, dim1, keepdimTrue)是支持导出的但某些版本的ONNX Runtime会报奇怪的错误。稳妥做法是部署时用torch.ops.aten.max.dim没转过的话直接在导出前把这一层换成平均池化或者在推理脚本里单独用传统实现替代。我在嵌入式设备上踩到过一次最后是在导出模型时改用average pool替代max pool精度损失很小部署难题倒是彻底没了。第三数据增强策略变了以后CBAM的效果可能被放大或缩小。CBAM对目标位置比较敏感如果训练数据里目标经常在中心区域空间注意力学到的权重图会偏向中心测试时目标一旦偏到角落这种偏好就会拖后腿。最简单的规避方式是训练时加随机裁剪和随机缩放让目标位置分布更均匀。第四BatchNorm和CBAM协同的问题。有些复现代码在CBAM两个子模块里也加BN层我觉得不是好主意。CBAM内部本身是做特征重标定的加BN会把已经过Sigmoid压到0-1的权重再归一化一次反而破坏了注意力权重的尺度影响收敛。我实际对比过内部不加BN的版本精度更高训练更稳。是以我在实际项目中的体会来说CBAM是一个性价比很高的注意力插件但它不是加了就涨点的万能药。真正好看的效果来自合理的插入位置、合适的reduction、稳妥的训练设置以及最重要的——在你自己数据集上做几组消融实验而不是照搬论文的参数。理解了它背后的通道筛选空间聚焦的设计逻辑你再去看网上各种CBAM的变体也就一目了然了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进