CS231n这门课的作业,做到assignment3的时候,大多数人都会卡在Network_Visualization这一节——不是因为代码写不出来,而是因为前面的作业全在训练判别模型,到了这里突然要“钻进模型的脑子里”,看看它到底依据什么做的决策。这篇博文把我在PyTorch版本下做完整套可视化作业的完整过程整理出来,包括显著性图、Grad-CAM热力图、对抗样本和类别可视化四个任务,从数学原理讲到可运行的代码,再聊到实际踩坑的细节。适合刚学完CNN基础、想理解深度学习可解释性的同学,也适合工作中需要给模型做“体检”的工程师参考。
1. 网络可视化到底在做什么:一份作业背后的四个核心问题
1.1 作业拆解:四个任务分别回答什么问题
CS231n的assignment3把可视化单独拎出来,不是让你画loss曲线,而是要求你回答四个完全不同层面的问题。官方notebook里其实只给了函数签名和几个显示图像的工具函数,真正的核心逻辑全部需要自己补全。
第一个任务是显著性图(Saliency Maps):给定一张图片和它的正确类别,计算每个像素对这个类别得分的影响程度,最终得到一张黑白“轮廓图”。模型识别一只猫时,哪些像素在起决定性作用,这张图会直接告诉你。
第二个任务是生成对抗样本(Fooling Images):构造一张与原始图片几乎无法用肉眼区分的图,但模型会以极高的置信度把它判定成指定的错误类别。这个任务直观展示了深度学习模型的决策边界有多脆弱。
第三个任务是类别可视化(Class Visualization):从随机噪声出发,通过梯度上升不断优化输入图像,最后生成一张模型认为“最像”某个类别的图。猫的类别可视化结果看起来像一张“猫的噩梦”,这正是模型内部表征的可视化产物。
第四个任务是Grad-CAM热力图(我补做的扩展任务):定位模型做判断时依赖的图像区域,以热力图形式叠加在原图上。输入一张狗的照片,你能看到模型主要是看狗的头、身体还是背景。现在的可解释性工作里,Grad-CAM基本是标配,所以做作业时不如直接一起学掉。
这四个任务的逻辑关系是一条递进线:先看输入层面模型对哪些像素敏感,再看中间层特征图的空间位置,接着探索决策边界的漏洞,最后反推模型内部对类别的抽象理解。共同引擎只有一个,就是反向传播求梯度。区别在于梯度作用的对象是输入像素、特征图通道、还是噪声图像本身。
| 任务 | 研究对象 | 梯度方向 | 产物 |
|---|---|---|---|
| 显著性图 | 输入像素 | 输入对正确类别得分的梯度绝对值 | 黑白轮廓图 |
| Grad-CAM | 特征图通道 | 类别得分对激活图的梯度加权 | 热力图叠加 |
| 对抗样本 | 输入像素 | 让错误类别得分上升 | 扰动极小的攻击图 |
| 类别可视化 | 噪声图像 | 让目标类别得分上升并加正则 | 模型“幻觉”图 |
1.2 为什么选了PyTorch而不是其他框架
原作业在早期确实提供过TensorFlow版本,但用PyTorch实现可视化这套流程,体验差别非常大。最核心的原因是PyTorch的动态计算图和autograd机制:你可以随时对输入张量设置requires_grad_,跑一次前向,再调用backward拿到输入梯度,整个过程不需要维护计算图会话,也没有“梯度停止”的额外开关。
另一个关键点是hook机制。Grad-CAM需要捕获中间层的激活和反向传播时的梯度,PyTorch的register_forward_hook和register_full_backward_hook用起来非常直接,几行代码就能搭好数据通路。新版PyTorch对backward hook的接口做过调整,返回的是(grad_input, grad_output)元组,从grad_output[0]里取值就能稳定拿到当前层输出的梯度。
torchvision里一行代码加载ImageNet预训练模型也是一个巨大优势:model = torchvision.models.resnet18(pretrained=True)直接返回带权重的模型,省去下载和转换格式的麻烦。作业里的可视化实验并不需要从头训练,预训练模型加eval模式就足够了。
用PyTorch做可视化还有一个隐性好处:调试直观。你可以随时打印中间张量的形状、均值、方差,甚至把hook捕获的梯度单独拉出来可视化。这种“随手能检查一切”的体验,在可视化这种需要反复试错的任务里太重要了。
2. 原理拆解:为什么梯度能告诉我们模型在看什么
2.1 显著性图:梯度就是模型的目光
显著性图的数学定义非常简洁:对于输入图像x和类别c,计算类别得分S_c对输入x的梯度,然后取绝对值并在颜色通道维度上取最大值,得到逐像素的显著程度。
公式可以写成:saliency = max_channel(|∂S_c / ∂x|)。
为什么这个量能表示“模型在看什么”?梯度的本质是输入变化对输出变化的影响速率。某个像素的梯度绝对值越大,说明这个像素的值稍微变一点点,类别得分就会发生剧烈变化。反过来说,模型对这个像素的位置非常在意。
拿猫的照片举例:如果模型真的在识别猫的轮廓、耳朵、胡须,那这些位置的梯度绝对值会明显高于平坦的背景区域,显著性图会把猫的轮廓勾勒出来。如果模型偷懒,靠背景里的草地判断“这是户外场景”,显著性图会诚实地把草地区域点亮。这就为诊断模型提供了直接抓手。
实现时有个细节值得注意:对三通道梯度取max(dim=1)而不是求平均,因为RGB三个通道的梯度可能存在互相抵消的情况——红色通道梯度为正、蓝色为负,平均后可能趋近于零,但每个通道本身都很敏感。取最大值可以保留“任一通道感兴趣”的信号。
2.2 Grad-CAM:把中间层的注意力搬上荧幕
显著性图反映的是输入像素层面的敏感度,但像素级的梯度往往看起来比较散乱,噪声不小。Grad-CAM则上了一个层次:它把目光放在最后一个卷积块的特征图上。
原始的CAM方法要求模型结构里必须有全局平均池化层,权重取的是类别对特征图通道的映射系数,局限性很大。Grad-CAM的思路是:既然得不到CAM直接要求的全连接权重,那就用梯度来代替。对于特征图A^k,计算类别得分y_c对A^k的梯度,然后对梯度的空间维度求平均,得到每个通道的权重α_k:
α_k = (1/Z) ΣΣ ∂y_c / ∂A^k_ij
再把权重和特征图做加权求和,经过ReLU得到最终的CAM:
L_cam = ReLU(Σ_k α_k * A^k)
最后把结果上采样到原图尺寸,叠加显示。ReLU的作用很关键:我们只关心对类别有正向促进作用的区域。负值区域意味着“这里的存在会让模型更不相信这个类别”,这不属于我们想展示的判别依据。
Grad-CAM的工程实现高度依赖hook,这也是它和显著性图最大的不同——你需要在forward的时候抓住特征图,在backward的时候抓住梯度。实际操作中,目标层选择对结果影响巨大。太浅的层语义信息不足,热力图会覆盖整个物体;太深的层空间分辨率太低,热力图会显得糊成一团。常见做法是取最后一个卷积块(例如ResNet的layer4最后一个BasicBlock),这层语义最强且空间尺度尚可,基本是经验最优解。
2.3 对抗样本与类别可视化:两个方向相反的梯度游戏
对抗样本和类别可视化用的都是梯度上升,但起点和目的完全相反。
对抗样本的出发点是一张真实图片。你选择某个错误的目标类别,然后沿着“让目标类别得分上升”的方向更新输入,同时限制总扰动量。迭代若干次后,原图几乎没变,但模型已经把它认成目标类别。这暴露了神经网络的线性特性:高维空间中沿着特定方向走一小步,就足以跨越决策边界。用sign(∂L/∂x)而不是原始梯度,能得到单位步长下的最大变化,配合幅度裁剪能精确控制扰动大小。
类别可视化的出发点则是纯随机噪声。同样做梯度上升,但目标是让某个类别得分最大化,同时加入各种正则约束,防止结果退化成不可理解的噪声。最终得到的图像往往带着强烈的纹理重复感——因为高层神经元对特定纹理模式非常敏感,而这些纹理从统计意义上代表了该类别的“特征碎片”。所以类别可视化生成的“猫”看起来像多只猫的残影扭曲在一起,这恰恰说明模型并没有一个完整的“猫模板”,而是学到了一大堆局部特征组合。
一个共同点是:这两个任务都必须小心处理图像值域。输入ImageNet预训练模型的数据经过了标准化,可视化图片最终要反标准化回0-255范围显示,否则你看到的全是灰蒙蒙一片。
3. PyTorch落地实战:核心代码逐行讲解
3.1 环境准备与数据预处理
我的实验环境是Python 3.10 + PyTorch 2.x + torchvision。模型用的ResNet-18,速度快、占用显存小,对可视化任务来说足够了。想折腾的同学也可以换SqueezeNet,效果类似。
ImageNet预训练模型要求输入尺寸为224x224,并严格按照mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]做标准化。这个mean和std来自ImageNet数据集的统计值,换成别的会直接影响可视化结果,因为预训练权重是在这套分布下学出来的。
import torch import torchvision import torchvision.transforms as T from PIL import Image def preprocess(image_path, size=224): transform = T.Compose([ T.Resize((size, size)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) image = Image.open(image_path).convert('RGB') return transform(image).unsqueeze(0) # 返回 [1, 3, 224, 224]注意:这里返回的是带batch维度的张量,后面所有可视化函数的输入输出我都统一用[batch=1, 3, H, W]的格式,减少维度混乱。
显示图像时一定记得反归一化:
def deprocess(img_tensor): img = img_tensor.squeeze(0).cpu().detach() img = img * torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1) img = img + torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1) img = img.clamp(0, 1) return img.permute(1, 2, 0).numpy()加载模型后第一件事就是调用model.eval(),如果没有这一步,BatchNorm会使用当前batch的统计量而不是训练时积累的全局统计量,可视化结果会充满随机噪声。这是新手最容易踩的第一个坑。
model = torchvision.models.resnet18(weights=torchvision.models.ResNet18_Weights.IMAGENET1K_V1) model.eval()3.2 显著性图:30行代码让模型“坦白”
显著性图的实现非常短,但每一步都有讲究:
def compute_saliency_maps(X, y, model): model.eval() X.requires_grad_(True) scores = model(X) loss = scores.gather(1, y.view(-1, 1)).sum() model.zero_grad() loss.backward() saliency = X.grad.abs().max(dim=1, keepdim=True).values return saliency几个关键点逐个说。
scores.gather(1, y.view(-1, 1))是从模型的输出logits里取出正确类别的得分,而不是直接用max。作业要求的是“正确类别的敏感度”,如果你用最大得分类别,那在对抗样本场景下语义就不对了。用sum而不是mean来合并batch的loss,因为sum的梯度是mean的batch倍,但归一化后不影响显著性图,而sum在代码上更直接。
backward之前必须调用model.zero_grad()。虽然我们的model参数没有更新,但某些层内部可能有buffer梯度残留,不清零会导致X.grad混入脏数据。
最后一步max(dim=1)是在颜色通道上取最大值,得到[1, 1, H, W]的显著性图。有些教材会建议做一次高斯模糊来降噪,实测下来模糊半径1.5左右效果不错,但Radam要保证不会把细小特征糊掉。
显示时用plt.imshow(saliency.squeeze(), cmap='hot'),突出黑白对比度。如果整张图几乎全黑,先检查X.grad是否存在非零值,很可能是在backward之前X被重新赋值了。
3.3 Grad-CAM:Hook是可视化的大杀器
Grad-CAM需要的不是输入梯度,而是特定中间层的激活和梯度。我用一个小类把它封装起来:
import torch.nn.functional as F class GradCAM: def __init__(self, model, target_layer): self.model = model self.activation = None self.gradient = None self.fwd_handle = target_layer.register_forward_hook(self._store_activation) self.bwd_handle = target_layer.register_full_backward_hook(self._store_gradient) def _store_activation(self, module, inp, out): self.activation = out.detach() def _store_gradient(self, module, grad_in, grad_out): self.gradient = grad_out[0].detach() def generate(self, X, class_idx=None): self.model.eval() if X.grad is not None: X.grad.zero_() X.requires_grad_(True) scores = self.model(X) if class_idx is None: class_idx = scores.argmax(dim=1) loss = scores.gather(1, class_idx.view(-1, 1)).sum() self.model.zero_grad() loss.backward() weights = self.gradient.mean(dim=(2, 3), keepdim=True) # [1, C, 1, 1] cam = (weights * self.activation).sum(dim=1, keepdim=True) # [1, 1, H', W'] cam = F.relu(cam) cam = F.interpolate(cam, size=(X.shape[2], X.shape[3]), mode='bilinear', align_corners=False) cam = cam - cam.min() cam = cam / (cam.max() + 1e-8) return cam def remove(self): self.fwd_handle.remove() self.bwd_handle.remove()使用方式很简单:
target_layer = model.layer4[-1] cam_helper = GradCAM(model, target_layer) cam = cam_helper.generate(X, class_idx=torch.tensor([281])) # 猫的类别索引用register_full_backward_hook而不是老版本的register_backward_hook,是因为现有接口明确返回grad_output。这里我刻意读取grad_output[0]而不是grad_input——如果目标层内部使用了ReLU这类inplace操作,grad_input可能会返回None,而grad_output通常稳定可用。
热力图叠加到原图上的推荐方式:
heatmap = cam.squeeze().cpu().numpy() # 0~1 # 用jet色彩映射再与原图加权融合Grad-CAM的常见迷惑行为是图全是一个颜色块:一方面检查target_layer是不是选太浅了(比如选了layer1),另一方面检查模型是否在train模式。两者都会导致空间信息模糊。
3.4 对抗样本:让模型“指鹿为马”
对抗样本的代码思路是迭代梯度上升,加上扰动幅度约束。下面是我整理的可运行版本:
def make_fooling_image(X, target_y, model, steps=200, lr=0.01, max_perturb=0.3): model.eval() X_fool = X.clone().detach().requires_grad_(True) for i in range(steps): scores = model(X_fool) loss = scores.gather(1, target_y.view(-1, 1)).sum() model.zero_grad() loss.backward() with torch.no_grad(): # 沿着梯度符号方向走,步长更容易控制 X_fool += lr * X_fool.grad.sign() # 限制相对原图的扰动幅度,保证肉眼不可辨 diff = (X_fool - X).clamp(-max_perturb, max_perturb) X_fool = (X + diff).detach().requires_grad_(True) return X_fool迭代次数和扰动上限是最敏感的两个超参数。我尝试过1000次迭代+0.5扰动上限,结果确实能够骗过模型,但图像已经出现肉眼可见的噪点。想要肉眼完全无感,max_perturb应该控制在0.2到0.3之间,迭代300次左右基本能达到目的。
分类网络对对抗扰动非常敏感的根本原因,是softmax在几百上千类的输出空间里保持着非常尖锐的决策边界。哪怕只是微小扰动,只要方向足够“刁钻”,logits之间的排序就会发生翻转。这提醒我们,评估模型不能只看准确率,鲁棒性测试也很重要。
3.5 类别可视化:梯度上升造出“噩梦图”
类别可视化是四种任务里最需要“艺术感”的。直接对随机噪声做梯度上升很容易得到纯高频雪花,必须加一堆正则约束。
def create_class_visualization(model, class_idx, jitter=4, blur_sigma=1.0, iters=200, lr=10.0, weight_decay=0.995): model.eval() x = torch.randn(1, 3, 224, 224).requires_grad_(True) for t in range(iters): x_jittered = x + torch.randn_like(x) * jitter scores = model(x_jittered) loss = scores.gather(1, class_idx.view(-1, 1)).sum() model.zero_grad() loss.backward() with torch.no_grad(): x += lr * x.grad x.grad.zero_() # L2正则:把像素拉回合理范围,防止某些通道极度放大 x = F.relu(x - 1.0) + F.relu(-x - 1.0) + x * weight_decay if blur_sigma > 0: x = gaussian_blur(x, blur_sigma) return xjitter的作用是在每一步给图像加上高斯噪声再算梯度,相当于一种随机数据增强,能有效防止结果卡在局部极值。L2正则项把超出[-1, 1]范围的像素值拉回来,避免某些通道数值爆炸。模糊则压制高频噪声,让生成结果更平滑。
类别可视化生成的结果往往让人大跌眼镜:狗类别会出现一堆扭曲的眼睛和毛发纹理,香蕉类别会有奇怪的黄色斑点。我的理解是:CNN在高层确实学到的是“特征碎片”——这堆碎片组合在一起,从统计意义上激活了某个类别,但绝不是一个人类能理解的完整物体。这正是神经网络与我们认知体系差异的直观体现。
4. 实操经验:那些论文里不会写但必须知道的细节
4.1 影响可视化效果的三大隐藏因素
做完整个作业,我最大的感触是:可视化代码本身不难,难的是让结果“看起来对”。有三个隐藏因素会直接影响效果。
第一个就是模型模式。几乎所有可视化任务都需要先model.eval()。BatchNorm在train模式下会利用当前batch的均值和方差,而可视化时输入通常只有一张图,batch统计量和全局统计量差异巨大,产生的梯度噪声会彻底淹没真正的显著性信号。Dropout同理,train模式下它随机丢弃神经元,可视化结果每次运行都不一样,严重干扰判断。
第二个是梯度来源。backward之前必须执行model.zero_grad()。有些模型在forward过程中会向某些buffer写入梯度,虽然不是主要参数,但不清零的话,X.grad里会混入之前遗留的累积值。最稳妥的做法是:每次backward前统一零化。
第三个是归一化方向。可视化结果展示前必须反归一化,这是新手最容易忽略的。很多人直接plt.imshow(tensor.numpy()),出来的全是灰蒙蒙的图像,因为像素值还在标准化后的分布里。反归一化时记得clamp到[0,1],否则高亮区域会过曝,丢失细节。
4.2 常见错误与排查速查表
我把实际跑下来遇到的各种问题和解决办法整理成了一张对照表,遇到问题直接查就行:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 显著性图全黑 | X.requires_grad_(True)设晚了或X被重新赋值 | 打印X.grad是否为None;确认loss.backward后梯度已生成 |
| 显著性图全是白噪点 | 模型在train模式,BN统计量异常 | 加model.eval();用固定随机种子复现 |
| 热力图没有高亮区域 | target_layer选太浅或太深 | 换用最后一个卷积块;检查热力图方差是否为0 |
| Grad-CAM代码报错 | hook版本兼容问题 | 使用register_full_backward_hook;读取grad_output[0] |
| 对抗样本肉眼可见噪声 | 扰动幅度上限过大或迭代过多 | 将max_perturb降到0.3以下;减少迭代次数 |
| 类别可视化出现NaN | 学习率太大导致梯度爆炸 | 降低lr;增加L2正则强度;缩小图像尺寸 |
| 叠加热力图后原图看不清 | alpha权重太低或太高 | 将热力图归一化后,用alpha=0.4~0.6试 |
杀招是“梯度统计先行”:碰到任何不符合预期的结果,先打印X.grad.min()、X.grad.max()、X.grad.mean()这三个值。如果梯度均值接近0且方差很大,说明梯度被噪声主导;如果梯度全为0,说明反向传播链路断了。这套方法论基本能定位90%的可视化问题。
4.3 Hook的生命周期与内存管理
Grad-CAM这类用hook的方法,最容易被忽略的是资源释放。注册hook之后,如果没有保存handle并主动移除,hook会一直挂在模型上,内存悄悄增长不说,还可能干扰后续可视化任务。
我建议把Grad-CAM封装成类并显式实现remove方法,用完务必调用。特别是在Jupyter Notebook这种长期运行的进程里,反复注册hook不清理,跑几次实验就会发现内存占用飙升到几个G。
另外一个细节:backward之后及时删除hook。因为可视化任务往往只做一次前向加反向,hook在拿到数据后就没有价值了。continue运行反而可能在下一次backward时更新到过期的缓存。
5. 从作业到真实项目:网络可视化能用在什么地方
5.1 模型体检:定位“偷看”的行为
我自己的实际项目里,可视化帮过大忙。曾经调试一个工业质检模型,测试集准确率接近97%,但部署到产线后频频漏检。后来用Grad-CAM对一批误检样本做了可视化,结果非常直观:模型关注的区域根本不是缺陷本身,而是产品背景中的一处固定纹理。原因是训练数据里,有缺陷的样本几乎都来自同一条产线的固定工位,模型偷学了背景特征。
这个案例说明一个通用思路:上线前对所有类别做一轮Grad-CAM,看模型关注区域是否合理。如果你是做医疗影像辅助诊断,模型如果依据图像角落的医院logo做判断,那准确率再高也不敢用。可视化不是花架子,它是模型可控性的基础手段。
5.2 论文与汇报中如何得体地使用可视化
做课题研究和写技术方案时,可视化的使用也有方法论。首先是图像规范:一张完整的可视化结果图,应该同时展示原图、热力图或显著性图、以及两者叠加图,三张并排。只放一张叠加图虽然省空间,但审稿人追问“热力图数值范围是什么”的几率很高。
叠加热的配色也要统一。显著性图建议用hot色彩映射,Grad-CAM建议用jet或turbo,全篇保持一致。表格里要写清楚模型名称、目标层、类别索引,以及归一化方式。这些都是别人复现你结果时必要的信息,不写清楚等于没展示。
数据集的可视化样本选择也有讲究:不能只挑最好的结果。如果模型在某些典型样本上表现异常,不要藏着,反而是很好的分析材料。论文里展示“失败案例+原因分析”往往比全成功案例更有说服力。
5.3 下一步可以玩的扩展方向
做完作业之后,可视化这条线还有很多延伸空间。想深入研究的话,我建议按以下顺序探索:
Transformer时代,注意力可视化成为新热点。你可以用类似Grad-CAM的思路,对ViT最后一个注意力块的梯度做加权平均,得到token级别的热力图,比较它和CNN热力图的差异。
特征图层面的PCA降维也很有意思。把某层所有通道的高维特征投影到三维空间,用颜色编码展示,能看到模型在不同层对物体的抽象程度变化。
Diffusion模型的视觉生成过程可视化也是当前热门。从噪声到成图的过程中,每个时间步的预测噪声图都包含信息,拉出来做一个过程回放,直观且震撼。
最后还有一个隐藏玩法:把显著性图当数据增强的“指导图”。用显著性图指导Cutout的位置,裁剪“模型不关注”的区域,往往比随机裁剪带来更明显的鲁棒性提升。
最后再分享一个小技巧
整个作业做完,我个人体会最深的一件事:可解释性工具本身不能解决模型错误,但它可以告诉你该往哪个方向找原因。建议你把GradCAM、显著性图、对抗样本、类别可视化这几个函数封装成独立工具类,存进自己的小工具库。日后任何新模型过来,第一件事就是跑一遍这四件套,用不了两分钟,但节省的排查时间可能是几天。
动手做作业的时候,别只满足于跑通。试试把ResNet-18换成别的模型,把Grad-CAM的target_layer从layer4换到layer3,看看热力图有什么变化。这些亲手试验得到的感觉,比任何现成的教程都来得深刻。CS231n这门课很多人当作通关任务,但Network_Visualization这一节确实是值得反复玩味的内容。