)
我最早接觸 CBAM 注意力模型是在折騰一個圖像分類網絡的時候。當時模型對全局特征提取得還行但對真正關鍵的局部區(qū)域和重要通道總是“一視同仁”導致背景干擾一大精度就往下掉。后來我把 CBAM 模塊用 Keras 一步步實現出來接到自己的卷積網絡上實測提升非常明顯。這篇就把實現細節(jié)、踩坑記錄和集成經驗完整寫出來。這篇文章適合已經在用卷積網絡做圖像分類、目標檢測或者特征提取的讀者也適合剛接觸注意力機制、想在自己模型里加一個即插即用模塊的新手。我會默認你用過 TensorFlow 和 Keras至少知道Conv2D、Dense這些基礎層是什么意思。如果只是聽說過注意力機制也不用慌代碼可以直接照抄原理我會用大白話講清楚。1. CBAM 到底在解決什么問題1.1 卷積層的信息瓶頸為什么只看當前層不夠卷積層在提取特征的時候本質上是用一組卷積核在輸入上滑動。每個卷積核對應一個輸出通道每個通道理解成一種“特征探測器”。問題是普通卷積網絡在做前向計算時并不會主動區(qū)分哪些通道對當前任務更重要。比如一個貓狗分類任務里有些通道可能提取的是毛發(fā)紋理有些可能提取的是耳朵輪廓有些可能對背景草地特別敏感。如果模型給這些通道分配一樣的權重無關特征就會干擾最終判斷。這就像一群人同時發(fā)言如果有重要信息的人聲音很小、講廢話的人嗓門很大結論肯定會被帶偏。注意力機制做的事情很簡單給每個通道、每個空間位置算一個權重把有用的信息放大沒用的信息壓下去。CBAM 就是這類思路里比較有代表性的一個實現。1.2 為什么不用 SE而是 CBAM很多人第一次接觸注意力模塊是從 Squeeze-and-Excitation 開始的。SE 模塊只做通道維度的注意力先對特征圖做全局池化然后用兩個全連接層學出一組通道權重。它解決的問題是“哪個通道值得看”但沒有回答“每個位置上哪些區(qū)域值得看”。CBAM 的名字直譯過來就是“卷積注意力模塊”它在通道注意力后面又加了一層空間注意力等于把兩個問題一起解決了。通道注意力負責找“要看什么”空間注意力負責找“看哪里”。CBAM 原文推薦的做法是先把特征圖送入通道注意力得到加權后的特征再送入空間注意力最后輸出最終特征。這種串聯順序我實測下來比反過來穩(wěn)定得多。因為先做通道加權之后特征圖上的噪聲響應會被壓掉一部分空間注意力再學位置權重時收到的輸入更干凈收斂也更快。實用建議在小模型上空間注意力帶來的提升往往比通道注意力更明顯。原因很簡單小模型通道數本來就不多通道注意力的可學習參數就那么幾個而空間注意力能從每個位置上重新組合信息表達能力更強。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 通道注意力完整代碼與關鍵細節(jié)下面是一個用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模型會直接報維度不匹配或者在你沒注意的時候偷偷把空間尺寸搞丟了。另一個細節(jié)是hidden max(channels // self.ratio, 4)。這個 4 是我自己加的下限。遇到像 MobileNet 那種通道數特別少的層比如只有 16 個通道再除以 8 就只剩 2 個神經元信息會被壓得太狠。下限設定為 4可以保證 MLP 中間層不至于太薄。如果你想用函數式 API 寫不習慣自定義層也可以把邏輯拆成一個普通函數但兩個Dense需要在外層創(chuàng)建并傳入否則每次調用都會新建參數網絡圖會變得很亂。我建議直接用自定義層在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 的卷積核空間注意力想捕捉的不只是單個像素的顯著性還包括相鄰區(qū)域之間的關系。7x7 的感受野比較大更容易把目標整體框出來。但這個核大小不是固定的。我在實際項目里發(fā)現如果輸入特征圖尺寸已經很小了比如小于 14x147x7 卷積的覆蓋范圍幾乎就是整張圖反而會丟失局部區(qū)分能力。這時候把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 后面效果最穩(wěn)。因為激活層之后特征的數值范圍更規(guī)整注意力模塊學習權重時不會受 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 通常放在殘差分支的最后一個卷積之后、和恒等映射相加之前。這樣可以保證注意力模塊的縮放操作不會直接影響殘差路徑的梯度流動訓練起來更穩(wěn)。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 卷積反而會帶來不小的計算量直接抵消輕量化優(yōu)勢。我的做法是只在大 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需要說明的是這些數字只是那一次的記錄不同網絡和數據集上的提升幅度差別很大。我在自建花卉數據集上提升更明顯因為那個數據集背景復雜很多樣本的目標區(qū)域只占圖像中央的一小塊。CBAM 相當于幫網絡把注意力集中在那小塊上抗背景干擾的能力一下就上來了。反過來如果任務非常簡單目標占圖像很大面積CBAM 帶來的提升就不明顯。5.2 注意力熱力圖怎么看只盯著準確率看還不夠我還習慣把空間注意力生成的權重圖可視化出來確認模塊真的學對了東西。做法是取一個測試樣本前向計算一次把SpatialAttention層輸出的(H, W, 1)權重用插值放大到原圖尺寸再疊加到原圖上。高亮的區(qū)域如果和人類判斷目標所在的位置重合說明模塊學得沒問題。有一次我在一個自建數據上發(fā)現熱力圖把整片背景都點亮了但目標區(qū)域反而不是高亮中心。檢查下來原因是數據增強里隨機裁剪比例太狠導致有些樣本里目標只占了很小一部分。后來把裁剪范圍收緊這個現象就消失了。這說明注意力熱力圖不只是個展示工具它也能幫你排查數據質量問題和預處理配置問題。6. 常見問題與避坑記錄6.1 維度對不上的排查思路遇到最多的問題就是Reshape維度錯誤。全局池化之后特征一般是(batch, C)沒有 H 和 W。有些人在這一步會下意識想加回高度和寬度維度寫成了Reshape((H, W, C))但 H 和 W 在前面已經丟掉了。排查技巧很簡單先不訓練只構建模型然后打印每一層的輸出形狀。Keras 自帶的model.summary()會在網絡構建失敗前直接給出 shape mismatch 的報錯點。如果用的是自定義層嵌套建議在call里加一行調試輸出把輸入 shape 打出來確認沒問題再刪掉。另一個容易踩的坑是動態(tài)維度。如果build里用了input_shape[-1]在某些使用None作為批量維的環(huán)境下得到的結果可能是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 等于又隨機丟棄了一部分有效信息訓練時會出現驗證集比訓練集還穩(wěn)定的“假收斂”現象。如果一定要做正則我建議把 Dropout 放在最后的全連接層之前而不是放在注意力模塊之后。6.3 推理耗時與參數量的平衡CBAM 雖然輕量但在移動端或者實時任務里積少成多也會成為負擔。我在一個移動端項目里做過優(yōu)化原來是每個 block 后面都接 CBAM特征圖大且層數多時整張網絡的推理耗時增加了 25% 左右。后來改成每經過一個 stage 只在最后一個 block 后接入推理耗時增加從 25% 降到了 7%精度只掉了約 0.3%。如果你的項目對延遲極度敏感還有一個選擇把空間注意力的卷積核從 7x7 改成 5x5 或者 3x3。這個改動對精度的損失通常很小尤其是在小輸入分辨率場景下有時候甚至沒有可感知的下降。另一個選擇是提高ratio到 16減少通道注意力 MLP 的中間層寬度。最后分享一個小經驗CBAM 不是加到網絡越深越好。在主干網絡比較深的情況下建議只把它加到 block 內部不要同時加到主干輸出后又接一個完整 CBAM那樣會造成注意力的過度疊加反而讓特征表達變得“偏科”。我習慣的做法是每個 stage 最多出現一次完整的通道加空間注意力后面的 stage 如果還想加就只保留通道注意力部分或者干脆不加。這樣模型容量不會被浪費在重復計算注意力權重上。