分类模型上线前最常被追问的一句是:它把这个样本判成了这一类,依据在哪儿。我前后做过几轮模型评审,发现拿一堆指标曲线解释不清的事,一张叠在原图上的热力图往往几十秒就能讲明白——红的地方是模型真正看的位置,蓝的地方它压根没在意。Grad-CAM 就是干这个的,它不需要改网络结构,不需要重新训练,给一个已经训好的卷积网络,前向跑一遍、反向跑一遍,就能拿到一张类激活热力图。我把它封装成了内部一个小库,基于 PyTorch,对外只暴露三行调用,支持单个样本、批量样本、指定类别,还能顺带把叠加图和 GIF 导出来。这篇就把这套东西从设计思路到踩过的坑完整拆一遍,适合手里已经有训练好的分类或检测骨干、想快速补上可解释性这一块的读者,也适合刚接触 CAM 家族、被各种公式绕晕的同学。
1. 库的整体设计与思路拆解
1.1 先划清能力边界:这个小库该做什么、不该做什么
动手写第一行代码之前,我先把边界画清楚了,不然很容易写成一个四不像。这个库的定位是"给定模型、给定输入、给定目标层,产出与原图同尺寸的二维响应图",它不负责训练,不负责改造模型结构,也不负责解释分割或检测头的输出。换句话说,它只做一件事:把某一层卷积特征和这一层的梯度,按一套固定的加权规则压成一张单通道的图。
明确不做什么同样重要。我见过有人试图用它去解释 Transformer 的自注意力,或者拿它去标注目标框的位置,结果自然是别扭的。Grad-CAM 的数学前提是:目标层的每个通道代表某种"语义检测器",通道在空间上的激活强度可以线性加权。这个前提在 CNN 上大致成立,换到别的主干上就得重新想。所以我在库的文档里直接写了一句:输入必须是四维张量 NCHW,模型必须是可反向传播的卷积网络;其它场景请自行评估。
还有一个边界是"不给结论"。热力图只是证据的可视化,不是因果证明。红区不等于模型"因为这块才判的",它只说明这块区域的梯度加权激活高。这句话我写进了库的 README 第一段,后来省掉了不少和业务方的口水战。
1.2 三条实现路线的取舍:改造模型、纯 hook、还是包一层
实现 Grad-CAM 有三条常见路线,我挨个试过,最后选的是第二条。
第一条是改造模型,在 forward 里直接返回中间特征图。优点是数据流清晰,缺点是要动别人的模型代码,遇到第三方权重或者已经编译过的模型就难受,而且每换一个骨干就要改一遍。我在一个多骨干对比的项目里试过这种写法,最后维护了五份几乎一样的 forward,直接放弃。
第二条是纯 hook,用 PyTorch 的register_forward_hook和register_full_backward_hook或者张量级register_hook,在不改模型的前提下拿到激活值和梯度。好处是完全解耦,模型对象原样传进来就能用;坏处是 hook 是全局注册的,忘了摘掉会泄漏显存,hook 内部拿到的张量生命周期也要小心处理。
第三条是直接包一个现成的第三方实现。省事,但可控性差:不同实现对归一化、上采样、多类别处理的口径都不一致,出了问题排查起来要在别人的代码里绕。我更倾向于自己写两百行,知道每一行在干什么。
提醒:hook 是挂在模块对象上的,不是挂在模型状态上的。同一个模块被注册两次,回调会执行两次,梯度会被重复累加。我的做法是构造时注册、析构时统一摘除。
1.3 对外 API 的形态:三行调用背后的设计考虑
我把对外接口压到了极简:
cam = GradCAM(model, target_layer=model.layer4[-1]) mask = cam(x, class_idx=None) # 返回 [B, 1, H, W],值域 0~1class_idx=None时自动取预测分数最高的类,这是最常见的用法;显式传整数或列表时,可以一次解释指定类别,也支持 batch 里每个样本指定不同的类。返回值统一是 0 到 1 的浮点张量,尺寸和输入对齐,这样上层无论是画图、存盘还是做遮挡实验,都不用再关心中间层的分辨率。
这里有个设计上的取舍:要不要在类里直接返回叠加好的彩色图?我最后决定不返回。可视化是下游的事,叠加涉及颜色映射、透明度、BGR/RGB 通道顺序这些和模型无关的细节,混进核心类只会让这个类越来越重。库只输出数值,画图交给一个独立的工具函数,职责干净,复用得也方便。
2. Grad-CAM 的数学内核与关键参数
2.1 权重从哪来:梯度全局平均的直觉解释
Grad-CAM 的核心公式不复杂:先对目标层特征图A(形状 C×h×w)的每个通道求一个权重 α,再把所有通道按权重加权求和,最后过一遍 ReLU。权重 α 的算法是:把类别分数y^c对第 k 个通道的每个像素求偏导,然后在空间维度上取平均。写成式子就是 α_k = (1/Z) Σ_i Σ_j ∂y^c / ∂A^k_{ij}。
这个操作的直觉是什么?梯度衡量的是"这个像素上的这个通道,稍微变一点,目标类的分数会变多少"。把所有像素的敏感度平均起来,就得到"这个通道整体上对目标类的贡献方向"。权重为正,说明这个通道的激活推高了目标类分数;权重为负,说明它在压低目标类分数。
这里有个容易被忽略的点:梯度是对通道整体平均的,所以空间信息在这一步被抹掉了,只剩下一个标量。真正保留空间分辨率的,是后面加权求和那一步。这也是为什么 Grad-CAM 的分辨率天花板就是目标特征图的尺寸——信息在求权重的时候已经被压缩过一次了。
我在代码里对应写的是:
weights = grads.mean(dim=(2, 3), keepdim=True) # [B, C, 1, 1] cam = (weights * activations).sum(dim=1, keepdim=True) # [B, 1, h, w]注意keepdim=True,否则后面的广播会出错,这是一个很典型的低级坑,我第一次写的时候忘了加,结果得到一个形状对不上的张量,报错信息还相当不直观。
2.2 ReLU 那一下到底过滤掉了什么
公式最后那个 ReLU,作用是只保留对目标类有正向贡献的区域,把负值全部归零。
为什么必须要有它?因为如果不做截断,加权求和的结果里会混入大量负值,画出来会发现背景一片蓝,而高亮区反而被稀释了。加上 ReLU 之后,图上的 0 就等价于"这里没有支持该类别的证据",视觉上的语义会更干净。
但代价也很实在:你只能看到支持证据,看不到反对证据。有些样本模型判得很犹豫,支持证据分散在好几处,每处的强度都不高,这时候 ReLU 之后的热力图可能几乎是全黑的,让人误以为是代码写错了。我遇到过好几次,排查半天发现是模型本身对这张图就没多少把握。
一个实用的调试手段:临时把 ReLU 去掉,看看原始加权和的分布。如果去掉之后能看出结构,说明模型是有响应的,只是正向证据弱;如果去掉之后依然是一片噪声,那才是 hook 或者目标层选错了。
提醒:有些实现对 ReLU 之后的图做 min-max 归一化,这是不对的。ReLU 之后最小值天然是 0,再做 min-max 会把原本为 0 的背景抬成一个中间值,叠加到原图上就是整张图泛红。正确的做法是只除以最大值。
2.3 目标层选择与感受野估算:为什么你的热力图总是糊成一团
目标层选哪儿,直接决定热力图的分辨率和语义层级,这是整个库里最需要经验的一步。选太浅,图上全是边缘和纹理,看不出物体;选太深,分辨率太低,一个 7×7 的图放大到 224×224,边缘必然是块状模糊的。
拿一个常见的骨干举例。224×224 输入下,layer4的输出是 7×7,总步长 32,意味着特征图上相邻两个格子在原图上的间距就是 32 像素。更关键的是感受野:按逐层递推r_out = r_in + (k-1) × j_in、j_out = j_in × s,一路算下来,这一层的单个激活点对应的原始感受野已经接近甚至超过 224 的输入尺寸。也就是说,每一个格子都在"看"几乎整张图,相邻格子的视野大面积重叠。热力图之所以糊,根子在这里,不是插值算法的问题。
下面是我在一组常见骨干上实测的记录,可以作为起步参考:
| 骨干类型 | 推荐目标层 | 224 输入下的输出尺寸 | 建议 |
|---|---|---|---|
| 浅层自定义 CNN | 最后一个卷积块 | 14×14 或 28×28 | 分辨率够,但语义弱,适合单目标 |
| VGG 风格 | 最后一个 stage 的第三个卷积 | 14×14 | 感受野约 196,细节保留尚可 |
| ResNet 风格 | 第四 stage 的最后一个 block | 7×7 | 语义最强,但只能定位到大区域 |
| 轻量级骨干 | 倒数第二个 stage 输出 | 14×14 | 语义和分辨率折中,我常用这个 |
| 检测骨干 | neck 的最后一层融合特征 | 视结构而定 | 多尺度融合后响应更完整 |
一个经验法则:如果任务只需要回答"模型大概在关注哪个物体",选最深层;如果要回答"模型看的是物体的哪个部位",往浅退一到两个 stage。我做过一个细粒度分类的对比,layer4的热力图只能框住整只鸟,退到layer3之后才能分辨出头部和尾部的差异,这中间的区别相当明显。
另外提醒一点:目标层不能选在会做 in-place 操作的层上。有些骨干里的 ReLU 设了inplace=True,如果你把 hook 挂在它之前的卷积输出上,前向时保存的张量会被原地改写,反向传播时 autograd 会直接报版本号不匹配的错误。解决办法有两个:在 hook 里对输出做一次clone(),或者干脆在加载模型后把带 inplace 的激活函数关掉。
2.4 上采样与归一化的数值细节
特征图生成之后,要放大回输入尺寸才能叠加。这里用双线性插值(mode='bilinear')是最常见的,align_corners建议设成False,和主流的图像缩放口径一致,否则在边界上会出现半像素的偏移。最近邻插值(nearest)会让结果更"硬",块状感更强,我一般不用,除非要做像素级的统计分析。
归一化的顺序也有讲究。正确的顺序是:加权求和 → ReLU → 上采样 → 除以最大值。先上采样再归一化和先归一化再上采样,结果会有细微差别,前者更容易因为插值产生的中间值把最大值抬高一点。我习惯把插值放在归一化之前,这样归一化是在最终分辨率上做的,视觉上更稳定。
除法那一步一定要防零。我对着一批几乎全黑的图排查过一次,就是因为ReLU之后全是 0,最大值也是 0,除以 0 得到了 NaN,后面astype(np.uint8)时 NaN 被转成了 0,看起来像是"热力图全黑"的 bug,实际是数值问题。加一个1e-8就能解决,但这 8 个字符让我多花了半小时。
3. 核心代码实现:从 hook 到热力图
3.1 抓特征图:forward hook 的正确写法
前向 hook 的回调签名是(module, inputs, output),output就是这一层输出的张量。问题在于,你要用这个张量做两件事:一是留着算加权和,二是挂一个梯度 hook 去截获反向时的梯度。这两件事对张量的要求是矛盾的。
保存激活值的时候,应该detach()掉,否则整个计算图都会被引用住,显存会一路涨上去;但一旦 detach 了,张量的requires_grad就是False,在它上面调register_hook是无效的,回调永远不会触发。我第一次踩这个坑时,gradients一直是None,还以为是模型结构的问题。
正确写法是在原始张量(未 detach 的那个)上注册梯度 hook,在回调里把梯度 detach 下来存着:
def _forward_hook(self, module, inputs, output): self.activations = output.detach() output.register_hook(self._save_grad) def _save_grad(self, grad): self.gradients = grad.detach()这样激活值脱离了计算图,梯度 hook 又能正常工作。要注意self.activations每轮会被覆盖,所以同一时刻只能处理一个前向。批量处理时,self.activations和self.gradients都是带 batch 维度的四维张量,形状[B, C, h, w],后续运算都是逐样本的,天然支持。
3.2 抓梯度:三种写法与各自的坑
第一种是我上面写的张量级register_hook,最轻量也最灵活,推荐。
第二种是模块级register_full_backward_hook,回调签名是(module, grad_input, grad_output),grad_output[0]就是输出张量的梯度。它在 PyTorch 新版本里行为比较稳定了,但老版本上有过一些已知问题,而且它对某些包装过的模块不生效。
第三种是完全不用 hook,直接对激活张量调retain_grad(),然后反向之后从.grad里读。这种写法最直观,缺点是必须在拿到激活张量的那一刻就调用,而激活张量通常在 hook 回调里,等于还是绕不开 hook。
不管用哪种,有三件事必须做对。一是每次调用前把gradients清成None,否则上一轮的结果会被重复使用,画出来的图和当前输入完全无关,这种 bug 极难发现,因为图看起来"挺像那么回事"。二是前向之后立刻反向,中间不要插入别的计算,否则目标分数和你以为的不是一回事。三是反向之后摘掉 hook 或者复用同一个类实例,频繁构造析构会有性能开销。
3.3 权重聚合与热力图生成的完整流程
把整条链路串起来,一共七步:前向拿到 logits;确定目标类别(自动取 argmax 或者用传入的);构造标量分数;反向;读梯度;按通道求平均权重并加权求和;上采样到输入尺寸并归一化。
构造标量分数这一步有个细节。如果要对 batch 里每个样本解释它自己的预测类,不能直接logits.sum().backward(),虽然数学上由于样本之间独立,梯度确实等价于逐个反向,但语义上不清晰,而且如果模型里有跨样本的运算(比如训练模式下的 BatchNorm)就完全不对了。稳妥的写法是:
idx = class_idx.view(-1, 1) score = logits.gather(1, idx).sum() score.backward()gather按位置取出每个样本的目标类分数,求和之后反向,每个样本的梯度互不干扰。
3.4 一份可以直接抄的实现
这段是我库里最终稳定下来的版本,去掉注释大概七十行,拿去改改就能用:
import torch import torch.nn.functional as F import numpy as np import cv2 class GradCAM: def __init__(self, model, target_layer): self.model = model self.target_layer = target_layer self.activations = None self.gradients = None self._handle = target_layer.register_forward_hook(self._forward_hook) def _forward_hook(self, module, inputs, output): self.activations = output.detach() output.register_hook(self._save_grad) def _save_grad(self, grad): self.gradients = grad.detach() def __call__(self, x, class_idx=None, out_size=None): self.model.eval() self.model.zero_grad() self.activations = None self.gradients = None logits = self.model(x) if class_idx is None: class_idx = logits.argmax(dim=1).detach() if isinstance(class_idx, int): class_idx = torch.full((x.size(0),), class_idx, dtype=torch.long) class_idx = class_idx.to(x.device) score = logits.gather(1, class_idx.view(-1, 1)).sum() score.backward() if self.gradients is None: raise RuntimeError("未捕获到梯度,检查目标层是否参与了前向计算") weights = self.gradients.mean(dim=(2, 3), keepdim=True) cam = (weights * self.activations).sum(dim=1, keepdim=True) cam = F.relu(cam) size = out_size if out_size else x.shape[-2:] cam = F.interpolate(cam, size=size, mode="bilinear", align_corners=False) flat = cam.flatten(2) cam = cam / (flat.amax(dim=2, keepdim=True).view(-1, 1, 1, 1) + 1e-8) return cam def remove(self): self._handle.remove()几个地方值得单独说。self.model.zero_grad()放在前向前,保证没有残留梯度干扰;self.activations = None置空,防止 hook 因为某种原因没触发时读到旧数据;归一化是按样本维度展开后取最大值,用了flatten(2)再还原形状的写法,这样 batch 内每个样本独立归一化,不会出现一张图的极值压掉另一张图的情况。
remove()是我强制要求调用方显式调的。曾经有一次在循环里每张图都 new 一个 GradCAM 对象,跑了两百张之后显存直接爆掉,查了半天才发现每次注册的 hook 都还挂在模块上,一直都在收集梯度和激活值。
3.5 叠加显示与批量输出
数值算对了,画出来难看也白搭。叠加这一步有三个坑。
第一是颜色映射,cv2.applyColorMap输出的是 BGR 顺序,直接和 RGB 原图混合会红蓝颠倒。要么先cv2.cvtColor(heat, cv2.COLOR_BGR2RGB),要么整个流程都用 BGR。我习惯前者,因为原图从数据集读出来通常是 RGB。
第二是类型,applyColorMap要求输入是uint8,把 0 到 1 的浮点直接传进去会被截断成 0 和 1 两个值,画出来只剩黑白两色。正确做法是先乘 255 再转uint8。
第三是透明度,alpha取 0.4 到 0.5 之间比较合适。太低看不出热力分布,太高会盖掉原图纹理,反而不好判断热区落在物体的哪个部位。我一般用 0.45,这个值不是玄学,是在一批样本上连着看了几十张之后定下来的。
def overlay(img_rgb, cam, alpha=0.45): heat = cv2.applyColorMap(np.uint8(255 * cam), cv2.COLORMAP_JET) heat = cv2.cvtColor(heat, cv2.COLOR_BGR2RGB).astype(np.float32) out = heat * alpha + img_rgb.astype(np.float32) * (1 - alpha) return np.clip(out, 0, 255).astype(np.uint8)还有一步不能忘:原图从张量转回来的时候要反归一化。如果预处理用了mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225],转回 uint8 之前必须x * std + mean再clip(0, 1),否则叠加出来的原图颜色是错的,而且偏得很明显,有人会误以为是热力图的问题。
4. 扩展:多类别、多方法与批量
4.1 同一张图解释不同类别
最容易被问到的问题是"它为什么不是别的类"。这个用同一张图跑多次不同class_idx就能回答,把几张热力图并排放在一起,能直观看到模型区分不同类别时依赖的区域差异。
我做过一个三分类的对比实验,原图是一张混合场景。结果显示,判成第一类时热区集中在左上,判成第二类时热区集中在右下,两张图的交集很小。这个对比直接说服了业务方:模型不是在瞎猜,它在不同类别之间确实看的是不同的区域。
需要注意,大类别的热力图和二分类的语义不完全一样。多类别下,梯度衡量的是"这个区域对提升该类别相对其它类别的分数有多大贡献",由于分母里隐含了其它类的分数,各个类别的热力图会呈现出一定的互补性,这不是 bug。
4.2 从 Grad-CAM 扩到更强的变体
基础的 Grad-CAM 有两个已知弱点:一是小目标定位不准,因为权重是全局平均的,小目标的贡献容易被大面积的背景稀释;二是多实例场景下会漏掉一部分。
Grad-CAM++ 的思路是对梯度做高阶加权,用一阶和二阶梯度构造出逐像素的权重系数,相当于给每个空间位置单独的权重而不是全局共享一个。代价是实现复杂一些,而且对噪声更敏感。我在小目标密集的场景里对比过,确实有改善,但改善幅度没有宣传的那么大。
Score-CAM 走的是另一条路,完全不用梯度:把热力图当掩码去遮挡输入,再做前向,用输出的分数变化作为权重。这种方法对梯度噪声免疫,结果通常更平滑、更稳,但计算量正比于通道数,一个 512 通道的层就要前向 512 次,实时场景基本不用考虑。
我在库里留了一个统一的抽象:所有方法都实现__call__(x, class_idx),返回同样格式的 0 到 1 张量,上层切换算法不用改代码。这个设计的收益在后来的对比实验里体现得很明显,做消融的时候只是换了个类名。
4.3 批量处理与显存控制
批量跑是因为要做统计评估,单张看没意义。实现上没什么难度,只要保证 batch 内每个样本的归一化是独立的就行。
真正的问题在显存。前向时要保留整个计算图,激活值和中间结果一样不少,显存占用大概是纯推理的两到三倍。我实测下来,一张 224×224 的输入,推理时 batch 能开到 64,加了 Grad-CAM 之后 batch 到 16 就开始吃紧,到 32 会 OOM。
应对办法有两个。一是分块:外层按 8 或 16 切,内层循环处理,每块处理完手动torch.cuda.empty_cache()。二是只对需要的样本反向:如果一次只要解释 batch 里的某几个样本,可以只对这几个的分数求和反向,其余样本虽然也参与了前向,但不产生梯度,能省一部分。
提醒:分块处理时,每块一定要重新构造或者重置梯度,别指望
zero_grad()能清掉 hook 里缓存的gradients,那个是纯 Python 对象,得手动置 None。
5. 常见问题与排查实录
5.1 热力图全黑或者全红
全黑排在问题榜第一。按可能性从高到低排查:先看gradients是不是 None(是的话 hook 没生效);再看 ReLU 之前的加权和是不是全负(是的话模型对这张图确实不自信);最后看归一化那一步是不是遇到了除零。
有个隐蔽的情况:目标层选在了model.eval()之后被裁剪掉的分支上,或者被torch.no_grad()包住的前向里。后者我在一个推理脚本里踩过,整个前向都在no_grad上下文里,梯度根本不会计算,hook 自然也不触发,gradients一直是 None。解决办法是把可视化的那段前向单独拎出来,不要复用推理路径。
全红通常是归一化写错了,最常见的错误是用了 min-max 而不是只除最大值,背景被整体抬高。另一种可能是alpha设得太高,把原图全盖住了。
5.2 反向时报 in-place 相关的错误
报错信息大概是"某个变量被原地操作修改了,梯度计算无法进行"。根源是目标层的输出张量在前向之后又被改写,而我们保存的引用指向的是同一块内存。
三种解法,按推荐顺序:在 hook 里保存激活值时做一次clone(),这是最省事的;或者全局关掉 inplace 激活,把模型里的inplace=True都改成False,代价是显存略涨;再或者把目标层往前挪一层,绕开那个 inplace 操作。
我一般用第一种,但要注意clone()会增加一次显存拷贝,批量大的时候要算进预算里。
5.3 结果和论文里的示意图对不上
这个我遇到太多次了,绝大多数情况不是实现错了,而是口径不同。
最大的差异来源是预处理。论文里展示的图很多是直接从原图算的,而你用的是标准化之后的张量,虽然对特征图的影响有限,但叠加显示时的底色会明显不同。其次是目标层的具体位置,同一句"最后一层卷积",有人取卷积输出,有人取整个 block 的输出(中间还夹着残差相加和激活),这两者算出来的热力图会有可见的差别。
还有一个容易被忽略的点:论文里展示的往往是从几百张结果里挑出来的典型样本。你拿随机一张去比,本来就容易对不上。我的做法是固定一个几十张的验证集,把自己的实现和参考实现各跑一遍,然后算两张热力图之间的相关系数,平均在 0.85 以上我就认为口径基本一致了。
5.4 排查速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| gradients 为 None | 前向在 no_grad 下执行;目标层未参与计算 | 检查上下文管理器;打印 hook 是否触发 |
| 热力图全黑 | 加权和后全负;除零得到 NaN | 临时去掉 ReLU 看分布;归一化加 1e-8 |
| 整张图飘红 | 用了 min-max 归一化;alpha 过高 | 改为只除最大值;alpha 降到 0.45 |
| 报 in-place 错误 | 目标层输出被原地改写 | hook 内 clone;关闭 inplace 激活 |
| 热区位置整体偏移 | 反归一化遗漏;插值 align_corners 设置不一致 | 检查预处理逆运算;统一 align_corners |
| 批量结果互相污染 | 未按样本独立归一化 | flatten 后按 batch 维取最大值 |
| 显存持续增长 | hook 未摘除;计算图被引用未释放 | 显式调用 remove;激活值 detach |
| 结果每次都不一样 | 模型处于 train 模式;Dropout 生效 | 调用前先 model.eval() |
6. 怎么验证一张热力图靠不靠谱
6.1 定性检查:先看它是否"指对了地方"
最省事的检查是拿一批带标注框的样本,看热力图的最高响应点是不是落在框内。这个检查在学术上叫指向准确性,实现起来就几行:取热力图的最大值位置,判断是否在标注框范围内,统计命中率。
我一般会跑两百张左右,命中的比例如果在六成以下,基本可以断定目标层选得不对或者预处理有 bug。这个检查的另一个好处是能快速定位"系统性偏移":如果热区的中心总是往某个方向偏,八成是反归一化或者 resize 的坐标系搞错了。
还要看一眼响应的分布形态。好的热力图通常是集中在目标上的一到两块连续区域,如果呈现满脸麻子一样的散点分布,说明这一层的语义性太弱,该往深选;如果响应完全铺满整张图、没有结构,说明感受野已经大到失去定位能力,该往浅选。
6.2 定量:遮挡删除曲线怎么做
我更信任的是遮挡实验,它能给出一个和视觉无关的数值指标。步骤是:先记录模型对原图的置信度 p0;然后把热力图上值最高的前 k% 像素替换成数据集的均值颜色;重新前向,记录新的置信度;对 k 取 0、5、10 一直到 100,画一条下降曲线。
判断标准很直接:如果高亮区域真的是关键证据,遮掉之后置信度应该快速下降,曲线在一开始就很陡;如果遮掉之后置信度几乎不动,说明热力图指的区域和模型的决策依据无关,这张图就是不合格的。
同时要跑两个对照组:一个是随机位置遮挡同样比例的像素,一个是按热力图的最低值排序去遮(反向遮挡)。三条曲线放在一起看,如果正向遮挡的下降明显快于另外两条,这个解释才算站得住。我跑了大概三百张,正向遮挡在 k=20 时置信度平均降了 40 多个点,随机遮挡只降了不到 10 个点,差距相当明显。
把这条曲线做成自动化脚本之后,它就成了我改动目标层或者换算法时的回归测试。以前靠肉眼判断"这张看起来差不多",现在直接看曲线下的面积有没有变差,省了很多扯皮。
6.3 几个我踩出来的实操心得
第一,先固定一批样本再调参数。热力图这东西太容易"看起来还行"了,如果每次都换新图看,你会一直在自我确认。我建了一个二十张的固定集合,覆盖大目标、小目标、多目标、低对比度四种情况,每次改完代码都过一遍这二十张,心里才有底。
第二,把目标层做成可配置项而不是写死。不同任务的合适层完全不一样,写死之后换数据集就得改代码。我在配置里放了三个候选层名,运行时用哪个看任务,改一行就够了。
第三,不要用热力图去下定论。它反映的是某一层在某个输入上的响应分布,不是因果关系。我曾经用热力图说服一位同事"模型在看背景",后来做了遮挡实验才发现,那片背景被遮掉之后置信度只掉了两个点,模型其实是从另一个不太起眼的区域取证的,只是那块的响应没被 ReLU 保留下来。从那以后,我养成了"先看图、再跑删除曲线、最后再下结论"的习惯。
第四,保存中间结果。每次生成热力图时,顺手把目标层的激活值、梯度、加权和都存成.npz,这在后续排查"为什么这张图和上次不一样"的时候能省掉大量重跑的时间。存储成本很低,一张图几百 KB,换来的是可复现性。
第五,如果要做多方法对比,把上采样和颜色映射这两步抽成一个公共函数,所有方法共用。我一开始图省事各写各的,后来发现几个方法画出来的图色调不一致,对比的时候非常干扰判断——同一张原图,一个偏冷一个偏暖,会让人产生错觉,以为响应分布差异很大。
最后分享一个扩展方向:这套东西跑顺之后,可以往时间维度上走,对视频的每一帧生成热力图再拼成序列,能观察到模型的注意力在时序上的漂移;也可以往层维度上走,把同一次前向里多个 stage 的热力图叠成一张多尺度图,浅层的高分辨率细节和深层的语义区域对照着看,对判断模型到底学到了什么,比单看一张图信息量大得多。