☰
图像细粒度分类大作业高分指南:从传统HOG到ResNet微调
2026/10/7 19:08:35 网站建设 项目流程

简介:这是一份数字图像处理期末大作业的图像细粒度分类项目源码,项目经导师指导获评审98分,难度适中,适合计算机视觉方向的学生用于期末大作业、毕业设计或项目实战练手。压缩包共14个文件,整体大小4.76MB,其中4个Python脚本承担数据预处理、BCNN模型搭建与迁移学习训练等核心任务,3个PDF涵盖细分类讲解、结题报告与大作业布置要求,另有PPTX答辩演示、Word文档、模型权重、PNG结果图及说明文档,各类型分工明确,便于对照学习。目前已有53人学习下载。项目提供可直接运行的完整方案,源码均通过本地编译与严格调试,并配有CUB_200_2011数据集解读与README使用说明。读者既能快速掌握细粒度分类的完整流程与关键技术,也能参考报告和PPT的写法,为自身项目提供高分开题与答辩蓝本。

1. 期末大作业里的图像细粒度分类:为什么普通分类思路拿不到 98 分

很多人把期末大作业做成“训练一个模型然后报准确率”,这恰恰是最容易丢分的地方。数字图像处理期末大作业里,图像细粒度分类是比普通物体分类更高一档的题目——普通分类区分的是猫和狗,细粒度分类要区分的是黄鹂和麻雀、别克和雪佛兰、蓝玫瑰和紫玫瑰,类间差异往往只在喙部长度、羽纹走向这种局部细节上,光靠“整体像不像”是分不对的。以我在这类课程项目上的经验看,能把分数顶到 98 分附近的方案,通常不是单个模型有多强,而是从数据划分、特征设计、训练策略到评估可视化,每一步都踩在给分点上。下面按这条链路依次展开:数据集怎么选、预处理怎么做、传统特征路线和深度迁移路线各自怎么落地,以及哪些坑能让你一夜回到 80 分。

2. 数据与预处理:细粒度数据集的选型、划分与增强参数

细粒度分类的第一步不是写模型,而是先确定“拿什么数据来分”。课程大作业和论文实验不一样,论文要的是完整 benchmark,大作业要的是“在有限时间内把故事讲完整”。所以数据集选型的第一原则是:类别不要太少(少于 5 类说服力不足),也不要太多(200 类的混淆矩阵在 A4 纸上根本看不清)。

2.1 数据集选型:CUB-200 还是 Stanford Dogs

公开的细粒度数据集主要有四个,特征差异很大:

数据集类别数总图像数判别难点
CUB-200-2011200(鸟)约 1.18 万姿态多变、背景杂乱、鸟种间差异细微
Stanford Cars196(车)约 1.6 万拍摄角度差异大、同品牌不同型号易混
Stanford Dogs120(狗)约 2 万犬种间形态连续、毛发纹理相似
Oxford Flowers 102102(花)约 8000颜色重叠、花瓣细节区分困难

课程作业不需要用整个数据集。我的做法是从 CUB-200 里抽 10 到 20 个类,每类挑 40 到 80 张,理由有三个:第一,完整 200 类的混淆矩阵在 PDF 里打印出来是糊的,答辩投影也看不清对角线;第二,训练时间控制在 10 到 30 分钟级别,一天能迭代十轮参数实验;第三,老师现场让演示时,小数据集的单张推理只有几秒,体验完全不同。抽类时有个反直觉的讲究:不要挑那些“肉眼一看就完全不同”的类别组合。比如一个类全是绿色鸟、一个类全是红色鸟,模型把背景色学了就分对了,报告的说服力会大打折扣。挑外观相近、只有喙部或翅斑有差异的类别,反而更容易展示细粒度分类的价值。

2.2 目录划分与标签文件:先定规则再写脚本

细粒度公开数据集经常自带官方划分,但不少官方测试集不公开标签(CUB-200-2011 就是典型),拿来做期末作业会卡在评测环节。常见做法是忽略官方划分,自己按分层抽样的方式重新划分,并在报告中注明“按 7:1:2 重新划分”。目录结构最省心的是 PyTorch 的 ImageFolder 约定:train/val/test 三个根目录,每个类别一个子目录。

