☰
从AlexNet到ResNet-18:CUB-200鸟类识别微调实践
2026/10/6 14:50:29 网站建设 项目流程

简介:这是一份基于卷积神经网络的鸟类识别实验报告,面向机器学习、深度学习方向的科研人员、学生及技术人员,帮助读者掌握细粒度图像分类模型的构建与调优方法。报告以AlexNet和ResNet-18为主干网络,详细展示了将输出层修改为200类、加载ImageNet预训练权重并微调的关键步骤,同时围绕CUB-200-2011数据集介绍了训练集/验证集/测试集划分策略。文中呈现了训练过程中的Loss和Accuracy变化曲线,并对使用预训练权重与随机初始化、不同学习率及训练步数等情形进行了对比分析,有助于理解过拟合、收敛速度与超参数选择之间的关系。压缩包内仅含1个docx文档,大小1.36MB,结构紧凑、阅读方便,既可作为课堂教学的补充材料,也能作为初学者动手实践的入门项目。目前已有180人学习,适合需要从理论到实验全面了解CNN图像识别流程的读者。

1. 鸟类识别实验:从 CNN 选型到 CUB-200-2011 微调全流程

做细粒度图像分类的人都知道,鸟类识别是这类任务里最磨人的一个:200 个类别,很多鸟外形几乎一样,区别只在翅膀上一撮羽毛的颜色或者嘴型的弧度。我拿到这份基于卷积神经网络的鸟类识别实验报告时,最关心的是它怎么处理这种「类间差异极小」的问题。实验用了 AlexNet 和 ResNet-18 两种经典卷积神经网络结构,在 CUB-200-2011 数据集上做预训练权重微调,最终把 200 类鸟类识别做到了可用的精度。这份报告适合正在学深度学习、想找一个完整 CNN 图像分类练手项目的学生,也适合要快速搭建细粒度分类基线、想参考别人参数设置的研究人员。下面按我自己的复现路径拆解:网络怎么改、数据怎么喂、训练怎么调、坑在哪儿。

2. 网络结构选型:AlexNet 与 ResNet-18 的差异在哪里

细粒度分类任务的网络选择,不是随便挑一个 CNN 就能出效果的。实验里选 AlexNet 和 ResNet-18 这两个结构,背后是有明确考量的:一个代表经典全连接主导的设计,一个代表残差结构主导的设计,两者在参数量、感受野和信息传递方式上差别很大,正好能用来对比迁移学习的效果基线。

2.1 两种卷积神经网络结构图背后的设计逻辑

AlexNet 的卷积神经网络结构图,核心是 5 个卷积层加 3 个全连接层,卷积层通道数从 96 一路扩到 384 再回到 256,最后接 4096 维的全连接层,整个网络参数量在 6000 万左右,输入要求是 227×227 的 RGB 图像。这个结构放在 2012 年是碾压级的,但放到细粒度任务上,问题在于全连接层占掉了绝大多数参数,而全连接层对「局部细微差异」的建模能力不如卷积层直接。

ResNet-18 的卷积神经网络结构图就完全不同了:它由 4 个 stage 的 BasicBlock 堆叠而成,每个 stage 经历一次空间下采样,通道数从 64 翻到 128、256、512,参数量只有约 1100 万,输入 224×224。残差连接让梯度可以跨层直接回传,网络可以做得更深而不退化。在鸟类识别这种任务上,深层的特征图能捕捉到翅膀纹理、羽毛边界这类高判别性信息,这是 AlexNet 那种「卷积提特征 + 全连接硬分类」的设计做不到的。

做图像分类选型,还有一个基本认知要摆正:一维卷积神经网络通常用来处理时序信号或者序列数据,比如音频、传感器波形;图像是二维数据,天然应该用二维卷积。有些新手把一维卷积的代码硬套到图像上,通道对不上、感受野也完全不是那回事,属于白费力气。本实验用的两个结构都是标准二维卷积,不存在这个问题。

2.2 把输出层从 1000 改成 200:模型改造脚本

两个预训练模型原本都是在 ImageNet 上做 1000 类分类的,输出层的维度是 1000。而 CUB-200-2011 数据集只有 200 个鸟类类别,所以第一步就是替换分类头。常见做法是保留主干特征提取部分,只把最后一层全连接换掉。在 PyTorch 里,这个操作非常直接:

import torchvision.models as models import torch.nn as nn # 加载 ImageNet 预训练权重 alexnet = models.alexnet(weights=models.AlexNet_Weights.IMAGENET1K_V1) # 替换最后一层:原输出 1000 -> 200 alexnet.classifier[6] = nn.Linear(4096, 200) # 同样的方式处理 ResNet-18 resnet18 = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 替换全连接层 resnet18.fc = nn.Linear(resnet18.fc.in_features, 200)

这里有几个关键点要说明。classifier[6]是 AlexNet 的 Sequential 容器里第 7 个模块,也就是最后的全连接层,in_features自动读取前一层的 4096 维输出,不用手写死。对 ResNet-18,它的分类头叫fc,原来是Linear(512, 1000),替换成Linear(512, 200)即可。替换完之后,新的输出层参数是随机初始化的,而主干网络的参数仍然是 ImageNet 上训练好的。实验报告里明确说了「对于新的输出层,我们从头开始训练,并使用较小的学习率对预训练的参数进行微调」,这句话就是迁移学习的标准操作:新层学得快,主干学得慢。

替换输出层之后建议先做一步验证:打印model.fc.out_features确认是 200,再跑一次 1×3×224×224 的假输入做 forward 测试,防止维度不匹配的问题在训练中途才爆出来。我见过不少人在这一步翻车,以为是代码问题,查了半天发现是预训练权重版本和模型结构对不上。

2.3 预训练权重初始化:为什么不建议随机初始化

细粒度数据集有个天然劣势:数据量太小。CUB-200-2011 总共才 11788 张图片,200 类平均每类不到 60 张。这点数据量,让一个 6000 万参数的 AlexNet 从零开始学 200 类的细微差异,结果必然是欠拟合或者过拟合二选一。而 ImageNet 预训练权重已经让网络学会了通用的视觉特征——边缘、纹理、形状、颜色分布,这些底层特征在鸟类识别里同样有效。

实验报告给出的对比结果也验证了这一点:使用预训练权重进行微调的方法,在性能和收敛速度方面都显著优于从头开始训练的方法。收敛速度的提升尤其明显,因为初始化的特征已经不是随机噪声了,新的输出层只需要在已有特征和 200 个类别之间建立映射关系,训练初期 loss 就能快速下降。从头训练的话,前几十个 epoch 网络都在摸索底层特征,loss 曲线会拖得很长。

手工初始化输出层时,我一般会单独设置一个kaiming_normal初始化,因为随机初始化的输出层在第一个 batch 的梯度可能会很大,把主干网络刚微调好的权重冲乱。实验报告里没有细讲这一步,但实际复现时值得加上:

def init_output_layer(model): for name, param in model.named_parameters(): if 'classifier.6' in name or 'fc' in name: if param.dim() >= 2: nn.init.kaiming_normal_(param)

这段逻辑是按参数名匹配输出层的权重,用 Kaiming 初始化把新层的初始分布拉到一个合理的方差范围,避免前向传播时信号过大或过小。常见做法是只对新层做初始化,主干参数保持预训练状态不动。

两个网络的选型对比,可以整理成一张表方便后面调参时查阅:

网络参数量输入尺寸卷积结构全连接层细粒度任务表现
AlexNet约 6000 万227×2275 层卷积3 层全连接基线水平,泛化一般
ResNet-18约 1100 万224×2244 stage 残差块1 层全连接精度更高,收敛更快

3. 数据集划分与 DataLoader:CUB-200-2011 的读入姿势

网络结构定好了,接下来最关键的是数据怎么进模型。CUB-200-2011 不是那种下下来就能直接喂给torchvision.datasets.ImageFolder的标准分类目录,它带了一套自己的标注文件系统。如果直接按目录结构去读,大概率会漏掉边界框和属性信息,而这恰恰是细粒度识别最值钱的标注。

3.1 CUB-200-2011 数据集的标注文件和目录结构

这份数据集是加州理工学院 2010 年发布的,224 MB 左右,解压后主要包含images目录和四个关键文本文件。images目录下是 200 个子文件夹,每个文件夹对应一种鸟,文件夹名是类别 ID 加英文名。但四个文本文件才是真正的入口:

文件内容格式
images.txt全部图片文件名图片ID + 文件名
image_class_labels.txt每张图片的类别标签图片ID + 类别ID(1-200)
bounding_boxes.txt每张图片的边界框图片ID + x + y + 宽 + 高
attributes.txt每张图片的属性标注图片ID + 属性ID + 是否具备
train_test_split.txt官方划分的图片图片ID + 是否为训练集

attributes.txt 里有 312 个二值属性,比如「翅膀主色是蓝色」「嘴型是尖锥形」,这些是细粒度分类的额外监督信号。实验报告里提到「每张图片都经过了精细的标注,包括鸟类的边界框和属性等」,说的就是这套东西。

有一点和实验报告描述不完全一致的地方需要注意:报告写「每种鸟类大约有 60 张图片」,这是平均值。实际上 CUB-200-2011 的类别分布并不完全均匀,有的类有 70 多张,有的类只有 40 多张。处理类别不平衡时,这个差异会体现出来。

读取这套标注文件的标准姿势是先把所有文本文件按图片 ID 关联起来,合成一个元数据表。常见做法是写一个小工具函数:

import pandas as pd def load_cub_metadata(root_dir): # 读取图片列表与类别标签 images_df = pd.read_csv(f'{root_dir}/images.txt', sep=' ', header=None, names=['img_id', 'filename']) labels_df = pd.read_csv(f'{root_dir}/image_class_labels.txt', sep=' ', header=None, names=['img_id', 'label']) bbox_df = pd.read_csv(f'{root_dir}/bounding_boxes.txt', sep=' ', header=None, names=['img_id', 'x', 'y', 'w', 'h']) split_df = pd.read_csv(f'{root_dir}/train_test_split.txt', sep=' ', header=None, names=['img_id', 'is_train']) # 合并:通过 img_id 关联 meta = images_df.merge(labels_df, on='img_id') \ .merge(bbox_df, on='img_id') \ .merge(split_df, on='img_id') meta['label'] = meta['label'] - 1 # 标签从 1 开始,转为 0-199 return meta

这段代码做的事很朴素:四个文件都按图片 ID 对齐,合成一张宽表。label减 1 是把类别 ID 从 1-based 转成 0-based,这也是 PyTorch 的CrossEntropyLoss期望的标签格式。训练集和测试集的划分在train_test_split.txt里已经有了,但如果你像我一样想自定义训练/验证集比例,可以忽略这一列,自己重新划分。

3.2 按类别做分层划分,而不是随机划分

实验报告说「我们将数据集分为训练集、验证集和测试集」,但没细说划分方法。网上很多复现直接random_split一把梭,这在普通分类任务上问题不大,但在细粒度任务上会埋一个坑:随机划分可能让某个类别在验证集里一条数据都没有,或者训练集里某个类只有三五张图。200 个类别,每个类别样本量本来就少,任何一类的缺失都会让验证指标失真。

正确的做法是按类别做分层抽样,保证每个类别在训练集、验证集、测试集中的比例一致。我用的是 scikit-learn 的StratifiedShuffleSplit:

import numpy as np from sklearn.model_selection import StratifiedShuffleSplit meta = load_cub_metadata('CUB_200_2011') labels = meta['label'].values # 第一次划分:训练集 60%,临时集 40% sss1 = StratifiedShuffleSplit(n_splits=1, test_size=0.4, random_state=42) train_idx, temp_idx = next(sss1.split(np.zeros(len(labels)), labels)) # 第二次划分:临时集拆成验证集和测试集,各占 20% temp_labels = labels[temp_idx] sss2 = StratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=42) val_idx, test_idx = next(sss2.split(np.zeros(len(temp_labels)), temp_labels)) val_idx, test_idx = temp_idx[val_idx], temp_idx[test_idx]

这里test_size=0.4的意思是临时集占总样本的 40%,第二次划分时再把这 40% 对半拆成验证集和测试集,最终比例是 6:2:2。random_state=42固定随机种子,保证每次复现划分结果一致。分层抽样确保 200 个类别在每个子集中的相对比例与全集一致,这样训练时每个类都有足够样本,验证时每个类都能被监控到。

实际使用中,如果你不想写这套划分逻辑,也可以直接用官方train_test_split.txt里的划分,然后把训练集再按 9:1 拆出验证集。但分层划分的逻辑适用于任何细粒度数据集,值得写一遍。

3.3 图像预处理:先按 bbox 裁剪再增强

