ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Fashion-MNIST对抗攻击与对抗训练:FGSM/PGD复现避坑

Fashion-MNIST对抗攻击与对抗训练:FGSM/PGD复现避坑

简介:一份基于Fashion-MNIST数据集的图像分类对抗攻防实践项目,面向深度学习安全方向的学习者与研究者,围绕对抗样本生成、鲁棒性评估与对抗训练防御三条主线展开。压缩包共121个文件、约15.12MB,其中9个py脚本负责模型搭建、FGSM与PGD攻击实现及对抗训练流程,2个h5文件保存训练好的模型权重,8个pkl文件存放数据与中间结果,100张jpg直接展示对抗样本与原图的对比效果,另外还有md说明,方便快速上手。目前已有117人学习。读者可以复现从常规训练、生成对抗样本到对抗训练的完整实验,观察细微扰动如何改变分类结果,并对比对抗训练前后准确率与泛化能力的变化,掌握鲁棒性评估的基本方法;也适合作为课程设计或安全研究的基础代码。

1. 一个 92% 的模型,被一步扰动打到 31%

一个在 Fashion-MNIST 上能跑到 92% 的小 CNN,用 FGSM 取 eps=0.03 走一步,准确率直接掉到 31% 左右;这个扰动换算回像素只有 8 个灰度级上下,肉眼几乎看不出差别。我最早做对抗攻击时不信这个数,把 eps 一路加到 0.1 才看到明显掉点,后来查出来是归一化尺度算错,扰动的量级被自己调小了将近三倍——这是对抗攻击里最典型的一次翻车。这份 Attack_Fashion_MNIST 资源做的正是同一件事:在 28×28 单通道灰度图上生成对抗样本,再把样本喂回训练循环做对抗训练。下面按攻击面拆解、复现跑通、对抗训练落地、避坑排查四段走,新手能照着跑出结果,熟手能看到每个参数为什么这么定。

2. 攻击面拆解:FGSM、PGD、CW 在 28×28 灰度图上的真实差别

Fashion-MNIST 的攻击性价比和 ImageNet 完全不是一回事。同样是 eps=0.03 的 L∞ 预算,在 28×28 的图上,这个预算相对整幅图像的比例,比 224×224 的图要高出一个数量级;换句话说,小图上"看不见的扰动"能撬动的信息更多,模型也更脆。这就是为什么很多人拿 CIFAR-10 上那套 eps=8/255 的经验直接搬到 Fashion-MNIST 上,会发现模型掉得太快、对抗训练又救不太回来。

先把三种攻击的定位讲清楚,再谈参数。

2.1 威胁模型先定死:L∞ 预算、eps 换算与白盒假设

动手写代码前,有三个量必须先写死在配置里,否则后面所有数字都不可比。

第一个是范数。Fashion-MNIST 上默认用 L∞,因为它的物理含义最直观——每个像素最多改多少个灰度级;L2 更适合看"最小改动量",但评估鲁棒性时会掩盖单点的大幅修改。

第二个是预算 eps 的坐标空间。这是个必须显式约定的东西:扰动到底加在 [0,1] 的像素空间,还是加在减均值除标准差之后的归一化空间。这两种写法的数值差一个 std 的倍数(Fashion-MNIST 常用 mean=0.2860、std=0.3530),差了三倍多。我一般把扰动定义在像素空间,归一化只发生在喂进网络前的那一步,理由是扰动可解释、可视化时不用反变换、clip 到 [0,1] 也自然。

第三个是攻击者能力假设。评估鲁棒性上界用白盒——攻击者知道结构和全部参数;测"这种攻击思路换到别的模型上还有没有效"用迁移攻击——拿 A 模型生成样本打 B 模型。Fashion-MNIST 上的实验绝大多数是白盒,因为目的是给对抗训练定一个可复现的靶子,不是模拟真实对手。

配置项建议取值说明
范数L∞小图上 L2 会掩盖单点大改
扰动空间像素 [0,1]归一化放在模型前向内部
主评估预算eps=0.03约合 8/255,图上基本无感
预算扫描0.01 / 0.02 / 0.03 / 0.05画成功率曲线的常用档位
评估集测试集 10000 张不要用训练集,会虚高

提示:eps 一旦定在像素空间,所有脚本、所有对比实验都必须沿用同一约定,中途换一次就等于换了一套实验。

2.2 FGSM:一步到位的下界,也是最快的体检

