图神经网络实战(6)——使用PyTorch构建图神经网络
0. 前言
图数据集通常比简单的连接集合更加丰富,因为节点和边可以具有表示分数、颜色、单词等特征。包含这些额外信息在输入数据中对于生成最佳嵌入至关重要。由于节点和边的特征与非图数据集具有相似的结构,这意味着经典技术如神经网络可以应用于这些数据。在本节中,我们将使用 Cora 和 Facebook Page-Page 数据集,首先将它们视为表格数据集,观察香草神经网络 (vanilla neural networks
) 在节点特征上的表现如何。然后,尝试在神经网络中加入拓扑信息,实现图神经网络 (Graph Neural Networks
, GNN
) 架构:一个同时考虑节点特征和边的简单模型。最后,我们将比较两种架构的性能。
1. 传统机器学习与人工智能
传统应用程序中,系统是通过使用程序员编写的复杂算法来实现智能化的。例如,假设我们希望识别照片中是否包含狗。在传统的机器学习 (Machine Learning
, ML
) 中,需要机器学习研究人员首先确定需要从图像中提取的特征,然后提取这些特征并将它们作为输入传递给复杂算法,算法解析给定特征以判断图像中是否包含狗:
然而,如果要为多种类别图像分类手动提取特征,其数量可能是指数级的,因此,传统方法在受限环境中效果很好(例如,识别证件照片),而在不受限制的环境中效果不佳,因为每张图像之间都有较大差异。
我们可以将相同的思想扩展到其他领域,例如文本或结构化数据。过去,如果希望通过编程来解决现实世界的任务,就必须了解有关输入数据的所有内容并编写尽可能多的规则来涵盖所有场景,并且不能保证所有新场景都会遵循已有规则。
而神经网络内含了特征提取的过程,并将这些特征用于分类/回归,几乎不需要手动特征工程,只需要标记数据(例如,哪些图片是狗,哪些图片不是狗)和神经网络架构,不需要手动提出规则来对图像进行分类,这减轻了传统机器学习技术强加给程序员的大部分负担。
训练神经网络需要提供大量样本数据。例如,在前面的例子中,我们需要为模型提供大量的狗和非狗图片,以便它学习特征。神经网络用于分类任务的流程如下,其训练与测试是端到端 (end-to-end
) 的:
2. 人工神经网络基础
2.1 人工神经网络组成
ANN
是张量(权重, weights
)和数学运算的集合,其排列方式近似于松散的人脑神经元排列。可以将其视为一种数学函数,它将一个或多个张量作为输入并预测相应输出(一个或多个张量)。将输入连接到输出的操作方式称为神经网络的架构,我们可以根据不同的任务构建不同架构,即基于问题是包含结构化数据还是非结构化(图像,文本,语音)数据,这些数据就是输入和输出张量的列表。ANN
由以下部分组成:
- 输入层:将自变量作为输入
- 隐藏(中间)层:连接输入和输出层,在输入数据之上执行转换;此外,隐藏层利用节点单元(下图中的圆圈)将其输入值修改为更高/更低维的值;通过修改中间节点的激活函数可以实现复杂表示函数
- 输出层:输入变量产生的值
综上,神经网络的典型结构如下:
输出层中节点的数量(上图中的圆圈)取决于实际任务以及我们是在尝试预测连续变量还是分类变量。如果输出是连续变量,则输出有一个节点。如果输出是具有 m
个可能类别的分类,则输出层中将有 m
个节点。接下来,我们深入介绍节点/神经元的工作原理,神经元按如下方式转换其输入:
其中, x 1 x_1 x1, x 2 x_2 x2,…, x n x_n xn 是输入变量, w 0 w_0 w0 是偏置项(类似于线性/逻辑回归中的偏差); w 1 w_1 w1, w 2 w_2 w2,…, w n w_n wn 是赋予每个输入变量的权重,输出值 a a a 计算如下:
a = f ( w 0 + ∑ w i N w i x i ) a=f(w_0+\sum _{w_i} ^N w_ix_i) a=f(w0+wi∑Nwixi)
可以看到, a a a 是权重和输入对的乘积之和,之后使用一个附加函数 f ( w 0 + ∑ w i N w i x i ) f(w_0+\sum _{w_i} ^N w_ix_i) f(w0+∑wiNwixi),函数 f f f 是在乘积之和之上应用的非线性激活函数,用于在输入和它们相应的权重值的总和上引入非线性,可以通过使用多个隐藏层实现更强的非线性能力。
整体而言,神经网络是节点的集合,其中每个节点都有一个可调整的浮点值(权重),并且节点间相互连接,返回由网络架构决定的输出。网络由三个主要部分组成:输入层、隐藏层和输出层。我们可以使用多层 (n
) 隐藏层,深度学习通常表示具有多个隐藏层的神经网络。通常,当神经网络需要学习具有复杂上下文或上下文不明显的任务(例如图像识别)时,就必须具有更多隐藏层。
2.2 神经网络的训练
训练神经网络实际上就是通过重复两个关键步骤来调整神经网络中的权重:前向传播和反向传播。
- 在前向传播 (
feedforward propagation
) 中,我们将一组权重应用于输入数据,将其传递给隐藏层,对隐藏层计算后的输出使用非线性激活,通过若干个隐藏层后,将最后一个隐藏层的输出与另一组权重相乘,就可以得到输出层的结果。对于第一次正向传播,权重的值将随机初始化 - 在反向传播 (
backpropagation
) 中,尝试通过测量输出的误差,然后相应地调整权重以降低误差。神经网络重复正向传播和反向传播以预测输出,直到获得令误差较小的权重为止
3. 图神经网络
图神经网络 (Graph Neural Network
,GNN
) 是一种专门用于处理图数据的深度学习模型。传统的神经网络主要用于处理向量或序列数据,而图神经网络则可以有效地处理非结构化的图形数据,如社交网络、推荐系统中的用户-物品关系、生物信息学中的蛋白质相互作用网络等。
图神经网络通过学习节点之间的连接模式和拓扑结构来捕捉图数据中的信息传播和相互作用。它通过将每个节点及其邻居节点的特征进行聚合和更新,从而实现对整个图的表示学习。在图神经网络中,通常会定义节点表示 (node embedding
) 和边表示 (edge embedding
),以便更好地表示节点之间的关系和特征。
图神经网络的基本原理是通过多层神经网络结构来逐步传播和更新节点的特征信息,从而实现对整个图的全局信息的学习和表示。这种方式可以帮助图神经网络在保留局部结构信息的同时,也考虑了全局图的拓扑结构和特征之间的关系,从而提高了对图数据的建模能力。
图神经网络已经在许多领域得到广泛应用,包括社交网络分析、推荐系统、生物信息学、知识图谱等。它为处理复杂的非结构化数据提供了一种强大的工具,有助于挖掘数据中的潜在模式和关联,从而推动了深度学习在图数据分析领域的发展和应用。
4. 使用香草神经网络执行节点分类
与 Zachary’s Karate Club 数据集相比,Cora 和 Facebook Page-Page 数据集包含了额外信息——节点特征,提供了图中节点的额外信息,例如用户的年龄、性别或兴趣爱好等。在香草神经网络( vanilla neural networks
, 也称为多层感知器, multilayer perceptron
)中,嵌入会直接用于模型,以执行节点分类等下游任务。
4.1 数据集构建
在本节中,我们将节点特征视为常规的表格数据集,并在这个数据集上训练一个简单的神经网络来对节点进行分类。需要注意的是,这种架构没有考虑到网络的拓扑结构。以 Cora
数据集为例,通过创建数据对象可以轻松访问节点特征的表格数据集。
首先,通过合并 data.x
(包含节点特征)和 data.y
(包含七个类别中每个节点的类别标签),将该对象转换为普通的 pandas DataFrame
:
import pandas as pd
df_x = pd.DataFrame(data.x.numpy())
df_x['label'] = pd.DataFrame(data.y)
print(df_x)
得到的数据集如下所示:
0 1 2 3 4 5 ... 1428 1429 1430 1431 1432 label
0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 3
1 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 4
2 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 4
3 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0
4 0.0 0.0 0.0 1.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 3
... ... ... ... ... ... ... ... ... ... ... ... ... ...
2703 0.0 0.0 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 3
2704 0.0 0.0 1.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 3
2705 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 3
2706 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 3
2707 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 3
[2708 rows x 1434 columns]
这是一个包含数据和标签的经典数据集,据此,我们可以构建一个简单的多层感知器 (Multilayer Perceptron
, MLP
),用 data.y
提供的标签对 data.x
进行训练。
创建 MLP
类,包含以下四个方法:
__init__()
用于初始化实例forward()
用于执行前向传播fit()
用于执行反向传播训练模型test()
用于评估模型
在训练模型之前,我们必须定义模型评价指标。多分类问题有多种评价指标,包括准确率 (accuracy
)、F1 分数
(F1 score
)、ROC AUC
( Area Under the Receiver Operating Characteristic Curve
) 等。在本节中,我们采用准确率作为评价指标,即正确预测的百分比。虽然这并不是多分类模型的最佳评价指标,但它更容易理解,我们也可以使用其他指标来代替它:
def accuracy(y_pred, y_true):
"""Calculate accuracy."""
return torch.sum(y_pred == y_true) / len(y_true)
4.2 模型构建
接下来,我们使用 PyTorch
构建 MPL
模型。
(1) 从 PyTorch
中导入所需的函数和类:
import torch
from torch.nn import Linear
import torch.nn.functional as F
(2) 创建一个名为 MLP
的新类,它继承自 torch.nn.Module
:
class MLP(torch.nn.Module):
(3) __init__()
方法有三个参数 (dim_in
、dim_h
和 dim_out
),分别表示输入层、隐藏层和输出层的神经元数量,在 __init__()
方法中还需要定义两个线性层:
def __init__(self, dim_in, dim_h, dim_out):
super().__init__()
self.linear1 = Linear(dim_in, dim_h)
self.linear2 = Linear(dim_h, dim_out)
(4) forward()
方法用于执行前向传递。输入通过 ReLU
(Rectified Linear Unit
) 激活函数馈送到第一个线性层,之后计算结果传递到第二个线性层。最后,返回分类结果的 log_softmax
值:
def forward(self, x):
x = self.linear1(x)
x = torch.relu(x)
x = self.linear2(x)
return F.log_softmax(x, dim=1)
(5) fit()
方法用于模型训练。首先,初始化一个损失函数和一个优化器用于训练过程:
def fit(self, data, epochs):
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(self.parameters(),
lr=0.01,
weight_decay=5e-4)
(6) 循环训练 MPL
模型,在损失函数之上使用 accuracy()
函数:
self.train()
for epoch in range(epochs+1):
optimizer.zero_grad()
out = self(data.x)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
acc = accuracy(out[data.train_mask].argmax(dim=1),
data.y[data.train_mask])
loss.backward()
optimizer.step()
(7) 在同一个循环中,每 20
个 epoch
打印一次训练数据和测试数据的损失和准确率:
if(epoch % 20 == 0):
val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
val_acc = accuracy(out[data.val_mask].argmax(dim=1),
data.y[data.val_mask])
print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc:'
f' {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | '
f'Val Acc: {val_acc*100:.2f}%')
(8) test()
方法用于在测试集上对模型进行评估,并返回模型准确率:
@torch.no_grad()
def test(self, data):
self.eval()
out = self(data.x)
acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
return acc
4.3 模型训练
对于不同数据集需要单独训练模型,由于我们想要分别在 Cora
和 Facebook Page-Page
数据集上执行节点分类任务,因此需要一个专门用于 Cora
的模型,和一个专门用于 Facebook Page-Page
的模型。首先,在 Cora
上训练 MLP
模型。
(1) 创建一个 MLP
模型并打印模型简要信息,以检查模型构建是否正确:
mlp = MLP(dataset.num_features, 16, dataset.num_classes)
print(mlp)
输出结果如下所示:
MLP(
(linear1): Linear(in_features=1433, out_features=16, bias=True)
(linear2): Linear(in_features=16, out_features=7, bias=True)
)
(2) 可以看到,我们得到了正确的特征数量,接下来,对这个模型进行 100
个 epoch
训练:
mlp.fit(data, epochs=100)
在训练循环中打印的指标变化情况如下所示:
Epoch 0 | Train Loss: 1.953 | Train Acc: 13.57% | Val Loss: 1.97 | Val Acc: 16.00%
Epoch 20 | Train Loss: 0.081 | Train Acc: 100.00% | Val Loss: 1.49 | Val Acc: 50.20%
Epoch 40 | Train Loss: 0.009 | Train Acc: 100.00% | Val Loss: 1.63 | Val Acc: 50.20%
Epoch 60 | Train Loss: 0.006 | Train Acc: 100.00% | Val Loss: 1.62 | Val Acc: 48.80%
Epoch 80 | Train Loss: 0.007 | Train Acc: 100.00% | Val Loss: 1.49 | Val Acc: 51.00%
Epoch 100 | Train Loss: 0.008 | Train Acc: 100.00% | Val Loss: 1.42 | Val Acc: 51.20%
(3) 最后,评估评估的准确率:
acc = mlp.test(data)
print(f'\nMLP test accuracy: {acc*100:.2f}%')
在测试数据上,模型准确率如下所示:
MLP test accuracy: 52.40%
(4) 对 Facebook Page-Page
数据集重复同样的过程,输出结果如下:
Epoch 0 | Train Loss: 1.400 | Train Acc: 26.00% | Val Loss: 1.41 | Val Acc: 25.86%
Epoch 20 | Train Loss: 0.651 | Train Acc: 74.33% | Val Loss: 0.66 | Val Acc: 73.89%
Epoch 40 | Train Loss: 0.575 | Train Acc: 77.14% | Val Loss: 0.62 | Val Acc: 75.49%
Epoch 60 | Train Loss: 0.547 | Train Acc: 78.07% | Val Loss: 0.60 | Val Acc: 75.79%
Epoch 80 | Train Loss: 0.531 | Train Acc: 78.83% | Val Loss: 0.60 | Val Acc: 75.54%
Epoch 100 | Train Loss: 0.517 | Train Acc: 79.52% | Val Loss: 0.59 | Val Acc: 75.69%
MLP test accuracy: 74.89%
尽管这两个数据集在某些方面非常相似,但可以看到,使用相同的模型训练后的模型准确率却大相径庭。接下来,我们将同时考虑图数据的节点特征和拓扑结构构建香草图神经网络 (Vanilla Graph Neural Network
, Vanilla GNN
)。
5. 实现香草图神经网络执行节点分类
5.1 图神经网络基本原理
在介绍图神经网络 (Graph Neural Network
, GNN
) 架构前,尝试从零开始构建 GNN
模型以了解 GNN
的核心思想。首先,我们回顾简单线性层的定义。
一个香草神经网络层对应于一个线性变换关系 h A = x A W T h_A=x_AW^T hA=xAWT,其中 x A x_A xA 是节点 A A A 的输入向量, W W W 是网络层的权重矩阵。在 PyTorch
中,可以使用 torch.mm()
函数或 nn.Linear
类来实现上述变换,而后者还可以添加偏置等其他参数。
对于图数据集,输入向量是节点特征,这意味着节点之间是完全独立的,因此 MPL
模型还不足以很好地理解图,与图像中的像素一样,节点的上下文对于理解节点至关重要。在图像中,我们只有在观察一组像素而不是单个像素时,才能够识别出边缘、模式等,同样,要了解一个节点,就需要查看它的邻居。
使用 N A \mathcal N_A NA 表示节点 A A A 的邻居集,则图线性层 (Graph Linear Layer
) 可以表示为:
h A = ∑ i ∈ N A x i W T h_A=\sum_{i\in \mathcal N_A} x_iW^T hA=i∈NA∑xiWT
也可以对上述公式进行简单的变换。例如,可以为中心节点设置一个权重矩阵 W 1 W_1 W1,为邻居设置另一个权重矩阵 W 2 W_2 W2。需要注意的是,我们不能为每个邻居设置一个权重矩阵,因为邻居节点数量会因节点不同而变化。
在神经网络中,为了高效的计算,不能将上述等式应用于每个节点,而应使用矩阵乘法。例如,线性层的方程可以改写为 H = X W T H=XW^T H=XWT,其中 X X X 是输入矩阵。
在 Cora
和 Facebook Page-Page
数据集中,邻接矩阵 A A A 包含图中每个节点之间的连接。将输入矩阵与邻接矩阵相乘,就能直接求出邻居节点的特征。可以在邻接矩阵中加入自循环,这样中心节点的特征也会被考虑在内。更新后的邻接矩阵可以表示为 A ~ = A + I \tilde A=A+I A~=A+I,图线性层可以重写如下:
H = A ~ T X W T H=\tilde A^TXW^T H=A~TXWT
5.2 实现香草图神经网络
在 PyTorch
中实现上述图线性层并进行测试,然后,将它作为常规网络层来构建一个香草图神经网络 (Vanilla Graph Neural Network
, Vanilla GNN
)。
(1) 首先,创建一个新类 VanillaGNNLayer
,继承自 torch.nn.Module
类:
class VanillaGNNLayer(torch.nn.Module):
(2) 类 VanillaGNNLayer
的初始化需要两个参数,dim_in
和 dim_out
,分别表示输入和输出的特征数,在 __init__()
方法中初始化一个无偏置线性变换:
def __init__(self, dim_in, dim_out):
super().__init__()
self.linear = Linear(dim_in, dim_out, bias=False)
(3) 在 forward()
方法中执行两个操作,首先执行线性变换,然后与邻接矩阵 A ~ \tilde A A~ 相乘:
def forward(self, x, adjacency):
x = self.linear(x)
x = torch.sparse.mm(adjacency, x)
return x
(4) 在创建 vanilla GNN
之前,还需要将数据集中的边索引 (data.edge_index
) 以坐标格式转换为邻接矩阵,此外,还需要加入自循环;否则,中心节点的特征将不会被它们自己的嵌入所考虑。使用 to_den_adj()
和 torch.eye()
函数能够快速实现上述操作:
from torch_geometric.utils import to_dense_adj
adjacency = to_dense_adj(data.edge_index)[0]
adjacency += torch.eye(len(adjacency))
print(adjacency)
输出邻接矩阵如下所示:
tensor([[1., 0., 0., ..., 0., 0., 0.],
[0., 1., 1., ..., 0., 0., 0.],
[0., 1., 1., ..., 0., 0., 0.],
...,
[0., 0., 0., ..., 1., 0., 0.],
[0., 0., 0., ..., 0., 1., 1.],
[0., 0., 0., ..., 0., 1., 1.]])
但由于它是一个稀疏矩阵,因此在这个张量中大部分元素 0
,节点之间存在连接时元素为 1
。有了图线性层和邻接矩阵后, 我们就可以用与实现 MLP
类似的方法实现 vanilla GNN
。
(5) 创建一个包含两个图线性层的新类:
class VanillaGNN(torch.nn.Module):
"""Vanilla Graph Neural Network"""
def __init__(self, dim_in, dim_h, dim_out):
super().__init__()
self.gnn1 = VanillaGNNLayer(dim_in, dim_h)
self.gnn2 = VanillaGNNLayer(dim_h, dim_out)
(6) 图神经网络层会将之前计算的邻接矩阵作为额外输入:
def forward(self, x, adjacency):
h = self.gnn1(x, adjacency)
h = torch.relu(h)
h = self.gnn2(h, adjacency)
return F.log_softmax(h, dim=1)
(7) fit()
和 test()
方法与 MLP
模型完全相同:
def fit(self, data, epochs):
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(self.parameters(),
lr=0.01,
weight_decay=5e-4)
self.train()
for epoch in range(epochs+1):
optimizer.zero_grad()
out = self(data.x, adjacency)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
acc = accuracy(out[data.train_mask].argmax(dim=1),
data.y[data.train_mask])
loss.backward()
optimizer.step()
if(epoch % 20 == 0):
val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
val_acc = accuracy(out[data.val_mask].argmax(dim=1),
data.y[data.val_mask])
print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc:'
f' {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | '
f'Val Acc: {val_acc*100:.2f}%')
@torch.no_grad()
def test(self, data):
self.eval()
out = self(data.x, adjacency)
acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
return acc
(8) 创建、训练并评估模型:
gnn = VanillaGNN(dataset.num_features, 16, dataset.num_classes)
print(gnn)
# Train
gnn.fit(data, epochs=100)
# Test
acc = gnn.test(data)
print(f'\nGNN test accuracy: {acc*100:.2f}%')
输出结果如下所示:
VanillaGNN(
(gnn1): VanillaGNNLayer(
(linear): Linear(in_features=1433, out_features=16, bias=False)
)
(gnn2): VanillaGNNLayer(
(linear): Linear(in_features=16, out_features=7, bias=False)
)
)
Epoch 0 | Train Loss: 2.626 | Train Acc: 16.43% | Val Loss: 2.56 | Val Acc: 7.40%
Epoch 20 | Train Loss: 0.274 | Train Acc: 95.71% | Val Loss: 1.22 | Val Acc: 67.60%
Epoch 40 | Train Loss: 0.046 | Train Acc: 100.00% | Val Loss: 1.40 | Val Acc: 74.60%
Epoch 60 | Train Loss: 0.015 | Train Acc: 100.00% | Val Loss: 1.60 | Val Acc: 74.00%
Epoch 80 | Train Loss: 0.008 | Train Acc: 100.00% | Val Loss: 1.65 | Val Acc: 73.40%
Epoch 100 | Train Loss: 0.005 | Train Acc: 100.00% | Val Loss: 1.68 | Val Acc: 72.00%
GNN test accuracy: 74.40%
用 Facebook Page-Page
数据集重复相同的训练过程。为了获得具有可比性的结果,我们在每个数据集上对每个模型重复了 100
次相同的实验,实验结果如下表所示:
可以看到,MLP
在 Cora
上的准确率较低,在 Facebook Page-Page
数据集上的表现较好,但在这两种情况下 MLP
的性能均被 vanilla GNN
所超越。这些结果表明了在节点特征中包含拓扑信息的重要性,由于 GNN
不使用表格数据集训练,而是考虑了每个节点的整个邻域,因此准确率提高了 10~20%
。
小结
在本节中,我们了解了 Vanilla 神经网络
和图神经网络 (Graph Neural Network
, GNN
)之间的差异,利用了一些简单的线性代数知识构建了香草图神经网络 (Vanilla Graph Neural Network
, Vanilla GNN
) 架构,同时使用了两个不同的图数据集,以便比较两种不同的架构。最后,使用 PyTorch
中实现了两种架构,并评估了它们的性能。结果表明,在这两个数据集上,即使是最简单的 Vanilla GNN
也完全优于 MLP
。
系列链接
图神经网络实战(1)——图神经网络(Graph Neural Networks, GNN)基础
图神经网络实战(2)——图论基础
图神经网络实战(3)——基于DeepWalk创建节点表示
图神经网络实战(4)——基于Node2Vec改进嵌入质量
图神经网络实战(5)——常用图数据集