☰
Python打卡第43天
2026/10/19 2:11:11 网站建设 项目流程

@浙大疏锦行

PyTorch Hook机制与Grad-CAM可视化实战(CIFAR10)

标签:#深度学习 #pytorch #GradCAM #可解释AI #Hook回调函数 摘要:CNN常被视为黑盒模型,Grad-CAM是图像分类任务经典可解释性算法。本文从底层原理入手,先讲解回调函数、装饰器、Lambda匿名函数,再深入PyTorch的Module Hook与Tensor Hook,最后完整实现Grad-CAM,在CIFAR10数据集上可视化模型关注区域,理解CNN到底看图片哪里做预测。

一、前言

训练好卷积神经网络后,我们想知道:模型凭什么判定这张图片是猫/青蛙?图片哪个区域对分类贡献最大?普通前向、反向传播无法直接拿到网络中间卷积层的输出与梯度。PyTorch提供Hook(钩子),它本质是回调函数,可以在不修改网络结构的前提下,在网络前向/反向传播的过程中捕获中间层特征、梯度,Grad-CAM就是基于Hook实现。

可解释AI方法分为很多:SHAP、PDPBox多用于结构化表格数据;Grad-CAM专门针对CNN图像任务,生成热力图高亮图像关键区域。同时可以用来检测模型偏见(例如模型依靠性别特征判断职业,而不是职业本身特征)。

二、前置基础:回调函数、装饰器、lambda

2.1 回调函数

回调函数:把函数作为参数传给另一个函数,当内部逻辑执行完毕后,自动调用传入的这个函数。 核心特点:延迟执行、事件触发、解耦代码。 示例代码:

def handle_result(result): """处理计算结果的回调函数""" print(f"计算结果是: {result}") def calculate(a, b, callback): result = a + b callback(result) # 计算完成,调用回调 calculate(3, 5, handle_result)

2.2 装饰器(高阶函数)

装饰器也是高阶函数,包装原始函数,修改原有函数的执行行为,适合统一给多个函数增加相同逻辑,语法糖@。

def handle_result(result): print(f"计算结果是: {result}") def with_callback(callback): def decorator(func): def wrapper(a, b): result = func(a, b) callback(result) return result return wrapper return decorator @with_callback(handle_result) def calculate(a, b): return a + b calculate(3, 5)

回调函数 vs 装饰器

对比维度回调函数装饰器
本质作为参数传递的普通函数包装函数的高阶函数
目标特定时机执行下游处理逻辑改造原函数,增加额外功能
执行控制权外层函数主动调用回调装饰器包装替换原函数,修改执行流程
适用场景异步、事件监听、单次临时逻辑日志、权限、通用增强逻辑,可批量复用

Hook = 回调思想的落地。PyTorch在网络节点预留触发点,前向/反向事件发生,自动执行我们注册的回调(hook)。

2.3 lambda匿名函数

一行定义临时简单函数,用完即弃,Hook场景经常使用。

# 普通匿名函数 square = lambda x: x ** 2 print(square(5)) #输出25
  • lambda:只支持单行表达式,不能写多行逻辑
  • def普通函数:支持循环、分支,适合复杂逻辑,可重复复用

三、PyTorch Hook钩子详解

Hook分为两大类:Module Hooks(模块钩子,作用在nn.Conv2d/nn.Linear这类网络层)和Tensor Hooks(张量钩子,直接监听张量梯度)

3.1 Module Hooks(模块钩子)

  1. register_forward_hook:前向钩子,模块前向计算完成后触发,可以拿到该层的输入、输出特征图
  2. register_backward_hook:反向钩子,反向传播经过该模块时触发,可以拿到输入梯度、输出梯度

注意:注册hook会返回handle句柄,使用完成后handle.remove()释放钩子,防止内存泄漏、多次运行时重复触发。

前向钩子示例:

import torch import torch.nn as nn # 简单模型 class SimpleModel(nn.Module): def __init__(self): super().__init__() self.conv = nn.Conv2d(1, 2, kernel_size=3, padding=1) self.relu = nn.ReLU() self.fc = nn.Linear(2 * 4 * 4, 10) def forward(self, x): x = self.conv(x) x = self.relu(x) x = x.view(-1, 2 * 4 * 4) x = self.fc(x) return x model = SimpleModel() conv_outputs = [] # 定义前向钩子 def forward_hook(module, input, output): print(f"模块:{type(module)}, input shape: {input[0].shape}, output shape:{output.shape}") conv_outputs.append(output.detach()) # detach切断梯度图,节约内存 # 在卷积层注册钩子 hook_handle = model.conv.register_forward_hook(forward_hook) x = torch.randn(1,1,4,4) out = model(x) hook_handle.remove() #释放钩子