import os, random, shutil from collections import defaultdict src_root = "raw_images" # 原始数据集根目录 dst_root = "data" # 输出根目录 ratios = {"train": 0.7, "val": 0.1, "test": 0.2} random.seed(42) # 固定随机种子,保证划分可复现 class_items = defaultdict(list) for cls in os.listdir(src_root): cls_dir = os.path.join(src_root, cls) if not os.path.isdir(cls_dir): continue for img in os.listdir(cls_dir): if img.lower().endswith((".jpg", ".jpeg", ".png")): class_items[cls].append(img) random.shuffle(class_items[cls]) for cls, items in class_items.items(): n_train = int(len(items) * ratios["train"]) n_val = int(len(items) * ratios["val"]) for split, chunk in [("train", items[:n_train]), ("val", items[n_train:n_train + n_val]), ("test", items[n_train + n_val:])]: out_dir = os.path.join(dst_root, split, cls) os.makedirs(out_dir, exist_ok=True) for img in chunk: shutil.copy2(os.path.join(src_root, cls, img), os.path.join(out_dir, img))

逻辑说明:按类别收集图片后,在每个类内部打乱再切片,而不是全局打乱。原因是细粒度数据集常有类别张数不均衡的情况,类内划分能保证每个类在三个 split 中都出现,测试集不会出现“某个类一张都没有”的极端情况。随机种子的作用是为复查留后悔药——训练途中发现某张图是坏图,删掉后重新跑脚本,还能复现一模一样的划分,不会出现“这次训练集比上次少了一张”的不可控变化。如果你的数据集类间差异过大,可以在报告里加一段“类别分布统计表”,说明你注意到了不均衡问题。

2.3 预处理与增强:随机裁剪是细粒度任务的性能开关

训练集和验证集的预处理必须分别写,不能共用一个 transform。细粒度分类的判断依据是喙、翅斑、轮毂这类局部区域,如果训练时每张图都保持完整构图,模型会倾向于记住整体形状,一旦验证集里鸟的姿态略有变化就分错。随机裁剪的本质是“每次让模型只看原图的一个局部,迫使它学会用多个局部区域投票决策”。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale=(0.5, 1.0), ratio=(0.75, 1.333)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), 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]) ])

参数说明:Resize(256) 再 Crop 到 224 是 ImageNet 时代留下的惯例,推理侧特征图尺寸主要由裁剪窗口决定,224 是目前性价比最高的输入尺寸。RandomResizedCrop 的 scale=(0.5, 1.0) 是本段最需要改的参数——torchvision 默认值是 (0.08, 1.0),对普通分类没问题,但细粒度任务里裁剪到只剩 8% 区域会把喙或翅斑裁掉大半,让模型学一堆背景。把下限提到 0.5,每张训练图至少保留一半内容。ratio=(0.75, 1.333) 是宽高比扰动范围,让长宽在 3:4 到 4:3 之间浮动,超过这个范围鸟的形变会失真。ColorJitter 的 hue 只能给到 0.05 以内,色调扰动过大等于直接改变物种外观,这在细粒度任务是灾难。

一个容易被“数字图像处理”课程老师追问的点是灰度化。很多课内作业要求展示灰度处理流程,但细粒度分类里羽色、花色是强判别特征,强行灰度化会损失大量信息。我的做法是把灰度化放进消融实验而不是默认管线——单独跑一组 RGB 对比灰度图的准确率,用实验证明颜色特征的贡献,既展示了对灰度处理的理解,又不牺牲主方案性能。训练前还可以做一步“背景作弊检查”:抽几张不同类别的图,把背景区域手动涂黑后喂给模型,如果预测结果大面积翻转,说明模型在靠背景分类,这时候回到 2.1 调整抽类策略或增强强度。

3. 传统特征路线:HOG/SIFT + 视觉词袋 + SVM 的课内高分打法

在数字图像处理这门课里有一个现实约束:部分老师明确不允许用深度学习,或者课程大纲里根本没有神经网络章节。这种场景下细粒度分类依然可以做,只是路线要从“端到端学习”换成“手工特征 + 统计分类器”。这条路线恰恰是课程里最有“数字图像处理味道”的方案——灰度化、边缘检测、特征提取、聚类、分类,课内知识点全覆盖,报告好写,答辩好讲。

3.1 先确认题目边界:老师要的是课内算法还是开放方法

拿到题目先做三件事。第一,翻课程大纲,看是否包含深度学习章节;第二,问清期末报告的评分维度,是只看准确率,还是看算法覆盖面、实验对比、可视化;第三,看往年高分样例的风格,如果往年高分全是薄薄几页只有准确率,认真做可视化的你几乎必然高分。这个判断直接决定选哪条路:课内算法路线准确率做到 75% 到 85%,整体分并不低;深度路线可以到 90%,但如果老师明确不要超纲方法,可能适得其反。最优解通常是“课内算法做基线,深度学习做扩展实验”,前提是老师允许后者的存在。

