图神经网络对抗性攻击实战:从原理到Python实现 📅 发布时间:2026/8/27 6:42:23 👁 浏览次数: 简介深度学习模型在高维空间中的决策边界异常复杂即使微小的输入扰动也可能引发输出剧变这一脆弱性在非欧几里得数据结构上表现得更为突出。图神经网络GNN通过消息传递机制聚合邻居节点信息使得攻击者无需修改目标节点自身仅靠增删图中的边就能操纵模型预测结果。理解这类威胁不仅是AI安全研究的重要课题更直接关系到推荐系统、社交网络分析、风控等实际应用的可靠性。围绕对抗性攻击这一核心概念本文从图数据与GNN的基本原理出发解析投毒攻击与逃逸攻击的异同并结合梯度方法演示如何在Cora数据集上实施图结构攻击、评估攻击效果最后讨论对抗训练与结构预处理等防御策略形成从攻击到防御的完整闭环。 近几年做图神经网络相关的项目时我最大的感受是模型效果越做越好但大家对模型“为什么可靠”的关注远远不够。直到我开始接触对抗性攻击这个方向才意识到图神经网络在真实场景中面临的威胁远比想象中大。这篇文章就围绕“针对图形数据的神经网络的对抗性攻击”这个主题用Python完整走一遍攻击的流程——从原理到代码实现再到防御思路全部摊开来讲。如果你正在研究图神经网络GNN或者你负责的推荐系统、社交网络分析、分子性质预测等项目里用到了图结构数据那么这篇文章特别适合你。即便你只是对AI安全感兴趣也能从中理解一个核心问题为什么说神经网络虽然强大但也很“脆弱”。1. 项目核心概念与背景准备1.1 图数据与图神经网络到底是什么要理解对抗性攻击先得把“图形数据”这四个字落到实处。这里的图形数据不是指图片image而是指图结构数据graph。图由节点和边组成比如社交网络里每个人是一个节点好友关系是边电商系统里商品是节点用户和商品之间的购买行为是边分子结构里原子是节点化学键是边。图神经网络GNN就是专门处理这类数据的一类神经网络。传统神经网络处理的是规则排列的数据比如图片的像素矩阵、文本的序列而图数据是不规则的每个节点的邻居数量都不一样。GNN的核心思想是“消息传递”——每个节点通过聚合邻居节点的特征来更新自己的表示。这个过程反复迭代节点就能学到包含局部结构信息的特征向量。举一个容易理解的例子在社交网络中预测某个用户是不是“潜在活跃用户”。只看这个用户自己的资料可能不够但如果他的好友普遍活跃他大概率也活跃。GNN正是利用这种邻居信息来做预测它比传统方法更能捕捉图结构中的关联模式。1.2 为什么神经网络在图形数据上也会“怕”攻击很多人对对抗性攻击的第一印象停留在图像领域——在图片上添加肉眼几乎不可见的噪点就能让分类模型把熊猫识别成长臂猿。这类攻击的本质是模型的决策边界在高维空间里非常复杂输入上的微小扰动经过层层非线性变换后被放大成输出上的巨大差异。图数据上的攻击有相似之处但更特殊也更“危险”。因为图数据有两个攻击面一个是节点特征比如篡改用户的年龄、兴趣标签另一个是图结构本身比如在社交网络中随意添加一条好友关系、删除一条关注关系。也就是说攻击者不需要修改目标节点自身只需要改变它的邻居关系就能让模型判断出错。这意味着图神经网络的鲁棒性问题比传统神经网络更复杂。我在实际项目中曾经遇到过一个案例一个基于GNN的信用风控模型在对某些用户进行风险评估时出现明显误判。后来深入排查才发现这些用户在图结构上被人为构造了一些“虚假关联”使得模型聚合了大量误导性的邻居信息。这就是结构攻击的现实影响——它甚至不需要黑进数据库只要能在图上“制造关系”就够了。1.3 环境准备与工具选型在动手写代码之前先把环境准备好。我用的是Python 3.9配合以下核心库PyTorch 2.0深度学习框架用于搭建和训练GNN模型PyTorch GeometricPyG图神经网络的官方扩展库提供了Cora、Citeseer等经典图数据集的加载接口DeepWalk / Node2Vec图嵌入工具用于生成节点的向量表示攻击后的嵌入质量评估会用到scikit-learn评估指标计算比如准确率、F1值networkx图结构的可视化与分析安装PyG时有个小坑需要注意它依赖PyTorch的版本直接用pip install torch-geometric大概率会报错。我习惯先去PyG官网查对应PyTorch版本的安装命令再通过官方提供的whl地址安装各依赖包这样最稳。pip install torch torchvision pip install torch-geometric pip install networkx scikit-learn2. 对抗性攻击原理与方案设计2.1 对抗性攻击的基本逻辑对抗性攻击的核心目标可以概括为一句话在尽量不破坏数据“正常性”的前提下让模型产生错误预测。攻击者的思路通常是定义一个损失函数——衡量模型输出与攻击目标之间的差距——然后通过梯度信息反向更新输入数据而不是更新模型参数。在图数据上输入数据有两部分特征矩阵 $X$ 和邻接矩阵 $A$。攻击可以发生在特征层面也可以发生在结构层面。特征攻击等价于在 $X$ 上添加扰动结构攻击则是在 $A$ 上增删边。如果采用梯度法核心公式是$$ X X \epsilon \cdot \text{sign}(\nabla_X L) $$其中 $\epsilon$ 是扰动幅度$L$ 是攻击目标对应的损失。这个公式和图像对抗攻击FGSM完全一致。区别在于图上的扰动对象还包括邻接矩阵而且邻接矩阵是离散的边要么存在要么不存在不能直接沿用连续梯度需要特殊处理。2.2 图数据攻击的三大类别图数据对抗性攻击的分类方式很多但从攻击发生的时间点划分最直观投毒攻击Poisoning Attack发生在模型训练之前。攻击者修改训练数据的一部分让模型在训练阶段就“学到”错误的模式。这类攻击隐蔽性强因为数据看起来只是有些噪声模型却在潜移默化中被带偏了。经典方法有Nettack它同时修改节点的特征和局部图结构让训练出来的模型对目标节点产生错误分类。逃逸攻击Evasion Attack发生在模型训练完成之后。攻击者保持模型参数不变只在推理阶段修改输入数据。比如模型上线了攻击者往自己的社交账号里添加一批特定类型的“好友”让推荐系统误判他的偏好。逃逸攻击更容易实施因为攻击者不需要了解训练过程只需要知道自己面对的是一个训练好的模型。图结构攻击Graph Structure Attack专门针对邻接矩阵的攻击也就是增删边。比如在社交网络中删除两个用户之间的关注关系或者人为添加大量“僵尸粉”关系都可以改变GNN聚合到的邻居特征从而影响预测结果。GraphSAGE那篇论文里也讨论过——GNN的预测严重依赖局部邻域局部邻域一旦被污染预测必然受影响。2.3 攻击方案的完整设计思路我们这次选择的方案是白盒环境下的图结构投毒攻击。整个过程如下加载Cora数据集——论文引用网络数据集2708个节点、5429条边、7个类别是图学习领域的“Hello World”。用DeepWalk生成节点的嵌入向量作为后续评估的特征输入。训练一个简单的GCN模型作为攻击目标。针对选定目标节点通过梯度计算识别“最有影响力的边”实施结构扰动增删边。重新生成嵌入向量模拟重新训练评估模型在目标节点上的分类结果变化。我选择DeepWalkGCN的组合是因为两者都是经典且轻量的方案易于复现和验证。DeepWalk负责把网络结构转化为嵌入特征GCN则在这个特征上做节点分类。攻击前后对比嵌入空间的变化与分类结果的变化可以很清晰地看到攻击效果。3. Python实现与核心代码解析3.1 数据准备与预处理Cora数据集在PyG里直接可以加载from torch_geometric.datasets import Planetoid from torch_geometric.transforms import NormalizeFeatures dataset Planetoid(root/tmp/Cora, nameCora, transformNormalizeFeatures()) data dataset[0] # 查看数据维度 print(f节点数量: {data.num_nodes}) print(f边的数量: {data.num_edges}) print(f特征维度: {data.num_node_features}) print(f类别数量: {dataset.num_classes})输出结果节点数量: 2708 边的数量: 5429 特征维度: 1433 类别数量: 7Cora的节点特征是词袋向量维度1433每个维度代表某个词是否出现在论文摘要中。它是一个稀疏数据集这也是图数据比较常见的特点——节点的特征维度往往很高但大部分是0。在进入攻击流程之前需要把邻接矩阵转为稠密形式或者稀疏张量。攻击过程中要计算边的梯度稠密矩阵在运算上更直观但内存消耗大。2708×2708的矩阵按float32算大约是28MB可以接受import torch adj torch.zeros((data.num_nodes, data.num_nodes)) edge_index data.edge_index adj[edge_index[0], edge_index[1]] 1.0 adj[edge_index[1], edge_index[0]] 1.0 # Cora是无向图提示Cora是无向图所以邻接矩阵必须是对称的增删边时要保证对称操作。这一步漏了的话后面的模型训练会出问题。3.2 搭建GCN分类模型用PyG搭建一个两层GCN模型这是GNN里最常见的基准模型架构import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCN(torch.nn.Module): def __init__(self, in_channels, hidden_channels, out_channels): super().__init__() self.conv1 GCNConv(in_channels, hidden_channels) self.conv2 GCNConv(hidden_channels, out_channels) def forward(self, x, edge_index): x self.conv1(x, edge_index) x F.relu(x) x F.dropout(x, p0.5, trainingself.training) x self.conv2(x, edge_index) return F.log_softmax(x, dim1)训练代码不必多说了标准流程Adam优化器、负对数似然损失、200个epoch。这里有个细节Cora的划分已经固定好了train_mask、val_mask、test_mask不用自己再做数据切分这保证了不同方法之间的对比公平性。3.3 实现图结构攻击的核心逻辑这是整个项目的关键部分。思路是选定一个目标节点 $v$找出所有与 $v$ 直接相连的节点作为候选邻居然后评估“删除某条边”或“添加某条边”对模型预测的影响选择影响最大的操作执行。梯度计算的数学逻辑对于目标节点 $v$GCN预测其属于类别 $c$ 的logit为 $z_c$。$z_c$ 是目标节点经过两层消息传递得到的第二层卷积计算如下$$ z_c^{(v)} \sum_{u \in N(v)} \frac{1}{\sqrt{d_v d_u}} h_u^{(1)} W^{(2)}_{:,c} $$其中 $h_u^{(1)}$ 是节点 $u$ 在第一层的输出$d_v$ 和 $d_u$ 是节点的度数。可以看到$z_c^{(v)}$ 直接依赖邻居节点的表示。如果去掉邻居 $t$梯度变化可以通过 $\nabla_{A_{vt}} z_c^{(v)}$ 近似。具体实现时不需要手动推导公式直接调用PyTorch的autograd即可def compute_edge_gradients(model, data, target_node): 计算目标节点所有邻居边的梯度影响 model.eval() x, edge_index data.x, data.edge_index x.requires_grad_(True) # 前向传播 logits model(x, edge_index) target_logit logits[target_node, data.y[target_node]] # 反向传播获取特征梯度 model.zero_grad() target_logit.backward(retain_graphTrue) x_grad x.grad.clone() # 获取目标节点的邻居 neighbors edge_index[1][edge_index[0] target_node] # 计算每一条邻居边的“得分”——删除该边对目标节点预测的影响程度 edge_scores [] hidden model.conv1(x, edge_index) hidden F.relu(hidden) for neighbor in neighbors: # 删除边 (target_node, neighbor) 后目标节点将无法聚合该邻居的特征 # 以邻接矩阵元素对应梯度大小的近似 score torch.abs(hidden[neighbor] x_grad[target_node]) edge_scores.append((inf score, neighbor)) return sorted(edge_scores, reverseTrue)这段代码的核心逻辑是通过梯度识别哪些邻居对目标节点的预测贡献最大然后把贡献最大的边删除模型的预测就会发生剧烈变化。这就是结构攻击的梯度近似解法——复杂度低思路直接。完整攻击函数如下def structural_attack(model, data, target_node, n_perturbations3): 对目标节点执行图结构攻击 n_perturbations: 修改的边数量 model.eval() edge_index data.edge_index.clone() adj torch.zeros((data.num_nodes, data.num_nodes)) adj[edge_index[0], edge_index[1]] 1.0 adj[edge_index[1], edge_index[0]] 1.0 original_pred model(data.x, edge_index).argmax(dim1)[target_node].item() print(f攻击前预测类别: {original_pred}, 真实类别: {data.y[target_node].item()}) for _ in range(n_perturbations): neighbors (adj[target_node] 1).nonzero().squeeze(1) if len(neighbors) 0: break # 计算每条边的梯度得分 x data.x.clone() x.requires_grad_(True) logits model(x, edge_index) logit logits[target_node, data.y[target_node]] model.zero_grad() logit.backward(retain_graphTrue) # 找梯度最大的邻居边并删除 grad_scores [] for nb in neighbors: grad torch.abs(x.grad[target_node] x[nb]).item() grad_scores.append((grad, nb.item())) grad_scores.sort(reverseTrue) _, worst_neighbor grad_scores[0] # 删除这条边双向删除 adj[target_node, worst_neighbor] 0 adj[worst_neighbor, target_node] 0 edge_index adj.nonzero().t().contiguous() print(f第{_1}次扰动: 删除边 ({target_node}, {worst_neighbor})) new_pred model(data.x, edge_index).argmax(dim1).item() print(f攻击后预测类别: {new_pred}, 真实类别: {data.y[target_node].item()}) return edge_index, new_pred ! original_pred注意这里删除的边数量不宜太多一般情况下3到5条就足够让模型预测翻转。我之前测试时发现删除太多边反而可能因为节点度数过低导致模型输出不稳定效果反而变差。3.4 攻击效果评估攻击完成后需要量化评估攻击效果。我一般用两个指标攻击成功率Attack Success Rate目标节点预测标签发生变化的比例。模型整体性能变化攻击前后的分类准确率、F1值对比。这个指标反映攻击对全局是否有影响虽然攻击是针对单个节点的但如果扰动幅度过大会波及到其他节点的预测。from sklearn.metrics import accuracy_score, f1_score def evaluate(model, data, edge_index): model.eval() with torch.no_grad(): pred model(data.x, edge_index).argmax(dim1) acc accuracy_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu()) f1 f1_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu(), averagemacro) return acc, f1 # 攻击前 acc_before, f1_before evaluate(model, data, data.edge_index) # 攻击后 acc_after, f1_after evaluate(model, data, attacked_edge_index) print(f攻击前测试集准确率: {acc_before:.4f}, F1: {f1_before:.4f}) print(f攻击后测试集准确率: {acc_after:.4f}, F1: {f1_after:.4f})3.5 从DeepWalk视角看攻击前后嵌入变化为了更直观地观察攻击的影响我还用DeepWalk生成了攻击前后的节点嵌入并用t-SNE可视化。DeepWalk的原理是通过随机游走生成节点序列再通过Skip-Gram学习节点向量。它的特点是如果图结构发生变化节点嵌入也会变化。from karateclub import DeepWalk def generate_embeddings(edge_index, num_nodes, dim128): 根据邻接关系生成DeepWalk嵌入 from karateclub import DeepWalk import networkx as nx G nx.Graph() G.add_nodes_from(range(num_nodes)) edges edge_index.t().numpy() G.add_edges_from(edges) model DeepWalk(walk_number10, walk_length80, dimensionsdim) model.fit(G) embeddings model.get_embedding() return embeddings把攻击前和攻击后的嵌入用t-SNE降到二维后可视化会发现目标节点在嵌入空间中的位置发生了明显偏移——原本它和同类节点聚在一起攻击后它可能游离到另一个类别的区域。这说明结构攻击不仅改变了预测标签还从根本上改变了节点在特征空间中的位置。4. 常见问题与排查技巧实录4.1 训练不稳定与超参数调节图神经网络训练有个特点不同随机种子下同一个模型的准确率可能差别很大。我建议在复现攻击效果时固定住三个关键随机种子——PyTorch的、NumPy的和Python内置的randomimport random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)GCN的隐藏层维度我选的是16这个值在Cora上是经验最优。如果选得太大比如128模型容易过拟合训练集攻击效果反而会变差因为模型已经“死记硬背”了训练数据对局部结构的依赖降低。如果选太小比如4模型本身表达能力不足攻击前后都可能预测不准无法清晰区分攻击效果。4.2 梯度计算中的常见报错与解决方案报错一Output 0 of BackwardHookFunctionBackward is a view and is being modified inplace这个是因为在backward之后又对输入张量做了原地修改。解决方法是每次计算梯度前重新克隆一份特征矩阵x data.x.clone() x.requires_grad_(True)报错二IndexError: index out of range in self这通常发生在边索引超出节点范围时检查一下删边后edge_index是否还有效。我用的是邻接矩阵重建的方式基本不会出现这个问题但如果直接操作edge_index的tensor切片就很容易踩坑。报错三CUDA out of memoryCora数据量小一般不会爆显存。但如果换成更大的数据集比如Reddit或者ogbn-arxiv稠密邻接矩阵会直接撑爆显存。这时候建议换成稀疏张量或者采用基于采样的GNN变体比如GraphSAGE它每次只聚合一部分邻居内存压力小很多。4.3 攻击失败的排查思路有时候攻击尝试多次也没有让预测翻转我总结下来通常有以下几个原因目标节点度数过低如果一个节点只有1到2个邻居删掉一条边基本就“孤立”了GCN聚合不到有效信息预测标签可能会保持不变甚至变成默认类。这种情况下可以尝试添加恶意边而不是删除边。目标节点特征过于明确如果目标节点的特征本身就极度偏向某个类别光靠结构扰动很难改变预测。可以考虑同时做特征攻击修改目标节点的特征向量和结构攻击双管齐下。模型过于鲁棒Dropout从0.5降到0.2或者模型隐藏层用更大的hidden dim都会让模型对扰动更不敏感。攻击方的难度也就相应增加。5. 防御思路与后续扩展5.1 简单有效的防御策略攻击做完之后自然要考虑如何防御。图神经网络的防御方向远不如图像领域成熟但有几类思路已经得到验证对抗训练Adversarial Training在训练过程中不断生成对抗样本把“攻击后的图”混合进训练集让模型提前适应扰动。这个方法实现简单缺点是计算开销大每轮训练都要重新生成对抗样本。图结构预处理在喂给模型之前对邻接矩阵做“清洗”。比如计算每个节点邻居的特征相似度把相似度极低的边可能是恶意添加的删掉。这相当于一个前置过滤层。GNN架构层面的鲁棒设计比如在消息传播过程中引入注意力机制让模型自动降低“可疑邻居”的权重或者采用更强的聚合器MedianAggregator对极端邻居特征不敏感。GraphSAGE论文里的LSTM聚合器在这方面的鲁棒性比Mean聚合器要好。5.2 个人经验总结与扩展方向做这个项目最大的收获是理解了“度量学习”和“模型鲁棒性”之间的关系。一个看起来准确率很高的GNN模型在局部结构扰动之下可能满盘皆输。这说明模型学到的表示并没有我们想象中那么稳健。从扩展方向来说这个项目还可以继续深入黑盒攻击不依赖模型梯度只通过查询模型的预测结果来判断哪些边更关键。方法有启发式比如基于节点中心性和基于替代模型的攻击。这类攻击更贴近真实攻击者——真实世界中你很难拿到模型的梯度。投毒攻击的时间维度前面做的是单次攻击实际威胁更大的是持续投毒——攻击者在训练数据中分批次混入恶意图结构让模型在每次增量训练后越陷越深。将攻击拓展到异质图Cora是同质图只有一种节点和一种边。在电商、社交等真实场景中异质图才是常态。异质图上有多种类型的节点和边攻击面更广攻击难度也更大。最后分享一个我实际踩过的坑攻击后的嵌入可视化很好看但不能只凭可视化就判断攻击“成功”。视觉上的偏移不一定对应预测标签的变化必须以分类指标为准。我在初版实验时t-SNE图显示目标节点已经明显移动但预测标签根本没变——后来发现是因为DeepWalk的嵌入经过分类器时决策边界比较宽节点在小范围内移动影响不了分类结果。所以评估攻击效果时多看几个指标永远是值得的。本文还有配套的精品资源点击获取