ResNet+CBAM实战:注意力机制提升细粒度分类准确率
发布时间:2026/9/28 16:39:20来源:尧图网络
简介这份资源面向计算机、人工智能及相关专业的在校学生与教师提供一套基于ResNet主干网络并嵌入CBAM注意力机制的Stanford Dogs犬种识别分类Python实现可作为课程设计、毕业设计、期末大作业或项目初期立项的参考方案也适合希望理解注意力模块如何提升细粒度图像分类效果的进阶学习者。压缩包共21个文件约228KB以py源码为主辅以pyc编译文件、md说明文档、sh训练脚本及少量jpg、png示例图涵盖模型定义、注意力模块、数据加载与训练入口等模块结构清晰便于按需查阅。目前已有383人学习下载。读者可从中获得完整的犬种分类训练与推理流程、CBAM与BAM注意力模块的对照实现、ResNet50训练脚本及数据读取代码并可在其基础上替换数据集迁移到其他图像识别分类任务中二次开发。1. ResNetCBAM 做 StanfordDogs 识别为什么加个注意力模块细粒度分类就稳了StanfordDogs 这个数据集做过的都知道它不是普通猫狗二分类而是 120 个犬种很多品种长得几乎一模一样——比格犬和巴吉度、哈士奇和阿拉斯加人眼都得盯着看半天。纯 ResNet50 直接上准确率往往卡在 70% 出头就上不去了因为卷积核在空间上是「一视同仁」的它不会主动去盯耳朵形状、口鼻比例这些真正区分品种的局部区域。CBAMConvolutional Block Attention Module干的事就是给网络加一层「该看哪儿」和「该看哪个通道」的权重让模型自己学会把注意力压到有判别力的部位上。这套 ResNetCBAM 的组合Python 源码结构清晰换数据集也方便适合想入门注意力机制、又不想从零搭网络的人。下面从原理到跑通、再到换数据一步步拆开讲。2. CBAM 到底往 ResNet 里塞了什么通道注意力与空间注意力的串联逻辑2.1 从 ResNet 的瓶颈说起为什么细粒度任务需要额外注意力ResNet 的核心是残差连接解决了深层网络退化问题但它的特征提取是「均匀卷积」——每个空间位置、每个通道对最终分类的贡献在卷积核看来是等权的。对于 StanfordDogs 这种类间差异极小、类内差异极大的细粒度任务问题就暴露了狗的整体轮廓都差不多真正有用的信息集中在耳朵、眼睛、口鼻这些局部而且不同品种起决定作用的部位还不一样。CBAM 的思路很直接既然卷积不会自己挑重点那我就显式地算一个权重图告诉网络「这个通道重要」「这个位置重要」。它分两步走先做通道注意力再做空间注意力串行叠加。这个顺序不是随便定的——先搞清楚「哪些特征通道值得关注」再在这个基础上问「特征图上的哪些位置值得关注」逻辑上是从粗到细的过滤。2.2 通道注意力模块用全局池化加两层 MLP 算通道权重通道注意力的做法是对输入特征图分别做全局平均池化和全局最大池化得到两个长度为 C 的向量然后各自过同一个两层共享 MLP中间有个降维比 reduction ratio通常取 16最后把两个结果相加、过 Sigmoid得到 C 个通道的权重。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction16): super().__init__() # 全局平均池化和全局最大池化输出形状都是 (B, C, 1, 1) self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) # 共享 MLP先降维再升维减少参数量 self.mlp nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels, 1, biasFalse) ) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.mlp(self.avg_pool(x)) # 平均池化分支 max_out self.mlp(self.max_pool(x)) # 最大池化分支 return self.sigmoid(avg_out max_out) # 相加后激活得到通道权重这里 reduction16 是原论文的默认值通道数少的时候比如小于 16要手动调小否则 in_channels // reduction 会变成 0直接报错。两个池化分支缺一不可平均池化保留整体响应强度最大池化抓最显著的特征实验里去掉任何一个StanfordDogs 上的准确率都会掉 1 到 2 个点。2.3 空间注意力模块在通道维度上做池化生成位置权重图通道注意力输出后特征图每个通道已经有了不同权重。空间注意力接着在通道维度上做文章对每个空间位置沿通道方向取平均值和最大值得到两张 H×W 的单通道图拼成 2 通道再用一个 7×7 卷积压成 1 通道Sigmoid 后就是空间权重图。class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() # 输入 2 通道avg max输出 1 通道空间权重 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) # 沿通道取最大 concat torch.cat([avg_out, max_out], dim1) # 拼成 (B, 2, H, W) return self.sigmoid(self.conv(concat)) # 卷积 激活kernel_size7 是原论文调出来的经验值换成 3 或 5 效果会略降因为感受野不够覆盖狗脸的关键区域。padding 必须设成 kernel_size // 2保证输出尺寸和输入一致否则残差相加时形状对不上。2.4 把 CBAM 插进 ResNet 的 Bottleneck插入位置决定收益大小CBAM 不是随便塞哪儿都行。常见做法是插在每个 Bottleneck 的残差相加之前也就是卷积走完、准备和 shortcut 相加的那个位置。这样注意力权重作用在残差分支的输出上再和恒等映射相加既修正了特征又不破坏残差结构。class BottleneckWithCBAM(nn.Module): def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() # 标准 Bottleneck 的 1x1 - 3x3 - 1x1 结构 self.conv1 nn.Conv2d(inplanes, planes, 1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d(planes, planes, 3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.conv3 nn.Conv2d(planes, planes * 4, 1, biasFalse) self.bn3 nn.BatchNorm2d(planes * 4) self.relu nn.ReLU(inplaceTrue) self.cbam CBAM(planes * 4) # 插在残差相加前 self.downsample downsample def forward(self, x): residual x out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) out self.cbam(out) # 先过注意力 if self.downsample is not None: residual self.downsample(x) return self.relu(out residual) # 再相加插入位置有两个选择残差相加前、相加后。我实测下来相加前更稳因为注意力直接作用在待融合的特征上梯度回传路径也更干净。如果插在相加后注意力会同时影响恒等映射那条路训练初期容易震荡。每个 stage 都插还是只插后两个 stage取决于数据量——StanfordDogs 只有两万来张图全插容易过拟合一般只在 layer3 和 layer4 插就够了。3. 从零跑通 StanfordDogs 训练数据准备、模型组装与训练脚本3.1 数据下载与目录结构StanfordDogs 的官方划分怎么用StanfordDogs 官方给的是 Images 加 Annotation 两套文件Images 里按 n02085620-Chihuahua 这种类别名分文件夹Annotation 里是 XML 格式的标注。做分类任务其实用不上 XML直接按文件夹读图就行。但官方没给标准的 train/val 划分常见做法是按 7:3 或者 8:2 自己切或者用 ImageNet 预训练时常用的那个 split 文件。# 假设数据解压到 data/ 下结构如下 # data/Images/n02085620-Chihuahua/*.jpg # data/Images/n02097658-silky_terrier/*.jpg # ...共 120 个类别文件夹 # 用 Python 快速统计类别数和图片总数 python -c import os root data/Images classes sorted(os.listdir(root)) total sum(len(os.listdir(os.path.join(root, c))) for c in classes) print(f类别数: {len(classes)}, 图片总数: {total}) 跑完应该看到类别数 120、图片总数两万出头。如果数字对不上多半是解压不完整或者多了一层嵌套目录。切分的时候要注意每个类别都要有验证集样本不能随机切完某个类别全进了训练集那样验证准确率会虚高。3.2 数据增强与 DataLoader细粒度任务别用太猛的增强细粒度分类的增强策略和普通分类不一样。随机裁剪、颜色抖动这些可以用但幅度要小——狗的关键特征在局部裁太狠可能把耳朵、口鼻裁没了。水平翻转可以用垂直翻转千万别用狗不会倒着站。RandAugment 这类强增强在 StanfordDogs 上反而会掉点因为把判别性细节给破坏掉了。from torchvision import transforms, datasets from torch.utils.data import DataLoader, random_split train_tf transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), # 裁剪幅度收窄 transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), # 抖动幅度调小 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) full datasets.ImageFolder(data/Images, transformtrain_tf) n_val int(len(full) * 0.2) train_set, val_set random_split(full, [len(full) - n_val, n_val]) val_set.dataset datasets.ImageFolder(data/Images, transformval_tf) # 验证集换增强 train_loader DataLoader(train_set, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_set, batch_size32, shuffleFalse, num_workers4)batch_size32 是 8G 显存下的稳妥值显存够可以上 64。num_workers 在 Windows 上设 0 或 2设 4 以上容易卡死这是血泪经验。Normalize 那组均值方差是 ImageNet 的用预训练权重就必须保持一致自己算数据集的均值反而会掉点。3.3 组装 ResNet50CBAM 并加载预训练权重模型组装的关键是「部分加载预训练权重」。ResNet50 的 backbone 权重从 torchvision 拿CBAM 模块是新增的没有预训练权重得随机初始化。加载的时候要跳过名字对不上的 key否则直接报错。import torchvision.models as models import torch.nn as nn def build_model(num_classes120, pretrainedTrue): # 拿 ResNet50 结构但不加载分类头 backbone models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None) # 把每个 Bottleneck 替换成带 CBAM 的版本这里示意 layer4 # 实际替换需要遍历 backbone.layer4 并逐层替换代码略长核心是保持 inplanes/planes 一致 backbone.fc nn.Linear(backbone.fc.in_features, num_classes) return backbone model build_model(num_classes120) # 只加载 backbone 部分权重CBAM 新增层随机初始化 state models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2).state_dict() model_dict model.state_dict() loaded {k: v for k, v in state.items() if k in model_dict and v.shape model_dict[k].shape} model_dict.update(loaded) model.load_state_dict(model_dict) print(f成功加载 {len(loaded)} 个预训练权重)替换 Bottleneck 的时候inplanes 和 planes 必须和原结构对齐否则残差相加形状对不上。加载完打印一下加载了多少个 key正常应该是 300 多个如果只有几十个说明名字匹配规则写错了。分类头 fc 的输入维度是 2048输出改成 120。3.4 训练循环与学习率策略余弦退火加 warmup 的实操参数优化器用 SGD 还是 AdamW实测在细粒度任务上 SGD momentum 泛化更好但收敛慢AdamW 收敛快但容易过拟合。我一般用 SGDlr0.01momentum0.9weight_decay1e-4配合余弦退火和 5 个 epoch 的 warmup。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) warmup LinearLR(optimizer, start_factor0.1, total_iters5) cosine CosineAnnealingLR(optimizer, T_max45) scheduler SequentialLR(optimizer, schedulers[warmup, cosine], milestones[5]) criterion nn.CrossEntropyLoss(label_smoothing0.1) # 标签平滑防过拟合 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 验证阶段略每 5 个 epoch 存一次 checkpointlabel_smoothing0.1 在类别多、样本少的时候很有用能压一压过拟合。warmup 的 5 个 epoch 别省CBAM 是随机初始化的一上来大学习率会让注意力权重乱跳训练不稳定。50 个 epoch 在单卡 2080Ti 上大概跑 3 到 4 小时准确率能到 82% 到 85%比纯 ResNet50 高 8 到 10 个点。4. 换数据集、调参、排错这套源码落地时最容易翻车的地方4.1 换成自己的数据改哪几个地方就够了这套源码换数据集其实只动三处数据目录结构、num_classes、以及类别名映射。数据按 ImageFolder 的格式放每个类别一个文件夹文件夹名就是类别名。num_classes 改成你的类别数分类头会自动适配。如果类别名有中文建议改成英文或拼音ImageFolder 对中文路径的支持在部分系统上有坑。# 换数据集时改这几行 DATA_ROOT data/your_dataset # 改成你的路径 NUM_CLASSES 10 # 改成你的类别数 model build_model(num_classesNUM_CLASSES) # 类别名映射会自动从文件夹名生成不用手动写如果新数据集类别少比如小于 10CBAM 的 reduction 要调小到 8 甚至 4否则通道降维太狠注意力权重学不出来。类别少的时候预训练权重更重要千万别从零训。4.2 显存不够、训练不收敛、验证准确率上不去三类问题的排查顺序显存不够先降 batch_size再考虑混合精度。训练不收敛先看学习率是不是太大CBAM 新增层建议单独给个小学习率。验证准确率上不去先确认验证集增强是不是用错了用了 train_tf再看是不是过拟合——训练准确率 99% 验证 70% 就是典型过拟合加 dropout 或者减 CBAM 插入层数。4.3 避坑清单CBAM 落地时我踩过的 5 个坑坑一in_channels // reduction 变成 0。现象是初始化直接报除零或者维度错误。原因是通道数小于 reduction 时整除结果为 0。解决是把 reduction 设成 min(16, in_channels // 2)或者通道少时直接不降维。坑二CBAM 插在残差相加后导致训练震荡。现象是 loss 前几个 epoch 上下乱跳。原因是注意力同时影响了两条分支。解决是统一插在相加前并且 warmup 阶段学习率压到 0.001。坑三预训练权重加载时把 CBAM 层也覆盖了。现象是加载后 CBAM 权重全是预训练的值其实根本没有或者报 shape 不匹配。原因是加载逻辑没做 shape 校验。解决是加载前逐个比对 key 和 shape只加载匹配的。坑四验证集用了训练增强准确率虚低。现象是验证准确率比预期低 5 个点以上。原因是 RandomResizedCrop 把验证图裁得乱七八糟。解决是验证集单独用 CenterCrop别复用 train_tf。坑五num_workers 设太大在 Windows 上卡死。现象是训练脚本启动后卡在第一个 batch 不动。原因是 Windows 的多进程 DataLoader 有兼容问题。解决是 num_workers 设 0 或 2配合 persistent_workersFalse。5. 把 CBAM 用出效果的两个进阶技巧插入策略与注意力可视化CBAM 插几层、插哪儿对最终结果影响比想象中大。我做过一组对比只在 layer4 插、layer3layer4 插、全部 stage 插。StanfordDogs 上 layer3layer4 最好全插反而掉 1 个多点因为浅层特征本身分辨率高、语义弱加注意力等于给噪声加权。数据量大的时候十万级以上全插才有优势。这个策略换数据集时要重新试没有万能解。插入策略参数量增加StanfordDogs 准确率适用场景仅 layer4约 0.3M82.5%数据量小、类别少layer3layer4约 0.6M84.8%通用推荐全部 stage约 1.2M83.6%数据量十万级以上另一个技巧是注意力可视化用来验证 CBAM 是不是真的学到了东西。把空间注意力的权重图叠加到原图上如果高亮区域集中在狗脸、耳朵这些部位说明模块起作用了如果高亮散在背景上多半是训练不够或者学习率没调好。import matplotlib.pyplot as plt def visualize_attention(model, img_tensor, layer_namecbam): # 注册 hook 抓空间注意力输出 feats {} def hook(module, inp, out): feats[attn] out.detach().cpu() # 假设 model.layer4[0].cbam 是目标模块 handle model.layer4[0].cbam.register_forward_hook(hook) model.eval() with torch.no_grad(): model(img_tensor.unsqueeze(0).cuda()) handle.remove() attn feats[attn][0].mean(0).numpy() # 通道平均得到 HxW plt.imshow(attn, cmapjet) plt.colorbar() plt.title(CBAM Spatial Attention) plt.show()可视化的时候注意hook 抓的是 CBAM 整体输出想看纯空间注意力得单独 hook SpatialAttention 那个子模块。另外注意力图要 resize 回原图尺寸再叠加直接看 7×7 或者 14×14 的图看不出细节。我一般每个 epoch 存一张训练过程中翻着看能提前发现注意力跑偏的问题——比如高亮全在角落那就是数据增强把目标裁出画面了。这套 ResNetCBAM 的方案核心价值不在 CBAM 本身多复杂而在于它用很小的参数量代价把细粒度分类的准确率拉了一个台阶而且换数据集的门槛很低。我自己的习惯是新数据集先跑纯 ResNet 做 baseline再加 CBAM 对比涨点超过 3 个点才值得保留涨点不明显就说明这个数据集的关键特征不在局部得换别的注意力机制试试。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网