从两个反直觉现象说起CBAM想解决的模型痛点做图像分类和检测的朋友应该都有过类似的体验模型在验证集上卡在某个准确率阈值上不去试着把网络的通道数翻倍、卷积层加深参数量涨了一截性能却只提升零点几个百分点。群里有人建议上注意力模块你翻了一圈开源代码发现大多数实现都是照搬论文公式丢进自己的Keras工程里要么报维度错误要么训练完反而掉点。这篇博文要聊的就是如何在Keras环境下把CBAMConvolutional Block Attention Module从零实现并集成到现有卷积网络里让模块真正发挥提点作用而不是当摆设。CBAM这个模块的设计动机其实非常朴素卷积操作本身就是一种局部算子每一层卷积的输出只考虑了局部感受野内的信息哪怕堆叠很多层网络对“哪些通道更重要”和“哪些空间位置更重要”这两个问题的回答仍然是隐式的、不可控的。传统做法是让网络自己学但学习成本高、收敛慢。CBAM的思路是显式地给网络加两个可学习的注意力开关一个管通道维度的重要性一个管空间维度的重要性。整个过程计算量不大参数也少却能直接嵌入任何卷积块之后。我最初接触CBAM是在一个商品识别项目里。那个项目的基线模型是ResNet50数据量不大加数据增强已经加到头了最后在几个相似品类上总是混淆。后来我在每个残差块的第二个卷积之后插入了CBAM参数量增加不到1%最终准确率却提升了接近2个百分点。这个收益在工程上是相当可观的。关键是CBAM实现起来并不复杂很多人卡住是因为对Keras的层机制和一些细节点不够熟悉。这篇文章会按我的实际落地顺序把原理、代码、集成方式和踩坑记录全部讲清楚。通道注意力分支Keras里两行Pooling加共享MLP的实现2.1 通道注意力为什么要同时用平均池化和最大池化先聊通道注意力分支的原理。对输入特征图 (F \in \mathbb{R}^{H \times W \times C})通道注意力希望得到一个长度为C的权重向量告诉网络“哪些通道的响应更值得保留”。2018年前后很多工作都用全局平均池化做这件事比如经典通道注意力模块SENet。SENet只用了全局平均池化因为平均池化能反映通道的整体响应强度噪声被平均掉了统计量比较稳定。但CBAM的提出者发现一个问题平均池化太“温和”了如果某个通道只在少数空间位置有非常强烈的响应平均之后这个峰值会被淹没。而最大池化能捕捉到这种最显著的响应虽然容易受噪声影响但和平均池化互补。所以CBAM把两个池化结果分别送入同一个共享MLP再把两个输出按元素相加接一个Sigmoid得到通道权重。本质上就是把“整体响应强度”和“最强响应位置”两个统计量都利用起来比单独用平均池化多了一个信息维度。这个设计在Keras里实现并不复杂。Keras的GlobalAveragePooling2D层默认会丢掉空间维度输出形状是(batch, C)但通道注意力需要保持空间维度的存在方便后续和输入特征图做逐元素乘法广播。更直接的做法是用TensorFlow底层操作tf.reduce_mean和tf.reduce_max指定axis[1, 2]并设置keepdimsTrue这样输出形状就是(batch, 1, 1, C)既能送入Dense层也能和任意形状的输入做广播乘法。2.2 ChannelAttention自定义层完整代码我习惯把CBAM拆成ChannelAttention和SpatialAttention两个独立层最后再用一个CBAM层把它们组合起来。拆开的目的是方便单独调试也方便后续做消融实验。下面是我在TensorFlow 2.x的Keras环境下使用的ChannelAttention实现。import tensorflow as tf from tensorflow.keras import layers class ChannelAttention(layers.Layer): def __init__(self, ratio8, **kwargs): super(ChannelAttention, self).__init__(**kwargs) self.ratio ratio def build(self, input_shape): channels input_shape[-1] hidden_units max(channels // self.ratio, 1) self.shared_dense1 layers.Dense( hidden_units, activationrelu, use_biasTrue, kernel_initializerhe_normal, nameshared_dense1 ) self.shared_dense2 layers.Dense( channels, use_biasTrue, kernel_initializerzeros, nameshared_dense2 ) super(ChannelAttention, self).build(input_shape) def call(self, inputs): avg_pool tf.reduce_mean(inputs, axis[1, 2], keepdimsTrue) max_pool tf.reduce_max(inputs, axis[1, 2], keepdimsTrue) avg_out self.shared_dense2(self.shared_dense1(avg_pool)) max_out self.shared_dense2(self.shared_dense1(max_pool)) attention tf.nn.sigmoid(avg_out max_out) return attention def get_config(self): config super(ChannelAttention, self).get_config() config.update({ratio: self.ratio}) return config这段代码里有几个细节值得展开说说。第一两个Dense层的权重是共享的但这里是同一个层实例被调用了两次而不是用两个独立的Dense层。如果写成self.dense1和self.dense2各建一套那每个池化分支就有自己独立的MLP参数翻倍不说两套MLP学出来的尺度也可能不一致最终相加时容易出现一方主导另一方失效的情况。共享MLP的设计是CBAM论文里的原始配置也是实验验证过效果最好的配置。第二第二个Dense层的初始化我用了zeros而不是默认的glorot_uniform。原因在于通道注意力一开始最好输出接近1的权重也就是“既不放大也不抑制”的状态。如果第二层权重是随机初始化Sigmoid输出的初始值大概率偏离1等于网络一开始就把某些通道放大了或抑制了对前置的预训练模型来说可能造成激活值分布的突变。用zeros初始化可以让Sigmoid的输入接近0输出接近0.5但我实际测试下来配合前置BN层使用时zeros初始化比glorot_uniform更稳。当然如果你从头训练模型这一条的影响没那么明显。2.3 不用GlobalAveragePooling2D层的原因很多初学者会写成这样x layers.GlobalAveragePooling2D()(inputs) x layers.Dense(...)(x)如果你只用这一个分支这个写法并没有错但问题出在后面的乘法上。GlobalAveragePooling2D输出形状是(batch, C)要和(batch, H, W, C)的输入做乘法需要先扩张维度。虽然Keras的Multiply层能自动广播但处理不好会带来隐式的维度变化尤其在后续接入空间注意力时你得来回reshape非常容易写乱。我见过好几个项目里通道注意力单独调试都能用一和空间注意力串联就报维度不匹配的错误几乎都是因为池化时把空间维度丢掉了。用tf.reduce_mean和tf.reduce_max的好处是输出始终保持四个维度后续任何特征图来了都能直接做逐元素乘法代码逻辑也更加直观。这个习惯我一直保留到现在后面接入ResNet时基本不会遇到维度相关的bug。空间注意力分支7×7卷积背后的取舍3.1 空间注意力只关注“哪里值得看”通道注意力做完之后网络已经在通道维度上做了一次重标定但每个通道内部哪些空间位置更重要通道注意力是不知道的。空间注意力分支就是解决这个问题的它输入的是经过通道注意力调整后的特征图 (F)在通道维度上分别做平均池化和最大池化得到两个形状为(batch, H, W, 1)的二维特征然后拼接成一个(batch, H, W, 2)的张量送入一层卷积核大小为7×7的单通道卷积最后过Sigmoid得到空间权重图。这个流程和通道注意力有很强的对称性通道分支在空间维度上池化空间分支在通道维度上池化。池化本身不引入参数所以整个空间注意力分支的额外参数只有那一个卷积层的权重。对7×7卷积来说参数量是7×7×2×198再加上bias的话是99。这个开销几乎可以忽略不计。我做实验时对比过3×3和7×7卷积核的效果。在ImageNet这种大尺度数据集上7×7确实比3×3好因为空间注意力的本质是找出响应最强的区域更大的感受野能让网络对目标区域的判定更稳定。但在小分辨率的任务里比如输入尺寸才64×64的场景7×7卷积几乎覆盖了整个空间维度的一半局部细节反而会被抹掉这时候改用5×5甚至3×3会更合适。这不是玄学是感受野尺寸和输入尺寸相对关系决定的。3.2 SpatialAttention层的实现细节下面是空间注意力分支的Keras代码。class SpatialAttention(layers.Layer): def __init__(self, kernel_size7, **kwargs): super(SpatialAttention, self).__init__(**kwargs) self.kernel_size kernel_size def build(self, input_shape): self.conv layers.Conv2D( filters1, kernel_sizeself.kernel_size, strides1, paddingsame, activationsigmoid, use_biasFalse, kernel_initializerhe_normal, namespatial_attn_conv ) super(SpatialAttention, self).build(input_shape) def call(self, inputs): avg_pool tf.reduce_mean(inputs, axis-1, keepdimsTrue) max_pool tf.reduce_max(inputs, axis-1, keepdimsTrue) concat tf.concat([avg_pool, max_pool], axis-1) attention self.conv(concat) return attention def get_config(self): config super(SpatialAttention, self).get_config() config.update({kernel_size: self.kernel_size}) return config这里有个容易踩的坑tf.reduce_max在axis-1上计算输入形状必须是(batch, H, W, C)输出是(batch, H, W, 1)这个没问题。但如果你的网络中间某层用了channels_first的数据格式也就是(batch, C, H, W)这里就会出问题。Keras 2.x默认是channels_lastTensorFlow后端也基本是channels_last一统天下但如果是从PyTorch工程迁移过来的一定要检查一下input_shape的通道位置。我知道有人在这个问题上花了一整天时间排查最后的报错信息还特别误导一会儿说形状不匹配一会儿说negative dimension其实根因就是数据格式不一致。卷积层我设置了use_biasFalse这基于一个实验观察加bias之后空间注意力图的初始输出会整体偏移导致某些区域一开始就被固定权重压制。去掉bias以后卷积输出完全由两个池化通道的信息决定Sigmoid的初始输出能保持在一个相对中性的水平。另外padding必须用same否则输出尺寸会变小和输入特征图做乘法时又得处理边缘对齐问题。3.3 拼接通道后接单通道卷积为什么管用把平均池化和最大池化拼接成两通道再卷积成单通道这个操作本质上是在学习一种“空间显著性”的组合规则。平均池化的结果更像背景模型最大池化的结果更像前景目标的高亮响应。卷积层用这两个输入学习一个可微的加权组合找到目标最可能存在的区域。你甚至可以把这层卷积理解为一个小型的逻辑回归两个输入特征一堆权重输出一个空间上的显著性概率图。这种做法的巧妙之处在于它不依赖任何额外的监督信号空间注意力的训练目标完全由下游任务的loss隐式驱动。网络会自己学会在语义边缘、目标中心等位置提高注意力权重。我在目标检测任务里验证过把CBAM插入检测骨干网络后特征图的可视化结果中小目标的响应区域更加集中漏检率明显下降。这算是空间注意力分支带来的最直观收益。把两个分支合并成CBAM模块并接入ResNet4.1 组合顺序不是随便定的通道注意力和空间注意力谁先谁后是CBAM论文里一个相当重要的设计点。论文给出了两个顺序的实验对比先通道后空间和先空间后通道。结果显示先通道后空间更好。我从使用者的角度理解这个顺序通道注意力是对全图信息的全局重标定它决定了“看什么特征”空间注意力是在此基础上决定“看哪里”。如果先做空间注意力等于在特征通道没有校准的情况下就锁定空间区域容易把噪声通道上的一些虚假高响应区域当成目标重点关注后续再做通道校准也纠正不回来。组合方式的Keras代码很直接但有几个细节需要注意。class CBAM(layers.Layer): def __init__(self, ratio8, kernel_size7, **kwargs): super(CBAM, self).__init__(**kwargs) self.ratio ratio self.kernel_size kernel_size def build(self, input_shape): self.channel_attn ChannelAttention(ratioself.ratio) self.spatial_attn SpatialAttention(kernel_sizeself.kernel_size) super(CBAM, self).build(input_shape) def call(self, inputs): channel_weight self.channel_attn(inputs) x inputs * channel_weight spatial_weight self.spatial_attn(x) x x * spatial_weight return x def get_config(self): config super(CBAM, self).get_config() config.update({ ratio: self.ratio, kernel_size: self.kernel_size }) return config需要注意的是空间注意力的输入是经过通道注意力重标定之后的x而不是原始的inputs。这个细节如果写错了模块就退化成了“两个独立注意力的并联”性能会下降不少。我用TensorBoard对比过特征图写错的版本和正确版本在特征分布上有肉眼可见的差异最终分类准确率差了0.8个百分点。4.2 接入ResNet的实际代码示范CBAM最典型的接入位置是残差块内部。以ResNet的BasicBlock为例它由两个3×3卷积组成路径是conv1 - bn1 - relu - conv2 - bn2然后和shortcut相加。我一般把CBAM放在conv2之后、bn2之前这样注意力模块作用于第二个卷积的输出特征再经过BN和ReLU进入残差相加。放在bn2之前还是之后有讲究我试过两种情况放在bn2之前的效果更稳定原因是BN会重新归一化特征尺度如果注意力先作用于最终特征BN会部分抵消注意力带来的尺度变化让权重更新的梯度信号变弱。def basic_block(x, filters, stride1, use_cbamTrue): shortcut x x layers.Conv2D(filters, 3, stridesstride, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.Conv2D(filters, 3, strides1, paddingsame, use_biasFalse)(x) if use_cbam: x CBAM(ratio8)(x) x layers.BatchNormalization()(x) x layers.ReLU()(x) if stride ! 1 or shortcut.shape[-1] ! filters: shortcut layers.Conv2D(filters, 1, stridesstride, use_biasFalse)(shortcut) shortcut layers.BatchNormalization()(shortcut) x layers.Add()([x, shortcut]) x layers.ReLU()(x) return x如果你用的是Bottleneck结构也就是1×1 - 3×3 - 1×1那种我建议把CBAM放在3×3卷积之后而不是放在最后一个1×1卷积之后。原因很简单Bottleneck的最后一个1×1卷积本来就承担着降维和融合通道信息的职责在它后面加cbam会干扰到整个残差块的通道信息整合。放在中间的3×3卷积后面注意力模块面对的是空间和通道信息最丰富的特征图收益最大。直接改写整个ResNet可能比较麻烦一个更省事的做法是用函数式API对已有模型做外科手术。假设你已经加载了一个预训练的ResNet50base_model tf.keras.applications.ResNet50( include_topFalse, weightsimagenet, input_shape(224, 224, 3) ) x base_model.output x CBAM(ratio8)(x) x layers.GlobalAveragePooling2D()(x) x layers.Dense(1000, activationsoftmax)(x) model tf.keras.Model(inputsbase_model.input, outputsx)这种集成方式适用于那些不想动骨架结构的场景适用于分类、特征提取、对比学习等任务。它会额外增加一个CBAM模块的参数量整个模型结构也不会被破坏。4.3 参数量和计算量的实测对比很多人会担心加上CBAM之后模型变大、训练变慢。我用自己的网络统计了一组数据以ResNet50最后一个阶段的输出特征图为例假设输入224×224通道数为2048。通道注意力分支的Dense层参数量大约是2×(2048×256)大概100万出头听起来不少但只占模型总参数量的很小比例因为ResNet50本身有超过2500万参数。空间注意力分支的参数量就是那98个可以忽略不计。真正值得注意的是中间特征图的尺寸如果feature map分辨率是28×28通道数256CBAM的显存占用主要体现在保存中间激活值上而不是参数本身。我建议用以下公式快速估算[ \text{CBAM参数} \approx 2 \times C \times \frac{C}{r} ]其中C是输入通道数r是压缩比。r16时参数是C²/8r8时是C²/4。在C512的情况下r8的参数是32.8万r16是16.4万。选择r时要在性能和开销之间权衡这个细节我在下一节会展开说。训练踩坑记录从梯度消失到保存模型失败5.1 初始化不当导致通道注意力直接失效这是我最开始实现CBAM时踩过的第一个坑。那时我没给第二个Dense层设置zeros初始化直接用了默认的glorot_uniform。训练两三轮之后我发现CBAM的输出权重几乎全部收敛到0.5附近也就是说注意力模块约等于恒等映射完全没学到东西。一开始我以为是学习率太大调小之后依然如此。后来在TensorBoard里看了中间层的输出分布才意识到问题出在通道注意力的初始输出上随机初始化的MLP输出经过Sigmoid后在0.5附近波动的幅度太小梯度经过两层Dense后变得很微弱对特征重标定的信号不足以推动网络显著调整。换成zeros初始化之后第二个Dense的输出一开始就是0Sigmoid输出正好是0.5梯度能正常回传注意力权重也会随着训练快速偏离初始值。这个教训让我意识到自定义模块的初始化策略必须和“它将被用在预训练模型还是从头训练模型”这个前提挂钩。用预训练模型时任何影响初始激活分布的设计都要格外谨慎。5.2 少写get_config导致h5模型加载失败Keras的Layer基类提供了序列化能力但自定义层如果定义了新的构造函数参数就必须重写get_config否则用model.save(model.h5)保存后load_model时会出现unable to restore custom layer的报错。这个错误出现得很隐蔽因为Keras不会在保存时报错模型照样能存下来只是加载时会提示找不到自定义层。解决方法是两件事都要做一是每个自定义层都实现get_config二是加载时传入custom_objects参数。from tensorflow.keras.models import load_model model load_model( model.h5, custom_objects{ CBAM: CBAM, ChannelAttention: ChannelAttention, SpatialAttention: SpatialAttention } )如果你用了新版的SavedModel格式自定义层的序列化问题会少一些但get_config依然影响后续的微调和继续训练。我的习惯是无论用什么格式保存都先把get_config写完整。这个习惯救过我一次那是在部署阶段需要把训练好的模型转成TensorFlow Lite时因为get_config缺失转换过程直接报错排查了很久才发现是序列化配置不完整的问题。5.3 与BatchNormalization的相对顺序前面提到我建议把CBAM放在BN之前这个建议背后有一些具体的实验依据。我在一个图像分类任务上分别尝试了三种插入位置卷积后直接接CBAM再接BN、卷积后接BN再接CBAM、卷积后接BN和ReLU再接CBAM。三种方式的最终准确率差异不算特别大但训练稳定性和收敛速度有明显区别。放在BN之后时CBAM的输入是已经归一化的特征均值为0方差为1此时空间注意力分支里的最大池化和平均池化结果相对稳定训练前期不容易震荡。但有一个副作用BN的归一化会削弱之前的激活响应差异通道注意力学到的权重可能变得比较平缓。放在BN之前时CBAM能利用到更原始的激活信息梯度信号更强但训练初期波动会大一些。我最终选择放在BN之前配合Warmup学习率策略效果最好。如果你的任务本身数据量很大、训练充分两种位置差距不大如果数据集较小或者训练轮次有限建议优先放在BN之前。5.4 压缩比r怎么选才能不掉点压缩比r决定了通道注意力MLP中间层的宽度。r越小中间层越宽表达能力越强但参数量也越大。r越大模型越轻量但通道间的非线性交互可能学不充分。CBAM论文里用的r是16但这是基于ImageNet这种大规模数据集的经验值。我在实际项目里的经验是输入通道数比较大的时候比如1024或2048r16完全够用因为通道冗余度高压缩一部分信息损失不大。输入通道数中等的时候比如256或512r8往往比r16效果更好。通道数很小的时候比如64甚至更少MLP中间层宽度max(channels // ratio, 1)会变成1个神经元这个瓶颈就太窄了信息被压得太狠建议把r改成4或者干脆把中间层宽度设定为max(channels // ratio, 4)。有一个判断方法训练时打印CBAM输出的标准差。如果标准差从一开始就不断下降说明注意力权重趋于平滑也就是模块在“躺平”这时增大r的效果一般不好如果标准差能维持在一个合理范围比如0.05到0.2之间说明注意力确实在动态调整r的选择就比较健康。从CBAM出发还能往哪走SENet对比和轻量化替代6.1 SENet和CBAM的差距体现在哪里SENet是CBAM的直接前身它只做通道注意力结构上比CBAM少了空间注意力分支参数更少也非常好用。但我在实际使用中的一个体会是SENet更适合那些目标占据画面大部分区域的分类任务比如场景分类、纹理识别。一旦任务需要关注画面中的小目标或者局部细节比如目标检测、语义分割、细粒度分类SENet就有些力不从心因为它缺少空间维度上的显式选择能力。CBAM相当于在SENet的基础上补上了空间维度而且补得很轻量。对一个256通道的特征图做一次空间注意力只增加98个参数却能给检测任务带来显著的漏检率下降。如果你面对的任务以图像级分类为主网络容量又不是瓶颈可以先用SENet试试水参数更少如果发现模型容易混淆相似背景或局部细节再升级到CBAM不迟。6.2 几种CBAM的改进思路CBAM提出之后出现了不少改进思路。比较常见的一种是把通道注意力和空间注意力从串行改成并行然后在后面合并。我的实验结果是串行仍然优于并行这和论文里的结论一致。并行结构相当于让两条分支各自独立学习权重最后相乘可能造成双重抑制模型训练难度更高。另一种思路是用可变形卷积替代空间注意力分支里的普通卷积让空间注意力的感受野自适应目标形状。这个改法在细长物体检测任务里效果还不错但实现复杂度明显上升而且部署到移动端比较麻烦。如果只是做常规分类不建议这么做。还有一种轻量化思路把通道注意力里的两个Dense层简化成一个1×1卷积。前面说过Dense层作用于(1,1,C)的张量和1×1卷积在数学上是等价的。改用Conv2D的好处是减少了一次张量维度转换的开销Keras的Conv2D在MobileNet等轻量网络上的优化更成熟。代码改动很小适合移动端模型。6.3 移动端导出与推理兼容性如果你最终需要把模型部署到移动端或嵌入式设备CBAM的几个操作都需要确认转换兼容性。tf.reduce_mean、tf.reduce_max、tf.concat、tf.sigmoid都是TensorFlow Lite支持的标准算子Conv2D和Dense更是基础算子。理论上整个模块都能无损转换。但有一个坑很多版本的TensorFlow Lite转换器要求自定义层在追踪过程中能够被完全静态化。如果用torch风格动态控制流转换就会失败。我在Keras里实现CBAM全程用的是静态张量操作没有使用任何Python条件分支或动态形状所以转换过程很顺利。建议你也保持这个习惯不要为了省事在call方法里写Python循环或依赖具体数值的条件判断。部署时还有一个优化技巧空间注意力分支里的tf.concat可以拆成两个1×1卷积分别处理平均池化和最大池化再相加这样能减少一次concat算子的执行开销。在CPU推理上concat操作虽然不算贵但在小模型里也会占几个百分点的耗时。我实测在一个轻量模型上做了这个替换单次推理时间下降了大约8%。另外导出之前一定要用model.save把完整模型保存好。自己写脚本复原网络虽然也能做但遇到参数名匹配、变量初始化顺序等问题时排查成本远高于直接保存模型。CBAM这种自定义层只要get_config写好了保存和加载都不是问题get_config没写好部署阶段就会非常痛苦。我在做移动端图像分类时用的就是上面这份实现了channel attention里面共享Dense加zeros初始化策略的自定义层。整个项目训练到导出没有出现结构性问题只在转换TFLite时因为忘记指定custom_objects报错过一次。所以这篇博文虽然重点是CBAM的Keras代码实现但更想传达的其实是一套自定义模块工程化的思路搞清楚原理写稳代码做好序列化再去追求涨点。如果你按照这个顺序走CBAM这个模块几乎不会给你的项目拖后腿反而会在很多原本停滞的模型上带来意外的提升。