☰
ResNet注意力模块改造:SE、CBAM、ECA插入位置与工程实践
2026/10/26 10:32:39 网站建设 项目流程

简介:ResNet_Attention 是一份面向深度学习实践者与计算机视觉入门进阶者的注意力机制实战代码资源,聚焦在 ResNet 主干网络上集成 SE 与 CBAM 两类注意力模块,帮助读者解决「如何在经典残差网络中插入通道/空间注意力并完成可复现训练」的问题,适用于图像分类实验、课程设计及论文复现前的对照验证。压缩包共 16 个文件,约 15KB,以 5 个 Python 脚本为核心(模型定义、SE 模块、CBAM 模块、CIFAR10 单卡与多卡训练入口),辅以 8 个 XML 项目配置与 md 说明文档,IDE 配置齐全,解压后可直接接管工程。资源提供了明确的运行方式,通过命令行参数切换 --att_type se 或 cbam,配合 epoch、device 等选项即可在 CIFAR10 上训练,并给出 ResNet50 训练 160 轮后 ACC@1 达 93.41% 的参考结果,便于读者校验环境与调参效果。该资源已有 13556 人学习,代码体量小、结构清晰,适合作为注意力机制入门与二次开发的起点。

1. 加了注意力反而掉点?先看这份 ResNet_Attention 改的是哪几层

ResNet 上加注意力,最容易踩的坑不是公式写错,而是加完以后验证集精度原地踏步,运气差还会比不加低两个点。这份 ResNet_Attention 资源就是干这件事的:一套 Python 写的注意力模块集合(SE、CBAM、ECA),加上把它们插进 ResNet 的 BasicBlock 与 Bottleneck 的改造代码,18/34/50/101 四种深度用工厂函数一行拼出来,预训练权重的 key 命名和官方实现保持对齐,训练脚本基本不用重写。它适合已经跑通分类基线、想在 backbone 上动结构的人;如果训练循环还没搭稳,先把基线跑通再来动结构,否则你分不清是注意力没用还是别的地方漏了。

2. 选型:SE、CBAM、ECA 在残差分支里的三个插入位置与算力账

动手改结构之前,得先确定两件事:模块插在残差块的哪个位置,以及选哪个模块。这两件事选错,后面调参调到天亮也没用。

2.1 为什么插在残差相加之前

一个残差块的 forward 可以拆成三段:主分支卷积、注意力加权、与 identity 相加后过 ReLU。注意力有三个候选位置。

第一是插在最后一个 BN 之后、相加之前,也就是out = attn(bn2(conv2(out))),然后relu(out + identity)。这是最主流的位置,好处是注意力只改主分支,identity 通路保持干净,梯度回传时短路连接不被污染,预训练权重也能原样搬过来。

第二是插在相加和 ReLU 之后,相当于对整个块的输出做重标定。这种写法在浅层问题不大,但在深层会让注意力直接作用在已经过 ReLU 的特征上,负值信息全丢,通道重要性估计会偏。

第三是插在 stem 之后或者每个 stage 的入口,属于「全局一次」的粗粒度做法,参数量最省,但层内冗余没有被处理。

我一般固定用第一种。原因是它和 SE-ResNet 的原始做法一致,社区里能对得上的实现最多,出问题好排查;另外相加前插入时,注意力的输出尺度直接决定残差分支的方差,配合 block 里最后一个 BN 的零初始化,可以让整个块在训练初期近似恒等映射,这一点在 5.1 里还会展开。

2.2 SE、CBAM、ECA 的算力账

三个模块的差别不在「谁更强」,而在「拿多少参数换多少感受野」。

模块建模维度额外参数(通道数 C,压缩比 r)额外算力更适合的场景
SE纯通道约 2C²/r可忽略,只有全局池化和两次 1x1通道冗余明显、数据量中等以上的分类
CBAM通道 + 空间约 2C²/r + 2k²与特征图面积成正比,空间分支在大特征图上明显变慢目标位置重要、小目标、检测分割下游
ECA局部跨通道只有 k 个(k 由 C 推出,通常 3~9)可忽略,一个 1D 卷积数据量小、怕过拟合、端侧部署