FGSM 的思路就一句话:沿着能让损失变大的方向,一个步长走到预算边界。它用的是损失对输入的梯度,取符号,只保留方向不保留大小,所以幅度天然由 eps 控制。

import torch import torch.nn.functional as F MEAN, STD = 0.2860, 0.3530 # Fashion-MNIST 训练集统计值 def fgsm_attack(model, x, y, eps): """单步 FGSM。x 是 [0,1] 像素空间的输入,扰动也加在像素空间。""" model.eval() # 关掉 BN 统计更新和 dropout x_adv = x.clone().detach().requires_grad_(True) # 梯度要对输入求,不是对权重 logits = model((x_adv - MEAN) / STD) # 归一化只在这里发生 loss = F.cross_entropy(logits, y) grad = torch.autograd.grad(loss, x_adv)[0] # 拿到 dLoss/dInput x_adv = x_adv.detach() + eps * grad.sign() # 梯度上升,最大化 loss return torch.clamp(x_adv, 0.0, 1.0).detach() # 投影回合法像素范围

几个容易写错的点:requires_grad_(True)必须加在输入上而不是模型参数上;grad.sign()取完符号后扰动是均匀的,这正是 L∞ 约束下的最优一步;最后那次clamp不是可选项,越界的像素在可视化时会变成纯黑纯白,看上去像模型崩了,实际是自己没剪裁。

FGSM 的价值在于快——一次前向加一次反向就能评估整个测试集,几十秒出结果。它的局限也很明确:对 loss 做了线性近似,eps 一大就失真;遇到做了梯度掩蔽(比如输入做量化、加随机变换)的模型,FGSM 会给出虚高的鲁棒准确率。所以我一般拿它当第一道体检,不当最终结论。

2.3 PGD:迭代步数、步长与随机起点的取舍

PGD 是 FGSM 的迭代版:走很多小步,每步都投影回 eps 球内。它更强,也更贵,参数从两个变成四个。

def pgd_attack(model, x, y, eps, alpha, steps, restarts=1): """L∞ PGD,带随机起点。alpha 是单步步长,steps 是迭代次数。""" model.eval() best_adv, best_loss = x.clone().detach(), torch.full((x.size(0),), -1.0, device=x.device) for _ in range(restarts): # 随机起点:在 eps 球内均匀采样,比从原点出发更容易跳出局部极大 x_adv = torch.clamp(x + torch.empty_like(x).uniform_(-eps, eps), 0.0, 1.0).detach() for _ in range(steps): x_adv.requires_grad_(True) loss = F.cross_entropy(model((x_adv - MEAN) / STD), y) grad = torch.autograd.grad(loss, x_adv)[0] x_adv = x_adv.detach() + alpha * grad.sign() delta = torch.clamp(x_adv - x, -eps, eps) # 第一步投影:回到 eps 球 x_adv = torch.clamp(x + delta, 0.0, 1.0).detach() # 第二步投影:回到合法像素 # 多次重启时,按 loss 保留每组样本攻击最狠的那一版 cur_loss = F.cross_entropy(model((x_adv - MEAN) / STD), y, reduction="none").detach() mask = cur_loss > best_loss best_adv[mask], best_loss[mask] = x_adv[mask], cur_loss[mask] return best_adv

参数字典里最值得琢磨的是 alpha 和 steps 的配比。常见写法是 alpha = eps/4、steps = 10,这样总行程是 eps 的 2.5 倍,靠每步的投影把越界部分拉回来——这是有意为之,多走一点比走得不够更容易逼近真正的最大值。步长再小(比如 eps/10)收敛更稳但更慢,在 28×28 这种小图上收益不明显;步长再大(eps/2)会出现震荡,攻击成功率反而掉。

随机起点这一项经常被省掉。省掉之后 PGD 变成确定性攻击,遇到对抗训练过的模型,成功率会低估好几个点,因为固定的起点容易卡在同一个局部极大上。评估对抗训练效果时我一般用 restarts=1 保底、restarts=5 做最终验收。

关于 steps:在 Fashion-MNIST 上,PGD-10 和 PGD-20 的成功率通常只差一两个百分点,但耗时差一倍。做对抗训练的内层攻击用 PGD-3 到 PGD-7 就够,做最终评估用 PGD-20 起步。

2.4 什么时候才轮到 CW 和迁移攻击

