ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Grad-CAM与Hook函数:深度学习模型可解释性实战

Grad-CAM与Hook函数:深度学习模型可解释性实战

模型的预测结果像一个黑盒:给一张猫的图片,模型说这是猫,但为什么?是因为看到了猫耳朵、猫胡须,还是仅仅因为背景里的沙发纹理?从业时间久了,你会发现“准确率”这件事并不能完全说服自己,更别说说服业务方。Grad-CAM 与 Hook 函数,就是打开这个黑盒的两把钥匙。简单说,Grad-CAM 用梯度生成热力图,告诉我们“模型看哪里”;Hook 函数则是 PyTorch 留给我们截获中间张量和梯度的“窃听器”,是获取 Grad-CAM 输入的前置手段。这两样工具适合任何搞深度学习的人,不管是做分类、检测还是图像生成,只要你想调试模型、排查 bad case、验证模型没有“抄捷径”,这篇笔记就值得你花十分钟读完。

1. 为什么要给模型“拍照”:Grad-CAM 的核心思路

1.1 从“只看指标”到“看懂决策”

我在训练模型的过程中,最常遇到的一个尴尬场景是:验证集准确率已经到了 98%,我信心满满地把模型部署到业务场景里,结果线下实测一塌糊涂。回看训练集,发现大量图片的背景高度相似——比如某次业务项目里,正样本几乎都带水印,模型学习的根本不是物体特征,而是水印的纹理。这个案例让我意识到,指标只能告诉你“模型表现如何”,不能告诉你“模型在用什么依据做判断”。

Grad-CAM(Gradient-weighted Class Activation Mapping)解决的就是这个问题。它把模型的决策依据以热力图的形式呈现出来:红色区域表示模型分类时重点关注的像素区域,蓝色区域表示几乎不关注的地方。如果一张猫的图片,模型给出“猫”的结论,但热力图却集中在画面角落的毛毯上,那就说明模型学到了不该学的东西——这就是典型的数据偏置(bias),而 Grad-CAM 能第一时间把它暴露出来。

1.2 CAM 的局限与 Grad-CAM 的改进

在 Grad-CAM 出现之前,业界已经有了 CAM(Class Activation Mapping)方法。CAM 的思路很直接:把全局平均池化(GAP)层之后拿到的特征图,按类别权重线性加权,再上采样回原图尺寸。

但 CAM 有一个硬伤:它要求模型结构必须包含 GAP 层 + 全连接层,而且只能看到最后一个卷积层的特征图。你训练好的模型如果用的自适应平均池化,或者干脆没有 GAP 层,CAM 基本没法直接用。我当时第一次在某个训练好的分类网络上试 CAM,结果因为要改模型结构、重训参数,直接放弃了。

Grad-CAM 绕开了这个限制。它不再依赖特定的网络结构,而是利用类别得分对特征图的梯度来生成权重。核心公式只有两步:

[ \alpha_k = \frac{1}{Z} \sum_i \sum_j \frac{\partial y^c}{\partial A^k_{ij}} ]

[ L^c_{Grad-CAM} = ReLU(\sum_k \alpha_k A^k) ]

第一步,对第 k 张特征图 (A^k) 的所有像素求梯度,再取平均,得到一个标量权重 (\alpha_k)。这个权重表示“第 k 张特征图对类别 c 的预测得分有多大贡献”。第二步,把每张特征图按权重加权求和,经过 ReLU 过滤掉负值,只保留对类别有正贡献的区域,再上采样到原图尺寸。

为什么加 ReLU?因为梯度有正有负,负梯度意味着“看到这个区域会降低类别得分”,也就是模型的“反证据”。我们画热力图时只关心哪些区域在“支持”模型的判断,所以负值直接归零。

1.3 可解释性的两种画法:heatmap 与 overlaid

拿到 Grad-CAM 的原始结果(一张与原图同尺寸的浮点型张量)之后,一般有两种展示方式:

第一种是纯热力图,用 colormap 将张量映射为彩色图,比如 Matplotlib 的 jet colormap,蓝到红表示关注度从低到高。

第二种是把热力图叠加到原图上。做法是先把热力图 resize 到原图尺寸,归一化到 [0,1] 区间,然后以 0.4 到 0.6 之间的比例与原图加权融合。

实际操作中我建议两种都生成,因为纯热力图适合观察“模型关注区域的形状”,叠加图适合“判断关注区域对应原图的哪个部分”,两者配合使用,定位问题的效率高很多。

2. Hook 函数:PyTorch 的“窃听器”到底怎么用

