精通 PyTorch——图神经网络

450 阅读39分钟

在前几章中,我们已经讨论了各种神经网络架构,从卷积神经网络到循环神经网络,从基于注意力机制的Transformer到自动生成的神经网络(NNs)。虽然这些架构覆盖了广泛的深度学习问题,但它们在处理存在于连续空间的数据时表现最佳,这些数据通常表示为向量,或在欧几里得空间中的坐标,如文本(1D)、图像(2D)和视频(3D)。然而,现实世界中的大量数据集以图或网络的形式存在,如社交网络、蛋白质相互作用网络、文献引用网络和万维网等。在本章中,我们将学习图神经网络(GNNs)——一种能够原生学习图结构中模式的深度学习模型。

我们首先会了解与图和图神经网络相关的基本概念。接着,我们将探讨不同类型的图学习任务,并研究一些重要的GNN模型。最后,我们将通过两个编码练习进行实操。首先,我们将使用PyTorch Geometric(一种基于PyTorch构建的库,能够轻松创建和训练GNNs)在图数据集上训练一种名为图卷积网络(GCN)的GNN模型。随后,我们将在同一图数据集上使用另一种名为图注意力网络(GAT)的GNN模型进行迭代。

本章分为以下几个主题:

  1. 图神经网络简介
  2. 图学习任务的类型
  3. 重要GNN模型的回顾
  4. 使用PyTorch Geometric构建GCN模型
  5. 使用PyTorch Geometric训练GAT模型

图神经网络简介

为了理解图神经网络(GNNs),我们将从回顾图数据结构开始。那么,什么是图?在计算机科学中,它指的是一种包含两个组成部分的数据结构:节点(或称顶点)和边,其中节点通常表示事物或对象,如人、地点或物体,而边则连接这些节点,例如表示节点之间的关系。图6.1展示了一个以人作为节点、以他们之间的关系作为边的图。需要注意的是,这里的边被画成了箭头,这表明这是一个有向图,其中关系有严格的顺序(B是A的父母,而不是反过来)。另一方面,无向图的边可以双向遍历(可以想象为兄弟姐妹关系),并且用连接节点的直线来表示。

image.png

从技术角度讲,图可以表示为

image.png

在这个方程中,VVV 表示顶点(节点)集合,EEE 表示节点之间的边集合。我们可以更具体地将图表示为邻接矩阵 AAA,其中矩阵的行和列代表图的顶点,而矩阵的条目则指示顶点对是否相邻(即是否连接)。

personApersonBpersonC
personA000
personB100
personC100

表6.1:人和关系的有向图的邻接矩阵

表6.1展示了图6.1中所示的示例图的邻接矩阵。如果这个矩阵中的任意(行,列)条目为1,则意味着该列代表的节点是该行节点的父母。注意,由于这是一个有向图,矩阵不是对称的。无向图则会产生对称的邻接矩阵。

理解图神经网络的直觉

在自然语言处理(NLP)中,单独看句子中的每一个词,可能对情感分析、语言翻译和文本生成图像等任务的机器学习模型训练没有太大帮助。我们通常需要查看多个词的序列才能理解句子的情感。同样地,在机器学习设置中,图数据不能直接使用。我们需要以一种有意义的方式表示图中节点的邻域。

此外,句子中的词通常被编码为嵌入向量,编码的方式使得同义词之间的距离较小(例如余弦距离),而反义词之间的(余弦)距离较大。这些词嵌入向量随后用于特定的下游任务。对于图,我们需要做类似的事情——将节点表示为嵌入,例如,这些嵌入不仅捕捉节点特定的信息,还捕捉给定节点邻域的信息,如图6.2所示。

image.png

如图6.2所示,父节点(B和C)的嵌入在嵌入空间中彼此靠近,而子节点(A)则被放置在较远的位置。这是因为(假设的)编码函数利用了图的结构,这揭示了节点B和C之间的相似性,例如它们的年龄相似。除了捕捉节点特定的信息之外,还要捕捉图的结构信息,这正是促成图神经网络(GNNs)这一新型深度学习模型类别诞生的原因,否则普通的神经网络(NNs)就已经足够了。让我们简要地探讨一下如何将普通的神经网络模型应用于图数据。

将普通神经网络应用于图数据的思维实验

我们可以从使用邻接矩阵开始,用来表示每个图节点。邻接矩阵的每一行都包含了对应节点与其余节点之间关系的信息。除了邻接矩阵,每个节点本身还可以由不同的特征构成。

例如,在图6.1所示的图中,每个人不仅可以用一个ID(A、B或C)来表示,还可以由一组不同的特征组成,如ID、年龄、性别、身高和体重。在给定邻接矩阵和节点内在特征的情况下,图6.3展示了我们如何利用这些信息来训练一个普通的(前馈)神经网络模型。

image.png

如图6.3所示,我们可以将邻接矩阵数据与节点级别的特征连接起来,作为性别预测神经网络模型的输入。虽然这种方法看起来可能适用于给定的任务,但它有几个局限性:

首先,这种方法只捕捉了节点周围的一阶图信息,以最近邻居的邻接特征形式存在。在大型图数据集中,虽然一阶(或直接)邻居已经能带来一定的价值,但二阶或更高阶邻居(即通过一个或多个中间节点连接的节点)在提取完整的图信息时可能至关重要。