CW 走的是另一条路:不追求"在预算内最大化 loss",而是优化一个带置信度 margin 的目标,去找能让模型翻类的最小扰动。它慢,一次优化要几百步,但在 Fashion-MNIST 上有个独特用途——看清楚"改多少个像素就能让模型改判"。我做过一次可视化,某些类之间(比如 Shirt 和 T-shirt)只改动十几个像素、集中在领口区域,模型就翻了;这种观察对理解模型到底学到了什么,比对着一张成功率表看数字有用得多。

迁移攻击的逻辑不一样:拿 A 模型生成的对抗样本去打 B 模型,衡量的是扰动在不同模型间的可迁移性。它的成功率天然比白盒低,但能说明一件事——模型的鲁棒性是不是只对特定梯度方向有效。如果白盒成功率 95%、迁移成功率 60%,说明对抗训练学到的东西有一定共性;如果迁移成功率只有 10%,多半是模型在做梯度掩蔽,而不是真的鲁棒。

攻击需要的梯度单张耗时主要用途未防御模型掉点
FGSM一次反向极低快速体检、训练中内层攻击92% → 30% 量级
PGD-10十次反向中鲁棒性评估主力92% → 5% 量级
PGD-20 × 5 重启一百次反向高最终验收92% → 2% 量级
CW-L2数百步优化很高最小扰动分析、可视化取决于目标 margin
迁移源模型梯度中检测梯度掩蔽因模型而异

3. 复现 Attack_Fashion_MNIST.zip:从目录到第一组对抗样本

这类工程解压后基本是四块:数据加载、模型定义与训练、攻击脚本、对抗训练脚本。第一次跑不要一上来就执行对抗训练,那个最慢也最容易因为前置没对齐而白跑。按"基线 → 攻击 → 对抗训练"的顺序来,每一步都能独立验证。

3.1 目录与运行顺序:先跑基线,再跑攻击,最后跑对抗训练

先把工程按职责对一遍:数据部分负责下载 Fashion-MNIST 和做归一化,模型部分定义一个小型卷积网络(两三层卷积加池化,参数量几十万,单卡几分钟一个 epoch),攻击部分是一组独立的函数,对抗训练部分是训练循环里调用攻击函数。

运行顺序上,我一般这样做:先训练或加载一个基线模型并把权重固定下来(存成固定文件,后面所有实验都用这一份),再拿它跑一遍干净测试集确认准确率在正常区间,然后跑 FGSM 和 PGD 各一次看掉点幅度,最后才开对抗训练。这么排的原因是,攻击脚本的正确性可以用基线模型的掉点幅度快速判断——如果基线模型在 eps=0.03 的 FGSM 下只掉了两三个点,八成不是模型太鲁棒,是攻击写错了。

基线模型的期望值给个参考:一个正常训练的小 CNN 在 Fashion-MNIST 上干净准确率 91%~93%,FGSM(0.03) 掉到 25%~35%,PGD-20(0.03) 掉到 3%~8%。偏离这个区间太多,先怀疑数据管线和归一化,别急着调模型。

3.2 归一化对齐:eps 到底该乘还是该除

这是最容易出问题的地方,单独拎出来说。扰动定义在像素空间 [0,1],而模型吃的是(x - 0.2860) / 0.3530。如果把 eps 直接加在归一化后的张量上,等效的像素扰动就是 0.03 × 0.3530 ≈ 0.0106,只有预期值的三分之一——攻击看起来"太弱",其实是自己把预算缩小了。

MEAN, STD = 0.2860, 0.3530 def eps_norm_to_pixel(eps_norm, std=STD): """如果别人的脚本里 eps 是加在归一化空间的,用它换回像素空间再比较。""" return eps_norm * std def eps_pixel_to_norm(eps_pixel, std=STD): """反过来:像素空间的 eps 折算到归一化空间是多少。""" return eps_pixel / std # 我推荐的写法:扰动只在像素空间操作,归一化写在模型前向内部 class NormalizedModel(torch.nn.Module): def __init__(self, backbone, mean=MEAN, std=STD): super().__init__() self.backbone, self.mean, self.std = backbone, mean, std def forward(self, x): return self.backbone((x - self.mean) / self.std)

把归一化包进模型里,好处是攻击函数和训练循环都只跟 [0,1] 的像素打交道,eps 的语义永远一致,读写别人的脚本时对照一次eps_norm_to_pixel就能判断对方的预算到底是多大。代价是state_dict的键名会多一层前缀,加载权重时要对应处理。

注意:Fashion-MNIST 的 mean/std 是数据集统计值,不是 ImageNet 那套 0.485/0.456/0.406。抄错这一组数,攻击会莫名其妙地失效。

