YAOTU INSIGHTS

GCN链路预测实战:Cora与Citeseer数据集AUC提升与避坑指南

GCN链路预测实战:Cora与Citeseer数据集AUC提升与避坑指南
简介这份资源面向图神经网络与链路预测方向的初学者及进阶学习者聚焦在Cora和Citeseer两个学术文献引用网络上用图卷积神经网络完成节点间未知链接的预测任务。压缩包共3个文件均为Python脚本整体约7KB分别承担数据读取与图结构预处理、GCN模型搭建、训练与测试流程控制等职责结构精简便于快速理解项目全貌。资源围绕词袋特征、邻接矩阵构建、节点嵌入生成与相似度计算等关键环节展开并涉及负采样、特征归一化、正则化及学习率、层数、隐层节点数等超参数调优思路测试阶段可用AUC-ROC或准确率评估效果。目前已有3070人学习适合希望把GCN理论落到链路预测实践、系统梳理图数据建模流程的读者参考。1. 从一次翻车现场说起为什么你的 GCN 链路预测跑不出论文里的 AUC很多开发者第一次在 Cora 和 Citeseer 上做链路预测时都会经历同一个场景照着论文把 GCN 搭起来训练集 loss 降得漂漂亮亮一到测试集 AUC 就卡在 0.6 上下怎么调学习率都没用。问题往往不在模型本身而在于链路预测和节点分类是两套完全不同的评估逻辑——你把边当成标签来用却还在用节点分类的思维划分数据集。这个项目就是冲着这个痛点来的它把 Cora、Citeseer 两个引文网络数据集上的链路预测流程完整拆开从边集划分、负采样、GCN 编码器搭建到 AUC 评估每一步都有可运行的代码。适合已经跑过节点分类、想往图结构任务上再走一步的开发者也适合需要快速复现 baseline 做对比实验的人。2. 项目骨架与技术选型为什么是 GCN PyG 这套组合2.1 整体目录结构与模块职责拿到这个项目的源码包第一件事是看目录怎么分的。一个典型的链路预测工程不会把所有东西塞进一个 train.py而是按数据、模型、训练、评估四块拆开。下面是我拆过多个类似项目后总结出的合理结构这个项目也基本遵循这个思路gcn-link-prediction/ ├── data/ │ ├── __init__.py │ └── loader.py # Cora/Citeseer 加载与边集划分 ├── models/ │ ├── __init__.py │ └── gcn_encoder.py # 两层 GCN 编码器 ├── utils/ │ ├── __init__.py │ ├── negative_sampling.py # 负采样策略 │ └── metrics.py # AUC / AP 计算 ├── configs/ │ └── default.yaml # 超参数集中管理 ├── train.py # 训练入口 ├── evaluate.py # 独立评估脚本 └── requirements.txt这个结构的好处是职责清晰data/loader.py只管把 PyG 的 Planetoid 数据集转成链路预测需要的格式models/gcn_encoder.py只负责前向传播出节点嵌入train.py串起整个流程。二次开发时想换编码器只动 models 目录想换数据集只动 data 目录。常见做法是把超参数抽到 yaml 里避免改一个学习率要翻三个文件。2.2 依赖清单与版本约束图神经网络项目最怕版本打架PyG 和 PyTorch 的版本对应关系尤其玄学。这个项目的 requirements.txt 核心依赖如下torch2.0.0 torch-geometric2.4.0 numpy1.24.0 scikit-learn1.3.0 pyyaml6.0 tqdm4.65.0这里有个血泪经验PyG 的Planetoid类在 2.4 之后对transform参数的处理有变化如果你装的是 2.3 以前的版本loader.py里对边索引的预处理逻辑可能要微调。我一般会先用pip install torch-geometric装最新版跑一遍python -c from torch_geometric.datasets import Planetoid; print(ok)确认环境没问题再往下走。另外 scikit-learn 不是可选项AUC 和 Average Precision 都靠它算别想着用 numpy 手搓数值稳定性差很多。2.3 数据加载Cora 和 Citeseer 的边集怎么切Cora 有 2708 个节点、5429 条边无向Citeseer 有 3327 个节点、4732 条边。链路预测不能拿全量边训练再拿全量边测试必须按边划分。标准做法是 85% 训练边、5% 验证边、10% 测试边同时要保证验证集和测试集中的边在训练时不可见。data/loader.py的核心逻辑import torch from torch_geometric.datasets import Planetoid from torch_geometric.transforms import RandomLinkSplit def load_dataset(nameCora, root./data_raw): # 加载 Planetoid 数据集Cora 和 Citeseer 都走这个接口 dataset Planetoid(rootroot, namename) data dataset[0] # RandomLinkSplit 自动完成边集划分和负采样 # num_val0.05, num_test0.1 对应 5% 验证、10% 测试 transform RandomLinkSplit( num_val0.05, num_test0.1, is_undirectedTrue, add_negative_train_samplesTrue, neg_sampling_ratio1.0 # 正负样本 1:1 ) train_data, val_data, test_data transform(data) return train_data, val_data, test_data这段代码的关键在RandomLinkSplit的参数。is_undirectedTrue表示按无向图处理Cora 和 Citeseer 的引文网络本身是有向的但链路预测任务里通常当无向图做这样正样本数量翻倍。neg_sampling_ratio1.0控制负采样比例训练时每个正样本配一个负样本验证和测试时 PyG 会自动生成等量的负边。注意add_negative_train_samplesTrue只在训练集上生效验证和测试集的负样本是在评估时动态生成的这样避免负样本泄漏。提示如果你把neg_sampling_ratio改成 2.0 或更高训练会变慢但 AUC 可能略有提升Cora 上一般 1.0 就够Citeseer 因为边更稀疏可以试到 2.0。3. GCN 编码器拆解两层图卷积到底在算什么3.1 从节点特征到边概率的完整前向链路链路预测的 GCN 和节点分类的 GCN 在编码器部分几乎一样区别在输出层。节点分类是每个节点出一个类别 logits链路预测是每对节点出一个相似度分数。这个项目的models/gcn_encoder.py实现如下import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCNEncoder(nn.Module): def __init__(self, in_channels, hidden_channels, out_channels, dropout0.5): super().__init__() # 第一层输入特征 - 隐藏层 self.conv1 GCNConv(in_channels, hidden_channels) # 第二层隐藏层 - 输出嵌入 self.conv2 GCNConv(hidden_channels, out_channels) self.dropout dropout def forward(self, x, edge_index): # 第一层卷积 ReLU Dropout x self.conv1(x, edge_index) x F.relu(x) x F.dropout(x, pself.dropout, trainingself.training) # 第二层卷积输出节点嵌入不加激活 x self.conv2(x, edge_index) return x def decode(self, z, edge_label_index): # 用点积作为边存在的打分函数 # edge_label_index 形状 [2, num_edges] src z[edge_label_index[0]] dst z[edge_label_index[1]] return (src * dst).sum(dim-1)GCNConv的数学本质是归一化的邻接矩阵乘特征再乘权重$H^{(l1)} \sigma(\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}H^{(l)}W^{(l)})$。第一层把原始特征Cora 是 1433 维词袋Citeseer 是 3703 维压到 hidden_channels第二层再压到 out_channels通常设 64 或 128。decode用点积做内积解码这是链路预测最常用的解码器简单且效果好。注意第二层后面没有 ReLU因为嵌入向量需要保留正负值加激活会损失表达能力。3.2 超参数怎么定Cora 和 Citeseer 的差异两个数据集的超参数不能照搬。Cora 的特征维度低、边密度高Citeseer 特征维度高但边更稀疏。我一般这样设参数CoraCiteseer说明hidden_channels128256Citeseer 特征多隐藏层给大点out_channels6464嵌入维度64 够用dropout0.50.5防止过拟合lr0.010.01Adam 默认学习率weight_decay5e-45e-4L2 正则epochs200200早停看验证 AUCCiteseer 上如果 hidden_channels 给 128验证 AUC 会比 256 低 1-2 个点因为它的输入特征更稀疏需要更大的隐藏层来保留信息。但也不能无限加大256 以上收益递减还容易过拟合。常见做法是先用 128 跑一遍看验证 AUC 曲线如果训练集 AUC 远高于验证集说明过拟合加大 dropout 或 weight_decay。3.3 训练循环与早停策略训练循环本身不复杂关键是早停要盯验证 AUC 而不是 loss。train.py的核心片段def train(model, optimizer, train_data): model.train() optimizer.zero_grad() z model(train_data.x, train_data.edge_index) # 用训练边的正负样本算损失 out model.decode(z, train_data.edge_label_index) loss F.binary_cross_entropy_with_logits(out, train_data.edge_label) loss.backward() optimizer.step() return loss.item() torch.no_grad() def test(model, data): model.eval() z model(data.x, data.edge_index) out model.decode(z, data.edge_label_index) pred out.sigmoid().cpu().numpy() label data.edge_label.cpu().numpy() return roc_auc_score(label, pred)注意binary_cross_entropy_with_logits比先 sigmoid 再 BCE 更稳定数值上不会溢出。早停逻辑我一般设 patience20验证 AUC 连续 20 轮不提升就停同时保存验证 AUC 最高的模型权重。Cora 上通常 80-120 轮就到最优Citeseer 稍慢150 轮左右。测试时用model.eval()关掉 dropout否则 AUC 会掉 3-5 个点这个坑我踩过不止一次。4. 避坑与排查链路预测里最容易翻车的五个点4.1 负采样泄漏导致 AUC 虚高现象测试 AUC 跑到 0.95 以上但换一组随机种子就掉到 0.7。原因通常是负样本在训练和测试之间重复了或者验证集的负边在训练时被模型见过。解决方法是确保RandomLinkSplit的add_negative_train_samples只在训练集开启验证和测试的负样本在评估时用negative_sampling动态生成且每次评估重新采样。如果自己手写负采样一定要用torch_geometric.utils.negative_sampling并传入num_nodes参数避免采到不存在的节点。4.2 边索引方向搞反导致消息传错现象训练 loss 正常下降但 AUC 只有 0.5 左右等于随机猜。原因多半是edge_index的方向和edge_label_index的方向不一致。PyG 的GCNConv默认按edge_index[0]是源节点、edge_index[1]是目标节点来聚合消息。如果你在构造edge_label_index时把正样本的源和目标写反了decode 出来的分数就没有意义。解决方法是统一用data.edge_index的顺序正样本直接取edge_index的列负样本用negative_sampling生成不要手动拼。4.3 特征未归一化导致 Citeseer 上不收敛现象Citeseer 上 loss 震荡AUC 在 0.5-0.6 之间跳。原因是 Citeseer 的原始特征是词袋计数数值范围差异大GCN 的邻接矩阵归一化只处理了结构没处理特征。解决方法是在loader.py里加一步RowNorm或NormalizeFeatures变换from torch_geometric.transforms import NormalizeFeatures dataset Planetoid(rootroot, namename, transformNormalizeFeatures())Cora 上不加也能跑但 Citeseer 不加基本废掉。这个坑很隐蔽因为 loss 不会报错只是学不动。4.4 评估时忘了 model.eval() 导致结果波动现象同一个模型跑两次评估AUC 差 2-3 个点。原因是 dropout 在评估时还在生效每次前向传播随机丢神经元。解决方法是在test函数开头加model.eval()结束加model.train()。另外如果用了 BatchNorm也要注意 running stats 的更新不过这个项目的 GCN 没有 BN影响小一些。4.5 随机种子不固定导致复现困难现象论文里写 AUC 0.85你跑出来 0.82换台机器又变 0.84。原因是 PyTorch、numpy、CUDA 的随机种子没固定。解决方法是在train.py开头加import random, numpy as np, torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True注意cudnn.deterministicTrue会稍微降低速度但换来确定性。Cora 和 Citeseer 上固定种子后AUC 波动能控制在 0.5 个点以内。5. 二次开发与改造从 baseline 到自己的图数据5.1 换数据集把 Cora 替换成自定义图这个项目的loader.py写死了Planetoid要换成自己的图数据需要改三处。第一把Planetoid换成torch_geometric.data.Data手动传入x、edge_index、num_nodes。第二RandomLinkSplit的is_undirected要根据你的图是否有向来设社交网络通常无向引用网络可以有向。第三如果节点没有特征用单位矩阵或随机初始化代替但 AUC 会降不少最好还是找点特征。from torch_geometric.data import Data # 假设你的图有 1000 个节点边列表存在 edge_list 里 x torch.randn(1000, 128) # 随机特征实际替换成你的节点特征 edge_index torch.tensor(edge_list, dtypetorch.long).t().contiguous() data Data(xx, edge_indexedge_index, num_nodes1000)改完后跑一遍RandomLinkSplit确认train_data.edge_label里正负样本比例接近 1:1如果偏差大调neg_sampling_ratio。5.2 换编码器从 GCN 到 GraphSAGE 或 GATGCN 的局限是它对所有邻居一视同仁GraphSAGE 可以做邻居采样GAT 可以学注意力权重。换编码器只需要改models/gcn_encoder.py把GCNConv换成SAGEConv或GATConv接口基本兼容from torch_geometric.nn import SAGEConv class SAGEEncoder(nn.Module): def __init__(self, in_channels, hidden_channels, out_channels): super().__init__() self.conv1 SAGEConv(in_channels, hidden_channels) self.conv2 SAGEConv(hidden_channels, out_channels) def forward(self, x, edge_index): x F.relu(self.conv1(x, edge_index)) x F.dropout(x, p0.5, trainingself.training) return self.conv2(x, edge_index)注意 GATConv 的heads参数会影响输出维度如果heads4out_channels要除以 4 再传给下一层否则维度对不上。我一般先用 GCN 跑通再换 SAGE 对比Cora 上 SAGE 通常比 GCN 高 1-2 个点Citeseer 上差距不大。5.3 验证改造是否有效的三个检查点改完代码别急着看 AUC先过三个检查。第一打印train_data.edge_label_index.shape和train_data.edge_label.shape确认边数和标签数一致。第二跑一个 epoch看 loss 是否在 0.6-0.7 之间如果 loss 是 0 或 nan说明解码器或损失函数写错了。第三用训练集评估一次 AUC正常应该比测试集高 5-10 个点如果训练集 AUC 也只有 0.6说明模型根本没学到东西回去检查特征和边索引。从那以后我每次改完编码器或数据加载逻辑都强制走一遍这三个检查比直接看测试 AUC 省时间。希望帮到你。本文还有配套的精品资源点击获取