资讯动态

GCN链路预测实战:Cora与Citeseer上的数据划分、负采样与避坑指南

发布时间:2026/10/16 3:23:20 来源:尧图企业网站定制
简介一套面向图神经网络学习者的链路预测Python工程基于GCN在Cora与Citeseer学术引文网络上完成链接预测任务适合希望用实际代码理解图卷积原理的开发者。Cora数据集含2708篇论文、分属7个类别Citeseer数据集含3312篇论文、覆盖6个类别两者节点特征均采用词袋模型能在同一套代码下对比不同规模图结构上的模型表现。压缩包共3个文件均为Python脚本整体约7KB分别承担数据预处理、GCN模型构建、训练与评估三部分前者读取并构造邻接矩阵与特征矩阵模型部分实现多层图卷积与全连接层训练流程则基于二元交叉熵和Adam优化器完成并输出AUC/准确率等指标。已有3070人学习下载适合希望通过具体实验快速掌握GCN在链路预测中的数据构建与调参思路。资源虽小但完整跑通了一个典型GCN链路预测流程读者可就着代码逐步理解负采样、特征归一化、超参数选择等关键细节整体模块边界清晰便于二次开发和扩展实验。1. 链路预测GCN 在 Cora 和 Citeseer 上到底能不能打做图神经网络实验的人十有八九是从 Cora 开始的但一做链路预测就发现不对劲Cora 是个引文网络节点是论文边是引用关系它天生是为节点分类设计的属性完备、图结构清晰却没有专门为链路预测准备过划分和负样本。直接拿节点分类那套流程硬套链路预测训练集里全是已存在的边验证集里也是模型根本没见过「不存在的边」长什么样AUC 刷得再高都是虚的。我最初跑通一个看似完美的 GCN 链路预测脚本时验证集 AUC 到了 0.93换到 Citeseer 直接掉到 0.78后来才发现问题出在数据划分和负采样策略上而不是模型本身。这篇笔记就把我在 Cora 和 Citeseer 上用 GCN 做链路预测的完整流程、参数设置和踩过的坑一次讲清楚适合已经跑过 GCN 节点分类、想扩展到链路预测的从业者。2. 数据集预处理Cora 和 Citeseer 的格式差异与边索引陷阱2.1 两份数据集的真实差异别把 Cora 的流程无脑套到 CiteseerCora 和 Citeseer 虽然都来自引文网络格式也几乎一样都有x、y、adj三个文件但实际使用中差异非常大。Cora 有 2708 个节点、5429 条边、1433 维特征Citeseer 有 3327 个节点、4732 条边、3703 维特征。单看数字Citeseer 的边比 Cora 还少但特征维度却是 Cora 的两倍多这意味着 GCN 的参数量会明显增加训练时对 dropout 和权重衰减更敏感。更隐蔽的差异在边文件格式。Cora 的边文件是每行两个 ID从 0 开始连续编号直接读就行。Citeseer 的边文件虽然也是每行两个 ID但有少量节点在特征矩阵里存在、在边文件里却完全没有出现或者反过来。我在这上面翻过一次车直接按 Cora 的读取方式加载 Citeseer结果邻接矩阵的维度对不上报错信息还特别隐晦只提示索引越界。import numpy as np import scipy.sparse as sp def load_graph_data(dataset_name): 加载 Cora 或 Citeseer 的图结构返回归一化邻接矩阵和边索引。 dataset_name: cora 或 citeseer # 读取边文件 edges [] with open(fdata/{dataset_name}/{dataset_name}.cites, r) as f: for line in f: parts line.strip().split(\t) if len(parts) 2: edges.append([int(parts[0]), int(parts[1])]) edges np.array(edges, dtypenp.int64) # 找出去重后的最大节点编号 num_nodes max(edges.max(), features.shape[0]) # 注意这里要用最大值兜底 # 构建稀疏邻接矩阵 adj sp.coo_matrix( (np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])), shape(num_nodes, num_nodes), dtypenp.float32 ) # 对称化处理因为引文网络理论上是无向的 adj adj adj.T.multiply(adj.T adj) return adj, edges这里有个关键参数值得注意num_nodes不能直接取特征矩阵的行数而应该取特征矩阵和边文件里节点 ID 最大值的较大者。Citeseer 的特征数据里有个别 ID 超过了边文件的覆盖范围如果按 Cora 的习惯只用特征矩阵行数后面做归一化时稀疏矩阵维度对不上直接报错。常见做法是打印一下features.shape和edges.max()看到差异心里就有数了。2.2 特征归一化稀疏矩阵的坑与标准化策略图神经网络的特征预处理和普通神经网络不太一样。Cora 和 Citeseer 的特征都是词袋向量每个节点的特征向量表示这篇论文是否包含某个词所以数值基本是 0 和 1但分布非常稀疏平均非零比例不到 2%。如果不做任何处理直接喂进 GCN第一层卷积就会被少数高频词的数值主导模型的泛化能力很差。我一般不会用标准化的(x - mean) / std因为稀疏矩阵做减法和除法会产生大量不必要的存储开销而且标准化对二值特征的意义不大。更实用的做法是只做行归一化让每个节点的特征向量模长为 1def normalize_features(features): 对稀疏特征矩阵做行归一化保留稀疏性。 row_sum np.array(features.sum(1)).flatten() row_sum[row_sum 0] 1 # 避免除零 row_inv np.power(row_sum, -1).flatten() row_inv[np.isinf(row_inv)] 0. return sp.diags(row_inv).dot(features) features normalize_features(features)如果你用 PyTorch 而不用 SciPy 的稀疏矩阵有个更省事的方法直接用torch.nn.functional.normalize但要注意先转成稠密张量。Cora 的 2708 x 1433 转稠密才不到 4MB 内存Citeseer 的 3327 x 3703 也不到 15MB对现代机器来说完全能接受。只有在特征维度更大、比如上万维时才考虑保持稀疏格式。另一个容易被忽略的点是邻接矩阵的归一化方式。GCN 原文用的是对称归一化D^(-1/2) A D^(-1/2)这个公式的作用是同时考虑节点自身的度对消息传递的影响避免度数大的节点在聚合时数值过大。实现时有个细节别忘了加自环也就是把邻接矩阵对角线上加 1不然节点自己上一层的特征在聚合时就丢了。def normalize_adj(adj): 对称归一化邻接矩阵GCN 标准做法。 adj adj sp.eye(adj.shape[0]) # 加自环 d np.array(adj.sum(1)).flatten() d_inv_sqrt np.power(d, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0. d_mat_inv_sqrt sp.diags(d_inv_sqrt) return adj.dot(d_mat_inv_sqrt).transpose().dot(d_mat_inv_sqrt).tocoo()这个函数返回的是 COO 格式后面转成 PyTorch 稀疏张量时可以直接用torch.sparse_coo_tensor不用再转格式。如果发现训练时内存占用异常高检查一下邻接矩阵是不是变成了稠密矩阵一个常见错误是用了.toarray()或者A A.T时不小心把稀疏矩阵转成了 Numpy 数组。3. GCN 层与消息传递从谱域卷积到两层实战3.1 GCN 的数学逻辑为什么邻域聚合等于低通滤波GCN 的核心公式看起来就一行H^(l1) σ(D^(-1/2) A D^(-1/2) H^(l) W^(l))但这行公式背后是谱图理论的结论。简单说这个归一化操作在频域上等价于一个低通滤波器让特征在节点间传播时趋向平滑邻居特征向量经过加权平均后节点自身的差异被淡化而局部结构信息被强化。这也解释了为什么 GCN 在节点分类上表现好——同社区的论文特征本来就相似GCN 相当于在特征空间里做了局部平滑。但链路预测不太一样。链路预测的目标是判断两个节点之间有没有边节点的属性只是辅助信息关键在图结构本身。所以我一般不会直接套用节点分类的 GCN 结构而是把 GCN 当作一个节点嵌入生成器后面再接一个打分函数。常见的打分函数有内积、双线性层、以及 Hadamard 积加 MLP 三种它们的效果差异不小。class GCNLayer(torch.nn.Module): 单层 GCN封装了消息传递和特征变换。 def __init__(self, in_dim, out_dim, dropout0.5): super().__init__() self.linear torch.nn.Linear(in_dim, out_dim, biasFalse) self.dropout torch.nn.Dropout(dropout) def forward(self, x, adj): # x: [num_nodes, in_dim]adj: 归一化稀疏邻接矩阵 support self.linear(x) # 线性变换 output torch.sparse.mm(adj, support) # 邻域聚合 return self.dropout(output)这里torch.sparse.mm是 PyTorch 里稀疏矩阵乘稠密矩阵的标准接口参数上没太多玄学但有个坑邻接矩阵必须是稀疏张量类型而且要保证对称归一化已经做过了。如果直接传稠密矩阵小数据集上没问题节点数到几万的时候显存就撑不住了。3.2 两层 GCN 的参数选择隐藏层维度、dropout 与激活函数两层 GCN 是最经典的配置一层把原始特征映射到隐藏空间第二层把隐藏表示映射到最终的嵌入维度。隐藏层维度我一般取 128这个是经验值——Cora 和 Citeseer 的节点数量都在三千左右128 维足够表达局部结构又不至于过拟合。如果你发现训练集 AUC 很高但验证集差距大先把隐藏层调到 64 试试效果往往会改善。激活函数用 ReLU放在第一层后面。第二层后面不加激活因为嵌入向量要直接进打分函数压缩到 [0,1] 反而丢信息。class GCNEncoder(torch.nn.Module): 两层层 GCN 编码器输出节点嵌入。 def __init__(self, in_dim, hidden_dim128, out_dim128, dropout0.5): super().__init__() self.layer1 GCNLayer(in_dim, hidden_dim, dropout) self.layer2 GCNLayer(hidden_dim, out_dim, dropout) self.act torch.nn.ReLU() def forward(self, x, adj): h self.act(self.layer1(x, adj)) h self.layer2(h, adj) return h # [num_nodes, out_dim]dropout 参数很关键。Citeseer 的特征维度是 3703比 Cora 的 1433 大不少第一层线性变换的参数量接近 47 万在这个数据规模下很容易过拟合。我的习惯是第一层 dropout 给 0.5第二层给 0.3训练集还能稳住验证集也跟得上。如果你用的是 PyTorch Geometric 里的GCNConv记得它默认不带自环处理需要在forward里手动加自环或者设置add_self_loopsTrue——这个参数文档里有但很多人会忽略。3.3 打分函数的选择内积、双线性还是 Hadamard MLP打分函数决定了模型如何判断「两个节点有多大概率存在边」。内积最简单把两个节点的嵌入做点积输出一个标量。双线性层加一个可学习矩阵公式是u^T W v表达能力比内积强但参数量是嵌入维度的平方128 维时会多出一万六千多个参数对小数据集有点重。我实际用下来Hadamard 积加 MLP 的效果最稳定把两个嵌入逐元素相乘得到一个向量再喂进一个两层的 MLP。这个结构相当于让模型自己学习哪些维度的一致性对链路预测更重要而不是像内积那样所有维度默认等权。class LinkPredictor(torch.nn.Module): 打分函数Hadamard 积 MLP def __init__(self, in_dim, hidden_dim64): super().__init__() self.mlp torch.nn.Sequential( torch.nn.Linear(in_dim, hidden_dim), torch.nn.ReLU(), torch.nn.Linear(hidden_dim, 1) ) def forward(self, z_i, z_j): h z_i * z_j # 逐元素乘积 return self.mlp(h).squeeze(-1)唯一要留心的是 MLP 最后一层没有激活函数输出是 logit后续接BCEWithLogitsLoss而不是BCELoss数值稳定性更好。4. 训练流程与评估负采样、数据划分和 AUC 计算4.1 训练集/验证集/测试集划分按边划分不是按节点GCN 节点分类是按节点划分链路预测按边划分这个区别如果搞混了实验结果直接失去意义。链路预测的标准做法是保留 10% 的边作为测试集再保留 5% 的边作为验证集剩下的 85% 参与训练。但还有个容易被忽略的细节测试集里的边对应的两个节点在训练阶段必须出现过。比如测试集有一条边是节点 A 和 B 之间的如果 A 在训练阶段完全没出现过模型对 A 的嵌入就是随机初始化预测自然不准。这在 Cora 和 Citeseer 上因为每个节点度数都不高很容易碰到。解决方法是划分前先做一次连通性检查保证每个测试节点至少有一条训练边。def split_edges(edges, test_frac0.1, val_frac0.05, seed42): 按边划分训练/验证/测试集保证节点覆盖。 rng np.random.RandomState(seed) n_edges edges.shape[0] # 随机打乱边 perm rng.permutation(n_edges) n_test int(n_edges * test_frac) n_val int(n_edges * val_frac) test_edges edges[perm[:n_test]] val_edges edges[perm[n_test:n_test n_val]] train_edges edges[perm[n_test n_val:]] # 检查测试集中的节点是否在训练集中出现 train_nodes set(train_edges.flatten()) for e in test_edges: if e[0] not in train_nodes or e[1] not in train_nodes: print(f警告测试边 ({e[0]}, {e[1]}) 包含未在训练集出现的节点) return train_edges, val_edges, test_edges这个函数是个基础版本Cora 和 Citeseer 上直接跑基本没问题。如果你想更严谨可以用迭代式划分先划分再把不满足覆盖要求的边从测试集挪回训练集重新随机补一条测试边直到所有测试节点都满足要求。4.2 负采样策略随机负采样与难负样本的权衡链路预测本质上是个二分类问题「有边」是正样本「没边」是负样本。但图里的负样本是隐式的——所有不存在的边都是潜在的负样本。Cora 有约 367 万对节点但只有 5429 条边如果全部负样本都参与训练正负比达到 1:1350模型会被负样本淹没。最简单的做法是随机负采样对于每条正边随机生成一条不存在的边作为负样本。这样做速度快但有个问题随机采到的负样本通常距离很远模型很轻松就能判断它们不是边学不到细粒度的结构差异。一个更高级的做法是难负样本采样——倾向于选那些「看起来像正样本」的负样本比如两个节点有共同邻居但本身没有边。这会显著提升模型在测试集上的表现但训练时间变长收敛也可能不稳定。def negative_sampling(adj, num_neg, seed0): 随机负采样从不存在边的节点对中采样。 adj adj.tocoo() existing_edges set(zip(adj.row, adj.col)) all_nodes np.arange(adj.shape[0]) negatives [] rng np.random.RandomState(seed) while len(negatives) num_neg: i rng.choice(all_nodes) j rng.choice(all_nodes) if i j or (i, j) in existing_edges or (j, i) in existing_edges: continue negatives.append((i, j)) return negatives这个函数有个性能隐患当数据规模变大时existing_edges这个集合的查询是 O(1) 的没问题但拒绝采样可能导致死循环。Cora 和 Citeseer 这个规模没压力如果你换成大规模数据集建议换成「先随机生成候选负样本再批量过滤掉存在的边」的策略一次向量化操作搞定。4.3 训练循环与 AUC 评估一个可复现的完整训练脚本训练循环本身不难难在评测和日志记录。我习惯每 10 个 epoch 在验证集上评估一次 AUC如果连续 20 个 epoch 没有提升就终止训练。这比固定训练 200 个 epoch 更高效因为 GCN 在这个任务上收敛很快早停能省一半训练时间。# 完整训练循环伪代码风格配合上面定义的模块 encoder GCNEncoder(in_dimfeatures.shape[1], hidden_dim128, out_dim128) predictor LinkPredictor(in_dim128) optimizer torch.optim.Adam( list(encoder.parameters()) list(predictor.parameters()), lr0.01, weight_decay5e-4 ) criterion torch.nn.BCEWithLogitsLoss() train_adj normalize_adj(train_adj) features_t torch.FloatTensor(features.toarray()) adj_t sparse_to_tensor(train_adj) # 转成稀疏张量 for epoch in range(200): encoder.train() predictor.train() z encoder(features_t, adj_t) # 正样本和负样本混合训练 pos_pred predictor(z[train_pos[:, 0]], z[train_pos[:, 1]]) neg_pred predictor(z[train_neg[:, 0]], z[train_neg[:, 1]]) loss criterion(pos_pred, torch.ones_like(pos_pred)) loss criterion(neg_pred, torch.zeros_like(neg_pred)) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 0: auc evaluate(encoder, predictor, val_pos, val_neg) print(fEpoch {epoch}, Loss: {loss.item():.4f}, Val AUC: {auc:.4f})学习率 0.01 是我对比过 0.001、0.005、0.01 之后的结果Cora 上 0.01 收敛最快Citeseer 上 0.005 更稳——但两者的差距最终 AUC 不超过 0.02只是收敛曲线形状不同。如果你追求一次跑通不调参优先试试 0.01不行再降到 0.005。评估时需要注意验证集的 AUC 是在「边的存在概率」上计算的要把正样本的输出 logit 和负样本的输出 logit 拼在一起用roc_auc_score计算。这里有个玄学Citeseer 的随机负采样结果会比 Cora 波动大因为它的边更稀疏随机采样的负样本分布更不稳定。多跑几个不同随机种子取平均才是可信的结果。5. 链路预测避坑指南三个典型案例与排查思路5.1 数据泄漏训练时把测试边也卷进了邻接矩阵现象训练集 AUC 特别高能达到 0.99但验证集只有 0.60 左右而且无论怎么调参都上不去。原因做链路预测时邻接矩阵A应该只包含训练边但如果你直接加载完整的数据集边文件然后把划分后的train_edges只用于生成监督信号模型在消息传递时已经看到了测试边的信息——测试集的两个节点在邻接矩阵里是相连的GCN 的邻域聚合自然会把这条边的信息编码进节点嵌入里相当于考试时偷看了答案。解决构建train_adj时只从训练边生成邻接矩阵测试边和验证边全部从邻接矩阵中移除。验证集和测试集只在评估时出现。从那以后我每次划分完边都强制检查一遍train_adj里的边数是否等于train_edges的数量对不上就说明泄漏了。5.2 负采样泄漏负样本里有验证集的正边现象验证集 AUC 高得离谱接近 1.0但测试集 AUC 一塌糊涂。原因负采样时用了全量已有边列表做过滤但验证集的正边在「全量已有边」里当然存在所以不会出错——问题反过来了。真正的问题在于负采样阶段如果和验证集划分不隔离验证集的正边被当成了训练阶段的负样本模型在训练时被明确告知「这条边不存在」验证时却要预测它存在这不叫模型强这叫矛盾数据。解决负采样时只能过滤训练集的正边验证集和测试集的正边不在过滤范围内。这要求划分边和负采样在同一个函数里完成不能分开跑。我一般把split_edges和negative_sampling合并成一个预处理函数输入完整边列表输出训练正边、训练负边、验证正边、验证负边、测试正边、测试负边一步到位后面不会再碰原始边文件。5.3 Citeseer 上邻接矩阵维度不匹配现象Citeseer 数据加载完成后邻接矩阵是 3327 x 3327但特征矩阵是 3703 x 3703或者反过来训练时 einsum 报维度错误。原因Citeseer 的数据文件里有个别论文节点只在特征文件里出现但没有任何引用边所以边文件的最大节点 ID 小于特征矩阵的行数。反过来也有节点只在边文件里出现不在特征矩阵里。两个矩阵的节点集合不完全重合。解决统一节点索引。以两个文件并集为准缺失的特征向量用全零向量填充缺失的边就用不上。这个坑的解决方案很机械但排查过程很绕——你从数据集官网下载的原始文件可能把这个坑藏得很深因为它不会报错只会让你的精度莫名其妙地偏低。如果实验精度一直上不去先检查节点 ID 的并集大小和特征矩阵行数是否一致。6. 进阶技巧嵌入维度对精度的影响与阈值调优链路预测最后一步是从 logit 到存在边的判定这里有个常被忽略的细节AUC 只看排序正确性不关心阈值但如果你的场景是「给定节点对判定是否存在边」那必须找一个合适的阈值。我在 Cora 上训练完成后会把验证集的 logit 分布画出来正样本和负样本的分布重叠区域就是最优阈值所在。常见的情况是 logit 分布近似高斯正样本均值在 0.3 附近由于 BCEWithLogitsLoss 的 logit 范围负样本均值在 -0.5 左右最优阈值大概是两个均值的中点。不过这个结论在两个数据集上有差异Cora 因为平均度数更高正样本 logit 分布更集中Citeseer 因为稀疏分布更发散阈值选择对最终 F1 的影响更大。嵌入维度的选择也值得多花时间。我测试过 32、64、128、256 四个档位Cora 上 64 和 128 的验证 AUC 几乎一样0.91 vs 0.92但 256 反而下降说明过拟合已经开始。Citeseer 上更明显64 维 AUC 只有 0.84128 维到 0.88256 维又掉回 0.85。这个趋势说明在 3000 节点规模的数据集上128 维是一个甜点值低于它嵌入容量不够表达多样化的局部结构高于它优化困难且容易过拟合。还有一个提高稳定性的习惯多随机种子平均。Cora 和 Citeseer 的边数量都在五千左右随机划分的比例稍微一波动验证集就可能多或少几十条边这足以让 AUC 波动 0.03。我跑实验时固定跑 5 个种子42、123、456、789、2024最终报告 5 次的平均 AUC 和标准差。比如我最终跑出的结果是 Cora 平均 AUC 0.916 ± 0.006Citeseer 平均 0.873 ± 0.009如果你复现的数字在这个范围内浮动都算正常。如果你只跑一个种子看到 0.85 或者 0.95别急着怀疑模型先拿另外四个种子验证一下。从那以后我每次做链路预测实验都强制走一遍完整检查确认划分时测试集节点全部出现在训练集中确认负采样只过滤训练边确认邻接矩阵只由训练边构成。这三步是最容易出错但最容易被忽略的地方也是新手翻车率最高的三个点。希望这篇笔记能帮你少走这些弯路把精力真正放在模型优化上。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