本文为pyg官网的中文翻译(官方原版链接 https://pytorch-geometric.readthedocs.io/en/latest/),并加入了一些其他网上大佬和自己的理解。如有翻译理解不当之处还请各位多多指出,非常感谢!
3.1Creating Message Passing Networks
将卷积算子推广到不规则域通常表示为邻域聚合或消息传递方案。用表示第(k-1)层中节点 i 的特征,表示节点 j 到节点 i 的(可选)边缘特征,消息传递图神经网络可以描述为
其中表示一个可微的,排列不变的函数,例如sum, mean或max; 和表示可微函数,如MLPs(多层感知器)。
3.1.1 The “MessagePassing” Base Class
PyG提供了MessagePassing
基类,它通过自动处理消息传播来帮助创建这种类型的消息传递图神经网络。用户只需要定义函数即message()
和即update()
,以及要使用的聚合方案,即aggr=“add”
,aggr=“mean”
或aggr=“max”
。
以下为消息传播的辅助方法:
MessagePassing(aggr="add", flow="source_to_target", node_dim=-2)
:定义要使用的聚合方案(“add”, “mean"或"max”)和消息传递的流方向(“source_to_target"或"target_to_source”)。此外,node_dim属性指示沿哪个轴传播(默认值是-2)。MessagePassing -propagate(edge_index, size=None, **kwargs):
开始传播消息的初始调用。接收边缘索引和所有构建消息和更新节点嵌入所需的附加数据。注意,==propagate()==不仅限于交换形状为[N, N]的方形邻接矩阵中的消息,而且还可以通过传递size = (N, M)作为附加参数来交换形状为[N, M]的一般稀疏分配矩阵中的消息,例如,二部图。如果设置为None,则假定赋值矩阵为方阵。对于有两个独立节点和索引集的二部图,每个集合都有自己的信息,这种分割可以通过将信息作为元组传递来标记,例如x=(x_N, x_M)。Messagepassing .message(…)
:如果flow="source_to_target"和=flow=“target_to_source”,则为每条边构造类似于为节点i构造消息。可以接受最初传递给==propagate()==的任何参数。此外,传递给propagate()的张量可以通过将 _i 或 _j 附加到变量名(例如 x_i 和 x_j )来映射到各自的节点 i 和 j 。请注意,我们通常将i称为聚合信息的中心节点,并将j称为相邻节点,因为这是最常见的表示法。MessagePassing.update(aggr_out,…):
对每个节点以类似的方式更新节点嵌入节点。将聚合的输出作为第一个参数最初传递给propagate()。让我们通过重新实现两个流行的GNN变体来验证这一点,它们分别是Kipf和Welling的GCN层和Wang等人的EdgeConv层。
3.1.2 实现GCN层
GCN层在数学上定义为:
其中相邻节点特征首先用权重矩阵 W 变换,再按它们的 度 归一化,最后求和。最后,我们将偏置向量b应用于聚合输出。该公式可分为以下步骤:
messageppassing
基类可以很容易地处理步骤4-5。完整的层实现如下所示:import torch
from torch.nn import Linear, Parameter #上一章还在讲datasets这章就直接用torch.nn了,不科学
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__(aggr='add') # "Add" aggregation (Step 5)加偏置b.
self.lin = Linear(in_channels, out_channels, bias=False)
self.bias = Parameter(torch.empty(out_channels))
self.reset_parameters()
def reset_parameters(self):
self.lin.reset_parameters()
self.bias.data.zero_()
def forward(self, x, edge_index): #所有的logits都发生在forward()方法部分
# x has shape [N, in_channels]
# edge_index has shape [2, E]
# Step 1: Add self-loops to the adjacency matrix.在邻接矩阵中加入自环
edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
# Step 2: Linearly transform node feature matrix.线性变化节点特征矩阵
x = self.lin(x)
# Step 3: Compute normalization.归一化
row, col = edge_index
deg = degree(col, x.size(0), dtype=x.dtype)
deg_inv_sqrt = deg.pow(-0.5)
deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
# Step 4-5: Start propagating messages.开始传递信息
out = self.propagate(edge_index, x=x, norm=norm)
# Step 6: Apply a final bias vector.加上最后的偏置
out += self.bias
return out
def message(self, x_j, norm):
# x_j has shape [E, out_channels]
# Step 4: Normalize node features.
return norm.view(-1, 1) * x_j
GCNConv
继承了带有 “add” 传播的Messagepassing
。该层的所有逻辑都发生在它的forward()
方法中。在这里,我们首先使用torch_geometry .utils.add_self_loops()
函数(第1步)向边缘索引添加自循环,并通过调用torch.nn.Linear
实例(第2步)对节点特征进行线性变换。
归一化系数由每个节点i的节点度 deg(i) 导出,每个边转换为,结果保存在形状为[num_edges,]
的张量范数中(步骤3)。
然后调用propagate()
,它在内部调用message()、aggregate()
和update()
。我们传递节点嵌入 x 和规范化系数 norm 作为消息传播的附加参数。
在message()
函数中,我们需要按范数规范化相邻节点的特征 x_i 。其中, x_j 表示一个提升张量,其中包含每条边的源节点特征,即:每个节点的邻居。可以通过在变量名后面附加 _i 或 _j 来自动提升节点特性。实际上,任何张量都可以这样转换,只要它们具有源节点或目的节点的特征。
以上就是创建一个简单的消息传递层所需要的全部内容。您可以将此层用作深层架构的构建块。初始化和调用它很简单哦:
conv = GCNConv(16, 32)
x = conv(x, edge_index)
3.1.3 实现边缘卷积
边缘卷积层处理图或点云,在数学上定义为:
其中表示MLP。与GCN层类似,我们可以使用MessagePassing类来实现这一层,这次使用“max”聚合:
import torch
from torch.nn import Sequential as Seq, Linear, ReLU
from torch_geometric.nn import MessagePassing
class EdgeConv(MessagePassing):
def __init__(self, in_channels, out_channels):
super().__init__(aggr='max') # "Max" aggregation.
self.mlp = Seq(Linear(2 * in_channels, out_channels),
ReLU(),
Linear(out_channels, out_channels))
def forward(self, x, edge_index):
# x has shape [N, in_channels]
# edge_index has shape [2, E]
return self.propagate(edge_index, x=x)
def message(self, x_i, x_j):
# x_i has shape [E, in_channels]
# x_j has shape [E, in_channels]
tmp = torch.cat([x_i, x_j - x_i], dim=1) # tmp has shape [E, 2 * in_channels]
return self.mlp(tmp)
在message()
函数中,我们使用self.mlp
转换目标节点特征 x_i 和相对源节点特征 x_j - x_i 对于每条边。
边缘卷积实际上是一个动态卷积,它为每个边缘重新计算图层使用最近邻的特征空间。幸运的是,PyG自带GPU命名为加速批处理k-NN图生成方法torch_geometric.nn.pool.knn_graph():
from torch_geometric.nn import knn_graph
class DynamicEdgeConv(EdgeConv):
def __init__(self, in_channels, out_channels, k=6):
super().__init__(in_channels, out_channels)
self.k = k
def forward(self, x, batch=None):
edge_index = knn_graph(x, self.k, batch, loop=False, flow=self.flow)
return super().forward(x, edge_index)
.这里,==knn_graph()==计算一个最近邻图,该图进一步用于调用EdgeConv的forward()方法。
这给我们留下了一个清晰的接口来初始化和调用这个层:
conv = DynamicEdgeConv(3, 128, k=6)
x = conv(x, batch)
3.2 建立一个你自己个儿的数据集
虽然PyG已经包含许多有用的数据集,但是您可能想用自己个儿记录或非公开可用的数据创建自己的数据集。
自己实现数据集很简单,您可能需要查看源代码以了解各种数据集是如何实现的。我们将简要介绍设置自己的数据集所需的内容:
我们为数据集提供了两个抽象类:torch_geometric.data.Dataset
数据集和torch_geometric.data.InMemoryDataset
。torch_geometric.data.InMemoryDataset继承自torch_geometric.data.Dataset。torch_geometric.data.InMemoryDataset适用于CPU内存。
按照torchvision(是图像处理工具,包含很多数据集)约定,每个数据集将获得一个根文件夹,该文件夹指示数据集应该存储在哪里。我们将根文件夹分成两个文件夹:raw_dir
:数据集下载到的位置 ; processsed_dir
:保存已处理数据集的。
此外,每个数据集可以传递一个transform
,一个pre_transform
和一个pre_filter
函数,两个函数默认为None。transform
函数在访问之前动态地转换数据对象(因此它最适合用于数据扩充)。pre_transform
函数在将数据对象保存到磁盘之前应用转换(因此它最适合用于只需要执行一次的大量预计算)。pre_filter
函数可以在保存前手动过滤掉数据对象,因此涉及数据对象属于特定类限制的工作。
3.2.1 创建内存数据集
为创建一个torch_geometric.data.InMemoryDataset
,你需要实现四个基本方法:
InMemoryDataset.raw_file_names()
: 在raw_dir中需要找到的文件列表,以便跳过下载。InMemoryDataset.processed_file_names()
: processed_dir中需要找到的文件列表,以便跳过处理。InMemoryDataset.download()
:将原始数据下载到raw_dir。InMemoryDataset.process()
:处理原始数据并将其保存到processed_dir中。您可以在torch_geometry .data
中找到下载和提取数据的有用方法。
真正的奇迹发生在process()
的主体中。这里,我们需要读取并创建一个Data
对象列表,并将其保存到processed_dir
中。因为保存一个巨大的python列表相当慢,所以我们在保存之前通过 torch_geometric.data.InMemoryDataset.collate()
将列表整理成一个巨大的Data对象。整理后的数据对象将所有示例连接到一个大数据对象中,此外,还返回一个slices
切片字典,以便从该对象重构单个示例。最后,我们需要将构造函数中的这两个对象加载到属性self中:self.data
和self.slices
。
让我们用一个简化的例子来看看这个过程:
import torch
from torch_geometric.data import InMemoryDataset, download_url
class MyOwnDataset(InMemoryDataset):
def __init__(self, root, transform=None, pre_transform=None, pre_filter=None):
super().__init__(root, transform, pre_transform, pre_filter)
self.data, self.slices = torch.load(self.processed_paths[0])
@property#属性装饰
def raw_file_names(self):
return ['some_file_1', 'some_file_2', ...]
@property
def processed_file_names(self):
return ['data.pt']
def download(self):
# Download to `self.raw_dir`.
download_url(url, self.raw_dir)
...
def process(self):
# Read data into huge `Data` list.
data_list = [...]
if self.pre_filter is not None:
data_list = [data for data in data_list if self.pre_filter(data)]
if self.pre_transform is not None:
data_list = [self.pre_transform(data) for data in data_list]
data, slices = self.collate(data_list)
torch.save((data, slices), self.processed_paths[0])
3.2.2 整个更大的数据集
torch_geometric.data.Dataset
可以用于创建不适合内存的数据集。它密切遵循torchvision
数据集的概念。还会额外使用以下方法:
在内部,torch_geometric.data.Dataset.__getitem__()
从torch_geometric.data.Dataset.get()
获取数据对象,并根据transform
变换它们。
接下来举个简单的小栗子来看看整个过程:
import os.path as osp
import torch
from torch_geometric.data import Dataset, download_url #导入数据datasets
class MyOwnDataset(Dataset):
def __init__(self, root, transform=None, pre_transform=None, pre_filter=None): #定义函数
super().__init__(root, transform, pre_transform, pre_filter)
@property
def raw_file_names(self): #定义原始生数据名
return ['some_file_1', 'some_file_2', ...]
@property #定义处理后熟数据名
def processed_file_names(self):
return ['data_1.pt', 'data_2.pt', ...]
def download(self): #设置下载路径
# Download to `self.raw_dir`.
path = download_url(url, self.raw_dir)
...
def process(self): #定义图像转换过程
idx = 0 #设置索引
for raw_path in self.raw_paths: 读数据
# Read data from `raw_path`.
data = Data(...)
if self.pre_filter is not None and not self.pre_filter(data):
continue #用filter处理数据
if self.pre_transform is not None:
data = self.pre_transform(data)
torch.save(data, osp.join(self.processed_dir, f'data_{idx}.pt'))
idx += 1 #保存处理后的数据
def len(self):
return len(self.processed_file_names) #len长度
def get(self, idx):
data = torch.load(osp.join(self.processed_dir, f'data_{idx}.pt'))
return data #返回
这里,每个图数据对象在process()
中单独保存,并在get()
中手动加载。
3.2.3 常见问题
download()
和/或process()
的执行? 你可以通过不重写download()
和process()
方法来跳过下载和/或处理:class MyOwnDataset(Dataset):
def __init__(self, transform=None, pre_transform=None):
super().__init__(None, transform, pre_transform)
Data
对象的常规python列表并将其传递给DataLoader
:class MyDataset(InMemoryDataset):
def __init__(self, root, data_list, transform=None):
self.data_list = data_list
super().__init__(root, transform)
self.data, self.slices = torch.load(self.processed_paths[0])
@property
def processed_file_names(self):
return 'data.pt'
def process(self):
torch.save(self.collate(self.data_list), self.processed_paths[0])
3.3 异构图学习
大量真实世界的数据集被存储为异构图,这促使在pyg中为它们引入专门的功能。例如,推荐领域的大多数图(如社交图)都是异构的,因为它们存储关于不同类型实体及其不同类型关系的信息。本教程介绍了如何将异构图映射到PyG,以及如何将它们用作图神经网络模型的输入。
异构图具有附加在节点和边上的不同类型的信息。因此,由于类型和维数的差异,单个节点或边缘特征张量不能容纳整个图的所有节点或边缘特征。相反,需要分别为节点和边指定一组类型,每个类型都有自己的数据张量。由于数据结构不同,消息传递公式也相应改变,从而允许根据节点或边缘类型计算消息和更新函数。
3.3.1 示例图
作为一个指导性的例子,我们来观察数据集OGB套件中的异构 ogbn-mag 网络:
给定的异构图有1,939,743个节点,分为作者、论文、机构和研究领域四种节点类型。它还有21,111,007条边,也属于以下四种类型之一:
这个图的任务是根据图中存储的信息推断出每篇论文(会议或期刊)的地点。
3.3.2 创造一个异构图
首先,我们可以创建一个torch_geometric.data.HeteroData
,我们为其定义每种类型的节点特征张量、边索引张量和边特征张量:
from torch_geometric.data import HeteroData
data = HeteroData()
data['paper'].x = ... # [num_papers, num_features_paper]
data['author'].x = ... # [num_authors, num_features_author]
data['institution'].x = ... # [num_institutions, num_features_institution]
data['field_of_study'].x = ... # [num_field, num_features_field]
data['paper', 'cites', 'paper'].edge_index = ... # [2, num_edges_cites]
data['author', 'writes', 'paper'].edge_index = ... # [2, num_edges_writes]
data['author', 'affiliated_with', 'institution'].edge_index = ... # [2, num_edges_affiliated]
data['paper', 'has_topic', 'field_of_study'].edge_index = ... # [2, num_edges_topic]
data['paper', 'cites', 'paper'].edge_attr = ... # [num_edges_cites, num_features_cites]
data['author', 'writes', 'paper'].edge_attr = ... # [num_edges_writes, num_features_writes]
data['author', 'affiliated_with', 'institution'].edge_attr = ... # [num_edges_affiliated, num_features_affiliated]
data['paper', 'has_topic', 'field_of_study'].edge_attr = ... # [num_edges_topic, num_features_topic]
节点或边张量将在第一次访问时自动创建,并通过字符串键索引。节点类型由单个字符串标识,而边缘类型由字符串组成的三元组(source_node_type, edge_type, destination_node_type)
标识:边缘类型标识符和边缘类型可以存在的两个节点类型。因此,数据对象允许每种类型具有不同的特征维度。
包含按属性名称而不是按节点或边缘类型分组的异构信息的字典可以通过数据直接访问data.{attribute_name}_dict
,作为以后GNN模型的输入:
model = HeteroGNN(...)
output = model(data.x_dict, data.edge_index_dict, data.edge_attr_dict)
如果数据集存在于Pytorch几何数据集列表中,则可以直接导入和使用。特别是,它将被下载到根目录并自动处理。
from torch_geometric.datasets import OGB_MAG
dataset = OGB_MAG(root='./data', preprocess='metapath2vec')
data = dataset[0]
可以打印数据对象data
以进行验证。
HeteroData(
paper={
x=[736389, 128],
y=[736389],
train_mask=[736389],
val_mask=[736389],
test_mask=[736389]
},
author={ x=[1134649, 128] }, #[点的个数,维度]
institution={ x=[8740, 128] },
field_of_study={ x=[59965, 128] },
(author, affiliated_with, institution)={ edge_index=[2, 1043998] }, #[边的起点终点,边的个数]
(author, writes, paper)={ edge_index=[2, 7145660] },
(paper, cites, paper)={ edge_index=[2, 5416271] },
(paper, has_topic, field_of_study)={ edge_index=[2, 7505078] }
)
注意 | - |
---|---|
最初的ogbn-mag 网络只为“纸质”节点提供功能。在OGB_MAG,我们提供了一个选项来下载它的一个处理过的版本,其中的结构特征(从“metapath2vec”或“TransE”获得)被添加到无特征节点通常出现在OGB排行榜的前几名。 |
3.3.3 Utility Functions效用函数
torch_geometric.data.HeteroData
类提供了许多有用的实用函数来修改和分析给定的图。
例如,单个节点或边缘存储可以单独索引:
paper_node_data = data['paper']
cites_edge_data = data['paper', 'cites', 'paper']
当只有源、目的节点类型对或边缘类型可以唯一标识边缘类型时,也可以采用以下操作:
cites_edge_data = data['paper', 'paper']
cites_edge_data = data['cites']
我们可以添加和删除新的节点类型或张量:
data['paper'].year = ... # Setting a new paper attribute
del data['field_of_study'] # Deleting 'field_of_study' node type
del data['has_topic'] # Deleting 'has_topic' edge type
我们可以访问数据对象data
的元数据,其中包含当前所有节点和边缘类型的信息:
node_types, edge_types = data.metadata()
print(node_types)
['paper', 'author', 'institution']
print(edge_types)
[('paper', 'cites', 'paper'),
('author', 'writes', 'paper'),
('author', 'affiliated_with', 'institution')]
数据对象data
可以像往常一样在设备之间传输:
data = data.to('cuda:0')
data = data.cpu()
我们还可以使用额外的帮助函数来分析给定的图表:
data.has_isolated_nodes() #孤立点
data.has_self_loops() #自环
data.is_undirected() #无向
并且可以通过to_homogeneous()
将其转换成同质的“类型化”图,该图能够在不同类型的维度匹配的情况下保持特征:
homogeneous_data = data.to_homogeneous()
print(homogeneous_data)
Data(x=[1879778, 128], edge_index=[2, 13605929], edge_type=[13605929])
这里,homogeneous_data.edge_type表示一个边缘级向量,它将每条边缘的边缘类型保存为一个整数。
3.3.4 异构图形转换
大多数预处理规则图的转换也适用于异构图数据data
对象。
import torch_geometric.transforms as T
data = T.ToUndirected()(data) #添加反向边,使有向图转为无向图
data = T.AddSelfLoops()(data) #添加自环
data = T.NormalizeFeatures()(data) #将制定特征归一化为一个特征
在这里,ToUndirected()
通过为图中的所有边添加反向边,将有向图转换为无向图(的PyG表示)。因此,未来的消息传递在所有边的两个方向上执行。如果需要,该函数可以将反向边类型添加到异构图中。
对于“node_type”
类型的所有节点和表单的所有现有边类型(“node_type”、“edge_type”、“node_type”)
,函数AddSelfLoops()
将添加自循环边。因此,在消息传递期间,每个节点可能会从自身接收一个或多个(每个适当的边类型一个)消息。
NormalizeFeatures()
的工作方式类似于同构情况,它将所有指定的特征(所有类型)归一化为一个。
3.3.5 创造异构GNN网络
标准消息传递GNNs (MP-GNNs)不能简单地应用于异构图数据,因为不同类型的节点和边特征由于特征类型的差异而不能被相同的函数处理。避免这种情况的一种自然方法是为每种边类型分别实现消息传递和更新功能。在运行时,MP-GNN算法需要在消息计算期间迭代边类型字典,在节点更新期间迭代节点类型字典。
为了避免不必要的运行时开销,并使异构MP-gnn的创建尽可能简单,Pytorch Geometric为用户提供了三种在异构图形数据上创建模型的方法:
torch_geometric.nn.to_hetero()
或torch _ geometric . nn . to _ hetero _ with _ bases()
自动将同质GNN模型转换为异质GNN模型 ;conv.HeteroConv
(用于异构卷积)为不同类型定义单独的函数。torch_geometric.nn.to_hetero()
或torch _ Geometric . nn . to _ hetero _ with _ bases()
将任何PyG GNN模型自动转换为异构输入图模型。以下示例显示了如何应用它:import torch_geometric.transforms as T
from torch_geometric.datasets import OGB_MAG
from torch_geometric.nn import SAGEConv, to_hetero
dataset = OGB_MAG(root='./data', preprocess='metapath2vec', transform=T.ToUndirected())
data = dataset[0]
class GNN(torch.nn.Module):
def __init__(self, hidden_channels, out_channels):
super().__init__()
self.conv1 = SAGEConv((-1, -1), hidden_channels)
self.conv2 = SAGEConv((-1, -1), out_channels)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index).relu()
x = self.conv2(x, edge_index)
return x
model = GNN(hidden_channels=64, out_channels=dataset.num_classes)
model = to_hetero(model, data.metadata(), aggr='sum')
该流程采用现有的GNN模型,并复制消息函数以单独处理每种边类型,如下图所示。
因此,该模型现在期望以节点和边类型作为键的字典作为输入参数,而不是在同构图中使用的单个张量。注意,我们将in_channels的元组传递给SAGEConv,以便允许在二分图中传递消息。
注意 | - |
---|---|
由于输入特征的数量以及张量的大小在不同类型之间有所不同,PyG可以利用惰性初始化来初始化异构gnn中的参数(用-1 表示为in_channels 参数)。这允许我们避免计算和跟踪计算图的所有张量大小。所有现有的PyG操作符都支持惰性初始化。我们可以通过调用一次来初始化模型的参数: |
with torch.no_grad(): # Initialize lazy modules.
out = model(data.x_dict, data.edge_index_dict)
to_hetero()
和to_hetero_with_bases()
相对于同构体系结构非常灵活,可以自动转换为异构体系结构,例如,应用跳过连接、跳转知识或其他开箱即用的技术。例如,这就是实现具有可学习跳过连接的异构图形注意力网络所需要的全部内容:
from torch_geometric.nn import GATConv, Linear, to_hetero
class GAT(torch.nn.Module):
def __init__(self, hidden_channels, out_channels):
super().__init__()
self.conv1 = GATConv((-1, -1), hidden_channels, add_self_loops=False)
self.lin1 = Linear(-1, hidden_channels)
self.conv2 = GATConv((-1, -1), out_channels, add_self_loops=False)
self.lin2 = Linear(-1, out_channels)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index) + self.lin1(x)
x = x.relu()
x = self.conv2(x, edge_index) + self.lin2(x)
return x
model = GAT(hidden_channels=64, out_channels=dataset.num_classes)
model = to_hetero(model, data.metadata(), aggr='sum')
之后,可以像往常一样训练创建的模型:
def train():
model.train()
optimizer.zero_grad()
out = model(data.x_dict, data.edge_index_dict)
mask = data['paper'].train_mask
loss = F.cross_entropy(out['paper'][mask], data['paper'].y[mask])
loss.backward()
optimizer.step()
return float(loss)
(2)使用异构卷积包装器
异构卷积包装torch _ geometric . nn . conv . heteroconv
允许定义自定义异构消息和更新函数,以从头开始为异构图构建任意MP-gnn。虽然自动转换器to_hetero()
对所有边类型使用相同的操作符,但是包装器允许为不同的边类型定义不同的操作符。这里,HeteroConv
将子模块的字典作为输入,图数据中的每种边类型都有一个子模块。下面的例子展示了如何应用它。
import torch_geometric.transforms as T
from torch_geometric.datasets import OGB_MAG
from torch_geometric.nn import HeteroConv, GCNConv, SAGEConv, GATConv, Linear
dataset = OGB_MAG(root='./data', preprocess='metapath2vec', transform=T.ToUndirected())
data = dataset[0]
class HeteroGNN(torch.nn.Module):
def __init__(self, hidden_channels, out_channels, num_layers):
super().__init__()
self.convs = torch.nn.ModuleList()
for _ in range(num_layers):
conv = HeteroConv({
('paper', 'cites', 'paper'): GCNConv(-1, hidden_channels),
('author', 'writes', 'paper'): SAGEConv((-1, -1), hidden_channels),
('paper', 'rev_writes', 'author'): GATConv((-1, -1), hidden_channels),
}, aggr='sum')
self.convs.append(conv)
self.lin = Linear(hidden_channels, out_channels)
def forward(self, x_dict, edge_index_dict):
for conv in self.convs:
x_dict = conv(x_dict, edge_index_dict)
x_dict = {key: x.relu() for key, x in x_dict.items()}
return self.lin(x_dict['author'])
model = HeteroGNN(hidden_channels=64, out_channels=dataset.num_classes,
num_layers=2)
我们可以通过调用它一次来初始化模型(参见这里关于惰性初始化的更多细节)
with torch.no_grad(): # Initialize lazy modules.
out = model(data.x_dict, data.edge_index_dict)
(3)部署现有的异构操作
PyG提供了运算符(如torch _ geometric . nn . conv . hgtconv),这些运算符是专门为异构图设计的。这些运算符可直接用于构建异构GNN模型,如下例所示:
import torch_geometric.transforms as T
from torch_geometric.datasets import OGB_MAG
from torch_geometric.nn import HGTConv, Linear
dataset = OGB_MAG(root='./data', preprocess='metapath2vec', transform=T.ToUndirected())
data = dataset[0]
class HGT(torch.nn.Module):
def __init__(self, hidden_channels, out_channels, num_heads, num_layers):
super().__init__()
self.lin_dict = torch.nn.ModuleDict()
for node_type in data.node_types:
self.lin_dict[node_type] = Linear(-1, hidden_channels)
self.convs = torch.nn.ModuleList()
for _ in range(num_layers):
conv = HGTConv(hidden_channels, hidden_channels, data.metadata(),
num_heads, group='sum')
self.convs.append(conv)
self.lin = Linear(hidden_channels, out_channels)
def forward(self, x_dict, edge_index_dict):
for node_type, x in x_dict.items():
x_dict[node_type] = self.lin_dict[node_type](x).relu_()
for conv in self.convs:
x_dict = conv(x_dict, edge_index_dict)
return self.lin(x_dict['author'])
model = HGT(hidden_channels=64, out_channels=dataset.num_classes,
num_heads=2, num_layers=2)
我们可以通过调用它一次来初始化模型(查看这里关于惰性初始化的更多细节)。
with torch.no_grad(): # Initialize lazy modules.
out = model(data.x_dict, data.edge_index_dict)
(4)异构图形采样器 Heterogeneous Graph Samplers
PyG提供了各种用于对异构图进行采样的功能,即在标准的torch _ geometric . loader . neighborhood loader
类中或在专用的异构图采样器(如torch _ geometric . loader . HGT loader
)中。这对于大型异构图上的高效表示学习特别有用,在这种情况下,处理全部数量的邻居在计算上过于昂贵。很快将添加对其他采样器(如torch _ geometric . loader . cluster loader
或torch _ geometric . loader . graphs aintloader
)的异构图形支持。总的来说,所有异构图形加载器都会产生一个HeteroData
对象作为输出,保存原始数据的一个子集,主要区别在于采样过程的工作方式。因此,将训练程序从全批次训练转换为小批次训练只需要最小的代码变化。
使用NeighborLoader执行邻居采样的工作方式如下例所示:
import torch_geometric.transforms as T
from torch_geometric.datasets import OGB_MAG
from torch_geometric.loader import NeighborLoader
transform = T.ToUndirected() # Add reverse edge types.
data = OGB_MAG(root='./data', preprocess='metapath2vec', transform=transform)[0]
train_loader = NeighborLoader(
data,
# Sample 15 neighbors for each node and each edge type for 2 iterations:
num_neighbors=[15] * 2,
# Use a batch size of 128 for sampling training nodes of type "paper":
batch_size=128,
input_nodes=('paper', data['paper'].train_mask),
)
batch = next(iter(train_loader))
值得注意的是,NeighborLoader
适用于同构和异构图。在异构图中操作时,可以对各个边类型的采样邻居数量进行更细粒度的控制,但这不是必需的,例如:
num_neighbors = {key: [15] * 2 for key in data.edge_types}
使用input_nodes
参数,我们进一步指定我们想要从中对局部邻域进行采样的节点的类型和索引,即根据data ['paper'].train_mask
标记为训练节点的所有“paper”节点。
然后,打印批处理会产生以下输出:
HeteroData(
paper={
x=[20799, 256],
y=[20799],
train_mask=[20799],
val_mask=[20799],
test_mask=[20799],
batch_size=128
},
author={ x=[4419, 128] },
institution={ x=[302, 128] },
field_of_study={ x=[2605, 128] },
(author, affiliated_with, institution)={ edge_index=[2, 0] },
(author, writes, paper)={ edge_index=[2, 5927] },
(paper, cites, paper)={ edge_index=[2, 11829] },
(paper, has_topic, field_of_study)={ edge_index=[2, 10573] },
(institution, rev_affiliated_with, author)={ edge_index=[2, 829] },
(paper, rev_writes, author)={ edge_index=[2, 5512] },
(field_of_study, rev_has_topic, paper)={ edge_index=[2, 10499] }
)
因此,batch
总共包含28,187个节点,用于计算128个“纸张”节点的嵌入。被采样的节点总是根据它们被采样的顺序进行排序。于是,第一批 batch['paper'].batch_size
。batch_size节点表示原始小批量节点的集合,使得通过切片获得最终的输出嵌入变得容易。
在小批量模式下训练我们的异构GNN模型类似于在全批量模式下训练它,除了我们现在迭代由train_loader
产生的小批量,并基于各个小批量优化模型参数:
def train():
model.train()
total_examples = total_loss = 0
for batch in train_loader:
optimizer.zero_grad()
batch = batch.to('cuda:0')
batch_size = batch['paper'].batch_size
out = model(batch.x_dict, batch.edge_index_dict)
loss = F.cross_entropy(out['paper'][:batch_size],
batch['paper'].y[:batch_size])
loss.backward()
optimizer.step()
total_examples += batch_size
total_loss += float(loss) * batch_size
return total_loss / total_examples
重要的是,在损失计算过程中,我们只使用前128个“paper节点。我们这样做是通过切片两个“paper”标签batch['paper'].y
和输出基于批次[“paper”]的预测输出out[‘paper’]。batch['paper'].batch_size
,分别表示原始小批量节点的标签和最终输出预测。
3.4 从CSV加载图表 Loading Graphs from CSV
在本例中,我们将展示如何加载一组*.csv
文件作为输入,并从中构造一个异构图,该图可以用作异构图模型的输入。本教程也可以在examples/hetero目录中作为可执行的示例脚本获得。
我们将使用GroupLens研究小组收集的MovieLens数据集。这个玩具数据集描述了MovieLens的5星评级和标记活动。该数据集包含来自600多个用户的超过9k部电影的大约100k个评级。我们将使用这个数据集来生成两个节点类型,分别保存电影和用户的数据,以及一个连接用户和电影的边类型,表示用户如何评价特定电影的关系。
首先,我们将数据集下载到任意文件夹(在本例中是当前目录):
from torch_geometric.data import download_url, extract_zip
url = 'https://files.grouplens.org/datasets/movielens/ml-latest-small.zip'
extract_zip(download_url(url, '.'), '.')
movie_path = './ml-latest-small/movies.csv' #电影
rating_path = './ml-latest-small/ratings.csv' #评级
在我们创建异构图之前,让我们看一下数据。
movield | title | genres |
---|---|---|
1 | Toy Story (1995) | Adventure、Animation、Children、Comedy、Fantasy |
2 | Jumanji (1995) | Adventure、Children、Fantasy |
3 | Grumpier Old Men (1995) | Comedy、Romance |
4 | Waiting to Exhale (1995) | Comedy、Drama、Romance |
5 | Father of the Bride Part II (1995) | Comedy |
我们看到movies.csv
文件提供了三列:movieId为每部电影分配一个惟一的标识符,而标题和类型列表示给定电影的标题和类型。我们可以利用这两列来定义机器学习模型可以轻松解释的特征表示。
Head of ratings.csv
userld | movield | rating | timestamp |
---|---|---|---|
1 | 1 | 4.0 | 964982703 |
1 | 3 | 4.0 | 964981247 |
1 | 6 | 4.0 | 964982224 |
1 | 47 | 5.0 | 964983815 |
1 | 50 | 5.0 | 964982931 |
ratings.csv
数据连接用户(由userId
给出)和电影(由movieId
给出),并定义给定用户如何对特定电影进行评级(评级rating
)。为了简单起见,我们不使用额外的时间戳timestamp
信息。
为了用PyG数据格式表示这些数据,我们首先定义一个load_node_csv()
方法,它读入一个*.csv
文件并返回 [num_nodes, num_features]
的节点级特征表示x
:
import torch
def load_node_csv(path, index_col, encoders=None, **kwargs):
df = pd.read_csv(path, index_col=index_col, **kwargs)
mapping = {index: i for i, index in enumerate(df.index.unique())}
x = None
if encoders is not None:
xs = [encoder(df[col]) for col, encoder in encoders.items()]
x = torch.cat(xs, dim=-1)
return x, mapping
在这里,load_node_csv()
从path
读取*.csv
文件,并创建一个字典映射mapping
,该映射将其索引列映射到范围{ 0, ..., num_rows - 1 }
。这是需要的,因为我们希望我们的最终数据表示尽可能紧凑,例如,第一行中的电影表示应该可以通过x[0]
访问。
我们进一步利用编码器的概念,它定义了如何将特定列的值编码为数字特征表示。例如,我们可以定义一个句子编码器,它将原始列字符串编码为低维嵌入。为此,我们利用了优秀的句子转换库,该库提供了大量最先进的预训练NLP嵌入模型:
pip install sentence-transformers
class SequenceEncoder:
def __init__(self, model_name='all-MiniLM-L6-v2', device=None):
self.device = device
self.model = SentenceTransformer(model_name, device=device)
@torch.no_grad()
def __call__(self, df):
x = self.model.encode(df.values, show_progress_bar=True,
convert_to_tensor=True, device=self.device)
return x.cpu()
SequenceEncoder
类加载由model_name
给出的预训练NLP模型,并使用它将字符串列表编码为形状为[num_strings,embedding_dim]
的PyTorch张量。我们可以使用这个SequenceEncoder
对movies.csv
文件的标题进行编码。
以类似的方式,我们可以创建另一个编码器,将电影的类型(例如,Adventure|Children|Fantasy) 转换为分类标签。为此,我们首先需要找到数据中存在的所有现有类型,创建形状[num_movies,num _ genders]
的特征表示x
,并在类型 j
存在于电影 i
:
class GenresEncoder:
def __init__(self, sep='|'):
self.sep = sep
def __call__(self, df):
genres = set(g for col in df.values for g in col.split(self.sep))
mapping = {genre: i for i, genre in enumerate(genres)}
x = torch.zeros(len(df), len(mapping))
for i, col in enumerate(df.values):
for genre in col.split(self.sep):
x[i, mapping[genre]] = 1
return x
有了这个,我们可以通过以下方式获得电影的最终表现形式:
movie_x, movie_mapping = load_node_csv(
movie_path, index_col='movieId', encoders={
'title': SequenceEncoder(),
'genres': GenresEncoder()
})
类似地,我们也可以利用load_node_csv()来获得从userId到连续值的用户映射。但是,对于该数据集中的用户,没有附加的功能信息。因此,我们不定义任何编码器:
_, user_mapping = load_node_csv(rating_path, index_col='userId')
这样,我们就可以初始化我们的HeteroData
对象,并向它传递两种节点类型:
from torch_geometric.data import HeteroData
data = HeteroData()
data['user'].num_nodes = len(user_mapping) # Users do not have any features.
data['movie'].x = movie_x
print(data)
HeteroData(
user={ num_nodes=610 },
movie={ x[9742, 404] }
)
由于用户没有任何节点级的信息,我们只定义它的节点数。因此,我们可能需要在异构图模型的训练期间以端到端的方式通过torch.nn.Embedding来学习不同的用户嵌入。
接下来,我们来看看如何通过电影的评级来将用户与电影联系起来。为此,我们定义了load_edge_csv()
方法,该方法从ratings.csv
返回shape [2,num_ratings]
的最终edge_index
表示,以及原始 *.csv
中存在的任何附加特征。csv文件:
def load_edge_csv(path, src_index_col, src_mapping, dst_index_col, dst_mapping,
encoders=None, **kwargs):
df = pd.read_csv(path, **kwargs)
src = [src_mapping[index] for index in df[src_index_col]]
dst = [dst_mapping[index] for index in df[dst_index_col]]
edge_index = torch.tensor([src, dst])
edge_attr = None
if encoders is not None:
edge_attrs = [encoder(df[col]) for col, encoder in encoders.items()]
edge_attr = torch.cat(edge_attrs, dim=-1)
return edge_index, edge_attr
这里,src_index_col
和dst_index_col
分别定义了源节点和目的节点的索引列。我们进一步利用节点级映射src_mapping
和dst_mapping
来确保原始索引被映射到最终表示中正确的连续索引。对于文件中定义的每个边,它在src_mapping
和dst_mapping
中查找前向索引,并适当地移动数据。
与load_node_csv()
类似,编码器用于返回附加的边缘级特征信息。例如,为了从ratings.csv
中的rating
列加载评级,我们可以定义一个IdentityEncoder
,它只是将一列浮点值转换为PyTorch张量:
class IdentityEncoder:
def __init__(self, dtype=None):
self.dtype = dtype
def __call__(self, df):
return torch.from_numpy(df.values).view(-1, 1).to(self.dtype)
这样,我们就可以完成我们的HeteroData对象了:
edge_index, edge_label = load_edge_csv(
rating_path,
src_index_col='userId',
src_mapping=user_mapping,
dst_index_col='movieId',
dst_mapping=movie_mapping,
encoders={'rating': IdentityEncoder(dtype=torch.long)},
)
data['user', 'rates', 'movie'].edge_index = edge_index
data['user', 'rates', 'movie'].edge_label = edge_label
print(data)
HeteroData(
user={ num_nodes=610 },
movie={ x=[9742, 404] },
(user, rates, movie)={
edge_index=[2, 100836],
edge_label=[100836, 1]
}
)
这个HeteroData对象是PyG中异构图的本地格式,可以用作异构图模型的输入。
3.5 可解释GNN
解释GNN模型对于许多用例来说至关重要。PyG (2.3及更高版本)提供了torch_geometric.explain包来支持一流的GNN可解释性,目前包括:
1.通过Explainer
类生成各种解释的灵活接口
2. 包括例如GNNExplainer
、PGExplainer
和CaptumExplainer
的几种底层解释算法
3. 支持通过解释 Explanation
或异质解释HeteroExplanation
类来可视化解释
4. 和度量来通过度量metric
包评估解释。
WARNING | - |
---|---|
这里讨论的解释API在将来可能会改变,因为我们会继续努力改进它们的易用性和通用性。 |
解释器接口
torch _ geometric . explain . explainer类旨在处理所有可解释性参数(有关更多详细信息,请参见ExplainerConfig类):
torch _ geometric . explain . algorithm
模块中的哪个算法(例如,GNNExplainer
)explanation_type="phenomenon"
来解释数据集的地城现象,以及explanation_type="model"
解释GNN预测模型 (有关更多详细信息,请参见“GraphFramEx:对图形神经网络的可解释性方法进行系统评估” “GraphFramEx: Towards Systematic Evaluation of Explainability Methods for Graph Neural Networks”论文)。mask="object "
或mask="attributes "
)threshold_type="topk "
或threshold_type="hard "
)这个类允许用户容易地比较不同的可解释性方法,并且容易地在不同类型的掩码之间切换,同时确保高层框架保持不变。解释器生成一个解释或异质解释对象,其中包含关于哪些节点、边和特征对于解释GNN模型至关重要的最终信息。
<注>你可以在这篇博文中阅读更多关于torch_geometric.explain
包的内容。
在接下来的内容中,我们将讨论一些带有相应代码示例的用例。
解释同构图上的节点分类
假设我们有一个在同构图上进行节点分类的GNN模型。我们可以使用torch _ geometric . explain . algorithm . gnnexplainer
算法生成解释。我们将解释器配置为使用node_mask_type
和edge_mask_type
,这样最终的解释对象包含(1)node _ mask
(指示哪些节点和特征对于预测至关重要),以及(2)edge _ mask
(指示哪些边对于预测至关重要)。
from torch_geometric.data import Data
from torch_geometric.explain import Explainer, GNNExplainer
data = Data(...) # A homogeneous graph data object.
explainer = Explainer(
model=model,
algorithm=GNNExplainer(epochs=200),
explanation_type='model',
node_mask_type='attributes',
edge_mask_type='object',
model_config=dict(
mode='multiclass_classification',
task_level='node',
return_type='log_probs', # Model returns log probabilities.
),
)
# Generate explanation for the node at index `10`:
explanation = explainer(data.x, data.edge_index, index=10)
print(explanation.edge_mask)
print(explanation.node_mask)
最后,我们可以可视化特征重要性和解释的关键子图:
explanation.visualize_feature_importance(top_k=10)
explanation.visualize_graph()
为了评估GNNExplainer的解释,我们可以使用torch_geometric.explain.metric模块。例如,要计算解释的不忠实度(),请运行:
from torch_geometric.explain import unfaithfulness
metric = unfaithfulness(explainer, explanation)
print(metric)
解释异构图上的节点分类
假设我们有一个异构GNN模型,它在异构图上进行节点分类。我们可以通过torch _ geometric . explain . algorithm . captumexplainer
算法使用Captum中的IntegratedGradient
attribution方法来生成 HeteroExplanation
异质解释算法。
<注>CaptumExplainer
是Captum库的包装器,支持大多数属性方法来解释任何同构或异构的PyG模型。
我们将解释器Explainer
配置为使用node_mask_type
和edge_mask_type
,使得最终的HeteroExplanation
对象包含(1)每个节点类型的node_mask
(指示每个节点类型的哪些节点和特征对于预测至关重要),以及(2)每个边类型的edge_mask
(指示每个边类型的哪些边对于预测至关重要)。
from torch_geometric.data import HeteroData
from torch_geometric.explain import Explainer, CaptumExplainer
hetero_data = HeteroData(...) # A heterogeneous graph data object.
explainer = Explainer(
model, # It is assumed that model outputs a single tensor.
algorithm=CaptumExplainer('IntegratedGradients'),
explanation_type='model',
node_mask_type='attributes',
edge_mask_type='object',
model_config = dict(
mode='multiclass_classification',
task_level=task_level,
return_type='probs', # Model returns probabilities.
),
)
# Generate batch-wise heterogeneous explanations for
# the nodes at index `1` and `3`:
hetero_explanation = explainer(
hetero_data.x_dict,
hetero_data.edge_index_dict,
index=torch.tensor([1, 3]),
)
print(hetero_explanation.edge_mask_dict)
print(hetero_explanation.node_mask_dict)
解释同质图上的图回归
假设我们有一个在同质图上进行图形回归的GNN模型。我们可以使用torch _ geometric . explain . algorithm . pg explainer
算法生成解释。我们配置解释器使用edge_mask_type
,这样最终的解释对象包含一个edge_mask
(指示哪些边对于预测是至关重要的)。重要的是,将node_mask_type
传递给Explainer
会抛出一个错误,因为PGExplainer
无法解释节点的重要性:
from torch_geometric.data import Data
from torch_geometric.explain import Explainer, PGExplainer
dataset = ...
loader = DataLoader(dataset, batch_size=1, shuffle=True)
explainer = Explainer(
model=model,
algorithm=PGExplainer(epochs=30, lr=0.003),
explanation_type='phenomenon',
edge_mask_type='object',
model_config=dict(
mode='regression',
task_level='graph',
return_type='raw',
),
# Include only the top 10 most important edges:
threshold_config=dict(threshold_type='topk', value=10),
)
# PGExplainer needs to be trained separately since it is a parametric
# explainer i.e it uses a neural network to generate explanations:
for epoch in range(30):
for batch in loader:
loss = explainer.algorithm.train(
epoch, model, batch.x, batch.edge_index, target=batch.target)
# Generate the explanation for a particular graph:
explanation = explainer(dataset[0].x, dataset[0].edge_index)
print(explanation.edge_mask)
由于这项功能仍在大量开发中,如果您有任何问题、评论或疑虑,请随时联系GitHub或Slack上的PyG核心团队。