其次,如果我们在邻接矩阵中将图节点的顺序从A、B、C更改为B、C、A,那么节点B的邻接特征将从1,0,01, 0, 01,0,0变为0,0,10, 0, 10,0,1。这意味着这种特征表示依赖于严格的节点顺序,而在这种设置下训练的模型在面对相同图但节点顺序不同的情况下会出现问题。

第三,如果在我们的人员图中添加一个新节点(人),那么新节点及现有节点的特征向量长度将从6增加到7,从而需要从头开始对所有节点重新训练模型。

最后,从邻接矩阵中获得的特征将是稀疏的,因为这个特征向量中的条目只有一个值为1,而其余的特征全为0。

上述限制表明,对于图数据,我们需要一个截然不同的解决方案,这就是图神经网络(GNNs)发挥作用的地方。

通过计算图理解GNNs的强大功能

为了解决使用普通神经网络的第一个限制,我们需要的不仅仅是邻接矩阵,还需要一种能够表示给定节点周围完整图信息的方式——包括一阶、二阶及更高阶的连接。这可以通过使用计算图来实现,如图6.4所示。

image.png

上图中的每个节点由其对应的节点级别特征表示,如果一个节点有多个邻居节点,这些邻居节点的特征会在传播到给定节点之前进行聚合。原始图示于右上角。

对于图中的每个节点,我们创建一个计算图,其中节点的邻域分层表示——每一层代表不同的连接度。在图6.4中,我们看到对于给定的无向图,节点A直接连接到节点B和C,而B和C又连接回节点A。因此,节点A与节点B和C有一阶连接,并且与自身有二阶连接。

在这个示例中,我们将计算图的深度限制为2,但可以将其推广到深度n,其中这种计算图的最后一层将表示与原始节点的第n阶连接。

在图6.4中节点A的计算图上,可以看到前向传播从最右端的节点A的节点级特征开始。这些特征通过一个神经网络(NN2)传播,生成节点B和C的潜在表示,然后这些表示被聚合,聚合结果再通过另一个神经网络(NN1)传播,生成节点A的潜在表示。然后我们使用节点A的这个表示(同样地,节点B和C的表示也来自它们各自的计算图)作为其嵌入向量,用于训练下游任务,例如性别分类。

注意

NN1和NN2的符号可能看起来有些混淆,但它们实际上是根据与原始节点的连接度编号的。

需要注意的是,给定层的所有神经网络以及所有计算图之间的权重都是共享的。例如,图6.4中的所有NN1在所有计算图中共享相同的参数。此外,GCN中的卷积(我们将在后面的章节讨论)正是源于这种权重共享机制。权重共享防止了随着图节点数量或计算图深度增加,整体模型参数数量的爆炸。权重共享还使得系统对节点顺序的变化或原始图中新节点的添加具有鲁棒性。

多层计算图帮助我们解决了使用普通神经网络处理图数据时遇到的第一个限制。由于在每一层聚合了多个节点特征,我们也解决了使用普通神经网络处理图时遇到的第二个限制,因为图中的节点顺序不再重要了。这隐含地意味着聚合函数需要与顺序无关,如求和、平均、最大值和最小值。我们不能使用如连接这样的聚合函数。为了应对第三个限制,即在添加新节点时需要重新训练整个神经网络,借助计算图,我们只需为这个新添加的节点创建一个计算图。最后,我们克服了与稀疏性相关的第四个限制,因为在计算图中,我们不使用邻接矩阵信息,仅依赖节点的内在特征,因此避免了输入特征中出现大量的零。

我们已经讨论了图神经网络的核心原理。在计算图的每一层中使用的具体聚合函数、为NN1和NN2选择的具体神经网络架构,以及端到端训练图神经网络的过程,在不同的GNN架构中都会有所不同,我们将在本章的后续部分进行讨论。但现在,让我们简要了解一下可以在图数据上使用GNNs执行的不同类型的学习任务。

图学习任务的类型

我们已经讨论了图和图神经网络(GNNs)的基本原理,但使用GNNs可以在图数据上执行哪些类型的任务呢?(这里所说的任务,指的是前一部分提到的下游任务。)总体而言,图学习任务可以分为三大类:

  • 节点级任务
  • 边级任务
  • 图级任务

让我们简要讨论一下这些任务。

理解节点级任务

节点级任务旨在预测图中给定节点的类别。我们在前面部分的演示(图6.3)基于节点级任务,其中任务是预测每个节点的性别——男性、女性或其他。对于此类任务,每个节点的潜在特征表示(节点计算图的最终输出)被用来训练下游任务。

这类任务可以是:

  • 分类任务——涉及离散标签(如性别、颜色等)
  • 回归任务——涉及连续数值(如年龄、身高等)
  • 聚类任务——将图中的节点划分为不同的簇

这些内容如图6.5所示。

image.png

如果将图像视为像素的图,那么一个等效的节点级任务就是语义分割,即为每个像素(节点)预测一个分割类别。同样,如果将句子视为词的图,那么词性标注就是一个节点级任务,其中每个词被分配一个词性标签(名词、动词、形容词等)。

理解边级任务

类似于节点级任务,边级任务的目标是对边进行分类。每条边可以通过结合/连接它所连接的节点的节点级特征来进行数值表示。此外,在某些图中,边也有其自身的边级特征。通常,边级任务使用相关的节点特征以及边特征来训练下游分类任务,例如在家庭关系图中预测关系类型(父母、兄弟姐妹等),如图6.6所示。

