ITPub博客

首页 > 人工智能 > 深度学习 > CBAM论文解读:双重注意力机制 | Towards Data Science

CBAM论文解读:双重注意力机制 | Towards Data Science

深度学习 作者:ITPUB社群 时间:1970-01-01 08:33:46 0 删除 编辑

使用 PyTorch 从零开始理解和实现 CBAM(卷积块注意力模块)

引言

在本文中,我将回顾并实现 Woo 等人发表的深度学习论文《CBAM: Convolutional Block Attention Module》[1]。顾名思义,这本质上是一个可以附加到基于 CNN 的模型上的模块,通过执行注意力机制来增强特征质量。尽管名字中有“注意力”一词,但它与 ViT(Vision Transformer(深度学习中的注意力模型架构))架构中的注意力机制完全不同。请记住,CBAM 首次发布于 2018 年,而 ViT 直到 2020 年才被提出。因此,我们可以简单地说,CBAM 是将注意力机制应用于图像数据的较早方法。尽管出现较早,我们也不必担心其相关性,因为与 ViT 相比,CBAM 要轻量得多,这使其适合作为部署在低功耗设备上的起点。

优于 SENet

如果回顾历史,CBAM 实际上是为了改进 SENet(Squeeze-and-Excitation Network)而提出的,后者比 CBAM 早一年问世。如果你还记得 SENet 架构,它本质上是通过在通道维度上执行注意力来工作的。通过这种方式,看起来不重要的通道会被赋予比重要通道更低的权重。——我实际上有一篇单独的文章更详细地讨论了 SENet,你可以通过参考文献 [2] 中给出的链接访问。

CBAM 不仅仅为每个通道分配权重,还为图像空间维度中的每个像素赋予权重。因此,我们可以说 CBAM 有两种注意力机制,作者将其称为 CAM(通道注意力模块)和 SAM(空间注意力模块)。所以,仅基于这一理论,CBAM 应该比 SENet 表现更好。

CBAM 架构

让我们更具体地讨论 CBAM 架构,我在下面的图 1 中展示了它。正如我之前提到的,CBAM 由 CAM 和 SAM 组成。这两个子模块负责生成注意力权重,然后通过乘法将其应用于原始张量。该模块的输出张量(即所谓的“精炼特征”)与输入(“输入特征”)具有完全相同的维度,这意味着我们可以轻松地将 CBAM 插入任何骨干模型,而无需担心改变张量形状。

通道注意力模块(CAM)

现在让我们仔细看看下面图 2 中的通道注意力模块。这个组件实际上与 SENet 模块非常相似,不同之处在于 CAM 除了使用全局平均池化层外,还使用了全局最大池化层。论文中解释说,这两种操作捕获了不同类型的信息,使得 CAM 产生的张量比 SENet 模块产生的张量更具信息量。

请记住,当我们对张量应用全局池化操作时,其空间维度会坍缩为 1×1。这本质上意味着,原本形状为 C × H × W 的输入张量现在变成了 C ×1×1,使我们能够在后续步骤中轻松地使用 MLP 对其进行进一步处理。这个 MLP 中有两个线性层,第一个用于根据缩减比率参数缩小特征数量,而第二个则将特征向量扩展回原始长度(即通道数 C)。MLP 中的这两个线性层共同负责学习每个通道的重要性。另外,请记住,这个 MLP 对于最大池化和平均池化操作产生的张量是共享的,这意味着这两个张量将由完全相同的 MLP 处理。

在这两个张量被处理后,我们通过逐元素求和将它们组合起来,并使其通过一个 sigmoid 函数。由于该函数将任何数值压缩到 0 到 1 的范围内,我们现在可以将得到的张量视为通道注意力权重。对应更重要通道的元素其值将接近 1,表明这些通道将比其他通道获得更高的权重。根据论文,这种机制有助于模型理解应该关注什么样的特征。

你可以在下面的图3中看到通道注意力模块的正式数学表达式,其中F是网络中的任意中间张量。这里你需要记住的一点是,两个线性层(即W₀和W₁)之间应该有一个ReLU激活函数,但不知何故在这个方程中没有写出来。

空间注意力模块(SAM)

空间注意力模块在概念上与通道注意力模块相似。请看下面图4中这个子模块的图示。