2.1 为什么拿不到中间层的梯度

前面提到,Grad-CAM 需要“特征图的梯度”,但 PyTorch 在设计上默认只保存叶子张量的梯度。中间层的特征图是运算产生的,不是叶子节点;而且在一个标准的推理流程里,你根本不会对中间层求梯度,所以用.backward()只能拿到模型输入和参数(权重、偏置)的梯度,中间层特征图的梯度默认是拿不到的。

有两个解决方案:

第一种,改网络的 forward 函数,把中间层特征图作为额外输出返回。这种方法侵入性太强,每换一个模型就要改一次前向代码,调试的时候还得小心影响原有输出。

第二种,使用 Hook 函数。PyTorch 的register_forward_hook和register_backward_hook允许我们在模块的前向传播之后或反向传播过程中,截获该模块的输入、输出以及梯度。不需要修改网络结构,只在外部注册一个回调函数,像在电路上并联一个电压表一样,不干扰原有通路。

2.2 forward_hook 和 backward_hook 的分工

我在代码中常用的 Hook 有四种,它们的作用各不相同:

Hook 类型触发时机拿到的数据
register_forward_hook前向传播执行完毕后模块的输入、输出
register_forward_pre_hook前向传播执行之前模块的输入
register_backward_hook反向传播时(较新 API 可能有变动)模块输入、输出的梯度
register_full_backward_hook反向传播时(推荐)模块输入、输出的梯度

Grad-CAM 需要的是特征图的输出(forward)和该特征图对应的梯度(backward),所以要同时挂 forward hook 和 backward hook。

一个容易踩的坑是:老版 PyTorch 中的register_backward_hook拿不到多输入模块的输入梯度,新版 API 改成了register_full_backward_hook。我在某版本的 PyTorch 下写代码,用旧接口死活拿不到想要的值,翻文档才发现需要改成 full 版本。建议新写的代码直接使用register_full_backward_hook,省得为版本差异头疼。

2.3 用“缓存字典”模式管理 Hook

我的习惯是写一个简单的 GradCAM 封装类,内部用一个字典存储 hook 捕获的数据。字典键是模块的名字(比如layer4.2),值是{"forward": 特征图, "backward": 梯度}。这样不管是单层可视化还是多层可视化,都能统一管理。

先看一个最小可用的 hook 注册示例:

import torch import torch.nn as nn class GradCAM: def __init__(self, model, target_layer): self.model = model self.target_layer = target_layer self.features = None self.gradients = None # 注册 forward hook target_layer.register_forward_hook(self._forward_hook) # 注册 backward hook target_layer.register_full_backward_hook(self._backward_hook) def _forward_hook(self, module, input, output): # output 是特征图,形状为 [B, C, H, W] self.features = output.detach() def _backward_hook(self, module, grad_input, grad_output): # grad_output[0] 是特征图的梯度,形状与特征图一致 self.gradients = grad_output[0].detach() def generate_cam(self, input_tensor, target_class=None): output = self.model(input_tensor) if target_class is None: target_class = output.argmax(dim=1).item() self.model.zero_grad() one_hot = torch.zeros_like(output) one_hot[0][target_class] = 1 output.backward(gradient=one_hot) weights = self.gradients.mean(dim=(2, 3), keepdim=True) cam = (weights * self.features).sum(dim=1, keepdim=True) cam = torch.relu(cam) return cam

这段代码是 Grad-CAM 的最小骨架。核心逻辑在第 23 到 25 行——先对梯度做全局平均池化,得到每张特征图的权重,然后与特征图相乘并在通道维度求和,最后通过 ReLU 过滤负值。

3. 完整实操:用 Grad-CAM 生成热力图

3.1 数据预处理:别把归一化忘了

很多人第一步就栽在数据预处理上。模型训练时用的输入是“标准化之后的张量”,如果你直接用原始图片做推理,得到的置信度分布会和预期差别很大,热力图自然也会偏移。

我的经验是,Grad-CAM 的输入预处理要和训练流程完全一致。以某个经典分类网络为例,常规操作是:

from torchvision import transforms from PIL import Image preprocess = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) image = Image.open("cat.jpg").convert("RGB") input_tensor = preprocess(image).unsqueeze(0) # 增加 batch 维度 [1, 3, 224, 224]

这里有几个细节:

  • unsqueeze(0)很容易漏掉,深度学习框架的输入几乎都是四维张量,缺少 batch 维度会直接报错。
  • Resize和CenterCrop的顺序不要搞反,通常是先缩放后裁剪,否则图形会被拉伸变形。
  • 用训练时的均值和标准差做归一化,模型才能看到“熟悉的数据分布”。

