PyG图神经网络节点分类实战:从GCN原理到调参排错全解析 1. 从一张图说起为什么节点分类值得单独拎出来讲图神经网络这几年从学术圈火到工业界很多人第一次接触 PyTorch Geometric后面统一简称 PyG都是从节点分类这个任务入手的。原因很简单它把图结构和特征学习这两件事揉在了一起而节点分类恰好是最直观、最容易验证效果的一个任务。你手里有一张图每个节点有自己的特征一部分节点已经知道标签剩下的节点标签未知你要做的就是让模型学会根据邻居信息和自身特征把未知节点的标签预测出来。听起来像半监督学习对吧确实经典的数据集 Cora、Citeseer、Pubmed 就是标准的半监督节点分类设定训练集很小测试集很大模型必须靠图结构把标签传播出去。这也是 GCN 当年一炮而红的核心场景。但我要先泼一盆冷水很多人跑通了官方示例就以为自己掌握了节点分类结果换到自己的数据上准确率直接掉到随机猜的水平。问题出在哪出在对整个流程的理解停留在调包层面不知道每一步在干什么也不知道哪些地方最容易翻车。这篇内容我会围绕 PyG 的 GNN、GCN 节点分类把从数据准备、模型搭建、训练循环到调参排错的完整链路拆开讲。适合两类人一是刚学完 GCN 公式、想动手跑通代码的二是跑通过 Demo、但换数据集就懵、想搞清楚底层逻辑的。我不会只给你一段能跑的代码而是把每个选择背后的理由讲清楚把那些文档里不写、但实际会踩的坑摆出来。先说结论性的判断节点分类任务里数据划分方式、归一化处理、过平滑问题这三件事比换模型结构更能决定最终效果。后面会逐一展开。2. PyG 的数据组织方式Data 对象到底装了什么2.1 一张图在 PyG 里长什么样PyG 用torch_geometric.data.Data这个类来承载一张图。它的核心字段其实就几个但每个都有讲究x节点特征矩阵形状是[num_nodes, num_node_features]。每一行是一个节点的特征向量。edge_index边索引形状是[2, num_edges]注意是COO 格式第一行是源节点第二行是目标节点。这个格式和邻接矩阵不一样很多人第一次看会懵。y节点标签形状[num_nodes]或[num_nodes, num_classes]。train_mask/val_mask/test_mask布尔掩码标记哪些节点参与训练、验证、测试。为什么用edge_index而不是邻接矩阵因为真实图往往非常稀疏一个百万节点的图邻接矩阵是百万乘百万存不下也算不动。COO 格式只存存在的边空间复杂度是 O(边数)这才是图数据能规模化的关键。PyG 内部做消息传递时也是基于这个稀疏结构来聚合邻居的。我建议你在写任何模型之前先把数据加载出来打印一下这几个字段的形状和类型。这一步花不了两分钟但能帮你避开后面 80% 的形状报错。from torch_geometric.datasets import Planetoid dataset Planetoid(root/tmp/Cora, nameCora) data dataset[0] print(data) print(节点数:, data.num_nodes) print(边数:, data.num_edges) print(特征维度:, data.num_node_features) print(类别数:, dataset.num_classes) print(训练节点数:, int(data.train_mask.sum()))跑完你会看到 Cora 大概是 2708 个节点、10556 条边、1433 维特征、7 个类别训练节点只有 140 个。这个数字对比很震撼用 140 个带标签的节点去预测剩下两千多个节点的类别这就是半监督节点分类的难度所在。2.2 数据划分别小看那 140 个训练节点Cora 的标准划分是 140 个训练、500 个验证、1000 个测试。这个划分是固定的来自原始论文方便大家对比。但这里有个大坑很多人做自己的数据集时直接随机划分结果训练集里混进了测试集的邻居导致准确率虚高。节点分类和图像分类不一样。图像之间是独立的但图里节点之间是有边连接的。如果你随机划分训练节点和测试节点在图上紧挨着模型通过消息传递很容易偷看到测试节点的信息评估结果就不可信了。正确的做法通常有两种一是用标准的公开划分二是自己做划分时考虑按连通分量或者时间顺序来切避免信息泄漏。提示如果你在做链路预测或者动态图任务划分逻辑又不一样节点分类这套掩码机制不能直接套用。另外train_mask这类掩码是布尔张量长度等于节点数。训练时只对掩码为 True 的节点计算损失这一点在写训练循环时千万别搞错否则模型会拿测试标签去学评估就废了。2.3 特征归一化一个被严重低估的步骤Cora 的特征是词袋模型每一维是 0/1本身已经比较规整。但换成其他数据集特征尺度可能差异巨大。我见过太多人直接把手里的特征丢进 GCN结果训练 loss 震荡、不收敛排查半天发现是特征没归一化。GCN 的每一层都做了邻接矩阵的归一化后面会讲但它不会自动帮你归一化节点特征。所以标准做法是在送入模型前对x做行归一化每个节点特征向量归一化到单位长度或者标准化减均值除标准差。from torch_geometric.transforms import NormalizeFeatures dataset Planetoid(root/tmp/Cora, nameCora, transformNormalizeFeatures())NormalizeFeatures做的是行归一化把每个节点的特征向量 L2 归一化。对词袋类特征特别有效。如果是连续型特征比如传感器读数用标准化更合适。这个选择要看你的特征语义没有万能答案。3. GCN 层到底在算什么把公式翻译成人话3.1 从消息传递的视角理解 GCNGCN 的经典公式是$$H^{(l1)} \sigma(\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}H^{(l)}W^{(l)})$$很多人看到这个公式就头大。我换个说法每个节点把自己的特征发给所有邻居同时接收所有邻居发来的特征把这些特征加权平均一下再乘一个可学习的权重矩阵最后过个激活函数。就这么简单。公式里的 $\tilde{A} A I$就是邻接矩阵加上自环。为什么要加自环因为如果不加节点聚合时只考虑邻居会把自己的信息丢掉。加上自环后节点在聚合时也包含了自己这符合直觉。$\tilde{D}$ 是 $\tilde{A}$ 的度矩阵$\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}$ 这一串是对称归一化。为什么要归一化如果不归一化度数高的节点聚合后的特征值会非常大度数低的节点值很小数值不稳定训练容易炸。归一化之后每个节点的聚合结果被控制在合理范围内。用 $\tilde{D}^{-1/2}$ 而不是 $\tilde{D}^{-1}$是为了保持矩阵的对称性让传播更稳定。在 PyG 里这一整套操作被封装在GCNConv里你只需要指定输入维度和输出维度from torch_geometric.nn import GCNConv conv GCNConv(in_channels1433, out_channels16)但封装归封装你得知道它在背后做了什么。PyG 的GCNConv默认add_self_loopsTrue、normalizeTrue也就是自动加自环、自动做对称归一化。如果你手动改过这两个参数行为就变了这点要留意。3.2 两层 GCN 为什么是标配官方示例里GCN 通常是两层第一层把 1433 维降到 16 维第二层把 16 维映射到 7 个类别。为什么是两层而不是三层、四层这里涉及一个核心问题过平滑over-smoothing。每做一次图卷积节点特征就融合一次邻居信息。层数越多每个节点能看到的范围越大但代价是不同节点的特征会越来越像最后趋于一致分类就分不开了。两层 GCN 意味着每个节点能聚合到二跳邻居的信息对 Cora 这种图已经够用。堆到四层以上准确率往往不升反降。我实测过Cora 上两层 GCN 大概能到 81% 左右三层可能掉到 79%四层可能就 75% 以下了。所以别迷信深就是好图神经网络和 CNN 不一样深度不是越多越好。import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCN(torch.nn.Module): def __init__(self, num_features, hidden, num_classes): super().__init__() self.conv1 GCNConv(num_features, hidden) self.conv2 GCNConv(hidden, num_classes) 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)注意这里的dropout放在两层之间且只在训练时生效。这是防止过拟合的关键Cora 训练节点太少不加 dropout 很容易过拟合。3.3 激活函数和输出层的选择隐藏层用 ReLU 是默认选择简单有效。输出层用log_softmax配合NLLLoss或者用原始 logits 配合CrossEntropyLoss两者等价别混用。我见过有人输出层用了 softmax损失函数又用 CrossEntropyLoss等于做了两次 softmax结果怎么训都不对。注意PyG 的很多示例用F.log_softmaxNLLLoss如果你习惯CrossEntropyLoss那 forward 里就别加 softmax直接返回 logits。4. 训练循环里的门道不只是 forward 和 backward4.1 一个完整的训练循环长什么样节点分类的训练循环和普通神经网络最大的区别在于整个图是一次性送入的全图训练而不是分批。因为节点之间通过边耦合分批会破坏图结构。当然超大图有邻居采样的做法但那是另一个话题Cora 这种规模直接全图训练就行。device torch.device(cuda if torch.cuda.is_available() else cpu) model GCN(dataset.num_features, 16, dataset.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 test(): model.eval() out model(data.x, data.edge_index) pred out.argmax(dim1) accs [] for mask in [data.train_mask, data.val_mask, data.test_mask]: correct (pred[mask] data.y[mask]).sum() accs.append(int(correct) / int(mask.sum())) return accs这里有几个细节值得说。第一optimizer.zero_grad()必须在 backward 之前否则梯度会累加。第二评估时一定要model.eval()关掉 dropout。第三weight_decay5e-4是 Cora 上的经验值相当于 L2 正则能明显抑制过拟合。4.2 早停别让模型在验证集上反复横跳节点分类的标准做法是用验证集做早停。训练过程中记录验证集准确率如果连续若干轮没有提升就停止训练取验证集最优时的模型参数去测测试集。这样能避免过拟合也能省时间。best_val_acc 0 best_model_state None patience 100 counter 0 for epoch in range(1, 1001): loss train() train_acc, val_acc, test_acc test() if val_acc best_val_acc: best_val_acc val_acc best_model_state copy.deepcopy(model.state_dict()) counter 0 else: counter 1 if counter patience: print(f早停于 epoch {epoch}) break model.load_state_dict(best_model_state)patience设多少Cora 上 100 轮足够。太小容易早停太大浪费时间。我一般会先跑一次看验证集准确率的波动周期再定这个值。4.3 学习率与优化器的取舍Adam 是节点分类的默认优化器学习率 0.01 是 Cora 上的常用值。但这不是铁律。如果你换数据集学习率可能需要调。我的经验是先用 0.01 跑一遍看 loss 曲线。如果 loss 下降很慢调大到 0.05如果 loss 震荡剧烈甚至发散调小到 0.001。另外weight_decay对节点分类影响很大。Cora 训练节点只有 140 个不加正则几乎必然过拟合。5e-4 是个不错的起点可以试 1e-3 到 1e-4 之间。超参数常用值作用调整方向学习率0.01控制更新步长loss 震荡则调小下降慢则调大weight_decay5e-4L2 正则抑制过拟合过拟合严重则调大dropout0.5随机失活防过拟合过拟合则调大欠拟合则调小hidden16隐藏层维度太小欠拟合太大易过拟合层数2聚合跳数多了过平滑一般 2-3 层5. 换数据集就翻车这些坑我替你踩过了5.1 特征维度对不上最常见的低级错误新手换数据集时第一个报错往往是维度不匹配。原因很简单模型第一层的in_channels是写死的换了数据集特征维度变了但模型没改。解决办法是用dataset.num_features动态获取别硬编码。model GCN(dataset.num_features, 16, dataset.num_classes)同理类别数也要用dataset.num_classes。我见过有人把 Cora 的 7 类写死换到 Pubmed 的 3 类输出维度不对训练半天准确率上不去。5.2 边索引方向有向图还是无向图PyG 的edge_index默认是有向的。但很多图在语义上是无向的比如引用网络、社交网络。如果你只存了一个方向的边消息传递就只能单向传播效果会打折。标准做法是把每条无向边存成两条有向边。from torch_geometric.utils import to_undirected edge_index to_undirected(data.edge_index)Cora 数据集本身已经处理成双向了但你自己构建图时一定要注意。判断方法很简单看edge_index里 (i, j) 和 (j, i) 是不是都出现。如果只出现一个方向那大概率需要转成无向。5.3 孤立节点与自环的处理图里如果有孤立节点没有任何边GCN 聚合时它只能靠自环拿到自己的信息相当于退化成 MLP。如果你的图里孤立节点很多模型对它们的预测基本靠特征图结构帮不上忙。这种情况要么补边要么接受这个现实。自环的处理前面提过GCNConv默认加。但如果你用的是其他卷积层比如SAGEConv默认行为可能不同要查文档确认。加不加自环对结果有影响别想当然。5.4 过平滑的识别与缓解怎么判断模型是不是过平滑了一个简单信号训练准确率和验证准确率都很高但测试准确率明显低而且层数越多越严重。另一个信号是节点特征的余弦相似度随层数增加而趋近于 1。缓解手段有几种一是减少层数这是最直接的二是加残差连接让每层输出保留一部分输入信息三是用JumpingKnowledge把多层输出拼起来四是加PairNorm这类专门对抗过平滑的归一化。对 Cora 这种小图减层就够了不用上复杂手段。class GCNWithResidual(torch.nn.Module): def __init__(self, num_features, hidden, num_classes): super().__init__() self.conv1 GCNConv(num_features, hidden) self.conv2 GCNConv(hidden, hidden) self.lin torch.nn.Linear(hidden, num_classes) def forward(self, x, edge_index): x1 F.relu(self.conv1(x, edge_index)) x2 F.relu(self.conv2(x1, edge_index)) x x1 x2 # 残差 return F.log_softmax(self.lin(x), dim1)5.5 随机种子的重要性图神经网络对随机初始化很敏感。同一个模型换个随机种子准确率可能差 2-3 个百分点。所以做对比实验时一定要固定种子并且跑多次取平均。import torch import numpy as np import random def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)我一般会跑 5 次不同种子报告均值和标准差。只跑一次就下结论很容易被随机性误导。6. 从 GCN 到更广的 GNN什么时候该换模型6.1 GCN 的局限在哪里GCN 的聚合方式是固定的加权平均权重只由度决定不可学习。这意味着它对不同邻居一视同仁无法区分哪些邻居更重要。在引用网络这种同质图上问题不大但在异质图邻居和自身类别不同的图上GCN 表现会明显下降。另外GCN 是直推式transductive的训练时见过整张图没法直接泛化到新节点。如果你的场景需要给新来的节点预测得用归纳式inductive的模型。6.2 GraphSAGE 与 GAT 的适用场景GraphSAGE通过采样邻居并聚合支持归纳学习适合节点会动态增加的场景。它的聚合函数可以选 mean、max、lstm 等比 GCN 灵活。GAT引入注意力机制让每个邻居的权重可学习。在异质图或者邻居重要性差异大的图上GAT 往往比 GCN 好。代价是计算量更大参数更多。from torch_geometric.nn import SAGEConv, GATConv # GraphSAGE conv SAGEConv(in_channels, out_channels, aggrmean) # GATheads 是注意力头数 conv GATConv(in_channels, out_channels, heads8, concatTrue)选哪个我的建议是先跑 GCN 做 baseline如果效果不理想再试 GraphSAGE 和 GAT。别一上来就上复杂模型很多时候问题出在数据和调参上不是模型不够强。6.3 模型对比的公平性做模型对比时要保证除了模型本身其他条件完全一致同样的数据划分、同样的优化器、同样的学习率、同样的训练轮数、同样的早停策略。我见过有人拿调好参的 GCN 去比没调参的 GAT然后得出 GCN 更好的结论这不公平。模型聚合方式是否归纳适用场景GCN度归一化加权平均否同质图直推式任务GraphSAGE采样可学习聚合是动态图归纳式任务GAT注意力加权是异质图邻居重要性差异大7. 一些实战中的经验与体会跑节点分类这段时间我最大的感受是模型结构只是冰山一角数据质量和评估方式才是决定成败的关键。同样一个两层 GCN数据划分合理、特征归一化到位、早停策略正确能到 81%数据泄漏、特征没处理、训练轮数拍脑袋可能只有 70% 出头。这中间的差距比换任何模型都大。另一个体会是别怕看中间结果。训练时把每层的节点特征均值、方差打印出来看看有没有爆炸或者消失把验证集准确率曲线画出来看看是不是过拟合把预测错误的节点挑出来看看它们有什么共同点。这些动作花不了多少时间但能帮你快速定位问题。最后分享一个小技巧如果你在调参别一次改多个参数。固定其他只动一个记录结果。这样你才能知道每个参数的真实影响。一次改一堆就算效果变好了你也不知道是哪个起的作用下次换数据集还是抓瞎。节点分类这个任务入门容易做好难。但正因为难把每个环节吃透之后你对图神经网络的理解会上一个台阶。后面再去看链路预测、图分类、图生成很多思路是相通的。