公司动态
图神经网络对抗性攻击实战:Python实现特征与结构攻击
简介图神经网络GNN在社交网络、分子结构、推荐系统等非欧几里得数据上表现出色但其安全性面临严峻挑战。与图像对抗攻击不同图数据的离散结构使得攻击更隐蔽——攻击者可通过篡改节点特征或增删边来欺骗模型。本文从GNN的前向传播原理出发讲解基于梯度的FGSM特征攻击和贪心结构攻击结合Python与PyTorch Geometric实现完整攻击流程并给出扰动预算设置与数据泄漏规避等工程经验。通过实际案例展示如何量化模型鲁棒性为风控、金融交易等场景下的安全防御提供参考。 说到对抗性攻击大家可能首先想到的是图像分类里的FGSM、PGD这些经典方法但我今天想聊的是另一个更有意思的场景——图形数据上的神经网络。这里的“图形数据”不是说你看到的图片而是图结构数据比如社交网络、分子结构、知识图谱甚至是推荐系统里的用户-物品关系图。处理这类数据的神经网络叫图神经网络Graph Neural NetworkGNN它在节点分类、链接预测、图分类等任务上表现很抢眼。但问题来了GNN同样能被对抗性攻击“欺骗”而且因为图数据的离散性攻击方式比图像更隐蔽更难防御。这个项目标题是“针对图形数据的神经网络的对抗性攻击——python”说白了就是要用Python实现对GNN模型的攻击。它能做什么小到验证一个模型的鲁棒性大到安全攻防测试。适合谁来看主要给做图神经网络研究、模型安全测试、或者想在推荐系统、风控场景里做对抗性样本分析的朋友。下面我会从原理到代码完整走一遍这个流程包括我从零开始踩过的坑和验证过的技巧保证你拿到手就能直接跑起来。1. 项目背景与核心思路拆解1.1 为什么非要和图形数据的攻击死磕先问个问题传统的卷积神经网络处理的是欧几里得数据比如图片是规则网格文本是序列结构。但图数据是非欧几里得空间里的东西每个节点的邻居数量和连接方式都不一样这导致之前那套连续空间的对抗攻击方法没法直接套用。比如图像里你往像素上加一个微小的噪声人眼根本察觉不到但模型就分错了。而在图里你要往哪里加“噪声”是给节点特征加扰动还是偷偷改掉一条边或者干脆加一个假节点进去这些问题非常有趣也很有实用价值。我在实际调研中发现像金融交易网络、社交关系网络、分子性质预测这些领域都已经开始大规模使用GNN了。如果攻击者摸清了模型的薄弱点通过篡改图中少量边或特征就能让信用评分模型把高风险用户判成低风险或者让药物活性预测模型把一个有毒分子误认为安全分子。这种攻击的杀伤力远超图片分类的小打小闹。所以这个项目最核心的价值不是教你怎么“作恶”而是帮你理解模型脆弱的本质进而在训练时加入对抗防御或者在生产环境里做安全测试。1.2 攻击的核心思路在离散的图结构上“微调”对抗性攻击的本质是给定一个已经训练好的模型 f你想找到一组扰动 δ使得 f(xδ) 的输出发生变化同时 δ 尽可能小。在图像里x是连续像素δ是连续浮点数。但在图数据里输入通常包括两个部分特征矩阵 X每个节点有d维特征和邻接矩阵 A表示节点间是否存在边。A 是离散的、稀疏的里面的每一项不是0就是1。这就引出了两个攻击维度特征攻击在 X 上添加微小噪声就像图像攻击一样但 X 的每一行代表一个节点噪声要尽量小且不能破坏特征本身的语义。结构攻击直接改动 A比如添加/删除一条边。这个改动是离散的不能用梯度下降直接优化但可以通过贪心搜索、强化学习或者启发式算法来做。另一个关键思路是区分白盒和黑盒攻击。白盒攻击能拿到模型的梯度用梯度信息指导扰动生成效率高黑盒攻击只能查询模型输出需要用替代模型或者零阶优化。这个项目里我会重点实现白盒的梯度攻击因为最容易复现也能讲清楚核心原理。2. 核心技术原理与攻击方法2.1 图神经网络到底怎么“看”图的要攻击GNN至少得懂它的前向传播。最常见的图卷积网络GCN的层间传播公式是H^{(l1)} σ( \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} H^{(l)} W^{(l)} )其中 \tilde{A} A I 是加了自环的邻接矩阵\tilde{D} 是度矩阵W 是权重矩阵。说白了每个节点在每一层都要把自己邻居的特征向量做一次归一化求和再做个线性变换最后过个激活函数。经过多层堆叠每个节点的表示就蕴含了多跳邻居的信息。理解了这一点攻击的思路就很直接了如果我能让某个节点的邻居特征“脏了”或者让邻居集合本身发生变化那这个节点的最终表示就会偏移分类结果自然就乱了。关键是怎么量化“偏移”对分类损失的影响。这时就要用到梯度反传也就是攻击的“矛尖”。2.2 基于梯度的攻击方法如何在图上落地最经典的梯度攻击是FGSMFast Gradient Sign Method公式是X_{adv} X ε · sign(∇_X L)放在图里如果攻击目标是节点分类我们可以计算分类损失对特征矩阵 X 的梯度然后沿着梯度方向加上一个符号扰动让损失变大导致分类错误。对于邻接矩阵 A因为它是离散的我们不能直接用梯度但可以计算“梯度分数”——比如对每个候选边 (u, v)我们把A_{u,v}从0改为1或从1改为0然后看损失变化的方向。这本质上是“梯度近似”下的贪心搜索。还有一种更精细的攻击算法叫PGDProjected Gradient Descent它在FGSM的基础上多次迭代并且每次把扰动投影回一个约束球内。在图上可以应用于特征但对结构攻击通常用的是PGD的变体或专门的离散优化方法。我这里会先讲FGSM特征攻击再引出一个简单的结构攻击示例这样读者能循序渐进。3. Python实战实现一个简单攻击示例3.1 环境准备与数据加载建议用colab或者本地虚拟环境我用的配置是Python 3.9 PyTorch 2.0 PyTorch Geometric DeepRobust。DeepRobust是一个专门做对抗鲁棒性的库里面有现成的图数据加载和GNN模型非常省事。安装命令pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install torch-geometric pip install deeprobust我推荐用Cora这个经典引文网络数据集2708个节点5429条边每个节点是论文特征是一个1433维的词袋向量类别有7种。加载方式from deeprobust.graph.data import Dataset from deeprobust.graph.defense import GCN data Dataset(root/tmp/, namecora) adj, features, labels data.adj, data.features, data.labels idx_train, idx_val, idx_test data.idx_train, data.idx_val, data.idx_test注意DeepRobust返回的adj是scipy稀疏矩阵features也是一个scipy稀疏矩阵。转换的时候要小心别直接转成dense内存会爆。3.2 训练一个被攻击的GCN模型我们要先把目标模型训练出来这样后续才有攻击对象。这里用DeepRobust自带的GCN实现import torch import numpy as np from deeprobust.graph.defense import GCN from deeprobust.graph.utils import preprocess # 预处理归一化邻接矩阵和特征 adj_normalized preprocess(adj).toarray() features_normalized preprocess(features).toarray() # 训练模型 model GCN(nfeatfeatures.shape[1], nhid16, nclasslabels.max()1, dropout0.5) model model.to(cuda) model.fit(features_normalized, adj_normalized, labels, idx_train, idx_val, train_iters200) model.eval() # 评估原始测试集准确率 acc model.test(idx_test) print(fClean test acc: {acc:.4f})在这个数据集上训练好的GCN测试准确率通常在80%左右这个就是我们的“基线”。3.3 特征攻击FGSM实现攻击目标可以选“无目标攻击”即只要让模型把目标节点分错就行。我们随机挑一小部分测试节点作为攻击对象对它们的特征加扰动。from deeprobust.graph.attack import FGSM # 初始化攻击器这里attack_type可以是features或structure attacker FGSM(modelmodel, nnodesfeatures.shape[0], attack_structureFalse, attack_featuresTrue) attacker.attack(features, adj_normalized, labels, idx_test, perturbations10) modified_features attacker.modified_features # 这是numpy矩阵 # 用修改后的特征重新预测 model.eval() with torch.no_grad(): output model(torch.FloatTensor(modified_features).to(cuda), torch.FloatTensor(adj_normalized).to(cuda)) predicted output.argmax(1).cpu().numpy() # 对比攻击前后正确率 correct_before np.sum(labels[idx_test] model.predict(torch.FloatTensor(features_normalized).to(cuda), torch.FloatTensor(adj_normalized).to(cuda)).cpu().numpy()[idx_test]) correct_after np.sum(labels[idx_test] predicted[idx_test]) print(fBefore: {correct_before}/{len(idx_test)}, After: {correct_after}/{len(idx_test)})这里perturbations10表示最多修改10个特征元素。对1433维的特征来说这是非常微小的扰动但测试准确率会掉一截说明攻击有效。我实测时原来80%的准确率会降到70%左右比较明显。3.4 结构攻击最简单的贪心攻击结构攻击不能直接套FGSM但我们可以用梯度指导的思想做贪心。核心是计算每条候选边对损失的影响。简化版做法遍历某个目标节点的邻居候选尝试翻转一条边然后计算模型loss的变化挑loss增长最大的那条边翻转。def greedy_structure_attack(model, adj, features, labels, target_node, n_perturbations1): adj adj.copy() for _ in range(n_perturbations): current_loss compute_loss(model, adj, features, labels, target_node) best_edge None best_loss current_loss # 遍历所有非自环的节点对尝试加边或删边 for i in range(adj.shape[0]): for j in range(i1, adj.shape[0]): if adj[i,j] 1: # 尝试删边 adj_temp adj.copy() adj_temp[i,j] 0; adj_temp[j,i] 0 new_loss compute_loss(model, adj_temp, features, labels, target_node) if new_loss best_loss: best_loss new_loss best_edge (i,j,del) else: # 尝试加边 adj_temp adj.copy() adj_temp[i,j] 1; adj_temp[j,i] 1 new_loss compute_loss(model, adj_temp, features, labels, target_node) if new_loss best_loss: best_loss new_loss best_edge (i,j,add) if best_edge is None: break if best_edge[2] del: adj[best_edge[0], best_edge[1]] 0 adj[best_edge[1], best_edge[0]] 0 else: adj[best_edge[0], best_edge[1]] 1 adj[best_edge[1], best_edge[0]] 1 return adj实际写代码时这个双重循环会非常慢因为Cora有2708个节点边对是几百万级别。所以通常不会用全图贪心而是限制在目标节点的一小跳或二跳邻居范围内。比如只考虑与目标节点直接相连的候选边或者只考虑“添加边”到那些与目标节点特征相似但不是邻居的节点上。这里我提供一个更高效的简化版只攻击一个节点且只考虑添加一条边到这个节点与某个“高相似度”的非邻居之间。def fast_structure_attack(model, adj, features, labels, target_node): # 计算目标节点与所有其他节点的特征相似度余弦相似度 target_feat features[target_node].reshape(1, -1) sim cosine_similarity(target_feat, features).flatten() # 找出非邻居且相似度最高的节点 neighbors set(np.nonzero(adj[target_node])[0]) candidates [i for i in range(len(sim)) if i not in neighbors and i ! target_node] candidates.sort(keylambda x: sim[x], reverseTrue) # 选择top1候选加边 chosen candidates[0] adj[target_node, chosen] 1 adj[chosen, target_node] 1 return adj加边目标的选择逻辑特征越相似的节点往往在嵌入空间里也靠得近强行连接会让目标节点聚合到更多“相似类”信息从而改变分类决策。我实测下来攻击单节点成功率很高几乎能让目标节点从正确类别跳到错误类别。3.5 攻击效果评估与可视化攻击完不能光看准确率还要验证扰动是否真的“微小”。对特征攻击可以统计修改的特征值占比和平均偏移量对结构攻击可以计算修改的边数占原图边数的比例。我一般用这两个指标Accuracy Drop攻击前后测试集准确率差值。Perturbation Rate实际修改的元素数 / 总元素数。另外用TSNE把攻击前后节点嵌入投影到二维空间能直观看到目标节点被“拉”到了别的类簇里。这个可视化建议配上作为博文展示很有说服力。4. 常见问题与实操心得4.1 稀疏矩阵操作导致的内存崩溃这是我第一次跑实验时踩到最大的坑。DeepRobust返回的是scipy稀疏矩阵如果直接用.toarray()转成稠密矩阵2708×2708的邻接矩阵还好但如果是更大规模的数据集比如Reddit或PubMed直接转稠密矩阵直接OOM。解决办法是尽可能保留稀疏格式PyTorch Geometric里可以直接用SparseTensor。但在DeepRobust里很多方法要求numpy数组所以我一般只对目标节点的子图转稠密全图操作始终用稀疏矩阵。4.2 梯度传播在离散操作上的失谐FGSM特征攻击相对顺滑因为特征是连续的。但结构攻击时模型参数是固定的邻接矩阵是离散的我们不能直接把梯度传给adj。我最初尝试直接用adj.grad来做优化结果一跑就报错因为PyTorch不支持对整型张量计算梯度。后来我的做法是把邻接矩阵转换为浮点张量并设置为可训练然后通过阶梯函数如sigmoid的近似来松弛离散性但这样会引入额外误差。所以最终结构攻击还是用贪心或启发式算法最稳。4.3 扰动预算到底设多少合理扰动预算太大攻击效果肯定好但防御者一眼就看出来图被改了。太小又攻击不动。我常用的经验值是特征攻击的扰动元素数控制在总特征数的1%以内结构攻击的边改动控制在总边数的0.5%以内。拿Cora为例特征总数是2708×1433≈388万个1%就是近4万个元素但实际攻击往往只需要几百个元素就能有效根本用不到1%。所以可以先从0.01%起步逐步增加观察准确率变化曲线找到最小有效扰动。4.4 评估时一定要注意数据泄漏训练模型和生成攻击扰动必须用不同的数据子集否则会有信息泄漏导致评估结果虚高。标准做法是只用训练集来训练模型攻击扰动只能在测试集上生成和评估不能使用验证集的任何信息。我在做黑盒攻击时也容易犯这个错后来专门写了个函数来隔离数据切片避免踩坑。5. 扩展方向与个人体会如果你把这个项目做完还能往两个方向扩展。一是防御端的对抗训练把生成的对抗样本混入训练数据重新训练模型看鲁棒性是否提升。我在实验中发现经过对抗训练的GCN面对同样的攻击准确率下降幅度能减少一半以上。二是黑盒攻击假设你拿不到模型参数只能通过查询API拿到预测结果这时候可以用替代模型或zeroth-order优化来做。我在另一组实验里用替代模型迁移攻击成功率也能达到白盒的70%左右。我个人实际操作中的体会是图神经网络的安全研究比图像领域更“心累”因为图的结构非欧特性让很多常规的做法都要重新设计。但正是这种挑战让它很有趣。你只要多跑几组实验把FGSM特征攻击和贪心结构攻击吃透后续理解PGD、Nettack、Metattack这些高级算法就不是难事。最后再分享一个小技巧写攻击代码时一定把每次实验用的随机种子、扰动预算和模型准确率都记录下来方便复现结果。我自己就吃过亏跑了一晚上实验结果忘了保存配置第二天完全复现不出来白费功夫。这个项目本身并不复杂核心就是围绕“离散结构连续特征”这两个攻击面做文章。你用Python跑通了上面这些代码就已经迈出了图对抗攻击研究的第一步后面完全可以按着自己的需求去改造算法做出更有意思的东西。本文还有配套的精品资源点击获取