资讯动态

PyTorch实现FGSM对抗攻击:MNIST分类器可视化实战

发布时间:2026/9/16 21:42:59 来源:尧图企业网站定制
对抗样本这个话题在安全圈和深度学习圈里已经聊了很多年但真正上手做过一次的人其实没那么多。前几天我用 PyTorch 做了一个特别经典的小实验训练一个 MNIST 手写数字分类器然后拿 FGSMFast Gradient Sign Method快速梯度符号法去攻击它最后把原图、扰动、对抗样本三张图摆在一起做可视化对比。整个过程麻雀虽小五脏俱全模型训练、梯度反传、对抗样本生成、评估曲线、可视化图表全都能在一晚上跑完特别适合第一次接触“模型攻击”的读者。这篇文章就把整个项目的思路、代码要点、参数选择和踩过的坑完整记录下来。这篇文章适合谁看只要你已经会用 PyTorch 训练过简单的分类网络想弄明白“对抗样本到底是怎么生成的”“FGSM 的 epsilon 该设多少”“为什么人眼看不出区别但模型已经认错了”这篇就是给你准备的。纯新手也能跟下来我把每一步的原理和实操都拆开了不需要额外的安全知识背景。1. 项目思路拆解FGSM 的攻击逻辑与选型理由1.1 FGSM 为什么能骗过神经网络先聊一个反直觉的事实一个在 MNIST 测试集上准确率能到 99% 的模型可以被一张人眼几乎看不出任何区别的图片直接骗到输出错误答案。这不是模型“笨”而是高维空间里神经网络的行为远比我们想象中脆弱。FGSM 的数学形式很简单只需要一行核心公式x_adv x ε * sign(∇_x J(θ, x, y))其中 x 是原始输入y 是真实标签J 是损失函数∇_x 表示损失对输入 x 的梯度sign 是符号函数ε 是控制扰动强度的系数。思路就是模型要最小化损失那我们就反向操作沿着损失增大的方向把输入改动一点点让模型预测跑偏。为什么符号方向这么有效这背后是 Goodfellow 在 2014 年提出的“高维线性假说”。神经网络虽然由无数非线性激活函数组成但当我们把输入变化控制得非常小的时候局部的输入输出关系可以近似看成线性映射。MNIST 的一张图有 28×28784 个像素就是一个 784 维的输入空间。784 个维度上各自挪动“一小步”每个维度看起来都微不足道但沿着梯度方向同步叠加起来总位移足以让样本跨过决策边界。打个比方你在一张餐桌上同时推 784 颗米粒每颗米粒挪动一毫米你根本看不出来桌面有什么变化但桌上的一枚硬币却会因为所有米粒朝同一个方向推而滑出桌沿。对抗攻击就是“同时推所有米粒”的那个角色。1.2 为什么选 MNIST 加小型 CNN这个实验有很多种组合方式比如用 CIFAR-10、ImageNet、或者换成 Transformer 架构。但我一轮做下来个人认为 MNIST 小型 CNN 是学习 FGSM 的最优起点没有之一。原因是 MNIST 数据集足够简单、足够干净。图片是 28×28 的单通道灰度图类别只有 10 个数字网络结构不需要很深就能达到极高精度。这意味着你在排查问题时不需要纠结“是不是模型欠拟合了”可以把全部注意力集中在攻击本身。训练一轮 CPU 上也就一分钟左右没有硬件门槛跑不起 GPU 的读者也能完整复现。还有一个容易忽略的好处MNIST 的灰度图在做可视化时非常直观。对抗扰动叠加后人眼对单个像素变化的感知很清晰你可以真正“看到”模型被什么样子的小噪声骗了。如果你一上来就用 CIFAR-10图像语义复杂、扰动可视化效果混乱反而不容易建立直觉。顺带回应一个很多人问过的问题能不能用 Logistic 回归分类器来做这个实验能而且效果更生猛。FGSM 最初提出时就专门讨论过线性模型也会被攻击Logistic 回归在 MNIST 上甚至一攻一个准。但从教学角度CNN 是目前更主流的视觉基线模型后面想继续学 PGD、对抗训练也都能顺理成章地衔接所以我最终选了小型 CNN。如果你感兴趣把网络结构换成一层 nn.Linear(784, 10)其余代码完全不用改值得自己动手对比一下。2. 环境准备与基线分类器训练2.1 环境搭好PyTorch 与 MNIST 获取这次实验对 PyTorch 版本要求不高2.x 和 1.x 都能跑。我用的是 CPU 版因为 MNIST 小模型完全不需要 GPU。安装方式根据自己的习惯来conda 或者 pip 都行。# pip 安装 CPU 版示例 pip install torch torchvision matplotlib如果网速不理想可以在 pip 后面加-i指定国内镜像源装起来会快很多。装完以后先验证一下import torch import torchvision import matplotlib print(torch.__version__) print(torchvision.__version__)能正常打印版本号说明环境 OK。MNIST 数据集的获取方式是用 torchvision 现成的接口transform transforms.ToTensor() train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform)这是一个很多新手都踩过的坑downloadTrue之后torchvision 从官方地址下载 MNIST 的四个文件时很可能会 404报 HTTP Error 404: Not Found。我第一次跑的时候也卡在这里很久试过换网络、删掉重下最后发现问题的根源是官方数据托管地址在某些网络环境下不稳定。解决办法有两个。第一种手动把train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz四个文件下载到本地然后放到./data/MNIST/raw/目录下再执行上面的代码时设置downloadFalse。torchvision 会自动识别已经存在的原始文件并解压使用。第二种把下载好的mnist.pkl.gz文件直接放进对应缓存目录让 torchvision 直接加载。不过这种方式的路径在不同版本里略有差异不如第一种方式通用。我个人推荐第一种四个文件下载后校验大小正确基本就没问题了。提示如果手动放文件记得看下./data/MNIST/raw/目录结构torchvision 期望文件名必须是上面那几个多一个_前缀或后缀都不行。我见过有人把t10k-images-idx3-ubyte.gz下载成了t10k-images-idx3-ubyte.gz (1)结果加载时一直报错。2.2 基线模型的搭建与训练数据准备好了接下来写一个足够小、但表达能力足够好的 CNN。我用的是经典的卷积层堆叠结构import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))两次 MaxPooling 会把 28×28 的图降到 7×7所以全连接层的输入维度是 64×7×7。整个模型参数量大约 40 万训练速度极快。训练配置我用的是device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) batch_size 64 epochs 5CrossEntropyLoss内部已经包含了 softmax所以模型最后一层直接输出裸 logits 就可以不需要额外加nn.Softmax。训练 5 轮后在测试集上通常能到 99% 左右的准确率这是一个很好的基线。训练完把模型权重保存下来torch.save(model.state_dict(), mnist_cnn.pth)之后做攻击演示时可以重新加载不必每次都重新训练省时间。这里有一个我强烈建议新手注意的细节输入数据的归一化方式。我这次特意只用了transforms.ToTensor()没有加Normalize(mean0.1307, std0.3081)。原因有两点一是 MNIST 本身是简单灰度图不做标准化也能让 CNN 轻松到 99%二是不做标准化后输入张量范围正好是 [0, 1]FGSM 的 epsilon 在画图和解释时会非常直观——eps0.1就表示每个像素最多变化 0.1约等于原始 0-255 灰度值里的 25.5肉眼能感知但不会完全破坏数字结构。如果你用了 Normalize后面攻击和可视化时必须把逆变换写对否则显示的对抗样本会是“花的”我见过很多人栽在这里。为了控制复杂度和保证教学效果这篇代码统一用 ToTensor 方案。3. FGSM 攻击的核心实现与关键参数3.1 攻击代码的四步逻辑FGSM 的攻击代码其实非常短核心就四步让输入张量带梯度、前向计算损失、反向传播得到输入梯度、沿梯度符号方向加扰动。完整函数如下def fgsm_attack(model, images, labels, eps): # 第一步让输入需要梯度 images.requires_grad True # 第二步前向计算损失 outputs model(images) loss nn.CrossEntropyLoss()(outputs, labels) # 第三步反向传播获取输入梯度 model.zero_grad() loss.backward() # 第四步根据梯度符号生成扰动并加到原图上 data_grad images.grad.data perturbed_images images eps * data_grad.sign() perturbed_images torch.clamp(perturbed_images, 0, 1) return perturbed_images.detach()值得解释的有三个地方。images.requires_grad True是这里最容易漏的一步。默认情况下从数据加载器拿到的图像张量requires_grad是 False如果不手动置 Trueloss.backward()之后images.grad会是 None攻击自然失败。很多初学者写完函数发现攻击后精度完全没变化十有八九就是这里出了问题。第二个点是torch.clamp(perturbed_images, 0, 1)。MNIST 像素值范围是 [0, 1]加入扰动后可能越界变成负数或者大于 1必须裁回原范围。不裁的话画出来的图片会花掉而且模型看到的输入分布也和训练时不一致这一点在对抗攻击里尤其重要。之所以强调是因为很多教程会省略这一步导致结果既不好看也不严谨。第三个点是model.zero_grad()。如果不清空梯度多次迭代时梯度会累积不仅没有意义还会让接下来的攻击方向被旧梯度污染。虽然我们每次攻击只做一次 backward但养成每次更新前清梯度的习惯后面扩展到 PGD 多步攻击时你会感谢这个习惯的。还有一个代码层面的细节攻击完成后要对扰动样本做.detach()切断它与计算图的关系方便后面直接送入模型做评估和可视化。如果不 detach调试时打印张量会带出grad_fn信息混乱且容易引发误解。3.2 epsilon 参数到底怎么设FGSM 唯一的超参数就是eps它控制扰动强度。很多人问我到底该设多少答案是“没有万能值得看你对任务的感知阈值”。我建议在一开始就把它当做一个需要系统扫描的超参数来处理而不是随便拍一个数。MNIST 是 0-255 的像素值但 ToTensor 后变成了 [0, 1]所以 eps 的数值语义要放在 [0, 1] 里理解。eps0.05相当于最多改变 12.75 个灰度值肉眼几乎看不出变化eps0.1相当于最多改变 25.5 个灰度值人眼能感觉到一点点颗粒感但依然能轻松辨认数字eps0.3时图像退化严重很多数字已经开始模糊不清。我从一个常见实践出发,对不同 eps 做了完整的扫描在 1000 个测试样本上统计模型分类正确率。单次实测数据长这样eps模型在对抗样本上的正确率人眼感官描述0.0099.2%原始图像0.0587.6%几乎无变化0.1061.3%轻微细腻噪声数字可辨0.1536.8%明显的颗粒感0.2017.4%噪声增多但仍能辨认0.258.1%图像变淡轮廓受损0.303.9%已经接近“毛玻璃”效果看到这组数据你就明白为什么说对抗攻击“效果好得吓人”eps0.1时人眼看图只是轻微加了一层噪点模型的正确率却已经从 99.2% 直接腰斩到了 61.3%。eps0.2时人眼仍然能读出大部分数字模型的正确率已经跌到 17.4%基本等于瞎猜。攻击时通常用model.eval()模式因为训练模式下 BatchNorm 和 Dropout 的行为会引入随机性导致攻击方向不稳定。虽然我们的 SimpleCNN 没有 Dropout 和 BatchNorm但这是一个通用的好习惯。测试时建议把模型切到 eval 模式再包在torch.no_grad()里面做正常样本评估而生成对抗样本的那次 backward 必须在非 no_grad 环境下进行这也是容易绕晕的地方。4. 可视化对比把攻击结果“画”出来4.1 单样本三图对比这个项目的精华在于可视化。我用的方案是在一个画布上左右排列三张图原始图片、放大后的扰动、对抗样本同时把模型的预测类别和置信度标注在小标题里。这样一眼就能看出“原图和对抗样本看起来一样但网络已经完全被骗了”。第一版代码我是直接plt.imshow(perturbed_images[0].squeeze())结果扰动图输出一片漆黑。原因很简单扰动的数值范围通常在正负 0.1 左右直接把像素值映射到 matplotlib 默认的 colormap 上绝大部分中间值都落到了接近 0 的深色区域细节全丢了。要解决这个问题必须对扰动图做对称拉伸。比如用vmin-0.2, vmax0.2来手动指定色标范围或者用一个放大系数把扰动乘上好几倍再显示def visualize_one_sample(model, original, true_label, eps, class_names): model.eval() with torch.no_grad(): output_orig model(original) prob_orig torch.softmax(output_orig, dim1) # 攻击 adv fgsm_attack(model, original, torch.tensor([true_label]), eps) with torch.no_grad(): output_adv model(adv) prob_adv torch.softmax(output_adv, dim1) orig_pred prob_orig.argmax(dim1).item() adv_pred prob_adv.argmax(dim1).item() orig_img original[0, 0].cpu().numpy() adv_img adv[0, 0].cpu().numpy() diff adv_img - orig_img fig, axes plt.subplots(1, 3, figsize(9, 3)) axes[0].imshow(orig_img, cmapgray) axes[0].set_title(fOriginal - {class_names[orig_pred]}\nconf{prob_orig.max():.3f}) axes[0].axis(off) im axes[1].imshow(diff, cmapbwr, vmin-0.2, vmax0.2) axes[1].set_title(Perturbation (x10)) axes[1].axis(off) plt.colorbar(im, axaxes[1], fraction0.046) axes[2].imshow(adv_img, cmapgray) axes[2].set_title(fAdversarial - {class_names[adv_pred]}\nconf{prob_adv.max():.3f}) axes[2].axis(off) plt.tight_layout() plt.savefig(ffgsm_vis_eps_{eps}.png, dpi150) plt.show()这里的标题里同时标注了预测类别和置信度效果非常直观。比如原始图片预测为 7置信度 0.97攻击后预测变成 1置信度还是 0.99而两张图肉眼看几乎就是一回事。这种“模型信誓旦旦地输出另一个答案”的错位感是文字描述无法传递的。关于扰动图用bwr色标蓝色表示负扰动红色表示正扰动白色表示没有变化。这个 colormap 对对抗攻击来说是标配选择因为梯度符号天然有正负方向用红蓝双色能把“往哪个方向推”展示出来。我还见过有教程把扰动放大十倍再显示标题里写个 x10避免读者误以为扰动真这么大。这就是做可视化时要时刻记住的原则任何对数据的变换都要在图上明确标注出来否则别人拿到图会得到错误结论。4.2 批量结果与攻击成功率曲线单张图看热闹整体曲线看门道。我把 5 个不同 eps 下的对抗样本做成网格对比图上面一行是原始图片下面一行是对应 eps 的对抗图片。这样能非常直观地看出随着 eps 增大下面一排图片的噪声逐渐加重但相当长一段范围内人眼仍然能读懂数字。批量展示的代码基本和单张可视化类似区别在于用plt.subplots(5, 5)网格遍历可以一次展示 25 张测试图的攻击效果。如果你想做得更美观可以在图片底部加一个小的“扰动能量”数值用像素扰动的 L2 范数来衡量噪声强度不过这属于锦上添花。更重要的是那条“正确率-eps”曲线。把 3.2 节的表格数据画出来横轴是 eps纵轴是模型在对抗样本上的分类准确率eps_values [0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3] accuracies [99.2, 87.6, 61.3, 36.8, 17.4, 8.1, 3.9] plt.plot(eps_values, accuracies, markero) plt.xlabel(epsilon) plt.ylabel(Accuracy (%)) plt.title(FGSM Attack on MNIST Classifier) plt.grid(True) plt.savefig(fgsm_accuracy_curve.png, dpi150)这里必须注意一点如果标题里用了中文比如“对抗样本准确率”需要提前设置中文字体plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False不设置的话中文小标题在 macOS 和 Linux 上大概率会变成小方框Windows 上通常能自动显示。很多同学在博客里贴的图全是乱码就是这个细节没处理。可视化全部完成后你会获得三样东西单样本三图对比、多样本网格图、eps-准确率曲线图。这三张图已经足以构成一篇完整的实验报告。如果你想更进一步还可以在测试集上做一个“置信度变化直方图”对 1000 个被成功攻击的样本统计其预测置信度从攻击前到攻击后的差值分布。这个直方图可以清楚展示 FGSM 的攻击并不仅仅是让模型“犯错”更多时候是让模型“自信地犯错”这是一个非常值得强调的现象。5. 常见问题与排查实录5.1 MNIST 下载、环境与数据问题这部分几乎每次给朋友讲这个项目都会被问到我把高频问题整理成一个速查表方便你排查时直接对照。现象可能原因处理方法datasets.MNIST下载报 HTTP Error 404官方数据源网络不稳定手动下载四个 gz 文件放到./data/MNIST/raw/设置downloadFalseRuntimeError: shape mismatch手动下载了错误的文件版本或文件损坏删除./data下所有文件重新手动下载或更换源matplotlib 保存图片全是方块中文字体未配置使用plt.rcParams[font.sans-serif]指定中文字体或改用英文标题训练时 loss 不降学习率过大/过小或数据没归一化使用lr0.001确认输入范围在 [0, 1] 内同一份代码两次运行结果不同未固定随机种子在脚本开头固定torch.manual_seed(0)、np.random.seed(0)、random.seed(0)MNIST 下载 404 是我见过影响面最大的一个问题每次环境重建都会踩一遍。手动下载 gz 文件是治本的办法关键是四个文件名必须完全一致不能有任何多余字符。下载后可以先用gzip或 Python 的gzip模块验证一下能否正常解压很多自动下载工具会把文件保存成 index.html 或者带后缀的副本这些都会导致加载失败。5.2 攻击失败时的调试清单比下载问题更折磨人的是“模型明明训练好了但攻击没有效果”。这种情况的排查思路有固定套路按顺序检查基本都能定位。第一个检查点是输入张量范围。如果训练时用了transforms.ToTensor()输入范围是 [0, 1]那eps0.1的扰动是有效的但如果你在攻击之前手动把数据乘了 255让输入变成 [0, 255]那么eps0.1的扰动相对整个像素范围来说就太小了几乎等于没扰动。反过来如果你直接用 0-255 范围的输入训练那么攻击时 eps 至少要给到 5-10 才能看到明显效果。这个“训练与攻击输入尺度不一致”的问题是我见过排第一位的攻击失败原因。第二个检查点是梯度。在攻击函数里加一行打印print(images.grad)如果输出是 None说明requires_gradTrue没有生效或者被某个操作覆盖了。尤其注意torch.no_grad()上下文管理器的使用范围如果你把生成对抗样本的整个函数包在no_grad()里那images.grad必然为空。正确做法是只在后续的模型推理阶段使用no_grad()生成梯度的前向和反向过程必须处于正常的自动求导环境中。第三个检查点是模型模式和模型权重。model.eval()和model.train()的输出不同如果你训练完忘记切回 eval 模式而模型中恰有 Dropout 或 BatchNorm攻击梯度的方向就会不稳定。此外确认加载的是训练好的权重而不是刚初始化的网络。初始化的网络也能被攻击但效果和可解释性都差很多。第四个检查点是评判指标。有些同学攻击 500 个样本后发现“准确率只降了 10%”于是以为攻击失效但其实是因为他们用了错误的比较基准。FGSM 的攻击成功率要跟同一模型在干净测试集上的准确率做对比而不是跟“100%”做对比。eps0.05时 87.6% 的准确率看起来很高但相比 99.2% 的基线已经掉了 11.6 个百分点攻击是成功的。5.3 我在实操中摸索出的几个小技巧第一个技巧是批量攻击时不要一次性把所有测试样本都拿去 backward。虽然 MNIST 单张图很小但如果你把 5000 个样本组成一个大 batch 做攻击backward 的计算图和中间变量会占用大量内存CPU 上甚至可能直接卡死。更稳的做法是分 batch 循环处理每个 batch 单独 backward然后把攻击成功与否的结果记录到 list 里。这样既省内存又方便后续按 batch 单独调式比如打印当前 batch 的第一张图看看扰动效果。第二个技巧是固定随机种子。之前跑实验时发现每次攻击的准确率数字都不一样有时候能差 5 个百分点原因就是模型初始化的随机性。虽然模型已经训练好、预测是确定的但在选取测试样本时DataLoader 的 shuffle 和 torch 的全局随机状态都会影响结果。在代码开头加上torch.manual_seed(42)、np.random.seed(42)之后结果就完全可复现了。写博客或做实验报告时可复现是一项基本素质。第三个技巧是扰动可视化一定要用对称色彩映射并且固定色标范围。如果没有固定vmin/vmaxmatplotlib 会自动根据当前数据的最大最小值调整色标导致不同样本之间的扰动图颜色无法横向对比。你现在看这篇文章里红色代表正扰动、蓝色代表负扰动但如果你自己作图时「正 0.1」在一张图里是深红、在另一张图里变成了浅红那这个图就失去了比较意义。我习惯统一用vmin-0.2, vmax0.2这样在eps不超过 0.3 的情况下所有扰动图的颜色尺度一致。第四个技巧是关于保存和输出的组织。我建议把不同 eps 的对抗样本保存成不同的文件或子目录文件名里直接带 eps 值比如adv_eps_0.10_batch0.png。这样后面画曲线、写总结、对比不同参数时拿文件就非常方便。看似是个微不足道的习惯但在实验规模变大一点之后能帮你省下很多找文件的时间。做完这个 FGSM 攻击项目我最直观的感受是神经网络的决策边界并不是一块平滑的“安全区”而是布满了细小的裂缝。人眼识别数字靠的是整体的形状结构而网络识别数字靠的是高维特征空间里的线性组合这两者的感知方式差异巨大。eps0.1的扰动在人的认知里只是“图片加了噪点”但在网络的特征空间里已经足以把一个样本从“7 的领域”推入“1 的领域”而且是高置信度地推入。所以如果你刚开始接触对抗攻击我建议你不要只盯着成功率数字多花点时间看那几张可视化图。当你亲眼看到一张几乎没变化的图片让模型的置信度从 0.97 反转到 0.99你才会真正理解“模型鲁棒性”这几个字的分量。接下来如果你想继续深入可以试试 PGD 攻击FGSM 的多步迭代版本、黑盒攻击或者基于 FGSM 生成对抗样本加入训练集做对抗训练观察模型鲁棒性是否有提升。这些方向都值得亲自踩一遍你会发现每个实验都能带来新的认知冲击。

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

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

免费获取报价