CNN模型训得好不好不能只盯着一张准确率曲线。很多时候分类准确率已经刷到99%但模型到底靠什么判断没人说得清——它可能抓住的是猫的耳朵也可能是背景里的窗框。这种困惑在图像分类、医学影像分析、自动驾驶场景里特别要命。PyTorch的CNN可视化就是专门用来拆穿这个黑箱的把特征图、卷积核、梯度、类激活图一一变成肉眼可见的图片我们就能直接“拷问”模型你到底在看哪里在学什么为什么这么判断本篇是可视化系列的第二篇紧接第一讲基础部分之后重点落在CNN的可视化方法上适合已经会写基本分类代码、想进一步理解模型行为的开发者。很多初学者容易把可视化理解成“给论文配图”其实它的定位更接近“调试工具”。模型不是训完就结束的你需要知道它有没有作弊、有没有偷懒、有没有在浅层就学崩。这一篇我尽量把方法背后的原理讲清楚再给可直接跑的代码全部基于PyTorch实现不依赖额外的可视化库。1. 先搞清楚CNN可视化到底在解决什么问题1.1 可视化的底层价值我在实际项目里见过不少模型训练集准确率漂亮得离谱换到真实场景就翻车。后来一查发现模型根本没在学目标物体而是把背景里的某一个固定色调当成了分类依据。这种情况不靠可视化几乎不可能定位到。没有可视化你只能对着loss曲线猜然后调参、重新训练成本极高有了可视化你能直接看到神经网络的中间状态知道它在每一层提取了什么东西、最后一层把注意力放在哪里很多问题当场就暴露了。所以可视化最核心的价值不是“好看”而是提供三类信息。第一模型在浅层看到了什么比如边缘、颜色、纹理第二模型在深层关注什么区域比如判断“猫”时是否真的聚焦在猫脸而不是猫窝第三梯度在反向传播时走没走通比如中间层的梯度是否大面积消失。这三类信息分别对应了特征图可视化、卷积核可视化、梯度类可视化各有各的用途不能互相替代。1.2 四类主流方法的选择逻辑我在下面整理了一个简洁的选型思路实际排障时可以按这个顺序来用。方法类别核心观察对象最适合解决什么问题上手难度特征图可视化中间层输出feature map分析层间语义、检查信息流动低卷积核可视化模型学到的卷积核权重判断训练状态、诊断学习率低Grad-CAM类热力图类别对特征图的加权响应解释单张预测、定位判别区域中显著图/导向反向传播输入像素的梯度找像素级决策依据、对比敏感区域中高我的建议很简单刚开始先做特征图和卷积核因为实现成本低、理解成本也低。跑通之后再上Grad-CAM它解释性更强也更容易给别人讲明白。显著图类方法可以放在最后因为涉及修改反向传播逻辑调试起来稍麻烦一些。1.3 环境与代码骨架准备本文所有代码基于Python 3.10 PyTorch 2.x torchvision matplotlib numpy PIL验证过。其实版本不用太苛刻PyTorch 1.9以上基本都能跑。唯一要注意的是register_full_backward_hook这个API旧版本没有如果你是老环境建议先升级。import torch import torch.nn as nn import torch.nn.functional as F import torchvision from torchvision import models, transforms import numpy as np import matplotlib.pyplot as plt from PIL import Image读取图片的部分我习惯写成函数方便后续反复调用。这里直接以ImageNet预训练ResNet18为例但方法本身对自定义CNN模型同样适用。device torch.device(cuda if torch.cuda.is_available() else cpu) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model model.to(device).eval() transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def load_image(path): img Image.open(path).convert(RGB) img_tensor transform(img).unsqueeze(0).to(device) return img, img_tensor这里有个小提醒后续可视化叠加到原图时一定要做反标准化不然图像颜色会显得很奇怪。我在第4节里会给具体写法。2. 特征图可视化让每一层的“所见”现出原形2.1 forward hook机制与挂载要点特征图可视化的本质就是“截胡”模型正常前向传播时我们想办法把某一层输出的张量偷偷保存下来。PyTorch提供了hook机制不用修改网络结构也不用写一个暴力截断式的前向函数。feat_maps {} def make_hook(name): def hook_fn(module, input, output): feat_maps[name] output.detach().cpu() return hook_fn handles [] target_layers [ layer1.0.conv1, layer2.0.conv1, layer3.0.conv1, layer4.0.conv1 ] for name, module in model.named_modules(): if name in target_layers: handle module.register_forward_hook(make_hook(name)) handles.append(handle) try: with torch.no_grad(): _ model(img_tensor) finally: for handle in handles: handle.remove()为什么用named_modules而不是直接model.layer1[0].conv1.register_forward_hook因为named_modules可以批量挂载代码更简洁而且能通过名称精确匹配层。有一点务必记住hook用完之后要remove()。很多人跑完就忘结果后续每次前向都多了一堆无用hook速度和内存都会受影响。2.2 单通道与多通道特征图的渲染hook里保存下来的output一般是[1, C, H, W]C是该层卷积核数量H和W是特征图尺寸。对于单张图Batch维可以直接去掉。画单通道特征图很简单取其中一个通道用imshow显示灰度图就行。但更常用的是把多个通道排成网格一次看全。from torchvision.utils import make_grid def show_feature_map(feat, nrow8, channelNone, cmapviridis, figsize(12, 8)): feat feat.squeeze(0) # [C, H, W] if channel is not None: plt.figure(figsize(4, 4)) plt.imshow(feat[channel].numpy(), cmapcmap) plt.axis(off) plt.show() return # 单通道特征图本身没有RGB三通道要先扩张成 [C, 1, H, W] grid make_grid(feat.unsqueeze(1), nrownrow, normalizeTrue, padding2) plt.figure(figsizefigsize) plt.imshow(grid.permute(1, 2, 0).numpy()) plt.axis(off) plt.show()这里容易踩一个细节坑make_grid默认处理的是包含RGB三通道的图像Tensor而单张特征图只有1个通道直接塞进去会报错或显示错乱。先unsqueeze(1)把它变成[C, 1, H, W]再交给make_grid它会把每个特征图当单通道灰度图处理。normalizeTrue会把每个特征图独立拉伸到0到1区间这样不会出现某个通道数值范围太窄而整体发黑的情况。2.3 从浅层到深层看特征演化ResNet18这个结构如果从layer1看到layer4你能很直观地感受到特征从“具体”变“抽象”。浅层特征图保留较多空间位置信息看到的是边缘、颜色梯度、局部纹理中层开始出现重复性纹理块类似花瓣、毛发、鳞片这些微观结构到了深层特征图分辨率变低但每个通道对应的语义更强可能是某个物体部件甚至整个物体的响应。我习惯把浅层比作“查笔画”把深层比作“认字义”。浅层特征图通常高亮区域与输入图像的边缘位置高度重合深层特征图虽然人类很难直接读懂但通过Grad-CAM这类方法可以把它的响应区域映射回原图。实操时还有一个判断技巧如果浅层特征图大量是纯黑先检查输入预处理是否正确如果深层特征图也几乎全黑可能要怀疑梯度消失或者网络容量不够信息在深层被“压没了”。3. 卷积核与权重可视化还原模型真正学到的模式3.1 权重怎么取、怎么归一化卷积核可视化是很多人一开始会忽略、但实际特别有效的方法。因为第一层卷积核直接和输入图片的RGB三个通道计算点积它学到的模式往往是各种方向的边缘、颜色组合非常直观。而深层卷积核的每个通道都对应上一层多个特征图的加权组合没法直接映射到RGB空间所以不能直接用彩色图展示。取权重的代码很简单w model.conv1.weight.data.cpu() print(w.shape) # [64, 3, 7, 7]64个卷积核3通道输入7x7大小第一个维度是卷积核数量第二个维度是输入通道数后两个是卷积核尺寸。对浅层核来说三通道可以直接当作RGB显示。深层核比如model.layer4[0].conv1.weight形状可能是[512, 1024, 3, 3]没法直接当RGB看需要做归一化后再转成灰度图或热力图。这里有一个我踩过的坑直接把原始权重丢给plt.imshow结果看到一堆灰黑色块很难判断。原因是权重有正有负且取值范围通常比较小必须做一次min-max归一化再显示。3.2 用make_grid拼图展示批量展示卷积核时make_grid依然是最省事的方式。w model.conv1.weight.data.cpu() grid make_grid(w, nrow8, normalizeTrue, padding2) plt.figure(figsize(12, 6)) plt.imshow(grid.permute(1, 2, 0).numpy()) plt.axis(off) plt.show()对于深层卷积核可以只取前若干个核来画否则图太大、看不出细节。还有一个进阶做法用PCA把深层核的输入通道维度压到3维然后再当RGB显示。这个方案我有段时间经常用能看出深层核在低维投影下的主模式不过对初学者来说意义不大先把浅层看懂就够了。3.3 卷积核可视化能验证什么最有价值的用途是判断训练状态。随机初始化的卷积核看起来就像高斯噪声没有明显结构。训练正常的模型第一层卷积核会出现清晰的边缘、相位、颜色方向选择性滤波器类似经典视觉皮层细胞感受野的模式。如果训练完成后第一层核仍然像噪声我会优先怀疑学习率设置不合理或者模型训练根本没有收敛。如果所有卷积核看起来几乎一样我会怀疑出现过平滑或者梯度更新异常。这些判断不需要精确数值肉眼扫一眼就能发现端倪非常高效。提示如果跑的是自定义小模型且随机初始化未预训练浅层核看起来比ImageNet预训练模型“粗糙”是正常的不用慌。训练收敛后的核结构会逐渐清晰。4. Grad-CAM类激活图定位模型“看哪里”4.1 从CAM到Grad-CAM的原理差异Grad-CAM是我个人用得最多的解释方法。它回答的问题特别直接模型把这张图判断为类别A时到底是图片里的哪片区域起了决定性作用。最早的CAM方法要求模型结构必须是“卷积层 全局平均池化层 全连接层”然后把池化后每一维的权重映射回最后一个卷积层特征图。这个限制很严格实际模型里很难直接用。Grad-CAM的创新在于用梯度来替代结构上的权重。它计算目标类别得分对最后一层特征图的梯度然后对梯度做全局平均池化得到每个特征通道的重要性再加权求和。公式上可以这么记权重 特征图梯度的全局平均加权结果 ReLU(Σ(权重 × 特征图))最后一步ReLU必不可少。它把负贡献区域过滤掉只保留“对类别判断有正向促进”的区域。如果不加ReLU热力图上会同时出现支持这个类别和抑制这个类别的区域解释起来非常混乱。4.2 用hook捕获梯度并计算权重核心思路是在目标卷积层上同时挂forward hook和backward hook分别拿到前向特征图和反向梯度。目标层的选择很关键一般选最后一个卷积层因为它的语义信息最完整、空间分辨率也还没完全丢失。ResNet18的话我习惯用model.layer4[-1].conv2。activations {} gradients {} def forward_hook(module, input, output): activations[target] output.detach() def backward_hook(module, grad_input, grad_output): gradients[target] grad_output[0].detach() target_layer model.layer4[-1].conv2 target_layer.register_forward_hook(forward_hook) target_layer.register_full_backward_hook(backward_hook) # 前向 img_tensor img_tensor.requires_grad_(True) out model(img_tensor) class_idx out.argmax(dim1).item() # 反向 model.zero_grad() out[0, class_idx].backward()这里有一个关键细节backward必须在torch.no_grad()之外执行否则梯度根本不会流动。而且输入张量需要requires_grad_(True)这样后续做显著图时才能取到img_tensor.grad。如果只做Grad-CAM其实输入不要求requires_gradTrue因为模型参数的梯度仍然会流动但为了代码统一我在示例里把它打开了。4.3 叠加热力图到原图的完整流程拿到activation和gradient后计算热力图的代码比较固定我直接封装成函数方便复用。def grad_cam(activations, gradients, orig_img_tensor, orig_pil_img): act activations[target] # [1, C, H, W] grad gradients[target] # [1, C, H, W] weights grad.mean(dim(2, 3), keepdimTrue) # [1, C, 1, 1] cam torch.relu((weights * act).sum(dim1, keepdimTrue)) # [1, 1, H, W] # 缩放到原图尺寸 cam F.interpolate(cam, size(orig_pil_img.height, orig_pil_img.width), modebilinear, align_cornersFalse) cam cam.squeeze().cpu().numpy() # 单张热力图min-max归一化 cam_max, cam_min cam.max(), cam.min() if cam_max - cam_min 1e-8: cam_norm (cam - cam_min) / (cam_max - cam_min) else: cam_norm np.zeros_like(cam) # 反标准化原图 inv_normalize transforms.Normalize( mean[-0.485 / 0.229, -0.456 / 0.224, -0.406 / 0.225], std[1 / 0.229, 1 / 0.224, 1 / 0.225] ) img_display inv_normalize(orig_img_tensor.squeeze().cpu()).permute(1, 2, 0).numpy() img_display np.clip(img_display, 0, 1) plt.figure(figsize(8, 6)) plt.imshow(img_display) plt.imshow(cam_norm, cmapjet, alpha0.5) plt.axis(off) plt.show()反标准化这一步非常容易被忽略。因为图片在进模型前做了减均值除方差不反标准化直接显示原图颜色会失真热力图叠加在上面会让结论看起来不直观。逆变换的mean和std写法有一定技巧先把x (x - mean) / std这个过程解出来反变换就是x x * std mean对应到Normalize参数就是mean-mean/std、std1/std。Grad-CAM的变体很多Grad-CAM针对多类别定位做了改进Score-CAM不要梯度改用扰动得分来生成权重Layer-CAM可以保留更多细粒度空间信息。这些我都试过新手阶段先把Grad-CAM跑通即可后面的变体是在同一个框架上加改动。5. 显著图与导向反向传播从梯度反推决策依据5.1 普通显著图与实现显著图的核心思想更简单输入图片中哪些像素的变化对分类得分影响最大。做法是把某个目标类别的得分对输入图片求梯度然后按像素取绝对值。img_tensor img_tensor.detach().clone().requires_grad_(True) out model(img_tensor) class_idx out.argmax(dim1).item() model.zero_grad() out[0, class_idx].backward() saliency img_tensor.grad.abs().max(dim1, keepdimTrue)[0] # [1, 1, H, W] saliency saliency.squeeze().cpu().numpy() plt.figure(figsize(6, 6)) plt.imshow(saliency, cmaphot) plt.axis(off) plt.show()这里用max(dim1)取的是RGB三通道中梯度绝对值最大的一层相当于把“最敏感通道”作为显著性指标。实际表现通常是一张偏噪声的边缘图高亮区域集中在物体的轮廓和纹理边界。为什么会出现噪声因为CNN是分块卷积的梯度在像素级很容易出现高频振荡这也是SmoothGrad等增强方法存在的意义。5.2 导向反向传播的ReLU改造普通显著图噪声大一个重要原因是在反向传播时梯度会被ReLU的导数截断输入小于0的神经元梯度直接被清零。导向反向传播的改进在于不仅在输入小于0时把梯度清零还把梯度值本身小于0的情况也清零。换句话说它只允许“正输入 正梯度”的信号往回传这样得到的结果更干净也更符合人眼对物体轮廓的感知。实现方式很粗暴替换模型里的所有ReLU为自定义反向传播版本。class GuidedReLUFunction(torch.autograd.Function): staticmethod def forward(ctx, x): ctx.save_for_backward(x) return x.clamp(min0) staticmethod def backward(ctx, grad_output): x, ctx.saved_tensors mask_x (x 0).float() # 输入为正 mask_grad (grad_output 0).float() # 梯度为正 grad_input grad_output * mask_x * mask_grad return grad_input class GuidedReLU(nn.Module): def forward(self, x): return GuidedReLUFunction.apply(x) def replace_relu(module): for name, child in module.named_children(): if isinstance(child, nn.ReLU): setattr(module, name, GuidedReLU()) else: replace_relu(child)替换模型之后重新跑一次前向和反向取到的img_tensor.grad就是导向反向传播的结果。model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1).eval() replace_relu(model) img_tensor img_tensor.detach().clone().requires_grad_(True) out model(img_tensor) class_idx out.argmax(dim1).item() model.zero_grad() out[0, class_idx].backward() gb_saliency img_tensor.grad.abs().max(dim1, keepdimTrue)[0] gb_saliency gb_saliency.squeeze().cpu().numpy()这个用法最常见的坑是为了追求效率训练时把ReLU设成了inplaceTrue。inplace操作会覆盖输入张量导致ctx.save_for_backward(input)保存的还是修改后的数据mask就失效了。所以替换前最好先确保原模型里的ReLU不是inplace的或者替换成自定义模块后统一走新前向逻辑。5.3 三种梯度类方法对比我整理了一个简化版对比方便你根据场景选方法高亮内容噪声水平主要用途普通显著图像素级敏感区域较高快速定位边缘、纹理敏感点导向反向传播清晰轮廓、内部纹理较低观察类别相关的精细结构Grad-CAM区域级语义判别位置很低解释模型“看哪里”适合汇报SmoothGrad是普通显著图的一个增强对输入多次加少量高斯噪声分别求显著图后取平均噪声会显著降低。如果只想快速改善效果这是性价比最高的做法。def smooth_grad(model, img_tensor, class_idx, n_samples10, std0.1): total 0 for _ in range(n_samples): noisy img_tensor torch.randn_like(img_tensor) * std noisy noisy.detach().requires_grad_(True) out model(noisy) model.zero_grad() out[0, class_idx].backward() total noisy.grad.abs().max(dim1, keepdimTrue)[0] return total / n_samples我第一次跑SmoothGrad时被效果惊到了噪声明显减少轮廓和纹理连续性大幅提升。但它需要多次forwardbackward代价是时间和显存实际使用前要心里有数。6. 实操中一定会踩的坑与排查技巧6.1 Hook不生效或内存泄漏“我明明注册了hook为什么feat_maps里是空的”这是我被问得最多的问题。常见原因有三个注册的层根本不在前向路径上模型可能被内部的if分支跳过了注册了forward hook但模型在torch.no_grad()下执行虽然forward hook还是会触发但如果你想在hook里保存的output是CPU张量问题不大第三种是hook handle没有被remove导致后续每一轮推理都重复调hook函数内存持续增长。排查思路很简单先加一个print(name)确认层路径再确认model(img_tensor)真的走过了该层。如果自定义模型用了forward里的拼接分支、动态层选择这种故障率会高很多。6.2 归一化把信息“洗没”了有时特征图明明有响应画出来却是全黑。根源在于没有统一的归一化策略。特征图数值范围在不同层之间差异极大有些通道可能最大值只有0.01有些通道最大到几十。直接imshow默认会把最小值当0、最大值当1如果某个通道整体数值都很小在一整张网格图里就是一团黑。解决办法就是我在2.2节写的单通道单独显示时手动做一次min-max拉伸网格显示时依赖make_grid(normalizeTrue)。如果这个拉伸之后还是全黑再考虑是不是前向输入本身就是零均值化的纯黑区域那就是数据问题而不是可视化问题。6.3 热力图和原图对不上Grad-CAM热力图叠加到原图上位置偏移是高频问题。原因基本都出在尺寸处理上原图是1024x768模型输入是224x224热力图初始尺寸是最后一个卷积层的特征图尺寸比如7x7你在F.interpolate时没指定成原始图片尺寸而是指定成了224x224叠加到原图上必然错位。我的习惯是先用F.interpolate把热力图缩放到PIL原图尺寸再加显示时确保原始图片的显示尺寸和热力图一致。这样无论原图多大都不会错位。注意modebilinear和align_cornersFalse这两个参数前者让热力图平滑后者保证坐标对齐。6.4 常见问题速查表现象常见原因处理建议特征图全是黑未做归一化或输入预处理错误检查输入图像像素范围手动min-max拉伸卷积核噪声大训练未收敛学习率异常查看训练曲线适当调低学习率Grad-CAM热力图全黑目标层选择过深或梯度消失尝试更浅的卷积层或检查backward是否成功热力图与物体位置错位插值尺寸没对准原图统一使用PIL原图尺寸导向反向传播结果和普通显著图一样ReLU替换没生效检查replace_relu是否覆盖所有模块hook累积导致显存暴涨hook handle未remove每次用完后remove或写成上下文管理器backward报错“in-place”输入张量被inplace操作覆盖不用inplace ReLU输入detach后clone再requires_grad注意做梯度类可视化时如果用了torch.no_grad()包裹前向后向直接用会报错因为计算图已经释放了。想要保留计算图就别在可视化路径上加no_grad。我自己这几年做可视化的最大体会是可视化不是用来给模型“锦上添花”的而是用来和模型“对质”的。它不会直接提升准确率但每次看到不对劲的可视化结果后面往往都跟着一个真正的训练bug或者数据bug。建议新手从特征图和卷积核开始跑通之后再上Grad-CAM最后再碰显著图这类需要改反向传播的方法每一步都亲手把图画出来不要只抄现成轮子。只有亲手踩过“全黑图”“错位热力图”“空hook”这些坑你才会积累出那种一看可视化结果就知道模型哪里不对劲的手感。
阅读完成 · 觉得有帮助?