image.png

在图6.6中,为了预测A和B之间的关系类型,使用了节点A和B的潜在特征。在无向图的情况下,两个节点的特征通过一个置换不变的聚合函数(例如求和、平均、最小值和最大值)进行组合,这样节点的顺序(A->B或B->A)就不会影响边的特征。

image.png

在计算机视觉领域,图像场景理解(见图6.7)是一个合适的边级任务等价例子,其中图像中的不同对象是节点,这些对象之间的关系是边,目标是预测图像中这些对象之间的关系类型。

理解图级任务

在图级任务中,我们通过聚合图中所有节点的潜在特征(使用置换不变的聚合函数),来预测整个图的类别或数值。许多图数据集中包含多个不相连的图,例如分子图数据集,其中每个分子都是一个图结构。在这种情况下,预测分子类型是图级任务的一个例子。

image.png

在图像领域,图像分类是图级任务的等价例子,因为图像(图)中的所有像素(节点)被用于为整个图像(图)赋予一个单一的值。

到此,我们完成了对各种图学习任务类型的讨论。在下一节中,我们将深入探讨过去十年(截至2024年5月)开发的一些重要的图神经网络模型。我们将学习一些著名GNN方法的关键架构和算法特性。

回顾重要的GNN模型

在本节中,我们将讨论几种流行的GNN模型。虽然在过去的二十年里开发了许多GNN架构,但我们将把回顾范围限定在以下模型,因为它们涵盖了当今图建模中使用的主要GNN概念(截至2024年5月):

  • GCN
  • GAT
  • GraphSAGE

我们将简要讨论这些模型的内部工作原理,并突出这些模型变体之间的差异。

理解GCN中的图卷积

我们之前提到过,GCN中的“卷积”一词来源于图中共享的权重(NN1, NN2)(参考图6.4)。为了更好地理解GCN的工作原理以及GCN如何从图数据集中提取额外的图信息,让我们回顾一下如何使用传统的神经网络解决图问题,如图6.9所示。

image.png

如图6.9所示,传统的(前馈)神经网络只能利用节点级的局部信息来预测节点类别。相比之下,GCN使用计算图为每个节点提取信息,不仅来自节点自身,还包括节点的邻居、邻居的邻居,依此类推,如图6.10所示。

image.png

如图6.10所示,为了预测节点A的类别,一个两层深度的GCN模型在第一层从节点A的邻居(包括它自己){A, B, D}中获取信息。第二层则从节点A、B和D的邻居(包括它们自己)中分别获取信息——{A, B, D}、{A, B}和{A, C, D}。在每一层中都会进行聚合——具体来说是取平均值,这确保了特征长度在各层之间保持不变,并且节点的顺序不会影响聚合过程。在两层GCN之间,还加入了ReLU(修正线性单元)和dropout层以增加非线性。

如果我们对比图6.10和图6.9,图6.9中的全连接层实际上被图6.10中的GCN层替代。GCN层本身包含一个全连接层,但还包含了邻居特征聚合组件,这就是使GCN在图数据集上表现出色的秘诀。虽然我们使用节点分类作为讨论GCN的例子,GCN也可以执行图分类,其中聚合是在整个图的所有节点上进行的。

虽然GCN使用邻居信息的平均值来聚合特征,已经能够提取有价值的图信息,但在寻找更好的邻居信息聚合方法方面,已经做了大量工作。GNN研究中一个重要的里程碑就是图注意力网络(GAT),我们将在接下来讨论。

在图中使用注意力机制——GAT

GCN使用平均值作为聚合邻居节点特征信息的机制。这有一个内在的局限性,即它假设所有邻居都应被平等对待,而这不一定符合实际情况。例如,如果两个节点X和Y具有相同的初始特征值和相同的邻居集合,GCN模型可能会将它们识别为同一类别或簇,但这可能并不准确。为了在图中捕捉这种细微的信息,我们可以用注意力机制替代简单的平均机制。这正是GATs的用武之地。

我们在第五章中学习了注意力机制,在处理文本数据时,通过为句子中的不同词分配不同的重要性,从而专注于特定部分进行进一步处理。在GAT的上下文中,注意力机制允许模型在对节点类型进行分类时,对节点的不同邻居赋予不同的权重,从而实现更复杂、更强大的模型。通过注意力机制,我们为每个邻居学习注意力系数,从而为模型增加了更多可训练的参数。图6.11展示了GAT和GCN在聚合邻居节点特征时的不同方法。

image.png

如图6.11所示,我们引入了一组新的可训练参数,形式为一个注意力向量。该注意力向量的长度是单个节点特征向量的两倍,因为它通过与给定节点的特征和邻居节点的特征连接后的点积来计算。这个可学习的注意力向量在给定层的所有<节点, 邻居>对之间共享。这组额外的可训练参数使得GATs能够为不同的特征维度和不同的邻居学习到不同的权重。

每个<节点, 邻居>对的注意力系数通过将注意力向量与节点和邻居特征的连接后的点积传递到一个带泄漏的ReLU函数中来计算。最后,每个邻居的权重通过应用于注意力系数的softmax函数来计算,softmax函数不仅增加了进一步的非线性,还确保权重的总和为1。通过这种方式,GATs使用可训练的注意力参数显著增强了从邻居节点提取信息的机制。

到目前为止,我们已经看到了适用于整个图的算法。为了对给定节点进行分类,我们查看了所有的邻居(第1层)、邻居的邻居(第2层)等。现实世界中的图可能包含数十亿个节点(如社交网络),当在整个图上使用这种GNN模型时,可扩展性可能会成为一个问题。

为了更有效地处理如此庞大的图数据集,近年来开发了许多图采样方法。GraphSAGE就是其中之一,我们将在接下来讨论它。

使用GraphSAGE进行图采样

GraphSAGE(Graph Sample and Aggregate的缩写)随机均匀地为给定节点采样邻居,并仅使用这些选定的邻居来提取图信息,而不像GCN和GAT那样使用所有邻居。因此,这种算法对于大型且密集的图非常有用。图6.12展示了GraphSAGE在节点分类中的工作原理。

image.png

在图6.12中演示的示例中,对于节点A,采样的邻居集合为{B, C, D, E}。GraphSAGE从中随机采样出{C, E}。在第二级连接(即第二层邻居)中,对于节点C和E,GraphSAGE分别从可能的邻居{A, D}和{A, B}中各采样两个邻居。接下来,将节点{A, D}的特征聚合,并将聚合结果与节点C的特征连接在一起。

将连接后的结果传递给前馈神经网络层,随后应用ReLU和dropout。相同的操作也在节点E的邻居节点{A, B}上执行。

在GraphSAGE的第一层结束时,我们得到了节点C和E的潜在表示。在模型的第二层,这些潜在表示进一步聚合,并与节点A的潜在特征连接在一起。对连接后的结果进行前馈层操作,最终生成节点的类别概率。

在图6.12中提到的聚合函数方面,原始GraphSAGE论文的作者提到了三种不同的邻居节点特征聚合方法:

  • 平均值
  • 长短期记忆(LSTM)模型
  • 最大池化

除了通常的平均技术外,我们还可以重新排列邻居节点的顺序,并将它们传递到LSTM模型中以生成输出嵌入。或者,每个邻居的特征向量可以通过前馈神经网络传递,生成num_neighbors数量的输出特征向量,并在所有邻居中取每个输出特征的最大值。

GraphSAGE模型的重要继任者之一是由Pinterest开发的PinSAGE,它用于其推荐系统中,涉及超过30亿个节点(用户)和超过180亿条边。到此,我们简要讨论了一些著名的GNN模型、其底层架构和算法。在接下来的部分中,我们将从GNN理论转向实际的编码练习,涉及普通神经网络、GCN和GAT。

使用PyTorch Geometric构建GCN模型

在前面的部分中,我们已经涵盖了大量的GNN理论。现在是动手实践的时间了。在本节中,我们将使用PyTorch Geometric——一个用于PyTorch的GNN库,具备以下特性:

  • 优化的图数据加载/处理功能
  • 流行图数据集的仓库
  • 著名GNN架构的实现
  • 预训练的流行GNN模型权重

我们将在著名的CiteSeer图数据集上构建我们自己的GCN模型。CiteSeer是一个引用网络的数据集,其中包含作为节点的科学出版物,并根据引用关系连接节点。我们将在该图中执行节点级任务,将科学出版物分类为数据集中可用的六个类别之一。

首先,我们将使用PyTorch Geometric的数据API来探索和理解数据集。然后,我们将构建一个简单的基于神经网络的分类解决方案,作为该任务的基准。这种基准方法不会使用任何图信息,而仅使用每个节点(出版物)的内在特征。之后,我们将在同一任务上构建、训练和评估一个GCN模型,并观察通过利用图信息,该方法是否能超过基准。我们将看到PyTorch Geometric如何通过几行代码让我们实现这一切。本章的所有代码可以从我们的GitHub仓库中访问。

加载和探索引用网络数据集

和任何机器学习项目一样,一切都从数据开始。在本节中,我们将学习如何使用PyTorch Geometric加载、处理和可视化CiteSeer图数据集,该数据集可以直接从PyTorch Geometric库中加载。在加载数据之前,我们需要从该库中导入一些重要模块:

from torch_geometric.nn import GCNConv
from torch_geometric.utils import to_networkx
from torch_geometric.datasets import Planetoid

第一行简单地导入了GCN模型类,稍后我们将用它快速开发我们的GCN模型。第二行导入了to_networkx函数,该函数将PyTorch Geometric数据集转换为适合NetworkX的图对象。NetworkX [6] 是一个Python库,提供了广泛的工具来创建、分析和可视化复杂网络/图及其属性。最后一行导入了一个称为Planetoid的数据集,其中包含引用网络数据集。

现在,我们可以使用以下代码行加载数据集并探索其关键属性:

dataset = Planetoid(root='data/Planetoid', name='CiteSeer')
print(f'Dataset: {dataset}:')
print('======================')
print(f'Number of graphs: {len(dataset)}')
print(f'Number of features: {dataset.num_features}')
print(f'Number of classes: {dataset.num_classes}')

这将产生以下输出:

Dataset: CiteSeer():
======================
Number of graphs: 1
Number of features: 3703
Number of classes: 6

这里首先要注意的是,这个数据集中只有一个图,不像其他图数据集包含多个不相连的图(参见图6.8)。其次,我们现在知道这个图中的每个节点有3703个特征表示一个节点。第三,我们可以看到这个数据集中总共有6个节点类别。但这个数据集中到底有多少个节点和边呢?让我们用以下代码找出答案:

data = dataset[0]  # 获取第一个图对象。
print(data)
print('================================================')
print(f'Number of nodes: {data.num_nodes}')
print(f'Number of edges: {data.num_edges}')
...
print(f'Is undirected: {data.is_undirected()}')

这将产生以下输出:

Data(x=[3327, 3703], edge_index=[2, 9104], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327])
==========================================================
Number of nodes: 3327
Number of edges: 9104
Average node degree: 2.74
Number of training nodes: 120
Training node label rate: 0.04
Has isolated nodes: True
Has self-loops: False
Is undirected: True

首先,我们在数据变量下获取这个数据集中唯一的图。这个图包含3327个节点和9104条边,其中3327个节点中有120个节点具有标签(六个类别之一)。该数据集中没有孤立的节点,也没有节点与自身连接(自环)。最后,由于这是一个无向图,边是双向的。

我们已经使用PyTorch Geometric的函数收集了一些关于引用图的信息。在构建这个数据集上的模型之前,让我们使用以下代码可视化这个图数据集:

def visualize_graph(G, color):
    plt.figure(figsize=(15,15))
    plt.xticks([])
    plt.yticks([])
    nx.draw_networkx(
        G, pos=nx.spring_layout(G), with_labels=False, 
        node_color=color, cmap="Set2")
    plt.show()

G = to_networkx(data, to_undirected=True)
visualize_graph(G, color=data.y)

这将产生如图6.13所示的输出。

image.png

如图6.13所示,在这个图中有一个密集的出版物网络,同时在图的外围还有多个较小的出版物子网络。

现在我们已经加载、探索并可视化了CiteSeer图数据集,接下来我们将使用该数据集构建一个基于简单神经网络的节点分类器。

构建一个简单的基于神经网络的节点分类器

在本节中,我们将使用PyTorch在CiteSeer图数据集上构建和训练一个多层感知机(MLP)模型。首先,我们实例化一个带有随机权重的MLP模型,该模型接受节点的特征作为输入,并输出节点的类别。接着,我们可视化由随机初始化的MLP模型生成的节点嵌入(大小为6)。然后,我们在多类分类任务上训练MLP模型。最后,我们评估训练好的MLP模型,并可视化训练后模型生成的节点嵌入。

图6.9展示了一个用于节点分类的MLP模型架构,包含一个输入层(与节点特征数量相同)、一个包含16个神经元的隐藏层,以及一个包含6个神经元的输出层(对应六种不同的节点类别)。我们在隐藏层中使用了ReLU激活和dropout。

下面是实例化MLP模型的等效PyTorch代码:

class MLP(torch.nn.Module):
    def __init__(self, hidden_channels):
        super().__init__()
        torch.manual_seed(12345)
        self.lin1 = Linear(dataset.num_features, hidden_channels)
        self.lin2 = Linear(hidden_channels, dataset.num_classes)

    def forward(self, x):
        x = self.lin1(x)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.lin2(x)
        return x

model = MLP(hidden_channels=16)
print(model)

这将产生以下输出:

MLP(
  (lin1): Linear(in_features=3703, out_features=16, bias=True)
  (lin2): Linear(in_features=16, out_features=6, bias=True)
)

如我们所见,该模型中有3703个输入特征和6个输出特征。接下来,我们将使用这个未训练的模型获取数据集中所有节点的6个输出特征,并对这些6维特征嵌入应用t-SNE算法,以获得一个有代表性的二维嵌入。t-SNE是一种降维技术,用于在保留成对相似性的同时,将高维数据可视化为低维空间。你可以详细了解t-SNE [7]。我们使用以下代码在二维图上绘制数据集中的所有节点,使用的是t-SNE嵌入:

def visualize(data, labels):
    tsne = TSNE(n_components=2, init='pca', random_state=7)
    tsne_res = tsne.fit_transform(data)
    v = pd.DataFrame(data, columns=[str(i) for i in range(data.shape[1])])
    v['color'] = labels
    v['label'] = v['color'].apply(lambda i: str(i))
    v["dim1"] = tsne_res[:,0]
    v["dim2"] = tsne_res[:,1]
    plt.figure(figsize=(12,12))
    sns.scatterplot(
        x="dim1", y="dim2",
        hue="color",
        palette=sns.color_palette(
            ["#52D1DC", "#8D0004", "#845218","#563EAA", 
             "#E44658", "#63C100", "#FF7800"]),
        legend=False,
        data=v,
    )

model.eval()
out = model(data.x)
visualize(out.detach().cpu().numpy(), data.y)

这将生成如图6.14所示的输出。

image.png

如图6.14所示,属于不同类别的所有节点(用6种不同颜色表示)在这个二维分布中随机分布。这是预期的结果,因为用于获取这些嵌入的MLP模型尚未训练,且权重是随机初始化的。

在可视化代码中,我们定义了一个visualize函数,该函数接受节点嵌入(MLP输出)以及节点类别标签(6个类别之一)作为输入。这个函数使用t-SNE算法将大小为6的节点嵌入转换为大小为2。我们使用这个visualize函数来直观地检查不同模型的性能,看它们如何将不同类别的节点分散成同质的簇。现在,我们将继续使用以下代码在节点级特征上训练MLP模型:

criterion = torch.nn.CrossEntropyLoss() 
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-3)

def train():
    model.train()
    optimizer.zero_grad() 
    out = model(data.x)
    loss = criterion(out[data.train_mask], data.y[data.train_mask]) 
    loss.backward() 
    optimizer.step() 
    return loss

def test(mask):
    model.eval()
    out = model(data.x)
    pred = out.argmax(dim=1) 
    correct = pred[mask] == data.y[mask] 
    acc = int(correct.sum()) / int(mask.sum())
    return acc

for epoch in range(1, 101):
    loss = train()
    val_acc = test(data.val_mask)
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val: {val_acc:.4f}')

首先,我们定义了损失函数——用于多类分类任务的交叉熵损失(CrossEntropyLoss)。然后,我们定义Adam作为我们的优化器。接下来,我们定义train函数,在这个函数中使用梯度下降和反向传播来训练MLP模型。我们还定义了一个test函数,用来评估训练好的MLP模型的性能。test函数有一个mask参数,该参数可以接受如train_maskval_masktest_mask这样的变量作为输入,其中mask是一个长度等于图中节点总数的列表。mask列表包含0和1,分别表示图中的哪个节点应该被忽略或选择。如果图中有3个节点,第一个节点属于训练集,第二个节点属于验证集,第三个节点属于测试集,那么训练掩码将是[1, 0, 0],验证掩码将是[0, 1, 0],测试掩码将是[0, 0, 1]

最后,上述代码运行了一个100轮的训练循环,并打印了模型在训练集和验证集上的表现。代码应该产生如下输出:

Epoch: 001, Loss: 1.8052, Val: 0.1020
Epoch: 002, Loss: 1.7371, Val: 0.1540
Epoch: 003, Loss: 1.6468, Val: 0.1940
Epoch: 004, Loss: 1.5291, Val: 0.2820
Epoch: 005, Loss: 1.3691, Val: 0.3720
...
Epoch: 096, Loss: 0.2214, Val: 0.5720
Epoch: 097, Loss: 0.1520, Val: 0.5720
Epoch: 098, Loss: 0.2303, Val: 0.5740
Epoch: 099, Loss: 0.2675, Val: 0.5700
Epoch: 100, Loss: 0.2040, Val: 0.5620

训练日志表明,模型正在从训练集中学习,如训练损失的逐步降低所示。日志还显示验证集准确率从第一轮的10%增加到接近第100轮的56-57%。现在让我们使用以下代码检查训练好的模型(第100轮)的性能在未接触过的测试集上的表现:

test_acc = test(data.test_mask)
print(f'Test Accuracy: {test_acc:.4f}')

这将产生如下输出:

Test Accuracy: 0.5710

测试集上的准确率与训练集上的表现非常接近,这表明我们的训练结果在图的未见部分上具有普遍性。因此,我们可以假设我们已经成功地训练了一个基于MLP的节点分类器。现在让我们回到基于t-SNE的可视化,并使用训练好的MLP模型生成节点嵌入,并使用以下代码在二维图上进行可视化:

out = model(data.x)
visualize(out.detach().cpu().numpy(), data.y)

这将产生如图6.15所示的输出。

image.png

图6.15显示了相同颜色的节点聚集成6个独立的簇或组。这些节点簇之间有一些重叠,但从视觉上可以明显看出,经过训练的MLP模型生成的嵌入在区分不同类别的节点方面远优于未训练的MLP模型。因此,MLP模型确实能够仅使用3703个节点级别的特征(没有任何图的上下文信息)以57%的准确率对节点进行分类。(记住,对于6个类别,随机分类器的准确率为16.67%。)但57%是否足够?或者我们能做得更好?我们是否充分利用了这个图数据集中可用的信息来对节点进行分类?我们使用了节点级别的特征,但邻域上下文呢?这些信息有帮助吗?我们如何在构建节点分类器时使用这些信息?我们将在接下来学习所有这些内容。

构建一个用于节点分类的GCN模型

在前一节中,我们利用CiteSeer图数据集的节点级别特征,使用一个简单的MLP模型对不同类型的节点进行了分类。在本节中,我们将超越节点级别的信息,进一步利用给定节点的邻域信息,使用GCN进行节点分类。我们将使用PyTorch Geometric,在几行代码内构建和训练一个GCN模型。让我们开始吧:

首先,我们定义GCN模型的架构,并通过以下代码定义模型的前向传播过程:

class GCN(torch.nn.Module):
    def __init__(self, hidden_channels):
        super().__init__()
        torch.manual_seed(1234567)
        self.conv1 = GCNConv(dataset.num_features, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, dataset.num_classes)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x

model = GCN(hidden_channels=16)
print(model)

这段代码应该生成如下输出:

GCN(
  (conv1): GCNConv(3703, 16)
  (conv2): GCNConv(16, 6)
)

上述代码定义并实例化了一个两层的GCN模型。图中的所有节点在第一层中从3703个输入特征转化为16个中间特征,然后在第二层/输出层中从16个特征转化为6个输出类别,类似于图6.10中的演示。

现在我们使用实例化的未训练的GCN模型来获取数据集中所有节点的6个输出特征,并对这6个特征应用t-SNE,将它们转换为2个特征,然后使用以下代码在二维图上绘制图数据集中的所有节点:

model.eval()
out = model(data.x, data.edge_index)
visualize(out.detach().cpu().numpy(), data.y)

这将生成图6.16中的输出。

image.png

与图6.14类似,如图6.16所示,不同类别的所有节点(用6种不同颜色表示)随机分布。这是预期的结果,因为GCN模型的权重是随机初始化的。

接下来,我们在定义优化器、损失函数以及模型训练和评估流程后,训练GCN模型100轮,代码如下:

optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-3)
criterion = torch.nn.CrossEntropyLoss()

def train():
    model.train()
    optimizer.zero_grad()
    out = model(data.x, data.edge_index)
    loss = criterion(out[data.train_mask], data.y[data.train_mask])
    loss.backward()
    optimizer.step()
    return loss

def test(mask):
    model.eval()
    out = model(data.x, data.edge_index)
    pred = out.argmax(dim=1)
    correct = pred[mask] == data.y[mask]
    acc = int(correct.sum()) / int(mask.sum())
    return acc

for epoch in range(1, 101):
    loss = train()
    val_acc = test(data.val_mask)
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val: {val_acc:.4f}')

这将生成如下输出:

Epoch: 001, Loss: 1.7871, Val: 0.3620
Epoch: 002, Loss: 1.6260, Val: 0.4100
Epoch: 003, Loss: 1.4544, Val: 0.5100
Epoch: 004, Loss: 1.2277, Val: 0.5740
Epoch: 005, Loss: 1.0790, Val: 0.6200
. . .
Epoch: 096, Loss: 0.1098, Val: 0.6880
Epoch: 097, Loss: 0.1258, Val: 0.6940
Epoch: 098, Loss: 0.0871, Val: 0.6960
Epoch: 099, Loss: 0.1123, Val: 0.7000
Epoch: 100, Loss: 0.1076, Val: 0.7000

上述代码与之前MLP模型训练代码的区别仅在于前向传播的部分,除了提供节点级特征(data.x)外,我们还提供了用紧凑格式表示的邻接矩阵(data.edge_index),该矩阵列出了图中所有边的节点索引对。这一额外的信息帮助GCN模型为每个节点运行计算图,正如图6.10中对节点A的演示所示。训练日志表明,GCN模型的训练损失低于MLP模型的训练损失,验证集的准确率也高于MLP模型的验证集准确率。

在训练好GCN模型后,我们使用与评估MLP模型相同的测试集对训练好的GCN模型进行评估,代码如下:

test_acc = test(data.test_mask)
print(f'Test Accuracy: {test_acc:.4f}')

这将生成如下输出:

Test Accuracy: 0.6960

如我们所见,GCN模型的准确率为69.60%,相比于MLP模型在测试集上获得的57.10%的准确率有了显著提升。这是合理的,因为GCN模型除了使用MLP模型的节点级特征外,还使用了额外的图信息。

最后,我们使用训练好的GCN模型对所有图节点进行前向传播,生成6个输出概率,再使用t-SNE将这6个数字转换为2个数字,并在二维图上进行可视化,代码如下:

out = model(data.x, data.edge_index)
visualize(out.detach().cpu().numpy(), data.y)

这将生成如图6.17所示的输出。

image.png

图6.17进一步确认了我们训练的GCN模型在节点分类任务上的表现非常出色,因为与图6.16中节点的随机分布相比,图6.17中节点的聚类效果显著更好。

此外,图6.17的节点聚类效果甚至优于图6.15中由训练过的MLP模型生成的聚类。图6.17几乎形成了一种星状结构,五个节点类别指向星星的五个角,而第六个类别分散在中心。

GCN模型成功地利用了额外的图信息,将不同类别的节点进一步分开。尽管我们现在已经使用了节点级特征和图级(邻域)信息来通过GCN模型对节点进行分类,但我们还能进一步提高节点分类的准确率吗?我们能进一步优化模型——图学习算法吗?在本章的最后一部分中,我们将借助注意力机制来提高节点分类的性能。

使用PyTorch Geometric训练GAT模型

在上一节中,我们通过使用GCN模型在节点分类任务上超越了基准MLP模型的性能。在本节中,我们将通过用GAT模型替换GCN模型进一步改进我们的解决方案。基本上,我们将用图6.11所示的注意力机制替换图6.10中的平均机制(聚合来自邻居节点的信息)。

借助PyTorch Geometric,我们只需几行代码就可以将基于GCN的解决方案重构为基于GAT的解决方案,步骤如下:

首先,我们定义GAT模型的架构及其前向传播函数:

class GAT(torch.nn.Module):
    def __init__(self, hidden_channels, heads):
        super().__init__()
        torch.manual_seed(1234567)
        self.conv1 = GATConv(dataset.num_features, 
                             hidden_channels, heads)
        self.conv2 = GATConv(hidden_channels * heads, 
                             dataset.num_classes, heads=1)

    def forward(self, x, edge_index):
        x = F.dropout(x, p=0.6, training=self.training)
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, p=0.6, training=self.training)
        x = self.conv2(x, edge_index)
        return x

model = GAT(hidden_channels=16, heads=8)
print(model)

这将生成如下输出:

GAT(
  (conv1): GATConv(3703, 16, heads=8)
  (conv2): GATConv(128, 6, heads=1)
)

这个模型的一个重要方面是每层GATConv的注意力头的数量。在第一层中,我们设置了八个注意力头。这意味着我们将使用八个并行且独立的可训练注意力系数,派生出八个并行的邻居节点特征聚合,如图6.18所示。

image.png

图6.18:展示了单头GAT与多头GAT工作方式的对比——多头GAT简单地复制了8个注意力头,并将这些头的结果连接起来,生成了8倍丰富的节点嵌入。

在第一个GATConv层结束时,这8个注意力头生成的特征向量(每个大小为16)被连接在一起,得到大小为128的输出。第二个GATConv层包含1个注意力头,并输出6个节点类别。在这个模型的前向传播过程中,我们使用了多个dropout层,以应对由于模型复杂性增加(尤其是多个注意力头,使得信息在图中通过时更加细致)可能导致的过拟合问题。

与我们为MLP和GCN模型所做的类似,现在我们将数据集中所有的节点通过刚刚定义的未训练的GAT模型,生成六个节点类别的概率向量。然后,我们对这六个数字应用t-SNE算法,将它们减少到两个特征,并使用二维表示法绘制图数据集中的所有节点,代码如下:

model.eval()
out = model(data.x, data.edge_index)
visualize(out.detach().cpu().numpy(), data.y)

这将生成如图6.19所示的输出。

image.png

正如预期的那样,分布是随机的。现在我们准备训练GAT模型。

接下来,我们在定义优化器、损失函数以及模型的训练和评估流程后,训练GAT模型100轮,代码如下:

# 超参数借鉴自torch geometric的GAT示例
# https://colab.research.google.com/
# drive/14OvFnAXggxB8vM4e8vSURUp1TaKnovzX?usp=sharing
optimizer = torch.optim.Adam(
    model.parameters(), lr=0.0005, weight_decay=1e-1)
criterion = torch.nn.CrossEntropyLoss()

def train():
    model.train()
    optimizer.zero_grad()
    out = model(data.x, data.edge_index)
    loss = criterion(out[data.train_mask], data.y[data.train_mask])
    loss.backward()
    optimizer.step()
    return loss

def test(mask):
    model.eval()
    out = model(data.x, data.edge_index)
    pred = out.argmax(dim=1)
    correct = pred[mask] == data.y[mask]
    acc = int(correct.sum()) / int(mask.sum())
    return acc

for epoch in range(1, 101):
    loss = train()
    val_acc = test(data.val_mask)
    test_acc = test(data.test_mask)
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val: {val_acc:.4f}')

这将生成如下输出:

Epoch: 001, Loss: 1.7793, Val: 0.2180
Epoch: 002, Loss: 1.7874, Val: 0.2320
Epoch: 003, Loss: 1.7888, Val: 0.2480
Epoch: 004, Loss: 1.7795, Val: 0.2760
Epoch: 005, Loss: 1.7772, Val: 0.2980
. . .
Epoch: 096, Loss: 1.1057, Val: 0.7180
Epoch: 097, Loss: 1.1514, Val: 0.7200
Epoch: 098, Loss: 1.1342, Val: 0.7220
Epoch: 099, Loss: 1.1354, Val: 0.7220
Epoch: 100, Loss: 1.1131, Val: 0.7220

GAT模型的训练和评估流程与GCN模型相同。从训练日志来看,验证集的准确率在100轮训练结束时略高于GCN模型的验证集准确率(72.20%对比70.00%)。我们可以通过评估测试集结果来确认这一趋势。

我们使用训练好的GAT模型来评估其在测试集上的准确率,代码如下:

test_acc = test(data.test_mask)
print(f'Test Accuracy: {test_acc:.4f}')

这将生成如下输出:

Test Accuracy: 0.7210

相比使用GCN模型获得的69.60%的准确率,使用GAT模型我们又提高了2.50%。这是预期的,因为注意力层的强大特性增加了模型的可训练参数,并为图模型提供了更多的灵活性,以学习不同邻居节点之间的定制关系。

最后,我们可以通过使用训练好的GAT模型对所有图节点进行预测,并使用t-SNE将6维节点类别概率转换为二维,来可视化这些节点嵌入,代码如下:

out = model(data.x, data.edge_index)
visualize(out.detach().cpu().numpy(), data.y)

这将生成如图6.20所示的输出。

image.png

首先,与图6.19相比,节点不再随机分散,而是明显地聚集在一起,这表明模型确实已被正确训练。而且,从图6.20中不同类别节点的更清晰分离可以看出,该模型似乎比GCN模型学到了更好的节点表示。

至此,我们使用PyTorch Geometric探索GNNs的过程也告一段落。虽然我们在CiteSeer数据集上覆盖了基于GCN和GAT的节点分类模型,PyTorch Geometric还提供了广泛的功能,我鼓励你去探索【8】,例如处理边分类和图分类任务,使用其他模型类型如GraphSAGE,或者使用这个库提供的不同的、或许更大的图数据集,以进一步强化你在PyTorch中使用GNN的技能。

总结

在本章中,我们首先简要概述了GNNs,并了解了不同类型的图学习任务。接着,我们探讨了一些著名的GNN模型。最后,我们使用PyTorch Geometric库在CiteSeer图数据集上进行了几个实际操作练习。在这些练习中,我们在图数据上训练了一个前馈神经网络模型来完成节点分类任务。随后,我们将前馈神经网络模型替换为GCN模型,显著提高了分类准确率。最后,我们将GCN模型替换为GAT模型,进一步增强了模型性能。在下一章中,我们将转换话题,学习如何使用PyTorch进行音乐和文本生成。