ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PyG入门指南:手写两层GCN实现节点分类与常见避坑

PyG入门指南:手写两层GCN实现节点分类与常见避坑 图神经网络这几年在工业界的落地速度比我预想的快得多——推荐系统里对用户和商品建图做消息传递分子性质预测里把原子当节点键当边风控场景里把交易网络直接建模成异构图。PyTorch Geometric下文统一叫PyG是这帮实践里最主流的工具库之一。这篇文章我按自己从零跑通第一个GNN模型的真实路径来写内容包括环境搭建的版本匹配、图数据的底层表示、两层GCN的手写与训练、以及我实际踩过的几个反直觉的坑。不管你之前只用PyTorch做过图像或文本模型还是刚入门想找个可靠的路线照着这篇文章走一遍应该能在半天内把第一个图模型跑起来并且理解它每一步在干什么。1. 先搞清楚GNN在解决什么问题再决定第一个模型选什么很多人在PyTorch里做图像做到手熟第一次接触图神经网络时容易犯一个认知错误把图当成一种“新型张量结构”套一个普通的MLP或者CNN进去。图数据根本不是规则网格节点和节点之间没有固定邻居数量也没有天然的顺序。GNN的价值恰恰是把“每个节点的输出由它自己和邻居共同决定”这一假设写进模型结构里。比如在社交网络里判断一个账号是否异常只看它自己的行为特征往往不够它的关注者、被关注者、互动对象的特征通常包含更强烈的信号。这种依赖关系用一个邻接矩阵就能表达但用常规神经网络很难直接处理。1.1 图神经网络能做的事情远比分类器宽我最初接触GNN时以为它只是另一个“深度学习分类器”后来才发现它的应用面宽很多。大致可以分成三类节点级任务给图中每个节点预测一个标签或数值。典型场景包括论文网络里的学科分类、欺诈检测里的风险用户识别。训练时可以只用部分节点做监督模型通过邻居信息的传播学到未标注节点的表示。边级任务预测两个节点之间是否存在某种联系或这条联系的权重。典型场景包括推荐系统里预测用户和商品之间会不会发生交互、知识图谱里补全缺失的关系。图级任务把整张图映射成一个向量或标签。典型场景包括分子性质预测每个分子是一张图原子为节点、化学键为边、程序代码的相似度分析。建第一个模型时不用追求覆盖全部三类先把节点分类跑通因为这是最直观、数据和代码都最简洁的方向。Cora论文数据集就是节点分类的标准入门数据。1.2 为什么第一个模型选GCN而不是GAT、GraphSAGEPyG里面图模型很丰富GCN、GAT、GraphSAGE、GIN、SGC……如果第一次就选GAT你会在多头注意力的维度设置上多花不少时间如果选GraphSAGE还得理解邻居采样的逻辑。GCN是最底层的基石它的核心思想就是“自己和邻居的特征做加权平均再过一层线性变换和激活函数”。理解了GCN的消息传递写法再去看GAT和GraphSAGE的源码基本就是多了一个注意力系数计算或者多了一个采样模块的问题。所以下面所有代码都围绕两层GCN来搭这是PyG官方文档里也默认推荐的起手式——花最少的时间理解框架把一个模型跑出合理指标。2. PyG安装版本匹配才是最大的坑不是下载速度如果你搜过PyG的安装教程大概率会看到两条命令pip install torch_geometric然后是安装配套的torch-scatter、torch-sparse等等。网上很多教程还在让你挨个装这些扩展库其实新版PyG已经默认集成了大部分算子核心场景普通GCN在中小规模图上根本不需要额外装扩展。真正折磨人的是PyTorch、CUDA、Python和PyG四者之间的版本联动关系。2.1 先统一版本再动手避免装完跑不起来我的建议顺序是先确定Python版本再装PyTorch最后装PyG。不要一上来就pip install torch装最新版因为PyG的预编译包和PyTorch版本强相关PyTorch太新或太旧都可能找不到对应的PyG轮子。下表是我在Python 3.10/3.11环境里验证过比较稳的组合供参考组件推荐版本说明Python3.10 或 3.113.12、3.13兼容性在部分扩展上仍有问题PyTorch2.1~2.5之间选一个稳定版不必追最新PyG官方预编译通常滞后CUDA11.8 或 12.1根据显卡驱动支持情况选没有N卡就选CPU版PyG2.5及以上2.5之后对扩展库的依赖大幅降低确认好组合后PyTorch这边推荐用官方指定源安装例如CUDA 12.1版本pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121没有NVIDIA显卡就装CPU版pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu装完验证一下PyTorch本身是否正常import torch print(torch.__version__) print(torch.cuda.is_available()) # 有显卡且装对CUDA版本时为True2.2 PyG本体的安装与验证PyG主库直接pip安装就行pip install torch_geometric如果你的PyTorch版本比较新建议此时尝试直接导入PyG并跑一个最简单的卷积层import torch from torch_geometric.nn import GCNConv conv GCNConv(in_channels16, out_channels32) x torch.randn(5, 16) # 5个节点每个16维特征 edge_index torch.tensor([[0, 1, 2, 3, 4], [1, 2, 3, 4, 0]], dtypetorch.long) out conv(x, edge_index) print(out.shape) # 期望输出 [5, 32]能跑通这一步说明PyG最核心的图卷积算子已经正常。如果你在import torch_geometric阶段报错九成概率是torch版本与PyG的预编译依赖不匹配直接换PyTorch到上表范围内的小版本即可。这里有一个很多教程不会提的细节PyG官方在2.3版本之后把torch-scatter和torch-sparse变成了可选依赖普通图规模下完全用不到所以看到网上老教程让你装扩展包时可以跳过。真要装也要根据你的torch版本和CUDA版本拼下载URL那才是真正的版本地狱。3. 上手前必须搞懂的图数据表示Data对象与Cora数据集图数据和图像数据在喂给模型之前组织方式完全不同。图像是规则的[H, W, C]张量图则是一堆节点、一堆边以及可能存在的节点特征和边特征。PyG为此设计了一个核心容器Data你所有模型接收的输入本质上都是这个对象。3.1 Data对象的四个核心字段一个最小的Data对象通常包含x节点特征矩阵形状为[num_nodes, num_node_features]。edge_index边的连接关系形状为[2, num_edges]每一列表示一条边的起点和终点。y标签。节点分类时形状为[num_nodes]图分类时通常是[num_graphs]。train_mask、val_mask、test_mask节点级任务的划分掩码告诉我们哪些节点用于训练、哪些用于验证、哪些用于测试。edge_index是最多人搞混的地方。它不是稠密邻接矩阵而是稀疏格式的COO表示。举个例子一条从节点0指向节点1的边在邻接矩阵里是A[0][1] 1在edge_index里就是[0, 1]这一列。为什么要这样设计因为真实图数据通常非常稀疏——一万个节点可能只有几万条边用稠密矩阵存就是一亿个元素的浪费。手动构造一个Data对象的代码很直观import torch from torch_geometric.data import Data # 三个节点每个节点2维特征 x torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtypetorch.float) # 三条边0-1, 1-2, 2-0 edge_index torch.tensor([[0, 1, 2], [1, 2, 0]], dtypetorch.long) # 节点标签 y torch.tensor([0, 1, 0], dtypetorch.long) data Data(xx, edge_indexedge_index, yy) print(data) # 检查图是否无向如果边有来回两条记录则为True print(data.is_undirected())我自己第一次调试时反复踩过edge_index的dtype问题图卷积算子里索引张量必须为torch.long你要是手滑建成了torch.int或者torch.float直接会报类型错误这个后面避坑部分再细说。3.2 Planetoid-Cora到底长什么样Cora是GNN界的“MNIST”几乎所有入门教程都拿它当第一个数据集。它收集了2708篇机器学习领域的论文每篇论文有1433维的词袋特征共分为7个学科类别比如神经网络、强化学习、概率方法等论文之间的引用关系构成了图的边总共5429条引用边。PyG把Cora封装在Planetoid数据类里加载方式非常简洁from torch_geometric.datasets import Planetoid dataset Planetoid(root/tmp/Cora, nameCora) print(f数据集包含 {len(dataset)} 张图) data dataset[0] print(data)这里有一个小知识点dataset是一个列表结构里面只有一张图。拿dataset[0]才能取到那张图的Data对象。len(dataset)表示样本数对图分类任务来说是图的数量对Cora这种单图节点分类任务来说就是1。加载完成后data会包含Data(x[2708, 1433], edge_index[2, 10556], y[2708], train_mask[2708], val_mask[2708], test_mask[2708])注意edge_index列数是10556比前面讲的5429条边多了一倍因为PyG把无向引用边存成了两条有向边每个方向各一条这样消息传递时两个方向都能传播。打印data.is_undirected()会返回True。3.3 mask字段的含义与使用边界Cora自带三个布尔掩码train_mask、val_mask、test_mask。每个都为True/False长度2708的向量分别标记哪些节点参与训练、验证和测试。标准划分是每类20个节点做训练500个节点做验证1000个节点做测试。这个设计对入门者非常友好意味着你不需要自己写数据切分逻辑。但代价是很多人不知道这个mask的存在意义训练时直接拿全部节点的标签计算损失导致验证集测试集泄露最后指标虚高到没有参考价值。后面第5部分我会专门展开说这个反直觉现象。4. 手写两层GCN消息传递机制与完整训练代码核心代码部分我分成两段来讲先解释GCNConv在做一件什么事再给出完整可跑的模型定义与训练循环。4.1 GCN的数学直觉邻居特征加权平均GCN单层做的事情用一句话概括每个节点的新特征 自己和邻居特征的加权平均再做一次线性变换最后过一个非线性激活函数。权重怎么确定不是学习出来的而是由图的度degree决定的归一化系数。数学上GCNConv的传播规则可以写成H σ(D̃^(-1/2) Ã D̃^(-1/2) H W)其中Ã A I是加了自环的邻接矩阵D̃是Ã的度矩阵。这个公式看着复杂拆开看就是先给自己加一条自环边让节点在聚合时考虑自身特征然后计算归一化系数1/sqrt(deg(i) * deg(j))对邻居特征做加权求和最后乘以可训练的权重矩阵W。PyG的GCNConv把这个过程封装好了你不需要手写矩阵乘法只需要把节点特征和edge_index传进去。但理解这个机制的收益是巨大的——后续如果你想调模型、换聚合方式、处理异构图都得回到这个“消息传递”的基本范式里思考。4.2 模型定义与训练循环代码下面是一个标准的双层GCN模型中间加ReLU激活和Dropout输出层接log_softmax做7分类import torch 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): # 第一层图卷积 - ReLU - Dropout x self.conv1(x, edge_index) x F.relu(x) x F.dropout(x, p0.5, trainingself.training) # 第二层图卷积 - log_softmax x self.conv2(x, edge_index) return F.log_softmax(x, dim1)注意forward接收的是x和edge_index而不是一个Data对象。PyG里更常见的写法是model(data.x, data.edge_index)。有些教程会把整个data传进去那是因为某些层比如global_pool需要用到batch字段但GCNConv不需要。训练循环和普通PyTorch网络非常相似唯一区别在损失计算时要通过mask做索引from torch_geometric.datasets import Planetoid # 加载数据 dataset Planetoid(root/tmp/Cora, nameCora) data dataset[0] # 初始化模型 device torch.device(cuda if torch.cuda.is_available() else cpu) model GCN(in_channelsdataset.num_node_features, hidden_channels16, out_channelsdataset.num_classes).to(device) data data.to(device) optimizer torch.optim.Adam(model.parameters(), lr0.01, weight_decay5e-4) def train(): model.train() optimizer.zero_grad() out model(data.x, data.edge_index) # 只取训练节点计算损失 loss F.nll_loss(out[data.train_mask], data.y[data.train_mask]) loss.backward() optimizer.step() return loss.item() def evaluate(mask): model.eval() with torch.no_grad(): out model(data.x, data.edge_index) pred out.argmax(dim1) correct pred[mask].eq(data.y[mask]).sum().item() acc correct / mask.sum().item() return acc for epoch in range(200): loss train() if epoch % 20 0: train_acc evaluate(data.train_mask) val_acc evaluate(data.val_mask) print(fEpoch {epoch:03d} | Loss: {loss:.4f} | Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f}) test_acc evaluate(data.test_mask) print(fTest Acc: {test_acc:.4f})这段代码跑200轮在Cora上测试准确率大概能到80%~82%和GCN原论文里的水平基本一致。这里有两个值得解释的设计损失函数用的是NLLLoss对应log_softmax和CrossEntropyLoss数学上是等价的。PyG官方示例里习惯用前者我也沿用这个写法避免你对照官方文档时产生困惑。隐藏层维度取16是GCN论文里的超参数。它不是越大越好因为Cora的标注样本只有140个隐藏维度过大容易过拟合。4.3 如果不想用内置数据集从CSV构造Data很多实际问题里你没有现成的Cora可用而是从业务表里导出了节点特征和边关系。这时候用Data手动构造就行。下面演示一个极简场景import pandas as pd import torch from torch_geometric.data import Data # 节点特征文件每一行是一个节点第一列为节点ID其余列为特征 node_df pd.read_csv(nodes.csv) # 边文件两列分别为起始终点ID edge_df pd.read_csv(edges.csv) # 建立节点ID到连续索引的映射 node_ids node_df[node_id].tolist() id2idx {nid: i for i, nid in enumerate(node_ids)} # 特征矩阵 x torch.tensor(node_df.drop(columns[node_id]).values, dtypetorch.float) # 边索引转为COO格式 src edge_df[src].map(id2idx).values dst edge_df[dst].map(id2idx).values edge_index torch.tensor([src, dst], dtypetorch.long) data Data(xx, edge_indexedge_index) print(data)需要注意id2idx的映射不能跳号也就是说节点ID必须是0到N-1的连续整数。如果原始ID是字符串或者有缺失一定要先做映射再构图。很多真实场景的图数据在清洗时都会在这里出问题。5. 训练里三个反直觉现象mask、网络深度与过平滑第一个模型能跑通只是开始。用Cora训练的时候我碰到了三个当时觉得很反直觉的现象后来才理解都是图结构的特殊性质导致的。这里逐个说清楚。5.1 不要碰val_mask和test_mask也不要手动shuffle普通深度学习任务里我们习惯把所有数据读进来然后train_test_split再做一个DataLoader打乱顺序。图神经网络里这种习惯非常危险。Cora的val_mask和test_mask是数据协议的一部分标准做法是模拟“半监督”场景训练节点只能看到很少一部分带标签的样本每类20个验证和测试节点连标签都不能参与计算损失。如果你图省事直接拿全图节点算损失会发生什么我第一次试的时候训练损失一路降到0.001以下train_acc到99.9%但测试准确率反而只有60%多。原因很简单模型把所有节点的标签都背下来了GCN的消息传递本来就是全局依赖全图节点的特征在计算时共享了太多信息等于开卷考试。这也是为什么PyG的教程里永远要用mask做索引再算损失这一步省不得。还有一个细节图数据在节点分类场景下不需要shuffle。因为edge_index本身就是图结构的静态描述节点编号是固定的shuffle节点会直接破坏图和标签的对应关系。这一点和图像数据截然不同。5.2 深层GCN反而掉点过平滑效应做图像时我们习惯层数越深效果越好ResNet都上百层了。但在GCN里层数一多效果反而断崖式下跌。我在Cora上试过5层GCN测试准确率从82%掉到73%左右再加到10层直接逼近60%——跟瞎猜差不多。这个现象叫过平滑over-smoothing。消息传递的本质是让每个节点去聚合邻居的特征层数越多每个节点能看到的多跳邻居就越多最终所有节点的表示会趋于一致变成“所有节点都长得差不多”的状态自然没法分类了。对于Cora这种小规模图2到3层是最优区间。这倒不完全是个缺点在一些无监督的图表示学习场景过平滑甚至会被故意利用来进行社区发现。但在做节点分类时它就是个需要警惕的坑。5.3 训练曲线震荡幅度比图像任务更大GCN训练的loss曲线不像图像分类那样平滑下降。用学习率0.01训练200轮前50轮loss经常出现明显的上下跳动。原因在于每轮迭代的参数更新会间接影响所有节点的表示——即使你用mask只算了140个节点的损失反向传播更新的权重却作用于整张图的传播过程。节点之间依赖关系强梯度就比独立同分布的数据更“躁”。这不是代码写错了是GNN训练的正常现象。应对办法和图像任务差不多调低学习率、增加weight_decay、或者用带warmup的调度器。Cora这个规模的数据Adam配lr0.01加上weight_decay5e-4是我调过之后比较稳的组合。6. 五个新手最容易踩的坑与最终排查清单最后这部分整理我实际遇到过的问题每一个都有真实报错或者真实“假高分”的经历。希望你能避开。6.1 edge_index用成了稠密邻接矩阵这是GNN新手最常见的错误——以为edge_index是邻接矩阵直接torch.tensor([[0,1,0],[1,0,1],[0,1,0]])传进去。PyG会直接报维度错误因为GCNConv内部期望的是[2, num_edges]的形状。记住一条PyG里的图结构默认是稀疏COO格式不是邻接矩阵也不是邻接表。如果你的数据源给的是稠密矩阵需要用torch_geometric.utils.dense_to_sparse()转换from torch_geometric.utils import dense_to_sparse adj torch.tensor([[0, 1, 1], [1, 0, 0], [1, 0, 0]], dtypetorch.float) edge_index, _ dense_to_sparse(adj) print(edge_index) # 得到 [2, num_edges]6.2 忘了调model.eval()验证集指标虚高PyTorch的通用习惯里model.eval()影响的是Dropout和BatchNorm的行为。GCN的前向过程里有Dropout层如果你在验证测试时忘记切到eval模式Dropout依然随机丢弃一部分特征导致结果波动很大。我在Cora上见过同一次测试跑两次准确率从81%跳到86%的情况就是因为没切eval模式。加上with torch.no_grad()是另一个容易忽略的细节虽然不影响指标但会多占显存、拖慢速度。6.3 数据集只加载了一次却想跑多个随机种子Cora的train_mask是固定的不是随机生成的。这本身没问题但如果你想通过多个随机种子做多次实验取平均或者想测试某个新模块的稳定性固定划分可不够。PyG其实给出了Planetoid的随机划分方案通过参数控制dataset Planetoid(root/tmp/Cora, nameCora, splitrandom, num_train_per_class20, num_val500, num_test1000)这里有个容易混淆的点split参数有public、random、full三种取值默认是public论文里公开的标准划分。random会每次运行重新生成随机maskfull则不划分验证测试集全部节点用于训练。做消融实验时用random复现论文时用public别搞反。6.4 用DataLoader加载单图数据的坑如果只是处理Cora这张单图直接data.to(device)就行根本不需要DataLoader。但很多人习惯性地想构造一个batch结果发现DataLoader打包出来的东西和自己预期完全不同。PyG的DataLoader对于图分类任务是把多张图拼成一个大图通过batch字段标识节点属于哪张图而不是像图像那样堆一个batch维度。如果你只是想在节点分类里做批处理正确做法是用NeighborLoader做邻居采样而不是普通DataLoader。入门阶段建议直接用全图训练Cora这种2708个节点的图全图训练毫无压力等数据量上去了再考虑采样。6.5 自己写消息传递然后发现和PyG结果对不上可能有人会觉得GCN实现起来很简单想自己写一个循环聚合邻居。这种探索精神是好的但结果很容易和PyG对不上。原因在于GCNConv的归一化方式很细节它对每个方向的边分别做归一化也就是源节点的度归一化乘上目标节点的度归一化。另外它默认加自环。如果你自己实现时忘了加自环或者归一化方式不同结果就会有偏差。如果确实想验证自己的实现建议用PyG官方的MessagePassing基类它把message、aggregate、update三个步骤拆开了既容易理解又保证数值对齐import torch from torch_geometric.nn import MessagePassing class MyGCNConv(MessagePassing): def __init__(self, in_channels, out_channels): super().__init__(aggradd) self.lin torch.nn.Linear(in_channels, out_channels) def forward(self, x, edge_index): # 先做线性变换再做邻居聚合PyG内部处理归一化 x self.lin(x) return self.propagate(edge_index, xx) def message(self, x_j): return x_j这个实现省去了手工索引邻居的操作propagate会自动根据edge_index找到每条边的源节点特征调用message对它们进行变换然后按aggradd聚合到目标节点。理解了这个流程再看PyG里任何卷积层的源码都会轻松很多。6.6 最终排查清单跑第一个模型时如果遇到问题按这个顺序排查现象排查方向导入torch_geometric报错检查torch和Python版本是否在PyG支持范围内GCNConv前向报维度错误检查edge_index是否[2, E]且dtype为torch.long训练loss不降检查是否用了train_mask学习率是否过大特征是否做了归一化验证准确率大幅波动检查是否忘记model.eval() / with torch.no_grad()测试准确率过高或过低检查是否误用了整个data算损失是否把测试集混进了训练GPU显存不够小图用CPU即可大图改用NeighborLoader采样我在实际项目里还遇到过一个和上面都不沾边的坑用Anaconda装PyG时conda源和pip源混用导致torch被静默降级了。装完记得时刻跑一下print(torch.__version__)确认当前解释器里用的Torch版本和环境变量指向的CUDA版本一致。这个检查放在训练之前做能帮你省下大量无意义的调试时间。从GCN跑通到现在我最大的体会是图神经网络的代码门槛其实不高真正的学习成本在数据建模和图论直觉上。第一次上手就把Data对象、edge_index和消息传递的流程过一遍后面的GAT、GraphSAGE、异构图模型都只是在这一套范式上加模块。把这篇文章的代码完整跑一遍你就已经跨过GNN最陡峭的那道坎了。
返回列表