3.2 HOG 特征提取:单元格、块与梯度的排布

HOG(方向梯度直方图)对细粒度分类的价值在于它显式刻画局部边缘和形状。喙部边缘、翅膀轮廓、轮眉线条在 HOG 特征里响应非常稳定,而且不受颜色干扰。OpenCV 的 HOGDescriptor 开箱即用:

import cv2 import numpy as np def extract_hog(gray_64): win_size = (64, 64) # 检测窗口,固定为64x64 block_size = (16, 16) # 每个block包含2x2个cell block_stride = (8, 8) # block滑动步长,与cell尺寸一致 cell_size = (8, 8) # 每个cell为8x8像素 n_bins = 9 # 每个cell的梯度方向直方图bin数 hog = cv2.HOGDescriptor(win_size, block_size, block_stride, cell_size, n_bins) feat = hog.compute(gray_64) # 返回一维向量 return feat.flatten() img = cv2.imread("data/train/class_001/0001.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray_64 = cv2.resize(gray, (64, 64)) feat = extract_hog(gray_64) print(feat.shape) # (1764,)

这段代码最容易翻车的是尺寸关系。64×64 窗口内,block_stride=(8,8) 横向能放下 (64-16)/8+1=7 个 block,纵向同样 7 个,共 49 个 block;每个 block 内 4 个 cell,每个 cell 9 个 bin,总维度 49×4×9=1764。如果改了 cell_size 或 block_size,记得重新推一遍维度,否则后面 SVM 特征维度对不上都不知道错在哪。HOG 的缺点是只描述梯度方向分布,对颜色和低频纹理不敏感——两个鸟种如果主要靠羽色区分,HOG 表现会很挣扎,所以需要 SIFT 来补纹理描述。

3.3 SIFT + 视觉词袋:从关键点到直方图的完整链路

SIFT 提取的是关键点周围的局部梯度模式描述子,128 维。和 HOG 固定窗口不同,SIFT 只在信息量足够的位置(角点、边缘交点、纹理变化剧烈处)产生关键点,纯色背景区域不参与计算,这对细粒度任务很有利。完整流程是:每张图提取 SIFT 描述子 → 所有描述子做 KMeans 聚类成视觉词典 → 每张图统计单词直方图 → SVM 分类。

import cv2, numpy as np from sklearn.cluster import KMeans from sklearn.svm import SVC sift = cv2.SIFT_create(nfeatures=800) # 每张图最多800个关键点 train_feats = [] for path in train_paths: gray = cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2GRAY) kps, des = sift.detectAndCompute(gray, None) if des is not None: train_feats.append(des) else: train_feats.append(np.zeros((1, 128), dtype=np.float32)) # 空描述子兜底 all_des = np.vstack(train_feats) print(all_des.shape) # (N*800, 128) V = 300 # 视觉词典大小 kmeans = KMeans(n_clusters=V, random_state=0, n_init=10).fit(all_des) def encode_bow(des): if des is None or len(des) == 0: return np.zeros(V) words = kmeans.predict(des) hist = np.bincount(words, minlength=V).astype(np.float32) return hist / (hist.sum() + 1e-6) # L1归一化,消除图片尺寸影响 X_train = np.array([encode_bow(sift.compute(cv2.imread(p, cv2.IMREAD_GRAYSCALE))[1]) for p in train_paths]) svm = SVC(kernel="rbf", C=10, gamma="scale") svm.fit(X_train, y_train)

几个关键参数要讲清楚。nfeatures=800 是内存止血点:一张高清图可能提取出几千个关键点,几百张图堆起来就是上百万行 128 维矩阵,KMeans 迭代时内存会爆炸,限制为响应最强的 800 个点通常也是判别性最强的点。空描述子的兜底处理很关键——低纹理图可能一个关键点都没有,直接跳过会让特征矩阵和标签对不上,补一个全零描述子保证数量一致。V=300 是视觉词典大小,V 太小各类直方图长得差不多,V 太大会稀疏化严重导致 SVM 死背训练集,300 在几百张训练图的作业量级上是一个安全的起点。直方图 L1 归一化不能省,否则大图小图特征幅度差异会直接干扰 SVM 的距离计算。SVM 的 C=10 和 gamma="scale" 是常用起点,C 可以网格搜索 {1, 10, 100},gamma 用 scale 让 sklearn 按特征方差自动适配。

