简介:《深入浅出图神经网络:GNN原理解析》配套代码面向希望结合源码理解GNN原理的读者,覆盖GCN、图采样、自注意力池化、自编码器等章节示例,适合有一定Python和PyTorch基础、正在学习图神经网络的中级开发者。压缩包共26个文件、约306KB,整体小巧,便于下载后快速解压;以Python脚本、Jupyter Notebook、Markdown说明为主体,并附带Cora数据集相关数据文件和一份PDF勘误。py文件对应各章可运行代码,ipynb便于交互式调试,md梳理环境依赖与使用说明;作者还针对Cora数据集下载问题提供本地数据放置指引,可减小环境配置阻力。已有3484人学习下载,按章节划分的目录结构便于对应书中内容逐一复现。读者可拿到可直接运行的GNN模型代码、数据预处理脚本和章节实验示例,适合边读边实践、对照源码理解图卷积与图滤波等关键概念。此外,章节示例覆盖从基础图卷积到高级注意力池化,可帮助串联全书知识。
1. 为什么说GNN的代码比论文更值得拆:这份配套代码能帮你少走三个月弯路
很多做图神经网络的人都有这种体验:论文里的公式推导看得懂,消息传递也能画出来,但一打开代码就懵了——邻接矩阵为什么要归一化?GAT那个多头注意力拼接维度怎么就对不上?我自己当年啃GCN时,书读了三遍,代码跑通花了整整一个周末。后来拿到《深入浅出图神经网络:GNN原理解析》的配套代码,才明白原来公式到代码之间还隔着一堆张量形状和PyTorch Geometric的细节。这份资源是跟着教材一起走的Jupyter Notebook,覆盖GCN、GraphSAGE、GAT三个主流模型,从数据加载到训练评估一条链路全打通。适合两类人:一是刚入门GNN、看完理论想实操的,二是已经在用PyG但经常在某些参数上翻车、想找一份能对照排查的完整代码的人。是的,它不是那种讲完概念就扔给你的示例,而是能直接跑、能改、能换数据集的东西。
2. 环境准备与数据加载:从PyTorch Geometric到Planetoid数据集的三个隐藏依赖
2.1 环境搭建:版本匹配比什么都重要
图神经网络的代码看起来就是几个PyTorch类,但真正动手装环境时会发现,一半的报错都来自版本不匹配。PyTorch Geometric(简称PyG)是一个独立于PyTorch的扩展库,它对PyTorch的版本要求非常苛刻——不是说你装了PyTorch 2.0就能随便配PyG 2.3,它要求底层CUDA编译的ABI一致。常见的坑是torch和torch-geometric版本不匹配,导致ImportError或segmentation fault。
我先说结论:这份配套代码用的是PyTorch 1.13 + PyG 2.2的稳定组合。装的时候建议用pip指定版本,不要直接装最新版。以下是我复现时用的安装命令。
pip install torch==1.13.0 pip install torch-scatter -f https://data.pyg.org/whl/torch-1.13.0.html pip install torch-sparse -f https://data.pyg.org/whl/torch-1.13.0.html pip install torch-geometric==2.2.0注意这里两个-f参数指向的是PyG官方预编译的wheel索引,它会根据你的CUDA版本自动匹配合适的扩展包。如果你是用CUDA 11.7,这个链接是没问题的;如果是纯CPU环境,可以把链接换成cpu对应的whl文件,或者直接不指定链接,pip会尝试从PyPI拉,但很可能会拉到一个不兼容的预编译版。
装完之后验证一下:
import torch, torch_geometric print(torch.__version__, torch_geometric.__version__) # 期望输出类似:1.13.0 2.2.02.2 数据加载:Cora、CiteSeer、PubMed背后的缓存机制
PyG内置了Planetoid数据集,也就是常用的Cora、CiteSeer、PubMed这三个引文网络。加载代码非常短,但里面有几个隐藏行为会影响你后续的结果。
from torch_geometric.datasets import Planetoid import torch dataset = Planetoid(root='./data/Cora', name='Cora') data = dataset[0] print(f'节点数: {data.num_nodes}, 边数: {data.num_edges}, 特征维度: {data.num_features}') print(f'类别数: {dataset.num_classes}') print(data)运行后你会看到Data(x=[2708, 1433], edge_index=[2, 10556], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708])。这里的x是词袋特征,edge_index是COO格式的边列表,y是标签,三个mask分别表示训练、验证、测试节点的划分。
隐藏依赖在于Planetoid的下载和缓存:它默认从外部服务器下载cora/citeseer/pubmed的压缩文件,在root目录下生成raw和processed两个子目录。如果你第二次运行,它会直接读缓存,不再下载。但有一个坑:下载过程如果中断,processed目录里会留下一个不完整的文件,加载时不会报错,但data.num_nodes可能变成0。这时候必须手动删除整个Cora目录重新下载。
另一个隐藏行为是Planetoid内部的划分方式:train_mask默认每类取20个节点,val_mask每类取30个,test_mask取剩余全部。这个划分是固定的,由seed决定,PyG内部用了一个固定随机种子,所以不同机器上跑出来的划分完全一致。这保证了可复现,但也意味着你不需要自己再做随机划分。
2.3 第一个跑通的GCN训练脚本
拿到数据之后,最迫切的是把模型跑起来。下面这个是配套代码里最简版本的GCN训练循环,我稍微压缩了一下,但核心逻辑没动。
import torch.nn.functional as F from torch_geometric.nn import GCNConv class GCNNet(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): x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, training=self.training) x = self.conv2(x, edge_index) return F.log_softmax(x, dim=1) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = GCNNet(dataset.num_features, 16, dataset.num_classes).to(device) data = data.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-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() for epoch in range(200): loss = train() if epoch % 20 == 0: print(f'Epoch {epoch:03d}, Loss: {loss:.4f}')这个脚本有几点值得说。GCNConv内部完成了邻接矩阵的对称归一化,不需要你像手写代码那样去算度矩阵。F.log_softmax放在模型最后一层,配合F.nll_loss使用,而不是直接用F.cross_entropy,这是GNN教程里很常见的做法,注意别搞混。data.train_mask是一个布尔张量,直接作为索引用,PyTorch会自动选取True对应的节点行。如果你在自己的数据上发现loss变成NaN,先检查是不是特征里存在NaN值或者边索引有越界。
3. 核心模型逐行拆解:GCN、GraphSAGE、GAT的PyTorch实现差异
3.1 GCN:从邻接矩阵归一化到消息传递的代码实现
GCN在代码层面就是两个关键点:归一化规则和消息聚合。PyG的GCNConv把这两步都封装了,但为了理解,最好看一下它的参数。
from torch_geometric.nn import GCNConv import torch # 手动构造一个小图:5个节点,每条边是(源, 目标) edge_index = torch.tensor([[0, 1, 2, 3, 4, 0], [1, 2, 3, 4, 0, 2]], dtype=torch.long) x = torch.randn(5, 3) # 5个节点,3维特征 conv = GCNConv(in_channels=3, out_channels=4, add_self_loops=True, bias=True) out = conv(x, edge_index) print(out.shape) # torch.Size([5, 4])GCNConv内部先调用add_self_loops给每个节点加自环,然后计算归一化系数c_i = 1 / sqrt(deg(i) + 1),最后执行scatter_add完成邻居特征聚合。这里有个容易忽略的点:edge_index的默认方向是从源到目标,即edge_index[0]是源节点,edge_index[1]是目标节点。GCN聚合时是对每个目标节点聚合所有源节点的特征。如果你把顺序搞反了,结果完全不正确。
add_self_loops参数很关键。如果你已经手动加了自环,再开这个参数,就相当于每个节点有两个自环,相当于权重加倍。配套代码里默认开True,这是标准GCN的做法。如果你从别的代码库迁移过来,注意检查这个参数。
3.2 GraphSAGE:采样邻居与聚合函数的边界条件
GraphSAGE和GCN最大的区别是:GCN聚合所有邻居,GraphSAGE每层采样固定数量的邻居。PyG里实现GraphSAGE用的是SAGEConv,但采样逻辑需要单独配合NeighborSampler。
我们先看纯图卷积层:
from torch_geometric.nn import SAGEConv class SAGENet(torch.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 = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, training=self.training) x = self.conv2(x, edge_index) return xSAGEConv的默认聚合方式是mean,也就是对邻居特征求和后除以邻居数。你可以通过设置aggr='max'或aggr='lstm'来换聚合函数。其中lstm模式比较特殊,它要求输入节点按顺序排列,而且更吃内存,一般小图上不建议用。
配套代码里GraphSAGE部分最大的价值在于NeighborSampler的边界处理。看下面这段:
from torch_geometric.loader import NeighborSampler loader = NeighborSampler( edge_index=data.edge_index, sizes=[15, 10], batch_size=128, shuffle=True, num_workers=0 ) for batch_size, n_id, adjs in loader: x_batch = data.x[n_id] for i, (edge_index_e, e_id, size) in enumerate(adjs): x_batch = net.conv(x_batch, edge_index_e)这里的n_id不是原始节点ID,而是本batch涉及到的所有节点ID,包含被采样到的邻居。adjs是一个列表,每个元素对应一层采样结果。最坑的是edge_index_e里的节点索引是相对于n_id的,不是全局原始索引。如果你在后续计算损失时直接使用data.y[some_original_id],必须先把n_id映射回去。配套代码里专门写了一个node_id_mapping的字典,就是为了处理这个映射关系。
3.3 GAT:注意力系数计算和多头拼接的维度陷阱
GAT的核心是注意力权重计算。PyG的GATConv实现里,你需要额外指定heads和concat。
from torch_geometric.nn import GATConv class GATNet(torch.nn.Module): def __init__(self, in_channels, hidden_channels, out_channels, heads=8): super().__init__() self.conv1 = GATConv(in_channels, hidden_channels, heads=heads) self.conv2 = GATConv(hidden_channels, out_channels, heads=1, concat=False) def forward(self, x, edge_index): x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, training=self.training) x = self.conv2(x, edge_index) return x注意第一层heads=8,输出维度是hidden_channels * heads;第二层heads=1且concat=False,输出维度就是out_channels。如果不设置concat=False,即使heads=1,输出维度也会变成out_channels * 1,结果等价,但如果你在某些框架里看到额外维度,多半是这里没对齐。
另一个坑是负斜率negative_slope参数,它控制LeakyReLU的斜率,默认是0.2。这个数值对注意力分布的平滑程度有影响,但一般人不会去改它。如果你在自己的数据集上发现GAT比GCN差很多,先检查是不是多头参数写错了,然后检查是否出现了注意力退化,也就是所有邻居的注意力系数几乎一样,导致GAT退化成GCN的加权平均。这种情况下可以尝试增大negative_slope到0.3或0.5,或者增加heads到16。
4. 训练与评估避坑指南:过平滑、随机种子和数据划分的三类典型问题
4.1 过平滑现象:为什么深度超过3层准确率反而崩了
现象:把GCN从2层改成5层后,验证集准确率不升反降,从78%掉到60%左右,而且训练loss下降得很快,但验证loss很高。
原因:这是GNN里著名的过平滑问题。随着层数加深,每个节点的表示逐渐收敛到相同的子空间,区分度消失。我用代码验证过,第5层的输出特征之间的余弦相似度高达0.96,几乎是同一个向量。
解决:一是限制层数,配套代码里的半监督任务最多用3层;二是引入残差连接或跳跃连接。如果你确实需要深网络,可以改成JK-Net思路,把每层输出拼接起来再分类。实操上最简单的办法是设置add_self_loops=False,自环会加速过平滑,也可以换成带残差的GCNII层。但在我自己改代码时,发现最直接有效的还是减少层数,换更大的隐藏层。
4.2 随机种子与数据划分:论文里的70%训练集是怎么来的
现象:我用自己的训练集划分方式跑Cora,跑了五次结果一次比一次差,准确率波动上下5个百分点。而配套代码里的结果非常稳定。
原因:PyG的Planetoid默认划分是每类20个训练节点,不是按比例。这个划分基于一个固定的随机种子,所以你无论跑多少次都是一样的。但如果你用random_split函数或手动torch.manual_seed来划分,数据分布一变,模型效果自然跟着变。之前的波动就是因为我没有固定种子。
解决:复现时直接用data.train_mask不要自己划分。如果你确实需要自定义划分,那必须同时固定三个种子:随机种子、numpy种子、PyTorch种子,并保存划分好的mask。
import random, numpy as np, torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42) # 然后自定义划分 perm = torch.randperm(data.num_nodes) train_idx = perm[:int(data.num_nodes * 0.7)] val_idx = perm[int(data.num_nodes * 0.7):int(data.num_nodes * 0.85)] test_idx = perm[int(data.num_nodes * 0.85):]注意torch.manual_seed要在randperm之前调用,否则随机流状态会不一样。另外torch.cuda.manual_seed_all只影响CUDA,CPU上还需要torch.manual_seed。很多人只写set_seed,但忘了把DataLoader的num_workers设为0,多进程下数据加载的随机性依然会导致结果波动。
4.3 评估指标:Accuracy、Micro-F1和Macro-F1怎么选
现象:在分类不平衡的数据集上,用Accuracy看模型有90%正确率,但换Micro-F1只有0.6,Macro-F1只有0.4。
原因:Accuracy对多数类偏向太大。Cora等引文网络的类别分布相对均衡,所以Accuracy够用,但如果你换成自己的数据集,类别可能一个类有5000条样本,另一个只有50条,Accuracy就完全失真。
解决:配套代码里提供了三种评估函数,我建议全部打印出来对比。下面是一个通用的评估代码:
from sklearn.metrics import f1_score, accuracy_score def evaluate(model, data): model.eval() out = model(data.x, data.edge_index) pred = out.argmax(dim=1) acc = accuracy_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu()) micro_f1 = f1_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu(), average='micro') macro_f1 = f1_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu(), average='macro') return acc, micro_f1, macro_f1注意这里pred和data.y都要先cpu(),否则在GPU上直接比较会报错Device mismatch。还有一个小坑:如果标签不是从0开始的连续整数,sklearn的f1_score会报错,需要重新映射标签到0..N-1。我在处理一个模拟项目X的数据时遇到标签从1开始,当时怎么都报"Labels must be in [0, n_classes)",改了半天才发现是标签编码问题。
5. 从复现到进阶:把Cora换到自己的数据集上,需要改哪几个文件
5.1 自定义数据集的格式要求:邻接矩阵和特征矩阵的维度对齐
如果你想用自己的图数据,最稳妥的方式是构造一个Data对象,而不是去改Planetoid的加载逻辑。配套代码里已经预留了一个dataset_from_adj.py文件,你只需要准备好以下三类数据:x(节点特征矩阵),edge_index(边索引,形状为[2, M]),y(节点标签向量)。
import torch from torch_geometric.data import Data x = torch.randn(1000, 64) # 1000个节点,64维特征 edge_index = torch.tensor([[0, 1, 2, ...], [1, 2, 3, ...]], dtype=torch.long) y = torch.tensor([0, 1, 0, ...]) # 标签 data = Data(x=x, edge_index=edge_index, y=y) # 一定记得设置num_nodes,否则某些层会卡在edge_index校验上 data.num_nodes = 1000这里最容易出问题的是边索引的构造。你有两个选择:一是直接给出边的两个端点,二是从邻接矩阵转成edge_index。如果你手头是邻接矩阵,常见的做法是:
adj = torch.randn(1000, 1000) > 0.5 # 模拟稀疏邻接矩阵 edge_index = adj.nonzero(as_tuple=False).t().contiguous()注意nonzero返回的是所有非零位置的行列坐标,转置后正好是[2, M]。但这个操作非常吃内存,1000*1000的稠密矩阵还会勉强,如果到1万节点,直接内存爆炸。所以最好还是直接在边列表上hstack和vstack。
5.2 超参数调优:学习率、Dropout、隐藏层的组合顺序
我在复现几个GNN模型后,总结了一个靠谱的调参顺序:先固定隐藏层宽度为16,跑一个基础的learning rate和dropout;然后同时调lr和weight_decay;最后再调hidden_channels。不要一开始就上网格搜索,因为GNN训练很快,但每次看tensorboard还是需要时间。
下面是我常用的一个小范围搜索代码,可以用来找最优lr:
import itertools for lr, dropout in itertools.product([0.005, 0.01, 0.05], [0.2, 0.5, 0.8]): set_seed(42) model = GCNNet(dataset.num_features, 16, dataset.num_classes).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=5e-4) for epoch in range(200): train() val_acc = evaluate(model, data) print(f'lr={lr:.3f}, dropout={dropout:.1f}, val_acc={val_acc:.4f}')注意dropout只插在ReLU之后、下一层卷积之前,不要放在最后一层输出前面,否则会把log_softmax的输出随机置零,导致预测全是NaN。
5.3 验证技巧:用可视化确认节点嵌入是否真正学到了结构信息
最后一个技巧是验证模型不是退化模型。我在跑完训练后,会把倒数第二层的隐藏表示拿出来,用t-SNE降到二维,然后用散点图看节点颜色的聚类情况。这能直观判断模型是否学到了结构信息。
import matplotlib.pyplot as plt from sklearn.manifold import TSNE def visualize_embedding(model, data, name='embedding.png'): model.eval() with torch.no_grad(): x = model.conv1(data.x, data.edge_index) x = F.relu(x) # 取第一层embedding emb = x.cpu().numpy() tsne = TSNE(n_components=2, random_state=42).fit_transform(emb) plt.figure(figsize=(8, 8)) plt.scatter(tsne[:, 0], tsne[:, 1], c=data.y.cpu(), cmap='tab10', s=10) plt.colorbar() plt.savefig(name, dpi=150)如果散点图上不同类别的点没有明显聚类,而是混成一团,说明模型可能只学到了特征层的线性变换,没有利用图结构。这时可以先检查edge_index是否为空,或者是不是边索引方向搞反了。当年我就是因为把有向图的边方向写反了,导致所有节点只聚合了出边的邻居,t-SNE图完全是一团浆糊。从那以后,我每次换新数据集都会先跑一次可视化,再用精度说话。希望帮到你。
本文还有配套的精品资源,点击获取