SAM与CAM的本质区别在于池化操作所沿的轴不同。之前在CAM中,池化是在空间维度上进行的,使得每个通道都有一个代表该通道的单一值。而在SAM中,池化是在通道维度上针对每个空间像素位置进行的。因此,每个像素现在包含一个同时代表所有通道的单一值。通过这样做,最初形状为C × H × W的输入张量将坍缩为1× H × W。记住,由于我们使用了最大池化和平均池化操作,因此我们将得到两个该大小的张量。这两个张量随后被拼接,形成一个形状为2× H × W的新张量。然后这个张量通过一个具有单个卷积核的7×7卷积层进行处理,这有效地将两个通道的信息合并为一个。所以,此时张量再次变为1× H × W,然后被送入sigmoid函数。与CAM类似,这个sigmoid产生的张量充当空间注意力权重。通过使用这个权重张量,我们本质上可以让模型知道它应该在哪里投入更多注意力。下面是空间注意力模块的正式数学定义。

将CBAM集成到任何骨干模型

之前我提到CBAM的输出形状与输入完全相同,使其能够轻松集成到任何骨干模型中。事实上,作者也提供了关于如何做到这一点的图示,我在下面图6中展示给你。在这个例子中,他们展示了如何将CBAM插入到ResNet构建块中。一旦我们像这样成功集成了它们,我们就可以像往常一样堆叠这些块。在后面的编码部分,我将演示如何从头实现CBAM以及如何将其插入到ResNeXt模型中。

实验结果

CBAM架构本身的设计并非随意选择的。相反,它是基于消融研究的实证结果构建的,在这些研究中他们证明了最终模型确实是最优的。

通道注意力模块的消融研究

他们进行的第一个消融研究与CAM中的池化层有关。这组实验的结果如图7所示。我们可以在这个表中看到,使用两种池化中任意一种时的错误率都显著低于普通的ResNet50骨干模型。这基本上表明最大池化和平均池化提取的信息都很重要。此外,当我们同时使用两种池化机制时,top-1错误率进一步降低到22.80%,我认为这证明了这两个张量包含的信息不仅重要而且是互补的(即相互完善)。从理论上讲,最大池化和平均池化产生的特征确实应该是互补的,因为前者捕获通道内最显著的像素值,而后者提取通道的一般信息。所以,这基本上就是为什么在图2中作者最终使用了两种池化操作的原因。

空间注意力模块的消融研究

关于空间注意力模块,论文中解释说作者也使用了不同的配置,如下图8所示。你可以看到这里最后一行的配置产生了最佳结果,它使用了两种池化操作后接一个7×7卷积核的卷积层。在SAM的情况下,最大池化和平均池化操作在技术上都可以被1×1卷积替代(它将使用可学习参数跨通道维度组合信息,而不是使用“固定的”平均和最大操作),但分类性能似乎并不理想。

关于CAM和SAM放置位置的消融研究

作者进行的最后一项消融研究是关于CAM和SAM在CBAM中如何排列的。如下方图9所示,使用顺序方法,尤其是先CAM后SAM,可以使模型表现最佳,top-1错误率仅为22.66%。你可以在下一行看到,他们尝试交换这两个模块的顺序,但发现错误率反而增加了。此外,当他们尝试将CAM和SAM并行化时,分类性能变得更差,尽管这种方法仍然优于仅使用SE模块的ResNet50。

与其他模型的比较

在随后的实验中,作者比较了普通模型、带有SE模块的模型以及在不同主干网络上带有CBAM的模型的性能。你可以在下方图10中看到,使用CBAM的模型几乎总是比其他两个表现更好,如绿色高亮所示。此外,在ResNeXt50中,尽管带有SE模块的模型优于带有CBAM的同一模型,但错误率差距仅为0.01%,我认为这是可以忽略不计的。

我还在该图中发现,带有CBAM的ResNet50的错误率低于普通ResNet101,如橙色高亮所示。有趣的是,这里可以看到带有CBAM的ResNet50的参数数量和GFLOPs要小得多。这些事实表明,CBAM使较浅的网络能够超越较深的网络,同时显著节省计算资源,这对于在低端设备上部署是理想的。

注意力热力图

除了上述定量结果外,作者还使用Grad-CAM进行定性评估。如果你还不熟悉Grad-CAM,它本质上是一种我们可以用来找出对预测类别贡献更大的特定区域的方法。下方图11展示了使用Grad-CAM生成的几个注意力热力图示例,其中红色高亮区域表示它对预测类别的贡献更大。

