拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Sheaf神经网络在归纳式任务中的基准测试与PyTorch实现

之前在做图神经网络相关项目时经常遇到一个尴尬的问题常见的 GCN、GAT 在直推式任务里表现还不错但一旦换成归纳式任务比如用一批训练图去预测一批从未见过的测试图模型效果就不稳定甚至出现严重的过平滑。后来注意到 Sheaf Neural Networks层丛神经网络这一研究方向发现它通过更丰富的边结构建模在一些场景下确实能缓解传统图神经网络的表达瓶颈。这篇文章打算把围绕 Sheaf 神经网络在归纳式任务中做基准测试的完整思路整理出来从核心概念、数学背景到可运行的教学版实现再到基准测试协议设计尽量让有 PyTorch 基础的同学也能跟着复现。文章偏工程实践代码会尽量完整给出。1. Sheaf 神经网络到底是什么1.1 从传统 GNN 的局限说起传统图神经网络的核心思想是消息传递。以最简单的一层 GCN 为例节点更新可以写成[ X \sigma(\tilde{A} X W) ]其中 (\tilde{A}) 是加了自环并对称归一化后的邻接矩阵(X) 是节点特征矩阵(W) 是可学习的权重矩阵。这个式子的核心假设是相邻节点之间的信息传递强度可以用一个标量权重表示。比如节点 (u) 和节点 (v) 相连那么传播时就拿这个标量乘以邻居的特征向量。问题也随之而来现实图中的边关系非常复杂可能同一个节点在不同“语义维度”上对邻居的影响是不同的。例如在引文网络中一篇论文可能在“研究领域”这个维度上和某邻居很接近但在“实验方法”维度上和另一个邻居更相似。如果用单一标量统一建模就很容易丢失这种多维度关系。另一个让人头疼的问题是过平滑。网络堆深之后所有节点的特征会逐渐趋同导致节点分类准确率大幅下降。传统 GNN 在深层结构中很难保留局部差异性。1.2 细胞层丛与“茎”空间Sheaf Neural Networks 的思想来自拓扑学中的细胞层丛cellular sheaf概念。这里不需要把它想象得太抽象可以直接这样理解每个图节点 (v) 不再只拥有一个标量特征而是拥有一个向量空间称为茎stalk每条边 (e (u, v)) 上有一个线性映射负责把邻居茎空间中的向量“搬运”到当前边上再传到目标节点消息传递的过程就是在这些茎空间之间做线性变换、聚合和更新。如果每条边上的映射都是 (1 \times 1) 的标量那么层丛模型就退化成普通的 GCN。这也是为什么很多研究者把 Sheaf GNN 看作是 GNN 的“几何化扩展”。1.3 什么是归纳式任务直推式任务和归纳式任务的区别非常关键直推式任务训练时可以看到所有节点包括测试节点的特征和图中结构只是测试节点的标签不可见。典型的例子是 Cora 上的半监督节点分类。归纳式任务训练阶段完全没见过测试图或测试节点模型需要从训练图中学到可迁移的规律再应用到新图上。典型例子包括图分类、分子性质预测、社交网络中新用户推荐。在真实业务中归纳式任务更常见。新的分子、新的用户、新的会话不断出现模型不可能在训练时见过所有节点。因此评估一个 GNN 是否实用不能只看它在直推式数据集上的精度更需要在归纳式协议下做基准测试。2. 环境准备与版本说明本文代码基于 Python 和 PyTorch 生态核心依赖如下Python 3.8 及以上PyTorch 1.13 或 2.xPyTorch Geometric 2.3 及以上NVIDIA GPU 可选没有 GPU 也能运行只是速度慢一些安装命令可以参考pip install torch pip install torch-geometric由于不同操作系统、不同 CUDA 版本的安装方式有差异更推荐根据 PyTorch Geometric 官方文档选择对应的安装命令。如果网络条件一般可以配置国内 pip 镜像加速。版本问题需要提醒一下PyTorch Geometric 在 1.x 到 2.x 的迭代过程中部分 API 发生了变化。本文代码主要以 2.x 风格编写如果你使用的是老版本需要根据实际情况调整。3. 核心原理拆解从 GCN 到 Sheaf 消息传递3.1 传统 GCN 的消息传递在 PyTorch Geometric 中自实现一个 GCN 卷积层并不复杂。下面这个实现只保留了最核心的逻辑import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import MessagePassing from torch_geometric.utils import add_self_loops, degree class GCNConv(MessagePassing): def __init__(self, in_channels, out_channels): super().__init__(aggradd) self.lin nn.Linear(in_channels, out_channels, biasFalse) def forward(self, x, edge_index): # 1. 添加自环 edge_index, _ add_self_loops(edge_index, num_nodesx.size(0)) # 2. 计算对称归一化系数 row, col edge_index deg degree(col, x.size(0), dtypex.dtype) deg_inv_sqrt deg.pow(-0.5) norm deg_inv_sqrt[row] * deg_inv_sqrt[col] # 3. 传播 return self.propagate(edge_index, xx, normnorm) def message(self, x_j, norm): return norm.view(-1, 1) * self.lin(x_j)这里的aggradd表示邻居消息通过相加聚合norm是归一化系数。GCN 的线性变换发生在消息函数内部先对邻居特征做变换再乘以归一化系数。3.2 Sheaf 卷积的核心差异Sheaf 卷积和 GCN 最大的区别就是消息函数中的变换方式。在 GCN 中self.lin(x_j)是一个映射到输出特征空间的线性层但这个线性层是全局共享的和具体边的方向、类型无关。在简化版 Sheaf 卷积中我们为每个节点引入一个“束空间”表示。具体来说将原始节点特征 (x_v \in \mathbb{R}^{d}) 映射为多个茎向量对每条边用一个可学习的线性映射对邻居茎向量做变换变换后的茎向量按照目标节点聚合再解码为输出特征。下面给出一个教学用的简化实现。注意真实论文中的 Neural Sheaf Diffusion 会为每条边学习独立的限制映射这里为了控制显存和便于理解使用所有边共享同一个限制映射矩阵可以看作一个受限的 Sheaf 层。class SheafConvLayer(MessagePassing): def __init__(self, in_dim, out_dim, stalk_dim4): super().__init__(aggrsum) self.in_dim in_dim self.out_dim out_dim self.stalk_dim stalk_dim # 将输入特征映射为 out_dim 个茎向量每个茎向量维度为 stalk_dim self.enc nn.Linear(in_dim, out_dim * stalk_dim, biasFalse) # 限制映射这里是一个 stalk_dim x stalk_dim 的矩阵所有边共享 self.stalk_map nn.Linear(stalk_dim, stalk_dim, biasFalse) # 将处理后的茎向量解码回输出特征 self.dec nn.Linear(out_dim * stalk_dim, out_dim, biasTrue) self.reset_parameters() def reset_parameters(self): nn.init.kaiming_uniform_(self.enc.weight, modefan_in, nonlinearityrelu) nn.init.kaiming_uniform_(self.stalk_map.weight, modefan_in, nonlinearityrelu) nn.init.zeros_(self.dec.bias) def forward(self, x, edge_index): # 编码到束空间 x self.enc(x) # [N, out_dim * stalk_dim] # 添加自环 edge_index, _ add_self_loops(edge_index, num_nodesx.size(0)) # 归一化系数 row, col edge_index deg degree(col, x.size(0), dtypex.dtype) deg_inv_sqrt deg.pow(-0.5) norm deg_inv_sqrt[row] * deg_inv_sqrt[col] # 传播 out self.propagate(edge_index, xx, normnorm) # 解码 return self.dec(out) def message(self, x_j, norm): # x_j: [E, out_dim * stalk_dim] E x_j.size(0) # 将每个节点的特征拆成多个茎向量 x_j x_j.view(E, self.out_dim, self.stalk_dim) # 对每个茎向量应用限制映射 x_j self.stalk_map(x_j) # [E, out_dim, stalk_dim] # 还原维度 x_j x_j.view(E, -1) # 归一化 return x_j * norm.view(-1, 1)这段代码有几个需要说明的地方。enc的作用是把输入的in_dim维特征变成out_dim * stalk_dim维。也就是说每个节点会拥有out_dim个“通道”每个通道是一个stalk_dim维向量。消息传递时stalk_map会对每个通道的茎向量分别做线性变换。由于nn.Linear默认作用在张量的最后一维所以这里可以直接对[E, out_dim, stalk_dim]做变换非常方便。聚合后的特征经过dec解码到out_dim维度作为下一层的输入特征。3.3 为什么 Sheaf 能缓解过平滑从形式上看Sheaf 消息传递比 GCN 多了一道“茎空间内部变换”。这道变换让相邻节点的特征在传播时不会无条件地趋同而是会根据可学习的映射发生旋转、缩放甚至反转。在深层网络中如果一个节点的特征和邻居特征方向相反GCN 的加和会互相抵消而 Sheaf 可以通过限制映射先对齐方向再做聚合从而保留更多有效信息。这也是为什么 Sheaf 神经网络在模型加深时过平滑现象普遍轻于传统 GNN。4. 实战在 Cora 上对比 GCN 与 SheafConv4.1 实验目标先不急着直接上归纳式基准我们先在 Cora 引用网络上跑一个节点分类实验。这一步有两个目的验证我们实现的 GCN 和 SheafConv 是否正常收敛为后续归纳式实验提供一个对比基准。4.2 完整训练脚本下面是一个可以直接运行的实验脚本包含数据加载、模型定义、训练和评估。import os import argparse import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.datasets import Planetoid from torch_geometric.transforms import NormalizeFeatures from torch_geometric.utils import add_self_loops, degree from torch_geometric.nn import MessagePassing # ---------- GCN Layer ---------- class GCNConv(MessagePassing): def __init__(self, in_channels, out_channels): super().__init__(aggradd) self.lin nn.Linear(in_channels, out_channels, biasFalse) def forward(self, x, edge_index): edge_index, _ add_self_loops(edge_index, num_nodesx.size(0)) row, col edge_index deg degree(col, x.size(0), dtypex.dtype) deg_inv_sqrt deg.pow(-0.5) norm deg_inv_sqrt[row] * deg_inv_sqrt[col] return self.propagate(edge_index, xx, normnorm) def message(self, x_j, norm): return norm.view(-1, 1) * self.lin(x_j) # ---------- Sheaf Layer ---------- class SheafConvLayer(MessagePassing): def __init__(self, in_dim, out_dim, stalk_dim4): super().__init__(aggrsum) self.in_dim in_dim self.out_dim out_dim self.stalk_dim stalk_dim self.enc nn.Linear(in_dim, out_dim * stalk_dim, biasFalse) self.stalk_map nn.Linear(stalk_dim, stalk_dim, biasFalse) self.dec nn.Linear(out_dim * stalk_dim, out_dim, biasTrue) self.reset_parameters() def reset_parameters(self): nn.init.kaiming_uniform_(self.enc.weight, modefan_in, nonlinearityrelu) nn.init.kaiming_uniform_(self.stalk_map.weight, modefan_in, nonlinearityrelu) nn.init.zeros_(self.dec.bias) def forward(self, x, edge_index): x self.enc(x) edge_index, _ add_self_loops(edge_index, num_nodesx.size(0)) row, col edge_index deg degree(col, x.size(0), dtypex.dtype) deg_inv_sqrt deg.pow(-0.5) norm deg_inv_sqrt[row] * deg_inv_sqrt[col] out self.propagate(edge_index, xx, normnorm) return self.dec(out) def message(self, x_j, norm): E x_j.size(0) x_j x_j.view(E, self.out_dim, self.stalk_dim) x_j self.stalk_map(x_j) x_j x_j.view(E, -1) return x_j * norm.view(-1, 1) # ---------- GNN Models ---------- class GCNNet(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.conv1 GCNConv(in_dim, hidden_dim) self.conv2 GCNConv(hidden_dim, out_dim) def forward(self, x, edge_index): x self.conv1(x, edge_index) x F.relu(x) x F.dropout(x, trainingself.training) x self.conv2(x, edge_index) return x class SheafGCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, stalk_dim4): super().__init__() self.conv1 SheafConvLayer(in_dim, hidden_dim, stalk_dim) self.conv2 SheafConvLayer(hidden_dim, out_dim, stalk_dim) def forward(self, x, edge_index): x self.conv1(x, edge_index) x F.relu(x) x F.dropout(x, trainingself.training) x self.conv2(x, edge_index) return x # ---------- Train Eval ---------- def train(model, data, optimizer): model.train() optimizer.zero_grad() out model(data.x, data.edge_index) loss F.cross_entropy(out[data.train_mask], data.y[data.train_mask]) loss.backward() optimizer.step() return loss.item() torch.no_grad() def evaluate(model, data, mask): model.eval() out model(data.x, data.edge_index) pred out.argmax(dim-1) correct (pred[mask] data.y[mask]).sum().item() return correct / int(mask.sum()) def run_cora_experiment(seed42, stalk_dim4, hidden_dim64, epochs200, lr0.01): torch.manual_seed(seed) np.random.seed(seed) dataset Planetoid(root/tmp/Planetoid, nameCora, transformNormalizeFeatures()) data dataset[0] in_dim dataset.num_node_features out_dim dataset.num_classes results {} for model_name, model_cls in [ (GCN, lambda: GCNNet(in_dim, hidden_dim, out_dim)), (SheafGCN, lambda: SheafGCN(in_dim, hidden_dim, out_dim, stalk_dim)), ]: model model_cls() optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay5e-4) best_val 0 best_test 0 for epoch in range(epochs): loss train(model, data, optimizer) val_acc evaluate(model, data, data.val_mask) test_acc evaluate(model, data, data.test_mask) if val_acc best_val: best_val val_acc best_test test_acc results[model_name] (best_val, best_test) print(f{model_name}: best_val{best_val:.4f}, best_test{best_test:.4f}) return results if __name__ __main__: run_cora_experiment()4.3 运行结果说明这段脚本在 Cora 上的运行时间一般不会太长CPU 环境下大致也在分钟级别。每次运行会打印两个模型的验证集准确率和测试集准确率。这里特别说明由于我使用了所有边共享限制映射的简化版 Sheaf它并不完全等价于原论文中的 Neural Sheaf Diffusion所以它的测试准确率不一定会显著高于 GCN。如果你的目标是在学术论文中报告 Sheaf 模型的优势需要使用原版实现和更细致的超参数搜索。本文的重点是提供一个基准测试框架而不是证明某个模型一定最强。5. 归纳式任务基准图分类实验5.1 为什么图分类更符合归纳式任务图分类任务天然具有归纳式特性训练集由若干张图组成测试集由训练时完全未见过的新图组成模型必须从训练图的结构和特征中学习可泛化的规律。常见的基准数据集包括 TUDataset 中的 MUTAG、PROTEINS、ENZYMES、IMDB-BINARY 等。分子数据集中的每张图代表一个分子节点是原子边是化学键图的标签是分子属性。5.2 图分类完整脚本下面的脚本直接在 TUDataset 上对比 GCN 和 SheafGCN代码基于上一节的模型定义只需要修改数据加载和训练逻辑。import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.datasets import TUDataset from torch_geometric.loader import DataLoader from sklearn.model_selection import train_test_split def build_loaders(dataset_name, root/tmp/TUDataset, batch_size32, seed42): dataset TUDataset(rootroot, namedataset_name, use_node_attrTrue) dataset dataset.shuffle(seedseed) num_graphs len(dataset) num_train int(num_graphs * 0.8) num_val int(num_graphs * 0.1) num_test num_graphs - num_train - num_val train_dataset dataset[:num_train] val_dataset dataset[num_train:num_train num_val] test_dataset dataset[num_train num_val:] train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_dataset, batch_sizebatch_size, shuffleFalse) test_loader DataLoader(test_dataset, batch_sizebatch_size, shuffleFalse) return train_loader, val_loader, test_loader def train_epoch(model, loader, optimizer): model.train() total_loss 0 for batch in loader: optimizer.zero_grad() out model(batch.x, batch.edge_index) loss F.cross_entropy(out, batch.y) loss.backward() optimizer.step() total_loss loss.item() * batch.num_graphs return total_loss / len(loader.dataset) torch.no_grad() def evaluate_accuracy(model, loader): model.eval() correct 0 total 0 for batch in loader: out model(batch.x, batch.edge_index) pred out.argmax(dim-1) correct (pred batch.y).sum().item() total batch.num_graphs return correct / max(total, 1) def run_graph_classification( dataset_namePROTEINS, hidden_dim64, stalk_dim4, epochs80, lr0.001, seed42 ): train_loader, val_loader, test_loader build_loaders(dataset_name, seedseed) # 读取一个 batch 确定输入特征维度 sample next(iter(train_loader)) in_dim sample.x.size(1) out_dim int(sample.y.max().item()) 1 results {} for model_name, model_cls in [ (GCN, lambda: GCNNet(in_dim, hidden_dim, out_dim)), (SheafGCN, lambda: SheafGCN(in_dim, hidden_dim, out_dim, stalk_dim)), ]: model model_cls() optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay5e-4) best_val_acc 0 best_test_acc 0 for epoch in range(epochs): train_epoch(model, train_loader, optimizer) val_acc evaluate_accuracy(model, val_loader) test_acc evaluate_accuracy(model, test_loader) if val_acc best_val_acc: best_val_acc val_acc best_test_acc test_acc results[model_name] (best_val_acc, best_test_acc) print(f{model_name}: best_val{best_val_acc:.4f}, best_test{best_test_acc:.4f}) return results if __name__ __main__: run_graph_classification(PROTEINS, epochs50)5.3 基准测试中需要注意的实验设置在跑图分类基准时有几点会直接影响结论的可靠性。第一数据集划分必须固定。最好使用官方或者学术界通用的划分方式或者用固定的随机种子多次运行取平均值和标准差。否则不同的随机划分可能导致结果差异很大。第二测试集只允许在训练完成后评估一次。如果反复用测试集调参就会产生信息泄漏导致报告的准确率偏高。第三分子数据集的节点特征非常重要。启用use_node_attrTrue后会使用数据集中原始的节点属性作为输入特征否则可能退化成仅用 one-hot 度特征。不同的特征设置会让模型对比结果完全不同。6. 常见问题与排查思路问题现象常见原因解决思路模型训练损失不下降学习率过大或过小特征未归一化尝试学习率 (0.1) 到 (0.0001)对节点特征做标准化SheafConv 显存占用过高原版实现为每条边学习独立映射矩阵参数量巨大使用共享限制映射或者采用对角化映射压缩参数深层网络准确率反而下降过平滑加入残差连接、层归一化或尝试 Sheaf 扩散层图分类测试集准确率波动大数据集划分随机测试集样本少固定种子多次运行取均值报告标准差运行报错expected scalar type Float节点特征不是浮点类型多为类别 id 特征转成torch.float32再进行后续计算TUDataset 下载失败网络环境受限手动下载数据集放入指定目录并确认目录结构符合 PyG 要求这里重点说一下过平滑问题。在实验中发现GCN 堆到 (3) 层以上Cora 测试集准确率会明显下降而 SheafConv 的下降速度会慢一些。但这不等于 Sheaf 模型一定更好因为简化版 Sheaf 的参数量和 GCN 并不一致需要控制变量的对比才有说服力。7. 最佳实践与工程建议7.1 基准测试要控制变量对比两个模型时最忌讳的是只比较最终精度而忽略了参数量、训练时间和随机种子。一个严谨的基准测试应该至少包含以下几个信息数据集名称和划分方式模型参数量训练轮数和学习率多次运行的均值与标准差硬件环境。在输出报告时可以用一个统一的表格记录。下面是一个参考结构模型数据集参数量测试准确率标准差训练时间GCNCora约 9 万0.81000.00530sSheafGCNCora约 10 万0.81500.00445s7.2 归纳式任务的评估协议如果你要在自己的业务数据上评估模型建议按照下面的流程设计协议如果数据是多张图按图划分训练集、验证集、测试集不要按节点划分如果数据是一张大图可以将大图切分成多个连通子图再按子图划分这样更贴近真实场景训练时不要让模型看到测试子图的任何节点特征和边如果模型依赖目标节点的邻居信息要确认推理时邻居信息是否真的可用。7.3 计算效率优化Sheaf 模型的核心瓶颈在边上的矩阵运算。如果使用共享限制映射计算量接近普通 GCN显存开销小如果使用每条边独立限制映射参数量和显存都会大幅上升不适合大规模图介于两者之间的方案是使用对角限制映射即只学习一个对角矩阵降低运算量。在实际业务中建议先跑通共享映射版本确认收益后再逐步提高限制映射的复杂度。7.4 代码工程化建议写实验代码时建议把模型定义、数据加载、训练逻辑、日志输出拆分到不同模块中方便切换数据集和模型。日志记录至少要包含以下内容当前 epoch训练损失验证集指标最佳模型对应的测试集指标。实验配置建议用配置文件管理例如 YAML 或 JSON避免在代码里硬编码一堆超参数。8. 总结与下一步学习方向这篇文章从传统 GNN 的局限出发介绍了 Sheaf Neural Networks 的核心思想并给出了一套针对归纳式任务做基准测试的可行方案。我们实现了两层 GCN 和两层简化版 SheafConv在 Cora 上验证了模型可以正常收敛又在 TUDataset 图分类任务上设计了真正的归纳式评估流程。这套流程可以直接扩展到 MUTAG、ENZYMES、IMDB-BINARY 等数据集也可以替换成自己的业务图数据。如果你希望继续深入可以从以下几个方向着手阅读原版论文理解 Neural Sheaf Diffusion 的完整数学模型尝试在每条边上学习独立限制映射观察模型表达能力的变化在更大规模的 OGB 数据集上做 benchmark关注显存和训练时间的开销将 Sheaf 思想与 Transformer、GraphSAGE 等结构结合探索更适合归纳式任务的消息传递机制。动手跑一遍代码比读十遍文章更有价值。如果这篇文章对你有帮助可以收藏备用后续遇到相关问题也能快速查阅。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门