反向钩子示例:

conv_gradients = [] def backward_hook(module, grad_input, grad_output): print("反向钩子触发") conv_gradients.append((grad_input, grad_output)) hook_handle = model.conv.register_backward_hook(backward_hook) x = torch.randn(1, 1, 4, 4, requires_grad=True) out = model(x) loss = out.sum() loss.backward() hook_handle.remove()

3.2 Tensor Hooks 张量钩子

直接绑定在张量上,监听/修改张量反向传播时的梯度,适合对单独张量做梯度操作:

x = torch.tensor([2.0], requires_grad=True) y = x ** 2 z = y ** 3 def tensor_hook(grad): print("原始梯度:", grad) return grad / 2 #修改梯度,减半 hook_handle = y.register_hook(tensor_hook) z.backward() print("x的梯度:",x.grad) hook_handle.remove()

四、Grad-CAM原理

Grad-CAM全称Gradient-weighted Class Activation Mapping,梯度加权类激活映射。 核心思想:

  1. 前向传播:使用Forward Hook捕获最后卷积层的输出特征图activations
  2. 反向传播:使用Backward Hook,求目标类别logit对卷积特征图的梯度
  3. 对梯度在空间维度(H,W)全局求平均,得到每个通道的权重α_c$$ \alpha_c = \frac{1}{H W}\sum_{i,j}\frac{\partial score_{target}}{\partial A_{c,i,j}} $$
  4. 特征图乘对应通道权重,沿通道维度求和,合并所有通道信息,得到原始CAM $$ CAM = ReLU\Big(\sum_{c}\alpha_c A_c\Big) $$
  5. ReLU:只保留正向贡献区域,抑制对分类起负面作用的区域
  6. 上采样+归一化:把CAM放大到原图尺寸,归一化0~1,叠加原图可视化

为什么不用loss.backward()?Grad-CAM原始论文直接使用类别logit分数求导,不是交叉熵损失。所以构造one-hot向量传入backward的gradient参数,单独选定目标类别做反向传播。

五、完整代码实现(CIFAR10+SimpleCNN+GradCAM)

