
简介一套完整的图神经网络GNN代码工程面向机器学习初学者、图数据研究者以及需要快速落地GNN模型的开发者可直接支撑节点分类、链接预测、图分类等任务的模型搭建与训练。压缩包共384个文件体积约2.16MB其中多数为json格式的数据与配置文件承担图数据预处理、特征组织、超参数或节点嵌入保存等职能同时包含zbak备份文件与txt说明文档便于对比修改、排错和自定义扩展。代码模块覆盖了嵌入embedding生成、边edge信息处理等关键环节能直观展示GNN如何聚合邻居信息并学习节点表示非常适合结合代码理解图卷积网络GCN等模型原理。目前已有238人学习/下载。拿到该资源后可基于完整工程结构直接运行实验也可按需调整json配置适配自有图数据从而有效降低GNN的入门与二次开发门槛。1. GNN代码落地第一步别急着跑先搞清楚图数据长什么样图神经网络这名词看着唬人但真正把一套GNN代码拿到手新手最容易在第一步翻车——不是模型报错而是连数据怎么喂进去都没搞明白。我拆过好几套网上流传的GNN代码包最典型的通病是模型定义写得像模像样结果数据处理部分用假数据或者直接读内置的Cora、Citeseer跑出来准确率挺高一换自己的业务数据就彻底傻眼。这份资源的价值不在于那个model.py写得有多花哨而在于它把数据构建、模型定义、训练评估、可视化整条链路串通了你用的时候只需要把数据格式对齐就能在推荐、风控、分子性质预测这类场景里直接套。适合三种人刚入门想跑通第一个GCN的、被PyG文档绕晕的、以及手里有业务数据想验证GNN能不能用的从业者。读完你先不用管注意力机制那些花活把图的构建方式吃透后面全是水到渠成的事。2. 先把图的「三件套」对齐edge_index、node_feat 与 label2.1 PyG 的数据格式为什么是这种鬼样子凡是接触过 PyGPyTorch Geometric的人第一眼都会被edge_index这个结构搞懵。它是torch.long类型的张量形状是[2, num_edges]第一行是所有边的起点第二行是所有边的终点每一列对应一条边。你目标代码里的邻接矩阵是方阵但这玩意直接拍扁了存目的就一个——避开稀疏矩阵浪费内存。import torch from torch_geometric.data import Data # 假设有 3 个节点4 条边含1条自环 edge_index torch.tensor([ [0, 1, 2, 2], # 起点 [1, 0, 1, 2] # 终点 ], dtypetorch.long) x torch.tensor([ [0.2, 0.5], [0.4, 0.1], [0.8, 0.9] ], dtypetorch.float) # 3 个节点的二维特征 y torch.tensor([1, 0, 1], dtypetorch.long) # 3 个节点的标签 data Data(xx, edge_indexedge_index, yy) print(data) print(f边数: {edge_index.size(1)}节点数: {data.num_nodes})这段代码看着简单但edge_index的方向性会影响训练。注意 PyG 里默认传递消息的方向是「终点向起点聚合」也就是节点 i 的邻居包含所有「以 i 为终点」的起点。如果你用有向图edge_index的第一行是源节点、第二行是目标节点如果你是普通的无向图就必须把边做成双向的——否则聚合出来的邻居只有一半准确率直接崩。常见做法是把边正反各存一次或者用torch_geometric.utils.to_undirected()转换。自环也经常需要手动加GCN 原始论文里加自环是标配。2.2Data对象之外整图分类任务的 batch 是怎么拼的如果你跑的是图分类比如分子毒性预测你不会只处理一张图而是一大批图每张图的节点数和边数都不一样。PyG 给的解法是Batch它不简单地把图拼成大矩阵而是用batch向量记录每个节点属于原图的哪一张。from torch_geometric.data import DataLoader # 造三张结构不同的图 g1 Data(xtorch.randn(2, 3), edge_indextorch.tensor([[0, 1], [1, 0]]), ytorch.tensor(0)) g2 Data(xtorch.randn(4, 3), edge_indextorch.tensor([[0, 1, 2, 3], [1, 2, 3, 0]]), ytorch.tensor(1)) g3 Data(xtorch.randn(3, 3), edge_indextorch.tensor([[0, 1, 2], [1, 2, 0]]), ytorch.tensor(1)) loader DataLoader([g1, g2, g3], batch_size2, shuffleTrue) batch_data next(iter(loader)) print(f融合后节点数: {batch_data.num_nodes}) print(fbatch 向量: {batch_data.batch}) print(f原图索引: 节点 {batch_data.batch.tolist()})batch向量是整型列表每个值代表当前节点的原图序号。模型里做全局池化比如global_mean_pool时必须传入这个向量才能把同一张图的节点特征汇聚成一个图级向量。我见过有人图省事直接x.mean(dim0)结果把 batch 里所有图混在一起做了平均指标直接没法看。这是跑图分类代码时最常见的坑没有之一。2.3 数据划分为什么节点分类不能瞎 shuffle节点分类和普通机器学习不同样本是同一张图里的节点它们之间存在依赖关系。如果按传统方式随机划分训练集和测试集相邻节点的信息会互相泄露——因为 GNN 聚合邻居特征模型在当前层就已经见过测试节点的邻居信息了。资源里的代码通常默认用torch_geometric.transforms.RandomNodeSplit做分层划分你要关注的是划分比例和随机种子from torch_geometric.transforms import RandomNodeSplit transform RandomNodeSplit(num_val0.15, num_test0.2, keyy) data transform(data) # 划分后会生成 train_mask / val_mask / test_mask print(f训练节点数: {int(data.train_mask.sum())}) print(f验证节点数: {int(data.val_mask.sum())}) print(f测试节点数: {int(data.test_mask.sum())}) # 手工指定训练集时必须保证训练节点覆盖所有类别 # 否则某些类在训练阶段完全不可见推理时这类必崩RandomNodeSplit要求key参数指定类别字段本质是保证每类节点在训练集、验证集里都有一定比例。我后来在自己的业务数据上发现当某类节点非常稀少比如欺诈节点只占 0.1%时随机分层仍然可能把训练集里那几十个欺诈节点全漏掉这时候就要用带约束的手工划分至少保证训练集里每个类别有阈值下限。这是资源代码里不会替你操心的事你需要自己加一步。3. 核心模型代码拆解GCN、GraphSAGE、GAT 到底在计算什么3.1 GCN 的矩阵公式与 PyG 实现对照很多人的 GNN 代码是「能跑但不知道在算啥」这属于黑匣子式复现换参数就手足无措。GCN 的核心公式每篇博客都在抄H(l1) σ( D̂^{-1/2} Â D̂^{-1/2} H(l) W(l) )其中 Â A I加自环、D̂ 是 Â 的度矩阵。PyG 底层帮你算好了归一化但你要知道一个关键细节默认add_self_loopsTrue也就是说你前一步没手动加自环PyG 在消息传递时也会帮你加。这会造成你对edge_index的理解偏差调试时建议打印实际边数对比一下。import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCNLayer(torch.nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.conv1 GCNConv(in_dim, hidden_dim) self.conv2 GCNConv(hidden_dim, out_dim) def forward(self, data): x, edge_index data.x, data.edge_index x self.conv1(x, edge_index) x F.relu(x) x F.dropout(x, trainingself.training, p0.2) x self.conv2(x, edge_index) return F.log_softmax(x, dim1)这里GCNConv做的是对称归一化聚合即把邻居特征按1 / sqrt(deg_i * deg_j)加权求和。所以代码里不需要手动做degree计算但你要明白如果某个节点是孤立点它的度是 0加自环后变成 1归一化分母不会为 0这也解释了为什么 GCN 代码里加自环是隐式标配。hidden_dim的选择有实际经验可循Cora 这类千级节点数据集用 16 就够万级节点推荐 32 或 64再大就容易在少样本场景下严重过拟合。3.2 GraphSAGE 的邻居采样逻辑与聚合GraphSAGE 的最大特点是「采样邻居而非全图聚合」这直接决定了它在大规模图上的训练方式。PyG 里对应的SAGEConv不负责采样它只做聚合采样由NeighborSampler或NeighborLoader完成。很多复现代码没搞清这层分工以为写了SAGEConv就等价于 GraphSAGE这是半吊子。from torch_geometric.nn import SAGEConv from torch_geometric.loader import NeighborLoader class SAGEModel(torch.nn.Module): def __init__(self, in_dim, hidden_dim): super().__init__() self.conv1 SAGEConv(in_dim, hidden_dim) self.conv2 SAGEConv(hidden_dim, hidden_dim) def forward(self, x, edge_index): x self.conv1(x, edge_index).relu() x self.conv2(x, edge_index) return x # 关键采样数 [10, 5] 表示第一层采样10个邻居第二层再采5个 loader NeighborLoader( data, num_neighbors[10, 5], batch_size256, shuffleTrue ) for batch in loader: out model(batch.x, batch.edge_index) loss criterion(out[batch.train_mask], batch.y[batch.train_mask]) loss.backward()num_neighbors的层次数决定感受野深度两跳就是聚合到二阶邻居的信息。要把batch_size和num_neighbors联系起来看每个节点第一层采 10 个邻居第二层每个邻居再采 5 个理论上一个 batch 会膨胀到 256 × 10 × 5 个节点所以显存吃紧时优先降batch_size而不是降num_neighbors——后者直接砍掉模型表达能力。资源代码里如果写了SAGEConv而没有配套NeighborLoader那它实际上只是把 SAGE 当普通全图 GCN 用你需要自己补采样逻辑。3.3 GAT 注意力机制多头拼接和均值在哪控制GAT 是代码里最容易抄错的部分因为多头注意力的输出拼接策略有两种中间层用concat维度翻倍输出层用mean维度不变。混用之后模型的参数量和你预期完全不一致而 PyG 的GATConv只给一个heads参数拼接还是平均由你后面怎么处理决定。from torch_geometric.nn import GATConv class GATModel(torch.nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, num_heads8): super().__init__() # 中间层多头拼接输出维度 hidden_dim * num_heads self.conv1 GATConv(in_dim, hidden_dim, headsnum_heads) # 输出层多头平均输出维度 out_dim self.conv2 GATConv(hidden_dim * num_heads, out_dim, heads1, concatFalse) def forward(self, data): x, edge_index data.x, data.edge_index x self.conv1(x, edge_index).relu() x self.conv2(x, edge_index) return F.log_softmax(x, dim1)注意GATConv里有两个容易忽略的参数negative_slope控制 LeakyReLU 的负斜率默认为 0.2这是原论文的设置一般不用动dropout参数是注意力系数的随机失活比例不是特征层面的 dropout路径上要区分开。我拆过的代码里最常见的问题是中间层concatTrue之后第二层的in_dim忘了乘num_heads导致维度不匹配直接报错。如果你准备把这份资源里的 GAT 用到自己的数据上先把这个乘积关系在纸上画一遍比在报错堆里挣扎省时间。4. 训练与评估闭环从 loss 曲线到过拟合的判定4.1 标准训练循环的写法与关键顺序GNN 的训练循环跟普通 PyTorch 网络没有本质区别但有三个顺序敏感点model.train()必须在每个 epoch 前设置影响 dropout 和 batch normoptimizer.zero_grad()必须在 loss 反向传播之前以及torch.cuda.amp混合精度如果想用要在 forward 之前开autocast。下面这段是完整的训练段代码你拿自己数据替换data对象就行import torch.nn.functional as F from torch_geometric.nn import GCNConv model GCNLayer(in_dimdata.num_features, hidden_dim32, out_dimnum_classes) optimizer torch.optim.Adam(model.parameters(), lr0.01, weight_decay5e-4) criterion torch.nn.NLLLoss() model.train() for epoch in range(200): optimizer.zero_grad() out model(data) loss criterion(out[data.train_mask], data.y[data.train_mask]) loss.backward() optimizer.step() # 每个 epoch 结束在验证集上看指标别只看训练 loss if epoch % 20 0: model.eval() with torch.no_grad(): val_logits model(data) val_pred val_logits.argmax(dim1) val_acc (val_pred[data.val_mask] data.y[data.val_mask]).float().mean() print(fEpoch {epoch:3d} | Loss {loss.item():.4f} | ValAcc {val_acc:.4f}) model.train()weight_decay这个参数在 GNN 里比在 CNN 里更敏感Cora 这类小图任务默认 5e-4 能用但节点数超过十万以后建议降一个量级到 5e-5否则验证集准确率会被正则项拖下来。lr 也建议跟着数据集规模走小图 0.01 起步大数据集 0.001 更稳。这段代码里值得说明的是out[data.train_mask]的索引方式——它只对训练集节点计算 loss但 forward 是把全图节点都过了一遍。所以即便只是训练全图的特征和边都会被计算这也意味着大图上显存开销从第一步起就是全图级别的。这就是为什么 GraphSAGE 采样方案在大图上是刚需而不是可选项。4.2 评估指标选型节点分类准确率不是万能药资源里的评估代码通常直接算accuracy但在类别极度不平衡的业务数据上准确率是典型的「骗人指标」——你把所有节点预测成多数类准确率照样 99%。常见做法是至少同时输出精确率、召回率、F1-Score并且用macro平均而不是micro平均因为 macro 能暴露出少数类的表现from sklearn.metrics import f1_score, classification_report model.eval() with torch.no_grad(): out model(data) pred out.argmax(dim1).cpu().numpy() true data.y.cpu().numpy() # 只对测试集节点评估 test_mask data.test_mask.cpu().numpy() pred_test pred[test_mask] true_test true[test_mask] macro_f1 f1_score(true_test, pred_test, averagemacro) print(fMacro F1: {macro_f1:.4f}) print(classification_report(true_test, pred_test, digits3))classification_report的好处是每个类别的 P/R/F1 和样本数一目了然。我拆项目时有个习惯只要打印出来发现某个类别的support低于 10这个结果我就不信先回去看数据划分。另外训练曲线里一个隐蔽的过拟合信号是「训练 loss 稳步下降但 val acc 在 30 epoch 后开始震荡下滑」原因通常是模型层堆得太深GCN 的过度平滑开始显现——节点特征在多层传播后趋同。遇到这种情况先减层数从 2 层改成 1 层试试别急着加正则。4.3 玄学问题为什么加了 dropout 反而变差不少人在资源代码基础上加dropout参数发现测试集指标不升反降然后跑来问是不是代码有问题。这大概率不是 bug是 dropout 位置用错了。GNN 的 dropout 有三个可加位置输入特征、隐藏层特征、消息传递中的邻居特征。PyG 的GCNConv没有内置 dropout 参数但GATConv有而且含义是「注意力系数的 dropout」跟特征 dropout 两码事。你在隐藏层加 dropout 没问题但如果在输入层也加了 0.5 这种高比例等于把原始特征破坏了一半在小数据集上特征本来就稀疏加完更没信息可用。def forward(self, data): x, edge_index data.x, data.edge_index # 输入层后面不建议加高比例 dropout x self.conv1(x, edge_index).relu() # 隐藏层加 dropout 在 0.2~0.5 之间调 x F.dropout(x, p0.3, trainingself.training) x self.conv2(x, edge_index) return x对深度 GNN更推荐用DropEdge——按比例随机删边后再做消息传递这比特征 dropout 更贴合图结构的语义。PyG 内置了torch_geometric.transforms.DropEdge你可以在每个 epoch 前随机丢掉一部分边。我在实际项目里把 2 层 GCN 的 dropout 从 0.5 降到 0.2同时加 0.1 的 DropEdgeCor 测试集 F1 能从 0.79 涨到 0.82 左右。这类调参手感是资源代码里不会写的你跑通基础版本之后值得在这上面花时间。5. 避坑与排查六个 GNN 复现中常见的翻车现场5.1 模型能跑但 loss 不下降现象训练循环没有任何报错loss 从第一个 epoch 开始就恒定在某个值附近比如 2.4 左右几百轮不动。原因最常见的是归一化出问题——输入特征的范围很大比如业务数据里某个维度取值从 0 到 10000而 GCN 的聚合操作本质是加权求和梯度会因特征量级悬殊而不稳定。另一个常见原因是log_softmax已经包含在模型里但 loss 用了CrossEntropyLoss等于做了两次 softmax梯度被压平了。解决先标准化输入特征(x - x.mean(dim0)) / x.std(dim0)然后检查损失函数模型输出log_softmax时用NLLLoss模型输出裸 logits 时用CrossEntropyLoss。这两条排查路径要写死在你的检查清单里。5.2 训练集指标很高验证集指标忽高忽低现象train acc 到 95% 以上val acc 在 60~70% 之间来回跳每个 epoch 结果变化大。原因验证集样本太少尤其是节点分类任务验证集只有几百个节点时随机采样的误差被放大准确率方差自然大。另外如果验证集和训练集在图上相邻也存在信息泄露导致的指标虚高。解决检查val_mask节点数量小于 1000 就要么增大验证集比例要么用 k 折交叉验证。同时确认划分时是否保证了验证节点不与训练节点直接相连——严格做法是用torch_geometric.transforms.RandomNodeSplit的splittrain_rest模式或自己按连通分量划分。5.3 报错 Expected all tensors to be on the same device现象运行到 forward 时报 device mismatch有人说 CPU 有人说 CUDA。原因模型的参数在 GPU 上但data对象哪些字段在 CPU 上通常问题出在edge_index和label y没跟模型同步.cuda()。PyG 的Data对象不能整体.cuda()的解法是把整个对象往设备搬device torch.device(cuda if torch.cuda.is_available() else cpu) data data.to(device) model model.to(device)注意data.to(device)会把x,edge_index,y还有各种 mask 全部迁移这是最省事的做法。而最容易漏的是——在循环里每次新建的 tensor比如手工造的 batch label也要记得.to(device)。代码里如果变量多了建议在训练循环开头打印一个检查断言assert data.x.device data.edge_index.device next(model.parameters()).device5.4 边数翻倍、内存炸掉的隐性原因现象用DataLoader加载多张图做图分类时内存和显存占用远超预期训练速度断崖式下跌。原因很多人先用了to_undirected()又把原图原封不动塞进数据等于边数直接翻倍后再翻倍。另一个常见原因是原本的图带边的权重权重字段edge_weight没有归一化聚合操作时数值膨胀。解决在DataLoader里设置follow_batch之前先单独打印data.num_edges确认边数期望值对带权图检查edge_weight分布过大就做 min-max 归一化。PyG 的Batch.from_data_list不会自动帮你做任何去重或压缩这是资源代码里容易忽略的事。5.5 打印 embedding 做可视化全是同一种颜色现象把 GNN 输出的节点 embedding 用 TSNE 降到 2D 画散点图所有节点颜色混成一团不同类别边界模糊。原因层数太多导致过度平滑或者特征标准化后直接进入模型但hidden_dim设置过低比如小于 8可表征空间太小。更隐蔽的原因是 TSNE 的perplexity相对节点数太小默认 30 在几千个节点上通常效果很差。解决先减少层数为 1 层再跑一次可视化调大hidden_dim到 64 以上TSNEperplexity调到 50~100。我一般在做可视化之前先算一下同类节点的平均余弦相似度如果大于 0.95过度平滑基本实锤再怎么调 TSNE 都白搭。5.6 代码里存在全局随机种子但结果完全不可复现现象设了seed之后跑两次训练结果不一致准确率波动在 1% 以上。原因PyTorch 和 PyG 各自有不同的随机数生成器只设torch.manual_seed()不够CUDA 的随机性、DataLoader 的 shuffle 随机性都没控制住。解决完整设置三处。import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False # PyG 的 DataLoader shuffle 也需要这个 seed torch_geometric.seed_everything(seed)torch_geometric.seed_everything()是 PyG 提供的复现函数它内部会同步 Python、NumPy、PyTorch 三套引擎别自己搞一套简化版。设好之后再跑同一份资源代码应该能稳定复现同一个精度。如果还差检查数据划分过程是否也设了种子。6. 从复现到消融一条可执行的 GNN 代码验证路径资源里给的模型无论 GCN、GraphSAGE 还是 GAT跑通只是第一步。我强烈建议你花一小时做一组消融实验确认这份代码在你自己的数据上GNN 结构到底贡献了多少提升。方法其实很直接把模型里的GCNConv换成等维度的Linear层其他所有代码不动跑同样的数据划分和训练循环得到一个「去图化」的 baseline。import torch.nn as nn class LinearBaseline(nn.Module): 去掉图结构信息的对照组同样两层参数规模对齐 def __init__(self, in_dim, hidden_dim, out_dim, num_nodes): super().__init__() self.linear1 nn.Linear(in_dim, hidden_dim) self.linear2 nn.Linear(hidden_dim, out_dim) # 关键用可学习嵌入补上节点ID信息否则线性层完全看不到图结构 self.node_embed nn.Embedding(num_nodes, hidden_dim) def forward(self, data): x data.x x self.linear1(x).relu() x x self.node_embed(data.node_id) if hasattr(data, node_id) else x x self.linear2(x) return F.log_softmax(x, dim1)对比 GNN 模型和这个LinearBaseline的测试集指标如果差距小于 1 个百分点说明你的数据里节点特征本身就含了绝大部分信息边的结构价值不高——这种情况要么换更复杂的邻域聚合方式比如加 degree 特征要么干脆用传统机器学习别上 GNN。反过来如果 GNN 显著领先你还可以继续做第二个实验把edge_index随机重连之后重新训练如果精度大跌说明边的模式确实有语义如果精度没变化说明模型主要靠节点特征边是噪声。这两组对照做完你对资源的理解深度就跟纯抄代码完全不是一个层次了。从那以后我每次拿一套新的 GNN 代码第一件事都是先把这两个对照实验跑出来再谈调参。否则你连这份代码好在哪里、坏在哪里都不知道复现得再成功也只能证明你会按回车。希望这一套验证路径对你也有用照着做一遍比看十篇理论文章都实在。本文还有配套的精品资源点击获取