资讯动态

CS231n笔记:从可视化到对抗样本,理解CNN的梯度本质

发布时间:2026/10/15 17:49:08 来源:尧图企业网站定制
笔记写到这里我在CS231n跟读营里已经蹲了快两个月第七期的进度正好把作业和视频推到一个转折点前面二十多篇都在解决“网络怎么做对”从第二十八篇开始问题换成了“网络到底看到了什么、为什么这么看”。如果你也是从线性分类器、反向传播一路跟过来的应该能明显感觉到一种变化课程不再只围着训练准确率转而是开始关心模型内部那些不太容易被量化、却极其影响判断的东西。这一篇我打算把可视化、风格迁移、对抗样本三块内容串在一起来写。它们在CS231n的讲义里本来就出现在相近的位置共用同一个底层的数学工具——对输入求梯度。不管你是为了做研究、写论文还是单纯想把深度学习的原理吃透搞明白这三件事怎么共享一套计算图中的逻辑比记住任何单独的API都值。1. 第七期学到第二十八篇这门课为什么突然关心“图像内部”1.1 漫长铺垫的终点过去这段时间我大部分精力花在这些事上线性分类器和Softmax的推导、反向传播的链式法则、卷积层里感受野怎么变化、批归一化和Dropout到底在训练中起什么作用以及目标检测里候选框和交并比的基本概念。CS231n第七期的课程视频到了这个节点开始明显偏离“准确率越高越好”的竞赛话术转向解释类的主题——可视化、对抗样本、风格迁移再往后还有生成式对抗网络。我一开始非常不适应因为前三周的作业都有一个可以量化的目标比如把CIFAR-10分类准确率从40%提到70%训练曲线画出来一目了然。但可视化这块没有标准答案它更像在说实验的价值在于你能挠到网络哪一层并且解释清楚你挠完之后发生了什么。其实从逻辑上推这个转折是顺理成章的。前二十七篇笔记一直在做同一件事让最终损失变小。反向传播背了两周小批量梯度下降背了一周我们已经知道梯度是把差距分摊到每个参数上的工具。到了第二十八篇整个思路不用换只是把作用对象从“参数”换成“输入像素”。于是你会发现可视化、风格迁移、对抗攻击表面上各干各的本质上都在拿同一张计算图做不同方向的梯度操作。1.2 可视化、对抗样本与风格迁移共同的数学底座我最早以为这三块是拼盘后来才发现它们共用同一个底座把某种损失或响应度量对输入图片本身求梯度。可视化里我们用梯度去看哪些像素对某个类别响应最强或者把梯度反传到特定层得到特征归因图。对抗攻击里我们用损失函数的梯度符号去改像素故意制造误差让模型把一只猫认成别的类别。风格迁移里我们同时计算内容损失和风格损失对一张噪声图反复求梯度把噪声图一步一步推向目标状态。这三者在训练框架里的地位几乎相同都是一次“输入空间优化”只是目标函数不同。可视化想让输入在某些维度上激活更强对抗攻击想让输入越过分类边界风格迁移想让输入同时在内容层和风格层靠近目标。理解了这一点学习曲线就从“记API”拉回到了“自己写计算图”的层面。我记得跟学群里有人直接在网上找了一个现成的可解释性工具包两三行就把热力图画出来了看起来很酷但一问原理就卡壳。反而是另一位同学自己写了个取梯度的函数虽然代码多写了三十行但对“梯度怎么从深层一路传回像素”这件事理解得很扎实。所以这篇笔记里的代码都是偏手写风格的尽量不依赖黑盒工具。2. CNN可视化的四种手段权重、激活、反向梯度和输入扰动2.1 权重可视化与特征图激活可视化早期卷积神经网络比如第一个比较出名的AlexNet研究者会把第一个卷积层的权重直接打印成小图。你会发现它就是一组彩色边缘检测器有的负责横边、有的负责竖边、有的对红绿色差敏感。这是理解一个模型“在最低层到底检测什么”的最直接方式。但到了深层这个办法就不太行了。第五个卷积层的权重如果直接画成图像看起来就是一片噪声因为深层通道不是对应某个具体的边缘模式而是对应更抽象的组合概念。换句话说权重可视化只对早期卷积通道直观深层的权重必须借助其他手段才能解释。特征图激活可视化则更实用把一张输入图喂进预训练网络缓存某一层的输出特征图按通道画成热度图。浅层通道对边缘和颜色敏感中层通道会显现出对眼睛、轮胎、文字这类部件的响应深层通道逐渐出现与类别更相关的信息。我经常拿含有多个物体的图片去测看一下不同通道的激活区域各落在哪里可以帮助判断模型到底是被哪个局部特征强影响的。有一个特别容易被忽略的细节不同通道的数值尺度差距非常大。有些通道整体激活值在0.1以下有些通道可以达到几十。如果不做通道级归一化画出来的热力图几乎全黑。正确的做法是对每个通道分别做min-max缩放再映射到0到255的灰度范围。这一步让初学者踩坑的概率非常高因为网络本身没有报错图片也正常就是热力图看不出内容。2.2 用Hook拿中间激活与方向梯度我实际写过的检查代码在PyTorch里最简单的方式是注册前向钩子。我平时会写一个小工具类把指定层的前向输出存起来用完立刻释放句柄避免内存问题。代码不长import torch import torch.nn as nn class FeatureExtractor: def __init__(self, model, layer_names): self.model model self.features {} self.handles [] for name, module in model.named_modules(): if name in layer_names: self.handles.append( module.register_forward_hook(self._make_hook(name)) ) def _make_hook(self, name): def fn(module, inp, out): # 保存该层的输出同时保留计算图方便后续反向传播 self.features[name] out return fn def release(self): for h in self.handles: h.remove()这个实现有个非常隐蔽的坑如果钩子只是把张量保留了下来上一批输入的计算图就不会被释放显存会慢慢被吃满。我在一台没GPU的机器上做实验盯着内存占用从几百MB一路涨到几个GB还以为是数据加载写崩了最后才锁定到是钩子缓存作怪。所以这里特别提醒一句用完钩子一定要执行release或者确保每次只保留当前批次的输出不要无限制累积。拿到特征图之后还可以继续反向操作。课程里提到的“引导反向传播”本质上是在反向传播过程中对梯度做修正让可视化结果更干净。普通反向传到卷积层时小于零的梯度也会传给上一层而引导反向传播把负数梯度直接截断只保留对激活有正向贡献的部分。这样做可视化的结果会减少很多噪声但要注意它并不代表真实的模型决策过程更像是一种便于人眼观察的“可视化滤镜”。2.3 把可视化接到判断上类激活与决策证据特征图激活回答的是“这一层关心哪个区域”但它不能直接回答“这次分类决策到底依赖哪块区域”。类激活映射就是为了解决这个问题出现的。思路很简单取目标类别对应的权重加权融合最后一个卷积层的特征通道生成热力图。哪个位置越热说明该位置对判定为目标类别的贡献越大。更通用的版本是梯度加权类激活映射。不需要模型里特殊插入全局平均池化只要对目标类别的输出分数求关于最后一个卷积特征图的梯度然后对每个通道取全局平均得到权重再把通道加权求和经过ReLU截断得到热力图。核心公式十几行就能写完def grad_cam(model, image, target_class, feature_layer): # 假设已经拿到 feature_map: [C, H, W] # 以及该特征图对应的梯度 grad: [C, H, W] weights grad.mean(dim(1, 2), keepdimTrue) # [C, 1, 1] cam (weights * feature_map).sum(dim0) # [H, W] cam torch.nn.functional.relu(cam) cam cam / (cam.max() 1e-8) return cam.numpy()这段代码在实践中最需要注意的地方是特征图尺寸往往比原图小很多最后需要做双线性插值放大到原图尺寸才能叠在原图上观察。另外取哪个卷积层也很讲究。太浅的层空间信息保留得多但类别判别性不足太深的层类别信息强但分辨率太低热力图放大后会比较糊。通常大家会选最后一个卷积输出这个位置在语义和空间分辨率的平衡上最好。2.4 四种可视化任务该怎么选型手段看什么常用位置典型局限权重可视化卷积核模式、边缘和颜色滤波器第一个卷积层深层权重不直观特征图激活对特定输入的响应位置浅层到深层均可依赖输入尺度差异大引导反向传播哪些像素对激活贡献大每一层可做是修正后的可视化不代表真实决策类激活映射分类决策的区域证据最后一个卷积层只能看类别相关区域不看具体特征我自己做实验时的选择逻辑是如果只是想快速确认模型有没有学到边缘和纹理先看第一层卷积核如果想定位某张图里哪个位置让模型最兴奋用特征图激活如果想分析一次具体分类是依据什么做出来的直接上梯度加权类激活映射。方向梯度和引导反向传播更适合做论文级别的展示图日常调试里性价比没那么高。3. 风格迁移实战一张噪声图与两个损失函数的反复拉扯3.1 内容损失让目标在特征空间靠近原图风格迁移的经典方案不训练模型参数而是训练一张输入图。一开始生成图就是随机噪声理论上什么都不是。然后我们定义两个损失一个管“内容”一个管“风格”让噪声图在这两个力量的拉扯下逐渐变成想要的样子。内容损失很简单用平方误差度量“原图经过某一层后的特征”和“生成图经过同一层后的特征”之间的距离L_content ||phi_l(generated) - phi_l(content)||^2这个损失保留了空间对应关系如果原图左边有一朵云生成图的左边也会有云的结构。关键在于选哪一层。选太浅了比如第一个卷积层损失管得太细生成图会过于接近像素级复制选太深了空间细节丢失生成图可能会糊成一团。课程里常用的是比较靠后的一个ReLU层比如VGG的4_2位置既包含足够的语义内容又还留着一定的空间分辨率。我一开始以为内容层选得越多越好后来发现不是这样。如果同时对三四个层做内容约束生成图会被绑得死板几乎没有风格变化的余地。内容约束的作用是“骨架”一个层就够风格约束才是让外观发生变化的动力。3.2 风格损失格拉姆矩阵与“不关注位置的特征统计”风格之所以能抓住是因为我们用格拉姆矩阵把通道之间的相关性收集起来了。假设某层特征图的大小是C乘H乘W把H和W展平得到C乘HW的矩阵再和它自己的转置相乘得到一个C乘C的矩阵。这个矩阵的每个元素表示两个通道在空间位置上是否经常同时激活。用一个生活化的类比一层里的各个通道就像不同颜色的画笔一张图的风格就像画笔两两同时出现的频次而不关心某一支笔画在了画面里的哪个具体位置。所以两张内容完全不同的图如果风格接近它们的格拉姆矩阵会很接近反过来如果只是颜色恰好相似但笔触习惯不同风格损失还是会拉开差距。风格损失的计算通常不止用一层。VGG的浅层风格偏向颜色和细小纹理深层风格偏向整体构图和大尺度模式所以实践中会从多个层分别取格拉姆矩阵再加权求和。课里常用的是ReLU 1_1、2_1、3_1、4_1、5_1这五层每层权重取相等或者差不多大小。这个组合现在几乎成了风格迁移的标配。3.3 手工风格迁移的训练循环骨架下面是一个最简化的实现思路先提取特征再分别计算内容损失和风格损失import torch def gram(x): b, c, h, w x.shape feats x.view(b, c, h * w) return torch.bmm(feats, feats.transpose(1, 2)) / (c * h * w) def content_loss(gen_feat, content_feat): return torch.nn.functional.mse_loss(gen_feat, content_feat) def style_loss(gen_feats, style_feats, style_layers): total 0 for name in style_layers: g gram(gen_feats[name]) s gram(style_feats[name]) total torch.nn.functional.mse_loss(g, s) return total拿到这两个损失之后优化流程就是经典的“输入图像可训练”模式optimizer optim.LBFGS([img.requires_grad_()]) for it in range(iterations): def closure(): optimizer.zero_grad() features extractor(img) c_loss content_loss(features[content_layer], content_feat) s_loss style_loss(features, style_feat, style_layers) total alpha * c_loss beta * s_loss total.backward() return total optimizer.step(closure) with torch.no_grad(): img.clamp_(0, 1)这里有一个容易被忽略的点每次迭代之后必须把图像像素限制在0到1范围内。因为优化器只会盯着损失下降它并不知道图像通道的合法范围。如果不做clamp训练到后面生成图会越来越“过曝”损失还在下降但人眼看上去全图质感已经坏了。3.4 我在调参中撞过的三个失灵场景第一个场景是beta远大于alpha。我一开始把风格权重设成内容权重的上百倍结果生成图快速变成一块花纹完整的“纹理地毯”内容轮廓完全消失。这个现象很容易理解风格损失的数量级通常比内容损失大尤其是多层格拉姆矩阵加在一起之后如果不做权重平衡风格就会碾压内容。平衡调整时不要只看比例数字要每几十次迭代把生成图存出来看一眼。第二个场景是优化器选择。Adam在风格迁移里表现尚可但迭代中后期容易出现高频噪声LBFGS每一步都比较稳收敛也更快但有时候会陷入局部平滑区生成图的细节不足。我现在倾向于用LBFGS配一个相对大的迭代上限然后每隔一段时间做一次像素扰动跳出局部效果会好一些。第三个场景是分辨率带来的问题。直接在256乘256的图片上调参速度很慢而且一次跑崩浪费的时间太多。正确做法是在64乘64的小图上先定好权重范围和迭代次数确认loss曲线稳定后再切换到大图。调参结论基本可以从小图迁移到大图效率高一个数量级。风格迁移这个任务的最终评价指标不是损失而是人眼主观感受所以中间图比损失曲线重要得多。4. 对抗样本小试验用一次FGSM攻击理解模型的线性盲区4.1 对抗扰动为什么存在高维空间的线性错觉很多人第一次听到对抗样本的反应是怎么可能在人眼看不出差别的图片上模型识别结果就完全变了课程里给出了一个非常直观的解释卷积模型在局部看起来非线性但当输入维度非常高时微小扰动被参数矩阵放大后的总和完全可能改变决策。FGSM快速梯度符号方法把这个理论落地了。它不要求扰动让图片看起来完全不一样只要求在每一个像素方向上朝“让损失变大”的符号方向移动然后统一用epsilon控制幅度。一个梯度符号只代表方向不代表长度乘上epsilon之后所有方向的累计变化叠加起来足够让模型跨过分类边界。这个观察让我重新认识了“模型鲁棒性”测试准确率高不代表模型对微小变化稳定。4.2 用MNIST做最直白的FGSM演示我不想一开始就上复杂的真实图片所以先用MNIST做了一个小实验。训练一个很简单的卷积网络几轮之后准确率就能到96%以上然后做白盒攻击。核心代码非常短def fgsm_attack(image, epsilon, grad): perturbed image.clone() epsilon * grad.sign() perturbed torch.clamp(perturbed, 0.0, 1.0) return perturbed loss F.nll_loss(model(image), target) model.zero_grad() loss.backward() data_grad image.grad.data perturbed fgsm_attack(image, epsilon, data_grad)看懂这五行代码的关键在于对输入求梯度梯度的符号就是“让当前损失变大还是变小”的方向。如果我们把输入加上正梯度符号可以让损失上升模型就更容易出错。epsilon控制变化量MNIST这类灰度图上用0.3会有明显的效果准确率可能会从96%掉到30%以下。如果把同样的方法用到真实照片上epsilon要小得多通常0.03左右就能产生肉眼不易察觉、却能让模型失效的扰动。放大看会有一层细微的颗粒噪声这种噪声人眼几乎注意不到但模型对某些高频信号极其敏感。我试过几次之后最大的感受是模型分类的决策面在高维空间里远比我们想象得曲折测试集准确率只能说在某个固定分布上表现好不能说明它理解了图像内容。4.3 攻击成功的边界与防御思路初步做完这个实验自然会想到怎么防御。课程里提到的方向主要有三种对抗训练把攻击后的样本也放进训练集输入预处理比如中值滤波对小幅的对抗噪声有抑制作用输出层面的置信度判断设置更保守的阈值。但这些方法都不是万能的。我更在意的是这件事的实验方法论价值。以前我评估模型只看测试集准确率现在会额外做几个小扰动攻击测试看看精度下降的斜率有多快。这个习惯比记住任何攻击算法都重要因为它逼着你思考模型是在利用真正的语义特征还是在利用一些脆弱的统计相关性。以后做图像分类、目标检测甚至图像生成都要把这一课纳入评估闭环。5. 从这篇笔记延伸出的调试习惯和三条个人经验5.1 让梯度变成调试的第一语言这周结束我认为最有用的收获不是会画热力图也不是会做风格迁移而是养成了一个习惯当模型出问题时先问“损失对什么敏感”而不是马上换结构。如果损失曲线震荡我会挑一个简单样本固定模型权重改变输入某个维度的值观察梯度方向变化如果生成结果糊成一片我会检查是不是梯度传到太深的层时已经消失如果分类任务老是混淆某两个类别我会用类激活映射看模型到底在关注图片的哪个区域而不是先怀疑数据增强不够。5.2 Hook的释放与显存管理前面已经提过钩子缓存导致内存爆炸的问题这里再单独拎出来说一次。任何带可视化功能的实验代码强烈建议把钩子对象的释放写进函数末尾或者用try/finally包起来。不要在循环里反复注册同名钩子不然特征列表会越积越多。如果做风格迁移特征提取器只应该构造一次每次前向直接取结果不要每轮迭代都重新注册一遍钩子。5.3 三条我认为最有性价比的个人经验第一先小图后大图。风格迁移和对抗攻击都可以先在64乘64或更小的图上验证逻辑再升级到256乘256。小图上能把调参范围、迭代次数和预期效果摸清楚速度优势是数量级的而且结论基本可迁移。第二分开记录每一项损失。只盯着总损失一个标量很容易掩盖问题。风格迁移里内容损失和风格损失要分开记录定期打印各自的值训练出问题时看哪一项不动就基本能定位是哪一端的梯度出了问题。第三使用层的名字而不是索引。不同框架或预训练模型的层编号可能不一致复制别人的代码时索引经常错位。我吃过一次亏把内容层选错结果损失一直不降查了好久才发现是索引对不上。改用命名方式之后代码可读性和可迁移性都好了很多。5.4 之后可以在这条线上继续顺下去的想法这篇笔记过后我计划把同样的“对输入求梯度”思路延伸到超分辨率和生成式对抗网络。超分辨率实际上和风格迁移共享差不多的图像生成框架只是把风格损失换成了更贴近真实图像的感知损失而生成式对抗网络更是把“让网络自己产生图像”这件事变成了对抗游戏。只要把这一课关于输入空间优化的基础打牢后面再学生成模型会顺很多。如果你也正在跟这门课恰好读到第二十八篇附近建议不要跳过可视化相关的作业代码花一个下午手写一遍后面省下的时间会远超这一点投入。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