3.3 基线模型的训练与权重固定

基线模型不需要多复杂,两层卷积加一个全连接足够,参数量大了反而拖慢对抗训练的内层迭代。训练配置上,Adam 或 SGD 都能到 92%,关键是固定下来:固定随机种子、固定 epoch 数、把权重存成单独文件。

def set_seed(seed=42): import random, numpy as np random.seed(seed); np.random.seed(seed) torch.manual_seed(seed); torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True # 复现优先,牺牲一点速度 torch.backends.cudnn.benchmark = False def evaluate(model, loader, device): model.eval() correct = total = 0 with torch.no_grad(): # 评估必须关梯度,否则显存会涨 for x, y in loader: x, y = x.to(device), y.to(device) correct += (model(x).argmax(1) == y).sum().item() total += y.numel() return correct / total

cudnn.deterministic打开后训练会慢一点,但换来的是每次跑出来的数字一致,做消融实验时这点很重要——不然你分不清掉点是模型变了还是随机性造成的。

3.4 攻击脚本的四个必查参数

拿到一份攻击脚本,我一般先查四件事,顺序固定。

第一查 eps 的坐标空间,看扰动是加在像素上还是归一化后,看有没有这次 clamp 到 [0,1]。第二查梯度是对谁求的,requires_grad_加在输入上还是模型参数上,加错地方梯度就是零,攻击不报错但完全不生效。第三查模式,攻击时有没有model.eval(),忘了切 eval 会让 BatchNorm 用当前 batch 的统计量,同一个样本单独攻击和批量攻击结果不一样。第四查随机性,DataLoader有没有设shuffle=False、worker 数量固定没有,评估集的顺序变了,攻击成功率会有零点几个百分点的抖动,做对比实验时容易误判。

这四项查完,才轮到看攻击强度对不对。

4. 对抗训练落地:把 PGD 塞进训练循环的代价与配比

对抗训练的核心思想很朴素:训练时别只喂干净样本,把攻击产生的对抗样本也喂进去,让模型在学习过程中见到这些输入。麻烦的地方在于代价——每步训练要多跑几次前向反向;以及配比——纯对抗训练会把干净准确率拖下去多少。

4.1 三种对抗训练配方与它们各自的账

我试过的配方归成三类,各有各的账。

纯 PGD 对抗训练:每个 batch 先用 PGD-k 生成对抗样本,然后只用对抗样本更新参数。鲁棒准确率最高,干净准确率掉得也最多,通常是 2~4 个点。计算开销大概是普通训练的 k+1 倍。

单步快速对抗训练:用 FGSM 生成对抗样本,开销接近普通训练,训练速度快得多,但鲁棒准确率明显偏低,而且 eps 一大就容易出现灾难性过拟合。适合做快速迭代和超参搜索,不适合交最终结果。

混合对抗训练:一个 batch 里一半干净样本、一半对抗样本。干净准确率损失小,鲁棒准确率介于前两者之间,是我在 Fashion-MNIST 上最常用的折中。

配方干净准确率FGSM(0.03)PGD-20(0.03)训练开销
基线 CNN92.4%31.7%4.6%1×
FGSM 对抗训练90.8%76.2%42.5%约 1.4×
混合(PGD-3 + 干净)90.2%84.9%74.8%约 2.6×
纯 PGD-7 对抗训练89.1%82.6%78.3%约 3.8×

表里最值得注意的是 FGSM 对抗训练那一行:FGSM 攻击下 76.2% 看着还行,但换成 PGD-20 就只有 42.5% 了。这说明它对单步攻击过拟合了,鲁棒性是"假的"。这种模型如果只拿 FGSM 评估,会得出完全错误的结论——所以评估必须用比训练更强的攻击。

4.2 训练循环:内层攻击、混合比例与 BN 处理

下面是混合对抗训练的主循环,几个关键决策都写在注释里。

def adversarial_train(model, train_loader, device, epochs=30, eps=0.03, alpha=0.0075, steps=3, adv_ratio=0.5): opt = torch.optim.SGD(model.parameters(), lr=0.05, momentum=0.9, weight_decay=5e-4) sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs) for ep in range(epochs): model.train() for x, y in train_loader: x, y = x.to(device), y.to(device) n_adv = int(x.size(0) * adv_ratio) # 一个 batch 里取一半做对抗样本 xa, ya = x[:n_adv], y[:n_adv] model.eval() # 生成对抗样本时临时切 eval,避免 BN 统计被污染 xa_adv = pgd_attack(model, xa, ya, eps, alpha, steps) model.train() # 立刻切回来,参数更新要用当前 batch 的统计量 x_mix = torch.cat([x, xa_adv], dim=0) # 干净 + 对抗拼成一个 batch y_mix = torch.cat([y, ya], dim=0) loss = F.cross_entropy(model(x_mix), y_mix) opt.zero_grad(); loss.backward(); opt.step() sched.step()

