从两个反直觉现象说起:CBAM想解决的模型痛点
做图像分类和检测的朋友应该都有过类似的体验:模型在验证集上卡在某个准确率阈值上不去,试着把网络的通道数翻倍、卷积层加深,参数量涨了一截,性能却只提升零点几个百分点。群里有人建议上注意力模块,你翻了一圈开源代码,发现大多数实现都是照搬论文公式,丢进自己的Keras工程里要么报维度错误,要么训练完反而掉点。这篇博文要聊的,就是如何在Keras环境下把CBAM(Convolutional 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]并设置keepdims=True,这样输出形状就是(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, ratio=8, **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, activation='relu', use_bias=True, kernel_initializer='he_normal', name='shared_dense1' ) self.shared_dense2 = layers.Dense( channels, use_bias=True, kernel_initializer='zeros', name='shared_dense2' ) super(ChannelAttention, self).build(input_shape) def call(self, inputs): avg_pool = tf.reduce_mean(inputs, axis=[1, 2], keepdims=True) max_pool = tf.reduce_max(inputs, axis=[1, 2], keepdims=True) 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×1=98,再加上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_size=7, **kwargs): super(SpatialAttention, self).__init__(**kwargs) self.kernel_size = kernel_size def build(self, input_shape): self.conv = layers.Conv2D( filters=1, kernel_size=self.kernel_size, strides=1, padding='same', activation='sigmoid', use_bias=False, kernel_initializer='he_normal', name='spatial_attn_conv' ) super(SpatialAttention, self).build(input_shape) def call(self, inputs): avg_pool = tf.reduce_mean(inputs, axis=-1, keepdims=True) max_pool = tf.reduce_max(inputs, axis=-1, keepdims=True) 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_last,TensorFlow后端也基本是channels_last一统天下,但如果是从PyTorch工程迁移过来的,一定要检查一下input_shape的通道位置。我知道有人在这个问题上花了一整天时间排查,最后的报错信息还特别误导,一会儿说形状不匹配,一会儿说negative dimension,其实根因就是数据格式不一致。
卷积层我设置了use_bias=False,这基于一个实验观察:加bias之后,空间注意力图的初始输出会整体偏移,导致某些区域一开始就被固定权重压制。去掉bias以后,卷积输出完全由两个池化通道的信息决定,Sigmoid的初始输出能保持在一个相对中性的水平。另外,padding必须用same,否则输出尺寸会变小,和输入特征图做乘法时又得处理边缘对齐问题。
3.3 拼接通道后接单通道卷积为什么管用
把平均池化和最大池化拼接成两通道,再卷积成单通道,这个操作本质上是在学习一种“空间显著性”的组合规则。平均池化的结果更像背景模型,最大池化的结果更像前景目标的高亮响应。卷积层用这两个输入学习一个可微的加权组合,找到目标最可能存在的区域。你甚至可以把这层卷积理解为一个小型的逻辑回归:两个输入特征,一堆权重,输出一个空间上的显著性概率图。
这种做法的巧妙之处在于它不依赖任何额外的监督信号,空间注意力的训练目标完全由下游任务的loss隐式驱动。网络会自己学会在语义边缘、目标中心等位置提高注意力权重。我在目标检测任务里验证过,把CBAM插入检测骨干网络后,特征图的可视化结果中,小目标的响应区域更加集中,漏检率明显下降。这算是空间注意力分支带来的最直观收益。
把两个分支合并成CBAM模块并接入ResNet
4.1 组合顺序不是随便定的
通道注意力和空间注意力谁先谁后,是CBAM论文里一个相当重要的设计点。论文给出了两个顺序的实验对比:先通道后空间,和先空间后通道。结果显示先通道后空间更好。我从使用者的角度理解这个顺序,通道注意力是对全图信息的全局重标定,它决定了“看什么特征”,空间注意力是在此基础上决定“看哪里”。如果先做空间注意力,等于在特征通道没有校准的情况下就锁定空间区域,容易把噪声通道上的一些虚假高响应区域当成目标重点关注,后续再做通道校准也纠正不回来。
组合方式的Keras代码很直接,但有几个细节需要注意。
class CBAM(layers.Layer): def __init__(self, ratio=8, kernel_size=7, **kwargs): super(CBAM, self).__init__(**kwargs) self.ratio = ratio self.kernel_size = kernel_size def build(self, input_shape): self.channel_attn = ChannelAttention(ratio=self.ratio) self.spatial_attn = SpatialAttention(kernel_size=self.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, stride=1, use_cbam=True): shortcut = x x = layers.Conv2D(filters, 3, strides=stride, padding='same', use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.Conv2D(filters, 3, strides=1, padding='same', use_bias=False)(x) if use_cbam: x = CBAM(ratio=8)(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) if stride != 1 or shortcut.shape[-1] != filters: shortcut = layers.Conv2D(filters, 1, strides=stride, use_bias=False)(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对已有模型做外科手术。假设你已经加载了一个预训练的ResNet50:
base_model = tf.keras.applications.ResNet50( include_top=False, weights='imagenet', input_shape=(224, 224, 3) ) x = base_model.output x = CBAM(ratio=8)(x) x = layers.GlobalAveragePooling2D()(x) x = layers.Dense(1000, activation='softmax')(x) model = tf.keras.Model(inputs=base_model.input, outputs=x)这种集成方式适用于那些不想动骨架结构的场景,适用于分类、特征提取、对比学习等任务。它会额外增加一个CBAM模块的参数量,整个模型结构也不会被破坏。
4.3 参数量和计算量的实测对比
很多人会担心加上CBAM之后模型变大、训练变慢。我用自己的网络统计了一组数据,以ResNet50最后一个阶段的输出特征图为例,假设输入224×224,通道数为2048。通道注意力分支的Dense层参数量大约是2×(2048×256),大概100万出头,听起来不少,但只占模型总参数量的很小比例,因为ResNet50本身有超过2500万参数。空间注意力分支的参数量就是那98个,可以忽略不计。真正值得注意的是中间特征图的尺寸,如果feature map分辨率是28×28,通道数256,CBAM的显存占用主要体现在保存中间激活值上,而不是参数本身。
我建议用以下公式快速估算:
[ \text{CBAM参数} \approx 2 \times C \times \frac{C}{r} ]
其中C是输入通道数,r是压缩比。r=16时参数是C²/8,r=8时是C²/4。在C=512的情况下,r=8的参数是32.8万,r=16是16.4万。选择r时要在性能和开销之间权衡,这个细节我在下一节会展开说。
训练踩坑记录:从梯度消失到保存模型失败
5.1 初始化不当导致通道注意力直接失效
这是我最开始实现CBAM时踩过的第一个坑。那时我没给第二个Dense层设置zeros初始化,直接用了默认的glorot_uniform。训练两三轮之后,我发现CBAM的输出权重几乎全部收敛到0.5附近,也就是说注意力模块约等于恒等映射,完全没学到东西。一开始我以为是学习率太大,调小之后依然如此。后来在TensorBoard里看了中间层的输出分布才意识到,问题出在通道注意力的初始输出上:随机初始化的MLP输出经过Sigmoid后,在0.5附近波动的幅度太小,梯度经过两层Dense后变得很微弱,对特征重标定的信号不足以推动网络显著调整。
换成zeros初始化之后,第二个Dense的输出一开始就是0,Sigmoid输出正好是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或2048,r=16完全够用,因为通道冗余度高,压缩一部分信息损失不大。输入通道数中等的时候,比如256或512,r=8往往比r=16效果更好。通道数很小的时候,比如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这个模块几乎不会给你的项目拖后腿,反而会在很多原本停滞的模型上带来意外的提升。