简介面向图像分类模型安全研究的学习者这份资源以Fashion_MNIST为基准数据集完整呈现对抗攻击与对抗训练的实践流程。包内包含模型定义与训练脚本、FGSM/PGD等攻击算法实现、训练好的h5模型权重、pkl数据文件以及大量攻击效果对比图片能够帮助读者快速复现实验并观察扰动对分类结果的影响。资源共121个文件涵盖Python源码、JPG图像、模型文件和Markdown说明压缩包约15MB结构清晰便于按模块查阅。目前已有115人学习下载。通过这份资料学习者可掌握对抗样本的生成方法、模型鲁棒性评估思路以及在不显著损失正常样本准确率的前提下提升防御能力的具体调参经验适合具备一定深度学习基础、希望深入理解AI安全攻防的开发者。1. 对抗攻击不是玄学在 Fashion_MNIST 上复现一次“模型失手”一个在测试集上跑出 93% 准确率的 Fashion_MNIST 图像分类模型看起来已经“够用”了。但只要你往图片上加一层人眼几乎察觉不到的微小扰动这个模型的准确率会直接掉到 3% 以下跟瞎猜没有区别。这就是对抗攻击而且它不需要什么高深技巧FGSM 和 PGD 这两个经典方法就能让你在本地完整复现整个过程。对抗训练是当前最直接、最可靠的防御手段它不会让模型变得完美但能把对抗场景下的准确率从个位数拉回到可用水平。这篇笔记就用 PyTorch 配合 Fashion_MNIST 数据集把攻击、防御、参数调优和踩坑点一次讲透。适合刚接触对抗安全方向、想在自己实验里做出可量化结果的开发者。2. 先立靶子用 PyTorch 搭一个 Fashion_MNIST 图像分类基线对抗攻击和对抗训练都建立在“一个能正常工作的图像分类模型”之上。你没法攻击一个还没训练好的模型也没法在随机权重上验证防御效果。所以第一步不是急着写攻击代码而是先把分类基线做扎实数据准备、模型结构、训练参数每一步都要确定下来。因为后面的所有攻击成功率和防御效果都是相对于这个基线算出来的。2.1 数据准备与归一化Fashion_MNIST 的灰度图要不要做复杂预处理Fashion_MNIST 是 28×28 的单通道灰度图共 10 个服装类别训练集 6 万张、测试集 1 万张。它比手写数字识别难不少尤其是衬衫、套头衫、连衣裙这几类彼此间的视觉差异很小这正好给了对抗攻击发挥的空间。我建议你直接用 PyTorch 的 torchvision 内置接口下载不需要自己找数据集。预处理只做一件事把像素从 [0, 255] 缩放到 [0, 1]。不要额外做标准化更不要上随机裁剪、旋转这些数据增强。原因有两个一是对抗扰动的大小 epsilon 是相对于像素范围定义的统一在 [0, 1] 上讨论哪个值算“大”、哪个值算“小”才直观二是 Fashion_MNIST 本身是灰度小图过强的数据增强会把对抗训练搞得很不稳定等你把整套流程跑通了再考虑增强也不迟。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), # 转 tensor 并把像素缩放到 [0, 1] ]) train_set datasets.FashionMNIST( root./data, trainTrue, downloadTrue, transformtransform ) test_set datasets.FashionMNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader DataLoader(train_set, batch_size128, shuffleTrue, num_workers4) test_loader DataLoader(test_set, batch_size256, shuffleFalse, num_workers4)downloadTrue 会自动把数据下载到 root 目录第一次跑会花一两分钟之后就直接从本地读取。ToTensor 是必须保留的它把 numpy 数组转成 tensor同时完成像素缩放。num_workers4 在 Windows 上如果报错改成 0 即可这个不影响训练结果只影响数据读取速度。归一化到 [0, 1] 后后面攻击代码里写 eps0.1意思就是“允许每个像素最大偏移 0.1”。你可以直观感受到这个扰动有多小因为像素值就被压缩在 0 到 1 之间0.1 的偏移意味着大概 10% 的灰度变化人眼只能勉强看出一点点噪声颗粒。2.2 轻量 CNN 基线模型结构与训练参数对抗攻击实验不需要重型网络层数越深、参数量越大只是让训练变慢并不会让攻击原理变得更好懂。我一般用一个两层卷积加两层全连接的轻量 CNN参数量大约四十万CPU 上跑完 10 个 epoch 也就几分钟时间。import torch.nn as nn import torch.nn.functional as F class TinyCNN(nn.Module): def __init__(self): super().__init__() # 输入是 1 通道的 28x28 灰度图 self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) # 经过两次 2x2 池化特征图从 28x28 变成 7x7 self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x F.relu(self.conv1(x)) x F.max_pool2d(x, 2) x F.relu(self.conv2(x)) x F.max_pool2d(x, 2) x x.view(x.size(0), -1) # 展平 x F.relu(self.fc1(x)) x self.fc2(x) return xforward 里最后没有做 softmax因为交叉熵损失函数在 PyTorch 里会把 logits 直接作为输入内部自己完成 log_softmax 和 nll_loss 的合并计算。如果你在模型输出端手动加 F.softmax反而会让梯度计算出问题这一点新手经常搞混。训练参数方面我用 Adam初始学习率 1e-3batch size 128训练 10 个 epoch。不要在这个阶段用力过猛去冲准确率对抗实验需要的是一个“正常但有缺陷”的模型而不是一个经过充分调参的完美模型。学习率也不需要调度器固定学习率跑完 10 个 epoch 就够。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model TinyCNN().to(device) optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (outputs.argmax(1) labels).sum().item() total images.size(0) return total_loss / total, correct / total训练时 model.train() 打开 Dropout 和 BatchNorm 的训练行为这一点在对抗攻击阶段要特别注意因为攻击时通常要切到 model.eval()这两个模式下的梯度行为差别很大。2.3 记录干净样本准确率一切对比的基准值在跑任何攻击之前先把模型在干净测试集上的准确率记录下来。这个数字是你的“对照基准”后面评估攻击效果和防御效果都拿它来说话。def evaluate(model, loader): model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) return correct / total clean_acc evaluate(model, test_loader) print(f干净测试集准确率: {clean_acc:.4f})我在自己机器上跑出来的基线准确率大概是 92% 到 94% 之间不同随机种子会有一两个点的波动。如果你的结果明显低于 88%先检查数据预处理是不是多做了标准化再检查学习率是不是太大导致没收敛。基线模型质量太差的话后面的对抗攻击成功率会虚高对抗训练的结果也会失真整个实验就不可信了。3. 白盒攻击实现FGSM 与 PGD 完整代码与参数拆解当模型在干净样本上达到 90% 以上的准确率之后就可以开始动手攻击了。这里只讨论白盒攻击也就是攻击者能拿到模型的完整参数和梯度。这是对抗攻击的基础场景也是对抗训练里必须使用的攻击类型。FGSM 和 PGD 的思路本质上是同一个沿着损失函数对输入图像的梯度方向对图像做扰动。区别在于走一步还是走多步。下面我把两个方法都写出来并说明每一步在做什么。3.1 FGSM单步梯度符号攻击的原理与实现FGSM 全称 Fast Gradient Sign Method思路非常直白既然梯度方向是让损失最快增大的方向那么直接把图像朝这个方向挪一小步就能让模型在改过的图像上产生错误预测。def fgsm_attack(model, images, labels, eps0.1): model.eval() images.requires_grad_(True) outputs model(images) loss F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() # 梯度的符号方向代表“让损失增大”的方向 grad_sign images.grad.sign() adv_images images eps * grad_sign adv_images torch.clamp(adv_images, 0, 1) return adv_images两个关键点需要拆开说。第一images.requires_grad_(True) 必须在 forward 之前设置否则梯度不会在图像上累积loss.backward() 之后 images.grad 会是 None。第二为什么要用 grad.sign() 而不是直接用完整的梯度FGSM 扰动的是“方向”而不是“幅度”sign 把梯度归一化成每个元素只取 1 或 -1这样 epsilon 就成了对每个像素统一的扰动幅度上限。如果直接用原始梯度扰动的大小会随着梯度范数变化epsilon 的含义就不明确了。torch.clamp(adv_images, 0, 1) 这行不可省略。Fashion_MNIST 的像素被缩放到 [0, 1] 之后加扰动可能会越界必须裁回合法范围。这也是后面评估攻击成功率时能够和干净像素做视觉对比的前提。跑完 FGSM 之后把对抗样本喂回模型统计准确率。在 93% 基线的模型上做 eps0.1 的 FGSM准确率大概会掉到 12% 到 25% 之间具体取决于模型训练时的随机性。对一个“平时准确率 93%”的模型来说这个掉幅已经很吓人了但 FGSM 只是开胃菜PGD 的杀伤力更强。3.2 PGD迭代扰动为什么比 FGSM 更有效PGD 全称 Projected Gradient Descent是 FGSM 的迭代升级版每次只朝梯度方向走一小步 alpha走完投影回原始图像的 epsilon 邻域内然后重复多步。因为每走一步都会重新计算梯度PGD 能在一个小范围内不断寻找让模型错得最惨的位置效果远好于 FGSM 的一次性操作。def pgd_attack(model, images, labels, eps0.1, alpha0.01, steps40): original_mode model.training model.eval() # 从原始图像出发复制一份作为对抗样本的载体 x images.detach().clone() x.requires_grad_(True) for _ in range(steps): outputs model(x) loss F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() grad_sign x.grad.sign() x x alpha * grad_sign # 投影保证扰动不超出 eps 邻域 x torch.clamp(x, images - eps, images eps) # 裁剪保证像素在合法范围 x torch.clamp(x, 0, 1) # detach 之后重新开梯度供下一步迭代使用 x x.detach().clone() x.requires_grad_(True) model.train(original_mode) return x这段代码有三个细节非常容易踩坑。第一个是每次迭代末尾的 x.detach().clone() 然后重新 requires_grad_(True)如果你偷懒不 detachx 的计算图会越叠越长到第 5 步就会因为内存爆炸直接卡死。第二个是 torch.clamp(x, images - eps, images eps)这一步是把每次迭代的中间结果拉回原始图像的 eps 邻域内保证对抗扰动始终被限制在一个可视范围里这个约束就是 PGD 设计的核心。第三个是函数开头记录 original_mode 并在结束时恢复否则调用完 pgd_attack 之后外层模型会一直停留在 eval 模式训练时该生效的 Dropout 全部失效。关于 alpha 和 steps 的配合一般经验是 alpha 取 eps 的 1/10 到 1/30。eps0.3、alpha0.01、steps40 是评估时的常用组合。alpha 太大容易在邻域边缘来回震荡太小则 40 步可能不够用。3.3 攻击效果量化用攻击成功率判断一个样本集是否被“攻破”代码写完之后不能只靠肉眼看几张对抗样本就说攻击成功要算出整个测试集上的量化指标。我这里报两个数对抗样本在模型上的准确率以及攻击成功率1 减去这个准确率。def evaluate_attack(model, loader, attack_fn, **kwargs): model.eval() correct, total 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) adv_images attack_fn(model, images, labels, **kwargs) outputs model(adv_images) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) return correct / total fgsm_acc evaluate_attack(model, test_loader, fgsm_attack, eps0.1) pgd_acc evaluate_attack(model, test_loader, pgd_attack, eps0.1, alpha0.01, steps40) print(fFGSM 攻击后准确率: {fgsm_acc:.4f}) print(fPGD 攻击后准确率: {pgd_acc:.4f})一个值得记录的现象eps0.1 时FGSM 攻击后模型准确率可能在 20% 左右而 PGD 攻击后直接掉到 5% 以下。同样的扰动幅度迭代方法就是比单步方法更能找到让模型崩溃的位置。这也解释了为什么对抗训练里普遍用 PGD 而不是 FGSM 来生成训练样本。我还习惯把对抗样本单独保存几张出来看。把原始图、扰动图、对抗图拼成一排你会直观看到人眼几乎分辨不出区别但模型的预测已经从正确类别翻到了完全无关的类别。这种“视觉无感、模型全崩”的核心反差就是对抗攻击最反直觉的地方。4. 把防御写进训练循环Fashion_MNIST 上的对抗训练实现实验做到这里你应该已经对“模型这么容易就被攻破”有了切身感受下一步自然是让模型对这类扰动免疫。对抗训练的思路是将计就计在每个训练批次中先生成对抗样本然后把对抗样本连同干净样本一起喂给模型做正常训练。模型在训练阶段就见过被攻击的图像梯度下降会同时优化模型在干净分布和对抗分布上的表现。4.1 对抗训练的最小拦截流程生成对抗样本并混入训练批次对抗训练最朴素的实现是“全对抗训练”每个批次只把对抗样本喂给模型。但我建议你采用“混合损失”的方案这是现在的主流做法也是踩坑最少的方式。import torch.nn.functional as F def adversarial_train_step(model, images, labels, optimizer, eps0.3, alpha0.01, steps7): model.train() # 先生成对抗样本攻击函数内部会切 eval 模式结束后恢复 train adv_images pgd_attack(model, images, labels, epseps, alphaalpha, stepssteps) optimizer.zero_grad() output_clean model(images) output_adv model(adv_images) loss_clean F.cross_entropy(output_clean, labels) loss_adv F.cross_entropy(output_adv, labels) loss (loss_clean loss_adv) / 2 loss.backward() optimizer.step() return loss.item()这段代码把“干净损失”和“对抗损失”各算一遍再取平均。这样模型既要认得原始图像也要认得被扰动过的图像不会为了防御而牺牲掉干净样本上的基础能力。如果你只算对抗损失模型很快就会在干净测试集上暴跌到 50% 以下这是最常见的翻车原因。pgd_attack 里对每张图都重新走 7 步梯度迭代这一步本身需要额外 forward/backward 七次训练时间会变成原来的 6 到 8 倍。在 Fashion_MNIST 这种小数据集上还能接受如果你换到更大的数据集就要考虑用更少的步数或者干脆换用 FGSM 做训练攻击但那样防御效果会打折扣。4.2 对抗训练参数策略epsilon、alpha、步数与随机初始化对抗训练的攻击参数和评估时的攻击参数不完全相同。评估时你有条件走 40 步、每步都精打细算训练时如果也用 40 步一个 epoch 的时间会让你怀疑人生。我给的推荐组合如下这些值在 Fashion_MNIST 上能兼顾防御效果和训练速度。参数评估攻击取值训练攻击取值说明epsilon0.1 ~ 0.30.3训练扰动范围通常大于评估扰动让模型适应更强的攻击alpha0.01eps / 7 约 0.04训练时步长可以略大减少迭代次数steps407训练时 7 步保证效率评估时 40 步保证攻击强度随机初始化无需要每次从 eps 邻域内随机起点开始迭代增加扰动覆盖随机初始化这一点值得单独解释。PGD 是从原始图像开始迭代的如果没有随机扰动生成的对抗样本总是落在同一个方向上模型多练几个 epoch 就“记住”了这个模式。加上随机初始化之后每次训练的对抗样本都从一个随机起点出发覆盖的空间范围会大得多。def pgd_attack_with_rand_start(model, images, labels, eps, alpha, steps): # 在原始图像上加一个 [-eps, eps] 范围的随机噪声作为起点 x images torch.empty_like(images).uniform_(-eps, eps) x torch.clamp(x, 0, 1) x x.detach().clone() x.requires_grad_(True) for _ in range(steps): outputs model(x) loss F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() x x alpha * x.grad.sign() x torch.clamp(x, images - eps, images eps) x torch.clamp(x, 0, 1) x x.detach().clone() x.requires_grad_(True) return x注意随机起点的噪声必须被 clamp 回 [0, 1]因为部分原始像素接近 0 或接近 1加上随机噪声后容易越界。这个细节不处理的话生成的对抗样本会带有明显的“灰色边框”影响训练效果。4.3 对抗训练效果评估干净准确率与对抗准确率要一起看第一阶段先用干净样本训练 10 个 epoch 得到基线模型第二阶段再用对抗训练微调 15 个 epoch。分两阶段的经验是先让模型在干净分布上充分收敛再引入对抗样本训练过程更稳定如果第一轮就直接对抗训练模型前期会同时面对“分类任务还没学会”和“扰动已经在干扰学习”两个困难收敛速度明显变慢。我的跑分记录大概如下不同机器和随机种子会有波动但总体趋势是稳定的。防御前 PGD 攻击后准确率不到 3%防御后能回到 50% 以上同时干净准确率只下降 4 到 5 个点。模型干净准确率FGSM 攻击后PGD 攻击后普通训练93%18%2%对抗训练88%80%56%这个结果说明两件事对抗训练是有效的但它不是免费的。干净样本上的准确率从 93% 掉到 88%这是模型把一部分容量用在抵抗扰动上的代价。另一个需要注意的现象是 FGSM 攻击在对抗训练后的准确率明显高于 PGD因为模型在训练时已经适应了类似 PGD 的迭代攻击模式FGSM 这种单步扰动反而容易落在模型的“安全区域”里。5. 对抗训练避坑指南五个常见的翻车现场我自己在 Fashion_MNIST 上跑对抗攻击和对抗训练的过程中踩过的坑比看教科书里列出来的多得多。这里挑五个最典型的按“现象、原因、解决”的结构写清楚每一条都在我自己的实验里真实发生过。5.1 对抗训练后干净准确率掉到 50% 以下模型被“对抗样本”带偏了现象训练完对抗模型后测干净测试集准确率只有 45%比基线掉了快一半。原因只对对抗样本计算了损失模型在整个训练过程中完全没见过干净样本对自然图像的判别能力被冲掉了。解决每个训练批次同时计算干净损失和对抗损失并取平均也就是上面写的 loss (loss_clean loss_adv) / 2。如果资源允许还可以在批次里拼一半干净样本、一半对抗样本效果等价但梯度更稳定。5.2 7 步 PGD 训练的模型被 40 步 PGD 直接击穿现象用 7 步 PGD 做对抗训练模型在训练时的对抗准确率看着有 70%但你用 40 步 PGD 一测直接掉到 30%。原因训练攻击强度不够7 步能找到的扰动有限模型的鲁棒性边界只覆盖到了浅层攻击。这种情况俗称“给自己的防御放了水”。解决评估攻击必须用远强于训练攻击的配置同时可以适当提高训练时的 alpha 和 steps让模型适应强度更高的扰动。还有一个常见做法是训练时步数不变但增大 epsilon让模型在更大扰动范围内获得鲁棒性。5.3 Fashion_MNIST 本身类别混淆严重衬衫和套头衫被彻底搅乱现象对比对抗样本的预测结果发现被改的衬衫图片大量被预测成套头衫和连衣裙而这几类在原始数据集上本来就难分。原因Fashion_MNIST 部分类别视觉特征高度重叠对抗扰动只需轻轻一推就能让模型越过本来就很模糊的决策边界。解决在类别粒度上分析对抗准确率找出哪些类最容易互相混淆如果业务场景对特定类别更敏感可以在损失函数上对易混淆类别加权重。5.4 训练速度慢到 10 分钟一个 epoch把评估攻击强度用在了训练上现象对抗训练 15 个 epoch 跑了一天每轮训练都要几分钟。原因直接把评估用的 40 步 PGD 用在训练阶段每张图都要反复迭代和反传时间当然成倍增长。解决训练攻击用 7 步和稍大的 alpha评估攻击再用 40 步。这么做牺牲的只是训练时对抗样本的“纯度”换来的是训练时间缩短 80% 以上而且最终评估结果不会差太多。5.5 只看攻击成功率导致自欺欺人模型被攻破概率 99% 不代表防御失败现象有同学跑完对抗训练后测试对抗准确率只有 5%整个人很沮丧觉得训练完全失败了。原因他把“攻击成功率”和“防御失败率”混为一谈。攻击会不断调整扰动方向成功率冲到 99% 不代表模型没有防御能力只能说明攻击者在白盒条件下已经把这个模型研究透了。解决评估时同时记录干净准确率、FGSM 准确率、PGD 准确率三个指标在攻击扰动强度不变的前提下横向对比训练前和训练后得出“扰动容忍度提升”的结论而不是单看成功率这一个数字。6. 进阶把鲁棒性评估做成可复用的多扰动强度验证流程对抗训练的评估不能只测一组固定参数。固定 eps0.3 的结论只对这个扰动强度成立换个更大的扰动边界模型的真实鲁棒性会暴露得很明显。我建议每次实验都扫一组 epsilon 值把准确率变化画成一条曲线这样你能一眼看出模型的脆弱边界在哪里。def evaluate_pgd_eps_sweep(model, loader, eps_list, alpha_ratio0.1, steps40): results [] for eps in eps_list: alpha max(eps * alpha_ratio, 1e-3) acc evaluate_attack( model, loader, pgd_attack, epseps, alphaalpha, stepssteps ) results.append((eps, acc)) print(feps{eps:.2f}, PGD 攻击后准确率{acc:.4f}) return results results evaluate_pgd_eps_sweep(adv_model, test_loader, eps_list[0.0, 0.05, 0.1, 0.2, 0.3, 0.4])eps0 时不加扰动对应的是干净准确率随着 eps 增大准确率下降越快说明模型越脆弱。普通训练的模型在 eps0.05 时可能就开始明显下跌对抗训练的模型往往能扛到 0.2 以上。如果看到训练好的模型在 eps0.1 处出现断崖式下跌说明训练时的 PGD 步数或者随机初始化还有问题需要回头调参。我建议把这份评估脚本固定下来作为每个实验的标配。只测一组参数的对抗训练说服力太弱能稳定扫出多组数据的才能作为备选方案去和别的防御方法对比。这个习惯帮我在两个模拟项目里少走了很多弯路也让我能更早发现模型在真实攻击下的崩溃方式。另外可以按类别统计对抗准确率找到被攻击后最容易混淆的类别对这比整体准确率更有诊断价值。如果你发现类别 2套头衫和类别 4连衣裙被批量混淆就该回去检查训练数据的采样比例和扰动起点设置。这条路走通之后你手里就有一套完整可用、可复现的对抗攻击与对抗训练评测流程了把它应用到其他图像分类任务上也很顺手。尤其是做模型上线前安全性验证的时候这类检测细节比模型本身更值得投入时间希望帮到你。本文还有配套的精品资源点击获取