第一次跑图神经网络的人,最常见的状态不是找不到代码,而是找到了开源项目却完全不知道从哪改起。我指的就是像 GCN、GTN、SiGAT、SDGNN 这类模型,网上能搜到不少仓库,可换到自己的论文场景时,大多数人卡在数据格式、邻接矩阵构造和 PyTorch 环境这三件事上。这篇文章就把这块讲清楚:用 PyTorch 从零写 GCN,再把 GTN 简化实现跑通,接着延伸到 SiGAT 的多关系注意力聚合,最后落到 SDGNN 的符号图边预测任务。整体按论文复现和毕设实验的节奏来组织,代码量不大,但每一步都能在你自己的环境里验证。
先给结论:GCN 适合作为第一个入门的模型,因为它结构最简单,公式少,代码量也少。GTN、SiGAT、SDGNN 更多是面向特定图任务的扩展,不适合一上来全部堆在一起跑。正确顺序是先跑通一个基础 GCN 节点分类,再根据论文任务决定要不要上后面三个模型。下面按我的实际落地顺序拆一遍。
1. 四个模型到底分别解决什么问题
1.1 先分清图数据上的四种任务
很多毕设题目写着“基于图神经网络的某某预测”,但拿到数据后不知道选哪个模型。这里我建议先按任务类型分,不要按模型名气选。
GCN 解决的是节点分类问题。比如社交网络里的用户属性预测、论文引用关系中的论文类别预测、大气污染监测站点等级判断、水文站点状态评估,本质都是把站点或样本看成节点,把站点之间的空间距离、相关性或引用关系看成边,然后预测每个节点的标签。GCN 的做法很简单:每一层把一个节点的邻居特征加权求和,再经过一次线性变换和激活函数。多叠几层,就能让信息沿着边传播更远。
GTN 解决的是异构图上的元路径学习问题。普通 GCN 默认只有一种节点、一种边,但现实数据往往有多个类型。比如论文、作者、会议是三种节点,引用、写作、发表于是三种边。GTN 的目标是自动学到哪些路径组合最有用,比如“作者-论文-作者”这种二阶元路径可以用来自动聚类学术社区。
SiGAT 更适合社交影响力分析。社交网络中用户之间存在关注、转发、评论等多种关系,不同关系对影响力传播的贡献不一样。SiGAT 的思路是在聚合邻居时,对不同关系类型分别计算注意力权重,再把多种关系的表示融合在一起。
SDGNN 面向符号有向图。这里的“符号”指边带有正负属性,比如“信任/不信任”、“支持/反对”。同时边有方向,A 信任 B 和 B 信任 A 含义不同。任务一般是边符号预测,给定一条边两端的节点,预测它带正号还是负号。
1.2 为什么不是直接下载一个开源仓库就跑
这个问题我踩过多次。网上很多 GNN 开源仓库是用老版本 PyTorch 写的,依赖的torch_geometric版本也和当前环境不匹配。你原样拉下来,启动报错、数据格式过时、Python 版本不兼容,往往要花两三天修环境。
更重要的是,论文场景要求你做消融实验和对比实验。你如果只用一个封装好的框架,很难解释自己的改进点在哪里。自己用 PyTorch 手写一遍,虽然前面会慢一点,但后面换数据、换任务、改模型都非常快。
下表是我对四个模型的实际判断:
| 模型 | 核心任务 | 典型应用方向 | 手写实现难度 | 适合入门程度 |
|---|---|---|---|---|
| GCN | 节点分类 / 半监督分类 | 论文引用、站点预测、用户分类 | 低 | 最先跑通 |
| GTN | 异构图元路径学习 | 异构网络、学术网络、推荐 | 中 | 有基础后尝试 |
| SiGAT | 多关系注意力聚合 | 社交影响力、多关系网络 | 中高 | 熟悉注意力后尝试 |
| SDGNN | 符号有向图边预测 | 信任网络、观点网络 | 中高 | 按需选择 |
如果你只是需要完成一个课设,跑 GCN 就够了。如果你要做毕设算法改进,通常是把 GCN 或 GTN 作为 Baseline,再提出自己的模块。SiGAT 和 SDGNN 更适合做横向对比模型。
2. 环境准备:先把 PyTorch 和图数据依赖装对
2.1 Python 环境与 PyTorch 安装优先级
我建议使用 Anaconda 创建独立环境,不要直接装在系统 Python 里。GNN 相关的包依赖很碎,独立环境可以随时重建,不会把别的项目搞坏。
创建环境的命令一般是这样:
conda create -n gnn python=3.10 conda activate gnnPython 版本我建议选 3.9 或 3.10。太新的版本有时候会和旧的 PyTorch 轮子冲突。这里不是越新越好。
接下来安装 PyTorch。先判断你的机器有没有可用 NVIDIA 显卡。可以用命令看:
nvidia-smi如果没有输出,说明没有 NVIDIA 驱动或者没有独立显卡,直接装 CPU 版本最省事:
pip install torch torchvision torchaudio如果有显卡,但属于老型号,比如很多人笔记本上的 MX150、MX250,这类显卡算力比较弱。我的个人建议是,学习阶段不要折腾 GPU 版本,直接 CPU 版本跑通代码更重要。GNN 在小规模数据集上 CPU 完全够用,Cora、Citeseer 这类数据集几十秒就能跑完一轮训练。装了 GPU 版本反而可能因为 CUDA 版本不匹配报一堆错。
如果你的显卡比较新,想用 GPU 加速,再按 CUDA 匹配版本安装。PyTorch 官方页面会提供不同 CUDA 版本的安装命令,比如 CUDA 11.8、12.1、12.4、13.0 等。要注意的是,这里的版本号必须小于等于你驱动支持的 CUDA 版本。不要看到数字大就装最新版。
2.2 判断安装成功的方法
安装完成后,先不要急着写模型,先跑这四行:
python -c "import torch; print(torch.__version__)" python -c "import torch; print(torch.cuda.is_available())"如果torch.cuda.is_available()返回False,不代表安装失败。CPU 版本本身就是False。如果你的显卡不支持当前 CUDA 版本,也返回False。这时候要区分:是 CPU 版本所以没有 CUDA,还是驱动不匹配导致检测不到。
安装完 PyTorch 后,我建议不要急着装torch_geometric。整篇教程里的 GCN、GTN、SiGAT、SDGNN 都可以只用 PyTorch 基础张量操作写完。等你自己手写一遍,再考虑要不要用图神经网络库来加速数据加载。对论文来说,手写实现更容易解释,也更容易查 bug。
注意:如果下载速度很慢,可以给 pip 配置国内镜像源,或者使用 conda 安装。不要反复中断重装,很多时候安装问题不是命令写错,而是网络传输不完整。
3. 先从 GCN 跑通第一个节点分类
3.1 GCN 的核心公式和归一化邻接矩阵
GCN 的核心操作可以理解成对邻居特征做加权求和。一个节点在图里的表示,等于它自己以及所有邻居的消息聚合结果。
常见的单层 GCN 公式是:
H' = σ(D^{-1/2} A D^{-1/2} H W)
其中 A 是邻接矩阵,D 是节点的度数矩阵。为什么要做这个变换?因为如果直接把 A 和特征乘在一起,那些度数特别高的节点会累积非常大的数值,不同节点的量纲不一致,训练很难稳定。D^{-1/2} A D^{-1/2} 相当于对每条边做了一个对称归一化,让高低度数节点都处于接近的数值范围。
实际写代码时,还要考虑自环。因为 GCN 更新节点表示时,理论上应该把节点自身信息也保留下来。常见做法是给 A 加上单位阵 I:
A_hat = A + I
然后在 A_hat 上做归一化。这个细节非常容易漏。漏掉之后,模型在孤立节点或者稀疏图上表现会很差。
一个基于 PyTorch 的归一化实现大概是这样的:
import torch def normalize_adjacency(adj): # adj 是带自环的邻接矩阵 degree = adj.sum(dim=1) degree_inv_sqrt = torch.diag(torch.pow(degree, -0.5)) # 处理除零,防止孤立节点 degree_inv_sqrt[torch.isinf(degree_inv_sqrt)] = 0.0 adj_norm = degree_inv_sqrt @ adj @ degree_inv_sqrt return adj_norm这段代码里的@是矩阵乘法。torch.diag把度数向量变成对角矩阵。核心就三行:算度数、取平方根倒数、做三次矩阵乘法。
3.2 用 PyTorch 手写两层 GCN
GCN 层本身不复杂。一个 Linear 层负责特征变换,一个矩阵乘法完成邻域聚合:
import torch.nn as nn import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.linear = nn.Linear(in_dim, out_dim) def forward(self, x, adj_norm): h = self.linear(x) return torch.mm(adj_norm, h)完整的两层 GCN 就是把这个层叠两次:
class GCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.layer1 = GCNLayer(in_dim, hidden_dim) self.layer2 = GCNLayer(hidden_dim, out_dim) def forward(self, x, adj_norm): h = F.relu(self.layer1(x, adj_norm)) return self.layer2(h, adj_norm)为什么是两层?很多论文里 GCN 就是两层。一层能聚合邻居信息,两层可以聚合到两跳范围内的信息。对 Cora 这类节点分类任务,两层已经能达到可用的效果。再深的话,图数据会出现过度平滑问题,所有节点表示趋同,分类效果反而下降。
3.3 在 Cora 这类数据集上做半监督分类
Cora 是论文引用数据集,里面有 2708 篇论文,每篇论文有一个 1433 维的词袋特征,类别有 7 种。边表示论文之间的引用关系。它是图神经网络论文中的经典数据,适合做第一次验证。
数据可以自己从公开数据集页面下载,通常包含两个文件:
cora.content:每行是一篇论文的编号、词向量和类别标签。cora.cites:每行是两篇论文的引用关系。
解析思路很简单。cora.content按空格切分,第一列是节点 ID,最后一列是类别,中间部分是特征向量。cora.cites按空格切分,第一列是被引论文,第二列是引用论文,构成有向边。
有了特征、标签和边列表,就可以构造邻接矩阵:
import torch edge_list = [] with open("./data/cora/cora.cites") as f: for line in f: parts = line.strip().split() u, v = int(parts[0]), int(parts[1]) edge_list.append([u, v]) edge_list.append([v, u]) # 变成无向边 num_nodes = 2708 adj = torch.zeros((num_nodes, num_nodes)) for u, v in edge_list: adj[u, v] = 1.0 adj = adj + torch.eye(num_nodes) adj_norm = normalize_adjacency(adj)训练时常用半监督设置:每个类别只取一部分节点当训练集,比如 20 个;其他节点一部分用于验证,一部分用于测试。这是 GCN 论文里的标准做法,也适合写进毕设实验。
训练循环和其他 PyTorch 分类任务一样:
model = GCN(in_dim=1433, hidden_dim=16, out_dim=7) optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) for epoch in range(200): model.train() optimizer.zero_grad() output = model(x, adj_norm) loss = F.cross_entropy(output[train_idx], y[train_idx]) loss.backward() optimizer.step() if epoch % 10 == 0: model.eval() with torch.no_grad(): pred = output.argmax(dim=1) acc = (pred[test_idx] == y[test_idx]).float().mean().item() print(f"epoch {epoch}, loss {loss.item():.4f}, test acc {acc:.4f}")判断训练是否正常,不要只看最后测试准确率,要先看 loss 是否在下降。正常情况前 50 个 epoch 内 loss 应该明显下降,测试准确率会逐步往上走。如果 loss 一直不降,或者变成 nan,先检查学习率是不是太高、邻接矩阵是不是没做归一化、特征里有没有 nan。
4. GTN:异构图上的元路径学习,怎么用 PyTorch 做简化实现
4.1 GTN 和 Graph Transformer 不是一回事
提到 GTN,很多人会联想到 Transformer,进而以为 GTN 就是把自注意力机制搬到图上。实际上,GTN 在原始论文中的全称是 Graph Transformer Network,核心不是多头注意力,而是自动学习异构图中的元路径。
异构图里,节点和边有不同类型。比如在论文网络中,作者、论文、会议分别对应不同节点类型,写作、引用、发表于对应不同边类型。如果直接用 GCN,必须先把异构边合并成一张无差别图,这会丢失大量关系信息。GTN 的做法是维护一组可学习的邻接矩阵组合方式,自动发现哪种路径模式对当前任务最有价值。
写论文时这里要注意:不要把 GTN 写成 Graph Transformer,评阅老师很可能盯住这个术语问题。
4.2 简化 GTN 层的实现思路
完整复现 GTN 需要做邻接矩阵的张量变换,代码比较绕。但如果是入门和论文 Baseline,可以先实现一个简化版:
- 输入是多个邻接矩阵,每种关系一个。
- 初始化为每个邻接矩阵分配一个可学习权重。
- 将多个邻接矩阵加权求和,得到一个组合邻接矩阵。
- 在组合邻接矩阵上继续做 GCN 或线性变换。
这个简化版至少能体现“多种关系自动组合”的核心思想。组合权重的实现可以用 Softmax 保证非负且总和为 1:
import torch.nn as nn import torch.nn.functional as F class SimpleGTNLayer(nn.Module): def __init__(self, relation_num): super().__init__() self.relation_weights = nn.Parameter(torch.ones(relation_num)) def forward(self, adjacency_list): weights = F.softmax(self.relation_weights, dim=0) combined_adj = 0 for w, adj in zip(weights, adjacency_list): combined_adj = combined_adj + w * adj return combined_adj注意,这里adjacency_list是多个形状相同的邻接矩阵组成的列表。如果关系数量很多,可以用批量张量代替列表。
组合之后,后续可以接 GCN 层,也可以叠多个 GTN 层来模拟更长的元路径。论文里如果想写“我们使用两层简化 GTN 来捕捉二阶元路径”,就可以把两个SimpleGTNLayer串起来,中间加一次归一化。
提醒:这个简化版适用于论文中的 Baseline 对比,不能声称是原版 GTN 的完整复刻。如果导师要求严格复现,需要把论文里的张量变换部分补上。
4.3 在论文中如何使用 GTN 对比
GTN 更适合在异构图数据集上做实验,常见的有 ACM、DBLP、IMDB。这些数据集有多个节点类型和边类型,评价指标一般用 Macro-F1 和 Micro-F1。
你可以这样设计实验:
| 方法 | 数据集 A Macro-F1 | 数据集 A Micro-F1 | 数据集 B Macro-F1 | 数据集 B Micro-F1 |
|---|---|---|---|---|
| GCN | 0.7621 | 0.7711 | 0.6310 | 0.6422 |
| GTN(简化版) | 0.7810 | 0.7924 | 0.6670 | 0.6719 |
| 本文方法 | 0.8033 | 0.8120 | 0.6901 | 0.7004 |
这样的表格能直观说明你的方法在基线之上有提升。注意,每行数据必须来自相同的数据划分和随机种子,否则比较没有意义。
5. SiGAT:社交影响力多关系注意力聚合
5.1 SiGAT 要处理的任务特点
SiGAT 的使用场景比 GCN 更贴近社交网络。你可以把用户看作节点,用户之间的关注、转发、评论看作不同类型的边。问题是:当聚合一个用户的信息时,该优先关注哪类关系,该更信任哪个邻居?
GAT(图注意力网络)也能给邻居分配权重,但 GAT 默认把所有边当成一种关系。SiGAT 的差异在于显式区分关系类型,让模型同时考虑多个影响力因素,再融合成一个节点表示。
如果你在毕设里研究谣言检测、社交用户属性分析,SiGAT 适合拿来做对比模型或改进基础。
5.2 一个可跑的 SiGAT 简化版
简化版 SiGAT 的核心可以拆成三部分:特征变换、每种关系下的注意力聚合、多关系融合。
先定义每个关系下的注意力聚合函数。对一条边 (u, v),注意力分数可以这样计算:
class RelationAttention(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.linear = nn.Linear(in_dim, out_dim) self.attention_vector = nn.Parameter(torch.randn(2 * out_dim)) def forward(self, x, edge_index): # x: [N, in_dim] h = self.linear(x) # [N, out_dim] src = h[edge_index[0]] # 源节点特征 dst = h[edge_index[1]] # 目标节点特征 attn_score = torch.cat([src, dst], dim=-1) @ self.attention_vector return attn_score如果每个关系都有自己的RelationAttention,得到多个聚合结果后,最简单的方式是直接相加或拼接,再通过一个线性层融合:
fusion = torch.stack(relation_outputs, dim=0).mean(dim=0)这里用mean只是初始版本。更完整的实现可以为每个关系再学一个权重,这正好可以作为你自己论文的改进点。
5.3 训练和评估的时候要注意什么
SiGAT 的输入组织方式通常有两种。一种是记录edge_index和edge_type,在同一个边集上按类型筛选;另一种是直接用多个邻接矩阵。写代码时我建议用edge_index加edge_type,因为 PyTorch 的索引操作更直观,也方便做 mini-batch。
评估指标要看任务类型。如果是节点分类,用准确率和 Macro-F1;如果是边预测,用 AUC 更合适。不要只看准确率,因为社交网络里类别经常不平衡。
第一次跑 SiGAT 时,建议在一个小图上验证代码能通,不要直接上大规模网络。小图能跑通后,再逐步扩大数据规模,观察显存和训练时间变化。
6. SDGNN:符号有向图上的表示学习
6.1 什么是符号图和有向符号预测
符号图是指边带有正负属性的图。电商平台上的信任关系、论坛里的正反对立、社交媒体的好友拉黑,都可以建模成符号图。如果边还带有方向,任务就更复杂:A 信任 B 不代表 B 信任 A。
关于 SDGNN 这个名字,不同仓库和资料里的实现口径不完全一致。如果你拿到的开源材料没有明确说明,建议先按最通用的符号有向图边符号预测任务来建模。这样既符合术语认知,也容易改造成自己的网络结构。
6.2 简化 SDGNN 实现
简化版可以分成编码器、边打分器和损失函数三部分。
编码器负责把节点变成向量表示。最简单的编码器是线性变换加激活:
class NodeEncoder(nn.Module): def __init__(self, in_dim, hidden_dim): super().__init__() self.linear = nn.Linear(in_dim, hidden_dim) def forward(self, x): return torch.relu(self.linear(x))边打分器判断一条边是正号还是负号。给定两个节点向量,先把两个向量拼接,再映射到 1 维:
class EdgeScorer(nn.Module): def __init__(self, hidden_dim): super().__init__() self.fc = nn.Linear(hidden_dim * 2, 1) def forward(self, source_vec, target_vec): edge_vec = torch.cat([source_vec, target_vec], dim=-1) return self.fc(edge_vec).squeeze(-1)训练时把正负边看成二分类问题:
loss = F.binary_cross_entropy_with_logits(pred, label)其中label是 0/1,1 表示正边,0 表示负边。输出时可以用 Sigmoid 转成概率,AUC 指标用概率计算。
6.3 负样本怎么采样
符号图预测中最容易出错的环节是负样本采样。如果你直接把所有未知边当负样本,不仅数量巨大,还会带来严重的数据泄露。常见做法是保留一部分真实负边作为测试集,然后在训练集中随机采样一部分节点对作为额外负样本。
假如训练集的负边数量太少,模型容易把所有边都预测成正边。可以尝试给负样本加权,或者调整损失函数中的正负样本比例。
评估时用 AUC 更稳妥。如果 AUC 一直在 0.5 附近,说明模型没有学到有效信息,先检查三点:负样本采样方式和测试集有没有重叠;节点特征和标签是否存在泄露;编码器输出是否全部收敛到同一个向量。
7. 拆成论文实验:表格怎么设计、消融怎么做
7.1 标准对比实验表格
毕业论文非常看重对比实验。通常的做法是:在主任务上列出 GCN、GAT、GTN、SiGAT、SDGNN 和你的方法,分别计算准确率、F1、AUC 等指标。
表格示例:
| 模型 | 节点分类 Acc | Macro-F1 | AUC | 训练时间 |
|---|---|---|---|---|
| GCN | 0.814 | 0.763 | 0.871 | 38s |
| GAT | 0.823 | 0.771 | 0.882 | 51s |
| GTN(简化版) | 0.832 | 0.780 | 0.893 | 60s |
| SiGAT(简化版) | 0.839 | 0.788 | 0.901 | 83s |
| SDGNN(简化版) | 0.846 | 0.795 | 0.910 | 96s |
| 本文方法 | 0.861 | 0.813 | 0.927 | 105s |
表格下面要写的不是“本文方法最好”这种空话,而是说明你的模型在哪类数据上提升明显,在哪类数据上提升有限。例如:在邻居数量偏少的稀疏节点上提升明显,在高度数节点上接近基线。这样写才像真实实验。
7.2 消融实验怎么做
消融实验的目的,是证明你的改进模块确实有效。做法是每次去掉一个模块,对比效果。
假设你的方法是在 SiGAT 上增加了多关系权重自适应层,那消融实验至少要有这四组:
- 完整模型
- 去掉多关系权重自适应层,改回简单平均融合
- 去掉注意力机制,改成普通线性聚合
- 只保留原始特征,不加图聚合模块
每组实验都要使用相同数据划分、相同训练轮数、相同随机种子。否则指标差异可能来自随机性,而不是模块本身起作用。
7.3 图表和复现记录建议
论文里除了表格,还需要训练曲线。用matplotlib画出训练集的 loss 下降曲线和验证集准确率曲线。画的时候注意两点:横轴是 epoch,纵轴是数值;每个方法画一条线,图例清晰。
复现记录一定要写环境信息。毕业论文经常需要填写实验环境说明,建议在论文附录里放一段:
- 操作系统:Ubuntu 22.04 / Windows 11
- Python 版本:3.10
- PyTorch 版本:2.1.0
- CPU 型号 / GPU 型号
- 显存 / 内存
- 随机种子
这些信息看起来琐碎,但等到别人想复现你实验时就是关键。
8. 常见报错与排查顺序
8.1 PyTorch 安装和环境匹配问题
报错信息里如果出现CUDA error: no kernel image is available,通常是显卡算力太低或驱动版本和 PyTorch 不匹配。最直接的解决办法是换 CPU 版本。
如果出现ModuleNotFoundError: torch,不要急着重装,先确认当前激活的是哪个 Python 环境。很多人 conda 环境切换后忘了重新安装依赖。
如果出现numpy 版本相关问题,优先检查 numpy 是否和 PyTorch 兼容。可以先卸载再安装对应版本,不要直接强制升级。
8.2 数据格式问题
解析 Cora 时,最容易报错的是int()解析失败。原因是标签列不是整数,比如Neural_Networks是字符串。解决办法是先建立一个类别到整数的映射。
构造邻接矩阵时,如果节点 ID 不是从 0 开始连续编号,矩阵会越界。解决办法是先收集所有节点 ID,重新映射到 0 到 N-1。
如果特征矩阵和标签张量的第一维长度不一致,先检查是不是漏掉了一些节点,或者解析cora.content时把空行也算进去了。
8.3 训练异常问题
loss 为 nan,常见原因有:学习率太高、邻接矩阵对角线有 nan、特征矩阵里有极大值、Softmax 权重出现退化为 0。排查顺序是先打印x.min(), x.max(),再打印adj_norm有没有 nan,最后降低学习率。
训练不收敛,但 loss 也没有变成 nan,常见原因是标签分配错误。比如训练集和测试集索引重叠,或者类别标签从 1 开始而不是从 0 开始,导致CrossEntropyLoss不匹配。
显存爆掉时,不要一开始就换大显卡。先检查是不是整个邻接矩阵用了torch.zeros((N, N))存储。图数据通常很稀疏,N 到几万时依然可能占用几个 GB 内存。解决思路是用稀疏矩阵存储,或者只保存边的索引,不保存完整邻接矩阵。
写在最后:这四个模型真正常用到的还是 GCN。先把 GCN 跑通,再根据自己的毕设任务决定要不要继续深入 GTN、SiGAT 或 SDGNN。论文里最重要的不是模型数量多,而是实验过程可复现、对比表干净、消融实验逻辑清晰。如果你在第一次跑通后发现准确率没有论文里那么高,不用着急,模型结构只是一部分,数据划分、超参数、随机种子、归一化方式都会影响结果。先确认环境稳定、数据没问题,再慢慢调参。