生成的对抗样本如果是用model.train()模式跑的,BatchNorm 会用对抗 batch 的均值方差去更新 running stats,而这些统计量的分布和干净数据不一样,跑几十个 epoch 之后模型的 BN 参数会整体偏移——表现是训练集准确率还行,测试集莫名其妙掉几个点。切成eval()生成、再切回train()更新,是个很便宜的修法。

另一个参数是adv_ratio。0.5 是个稳妥的起点;数据分布偏简单时可以往下调到 0.3,干净准确率更保得住;想冲鲁棒准确率就调到 1.0,代价是干净准确率和训练时间。

学习率也要单独说一句:对抗训练对 lr 比普通训练敏感,用基线模型那套 lr 常常训到一半就开始震荡。我一般把初始 lr 降一档,加 5 个 epoch 的 warmup,再走余弦退火。

4.3 结果对比:干净准确率换鲁棒准确率,汇率是多少

上表那组数字可以换个角度看:从混合训练换到纯 PGD-7,鲁棒准确率涨了 3.5 个点,干净准确率掉了 1.1 个点 —— 汇率大概是 3:1。这个比例在 Fashion-MNIST 上比较稳定,可以作为判断"还要不要继续加码对抗训练"的依据:如果某次改动让干净准确率掉了 5 个点、鲁棒准确率只涨 1 个点,那基本是训练配置出了问题,不是对抗训练本身的极限。

还有一项经常被忽略:鲁棒准确率要和攻击强度一起看。同一份权重,PGD-20 下 78%、PGD-50 下 71%,说明它还没到"真鲁棒",只是把决策边界推远了。评估时把攻击步数标在数字旁边,比单独一个百分数有意义得多。

4.4 灾难性过拟合与早停:什么时候该怀疑训练崩了

单步对抗训练在 eps 稍大时会出现一种很怪的现象:训练过程中鲁棒准确率突然从 70% 掉到 20% 出头,而且训练 loss 依然很低,看起来"学得很好"。这就是灾难性过拟合——模型找到了一个对当前 FGSM 攻击有效的退化解,对任何迭代攻击都完全失效。

排查办法很简单:训练过程中每隔几个 epoch 存一次权重,同时用 PGD-20 评估,把曲线画出来。如果看到干净准确率平平稳稳、PGD 准确率断崖下跌,就是踩上了。修法有几种:换成迭代攻击训练、降低 eps、加梯度惩罚、或者干脆用 EMA 权重做评估。最省事的是按鲁棒验证集的准确率早停,别按训练 loss 早停。

5. 避坑与排查:五个让我返工的细节

这一节全是踩过的坑,每条按"现象 → 原因 → 解决"写,能对号入座就不用重新走一遍。

5.1 扰动看不见但攻击几乎无效

现象:eps=0.03 的 FGSM 跑完,模型准确率只从 92% 掉到 88%,可视化看扰动确实加上了。

原因:扰动加在了归一化空间。像素空间里实际只有 0.03×0.3530≈0.0106 的改动,预算被自己砍到三分之一。

解决:把归一化包进模型前向,或显式做一次eps_pixel_to_norm换算。改完重跑,掉点应该落在 25%~35% 这个区间,对不上就继续查梯度是不是对输入求的。

5.2 同一个样本单张攻击和批量攻击结果不一样

现象:把一张图单独拿出来跑 PGD,成功翻类;放进 batch 里跑,同一张图没翻。

原因:攻击时没有model.eval(),BatchNorm 用的是当前 batch 的均值方差,batch 组成一变,前向结果就变了。dropout 没关也是一个来源。

解决:攻击函数入口强制model.eval(),并在函数内部不要恢复训练模式,让调用方自己决定什么时候切回来。评估和攻击分开写,别共用同一段状态管理。

5.3 评估数字每次抖动一两个点

现象:代码没改,重跑一次 PGD-20 的鲁棒准确率差 1.2 个点,消融实验完全没法比。

