资讯动态

PyTorch手写GCN原理到实战:Cora节点分类与避坑指南

发布时间:2026/10/18 1:52:37 来源:尧图企业网站定制
简介《深入浅出图神经网络GNN原理解析》配套代码面向研读该书或入门GNN的读者覆盖GCN、图采样、自注意力池化等章节的核心实现可配合书中理论进行复现与实验。压缩包共26个文件包含Python脚本、Markdown说明、Jupyter Notebook演示、Cora数据集及勘误PDF等兼顾代码阅读、环境配置与数据使用需要包体约306KB轻量便携。已有3484人学习。仓库对书中已知问题提供勘误并针对Cora数据集下载失效给出本地数据方案降低复现门槛。学习者可获取chapter5 GCN实现、chapter7采样与主流程、chapter8自注意力池化、chapter9自编码器等模块便于按图神经网络典型任务逐章推进。1. 图神经网络项目实战把 GNN 原理从公式变成能跑的 GCN 代码如果只读《深入浅出图神经网络GNN原理解析》而不动手跑配套代码大概率会卡在同一个地方书上 GCN 那节用一页篇幅推导谱域卷积怎么化简成邻域聚合合上书觉得懂了打开 Jupyter Notebook 却不知道邻接矩阵从哪来、归一化往哪放、两百轮训练怎么跑。这套代码把书里每个模型都落成了可逐格运行的 notebook从拉普拉斯矩阵到两层 GCN 的 Cora 节点分类再到 GAT 注意力聚合全程用 PyTorch 原生算子实现不套图神经网络库的黑匣子。适合读完书想验证公式与代码对应的同学也适合准备在自己数据集上做节点分类、链路预测的开发者——拿它当基线改一改就能跑。2. 代码仓库解剖目录结构、依赖清单与技术选型2.1 配套代码的典型组织方式与模块职责书籍配套代码和工业项目有一个显著区别工业项目按服务边界拆目录配套代码按书章节拆目录。读这本书的节奏是今天看完图信号处理、明天看 GCN 原理解析所以配套代码必须保证每一章能独立运行。拿到代码后先不急着开 notebook把仓库顶层结构过一遍你会发现这是个典型的四层组织data 放原始数据与预处理结果models 放每个章节定义的网络utils 放加载、预处理、指标计算的公共函数notebooks 放按章节拆好的演示文档。GraphNeuralNetwork/ ├── README.md ├── requirements.txt ├── data/ │ ├── cora/ │ │ ├── cora.cites │ │ └── cora.content │ ├── citeseer/ │ └── pubmed/ ├── models/ │ ├── __init__.py │ ├── gcn.py │ ├── gat.py │ └── ggnn.py ├── utils/ │ ├── __init__.py │ ├── data_loader.py │ ├── preprocess.py │ └── metrics.py └── notebooks/ ├── 01_图的基本表示.ipynb ├── 02_GCN节点分类.ipynb ├── 03_GAT节点分类.ipynb └── 04_链路预测入门.ipynbCora 是 GNN 领域最常用的引文网络数据集2708 篇论文分成 7 类每篇论文用 1433 维词袋向量表示cora.cites 里是 5429 条引用边。这个数据集对入门很理想规模小、类别均衡、跑一轮实验只要几十秒GCN 在这种规模上通常能到 80% 以上的准确率。citeseer、pubmed 是它的姊妹数据集结构一样区别只在数据量和特征维度代码里把 load_cora 的路径换掉就能复用。notebooks 目录按书章节命名01 讲图的基本表示02 讲 GCN 节点分类03 讲 GAT04 讲链路预测。建议阅读顺序是 01 到 02 到 03链路预测放最后因为它的损失函数和评估指标跟节点分类差别较大直接跑容易一头雾水。models/gcn.py 是本仓库最值得读的文件它把卷积层、两层网络、归一化逻辑写在一起utils/data_loader.py 几乎被每个 notebook import改动频率最高。2.2 技术选型为什么用 PyTorch 原生实现而不是 PyG这本书定位是「原理解析」配套代码的使命是让读者看清公式的每一步实现。拿 GCN 来说PyTorch Geometric 里一个 GCNConv 类就封装完了归一化和聚合但对原理学习来说这个类是个黑匣子为什么需要加自环、邻接矩阵为什么要对称归一化、消息传递到底在哪一步发生全被库遮住了。而用 PyTorch 原生算子手写一层 GraphConvolution代码量也就二十行却能完整暴露特征变换 WX、邻域聚合 AX 这两步核心操作。对比维度PyTorch 原生实现PyTorch Geometric可读性公式和代码逐行对应一个 GCNConv 封装到底依赖数量torch numpy scipy还要配 torch-geometric适用场景原理学习、自定义实验快速跑标准 benchmark环境成本低需要处理 torch 与 PyG 版本匹配Jupyter Notebook 在这种学习场景里比纯 .py 文件更合适。图卷积层的输入输出维度每一层都在变用 notebook 可以把中间张量 shape 打印出来甚至用一个小图人工推导一遍设三个节点的邻接矩阵、给每个节点赋值一个特征跑一次 forward 看输出和手算结果是否一致。这种验证方式在脚本文件里做起来很别扭但在 notebook 里天然就是「写一步、看一步」的流程。依赖也遵循「能少则少」的原则。实现核心模型只需要 torch、numpy、scipy 三个包networkx 用来画图、matplotlib 用来画训练曲线、pandas 偶尔用来读表格数据。刻意不引入 torch_geometric不是显得硬核而是因为一旦引入新手装环境时就要处理 torch 和 torch_geometric 的版本匹配问题很多人在这一步浪费几个小时。2.3 依赖清单与版本组合照着装的 requirements.txtrequirements.txt 大致长这样torch1.9.0 numpy1.19.0 scipy1.5.0 networkx2.5 matplotlib3.3.0 pandas1.1.0 jupyter1.0.0torch 是核心计算库所有模型和训练循环都依赖它numpy 负责把 cora.content 的文本特征转换成数值矩阵scipy 提供稀疏矩阵 CSR/COO 格式GCN 的邻接矩阵就是用 scipy.sparse.coo_matrix 构建的networkx 用来把边表转成图并做可视化matplotlib 画 loss 曲线和节点嵌入的二维散点图jupyter 是运行 notebook 的环境。版本方面要特别注意 torch 和 Python 版本的匹配。一般建议用 Python 3.8 或 3.9配 torch 1.10 以上的稳定版本如果机器上有 CUDA装对应 CUDA 版本的 torch 能大幅加速训练但 Cora 这种几千个节点的小数据集CPU 上跑 200 轮也就一两分钟不配 GPU 也完全能跑完。这个仓库体量不大环境上卡住的地方大多出在 scipy 稀疏矩阵转 torch 稀疏张量或者 torch 装错版本导致 import 报错看到这类问题先检查 requirements 版本不要急着改代码。我一般的习惯是用虚拟环境装依赖装完先跑一遍python -c import torch, scipy, networkx; print(torch.__version__)确认三个核心库能同时 import再开 notebook。这一步能过滤掉八成环境问题剩下的基本是路径和数据集缺失后面避坑章节会细说。3. 核心模块拆解从邻接矩阵到消息传递GCN 的完整实现链路3.1 图的矩阵表示与拉普拉斯代码里先处理什么GCN 的输入和普通神经网络的输入有个根本差异普通网络输入是一批独立的样本GCN 输入除了样本特征还多一张「图」。这张图在代码里最常见的存在形式是 N×N 的邻接矩阵 AA[i][j]1 表示节点 i 和节点 j 之间有边。特征矩阵 X 是 N×F每一行对应该节点的 F 维属性。图卷积的谱域推导绕不开拉普拉斯矩阵 L D - A但配套代码里并不直接用这个 L 做卷积而是用对称归一化后的版本。原因一句话说清直接乘 L 会把节点度数高低的尺度差异带进特征里归一化之后信号传播更稳定。代码里拉普拉斯有两种import numpy as np def compute_laplacian(adj): degree np.sum(adj, axis1) degree_matrix np.diag(degree) return degree_matrix - adj def compute_normalized_laplacian(adj): degree np.sum(adj, axis1).astype(np.float32) degree_inv_sqrt np.diag(np.power(degree, -0.5)) return np.eye(adj.shape[0]) - degree_inv_sqrt adj degree_inv_sqrt逻辑说明compute_laplacian 返回的是非归一化 Lcompute_normalized_laplacian 返回的是对称归一化 L_sym I - D^-1/2 A D^-1/2这是在谱图理论里特征值上限为 2 的那一版。实际 GCN 代码里用的归一化是 D^-1/2 A D^-1/2 而不是 I 减去它两者差一个单位矩阵效果上只影响自环的处理方式。参数 adj 是 scipy 稀疏矩阵或 numpy 稠密矩阵这里用 numpy 便于打印验证。建议第一次跑时选一个 5 个节点的小图手动构建邻接矩阵把 degree_matrix 打印出来看对角线再对照公式理解 D^-1/2 A D^-1/2 的每个位置为什么是那个数值。这一步理解了后面 GCN 的 forward 几乎不用调参。3.2 GraphConvolution 层把公式 H σ(ÂHW) 写成 torch 代码GCN 单层 forward 的核心是线性变换加消息聚合两步。线性变换就是 torch.mm(x, weight)把每个节点的 F 维特征映射到 F 维消息聚合是用稀疏矩阵乘法 torch.spmm(adj_norm, support)让每个节点收集邻居经过变换后的特征加权求和。这里的权重就是归一化邻接矩阵里预先算好的数值。import math import torch import torch.nn as nn class GraphConvolution(nn.Module): def __init__(self, in_features, out_features, biasTrue): super().__init__() self.in_features in_features self.out_features out_features self.weight nn.Parameter(torch.FloatTensor(in_features, out_features)) if bias: self.bias nn.Parameter(torch.FloatTensor(out_features)) else: self.register_parameter(bias, None) self.reset_parameters() def reset_parameters(self): stdv 1.0 / math.sqrt(self.weight.size(1)) self.weight.data.uniform_(-stdv, stdv) if self.bias is not None: self.bias.data.uniform_(-stdv, stdv) def forward(self, x, adj_norm): support torch.mm(x, self.weight) output torch.spmm(adj_norm, support) if self.bias is not None: output output self.bias return output参数说明in_features 是输入特征维度Cora 里是 1433out_features 是这一层输出维度隐藏层通常取 16adj_norm 是已经加了自环并做对称归一化的 N×N 稀疏矩阵。torch.mm 要求两个参数都是稠密矩阵torch.spmm 的输入第一个是稀疏矩阵、第二个是稠密矩阵返回稠密结果。初始化用 uniform 分布范围是正负 sqrt(1/out_features)这是早期 PyTorch Linear 层的标准做法。这一步代码是整个仓库里最值得抄进自己项目的部分。GCN、GAT、GraphSAGE 本质都是对邻居特征做加权聚合差异只体现在权重的计算方式上GCN 的权重来自归一化邻接矩阵的固定数值GAT 的权重来自 attention 系数GraphSAGE 的权重来自可学习的聚合函数。把消息聚合写成独立的 GraphConvolution 层后续换模型只需要替换这个层训练逻辑一行都不用动。3.3 两层 GCN 组装Cora 节点分类网络与归一化函数节点分类用两层 GCN 是标配输入层到隐藏层一个 GraphConvolution隐藏层到输出层一个中间加 ReLU 和 dropout。两层意味着每个节点最终能感知二阶邻居的信息Cora 上两层效果最好三层以上会出现过平滑所有节点的表示趋于一致准确率不升反降。class GCN(nn.Module): def __init__(self, nfeat, nhid, nclass, dropout0.5): super().__init__() self.gc1 GraphConvolution(nfeat, nhid) self.gc2 GraphConvolution(nhid, nclass) self.dropout dropout def forward(self, x, adj_norm): x torch.relu(self.gc1(x, adj_norm)) x torch.dropout(x, self.dropout, trainself.training) x self.gc2(x, adj_norm) return torch.log_softmax(x, dim1)归一化辅助函数单独放不塞进模型里def normalize_adj(adj): adj adj torch.eye(adj.size(0)) # 补自环让节点聚合时包含自身特征 degree adj.sum(dim1) degree_inv_sqrt degree.pow(-0.5) degree_inv_sqrt[torch.isinf(degree_inv_sqrt)] 0.0 return degree_inv_sqrt.view(-1, 1) * adj * degree_inv_sqrt.view(1, -1)逻辑说明normalize_adj 的输入是 torch 稀疏张量或稠密张量。如果传稠密矩阵运算很直观如果传稀疏矩阵需要先.to_dense()或改用稀疏对角矩阵乘法否则 degree_inv_sqrt.view 会把维度搞乱。实际项目里更稳的写法是先转稠密做归一化、再转回稀疏传给模型Cora 这种小图完全够用。nfeat、nhid、nclass 三个参数决定网络宽度nhid 可以调大换取表达能力但内存和时间也会线性增长。4. 数据加载与训练流程从 Cora 文本文件到 81% 准确率4.1 Cora 数据加载把两坨文本变成长稀疏矩阵Cora 数据集落地后是两个文件。cora.content 有 2708 行每行第一列是论文 ID最后一列是类别中间 1433 列是词袋特征。cora.cites 有 5429 行每行两个 ID表示前面那篇论文引用了后面那篇。数据加载要做的事就是把 content 的特征列变成 N×1433 的稀疏矩阵、类别列变成连续整数标签、cites 变成 N×N 的邻接矩阵。import numpy as np import scipy.sparse as sp import torch def load_cora(pathdata/cora/): idx_features_labels np.genfromtxt( f{path}cora.content, dtypenp.dtype(str)) features sp.csr_matrix(idx_features_labels[:, 1:-1], dtypenp.float32) labels idx_features_labels[:, -1] classes sorted(set(labels)) class_to_id {c: i for i, c in enumerate(classes)} labels_idx np.array([class_to_id[l] for l in labels], dtypenp.int64) idx np.array(idx_features_labels[:, 0], dtypenp.int64) idx_map {j: i for i, j in enumerate(idx)} edges np.genfromtxt(f{path}cora.cites, dtypenp.int64) edges np.array( [idx_map[e] for e in edges.flatten()], dtypenp.int64).reshape(edges.shape) adj sp.coo_matrix( (np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])), shape(labels_idx.shape[0], labels_idx.shape[0]), dtypenp.float32) adj adj adj.T.multiply(adj.T adj) - adj.multiply(adj.T adj) return features, adj, labels_idx逻辑说明genfromtxt 按字符串读入是为了保留 ID 和类别文本再用切片分离特征与标签idx_map 把原始论文 ID 映射到 0 到 2707 的连续索引不然邻接矩阵下标会有一个大跳变coo_matrix 接收「数据, (行索引, 列索引)」三元组来构建稀疏矩阵。最后一行是去重处理cora.cites 里每条引用只出现一次代码通过保留上三角的方式构造无向邻接矩阵避免 A[i][j] 和 A[j][i] 各计一次导致权重变 2。训练集、验证集、测试集的划分在配套代码里通常用固定索引Cora 的标准划分是 140 个训练节点、500 个验证节点、1000 个测试节点很多实现直接构造 mask 数组。我一般会在 load_cora 之后单独打印 features.shape、adj.shape、labels_idx 的类别分布确认数据没读歪再进训练。这个习惯在换 citeseer、pubmed 时特别管用。4.2 训练循环mask 索引决定模型从哪里学图神经网络的训练和普通分类有本质区别前向传播时模型看到的是全图反向传播时只更新训练节点上的损失。Cora 的 2708 个节点里每类只取 20 个共 140 个参与梯度计算剩下节点只提供结构信息。mask 就是一组索引PyTorch 用 output[idx_train] 取出行做 loss。def train(model, features, adj_norm, labels, idx_train, idx_val, epochs200, lr0.01, weight_decay5e-4): optimizer torch.optim.Adam( model.parameters(), lrlr, weight_decayweight_decay) history {train_loss: [], val_acc: []} for epoch in range(epochs): model.train() optimizer.zero_grad() output model(features, adj_norm) loss torch.nn.functional.nll_loss(output[idx_train], labels[idx_train]) loss.backward() optimizer.step() if epoch % 20 0: model.eval() with torch.no_grad(): val_output model(features, adj_norm) val_acc accuracy(val_output[idx_val], labels[idx_val]) history[train_loss].append(loss.item()) history[val_acc].append(val_acc) print(fEpoch {epoch:03d} | Loss {loss.item():.4f} | Val Acc {val_acc:.4f}) return history关键点集中在三行output[idx_train] 是索引操作取出训练集节点的预测分布labels[idx_train] 是对应的真实标签nll_loss 要求模型最后一层输出 log_softmax 后的对数概率。模型输出的是 2708×7 的矩阵但 loss 只在 140 行上计算——这个过程也叫直推式学习和传统归纳式学习的区别在于测试节点在训练时就能看到只是不能参与梯度计算。accuracy 函数配合使用def accuracy(output, labels): preds output.argmax(dim1) correct (preds labels).sum().item() return correct / len(labels)这里 output.argmax(dim1) 返回每个节点预测的类别下标和 labels 逐位比较之后求和再除以样本数就是准确率。评估时务必切到 model.eval() 并包在 torch.no_grad() 里否则 dropout 会干扰结果、梯度计算白白消耗显存。4.3 可视化与结果复现训练曲线与嵌入调试Cora 上两层 GCN 跑 200 轮训练 loss 会在前 50 轮快速下降之后缓慢收敛验证准确率落在 78% 到 81% 之间是正常范围。如果 val_acc 一直在 40% 以下徘徊先回头查邻接矩阵归一化和自环处理而不是调学习率——大概率是数据链路出了问题。def plot_history(history): import matplotlib.pyplot as plt fig, ax1 plt.subplots(figsize(6, 4)) ax1.plot(history[train_loss], labeltrain loss, colortab:red) ax1.set_xlabel(epoch) ax1.set_ylabel(loss, colortab:red) ax2 ax1.twinx() ax2.plot(history[val_acc], labelval acc, colortab:blue) ax2.set_ylabel(accuracy, colortab:blue) plt.title(GCN Training on Cora) plt.tight_layout() plt.show()画完曲线还能把节点嵌入可视化。取 GCN 隐藏层输出或者第二层输出用 TSNE 降维到二维按类别着色能看到同类节点聚成一团。这一步对调试特别有效如果嵌入图里各类完全混在一起基本可以断定特征没有传播开问题大概率出在归一化或模型层数上。我通常会把训练好的模型保存成 state_dict后面做消融实验时直接加载省去重复训练的时间。5. 避坑指南跑 GNN 代码时最容易翻车的五个位置5.1 坑位一忘了加自环模型永远学不到自己现象训练 loss 下降明显比正常情况慢Cora 上 val_acc 卡在 60% 左右上不去。原因GCN 的公式推导里邻接矩阵是 Â A I也就是说每个节点在聚合时包含自身。直接用原始 A每个节点只聚合邻居特征自己的特征反而丢了。这在图比较稀疏时影响被放大叶子节点几乎学不到有效表示。解决归一化之前先补自环。检查方式也简单打印 adj.diagonal().sum()结果应该等于节点数 N如果不是回到 normalize_adj 第一行去查。5.2 坑位二对称归一化写成行归一化准确率差几个点现象代码看上去能跑准确率比论文低 3 到 5 个点调大隐藏维度也补不回来。原因把 Â D^-1/2 Â D^-1/2 简写成了 D^-1 Â。行归一化只归一了出边权重列方向不做处理多跳传播时信号强度会随节点度数极大扭曲高密度区域的节点特征被过度放大。解决严格按对称归一化公式写。D 的计算对象是补过自环的邻接矩阵不是原始 A这一步顺序错了也会导致结果系统性偏差。5.3 坑位三scipy 稀疏矩阵直接塞进 torch.spmm现象报错 TypeError: addmm_ received an invalid combination of arguments或者 GPU 上报 device 不一致。原因scipy.sparse 是科学计算生态的对象torch.spmm 只认 torch.sparse.FloatTensor。很多新人把 load_cora 返回的 csr_matrix 直接传给模型类型对不上还有人把稀疏矩阵和稠密矩阵混着送进 .to(device)一半在 CPU 一半在 GPU。解决统一走一个转换工具函数def scipy_sparse_to_torch_sparse(adj): adj adj.tocoo().astype(np.float32) indices torch.LongTensor(np.vstack((adj.row, adj.col))) values torch.FloatTensor(adj.data) shape torch.Size(adj.shape) return torch.sparse.FloatTensor(indices, values, shape).coalesce()转换之后再做 .to(device)让稀疏矩阵和特征矩阵在同一个设备上。5.4 坑位四Cora 数据文件缺失或路径写死现象load_cora 返回空矩阵或者 notebook 里报 FileNotFoundError。git clone 下来后直接跑第一个 cell 就挂。原因Cora 原始数据不在仓库里或软链接失效代码里用相对路径 data/cora/notebook 的工作目录不在仓库根目录时路径全对不上。解决先把数据下载到本地 data/cora/ 下确认 cora.content 和 cora.cites 都存在文件大小不为 0。notebook 第一格用 os.chdir 切到仓库根目录或者所有路径用 os.path.join 基于项目根目录拼接。这个坑跟 GNN 本身无关但确实是配套代码里最容易让新手劝退的。5.5 坑位五随机种子没固定结果复现不了现象同一份代码连续跑三次测试准确率波动超过 2 个点文档里写的 80% 复现不出来。原因PyTorch 的模型参数初始化、dropout、以及数据加载的随机性没有统一控制。深度学习实验结果本来就带方差不固定种子会把参数初始化的差异叠加进结果。解决在训练入口统一设置import torch import numpy as np def set_seed(seed42): torch.manual_seed(seed) np.random.seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)如果追求更严格的复现把 torch.backends.cudnn.deterministic 也打开。做对比实验时每轮用不同 seed 多跑几遍取平均比单次跑到 82% 更有说服力。6. 二次开发指南从 GCN 扩展到 GAT 与自定义数据集6.1 抽象一个 BaseGNNLayer换模型只改聚合方式GCN 的 forward 已经说明一个事实GNN 层可以拆成「特征变换 邻居聚合」两段。做二次开发时先抽一个基类把特征变换固定下来class BaseGNNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.linear nn.Linear(in_dim, out_dim) def forward(self, x, adj): raise NotImplementedErrorGCN 继承它只需要重写 forward。这样设计的好处是后面接 GAT 或 GraphSAGE 时训练循环、评估函数、数据加载一行都不用动。6.2 从 GCN 切到 GAT注意力权重的注入点GAT 和 GCN 的差别集中在聚合权重上GCN 用归一化邻接矩阵里的固定系数GAT 用神经网络算出来的 attention 系数。常见做法是继承 BaseGNNLayer在 forward 里先算 alpha 再聚合极简版长这样class GATLayer(BaseGNNLayer): def forward(self, x, adj): h torch.relu(self.linear(x)) # 实际项目里用一个可学习的向量计算边上的注意力系数 return torch.spmm(adj, h)把注释替换成完整的注意力计算后其余代码保持原样Cora 上通常能比 GCN 再高 1 到 2 个点。6.3 换自己的数据集三个必须对齐的位置接自己的图数据只需要对齐三样东西特征矩阵 X 的 shape 是 N×F、邻接矩阵 adj 的 shape 是 N×N、训练验证测试 mask 是节点索引列表。我一般把 load_cora 里的三段逻辑拆开复用读特征、建边、划分 mask。只要这三个 shape 不出错模型的 forward 不用改。从那以后我每次换数据集第一步都是打印这三个 shape 再往下走省掉了大量排错时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