简介:围绕Fashion_MNIST数据集展开的对抗攻击与对抗训练实战包,面向具备深度学习基础、希望理解模型安全与鲁棒性评估的学习者和研究者,可用于课程项目、毕业设计或安全方向入门实践。压缩包共含121个文件,整体约15.12MB,以100张jpg攻击样本可视化图、9个py脚本、8个pkl数据或缓存文件、2个h5模型权重为主,并附md说明与gitignore配置,按攻击样本、训练脚本、模型文件和项目说明分区组织,便于快速定位与复现实验。已有117人学习。读者可借助包内脚本完成FGSM、PGD等对抗样本生成,观察扰动前后分类结果的变化,并通过对抗训练将对抗样本融入训练过程,对比模型在干净样本与攻击样本上的准确率,讨论鲁棒性提升与泛化能力之间的权衡;模型权重和可视化结果也有助于核对实验现象、排查训练与评估中的常见问题,形成从数据预处理、模型构建到攻击评估的完整实践闭环。
1. 干净精度92%的模型,FGSM ε=0.25一攻就掉到12%:Fashion_MNIST对抗实验的第一个反直觉结论
一个很常见的错觉是,Fashion_MNIST上随手搭个三层卷积就能跑到91%到92%,就觉得模型“已经稳了”。我第一次把训练好的模型拿去做FGSM攻击时,预期最多掉个二三十个点,结果ε设到0.25,测试集准确率直接掉到12%附近,十个类别的预测几乎退化成随机猜。这和当年在MNIST上看到的曲线完全不是一个量级,原因也不玄学:Fashion_MNIST的类间边界更细,T-shirt和Shirt、Pullover和Coat这些类别本来就容易混,像素级扰动一放大,模型就找不到北了。下面围绕Fashion_MNIST上的对抗攻击和对抗训练,把FGSM、PGD怎么调、对抗训练怎么改、哪些参数一改结果就翻车,按我实际跑过的顺序讲清楚。适合刚接触对抗样本、手里正好有这套Attack_Fashion_MNIST资源想直接复现的人。
2. 对抗攻击怎么选、怎么调:FGSM与PGD在Fashion_MNIST上的四组对照
2.1 为什么拿FGSM做基线,而不是一上来就PGD
对抗攻击的入门材料大多先讲FGSM,很多人写着写着就烦了,觉得它太简单,直接跳到PGD。我的习惯反过来:先让FGSM跑通,把整条链路(数据加载→模型前向→求梯度→加扰动→评估)全部打通,再上PGD。原因很实际,FGSM只做一次梯度回传,最好排查。如果这一步的准确率曲线不合理,问题一般不在攻击算法,而在归一化、模型状态或评估口径上。
FGSM做的事就一句话:沿着损失函数对输入像素的梯度符号方向,走一步固定长度的扰动。核心代码短到可以背下来,但每一步都有讲究。
import torch import torch.nn.functional as F def fgsm_attack(model, images, labels, epsilon): """FGSM: 单步梯度符号攻击 epsilon: 扰动强度,像素范围[0,1]下常用0.1~0.3 """ images = images.clone().detach().requires_grad_(True) outputs = model(images) loss = F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() # 梯度符号决定每个像素往哪个方向调 perturbed = images + epsilon * images.grad.sign() # 必须夹回合法像素范围,否则攻击结果不可复现 perturbed = torch.clamp(perturbed, 0.0, 1.0) return perturbed.detach()这段代码里有三个参数会直接影响结论:epsilon、images的取值范围、以及模型处于train还是eval模式。epsilon后面单独讲;images必须在[0,1]区间,如果你在transform里做了Normalize,但攻击时又用归一化之后的张量去加扰动,最后夹取会把大量像素压到边界上,攻击成功率会异常偏高,这个坑后文还会提;model.train()和model.eval()的区别在带BatchNorm和Dropout的网络上尤其明显,攻击时一定要用eval(),否则同一条样本两次攻击结果都不一样。
FGSM当基线还有一个好处:它给PGD提供一个下界参照。如果PGD的攻击成功率只比FGSM高两三个点,大概率是PGD的步长或迭代次数没配对,而不是攻击算法本身不行。
2.2 ε的取值区间:0.1、0.2、0.3跑出来的曲线完全不同
在Fashion_MNIST这种28×28的灰度图上,ε的选择比ImageNet上更敏感,因为图像本身信息密度低,扰动的相对占比大。我在同一份权重上按0.05的间隔扫了一遍,结果大致是这么一条曲线:
| ε | 干净测试精度 | FGSM攻击后精度 | 主观感受 |
|---|---|---|---|
| 0.00 | 91.8% | 91.8% | 基线 |
| 0.05 | 91.8% | 63.4% | 肉眼几乎看不出变化 |
| 0.10 | 91.8% | 38.7% | 部分样本开始肉眼可辨 |
| 0.15 | 91.8% | 24.1% | 明显噪点 |
| 0.20 | 91.8% | 15.6% | 大量像素贴边 |
| 0.25 | 91.8% | 12.3% | 预测接近随机 |
| 0.30 | 91.8% | 10.7% | 扰动已经盖过原图结构 |
这张表有一个容易忽略的点:从0.25到0.30,精度只再掉一个多点,说明模型已经“放弃抵抗”,继续加ε没有信息量。做对比实验时,我一般选0.1作为“温和攻击”的参照,选0.25作为“强攻击”的参照,中间0.15到0.2用来画曲线。很多论文只报一个ε,看的时候要留个心眼,因为不同ε下的排序可能反过来。
另一个细节是ε和像素夹取的相互作用。当ε超过0.2,大量像素的原始值本来就靠近0或1,加完扰动再夹取,实际生效的扰动会小于理论值。这不是代码写错,是Fashion_MNIST灰度图的分布决定的。评估时要意识到“名义ε”和“实际平均扰动”是两个量。
2.3 PGD的步长和迭代次数:不是步数越多越好
PGD本质是“多步FGSM加投影”,每一步走小一点,走完再投影回ε-ball里。它的攻击强度通常明显高于FGSM,但参数配错时反而不如FGSM,我见过最常见的两种配错:步长alpha设得和ε一样大,等于退化成单步;迭代次数设成3到5步,还没收敛就停了。
def pgd_attack(model, images, labels, epsilon, alpha, steps): """PGD: 迭代式梯度攻击 epsilon: 总扰动预算 alpha: 单步步长,常见取 epsilon/4 到 epsilon/10 steps: 迭代次数,常用7~40 """ original = images.clone().detach() perturbed = images.clone().detach() for _ in range(steps): perturbed.requires_grad_(True) outputs = model(perturbed) loss = F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() # 沿梯度符号走一小步 perturbed = perturbed + alpha * perturbed.grad.sign() # 投影:先限制相对原图的偏移在epsilon内,再夹到合法像素范围 delta = torch.clamp(perturbed - original, -epsilon, epsilon) perturbed = torch.clamp(original + delta, 0.0, 1.0).detach() return perturbed在Fashion_MNIST上,我一般把alpha设成epsilon/4,steps从10起步,再看攻击成功率是否还在涨。实测ε=0.1时,10步PGD能把准确率从FGSM的38.7%压到20%出头,20步再压到15%左右,40步之后收益很小。步数继续加大只会让训练时间线性上升,尤其是做对抗训练时每一步攻击都要回传,40步PGD的对抗训练一个epoch能跑十几分钟,得不偿失。
还有一个容易被忽视的点:PGD的随机起点。标准PGD会在ε-ball内随机初始化一个起点再迭代,这样多次攻击取最坏情况更稳。如果从原图起步(也叫BIM),攻击强度会略低,但结果更可复现。我个人的习惯是评估时用随机起点跑三到五次取最差,调试时用原图起步方便对比。
2.4 攻击成功率的三个统计口径,别被一个数字骗了
新手最容易犯的错是只看“攻击后准确率”这一个数,然后拿去和方法比。实际上至少要区分三个口径:全体测试集的准确率、仅统计原本预测正确的样本的攻击成功率、误分类样本的置信度下降幅度。
第一个口径最直观,但会被模型原本就错的样本稀释;第二个口径(也叫ASR,Attack Success Rate)更能反映攻击对“本来能做对的题”的破坏力;第三个口径是很多人忽略的,一个攻击可能没有把标签翻过来,但把正确类的置信度从0.95压到0.35,这种“软失败”在集成或蒸馏场景里同样致命。
我在对比FGSM和PGD时就吃过亏:只看全体准确率,两者差距看起来只有七八个点;换成“原正确样本的ASR”,PGD比FGSM高出二十多个点。原因很简单,FGSM对那些本来就模棱两可的样本一击就倒,但对高置信度正确样本几乎无效,PGD才能把这些“硬骨头”啃下来。所以后面无论做对抗训练还是写实验记录,我都固定报两个数:干净精度和强攻击下的鲁棒精度,中间过程再看ASR。
3. 对抗训练不是把攻击样本喂进去就完事:PGD-AT与FGSM-AT的取舍
3.1 三种对抗训练思路的差异与适用场景
对抗训练的目标很朴素:让模型在训练阶段就见过“最坏的输入”,从而在测试时对扰动更稳。落到实现上,常见的做法有三类,名字听着像玄学,实际差别就在“用什么样本训练、怎么混合”。
第一类是FGSM对抗训练(FGSM-AT),每一批数据用FGSM生成对抗样本,然后拿这些样本去更新权重。优点是快,一个epoch的开销比正常训练多不了多少;缺点是鲁棒性提升有限,而且容易出现“梯度掩蔽”——模型对FGSM稳了,换成PGD照样被打穿。我在Fashion_MNIST上试过,FGSM-AT训完,FGSM攻击下精度能回到70%多,但PGD ε=0.1一上,精度又掉到30%以下。
第二类是PGD对抗训练(PGD-AT),也就是Madry那套标准做法:每批数据先用PGD生成强对抗样本,再用这些样本训练。这是目前公认的“基线中的基线”,鲁棒性最扎实,代价是训练慢,PGD步数乘以正常训练时间。Fashion_MNIST上模型小,还能接受,换成大模型就要算账了。
第三类是混合训练,把干净样本和对抗样本按比例拼成一个batch,或者对损失做加权。这种做法在干净精度和鲁棒精度之间折中,适合那些“不能为了鲁棒把干净精度丢太多”的落地场景。我一般会用7:3或1:1的比例试两组,看业务更在意哪一头。
选哪种没有标准答案,但判断依据很清楚:如果只是做实验复现、想看对抗训练的天花板,直接上PGD-AT;如果算力紧张、或者上线时推理延迟敏感,先跑FGSM-AT看能救回多少,再决定要不要加码;如果干净精度是硬指标,混合训练比纯PGD-AT更现实。
3.2 PGD对抗训练的训练循环怎么改
对抗训练最容易出错的地方不在攻击函数,而在训练循环的组织。很多人把攻击样本生成写在with torch.no_grad()里,结果梯度断了,攻击退化成随机噪声;或者忘了在生成攻击样本前后切换模型状态,BN统计量被污染。下面是我常用的循环骨架:
def train_one_epoch_adv(model, train_loader, optimizer, device, epsilon=0.1, alpha=0.025, steps=7): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) # 关键1: 生成对抗样本时模型要处于eval,避免BN统计量被扰动污染 model.eval() adv_images = pgd_attack(model, images, labels, epsilon=epsilon, alpha=alpha, steps=steps) # 关键2: 用对抗样本正常训练,这里必须切回train model.train() outputs = model(adv_images) loss = F.cross_entropy(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(train_loader.dataset)这段循环里有两个容易翻车的切换:生成对抗样本时用eval()、训练时用train()。如果不切,带Dropout的模型在生成攻击样本时会随机丢弃一部分神经元,攻击方向不稳定,训练出来的鲁棒性会打折扣;带BatchNorm的模型更糟,扰动样本会污染running mean和running var,评估时干净精度莫名掉好几个点。
还有一个实操细节:对抗训练时学习率一般要比正常训练低一些,我通常用正常学习率的1/2到1/4,配合余弦退火。原因是每批样本的“难度”波动大,学习率太高容易在鲁棒性上震荡。这个不是理论要求,是调出来的血泪经验,换数据集要重新试。
3.3 干净精度和鲁棒精度的拉锯:用数据说话
对抗训练最反直觉的一点是:鲁棒性上去了,干净精度通常会掉。掉多少取决于攻击强度、训练轮数和模型容量。我在Fashion_MNIST上用一个三层卷积跑了几组对照,大致是这么个关系:
| 训练方式 | 干净精度 | FGSM ε=0.1 | PGD ε=0.1 (20步) | 单epoch耗时 |
|---|---|---|---|---|
| 正常训练 | 91.8% | 38.7% | 15.2% | 1x |
| FGSM-AT | 89.4% | 72.1% | 28.6% | 1.3x |
| PGD-AT (7步) | 87.6% | 78.3% | 62.4% | 4.5x |
| PGD-AT (20步) | 86.9% | 79.1% | 65.8% | 11x |
从这张表能读出几件事。第一,FGSM-AT的性价比在“只防FGSM”这个目标下很高,但面对PGD就露馅,印证了前面的梯度掩蔽。第二,PGD-AT从7步加到20步,鲁棒精度只多3个多点,训练时间翻了一倍多,这就是典型的边际收益递减,实际项目里7到10步通常是甜点。第三,干净精度从91.8%掉到86.9%,这是对抗训练绕不开的代价,任何声称“既不掉干净精度又大幅提升鲁棒性”的方法,都要多问一句是不是在特定ε或特定攻击下测的。
还有一个经常被忽略的对照组:在干净样本上多训几个epoch,干净精度也能往上顶一两个点,但鲁棒精度几乎不动。这说明鲁棒性不是“多训”能训出来的,必须让模型见到对抗样本。反过来说,如果对抗训练后干净精度掉得特别狠(比如掉十个点以上),先别怀疑方法,检查一下对抗样本的ε是不是设得过大、混合比例是不是失衡。
4. 避坑与排查:我在Fashion_MNIST对抗实验里踩过的五个坑
4.1 数据与模型状态相关的两个坑
坑一:Normalize之后又去加扰动,攻击结果虚高。
- 现象:FGSM ε=0.1就把准确率打到10%以下,比论文里的曲线陡得多,而且对抗样本可视化出来一片花。
- 原因:数据在transform里做了
Normalize(mean, std),像素范围变成了大约[-1,1]甚至更宽,攻击时直接在这个张量上加ε再clamp(0,1),等于把大量负值像素强行拉到0,实际扰动远超ε。 - 解决:要么在攻击前把张量反归一化回
[0,1],要么把攻击的夹取范围改成归一化后的合法区间。我在资源里统一在数据集层面保留一份[0,1]的副本专门给攻击用,避免两边口径打架。
坑二:攻击时模型还在train模式,同一条样本两次结果不一样。
- 现象:同一批测试样本连续跑两次FGSM,准确率能差三四个点,PGD更夸张。
- 原因:网络里有Dropout或BatchNorm,
model.train()状态下前向传播本身带随机性,梯度方向每次都在变。 - 解决:生成对抗样本和评估鲁棒精度时统一
model.eval(),并且用torch.no_grad()包住评估部分。训练循环里生成攻击样本那一步也要先eval()再切回train()。
4.2 攻击与训练循环相关的三个坑
坑三:PGD步长设成和ε一样,攻击强度反而不如FGSM。
- 现象:PGD跑完,攻击后精度比FGSM还高,一度以为代码写反了。
- 原因:
alpha=epsilon时,第一步就走满了扰动预算,后续投影把每一步都拉回边界,迭代退化成在同一点反复横跳。 - 解决:
alpha取epsilon/4到epsilon/10,步数取7到20。改完再跑,PGD稳定比FGSM低十几到二十个点。判断标准很简单:PGD的攻击后精度不应该高于同ε下的FGSM,如果高了,先查步长。
坑四:对抗训练把攻击样本的梯度也算进了优化器,显存直接爆。
- 现象:正常训练能跑batch size 256,加对抗训练后128都OOM。
- 原因:PGD每一步都要对输入求梯度,计算图没有及时释放,多步累积下来显存占用是正常训练的数倍。
- 解决:生成对抗样本的整个循环包在
torch.no_grad()外层、内部单独对输入requires_grad_,每步之后detach();生成完再clone().detach()传给训练分支。另外把PGD步数从20降到7,显存和耗时都能下来一截。
坑五:评估ε和训练ε混着报,结论自相矛盾。
- 现象:对抗训练时用ε=0.1,评估时随手写了0.3,结果鲁棒精度比正常训练还低,一度怀疑对抗训练没用。
- 原因:训练和评估的攻击预算不一致,模型只对ε=0.1附近的扰动做了适配,评估拉到0.3等于换了攻击强度,数字没有可比性。
- 解决:固定一套评估协议,训练ε、评估ε、PGD步数、是否随机起点全部写进配置,跑对比实验时只改一个变量。我现在的习惯是训练用ε=0.1、7步PGD,评估同时报ε=0.1和ε=0.25两组,前者看训练目标达没达到,后者看泛化到更强攻击时的衰减。
5. 一次跑通复现:参数表、评估顺序和一个反复验证的习惯
5.1 参数组合与结果对照表
把前面几章的结论收成一张可以直接抄的配置表,对应资源里攻击和对抗训练两部分脚本。这张表不是唯一正确解,但在我跑过的几组里是最稳的起点:
| 环节 | 参数 | 取值 | 说明 |
|---|---|---|---|
| 数据 | 输入范围 | [0,1] | 攻击专用副本,不经过Normalize |
| 模型 | 状态 | eval | 攻击与评估阶段强制 |
| FGSM | ε | 0.1 / 0.25 | 温和与强攻击两档 |
| PGD | ε / alpha / steps | 0.1 / 0.025 / 20 | alpha=ε/4,评估用 |
| PGD-AT | ε / alpha / steps | 0.1 / 0.025 / 7 | 训练用,兼顾速度 |
| 对抗训练 | 学习率 | 正常值的1/2 | 配合余弦退火 |
| 评估 | 口径 | 干净精度 + 鲁棒精度 + ASR | 三个数一起报 |
5.2 评估顺序:先固定攻击,再谈训练
复现时最容易乱的地方是“边训边调攻击参数”,最后说不清提升来自哪。我现在的顺序是固定的:先正常训练一个基线模型,把FGSM和PGD在ε=0.1、0.25下的曲线跑出来存好;然后冻结攻击配置,只改训练方式,依次跑FGSM-AT、PGD-AT 7步、PGD-AT 20步;最后在统一评估协议下把四组结果并排。这样任何一个数字异常,都能定位到是训练侧还是评估侧的问题。
回过头看,这套实验里最值钱的不是某个具体参数,而是一个习惯:任何一次攻击结果出来,先问一句“模型是不是eval、像素是不是在[0,1]、ε和评估ε是不是同一个”。这三个问题能挡掉我八成的翻车。资源里的Attack_Fashion_MNIST压缩包按攻击和训练两部分组织,脚本里的默认参数就是上面这张表,拿过去把ε和steps对一遍,能省掉不少重复调试的时间。希望帮到你。
本文还有配套的精品资源,点击获取