结果相当有趣。现在让我们来看看槌球类别。使用普通ResNet50时,模型似乎同时关注球和人。当应用SE模块(即仅通道注意力)时,注意力图变得更加精细地聚焦于球。然后,当我们将SE模块替换为CBAM后,模型对目标物体的聚焦更加锐利。

在其他类别中也可以观察到类似的情况。例如,在爱斯基摩犬和雪豹中,我们可以看到模型只关注眼睛。如果让我说的话,只要预测类别正确,这基本上没有错。然而,如果我们(作为人类)要预测某物,尽可能看到整个物体不是更有意义吗?而这正是注意力模块所做的。你可以看到,当使用CBAM时,注意力热力图中的红色区域覆盖了整个面部,表明模型现在将面部区域纳入考虑来进行预测。

此外,从图中还可以看到,通过使用CBAM,我们可以使模型在预测时更加自信。看看上图中的校车图像。你可以看到公交车并不位于图像的正中央。这基本上导致普通ResNet50在预测公交车时置信度仅为0.07(我认为这应该被误分类了)。同时,SE模块使模型能够以0.92的置信度正确分类,然后CBAM进一步将其提升至0.98。

CBAM实现

既然我们已经理解了CBAM背后的所有理论,现在让我们卷起袖子,动手写一些代码吧!正如我之前提到的,这里我将实现CBAM并尝试将其集成到ResNeXt主干网络中。虽然我是从头开始实现两者,但我将主要讨论CBAM模块。所以如果你还不熟悉ResNeXt,我鼓励你先阅读我之前关于该模型的文章,可以通过参考文献[4]中的链接访问。

和往常一样,我们需要做的第一件事是导入所需的模块,即基础 torch 模块及其 nn 子模块。

# Codeblock 1
import torch
import torch.nn as nn

接下来,在下面的代码块 2 中,我初始化可配置变量。缩减比 R 用于调整 CAM 内部 MLP 层的宽度。按照论文中的建议,我将其值设为 16。同时,CARDINALITY、NUM_CHANNELS 和 NUM_BLOCKS 属于 ResNeXt 的参数。

# Codeblock 2
R            = 16
CARDINALITY  = 32
NUM_CHANNELS = [3, 64, 256, 512, 1024, 2048]
NUM_BLOCKS   = [3, 4, 6, 3]
NUM_CLASSES  = 1000

CAM 实现

让我们先从 CAM 开始。如果你回到图 2,可以看到我们有两个池化操作。在下面的代码块 3 中,与它们对应的两个层分别在第 #(1) 行和第 #(2) 行初始化。别忘了将 output_size 参数设置为 (1,1),因为我们希望每个通道被表示为一个单一数值。

接下来在 __init__() 方法中,我们初始化由两个线性层组成的 MLP。第一个线性层负责根据 R 参数减少特征数量(#(3)),而第二个线性层用于将其扩展回原始特征数量(#(5))。另外,别忘了在它们之间放置 ReLU 激活函数(#(4))。事实上,这个 MLP 层的结构与 SENet 中使用的完全相同。——你可以在我之前关于该模块的文章(参考文献 [2])中了解更多关于这个结构背后的思想。——在 __init__() 方法中我们要做的最后一件事是初始化 sigmoid 激活函数(#(6)),它负责重新缩放张量,使值始终介于 0 和 1 之间,适合用作注意力权重。

# Codeblock 3
class CAM(nn.Module):
def __init__(self, num_channels, r=16):
super().__init__()
self.maxpool = nn.AdaptiveMaxPool2d(output_size=(1,1))  #(1)
self.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1))  #(2)
self.mlp = nn.Sequential(
nn.Linear(in_features=num_channels,
out_features=num_channels//r,   #(3)
bias=False),
nn.ReLU(inplace=True),                    #(4)
nn.Linear(in_features=num_channels//r,    #(5)
out_features=num_channels,
bias=False)
)
self.sigmoid = nn.Sigmoid()                   #(6)
def forward(self, x):         #(7)
original = x
print(f'original\t\t: {x.size()}\n')
x_max = self.maxpool(x)   #(8)
print(f'x after maxpool (x_max)\t: {x_max.size()}')
x_avg = self.avgpool(x)   #(9)
print(f'x after avgpool (x_avg)\t: {x_avg.size()}\n')
x_max = torch.flatten(x_max, start_dim=1)    #(10)
print(f'x_max after flatten\t: {x_max.size()}')
x_avg = torch.flatten(x_avg, start_dim=1)    #(11)
print(f'x_avg after flatten\t: {x_avg.size()}\n')
x_max = self.mlp(x_max)   #(12)
print(f'x_max after mlp\t\t: {x_max.size()}')
x_avg = self.mlp(x_avg)   #(13)
print(f'x_avg after mlp\t\t: {x_avg.size()}\n')
x = x_max + x_avg         #(14)
print(f'after sum\t\t: {x.size()}')
x = self.sigmoid(x)       #(15)
print(f'after sigmoid\t\t: {x.size()}')
x = x[:, :, None, None]   #(16)
print(f'after reshape\t\t: {x.size()}')
x = x * original          #(17)
print(f'after multiply\t\t: {x.size()}')
return x