3.2 目标层的选择:不是哪一层都合适

Grad-CAM 的质量高度依赖你选择哪一层作为目标层。直观规律是:越靠近输出的卷积层,语义信息越强,空间分辨率越低;越靠近输入的卷积层,空间分辨率越高,语义信息越弱。

如果选太靠近输入的低层(比如第一个卷积块的输出),特征图分辨率很高,但包含的多是边缘、纹理等低级特征,热力图会非常零散,到处都有高亮,看不出明确的语义区域。如果选太靠近输出的特征层,比如 GAP 层之前的最后一个卷积层,热力图通常最清晰,因为这里的通道已经编码了高级语义信息,比如“耳朵”“轮子”“眼睛”这些概念,空间分辨率虽然小,但足够定位。

我最常用的选择是最后一个残差块(比如某个分类网络的layer4的最后一层)或者分类头之前的最后一层卷积。如果模型有特征金字塔结构(比如检测模型),可能需要针对特定尺度选择对应的层。

3.3 一次完整的前向-反向流程

下面是一个可以实际运行的完整流程,我把 Grad-CAM 的生成、热力图画图以及叠加图的绘制拼接起来:

import torch import matplotlib.pyplot as plt import numpy as np import cv2 # 假设 model 已经加载,target_layer 已经选定 cam_model = GradCAM(model, target_layer) # 前向 + 反向,拿到 cam cam = cam_model.generate_cam(input_tensor, target_class=282) # 282 是猫的类别 id # 去掉 batch 和 channel 维度 cam = cam.squeeze().cpu().numpy() # 归一化到 0~255 并 resize 到原图尺寸 cam = np.maximum(cam, 0) cam = cam / (cam.max() + 1e-8) cam = cv2.resize(cam, (image.width, image.height)) # 把灰度热力图映射为彩色 heatmap = cv2.applyColorMap(np.uint8(255 * cam), cv2.COLORMAP_JET) heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) # 叠加到原图上 original = np.array(image) overlay = 0.5 * original + 0.5 * heatmap overlay = overlay.astype(np.uint8) # 三图并排显示 fig, axes = plt.subplots(1, 3, figsize=(12, 4)) axes[0].imshow(original) axes[0].set_title("Original") axes[1].imshow(heatmap) axes[1].set_title("Grad-CAM Heatmap") axes[2].imshow(overlay) axes[2].set_title("Overlay") plt.show()

这里我用 opencv 的applyColorMap把单通道热力图映射成彩色,比直接用 matplotlib 的 colormap 更简单,而且方便后续导出图片。归一化时加一个1e-8是为了防止除数出现零。如果你发现热力图整体偏暗或者整体过曝,优先检查归一化那一步,而不是换 colormap。

3.4 可视化卷积核权重:额外收获

实践中顺手做了一个操作,把特征图的通道权重排序输出,会发现一个有趣的现象:对“猫”这个类别贡献最高的通道,往往正好对应高激活区域。比如某个通道的权重是 0.85,它的特征图恰好高亮猫的耳朵区域。这算是一个衍生的小技巧,你通过 Grad-CAM 不仅能知道模型看哪里,还能知道具体是哪些卷积核在起作用。

具体代码如下:

weights = cam_model.gradients.mean(dim=(2, 3)).squeeze().cpu().numpy() top_k = np.argsort(weights)[-5:][::-1] print("权重最大的 5 个通道索引:", top_k)

结合特征图可视化,你可以进一步分析这些通道到底在提取什么模式。这在做模型压缩和剪枝的时候特别有用,能直接找出冗余或噪声通道。

4. 实战案例分析:Grad-CAM 帮我抓到两个经典问题

4.1 案例一:模型学的是水印,不是商品

某次给一个电商场景做商品分类模型,训练集准确率 99.2%,测试集也表现很好,但客户反馈说图片换了个背景,模型就频繁出错。我当时用 Grad-CAM 随便抽了十张验证集图片观察,结果十里有至少八张热力图高亮区域集中在图片右下角。顺着这个线索检查训练集才发现,所有正样本都是从同一个平台爬下来的,右下角全部带半透明水印。模型实际上学的是一个非常稳定的“水印特征”,而不是商品本身。这个案例让我养成了一个习惯:新数据集训练完,第一件事不是看指标,而是抽 20 张图跑一遍 Grad-CAM。如果高亮区域与语义目标重合度低,那指标再好看也要警惕。

4.2 案例二:多标签分类下,模型忽略小目标

