ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从FGSM到PGD:Fashion-MNIST对抗攻击与对抗训练实战

从FGSM到PGD:Fashion-MNIST对抗攻击与对抗训练实战 简介面向深度学习安全入门与对抗攻防复现需求这套材料围绕Fashion_MNIST图像分类任务提供从对抗样本生成到对抗训练防御的完整实验内容适合已掌握基础神经网络、希望理解模型鲁棒性评估的学生与算法爱好者也可用于课程设计或安全方向的前期验证。压缩包共121个文件约15.12MB其中9个Python脚本承载模型训练、攻击实现与评估流程8个pkl与2个h5文件保存训练中间结果及模型权重100张jpg直观呈现对抗样本与原始图像的对照另附md说明与gitignore便于环境整理。已有117人学习下载。读者可据此复现FGSM、PGD等攻击方法观察细微扰动对分类结果的影响并对比对抗训练前后模型在干净样本与对抗样本上的准确率变化进而理解鲁棒性、泛化能力与安全部署之间的权衡形成从攻击生成、防御训练到指标评估的可运行闭环。1. 一个 93% 掉到 12% 的分类器对抗攻击与对抗训练解决的是什么问题一个做课程设计的同学给我看他的模型Fashion-MNIST 测试集 93.1%我让他把输入叠一层 eps0.1 的扰动再喂进去准确率掉到 11%比十类瞎猜还低。这就是对抗攻击最反直觉的地方——扰动小到人眼看不出模型却彻底失明。这份 Attack_Fashion_MNIST 代码包做的事就是把整条线完整展开先干净训练一个卷积基线再用 FGSM、I-FGSM、PGD 三种方式把它打穿然后把对抗样本塞回训练循环做对抗训练最后拿干净准确率和鲁棒准确率两条曲线对照。它适合两类人刚接触对抗样本、想要一个能完整跑通的最小闭环的以及要拿它当实验底座换成自己的数据集或攻击算法的。2. 目录结构与数据管线Fashion-MNIST 怎么读、怎么切、怎么喂进去拿到压缩包先别急着执行 train把目录看一遍能省掉后面一半的调试时间。这个项目的模块边界切得比较克制没有一上来就上 Lightning 或者 Hydra纯 PyTorch 加一个 yaml好处是可以把每一行发生的事都看清楚。2.1 从目录树看模块职责的切分Attack_Fashion_MNIST/ ├── configs/base.yaml # eps / alpha / steps / lr 等全部外置 ├── data/ # 首次运行自动下载的 idx 文件落在这里 ├── models/ │ └── small_cnn.py # 4 层卷积 BN 全连接的分类器 ├── attacks/ │ ├── fgsm.py # 单步攻击 │ ├── ifgsm.py # 迭代攻击固定步长 │ └── pgd.py # 迭代攻击 随机起点 投影 ├── train_clean.py # 干净基线训练 ├── train_adv.py # 对抗训练 ├── evaluate.py # eps 扫描输出 csv 和曲线 ├── visualize.py # 对抗样本网格图 扰动放大图 ├── utils.py # 种子、ckpt 保存、指标统计 └── requirements.txt关键点是attacks/单独成包。攻击函数既被evaluate.py调用又被train_adv.py在训练循环里调用如果写在评估脚本里训练脚本就得反向 import很容易写出循环依赖。把它们抽出来之后所有攻击必须满足同一个签名def attack_fn(model, x, y, eps, **kwargs) - torch.Tensor: 输入干净样本与标签返回对抗样本形状与 x 一致取值仍在 [0,1]签名的第 4 个位置参数留给 eps 是有意为之——evaluate.py要在一组 eps 上扫出鲁棒性曲线把 eps 当可变参数传扫描循环才写得干净。2.2 DataLoader这份代码没做 Normalize数据这块是第一个容易分歧的地方。绝大多数图像分类工程会在transforms里加Normalize(mean, std)但对抗攻击项目里我一般不加原因后面代码里会显出来。import torch from torch.utils.data import DataLoader, random_split from torchvision import datasets, transforms DATA_ROOT ./data # 只做 ToTensor像素被缩放到 [0, 1]不做 Normalize base_tf transforms.Compose([transforms.ToTensor()]) def build_loaders(batch_size128, val_ratio0.1, seed42, num_workers2): full_train datasets.FashionMNIST(DATA_ROOT, trainTrue, downloadTrue, transformbase_tf) test_set datasets.FashionMNIST(DATA_ROOT, trainFalse, downloadTrue, transformbase_tf) n_val int(len(full_train) * val_ratio) # 60000 * 0.1 6000 train_set, val_set random_split( full_train, [len(full_train) - n_val, n_val], generatortorch.Generator().manual_seed(seed), # 切分可复现 ) train_loader DataLoader(train_set, batch_sizebatch_size, shuffleTrue, num_workersnum_workers, pin_memoryTrue, drop_lastTrue) val_loader DataLoader(val_set, batch_size512, shuffleFalse, num_workersnum_workers) test_loader DataLoader(test_set, batch_size512, shuffleFalse, num_workersnum_workers) return train_loader, val_loader, test_loader参数上batch_size在干净训练阶段用 128 就够了对抗训练阶段同样 128但显存占用大约是干净训练的两到三倍因为攻击那几步还要保留计算图val_ratio0.1是给「选超参」留的位置测试集从头到尾只看一次否则 eps 和步数会在测试集上过拟合报出来的鲁棒准确率就是假的drop_lastTrue是为了让 BN 在训练时每个 batch 都有稳定的统计量最后不满 128 的那个 batch 直接丢掉。不做 Normalize 的理由很实在攻击函数要把扰动投影回「合法像素空间」如果输入已经被归一化到[-1, 1]附近那clamp(0, 1)就是个错误的约束投影会把样本拉到一个根本不存在的像素分布上攻击强度会被莫名削弱。常见做法有两种——要么把每张图的合法区间存下来在归一化空间里算 clamp 边界要么干脆不归一化让模型第一层的 BatchNorm 去吸收尺度差异。我选后者代码短出错概率低。2.3 种子与确定性别让两次实验对不上对抗实验的结果波动比普通分类大得多同一个 eps 跑两遍差三四个点很常见所以种子的设置必须比平时更狠import os, random import numpy as np import torch def set_seed(seed42, deterministicTrue): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ[PYTHONHASHSEED] str(seed) if deterministic: torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.benchmarkFalse会牺牲一点吞吐来换取卷积算法固定在对比两种训练策略时这点吞吐值得让出去。PYTHONHASHSEED主要是防 DataLoader 在多 worker 下的顺序抖动。另外 PGD 里的随机起点也是用全局种子驱动的如果只设了 torch 的种子没设 numpy 的np.random.uniform那条分支就会每轮都不一样实验日志根本对不齐。提示Windows 下num_workers 0必须把入口代码放进if __name__ __main__:否则 DataLoader 会反复重启进程表现是显存一点点涨上去然后卡死。3. 干净的分类基线SmallCNN 的结构和训练循环对抗实验有个前提干净样本上得先训出个像样的模型。基线太弱攻击再强也没意义基线太强又会把大部分时间耗在调参上。这个项目用的结构是折中方案在单卡上十几分钟能训完一轮。3.1 模型结构四层卷积加一个全连接头import torch import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 28 - 14 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 14 - 7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))结构没什么稀奇但每个选择都对应一个理由。输入是单通道 28×28所以第一层in_channels1换成 CIFAR-10 就是 3这是二次开发时第一个要动的地方。两次MaxPool2d把 28 降到 7全连接输入是64*7*73136整个模型参数量在五十万上下不算大正好适合在单张消费级卡上反复跑对抗训练。BatchNorm 放在每个卷积后面是必须的——没有它对抗训练阶段 loss 会抖得非常厉害几乎没法收敛。Dropout 只放在全连接头卷积部分不加加了会让攻击的梯度变得稀疏FGSM 打不动。3.2 训练循环里几个必须写对的位置import torch import torch.nn as nn from models.small_cnn import SmallCNN def train_clean(model, train_loader, val_loader, epochs15, lr1e-3, devicecuda): criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) best_acc 0.0 for epoch in range(epochs): model.train() for x, y in train_loader: x, y x.to(device, non_blockingTrue), y.to(device, non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) loss criterion(model(x), y) loss.backward() optimizer.step() scheduler.step() acc evaluate_acc(model, val_loader, device) # 验证集上只看干净准确率 if acc best_acc: best_acc acc torch.save(model.state_dict(), ckpt/clean_best.pt) return best_acc几个位置值得单独说。zero_grad(set_to_noneTrue)比默认的zero_grad()省一点显存也快一点在大规模对抗训练里能积少成多。调度器用余弦退火最后几个 epoch 学习率压得很低验证准确率能再稳零点几个点。evaluate_acc内部必须切model.eval()和torch.no_grad()这个函数后面会被反复复用训练循环里一旦漏掉 evalBN 的 running stats 会被验证集数据污染下一轮训练的起点就不对了。3.3 基线结果怎么读先看混淆矩阵再看总准确率干净训练跑完验证集准确率落在 92% 到 93% 之间是这份代码的正常水平。如果只有 88% 上下八成是学习率或者 epoch 数不对如果到了 94% 以上先怀疑是不是验证集和训练集有重叠——random_split用了独立的 generator正常情况下不会重叠但如果手动切分时索引写错结果会好得很可疑。Fashion-MNIST 有个很典型的现象总的准确率看着不错但 T-shirt/top、Shirt、Pullover 这三类的混淆率一直偏高原因是它们在 28×28 灰度图里本来就长得很像。这个细节在对抗实验里会放大——攻击最容易把 Shirt 推到 Pullover 或者反过来做可视化的时候你看到的第一批「成功攻击样本」大概率就是这三类互相串。心里有数就不会误以为是代码写错了。4. 攻击与对抗训练的核心实现FGSM、I-FGSM、PGD 三段代码这一章是整个项目的重心。三种攻击的代码量都不大但每一行都有讲究写错一处结果就差一个数量级。4.1 FGSM单步梯度上升十行代码但有三处会写错import torch def fgsm_attack(model, x, y, eps, criterionNone, clamp(0.0, 1.0)): Fast Gradient Sign Method沿损失对输入的梯度符号方向走一步 if criterion is None: criterion torch.nn.CrossEntropyLoss() model.eval() # 1. 冻结 BN x_adv x.detach().clone().requires_grad_(True) # 2. 只有输入需要梯度 loss criterion(model(x_adv), y) grad, torch.autograd.grad(loss, x_adv) # 3. 只求 dL/dx不求参数梯度 x_adv x_adv.detach() eps * grad.sign() # 沿符号方向走 eps return x_adv.clamp(*clamp).detach() # 投影回合法像素范围三个容易翻车的位置都用注释标出来了。第一model.eval()必须在前面哪怕调用方已经切过——攻击函数被训练循环调用时上一行往往刚把模型切回 train。第二用x.detach().clone().requires_grad_(True)而不是直接对x开梯度原因是 DataLoader 吐出来的张量可能是只读视图原地改会污染原始数据。第三用torch.autograd.grad而不是loss.backward()这样不会在模型参数的.grad上堆积梯度攻击完模型还是干净的。如果用 backward下一个 batch 的优化器 step 会带着攻击残留的梯度一起更新训练直接崩。eps的量级是另一个坑。Fashion-MNIST 像素在[0,1]常见的有效区间是 0.05 到 0.15对应 12/255 到 38/255。如果从 ImageNet 的代码里抄了8/255在 Fashion-MNIST 上偏弱攻击后准确率可能只掉十来个点容易误判成「模型很鲁棒」。4.2 I-FGSM 与 PGD差别只在一行随机起点I-FGSM 就是小步多走的 FGSMPGD 在它基础上加了随机起点。两者共用一套骨架import torch def pgd_attack(model, x, y, eps0.1, alpha0.01, steps10, criterionNone, random_startTrue, clamp(0.0, 1.0)): if criterion is None: criterion torch.nn.CrossEntropyLoss() model.eval() x_adv x.detach().clone() if random_start: # PGD 与 I-FGSM 的唯一差别 x_adv x_adv torch.empty_like(x_adv).uniform_(-eps, eps) x_adv x_adv.clamp(*clamp) for _ in range(steps): x_adv.requires_grad_(True) loss criterion(model(x_adv), y) grad, torch.autograd.grad(loss, x_adv) x_adv x_adv.detach() alpha * grad.sign() # 单步步长 alpha delta (x_adv - x).clamp(-eps, eps) # 投影回 eps 球 x_adv (x delta).clamp(*clamp) # 再投影回像素范围 return x_adv.detach()参数关系上alpha一般取eps/10到eps/4之间steps * alpha略大于eps是合理的因为投影会把超出部分拉回来。steps10是评估的常规配置steps7常用于训练内循环以省时间。random_startTrue是 PGD 强于 I-FGSM 的关键——每次从 eps 球内的随机点出发多次重启可以摸到不同的局部最优评估时把restarts设成 5 到 10 再取最差准确率报出来的数才经得起追问。requires_grad_(True)放在循环里而不是循环外是因为每次detach()之后图就断了必须重新挂上。4.3 对抗训练把攻击塞进训练循环的顺序问题对抗训练PGD-AT的核心思路很朴素每个 batch 先用当前模型生成对抗样本再用这些样本来算 loss 和反传。import torch import torch.nn as nn from attacks.pgd import pgd_attack def train_adv(model, train_loader, val_loader, epochs25, eps0.1, alpha0.01, steps7, lr0.01, devicecuda): criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lrlr, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) for epoch in range(epochs): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) # 1) 生成对抗样本内部会切 eval结束后手动切回 train x_adv pgd_attack(model, x, y, epseps, alphaalpha, stepssteps, criterioncriterion) model.train() # 2) 用对抗样本前向、反传、更新 optimizer.zero_grad(set_to_noneTrue) loss criterion(model(x_adv), y) loss.backward() optimizer.step() scheduler.step() print(fepoch {epoch}: val_acc{evaluate_acc(model, val_loader, device):.4f})顺序上有两个点必须对。第一x_adv必须在optimizer.step()之前生成用旧参数生成攻击、用新参数学习这是标准做法如果反过来先更新参数再生成就变成了对旧模型的攻击鲁棒性提升会打折扣。第二攻击生成阶段切eval()、反传阶段切回train()目的是不让对抗样本去更新 BN 的 running stats——对抗样本的分布和干净样本有偏移混进统计量之后干净准确率会莫名其妙掉两三个点而且很难查出来。评估这步也别省。扫一遍 eps 画出曲线是把「鲁棒」这个词说清楚的最低要求def eval_under_attack(model, loader, eps_list, devicecuda): model.eval() rows [] for eps in eps_list: correct total 0 for x, y in loader: x, y x.to(device), y.to(device) x_adv pgd_attack(model, x, y, epseps, alphaeps/8, steps20, random_startTrue) # 评估用强攻击 correct (model(x_adv).argmax(1) y).sum().item() total y.size(0) rows.append({eps: eps, acc: correct / total}) return rows评估时steps20起步训练时 7 步就够这个不对称是有意的——训练内循环追求速度评估必须追求可信。用弱攻击评估强模型很容易得出「鲁棒性很好」的结论实际上只是梯度被掩蔽了。我这份代码跑下来的数量级参考干净模型在eps0.1、PGD-20 下准确率掉到个位数做完 PGD-AT 之后干净准确率大概 87% 到 88%同一个攻击下能守在 75% 上下。这个交换比就是对抗训练的本质——用几个点的干净精度换几十个点的鲁棒精度。5. 避坑与排查五个让实验结果不可信的现场对抗实验最麻烦的地方不是跑不起来是跑起来了但结果是错的而错的结果看上去还挺合理。下面五条是我在这类项目里反复遇到的。5.1 攻击侧的三个坑坑一攻击之后准确率还在 80% 以上以为模型很鲁棒。现象是 FGSM 打完几乎没掉点甚至个别轮次还涨了。原因通常是 eps 量级用错从别的项目抄了8/255≈0.031在 Fashion-MNIST 上太弱也可能是像素被 Normalize 过而攻击的clamp(0,1)把扰动削掉了一大半。解决方式是先打印(x_adv - x).abs().max()正常应该接近 eps如果远小于 eps说明投影把它压掉了。坑二第二个 batch 报 Trying to backward through the graph a second time。现象是第一个 batch 正常第二个直接抛异常。原因是攻击函数里用了loss.backward()而不是torch.autograd.grad或者在循环外requires_grad_(True)之后没有detach()就进入下一轮。计算图被复用了。解决方式是统一改成torch.autograd.grad(loss, x_adv)并且在每轮迭代末尾显式detach()。坑三同样的 eps攻击结果每次跑都不一样波动能到十个点。现象是重跑三次拿到三个差距很大的数。原因之一是攻击生成时模型还处在 train 模式BN 用的是当前 batch 的统计量batch 组成一变梯度方向就变攻击强度跟着漂。解决方式是在攻击函数第一行强制model.eval()不要指望调用方记得切。另一个原因是 PGD 的random_start没设种子多 worker 下每个进程的随机流不同这个只能靠固定种子加单 worker 调试来定位。5.2 训练与评估侧的两个坑坑四对抗训练 loss 不降干净准确率从 92% 掉到 70% 以下。现象是前几个 epoch loss 一直在 2.0 附近横盘。原因往往是超参直接沿用了干净训练的配置——Adam 加lr1e-3再加 20 步攻击等价于每一步都在最难的样本上学模型根本跟不上。解决方式是对抗训练换 SGDlr0.01配余弦退火训练内循环的steps降到 7前两个 epoch 先用干净样本预热再切到对抗样本。这几个改动做完曲线通常会正常下来。坑五用 5 步 PGD 评估就宣布鲁棒换个攻击就现原形。现象是自己报的鲁棒准确率 80%换成 20 步加 5 次重启之后掉到 45%。这是典型的梯度掩蔽——模型学到的不是真鲁棒而是让梯度变得不可用。解决方式是评估统一用steps20、random_startTrue、多次重启取最差值另外至少再交叉验证一种攻击比如 FGSM 和 PGD 都跑两种攻击的排名如果反过来说明鲁棒性是假的。坑六可视化出来的图全黑或者全白。现象是visualize.py存出来的对抗样本看不出原图。原因是前面某处做了 Normalize画图时直接imshow就会越界。解决方式是画图前统一反归一化或者像这份代码一样全程不 Normalize只在[0,1]区间里做画图直接乘 255 就行。注意这五条里有三条的共同根源是「训练/评估模式没切对」。写完攻击函数之后养成立刻在里面补一行model.eval()的习惯能省掉大量排查时间。6. 二次开发换数据集、换攻击组合把 demo 变成实验底座这份代码要往上长最先动的是数据入口。把build_loaders里的FashionMNIST换成CIFAR10同时改三处transforms加RandomCrop(32, padding4)和RandomHorizontalFlip模型第一层in_channels从 1 改成 3全连接的输入维度从64*7*7改成64*8*832 输入两次池化后是 8。eps 也要跟着换CIFAR-10 上常用8/255正好是 Fashion-MNIST 那套数值的三分之一直接照搬会攻击过强。第二个改造点是把攻击做成可注册的字典这样evaluate.py扫描的时候可以一次跑多种攻击不用改代码# attacks/__init__.py from .fgsm import fgsm_attack from .ifgsm import ifgsm_attack from .pgd import pgd_attack ATTACKS { fgsm: fgsm_attack, ifgsm: ifgsm_attack, pgd: pgd_attack, } def build_attack(name, **kwargs): if name not in ATTACKS: raise KeyError(funknown attack: {name}, available: {list(ATTACKS)}) return lambda model, x, y, eps: ATTACKS[name](model, x, y, epseps, **kwargs)新加一个攻击只需要在目录里放一个同签名的文件注册进去即可训练脚本和评估脚本都不用碰。这是我觉得这份代码结构上最值得保留的一点。几个常见的改造方向可以直接对着改改造目标改动文件关键动作换数据集models/small_cnn.py、utils.py改输入通道、全连接维度、eps 量级加新攻击attacks/新增文件保持(model, x, y, eps)签名并注册加集成攻击attacks/ensemble.py多个模型梯度取平均或取最大评估更可信参数外置configs/base.yamleps / alpha / steps / lr 全提出来方便网格搜索结果归档evaluate.py每个 eps 打一行 csv附上 commit 和种子验证改造有没有改对方法很土但有效先拿干净模型跑一遍新数据集的干净准确率确认落在合理区间再跑eps0的攻击结果必须和干净准确率完全一致如果对不上说明攻击函数在 eps 为 0 时仍然改了输入投影或者随机起点那步有 bug。这个自检我每次都做比看曲线管用。从那以后我每次动 eps 或者换数据集都强制先把「eps0 的攻击 干净准确率」这条断言跑一遍。对抗实验的坑大多不在算法本身而在那些看起来无害的默认值上。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表