PyTorch CNN可视化实战:特征图、Grad-CAM与卷积核详解

发布时间:2026/10/18 4:32:30
PyTorch CNN可视化实战:特征图、Grad-CAM与卷积核详解
自从入坑卷积神经网络以来我一直有个执念模型准确率上去了可它到底是在看图像的哪个部分是靠轮廓、纹理还是单纯记住了数据集里的背景噪声这种不弄清楚就上线使用的感觉就像把一台自己都看不透的设备交到用户手里。我之前习惯靠TensorBoard看训练曲线但那种监控解决不了“模型注意力在哪”的问题。这篇文章就围绕PyTorch生态下CNN可视化的几种核心玩法展开包括特征图可视化、输入梯度可视化、Grad-CAM类激活图、卷积核可视化以及实操过程中特别容易翻车的几个细节。如果你已经能跑通一个简单的图像分类训练流程但想进一步把网络内部的工作机制“拆开看”那这篇很适合你。1. 先把方法家族认清楚这四类可视化各回答什么问题CNN可视化不是单一技术而是一整个工具族。我刚接触那会儿很容易搞混觉得都是画图为什么这个代码保存下来像随机噪声另一个又像热力图。其实不同方法回答的问题完全不一样。特征图可视化回答的是“某一层在处理这张输入时提取到了什么模式”。比如第一层卷积可能提取出边缘、颜色块中间层可能激活纹理、局部形状高层则可能响应出更抽象的部件语义。它展示的是中间特征张量在空间上的响应强度。卷积核可视化回答的是“这个滤波器在找什么样的局部模式”。你把训练好的卷积核本身画出来看到的是一些类似边缘检测器、色块响应模板的图样。对浅层卷积核来说这些模式人类还比较容易解释到了深层卷积核尺寸往往是3x3或1x1直接看核就基本看不出什么语义了。**梯度可视化Saliency Map**回答的是“输入图像的每个像素对最终分类得分有多大影响”。它通过反向传播把损失对输入的梯度算出来每个像素的梯度绝对值大小就代表该像素的“贡献度”。**类激活图CAM / Grad-CAM**回答的是“模型做这个判断时注意力落在图像的哪个区域”。它会以热力图叠加在原图上的形式呈现比如一个“猫狗分类”模型对某个猫图输出“猫”的置信度很高时热力图会集中在猫的头部或身体区域。这四类方法可以综合使用。比如先用特征图看每一层的抽象过程再用Grad-CAM看最终决策依据最后用Saliency Map验证一下像素级归因是否合理。把它们组合起来看才算是把一个卷积模型从“黑盒”变成“灰盒”。下面几节我会按实操频率从高到低展开。2. 特征图可视化把中间层的“标准答案”直接画出来特征图可视化是最容易上手的方法本质上就是把某个卷积层输出的特征张量拿出来转成图片保存或展示。2.1 拿到中间层输出的两种姿势在PyTorch里拿中间层特征图常见做法有两种。第一种是直接改forward代码在forward里把想要的层输出return出来。这种改法最简单直白缺点是入侵了原始逻辑。第二种是用register_forward_hook挂个钩子不动原模型代码前向传播经过该层时自动拿到输出。我平时更推荐hook方式尤其是要可视化多个层的时候。import torch import torch.nn as nn import torchvision.models as models import matplotlib.pyplot as plt model models.resnet18(pretrainedTrue) model.eval() # 保存中间特征 features {} def hook_fn(name): def fn(module, input, output): features[name] output.detach() return fn # 举例看ResNet18的第一个block输出 model.layer1[0].register_forward_hook(hook_fn(layer1_0))这里要特别注意hook里拿到的output如果是单输出就是一个Tensor但有些模块会返回tuple或者dict不能假设永远是形状是(B, C, H, W)的张量。碰到复杂模块时可以先print看看output的类型和shape再处理。2.2 特征图怎么拼、怎么存、怎么看得舒服一个卷积层输出的特征图通常有几十上百个通道。每个通道单独看就是一张灰白的“响应图”。保存时建议先做归一化把值域压到0到255之间否则直接保存会得到一整片黑图或者过曝白图。def normalize_and_save(feature_map, out_path): # feature_map shape: (C, H, W) import torchvision.transforms.functional as TF import torchvision.utils as vutils # 将单通道特征图转成伪彩色更方便观察 vutils.save_image(feature_map.unsqueeze(0), out_path, normalizeTrue)但是这里有个容易踩的坑直接用normalizeTrue是按整个批次的最大最小值线性拉伸。如果这一层激活图绝大多数区域是近零的稀疏响应只有少数几个像素有响应线性拉伸会把微弱信号压得更不明显。我自己的习惯是保存前先对每个通道分别做min-max归一化需要保留相对强弱关系再看整张总图。你要是更看重“哪些位置响应最强”可以给每个通道单独归一化后再拼接这样虽然会丢失通道间相对强弱但人眼能看清每个通道的空间激活区域。多通道特征图的呈现方式也建议灵活一点。通道数少的时候比如64通道以内排列成8x8的网格图特别直观。通道数多的话比如256通道往上排成整张大图已经很难看出信息我会挑响应最强的几个通道单独保存再配合后续的Grad-CAM一起看。说到底特征图可视化更大的价值是“看模式”不是“看整图”。2.3 想看特定层的输出先搞清楚层名用print(model)把模型结构打印出来确认层名和模块层级再注册hook。ResNet系列还好层名基本都是layer1、layer2这类。但像VGG16这种模块嵌套比较深的模型层的名字很容易搞错注册hook前一定要确认你要挂的模块输出形状符合预期。曾经我在一个自己改过的分类网络上做可视化两层模块名字都带“conv”结果挂错了层输出形状直接不匹配后面一串报错。3. Saliency Map用梯度告诉每个像素它对预测贡献了多少特征图画的是“网络的兴趣”而梯度可视化画的是“输入的每个像素对结果的影响”。这种方式在图像检索、注意力分析这类场景里用得很多而且代码量很小。3.1 反传梯度的本质一张图像输入网络后前向得到输出我们来取目标类别的得分比如C类然后对输入图像求梯度。因为输出分数对输入图像的梯度对应的是“在这个像素上稍微改动一点点分数会变多少”梯度的绝对值越大就说明这个像素对结果的影响越大。这个方法在实现上就是三行核心代码。import torch.nn.functional as F def saliency_map(model, x, target_classNone): x.requires_grad_(True) logits model(x) if target_class is None: target_class logits.argmax(dim1) score logits[0, target_class] model.zero_grad() score.backward() grad x.grad[0] # shape: (C, H, W) saliency, _ torch.max(grad.abs(), dim0) # 取通道维度的最大绝对值 return saliency这里有个大家经常问的点为什么要对梯度取绝对值再跨通道取最大值因为梯度有正有负正的梯度表示提高这个像素会让目标分数上升负的梯度表示降低这个像素会让分数上升。单独看任意一个通道的正负号都容易产生偏差跨通道取绝对值的最大值相当于把每个位置“最强烈的影响信号”提取出来遗漏的信息最少。3.2 归一化和可视化陷阱Saliency Map的热图颜色常用的有三种画法直接灰度图灰度高的地方贡献大用matplotlib的jet/hot伪彩色叠在原图上只显示梯度值大于某个阈值的区域作为掩膜。我最常用的是伪彩色叠加但叠加前必须把saliency map归一化到[0,1]区间。import numpy as np import cv2 saliency_np saliency.cpu().numpy() saliency_np (saliency_np - saliency_np.min()) / (saliency_np.max() - saliency_np.min() 1e-8) heatmap cv2.applyColorMap(np.uint8(255 * saliency_np), cv2.COLORMAP_JET)一个非常容易踩的坑是如果你的训练数据做过标准化normalize比如ImageNet的mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]可视化前一定要把输入图像先做逆标准化否则计算出来的梯度是“标准化域”里的梯度可视化出来的图对比度和空间分布都会偏无法直观对应到真实像素上。3.3 一个小技巧目标类别别选错Saliency Map需要指定一个目标类别来反传。如果你想看“为什么模型把这张图判断成狗”那目标类别就选狗但如果你想看“模型对猫这个类别的响应有多强烈”哪怕输入是狗图也可以把目标类别设为猫。两种视角得到的信息是不同层次的。实践中我经常同时算多个目标类别的Saliency Map横向对比能看出模型对不同类别的敏感区域差异这比单独看一张图有价值得多。4. Grad-CAM热力图把“模型到底在看哪里”直接画出来Saliency Map关注的是像素级归因Grad-CAM关注的则是更宏观的空间区域。这也是目前最常用、最“出图效果”最好的一种CNN可视化手段。具体到工程实现Grad-CAM不要求改forward结构用一个hook就能拿下。4.1 核心原理梯度全局平均池化加权重求和Grad-CAM的基本思路是这样的取某个卷积层通常是最后一个卷积层输出的特征图 A形状为 (C, H, W)。求目标类别得分对 A 的梯度形状也是 (C, H, W)。对每个通道的梯度做全局平均池化得到一个长度为 C 的权重向量代表每个通道对目标类别的重要性。把特征图 A 按照这个权重向量加权求和得到 (H, W) 的CAM图。最后过ReLU把负值清零只保留正向促进目标类别的区域。为什么用全局平均池化而不用全局最大池化因为CAM要回答的是“哪些区域整体上促成了这个分类结果”最大池化只取最大的那个敏感点很容易丢掉一批协同贡献的区域。为什么最后要过ReLU因为梯度有正有负负梯度区域表示抑制目标类别的区域——可视化时我们通常只关心促进区域ReLU负值清零后热力图更干净。当然你想看模型“刻意忽略什么”也可以不过ReLU把负值区域也叠加显示这属于变体用法。4.2 手写Grad-CAM完整代码class GradCAM: def __init__(self, model, target_layer): self.model model self.target_layer target_layer self.activations None self.gradients None target_layer.register_forward_hook(self.save_activation) target_layer.register_full_backward_hook(self.save_gradient) def save_activation(self, module, input, output): self.activations output.detach() def save_gradient(self, module, grad_input, grad_output): self.gradients grad_output[0].detach() def generate(self, x, target_classNone): self.model.zero_grad() logits self.model(x) if target_class is None: target_class logits.argmax(dim1) score logits[0, target_class] score.backward() # 梯度全局平均池化 weights self.gradients.mean(dim(2, 3), keepdimTrue) # shape (1, C, 1, 1) cam (weights * self.activations).sum(dim1, keepdimTrue) # (1, 1, H, W) cam torch.relu(cam) cam cam.squeeze() # 缩放到原图尺寸 H, W self.activations.shape[2], self.activations.shape[3] cam F.interpolate(cam.unsqueeze(0).unsqueeze(0), size(H, W), modebilinear, align_cornersFalse) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam.squeeze().cpu().numpy()这里要提醒一下register_full_backward_hook和旧版register_backward_hook的区别。PyTorch新版本里推荐用register_full_backward_hook因为旧版hook在部分模块上会拿不到梯度。如果你的PyTorch版本比较老先升级一下再跑能少很多莫名其妙的报错。在实际项目里如果模型很大图像尺寸也大建议让CAM的hook只保留CPU张量副本不要一直持有GPU上的计算图节点否则多跑几次会出现显存越占越多的现象。4.3 热力图叠加的标准化操作生成CAM图后怎么叠到原图上也有讲究。我的标准操作是def overlay_cam_on_image(img_bgr, cam, alpha0.4): cam_resized cv2.resize(cam, (img_bgr.shape[1], img_bgr.shape[0])) heatmap cv2.applyColorMap(np.uint8(255 * cam_resized), cv2.COLORMAP_JET) result cv2.addWeighted(img_bgr, 1 - alpha, heatmap, alpha, 0) return resultalpha系数建议在0.3到0.5之间。太小了热力图看不出来太大了原图细节丢失。另外就是注意通道顺序PyTorch读图习惯是RGB而OpenCV很多操作默认是BGR混用之前先统一。Grad-CAM最常见的失败场景是热力图全部集中在某一个点上或者整张图都是蓝色低响应。如果你是拿训练后的模型来看验证集图片先检查一下模型是否处于eval模式。BN层和Dropout在train/eval两种模式下的表现差异很大train模式下BN还在用当前batch的统计量热力图会很不稳定。大多数型号的模型可视化务必加上model.eval()。5. 卷积核可视化看看每个滤波器到底在找什么这一节相对独立但同样非常有趣。卷积核可视化是把权重矩阵本身当作“小图”来看。5.1 把卷积核权重变成图片假设某层卷积核的shape是(C_out, C_in, K, K)画的时候一般是把C_out个卷积核排列成网格每个小格子显示C_in个通道在KxK空间上的权重。如果C_in是3RGB输入你甚至可以直接把它当作彩色图显示很多论文里展示的第一层卷积核就是这么画出来的。def visualize_filters(layer_weight, num_cols8): # layer_weight shape: (C_out, C_in, K, K) C_out, C_in, K, K layer_weight.shape num_rows (C_out num_cols - 1) // num_cols fig, axes plt.subplots(num_rows, num_cols, figsize(num_cols * 1.2, num_rows * 1.2)) for i in range(C_out): ax axes[i // num_cols, i % num_cols] filt layer_weight[i].detach().cpu().numpy() if C_in 3: # 直接当作RGB图显示但是要先对每个通道做归一化 filt_min filt.min() filt_max filt.max() filt_norm (filt - filt_min) / (filt_max - filt_min) filt_norm filt_norm.transpose(1, 2, 0) ax.imshow(filt_norm) else: # 单通道画灰度 filt_norm (filt[0] - filt[0].min()) / (filt[0].max() - filt[0].min()) ax.imshow(filt_norm, cmapgray) ax.axis(off) plt.show()第一层卷积核通常能看出明显的边缘检测器、颜色梯度方向。但深入几层之后卷积核的语义就非常难解释了这不一定是模型没学好而是高层特征本来就不是单一方向滤波器能解释的。如果你发现保存下来的卷积核图片很多是灰色的先确认权重是否已经经过训练随机初始化的权重观看意义不大。5.2 警惕反卷积与导向反向传播的“伪解释风险”有一种常见做法是用反卷积或者导向反向传播把“卷积核学到的模式”反演回原图空间。技术本身通用但在解释时要非常小心。反卷积图的本质是从高层激活反卷积回输入空间它展示的“像是某个模式”不等于模型内部真的在匹配这个具体图像模式。换句话说反卷积做出来的图更像是一个“重构可视化”而不是“识别依据可视化”。表达结论时措辞建议用“这个输入区域可以激活该类”而不是“这个滤波器就是识别这个的”。类似地DeepDream这类方法从随机噪声出发沿着梯度放大某种模式产生的图像虽然非常华丽但更多是展示“模型能响应什么”不是“模型最近在看什么”。很多新手在这里容易把概念混淆我建议做项目汇报时先分清楚你画的图属于哪一类再下结论。6. 实操阶段容易翻车的几个坑从层名到归一化处处都是细节可视化听起来简单真正跑起来会遇到一堆小问题。我把自己踩过的高频坑整理一下。6.1 模型模式和训练模式的差异这个前面提过但值得单独再强调model.eval()和model.train()不切Grad-CAM和特征图都会飘。尤其是带BN的ResNet系模型train模式下BN的统计量是动态的同一张图多次前向得到的特征图都会有差异。可视化是分析类任务要求可复现统一用eval模式是基本操作。6.2 归一化对象要分清我在第3节提过输入标准化的问题。这里再补充一个特征图归一化的注意事项有些人的特征图可视化代码里直接对整张图做min-max结果保存出来的图一片漆黑因为某个通道的异常大值把整体的动态范围拉爆了。解决办法有两种要么对每个通道单独归一化要么把离群值用clip截断到比如 [1%, 99%] 分位数之间再归一化。具体用哪种取决于你是想看相对位置还是想看相对强度。6.3 不要执着于把每个卷积层都画一遍网络一深层数一多你能生成的图数量爆炸级增长。但层数深了特征图之间的差异也越来越小画出来都是一堆密密麻麻的小格子信息价值很低。我更建议有选择性地可视化关键节点比如每个stage的第一层和最后一层、分类头之前的最后一层。把有限的注意力放在语义跳变明显的层上才会看到有价值的差异。6.4 可视化结果也要做“消融验证”Grad-CAM热力图叠出来看起来很漂亮但你知道它靠谱吗一个常见做法是做个简单的验证实验把热力图上响应最强的区域裁剪出来单独扔进模型看预测置信度是否依然保持高位再把响应最弱的区域裁剪出来单独测试如果强区域置信度明显高于弱区域说明热力图和决策之间的一致性比较好。如果两个区域测试结果差异很小那就要回头检查一下是不是网络没有充分训练或者特征提取层面出了问题。这一点我在实际项目里屡试不爽。之前遇到一个部署效果不稳定的模型所有人都盯着特征图看折腾了半天也没发现问题。后来做了热力图消融测试才发现模型注意力其实一直落在图像角落的水印上。从那以后我养成了习惯做完可视化一定顺手验证一下归因是否正确。7. 几个适合进一步探索的方向如果你把这几种基础方法吃透了继续深入可以从下面几个方向走Ablation CAM / Score CAMGrad-CAM的变体能在某些场景下给出更清晰的注意力区域。Feature Attribution的最优方法组合把Saliency Map、Integrated Gradients、DeepLIFT放在一起比较能更全面理解模型行为。Attention Distillation可视化把你的模型与教师模型的注意力图做对比很多模型压缩场景里会用到。不过我做项目最大的体会是可视化是手段不是目的。它最大的作用不是让图变得好看而是帮你建立对模型行为的“直觉”。什么时候该信这个模型、什么时候不该信可视化会给你很多直观的参考依据。把特征图、卷积核、Grad-CAM、Saliency Map这四类工具组合成自己的工作流在排查模型不足、对比模型改进、向别人解释模型行为的时候会顺手很多。