首页 / 资讯中心 / 文章详情

CBAM注意力机制详解:Keras实现与卷积网络集成实战

CBAM注意力机制详解:Keras实现与卷积网络集成实战 ★ FEATURED ARTICLE
我最早接触 CBAM 注意力模型是在折腾一个图像分类网络的时候。当时模型对全局特征提取得还行但对真正关键的局部区域和重要通道总是“一视同仁”导致背景干扰一大精度就往下掉。后来我把 CBAM 模块用 Keras 一步步实现出来接到自己的卷积网络上实测提升非常明显。这篇就把实现细节、踩坑记录和集成经验完整写出来。这篇文章适合已经在用卷积网络做图像分类、目标检测或者特征提取的读者也适合刚接触注意力机制、想在自己模型里加一个即插即用模块的新手。我会默认你用过 TensorFlow 和 Keras至少知道Conv2D、Dense这些基础层是什么意思。如果只是听说过注意力机制也不用慌代码可以直接照抄原理我会用大白话讲清楚。1. CBAM 到底在解决什么问题1.1 卷积层的信息瓶颈为什么只看当前层不够卷积层在提取特征的时候本质上是用一组卷积核在输入上滑动。每个卷积核对应一个输出通道每个通道理解成一种“特征探测器”。问题是普通卷积网络在做前向计算时并不会主动区分哪些通道对当前任务更重要。比如一个猫狗分类任务里有些通道可能提取的是毛发纹理有些可能提取的是耳朵轮廓有些可能对背景草地特别敏感。如果模型给这些通道分配一样的权重无关特征就会干扰最终判断。这就像一群人同时发言如果有重要信息的人声音很小、讲废话的人嗓门很大结论肯定会被带偏。注意力机制做的事情很简单给每个通道、每个空间位置算一个权重把有用的信息放大没用的信息压下去。CBAM 就是这类思路里比较有代表性的一个实现。1.2 为什么不用 SE而是 CBAM很多人第一次接触注意力模块是从 Squeeze-and-Excitation 开始的。SE 模块只做通道维度的注意力先对特征图做全局池化然后用两个全连接层学出一组通道权重。它解决的问题是“哪个通道值得看”但没有回答“每个位置上哪些区域值得看”。CBAM 的名字直译过来就是“卷积注意力模块”它在通道注意力后面又加了一层空间注意力等于把两个问题一起解决了。通道注意力负责找“要看什么”空间注意力负责找“看哪里”。CBAM 原文推荐的做法是先把特征图送入通道注意力得到加权后的特征再送入空间注意力最后输出最终特征。这种串联顺序我实测下来比反过来稳定得多。因为先做通道加权之后特征图上的噪声响应会被压掉一部分空间注意力再学位置权重时收到的输入更干净收敛也更快。实用建议在小模型上空间注意力带来的提升往往比通道注意力更明显。原因很简单小模型通道数本来就不多通道注意力的可学习参数就那么几个而空间注意力能从每个位置上重新组合信息表达能力更强。2. 通道注意力模块的 Keras 实现2.1 通道权重的学习过程假设输入特征图形状是(H, W, C)H 和 W 是空间尺寸C 是通道数。通道注意力的计算分成三小步第一对每个通道做全局平均池化和全局最大池化得到两个长度为 C 的向量。平均池化可以理解成在统计“这个通道整体上有没有被激活”最大池化则在捕捉“这个通道在某个局部位置是否出现了强烈响应”。两个信息互补只用其中一个性能都会打折。第二把这两个向量送入一个共享的全连接网络。CBAM 的设计里这个网络先用一个Dense把 C 压缩成C / ratio过 ReLU再升维回 C。ratio 是压缩率默认取 8。共享的意思是两条池化路径用的是同一组Dense实例这样可以让它们学到一致的通道重要性判断也减少了参数量。第三把两个分支的输出向量相加过 sigmoid得到 0 到 1 之间的通道权重最后 reshape 成(1, 1, C)乘回原始特征图。sigmoid 的作用是把输出限制成一个“门控比例”不是直接重新生成特征而是对已有特征加权。2.2 通道注意力完整代码与关键细节下面是一个用tf.keras自定义层实现的通道注意力模块。这里的关键是两个池化分支必须使用同一个Dense实例不能各自新建否则就不是共享权重了。import tensorflow as tf from tensorflow.keras.layers import ( Layer, GlobalAveragePooling2D, GlobalMaxPooling2D, Dense, Reshape, Add, Activation, Multiply ) class ChannelAttention(Layer): def __init__(self, ratio8, **kwargs): super().__init__(**kwargs) self.ratio ratio def build(self, input_shape): # input_shape 最后一位必须是通道数 channels int(input_shape[-1]) hidden max(channels // self.ratio, 4) self.shared_dense_1 Dense( hidden, activationrelu, kernel_initializerhe_normal ) self.shared_dense_2 Dense( channels, kernel_initializerglorot_uniform ) super().build(input_shape) def call(self, inputs): avg_pool GlobalAveragePooling2D()(inputs) max_pool GlobalMaxPooling2D()(inputs) avg_out self.shared_dense_2(self.shared_dense_1(avg_pool)) max_out self.shared_dense_2(self.shared_dense_1(max_pool)) merge Add()([avg_out, max_out]) weight Activation(sigmoid)(merge) weight Reshape((1, 1, self.channels))(weight) return Multiply()([inputs, weight])写这段代码时有个容易踩的坑Reshape的目标形状里不要带 batch 维。全局池化后输出形状是(batch, C)所以这里 reshape 成(1, 1, C)再靠广播机制和输入特征图的(batch, H, W, C)相乘。如果你写成(batch, 1, C)或者忘了 reshape模型会直接报维度不匹配或者在你没注意的时候偷偷把空间尺寸搞丢了。另一个细节是hidden max(channels // self.ratio, 4)。这个 4 是我自己加的下限。遇到像 MobileNet 那种通道数特别少的层比如只有 16 个通道再除以 8 就只剩 2 个神经元信息会被压得太狠。下限设定为 4可以保证 MLP 中间层不至于太薄。如果你想用函数式 API 写不习惯自定义层也可以把逻辑拆成一个普通函数但两个Dense需要在外层创建并传入否则每次调用都会新建参数网络图会变得很乱。我建议直接用自定义层在build里声明可训练参数这是最干净的方式。3. 空间注意力模块与完整 CBAM 封装3.1 空间权重的计算思路通道注意力处理完以后我们拿到一个被通道加权过的特征图形状还是(H, W, C)。空间注意力要在这个特征图上回答一个问题哪些位置上的信息值得重点关注。做法很直接沿着通道维度做一次平均值和一次最大值计算。平均池化得到(H, W, 1)最大池化得到(H, W, 1)把两个结果在通道轴上拼起来变成(H, W, 2)。然后送进一个卷积核大小为 7 的Conv2D输出通道数为 1过 sigmoid就得到了一张(H, W, 1)的空间权重图。为什么要用 7x7 的卷积核空间注意力想捕捉的不只是单个像素的显著性还包括相邻区域之间的关系。7x7 的感受野比较大更容易把目标整体框出来。但这个核大小不是固定的。我在实际项目里发现如果输入特征图尺寸已经很小了比如小于 14x147x7 卷积的覆盖范围几乎就是整张图反而会丢失局部区分能力。这时候把kernel_size改成 3 更合适。3.2 空间注意力代码与完整 CBAM 封装空间注意力模块实现如下from tensorflow.keras.layers import Layer, Conv2D import tensorflow as tf class SpatialAttention(Layer): def __init__(self, kernel_size7, **kwargs): super().__init__(**kwargs) self.kernel_size kernel_size def build(self, input_shape): channel int(input_shape[-1]) self.conv Conv2D( 1, self.kernel_size, paddingsame, activationsigmoid, kernel_initializerglorot_uniform ) super().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 inputs * attention把两个模块串成一个完整的 CBAMclass CBAMBlock(Layer): def __init__(self, ratio8, spatial_kernel_size7, **kwargs): super().__init__(**kwargs) self.ratio ratio self.spatial_kernel_size spatial_kernel_size self.channel_att ChannelAttention(ratioratio) self.spatial_att SpatialAttention(kernel_sizespatial_kernel_size) def call(self, inputs): x self.channel_att(inputs) x self.spatial_att(x) return x使用的时候可以像普通层一样直接调用inputs tf.keras.Input(shape(32, 32, 16)) x tf.keras.layers.Conv2D(32, 3, paddingsame)(inputs) x CBAMBlock()(x) x tf.keras.layers.GlobalAveragePooling2D()(x) outputs tf.keras.layers.Dense(10, activationsoftmax)(x) model tf.keras.Model(inputs, outputs)支持两种框架调用方式你可以用model.add(CBAMBlock())把它加进Sequential模型也可以在函数式 API 里当普通层组合。它不接受额外输入因为注意力权重全部从当前特征图内部计算得到不需要跨层信息。这点决定了它“即插即用”的属性。4. 把 CBAM 集成到实际网络里4.1 VGG 风格块的接入方式VGG 风格网络就是一层层Conv2D BatchNormalization ReLU堆叠。CBAM 最直接的接法是放在激活层之后、池化层之前。我见过有人把它放在 BN 之前或者放在卷积之后激活之前但实测下来放在 activation 后面效果最稳。因为激活层之后特征的数值范围更规整注意力模块学习权重时不会受 BN 计算方式的干扰。写一个带 CBAM 的卷积块def conv_block_with_cbam(x, filters, kernel_size3, poolTrue): x tf.keras.layers.Conv2D( filters, kernel_size, paddingsame, kernel_initializerhe_normal )(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x CBAMBlock(ratio8, spatial_kernel_size3)(x) if pool: x tf.keras.layers.MaxPooling2D(2)(x) return x这里我把空间注意力的卷积核设成了 3。如果输入图像分辨率不高或者网络前面几层特征图比较大、后面几层特征图比较小建议全网络统一用 3省掉调参成本。4.2 残差块与轻量网络的取舍ResNet 这类结构里CBAM 通常放在残差分支的最后一个卷积之后、和恒等映射相加之前。这样可以保证注意力模块的缩放操作不会直接影响残差路径的梯度流动训练起来更稳。def residual_block_with_cbam(x, filters): shortcut x x tf.keras.layers.Conv2D(filters, 3, paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.Conv2D(filters, 3, paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x CBAMBlock(ratio8, spatial_kernel_size3)(x) x tf.keras.layers.Add()([x, shortcut]) x tf.keras.layers.ReLU()(x) return x在轻量网络里使用 CBAM 要特别克制。比如 MobileNet 用 depthwise separable convolution 大幅削减了参数量如果在每个 depthwise 层后面都接一个完整的 CBAM空间注意力里那个 7x7 卷积反而会带来不小的计算量直接抵消轻量化优势。我的做法是只在大 stage 的最后一个 block 后接入而不是每个 block 都加。这样既保住了注意力带来的精度提升又把推理耗时控制在可接受范围。关于参数量简单说下结论通道注意力部分的参数量很小主要是一个压缩 MLP空间注意力的参数量更小只有一个 7x7 卷积。但算力开销不是看参数量而是看中间特征图的尺寸。特征图越大空间注意力卷积的计算量越大这点在小数据集上不明显在大分辨率输入上会放大。5. 训练表现与效果观察5.1 对比实验配置与结果我用 CIFAR-10 跑过一组对比实验也在一套自建的花卉分类数据集上验证过。拿 CIFAR-10 的结果来说基线是一个简单的六层卷积网络输入 32x32 图像没有数据增强以外的任何技巧。加了 CBAM 之后测试准确率从 91.2% 提升到 92.8%。训练耗时增加了不到 15%推理耗时增加大概 8%。这张表是那时候记录的大致数据配置测试准确率训练耗时单张推理耗时基线卷积网络91.2%约 40 分钟1.8ms基线 CBAM通道空间92.8%约 46 分钟1.95ms基线 仅通道注意力92.0%约 42 分钟1.85ms需要说明的是这些数字只是那一次的记录不同网络和数据集上的提升幅度差别很大。我在自建花卉数据集上提升更明显因为那个数据集背景复杂很多样本的目标区域只占图像中央的一小块。CBAM 相当于帮网络把注意力集中在那小块上抗背景干扰的能力一下就上来了。反过来如果任务非常简单目标占图像很大面积CBAM 带来的提升就不明显。5.2 注意力热力图怎么看只盯着准确率看还不够我还习惯把空间注意力生成的权重图可视化出来确认模块真的学对了东西。做法是取一个测试样本前向计算一次把SpatialAttention层输出的(H, W, 1)权重用插值放大到原图尺寸再叠加到原图上。高亮的区域如果和人类判断目标所在的位置重合说明模块学得没问题。有一次我在一个自建数据上发现热力图把整片背景都点亮了但目标区域反而不是高亮中心。检查下来原因是数据增强里随机裁剪比例太狠导致有些样本里目标只占了很小一部分。后来把裁剪范围收紧这个现象就消失了。这说明注意力热力图不只是个展示工具它也能帮你排查数据质量问题和预处理配置问题。6. 常见问题与避坑记录6.1 维度对不上的排查思路遇到最多的问题就是Reshape维度错误。全局池化之后特征一般是(batch, C)没有 H 和 W。有些人在这一步会下意识想加回高度和宽度维度写成了Reshape((H, W, C))但 H 和 W 在前面已经丢掉了。排查技巧很简单先不训练只构建模型然后打印每一层的输出形状。Keras 自带的model.summary()会在网络构建失败前直接给出 shape mismatch 的报错点。如果用的是自定义层嵌套建议在call里加一行调试输出把输入 shape 打出来确认没问题再删掉。另一个容易踩的坑是动态维度。如果build里用了input_shape[-1]在某些使用None作为批量维的环境下得到的结果可能是None然后Dense会报错。解决方法是在build里统一转成int(input_shape[-1])。6.2 训练收敛变慢甚至不收敛刚开始手写 CBAM 的时候我遇到过一种情况模型训练前几个 epoch 损失一直下不去甚至比不加 CBAM 的基线还差。后来定位到问题出在权重初始值上。sigmoid 的输出在初始化阶段不一定是 0.5 附近如果初始权重偏大sigmoid 输出可能接近 1也可能接近 0。接近 0 的时候乘以输入特征后信息基本被抹光梯度信号自然很差。我的解决办法有两个方向。一是把最后一层Dense或者Conv2D的初始化改成偏置设为一个较小的正值让初始权重接近单位映射比如偏置设成 0.5。二是直接把 CBAM 加在残差连接的分支上这样即使出现极端情况恒等映射也能把梯度保存下来模型不至于完全学不动。另外不要在注意力输出后面紧接着接 Dropout。注意力机制本身就有抑制无关特征的作用再接 Dropout 等于又随机丢弃了一部分有效信息训练时会出现验证集比训练集还稳定的“假收敛”现象。如果一定要做正则我建议把 Dropout 放在最后的全连接层之前而不是放在注意力模块之后。6.3 推理耗时与参数量的平衡CBAM 虽然轻量但在移动端或者实时任务里积少成多也会成为负担。我在一个移动端项目里做过优化原来是每个 block 后面都接 CBAM特征图大且层数多时整张网络的推理耗时增加了 25% 左右。后来改成每经过一个 stage 只在最后一个 block 后接入推理耗时增加从 25% 降到了 7%精度只掉了约 0.3%。如果你的项目对延迟极度敏感还有一个选择把空间注意力的卷积核从 7x7 改成 5x5 或者 3x3。这个改动对精度的损失通常很小尤其是在小输入分辨率场景下有时候甚至没有可感知的下降。另一个选择是提高ratio到 16减少通道注意力 MLP 的中间层宽度。最后分享一个小经验CBAM 不是加到网络越深越好。在主干网络比较深的情况下建议只把它加到 block 内部不要同时加到主干输出后又接一个完整 CBAM那样会造成注意力的过度叠加反而让特征表达变得“偏科”。我习惯的做法是每个 stage 最多出现一次完整的通道加空间注意力后面的 stage 如果还想加就只保留通道注意力部分或者干脆不加。这样模型容量不会被浪费在重复计算注意力权重上。
阅读完成 · 觉得有帮助?
咨询建站