简介:这份资源面向计算机、人工智能、数据科学等专业的在校学生与教师,提供一套基于ResNet主干网络并嵌入CBAM注意力机制的Stanford Dogs犬种识别分类Python源码,可作为课程设计、毕业设计、期末大作业或项目初期立项的参考实现。压缩包共21个文件,约228KB,以py源码为主,辅以pyc编译文件、md说明文档、sh训练脚本及jpg、png示例图片,涵盖模型定义、注意力模块、数据加载与训练入口等模块,结构清晰便于按需查阅。资源已验证可稳定运行,并预留了替换数据集与二次开发的拓展空间,读者可据此理解注意力机制在细粒度图像分类中的接入方式,掌握训练脚本调用与数据组织流程,并迁移到其他识别分类任务。目前已有383人学习下载,适合具备一定深度学习基础、希望快速上手实战的读者参考使用。
1. ResNet+CBAM 做 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 的残差相加之后、激活之前。我实际试下来,插在BasicBlock(ResNet18/34)和Bottleneck(ResNet50 及以上)里的效果差异不大,但插的位置有讲究:
| 插入位置 | 效果 | 训练速度 | 推荐度 |
|---|---|---|---|
| 每个 block 的残差相加后 | 最好 | 慢 15% 左右 | 推荐 |
| 只在每个 stage 末尾插一个 | 一般 | 几乎不变 | 不推荐 |
| 插在 stem 之后 | 差 | 快 | 不推荐 |
原因是 CBAM 需要足够的通道数才能算出有意义的通道注意力,stem 之后只有 64 通道,注意力图太粗糙。每个 block 都插,等于让网络在每个尺度上都重新校准一次。
2.2 CBAM 的两个子模块:通道注意力和空间注意力
CBAM 拆开就是 CAM(Channel Attention Module)和 SAM(Spatial 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, reduction=16): 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, bias=False), nn.ReLU(inplace=True), nn.Conv2d(hidden, channels, 1, bias=False) ) 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_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_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) x = torch.cat([avg_out, max_out], dim=1) return self.sigmoid(self.conv(x)) 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): 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(arch='resnet50', num_classes=120, pretrained=True): from torchvision import models base = getattr(models, arch)(pretrained=pretrained) # 替换每个 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, _block=block): 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,_block=block是为了避免循环变量晚绑定。channels的取法区分了 BasicBlock(conv2)和 Bottleneck(conv3)。base.fc换成num_classes输出,StanfordDogs 是 120 类。换成其他数据集时只需要改num_classes,backbone 部分完全不用动。
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_ratio=0.15, seed=42): 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(parents=True, exist_ok=True) 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_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)RandomResizedCrop的scale=(0.7, 1.0)比默认的(0.08, 1.0)更保守,因为细粒度分类里把狗裁得只剩一只眼睛反而有害。batch_size=32在 8G 显存上跑 ResNet50+CBAM 刚好,再大就要降分辨率或者用梯度累积。
3.2 训练循环和三个必调参数
训练循环本身没什么特别,但有三个参数直接决定这套方案能不能跑出效果:
| 参数 | 推荐值 | 作用 | 调错后果 |
|---|---|---|---|
| 初始学习率 | 1e-3(fc 层)/ 1e-4(backbone) | 控制微调幅度 | 太大预训练权重被冲掉,太小收敛慢 |
| 权重衰减 | 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_classes=120).cuda() criterion = nn.CrossEntropyLoss(label_smoothing=0.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_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30) 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(f'epoch {epoch}: val_acc={correct/total:.4f}')label_smoothing=0.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
现象:ResNet50+CBAM 在 8G 显存上 batch_size 开到 16 就 OOM。
原因:CBAM 的 SAM 里那个 7x7 卷积在 7x7 特征图上计算量不大,但在 56x56 的 layer1 输出上,2 通道 7x7 卷积的中间激活不小。加上每个 block 都插,累积起来显存涨了 20% 左右。
解决:把 SAM 的kernel_size从 7 改成 3,显存能降 8% 左右。或者只在 layer3、layer4 插 CBAM,layer1、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)这行确实改了输出维度,但如果加载了之前保存的 checkpoint,load_state_dict会因为 fc 层形状不匹配报错,有人用strict=False跳过,结果 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_idx=None): 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:], dtype=np.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 的版本更集中在狗身上,说明这个模块起作用了;如果热力图反而更散,那多半是过拟合,得回去调正则化。这个验证步骤花不了几分钟,但能省掉很多盲目调参的时间。希望帮到你。
本文还有配套的精品资源,点击获取