写在前面:本博客仅作记录学习之用,部分图片来自网络,如需引用请注明出处,同时如有侵犯您的权益,请联系删除!
前言
在人工智能与机器学习迅猛发展的今天,深度神经网络虽在诸多领域大放异彩,但其“黑箱”特性常让人望而却步——模型如何做出决策?哪些特征起了关键作用? 特别是基于CNN的模型,在计算机视觉任务中取得了显著突破,但这些模型往往缺乏可解释性,导致在失败时难以理解其原因。
而热力图,作为一把揭开“黑箱”之谜的钥匙,正逐渐成为可解释性AI领域的研究热点。它通过直观的颜色编码,直观呈现模型对输入特征的关注分布,将模型对输入数据的关注程度可视化,帮助理解模型决策背后的逻辑。为神经网络的透明化部署与鲁棒性提升提供更强支撑。
预备知识
论文名: Learning Deep Features for Discriminative Localization
论文速递: 点我转跳哦
代码通道: GitHub
论文名: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization
论文速递: 点我转跳哦
代码通道: GitHub
论文《Learning Deep Features for Discriminative Localization》提出了类激活映射(Class Activation Mapping, CAM)技术,通过将输出层的权重反向投影到最后一个卷积层的特征图上,生成类激活图,突出显示对分类贡献最大的图像区域,提高模型的透明度和可解释性。

缺点:依赖于全局平均池化层作为CNN实现判别性定位的关键组件,必须在最终输出层之前(分类默认softmax)。
那中间层怎么办呢?
不是分类又该怎么办呢?
因此《Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization》主要提出了一种名为Grad-CAM(梯度加权类激活映射)的技术,用于为基于卷积神经网络(CNN)的模型生成视觉解释。通过计算目标类别分数相对于卷积层特征图激活的梯度,并对这些梯度进行全局平均,得到神经元重要性权重,进一步与特征图进行加权组合,并通过ReLU激活函数,生成最终的Grad-CAM定位图。