现在让我们继续到 forward() 方法,执行过程将在这里发生。你可以在上面代码块的第 #(7) 行看到,我们接收一个单一张量 x 作为输入。这个输入张量随后会被保存在 original 变量中,这样做是因为我们稍后会将它与生成的通道注意力权重张量相乘(#(17))。x 张量本身将并行经过最大池化和平均池化操作(#(8–9))。x_max 和 x_avg 都被前向传递到同一个 MLP,这就是为什么这个 MLP 被称为“共享”的(#(12–13))。然后在第 #(14) 行,我们通过逐元素求和将 x_max 和 x_avg 组合起来,再将结果张量前向传递到 sigmoid 函数(#(15))。在第 #(16) 行我们做了一个小的技术性操作,用于重新引入我们之前在第 #(10) 行和第 #(11) 行丢弃的空间维度。最后,当权重张量准备好后,我们就可以通过相乘来对原始张量进行加权(#(17))。

至此,我们已经完成了 CAM 类。接下来我们要做的是用以下代码来测试它。这里我初始化一个接受 512 通道图像的 CAM 实例,并将一个大小为 512×28×28 的虚拟张量传递给它,模拟网络中任意的中间张量。

# Codeblock 4
cam = CAM(num_channels=512, r=16)
x = torch.randn(1, 512, 28, 28)
out = cam(x)

如果你运行上面的代码,应该会得到以下输出。注意,之前在代码块 3 中我写了很多 print 函数,这就是为什么在这里你可以清楚地看到网络的详细流程。

# Codeblock 4 Output
original                : torch.Size([1, 512, 28, 28])
x after maxpool (x_max) : torch.Size([1, 512, 1, 1])
x after avgpool (x_avg) : torch.Size([1, 512, 1, 1])
x_max after flatten     : torch.Size([1, 512])
x_avg after flatten     : torch.Size([1, 512])
x_max after mlp         : torch.Size([1, 512])    #(1)
x_avg after mlp         : torch.Size([1, 512])    #(2)
after sum               : torch.Size([1, 512])
after sigmoid           : torch.Size([1, 512])
after reshape           : torch.Size([1, 512, 1, 1])    #(3)
after multiply          : torch.Size([1, 512, 28, 28])  #(4)