另一个案例是多标签分类任务,一张图同时包含“车辆”和“行人”,但模型总是漏掉小尺寸的行人。Grad-CAM 显示,模型在预测“行人”类别时,热力图仍然集中在车辆区域,说明模型对“行人”类别的判别特征没有建立起来。究其原因,数据集中行人区域太小,正样本的标注框覆盖了大量背景,干扰了特征学习。

定位到问题后,我用了两个办法解决:一是做过采样 + 局部放大,让小目标的样本在训练中出现频率更高;二是调整损失函数,对包含小目标的样本提高权重。重新训练之后,再用 Grad-CAM 检查,热力图能正确聚焦到行人区域,离线指标和线上反馈都明显改善。

4.3 案例三:用 Grad-CAM 做数据增强的效果验收

我给图像分类任务加了随机裁剪和颜色抖动之后,指标提升了一些,但我不确定是“增强策略本身有效”还是“受益于更长的训练步数”。为了验证,我用 Grad-CAM 对比了增强前后模型在相同图片上的热力图。结果发现,增强后模型的高亮区域更紧密地包围目标边缘,不再像原来那样扩散到背景。这让我有更充分的信心后续继续使用这种增强策略,也让我意识到 Grad-CAM 不只是事后分析工具,还可以作为训练策略评估的辅助工具。

5. 常见问题与避坑手册

5.1 梯度全为零,热力图全是黑的

这是刚写 Grad-CAM 时遇到率最高的问题。绝大多数情况下是目标类别的 one-hot 向量构造错误。如果你用torch.zeros_like(output)构造 one-hot 向量,但类别索引值超出了输出通道数,内部会静默失败或者梯度传播不到特征图上。建议打印output.shape和target_class确认索引合法。

第二个原因是目标层选得太靠近输入,梯度经过多层 ReLU 之后衰减为 0。换一个更靠近输出的层,问题通常会消失。

第三个原因是梯度被detach()截断了。我最初为了省内存,在 hook 函数里直接output.detach()存储,但这会让反向传播的梯度无法流经该节点,最终 Grad-CAM 权重全是零。正确的做法是在保存特征图和梯度时都使用detach(),但反向传播本身不能提前截断。可以这样理解:hook 里存的是“备份”,真正的计算图要保持完整。

5.2 backward 拿到 None 或者维度对不上

在较早的 PyTorch 版本中,register_backward_hook对某些模块(尤其是多输入模块)拿不到输入梯度,返回 None。建议直接改用register_full_backward_hook,这个接口对常见的模块兼容性更好。如果维度对不上,请先输出grad_output[0].shape和output.shape做比较,确认你取的确实是特征图的梯度,而不是损失函数的梯度。

5.3 热力图与目标错位

这是最让人困惑的现象:热力图高亮区域看起来合理,但与原图目标偏移了几个像素。原因往往是数据预处理中的 resize 与反推回原图尺寸的 resize 不一致。比如训练时用的是Resize((224,224)),而你画热力图时用cv2.resize(cam, (width, height)),两者插值方式不一致导致对齐偏差。解决方法是统一使用相同的缩放方法,或者记录预处理时的变换参数并做反变换。

还有一个常见的错误是使用了transforms.CenterCrop训练,却在可视化时没有做同样的裁剪处理,导致热力图整体偏移。经验法则是:推理时用什么预处理,可视化热力图前就要对原始输入做一模一样的预处理。

5.4 模型处于 eval 模式与 train 模式的差异

如果模型包含 Dropout 或 BatchNorm 层,model.eval()和model.train()状态下同一张图片的热力图会不同。Grad-CAM 的目的是分析模型的稳定特征,一般应该在 eval 模式下进行。如果忘了切换,BatchNorm 层会因为 batch 统计量与训练分布不一致而输出异常激活,热力图也会变得奇怪。所以我习惯在生成热力图前加一行:

model.eval()

如果之后还要继续训练,记得再切换回model.train(),否则模型的 BatchNorm 统计量会被污染。

5.5 内存爆炸问题

批量生成多张热力图时,PyTorch 会保存所有反向传播的计算图,内存容易爆掉。我的处理方式是每处理一张图就调用torch.cuda.empty_cache(),并且用with torch.no_grad()块包裹与 Grad-CAM 无关的前向操作,只在真正需要梯度的那一次前向-反向过程中开启梯度计算。

5.6 Hook 注册后忘记移除

在 Jupyter 笔记本或调试脚本中反复注册 hook,会导致同一个模块上挂载多个回调函数,轻则存储混乱,重则影响反向传播速度。注册 hook 时会返回一个句柄,用完后调用.remove()移除:

handle = target_layer.register_forward_hook(self._forward_hook) # 用完后 handle.remove()

在封装类里,我记得在析构函数里统一清理,避免多次实验时互相干扰。

6. 进阶扩展:从 Grad-CAM 到更多可解释性方法

Grad-CAM 不是终点,学会它之后再往后扩展就会很轻松。基于同样的 Hook 机制,你可以很自然地迁移到一类可解释性方法上。

6.1 从单层到多层:Layer-CAM / Score-CAM

Grad-CAM 只使用单层特征图,会丢失一些空间细节。Layer-CAM 的思路是把多个层级的特征图分别计算权重,再做跨层融合,能同时保留高层的语义信息和低层的空间细节。实现方法上,你只需要在多个目标层上注册 hook,然后在反向传播后对每一层分别计算 CAM,最后做加权平均。核心代码与前面几乎一致,差别只是多注册几个 hook。

Score-CAM 则完全绕开梯度,用通道的置信度增量作为权重,解决了一个实际痛点:梯度在某些任务上会出现饱和或噪声,导致热力图不稳定。用 Score-CAM 替换权重的计算方式后,热力图反而更干净。作为一个补充手段,当 Grad-CAM 图结果明显不合理时,我会用 Score-CAM 交叉验证。

6.2 从图像分类到目标检测

目标检测模型(比如 Faster R-CNN 或 YOLO 系列)同样可以用 Grad-CAM 分析,只是需要修改反向传播的目标值。分类模型是对 logits 构造 one-hot 向量做 backward,检测模型则是对某个 anchor 或 proposal 的分类得分做 backward,或者更直接一点,对检测头的分类分支某个类别的输出做 backward。我曾在某个检测模型上实验,将某个物体的类别得分做 backward 后,热力图能正确显示出模型是根据该物体的纹理和边缘做的判断。

6.3 从 CNN 到 Transformer: Attention Rollout 与 Grad-CAM 类比

Vision Transformer(ViT)类模型没有卷积层,Hook 的用法从 CNN 迁移过来时,目标从“卷积特征图”变成“注意力权重”。一种常见的方案是 Attention Rollout,将各层的注意力矩阵相乘,得到从输入到输出的“信息流动路径”;另一种方案是全程 Grad-CAM,把最后一层 LayerNorm 的输出当特征图,把类别得分对它的梯度当权重。理解 Grad-CAM 之后,这两种方法的核心不再是“会不会用 API”,而是“选什么作为特征图、用什么作为反向目标”,思路是相通的。

6.4 Grad-CAM 代码封装成通用工具类

如果持续做模型调试,建议把 Grad-CAM 封装成一个模块级工具类,支持传入任意模型、任意目标层、任意预处理函数。我在公司内部就是这么做的,把工具类发布到共享代码库之后,团队里其他同学也能直接画出自己模型的热力图,而不用每次都在网上搜索实现。封装的要点是:模型引用、目标层索引、预处理函数这三个输入要解耦,hook 生命周期要自动管理。

下面是一个简易封装的类结构,供参考:

class ModelExplainer: def __init__(self, model, target_layers): self.model = model self.hooks = self._register_hooks(target_layers) def explain(self, input_tensor, class_idx): # 返回 {目标层名称: cam张量} pass def _register_hooks(self, layers): pass def remove_hooks(self): pass

在实际项目中,我还加了一个功能:批量遍历验证集图片,对模型预测错误的样本自动生成 Grad-CAM 叠加图,集中输出到文件夹。这样每天训练完,只需要翻一眼文件夹,就知道模型犯错的模式是什么。

7. 我的最终实操体会

一个一个模块写下来,实际用下来,Grad-CAM 配合 Hook 几乎是做深度学习模型调试性价比最高的工具组合。不需要改模型结构,不需要重训,只需要两个 hook 加一次反向传播,就能把模型的注意力可视化。而且这套思路不光适用于图像分类,检测、分割、多模态模型,只要你能定义“模型的某一项输出”,就能用同样的方法分析它依赖的输入区域。

如果只能从我这些经验里提炼一句话,那就是:在你相信一个模型的指标之前,先用 Grad-CAM 看看它到底在看哪里。很多时候模型的错误不是因为它笨,而是因为它“认真”地学习了你给它的虚假线索。Hook 是我们的耳朵,Grad-CAM 是我们的眼睛,两者配合,才算是真正在用“可解释”的方式做模型调试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进