最近把CS231n的Assignment 3重新翻出来做,前几块RNN、Image Captioning还算常规,到了Network_Visualization这一节,反而让我在PyTorch上卡了整整一个晚上。说实话,这个作业在整套课程里显得最“玄学”——它不是在教你怎么训练更准的模型,而是反过来,把模型脑子里“看到”的东西画出来给你看。这里面的核心套路只有一句话:对输入图像求梯度。但真正用PyTorch写起来,预处理、张量状态、迭代更新各种边角问题一个接一个。这篇文章就把我跑通官方框架之外踩过的坑、补齐的细节和最后的一点心得写出来,给准备做或者正在做这个作业的同学参考。
1. 先拆作业:Network_Visualization到底要我们证明什么
其实官方说明里,这一部分分成几个子任务:Saliency Map(显著性图)、Class Visualization(类别可视化)、Deep Dream,另外还有可选的Fooling Image。很多人一上来就埋头填代码,但我建议先想清楚每块到底在干嘛,否则很容易出现“代码填对了、图出不来、不知道去哪查”的情况。
1.1 核心思想不是“让模型画画”,而是“让梯度说话”
我们训练网络的时候,backward是为了更新模型参数。但这个作业里的backward不是为了更新任何参数,而是为了观察输入图像本身应该怎么变化。具体来说:
- Saliency Map:固定模型,对输入算一次梯度,回答“哪些像素对判断影响最大”;
- Class Visualization:从随机噪声或模糊图出发,反复让某个类别的分数变大,回答“模型理想中的这个类别长什么样”;
- Deep Dream:把这个过程作用在真实图片上,回答“如果图像可以随便改,模型希望图像变成什么样”。
这三件事的共同底层机制都是梯度,区别只是把梯度算出来以后怎么用。理解了这一点,你就不会被各种花哨的名字带偏,遇到任何可视化问题都能回到“对谁求梯度、梯度怎么用”这个原点去思考。
1.2 别小看“填空式”作业,细节全在框架外面
课程给的notebook其实已经把很多流程搭好了,好像只需要填几行forward和backward。但实际运行起来你会发现,框架不会帮你处理那些真正耗时间的细节。比如:
- 它不会告诉你输入的图像张量需不需要
requires_grad_(); - 它不会告诉你
model.eval()没调用时,BN层会把梯度搅成什么样; - 它也不会告诉你生成的图像应该怎么反归一化才能显示成正常颜色;
- 它更不会告诉你,迭代更新图像时不能直接对叶子张量做in-place操作。
我把这些坑归纳成三类:环境和预处理、梯度计算、迭代更新时的张量状态管理。后面每一章就按这个顺序讲,基本覆盖了我自己踩过和帮别人排查过的绝大多数问题。
2. 环境准备最容易埋雷的三个地方:模型、预处理、张量状态
2.1 用预训练模型时,eval()这一步不能省
作业默认使用ImageNet预训练的AlexNet或VGG系列。我在torchvision里加载模型时发现新版接口已经变了,写着alexnet(weights=models.AlexNet_Weights.IMAGENET1K_V1),而不是老的alexnet(pretrained=True)。如果沿用旧写法,新版库虽然不一定报错,但拿不到真正预训练过的权重,跑出来的可视化结果就是一团无意义的噪声。
另一个更隐蔽的问题是model.eval()。只要模型里带了BN层,训练模式和推理模式下的统计量使用方式完全不同。训练时BN会用当前batch的均值和方差来归一化,同一个输入在不同的batch里会得到不同的输出;而eval模式下BN用的是训练阶段缓存好的全局统计量,输出是确定性的。
我在第一次跑Saliency Map时忘了调用model.eval(),结果同样一张图连续跑两次,得到的saliency图差异很大。后来才反应过来是BN在“捣乱”——梯度下降方向每一轮都在变,可视化结果自然不稳定。切换到eval之后,问题立刻消失。
from torchvision import models model = models.alexnet(weights=models.AlexNet_Weights.IMAGENET1K_V1) model.eval() model = model.to(device)这里有一个容易遗漏的点:切换eval只是让BN和Dropout的行为正确,不代表你不能backward,梯度仍然会正常计算。可视化作业需要的就是“模型处于推理状态,但输入仍然可求导”这个组合。
2.2 图像预处理是双向的:输入要归一化,输出要反归一化
这个作业里90%的显示问题都出在预处理方向搞反了。加载图片时,标准流程是:
transform = transforms.Compose([ transforms.Resize(224), transforms.ToTensor(), # 像素范围变成 [0, 1] transforms.Normalize(mean, std) # 转入“归一化空间” ]) img = transform(Image.open("demo.jpg").convert("RGB")) img = img.unsqueeze(0).to(device)网络训练时用的是ImageNet的统计量,所以可视化任务里也要保持一致。常用的均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225],这是PyTorch在训练时就已经固化在预训练模型里的值,不能随便换。
但很多人做完归一化之后,直接把这个张量显示出来看效果,结果发现图片灰蒙蒙的、颜色偏得很奇怪。原因很简单:网络看到的图像分布是“均值接近0、标准差接近1”的,并不是我们肉眼习惯的0到1范围。所以在显示之前,必须做一次逆变换:
img = img * std + mean img = img.clamp(0, 1)在Class Visualization里还要额外注意:迭代过程中图像一直停留在归一化空间,合法范围其实是跨正负的,不是0到1。你只需要在最后显示之前反归一化一下,不要在迭代过程中就人为把图像限制在[0,1],否则优化空间被大幅压缩,生成图很容易发灰、发闷。
2.3 requires_grad与device:三行检查,省两小时
如果saliency图的梯度是None,十有八九是下面三种情况之一:
- 输入张量没开
requires_grad。transforms.ToTensor()产生的是普通张量,你必须显式写img.requires_grad_(True); - forward被包在
torch.no_grad()里。很多教程喜欢用with torch.no_grad():做推理,但这种写法在可视化作业里是致命的,你最后backward只会得到None; - 模型和图像不在同一个device上。这个比较直接,报错信息也会说,但很多人改了模型却忘记改输入,或者反过来。
我的检查顺序是固定的:先打印img.requires_grad和next(model.parameters()).device,再确认没有no_grad包裹,然后才往下走。三次排错里至少有两次是这几步的问题,排查顺序从简单到复杂,千万别一上来就怀疑模型写错了。
3. Saliency Map踩坑实录:从一片黑到正常出图
3.1 原理和核心代码只差三行,但每行都有讲究
Saliency Map的数学定义是:分类得分对输入图像的梯度,取绝对值,再按通道取最大值。核心代码大概是这样:
img.requires_grad_(True) scores = model(img.unsqueeze(0)) score = scores[0, target_class] score.backward() saliency, _ = torch.max(img.grad.abs(), dim=0)这里有几个容易忽略的地方:
scores[0, target_class]中的target_class,取决于你想看“模型自己认为这是什么类别”还是“正确答案是什么类别”。做作业时一般用给定的标签,但自己做分析时建议把两个都跑一遍对比,差异会很明显;torch.max(..., dim=0)是在通道维度上取最大值,因为RGB三个通道的梯度不能直接相加,只要有一个通道变化显著,就说明该位置的像素对判断有影响;- 最后显示
saliency之前,要把它归一化到0到1。否则大多数梯度图的数值范围非常小,直接显示会暗到几乎看不清:
saliency = (saliency - saliency.min()) / (saliency.max() - saliency.min() + 1e-8) plt.matshow(saliency.detach().cpu())3.2 我遇到“梯度全为None”时是怎么一步步查出来的
第一次实现时我自信满满地写完了,结果img.grad是None。不是全0,是None。我当时按下面这条链路排查:
先是打印img.requires_grad,发现是False,原因是我在加载图片之后忘了开requires_grad_()。补上之后再次backward,发现还是None。这次我检查到是自己把score.backward()写在了with torch.no_grad():的作用域里,这阴影是从之前跑inference代码的片段里顺手复制过来的。
移出no_grad之后,终于能打印梯度了,但梯度全为0。问题出在模型没有调用model.eval(),BN层拿当前batch的统计量去算输出,输入只过了几层卷积后梯度就被稀释得很厉害。我这个原因比较特殊,但排查思路是通用的:从“叶子张量是否可导”开始,再往前向链路逐步检查。
| 现象 | 可能原因 | 检查点 |
|---|---|---|
img.grad为 None | requires_grad未开启 / 在torch.no_grad内 | 打印requires_grad,检查作用域 |
| 梯度全为0 | 未调用eval / 梯度流被detach切断 | 确认model.eval(),检查forward里有没有detach |
| backward报device错误 | 模型在cuda,输入在cpu | 打印device,统一两端 |
3.3 同一个目标类别的saliency才具备可比性
作业要求的只是算出梯度并画出来,但我觉得这一步真正的价值在于对比。我拿一张同时包含狼和狗的照片,分别指定目标类别为狼和狗的索引,生成两张saliency图。狼的那张高亮区域集中在眼睛和毛发边缘,狗的那张则集中在耳朵轮廓——说明模型判断不同类别时,确实在“看”不同的局部特征。
这里有一个技术细节:如果要对同一个img.grad连续两次backward,需要加retain_graph=True,或者每次重新forward。我第一次直接在第二次又调了score.backward(),结果PyTorch报“Trying to backward through the graph a second time”。正确做法是:
score_wolf.backward(retain_graph=True) saliency_wolf, _ = torch.max(img.grad.abs(), dim=0) img.grad.zero_() score_dog.backward() saliency_dog, _ = torch.max(img.grad.abs(), dim=0)中间那行img.grad.zero_()也很重要,不清零的话,第二次backward会把第一次的梯度叠加进去,出来的图就是两张图的混合,完全失去对比意义。
4. Class Visualization:让模型画出它“想看”的东西
4.1 从随机噪声到“一只鸟”:梯度上升的完整循环
Class Visualization的思路是反着找输入:固定模型,迭代更新图像,让某一类别的分数不断提高。对应公式就是X += lr * ∂score/∂X。写代码时要注意,我们要更新的X不是网络参数,而是一个普通张量。我用的一段核心循环大体是这样:
X = mean.clone().unsqueeze(0) + torch.randn(1, 3, 224, 224).to(device) * 0.05 for it in range(200): X.requires_grad_(True) scores = model(X) score = scores[0, target_class] model.zero_grad() score.backward() grad = X.grad grad_normalized = grad / (torch.norm(grad.view(-1), p=2) + 1e-8) X.data = (X.data + lr * grad_normalized)两个我实验下来非常必要的补充:
- 梯度要归一化。原始梯度的L2范数每次迭代差异可能很大,不归一化的话,一开始可能会直接冲过头,图像变成一坨噪点。除以L2范数相当于只保留方向、不考虑幅度,这样学习率的选择会稳定很多;
- 初始化要在mean附近而不是0附近。如果你用标准正态随机初始化,图像一开始就是极端像素值,模型输出的梯度变化非常剧烈,很难收敛到“有语义”的图。我在mean附近加0.05倍标准差的小噪声,既能保证每次生成的图不一样,又不容易跑飞。
4.2 叶子张量上的in-place更新报错,以及怎么绕过
我跑第三条迭代时直接遇到了这个报错:
RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.原因是X += lr * grad_normalized直接对requires_grad=True的叶子张量做了in-place加法。PyTorch的autograd机制为了正确记录计算图结构,禁止对叶子节点做原地修改。
绕法有两种:
- 用
X.data.add_(lr * grad_normalized),相当于告诉PyTorch“我只是把这个张量的底层数值改一下,不参与求导记录”。在可视化任务里这是安全的,因为X本来就不是要学习的参数; - 在
with torch.no_grad():里执行X = X + lr * grad_normalized,这会生成一个新张量,下次迭代再手动X.requires_grad_(True)。
我实际推荐第一种,因为X一直是同一个对象,代码语义更清晰。不要看到X.data就心慌,X.data在非参数张量上的用法非常普遍,只要明确知道自己在做什么,就不会有副作用。
4.3 生成图太噪?把“正则化”当成必需品
第一次跑出来的图,放大看全是密密麻麻的高频噪点,像电视雪花。原因有两类:
- 梯度上升天然偏爱高频纹理,因为模型很擅长用局部高频特征判断类别;
- 迭代过程没有约束,像素值很容易走到离谱的范围。
我的对策有三个:
- 每隔固定步数对X做一次高斯模糊。
torchvision.transforms.functional.gaussian_blur(X, kernel_size=3, sigma=0.5)可以把高频噪点抹平,让画面更接近自然图像; - 每步都做clamp,把X限制在合理范围。我一般以mean为中心,限制为mean加减2倍std对应的值域范围;
- 学习率衰减。前50次迭代用lr=1.0,后面每50次减半,先快速确定轮廓,再慢慢细化细节。
另外,目标类别的选择对结果影响很大。纹理鲜明的类别(比如“鸡蛋花”“鸵鸟”)生成出来的图会带有类似羽毛和花朵的图案感;而“雨伞”“跑步机”这类轮廓弱、纹理少的类别生成出来就比较糊。这不是代码bug,而是模型对这类别的“内部先验”本身就更模糊。建议选几个类别先验证流程没问题,再回头处理那些难对付的类别。
4.4 Deep Dream其实只是Class Visualization的变体
官方作业里还有一个Deep Dream模块。它的不同点在于:从一张真实图片开始,梯度不再以“某个类别的分数”为目标,而是以网络中某一层激活的范数为目标,让图片在特征空间中变得越来越“极端”。我当时以为要单独写一套逻辑,后来发现就是把Class Visualization里的目标类别换成layer_activations.norm()就行。
唯一要强调的是尺度。真实图片分辨率太高,如果是CPU环境,直接跑224×224还行,再大就非常慢。可以先缩到128×128跑通流程,确认效果对了再放大分辨率。多尺度迭代时,每次放大后再重新归一化一次梯度,效果会更稳。
5. 更进一步的玩法:Fooling Image与模型诊断
5.1 几分钟就能复现的“欺骗图像”
作业里还有一个可选任务叫Fooling Image,思想极其简单:从纯随机噪声出发,把目标类别设成和图片内容无关的类别(比如一只狮子),跑几十轮梯度上升。最后得到的图在人类看来仍然是雪花噪声,但模型可以高置信度地把它分类成“狮子”。
这个实验对我冲击不小。它说明深度模型的置信度并不等于“模型像人一样看到了一个狮子”,而只代表它在特征空间里找到了一个能触发该类别神经元的模式。做这个实验不需要额外写代码,把Class Visualization的那个循环改一下初始化和目标类别即可。我建议大家都做一次,做完再回头看Saliency Map,你会对“可解释性”这三个字有完全不一样的感受。
5.2 用可视化定位模型“走捷径”的实际案例
做完作业后,我把Saliency Map的方法用在了自己训练的一个小分类模型上。那个任务很简单,是二分类区分两类工业零件。训练集里有一类零件的照片总是带着明显的油渍背景,另一类没有。最终准确率看起来有96%,我一开始还挺高兴,但Saliency图一画就露馅了——模型的高亮区域完全集中在那块油渍上,而不是零件本身的轮廓。
这说明模型学到的是环境背景特征,不是目标语义。准确率高只不过是因为训练集和验证集里的背景分布恰好一致。后来我重新采集了一部分数据、去掉背景干扰再训练,准确率虽然没怎么涨,但Saliency图终于集中到了零件本体上,模型的泛化能力也明显好了不少。
如果你也在做自己的分类任务,我强烈建议在调优阶段就把Saliency Map当成一个固定检查项。做法很简单:每训完一个模型,随机抽20张验证集图,计算梯度画出来扫一眼。凡是“高亮区域跟人眼关注的物体位置不一致”的情况,八成是模型在走捷径。这个检查每次大概五分钟,但能省下后面大量反复调参的时间。
我做完整个作业最大的感受是:网络可视化不是课程作业里的锦上添花,而是一种非常实用的模型诊断手段。几个小习惯供大家参考:
遇到梯度问题先查requires_grad和no_grad作用域,别急着怀疑模型;所有显示用的图像都先走一遍反归一化和clamp,再判断颜色对不对;Class Visualization先小图后大图,先少迭代次数再加迭代次数,梯度范数打印出来看走势,方向对不对一眼就知道。
如果以后你对Grad-CAM或者风格迁移感兴趣,你会发现这个作业打下的梯度直觉是通用的——所有“看模型在想什么”的工具,本质都是换着花样利用梯度。祝一次跑通。