有必要理解的是,尽管 MLP 看起来完全没有改变张量维度(#(1–2)),但你需要知道,特征向量长度在内部被第一个线性层缩减到 32,然后最终被第二个线性层扩展回 512。接下来,可能还值得注意的是,通道注意力权重张量最初的形状是 512×1×1(#(3)),表明原始张量中的每个通道都有一个与之关联的单一权重数值。这个注意力权重随后通过简单的乘法应用于原始张量,从技术上讲,这个权重张量是沿着原始张量的空间维度进行广播的(#(4))。此时我们的张量已经准备好被前向传递到 SAM,我们很快就会构建它。

SAM 实现

空间注意力模块的实现展示在下面的代码块 5 中。我们需要在 __init__() 方法中初始化的只有一个 7×7 卷积层(#(1))和一个 sigmoid 激活函数(#(2))。

# Codeblock 5
class SAM(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(in_channels=2,     #(1)
out_channels=1,
kernel_size=7,
padding=3,
bias=False)
self.sigmoid = nn.Sigmoid()              #(2)
def forward(self, x):
original = x      #(3)
print(f'original\t\t: {x.size()}\n')
x_max, _ = torch.max(x,  dim=1, keepdim=True)    #(4)
print(f'x after maxpool (x_max)\t: {x_max.size()}')
x_avg    = torch.mean(x, dim=1, keepdim=True)    #(5)
print(f'x after avgpool (x_avg)\t: {x_avg.size()}\n')
x = torch.cat([x_max, x_avg], dim=1)             #(6)
print(f'after concatenate\t: {x.size()}')
x = self.conv(x)                                 #(7)
print(f'after conv\t\t: {x.size()}')
x = self.sigmoid(x)                              #(8)
print(f'after sigmoid\t\t: {x.size()}')
x = x * original                                 #(9)
print(f'after multiply\t\t: {x.size()}')
return x

在 forward() 方法中,我们做的第一件事是将原始输入存储到一个单独的变量中(#(3)),这与我们在 CAM 中所做的完全相同。SAM 中的池化机制有些独特,因为这里我们想要跨通道维度进行池化。这本质上就是我没有在 __init__() 方法中初始化任何池化层的原因,因为 nn.AdaptiveMaxPool2d() 和 nn.AdaptiveAvgPool2d() 操作的是空间维度,在这种情况下是不相关的。相反,这里我们使用简单的 torch.max() 和 torch.mean() 函数来分别执行最大池化(#(4))和平均池化(#(5))操作。只是别忘了将 dim 参数设置为 1,这样它们才能真正跨通道维度执行操作。

尽管取值不同,两种池化产生的张量形状完全相同,这正是我们可以在第 #(6) 行直接将它们拼接的原因。请记住,张量拼接并不会真正融合信息,它只是将两者堆叠在一起,而不会混合其中的数值。因此,在后续步骤中,我们应用之前初始化的卷积层来真正实现这一点(#(7))。该卷积仅包含一个卷积核,这意味着生成的张量也将只有一个通道。这一思路在概念上不同于 CAM 中的做法,在 CAM 模块中我们通过逐元素求和来融合信息。从技术上讲,我们本质上也可以对 SAM 使用求和,因为它会产生完全相同的张量维度。然而,我确实认为作者可能也意图捕捉相邻像素之间的相关性,而不是独立地为每个像素赋予权重,这就是他们决定使用卷积而非求和的原因。

在两个张量合并之后,接下来我们要做的是将结果张量通过 sigmoid 激活函数,以真正获得空间注意力权重(#(8))。最后,我们将这个权重张量与原始 SAM 输入相乘,如第 #(9) 行所示。

现在让我们运行下面的 Codeblock 6 来测试我们的空间注意力模块是否正常工作。

# Codeblock 6
sam = SAM()
x = torch.randn(1, 512, 28, 28)
out = sam(x)

下面是 SAM 的流程示意图。我们可以看到,当池化操作应用于输入张量时,通道维度坍缩为 1(#(1–2))。这本质上意味着每个像素现在被表示为从该空间位置的所有通道聚合而来的单个数值。然后在第 #(3) 行,张量变为 2×28×28,因为我们将两者拼接在一起,之后最终通过卷积层将其再次缩减为 1×28×28(#(4))。经过 sigmoid 函数处理后,得到的空间注意力权重与原始张量相乘,其中前者沿后者的通道维度进行广播,使得最终输出张量的形状与输入完全相同(#(5))。

# Codeblock 6 Output
original                : torch.Size([1, 512, 28, 28])
x after maxpool (x_max) : torch.Size([1, 1, 28, 28])    #(1)
x after avgpool (x_avg) : torch.Size([1, 1, 28, 28])    #(2)
after concatenate       : torch.Size([1, 2, 28, 28])    #(3)
after conv              : torch.Size([1, 1, 28, 28])    #(4)
after sigmoid           : torch.Size([1, 1, 28, 28])
after multiply          : torch.Size([1, 512, 28, 28])  #(5)

完整的 CBAM 模块

现在 CAM 和 SAM 都已完成,我们将它们放在一起组成 CBAM 类。详见下面的 Codeblock 7。你可以看到这个类非常简单,我们只需要初始化两个注意力模块并按顺序放置它们。请记住,每次想要初始化一个 CBAM 实例时,我们都需要传入 num_channels 参数(#(1)),因为稍后我们会将这个模块集成到 ResNeXt 中,而 ResNeXt 的每一个构建块都接受不同数量的通道,因此我们也需要让这个 CBAM 块具有灵活性。

# Codeblock 7
class CBAM(nn.Module):
def __init__(self, num_channels):    #(1)
super().__init__()
self.cam = CAM(num_channels=num_channels)
self.sam = SAM()
def forward(self, x):
print(f'original\t\t: {x.size()}')
x = self.cam(x)
print(f'after cam\t\t: {x.size()}')
x = self.sam(x)
print(f'after sam\t\t: {x.size()}')
return x

同样,为了确保这个类能正常工作,让我们使用下面的 Codeblock 8 传入一个虚拟张量进行测试。

# Codeblock 8
cbam = CBAM(num_channels=512)
x = torch.randn(1, 512, 28, 28)
out = cbam(x)
# Codeblock 8 Output
original   : torch.Size([1, 512, 28, 28])
after cam  : torch.Size([1, 512, 28, 28])
after sam  : torch.Size([1, 512, 28, 28])

在 ResNeXt 构建块上实现 CBAM

好的,至此我们的 CBAM 已经准备好了,在本节中我将实际演示如何将这个模块附加到 ResNeXt 构建块上。我在 Codeblock 9 及之后编写的代码基本上与我演示如何在 ResNeXt 上集成 SENet 时使用的代码相同。我鼓励你阅读那篇文章 [2] 以及我对纯 ResNeXt 主干网络的解释 [4],因为如果在这里解释所有内容会太长了。

在 Codeblock 9 中我唯一想强调的是,CBAM 模块本身在第 #(1) 行初始化,然后在第 #(2) 行附加到流程中。

# Codeblock 9
class Block(nn.Module):
def __init__(self,
in_channels,
add_channel=False,
channel_multiplier=2,
downsample=False):
super().__init__()
self.add_channel = add_channel
self.channel_multiplier = channel_multiplier
self.downsample = downsample
if self.add_channel:
out_channels = in_channels*self.channel_multiplier
else:
out_channels = in_channels
mid_channels = out_channels//2
if self.downsample:
stride = 2
else:
stride = 1
if self.add_channel or self.downsample:
self.projection = nn.Conv2d(in_channels=in_channels,
out_channels=out_channels,
kernel_size=1,
stride=stride,
padding=0,
bias=False)
nn.init.kaiming_normal_(self.projection.weight, nonlinearity='relu')
self.bn_proj = nn.BatchNorm2d(num_features=out_channels)
self.conv0 = nn.Conv2d(in_channels=in_channels,
out_channels=mid_channels,
kernel_size=1,
stride=1,
padding=0,
bias=False)
nn.init.kaiming_normal_(self.conv0.weight, nonlinearity='relu')
self.bn0 = nn.BatchNorm2d(num_features=mid_channels)
self.conv1 = nn.Conv2d(in_channels=mid_channels,
out_channels=mid_channels,
kernel_size=3,
stride=stride,
padding=1,
bias=False,
groups=CARDINALITY)
nn.init.kaiming_normal_(self.conv1.weight, nonlinearity='relu')
self.bn1 = nn.BatchNorm2d(num_features=mid_channels)
self.conv2 = nn.Conv2d(in_channels=mid_channels,
out_channels=out_channels,
kernel_size=1,
stride=1,
padding=0,
bias=False)
nn.init.kaiming_normal_(self.conv2.weight, nonlinearity='relu')
self.bn2 = nn.BatchNorm2d(num_features=out_channels)
self.relu = nn.ReLU()
self.cbam = CBAM(num_channels=out_channels)               #(1)
def forward(self, x):
print(f'original\t\t: {x.size()}')
if self.add_channel or self.downsample:
residual = self.bn_proj(self.projection(x))
print(f'after projection\t: {residual.size()}')
else:
residual = x
print(f'no projection\t\t: {residual.size()}')
x = self.conv0(x)
x = self.bn0(x)
x = self.relu(x)
print(f'after conv0-bn0-relu\t: {x.size()}')
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
print(f'after conv1-bn1-relu\t: {x.size()}')
x = self.conv2(x)
x = self.bn2(x)
print(f'after conv2-bn2\t\t: {x.size()}')
x = self.cbam(x)                                          #(2)
print(f'after cbam\t\t: {x.size()}')
x = x + residual
x = self.relu(x)
print(f'after summation\t\t: {x.size()}')
return x

现在我们可以通过运行下面的 Codeblock 10 来测试上面的 Block 类。你可以在下面的输出中看到,张量成功通过了整个网络,包括我们在主流程末尾附加的 CBAM 块(#(1))。

# Codeblock 10
block = Block(in_channels=512, add_channel=False, downsample=False)
x = torch.randn(1, 512, 28, 28)
out = block(x)
# Codeblock 10 Output
original             : torch.Size([1, 512, 28, 28])
no projection        : torch.Size([1, 512, 28, 28])
after conv0-bn0-relu : torch.Size([1, 256, 28, 28])
after conv1-bn1-relu : torch.Size([1, 256, 28, 28])
after conv2-bn2      : torch.Size([1, 512, 28, 28])
after cbam           : torch.Size([1, 512, 28, 28])    #(1)
after summation      : torch.Size([1, 512, 28, 28])

最终的 CBAM 化 ResNeXt

由于 CBAM 模块已经附加到主 ResNeXt 构建块上,我们只需按照 ResNeXt 论文中给出的结构堆叠这些块即可。Codeblock 11 中的 CBAMResNeXt 类实际上是从我的 SENet 文章 [2] 中直接复制粘贴过来的,因为将 CBAM 模块附加到 ResNeXt 上唯一需要做的就是修改 Codeblock 9 中的 Block 类。

# Codeblock 11
class CBAMResNeXt(nn.Module):
def __init__(self):
super().__init__()
# conv1 stage
self.resnext_conv1 = nn.Conv2d(in_channels=NUM_CHANNELS[0],
out_channels=NUM_CHANNELS[1],
kernel_size=7,
stride=2,
padding=3,
bias=False)
nn.init.kaiming_normal_(self.resnext_conv1.weight,
nonlinearity='relu')
self.resnext_bn1 = nn.BatchNorm2d(num_features=NUM_CHANNELS[1])
self.relu = nn.ReLU()
self.resnext_maxpool1 = nn.MaxPool2d(kernel_size=3,
stride=2,
padding=1)
# conv2 stage
self.resnext_conv2 = nn.ModuleList([
Block(in_channels=NUM_CHANNELS[1],
add_channel=True,
channel_multiplier=4,
downsample=False)
])
for _ in range(NUM_BLOCKS[0]-1):
self.resnext_conv2.append(Block(in_channels=NUM_CHANNELS[2]))
# conv3 stage
self.resnext_conv3 = nn.ModuleList([Block(in_channels=NUM_CHANNELS[2],
add_channel=True,
downsample=True)])
for _ in range(NUM_BLOCKS[1]-1):
self.resnext_conv3.append(Block(in_channels=NUM_CHANNELS[3]))
# conv4 stage
self.resnext_conv4 = nn.ModuleList([Block(in_channels=NUM_CHANNELS[3],
add_channel=True,
downsample=True)])
for _ in range(NUM_BLOCKS[2]-1):
self.resnext_conv4.append(Block(in_channels=NUM_CHANNELS[4]))
# conv5 stage
self.resnext_conv5 = nn.ModuleList([Block(in_channels=NUM_CHANNELS[4],
add_channel=True,
downsample=True)])
for _ in range(NUM_BLOCKS[3]-1):
self.resnext_conv5.append(Block(in_channels=NUM_CHANNELS[5]))
self.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1))
self.fc = nn.Linear(in_features=NUM_CHANNELS[5],
out_features=NUM_CLASSES)
def forward(self, x):
print(f'original\t\t: {x.size()}')
x = self.relu(self.resnext_bn1(self.resnext_conv1(x)))
print(f'after resnext_conv1\t: {x.size()}')
x = self.resnext_maxpool1(x)
print(f'after resnext_maxpool1\t: {x.size()}')
for i, block in enumerate(self.resnext_conv2):
x = block(x)
print(f'after resnext_conv2 #{i}\t: {x.size()}')
for i, block in enumerate(self.resnext_conv3):
x = block(x)
print(f'after resnext_conv3 #{i}\t: {x.size()}')
for i, block in enumerate(self.resnext_conv4):
x = block(x)
print(f'after resnext_conv4 #{i}\t: {x.size()}')
for i, block in enumerate(self.resnext_conv5):
x = block(x)
print(f'after resnext_conv5 #{i}\t: {x.size()}')
x = self.avgpool(x)
print(f'after avgpool\t\t: {x.size()}')
x = torch.flatten(x, start_dim=1)
print(f'after flatten\t\t: {x.size()}')
x = self.fc(x)
print(f'after fc\t\t: {x.size()}')
return x

现在我们可以通过运行以下测试代码来检查整个网络是否正常工作。这里我初始化一个 CBAMResNeXt 实例,并将一张大小为 224×224 的虚拟 RGB 图像传入其中。

# Codeblock 12
cbamresnext = CBAMResNeXt()
x = torch.randn(1, 3, 224, 224)
out = cbamresnext(x)

输出结果的样子。在这里我们可以看到,模型成功地将原始图像通过整个网络传递到了分类头。这基本上表明我们的 CBAM 已正确附加,因此该模型已准备好进行图像分类的训练。

# Codeblock 12 Output
original               : torch.Size([1, 3, 224, 224])
after resnext_conv1    : torch.Size([1, 64, 112, 112])
after resnext_maxpool1 : torch.Size([1, 64, 56, 56])
after resnext_conv2 #0 : torch.Size([1, 256, 56, 56])
after resnext_conv2 #1 : torch.Size([1, 256, 56, 56])
after resnext_conv2 #2 : torch.Size([1, 256, 56, 56])
after resnext_conv3 #0 : torch.Size([1, 512, 28, 28])
after resnext_conv3 #1 : torch.Size([1, 512, 28, 28])
after resnext_conv3 #2 : torch.Size([1, 512, 28, 28])
after resnext_conv3 #3 : torch.Size([1, 512, 28, 28])
after resnext_conv4 #0 : torch.Size([1, 1024, 14, 14])
after resnext_conv4 #1 : torch.Size([1, 1024, 14, 14])
after resnext_conv4 #2 : torch.Size([1, 1024, 14, 14])
after resnext_conv4 #3 : torch.Size([1, 1024, 14, 14])
after resnext_conv4 #4 : torch.Size([1, 1024, 14, 14])
after resnext_conv4 #5 : torch.Size([1, 1024, 14, 14])
after resnext_conv5 #0 : torch.Size([1, 2048, 7, 7])
after resnext_conv5 #1 : torch.Size([1, 2048, 7, 7])
after resnext_conv5 #2 : torch.Size([1, 2048, 7, 7])
after avgpool          : torch.Size([1, 2048, 1, 1])
after flatten          : torch.Size([1, 2048])
after fc               : torch.Size([1, 1000])

结语

好了,我认为关于 CBAM 以及如何从零实现它的内容差不多就是这些了。你也可以在我的 GitHub 仓库 [6] 中找到本文使用的代码。如果你在讨论或代码中发现任何错误,请告诉我。感谢阅读,希望你今天学到了新东西。我们下篇文章再见!

参考文献

[1] Sanghyun Woo et al. CBAM: Convolutional Block Attention Module. Arxiv. https://arxiv.org/abs/1807.06521 [Accessed November 12, 2025].

[2] Muhammad Ardi Putra. SENet Paper Walkthrough: The Channel-Wise Attention. Towards Data Science. https://towardsdatascience.com/the-channel-wise-attention/ [Accessed November 12, 2025]. Also available at https://medium.com/ai-advances/senet-paper-walkthrough-the-channel-wise-attention-8ac72b9cc252 .

[3] 图片由作者原创。

[4] Muhammad Ardi Putra. ResNeXt Paper Walkthrough: Taking ResNet to the Next Level. Towards Data Science. https://towardsdatascience.com/taking-resnet-to-the-next-level/ [Accessed November 12, 2025]. Also available at https://medium.com/ai-advances/taking-resnet-to-the-next-level-resnext-77088c245698 .

[5] Saining Xie et al . Aggregated Residual Transformations for Deep Neural Networks. Arxiv. https://arxiv.org/abs/1611.05431 [Accessed November 12, 2025].

[6] MuhammadArdiPutra. CBAM. GitHub. https://github.com/MuhammadArdiPutra/medium_articles/blob/main/Deep%20Learning%20From%20Scratch/CBAM.ipynb [Accessed November 12, 2025].


转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:Towards Data Science

原创地址:https://towardsdatascience.com/cbam-paper-walkthrough-the-double-attention-mechanism/

原文作者:Muhammad Ardi Putra;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-11-21

  • 博文量
    1090
  • 访问量
    546860