使用 PyG 进行图神经网络训练
前言
最近一直在想创新点,搭模型,想尝试一下图神经网络,想着自己实现一个,但是之前也没有尝试过写 GNN 模型,对其中的实现细节也没有实际尝试过,最后找到了 PyG ,尝试一下之后发现还是挺简单的,也比较好拿到现有模型里面,于是开始挖坑。
PyG (PyTorch Geometric) 是一个基于 PyTorch 的库,可轻松编写和训练图形神经网络 (GNN),用于与结构化数据相关的广泛应用。
目前网上对 PyG 的相关文档并不多,大本部也都是比较重复的内容,因此我主要参考的还是官方文档 。具体安装方式参考 PyG Installation 。
图结构
建图
首先,我们需要根据数据集进行建图,在 PyG 中,一个 Graph 的通过torch_geometric.data.Data
进行实例化,它包括下面两个最主要的属性:
data.x
: 节点的特征矩阵,形状为[num_nodes, num_node_features]
;
data.edge_index
: 图的边索引,用 COO 稀疏矩阵格式保存。形状为[2, num_edges]
;
稀疏矩阵是数值计算中普遍存在的一类矩阵,主要特点是绝大部分的矩阵元为零。COO (Coordinate) 格式是将矩阵中的非零元素存储,每一个元素用一个三元组来表示,分别是(行号,列号,数值)
上面两个属性也就是整个图中最重要的部分。同时你也可以定义下面的一些其他信息:
data.edge_attr
: 边的特征矩阵,形状为[num_edges, num_edge_features]
;
data.y
: 计算损失所需的目标数据,比如节点级别的形状为[num_nodes, *]
,或者图级别的形状为[1, *]
;
现在,我们来简单创建一张图:
1 2 3 4 5 6 7 8 9 import torchfrom torch_geometric.data import Dataedge_index = torch.tensor([[0 , 1 , 1 , 2 ], [1 , 0 , 2 , 1 ]], dtype=torch.long) x = torch.tensor([[-1 ], [0 ], [1 ]], dtype=torch.float ) data = Data(x=x, edge_index=edge_index) >>> Data(edge_index=[2 , 4 ], x=[3 , 1 ])
需要注意的是:
第一行 edge_index[0]
表示起点,第二行 edge_index[1]
表示终点;
虽然只有两条边,但在 PyG 中处理无向图时实际上是互为头尾节点;
矩阵中的值表示索引,指代 x
中的节点。
实际上,你不需要找对专门的属性去存取某些信息,你完全可以「自定义属性名」,并放入你想要的内容,事实上就像一个字典。例如,你想要为图再添加一个 freq
属性以表示访问频率:
1 2 3 4 5 6 7 edge_index = torch.tensor([[0 , 1 , 1 , 2 ], [1 , 0 , 2 , 1 ]], dtype=torch.long) x = torch.tensor([[-1 ], [0 ], [1 ]], dtype=torch.float ) data = Data(x=x, edge_index=edge_index) data.freq = torch.tensor([2 , 5 , 1 ], dtype=torch.long) >>> Data(x=[3 , 1 ], edge_index=[2 , 4 ], freq=[3 ])
此外,这里再列举一些个人觉得比较常用的操作,感觉官方文档有些地方不清不楚,完整的类说明可以看这里 :
coalesce()
: 对 edge_index
中的边排序并去重
clone()
: 创建副本
to(cuda:0)
: 把数据放到 GPU
num_edges
: 返回边数量
num_nodes
: 返回节点数量
关于 train_mask
在官方的数据集中,划分「训练集」和「测试集」的方式是创建一张大图,然后指定训练节点以及测试节点,通过 train_mask
和 test_mask
来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from torch_geometric.datasets import Planetoiddataset = Planetoid(root='/tmp/Cora' , name='Cora' ) data = dataset[0 ] >>> Data(edge_index=[2 , 10556 ], test_mask=[2708 ], train_mask=[2708 ], val_mask=[2708 ], x=[2708 , 1433 ], y=[2708 ]) data.is_undirected() >>> True data.train_mask.sum ().item() >>> 140 data.val_mask.sum ().item() >>> 500 data.test_mask.sum ().item() >>> 1000
上面是官方提供的数据集中的一个例子,但个人觉得并不是所有的数据集都适合这种方法,我仍习惯于训练集和测试集分开,也就是创建两张图,因为大多数情况下都需要自定义一个 MyDataset
,你可以把建图的操作放在里面,所以其实感觉这样还更方便。
1 2 3 4 5 data_train, data_test = split_train_test(data) dataset_train = MyDataset(data_train) dataset_test = MyDataset(data_test)
当然,这还是要看具体做什么任务的,对某些任务来说可能只需要一张大图,对我来说可能就需要分成两张。
关于 Embedding
最开始的时候我有想过,为什么要在一开始创建 x
的时候就让我把节点的维度给定下来,这不应该是我后面模型里面 Embedding 的时候再做的事情吗,难不成建图的时候就要 Embedding?当然,对于有些情况,建图的时候完全就可以直接 Embedding。
事实上,其实就是我想多了,其实把它当做一个字典就好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import torchfrom torch import nnfrom torch_geometric.data import Dataedge_index = torch.tensor([[0 , 1 , 1 , 2 ], [1 , 0 , 2 , 1 ]], dtype=torch.long) x = torch.tensor([1 , 0 , 2 ], dtype=torch.long) data = Data(x=x, edge_index=edge_index) data.freq = torch.tensor([2 , 5 , 1 ], dtype=torch.long) ... emb_layer = nn.Embedding(3 , 8 ) data.x = emb_layer(data.x) print (data)>>> Data(x=[3 , 8 ], edge_index=[2 , 4 ], freq=[3 ])
就像上面展示的这样,我的做法是先把节点的 ID 填进去,接着在模型里面进行 Embedding,当然你可以直接使用 data.x = emb_layer(data.x)
把原来的 ID 给替换掉;也可能你需要保留 ID,那么就可以把它放到一个新的属性中,比如 data.x_emb
。总的来说其实就是不需要把 data
的属性看得那么死,完全可以按照你的需要来。
关于 Batch 和 DataLoader
Batch
在 PyG 中,你可以通过手动的方式进行批量化操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import torchfrom torch import nnfrom torch_geometric.data import Data, Batchedge_index = torch.tensor([[0 , 1 , 1 , 2 ], [1 , 0 , 2 , 1 ]], dtype=torch.long) x = torch.tensor([1 , 0 , 2 ], dtype=torch.long) g1 = Data(x=x, edge_index=edge_index) g2 = Data(x=x, edge_index=edge_index) g3 = Data(x=x, edge_index=edge_index) data = [g1, g2, g3] >>> Data(x=[3 ], edge_index=[2 , 4 ])batch = Batch().from_data_list(data) >>> DataDataBatch(x=[9 ], edge_index=[2 , 12 ], batch=[9 ], ptr=[4 ])
在上面的例子中,为了方便我创建了 3 张相同的图。现在我们来看看 batch 具体做了些什么:
1 2 3 4 5 6 7 8 9 10 11 12 print (batch.x)>>> tensor([1 , 0 , 2 , 1 , 0 , 2 , 1 , 0 , 2 ])print (batch.edge_index)>>> tensor([[0 , 1 , 1 , 2 , 3 , 4 , 4 , 5 , 6 , 7 , 7 , 8 ], [1 , 0 , 2 , 1 , 4 , 3 , 5 , 4 , 7 , 6 , 8 , 7 ]]) print (batch.batch)>>> tensor([0 , 0 , 0 , 1 , 1 , 1 , 2 , 2 , 2 ])print (batch.ptr)>>> tensor([0 , 3 , 6 , 9 ])
实际上,Batch 的本质就是将多个图组合起来,创建一张大图。可以看出,首先在 x
中将所有节点拼接在一起,batch
指明了每个节点所属的批次,ptr
指明了每个 batch 的节点的起始索引号,然后在 edge_index
中对边进行拼接,同时为了避免冲突对索引加上了 ptr
。
用公式来表示也就是:
A = [ A 1 ⋱ A n ] , X = [ X 1 ⋮ X n ] , Y = [ Y 1 ⋮ Y n ] \mathbf{A} = \begin{bmatrix}
\mathbf{A}_1 & & \\
& \ddots & \\
& & \mathbf{A}_n
\end{bmatrix},
\mathbf{X} = \begin{bmatrix}
\mathbf{X}_1 \\ \vdots \\ \mathbf{X}_n
\end{bmatrix},
\mathbf{Y} = \begin{bmatrix}
\mathbf{Y}_1 \\ \vdots \\ \mathbf{Y}_n
\end{bmatrix}
A = A 1 ⋱ A n , X = X 1 ⋮ X n , Y = Y 1 ⋮ Y n
DataLoader
PyTorch 原生的 DataLoader 实际上对 Data
并不支持,虽然可以创建成功,但在遍历取数据的时候,你会发现如下错误:
default_collate: batch must contain tensors, numpy arrays, numbers, dicts or lists; found <class ‘torch_geometric.data.data.Data’>
PyG 有一个自己的 DataLoader,实际上只需要用它替换 PyTorch 原生的 DataLoader 就可以,个人觉得使用体验上和 PyTorch 差别不大。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from torch.utils.data import Datasetfrom torch_geometric.loader import DataLoaderclass MyDataset (Dataset ): def __init__ (self, data ): super (MyDataset, self).__init__() self.data = data def __len__ (self ): return len (self.data) def __getitem__ (self, idx ): sub = self.data[idx] return sub dataset = MyDataset(data) dataloader = DataLoader(dataset)
图神经网络
讲完了图结构,以及数据集之后,现在正式进入到了模型训练阶段
Convolutional Layers
PyG 其实定义了非常多可供直接使用的 Convolutional Layers,具体你可以看这里 。总的来说常用的结构基本都帮你实现好了:
GCNConv
GATConv
SAGEConv
LGConv
…
这里以 GCNConv
为例,关于 GCN 的具体原理这里就不过多介绍了,有兴趣可以看我的另一篇文章:简单理解图神经网络 GNN 。
还是以官方的例子开始:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import torchimport torch.nn.functional as Ffrom torch_geometric.nn import GCNConvclass GCN (torch.nn.Module): def __init__ (self ): super ().__init__() self.conv1 = GCNConv(dataset.num_node_features, 16 ) self.conv2 = GCNConv(16 , dataset.num_classes) def forward (self, data ): x, edge_index = data.x, data.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 )
其实这个例子已经把网络结构定义讲得很清楚了,我们需要做的就是实例化一个 GCNConv
并制定输入输出维度,比如 conv1
。之后在 forward()
中只需要把我们一开始定义的图中的 x
和 edge_index
传进去就行了,也就是节点信息和边信息,当然,具体的输入你可以根据自己的实际情况,假如我的节点信息在 x_emb
这里,那我就把 x_emb
丢进去。其余的就和 torch 一样,我们做的只是说引入了一层。
自定义网络
当然,假如你需要的网络结构 PyG 中并没有预先给出,或者你想实现自己的聚合结构,那就需要重新定义自己的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class GCNConv (MessagePassing ): def __init__ (self, in_channels: int , out_channels: int ): self.in_channels = in_channels self.out_channels = out_channels self.lin = Linear(in_channels, out_channels) def forward (self, x: Tensor, edge_index: Adj, edge_weight: OptTensor = None ) -> Tensor: edge_index = gcn_norm(edge_index, edge_weight, x.size(self.node_dim)) x = self.lin(x) out = self.propagate(edge_index, x=x, edge_weight=edge_weight, size=None ) return out def message (self, x_j: Tensor, edge_weight: OptTensor ) -> Tensor: return x_j if edge_weight is None else edge_weight.view(-1 , 1 ) * x_j def message_and_aggregate (self, adj_t: SparseTensor, x: Tensor ) -> Tensor: return matmul(adj_t, x)
上面我根据 PyG 的 GCNConv
拎出来的核心代码,主要在于传播 propagate()
以及消息传递 message()
。PyG 提供了 MessagePassing 基类,它封装了「消息传递」的运行流程。关于「消息传递」的实现我自己目前并不算熟悉,这里就不展开了,有兴趣的可以去看其他的资料。
参考资料