SIFT+BOW 在 CUB 抽 20 类、每类约 60 张的子集上,我跑过的结果在 72% 到 82% 之间,具体取决于类别挑得好不好。它比 ResNet50 的 90% 以上低,但是纯课内算法路线,报告中可以完整重现从灰度化到 SVM 的每一步,这个逻辑链在给分点上很值钱。

3.4 消融实验表:把特征对比写成报告里的“故事”

拿高分不是堆特征,而是讲清楚每个环节的增益。报告里放一张特征对比表,比放十个准确率折线图都有用:

特征方案特征维度验证集准确率
HOG 单独176468.2%
SIFT-BOW 单独30075.4%
SIFT-BOW + HOG 拼接206478.1%

拼接方式就是把两个特征向量各自归一化后串起来,喂同一个 SVM。拼接前必须分别归一化,否则 HOG 的幅度会压制 SIFT 直方图,拼接后维度也没过万,SVM 训练依旧很快。这张表的价值在于向老师证明:你知道每类特征在干什么,而且做了完整对比实验才选了最终方案,而不是瞎试到最高分就停手。

4. 深度迁移路线:用 ResNet50 两阶段微调把精度顶到 90+

如果你的课程允许使用深度学习,迁移学习是细粒度分类大作业的最优解,没有之一。原因不在模型结构多先进,而在数据量:细粒度公开数据集通常只有几千到两万张,这个规模从头训练一个卷积网络是典型的“小样本深网络”,训练集损失能压到很低,测试集一塌糊涂。而 ImageNet 预训练模型已经具备多尺度边缘、纹理基元、局部形状组合这些通用视觉能力,这些能力和细粒度分类所需的喙缘、羽纹、轮毂细节几乎完全连续,你只需要在分类头上用少量训练样本做“对接”。

4.1 模型选型:ResNet50 还是 VGG16

课程答辩有一个隐性要求:模型原理你得能用课内语言解释。VGG16 的结构最简单,卷积池化堆叠一眼看穿,但参数量大、训练慢、精度不如 ResNet。ResNet50 性能好,但残差连接需要额外讲清楚。我的建议是:如果老师更看重效果,就 ResNet50;如果怕被追问原理追问到卡壳,VGG16 也够用,准确率差距在细粒度子集上通常只有两三个点。还有一个折中方案——用 ResNet50 跑实验,报告里专门用一小节对比 VGG16 和 ResNet50 的验证集准确率,把“为什么选 ResNet”也变成一个加分实验。

4.2 加载预训练模型并替换分类头

import torchvision.models as models import torch.nn as nn num_classes = 20 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) # 2048 -> 20

注意 weights 参数在较新版本 torchvision 里必须显式传入,旧写法 pretrained=True 在新版已经废弃,复现时如果报参数找不到,先升级 torchvision 再跑。只替换 fc 层的原因是 ResNet50 最后的 2048 维特征已经抽象到“物体部件”级别,分类头只是做一个线性打分,完全没必要动前面的主干。

4.3 两阶段微调:先冻结后解冻的具体节奏

两阶段微调是我在细粒度任务里最推荐的做法,比一步到位全微调稳定得多:

# 阶段一:冻结backbone,只训练分类头 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True optimizer = torch.optim.SGD([ {"params": model.fc.parameters(), "lr": 0.01} ], momentum=0.9, weight_decay=1e-4) # 跑5~8轮,让随机初始化的fc层先适应预训练特征空间

阶段一结束后,解冻 layer4 做第二轮微调:

# 阶段二:解冻 layer4 和 fc,学习率整体降低 for name, param in model.named_parameters(): if name.startswith("layer4"): param.requires_grad = True optimizer = torch.optim.SGD([ {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3} ], momentum=0.9, weight_decay=1e-4)

为什么不一开始就全解冻?预训练特征在细粒度任务上已经可用,全解冻加高学习率会在头几百步就把 backbone 的响应冲毁,训练集准确率看着上去了,验证集反而崩掉。阶段二为什么只解冻 layer4?layer3 和 layer2 学到的还是通用边缘纹理,动它们容易被小数据集带偏;layer4 语义层级最高,最接近细粒度判别区域,解冻它的性价比最高。学习率逐层递减是个实用直觉:离输入越远的层越接近任务语义,可以给更大学习率;靠近输入的层保持低学习率稳定住底层特征。

4.4 训练监控与早停:保住验证集最高的那个权重

