
做CS231n作业3的网络可视化部分是我第一次真切感受到深度模型的“黑箱”原来是可以用梯度撬开的。这个项目包含三个递进的小任务Saliency Map、Fooling Image、Class Visualization全部基于PyTorch实现。它们解决的是一个很直接的问题——训练好的CNN到底在“看”图片的哪一部分又是凭什么把某个类别认出来。整套作业做完你会得到三样东西可直接运行的代码、每个步骤背后的数学动机以及我在这里详细记录的调试经验。适合刚完成CNN分类任务、想深入理解梯度可视化或者准备算法岗面试里模型可解释性问题的读者。1. 项目拆解三个任务原来是一条主线1.1 一条梯度的三种玩法CS231n作业3的网络可视化部分我把它简称为“梯度可视化三连”。很多人第一眼看到三个任务时会觉得彼此毫无关联一个在真实图片上画热力图一个在图片上加干扰让模型出错还有一个直接凭空生成一张图。但当我真正动手实现之后才意识到这三件事的本质都是同一套数学工具那就是“类别得分对输入像素的梯度”。区别只在于梯度的使用方式。Saliency Map是“只求一次梯度不改输入”把梯度值的大小当成像素重要性的度量Fooling Image是“用梯度方向去修改一张真实图片”通过把扰动向使目标类别分数增大的方向推进让模型产生误判Class Visualization则是“从一张纯噪声出发连续多轮沿着梯度方向更新整张图像”最终生成一张模型心中最像该类别的东西。基于这个主线我建议你在写代码之前先在纸上把这个关系画出来输入 → 模型 → 得分 → 反向传播 → 梯度 → 三种后处理。有了这个框架三个任务写起来其实就是在同一个循环里换不同的目标函数和更新策略。1.2 为什么值得亲手实现一遍以前看教程的时候我总觉得可视化只是“锦上添花”训练好模型才是正经事。真正做完这个作业后我的观点改变了。因为只有当你亲眼看到显著图和类别可视化图像摆在一起时才会意识到CNN的决策逻辑跟我们想象的“看整体形状”差得有多远。另一个实际理由是面试中关于模型可解释性的问题越来越常见。以前被问到“你怎么解释模型学到了什么”我只能背一些论文结论但做完这个作业我可以直接拿出自己跑出来的三张图说出每种可视化方法的信息瓶颈和适用场景。这种从“背概念”到“有操作记忆”的转变只有亲手实现才会发生。2. 环境准备把预训练模型搬进PyTorch2.1 推荐环境与硬件要求我用的是PyTorch 2.0配合CUDA 11.8显卡是入门级的4GB显存跑ResNet-50完全没问题。PyTorch 1.9以上基本都兼容本作业不涉及任何高级API核心就backward、optimizer、clamp这几样。如果你没有GPU用CPU跑也能成只是三个任务加在一起大约需要10到20分钟调试阶段建议把迭代次数先调小跑通逻辑后再放大。依赖也非常精简torch、torchvision、numpy、matplotlib就够了不需要额外安装重量级的可视化工具。这里额外提一句写代码之前先固定随机种子尤其是类别可视化这种随机初始化很强的任务不固定种子的话你永远不知道自己到底是在调试代码还是在调试运气。2.2 关键一步ImageNet归一化不能省预训练模型是在大规模图像识别数据集ImageNet的统计分布上训练出来的。训练时图片会被标准化每个通道减去均值[0.485, 0.456, 0.406]再除以标准差[0.229, 0.224, 0.225]。所有进入模型的图片都必须处在同一个标准化空间里梯度的大小才有意义。我第一次跑这个作业时就栽在这里读取图片后直接作为张量喂给模型没有做标准化结果显著图变成了一堆无意义的亮斑愚弄图像也发散到完全不可控。排查了很久才发现是预处理少写两行。下面这段是我整理好的工具函数读取图片后立即转成标准化张量后面的三个任务直接复用即可。import torch import numpy as np from PIL import Image MEAN np.array([0.485, 0.456, 0.406]) STD np.array([0.229, 0.224, 0.225]) def load_image(path, size224): img Image.open(path).convert(RGB).resize((size, size)) img np.array(img) / 255.0 # 先归一化到 [0, 1] img (img - MEAN) / STD # 再做标准化 img img.transpose(2, 0, 1)[None] # 变成 (1, C, H, W) return torch.from_numpy(img.astype(np.float32))这里有一个很多人容易忽略的细节如果用torchvision.transforms.ToTensor()它内部会自动完成除以255的步骤所以后面接Normalize的时候直接填均值和标准差就行但如果像我这样手动从PIL图像开始处理就必须先除以255再减均值否则拿0到255的整数数组去减均值分布完全就不对了。2.3 模型加载与测试模式预训练模型我推荐直接用torchvision.models比如resnet50或vgg16。我把两个都跑过对比后发现VGG结构更简单直观中间层的特征更容易被“解释”但前向和反向都慢ResNet在类别可视化时生成的纹理更抽象但也更有代表性。实际实验里我习惯先用ResNet-50把逻辑跑通再用VGG验证效果。import torchvision.models as models model models.resnet50(weightsmodels.ResNet50_Weights.DEFAULT) model.eval()一定不要忘记model.eval()。如果不切换到eval模式BatchNorm会继续用当前batch的统计量更新Dropout也会随机丢弃神经元每次前向的梯度都不同显著图会出现明显的闪烁噪声。整个作业过程中模型应当完全冻结不更新任何参数只更新图片或扰动向量。3. Saliency Map实战三行核心代码算出“重要性地图”3.1 显著图的数学原理显著图的基本思想来自一篇很早期的CNN可解释性工作核心一句话就能讲完对一张分类正确的图片取模型在该类上的得分求出该得分对每一个输入像素的梯度梯度大的像素就是模型做判断时所依赖的“证据”。有两个细节值得展开。第一为什么对梯度取绝对值因为某个像素对得分的贡献可能是正的也可能是负的显著图想表达的是“这个像素重不重要”而不是“它把分数推向哪一边”所以方向信息要丢掉。第二为什么沿着RGB通道取最大值而不是求和因为只要三个颜色通道里有一个强烈敏感这个像素位置就足够重要了求和反而会把不同通道的弱噪声混在一起让热力图变得浑浊。这两个选择都是我逐一对比过的取最大值的效果明显更干净。3.2 PyTorch实现与逐行解释主体函数就几行但每一行都有它的意义。def compute_saliency_maps(X, y, model): model.eval() X.requires_grad_(True) scores model(X) loss scores.gather(1, y.view(-1, 1)).squeeze() loss.backward(torch.ones_like(loss)) saliency X.grad.abs() saliency, _ torch.max(saliency, dim1) return saliency逐行拆开看X.requires_grad_(True)是在输入上打开梯度追踪只有这样才能在backward之后拿到X.grad。scores model(X)得到(N, num_classes)的得分矩阵。scores.gather(1, y.view(-1, 1)).squeeze()是取出每个样本对应正确类别的分数这一步等价于scores[range(N), y]但写法更紧凑。loss.backward(torch.ones_like(loss))是整个作业最容易懵的一行。loss是一个形状为(N,)的向量不是标量PyTorch要求调用backward()时传入与它同形状的梯度权重所以必须给一个全1张量。它的实际效果相当于loss.sum().backward()即把batch内每个样本的梯度分别算出来后求和。X.grad.abs()对梯度取绝对值。torch.max(saliency, dim1)返回值和索引我们只需要值所以用_丢弃索引。3.3 后处理与可视化技巧从函数里拿到的显著图数值范围是从0到某个比较大的值直接显示一定会糊成一团黑色所以要先做归一化sal saliency[0].cpu().numpy() sal (sal - sal.min()) / (sal.max() - sal.min() 1e-12)显示的时候我强烈建议不要把热力图单独拉出来看而是叠加在原图上。做法是用plt.imshow(original)打底再用plt.imshow(sal, cmapjet, alpha0.6)叠加。这样你能非常直观地看到模型关注的是物体轮廓、纹理交界线而不是大范围的填充区域。3.4 实验观察记录我拿一张金毛犬的图片跑了一次高亮区域主要集中在狗狗头部的毛发边缘、嘴部轮廓和身体与背景的分界线躯干上大片平滑的毛色区域几乎是暗的。这个现象在我做Fooling Image和Class Visualization之前就已经给我打了预防针CNN在图像分类时并不像人类那样先理解“这是一只狗的整体形状”它更像是在对一堆局部纹理特征做投票。你看清楚这一点之后再看后面两类实验反应就不会那么吃惊了。4. Fooling Image实战让模型把金毛犬认成香蕉4.1 愚弄图像与显著图的本质区别显著图的任务是“解释”愚弄图的任务是“欺骗”。两者的共同点是都依赖梯度区别在于是否动用了梯度去修改输入。显著图只做一次前向和一次反向然后就把梯度当作地图展示愚弄图像则是在优化循环里反复使用梯度把一个微小的扰动向量逐步向“让目标类别分数变大”的方向推进。这里有一个容易误写的点Fooling Image一般有两种写法一种是直接优化整张图片X_fool另一种是优化扰动向量delta再叠加到原图上。我推荐后者因为它的物理意义更清晰——我们希望原图基本不变只叠加一个肉眼几乎看不见的扰动让模型的输出从“金毛犬”翻转到“香蕉”。直接优化整张图片的话优化器很容易把图片本身的像素值整体推移偏离原图的初衷。4.2 完整实现代码def make_fooling_image(X, target_y, model, num_iterations200, lr0.1, max_delta0.3): delta torch.zeros_like(X, requires_gradTrue) optimizer torch.optim.Adam([delta], lrlr) for step in range(num_iterations): x_fool X delta x_fool x_fool.clamp(-2.5, 2.5) # 保持在标准化空间内 scores model(x_fool) loss -scores[0, target_y] # 最小化负目标分 最大化目标分 optimizer.zero_grad() loss.backward() optimizer.step() with torch.no_grad(): delta.data delta.data.clamp(-max_delta, max_delta) return X delta说几个关键设计使用Adam而不是SGD。Adam的自适应学习率让这个任务几乎不需要额外调参我实验里SGD需要把学习率压到0.02以下才能稳定而Adam在0.1附近就能在200步内完成任务。loss -scores[0, target_y]是核心。梯度下降只会让loss变小所以想增大某个类的得分就要对这个分数取负。每次迭代后对delta做截断控制在[-0.3, 0.3]区间内。这个数值是在标准化空间下的扰动预算转换回原始像素空间大约只有不到正负0.1的偏移人眼很难察觉。我在循环里对x_fool做了裁剪到[-2.5, 2.5]这是参考ImageNet标准化后像素的大致范围防止优化过程中某些通道数值飞出去。4.3 手写梯度更新也可以如果你不想借用Adam优化器手写梯度更新其实也很直观核心就是每一轮拿delta.grad乘上学习率然后从delta中减去这个值再把梯度清零delta.data - lr * delta.grad delta.grad.zero_()这样你能在最小框架里看到梯度是如何驱动数据变化的。用优化器的好处是少写代码并且顺带获得动量效果收敛更稳定手写版本需要把学习率设得更小否则两个像素之间梯度抖动就会传导到整张图片上。我在调参时两种方式都试过最终留在代码里的还是Adam版本因为它在不同目标类别之间表现更一致。4.4 调参经验与效果观察关于迭代次数我试过200步和2000步。200步能把目标类别的置信度推到0.8以上而且扰动几乎不可见2000步虽然能到0.99但图像上已经能看出薄薄一层彩色噪点视觉上已经不属于“愚弄”而更像“涂鸦”。所以实际使用我先从200步开始如果置信度不够再往上加。目标类别的选择也很影响体验。把金毛犬当成“香蕉”200步内基本必成但如果你非要让一张真实照片被认成“吸尘器”这类整体外观差异极大的目标就需要更多迭代和更大的扰动预算。建议第一次实验选一个与当前图片有部分视觉重叠的类别比如金毛犬改“萨摩耶”、或者改“香蕉”这类形状接近的物体效果更有解释力。跑完Fooling Image之后你可以把扰动delta单独保存出来看它往往不是随机噪点而是隐约带着目标类别的形状轮廓。这个细节非常有趣模型对“香蕉”的敏感特征被成功投影到了原图的某个局部区域中。5. Class Visualization实战模型心中的“理想图像”长什么样5.1 原理从噪声出发的“逆向炼丹”类别可视化可以理解为愚弄图像的极端版本。愚弄图像还要求“长得像原图”类别可视化则完全放弃了人类视角的合理性直接生成一张模型自己最满意的图。流程很简单初始化一张随机噪声图然后反复执行梯度上升让目标类别的得分变大。所有更新都只作用于图像本身模型权重全程冻结。问题在于如果没有额外约束模型会倾向于把高频噪声无限放大因为高频噪声非常容易被分类器的某些局部滤波器捕捉。所以实际实现里必须加几样“调料”像素值裁剪、周期性高斯模糊、随机平移抖动、可能的正则项。它们的作用都是压制高频噪声让生成的图像在某些局部区域形成可识别的纹理结构。5.2 核心代码实现完整代码以作业常见版本为蓝本我用PyTorch重新实现。记得要从torchvision.transforms.functional导入gaussian_blurdef create_class_visualization(target_y, model, lr0.1, num_iterations300, blur_every10, sigma2.0, jitterTrue): image torch.randn(1, 3, 224, 224).requires_grad_() optimizer torch.optim.Adam([image], lrlr) for step in range(num_iterations): if jitter: tx, ty np.random.randint(-3, 4), np.random.randint(-3, 4) with torch.no_grad(): image.data torch.roll(image.data, shifts(tx, ty), dims(2, 3)) image.data.clamp_(-1, 1) if blur_every and step % blur_every 0: with torch.no_grad(): image.data gaussian_blur(image.data, kernel_size5, sigmasigma) scores model(image) loss scores[0, target_y] optimizer.zero_grad() (-loss).backward() optimizer.step() return image这里我用了torch.roll实现随机平移而不是用仿射变换。原因是在图像边缘做常规平移会引入填充伪影而循环滚动简单且有效。高斯模糊要放在torch.no_grad()下对image.data操作避免在计算图里叠加越来越多的中间节点否则显存会被慢慢吃满。赋值给image.data不会改变叶子张量的requires_grad状态所以下次迭代仍然能正常构建计算图。5.3 为什么生成图会有“多眼怪”“狗头鱼身”现象你真正把类别可视化跑出来后大概率会发现生成的图一点也不像“标准照片”。金毛犬类别可能出现多个狗头叠在一起鲨鱼类别会出现类似鱼鳍和鱼身的混合体香蕉类别会出现类似网纹的背景纹理。这不是代码写错了而是优化目标和人类审美之间的天然矛盾。模型内部存储的其实是训练样本的纹理统计特征的平均。当它最大化某个类别的得分时会把所有与这个类别共现过的纹理都一起激活。比如训练数据里“香蕉”的图片经常出现在有网格背景的桌子或渔网上模型就会把“网格纹理”和“香蕉类别”绑定在一起最终画出一根长在渔网上的香蕉。这是模型“死记硬背”的证据也是深度学习可解释性研究里一直在讨论的“纹理偏置”现象。5.4 进阶优化技巧如果觉得默认参数下生成的图像还不够有意思可以从几个方向调换用VGG16而不是ResNet50VGG的可视化结果更“结构化”。把损失函数从分类头的得分换成某层卷积特征的模长或方向一致性浅层生成的是局部纹理深层生成的是更整体的结构。增加总变分正则化项公式很简单tv torch.sum(torch.abs(image[:, :, :, 1:] - image[:, :, :, :-1])) torch.sum(torch.abs(image[:, :, 1:, :] - image[:, :, :-1, :]))把它乘以一个很小的系数比如0.01加进loss可以让输出更平滑。把裁剪区间从“标准化空间”换成“原始像素空间”即clamp(0, 1)配合适当的反标准化生成图会更接近人类可见颜色。6. 常见问题与调试记录6.1 显著图全是噪声看不出任何结构最常见的原因是预处理不匹配。请依次检查输入图片是否做了ImageNet标准化、模型是否在eval()模式、loss是否准确取到了样本对应类别的分数。如果三项都正确但图还是噪可以把输入归一化后的像素值打印出来看一眼正常应该在负二到正二之间浮动如果出现了几十或上百说明预处理步骤肯定有问题。6.2 愚弄图像loss下降得很慢先把学习率调到0.1附近试一轮。另一个容易忽略的点是扰动预算max_delta如果把它限制在0.05以下200步内很难完成目标类别翻转。我的经验是0.2到0.3是一个比较舒服的区间既能保证肉眼不可见又给优化留出足够空间。6.3 类别可视化生成一片纯色这通常是约束过强的表现。像素裁剪区间太小比如正负0.2或者模糊频率太高、sigma过大都会把图像抹成纯色块。解决办法是把裁剪区间放宽到正负1blur_every从10调到20观察几次再决定往哪个方向收紧。6.4 显存不足OOM)Saliency Map如果一次性处理大量样本反向传播会同时保存整个batch的中间激活显存压力不小。解决方案是batch size设为1循环逐张处理。类别可视化虽然只有一张图但如果把模糊操作留在计算图里面每次模糊都会新增节点导致图越来越深也会拖垮显存。模糊和平移都应该在torch.no_grad()下对image.data操作这一点我的经验教训很深刻。6.5 复现性不佳每次跑图都不同因为类别可视化是随机初始化的想要复现必须在初始化前固定随机种子。一般的做法是同时设置Python、NumPy、PyTorch和CUDA四类的随机种子import random, numpy as np, torch random.seed(0) np.random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0)另外CUDA的某些算子本身具有非确定性即使固定了种子也可能有轻微差异这不算bug也不需要过度焦虑。7. 从作业到实战还能怎么扩展作业本身做完已经很解渴但它其实是一扇门。Saliency Map是基础版的可解释性工具你可以顺手升级为Grad-CAM——用最后一层卷积的梯度加权激活图替换黑白热力图视觉上显著直观很多。Fooling Image的思路可以迁移到目标检测模型上做成对抗补丁贴在物体旁边就能让检测器失明。Class Visualization则是理解对抗攻击和生成模型的起点对抗样本、风格迁移里常见的损失函数设计思路很多都能从这个作业里找到影子。我不建议你把作业跑完就丢到一边。换个数据集、换几个目标类别、把可视化结果和中间特征图放在一起看这些延伸实验的成本很低但带来的认知收益远高于“能跑通”本身。我自己做完这个作业记住的不只是三个函数怎么写而是对“模型究竟在记什么”有了实打实的感受。梯度这东西训练时是更新权重的工具推理时就成了照妖镜。网上一键运行的代码到处都是但只有自己把backward()的梯度形状、归一化的坑、正则化与高频噪声的拉扯都踩一遍再跟人聊模型可解释性才有底气说话。最后再分享一个小技巧把原图、显著图、愚弄图、类别可视化图放在同一张画布里对比着看。你会在第一次看的时候就明白什么叫“纹理偏置”比翻任何论文里的抽象示意图都更震撼。跑完代码后多换几张图片、几个目标类别试一轮你对深度学习模型的很多直觉判断都会在里面被悄悄修正。