资讯中心

ResNet+CBAM 细粒度分类实战:StanfordDogs 识别与注意力模块插入指南

📅 2026/9/28 16:39:17
ResNet+CBAM 细粒度分类实战:StanfordDogs 识别与注意力模块插入指南
简介这份资源面向计算机、人工智能、数据科学等专业的在校学生与教师提供一套基于ResNet主干网络并嵌入CBAM注意力机制的Stanford Dogs犬种识别分类Python源码可作为课程设计、毕业设计、期末大作业或项目初期立项的参考实现。压缩包共21个文件约228KB以py源码为主辅以pyc编译文件、md说明文档、sh训练脚本及jpg、png示例图片涵盖模型定义、注意力模块、数据加载与训练入口等模块结构清晰便于按需查阅。资源已验证可稳定运行并预留了替换数据集与二次开发的拓展空间读者可据此理解注意力机制在细粒度图像分类中的接入方式掌握训练脚本调用与数据组织流程并迁移到其他识别分类任务。目前已有383人学习下载适合具备一定深度学习基础、希望快速上手实战的读者参考使用。1. ResNetCBAM 做 StanfordDogs 识别为什么加个注意力模块细粒度分类就不一样了StanfordDogs 这个数据集有意思的地方在于它分的不是「猫和狗」而是「哈士奇和阿拉斯加」「比格和巴吉度」这种连人眼都要愣一下的细粒度类别。纯 ResNet50 跑这个数据集top-1 通常卡在 70% 出头就上不去了原因不玄学卷积核在空间上是共享权重的它不知道哪块区域是耳朵、哪块是背景草地。CBAM 干的事就是给特征图每个通道、每个空间位置算一个权重让网络自己学会「看哪里」。这篇讲的就是怎么用 ResNet 做 backbone、把 CBAM 插进残差块里、在 StanfordDogs 上把分类精度往上推一截并且整套代码换成其他数据集也能跑。适合已经能跑通基础 CNN 分类、想搞清楚注意力模块到底插在哪、参数怎么调的人。2. 先把结构定下来ResNet 主干和 CBAM 到底怎么拼2.1 为什么选 ResNet 做 backbone 而不是直接上 Transformer细粒度分类这两年确实有一堆 ViT 方案但落到实际能跑、能改、显存吃得消这个层面ResNet 仍然是性价比最高的起点。StanfordDogs 训练集只有 12000 张这个量级上纯 Transformer 很容易过拟合而且预训练权重和微调策略都比 ResNet 麻烦。ResNet 的残差结构保证了深了也能训torchvision 里直接有 ImageNet 预训练权重换数据集时把最后的 fc 层一改就能用。CBAM 的插入位置是这套方案的核心决策点。CBAM 论文里给的建议是插在每个 Bottleneck 的残差相加之后、激活之前。我实际试下来插在BasicBlockResNet18/34和BottleneckResNet50 及以上里的效果差异不大但插的位置有讲究插入位置效果训练速度推荐度每个 block 的残差相加后最好慢 15% 左右推荐只在每个 stage 末尾插一个一般几乎不变不推荐插在 stem 之后差快不推荐原因是 CBAM 需要足够的通道数才能算出有意义的通道注意力stem 之后只有 64 通道注意力图太粗糙。每个 block 都插等于让网络在每个尺度上都重新校准一次。2.2 CBAM 的两个子模块通道注意力和空间注意力CBAM 拆开就是 CAMChannel Attention Module和 SAMSpatial Attention Module串联。CAM 的做法是同时对特征图做全局平均池化和全局最大池化各过一个共享的 MLP加起来过 sigmoid。这里有个容易翻车的点MLP 的降维比例reduction默认是 16但如果你 backbone 通道数本来就小比如 ResNet18 第一层只有 64降到 4 就太狠了信息丢太多。我一般会把 reduction 设成max(8, channels // 16)。SAM 是在通道维度上做平均池化和最大池化拼成 2 通道过一个 7x7 卷积再 sigmoid。7x7 这个感受野是论文定的我试过 3x3 和 5x5在 StanfordDogs 上 7x7 确实略好但差距在 0.3% 以内显存紧张的话换 3x3 也能接受。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction16): super().__init__() # 自适应降维避免小通道数被压太狠 hidden max(8, channels // reduction) self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) self.mlp nn.Sequential( nn.Conv2d(channels, hidden, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(hidden, 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) class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__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 torch.cat([avg_out, max_out], dim1) return self.sigmoid(self.conv(x)) class CBAM(nn.Module): def __init__(self, channels, reduction16, kernel_size7): super().__init__() self.ca ChannelAttention(channels, reduction) self.sa SpatialAttention(kernel_size) def forward(self, x): x x * self.ca(x) # 通道加权 x x * self.sa(x) # 空间加权 return x这段代码里ChannelAttention的hidden计算是关键参数channels // reduction在通道数小于 128 时会得到很小的值加max(8, ...)是保底。SpatialAttention的kernel_size控制空间注意力的感受野7 是论文默认值。两个模块都是先算权重再乘回原特征不改变特征图尺寸所以可以无缝插进任何卷积块后面。2.3 把 CBAM 塞进 ResNet 的残差块torchvision 的 ResNet 源码里BasicBlock和Bottleneck的 forward 都是out identity; out relu(out)。我们要改的就是在out identity之后、relu之前插入 CBAM。最干净的做法是继承原 block 类重写 forward而不是去改 torchvision 源码。import torch.nn as nn from torchvision.models.resnet import BasicBlock, Bottleneck, ResNet class CBAMBasicBlock(BasicBlock): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.cbam CBAM(self.expansion * self.out_channels if hasattr(self, out_channels) else 64) 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 identity out self.cbam(out) # 残差相加后做注意力 out self.relu(out) return out这里有个坑BasicBlock的out_channels属性在旧版 torchvision 里不一定存在稳妥做法是在构造时显式传入通道数。实际工程里我一般直接写一个ResNetWithCBAM类从零组装 layer而不是继承改这样通道数完全可控。下面这个组装方式更稳def make_cbam_resnet(archresnet50, num_classes120, pretrainedTrue): from torchvision import models base getattr(models, arch)(pretrainedpretrained) # 替换每个 bottleneck 为带 CBAM 的版本 for layer_name in [layer1, layer2, layer3, layer4]: layer getattr(base, layer_name) for i, block in enumerate(layer): channels block.conv3.out_channels if hasattr(block, conv3) else block.conv2.out_channels block.cbam CBAM(channels) # 用闭包重写 forward def new_forward(self, x, _blockblock): identity x out _block.conv1(x); out _block.bn1(out); out _block.relu(out) if hasattr(_block, conv2): out _block.conv2(out); out _block.bn2(out) if hasattr(_block, conv3): out _block.relu(out) out _block.conv3(out); out _block.bn3(out) out identity out _block.cbam(out) out _block.relu(out) return out block.forward new_forward.__get__(block, type(block)) base.fc nn.Linear(base.fc.in_features, num_classes) return base这段代码用闭包给每个 block 动态绑定了新的 forward_blockblock是为了避免循环变量晚绑定。channels的取法区分了 BasicBlockconv2和 Bottleneckconv3。base.fc换成num_classes输出StanfordDogs 是 120 类。换成其他数据集时只需要改num_classesbackbone 部分完全不用动。3. 数据准备和训练流程从 StanfordDogs 到任意数据集3.1 StanfordDogs 的目录结构和 Dataset 写法StanfordDogs 原始格式是.mat标注加按品种分文件夹的图片但网上流传的版本大多已经整理成train/val/test加类别子文件夹的 ImageFolder 格式。如果你拿到的是原始格式需要先转一道。我一般直接用ImageFolder前提是目录长这样data/ train/ n02085620-Chihuahua/ n02085782-Japanese_spaniel/ ... val/ ...如果只有 train 和 test就从 train 里切 15% 做验证。切分脚本用splitfolders或者手写都行手写更可控import os, shutil, random from pathlib import Path def split_train_val(src_dir, dst_dir, val_ratio0.15, seed42): random.seed(seed) src Path(src_dir) for cls_dir in src.iterdir(): if not cls_dir.is_dir(): continue imgs list(cls_dir.glob(*.jpg)) list(cls_dir.glob(*.png)) random.shuffle(imgs) n_val int(len(imgs) * val_ratio) for split, files in [(val, imgs[:n_val]), (train, imgs[n_val:])]: out Path(dst_dir) / split / cls_dir.name out.mkdir(parentsTrue, exist_okTrue) for f in files: shutil.copy(f, out / f.name)val_ratio设 0.15 是经验值StanfordDogs 每类大概 150-200 张15% 大约 25 张做验证够看趋势了。seed固定保证可复现。换成其他数据集时这个脚本不用改只要源目录是「类别子文件夹」结构就行。Dataset 和 DataLoader 部分训练时用RandomResizedCrop(224)、RandomHorizontalFlip、ColorJitter验证时用Resize(256) CenterCrop(224)。归一化用 ImageNet 的均值和方差因为 backbone 是 ImageNet 预训练的。from torchvision import transforms, datasets from torch.utils.data import DataLoader train_tf transforms.Compose([ 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]) ]) train_ds datasets.ImageFolder(data/train, train_tf) val_ds datasets.ImageFolder(data/val, val_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)RandomResizedCrop的scale(0.7, 1.0)比默认的(0.08, 1.0)更保守因为细粒度分类里把狗裁得只剩一只眼睛反而有害。batch_size32在 8G 显存上跑 ResNet50CBAM 刚好再大就要降分辨率或者用梯度累积。3.2 训练循环和三个必调参数训练循环本身没什么特别但有三个参数直接决定这套方案能不能跑出效果参数推荐值作用调错后果初始学习率1e-3fc 层/ 1e-4backbone控制微调幅度太大预训练权重被冲掉太小收敛慢权重衰减1e-4抑制过拟合太大欠拟合太小验证集掉点学习率调度CosineAnnealingLR后期精细收敛用 StepLR 容易在台阶处震荡分层学习率是这套方案的关键技巧。backbone 是预训练的fc 是随机初始化的两者用同一个学习率必然有一方不合适。我一般给 backbone 1e-4、fc 1e-3训练 30 个 epoch前 5 个 epoch 冻结 backbone 只训 fc之后再解冻。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model make_cbam_resnet(resnet50, num_classes120).cuda() criterion nn.CrossEntropyLoss(label_smoothing0.1) # 分层学习率 backbone_params [p for n, p in model.named_parameters() if fc not in n] fc_params [p for n, p in model.named_parameters() if fc in n] optimizer optim.AdamW([ {params: backbone_params, lr: 1e-4}, {params: fc_params, lr: 1e-3} ], weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30) for epoch in range(30): model.train() if epoch 5: # 前 5 epoch 冻结 backbone for p in backbone_params: p.requires_grad False else: for p in backbone_params: p.requires_grad True 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() # 验证 model.eval() correct total 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.cuda(), labels.cuda() pred model(imgs).argmax(1) correct (pred labels).sum().item() total labels.size(0) print(fepoch {epoch}: val_acc{correct/total:.4f})label_smoothing0.1在细粒度分类上很有用因为有些样本本身标注就有歧义比如哈士奇和阿拉斯加混血。AdamW比Adam的权重衰减实现更正确配合CosineAnnealingLR在 30 epoch 内能稳定收敛。前 5 epoch 冻结 backbone 是为了让 fc 先找到一个合理起点避免随机初始化的 fc 产生大梯度把预训练特征带偏。4. 避坑与排查CBAM 训崩的四种典型情况4.1 验证集准确率不升反降现象加了 CBAM 之后训练集 loss 正常下降但验证集准确率比不加 CBAM 的纯 ResNet 还低 2-3 个点。原因CBAM 引入了额外参数在小数据集上过拟合了。StanfordDogs 训练集只有 12000 张CBAM 的 MLP 和 7x7 卷积加起来参数量不小如果数据增强不够强网络会记住训练集的注意力模式。解决把ColorJitter的强度调大加RandomErasing同时把weight_decay从 1e-4 提到 5e-4。如果还不行把 CBAM 的reduction从 16 改成 8减少 MLP 参数量。我遇到过最极端的情况是数据集只有 3000 张最后只在 layer3 和 layer4 插 CBAM 才稳住。4.2 训练 loss 直接变 NaN现象第一个 epoch 跑了几十个 batch 后 loss 突然变 NaN梯度爆炸。原因CBAM 的 sigmoid 输出在 0 到 1 之间乘回特征图后整体数值会变小如果后面接的 BN 层没跟上累积几层后数值下溢。另一个可能是学习率对 CBAM 的新增参数来说太大了。解决给 CBAM 模块单独设更小的学习率或者把 backbone 的初始学习率从 1e-4 降到 5e-5。同时在 CBAM 的 forward 里加一个x x * self.ca(x)之后检查一下数值范围必要时在 CBAM 后面补一个 BN。我一般会在 CBAM 的__init__里加self.bn nn.BatchNorm2d(channels)forward 最后return self.bn(x)这样最稳。4.3 显存不够batch_size 只能开到 8现象ResNet50CBAM 在 8G 显存上 batch_size 开到 16 就 OOM。原因CBAM 的 SAM 里那个 7x7 卷积在 7x7 特征图上计算量不大但在 56x56 的 layer1 输出上2 通道 7x7 卷积的中间激活不小。加上每个 block 都插累积起来显存涨了 20% 左右。解决把 SAM 的kernel_size从 7 改成 3显存能降 8% 左右。或者只在 layer3、layer4 插 CBAMlayer1、layer2 不插显存降 15%精度只掉 0.5%。再不行就用torch.cuda.amp混合精度batch_size 能翻倍。4.4 换数据集后类别数改了但模型没变现象把num_classes从 120 改成 10训练时 loss 正常但预测全是同一类。原因make_cbam_resnet里base.fc nn.Linear(base.fc.in_features, num_classes)这行确实改了输出维度但如果加载了之前保存的 checkpointload_state_dict会因为 fc 层形状不匹配报错有人用strictFalse跳过结果 fc 层还是随机初始化的旧形状输出维度对不上。解决换数据集时不要加载旧 checkpoint 的 fc 层只加载 backbone 部分。或者干脆重新初始化 fc用model.fc nn.Linear(model.fc.in_features, new_num_classes)显式重建。加载权重时用state_dict {k: v for k, v in ckpt.items() if fc not in k}过滤掉 fc 层。5. 进阶技巧用 Grad-CAM 验证 CBAM 到底学到了什么训练完模型怎么确认 CBAM 真的在「看狗」而不是「看背景」最直接的办法是可视化注意力图。Grad-CAM 能生成热力图叠加在原图上看模型关注区域是否落在狗的头部、躯干这些判别性部位。如果热力图集中在背景草地或者图片角落说明 CBAM 没学到东西需要检查插入位置或者数据增强。import cv2 import numpy as np import torch.nn.functional as F def grad_cam(model, img_tensor, target_layer, class_idxNone): model.eval() features [] grads [] def hook_fwd(m, i, o): features.append(o) def hook_bwd(m, gi, go): grads.append(go[0]) h1 target_layer.register_forward_hook(hook_fwd) h2 target_layer.register_full_backward_hook(hook_bwd) out model(img_tensor.unsqueeze(0).cuda()) if class_idx is None: class_idx out.argmax(1).item() model.zero_grad() out[0, class_idx].backward() fmap features[0].detach().cpu().numpy()[0] grad grads[0].detach().cpu().numpy()[0] weights grad.mean(axis(1, 2)) cam np.zeros(fmap.shape[1:], dtypenp.float32) for i, w in enumerate(weights): cam w * fmap[i] cam np.maximum(cam, 0) cam cv2.resize(cam, (224, 224)) cam (cam - cam.min()) / (cam.max() 1e-8) h1.remove(); h2.remove() return camtarget_layer一般选model.layer4[-1]这是最后一层卷积输出空间分辨率 7x7热力图最粗糙但语义最强。如果想看更细的定位选model.layer3[-1]14x14 分辨率。register_full_backward_hook在 PyTorch 1.8 之后才有旧版本用register_backward_hook但行为有差异。生成的热力图用cv2.applyColorMap上色后和原图按 0.5 权重叠加就能直观看到 CBAM 把权重加在了哪里。我自己的习惯是每训完一个版本就抽 20 张验证集图片跑一遍 Grad-CAM如果热力图明显比不加 CBAM 的版本更集中在狗身上说明这个模块起作用了如果热力图反而更散那多半是过拟合得回去调正则化。这个验证步骤花不了几分钟但能省掉很多盲目调参的时间。希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案