从表里能看出关键差异:SE 和 CBAM 的通道分支参数量是通道数的平方级,在 2048 通道的 layer4 上会突然变重;ECA 用一个长度为 k 的 1D 卷积替代全连接,参数量几乎可以当作 0,代价是它只能看局部通道邻域,通道间的长程依赖建模弱一些。

CBAM 的空间分支要单独说。它把通道维的均值和最大值拼成两通道,过一次 7x7 卷积得到一张单通道权重图。这张图和特征图同尺寸,所以在 56x56 的 layer2 上,7x7 卷积的开销是 56×56×2×49,而到了 7x7 的 layer4 就几乎白送。这就是为什么很多实现只在 layer3、layer4 挂 CBAM——不是后面更重要,是前面太贵。

2.3 压缩比 r 和 ECA 的核长 k 怎么定

SE 和 CBAM 的通道分支有个瓶颈层,通道数从 C 压到 C/r 再升回 C。r 取 16 是常见默认值,但它不该无脑套。

先把额外参数量算出来看,比凭感觉调靠谱:

import math def attn_params(name, c, r=16, k=7): """估算单层插入注意力后的额外参数量(忽略 bias)""" hidden = max(c // r, 8) # 瓶颈至少留 8 个通道,防止浅层被压成 0 if name == "se": return 2 * c * hidden if name == "cbam": return 2 * c * hidden + 2 * k * k # 通道分支 + 空间分支 2->1 的 7x7 卷积 if name == "eca": t = int(abs((math.log2(c) + 1) / 2)) return t if t % 2 else t + 1 # ECA 的核长取奇数 return 0 # ResNet50 各 stage 的输出通道 for c in (256, 512, 1024, 2048): row = {n: attn_params(n, c) for n in ("se", "cbam", "eca")} print(c, row)

跑出来会看到,c=2048、r=16 时 SE 单层就是 52 万参数左右,一个 stage 六个 block 加起来三百万,已经接近整个 ResNet50 的四分之一。如果 r 改成 8,这个数字直接翻倍。

参数说明和取值建议:

  • r=16是通用起点,通道数小于 256 的层我一般把瓶颈下限卡在 8,否则 64 通道的 layer1 会被压到 4,信息损失明显。
  • 数据量偏小(万级以下)时把 r 提到 32,或者干脆换 ECA。小数据上通道注意力参数太多,很容易在训练后期过拟合到训练集的通道分布上。
  • ECA 的 k 不要手填。它的设计出发点就是让核长随通道数自适应,C=512 时大概 5,C=2048 时大概 9,用公式推就行。我见过有人把 k 固定成 3,结果在深层通道数上千时,等于只做了相邻三个通道的加权,效果和没加差不多。

2.4 什么情况下不该加注意力

有三种情况,我踩过或者看别人踩过,加之前先自问一遍。

一是主干本身在目标数据上还没跑满。分类基线如果连预训练权重都没加载、只训了二三十个 epoch,加上注意力后精度波动会完全掩盖模块本身的效果,这时候做任何对比都是噪音。

二是任务对推理延迟极其敏感,且瓶颈在高分辨率特征图上。CBAM 的空间分支在 224 输入、layer2 分辨率下的开销能占到整个前向的 10% 以上,这种场景宁可只在 layer4 加 SE。

三是下游任务的特征已经很强。检测里如果 neck 已经做了充分的多尺度融合,backbone 末端再加一个通道注意力,收益往往被neck吃掉,涨点会非常不明显。

3. 把注意力焊进 BasicBlock 与 Bottleneck:模块实现与工厂函数

3.1 目录与依赖

这套代码结构很扁平,一个模块一个文件,方便单独抠出来塞进现有工程:

pip install torch torchvision # 目录约定 # resnet_attn/ # __init__.py # attention.py # SE / CBAM / ECA 三个模块 # resnet.py # BasicBlock / Bottleneck / ResNetAttn / 工厂函数 # check.py # 参数量与输出 shape 自检

依赖只有 PyTorch 和 torchvision。torch.amax需要 1.7 以上,torch.cuda.amp需要 1.6 以上,两个条件现在的环境基本都满足。

3.2 三个注意力模块的实现

先写模块。三个类都保持同一个约定:输入输出 shape 完全一致,内部做乘性加权,不改变通道数和分辨率,这样插到任何位置都不会破坏后面的 shape 推导。

import math import torch import torch.nn as nn class SEBlock(nn.Module): """通道注意力:全局池化 -> 降维 -> 升维 -> sigmoid -> 逐通道缩放""" def __init__(self, channels, reduction=16): super().__init__() hidden = max(channels // reduction, 8) # 浅层通道少时别把瓶颈压没 self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, hidden, bias=False), nn.ReLU(inplace=True), nn.Linear(hidden, channels, bias=False), nn.Sigmoid(), ) def forward(self, x): b, c, _, _ = x.shape w = self.pool(x).view(b, c) # (B, C) w = self.fc(w).view(b, c, 1, 1) # (B, C, 1, 1) return x * w # 广播,返回新张量,不改输入 class ChannelAttention(nn.Module): """CBAM 的通道分支:均值池化和最大值池化共享同一组 1x1 卷积""" def __init__(self, channels, reduction=16): super().__init__() hidden = max(channels // reduction, 8) self.mlp = nn.Sequential( nn.Conv2d(channels, hidden, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(hidden, channels, 1, bias=False), ) self.sigmoid = nn.Sigmoid() def forward(self, x): avg = self.mlp(torch.mean(x, dim=(2, 3), keepdim=True)) mx = self.mlp(torch.amax(x, dim=(2, 3), keepdim=True)) return x * self.sigmoid(avg + mx) class SpatialAttention(nn.Module): """CBAM 的空间分支:通道维均值+最大值拼成两通道,7x7 卷积出权重图""" def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): avg = torch.mean(x, dim=1, keepdim=True) # (B,1,H,W) mx = torch.amax(x, dim=1, keepdim=True) w = self.sigmoid(self.conv(torch.cat([avg, mx], dim=1))) return x * w class CBAM(nn.Module): def __init__(self, channels, reduction=16, kernel_size=7): super().__init__() self.ca = ChannelAttention(channels, reduction) self.sa = SpatialAttention(kernel_size) def forward(self, x): return self.sa(self.ca(x)) # 先通道后空间,顺序反过来也能跑但收敛更慢 class ECAAttention(nn.Module): """极轻量通道注意力:全局池化后用 1D 卷积做局部跨通道交互""" def __init__(self, channels, gamma=2, b=1): super().__init__() t = int(abs((math.log2(channels) + b) / gamma)) k = t if t % 2 else t + 1 # 核长必须是奇数,保证 padding 后长度不变 self.conv = nn.Conv1d(1, 1, kernel_size=k, padding=k // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): y = torch.mean(x, dim=(2, 3), keepdim=True) # (B,C,1,1) y = y.squeeze(-1).transpose(-1, -2) # (B,1,C) y = self.conv(y).transpose(-1, -2).unsqueeze(-1) # (B,C,1,1) return x * self.sigmoid(y)

几处实现细节值得说:

SEBlock里用nn.Linear而不是 1x1 卷积,是因为池化后已经是 1x1 空间,全连接更省事,也方便后面注册 hook 观察 sigmoid 输出。如果要用在全卷积、需要支持任意输入尺寸的场景,换成nn.Conv2d等价。

ChannelAttention里的两个池化共享同一个self.mlp,这是 CBAM 的原意,不共享的话参数翻倍、效果也没更好。

ECAAttention中间那次squeeze(-1).transpose(-1, -2)是把(B,C,1,1)变成(B,1,C)喂给 Conv1d,做完再转回来。这个维度变换写错的话,Conv1d 会在批维上做卷积,loss 会非常怪地不收敛,是个典型的静默错误。

3.3 改造 BasicBlock 与 Bottleneck

ResNet 有两个块类型:18/34 用 BasicBlock(两层 3x3),50 及以上用 Bottleneck(1x1 降维、3x3、1x1 升维,通道数扩 4 倍)。注意力要插在最后一个 BN 之后、残差相加之前,所以两个块都得改一份。

def build_attn(name, channels, reduction=16): """统一入口,返回 None 表示不加注意力""" name = (name or "none").lower() if name == "none": return None if name == "se": return SEBlock(channels, reduction) if name == "cbam": return CBAM(channels, reduction) if name == "eca": return ECAAttention(channels) raise ValueError(f"unknown attn type: {name}") class AttnBasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None, attn="se", reduction=16): super().__init__() self.conv1 = nn.Conv2d(inplanes, planes, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.relu = nn.ReLU(inplace=True) self.downsample = downsample self.attn = build_attn(attn, planes * self.expansion, reduction) self.last_bn = self.bn2 # 供初始化时零初始化用 def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.attn is not None: out = self.attn(out) # 只作用于主分支,identity 保持原样 if self.downsample is not None: identity = self.downsample(x) return self.relu(out + identity) class AttnBottleneck(nn.Module): expansion = 4 def __init__(self, inplanes, planes, stride=1, downsample=None, attn="se", reduction=16): super().__init__() width = planes self.conv1 = nn.Conv2d(inplanes, width, 1, bias=False) self.bn1 = nn.BatchNorm2d(width) self.conv2 = nn.Conv2d(width, width, 3, stride, 1, bias=False) self.bn2 = nn.BatchNorm2d(width) self.conv3 = nn.Conv2d(width, width * self.expansion, 1, bias=False) self.bn3 = nn.BatchNorm2d(width * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample # 注意通道数是 width * 4,不是 width self.attn = build_attn(attn, width * self.expansion, reduction) self.last_bn = self.bn3 def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.relu(self.bn2(self.conv2(out))) out = self.bn3(self.conv3(out)) if self.attn is not None: out = self.attn(out) if self.downsample is not None: identity = self.downsample(x) return self.relu(out + identity)

AttnBottleneck里最容易错的是build_attn传的通道数。conv3 输出的是width * 4,如果顺手写成width,SE 的 fc 会按 512 建,实际输入 2048,forward 时直接报 shape 不匹配。这个错误很显眼,反倒不容易漏;真正阴的是把 CBAM 的 spatial 分支建对了、通道分支建错了,只在特定深度炸。

last_bn这个属性不是必需的,但加上以后初始化阶段可以统一处理,不用在外部写 isinstance 判断 bn2 还是 bn3。

3.4 工厂函数拼出四种深度

CFG = { 18: (AttnBasicBlock, [2, 2, 2, 2]), 34: (AttnBasicBlock, [3, 4, 6, 3]), 50: (AttnBottleneck, [3, 4, 6, 3]), 101: (AttnBottleneck, [3, 4, 23, 3]), } class ResNetAttn(nn.Module): def __init__(self, block, layers, num_classes=1000, attn="se", reduction=16): super().__init__() self.inplanes = 64 self.attn_name = attn self.reduction = reduction self.conv1 = nn.Conv2d(3, 64, 7, 2, 3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(3, 2, 1) self.layer1 = self._make_layer(block, 64, layers[0], stride=1) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(512 * block.expansion, num_classes) self._init_weights(block) def _make_layer(self, block, planes, blocks, stride=1): downsample = None if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, 1, stride, bias=False), nn.BatchNorm2d(planes * block.expansion), ) layers = [block(self.inplanes, planes, stride, downsample, self.attn_name, self.reduction)] self.inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes, 1, None, self.attn_name, self.reduction)) return nn.Sequential(*layers) def _init_weights(self, block): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0) # 关键一步:每个 block 的最后一个 BN 零初始化,残差分支初始为恒等 for m in self.modules(): if isinstance(m, block): nn.init.constant_(m.last_bn.weight, 0) def forward(self, x): x = self.maxpool(self.relu(self.bn1(self.conv1(x)))) x = self.layer4(self.layer3(self.layer2(self.layer1(x)))) x = self.avgpool(x).flatten(1) return self.fc(x) def resnet_attn(depth=50, attn="se", num_classes=1000, reduction=16): block, layers = CFG[depth] return ResNetAttn(block, layers, num_classes, attn, reduction)