训练循环本身不复杂,但有一个很容易被忽略的细节——保存的权重应该来自验证集准确率最高的 epoch,而不是最后一轮。我习惯把早停逻辑写进训练脚本,patience 设为 5,验证集连续 5 轮不涨就停:

best_val_acc = 0.0 patience = 5 bad_epochs = 0 for epoch in range(40): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) val_acc = evaluate(model, val_loader) if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "best_model.pth") bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print(f"early stop at epoch {epoch}") break

train_one_epoch 和 evaluate 按你自己的数据 loader 实现,核心逻辑是:以验证集准确率而不是训练集 loss 作为早停依据。后续画混淆矩阵、Grad-CAM 热力图,全部要加载这个 best_model.pth。如果用了最后一轮的权重,可能已经过拟合,画出来的热力图会乱指——这是后期可视化阶段最隐蔽的坑。优化器用 SGD 加 momentum 而不是 Adam 系列,原因是在小数据微调场景里 SGD 的泛化性通常更好,Adam 收敛快但准确率上限常见低一个点左右。

4.5 数字图像处理课里深度路线的边界

用 PyTorch 不算“从零实现”。如果老师要求从原理层面解释卷积、池化、反向传播,你要能把卷积操作对应到数字图像处理里的卷积核运算、把池化对应到区域降采样那一套语言讲出来,否则答辩容易露馅。另外,有的课程明确规定期末作业必须是课内算法,深度模型只能作为补充实验放附录,标题写成“深度学习扩展探索”,不要喧宾夺主。如果课程环境是 Matlab,预处理、HOG、SIFT 这套流程可以用 Image Processing Toolbox 复现,核心参数不变;深度迁移部分建议还是用 Python 生态,Matlab 的深度学习工具箱在课程场景里调试效率低不少。

5. 细粒度大作业最容易翻车的 5 个现场与排查方法

这一章的内容都是我在类似项目里见过或被问过的真实问题,按“现象 → 原因 → 解决”的排查思路写。如果你做完一遍发现准确率不正常,先按这五条过一遍。

5.1 现象:同一份测试数据,三次运行三个结果

验证集和测试集用了带随机性的预处理,比如 RandomResizedCrop 或 RandomHorizontalFlip,导致每次推理时输入图都不一样,准确率在 90% 上下随机波动。这是个典型的复制粘贴事故——很多人图省事,从 train_transform 复制一份删掉两行就当 val_transform 用,结果漏删了随机裁剪。

解决:验证和测试阶段只允许 CenterCrop。还有一个隐蔽变种:训练输入是 224×224,测试时用原始尺寸直接推理,输入尺寸不一致会让模型行为完全变形,准确率可能掉到 30% 以下。血泪经验:val_transform 一定要单独重写一遍,不要从 train_transform 复制。

5.2 现象:混淆矩阵里两个类别几乎分不开,但人眼一眼就能看出区别

两个类被反复互相误认,比如颜色接近的两个鸟种。原因通常是三类情况之一:数据标注本身有错图;训练样本太少,判别部位没有被充分覆盖;或者这两个类的差异区域在原始图像分辨率下本来就不清晰。

解决:先检查标注错图,在数据目录里把两个类的图并排看一遍,错标图直接删掉;再给困难类补几张训练样本;如果还没改善,把这两个类的“困难样本”挑出来单独分析。很多细粒度数据集的官方标注里混有遮挡严重或主体很小的图,这类样本在传统特征路线上几乎不可能分对,把它们标注成“数据集噪声”写进报告反而是加分项,至少证明你做了错误分析。

5.3 现象:训练集准确率 99%,验证集只有 60%

这是最典型的过拟合,尤其在深度路线上。细粒度任务的判别特征非常细微,模型容量大时可以在训练阶段记住每个样本局部的噪声纹理,而验证集里相同噪声模式不存在,准确率自然崩掉。

解决:优先从增强下手而不是改模型。把 RandomResizedCrop 的 scale 下限从 0.5 再往高调,加上 label smoothing(0.1 起步),weight_decay 从 1e-4 提到 5e-4,早停必须开。如果增强做到位还是过拟合,回头检查数据划分——类别数很少时(比如只有 5 个类)训练集和验证集类别分布不均会让验证波动很大,回到 2.2 确认分层划分真的做了。

5.4 现象:SIFT 聚类时 KMeans 卡死,笔记本风扇狂转

