ResNet+CBAM注意力机制实现细粒度图像分类实战解析

📅 发布时间:2026/10/11 0:39:58
ResNet+CBAM注意力机制实现细粒度图像分类实战解析
简介面向课程设计、毕业设计与期末大作业需求这份基于ResNetCBAM注意力机制的Stanford Dogs识别分类Python源码包可快速搭建图像分类训练流程也能替换数据集用于其他场景。源码包共21个文件包括Python源码及pyc编译文件、Markdown说明文档、Shell训练脚本和展示图片压缩包仅228KB轻量易用。目前已有383人学习下载。包内完整覆盖CBAM/BAM注意力模块、ResNet模型定义、数据加载与训练入口并附有训练脚本和效果展示图既可直接复现Stanford Dogs犬种实验也可迁移到自定义图像数据集便于在注意力机制与残差网络结合方向做二次开发。1. 基于ResNetCBAM的Stanford Dogs识别这套源码为什么值得你跑一遍做细粒度图像分类的人大概率在Stanford Dogs这种数据集上栽过跟头120个犬种很多品种之间只差耳朵轮廓和毛色深浅用普通ResNet做基准模型Top-1准确率卡在70%上下就是上不去。这里说的基于ResNetCBAM注意力机制的Stanford Dogs识别分类源码本质上就是把CBAM这种同时关注通道和空间位置的注意力模块插进ResNet的残差结构里让模型不再对所有特征图一视同仁而是重点盯住“狗头”“耳朵”“爪子”这些真正能区分品种的局部区域。它能解决的就是细粒度分类中“特征表达够但注意力跑偏”的问题适合正在做图像分类毕设、入门注意力机制、或者想把分类模型迁移到自己数据集的Python开发者。这套方案不挑数据换掉图片目录就能跑别的分类任务。2. CBAM注意力机制与ResNet的融合位置先搞清楚加在哪、为什么2.1 通道注意力与空间注意力CBAM的两段式设计CBAM全称是Convolutional Block Attention Module它和SE注意力机制最大的区别在于多了一条空间注意力分支。SE只做通道维度的重标定也就是对每个特征通道算一个权重把有用的通道放大、没用的压缩CBAM在这之后还要对特征图的空间位置做一次加权。用大白话说SE告诉模型“看哪些颜色”CBAM额外告诉模型“看画面里的哪个位置”。这两个信息对细粒度分类缺一不可——斯坦福狗数据集里不同品种的色调差异不大但耳朵形状、腿部比例这些空间结构差异非常明显。通道注意力模块的计算方式是这样的输入特征图F分别走全局平均池化和全局最大池化两条路得到两个一维向量送进一个共享权重的多层感知机把两个输出相加过sigmoid得到通道权重。这里有个关键参数叫reduction ratio一般记为r它的作用是控制MLP中间层的压缩程度。我一般会把r设为16这时MLP的中间层神经元数量等于输入通道数除以16。r值设太大会让通道间的信息交互变弱太小则参数膨胀后面避坑部分我会单独讲。空间注意力模块的输入是通道注意力加权后的特征图。它先在通道维度上做平均池化和最大池化得到两张二维的特征图然后把它们拼在一起过一层7x7的卷积输出单通道的空间权重图再过sigmoid。两段式设计的逻辑是先让模型确定“看哪些通道”再让模型在选定的通道里确定“看哪些像素位置”。串行比并行效果好的原因论文里的消融实验说得很清楚串行时第二个模块的输入已经经过第一轮筛选噪声更少注意力图更干净。我在实践里对比过串行和并联的写法串行在Stanford Dogs验证集上大概能高出1.5个百分点。2.2 加在ResNet的哪个位置对比三种接法CBAM加进ResNet的位置没有标准答案但实际做下来效果差别不小。以ResNet18和ResNet34这种用BasicBlock的基础残差块为例常见的接法有三种。第一种是加在残差块内部让CBAM对残差分支的输出做加权再和shortcut相加第二种是加在add操作之后、ReLU激活之前第三种是加在每个Stage的输出之后也就是下采样之前。我一般会选第一种也就是把CBAM嵌进BasicBlock的forward函数里处理残差分支的输出。这么做的理由是残差分支的输出是当前层从输入中学到的新特征shortcut是恒等映射或经过1x1卷积的原始特征。CBAM只对新增特征做注意力加权不污染原始特征的传递梯度回传路径也更清晰。把CBAM放在add之后的问题在于恒等映射的特征也被一起重新标定了如果某个通道的特征本身就足够好再乘一个小于1的注意力权重反而会拖后腿。放在Stage之后则粒度太粗一个Stage内部可能有三到四个残差块前面的块在提取轮廓后面的块在提取纹理用一个注意力模块去覆盖所有块的输出等于让模型做了一锅炖。这里还有一个工程上的选型问题是不是每个残差块都要加CBAM我可以负责任地说不是越多越好。在ResNet18上加满四层Stage的CBAM参数会增加大约15%训练时间也明显变长精度提升只有0.8%左右。我只在前两个Stage加效果反而更稳。原因不难理解前两个Stage输出的特征图分辨率高空间注意力能捕捉到狗耳朵、狗嘴这些局部细节后两个Stage的特征图分辨率低每个像素对应原图一大块区域空间注意力能做的区分已经非常有限。当然你如果换成了ResNet50这种Bottleneck结构的网络可以每个大Stage里的最后一个Block加一个CBAM效果也比较均衡。这里顺便说一下PyTorch里怎么改BasicBlock。以下是一个简化的CBAM模块定义和BasicBlock修改示意import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction_ratio16): super().__init__() self.mlp nn.Sequential( nn.Linear(in_channels, in_channels // reduction_ratio), nn.ReLU(inplaceTrue), nn.Linear(in_channels // reduction_ratio, in_channels) ) def forward(self, x): # x: [B, C, H, W] avg_out self.mlp(x.mean(dim(2, 3))) # 全局平均池化 max_out self.mlp(x.amax(dim(2, 3))) # 全局最大池化 weight torch.sigmoid(avg_out max_out) # 逐元素相加后过sigmoid return weight.unsqueeze(2).unsqueeze(3) # 还原为 [B, C, 1, 1] class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_sizekernel_size, paddingkernel_size // 2) def forward(self, x): avg_out x.mean(dim1, keepdimTrue) # 通道维平均池化 max_out x.amax(dim1, keepdimTrue) # 通道维最大池化 concat torch.cat([avg_out, max_out], dim1) return torch.sigmoid(self.conv(concat)) # [B, 1, H, W]# 在BasicBlock内部插入CBAM class BasicBlockWithCBAM(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.cbam CBAM(out_channels) # shortcut 部分的代码省略 def forward(self, x): identity self.shortcut(x) out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.cbam(out) # 残差分支输出先过CBAM out identity # 再与恒等映射相加 return self.relu(out)这段代码里有个细节值得注意CBAM放在bn2之后、add之前这意味着BatchNorm已经把特征分布拉回零均值附近CBAM的sigmoid输出在这里不会遇到数值饱和的问题。如果你把CBAM放在add之后sigmoid的输入是两路特征之和数值范围不受控制训练早期很容易出现注意力权重全部贴到0或1的极端情况。另外一个容易忽略的点通道注意力里的MLP第一个Linear层的输入维度是in_channels也就是输入特征图的通道数这里不要和out_channels搞混。如果你在BasicBlock里加CBAM输入输出通道数不同的时候以out_channels为准。3. 把Stanford Dogs数据集喂给模型目录组织与DataLoader写法3.1 Stanford Dogs的标注格式与目录结构Stanford Dogs数据集可以从斯坦福大学官网下载整个数据集分两部分images目录和annotation目录。images目录下是按类别组织的文件夹每个文件夹的命名格式是“WordNet编号-品种名”比如n02085620-Chihuahua表示吉娃娃n02085936-Maltese_dog表示马耳他犬。整个数据集一共120个品种、两万多张图片图片尺寸不统一从几百像素到上千像素都有直接用原始尺寸训练肯定不行。annotation目录里是MATLAB的.mat标注文件每个品种一个。用scipy.io.loadmat读出来里面有四个关键字段annotation是一个结构体数组每个元素对应一张图片的标注信息imgname是图片文件名bbox是目标检测框坐标。还有一个更重要的字段是train/test列表的划分索引官方已经帮你分好了训练集和测试集。这里有个常见的坑mat文件里的类别顺序和images目录下的文件夹顺序不一定一致如果直接按os.listdir的顺序去对类别id训练集和验证集会串味。正确的读取方式应该以mat文件里的类别名称为基准构建一个类别id到文件夹名的映射表。我写了一个小脚本把每个品种的文件夹名读出来按字母序排序后分配从0到119的类别id再读mat文件里的训练集图片索引把每张图片的完整路径和类别id对应起来。mat文件里imgname字段存的不是完整路径而是相对于images目录的路径拼接的时候要注意。import os import scipy.io as sio from collections import OrderedDict def load_stanford_dogs(images_root, anno_root): # images_root: 存放狗品种图片的根目录 # anno_root: 存放mat标注文件的目录 classes sorted([d for d in os.listdir(images_root) if os.path.isdir(os.path.join(images_root, d))]) class_to_idx {name: i for i, name in enumerate(classes)} samples [] for cls_name in classes: mat_path os.path.join(anno_root, cls_name.split(-)[0] _annotation.mat) mat sio.loadmat(mat_path) annos mat[annotation] # 官方标注里没有直接给出划分需要用测试集列表单独读 # 这里先把所有图片路径和类别id存下来 for item in annos: img_name str(item[imgname][0][0]) full_path os.path.join(images_root, cls_name, img_name) samples.append((full_path, class_to_idx[cls_name])) return samples, class_to_idx这段代码里我用的是类别文件夹名直接推mat文件路径的写法因为mat文件名是WordNet编号和文件夹名的前缀一致这样最不容易错。很多现成教程把mat里的类别列表和images目录分开处理最后拼映射表逻辑绕了一圈还容易踩顺序的坑。另外注意scipy.io.loadmat读出来的字符串是numpy的bytes类型用str()转一次才能正常拼接路径。读图片的时候用PIL的Image.open或者cv2.imread都行但要注意cv2读出来是BGR通道顺序训练前要转成RGB否则模型学到的颜色特征全是反的。3.2 按ImageFolder格式重排数据与DataLoader参数torchvision的ImageFolder是最省事的数据集类只要目录结构是“根目录/类别名/图片文件”它就能自动读取图片并生成类别标签。所以我在每次跑这个源码之前都会先把Stanford Dogs整理成这个格式。具体做法是建一个data目录下面分train和val两个子目录再往每个子目录里按类别建120个子文件夹把官方划分好的训练集和测试集图片分别复制进去。复制比软链接稳虽然占用磁盘空间大一些但Windows和Linux下都不会出现软链接读不到的问题。整理完目录结构之后用torchvision.datasets.ImageFolder加载再配合DataLoader做数据迭代。这里有几个参数比模型本身更影响训练结果batch_size、num_workers和pin_memory。batch_size取决于显卡显存ResNet18加CBAM在1080Ti上可以设64ResNet50的话32比较稳num_workers设成CPU物理核心数的一半通常最快设太大反而因为线程切换开销导致数据加载变慢pin_memory设为True可以让GPU训练时数据拷贝快一截前提是你的机器内存够大。from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集和验证集用不同的数据增强策略 train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(data/train, transformtrain_transform) val_dataset datasets.ImageFolder(data/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)这里的Normalize参数用的是ImageNet数据集的均值和标准差原因是你后面要加载ResNet的预训练权重预训练模型是在ImageNet上做的标准化输入数据分布不一致的话预训练特征基本发挥不出来。换自己的数据集时这个标准化参数一般不用改等模型在你的数据上训练充分后如果精度还是上不去再考虑统计自己数据集的均值和标准差但大多数情况下多此一举。RandomResizedCrop的scale参数是从0.7到1.0这是在限制裁剪面积占原图的比例。Stanford Dogs官方数据里很多狗是全身照bbox标注比较紧裁得太小会把狗头截掉一半。如果你的显卡显存足够可以把输入分辨率从224提高到256对细粒度分类的精度提升非常明显代价是训练时间大约增加四成。4. 核心代码实现ResNetCBAM的训练流程与关键参数4.1 在PyTorch里给ResNet插入CBAM模块torchvision自带的resnet18、resnet34这些模型类BasicBlock是写死的不能直接往forward里插模块。好在PyTorch的源码设计得比较开放可以用继承和重写的方式改造。我的做法是写一个子类继承torchvision.models.ResNet然后替换掉它内部的BasicBlock把它指向我自定义的BasicBlockWithCBAM。torchvision的ResNet代码在构造时会根据block参数创建每一层的残差块所以只要把block参数换成带CBAM的版本就行。加载预训练权重的时候有个关键操作用pretrainedTrue拿到原版ResNet的权重然后遍历state_dict把和自定义网络名称匹配的键值对加载进去。因为自定义Block新增了cbam相关的权重层这两层在预训练权重里不存在加载时需要用strictFalse跳过。千万不能把预训练权重直接喂给自定义网络然后报错就放弃了正确做法是加载完同名的权重后对CBAM的卷积层和Linear层做一次kaiming初始化。import torchvision.models as models def build_resnet_cbam(num_classes120, pretrainedTrue): # 使用torchvision内置的ResNet骨架替换block为带CBAM的版本 model models.ResNet(blockBasicBlockWithCBAM, layers[2, 2, 2, 2], num_classesnum_classes) if pretrained: # 加载原版ResNet18的预训练权重 state_dict models.resnet18(pretrainedTrue).state_dict() model.load_state_dict(state_dict, strictFalse) # 对CBAM新增层做初始化 for name, module in model.named_modules(): if isinstance(module, (nn.Conv2d, nn.Linear)): if cbam in name: nn.init.kaiming_normal_(module.weight, modefan_out, nonlinearityrelu) return model这里的layers参数[2,2,2,2]对应ResNet18的结构如果你换ResNet34就改成[3,4,6,3]。torchvision源码里ResNet的构造函数是允许直接传入自定义block的但要注意自定义block的__init__签名必须和原版BasicBlock保持一致否则构造到一半会报参数不匹配的错误。加载权重时strictFalse会跳过所有名称匹配不上的键不要只盯着CBAM层看shortcut里的1x1卷积如果名称对不上也会被跳过所以加载完成后最好打印一下missing_keys和unexpected_keys确认跳过的确实只有CBAM相关层。4.2 训练配置与超参数从学习率到早停训练超参数这块我踩过不少坑先说结论。优化器用SGD配上momentum0.9和weight_decay1e-4比Adam稳。CBAM这种带注意力模块的网络对学习率非常敏感Adam在训练初期收敛快但到了后期loss会在一个高位来回震荡很难逼近最优值。SGD配合余弦退火学习率调整训练曲线比Adam平滑得多。初始学习率我一般设在0.001加载预训练权重的情况下0.01容易把浅层特征冲坏0.0001又收敛太慢。损失函数用交叉熵就够了Stanford Dogs的120个类别样本数量相对均衡没有严重的类别不平衡问题。如果你的训练数据分布很不均匀可以考虑给CrossEntropyLoss传class_weight参数权重设为每个类别的样本数倒数或者用更简单的方案每个batch里做加权采样。训练周期一般设30到50轮细粒度分类模型在20轮之前几乎不会收敛40轮之后又开始过拟合我一般盯验证集loss做早停连续5轮不降就停止训练并回滚到最佳权重。import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model build_resnet_cbam(num_classes120, pretrainedTrue).cuda() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.001, momentum0.9, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-6) best_acc 0.0 best_model_path best_resnet_cbam.pth for epoch in range(50): model.train() train_loss 0.0 for images, labels in train_loader: images, labels images.cuda(non_blockingTrue), labels.cuda(non_blockingTrue) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() scheduler.step() # 验证 model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() val_acc correct / total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model_path)这里的CosineAnnealingLR把学习率从0.001按余弦曲线逐步降到0.000001比固定学习率或者每隔30轮降一次的方式好不少。注意scheduler.step()要每个epoch调用一次不是每个batch调用一次否则学习率下降过快最后几十轮基本在空转。保存模型用state_dict而不是整个模型对象这样换机器或者换Python版本加载都更灵活。验证时记得包在torch.no_grad()里否则验证过程也会构建计算图显存消耗直接翻倍。我之前在验证阶段忘了加这个12G显存的卡直接OOM这个坑下面细说。5. 避坑指南训练CBAM模型最容易翻车的几个细节5.1 坑一加载预训练权重直接报size mismatch现象运行build_resnet_cbam时PyTorch提示size mismatch for conv1.weight或者报unexpected key。原因是torchvision预训练模型的输入是3通道ImageNet图片但你的数据集可能是单通道灰度图或者你的网络输入层被改成了别的通道数。解决先检查你的输入图片是不是RGB三通道。如果是灰度图把图片复制成三通道再送进网络如果你用的是ResNet的变体比如把输入层换成了更大尺寸的卷积核那预训练权重里conv1也匹配不上。还有一个办法是把加载权重那行换成model.load_state_dict(state_dict, strictFalse)然后打印missing_keys看看跳过了哪些层再人工决定是否需要重新初始化这些层。大多数情况下跳过的只有fc层和cbam层这两个是可以放心重新初始化的。5.2 坑二mat标注文件解析出来的类别顺序和目录对不上现象训练时loss正常下降但验证集准确率始终在20%上下徘徊和随机猜差不多。原因多半是训练集和验证集的类别id对不上或者测试时用的类别映射表顺序和训练时不一样。Stanford Dogs的mat文件里类别顺序是按WordNet编号排的但images目录下的文件夹如果按字母序排序顺序和WordNet编号不完全一致。解决以images目录下的文件夹名为唯一基准先给每个文件夹分配类别id再去mat文件里通过文件夹名前缀查对应的标注文件。不要试图从mat文件里读类别名再反推id这条路绕远了。整理完目录之后用一个简单脚本随机抽查几张图打印图片路径、类别id和类别名人眼确认对应关系正确再开跑。这个检查只需要一分钟能省下好几个小时的无效训练。5.3 坑三验证时忘了包no_grad导致显存溢出现象训练跑到第10个epoch验证阶段突然报CUDA out of memory训练过程倒是没任何问题。原因是验证阶段模型仍然处于梯度跟踪状态每个batch的前向计算都会构建计算图并保存中间变量显存被快速吃满。解决把所有验证代码包在with torch.no_grad()里面同时用model.eval()切换BatchNorm和Dropout的工作模式。记住model.eval()只影响BatchNorm和Dropout不影响梯度跟踪两个都要做。另外验证阶段把batch_size调大一些是没问题的因为没有梯度显存占用比训练时小得多可以适当提高batch_size来加速验证。5.4 坑四reduction ratio设太小导致参数量暴涨现象模型训练速度明显变慢显存占用比预期的ResNet18大了将近一倍而且训练出来的模型在验证集上没有明显提升。原因是通道注意力MLP的中间层维度是通道数除以r如果r设成4甚至2中间层神经元数量会非常大参数翻倍。解决r16是性价比最高的默认值换成r8也就多一点点参数量但r4基本没必要。如果你改用的是ResNet50它的Bottleneck结构本身通道数就大r16可能让中间层还是很大这种情况下可以试试r32。判断标准很简单打印模型的总参数量如果比原版ResNet多了30%以上优先检查r值是不是太小了。5.5 坑五数据增强太强把狗头裁没了现象训练集准确率缓慢上升但验证集准确率一直在低位震荡和训练误差之间的gap越来越大。原因是RandomResizedCrop的scale参数设得太激进比如默认的(0.08, 1.0)它会把一张狗的照片裁剪到只剩背景或者只留狗肚子模型学到的特征全是颜色和纹理和品种完全无关。解决把scale改为(0.7, 1.0)或者(0.6, 1.0)保证每次裁剪出来的区域至少包含大半个狗身。如果你用的数据集是已经裁剪过的目标框那么可以用Resize加CenterCrop完全不用RandomResizedCrop。细粒度分类的数据增强策略和通用分类不一样后者随便裁前者必须保证主体物完整这是最容易被忽略的细节。6. 把源码换到自己的数据集迁移步骤与一个验证技巧换数据集是这个方案最大的卖点实际操作比想象中简单。以这个源码为例你只需要做三件事把图片整理成“根目录/类别名/图片文件”的结构修改num_classes为你的类别数把train_transform和val_transform里的输入尺寸按你的图片分辨率做微调。如果你的数据集图片尺寸很小比如只有96x96分辨率就不要强行Resize到224否则图片会糊到连人眼都分不清这时候反而应该把输入尺寸改小比如128x128或者换用更浅的ResNet18而不是ResNet50。换完数据集之后我建议先跑一个10轮的短实验只训练模型的后半部分也就是冻结前两个Stage的权重。这一步是为了验证数据读取、损失计算、验证逻辑这些管道都没问题。10轮之后看验证集准确率如果比随机猜测高出一截说明管道正常如果和随机猜测差不多先检查类别映射和图片增强别急着调模型结构。确认管道没问题后再解冻全部参数正式训练。一个很有用的验证技巧把CBAM模块的sigmoid输出可视化出来叠加到原图上。如果注意力热力图集中在狗头的眼睛、鼻子周围说明模型学到了合理的判别区域如果热力图散落在背景上说明数据增强或预训练权重出了问题。这个可视化的代码不复杂保存输入图片的梯度或者特征图就行但它能直观地告诉你CBAM到底在“看”什么。我用这个技巧排查过一个数据标注错误的问题某类图片里混入了大量背景图注意力热力图全打在背景上一眼就看出来了。我做这个项目的最大教训是注意力机制不是银弹它的收益上限取决于骨干网络和数据质量。ResNet18加CBAM在Stanford Dogs上能到85%左右ResNet50加CBAM能到88%上下但如果你把数据增强或者类别映射搞错了再强的注意力也白搭。所以每换一个数据集我都会先做一轮最小的冒烟测试确认每条数据流都是通的再放开手脚调参。希望这个思路和这套踩坑记录对你有所帮助。本文还有配套的精品资源点击获取