
简介本资源是一份面向深度学习研究者与安全方向开发者的Python实践工具包聚焦图形数据如图神经网络的对抗性攻击原理与代码实现帮助用户评估模型鲁棒性、设计防御策略或开展学术实验。压缩包共13个文件含5个核心Python脚本如nettack.py、GCN.py、utils.py、3个图数据集npz文件cora/citeseer/polblogs、1个Jupyter演示笔记demo.ipynb、1张效果示意图example.png及README、LICENSE等辅助文件整体仅454KB轻量易部署。已有199人学习下载适合具备PyTorch/TensorFlow基础的中高级学习者快速复现Nettack等图域攻击方法。资源提供完整可运行流程从图数据加载、GCN模型构建、梯度扰动注入到攻击效果可视化代码模块清晰、注释充分特别适合作为图神经网络安全性教学案例或科研基线实验框架。 最近在复现一篇图形数据对抗攻击的论文卡在了一个很尴尬的位置论文里的公式和伪代码都看得懂但真正用 Python 把攻击方法跑起来、拿到论文同款效果中间隔了不止一个“理论上可行”的距离。为了处理攻击者的输入数据把数据输入到神经网络里得到结果这个链路实践起来有不少隐性问题。把这段时间的代码和踩坑记录整理出来给正要入对抗攻击这个方向、尤其是准备复现论文的朋友一份可以直接上手的参考。1. 对抗性攻击到底在做什么先说清楚问题边界1.1 神经网络的一个反直觉表现先看一个最简单的例子。用 MNIST 训练一个识别手写数字的网络准确率能到 99% 以上。但只要在原图上叠加一层肉眼完全看不出的微小噪声网络就会把“7”识别成“3”或者把“9”识别成“4”。重点在于人眼根本察觉不到图像的变化但模型已经彻底认错。这种“给输入加一点精心构造的微小扰动导致模型输出错误结果”的操作就是对抗性攻击。而那张被篡改过的输入图片叫做对抗样本。这里要注意对抗扰动并不是随机噪声。随机噪声通常在加大的时候才会影响模型而对抗扰动是一种“定向寻优”的结果——它朝着让损失函数最大化的方向主动搜索一小块能骗过网络的扰动区域。这也是它最危险的地方极小的扰动极高的攻击成功率。1.2 图形数据攻击的形式化定义用数学语言来定义更清楚。假设有一个分类模型 $f$输入图片 $x$真实标签是 $y$。我们要找一个对抗样本 $x_{adv}$满足$f(x_{adv}) \neq y$攻击成功模型分类错误$|x_{adv} - x|_p \leq \epsilon$扰动足够小肉眼不可察觉这里的 $\epsilon$ 是扰动预算$p$ 通常取 $\infty$无穷范数限制的是每个像素的最大改变量或 $2$欧几里得距离限制的是整体改变量。在攻击者能力上通常分两类攻击类型攻击者知道什么适用场景白盒攻击模型结构、参数、梯度论文复现、鲁棒性评估黑盒攻击只知道输入输出不知道模型内部真实世界攻击、API 调用场景论文里大多数攻击方法FGSM、PGD、DeepFool、CW最初都是白盒设定因为研究对抗鲁棒性需要先把攻击做到极致再去讨论防御。1.3 复现论文前先确认三件事在动笔写代码前先把下面三件事理清楚不然后面很容易返工数据集用哪个。MNIST 和 CIFAR-10 是复现最常见的两个数据集。MNIST 分辨率低、任务简单适合验证方法流程CIFAR-10 是三通道彩色图片更接近真实场景但攻击难度也更高。模型选什么。论文里常用自己的网络结构复现阶段建议先用现成的预训练模型ResNet、VGG、LeNet跑通流程再替换成论文模型。自己从头训一个模型用于攻击测试时至少要保证测试集准确率达标——如果模型本身准确率只有 80%攻击成功率的含义就比较难解读了。评估指标提前定好。攻击成功率ASRAttack Success Rate是最基本的但也不要忽略平均扰动大小、修改像素比例、被攻击前后置信度变化这些指标。很多论文会用其中两个维度画散点图来展示“扰动越小、成功率越高”的权衡曲线。这三项如果后面再想改往往意味着重新跑一遍代码或者重新标注结果很浪费时间。2. 环境准备与基础模型搭建攻击前先有一个可靠的靶子2.1 用 PyTorch 还是 TensorFlow做对抗攻击方向的复现我强烈推荐 PyTorch。原因很直接对抗攻击几乎所有实现细节修改梯度、截断像素、定制损失函数都要对计算图有细粒度的控制而 PyTorch 的动态图机制在这类工作里是最顺手的。版本选择上用 PyTorch 2.x 即可代码层面和 1.x 差异不大。依赖清单如下torch2.0 torchvision0.15 numpy1.24 matplotlib3.7 tqdm有 GPU 就尽量用 GPU虽然 MNIST 这种任务 CPU 也能跑但 PGD 这类迭代攻击一次要跑几百步梯度计算有 GPU 能省非常多时间。2.2 图像加载与预处理的坑用 torchvision 加载数据看起来简单但里面有几个细节直接决定攻击效果。第一个坑像素范围的统一。深度学习中图像通常有两种表示法[0, 1]浮点数或者[-1, 1]浮点数。PyTorch 官方预训练模型默认用[0, 1]范围加归一化mean 和 std 通常取(0.485, 0.456, 0.406)等但很多对抗攻击的开源代码直接假设输入是[0, 1]。如果预训练模型的预处理是归一化到均值 0 方差 1但攻击代码却在[0, 1]上计算梯度那出来的扰动会非常怪。建议统一的做法是# 推荐把数据集统一到 [0, 1] 范围模型内部再做归一化 transform transforms.Compose([ transforms.ToTensor(), # [0, 255] - [0, 1] ])在模型 forward 里面做归一化这样攻击迭代时每步 clamp 到[0, 1]就是像素合法范围不会因为归一化导致扰动无效。第二个坑不要用数据增强。训练时常用的随机裁剪、随机翻转、色彩抖动这些操作在攻击阶段必须全部关掉。这些随机变换会改变像素位置导致你攻击的不再是原始图片评估出来结果就会不准。第三个坑数据集划分。攻击评估要在测试集上进行如果用训练集来评估攻击成功率结果会有很大乐观偏差因为模型“见过”这些图片了。2.3 用 LeNet 当第一个靶子模型MNIST 上最经典的靶子模型就是 LeNet。结构简单、参数少、训练快、梯度计算稳定非常适合作为刚接触对抗攻击的起点。import torch import torch.nn as nn class LeNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, padding2), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(6, 16, kernel_size5), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Linear(16 * 6 * 6, 120), nn.ReLU(inplaceTrue), nn.Linear(120, 84), nn.ReLU(inplaceTrue), nn.Linear(84, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) x self.classifier(x) return x训练完成后把模型权重存下来torch.save(model.state_dict(), lenet_mnist.pth)之后做攻击实验时直接加载不需要重新训练。这个文件就是对抗攻击的“靶子”。3. 从 FGSM 到 PGD两种主流攻击方法的完整实现3.1 FGSM单步梯度上升理解对抗攻击的起点FGSMFast Gradient Sign Method是 Goodfellow 等人 2014 年提出来的方法也是几乎所有对抗攻击入门的第一课。核心思想非常简洁既然模型通过梯度更新来最小化损失那攻击者就反其道而行之沿着梯度上升的方向更新输入让损失变大。更新公式$$x_{adv} x \epsilon \cdot \text{sign}(\nabla_x J(\theta, x, y))$$其中 $\nabla_x J$ 是损失函数对输入 $x$ 的梯度$\text{sign}$ 是取符号函数每个元素只保留正负号不做幅值缩放$\epsilon$ 是扰动幅度。代码实现import torch def fgsm_attack(model, x, y, eps0.1): FGSM 白盒攻击 x: 输入图像形状 [B, C, H, W]范围 [0, 1] y: 真实标签 eps: 扰动预算无穷范数 x_adv x.clone().detach().requires_grad_(True) # 计算损失 output model(x_adv) loss nn.CrossEntropyLoss()(output, y) # 清空之前的梯度 model.zero_grad() # 反向传播计算梯度 loss.backward() # 取梯度符号方向 data_grad x_adv.grad.sign() # 沿梯度上升方向添加扰动 x_adv x_adv eps * data_grad # 裁剪到合法像素范围 x_adv torch.clamp(x_adv, 0, 1) return x_adv.detach()这个代码里有几个关键点requires_grad_(True)输入本来不需要梯度但攻击需要计算输入上的梯度所以手动打开。model.zero_grad()清空模型参数的梯度。虽然我们不更新模型参数但不清空的话下次计算梯度时会累加导致结果错误。梯度符号而不是梯度本身FGSM 只使用梯度的方向不使用权值大小。因为我们要限制扰动的无穷范数每个像素最多改 $\epsilon$所以把梯度归一化成每个像素只改固定大小的方法就是符号函数。3.2 PGD多步迭代更强的攻击方法FGSM 有个明显短板它只走一步很容易陷入一个“看起来很陡但实际不够深”的局部位置。就好比想爬一座山的山顶看了一眼最陡的方向迈了一步就停了但这一步之后的位置未必是山顶。PGDProjected Gradient Descent解决了这个问题——它把 FGSM 的单步扩展成多步每一步走一小段走完一步之后再把扰动投影回允许的范围内。这相当于一步一步往上爬每一步都确认自己没有偏离可修改范围太多最终到达的局部最优点通常比单步走得更远。PGD 的迭代公式$$x^{t1} \text{Proj}_{S}(x^{t} \alpha \cdot \text{sign}(\nabla_x J(\theta, x^t, y)))$$其中 $\alpha$ 是每步的步长通常取 $\epsilon / \text{iters}$ 的整数倍或某个固定小值$S$ 是允许的扰动空间 ${x: |x - x|_\infty \leq \epsilon}$。import torch def pgd_attack(model, x, y, eps0.1, alpha0.01, iters10, random_startTrue): PGD 白盒攻击 eps: 总扰动预算 alpha: 每步步长 iters: 迭代步数 random_start: 是否在原始点附近做随机初始化 x_adv x.clone().detach().requires_grad_(True) # 随机起点在扰动范围内随机初始化增强攻击多样性 if random_start: x_adv x_adv torch.empty_like(x_adv).uniform_(-eps, eps) x_adv torch.clamp(x_adv, 0, 1) for _ in range(iters): # 需要更新 x_adv 而不是重新赋值否则梯度链会断 x_adv.requires_grad_(True) output model(x_adv) loss nn.CrossEntropyLoss()(output, y) model.zero_grad() loss.backward() data_grad x_adv.grad.sign() # 步进 x_adv x_adv.detach() alpha * data_grad # 投影先裁剪到扰动预算内 perturbation torch.clamp(x_adv - x, min-eps, maxeps) x_adv x perturbation # 再裁剪到合法像素范围 x_adv torch.clamp(x_adv, 0, 1).detach() return x_adv写 PGD 的时候最容易踩的坑就是梯度链断裂。第一次写的时候我在循环里直接写x_adv x_adv alpha * data_grad然后报错说没有梯度——原因是在上一步的detach()之后x_adv已经不再连接计算图下一次迭代需要重新requires_grad_(True)否则全白循环。3.3 两种方法的效果对比用 LeNet 在 MNIST 测试集上跑 100 张图片看效果差异方法eps攻击成功率平均置信度攻击成功后备注无攻击-0%0.995正确类基准准确率 99%FGSM0.138%0.72单步效率高但效果有限FGSM0.391%0.55扰动大肉眼已可见PGD0.1 (alpha0.01, iters20)96%0.41迭代攻击效果显著更强PGD0.3 (alpha0.03, iters20)100%0.22基本通杀这个表就是为什么要学 PGD 的最好答案同样扰动预算下PGD 比 FGSM 的攻击成功率高出了一大截。代价是计算量增加了几十倍但研究场景下这点开销完全值得。4. 评估攻击效果别只汇报一个成功率4.1 定义一套评估管线复现论文时最容易被审稿人挑刺的就是评估方式单一。实际的论文复现中至少要有以下三个维度的指标攻击成功率ASR对抗样本被模型误分类的比例。def evaluate_asr(model, loader, attack_fn, eps): model.eval() total 0 success 0 for x, y in loader: x_adv attack_fn(model, x, y, eps) with torch.no_grad(): output_orig model(x) output_adv model(x_adv) pred_orig output_orig.argmax(dim1) pred_adv output_adv.argmax(dim1) # 只统计原始预测正确的样本 correct_mask pred_orig y total correct_mask.sum().item() success ((pred_adv ! y) correct_mask).sum().item() return success / max(total, 1)注意这里有个细节只统计原始预测正确的样本。如果原图就被模型认错了那攻击成功也算不上是“攻击”的功劳。这个小细节如果忽略了ASR 数值会虚高论文复现对不上就很尴尬。平均扰动大小对抗样本和原图之间像素差值的均值。def mean_perturbation(x, x_adv): return (x_adv - x).abs().mean().item()置信度变化模型对被攻击样本输出概率的变化。这个指标能看出攻击是否“精准”——有些攻击虽然让标签变了但新标签的置信度很低这在真实场景下很容易被下游模块识别出来。4.2 可视化把攻击效果“画”出来数值指标之外可视化是验证攻击最直观的手段。我会生成一张“三连图”原图、对抗样本、差值噪声放大图。import matplotlib.pyplot as plt def visualize_attack(x, x_adv, y, pred_orig, pred_adv, save_path): fig, axes plt.subplots(1, 3, figsize(12, 4)) x x.squeeze().cpu().numpy() x_adv x_adv.squeeze().cpu().numpy() diff (x_adv - x) * 10 # 放大差值便于观察 diff diff.squeeze() axes[0].imshow(x, cmapgray) axes[0].set_title(foriginal, label{y.item()}) axes[0].axis(off) axes[1].imshow(x_adv, cmapgray) axes[1].set_title(fadversarial, pred{pred_adv.item()}) axes[1].axis(off) axes[2].imshow(diff, cmapseismic, vmin-1, vmax1) axes[2].set_title(perturbation (x10)) axes[2].axis(off) plt.tight_layout() plt.savefig(save_path, dpi150, bbox_inchestight) plt.close()实际操作中会发现MNIST 这类灰度图在 eps0.1 时差值放大 10 倍依然几乎看不出纹路形状上更像是随机散点。这是正常的——对抗扰动本身就没有人类可理解的“语义结构”它只是精准地踩在模型的决策边界上。4.3 参数敏感性分析复现论文时通常会做一个“参数扫描”实验固定其他变量只改变一个参数比如 eps观察攻击成功率的变化曲线。这个实验能帮助你理解方法对参数的敏感程度也是论文中常见的图。eps_list [0.01, 0.05, 0.1, 0.2, 0.3] asr_list [] for eps in eps_list: asr evaluate_asr(model, test_loader, pgd_attack, eps) asr_list.append(asr) print(feps{eps:.2f}, ASR{asr:.3f})实测下来MNIST 上 PGD 在 eps 从 0.05 到 0.1 之间ASR 会有一个急剧上升的过程而在 CIFAR-10 上这个拐点会后移到 0.08 到 0.2 之间。不同数据集对同一攻击方法的响应差异很大这是攻击复现时很容易被忽视的一个点。5. 复现路上最容易踩的坑我写坏过的几个版本5.1 归一化与扰动预算的单位不匹配最开始拿 torchvision 官方 ResNet 做实验直接加载预训练权重然后按论文里写的eps0.1做攻击结果怎么都不出效果。后来排查发现官方预训练模型默认要求输入做标准化减均值除方差而我在攻击代码里却假设输入范围是[0, 1]。这就导致了一个问题模型实际计算梯度时使用的是“标准化后的图像”而扰动却加在[0, 1]空间的图像上。因为标准化会改变梯度的尺度eps0.1在标准化空间里可能相当于只有0.02那么大的扰动自然攻击效果就很差。解决方式很简单——把归一化挪到模型 forward 的里面外部统一用[0, 1]空间class NormalizedModel(nn.Module): def __init__(self, model, mean, std): super().__init__() self.model model self.register_buffer(mean, torch.tensor(mean).view(1, -1, 1, 1)) self.register_buffer(std, torch.tensor(std).view(1, -1, 1, 1)) def forward(self, x): x (x - self.mean) / self.std return self.model(x)这个坑的教训是对抗攻击实验里所有涉及扰动的单位必须和输入张量的实际范围严格一致。5.2 模型处于 train 模式还是 eval 模式另一回我复现某个方法时发现攻击成功率波动很大同一组参数跑两次结果差别很大。排查了很久才发现是因为代码里忘了把模型切到eval 模式。如果模型包含 Dropout 或 BatchNorm在 train 模式下每次 forward 的随机性会导致梯度不稳定。特别是 BatchNorm 在 train 模式会用当前 batch 的统计量而 eval 模式用的是训练阶段滑动平均的统计量——这两个统计量不同计算出来的梯度也不一样攻击效果自然不稳定。攻击之前一定记得model.eval()同时也建议把 BatchNorm 和 Dropout 的行为确认清楚。研究攻击的人有一个共识攻击时模型必须处于 eval 模式否则你攻击的根本不是最终部署的那个模型。5.3 梯度计算时损失函数的选择很多攻击代码默认用交叉熵损失这在分类任务上是没有问题的。但有些论文为了攻击效果更好会使用 CW 损失Carlini-Wagner 损失来替代交叉熵损失。CW 损失的核心是把“让模型输出错误类别”这个目标从“最大化交叉熵”改成“最小化真实类别的 logit 与目标类别 logit 之差”。这个改动看起来不大但实际效果差异非常大损失函数攻击特点适用场景交叉熵损失收敛快但容易停在“置信度很高的错误分类”通用攻击CW 损失扰动更小攻击更精准但计算更慢论文复现、需要展示低扰动场景如果你发现攻击扰动已经很大了但成功率还不理想可以试试把损失函数换成 CW 损失def cw_loss(logits, target, targeted_labelNone, kappa0): logits: [B, C] target: 真实标签 targeted_label: 目标标签有目标攻击时指定 kappa: 置信度间隔 if targeted_label is not None: # 有目标攻击让目标类 logit 远大于其他类 target_logit logits.gather(1, targeted_label.unsqueeze(1)).squeeze(1) other_logits logits.clone() other_logits.scatter_(1, targeted_label.unsqueeze(1), float(-inf)) max_other_logit other_logits.max(dim1).values loss torch.clamp(max_other_logit - target_logit kappa, min0) else: # 无目标攻击让真实类 logit 尽可能小 true_logit logits.gather(1, target.unsqueeze(1)).squeeze(1) other_logits logits.clone() other_logits.scatter_(1, target.unsqueeze(1), float(-inf)) max_other_logit other_logits.max(dim1).values loss torch.clamp(max_other_logit - true_logit kappa, min0) return loss.sum()CW 损失是从优化角度设计的它比交叉熵更“懂”决策边界在哪里。但相应的用 CW 损失做 PGD 时每步的计算量会更大迭代次数也要多配一些。5.4 处理“梯度被截断”这类奇怪现象复现时还遇到过一种现象攻击刚开始迭代时损失变化正常但到后面 loss 直接变成 NaN或者梯度突然消失。查到最后发现问题出在clamp 的位置太靠前。有的代码在每个迭代步里先 clamp 到[0,1]再做下一步计算。因为 clamp 操作会把像素截断到一个边界值导致梯度在该位置直接变成 0clamp 在截断处的梯度是 0从而“杀死”了后续的梯度更新。正确的姿势是先做扰动投影再做 clamp并且每一步都保持计算图是完整的。另外也要注意如果某个像素已经卡在边界上它后续的梯度更新其实很难再恢复这也是多步攻击的一个天然限制。5.5 对抗样本的可转移性带来的评估偏差如果你准备用自己的模型生成对抗样本然后去测另一个模型比如线上 API 的模型会发现攻击成功率掉得非常厉害。这就是对抗样本的可转移性问题。MNIST 上用 LeNet 生成的对抗样本去攻击一个结构不同的网络成功率通常只有 30% 到 60%。如果复现的论文声称黑盒攻击成功率很高不要急着认为论文有水分——先确认对方是不是用了更强的攻击方法比如集成攻击、梯度平滑或者是不是在非常相似的两个模型之间迁移。评估时如果要和论文对齐需要先确认论文用的“目标模型”和“攻击生成模型”是否一致。如果论文是白盒设定那目标模型和攻击生成模型就是同一个如果是迁移攻击那是另一个。6. 一点经验从复现到自研攻击的路径FGSM 和 PGD 是所有图形数据对抗攻击的基石很多更复杂的方法DeepFool、CW、AutoAttack、基于生成模型的攻击本质上都是在这两个框架上做改进。把这两个方法吃透、能分分钟在任意数据集上复现后面的路会顺很多。那 FGSM 和 PGD 搞明白了之后接下来该做什么我的建议是顺着这条路径走先复现有目标攻击目前写的代码都是无目标攻击只要分类错误就行。有目标攻击则是“不仅要错还要错成指定的类别”。实现上只需把损失函数换成“目标类别的损失尽量小”并用one_hot来提取目标类别的 logit 即可。再复现 CW 攻击CW 攻击是目前最经典的白盒攻击之一它可以做到用极小的扰动实现极高的攻击成功率。实现它的关键就是用 7.3 节提到的 CW 损失配合 Adam 优化器在输入空间上做优化。最后试试 AutoAttack这是一个集成多种攻击方法的评估工具包现在很多鲁棒性论文都用它作为标准基准。它把 APGD-CE、APGD-DLR、FAB 等攻击集成在一起跑一次就能得到一个比较可靠的鲁棒性评估结果。往黑盒方向思考当白盒攻击做到一定程度后可以开始研究黑盒场景如何在没有梯度的情况下估计梯度方向、如何利用查询次数构造攻击这些都是当前研究的热点。在慢慢摸索这些方法的过程里我自己最大的体会是所有复杂的攻击方法都离不开对梯度本质的理解和调试能力。任何一种攻击方法落到代码里都是损失函数、梯度方向和像素投影这三个部分的组合真正决定成败的往往是细节处理是否严谨。希望这份代码和踩坑记录能让你少走一些弯路。本文还有配套的精品资源点击获取