关于last_bn零初始化,值得多说两句。ResNet 的原始做法是把每个 block 最后一个 BN 的缩放系数置零,这样训练开始时残差分支输出为 0,整个块等于恒等映射,深网络也能稳定起步。加上注意力以后,主分支多了一层乘性缩放,如果这层不初始化、直接按默认 kaiming 来,残差分支的方差会比原来大,深层会明显抖。用上面这套初始化,注意力在训练初期近似于「以 0.5 左右的门控缩放一个接近 0 的分支」,影响被压到最小,后面再慢慢学。

3.5 三条自检:参数量、输出 shape、state_dict key

改完结构别急着开训,先跑一个不依赖数据的自检脚本,三十秒能省半天。

import torch def check(depth, attn, num_classes=10): model = resnet_attn(depth, attn, num_classes).eval() x = torch.randn(2, 3, 224, 224) with torch.no_grad(): y = model(x) n = sum(p.numel() for p in model.parameters()) attn_keys = [k for k in model.state_dict() if ".attn." in k] print(f"resnet{depth}-{attn:4s} out={tuple(y.shape)} " f"params={n/1e6:.2f}M attn_tensors={len(attn_keys)}") return attn_keys[:2] for d in (18, 50): for a in ("none", "se", "cbam", "eca"): check(d, a) print("sample keys:", check(50, "se"))

