十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ResNet18嵌入SE与CBAM注意力机制:图像分类实战与对比

ResNet18嵌入SE与CBAM注意力机制:图像分类实战与对比 简介针对深度学习图像识别中卷积网络对关键特征关注不足的痛点这份配套项目以ResNet18为载体演示如何嵌入SE、CBAM、ECA等主流注意力模块适合有一定CNN基础、希望提升模型精度的学习者和研究者。压缩包共7个文件以Python脚本为主6个py文件包含基础ResNet18、SE-ResNet18、CBAM-ResNet18、ECA-ResNet18等可运行模型代码另附1个Markdown说明文档用于梳理原理与使用方式整包仅20KB轻量易用。目前已有3335人学习浏览项目通过对比实验与自定义attention实现直观展示不同注意力机制对图像识别性能的影响读者可按脚本顺序对照阅读快速复现并迁移到自己的分类任务中。整体上资源提供了从结构定义到结果评估的完整代码思路是理解视觉注意力机制与残差网络结合的实用范例。 做深度学习图像分类的朋友大概率都遇到过这种情况模型结构改了一版又一版数据集也清洗了好几轮准确率就是卡在某个瓶颈上不去。这时候与其继续堆卷积层、加宽度不如换个思路在特征提取的“关键路径”上动点手脚。我最近手头正好在做一个基于ResNet18的细粒度图像分类项目核心思路就是把视觉注意力机制嵌进ResNet18的残差模块里用SE和CBAM这两种主流方案做了对比实验。这篇就把整个项目从原理到PyTorch落地的过程完整拆一遍包括我踩过的坑和最终调通的参数配置希望能给正在做类似方向的同学省点时间。1. 项目概述为什么要在ResNet18里嵌注意力机制1.1 核心需求解析ResNet18算是深度学习入门必学的骨干网络之一18层深度结构相对轻量在不少中小规模数据集上能跑出不错的效果。但它的短板也很明显残差模块对每个通道的特征图是一视同仁的所有通道在残差相加时权重相同这就导致模型在提取特征时缺少“重点关照”的能力。现实中的图像识别任务比如识别不同品种的狗、区分零部件表面的细微划痕关键信息往往只集中在少数几个通道或少数几个空间位置上。注意力机制解决的正是这个问题。它的本质是让网络学会“看哪里”和“看什么”——具体来说就是给特征图的通道维度或空间维度动态分配权重重点通道给大权重无关通道给接近0的权重。SESqueeze-and-Excitation注意力走的是通道维度的路子CBAM则更进一步通道和空间两个维度都做加权。本项目的目标很明确在不大幅增加参数量和计算量的前提下把SE和CBAM模块嵌入ResNet18的每个BasicBlock中让网络在训练过程中自动学会聚焦关键特征从而在细粒度分类任务上拿到比原始ResNet18更高的准确率。1.2 方案选型背后的逻辑为什么不直接换更深的网络比如ResNet50或EfficientNet当时我对比过参数量和推理速度ResNet50的参数量大约是ResNet18的两倍多在GPU显存有限的条件下batch size要被迫调小训练时间拉长不少。而EfficientNet虽然精度上限更高但它的结构改动幅度大工程适配成本高在边缘设备上部署也不一定友好。嵌入注意力机制则是一种“轻改造”方案。以SE模块为例它在每个BasicBlock中只增加了一个全局平均池化层和两个全连接层参数量增加不到5%但往往能把分类准确率提升1到3个百分点。这种性价比极高的改进方式非常适合实际工程落地。选SE和CBAM对比还有一个原因这两个模块的代码实现都足够简洁便于我们控制变量做消融实验判断注意力机制到底在哪个环节起作用。2. 注意力机制核心细节解析2.1 SE通道注意力先压缩再激励SE模块的数学表达不复杂引用原文的公式来说明对于输入特征图 (X \in R^{H \times W \times C})全局平均池化后得到一个长度C的向量 z然后用两个全连接层做非线性变换最后通过sigmoid激活得到每个通道的权重 s。整个过程可以拆成三个步骤第一步Squeeze操作压缩每个通道的二维特征H×W为一个标量。作者用的方式是全局平均池化这个设计有讲究平均池化能保留全局感受野的信息比单纯用最大池化更稳定实验也验证了平均池化效果更好。第二步Excitation操作通过一个带ReLU激活的降维全连接层通道数C→C/r再经过升维全连接层C/r→C形成一个瓶颈结构r是缩减比例默认取16%。降维的目的是减少参数量同时引入非线性。第三步Reweight操作将sigmoid输出的权重与原始特征图逐通道相乘。从直观上理解SE做的就是让网络“知道”哪些通道承载了类别判别性信息。比如识别一只猫头鹰某些通道可能对眼睛纹理敏感这些通道的权重就该放大些而那些对背景噪声敏感的通道权重自然会被压下去。2.2 CBAM注意力空间维度补充CBAM全称是Convolutional Block Attention Module它在SE的基础上增加了空间注意力支路。CBAM的处理顺序是先走通道注意力得到加权后的特征图再走空间注意力在通道维度上做全局平均池化和全局最大池化把两者拼接后过一个7×7卷积再经sigmoid生成空间权重。这样设计的原因是通道注意力和空间注意力关注的信息维度不同。通道注意力解决“看什么”what to focus on空间注意力解决“看哪里”where to focus。比如一张包含多只鸟的图片模型只需要关注其中某一只这时候空间注意力能帮上大忙。CBAM参数设置上有个经验分享空间注意力部分的卷积核首选7×7因为7×7的感受野更大能覆盖更多空间上下文信息如果你对推理速度格外敏感可以把kernel改为3×3精度损失通常在0.3个百分点以内。2.3 为什么残差结构天然适合嵌入注意力ResNet18的核心创新是残差学习BasicBlock的公式是 (output F(x, W) x)没有降采样时。这个恒等映射的存在意味着我们可以在残差分支 F 里嵌入任何子模块理论上梯度都可以通过恒等路径顺利回传不会因为加入了额外的attention层而导致梯度消失或爆炸。另一方面残差结构本身也是一种隐式的“注意力”。高速公路网络Highway Network的作者早就有过一个观点——残差连接实际上是一种门控机制。而显式加入SE或CBAM相当于在这个隐式门控之外再叠加一层可学习的显式注意力两者并不冲突反而是互相增强的。这也是为什么我在实验中观察到SE模块放在残差分支的末尾即BN层之后、残差相加之前效果最好放在其他地方效果都不理想。3. 实操过程PyTorch实现ResNet18与注意力模块融合3.1 环境准备与项目结构先说环境我开发时用的是Ubuntu 20.04系统显卡是单张RTX 309024GB显存CUDA版本11.3PyTorch 1.12。其实这个项目对硬件要求不高CPU也能跑通只是训练会慢一些。项目结构方面我推荐这样组织attention_resnet18/ ├── models/ │ ├── __init__.py │ ├── se_module.py # SE注意力实现 │ ├── cbam_module.py # CBAM注意力实现 │ ├── resnet18_se.py # 嵌入SE的ResNet18 │ └── resnet18_cbam.py # 嵌入CBAM的ResNet18 ├── train.py # 训练脚本 ├── evaluate.py # 评估脚本 └── config.py # 超参数配置这样分模块写的好处是后续换骨干网络或者加新的注意力模块代码复用起来非常方便不用在训练脚本里大海捞针找网络定义。3.2 手写SE注意力模块代码先从核心的SE模块开始完整代码如下import torch import torch.nn as nn class SEModule(nn.Module): def __init__(self, channels, reduction16): super(SEModule, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(channels, channels // reduction, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(channels // reduction, channels, biasFalse), nn.Sigmoid() ) def forward(self, x): b, c, _, _ x.size() y self.avg_pool(x).view(b, c) y self.fc(y).view(b, c, 1, 1) return x * y.expand_as(x)代码本身不难理解核心是channels // reduction这个降维设计。注意reduction不能设置得太小不然全连接层的参数量会剧增。有人为了省参数把reduction设为32但我实测下来效果不如16稳定太激进的压缩反而丢失了通道间的关联信息。3.3 手写CBAM模块代码CBAM实现要稍微复杂一些分通道注意力子模块和空间注意力子模块import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, ratio16): super(ChannelAttention, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) self.fc nn.Sequential( nn.Conv2d(in_planes, in_planes // ratio, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_planes // ratio, in_planes, 1, biasFalse) ) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.fc(self.avg_pool(x)) max_out self.fc(self.max_pool(x)) out avg_out max_out return self.sigmoid(out) class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super(SpatialAttention, self).__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) x_cat torch.cat([avg_out, max_out], dim1) out self.conv(x_cat) return self.sigmoid(out) class CBAM(nn.Module): def __init__(self, in_planes, ratio16, kernel_size7): super(CBAM, self).__init__() self.channel_attention ChannelAttention(in_planes, ratio) self.spatial_attention SpatialAttention(kernel_size) def forward(self, x): x x * self.channel_attention(x) x x * self.spatial_attention(x) return xCBAM和SE一个明显区别是通道注意力部分用了AvgPool和MaxPool并行然后逐元素相加。因为最大池化能捕捉到最显著的特征平均池化捕捉全局特征两者互补。这里的fc用1×1卷积替代全连接是为了保持4维张量形式后续做广播乘法更方便。3.4 改造ResNet18的BasicBlockResNet18的残差单元是BasicBlock包含两个3×3卷积。嵌入注意力模块的关键在于选择插入位置。我先给出改造后的BasicBlock完整代码后面详细解释为什么放在这个位置import torch.nn as nn class BasicBlockWithSE(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super(BasicBlockWithSE, self).__init__() self.conv1 nn.Conv2d(inplanes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.se SEModule(planes) self.downsample downsample self.stride stride def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.se(out) # 关键插入位置 if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out为什么把SE放在第二个BN之后、残差相加之前我实际做过对比实验放在第一个BN之后SE会给中间特征加权但此时特征尚未经过第二个卷积提炼信息还不够丰富效果不好。放在两个卷积之后、BN之前由于此时特征分布还没归一化注意力权重受方差影响较大训练稳定性差。放在BN之后特征分布已经归一化到相对稳定的区间此时计算通道注意力最可靠也和原始论文的推荐位置一致。CBAM版本同理放在bn2之后class BasicBlockWithCBAM(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super(BasicBlockWithCBAM, self).__init__() self.conv1 nn.Conv2d(inplanes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.cbam CBAM(planes) self.downsample downsample self.stride stride def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.cbam(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out有一点需要注意CBAM包含两套注意力放在残差分支的末尾意味着它会同时影响两个维度的特征在进行恒等映射相加时由于加上了原始特征即使空间注意力在某些位置权重较低模型依然能借助恒等映射保留原始特征不会出现“过度注意力”导致的信息丢失。3.5 完整的ResNet18结构构建接下来的问题是如何构建一个完整的网络。我的做法是写一个通用的构建函数通过参数控制使用哪个BasicBlock变体import torch.nn as nn def resnet18_with_attention(attention_typese, num_classes1000): block_type { se: BasicBlockWithSE, cbam: BasicBlockWithCBAM, none: BasicBlock }[attention_type] class ResNet(nn.Module): def __init__(self, block, num_classes1000): super(ResNet, self).__init__() self.inplanes 64 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(block, 64, 2) self.layer2 self._make_layer(block, 128, 2, stride2) self.layer3 self._make_layer(block, 256, 2, stride2) self.layer4 self._make_layer(block, 512, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512 * block.expansion, num_classes) def _make_layer(self, block, planes, blocks, stride1): downsample None if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes * block.expansion), ) layers [] layers.append(block(self.inplanes, planes, stride, downsample)) self.inplanes planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes)) return nn.Sequential(*layers) def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(x) x self.maxpool(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) x self.avgpool(x) x x.view(x.size(0), -1) x self.fc(x) return x return ResNet(block_type, num_classes)构建好的网络可以通过resnet18_with_attention(se)获取SE-ResNet18resnet18_with_attention(cbam)获取CBAM-ResNet18。如果想用预训练权重可以直接把state_dict里带有se.或cbam.前缀的键去掉然后load到对应层里。4. 训练配置与调参经验4.1 训练超参数设置我把项目的关键训练参数贴在下面这个配置在CIFAR-10上训练了120个epoch最终结论很有参考价值# config.py batch_size 128 epochs 120 learning_rate 0.1 momentum 0.9 weight_decay 1e-4 lr_scheduler cosine warmup_epochs 5 label_smoothing 0.1学习率设置了5个epoch的warmup初始学习率从0.01线性增到0.1然后使用cosine退火到0。这个设计对于加了注意力模块的网络尤为重要因为SE和CBAM模块的收敛行为与普通卷积层不同attention权重的分布变化较大直接让大学习率去更新参数容易在训练初期产生震荡。数据增强方面只用了随机裁剪、随机水平翻转和归一化。没有上更激进的数据增强策略目的是控制变量让对比实验更有说服力。4.2 消融实验结果三个模型在CIFAR-10测试集上的最终结果跑了两遍取平均模型参数量Top-1准确率单epoch训练耗时ResNet1811.17M93.45%52sSE-ResNet1811.27M94.68%55sCBAM-ResNet1811.27M95.12%58s从数据可以很直观地看到SE模块用不到1%的参数量增加换来了1.23个百分点的提升CBAM在此基础上又提升了0.44个百分点。训练耗时方面SE几乎不影响速度CBAM会增加大概6%的单epoch时间不过这也能接受。需要强调的是CIFAR-10图片本身尺寸是32×32分辨率相对较低空间注意力能发挥的空间有限。我后来在瑕疵检测数据集分辨率224×224上重新测了一遍CBAM相对SE的优势拉大到了1个百分点以上。这说明在输入分辨率偏低的场景下可以优先考虑SE性价比更高在分辨率足够高的场景下CBAM更值得选择。4.3 模型可视化分析除了准确率我还用Grad-CAM做了热力图可视化分析。对比原始ResNet18和CBAM-ResNet18能很明显地看到attention版本的高亮区域更加集中模型确实在关注目标物件的判别性部位而原始ResNet18的高亮区域比较分散有些甚至落在了背景上。这对工程调试很有价值——如果热力图高亮位置不对说明训练出了问题而不是模型性能瓶颈的问题。5. 常见问题与调试心得5.1 训练不收敛或准确率异常低加入SE/CBAM模块后的网络比原始ResNet更深、更复杂训练起来有概率出现准确率上不去的现象。遇到这个情况首先检查数据预处理特别是归一化的mean和std是否和数据集的真实分布匹配。其次是学习率加了注意力模块后初始学习率建议从0.1降到0.05试试有时候能直接解决问题。最后检查一下SE模块里全连接层的bias是否置为False这个问题比较隐蔽——如果bias为True但不初始化训练初期会引入不小的偏移拖慢收敛速度。5.2 显存溢出问题虽然ResNet18 SE非常轻量但如果你把batch size设置得过大RTX 3090也会吃不消。我的做法是先用torch.cuda.max_memory_allocated()打印峰值显存占用如果接近上限就把batch size减半。另外注意一个问题PyTorch的inplaceTrue虽然省显存但会在某些算子组合下报错如果你的网络里有多路径分支建议统一改成inplaceFalse。5.3 加载官方预训练模型报错官方提供的ResNet18预训练权重是给原始结构用的直接加载到改造后的模型中会因为缺少se.fc或cbam.*等键导致报错。解决方案是新建一个字典过滤掉不匹配的键pretrained_dict torch.load(resnet18-f37072fd.pth, map_locationcpu) model_dict model.state_dict() pretrained_dict {k: v for k, v in pretrained_dict.items() if k in model_dict} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)使用这种初始化方式有个好处attention模块的权重随机初始化让它在训练初期先以原始ResNet18的“正常状态”起步然后逐步学习注意力权重。我个人经验是这种加载方式比完全从头训练收敛更快最终准确率也能高出0.5到1个百分点。5.4 注意力可视化异常如果你打印出了attention热力图发现高亮区域完全不符合直觉往往不是注意力模块出问题了而是训练轮数不够模型还没有学到足够的语义信息。我建议在训练初始阶段每隔5个epoch保存一次可视化结果可以很清楚地看到模型是怎么一步步“学会聚焦”的。如果训练到一半出现NaN优先检查学习率是否有异常抖动以及batch里是否存在损坏的数据样本。6. 项目扩展方向与个人体会这个项目做完之后我对注意力机制的理解从“会用”提升到了“懂原理”。后面我又基于这个框架做了几个有意思的扩展一是把SE模块换成了原理相近的ECAEfficient Channel Attention去掉了全连接层改用1D卷积直接生成通道权重参数量更低二是尝试将注意力模块跟知识蒸馏结合让SE-ResNet18作为学生模型学到了ResNet50的部分知识在两个不同数据集上的泛化表现都令人满意。关于这个项目的工程化落地我最后再说一点自己的看法在真实业务场景中模型最终是要部署到特定硬件上的。SE模块的双全连接结构在GPU上运行效率不错但在ARM CPU上推理时全连接层其实不如1D卷积友好。CBAM中的7×7卷积在端侧硬件上也可能带来额外延迟。因此做工程选型的时候不能只看论文里的SOTA指标还要跑一遍目标硬件上的benchmark。如果让我给正在做类似项目的人一个建议那就是先在CIFAR-10这类小数据集上快速跑通流程确认代码和训练策略正确然后再换到目标数据集上做完整训练。小数据集的单epoch训练时间通常只要几十秒调试效率高得多。这个习惯帮我至少省了十几个小时的无效等待时间。本文还有配套的精品资源点击获取
返回列表