多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

神经网络模型可视化实战:特征图、Grad-CAM与t-SNE全解析

神经网络模型可视化实战:特征图、Grad-CAM与t-SNE全解析 简介面向深度学习研究者与算法工程师这份PDF系统综述了神经网络模型可视化的研究进展。内容涵盖可视化技术的兴起背景、经典模型LeNet-5、AlexNet、Inception、ResNet的对比以及Draw_Convnet、NN-SVG、TensorBoard、Netron等工具的特点与适用场景并探讨了可视化在诊断网络、指导调参、提升模型可解释性方面的应用对医疗、金融、自动驾驶等可解释性需求较高的领域具有参考价值。资源为单个PDF学术论文压缩包整体1.96MB来自《计算机科学与应用》2022年第12卷。目前已有460人学习。阅读后可系统了解该领域全貌获得工具选型依据与未来研究方向适合希望突破神经网络黑盒瓶颈的研究者快速入门。1. 神经网络模型可视化到底能回答什么问题一张特征图顶十行调试日志训练一个卷积神经网络loss 降到 0.4 后死活不动了调了三天学习率也没用——这时候与其继续喂学习率衰减不如把网络中间层的特征图和决策热力图打出来看一眼。神经网络模型可视化就是这么一门技术它不是给论文画几张漂亮插图而是用来回答“模型在学什么”“它凭什么这么判”“训练卡住时卡在哪”。不管你是照着吴恩达深度学习课后题在补基础还是用 PyTorch 复现一个分类网络都会遇到同一个问题网络是个黑匣子可视化就是撬开它的那根铁棍。这篇就沿着研究进展和工具应用两条线把能上手的做法和坑一次讲透。2. 可视化研究的三条主线结构、决策依据与特征空间从 AlexNet 在 ImageNet 夺冠之后神经网络规模越来越大“看权重”这种早期手段很快就失效了。无论是理解模型行为、排查 badcase还是给非技术背景的评审讲清楚算法为什么可靠都需要可视化。这几年神经网络模型可视化的研究基本沿着三条线走结构可视化、决策依据可视化、特征空间可视化。三者解决的问题完全不同落到工具上也各有各的用法。2.1 结构可视化过滤器和前向传播图最早期的可视化就是看权重。第一层卷积核通常能学到边缘、颜色、纹理这类底层特征把这几十个小图拼成一张大图肉眼还能看出点规律到了深层卷积核变成高维抽象模式单张图已经没法解释了。所以结构可视化的重点很快变成了“看数据流”张量从输入到输出每经过一层尺寸和通道数怎么变哪个环节塌了。常见做法是遍历模型的子层把每一层输出 shape 打出来确认前向传播和预期一致。# 打印每一层输出的shape快速检查数据流 def show_layers(model, x): for name, layer in model.named_children(): x layer(x) print(f{name}: {x.shape})这段代码的逻辑很简单named_children()只返回模型的直接子层不会递归到Sequential内部。遇到nn.Sequential包了一堆层的情况要在循环里再加一层判断否则你只能看到features: [1, 64, 56, 56]这种粗粒度信息看不到中间层的细节。输入x必须符合模型要求比如 ResNet 的 224×224否则会在某一层直接报 shape 错误这本身就是一种排错。结构可视化适合排查模型拼接、特征提取、多分支网络这类问题但它的缺点也很明显它只能告诉你数据流没断不能告诉你模型有没有学到正确的东西。于是研究重点很快转向了“决策依据”。2.2 决策依据可视化从 CAM 到 Grad-CAMCAMClass Activation Mapping是决策依据可视化的标志性方法。它的核心约束是网络在最后一个卷积层之后接全局平均池化再接全连接分类层。CAM 用分类层的权重把最后一层卷积输出的特征图做加权求和得到一张和输入图像尺寸接近的热力图亮的地方就是模型做判断时依赖的区域。但 CAM 的限制很死只有结构满足 GAP 的网络才能直接套用。很多模型在卷积层后直接接展平或全连接CAM 就废了。Grad-CAM 把“分类层权重”换成了“梯度”对目标类反向传播得到模型输出对最后一个卷积层每个通道的梯度把每个通道的梯度取平均当作该通道的权重再用这个权重对激活图做加权求和过一层 ReLU最后插值回原图。它不要求网络结构必须带 GAP所以成了实际项目里用得最多的工具。选目标层是这一步的关键。我一般会用下面这段代码先找出最后一个卷积层# 遍历所有模块记录最后一个Conv2d的名字 last_conv_name None for name, module in model.named_modules(): if isinstance(module, torch.nn.Conv2d): last_conv_name name print(last_conv_name)这里要注意named_modules()是全量递归MobileNet 里的 DepthwiseConv2d 也是 Conv2d 的子类会被识别到。实际操作时要打印出来去模型源码里确认这里是不是真正意义的“最后一个带空间下采样的卷积层”别把 1×1 卷积也算进去。2.3 特征空间可视化t-SNE 与 UMAP 把高维向量压进二维结构可视化看层决策依据可视化看区域还有一条线是看“特征长什么样”。比如人脸识别输入一张图经过神经网络输出高维向量这个向量通常是几百上千维无法直接画出来。特征空间可视化做的事情就是把高维向量降到二维让每个样本变成一个散点观察同类是否聚在一起、不同类是否分得开。主流的降维方法是 t-SNE 和 UMAP后者更快但参数更多。t-SNE 的核心思想是保持高维空间里的局部邻居关系让降维后相近的点仍然相近所以它对聚类结构的展示非常敏感。常见用法是取验证集或测试集的特征矩阵控制样本量在数千到一万左右直接交给TSNE拟合。训练曲线和 loss 只能告诉你“模型在收敛”特征空间可视化能告诉你“模型有没有把不同类别分开”。2.4 研究趋势可视化工具从“画图”走向“训练诊断”这几年神经网络模型可视化越来越不满足于“好看”。研究开始把可视化与对抗样本、分布外检测、多任务学习结合用 Grad-CAM 检查模型是不是靠背景纹理做判断用特征空间可视化发现训练集里的脏标签甚至有团队把注意力图当成训练时的正则约束。工具层面同样在进化。PyTorch 的 hook 机制让自定义可视化变得非常灵活TensorBoard 自带 Embedding Projector 可以直接投影高维向量但真正值钱的不是工具本身而是“可视化怀疑论”这套习惯拿到一张热力图先问它对应哪个类别的梯度、在哪个层取的激活、模型是 train 还是 eval 状态。工具只是加速验证怀疑才是避免被图骗的底层能力。3. 用 PyTorch 做一套基础可视化特征图、卷积核与高维向量研究进展回答了“为什么做”这一章解决“怎么做”。我习惯不依赖重型可视化框架直接用 PyTorch 的 hook 配合 matplotlib先跑通最小逻辑再考虑封装。因为只有亲手把张量抓出来才明白哪些环节容易翻车。3.1 用 forward hook 读取中间层特征图读取中间层输出最直接的办法是在目标层上挂一个 forward hook。hook 不会修改模型的前向过程只是在层执行完后回调一次。import torch import torchvision.models as models model models.resnet18(pretrainedTrue) model.eval() mid_output {} def hook_fn(module, input, output): mid_output[layer2] output.detach().cpu() # 注册hook拿到句柄用完之后要remove hook_handle model.layer2.register_forward_hook(hook_fn) x torch.randn(1, 3, 224, 224) with torch.no_grad(): model(x) feat mid_output[layer2] print(feat.shape) # 例如 [1, 128, 28, 28] # 用完移除hook避免影响后续调用 hook_handle.remove()代码里最关键的是output.detach().cpu()。如果不detach()这个输出会带着整张计算图GPU 显存会随着前向次数不断累积不cpu()后面做 matplotlib 画图时张量在显存里没法直接用还得再搬一次。torch.no_grad()则避免了前向过程中再建一张反向图。很多人第一次写 hook 都忘了 remove这一步后面避坑章还会细说。拿到特征图后画图也有一点讲究import matplotlib.pyplot as plt feat_np feat[0].numpy() # [128, 28, 28] fig, axes plt.subplots(4, 4, figsize(10, 10)) for i in range(4): for j in range(4): idx i * 4 j axes[i][j].imshow(feat_np[idx], cmapviridis) axes[i][j].axis(off) plt.tight_layout() plt.savefig(layer2_feature_maps.png, dpi150)这里有个坑imshow默认会按图片自身最大最小值做归一化如果某个通道的值都集中在很小范围内画出来就会偏暗甚至全黑。我一般会先对每个通道做一次独立的归一化再画效果会稳定很多。cmap 用viridis还是gray是喜好问题但灰度图在论文里看起来更像“特征图”展示时优先选它。3.2 卷积核可视化先归一化再拼图卷积核可视化比特征图更简单本质就是把权重张量变成图像。以 ResNet18 为例第一层卷积核形状是[64, 3, 7, 7]意思是 64 个 3 通道的 7×7 卷积核可以直接拼成 8×8 的网格。# 拿到第一层权重顺序是 [out_channel, in_channel, kh, kw] weights model.conv1.weight.detach().cpu().numpy() print(weights.shape) # 每个卷积核单独做最小最大归一化别整个张量一起做 w_min weights.min(axis(1, 2, 3), keepdimsTrue) w_max weights.max(axis(1, 2, 3), keepdimsTrue) weights_norm (weights - w_min) / (w_max - w_min 1e-8) fig, axes plt.subplots(8, 8, figsize(10, 10)) for i in range(8): for j in range(8): idx i * 8 j # 通道维要挪到最后matplotlib才能正确显示彩色图 axes[i][j].imshow(weights_norm[idx].transpose(1, 2, 0)) axes[i][j].axis(off) plt.savefig(conv1_filters.png, dpi150)这里最容易错的是维度weights[idx]的 shape 是[3, 7, 7]但 matplotlib 的imshow对彩色图要求[高, 宽, 通道]所以必须transpose(1, 2, 0)。归一化也建议对每个卷积核单独做因为同一个层里有的核数值范围大有的范围小统一归一化会把那些数值小但有清晰模式的核压成一片灰看起来好像没学到东西。3.3 高维特征降维到二维t-SNE 的调用与困惑度训练好的模型在倒数第二层输出的特征通常是一个几百上千维的向量。为了看它学得怎么样我会把验证集或测试集过一遍模型把所有特征堆成一个矩阵再用 t-SNE 降到二维按类别标色画散点图。from sklearn.manifold import TSNE # features_np: [N, 2048]labels_np: [N] # N 建议在 100010000样本量太大 t-SNE 会非常慢 tsne TSNE( n_components2, perplexity30, learning_rateauto, initpca, random_state42, ) feat_2d tsne.fit_transform(features_np) plt.figure(figsize(8, 6)) for cls in set(labels_np): mask labels_np cls plt.scatter(feat_2d[mask, 0], feat_2d[mask, 1], s5, labelstr(cls)) plt.legend() plt.savefig(tsne_feature.png, dpi150)t-SNE 的核心参数是perplexity可以理解成每个点在降维时考虑的邻居数量。默认 30但样本量少时比如只有几百张需要调小到 510否则每个点被迫“拉着”一堆不存在的邻居图会非常散。initpca能让结果更稳定random_state必须固定否则同一份特征跑两次会得到两张完全不同的图后面避坑章再展开。4. 用 Grad-CAM 定位“模型到底在看哪里”实现与三个必调参数特征图和卷积核能告诉你模型在学习什么东西但回答不了“模型为什么把这个样本判成这一类”。这个需求要靠 Grad-CAM 这类归因方法。它的结论非常直观把原始图像和热力图叠加亮的地方就是模型决策时的主要依据。4.1 Grad-CAM 的设计思路为什么梯度能当权重Grad-CAM 的前身是 CAM。CAM 用分类层的权重直接加权最后一层卷积特征思路好但结构限制大。Grad-CAM 把权重来源换成了梯度目标类分数对每个通道的梯度越大说明这个通道的变化对目标类分数的影响越大这个通道对当前决策就越重要。把每个通道的梯度取平均乘到激活图上再求和就得到了一张“通道重要性加权”的热力图。为什么要过 ReLU因为 Grad-CAM 只想保留对目标类分数有正向促进作用的区域。梯度乘激活值可能为正也可能为负负的那部分意味着“该区域越强目标类分数反而越低”也就是模型在刻意压制它。过滤掉负值后高亮的区域就是模型真正“依赖”的区域。4.2 一个可复制的 GradCAM 实现下面是一个不依赖外部库的最小实现目标层和类别都可以由调用方指定。import torch import torch.nn.functional as F import numpy as np class GradCAM: def __init__(self, model, target_layer): self.model model self.activation None self.gradient None target_layer.register_forward_hook(self._forward_hook) target_layer.register_full_backward_hook(self._backward_hook) def _forward_hook(self, module, input, output): self.activation output.detach() def _backward_hook(self, module, grad_input, grad_output): self.gradient grad_output[0].detach() def __call__(self, x, class_idxNone): self.model.eval() output self.model(x) if class_idx is None: class_idx output.argmax(dim1).item() self.model.zero_grad() # 只对目标类求梯度不要对batch的loss求梯度 output[0, class_idx].backward() # 每个通道取平均梯度作为权重 weights self.gradient.mean(dim(2, 3), keepdimTrue) cam (weights * self.activation).sum(dim1, keepdimTrue) cam F.relu(cam) # 插值回原图分辨率 cam F.interpolate(cam, size(x.shape[2], x.shape[3]), modebilinear, align_cornersFalse) cam cam.squeeze().cpu().numpy() # 最大最小归一化让热力图铺满0到1 cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam使用方式很简单先确定目标层比如 ResNet18 的model.layer4然后传入一张预处理好的图像张量得到热力图数组。要注意register_full_backward_hook是 PyTorch 1.8 之后推荐的接口旧版本是register_backward_hook但旧实现里grad_output不是 tensor处理起来更麻烦。如果跑的是老环境优先升级 PyTorch而不是迁就旧 API。这一段代码最容易踩的坑是反向传播对象。正确的做法是output[0, class_idx].backward()也就是对单张图片的目标类 logit 求梯度。如果你图省事对loss.backward()当 batch 大于 1 时梯度会被其他样本稀释最终热力图是“整批样本的平均关注区域”和单张图片的决策依据是两码事。4.3 三个必调参数目标层、归一化与叠加系数Grad-CAM 跑通容易跑好难。实际项目中我会重点关注三个参数。参数推荐做法作用常见问题目标层最后一个真正的卷积层平衡空间分辨率与语义强度选太浅热力图是边缘纹理选太深插值后模糊归一化方式插值后再做最大最小归一化让热力图对比度可读先归一化再插值会压掉大值区域叠加系数原图 0.5热力图 0.5控制原始信息的可见度系数太高热力图挡脸太低看不出区域目标层是最重要的。对 ResNet 来说我一般选layer4的最后一层卷积输出而不是layer4的整个输出。layer4输出后面可能还接 GAP如果你把整个layer4模块作为目标层hook 拿到的输出已经过了最后的激活层语义还在但分辨率会更低。最稳的做法是先打印模块结构找到最后一个Conv2d再决定。叠加热力图我习惯用 OpenCV 的applyColorMapimport cv2 # cam 已经是 [H, W] 且归一化到[0,1] heatmap np.uint8(255 * cam) heatmap_color cv2.applyColorMap(heatmap, cv2.COLORMAP_JET) # 原图是BGR格式权重系数按需调整 superimposed cv2.addWeighted(img_bgr, 0.5, heatmap_color, 0.5, 0)addWeighted的系数可以理解成透明度。0.5/0.5 是通用值如果原始图像细节很重要比如医学影像我会把原图权重提到 0.7热力图降到 0.3避免遮挡组织结构。5. 模型可视化避坑指南五条实打实的翻车记录工具跑通不难难在结果可信。以下几条都是我自己在项目里踩过、或者帮别人排查时常见的问题每条都按“现象 → 原因 → 解决”写能省很多时间。5.1 特征图全黑或全白归一化对象搞反了现象画出来的中间层特征图要么黑成一团要么白成一片完全看不出结构。原因有两个一是卷积层输出有正有负负值在imshow里会被裁成黑色如果激活值整体集中在小范围对比度就非常低二是很多人习惯把整个 batch 的特征矩阵一起做 min-max 归一化结果个别大值把其他通道全部压死。解决显示特征图前对每个通道单独做 min-max。如果只看结构也可以直接取绝对值再画。我之前有一段专门用的显示函数本质就是一行ch feat_np[i] ch (ch - ch.min()) / (ch.max() - ch.min() 1e-8)5.2 Grad-CAM 热力图和真实关注区域对不上现象分类“鸟”的样本热力图打在天空背景上模型明明有正确的眼和嘴特征可视化却显示它在看背景。原因最常见的是目标层选得太浅浅层特征包含大量边缘和纹理全局语义弱所以热力图会散布在整张图上。另一个高频问题是反向传播时对 batch 的 loss 求梯度导致热力图是整批样本的注意力平均结果不是当前输入的真实决策区域。解决把目标层换到最后一个卷积层反向传播改用output[0, class_idx].backward()。如果一张图同时有多个目标比如多标签任务就针对每个类分别做一次 backward别想一次把多个类都算出来。5.3 t-SNE 每次运行图形都变随机种子不是唯一原因现象同一份特征矩阵连续跑两次 t-SNE类间距离和簇形状差异非常大看起来像两份不同的数据。原因t-SNE 初始化默认是随机的优化过程本身也有随机性。另一个容易被忽略的是perplexity和样本量的关系当样本数偏少而 perplexity 偏大时每个点被强行连接的邻居太多稳定不下来。解决固定random_state并设置initpca样本量少于几百时不建议上 t-SNE直接用 PCA 更靠谱。用 t-SNE 做对比实验时所有模型的特征都要在同一个random_state、同一个perplexity下跑否则图形差异不是模型差异而是算法随机性。5.4 hook 不清理导致显存悄悄上涨现象训练时显存每隔几步稳定涨一块跑不到几百个 step 就 OOM代码逻辑看着没有任何泄漏。原因register_forward_hook返回的句柄如果被丢弃hook 会一直挂在模块上。更隐蔽的是 hook 回调里保存了output但没有detach()导致每次前向都留着一张计算图占用的显存只增不减。解决回调里第一时间output.detach().cpu()注册 hook 时把返回句柄存进列表用完后统一remove()。我建议写个小函数统一管理handles [] for name, module in model.named_modules(): if isinstance(module, torch.nn.Conv2d): handles.append(module.register_forward_hook(hook_fn)) # 结束时 for h in handles: h.remove()5.5 可视化结果无法复现BN 和 dropout 是隐形杀手现象同一个模型、同一张图上午跑和下午跑特征图和热力图都不一样尤其在训练中途可视化时结果每次都在变。原因模型没有切成eval()模式。dropout 在训练模式下会随机丢节点BN 会用当前 mini-batch 统计量归一化而不是使用累积的全局统计量。这两个因素都会让前向输出随每次运行不同。解决可视化前调用model.eval()。如果你的模型是自己训练完存在本地加载后没有跑过前向更新 BN 的 running 均值最好先用验证集跑一批数据再进入可视化流程。另外要严格复现时设置torch.manual_seed、np.random.seed并打开torch.backends.cudnn.deterministicTrue但这样会牺牲一部分速度只在对比实验时开。6. 把可视化推进到项目中的三个习惯6.1 批量跑验证集把 badcase 按 loss 排序再可视化单张图可视化只能看个例项目里真正有用的是批量看。我会把验证集全部跑一遍保存每个样本的 loss然后按 loss 从大到小排序对 top 几十个样本生成 Grad-CAM 热力图。这样能在半小时内判断模型是“普遍错”还是“集中在某一类上错”。# 伪代码逻辑先算loss再按loss排序 samples_with_loss [(loss.item(), img) for img, label in val_loader] samples_with_loss.sort(reverseTrue) for loss_val, img in samples_with_loss[:20]: cam grad_cam(img.unsqueeze(0)) save_overlay(img, cam, fbadcase_loss{loss_val:.3f}.jpg)6.2 在训练日志里记录“注意力中心”的偏移除了看单张热力图我还会把热力图的注意力中心存下来对热力图做阈值化计算高亮区域的质心。如果一个模型训了几十个 epoch注意力中心在同类样本上还是乱飘说明它没有形成稳定的语义特征这时候加数据增强比换网络结构更有效。def cam_center(cam, threshold0.5): mask cam cam.max() * threshold ys, xs np.where(mask) if len(xs) 0: return None return xs.mean(), ys.mean()6.3 换网络结构前先可视化一个 batch这是我的硬性习惯换网络、改 loss、调整参数量之前先对当前模型跑一次可视化。如果模型在看背景换更大的网络只是把背景学得更到位如果能确认模型已经在看目标区域剩下的问题才是容量不够。有一次我怀疑 ResNet18 容量不足做成 50 层之后效果没提升回头用可视化一看模型关注的是标签水印于是改成遮挡后处理效果立刻上来了。这个教训让我再也不敢跳过可视化直接堆参数。可视化这件事工具从来不是瓶颈动手前先问一句“我要验证的是哪类假设”才是瓶颈。希望这些方法和踩坑记录能帮你在下一个项目里少走几步弯路。本文还有配套的精品资源点击获取
返回列表