CUB-200-2011 的原图大多是一整张带复杂背景的照片,鸟可能只占画面中心的一小块区域。如果直接整图 resize 到 224×224,背景像素会淹没鸟本身的特征,模型很容易学到「背景是绿色就是这种鸟」这种错误关联。实验报告可视化部分展示的 bounding_boxes 预测结果说明边界框信息是实验明确使用的,所以在预处理阶段就应该利用它。

我通常会先按照 bounding box 把鸟的区域裁出来,再 resize 到网络输入尺寸,最后做数据增强:

from torchvision import transforms class BBoxCrop: def __init__(self, meta): self.bbox = {row.img_id: (row.x, row.y, row.w, row.h) for row in meta.itertuples()} def __call__(self, img, img_id): x, y, w, h = self.bbox[img_id] # 边界框越界时做截断,防止裁剪区域超出图像范围 x, y = max(0, int(x)), max(0, int(y)) return img.crop((x, y, x + int(w), y + int(h))) train_transform = transforms.Compose([ BBoxCrop(meta), transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ BBoxCrop(meta), transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

BBoxCrop先按标注框裁出鸟的主体,这一步对细粒度识别的影响极大。RandomResizedCrop的scale=(0.8, 1.0)做了随机缩放裁剪,相当于轻度模仿不同拍摄距离下的尺度变化。ColorJitter的三个分量都设为 0.2,让颜色特征不至于被模型当成唯一判别依据。Normalize 的均值和方差用的是 ImageNet 的统计值,因为主干网络是在 ImageNet 上预训练的,输入分布对齐后特征提取才不会被扭曲。

验证集就不用随机增强了,只做 CenterCrop 取中心区域,保证评估时结果可复现。

4. 微调训练配置:学习率、衰减策略与收敛判断

网络结构改完了,数据集也喂得进去了,剩下的就是训练过程本身。这一章是最容易出「看起来在训练、实则没学到东西」的阶段。实验报告里有一段话值得反复读:「过大的学习率可能导致模型在训练过程中发生震荡,而过小的学习率则可能导致模型收敛速度过慢」——这句话是实验结果里最直接的经验总结。

4.1 预训练微调 vs 从头训练的性能鸿沟

实验报告对比了「仅使用 CUB-200-2011 数据集从随机初始化的网络参数开始训练的结果」和「使用预训练权重进行微调的结果」,结论是预训练在性能和收敛速度两方面都显著占优。这个结论不意外,但背后的原因值得说透。

CUB-200-2011 只有 11788 张图,随机初始化意味着网络连「边缘」和「纹理」这种最基础的特征都要从头学。一个 6000 万参数的 AlexNet 在这点数据上拟合 200 个细分类别,几乎必然过拟合。预训练权重则把底层特征层变成了「现成的特征提取器」,微调阶段只需要调整高层的语义信息,把 ImageNet 的 1000 类抽象映射到 CUB 的 200 类具体鸟种。这也是为什么实验里预训练模型在训练初期 loss 下降速度明显更快——它起步的位置已经接近局部最优了。

按我的经验,预训练模型的收敛 epoch 数大约是随机初始化模型的四分之一到三分之一。也就是说,如果随机初始化要跑 80 个 epoch 才能到 60% 精度,预训练微调大概 20 个 epoch 就能到 75% 以上,而且继续训练还能往上走。

4.2 参数设置与训练循环

实验报告没有给出具体的超参数数值,只说「尝试了不同的训练步数、学习率以及学习率衰减策略」。我按细粒度分类的常见配置补一套能直接跑通的参数:优化器用 SGD + momentum,学习率主干 1e-4、输出层 1e-3,权重衰减 1e-4,批次大小 32,训练 60 个 epoch,学习率在第 30 和第 50 个 epoch 各衰减一次到原来的 0.1。输出层的学习率比主干大 10 倍,因为它是随机初始化的新层,学得快;主干已经有预训练基础,学太猛会把原有特征破坏掉。

训练循环的核心代码如下:

import torch import torch.optim as optim # 分组设置学习率:输出层 1e-3,其余层 1e-4 resnet18.fc = nn.Linear(512, 200) optimizer = optim.SGD([ {'params': [p for n, p in resnet18.named_parameters() if 'fc' not in n], 'lr': 1e-4}, {'params': resnet18.fc.parameters(), 'lr': 1e-3} ], momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1) criterion = nn.CrossEntropyLoss() for epoch in range(60): resnet18.train() total_loss = 0.0 for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() outputs = resnet18(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() # 每 5 个 epoch 在验证集上评估一次 if epoch % 5 == 0: resnet18.eval() correct = 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() preds = resnet18(imgs).argmax(dim=1) correct += (preds == labels).sum().item() val_acc = correct / len(val_loader.dataset) print(f'Epoch {epoch}: loss={total_loss / len(train_loader):.4f}, val_acc={val_acc:.4f}')

PyTorch 的optimizer支持按参数组设置独立学习率,named_parameters()按名称匹配fc来区分输出层和主干层,这是微调任务的标准写法。StepLR(step_size=30, gamma=0.1)表示每 30 个 epoch 把学习率乘 0.1,让训练后期用更小的步长做精细调优。CrossEntropyLoss内部已经包含了 softmax 操作,所以模型输出的 logits 直接喂进去就行,不要再手动加一次 softmax。

训练过程中每 5 个 epoch 做一次验证,目的是监控模型有没有过拟合。验证时一定记得切换成eval()模式,把 dropout 和 batch normalization 的行为切换到推理状态,否则验证集指标会偏高或偏低不稳定。

4.3 loss 曲线和 accuracy 曲线怎么读

实验报告用可视化方式展示了训练过程,文字描述是「loss 曲线随着训练步数的增加而逐渐下降,前期快、后期慢;accuracy 逐渐提高并趋于稳定」。这两句话放在任何深度学习训练里都对,但真正会读图的人看的不是整体趋势,而是具体形态。

loss 曲线前期快速下降,说明预训练特征正在有效迁移;后期缓慢下行是正常的,因为网络在做精细调整。但如果 loss 曲线在第 10 个 epoch 之后出现「断崖式下降再回升」,大概率是学习率太大导致参数越过最优点,在振荡中回升。如果 loss 曲线基本水平,验证集 accuracy 也没显著提升,那就是学习率太小,训练陷入停滞。

验证集 accuracy 曲线后期趋平有两种可能:一是模型确实收敛到了性能上限,二是已经过拟合,继续训练只会让训练集精度继续上升、验证集精度不再变化甚至下降。分辨两者的方法很简单——把训练集精度也算出来,对比两条曲线。如果训练集精度接近 100% 而验证集停在 70%,过拟合实锤;如果两条曲线都停在相近位置,那就是模型容量到顶了,该换网络或者加数据增强。

5. 避坑:细粒度训练里最容易翻车的四个现场

细粒度分类的坑和普通分类任务不太一样,普通任务翻车大多是数据预处理的问题,细粒度则更多出在「模型根本没在学目标特征」和「训练过程不稳定」这两类问题上。下面四个场景都是我复现时真实遇到过的,按「现象 → 原因 → 解决」逐个说。

5.1 数据侧的坑:精度卡死不下降,问题出在输入上

第一个现象很典型:训练了 20 个 epoch,验证集 accuracy 一直卡在 50% 上下,loss 也降不下去。一开始我以为是网络结构改错了,后来发现原因很简单——我没有先用 bbox 裁剪,直接把整张原图 resize 到 224×224 送进网络。CUB 的原始图片背景占比太高,有些鸟在图里只占 30% 的像素,背景的树干、草地、天空占了大部分,模型学到的全是「有蓝天就是某种鸟」的捷径,根本没在学鸟本身。解决方法是回到第 3.3 节的BBoxCrop,先按标注框裁剪出鸟主体再送进增强管线。这一步改完之后,同样参数下验证集精度立刻跳了 10 个点以上。

第二个现象是某几个类别在验证集上 accuracy 恒为 0,而且每次固定是那几个类。查数据后发现,CUB-200-2011 虽然官方说每类约 60 张,但实际个别类别只有 40 张出头,训练集按 6:2:2 划分后只剩 25 张左右,其中还有几张被随机裁剪增强后变得几乎不可辨认,模型对这几个类的特征覆盖不足。解决方法是给 DataLoader 按类别权重做加权采样,让样本少的类别在每个 epoch 里被采样更多次。具体做法是用WeightedRandomSampler,权重设置为1 / 类别样本数,这样样本量少的类每轮被抽到的概率更高。

5.2 训练侧的坑:loss 变 nan 和验证集不涨

第三个现象是训练到中途 loss 突然变成nan,精度直接清零。原因是新替换的输出层参数方差过大,加上初始学习率较高,前向传播经过最后一个全连接层时数值溢出。输出层Linear(512, 200)默认初始化是按均匀分布取的,方差跟输入维度有关,如果主干网络输出的特征值偏大,几个 batch 之后梯度爆炸就发生了。解决方法是给输出层单独做 Kaiming 初始化,或者把输出层的学习率单独调小到 5e-4。我习惯在替换完输出层之后先用一个小 batch 跑一次 forward 和 backward,观察 loss 是否是有限值,有异常趁早调,别等训练到第 20 个 epoch 才爆。

第四个现象是训练集 loss 持续下降但验证集 accuracy 纹丝不动。这种情况我先看训练集 accuracy,如果训练集已经超过 95% 而验证集只有 60%,就是过拟合。CUB 每类样本太少,模型参数又多,过拟合几乎是必然趋势。解决方式按优先级排:先把weight_decay从 1e-4 提高到 5e-4;再把RandomResizedCrop的scale下限从 0.8 降到 0.5,增强尺度多样性;最后加上Mixup增强,alpha 取 0.2,把不同类别的样本按比例混合,让模型不能只记住训练样本的精确外观。如果这三步做完验证集还是不动,那就不是过拟合问题,而是模型容量不够,应该换 ResNet-50 而不是继续调参。

6. 进阶验证:用 Grad-CAM 看模型到底在关注什么

训练完模型、精度也达标了,但「精度高」不等于「模型学会了正确答案」。细粒度分类有个经典风险:模型可能靠背景线索把类别分对了,而不是靠鸟本身的特征。要验证这一点,最直接的方法是用 Grad-CAM 对验证集图片做热力图可视化,看模型的注意力集中在哪里。

Grad-CAM 的核心逻辑很简单:取网络最后一个卷积层的输出特征图,用类别得分对特征图通道做梯度加权,得到一个空间热力图。热力图亮的位置就是模型做出该分类决策时重点关注的区域。对于 200 类鸟类识别,如果热力图的高亮区域集中在鸟的头部、翅膀、腹部,说明模型学到了生物特征;如果高亮跑到背景的树枝或者地面上,说明模型捡了便宜,这模型换个背景就会失效。

实现一个简化的 Grad-CAM 不需要复杂的封装,PyTorch 的 hook 机制几十行就能搞定:

import torch import torch.nn.functional as F def grad_cam(model, img_tensor, label): model.eval() features = {} gradient = {} # 注册 hook:捕获最后一个卷积层的输出和梯度 def forward_hook(module, input, output): features['value'] = output def backward_hook(module, grad_input, grad_output): gradient['value'] = grad_output[0] target_layer = model.layer4[-1] # ResNet-18 最后一个 BasicBlock handler_f = target_layer.register_forward_hook(forward_hook) handler_b = target_layer.register_full_backward_hook(backward_hook) output = model(img_tensor.unsqueeze(0)) score = output[0, label] model.zero_grad() score.backward() weights = gradient['value'].mean(dim=(2, 3), keepdim=True) # 全局平均池化 cam = (weights * features['value']).sum(dim=1, keepdim=True) cam = F.relu(cam).squeeze().detach().cpu().numpy() # 归一化到 0-1 用于叠加显示 cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-6) handler_f.remove() handler_b.remove() return cam

register_forward_hook拿最后卷积层的输出特征图,register_full_backward_hook拿反向传播时的梯度。梯度的全局平均池化得到每个通道的权重,乘上特征图再求和,就得到类的空间响应。relu只保留正响应,负响应对分类决策没有语义贡献。unsqueeze(0)把单张图片扩成 batch 维度,前后向结束后记得移除 hook,避免内存累积。

做可视化时我一般会抽查每一个类别的 5 张验证集图片,统一观察热力图分布。如果某个类别的高亮区域明显偏移到背景,就把这类单独拉出来看原始图和 bbox 是否对齐,必要时对这个类单独做更重的裁剪增强。

从那以后,我每次跑完细粒度分类实验,都强制自己走一遍 Grad-CAM 可视化,而不仅仅看 accuracy 数字。精度达标但注意力不集中在目标上,这种模型上线等于裸奔——换了个环境就崩。养成这个习惯之后,很多「看起来精度不错但泛化很差」的模型在我这里都会被拦下来重新调。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询