传统特征路线下,所有训练图的关键点描述子无上限堆在一起。假设 500 张图每张提取 2000 个关键点,就是 100 万行 × 128 维的 float32 矩阵,约 500MB,KMeans 内部迭代计算距离时内存还要再膨胀好几倍,直接 OOM。

解决:nfeatures=800 限制每张图的关键点数量是第一道止血。进阶做法是分块提取——把图 resize 到 256 后切成四块 128×128 的区域,每块提取 400 个关键点,合并后最多 1600 个。分块提取对细粒度任务还有一个直接收益:整图 SIFT 里小判别区域只贡献少数关键点,分块能保证每块都有关键点参与,局部细节不会被全局响应淹没。

5.5 现象:报告只有准确率数字,老师一问“凭什么分对”就卡壳

准确率很高但解释不了模型依据,这在答辩里是最伤的一种情况。细粒度分类的判读核心是“判别区域”,如果连模型注意力落在哪都不知道,老师会怀疑你是调包跑出来的结果。

解决:准备三类可视化证据——分对的样本、分错的样本、以及“模型注意力明显落在判别区域”的热力图。这一招几乎可以稳定撬动 5 到 8 分。具体怎么画、怎么读,下一章单独展开。

6. 最后的加分动作:混淆矩阵、Grad-CAM 与答辩的三句话框架

高分不是靠准确率数字堆出来的,而是靠“证据链”堆出来的。最后一个环节做三件事:混淆矩阵、Grad-CAM 热力图、答辩陈述框架。

6.1 混淆矩阵的正确画法与读法

画混淆矩阵时注意两个细节:类别超过 20 类时不要开 annot 参数,数字会挤成一团;x 轴放真实类别、y 轴放预测类别是答辩里最常见的约定。读矩阵不要只盯着对角线,非对角线上的“成对错误”才是分析重点——比如 A 类和 B 类互为最常混淆对象,就在报告里给这两个类单独放大图,说明原因和后续改进方向。

6.2 Grad-CAM:让模型自己指出它看了哪里

Grad-CAM 的原理一句话说就是:用目标类别对最后一个卷积层输出做梯度加权,得到每张特征图的通道重要性,再上采样回原图尺寸叠加显示。代码核心逻辑不长:

import torch import torch.nn.functional as F def grad_cam(model, img_tensor, target_layer): acts, grads = {}, {} def forward_hook(module, inp, out): acts["value"] = out def backward_hook(module, grad_in, grad_out): grads["value"] = grad_out[0] handle_f = target_layer.register_forward_hook(forward_hook) handle_b = target_layer.register_backward_hook(backward_hook) logits = model(img_tensor.unsqueeze(0)) # 1 x num_classes pred = logits.argmax(dim=1) model.zero_grad() logits[0, pred].backward() # 只回传预测类别 weights = grads["value"].mean(dim=(2, 3), keepdim=True) # GAP cam = (weights * acts["value"]).sum(dim=1, keepdim=True) cam = F.relu(cam) cam = F.interpolate(cam, size=(224, 224), mode="bilinear", align_corners=False) handle_f.remove() handle_b.remove() return cam.detach().squeeze().numpy()

weights 是对梯度做空间均值(GAP),表示每个通道对预测类别的重要性;relu 只保留正贡献区域,找的是“让模型朝该类别打分的原因”。判读热力图的要点不是“热区是否覆盖物体”——细粒度任务里热区覆盖整只鸟不算本事,落在喙、翅缘、头部斑纹上才说明模型学到了判别特征。如果热区总是落在背景树枝上,说明分类在靠场景作弊,需要回到第 4 章加强局部裁剪。

6.3 答辩的三句话框架

第一句讲任务难点:细粒度分类的类间差异小、局部判别区域分散,普通全局特征不鲁棒。第二句讲方案推理链:先用传统特征路线验证课内算法的表达能力,再用迁移学习获得更高上限,两阶段微调是为了防过拟合。第三句讲证据:把混淆矩阵、Grad-CAM 热力图、消融实验表三样东西放到屏幕上,说“模型在 90% 以上样本里把注意力落在判别区域附近”,这个结论是可验证的,也是 98 分和 85 分之间的那 13 分真正差在的地方。

我当年吃亏最大的一步,是把所有时间耗在调准确率数字上,忘了准备可视化证据,答辩被一句“凭什么分对”问哑火。后来把 Grad-CAM 和混淆矩阵做在前面,实验的完整度反而让准确率提升过程变得清晰可控。希望帮到你——期末周少熬几个夜,把每一张图表都做到能被人追问的深度,分就自然高了。

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

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

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

立即咨询