判断标准有三条。输出 shape 必须是(2, num_classes),不符合说明avgpool到fc之间漏了 flatten。参数量上,resnet50-se 应该比 resnet50 多出三百万出头,如果只多几千,基本可以确定注意力只挂在了某个 stage 或者通道数传错被压成了瓶颈宽度。attn_tensors的数量:SE 每层 2 个(两个 Linear 的 weight),CBAM 每层 5 个,ECA 每层 1 个,用这个数反推每个 stage 是不是都挂上了,比数代码靠谱。

4. 训练链路:预训练权重对齐、分组学习率与 AMP

4.1 预训练权重的 key 对齐

这是整套流程里最影响结果的一步。加了注意力以后,load_state_dict(strict=True)必然报错,很多人顺手改成strict=False就当加载成功了,结果主干有一半是随机初始化的,训出来比从头训还差。

能这样直接加载,前提是模块的命名和官方实现保持一致:conv1、bn1、layer1.0.conv2、downsample.0这些属性名一个都不能改。只要名字对上,strict=False之后 missing_keys 里应该恰好只有.attn.开头的张量和fc。

def load_backbone(model, weights_path, verbose=True): ckpt = torch.load(weights_path, map_location="cpu") state = ckpt.get("state_dict", ckpt.get("model", ckpt)) # 去掉多卡训练留下的包装前缀 state = {k[7:] if k.startswith("module.") else k: v for k, v in state.items()} # 类别数不同,分类头直接丢掉 state = {k: v for k, v in state.items() if not k.startswith("fc.")} msg = model.load_state_dict(state, strict=False) attn_missing = [k for k in msg.missing_keys if ".attn." in k] other_missing = [k for k in msg.missing_keys if ".attn." not in k] if verbose: print(f"注意力新增张量 {len(attn_missing)} 个") print(f"非预期缺失 {len(other_missing)} 个") if other_missing: print("前 8 个:", other_missing[:8]) if msg.unexpected_keys: print("多余 key 前 8 个:", msg.unexpected_keys[:8]) # 非预期缺失超过阈值直接中止,别带着半个随机主干去训 assert len(other_missing) <= 3, "主干权重没对齐,先修 key 再训练" return model