import torch import torch.nn as nn import torch.nn.functional as F import torchvision import torchvision.transforms as transforms import numpy as np import matplotlib.pyplot as plt from PIL import Image import warnings warnings.filterwarnings("ignore") # 设置随机种子,保证可复现 torch.manual_seed(42) np.random.seed(42) # 数据预处理 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) ]) # CIFAR10测试集 testset = torchvision.datasets.CIFAR10( root='./data', train=False, download=True, transform=transform ) # CIFAR10类别 classes = ('飞机', '汽车', '鸟', '猫', '鹿', '狗', '青蛙', '马', '船', '卡车') # 搭建简易CNN class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(128 * 4 * 4, 512) self.fc2 = nn.Linear(512, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = self.pool(F.relu(self.conv3(x))) x = x.view(-1, 128 * 4 * 4) x = F.relu(self.fc1(x)) x = self.fc2(x) return x # 设备 device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") model = SimpleCNN().to(device) # 训练函数 def train_model(model, epochs=1): trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) trainloader = torch.utils.data.DataLoader(trainset, batch_size=64, shuffle=True, num_workers=2) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(epochs): running_loss = 0.0 for i, data in enumerate(trainloader, 0): inputs, labels = data inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() if i % 100 == 99: print(f'[{epoch + 1}, {i + 1}] 损失: {running_loss / 100:.3f}') running_loss = 0.0 print("训练完成") # 加载/训练权重 try: model.load_state_dict(torch.load('cifar10_cnn.pth')) print("已加载预训练模型") except: print("无预训练权重,开始训练") train_model(model, epochs=1) torch.save(model.state_dict(), 'cifar10_cnn.pth') model.eval() # GradCAM类封装 class GradCAM: def __init__(self, model, target_layer): self.model = model self.target_layer = target_layer self.gradients = None self.activations = None self.register_hooks() def register_hooks(self): # 前向钩子:保存卷积层输出特征图 def forward_hook(module, input, output): self.activations = output.detach() # 反向钩子:保存梯度 def backward_hook(module, grad_input, grad_output): self.gradients = grad_output[0].detach() self.target_layer.register_forward_hook(forward_hook) self.target_layer.register_backward_hook(backward_hook) def generate_cam(self, input_image, target_class=None): model_output = self.model(input_image) if target_class is None: target_class = torch.argmax(model_output, dim=1).item() self.model.zero_grad() # 构造one-hot,选定目标类别求导 one_hot = torch.zeros_like(model_output) one_hot[0, target_class] = 1 model_output.backward(gradient=one_hot) gradients = self.gradients activations = self.activations # H、W维度求平均,得到每个通道权重 weights = torch.mean(gradients, dim=(2, 3), keepdim=True) # 加权求和,沿通道dim=1合并 cam = torch.sum(weights * activations, dim=1, keepdim=True) cam = F.relu(cam) # 上采样到原图32×32 cam = F.interpolate(cam, size=(32, 32), mode='bilinear', align_corners=False) # 归一化0~1,可视化使用 cam = cam - cam.min() cam = cam / cam.max() if cam.max() > 0 else cam return cam.cpu().squeeze().numpy(), target_class # 图片tensor转numpy,反归一化 def tensor_to_np(tensor): img = tensor.cpu().numpy().transpose(1, 2, 0) mean = np.array([0.5, 0.5, 0.5]) std = np.array([0.5, 0.5, 0.5]) img = std * img + mean img = np.clip(img, 0, 1) return img # 挑选测试集图片 idx = 102 image, label = testset[idx] print(f"选择的图像类别: {classes[label]}") input_tensor = image.unsqueeze(0).to(device) # 实例化GradCAM,目标层选conv3最后一层卷积 grad_cam = GradCAM(model, model.conv3) heatmap, pred_class = grad_cam.generate_cam(input_tensor) # 绘图 plt.rcParams["font.family"] = ["SimHei"] plt.rcParams['axes.unicode_minus'] = False plt.figure(figsize=(12, 4)) # 原图 plt.subplot(1, 3, 1) plt.imshow(tensor_to_np(image)) plt.title(f"原始图像: {classes[label]}") plt.axis('off') # 单独热力图 plt.subplot(1, 3, 2) plt.imshow(heatmap, cmap='jet') plt.title(f"Grad-CAM热力图: {classes[pred_class]}") plt.axis('off') # 原图+热力叠加 plt.subplot(1, 3, 3) img = tensor_to_np(image) heatmap_resized = np.uint8(255 * heatmap) heatmap_colored = plt.cm.jet(heatmap_resized)[:, :, :3] superimposed_img = heatmap_colored * 0.4 + img * 0.6 plt.imshow(superimposed_img) plt.title("叠加热力图") plt.axis('off') plt.tight_layout() plt.savefig('grad_cam_result.png') plt.show()

六、结果解读

运行代码,输出三张子图:原图、单独GradCAM热力图、叠加在原图上的热力图。

  • 热力图红色区域 = 模型最关注区域;蓝色区域代表几乎没有贡献。
  • 示例图片是青蛙,热力图高亮青蛙头部和腿部,说明模型依靠青蛙的头、腿特征判定这是青蛙。

区分两个概念:

  1. 原始GradCAM热力图:单纯数值矩阵,只有重要程度信息,看不到原图物体。
  2. 叠加(overlay)热力图:可视化后处理,半透明热力图盖在原图上,方便人观察,不会改变cam本身数值。

七、踩坑总结

  1. 钩子用完一定要remove,否则多次推理会重复注册钩子,内存泄漏。
  2. 目标层要选卷积层输出(池化之前),不要选池化之后特征图,否则热力图过于粗糙。
  3. model.zero_grad():每次生成cam前清空梯度,上一轮梯度残留会污染本次计算。
  4. output.detach()保存特征图,脱离计算图,防止内存占用爆炸。
  5. 归一化仅用于可视化:梯度本身可以正数负数,不是天然0~1。ReLU只保留正向贡献。
  6. backward(gradient=one_hot):不是loss.backward,GradCAM直接对类别logit求梯度。

八、拓展方向

  1. 用GradCAM做模型偏见检测:检查模型是否关注无关特征做预测。
  2. 更换目标层,浅层卷积可视化底层边缘特征、高层卷积可视化语义特征。
  3. 对比CAM、ScoreCAM、Ablation-CAM等其他可解释算法。

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

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

立即咨询