优点:Grad-CAM适用于多种CNN模型,包括带有全连接层的CNN、用于结构化输出的CNN,以及用于多模态输入或强化学习的CNN,无需架构更改或重新训练。
Grad-CAM
代码
网络以黑箱特点被炼丹师所熟知,其通常由众多的线性操作(如卷积)和非线性单元(如激活函数),再加上其参数多依赖于梯度下降而自动学习,进一步设计的损失函数很难保证其是凸优化问题,因此很难通过数学方法去精确描述,因此需要其他方式进行说明。
Grad-CAM:简言之在前向传播得到网络输出后,根据特定类别的分类特征进行损失计算,进一步通过反向传播获取对应特征层的梯度,进一步进行加权求和,在利用COLORMAP_JET做可视化,以不同颜色图像网络的关注点。以下代码参考自太阳花的小绿豆 | 霹雳吧啦Wz。
直观的感受:对于分类任务而言,网络输出存在不同的类别,因此在反向传播时可针对某一类进行求偏导,即进行反向传播。通过链式求导法则可求导到任意层,通过求到的梯度的大小,或者说梯度系数来表示网络的关注点。通过对应梯度对通道特征进行加权,并缩放到输入图像尺寸,将网络的关注点直观展示出来。
非分类任务,又该如何实现Grad-CAM呢?
以下代码为分类任务:
import os
import cv2
import torch
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
from torchvision import models
from torchvision import transforms
class ActivationsAndGradients:
""" Class for extracting activations and
registering gradients from targeted intermediate layers """
def __init__(self, model, target_layers, reshape_transform):
self.model = model
self.gradients = []
self.activations = []
self.reshape_transform = reshape_transform
self.handles = []
for target_layer in target_layers:
self.handles.append(
target_layer.register_forward_hook(
self.save_activation))
# Backward compatibility with older pytorch versions:
if hasattr(target_layer, 'register_full_backward_hook'):
self.handles.append(
target_layer.register_full_backward_hook(
self.save_gradient))
else:
self.handles.append(
target_layer.register_backward_hook(
self.save_gradient))
def save_activation(self, module, input, output):
activation = output
if self.reshape_transform is not None:
activation = self.reshape_transform(activation)
self.activations.append(activation.cpu().detach())
def save_gradient(self, module, grad_input, grad_output):
# Gradients are computed in reverse order
grad = grad_output[0]
if self.reshape_transform is not None:
grad = self.reshape_transform(grad)
self.gradients = [grad.cpu().detach()] + self.gradients
def __call__(self, x):
self.gradients = []
self.activations = []
return self.model(x)
def release(self):
for handle in self.handles:
handle.remove()
class GradCAM:
def __init__(self,
model,
target_layers,
reshape_transform=None,
use_cuda=False):
self.model = model.eval()
self.target_layers = target_layers
self.reshape_transform = reshape_transform
self.cuda = use_cuda
if self.cuda:
self.model = model.cuda()
self.activations_and_grads = ActivationsAndGradients(
self.model, target_layers, reshape_transform)
""" Get a vector of weights for every channel in the target layer.
Methods that return weights channels,
will typically need to only implement this function. """
@staticmethod
def get_cam_weights(grads):
return np.mean(grads, axis=(2, 3), keepdims=True)
@staticmethod
def get_loss(output, target_category):
loss = 0
for i in range(len(target_category)):
loss = loss + output[i, target_category[i]]
return loss
def get_cam_image(self, activations, grads):
weights = self.get_cam_weights(grads)
weighted_activations = weights * activations
cam = weighted_activations.sum(axis=1)
return cam
@staticmethod
def get_target_width_height(input_tensor):
width, height = input_tensor.size(-1), input_tensor.size(-2)
return width, height
def compute_cam_per_layer(self, input_tensor):
activations_list = [a.cpu().data.numpy()
for a in self.activations_and_grads.activations]
grads_list = [g.cpu().data.numpy()
for g in self.activations_and_grads.gradients]
target_size = self.get_target_width_height(input_tensor)
cam_per_target_layer = []
# Loop over the saliency image from every layer
for layer_activations, layer_grads in zip(activations_list, grads_list):
cam = self.get_cam_image(layer_activations, layer_grads)
cam[cam < 0] = 0 # works like mute the min-max scale in the function of scale_cam_image
scaled = self.scale_cam_image(cam, target_size)
cam_per_target_layer.append(scaled[:, None, :])
return cam_per_target_layer
def aggregate_multi_layers(self, cam_per_target_layer):
cam_per_target_layer = np.concatenate(cam_per_target_layer, axis=1)
cam_per_target_layer = np.maximum(cam_per_target_layer, 0)
result = np.mean(cam_per_target_layer, axis=1)
return self.scale_cam_image(result)
@staticmethod
def scale_cam_image(cam, target_size=None):
result = []
for img in cam:
img = img - np.min(img)
img = img / (1e-7 + np.max(img))
if target_size is not None:
img = cv2.resize(img, target_size)
result.append(img)
result = np.float32(result)
return result
def __call__(self, input_tensor, target_category=None):
if self.cuda:
input_tensor = input_tensor.cuda()
# 正向传播得到网络输出logits(未经过softmax)
output = self.activations_and_grads(input_tensor)
if isinstance(target_category, int):
target_category = [target_category] * input_tensor.size(0)
if target_category is None:
target_category = np.argmax(output.cpu().data.numpy(), axis=-1)
print(f"category id: {target_category}")
else:
assert (len(target_category) == input_tensor.size(0))
self.model.zero_grad()
loss = self.get_loss(output, target_category)
loss.backward(retain_graph=True)
cam_per_layer = self.compute_cam_per_layer(input_tensor)
return self.aggregate_multi_layers(cam_per_layer)
def __del__(self):
self.activations_and_grads.release()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_tb):
self.activations_and_grads.release()
if isinstance(exc_value, IndexError):
# Handle IndexError here...
print(
f"An exception occurred in CAM with block: {exc_type}. Message: {exc_value}")
return True
def show_cam_on_image(img: np.ndarray,
mask: np.ndarray,
use_rgb: bool = False,
colormap: int = cv2.COLORMAP_JET) -> np.ndarray:
""" This function overlays the cam mask on the image as an heatmap.
By default the heatmap is in BGR format.
:param img: The base image in RGB or BGR format.
:param mask: The cam mask.
:param use_rgb: Whether to use an RGB or BGR heatmap, this should be set to True if 'img' is in RGB format.
:param colormap: The OpenCV colormap to be used.
:returns: The default image with the cam overlay.
"""
heatmap = cv2.applyColorMap(np.uint8(255 * mask), colormap)
if use_rgb:
heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
heatmap = np.float32(heatmap) / 255
if np.max(img) > 1:
raise Exception(
"The input image should np.float32 in the range [0, 1]")
cam = heatmap + img
cam = cam / np.max(cam)
return np.uint8(255 * cam)
def center_crop_img(img: np.ndarray, size: int):
h, w, c = img.shape
if w == h == size:
return img
if w < h:
ratio = size / w
new_w = size
new_h = int(h * ratio)
else:
ratio = size / h
new_h = size
new_w = int(w * ratio)
img = cv2.resize(img, dsize=(new_w, new_h))
if new_w == size:
h = (new_h - size) // 2
img = img[h: h+size]
else:
w = (new_w - size) // 2
img = img[:, w: w+size]
return img
def main():
model = models.mobilenet_v3_large(pretrained=True)
target_layers = [model.features[-1]]
data_transform = transforms.Compose([transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
# load image
img_path = "test.png"
assert os.path.exists(img_path), "file: '{}' dose not exist.".format(img_path)
img = Image.open(img_path).convert('RGB')
img = np.array(img, dtype=np.uint8)
# img = center_crop_img(img, 224)
# [C, H, W]
img_tensor = data_transform(img)
# expand batch dimension
# [C, H, W] -> [N, C, H, W]
input_tensor = torch.unsqueeze(img_tensor, dim=0)
cam = GradCAM(model=model, target_layers=target_layers, use_cuda=False)
target_category = 281 # tabby, tabby cat
grayscale_cam = cam(input_tensor=input_tensor, target_category=target_category)
grayscale_cam = grayscale_cam[0, :]
visualization = show_cam_on_image(img.astype(dtype=np.float32) / 255.,
grayscale_cam,
use_rgb=True)
plt.imshow(visualization)
plt.show()
if __name__ == '__main__':
main()
- 上述内容调用时的预训练好的模型,如果换成自己代码,则需要实例化模型,可参考训练和测试时的模型实例化,并加载对应的权重。
- target_layers 的选取,可先打印网络结构后进行选择,并非只能最后卷积层的输出。
- target_category的选取,和使用的数据集有关,如Imagenet数据上预训练好的模型,id则是Imagenet数据中1000个类别的id。
注意事项
色彩相反
由于要将热力图叠加到原图上,有时候会出现色彩相反,可考虑图像的RGB顺序不对,由于OpenCV读取默认是BGR,而PIL、plt,np所使用的RGB,因此需要检查是否通道顺序不对。
在下列代码中,可见heatmap 默认BGR,如需要将其转化为RGB,在调用函数show_cam_on_image时将use_rgb设置未True。
def show_cam_on_image(img: np.ndarray,
mask: np.ndarray,
use_rgb: bool = False,
colormap: int = cv2.COLORMAP_JET) -> np.ndarray:
heatmap = cv2.applyColorMap(np.uint8(255 * mask), colormap)
if use_rgb:
heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
heatmap = np.float32(heatmap) / 255
if np.max(img) > 1:
raise Exception(
"The input image should np.float32 in the range [0, 1]")
cam = heatmap + img
cam = cam / np.max(cam)
return np.uint8(255 * cam)
颜色溢出
COLORMAP_JET的颜色分布如下,值越大越红,反之越蓝,意味着暖色调的部分是关注的地方。

在RGB的图像叠加中,如果对于原图某通道在一定范围内等于255,在后续处理中叠加热力图的时候可能存在其他颜色,如粉红色或者其他区别于COLORMAP_JET的色彩,如果有这种情况,则需要降低两者叠加的比例,即cam = A*heatmap + B*img,其中A、B的值可酌情调整,或者满足A+B=1。
非分类任务
以下内容仅供参考(若有不对请指出):
对于非分类任务而言,在反向传播时不可能针对某一类进行求偏导,此时需要根据任务需要进行损失计算并反向传播,并通过对应梯度对通道特征进行加权,此时修改下列函数修改损失替换方式。
def get_loss(output, target_category):
loss = your_loss
return loss
并且随着网络深度增加,特征层抽象程度越高,既然是非分类任务,也无需特定层的特征进行特定加权,或者说一视同仁,所有的通道权重相等,在通道进行平均,进而去描述网络总体关注的地方,而非针对莫一类的关注点,此时Grad-CAM退化成类CAM的算法,只不过此时不依赖于于全局平均池化和最终输出层之前。此时修改下列函数的权重为1即可。
def get_cam_image(self, activations, grads):
weights = 1
weighted_activations = weights * activations
cam = weighted_activations.sum(axis=1)
return cam
总结
总结: 本文探讨了深度神经网络可解释性中的热力图可视化技术,详细解析了Grad-CAM的实现原理,即通过反向传播获取目标类别的梯度信息,对特征图进行加权融合,最终生成可视化热力图。代码部分展示了如何提取中间层激活与梯度,通过梯度加权实现分类任务的热力图生成,最后提供了常见显示异常和非分类任务的可视化问题的解决方案。
致谢
欲尽善本文,因所视短浅,怎奈所书皆是瞽言蒭议。行文至此,诚向予助与余者致以谢意。
参考
[1] 太阳花的小绿豆
[2] 霹雳吧啦Wz
[3] Learning Deep Features for Discriminative Localization
[4] Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization