图神经网络实战-全-

图神经网络实战(全)

原文:zh.annas-archive.org/md5/aa0f9b9d5919ff9efe42c7ab05a87a0b

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分 初步步骤

图是表示复杂、相互关联数据最灵活和最有力的方式之一。本部分首先介绍了图论的基本概念,解释了什么是图,为什么它们作为数据类型很重要,以及它们的结构如何捕捉传统数据格式所遗漏的关系。你将探索图的构建块和不同类型的图。

然后,我们将探讨关于图神经网络(GNNs)的基础概念,从它们是什么以及它们与传统神经网络的不同之处开始。有了这个基础,我们研究图嵌入,揭示如何以使它们对机器学*有用的方式表示图。这些概念为在后续章节中掌握 GNNs 及其变革性能力奠定了基础。到这本书的这一部分结束时,你将有一个扎实的理解,为你深入探索 GNNs 的机制做好准备。

第一章:发现图神经网络

本章涵盖

  • 定义图和图神经网络

  • 理解为什么人们对图神经网络感到兴奋

  • 认识到何时使用图神经网络

  • 从宏观角度审视使用图神经网络解决问题

对于数据从业者来说,机器学*和数据科学领域最初之所以令人兴奋,是因为从数据中提取非直观且有用的见解的潜力。特别是,机器学*和深度学*的见解有望增强我们对世界的理解。对于工作的工程师来说,这些工具承诺以前所未有的方式创造商业价值。

经验与这一理想有所偏差。现实世界的数据通常杂乱无章、污秽且存在偏见。此外,统计方法和学*系统都带有自己的局限性。实践者的一个基本作用是理解这些局限性,弥合真实数据与可行解决方案之间的差距。例如,我们可能想要预测银行的欺诈活动,但首先需要确保我们的训练数据已被正确标记。更重要的是,我们还需要检查我们的模型不会错误地将欺诈活动分配给正常行为,这可能是由于数据中的一些隐藏混杂因素。

对于图数据,直到最*,弥合这一差距一直特别具有挑战性。图是一种信息丰富的数据结构,特别擅长捕捉数据中关系所扮演的关键角色。图无处不在,关系数据以不同的形式出现,如分子中的原子(自然)、社交网络(社会),甚至互联网上网页之间的连接模型(技术)[1]。需要注意的是,这里的“关系”一词并不指代“关系数据库”,而是指关系中具有显著意义的数据。

以前,如果你想在深度学*模型中整合图中的关系特征,必须以间接的方式进行,使用不同的模型来处理、分析和然后使用图数据。这些独立的模型通常难以扩展,并且难以考虑到图数据的所有节点和边属性。为了充分利用这种丰富且普遍存在的数据类型进行机器学*,我们需要一种专门为图的独特品质和关系数据设计的机器学*技术。这正是图神经网络(GNNs)填补的差距。

深度学*领域常常围绕新技术和方法存在很多炒作。然而,GNNs 被广泛认为是基于图学*的一个真正的飞跃[2]。这并不意味着 GNNs 是一劳永逸的解决方案。应该仔细比较 GNNs 得出的预测结果与其他机器学*和深度学*方法。

需要记住的关键点是,如果你的数据科学问题涉及可以结构化为图的数据——也就是说,数据是连接的或相关的——那么 GNNs 可以提供一种有价值的解决方案,即使你还没有意识到你的方法中缺少了什么。GNNs 可以设计来处理非常大的数据,进行扩展,并适应不同大小和形状的图。这可以使处理以关系为中心的数据更加容易和高效,同时产生更丰富的结果。

GNNs 的突出优势是为什么数据科学家和工程师越来越认识到掌握它们的重要性。GNNs 能够从关系数据中揭示独特的见解——从识别新的药物候选者到优化 Google Maps 应用中的 ETA 预测准确性——作为发现和创新的催化剂,并赋予专业人士推动传统数据分析边界的力量。它们的广泛应用跨越各个领域,为专业人士提供了一种多功能的工具,在电子商务(例如,推荐引擎)和生物信息学(例如,药物毒性预测)中同样相关。GNNs 的熟练掌握使数据专业人士能够进行增强、准确和创新的图数据分析。

由于所有这些原因,图神经网络(GNNs)现在已成为推荐引擎、分析社交网络、检测欺诈、理解生物分子行为以及本书中我们将遇到的其他许多实际应用的流行选择。

1.1 本书的目标

《图神经网络实战》(Graph Neural Networks in Action)旨在帮助实践者开始部署 GNNs 来解决实际问题。这可能是一个不熟悉图数据结构的机器学*工程师,一个尚未尝试 GNNs 的数据科学家,甚至可能是一个可能对两者都不熟悉的软件工程师。在本书中,我们将从图的基本知识一直覆盖到更复杂的 GNN 模型。我们将逐步构建 GNN 的架构。这包括 GNN 的整体架构和消息传递的关键方面。然后我们继续添加不同的功能和扩展到这些基本方面,例如引入卷积和采样、注意力机制、生成模型以及在动态图上操作。在构建我们的 GNNs 时,我们将使用 Python 和一些标准库。GNN 库要么是独立的,要么使用 TensorFlow 或 PyTorch 作为后端。在本文本中,重点将放在 PyTorch Geometric(PyG)上。其他流行的库包括深度图库(Deep Graph Library,DGL,一个独立的库)和 Spektral(它使用 Keras 和 TensorFlow 作为后端)。还有 Jraph,适用于 JAX 用户。

本书的目标是使你能够

  • 评估 GNN 解决方案对你问题的适用性。

  • 了解何时传统的神经网络在处理图结构数据时不如 GNN 表现得好,以及何时 GNN 可能不是表格数据的最佳工具。

  • 设计和实现一个 GNN 架构来解决你特有的问题。

  • 明确 GNNs 的限制。

本书侧重于使用编程实现。我们也花了一些时间在必要理论和概念上,以确保所涵盖的技术能够被充分理解。这些内容在大多数章节的“内部机制”部分进行介绍,以区分技术原因和实际实现。本书基于我们在书中介绍的关键概念,有许多不同的模型和包。因此,本书不应被视为对所有 GNN 方法和模型的全面回顾,这可能需要数千页,而应被视为好奇和渴望学*的实践者的起点。

本书分为三个部分。第一部分涵盖了 GNNs 的基础知识,特别是它们与其他神经网络(如消息传递嵌入)的不同之处,这些对于 GNNs 具有特定的含义。第二部分,本书的核心内容,介绍了模型本身,其中我们涵盖了几个关键模型类型。然后,在第三部分,我们将对一些更复杂的模型和概念进行更详细的探讨,包括如何扩展图以及处理时间数据。

《图神经网络实战》旨在帮助人们快速进入这个新领域并开始构建应用。本书的目标是通过填补空白和回答关键开发问题来减少实现新技术的摩擦,这些问题可能不容易找到或根本未在其他地方涉及。每种方法都是通过一个示例应用来介绍的,这样你可以了解 GNNs 在实际中的应用。我们强烈建议你在过程中亲自尝试代码。

1.1.1 补充图的基本知识

是的,在理解 GNNs 之前,你需要了解图的基本知识。然而,本书的目标是教授 GNNs 给深度学*实践者和传统神经网络的构建者,他们可能对图知之甚少。同时,我们也认识到本书的读者在图的知识上可能存在巨大差异。如何解决这些差异并确保每个人都能充分利用本书?在本章中,我们提供了对理解 GNNs 最基本图概念的介绍。如果你对图非常熟悉,你可以选择跳过这一部分,尽管我们建议快速浏览,因为我们涵盖了一些对理解本书剩余部分有帮助的特定术语和用例。对于那些对图有更多疑问的人,我们在附录 A 中也包含了一个关于基本图概念和术语的完整教程。这个入门指南也应当作为查找特定概念的参考。

在回顾了图和图学*中的关键概念之后,我们将探讨几个领域中的案例研究,在这些领域中 GNN 正在成功应用。然后,我们将分析这些特定案例,看看是什么使得 GNN 成为一种好的应用案例,以及如何知道你手头是否有 GNN 问题。在本章末尾,我们将介绍 GNN 的机制,这是本书其余部分将添加的骨架。

1.2 基于图的学*

本节定义了图、基于图的学*和 GNN 的某些基本原理,包括图的基本结构和不同类型图的分类。然后,我们将回顾基于图的学*,将 GNN 与其他学*方法联系起来。最后,我们将解释图的价值,并以泰坦尼克号数据集的数据为例。

1.2.1 什么是图?

图是包含元素的数据结构,用 节点或顶点 表示,元素之间的关系用 边或链接 表示,如图 1.1 所示。图中的所有节点都将有额外的 特征数据。这是节点特定的数据,与社交网络中个人的姓名或年龄等相关。链接是关系数据力量的关键,因为它们使我们能够了解系统,为数据分析提供新的工具,并从中预测新的属性。这与数据库表、数据框或电子表格等表格数据形成对比,其中数据固定在行和列中。

图

图 1.1 一个图。图中由字母 A 到 E 表示的各个元素是节点,也称为顶点,它们之间的关系由边描述,也称为链接。

为了描述和从节点之间的边中学*,我们需要一种方法来记录它们。这可以通过明确声明 A 节点连接到 B 和 E,以及 B 节点连接到 A、C、D 和 E 来完成。很快,我们可以看到以这种方式描述事物变得难以管理,我们可能会重复冗余信息(A 连接到 B 和 B 连接到 A)。幸运的是,有许多数学形式用于描述图中的关系。其中最常见的一种是描述 邻接矩阵,我们在表 1.1 中将其列出。请注意,邻接矩阵在对角线两侧是对称的,并且所有值都是 1 或 0。

表 1.1 图 1.1 中简单图的邻接矩阵
A B C D E
A 0 1 0 0 1
B 1 0 1 1 1
C 0 1 0 1 0
D 0 1 1 0 1
E 1 1 0 1 0

图的邻接矩阵是一个重要的概念,它使得在一个表格中观察图的所有连接变得容易[3]。在这里,我们假设我们的图没有方向性;也就是说,如果 0 连接到 1,那么 1 也连接到 0。这被称为无向图。无向图可以从邻接矩阵中轻松推断出来,因为在这种情况下,矩阵在对角线上是对称的(例如,在表 1.1 中,右上角被反射到左下角)。

我们还假设节点之间的关系都是相同的。如果我们想让节点 B-E 的关系比节点 B-A 的关系更重要,我们可以增加这条边的权重。这相当于增加邻接矩阵中的值,例如,使表 1.1 中 B-A 边的条目等于 10 而不是 1。

所有关系都同等重要的图称为无权图,并且可以从邻接矩阵中轻松观察到,因为所有图条目要么是 1,要么是 0。具有多个值的边的图称为加权图

如果图中任何节点都没有连接到自身的边,那么这些节点在其邻接矩阵中的值也将是 0(对角线上的 0)。这意味着图中没有自环。当一个节点有一个连接到同一节点的边时,就发生了自环。要添加自环,我们只需将该节点在对角线上的值设置为非零。

在实践中,邻接矩阵只是描述图中关系的许多方法之一。其他包括邻接表、边列表或关联矩阵。充分理解这些数据结构对于基于图的学*至关重要。如果您不熟悉这些术语或需要复*,我们建议查阅附录 A,其中包含更多细节和解释。

1.2.2 不同类型的图

了解许多不同类型的图可以帮助我们确定使用哪些方法来分析和转换图,以及应用哪些机器学*方法。以下,我们给出了一些最常见的图属性的快速概述。和之前一样,我们建议您查阅附录 A 以获取更多信息。

同质图和异质图

最基本的图是同质图,由一种类型的节点和一种类型的边组成。考虑一个描述招聘网络的同质图。在这种类型的图中,节点将代表求职者,边将代表候选人之间的关系。

如果我们想要扩展我们图的能力来描述我们的招聘网络,我们可以给它更多的节点和边类型,使其成为一个异质图。通过这种扩展,一些节点可能是候选人,而其他节点可能是公司。边现在可以由候选人与公司当前或过去的就业关系组成。参见图 1.2 中同质图(所有节点或边具有相同的阴影)与异质图(节点和边具有各种阴影)的比较。

figure

图 1.2 同质图和异质图。在此,节点或边的阴影代表其类型或类别。对于同质图,所有节点都是同一类型,所有边也都是同一类型。对于异质图,节点和边有多种类型。

二部图

与异质图类似,二部图也可以被分离或划分为不同的子集。然而,二部图(图 1.3)具有一个非常特定的网络结构,即每个子集中的节点连接到其子集之外的节点,而不是内部的节点。稍后,我们将讨论推荐系统和 Pinterest 图。这个图是二部图,因为一组节点(图钉)连接另一组节点(版面),但不是它们自己组内的节点(图钉)。

figure

图 1.3 二部图。有两种类型的节点(两种阴影的圆圈)。在二部图中,节点不能连接到同一类型的节点。这也是一个异质图的例子。

循环图、无环图和有向无环图

如果一个图是循环的,那么你可以从一个节点开始,沿着其边旅行,不重复任何步骤,返回到起始节点,在图中创建一个环形路径。相比之下,在一个无环图中,无论从任何起始节点选择哪条路径,你都不能不回溯就返回到起始点。这些图,如图 1.4 所示,通常类似于树状结构或没有回环的路径。

虽然有向图和无向图都可以是有向的或无向的,但有向无环图(DAG)是一种特定的无环图,它仅限于有向。在有向无环图中,所有边都有方向,不允许有环。DAGs 代表单向关系,你不能跟随箭头并最终回到起点。这种特性使得 DAGs 在因果分析中至关重要,因为它们反映了假设因果是单向的因果结构。例如,A 可以导致 B,但 B 不能同时导致 A。这种单向性质与 DAGs 的结构完美契合,使它们成为建模各种领域中的工作流程过程、依赖链和因果关系的理想选择。

figure

图 1.4 展示了循环图(左)、无环图(右)和 DAG(底部)。在循环图中,通过连接节点 A-E-D-C-B-A 的箭头(有向边)显示了循环。请注意,节点 G 和 F 是图的一部分,但不是其定义循环的一部分。无环图由无向边组成,不可能存在循环。在 DAG 中,所有有向边都流向一个方向,从 A 到 F。

知识图谱

知识图谱是一种特殊类型的异构图,它通过丰富的语义意义来表示数据,不仅捕捉不同实体之间的关系,还捕捉这些关系的上下文和本质。与主要强调结构和连接性的传统图不同,知识图谱结合了元数据和遵循特定模式,以提供更深入的上下文信息。这允许进行高级推理和查询功能,例如识别模式、揭示特定类型的连接或推断新的关系。

在大学学术研究网络的例子中,知识图谱可能表示各种实体,如教授、学生、论文和研究主题,并明确定义它们之间的关系。例如,教授和学生可以通过作者关系与论文相关联,而教授也可能指导学生。此外,图将反映层级结构,如教授和学生被归类在系别下。您可以在图 1.5 中看到这个知识图谱的表示。

figure

图 1.5 表示大学物理系内部学术研究网络的图谱。该图谱展示了层级关系,例如教授和学生作为系成员,以及行为关系,例如教授指导学生和撰写论文。实体如教授、学生、论文和主题通过语义上有意义的关系(指导、撰写、启发)相互连接(Supervises, Wrote, Inspires)。实体还具有详细特征(姓名、系别、类型),提供更多上下文。语义连接和特征使得对复杂的学术互动进行高级查询和分析成为可能。

知识图谱的一个关键特征是它们提供明确上下文的能力。与传统的异构图不同,传统的异构图显示不同类型的实体及其基本连接,但没有详细的语义意义,知识图谱通过定义特定类型和关系的具体类型和意义更进一步。例如,虽然传统的图可能显示教授与系部相连或学生与论文相链接,但知识图谱会具体说明教授监督学生或学生和教授共同撰写论文。这一层额外的意义使得查询和分析更加强大,使得知识图谱在自然语言处理、推荐系统和学术研究分析等领域特别有价值。

超图

在所有需要处理的图中,超图是较为复杂和困难的一种。超图是指单条边可以连接多个不同节点的图。对于不是超图的图,边用于连接恰好两个节点(或节点自身形成自环)。如图 1.6 所示,超图中的边可以连接任意数量的节点。超图的复杂性体现在其邻接数据上。对于典型图,网络连通性由二维邻接矩阵表示。对于超图,邻接矩阵扩展到更高维的张量,称为关联张量。这个张量是 N 维的,其中 N 是单条边连接的最大节点数。一个超图的例子可能是一个允许进行群聊以及单个人对话的通信平台。在普通图中,边只会连接两个人。在超图中,一个超边可以连接多个人,表示一个群聊。

图

图 1.6 一种无向超图,以两种方式展示。在左侧,我们有一个图,其边由阴影区域表示,用字母标记,其顶点由点表示,用数字标记。在右侧,我们有一个图,其边线(用字母标记)连接最多 3 个节点(用数字标记的圆圈)。节点 8 没有边。节点 7 有一个自环。

1.2.3 基于图的学*

正如我们将在本章的其余部分看到的那样,图在我们的日常生活中无处不在。基于图的学*将图作为输入数据来构建模型,以洞察关于这些数据的问题。在本章的后面部分,我们将探讨不同的图数据示例,以及我们可以使用基于图的学*来回答的问题和任务。

基于图的学*使用各种机器学*方法来构建图的表示。这些表示随后用于下游任务,如节点或链接预测或图分类。在第二章中,你将了解基于图学*中的一个基本工具,即构建嵌入。简而言之,嵌入是低维向量表示。我们可以构建不同节点、边或整个图的嵌入,并且有几种不同的方法可以实现这一点,例如 Node2Vec (N2V)或 DeepWalk 算法。

图数据上的分析方法已经存在很长时间了,至少早在 20 世纪 50 年代,当团方法使用图的某些特征来识别图数据中的子集或社区时[4]。

最著名的基于图的算法之一是 PageRank,它由拉里·佩奇和谢尔盖·布林在 1996 年开发,并成为谷歌搜索算法的基础。有些人认为这个算法是公司在随后的几年中迅速崛起的关键因素。这突显了成功的基于图的学*算法可以产生巨大的影响。

这些方法只是基于图的学*和分析技术的一个小子集。其他包括信念传播[5]、图核方法[6]、标签传播[7]和等距映射[8]。然而,在这本书中,我们将重点关注基于图的学*技术家族中最新且最激动人心的补充之一:GNN。

1.2.4 什么是 GNN?

GNN 结合了基于图的学*和深度学*。这意味着神经网络被用来构建嵌入并处理关系数据。GNN 内部工作原理的概述如图 1.7 所示。

GNN 允许你表示和学*图,包括它们的构成节点、边和特征。特别是,许多 GNN 方法专门设计用来有效地扩展到图的大小和复杂性。这意味着 GNN 可以在非常大的图上运行,正如我们将讨论的。在这方面,GNN 为关系数据提供了与卷积神经网络为基于图像的数据和计算机视觉所提供的类似的优势。

figure

图 1.7 GNN 工作原理概述。一个输入图被传递给 GNN。然后 GNN 使用神经网络通过称为消息传递的过程将图特征(如节点或边)转换为非线性嵌入。然后使用训练数据对这些嵌入进行调整,以适应特定的未知属性。GNN 训练完成后,它可以预测图的未知特征。

历史上,将传统的机器学*方法应用于图数据结构一直具有挑战性,因为当图数据以网格状格式和数据结构表示时,可能会导致数据的大量重复。为了解决这个问题,基于图的学*专注于排列不变性的方法。这意味着机器学*方法不受图表示顺序的影响。具体来说,这意味着我们可以随意打乱邻接矩阵的行和列,而不会影响算法的性能。每当我们在处理包含关系数据的数据时,也就是说,具有邻接矩阵的数据时,我们希望使用排列不变的机器学*方法,使我们的方法更加通用和高效。尽管 GNNs 可以应用于所有图数据,但 GNNs 特别有用,因为它们可以处理巨大的图数据集,并且通常比其他机器学*方法表现更好。

排列不变性是一种归纳偏差,或算法的学*偏差,是设计机器学*算法的强大工具 [1]。对排列不变性方法的需求是*年来基于图的学*增加受欢迎程度的核心原因之一。

设计用于排列不变性数据带来了一些缺点,同时也带来了优点。GNNs 并不适合其他数据,如图像或表格。虽然这看起来可能很明显,但图像和表格不是排列不变的,因此不适合 GNNs。如果我们打乱图像的行和列,那么就会打乱输入。相反,图像的机器学*算法寻求平移不变性,这意味着我们可以平移(移动)图像中的对象,而不会影响算法的性能。其他神经网络,如卷积神经网络(CNNs),通常在图像上表现更好。

1.2.5 表格数据和图数据之间的差异

图数据包括所有具有某种关系内容的数据,使其成为表示复杂连接的有力方式。虽然图数据最初可能看起来与传统表格数据不同,但许多通常以表格形式表示的数据集可以通过一些数据工程和想象力重新创建为图。让我们更仔细地看看泰坦尼克号数据集,它是机器学*中的一个经典例子,并探讨它如何从表格格式转换为图格式。

泰坦尼克号数据集描述了泰坦尼克号上的乘客,这艘船因与冰山相撞而著名地遭遇了不幸的结局。历史上,这个数据集一直是用表格格式分析的,包含每名乘客的行,列代表年龄、性别、票价、等级和生存状态等特征。然而,该数据集还包含丰富的、在表格格式中不立即可见的关系,如图 1.8 所示。

figure

图 1.8 泰坦尼克号数据集通常以表格格式显示和分析。

将泰坦尼克号数据集重新构造成图

要将泰坦尼克号数据集转换为图,我们需要考虑如何将乘客之间的基本关系表示为节点和边:

  • 节点——在图中,每个乘客都可以表示为一个节点。我们还可以为其他实体引入节点,例如客舱、家庭,甚至如“三等乘客”这样的群体。

  • ——边代表这些节点之间的关系或连接。例如:

    • 根据可用数据,是家庭成员(兄弟姐妹、配偶、父母或子女)的乘客

    • 共享同一客舱或曾一同旅行的乘客

    • 可能从共享的票号、姓氏或其他识别特征中推断出的社会或商业关系

要构建这个图,我们需要使用表格中的现有信息,并可能通过次要数据源或假设(例如,将姓氏链接以创建家庭群体)来丰富它。这个过程将表格数据转换为基于图的架构,如图 1.9 所示,其中每个边和节点封装了有意义的关联数据。

图

图 1.9 泰坦尼克号数据集,展示了泰坦尼克号上人们的家庭关系,以图的形式可视化(来源:Matt Hagy)。在这里,我们可以看到存在丰富的社交网络以及许多具有未知家庭联系的乘客。

图数据如何增加深度和意义

一旦数据集被表示为图,它就提供了对乘客之间社会和家庭联系的更深入视角。例如:

  • 家庭关系——图清楚地显示了某些乘客之间的关系(例如,作为父母、子女或兄弟姐妹)。这有助于我们理解生存模式,因为家庭成员在危机中可能的行为与独自旅行的个体可能不同。

  • 社交网络——除了家庭之外,图可能揭示更广泛的社会网络(例如,友谊或商业联系),这些可能是分析行为和结果的重要因素。

  • 社区洞察——图结构还允许社区检测算法识别相关或连接的乘客集群,这可能揭示关于生存率、救援模式或其他行为的新的见解。

图表示通过指定在表格格式中可能不明显的关系来增加深度。例如,了解谁曾一同旅行、谁共享了客舱,或者谁有社会或家庭联系,可以提供更多关于生存率和乘客行为的背景信息。这对于诸如节点预测等任务至关重要,在这些任务中,我们希望根据图中表示的关系来预测属性或结果。

通过创建邻接矩阵或根据数据集中的关系定义图中的边和节点,我们可以从简单的数据分析过渡到更复杂的基于图的学*方法。

1.3 GNN 应用:案例研究

正如我们所见,GNN 是设计用于处理关系数据的神经网络。它们通过比以前的基于图的学*方法更容易扩展和更准确,为关系数据的转换和处理提供了新的方法。在以下内容中,我们将讨论一些 GNN 的激动人心的应用,从高层次上了解这类模型是如何解决现实世界问题的。如果您想了解更多关于这些特定项目的详细信息,可以在本书末尾找到相关论文的链接。

1.3.1 推荐引擎

企业图可以超过数十亿个节点和数十亿条边。另一方面,许多 GNN 在包含不到一百万个节点的数据集上进行基准测试。当将 GNN 应用于大型图时,必须调整训练和推理算法以及存储技术。(您可以在第七章中了解更多关于扩展 GNN 的具体细节。)

GNN 最著名的行业应用之一是它们作为推荐引擎的使用。例如,Pinterest 是一个用于寻找和分享图像和想法的社会媒体平台。对于 Pinterest 的用户有两个主要概念:称为版面的想法集合或类别(就像公告板一样);以及用户想要书签的对象,称为图钉。图钉包括图像、视频和网站 URL。一个专注于狗的用户版面可能包括宠物照片、小狗视频或与狗相关的网站链接。版面的图钉并不局限于它;如图 1.10 所示,一个被图钉到狗版面的宠物画也可能被图钉到小狗版面。

图

图 1.10 一个类似于 Pinterest 图的二分图。本例中的节点是图钉和版面。

到目前为止,Pinterest 有 4 亿活跃用户,他们可能每人已经图钉了数十甚至数百个物品。Pinterest 的一个紧迫任务是帮助他们通过推荐找到感兴趣的内容。这样的推荐不仅应该考虑图像数据和用户标签,还应该从图钉和版面之间的关系中汲取见解。

解释图钉和版面之间关系的一种方式是将其视为二分图,这是我们之前讨论过的。对于 Pinterest 图,所有图钉都与版面相连,但没有图钉与另一个图钉相连,也没有版面与另一个版面相连。图钉和版面是两种节点类别。这些类别的成员可以与其他类别的成员相连接,但不能与同一类别的成员相连接。据报道,Pinterest 图有 30 亿个节点和 180 亿条边。

PinSage,一种图卷积网络(GCN),是第一个在企业系统中记录的高规模 GNN 之一[9]。它在 Pinterest 的推荐系统中被用来克服将图学*模型应用于大规模图的传统挑战。与基线方法相比,该系统的测试表明,它提高了用户参与度 30%。具体来说,PinSage 被用来预测应推荐给用户包含在其图中的哪些对象。然而,GNNs 也可以用来预测一个对象是什么,例如它是否包含狗或山,基于图中其余节点及其连接方式。我们将在第三章中深入探讨 GCNs,其中 PinSage 是其扩展。

1.3.2 药物发现与分子科学

在化学和分子科学中,一个突出的问题是以通用、与应用无关的方式表示分子,并推断分子之间(如蛋白质)的可能界面。对于分子表示,我们可以看到在高中化学课堂上常见的分子结构图与图结构相似,由节点(原子)和边(原子键)组成,如图 1.11 所示。

figure

图 1.11 在这个分子中,我们可以将单个原子视为节点,将原子键视为边。

在某些情况下,将 GNNs 应用于这些结构可以优于传统的“指纹”方法,以确定分子的属性。这些传统方法涉及领域专家创建特征来捕捉分子的属性,例如解释某些分子或原子的存在与否[10]。GNNs 学*新的数据驱动特征,可以以新的和意想不到的方式将某些分子分组在一起,甚至可以提出新的分子用于合成。这对于预测化学物质是否有毒性或是否安全使用,或者它是否有可能影响疾病进展的下游效应至关重要。因此,GNNs 在药物发现领域已被证明是非常有用的。

药物发现,特别是对于图神经网络(GNNs),可以理解为图预测问题。图预测任务是需要学*和预测整个图属性的任务。对于药物发现,目标是预测诸如毒性或治疗有效性(判别性)等属性,或者提出应合成和测试的全新图(生成性)。为了提出这些新图,药物发现方法通常将 GNNs 与其他生成模型(如变分图自动编码器(VGAEs))相结合,例如图 1.12 所示。我们将在第五章中更详细地描述 VGAEs,并展示如何使用这些模型来预测分子。

figure

图 1.12 展示了用于预测新分子的 GNN 系统 [11]。这里的流程从左边的分子作为图的表示开始。在图的中部部分,这个图表示通过 GNN 转换为潜在表示。然后,潜在表示被转换回分子,以确保潜在空间可以被解码(右图)。

1.3.3 机械推理

我们在非常年轻的时候,甚至在没有任何正式训练的情况下,就发展了对周围世界力学和物理的初步直觉。我们不需要写下一系列方程式就能知道如何接住弹跳的球。我们甚至不需要一个实际的球。给定一系列弹跳球的快照,我们能够合理地预测球最终会落在何处。

虽然这些问题对我们来说可能看似微不足道,但对于许多物理行业,包括制造业和自动驾驶,它们是至关重要的。例如,自动驾驶系统需要预测由许多移动物体组成的交通场景中会发生什么。直到最*,这项任务通常被视为计算机视觉问题。然而,最*的方法已经开始使用 GNN [12]。这些基于 GNN 的方法表明,包括关系信息,如肢体如何连接,可以使算法以更高的准确性和更少的数据开发出关于人或动物如何移动的物理直觉。

在图 1.13 中,我们给出了一个例子,说明一个物体可以被看作是一个“机械”图。这些物理推理系统的输入图具有反映问题的元素。例如,当推理关于人或动物的身体时,图可以由表示身体上肢体连接点的节点组成。对于自由体系统,图的节点可以是单个物体,如弹跳球。图的边代表节点之间的物理关系(例如,重力、弹性弹簧或刚性连接)。给定这些输入,GNN 学*预测一组物体的未来状态,而不需要明确调用物理/力学定律 [13]。这些方法是一种边预测形式;也就是说,它们预测节点随时间如何连接。此外,这些模型必须是动态的,以解释系统的时态演变。我们将在第六章详细讨论这些问题。

figure

图 1.13 展示了机械体的图形表示,摘自 Sanchez-Gonzalez [13]。该物体的各个部分被表示为节点,而将它们连接在一起的机械力则表示为边。

1.4 何时使用 GNN?

既然我们已经探讨了图神经网络(GNNs)在现实世界中的应用,让我们来识别一些使问题适合基于图解决方案的潜在特征。虽然上一节中的案例明显涉及自然建模为图的数据,但认识到 GNNs 也可以有效地应用于图形性质可能并不立即明显的问题至关重要。

因此,本节将帮助您识别数据中的模式和关系,即使这些关系并不立即明显,这些模式和关系也可能从基于图的建模中受益。本质上,识别 GNN 问题的有三个标准:隐含关系和相互依赖性;高维度和稀疏性;以及复杂的非局部交互。

1.4.1 隐含关系和相互依赖性

图是灵活的数据结构,可以模拟广泛的关系。即使问题最初看起来不是图形化的,即使你的数据集是表格的,探索是否存在可能明确表示的隐含关系或相互依赖性也是有益的。隐含关系是那些在数据中未立即记录或明显的连接,但仍然在理解潜在的模式和行为的理解中扮演着重要角色。

关键指标

要确定你的问题是否可能从使用图来建模隐含关系中获得益处,考虑一下在你的数据集中是否存在实体之间的隐藏或间接联系。例如,在客户行为分析中,客户可能看起来在包含他们的购买、人口统计和其他细节的表格数据集中是独立的实体。然而,他们可能通过社交媒体影响、同伴推荐或共享的购买模式相互连接,形成一个潜在的交互网络。

另一个指标是存在共享共同属性或活动但没有直接或记录关系的实体。例如,在投资者的情况下,两个或更多的投资者可能没有任何正式的联系,但在类似条件下可能会频繁共同投资于同一公司。这种共同投资模式可能表明共享策略或影响。在这种情况下,可以创建一个图表示,其中节点代表个别投资者,当两个或更多投资者共同投资于同一公司时,节点之间形成边。可以添加到节点或边上的附加属性,如投资规模、时间或投资的公司类型,从而使 GNNs 能够识别模式、趋势,甚至潜在的合作机会。

此外,考虑数据是否涉及通过共享引用或共现模式相互连接的实体。文档和文本数据可能不会立即表明图结构,但如果文档相互引用或共享共同的主题或作者,它们可以表示为图中的节点,边反映了这些关系。同样,文档中的术语可以形成共现网络,这对于诸如关键词提取、文档分类或主题建模等任务是有用的。

通过识别你数据中的这些关键指标,你可以揭示可以通过图明确表示的隐藏或隐含关系。这种表示允许使用 GNNs 进行更高级的分析,这些 GNNs 可以有效地捕捉和建模这些关系,从而实现更准确的预测并更深入地了解数据。

1.4.2 高维性和稀疏性

基于图模型在处理高维数据方面特别有效,在这些数据中,许多特征可能是稀疏或缺失的。这些模型在存在连接稀疏实体的潜在结构的情况下表现出色,这允许进行更有意义的分析并提高性能。

关键指标

要确定你的问题是否涉及适合 GNNs 的高维和稀疏数据,考虑你的数据集是否包含大量具有有限直接交互或关系的实体。例如,在推荐系统中,用户-项目交互数据可能看起来是表格形式的,但它是固有的稀疏的——大多数用户只与可用项目的一小部分进行交互。通过将用户和项目表示为节点,并将它们的交互(例如,购买或点击)表示为边,GNNs 可以利用网络效应来做出更准确的推荐。这些模型还可以通过揭示显性和隐性的关系来解决冷启动问题,从而在向用户推荐新项目或与现有项目吸引新用户方面表现出更好的性能。

另一个表明你的问题可能适合基于图模型的指标是,当数据表示的是稀疏连接但具有显著特征的实体时。例如,在药物发现中,分子被表示为图,原子作为节点,化学键作为边。这种表示捕捉了分子结构的固有稀疏性,其中大多数原子只形成少数键,分子的大部分部分在图中可能相距甚远。由于这种稀疏性,传统的机器学*方法往往难以预测新分子的属性,因为它们没有考虑到完整的结构背景。

基于图的模型,尤其是 GNNs,通过捕捉局部原子环境和全局分子结构来克服这些挑战。GNNs 从细粒度原子交互中学*层次特征,以更广泛的分子属性,并且它们对原子顺序的不变性确保了一致的预测。通过使用分子的图结构,GNNs 可以从稀疏、连接的数据中做出准确的预测,从而加速药物发现过程。

通过识别数据中的这些关键指标,你可以确定哪些情况下基于图模型可以有效地处理高维和稀疏数据集。将这些数据表示为图,允许 GNNs 捕捉和使用底层结构,从而在各种应用中实现更准确的预测和更深入的洞察。

1.4.3 复杂、非局部交互

某些问题需要理解数据集中遥远元素之间是如何相互影响的。在这些情况下,GNNs 提供了一个框架来捕捉这些复杂的交互,其中特定数据点的预测值或标签不仅取决于其直接邻居的特征,还取决于其他相关数据点的特征。这种能力在关系超越了直接连接,涉及多个层级或分离度时特别有用。

然而,一些主要依赖于局部消息传递的标准 GNNs 可能难以有效地捕捉长距离依赖。通过结合全局注意力、非局部聚合或分层消息传递等高级架构或修改,可以更好地解决这些挑战 [14]。

关键指标

要确定你的问题是否涉及适合 GNNs 的复杂、非局部交互,考虑一个实体的结果或行为是否依赖于与其没有直接连接但可能通过其他实体间接连接的实体的属性或行为。例如,在供应链优化中,一个供应商的延误不仅可能影响其直接下游客户,还可能通过网络的多个层级级联,影响分销商和最终消费者。

另一个指标是问题是否涉及信息、影响或效应随时间通过网络传播的场景。例如,在医疗保健和流行病学中,疾病爆发可能通过患者与共享医疗保健提供者、共同环境或重叠的社会网络的互动从一个小的患者群传播开来。这种传播需要一种能够捕捉信息或效应间接传播途径的方法。

在结束本节之前,在确定你的问题是否适合 GNN 时,请自问以下问题:

  • 我的数据中是否存在我可以建模的隐含关系或相互依赖?

  • 实体之间的交互是否表现出超越直接连接的复杂、非局部依赖?

  • 数据是否是高维且稀疏的,需要捕捉潜在的关联结构?

如果这些问题的答案中的任何一个为是,考虑将你的问题构建为图,并应用 GNN 来解锁新的见解和预测能力。

1.5 理解 GNN 的工作原理

在本节中,我们将探讨 GNN 的工作原理,从最初收集原始数据到最终部署训练好的模型。我们将检查每个步骤,突出数据处理、模型构建以及将 GNN 区别于传统深度学*模型的独特消息传递技术。

1.5.1 训练 GNN 的思维模型

我们的思维模型涵盖了数据来源、图表示、预处理和模型开发工作流程。我们从原始数据开始,最终得到一个训练好的 GNN 模型及其输出。图 1.14 说明了与这些阶段相关的话题,并标注了这些话题出现在哪些章节中。

图

图 1.14 GNN 项目的思维模型。我们从原始数据开始,将其转换为可以存储在图数据库中或用于图处理系统的图数据模型。从图处理系统(以及一些图数据库)中,可以进行探索性数据分析和可视化。最后,为了进行图机器学*,数据需要预处理成可以提交进行训练的形式。

虽然并非所有工作流程都包括这个过程的每个步骤或阶段,但大多数都会包含至少一些元素。在模型开发项目的不同阶段,通常将使用这个过程中的不同部分。例如,当训练模型时,可能需要进行数据分析可视化以做出设计决策,但当部署模型时,可能只需要流式传输原始数据并快速预处理它以便将其摄入模型。尽管本书涉及了这个思维模型中的早期阶段,但本书的大部分内容集中在如何训练不同类型的 GNN。当讨论其他主题时,它们服务于支持这个主要焦点。

思维模型展示了将 GNN 应用到机器学*问题中的核心任务,我们将在本书的其余部分反复回到这个过程。让我们从头到尾检查这个图。

训练 GNN 的第一步是将这些原始数据结构化为图格式,如果它还不是的话。这需要决定在数据中哪些实体应表示为节点和边,以及确定要分配给它们的特征。还必须做出关于数据存储的决定——是使用图数据库、处理系统还是其他格式。

对于机器学*,数据必须在训练和推理前进行预处理,包括采样、批处理以及将数据分割成训练集、验证集和测试集等任务。在这本书中,我们使用 PyTorch Geometric(PyG),它提供了用于预处理和数据分割的专用类,同时保留图的架构。预处理在大多数章节中都有涉及,更深入的解释可以在附录 B 中找到。

在处理完数据后,我们就可以继续进行模型训练。在这本书中,我们涵盖了几个架构和训练类型:

  • 第二章和第三章讨论了卷积 GNN,我们首先使用 GCN 层来生成图嵌入(第二章),然后在第三章中训练完整的 GCN 和 GraphSAGE 模型。

  • 第四章解释了图注意力网络(GATs),它为我们的 GNN 添加了注意力。

  • 第五章介绍了用于无监督和生成问题的 GNN,其中我们训练并使用变分图自动编码器(VGAE)。

  • 第六章接着探讨了时空 GNN 的高级概念,基于随时间演变的图。我们训练了一个神经关系推理(NRI)模型,该模型结合了自动编码器结构和循环神经网络。

到目前为止,提供的 GNN 示例大多数都是通过代码示例来展示的,这些示例使用的是可以适应笔记本电脑或台式计算机内存的小规模图。

  • 在第七章中,我们深入探讨了处理超出单机处理能力的数据的策略。

  • 在第八章中,我们总结了关于图和 GNN 项目的考虑因素,例如与图数据一起工作的实际方面,以及如何将非图数据转换为图格式。

1.5.2 GNN 模型独特的机制

尽管此时有各种各样的 GNN 架构,但它们都解决相同的问题,即以排列不变的方式处理图数据。它们通过在学*过程中对图结构进行编码和交换信息来实现这一点。

在传统的神经网络中,我们首先需要初始化一组参数和函数。这包括层数、层的大小、学*率、损失函数、批大小以及其他超参数。(这些在其他关于深度学*的书籍中都有详细讨论,所以我们假设你熟悉这些术语。)一旦我们定义了这些特性,我们就通过迭代更新网络的权重来训练我们的网络,如图 1.15 所示。

figure

图 1.15 训练 GNN 的过程,这与训练大多数其他深度学*模型类似

明确地说,我们执行以下步骤:

  1. 输入我们的数据。

  2. 将数据通过神经网络层传递,这些层根据层的参数和激活规则转换数据。

  3. 从网络的最后一层输出一个表示。

  4. 反向传播错误,并相应地调整参数。

  5. 重复这些步骤固定数量的epoch(数据正向和反向传递以训练神经网络的流程)。

对于表格数据,这些步骤与列表中列出的完全一致,如图 1.16 所示。对于基于图或关系的数据,这些步骤类似,但每个 epoch 对应于一次消息传递迭代,这将在下一小节中描述。

figure

图 1.16 比较了(简单的)非 GNN(上方)和 GNN(下方)。GNN 有一个层,它在其顶点之间分配数据。

1.5.3 消息传递

本书多次提到的消息传递是 GNN 中的一个核心机制,它使得节点能够在图中进行通信和共享信息[15]。这个过程允许 GNN 学*丰富的、信息丰富的图结构数据的表示,这对于节点分类、链接预测和图级预测等任务至关重要。图 1.17 说明了典型消息传递层中涉及的步骤。

figure

图 1.17 展示了我们的消息传递层的元素。每个消息传递层由聚合、转换和更新步骤组成。

消息传递过程从初始图的输入(步骤 1)开始,其中每个节点和边都有自己的特征。在收集步骤(步骤 2)中,每个节点从其直接邻居那里收集信息——这些信息被称为“消息”。这一步骤确保每个节点都能访问其邻居的特征,这对于理解局部图结构至关重要。

接下来,在聚合步骤(步骤 3)中,使用不变函数(如求和、平均值或最大值)将来自邻居节点的收集到的消息组合起来。这种聚合将来自节点邻域的信息合并成一个向量,捕捉其局部环境中最相关的细节。

在转换步骤(步骤 4)中,聚合的消息通过神经网络进行处理,为每个节点生成一个新的表示。这种转换允许 GNN 通过将非线性函数应用于聚合信息来学*图中的复杂交互和模式。

最后,在更新步骤(步骤 5)中,图中每个节点的特征被用这些新的表示替换或更新。这完成了一轮消息传递,结合了来自邻居节点的信息以细化每个节点的特征。

GNN 中的每一层消息传递层都允许节点从图中更远的节点或更多“跳数”的节点那里收集信息。通过在多层上重复这些步骤,GNN 能够捕捉到图中的更复杂依赖关系和长距离相互作用。

通过使用消息传递,图神经网络(GNNs)有效地将图结构和数据编码成对各种下游任务有用的表示。高级架构,如那些结合全局注意力或分层消息传递的架构,进一步增强了模型捕捉图中长距离依赖关系的能力,从而在多样化的应用中实现更稳健的性能。

摘要

  • 图神经网络(GNNs)是专门用于处理关系型或以关系为中心的数据的工具,尤其是在传统神经网络因图结构的复杂性和多样性而难以处理的情况下。

  • GNNs 在推荐引擎、药物发现和机械推理等领域找到了显著的应用,展示了它们在处理大型和复杂关系数据以增强洞察力和预测方面的多功能性。

  • 特定的 GNN 任务包括节点预测、边预测、图预测以及通过嵌入技术进行图表示。

  • 当数据以图的形式表示,表明对数据点之间关系和连接的强烈关注时,GNNs 的使用最为理想。它们不适用于关系信息不重要的单个、独立的数据条目。

  • 当决定 GNN 解决方案是否适合你的问题时,考虑具有隐含关系、高维度、稀疏性和复杂非局部交互等特征的案例。通过理解这些基本原理,从业者可以评估 GNNs 对其特定问题的适用性,有效地实施它们,并在实际应用中认识到它们的权衡和限制。

  • 消息传递是 GNNs 的核心机制,它使它们能够在图结构中编码和交换信息,从而允许进行有意义的节点、边和图级别预测。GNN 的每一层代表消息传递的一个步骤,使用各种聚合函数有效地组合消息,为机器学*任务提供洞察力和表示。

第二章:图嵌入

本章涵盖

  • 探索图嵌入及其重要性

  • 使用非 GNN 和 GNN 方法创建节点嵌入

  • 在半监督问题上比较节点嵌入

  • 深入探讨嵌入方法

图嵌入是图机器学*中的基本工具。它们将图复杂的结构——无论是整个图、单个节点还是边——转换成一个更易于管理的、低维度的空间。我们这样做是为了将复杂的数据集压缩成更容易处理的形式,同时不丢失其固有的模式和关系,这些信息将应用于图神经网络(GNN)或其他机器学*方法。

正如我们所学的,图封装了网络中的关系和交互,无论是社交网络、生物网络还是任何实体相互连接的系统。嵌入以紧凑的形式捕捉这些现实生活中的关系,促进了可视化、聚类或预测建模等任务。

有许多策略可以推导这些嵌入,每种都有其独特的方法和应用:从使用网络拓扑的经典图算法,到分解表示图的矩阵的线性代数技术,以及更高级的方法如 GNN [1]。GNN 之所以突出,是因为它们可以将嵌入过程直接集成到学*算法本身。

在传统的机器学*工作流程中,嵌入作为单独的步骤生成,在回归或分类等任务中作为降维技术。然而,GNN 将嵌入生成与模型的训练过程合并。随着网络通过其层处理输入,嵌入被精炼和更新,使得学*阶段和嵌入阶段不可分割。这意味着 GNN 在训练时间学*图数据的最大信息表示。

使用图嵌入可以显著提升你的数据科学和机器学*项目,尤其是在处理复杂网络数据时。通过在低维空间中捕捉图的本质,嵌入使得将各种其他机器学*技术应用于图数据成为可能,为分析和模型构建开辟了一个广阔的可能性世界。

在本章中,我们首先介绍图嵌入以及关于政治书籍购买图的案例研究。我们首先使用 Node2Vec(N2V)来建立一个非 GNN 方法的基线,引导您了解其实际应用。在第 2.2 节中,我们转向 GNN,提供基于 GNN 的嵌入的动手介绍,包括设置、预处理和可视化。第 2.3 节提供了 N2V 和 GNN 嵌入的比较分析,突出了它们的应用。本章随后通过讨论这些嵌入方法的理论方面结束,特别关注 N2V 背后的原理和 GNN 中的消息传递机制。本章所采用的过程在图 2.1 中得到了说明。

figure

图 2.1 第二章过程和目标总结

注意:本章的代码以笔记本形式存储在 GitHub 仓库中(mng.bz/qxnE)。本章的 Colab 链接和数据可以在同一位置访问。

2.1 使用 Node2Vec 创建嵌入

在许多领域,理解网络中的关系是一个核心任务,从社交网络分析到生物学和推荐系统。在本节中,我们将探讨如何使用受自然语言处理(NLP)中的 Word2Vec 启发的技术Node2Vec(N2V)来创建节点嵌入。N2V 通过模拟随机游走来捕捉图中节点的上下文,使我们能够在低维空间中理解节点之间的邻域关系。这种方法对于识别模式、聚类相似节点以及为机器学*任务准备数据是有效的。

为了使这个过程易于理解,我们将使用Node2Vec Python 库,它对初学者友好,尽管在大型图上可能较慢。N2V 有助于创建捕获节点之间结构关系的嵌入,然后我们可以将其可视化,以揭示关于图结构的见解。我们的工作流程涉及几个步骤:

  1. 加载数据并设置 N2V 参数。 我们首先加载数据,并使用特定的参数初始化 N2V 来控制随机游走,例如游走长度和每个节点的游走次数。

  2. 创建嵌入。 N2V 通过在图上执行随机游走来生成节点嵌入,有效地将每个节点的局部邻域总结为向量格式。

  3. 转换嵌入。 得到的嵌入被保存,然后转换为适合可视化的格式。

  4. 在二维中可视化嵌入。 我们使用 UMAP,一种降维技术,将这些嵌入投影到二维空间,使其更容易可视化和解释结果。

我们的数据是政论书籍数据集,它由 2004 年美国选举期间在 Amazon.com 上频繁共同购买的书籍(边)连接而成[3]。使用这个数据集提供了一个令人信服的例子,说明 N2V 如何揭示共同购买行为中的潜在模式,可能反映了书籍购买者中更广泛的意识形态分组[4]。表 2.1 提供了政论书籍图的关键信息。

表 2.1 政论书籍数据集概览
在 Amazon.com 上共同购买的政论书籍
节点数量(书籍) 105
左倾节点 41.0%
右倾节点 46.7%
中立节点 12.4%
边的数量 边代表两本书之间共同购买的发生频率。 441

政论书籍数据集包含以下内容:

  • 节点 — 代表由Amazon.com销售的关于美国政治的书籍。

  • — 指示相同买家频繁共同购买,如亚马逊的“购买此书的顾客也购买了这些其他书籍”功能所示。

在图 2.2 中,书籍根据其政治立场进行着色 — 深色代表自由主义,浅色代表保守主义,条纹代表中立。这些类别是通过 Mark Newman 对亚马逊上发布的书籍描述和评论的定性分析来分配的。

figure

图 2.2 政论书籍数据集的图形可视化。右倾书籍(节点)以浅色显示,并聚集在图的上半部分,左倾书籍以深色阴影显示,并聚集在图的下半部分,中立的政治立场以深色方块显示,并出现在中间。当两个节点相连时,表示它们在 Amazon.com 上经常一起购买。

这个数据集由 Valdis Krebs 编制,可通过 GNN in Action 仓库(mng.bz/qxnE)或卡内基梅隆大学网站(mng.bz/mG8M)获取,包含 105 本书(节点)和 441 条边(共同购买)。如果您想了解更多关于这个数据集的背景信息,Krebs 已经撰写了一篇文章包含这些信息[4]。

使用 N2V,我们旨在探索这本书集合的结构,基于政治倾向和不同书籍类别之间可能存在的关联来揭示洞察。通过可视化 N2V 创建的嵌入,我们可以更好地理解书籍是如何分组的,哪些书籍可能拥有共同的受众,为政治敏感时期的消费者行为提供有价值的见解。

从可视化中可以看出,数据已经按照逻辑方式进行了聚类。这得益于Kamada-Kawai 算法这一图算法,它仅利用拓扑数据而不使用元数据,因此对图形可视化很有用。这种图形可视化技术将节点定位在反映它们连接的位置,旨在使紧密连接的节点彼此靠*,而较少连接的节点则相隔较远。它是通过将节点视为由弹簧连接的点,迭代调整其位置,直到弹簧中的“张力”最小化来实现的。这导致布局自然地揭示了基于图形结构的集群和关系。

对于政治书籍数据集,Kamada-Kawai 算法帮助我们根据在亚马逊上共同购买的频率来可视化书籍(节点),而不使用任何外部信息,如政治立场或书籍标题。这使我们能够看到书籍是如何根据购买行为分组在一起的。在接下来的步骤中,我们将使用 N2V 等方法创建嵌入,以捕获更详细的模式和进一步区分不同的书籍组。

2.1.1 加载数据、设置参数和创建嵌入

我们使用Node2VecNetworkX库来体验我们的第一次图嵌入实践。通过使用 pip 安装这些包后,我们使用NetworkX库加载我们的数据集的图形数据,该数据存储在.gml 格式(图形建模语言,GML)中,并使用Node2Vec库生成嵌入。

GML 是一种简单、可读的纯文本文件格式,用于表示图结构。它以结构化的方式存储有关节点、边及其属性的信息,使其易于读取和写入图数据。例如,一个.gml 文件可能包含节点列表(例如,我们数据集中的书籍)和边(表示共同购买的联系),以及额外的属性,如标签或权重。这种格式在交换不同软件和工具之间的图数据方面得到了广泛应用。通过使用NetworkX加载.gml 文件,我们可以轻松地在 Python 中操作和分析图形。

Node2Vec库的Node2Vec函数中,我们可以使用以下参数来指定进行的计算和输出嵌入的性质:

  • 嵌入的大小(dimensions — 将其视为每个节点配置文件的详细程度,就像记录了多少种不同的特征一样。标准的详细程度是 128 个特征,但你可以根据你希望每个节点的配置文件有多复杂来调整这个值。

  • 每次遍历的长度(Walk Length — 这是指每次随机遍历你的图形有多远,通常的旅程是 80 步。如果你想看到节点周围更多的邻域,增加这个数字。

  • 每个节点的行走次数(Num Walks)—这告诉我们我们将从每个节点开始行走多少次。从 10 次行走开始可以提供一个良好的概述,但如果你想要更全面地了解节点的周围环境,可以考虑进行更多次行走。

  • 回溯控制(返回参数,p)—这个设置有助于决定我们的行走是否应该回到它曾经去过的地方。将其设置为 1 可以保持平衡,但调整它可以使你的行走更具或更少的探索性。

  • 探索深度(输入-输出参数,q)—这一部分是关于选择是接受更广泛的邻域场景(例如,当q大于 1 时的广度优先搜索)还是深入特定的路径(例如,当q小于 1 时的深度优先搜索),其中 1 是两者的混合。

根据你想了解的节点及其连接的情况调整这些设置。想要更深入的了解?调整探索深度。想要更广泛的上下文?调整行走长度和行走次数。此外,请注意,你的嵌入大小应该与所需的细节水平相匹配。一般来说,尝试这些参数的不同组合以查看对嵌入的影响是一个好主意。

对于这个练*,我们将使用前四个参数。关于这些参数的更详细信息可以在第 2.4 节中找到。

列表 2.1 中的代码首先使用NetworkX库的read_gml方法将图加载到名为books_graph的变量中。接下来,使用加载的图初始化一个 N2V 模型。此模型设置了特定的参数:它将为每个节点创建 64 维的嵌入,使用 30 步长的行走,从每个节点开始执行 200 次行走以收集上下文,并在四个工作器上并行运行这些操作以加快处理速度。

然后使用fit方法中定义的附加参数训练 N2V 模型。这包括设置每个目标节点周围 10 个节点的上下文窗口大小以学*嵌入,考虑所有节点至少一次(min_count=1),并在训练过程中每次处理四个单词(在这个上下文中是节点)。

训练完成后,我们使用modelwv方法(反映其 NLP 传承,wv 代表词向量)访问节点嵌入。对于我们的下游任务,我们使用字典推导式将每个节点映射到其嵌入。

列表 2.1 生成 N2V 嵌入
import NetworkX as nx
from Node2Vec import Node2Vec
books_graph = nx.read_gml('PATH_TO_GML_FILE')   #1
node2vec = Node2Vec(books_graph, dimensions=64,
 walk_length=30, num_walks=200, workers=4)   #2
model = node2vec.fit(window=10, min_count=1,\
batch_words=4)   #3
embeddings = {str(node): model.wv[str(node)]\
 for node in gml_graph.nodes()}   #4

1 从 GML 文件中加载图数据到 NetworkX 图对象

2 使用输入图的指定参数初始化 N2V 模型

3 训练 N2V 模型

4 从 N2V 模型中提取并存储生成的节点嵌入到字典中

2.1.2 揭秘嵌入

让我们探索这些嵌入是什么以及为什么它们有价值。嵌入是一个密集的数值向量,以捕捉节点、边或图的结构和关系的方式表示其身份。在我们的上下文中,由 N2V 创建的嵌入使用拓扑信息捕捉节点在图中的位置和邻域。这意味着它总结了节点如何与其他节点连接,有效地捕捉了其在网络中的角色和重要性。稍后,当我们使用 GNN 创建嵌入时,它们也将封装节点的特征,提供包含结构和属性的双重丰富表示。我们将在第 2.4 节中深入了解嵌入的理论方面。

这些嵌入非常强大,因为它们将复杂的高维图数据转换成固定大小的向量格式,可以轻松用于各种分析和机器学*任务。例如,它们允许我们通过揭示图中的模式、集群和关系来执行探索性数据分析。除此之外,嵌入可以直接用作机器学*模型中的特征,其中向量的每个维度代表一个独特的特征。这在理解数据点之间的结构和连接的应用中特别有用,例如在社会网络或推荐系统中,这可以显著提高模型性能。

为了说明这一点,考虑一下我们政治书籍数据集中代表书籍《丢失的本·拉登》的节点。使用命令model.wv['Losing Bin Laden'],我们可以检索其密集向量嵌入。这个向量如图 2.3 所示,捕捉了书籍在共同购买书籍网络中的各种角色,提供了一个紧凑、信息丰富的表示,可用于进一步分析或作为其他模型的输入。

figure

图 2.3 提取与政治书籍《丢失的本·拉登》相关的节点嵌入。输出是一个以 Python 列表表示的密集向量。

这些嵌入可用于探索性数据分析,以查看图中的模式和关系。然而,它们的用途更广泛。一个常见应用是将这些向量用作使用表格数据的机器学*问题中的特征。在这种情况下,我们嵌入数组中的每个元素将成为表格数据中一个独特的特征列。这可以为模型训练中的其他属性添加丰富的表示。在下一节中,我们将探讨如何可视化这些嵌入,以深入了解它们所代表的模式和关系。

2.1.3 转换和可视化嵌入

如统一流形*似和投影(UMAP)之类的可视化方法是将高维数据集降低到低维空间的有力工具[5]。UMAP 特别有效于识别内在集群和可视化在高度数据中难以感知的复杂结构。与其他方法,如 t-SNE 相比,UMAP 在保留局部和全局结构方面表现出色,使其成为揭示数据中不同尺度上的模式和关系的理想选择。

当 N2V 通过捕获我们的数据网络结构生成嵌入时,UMAP 将这些高维嵌入映射到低维空间(通常是两到三个维度)。这种映射旨在使相似的节点彼此靠*,同时保留更广泛的结构关系,从而提供对图拓扑的更全面可视化。在获得我们的 N2V 嵌入并将它们转换为数值数组后,我们使用两个组件初始化 UMAP 模型,以将我们的数据投影到二维平面上。通过仔细选择参数,如邻居数量和最小距离,UMAP 可以在揭示细粒度局部关系和保持集群之间的全局距离之间取得平衡。

通过使用 UMAP,我们获得了更准确和可解释的图嵌入可视化,如下所示列表,使我们能够比使用 t-SNE 等传统方法更有效地探索和分析模式、集群和结构。

列表 2.2 使用 UMAP 可视化嵌入
node_embeddings = [embeddings[str(node)] \
for node in gml_graph.nodes()]   #1
node_embeddings_array = np.array(node_embeddings)  

umap_model = umap.UMAP(n_neighbors=15, min_dist=0.1, n_components=2, \
random_state=42)
umap_features = umap_model.fit_transform\
(node_embeddings_array)  #2

plt.scatter(umap_features[:, 0], \
umap_features[:, 1], color=node_colors, alpha=0.7)  #3

1 将嵌入转换为 UMAP 的向量列表

2 初始化和拟合 UMAP

3 使用 UMAP 嵌入绘制节点并按其值着色

结果图 2.4 封装了由 N2V 提炼并由 UMAP 可视化的政治书籍图嵌入。节点根据其政治立场以不同的阴影出现。可视化展开了一个可识别的结构,其中潜在的集群对应于各种政治倾向。

figure

图 2.4 展示了由 N2V 生成并通过 UMAP 可视化的政治书籍数据集图嵌入。形状和阴影的变化区分了三个政治类别。

你可能会想知道为什么我们不直接将 N2V 嵌入的维度从64降低到2并直接可视化它们,完全绕过 UMAP?在列表 2.3 中,我们展示了这种方法,直接将 2D N2V 转换应用于我们的books_graph对象。(有关这些方法的更多技术细节和理论,请参阅第 2.4 节。)

dimensions参数设置为2,旨在实现直接的二维表示,适合立即可视化,无需进一步降维。其他参数保持不变。

一旦模型根据指定的窗口和词批次设置拟合,我们就提取 2D 嵌入并将它们存储在一个字典中,该字典以每个节点的字符串表示为键。这使得可以直接从节点映射到其嵌入向量。

提取的 2D 点被编译成一个 NumPy 数组并绘制。我们使用标准的Matplotlib库创建这些点的散点图,并使用准备好的颜色方案来直观地表示每个节点的政治倾向。

列表 2.3 无 t-SNE 可视化 2D N2V 嵌入
node2vec = Node2Vec(gml_graph, dimensions=2, \
walk_length=30, num_walks=200, workers=4)   #1
model = node2vec.fit(window=10, min_count=1,\
 batch_words=4)   #2

embeddings_2d = {str(node): model.wv[str(node)] \
for node in gml_graph.nodes()}   #3

points = np.array([embeddings_2d[node] \
for node in gml_graph.nodes()])   #4

plt.scatter(points[:, 0], points[:, 1], \
color=node_colors, alpha=0.7)   #5

1 初始化 N2V,使用 2D 嵌入进行可视化

2 使用指定的窗口和行走设置训练 N2V 模型

3 将节点映射到它们的 2D 嵌入

4 为每个节点的嵌入形成 2D 点数组

5 使用指定的节点颜色绘制 2D 嵌入

结果显示了书籍如何根据政治倾向被分开,类似于 UMAP 的结果,但书籍更加密集地聚集在一起(见图 2.5)。然后,这两个嵌入在图 2.6 中展示。

figure

图 2.5 由 N2V 生成并可视化的政治书籍数据集图嵌入,用于两个维度。形状和阴影变化区分了三个政治类别。在这里,我们看到与图 2.4 中类似的按政治倾向的聚类,但更加密集。

figure

图 2.6 N2V 和 t-SNE 生成的嵌入比较以及 2D Node2Vec 的直接可视化

很明显,这两种方法都知道根据政治倾向将书籍分成组。N2V 在分离书籍方面表达性较弱,将它们在两个维度上聚集在一起。同时,UMAP 更适合在两个维度上分散书籍。这些维度中包含的相关好处或信息取决于手头的任务。

2.1.4 超越可视化:N2V 嵌入的应用和考虑

虽然可视化 N2V 嵌入可以为数据集的结构提供直观的见解,但其用途远不止图形表示。N2V 是一种专门为图设计的嵌入方法;它通过在图中模拟随机游走来捕捉节点的局部和全局结构属性。这个过程允许 N2V 创建密集的数值向量,这些向量总结了每个节点在整个网络中的位置和上下文。

这些嵌入可以作为丰富特征输入用于各种机器学*任务,如分类、推荐或聚类。例如,在我们的政治书籍数据集中,嵌入可以帮助根据书籍的共购买模式预测书籍的政治倾向,或者向具有相似政治兴趣的用户推荐书籍。它们甚至可以用来根据书籍的内容预测未来的销售。

然而,了解 N2V 学*方法的本质,即它是归纳的,是很重要的。归纳学*旨在仅与训练数据集一起工作,并且不能在没有重新训练模型的情况下泛化到新的、未见的节点。这一特性使得 N2V 在所有节点和边都事先已知的情况下非常有效,但在新数据点或连接频繁出现的动态环境中则不太适用。本质上,N2V 专注于从现有图中提取详细模式和关系,而不是开发能够轻松适应新数据的模型。

虽然这种归纳性质有其局限性,但它也提供了显著的优势。因为 N2V 在训练过程中使用了图的完整结构,它可以捕捉到可能被更通用方法遗漏的复杂关系和依赖。这使得 N2V 在数据完整、固定结构已知且稳定的情况下特别强大。然而,为了有效地应用 N2V,至关重要的是要确保图数据以能够捕捉所有相关特征的方式表示。在某些情况下,可能需要向图中添加额外的边或节点,以完全表示潜在的关系。

对于那些对归纳模型有更深入理解以及 N2V 方法与其他方法比较感兴趣的人来说,2.4.2 节提供了更多细节。该节将进一步探讨归纳学*和归纳学*之间的权衡[6, 7],帮助你理解何时采用每种方法最为合适。

虽然 N2V 在生成能够捕捉固定图结构的嵌入方面是有效的,但现实世界的数据通常需要更灵活和可泛化的方法。这种需求将我们引向了第一个用于创建节点嵌入的 GNN 架构。与仅限于训练数据中特定节点和边的归纳方法 N2V 不同,GNNs 可以以归纳的方式学*。这意味着 GNNs 能够泛化到新的、未见的节点或边,而无需在整个图上重新训练。

GNNs 通过不仅理解网络的复杂结构,还将节点特征和关系纳入学*过程来实现这一点。这种方法允许 GNNs 动态地适应图中的变化,使它们非常适合数据持续演变的应用。从 N2V 到 GNNs 的转变代表了从关注静态数据集内的深度分析到更广泛适用于各种演变网络的适用性的关键过渡。这种适应性为需要灵活性和可扩展性的更广泛的基于图机器学*应用奠定了基础。在下一节中,我们将探讨 GNNs 如何超越 N2V 和其他归纳方法的能力,允许构建更灵活、更强大的模型,以处理现实世界数据的动态特性。

2.2 使用 GNN 创建嵌入

尽管 N2V 提供了一种通过捕捉图的本地和全局结构来生成嵌入的强大方法,但它的本质上是归纳方法,这意味着它不能轻易地泛化到未见过的节点或边,除非重新训练。尽管 N2V 的扩展使其能够在归纳设置中工作,但 GNNs 本质上是为归纳学*设计的。这意味着它们可以从图数据中学*一般模式,从而允许它们做出预测或为新节点或边生成嵌入,而无需重新训练整个模型。这使 GNNs 在灵活性和适应性至关重要的场景中具有显著优势。

GNNs 不仅结合了图的结构信息,如 N2V,而且它们还使用节点特征来创建更丰富的表示。这种双重能力使得 GNNs 能够学*图内复杂的相互关系以及单个节点的特定特征,使它们在需要这两种类型信息的重要任务中表现出色。

虽然 GNNs 在许多应用中展示了令人印象深刻的性能,但它们并不在所有情况下都优于 N2V 等方法。例如,N2V 和其他基于随机游走的方法有时在标记数据稀缺或噪声的情景中表现更好,这得益于它们仅通过图结构就能工作,而不需要额外的节点特征。

2.2.1 构建嵌入

与 N2V 不同,GNNs 在训练期间同时学*图表示和执行节点分类或链接预测等任务。整个图的信息通过连续的 GNN 层进行处理,每一层都细化节点嵌入,而不需要为它们的创建单独的步骤。

为了展示 GNN 如何从图数据中提取特征,我们将使用一个未训练的模型进行简单的传递,以生成初步嵌入。即使没有通常涉及训练的优化,这种方法也将展示 GNNs 如何使用消息传递(在 2.4.4 节中进一步探讨)来更新嵌入,捕捉图的结构和节点特征。当添加优化时,这些嵌入将针对特定任务进行调整,如节点分类或链接预测。

定义我们的 GNN 架构

我们通过定义一个简单的 GCN 架构来启动我们的过程,如列表 2.4 所示。我们的SimpleGNN类继承自torch.nn.Module,由两个GCNConv层组成,这是我们的 GNN 的构建块。这种架构如图 2.7 所示,包括第一层、一个消息传递层(self.conv1)、一个激活(torch.relu)、一个 dropout 层(torch.dropout)和第二个消息传递层。

列表 2.4 我们的SimpleGNN
class SimpleGNN_embeddings(torch.nn.Module):
    def __init__(self, num_features, hidden_channels):   #1
        super(SimpleGNN, self).__init__()
        self.conv1 = GCNConv(num_features, \
hidden_channels)   #2
        self.conv2 = GCNConv(hidden_channels,\
 hidden_channels)  #3

    def forward(self, x, edge_index):   #4
        x = self.conv1(x, edge_index)  #5
        x = torch.relu(x)   #6
        x = torch.dropout(x, p=0.5, train=self.training)   #7
        x = self.conv2(x, edge_index)    #8
        return x   #9

1 初始化 GNN 类,包括输入和隐藏层大小

2 从输入特征到隐藏通道的第一个 GCN 层

3 隐藏空间中的第二个 GCN 层

4 前向传递函数定义数据流

5 第一个 GCN 层处理

6 非线性激活函数

7 训练过程中的 Dropout 正则化

8 第二个 GCN 层处理

9 返回最终的节点嵌入

figure

图 2.7 SimpleGNN模型架构图

让我们谈谈 GNNs 特有的架构方面。激活和 Dropout 在许多深度学*场景中都很常见。然而,GNN 层在本质上与传统的深度学*层不同。允许 GNNs 从图数据中学*的核心原理是消息传递。对于每个 GNN 层,除了更新层的权重外,还会从每个节点或边邻域收集一个“消息”,并用于更新一个嵌入。本质上,每个节点向其邻居发送消息,并同时从他们那里接收消息。对于每个节点,其新的嵌入是通过结合自己的特征和从邻居那里聚合的消息,通过非线性变换的组合来计算的。

在这个例子中,我们将使用图卷积网络(GCN)作为我们的消息传递 GNN 层。我们在第三章中对 GCNs 进行了更详细的描述。现在,你只需要知道 GCNs 作为消息传递层,对于构建嵌入至关重要。

数据准备

接下来,我们准备我们的数据。我们将从上一节中的相同图表开始,即books_gml,以它的NetworkX形式。我们必须将这个NetworkX对象转换成适合与 PyTorch 操作使用的张量形式。因为 PyTorch Geometric (PyG)有许多将图对象转换的功能,我们可以通过data = from_NetworkX(gml_graph)这一步相当简单地完成。from_NetworkX方法特别将边列表和节点/边属性转换成 PyTorch 张量。

对于 GNNs,生成节点嵌入需要初始化节点特征。在我们的例子中,我们没有任何预定义的节点特征。当没有节点特征可用或它们不具信息性时,通常的做法是随机初始化节点特征。一种更有效的方法是使用Xavier 初始化,该方法通过从保持激活多样性一致性的分布中抽取值来设置初始节点特征。这项技术确保模型从平衡的表示开始,防止梯度消失或爆炸等问题。

通过使用 Xavier 初始化 data.x,我们为 GNN 提供了一个起点,使其能够从非信息性特征中学*有意义的节点嵌入。在训练过程中,网络调整这些初始值以最小化损失函数。当损失函数与特定目标(如节点预测)对齐时,从初始随机特征学*到的嵌入将针对当前任务进行调整,从而产生更有效的表示。我们使用以下方法随机化节点特征:

data.x = torch.randn((data.num_nodes, 64), dtype=torch.float)
'nn.init.xavier_uniform_(data.x) '

我们还可以使用 N2V 练*中的嵌入作为节点特征。回想一下 2.1.3 节中的 node_embeddings 对象:

node_embeddings = [embeddings[str(node)] for node in gml_graph.nodes()]

从这个结果中,我们可以将节点嵌入转换为 PyTorch 张量对象,并将其分配给节点特征对象,data.x

node_features = torch.tensor(node_embeddings, dtype=torch.float) 
data.x = node_features

将图通过 GNN 传递

在我们的 GNN 模型结构定义和图数据格式化为 PyG 后,我们进入嵌入生成步骤。我们初始化我们的模型,SimpleGNN,指定每个节点的特征数量和网络中隐藏通道的大小。

model = SimpleGNN(num_features=data.x.shape[1], hidden_channels=64)

在这里,我们指定了 64 个隐藏通道,因为我们想将生成的嵌入与使用 node2vec 方法生成的嵌入进行比较,后者有 64 个维度。由于第二个 GNN 层是最后一层,输出将是一个 64 元素的向量。

初始化完成后,我们使用 model.eval() 将模型切换到评估模式。在推理或验证阶段,当我们想要做出预测或评估模型性能而不修改模型参数时,使用此模式。具体来说,model.eval() 关闭了特定于训练的某些行为,例如 dropout,它随机停用一些神经元以防止过拟合,以及 批量归一化,它对 mini-batch 中的输入进行归一化。通过禁用这些功能,模型提供一致且确定性的输出,确保评估能够准确反映其在未见数据上的真实性能。

禁用梯度计算很重要,因为在正向传递和嵌入生成过程中它们不是必需的。因此,我们使用 torch.no_grad(),这确保了记录反向传播操作的计算图不会被构建,从而防止我们意外地改变性能。

接下来,我们将节点特征矩阵(data.x)和边索引(data.edge_index)通过模型传递。结果是 gnn_embeddings,一个张量,其中每一行对应于我们图中节点的嵌入——这是我们的 GNN 学*到的数值表示,为下游任务(如可视化或分类)做好准备:

model.eval()
with torch.no_grad():
    gnn_embeddings = model(data.x, data.edge_index)

在生成这些嵌入后,我们使用 UMAP 来可视化它们,就像我们在 2.1.3 节中做的那样。由于我们一直在使用在 GPU 上运行的 PyTorch 张量数据类型,我们需要将我们的嵌入转换为 NumPy 数组数据类型,以便使用 PyTorch 之外的 CPU 上的分析方法:

gnn_embeddings_np = gnn_embeddings.detach().cpu().numpy()

通过这种转换,我们可以按照在 N2V 案例中使用的流程生成 UMAP 计算和可视化。得到的散点图(图 2.8)是我们图中集群的第一印象。我们根据每个节点的标签(左倾、右倾或中立)添加不同的阴影,以显示相似倾向的书籍被相当好地分组,因为这些嵌入仅从拓扑结构构建。

接下来,让我们讨论 GNN 嵌入的使用方式以及它们与 N2V 生成的嵌入的不同之处。

figure

图 2.8 通过 GNN 传递图生成的嵌入可视化

2.2.2 GNN 与 N2V 嵌入的比较

在整本书中,我们主要使用 GNN 来生成嵌入,因为嵌入过程是 GNN 架构固有的。虽然嵌入在我们探索的方法和应用的其余部分中扮演着关键角色,但它们的呈现往往是微妙的,并不总是被强调。这种方法使我们能够专注于 GNN 机器学*的更广泛概念和应用,而不会因为技术细节而减慢速度。尽管如此,承认嵌入的潜在力量和适应性是我们全文中获得的先进技术和洞察力的核心。

GNN 生成的节点嵌入特别强大,因为它们能够通过其归纳性质,使我们能够通过使用它们来解决广泛的图相关任务。归纳学*使得这些嵌入能够推广到新的、未见的节点,甚至完全新的图,而无需重新训练模型。相比之下,N2V 嵌入仅限于它们被训练的特定图,并且难以适应新数据。让我们重申 GNN 嵌入与其他嵌入方法(如 N2V [1, 3])不同的关键方式。

适应新图的能力

GNN 嵌入的一个关键特性是其适应性。因为 GNN 学*一个将节点特征映射到嵌入的函数,所以这个函数可以应用于新图中的节点,而无需重新训练,前提是节点具有相似的特征空间。这种归纳能力在图可能随时间演变或模型需要应用于不同但结构相似的图的应用中特别有价值。另一方面,N2V 需要为每个新的图或节点集重新应用。

增强的特征集成

GNNs(图神经网络)在嵌入过程中天生考虑节点特征,这使得每个节点都能有复杂和细腻的表现。这种节点特征与结构信息的结合,比 N2V 和其他仅关注图拓扑的方法提供了更全面的视角。这种能力使得 GNN 嵌入特别适合于节点特征包含大量额外信息的任务。

任务特定优化

GNN 嵌入与特定任务(如节点分类、链接预测甚至图分类)一起训练。通过端到端训练,GNN 模型学会优化嵌入以适应当前任务,与使用如 N2V 生成的预生成嵌入相比,可能带来更高的性能和效率。

话虽如此,虽然 GNN 嵌入在适应性和对新数据的适用性方面具有明显优势,但 N2V 嵌入也有其优势,尤其是在捕捉特定图结构中的细微模式方面。在实践中,GNN 和 N2V 嵌入之间的选择可能取决于任务的特定要求、图数据的性质以及计算环境的限制。

对于那些图结构静态且定义明确的任务,N2V 可能提供了一种更简单且计算效率更高的解决方案。相反,对于动态图、大规模应用或需要结合节点特征的场景,GNN 通常会是更稳健和通用的选择。此外,当任务本身定义不明确且工作具有探索性时,N2V 可能更快且更容易使用。

我们现在已经成功构建了第一个 GNN 嵌入。这是所有 GNN 模型的关键第一步,从这一点开始,所有后续步骤都将基于它。在下一节中,我们将给出一些后续步骤的示例,并展示如何使用嵌入来解决机器学*问题。

2.3 使用节点嵌入

半监督学*,涉及标记数据和未标记数据的组合,为比较不同的嵌入技术提供了宝贵的机会。在本章中,我们将探讨如何使用 GNN 和 N2V 嵌入来预测标签,当大多数数据缺乏标签时。

我们的任务涉及政治书籍数据集(books_graph),其中节点代表政治书籍,边表示共同购买关系。为了使过程更清晰,让我们回顾迄今为止采取的步骤,并概述我们的下一步计划,如图 2.9 所示。

figure

图 2.9 第二章采取的步骤概述

我们从books_graph数据集开始,以图格式进行轻量级预处理,为嵌入准备数据。对于 N2V,这包括将数据集从.gml 文件转换为NetworkX格式。对于基于 GNN 的嵌入,我们将NetworkX图转换为 PyTorch 张量,并使用 Xavier 初始化来初始化节点特征,以确保层间变异性平衡。

在准备数据后,我们使用 N2V 和 GCN 生成了嵌入。现在,在本节中,我们将将这些嵌入应用于半监督分类问题。这涉及到进一步处理以定义分类任务,其中仅保留了 20%的书籍标签,模拟了一个具有稀疏标记数据的现实场景。

我们将使用两组嵌入(N2V 和 GCN)和两种不同的分类器:一个随机森林分类器(将嵌入用作表格特征)和一个 GCN 分类器(使用图结构和节点特征)。目标是预测书籍的政治倾向,其余 80%的标签基于给定的嵌入进行推断。

2.3.1 数据预处理

首先,我们对books_gml数据集进行一些额外的预处理(见列表 2.5)。我们必须以适合学*过程的方式格式化标签。因为所有节点都有标签,我们还必须通过随机选择节点来设置半监督问题,从这些节点中隐藏标签。

与属性'c'关联的节点被分类为'right',而与'l'关联的节点被分类为'left'。不符合这些标准的节点,包括具有中性或未指定属性的节点,被归类为'neutral'。然后,将这些分类放入一个 NumPy 数组labels中,以便于优化的计算处理。

然后,创建了一个名为indices的数组,表示数据集中所有节点的位置索引。这些索引的一个子集,对应于总节点数的 20%,被指定为我们标记的数据。

为了管理标记和未标记的数据,初始化并填充了布尔掩码labelled_maskunlabelled_masklabelled_mask对于选为标记的索引设置为True;这些是相应节点的真实标签。同样,unlabelled_mask设置为False。这些掩码将数据集分割为训练和评估部分,确保算法在正确的数据子集上正确训练和验证。

列表 2.5 半监督问题的预处理
labels = []
for node, data in gml_graph.nodes(data=True):   #1
    if data['value'] == 'c':
        labels.append('right')
    elif data['value'] == 'l':
        labels.append('left')
    else:  
        labels.append('neutral')
labels = np.array(labels)

random.seed(52)   #2

indices = list(range(len(labels)))   #3

labelled_percentage = 0.2    #4

labelled_indices = random.sample(indices, \
int(labelled_percentage * len(labels)))   #5

labelled_mask = np.zeros(len(labels), dtype=bool)   #6
unlabelled_mask = np.ones(len(labels), dtype=bool)

labelled_mask[labelled_indices] = True   #7
unlabelled_mask[labelled_indices] = False

labelled_labels = labels[labelled_mask]   #8
unlabelled_labels = labels[unlabelled_mask]

label_mapping = {'left': 0, 'right': 1, 'neutral': 2}   #9
numeric_labels = np.array([label_mapping[label] for label in labels])

1 提取标签并处理中性值

2 用于可重复性的随机种子

3 所有节点的索引

4 保留 20%的数据作为标记数据

5 选择保留标记的索引子集

6 初始化标签数据和未标记数据的掩码

7 更新掩码

8 使用掩码分割数据集

9 将标签转换为数值形式

现在我们对模型训练数据进行转换,如列表 2.6 所示。对于由 GNN 生成的嵌入,X_train_gnny_train_gnn被分配了嵌入数组和相应的数字标签,这些标签通过一个labelled_mask进行过滤。这个掩码是一个布尔数组,指示图中哪些节点是标签子集的一部分,确保只有具有已知标签的数据点包含在训练集中。

对于 N2V 嵌入,采用类似的方法,并添加预处理步骤以对齐嵌入与其对应的标签。每个节点的嵌入按books_graph中节点出现的顺序聚合到 NumPy 数组X_n2v中。这确保了嵌入与其标签之间的一致性,对于监督学*任务是一个关键步骤。随后,X_train_n2vy_train_n2v被填充了 N2V 嵌入和标签,再次应用labelled_mask来过滤标记数据点。

列表 2.6 预处理:构建训练数据
X_train_gnn = gnn_embeddings[labelled_mask]   #1
Y_train_gnn = numeric_labels[labelled_mask]  

X_n2v = np.array([embeddings[str(node)] \
for node in gml_graph.nodes()])  #2
X_train_n2v = X_n2v[labelled_mask]              #3
y_train_n2v = numeric_labels[labelled_mask]     #3

1 用于 GNN 嵌入

2 用于 N2V 嵌入

3 确保 N2V 嵌入与标签顺序相同

对于 N2V 嵌入,不需要额外的对齐步骤,因为 GNN 模型在以结构化方式处理整个图时,内在地维护了节点的顺序。因此,GNN 的输出嵌入自然地按照输入图的节点顺序排序。

相比之下,N2V 通过从每个节点开始的独立随机游走来生成嵌入,生成的嵌入的顺序不一定与原始图数据结构中节点的顺序相匹配。因此,需要一个显式的对齐步骤来确保每个 N2V 嵌入与其从图中提取的对应标签正确关联。这一步骤对于监督学*任务至关重要,在这些任务中,将特征(嵌入)与标签的正确匹配对于模型训练和评估至关重要。对于这个任务,我们使用属性index_to_key,它包含节点标识符,按照它们在模型中处理和存储的顺序排列。

2.3.2 随机森林分类

在我们的数据准备就绪后,我们使用第 2.1 节和第 2.2 节中的 GNN 和 N2V 嵌入作为RandomForestClassifier的输入特征,如列表 2.7 所示。

列表 2.7 预处理:构建训练数据
clf_gnn = RandomForestClassifier()   #1
clf_gnn.fit(X_train_gnn, y_train_gnn)

clf_n2v = RandomForestClassifier()  #2
clf_n2v.fit(X_train_n2v, y_train_n2v)

1 GNN 嵌入的分类器

2 N2V 嵌入的分类器

这种方法使我们能够直接比较嵌入的预测能力,如表 2.2 所示。

表 2.2 分类性能
嵌入类型 准确率 F1 分数
GNN 83.33% 82.01%
N2V 84.52% 80.72%

对于这个基本的分类练*,我们将使用两个基本指标来评估我们模型的性能:

  • 准确率—这个指标衡量模型在所有预测中正确预测的比例。它提供了一个直接的评估,即分类器正确识别书籍政治倾向的频率。例如,准确率为 84.52%意味着模型在大约 100 次中有 85 次正确预测了书籍的倾向。

  • F1 分数—这是一个更细微的指标,它平衡了精确度和召回率,在数据不平衡的情况下尤其有用——这意味着类别并不均匀地表示。它提供了精确度(真实正预测数除以总正预测数)和召回率(真实正预测数除以实际正总数)的调和平均值。更高的 F1 分数表明模型在正确识别不同类别的存在和不存在方面表现出稳健的性能,同时最小化错误正例和错误负例。

性能指标显示,当在RandomForestClassifier中使用时,N2V 嵌入产生了略高的准确率 84.52%,而 GNN 嵌入为 83.33%。然而,GNN 嵌入实现了略好的 F1 分数 82.01%,而 N2V 嵌入为 80.72%。这种细微的差异强调了两种嵌入类型之间的潜在权衡:虽然 N2V 提供了略好的整体预测准确率,但 GNN 嵌入可能在多数和少数类别上提供更平衡的性能。

通常,GNN 的归纳性质为学*不同大小图的网络节点表示提供了一个稳健的框架。即使在较小的图上,GNN 也能有效地学*节点之间的底层模式和交互,这从更高的 F1 分数中可以看出,这表明在分类任务中精确度和召回率之间的平衡更好。

在这个背景下,GNN 和 N2V 嵌入的选择也可能取决于分析的具体目标和最感兴趣的性能指标。如果优先考虑实现尽可能高的准确率,并且数据集不太可能显著扩展,那么 N2V 可能是更合适的选择。相反,如果任务重视精确度和召回率之间的平衡,并且有将学*到的模型应用于类似但新的图的可能性,那么 GNN 提供了宝贵的灵活性和稳健性,即使对于较小的数据集也是如此。在将 N2V 和 GNN 嵌入用作随机森林模型的输入之后,接下来让我们研究当我们将它们用作全端到端 GNN 模型的输入时会发生什么。

2.3.3 全端到端模型中的嵌入

在上一节中,我们使用了 GNN 和 N2V 嵌入作为静态输入到传统的机器学*模型中,即随机森林分类器。在这里,我们使用一个端到端的 GNN 模型来解决相同的标签预测问题。通过“端到端”,我们指的是在预测标签的同时生成嵌入。这意味着这里的嵌入不会是静态的,因为随着 GNN 的学*,它将更新节点嵌入。

为了构建这个模型,我们将使用之前相同的工具——books_gml 数据集和 SimpleGNN 架构。我们将稍微修改 GNN,通过在末尾添加 log softmax 激活,以方便对三标签分类问题的输出。我们还将稍微修改 SimpleGNN 类的输出,使我们能够观察到嵌入以及预测输出。我们的过程包括以下内容:

  • 数据准备

  • 模型/架构修改

  • 建立训练循环

  • 研究性能

  • 研究嵌入预训练和训练后

数据准备

假设我们使用 books_gml 数据集,将其转换为 PyG 框架内使用的进程保持不变。我们将训练两种版本的数据:一种使用随机初始化的节点特征,另一种使用 N2V 嵌入的节点特征。

模型修改

我们使用相同的 SimpleGNN 类及其修改。首先,在这个 SimpleGNN 类的增强版本中,我们扩展了其功能,为每个节点提供预测输出。这是通过将第二个 GCN 层产生的嵌入应用 log softmax 激活来实现的。log softmax 输出为每个节点的潜在类别提供了一个归一化的对数概率分布,用于分类任务。

第二,我们引入双重输出。该方法返回两个值:来自 conv2 层的原始嵌入,它捕获节点表示,以及这些嵌入的 log softmax。为了观察嵌入和预测,我们将 forward 方法返回两者。除了这个双层模型之外,我们还添加了两个层到这个架构中,以形成一个四层模型进行比较,如列表 2.8 所示。

列表 2.8 预处理:构建训练数据
class SimpleGNN_inference(torch.nn.Module):
    def __init__(self, num_features, hidden_channels):
        super(SimpleGNN, self).__init__()
        self.conv1 = GCNConv(num_features, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, hidden_channels)

    def forward(self, x, edge_index):
        # First Graph Convolutional layer
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, training=self.training)

        # Second Graph Convolutional layer
        x = self.conv2(x, edge_index)
        predictions = F.log_softmax(x, dim=1)    #1

        return x, predictions   #2

1 通过将最终卷积层通过 log softmax 预测类别

2 该类返回最后一个嵌入和预测。

建立训练循环

我们在半监督学*环境中为 GNN 模型编写训练循环,如列表 2.9 所示。这个循环遍历指定数量的 epoch,其中 epoch 代表整个训练数据集的一次完整遍历。在每个 epoch 中,模型的参数被更新以最小化损失函数,该函数量化了预测输出与训练集中节点实际标签之间的差异。对于那些熟悉深度学*训练循环编程的人来说,这应该非常熟悉。对于那些需要快速提醒的人来说,以下描述了初始化和运行训练循环的一些关键步骤:

  • 优化器初始化—当创建优化器时,它会使用一个特定的学*率进行初始化。例如,在这里我们使用 Adam 优化器,初始学*率为 0.01。

  • 归零梯度optimizer.zero_grad() 确保在每次更新之前重置梯度,防止它们在 epoch 之间累积。

  • 模型前向传播—模型处理节点特征(data.x)和图结构(data.edge_index)以产生输出预测。在半监督设置中,并非所有节点都有标签,因此模型的输出包括对标记节点和无标记节点的预测。

  • 应用训练掩码out_masked = out[data.train_mask] 对模型的输出应用掩码,以仅选择对应于标记节点的预测。这在半监督学*中至关重要,因为在半监督学*中,只有一部分节点具有已知的标签。

  • 损失计算和反向传播—损失函数 loss_fn 将选定的预测(out_masked)与标记节点的真实标签(train_labels)进行比较。loss.backward() 调用计算损失函数相对于模型参数的梯度,然后通过 optimizer.step() 更新这些参数。

  • 日志记录—训练循环在固定间隔(在这种情况下为每 10 轮)打印损失,以监控训练进度。

列表 2.9 训练循环
for epoch in range(3000):    #1
    optimizer.zero_grad()

    _, out = model(data.x, data.edge_index)   #2

    out_masked = out[data.train_mask]   #3

    loss = loss_fn(out_masked, train_labels)   #4
    loss.backward()
    optimizer.step()

    if epoch % 10 == 0:    #5
        print(f'Epoch {epoch}, Log Loss: {loss.item()}')

1 训练轮数

2 将节点特征和 edge_index 传递给模型

3 应用训练掩码以仅选择标记节点的输出

4 仅使用标记节点计算损失

5 每 10 轮打印一次损失

此过程迭代地细化模型的参数,以提高其在数据集标记部分上的预测能力,目标是学*一个能够很好地泛化到无标记节点,甚至可能到新的、未见过的数据的模型。

GNN 结果:随机化与 N2V 节点特征

让我们比较分类任务,比较随机节点特征与 N2V 节点特征的 GNN 性能,如表 2.3 所示。

表 2.3 使用不同节点特征的 GNN 模型的分类性能
模型 GNN 准确率 GNN F1 分数
双层,随机化特征 82.27% 82.14%
双层,N2V 特征 87.79% 88.10%
四层,随机化特征 86.58% 86.90%
四层,N2V 特征 88.99% 89.29%

表格总结了不同 GNN 模型的性能,基于它们的准确率和 F1 分数。它突出了使用 N2V 特征的 GNN 在所有模型配置中一致优于使用随机化特征。具体来说,具有 N2V 特征的四层 GNN 实现了最高的准确率和 F1 分数,这表明从 N2V 嵌入中提取的有意义节点表示的有效性。如果我们对节点具有更多具体信息,就像我们在第三章中所做的那样,GNN 嵌入可能会进一步提高 GNN 模型的准确率。

结果:GNN 与随机森林

我们现在比较本节中 GNN 模型的性能与上一节中随机森林模型的性能(见表 2.4)。

表 2.4 GNN 模型和随机森林模型分类性能的比较
模型 数据输入 准确率 F1 分数
随机森林 GNN 嵌入 83.33% 82.01%
随机森林 N2V 嵌入 84.52% 80.72%
两层简单 GNN 具有随机节点特征的图 82.27% 82.14%
两层简单 GNN 使用 n2v 嵌入作为节点特征的图 87.79% 88.10%
四层简单 GNN 随机节点特征的图 86.58% 86.90%
四层简单 GNN 使用 n2v 嵌入作为节点特征的图 88.99% 89.29%

图 2.10 可视化表 2.4 的结果。总体而言,GNN 模型优于随机森林模型。

figure

图 2.10 比较随机森林与 GNN 分类性能的图表。只有一个 GNN 模型被随机森林超越:在具有随机节点特征的图数据上训练的两层模型在准确率方面被超越。

当比较 GNN 模型与随机森林模型的性能时,我们可以得出几个观察结果。当在由 GNN 透传或 N2V 嵌入得到的嵌入上进行训练时,随机森林达到与两层简单 GNN 模型相当的准确率。然而,在考虑 F1 分数时,两个 GNN 模型都优于随机森林。值得注意的是,四层简单 GNN 模型,尤其是在使用 N2V 嵌入作为特征时,比随机森林模型表现出显著更好的性能,展示了更高的准确率和 F1 分数。

这表明,虽然随机森林在准确率方面可能优于简单的 GNN 架构,如两层模型,但在 F1 分数方面,更复杂的 GNN 架构表现出更优越的性能,尤其是在使用 N2V 等复杂的节点嵌入时。因此,在随机森林和 GNN 之间进行选择时,应考虑准确率、F1 分数以及模型架构的复杂性和输入特征的性质,以实现给定任务和数据集的最佳性能。

需要注意的是,这个简短的例子并没有对 GNN 或随机森林模型进行广泛的微调。对这两种类型的模型进行进一步的优化可能会显著提高它们的性能。微调超参数、调整模型架构和优化训练过程都可以有助于提高 GNN 和随机森林分类器的准确率和 F1 分数。因此,虽然这里呈现的结果为小图数据集上的性能提供了初步的见解,我们建议您尝试这些模型并实验其性能。

2.4 内部结构

本节深入探讨了图表示和嵌入的理论基础,特别是在 GNN 的背景下。它强调了嵌入在将复杂的图数据转换为低维、可管理的形式并保留关键信息方面的重要性。

我们区分两种主要的学*类型:归纳和演绎。归纳方法,如 N2V,针对训练数据优化嵌入,使其在已知数据集中有效,但不太适应新数据。相比之下,归纳方法,如 GNNs,通过在训练过程中整合图结构和节点特征,使模型能够泛化到新的、未见过的数据。本节还探讨了 N2V(随机游走)和 GNNs(消息传递)背后的机制。

2.4.1 表示和嵌入

理解图表示和嵌入的作用对于在机器学*中有效地应用 GNNs 至关重要。表示将复杂的图数据转换为更简单、更易管理的形式,同时不丢失关键信息,从而促进对图内部结构的分析和解释。在 GNNs 的背景下,表示使图数据以与机器学*算法兼容的方式处理,确保图的丰富和复杂结构得到保留。

传统的如邻接矩阵和边列表的方法为表示图结构提供了一种基础方式,但它们往往不足以捕捉更丰富的信息,如节点特征或微妙的拓扑细节。这种局限性正是图嵌入发挥作用的地方。图嵌入是图、节点或边的低维向量表示,它保留了基本的结构和关系信息。就像将高分辨率图像降低到紧凑的特征向量一样,嵌入将图的复杂性压缩,同时保留其独特的特征。

嵌入简化了数据处理,并为机器学*应用开辟了新的可能性。它们使复杂图在二维或三维中可视化成为可能,使我们能够更直观地探索其固有的结构和关系。此外,嵌入作为各种下游任务的通用输入,如节点分类和链接预测,正如本章前面的部分所展示的。通过在原始图数据和机器学*模型之间架起桥梁,嵌入是解锁 GNNs 全部潜力的关键。

节点相似性和上下文的重要性

图嵌入的一个重要用途是在图中封装相似性和上下文的概念。在空间上下文中,邻*性(或相似性)通常转化为点之间的可测量距离或角度。

对于图而言,这些概念在连接和路径的术语下被重新定义。节点之间的相似性可以通过它们的连通性来解释,即从一个节点移动到另一个节点需要多少“跳数”或步骤,或者是在图上随机游走时从一个节点穿越到另一个节点的可能性(图 2.11)。

figure

图 2.11 相似性概念的比较:在平面上使用距离(左)和使用图上的步数(右)

另一种思考邻*性的方式是使用概率:给定两个节点(节点 A 和节点 B),如果我从节点 A 开始跳跃,遇到节点 B 的概率是多少?在图 2.12 中,如果跳跃次数为 1,概率为 0,因为从节点 A 到节点 B 不可能在一跳内到达。然而,如果跳跃次数为 2,那么我们需要先计算有多少不同的可能路径。假设在遍历中不会遇到任何节点两次,并且每个方向的可能性相同。在这些假设下,从节点 A 开始有三个独特的 2 跳路径。其中,只有一条通向节点 B。因此,概率是三分之一,或者说 33%。这种基于概率的节点间邻*性测量方法提供了对图拓扑的细微理解,这意味着图结构可以编码在概率空间内。

figure

图 2.12 展示了基于概率计算的邻*性概念:给定从节点 A 的行走路径,遇到节点 B 的概率是邻*性的度量。

这里解释的思想与我们接*图嵌入的归纳和转导方法相关。这两种方法都使用节点邻*性的概念,尽管方式不同,以生成能够捕捉节点关系和图结构的嵌入。归纳方法擅长推广以适应新的、未见过的数据,使模型能够适应并学*超出其初始训练集。相反,转导方法专门优化嵌入以适应训练数据本身,使其在其学*环境中非常有效,但引入新数据时灵活性较低。

2.4.2 转导和归纳方法

嵌入创建的方式决定了其后续使用的范围。在这里,我们研究可以广泛归类为转导和归纳的嵌入方法。转导嵌入方法学*单个静态图中固定节点集的表示:

  • 这些方法直接优化每个节点的单个嵌入。

  • 整个图结构必须在训练期间可用。

  • 这些方法无法自然地推广到未见过的节点或图。

  • 添加新节点需要重新训练整个模型。

  • 例如,包括 DeepWalk [8]、N2V 和矩阵分解方法。

  • 转导方法使我们能够缩小预测问题的范围。对于转导,我们只关心我们呈现的数据。

  • 对于大量数据,这些方法在计算上成本较高。

归纳嵌入方法学*一个生成嵌入的函数,允许推广到未见过的节点甚至全新的图:

  • 这些方法学*聚合和转换节点特征和局部图结构。

  • 这些方法可以在不重新训练的情况下为以前未见过的节点生成嵌入。

  • 节点属性或结构特征通常被使用。

  • 这些方法对于动态或扩展图更加灵活和可扩展。

  • 示例包括 GraphSAGE、GCNs 和图注意力网络(GATs)。

让我们用两个示例来说明这一点:

  • 示例 1:电子邮件垃圾邮件检测—一个用于电子邮件垃圾邮件检测的归纳模型在标记的电子邮件数据集(垃圾邮件或非垃圾邮件)上训练,并从训练数据中学*泛化。一旦训练完成,该模型可以对新到达的电子邮件进行分类,判断其为垃圾邮件或非垃圾邮件,而无需重新训练。

在这个例子中,归纳方法不会更好,因为模型需要为每个新的电子邮件批次重新训练,这使得它们在计算上昂贵且不适用于实时垃圾邮件检测。

  • 示例 2:社交网络社区检测的半监督学*—一个归纳模型使用整个图来识别社交网络中的社区。通过结合标记和无标记的节点,该模型更好地利用了网络:归纳模型不会充分利用特定的网络结构和节点互连,因为它们只处理部分数据——训练集。这不足以进行准确的社区检测。

表 2.5 比较了迄今为止我们学到的图表示类型,包括由非嵌入方法和嵌入方法生成的表示。

表 2.5 不同图表示方法
表示 描述 示例

| 基本数据表示 | • 对于涉及网络遍历的分析方法非常有用 • 对于某些节点分类算法有用

• 提供的信息:节点和边邻居

| • 邻接表 • 边列表

• 邻接矩阵

|

| 归纳(浅层)嵌入 | • 对于未在数据上训练的数据无作用 • 难以扩展

| • DeepWalk • N2V

• TransE

• RESCAL

• 图分解

• 谱技术

|

| 归纳嵌入 | • 模型可以推广到新的和结构不同的图 • 将数据表示为连续空间中的向量

• 学*从数据(新和旧)到连续空间中位置的映射

| • GNNs 可以用于归纳生成嵌入 • Transformers

• N2V 与特征拼接

|

与归纳嵌入方法相关的术语总结

与嵌入方法相关且有时与其互换使用的两个额外术语是浅层方法编码器。在这里,我们将简要区分这些术语。

之前解释过的归纳方法是一大类方法,其中图嵌入是其中一个应用。因此,在我们当前关于表示学*的背景下,归纳学*属性保持不变。

在机器学*中,浅层通常用来指代深度学*模型或算法的相反。这些模型与深度学*模型不同,因为它们不使用多个处理层从输入数据中产生输出。在我们的图/节点嵌入的上下文中,这个术语也指那些不是基于深度学*的方法,但更具体地指向那些模仿简单的查找表,而不是从监督学*算法生成的通用模型。

任何能够再现数据低维表示(嵌入)的方法通常被称为编码器。这个编码器简单地匹配给定的数据点,如节点(甚至整个图)到其在低维空间中的相应嵌入。GNNs 可以广泛地理解为编码器的一类,类似于 Transformers。然而,有一些特定的 GNN 编码器,如图自动编码器(GAE),你将在第五章中遇到。

2.4.3 N2V:图上的随机游走

随机游走方法通过在图上使用随机游走来构建嵌入。使用这些方法,两个节点 A 和 B 之间的相似性被定义为从节点 A 到一个随机图遍历中遇到节点 B 的概率(正如我们在 2.4.1 节中描述的)。这些游走是不受限制的,没有任何限制阻止游走回溯或多次遇到相同的节点。

对于每个节点,我们在其邻域内执行随机游走。随着我们执行越来越多的随机游走,我们开始注意到我们遇到的节点类型的相似性。一个潜在的心理模型是探索一个城市或森林。例如,在一个独特的邻域中,当我们多次走相同的街道或路径时,我们开始注意到房屋有相似的风格,树木有相似的种类。

随机游走方法的结果是每个游走访问的节点向量,具有不同的起始节点。在即将出现的图 2.13 中,我们展示了如何在我们可以在图上行走(或搜索)的一些示例。

DeepWalk 是一种通过为每个节点执行固定大小的多个随机游走来创建嵌入的方法,并从这些中计算嵌入。在这里,任何路径出现的可能性都是相同的,这使得游走无偏,意味着所有通过边连接的节点在每个步骤中遇到的可能性都是相同的。图 2.13 上的 DeepWalk 的输出可能是向量[u, s1, s3]或向量[u, s1, s2, s4, s5]。这些向量中的每一个都包含随机游走中访问的唯一节点。

N2V 通过在这些随机游走中引入可调偏置来改进 DeepWalk。想法是能够在节点附*邻域的学*和更远处的学*中权衡。N2V 通过两个参数来捕捉这一点:

  • p—控制路径游走返回到前一个节点的概率。

  • q——控制深度优先搜索(DFS,一种强调远端节点的跳跃策略)或广度优先搜索(BFS,一种强调附*节点的策略)的概率。DFS 和 BFS 在图 2.13 中展示,我们展示了四个跳跃发生的情况。

为了模拟 DeepWalk 算法,pq都会被设置为0,这样搜索就是无偏的。因此,对于图 2.13,N2V 的输出可以是[u, s1,s2]或[u,s4,s5,s6],这取决于行走是 BFS 还是 DFS。

figure

图 2.13 展示了基于随机行走和这些图遍历策略生成的嵌入的深度优先搜索(DFS)和广度优先搜索(BFS)。DFS(浅色箭头)优先深入一条路径,而 BFS(深色箭头)优先检查所有相邻和附*的路径。

一旦我们有了节点向量,我们通过使用神经网络来预测给定节点的最可能相邻节点来创建嵌入。通常,这个神经网络是浅层的,只有一个隐藏层。训练后,隐藏层就变成了该节点的嵌入。

2.4.4 消息传递作为深度学*

深度学*方法通常由构建块或层组成,这些层接受一些基于张量的输入,并在通过各个层流动时对其进行转换。最后,应用更多的转换和聚合以生成预测。然而,通常隐藏层的输出会直接在模型架构中的其他任务中被利用,或者作为其他模型的输入。这就是我们在第 2.3 节中分类问题中看到的情况。我们构建了一个访问节点向量,然后这些节点被传递给深度学*模型。深度学*模型学会了根据起始节点预测未来的节点。但实际的嵌入包含在网络中的隐藏层

提示:为了复*深度学*,请阅读弗朗索瓦·肖莱特(François Chollet)的《Python 深度学*》(Manning, 2021)。

我们在图 2.14 中展示了深度前馈神经网络的经典架构,具体来说是一个多层感知器(MLP)。简而言之,网络以节点向量为输入,隐藏层被训练以产生一个输出向量,以完成某些任务,例如识别节点类别。输入向量可能是展平的图像,输出可能是一个数字,反映图像中狗或猫的位置。在 N2V 示例中,输入是起始节点的向量,输出是在从起始节点遍历图后访问的其他节点向量。在图像示例中,输出是显式的任务函数,即根据图像中是否包含狗或猫来对图像进行分类。在 N2V 中,输出隐含在图结构中。我们知道后续访问的节点,但我们感兴趣的是网络如何编码数据,即它是如何构建节点数据的嵌入。这包含在隐藏层中,通常我们只取最后一层。

figure

图 2.14 多层感知器的结构

对于 GNNs,输入将是整个图结构,输出将是嵌入。因此,模型在构建嵌入的方式上是明确的。然而,输出并不局限于嵌入。相反,我们可以将输出作为分类,例如在图中的该节点是否有特定的政治倾向。嵌入再次隐含在隐藏层中。然而,整个过程被封装在一个单一模型中,因此我们不需要提取这些数据。相反,我们使用了嵌入来实现我们的目标,例如节点分类。

尽管 GNNs 的架构与前馈神经网络非常不同,但也有一些相似之处。在我们所学*的许多 GNNs 中,一个以张量形式表示的图被输入到 GNN 架构中,并应用一到多个消息传递迭代。消息传递过程如图 2.15 所示。

figure

图 2.15 我们的消息传递层的元素。每个消息传递层由聚合、转换和更新步骤组成。

在第一章中,我们首先讨论了消息传递的概念。在其最简单形式中,消息传递反映了我们从节点或边获取信息或数据并将其发送到其他地方[1]。消息是数据,我们在图的结构中传递消息。每个消息可以包含发送者或接收者的信息,或者通常是两者都有。

我们现在可以进一步解释为什么消息传递对于 GNNs 如此重要。消息传递步骤通过使用节点信息和节点邻*区域的信息(包括附*节点数据和连接它们的边数据)来更新每个节点的信息。消息传递是我们构建关于我们的图表示的方法。这些是构建图嵌入的关键机制,这些嵌入为其他任务,如节点分类提供了信息。在构建这些节点(或边)嵌入时,有两个重要的方面需要考虑。

首先,我们需要考虑消息中包含的内容。在我们之前的例子中,我们有一个关于政治主题的书籍列表。这个数据集只包含关于书籍的共购买连接和它们的政治倾向标签。然而,如果我们有额外的信息,比如书籍长度、作者名字,甚至是简介,那么这些节点特征就可以包含在我们的消息中。然而,重要的是要记住,它也可能是边数据,比如当另一本书一起购买时,这也可能包含在消息中。实际上,有时消息可以同时包含节点和边数据。

其次,我们需要考虑在制作每个嵌入时想要考虑多少本地信息。我们需要知道要采样多少邻*区域的信息。当我们介绍随机游走方法时已经讨论过这一点。我们需要定义在采样我们的图时需要跳过多少步。

在 GNNs(图神经网络)中,数据和跳数对于消息传递至关重要。特征,无论是节点还是边数据,都是消息,而跳数是我们传递消息的次数。这两者都由 GNN 的层来控制。隐藏层的数量是我们将发送消息的跳数。每个隐藏层的输入是消息中包含的数据。对于 GNNs 来说,这几乎总是情况,但值得注意的是,这并不总是正确的。有时,其他机制,如注意力,可以确定从邻*区域的消息传递样本的深度。我们将在第四章讨论图注意力网络(GATs)。在此之前,理解 GNN 中的层数反映了消息传递过程中进行的跳数是一个很好的直觉。

对于前馈网络,如图 2.15 左侧所示,信息在我们神经网络的节点之间传递。在 GNN 中,这些信息包括我们在图上发送的消息。对于每个消息传递步骤,我们神经网络中的顶点从一步之遥的节点或边收集信息。因此,如果我们想让节点表示考虑每个节点三步之遥的节点,我们需要三个隐藏的消息传递层。三个层可能看起来并不多,但覆盖的图量会随着步数的增加而指数级增长。直观上,我们可以将其理解为一种六度分隔原理——所有人之间都只有六度的社会距离。这意味着我和你之间可能通过全球综合社交网络中的六个短暂的跳跃而连接。

不同的消息传递方案会导致不同的 GNN 变体。因此,在这本书中我们研究的每个 GNN,我们都会密切关注消息传递的数学和代码实现。一个重要的方面是我们如何聚合消息,我们将在第三章深入讨论 GCN 时进行讨论。

在消息传递之后,得到的张量会通过前馈层,从而产生预测。在图 2.16 的左侧,该图展示了用于节点预测的 GNN 模型,数据流经消息传递层,然后张量通过一个额外的 MLP 和激活函数以输出预测。例如,我们可以使用我们的 GNN 来分类员工是否可能加入一家新公司或获得良好的推荐。

figure

图 2.16 一个简单的 GNN 架构图(左侧)。一个图在左侧输入,遇到节点信息传递层。随后是 MLP 层。应用激活后,产生预测。一个 GNN 架构(右侧)。

然而,就像之前图示的前馈神经网络一样,我们也可以只输出隐藏层,并直接与该输出工作。对于 GNN,这个输出是图、节点或边嵌入。

关于 GNN 中的消息传递的最后一点是,在 GNN 的消息传递层中的每一步,我们将从节点传递信息到另一个跳数远的节点。重要的是,一个神经网络随后从单跳邻居那里获取数据并应用非线性变换。这是 GNNs 的美丽之处;我们在单个节点和/或边的层面上应用许多小的神经网络来构建图特征的嵌入。因此,当我们说消息传递层就像神经网络的第 1 层时,我们实际上是在说它是许多单个神经网络的第 1 层,这些神经网络都在学*局部节点数据或边特定数据。在实践中,整体代码构建和训练与一个单一变换相同,但当我们深入研究复杂 GNN 模型的工作原理时,应用单个非线性变换的直觉将变得有用。

摘要

  • 节点和图嵌入是强大的方法,可以从我们的数据中提取洞察,并且可以作为我们的机器学*模型中的输入/特征。有几种独立的方法可以生成此类嵌入。GNNs 在其架构中内置了嵌入。

  • 图嵌入,包括节点和边嵌入,是将复杂图数据转换为适合机器学*任务的格式的基础技术。

  • 我们探索了两种主要的图嵌入类型:N2V,一种归纳方法,以及基于 GNN 的嵌入,一种归纳方法,每种方法都有其独特的特性和应用。

  • N2V 在固定数据集上操作,使用随机游走来建立节点上下文和相似性,但它不能推广到未见数据或图。

  • 相反,GNNs 是灵活的归纳框架,可以为新的、未见数据生成嵌入,使它们能够适应不同的图结构。

  • 在机器学*任务中,如半监督学*,嵌入的比较揭示了根据数据大小、复杂性和具体问题选择正确嵌入方法的重要性。

  • 尽管随机森林分类器在处理较小图的 N2V 和 GNN 嵌入方面非常有效,但 GNNs 展示了使用图拓扑和节点特征的独特能力,尤其是在较大和更复杂的图中。

  • 嵌入可以被用作传统机器学*模型和图数据可视化和洞察提取中的特征。

第二部分 图神经网络

现在您已经了解了基础知识,是时候卷起袖子深入探索使图神经网络(GNNs)工作的核心架构了。本节通过介绍关键 GNN 架构并将其应用于实际问题,将理论与实际相结合。您将探索基础模型,如图卷积网络(GCNs)、GraphSAGE 和图注意力网络(GATs),以及图自动编码器(GAEs)——每个都是为利用图数据的独特结构而设计的。

这些架构通过实际应用变得生动起来。它们已被用于虚假评论检测、产品类别预测以及药物发现中的分子图生成。通过将尖端模型与高度有效的用例相结合,本书的这一部分提供了理解和实际工具,以解锁您项目中图神经网络(GNNs)的变革潜力。

第三章:图卷积网络和 GraphSAGE

本章涵盖

  • 介绍 GraphSAGE 和图卷积网络

  • 将卷积图神经网络应用于从 Amazon 生成产品捆绑

  • 图卷积网络和 GraphSAGE 的关键参数和设置

  • 包括卷积和消息传递在内的更多理论见解

在本书的前两章中,我们探讨了与图和图表示学*相关的根本概念。所有这些都为我们设置了第二部分的基础,我们将探索不同类型的图神经网络(GNN)架构,包括卷积 GNNs、图注意力网络(GATs)和图自动编码器(GAEs)。

在本章中,我们的目标是理解和应用图卷积网络(GCNs)和 GraphSAGE [1, 2]。这两个架构是更大类别的 GNNs 的一部分,通过在图数据上应用卷积来接*深度学*。

卷积操作在深度学*模型中相对常见,尤其是在依赖于卷积神经网络(CNNs)的图像任务中。要了解更多关于 CNNs 以及其在计算机视觉中的应用,我们建议查阅 使用 Python 进行深度学*(Manning, 2024)或 使用 PyTorch 进行深度学*(Manning, 2023)。

我们将在本章后面提供关于卷积的简要介绍,但基本上,卷积操作可以理解为在实体上执行空间或局部平均。例如,在图像中,CNN 层在递增更大的像素子域中形成表示。对于 GCNs,我们将应用相同的局部平均概念,但使用节点的邻域。

在本章中,您将学*如何将卷积 GNNs 应用于节点预测问题,GCN 和 GraphSAGE 的关键参数和设置,以及优化卷积 GNNs 性能的方法,以及相关的理论主题,包括图卷积和消息传递。此外,我们将探索 Amazon 产品数据集。本章的结构如下:首先,我们跳转到产品类别预测问题并创建基线模型(第 3.1 节);然后,我们使用邻域聚合调整我们的模型(第 3.2 节);接下来,我们使用通用深度学*方法优化我们的模型(第 3.3 节);随后,我们更详细地解释相关理论(第 3.4 节);最后,我们深入探讨本章以及书中后续章节使用的 Amazon 产品数据集(第 3.5 节)。

本章旨在让您立即沉浸在卷积图神经网络(convolutional GNNs)的应用中,为您提供部署这些模型所需的基本知识。最初的部分为您提供了在实践中对卷积 GNNs 有功能理解的最小工具集。

然而,面对具有挑战性的建模问题,更深入的理解变得极为宝贵。本章的后半部分涵盖了之前引入的层、设置和参数的底层原理。它们旨在增强你的概念理解,确保你的实践技能与彻底的理论理解相辅相成。这种整体方法旨在不仅使你能够应用 GNN,而且能够创新和适应现实世界问题的细微需求。

注意:虽然GraphSAGE指的是一个特定的个体架构,但可能会令人困惑的是GCN也指代一个特定的架构,而不是基于卷积的整个 GNN 类。因此,在本章中,我们将使用卷积 GNN来指代这一整个 GNN 类,包括 GraphSAGE 和 GCN。我们将使用GCN来指代由 Thomas Kipf 和 Max Welling [1]引入的个体架构。

注意:本章的代码以笔记本形式存储在 GitHub 仓库中(mng.bz/wJMW)。本章的 Colab 链接和数据可以在相同的位置访问。

3.1 预测消费者产品类别

让我们以使用 Amazon Products 数据集(见表 3.1)的产品管理问题开始我们对卷积 GNN 的探索。想象一下,你是一位产品经理,旨在通过识别和推广产品捆绑中的新兴趋势来提高销售额。你有一个来自亚马逊产品共购买网络的数据库,其中包含基于客户购买行为的丰富产品关系。你的任务是利用对产品类别和共购买模式的认识,揭示与客户产生共鸣的隐藏且吸引人的产品捆绑。

表 3.1 Amazon Products 数据集概述
按产品类别组织的亚马逊共购买
节点数量(产品) ~2,500,000
节点特征 100
节点类别 47
边的总数 ~61,900,000

为了应对这一问题,我们引入了 GCN 和 GraphSAGE——两种卷积 GNN 架构。本节将指导你如何在 Amazon Products 数据集上训练这些模型。我们将关注两个任务:通过分析训练模型产生的产品嵌入之间的相似性来识别产品的类别和找到产品捆绑的集合。

注意:如果您想深入了解 GCN 和 GraphSAGE 背后的理论,请参阅第 3.4 节。有关 Amazon Products 数据集的详细信息,请参阅第 3.5 节。

在我们的模型训练过程之后,在本节中,我们将执行以下操作:

  • 预处理我们的数据集——我们将使用 Amazon Products 数据集并将其大小缩减,以便在资源最少的情况下工作。

  • 构建我们的模型类——我们将关注两种卷积 GNN:GCN 和 GraphSAGE。我们最初将创建模型类,并用默认参数实例化它们。

  • 编写训练和验证循环的代码—我们将为每个 epoch 设置一个验证步骤来训练模型。为了比较两个模型,我们将使用相同的批次同时训练它们。

  • 评估模型性能—我们将查看训练曲线。然后,我们将使用传统的分类指标来观察模型预测特定类别的能力。

我们当前的目标是开发我们训练模型的初步版本。因此,在这个阶段,重点不在于性能优化,而在于覆盖使基线模型工作的基本步骤。随后的章节将细化这些方法,提高性能和效率。

3.1.1 加载数据和处理

我们首先从开放图基准(OGB)网站(ogb.stanford.edu/)下载亚马逊产品数据集。这个数据集对于一个单机来说很大,占用了 1.3 GB 的空间。它包括 250 万个节点(产品)和 6190 万个边(共同购买)。

为了使具有较小内存容量和较不强大的处理器的系统能够处理这些数据,我们将减小其大小。我们简单地取原始图中前 10,000 个节点索引的节点,并基于这些节点创建一个子图。根据你的问题,还有其他创建子图的战略。在第八章中,我们将更深入地探讨创建子图的方法。

在创建子图时,通常需要进行一些记录工作,以确保我们的节点子集具有一致性和逻辑顺序,并且与正确的标签和特征连接。我们还必须过滤掉连接到子集外节点的边。最后,我们还想确保在需要回溯有用信息时可以回溯子集的原始索引;例如,对于亚马逊产品数据集,我们可以使用原始索引访问每个节点的 SKU(亚马逊标准识别号,ASIN)编号和产品类别。

因此,我们以一致的顺序重新标记节点。然后,我们将相应的节点特征和标签重新分配以对应新的索引。尽管我们选择了前 10,000 个索引的节点,但这在任何特定情况下可能并不适用。以下是我们将如何通过四个步骤精炼和准备数据以进行建模的方法:

  1. 初始化子图—我们创建一个新的图对象,将存储我们的数据子集。这个图将包含原始图中索引为 0–9,999 的节点的边、特征和标签。

  2. 重新标记节点索引—为了确保一致性和避免索引不匹配,我们在子图内部重新标记节点索引。这种重新标记至关重要,因为 GNN 中的操作在很大程度上依赖于索引来处理节点和边信息。

  3. 特征和标签分配—我们将节点特征(x)和标签(y)分配给我们的新图对象。这些特征和标签是从原始数据集中切分出来的,对应于我们指定的子集索引。

  4. 边缘掩码利用 —在子图提取期间使用的 return_edge_mask 选项使我们能够识别在子图创建过程中选择的哪些边。这对于追踪到原始图的结构或进行后续所需的任何结构分析都很有用。

通过这种方式重新组织数据,我们不仅使其易于管理,而且专门针对后续基于图的机器学*任务的高效处理进行了定制。这种设置是我们接下来在以下章节中构建和评估我们的 GNN 模型的基础。以下列表显示了实现该过程的代码。

列表 3.1 读取数据并创建子图
dataset = PygNodePropPredDataset(name='ogbn-products',\
 root=root)   #1
data = dataset[0]   #2

subset_indices = torch.arange(0, 10000)   #3

subset_edge_index, edge_attr, edge_mask = \
subgraph(subset_indices, data.edge_index, \
None, relabel_nodes=True, num_nodes=\
data.num_nodes, return_edge_mask=True)   #4

subset_features = data.x[subset_indices]   #5
subset_labels = data.y[subset_indices]   #6

subset_graph = data.__class__()   #7
subset_graph.edge_index = subset_edge_index   #8
subset_graph.x = subset_features   #9
subset_graph.y = subset_labels   #10

1 从指定的根目录加载数据集,并指定 ogbn-products 以指示正在加载哪个数据集

2 从数据集中选择第一个图对象进行处理。

3 创建一个包含前 10,000 个节点索引的数组,这定义了我们的实验子集

4 使用 subset_indices 调用子图函数以提取与这些索引相关的边和属性。节点被重新标记以保持在新图中的零基索引的一致性。

5 根据 subset_indices 从原始数据中索引节点特征,以确保只将相关特征转移到新图中

6 类似地,索引节点标签以保持与子集特征的一致性

7 创建一个新的数据类实例以存储我们的子集图

8 将在子图提取期间创建的边缘索引数组分配给新图

9 将子集特征分配给新图的节点特征矩阵

10 将子集对应的节点标签分配给新图

3.1.2 创建我们的模型类

在设置我们的数据集并准备一个可管理的子图后,我们转向图机器学*管道的核心:定义模型。在本节中,我们关注 PyTorch Geometric (PyG) 库提供的两种流行的 GNN 类型:GCN 和 GraphSAGE。

理解我们的模型架构

PyG 通过模块化层对象简化了 GNN 的构建,每个层封装了一种特定的图卷积类型。这些层可以堆叠并与其他 PyTorch 模块集成,以构建针对各种基于图的任务的复杂架构。

GCN 模型

GCN 模型使用 GCNConv 层,该层实现了 Kipf 和 Welling 在其开创性论文 [1] 中描述的图卷积操作。它利用图的谱性质来促进节点之间的信息流动,使模型能够学*嵌入局部图结构和节点特征的表示。

在列表 3.2 中,GCN 类设置了一个两层模型。每一层都由 GCNConv 模块表示,该模块通过应用直接使用图结构的卷积操作来处理图数据。

总结其工作原理,从输入的节点特征集和图结构(edge_index),网络将通过聚合每个相应节点的邻域信息来更新节点特征。在第一层之后,我们应用一个修正线性单元(ReLU)激活函数,这为模型添加了非线性。第二层进一步细化这些特征。

如果我们想直接查看节点嵌入——例如,为了可视化或用于其他分析——我们可以在第二层之后直接返回它们。否则,我们应用另一个激活函数——在这种情况下,softmax 函数——以对分类问题的输出进行归一化。

列表 3.2 GCN 类
class GCN(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
    super(GCN, self).__init__()
    self.conv1 = GCNConv(in_channels, hidden_channels)  #1
    self.conv2 = GCNConv(hidden_channels, out_channels)

    def forward(self, x, edge_index, \
return_embeds=False):  #2
        x = self.conv1(x, edge_index)
        x = torch.relu(x)  #3
        x = self.conv2(x, edge_index)
        if return_embeds:  #4
             return x

    return torch.log_softmax(x, dim=1)  #5

1 初始化第一个图卷积层,将输入特征(in_channels)转换为隐藏特征(hidden_channels)

2 前向方法,规定了数据从输入到输出的流动方式

3 在第一次卷积后应用 ReLU 激活函数,以向模型添加非线性

4 可选地返回网络的原始嵌入,这对于需要原始节点表示而不进行分类的任务很有用,例如可视化或进一步处理

5 对最终层的输出应用 log softmax 激活函数

GraphSAGE 模型

与 GCN 模型类似,我们代码中的 GraphSAGE 模型类也设置了一个两层网络,但使用的是SAGEConv层。虽然在代码结构上相似,但 GraphSAGE 在理论上对 GCN 是一个重大的转变。与 GCN 完全依赖整个图的邻接矩阵不同,GraphSAGE 旨在从随机采样的邻域数据中学*,这使得它特别适合大型图。这种采样方法允许 GraphSAGE 通过关注图的局部区域来有效地进行扩展。

GraphSAGE 使用SAGEConv层,该层支持各种聚合函数——平均、池化和长短期记忆(LSTM),在节点特征聚合方面提供了灵活性。在每个SAGEConv层之后,类似于 GCN 模型,应用一个非线性函数。如果需要直接将节点嵌入用于任务,如可视化或进一步分析,它们可以在第二层之后立即返回。否则,应用 softmax 函数对输出进行归一化,以用于分类任务。

PyG 对这些模型实现的显著差异在于它们的效率和在大数据集上的可扩展性。这两个模型都学*节点表示,但 GraphSAGE 在涉及非常大型图的实用应用中提供了显著的优点。与 GCN 不同,GCN 可以在稀疏数据表示上操作,但仍然处理整个图结构的信息,GraphSAGE 不需要整个邻接矩阵。相反,它采样局部邻域,这使得它能够有效地处理大型网络,而不会因为需要加载整个图表示而耗尽内存资源。

列表 3.3 GraphSAGE 类
class GraphSAGE(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
    super(GraphSAGE, self).__init__()
    self.conv1 = SAGEConv(in_channels, \
    hidden_channels)  #1
    self.conv2 = SAGEConv(hidden_channels, out_channels)

    def forward(self, x, edge_index, \
    return_embeds=False):  #2
        x = self.conv1(x, edge_index)
        x = torch.relu(x)  #3
        x = self.conv2(x, edge_index)
        if return_embeds:  #4
            return x

     return torch.log_softmax(x, dim=1)  #5

1 初始化第一个图卷积层,该层将输入特征(in_channels)转换为隐藏特征(hidden_channels)

2 前向方法,它决定了数据从输入到输出的流动方式

3 在第一次卷积后应用 ReLU 激活函数以向模型添加非线性

4 可选地返回网络中的原始嵌入,这对于需要原始节点表示而不进行分类的任务很有用,例如可视化或进一步处理

5 将 log softmax 激活函数应用于最终层的输出

集成和定制

尽管在这个入门示例中我们使用默认设置,但这两个模型都是高度可定制的。可以调整诸如层数、隐藏维度和聚合函数类型(对于 GraphSAGE)等参数,以优化特定数据集或任务的性能。接下来,我们将在我们的子图上训练这些模型并评估它们的性能,以展示它们的实际应用和有效性。

3.1.3 模型训练

数据准备就绪,模型设置完毕,让我们进入训练过程。训练相对简单,因为它遵循典型的机器学*流程,但应用于图数据。我们将通过在每个 epoch 向它们提供相同的数据来同时训练两个模型—GCN 和 GraphSAGE。这种并行训练使我们能够在相同条件下直接比较这两种模型类型的性能和效率。以下是训练循环的简要概述:

  • 初始化优化器—设置学*率为 0.01 的 Adam 优化器。这有助于我们在训练期间有效地微调模型权重。

  • 训练和验证循环—对于每个 epoch,运行训练函数,该函数通过模型处理数据以计算损失并更新权重。同时,在未见过的数据上验证模型,以监控过拟合并相应地调整训练策略。

  • 跟踪进度—记录训练和验证阶段的损失,以便可视化学*曲线并在需要时调整参数。

  • 以测试结束—在训练后,模型将在单独的测试集上评估,以衡量它们的泛化能力。

通过为两个模型保持一致的训练计划,我们确保任何性能差异都可以归因于模型架构的差异,而不是不同的训练条件。以下列表包含我们训练逻辑的注释代码。

列表 3.4 训练循环
gcn_model = GCN(in_channels=dataset.num_features,\
 hidden_channels=64, out_channels=\
dataset.num_classes)  #1
graphsage_model = GraphSAGE(in_channels=d\
ataset.num_features, hidden_channels=64, \
out_channels=dataset.num_classes)  #2

optimizer_gcn = torch.optim.Adam\
(gcn_model.parameters(), lr=0.01)   #3
optimizer_sage = torch.optim.Adam(\
graphsage_model.parameters(), lr=0.01)   #4
criterion = torch.nn.CrossEntropyLoss()   #5

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

def validate(model, data):  #7
    model.eval()  
    with torch.no_grad():  
        out = model(data.x, data.edge_index)  
        val_loss = criterion(out[data.val_mask], data.y[data.val_mask].squeeze())  
    return val_loss.item()  

train_loss_gcn = []   #8
val_loss_gcn = []  
train_loss_sage = []  
val_loss_sage = []  

for epoch in range(200):  #9
    loss_gcn = train(gcn_model, optimizer_gcn, subset_graph)  
    train_loss_gcn.append(loss_gcn)  
    val_loss_gcn.append(validate(gcn_model, subset_graph))  

    loss_sage = train(graphsage_model, optimizer_sage, subset_graph)  
    train_loss_sage.append(loss_sage)  
    val_loss_sage.append(validate(graphsage_model, subset_graph))  

    if epoch % 10 == 0:  
        print(f'Epoch {epoch}, GCN Loss: \
{loss_gcn:.4f}, GraphSAGE Loss: \
{loss_sage:.4f}, GCN Val Loss: \
{val_loss_gcn[-1]:.4f}, GraphSAGE \
Val Loss: {val_loss_sage[-1]:.4f}')

1 初始化 GCN 和 GraphSAGE 模型

2 初始化 GCN 和 GraphSAGE 模型

3 为 GCN 和 GraphSAGE 模型设置优化器

4 为 GCN 和 GraphSAGE 模型设置优化器

5 为分类任务初始化交叉熵损失函数

6 每个 epoch 使用的训练函数

7 每个 epoch 使用的验证函数

8 设置数组以捕获每个模型的损失

9 训练和验证循环

现在我们已经设置了模型并进行了训练,是时候看看它们的性能如何了。下一节将分析训练和验证损失曲线,以了解模型随时间的学*情况。它将检查关键指标,如准确率、精确率、召回率和 F1 分数,以评估我们的模型根据我们的图数据预测产品类别的能力。所有这些都是为了理解我们的模型,并在后面的章节中找出我们可以改进的地方。

3.1.4 模型性能分析

在下一节中,我们将查看 GCN 和 GraphSAGE 模型的模型性能。我们首先检查训练曲线,并指出我们将在下一章中改进过拟合问题。然后,我们将查看 F1 分数和对数损失分数,接着检查产品类别的准确率。

训练曲线

在训练过程中,我们为每个模型在每个时代保存了损失。损失是衡量我们的模型能够做出正确预测的能力的指标,数值越低越好。

使用 Matplotlib,我们使用这些数据绘制训练损失和验证损失曲线,如图 3.1 所示。此类曲线跟踪模型在训练过程中在训练和验证数据集上的性能。理想情况下,两个损失都应随时间下降。然而,在我们的曲线中,我们看到从第 20 个时代开始出现偏差。验证损失曲线达到最低点后开始上升。同时,训练损失继续下降。我们的模型在训练数据上的性能继续提高,但在某个最佳点之后在验证数据上的性能下降。这是经典的过拟合问题,我们将在本章后面解决。

图

图 3.1 本节训练的 GCN 模型(左)和 GraphSAGE 模型(右)的训练和验证损失曲线。验证损失与训练曲线的偏差表明过拟合,即模型在训练数据上学*得太好,而以泛化到新数据为代价。

在我们的训练过程中,我们保存了表现最佳模型的实例,即具有最低验证损失的那个实例。接下来,我们查看两个分类指标以评估性能:对数损失和 F1 分数。

分类性能:F1 分数和对数损失

鉴于前面显示的过拟合问题,我们转向我们的模型分类性能,以建立改进努力的基准。我们使用验证集来建立 F1 分数和对数损失分数,如表 3.2 所示。(F1 分数是加权的,它分别衡量每个类的 F1 分数,然后取平均值,每个类按其在总数据中的比例进行加权。)

中等的分数表明模型有很大的改进空间。我们的 F1 分数没有超过 80%,而对数损失分数不低于 1.25。

表 3.2 模型按 F1 分数和对数损失的分类性能
F1 分数 对数损失
GCN 0.781 1.25
GraphSAGE 0.733 1.88

在这种情况下,GCN 在两个指标上都表现得更好。为了提高多类问题的这些分数,我们可以更深入地研究模型预测单个类别的能力,并检查其在不平衡类别上的表现。

模型在类别层面的性能

亚马逊产品数据集附带两个有用的文件,分别将每个节点与其类别以及每个节点与其单独的亚马逊产品编号(ASIN)映射。为了按类别评估我们基线模型的表现,我们取节点类别信息并创建一个表格,如图 3.2 所示,总结包含最多项目的 25 个类别的预测准确率。

除了准确率之外,在这个表格中,我们还检查了每个类别的最大误判。从这个信息中,让我们做一些高级观察:

  • 按类别性能—两个模型在不同产品类别上的预测准确率都有所变化。书籍类别和 CD 与黑胶类别具有很高的准确率。这可能是由于它们的相对样本数量较多。这也可能表明这些类别更加独特或定义良好,使得模型更容易区分它们。第一个因素,样本数量,很容易调整,因为我们使用了 10,000 个产品节点,并可以从我们的数据集中提取数百万个。您可以通过调整提供的代码中子集的大小来尝试一下。

为了改进不那么独特的类别,我们需要更深入地探索节点特征,以确定这些类别相对于彼此的独特性,并头脑风暴如何增强这些特征以突出其新颖性。

  • 按模型性能—查看所有类别后,GraphSAGE 在大多数类别中似乎比 GCN 表现得更好,正如更高的正确预测百分比所示。这表明 GraphSAGE 从节点的邻域聚合特征的方法可能更适用于这个数据集。

  • 误分类—常见的误分类往往发生在可能具有相似特征或经常一起购买的类别之间。例如,书籍与电影和电视或电子产品与手机及配件之间的误分类表明,这些类别中的项目可能具有重叠的特征,或者经常被相似的客户群体购买。

figure

图 3.2 按产品类别分类性能(准确率),比较 GCN 和 GraphSAGE

虽然我们通常不希望出现误分类,但观察最有可能被误认为是另一个类别的类别,可能会告诉我们关于常见客户对产品类别的感知或混淆的信息,突出潜在的市场营销和产品定位策略。

下两个部分将通过利用我们的 GNNs(第 3.2 节)和采用已知的深度学*方法(第 3.3 节)来提高模型性能,从而从这些基线结果中提升。为了结束本节,让我们使用我们的模型为我们的产品经理提出一个产品组合。

3.1.5 我们的第一个产品组合

在本节的开始,我们讨论了我们的产品经理使用案例,他希望通过引入产品组合来提高销售额。让我们使用我们新训练的一个模型为给定产品提出一个组合。我们将把与所选节点嵌入最相似的节点分组在一起,根据它们的相似性形成一个组合。在章节的后面,当我们改进模型时,我们将回到这个练*。

注意:在此处不会对代码进行详细审查,但可以在存储库中找到。

节点 ID 到产品编号

亚马逊产品数据集中提供的一个关键文件是一个逗号分隔值(CSV)文件,将节点索引映射到亚马逊产品 ID(ASIN)。在存储库中,这被用来创建一个 Python 字典,将节点 ID(键)映射到 ASIN(值)。使用一个节点的 ASIN,我们可以通过以下格式的 URL 访问有关产品的信息:www.amazon.com/dp/{ASIN}。(鉴于数据集的年龄,一些 ASIN 目前没有网页,但在撰写本文时,我们测试的大多数都有。)

要创建一个产品组合,我们与节点嵌入一起工作。我们选择一个单独的产品节点,然后找到与它最相似的六个产品。这需要四个步骤:

  1. 通过运行我们的节点通过我们的训练好的 GNN 来产生节点嵌入。

  2. 使用节点嵌入创建一个相似度矩阵。

  3. 按相似度对所选产品的顶级嵌入进行排序。

  4. 将这些顶级嵌入的节点索引转换为产品 ID。

可以设置一个种子以确保可重复性。否则,每次运行程序的结果都会不同。

产生节点嵌入

与第二章类似,我们通过模型运行我们的节点以产生嵌入而不是预测。与第二章不同,我们有一个为此目的训练好的模型,它已经从我们的数据集的节点特征和共同购买关系中学到了知识。为了完成这个任务,我们将我们的模型置于评估模式(eval()),禁用支持反向传播的梯度计算(no_grad()),然后通过模型运行图数据的正向传递。在定义模型类的时候,我们启用了一个选项来返回嵌入或预测(return_embeds):

gcn_model.eval()

with torch.no_grad():
     gcn_embeddings = gcn_model(subset_graph.x, \
subset_graph.edge_index, return_embeds=True)

创建一个相似度矩阵

相似度矩阵是一组数据,通常以表格形式呈现,包含集合中所有项目对之间的相似性。在我们的案例中,我们使用余弦相似性,并比较我们集合中所有节点的嵌入。SciKit Learn 的cosine_similarity函数实现了这一点:

gcn_similarity_matrix = cosine_similarity(gcn_embeddings.cpu().numpy())

列出与所选节点最相似的条目

为了识别与特定节点最相似的物品,我们首先选择一个节点——用其索引product_idx来指代。使用余弦相似度矩阵,我们通过降序排列相似度来检查每个节点与所选节点的紧密程度。排序中的前几项(具体来说,前六项,其中top_k设置为6)代表了与所选节点最相似的节点。值得注意的是,这个列表中包括了所选节点本身,因此,出于实用目的,我们考虑接下来的五个节点来有效地创建一个相似物品的捆绑包:

product_idx = 123 
top_k = 6
top_k_similar_indices_gcn = np.argsort(-
gcn_similarity_matrix[product_idx])[:top_k]

将节点索引转换为产品 ID

从这里,使用索引到 ASIN 字典,我们可以根据节点索引识别产品捆绑包。完成此操作后,让我们随机选择一个产品节点并围绕它生成一个产品捆绑包。

产品捆绑包演示

随机选择节点#123。使用我们的索引到 ASIN 字典,我们得到 ASIN:B00BV1P6GK。这个 ASIN 属于图 3.3 中所示的产品 Funko POP 电视:冒险时间马塞尔琳乙烯基人偶。该产品的类别是玩具与游戏。

figure

图 3.3 我们所选的产品,Funko POP 电视:冒险时间马塞尔琳乙烯基人偶。在本节中,将为该产品生成一个产品捆绑包。

马塞尔琳,这位数百岁的吸血鬼女王,是流行动画电视剧《冒险时间》中的主要角色之一。马塞尔琳以其摇滚明星形象、对音乐的热爱以及弹奏她的贝斯吉他而闻名,这在她的出场中经常成为焦点。她的形象在雕像中得到了体现,雕像面带微笑,姿势放松但自信。

冒险时间》是一部动画系列,讲述了名叫芬恩的男孩和他的魔法狗杰克在神秘的土地奥兹的奇幻和史诗般的冒险故事,这里充满了公主、吸血鬼、冰王和许多其他奇怪的角色。

对于基于《冒险时间》系列的收藏,人们可能会期待一系列代表该节目多元角色阵容的乙烯基人偶。让我们看看我们的系统会生成什么。

使用前面概述的过程,生成了图 3.4 所示的捆绑包。其中包含一个冒险时间乙烯基人偶。乍一看,其余的选择似乎无关,但也许这个套装是一个非直观的捆绑包。让我们仔细看看:

  • 排名第一的相似度:Funko POP 电视:冒险时间芬恩带配件——芬恩是《冒险时间》的中心角色,这是我们预期的推荐。这表明,马塞尔琳的粉丝可能也会欣赏或收集与该节目其他主要角色相关的商品。

  • 第二相似度排名:Funko My Little Pony:DJ Pon-3 乙烯基人偶——这个物品乍一看可能显得与上下文不符,但它可能表明对动画系列的跨界兴趣。来自《我的小马驹》的 DJ Pon-3,或称乙烯基 Scratch,是一个像马塞尔琳一样的音乐角色,吸引那些喜欢与音乐相关的角色。

图

图 3.4 以 Marceline 产品为中心的产品组合。推荐的产品属于玩具与游戏类别。这些产品的主题与所选产品有松散的联系。
  • 第三位相似度:Funko 小马宝莉:暮光闪闪乙烯基人偶—与 DJ Pon-3 一样,来自 小马宝莉 的暮光闪闪代表着与一部流行动画系列的另一种联系。这种包含可能会吸引那些喜欢奇幻主题和强大女性角色的收藏家。

  • 第四和第五位相似度:海盗主题配饰(金币、纹身、手持黄铜望远镜带木盒)—这些物品与“冒险时间”或“小马宝莉”的直接关联较少,但它们增强了探险和探索的主题,这是这两部系列的重要元素。

总的来说,这是我们基线模型中不错的产品组合!总结本节关于模型训练和评估的介绍,我们现在已经为理解和使用 GNN 建立了一个坚实的基础。这种理解对于我们进入 3.2 节至关重要,在那里我们将更深入地探讨邻域聚合,这是一种有效的工具,可以增强性能。然后,在 3.3 节中,我们将借鉴通用的深度学*方法来进一步优化模型的性能。

3.2 聚合方法

在本节中,我们扩展了上一节的产品类别分析,并深入研究了影响 GNN 在产品分类等任务上性能的特征。具体来说,我们探讨了聚合方法,这些方法对卷积 GNN 的性能有重大影响。邻域聚合允许节点从其局部节点邻域收集和整合特征信息,捕捉更大网络中的上下文相关性。

我们从简单的聚合方法开始,包括均值、总和和最大值,这些方法应用于模型的所有层。然后,我们在 PyG 中调查了几种更高级的实现:每层应用的独特聚合、列表聚合、聚合函数以及称为跳跃知识网络(JK-Nets)的层内聚合。最后,我们提供了一些应用这些方法的指导方针。

3.2.1 邻域聚合

图数据结构的一种不同之处在于节点通过边相互连接,形成一个节点可以直接链接或由几个度数分隔的网络。这种空间排列意味着任何给定的节点可能与其他某些节点非常接*,形成我们所说的其 邻域。节点邻域的概念至关重要,因为它通常包含关于节点特征和整个图的关键见解。

在卷积图神经网络(GNN)中,节点邻域通过称为邻域聚合的过程来使用。这项技术涉及收集和组合节点直接邻居的特征信息,以捕捉它们的个体和集体属性。通过这样做,节点的表示被其周围环境提供的上下文信息丰富,这增强了模型在图中学*更复杂和细微模式的能力。

邻域聚合基于这样一个前提:彼此靠*的节点可能比距离较远的节点更可能相互影响。这在节点之间的关系和交互可以预测其行为或属性的任务中特别有利。

PyG 中的邻域聚合

在 PyG 层 GCN(GCNconv)和 GraphSAGE(SAGEConv)中,邻域聚合以不同的方式实现。在 GCN 中,加权平均聚合内置到层中;如果你想要调整它,你必须创建这个层的自定义版本。在本节中,我们将主要关注 GraphSAGE,它允许你通过参数设置聚合。下一节将检查 GCN 中使用的层级聚合。

SAGEConv中,aggr参数指定了聚合的类型。选项包括但不限于以下内容:

  • 求和聚合——一种简单的聚合,将所有邻居节点的特征求和。

  • 均值聚合——计算邻居节点特征的均值。这通常因其简单性和在平均特征信息方面的有效性而得到应用,有助于平滑数据中的异常值。

  • 最大值聚合——对于每个特征维度,从所有邻居中取最大特征值。当最显著的特征比平均特征更有信息量时,这有助于捕捉来自邻居的最重要信号。

  • LSTM 聚合——一种相对计算和内存密集的方法,使用 LSTM 网络处理邻居节点有序序列的特征。它考虑了节点的序列,这在节点处理顺序影响结果的任务中可能至关重要。因此,必须特别注意安排数据集的节点和边以进行训练。

在这些类型中选择将取决于给定图的特性和预测目标。如果你对哪种方法对你的图和用例更有效没有很好的感觉,可以通过试错来选择聚合方法。此外,虽然一些聚合选项可以即插即用,但其他一些选项——例如依赖于训练好的 LSTM 网络的 LSTM 聚合——在数据准备上需要一些思考。

为了看到不同聚合的效果,我们在模型类中添加了 aggr 参数,然后继续按照第 3.1 节中的步骤进行训练,用均值、求和和最大聚合替换。需要注意的是,均值聚合是 SAGEConv 层的默认值,因此它与我们的 GraphSAGE 基线模型等效。创建具有聚合的 GraphSAGE 类的示例如下。

列表 3.5 带聚合参数的 GraphSAGE 类
class GraphSAGE(torch.nn.Module):
    def __init__(self, in_channels, \
hidden_channels, out_channels, agg_func='mean'):  #1
        super(GraphSAGE, self).__init__()
        self.conv1 = SAGEConv(in_channels, \
hidden_channels, aggr=agg_func)   #2
        self.conv2 = SAGEConv(hidden_channels, \
out_channels, aggr=agg_func)   #3

    def forward(self, x, edge_index):
         x = self.conv1(x, edge_index)
         x = F.relu(x)
         x = self.conv2(x, edge_index)

    return F.log_softmax(x, dim=1)

1 设置聚合的关键字参数

2 指定聚合的第一层 GraphSAGE

3 指定聚合的第二层 GraphSAGE

使用均值、最大值和求和聚合的结果

表 3.3 比较了使用 F1 分数和对数损失作为性能指标的不同模型。表中显示,使用最大聚合的模型在两个指标下都是最好的。使用最大聚合的模型结果显示 F1 分数最高为 0.7449,对数损失最低为 2.1039,这表明最大聚合在识别和使用预测任务中最具影响力的特征方面略胜一筹。使用均值聚合的模型等同于第 3.1 节中训练的模型。我们观察到最大聚合优于其他两种聚合。总体而言,使用不同聚合的性能与我们的基线 GraphSAGE 模型非常相似。

表 3.3 不同邻域聚合设置下 GraphSAGE 模型的分类性能
聚合类型 F1 分数 对数损失
均值(默认) 0.7406 2.1214
求和 0.7384 2.2496
最大值 0.7449 2.1039

如果单独的模型在 F1 分数和对数损失上都有最高分,应该选择哪个模型?例如,如果最大聚合模型在 F1 分数上得分最高,而均值聚合模型在对数损失上得分最高,这将取决于你应用的上下文、预测的要求以及潜在错误的后果。

在医疗保健情况下,例如预测患者出院后 30 天内的再入院,模型的选择可以显著影响患者结果和资源分配。具有高 F1 分数的模型将具有更平衡的精确度和召回率,在漏诊再入院可能代价高昂或危险的情况下表现更好。它预计会识别出更多处于风险中的患者,从而允许及时干预。然而,这也可能导致更高的假阳性,导致不必要的治疗和成本增加。

另一方面,具有低对数损失的模型对其预测有很高的信心,它优先考虑每个预测的准确性,而不是检测到的阳性案例数量。这种模型在资源分配需要精确或治疗方案有显著副作用时非常有用。

回到我们的产品经理,他正在决定将营销资金分配给哪些产品和产品组合,更可靠的预测将有助于防止营销努力的浪费。降低假阳性的可能性有助于高效地使用资源,但同时也存在因保守预测而错过一些能带来收入的组合配置的风险。

在本节中,我们使用了简单的字符串参数aggr。然而,PyG 有一套广泛的工具,可以将各种聚合方法纳入你的模型。我们将在下一节中探讨这些工具。

3.2.2 高级聚合工具

本节探讨了 PyG 中更高级的聚合工具。我们首先将不同的聚合方法分配给多层架构中的不同层。接下来,我们探索了在单个层中组合各种聚合策略——如'mean''max''sum'——的可能性。最后,我们回顾 GCNs 以检查跳跃知识(JK)方法。

在层间使用多个聚合

在多层 GraphSAGE 模型中,你当然可以在每个层独立地调整聚合函数。例如,你可能会在第一层使用平均聚合来平滑特征,但在后续层切换到最大聚合以突出显示最显著的邻居特征。

作为探索的第一步,让我们将几种聚合排列应用于两层,看看这些配置是否优于我们之前的结果。我们使用之前的代码,将aggr设置从conv1conv2中替换出来。对于一种模型,我们在第一层使用mean,在第二层使用max。对于另一种模型,我们在第一层使用sum,在第二层使用max。表 3.4 总结了结果。

表 3.4 不同邻域聚合设置下的 GraphSAGE 模型分类性能
聚合类型 F1 分数 对数损失
平均值(默认) 0.7406 2.1214
求和 0.7384 2.2496
最大值 0.7449 2.1039
层次化:平均值 → 最大值 0.7316 2.2041
层次化:求和 → 最大值 0.7344 2.345

对于我们的数据集,我们的结果最多是中等。只有最大聚合的模型优于新模型。让我们继续为每个层组合多个聚合。

列表聚合和聚合函数

在 PyG 中,使用列表指定聚合函数的概念允许你同时使用多种聚合策略来定制你的模型。这个特性很重要,因为它使模型能够使用图数据的各个方面,通过捕捉图的多种属性来提高模型性能。从某种意义上说,你是在聚合你的聚合。例如,你可以在单个层中结合'mean''max''sum'聚合,以捕捉邻域的平均、最显著和总和的结构属性。

这在 PyG 中通过将聚合函数的列表(可以是字符串或Aggregation模块实例)传递给MessagePassing类来实现。PyG 将这些字符串与预定义的聚合函数集进行解析,或者可以直接将聚合函数作为aggr参数使用。例如,使用关键字'mean'将调用MeanAggregation()函数。

有无数种组合可以尝试,但让我们尝试两个示例来演示,混合熟悉的聚合,'max''sum''mean';以及一组更奇特的聚合,SoftmaxAggregationStdAggregation[3]。它们可以应用于我们的conv1层,如下所示(表 3.5 比较了这些结果与之前的结果):

       self.conv1 = SAGEConv(in_channels,\
 hidden_channels, aggr=['max', 'sum', 'mean'])

       self.conv1 = SAGEConv(in_channels,\
 hidden_channels, aggr=[SoftmaxAggregation(),\
 StdAggregation() ])
表 3.5 添加列表聚合的 GraphSAGE 模型的分类性能
聚合类型 F1 分数 对数损失
平均(默认) 0.7406 2.1214
求和 0.7384 2.2496
最大值 0.7449 2.1039
层次:平均→最大值 0.7316 2.2041
层次:求和→最大值 0.7344 2.345
列(标准) 0.7484 2.622
列(奇特) 0.745 2.156

图 3.5 可视化了表 3.5 中的性能比较。虽然 F1 分数非常相似,但“标准”列表聚合在 F1 分数上略有提升,尽管代价是更高的对数损失。

图

图 3.5 从表 3.5 中可视化的性能比较。虽然 F1 分数非常相似,但标准列表聚合在对数损失方面表现略好。

给定我们对这些聚合方法应用于 GraphSAGE 层的快速调查结果,你可能会得出结论,坚持默认设置通常是最佳选择。然而,通过定制聚合策略提高性能的潜力表明,进一步的探索可能是有益的。

在即将到来的 3.2.3 节中,我们将回顾应用这些聚合方法时的一些考虑因素。在那之前,我们将回到 GCN 层来检查 JK 聚合方法。

跳跃知识网络

跳跃知识(JK)是一种在图上进行节点表示学*的新方法,它解决了现有模型(如 GCNs 和 GraphSAGE)的局限性[4]。它专注于克服邻域聚合模型的问题,即模型对图结构敏感,导致不同图部分的学*质量不一致。

跳跃知识网络(JK-Nets)允许对每个节点灵活地使用不同的邻域范围,从而适应局部邻域属性和特定任务的要求。这种适应通过使模型能够根据节点和子图上下文有选择地使用不同邻域深度的信息,从而提高了节点表示。JK 已在 PyG 的 GCN 层中实现,如列表 3.6 所示。

它的主要参数mode指定了用于组合不同层输出的聚合方案。选项如下:

  • 'cat'—沿着特征维度连接所有层的输出。这种方法保留了每一层的所有信息,但增加了输出的维度性。

  • 'max'—在层输出上应用最大池化。这种方法对每个特征在所有层中取最大值,这有助于从图中捕获最重要的特征,同时对于不太有信息性的信号具有鲁棒性。

  • 'lstm'—使用双向 LSTM 为每一层的输出学*注意力分数。然后根据这些学*到的注意力权重进行输出组合,允许模型根据输入图结构动态地关注最相关的层。

列表 3.6 带有JumpingKnowledge层的 GCN 类
class CustomGCN(torch.nn.Module):
   def __init__(self, in_channels, hidden_channels, out_channels):
       super(CustomGCN, self).__init__()
       self.conv1 = GCNConv(in_channels, hidden_channels)
       self.conv2 = GCNConv(hidden_channels, out_channels)

       self.jk = JumpingKnowledge(mode='cat')  #1

   def forward(self, x, edge_index):
       layer_outputs = []  #2

       x1 = self.conv1(x, edge_index)
       x1 = F.relu(x1)
       layer_outputs.append(x1)  #3

       x2 = self.conv2(x1, edge_index)
       layer_outputs.append(x2) 

       x = self.jk(layer_outputs)  #4

       return x

1 使用连接模式初始化 JK

2 列表用于保存 JK 的每一层输出

3 将层输出列表附加到

4 对收集到的层输出应用 JK 聚合

在列表中,对于初始化,JumpingKnowledge层被初始化为模式设置为'cat'(连接),表示每一层的特征将被连接以形成最终的节点表示。

在前向传递中,layer_outputs被初始化为一个空列表,用于存储每个卷积层的输出。这个列表将被JumpingKnowledge层使用。

  • 第一个卷积层处理输入x和图结构edge_index,并应用 ReLU 激活函数以引入非线性。

  • 首层的输出(x1)随后被添加到layer_outputs列表中。

  • 在第二个卷积层之后,第二个输出(x2)也被添加到layer_outputs列表中。

  • 然后,JumpingKnowledge层接受所有先前层的输出列表,并根据指定的模式('cat')进行聚合。在连接模式下,每个层的特征向量沿着特征维度进行连接。

表 3.6 比较了 GCN 模型的分类性能。第 3.1 节中的基线 GCN 模型与使用JumpingKnowledge聚合方法的版本进行比较。基线模型具有更好的 F1 分数,而 JK 模型在 log 损失方面表现更优。

表 3.6 GCN 模型的分类性能
模型 F1 分数 Log 损失
基线 GCN 0.781 1.42
JK (GCN) 0.699 1.36

结果显示,在基线版本和 JK 版本之间进行选择涉及在更高的召回率/精确度和更高的预测确定性之间进行权衡。这种权衡应根据任务的具体要求和目标仔细考虑。第 3.2.3 节将进一步探索有效应用这些聚合方法的考虑因素。

3.2.3 应用聚合的实际考虑因素

选择合适的聚合方法是一个技术决策,应基于手头数据集的具体特征和需求以及用例。对于局部邻域结构至关重要的数据集,使用平均或求和聚合可能会模糊关键特征。相比之下,最大聚合可以帮助突出关键属性。例如,在一个关键人物检测至关重要的社交网络图中,最大聚合可能更有效。另一方面,如果我们想要表示典型特征,最大聚合可能会过分强调异常值。在一个我们想要了解典型用户行为的金融交易数据集中,最大聚合可能会扭曲常见的用户行为特征,以有利于一两个大但不太常见的交易。

任务本身可以决定聚合方法的选择。需要捕捉最有影响力特征的任务可能从最大聚合中受益,而需要一般表示的任务可能发现平均聚合足够。在一个产品推荐系统中,最大聚合可以帮助识别驱动购买的最重要产品特征。此外,图拓扑的性质应指导聚合方法。密集连接的图可能需要与稀疏连接的图不同的策略,以避免过度平滑或节点特征的欠表示。例如,具有不同节点连接性的交通网络图可能在不同层需要不同的聚合。

由于数据集的复杂性,对不同的聚合方法进行实证测试是必不可少的。实验可以帮助确定哪些方法最能捕捉数据集的关系动态和特征分布。这对于更复杂的聚合方法尤为重要,在这些方法中,仅凭直觉可能不足以确定其有效性。所选聚合方法的可扩展性,以高效处理数百万个节点和边,也是至关重要的。在实时应用(如推荐系统)中,平衡计算效率与方法复杂性尤为重要。

在考虑其他模型增强方法(如特征工程、节点嵌入技术和正则化策略以解决过拟合和改善模型泛化)的同时,应考虑聚合方法。例如,将有效的聚合方法与高级嵌入技术(例如 Node2Vec)相结合或引入 dropout 进行正则化,可以显著提高模型性能。

虽然没有一种适合所有情况的聚合方法,但经过实证验证的深思熟虑的技术组合可以显著提高模型性能和适用性。这种战略方法不仅有助于准确的产品分类,还有助于构建有效的推荐系统,这在电子商务环境中至关重要。

本节探讨了并应用了不同的聚合方法到我们的模型中。下一节将通过应用正则化和调整我们模型的深度来完善我们对卷积 GNNs 的探索。我们将把我们的改进整合到一个最终模型中,然后基于 Marceline 小雕像生成另一个产品捆绑,以查看是否有改进。

3.3 进一步优化和改进

到目前为止,GCN 和 GraphSAGE 层是通过产品管理示例引入的。我们在 3.1 节中使用了默认设置建立了基线。在 3.2 节中,我们考察了使用邻域和层聚合的方法。在本节中,我们将考虑其他我们可以用来改进和优化我们的模型的方法。在前几小节中,我们将介绍两种其他调整:使用 dropout 和模型深度。Dropout 是一种众所周知的正则化技术,可以减少过拟合,而模型深度是对 GNNs 具有独特意义的调整。

在 3.3.3 节中,我们综合这些见解来开发一个包含多个改进的模型,并观察累积的性能提升。最后,在 3.3.4 节中,我们重新审视我们的产品捆绑问题。我们使用 3.3.3 节中精炼的模型创建一个新的产品捆绑,并将其性能与 3.1 节中创建的捆绑进行比较。

3.3.1 Dropout

Dropout 是一种正则化技术,通过在训练过程中随机丢弃单元来防止神经网络过拟合。这有助于模型更好地泛化,因为它减少了模型对特定神经元的依赖。

在 PyG 中,dropout函数的工作方式与标准的 PyTorch dropout 相似,这意味着在训练过程中,它会随机将输入张量和隐藏层激活的一些元素设置为0。在训练过程中的每次前向传递中,根据指定的 dropout 率,输入和激活被设置为0。这有助于通过确保模型不会过度依赖任何特定的输入或激活来防止过拟合。

图的结构,包括其顶点(节点)和边,在 dropout 过程中保持不变。图的拓扑结构得到保留,只有神经网络的激活受到影响。这种区别至关重要,因为它在仍然使用 dropout 来提高模型鲁棒性的同时,保持了图的完整性。PyG 确实有函数可以在训练过程中删除节点或边,但内置在GCNConvSAGEConv中的 dropout 指的是传统的深度学* dropout。

在 PyG 中,GraphSAGE 和 GCN 层都使用 dropout 率作为参数,默认值为 0。图 3.6 展示了具有不同 dropout 率(0%、50% 和 85%)的 GCN 模型的性能。如图所示,更高的 dropout 率可以帮助减轻过拟合,这可以从训练损失和验证损失之间的差距减小中看出。对于 85% 的情况,更高的 dropout 率可能会导致模型收敛速度变慢,或者这可能是一个过拟合的迹象。需要进行更多测试来确定。

接下来,让我们来探讨模型深度及其在卷积图神经网络(GNNs)中的实现方式。

3.3.2 模型深度

在 GNNs 中, 指的是跳跃或消息传递步骤的数量。每一层允许节点从其直接邻居中聚合信息,有效地通过每一层增加一个跳跃的感知场。例如,一个三层模型会查询每个节点三个跳跃之外的邻域。因此,GNN 的 深度 指的是网络中的层数,类似于传统深度学*模型中的深度,但由于图结构数据的关键差异而有所不同。

如果一个 GNN 层数过少,它可能无法从图中捕获足够的信息,导致较差的表示学*,因为每个节点只能从有限的邻域中聚合信息。相反,增加层数可能导致 过度平滑,节点特征变得过于相似,难以区分不同的节点。随着每增加一层,节点从更大的邻域中聚合信息,稀释了单个节点的独特特征。已经提出了各种指标和方法来衡量和减轻这种影响。

figure

图 3.6 三种不同 dropout 水平的模型训练曲线比较。左侧的 dropout 为 0%,中间为 50%,右侧为 85%。对于我们的模型和数据集,添加 dropout 确实改善了过拟合。具有 85% dropout 的模型可能显示出欠拟合或收敛缓慢的迹象,需要更多的实验。

不同深度的 GNNs 的性能可能会有显著差异。通常,具有 2 或 3 层的 GNNs 在许多任务上表现竞争力,在满足足够邻域信息需求的同时,不会导致过度平滑。虽然更深层的 GNNs 从理论上可以捕捉更复杂的模式,但它们通常会受到过度平滑和计算复杂度增加的影响。非常深的 GNNs,例如具有 50 层或更多层的,可能会导致更高的验证损失,这表明过拟合和/或过度平滑。

图 3.7 比较了不同深度 GNN 的性能(例如,2 层、10 层和 50 层)。我们看到 2 层模型在训练和验证损失之间取得了良好的平衡。在 10 层 GNN 中,我们看到了训练损失的改进,但也出现了来自更高验证损失的过平滑迹象。50 层模型显示训练和验证损失下降,这表明存在严重的过平滑或过拟合。

figure

图 3.7 不同深度训练模型的训练曲线:2 层(顶部),10 层(中间),50 层(底部)。2 层模型具有最佳性能,没有过拟合或性能下降的迹象。

在 GNN 中实现最佳性能的关键在于平衡模型的深度。层数过少可能导致弱表示学*,而层数过多可能导致过平滑,节点特征变得难以区分。在下一节中,我们将应用本章中关于调整模型所学到的东西,从而得到一个优于基线的优化模型。

3.3.3 提高基线模型的性能

在本章获得的所有洞察的基础上,让我们训练合成这些学*的模型,并将它们与基线进行比较。以下是我们将在此处包含的一些关键要点:

  • 模型深度—我们将将其保持在较低水平,即两层。

  • 邻域聚合—我们将使用最大聚合并尝试两种列表聚合。相同的聚合将应用于两层。

  • Dropout—我们将对两层都使用 50% dropout。

下面的列表显示了一个具有可调整 dropout、层深度和聚合的 GraphSAGE 类。

列 3.7 GraphSAGE 类
class GraphSAGEWithCustomDropout(torch.nn.Module):
   def __init__(self, in_channels, \
hidden_channels, out_channels, num_layers, \
dropout_rate=0.5, aggr='mean'):  #1
       super(GraphSAGEWithCustomDropout, self).__init__()
       self.layers = torch.nn.ModuleList\
([SAGEConv(in_channels, hidden_channels, aggr=aggr)])
       for _ in range(1, num_layers-1):  #2
           self.layers.append(SAGEConv\
(hidden_channels, hidden_channels, aggr=aggr))
       self.layers.append(SAGEConv\
(hidden_channels, out_channels, aggr=aggr))
       self.dropout_rate = dropout_rate

   def forward(self, x, edge_index):
       for layer in self.layers[:-1]:
           x = F.relu(layer(x, edge_index))
           x = F.dropout(x, p=self.dropout_rate, training=self.training)
       x = self.layers-1
       return F.log_softmax(x, dim=1)

1 层初始化时使用层数、dropout 率和聚合类型。

2 循环将聚合应用于每一层。

我们使用前面的类训练了三个模型:

model_1 = GraphSAGEWithCustomDropout\
(subset_graph.num_features, 64, \
dataset.num_classes, 2, dropout_rate=.5, \
aggr= ‘max’).to(device)

model_2 = GraphSAGEWithCustomDropout\
(subset_graph.num_features, 64, \
dataset.num_classes, 2, dropout_rate=0.5, \
aggr=['max', 'sum', 'mean']).to(device)

model_3 = GraphSAGEWithCustomDropout\
(subset_graph.num_features, 64, \
dataset.num_classes, 2, dropout_rate=0.50,\
 aggr=[SoftmaxAggregation(), \
StdAggregation() ] ).to(device)

表 3.7 总结了不同聚合方法和默认平均值聚合的 GraphSAGE 模型的性能。结果表明,所有改进的模型在 F1 分数和 log loss 方面都优于基线。值得注意的是,使用 'max''sum''mean' 聚合组合的模型 2 实现了最高的 F1 分数 0.8828。模型 3 使用 SoftmaxAggregation()StdAggregation() 的组合,在 0.5764 的最佳 log loss 下表现最佳,这表明它在测试配置中具有最高的预测确定性。

表 3.7 使用 50% dropout 和不同聚合类型的两层 GraphSAGE 模型
GraphSAGE 模型 聚合类型 F1 分数 Log Loss
模型 1 'max' 0.8674 0.594
模型 2 ['max', 'sum', 'mean'] 0.8876 0.660
模型 3 [SoftmaxAggregation(), StdAggregation()] 0.8829 0.574
基线模型 平均值(默认) 0.7406 2.1214

图 3.8 中的混淆矩阵可视化了使用最大聚合的模型 1 的分类性能。大多数值都在对角线上,表明模型正确分类了大多数实例。然而,也存在非对角线元素,代表错误分类,例如,将类别 0 的实例错误分类为类别 1 或反之亦然。这些错误分类的频率和分布突出了模型在哪些方面存在困难。此外,使用侧边栏上的条形图表示每个类别的成员数量,混淆矩阵显示了这些不同类别的分布情况。有些类别的计数较高,而其他类别的计数则显著较低,这表明数据集中存在类别不平衡。

figure

图 3.8 展示了具有 50%丢弃率和最大聚合的两层 GraphSAGE 模型的混淆矩阵。强烈的对角线模式表明分类性能良好。侧边栏给出了类别的分布,突出了类别不平衡。

注意,在这整个过程中,我们只使用了数据集节点中不到 1%的部分,这些节点是任意按照索引顺序选择的。增加节点的数量将提高我们模型的表现。此外,在保持节点数量不变的同时,以更有意义的方式选择子图也可以提高性能。

虽然当前的模型显示出显著的改进,但还可以考虑其他几种策略来进一步提高性能。通过使用更大的子集来增加数据集的大小可以提供更多的训练数据,从而可能提高模型的泛化能力。根据领域知识细化子图选择或使用图采样技术可以确保使用更有意义的数据进行训练。使用 Hyperopt 等工具系统地调整超参数可以帮助找到模型的最佳设置。Hyperopt 允许使用贝叶斯优化等算法高效地搜索超参数空间。探索更复杂的聚合函数或针对数据集特定特征的定制聚合也可以带来改进。此外,实现正则化方法,如 L2 正则化或梯度裁剪,可以稳定训练并防止过拟合。图预处理技术,如归一化、特征工程和图特征的降维,可以提高输入数据的质量,进一步提升模型性能。接下来,我们将选择在日志损失上表现最高的模型来生成另一个产品包。

3.3.4 重访 Marcelina 产品包

模型在 3.1 节中的基线模型上有了显著改进。让我们重新审视产品捆绑问题,并根据前面章节中改进的 GraphSAGE 模型为我们的产品经理推荐一个方案。使用 3.1.5 节中的过程得到的捆绑方案如图 3.9 所示,并与原始捆绑方案进行了比较。

图片

图 3.9 以 Marceline 产品为中心的产品捆绑。上面的捆绑方案来自 3.3.3 节的改进模型,而下面的捆绑方案来自 3.1.5 节的基线模型。新的推荐方案是玩具与游戏、书籍和电影与电视类别的成员。

您对这款新套餐有何看法?这是否是一个改进,也就是说,比之前的套餐更有可能推动购买?这个新套餐包含了玩具与游戏、书籍和电影与电视类别的商品,这是一个多样化的产品选择。在冒险书籍《沙纳拉之剑》和动作人偶的旁边引入《野生克鲁特:最野的动物冒险》DVD,反映了向更家庭化和儿童友好的产品组合的转变。

这个新套餐推动购买的可能性基于对客户购买行为和偏好的更新模型所捕捉到的更深入的理解。这个套餐似乎非常适合送礼目的,既满足了流行文化纪念品收藏者(例如 Marceline 人偶和相关收藏品)的需求,也满足了年轻奇幻和冒险叙事粉丝的需求。

从更通用的玩具集合转向一个专注的、主题导向的捆绑方案可能会增加其作为购买吸引力的可能性。除了收藏品(如 Marceline 人偶和相关收藏品)之外,还包括娱乐(《野生克鲁特:最野的动物冒险》DVD)和文学(《沙纳拉之剑》)元素,提供了一个围绕流行的冒险和探索主题的更全面的娱乐体验。这可能吸引那些寻找有吸引力和主题礼物,同时也提供教育价值的父母,例如《野生克鲁特》中关于动物和自然的内容。

考虑到一个精心策划的套餐的心理效应是至关重要的。通过将产品与已识别的客户兴趣和交叉销售模式更紧密地对齐,这个套餐不仅满足了现有需求,而且通过增强捆绑商品之间的互补性,提高了感知价值,从而鼓励了额外的购买。

最终,关于这个新捆绑包是否比原始版本有所改进的决定,应该通过客户反馈和销售数据进行验证。跟踪两个捆绑包(以及由人类产品经理建议的捆绑包)的销售表现,并通过调查或 A/B 测试收集直接客户洞察,将有助于定量评估哪个捆绑包在销售和客户满意度方面表现更好。这种数据驱动的方法将证实新捆绑包创建中使用的先进建模技术的理论优势。

通过这一点,我们结束了本章的动手产品示例。接下来的两个部分是可选的,因为它们深入探讨了卷积 GNN 的理论,并更详细地研究了 Amazon 产品数据集。

3.4 内部机制

现在我们已经创建并改进了一个工作的卷积 GNN,让我们更深入地研究 GNN 的元素,以更好地理解它们是如何工作的。这种知识有助于我们在设计新的 GNN 或调试 GNN 时。

在第二章中,我们介绍了使用 GNN 层通过消息传递来生成预测或创建嵌入的想法。这是那个架构图再次呈现,如图 3.10 所示。

让我们深入到 GNN 层的表面之下,检查其元素。然后,我们将将其与聚合函数的概念联系起来。

图

图 3.10 来自第二章的节点嵌入架构图

3.4.1 卷积方法

让我们首先考虑深度学*中最受欢迎的架构之一,卷积神经网络(CNN)。CNN 通常用于计算机视觉任务,如分割或分类。可以将 CNN 层视为对输入数据应用一系列操作的序列:

层:滤波器 → 激活函数 → 池化

每个整个层的输出是一些经过变换的数据,这使得某些下游任务更容易或更成功。这些变换操作包括以下内容:

  • 滤波器(或核操作)——一种变换输入数据的过程。滤波器用于突出输入数据的一些特定特征,并包含通过目标或损失函数优化的可学*权重。

  • 激活函数——应用于滤波器输出的非线性变换。

  • 池化——一种减少后续学*任务中滤波器输出大小的操作。

CNNs 和许多 GNNs 有一个共同的基础:卷积的概念。当讨论 CNN 中使用的三个操作时,你了解了卷积的概念。在 CNNs 和 GNNs 中,卷积都是通过在数据中建立局部模式的层次结构来学*的。对于 CNNs,这可以用于图像分类,而卷积 GNN,如 GCN,可能使用卷积来预测节点的特征。为了强调这一点,CNNs 将卷积应用于固定像素网格以识别网格中的模式。GCN 模型将卷积应用于节点图以识别图中的模式。

我在上一个段落中提到了卷积的概念,因为卷积可以以不同的方式实现。从理论上讲,卷积与数学上的卷积算子相关,我们将在稍后更详细地讨论这一点。对于 GNNs,卷积可以分为空间和频谱方法[1, 5, 6]:

  • 空间—在图上滑动一个窗口(过滤器)。

  • 频谱—使用频谱方法过滤图信号。

空间方法

在传统的深度学*中,卷积过程通过将一个称为卷积核的特殊过滤器应用于输入数据来学*数据表示。这个内核的大小小于输入数据,并且通过在其上移动来应用。这如图 3.11 所示,我们应用我们的卷积核(中间的矩阵)到一只狮子的图像上。由于我们的卷积核的所有非中心元素都有负值,结果图像已经被反转。我们可以看到一些特征被强调,例如狮子的轮廓。这突出了卷积的过滤方面。

figure

图 3.11 输入图像的卷积(左侧)。内核(中间)在动物图像上移动,从而得到输入图像的特定表示(右侧)。在深度学*过程中,过滤器的参数(矩阵中的数字)是学*参数。

这种卷积网络的使用在计算机视觉领域尤为常见。例如,当在 2D 图像上学*时,我们可以应用几层的简单 CNN。在每一层中,我们通过每个图像传递一个 2D 过滤器(内核)。3 × 3 的过滤器在一个比其大得多的图像上多次工作。通过这种方式,我们可以通过连续的层产生输入图像的学*表示。

对于图,我们希望应用这种在数据上移动窗口的相同想法,但现在我们需要调整以考虑我们数据的关联和非欧几里得拓扑。对于图像,我们处理的是刚性的二维网格;对于图,我们处理的是没有固定形状或顺序的数据。在没有预定义图节点顺序的情况下,我们使用邻域的概念,包括一个起始节点及其所有一跳邻居(即从中心节点出发的一跳范围内的所有节点)。然后,我们的滑动窗口通过移动节点的邻域在图上移动。

在图 3.12 中,我们看到一个比较卷积应用于网格数据和应用于图数据的插图。在网格情况下,像素值在围绕中心像素(用灰色点标记)的九个像素周围被过滤。然而,对于图,节点属性是基于可以通过一条边连接的所有节点进行过滤的。一旦我们确定了将要考虑的节点,我们然后需要对节点执行某些操作。这被称为聚合操作;例如,一个邻域中所有节点权重可能被平均或求和,或者我们可能取最大值。对于图来说,重要的是这个操作是置换不变的。节点的顺序不应该很重要。

图

图 3.12 在网格数据(左;例如,二维图像)和图(右)上应用卷积的比较。

频谱方法

为了介绍卷积的第二种方法,让我们考察图信号[6]的概念。在信息处理领域,信号是可以考察时间或频率域的序列。当在时间域研究信号时,我们考虑其动态性,即它是如何随时间变化的。从频率域来看,我们考虑信号中有多少位于每个频率带内。

我们也可以以类似的方式研究图的信号。为此,我们定义图信号为节点特征的向量。因此,对于给定的图,其节点权重集可以用来构建其信号。作为一个视觉示例,在图 3.13 中,我们有一个与每个节点相关联的值的图,其中每个相应的条的高度代表某些节点特征。

图

图 3.13 图的顶点上的随机正图信号。每个垂直条的高度代表条形起始节点的信号值。

要操作这个图信号,我们将图信号表示为一个矩阵,其中每一行是与特定节点相关联的一组特征。然后,我们可以在图矩阵上应用信号处理操作。一个关键操作是傅里叶变换。傅里叶变换可以将图信号及其节点特征集表示为频率表示。相反,逆傅里叶变换将频率表示转换回图信号。

除此之外:传统深度学*方法在图上的局限性

为什么我们不能直接将 CNN 应用于图结构?原因是图表示具有图像表示所不具备的歧义。CNN 以及传统的深度学*工具通常无法解决这种歧义。能够处理这种歧义的神经网络被称为排列等变排列不变

让我们通过考虑之前展示的狮子图像来阐述图与图像之间的歧义。这个像素集的简单表示是一个二维矩阵(具有高度和宽度的维度)。这种表示将是唯一的:如果我们交换图像的两行或两列,我们不会得到一个等效的图像。同样,如果我们交换图像矩阵表示中的两列或两行(如图 3.14 所示),我们也不会得到一个等效的矩阵。

figure

图 3.14 狮子的图像是唯一的(左)。如果我们交换两列(右),我们最终得到一个与原始图像不同的独特照片。

对于图来说,情况并非如此。图可以通过邻接矩阵(在第一章和附录 A 中描述)来表示,其中每一行和每一列的元素代表两个节点之间的关系。如果一个元素非零,这意味着行节点和列节点是相连的。给定这样一个矩阵,我们可以重复我们之前的实验,并像处理图像那样交换两行。与图像的情况不同,我们最终得到一个代表我们最初图的矩阵。我们可以进行任意数量的排列或交换行和列,并得到一个代表相同图的矩阵。

回到卷积操作,为了成功地将卷积滤波器或 CNN 应用于图的矩阵表示,这样的操作或层必须无论邻接矩阵的顺序如何都能得到相同的结果(因为每种顺序都描述了相同的事物)。CNN 在这方面失败了。

找到可以应用于图的卷积滤波器已经以多种方式解决了。在本章中,我们考察了两种实现方式:空间方法和频谱方法。(对于卷积滤波器应用于图的更深入讨论和推导,请参见[7]。)

3.4.2 消息传递

空间和频谱方法都描述了我们可以如何将我们的图上的数据结合起来。空间方法关注图的结构,并在空间邻域间组合数据。频谱方法关注图信号,并使用信号处理方法,例如傅里叶变换,来在图上组合数据。这两种方法都隐含了消息传递的概念。

在第三章中,我们介绍了消息传递作为一种从我们的图中提取更多信息的方法。让我们一步一步地考虑消息传递做了什么。首先,从每个节点或边收集消息。其次,我们将这些消息转换为特征向量来编码数据。最后,我们将节点或边数据更新以包含这些消息。结果是,每个节点或边最终包含个别数据以及图中其他部分的数据。编码在这些节点中的数据量反映了跳数或消息传递步骤的数量。这与 GNN 中的层数相同。在图 3.15 中,我们展示了消息传递的心理模型。

图

图 3.15 消息传递层的元素。每个消息传递层由一个聚合步骤、一个转换步骤和一个更新步骤组成。

每个消息传递层的输出是一组嵌入或特征。在聚合步骤中,我们从图邻域收集消息。在转换步骤中,我们将神经网络应用于聚合消息。最后,在更新步骤中,我们改变节点或边的特征以包含消息传递数据。

以这种方式,一个 GNN 层类似于一个 CNN 层。它可以被解释为一系列应用于输入数据的操作:

层:聚合 → 转换 → 更新

在我们探索本书中的不同 GNN 时,我们将回到这一组操作,因为大多数类型的 GNN 都可以被视为这些元素的修改。例如,在本章中,你正在学* GCN 作为一种特定的聚合类型。在下一章中,你将学* GATs,它通过学*如何使用注意力机制聚合消息来结合转换和聚合步骤。

要构建这个消息传递步骤,让我们逐步分析前面的过程,并添加更多细节。前两个步骤可以理解为一种过滤器,类似于传统神经网络的第一个步骤。首先,我们使用我们的聚合算子对节点或边数据进行聚合。例如,我们可能对特征求和、平均特征或选择最大值。最重要的是,节点的顺序对于最终表示不应该很重要。顺序不应该很重要的原因是,我们希望我们的模型是排列等变的,这意味着减法或除法可能不合适。

一旦我们从所有节点或收集了所有消息,我们就通过传递新的消息通过神经网络和激活函数将它们转换成嵌入。一旦我们有了这些转换后的嵌入,我们就应用激活函数,然后将它们与节点或边数据和之前的嵌入结合起来。

激活函数是对转换和聚合消息应用的非线性变换。我们需要函数是非线性的;否则,无论有多少层,模型都会是线性的,类似于线性(或在我们的情况下是逻辑回归)模型。这些是在人工神经网络中使用的标准激活函数,例如 ReLU,它是零和输入值之间的最大值。池化步骤随后减少了任何图级学*任务中滤波器输出的总体大小。对于节点预测,这可以省略,我们在这里就是这样做的。

我们可以将前面的描述组合成一个消息传递操作的单一表达式。首先,让我们假设我们正在使用节点嵌入,正如我们将在本章中所做的那样。我们希望将节点 n 的数据转换成节点嵌入。我们可以使用以下公式来完成:

(3.1)

figure

在这里,u 代表节点。可学*的权重由 W[a] 给出,这些权重将根据损失函数进行调整,而 σ 是激活函数。为了构建嵌入,我们需要将所有节点数据组合成一个单一的向量。这就是聚合函数发挥作用的地方。对于 GCN,聚合操作符是求和。因此,

(3.2)

figure

其中,对于节点 uh[v]是节点 u 邻域中节点 v 的数据,N(u)。结合这两个方程,我们可以构建一个构建节点嵌入的一般公式:

(3.3)

figure

对于前面的公式,我们看到节点及其邻域起着核心作用。确实,这是 GNN 证明非常成功的主要原因之一。我们还看到,我们需要在激活函数和聚合函数上做出选择。最后,这些更新包括每个节点上的先前数据:

(3.4)

figure

在这里,我们将消息连接在一起。也可以使用其他方法来更新消息信息,选择取决于所使用的架构。

这个更新方程是消息传递的本质。对于每一层,我们使用包含所有 聚合 消息的 转换 数据来 更新 所有节点数据。如果我们只有一个层,我们只执行一次这个操作,我们正在从起始节点的一跳邻居中聚合信息。如果我们运行多次迭代这些操作,我们将中央节点两跳内的节点聚合到节点特征数据中。因此,GNN 层的数量直接关联到我们用模型询问的邻域大小。

这些是消息传递步骤中执行的操作的基本原则。聚合或激活函数等事物的变化突出了 GNN 架构中的关键差异。

3.4.3 GCN 聚合函数

GCN 与 GraphSAGE 之间的关键区别在于它们执行不同的聚合操作。GCN 是一种基于谱的 GNN,而 GraphSAGE 是一种空间方法。为了更好地理解这两种方法之间的区别,让我们看看如何实现它们。

首先,我们需要了解如何将卷积应用于图。从数学上讲,卷积操作可以表示为两个函数的组合,产生第三个函数

(3.5)

figure

其中 f(x) 和 h(x) 是函数,运算符表示逐元素乘法。在 CNN 的上下文中,图像和核矩阵是方程 3.6 中的函数:

(3.6)

figure

这种数学运算被解释为核在图像上滑动,就像滑动窗口方法一样。我们可以将前面的描述转换为描述我们数据的矩阵或张量。为了将方程 3.7 的卷积应用于图,我们使用以下成分:

  • 图的矩阵表示:

    • 向量 x 作为图信号

    • 邻接矩阵 A

    • 拉普拉斯矩阵 L

    • 拉普拉斯算子的特征向量矩阵 U

  • 权重的参数化矩阵 H

  • 基于矩阵操作的傅里叶变换:U^Tx

这导致了在图上的频谱卷积的表达式:

(3.7)

figure

因为这个操作不是简单的逐元素乘法,所以我们使用符号 *[G] 来表达这个操作。几个基于卷积的 GNN 基于方程 3.8;接下来,我们将检查 GCN 版本。

GCN 对卷积方程(3.8)进行了修改,以简化操作并降低计算成本。这些修改包括使用基于多项式的滤波器而不是一组矩阵,并限制跳数为一跳。这将从二次复杂度降低到线性复杂度,这是一个显著的改进。然而,需要注意的是,GCN 更新了我们之前描述的聚合函数。这仍然使用求和,但包括一个归一化项。

之前,聚合算子是求和。这可能导致节点度数变化大的图中的问题。如果一个图中包含度数高的节点,这些节点将占主导地位。为了解决这个问题,一种方法是用平均值代替求和。聚合函数随后表示为

(3.8)

figure

因此,对于 GCN 消息传递,我们有

(3.9)

figure

其中

  • h 是更新的节点嵌入。

  • sigma, σ, 是应用于每个元素的非线性(即激活函数)。

  • W 是一个训练好的权重矩阵。

  • |N| 表示图节点集合中元素的数量

求和因子,

(3.10)

figure

是一种特殊的正则化,称为对称正则化。此外,GCN 包括自环,使得节点嵌入包含邻域数据和起始节点的数据。因此,要实现 GCN,必须发生以下操作:

  • 调整图节点以包含自环

  • 训练权重矩阵和节点嵌入的矩阵乘法

  • 正则化操作是对称正则化项的总和

在图 3.16 中,我们详细解释了消息传递步骤中使用的每个术语。

到目前为止,这些都是理论上的。接下来,让我们看看如何在 PyG 中实现这些操作。

figure

图 3.16 GCN 嵌入公式中关键计算操作的映射

3.4.4 PyTorch Geometric 中的 GCN

在 PyG 文档中,您可以找到实现 GCN 层的源代码,以及 GCN 层的简化实现。以下,我们将指出源代码如何实现前面的关键操作。

在表 3.8 中,我们将 GCN 嵌入计算中的关键步骤分解,并将其与源代码中的函数关联。这些操作通过类和函数实现:

  • 函数gcn_norm执行正则化和向图中添加自环。

  • GCNConv实例化 GNN 层并执行矩阵操作。

表 3.8 GCN 嵌入公式中的关键计算操作映射
操作 函数/方法
向节点添加自环 gcn_norm(),列表 3.8 中的注释
乘以权重和嵌入 W((k)^)h[u] GCNConv.__init__; GCNConv.forward
对称正则化 gcn_norm(),列表 3.8 中的注释

在列表 3.8 中,我们详细展示了gcn_norm函数和类的代码,并使用注释突出关键操作。这个正则化函数是 GCN 架构的关键方面。gcn_norm的参数如下:

  • edge_index—节点表示以张量或稀疏张量形式存在。

  • edge_weight—一个可选的一维边权重数组。

  • num_nodes—这是输入图的维度。

  • improved—这引入了从 Graph U-Nets 论文[8]中引入的添加自环的替代方法。

  • Add_self_loops—添加自环是默认操作,但它是可选的。

列表 3.8 gcn_norm函数
def gcn_norm(edge_index, edge_weight=None, num_nodes=None, improved=False,
             add_self_loops=True, dtype=None):  #1

   fill_value = 2\. **if** improved **else** 1\.  #2

   **if** isinstance(edge_index, SparseTensor):  #3
       adj_t = edge_index
       **if** not adj_t.has_value():
           adj_t = adj_t.fill_value(1., dtype=dtype)
       **if** add_self_loops:
           adj_t = fill_diag(adj_t, fill_value)
       deg = sparsesum(adj_t, dim=1)
       deg_inv_sqrt = deg.pow_(-0.5) 
       deg_inv_sqrt.masked_fill_(deg_inv_sqrt == float('inf'), 0.)
       adj_t = mul(adj_t, deg_inv_sqrt.view(-1, 1))
       adj_t = mul(adj_t, deg_inv_sqrt.view(1, -1))
       **return** adj_t

   **else**: 
       num_nodes = maybe_num_nodes(edge_index, num_nodes)

       **if** edge_weight is None:
           edge_weight = torch.ones((edge_index.size(1), ), dtype=dtype,
                                    device=edge_index.device)

       **if** add_self_loops:
           edge_index, tmp_edge_weight = add_remaining_self_loops(
               edge_index, edge_weight, fill_value, num_nodes)
           **assert** tmp_edge_weight is not None
           edge_weight = tmp_edge_weight

       row, col = edge_index[0], edge_index[1]
       deg = scatter_add(edge_weight, col, dim=0, dim_size=num_nodes)
       deg_inv_sqrt = deg.pow_(-0.5)
       deg_inv_sqrt.masked_fill_(deg_inv_sqrt == float('inf'), 0)
       **return** edge_index, deg_inv_sqrt[row] * edge_weight * deg_inv_sqrt[col]

1 对输入图执行对称正则化,并在输入图中添加自环

2 fill_value 参数用于替代自环操作。

3 如果图输入是稀疏张量,则 if 语句中的第一块代码将应用。否则,将应用第二块。

在实践中,我们可以通过使用 PyTorch 和 PyG 的一些函数来显著简化归一化的实现。在列表 3.9 中,我们展示了归一化邻接矩阵的简短版本。首先,我们计算每个节点的入度,然后计算逆平方根。然后我们使用这个逆平方根来创建新的边权重,并将基于度数的逆平方根应用于这个权重。最后,我们创建一个表示邻接矩阵的稀疏张量,并将其分配给我们的数据。

列表 3.9 使用 PyTorch 和 PyG 进行归一化
    edge_index = data.edge_index
    num_nodes = edge_index.max().item() + 1  #1

    deg = torch.zeros(num_nodes, \
    dtype=torch.float).to(edge_index.device)       #2
    deg.scatter_add_(0, edge_index[1],            
                 torch.ones(edge_index.size(1))\
.to(edge_index.device))                           

    deg_inv_sqrt = deg.pow(-0.5)  #3
    deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0   #3

    edge_weight = torch.ones(edge_index.size(1))\
    .to(edge_index.devic)  #4
    edge_weight = deg_inv_sqrt[edge_index[0]]*edge_weight*\
    deg_inv_sqrt[edge_index[1]]  #5

    num_nodes = edge_index.max().item() + 1   #6

    adj_t = torch.sparse_coo_tensor(indices=edge_index,\
    values=edge_weight, size=(num_nodes, num_nodes))      #7
    data.adj_t = adj_t.coalesce()                         #7

1 假设节点索引从 0 开始

2 计算每个节点的入度

3 计算基于度的逆平方

4 创建一个新的边权重张量

5 将逆平方根应用于边权重

6 假设节点索引从 0 开始

7 创建一个稀疏张量并将其分配给数据

在下面的列表中,我们提供了 GCNConv 类的摘录,该类调用了 gcn_norm 函数以及矩阵操作。

列表 3.10 GCNConv
class GCNConv(MessagePassing):    

    def __init__(self, in_channels: int, out_channels: int,
improved: bool = False, cached: bool = False,
        add_self_loops: bool = True, normalize: bool = True,
        bias: bool = True, **kwargs):    

        self.lin = Linear(in_channels, out_channels, bias=False,
                         weight_initializer='glorot')
    def forward(self, x: Tensor, edge_index: Adj,
                edge_weight: OptTensor = None) -> Tensor:

if self.normalize:  #1
    if isinstance(edge_index, Tensor):
        cache = self._cached_edge_index
            if cache is None:
                edge_index, edge_weight = gcn_norm( 
                edge_index, edge_weight, x.size(self.node_dim),
                self.improved, self.add_self_loops)
                if self.cached:
                    self._cached_edge_index = (edge_index, edge_weight)
                else:
                    edge_index, edge_weight = cache[0], cache[1]

        x = self.lin(x)  #2

        out = self.propagate(edge_index, x=x,\
 edge_weight=edge_weight, size=None)  #3

        **if** self.bias is not None:  #4
            out += self.bias

        **return** out

1 前向传播函数执行对称归一化,给定两种选择之一:输入图是一个张量或稀疏张量。这里包含了张量输入的源代码。

2 节点特征矩阵的线性变换

3 消息传播

4 输出可选的加性偏置。

3.4.5 光谱卷积与空间卷积

在上一节中,我们讨论了两种解释卷积的方式:(1) 通过在由链接节点局部邻域组成的图的一部分上滑动窗口滤波器的思维实验,以及(2) 通过滤波器处理图信号数据。我们还讨论了这两种解释如何突出了卷积 GNN 的两个分支:空间方法和光谱方法。滑动窗口和其他空间方法依赖于图的几何结构来执行卷积。相反,光谱方法使用图信号滤波器。

光谱方法和空间方法之间没有明确的界限,通常一种类型可以解释为另一种类型。例如,GCN 的一个贡献是证明了其光谱推导可以以空间方式解释。然而,在撰写本文时,空间方法更受欢迎,因为它们有更少的限制,并且通常具有更低的计算复杂度。我们在表 3.9 中突出了光谱和空间方法的附加方面。

表 3.9 光谱卷积与空间卷积方法的比较
光谱 空间
操作:使用图的特征值进行卷积 操作:在节点邻域中聚合节点特征

| • 必须是无向的 • 操作依赖于节点特征

• 通常计算效率更低

| • 不需要是无向的 • 操作不依赖于节点特征

• 通常计算效率更高

|

3.4.6 GraphSAGE 聚合函数

GraphSAGE 通过限制聚合操作中使用的邻居节点数量来改进 GCN 的计算成本。相反,GraphSAGE 从邻居的随机样本中进行聚合。聚合算子更加灵活(例如,可以是求和或平均值),但现在考虑的消息只是所有消息的一个子集。从数学上讲,我们可以将其表示为

(3.11)

figure

其中 Ɐu ϵ S 表示邻居是从总邻居的随机样本 S 中选择的。从 GraphSAGE 论文[2]中,我们有通用的嵌入更新过程,该过程在论文中作为算法 1 介绍,此处以图 3.17 的形式重现。

figure

图 3.17 算法 1,GraphSAGE 嵌入生成算法,来自 GraphSAGE 论文[2]

该算法的基本原理可以描述如下:

  1. 对于每一层/迭代以及每个节点:

    1. 聚合邻居的嵌入。

    2. 将邻居嵌入与中心节点连接起来。

    3. 将该连接与权重矩阵相乘。

    4. 将该结果与激活函数相乘。

    5. 应用归一化。

  2. 使用节点嵌入 h 更新节点特征 z。

让我们更详细地看看这将对消息传递步骤意味着什么。我们已将 GraphSAGE 的消息传递定义为如下:

(3.12)

figure

如果我们选择平均值作为聚合函数,则变为

(3.13)

figure

对于实现,我们可以进一步将其简化为

(3.14)

figure

其中 x'[i] 表示生成的中心节点嵌入,而 x[i] 和 x[j] 分别是中心节点和邻居节点的输入特征。权重矩阵应用于中心节点和邻居节点,如图 3.18 所示,但只有邻居节点具有聚合算子(在这种情况下,平均值)。

figure

图 3.18 GraphSAGE 嵌入公式中的关键计算操作映射

我们现在已经看到了 GraphSAGE 算法的所有主要特性。接下来,让我们看看如何在 PyG 中实现它。

3.4.7 PyTorch Geometric 中的 GraphSAGE

在表 3.10 中,我们分解了 PyG 的 GraphSAGE 类中的关键操作及其发生的位置。关键操作包括邻居嵌入的聚合、节点邻居嵌入与节点嵌入的连接、权重与连接的乘法以及激活函数的应用。

表 3.10 GCN 嵌入公式中的关键计算操作映射
操作 函数/方法
聚合邻居的嵌入(求和、平均值或其他)。 SAGEConv.message_and_aggregate
将邻居嵌入与中心节点的嵌入连接起来。 SAGEConv.forward
将该连接与权重矩阵相乘。 SAGEConv.message_and_aggregate
应用激活函数。 如果project参数设置为True,则在SAGEConv.forward中完成
应用归一化。 SAGEConv.forward

对于 GraphSAGE,PyG 还提供了在SAGEConv类中实现此层的源代码,以下列出其中的一些摘录。

列表 3.11 GraphSAGE 类
class SAGEConv(MessagePassing):
…
   def forward(self, x, edge_index, size):

        if isinstance(x, Tensor):
            x: OptPairTensor = (x, x)

       if self.project and hasattr(self, 'lin'):  #1
           x = (self.lin(x[0]).relu(), x[1])

       out = self.propagate(edge_index, x=x, size=size)  #2
       out = self.lin_l(out)  #2
       x_r = x[1]  #3

       **if** self.root_weight and x_r is not None:  #4
           out += self.lin_r(x_r)  #4

       **if** self.normalize:  #5
           out = F.normalize(out, p=2., dim=-1)  #5
       **return** out

   **def** message(self, x_j):
       **return** x_j

   **def** message_and_aggregate(self, adj_t, x):
       adj_t = adj_t.set_value(None, layout=None)  #6
       **return** matmul(adj_t, x[0], reduce=self.aggr)  #6
…

1 如果 project 参数设置为 True,则将对邻居节点应用带有激活函数(在这种情况下为 ReLU)的线性变换。

2 传播消息并应用线性变换

3 将根节点分配给一个变量

4 如果 root_weight 参数设置为 True 且存在根节点,则将转换后的根节点特征添加(连接)到输出中。

5 如果 normalize 参数设置为 True,则将对输出特征应用 L2 归一化。

6 使用聚合的矩阵乘法。设置 aggr 参数建立聚合方案(例如,平均值、最大值、lstm;默认为 add)。adj_t 是输入的稀疏矩阵表示;使用这种表示可以加快计算速度。

3.5 亚马逊产品数据集

在本章和第五章中,我们使用亚马逊产品数据集[9]。该数据集探讨了产品关系,特别是共同购买,即在同一交易中购买的产品。这种共同购买数据是预测节点和边的方法的基准数据集。在本节中,我们提供了关于数据集的一些更多信息。

为了说明共同购买的概念,在图 3.19 中,我们展示了六个在线客户的共同购买示例图像。对于每个产品,我们包括一张图片、一个纯文本产品标签和一个粗体文本类别标签。

figure

图 3.19 亚马逊.com 上的共同购买示例。每个产品由一张图片、一个纯文本产品标题和一个粗体文本产品类别表示。我们看到一些共同购买的产品是彼此的明显互补,而其他分组则不那么明显。

其中一些共同购买组似乎很好地结合在一起,例如书籍购买或服装购买。其他共同购买则不太容易解释,例如购买苹果 iPod 与即食餐食一起,或者购买豆类与无线扬声器一起。在这些不太明显的分组中,可能存在一些潜在的产品关系,或者可能只是纯粹的巧合。在规模上检查数据可以提供线索。

为了展示共同购买图在小规模上的外观,图 3.20 取了前一个图中的一个图像,并将产品表示为节点,它们之间的边代表每次共同购买。对于一个客户和一次购买,这是一个小图,只有四个节点和六条边。但对于同一个客户随着时间的推移,对于有相同口味的一组客户,甚至所有客户,很容易想象这个图如何随着更多产品和产品连接从这些少数产品中分支出来而扩展。

构建这个数据集本身就是一个漫长的旅程,这对于图构建以及为了得到一个有意义和有用的数据集所必须做出的决策非常感兴趣。简单来说,这个数据集是从亚马逊的购买日志数据中提取的,这些数据直接显示了共同购买的情况,以及从产品评论中的文本数据,这些数据被用来间接展示产品关系。(关于详细的故事,参见[8])。

figure

图 3.20 图 3.19 中一次共同购买的一个图表示。每个产品的图片是一个节点,共同购买是产品之间的边(以线条表示)。对于这里显示的四个产品,这个图只是单个客户的共同购买图。如果我们展示亚马逊所有客户的对应图,产品节点和共同购买边的数量可能达到数万个产品节点和数百万条共同购买边。

为了探索产品关系,我们可以使用亚马逊产品共同购买图,这是一个包含在同一交易中一起购买的产品数据集(定义为共同购买)。在这个数据集中,产品由节点表示,这些节点既有购买的产品类型,即分类标签,还有一些特征信息。特征信息首先将产品描述应用自然语言处理(NLP)方法,即词袋算法,将字符串转换为数值。然后,为了将其转换为相同的固定长度,数据集的创建者使用了主成分分析(PCA)将其转换为长度为 100 的向量。

同时,共同购买由边表示,这指的是一起购买的两个产品。总的来说,ogbn-products数据集包含 250 万个节点(产品)和 6190 万个边(共同购买)。这个数据集是通过本章开头提到的开放图基准(OGB)数据集提供的,并获得了亚马逊的使用许可。每个节点有 100 个特征。有 47 个类别被用作分类任务的目标。我们注意到这里的边是无向和无权的。

在图 3.21 中,我们看到节点数量最多的类别是书籍(668,950 个节点)、CD 和黑胶(172,199 个节点)以及玩具和游戏(158,771 个节点)。最少的类别是家具和装饰(9 个节点)、数字音乐(6 个节点)以及一个未知类别(#508510)有 1 个节点。

figure

图 3.21 亚马逊产品数据集中节点标签的分布

我们还观察到,在数据集中许多类别的比例非常低。每个标签/类别的节点平均计数为 52,107;中位数为 3,653。这突显出我们的数据集中存在强烈的类别不平衡。这可能会对典型的表格结果构成挑战。

在本章中,我们探讨了图卷积网络(GCNs)和 GraphSAGE 的基本原理,这两种强大的架构用于在图结构数据上学*。我们使用亚马逊产品数据集将这些模型应用于实际的产品分类问题,展示了如何实现、训练和优化 GNNs。我们还深入研究了这些模型的理论基础,考察了诸如邻域聚合、消息传递以及频谱和空间卷积方法之间的区别。通过将实际操作与理论洞察相结合,本章为理解并应用卷积 GNNs 到现实世界的图学*任务提供了一个全面的基石。在下一章中,我们将研究一种特殊的卷积 GNN,它使用注意力机制,即图注意力网络(GAT)。

摘要

  • GCNs 和 GraphSAGE 是使用卷积的 GNNs,分别通过空间和频谱方法进行。

  • 这些 GNNs 可以用于监督学*和半监督学*问题。我们将它们应用于预测产品类别的半监督问题。

  • 亚马逊产品数据集ogbn-products由一组产品(节点)组成,这些产品通过同一交易中的购买(共同购买)相互连接。每个产品节点都有一个特征集,包括其产品类别。这个数据集是图分类问题的流行基准。我们还可以研究它是如何构建的,以获得关于图创建方法的见解。

  • 根据领域知识选择子图或使用图采样技术可以确保使用更有意义的数据进行训练。这可以通过关注图的关联部分来提高模型的性能。

  • 不同的聚合方法,如平均值、最大值和总和,对模型性能有不同的影响。通过实验多种聚合策略可以帮助捕捉图数据的各种属性,从而可能提高模型性能。

  • 探索更复杂的聚合函数或针对数据集特定特征的定制聚合可以带来性能提升。例如包括SoftmaxAggregationStdAggregation

  • 在图神经网络(GNNs)中,深度与跳跃次数或消息传递步骤的数量相当。虽然更深层次的模型在理论上可以捕捉更复杂的模式,但它们通常会受到过度平滑的影响,节点特征变得过于相似,这使得区分不同节点变得困难。

  • 对不同的聚合方法和模型配置进行实证测试是至关重要的。实验有助于确定哪些方法最能捕捉数据集的关系动态和特征分布。

第四章:图注意力网络

本章涵盖

  • 理解注意力及其在图注意力网络中的应用

  • 了解何时在 PyTorch Geometric 中使用 GAT 和 GATv2 层

  • 通过NeighborLoader类使用小批量处理

  • 在垃圾邮件检测问题中实现和应用图注意力网络层

在本章中,我们通过查看这种模型的特殊变体,即图注意力网络(GAT),扩展了我们关于卷积图神经网络(卷积 GNN)架构的讨论。虽然这些 GNNs 使用的是前一章中介绍的卷积,但它们通过引入注意力机制来扩展这一想法,以突出学*过程中的重要节点[1, 2]。与传统的卷积 GNN 不同,它对所有节点给予相同的权重,注意力机制允许 GAT 学*在训练中额外强调哪些方面。

与卷积一样,注意力是深度学*(除了 GNNs 之外)中广泛使用的机制。依赖于注意力的架构(尤其是变换器)在解决自然语言问题方面取得了如此大的成功,以至于它们现在主导了该领域。在图世界中,注意力是否会产生类似的影响还有待观察。

当处理某些节点比图结构显示的重要性更大时,GATs 表现出色。有时在图中,可能存在一个高度节点,其对整个图的重要性超出了其度数,而传统的消息传递(在前一章中介绍)可能会因为节点的许多邻居而捕捉到其重要性。然而,有时一个节点即使与其他节点的度数相似,也可能产生很大的影响。一些例子包括社交网络,其中网络的一些成员对生成或传播信息和新闻有更大的影响力;欺诈检测,其中一小部分行为者和交易推动了欺骗;以及异常检测,其中一小部分人、行为或事件将超出常规[3–5]。GATs 特别适合这类问题。

在本章中,我们将应用 GATs 到欺诈检测领域。在我们的问题中,我们检测 Yelp 网站上的虚假客户评论。为此,我们使用一个由包含芝加哥地区酒店和餐厅 Yelp 评论的数据集派生出的用户评论网络[6, 7]。

在介绍问题和数据集之后,我们首先在没有图结构的情况下训练一个基线模型,然后再应用两种版本的 GAT 模型来解决这个问题。最后,我们讨论类别不平衡以及一些解决方法。

将使用代码片段来解释过程,但大部分代码和注释可以在存储库中找到。与前面的章节一样,我们在章节末尾的 4.5 节提供了对理论的深入探讨。

注意:本章的代码以笔记本形式存储在 GitHub 仓库中(mng.bz/JYoP)。本章的 Colab 链接和数据可以在同一位置访问。

4.1 检测垃圾邮件和欺诈性评论

在以消费者为导向的网站和电子商务平台,如 Yelp、Amazon 和 Google 商业评论中,用户生成的评论和评分通常伴随着产品或服务的展示和描述。在美国,超过 90%的成年人信任并依赖这些评论和评分来做出购买决策[3]。同时,许多这些评论都是虚假的。Capital One 估计,到 2024 年,30%的在线评论不是真实的[5]。在本章中,我们将训练我们的模型来检测虚假评论。

垃圾邮件或欺诈性评论检测一直是机器学*和自然语言处理(NLP)中的一个热门领域。因此,从主要消费者网站和平台中可以找到几个数据集。在本章中,我们将使用 Yelp.com 的评论数据,这是一个专注于消费者服务的用户评论和评分平台。在 Yelp.com 上,用户可以查找他们附*的本地企业,并浏览有关企业及其用户书面反馈的基本信息。Yelp 使用内部开发的工具和模型根据其可信度过滤评论。我们将使用图 4.1 所示的过程来处理这个问题。

figure

图 4.1 我们将使用非图和图数据来解决欺诈性用户评论分类问题。

首先,我们将使用非 GNN 模型和表格数据建立基线:逻辑回归、XGBoost 和 scikit-learn 的多层感知器(MLP)。然后,我们将应用图卷积网络(GCN)和 GAT 来解决这个问题,引入图结构数据。

这个欺诈性评论问题可以作为一个节点分类问题来解决。我们将使用 GAT 对 Yelp 评论进行节点分类,从合法评论中筛选出欺诈性评论。这种分类是二元的:“垃圾邮件”或“非垃圾邮件”。

我们预期图结构数据和注意力机制将使基于注意力的 GNN 模型更具优势。在本章中,我们将遵循以下过程:

  • 加载数据集并进行预处理

  • 定义基线模型和结果

  • 实现 GAT 解决方案并与基线结果进行比较

4.2 探索评论垃圾邮件数据集

从更广泛的电影评论数据集中提取,我们的数据专注于芝加哥的酒店和餐厅评论。它也已经过预处理,以便数据具有图形结构。这意味着我们将使用 Yelp Multirelational 数据集的专用版本,该版本以其图形结构和其专注于许多芝加哥酒店和餐厅的消费者评论而著称。Yelp Multirelational 数据集是从 Yelp Review 数据集派生出来的,并处理成图形。此数据集包含以下内容(数据集的最终版本总结在表 4.1 中):

  • 45,954 个节点 —每个节点代表一条单独的评论,其中 14.5%被标记为可能欺诈,并由机器人创建以歪曲评论。

  • 预处理节点特征 —我们的节点带有 32 个特征,这些特征已经被归一化,以方便机器学*算法。

  • 3,892,933 条边 —边连接具有共同作者或评论共同业务的评论。虽然原始数据集有多种类型的关联边,但我们使用具有同质边的边以简化分析。

  • 无用户或业务 ID —区分 ID 已被删除。

表 4.1 Yelp Multirelational 数据集概述
将芝加哥的 Yelp Review 数据集处理成图形,节点特征基于评论文本和用户数据
节点数(评论) 45,954
过滤(欺诈)节点 14.5%
节点特征 32
总边数(在我们的分析中假设边是同质的) 3,846,979
具有共同作者的评论 49,315
同一月份在同一业务中撰写的评论 73,616
具有相同评分的同一业务的评论 3,402,743

接下来,表 4.2 显示了此数据集中的文本评论示例,按星级评分系统排序。

表 4.2 YelpChi 数据集中一家餐厅的评论抽样,按评分降序排列(5 分是最高分)
评分(1-5) 日期 评论*
5 7/7/08 完美。Snack 已经成为我最喜欢的晚午餐/早晚餐地点。一定要尝试黄油豆!!!
4 7/1/13 上周五从 Snack 订购了 15 份午餐。准时送达,没有遗漏,食物很棒。我已经将它添加到常规公司午餐名单中,因为每个人都喜欢他们的餐点。
3 12/8/14 Snack 的食物是希腊流行菜肴的选择。开胃小吃盘和希腊沙拉都很好。我们对主菜不太满意。这里有 4-5 张桌子,所以有时很难找到座位。
2 9/10/13 一直想尝试这个地方,一个朋友强烈推荐。点了金枪鱼三明治……很好,但之后感觉非常不舒服。还有,迷迭香茶也很不错。
1 8/12/12 服务平淡,菠菜派湿漉漉的,不热,黄瓜沙拉已经两天了。还是去 Local 吧!
*这些评论中的拼写、语法和标点符号未经校正。

4.2.1 解释节点特征

本数据集的亮点是其节点特征。这些特征是从可用的元数据中提取的,例如评分、时间戳和评论文本。它们分为以下几类:

  • 文本评论的特征

  • 评论者的特征

  • 被评论商业的特征

这些特征随后进一步分为行为和文本特征:

  • 行为特征突出显示评论者的行为和行动模式。

  • 文本特征基于评论中找到的文本。

计算这些特征的过程是由 Rayana 和 Akoglu [7] 以及 Dou [9] 开发的。Dou 在此例中预处理并标准化了我们从 Rayana 和 Akoglu 那里得到的特征数据。特征摘要如图 4.2 所示。(有关定义及其计算方法的更多详细信息,请参阅原始论文 [8]。)以下是对节点特征的总结:

  • 评论者和商业特征:

行为:

    • 每天撰写的最大评论数 (MNR) — 高值表示垃圾邮件。

    • 正面评论比率 (4-5 星) (PR) — 高值表示垃圾邮件。

    • 负面评论比率 (1-2 星) (NR) — 高值表示垃圾邮件。

    • 平均评分偏差 (avgRD) — 高值表示垃圾邮件。

    • 加权评分偏差 (WRD) — 高值表示垃圾邮件。

    • 爆发性 (BST) — 特指用户首次和最后一次评论之间的时间段。高值表示垃圾邮件。

    • 熵值分布熵 (ERD) — 低值表示垃圾邮件。

    • 时间间隔熵 (ETG) — 低值表示垃圾邮件。

文本:

    • 平均评论长度(单词数)(RL) — 低值表示垃圾邮件。

    • 平均/最大内容相似度使用余弦相似度和双词袋方法测量 (ACS, MCS) — 高值表示垃圾邮件。

  • 评论特征:

行为:

    • 产品所有评论中的排名顺序 — 低值表示垃圾邮件。

    • 产品平均评分的绝对评分偏差 (RD) — 高值可疑。

    • 评分极端性 (EXT) — 高值 (4-5 星) 被视为垃圾邮件。

    • 评论评分偏差的阈值 (DEV) — 高偏差可疑。

    • 早期时间段 (ETF) — 出现过早的评论可疑。

    • 单一评论者检测 (ISR) — 如果评论是用户的唯一评论,则标记为可疑。

文本:

    • 全大写单词百分比 (PCW) — 高值可疑。

    • 大写字母百分比 (PC) — 高值可疑。

    • 评论长度(单词数) — 低值可疑。

    • 第一人称代词如“我”、“我的” (PP1) — 低值可疑。

    • 感叹句比率 (RES) — 高值可疑。

    • 主观词比率由 sentiWordNet 检测 (SW) — 高值可疑。

    • 客观词比率由 sentiWordNet 检测 (OW) — 低值可疑。

    • 评论频率使用局部敏感哈希 (F) *似 — 高值可疑。

    • 基于单词和双词的描述长度(DLu, DLb) — 低值可疑。

图 4.2 给出了特征集的总结。

figure

图 4.2 展示了示例中使用的节点特征的总结定义。高标签表示数据的高值表明了垃圾邮件的倾向性。同样,低标签表示数据低值表明了垃圾邮件的倾向性。(关于这些特征的推导细节,请参阅[7])

这种特征组合需要不同程度的直觉来解释。这些特征不仅有助于理解评论者的行为,还有助于推断评论的上下文和本质。很明显,某些特征,如单评论者检测或评论长度(以单词计),可以提供直接的洞察,而其他特征,如时间间隔的熵 Dt,则需要更深入的理解。接下来,让我们检查数据中这些特征的分部。

4.2.2 探索性数据分析

在本节中,我们下载并探索数据集,重点关注节点特征。节点特征将作为我们非图基线模型中的主要表格特征。

数据可以从 Yingtong Dou 的 GitHub 仓库下载(mng.bz/Pdyg),压缩在一个 zip 文件中。解压后的文件将是 MATLAB 格式。使用scipy库中的loadmat函数和 Dou 仓库中的实用函数,我们可以生成开始所需的对象(参见列表 4.1):

  • 一个包含节点特征的features对象

  • 一个包含节点标签的labels对象

  • 一个邻接表对象

列表 4.1 加载数据
prefix = 'PATH_TO_MATLAB_FILE/'

data_file = loadmat(prefix +  'YelpChi.mat')   #1

labels = data_file['label'].flatten()          #2
features = data_file['features'].todense().A   #2

yelp_homo = data_file['homo']            #3
sparse_to_adjlist(yelp_homo, prefix +\
 'yelp_homo_adjlists.pickle')

1 loadmat是 scipy 库中的一个函数,用于加载 MATLAB 文件。

2 分别获取节点标签和特征

3 获取并序列化邻接表。“Homo”表示这个邻接表将基于同质边集;也就是说,我们消除了边的多关系性质。

一旦提取并序列化邻接表,就可以在未来通过以下方式调用它

with open(prefix + 'yelp_homo_adjlists.pickle', 'rb') as file:
    homogenous = pickle.load(file)

数据加载完成后,我们现在可以执行一些探索性数据分析(EDA),以分析图结构和节点特征。

4.2.3 探索图结构

为了更好地理解数据集中的欺诈行为,我们探索了底层图结构。通过分析连通分量和各种图度量,我们可以了解网络的拓扑概览。这种理解将揭示数据固有的特性,并确保没有潜在的阻碍有效 GNN 训练的因素。我们详细分析了连通分量、密度、聚类系数和其他关键指标。

为了执行这种结构化 EDA,我们使用我们的邻接表,通过 NetworkX 库来检查我们图的结构的性质。在下面的代码片段中,我们加载邻接表对象,将其转换为 NetworkX 图对象,然后查询此图对象的基本属性。更长的代码可以在存储库中找到:

with open(prefix + 'yelp_homo_adjlists.pickle', 'rb') as file:
homogenous = pickle.load(file)
g = nx.Graph(homogenous)
print(f'Number of nodes: {g.number_of_nodes()}')
print(f'Number of edges: {g.number_of_edges()}')
print(f'Average node degree: {len(g.edges) / len(g.nodes):.2f}')

通过 EDA,我们获得了表 4.3 中列出的属性。

表 4.3 图属性
属性 值/详情
节点数量 45,954
边数量 3,892,933
平均节点度数 84.71
密度 ~0.00
连通性 图不连通
平均聚类系数 0.77
连接组件数量 26
度分布(前 10 个节点) [4, 4, 4, 3, 4, 5, 5, 6, 5, 19]

让我们深入探讨这些属性。该图相对较大,有 45,954 个节点和 3,892,933 条边。这意味着图具有相当复杂度,可能包含复杂的关系。平均节点度数为 84.71,表明在图中,节点平均连接到大约 85 个其他节点。这表明图中的节点连接得相当好,它们之间可能存在丰富的信息流。图的密度接* 0.00,这表明它相当稀疏。换句话说,实际连接(边)的数量远低于可能连接的数量。图的密度是其边数除以总可能边数。

图不完全连通,由 26 个独立的连接组件组成。多个连接组件的存在可能在建模时需要特别考虑,尤其是如果不同的组件代表不同的数据集群或现象。平均聚类系数为 0.77,相对较高。这个指标给出了图“紧密性”的概念。高值意味着节点倾向于聚集在一起,形成紧密的群体。这可能表明数据中的局部社区或集群,这在理解模式或异常,尤其是在欺诈检测中至关重要。

由于我们有 26 个不同的组件,检查它们对于模型训练计划很重要。我们想知道这些组件的大小是否大致相同,是否是大小混合,或者有一个或两个组件占主导地位。这些单独图的属性有显著差异吗?我们对 26 个组件进行类似的分析,并在表 4.4 中总结属性,组件按节点数量降序排列。第一列包含组件的标识符。从这张表中,我们可以观察到有一个大型组件主导了数据集。

表 4.4 26 个图组件的属性,按节点数量降序排列
组件 ID 节点数量 边数量 平均节点度数 密度 平均聚类系数
3 45,900 38,92810 169.62 0 0.77
4 13 60 9.23 0.77 0.77
2 6 14 4.67 0.93 0.58
1, 22 3 6 4 2 1
5–9, 14, 17, 24, 26 2 3 3 3 0
7–21, 23, 25 1 1 2 0 0
在底部三行中,几个组件具有相同的属性,因此被放在同一行以节省空间。

我们在这里看到,组件 3 是主导组件,其次是 25 个相对较小的组件。这些较小的组件可能对我们的模型影响不大,因此我们将重点关注组件 3。让我们将这个组件与表 4.5 中找到的整体图进行对比,这个组件的许多属性都非常相似或相同,唯一的例外是平均节点度,组件 3 是这个数值的两倍。

表 4.5 比较图的最大组件组件 3 与整体图
属性 组件编号 3 整体图 启示/对比
节点数量 45,900 45,954 组件 3 几乎包含了整个图的所有节点。
边的数量 3,892,810 3,892,933 组件 3 几乎贡献了整个图的所有边。
平均节点度 169.62 84.71 组件 3 中的节点比整体图中的节点连接得更密集。
密度 0.00 0.00 组件和整个图都是稀疏的;这种属性主要是由组件 3 驱动的。
平均聚类系数 0.77 0.77 组件 3 在聚类方面与整体图相匹配,表明它在定义图结构方面的主导地位。

对于我们的 GNN 建模目的,我们应该从这次结构分析中得到什么?主要的是,组件 3 在节点和边方面的压倒性主导地位强调了它在我们的数据集中的重要性;整个图的结构几乎都包含在这个单一组件中。这表明,组件 3 中的模式、关系和异常将严重影响模型的训练和结果。与整体图相比,组件 3 的平均节点度更高,这表明更丰富的相互连接,强调了有效捕捉这些密集连接的重要性。此外,组件 3 与整个图在密度和聚类系数值上的相同性突显出,这个组件高度代表了数据集的整体结构属性。我们有两种选择:

  1. 假设其他组件将对模型产生轻微的影响,并且在不做任何调整的情况下进行训练。

  2. 仅对组件 3 本身进行建模,完全将较小组件的数据排除在训练和测试数据之外。

我们研究了图数据的结构特性,以了解图的特征,并获得了指导 GNN 模型设计和训练以理解潜在欺诈模式的宝贵见解。接下来,我们将深入探讨节点特征。

4.2.4 探索节点特征

在探索了我们的图的结构的性质之后,我们转向节点特征。在本节开头代码中,我们从数据文件中提取了节点特征:

features = data_file['features'].todense().A

注意:如前所述,这些特征定义是由 Rayana 和其他人手工制作的[7, 8]。在特征生成过程的指导下,Dou 等人[8]进行了非平凡的进一步处理 Yelp 评论数据集的工作,以创建一组归一化的节点特征。

通过一些额外的工作,如代码库中所示,我们在为每个特征创建图表分布之前,也为特征添加了一些标签和描述(示例图见 4.3 至 4.5 图)。每一组图对应于描述评论文本、评论者和企业的特征。我们希望使用这些图来检查节点特征是否可以在区分欺诈中发挥作用。图 4.3 显示了从评论的特征中导出的两个特征的分布。

figure

图 4.3 显示了基于评论的 15 个归一化节点特征中的 2 个特征的分布图(参见 4.2.1 节中的特征定义)

图 4.4 显示了从评论者的特征中导出的两个特征分布。

figure

图 4.4 显示了基于评论者的 9 个归一化节点特征中的两个特征的分布图(参见 4.2.1 节中的特征定义)

最后,图 4.5 显示了从被评论的餐厅或酒店的特征中导出的两个特征分布。

figure

图 4.5 显示了基于被评论的企业的 8 个归一化节点特征中的两个特征的分布图(参见 4.2.1 节中的特征定义)

通过检查 32 个节点特征的直方图,我们可以得出几个观察结果。首先,许多特征都存在明显的偏态。具体来说,如排名(Rank)、RD 和 EXT 等特征倾向于右偏态分布。这表明大多数数据点落在直方图的左侧,但少数高值点将直方图拉伸到右侧。相反,如 MNR_user、PR_user 和 NR_user 等其他特征显示左偏态分布。在这些情况下,大多数数据点集中在直方图的右侧,少数低值点将直方图拉伸到左侧。

一些特征也表现出双峰分布,这意味着数据中存在两个不同的峰值或组。这表明对数据进行分段并为每个组创建单独的模型可能是一种有用的策略。

最后,几个直方图中的长尾表明存在一些异常值。鉴于某些模型,如线性回归,对极端值非常敏感,解决这些异常值可能对我们的模型优化和改进至关重要。这可能意味着选择抗异常值模型,制定减轻其影响的策略,甚至完全删除它们。

在那些一般见解的基础上,让我们更仔细地检查一个特征图。PP1 是评论中第一人称代词(即我、我们、我们的等)与第二人称代词(你、你的等)的比例。这个特征是由于观察到垃圾邮件通常包含更多的第二人称代词而开发的。从 PP1 的分布图中,我们观察到分布是偏左的,尾部在低值处达到峰值。因此,如果低比例是垃圾邮件的指标,这个特征将很好地区分垃圾邮件。

为了总结我们对节点特征的探索,这些数据表现出多种特性,为模型训练提供了许多机会。进一步的前处理,可能包括异常值处理、偏斜特征转换、数据分段和特征缩放,可能对优化模型的预测性能至关重要。

我们对评论垃圾邮件数据集的探索揭示了某些模式、异常和见解。从数据集的复杂结构特征来看,主要由主要成分 3 表示,到提供区分真实和欺诈评论的潜在指示的节点特征,我们已经为我们的模型训练奠定了基础。

在第 4.3 节中,我们将开始训练我们的基线模型。这些初始模型作为基础,帮助我们评估基本模型性能的有效性。通过这些模型,我们将利用数据的图结构和节点特征来区分欺诈和垃圾邮件与真实评论。

4.3 训练基线模型

考虑到我们的数据集,我们将首先开发三个基线模型:逻辑回归、XGBoost 和一个 MLP。请注意,对于这些模型,数据将以表格格式呈现,节点特征作为我们的列特征。我们的图数据集的每个节点将有一个行或观察值。接下来,我们将通过训练一个 GCN 来开发一个额外的 GNN 基线,以评估引入图结构数据到我们问题中的影响。

我们现在将我们的表格数据分为测试集和训练集,并应用三个基线模型。首先,进行测试/训练分割:

from sklearn.model_selection import train_test_split 
split = 0.2
xtrain, xtest, ytrain, ytest = train_test_split\
(features, labels, test_size = \
split, stratify=labels, random_state = 99)   #1

print(f'Required shape is {int(len(features)*(1-split))}')   #2
print(f'xtrain shape = {xtrain.shape}, \
xtest shape = {xtest.shape}')                               
print(f'Correct split = {int(len(features)*(1-split))\
 == xtrain.shape[0]}')

1 将数据分为测试集和训练集,比例为 80/20

2 两次检查对象形状

我们可以使用这些分割数据为三个模型中的每一个。对于这次训练,我们只使用节点特征和标签。没有使用图数据结构或几何形状。对于基线模型和 GNN,我们将主要依靠受试者工作特征(ROC)和曲线下面积(AUC)来衡量性能,并比较我们的 GAT 模型的性能。

4.3.1 非图神经网络(GNN)基线

我们首先使用 scikit-learn 的实现和默认超参数的逻辑回归模型:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.metrics import roc_auc_score, f1_score

clf = LogisticRegression(random_state=0)\
.fit(xtrain, ytrain)   #1
ypred = clf.predict_proba(xtest)[:,1]
acc = roc_auc_score(ytest,ypred)   #2

print(f"Model accuracy (logression) = {100*acc:.2f}%")

1 实例化和训练逻辑回归模型

2 准确度得分

该模型产生了 76.12% 的 AUC。对于 ROC 性能,我们还将使用 scikit-learn 中的一个函数。我们还将回收真实阳性率(tpr)和假阳性率(fpr)来与我们的其他基线模型进行比较:

from sklearn.metrics import roc_curve 
fpr, tpr, _ = roc_curve(ytest,ypred)   #1

plt.figure(1)
plt.plot([0, 1], [0, 1])
plt.plot(fpr, tpr)
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.show()

1 计算 ROC 曲线,得到假阳性率(fpr)和真阳性率(tpr)

在图 4.6 中,我们看到 ROC 曲线。我们发现曲线在假阳性和假阴性之间相对平衡,但考虑到它接*对角线,整体特异性相当差。

figure

图 4.6 显示了逻辑回归基线模型(橙色线)和机会线(蓝色对角线)。76% 的 AUC 表明模型还有改进的空间。

XGBoost

XGBoost 基线遵循逻辑回归,如列表 4.2 所示。我们使用了一个裸机模型,具有相同的训练和测试集。为了比较,我们区分了生成的预测名称(命名为 pred2)、真实阳性率(tpr2)和假阳性率(fpr2)。

列表 4.2 XGBoost 基线和绘图
import xgboost as xgb
xgb_classifier = xgb.XGBClassifier()

xgb_classifier.fit(xtrain,ytrain)
ypred2 = xgb_classifier.predict_proba(xtest)[:,1]   #1
acc = roc_auc_score(ytest,ypred2)

print(f"Model accuracy (XGBoost) = {100*acc:.2f}%")

fpr2, tpr2, _ = roc_curve(ytest,ypred2)   #2

plt.figure(1)
plt.plot([0, 1], [0, 1])
plt.plot(fpr, tpr)
plt.plot(fpr2, tpr2)                     
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.show()

1 为了比较,我们将 XGBoost 预测命名为“ypred2”。

2 为了比较,我们区分了 XGBoost 的 tpr 和 fpr,并将它们与逻辑回归结果并排绘制。

图 4.7 显示了 XGBoost 和逻辑回归的 ROC 曲线。很明显,XGBoost 在这个指标上具有优越的性能。

figure

图 4.7 显示了 XGBoost(虚线)和逻辑回归曲线(实线)。我们看到 XGBoost 曲线比逻辑回归曲线表现更好。对角线是机会线。

XGBoost 在此数据上比逻辑回归表现更好,产生了 94% 的 AUC,并具有更优越的 ROC 曲线。这表明即使是简单的模型也可能适用于某些问题,检查性能始终是一个好主意。

多层感知器

对于 MLP 基线,我们使用 PyTorch 构建了一个简单、三层模型,如列表 4.3 所示。与 PyTorch 类似,我们通过一个类来建立模型,定义层和前向传递。在 MLP 中,我们使用二元交叉熵(BCE)作为损失函数,这在二元分类问题中是常用的。

列表 4.3 MLP 基线模型和绘图
import torch   #1
import torch.nn as nn
import torch.nn.functional as F

class MLP(nn.Module):   #2
    def __init__(self, in_channels, out_channels, hidden_channels=[128,256]):
        super(MLP, self).__init__()
        self.lin1 = nn.Linear(in_channels,hidden_channels[0])
        self.lin2 = nn.Linear(hidden_channels[0],hidden_channels[1])
        self.lin3 = nn.Linear(hidden_channels[1],out_channels)

    def forward(self, x):
        x = self.lin1(x)
        x = F.relu(x)
        x = self.lin2(x)
        x = F.relu(x)
        x = self.lin3(x)
        x = torch.sigmoid(x)

        return x

model = MLP(in_channels = features.shape[1],\
 out_channels = 1)   #3

epochs = 100   #4
lr = 0.001
wd = 5e-4
n_classes = 2
n_samples = len(ytrain)

w= ytrain.sum()/(n_samples - ytrain.sum())   #5

optimizer = torch.optim.Adam(model.parameters()\
,lr=lr,weight_decay=wd)   #6
criterion = torch.nn.BCELoss()   #7

xtrain = torch.tensor(xtrain).float()   #8
ytrain = torch.tensor(ytrain)

losses = []

for epoch in range(epochs):  #9
    model.train()
    optimizer.zero_grad()
    output = model(xtrain)
    loss = criterion(output, ytrain.reshape(-1,1).float())
    loss.backward()
    losses.append(loss.item())

    ypred3 = model(torch.tensor(xtest,dtype=torch.float32))

    acc = roc_auc_score(ytest,ypred3.detach().numpy())
    print(f'Epoch {epoch} | Loss {loss.item():6.2f}\
    | Accuracy = {100*acc:6.3f}% | # True\ Labels = \
    {ypred3.detach().numpy().round().sum()}', end='\r')

    optimizer.step()

fpr, tpr, _ = roc_curve(ytest,ypred)
fpr3, tpr3, _ = roc_curve(ytest,ypred3.detach().numpy())   #10

plt.figure(1)   #11
plt.plot([0, 1], [0, 1])
plt.plot(fpr, tpr)
plt.plot(fpr2, tpr2)
plt.plot(fpr3, tpr3)
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.show()

1 导入本节所需的包

2 使用类定义 MLP 架构

3 实例化定义的模型

4 设置关键超参数

5 考虑到类别不平衡

6 定义了优化器和训练标准

7 使用 BCE 损失作为损失函数

8 将训练数据转换为 torch 数据类型:torch 张量

9 训练循环。在这个例子中,我们指定了 100 个 epoch。

10 区分 tpr 和 fpr 进行比较

11 绘制所有三个 ROC 曲线

图 4.8 显示了逻辑回归、XGBoost 和 MLP 的 ROC 结果。

图

图 4.8 展示了所有三个基线模型的 ROC 曲线。逻辑回归和 MLP 的曲线重叠。XGBoost 模型在此指标上表现出最佳性能。对角线是机会线。

MLP 运行 100 个 epoch,在基线中达到 85.9%的准确率。其 ROC 曲线仅略优于逻辑回归模型。这些结果总结在表 4.6 中。

表 4.6 三种基线模型的日志损失和 ROC AUC
模型 日志损失 ROC AUC
逻辑回归 0.357 75.90%
XGBoost 0.178 94.17%
多层感知器 0.295 85.93%

总结本节,我们运行了三个基线模型作为我们的 GNN 模型的基准。这些基线没有使用结构化图数据,只使用了一组从节点特征派生出的表格特征。我们没有尝试优化这些模型,XGBoost 最终以 89.25%的准确率表现最佳。接下来,我们将使用 GCN 训练另一个基线,然后应用 GAT。

4.3.2 GCN 基线

在本节中,我们将应用 GNN 到我们的问题上,从第三章中的 GCN 开始,然后转向 GAT 模型。我们预计我们的 GNN 模型将由于图结构数据而优于其他基线,并且具有注意力机制的模型将表现最佳。对于 GNN 模型,我们需要对我们的管道进行一些修改。这其中的很多都与数据预处理和数据加载有关。

数据预处理

第一个关键步骤是为我们的 GNN 准备数据。这遵循了第二章和第三章中已经介绍的一些内容。这段代码在列表 4.4 中提供,我们采取以下步骤:

  • 建立训练/测试分割。 我们使用之前相同的test_train_split函数,稍作调整以生成索引,并且我们只保留生成的索引。

  • 将我们的数据集转换为 PyG 张量。 为此,我们从一个早期部分生成的同构邻接列表开始。使用 NetworkX,我们将其转换为 NetworkX graph对象。从那里,我们使用 PyG 的from_networkx函数将其转换为 PyG data对象。

  • 将训练/测试分割应用于转换后的数据对象。 为此,我们使用第一步中的索引。

我们希望展示多种安排训练数据以供摄入的方式。因此,对于 GCN,我们将整个数据集通过模型运行,而在 GAT 示例中,我们将训练数据进行分批。

列表 4.4 转换训练数据的数据类型
from torch_geometric.transforms import NormalizeFeatures

split = 0.2                                        #1
indices = np.arange(len(features))                 #1
xtrain, xtest, ytrain, ytest, idxtrain, idxtest\
 = train_test_split(features labels,indices, \
stratify=labels, test_size = split, \
random_state = 99)                                 #2

g = nx.Graph(homogenous)                                            #3
print(f'Number of nodes: {g.number_of_nodes()}')
print(f'Number of edges: {g.number_of_edges()}')
print(f'Average node degree: {len(g.edges) / len(g.nodes):.2f}')
data = from_networkx(g)                                            
data.x = torch.tensor(features).float()                            
data.y = torch.tensor(labels)                                      
data.num_node_features = data.x.shape[-1]                          
data.num_classes = 1 #binary classification                        

A = set(range(len(labels)))                                  #4
data.train_mask = torch.tensor([x in idxtrain for x in A])   #4
data.test_mask = torch.tensor([x in idxtest for x in A])     #4

1 建立训练/测试分割。我们将只使用索引变量。

2 建立训练/测试分割。我们将只使用索引变量。

3 将邻接表转换为 PyG 数据对象

4 在数据对象中建立训练/测试分割

预处理完成后,我们准备应用 GCN 和 GAT 解决方案。我们在第三章详细介绍了 GCN 架构。在列表 4.5 中,我们建立了一个两层 GCN,在 1,000 个 epoch 上训练。我们选择两层是因为第三章的见解,一般来说,低模型深度可以提高性能并防止过平滑。

列表 4.5 GCN 定义和训练
class GCN(torch.nn.Module):       #1
    def __init__(self, hidden_layers = 64):
        super().__init__()
        torch.manual_seed(2022)
        self.conv1 = GCNConv(data.num_node_features, hidden_layers)
        self.conv2 = GCNConv(hidden_layers, 1)
    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 torch.sigmoid(x)

device = torch.device("cuda"\
 if torch.cuda.is_available() \
else "cpu")              #2
print(device)
model = GCN()
model.to(device)
data.to(device)

lr = 0.01
epochs = 1000

optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=5e-4)
criterion = torch.nn.BCELoss()

losses = []
for e in range(epochs):      #3
    model.train()
    optimizer.zero_grad()
    out = model(data)                          #4

    loss = criterion(out[data.train_mask], \
    data.y[data.train_mask].\
    reshape(-1,1).float())                    
    loss.backward()
    losses.append(loss.item())

    optimizer.step()

    ypred = model(data).clone().cpu()
    pred = data.y[data.test_mask].clone().cpu().detach().numpy()
    true = ypred[data.test_mask].detach().numpy()
    acc = roc_auc_score(pred,true)

    print(f'Epoch {e} | Loss {loss:6.2f} \
    | Accuracy = {100*acc:6.3f}% \
    | # True Labels =\ {ypred.round().sum()}')
fpr, tpr, _ = roc_curve(pred,true)      #5

1 定义了两层 GCN 架构

2 实例化模型并将模型和数据放在 GPU 上

3 训练循环

4 对于每个 epoch,我们将整个数据对象通过模型,然后使用训练掩码来计算损失。

5 计算假阳性率(fpr)和真阳性率(tpr)

应用解决方案

值得注意的是我们训练中使用了掩码。虽然我们使用训练掩码中的节点来建立损失,但在前向传播时,我们必须将整个图通过模型传递。为什么是这样?与在独立数据点(例如表格数据集的行)上工作的传统机器学*模型不同,GNNs 在图结构数据上操作,其中节点之间的关系至关重要。在训练 GCN 时,每个节点的嵌入是基于其邻居的信息更新的。因为这个消息传递过程涉及到从节点的局部邻域中聚合信息,所以模型需要访问整个图结构,以便它可以正确准确地计算这些聚合并有效地执行此过程。

因此,在训练过程中,尽管我们只对某些节点的预测感兴趣(那些在训练集中的节点),但通过将整个图通过模型传递确保了考虑了所有必要上下文。如果只将图的一部分通过模型传递,网络将缺乏传播消息和有效更新节点表示所需的所有完整信息。

GCN 的 100 个 epoch 的训练会得到 94.37%的准确率。通过引入图数据,我们看到了与 XGBoost 模型相比的渐进改进。表 4.7 比较了模型性能水平。

表 4.7 四个基线模型的 AUC
模型 AUC
逻辑回归 75.90%
XGBoost 94.17%
多层感知器 85.93%
GCN 94.37%

总结来说,我们已看到,使用 GNN 模型包含图结构信息与仅基于特征或表格模型相比,略微提高了性能。很明显,XGBoost 模型即使没有使用图结构也展示了令人印象深刻的成果。然而,GCN 模型略微更好的性能凸显了 GNN 在利用图数据中嵌入的关系信息方面的潜力。

在我们研究的下一阶段,我们的注意力将转向图注意力网络(GATs)。GATs 具有专门针对在消息传递步骤中如何权衡邻居重要性进行学*的注意力机制。这可能会提供更好的模型性能。在下一节中,我们将深入了解训练 GAT 模型的细节,并将它们的成果与我们已经建立的基线进行比较。让我们继续进行 GAT 模型的训练。

4.4 训练 GAT 模型

为了训练我们的 GAT 模型,我们将应用两种 PyG 实现(GAT 和 GATv2)[2]。在本节中,我们将直接进入模型的训练过程,而不讨论对于机器学*模型来说注意力意味着什么以及为什么它是有帮助的。然而,关于注意力及其为何可能就是你所需要的简要概述,请参阅第 4.5 节。

我们将训练两种不同的 GAT 模型。这两个模型都遵循相同的基本思想——我们将用注意力机制替换我们的 GCN 中的聚合操作,以学*模型应该最关注哪些消息(节点特征)。第一个——GATConv——是对第三章中 GCN 的简单扩展,加入了注意力机制。第二个是对此模型稍作修改的 GATv2Conv。这个模型与 GATConv 相同,除了它解决了原始实现中的一个限制,即注意力机制在单个 GNN 层上是静态的。相反,对于 GATv2Conv,注意力机制在层之间是动态的。

再次强调,原始的 GAT 模型仅在每次训练循环中通过使用单个节点和邻域特征来计算注意力权重一次,并且这些权重在所有层中都是静态的。在 GATv2 中,注意力权重是在节点特征通过层变换时计算的。这允许 GATv2 更具表现力,学*在整个训练模型中强调节点邻域的影响。

由于引入了注意力机制,这两个模型引入了显著的计算开销。为了解决这个问题,我们在训练循环中引入了小批量处理。

4.4.1 邻域加载器和 GAT 模型

从实现的角度来看,先前研究的卷积模型和我们的 GAT 模型之间有一个关键的区别,那就是 GAT 模型的内存需求要大得多 [9]。这是因为 GAT 需要对每个注意力头和每条边进行注意力分数的计算。这反过来又需要 PyTorch 的 autograd 方法在内存中保留可以显著扩展的张量,这取决于边的数量、头的数量和(两倍)节点特征的数量。

为了解决这个问题,我们可以将我们的图分成批次,并将这些批次加载到训练循环中。这与我们之前对 GCN 模型的做法形成对比,我们当时在一个单独的批次(整个图)上训练。PyG 的 NeighborLoader(在其 dataloader 模块中)允许这种小批量训练,我们在这个列表 4.6 中提供了相应的实现代码。(PyG 函数 NeighborLoader 基于“在大型图上的归纳表示学*”论文 [10]。)NeighborLoader 的关键输入参数是

  • num_neighbors——将被采样的邻居节点数量,乘以迭代次数(即 GNN 层)。在我们的例子中,我们指定在两次迭代中采样 1,000 个节点。

  • batch_size——每个批次选择的节点数量。在我们的例子中,我们将批量大小设置为 128

列表 4.6 为 GAT 设置 NeighborLoader
from torch_geometric.loader import NeighborLoader

batch_size = 128
loader = NeighborLoader(
    data,
    num_neighbors=[1000]*2,   #1
    batch_size=batch_size,   #2
    input_nodes=data.train_mask)

sampled_data = next(iter(loader))
print(f'Checking that batch size is \
{batch_size}: {batch_size == \
sampled_data.batch_size}')
print(f'Percentage fraud in batch: \
{100*sampled_data.y.sum()/\
len(sampled_data.y):.4f}%')
sampled_data

1 在两次迭代中为每个节点采样 1,000 个邻居

2 使用批量大小来采样训练节点

在创建我们的 GAT 模型时,相对于我们的 GCN 类别,有两个关键的改变。首先,因为我们是在批量训练,所以我们想应用一个批量归一化层。批量归一化是一种用于将神经网络中每一层的输入归一化到均值为 0 和标准差为 1 的技术。这有助于通过减少内部协变量偏移来稳定和加速训练过程,允许使用更高的学*率,并提高模型的总体性能。

其次,我们注意到我们的 GAT 层有一个额外的输入参数——heads——它表示多头注意力的数量。在我们的例子中,我们的第一个 GATConv 层有两个头,如列表 4.7 所指定。

第二个 GATConv 层,即输出层,有一个头。在这个 GAT 模型中,因为我们希望最终层对每个节点都有一个单一的表现形式来完成我们的任务,所以我们使用一个头。多个头会导致输出混乱,出现多个节点表现形式。

列表 4.7 基于 GAT 的架构
class GAT(torch.nn.Module):
    def __init__(self, hidden_layers=32, heads=1, dropout_p=0.0):
        super().__init__()
        torch.manual_seed(2022)
        self.conv1 = GATConv(data.num_node_features,\
 hidden_layers, heads, dropout=dropout_p)                            #1
        self.bn1 = nn.BatchNorm1d(hidden_layers*heads)     #2
        self.conv2 = GATConv(hidden_layers * heads, \
1, dropout=dropout_p)                                               

    def forward(self, data, dropout_p=0.0):
        x, edge_index = data.x, data.edge_index
        x = self.conv1(x, edge_index)
        x = self.bn1(x)                                   
        x = F.relu(x)
        x = F.dropout(x, training=self.training)
        x = self.conv2(x, edge_index)

        return torch.sigmoid(x)

1 GAT 层有一个 heads 参数,它决定了每一层中的注意力机制数量。在这个实现中,第一层(conv1)使用多个头进行更丰富的特征提取,而最终的输出层(conv2)使用一个头将学*到的信息聚合为每个节点的单一输出。

2 因为正在执行小批量训练,所以添加了一个批量归一化层。

我们对 GAT 的训练程序与单批次的 GCN 相似,我们将在以下列表中提供,但现在我们需要为每个批次进行嵌套循环。

列表 4.8 GAT 的训练循环
lr = 0.01
epochs = 1000

model = GAT(hidden_layers = 64,heads=2)
model.to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=lr,weight_decay=5e-4)
criterion = torch.nn.BCELoss()

losses = []
for e in range(epochs):    
    epoch_loss = 0.
    for i, sampled_data in enumerate(loader):  #1
        sampled_data.to(device)
        model.train()
        optimizer.zero_grad()
        out = model(sampled_data)
        loss = criterion(out[sampled_data.train_mask],\ 
sampled_data.y[sampled_data.train_mask].\
reshape(-1,1).float())
loss.backward()
epoch_loss += loss.item()

        optimizer.step()

        ypred = model(sampled_data).clone().cpu()
        pred = sampled_data.y[sampled_data.test_mask]\
.clone().cpu().detach().numpy()
        true = ypred[sampled_data.test_mask].detach().numpy()
        acc = roc_auc_score(pred,true)    
    losses.append(epoch_loss/batch_size)

    print(f'Epoch {e} | Loss {epoch_loss:6.2f}\
    | Accuracy = {100*acc:6.3f}% | 
    # True Labels = {ypred.round().sum()}')

1 用于迷你批次的嵌套循环。这里的每次迭代都是通过 NeighborLoader 加载的节点批次。

之前概述的步骤对 GATv2Conv 相同,可以在我们的存储库中找到。训练 GATConv 和 GATv2Conv 分别产生 95.65%和 95.10%的准确率。如表 4.8 所示,我们的 GAT 模型优于基线模型和 GCN。图 4.9 显示了 GCN 和 GAT 模型的 ROC 结果。图 4.10 显示了 GCN、GAT 和 GATv2 模型的 ROC 结果。

表 4.8 模型的 ROC AUC
模型 ROC AUC (%)
逻辑回归 75.90
XGBoost 94.17
多层感知器 85.93
GCN 94.37
GAT 95.65
GATv2 95.10

figure

图 4.9 GCN 和 GATConv 的 ROC 曲线。GATConv 模型在此指标上表现出最佳性能,因为它具有更高的 AUC,并且其假阳性率明显较低。对角线是机会线。

figure

图 4.10 GCN、GATConv 和 GATv2 的 ROC 曲线。GAT 模型都优于 GCN。GATv2 具有与 GAT 相同的更高的假阳性特征,但具有相似的真实阳性率。

在观察 ROC 曲线时,我们看到 GAT 模型都优于 GCN。我们还看到两者都有更好的假阳性率。这对于欺诈/垃圾邮件检测至关重要,因为假阳性可能导致真正的交易/用户被错误标记,造成不便和信任损失。对于 GATv2,我们注意到在真正阳性率方面,其性能与 GCN 和 GAT 相同。这表明,虽然它在避免将真实交易错误标记为欺诈方面比较保守,但它可能错过一些实际的欺诈行为。这些见解可以导致改进模型或做出决策的路径。尽管 AUC 曲线和得分很有利,但我们必须解决影响我们 GAT 模型可用性的一个最终问题:类别不平衡。

4.4.2 解决模型性能中的类别不平衡问题

类别不平衡是 GNN 问题中的一个关键挑战,其中少数类(通常代表罕见但重要的实例,例如欺诈活动)与多数类相比代表性显著不足。在我们的数据集中,只有 14.5%的节点被标记为欺诈,这使得模型难以从这些稀疏数据中有效学*。虽然高 AUC 分数可能表明整体性能良好,但它们可能是误导性的,掩盖了对少数类的性能不足,而少数类对于平衡评估至关重要。更深入的分析揭示了一个关键疏忽:类别不平衡严重影响了我们的精确度和 F1 分数。

为了应对这一挑战,已经开发出针对 GNN 的几种专门方法来解决类别不平衡问题。传统的技术,如合成少数类过采样技术(SMOTE),已被改编为创建特定于图的图 SMOTE 方法,该方法生成合成节点和边以平衡类别分布,同时不破坏图结构。其他方法包括重采样技术(包括过采样和欠采样)、成本敏感学*、架构修改以及关注少数类特征的注意力机制 [11, 12]。

虽然这些方法有助于提高模型性能,但它们也带来了独特的挑战,例如保留图的拓扑结构、维护节点依赖性以及确保可扩展性。最*的发展,如图图神经网络(G2GNN),已被开发出来更有效地处理这些问题。通过理解和应用这些策略,我们可以增强 GNN 模型在实际应用中的鲁棒性和公平性,在这些应用中,类别不平衡是一个常见问题。以上一节中的 GATv2 模型为例,我们在表 4.9 中比较了其 F1、召回率和精度与 XGBoost 的比较。XGBoost 具有优越的性能,而 GATv2 在处理不平衡数据方面存在困难。

表 4.9 比较本章训练的 GATv2 和 XGBoost 模型的 F1、召回率和精度
指标 GATv2 XGBoost
F1 分数 0.254 0.734
精确度 0.145 0.855
召回率 1 0.643

GATv2 模型的性能反映了在具有显著类别不平衡的场景中面临的常见挑战。由于少数类仅占数据的 14.5%,该模型强调最大化召回率,实现了完美的召回率分数 1.000。这表明模型正确识别了所有少数类的实例,避免了任何可能至关重要的案例的遗漏检测。然而,这以牺牲精度为代价,精度显著低至 0.145。这表明虽然 GAT 在检测所有真阳性方面有效,但它也将许多负例错误地分类为阳性,导致大量假阳性。因此,反映精确度和召回率的 F1 分数低至 0.254,突显了模型在平衡检测与准确性方面的低效。

为了缓解这一问题,我们实施了两种旨在减轻类别不平衡的策略:图 4.11 中所示的 SMOTE 和图 4.12 中所示的定制洗牌方法。

figure

图 4.11 SMOTE 的示意图,它通过上采样少数类来寻求提供更平衡的数据集。在左侧,我们从原始数据集开始。在中间,SMOTE 在少数类中创建合成数据。在右侧,将合成数据添加到少数类后,数据集更加平衡。

使用 SMOTE 生成合成节点,反映原始数据集的平均度特征,并人为增强少数类的表示。重排方法采取了不同的方法,通过避免生成合成数据。相反,它通过在批次之间重新分配多数类数据来确保每个训练批次中类别的平衡表示。这是通过使用 BalancedNodeSampler 类来实现的,该类保证每个批次都有来自多数类和少数类的节点数量相等。对于每个批次,采样器随机选择一个平衡的节点集,提取相应的子图,并重新索引节点以保持一致性。图 4.12 中展示了这个过程的一个典型批次重新分配。该类在列表 4.9 中显示。

图

图 4.12 使用 100 个数据点的示例说明重排方法,其中多数类有 76 个,少数类有 24 个。在创建训练批次时,每个批次都包含多数类和少数类相等的部分。
列表 4.9 BalancedNodeSampler
class BalancedNodeSampler(BaseSampler): 
    def __init__(self, data, num_samples=None):
        super().__init__()
        self.data = data  
        self.num_samples = num_samples    #1

    def sample_from_nodes(self, index, **kwargs):
        majority_indices = torch.\
where(self.data.y == 0)[0]    #2
        minority_indices = torch.\
where(self.data.y == 1)[0]    #3

        if self.num_samples is None:
            batch_size = min(len(majority_indices),\
 len(minority_indices))    #4
        else:
            batch_size = self.num_samples // 2 

        majority_sample = majority_indices[torch.randperm\
(len(majority_indices))[:batch_size]]                        #5
        minority_sample = minority_indices[torch.randint\
(len(minority_indices), (batch_size,))]                     
        batch_indices = torch.cat\
((majority_sample, minority_sample))   #6

        mask = torch.zeros(self.data.num_nodes, dtype=torch.bool)
        mask[batch_indices] = True    #7
        row, col = self.data.edge_index 
        mask_edges = mask[row] & mask[col]    #8
        sub_row = row[mask_edges] 
        sub_col = col[mask_edges] 

        new_index = torch.full((self.data.num_nodes,), -1, dtype=torch.long)
        new_index[batch_indices] = \
torch.arange(batch_indices.size(0))    #9
        sub_row = new_index[sub_row] 
        sub_col = new_index[sub_col] 

        return SamplerOutput(
            node=batch_indices,
            row=sub_row,
            col=sub_col,
            edge=None,  
            num_sampled_nodes=[len(batch_indices)],  
            metadata=(batch_indices, None)
        )

1 可选:为每个类别定义固定的采样大小

2 多数类的索引

3 少数类的索引

4 确定平衡的批量大小

5 随机选择两个类别的节点

6 将两个类别的样本合并到一个批次中

7 为采样节点创建掩码

8 过滤采样节点之间的边

9 重新索引采样节点

在这种情况下,SMOTE 没有带来性能提升。因此,我们将关注应用重排方法的结果。表 4.10 中的指标表明,我们的干预不仅提高了模型的公平性,而且通过更好地捕捉少数类而不会牺牲整体精度,增强了模型的鲁棒性。虽然重排方法的 AUC 不超过 XGBoost(94.17%),但它以优越的 F1、精确率和召回率很好地处理了类别不平衡。

表 4.10 使用类重排方法训练的 GATv2 模型的 F1、精确率、召回率和 AUC 对比
指标
平均验证 F1 分数 0.809
平均验证精确率 0.878
平均验证召回率 0.781
平均验证 AUC 0.914

4.4.3 决定使用 GAT 还是 XGBoost

在使用 XGBoost 和 GATs 之间的选择应该根据具体的用例需求和约束来决定。XGBoost 提供了效率和速度,这对于计算资源有限的项目或需要快速模型训练的项目来说是有利的。然而,GATs 提供了深度集成节点关系数据的额外好处,这对于节点关系对于理解复杂数据模式至关重要的项目是必不可少的。

GATs 特别有价值,因为它们能够集成到更广泛的深度学*框架中,提供封装了丰富上下文信息的节点嵌入,因此适合复杂的关系数据集。

我们对解决类别不平衡的方法的探索,极大地丰富了我们对模型在实际场景中性能的理解。这些见解对于开发稳健且有效的模型至关重要,特别是在精度和召回率需要平衡的关键领域。在下一节(可选)中,我们将更深入地探讨 GATs 背后的概念。

4.5 内部机制

在本节中,我们讨论了关于注意力和 GATs 的一些附加细节。这是为那些想要了解内部机制的人提供的,但如果你更感兴趣于学*如何应用模型,你可以安全地跳过这一节。我们深入到 GAT 论文[8]中的方程,并从更直观的角度解释注意力。

4.5.1 解释注意力和 GAT 模型

在本节中,我们提供了一个关于注意力机制的概述。解释了注意力、自注意力和多头注意力等概念。然后,将 GATs 定位为卷积 GNNs 的扩展。

概念 1:各种注意力机制类型

注意力是过去十年中引入深度学*中最重要概念之一。它是现在著名的、由注意力机制驱动的转换器模型的基础,该模型推动了诸如大型语言模型(LLMs)等生成模型中的许多突破。注意力是模型学*在其训练中哪些方面需要额外重视的机制[13, 14]。模型中有哪些不同类型的注意力?

注意力

想象你正在阅读一部小说,其情节不是线性的,而是跳跃式的,连接着各种人物、事件,甚至平行故事线。在阅读关于特定角色的章节时,你会回忆并考虑书中其他部分,该角色曾出现或被提及的地方。在任何给定时刻,你对这个角色的理解都会受到这些不同部分的影响。

在深度学*和图神经网络(GNNs)中,注意力机制发挥着类似的作用。在处理自然语言处理(NLP)问题中的句子时,注意力意味着模型可以学*邻*词语的重要性。对于一个考虑图中特定节点的 GNN,模型使用注意力来权衡邻*节点的重要性。这有助于模型在尝试理解当前节点时决定哪些邻*节点最为相关,类似于你如何通过记住书中相关的部分来更好地理解一个角色。

自注意力

想象一下在小说中阅读一句话,这句话提到了多个角色和事件,其中一些以复杂的方式相互关联。要完全理解这句话,你必须回忆起每个角色和事件是如何相互关联的,所有这些都在这句话的范围内。你可能会发现自己更多地关注那些对理解你正在阅读的句子的上下文至关重要的角色或事件。

对于使用自注意力的 GNN,图中的每个节点不仅考虑其直接邻居,还考虑其自身的特征和在图中的位置。通过这样做,每个节点都会接收到一个新的表示,该表示受其自身和其他节点的加权上下文的影响,这有助于需要理解复杂图中节点之间关系的任务。

多头注意力

假设你是读书俱乐部的一员,俱乐部正在阅读小说,每个成员都被要求关注小说的不同方面——一个关注人物发展,另一个关注情节转折,还有一个关注主题元素。当你们聚在一起讨论时,你们对这本书的理解就变得多方面了。

类似地,在 GNN 中,多头注意力允许模型具有多个“头”或注意力机制,关注邻居节点的各个方面或特征。这些不同的头可以学*图中的不同模式或关系,它们的输出通常被聚合,以形成一个更完整的理解,了解每个节点在更大图中的作用。

概念 2:GATs 作为卷积 GNN 的变体

GATs 通过结合注意力机制扩展了卷积 GNN。在传统的卷积 GNN,如 GCNs 中,在消息传递步骤中,所有邻居的贡献在聚合时是等权重的。然而,GATs 在聚合函数中添加了注意力分数来权衡这些贡献。这仍然是排列不变的(按设计),但比 GCNs 中的求和操作更具描述性。

PyG 实现

PyG 提供了两种 GAT 层的版本。这两种版本的区别在于使用的注意力和注意力分数的计算:

  • GATConv—基于 Veličković的论文[1],这一层在整个图上使用自注意力来计算注意力分数。它还可以配置为使用多头注意力,从而使用多个“头”来关注输入节点的各个方面。

  • GATv2Conv—这一层通过引入动态注意力对 GATConv 进行了改进。在这里,节点特定的上下文中,在层之间重新计算自注意力分数,使得模型在如何学*在每个 GNN 层的消息传递步骤中构建节点表示的权重方面更具表现力。与GATConv一样,它支持多头注意力,以更有效地捕捉各种特征或方面。

与其他卷积 GNN 的权衡

在 PyG 中实现时,由于使用了注意力,GAT 层具有优势。然而,需要考虑性能权衡。需要考虑的关键因素是:

  • 性能——GATs 通常比标准的卷积 GNNs 有更高的性能,因为它们可以关注最相关的特征。

  • 训练时间——由于计算注意力机制的增加复杂性,提高性能需要更多的时间来训练模型。

  • 可扩展性——计算成本也影响了可扩展性,使得 GATs 不太适合非常大的或密集的图。

4.5.2 过度平滑

你已经学会了如何更改消息传递步骤中使用的聚合操作,以包括更复杂的方法,例如注意力机制。然而,在应用多轮消息传递时,总是存在性能退化的风险。这种效应被称为 过度平滑,因为它在经过多轮消息传递 [15] 后,更新的特征可能会收敛到相似值。这种效应的示例如图 4.13 所示。

figure

图 4.13 基于改变节点特征的过度平滑示例

如我们所知,消息传递发生在 GNN 的每一层。事实上,具有许多层的 GNN 比具有较少层的 GNN 更容易受到过度平滑的影响。这是 GNN 通常比传统深度学*模型更浅的一个原因。

另一个导致过度平滑的原因是,当问题有一个需要解决的显著长距离(从跳数的角度来看)任务时。例如,一个节点可能受到一个遥远节点的 影响。这也被称为拥有较大的“问题半径”。每当我们在一个图中,节点可以非常大地影响其他节点,尽管它们相隔多个跳数时,那么问题半径应该被认为是大的。例如,如果某些个体,如名人,尽管与他们的联系遥远,但仍能影响其他个体,那么社交媒体网络可能有一个很大的问题半径。通常,这种情况发生在图足够大,以至于有遥远连接的节点时。

通常,如果你认为一个问题可能存在过度平滑的风险,那么在引入 GNN 的层数时要小心,也就是说,要使其深度适中。然而,请注意,某些架构比其他架构更不容易出现过度平滑。例如,GraphSAGE 采样固定数量的邻居并聚合它们的信息。这种采样可以减轻过度平滑。另一方面,GCNs 更容易受到过度平滑的影响,因为它们没有这种采样过程,尽管注意力机制部分降低了风险,但 GATs 也可能受到过度平滑的影响,因为聚合仍然是局部的。

4.5.3 关键 GAT 方程概述

在本节中,我们将简要介绍 Veličković 等人在 GAT 论文中给出的关键方程 [1],并将它们与我们关于 GAT 的概念联系起来。GAT 使用注意力机制来学*在更新节点特征时哪些邻居节点更重要。它们通过计算注意力分数(方程 1–3)来实现,然后使用这些分数来权衡和组合邻居节点的特征(方程 4–6)。使用多头注意力增强了模型的表达能力和鲁棒性,使其能够从多个角度同时学*。这种方法在计算上可能很昂贵,但通常可以提高 GNN 在各种任务(如节点分类和链接预测)上的性能。

注意力系数计算(方程 4.1–4.3)

使用 GAT 的第一步是计算每对连接节点的注意力分数或系数。这些系数表示节点应给予其邻居多少“注意力”或重要性。原始注意力分数 [1] 计算如下:

(4.1)

figure

在这里,e[ij] 代表从节点 iii 到其邻居 j 的原始注意力分数:

  • h[i] 和 h[j] 是节点 ij 的特征向量(表示)。

  • W 是一个可学*的权重矩阵,它将每个节点的特征线性变换到更高维的空间。

  • α 是一个注意力机制(通常是神经网络),它计算每个节点对的重要性分数。

理念是评估节点 i 应从节点 j 考虑多少信息。归一化注意力系数 [1] 计算如下:

(4.2)

figure

一旦我们有了原始分数 e[ij],我们使用 softmax 函数对其进行归一化:

  • α[ij] 代表归一化注意力系数,它量化了节点 j 的特征对节点 i 的重要性。

  • Softmax 确保给定节点的所有注意力系数之和为 1,使它们在不同节点之间具有可比性。

以下是注意力系数 [1] 的详细计算:

(4.3)

figure

在这里,注意力机制 α 使用具有参数 a 的单层前馈神经网络实现。项 figure 涉及连接节点 ij 的转换特征向量的拼接,然后应用线性变换后跟非线性激活(漏斗形修正线性单元 [leaky ReLU])。

节点表示更新(方程 4.4–4.6)

计算注意力系数后,下一步是使用它们从邻居处聚合信息,并使用注意力 [1] 更新节点表示:

(4.4)

figure

此方程计算节点 i 的新表示 h[i]*':

  • 术语figure表示相邻节点特征的加权求和,其中每个特征向量由其相应的注意力系数 α[ij] 加权。

  • σ 是一个非线性激活函数(如 ReLU 或 sigmoid),它将非线性引入模型,帮助其学*复杂模式。

多头部注意力机制[1]的计算如下:

(4.5)

figure

为了稳定学*过程,GATs 使用多头部注意力,如前所述:

  • 在这里,K 个注意力头部独立计算不同集合的注意力系数和相应的加权求和。

  • 所有头部的结果被拼接起来,形成一个更丰富、更具表现力的节点表示。

下图显示了最终层中多头部注意力的平均值[1]:

(4.6)

figure

在网络的最终预测层中,我们不是将不同头部的输出进行拼接,而是取它们的平均值。这降低了最终输出的维度,并简化了模型的预测过程。

摘要

  • 图注意力网络(GAT)是一种特殊的图神经网络(GNN),它结合了注意力机制,在学*过程中关注最相关的节点。

  • GATs 在节点具有不成比例重要性的领域表现出色,例如社交网络、欺诈检测和异常检测。

  • 本章使用从 Yelp 评论中提取的数据集,专注于检测芝加哥的酒店和餐厅的虚假评论。评论被表示为节点,边表示共享特征(例如,共同作者或企业)。

  • GATs 被应用于此数据集,将节点(评论)分类为欺诈或合法。GAT 模型在基线模型(如逻辑回归、XGBoost 和图卷积网络(GCNs))上显示出改进。

  • 由于需要计算所有边的注意力分数,GATs 内存密集。为了处理这个问题,使用了 PyTorch Geometric(PyG)中的NeighborLoader类的 mini-batching。

  • PyG 中的 GAT 层,如GATConvGATv2Conv,将不同类型的注意力应用于图学*问题。

  • 可以采用如 SMOTE 和类别重排等策略来解决类别不平衡问题。在我们的案例中,类别重排显著提高了模型性能。

第五章:图自动编码器

本章涵盖

  • 区分区分性和生成性模型

  • 将自动编码器和变分自动编码器应用于图

  • 使用 PyTorch Geometric 构建图自动编码器

  • 过度压缩和图神经网络

  • 链接预测和图生成

到目前为止,我们已经介绍了如何将经典深度学*架构扩展到图结构数据。在第三章中,我们考虑了卷积图神经网络(GNNs),它们将卷积算子应用于数据中识别模式。在第四章中,我们探讨了注意力机制以及如何将其用于改进图学*任务(如节点分类)的性能。

卷积 GNN 和注意力 GNN 都是区分性 模型的例子,因为它们学会区分不同数据实例,例如照片是猫还是狗。在本章中,我们介绍了生成性 模型的主题,并通过两种最常见架构——自动编码器和变分自动编码器(VAEs)——对其进行探讨。生成性模型旨在学*整个数据空间,而不是像区分性模型那样在数据空间内分离边界。例如,生成性模型学*如何生成猫和狗的图像(学*再现猫或狗的方面,而不是仅仅学*区分两个或多个类别的特征,如猫的尖耳朵或猎犬的长耳朵)。

我们将发现,区分性模型学会在数据空间中分离边界,而生成性模型学会对数据空间本身进行建模。通过*似数据空间,我们可以从生成性模型中采样以创建训练数据的新的示例。在先前的例子中,我们可以使用我们的生成性模型来制作新的猫或狗的图像,甚至是一些具有两者特征的混合版本。这是一个非常强大的工具,对于初学者和资深数据科学家来说都是重要的知识。*年来,深度生成模型,即使用人工神经网络的生成模型,在许多语言和视觉任务上显示出惊人的能力。例如,DALL-E 模型系列能够根据文本提示生成新的图像,而像 OpenAI 的 GPT 模型这样的模型已经极大地改变了聊天机器人的能力。

在本章中,你将学*如何扩展生成式架构以作用于图结构数据,从而产生图自动编码器(GAEs)和变分图自动编码器(VGAEs)。这些模型与之前章节中的区分性模型不同。正如我们将看到的,生成式模型对整个数据空间进行建模,并且可以与区分性模型结合用于下游机器学*任务。

为了展示生成式方法在学*任务中的强大能力,我们回到第三章中介绍的亚马逊产品共同购买网络。然而,在第三章中,你学*了如何根据物品在网络中的位置预测其可能属于的类别。在本章中,我们将展示如何根据物品的描述预测其在网络中的位置。这被称为边缘(或链接)预测,在例如设计推荐系统时经常出现。我们将运用对 GAEs 的理解来进行边缘预测,构建一个可以预测图中节点何时连接的模型。我们还将讨论过度压缩的问题,这是 GNNs 的一个特定考虑因素,以及我们如何将 GNN 应用于生成潜在的化学图。

到本章结束时,你应该了解何时何地使用图生成模型(而不是判别模型)的基本知识,以及当我们需要时如何实现它们。

注意:本章的代码以笔记本形式存储在 GitHub 仓库中(mng.bz/4aGQ)。本章的 Colab 链接和数据可以在同一位置访问。

5.1 生成模型:学*如何生成

深度学*的经典例子是,给定一组标记图像,如何训练模型来学*为新的和未见过的图像分配什么标签。如果我们考虑一组船只和飞机的图像,我们希望我们的模型能够区分这些不同的图像。如果我们随后向模型传递一个新图像,我们希望我们的模型能够正确地将其识别为,例如,一艘船。判别模型通过学*基于其特定目标标签来区分类别。通常使用卷积架构(在第三章中讨论)和基于注意力的架构(在第四章中介绍)来创建判别模型。然而,正如我们将看到的,它们也可以被纳入生成模型。为了理解这一点,我们首先必须了解判别和生成建模方法之间的区别。

5.1.1 生成模型和判别模型

如前几章所述,我们用来训练模型的原始数据集被称为我们的训练数据,我们试图预测的标签被称为我们的训练目标。未见过的数据是我们的测试数据,我们希望学*目标标签(从训练中)以对测试数据进行分类。另一种描述方式是使用条件概率。我们希望我们的模型返回给定数据实例 X 时某些目标 Y 的概率。我们可以将其写为 P(Y|X),其中竖线表示 Y 是“条件”于 X 的。

正如我们所说的,判别性模型学*区分之间的类别。这相当于在数据空间中学*数据的分离边界。相比之下,生成性模型学*建模数据空间本身。它们捕捉数据空间中数据的整个分布,并且当面对一个新的示例时,它们会告诉我们新示例的可能性有多大。使用概率语言,我们说它们建模了数据与目标之间的联合概率,P(X,Y)。一个典型的生成性模型可能是一个用于预测句子中下一个单词的模型(例如,许多现代智能手机中的自动完成功能)。生成性模型为每个可能的下一个单词分配一个概率,并返回那些概率最高的单词。判别性模型可以告诉你一个单词具有某种特定情感的可能性有多大,而生成性模型将建议使用哪个单词。

回到我们的图像示例,生成性模型*似图像的整体分布。这可以在图 5.1 中看到,其中生成性模型已经学会了数据空间中点的位置(而不是它们是如何被区分的)。这意味着生成性模型必须比它们的判别性对应物学*数据中的更复杂的相关性。例如,生成性模型学*到“飞机有翅膀”和“船只出现在水中”。另一方面,判别性模型只需要学*“船”与“非船”之间的区别。它们可以通过寻找图像中的明显标志,如桅杆、龙骨或吊杆来实现。然后它们可以很大程度上忽略图像的其余部分。因此,生成性模型在训练时可能更耗费计算资源,可能需要更大的网络架构。(在第 5.5 节中,我们将描述过度压缩问题,这是大型 GNN 的一个特定问题。)

图

图 5.1 生成性任务与判别性任务的比较。在左侧,判别性模型学*区分不同类型的船只和飞机图像。在右侧,生成性模型试图学*整个数据空间,这允许创建新的合成示例,例如天空中的船只或水上的飞机。

5.1.2 合成数据

由于判别模型在训练上比生成模型计算成本更低,且对异常值更鲁棒,你可能会 wonder 为什么我们还要使用生成模型。然而,生成模型在数据标注相对昂贵但生成数据集相对容易的情况下是有效的工具。例如,生成模型在药物发现中越来越受欢迎,它们可以生成可能具有某些特性的新候选药物,例如减少某些疾病的影响的能力。从某种意义上说,生成模型试图学*如何创建合成数据,这使我们能够创建新的数据实例。例如,图 5.2 中显示的任何人都不存在,而是通过从数据空间中采样(使用生成模型*似)来创建的。

figure

图 5.2 显示合成面孔的图(来源:[1])

生成模型创建的合成示例可以用来增强一个昂贵的数据集。我们不需要在每种条件下拍摄很多面部照片,而是可以使用生成模型创建新的数据示例(例如,一个人戴着帽子、眼镜和口罩),以增加我们的数据集,使其包含棘手的边缘情况。然后,这些合成示例可以用来进一步改进我们的其他模型(例如,一个识别某人是否戴着口罩的模型)。然而,在引入合成数据时,我们必须小心不要将其他偏差或噪声引入我们的数据集中。

此外,判别模型通常用于生成模型之后。这是因为生成模型通常以“自监督”的方式进行训练,不依赖于数据标签。它们学*将复杂的高维数据压缩(或编码)到低维。这些低维表示可以用来更好地揭示我们数据中的潜在模式。这被称为降维,有助于数据聚类或分类任务。稍后,我们将看到生成模型如何在不看到其标签的情况下将图分离成不同的类别。在标注每个数据点成本高昂的情况下,生成模型可以节省大量成本。让我们继续了解我们的第一个生成 GNN 模型。

5.2 用于链接预测的图自动编码器

深度生成模型的一个基本且流行的模型是自动编码器。自动编码器框架之所以被广泛使用,是因为它具有极高的适应性。正如第三章中提到的注意力机制可以用于改进许多不同的模型一样,自动编码器可以与许多不同的模型结合,包括不同类型的 GNN。一旦理解了自动编码器的结构,编码器和解码器可以用任何类型的神经网络替换,包括第二章中提到的不同类型的 GNN,如图卷积网络(GCN)和 GraphSAGE 架构。

然而,在将自动编码器应用于基于图的数据时,我们需要小心。在重建我们的数据时,我们也必须重建我们的邻接矩阵。在本节中,我们将探讨使用第三章中的亚马逊产品数据集实现 GAE 的方法[2]。我们将构建一个用于链接预测任务的 GAE,这是处理图时常见的问题。这允许我们重建邻接矩阵,并且在我们处理有缺失数据的数据集时特别有用。我们将遵循以下过程:

  1. 定义模型:

    1. 创建一个编码器和解码器。

    2. 使用编码器创建一个潜在空间进行采样。

  2. 定义训练和测试循环,包括适合构建生成模型的损失函数。

  3. 将数据准备为图,包括边列表和节点特征。

  4. 训练模型,传递边数据以计算损失。

  5. 使用测试数据集测试模型。

5.2.1 对第三章中亚马逊产品数据集的回顾

在第三章中,我们学*了带有共同购买者信息的亚马逊产品数据集。这个数据集包含了关于一系列不同物品的购买信息,包括谁购买了它们以及如何购买,以及物品的分类,这些分类在第三章中作为标签。我们已经学*了如何将这个表格数据集转换为图结构,通过这样做,可以使我们的学*算法更加高效和强大。我们还已经在不经意间使用了一些降维技术。主成分分析(PCA)被应用于亚马逊产品数据集以创建特征。每个产品描述都被使用词袋算法转换为数值,然后 PCA 被应用于将(现在已经是数值的)描述减少到 100 个特征。

在本章中,我们将重新审视亚马逊产品数据集,但目的是不同的。我们将使用我们的数据集来学*链接预测。本质上,这意味着学*我们图中的节点之间的关系。这有许多用例,例如预测用户接下来想看哪些电影或电视节目,在社交媒体平台上建议新的连接,甚至预测更有可能违约的客户。在这里,我们将用它来预测亚马逊电子产品数据集中哪些产品应该连接在一起,如图 5.3 所示。有关链接预测的更多详细信息,请参阅本章末尾的 5.5 节。

figure

图 5.3 亚马逊电子产品数据集,其中不同的产品,如相机和镜头,根据它们是否曾经一起购买而相互连接

与所有数据科学项目一样,首先查看数据集并理解问题所在是值得的。我们首先加载数据,就像我们在第三章中所做的那样,这将在列表 5.1 中展示。数据已预处理并标记,因此可以使用 NumPy 加载。有关数据集的更多详细信息,请参阅[2]。

列表 5.1 加载数据
import numpy as np

filename = 'data/new_AMZN_electronics.npz'

data = np.load(filename)

loader = dict(data)
print(loader)

前面的输出打印以下内容:

{'adj_data': array([[0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       ...,
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.]],\
 dtype=float32), 'attr_data': \
array([[0., 0., 0., ..., 0., 1., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       ...,
       [0., 1., 0., ..., 0., 0., 0.],
       [1., 1., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 1.]],\
 dtype=float32), 'labels': \
array([6, 4, 3, ..., 1, 2, 3]),\
 'class_names': array(['Film Photography',\
 'Digital Cameras', 'Binoculars & Scopes',
       'Lenses', 'Tripods & Monopods', 'Video Surveillance',
       'Lighting & Studio', 'Flashes'], dtype='<U19')}

数据加载完成后,我们可以查看一些基本统计数据和数据细节。我们感兴趣的是边缘或链接预测,因此了解存在多少不同的边缘是很有价值的。我们可能还想了解有多少个组件以及平均度,以了解我们的图有多连通。我们将在下面的列表中展示计算这些值的代码。

列表 5.2 探索性数据分析
adj_matrix = torch.tensor(loader['adj_data'])
if not adj_matrix.is_sparse:
    adj_matrix = adj_matrix.to_sparse()

feature_matrix = torch.tensor(loader['attr_data'])
labels = loader['labels']

class_names = loader.get('class_names')
metadata = loader.get('metadata')

num_nodes = adj_matrix.size(0)
num_edges = adj_matrix.coalesce().values().size(0)   #1
density = num_edges / (num_nodes \
* (num_nodes - 1) / 2) if num_nodes \
> 1 else 0  #2

1 这只因为邻接矩阵是无向的。

2 实际边与可能边的比率

我们还绘制了度数的分布,以查看连接如何变化,如以下列表和图 5.4 所示。

列表 5.3 绘制图形
degrees = adj_matrix.coalesce().indices().numpy()[0]    #1
degree_count = np.bincount(degrees, minlength=num_nodes)

plt.figure(figsize=(10, 5))
plt.hist(degree_count, bins=25, alpha=0.75, color='blue')
plt.xlabel('Degree')
plt.ylabel('Frequency')
plt.grid(True)
plt.show()

1 获取每个非零值对应的行索引

我们发现共有 7,650 个节点,超过 143,000 条边,整体密度为 0.0049。因此,我们的图是中等大小(约 10,000 个节点),但非常稀疏(密度远小于 0.05)。我们看到大多数节点的度数较低(小于 10),但还存在一个度数较高的边的高峰(约 30)和更长的尾部。总的来说,我们看到高度数的节点非常少,这在低密度的图中是预期的。

figure

图 5.4 亚马逊电子产品共同购买者图的度分布

5.2.2 定义图自动编码器

接下来,我们将使用生成模型,即自动编码器,来估计和预测亚马逊电子产品数据集中的链接。这样做是有先例的,因为链接预测是 Kipf 和 Welling 在 2012 年首次发布 GAE 时应用的问题[3]。在他们开创性的论文中,他们介绍了 GAE 及其变分扩展,我们将在稍后讨论这些内容,并将这些模型应用于图深度学*的三个经典基准,即 Cora 数据集、CiteSeer 和 PubMed。如今,大多数图深度学*库都使得创建和开始训练 GAE 非常容易,因为它们已经成为最受欢迎的基于图的深度生成模型之一。我们将在本节中更详细地查看构建一个 GAE 所需的步骤。

GAE 模型类似于典型的自动编码器。唯一的区别是,我们网络中的每一层都是一个 GNN,例如 GCN 或 GraphSAGE 网络。在图 5.5 中,我们展示了 GAE 架构的示意图。总的来说,我们将使用编码器网络将我们的边缘数据压缩成低维表示。

figure

图 5.5 GAE 的示意图,展示了模型的关键元素,如编码器、潜在空间和解码器

对于我们的 GAE,我们需要定义的第一个东西是编码器,它将我们的数据转换成潜在表示。实现编码器的代码片段在列表 5.4 中给出。我们首先导入我们的库,然后构建一个 GNN,其中每一层都逐渐变小。

列表 5.4 图编码器
from torch_geometric.nn import GCNConv   #1

class GCNEncoder(torch.nn.Module):                         #2
    def __init__(self, input_size, layers, latent_dim):    #2
        super().__init__() #2
        self.conv0 = GCNConv(input_size, layers[0])    #3
        self.conv1 = GCNConv(layers[0], layers[1])     #3
        self.conv2 = GCNConv(layers[1], latent_dim)    #3

    def forward(self, x, edge_index):           #4
        x = self.conv0(x, edge_index).relu()    #4
        x = self.conv1(x, edge_index).relu()    #4
        return self.conv2(x, edge_index)        #4

1 从 PyG 加载 GCNConv 模型

2 定义编码器层并使用预定义的大小初始化它

3 定义编码器每一层的网络

4 使用边缘数据的编码器前向传递

注意,我们还需要确保我们的前向传递可以从我们的图中返回边缘数据,因为我们将使用我们的自动编码器从潜在空间重建图。换句话说,自动编码器将学*如何从特征空间的一个低维表示中重建邻接矩阵。这意味着它也在学*如何从新数据中预测边缘。为了做到这一点,我们需要让自动编码器结构学*重建边缘,特别是通过改变解码器。在这里,我们将使用内积从潜在空间预测边缘。这如列表 5.5 中所示。(要了解为什么我们使用内积,请参阅第 5.5 节的技术细节。)

列表 5.5 图解码器
class InnerProductDecoder(torch.nn.Module):     #1
    def __init__(self):                        
         super().__init__()                    

def forward(self, z, edge_index):     #2
        value = (z[edge_index[0]] * \
z[edge_index[1]]).sum(dim=1)   #3
        return torch.sigmoid(value)

1 定义解码器层

2 说明解码器的形状和大小(这再次是编码器的反转)

3 解码器的前向传递

现在我们已经准备好在 GAE 类中将编码器和解码器结合起来,该类包含两个子模型(见列表 5.6)。请注意,我们现在没有用任何输入或输出大小初始化解码器,因为这只是在将编码器的输出与边缘数据应用内积。

列表 5.6 图自动编码器
   class GraphAutoEncoder(torch.nn.Module):
        def __init__(self, input_size, layers, latent_dims):
            super().__init__()
            self.encoder = GCNEncoder(input_size, \
   layers, latent_dims)     #1
            self.decoder = InnerProductDecoder()      #2

        def forward(self, x):
            z = self.encoder(x)
            return self.decoder(z)

1 定义 GAE 的编码器

2 定义解码器

在 PyTorch Geometric (PyG) 中,通过仅导入 GAE 类,GAE 模型可以变得更加简单,该类在传递给编码器后自动构建解码器和自动编码器。当我们在本章后面构建 VGAE 时,我们将使用此功能。

5.2.3 训练图自动编码器进行链接预测

在构建了我们的 GAE 之后,我们可以继续使用它来对 Amazon Products 数据集的子模型进行边缘预测。整体框架将遵循典型的深度学*问题格式,我们首先加载数据,准备数据,并将这些数据分成训练、测试和验证数据集;定义我们的训练参数;然后训练和测试我们的模型。这些步骤在图 5.6 中显示。

图

图 5.6 训练我们的模型进行链接预测的总体步骤

我们首先加载数据集并为其学*算法做准备,这已经在列表 5.1 中完成。为了使用 PyG 模型进行 GAE 和 VGAE,我们需要从邻接矩阵中构建一个边缘索引,这可以通过使用 PyG 的一个实用函数 to_edge_index 轻松完成,正如我们在以下列表中描述的那样。

列表 5.7 构建边索引
from torch_geometrics.utils import to_edge_index   #1

edge_index, edge_attr = to_edge_index(adj_matrix)   #2
num_nodes = adj_matrix.size(0)

1 从 PyG 实用库中加载 to_edge_index

2 将邻接矩阵转换为边索引和边属性向量

然后我们加载 PyG 库并将我们的数据转换为 PyG 数据对象。我们还可以对我们的数据集应用转换,其中特征和邻接矩阵的加载方式与第三章中描述的相同。首先,我们对特征进行归一化,然后根据图的边或链接将我们的数据集划分为训练集、测试集和验证集,如列表 5.8 所示。这是进行链接预测时的一个关键步骤,以确保我们正确地分割了数据。在代码中,我们使用了 5%的数据用于验证,10%的数据用于测试数据,注意我们的图是无向的。在这里,我们没有添加任何负样本训练数据。

列表 5.8 转换为 PyG 对象
data = Data(x=feature_matrix,         #1
            edge_index=edge_index,   
            edge_attr=edge_attr,     
            y=labels)                

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

transform = T.Compose([\
     T.NormalizeFeatures(),\                  #2
     T.ToDevice(device),                     
     T.RandomLinkSplit(num_val=0.05,\
     num_test=0.1, is_undirected=True,       
     add_negative_train_samples=False)])     
train_data, val_data, test_data = transform(data)

1 将我们的数据转换为 PyG 数据对象

2 对我们的数据进行转换并将链接划分为训练、测试和验证集

一切准备就绪后,我们现在可以将 GAE 应用于 Amazon Products 数据集。首先,我们定义我们的模型、优化器和损失函数。我们将二元交叉熵损失应用于解码器的预测值,并将其与我们的真实边索引进行比较,以查看我们的模型是否正确地重建了邻接矩阵,如以下列表所示。

列表 5.9 定义模型
input_size, latent_dims = feature_matrix.shape[1], 16   #1
layers = [512, 256]                                    
model = GraphAutoEncoder(input_size, layers, latent_dims)   #2
model = model.to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
criterion = torch.nn.BCEWithLogitsLoss()   #3

1 指定编码器的形状

2 定义具有正确形状的 GAE

3 我们现在的损失是二元交叉熵。

使用二元交叉熵损失是很重要的,因为我们想计算每个边是真实边的概率,其中真实边对应于未被隐藏且不需要预测的边(即正样本)。编码器学*压缩边数据但不会改变边的数量,而解码器学*预测边。从某种意义上说,我们在这里结合了判别性和生成性步骤。因此,二元交叉熵给出了在这些节点之间可能存在边的概率。它是二元的,因为边要么应该存在(标签 1),要么不应该存在(标签 0)。我们可以将所有二元交叉熵概率大于 0.5 的边与训练循环每个 epoch 中的实际真实边进行比较,如以下列表所示。

列表 5.10 训练函数
def train(model, criterion, optimizer):

    model.train() 

    optimizer.zero_grad() 
    z = model.encoder(train_data.x,\
    train_data.edge_index)   #1

    neg_edge_index = negative_sampling(\         #2
    edge_index=train_data.edge_index,\
    num_nodes=train_data.num_nodes,             
    num_neg_samples=train_data.\
    edge_label_index.size(1), method='sparse')  

    edge_label_index = torch.cat(                     #3
    [train_data.edge_label_index, neg_edge_index],    #3
    dim=-1,)                                          #3

    out = model.decoder(z, edge_label_index).view(-1)   #4

    edge_label = torch.cat([        #5
    train_data.edge_label,         
train_data.edge_label.new_zeros\
(neg_edge_index.size(1))           
    ], dim=0)                      
    loss = criterion(out, edge_label)   #6
    loss.backward()                     #6
    optimizer.step() #6

    return loss

1 将图编码为潜在表示

2 执行新一轮的负采样

3 将新的负样本与边标签索引结合

4 生成边预测

5 将边标签与 0s 结合用于负样本

6 计算损失并进行反向传播

在这里,我们首先将我们的图编码成潜在表示。然后我们进行一轮负采样,为每个 epoch 抽取新的样本。负采样在训练期间取一个随机子集的非存在标签,而不是现有的正标签,以解决真实标签和非存在标签之间的类别不平衡。一旦我们有了这些新的负样本,我们将它们与我们的原始边缘标签索引连接起来,并将这些传递给我们的解码器以获得一个重构的图。最后,我们将我们的真实边缘标签与负边缘的 0 标签连接起来,并计算预测边缘和真实边缘之间的损失。请注意,我们在这里不是进行批量学*;相反,我们选择在每个 epoch 期间在整个数据上训练。

我们在列表 5.11 中展示的测试函数比我们的训练函数简单得多,因为它不需要执行任何负采样。相反,我们只需使用真实和预测的边缘,并返回一个接收者操作特征(ROC)/曲线下面积(AUC)分数来衡量我们模型的准确率。回想一下,ROC/AUC 曲线将在 0 和 1 之间变化,一个完美的模型,其预测 100%正确,将有一个 AUC 为 1。

列表 5.11 测试函数
from sklearn.metrics import roc_auc_score

@torch.no_grad() 
def test(data):
    model.eval() 
    z = model.encode(data.x, data.edge_index)   #1
    out = model.decode(z, \
    data.edge_label_index).view(-1).sigmoid()   #2
    loss = roc_auc_score(data.edge_label.cpu().numpy(),   #3
                        out.cpu().numpy())                #3
    return loss

1 将图编码成潜在表示

2 使用完整的边缘标签索引解码图

3 计算整体 ROC/AUC 分数

在每个时间步,我们将使用我们验证数据中的所有边缘数据来计算一个模型的整体成功率。训练完成后,我们使用测试数据来计算最终的测试准确率,如下所示。

列表 5.12 训练循环
best_val_auc = final_test_auc = 0 
for epoch in range(1, 201): 
    loss = train(model, criterion, optimizer)  #1
    val_auc = test(val_data)   #2
    if val_auc > best_val_auc: 
        best_val_auc = val_auc 
test_auc = test(test_data)   #3

1 执行训练步骤

2 在验证数据上测试我们的更新模型

3 在测试数据上测试我们的最终模型

我们发现,经过 200 个 epoch 后,我们达到了超过 83%的准确率。甚至更好,当我们使用测试集来查看我们的模型能够多好地预测边缘时,我们得到了 86%的准确率。我们可以将我们的模型性能解释为有 86%的时间可以向购买者推荐有意义的商品,假设所有未来的数据都和我们的当前数据集相同。这是一个非常好的结果,展示了图神经网络(GNNs)在推荐系统中的有用性。我们还可以通过探索我们新构建的潜在空间来更好地理解数据集的结构,或者通过执行额外的分类和特征工程任务来使用我们的模型。接下来,我们将学*图自动编码器模型最常见的扩展之一——变分图自动编码器(VGAE)。

5.3 变分图自动编码器

自动编码器将数据映射到潜在空间中的离散点。为了从训练数据集之外采样并生成新的合成数据,我们可以在这些离散点之间进行插值。这正是我们在图 5.1 中描述的过程,我们生成了未见过的数据组合,例如飞艇。然而,自动编码器是确定性的,其中每个输入映射到潜在空间中的一个特定点。这可能导致采样时的尖锐不连续性,这可能会影响数据生成的性能,导致合成的数据不能很好地再现原始数据集。为了改进我们的生成过程,我们需要确保我们的潜在空间结构良好,即常规。例如,在图 5.7 中,我们展示了如何使用 Kullback-Liebler 散度(KL 散度)重构潜在空间以改善重建。

figure

图 5.7 常规空间是连续和紧致的,但数据区域可能会变得不那么分离。或者,高重建损失通常意味着数据分离良好,但潜在空间可能覆盖不足,导致生成样本较差。在这里,KL 散度指的是 Kullback-Liebler 散度。

KL 散度是衡量一个概率分布与另一个概率分布差异的度量。它计算将一个分布(原始数据分布)中的值编码到另一个分布(潜在空间)中所需的“额外信息”量。在左侧,数据组(x[i])重叠不多,这意味着 KL 散度较高。在右侧,不同数据组之间存在更多重叠(相似性),这意味着 KL 散度较低。当构建具有高 KL 散度的更常规的潜在空间时,我们可以获得非常好的重建效果,但插值效果较差,而对于低 KL 散度,情况则相反。更多细节请参阅第 5.5 节。

常规意味着空间满足两个属性:连续性和紧致性。连续性意味着潜在空间中邻*的点被解码成大约相似的事物,而紧致性意味着潜在空间中的任何一点都应导致一个有意义的解码表示。这些术语,即大约相似和有意义的,有精确的定义,你可以在《使用 PyTorch 学*生成式 AI》(Manning,2024;mng.bz/AQBg)中了解更多。然而,对于本章,你需要知道的是,这些属性使得从潜在空间中采样变得更加容易,从而产生更干净的生成样本,并可能提高模型精度。

当我们对潜在空间进行正则化时,我们使用变分方法,这些方法通过概率分布(或密度)来建模整个数据空间。正如我们将看到的,使用变分方法的主要好处是潜在空间结构良好。然而,变分方法并不一定保证更高的性能,因此在使用这些类型的模型时,通常很重要的一点是要测试自动编码器和变分对应物。这可以通过查看测试数据集上的重建分数(例如,均方误差)、对潜在编码应用一些降维方法(例如,t-SNE 或均匀流形*似和投影[UMAP]),或使用特定任务的度量(例如,图像的 Inception Score 或文本生成的 ROUGE/METEOR)来实现。对于图而言,最大均值差异(MMD)、图统计或图核方法都可以用来比较不同合成的图副本。

在接下来的几节中,我们将更详细地介绍将数据空间建模为概率密度意味着什么,以及我们如何仅用几行代码将我们的图自动编码器转换为 VGAE。这些依赖于一些关键的概率机器学*概念,如 KL 散度和重参数化技巧,我们将在第 5.5 节中概述这些概念。对于对这些概念进行更深入的了解,我们推荐阅读《概率深度学*》(Manning,2020)。让我们构建一个 VGAE 架构,并将其应用于之前相同的 Amazon Products 数据集。

5.3.1 构建变分图自动编码器

VGAE 架构与 GAE 模型类似。主要区别在于,变分图编码器的输出是通过从概率密度中采样生成的。我们可以用其均值和方差来表征密度。因此,编码器的输出现在将是之前空间每个维度的均值和方差。然后,解码器将这个采样的潜在表示解码,使其看起来像输入数据。这可以在图 5.8 中看到,其中高级模型是我们现在将之前的自动编码器扩展到输出均值和方差,而不是从潜在空间中输出点估计。这允许我们的模型从潜在空间中进行概率采样。

figure

图 5.8 展示了通用 VAE 的结构,我们现在从潜在空间中的概率密度中采样,而不是像典型自动编码器那样从点估计中采样。VGAE 扩展了 VAE 架构,使其适用于图结构数据。

我们必须调整我们的架构,并更改我们的损失以包括一个额外的项来正则化潜在空间。列表 5.13 提供了一个 VGAE 的代码片段。列表 5.4 和列表 5.13 中的VariationalGCNEncoder层之间的相似之处在于,我们已经将潜在空间的维度加倍,并在前向传递的末尾从编码器返回均值和对数方差。

列表 5.13 VariationalGCNEncoder
class VariationalGCNEncoder(torch.nn.Module):            #1
  def __init__(self, input_size, layers, latent_dims):
    super().__init__()
    self.layer0 = GCNConv(input_size, layers[0])
    self.layer1 = GCNConv(layers[0], layers[1])
    self.mu = GCNConv(layers[1], latent_dims)           
    self.logvar = GCNConv(layers[1], latent_dims)       

  def forward(self, x, edge_index):
    x = self.layer0(x, edge_index).relu()
    x = self.layer1(x, edge_index).relu()
    mu = self.mu(x, edge_index)
    logvar = self.logvar(x, edge_index)
    return mu, logvar                      #2

1 添加均值和对数方差变量以进行采样

2 前向传递返回均值和对数方差变量

当我们讨论 GAE 时,我们了解到解码器使用内积来返回邻接矩阵或边列表。之前我们明确实现了内积。然而,在 PyG 中,这个功能是内置的。为了构建 VGAE 结构,我们可以调用下面的VGAE函数。

列表 5.14 变分图自动编码器(VGAE
from torch_geometric.nn import VGAE   #1
model = VGAE(VariationalGCNEncoder(input_size,\
 layers, latent_dims))

1 使用 PyG 库中的 VGAE 函数构建自动编码器

这个功能使得构建 VGAE 变得更加简单,其中 PyG 中的 VGAE 函数负责处理重新参数化技巧。现在我们有了我们的 VGAE 模型,接下来我们需要修改训练和测试函数以包含 KL 散度损失。下面的列表显示了训练函数。

列表 5.15 训练函数
def train(model, criterion, optimizer):
    model.train() 
    optimizer.zero_grad() 
    z = model.encode(train_data.x, train_data.edge_index)      #1

    neg_edge_index = negative_sampling( 
    edge_index=train_data.edge_index, num_nodes=train_data.num_nodes,
    num_neg_samples=train_data.edge_label_index.size(1), method='sparse')

    edge_label_index = torch.cat( 
    [train_data.edge_label_index, neg_edge_index], 
    dim=-1,) 
    out = model.decode(z, edge_label_index).view(-1)          

    edge_label = torch.cat([ 
    train_data.edge_label,
    train_data.edge_label.new_zeros(neg_edge_index.size(1))
    ], dim=0)

    loss = criterion(out, edge_label)            #2
+ (1 / train_data.num_nodes) * model.kl_loss()  

    loss.backward() 
    optimizer.step()

    return loss

1 由于我们正在使用 PyG 的 VGAE 函数,我们需要使用编码和解码方法。

2 在损失中添加由 KL 散度给出的正则化项

这是我们之前在列表 5.12 中用于训练 GAE 模型的相同训练循环。唯一的区别是我们将一个额外的项添加到损失中,以最小化 KL 散度,并将encoderdecoder方法调用更改为encodedecode(我们还需要在我们的测试函数中更新这些)。否则,训练保持不变。请注意,多亏了 PyG 添加的功能,这些更改比我们在 PyTorch 中早期所做的更改要简单得多。然而,通过逐一完成这些额外步骤,我们可以对 GAE 的底层架构有更多的直观理解。

我们现在可以将我们的 VGAE 应用于 Amazon Products 数据集,并使用它进行边预测,这产生了 88%的整体测试准确率。这略高于我们的 GAE 准确率。重要的是要注意,VGAEs 并不一定会给出更高的准确率。因此,你应该始终尝试 GAE 和 VGAE,并在使用此架构时进行仔细的模型验证。

5.3.2 何时使用变分图自动编码器

由于 VGAE 的准确度与 GAE 相似,因此认识到这两种方法的局限性是很重要的。一般来说,当你想构建一个生成模型或你想使用数据的一个方面来学*另一个方面时,GAEs 和 VGAEs 是很好的模型。例如,我们可能想为姿态预测制作一个基于图的模型。我们可以使用 GAE 和 VGAE 架构来根据视频片段预测未来的姿态。(我们将在后面的章节中看到类似的例子。)当我们这样做时,我们正在使用 GAE/VGAE 来学*一个基于身体的图,条件是每个身体部分的未来位置。然而,如果我们特别感兴趣于生成新数据,例如用于药物发现的新的化学图,那么 VGAEs 通常更好,因为潜在空间更有结构。

通常,GAEs 对于特定的重建任务(如链接预测或节点分类)非常出色,而 VGAEs 更适合那些需要更大或更多样化合成样本的任务,例如当你想要生成全新的子图或小图时。与 GAEs 相比,VGAEs 也更适合于当底层数据集有噪声时,因为 GAEs 更快且更适合具有清晰结构的图数据。最后,请注意,由于它们的变分方法,VGAEs 更不容易过拟合,因此它们可能具有更好的泛化能力。像往常一样,你的架构选择取决于手头的问题。

在本章中,我们学*了两个生成模型的例子,即 GAE 和 VGAE 模型,以及如何实现这些模型以与图结构化数据一起工作。为了更好地理解如何使用这个模型类,我们将我们的模型应用于边缘预测任务。然而,这只是在应用生成模型中的一步。

在许多需要生成模型的情况下,我们使用连续的自动编码器层来进一步降低我们系统的维度并增加我们的重建能力。在药物发现和化学科学领域,GAEs 允许我们重建邻接矩阵(正如我们在这里所做的那样),以及重建分子类型甚至分子的数量。GAEs 在许多科学和工业领域被频繁使用。现在你也有了尝试它们的工具。

在下一节中,我们将演示如何使用 VGAE 生成具有特定品质的新图,例如具有高性质的新分子,这表明其作为潜在药物候选人的有用性。

5.4 使用 GNNs 生成图

到目前为止,我们考虑了如何使用我们图的生成模型来估计节点之间的边。然而,有时我们感兴趣的不仅仅是生成节点或边,而是整个图。当试图理解或预测图级数据时,这尤其重要。在这个例子中,我们将通过使用我们的 GAE 和 VGAEs 来生成具有某些特性的新潜在分子来实现这一点,这些分子可以用来合成。

GNN 对影响最大的领域之一是药物发现,特别是对新分子或潜在药物的身份识别。2020 年,一种新的抗生素被提出,它是通过使用 GNN 发现的,而在 2021 年,一种用于识别食物中致癌物的新的方法被发表,该方法也使用了 GNN。从那时起,已经有许多其他论文使用 GNN 作为工具来加速药物发现流程。

5.4.1 分子图

我们将考虑那些在 ZINC 数据集中已筛选过的药物的小分子,该数据集大约有 250,000 个单独的分子。这个数据集中的每个分子都有额外的数据,包括以下内容:

  • 简化分子输入行系统(SMILES) —分子结构或分子 的 ASCII 格式描述。

重要属性 —合成可及性分数(SAS)、水-辛醇分配系数(logP),最重要的是,定量估计药物相似度(QED)的度量,这突出了该分子作为潜在药物的可能性。

*为了使这个数据集能够被我们的 GNN 模型使用,我们需要将其转换为合适的图结构。在这里,我们将使用 PyG 来定义我们的模型并运行我们的深度学*流程。因此,我们首先下载数据,然后使用 NetworkX 将数据集转换为图对象。我们在列表 5.16 中下载数据集,生成了以下输出:

     smiles     logP     qed     SAS
0     CC(C)(C)c1ccc2occ(CC(=O)Nc3ccccc3F)c2c1
     5.05060     0.702012     2.084095
1     C[C@@H]1CC(Nc2cncc(-c3nncn3C)c2)CC@@HC1
     3.11370     0.928975     3.432004
2     N#Cc1ccc(-c2ccc(OC@@HN3CCCC3)c3ccccc3)...
     4.96778     0.599682     2.470633
3     CCOC(=O)[C@@H]1CCCN(C(=O)c2nc
      (-c3ccc(C)cc3)n3c...     
      4.00022     0.690944     2.822753
4     N#CC1=C(SCC(=O)Nc2cccc(Cl)c2)N=C([O-])
      C@H:
     response = requests.get(url)
     response.raise_for_status() 
     with open(filename, 'wb') as f:
     f.write(response.content)

url = "https://raw.githubusercontent.com/
aspuru-guzikgroup/chemical_vae/master/models/
zinc_properties/250k_rndm_zinc_drugs_clean_3.csv"
filename = "250k_rndm_zinc_drugs_clean_3.csv"

download_file(url, filename)

df = pd.read_csv(filename)
df["smiles"] = df["smiles"].apply(lambda s: s.replace("\n", ""))

在列表 5.17 中,我们定义了一个函数,用于将 SMILES 转换为小图,然后我们使用这些图来创建 PyG 数据集。我们还为我们数据集中的每个对象添加了一些附加信息,例如我们可以用于进一步数据探索的重原子数量。在这里,我们使用递归 SMILES 深度优先搜索(DFS)工具包(RDKit)包(www.rdkit.org/docs/index.xhtml),这是一个出色的开源化学信息学工具。

列表 5.17 创建分子图数据集
   from torch_geometric.data import Data
   import torch
   from rdkit import Chem

   def smiles_to_graph(smiles, qed):
     mol = Chem.MolFromSmiles(smiles)
        if not mol:
             return None

        edges = []
        edge_features = []
        for bond in mol.GetBonds():
             edges.append([bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()])
             bond_type = bond.GetBondTypeAsDouble()
             bond_feature = [1 if i == bond_type\
             else 0 for i in range(4)]
             edge_features.append(bond_feature)

        edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous()
        edge_attr = torch.tensor(edge_features, dtype=torch.float)
        x = torch.tensor([atom.GetAtomicNum()\
 for atom in mol.GetAtoms()], \
 dtype=torch.float).view(-1, 1)

        num_heavy_atoms = mol.GetNumHeavyAtoms()

        return Data(x=x, edge_index=edge_index,\
 edge_attr=edge_attr, \
qed=torch.tensor([qed], \
dtype=torch.float), \
num_heavy_atoms=num_heavy_atoms)

我们数据集的一个随机样本如图 5.9 所示,突出了我们的分子图的多样性及其小型化,其中每个图都少于 100 个节点和边。

figure

图 5.9 示例分子图及其药物相似度(QED)的定量估计

5.4.2 识别新的药物候选物

在图 5.10 中,我们开始看到 QED 如何随着不同的分子结构而变化。药物发现的主要障碍之一是不同分子潜在组合的数量以及如何知道哪些分子需要合成并测试其药效。这远在将药物引入人体、动物(体内)或有时甚至细胞(体外)试验之前。即使仅使用分子图来评估分子如溶解度等问题也可能是一个挑战。在这里,我们将专注于预测分子的 QED,以查看哪些分子最有可能作为药物有潜在用途。为了说明 QED 如何变化,请参见图 5.10,其中包含四个具有高(约 0.95)和低(约 0.12)QED 的分子。我们可以看到这些分子之间的一些定性差异,例如低 QED 的分子中强键的数量增加。然而,直接从图中估计 QED 是一个挑战。为了帮助我们完成这项任务,我们将使用 GNN 来生成和评估新的潜在药物。

figure

图 5.10 高 QED(顶部)和低 QED(底部)的分子

我们的工作将基于两篇重要的论文,这些论文展示了生成模型如何成为识别新分子的有效工具(Gómez-Bombarelli 等人[4]和 De Cao 等人[5])。具体来说,Gómez-Bombarelli 等人表明,通过构建数据空间(即我们在本章前面描述的潜在空间)的平滑表示,可以优化以找到具有特定感兴趣特性的新候选者。这项工作大量借鉴了 Victor Basu[6]帖子中概述的 Keras 库中的等效实现。图 5.11 重现了[5]中的基本思想。

figure

图 5.11 展示了如何将训练用于重建小图的图自动编码器也用于进行属性预测的例子。属性预测应用于潜在空间,并创建了一个特定图属性的学得梯度——在我们的案例中,是 QED 值。

在图 5.11 中,我们可以看到底层模型结构是一个自动编码器,就像我们在本章中讨论过的那些一样。在这里,我们将分子的 SMILES 作为输入传递给编码器,然后用于构建不同分子表示的潜在空间。这表现为不同颜色区域代表不同的分子组。然后,解码器被设计成忠实地将潜在空间转换回原始分子。这与我们在图 5.5 中展示的自动编码器结构类似。

除了潜在空间,我们现在还有一个额外的函数,它将预测分子的属性。在图 5.11 中,我们将预测的属性也是我们正在优化的属性。因此,通过学*如何将分子和属性(在我们的案例中是 QED)编码到潜在空间中,我们可以优化药物发现,生成具有高 QED 的新候选分子。

在我们的例子中,我们将使用变分自编码器(VGAE)。该模型包括两个损失:一个重构损失,用于衡量编码器接收到的原始输入数据与解码器输出之间的差异,以及一个衡量潜在空间结构的度量,我们使用 KL 散度。

除了这两个损失函数,我们还将添加一个额外的函数:属性预测损失。属性预测损失在通过属性预测模型运行潜在表示后,估计预测属性和实际属性之间的均方误差(MSE),如图 5.11 中间所示。

为了训练我们的图神经网络(GNN),我们将第 5.15 列中提供的早期训练循环进行修改,以包含这些单独的损失。这可以在第 5.18 列中看到。在这里,我们使用二元交叉熵(BCE)作为邻接矩阵的重构损失,而属性预测损失仅考虑量子电动力学(QED),可以基于均方误差(MSE)。

列表 5.18:分子图生成的损失
        def calculate_loss(self, pred_adj, \
   true_adj, qed_pred, qed_true, mu, logvar):
             adj_loss = F.binary_cross_entropy\
   (pred_adj, true_adj)   #1

             qed_loss = F.mse_loss\
(qed_pred.view(-1), qed_true.view(-1))     #2

             kl_loss = -0.5 * torch.mean\
(torch.sum(1 + logvar - mu.pow(2)\     #3
 - logvar.exp(), dim=1))

             return adj_loss + qed_loss + kl_loss

1 重构损失

2 属性预测损失

3 KL 散度损失

5.4.3 用于生成图的 VGAE

现在我们已经有了训练数据和损失,我们可以开始考虑模型。总体而言,这个模型将与本章 earlier 讨论过的模型相似,即 GAE 和 VGAE。然而,我们需要对我们的模型进行一些细微的调整,以确保它能很好地应用于当前的问题:

  • 使用异构图神经网络(GCN)来考虑不同的边类型。

  • 训练解码器以生成整个图。

  • 引入一个属性预测层。

让我们逐一看看这些。

异构图神经网络

我们生成的图将具有不同的边类型,这些边类型连接着我们的图的节点。具体来说,原子之间可以有不同的键数,如单键、双键、三键,甚至芳香键,这些键与形成环状结构的分子相关。具有多个边类型的图被称为异构图,因此我们需要使我们的 GNN 适用于异构图。

到目前为止,我们考虑的所有图都是同构图(只有一种边类型)。在第 5.19 列中,我们展示了第三章中讨论的 GCN 如何适应异构图。在这里,我们明确地绘制了一些异构图的不同特征。然而,需要注意的是,许多 GNN 包已经支持异构图模型。例如,PyG 有一个名为HeteroConv的特定模型类。

列表 5.19 展示了创建异构 GCN 的代码。这基于 PyG 中的消息传递类,这是所有 GNN 模型的基础。我们还使用 PyTorch 的Parameter类创建一个新的参数子集,这些参数针对不同的边类型(关系)是特定的。最后,我们在此处还指定,消息传递框架中的聚合操作基于求和('add')。如果您感兴趣,可以随意尝试其他聚合操作。

列表 5.19 异构 GCN
from torch.nn import Parameter
from torch_geometric.nn import MessagePassing

   class HeterogeneousGraphConv(MessagePassing):
def __init__(self, in_channels, out_channels, num_relations, bias=True):
        super(HeterogeneousGraphConv, self).\
__init__(aggr='add')       #1
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.num_relations = num_relations

        self.weight = Parameter(torch.\
Tensor(num_relations, in_channels, \
out_channels))  #2
        if bias:
             self.bias = Parameter(torch.Tensor(out_channels))
        else:
             self.register_parameter('bias', None)

        self.reset_parameters()

        def reset_parameters(self):
             torch.nn.init.xavier_uniform_(self.weight)
             if self.bias is not None:
                  torch.nn.init.zeros_(self.bias)

        def forward(self, x, edge_index, edge_type):

       return self.propagate\
(edge_index, size=(x.size(0), 
x.size(0)), x=x, edge_type=edge_type)  #3

       def message(self, x_j, edge_type, index, size):   #4

            W = self.weight[edge_type]    #5
            x_j = torch.matmul(x_j.unsqueeze(1), W).squeeze(1)

            return x_j

       def update(self, aggr_out):
            if self.bias is not None:
                 aggr_out += self.bias
            return aggr_out

1 "添加"聚合

2 权重参数

3 使用 edge_type 来选择权重。

4 x_j 的形状为[E, in_channels],而 edge_type 的形状为[E]。

5 选择相应的权重。

在前面的 GNN 的基础上,我们可以将编码器组合为这些单独的 GNN 层的组合。这如图表 5.20 所示,其中我们遵循与定义我们的边编码器时相同的逻辑(参见图表 5.13),只是我们现在用异构 GCN 层替换了 GCN 层。由于我们有不同的边类型,我们现在还必须指定不同类型(关系)的数量,以及将特定的边类型传递给图编码器的正向函数。同样,我们返回对数方差和均值,以确保潜在空间是通过分布而不是点样本来构建的。

列表 5.20 小图编码器
   class VariationalGCEncoder(torch.nn.Module):
        def __init__(self, input_size, layers, latent_dims, num_relations):
             super().__init__()
             self.layer0 = HeterogeneousGraphConv(input_size, 
   layers[0], num_relations)                                     #1
             self.layer1 = HeterogeneousGraphConv(layers[0], 
   layers[1], num_relations)                                    
             self.layer2 = HeterogeneousGraphConv(layers[1], 
   latent_dims, num_relations)                                  

        def forward(self, x, edge_index, edge_type):
             x = F.relu(self.layer0\
(x, edge_index, edge_type))              #2
             x = F.relu(self.layer1\
(x, edge_index, edge_type))             
             mu = self.mu(x, edge_index) 
             logvar = self.logvar(x, edge_index)
             return mu, logvar

1 异构 GCNs

2 前向传递 GCNs

图解码器

在我们之前的例子中,我们使用 GAE 在单个图中的节点之间生成和预测边。然而,我们现在对使用我们的自动编码器生成整个图感兴趣。因此,我们不再仅仅考虑内积解码器来考虑图中边的存在,而是解码每个小分子图的邻接矩阵和特征矩阵。这如图表 5.21 所示。

列表 5.21 小图解码器
class GraphDecoder(nn.Module):
        def __init__(self, latent_dim, adjacency_shape, feature_shape):
        super(GraphDecoder, self).__init__()

        self.dense1 = nn.Linear(latent_dim, 128)
        self.relu1 = nn.ReLU()
        self.dropout1 = nn.Dropout(0.1)

        self.dense2 = nn.Linear(128, 256)
        self.relu2 = nn.ReLU()
        self.dropout2 = nn.Dropout(0.1)

        self.dense3 = nn.Linear(256, 512)
        self.relu3 = nn.ReLU()
        self.dropout3 = nn.Dropout(0.1)

        self.adjacency_output = nn.Linear(512,\
torch.prod(torch.tensor(adjacency_shape)).item())
        self.feature_output = nn.Linear(512,\
torch.prod(torch.tensor(feature_shape)).item())

        def forward(self, z):
             x = self.dropout1(self.relu1(self.dense1(z)))
             x = self.dropout2(self.relu2(self.dense2(x)))
             x = self.dropout3(self.relu3(self.dense3(x)))

             adj = self.adjacency_output(x)   #1
             adj = adj.view(-1, *self.adjacency_shape)
             adj = (adj + adj.transpose(-1, -2)) / 2    #2
             adj = F.softmax(adj, dim=-1)                     #3

             features = self.feature_output(x)   #4
             features = features.view(-1, *self.feature_shape)
             features = F.softmax(features, dim=-1)    #5

             return adj, features

1 生成邻接矩阵

2 对邻接矩阵进行对称化

3 应用 softmax

4 生成特征

5 应用 softmax

这段代码的大部分是典型的解码器风格网络。我们从一个小的网络开始,该网络与编码器创建的潜在空间维度相匹配。然后我们通过网络的后续层逐步增加图的尺寸。在这里,我们可以使用简单的线性网络,其中我们包括网络 dropout 以提高性能。在最后一层,我们将解码器输出重塑为邻接矩阵和特征矩阵。我们还确保在应用 softmax 之前,邻接矩阵是对称的。我们通过对邻接矩阵加上其转置并除以 2 来对称化邻接矩阵。这确保了节点i连接到j,而j也连接到i。然后我们对邻接矩阵应用 softmax 进行归一化,确保每个节点发出的所有边之和为 1。我们还可以在此处做出其他选择,例如使用最大值、应用阈值或使用 sigmoid 函数而不是 softmax。一般来说,平均+ softmax 是一个好的方法。

属性预测层

剩下的就是将编码器和解码器网络合并成一个最终模型,该模型可用于分子图生成,如列表 5.22 所示。总体而言,这遵循了与列表 5.14 中相同的步骤,其中我们定义了编码器和解码器,并使用了重新参数化技巧。唯一的区别是,我们还包含了一个简单的线性网络来预测图的属性,在这种情况下,是 QED。这应用于重新参数化后的潜在表示(z)。

列表 5.22 用于分子图生成的 VGAE
   import torch
   import torch.nn as nn
   import torch.nn.functional as F
   from torch_geometric.nn import MessagePassing

   class VGAEWithPropertyPrediction(nn.Module):
        def __init__(self, encoder, decoder, latent_dim):
             super(VGAEWithPropertyPrediction, self).__init__()
             self.encoder = encoder
             self.decoder = decoder
             self.property_prediction_layer = nn.Linear(latent_dim, 1)

        def reparameterize(self, mu, logvar):
             std = torch.exp(logvar / 2)
             eps = torch.randn_like(std)
             return eps.mul(std).add_(mu)

        def forward(self, data):
             mu, logvar = self.encoder(data.x, \
data.edge_index, data.edge_attr)
             z = self.reparameterize(mu, logvar)
             adj_recon, x_recon = self.decoder(z)
             qed_pred = self.property_prediction_layer(z)
             return adj_recon, x_recon, qed_pred, mu, logvar, z

模型的输出包括均值和对数方差,这些被传递到 KL 散度中;重构的邻接矩阵和特征矩阵,传递到重构损失中;以及预测的 QED 值,这些用于预测损失。使用这些,我们就可以计算我们网络的损失,并通过网络权重反向传播损失来细化生成的图,使其具有特定的、较高的 QED 值。接下来,我们将展示如何在我们的训练和测试循环中实现这一点。

5.4.4 使用 GNN 生成分子

在上一节中,我们讨论了使用 GNN 生成分子所需的所有单个部分。现在,我们将不同的元素组合在一起,并展示如何使用 GNN 创建针对特定属性优化的新图。在图 5.12 中,我们展示了使用 GNN 生成具有高 QED 的分子的步骤。这些步骤包括创建合适的图来表示小分子,将这些图通过我们的自编码器传递,预测特定的分子特征,如 QED,然后重复这些步骤,直到我们能够重新创建具有特定特征的新颖分子图。

figure

图 5.12 使用 GNN 生成分子的步骤

剩下的关键要素是将我们的损失函数与适应的 VGAE 模型相结合。这在上面的列表 5.23 中展示,它定义了我们的训练循环。这与你在前面章节和示例中看到的先前训练循环类似。主要思想是我们的模型用于预测图的某些属性。然而,在这里我们预测的是整个图,如预测的邻接矩阵(pred_adj)和预测的特征矩阵(pred_feat)所定义的。

我们模型的输出和真实数据传递到我们的损失计算方法中,该方法包含重建损失、KL 散度损失和属性预测损失。最后,我们计算梯度惩罚,它作为我们模型(在 5.5 节中更详细地定义)的进一步正则化器。在计算了损失和梯度之后,我们通过模型进行反向传播,将优化器向前移动一步,并返回损失。

列表 5.23 分子图生成训练函数
   def train(model, optimizer, data, test=False):
        model.train()
        optimizer.zero_grad()

        pred_adj, pred_feat, pred_qed, mu, logvar, _ = model(data)

        real_adj = create_adjacency_matrix\
(data.edge_index, data.edge_attr, \
num_nodes=NUM_ATOMS)
        real_x = data.x
        real_qed = data.qed

        loss = calculate_loss\
(pred_adj[0], real_adj, pred_qed, \
real_qed, mu, logvar)   #1

        total_loss = loss

        if not test:
             total_loss.backward()
        optimizer.step()
        return total_loss.item()

1 计算损失

在训练过程中,我们发现模型损失下降,这表明模型正在有效地学*如何复制新型分子。我们在图 5.13 中展示了其中一些分子。

figure

图 5.13 使用 GNN 生成的小分子图

为了更好地理解预测 QED 属性在我们潜在空间中的分布,我们将编码器应用于新的数据子集,并查看数据在潜在空间中的前两个轴,如图 5.14 所示。在这里,我们可以看到潜在空间已经被构建成将具有更高 QED 的分子聚集在一起。因此,通过从这个区域周围的区域进行采样,我们可以识别出新的分子进行测试。未来的工作将需要验证我们的结果,但作为发现新分子的第一步,我们已经证明 GNN 模型可能被用来提出新的、可能具有价值的药物候选者。

figure

图 5.14 药物分子的潜在空间

在本章中,我们关注的是生成任务而不是经典判别模型。我们展示了生成模型,如 GAEs 和 VGAEs,可以用于边缘预测,学*识别节点之间的连接,这些节点可能没有可用信息。然后我们继续展示,当我们应用我们的 GNN 生成具有高 QED 的新小分子时,生成式 GNN 可以用来发现图的不知名部分,如节点或边,甚至可以生成全新的、复杂的图。这些结果强调了 GNN 对于在化学、生命科学以及许多处理大量单个图的学科领域工作的研究人员来说是至关重要的工具。

此外,我们已经了解到 GNNs 在判别性和生成性任务中都非常有用。在这里,我们考虑小分子图的主题,但 GNNs 也已被应用于知识图和小型社交集群。在下一章中,我们将探讨如何通过结合生成性 GNN 和时间编码来学*生成随时间一致性的图。在这种精神下,我们进一步前进,学*如何教会 GNN 如何行走。

5.5 内部结构

深度生成模型使用人工神经网络来建模数据空间。深度生成模型的经典例子之一是自动编码器。自动编码器包含两个关键组件,编码器和解码器,都由神经网络表示。它们学*如何将数据编码(压缩)成低维表示,以及如何解码(解压缩)它。图 5.15 显示了一个基本的自动编码器,它以图像作为输入并压缩它(步骤 1)。这导致低维表示,或潜在空间(步骤 2)。然后自动编码器重建图像(步骤 3),这个过程会重复进行,直到输入图像(x)和输出图像(x*)之间的重建误差尽可能小。自动编码器是 GAEs 和 VGAEs 背后的基本思想。

图

图 5.15 自动编码器的结构 [9]

5.5.1 理解链接预测任务

链接预测是图学*中的常见问题,尤其是在我们对我们数据的不完整知识有所了解的情况下。这可能是由于图随时间变化,例如,我们预计新客户将使用电子商务服务,我们希望有一个模型可以在那时提供最佳建议产品。或者,获取这种知识可能成本高昂,例如,如果我们希望我们的模型预测哪些药物组合会导致特定的疾病结果。最后,我们的数据可能包含错误或故意隐藏的细节,例如社交媒体平台上的虚假账户。链接预测使我们能够推断图中节点之间的关系。本质上,这意味着创建一个模型来预测节点何时以及如何连接,如图 5.16 所示。

图

图 5.16 实际中如何进行链接预测的示意图。输入图的子图(子图)被传递给 GNN,其中缺少不同的链接,模型学*预测何时重新创建链接。

对于链接预测,模型将接受节点对作为输入,并预测这些节点是否连接(是否应该链接)。为了训练模型,我们还需要真实的目标。我们通过在图中隐藏一部分链接来生成这些目标。这些隐藏的链接成为我们将学*推断的缺失数据,被称为负样本。然而,我们还需要一种方法来编码节点对的信息。这两个部分都可以通过 GAEs 同时解决,因为自动编码器既编码关于边的信息,又预测边是否存在。

5.5.2 内积解码器

内积解码器在图中被使用,因为我们希望从特征数据的潜在表示中重建邻接矩阵。GAE 学*如何根据节点的潜在表示重建图(推断边)。在高维空间中的内积计算两个位置之间的距离。我们使用内积,通过 sigmoid 函数进行缩放,来获得节点之间边的概率。本质上,我们使用潜在空间中点之间的距离作为解码时节点连接的概率。这使我们能够构建一个解码器,它从我们的潜在空间中抽取样本,并返回边的存在概率,即执行边预测,如图 5.17 所示。

图

图 5.17 链接预测模型训练的整体步骤

内积解码器通过取我们数据的潜在表示并使用我们数据的传递边索引来计算这个数据的内积来工作。然后我们应用 sigmoid 函数到这个值上,这会返回一个矩阵,其中每个值代表两个节点之间存在边的概率。

正则化潜在空间

简单来说,KL 散度告诉我们,如果我们使用错误的概率密度来估计某物的概率,我们会做得有多糟糕。假设我们有两个硬币,并想要猜测一个硬币(我们知道它是公平的)与另一个硬币(我们不知道它是否公平)匹配得有多好。我们试图使用具有已知概率的硬币来预测具有未知概率的硬币的概率。如果它是一个好的预测器(未知的硬币实际上是公平的),那么 KL 散度将为零。两个硬币的概率密度相同;然而,如果我们发现这个硬币是一个坏的预测器,那么 KL 散度将很大。这是因为两个概率密度将相距甚远。在图 5.18 中,我们可以明确地看到这一点。我们试图使用条件概率密度 P(Z|X)来建模未知的概率密度 Q(z)。随着密度的重叠,这里的 KL 散度将很低。

图

图 5.18 KL 散度计算两个概率密度之间的差异程度。高 KL 散度意味着它们很好地分离,而低 KL 散度意味着它们没有分离。

实际上,我们通过在损失中引入 KL 散度将自编码器转换为 VGAE。我们的意图是既最小化编码器和解码器之间的差异,就像在自编码器损失中那样,也最小化由我们的编码器给出的概率分布与用于生成我们数据的“真实”分布之间的差异。这是通过将 KL 散度添加到损失中实现的。对于许多标准的 VGAE,这由以下给出

(5.1)

figure

其中 (p||q) 表示概率 p 相对于概率 q 的散度。术语 m 是潜在特征的均值,log(var) 是方差的对数。我们在构建 VGAE 时,在损失函数中使用这个值,确保正向传递返回均值和方差给我们的解码器。

过度压缩

我们已经讨论了如何通过消息传递传播节点和边表示来使用 GNN 找出有关节点的信息。这些用于创建单个节点或边的嵌入,有助于引导模型执行某些特定任务。在本章中,我们讨论了如何构建一个模型,通过将消息传递层创建的所有嵌入传播到潜在空间来构建潜在表示。两者都执行图特定数据的降维和表示学*。

然而,GNNs 在如何使用信息来表示方面存在一个特定的限制。GNNs 遭受一种被称为过度压缩的问题,这指的是在图中传播许多跳(即消息传递)的信息如何导致性能的显著下降。这是因为每个节点接收信息来自的邻域,也称为其感受野,随着 GNN 层数的增加而呈指数增长。随着通过这些层进行消息传递而聚合的信息越来越多,来自远端节点的信号与来自*端节点的信息相比变得稀释。这导致节点表示变得相似或更均匀,最终收敛到相同的表示,也称为过度平滑,这在第四章中已讨论过。

实证证据表明,这可能在只有三到四层时就开始发生[7],如图 5.19 所示。这突出了 GNN 与其他深度学*架构之间的一个关键区别:我们很少希望构建一个非常深的模型,其中有很多层堆叠在一起。对于具有许多层的模型,通常也会引入其他方法来确保包括长距离信息,例如跳跃连接或注意力机制。

figure

图 5.19 过度压缩的可视化(来源:Alon 和 Yahav [7])

在本章前面的例子中,我们讨论了使用 GNNs 进行药物发现。在这里,我们考虑了一个图相对较小的例子。然而,当图变得更大时,长程相互作用变得重要的风险会增加。这在化学和生物学中尤其如此,图中极端端的节点可能对图的总体性质有不成比例的影响。在化学的背景下,这些可能是一对位于大分子两端的原子,它们决定了分子的总体性质,如毒性。我们需要考虑以有效建模问题的相互作用范围或信息流被称为问题半径。在设计 GNN 时,我们需要确保层数至少与问题半径一样大。

通常,有几种方法可以解决 GNNs 的过度压缩问题:

  • 确保不要堆叠太多的层。

  • 在节点之间添加新的“虚假”边,这些节点相隔很远/跳数很多,或者引入一个连接到所有其他节点的单个节点,这样问题半径就减少到 2。

  • 使用采样,如 GraphSAGE,它从邻域中采样或引入跳跃连接,这些连接类似地跳过一些局部邻居。对于采样方法,重要的是在损失局部信息与获得更多长程信息之间取得平衡。

所有这些方法都非常特定于问题,当决定长程相互作用是否重要时,你应该仔细思考你图中节点之间的交互类型。例如,在下一章中,我们考虑了运动预测,其中头部相对于膝盖对脚的影响可能很小。或者,正如本章所描述的分子图,可能来自更远节点的较大影响。因此,解决过度压缩等问题最重要的部分是确保你对问题和数据都有扎实的理解。

摘要

  • 判别模型学*区分数据类别,而生成模型学*模拟整个数据空间。

  • 生成模型通常用于执行降维。主成分分析(PCA)是一种线性降维方法。

  • 自动编码器包含两个关键组件,编码器和解码器,都由神经网络表示。它们学*如何将数据编码(压缩)成低维表示,以及如何解码(解压缩)它。对于自动编码器,低维表示被称为潜在空间。

  • VAEs 将自动编码器扩展到损失中包含正则化项。这个正则化项通常是 Kullback-Liebler(KL)散度,它衡量两个分布之间的差异——学*到的潜在分布和先验分布。VAEs 的潜在空间更加结构化和连续,其中每个点代表一个概率密度,而不是固定的点编码。

  • 自动编码器和变分自动编码器(VAEs)也可以应用于图。它们分别是图自动编码器(GAE)和变分图自动编码器(VGAE)。它们与典型的自动编码器和 VAEs 类似,但解码器元素通常是应用于边列表的点积。

  • GAEs 和 VGAEs 在边缘预测任务中非常有用。它们可以帮助我们预测图中可能存在的隐藏边。

第三部分 高级主题

图神经网络(GNNs)的演变解锁了丰富的新的可能性,本书的这一部分深入探讨了其中一些最激动人心且复杂的领域。我们首先考察时空 GNNs,它们模拟随时间演变的动态图,以及如运动分析中的姿态估计等应用。接下来,我们应对将 GNNs 扩展到大规模数据集的挑战,探索在保持高性能的同时高效处理工业规模图的策略。最后,我们关注构建和部署 GNN 项目的实际考虑因素,包括如何从非图数据创建图数据模型,从原始数据源执行 ETL(提取、转换、加载)和预处理,以及使用 PyTorch Geometric(PyG)构建数据集和数据加载器。本部分中的每一章都提供了可操作的建议和工具,帮助你掌握这些高级主题,让你能够充分发挥 GNNs 在工作中的潜力。

第六章:动态图:时空 GNN

本章涵盖

  • 将记忆引入你的深度学*模型

  • 理解使用图神经网络建模时序关系的不同方法

  • 实现动态图神经网络

  • 评估你的时序图神经网络模型

到目前为止,我们所有的模型和数据都只是时间上的单一快照。在实践中,世界是动态的,并且处于不断变化之中。物体可以物理移动,在我们眼前沿着轨迹移动,我们能够根据这些观察到的轨迹预测它们的未来位置。交通流量、天气模式和疾病在人群网络中的传播都是当使用时空图而不是静态图建模时可以获取更多信息的情况。

我们今天构建的模型,一旦部署到现实世界中,可能会迅速失去性能和准确性。这些问题是任何深度学*(以及机器学*)模型固有的,被称为分布外(OOD)泛化问题,即模型对完全未见过的数据的泛化能力如何。

在本章中,我们考虑如何构建适合动态事件的模型。虽然这并不意味着它们可以处理 OOD 数据,但我们的动态模型将能够利用最*过去的数据对未来未见的事件进行预测。

为了构建我们的基于动态图的 学*模型,我们将考虑姿态估计的问题。姿态估计与那些预测身体(人类、动物或机器人)随时间移动的类问题相关。在本章中,我们将考虑一个行走的人体,并构建几个模型来学*如何从一系列视频帧中预测下一步。为此,我们首先将更详细地解释这个问题,以及如何将其理解为一个关系问题,然后再深入探讨基于图的学*方法是如何处理这个问题的。与本书的其他部分一样,更详细的技术细节留到本章末尾的 6.5 节。

我们将使用本书中已经覆盖的大部分材料。如果你已经跳到了这一章,请确保你对“建立在所学知识的基础上”侧边栏中描述的概念有很好的理解。

备注:本章的代码以笔记本形式可在 GitHub 仓库(mng.bz/4a8D)中找到。

建立在所学知识的基础上

要将时序更新引入我们的 GNN,我们可以基于之前章节中学到的某些概念。作为一个快速回顾,我们已经总结了每个章节的一些主要重要特性:

  • 消息传递——在第二章中,你了解到 GNN(图神经网络)从关系数据中学*的主要方法是结合消息传递与人工神经网络。GNN 的每一层都可以理解为消息传递的一个步骤。

  • 图卷积网络(GCNs)—在第三章中,你看到消息传递本身可以理解为卷积算子的关系形式(如卷积神经网络 [CNNs] 中的那样),这是 GCNs 背后的核心思想。消息也可以通过仅采样最*邻的子集来在邻域内平均。这用于 GraphSAGE,并且可以显著减少所需的总体计算量。

  • 注意—在第四章中,我们展示了消息传递的聚合函数不需要仅限于求和、平均或最大操作(尽管操作必须是排列不变的)。注意力机制允许在训练过程中学*权重,从而为消息传递聚合函数提供更大的灵活性。使用图注意力网络(GAT)是向消息传递添加注意力的基本形式。

  • 生成模型—虽然判别模型试图学*数据类之间的分离,但生成模型试图学*底层的数据生成过程。自动编码器是设计生成模型中最受欢迎的框架之一,其中数据通过神经网络瓶颈传递以创建数据的低维表示,也称为潜在空间。这些通常作为图自动编码器(GAEs)或变分图自动编码器(VGAEs)在图中实现,正如我们在第五章中讨论的那样。

6.1 时间模型:通过时间的关系

几乎每个数据问题在某种程度上也会是一个动态问题。在许多情况下,我们可以忽略时间的变化,并构建适合我们所收集的数据快照的模型。例如,图像分割方法很少考虑视频素材来训练模型。

在第三章中,我们使用 GCN 根据客户购买网络上的数据预测向客户推荐的产品。我们使用了一个跨越数年的玩具数据集。然而,在现实中,我们通常会拥有持续的数据流,并希望做出最新的预测,这些预测要考虑到客户和文化*惯的变化。同样,当我们将 GAT 应用于欺诈检测问题时,我们所使用的数据是在数年内收集的金融记录的单一快照。然而,我们没有在我们的模型中考虑到金融行为随时间的变化。再次,我们可能会希望使用这些信息来预测个人的消费行为突然改变的位置,以帮助我们检测欺诈活动。

这些只是我们每天面临的大量不同动态问题中的一小部分(见图 6.1)。GNN 的独特之处在于它们可以模拟动态和关系变化。这一点非常重要,因为围绕我们的许多网络也在随时间移动。以社交网络为例。我们的友谊会变化、成熟,并且不幸地(或者幸运地!)会减弱。我们可能会与工作同事或朋友的朋友变得更亲密,而与家乡的朋友见面的频率会降低。对社交网络进行预测需要考虑到这一点。

作为另一个例子,我们经常根据我们对道路、交通模式和我们的紧迫感的了解来预测我们将走向何方以及我们何时可能到达。动态 GNN 也可以用来帮助利用这些数据,通过将道路网络视为图,并对该网络如何变化进行时间预测。最后,我们可以考虑预测两个或更多物体如何一起移动,即通过估计它们的未来轨迹。虽然这可能不如交朋友或按时到达工作地点有用,但预测相互作用物体的轨迹,如分子、细胞、物体甚至恒星,对于许多科学以及机器人规划都是至关重要的。同样,动态 GNN 可以帮助我们预测这些轨迹并推断解释它们的新的方程或规则。

figure

图 6.1 不同动态问题的示例

这些例子只是我们需要对时间变化进行建模的应用的冰山一角。实际上,我们确信你们可以想到很多其他的例子。鉴于了解如何结合关系学*和时间学*的重要性,我们将介绍三种构建动态模型的不同方法,其中两种使用 GNN:一个循环神经网络(RNN)模型,一个 GAT 模型,以及一个神经关系推理(NRI)模型。我们将通过估计人类姿态随时间的变化来构建“学*走路”的机器学*模型。这些模型通常被部署在例如医疗咨询、远程家庭安全服务和电影制作中。这些模型也是我们在能够奔跑之前学*走路的绝佳玩具问题。本着这种精神,让我们首先更多地了解数据并构建我们的第一个基准模型。

6.2 问题定义:姿态估计

在本章中,我们将使用一组数据解决一个“动态关系”问题:一个行走身体的预处理分割。这是一个探索这些技术的有用数据集,因为移动的身体是相互作用系统的教科书式例子:我们的脚移动是因为膝盖移动,因为腿移动,而我们的手臂和躯干也会移动。这意味着我们的问题有一个时间成分。

简而言之,我们的姿态估计问题关乎路径预测。更精确地说,我们想要知道,例如,在跟随身体其他部分经过一定数量的前一时间步之后,一只脚会移动到哪个位置。这种类型的对象跟踪是我们每天都会做的事情,例如,当我们运动、接住掉落的东西或观看电视节目时。我们在儿童时期就学会了这项技能,并且常常认为这是理所当然的。然而,正如你将看到的,直到时空图神经网络(spatiotemporal GNNs)的出现,教机器执行这种对象跟踪是一个重大的挑战。

我们将用于路径预测的技能对于许多其他任务都很重要。当我们想要预测客户的下一次购买或根据地理空间数据了解天气模式如何变化时,预测未来的事件是有用的。

我们将使用卡内基梅隆大学(CMU)动作捕捉数据库(mocap.cs.cmu.edu/),其中包含许多不同动态姿态的示例,包括行走、跑步、跳跃以及进行体育动作,以及多人互动[1]。在本章中,我们将使用受试者#35 行走的相同数据集。在每一个时间步,受试者有 41 个传感器,每个传感器跟踪一个单一的关节,从脚趾到颈部。这个数据库中的数据示例如图 6.2 所示。这些传感器跟踪身体部分在运动快照中的移动。在本章中,我们不会跟踪整个运动,而只考虑运动的小部分。我们将使用前 49 帧作为我们的训练和验证数据集,以及 99 帧作为我们的测试集。总共有 31 个不同的人体受试者行走示例。我们将在下一节中讨论我们数据的结构。

figure

图 6.2 展示了一个人体受试者行走的时间快照(t = 秒)。这些点代表放置在人体关键关节上的传感器。这些快照跨越了 30 秒。为了将这些图像表示为图,传感器的放置(关节)可以表示为节点,而身体关节之间的连接是边。

6.2.1 设置问题

我们的目标是预测所有单个关节的动态。显然,我们可以将其构建为一个图,因为所有关节都通过边连接,如图 6.2 所示。因此,使用图神经网络(GNNs)来解决这个问题是有意义的。然而,我们首先将比较另一种方法,这种方法没有考虑图数据,以作为我们 GNN 模型的基准。

下载数据

我们在我们的代码仓库中包含了下载和预处理数据的步骤。数据包含在一个 zip 文件中,其中每个不同的试验都保存为高级系统格式(.asf)文件。这些.asf 文件基本上只是包含每个传感器在每个时间步的标签及其 xyz 坐标的文本文件。在下面的列表中,我们展示了一段文本的片段。

列表 6.1 传感器数据文本文件示例
   1
   root 4.40047 17.8934 -21.0986 -0.943965 -8.37963 -7.42612
   lowerback 11.505 1.60479 4.40928
   upperback 0.47251 2.84449 2.26157
   thorax -5.8636 1.30424 -0.569129
   lowerneck -15.9456 -3.55911 -2.36067
   upperneck 19.9076 -4.57025 1.03589

在这里,第一个数字是帧号,root是特定于传感器的,可以忽略。lowerbackupperbackthoraxlowerneckupperneck表示传感器的位置。总共有 31 个传感器映射一个行走的人的运动。为了将这个传感器数据转换为轨迹,我们需要计算每个传感器的位置变化。这变成了一项相当复杂的工作,因为我们需要考虑每个帧之间各种传感器的平移运动和角旋转。在这里,我们将使用与 NRI 论文[2]中相同的数据文件。我们可以使用这些文件在 x、y 和 z 方向上绘制每个单个传感器的轨迹,或者观察传感器在二维空间中的运动,以了解整个身体的运动。图 6.3 中的例子展示了这一点,我们关注脚传感器的 x、y 和 z 方向的运动,以及随着时间的推移身体的整体运动(传感器以实心黑色星号表示)。

图

图 6.3 传感器的预构建空间轨迹

除了空间数据,我们还可以计算速度数据。这些数据以每个电影帧的单独文件提供。速度数据变化的一个例子如图 6.4 所示。如图所示,速度数据在一个较小的范围内变化。空间和速度数据都将作为我们机器学*问题中的特征。在这里,我们现在有 31 个传感器和 33 个不同试验的每个传感器的 50 帧,共有六个特征。我们可以将此视为一个多元时间序列问题。我们试图预测一个六维(三个空间和三个速度)对象(每个传感器)的未来演化。我们的第一个方法将它们视为独立的,试图根据过去的传感器数据预测未来的位置和速度。然后我们将转向将其视为一个图,其中我们可以将所有传感器耦合在一起。

图

图 6.4 传感器的预构建速度数据

目前,这是一个关系问题,但我们只考虑节点数据,而不是边数据。当存在节点数据而没有边数据时,我们必须小心不要做出太多的假设。例如,如果我们选择根据节点之间的距离来连接节点,那么我们可能会得到一个非常奇怪的骨架,如图 6.5 所示。幸运的是,我们还有边数据,这些数据是使用 CMU 数据集构建的,并包含在提供的数据中。这是一个警示故事,说明 GNN 的强大程度取决于它们训练的图,我们必须小心确保图结构正确。然而,如果边数据完全缺失,那么我们可以尝试从节点数据本身推断边数据。虽然我们在这里不会这样做,但请注意,我们将使用的 NRI 模型具有这种能力。

figure

图 6.5 显示错误推断图结构的传感器网络。节点是人体骨骼连接。左图显示了一个具有从节点邻*性推断的边(最*的节点相互连接)的网络。此图并不反映真实的人体骨骼。真正的边集在右图中显示。

我们现在已经加载了所有数据。总共有三个数据集(训练、验证、测试),每个数据集包含 31 个单个传感器位置。每个传感器包含六个特征(空间坐标),并通过一个随时间恒定的邻接矩阵连接。传感器图是无向的,边是无权的。训练和验证集包含 49 帧,测试集包含 99 帧。

6.2.2 带有记忆的模型构建

现在我们已经定义了问题并加载了数据,让我们考虑如何处理预测关节动态的问题。首先,我们需要思考基本目标是什么。本质上,我们将参与序列预测,就像手机上的自动完成或搜索工具一样。这类问题通常使用网络(如 transformers)来解决,我们在第四章中使用了注意力机制。然而,在基于注意力的网络之前,许多深度学*从业者通过在模型中引入记忆来处理序列预测任务[3]。这从直觉上是有意义的:如果我们想要预测未来,我们需要记住过去。

让我们构建一个简单的模型,该模型使用过去的事件预测所有单个传感器的下一个位置。本质上,这意味着我们将构建一个模型来预测没有边数据的节点位置。我们将尝试的示例如图 6.6 所示。在这里,我们将首先进行预处理和准备数据,以便传递给可以预测数据随时间演变的模型。这使我们能够根据几个输入帧预测姿态的变化。

figure

图 6.6 仅使用传感器数据预测未来位置

为了将记忆引入我们的神经网络,我们首先考虑循环神经网络(RNN)。与卷积和注意力神经网络类似,RNN 是一类广泛的架构,是研究人员和实践者共同的基本工具。有关 RNN 的更多信息,请参阅例如《用 TensorFlow 进行机器学*》(Manning,2020 年,mng.bz/VVOW)。RNN 可以被视为多个相互连接的独立网络。这些重复的子网络允许“记住”过去的信息,以及过去数据对未来预测的影响。初始化后,每个子网络都会接收输入数据以及最后一个子网络的输出,并使用这些信息进行新的预测。换句话说,每个子网络都会接收来自最*过去的信息和输入,以构建关于数据的推理。然而,普通的 RNN 只会记住前一步。它们的记忆非常短暂。为了增强过去对未来影响的效果,我们需要更强大的东西。

长短期记忆(LSTM)网络是另一种用于建模和预测时间或序列信息的极受欢迎的神经网络架构。这些网络是 RNN 的特殊情况,类似于将多个子网络链接在一起。不同之处在于,LSTMs 在子网络结构中引入了更复杂的依赖关系。LSTMs 对于序列数据特别有用,因为它们解决了 RNN 中观察到的梯度消失问题。简单来说,梯度消失指的是我们使用梯度下降法训练神经网络时,梯度变为零的情况。当我们训练具有许多层的 RNN 时,这种情况尤其可能发生。(我们在这里不会深入探讨其原因,但如果您对此感兴趣,请阅读《用 Python 进行深度学*》(Manning,2024 年,mng.bz/xKag)以获取更多信息。)

门控循环单元网络(GRUs)通过允许将关于最*过去的新信息添加到记忆存储中,解决了梯度消失的问题。这是通过一个门控结构实现的,模型架构中的门控帮助控制信息的流动。这些门控还为我们构建和调整神经网络添加了一个新的设计元素。在这里我们不会考虑 LSTM,因为它超出了本书的范围,但再次建议您查阅《用 Python 进行深度学*》(Manning,2024 年,mng.bz/xKag)以获取更多信息。

构建循环神经网络

现在我们来看看如何使用 RNN 来预测随时间变化的身体传感器的轨迹,这将成为我们未来性能提升的基准之一。我们不会深入探讨 RNN 和 GRU 架构的细节,但有关信息可在本章 6.5 节末尾找到。

这个模型的想法是我们的 RNN 将预测传感器的未来位置,而不考虑关系数据。当我们开始介绍我们的图模型时,我们将看到如何改进这一点。

我们将使用与图 6.7 所示相同的深度学*标准训练循环。一旦我们定义了我们的模型并定义了训练和测试循环,我们就使用这些循环来训练和测试模型。一如既往,我们将训练数据和测试数据完全分开,并包括一个验证数据集,以确保我们的模型在训练过程中不会过拟合。

图

图 6.7 本章我们将遵循的深度学*模型的标准训练流程

这里使用的训练循环相当标准,所以我们首先描述它。在列表 6.2 中显示的训练循环定义中,我们遵循与之前章节相同的惯例,通过固定数量的时代在模型预测和损失更新之间循环。在这里,我们的损失将包含在我们的标准函数中,我们将其定义为简单的均方误差(MSE)损失。我们将使用学*率调度器,该调度器将在验证损失开始平台期后降低学*率参数。我们将最佳损失初始化为无穷大,并在验证损失小于最佳损失N步之后降低学*率。

列表 6.2 训练循环
   num_epochs = 200  
   train_losses = []
   valid_losses = []

   pbar = tqdm(range(num_epochs))

   for epoch in pbar:

        train_loss = 0.0  #1
        valid_loss = 0.0  #1

        modelRNN.train()  #2
        for i, (inputs, labels) in enumerate(trainloader):
             inputs = inputs.to(device)
             labels = labels.to(device)

        optimizer.zero_grad()  #3

        outputs = modelRNN(inputs)  #4
        loss = criterion(outputs, labels)  #4
        loss.backward()  #4
        optimizer.step()  #4

        train_loss += loss.item() * inputs.size(0)  #5

        modelRNN.eval()   #6
        with torch.no_grad():
             for i, (inputs, labels) in enumerate(validloader):
                  inputs = inputs.to(device)
                  labels = labels.to(device)

                  outputs = modelRNN(inputs) 
                  loss = criterion(outputs, labels)
                  valid_loss += loss.item() * inputs.size(0)

         if valid_loss < best_loss:  #7
              best_loss = valid_loss
              counter = 0
         else:
              counter += 1

         scheduler.step(best_loss)  #8

         if counter == early_stop:
         print(f"\n\nEarly stopping \
initiated, no change \
after {early_stop} steps")
         break

        train_loss = train_loss/len(trainloader.dataset)  #9
        valid_loss = valid_loss/len(validloader.dataset)  #9

        train_losses.append(train_loss)  #9
        valid_losses.append(valid_loss)  #9

1 初始化损失和准确率变量

2 开始训练循环

3 将参数梯度置零

4 前向 + 反向 + 优化

5 更新训练损失,乘以当前小批次的样本数

6 开始验证循环

7 检查早期停止

8 步进调度器

9 计算并存储损失

两个层都针对特定任务进行训练(使用列表 6.3 中的训练循环)。对于 RNN 和 GRU,数据的格式将是单独的试验或视频,帧时间戳,传感器数量和传感器的特征。通过提供分割成单独时间快照的数据,模型能够利用时间方面进行学*。在这里,我们使用 RNN 根据 40 个之前的帧预测每个单独传感器的未来位置。对于所有的计算,我们将基于节点特征(位置和速度)使用最小-最大缩放来归一化数据。

在我们完成训练循环后,我们测试我们的网络。一如既往,我们不希望更新网络的参数,因此我们确保没有反向传播的梯度(通过选择torch.no_grad())。请注意,我们选择 40 个序列长度,以便我们的测试循环能够看到前 40 帧,然后尝试推断最后的 10 帧。

列表 6.3 测试循环
   model.eval()   #1
   predictions = [] 
   test_losses = [] 
   seq_len = 40 

   with torch.no_grad():
        for i, (inputs, targets) in enumerate(testloader):
             inputs = inputs.to(device)
             targets = targets.to(device)

             preds = []
             for _ in range(seq_len):
                  output = model(inputs)
                  preds.append(output)

             inputs = torch.cat([inputs[:, 1:]\
, output.unsqueeze(1)], dim=1) \ #2

        preds = torch.cat(preds, dim=1)  #3
           loss = criterion(preds, targets)  #3
           test_losses.append(loss.item())  #3

           predictions.append(preds.detach().cpu().numpy())

    predictions = np.concatenate(predictions, axis=0)  #4
   test_loss = np.mean(test_losses)  #5

1 将模型设置为评估模式

2 更新下一次预测的输入

3 计算此序列的损失

4 将预测转换为 NumPy 数组以便更容易操作

5 计算平均测试损失

一旦我们的模型被定义,我们就可以使用列表 6.3 中给出的训练循环来训练我们的模型。在这个阶段,你可能想知道我们如何修改训练循环以正确地考虑反向传播时的时序元素。好消息是,PyTorch 会自动处理这个问题。我们发现,RNN 模型能够以 70%的准确率预测验证数据的未来位置,以及 60%的准确率预测测试数据的未来位置。

我们还尝试了一个 GRU 模型来预测未来的步骤,并发现这个模型使用验证数据能够达到 75%的准确率。这相当低,但考虑到模型的简单性和我们传递给它的信息量很小,这并不低。然而,当我们测试模型在测试数据上的性能时,我们可以看到性能下降到 65%。我们的模型的一些示例输出显示在图 6.8 中。显然,模型很快就会退化,估计的姿态位置开始大幅变化。为了获得更高的准确率,我们需要在姿态数据中使用一些关系归纳偏见。

figure

图 6.8 使用 RNN 预测未来运动。在这里,左边的图表示真实数据,右边的图表示预测数据。

6.3 动态图神经网络

为了预测图的未来演化,我们需要重新结构我们的数据以考虑时序数据。具体来说,动态 GNN 连接图演化的不同连续快照,并学*预测未来的演化[4–6]。实现这一目标的一种方法是将它们组合成一个单一的图。这个时序图现在包含了每一步的数据以及作为具有时序边的节点编码的时序连接。我们将首先通过采用对图演化建模的朴素方法来处理姿态估计的任务。我们将探讨如何将我们的时序数据组合成一个大型图,然后通过屏蔽感兴趣的节点来预测未来的演化。我们将使用与第三章中看到相同的 GAT 网络。然后,在第 6.4 节中,我们将展示另一种通过编码每个图的快照并使用变分自编码器(VAEs)和 RNNs 的组合来预测演化的方法,即 NRI 方法[2]。

6.3.1 动态图上的图注意力网络

我们将探讨如何将我们的姿态估计问题转换为基于图的问题。为此,我们需要构建一个考虑时间信息的邻接矩阵。首先,我们需要将我们的数据作为 PyTorch Geometric(PyG)数据对象加载进来。我们将使用与训练我们的 RNN 相同的地点和速度数据。这里的区别在于,我们将构建一个包含所有数据的单个图。列表 6.4 中的代码片段展示了我们如何初始化我们的数据集。我们传递位置和速度数据以及边缘数据所在的路径。我们还传递是否需要转换我们的数据以及我们将预测的掩码和窗口大小。

列表 6.4 以图形式加载数据
   class PoseDataset(Dataset):
        def __init__(self, loc_path, 
                          vel_path, 
                          edge_path, 
                          mask_path, 
                          mask_size, 
                          transform=True):

       self.locations = np.load(loc_path)  #1
        self.velocities = np.load(vel_path)  #1
        self.edges = np.load(edge_path)

        self.transform=transform
        self.mask_size = mask_size  #2
        self.window_size = self.locations\
.shape[1] - self.mask_size  #3

1 从.npy 文件加载数据

2 确定掩码大小

3 确定窗口大小

对于我们所有的数据集对象,我们需要在类中实现一个get方法来描述如何检索这些数据,这将在列表 6.5 中展示。此方法将位置和速度数据组合成节点特征。我们还提供了一个选项,可以使用normalize_array函数转换数据。

列表 6.5 使用位置和速度数据设置节点特征
   def __getitem__(self, idx):
        nodes = np.concatenate((self.locations[idx], 
   self.velocities[idx]), axis=2)  #1
        nodes = nodes.reshape(-1, nodes.shape[-1])  #2

        if self.transform:  #3
             nodes, node_min, node_max\
    = normalize_array(nodes) 

        total_timesteps = self.window_size + self.mask_size  #4
        edge_index = np.repeat(self.\
edges[None, :], total_timesteps, axis=0) 

         N_dims = self.locations.shape[2]
        shift = np.arange(total_\
   timesteps)[:, None, None]*N_dims  #5
         edge_index += shift
         edge_index = edge_index.reshape(2, -1)   #6

         x = torch.tensor(nodes, dtype=torch.float)  #7
         edge_index = torch.tensor\
(edge_index, dtype=torch.long) 
          mask_indices = np.arange(         #8
               self.window_size * self.\
locations.shape[2],                        
               total_timesteps * \
self.locations.shape[2]                    
                    )                      
           mask_indices = torch.tensor(mask_indices, dtype=torch.long)

           if self.transform:
                  trnsfm_data = [node_min, node_max]
                  return Data(x=x, 
                       edge_index=edge_index, 
                       mask_indices=mask_indices,  
                       trnsfm=trnsfm_data
                        )
            return Data(x=x, edge_index=\
edge_index, mask_indices=mask_indices)

1 将每个节点的位置和速度数据连接起来

2 确定掩码大小

3 如果转换为 True,则应用归一化

4 对总时间步数(过去+未来)重复边缘

5 将平移应用于边缘索引

6 将边缘索引展平到二维

7 将所有内容转换为 PyTorch 张量

8 计算掩码节点的索引

接下来,我们希望将不同时间步长的所有节点组合成一个包含所有单独帧的大型图。这给出一个覆盖所有不同时间步长的邻接矩阵。(关于时间邻接矩阵概念的进一步细节,请参阅本章末尾的 6.5 节。)为了对我们的姿态估计数据进行此操作,我们首先构建每个时间步长的邻接矩阵,如列表 6.6 所示,并包含在列表 6.5 中。

如图 6.9 所示,过程从表示跨越多个时间步长的图数据开始,其中每个时间步长被视为一个独立的层(步骤 1)。所有节点都有节点特征数据(图中未显示)。对于我们的应用,节点特征数据由位置和速度信息组成。

在同一时间步长内的节点通过时间步长内边缘相互连接,即同一时间步长层(步骤 2)之间的节点连接。这些边缘确保特定时间步长的每个图在内部是一致的。节点尚未在不同时间步长之间连接。

为了纳入时间关系,添加了时间步长间边缘(即不同时间步长层之间的节点连接),以连接相邻时间步长中的对应节点(步骤 3)。这些边缘允许不同时间步长的节点之间传递信息,从而实现图数据的时序建模。

为了预测未来的值,最后时间步的节点被掩码以表示未知数据(步骤 4)。这些掩码节点被视为预测任务的靶标。它们的值是未知的,但可以通过利用早期时间步中未掩码节点的特征和关系来推断。

推理过程(步骤 5)涉及使用来自先前时间步(t = 0 和 t = 1)的未掩码节点的已知特征来预测 t = 2 中掩码节点的特征。虚线箭头说明了信息如何从未掩码节点流向掩码节点,显示了预测对早期图数据的依赖性。这把任务转换成了一个节点预测问题,其目标是根据未掩码节点的关联和特征来估计掩码节点的特征。

图

图 6.9 时空图构建和推理过程的示意图。步骤 1 显示了时间步之间的图序列,每个时间步的节点代表实体。步骤 2 突出了同一图层内节点之间的时间步内边(实线)。步骤 3 引入了时间步间边(虚线),通过连接相邻时间步中相应的节点来编码时间依赖性。在步骤 4 中,最终时间步的节点被掩码(灰色)以表示预测的未知值。步骤 5 展示了推理过程(虚线箭头),其中使用早期时间步中未掩码节点的信息来估计掩码节点的特征。图例说明了在图表示中使用的节点和边的类型。
列表 6.6 构建邻接矩阵
       total_timesteps = self.\
window_size + self.mask_size  #1
       edge_index = np.repeat(self.edges[None, :],\
 total_timesteps, axis=0) 

       shift = np.arange(total_timesteps)[:, None, \
None] * num_nodes_per_timestep  #2
       edge_index += shift  #3
       edge_index = edge_index.reshape(2, -1)  #4

1 重复边以匹配总时间步数(过去 + 未来)

2 为每个时间步创建一个偏移

3 将偏移应用于边索引

4 将边索引展平到二维

现在我们有了邻接矩阵,下一步是构建一个可以预测未来时间步的模型。在这里,我们将使用第四章中介绍的 GAT 模型 [7]。我们选择这个 GNN 是因为它可以比其他 GNN 更具表现力,我们想要一个能够考虑不同时间和空间信息的模型。模型架构在列表 6.7 中提供。

列表 6.7 定义 GAT 模型
  class GAT(torch.nn.Module):
        def __init__(self, n_feat,
                      hidden_size=32,
                      num_layers=3,
                      num_heads=1,
                      dropout=0.2,
                      mask_size=10):
             super(GAT, self).__init__()

             self.num_layers = num_layers
             self.heads = num_heads
             self.n_feat = n_feat
             self.hidden_size = hidden_size
             self.gat_layers = torch.nn.ModuleList()
             self.batch_norms = torch.nn.ModuleList()
             self.dropout = nn.Dropout(dropout)
             self.mask_size = mask_size

             gat_layer = GATv2Conv(self.n_feat,\
 self.hidden_size, heads=num_heads)  #1
             self.gat_layers.append(gat_layer)  #1
             middle_size = self.hidden_size*num_heads 
             batch_layer = nn.BatchNorm1d\
(num_features=middle_size)  #2
             self.batch_norms.append(batch_layer) #2

             for _ in range(num_layers-2):  #3
                  gat_layer = GATv2Conv(input_size,\
 self.hidden_size, heads=num_heads) 
                  self.gat_layers.append(gat_layer) 
                  batch_layer = nn.BatchNorm1d(num_features\
=middle_size)                                           #4
                  self.batch_norms.append(batch_layer) 

             gat_layer = GATv2Conv(middle_size, self.n_feat)
             self.gat_layers.append(gat_layer)  #5

        def forward(self, data):
             x, edge_index = data.x, data.edge_index
             for i in range(self.num_layers):
                  x = self.gat_layersi
                  if i < self.num_layers - 1:  #6
                       x = self.batch_normsi  #6
                       x = torch.relu(x)  #6
                       x = self.dropout(x)  #6

             n_nodes = edge_index.max().item() + 1  #7
             x = x.view(-1, n_nodes, self.n_feat)
             return x[-self.mask_size:].view(-1, self.n_feat)

1 第一个 GAT 层

2 第一个 GAT 层的 BatchNorm 层

3 中间 GAT 层

4 中间 GAT 层的 BatchNorm 层

5 最后一个 GAT 层

6 不要将批归一化和 dropout 应用于最后一个 GAT 层的输出。

7 仅输出最后一帧

此模型遵循第四章中概述的基本结构。我们定义了模型的层数和头数,以及相关的输入大小,这取决于我们预测的特征数量。我们每个 GAT 层都有一个隐藏大小,并包括 dropout 和批量归一化来提高性能。然后我们遍历模型中的层数,确保维度正确以匹配我们的目标输出。我们还定义了我们的前向函数,该函数预测掩码节点的节点特征。通过将每个时间步展开到更大的图中,我们开始引入时序效应,作为模型可以学*的额外网络结构。

在定义了模型和数据集之后,让我们开始训练我们的模型并看看它的表现如何。回想一下,RNN 和 GRU 在测试准确率上分别达到了 60%和 65%。在列表 6.8 中,我们展示了 GAT 模型的训练循环。这个训练循环遵循了之前章节中使用的相同结构。我们使用均方误差(MSE)作为损失函数,并将学*率设置为 0.0005。我们使用 GAT 计算掩码节点的节点特征,并将其与存储在data中的真实数据进行比较。我们首先训练我们的模型,然后使用验证集比较模型预测。请注意,由于我们现在预测了多个图序列,这个训练循环比之前的模型花费了更多的时间。在 Google Colab 的 V100 GPU 上,这需要不到一个小时来训练。

列表 6.8 GAT 训练循环
   lr = 0.001
   criterion = torch.nn.MSELoss()                            #1
   optimizer = torch.optim.Adam(model.parameters(), lr=lr)  

   for epoch in tqdm(range(epochs), ncols=300):
        model.train()
        train_loss = 0.0
        for data in train_dataset:
             optimizer.zero_grad()
             out = model(data)  #2

        loss = criterion(out, \
data.y.reshape(out.shape[0], -1))  #3
        loss.backward() 
        optimizer.step()
        train_loss += loss.item()

        model.eval()  #4
        val_loss = 0.0  #4
        with torch.no_grad():  #4
             for val_data in val_dataset:  #4
               val_out = model(val_data)  #5
                  val_loss += criterion(out, \
data.y.reshape(out.shape[0],\
 -1)).item()  #6

        val_loss /= len(val_dataset)
        train_loss /= len(train_dataset)

1 初始化损失和优化器,设置学*率

2 生成模型对输入的预测

3 计算输出和目标之间的损失

4 验证循环

5 生成模型对输入的预测

6 计算输出和目标之间的损失

最后,我们使用以下列表中所示的训练集和代码测试我们的训练好的模型。

列表 6.9 GAT 测试循环
   test_loss = 0
   for test_data in test_dataset:
        test_out = model(test_data)  #1
        test_loss += criterion(out,\
 data.y.reshape(out.shape[0], -1)).item()  #2

1 生成模型对输入的预测

2 计算输出和目标之间的损失

我们发现这种朴素的方法无法预测姿态。我们的整体测试准确率为 55%,预测的图表与我们对姿态外观的预期大相径庭。这是由于我们在单个图中存储了大量的数据。我们将节点特征和时序数据压缩到一个图中,并且在定义我们的模型时没有强调时序属性。有方法可以改进这一点,例如使用时序编码来提取未使用的边数据,就像在时序 GAT(TGAT)模型中那样。TGAT 将边视为动态的而不是静态的,这样每条边也编码了一个时间戳。

然而,没有这些时间数据,我们的模型变得过于表达,以至于姿态的整体结构已经与原始结构显著偏离,如图 6.10 中的预测姿态所示。接下来,我们将研究如何将两种方法中的优点结合起来,形成一个使用基于 RNN 预测的 GNN,通过在每个图快照上进行学*来实现。

figure

图 6.10 GAT 模型的输出

6.4 神经关系推理

我们的研究中的 RNN 模型完全关注于时间数据,但忽略了底层的关系数据。这导致了一个模型,它在平均方向上能够移动,但并没有很好地改变各个传感器的位置。另一方面,我们的 GAT 模型通过将所有个体时间图编码成一个单一图,并尝试在未知未来图上进行节点预测,从而忽略了时间数据。这个模型导致传感器大幅移动,我们得到的图与我们所期望的人类移动方式非常不同。

如前所述,神经关系推理(NRI)是一种稍微不同的方法,它使用更复杂的编码框架来结合 RNN 和 GNNs 的最佳之处[2]。该模型的架构如图 6.11 所示。具体来说,NRI 使用自动编码器结构在每个时间步嵌入信息。因此,嵌入架构以类似于我们在第五章中讨论的 GAE 的方式应用于整个图。然后使用 RNN 更新编码的图数据。一个关键点是 NRI 会演化嵌入的潜在表示。

figure

图 6.11 NRI 的示意图(来源:Kipf 等人[2])。该模型由一个编码器和解码器层以及几个消息传递步骤组成。然而,在这里,消息是从节点传递到边,然后从边传递回节点,再从节点传递回边。对于解码器,消息是从节点传递到边,然后从边传递回节点。最后一步使用潜在表示来预测身体时间演变的下一步。

让我们探索这个模型如何应用于我们的姿态估计问题,以便我们最好地理解模型中的不同组件。我们将在训练期间对一些数据进行掩码,然后在测试日识别这些掩码节点。回想一下,这相当于推断我们的视频中的未来帧。然而,我们现在需要改变模型架构和损失。我们需要改变模型架构以考虑新的自动编码器结构,并且需要调整损失以包括最小化重建损失以及 Kullback-Leibler 散度(KL 散度)。有关 NRI 模型和相关更改的更多信息,请参阅本章末尾的 6.5 节。

NRI 模型基类的代码在列表 6.10 中提供。从代码中可以看出,当我们调用这个类时,需要定义一个编码器和一个解码器。除了编码器和解码器之外,还有一些其他特定于模型的具体细节我们需要注意。首先,我们需要定义变量的数量。这关系到我们图中的节点数量,而不是每个节点的特征数量。在我们的情况下,这将对应于 31 个,对应于跟踪关节位置的每个不同传感器。我们还需要定义节点之间的不同类型边。这将表示为 1 或 0,表示是否存在边。

我们将假设节点或传感器的连接方式不会改变,也就是说,图结构是静态的。请注意,此模型还允许动态图,其中连接性随时间变化,例如,当不同的球员在篮球场周围移动时。球员的总数是固定的,但可以传球的球员数量会变化。实际上,此模型也被用来预测 NBA 球员如何传球。

最后,这个模型需要设置一些超参数,包括 Gumbel 温度和先验方差。Gumbel 温度控制在进行离散采样时探索和利用之间的权衡。在这里,我们需要使用一个离散概率分布来预测边类型。我们将在第 6.5 节中更详细地讨论这个问题。先验方差反映了我们在开始之前对图连接性的不确定性。我们需要设置这个参数,因为模型假设我们不知道连接性。实际上,模型学*的是最能帮助它改进预测的连接性。这正是我们在调用_initialize_log_prior函数时所做的设置。我们告诉模型我们对可能连接模式的最佳猜测。例如,如果我们将此模型应用于一个运动队,我们可能会使用高均值的高斯分布来表示经常互相传球或甚至同一队球员之间的边。

为了展示我们的模型,我们将假设一个均匀先验,这意味着所有边与其他边一样可能,或者用日常用语来说,“我们不知道。”先验方差为每个边设置我们的不确定性界限。在下面的列表中,我们将其设置为 5 × 10^(–5),以保持数值稳定性,但鉴于我们的先验是均匀的,它不应有太大影响。

列表 6.10 NRI 模型基类
   class BaseNRI(nn.Module):
        def __init__(self, num_vars, encoder, decoder,
                num_edge_types=2,
                gumbel_temp=0.5, 
                prior_variance=5e-5):
           super(BaseNRI, self).__init__()
           self.num_vars = num_vars  #1
           self.encoder = encoder  #2
           self.decoder = decoder  #3
           self.num_edge_types = num_edge_types 
           self.gumbel_temp = gumbel_temp  #4
           self.prior_variance = prior_variance  #5

           self.log_prior = self._initialize_log_prior()

        def _initialize_log_prior(self): 
             prior = torch.zeros(self.num_edge_types)
             prior.fill_(1.0 / self.num_edge_types)  #6
             log_prior = torch.log(prior)\
   .unsqueeze(0).unsqueeze(0)  #7
             return log_prior.cuda(non_blocking=True)

1 模式中的变量数量

2 编码器神经网络

3 解码器神经网络

4 用于采样分类变量的 Gumbel 温度

5 先验方差

6 用均匀概率填充先验张量

7 对数并添加两个单例维度

正如我们在第五章中发现的,VAEs 有两个组成部分的损失——重建误差和表示数据分布特性的误差——由 KL 散度捕捉。总损失函数在列表 6.11 中给出。

我们的编码器接收边缘嵌入,然后输出边缘类型的对数概率。Gumbel-Softmax 函数将这些离散的 logits 转换为可微的连续分布。解码器接收这个分布和边缘表示,然后将这些转换回节点数据。在这个时候,我们就可以使用 VAE 的标准损失机制了,所以我们计算重建损失为均方误差 (MSE) 和 KL 散度。对于 VAE 损失和 KL 散度如何计算的进一步了解,请回顾第五章。

列表 6.11 NRI 模型的损失
   def calculate_loss(self, inputs,
       is_train=False,
       teacher_forcing=True,
       return_edges=False,
       return_logits=False):

       encoder_results = self.encoder(inputs)
       logits = encoder_results['logits']
       hard_sample = not is_train
       edges = F.gumbel_softmax\
               (logits.view(-1, self.num_edge_types),
               tau=self.gumbel_temp,
               hard=hard_sample).view\
                       (logits.shape)  #1

       output = self.decoder(inputs[:, :-1], edges)

       if len(inputs.shape) == 3: \
target = inputs[:, 1:] 
       else:
           Target = inputs[:, 1:, :, :]

       loss_nll = F.mse_loss(\
output, target) / (2 * \
self.prior_variance)  #2

       probs = F.softmax(logits, dim=-1)
       log_probs = torch.log(probs + 1e-16)  #3
       loss_kl = (probs * \
(log_probs - torch.log(\
torch.tensor(1.0 /  #4
       self.num_edge_types)))).\
sum(-1).mean() 

        loss = loss_nll + loss_kl

        return loss, loss_nll, loss_kl, logits, output

1 使用 PyTorch 的功能 API 计算 Gumbel-Softmax,在代码中导入为 F

2 高斯分布的负对数似然 (NLL)

3 添加一个小的常数以避免取零的对数

4 与均匀分类分布的 KL 散度

最后,我们需要我们的模型能够预测传感器的未来轨迹。预测图未来状态的代码在列表 6.12 中给出。一旦我们的编码器和解码器被训练,这是一个相对简单的函数。我们传递当前图给编码器,这返回一个表示是否存在边缘的潜在表示。然后我们使用 Gumbel-Softmax 将这些概率转换为合适的分布,并将其传递给解码器。解码器的输出是我们的预测。我们可以直接获取预测,或者获取预测和是否存在边缘。

列表 6.12 预测未来
   def predict_future(self, inputs, prediction_steps, 
      return_edges=False, 
      return_everything=False): #1
       encoder_dict = self.encoder(inputs) #1
       logits = encoder_dict['logits'] 
       edges = nn.functional.gumbel_softmax(  #2
           logits.view(-1, \
           self.num_edge_types),  
           tau=self.gumbel_temp,\
           hard=True).view(logits.shape\ 
           ) 
       tmp_predictions, decoder_state =\
          self.decoder(  #3
          inputs[:, :-1],  #3
          edges,  #3
          return_state=True  #3
       )  #3
       predictions = self.decoder(  #4
          inputs[:, -1].unsqueeze(1),   #4
          edges,   #4
          prediction_steps=prediction_steps,   #4
          teacher_forcing=False,  #4
          state=decoder_state  #4
          ) #4
       if return_everything:  #5
           predictions = torch.cat([\  #4
              tmp_predictions,\  #5
              Predictions\  #5
              ], dim=1)  #5

       return (predictions, edges)\
          if return_edges else predictions  #6

1 运行编码器以获取边缘类型的 logits

2 将 Gumbel-Softmax 应用到边缘上

3 运行解码器以获取初始预测和解码器状态

4 使用最后一个输入和解码器状态来预测未来步骤

5 如有必要,则连接初始和未来预测

6 如果指定,则返回预测和边缘

这是 NRI 模型的基础。我们有一个编码器,它将我们的初始节点数据转换为边缘概率。边缘概率传递给我们的解码器,解码器根据最可能的图表示预测条件下的未来轨迹。我们的编码器将是一个简单的多层感知器 (MLP),它处理图数据。我们的解码器需要能够做出未来预测,因此我们将使用 RNN 来实现这一点,具体是我们在第 6.2.2 节中讨论的相同的 GRU 模型。接下来,让我们认识一下编码器和解码器网络,这样我们就可以将模型应用于数据并查看其性能。

6.4.1 编码姿态数据

现在我们已经了解了我们 NRI 模型的不同部分,让我们定义我们的编码器。这个编码器将作为瓶颈来简化我们的问题。编码后,我们将剩下边数据的低维表示,所以我们在这个阶段不需要担心时间数据。然而,通过一起提供我们的时间数据,我们将时间结构转移到我们的潜在空间中。具体来说,编码器从输入数据中提取时间模式和关系,并在压缩的低维表示中保留这些信息。这使得解码更容易,使我们的姿态预测问题更容易解决。

实现编码器有几个子集。首先,我们传递输入数据,它由不同帧、不同实验中的不同传感器组成。然后,编码器将此数据,x,执行消息传递步骤,将边数据转换为节点数据,然后再转换回边数据。然后,边数据再次转换为节点数据,在潜在空间中进行编码。这相当于三个消息传递步骤,从边到节点,从边到边,然后再从边到节点。重复的转换对于通过重复消息传递进行信息聚合和捕获图中的高阶交互是有用的。通过在节点和边之间重复转换,模型能够意识到局部和全局结构信息。

在整本书中,我们探讨了如何使用消息传递将节点或边特征转换为节点或边的复杂表示。这些是所有 GNN 方法的核心。NRI 模型与我们之前探索的方法略有不同,因为消息是在节点和边之间传递,而不是节点到节点或边到边。为了明确这些步骤的作用,我们将从 PyG 转向,并用纯 PyTorch 编写我们的模型。

在列表 6.13 中,我们展示了我们编码器的基类,它需要几个关键特性。首先,请注意,我们还没有描述将要用于编码数据的实际神经网络。我们将很快介绍这一点。相反,我们有两个消息传递函数,edge2nodenode2edge,以及一个编码函数,one_hot_recv

列表 6.13 编码器基类
   class BaseEncoder(nn.Module):
       def __init__(self, num_vars):
           super(BaseEncoder, self).__init__()
           self.num_vars = num_vars
           edges = torch.ones(num_vars)\
 - torch.eye(num_vars)  #1
           self.send_edges, self.\
recv_edges = torch.where(edges)  #2

           one_hot_recv = torch.nn.functional.one_hot(  #3
              self.recv_edges,  #3
              num_classes=num_vars  #3
                                                ) #3
           self.edge2node_mat = \
nn.Parameter(one_hot_recv.\
float().T, requires_grad=False)  #4

       def node2edge(self, node_embeddings):
           send_embed = \
node_embeddings[:, self.send_edges]  #5
           recv_embed = \
node_embeddings[:, self.recv_edges] 
           return torch.\
cat([send_embed, recv_embed], dim=2)  #6

       def edge2node(self, edge_embeddings):
           incoming = torch.\
matmul(self.edge2node_mat, edge_embeddings)  #7
           return incoming / (self.num_vars - 1)  #8

1 创建表示变量之间边的矩阵

2 查找存在边的索引

3 创建接收边的 one-hot 表示

4 创建边到节点转换的参数张量

5 提取发送者和接收者的嵌入

6 连接发送者和接收者的嵌入

7 将边嵌入与边到节点矩阵相乘

8 正则化传入的嵌入

我们编码器类的第一步是构建一个邻接矩阵。在这里,我们假设图是完全连接的,这意味着所有节点都与其他所有节点相连,但与自己不相连。node2edge函数接受节点嵌入数据并识别这些消息发送的方向。图 6.12 展示了我们构建邻接矩阵的一个示例。

figure

图 6.12 展示了为具有三个节点的完全连接图创建邻接矩阵的示例。左边的矩阵代表一个完全连接的图,中间的矩阵代表单位矩阵,右边的矩阵显示了减去单位矩阵后的最终邻接矩阵。这导致一个每个节点都与其他每个节点相连但没有自环的图。

下一个函数调用通过返回包含连接节点行和列的两个向量来确定哪些节点正在发送或接收数据。回想一下,在邻接矩阵中,行代表接收节点,列代表发送节点。输出结果是

send_edges = tensor([0, 0, 1, 1, 2, 2])
recv_edges = tensor([1, 2, 0, 2, 0, 1])

我们可以理解为,行 0 的节点向列 1 和 2 的节点发送数据,依此类推。这使我们能够提取节点之间的边。一旦我们构建了节点嵌入,我们就使用发送和接收数据将节点数据转换为边。这就是node2edge函数的原理。

我们需要的下一个函数是如何根据我们的edge_embeddings构建edge2node。我们首先构建一个edge2node矩阵。在这里,我们使用一种 one-hot 编码方法,将接收到的边转换为 one-hot 编码表示。具体来说,我们创建一个矩阵,其中每一行表示该类别(接收节点)是否存在。对于我们的简单三个节点案例,接收边的 one-hot 编码方法如图 6.13 所示。

然后,我们将这个矩阵转置以交换行和列,这样维度将是(节点数,边数),并将其转换为 PyTorch 参数,以便我们可以对其进行微分。一旦我们有了edge2node矩阵,我们就将其与边嵌入相乘。我们的边嵌入将是形状为(边数,嵌入大小)的对象,这样将edge2node矩阵与边嵌入相乘就给我们一个形状为(节点数,嵌入大小)的对象。这些就是我们的新节点嵌入!最后,我们通过可能的节点数来归一化这个矩阵,以确保数值稳定性。

这一部分是理解模型中消息传递步骤的关键。(关于消息传递的更多信息,请回顾第二章和第三章。)正如所讨论的,一旦我们有一种在节点、边或两者的组合之间传递消息的原则性方法,我们就将这些嵌入应用于神经网络以获得非线性表示。为此,我们需要定义我们的嵌入架构。完整的编码器代码在列表 6.14 中给出。

figure

图 6.13 展示了在具有三个节点的全连接图中,每个节点 incoming edges 的一热编码矩阵,位于左侧。每一行对应一条边,每一列对应一个节点。位置(i, j)处的 1 表示边 i 指向节点 j。这个矩阵用于在编码器基类的edge2node函数中将边嵌入转换为节点嵌入,使模型能够为每个节点聚合来自 incoming edges 的信息。在这个图结构中,节点 0、1 和 2 各自向其他两个节点发送消息,从而产生总共六个有向边。三个节点图的示意图位于右侧。

RefMLPEncoder在列表 6.14 中展示。这个编码器使用四个 MLP 进行消息处理,每个 MLP 都具备指数线性单元(ELU)激活和批归一化(在RefNRIMLP中定义,本章代码库中展示)。

备注:指数线性单元(ELU)是一种有用的激活函数,可用于平滑多层输出并防止梯度消失。与 ReLU 不同,ELU 在负输入时内置了更平滑的梯度,并允许有负输出。

网络的最后一部分(self.fc_out)是一系列带有 ELU 激活的线性层,以输出所需嵌入或预测的线性层结束。这个序列的最后一层是一个全连接层。

列表 6.14 NRI MLP 编码器
   class RefMLPEncoder(BaseEncoder):
       def __init__(self, 
               num_vars=31, 
               input_size=6, 
               input_time_steps=50, 
               encoder_mlp_hidden=256, 
               encoder_hidden=256, 
               num_edge_types=2, 
               encoder_dropout=0.):
           super(RefMLPEncoder, self).__init__(num_vars)
           inp_size = input_size * input_time_steps
           hidden_size = encoder_hidden
           num_layers = 3
           self.input_time_steps = input_time_steps

           self.mlp1 = RefNRIMLP\
(inp_size, hidden_size, \
hidden_size, encoder_dropout)  #1
           self.mlp2 = RefNRIMLP\
(hidden_size*2, hidden_size,\
 hidden_size, encoder_dropout) 
           self.mlp3 = RefNRIMLP\
(hidden_size, hidden_size,\
 hidden_size, encoder_dropout) 
           mlp4_inp_size = hidden_size * 2
           self.mlp4 = RefNRIMLP\
(mlp4_inp_size, hidden_size,\
 hidden_size, encoder_dropout)

           layers = [nn.Linear\
(hidden_size, encoder_mlp_hidden), \
nn.ELU(inplace=True)]  #2
           layers += [nn.Linear\
(encoder_mlp_hidden, \
encoder_mlp_hidden),\ 
   nn.ELU(inplace=True)] \
   * (num_layers - 2) 
           layers.append(nn.\
Linear(encoder_mlp_hidden, \
num_edge_types)) 
           self.fc_out = nn.Sequential(*layers) 
           self.init_weights()

1 定义了 MLP 层。RefNRIMLP 是一个 2 层全连接 ELU 网络,带有批归一化。

2 定义了最终的完全连接层

在这里,我们定义与编码器相关的架构细节。如前所述,我们使用num_vars变量表示 31 个传感器。特征数量为 6,这是网络的input_size。我们的训练和验证集的时间步数为 50,编码器网络的大小将为 256。edge_types的数量为 2,我们假设权重没有 dropout。然后我们初始化我们的网络,这些是典型的 MLP,在共享的代码库中描述。网络包括一个批归一化层和两个全连接层。一旦定义了网络,我们也预先初始化权重,如列表 6.15 所示。在这里,我们遍历所有不同的层,然后使用 Xavier 初始化方法初始化权重。这确保了层中的梯度都大致处于相似的比例,从而降低了损失迅速发散的风险——即爆炸。当我们像这里这样组合具有不同架构的多个网络时,这是一个重要的步骤。我们还设置了初始偏置为 0.1,这有助于提高训练的稳定性。

列表 6.15 权重初始化
    def init_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):  #1
                nn.init.xavier_normal_(m.weight.data)  #2
                m.bias.data.fill_(0.1)  #3

1 仅适用于线性层

2 使用 Xavier 正态初始化权重

3 将偏置设置为 0.1

最后,我们需要定义我们的前向传递方法,如列表 6.16 所示。这就是我们的消息传递步骤发生的地方。

列表 6.16 编码器前向传递
   def forward(self, inputs, state=None, return_state=False):
       if inputs.size(1) > self.input_time_steps:
           inputs = inputs[:, -self.input_time_steps:]
       elif inputs.size(1) < self.input_time_steps:
           begin_inp = inputs[:, 0:1].expand(
           -1, 
           self.input_time_steps-inputs.size(1),
           -1, -1
           )
           inputs = torch.cat([begin_inp, inputs], dim=1) #1

       x = inputs.transpose(1, 2).contiguous()  #1
       x = x.view(inputs.size(0), inputs.size(2), -1) 

       x = self.mlp1(x)  #2
       x = self.node2edge(x)  #3
       x = self.mlp2(x)  #4

       x = self.edge2node(x)  #5
       x = self.mlp3(x)

       x = self.node2edge(x)  #6
       x = self.mlp4(x)

       result =  self.fc_out(x)  #7
       result_dict = {
          'logits': result,
          'state': inputs,
           }
       return result_dict

1 新形状:[num_sims, num_atoms, num_timesteps*num_dims]

2 通过第一个 MLP 层(每个节点两个 ELU 网络层)

3 将节点嵌入转换为边嵌入

4 通过第二个 MLP 层

5 将边嵌入转换回节点嵌入

6 再次将节点嵌入转换为边嵌入

7 最终的全连接层以获取 logits

我们的编码器允许我们的模型将我们的传感器图的帧集转换为边概率的潜在表示。接下来,让我们探索如何构建一个解码器,该解码器使用最*的传感器数据将潜在边概率转换为轨迹。

6.4.2 使用 GRU 解码姿态数据

为了将潜在表示转换为未来帧,我们需要考虑轨迹的时间演化。为此,我们训练一个解码器网络。在这里,我们将遵循 NRI 论文[2]的原始结构,并使用 GRU 作为我们的 RNN。

我们在 6.2.2 节中介绍了 GRU 的概念。为了快速回顾,门控循环单元(GRU)是一种使用门控过程来允许 RNN 捕获数据中长期行为的 RNN 类型。它们由两种类型的门组成——重置门和更新门。

对于 NRI 模型,我们将 GRU 应用于我们的边,而不是整个图。更新门将用于确定根据接收到的数据,应该更新节点隐藏状态的多少,而重置门决定应该删除或“忘记”多少。换句话说,我们将使用 GRU 根据编码器网络中的边类型概率预测节点的未来状态。

让我们一步一步地看看我们是如何构建这个步骤的。我们的解码器初始化代码在列表 6.17 中给出。首先,我们注意到传递给这个网络的一些变量。我们再次定义我们图中变量或节点的数量,31 个,以及输入特征的个数,6 个。我们假设权重没有 dropout,并且每层的隐藏大小为 64。再次明确,我们的解码器应该预测两种不同类型的边。在预测时,我们也会跳过第一种边类型,因为这表示没有边。

一旦我们定义了输入参数,我们就可以引入网络架构。第一层是一个简单的线性网络,需要具有两倍的输入维度,以考虑由编码器提供的均值和方差,并且我们为每种边类型定义了这个网络。然后我们定义第二层以进一步提高我们网络的表达能力。这两个线性层的输出传递给我们的 RNN,即 GRU。在这里,我们必须使用自定义 GRU 来考虑节点数据和边数据。GRU 的输出传递给三个更多的神经网络层以提供未来预测。最后,我们需要定义我们的edge2node矩阵以及发送和接收节点,就像我们处理编码器时那样。

列表 6.17 RNN 解码器
   class GraphRNNDecoder(nn.Module):
       def __init__(self, 
           num_vars=31, 
           input_size=6, 
           decoder_dropout=0., 
           decoder_hidden=64, 
           num_edge_types=2, 
           skip_first=True):
           super(GraphRNNDecoder, self).__init__()
           self.num_vars = num_vars
           self.msg_out_shape = decoder_hidden
           self.skip_first_edge_type = skip_first
           self.dropout_prob = decoder_dropout
           self.edge_types = num_edge_types

           self.msg_fc1 = nn.ModuleList\
([nn.Linear(2 * decoder_hidden,\
 decoder_hidden) for _ in \
range(self.edge_types)])  #1
           self.msg_fc2 = nn.ModuleList\
([nn.Linear(decoder_hidden, decoder_hidden)\
 for _ in range(self.edge_types)])

           self.custom_gru = CustomGRU\
(input_size, decoder_hidden)  #2

           self.out_fc1 = nn.Linear\
(decoder_hidden, decoder_hidden)  #3
           self.out_fc2 = nn.Linear(decoder_hidden, decoder_hidden)
           self.out_fc3 = nn.Linear(decoder_hidden, input_size)

           self.num_vars = num_vars
           edges = np.ones(num_vars) - np.eye(num_vars)
           self.send_edges = np.where(edges)[0]
           self.recv_edges = np.where(edges)[1]
           self.edge2node_mat = \
                torch.FloatTensor\
                (encode_onehot(self.recv_edges))
           self.edge2node_mat = self.edge2node_mat.cuda(non_blocking=True)

1 与边相关的层

2 GRU 层

3 全连接层

在列表 6.18 中,我们提供了我们的 GRU 架构。这个网络的整体架构与典型的 GRU 结构相同。我们定义了三个隐藏层,这些层代表了由hidden_rinput_r定义的重置门,由hidden_iinput_i定义的更新门,以及由hidden_hinput_h定义的激活网络。然而,正向网络需要考虑来自编码器消息传递输出的聚合消息。这可以在正向传递中看到。我们将agg_msgs中的边概率以及输入节点数据传递,这些数据结合起来返回未来预测。这可以在我们的基础 NRI 类中的predict_future代码中看到:

      predictions = self.decoder(inputs[:, -1].unsqueeze(1), edges,
      prediction_steps=prediction_steps, teacher_forcing=False, 
      state=decoder_state)

我们的解码器接收我们图的最后一个时间帧。从编码器输出的边数据也传递给解码器。

列表 6.18 自定义 GRU 网络
   class CustomGRU(nn.Module):
       def __init__(self,input_size, n_hid,num_vars=31):
           super(CustomGRU, self).__init__()
           self.num_vars = num_vars
           self.hidden_r = nn.Linear
(n_hid, n_hid, bias=False)  #1
           self.hidden_i = nn.Linear\
(n_hid, n_hid, bias=False) 
           self.hidden_h = nn.Linear\
(n_hid, n_hid, bias=False) 

           self.input_r = nn.Linear\
(input_size, n_hid, bias=True)  #2
           self.input_i = nn.Linear(\
input_size, n_hid, bias=True) 
           self.input_n = nn.Linear\
(input_size, n_hid, bias=True) 

       def forward(self, inputs, agg_msgs, hidden):
           inp_r = self.input_r(inputs)\
.view(inputs.size(0), self.num_vars, -1)
           inp_i = self.input_i(inputs)\
.view(inputs.size(0), self.num_vars, -1)
           inp_n = self.input_n(inputs)\
.view(inputs.size(0), self.num_vars, -1)

           r = torch.sigmoid(inp_r + \
self.hidden_r(agg_msgs))  #3
           i = torch.sigmoid(inp_i + \
self.hidden_i(agg_msgs))  #4
           n = torch.tanh(inp_n + \
r*self.hidden_h(agg_msgs))  #5
           hidden = (1 - i)*n + i*hidden  #6

           return hidden

1 定义重置、输入和新门的隐藏层变换

2 定义重置、输入和新门的输入层变换

3 计算重置门激活

4 计算输入门激活

5 计算新门激活

6 隐藏状态更新

解码器网络的输出是未来的预测时间步。为了更好地理解这一点,让我们看看解码器的正向传递方法,如列表 6.19 所示。我们的正向传递接收输入和采样边来构建预测。还有四个额外的参数有助于控制行为。首先,我们定义一个teacher_forcing变量。教学强制是一种在训练序列模型时常用的典型方法,例如 RNN。如果这是真的,我们使用真实值(真实图)来预测下一个时间帧。当这是假的时,我们使用模型上一个时间步的输出。这确保了模型在训练期间不会被错误的预测所误导。接下来,我们包括一个return_state变量,它允许我们访问解码器网络提供的隐藏表示。当我们预测未来图演化时,我们使用这个变量,如这里所示:

     tmp_predictions, decoder_state = \
        self.decoder(inputs[:, :-1], edges, 
        return_state=True)
     predictions = self.decoder\
        (inputs[:, -1].unsqueeze(1), edges, 
        prediction_steps=prediction_steps, \
        teacher_forcing=False, state=decoder_state)

现在我们来讨论预测过程。首先,我们预测一个临时预测集。然后,我们使用隐藏表示来预测所需的所有未来步骤。当我们想要预测多个时间步时,这特别有用,正如我们在该模型的测试阶段所展示的。这由prediction_steps变量控制,它告诉我们 RNN 要循环多少次,即我们想要预测多少未来的时间步。最后,我们有一个state变量,用于控制传递给解码器的信息。当它为空时,我们初始化一个零张量,以便没有信息被传递。否则,我们将使用之前时间步的信息。

列表 6.19 解码器正向传递
     def forward(self, inputs, sampled_edges,
         teacher_forcing=False,
         return_state=False,
         prediction_steps=-1,
         state=None):

         batch_size, time_steps, num_vars, num_feats = inputs.size()
         pred_steps = prediction_steps if \
            prediction_steps > 0 else time_steps  #1

         if len(sampled_edges.shape) == 3:  #2
             sampled_edges = sampled_edges.unsqueeze(1) 
             sampled_edges = sampled_edges.expand\
                (batch_size, pred_steps, -1, -1) 

         if state is None:  #3
             hidden = torch.zeros(batch_size,  #3
                Num_vars,  #3
                Self.msg_out_shape,  #3
                device=inputs.device)  #3
         else:  #3
             hidden = state  #3
             teacher_forcing_steps = time_steps  #4

         pred_all = []
         for step in range(pred_steps):  #5
         if step == 0 or (teacher_forcing \
            and step < teacher_forcing_steps): 
             ins = inputs[:, step, :] 
         else: 
             ins = pred_all[-1] 

         pred, hidden = self.single_step_forward(  #6
              ins,   #6
              sampled_edges[:, step, :],   #6
              hidden  #6
              )  #6
              pred_all.append(pred)

         preds = torch.stack(pred_all, dim=1)

         return (preds, hidden) if return_state else preds  #7

1 确定预测步骤的数量

2 如有必要扩展 sampled_edges 张量

3 如果未提供,初始化隐藏状态

4 确定应用教师强制的步骤数量

5 根据教师强制决定这一步的输入

6 使用从输入或 pred_all 计算出的 ins 执行单个正向步骤(见上一条注释)

7 返回预测和隐藏状态

为了预测未来的时间步,我们进行一个基于单个时间步的额外正向传递,如第 6.20 列所示。这是我们的网络执行额外消息传递步骤的地方。我们取我们的接收节点和发送节点,这些节点是从编码器的边概率中定义的。我们忽略第一个边,因为这些是无连接的节点,然后网络遍历不同类型的边,从网络中获取所有与边相关的消息。这是使我们的预测依赖于图数据的临界步骤。我们的 GRU 随后从连接节点接收消息,以告知其对轨迹的预测。在这一步,我们正在学*如何根据我们对身体连接的了解来预测身体的行走方式。输出包括身体上传感器的预测轨迹以及为什么做出这些预测的网络数据,这些数据编码在隐藏权重中。这完成了估计姿态的 NRI 模型。

列表 6.20 解码器单步正向
     def single_step_forward(self, inputs, rel_type, hidden):
         receivers = hidden[:, self.recv_edges, :]  #1
         senders = hidden[:, self.send_edges, :]  #1

         pre_msg = torch.cat([receivers, senders], dim=-1)  #2

         all_msgs = torch.zeros(
             pre_msg.size(0), 
             pre_msg.size(1), 
             self.msg_out_shape, 
             device=inputs.device
             )

         start_idx = 1 if self.skip_first_edge_type else 0
         norm = float(len(self.msg_fc2) - start_idx)

         for i in range(start_idx, len(self.msg_fc2)):  #3
             msg = torch.tanh(self.msg_fc1i)  #3
             msg = F.dropout(msg, p=self.dropout_prob)  #3
             msg = torch.tanh(self.msg_fc2i)  #3
             msg = msg * rel_type[:, :, i:i+1]  #3
             all_msgs += msg / norm  #3

         agg_msgs = all_msgs.transpose(-2, -1)  #4
         agg_msgs = agg_msgs.matmul(self.edge2node_mat) 
         agg_msgs = agg_msgs.transpose\
            (-2, -1) / (self.num_vars - 1) 

         hidden = self.custom_gru(inputs, agg_msgs, hidden)  #5

         pred = F.dropout(F.relu\
           (self.out_fc1(hidden)), \
           p=self.dropout_prob)  #6
         pred = F.dropout(F.relu\
         (self.out_fc2(pred)), \
         p=self.dropout_prob) 
         pred = self.out_fc3(pred) 

         pred = inputs + pred   
         return pred, hidden

1 节点到边步骤

2 消息大小:[batch, num_edges, 2*msg_out]

3 为每种边类型运行一个单独的 MLP

4 对每个节点求所有消息的总和

5 GRU 风格的门控聚合

6 构建输出多层感知器(MLP)

6.4.3 训练 NRI 模型

现在我们已经定义了模型的各个部分,让我们训练模型并看看它的表现。为了训练我们的模型,我们将采取以下步骤:

  1. 训练一个编码器,将传感器数据转换为边概率的表示,指示传感器是否连接到另一个。

  2. 训练一个解码器,根据不同传感器之间存在边的概率来预测未来的轨迹。

  3. 运行解码器,使用 GRU 预测未来轨迹,GRU 接收到的边缘概率。

  4. 基于重建姿态减少损失。这种损失有两个组成部分:重建损失和 KL 散度。

  5. 重复步骤 1 至 4,直到训练收敛。

这也在图 6.14 中显示,训练循环在列表 6.21 中给出。

figure

图 6.14 NRI 模型的流程
列表 6.21 NRI 训练循环
   pbar = tqdm(range(start_epoch, num_epochs + 1), desc='Epochs')
   for epoch in pbar:
       model.train()  #1
       model.train_percent = epoch / num_epochs
       total_training_loss = 0
       for batch in train_data_loader:
           inputs = batch['inputs'].cuda(non_blocking=True)
           loss, _, _, _, _ = model.\
              calculate_loss(inputs, 
              is_train=True, 
              return_logits=True)
           loss.backward()  #2
           optimizer.step() 
           optimizer.zero_grad()  #3
           total_training_loss += loss.item()

      if training_scheduler is not None:
          training_scheduler.step()

      total_nll, total_kl = 0, 0
      for batch in val_data_loader:
          inputs = batch['inputs'].cuda(non_blocking=True)
            , loss_nll, loss_kl, _, _ = model.calculate_loss(inputs,
            is_train=False, 
            teacher_forcing=True, 
            return_logits=True)
          total_kl += loss_kl.sum().item()
          total_nll += loss_nll.sum().item()

          total_kl /= len(val_data)
          total_nll /= len(val_data)
          total_loss = total_kl + total_nll
          tuning_loss = total_nll 

      if tuning_loss < best_val_result:
          best_val_epoch, best_val_result = epoch, tuning_loss

1 训练循环

2 更新权重。

3 验证过程中的梯度为零

我们将使用学*率为 0.0005,学*率调度器在 500 次正向传递后减少学*率因子为 0.5,批大小为 8 进行 50 个 epoch 的训练。大部分训练基于我们之前在列表 6.14 中定义的calculate_loss方法调用。我们发现我们的模型损失随着验证损失下降,基于负对数似然(nll)达到验证损失 1.21。这看起来不错,但让我们看看它在测试数据上的表现,它需要预测未来的多个步骤。为此,我们需要定义一个新的函数,如下面的列表所示。

列表 6.22 评估未来预测
def eval_forward_prediction(model, 
  dataset, 
  burn_in, 
  forward_steps, 
  gpu=True, batch_size=8, 
  return_total_errors=False):

  dataset.return_edges = False

  data_loader = DataLoader\
    (dataset, batch_size=\
    batch_size, pin_memory=gpu)
  model.eval()
  total_se = 0
  batch_count = 0
  all_errors = []

  for batch_ind, batch in enumerate(data_loader):
    inputs = batch['inputs']
    with torch.no_grad():
      model_inputs = inputs[:, :burn_in]
      gt_predictions = inputs[:, burn_in:burn_in+forward_steps]
      model_inputs = model_inputs.cuda(non_blocking=True)
      model_preds = model.predict_future(
          model_inputs,
          forward_pred_steps
          ).cpu()
      batch_count += 1
      if return_total_errors:
          all_errors.append(
            F.mse_loss(
              model_preds, 
              gt_predictions,
              reduction='none'
             ).view(
               model_preds.size(0), 
               model_preds.size(1), -1
             ).mean(dim=-1)
          )
      else:
          total_se += F.mse_loss(
            model_preds, 
            gt_predictions,
            reduction='none'
          ).view(
            model_preds.size(0),
            model_preds.size(1),
            -1
          ).mean(dim=-1).sum(dim=0)

  if return_total_errors:
         return torch.cat(all_errors, dim=0)
     else:
            return total_se / len(dataset)

此函数加载我们的测试数据,然后根据不同的时间范围计算我们的预测的均方误差(MSE)。当我们测试我们的模型时,我们发现它能够以均方误差 0.00008 预测下一个时间步。更好的是,它能够以 94%的准确度预测 40 个时间步的未来。这显著优于我们的 LSTM 和 GAT 模型,分别达到了 65%和 55%。未来时间步的准确度降低如图 6.15 所示,示例输出如图 6.16 所示。

figure

图 6.15 预测未来时准确度的降低

figure

图 6.16 NRI 模型的示例输出

我们已经涵盖了 NRI 模型的所有核心组件,完整的可工作代码已提供在 GitHub 仓库(mng.bz/4a8D)中。准确度令人印象深刻,突出了结合生成和基于图的方法与时间模型的强大功能。这如图 6.15 所示,我们看到预测姿态和结果估计姿态之间有很好的吻合。

此外,这种方法不仅能够预测图,而且在所有图数据不可用的情况下,也能学*底层结构,因此非常稳健。在这个问题中,我们知道预期的交互网络。然而,有许多情况下我们并不知道交互网络。一个例子是处于封闭空间中运动的粒子。当它们在某个交互半径内时,它们会相互影响,但当它们距离更远时则不会。这种情况适用于从细胞到运动员的所有生物体。事实上,世界上大多数情况都涉及具有秘密交互网络的交互代理。NRI 模型不仅提供了一种预测这些代理的行为和运动的方法,还能了解它们与其他代理的交互模式。确实,原始的 NRI 论文通过篮球比赛的视频跟踪数据展示了这一点,并表明该模型可以学*球、球手、挡拆者和不同球员之间的典型模式。(更多信息,请参阅 Kipf 等人[2]的研究。)

6.5 内部机制

在本章中,我们展示了如何处理时间或动态问题。在这里,我们更详细地讨论了我们使用的一些关键模型组件。

6.5.1 循环神经网络

在图 6.16 中,我们展示了 RNN 模型的示意图。与我们所见过的所有其他模型相比,RNN 模型的主要区别在于模型可以处理序列数据。这意味着每个时间步都有一个隐藏层,并且从该隐藏层输出的结果会在后续时间步与新的输入相结合。在图 6.17 中,这以两种方式展示。首先,在左侧,我们展示时间更新作为一个单独的自我循环,用 Whh 表示。为了更好地理解这个自我循环的作用,我们已经“展开”了模型在时间上的表现,以便我们可以明确地看到我们的模型是如何更新的。在这里,我们将我们的输入、输出和隐藏层(x, y, h)更改为时间变量(xt, yt, ht)。在我们的初始步骤 t,我们使用 xt 的数据和从我们之前的隐藏层 ht–1 的权重来更新我们的当前隐藏层,然后使用这个来输出 yt。然后,ht 的权重会传递给 ht+1,并伴随着新的输入 xt+1 来推断 yt+1。

该模型的一个关键特性是,当我们反向传播以更新我们的权重时,我们需要进行时间反向传播(BPTT)。这是所有 RNN 的一个特定特性。然而,大多数现代深度学*包都使这一过程变得非常简单,并隐藏了所有复杂的计算细节,以便于实践者使用。

figure

图 6.17 RNN 的结构。时间更新作为一个单环自我循环,由 Whh 表示(左侧)。展开的时间模型显示了模型更新(右侧)。在这里,我们将输入、输出和隐藏层(x、y、h)更改为时间变量(x[t]、y[t]、h[t])。在初始步骤 t,我们使用来自 x[t] 的输入数据和来自先前隐藏层 h[t–1] 的权重来更新当前隐藏层,然后使用它来输出 y[t]。然后,ht 的权重与新的输入 x[t+1] 一起传递到 h[t+1],以推断 y[t+1]。

让我们看看如何使用 PyTorch 实现一个 RNN。这就像定义一个神经网络类,然后在网络中引入特定的 RNN 层一样简单。例如,在列表 6.23 中,我们展示了定义具有单个 RNN 层的网络的代码。这是一个非常基本的 RNN 定义,因为只有一个隐藏层。然而,看到这个例子对于了解模型如何训练有一些直观的帮助。对于每个时间步,我们的输入都传递到隐藏层和输出。当我们执行前向传递时,输出返回到输出和隐藏层。最后,我们需要用某物初始化我们的隐藏层,所以我们使用全连接层。

列表 6.23 定义 RNN
   class PoseEstimationRNN(nn.Module):
       def __init__(self, input_size, hidden_size, output_size, num_layers):
           super(PoseEstimationRNN, self).__init__()

           self.hidden_size = hidden_size
           self.num_layers = num_layers

           self.rnn = nn.RNN\
(input_size, hidden_size, \
num_layers, batch_first=True)  #1
           self.fc = nn.Linear(hidden_size, output_size)  #2

       def forward(self, x):    
           h0 = torch.zeros(self.num_layers,\ #3
             x.size(0), self.hidden_size)  #3
           H0 = h0.to(x.device) 

           out, _ = self.rnn(x, h0)  #4
           out = self.fc(out[:, -10:, :]) #5
           return out

1 RNN 层

2 全连接层

3 设置初始隐藏和细胞状态

4 前向传播 RNN

5 将最后一个时间步的输出传递到全连接层

在实践中,我们经常想要使用更复杂的 RNN。这包括 RNN 的扩展,如 LSTM 网络或 GRU 网络。我们甚至可以使用我们选择的深度学*库将 RNN、LSTMs 和 GRUs 堆叠起来。GRU 与 RNN 类似,因为它对数据序列很有用。它们被特别设计来解决 RNN 的一个关键缺点,即梯度消失问题。它使用两个门,这些门决定了保留多少过去信息(更新门)以及忘记或丢弃多少(重置门)。我们在图 6.18 中展示了 GRU 的一个示例设计。在这里,z**[t] 表示更新门,r[t] 表示重置门。~h**[t] 术语被称为候选激活,反映了表示的新状态的候选,而 h**[t] 术语是实际的隐藏状态。

图像

图 6.18 GRU 层的设计,其中 r[t] 表示重置门,z[t] 是更新门,~h[t] 是候选函数,h[t] 是最终的实际隐藏状态

在列表 6.24 中,我们展示了如何使用 GRU 层构建模型。在这里,大多数实现由 PyTorch 处理,其中层是从标准 PyTorch 库导入的。模型定义的其余部分是典型的神经网络。

列表 6.24 GRU
   class PoseEstimationGRU(nn.Module):
       def __init__(self, input_size, hidden_size, output_size, num_layers):
           super(PoseEstimationGRU, self).__init__()
           self.hidden_size = hidden_size
           self.num_layers = num_layers
           self.gru = nn.GRU\
(input_size, hidden_size, \
num_layers, batch_first=True)  #1
           self.fc = nn.Linear(hidden_size, output_size)  #2

        def forward(self, x):

            h0 = torch.zeros\
(self.num_layers, \
x.size(0), self.hidden_size)  #3
            h0 = h0.to(x.device)  #3
            out, _ = self.gru(x, h0)  #4
            out = self.fc(out[:, -10:, :])  #5
            return out

1 GRU 层

2 全连接层

3 设置初始隐藏状态

4 前向传播 GRU

5 将最后一个时间步的输出传递到全连接层

6.5.2 时间邻接矩阵

当考虑时间图时,我们可能从一个由一条边连接的两个节点开始,然后在每个后续的时间步长中,再添加一些节点和/或边。这导致出现几个不同的图,每个图都有一个不同大小的邻接矩阵。

这在设计我们的 GNN 时可能会带来困难。首先,每个时间步长都有不同大小的图。这意味着我们无法使用节点嵌入,因为节点数量将在输入数据中不断变化。一种方法是使用每个时间步长的图嵌入来存储整个图作为一个低维度的表示。这种方法是许多时间方法的核心,在这些方法中,图嵌入随时间演变,而不是实际的图。我们甚至可以在我们的图上使用更复杂的变换,例如使用我们的 NRI 模型中的自动编码器模型。

或者,我们可以通过创建时间邻接矩阵将每个时间步长的所有单个图转换成一个更大的图。这涉及到将每个时间步长包裹成一个单一的图,该图跨越了每个时间步长的数据以及动态时间数据。如果图很小,我们只对未来的几个时间步长感兴趣,时间邻接矩阵可能很有用。然而,它们通常变得非常大且难以处理。另一方面,使用时间嵌入方法通常涉及多个复杂的子组件,并且变得难以训练。不幸的是,没有一种适用于所有时间图的通用方法,最佳方法几乎总是特定于问题的。

6.5.3 将自动编码器与 RNN 结合

在本节中,为了对 NRI 模型建立直观理解,我们将总结其组件并说明其在预测图结构和节点轨迹中的应用。首先,在图 6.19 中,我们重复了 NRI 模型的示意图。

figure

图 6.19 NRI 的示意图(来源:Kipf 等人[2])。该模型由编码器和解码器层以及几个消息传递步骤组成。然而,在这里,消息是从节点传递到边,然后从边传递回节点,然后再从节点传递回边。对于解码器,消息是从节点传递到边,然后从边传递回节点。最后一步使用潜在表示,并用于预测身体时间演变的下一步。

在这个模型中,有两个关键组件。首先,我们训练一个编码器将每个帧的图编码到潜在空间中。具体来说,我们使用编码器来预测给定初始图(x)的潜在交互(z)的概率分布 qj。一旦我们训练了编码器,我们就使用解码器将这个概率分布的样本转换为轨迹,使用潜在编码以及之前的时间步长。在实践中,我们使用编码器-解码器结构来推断具有不同交互类型(或边)的节点的轨迹。

在本章中,我们只考虑了两种边类型:传感器之间是否存在物理连接。然而,这种方法可以扩展到考虑许多不同的连接,所有这些连接都会随时间变化。此外,解码器模型需要一个 RNN 来有效地捕获我们图中的时间数据。为了对 NRI 模型有一个直观的了解,让我们再次重复这个过程。

  1. 输入—节点数据。

  2. 编码

    1. 编码器接收节点数据。

    2. 编码器将节点数据转换为边数据。

    3. 编码器在潜在空间中表示边数据。

  3. 潜在空间—潜在空间表示不同边类型的概率。在这里,我们有两种边类型(连接和不连接),尽管对于更复杂的关系,可能存在多种边类型。我们始终需要包括至少两种类型,否则模型会假设所有节点都是连接的,或者更糟糕的是,没有任何节点是连接的。

  4. 解码

    1. 解码器从潜在空间中获取边类型概率。

    2. 解码器学*根据这些概率重建未来的图状态。

  5. 预测—模型通过学*预测图连通性来预测未来的轨迹。

注意,这个模型同时给出了图和轨迹预测!虽然这可能对我们的问题没有帮助,但对于我们不知道底层图结构的情况,例如社交媒体网络或体育队伍,这可以提供发现系统中新交互模式的方法。

6.5.4 Gumbel-Softmax

在 NRI 模型中,在计算这两个损失之前有一个额外的步骤,即使用 Gumbel-Softmax 计算边的概率。我们需要引入 Gumbel-Softmax 的关键原因是我们的自动编码器正在学*预测表示我们边的邻接矩阵,即网络连通性,而不是节点及其特征。因此,自动编码器的最终预测必须是离散的。然而,我们也在推断一个概率。当需要将概率数据离散化时,Gumbel-Softmax 是一种流行的方法。

在这里,我们有两种离散的边类型,即某物是否连接。这意味着我们的数据是分类的——每条边要么属于类别 0(未连接),要么属于类别 1(连接)。Gumbel-Softmax 用于从分类分布中抽取和评分样本。在实践中,Gumbel-Softmax 将逼*来自我们的编码器的输出,这种输出以对数概率或logits的形式出现,作为一个 Gumbel 分布,它是一种极值分布。这将*似我们的数据的连续分布为一个离散的分布(边类型),并允许我们随后对分布应用损失函数。

Gumbel 分布的温度,作为我们的超参数之一,反映了分布的“尖锐度”,类似于方差如何控制高斯分布的尖锐度。在本章中,我们使用了 0.5 的温度,这大约是中等尖锐度。我们还指定Hard作为超参数,表示是否存在一个或多个类别。如前所述,我们希望在训练时有两个类别来表示是否存在边。这允许我们将分布*似为连续的,然后我们可以将其作为损失通过我们的网络进行反向传播。然而,在测试时,我们可以将Hard设置为True,这意味着只有一个类别。这使得分布完全离散,意味着我们无法使用损失进行优化,因为离散变量在定义上是非可微的。这是一个有用的控制,以确保我们的测试循环不会传播任何梯度。

摘要

  • 虽然一些系统可以使用单一数据快照进行预测,但其他系统需要考虑时间的变化以避免错误或漏洞。

  • 空间时间 GNNs 考虑之前的时刻来模拟图随时间演变的方式。

  • 空间时间 GNNs 可以解决姿态估计问题,其中我们根据身体在最*过去的位置数据预测身体的下一个位置。在这种情况下,节点代表放置在身体关节上的传感器,边代表关节之间的身体连接。

  • 邻接矩阵可以通过沿对角线连接不同的邻接矩阵来调整,以考虑时间信息。

  • 记忆可以被引入模型中,包括图神经网络(GNNs),例如通过使用循环神经网络(RNN)或门控循环单元网络(GRU)。

  • 神经关系推理(NRI)模型结合了循环网络,如 GRU,与自动编码器 GNNs。这些模型可以推断时间模式,即使相邻信息未知。

第七章:规模化的学*和推理

本章涵盖

  • 处理小型系统中数据过载的策略

  • 识别需要扩展资源的图神经网络问题

  • 七种减轻大数据问题影响的稳健技术

  • 使用 PyTorch Geometric 扩展图神经网络并解决可扩展性挑战

在我们大部分关于图神经网络(GNNs)的旅程中,我们已经解释了关键架构和方法,但我们将示例限制在相对较小的规模问题上。我们这样做的原因是让您能够轻松地访问示例代码和数据。

然而,在现实世界的深度学*中,问题往往并不如此整洁地打包。在现实场景中,一个主要挑战是在数据集足够大以至于可以放入内存或压倒处理器时训练 GNN 模型[1]。

在探索可扩展性的挑战时,拥有一个清晰的 GNN 训练过程的心理模型至关重要。图 7.1 回顾了我们对这一过程的熟悉可视化。其核心是,GNN 的训练围绕着从源获取数据,处理这些数据以提取相关的节点和边特征,然后使用这些特征来训练模型。随着数据量的增长,这些步骤中的每一个都可能变得更加资源密集,因此需要我们在本章中探讨的可扩展策略。

figure

图 7.1 GNN 训练过程的心理模型。在本章中,我们将关注为大数据扩展我们的系统。

在深度学*开发项目中,在训练和部署中考虑大量或扩展数据可以决定一个项目是成功还是失败。在紧迫的截止日期和有要求的利益相关者面前工作的机器学*工程师没有时间花费数周进行长时间的训练或纠正由处理器过载引起的错误。通过提前规划来避免规模问题可以防止这种时间浪费。

在本章中,您将学*如何处理当数据对于小型系统来说太大而无法处理时出现的问题。为了描述规模问题,我们关注三个指标:处理或训练过程中的内存使用量、训练一个 epoch 所需的时间以及问题收敛所需的时间。我们解释了这些指标,并指出如何在 Python 或 PyTorch Geometric(PyG)环境中计算它们。

在本章中,重点是从不起眼的开端进行扩展,从单一机器进行优化。虽然本书的主要焦点不是数据工程或构建大规模解决方案,但这里讨论的一些概念可能在这些背景下相关。为了解决规模问题,解释了七种可以协同使用或单独使用的方法:

  • 选择和配置处理器(第 7.4 节)

  • 使用数据集的稀疏表示与密集表示(第 7.5 节)

  • 选择 GNN 算法(第 7.6 节)

  • 根据从您的数据中抽取的样本进行批量训练(第 7.7 节)

  • 使用并行或分布式计算(第 7.8 节)

  • 使用远程后端(第 7.9 节)

  • 粗化您的图(第 7.10 节)

为了说明在实践中如何决定这些方法,提供了示例或迷你案例。虚构公司 GeoGrid Inc.(以下简称 GeoGrid)在各种案例中跟随,该公司处理与大数据相关的问题。

此外,您在第三章中遇到的亚马逊产品数据集,其中使用了图卷积网络(GCN)和 GraphSAGE 进行节点分类,也用于演示各种方法。对于相关方法,可以在本书的 GitHub 仓库中找到示例代码。

本章与之前的章节有所不同。而之前的章节专注于一个或两个示例来阐述一系列概念,而规模问题的独特性质意味着将探索各种方法,每种方法都伴随着简短的示例。因此,本章的章节可以在 7.3 节之后以任何顺序阅读。

我们将首先回顾第三章的亚马逊产品数据集并介绍 GeoGrid。然后,我们将讨论如何表征和衡量规模,重点关注三个指标。最后,我们将更详细地介绍每种方法,并在适当的地方提供代码。

注意:本章的代码可以在 GitHub 仓库的笔记本形式中找到(mng.bz/QDER)。本章的 Colab 链接和数据也可以在相同的位置访问。

7.1 本章的示例

在本章中,我们使用两个案例来阐述各种概念。我们使用第三章的亚马逊产品数据集。我们将使用这个数据集来演示代码示例,这些示例可以在 GitHub 仓库中找到。其次,我们将使用一个名为 GeoGrid 的虚构公司的迷你案例来阐明指南和运用所提出的方法的实践。

7.1.1 亚马逊产品数据集

本小节将重新介绍第三章中的数据集及其训练。首先,回顾数据集,然后介绍用于训练它的硬件配置。最后,作为后续章节的序言,我们突出介绍在第三章中应用的一些方法,以适应数据集的大小。这个数据集将在后续章节的 GitHub 代码示例中广泛使用。

在第三章中,我们使用两个卷积 GNN:GCN 和 GraphSAGE,研究了节点分类问题。为此,我们使用了包含共购买信息的 Amazon Products 数据集,该数据集常用于说明和基准测试节点分类[2]。这个数据集(也称为ogbn-products)由一组通过同一交易购买而相互连接的产品节点组成,如图 7.2 所示。每个产品节点都有一组特征,包括其产品类别。ogbn-products 数据集包含 250 万个节点和 6190 万个边。关于此数据集的更多信息总结在表 7.1 中。

图

图 7.2 第三章中使用 Amazon Products 数据集的一个共购买示例的图表示。每个产品的图片是一个节点,共购买是产品之间的边(以线条表示)。对于这里显示的四个产品,这个图只是单个客户的共购买图。如果我们显示所有亚马逊客户的相应图,产品节点和共购买边的数量可能达到数万个产品节点和数百万共购买边。

备注:有关此数据集及其来源,以及 GCN 和 GraphSAGE 的更多详细信息,请参阅第三章。

表 7.1 ogbn-products 数据集的总结特征
节点 平均节点度 类别标签数量 节点特征维度数量 压缩数据大小(GB)
2.5 百万 6190 万 51 47 100 1.38

对于第三章中实现的代码,我们使用了一个具有以下配置的 Colab 实例:

  • 存储:56 GB HDD

  • 两块 CPU:2 核 Xeon 2.2GHz

  • CPU RAM: 13 GB

  • 一块 GPU:Tesla T4

  • GPU RAM: 16 GB

虽然我们将在后面讨论细节,但我们已经确定了三个可能因数据过多而引起问题的因素。第一个因素显然是数据集本身的大小——不仅包括存储中的原始、未解压的大小,还包括其表示形式,这会影响在应用处理和训练时的工作大小(在 7.5 节中详细说明)。第二个因素是硬件的存储和内存容量(7.4 节)。最后,GNN 训练算法的选择——例如 GraphSAGE——将显著影响计算需求,尤其是在时间和内存限制方面(7.6 节)。

在我们实现第三章的示例时,我们确实遇到了问题,其根本原因是数据集的大小。在第三章中,我们的重点是展示算法,因此我们没有指出这一点,并默默地使用了一种方法来减轻这个问题。具体来说,我们使用了数据集的最佳表示(稀疏而不是密集)。

7.1.2 GeoGrid

在您浏览本章内容时,我们将借鉴一个虚构但具有代表性的科技公司——GeoGrid——在处理该领域挑战和机遇的例子。GeoGrid 是一家地理空间数据分析建模公司。利用 GNN 等先进技术,该公司为从交通预测到气候变化规划等问题的解决方案提供支持。作为一个在竞争激烈的领域中的初创公司,GeoGrid 经常面临可能决定公司成败的关键技术决策,尤其是在与大规模政府项目竞争时。

GeoGrid 将被用来探讨与规模问题相关的各种概念和技术决策。无论团队是在讨论不同机器学*架构的利弊,考虑在多个 GPU 上使用分布式数据并行(DDP)训练,还是制定如何将算法扩展到大规模数据集的战略,公司的故事为本章讨论的理论和方法提供了现实世界的背景。

在下一节中,我们将提供一个框架来评估和描述规模问题。然后,我们将总结解决此类问题的方法。最后,我们将详细调查这些方法。

7.2 规模问题的框架

在我们深入解决方案之前,让我们定义一下规模带来的挑战。本节概述了数据大小问题的根本原因及其症状。然后,它强调了在识别、诊断和解决此类问题中至关重要的关键指标 [1, 3]。

从机器资源的角度来看,发展过程被分解为三个阶段。在以下三个阶段中,本章将重点关注预处理和训练:

  • 预处理 — 将原始数据集转换为适合训练的格式

  • 训练 — 通过将训练算法应用于预处理后的数据集来创建 GNN 模型

  • 推理 — 从训练好的模型中创建预测或其他输出

7.2.1 根本原因

简而言之,当训练数据变得太大以至于无法适应我们的系统时,就会产生规模问题。确定数据大小何时成为问题很复杂,并取决于多个因素,包括硬件能力、图的大小以及时间和空间上的限制。

硬件速度和容量

一个合适的系统必须能够通过其内存容量和处理速度来支持预处理和训练过程。内存不仅要支持图的大小本身,还要容纳实现转换和训练算法所需的数据。处理速度应该足够快,能在合理的时间内完成训练。

我们编写这本书时假设您有权访问免费的云资源,例如 Google 的 Colab 和 Kaggle 上找到的资源,或者至少有一个 GPU 处理器的适度本地资源。当这些资源不足时,如果存在资源,升级硬件配置可能是一个选择。对于训练最大的企业图,使用计算集群是不可避免的。我们将在第 7.4 节中更详细地探讨计算硬件。

图的大小

基本上,我们可以通过节点和边的数量来大致了解规模及其可能对我们训练解决方案的影响。了解这些特征可以帮助我们判断算法处理图所需的时间。此外,持有结构信息的表示形式将影响数据的大小。

除了结构信息之外,节点和边可以包含包含一个或多个维度的特征。通常,节点和边特征的大小可能大于图的结构信息。

为 GNNs 定义小、中、大型图的精确大小具有一定的情境性。这取决于具体的问题领域、硬件和可用的计算资源。在撰写本文时,以下是一个通用的分类:

  • 小型图 — 这些可能包括具有数百到数千个节点和边的图。它们通常可以在标准硬件上处理,无需专用资源。

  • 中等规模的图 — 这个类别可能包括具有数万个节点和边的图。中等规模图中的复杂性可能需要更复杂的算法或硬件,例如 GPU,以有效地处理。

  • 大型图 — 大型图可以包括数十万到数百万(甚至数十亿)个节点和边。处理此类图通常需要分布式计算和专为可扩展性设计的专用算法。

  • 算法的时间和空间复杂度 — 时间和空间复杂度指向运行算法所需的计算和内存资源。这些直接影响到处理速度、内存使用和效率。了解这些复杂度有助于在算法选择和资源分配方面做出明智的决定。高时间复杂度可能导致运行时间变慢,影响您的模型训练计划。高空间复杂度可能限制 GNN 可以处理的数据集的大小,影响您处理大型、复杂图的能力。我们将在第 7.6 节中进一步探讨这一点。

7.2.2 症状

可扩展性问题的根本原因以多种方式表现出来。一个常见问题是 处理时间长,这可能会发生在需要更多计算能力和时间来处理的大型数据集时。较慢的算法会增加训练模型所需的时间,使得快速迭代和改进模型变得困难。然而,被视为太长的时间将取决于具体问题。对于需要每周提供的结果,几个小时可能没问题,但如果模型需要在一天内重新训练,时间可能会过长。同样,如果处理时间长,计算成本可能会迅速增加,特别是如果需要大型机器来运行模型。

另一个问题是在或超过容量时的 内存使用,这可能会发生在大型数据集消耗大量内存的情况下。如果数据集太大,无法适应您的系统内存,可能会导致系统变慢或甚至崩溃。

最后,当您的算法和系统设置无法处理数据大小的 增加 时,可能会出现无法扩展到更大数据集的情况。确保时间和空间效率对于您的系统保持有效和可扩展至关重要。

7.2.3 关键指标

为了理解可扩展性的见解,对关键性能指标进行实证分析是有帮助的。这些指标包括内存、每轮时间、FLOPs 和收敛速度,如本处所述:

  • 内存使用 — 内存使用(单位为千兆字节),特别是可用的 RAM 或处理器内存量,在确定您可以训练的模型的大小和复杂性方面起着重要作用 [4, 5]。这是因为 GNN 需要在内存中存储节点特征、边特征和邻接矩阵。如果您的图很大或节点和边特征是高维的,您的模型将需要更多的内存。

PyTorch 和 Python 中有几个模块可以进行内存分析。PyTorch 内置了一个分析器,可以单独使用,也可以与 PyTorch Profiler Tensorboard 插件 [4] 结合使用。还有一个 torch_geometric.profile 模块。此外,托管在 Colab 和 Kaggle 上的云笔记本提供了每个处理器的内存使用实时可视化。

在我们仓库的代码示例中,我们使用两个库来监控系统资源:psutil(Python 系统和进程实用程序库)和 pynvml(NVIDIA 管理库的 Python 绑定)。psutil 是一个跨平台实用程序,它提供了一个接口来检索有关系统利用率(CPU、内存、磁盘、网络、传感器)、正在运行的过程和系统运行时间的详细信息。它特别适用于系统监控、分析和实时限制进程资源。以下是如何在代码中使用 psutil 的一个片段:

import psutil 

def get_cpu_memory_usage(): 
process = psutil.Process(os.getpid()) 
return process.memory_info().rss

在这个片段中,psutil.Process(os.getpid()) 用于获取当前进程,而 memory_info().rss 获取常驻集大小,即进程内存中保留在 RAM 中的部分。

除了psutil之外,pynvml是一个用于与 NVIDIA GPU 交互的 Python 库。它提供了关于 GPU 状态的详细信息,包括使用情况、温度和内存。pynvml允许用户以编程方式检索 GPU 统计信息,使其成为管理监控机器学*和其他 GPU 加速应用中 GPU 资源的必备工具。以下是如何在代码中使用pynvml的示例:

import pynvml 

pynvml.nvmlInit() 
def get_gpu_memory_usage(): 
   handle = pynvml.nvmlDeviceGetHandleByIndex(0) 
  info = pynvml.nvmlDeviceGetMemoryInfo(handle) 
  return info.used

在这里,pynvml.nvmlInit()初始化 NVIDIA 管理库,pynvml.nvmlDeviceGetHandleByIndex(0)检索索引为0的 GPU 句柄,而pynvml.nvmlDeviceGetMemoryInfo(handle)提供了关于 GPU 内存使用的详细信息。

在我们的示例中,psutilpynvml都用于提供对预处理和训练过程性能特征的洞察,提供了对系统和 GPU 资源利用的详细视图。

  • 每个 epoch 的时间 — 每个 epoch 的时间(也称为“每个 epoch 的秒数”,因为这个指标的计量单位通常是秒)指的是完成整个训练数据集的一次遍历所需的时间。这个因素受你的 GNN 的大小和复杂性、图的大小、批处理大小以及可用的计算资源的影响。具有较低每个 epoch 时间的模型更可取,因为它允许更多的迭代和更快的实验。PyTorch 或 PyG 证明的剖析器也可以用于此类测量。

在提供的代码中,每个 epoch 的时间是通过计算 epoch 的开始和结束时间之间的差异来测量的。在每个 epoch 的开始,使用start_time = time.time()捕获当前时间。然后对模型进行 1 个 epoch 的训练,完成之后,再次使用end_time = time.time()捕获当前时间。epoch 时间,即完成 1 个 epoch 训练所需的时间,随后计算为结束时间和开始时间之间的差异(epoch_time = end_time - start_time)。这给出了模型训练 1 个 epoch 所需时间的精确测量,包括训练过程中涉及的所有步骤,如前向传递、损失计算、反向传递和模型参数更新。

  • FLOPs — 浮点运算(不要与每秒浮点运算数 FLOP/s[6, 7]混淆)计算训练模型所需的浮点运算次数。这可能包括矩阵乘法、加法和激活等操作。就我们的目的而言,FLOPs 的总数给出了训练 GNN 的计算成本的估计。

FLOPs 在执行时间上并不完全相同。这种可变性源于几个因素。首先,涉及的运算类型可以极大地影响计算成本:如加法和减法等简单运算通常更快,而如除法或平方根计算等更复杂的运算通常需要更长的时间。其次,FLOPs 的执行时间会根据所使用的硬件而显著变化。一些处理器针对特定类型的运算进行了优化,而像 GPU 这样的专用硬件可能比 CPU 更有效地处理某些运算。此外,算法的结构会影响 FLOPs 的执行效率;可以并行化的运算可能在多核系统上处理得更快,而依赖于先前结果的顺序运算可能总体上需要更长的时间。尽管执行时间存在这些变化,但给定算法所需的 FLOPs 总数保持不变。

在撰写本文时,尽管有一些外部模块可以分析 PyTorch 操作,但这些与 PyG 模型和层不兼容。文献中看到的一些努力依赖于自定义编程。

在我们的 GitHub 代码示例中,我们经常使用thop库来估算神经网络训练过程中每个 epoch 相关的 FLOPs。以下是一个计算 FLOPs 的简要片段:

from thop import profile   #1

input = torch.randn(1, 3, 224, 224) 
macs, params = profile(model, inputs=(input, )) 
print(f"FLOPs: {macs}")

1 异构 GCN

调用thop库的配置函数,将模型和样本输入批次作为参数传递。它返回正向传递的总 FLOPs 和参数。在此上下文中,FLOPs 衡量的是操作的总数,而不是每秒的操作数。

FLOPs 是一个有用的指标,可以用来对模型的计算需求和复杂性有一个大致的了解,当与其他指标一起使用时,可以全面理解性能。

  • 收敛速度 — 收敛速度(单位为秒或分钟)是指模型在训练过程中学*或达到最佳状态的速度。收敛速度受模型复杂度、学*率、使用的优化器以及训练数据质量等因素的影响。通常希望收敛速度更快,因为这意味着模型需要更少的迭代次数才能达到其最佳状态,从而节省时间和计算资源。

与内存和时间-per-epoch 分析一样,PyTorch 和 PyG 分析器可以用来测量收敛所需的时间。

在我们的代码示例中,收敛时间是通过测量在指定数量的周期内完成模型训练所需的时间间隔来计算的。在训练过程开始时,使用 time.time() 记录 convergence_start_time,标记训练的开始。然后,模型通过几个周期进行训练,每个周期涉及正向传播、损失计算、反向传播和参数更新等步骤。所有周期完成后,再次捕获当前时间,通过从最终时间戳中减去 convergence_start_time 来计算 convergence_time。这个 convergence_time 给出了模型在所有周期中完成训练所需的总时间,为模型在时间效率方面的性能和效率提供了见解。收敛时间越短,模型学*速度越快,达到令人满意的性能水平,前提是保持学*质量。

这四个因素之间的正确平衡取决于具体项目的约束条件,例如可用的计算资源、项目时间表以及数据集的复杂性和大小。对于这些指标的某些实际基准测试,Chiang [8] 在使用这些指标对其提出的 GNN、ClusterGCN 和基准 GNNs 进行比较分析方面做得非常出色。鉴于我们对构成规模问题的构成以及如何基准测试和测量此类问题的了解,我们现在转向可以缓解这些挑战的方法。

7.3 应对规模问题的技术

正如我们在上一节中概述的,当数据变得庞大时,我们必须处理与内存限制、处理时间和效率相关的问题。为了应对这些挑战,拥有一系列策略工具箱变得至关重要。在接下来的章节中,我们介绍了一系列旨在提供训练过程灵活性和控制的方法。这些策略从硬件配置到算法优化,针对不同的场景和需求进行了定制。这些方法源自学术界和工业界在深度学*和图深度学*中的最佳实践。

7.3.1 七种技术

首先,我们考虑三个可以在项目开始前规划并在项目过程中重新配置的基本选择。为了准备,为您的项目选择以下内容:

  • 硬件配置 — 这些选择包括处理器类型、处理器的内存配置,以及是否使用单个机器/处理器或多个处理器。

  • 数据集表示 — PyG 支持稠密和稀疏张量。在处理大型图时,从稠密到稀疏的转换可能会显著减少内存占用。您可以使用 PyG 的 torch_geometric .utils.to_sparse 函数将稠密邻接矩阵或节点特征矩阵转换为稀疏表示。

  • GNN 架构 — 某些 GNN 架构被设计为计算效率高且可扩展性适用于大型图。选择一个扩展性好的算法可以显著减轻大小问题。

考虑到这些三种选择类别,如果问题超出了我们的系统,那么以下是我们可以使用的技术来减轻问题:

  • 采样 — 在整个大型图上进行训练而不是,你可以为每个训练迭代采样节点或子图的子集。通过增加采样和批处理例程的复杂性,可以在内存效率的收益中得到补偿。为了执行节点或图的采样,PyG 提供了来自其torch_geometric.samplertorch_geometric.loader模块的功能。

  • 并行和分布式计算 — 你可以使用多个处理器或机器集群,通过在训练期间将数据集从一台机器分散到多台机器来减少训练时间。根据你这样做的方式,可能需要一些开发和配置开销。

  • 使用远程后端 — 而不是将训练图数据集存储在内存中,它可以完全存储在后端数据库中,并在需要时拉取小批量数据。这种情况的最简单例子是将数据存储在本地硬盘上,并从那里迭代地读取小批量数据。在 PyG 中,这种方法被称为远程后端。这是 PyG 中相对较新的方法,有一些示例,但不多。在撰写本文时,两家数据库公司已经为 PyG 的远程后端功能开发了一些支持。这种方法需要最多的开发和维护开销,但在缓解大数据问题方面最有回报。

  • 图粗化 — 图粗化技术用于在(希望)保留其基本结构的同时减小图的大小。这些技术通过聚合节点和边,创建原始图的粗化版本。PyG 为此提供了图聚类和池化操作。缺点是必须小心确保粗化图真正代表原始图,并且在监督学*中,你必须决定如何合并目标。

训练 GNN 时规模的多方面问题需要深思熟虑的方法。通过应用各种杠杆,如硬件选择、优化技术、内存管理和架构决策,你可以调整过程以适应特定的需求和约束。

7.3.2 一般步骤

在本节中,我们提供了一些关于规划和评估考虑规模的项目的通用指南。一般步骤如下:

  1. 规划阶段

    • 预测硬件需求 — 提前熟悉可用的硬件选项。许多在线和本地系统已发布配置。

    • 理解你的数据 — 对于机器学*生命周期的每个阶段,对你的数据集大小有一个清晰的认识。

    • 内存与数据比率 — 作为经验法则,您的内存容量应理想地介于数据集大小的 4 到 10 倍之间。

  2. 基准测试阶段

    • 建立基线 — 使用代表性数据集对这些指标进行基准测试。这些初始数据可以随后作为预测项目训练和实验时间线的基石。

      • 训练指标 — 监控和测量关键指标,如内存利用率、每个 epoch 的时间、每秒浮点运算次数(FLOP/s)以及收敛时间。
  3. 故障排除 — 如果您遇到挑战且缺乏硬件升级的资源,请考虑实施本章中详细说明的策略来绕过硬件限制。

现在我们已经了解了规模问题、衡量它们的指标以及一系列缓解这些问题的技术,让我们更深入地探讨这些个别方法。

7.4 硬件配置的选择

本节探讨了选择和调整硬件配置以解决规模问题。首先,我们将回顾硬件配置的一般选择,然后对相关的系统和处理器选择进行广泛概述。为这些选项提供了指南和建议。本节最后以第一个 GeoGrid 迷你案例研究结束。

7.4.1 硬件选择的类型

可用于训练图神经网络(GNNs)的硬件配置多种多样。每种配置都针对不同的需求进行了定制,以优化性能:

  • 处理器类型 — PyTorch 提供了在多种处理器上运行的灵活性,包括中央处理器(CPUs)、图形处理器(GPUs)、神经处理器(NPUs)、张量处理器(TPUs)和智能处理器(IPUs)。虽然 CPU 无处不在,可以处理大多数通用任务,但配备了并行处理能力的 GPU 专门设计用于密集计算,因此它们非常适合训练大规模神经网络模型。TPU 是针对机器学*任务的定制加速器。它们可以提供更大的计算能力,但它们的可用性可能受到限制。更多细节将在下一小节中给出。两种其他加速器,神经处理器(NPUs,专门设计在手机、笔记本电脑和边缘设备上运行神经网络工作负载的处理器)和智能处理器(IPUs,设计用于需要大规模数据处理的极高并行工作负载),是重要的处理器类别。PyTorch 目前仅支持 Graphcore IPUs。

  • 内存大小 — 每种处理器类型都配备其相应的 RAM。这种 RAM 的大小在确定系统可以处理的工作负载规模中起着关键作用。充足的 RAM 确保模型训练顺畅,特别是对于需要处理大量数据或具有复杂架构的网络。

  • 单 GPU 或 TPU 与多 GPU 或 TPU 的选择 — 对于有幸能够访问多个 GPU 或 TPU 的用户来说,它们可以显著缩短训练时间。PyTorch 提供了 DistributedDataParallel 模块,它利用多个 GPU 或 TPU 的力量并行训练模型。这意味着您可以将计算负载分配到多个设备上,从而实现更快的迭代和模型收敛。

  • 单机与计算集群 — 除了单机的范围之外,有时训练需求可能需要扩展到整个集群。在这个上下文中,集群指的是由机器组成的集合,每台机器都配备了其独特的计算、内存和存储资源。如果您能够访问这样的资源,PyTorch 的 DistributedDataParallel 模块再次成为首选工具,至少在小型集群方面是这样。在这种情况下,它允许您将训练过程扩展到整个集群,这对于处理特别大的模型或大量数据集非常有价值。

随着你在硬件能力方面的提升——从单个处理器到多个设备,再到整个集群——规划、设置和管理复杂性也会增加。根据任务的要求和可用资源做出明智的决策可以使这一过程更加顺畅和高效。正如引言中提到的,在本章中,我们将重点关注单机优化。

7.4.2 处理器和内存大小的选择

当我们转向硬件考虑的话题时,了解训练 GNN 的主要选项(CPU、GPU、NPU、IPU 和 TPU)非常重要。在本节中,我们提供了每种硬件类型的简要概述,并提供了它们应用的指南。这些关键点总结在表 7.2 中。

  • 中央处理器 (CPUs) — CPU 在通用计算任务中表现出色,从数据预处理到模型训练。然而,它们并不是针对专门的深度学*任务进行优化的,这可能会影响它们的速度和效率。另一方面,与其它硬件选项相比,CPU 通常更具成本效益,这使得它们对更广泛的用户群体来说更加可访问。

  • 图形处理单元 (GPUs) — GPU 是为需要并行计算能力的任务而设计的。到目前为止,通过阅读这本书,你知道它们经常被用作 PyTorch 环境中训练 GNN 的首选硬件,尤其是在使用旨在充分利用 GPU 并行性的库(例如,PyG)时。本书中的大多数示例都是在 Colab 平台上可用的 NVIDIA GPU 上运行的,包括 Tesla T4、A100 和 V100。

  • 张量处理单元(TPUs) — TPUs 是一种专门的选择,由 Google 构建以提升机器学*计算。它们提供快速的计算速度,并且可能具有成本效益。然而,它们的范围可能有限,因为它们是一种专有技术,主要与 Google Cloud 和 TensorFlow 兼容,并且可能不完全兼容 PyTorch。

  • 神经处理单元(NPUs) — AMD 和 Intel 都有 NPU 产品线,并配有可以与 PyTorch 集成的加速库。NPUs 是用于并行处理的专用硬件,类似于 TPUs。虽然 GPU 最初是为处理图形而设计的,但它们通常包含专门用于机器学*任务的电路。NPUs 将这些电路转化为专用单元,提高了效率和性能。苹果通常在其大多数笔记本电脑和计算机中提供类似的专用单元(称为 Apple Neural Engine [ANE])。

  • 智能处理单元(IPUs) — 这些是专门设计的电路芯片,考虑到深度学*任务进行设计和优化。IPUs 由 Graphcore 开发,擅长基于图计算。它们非常适合基于 GNN 的模型,因为它们允许在消息传递过程中根据需要并行化独立任务。IPUs 与 PyTorch 和 PyG 兼容,但需要重写某些任务。其他设计非常大型和强大专用芯片的公司包括 Cerebras 和 Groq。

  • 配置考虑因素 — 在选择硬件时,考虑到内存限制至关重要,因为 GNN 通常由于图数据的独特结构而数据密集。硬件的选择也可能影响训练和推理的速度。因此,权衡成本和性能之间的权衡,以适应您项目的具体需求是至关重要的。

在 PyTorch 中选择用于 GNN 训练的硬件时需要考虑的主要因素包括处理器类型(例如,CPU、GPU 或 TPU)、可用内存以及您的预算限制。这些考虑因素在表 7.2 中组织,以便快速参考。

表 7.2 处理器选择的优缺点
硬件 推荐工作负载 优点 缺点

| CPU | 预处理 | 适用于数据收集和预处理,比 GPU 和 TPU 便宜

由于缺乏加速并行处理而训练较慢

| GPU | 训练 | 由于并行处理,非常适合训练 | 比 CPU 贵,在深度学*任务上被 TPU 超越

|

| TPU | 预处理和训练 | 对于深度学*任务,计算时间快且成本效益高 | 需要特定的软件基础设施

仅限于 Google 平台

|

NPU 训练 优化用于深度学*,尤其是在设备端 AI 应用中表现优异,减少对云服务的依赖 限制于特定的 AI 工作负载,主要是基于神经网络的任务
IPU 训练 特别适合基于图的任务,如 GNNs 与 NPUs 相比,编程和优化可能更复杂

需要考虑的最后一点是,某些处理器类型在机器学*生命周期的特定步骤中表现突出:

  • 数据收集和预处理 — CPU 通常足以完成这些步骤。通常,它们可以高效地处理各种任务,而无需专用硬件。然而,根据我们的经验,对于一些内存密集型、长预处理步骤,如果可用,TPU 的表现会更好。

  • 模型训练 — 通常,这是生命周期中最计算密集的部分,GPU 通常是最佳选择。它们设计用于并行处理,这加速了神经网络的训练。GNNs 尤其受益于此,因为它们通常涉及图中的多个节点和边的计算。当可用时,TPU 可能提供性能优势。

  • 模型评估和推理 — 对于评估和推理,CPU 和 GPU 的选择取决于具体的使用场景。如果成本效益更重要,CPU 可能更受欢迎。TPU 凭借其高计算速度和成本效益,对于大规模部署来说可能是一个不错的选择,但与 CPU 和 GPU 相比,其使用范围更有限。

注意,最佳处理器选择可能取决于项目的具体要求,例如模型复杂性、数据集大小、使用的平台和可用的预算。我们以我们虚构的 GeoGrid 公司的一个例子结束本节。

示例

史密斯博士在 GeoGrid 公司工作,这是一家领先的地图公司,她正在进行一个涉及 GNNs 以分析不同城市间传染病传播的研究项目。她的数据集包含来自 10,000 个相连城镇(节点)的数据,每个城镇大约有 1,000 个节点特征。这个数据集的大小为 10 GB。以下概述了在准备使用 GNN 进行分析的项目时所需的一些不同步骤:

  1. 规划阶段

    • 预测硬件需求 — 史密斯博士审查了她所在大学的计算资源,发现他们可以访问 GPU 和 CPU,但 TPU 目前供应有限。

    • 理解您的数据 — 史密斯博士估计她的数据集总大小约为 10 GB。通过探索性数据分析,她确定她的数据是稀疏的。

    • 内存与数据比率 — 考虑到保留 4 到 10 倍数据大小的容量这一经验法则,她推断她理想情况下希望访问至少拥有 40 GB 到 100 GB RAM 的机器。

  2. 基准测试阶段 — 使用她数据的一个子集,史密斯博士在 GPU 和 CPU 上对数据预处理时间和模型训练时间进行了基准测试。正如预期的那样,当使用 GPU 进行模型训练时,她注意到速度显著提高,但 CPU 在数据预处理方面表现相对较好。她决定使用 CPU 设备进行预处理,使用 GPU 进行模型训练。

  3. 故障排除 — 通过调查频繁的系统崩溃和内存错误的根本原因,史密斯博士意识到她当前的 GPU 没有足够的内存来处理更大的图。鉴于当时内存更大的设备(短缺)无法申请,她决定使用子图采样方法,这是一种在第 7.7 节中详细描述的技术,以便使她的数据更适合当前硬件。

通过这个例子,我们看到了理解你的数据集和可用资源的重要性,基准测试以设定期望,以及故障排除以在限制内找到解决方案。接下来,我们将检查如何表示我们的数据的选择。

7.5 数据表示的选择

根据你的输入图(组)的特性,你在 PyG 中如何存储和表示它们将对时间和空间限制产生影响。在 PyG 中,主要的数据类 torch_geometric.data.Datatorch_geometric.data.HeteroData 可以用两种格式表示,以稀疏或密集格式表示图。在 PyG 中,密集表示和稀疏表示之间的区别在于图邻接矩阵和节点特征在内存中的存储方式。密集表示具有以下特点:

  • 整个邻接矩阵以大小为 N × N 的二维张量形式存储在内存中,其中 N 是节点的数量。

  • 节点特征存储在大小为 N × F 的密集二维张量中,其中 F 是每个节点的特征数量。

  • 这种表示方式内存密集,但在图密集时允许更快的计算,这意味着图的大部分顶点相互连接;也就是说,其邻接矩阵有很高的非零元素百分比,如附录 A 中所述。

相反,稀疏表示具有以下特点:

  • 邻接矩阵以稀疏格式存储,例如 COO(坐标)格式,它只存储非零元素的索引及其值。

  • 节点特征可以存储在稀疏二维张量中,或者是一个将节点索引映射到其特征向量的字典。

  • 这种表示方式在图稀疏时内存效率更高,意味着图中很少的顶点相互连接;也就是说,其邻接矩阵有很低的非零元素百分比,如附录 A 中所述。然而,与密集表示相比,它可能在特定任务中导致计算速度较慢。

注意 — 要了解稀疏或密集格式之间的区别以及图 是否 稀疏或密集的特点,请参阅附录 A,第 A.2 节。

在 PyG 中,可以将密集数据集转换为稀疏表示的两种方法是使用内置函数或手动执行转换:

  • torch_geometric.transforms.ToSparseTensor — PyG 中的这种转换可以用来将密集邻接矩阵或边索引转换为稀疏张量表示。它使用 COO(坐标)格式构建稀疏邻接矩阵。您可以将此转换应用于您的数据集,将密集表示转换为稀疏表示:
torch_geometric.transforms import ToSparseTensor

dataset = YourDataset(transform=ToSparseTensor())
  • 手动转换 — 您可以使用 PyTorch 或 SciPy 稀疏张量功能手动将密集邻接矩阵或边索引转换为稀疏表示。您可以创建一个torch_sparse.SparseTensorscipy.sparse矩阵,并从密集表示中构建它:
from torch_sparse import SparseTensor

dense_adj = ...    #1
sparse_adj = SparseTensor.from_dense(dense_adj)

1 密集邻接矩阵

通常,使用稀疏张量的主要动机是节省内存,尤其是在处理大规模图或零百分比很高的矩阵时。但是,如果您的数据零元素非常少,密集张量在内存访问和计算速度方面可能略有优势,因为与索引和访问稀疏张量相关的开销可能超过了节省的空间。请注意,将您的图数据集从一种表示转换为另一种表示本身可能会对您的内存和处理能力造成压力。

示例

一个学区雇佣了 GeoGrid 来研究其多个校园中优秀学生的关系。这项工作的一个方面是一个社交网络,其中学生是节点,学生之间的关联是边。巴克博士正在研究学生的社交网络图,希望确定友谊形成的模式:

  • 初步分析 — 巴克博士发现,在这个小社区中,几乎每个人都认识其他人。从原始数据来看,有 1,000 名学生(节点)和大约 450,000 个友谊(边)。巴克博士将现有的边与总可能连接数进行比较:n(n-1)/2,其中 n 是节点的数量;这等于 499,500。因为现有的边(450,000)几乎等于边的总数(499,500),他确定他正在处理一个密集图。

  • 密集表示 — 考虑到图的密度:

    • 邻接矩阵的大小为 1,000 × 1,000。

    • 如果每个学生都有一个包含 10 个属性的特征向量(例如,成绩、参加的社团数量等),节点特征存储在一个大小为 1,000 × 10 的张量中。

由于图的高度密集性,邻接矩阵中非零元素的数量很高,因此巴克博士首先考虑使用密集表示来进行更有效的计算:

  • 内存考虑 — 然而,随着巴克博士研究的进展,他计划将更多学校纳入他的数据集,预计图将变得更大,但不一定是更密集的。他预计随着规模的增加,密集表示可能会变得内存密集。

  • 稀疏表示—为了处理这个潜在的问题,他决定尝试使用稀疏表示。他使用torch_geometric.transforms.ToSparseTensor转换将他的当前密集图数据集转换为稀疏张量表示。

  • 结果—在转换后,他观察到稀疏表示可以节省足够的内存,足以选择它,特别是考虑到他的未来计划。尽管计算时间略有增加,但内存节省使得稀疏格式更适合他的不断扩大的数据集。

7.6 GNN 算法的选择

选择合适的 GNN 算法对于确保你的机器学*任务的扩展性和效率至关重要,尤其是在处理大规模图和有限的计算资源时。除了预测性能和任务适用性之外,考虑时间和空间复杂度以及评估一些关键指标是选择具有扩展性的 GNN 算法的两种方法。

7.6.1 时间和空间复杂度

我们通过使用Big O 表示法来评估时间和空间复杂度,这是一种数学简写,用于解释函数随着输入大小的变化而增长或减少的速度。它就像函数或算法的速度计,告诉你当输入变得非常大或趋向于特定值时它们会如何表现。这在机器学*工程和开发中特别有用,可以用来衡量算法的效率。

注意:对于 Big O 表示法的更全面解释,请参阅 Goodrich 等人[9]。此外,任何关于算法的入门文本都应该涵盖这个主题。

我们还在附录中讨论了与图和图算法相关的时间和空间复杂度,但这里有一些关于时间复杂度的 Big O 表示法的例子,按升序排列:

  • 常数时间复杂度,O(1)—这是最佳情况,算法总是花费相同的时间,无论输入大小如何。一个例子是通过索引访问数组元素。

  • 线性时间复杂度,O(n)—算法的运行时间随着输入大小的增加而线性增长。一个例子是在数组中查找特定值。

  • 对数时间复杂度,O(log n)—运行时间随着输入大小的增加而对数增长。具有这种时间复杂度的算法非常高效。一个例子是二分搜索。

  • 二次时间复杂度,O(n²)—算法的运行时间与输入大小的平方成正比。一个例子是冒泡排序。

当你理解了如何评估 Big O 的基本方法后,你可以使用 GNN 算法作者提供的信息来评估这一点。通常在算法的出版物中,作者会提供算法本身的步骤,这可以用来进行 Big O 分析。此外,作者还会经常提供他们自己的复杂度分析。

现在我们已经介绍了 Big O 的好处,我们将列出一些其注意事项。由于以下原因,对 GNN 算法进行独立或比较复杂度分析可能具有挑战性:

  • 多样化操作 — GNN 算法涉及各种操作,如矩阵乘法、非线性变换和池化。每个操作具有不同的复杂度,这使得提供一个单一的度量标准变得困难。此外,并非所有 GNN 都采用相同的操作,因此并排比较可能有限。在文献中,当比较 GNN 时,通常比较一个主要操作而不是整个算法。

  • 实现细节 — GNN 算法的实际实现,如使用特定库、硬件优化或并行计算策略,也会影响复杂度。

例如,表 7.3 比较了 Bronstein 等人 [10] 中发现的 GCN 与 GraphSAGE 的复杂度。这种比较特别关注一种操作(正向传播中的卷积类似操作)在一种输入图(稀疏图)上的操作。具体来说,Bronstein 等人比较了操作 Y = ReLU(A × W) 的时间和空间复杂度。分解来看,这个操作包括两个主要阶段:

  • 矩阵乘法(A × W) — 这意味着我们在矩阵 A(可能是我们的输入数据)上乘以矩阵 X(我们的权重或算法试图优化的参数),然后乘以矩阵 W。矩阵乘法是一种转换我们的数据的方式。

  • 激活(ReLU) — 矩形线性单元(ReLU)是一种用于将非线性引入我们模型的激活函数。本质上,ReLU 取矩阵乘法的结果,对于每个元素,如果值小于 0,则将其设置为 0。如果值大于 0,ReLU 保持不变。

表 7.3 两种图算法(GCN 和 GraphSAGE)的可扩展性因素。
算法 时间复杂度 空间复杂度 内存/Epoch 时间/收敛速度 备注

| GCN | O( Lnd²) | O( Lnd + Ld²) | 内存:差 Epoch 时间:好

收敛速度:差

| 优点:光谱卷积:高效且适用于大规模图

通用性:适用于各种与图相关的问题

节点特征学*:丰富的特征学*,能够捕捉图的拓扑结构

Con:

由于需要存储整个邻接矩阵和节点特征,导致高内存和时间复杂度

|

| GraphSAGE | O( Lbd² k ^L) | O( bk ^L) | 内存:好 Epoch 时间:差

收敛速度:好

| 优点:通过使用邻域采样和批量处理解决了 GCN 的可扩展性问题

Cons:

当采样节点在邻域中出现多次时,可能会引入冗余计算

每个批次保留 O( bk ^L) 个节点在内存中,但只在其中的 b 个上计算损失

|

| n = 图中的节点数量 d = 节点特征表示的维度

L = 算法中的消息传递迭代次数或层数

k = 每跳采样的邻居数量

b = 小批量中的节点数量

|

从这次比较中得出的一个启示是,虽然 GCN 的复杂性依赖于输入图中整个节点数量,但 GraphSAGE 的复杂性与此无关,在空间和时间性能上都有很大的改进。GraphSAGE 通过采用邻域采样和小批量处理来实现这一点。

示例

GeoGrid 的任务是根据各种城市因素预测一个区域进行开发的可能性。图中的节点代表地理区域,而边可能代表便利设施、道路网络或已经开发的其他区域:

  • 团队分析 — 虽然当前项目只包含一个都市区,GeoMap 希望未来逐步扩展系统,实现全国覆盖,包括包含数百万地理节点和数十亿边的数据库。每个节点都有一个特征向量,可能包括诸如土地价值、距离公共交通的远*和分区法规等属性。

由于当前图的大小、扩展计划以及对及时预测的需求,GeoGrid 的数据科学团队必须仔细选择一个合适的 GNN 架构。

  • GCN — GCN 易于解释,但它们的时间复杂度 O(Lnd)在图规模扩大时可能带来挑战。然而,使用 PyG 的小批量方法,团队可以在不需要存储整个邻接矩阵的情况下管理图,使 GCN 成为一个合理的候选方案。

  • GraphSAGE — GraphSAGE 提供了 O(Lbdk)的时间复杂度,由于其内存效率和可扩展性而具有吸引力。它允许调整小批量大小b和采样邻居数量k,从而在性能调整方面提供灵活性。

  • GAT — 图注意力网络(GATs)通过注意力机制提供细微洞察的潜力,但它们带来了额外的计算成本。虽然大 O 复杂度可能与 GCN 相似,但注意力机制可能会引入额外的计算开销。

算法比较

虽然 GCN 看起来比 GraphSAGE 简单,但随着图的增长,其依赖于节点数量n可能会出现问题。GraphSAGE 由于其依赖于bk而提供可扩展性。尽管 GAT 可能更准确,但其注意力机制带来了计算复杂性。

使用 PyG 进行小批量处理使得 GCN 更易于管理。然而,团队也喜欢 GraphSAGE,因为它固有的可扩展性优势。尽管 GAT 可能具有更高的准确性,但对于这个应用来说可能过于资源密集。

  • 决策 — 经过彻底评估后,GeoGrid 团队决定 GraphSAGE 提供了最平衡的方法,在计算效率和预测准确性之间进行优化。

  • 结论 — 他们计划在受控环境中试验 GAT,以评估其增加的计算需求是否真正产生了更准确的城市发展预测。在进入生产之前,他们将以明确的指标开始用户接受度测试。

前三个部分已经涵盖了在考虑规模问题时计划训练 GNN 时需要做出的基本选择。在接下来的五个部分中,我们将回顾可以解决规模问题的方法,包括深度学*优化、采样、分布式处理、使用远程后端和图粗化。

7.7 使用采样方法进行批量处理

在本节中,我们探讨了如何将大量数据分成由采样方法选择的批次。我们将一般性地解释这一点,然后分析 PyG 包中的一些实现。我们以 GeoGrid 案例结束,强调使用这些方法的实际选择和影响。

采样:文献与实现

在文献中,有许多关于设计到 GNN 算法中的各种类型采样技术(通常分为节点采样、层采样和图采样)的讨论,但在这个部分,我们将专注于 PyG 包中的采样实现。许多这些技术源自文献,但它们的目的仍然是推广采样以支持各种 GNN 算法和训练操作。为了本部分的目的,我们使用这些采样实现来支持小批量处理。

GCN(图卷积网络)为这一点提供了一个很好的说明。虽然按照其标准形式所设计的 GCN 模型确实不涉及采样,但 PyG 的NeighborSampler函数仍然可以与GCNConv层一起使用。这是可能的,因为NeighborSampler本质上是一个数据加载器,它从更大的图中返回一批子图。

在这种情况下,子图被用来*似完整的图卷积操作。明显的优势是我们可以处理可能使算法或我们的机器内存不堪重负的大图。一个缺点是,由于这种*似,使用NeighborSamplerGCNConv的准确性可能不如完整批次训练那么高。

7.7.1 两个概念:小批量处理和采样

两种不同的方法——批量处理和采样——通常可以合并为一个函数。批量处理(在 PyG 中由加载器执行)是将大型数据集分成节点或边的子集,以便在训练过程中运行。但我们如何确定要包含在较小组中的节点或边的子集?采样是我们用来选择子集的具体机制。这些子集可以是连接的子图的形式,但它们不一定是。以这种方式进行的批量处理将减轻内存负载。在一个 epoch 期间,我们不必将整个图存储在内存中,我们可以一次存储它的一小部分。

带有采样的批处理可能存在缺点。一个担忧是丢失关键信息。例如,如果我们考虑消息传递过程,每个节点及其邻域对于更新节点信息都是关键的。采样可能会错过重要的节点,从而影响模型的性能。这可以比作在消息传递框架中省略关键消息。此外,采样过程可能会引入偏差,影响模型的泛化能力。这相当于在消息传递框架中有一个偏差的聚合操作。

PyG 中的批处理实现

批处理方法可以在loadersampler模块中找到。其中大多数结合了采样方法以及将采样数据批量提供给模型训练过程的函数。有通用类允许您编写自定义采样器(baseloaderbasesampler),以及具有预定义采样机制的加载器[11, 12]。

选择合适的采样器

选择理想的采样方法可能相当复杂,并且取决于图的性质和训练目标。不同的采样器会产生不同的 epoch 时间和收敛时间。没有普遍的规则来确定最佳采样器;最好是实验性地使用有限的数据集来查看哪种方法最有效。实现采样为 GNN 架构增加了另一层复杂性,就像消息传递需要精心编排的聚合和更新步骤一样。

7.7.2 概览 PyG 中显著的采样器

正如我们所见,GNNs 通过聚合局部邻域来工作。然而,对于非常大的图,考虑聚合操作中的所有节点或边可能是不切实际的,因此通常使用采样器。以下列出了一些 PyG 库默认支持的常用采样器:

  • NeighborLoader—非常适合捕捉局部邻域动态,常用于社交网络分析。

  • ImbalancedSampler—专为不平衡数据集设计,例如在欺诈检测场景中。

  • GraphSAINT Variants—旨在最小化梯度噪声,使其适用于大规模训练[9]。

  • ShaDowKHopSampler—适用于采样较大的邻域,捕捉更广泛的结构信息。

  • DynamicBatchSampler—旨在按邻域计数分组节点,优化批处理计算一致性。

  • LinkNeighborLoader—一种使用类似于neighborloader的方法采样边的加载器。

注意:此概述并不全面,功能可能根据使用的 PyG 版本而有所不同。如需深入了解,请参阅官方 PyG 文档(mng.bz/DMBa)。

让我们看看使用Neighborloader加载器的代码片段。完整代码在 GitHub 仓库中,我们在这里将查看代码片段。该代码使用采样器运行一个 GNN 的训练循环。对于每个批次,它将节点特征、标签和邻接信息移动到设备上,即 GPU。然后清除先前的梯度,通过模型进行正向和反向传递以计算损失,并相应地更新模型参数。要在你的代码中添加使用NeighborSampler的邻居批处理,你可以按照以下步骤操作:

  1. 导入所需的模块:
from torch_geometric.loader import NeighborLoader
    1. 定义迷你批次大小和要采样的层数:
batch_size = 128    #1
num_neighbors = 2    #2

1 设置所需的迷你批次大小

2 设置每个节点要采样的层数

    1. 创建用于迷你批次训练期间在邻域中采样的NeighborLoader实例:
loader = NeighborLoader(data, input_nodes = train_mask, batch_size=batch_size\
   num_neighbors=*num_neighbors)

在这里,data是输入图,input_nodes包含训练节点的索引,num_neighbors指定每个层要采样的邻居数量。

    1. 修改你的训练循环,使用采样器遍历迷你批次,如下所示。
列表 7.1 使用NeighborSampler的训练循环
for batch_size, n_id, adjs in sampler:    #1
  x = data.x[n_id].to(device)        #2
  y = data.y[n_id].squeeze(1).to(device)   #3
  adjs = [adj.to(device) for adj in adjs]  #4

  optimizer.zero_grad()              #5
  out = model(x, adjs)             #6
  loss = F.nll_loss(out, y)         #7
  loss.backward()               #8
. optimizer.step()     #9

1 初始化训练循环,使用 NeighborSampler 遍历批次。batch_size 是批次的大小,n_id 包含节点 ID,adjs 存储采样子图的邻接信息。

2 获取当前批次中节点的特征(x),并将它们移动到目标设备(通常是 GPU)。这与在消息传递范式下获取嵌入相似。

3 获取当前批次中节点的对应标签(y),删除任何单例维度,并将它们移动到设备上。

4 将采样子图的邻接信息移动到设备上。

5 将所有优化变量的梯度设置为零。这在反向传播期间正确计算梯度是必不可少的。

6 通过 GNN 模型进行正向传递以计算预测。模型接收节点特征和邻接信息作为输入。

7 使用负对数似然损失计算模型输出和真实标签之间的损失

8 反向传递以根据损失计算梯度

9 根据计算出的梯度更新模型参数

为了完善这一部分,我们将探讨 GeoGrid 团队在项目中必须从三个批处理器中选择的情况。

示例

让我们回到 GeoGrid,一家领先的地图公司。一个团队正在开发整个美国道路系统的基于图的表示,交叉路口作为节点,道路段作为边。这个项目的规模巨大,带来了计算和内存挑战。

经过彻底调查后,团队筛选出了三种突出的批处理技术,我们将在这里评估每种技术的权衡:

  • GraphSAINTSampler 在其噪声减少能力方面具有优势,提供更准确的梯度估计,并且可扩展——非常适合像美国道路网络这样的大型系统。然而,其实现可能复杂,存在过度表示高度连接节点的风险。

  • NeighborSampler 是一种内存高效的采样器,专注于关键道路段,并强调局部邻域连接,为重要交叉口提供见解。然而,它可能会遗漏较少旅行路线上的关键数据,并且可能偏向于密集连接的节点。

  • ShaDowKHopSampler 有效地采样 k-跳子图,捕捉更大的邻域,其深度可调节以适应各种道路系统的复杂性。然而,某些 k 值可能会使其计算成本高昂,广泛的捕获可能会引入过多且不立即相关的数据。

在以下内容中,我们将展示不同采样器在实际中的应用,以 GeoGrid 公司作为案例研究:

  • 决策 — 经过广泛讨论,团队倾向于选择 ShaDowKHopSampler。该方法能够捕捉更广泛的邻域而无需局限于直接邻居,这似乎非常适合美国道路系统的多样化复杂性。他们相信,通过实验确定正确的 k 值,他们可以在深度和计算效率之间取得平衡。

为了对抗潜在的信息过载并确保相关性,GeoGrid 计划将结果与实际交通数据进行对比,确保采样图保持实用和准确。

  • 结论 — GeoGrid 采用 ShaDowKHopSampler 的决定源于对每种技术优缺点的深入分析。通过将采样方法与实际数据相结合,他们旨在在图表示的粒度和相关性之间取得平衡。

现在我们已经掌握了批处理的概念,我们可以检查两种与采样协同工作的技术:并行处理和使用远程后端。

7.8 并行和分布式处理

批处理非常适合于接下来的两种方法,并行处理和使用远程后端,因为这些方法在数据分割时效果最佳。并行处理是一种通过在多个计算节点或多台机器上分散计算任务来训练机器学*模型的方法。在本节中,我们专注于在单台机器上的多个 GPU 之间分散模型训练 [13–17]。我们将使用 PyTorch 的 DistributedDataParallel 来实现这一点。

DataParallel 和 DistributedDataParallel

在 PyTorch 领域,你将遇到两种并行化神经网络模型的主要选项:DataParallelDistributedDataParallel。每种方法都有其优点和局限性,这对于做出明智的决定至关重要。

DataParallel是为单台机器上的多 GPU 设置量身定制的,但也有一些注意事项,例如在每个前向传递期间模型的复制会带来额外的计算成本。随着你的模型和数据规模的扩大,这些限制变得更加明显。

另一方面,DistributedDataParallel可以跨多台机器和 GPU 进行扩展。它通过为 GPU 间通信分配专用的 Compute Unified Device Architecture (CUDA) 缓冲区,并且通常产生更少的开销,从而优于DataParallel。这使得它非常适合大规模数据和复杂模型。

DataParallelDistributedDataParallel都为在 PyTorch 中并行化你的模型提供了途径。了解它们各自的优势和劣势,使你能够选择最适合你特定机器学*挑战的技术。鉴于其在可扩展性和效率方面的优势,尤其是在复杂或大规模项目中,我们选择了DistributedDataParallel作为我们模型并行化的首选选项。

7.8.1 使用分布式数据并行

用简单的话说,分布式数据并行(DDP)是一种同时使用多个图形卡(GPU)训练机器学*模型的方法。想法是将数据和模型分布在不同的 GPU 上,执行计算,然后将结果汇总在一起。为了使这成为可能,你首先需要设置一个进程组,这仅仅是一种组织你使用的 GPU 的方式。与一些其他方法不同,DDP 不会自动分割你的数据;你必须自己完成这部分工作。

当你准备训练时,DDP 通过在所有 GPU 之间同步对模型所做的更新来提供帮助。这是通过共享梯度来完成的。因为所有 GPU 都获得了这些更新,它们都在帮助改进同一个模型,尽管它们正在处理不同的数据。

与在单个 GPU 上运行或使用更简单的并行方法相比,这种方法特别快速且高效。然而,有一些技术细节需要记住,例如如果你使用多台机器,确保你正确地加载和保存你的模型。训练的一般步骤如下:

  • 模型实例化 — 初始化用于训练的 GNN 模型。

  • 分布式模型设置 — 使用 PyTorch 的DistributedDataParallel包装模型,以准备分布式训练。

  • 训练循环 — 实现一个包含前向传播、计算损失、反向传播和更新模型参数的训练循环。

  • 进程同步 — 使用 PyTorch 的分布式通信包来同步所有进程,确保在进入下一步之前所有进程都已完成训练。这可以通过在移动到下一个 epoch 之前使用dist.barrier()来实现。一旦所有 epoch 都完成,它将销毁进程组。

  • 入口点守卫 — 使用 if __name__ == '__main__': 来指定数据集并启动分布式训练。这确保了只有在脚本直接运行时才会执行训练代码,而不是当它作为模块导入时。

使用分布式处理需要仔细处理同步点,以确保模型被正确训练。你还必须确保你的机器或集群有足够的资源来处理并行计算。

Torch.distributed 支持分布式计算的多种后端。以下是最推荐的两种:

  • NVIDIA 集体通信库(NCCL)—Nvidia 的 NCCL 用于基于 GPU 的分布式训练。它为集体通信提供了优化的原语。

  • Gloo — Gloo 是由 Facebook 开发的集体通信库,提供广播、全归约等各种操作。这个库用于 CPU 训练。

7.8.2 DDP 代码示例

下面是使用 PyTorch 进行分布式训练的示例。为了简单起见,我们使用修改后的国家标准与技术研究院(MNIST)数据集训练一个简单的神经网络。在 GitHub 仓库中可以找到一个使用 GCN 在 Amazon 产品数据集上的示例。在那个例子中,我们不是使用 Google Colab 来运行代码,而是使用 Kaggle 笔记本,它具有双 GPU 系统。GCN 示例中的另一个区别是我们使用了 NeighborLoader 数据加载器,它使用 NeighborSampler 样本器。

让我们分解一下这段代码中发生的事情。GCN 版本基本上遵循同样的逻辑。

分布式训练设置

脚本导入必要的模块,如 torchtorch.distributed 等。它使用 dist.init_process_group 初始化 DDP 环境。它使用 NCCL 设置通信,并指定本地主机地址和端口(tcp://localhost:23456)用于同步。

准备模型和数据

代码定义了一个简单的 Flatten 层,这是神经网络的一部分,用于重塑其输入。数据转换和加载步骤使用 PyTorch 的 DataLoader 和 torchvision 数据集进行设置。加载的数据是 MNIST。

训练函数

train 是负责训练模型的函数。它遍历数据批次,执行正向和反向传递,并更新模型参数。

主函数

main() 函数内部,每个进程(在本例中代表单个 GPU)设置其随机种子和设备(基于进程的排名的 CUDA 设备)。神经网络模型被定义为具有 Flatten 层和 Linear 层的顺序模型。然后使用 DistributedDataParallel 进行包装。定义了损失函数(CrossEntropyLoss)和优化器(SGD)。

多进程启动

最后,脚本使用mp.spawn函数启动分布式训练。它在world_size数量的进程(基本上是两个 GPU)上运行main()。每个进程将在其数据子集上训练模型。

运行训练

每个进程使用其数据子集训练模型,但梯度会在所有进程(GPU)之间同步,以确保处理器正在更新相同的全局模型。这个过程总结在图 7.3 中。

图

图 7.3 使用多个处理器设备启动和运行训练的过程图

以下列表使用DistributedDataParallel模块训练神经网络。

列表 7.2 使用 DDP 进行训练
import torch 
import torch.distributed as dist 
import torch.multiprocessing as mp 
import torch.nn as nn 
from torch.nn.parallel import DistributedDataParallel    #1
from torch.utils.data import DataLoader    #2
from torchvision import datasets, transforms 

class Flatten(nn.Module): 
  def forward(self, input): 
    return input.view(input.size(0), -1) 

def train(model, trainloader, 
                 criterion, 
                 optimizer,
                 device):   #3
    model.train() 
    for batch_idx, (data, target) in enumerate(trainloader): 
      print(f'Process {device}, Batch {batch_idx}') 
       data, target = data.to(device), target.to(device) 
       optimizer.zero_grad() 
       output = model(data) 
       loss = criterion(output, target) 
       loss.backward() 
       optimizer.step() 

def main(rank, world_size):    #4
    filepath = '~/.pytorch/MNIST_data/'
    dist.init_process_group(   #5
    backend='nccl', 
    init_method='tcp://localhost:23456', 
    rank=rank,
    world_size=world_size    #6
    )

    torch.manual_seed(0)   #7
    device = torch.device(f'cuda:{rank}')    #8

    transform = transforms.Compose(
                        [transforms.ToTensor(),
                        transforms.Normalize((0.5,),
                        (0.5,))]
                        )

    trainset = datasets.MNIST(filepath ,                #9
                                download=True,          #9
                                train=True,             #9
                                transform=transform)    #9
    train_loader = DataLoader(trainset,           #10
                                batch_size=64,    #10
                                shuffle=True,     #10
                                num_workers=2)    #10

    model = nn.Sequential(Flatten(), nn.Linear(784, 10)).to(device) 
    model = DistributedDataParallel(model, device_ids=[rank])   #11

    criterion = nn.CrossEntropyLoss() 
    optimizer = torch.optim.SGD(model.parameters(), lr=0.01) 

    train(model, train_loader, criterion, optimizer, device)    #12

1 导入分布式训练的 DistributedDataParallel 类

2 导入数据加载器实用工具

3 定义主要训练函数

4 定义分布式训练设置的主要函数

5 初始化分布式进程组

6 指定参与进程的总数

7 设置随机种子以确保可重复性

8 根据进程排名设置设备

9 加载和转换 MNIST 数据集

10 为训练数据创建数据加载器

11 包装模型以进行分布式训练

12 调用训练函数以启动训练过程

我们以 GeoGrid 的朋友们的另一个例子结束本节。

示例

GeoGrid 有机会提交一个政府项目的概念验证,该项目旨在使用 GNN 进行复杂的环境建模。赢得这个合同可能使他们成为该领域的领导者,但他们面临着激烈的竞争。政府设定了一个紧张的截止日期来审查概念验证演示,这使得 GeoGrid 的情况变得紧张,因为它仍处于开发初期。

在一次团队会议上,焦点转向了一个关键的技术决策和一个重要的困境:在多个 GPU 上使用 DDP 训练的潜在用途。首席数据科学家看到了 DDP 加快训练时间的吸引力,这为政府项目提供了一个可能令人印象深刻的效率展示和准备情况。

另一方面,团队中的一位经验丰富的工程师心存担忧。尽管 DDP 有其优势,但它可能会引入问题,例如在 GPU 之间同步梯度时产生的计算开销。另一层复杂性来自其他团队成员,他们指出,他们的专用 GNN 算法尚未与 DDP 进行过测试。他们表达了对数据如何在 GPU 之间分配以及可能出现的不平衡和低效的担忧。其他担忧集中在开发和测试代码所需的时间上。

团队仔细权衡了这些因素。快速且按时完成演示将是理想的选择。然而,将 DDP 应用于他们特定的 GNN 模型可能带来的复杂性和未知因素可能会带来意外的延误和成本,可能使他们错过提交截止日期。

对模型开发的迭代性质进行了进一步考虑。在概念验证阶段,为了性能优化进行快速迭代至关重要。将 DDP 加入其中可能会使调试复杂化并延长开发周期:

  • 决策 — 最终,团队选择了一种谨慎的方法。他们决定进行为期一周的可行性研究,严格评估使用 DDP 对他们的 GNN 架构的影响。这将使他们能够根据经验数据做出明智的决定,这些数据跟踪了收敛时间和每个 epoch 的平均时间。将咨询 IT 部门以确保必要的计算资源仅用于这项关键研究。

  • 结论 — 推出 GNN 的决定通常高度依赖于数据、时间表和计算需求。可行性研究是决策过程中的重要部分,尤其是在确定计算需求时。

在下一节中,我们将探讨另一种基于采样的技术,该技术通过直接从远程存储系统中抽取数据来进行训练。

7.9 使用远程存储进行训练

本书在数据处理管道方面的一个突出方法是从数据存储系统中获取数据,然后通过将其转换为 GNN 平台使用的形式来预处理这些数据。在训练过程中,这些预处理好的数据被存储在内存中。

相比之下,当数据变得太大而无法放入内存时,一种方法是将预处理集成到训练过程中。我们不必预处理整个数据集,将其放入内存,然后进行训练,我们基本上可以直接从初始数据存储系统进行采样和批处理。通过我们 GNN 平台和数据源之间的接口,我们可以处理直接从数据源抽取的每个批次[18]。在 PyG 中,这被称为远程后端,并且设计为对所使用的特定后端无感知[19–22]。

好处在于,我们现在数据集的大小仅受我们数据库容量的限制。权衡如下:

  • 我们需要做一些工作来设置远程后端,具体细节在本节中详细说明。

  • 从远程后端抽取数据将引入 I/O 延迟。

  • 集成远程后端会增加训练设置的复杂性。基本上,可能出现更多问题,并且需要调试的项目也会更多。

在 PyG 中,远程后端是通过存储和从图的两个方面进行采样实现的:使用GraphStore对结构信息(即,边)进行采样,以及使用FeatureStore对节点特征进行采样(在撰写本文时,边特征尚未支持)。对于存储图结构,PyG 团队建议使用图数据库作为后端,例如 Neo4J、TigerGraph、Kùzu 和 ArangoDB。同样,对于节点特征,PyG 团队建议使用键值数据库,例如 Memcached、LevelDB 和 RocksDB。实现远程后端的关键要素如下:

  • 远程数据源 — 存储您的图结构和节点特征的数据库。这个选择可能仅仅是您目前用来存储图的数据库系统。

  • 图存储对象torch_geometric.data.GraphStore 对象存储图的边索引,从而实现节点采样。您自定义类的主要组件必须是与您数据库的连接,以及 CRUD(创建、读取、更新、删除)函数,包括 put_edge_index()get_edge_index()remove_edge_index()

  • 特征存储对象torch_geometric.data.FeatureStore 管理图节点的特征。在图学*应用中,节点特征的大小被认为是一个主要的存储问题。与 GraphStore 类似,自定义实现包括连接到远程数据库和 CRUD 函数。

  • 采样器 — 一个与 GraphStore 链接的图采样器,通过 torch_geometric.sampler.BaseSampler 接口使用采样算法从输入节点生成子图。PyG 的默认采样器拉取边索引,将它们转换为压缩稀疏列 (CSC) 格式,并使用内存中的采样例程。自定义采样器可以通过实现 BaseSampler 类的 sample_from_nodes()sample_from_edges() 方法来使用专门的 GraphStore 方法。这涉及到节点级和链接级采样。

  • 数据加载器 — 数据加载器的工作方式与前面章节中介绍的方式类似。这里的区别在于数据加载器使用 GraphStoreFeatureStoresampler 对象,而不是通常的 PyG 数据对象。PyG 文档中的一个示例将在下一列表中展示。

列表 7.3 使用远程后端的加载器对象
loader = NodeLoader(
    data=(feature_store, graph_store),
    node_sampler=node_sampler,
    batch_size=20,
    input_nodes='paper',
)

for batch in loader:
    <training loop>

虽然可以开发自定义类和功能,但鼓励使用数据库供应商制作的工具。目前,KuzuDB 和 ArangoDB 为 PyG 的远程后端提供了实现[14, 18–20, 23]。我们以另一个以 GeoGrid 为例的迷你案例结束本节。

7.9.1 示例

GeoGrid 有一个如此大的图,以至于它无法适应可用硬件的内存。他们想使用 GNN 分析这个大图,预测交通拥堵和路线受欢迎程度等特征。但是,如何在连内存都装不下图的图上训练 GNN 呢?以下是一些与大型 GNN 一起工作的具体示例:

  • 采用 PyG 的远程后端 — GeoGrid 使用 PyG 的远程后端功能,这与公司处理大规模图的需求完美契合。他们使用 Neo4J 作为存储图结构的图数据库,使用 RocksDB 存储节点特征,如位置类型、历史交通数据等。

  • 远程数据源 — GeoGrid 选择了 Neo4J 和 RocksDB 作为他们的数据存储系统。第一个任务是编写将大量图数据加载到这些数据库中的脚本。这涉及到数据验证,以确保加载的数据是正确和一致的。

  • GraphStore 对象 — GeoGrid 的开发团队花费了大量时间来实现GraphStore对象。他们需要建立到 Neo4J 数据库的安全和可靠连接。一旦建立了连接,他们实现了 CRUD 操作。

  • FeatureStore 对象 — 类似地,为 RocksDB 实现FeatureStore对象也不是一件简单的事情。主要挑战是处理节点特征的变量大小和类型,这需要彻底测试以确保效率和正确性。

  • 采样器 — 开发定制的采样策略本身就是一个项目。采样器需要既有效又高效,在满足性能标准之前,它经历了多次迭代。

  • 数据加载器NodeLoader是最后一部分,将所有前面的元素组合成一个连贯的流水线,用于训练。开发团队必须确保NodeLoader在速度上进行了优化,以最小化 I/O 延迟。

测试和故障排除

正如所有软件开发、机器学*或 AI 项目一样,测试是工作流程中的关键部分。以下列出了一些在项目工作中典型的测试和质量保证(QA)步骤:

  • 单元测试 — 每个组件都经历了严格的单元测试。这至关重要,可以早期捕捉到错误,并确保系统每个部分在独立工作时都能按预期工作。

  • 集成测试 — 在单元测试之后,团队进行了集成测试,其中他们从加载数据批次到运行 GNN 模型的全流程进行了测试。他们发现了一些瓶颈和错误,特别是采样器和数据库连接方面,这些问题需要相当多的时间来排查和解决。

  • I/O 延迟 — 公司遇到的一个重大问题是,从 Neo4J 和 RocksDB 中拉取数据时的 I/O 延迟。GeoGrid 优化了其查询,并使用了一些缓存机制来减轻这一问题。

  • 调试 — 在开发和测试阶段,团队遇到了各种错误和问题,从数据不一致到采样过程中的意外行为。每个问题都必须仔细调试,这增加了整体开发时间。

尽管存在这些挑战,GeoGrid 仍然能够成功实施了一个可扩展的解决方案,用于在庞大的地理图上训练 GNN。这个项目耗时且复杂,但可扩展性和在内存外图上训练的能力是无价的收益,这些收益证明了付出的努力是值得的。

7.10 图细化

图粗化 是一种在保留图的基本特征的同时减小图大小的技术。该技术通过创建原始图的粗化版本来减少图的大小和复杂性。图粗化减少了节点和边的数量,使它们更容易管理和分析。它涉及聚合或合并节点和边,以形成原始图的简化表示,同时试图保留其结构和关系信息。

图粗化的一种方法是从输入图 G 和其标签 Y 开始,然后使用以下步骤[23]生成粗化图 G’

  1. G 上应用图粗化算法,生成归一化的划分矩阵(即节点簇集合)P

  2. 使用这个划分矩阵来完成以下操作:

    1. 构建粗化图,G’

    2. 计算 G’的特征矩阵。

    3. 计算 G’的标签。

  3. 使用粗化图进行训练,生成可以在原始图上测试的权重矩阵。

虽然我们可以通过减少顶点和边来使用图粗化来减小大型图的大小,但它有缺点。它可能导致信息丢失,因为原始图的关键细节可能被删除,从而复杂化后续分析。它也可能引入不准确之处,未能完全代表原始图的结构。最后,没有通用的图粗化方法,导致结果各异和可能的偏差。在 PyG 中,图粗化涉及两个步骤:

  1. 聚类 — 这涉及将相似节点分组在一起形成超节点。每个超节点代表原始图中的节点簇。聚类算法根据某些标准确定哪些节点是相似的。在 PyG 中,有各种聚类算法可用,如graclus()voxel_grid()

  2. 池化 — 一旦形成簇或超节点,就使用池化从原始图创建更粗的图。池化将每个簇中的节点信息合并为粗化图中的一个单个节点。PyG 中的max_pool()avg_pool()函数是池化操作,它们从第一步输入簇。

如果反复使用,聚类和池化的组合允许我们创建一个图层次结构,每个图都比上一个简单,如图 7.4 所示。

figure

图 7.4 图粗化过程:原始图(左侧)通过粗化逐步简化。第一阶段(中间)合并附*的节点以创建粗化图,而第二阶段(右侧)进一步降低图的复杂性,突出显示高效处理的基本结构。

如果用于监督或半监督学*,必须为新节点集生成标签。此生成必须仔细处理,以尽可能保留新标签与原始标签的相似性。实现此目的的简单方法包括使用新的分配标签的中心性统计量,例如聚类中标签的众数或平均值。

在列表 7.4 中,通过使用 Graclus 算法实现图粗化,该算法递归地对图中的节点应用聚类过程,将它们分组为大小大致相等的聚类。然后将这些聚类合并到一个新的图中,该图比原始图更粗。这是一种在图边索引上操作的层次聚类。函数graclus(edge_index)根据图的结构将图中的节点聚在一起。结果cluster张量将每个节点映射到它所属的聚类。

然后将max_pool函数应用于这些聚类数据。此操作本质上粗化了图,根据 Graclus 形成的聚类减少了节点数量。每个聚类中最有影响力的节点(基于某些标准,例如边权重)成为粗化图中该聚类的代表。

列表 7.4 使用graclusMax_Pool进行图粗化
import torch
from torch_geometric.data import Data
from torch_geometric.nn import graclus, max_pool
from torch_geometric.utils import to_undirected
from torch_geometric.datasets import KarateClub

dataset = KarateClub()
data = dataset[0]  # Get the first graph

edge_index = to_undirected(data.edge_index)  #1

batch = torch.zeros(data.num_nodes, dtype=torch.long)   #2

cluster = graclus(edge_index)   #3

data_coarse = max_pool(cluster, data)   #4

1 将图转换为无向图以供 graclus 函数使用

2 为 max_pool 创建批向量

3 应用 Graclus 聚类

4 设置早期停止标准

此代码对图数据进行两项主要操作,这改变了其结构和属性。结果是原始图的粗化版本。由于最大池化操作,节点数量从 34 减少到 22。同时,由于图变得更加紧凑,边数也从 156 减少到 98。这总结在表 7.4 中。

表 7.4 列表 7.4 中的输入和输出图
输入 输出
Data(x=[34, 34], edge_index=[2, 156], y=[34], train_mask=[34]) DataBatch(x=[22, 34], edge_index=[2, 98])
节点:34 边:156 节点:22 边:98

此表概述了列表 7.4 中描述的输入和输出图的结构和特征。输入图表示为数据,具有 34 个节点,每个节点有 34 个特征,如x=[34, 34]所示。它包含 156 条边,由边索引张量edge_index=[2, 156]描述。此外,输入图还包括一个标签张量y=[34],表示每个节点一个标签,以及一个训练掩码train_mask=[34],指定哪些节点是训练集的一部分。

输出图,经过处理并以DataBatch表示,显示出大小的减少。现在它包含 22 个节点,每个节点保留原始的 34 个特征(x=[22, 34])。边的数量也减少到 98,如edge_index=[2, 98]所示。这种转换展示了典型的图简化过程,简化了图以适应下游任务。

7.10.1 示例

GeoGrid 面临一项艰巨的任务:分析美国道路系统的庞大图,以实现其雄心勃勃的交通管理解决方案。初始数据集包含 50,000 个节点和 200,000 条边,计算成本令人畏惧。在初始探索中,当 GeoGrid 考虑计算负载时,图粗化似乎是一个诱人的策略。但担忧很高。初始担忧包括标签保留和方法偏差周围的复杂性导致的损失关键信息和引入误差。

GeoGrid 决定谨慎地进行试验运行,使用 Graclus 算法和max_pool在整个图上进行池化。试验运行证实了公司的担忧。图的大小显著减少,但代价是在高流量区域失去了细节。为聚类节点生成的新标签没有反映原始的最佳状态,影响了机器学*模型的表现。

由于试验结果不尽如人意,GeoGrid 探索了其他优化方案。GeoGrid 的突破性想法是一个多层分析框架,如下所示:

  • 国家级别 — 一个广泛的、高级别的层,其中每个节点代表一个州或主要地区

  • 状态级别 — 代表城市或县的中间层

  • 城市级别 — 最细粒度的层,专注于单个交叉口和路段

团队推测,在中间层应用图粗化可能有助于缓解一些初始担忧。状态级别成为公司粗化的目标,这保证了计算效率和数据完整性的平衡。考虑到这种新的方法,GeoGrid 重新评估了图粗化的不利因素:

  • 粒度信息丢失 — 虽然仍然是一个担忧,但由于粗化是在中间层进行的,因此损害似乎已经最小化,因为保留了城市级别的细节。

  • 引入误差 — GeoGrid 理论认为,其他层可以作为在状态级别引入的任何误差的补偿机制。

  • 标签保留 — 在状态级别进行粗化似乎在标签协调方面风险较低,因为它们可以参考国家和城市级别进行修正。

他们继续使用相同的 Graclus 算法和max_pool技术对状态级别进行粗化。随后的评估发现,对于这个特定层,粒度损失是可以接受的,并且引入的任何误差主要被城市和国家级别所平衡。

尽管公司最初回避了图粗化,但 GeoGrid 找到了一种有意义地将它纳入更复杂、多层系统的方法。这种妥协使得 GeoGrid 能够在不严重损害模型准确性的情况下节省计算资源。然而,他们仍然保持谨慎,并致力于持续研究以全面理解所涉及的权衡。

表 7.5 总结了图粗化的权衡。图粗化在计算效率和数据保真度之间提供了一个平衡。从积极的一面来看,它使得实时处理更快,简化了高级分析,并提供了可扩展性。其灵活性允许选择性地应用于分层图的特定层,正如 GeoGrid 仅将其状态层应用于粗化时所展示的那样。

表 7.5 使用图粗化的权衡,以及 GeoGrid 案例的见解
类别 洞察 GeoGrid 的应用场景
计算效率 对于有限的计算资源来说,是实时处理的理想选择 在状态层实现了更快的分析,减少了计算负担
简化分析 适用于高级概述以获得初步理解或宏观层面的决策 国家层面的层提供了广泛的视角,为较低层级的更详细分析提供了基础
可扩展性 允许处理可能因计算不可行而无法处理的更大图 如果需要,多层方法可以进一步扩展以包括额外的分层层
灵活性 可以应用于图的选择层或段,而不是整个图 仅将粗化应用于状态层,在减轻一些不利因素的同时,仍然获得了计算上的好处
信息粒度损失 不适用于需要精确、详细数据的任务 由于交叉层面的关键细节丢失,最初回避了粗化
不准确性的可能性 需要来自更详细层或额外数据的验证来减轻不准确 市级和国家级作为对粗化状态层的检查
标签保留挑战 需要额外的步骤来生成或映射新标签,这可能会引入错误 发现当粗化应用于中间层时,更容易协调标签
方法偏差 选择粗化算法可能会影响结果并引入偏差 被确定为持续研究的一个领域,以更好地理解其影响

随着本节的结束,很明显,对于使用 GNN 的个人来说,能够扩展到庞大的数据集是至关重要的。处理大规模数据问题需要谨慎的策略,本节已经提供了一系列详细的方法来克服这些障碍。从选择理想的处理器到决定使用稀疏表示还是密集表示,从批量处理策略到分布式计算——扩展优化的选项众多。

随着你继续前进,我们仓库中提供的代码可以用作有用的基准,确保这里提到的不仅仅是高级想法,而是可执行的计划。

在 GNN 的广阔领域中导航需要战略远见和实际操作的结合。无论您数据的大小或复杂性如何,关键在于规划、优化和迭代。让我们提供的见解成为您的指南针,引导您自信地通过各种规模挑战。

摘要

  • 当在非常大的数据集上进行训练时,时间和尺度优化方法是至关重要的。我们可以通过原始的顶点数和边数、边的特征和节点的特征大小,或者处理和训练我们数据集所使用的算法的时间和空间复杂度来描述一个大图。

  • 存在一些著名的技巧可以管理规模问题,这些技巧可以单独使用或结合使用:

    • 您选择的处理器及其配置

    • 使用数据集的稀疏表示与密集表示

    • 您选择的 GNN 算法

    • 根据从您的数据中采样进行批量训练

    • 使用并行或分布式计算

    • 使用远程后端

    • 粗化您的图

  • 对训练中图数据的表示进行选择性选择可能会影响性能。PyTorch Geometric (PyG) 提供了对稀疏和密集表示的支持。

  • 训练算法的选择可能会影响训练的时间性能和内存的空间需求。使用大 O 符号和基准测试关键指标可以帮助您选择最佳的 GNN 架构。

  • 节点或图批量可以通过在训练中使用数据的一部分而不是整个数据集来提高时间和空间复杂度。

  • 并行化,将训练工作分配到一台机器上的多个处理器节点或机器集群中,可以提高执行速度,但需要设置和配置额外设备的开销。

  • 远程后端在训练期间直接从您的外部数据源(图数据库和键/值存储)拉取到小批量。这可以减轻内存问题,但需要额外的工作来设置和配置。

  • 图粗化可以通过用自身的一个更小版本替换图来减少内存需求。这个更小的版本是通过合并节点创建的。这种方法的一个缺点是粗化后的图将与原始图的表示有所偏差。图粗化是在计算效率和数据保真度之间的权衡。当谨慎应用并作为更大、分层的分析策略的一部分时,它最为有效。应用于中间层可以缓解一些缺点。

第八章:GNN 项目的考虑因素

本章涵盖

  • 从非图数据创建图数据模型

  • 从原始数据源提取、转换、加载和预处理

  • 使用 PyTorch Geometric 创建数据集和数据加载器

在本章中,我们将描述与图数据一起工作的实际方面,以及如何将非图数据转换为图格式。我们将解释将数据从原始状态转换为预处理格式所涉及的一些考虑因素。这包括将表格或其他非图数据转换为图,并对其进行预处理,以便用于基于图的机器学*包。在我们的思维模型中,如图 8.1 所示,我们处于图的一半左侧。

figure

图 8.1 图训练过程的心理模型。我们处于过程的开始阶段,其中我们为训练准备数据。

我们将按以下步骤进行。在第 8.1 节中,我们将介绍一个可能需要图神经网络(GNN)的示例问题以及如何处理这个项目。第 8.2 节将更详细地介绍如何在图模型中使用非图数据。然后,在第 8.3 节中,我们将通过将数据集从原始文件转换为预处理数据,为训练做好准备,将这些想法付诸实践。最后,在第 8.4 节中,我们将给出寻找更多图数据集的建议。

在本章中,我们将考虑如何将 GNN 应用于由招聘公司创建的社会图。在我们的例子中,节点是求职候选人,边代表求职候选人之间的关系。我们生成图数据,以边列表和邻接列表的形式,然后使用这些数据在图处理框架(NetworkX)和 GNN 库(PyTorch Geometric [PyG])中。这些数据中的节点包括候选人的ID工作类型(会计、工程师等)和行业(银行、零售、科技等)。

我们将候选人的目标视为一个基于图的挑战,详细说明将他们的数据转换为图学*步骤。我们的目标是绘制数据工作流程图,从原始数据开始,将其转换为图格式,然后为本书中使用的 GNN 训练做准备。

备注:本章的代码以笔记本形式可在 GitHub 仓库中找到(mng.bz/Xxn1)。本章的 Colab 链接和数据可以在相同的位置访问。

8.1 数据准备和项目规划

考虑一个假设的招聘公司 Whole Staffing 的案例。Whole Staffing 为多个行业搜寻员工,并维护一个包含候选人档案的数据库,其中包括他们与公司的互动历史和其他候选人的信息。一些候选人通过其他候选人的推荐被介绍到公司。

8.1.1 项目定义

Whole Staffing 希望从其数据库中获得最大价值。他们对收集到的求职候选人有以下一些初步问题:

  1. 一些档案缺少数据。在不打扰候选人的情况下,是否有可能填补这些缺失的数据?

  2. 历史表明,过去在类似项目上工作过的候选人可以在未来的工作中很好地合作。是否有可能找出哪些候选人可以很好地合作?

Whole Staffing 委派你探索数据以回答这些问题。在众多分析和机器学*方法中,你认为有可能将数据表示为图,并使用 GNN 来回答客户的问题。

你的想法是将推荐集合转化为一个社交网络,其中求职者是节点,候选人之间的推荐是边。为了简化问题,你可以忽略推荐的方向,以便图可以是无向的。你还可以忽略重复的推荐,以便候选人之间的关系保持无权重。

我们将逐步介绍准备数据和建立数据管道以将数据传递到 GNN 模型的步骤。首先,让我们考虑项目规划阶段。

8.1.2 项目目标和范围

对于任何问题,拥有明确的目标、需求和范围将作为指南,引导所有后续行动和决策。从规划和模式创建到工具选择,每个方面都应遵循核心目标和范围。让我们考虑我们问题的每个方面。

项目目标

Whole Staffing 希望优化其候选人数据库的使用。首先,项目应通过填补候选人档案中的缺失信息来提高数据质量,减少对直接候选人参与的需求。其次,接下来的工作应便于提出有根据的候选人建议,预测哪些团队将利用候选人的历史成功表现而合作得很好。

项目需求和范围

几个关键需求将直接影响你的项目。让我们快速浏览几个,并指出它们对我们客户行业的意义。然后,我们将对当前项目得出一些结论。需求包括以下内容:

  • 数据大小和速度 — 数据的大小从项目数量、字节大小还是节点数量来衡量是多少?如果有的话,新信息添加到数据中的速度有多快?数据是否预期从实时流或每日更新的数据湖上传?

计划的图可能会随着数据的增加而增长,影响所需的计算资源和算法的效率。准确评估数据大小和速度可以确保系统可以处理预期的负载,可以提供实时洞察,并且可以扩展以适应未来的增长。

  • 推理速度 — 应用程序和底层机器学*模型需要有多快?有些应用可能需要亚秒级响应,而其他应用则没有时间限制。

响应时间在提供及时的建议和洞察方面尤为重要。对于招聘公司来说,将候选人与合适的职位空缺匹配是时间敏感的,机会很快就会消失。

  • 数据隐私 — 关于个人身份信息(PII)的政策和法规是什么,这会如何涉及数据转换和预处理?

当处理敏感信息,如候选人档案、联系详情和就业历史时,数据隐私成为一个巨大的关注点。在图和 GNN 设置中,确保节点和边不泄露 PII 是至关重要的。遵守如通用数据保护条例(GDPR)或加利福尼亚消费者隐私法案(CCPA)等法规是强制性的,以避免法律纠纷。图数据应以尊重隐私规范的方式处理、存储和处理。可能需要匿名化和加密技术来保护个人隐私,同时仍然允许有效的数据分析。在项目规划早期理解这些要求,确保系统架构和数据处理管道的设计考虑到隐私保护。

  • 可解释性 — 响应应该有多高的可解释性?直接答案是否足够,或者是否应该有额外的数据来阐明为什么做出了推荐或预测?

在招聘领域,可解释性和透明度至关重要。它们通过确保人才选拔过程中的公平性和清晰性,在候选人和雇主之间建立信任。维护道德标准,并应减轻无意中的偏见。这些要素不仅是道德上的要求,而且往往是法律上的约束。

根据目标和范围,对于 Whole Staffing,可交付成果可能是一个执行以下操作的系统:

  1. 每两周扫描一次候选人数据,查找缺失项。缺失项可以被推断、建议或填充。

  2. 通过使用链接预测和/或节点分类来预测将很好地一起工作的候选人。与第一个可交付成果不同,这里的响应时间应该很快。

以下列出了一些先前要求的具体规格:

  • 数据大小 — 这被保守地设定为足够容纳 10 万个候选人和他们的属性,估计为 1 GB 的数据。

  • 推理速度 — 应用程序将每两周运行一次,可以在一夜间完成,因此我们没有明显的速度限制。

  • 数据隐私 — 不能使用直接识别候选人的个人信息。然而,招聘公司已知的数据,例如员工是否在相同的雇主处成功安置,可以用来改善公司的运营,前提是这些数据不共享。

  • 可解释性 — 结果必须有一定的可解释性。

目标和要求将指导系统设计、数据模型以及通常 GNN 架构的决策。上述内容给出了在开始或界定基于图的项目时所需考虑的类型示例。

8.2 设计图模型

在给定适当的工作范围后,下一步是构建图模型。对于大多数机器学*问题,数据将以标准方式组织。例如,当处理表格数据时,行被视为观察值,列被视为特征。我们可以通过使用索引和键来连接此类数据表。此框架灵活且相对明确。我们可能会对包含哪些观察值和特征有所争议,但我们知道它们的位置在哪里。

当我们想要用图来表示我们的数据时,在除最简单场景之外的所有情况下,我们都会有几个选择来决定使用哪种结构。使用图时,并不总是直观地知道将感兴趣的实体放在哪里。正是这种不确定性推动了在图数据中使用系统方法的必要性,但尽早做对可以成为下游机器学*任务的基础[1]。

在本节中,我们开始将 Whole Staffing 的招聘数据转换为基于图的数据,以支持我们的下游流程。我们首先考虑领域和用例,这是理解数据的关键步骤。接下来,我们创建和细化一个模式,这对于组织和解释复杂数据集至关重要。通过严格测试模式,我们可以确保其健壮性和可靠性。任何必要的改进都应进行以优化性能和准确性。这种方法确保我们的未来分析系统,即摄入基于图的数据,可以精确可靠地回答关于求职者的复杂查询。以下是遵循的过程,图 8.2 提供了视觉说明:

  1. 理解数据和用例。

  2. 创建数据模型、模式和实例模型。

  3. 使用模式和实例模型测试您的模型。

  4. 如有必要,进行重构。

图

图 8.2 从非图数据创建健壮的图数据模型的过程

8.2.1 熟悉领域和用例

与大多数数据项目一样,为了有效,我们必须掌握数据集和上下文。对于我们创建模型的直接目标,了解原始格式的推荐数据并深入了解招聘行业的复杂性可以提供关键见解。这些知识也为我们提供了在部署期间为模型设计测试的基础。例如,对原始数据的初步分析为我们提供了表 8.1 中的信息。

表 8.1 数据集特征
候选人数 1,933
推荐人数 12,239

从原始数据中可以看出,存在许多关系,这为候选人的推荐提供了潜在的见解。与候选人数量相比,大量的推荐表明了一个相互关联的网络。我们的模型需要足够大,才能将这种结构转化为招聘问题空间内的结果。

转向领域知识,除了客户的直接需求之外,我们还应该提出巩固我们对行业理解的问题。在设定我们的数据模型的要求时,我们应该考虑行业的关键问题和挑战。对于招聘问题,我们可能会问如何优化推荐流程或是什么底层结构和模式支配着候选人的推荐。通过解决这类问题,我们可以使我们的模型与领域专业知识相一致,这可能会提高其相关性和有效性。

8.2.2 构建图数据集和模式

接下来,我们将讨论如何设计我们的数据库。术语图数据集表示使用图的元素和结构(节点、边、节点特征和边特征)描述数据的一般努力。为了实现这一点,我们需要一个模式和一个实例。这些明确指定了我们的图的结构和规则,并允许我们的图数据集被测试和改进。本节内容来源于几篇参考文献,列在书末供进一步阅读。

通过提前处理我们的图数据集的细节,我们可以避免技术债务,并更容易地测试我们数据的一致性。我们还可以更系统地实验不同的数据结构。此外,当我们明确设计我们的图的结构和规则时,它增加了我们参数化这些规则并在我们的 GNN 管道中实验它们的便利性。

图数据集可以是简单的,只包含一种类型的节点和一种类型的边。或者它们可以是复杂的,涉及许多类型的节点和边、元数据,在知识图中还包括本体论。

关键术语

以下是在本节中使用的术语(有关图数据模型和图类型的更多详细信息,请参阅附录 A):

  • 二部图(或二分图)—具有两组节点的图。同一组内的节点之间没有边。

  • 实体-关系图(ER 图)—显示图中的实体、关系和约束的图形。

  • 图数据集—节点、边及其关系的表示。

  • 异构/同构图—同构图只有一种类型的节点或边。异构图可以有多种不同类型的节点或边。

  • 实例模型—基于模式且包含实际数据子集的模型。

  • 本体论—描述特定知识领域中的概念和关系的一种方式,例如,在语义网(文学作品网)中不同实体(作家)之间的联系。本体论是定义这些作家及其文学作品的角色、属性和相互关系的结构化框架。

  • 属性图—使用元数据(标签、标识符、属性/属性)来定义图元素的模型。

  • 资源描述框架图(RDF 图,又称三元组存储)—遵循主语-谓语-宾语模式的模型,其中节点是主语和宾语,边是谓语。

  • 模式—一个蓝图,定义了图元素的组织方式以及将用于这些元素的具体规则和约束。

  • 概念模式—一种与任何特定数据库或处理系统无关的模式。

  • 系统模式—针对特定图数据库或处理系统设计的模式。

  • 技术债务—优先考虑快速交付而非高质量代码的后果,这后来需要重构。

图数据集擅长提供快速且易于他人理解的图的概念描述。例如,对于理解属性图或 RDF 图的人来说,告诉他们一个图是在属性图上实现的二分图,可以揭示你数据设计(属性图和 RDF 图在附录 A 中解释)的很多信息。

模式是一个蓝图,定义了数据在数据存储系统(如数据库)中的组织方式。图模式是图数据集的具体实现,详细说明了在特定用例中数据如何在真实系统中表示。模式可以由图表和书面文档组成。模式可以使用查询语言在图数据库中实现,也可以使用编程语言在处理系统中实现。模式应该回答以下问题:

  • 元素(节点、边、属性)是什么?它们代表了哪些现实世界的实体和关系?

  • 图是否包含多种类型的节点和边?

  • 关于节点表示的约束是什么?

  • 关系的约束是什么?某些节点是否有关于相邻和发生的限制?某些关系有计数限制吗?

  • 如何处理描述符和元数据?这些数据有哪些约束?

根据您数据和使用系统的复杂性,您可能需要使用多个但一致的方案。一个概念模式概述了图元素、规则和约束,但并不绑定到任何系统。一个系统模式反映了概念模式的规则,但仅针对特定系统,例如选择的数据库。系统模式还可以从概念模式中省略不必要的元素。以下是创建模式的方法:

  1. 识别主要实体和关系。例如,在我们的社交网络示例中,实体可以是候选人、招聘人员、推荐人、招聘活动,以及关系。

  2. 定义节点和边标签。这些标签作为图中实体类型及其相互关系的标识符。

  3. 指定属性和约束。每个顶点和边标签都与特定的属性和约束相关联,分别用于存储和限制信息。

  4. 定义索引(可选,针对面向数据库的模式)。基于属性或其组合的索引可以增强图数据的查询速度。

  5. 将图模式应用于数据库(可选,针对面向数据库的模式)。根据特定的图数据库,使用命令或代码来创建图模式,并指定其静态或动态性质。

根据图数据集的复杂性和用例,可能需要调用一个或多个模式。在存在多个模式的情况下,通过映射实现模式之间的兼容性也是必需的。

对于具有少量元素的数据库,一个简单的带有注释的图表就足以传达足够的信息,以便其他开发者能够用查询语言或代码实现。对于更复杂的网络设计,ER 图和相关语法有助于以视觉和可读的方式说明网络模式。

实体-关系图(ER 图)

ER 图具有说明图节点、边和属性以及控制图的规则和约束的元素 [2, 3]。以下图(左)显示了可以用来说明边和关系约束的一些连接器符号。图(右)显示了一个模式图示例,传达了在我们的招聘示例(招聘人员和候选人)中可能表示的两个节点类型,以及两种边类型(了解和招聘/被招聘)。该图传达了隐含和显式约束。

sidebar figure

在左侧是 ER 图的关系命名法。在右侧是一个使用 ER 图的概念模式示例。

一些显式约束包括一个员工可以推荐许多其他员工,以及一个推荐人可以被许多员工推荐。另一个显式约束是,一个人只能被一家企业全职雇佣,但一家企业可能有多个员工。一个隐式约束是,对于这个图模型,企业和推荐之间不能存在关系。

转到我们的示例,为了为我们的示例数据集设计概念和系统模式,我们应该考虑以下因素:

  • 我们数据中的实体和关系

  • 可能的规则和约束

  • 操作约束,例如我们可用的数据库和库

  • 我们希望从应用程序中获得的结果

我们的数据将包括候选人及其个人资料数据(例如,行业、工作类型、公司等),以及招聘人员。属性也可以被视为实体;例如,医疗行业可以被视为一个节点。关系可以是候选人了解候选人、候选人推荐候选人,或者招聘人员招聘候选人。如前所述,图数据在实体表示方面可以非常灵活。

在这些选择的基础上,我们展示了几个概念模式选项。选项 A 在图 8.3 中展示。

figure

图 8.3 一个节点类型和一个边类型的模式

如您所见,示例 A 由一个节点类型(候选人)通过一个无向边类型(了解)连接。节点属性是候选人的行业和他们的工作类型。关系没有限制,因为任何候选人都可以了解 0 到n-1 个其他候选人,其中n是候选人的数量。第二个概念模式如图 8.4 所示。

图

图 8.4 两个节点类型和一个边类型的模式

示例 B 由两个节点类型(候选人、招聘人员)通过一个无向边类型(了解)连接。候选人之间的关系没有限制。候选人与招聘人员之间的关系有一个约束:候选人只能链接到一个招聘人员,而招聘人员可以链接到多个候选人。

第三个模式如图 8.5 所示。它有多个节点和关系类型。在示例 C 中,类型包括候选人、招聘人员和行业。关系类型包括候选人了解候选人、招聘人员招聘候选人、候选人属于行业。请注意,我们将行业作为一个单独的实体,而不是候选人的属性。这类图被称为异构图,因为它们包含许多不同类型的节点和边。从某种意义上说,我们可以想象这些图是叠加在一起的多个图。当我们只有一种类型的节点和边时,这些图被称为同构图。示例 C 的一些约束包括以下内容:

  • 候选人只能有一个招聘人员和一个行业。

  • 招聘人员不链接到行业。

图

图 8.5 三个节点类型和三个边类型的模式

根据查询和机器学*模型的目标,我们可以在开发应用程序的过程中选择一个模式或尝试所有三个模式。让我们坚持第一个模式,它可以作为我们探索和实验的简单结构。

8.2.3 创建实例模型

实例模型通过根据模式提供可触摸的、具体的示例数据,与图数据集的抽象性质形成对比。这样的示例用于验证和测试模式。以下是为创建实例模型所遵循的步骤:

  1. 识别模式。首先,确定你的实例将基于的一般模型或模式。确保类定义、属性和方法都已确立。

  2. 选择数据子集。选择一个特定的数据子集来表示,并遵循已建立的图形模式。

  3. 创建节点。为数据子集中的每个实体开发节点,确保每个节点都有一个标签、唯一的标识符和相关的属性。

  4. 创建边。为每个关系开发链接,分配标签和属性,并指定边方向和多重性。

  5. 遵守您模式中的规则和约束。在构建实例模型时,请确保遵循模式的规则和约束。

  6. 可视化。使用可视化工具以图形方式表示实例模型。

  7. 实例化。使用图数据库或图处理系统实现实例模型。这将允许进行查询以测试和验证它。

图 8.6 显示了从前讨论的方案中派生出的实例模型的示例。节点和边填充了候选人的实际数据,而不是占位符。

图

图 8.6 示例:一个节点填充了招聘示例中实际数据的实例模型。实际的实例模型可能包含更多的数据。

8.2.4 测试和重构

技术债务可能发生在我们必须更改和演进我们的数据或代码,但我们尚未在我们的模型中计划向后或向前兼容性时。它也可能发生在我们的建模选择不适合我们的数据库和软件选择时,这可能导致昂贵的(在时间或金钱上)的解决方案或替代方案。

在我们的数据和模型上定义明确的规则和约束为我们提供了测试管道的明确方法。例如,如果我们知道我们的节点最多只能有两个度数,我们可以设计简单的函数或查询来处理和测试每个节点是否符合这一标准。

测试和重构是迭代过程,对于扩展优化的图模式和实例模型至关重要[4, 5]。它将涉及执行查询、分析结果、进行必要的调整,并对照指标进行验证。在 Whole Staffing 的招聘数据背景下,这项实践将确保模型能够捕捉现实世界的关系和稳健的新数据流。以下是测试和重构的一些示例:

  1. 将实例模型映射到系统中。将模型存储在您选择的图数据库或处理系统中。

  2. 创建测试和运行查询。根据具体要求,起草查询以测试您模型的完整性。使用 Cypher 或 SPARQL 等查询语言在图数据库上执行查询。例如,Python 等编程语言也可以用于查询图处理系统(如 NetworkX)内的图。

对于我们示例的简单模式,以下是一些可能的测试:

    • 节点属性验证 —应检查每个节点,以确认它具有所需的属性,特别是候选人的行业和职位类型,以及这些属性具有非空值。

    • 边类型验证 —应验证所有候选人间的关系,以确认它们是 Knows 类型,确保关系标签的一致性。

    • 关系验证 —检查关系的平均数量,以确保它与平均推荐数量的平均数量一致。

    • 唯一标识符 — 应检查每个候选节点是否有唯一标识符,以防止数据重复并确保数据完整性。

    • 属性数据类型 — 应验证industryjobType属性的数据类型,以确保所有候选节点的一致性。

    • 网络结构 — 应验证网络结构以确保其为无向的,确认候选节点之间 Knows 关系的双向性质。

    • 边缘情况 — 确定边缘情况并查询这些情况。在我们的案例中,未连接的节点可能存在问题。使用查询来了解未连接节点的范围及其对分析的影响,将推动重构决策。另一个边缘情况可能是一组孤立候选人,他们的关系形成一个循环。确保数据模型和分析工具能够处理这种复杂或异常的数据模式,并仍然产生有效答案,这将是重要的。

    1. 验证和评估性能 — 根据测试结果,确定您的模型和用例是否存在逻辑问题,或者数据属性存在问题。
    1. 重构 — 根据需要调整标签、属性、关系或约束,以最大限度地减少错误。
    1. 重复 — 重复前面的步骤,根据评估结果精炼模型,并确保与项目需求和约束一致。
    1. 最终评估 — 评估最终模型是否符合标准和最佳实践,以确保其适用于复杂查询和机器学*应用。

通过这种测试和重构的迭代过程,我们精炼 Whole Staffing 招聘数据和使用案例的数据集。对细节的关注确保模型准备好支持对招聘数据中隐藏的复杂、细微关系的评估。

当我们过渡到下一节时,我们的重点转向这些概念的实际实施。我们将探讨在 PyG 中创建数据管道,展示如何将数据从其初始原始形式转换为预处理状态,以便输入到其他下游模型训练和测试程序。

8.3 数据管道示例

在确定模式后,让我们通过一个数据管道的示例。在本节中,我们假设我们的目标是创建一个简单的数据工作流程,它从原始状态的数据开始,并以可以传递给 GNN 的预处理数据集结束。这些步骤在图 8.7 中进行了总结。

注意,虽然显示的总体步骤可以从一个问题到另一个问题保持一致,但每个步骤的实现细节可能因问题、其数据以及选择的数据存储、处理和模型训练选项而独特。

figure

图 8.7 本节中数据管道处理步骤的总结
关键术语

以下是在本节中使用的关键术语(有关图数据模型和图类型的更多详细信息,请参阅附录 A):

  • 邻接表—图数据的基本表示。在这种格式中,每个条目包含一个节点及其相邻节点的列表。

  • 邻接矩阵—图数据的基本表示。在一个矩阵中,每一行和每一列对应一个节点。这些行和列交叉的单元格表示节点之间存在边。非零值的单元格表示节点之间存在边,而零值表示没有连接。

  • —节点的度是其相邻节点的数量。

  • 边列表—图的基本表示。它是一个包含图中所有边的数组;数组中的每个条目包含一对连接的节点。

  • 掩码—一个布尔数组(或 PyTorch 中的张量),用于选择特定的数据子集。掩码通常用于将数据集分割成不同的部分,如训练集、验证集和测试集。

  • 排名—在我们的上下文中,排名指的是每个节点度在排序列表中的位置。因此,度最高的节点排名为 1,下一个最高的排名为 2,依此类推。

  • 原始数据—最未加工的数据形式。

  • 序列化—将数据放入易于存储或导出的格式。

  • 子图—子图是较大图节点和边的子集。

8.3.1 原始数据

原始数据指的是最未加工状态的数据;此类数据是管道的起点。这些数据可以存储在各种数据库中,以某种方式序列化,或生成。

在应用程序的开发阶段,了解所使用的原始数据与生产中使用的实时数据之间的匹配程度非常重要。一种方法是通过对数据存档进行抽样。

如第 8.1 节所述,我们的示例问题至少有两个来源:包含推荐日志和候选者资料的关联数据库表。为了使我们的示例保持简洁,我们假设一位有助的工程师已经查询了日志数据并将其转换为 JSON 格式,其中键是推荐候选者,值是推荐的候选者。从我们的资料数据中,我们还有两个其他字段:行业工作类型。对于这两个数据源,我们的工程师已经使用散列来保护 PII,我们可以将其视为候选者的唯一标识符。在本节中,我们将使用 JSON 数据,其中示例片段如图 8.8 所示。数据以两种方式显示:带有散列和不带散列。

数据编码和序列化

在构建管道时,一个关键的考虑因素是选择在从一个系统导入和导出数据到另一个系统时使用的数据格式。为了将图数据传输到另一个系统或通过互联网发送,通常使用编码序列化。这些术语指的是将数据放入易于传输的形式[6, 7]。在选择编码格式之前,你必须决定以下内容:

  • 数据模型—简单模型、属性图或其他?

  • 模式 — 你的数据中的哪些实体是节点、边和属性?

  • 数据结构 — 数据是如何存储的:在邻接矩阵、邻接列表或边列表中?

  • 接收系统 — 接收系统(在我们的案例中,是 GNN 库和图处理系统)如何接受数据?首选的编码和数据结构是什么?导入的数据是否自动识别,或者是否需要自定义编程来读取数据?

图像

图 8.8 原始数据视图:JSON 文件。左边的图是键/值格式。键是成员,值是它们已知的关联。右边的图显示了未散列的值,展示了这些个人的示例名称。

这里有一些你可能会遇到的编码选择:

  • 语言和系统无关的编码格式 — 这些格式因其极端灵活性和跨多个系统和语言的适用性而最受欢迎。然而,数据排列可能仍然因系统而异。因此,CSV 文件中的边列表,带有特定的标题集,可能在不同的系统之间不被接受或以相同的方式解释。以下是一些该格式的示例:

    • JSON — 在从 API 读取或输入到 JavaScript 应用程序时具有优势。图可视化库 Cytoscape.js 接受 JSON 格式的数据。

    • CSV — 被许多处理系统和数据库接受。然而,数据排列和标签的要求因系统而异。

    • XML — 图交换 XML (GEXF) 格式当然是一种 XML 格式。

  • 语言特定 — Python、Java 和其他语言有内置的编码格式。

  • Pickle — Python 的格式。一些系统接受 Pickle 编码的文件。尽管如此,除非你的数据管道或工作流程广泛受 Python 管理,否则应谨慎使用 Pickle。其他语言特定的编码也适用同样的原则。

  • 系统驱动 — 特定软件、系统和库有自己的编码格式。尽管这些格式在系统间的可用性可能有限,但一个优点是这些格式的模式是一致的。拥有自己编码格式的软件和系统包括斯坦福网络分析平台 (SNAP)、NetworkX 和 Gephi。

  • 大数据 — 除了之前列出的语言无关格式外,还有其他用于更大数据量的编码格式。

  • Avro — 这种编码在 Hadoop 工作流程中被广泛使用

  • 基于矩阵 — 因为图可以用矩阵表示,所以有一些基于这种数据结构的格式。对于稀疏图,以下格式提供了大量的内存节省和计算优势(对于查找和矩阵/向量乘法):

    • 稀疏列矩阵 (.csc 文件类型)

    • 稀疏行矩阵 (.csr 文件类型)

    • 矩阵市场格式 (.mtx 文件类型)

8.3.2 ETL 步骤

在选择模式和建立数据源后,ETL提取、转换、加载)步骤包括从其来源提取原始数据,然后生成符合模式且准备进行预处理或训练的数据。对于我们的数据,这包括编写一系列操作,从各种数据库中提取数据,然后根据需要将它们连接起来。

我们需要最终以特定格式结束的数据,我们可以将其输入到预处理步骤中。这可以是 JSON 格式或边列表。对于 JSON 示例或边列表示例,我们的模式得到满足;我们将有节点(个人)和边(这些人之间的关系)。

对于我们的招聘示例,我们希望将我们的原始数据转换为编码在 CSV 中的图数据结构。这是为了方便用 Python 进行操作。然后,我们可以将此文件加载到我们的图处理系统 NetworkX 或 PyG 等 GNN 包中。为了总结下一步,我们将执行以下操作:

  1. 按照您选择的图数据模型将原始数据文件转换为图格式。在我们的情况下,我们将原始数据转换为边列表和邻接表。然后我们将其保存为 CSV 文件。

  2. 将 CSV 文件加载到 NetworkX 中进行探索性数据分析(EDA)和可视化。

  3. 加载到 PyG 并进行预处理。

将原始数据转换为邻接表和边列表

从我们的 CSV 和 JSON 文件开始,我们接下来将数据转换为两个关键数据模型:边列表和邻接列表,我们在附录 A 中定义了它们。邻接表和边列表都是与图一起使用的两种基本数据表示。边列表是一个列表,其中该结构中的每个条目都包含一个节点及其相邻节点的列表。这些表示在图 8.9 中说明。

figure

图 8.9 一个带有节点和边标记的图(顶部)。边列表表示(中间);每个条目包含边号和连接的节点对。字典中的邻接列表表示(底部);每个键是一个节点,其值是该节点的相邻节点。

首先,使用json模块,我们将数据从 JSON 文件加载到 Python 字典中。Python 字典的结构与 JSON 相同,成员哈希作为键,其关系作为值。

创建邻接表

接下来,我们从该字典创建一个邻接表。此列表将存储为文本文件。文件的每一行将包含成员哈希,后跟该成员关系的哈希。创建邻接表的过程在图 8.10 中说明。

此函数将我们的原始数据转换为邻接表,我们将将其应用于我们的招聘示例。我们将有以下输入

  • 一个字典,其中包含候选人的推荐,键是推荐其他候选人的成员,值是被推荐的人的列表

  • 要附加到文件名上的后缀

figure

图 8.10 流程图说明了将关系字典转换为结构良好的邻接表的过程,该邻接表存储在文本文件中,同时确保无向图中连接的对称性。

我们将得到以下内容的 输出

  • txt 文件中的编码邻接表

  • 找到的节点 ID 列表

这在下面的列表中显示。

列表 8.1 从关系字典创建邻接表
def create_adjacency_list(data_dict, suffix=''):
   list_of_nodes = []

   for source_node in list(data_dict.keys()):  #1

       if source_node not in list_of_nodes:
           list_of_nodes.append(source_node)

       for y in data_dict[source_node]:               #2
           if y not in list_of_nodes:                 #2
               list_of_nodes.append(y)                #2
           if y not in data_dict.keys():              #2
               data_dict[y]=[source_node]             #2
           Else:                                      #2
               if source_node not in data_dict[y]:    #2
                   data_dict[y].append(source_node)   #2
               else: continue                         #2

   g= open("adjacency_list_{}.txt".format(suffix),"w+")  #3
   for source_node in list(data_dict.keys()):  #4
       dt = ' '.join(data_dict[source_node])   #5
       print("{} {}".format(source_node, dt))  #6
       g.write("{} {} \n".format(source_node, dt))   #7

   g.close
   return list_of_nodes

1 遍历输入数据字典中的每个节点

2 因为这是一个无向图,所以值之间必须存在对称性;也就是说,一个键中的每个值都必须包含该键在其自己的条目中。例如,对于条目 F,如果 G 是一个值,那么对于条目 G,F 必须也是一个值。这些行检查这些条件是否存在,并在这些条件不存在时修复字典。

3 创建一个将存储邻接表的文本文件

4 对于字典中的每个键

5 从字典值的列表中创建一个字符串。这个值是成员 ID 的字符串,由空格分隔。

6 可选打印

7 将一行写入文本文件。这一行将包含成员哈希,然后是一个关系哈希的字符串。

创建边列表

接下来,我们展示创建边列表的过程。与邻接表一样,我们转换数据以考虑节点对对称性。请注意,这两种格式都可以用于此项目。对于您自己的项目,另一种格式也可能是必要的。图 8.11 说明了这个过程。

图

图 8.11 列表 8.2 中编程创建边列表文件的过程

与邻接表函数一样,边列表函数说明了原始数据到边列表的转换,并且具有与上一个函数相同的输入。输出包括以下内容:

  • .txt 文件中的边列表

  • 找到的节点 ID 列表和生成的边列表

根据定义,边列表中的每个条目都必须是唯一的,因此我们必须确保我们生成的边列表是相同的。以下是创建从关系字典到边列表的代码。

列表 8.2 从关系字典创建边列表
def create_edge_list(data_dict, suffix=''):
    edge_list_file = open("edge_list_{}.txt".format(suffix),"w+")
    edges = []    
    nodes_all = []

    for source in list(data_dict.keys()):
        if source not in list_of_nodes_all:
            nodes_all.append(source)
        connections = data_dict[source]

        for destination in connections:   #1
            if destination not in nodes_all:
                nodes_all.append(destination)

           if {source, destination} not in edges:   #2
               print(f"{source} {destination}")
               out_string =  f"{source} {destination}\n”
               edge_list_file.write(out_string)   #3
               edges.append({source, destination })

           else: continue

       edge_list_file.close
       return list_of_edges, list_of_nodes_all

1 每个成员字典的值是一个关系列表。对于每个键,我们遍历每个值。

2 因为这个图是无向的,我们不希望创建重复的边。例如,因为 {F,G} 与 {G,F} 相同,我们只需要其中一个。这一行检查节点对是否已经存在。我们使用集合对象,因为节点顺序不重要。

3 将一行写入文本文件。这一行将包含节点对。

在接下来的几节中,我们将使用邻接表将我们的图加载到 NetworkX 中。关于使用邻接表与边列表加载图之间的区别,有一点需要注意,那就是边列表无法考虑单个未连接的节点。结果发现,Whole Staffing 的许多候选人都没有推荐任何人,也没有与他们相关的边。这些节点在数据边列表表示中将是不可见的。

8.3.3 数据探索和可视化

接下来,我们希望将我们的网络数据加载到图处理框架中。我们选择了 NetworkX,但根据您的任务和语言偏好,还有许多其他选择。我们选择 NetworkX 是因为我们的图很小,我们还想做一些轻量级的 EDA 和可视化。

使用我们新创建的邻接表,我们可以通过调用read_edgelistread_adjlist方法来创建一个 NetworkX 图对象。接下来,我们可以加载属性industryjob type。在这个例子中,这些属性作为字典加载,其中节点 ID 作为键。

在我们的图加载后,我们可以探索和检查我们的数据,以确保它与我们的假设一致。首先,节点和边的数量应该与我们的成员数量相匹配,以及我们在边列表中创建的边的数量,如下所示。

列表 8.3 从关系字典创建边列表
social_graph = nx.read_adjlist('adjacency_list_candidates.txt')
nx.set_node_attributes(social_graph, attribute_dict)
print(social_graph.number_of_nodes(), social_graph.number_of_edges())
>> 1933 12239

我们想检查我们的图有多少个连通分量:

len(list((c for c in nx.connected_components(social_graph))))
>>> 219

connected_components方法生成图的连通分量;可视化如图 8.12 所示,并使用 NetworkX 生成。有数百个分量,但当我们检查这些数据时,我们发现有一个由 1,698 个节点组成的大分量,其余的由不到 4 个节点组成。大多数断开连接的分量是单节点(从未推荐任何人的候选人)。有关图分量的更多信息,我们在附录 A 中给出了定义和细节。

figure

图 8.12 完整的图,其中包含中间的大连通分量,周围环绕着许多较小的分量。在我们的例子中,我们将只使用大连通分量中的节点。

我们对这个大连通分量感兴趣,并将继续使用它。subgraph方法可以帮助我们隔离这个大分量。

最后,我们使用 NetworkX 来可视化我们的图。为此,我们将使用分析图的标准配方,该配方也可以在 NetworkX 文档中找到。

让我们逐一介绍不同的步骤(每个步骤的完整代码示例也存放在仓库中,标记为“可视化社交图并显示度统计的函数”):

  1. 创建图对象。从给定的图中选择最大的连通分量,生成一个独特的图对象。在只有一个连通分量的情况下,这一步可能是不必要的,但确保选择了主要分量。
connected_component = nx.connected_components(social_graph
Gcc = social_graph.subgraph(sorted(connected ), 
                            key=len, 
                            reverse=True)[0]
                            )
    1. 确定布局。决定节点和边的可视化位置。选择合适的布局算法;例如,Spring Layout 将边建模为弹簧,节点为排斥质量:
pos = nx.spring_layout(Gcc, seed=10396953)
    1. 绘制节点和边。使用所选布局在可视化中绘制节点。调整节点大小等视觉参数以增强图形的清晰度。根据所选布局绘制边。通过调整透明度等外观设置以实现所需的视觉效果。
nx.draw_networkx_nodes(Gcc, pos, ax=ax0, node_size=20)
nx.draw_networkx_edges(Gcc, pos, ax=ax0, alpha=0.4)
ax0.set_title("Connected component of Social Graph")
ax0.set_axis_off()
    1. 生成和绘制节点度数。在图对象上使用度数方法创建一个包含各自度数的节点可迭代对象,并按从高到低的顺序排序。在图上可视化排序后的节点度数列表,以分析各种节点的分布和突出度。使用 NumPy 的unique方法与return_counts参数来绘制一个直方图,显示节点的度数及其计数,从而深入了解图的结构和复杂性:
degree_sequence = sorted([d for n, d in social_graph.degree()], reverse=True)

ax1 = fig.add_subplot(axgrid[3:, :2])
ax1.plot(degree_sequence, "b-", marker="o")
ax1.set_title("Degree Rank Plot")
ax1.set_ylabel("Degree")
ax1.set_xlabel("Rank")

ax2 = fig.add_subplot(axgrid[3:, 2:])
ax2.bar(*np.unique(degree_sequence, return_counts=True))
ax2.set_title("Degree histogram")
ax2.set_xlabel("Degree")
ax2.set_ylabel("# of Nodes")

这些图示显示在图 8.13 中。

figure

图 8.13 展示了社交图及其大型连通组件的视觉化和统计数据。使用 NetworkX 默认设置进行网络可视化(顶部)。整个图节点度的排名图(左下角)。我们看到大约四分之三的节点相邻节点少于 20 个。度数的直方图(右下角)。

最后,我们可以使用以下命令可视化我们图的邻接矩阵,如图 8.14 所示:

plt.imshow(nx.to_numpy_matrix(social_graph), aspect='equal',cmap='twilight')

figure

图 8.14 展示了我们的社交图的视觉邻接矩阵。垂直和水平值分别指代相应的节点。

与数值邻接矩阵一样,对于我们的无向图,这个视觉邻接矩阵在对角线处具有对称性。所有无向图都将具有对称的邻接矩阵。对于有向图,这可能发生,但并不保证。

8.3.4 预处理和将数据加载到 PyG 中

对于本书,预处理包括将我们的数据及其属性、标签或其他元数据放入适合下游机器学*模型的格式。特征工程也可以是这个过程的一个步骤。对于特征工程,我们通常会使用图算法来计算节点、边或子图的性质。

节点特征的例子是介数中心性。如果我们的模式允许,我们可以计算并将此类属性附加到数据中节点实体。为此,我们取 ETL 步骤的输出,比如说一个边列表,并将其导入到图处理框架中,以计算每个节点的介数中心性。一旦获得这个量,我们可以使用以节点 ID 为键的字典来存储它,然后稍后将其用作节点特征。

介数中心性

中介中心性 是衡量节点重要性的关键指标,它量化了节点位于源节点到目标节点最短路径中的趋势。给定一个包含 n 个节点的图,你可以确定图中每对唯一节点之间的最短路径。我们可以取这组最短路径,并查找特定节点的存在。如果一个节点出现在所有或大多数这些路径中,它就具有高中介中心性,并且被认为是高度有影响力的。相反,如果一个节点在 shortest paths 集合中只出现几次(或只出现一次),它将具有低中介中心性,并且影响力低。

现在我们有了数据,我们希望使其准备好在我们的所选 GNN 框架中使用。在这本书中,我们使用 PyG,因为它拥有强大的工具套件和灵活处理复杂图数据的能力。然而,大多数标准 GNN 软件包都有导入自定义数据到其框架的机制。对于本节,我们将关注 PyG 中的三个模块:

  • 数据 模块 (torch_geometric.data) — 允许检查、操作和创建 PyG 环境中使用的数据对象。

  • Utils 模块 (torch_geometric.utils) — 许多有用的方法。在本节中,允许快速导入和导出图数据的方法很有帮助。

  • 数据集 模块 (torch_geometric.datasets) — 预加载的数据集,包括基准数据集和该领域有影响力的论文中的数据集。

让我们从 Datasets 模块开始。此模块包含已经预处理的并可以由 PyG 的方法直接使用的数据集。当你开始使用 PyG 时,拥有这些数据集可以让你轻松地进行实验,无需担心创建数据管道。同样,通过研究这些数据集背后的代码库,我们也可以学*如何创建我们自己的自定义数据集。

在上一节的结尾,我们将原始数据转换为标准格式,并将我们的新图加载到图处理框架中。现在,我们希望将数据加载到 PyG 环境中。PyG 中的预处理有几个目标:

  • 从节点和边级别到子图和图级别的多个属性的数据对象创建

  • 将不同的数据源组合成一个对象或一组相关对象

  • 将数据转换为可以使用 GPU 处理的对象

  • 允许分割训练/测试/验证数据

  • 允许数据批处理以进行训练

这些目标通过 Data 模块中的类层次结构来实现:

  • 数据 — 创建图对象。这些对象可以具有可选的内置和自定义属性。

  • Dataset InMemoryDataset — 创建可重复的数据预处理管道。你可以从原始数据文件开始,并添加自定义过滤器转换以实现你的预处理 数据 对象。Dataset 对象比内存大,而 InMemoryDataset 对象适合内存。

  • Dataloader —为模型训练批量数据对象。

这在图 8.15 中显示,包括不同的数据和数据集类如何连接到 dataloader。

图

图 8.15 PyG 中预处理数据的步骤。从原始文件中,有两条基本路径用于为 PyG 算法准备数据。第一条路径,如这里所示,直接创建数据实例的迭代器,该迭代器由 dataloader 使用。第二条路径模仿第一条路径,但在 dataloader 类内部执行此过程。

预处理数据有两种途径,一种使用 dataset 类,另一种则不使用。使用 dataset 类的优势在于它允许我们保存生成的数据集,并保留过滤和转换的细节。数据集对象是灵活的,可以修改以输出数据集的不同变体。另一方面,如果你的自定义数据集很简单或即时生成,并且你不需要保存数据或长期处理,绕过数据集对象可能对你很有帮助。因此,总的来说,我们有以下不同的数据相关类:

  • 数据集 对象 —用于基准测试或测试算法或架构的预处理数据集(不要与 Dataset—结尾没有“s”的对象混淆)。

  • 数据 对象转换为迭代器 —即时生成或无需保存的图对象。

  • Dataset 对象 —对于应该保留的图对象,包括数据管道、过滤和转换、输入原始数据文件和输出处理后的数据文件。不要与 Datasets(结尾有“s”)对象混淆。

在掌握这些基础知识后,让我们预处理我们的社交图数据。我们将涵盖以下情况:

  • 将数据转换为 数据实例 使用 NetworkX. 快速将 NetworkX 转换为 PyG,非常适合临时处理或使用 NetworkX 的功能。

  • 使用输入文件 将数据转换为 数据实例。这提供了对数据导入过程的控制,非常适合原始数据和定制预处理需求。

  • 转换为 数据集 实例。对于系统化、可扩展和可重复的数据预处理和管理,特别是对于复杂或可重复使用的数据集。

  • 将数据对象转换为 dataloader 使用,不使用 dataset 。对于优先考虑简单性和速度,而不是系统化数据管理和预处理的场景,或者对于即时和合成数据。

首先,我们将导入 PyG 中所需的模块,如下所示。

列表 8.4 必要的导入,包括数据对象创建
import torch
from torch_geometric.data import Data
from torch_geometric.data import InMemoryDataset
from torch_geometric import utils

情况 A:使用 NetworkX 对象创建 PyG 数据对象

在前面的章节中,我们探讨了以 NetworkX graph 对象表示的图。PyG 的 util 模块有一个方法可以直接从 NetworkX graph 对象创建 PyG data 对象:

data = utils.from_networkx(social_graph)

from_networkx 方法保留了节点、边及其属性,但应检查以确保从一个模块到另一个模块的转换顺利进行。

情况 B:使用原始文件创建 PyG 数据对象

为了更好地控制数据导入 PyG,我们可以从原始文件或 ETL 流程的任何阶段的文件开始。在我们的社交图案例中,我们可以从之前创建的边列表文件开始。

现在,让我们回顾一个示例,其中我们使用代码将我们的社交图从边列表文本文件处理并转换为适合在 PyG 中训练 GNN 模型的格式。我们为 PyG 环境准备节点特征、标签、边和训练/测试集。

第一部分:导入和准备图数据

这部分包括从文件中读取边列表以创建一个 NetworkX 图,提取节点列表,创建从节点名称到索引的映射,反之亦然:

social_graph = nx.read_edgelist('edge_list2.txt')   #1

list_of_nodes = list(set(list(social_graph)))   #2
indices_of_nodes = [list_of_nodes.index(x)\
 for x in list_of_nodes]    #3

node_to_index = dict(zip(list_of_nodes, indices_of_nodes))   #4
index_to_node = dict(zip(indices_of_nodes, list_of_nodes))

1 从文本文件中读取边列表并用于创建一个 NetworkX 图。

2 然后从图中提取并列出所有唯一的节点。

3 为每个节点生成索引。

4 创建了两个字典,以便于节点名称与其相应索引之间的转换,从而便于处理和操作图数据。

第二部分:处理边和节点特征

这部分专注于将边和节点属性转换为可以轻松用于 PyTorch 机器学*任务的格式:

list_edges = nx.convert.to_edgelist(social_graph)   #1
list_edges = list(list_edges)
named_edge_list_0 = [x[0] for x in list_edges]   #2
named_edge_list_1 = [x[1] for x in list_edges]

indexed_edge_list_0 = [node_to_index[x]\
 for x in named_edge_list_0]   #3
indexed_edge_list_1 = [node_to_index[x] for x in named_edge_list_1]

x = torch.FloatTensor([[1] for x in\ 
range(len(list_of_nodes))])  #4
y = torch.FloatTensor([1]*974 + [0]*973)   #5
y = y.long()

1 创建了一个 NetworkX 边列表对象。

2 然后将其转换为两个单独的列表,分别表示每条边的源节点和目标节点。

3 这些列表随后使用先前创建的节点到索引映射进行索引。

4 使用 PyTorch 张量对象准备节点特征和标签,假设所有节点具有相同的单个特征。

5 使用 PyTorch 张量对象准备节点特征和标签,假设所有节点具有相同的单个特征。

第三部分:准备训练和测试数据

在这部分,通过创建数据拆分的掩码并将所有处理过的数据组合成一个单一的 PyTorch 数据对象,为训练和测试准备数据集:

edge_index = torch.tensor([indexed_edge_list_0,\
 indexed_edge_list_1])    #1

train_mask = torch.zeros(len(list_of_nodes),\
 dtype=torch.uint8)   #2
train_mask[:int(0.8 * len(list_of_nodes))] = 1 #train only on the 80% nodes
test_mask = torch.zeros(len(list_of_nodes),\
 dtype=torch.uint8) #test on 20 % nodes 
test_mask[- int(0.2 * len(list_of_nodes)):] = 1
train_mask = train_mask.bool()
test_mask = test_mask.bool()

data = Data(x=x, y=y, edge_index=edge_index,\
 train_mask=train_mask, test_mask=test_mask)    #3

1 在第二部分中创建的边索引被转换为 PyTorch 张量。

2 通过将节点分成两个独立的组来创建训练和测试数据集的掩码,确保数据的具体部分用于训练和测试。

3 将所有处理过的组件,包括节点特征、标签、边索引和数据掩码,然后组合成一个单一的 PyTorch 数据对象,为后续的机器学*任务准备数据。

我们已从 edgelist 文件创建了一个 data 对象。这样的对象可以使用 PyG 命令进行检查,尽管与图处理库相比,命令集有限。这样的 data 对象还可以进一步准备,以便可以通过 dataloader 访问,我们将在下一部分介绍。

情况 C:使用自定义类和输入文件创建 PyG 数据集对象

如果前面的列表适合我们的用途,并且我们希望重复使用它,一个更好的选择是创建一个永久的类,我们可以将其包含在我们的管道中。这就是dataset类的作用。

接下来,让我们创建一个dataset对象,如列表 8.5 所示。在这个例子中,我们命名我们的datasetMyOwnDataset,并让它继承自InMemoryDataset,因为我们的社交图足够小,可以放在内存中。如前所述,对于更大的图,可以通过让dataset对象继承自Dataset而不是InMemoryDataset从磁盘访问数据。

代码的这一部分初始化自定义的dataset类,继承自InMemoryDataset类。构造函数初始化数据集,加载处理后的数据,并定义原始和处理的文件名属性。由于这个例子不需要它们,所以原始文件被保留为空,处理后的数据从指定的路径获取。

列表 8.5 创建数据集对象类(第一部分)
class MyOwnDataset(InMemoryDataset):
    def __init__(self, root, \
    transform=None, pre_transform=None):\    #1
        super(MyOwnDataset, self).__init__(root,
    @property transform, pre_transform)
        self.data, self.slices = torch.load(self.processed_paths[0])

    def raw_file_names(self):  #2
        return []
    @property
    def processed_file_names(self):   #3
        return ['../test.dataset']

1 初始化数据集类。这个类继承自 InMemoryDataset 类。这个 init 方法创建数据和切片对象,在处理方法中更新。

2 一个可选的方法,用于指定处理所需的原始文件的位置。在我们的更基础的例子中,我们没有使用这个方法,但为了完整性,我们包括了它。在后面的章节中,我们将使用这个方法,因为我们的数据集变得稍微复杂一些。

3 这个方法将我们生成的数据集保存到磁盘。

这段代码用于数据下载和处理。它从一个文本文件中读取边列表并将其转换为 NetworkX 图。然后,图的节点和边被索引并转换为适合机器学*任务的张量。下载的方法被保留作为占位符,以防将来需要下载原始数据。

列表 8.6 创建数据集对象类(第二部分)
    def download(self):   #1
        # Download to `self.raw_dir`.
        pass

    def process(self):   #2
        # Read data into `Data` list.
        data_list = []

        eg = nx.read_edgelist('edge_list2.txt') 

        list_of_nodes = list(set(list(eg)))
        indices_of_nodes = [list_of_nodes.index(x) for x in list_of_nodes]

        node_to_index = dict(zip(list_of_nodes, indices_of_nodes))
        index_to_node = dict(zip(indices_of_nodes, list_of_nodes))

        list_edges = nx.convert.to_edgelist(eg)
        list_edges = list(list_edges)
        named_edge_list_0 = [x[0] for x in list_edges]
        named_edge_list_1 = [x[1] for x in list_edges]

        indexed_edge_list_0 = [node_to_index[x] for x in named_edge_list_0]
        indexed_edge_list_1 = [node_to_index[x] for x in named_edge_list_1]

1 允许将原始数据下载到本地磁盘。

2 处理方法包含创建我们的数据对象的预处理步骤,然后进行额外的步骤来分区我们的数据以便加载。

代码的这一部分专注于准备和保存数据以供机器学*模型使用。它创建了特征和标签张量,准备了边索引,并生成训练和测试掩码以分割数据集。然后,数据被整理并保存在处理路径中,以便在模型训练期间方便检索。

列表 8.7 创建数据集对象类(第三部分)
        x = torch.FloatTensor([[1] for x in range(len(list_of_nodes))])#
  [[] for x in xrange(n)]
        y = torch.FloatTensor([1]*974 + [0]*973)
        y = y.long()

        edge_index = torch.tensor([indexed_edge_list_0, indexed_edge_list_1])

        train_mask = torch.zeros(len(list_of_nodes), dtype=torch.uint8)
        train_mask[:int(0.8 * len(list_of_nodes))]\
 = 1 #train only on the 80% nodes
        test_mask = torch.zeros(len(list_of_nodes), \
dtype=torch.uint8) #test on 20 % nodes 
        test_mask[- int(0.2 * len(list_of_nodes)):] = 1

        train_mask = train_mask.bool()
        test_mask = test_mask.bool()

        data_example = Data(x=x, y=y, edge_index=edge_index, \
train_mask=train_mask, test_mask=test_mask)

        data_list.append(data_example)           #1

        data, slices = self.collate(data_list)  
        torch.save((data, slices),\
 self.processed_paths[0])    #2

1 在这个使用数据集类的第一个简单例子中,我们使用了一个小数据集。在实践中,我们会处理更大的数据集,并且不会一次性完成。我们会创建数据示例,然后将它们追加到一个列表中。对于我们的目的(在这个数据集上进行训练),从列表对象中提取数据会很慢,所以我们取这个可迭代对象,并使用 collate 将数据示例组合成一个数据对象。collate 方法还会创建一个名为 slices 的字典,用于从这个数据对象中提取单个样本。

2 将我们的预处理数据保存到磁盘

情况 D:创建用于 dataloader 的 PyG 数据对象,而不使用 dataset 对象

最后,我们解释了如何绕过dataset对象创建,并让dataloader直接与你的data对象一起工作,如图 8.15 所示。在 PyG 文档中,有一个部分概述了如何做到这一点。

正如在常规 PyTorch 中一样,当你想要即时创建合成数据而不将其明确保存到磁盘时,你不必使用数据集。在这种情况下,只需传递一个包含torch_geometric.data.Data对象的常规 Python 列表,并将它们传递给torch_geometric.data.DataLoader

from torch_geometric.data import Data, DataLoader

data_list = [Data(...), ..., Data(...)]
loader = DataLoader(data_list, batch_size=32)

在本章中,我们介绍了从项目概述到将原始数据转换为 GNN 准备格式的步骤。在结束本节时,值得注意的是,每个数据集都是不同的。本讨论中概述的程序提供了一个结构框架,作为起点,而不是一个通用的解决方案。在最后一节中,我们将转向数据来源的主题,以支持数据项目。

8.4 哪里可以找到图数据

为了在开发针对你的问题的图数据模型和模式时不必从头开始,有几种已发布的模型和模式来源。它们包括行业标准数据模型、已发布的数据集、已发布的语义模型(包括知识图谱)和学术论文。表 8.2 提供了一个示例来源集。

获取图数据

详细介绍了可用于 GNN 项目的基于图的数据的不同来源。

  • 从非图数据—在本章中,我们假设数据位于非图源,并且必须使用 ETL 和预处理将其转换为图格式。拥有模式可以帮助指导这种转换,并使其为后续分析做好准备。

  • 现有的图数据集—可自由获取的图数据集的数量正在增长。本书中我们使用的两个 GNN 库,Deep Graph Library (DGL) 和 PyG,都附带了一些预装的基准数据集。许多这样的数据集来自有影响力的学术论文。然而,这些数据集规模较小,这限制了结果的复现性,并且其性能不一定适用于大型数据集。

  • Open Graph Benchmark(OGB)是寻求减轻该领域早期基准数据集问题的数据来源。这一倡议提供了各种不同规模的真实世界数据集。OGB 还通过学*任务发布性能基准。表 8.2 列出了几个图数据集存储库。

  • 从生成——许多图处理框架和图数据库允许使用多种算法生成随机图。虽然随机,但根据生成算法的不同,生成的图将具有可预测的特征。

表 8.2 图数据集和语义模型
来源 类型 问题领域 URL
开放图基准(OGB) 图数据集和基准 社交网络、药物发现 ogb.stanford.edu/
GraphChallenge 数据集 图数据集 网络科学、生物学 graphchallenge.mit.edu/data-sets
网络存储库 图数据集 网络科学、生物信息学、机器学*、数据挖掘、物理学和社会科学 networkrepository.com/
SNAP 数据集 图数据集 社交网络、网络科学、道路网络、商业网络、金融 snap.stanford.edu/data/
Schema.org 语义数据模型 互联网网页 schema.org/
维基数据 语义数据模型 维基百科页面 www.wikidata.org/
金融行业业务本体 语义数据模型 金融 github.com/edmcouncil/fibo
生物门户 医学语义模型列表 医学 bioportal.bioontology.org/ontologies/

公共图数据集也存在于几个地方。已发布的数据集具有可访问的数据和总结统计信息。然而,通常它们缺乏明确的模式,无论是概念上的还是其他方面的。为了推导数据集的实体、关系、规则和约束,查询数据变得必要。

对于基于属性、RDF 和其他数据模型的语义模型,有一些通用数据集,还有一些针对特定行业和垂直领域。这样的参考很少使用以图为中心的术语(例如,节点顶点),但会使用与语义和本体相关的术语(例如,实体关系链接)。与图数据集不同,语义模型提供数据框架,而不是数据本身。

参考论文和已发布的模式可以提供想法和模板,有助于开发您的模式。有几个针对行业垂直领域的用例,这些用例既使用图来表示情况,又使用图算法(包括 GNN)来解决相关问题。金融机构的交易欺诈、化学工程中的分子指纹识别和社交网络中的页面排名是一些例子。查阅这些现有工作可以推动开发工作。另一方面,这种已发布的工作往往是出于学术目的,而不是行业目标。为了证明学术观点或进行经验观察而开发的网络可能不具备适合维护和使用在脏数据和动态数据上的企业系统所需的特性。

摘要

  • 计划一个图学*项目比传统机器学*项目涉及更多步骤。目标和需求将影响系统的设计、数据模型和 GNN 架构。项目包括创建健壮的图数据模型、理解和转换原始数据,并确保模型能够有效地表示招聘领域的复杂关系。

  • 一个重要的步骤是为您的数据创建数据模型和模式。这些过程对于避免技术债务至关重要。这包括设计元素、关系和约束;运行查询;分析结果;进行调整;并验证是否符合标准,以确保模型能够为复杂查询和机器学*应用做好准备。图数据模型将通过迭代测试和重构来细化,以确保它能够有效地支持招聘数据中复杂关系的分析。

  • 存储数据在内存或原始文件中有很多编码和序列化选项,包括 JSON、CSV 和 XML 等语言和系统无关的格式。还提到了特定语言的格式,如 Python 的 Pickle,以及来自特定软件和库的系统驱动格式,例如 SNAP、NetworkX 和 Gephi。对于大数据,Avro 和基于矩阵的格式(稀疏列矩阵、稀疏行矩阵和矩阵市场格式)被突出显示为处理大型数据集的高效选项。

  • 数据管道可以从经过探索性分析和预处理以供 GNN 库如 PyG 使用的原始数据开始。原始数据被转换为标准格式,如边列表或邻接矩阵,确保了不同问题的一致性和可用性。

  • 网络 X 等图处理框架用于轻量级的探索性数据分析(EDA)和可视化。将邻接和边列表等图对象加载到 NetworkX 中。通过统计分析,如节点数、边数和连通分量,得出视觉表示和结构复杂性,以理解图的结构和复杂性。

  • PyG 库用于预处理,涉及将数据转换为易于操作和训练的格式。数据对象以多个属性在各个级别上创建,使 GPU 处理成为可能,并便于将训练、测试和验证数据分开。是否使用数据集对象或绕过它们取决于保存数据的需求和数据集的复杂性。

  • 存在着许多包含各种领域(如社交网络和药物发现)的现成图数据集和语义模型的存储库。然而,尽管这些数据集对于学*和基准测试很有用,但它们通常规模较小,可能不适用于大型、现实世界的问题。

  • 虽然公共图数据集和语义模型提供了一个起点,但它们通常缺乏明确的模式,需要额外的工作来推导实体、关系和约束。此外,虽然学术论文提供了开发模式的模板,但它们通常是为学术目的设计的,可能无法直接应用于具有动态和脏数据的现实世界、行业特定应用。

附录 A 发现图

在这个附录中,我们探讨了与本书中涵盖的 GNNs 最相关的图理论和实现。目标是帮助那些不太熟悉图的人学*足够的知识来跟随本书(如果你熟悉图,可以跳过这个附录)。我们建立了基本定义、概念和命名法,然后概述了理论如何在现实系统中实现。这个基础不仅对于跟随本书中的材料是必要的,而且对于构建使构建定制系统和错误排除更容易的见解也是必要的。

此外,在一个快速发展的领域,快速吸收新的学术和技术文献的能力对于跟上最前沿的状态至关重要。我们还提供了基本背景,以便抓住相关已发表论文的精髓。在这个附录中,我们将使用一个社交网络数据集的运行示例来展示这些概念。这是一个包含 1900 多名专业人士及其行业关系的数据库。图 A.1 可视化了这个图(使用 Graphistry 生成)。

figure

图 A.1 示例社交网络的风格化可视化,包括行业专业人士及其关系。节点(点)是专业人士,边(线)表示人与人之间的关系。在这个使用 Graphistry 创建的可视化中,左图显示一条边从框架中发散出来(右下角)。右图是整个图,显示了截断的边和节点。

A.1 图的基本概念

让我们从一些定义开始,然后我们将看到这些概念是如何工作的。

关键术语

—由节点和边组成的数据类型。

节点—也称为顶点,节点是图中的一个端点。它们通过边连接。

—也称为链接关系,边连接节点。它们可以是定向的或非定向的。

sidebar figure

环和三种类型的边

有向边—有向边,通常用箭头表示,表示从一个节点到另一个节点的一个方向关系或流动。

无向边—无向边没有方向。在这样的边中,关系或流动可以朝两个方向进行。

相邻—两个节点通过边直接连接的性质。这样的节点被称为连接

自环—连接到节点的边。这样的边可以是定向的或非定向的。

并行边—连接相同两个节点的多条边。

权重—边的一个重要属性是权重,它是一个分配给边的数值。这样的属性可以描述连接的强度,或者某些其他现实世界的值,例如长度(如果图是按照道路地图模拟城市)。

这些概念为我们提供了创建最简单图的工具。通过从这些概念创建简单图,我们可以推导出下节中解释的网络属性。

虽然现实世界的图很复杂,但简单的图往往可以有效地代表它们,用于各种目的。例如,尽管我们的社交图数据包含节点特征(在 A.1.2 节中介绍),但要创建图 A.1 中的可视化,我们只使用了节点和边的信息。

A.1.1 图属性

在以下小节中,我们将讨论图的一些更重要的属性。图生态系统中的许多软件程序和数据库(在 A.3 节中描述)应该能够计算这些属性的一些或全部。

大小/阶数

我们通常对图中节点和边的总数感兴趣。这些属性的正式名称是 大小(边的数量)和 阶数(节点的数量)。在我们的社交图中,节点的数量是 1,933,边的数量是 12,239。

度分布

度分布简单来说就是图中所有节点的度的分布。这可以表示为直方图,如图 A.2 所示。

节点的 是无向图中相邻节点的数量。对于有向图,一个节点可以有三种类型的度:指向该节点的边的 入度 和从节点向外延伸的边的 出度。在计算度时,自环通常被计为 2。如果边有权重,则 加权度 也可以考虑这些权重。

figure

图 A.2 展示了我们社交图度分布的直方图

与度的概念相关的是节点的邻域。对于给定的节点,其相邻节点也称为其 邻居。所有邻居的集合称为其 邻域。节点邻域中的顶点数等于该节点的度。

连通性

图是由节点和边组成的集合。然而,通常没有条件说明对于无向图,同一网络中的每个节点都可以被任何其他节点到达。可能发生的情况是,在同一图中,节点集完全相互分离;也就是说,没有边将它们连接起来。

任何节点都可以到达任何其他节点的无向图称为 连通图。这似乎很明显,所有图都必须是连通的,但这种情况通常并不成立。具有不连续性(节点或节点集未与其他图的其他部分连接)的图称为 不连通图。另一种思考方式是,在连通图中,存在一条路径或行进方式,使得每个节点都可以到达图中的其他节点。对于不连通图,每个不连通的部分称为 组件。对于有向图,如果不可能从任何节点到达任何其他节点,则 强连通图 是每个节点都可以到达其他节点的图。

例如,如果我们把每一个个体人类视为一个节点,把我们的通信渠道视为边,那么人类人口可以被视为一个断开的社交图。虽然大多数人口可以通过现代通信渠道连接起来,但还有一些隐士选择脱离电网生活,以及拒绝与世界其他地区接触的孤立狩猎采集部落。在其他用例中,网络及其数据通常存在不连续性。

检查我们的社交图,我们看到它是断开的,有一个包含大多数节点的大的分量。图 A.3 和 A.4 显示了整个图和大的连通分量。如果我们专注于大的连通分量,我们会发现节点数是 1,698,边数是 12,222。

图

图 A.3 我们整个社交图,它是断开的。(本图使用NetworkX生成。)我们观察到中心有一个大的连通分量,周围是断开的节点和由两到三个节点组成的小分量。

图

图 A.4 社交图的连通分量。(本图使用 NetworkX 生成。)与图 A.1 进行比较,图 A.1 是使用 Graphistry 可视化的相同图。算法中使用的参数以及视觉特征的不同,导致了这两个图的不同之处。

图遍历

在一个图中,我们可以想象从一个给定的节点a到第二个节点b的旅行。这样的旅行可能只需要通过一条边,或者通过几条边和节点。这样的旅行被称为遍历,或者游走,还有其他名称。从一个节点到另一个节点的遍历有时被称为跳跃。跨越一系列节点被称为跳数。一个游走可以是开放的封闭的。开放的游走有一个与起始节点不同的结束节点。封闭的游走以相同的节点开始和结束。

路径是一种没有节点被多次遇到的游走。是一个封闭的路径(除了起始节点也是结束节点,没有节点被遇到两次)。是一种没有边被多次遇到的游走,而回路是一个封闭的迹。这些不同类型路径的例子在图 A.5 中给出。注意不同类型的路径之间步数(或跳跃数)的变化。

想象一下,对于一对给定的节点,我们可以在它们之间找到游走和路径。在我们能导航的路径中,将会有最短的一条(或者可能有超过一条路径长度相同)。这条路径的长度被称为距离最短路径长度

图

图 A.5 五种类型的路径

如果我们放大并检查整个图及其节点对,我们可以列出所有最短路径长度。其中之一将是最长(或者可能多个距离并列最长)。最大的距离是图的直径。直径常用于描述和比较图。

如果我们将我们的距离列表取平均值,我们将生成图的平均路径长度。平均路径长度是图的重要度量之一。平均路径长度和直径都给出了图密度的指示。这些度量指标的高值意味着更多的连接,这反过来又允许有更多种类的路径,无论是更长还是更短。

对于我们的社交图,我们最大组件的直径是 10。整个图的直径是未定义的,因为它是不连通的。

子图

考虑一个由节点和边组成的图。子图是这些节点和边的子集。当图中这些邻域具有与其他图中的其他位置不同的属性时,子图就很重要。子图出现在连通图和断开图之中。断开图的组件就是一个子图。

聚类系数

一个节点可能具有高度,但它的邻域连接得有多好呢?我们可以想象一个公寓楼,每个人都知道房东,但没有人知道他们的邻居(多么悲哀的地方啊!)房东的聚类系数将是 0。在另一个极端,我们可能有一个公寓,房东知道所有租户,每个租户也知道其他租户。那么,房东的聚类系数将是 1(所有网络中的节点都相互连接的情况称为完全图全连接图)。当然,也会有中间情况,只有一些租户相互认识,这些情况将具有介于 0 和 1 之间的系数。

图的维度

在机器学*和工程的一般应用中,维度被以几种方式使用。这个术语可能会令人困惑。

即使在图的主题内部,这个术语在文章和学术文献中也有几种用法。然而,这个术语通常并没有明确定义或阐明。因此,在以下列表中,我们试图分解这个术语的含义:

  • 数据集的大小/形状——在这种情况下,维度指的是数据集中特征的数量。低维数据集意味着足够小,可以可视化(即,两个或三个特征)或足够小,可以计算可行。

  • 数学定义——在数学中,图的维度有更严格的定义。在线性代数中,图可以在向量空间中表示,维度是这些向量空间的属性[1]。

  • 几何定义—也存在图的维度的几何定义。这个定义将图的维度与允许图边为单位大小 1 的最小欧几里得维度相关联[1]。

A.1.2 节点和边的特征

在最基本的图类型中,我们有一组节点和边,没有并行边或自环。对于这个基本图,我们只有一个几何结构。虽然即使这个基本的图结构也是有用的,但通常还需要更多的复杂性来正确地模拟现实世界问题和用例。例如,我们可以做以下事情:

  1. 减少前面讨论的几何限制。具体来说,这些限制如下:

    • 每条边都与两个节点相关联,一个在边的每一端。

    • 在两个节点之间,只能存在一条边。

    • 不使用自环。

在放宽这些限制后,我们能够以更复杂的图代价来模拟更多的情况。

    1. 向我们的图元素(节点、边、图本身)添加属性。属性或特征是与特定元素相关的数据。根据上下文,可以使用标签属性装饰器等术语来代替属性

在本节和下一节中,我们将讨论节点、边以及整个图的特征和变体。

节点属性

在以下列表中,我们概述了节点可能包含的一些不同属性。这些属性在许多数据科学或 GNN 任务中成为特征:

  • 名称、ID 和唯一标识符—名称或 ID 是一个唯一标识符。许多图系统要么将索引等标识符分配给节点,要么允许用户指定 ID。在我们的社交图中,每个节点都有一个唯一的字母数字 ID。

  • 标签—在图中,节点可能属于某些类别或组。例如,模拟社交网络的图可能根据居住国(美国、中国、尼日利亚)或网络内的活动水平(频繁用户、偶尔用户)对人们进行分组。这样,与前面解释的唯一标识符不同,我们预计几个节点将共享相同的标签。

  • 属性/属性/特征—不是 ID 或标签的属性通常被称为属性或特征。虽然这样的属性不必对节点是唯一的,但它们也不描述节点类。属性可以基于结构或非结构特性。

  • 结构/拓扑属性—节点的内在特性与节点的拓扑属性以及节点附*的图几何结构相关。以下列出了两个例子:

    • 节点的度,正如我们所学的,是它拥有的入边数量。

    • 节点的中心性,这是一个衡量节点相对于其邻域中节点重要性的度量。

通过使用图分析方法(在第 A.4 节中描述)可以识别节点相对于其局部环境的特征。这些特征可以作为某些 GNN 问题中的特征。例如,由归纳方法(第二章)生成的节点嵌入是基于图局部结构的另一个属性示例。

  • 非结构属性—这些通常基于现实世界的属性。以我们的社交图为例,我们有两个分类属性:一个人的职业类别(例如,科学家、营销人员、管理员)以及他们工作的公司类型(例如,医疗、交通、咨询)。这些示例是分类属性。还可能有数值属性,例如 工作经验年数平均直接下属人数 在所有当前和过去的角色中。

  • 边属性—边的属性与节点的属性相似。最常用且重要的边属性是边权重,这在前面已经描述过。

边的变化

与节点不同,边有一些几何变体可以用来使图模型更具描述性。

  • 平行边—指两个节点 uv 之间有多于一条边。

  • 方向性—边可以没有方向或一个方向。因为节点 uv 可以通过平行边连接,所以可能有两个方向相反的边或多个具有某些方向组合或无方向的边。

  • 双向性—在两个节点之间,两个方向都在各自的边中表示出来。在实践中,这个术语有几种用法:

    • 要描述非定向边,或简单边。

    • 要描述两个方向相反的边(如图 A.6 所示)。

图

图 A.6 从上到下,两个节点之间,一个无向边、一个从左到右的有向边、一个从右到左的有向边,以及两个双向的有向边(双向性)
    • 要描述每个端点都有方向的边。这种用法在文献中很流行,但在写作时,在实际系统中相当罕见。
  • 自环—之前已讨论,自环或环是指边的两端都连接到同一个节点。在现实世界中,在哪里会遇到自环?对于我们的社交图,让我们保留所有节点,并考虑一个边将是一个从一位专业人士发送给另一位专业人士的电子邮件的情况。有时,人们会给自己发送电子邮件(作为提醒)。在这种情况下,给自己发送的电子邮件可以建模为自环。

A.1.3 图的类别

不同类别的图取决于我们刚刚描述的节点和边特征。以下是一些图类别:

  • 简单图—边的边不能是平行边或自环的图。简单图可以是连通的或断开的,也可以是有向的。

  • 加权图—使用权重的图。我们的社交图没有权重;另一种表达没有权重的说法是将所有权重设置为 1 或 0。

  • 多重图—允许任何两个节点之间有多个边和任何单个节点有多个自环的图。如果我们在一个可以添加更多边和自环的问题中工作,一个简单图可以是多重图的一个特例。

  • 有向图—有向图的另一种说法。

  • K 部图—在许多图中,我们可能有两个或更多节点组的情况,其中边只允许在组之间,而不是在同一组内的节点之间。 “Partite”指的是节点组的分区,“k”指的是这些分区的数量。

  • 单部图—只有一个节点组和一条边组的图。一个单部社交图可能只包含“德克萨斯人”节点和“工作同事”边。例如,在社交图中,节点可以属于“纽约人”或“德克萨斯人”组,关系可以属于“朋友”或“工作同事”组。

  • 双部图(或双图)—在图中有两个节点分区的图。一个组的节点只能连接到第二类型的节点,而不能连接到它们自己的组内的节点。在我们的社交图示例中,节点可以属于“纽约人”或“德克萨斯人”组,关系可以属于“朋友”或“工作同事”组。在这个图中,没有纽约人会与另一个纽约人相邻,德克萨斯人也是如此。这如图 A.7 所示。

figure

图 A.7 双部图。有两种类型的节点(圆圈的上下行)。在双部图中,节点不能连接到同一类型的节点(同一行的节点)。这也是一个异构图的例子。

对于超过三个分区,相邻节点不能是同一类型的要求仍然成立。在实践中,k 可以是一个很大的数。

  • —树是机器学*中研究得很好的数据结构,是图的一个特例。它是一个无环的连通图。另一种描述无环图的方式是无环的。在数据科学和深度学*领域,一个著名的例子是用于设计和治理数据工作流的定向无环图(DAG)。

  • 超图—到目前为止,我们的图由连接两个节点或一个节点(自环)的边组成。对于超图,一条边可以与超过两个节点相关。这些数据结构有各种应用,包括涉及使用 GNNs 的应用。这如图 A.8 所示。

  • 异构图—异构图有多个节点和边类型,而多关系图有多个边类型。

figure

图 A.8 两种方式展示的一个无向超图。在左边,我们有一个图,其边由阴影区域表示,并用字母标记,其顶点由点表示,并用数字标记。在右边,我们有一个图,其边线(用字母标记)连接最多三个节点(用数字标记的圆圈)。节点 8 没有边。节点 7 有一个自环。

A.2 图表示

现在我们对图的概念有了基本的了解,我们将继续探讨如何与它们一起工作。首先,我们关注与构建图算法和存储图数据最相关的数据结构。我们将看到,其中一些结构,尤其是邻接矩阵,在我们这本书的大部分内容中研究的 GNN 算法中起着突出的作用。

接下来,我们将考察几种图数据模型。这些在设计和管理数据库以及其他数据系统如何处理网络数据方面非常重要。最后,我们将简要地看看图数据是如何通过 API 和查询语言暴露给分析师和工程师的。

A.2.1 基本图数据结构

有几种重要的图表示方法可以移植到计算环境中:

  • 邻接矩阵—节点到节点的矩阵。

  • 关联矩阵—边到节点的矩阵。

  • 边列表—按节点列出的边列表。

  • 邻接表—每个节点的相邻节点列表。

  • 度矩阵—节点到节点的度值矩阵。

  • 拉普拉斯矩阵—度矩阵减去邻接矩阵(D-A)。这在谱理论中很有用。

这些绝不是表示图的唯一方式,但从文献、软件、存储格式和库的调查来看,这些是最普遍的。在实践中,图可能不会永久存储为这些结构之一,但为了执行所需的操作,图或子图可能需要从一种表示转换为另一种表示。

使用的表示取决于许多应该被考虑在计划中的因素。以下是一些因素:

  • 图的规模—图包含多少个顶点和边,以及这些预计会扩展到多大?

  • 图的密度—图是稀疏的还是密集的?我们将在下一小节中涉及这些术语。

  • 图结构的复杂性—图更接*于简单图,还是使用之前讨论的变体之一?

  • 要使用的算法—对于给定的算法,与其它数据结构相比,一个给定的数据结构可能表现相对较弱或较强。在以下小节中,对于每个结构,我们将简要介绍两个简单的算法进行比较。

  • 执行 CRUD(创建、读取、更新、删除)操作的成本—在你的操作过程中,你将如何修改你的图(包括创建、读取、更新或删除节点、边及其属性),以及你将多久进行一次这样的操作?

在许多数据项目中,从一个数据结构转换到另一个数据结构以适应特定操作是常见的。因此,在项目中使用前面提到的两个或多个数据结构是正常的。在这种情况下,理解执行转换的计算工作量是关键。对于最流行的结构,图库允许进行无缝转换的方法,但考虑到前面列出的因素,执行这些转换可能需要意想不到的时间或成本。

在接下来的讨论中,我们将讨论如何使用这些数据结构来存储关于图拓扑信息。我们将考虑的唯一属性是节点 ID 和边权重。为了说明这些概念,让我们使用包含五个节点的加权图,如图 A.9 所示。圆圈表示具有其 ID 的节点;矩形是边权重。

figure

图 A.9 一个具有不同加权边和标记节点(从 0 到 4)的示例图

让我们现在深入探讨这六种流行的表示图的方法,以便它们可以在计算中使用。

邻接矩阵

对于具有n个节点的图,一个邻接矩阵以N × N矩阵格式表示图,其中每一行或每一列描述两个节点之间的边。对于前面在图 A.9 中显示的示例图,我们有五列和五行。这些行和列为每个节点进行了标记。矩阵的单元格表示相邻性。

邻接矩阵可用于简单的有向和无向图。它们也可以用于具有自环的图。在无权图中,每个单元格要么是 0(无相邻性)要么是 1(相邻性)。对于加权图,单元格中的值是边权重。对于无权并行边,单元格的值是边的数量。

对于我们的示例,一个加权无向图,相应的邻接矩阵如表 A.1 所示。因为我们的图是无向的,所以邻接矩阵是对称的。对于有向图,对称是可能的,但不是保证的。

表 A.1 图 A.9 中图的邻接矩阵
0 1 2 3 4
0 0 0 0 3 5
1 0 0 0 1 1
2 0 0 0 3 0
3 3 1 3 0 0
4 5 1 0 0 0

通过检查这个矩阵,我们可以快速直观地了解矩阵的特性。例如,我们可以看到节点 1 有多少度,并大致了解度的分布。我们还看到空格(值为 0 的单元格)比边多。使用矩阵为小图绘制快速洞察的这种便利性是邻接矩阵的一个优点。即使对于大型图,绘制邻接矩阵也可以指示某些子图结构。

邻接矩阵,以及一般的矩阵表示,允许你通过使用线性代数来分析图。一个相关的例子是谱图理论(它是几个 GNN 算法的基础)。

邻接矩阵在 Python 中实现起来非常简单。我们示例中的矩阵可以使用列表的列表或 NumPy 数组来创建:

>>import numpy as np
>>arr = np.array([[0, 0, 0, 3, 3],
                    [0, 0, 0, 1, 1],
                    [0, 0, 0, 3, 0], 
                    [3, 1, 3, 0, 0],
                    [5, 1, 0, 0, 0]])

以我们的邻接矩阵作为 NumPy 数组,让我们来探索我们图的一个另一些特性。从我们对矩阵的视觉检查中,我们注意到零值比非零值多得多。这使得它成为一个稀疏矩阵。稀疏矩阵,即具有大量零值的矩阵,可能会占用不必要的存储或内存空间,并增加计算时间。稠密矩阵,相反,包含大量非零矩阵。以下确定了我们矩阵的稀疏性:

>>sparsity = 1.0 - ( np.count_nonzero(arr) / arr.size )
>>print(sparsity)
>> 0.6

因此,我们的矩阵稀疏度为 0.6,这意味着这个矩阵中有 60%的值是零。

使用节点度数的稀疏性

另一种从节点度数的角度来考虑稀疏性的方法是,让我们从节点度数的角度推导出刚才显示的稀疏值。

对于一个由n个节点组成的简单无向图,每个节点最多可以建立n-1个连接,因此具有最大度数n-1。边的最大数量可以使用组合数学来计算:因为每条边代表一对节点,对于n个节点的集合,边的最大数量是“n选 2”,即(n C 2)n(n – 1)/2。然而,对于我们的小矩阵,我们有一个有向图,这很清楚,因为邻接矩阵不是对称的。这意味着两个方向都要单独计算,并需要乘以 2。因此,对于我们的小矩阵,可能的最大边数是 5(5 – 1) = 20。图的密度定义为实际边数e与所有可能边数之比,然后稀疏性可以定义为 1 – 密度。在我们的例子中,这导致了一个与仅使用矩阵计算出的值不一致的数量,即(1 – 10/20) = 0.5,这与前面代码片段中的 0.6 不相等。这是因为我们没有考虑自环,这在图论中是标准做法。如果我们包括自环,我们就会有五个额外的可能边(或 5²),结果为(1 – 10/25),即 0.6,与早期代码中的值相匹配。这表明在报告图的稀疏性时需要格外小心。

现在,想象一个有不是五个,而是数百万或数十亿个节点的图。这样的图在现实世界中确实存在,而且稀疏性往往比 0.6 低几个数量级。对于无向简单图,邻接矩阵是对称的,因此只需要一半的存储空间。大部分包含邻接矩阵的内存或存储空间将用于零值。因此,这种数据结构的高稀疏性导致了内存效率低下。

在复杂性方面,对于简单图,空间复杂度为O(n²),对于无向简单图。对于无向图,由于对称性,空间复杂度为O(n(n–1)/2)。

对于时间复杂度,这当然取决于任务或算法。让我们看看两个基本任务,我们也会针对邻接表和边表进行讨论:

  • 检查特定节点对之间是否存在边

  • 查找节点的邻居

对于第一个任务,我们只需检查对应节点的行和列。这将花费O(1)时间。对于第二个任务,我们需要检查该节点行中的每个项目;这将花费O(deg(n))时间,其中 deg(n)是节点的度。

总结来说,邻接矩阵的优点是它们可以快速检查节点之间的连接,并且易于视觉解释。缺点是它们对于稀疏矩阵来说空间效率较低。计算权衡取决于你的算法。它们在具有小型和密集图的场景中表现突出。

关联矩阵

虽然邻接矩阵为每个节点都有一个行和列,但关联矩阵将每条边表示为一列,将每个节点表示为一行。使用之前在图 A.9 中展示的相同图,我们可以构建一个关联矩阵,如表 A.2 所示。

表 A.2 图 A.9 中示例图的关联矩阵
0 1 2 3 4
0 0 3 5 0 0
1 0 0 0 1 1
2 3 0 0 0 0
3 3 3 0 1 0
4 0 0 5 0 1

关联矩阵可以表示比邻接矩阵更广泛的图类型。使用这种数据结构可以直观地表达多重图和超图。

关联矩阵在空间和时间复杂度方面表现如何?为了存储简单图的数据,关联矩阵的空间复杂度为O(|E| * |V|),其中|V|是节点数(V表示顶点),|E|是边数。因此,对于边数少于节点的图,包括稀疏矩阵,它优于邻接矩阵。

为了了解时间复杂度,我们转向两个简单任务:检查边和查找节点的邻居。要检查边的存在,关联矩阵的时间复杂度为O(|E| * |V|),远慢于邻接矩阵,后者可以在常数时间内完成这项任务。要查找节点的邻居,关联矩阵也需O(|E| * |V|)。

总体而言,当与稀疏矩阵一起使用时,关联矩阵具有空间优势。在时间性能方面,它们在简单任务上的表现较慢。使用关联矩阵的整体优势在于明确表示复杂图,例如多重图和超图。

邻接表

邻接表中,目标是显示每个节点与哪些顶点相邻。因此,对于n个节点,我们为每个节点有n个邻居列表。根据用于列表的数据结构,摘要中也可能包括属性。在我们的例子中,简单的邻接表如图 A.10 所示。

figure

图 A.10 我们的示例图及其邻接表

使用字典,其中每个节点作为键,相邻节点的列表作为值,可以在 Python 中实现这样的邻接表:

{ 0 : [ 3, 4],
1 : [3, 4],
2 : [3],
3 : [0, 1, 2],
4 : [0, 1] }

我们可以改进字典值,以便包括邻居的权重:

{ 0 : [ (3, 3), (4, 5)],
1 : [(3, 1), (4, 1)],
2 : [(3, 3)],
3 : [(0, 3), (1, 1), (2, 3)],
4 : [(0, 5) , (1, 1)] }

对于无向图,节点的集合不需要排序。因为邻接表不会为非邻居的节点对分配空间,所以我们看到邻接表没有邻接矩阵的稀疏性问题。因此,为了存储这种数据结构,我们有一个空间复杂度为O(n + v),其中n是节点的数量,v是边的数量。

回到两个计算任务,检查边的存在(任务 1)将需要O(deg(node))时间,其中 deg(node)是任一节点的度。为此,我们只需检查该节点列表中的每个项目,在最坏的情况下,我们可能需要检查所有项目。对于任务 2,找到节点的邻居也需要O(deg(node))时间,因为我们必须检查该节点列表中的每个项目,其长度等于节点的度。

让我们总结一下邻接表的权衡。优点是它们在存储方面相对高效,因为只存储边关系。这意味着稀疏矩阵作为邻接表存储比作为邻接矩阵存储占用的空间更少。在计算上,权衡取决于你运行的算法以及你用作输入数据的图类型。

边列表

与前两种表示相比,边列表相对简单。它们由一组双数(两个节点)或三数(两个节点和一个边权重)组成。这些以此方式标识唯一的边:

  • 节点,节点(边权重),对于无向图

  • 源节点,目标节点(边权重),对于有向图

边列表可以表示单个、未连接的节点。对于我们的示例图,边列表如下:

{ 0, 3, 3 }
{ 0, 4, 5 }
{ 1, 3, 1 }
{ 1, 4, 1 }
{ 2, 3, 3 }

在 Python 中,我们可以将其创建为一组元组:

>> edge_list = {( 0, 3, 3 ), ( 0, 4, 5 ), \
( 1, 3, 1 ), ( 1, 4, 1 ), ( 2, 3, 3 ) }

在性能方面,对于存储,边列表的空间复杂度为 O(e),其中e是边的数量。关于我们之前显示的两个任务,要建立特定边的存在,假设是无序边列表,其时间复杂度为O(e)。要发现一个节点的所有邻居,O(e)是空间复杂度。在每种情况下,我们必须逐个遍历列表中的边来检查边或节点的邻居。因此,从计算性能的角度来看,边列表与其他两种数据结构相比有劣势,尤其是在执行更复杂的算法时。

然而,边列表的另一个优点是它们比邻接列表或邻接矩阵更紧凑。此外,它们易于创建和解释。例如,我们可以将边列表存储为文本文件,其中每行只包含两个由空格分隔的标识符。对于许多系统和数据库,CSV 或文本文件中的边列表是序列化数据的默认选项。

拉普拉斯矩阵

如前所述,图的一种非常有价值的数据表示是拉普拉斯矩阵。这个矩阵是图谱理论发展的关键,而图谱理论又是基于谱的 GNN 方法发展的关键。

要生成拉普拉斯矩阵,我们需要从度矩阵中减去邻接矩阵(D - A)。度矩阵是一个节点到节点的矩阵,其值是特定节点的度。我们示例图的度矩阵在第一张表中给出,拉普拉斯矩阵随后给出。

侧边栏图

我们示例图的度矩阵

侧边栏图

我们示例图的拉普拉斯矩阵

在实践中,拉普拉斯矩阵不像本节中介绍的其他数据结构那样用于存储或作为图操作的依据。它们的优点在于谱分析。我们将在第三章讨论谱图分析。

A.2.2 关系数据库

我们正稳步地从理论走向实践。在前一节中,我们回顾了用于表示图及其权衡的常见数据结构。您可以使用首选的编程语言从零开始在这些结构中实现图,并且这些结构也已在流行的图处理库中得到实现。

使用列出的数据结构,我们有多种方式来实现图中的结构信息。但是,图及其元素通常带有有用的属性和元数据。

关系数据库是一种有组织地表示图的结构信息、属性和元数据的方式。这与模式的概念密切相关,模式是一个框架,它明确地定义了构成图(即,节点的种类和边,属性等)的元素,并明确地定义了这些元素如何协同工作。

数据模型和模式是设计图系统(如图数据库和图处理系统)所使用的脚手架的关键部分,并且它们通常建立在上一节中审查的数据结构之上。我们将审查三种此类模型,并提供它们在实际系统中使用的示例。

最简图数据模型

最简单的关系数据库仅使用节点、边和权重。它可以用于有向图或无向图。如果使用权重,可以使用查找表检索。

Pregel,谷歌的图处理框架,其他流行框架(包括 Facebook 使用的 Apache Giraph 和 Apache Spark GraphX)都基于此有向图。在那里,边和节点都有一个标识符和一个单一的数值,这可以解释为权重或属性。

RDF 图数据模型

资源描述框架(RDF;又称三元组存储)模型遵循主语-谓语-宾语模式,其中节点是主语和宾语,边是谓语。节点和边有一个主要属性,可以是唯一的资源标识符(URI)或字面量。本质上,URI 标识了所描述节点或边的类型。字面量的例子可以是特定的时间戳或日期。谓语代表关系。这样的三元组(主语-谓语-宾语)代表在这个上下文中称为事实的内容。通常,事实是有向的,并从主语流向宾语。

使用 RDF 模型的流行图数据库包括亚马逊的 Neptune(Neptune 还允许使用标签属性图[LPGs]),Virtuoso 和 Stardog。

属性图数据模型

在属性图(又称 LPGs)中,允许为节点和边赋予各种元数据。此类元数据包括以下内容:

  • 标识符 — 区分单个节点和边。

  • 标签 — 描述节点或边的类别(或子集)。

  • 属性或属性 — 描述单个节点或边。

节点有一个 ID 和一组键/值对,可以用来提供额外的属性(也称为属性)。同样,边也有一个 ID 和一组键/值对用于属性。

你可以将属性图视为通过添加标签并取消对属性类型和数量的限制来扩展的最简图。图 A.11 提供了查看属性图及其等效的 RDF 图的机会。使用基于属性图模型的流行图数据库包括 Neo4j、Azure Cosmos 和 TigerGraph。

figure

图 A.11 属性图及其等效的 RDF 图示例

非图数据模型

有许多数据库和系统既不使用 RDF 也不使用 LPG。这些数据库和系统在其他存储框架中存储或表达节点、边和属性,例如文档存储、键值存储,甚至在关系数据库框架内。

知识图谱

尽管该术语在学术界、商业界和实践者圈子中被广泛使用,但并没有一个统一的定义。对于 GNNs 来说,最相关的是,我们将知识图谱定义为将知识离散化为事实的表示,如之前定义的。换句话说,知识图谱是一个多图集,它被映射到一个特定的主题-关系-对象模式上。

知识图谱可以用 RDF 模式表示,但还有其他数据模型和图模型可以容纳知识图谱。GNN 方法用于在节点和边中嵌入数据,建立事实的质量,并发现新的实体和关系。一个知识图谱的例子在图 A.12 中展示。

图

图 A.12 一个知识图谱示例,表示大学物理系内的学术研究网络。该图展示了层级关系,例如教授和学生是该系的成员,以及行为关系,例如教授指导学生和撰写论文。实体如教授、学生、论文和主题通过语义上有意义的关系(例如,监督、撰写和启发)相连。实体还具有详细特征(例如,姓名、系别和类型),以提供更多上下文。语义连接和特征使得对复杂的学术互动进行高级查询和分析成为可能。

节点和边类型

在具有模式(包括知识图谱)的图中,边和节点可以被分配一个类型类型是定义模式的一部分,因此,它们决定了数据元素如何相互作用以及它们如何被数据系统解释。它们通常也具有描述性。为了区分类型属性,考虑一下,虽然类型有助于定义数据元素如何一起工作以及它们如何被数据系统解释的规则,但属性仅具有描述性。

为了说明类型,我们可以使用一个路线图类比,其中城镇是节点,它们之间的通道是边。我们的边可能包括高速公路、小径、运河或自行车道。每一种都是一个类型。由于地理原因,城镇可能被沼泽包围,位于山顶上,或者有其他障碍和阻碍,使得一条通道相对于另一条通道难以通行。对于被沙漠隔开的城镇,通道只能通过高速公路。对于其他城镇,通道可以通过多种通道类型。在这个类比中,我们注意到我们的城镇节点也由它们邻*的地理特征定义了类型:沼泽城镇、沙漠城镇、岛屿城镇、山谷城镇。

A.2.3 如何展示图

我们已经讨论了关系数据结构和关系数据库,以了解图是如何构建和存储的。然而,在现实生活中,我们中的大多数人不会从头开始或从底层构建图。在构建和分析图时,我们和原始数据之间将有一个抽象层。那么,图以何种方式暴露给数据科学家或工程师呢?接下来,我们将简要解释以下两种方式,然后讨论图生态系统:

  • APIs—使用图库或数据处理系统

  • 查询语言—通过专用查询语言查询图数据库

APIs:图系统中的图对象

当使用图库或处理软件时,我们通常希望我们工作的图具有某些属性并且能够在图上执行操作。从这个角度来看,将图视为可以由软件函数操作的软件对象是有帮助的。

在 Python 中,实现这些的一个有效方法是拥有一个图类,其中一些操作作为图类的方法或作为独立的函数实现。节点和边可以是图类的属性,或者它们可以有自己的节点和边类。以这种方式实现的图属性可以是相应类的属性。

例如,NetworkX是一个基于 Python 的图处理库。NetworkX实现了一个图类。节点可以是任何可哈希的对象;节点对象的例子包括整数、字符串、文件,甚至是函数。边是它们各自节点的元组对象。节点和边都可以有作为 Python 字典实现的属性。以下是在库和系统中找到的图类的典型方法和属性的两个简短列表。

图对象的基本方法

在以下列表中,我们概述了一些可以应用于图对象的方法:

  • Graph_Creation—一个构造函数,用于创建新的图对象

  • Add_Node, Add_Edge—添加节点或边,以及它们的属性和标签(如果有)

  • Get_Node, Get_Edge—检索存储的节点或边,带有指定的属性和标签

  • Update_Node, Updage_Edge, Update_Graph—更新节点、边和图对象的属性和属性

  • Delete_Node, Delete_Edge—删除指定的节点或边

图对象的基本属性

在以下列表中,我们概述了一些图对象的属性:

  • Number_of_Nodes, Number_of_Edges—一个构造函数,用于创建新的图对象

  • Node_Neighbors—检索节点的相邻节点或相关边

  • Node_List, Edge_List—添加节点或边及其属性和标签(如果有)

  • Connected_Graph—检索存储的节点或边,带有指定的属性和标签

  • Graph_State—检索图的全局属性、标签和属性

  • Directed_Graph—删除指定的节点或边

图查询语言

在图数据库中处理图时,使用查询语言。对于大多数关系数据库,SQL 的某个变体被用作标准语言。在图数据库领域,没有标准查询语言。以下是当前突出的语言:

  • Gremlin——一种可以声明性或命令性编写的语言,专为数据库或处理系统查询设计。由 Apache TinkerPop 项目开发,Gremlin 被用于多个数据库(Titan、OrientDB)和处理系统(Giraph、Hadoop、Spark)。

  • Cypher——一种基于属性图数据库查询的声明性语言。由 Neo4j 开发,Cypher 被 Neo4j 和其他几个数据库使用。

  • SPARQL——一种基于 RDF 数据库查询的声明性查询语言。SPARQL 被 Amazon Neptune、AllegroGraph 等使用。

A.3 图系统

我们已经介绍了允许我们在编程语言中实现图的基本构建块。在实践中,你很少从头创建图,因为你将使用库或 API 将数据加载到内存或数据库中。图库、数据库和商业软件领域广泛且发展迅速。确定要使用什么的一个好方法是先从你的用例和需求开始,然后根据这些选择你的开发和部署架构。本节将简要概述这一领域,以帮助你。我们在这里开发的分类法绝不是绝对的,但应作为有用的指南。

在撰写本文时,商业和开源的图分析、机器学*建模、可视化和存储工具正在相对快速地扩展。由于工具和功能之间存在大量重叠,以及许多混合工具无法完美地归入任何类别,因此没有清晰的细分。鉴于此,我们只突出基本方法,并专注于以下各段中最受欢迎的工具:

  • 图数据库

  • 图计算引擎(或图框架)

  • 可视化库

  • GNN 库

A.3.1 图数据库

从功能角度来看,图数据库是传统关系数据库的图类似物。这类数据库是为了处理以在线事务处理(OLTP)为中心的交易而设计的。它们允许 CRUD 事务,并且通常遵循 ACID(原子性、一致性、隔离性和持久性)原则,以确保数据的完整性。这类图数据库与关系数据库的不同之处在于,它们使用图数据模型和模式来存储数据。在撰写本文时,最受欢迎的图数据库包括 Neo4j、Microsoft Cosmos DB、OrientDB 和 ArangoDB。除了 Neo4j 之外,这些数据库支持多种模型,包括属性图。Neo4j 仅支持属性图。支持 RDF 模型的最受欢迎的数据库是 Virtuoso 和 Amazon Neptune。

除了属性图和 RDF 数据库之外,其他类型的非图数据库也用于存储图数据。文档存储、关系数据库和键值存储是例子。要使用此类非图数据库与图数据模型一起使用,必须仔细定义现有模式如何映射到图元素及其属性。

A.3.2 图计算引擎(或图框架)

图计算引擎旨在使用数据批次进行查询。此类查询可以输出汇总统计信息或输出特定于图的项,例如聚类识别和找到最短路径。这些数据系统通常遵循在线应用处理(OLAP)模型。此类系统与图数据库紧密合作,提供用于分析查询所需的数据批次,并不罕见。此类系统的例子包括 Apache Spark 的 GraphX、Giraph 和斯坦福网络分析平台(SNAP)。

A.3.3 可视化库

图可视化工具与图计算引擎具有相似的特征,因为它们旨在进行数据分析而非事务性查询和计算。然而,此类工具的设计目的是创建美观且实用的网络分析图像。在最佳可视化工具中,这些图像是交互式和动态的。可视化系统的输出可以优化用于网页展示或以高分辨率打印的格式。此类工具的例子包括 Gephi、Cytoscape 和 Tulip。

A.3.4 GNN 库

图工具的最后一段是本书的核心主题。在这里,我们将创建图嵌入的软件工具与使用图数据进行模型训练的工具分组。在撰写本文时,有许多解决方案可供选择。图表示工具的范围从专门的独立库(PyTorch BigGraph [PBG])到具有嵌入功能的图系统(Neo4j 作为数据库和 SNAP 作为计算框架)。

GNN 库既可以作为独立的库,也可以作为使用 TensorFlow 或 PyTorch 作为后端的库。在本文中,我们将重点关注 PyTorch Geometric(PyG)。其他流行的库包括 Deep Graph Library(DGL;一个独立的库)和 Spektral,后者使用 Kera 和 TensorFlow 作为后端。最好的库不仅实现了多种深度学*层,还包括可用的基准数据集。

A.4 图算法

由于图论领域已经存在一段时间了,不同的图算法数量庞大。深入了解常用的图算法可以为思考神经网络中使用的算法提供有价值的背景。图算法还可以作为 GNN 的节点、边或图特征的来源。最后,与所有机器学*方法一样,有时统计模型并不是最佳解决方案。了解分析景观有助于在决定是否使用 GNN 解决方案时做出选择。

在本节中,我们回顾两种图算法类型,搜索算法最短路径。我们提供了一般描述,解释了为什么它们很重要。对于这个主题的深入探讨,请参阅本书末尾附录的参考文献,特别是[1-3]。

A.4.1 遍历和搜索算法

在 A.1.1 节中,我们讨论了行走的概念和路径的概念。在这些基本概念中,我们通过遍历节点和它们之间的边从图中的一个节点到达另一个节点。

对于具有许多非唯一路径和节点对之间路径的大图,我们如何决定选择哪条路径?同样,对于尚未探索且没有地图的图,最好的创建地图的方法是什么?这些问题中包含的问题是在特定节点遍历图时选择哪个方向。对于度为 1 的节点,这个答案很简单;对于度为 100 的节点,答案就不那么简单了。

遍历算法提供了一种系统地遍历图的方法。对于此类算法,我们从节点开始,遵循一组规则,决定跳转到下一个节点。通常,在我们进行遍历时,我们会记录遇到的节点和边。对于某些算法,如果我们概述所采取的路径,我们最终可能会得到一个树结构。这里给出了三种著名的遍历策略:

  • 广度优先搜索 — 广度优先遍历倾向于在探索节点的所有直接邻居之后再探索更远的节点。这也被称为广度优先搜索(BFS)。

  • 深度优先搜索 — 使用深度优先搜索(DFS),我们不是首先探索每个直接邻居,而是不考虑当前节点与新的节点之间的关系,跟随每个新节点。这样做的方式是每个节点至少被遇到一次,每条边恰好被遇到一次。

对于有向图也有 DFS 和 BFS 的版本。

  • 随机 — 与 BFS 和 DFS 不同,在随机遍历中,遍历受一组规则控制,到下一个节点的遍历是随机的。在一个具有 4 度起始节点的随机遍历中,每个相邻节点被选中的概率都是 25%。这种方法在 DeepWalk 和 Node2Vec 等算法(在第二章中介绍)中使用。

A.4.2 最短路径

与图高度相关的持久性问题之一是最短路径问题。解决这个问题的兴趣已经存在了几十年(早在 1969 年就发表了一篇关于最短路径方法的优秀综述论文[4]),存在几种不同的算法。现代最短路径方法的应用包括导航应用,如找到到达目的地的最快路线。此类算法的变体包括以下内容:

  • 两个节点之间的最短路径

    • 两个节点

    • 包含指定节点的路径上的两个节点

    • 所有节点

    • 一个节点到所有其他节点

  • 排序最短路径(即,第二短路径、第三短路径等)

这些算法也可以考虑图中的权重。在这些情况下,最短路径算法也被称为最低成本算法。

一种备受赞誉的最低成本确定算法是迪杰斯特拉算法。给定一个节点,它找到到每个其他节点或指定节点的最短路径。随着算法的进行,它遍历图,同时跟踪每个遇到的节点(到起始节点)的距离和连接节点。它优先考虑遇到的节点,这些节点通过最短(或最低成本)路径到达起始节点。随着算法的遍历,它优先考虑低成本路径。

A.5 如何阅读 GNN 文献

GNNs 是一个快速发展的主题。在很短的时间内提出了新的方法和技术。尽管这本书侧重于图的实际和商业应用,但该领域的许多最先进的技术都披露在学术期刊和会议上。了解如何有效地研究这些来源的出版物对于跟上该领域的发展并遇到可以实施在代码中的有价值的想法至关重要。

在本节中,我们列出了一些常用的符号来描述技术出版物中的图,以及一些针对实践者的阅读学术文献的技巧。这些技巧尤其适用于那些对在论文中使用该方法但受时间限制的人:

  • 为了有效地从论文中提取价值,要选择性地关注出版物中的哪些部分。清楚地理解问题陈述和解决方案对于将其转换为代码至关重要。这听起来可能很显然,但许多论文包括一些对于实践者来说最多只能分散注意力的部分。数学证明和冗长的历史注释是很好的例子。

  • 一个积极的趋势是越来越多的研究论文中包含代码和数据,以增强可重复性。然而,由于模型特定的优化或硬件限制等因素,复制结果可能仍然具有挑战性。如果你遇到困难,联系作者通常可以提供有价值的澄清。

  • 仔细观察问题和解决方案的应用范围指标。一个令人兴奋的发展可能不适用于你的问题,而且可能并不立即明显。同样,不要将所有关于最先进成果的声明都视为理所当然。学术界竞争非常激烈,声称的最先进成果可能并不成立,尤其是如果论文尚未经过同行评审。

A.5.1 常见图符号

在数学符号中,图被描述为一组节点和边:

(A.1) G = (V, E)

其中 VE 分别是顶点(节点)和边的集合或集合。当我们想要表达这些集合中元素的数量时,我们使用 |V||E|。在以下列表中,我们概述了一些图数学的典型命名法:

  • 对于有向图,有时但并不总是使用带重音的 G (equation image)。

  • 单个节点和边分别用小写字母 ve 表示。

  • 当提到一对相邻节点时,我们使用 uv。因此,一条边也可以表示为 {u, v} 或 uv

  • 在处理带权图时,特定边的权重表示为 w(e)。就边的节点而言,我们可以将权重包括在内,表示为 {u, v, w}。

  • 为了表达图或其元素的特征,当特征以向量或矩阵的形式表示时,我们使用符号 xx

  • 对于图表示,因为许多这样的表示是矩阵,所以使用粗体字母来表示它们:A 表示邻接矩阵,L 表示拉普拉斯矩阵,等等。

附录 B 安装和配置 PyTorch Geometric

B.1 安装 PyTorch Geometric

PyTorch Geometric (PyG) 是一个基于 PyTorch 构建的库,用于处理图神经网络(GNNs)。最新版本的 pytorch geometric 可以通过以下命令安装:pip install torch_geometric。只需要 PyTorch 作为依赖项。要安装带有其扩展的 PyG,您需要确保已安装并兼容的正确版本的 Compute Unified Device Architecture (CUDA)、PyTorch 和 PyG。

B.1.1 在 Windows/Linux 上

如果您使用的是 Windows 或 Linux 系统,请按照以下步骤操作:

  • 安装 PyTorch。首先,为您的系统安装适当的 PyTorch 版本。您可以在官方 PyTorch 网站上找到说明(pytorch.org/get-started/locally/)。如果您有 NVIDIA GPU,请确保选择正确的 CUDA 版本。

  • 查找 PyTorch CUDA 版本。在安装 PyTorch 后,通过在 Python 中运行以下命令来检查其版本和构建时使用的 CUDA 版本:

import torch
print(torch.__version__)
print(torch.version.cuda)

这也可以通过以下命令行运行:

!python -c "import torch; print(torch.__version__)"
!python -c "import torch; print(torch.version.cuda)"

此代码的输出将在下一步中使用。

  • 安装 PyG 依赖项。从 PyG 仓库安装 PyG 依赖项(torch-scattertorch-sparsetorch-clustertorch-spline-conv),指定正确的 CUDA 版本:
pip install torch-scatter torch-sparse torch-cluster torch-spline-conv -f
https://data.pyg.org/whl/torch-${PYTORCH}+${CUDA}.xhtml

在此代码中,将${PYTORCH}替换为您的 PyTorch 版本(例如,1.13.1),将${CUDA}替换为上一步中的 CUDA 版本(例如,cu117)。

  • 安装 PyG。最后,安装 PyG 库本身:
pip install torch-geometric

B.1.2 在 MacOS 上

由于 Mac 没有配备 Nvidia GPU,您可以通过遵循上一节中的相同步骤安装 PyG 的cpu版本,但在安装依赖项时使用cpu而不是CUDA版本。

B.1.3 兼容性问题

在安装扩展时,确保 CUDA、PyTorch 和 PyG 的版本匹配,以避免兼容性问题。使用不匹配的版本可能导致安装或运行时出错。始终参考官方文档以获取最新的安装说明和版本兼容性信息。在编写本书时,我们遇到了一些令人沮丧的错误,这些错误只有在安装正确的 CUDA、PyTorch 和 PyG 组合后才能解决。

从处理与 PyG 兼容的工具(如 Open Graph Benchmark (OGB)和 DistributedDataParallel (DDP))的经历中,我们获得的一个特别见解是,它们可能只与 PyTorch 的特定版本兼容。在第七章中,分布式计算示例仅适用于 PyTorch v2.0.1 和 CUDA v11.8。

第九章:进一步阅读

第一章

Chen, F., Wang, Y-C., Wang, B., and Kuo, C-C. Jay. (2020). 图表示学*:综述. 《APSIPA 信号与信息处理学报》 9, e15.

Elinas, P. (2019, June 5). 了解你的邻居:图上的机器学*. mng.bz/1XBQ

Hua, C., Rabusseau, G., and Tang, J. (2022). 高阶池化用于具有张量分解的图神经网络. 《神经信息处理系统进展》 35, 6021-6033.

Liu, Z., and Zhou, J. (2020). 《图神经网络导论》, Morgan & Claypool.

Sanchez-Gonzalez, A., Heess, N., Springenberg, J. T., et al. (2018). 作为推理和控制的可学*物理引擎的图网络. 在《国际机器学*会议》 (第 4470–4479 页). PMLR.

第二章

DIMACS. (2011). 第十届 DIMACS 实现挑战赛. 乔治亚理工学院. sites.cc.gatech.edu/dimacs10/archive/clustering.shtml

Duong, C. T., Hoang, T. D., Dang, H. T. H., Nguyen, Q. V. H., and Aberer, K. (2019). 关于图神经网络节点特征. arXiv 预印本 arXiv:1911.08795.

Krebs, V. (2003, January). 分裂我们才能站立??? Orgnet.com. www.orgnet.com/divided1.xhtml

第七章

Cai, T., Shengjie L., Keyulu X., et al. (2021). GraphNorm:加速图神经网络训练的原理性方法. 在《第 38 届国际机器学*会议》 (第 1204–1215 页). PMLR.

Iacurci, G. (2022, February 22). 去年消费者因欺诈损失了 58 亿美元——比 2020 年增长 70%。CNBC.com. mng.bz/2yG9

Li, F., Huang, M., Yang, Y., and Zhu, X. (2011). 学*识别评论垃圾邮件. 在《第二十二届国际人工智能联合会议》 (第 2489–2493 页). IJCAI.

Ma, Y., and Tang, J. (2021). 《图上的深度学*》. 剑桥大学出版社.

Wang, Y., Zhao, Y., Shah, N., and Derr, T. (2022). 通过图-of-图神经网络进行不平衡图分类. 在《第 31 届 ACM 国际信息与知识管理会议》 (第 2067–2076 页). ACM.

Yelp 多关系评论数据集. (n.d.). GitHub. mng.bz/RVaD

Zhang, L., and Sun, H. (2024). ESA-GCN:使用 ENN-SMOTE 采样和注意力机制增强的图节点分类方法,用于处理类别不平衡. 《应用科学》 14, 111.

第五章

Kingma, D. P., and Welling, M. (2013). 自动编码变分贝叶斯. arXiv 预印本 arXiv:1312.6114.

Langr, J., and Bok, V. (2019). 《GANs 实战》. Manning.

RDKit. (n.d.). RDKit 文档. www.rdkit.org/docs/index.xhtml

Topping, J., Di Giovanni, F., Chamberlain, Dong, X., and Bronstein, M.M. (2021). 通过曲率理解图上的过度压缩和瓶颈. arXiv 预印本 arXiv:2111.14522.

第七章

AMD. (n.d.). Ryzen AI 软件. mng.bz/0QOl

Goodrich, M. T., Tamassia, R., and Goldwasser, M. H. (2013). 《Python 中的数据结构与算法》. Wiley.

Higham, N. J. (2023, September 5). 什么是 flop?[博客文章]. nhigham.com/2023/09/05/what-is-a-flop/

Intel. (n.d.). Intel NPU 加速库. mng.bz/zZeX

Kingma, D. P., and Welling, M. (2013). 自动编码变分贝叶斯. arXiv 预印本 arXiv:1312.6114.

PyTorch. (n.d.). 如何调整学*率. mng.bz/lY8B

Zeng, H., Zhou, H., Srivastava, A., Kannan, R., and Prasanna, V. (2019). GraphSAINT:基于图采样的归纳学*方法. arXiv 重印 arXiv:1907.04931.

第八章

Bechberger, D., and Perryman, J. (2020). 《图数据库实战》. Manning.

附录 A

Besta, M., Peter, E., Gerstenberger, R., et al. (2019). 揭秘图数据库:数据组织、系统设计和图查询的分析与分类. ArXiv 摘要/1910.09017.

Duong, V. M., Nguyen, Q. V. H., Yin, H., Weidlich, M., and Aberer, K. (2019). 关于图神经网络节点的特征. arXiv 预印本 arXiv:1911.08795.

Fensel, D., S¸ims¸ek, U., Angele, K., et al. (2020). 《知识图谱:方法论、工具和选定的用例》. Springer.

Goodrich, M. T., and Tamassia, R. (2015). 《算法设计与应用》. Wiley.

Hamilton, W. L. (2020). 《图表示学*》. Springer.

Nickel, M., Murphy, K., Tresp, V., and Gabrilovich, E. (2015). 关系机器学*综述. arXiv 预印本 arXiv:1503.00759v3.

资源描述框架工作组. (2004, February 10). 资源描述框架(RDF):概念和抽象语法. W3C 推荐. www.w3.org/TR/rdf-concepts/

第十章:参考文献

第一章

1 布朗斯坦,M. M.,布卢纳,J.,莱克恩,Y.,斯兹拉姆,A.,范德盖恩施,P. (2017)。几何深度学*:超越欧几里得数据。IEEE 信号处理杂志, 34(4),18–42。

2 吴,Z.,石瑞,P.,陈,F.,等 (2020)。图神经网络综合调查。IEEE 神经科学和机器学*杂志, 32,4–24。

3 德奥,N. (1974)。图论及其在工程和计算机科学中的应用,印度普伦蒂斯-霍尔出版社。

4 卢斯,D.,佩里,A. D. (1949)。矩阵分析群体结构的方法。心理测量学,14,95–116。

5 贾佳,J.,拜卡尔,C.,波特拉鲁,V. K.,本森,A. R. (2021)。图信念传播网络。arXiv 预印本 arXiv:2106.03033。

6 加特纳,T.,黎,Q. V.,斯莫拉,A. (2005)。图核方法的简要游览。api.semanticscholar.org/CorpusID:4854202

7 库科夫,L. (2015 年 5 月 19 日)。网络分析:第 17 讲(第一部分)。图上的标签传播 [视频]。youtu.be/hmashUPJwSQ

8 基恩,B. A. (2017 年 5 月 9 日)。Python 中的 Isomap 降维 [博客文章]。mng.bz/ey8P

9 英,R.,何,R.,陈,K.,等 (2018)。图卷积神经网络在 Web 规模推荐系统中的应用。在第 24 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集。ACM。

10 桑切斯-伦格林,B.,魏,J. N.,李,B. K.,等 (2019)。机器学*用于气味:学*小分子的可泛化感知表示。arXiv 预印本 arXiv:1910.10685。

11 里戈尼,D.,纳瓦林,N.,斯佩杜蒂,A. (2020). 用于分子设计的条件约束图变分自编码器。2020 年 IEEE 计算智能系列研讨会(SSCI)。IEEE。

12 江伟,罗,J.,何,M.,等 (2023)。图神经网络在交通预测中的应用:研究进展。ISPRS 国际地理信息期刊,12(3),100。 doi.org/10.3390/ijgi12030100

13 桑切斯-冈萨雷斯,A.,赫斯,N.,斯普林伯格,J. T.,等 (2018)。图网络作为可学*的物理引擎用于推理和控制。在国际机器学*会议。PMLR。

14 拉马斯佩克,L.,沃尔夫,G. (2021)。分层图神经网络可以捕捉长距离相互作用。”在2021 年 IEEE 第 31 届国际信号处理机器学*研讨会(第 1-6 页)。IEEE。

15 汉密尔顿,W. (2020). 图表示学*。摩根与克莱普顿出版社。

第二章

1 汉密尔顿,W. L. (2020). 图表示学*。人工智能与机器学*综合讲座, 14(3), 1–159。

2 格罗弗,A.,莱斯克维茨,J. (2016)。Node2Vec:网络的缩放特征学*。在第 22 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集(第 855-864 页)。ACM。

3 Krebs, V. (未注明)。Pol Books 数据集。www.orgnet.com

4 Krebs, V. (未注明)。关于美国政治的书籍。www.orgnet.com/divided.xhtml

5 McInnes, L., Healy, J. 和 Melville, J. (2018)。UMAP:统一流形逼*和投影用于降维。arXiv 预印本 arXiv:1802.03426。

6 Rossi, A., Tiezzi, M., Dimitri, G. M., et al. (2018). 基于图神经网络的归纳-转导学*。在《模式识别中的人工神经网络》(第 201-212 页)。Springer-Verlag.

7 Perozzi, B., Al-Rfou, R. 和 Skiena, S. (2014). DeepWalk:社交表示的在线学*。在第 20 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集(第 701-710 页)。ACM。

第三章

1 Kipf, T. N. 和 Welling, M. (2016). 基于图卷积网络的半监督分类。arXiv 预印本 arXiv:1609.02907。

2 Hamilton, W. L., Ying, R. 和 Leskovec, J. (2017)。在大图上的归纳表示学*。在第 31 届国际神经信息处理系统会议论文集(第 1025-1035 页)。Springer。

3 Li, G. (2022 年 11 月)。聚合的原理方法。PyTorch Geometricmng.bz/ga8x

4 Xu, K., Li, C., Tian, Y. 等. (2018)。跳跃知识网络上的图表示学*。在国际机器学*会议(第 5453-5462 页)。PMLR。

5 Niepert, M., Ahmed, M. 和 Kutzkov, K. (2016)。为图学*卷积神经网络。在国际机器学*会议(第 2014-2023 页)。PMLR。

6 Shuman, D. I., Narang, S. K., Frossard, P., et al. (2012). 图信号处理领域的兴起:将高维数据分析扩展到网络和其他不规则领域。IEEE 信号处理杂志, 第 30 卷(第 3 期),第 83-98 页。

7 Hamilton, W. L. (2020). 图表示学*。人工智能与机器学*综合讲座, 第 14 卷(第 3 期),第 51-89 页。

8 Gao, H. 和 Ji, S. (2019)。图 U-nets。在国际机器学*会议(第 2083-2092 页)。PMLR。

9 McAuley, J., Pandey, R. 和 Leskovec, J. (2015). 推断可替代和互补产品的网络。在第 21 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集(第 785-794 页)。AMC。

第四章

1 Veličković, P., Cucurull, G., Casanova, A. 等. (2017). 图注意力网络。arXiv 预印本 arXiv:1710.10903。

2 Brody, S., Alon, U. 和 Yahav, E. (2021)。图注意力网络有多关注?arXiv 预印本 arXiv:2105.14491。

3 网络犯罪对策联盟。 (2020)。虚假评论欺诈。www.counteringcrime.org/review-fraud

4 Howarth, Josh. 2023。 “81 Online Review Statistics。” ExplodingTopics。explodingtopics.com/blog/online-review-stats

5 假评论统计数据。(2024 年 9 月 26 日)。CapitalOne 购物。mng.bz/ga0x

6 YelpChi 数据集。GitHub。mng.bz/aveo

7 拉扬纳,S.,和阿科古卢,L.(2015 年)。集体意见垃圾邮件检测:连接评论网络和元数据。在第 21 届 ACM SIGKDD 国际知识发现和数据挖掘会议论文集(第 985-994 页)。ACM。

8 杜,Y.,刘,Z.,孙,L.,等。(2020 年)。增强基于图神经网络的欺诈检测器以对抗伪装的欺诈者。”在第 29 届 ACM 国际信息与知识管理会议论文集(第 315-324 页)。ACM。

9 GATConv 消耗大量 GPU 内存:问题编号#527。(2019 年)。GitHub。mng.bz/Zl05

10 汉密尔顿,W. L.,英格,R.,和莱斯克维茨,J.(2017 年)。在大图上进行归纳表示学*。在神经信息处理系统进展(第 1025-1035 页)。ACM。

11 马宇,田宇,莫尼兹,N.,和查瓦拉,N. V.(2023 年)。图上的类不平衡学*:综述。arXiv 预印本 arXiv:2304.04300。

12 赵天,张翔,王帅(2021 年)。GraphSMOTE:使用图神经网络在图上进行不平衡节点分类。在第 14 届 ACM 国际网络搜索和数据挖掘会议论文集(第 833-841 页)。ACM。

13 克里斯蒂娜,S.,和赛义德,M.(2022 年)。使用注意力构建 Transformer 模型。机器学*精通。

14 阿拉马尔,J. 图像化的 Transformer。jalammar.github.io/illustrated-transformer/

15 茹施,T. K.,布罗尼斯坦,M. M.,和米什拉,S.(2023 年)。关于图神经网络中过度平滑的综述。arXiv 预印本 arXiv:2303.10993。

第五章

1 卡拉斯,T.,莱奈,S.,和艾拉,T.(2019 年)。用于生成对抗网络的基于风格的生成器架构。”在第 IEEE/CVF 计算机视觉和模式识别会议论文集(第 4217-4228 页)。IEEE。

2 麦考利,J.,潘德,R.,和莱斯克维茨,J.(2015 年)。推断可替代和互补产品的网络。在第 21 届 ACM SIGKDD 国际知识发现和数据挖掘会议论文集(第 785-794 页)。ACM。

3 吉普夫,T. N.,和韦林,M.(2016 年)。变分图自动编码器。arXiv 预印本 arXiv:1611.07308。

4 德·高,N.,和吉普夫,T.(2018 年)。MolGAN:用于小分子图的隐式生成模型。arXiv 预印本 arXiv:1805.11973。

5 戈麦斯-博姆巴拉里,R.,魏,J. N.,杜文纳德,D.,等(2018 年)。使用分子数据驱动的连续表示自动进行化学设计。ACS 中央科学,4,268-276。

6 Basu, V. (2024 年 12 月 17 日). 使用 VAE 生成药物分子。Keras。mng.bz/rKve

7 阿隆,U.,和亚哈夫,E.(2020 年)。关于图神经网络瓶颈及其实际影响。arXiv 预印本 arXiv:2006.05205。

第六章

1 Carnegie Mellon University. (n.d.). CMU Graphics Lab Motion Capture Database. mocap.cs.cmu.edu/

2 Kipf, T., Fetaya, E., Wang, K-C., Welling, M., and Zemel, R. (2018). Neural relational inference for interacting systems. In International Conference on Machine Learning (pp. 2688–2697). PMLR.

3 Raff, E. (2022). Inside Deep Learning. Manning.

4 Xu, D., Ruan, C., Korpeoglu, E., Kumar, S., and Achan, K. (2020). Inductive representation learning on temporal graphs. arXiv preprint arXiv:2002.07962.

5 Rossi, E., Chamberlain, B., Frasca, F., et al. (2020). Temporal graph networks for deep learning on dynamic graphs. arXiv preprint arXiv:2006.10637.

6 Zheng, Y., Yi, L., and Wei, Z. (2024). A survey of dynamic graph neural networks. arXiv preprint arXiv:2404.18211.

7 Veličković, P., Cucurull, G., Casanova, A., et al. (2017). Graph attention networks. arXiv preprint arXiv:1710.10903.

Chapter 7

1 Khatua, A., Mailthody, V. S., Taleka, B., et al. (2023). IGB: Addressing the gaps in labeling, features, heterogeneity, and size of public graph datasets for deep learning research. arXiv preprint arXiv:2302.13522.

2 McAuley, J., Pandey, R., and Leskovec, J. (2015). Inferring networks of substitutable and complementary products. In Proceedings of the 21st ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (pp. 785–794). ACM.

3 Bronstein, M., Frasca, F., and Rossi, E. (2020, August 8). Simple scalable graph neural networks. Towards Data Science. mng.bz/N1Q7

4 Chiang, W-L., Liu, X., Xiaoqing, S., et al. (2019). Cluster-GCN: An efficient algorithm for training deep and large graph convolutional networks. In Proceedings of the 25th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (pp. 257–266). ACM.

5 PyTorch. (n.d.). PyTorch Profiler with TensorBoard. mng.bz/EaAj

6 PyTorch. (n.d.). PyTorch Profiler. mng.bz/8OBB

7 Heim, L. (2023, September 11). FLOP for quantity, FLOP/s for performance [blogpost]. mng.bz/KGnZ

8 Wu, Z., Pan, S., Chen, F., et al. (2020). A comprehensive survey on graph neural networks. IEEE Transactions on Neural Networks and Learning Systems, 32, 4–24.

9 PyTorch Geometric. (n.d.). Torch_Geometric.Loader. mng.bz/BXBr

10 PyTorch Geometric. (n.d.). Torch_Geometric.Sampler. mng.bz/dX8v

11 Kipf, T. N., and Welling, M. (2016). Variational graph auto-encoders. arXiv preprint arXiv:1611.07308.

12 PyTorch Geometric. (n.d.). Memory-efficient aggregations. mng.bz/9Ymo

13 namespace-PT. (2021, August 15). A comprehensive tutorial to PyTorch DistributedDataParallel. mng.bz/YDzK

14 PyTorch Geometric. (n.d.). Distributed batching. GitHub. mng.bz/GeBR

15 林,H.,颜,M.,杨,X.,等. (2022). 分布式 GNN 在 GPU 上的特性和理解.” IEEE 计算机架构信函, 21, 21–24.

16 德费拉德,M.,布雷斯松,X.,和范德盖恩施,P. (2016). 基于快速局部频谱滤波的图上的卷积神经网络. 在 神经信息处理系统进展 (第 3844–3852 页). ACM.

17 PyTorch Geometric. (n.d.) 通过远程后端扩展 GNN. mng.bz/jpdp

18 PyTorch Geometric. (n.d.) Graph_store.py. GitHub. mng.bz/W2Yw

19 KuzuDB. (n.d.). KuzuDB 图存储实现. GitHub. mng.bz/OBXo

20 ArangoDB. (n.d.). ArangoDB 远程后端模块,FastGraphML. GitHub. github.com/arangoml/fastgraphml#fastgraphml

21 黄,Z.,张,S.,西,C.,刘,T.,和周,M. (2021). 通过图粗化扩展图神经网络. 在 第 27 届 ACM SIGKDD 知识发现与数据挖掘会议 (第 675–684 页). ACM.

第八章

1 亚历克索普洛斯,P. (2020). 数据语义建模. O’Reilly 媒体.

2 巴克,R. (1990). CaseMethod: 实体关系建模. 埃迪生·韦斯利出版社.

3 波科尼,J. (2016). 图数据库的概念和数据库建模. 在 第 20 届国际数据库工程与应用研讨会(IDEAS '16) (第 370–377 页). ACM.

4 戈斯尼尔,D.,和布罗切勒,M. (2020). 图数据实践指南. O’Reilly 媒体.

5 波科尼,J.,和科瓦奇奇,J. (2017). 图数据库中的完整性约束. 计算机科学进展, 109, 975–981.

6 内戈,A. (2021). 图增强机器学*. 曼宁出版社.

7 Neo4j GraphAcademy. (n.d.). 图数据建模基础. mng.bz/pKo2

附录 A

1 迪奥,N. (2017). 图论及其在工程和计算机科学中的应用. 多佛出版社.

2 科尔曼,T. H.,利瑟森,C. E.,里维斯,R. L.,和斯坦,C. (2009). 算法导论. 第 3 版. MIT 出版社.

3 斯基纳,S. (1997). 算法设计手册. Springer-Verlag.

4 德雷夫斯,S. E. (1969). 一些最短路径算法的评价. 运筹学, 17, 395–412.

索引

A

aggr 参数

激活函数

邻接表, 第 2 版

Avro

准确度指标

属性

Amazon 产品数据集, 第 2 版, 第 3 版

聚合运算符

平均路径长度

邻接矩阵, 第 2 版, 第 3 版

aggr 参数, 第二章, 第三章

Adam 优化器

APIs(应用程序编程接口)

图对象的属性

聚合操作

相邻节点

Add_self_loops

聚合方法, 第二章

高级聚合工具

应用中的实际考虑因素

ASIN(亚马逊标准识别号)

Add_Node 方法

注意力

系数计算

B

使用采样方法进行批处理

选择合适的采样器

BalancedNodeSampler 类

BFS(广度优先搜索)

books_graph 数据集, 第二章

BPTT(时间反向传播)

大数据

中介中心性

基线

二部图, 第二章, 第三章, 第四章

BCE(二元交叉熵)

双向性

books_graph 变量

books_gml 数据集

C

循环图

CNNs(卷积神经网络)

卷积 GNNs(卷积图神经网络)

connected_components 方法

Cypher 语言

calculate_loss 方法

连通图

连通性

常数时间复杂度

收敛速度

卷积方法, 第二章

聚类

CSV(逗号分隔值), 第二章

团方法

连续性

conv2 层

Connected_Graph 方法

cat 参数

CPUs(中央处理单元), 第二章

CUDA(统一计算设备架构)

概念模式, 第二章

D

将数据对象转换为迭代器

Delete_Node 方法

DFS(深度优先搜索), 第 2 次, 第 3 次

数据集对象

方向性

解码

多样化操作

DAG(有向无环图)

DataParallel

有向边

data.x 节点特征矩阵

DataBatch

数据隐私

Dataloader 类

dataloader 模块

深度学*方法

DDP(分布式数据并行)

主函数

多进程 spawn

准备模型和数据

运行训练, 第 2 次

为分布式训练设置

训练函数

动态图, 第 2 次

将自动编码器与 RNN 结合

针对的图注意力网络, 第 2 次

姿态估计

循环神经网络

数据集对象

dataloader, 第 2 次

DGL(深度图库), 第 2 次

数据集表示

直径

判别模型

降维

调试

度分布

数据准备和项目规划

项目定义

项目目标和范围

数据大小和速度

数据结构

数据类

Directed_Graph 方法

data.x 节点特征对象

数据模型

数据

数据模块(torch_geometric.data)

度矩阵, 第 2 次

数据集类, 第 2 次

数据集模块(torch_geometric_datasets)

有向图

E

边属性

编码

ER(实体-关系)图

边索引张量

边预测, 第二部分

边到节点矩阵

边列表, 第二部分

边到节点函数, 第二部分, 第三部分

ETL(提取、转换、加载)步骤

创建邻接列表

创建边列表

ELU(指数线性单元)

边索引

EDA(探索性数据分析)

嵌入

可解释性

边权重

周期

F

滤波器(核操作)

拟合方法

from_networkx 方法

FLOP, 第二部分

F1 分数

特征存储

G

GraphSAGE(图采样和聚合), 第二部分, 第三部分

聚合函数

图自动编码器(GAEs), 第二部分

使用 GNN 生成图

生成模型

概述

GATConv 层

GATs(图注意力网络), 第二部分, 第三部分, 第四部分

概述, 第二部分

训练模型

Gremlin 语言

GeoGrid, 第二部分

Graph_State 方法

图粗化

GNN 架构

Gumbel 温度

图对象

GRUs(门控循环单元), 第二部分, 第三部分

graclus(edge_index) 函数

Gloo

GPU(图形处理单元), 第二章, 第三章

GCNs(图卷积网络), 第二章

聚合函数, 第二章

基于图的学*

定义

Get_Node 方法

GDPR(通用数据保护条例)

GraphStore, 第二章

G2GNN(图图神经网络)

图, 第二章

算法, 第二章

类别

聚类系数

定义, 第二章

维度

暴露

基础

属性, 第二章

表示

系统

类型

gcn_norm 函数, 第二章

GNNs(图神经网络), 第二章, 第三章, 第四章, 第五章

应用

项目考虑因素

使用创建嵌入, 第二章

数据管道示例

定义

设计图模型

训练的心理模型

概述, 第二章

独特机制

何时使用, 第二章

图大小

图数据

查找位置

图数据集

GATv2Conv 层

图预测任务

GCNConv 类, 第二章

获取方法

图嵌入

使用 Node2Vec 创建, 第二章

理论基础

Gumbel-Softmax

图创建方法

H

硬件需求

超图, 第 2 次

同质图

heads 参数

硬件配置

异质/同质图

硬件速度和容量

局部化模式层次

异质图, 第 2 次

I

归纳偏差

推理速度

集成测试

输入, 第 2 次

关联矩阵, 第 2 次

标识符

归纳学*

index_to_key 属性

InMemoryDataset 类

实现细节

I/O 延迟

实例模型, 第 2 次

隐含关系和相互依赖,关键指标

IPUs(智能处理单元)

内积解码器, 第 2 次

正则化潜在空间

索引数组

入度

J

联合概率

JumpingKnowledge 层

JSON

json 模块

JumpingKnowledge 聚合方法

JK(跳跃知识)网络, 第 2 次

K

KL 散度(Kullback-Liebler 散度)

知识图谱

k-分割图

关键词

Kamada-Kawai 算法

L

lstm 参数

logits

线性时间复杂度

潜在空间

语言和系统无关的编码格式

层输出

标签, 第 2 次

LSTM(长短期记忆)

大规模学*和推理

使用采样方法进行批处理

GNN 算法的选择

数据表示的选择, 第 2 次

硬件配置的选择

规模问题的框架

解决规模问题的技术, 第 2 次

标记的掩码布尔掩码

长短期记忆 (LSTM) 网络

拉普拉斯矩阵, 第 2 次

损失函数 (loss_fn) 函数

链接预测任务

图神经网络 (GNN)

LSTM 聚合

对数时间复杂度

loadmat 函数

日志记录

标签 NumPy 数组

链接预测,用于图自动编码器的

亚马逊产品数据集, 第 2 次

定义, 第 2 次

训练, 第 2 次

特定语言

M

均方误差 (MSE), 第 2 次

model.eval() 方法

多头注意力

机械推理

分子图, 第 2 次

多层感知器 (MLP), 第 2 次, 第 3 次

内存到数据比率

多重图

矩阵乘法

max_pool() 函数

手动转换

分子科学

模型前向传递

基于矩阵的

内存大小

掩码

模式参数

最大参数

最大聚合

Matplotlib 库

平均聚合, 第 2 次

单部分图

消息传递, 第 2 次

N

神经处理单元 (NPUs)

NeighborSampler 函数

邻域聚合

节点分类

node2vec 方法

邻域加载器, 第 2 次

Node_List 方法

Node_Neighbors 方法

节点

特性

num_nodes

节点嵌入

数据预处理

端到端模型中

随机森林分类

名称

负样本

非结构化属性

NLP (自然语言处理), 第 2 次

节点

N2V (node2vec), 第 2 次

应用和考虑因素

嵌入

加载数据,设置参数,创建嵌入

转换和可视化嵌入, 第 2 次

N2V (node2vec) 嵌入

对新图的可适应性

增强特征集成

特定任务优化

Number_of_Nodes 构造函数

邻域

NRI (神经关系推理) 模型, 第 2 次

训练

num_vars 变量

NetworkX 库, 第 2 次, 第 3 次

node2edge 函数, 第 2 次

节点表示更新

node_embeddings 对象

NCCL (NVIDIA 集体通信库)

NeighborLoader 数据加载器

Node2Vec Python 库

非局部交互,关键指标

O

优化器初始化

本体

ogbn-products 数据集, 第 2 次

OGB (开放图基准), 第 2 次

OLAP (在线应用处理)

过度压缩

OOD (分布外) 泛化

过度平滑, 第 2 次

输出

OLTP (在线事务处理)

P

Pinterest

参数类

产品类别预测

创建模型类

加载数据和处理数据

模型性能分析,第 2 次

模型训练,第 2 次

产品捆绑演示,第 2 次

并行性

项目目标

预测

Pickle 数据编码

属性

PyG(PyTorch Geometric),第 2 次

GCN 在

GraphSAGE 在

在中实现的批处理

使用原始文件创建数据对象

在数据加载器中使用数据对象创建,不使用数据集对象

使用自定义类和输入文件创建数据集对象

GATs 的实现

导入和准备图数据

安装和配置,第 2 次

邻域聚合在

处理边和节点特征

采样器,第 2 次

项目需求和范围,第 2 次

排列不变性

路径

psutil 库

prediction_steps 变量

pred_adj 邻接矩阵

并行和分布式处理

DDP 的代码示例

使用分布式数据并行(DDP)

并行边,第 2 次

池化,图粗化

属性图

PinSage

问题半径

姿态估计

使用内存构建模型

设置问题

Q

二次时间复杂度

QED(药物相似性定量估计)

R

排名

远程存储,使用进行训练,第 2 次

示例

return_edge_mask 选项

RNNs (循环神经网络), 第 2 版, 第 3 版, 第 4 版, 第 5 版, 第 6 版

推荐引擎

随机森林分类

接收系统

表示, 第 2 版

节点相似性和上下文

随机游走

原始数据, 第 2 版

到邻接表和边列表

RDF (资源描述框架) 图

RandomForestClassifier

细化, 第 2 版

基线模型性能, 第 2 版

dropout

模型深度

ReLU (修正线性单元), 第 2 版

read_edgelist 方法

评论垃圾邮件数据集

探索性数据分析

图结构, 第 2 版

节点特征, 第 2 版

阅读 GNN 文献

常见的图表示法

远程后端, 第 2 版

ROC (接收者操作特征), 第 2 版

RefMLPEncoder

关系数据库, 第 2 版, 第 3 版

RDF 图数据模型

知识图谱

简约图数据模型

节点和边类型

非图数据模型

属性图数据模型

远程数据源

S

子图方法

子图, 第 2 版

结构属性

系统驱动,编码选择

模式, 第 2 版, 第 3 版

SMOTE (合成少数类过采样技术)

垃圾邮件和欺诈评论检测

稀疏性,关键指标

系统架构

序列化,2 次

缩放 GNNs,图粗化,2 次

自环,2 次,3 次

对称归一化

谱方法

稀疏矩阵

空间时序 GNNs

使用 GRU 解码姿态数据

编码姿态数据

自注意力

最短路径

采样,2 次

SAGEConv 类

SMILES(简化分子输入行系统)

求和聚合

空间方法

浅层方法

空间复杂度

合成数据

大小属性

采样器

简单图

最短路径长度

SPARQL 语言

SAS(合成可访问性分数)

空间卷积

T

torch_geometric.profile 模块

时序邻接矩阵

to_edge_index 实用函数

时序模型

测试和故障排除

训练

GCN 基线

MLP,2 次

基线模型

非 GNN 基线

为...准备数据

训练掩码

团队分析

test_train_split 函数

平移不变性

测试,2 次

torch.no_grad()方法

TGAT(时序 GAT)

TPUs(张量处理单元)

torch_geometric.transforms.ToSparseTensor 函数,2 次

归纳方法

遍历

故障排除

技术债务

表格数据, 第 2 次

图数据以及增加深度和意义

将泰坦尼克数据集重铸为图

时间和空间复杂度, 第 2 次

torch_geometric.data.GraphStore 对象

transformers

归纳学*

torch-scatter 依赖

torch.no_grad()函数

thop 库, 第 2 次

torch_geometric.data.FeatureStore 对象

tpr(真正例率)

遍历算法

U

无权图

单元测试

util 模块

唯一方法

无向边

用例

无向图

Utils 模块(torch_geometric.utils)

Update_Node 方法

V

可视化

可视化库

VGAEs(变分图自动编码器), 第 2 次

构建, 第 2 次

用于生成图, 第 2 次

何时使用

顶点

VariationalGCNEncoder 层

W

在 Windows 上安装 PyTorch Geometric

权重

wv 方法

加权图

X

X_train_gnn 嵌入

Xavier 初始化

X_n2v NumPy 数组

XGBoost(极端梯度提升)

XML

Z

梯度归零

posted @ 2025-11-24 09:12  绝不原创的飞龙  阅读(34)  评论(0)    收藏  举报