参数和逻辑说明:

ckpt.get("state_dict", ckpt.get("model", ckpt))是为了兼容三种常见保存格式,直接存 state_dict、包一层state_dict、包一层model。三种都会遇到,尤其接手别人的 checkpoint 时。

strict=False只用来跳过注意力新增的张量,不能用来兜底别的问题,所以后面那个 assert 很重要。正常情况下,除了 attention 相关的,可能就剩num_batches_tracked这类不影响数值的键;一旦出现layer2.0.conv1.weight这种,说明主干根本没加载上,必须停下来修。

如果num_classes恰好也是 1000,可以保留fc不丢,这样省一次重新初始化。类别数不同时丢掉fc,让它走默认初始化,效果比强行加载再覆盖好。

4.2 分组学习率与 warmup

新加的注意力模块是随机初始化的,主干是预训练的,两者放同一个学习率不合理。主干需要小的学习率做微调,注意力需要大一点快速收敛。按 name 分三组是常用做法。

def build_optimizer(model, base_lr=0.02, attn_mult=5.0, weight_decay=5e-5): attn_params, decay, no_decay = [], [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if ".attn." in name: attn_params.append(p) # 新模块单独一组 elif p.ndim <= 1: no_decay.append(p) # BN 的 weight/bias、所有 bias else: decay.append(p) groups = [ {"params": decay, "lr": base_lr, "weight_decay": weight_decay}, {" <p> <a href="https://download.csdn.net/download/weixin_42165712/15766833" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询