对抗攻击实操入门(一):从零搭建环境并复现 FGSM 与 PGD 攻击(超详细)
前言
最近在搞自动驾驶感知安全方向,想开个新坑分享一下。网上讲 FGSM、PGD 的文章一大堆,但多数停在贴公式,真正从建环境到跑出图的保姆级教程很少。所以这个系列从最基础的 FGSM 和 PGD 开始,一步一步实操。
为什么要先讲这俩。因为后面要写的所有东西,NDSS 2025 打商业交通标志识别的、USENIX 2024 用 LiDAR 让自动驾驶撞车的、CVPR 2025 同时骗摄像头和雷达的,底层全是 FGSM 和 PGD 的变种。这俩不搞懂,后面没法看。
本文开始介绍从零搭建环境,完整复现 FGSM 和 PGD 两个攻击。环境配置到代码运行,一步一步来,踩坑都在正文里标出来了。
系列前文链接(这是第一篇,后续补)。
1 对抗样本是什么
2015 年 Goodfellow 等人在 ICLR 论文《Explaining and Harnessing Adversarial Examples》(https://arxiv.org/abs/1412.6572)里给出了一个经典案例,直接引爆了整个领域。
图分三块。左边是原始输入,一张熊猫图片,GoogLeNet 给出 panda 置信度 57.7%。中间那团彩色噪点叫扰动,它是损失函数对输入求梯度再取符号乘上一个小系数得到的,扰动量极小。把中间这层加到左边的熊猫上,得到右边那张图,人眼看还是熊猫,模型却给出 gibbon 长臂猿,置信度飙到 99.3%。
核心结论。神经网络在高维空间里的决策边界,和人眼感知存在系统性偏差。在输入空间施加范数有界的人眼不可见扰动,就能让模型预测结果发生任意翻转。
这个性质叫对抗脆弱性(adversarial vulnerability),是后面所有攻击方法的物理前提。自动驾驶领域的对抗贴片、LiDAR 欺骗、3D 对抗物体,本质上都是在不同模态、不同物理约束下构造这种扰动。
2 环境搭建(从头开始)
注意注意!以下操作建议在 Ubuntu 20.04 或 Windows + Anaconda 下完成,Python 版本用 3.10!!!!!没 NVIDIA 显卡也能跑,就是慢点。
2.1 创建 conda 环境
打开终端,输入以下命令创建一个新的 conda 环境。
conda create-nadv_attackpython=3.10-y激活环境。
conda activate adv_attack建议大家环境名字保持一致,后面排查问题方便对照。
2.2 安装 PyTorch
PyTorch 版本很关键,建议装 2.0 以上。有 NVIDIA 显卡的装 CUDA 版,没有的装 CPU 版。
有显卡的(终端输入nvidia-smi能看到显卡信息),输入以下命令。
condainstallpytorch torchvision pytorch-cuda=11.8-cpytorch-cnvidia-y没有显卡或者不想用 GPU 的,装 CPU 版。
condainstallpytorch torchvision cpuonly-cpytorch-y装完之后验证一下,在终端输入。
python-c"import torch; print(torch.__version__); print('CUDA:', torch.cuda.is_available())"正常的话会打印类似下面这样。
2.1.0 CUDA: True如果CUDA 显示 False,说明 GPU 没用上。这不会报错,代码也能跑,就是慢。排查的话一般是 CUDA 版本和显卡驱动对不上,先看nvidia-smi右上角的 CUDA Version,对应着装。
2.3 安装其他依赖
还需要 matplotlib 画图,numpy 做数值计算。
pipinstallmatplotlib numpy2.4 准备数据集
本文用的是CIFAR-10 数据集(https://www.cs.toronto.edu/~kriz/cifar.html),PyTorch 会自动下载。第一次运行代码的时候会下载,大概 170MB,存到当前目录的data文件夹下。
注意国内下载 CIFAR 偶尔会失败,多试几次或者挂梯子。下载成功一次以后就不用再下了。
3 FGSM 和 PGD 原理
3.1 FGSM 原理
FGSM 全称 Fast Gradient Sign Method(快速梯度符号法),Goodfellow 等人 2015 年提出。核心公式。
x a d v = x + ϵ ⋅ sign ( ∇ x L ( θ , x , y ) ) x_{adv} = x + \epsilon \cdot \text{sign}(\nabla_x L(\theta, x, y))xadv=x+ϵ⋅sign(∇xL(θ,x,y))
这个公式里最关键的一点,也是新手最容易搞错的,就是这个梯度是对输入图片x xx求的,不是对模型参数θ \thetaθ求的。
正常训练神经网络的时候,我们算的是∇ θ L \nabla_\theta L∇θL,让参数沿负梯度方向走,损失变小,模型变聪明。FGSM 反过来,参数不动,让输入沿正梯度方向走一步,损失变大,模型预测就倾向于出错。
FGSM 本质就是一句话。固定模型,反向训练输入。
公式里几个符号说明一下。
- x xx是原始输入图片
- ∇ x L \nabla_x L∇xL是损失函数对输入图片的梯度
- sign ( ⋅ ) \text{sign}(\cdot)sign(⋅)是取符号函数,每个元素变成 +1 或 -1
- ϵ \epsilonϵ是扰动大小,控制走多远
为什么要取 sign 而不直接用梯度原值。因为取符号能让每个像素的扰动量都一样大,等于ϵ \epsilonϵ,这样扰动最均匀,人眼不容易察觉。要是按梯度原值加,有些像素会被改得很大,一眼就看出来了。
ϵ \epsilonϵ取多少。CIFAR 和 ImageNet 上常用ϵ = 8 / 255 ≈ 0.031 \epsilon = 8/255 \approx 0.031ϵ=8/255≈0.031,意思是每个像素最多变化 8 个灰度级(0 到 255 范围内)。这个数小到人眼分不出来,但够把模型干翻。
3.2 PGD 原理
FGSM 有个硬伤,它只走一步。一步走出去对不对,全看运气。
Madry 等人在 2018 年 ICLR 的论文《Towards Deep Learning Models Resistant to Adversarial Attacks》(https://arxiv.org/abs/1706.06083)里提出 PGD,全称 Projected Gradient Descent(投影梯度下降)。
PGD 相对 FGSM 多了三件事。
(1) 走很多步。迭代 20 到 40 步,每步走一个小一点的步长α \alphaα,比如2 / 255 2/2552/255。
(2) 随机起点。不从原图开始,从原图附近随机一个点开始。这一步看着不起眼,但对后面做对抗训练特别重要,能找到更多局部最大值。
(3) 每步投影回去。这是 PGD 的精髓。走多步有个问题,走着走着总扰动超过ϵ \epsilonϵ了怎么办。PGD 每走一步,都把当前点投影回以原图为中心、半径ϵ \epsilonϵ的框里,保证不管走多少步,总扰动永远不超过ϵ \epsilonϵ。
Madry 那篇论文有个重要结论。在L ∞ L_\inftyL∞约束下,PGD 找到的对抗样本基本就是模型能达到的局部最坏情况了。也就是说,扛得住 PGD 的模型,基本就扛得住所有一阶攻击。所以后来做鲁棒性的论文,baseline 清一色 PGD 对抗训练。
FGSM 和 PGD 的关系记一句就够了。FGSM 是 PGD 只走一步的特例,PGD 是多步加投影的 FGSM。
4 完整代码(可直接复制运行)
先把完整代码放这里,可以直接复制粘贴到一个文件里跑通。下面 5、6 节再逐段讲解。
importtorchimporttorch.nnasnnimporttorch.nn.functionalasFimporttorchvisionimportmatplotlib.pyplotasplt# ===== 0. 设置设备 =====device="cuda"iftorch.cuda.is_available()else"cpu"print(f"运行设备:{device}")# ===== 1. 加载模型 =====model=torchvision.models.resnet18(weights="IMAGENET1K_V1")model.fc=nn.Linear(512,10)# 改成 CIFAR-10 的 10 分类model=model.to(device).eval()# CIFAR-10 类别CIFAR_CLASSES=["airplane","auto","bird","cat","deer","dog","frog","horse","ship","truck"]# ===== 2. 加载一张测试图片 =====transform=torchvision.transforms.ToTensor()cifar=torchvision.datasets.CIFAR10(root="./data",train=False,download=True,transform=transform)img,label=cifar[0]img=img.unsqueeze(0).to(device)label=torch.tensor([label]).to(device)# ===== 3. FGSM 攻击函数 =====deffgsm_attack(image,epsilon,data_grad):"""FGSM 单步攻击"""perturbed_image=image+epsilon*data_grad.sign()returntorch.clamp(perturbed_image,0,1)# ===== 4. PGD 攻击函数 =====defpgd_attack(model,image,label,epsilon=8/255,alpha=2/255,steps=20):"""PGD 多步投影攻击"""image=image.clone().detach().to(device)label=label.to(device)# 随机起点adv_image=image+torch.empty_like(image).uniform_(-epsilon,epsilon)adv_image=torch.clamp(adv_image,0,1).detach()foriinrange(steps):adv_image.requires_grad_(True)output=model(adv_image)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=adv_image.grad.datawithtorch.no_grad():adv_image=adv_image+alpha*data_grad.sign()# 投影回 epsilon 球内delta=torch.clamp(adv_image-image,-epsilon,epsilon)adv_image=torch.clamp(image+delta,0,1).detach()returnadv_image# ===== 5. 执行 FGSM 攻击 =====print("\n===== FGSM 攻击 =====")img.requires_grad_(True)output=model(img)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=img.grad.data adv_fgsm=fgsm_attack(img,8/255,data_grad)withtorch.no_grad():clean_pred=model(img).argmax(1).item()fgsm_pred=model(adv_fgsm).argmax(1).item()clean_conf=F.softmax(model(img),dim=1)[0,clean_pred].item()fgsm_conf=F.softmax(model(adv_fgsm),dim=1)[0,fgsm_pred].item()print(f"干净样本:{CIFAR_CLASSES[clean_pred]}({clean_conf:.3f})")print(f"FGSM攻击后:{CIFAR_CLASSES[fgsm_pred]}({fgsm_conf:.3f})")# ===== 6. 执行 PGD 攻击 =====print("\n===== PGD 攻击 =====")img2,label2=cifar[0]img2=img2.unsqueeze(0).to(device)label2=torch.tensor([label2]).to(device)adv_pgd=pgd_attack(model,img2,label2,epsilon=8/255,alpha=2/255,steps=20)withtorch.no_grad():pgd_pred=model(adv_pgd).argmax(1).item()pgd_conf=F.softmax(model(adv_pgd),dim=1)[0,pgd_pred].item()print(f"干净样本:{CIFAR_CLASSES[clean_pred]}({clean_conf:.3f})")print(f"PGD攻击后:{CIFAR_CLASSES[pgd_pred]}({pgd_conf:.3f})")# ===== 7. 可视化 =====fig,axes=plt.subplots(1,3,figsize=(10,3.2))axes[0].imshow(img2.squeeze().permute(1,2,0).cpu().numpy())axes[0].set_title(f"Clean:{CIFAR_CLASSES[clean_pred]}({clean_conf:.2f})")axes[1].imshow(adv_pgd.squeeze().permute(1,2,0).cpu().numpy())axes[1].set_title(f"Adv:{CIFAR_CLASSES[pgd_pred]}({pgd_conf:.2f})")perturbation=(adv_pgd-img2).squeeze().permute(1,2,0).abs().cpu().numpy()axes[2].imshow(perturbation*50)axes[2].set_title("Perturbation x50")foraxinaxes:ax.axis("off")plt.tight_layout()plt.savefig("pgd_result.png",dpi=120)print("\n图片已保存到 pgd_result.png")5 代码逐段讲解
5.1 加载模型
model=torchvision.models.resnet18(weights="IMAGENET1K_V1")model.fc=nn.Linear(512,10)model=model.to(device).eval()用 ImageNet 预训练的 ResNet18,把最后一层全连接改成 CIFAR-10 的 10 分类。注意这里要用eval()切到测试模式,不然 BatchNorm 和 Dropout 的行为不对,攻击效果会受影响。
5.2 FGSM 攻击函数
deffgsm_attack(image,epsilon,data_grad):perturbed_image=image+epsilon*data_grad.sign()returntorch.clamp(perturbed_image,0,1)逻辑很简单,沿梯度符号方向加扰动,再用torch.clamp把像素值限制在 0 到 1 之间(防止超出合法范围)。
5.3 PGD 攻击函数
defpgd_attack(model,image,label,epsilon=8/255,alpha=2/255,steps=20):image=image.clone().detach().to(device)label=label.to(device)adv_image=image+torch.empty_like(image).uniform_(-epsilon,epsilon)adv_image=torch.clamp(adv_image,0,1).detach()foriinrange(steps):adv_image.requires_grad_(True)output=model(adv_image)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=adv_image.grad.datawithtorch.no_grad():adv_image=adv_image+alpha*data_grad.sign()delta=torch.clamp(adv_image-image,-epsilon,epsilon)adv_image=torch.clamp(image+delta,0,1).detach()returnadv_image逐行说明。
(1)image.clone().detach()复制一份原图,避免污染原始数据。
(2) 随机起点,在 epsilon 球内随机取一个点开始。
(3) 每步迭代里,先requires_grad_(True)让对抗图需要梯度,前向算 loss,反向求梯度。
(4) 每步走完,投影回 epsilon 球内,限制总扰动不超过ϵ \epsilonϵ。
注意注意!每步迭代结束的
.detach()不能省!!!!!新手第一次写经常漏,结果跑到第 5 步左右显存直接爆了。原因是 PyTorch 会把每一步的计算图都存着,越堆越大,最后 OOM。加上.detach()就是把计算图截断,每步重新开始。
5.4 执行攻击(关键求梯度部分)
img.requires_grad_(True)output=model(img)loss=F.cross_entropy(output,label)model.zero_grad()loss.backward()data_grad=img.grad.data adv_fgsm=fgsm_attack(img,8/255,data_grad)注意注意!loss.backward() 之前一定要 model.zero_grad() 清掉模型参数的梯度,不然历史梯度会累积,求出来的输入梯度是错的!!!!!很多教程漏了这一步,照着跑攻击成功率贼低,怎么调都不对,根本原因就在这。
6 踩坑总结
把正文里提到过的坑集中放一下,方便排查。
坑一 梯度方向算反了。攻击成功率贼低。原因要么是漏了model.zero_grad(),要么是梯度对象搞错了。检查方法,打印data_grad.abs().mean(),如果接近 0,基本就是梯度算错了。
坑二 像素范围没统一。模型预处理是 0 到 1 还是 0 到 255 还是 ImageNet 标准化。三个混用是新手最常见的 bug。建议统一在0 到 1 空间做攻击。模型如果用了 ImageNet 标准化,epsilon 也要换算到标准化后的空间,换算公式是ϵ n o r m = ϵ / σ \epsilon_{norm} = \epsilon / \sigmaϵnorm=ϵ/σ。
坑三 detach 忘了加。PGD 多步迭代时,每步末尾必须.detach()。漏了会导致计算图越堆越大,第 5 步左右显存溢出。
坑四 epsilon 设太大。新手为了攻击成功率高,把ϵ \epsilonϵ设到 32/255 甚至更大,扰动人眼已经能看见了,这就不是对抗样本了,是瞎改图。标准取值,CIFAR 和 ImageNet 用 8/255,MNIST 用 0.3。
7 这俩跟自动驾驶什么关系
FGSM 和 PGD 看着是数字空间攻击,直接改图片像素,离真实世界还远。但它定了三个贯穿整个领域的基础概念。
第一,对抗脆弱性。神经网络对输入的微小扰动极度敏感,这是后面所有攻击的前提条件。
第二,梯度攻击。用损失对输入的梯度指导攻击,C&W、DeepFool、AutoAttack 全是这个套路。PGD 是其中最强的一阶攻击。
第三,L p L_pLp范数约束。限制扰动大小用L ∞ L_\inftyL∞、L 2 L_2L2、L 0 L_0L0,这是衡量攻击强度的通用语言。
后面这个系列要做的事,就是把 PGD 这套思路搬到不同的物理载体上。物理世界攻击把扰动改造成能打印的贴片、能 3D 打印的物体。LiDAR 和点云攻击把像素扰动换成加点云点或删点云点。多模态融合攻击同时打摄像头和 LiDAR。
FGSM 和 PGD 不是入门科普,是后面所有论文的公共语言。做自动驾驶安全,这关必须先过。
8 下一步
下一篇介绍物理世界对抗攻击的全景图,从贴纸到激光器,把能在真实世界打出来的攻击分类讲清楚。再后面精读 USENIX Security 2024 那篇用 LiDAR 让自动驾驶碰撞率到 63% 的论文。
系列持续更新中,有问题可以在评论区聊。
参考文献
(1) Goodfellow I, Shlens J, Szegedy C. Explaining and Harnessing Adversarial Examples. ICLR 2015. https://arxiv.org/abs/1412.6572
(2) Madry A, Makelov A, Schmidt L, et al. Towards Deep Learning Models Resistant to Adversarial Attacks. ICLR 2018. https://arxiv.org/abs/1706.06083
(3) Kurakin A, Goodfellow I, Bengio S. Adversarial Examples in the Physical World. ICLR 2017. https://arxiv.org/abs/1607.02533
(4) Carlini N, Wagner D. Towards Evaluating the Robustness of Neural Networks. IEEE S&P 2017
(5) CIFAR-10 数据集。https://www.cs.toronto.edu/~kriz/cifar.html
(6) PyTorch FGSM 官方教程。https://docs.pytorch.org/tutorials/beginner/fgsm_tutorial.html
文中第一张图来源 Goodfellow et al. 2015 Figure 1,经 TensorFlow 官方教程(https://www.tensorflow.org/tutorials/generative/adversarial_fgsm)复现,CC BY 4.0。代码基于 PyTorch 官方 FGSM 教程改写,Apache 2.0 协议。