原因:两处随机性。一是DataLoader的 shuffle 和多 worker,评估集顺序每轮不一样;二是 PGD 的随机起点没固定种子。

解决:评估时shuffle=False、num_workers固定且worker_init_fn里设种子;PGD 随机起点用局部torch.Generator固定。做对比实验时把这两条都写进配置,别靠记忆。

5.4 对抗训练后干净准确率掉太多

现象:纯 PGD 训练训了 30 个 epoch,鲁棒准确率 78%,干净准确率从 92% 掉到 85%,比预期多掉了一倍。

原因:多数情况是学习率没降、没有 warmup,前半程模型在干净数据上根本还没收敛就被对抗样本推着走,最后停在一个偏的位置。另一种是 BN 统计被污染。

解决:降初始 lr、加 5 个 epoch warmup、生成对抗样本时切 eval。三处一起改,干净准确率一般能回到 89% 上下。

5.5 攻击成功率虚高,换个攻击就露馅

现象:FGSM 对抗训练后 FGSM 攻击下 76%,自己看着挺满意,换成 PGD-20 只剩 42%。

原因:拿训练时用的同一种攻击做评估,等于自己批自己的卷子。单步对抗训练特别容易对这种攻击过拟合。

解决:评估一律用比训练更强的攻击,训练用 PGD-3,评估就用 PGD-20 带多次重启;再加一列迁移攻击的结果,看鲁棒性有没有共性。三列数字放在一起看,虚高的部分会立刻暴露。

6. 验收:一张扰动放大图和一条成功率曲线

对抗攻击这个方向最容易犯的错是只看百分数,不看图。我最后一道验收固定做两件事:把对抗样本和放大十倍的扰动并排画出来,确认扰动确实集中在有语义的区域;再扫一遍 eps 画成功率曲线,看曲线是不是单调、拐点在哪。

import matplotlib.pyplot as plt def viz(model, x, y, eps=0.03, n=5): x_adv = pgd_attack(model, x[:n], y[:n], eps=eps, alpha=eps/4, steps=20) pred = model(x_adv).argmax(1) fig, axes = plt.subplots(3, n, figsize=(2.2*n, 7)) for i in range(n): axes[0, i].imshow(x[i, 0].cpu(), cmap="gray"); axes[0, i].set_title(f"y={y[i].item()}") axes[1, i].imshow(x_adv[i, 0].cpu(), cmap="gray"); axes[1, i].set_title(f"pred={pred[i].item()}") # 扰动放大 10 倍再偏移 0.5,否则一片灰什么都看不出来 axes[2, i].imshow((0.5 + 10 * (x_adv[i, 0] - x[i, 0])).cpu(), cmap="gray", vmin=0, vmax=1) for r in range(3): axes[r, i].axis("off") plt.tight_layout(); plt.savefig("adv_grid.png", dpi=150)

三段分别对应干净图、对抗图、放大后的扰动。扰动图里如果是一整片均匀噪点,说明攻击在乱改;如果集中在衣领、鞋底、包带这些轮廓位置,说明梯度指对了地方。这个观察比准确率数字更能说明模型学到了什么。

eps 扫描就简单了,0.01 到 0.1 取五档,每档跑 FGSM 和 PGD-20 各一次,画两条线。表现正常的模型和鲁棒模型在图上很好区分:正常模型的曲线在 eps=0.01 就已经掉到 70% 以下,鲁棒模型的曲线更平,到 0.05 才开始明显下滑。如果某条曲线在某个 eps 上突然反弹,多半是评估集或者随机种子出了问题。

扫描档位基线 PGD-20混合对抗训练 PGD-20
eps=0.0142.1%88.6%
eps=0.0213.4%82.3%
eps=0.034.6%74.8%
eps=0.050.8%58.2%
eps=0.100.1%21.7%

这张表最有价值的信息在最后两行:鲁棒模型在 eps=0.03 以内的表现很稳,超出训练预算后一样会崩。所以对抗训练给的是"预算内的鲁棒",不是"抗一切扰动",写结论时这一点必须说清楚。

回过头看,我从一开始把 eps 尺度算错、到后来每次实验都先跑一张扰动放大图,中间返工了三四次。从那以后我每次开新的对抗训练实验,都强制先走一遍流程:先确认 eps 在哪个空间、跑一次 FGSM 看掉点是否落在预期区间、画一张扰动图确认梯度没指错、再用 PGD-20 出最终数字,这四步少一步都不敢往下写结论。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进