Python-图神经网络实用指南-全-

Python 图神经网络实用指南(全)

原文:annas-archive.org/md5/be12ac19810306fa2043558635436763

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在短短十年间,图神经网络GNNs)已成为一种重要且流行的深度学习架构。它们已经在多个行业产生了重大影响,比如在药物发现领域,GNNs 预测出了一种名为 halicin 的新型抗生素,并改善了 Google Maps 上的预计到达时间计算。科技公司和大学正在探索 GNNs 在各种应用中的潜力,包括推荐系统、假新闻检测和芯片设计等。GNNs 具有巨大的潜力和许多尚未发现的应用,成为解决全球问题的重要工具。

本书旨在提供关于 GNNs 世界的全面且实用的概述。我们将首先探讨图论和图学习的基本概念,然后深入了解最广泛使用和最成熟的 GNN 架构。随着进展,我们还将涵盖 GNNs 的最新进展,并介绍专门设计用于处理特定任务的架构,如图生成、链接预测等。

除了这些专门的章节外,我们还将通过三个实践项目提供实际操作经验。这些项目将涵盖 GNNs 的关键现实世界应用,包括交通预测、异常检测和推荐系统。通过这些项目,你将深入理解 GNNs 是如何工作的,并且掌握将其应用于实际场景中的技能。

最后,本书通过每章技术和相关应用的可读代码提供动手学习经验,这些代码可在 GitHub 和 Google Colab 上轻松获取。

到本书结束时,你将全面了解图学习和 GNNs 领域,并且能够设计和实现这些模型,用于广泛的应用场景。

本书适用人群

本书面向那些有兴趣学习 GNNs 及其如何应用于各种现实问题的读者。数据科学家机器学习工程师人工智能AI)专业人士,尤其是希望获得设计和实现 GNNs 的实际经验的读者,将从本书中受益。此书适合已有深度学习和机器学习知识的读者,但对于初学者,它也提供了关于图论和图学习的基础概念的全面介绍。此外,本书对于计算机科学、数学和工程领域的研究人员和学生也具有很大价值,尤其是那些希望扩展在这一迅速发展的研究领域中的知识的人。

本书内容

第一章图学习入门,全面介绍了图神经网络(GNN),包括其在现代数据分析和机器学习中的重要性。本章首先探讨了图作为数据表示的相关性,以及它们在各个领域中的广泛应用。接着深入讨论了图学习的重要性,包括不同的应用和技术。最后,本章聚焦于 GNN 架构,突出了其独特的特性和与其他方法相比的性能。

第二章图神经网络的图论基础,涵盖了图论的基础知识,并介绍了各种类型的图,包括它们的属性和应用。本章还讨论了基本的图概念,如邻接矩阵、图度量(例如中心性)和图算法,广度优先搜索BFS)和深度优先搜索DFS)。

第三章使用 DeepWalk 创建节点表示,专注于 DeepWalk,作为将机器学习应用于图数据的开创者。DeepWalk 架构的主要目标是生成节点表示,供其他模型用于下游任务,例如节点分类。本章介绍了 DeepWalk 的两个关键组件——Word2Vec 和随机游走——特别强调了 Word2Vec 跳字模型(skip-gram model)。

第四章通过 Node2Vec 中的有偏随机游走改进嵌入,专注于 Node2Vec 架构,该架构基于前一章介绍的 DeepWalk 架构。本章介绍了 Node2Vec 中随机游走生成的修改,以及如何为特定图选择最佳参数。本章将 Node2Vec 的实现与 DeepWalk 在扎卡里武术俱乐部(Zachary's Karate Club)上的表现进行对比,突显了两种架构的不同。最后,本章通过构建电影推荐系统,展示了 Node2Vec 的实际应用。

第五章使用传统神经网络包含节点特征,探讨了将附加信息(如节点和边的特征)集成到图嵌入中,以产生更准确的结果。本章首先比较了传统神经网络仅在节点特征上的表现,节点特征被视为表格数据集。然后,我们将实验通过将拓扑信息添加到神经网络中,从而创建一个简单的传统 GNN 架构。

第六章引入图卷积网络,重点介绍了图卷积网络GCN)架构及其作为图神经网络(GNN)的蓝图的重要性。它讨论了之前的基本 GNN 层的局限性,并解释了 GCN 背后的动机。本章详细介绍了 GCN 层的工作原理、相较于传统 GNN 层的性能提升,并展示了如何在 Cora 和 Facebook Page-Page 数据集上使用 PyTorch Geometric 进行实现。本章还涉及了节点回归任务以及将表格数据转化为图结构的好处。

第七章图注意力网络,重点介绍了图注意力网络GAT),它是对 GCN 的改进。章节解释了 GAT 如何通过自注意力机制工作,并提供了图注意力层的逐步理解。该章节还从零开始使用 NumPy 实现了一个图注意力层。最后,章节讨论了在两个节点分类数据集(Cora 和 CiteSeer)上使用 GAT,并与 GCN 的准确度进行了对比。

第八章使用 GraphSAGE 扩展图神经网络,重点介绍了 GraphSAGE 架构及其有效处理大规模图的能力。章节讲解了 GraphSAGE 的两大核心思想,包括其邻居采样技术和聚合操作符。你将了解像 Uber Eats 和 Pinterest 等技术公司提出的变种,以及 GraphSAGE 的归纳方法的好处。本章最后通过实现 GraphSAGE 进行节点分类和多标签分类任务的示范。

第九章定义图分类的表现力,探索了 GNN 中的表现力概念及其如何用于设计更好的模型。它介绍了Weisfeiler-LemanWL)测试,这为理解 GNN 中的表现力提供了框架。本章使用 WL 测试对不同的 GNN 层进行比较,确定最具表现力的层。基于这一结果,设计并实现了一个更强大的 GNN,并使用 PyTorch Geometric 进行了实现。章节最后对 PROTEINS 数据集上的图分类进行了不同方法的比较。

第十章使用图神经网络预测链接,重点讲解图中的链接预测。内容涵盖了传统技术,如矩阵分解和基于 GNN 的方法。章节解释了链接预测的概念及其在社交网络和推荐系统中的重要性。你将了解传统技术的局限性以及使用基于 GNN 的方法的优势。我们将探索来自两个不同类别的三种基于 GNN 的技术,包括节点嵌入和子图表示。最后,你将实现各种链接预测技术,并在 PyTorch Geometric 中选择最适合给定问题的方法。

第十一章使用图神经网络生成图,探索了图生成领域,该领域涉及寻找创建新图的方法。章节首先介绍了传统技术,如 Erdős–Rényi 模型和小世界模型。接着,你将重点学习三类基于 GNN 的图生成解决方案:基于 VAE 的模型、基于自回归的模型和基于 GAN 的模型。章节最后通过强化学习RL)实现一个基于 GAN 的框架,使用 DeepChem 库和 TensorFlow 生成新的化学化合物。

第十二章来自异构图的学习,重点讲解异构 GNN(图神经网络)。异构图包含不同类型的节点和边,与只涉及一种类型节点和边的同构图相对。章节首先回顾了用于同构 GNN 的消息传递神经网络MPNN)框架,然后将该框架扩展到异构网络。最后,我们介绍了一种创建异构数据集的技术,如何将同构架构转化为异构架构,并讨论了专门设计用于处理异构网络的架构。

第十三章时序图神经网络,重点讲解时序 GNN 或时空 GNN,这是一种能够处理随着时间变化的边和特征的图神经网络。章节首先解释了动态图的概念及时序 GNN 的应用,重点介绍了时间序列预测。接着,章节讲解了时序 GNN 在网页流量预测中的应用,利用时序信息来改善结果。最后,章节介绍了另一种专门为动态图设计的时序 GNN 架构,并将其应用于疫情预测任务。

第十四章解释图神经网络,介绍了各种技术,用于更好地理解 GNN 模型的预测和行为。本章重点介绍了两种流行的解释方法:GNNExplainer 和集成梯度。接着,您将看到这些技术在使用 MUTAG 数据集进行图分类任务和使用 Twitch 社交网络进行节点分类任务中的应用。

第十五章使用 A3T-GCN 预测交通,重点介绍了时序图神经网络在交通预测领域的应用。它突出了在智能城市中准确交通预测的重要性,以及由于复杂的时空依赖性导致的交通预测挑战。本章涵盖了处理新数据集以创建时序图的步骤,并实现了一种新的时序 GNN 类型来预测未来的交通速度。最后,结果与基准解决方案进行了比较,以验证该架构的相关性。

第十六章使用异构 GNN 进行异常检测,重点介绍了 GNN 在异常检测中的应用。GNN 具有捕捉复杂关系的能力,使其非常适合用于异常检测,并且能够高效地处理大量数据。在本章中,您将学习如何使用 CIDDS-001 数据集实现一个用于计算机网络入侵检测的 GNN。该章节涵盖了数据集处理、构建相关特征、实现异构 GNN,以及评估结果以确定其在检测网络流量异常中的有效性。

第十七章使用 LightGCN 推荐书籍,重点介绍了 GNN 在推荐系统中的应用。推荐系统的目标是根据用户的兴趣和过去的互动,提供个性化的推荐。GNN 非常适合这一任务,因为它们可以有效地整合用户与物品之间的复杂关系。在本章中,介绍了 LightGCN 架构,它是专为推荐系统设计的 GNN。通过使用 Book-Crossing 数据集,本章演示了如何使用 LightGCN 架构构建一个基于协同过滤的书籍推荐系统。

第十八章解锁图神经网络在现实世界应用中的潜力,总结了我们在整本书中学到的内容,并展望了 GNN 的未来。

为了最大程度地从本书中受益

您应该对图论和机器学习概念(如监督学习和无监督学习、训练和模型评估)有基本的了解,以便最大化您的学习体验。熟悉深度学习框架(如 PyTorch)也会有所帮助,尽管不是必需的,因为本书将提供数学概念及其实现的全面介绍。

书中涉及的软件 操作系统要求
Python 3.8.15 Windows、macOS 或 Linux
PyTorch 1.13.1 Windows、macOS 或 Linux
PyTorch Geometric 2.2.0 Windows、macOS 或 Linux

要安装 Python 3.8.15,您可以从 Python 官网上下载最新版本:www.python.org/downloads/。我们强烈建议使用虚拟环境,例如venvconda

可选地,如果您希望使用 NVIDIA 的图形处理单元GPU)来加速训练和推理,您需要安装CUDAcuDNN

CUDA 是 NVIDIA 开发的并行计算*台和 API,用于 GPU 上的通用计算。要安装 CUDA,您可以按照 NVIDIA 官网上的说明进行操作:developer.nvidia.com/cuda-downloads

cuDNN 是 NVIDIA 开发的一个库,它提供了深度学习算法的 GPU 优化实现。要安装 cuDNN,您需要在 NVIDIA 官网上创建一个账户,并从 cuDNN 下载页面下载库:developer.nvidia.com/cudnn

您可以在 NVIDIA 官网上查看支持 CUDA 的 GPU 产品列表:developer.nvidia.com/cuda-gpus

要安装 PyTorch 1.13.1,您可以按照 PyTorch 官网上的说明进行操作:pytorch.org/。您可以选择最适合您系统(包括 CUDA 和 cuDNN)的安装方法。

要安装 PyTorch Geometric 2.2.0,您可以按照 GitHub 仓库中的说明进行操作:pytorch-geometric.readthedocs.io/en/2.2.0/notes/installation.xhtml。您需要首先在系统上安装 PyTorch。

第十一章需要 TensorFlow 2.4 版本。要安装它,您可以按照 TensorFlow 官网上的说明进行操作:www.tensorflow.org/install。您可以选择最适合您的系统和所需 TensorFlow 版本的安装方法。

第十四章需要一个旧版本的 PyTorch Geometric(版本 2.0.4)。建议为本章创建一个专门的虚拟环境。

第十五章第十六章,和第十七章需要较高的 GPU 内存使用。你可以通过减少代码中训练集的大小来降低内存使用。

一些章节或大多数章节需要其他 Python 库。你可以使用 pip install <name==version> 安装它们,或者根据你的配置使用其他安装程序(如 conda)。以下是所需软件包及其相应版本的完整列表:

  • pandas==1.5.2

  • gensim==4.3.0

  • networkx==2.8.8

  • matplotlib==3.6.3

  • node2vec==0.4.6

  • seaborn==0.12.2

  • scikit-learn==1.2.0

  • deepchem==2.7.1

  • torch-geometric-temporal==0.54.0

  • captum==0.6.0

所有的需求列表可在 GitHub 上查阅:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python。另外,你也可以直接在 Google Colab 上导入笔记本:colab.research.google.com

如果你使用的是本书的电子版,我们建议你自己输入代码,或者访问本书 GitHub 仓库中的代码(链接将在下一节提供)。这样可以帮助你避免与复制粘贴代码相关的潜在错误。

下载示例代码文件

你可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python。如果代码有更新,它将会在 GitHub 仓库中更新。

我们还提供了来自丰富书籍和视频目录的其他代码包,欢迎访问 github.com/PacktPublishing/ 查看!

下载彩色图像

我们还提供了一份包含本书截图和图表的彩色图像的 PDF 文件。你可以在这里下载:packt.link/gaFU6

使用的约定

本书中使用了多种文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“我们初始化了两个列表(visitedqueue),并添加了起始节点。”

代码块设置如下:

DG = nx.DiGraph()
DG.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'F'), ('C', 'G')])

提示或重要说明

显示如图所示。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:虽然我们已尽最大努力确保内容的准确性,但仍可能发生错误。如果你在本书中发现错误,欢迎反馈给我们。请访问 www.packtpub.com/support/errata 填写表单。

copyright@packt.com并附上材料链接。

如果您有兴趣成为作者:如果您在某个主题上有专长,并且有意撰写或为书籍贡献内容,请访问authors.packtpub.com

分享您的想法

阅读完《动手实践图神经网络与 Python》,我们很乐意听到您的想法!请点击这里直接前往亚马逊书评页面分享您的反馈。

您的评价对我们和技术社区非常重要,将帮助我们确保提供优质的内容。

下载这本书的免费 PDF 副本

感谢您购买本书!

您是否喜欢在旅途中阅读,但又无法随身携带纸质书籍?

您的电子书购买是否无法在您选择的设备上兼容?

别担心,现在购买每本 Packt 书籍,您都能免费获得该书的无 DRM PDF 版本。

随时随地,在任何设备上阅读。直接从您喜爱的技术书籍中搜索、复制并粘贴代码到您的应用程序中。

福利不仅仅如此,您还可以独家获得每日的折扣、新闻通讯以及精彩的免费内容。

按照以下简单步骤获取福利:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/9781804617526

  1. 提交您的购买证明

  2. 就是这样!我们会将您的免费 PDF 和其他福利直接发送到您的电子邮件。

第一部分:图学习简介

*年来,数据的图表示在各个领域越来越普及,从社交网络到分子生物学。要充分挖掘这一表示的潜力,深入理解图神经网络GNN)至关重要,因为 GNN 专门用于处理图结构数据。

本部分包含两章,是本书其余部分的坚实基础。它介绍了图学习和 GNN 的概念,并探讨了它们在多个任务和行业中的相关性。同时,它还涵盖了图论的基本概念及其在图学习中的应用,例如图中心性度量。本部分还突出了 GNN 架构相较于其他方法的独特特点和性能。

到本部分结束时,您将全面了解 GNN 在解决许多现实世界问题中的重要性。您将熟悉图学习的基础知识及其在各个领域的应用。此外,您还将全面了解图论的主要概念,这些概念将在后续章节中使用。有了这个坚实的基础,您将为后续更深入的图学习和 GNN 概念做好准备。

本部分包括以下章节:

  • 第一章**, 图学习入门

  • 第二章**, 图神经网络的图论

第一章:图学习入门

欢迎来到我们探索图神经网络GNNs)世界的第一章。在本章中,我们将深入探讨 GNN 的基础知识,了解为什么它们是现代数据分析和机器学习中的关键工具。为此,我们将回答三个基本问题,这将为我们提供对 GNN 的全面理解。

首先,我们将探讨图作为数据表示的意义,以及为何它们在计算机科学、生物学、金融等多个领域得到广泛应用。接下来,我们将深入研究图学习的重要性,了解图学习的不同应用以及图学习技术的不同类别。最后,我们将聚焦于 GNN 家族,突出其独特的特点、性能,以及它与其他方法相比的优势。

到本章结束时,您将清楚了解为什么 GNN 重要,以及它们如何用于解决现实世界中的问题。您还将掌握深入研究更高级话题所需的知识和技能。那么,让我们开始吧!

在本章中,我们将覆盖以下主要主题:

  • 为什么选择图?

  • 为什么选择图学习?

  • 为什么选择图神经网络?

为什么选择图?

我们需要回答的第一个问题是:为什么我们最初对图感兴趣?图论,即图的数学研究,已经成为理解复杂系统和关系的基础工具。图是一种视觉表示,由一组节点(也称为顶点)和组成,这些边连接节点,为表示实体及其关系提供了结构(见图 1.1)。

图 1.1 – 一个包含六个节点和五条边的图示例

图 1.1 – 一个包含六个节点和五条边的图示例

通过将一个复杂系统表示为由实体和它们的相互作用构成的网络,我们可以分析它们之间的关系,从而更深入地理解其潜在结构和模式。图的多功能性使其在多个领域中广受欢迎,包括以下几个领域:

  • 计算机科学中,图可以用来模拟计算机程序的结构,使得理解系统中不同组件如何相互作用变得更加容易

  • 物理学中,图可以用来模拟物理系统及其相互作用,例如粒子之间的关系及其属性

  • 生物学中,图可以用来模拟生物系统,例如代谢途径,作为一个互联实体的网络

  • 社会科学中,图可以用来研究和理解复杂的社会网络,包括社区中个体之间的关系

  • 金融学中,图可以用来分析股市趋势以及不同金融工具之间的关系

  • 工程学中,图形可以用于建模和分析复杂系统,例如交通网络和电力网。

这些领域自然地展示了一个关系结构。例如,图形是社交网络的自然表示:节点是用户,边代表朋友关系。但图形非常通用,它们也可以应用于那些关系结构不太自然的领域,从而解锁新的洞察和理解。

例如,图像可以表示为图形,如图 1.2所示。每个像素是一个节点,边代表邻*像素之间的关系。这使得基于图形的算法能够应用于图像处理和计算机视觉任务。

图 1.2 – 左:原始图像;右:该图像的图形表示

图 1.2 – 左:原始图像;右:该图像的图形表示

类似地,一个句子可以转换为图形,其中节点是单词,边代表相邻单词之间的关系。这种方法在自然语言处理和信息检索任务中非常有用,因为在这些任务中,单词的上下文和含义是至关重要的。

与文本和图像不同,图形没有固定的结构。然而,这种灵活性也使得图形更难处理。缺乏固定结构意味着它们可以有任意数量的节点和边,且没有特定的顺序。此外,图形还可以表示动态数据,其中实体之间的连接会随时间变化。例如,用户与产品之间的关系可能会随着他们的互动而变化。在这种情况下,节点和边会更新以反映现实世界中的变化,比如新增用户、新增产品和新增关系。

在下一节中,我们将深入探讨如何利用图形与机器学习结合,创造有价值的应用程序。

为什么选择图形学习?

图形学习是将机器学习技术应用于图形数据的过程。这个研究领域涵盖了一系列旨在理解和操作图形结构数据的任务。图形学习任务有很多种,以下是其中的一些:

  • 节点分类是一个任务,涉及预测图形中节点的类别(类)。例如,它可以根据用户或物品的特征对其进行分类。在这个任务中,模型在一组已标注节点及其属性上进行训练,并利用这些信息预测未标注节点的类别。

  • 链接预测是一个任务,涉及预测图形中一对节点之间的缺失链接。这在知识图谱补全中非常有用,目标是补全一个包含实体及其关系的图形。例如,它可以用于预测基于社交网络连接的人际关系(朋友推荐)。

  • 图分类是一个任务,涉及将不同的图分类到预定义的类别中。一个例子是分子生物学,其中分子结构可以表示为图,目标是预测它们在药物设计中的性质。在这个任务中,模型会在一组标记图及其属性上进行训练,并利用这些信息来分类未见过的图。

  • 图生成是一个任务,涉及根据一组期望的属性生成新图。其主要应用之一是为药物发现生成新的分子结构。这是通过在一组现有的分子结构上训练模型,并使用它生成新的、未见过的结构来实现的。生成的结构可以评估其作为药物候选物的潜力,并进一步研究。

图学习有许多其他实际应用,可以产生显著的影响。其中最著名的应用之一是推荐系统,在这个应用中,图学习算法根据用户之前的互动以及与其他项目的关系,向用户推荐相关的项目。另一个重要的应用是交通预测,图学习可以通过考虑不同路线和交通方式之间的复杂关系,改善旅行时间预测。

图学习的多功能性和潜力使其成为一个令人兴奋的研究和开发领域。随着大数据集、强大计算资源以及机器学习和人工智能技术的进步,图的研究在*年来迅速发展。因此,我们可以列出四种主要的图学习技术类别[1]:

  • 图信号处理,它将传统的信号处理方法应用于图,如图傅里叶变换和谱分析。这些技术揭示了图的内在属性,如其连接性和结构。

  • 矩阵分解,旨在寻找大型矩阵的低维表示。矩阵分解的目标是识别潜在因素或模式,解释原始矩阵中观察到的关系。这种方法可以提供数据的简洁且易于解释的表示。

  • 随机游走,这是一种用于建模图中实体运动的数学概念。通过在图中模拟随机游走,可以收集节点之间关系的信息。因此,它们常常用于生成机器学习模型的训练数据。

  • 深度学习,这是机器学习的一个子领域,专注于多层神经网络。深度学习方法可以有效地将图数据编码并表示为向量。这些向量随后可以在各种任务中使用,并且具有出色的表现。

需要注意的是,这些技术并非互相排斥,它们的应用通常有重叠。在实践中,它们常常被结合起来,形成能够利用各自优点的混合模型。例如,矩阵分解和深度学习技术可以结合使用,以学习图结构数据的低维表示。

在我们深入研究图学习的世界时,理解任何机器学习技术的基本构建块——数据集是至关重要的。传统的表格数据集,如电子表格,将数据表示为行和列,每一行代表一个数据点。然而,在许多实际场景中,数据点之间的关系与数据点本身一样重要。这就是图数据集的作用。图数据集将数据点表示为图中的节点,数据点之间的关系则表示为边。

让我们以图 1.3中显示的表格数据集为例。

图 1.3 – 作为表格数据集与图数据集的家谱树

图 1.3 – 作为表格数据集与图数据集的家谱树

这个数据集表示了一个家庭中五个成员的信息。每个成员有三个特征(或属性):姓名、年龄和性别。然而,表格版本的数据集并没有显示这些人之间的连接。相反,图版本通过边来表示它们,这使我们能够理解这个家庭中的关系。在许多场合,节点之间的连接对理解数据至关重要,这也是为什么以图的形式表示数据越来越流行的原因。

现在我们已经对图机器学习及其涉及的不同类型的任务有了基本了解,我们可以继续探索解决这些任务的最重要方法之一:图神经网络

为什么是图神经网络?

本书将重点介绍图学习技术中的深度学习家族,通常被称为图神经网络(GNN)。GNN 是深度学习架构中的一个新类别,专门设计用于图结构数据。与主要为文本和图像开发的传统深度学习算法不同,GNN 显式地用于处理和分析图数据集(见图 1.4)。

图 1.4 – 图神经网络管道的高级架构,以图为输入,输出与给定任务对应

图 1.4 – 图神经网络管道的高级架构,以图为输入,输出与给定任务对应

GNN 已成为图学习的强大工具,并在多个任务和行业中展示了出色的结果。一个最引人注目的例子是 GNN 模型如何识别出一种新型抗生素[2]。该模型在 2500 个分子上进行了训练,并在 6000 个化合物的库上进行了测试。它预测了一种名为哈利辛(halicin)的分子应该能够杀死许多耐药性细菌,同时对人类细胞的毒性较低。基于这一预测,研究人员使用哈利辛治疗感染耐药性细菌的小鼠,证明了其有效性,并认为该模型可以用于设计新药物。

GNN 是如何工作的?让我们以社交网络中的节点分类任务为例,就像前面提到的家谱(图 1.3)。在节点分类任务中,GNN 利用来自不同来源的信息来创建每个节点的向量表示。该表示不仅包括原始节点特征(如姓名、年龄和性别),还包括来自边缘特征(如节点之间关系的强度)和全局特征(如网络统计信息)的信息。

这就是为什么 GNN 比传统的图学习技术更高效的原因。GNN 不仅限于原始属性,而是通过邻居节点、边缘和全局特征来丰富原始节点特征,使得表示更加全面且有意义。然后,新的节点表示被用于执行特定任务,例如节点分类、回归或链路预测。

具体而言,GNN 定义了一种图卷积操作,该操作从邻居节点和边缘聚合信息,以更新节点的表示。该操作是迭代执行的,随着迭代次数的增加,模型能够学习到节点之间更复杂的关系。例如,图 1.5 显示了 GNN 如何使用邻居节点计算节点 5 的表示。

图 1.5 – 左:输入图;右:计算图,表示 GNN 如何基于邻居节点计算节点 5 的表示

图 1.5 – 左:输入图;右:计算图,表示 GNN 如何基于邻居节点计算节点 5 的表示

值得注意的是,图 1.5 提供了一个简化的计算图示例。实际上,GNN 有各种类型的变体和层次,每种都有其独特的结构和聚合邻居节点信息的方式。这些不同的 GNN 变体也有各自的优缺点,适用于特定类型的图数据和任务。在选择适当的 GNN 架构时,了解图数据的特性和期望的结果至关重要。

更一般来说,GNN(图神经网络)与其他深度学习技术一样,在应用于特定问题时最为有效。这些问题具有高度复杂性,这意味着学习良好的表示对于解决当前任务至关重要。例如,一个高度复杂的任务可能是从数十亿个选项中为数百万客户推荐合适的产品。另一方面,一些问题,如找到家谱中最年轻的成员,可以在没有任何机器学习技术的情况下解决。

此外,GNNs 需要大量数据才能有效执行。传统的机器学习技术在数据集较小时可能更适合,因为它们对大量数据的依赖较小。然而,这些技术的扩展性不如 GNNs。得益于并行和分布式训练,GNNs 能够处理更大的数据集。它们还可以更有效地利用额外的信息,从而产生更好的结果。

摘要

在本章中,我们回答了三个主要问题:为什么选择图,为什么选择图学习,为什么选择图神经网络?首先,我们探讨了图在表示各种数据类型方面的多功能性,如社交网络和交通网络,还包括文本和图像。我们讨论了图学习的不同应用,包括节点分类和图分类,并强调了图学习技术的四大类。最后,我们强调了 GNN 的意义及其相对于其他技术的优越性,特别是在大规模复杂数据集方面。通过回答这三个主要问题,我们旨在提供一个关于 GNN 重要性以及它们为何成为机器学习中重要工具的全面概述。

第二章《图神经网络的图论》中,我们将深入探讨图论的基础知识,图论为理解 GNN 提供了基础。本章将介绍图论的基本概念,包括邻接矩阵和度数等概念。此外,我们将探讨不同类型的图及其应用,如有向图和无向图,以及加权图和无加权图。

深入阅读

第二章:图论与图神经网络

图论是数学的一个基础分支,研究图和网络的相关内容。图是复杂数据结构的可视化表示,帮助我们理解不同实体之间的关系。图论为我们提供了建模和分析各种现实问题的工具,如交通系统、社交网络和互联网连接等。

在本章中,我们将深入探讨图论的基础知识,涵盖三个主要主题:图的属性、图的概念和图算法。我们将从定义图及其组成部分开始。然后,我们将介绍不同类型的图,并解释它们的属性和应用。接着,我们将讲解基本的图概念、对象和度量,包括邻接矩阵。最后,我们将深入讨论图算法,重点讲解两种基础算法:广度优先搜索BFS)和深度优先搜索DFS)。

到本章结束时,你将建立起图论的坚实基础,从而能够处理更高级的主题并设计图神经网络。

本章我们将涵盖以下主要内容:

  • 介绍图的属性

  • 探索图的概念

  • 探索图算法

技术要求

本章所有的代码示例可以在 GitHub 上找到,链接:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter02

在本书的前言中可以找到在本地机器上运行代码所需的安装步骤。

介绍图的属性

在图论中,图是由一组对象(称为顶点节点)和一组连接(称为)组成的数学结构,边连接顶点对。符号 用于表示一个图,其中 是图, 是顶点集, 是边集。

图的节点可以表示任何对象,如城市、人物、网页或分子,边表示它们之间的关系或连接,例如物理道路、社交关系、超链接或化学键。

本节提供了图的基本属性概述,这些属性将在后续章节中广泛使用。

有向图

图的最基本性质之一是它是有向图还是无向图。在有向图中,也叫有向图digraph),每条边都有方向或指向性。这意味着边连接两个节点,并且有特定的方向,其中一个节点是源节点,另一个是目标节点。相比之下,无向图的边是无方向的,这意味着连接两个顶点的边可以在任意方向上遍历,并且访问节点的顺序无关紧要。

在 Python 中,我们可以使用networkx库来定义一个无向图,代码如下:nx.Graph()

import networkx as nx
G = nx.Graph()
G.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'),  
('B', 'E'), ('C', 'F'), ('C', 'G')])

G图对应以下图示:

图 2.1 – 无向图示例

图 2.1 – 无向图示例

创建有向图的代码类似;我们只需将nx.Graph()替换为nx.DiGraph()

DG = nx.DiGraph()
DG.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'),  
('B', 'E'), ('C', 'F'), ('C', 'G')])

DG图对应以下图示:

图 2.2 – 有向图示例

图 2.2 – 有向图示例

在有向图中,边通常使用箭头来表示其方向性,如在图 2.2中所示。

加权图

图的另一个重要属性是边是否加权。在加权图中,每条边都有一个与之相关的权重或代价。这些权重可以表示各种因素,如距离、旅行时间或成本。

例如,在交通网络中,边的权重可能代表不同城市之间的距离或旅行所需的时间。相比之下,无加权图的边没有权重与之关联。这些类型的图通常用于节点之间关系是二元的情况,其中边仅表示它们之间连接的存在与否。

我们可以修改之前的无向图,给边添加权重。在networkx中,图的边是通过包含起始节点和结束节点的元组以及指定边权重的字典来定义的:

WG = nx.Graph()
WG.add_edges_from([('A', 'B', {"weight": 10}), ('A', 'C', {"weight": 20}), ('B', 'D', {"weight": 30}), ('B', 'E', {"weight": 40}), ('C', 'F', {"weight": 50}), ('C', 'G', {"weight": 60})])
labels = nx.get_edge_attributes(WG, "weight")

WG图对应以下图示:

图 2.3 – 加权图示例

图 2.3 – 加权图示例

连通图

图的连通性是图论中的一个基本概念,与图的结构和功能密切相关。

连通图中,图中任意两个顶点之间都有一条路径。形式上,图是连通的,当且仅当,对于每一对顶点,图中存在从的路径。相反,图是不连通的,如果它不是连通的,这意味着至少有两个顶点之间没有路径连接。

networkx库提供了一个内置函数来验证图是否连通。在下面的例子中,第一个图包含孤立节点(45),与第二个图不同。这个特性在图 2.4中得到了可视化:

G1 = nx.Graph()
G1.add_edges_from([(1, 2), (2, 3), (3, 1), (4, 5)])
print(f"Is graph 1 connected? {nx.is_connected(G1)}")
G2 = nx.Graph()
G2.add_edges_from([(1, 2), (2, 3), (3, 1), (1, 4)])
print(f"Is graph 2 connected? {nx.is_connected(G2)}")

这段代码会输出以下内容:

Is graph 1 connected? False
Is graph 2 connected? True

第一个图是断开的,因为存在节点45。另一方面,第二个图是连通的。这个性质在小图中很容易可视化,如下图所示:

图 2.4 – 左:图 1 含有孤立节点(断开图);右:图 2 中每个节点至少与另一个节点相连(连通图)

图 2.4 – 左:图 1 含有孤立节点(断开图);右:图 2 中每个节点至少与另一个节点相连(连通图)

连通图具有一些有趣的属性和应用。例如,在通信网络中,连通图确保任何两个节点都可以通过路径相互通信。相比之下,断开图可能会包含无法与其他节点通信的孤立节点,这使得设计高效的路由算法变得更加困难。

测量图的连通性有多种方法。最常见的测量方法之一是最小割,即需要删除的最小边数,以使图断开。最小割问题在网络流优化、聚类和社区检测中有着广泛的应用。

图的类型

除了常见的图类型外,还有一些特殊类型的图具有独特的属性和特点:

  • 是一种连通的无向图,没有环路(如图 2.1所示)。由于树中任意两节点之间只有一条路径,树实际上是图的一种特殊情况。树常用于建模层次结构,如家谱、组织结构或分类树。

  • 有根树 是一种树,其中一个节点被指定为根,所有其他顶点通过唯一路径与根相连。根树常用于计算机科学中表示层次数据结构,如文件系统或 XML 文档的结构。

  • 有向无环图 (DAG) 是一种没有环路的有向图(如图 2.2所示)。这意味着边只能按照特定方向遍历,并且没有环路或回路。DAG 通常用于建模任务或事件之间的依赖关系——例如,在项目管理中或计算作业的关键路径时。

  • 二分图 是一种图,其中顶点可以分为两个互不相交的集合,且所有边都连接不同集合中的顶点。二分图常用于数学和计算机科学中建模两种不同对象之间的关系,例如买家与卖家、员工与项目。

  • 完全图是每对顶点之间都有一条边相连的图。完全图常用于组合学中模拟涉及所有可能的配对连接的问题,也用于计算机网络中模拟完全连接的网络。

图 2.5 展示了这些不同类型的图:

图 2.5 – 常见图类型

图 2.5 – 常见图类型

现在我们已经回顾了基本的图类型,接下来让我们探索一些最重要的图对象。理解这些概念将帮助我们更有效地分析和操作图。

探索图概念

在本节中,我们将探讨图论中的一些基本概念,包括图对象(如度数和邻居)、图度量(如中心性和密度),以及邻接矩阵表示法。

基本对象

图论中的一个关键概念是节点的度数,即与该节点相连的边的数量。如果一条边的端点之一是该节点,则称这条边与该节点相交。节点的度数 通常用 表示。它可以在有向图和无向图中定义:

  • 在无向图中,顶点的度数是与之相连的边的数量。需要注意的是,如果节点与自身相连(称为自环),则它会将度数加二。

  • 在有向图中,度数分为两种类型:入度出度。节点的入度(由 表示)表示指向该节点的边的数量,而出度(由 表示)表示从该节点出发的边的数量。在这种情况下,自环会将入度和出度都加一。

入度和出度对于分析和理解有向图至关重要,因为它们提供了关于图中信息或资源分布的洞察。例如,入度较高的节点可能是重要的信息或资源来源。相反,出度较高的节点可能是重要的信息或资源的目的地或消费者。

networkx中,我们可以通过内置方法简单地计算节点的度数、入度或出度。让我们对图 2.1中的无向图和图 2.2中的有向图进行计算:

G = nx.Graph()
G.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'F'), ('C', 'G')])
print(f"deg(A) = {G.degree['A']}")
DG = nx.DiGraph()
DG.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'F'), ('C', 'G')])
print(f"deg^-(A) = {DG.in_degree['A']}")
print(f"deg^+(A) = {DG.out_degree['A']}")

这段代码输出如下结果:

deg(A) = 2
deg^-(A) = 0
deg^+(A) = 2

我们可以将其与图 2.1图 2.2中的图进行比较:节点 连接到两条边 (),但不是它们的目的地 ()。

节点度的概念与邻居的概念相关。邻居是通过一条边直接连接到特定节点的节点。此外,如果两个节点共享至少一个共同的邻居,则称它们为相邻。邻居和相邻的概念对于许多图算法和应用非常基础,例如在两个节点之间寻找路径或识别网络中的簇。

在图论中,路径是连接图中两个(或更多)节点的边的序列。路径的长度是沿路径遍历的边的数量。路径有不同的类型,但其中有两种特别重要:

  • 简单路径是指一个路径,在此路径中,除了起始和结束顶点外,不访问任何节点超过一次。

  • 环路是一个路径,其中第一个和最后一个顶点是相同的。如果图中没有环路(例如树和有向无环图),则称该图为无环图。

度和路径可以用来确定节点在网络中的重要性。这个度量被称为中心性

图度量

中心性量化了图中一个顶点或节点的重要性。它帮助我们基于节点的连接性和对信息或交互流动的影响,识别图中的关键节点。中心性有几种度量方式,每种度量方式从不同的角度衡量节点的重要性:

  • 度中心性是最简单且最常用的中心性度量之一。它被简单地定义为节点的度数。高度中心性表示一个顶点与图中其他顶点的连接非常紧密,因此对网络有着重要的影响。

  • 接*中心性衡量一个节点与图中所有其他节点的接*程度。它对应于目标节点与图中所有其他节点之间最短路径的*均长度。具有高接*中心性的节点能够迅速到达网络中的所有其他顶点。

  • 介数中心性衡量一个节点在图中两个其他节点之间的最短路径上出现的次数。具有高介数中心性的节点充当图中不同部分之间的瓶颈或桥梁。

让我们使用networkx的内置函数在我们之前的图中计算这些度量,并分析结果:

print(f"Degree centrality      = {nx.degree_centrality(G)}")
print(f"Closeness centrality   = {nx.closeness_centrality(G)}")
print(f"Betweenness centrality = {nx.betweenness_centrality(G)}")

之前的代码会打印包含每个节点得分的字典:

Degree centrality      = {'A': 0.333, 'B': 0.5, 'C': 0.5, 'D': 0.167, 'E': 0.167, 'F': 0.167, 'G': 0.167}
Closeness centrality   = {'A': 0.6, 'B': 0.545, 'C': 0.545, 'D': 0.375, 'E': 0.375, 'F': 0.375, 'G': 0.375}
Betweenness centrality = {'A': 0.6, 'B': 0.6, 'C': 0.6, 'D': 0.0, 'E': 0.0, 'F': 0.0, 'G': 0.0}

图中节点的重要性取决于使用的中心性类型。度中心性认为节点更重要,因为它们有比节点更多的邻居。然而,在接*中心性中,节点最为重要,因为它可以通过最短路径到达图中的任何其他节点。另一方面,节点具有相同的介数中心性,因为它们都位于其他节点之间许多最短路径上。

除了这些措施,我们将在接下来的章节中看到如何使用机器学习技术计算一个节点的重要性。然而,这并不是我们将要讨论的唯一指标。

确实,密度是另一个重要的度量,表示图的连通性。它是图中实际边数与最大可能边数的比率。一个高密度的图被认为更为连通,并且比低密度图有更多的信息流动。

计算密度的公式取决于图是否是有向图或无向图。对于一个包含个节点的无向图,最大可能的边数是。对于一个包含个节点的有向图,最大边数是

图的密度是通过边的数量除以最大边数来计算的。例如,图 2**.1中的图有条边,最大可能的边数为。因此,其密度为

一个密集的图的密度接* 1,而一个稀疏的图的密度接* 0。对于密集图或稀疏图没有严格的定义,但一般来说,如果图的密度大于 0.5,则认为它是密集的;如果密度小于 0.1,则认为它是稀疏的。这个指标直接与图的一个基本问题相关:如何表示邻接矩阵

邻接矩阵表示法

邻接矩阵是表示图中边的矩阵,其中每个单元格指示两个节点之间是否有边。该矩阵是一个大小为的方阵,其中是图中节点的数量。单元格中的值表示节点和节点之间有边,而值表示没有边。对于无向图,矩阵是对称的,而对于有向图,矩阵不一定是对称的。

下图表示与图相关的邻接矩阵:

图 2.6 – 邻接矩阵示例

图 2.6 – 邻接矩阵示例

在 Python 中,它可以实现为一个列表的列表,如下面的示例所示:

adj = [[0,1,1,0,0,0,0],
       [1,0,0,1,1,0,0],
       [1,0,0,0,0,1,1],
       [0,1,0,0,0,0,0],
       [0,1,0,0,0,0,0],
       [0,0,1,0,0,0,0],
       [0,0,1,0,0,0,0]]

邻接矩阵是一种简单直观的表示方式,可以轻松地将其可视化为二维数组。使用邻接矩阵的一个主要优点是检查两个节点是否连接是一个常数时间操作。这使得它成为测试图中边存在与否的高效方式。此外,它还用于执行矩阵操作,这对于某些图算法很有用,例如计算两个节点之间的最短路径。

然而,添加或删除节点可能会很昂贵,因为矩阵需要调整大小或移动。使用邻接矩阵的主要缺点之一是其空间复杂度:随着图中节点数量的增加,存储邻接矩阵所需的空间呈指数增长。形式上,我们说邻接矩阵的空间复杂度为,其中表示图中节点的数量。

总的来说,虽然邻接矩阵是表示小型图的有用数据结构,但由于其空间复杂度,对于较大的图来说可能不太实际。此外,添加或删除节点的开销可能使其在动态变化的图中效率低下。

这就是为什么其他表示方式可能会很有帮助的原因。例如,存储图的另一种流行方式是networkx

edge_list = [(0, 1), (0, 2), (1, 3), (1, 4), (2, 5), (2, 6)]

当我们比较两种数据结构在我们的图中的应用时,很明显边列表更简洁。这是因为我们的图是相对稀疏的。另一方面,如果我们的图是完全图,我们将需要 21 个元组,而不是 6 个。这是由空间复杂度解释的,其中是边的数量。边列表在存储稀疏图时更为高效,在这种情况下,边的数量远小于节点的数量。

然而,在边表中检查两个顶点是否相连,需要遍历整个列表,对于拥有大量边的大型图,这也是耗时的。因此,边表更常用于空间有限的应用中。

第三种也是常用的表示方式是邻接表。它由一系列对组成,其中每对表示图中的一个节点及其相邻的节点。根据实现方式,节点对可以存储在链表、字典或其他数据结构中。例如,我们图的邻接表可能如下所示:

adj_list = {
    0: [1, 2],
    1: [0, 3, 4],
    2: [0, 5, 6],
    3: [1],
    4: [1],
    5: [2],
    6: [2]
}

相比邻接矩阵或边表,邻接表有几个优点。首先,空间复杂度是 ,其中 是节点数, 是边数。对于稀疏图,这比邻接矩阵的空间复杂度 更高效。其次,它允许高效地迭代节点的相邻顶点,这在许多图算法中非常有用。最后,添加节点或边可以在常数时间内完成。

然而,检查两个顶点是否相连可能比邻接矩阵慢。这是因为它需要遍历其中一个顶点的邻接表,而对于大型图来说,这可能会非常耗时。

每种数据结构都有其自身的优缺点,这取决于具体的应用和需求。在下一节中,我们将处理图,并介绍两种最基础的图算法。

探索图算法

图算法在解决与图相关的问题时至关重要,比如寻找两个节点之间的最短路径或检测环路。本节将讨论两种图遍历算法:BFS 和 DFS。

广度优先搜索

BFS 是一种图遍历算法,它从根节点开始,首先探索特定层次的所有相邻节点,然后再进入下一层的节点。它通过维护一个待访问节点的队列来工作,并在节点被加入队列时标记该节点已访问。然后,算法出队队列中的下一个节点,并探索其所有相邻节点,如果这些节点还没有被访问过,就将它们加入队列。

BFS 的行为如图 2.7所示:

图 2.7 – 广度优先搜索遍历图的示例

图 2.7 – 广度优先搜索遍历图的示例

现在让我们看看如何在 Python 中实现它:

  1. 我们创建一个空图并使用add_edges_from()方法添加边:

    G = nx.Graph()
    G.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'F'), ('C', 'G')])
    
  2. 我们定义了一个名为bfs()的函数,来实现图的 BFS 算法。该函数接受两个参数:graph对象和搜索的起始节点:

    def bfs(graph, node):
    
  3. 我们初始化两个列表(visitedqueue),并添加起始节点。visited列表用于跟踪在搜索过程中已经访问过的节点,而queue列表则存储需要访问的节点:

        visited, queue = [node], [node]
    
  4. 我们进入一个while循环,直到queue列表为空。在循环内部,我们使用pop(0)方法删除queue列表中的第一个节点,并将结果存储在node变量中:

        while queue:
            node = queue.pop(0)
    
  5. 我们使用for循环遍历该节点的邻居。对于每个尚未访问的邻居,我们将其添加到visited列表,并将其添加到queue列表的末尾,使用append()方法。当完成后,我们返回visited列表:

            for neighbor in graph[node]:
                if neighbor not in visited:
                    visited.append(neighbor)
                    queue.append(neighbor)
        return visited
    
  6. 我们调用bfs()函数,并传入G参数和'A'起始节点:

    bfs(G, 'A')
    
  7. 函数返回按访问顺序排列的已访问节点列表:

    ['A', 'B', 'C', 'D', 'E', 'F', 'G']
    

我们得到的顺序是我们在图 2.7中预期的顺序。

BFS 在无权图中寻找两个节点之间最短路径时特别有用。因为该算法按照节点与起始节点的距离顺序访问节点,所以当目标节点第一次被访问时,必定是沿着从起始节点到目标节点的最短路径。

除了寻找最短路径,BFS 还可以用来检查图是否连通,或寻找图的所有连通分量。它还被应用于诸如网页爬虫、社交网络分析和网络中的最短路径路由等场景。

BFS 的时间复杂度是,其中是节点数,是图中的边数。这对于高连接度图或者稀疏图来说可能是一个显著的问题。为了解决这个问题,已经开发了几种 BFS 的变体,如双向 BFSA*搜索,这些变体使用启发式方法来减少需要探索的节点数。

深度优先搜索

DFS 是一种递归算法,它从根节点开始,在每个分支上尽可能深入,直到无法再深入,然后回溯。

它选择一个节点,探索所有未访问的邻居,访问第一个未被探索的邻居,只有在所有邻居都已被访问过时才回溯。这样,它通过尽可能深入地沿着从起始节点出发的路径进行图的探索,然后再回溯去探索其他分支。这个过程会一直持续,直到所有节点都被探索过。

DFS 的行为如图 2.8所示:

图 2.8 – 深度优先搜索的图遍历示例

图 2.8 – 深度优先搜索的图遍历示例

让我们在 Python 中实现 DFS:

  1. 我们首先初始化一个空的列表visited

    visited = []
    
  2. 我们定义了一个名为dfs()的函数,该函数接受visitedgraphnode作为参数:

    def dfs(visited, graph, node):
    
  3. 如果当前的node不在visited列表中,我们将其添加到该列表:

        if node not in visited:
            visited.append(node)
    
  4. 然后我们遍历当前node的每一个邻居。对于每个邻居,我们递归调用dfs()函数,传入visitedgraph和邻居作为参数:

            for neighbor in graph[node]:
                visited = dfs(visited, graph, neighbor)
    
  5. dfs()函数继续深度优先地遍历图,访问每个节点的所有邻居,直到没有更多未访问的邻居。最后,返回visited列表:

        return visited
    
  6. 我们调用dfs()函数,visited设置为空列表,G作为图,'A'作为起始节点:

    dfs(visited, G, 'A')
    
  7. 该函数返回按访问顺序排列的访问节点列表:

    ['A', 'B', 'D', 'E', 'C', 'F', 'G']
    

再次地,我们得到的顺序与图 2**.8中的预期顺序一致。

DFS 在解决各种问题时非常有用,例如查找连通分量、拓扑排序和解决迷宫问题。它特别适用于在图中查找环,因为它以深度优先的顺序遍历图,只有在遍历过程中某个节点被访问两次时,才说明图中存在环。

和 BFS 一样,它的时间复杂度是,其中是节点的数量,是图中的边的数量。它需要的内存较少,但不能保证最短路径解。最后,与 BFS 不同的是,使用 DFS 时可能会陷入无限循环。

此外,图论中的许多其他算法都基于 BFS 和 DFS,例如 Dijkstra 最短路径算法、Kruskal 最小生成树算法和 Tarjan 强连通分量算法。因此,深入理解 BFS 和 DFS 对于任何想从事图相关工作并开发更高级图算法的人来说,都是至关重要的。

总结

在本章中,我们介绍了图论的基本知识,图论是研究图和网络的数学分支。我们首先定义了图的概念,并解释了不同类型的图,如有向图、加权图和连通图。然后我们介绍了基本的图对象(包括邻居)和度量(如中心性和密度),这些概念用于理解和分析图结构。

此外,我们讨论了邻接矩阵及其不同的表示方法。最后,我们探讨了两个基础的图算法,BFS 和 DFS,它们构成了开发更复杂图算法的基础。

第三章使用 DeepWalk 创建节点表示中,我们将探讨 DeepWalk 架构及其两个组成部分:Word2Vec 和随机游走。我们将首先了解 Word2Vec 架构,然后使用专门的库实现它。接着,我们将深入研究 DeepWalk 算法,并在图上实现随机游走。

第三章:使用 DeepWalk 创建节点表示

DeepWalk机器学习ML)技术在图数据中最早且最成功的应用之一。它引入了像嵌入(embeddings)这样的重要概念,这些概念是图神经网络(GNN)核心的一部分。与传统的神经网络不同,该架构的目标是生成表示,这些表示会被传递给其他模型,后者执行下游任务(例如,节点分类)。

在本章中,我们将学习 DeepWalk 架构及其两个主要组成部分:在自然语言处理NLP)的例子中使用 gensim 库来理解它应该如何使用。

然后,我们将专注于 DeepWalk 算法,看看如何使用层次化 softmaxH-Softmax)提高性能。这个强大的 softmax 函数优化在许多领域都可以找到:当您的分类任务中有大量可能的类别时,它非常有用。我们还将在图上实现随机游走,最后通过一个关于 Zachary’s Karate Club 的端到端监督分类练习来总结。

在本章结束时,您将掌握 Word2Vec 在 NLP 及其他领域中的应用。您将能够使用图的拓扑信息创建节点嵌入,并在图数据上解决分类任务。

在本章中,我们将涵盖以下主要主题:

  • 介绍 Word2Vec

  • DeepWalk 和随机游走

  • 实现 DeepWalk

技术要求

本章中的所有代码示例可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter03。运行代码所需的安装步骤可以在本书的前言部分找到。

介绍 Word2Vec

理解 DeepWalk 算法的第一步是理解其主要组成部分:Word2Vec。

Word2Vec 是自然语言处理(NLP)中最具影响力的深度学习技术之一。由 Tomas Mikolov 等人(谷歌)于 2013 年在两篇不同的论文中发布,它提出了一种新技术,通过使用大量的文本数据集将单词转换为向量(也称为嵌入)。这些表示可以在下游任务中使用,如情感分类。它也是少数几种已获得专利且广受欢迎的机器学习架构之一。

下面是一些 Word2Vec 如何将单词转换为向量的例子:

在这个例子中,我们可以看到,按照欧几里得距离,king(国王)和queen(女王)的词向量距离比king(国王)和woman(女人)更*(4.37 与 8.47)。通常,其他度量方法,如流行的余弦相似度,用于衡量这些单词的相似度。余弦相似度关注的是向量之间的角度,而不考虑它们的大小(长度),这在比较向量时更为有用。其定义如下:

Word2Vec 最令人惊讶的结果之一是其解决类比问题的能力。一个流行的例子是它如何回答问题:“man is to woman, what king is to ___?”(男人与女人的关系,国王与 ___ 的关系?)它可以按如下方式计算:

这并不适用于所有类比,但这一特性可以为使用嵌入进行算术操作带来有趣的应用。

CBOW 与跳字模型的对比

模型必须在一个预设任务上进行训练,以生成这些向量。任务本身不需要具有实际意义:其唯一目标是生成高质量的嵌入。在实践中,这个任务通常与根据特定上下文预测单词相关。

作者提出了两种架构,执行相似任务:

  • 连续袋词(CBOW)模型:这个模型通过上下文(目标词前后出现的单词)来预测一个单词。由于上下文单词的嵌入在模型中会被求和,所以上下文单词的顺序并不重要。作者称,通过使用目标词前后各四个单词,能够获得更好的结果。

  • 连续跳字模型:在这里,我们将一个单词输入模型,并尝试预测其周围的单词。增加上下文单词的范围可以得到更好的嵌入,但也会增加训练时间。

总结来说,以下是两种模型的输入和输出:

图 3.1 – CBOW 和跳字模型架构

图 3.1 – CBOW 和跳字模型架构

一般来说,CBOW 模型被认为训练速度较快,但跳字模型更准确,因为它能够学习不常见的单词。这个话题在 NLP 社区中仍然存在争议:不同的实现方式可能会解决某些情况下 CBOW 的问题。

创建跳字模型

目前,我们将专注于跳字模型,因为它是 DeepWalk 使用的架构。跳字模型以单词对的形式实现,结构如下:,其中 是输入, 是待预测的单词。同一目标词的跳字对数依赖于一个叫做上下文大小的参数,如 图 3.2 所示:

图 3.2 – 跳字模型

图 3.2 – 跳字模型

相同的思路可以应用于文本语料库,而不仅仅是单个句子。

在实际操作中,我们将相同目标词的所有上下文词汇存储在一个列表中,以节省内存。让我们通过一个完整段落的例子来看看这是如何做到的。

在以下示例中,我们为存储在 text 变量中的整个段落创建了 skip-grams。我们将 CONTEXT_SIZE 变量设置为 2,意味着我们将查看目标词前后各两个词:

  1. 让我们开始导入必要的库:

    import numpy as np
    
  2. 然后,我们需要将 CONTEXT_SIZE 变量设置为 2,并引入我们想要分析的文本:

    CONTEXT_SIZE = 2
    text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc eu sem scelerisque, dictum eros aliquam, accumsan quam. Pellentesque tempus, lorem ut semper fermentum, ante turpis accumsan ex, sit amet ultricies tortor erat quis nulla. Nunc consectetur ligula sit amet purus porttitor, vel tempus tortor scelerisque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Quisque suscipit ligula nec faucibus accumsan. Duis vulputate massa sit amet viverra hendrerit. Integer maximus quis sapien id convallis. Donec elementum placerat ex laoreet gravida. Praesent quis enim facilisis, bibendum est nec, pharetra ex. Etiam pharetra congue justo, eget imperdiet diam varius non. Mauris dolor lectus, interdum in laoreet quis, faucibus vitae velit. Donec lacinia dui eget maximus cursus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Vivamus tincidunt velit eget nisi ornare convallis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec tristique ultrices tortor at accumsan.
    """.split()
    
  3. 接下来,我们通过一个简单的 for 循环来创建 skip-grams,考虑 text 中的每个词。列表推导式生成上下文词汇,并将其存储在 skipgrams 列表中:

    skipgrams = []
    for i in range(CONTEXT_SIZE, len(text) - CONTEXT_SIZE):
        array = [text[j] for j in np.arange(i - CONTEXT_SIZE, i + CONTEXT_SIZE + 1) if j != i]
        skipgrams.append((text[i], array))
    
  4. 最后,使用 print() 函数查看我们生成的 skip-grams:

    print(skipgrams[0:2])
    
  5. 这会产生如下输出:

    [('dolor', ['Lorem', 'ipsum', 'sit', 'amet,']), ('sit', ['ipsum', 'dolor', 'amet,', 'consectetur'])]
    

这两个目标词及其对应的上下文,展示了 Word2Vec 输入数据的样子。

Skip-gram 模型

Word2Vec 的目标是生成高质量的词向量。为了学习这些词向量,skip-gram 模型的训练任务包括给定目标词,预测正确的上下文词汇。

假设我们有一个词汇序列 。给定词语 ,看到词语 的概率写作 。我们的目标是最大化在整篇文本中,看到每个上下文词汇给定目标词汇的概率之和:

其中 是上下文向量的大小。

注意

为什么我们在之前的方程式中使用对数概率?将概率转换为对数概率是机器学习(以及计算机科学一般)中常见的技术,主要有两个原因。

加法变成了加法(除法变成了减法)。乘法的计算开销比加法大,因此计算对数概率更快:

计算机存储非常小的数字(如 3.14e-128)并不完全准确,而同样数字的对数(在此案例中为 -127.5)却非常精确。这些小的误差会累积,并在事件极不可能发生时影响最终结果。

总的来说,这个简单的转换让我们在不改变初始目标的情况下,获得了更快的速度和更高的准确性。

基本的 skip-gram 模型使用 softmax 函数来计算给定目标词向量 的上下文词向量 的概率:

其中 是大小为 的词汇表。这个词汇表对应模型尝试预测的唯一词汇列表。我们可以使用 set 数据结构去除重复的词汇来获取这个列表:

vocab = set(text)
VOCAB_SIZE = len(vocab)
print(f"Length of vocabulary = {VOCAB_SIZE}")

这会给我们如下输出:

Length of vocabulary = 121

现在我们已经知道了词汇表的大小,还有一个参数需要定义: ,即词向量的维度。通常,这个值设置在 100 到 1,000 之间。在这个示例中,由于数据集的大小有限,我们将其设置为 10。

Skip-gram 模型由仅两个层组成:

  • 一个 投影层,其权重矩阵为 ,该层以独热编码的词向量作为输入,并返回相应的 -维词嵌入。它充当一个简单的查找表,存储预定义维度的嵌入。

  • 一个 全连接层,其权重矩阵为 ,该层以词嵌入作为输入并输出 -维的 logits。对这些预测应用 softmax 函数,以将 logits 转换为概率。

注意

没有激活函数:Word2Vec 是一个线性分类器,它建模单词之间的线性关系。

我们将 称为独热编码的词向量,作为 输入。相应的词嵌入可以通过简单的投影来计算:

使用 skip-gram 模型,我们可以将先前的概率重写为以下形式:

Skip-gram 模型输出一个 -维向量,这是词汇表中每个单词的条件概率:

在训练过程中,这些概率会与正确的独热编码目标词向量进行比较。通过损失函数(如交叉熵损失)计算的这些值之间的差异会通过网络进行反向传播,以更新权重并获得更好的预测结果。

整个 Word2Vec 架构在以下图示中进行了总结,包含了矩阵和最终的 softmax 层:

图 3.3 – Word2Vec 架构

图 3.3 – Word2Vec 架构

我们可以使用 gensim 库来实现这个模型,gensim 也被官方用于 DeepWalk 的实现。然后我们可以根据前面的文本构建词汇表并训练我们的模型:

  1. 首先,让我们安装 gensim 并导入 Word2Vec 类:

    !pip install -qU gensim
    from gensim.models.word2vec import Word2Vec
    
  2. 我们使用 Word2Vec 对象和 sg=1 参数(skip-gram = 1)初始化一个 skip-gram 模型:

    model = Word2Vec([text],
                     sg=1,   # Skip-gram
                     vector_size=10,
                     min_count=0,
                     window=2,
                     workers=2,
                     seed=0)
    
  3. 检查我们第一个权重矩阵的形状是个好主意。它应当与词汇表的大小以及词嵌入的维度相对应:

    print(f'Shape of W_embed: {model.wv.vectors.shape}')
    
  4. 这会生成以下输出:

    Shape of W_embed = (121, 10)
    
  5. 接下来,我们训练模型 10 轮:

    model.train([text], total_examples=model.corpus_count, epochs=10)
    
  6. 最后,我们可以打印一个词嵌入,以查看该训练的结果是怎样的:

    print('Word embedding =')
    print(model.wv[0])
    
  7. 这将给我们以下输出:

    Word embedding =
    [ 0.06947816 -0.06254371 -0.08287395  0.07274164 -0.09449387  0.01215031  -0.08728203 -0.04045384 -0.00368091 -0.0141237 ]
    

虽然这种方法在小词汇量的情况下效果很好,但将完整 softmax 函数应用于数百万个词汇(词汇量)的计算成本在大多数情况下是太高昂的。长期以来,这一点一直是开发准确语言模型的一个限制因素。幸运的是,已经设计出其他方法来解决这个问题。

Word2Vec(以及 DeepWalk)实现了其中一种技术,称为 H-Softmax。这种技术不是直接计算每个词的概率的*坦 softmax,而是使用一个二叉树结构,其中叶子是单词。更有趣的是,可以使用哈夫曼树,其中罕见的单词存储在比常见单词更深的层级。在大多数情况下,这显著加快了至少 50 倍的单词预测速度。

gensim 中可以通过设置 hs=1 来激活 H-Softmax。

这是 DeepWalk 结构中最困难的部分。但在我们实施之前,我们需要一个额外的组件:如何创建我们的训练数据。

DeepWalk 和随机游走

Perozzi 等人在 2014 年提出了 DeepWalk,迅速在图研究领域中广受欢迎。受到*年来在自然语言处理中的进展的启发,DeepWalk 在多个数据集上一直优于其他方法。尽管此后提出了更高性能的架构,DeepWalk 是一个可以快速实现并解决许多问题的简单可靠基准。

DeepWalk 的目标是以无监督方式生成节点的高质量特征表示。这种结构受到 NLP 中 Word2Vec 的启发。然而,我们的数据集由节点组成,而不是单词。这就是为什么我们使用随机游走生成像句子一样的有意义节点序列。以下图示说明了句子和图之间的关系:

图 3.4 – 句子可以被表示为图形

图 3.4 – 句子可以被表示为图形

随机游走是通过在每一步随机选择一个相邻节点来产生的节点序列。因此,节点可以在同一序列中出现多次。

为什么随机游走很重要?即使节点是随机选择的,它们经常在序列中一起出现的事实意味着它们彼此接*。根据网络同质性假设,彼此接*的节点是相似的。这在社交网络中尤为明显,人们通过朋友和家人连接在一起。

这个想法是 DeepWalk 算法的核心:当节点彼此接*时,我们希望获得高相似度分数。相反,当它们相距较远时,我们希望得到低分数。

让我们使用 networkx 图实现一个随机游走函数:

  1. 让我们导入所需的库并为了可重复性初始化随机数生成器:

    import networkx as nx
    import matplotlib.pyplot as plt
    import numpy as np
    import random
    random.seed(0)
    
  2. 我们通过使用固定节点数目(10)和预定义的两节点之间创建边的概率(0.3)来生成一个随机图。

    G = nx.erdos_renyi_graph(10, 0.3, seed=1, directed=False)
    
  3. 我们绘制这个随机图来看它的样子:

    plt.figure(dpi=300)
    plt.axis('off')
    nx.draw_networkx(G,
                     pos=nx.spring_layout(G, seed=0),
                     node_size=600,
                     cmap='coolwarm',
                     font_size=14,
                     font_color='white'
                     )
    

这将生成以下图形:

图 3.5 – 随机图

图 3.5 – 随机图

  1. 让我们用一个简单的函数来实现随机游走。这个函数有两个参数:起始节点(start)和游走的长度(length)。每一步,我们随机选择一个邻*的节点(使用np.random.choice),直到游走完成:

    def random_walk(start, length):
        walk = [str(start)]  # starting node
        for i in range(length):
            neighbors = [node for node in G.neighbors(start)]
            next_node = np.random.choice(neighbors, 1)[0]
            walk.append(str(next_node))
            start = next_node
        return walk
    
  2. 接下来,我们用起始节点0和长度为10来打印这个函数的结果:

    print(random_walk(0, 10))
    
  3. 这将生成以下列表:

    ['0', '4', '3', '6', '3', '4', '7', '8', '7', '4', '9']
    

我们可以看到,某些节点,如09,经常一起出现。考虑到这是一个同质性图,意味着它们是相似的。这正是我们希望通过 DeepWalk 捕捉的关系类型。

现在我们已经分别实现了 Word2Vec 和随机游走,让我们将它们结合起来创建 DeepWalk。

实现 DeepWalk

现在我们已经对架构中的每个组件有了很好的理解,让我们用它来解决一个机器学习问题。

我们将使用的数据集是 Zachary 的空手道俱乐部。它简单地表示 1970 年代 Wayne W. Zachary 研究的一个空手道俱乐部内的关系。这是一个社交网络,每个节点是一个成员,而在俱乐部外部互动的成员是相互连接的。

在这个例子中,俱乐部被分成了两组:我们希望通过查看每个成员的连接,将正确的组分配给每个成员(节点分类):

  1. 让我们使用nx.karate_club_graph()导入数据集:

    G = nx.karate_club_graph()
    
  2. 接下来,我们需要将字符串类型的类别标签转换为数值(Mr. Hi = 0Officer = 1):

    labels = []
    for node in G.nodes:
        label = G.nodes[node]['club']
        labels.append(1 if label == 'Officer' else 0)
    
  3. 让我们使用新的标签绘制这个图:

    plt.figure(figsize=(12,12), dpi=300)
    plt.axis('off')
    nx.draw_networkx(G,
                     pos=nx.spring_layout(G, seed=0),
                     node_color=labels,
                     node_size=800,
                     cmap='coolwarm',
                     font_size=14,
                     font_color='white'
                     )
    

图 3.6 – Zachary’s Karate Club

图 3.6 – Zachary’s Karate Club

  1. 下一步是生成我们的数据集,即随机游走。我们希望尽可能全面,这就是为什么我们会为图中的每个节点创建80个长度为10的随机游走:

    walks = []
    for node in G.nodes:
        for _ in range(80):
            walks.append(random_walk(node, 10))
    
  2. 让我们打印一个游走来验证它是否正确:

    print(walks[0])
    
  3. 这是生成的第一次随机游走:

    ['0', '19', '1', '2', '0', '3', '2', '8', '33', '14', '33']
    
  4. 最后一步是实现 Word2Vec。在这里,我们使用先前看到的带有 H-Softmax 的 skip-gram 模型。你可以调整其他参数来提高嵌入的质量:

    model = Word2Vec(walks,
                     hs=1,   # Hierarchical softmax
                     sg=1,   # Skip-gram
                     vector_size=100,
                     window=10,
                     workers=2,
                     seed=0)
    
  5. 然后,模型只需要在我们生成的随机游走上进行训练。

    model.train(walks, total_examples=model.corpus_count, epochs=30, report_delay=1)
    
  6. 现在我们的模型已经训练完成,让我们看看它的不同应用。第一个应用是允许我们找到与给定节点最相似的节点(基于余弦相似度):

    print('Nodes that are the most similar to node 0:')
    for similarity in model.wv.most_similar(positive=['0']):
        print(f'   {similarity}')
    

这将产生以下输出:与 node 0 最相似的节点

   ('4', 0.6825815439224243)
   ('11', 0.6330500245094299)
   ('5', 0.6324777603149414)
   ('10', 0.6097837090492249)
   ('6', 0.6096848249435425)
   ('21', 0.5936519503593445)
   ('12', 0.5906376242637634)
   ('3', 0.5797219276428223)
   ('16', 0.5388344526290894)
   ('13', 0.534131646156311)

另一个重要的应用是计算两个节点之间的相似度分数。可以如下进行:

# Similarity between two nodes
print(f"Similarity between node 0 and 4: {model.wv.similarity('0', '4')}")

这段代码直接给出了两个节点之间的余弦相似度:

Similarity between node 0 and 4: 0.6825816631317139

我们可以使用t 分布随机邻居嵌入t-SNE)绘制结果嵌入图,将这些高维向量可视化为 2D:

  1. 我们从sklearn导入TSNE类:

    from sklearn.manifold import TSNE
    
  2. 我们创建两个数组:一个用于存储词嵌入,另一个用于存储标签:

    nodes_wv = np.array([model.wv.get_vector(str(i)) for i in range(len(model.wv))])
    labels = np.array(labels)
    
  3. 接下来,我们使用两维(n_components=2)的 t-SNE 模型对嵌入进行训练:

    tsne = TSNE(n_components=2,
                learning_rate='auto',
                init='pca',
                random_state=0).fit_transform(nodes_wv)
    
  4. 最后,让我们绘制训练好的 t-SNE 模型生成的二维向量,并标注对应的标签:

    plt.figure(figsize=(6, 6), dpi=300)
    plt.scatter(tsne[:, 0], tsne[:, 1], s=100, c=labels, cmap="coolwarm")
    plt.show()
    

图 3.7 – 节点的 t-SNE 图

图 3.7 – 节点的 t-SNE 图

这个图表相当鼓舞人心,因为我们可以看到一个清晰的分界线将两个类别分开。一个简单的机器学习算法应该能够在足够的样本(训练数据)下对这些节点进行分类。让我们实现一个分类器,并在我们的节点嵌入上进行训练:

  1. 我们从sklearn导入了一个随机森林模型,这是分类任务中的一个流行选择。我们将使用准确率作为评估此模型的指标:

    from sklearn.ensemble import RandomForestClassifier
    from sklearn.metrics import accuracy_score
    
  2. 我们需要将嵌入分成两组:训练数据和测试数据。一种简单的方法是创建如下的掩码:

    train_mask = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28]
    test_mask = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 30, 31, 32, 33]
    
  3. 接下来,我们在训练数据上使用适当的标签训练随机森林分类器:

    clf = RandomForestClassifier(random_state=0)
    clf.fit(nodes_wv[train_mask], labels[train_mask])
    
  4. 最后,我们基于准确率对训练好的模型在测试数据上进行评估:

    y_pred = clf.predict(nodes_wv[test_mask])
    accuracy_score(y_pred, labels[test_mask])
    
  5. 这是我们分类器的最终结果:

    0.9545454545454546
    

我们的模型获得了 95.45%的准确率,考虑到我们给出的不利训练/测试数据分割,结果相当不错。虽然仍有改进空间,但这个例子展示了 DeepWalk 的两个有用应用:

  • 使用嵌入和余弦相似度发现节点之间的相似性(无监督学习)

  • 将这些嵌入作为数据集用于节点分类等监督任务

正如我们在接下来的章节中将看到的,学习节点表示的能力为设计更深层次、更复杂的架构提供了很大的灵活性。

总结

在本章中,我们了解了 DeepWalk 的架构及其主要组件。然后,我们通过随机游走将图数据转化为序列,应用强大的 Word2Vec 算法。生成的嵌入可以用来查找节点之间的相似性,或者作为其他算法的输入。特别是,我们使用监督学习方法解决了一个节点分类问题。

第四章《使用节点 2Vec 中的带偏随机游走改进嵌入》中,我们将介绍基于 Word2Vec 的第二种算法。与 DeepWalk 的区别在于,随机游走可以倾向于更多或更少的探索,这直接影响到生成的嵌入。我们将在一个新示例中实现此算法,并将其表示与使用 DeepWalk 获得的表示进行比较。

进一步阅读

  • [1] B. Perozzi, R. Al-Rfou, 和 S. Skiena, DeepWalk, 2014 年 8 月. DOI: 10.1145/2623330.2623732. 访问地址:arxiv.org/abs/1403.6652.

第二部分:基础知识

在本书的第二部分,我们将深入探讨使用图学习构建节点表示的过程。我们将从探索传统的图学习技术开始,借鉴自然语言处理领域的进展。我们的目标是理解这些技术如何应用于图结构,以及如何利用这些技术构建节点表示。

我们接下来将讨论如何将节点特征融入到我们的模型中,并探索它们如何用于构建更准确的表示。最后,我们将介绍两种最基本的 GNN 架构,即图卷积网络GCN)和图注意力网络GAT)。这两种架构是许多最先进的图学习方法的基石,并为接下来的内容提供了坚实的基础。

到本部分结束时,您将更深入地理解如何使用传统的图学习技术,如随机游走,来创建节点表示并开发图应用。此外,您还将学习如何使用 GNN 构建更强大的表示。您将接触到两种关键的 GNN 架构,并了解它们如何用于解决各种基于图的任务。

本部分包括以下章节:

  • 第三章**, 使用 DeepWalk 创建节点表示

  • 第四章**, 在 Node2Vec 中通过带偏随机游走改进嵌入

  • 第五章**, 使用普通神经网络包含节点特征

  • 第六章**, 介绍图卷积网络

  • 第七章**, 图注意力网络

第四章:在 Node2Vec 中通过偏向随机游走改善嵌入

Node2Vec是一种主要基于 DeepWalk 的架构。在上一章中,我们了解了这个架构的两个主要组成部分:随机游走和 Word2Vec。如何提高我们嵌入的质量呢?有趣的是,这并不是通过更多的机器学习来实现的。相反,Node2Vec 对随机游走的生成方式进行了关键性的修改。

在本章中,我们将讨论这些修改以及如何为给定图找到最佳参数。我们将实现 Node2Vec 架构,并与在 Zachary 的空手道俱乐部上使用 DeepWalk 进行比较。这将帮助你深入理解这两种架构之间的差异。最后,我们将使用这项技术构建一个真实的应用:一个由 Node2Vec 驱动的电影推荐系统RecSys)。

到本章结束时,你将知道如何在任何图数据集上实现 Node2Vec,并且如何选择合适的参数。你将理解为什么这个架构通常比 DeepWalk 表现得更好,并且如何将其应用于构建创意应用。

本章将涵盖以下内容:

  • 介绍 Node2Vec

  • 实现 Node2Vec

  • 构建电影推荐系统

技术要求

本章中的所有代码示例都可以在 GitHub 上的github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter04找到。

在本书的前言中可以找到在本地机器上运行代码所需的安装步骤。

介绍 Node2Vec

Node2Vec 是由斯坦福大学的 Grover 和 Leskovec 于 2016 年提出的[1]。它保留了 DeepWalk 的两个主要组成部分:随机游走和 Word2Vec。不同之处在于,在 Node2Vec 中,随机游走并不是均匀分布的,而是经过精心偏向的。我们将在接下来的两节中看到这些偏向的随机游走为什么表现更好,以及如何实现它们:

  • 定义邻域

  • 在随机游走中引入偏向

让我们从质疑我们直观的邻域概念开始。

定义邻域

如何定义节点的邻域?Node2Vec 中引入的关键概念是邻域的灵活定义。直观上,我们认为邻域是离初始节点较*的某个区域,但在图的背景下,“*”到底意味着什么呢?我们以以下图为例:

图 4.1 – 随机图示例

图 4.1 – 随机图示例

我们想要探索节点A邻域中的三个节点。这个探索过程也叫做采样策略

  • 一个可能的解决方案是考虑连接的三个最接*节点。在这种情况下, 的邻域,标为 ,将是

  • 另一种可能的抽样策略包括首先选择与先前节点不相邻的节点。在我们的例子中, 的邻域将是

换句话说,我们希望在第一个案例中实现广度优先搜索BFS),在第二个案例中实现深度优先搜索DFS)。关于这些算法和实现的更多信息,请参见第二章**,用于图神经网络的图论

在这里需要注意的重要一点是,这些抽样策略具有相反的行为:BFS 关注节点周围的局部网络,而 DFS 则建立了图的更宏观视图。考虑到我们对邻域的直觉定义,很容易就会简单地丢弃 DFS。然而,Node2Vec 的作者认为这将是一个错误:每种方法捕获到了网络的不同但有价值的表示。

他们建立了这些算法与两个网络属性之间的联系:

  • 结构等价性,即如果节点共享许多相同的邻居,则节点在结构上等价。因此,如果它们共享许多邻居,则它们的结构等价性更高。

  • 如前所述,同质性表示类似的节点更有可能相连。

他们认为 BFS 是理想的选择,因为它强调结构等价性,这种策略只查看相邻节点。在这些随机游走中,节点经常重复出现并保持相*。相反,DFS 通过创建远程节点序列强调异质性。这些随机游走可以抽样远离源节点的节点,因此变得不太代表性。这就是为什么我们寻求在这两个属性之间取得*衡的原因:同质性可能对理解某些图更有帮助,反之亦然。

如果您对这种连接感到困惑,您并不孤单:几篇论文和博客错误地认为 BFS 强调同质性,DFS 与结构等价性相关联。无论如何,我们考虑将同质性和结构等价性结合的图形为所需的解决方案。这就是为什么,无论这些连接如何,我们都希望使用两种抽样策略来创建我们的数据集。

让我们看看如何实施它们来生成随机游走。

引入随机游走中的偏差

作为提醒,随机游走是在图中随机选择的节点序列。它们有一个起点,这也可以是随机的,并且有一个预定义的长度。在这些行走中经常一起出现的节点就像在句子中一起出现的单词:根据同质性假设,它们共享相似的含义,因此具有相似的表示。

在 Node2Vec 中,我们的目标是使这些随机游走的偏差指向以下其中一个方向:

  • 提升那些与前一个节点不连接的节点(类似于 DFS)

  • 提升那些与前一个节点接*的节点(类似于 BFS)

让我们以图 4.2为例。当前节点为,前一个节点为,未来节点为。我们注意到,这是从节点到节点的未归一化转移概率。这个概率可以分解为,其中是节点和节点之间的搜索偏差,而是从节点到节点的边的权重。

图 4.2 – 随机图示例

图 4.2 – 随机图示例

在 DeepWalk 中,对于任意一对节点,我们有。而在 Node2Vec 中,的值是基于节点间的距离和两个额外参数定义的:,即返回参数,以及,即进出参数。它们的作用是分别*似 DFS 和 BFS。

这里是值的定义方式:

这里,是节点和节点之间的最短路径距离。我们可以按照如下方式更新前图中的未归一化转移概率:

图 4.3 – 带有转移概率的图

图 4.3 – 带有转移概率的图

让我们解密这些概率:

  • 随机游走从节点开始,现在到达节点。回到前一个节点的概率由参数控制。它越高,随机游走越倾向于探索新节点,而不是重复相同的节点,看起来更像是 DFS。

  • 去往的未归一化概率为,因为该节点位于我们前一个节点的直接邻域中。

  • 最后,去往节点的概率由参数控制。它越高,随机游走越倾向于集中在靠*前一个节点的节点上,看起来更像是 BFS。

理解这一点的最佳方式是实际实现这个架构并调整参数。让我们一步一步地在 Zachary 的空手道俱乐部(来自前一章的图)上实现,如 图 4.4 所示:

图 4.4 – Zachary 的空手道俱乐部

图 4.4 – Zachary 的空手道俱乐部

请注意,这是一个无权网络,这就是为什么转移概率仅由搜索偏置决定的原因。

首先,我们希望创建一个函数,根据前一个节点、当前节点和两个参数 ,在图中随机选择下一个节点。

  1. 我们首先导入所需的库:networkxrandomnumpy

    import networkx as nx
    import random
    random.seed(0)
    import numpy as np
    np.random.seed(0)
    G = nx.erdos_renyi_graph(10, 0.3, seed=1, directed=False)
    
  2. 我们用我们的参数列表定义了 next_node 函数:

    def next_node(previous, current, p, q):
    
  3. 我们从当前节点中获取邻居节点列表,并初始化 alpha 值列表:

            neighbors = list(G.neighbors(current))
            alphas = []
    
  4. 对于每个邻居,我们需要计算适当的 alpha 值:如果该邻居是前一个节点,使用 ;如果该邻居与前一个节点相连,使用 ;否则使用

        for neighbor in neighbors:
            if neighbor == previous:
                alpha = 1/p
            elif G.has_edge(neighbor, previous):
                alpha = 1
            else:
                alpha = 1/q
            alphas.append(alpha)
    
  5. 我们将这些值标准化以创建概率:

    probs = [alpha / sum(alphas) for alpha in alphas]
    
  6. 我们根据前一步计算出的转移概率,使用 np.random.choice() 随机选择下一个节点并返回:

        next = np.random.choice(neighbors, size=1, p=probs)[0]
        return next
    

在测试该函数之前,我们需要生成整个随机游走的代码。

我们生成这些随机游走的方式与前一章看到的类似。不同之处在于,下一个节点是通过 next_node() 函数选择的,该函数需要额外的参数:,以及前一个和当前节点。这些节点可以通过查看 walk 变量中添加的最后两个元素轻松获得。为了兼容性,我们还返回字符串而不是整数。

这是 random_walk() 函数的新版本:

def random_walk(start, length, p, q):
    walk = [start]
    for i in range(length):
        current = walk[-1]
        previous = walk[-2] if len(walk) > 1 else None
        next = next_node(previous, current, p, q)
        walk.append(next)
    return [str(x) for x in walk]

我们现在拥有了生成随机游走的所有元素。让我们尝试一次长度为 5 的随机游走,,以及

random_walk(0, 8, p=1, q=1)

该函数返回以下序列:

[0, 4, 7, 6, 4, 5, 4, 5, 6]

由于每个邻*节点具有相同的转移概率,因此这应该是随机的。使用这些参数,我们可以复现完全相同的 DeepWalk 算法。

现在,让我们通过使用 来让它们更倾向于返回到前一个节点:

random_walk(0, 8, p=1, q=10)

该函数返回以下序列:

[0, 9, 1, 9, 1, 9, 1, 0, 1]

这次,随机游走探索了图中的更多节点。你可以看到,它永远不会返回到前一个节点,因为使用 时其概率较低:

random_walk(0, 8, p=10, q=1)

该函数返回以下序列:

[0, 1, 9, 4, 7, 8, 7, 4, 6]

让我们看看如何在实际示例中使用这些属性,并将其与 DeepWalk 进行比较。

实现 Node2Vec

现在我们有了生成偏置随机游走的函数,Node2Vec 的实现与 DeepWalk 非常相似。它们如此相似,以至于我们可以重用相同的代码,并使用 创建序列,将 DeepWalk 作为 Node2Vec 的特例来实现。让我们用 Zachary 的空手道俱乐部来做这个任务:

与上一章一样,我们的目标是正确地将俱乐部的每个成员分类为两组之一(“Mr. Hi”和“Officer”)。我们将使用 Node2Vec 提供的节点嵌入作为机器学习分类器(此处为随机森林)的输入。

让我们一步一步地看如何实现:

  1. 首先,我们需要安装gensim库来使用 Word2Vec。这次,我们将使用 3.8.0 版本以确保兼容性:

    !pip install -qI gensim==3.8.0
    
  2. 我们导入所需的库:

    from gensim.models.word2vec import Word2Vec
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.metrics import accuracy_score
    
  3. 我们加载数据集(Zachary 的空手道俱乐部):

    G = nx.karate_club_graph()
    
  4. 我们将节点标签转换为数值(01):

    labels = []
    for node in G.nodes:
        label = G.nodes[node]['club']
        labels.append(1 if label == 'Officer' else 0)
    
  5. 我们生成了一个随机游走列表,如前所示,使用我们的random_walk()函数对图中的每个节点进行 80 次随机游走。参数 如此处所指定(分别为 2 和 1):

    walks = []
    for node in G.nodes:
        for _ in range(80):
            walks.append(random_walk(node, 10, 3, 2))
    
  6. 我们创建了一个 Word2Vec 实例(一个 skip-gram 模型),并使用了分层的softmax函数:

    node2vec = Word2Vec(walks,
                    hs=1,   # Hierarchical softmax
                    sg=1,   # Skip-gram
                    vector_size=100,
                    window=10,
                    workers=2,
                    min_count=1,
                    seed=0)
    
  7. Skip-gram 模型在我们生成的序列上训练了30个 epoch:

    node2vec.train(walks, total_examples=node2vec.corpus_count, epochs=30, report_delay=1)
    
  8. 我们创建了用于训练和测试分类器的掩码:

    train_mask = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24]
    train_mask_str = [str(x) for x in train_mask]
    test_mask = [0, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 26, 27, 28, 29, 30, 31, 32, 33]
    test_mask_str = [str(x) for x in test_mask]
    labels = np.array(labels)
    
  9. 随机森林分类器在训练数据上进行训练:

    clf = RandomForestClassifier(random_state=0)
    clf.fit(node2vec.wv[train_mask_str], labels[train_mask])
    
  10. 我们根据测试数据的准确性来评估它:

    y_pred = clf.predict(node2vec.wv[test_mask_str])
    acc = accuracy_score(y_pred, labels[test_mask])
    print(f'Node2Vec accuracy = {acc*100:.2f}%')
    

为了实现 DeepWalk,我们可以用 重复相同的过程。然而,为了公*比较,我们不能只使用单一的准确率评分。实际上,涉及到很多随机过程——我们可能会运气不好,得到来自最差模型的更好结果。

为了限制结果的随机性,我们可以重复这一过程 100 次并取*均值。这个结果要稳定得多,甚至可以包括标准差(使用np.std())来衡量准确率评分的变化性。

但在我们开始之前,让我们玩一个游戏。在前一章中,我们讨论了 Zachary 的空手道俱乐部作为一个同质网络。这个属性由 DFS 强调,DFS 的鼓励是通过增大参数 来实现的。如果这一声明以及 DFS 与同质性之间的联系是正确的,我们应该会在较高的 值下得到更好的结果。

我重复进行了相同的实验,参数 在 1 到 7 之间变化。在一个真实的机器学习项目中,我们会使用验证数据来进行参数搜索。在这个例子中,我们使用测试数据,因为这个研究已经是我们的最终应用。

下表总结了结果:

图 4.5 – 不同 p 和 q 值下的*均准确度和标准差

图 4.5 – 不同 p 和 q 值下的*均准确度和标准差

有几个值得注意的结果:

  • DeepWalk()的表现比这里讨论的任何其他的组合都要差。这在这个数据集中是成立的,并且显示了有偏随机游走的有效性。然而,这并不总是如此:在其他数据集中,无偏的随机游走也可能表现得更好。

  • 高值的会带来更好的性能,这验证了我们的假设。知道这是一个社交网络,强烈暗示将我们的随机游走偏向同质性是一种不错的策略。在处理这种类型的图时,这是需要牢记的一点。

随意尝试调整参数,看看是否能找到其他有趣的结果。我们可以尝试使用非常高的)值,或者相反,尝试在 0 到 1 之间调整的值。

Zachary 的空手道俱乐部是一个基本的数据集,但在接下来的章节中,我们将看到如何利用这项技术构建更有趣的应用。

构建一个电影推荐系统

GNNs 最受欢迎的应用之一是推荐系统(RecSys)。如果你想一下 Word2Vec 的基础(因此也包括 DeepWalk 和 Node2Vec),目标是生成具有衡量相似性能力的向量。将电影编码代替单词,你就可以突然询问哪些电影最类似于给定的输入标题。这听起来很像一个推荐系统,不是吗?

但是如何编码电影呢?我们想要创建(有偏的)电影随机游走,但这需要一个图形数据集,其中相似的电影相互连接。这并不容易找到。

另一种方法是查看用户评分。构建基于评分的图的技术有很多种:双分图、基于点对点互信息的边等。在本节中,我们将实现一种简单直观的方法:喜欢相同电影的用户之间建立连接。然后,我们将使用这个图来学习电影嵌入,采用 Node2Vec 方法:

  1. 首先,让我们下载一个数据集。MovieLens [2]是一个流行的选择,它的小版本(2018 年 9 月)包括 100,836 个评分,9,742 部电影和 610 个用户。我们可以通过以下 Python 代码下载它:

    from io import BytesIO
    from urllib.request import urlopen
    from zipfile import ZipFile
    url = 'https://files.grouplens.org/datasets/movielens/ml-100k.zip'
    with urlopen(url) as zurl:
        with ZipFile(BytesIO(zurl.read())) as zfile:
            zfile.extractall('.')
    
  2. 我们感兴趣的有两个文件:ratings.csvmovies.csv。第一个文件存储了用户的所有评分,第二个文件则允许我们将电影标识符转换为标题。

  3. 让我们通过使用pandaspd.read_csv()导入它们,看看它们是什么样的:

    import pandas as pd
    ratings = pd.read_csv('ml-100k/u.data', sep='\t', names=['user_id', 'movie_id', 'rating', 'unix_timestamp'])
    ratings
    
  4. 这将给我们以下输出:

         user_id movie_id rating unix_timestamp
    0     196      242      3      881250949
    1     186      302      3      891717742
    2      22      377      1      878887116
    ...    ...     ...     ...      ...
    99998  13      225      2      882399156
    99999  12      203      3      879959583
    100000 rows × 4 columns
    
  5. 现在让我们导入movies.csv

    movies = pd.read_csv('ml-100k/u.item', sep='|', usecols=range(2), names=['movie_id', 'title'], encoding='latin-1')
    
  6. 这个数据集给我们带来了如下输出:

    movies
         movie_id      title
    0      1      Toy Story (1995)
    1      2      GoldenEye (1995)
    2      3      Four Rooms (1995)
    ...      ...      ...
    1680      1681      You So Crazy (1994)
    1681      1682      Scream of Stone (Schrei aus Stein) (1991)
    1682 rows × 2 columns
    
  7. 在这里,我们想查看那些被相同用户喜欢的电影。这意味着像 1、2、3 的评分并不太相关。我们可以丢弃这些,只保留评分为 4 和 5 的电影:

    ratings = ratings[ratings.rating >= 4]
    ratings
    
  8. 这将给我们以下输出:

         user_id   movie_id    rating      unix_timestamp
    5      298      474      4      884182806
    7      253      465      5      891628467
    11     286      1014     5      879781125
    ...      ...      ...      ...      ...
    99991      676      538      4      892685437
    99996      716      204      5      879795543
    55375 rows × 4 columns
    
  9. 现在我们有 48,580 个评分,来自 610 个用户。下一步是计算每当两部电影被同一个用户喜欢时的次数。我们会对数据集中的每个用户重复此过程。

  10. 为了简化,我们将使用一个defaultdict数据结构,它会自动创建缺失的条目,而不是抛出错误。我们将用这个结构来计算一同被喜欢的电影:

    from collections import defaultdict
    pairs = defaultdict(int)
    
  11. 我们遍历数据集中的所有用户:

    for group in ratings.groupby("userId"):
    
  12. 我们检索当前用户喜欢的电影列表:

    user_movies = list(group[1]["movieId"])
    
  13. 每当一对电影在同一列表中一起出现时,我们就会增加一个特定于该电影对的计数器:

    for i in range(len(user_movies)):
                for j in range(i+1, len(user_movies)):
                    pairs[(user_movies[i], user_movies[j])] += 1
    
  14. pairs对象现在存储了两部电影被同一用户喜欢的次数。我们可以利用这些信息按照以下方式构建图的边:

  15. 我们使用networkx库创建一个图:

    G = nx.Graph()
    
  16. 对于我们pairs结构中的每一对电影,我们解包这两部电影及其对应的分数:

    for pair in pairs:
        movie1, movie2 = pair
        score = pairs[pair]
    
  17. 如果该分数大于 10,我们会根据该分数向图中添加加权链接,将这两部电影连接起来。我们不考虑低于 10 的分数,因为这会生成一个大图,其中的连接意义不大:

    if score >= 20:
        G.add_edge(movie1, movie2, weight=score)
    
  18. 我们创建的图包含 410 个节点(电影)和 14,936 条边。现在我们可以在上面训练 Node2Vec,学习节点嵌入!

我们可以重用上一节中的实现,但实际上有一个专门用于 Node2Vec 的 Python 库(也叫node2vec)。我们在这个示例中尝试使用它:

  1. 我们安装node2vec库并导入Node2Vec类:

    !pip install node2vec
    from node2vec import Node2Vec
    
  2. 我们创建了一个Node2Vec实例,它将根据参数自动生成带偏的随机游走:

    node2vec = Node2Vec(G, dimensions=64, walk_length=20, num_walks=200, p=2, q=1, workers=1)
    
  3. 我们在这些带偏的随机游走上训练一个模型,窗口大小为 10(前后各 5 个节点):

    model = node2vec.fit(window=10, min_count=1, 
    batch_words=4)
    

Node2Vec 模型已经训练完成,我们现在可以像使用gensim库中的 Word2Vec 对象一样使用它。让我们创建一个函数,根据给定的标题推荐电影:

  1. 我们创建了recommend()函数,该函数接受电影标题作为输入。它首先将标题转换为我们可以用来查询模型的电影 ID:

    def recommend(movie):
        movie_id = str
            movies.title == movie].movie_ id.values[0])
    
  2. 我们遍历五个最相似的词向量,将这些 ID 转换为电影标题,并打印出它们对应的相似度分数:

        for id in model.wv.most_similar(movie_id)[:5]:
            title = movies[movies.movie_id == int(id[0])].title.values[0]
            print(f'{title}: {id[1]:.2f}')
    
  3. 我们调用此函数,获取与《星际大战》在余弦相似度上最相似的五部电影:

    recommend('Star Wars (1977)')
    
  4. 我们得到以下输出:

    Return of the Jedi (1983): 0.61
    Raiders of the Lost Ark (1981): 0.55
    Godfather, The (1972): 0.49
    Indiana Jones and the Last Crusade (1989): 0.46
    White Squall (1996): 0.44
    

模型告诉我们,Return of the JediRaiders of the Lost ArkStar Wars 最为相似,尽管分数相对较低(< 0.7)。尽管如此,对于我们踏入推荐系统(RecSys)世界的第一步,这仍然是一个不错的结果!在后续章节中,我们将看到更强大的模型和构建最先进推荐系统的方法。

总结

本章中,我们了解了 Node2Vec,这是一种基于流行的 Word2Vec 的第二种架构。我们实现了生成有偏随机游走的函数,并解释了它们的参数与两个网络属性之间的关系:同质性和结构等价性。通过将 Node2Vec 的结果与 DeepWalk 在 Zachary 的空手道俱乐部的数据上的表现进行比较,我们展示了其有效性。最后,我们使用自定义图数据集和另一个 Node2Vec 的实现构建了我们的第一个推荐系统(RecSys)。它给出了正确的推荐,我们将在后续章节中进一步改进。

第五章**,《使用普通神经网络包含节点特征》中,我们将讨论一个被忽视的问题,涉及到 DeepWalk 和 Node2Vec:缺乏适当的节点特征。我们将尝试通过使用传统神经网络来解决这个问题,而这些网络无法理解网络拓扑。在我们最终引入答案:图神经网络之前,理解这个困境非常重要。

进一步阅读

第五章:用香草神经网络在节点特征中包含节点特征

到目前为止,我们考虑的唯一类型的信息是图拓扑结构。然而,图形数据集往往比单纯的连接更丰富:节点和边缘也可以具有特征,用于表示分数、颜色、单词等等。将这些附加信息包含在我们的输入数据中对于产生最佳嵌入至关重要。事实上,这在机器学习中是自然而然的:节点和边缘特征具有与表格(非图形)数据集相同的结构。这意味着可以将传统技术应用于这些数据,例如神经网络。

在本章中,我们将介绍两个新的图形数据集:CoraFacebook Page-Page。我们将看到香草神经网络如何仅将其视为表格数据集来处理节点特征。然后,我们将尝试在神经网络中包含拓扑信息。这将给我们带来我们第一个 GNN 架构:一个简单的模型,同时考虑节点特征和边缘。最后,我们将比较这两种架构的性能,并得到本书中最重要的结果之一。

通过本章结束时,您将掌握在 PyTorch 中实现香草神经网络和香草 GNN 的方法。您将能够将拓扑特征嵌入节点表示中,这是每个 GNN 架构的基础。这将允许您通过将表格数据集转换为图问题来大大提高模型的性能。

在本章中,我们将涵盖以下主题:

  • 介绍图形数据集

  • 使用香草神经网络对节点进行分类

  • 使用香草图神经网络对节点进行分类

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter05

在本书的前言中可以找到运行代码所需的安装步骤。

介绍图形数据集

我们在本章中将使用的图形数据集比 Zachary's Karate Club 更丰富:它们具有更多的节点、更多的边缘,并包含节点特征。在本节中,我们将介绍它们,以便更好地理解这些图形及如何使用 PyTorch Geometric 处理它们。以下是我们将使用的两个数据集:

  • Cora 数据集

  • Facebook Page-Page 数据集

让我们从较小的一个开始:流行的Cora数据集。

Cora 数据集

由 Sen 等人于 2008 年提出 [1],Cora(无许可)是科学文献中最流行的节点分类数据集。它代表了一个包含 2,708 篇出版物的网络,其中每个连接代表一个参考文献。每篇出版物用一个包含 1,433 个独特单词的二进制向量表示,其中 01 分别表示对应单词的缺失或存在。这种表示方法也被称为自然语言处理中的二进制 词袋。我们的目标是将每个节点分类到七个类别中的一个。

无论数据类型如何,可视化始终是理解我们面临问题的一个重要步骤。然而,图形很快就会变得太大,无法使用像 networkx 这样的 Python 库进行可视化。这就是为什么专门的工具被开发出来,专门用于图数据的可视化。在本书中,我们使用了两款最流行的工具:yEd Live (www.yworks.com/yed-live/) 和 Gephi (gephi.org/)。

以下图是使用 yEd Live 绘制的 Cora 数据集。你可以看到,节点以橙色表示对应的论文,绿色表示它们之间的连接。一些论文的互联性如此强,以至于它们形成了聚类。这些聚类应该比连接较差的节点更容易分类。

图 5.1 – 使用 yEd Live 可视化的 Cora 数据集

图 5.1 – 使用 yEd Live 可视化的 Cora 数据集

我们导入它并使用 PyTorch Geometric 分析它的主要特征。这个库有一个专门的类来下载数据集并返回相关的数据结构。我们假设这里已经安装了 PyTorch Geometric:

  1. 我们从 PyTorch Geometric 导入 Planetoid 类:

    from torch_geometric.datasets import Planetoid
    
  2. 我们使用这个类下载它:

    dataset = Planetoid(root=".", name="Cora")
    
  3. Cora 只有一个图,我们可以将其存储在一个专门的 data 变量中:

    data = dataset[0]
    
  4. 让我们打印出有关数据集的一般信息:

    print(f'Dataset: {dataset}')
    print('---------------')
    print(f'Number of graphs: {len(dataset)}')
    print(f'Number of nodes: {data.x.shape[0]}')
    print(f'Number of features: {dataset.num_features}')
    print(f'Number of classes: {dataset.num_classes}')
    
  5. 这给我们带来了以下输出:

    Dataset: Cora()
    ---------------
    Number of graphs: 1
    Number of nodes: 2708
    Number of features: 1433
    Number of classes: 7
    
  6. 我们还可以通过 PyTorch Geometric 的专门函数获得详细信息:

    print(f'Graph:')
    print('------')
    print(f'Edges are directed: {data.is_directed()}')
    print(f'Graph has isolated nodes: {data.has_isolated_nodes()}')
    print(f'Graph has loops: {data.has_self_loops()}')
    
  7. 这是前一个代码块的结果:

    Graph:
    ------
    Edges are directed: False
    Graph has isolated nodes: False
    Graph has loops: False
    

第一个输出确认了关于节点数量、特征和类别的信息。第二个输出则提供了更多关于图形本身的见解:边是无向的,每个节点都有邻居,而且图中没有自环。我们可以使用 PyTorch Geometric 的 utils 函数测试其他属性,但在这个例子中不会学到任何新东西。

现在我们了解了更多关于 Cora 的信息,让我们来看一个更具代表性的,体现现实世界社交网络规模的数据集:Facebook Page-Page 数据集。

Facebook Page-Page 数据集

该数据集由 Rozemberczki 等人在 2019 年提出[2]。它是通过 Facebook Graph API 于 2017 年 11 月创建的。在这个数据集中,22,470 个节点中的每一个代表一个官方 Facebook 页面。当两个页面之间有互相点赞时,它们会被连接。节点特征(128 维向量)是通过这些页面的所有者编写的文本描述创建的。我们的目标是将每个节点分类到四个类别之一:政治人物、公司、电视节目和政府组织。

Facebook 页面-页面数据集与前一个数据集相似:它是一个具有节点分类任务的社交网络。然而,它与Cora有三大不同之处:

  • 节点的数量要高得多(2,708 与 22,470)

  • 节点特征的维度大幅度降低(从 1,433 降到 128)

  • 目标是将每个节点分类为四个类别,而不是七个类别(这更容易,因为选项更少)。

下图是使用 Gephi 可视化的数据集。首先,少量连接的节点已被过滤掉,以提高性能。剩余节点的大小取决于它们的连接数量,颜色表示它们所属的类别。最后,应用了两种布局:Fruchterman-Reingold 和 ForceAtlas2。

图 5.2 – 使用 Gephi 可视化的 Facebook 页面-页面数据集

图 5.2 – 使用 Gephi 可视化的 Facebook 页面-页面数据集

我们可以以与Cora相同的方式导入Facebook 页面-页面数据集:

  1. 我们从 PyTorch Geometric 导入FacebookPagePage类:

    from torch_geometric.datasets import FacebookPagePage
    
  2. 我们使用这个类下载它:

    dataset = FacebookPagePage(root=".")
    
  3. 我们将图存储在一个专用的data变量中:

    data = dataset[0]
    
  4. 让我们打印一下关于数据集的一般信息:

    print(f'Dataset: {dataset}')
    print('-----------------------')
    print(f'Number of graphs: {len(dataset)}')
    print(f'Number of nodes: {data.x.shape[0]}')
    print(f'Number of features: {dataset.num_features}')
    print(f'Number of classes: {dataset.num_classes}')
    
  5. 这会给我们以下输出:

    Dataset: FacebookPagePage()
    -----------------------
    Number of graphs: 1
    Number of nodes: 22470
    Number of features: 128
    Number of classes: 4
    
  6. 同样的专用函数可以在这里应用:

    print(f'\nGraph:')
    print('------')
    print(f'Edges are directed: {data.is_directed()}')
    print(f'Graph has isolated nodes: {data.has_isolated_nodes()}')
    print(f'Graph has loops: {data.has_self_loops()}')
    

这是上一部分的结果:

Graph:
------
Edges are directed: False
Graph has isolated nodes: False
Graph has loops: True
  1. Cora不同,Facebook 页面-页面数据集默认没有训练、评估和测试掩码。我们可以使用range()函数随意创建掩码:

    data.train_mask = range(18000)
    data.val_mask = range(18001, 20000)
    data.test_mask = range(20001, 22470)
    

另外,PyTorch Geometric 提供了一个转换函数,用来在加载数据集时计算随机掩码:

import torch_geometric.transforms as T
dataset = Planetoid(root=".", name="Cora")
data = dataset[0]

第一个输出确认了我们在数据集描述中看到的节点和类别的数量。第二个输出告诉我们,这个图包含了self环路:一些页面与自己连接。这虽然让人惊讶,但实际上它不会影响结果,因为我们很快就会看到。

这是我们将在下一部分中使用的两个图数据集,用来比较普通神经网络与我们第一个图神经网络(GNN)的性能。让我们一步步实现它们。

使用普通神经网络进行节点分类

与 Zachary 的 Karate Club 数据集相比,这两个数据集包含了一种新的信息类型:节点特征。它们提供了关于图中节点的额外信息,例如社交网络中用户的年龄、性别或兴趣。在一个普通的神经网络(也称为多层感知机)中,这些嵌入将直接用于模型中执行下游任务,如节点分类。

在本节中,我们将把节点特征视为常规的表格数据集。我们将对该数据集训练一个简单的神经网络来对节点进行分类。请注意,这种架构并未考虑网络的拓扑结构。我们将在下一节中尝试解决这个问题并比较结果。

节点特征的表格数据集可以通过我们创建的data对象轻松访问。首先,我想将这个对象转换为常规的 pandas DataFrame,通过合并data.x(包含节点特征)和data.y(包含每个节点的类标签,标签来自七个类别)。在接下来的内容中,我们将使用Cora数据集:

import pandas as pd
df_x = pd.DataFrame(data.x.numpy())
df_x['label'] = pd.DataFrame(data.y)

这将给我们带来以下数据集:

0 1 1432 标签
0 0 0 0 3
1 0 0 0 4
2707 0 0 0 3

图 5.3 – Cora 数据集的表格表示(不包含拓扑信息)

如果你熟悉机器学习,你可能会认识到一个典型的数据集,它包含数据和标签。我们可以开发一个简单的data.x,并用data.y提供的标签。

让我们创建我们自己的 MLP 类,并包含四个方法:

  • 使用__init__()来初始化一个实例

  • 使用forward()来执行前向传播

  • 使用fit()来训练模型

  • 使用test()来评估它

在训练模型之前,我们必须定义主要的评估指标。多分类问题有几种常见的评估指标:准确率、F1 分数、接收者操作特征曲线下的面积ROC AUC)得分等。对于这项工作,我们实现一个简单的准确率,它定义为正确预测的比例。虽然这不是多分类问题中最好的评估指标,但它更容易理解。你可以自由地将其替换为你选择的指标:

def accuracy(y_pred, y_true):
    return torch.sum(y_pred == y_true) / len(y_true)

现在,我们可以开始实际的实现。我们在本节中不需要使用 PyTorch Geometric 来实现 MLP。所有操作都可以通过常规的 PyTorch 按照以下步骤完成:

  1. 我们从 PyTorch 导入所需的类:

    import torch
    from torch.nn import Linear
    import torch.nn.functional as F
    
  2. 我们创建了一个新的类MLP,它将继承torch.nn.Module的所有方法和属性:

    class MLP(torch.nn.Module):
    
  3. __init__()方法有三个参数(dim_indim_hdim_out),分别表示输入层、隐藏层和输出层中的神经元数量。我们还定义了两个线性层:

        def __init__(self, dim_in, dim_h, dim_out):
            super().__init__()
            self.linear1 = Linear(dim_in, dim_h)
            self.linear2 = Linear(dim_h, dim_out)
    
  4. forward() 方法执行前向传递。输入传递给第一个线性层,并使用 整流线性单元 (ReLU) 激活函数,然后结果传递给第二个线性层。我们返回这个最终结果的对数 softmax,用于分类:

        def forward(self, x):
            x = self.linear1(x)
            x = torch.relu(x)
            x = self.linear2(x)
            return F.log_softmax(x, dim=1)
    
  5. fit() 方法负责训练循环。首先,我们初始化一个损失函数和一个优化器,这些将在训练过程中使用:

        def fit(self, data, epochs):
            criterion = torch.nn.CrossEntropyLoss()
            optimizer = torch.optim.Adam(self.parameters(), lr=0.01, weight_decay=5e-4)
    
  6. 然后实现一个常规的 PyTorch 训练循环。我们在损失函数的基础上使用 accuracy() 函数:

            self.train()
            for epoch in range(epochs+1):
                optimizer.zero_grad()
                out = self(data.x)
                loss = criterion(out[data.train_mask], data.y[data.train_mask])
                acc = accuracy(out[data.train_mask].argmax(dim=1), data.y[data.train_mask])
                loss.backward()
                optimizer.step()
    
  7. 在同一个循环中,我们每 20 个 epoch 绘制一次训练数据和评估数据的损失和准确度:

                if epoch % 20 == 0:
                    val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                    val_acc = accuracy(out[data.val_mask].argmax(dim=1), data.y[data.val_mask])
                    print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc: {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | Val Acc: {val_acc*100:.2f}%')
    
  8. test() 方法在测试集上评估模型并返回准确度分数:

        def test(self, data):
            self.eval()
            out = self(data.x)
            acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
            return acc
    

现在我们的类已完成,我们可以创建、训练并测试一个 MLP 实例。

我们有两个数据集,所以我们需要一个专门针对 Cora 的模型,以及另一个针对 Facebook Page-Page 的模型。首先,让我们在 Cora 上训练一个 MLP:

  1. 我们创建一个 MLP 模型并打印它,检查我们的层是否正确:

    mlp = MLP(dataset.num_features, 16, dataset.num_classes)
    print(mlp)
    
  2. 这给出了以下输出:

    MLP(
      (linear1): Linear(in_features=1433, out_features=16, bias=True)
      (linear2): Linear(in_features=16, out_features=7, bias=True)
    )
    
  3. 好的,我们得到了正确数量的特征。让我们训练这个模型 100 个 epoch:

    mlp.fit(data, epochs=100)
    
  4. 以下是训练循环中打印的指标:

    Epoch   0 | Train Loss: 1.954 | Train Acc: 14.29% | Val Loss: 1.93 | Val Acc: 30.80%
    Epoch  20 | Train Loss: 0.120 | Train Acc: 100.00% | Val Loss: 1.42 | Val Acc: 49.40%
    Epoch  40 | Train Loss: 0.015 | Train Acc: 100.00% | Val Loss: 1.46 | Val Acc: 50.40%
    Epoch  60 | Train Loss: 0.008 | Train Acc: 100.00% | Val Loss: 1.44 | Val Acc: 53.40%
    Epoch  80 | Train Loss: 0.008 | Train Acc: 100.00% | Val Loss: 1.40 | Val Acc: 54.60%
    Epoch 100 | Train Loss: 0.009 | Train Acc: 100.00% | Val Loss: 1.39 | Val Acc: 54.20%
    
  5. 最后,我们可以通过以下代码评估它的准确度表现:

    acc = mlp.test(data)
    print(f'MLP test accuracy: {acc*100:.2f}%')
    
  6. 我们在测试数据上获得了以下准确度分数:

    MLP test accuracy: 52.50%
    
  7. 我们对 Facebook Page-Page 数据集重复相同的过程,以下是我们获得的输出:

    Epoch   0 | Train Loss: 1.398 | Train Acc: 23.94% | Val Loss: 1.40 | Val Acc: 24.21%
    Epoch  20 | Train Loss: 0.652 | Train Acc: 74.52% | Val Loss: 0.67 | Val Acc: 72.64%
    Epoch  40 | Train Loss: 0.577 | Train Acc: 77.07% | Val Loss: 0.61 | Val Acc: 73.84%
    Epoch  60 | Train Loss: 0.550 | Train Acc: 78.30% | Val Loss: 0.60 | Val Acc: 75.09%
    Epoch  80 | Train Loss: 0.533 | Train Acc: 78.89% | Val Loss: 0.60 | Val Acc: 74.79%
    Epoch 100 | Train Loss: 0.520 | Train Acc: 79.49% | Val Loss: 0.61 | Val Acc: 74.94%
    MLP test accuracy: 74.52%
    

尽管这些数据集在某些方面相似,但我们可以看到我们获得的准确度分数差异很大。当我们将节点特征和网络拓扑结合在同一个模型中时,这将形成一个有趣的比较。

使用基础图神经网络对节点进行分类

与其直接引入著名的 GNN 架构,不如尝试构建我们自己的模型,以理解 GNN 背后的思维过程。首先,我们需要回顾简单线性层的定义。

基本的神经网络层对应于线性变换 ,其中 是节点 的输入向量, 是权重矩阵。在 PyTorch 中,这个方程可以通过 torch.mm() 函数实现,或者使用 nn.Linear 类,它会添加其他参数,比如偏置项。

对于我们的图数据集,输入向量是节点特征。这意味着节点彼此完全独立。这还不足以充分理解图:就像图像中的一个像素,节点的上下文对于理解它至关重要。如果你看一组像素而不是单个像素,你可以识别出边缘、图案等等。同样,为了理解一个节点,你需要观察它的邻域。

让我们称 为节点 的邻居集。我们的 图线性层 可以如下书写:

你可以想象这个方程的几种变体。例如,我们可以为中央节点设置一个权重矩阵 ,为邻居节点设置另一个权重矩阵 。需要注意的是,我们不能为每个邻居节点设置一个权重矩阵,因为邻居的数量会根据节点不同而变化。

我们讨论的是神经网络,所以我们不能对每个节点应用之前的方程。相反,我们执行矩阵乘法,这种方式更高效。例如,线性层的方程可以重写为 ,其中 是输入矩阵。

在我们的例子中,邻接矩阵 包含了图中每个节点之间的连接。将输入矩阵与这个邻接矩阵相乘,将直接累加邻居节点的特征。我们可以将 self 循环添加到邻接矩阵中,这样就可以在这个操作中考虑中央节点。我们称这个更新后的邻接矩阵为 。我们的图线性层可以重新编写如下:

让我们通过在 PyTorch Geometric 中实现这个层来测试它。然后我们就可以像普通层一样使用它来构建一个 GNN:

  1. 首先,我们创建一个新的类,它是 torch.nn.Module 的子类:

    class VanillaGNNLayer(torch.nn.Module):
    
  2. 这个类接收两个参数,dim_indim_out,分别代表输入和输出的特征数量。我们添加一个没有偏置的基本线性变换:

        def __init__(self, dim_in, dim_out):
            super().__init__()
            self.linear = Linear(dim_in, dim_out, bias=False)
    
  3. 我们执行两个操作——线性变换,然后与邻接矩阵 相乘:

        def forward(self, x, adjacency):
            x = self.linear(x)
            x = torch.sparse.mm(adjacency, x)
            return x
    

在创建我们的普通 GNN 之前,我们需要将数据集中的边索引(data.edge_index)从坐标格式转换为稠密的邻接矩阵。我们还需要包括 self 循环;否则,中央节点的嵌入不会被考虑。

  1. 这个可以通过 to_den_adj()torch.eye() 函数轻松实现:

    from torch_geometric.utils import to_dense_adj
    adjacency = to_dense_adj(data.edge_index)[0]
    adjacency += torch.eye(len(adjacency))
    adjacency
    

这是邻接矩阵的样子:

tensor([[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.],
        [0., 0., 0.,  ..., 0., 0., 0.]])

不幸的是,我们在这个张量中只看到零,因为它是一个稀疏矩阵。更详细的打印会显示一些节点之间的连接(用 1 表示)。现在,我们已经有了专用层和邻接矩阵,实现普通的 GNN 与实现 MLP 非常相似。

  1. 我们创建一个新的类,包含两个常规的图线性层:

    class VanillaGNN(torch.nn.Module):
        def __init__(self, dim_in, dim_h, dim_out):
            super().__init__()
            self.gnn1 = VanillaGNNLayer(dim_in, dim_h)
            self.gnn2 = VanillaGNNLayer(dim_h, dim_out)
    
  2. 我们使用新层执行相同的操作,这些层将之前计算的邻接矩阵作为额外输入:

        def forward(self, x, adjacency):
            h = self.gnn1(x, adjacency)
            h = torch.relu(h)
            h = self.gnn2(h, adjacency)
            return F.log_softmax(h, dim=1)
    
  3. fit()test() 方法的工作方式完全相同:

        def fit(self, data, epochs):
            criterion = torch.nn.CrossEntropyLoss()
            optimizer = torch.optim.Adam(self.parameters(), lr=0.01, weight_decay=5e-4)
            self.train()
            for epoch in range(epochs+1):
                optimizer.zero_grad()
                out = self(data.x, adjacency)
                loss = criterion(out[data.train_mask], data.y[data.train_mask])
                acc = accuracy(out[data.train_mask].argmax(dim=1), data.y[data.train_mask])
                loss.backward()
                optimizer.step()
                if epoch % 20 == 0:
                    val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                    val_acc = accuracy(out[data.val_mask].argmax(dim=1), data.y[data.val_mask])
                    print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc: {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | Val Acc: {val_acc*100:.2f}%')
        def test(self, data):
            self.eval()
            out = self(data.x, adjacency)
            acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
            return acc
    
  4. 我们可以使用以下几行代码来创建、训练和评估我们的模型:

    gnn = VanillaGNN(dataset.num_features, 16, dataset.num_classes)
    print(gnn)
    gnn.fit(data, epochs=100)
    acc = gnn.test(data)
    print(f'\nGNN test accuracy: {acc*100:.2f}%')
    
  5. 这给出了以下输出:

    VanillaGNN(
      (gnn1): VanillaGNNLayer(
        (linear): Linear(in_features=1433, out_features=16, bias=False)
      )
      (gnn2): VanillaGNNLayer(
        (linear): Linear(in_features=16, out_features=7, bias=False)
      )
    )
    Epoch   0 | Train Loss: 2.008 | Train Acc: 20.00% | Val Loss: 1.96 | Val Acc: 23.40%
    Epoch  20 | Train Loss: 0.047 | Train Acc: 100.00% | Val Loss: 2.04 | Val Acc: 74.60%
    Epoch  40 | Train Loss: 0.004 | Train Acc: 100.00% | Val Loss: 2.49 | Val Acc: 75.20%
    Epoch  60 | Train Loss: 0.002 | Train Acc: 100.00% | Val Loss: 2.61 | Val Acc: 74.60%
    Epoch  80 | Train Loss: 0.001 | Train Acc: 100.00% | Val Loss: 2.61 | Val Acc: 75.20%
    Epoch 100 | Train Loss: 0.001 | Train Acc: 100.00% | Val Loss: 2.56 | Val Acc: 75.00%
    GNN test accuracy: 76.80%
    

我们使用Facebook Page-Page数据集复制了相同的训练过程。为了获得可比的结果,我们对每个模型和每个数据集重复了 100 次相同的实验。以下表格总结了结果:

MLP GNN
Cora 53.47%(±1.81%) 74.98%(±1.50%)
Facebook 75.21%(±0.40%) 84.85%(±1.68%)

图 5.4 – 带标准偏差的准确度得分总结

如我们所见,MLP 在Cora数据集上的准确率较低。它在Facebook Page-Page数据集上的表现较好,但仍然在这两个数据集中被我们的原始 GNN 超越。这些结果展示了在节点特征中包含拓扑信息的重要性。与表格数据集不同,我们的 GNN 考虑了每个节点的整个邻域,这在这些示例中带来了 10-20%的准确率提升。这个架构仍然很粗糙,但它为我们改进和构建更好的模型提供了指导。

总结

在这一章中,我们学习了原始神经网络和 GNN 之间缺失的联系。我们基于直觉和一些线性代数知识构建了自己的 GNN 架构。我们探索了来自科学文献的两个流行图数据集,以比较我们的两种架构。最后,我们在 PyTorch 中实现了它们,并评估了它们的性能。结果很明显:即使是我们直观版本的 GNN,在这两个数据集上的表现也完全超越了 MLP。

第六章《使用图卷积网络对嵌入进行归一化》中,我们对原始的 GNN 架构进行了改进,以正确归一化其输入。这个图卷积网络模型是一个极其高效的基准模型,我们将在本书的后续部分继续使用它。我们将对其在之前两个数据集上的结果进行比较,并引入一个新的有趣任务:节点回归。

进一步阅读

  • [1] P. Sen, G. Namata, M. Bilgic, L. Getoor, B. Galligher, 和 T. Eliassi-Rad, “网络数据中的集体分类”,AIMag,第 29 卷,第 3 期,第 93 页,2008 年 9 月。可用链接:ojs.aaai.org//index.php/aimagazine/article/view/2157

  • [2] B. Rozemberczki, C. Allen, 和 R. Sarkar, 《多尺度属性化节点嵌入》。arXiv, 2019。doi: 10.48550/ARXIV.1909.13021。可用链接:arxiv.org/abs/1909.13021

第六章:介绍图卷积网络

图卷积网络GCN)架构是 GNN 的蓝图。它由 Kipf 和 Welling 于 2017 年提出[1],基于创建一个有效的卷积神经网络CNN)变体,应用于图结构。更准确地说,它是图信号处理中图卷积操作的*似。由于其多功能性和易用性,GCN 已成为科学文献中最受欢迎的 GNN。更广泛地说,它是处理图数据时建立坚实基准的首选架构。

在本章中,我们将讨论之前基础 GNN 层的局限性。这将帮助我们理解 GCN 的动机。我们将详细介绍 GCN 层的工作原理以及它为什么比我们的解决方案表现更好。我们将通过使用 PyTorch Geometric 在CoraFacebook Page-Page数据集上实现 GCN 来验证这一点。这应该能进一步改善我们的结果。

最后一节介绍了一个新任务:节点回归。这是 GNN 中不太常见的任务,但在处理表格数据时特别有用。如果你有机会将表格数据集转化为图结构,那么这将使你能够进行回归任务,除了分类任务之外。

在本章结束时,你将能够在 PyTorch Geometric 中实现一个 GCN,用于分类或回归任务。通过线性代数,你将理解为什么这个模型比我们的基础 GNN 表现更好。最后,你将学会如何绘制节点度数和目标变量的密度分布。

本章将涵盖以下主题:

  • 设计图卷积层

  • 比较图卷积层与图线性层

  • 使用节点回归预测网络流量

技术要求

本章的所有代码示例可以在 GitHub 上找到,链接为github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter06

在本书的前言部分,你可以找到在本地计算机上运行代码所需的安装步骤。

设计图卷积层

首先,让我们讨论一个我们在上一章没有预见到的问题。与表格数据或图像数据不同,节点的邻居数并不总是相同。例如,在图 6.1中,节点有 3 个邻居,而节点只有 1 个:

图 6.1 – 简单图,其中节点的邻居数不同

图 6.1 – 简单图,其中节点的邻居数不同

然而,如果我们观察我们的 GNN 层,我们并没有考虑邻居数量的差异。我们的层由一个简单的求和构成,没有任何归一化系数。下面是我们计算节点嵌入的方式,

假设节点 有 1000 个邻居,而节点 只有 1 个:嵌入 的值将比 大得多。这是一个问题,因为我们想要比较这些嵌入。当它们的值差异如此之大时,我们该如何进行有意义的比较呢?

幸运的是,有一个简单的解决方法:通过邻居数量来除以嵌入。让我们写出 ,节点 的度。这里是 GNN 层的新公式:

但我们如何将其转化为矩阵乘法呢?提醒一下,这是我们为普通 GNN 层获得的结果:

这里是

这个公式中唯一缺少的是一个矩阵,用来给我们提供归一化系数,。这一点可以通过度矩阵 获得,它计算每个节点的邻居数量。下面是图 6.1 中所示图形的度矩阵:

这是相同的矩阵在 NumPy 中的表示:

import numpy as np
D = np.array([
    [3, 0, 0, 0],
    [0, 1, 0, 0],
    [0, 0, 2, 0],
    [0, 0, 0, 2]
])

根据定义, 给出了每个节点的度,。因此,这个矩阵的逆 直接给我们归一化系数,

矩阵的逆可以直接通过 numpy.linalg.inv() 函数计算:

np.linalg.inv(D)
array([[0.33333333, 0.        , 0.        , 0.        ],
       [0.        , 1.        , 0.        , 0.        ],
       [0.        , 0.        , 0.5       , 0.        ],
       [0.        , 0.        , 0.        , 0.5       ]])

这正是我们所寻找的。为了更加准确,我们在图中添加了自环,用 表示。同样,我们应该在度矩阵中添加自环,。我们真正感兴趣的最终矩阵是

NumPy 有一个特定的函数,numpy.identity(n),可以快速创建一个单位矩阵 ,它的维度是 。在这个例子中,我们有四个维度:

np.linalg.inv(D + np.identity(4))
array([[0.25      , 0.        , 0.        , 0.        ],
       [0.        , 0.5       , 0.        , 0.        ],
       [0.        , 0.        , 0.33333333, 0.        ],
       [0.        , 0.        , 0.        , 0.33333333]])

现在我们得到了归一化系数矩阵,我们应该把它放在哪里呢?有两个选项:

  • 将对每一行特征进行归一化。

  • 将对每一列特征进行归一化。

我们可以通过实验验证这一点,计算

的确,在第一个案例中,每一行的和等于 1。在第二个案例中,每一列的和等于 1。

矩阵乘法可以使用 numpy.matmul() 函数进行。更方便的是,Python 从版本 3.5 开始就有了自己的矩阵乘法运算符 @。让我们定义邻接矩阵 并使用这个运算符来计算矩阵乘法:

A = np.array([
    [1, 1, 1, 1],
    [1, 1, 0, 0],
    [1, 0, 1, 1],
    [1, 0, 1, 1]
])
print(np.linalg.inv(D + np.identity(4)) @ A)
[[0.25       0.25       0.25       0.25      ]
 [0.5        0.5        0.         0.        ]
 [0.33333333 0.         0.33333333 0.33333333]
 [0.33333333 0.         0.33333333 0.33333333]]
print(A @ np.linalg.inv(D + np.identity(4)))
[[0.25       0.5        0.33333333 0.33333333]
 [0.25       0.5        0.         0.        ]
 [0.25       0.         0.33333333 0.33333333]
 [0.25       0.         0.33333333 0.33333333]]

我们得到了与手动矩阵乘法相同的结果。

那么,我们应该使用哪个选项呢?自然,第一个选项看起来更具吸引力,因为它很好地对邻居节点特征进行了归一化。

然而,Kipf 和 Welling [1] 注意到,具有很多邻居的节点的特征传播非常容易,这与更孤立节点的特征不同。在原始 GCN 论文中,作者提出了一种混合归一化方法来对抗这种效果。实际上,他们通过以下公式给具有较少邻居的节点分配更高的权重:

在个体嵌入方面,这个操作可以写成如下形式:

这些是实现图卷积层的原始公式。与我们基本的 GNN 层一样,我们可以堆叠这些层来创建 GCN。让我们实现一个 GCN 并验证它是否优于我们之前的方法。

比较图卷积层和图线性层

在上一章中,我们的基本 GNN 超越了 Node2Vec 模型,但它与 GCN 比如何呢?在本节中,我们将比较它们在 Cora 和 Facebook Page-Page 数据集上的表现。

相比于基本 GNN,GCN 的主要特点是它考虑节点的度数来加权其特征。在实际实现之前,让我们先分析这两个数据集中的节点度数。这些信息是相关的,因为它与 GCN 的性能直接相关。

根据我们对这个架构的了解,我们预计当节点度数变化很大时,它的表现会更好。如果每个节点的邻居数量相同,这些架构是等效的:():

  1. 我们从 PyTorch Geometric 导入 Planetoid 类。为了可视化节点度数,我们还导入了 matplotlib 和另外两个类:degree 用于获取每个节点的邻居数量,Counter 用于统计每个度数的节点数量:

    from torch_geometric.datasets import Planetoid
    from torch_geometric.utils import degree
    from collections import Counter
    import matplotlib.pyplot as plt
    
  2. Cora 数据集已导入,其图被存储在 data 中:

    dataset = Planetoid(root=".", name="Cora")
    data = dataset[0]
    
  3. 我们计算图中每个节点的邻居数量:

    degrees = degree(data.edge_index[0]).numpy()
    
  4. 为了生成更自然的可视化,我们统计每个度数的节点数量:

    numbers = Counter(degrees)
    
  5. 让我们使用条形图来绘制这个结果:

    fig, ax = plt.subplots()
    ax.set_xlabel('Node degree')
    ax.set_ylabel('Number of nodes')
    plt.bar(numbers.keys(), numbers.values())
    

这为我们提供了如下的图表。

图 6.2 – Cora 数据集中具有特定节点度数的节点数量

图 6.2 – Cora 数据集中具有特定节点度数的节点数量

这种分布看起来是指数型的,并且有重尾:它的邻居数量从 1 个(485 个节点)到 168 个(1 个节点)不等!这正是我们希望进行归一化处理来考虑这种不*衡的数据集。

相同的过程在 Facebook 页面-页面数据集上重复,得到以下结果:

图 6.3 – Facebook 页面-页面数据集中具有特定节点度数的节点数量

图 6.3 – Facebook 页面-页面数据集中具有特定节点度数的节点数量

这种节点度数分布看起来更加偏斜,邻居数量从 1 到 709 不等。出于同样的原因,Facebook 页面-页面数据集也是一个很好的应用 GCN 的案例。

我们可以构建自己的图层,但幸运的是,PyTorch Geometric 已经预定义了一个 GCN 层。让我们先在 Cora 数据集上实现它:

  1. 我们从 PyTorch Geometric 导入 PyTorch 和 GCN 层:

    import torch
    import torch.nn.functional as F
    from torch_geometric.nn import GCNConv
    
  2. 我们创建一个函数来计算准确率:

    def accuracy(pred_y, y):
        return ((pred_y == y).sum() / len(y)).item()
    
  3. 我们创建一个 GCN 类,其中包含一个__init_()函数,该函数接受三个参数作为输入:输入维度的数量dim_in,隐藏维度的数量dim_h,以及输出维度的数量dim_out

    class GCN(torch.nn.Module):
        """Graph Convolutional Network"""
        def __init__(self, dim_in, dim_h, dim_out):
            super().__init__()
            self.gcn1 = GCNConv(dim_in, dim_h)
            self.gcn2 = GCNConv(dim_h, dim_out)
    
  4. forward方法是相同的,并且有两个 GCN 层。对结果应用一个对数softmax函数进行分类:

        def forward(self, x, edge_index):
            h = self.gcn1(x, edge_index)
            h = torch.relu(h)
            h = self.gcn2(h, edge_index)
            return F.log_softmax(h, dim=1)
    
  5. fit()方法相同,使用相同的Adam优化器参数(学习率为 0.1,L2 正则化为 0.0005):

        def fit(self, data, epochs):
            criterion = torch.nn.CrossEntropyLoss()
            optimizer = torch.optim.Adam(self.parameters(),
                                        lr=0.01,
                                        weight_decay=5e-4)
            self.train()
            for epoch in range(epochs+1):
                optimizer.zero_grad()
                out = self(data.x, data.edge_index)
                loss = criterion(out[data.train_mask], data.y[data.train_mask])
                acc = accuracy(out[data.train_mask].argmax(dim=1), data.y[data.train_mask])
                loss.backward()
                optimizer.step()
                if(epoch % 20 == 0):
                    val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                    val_acc = accuracy(out[data.val_mask].argmax(dim=1), data.y[data.val_mask])
                    print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc: {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | Val Acc: {val_acc*100:.2f}%')
    
  6. 我们实现相同的test()方法:

        @torch.no_grad()
        def test(self, data):
            self.eval()
            out = self(data.x, data.edge_index)
            acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
            return acc
    
  7. 让我们实例化并训练我们的模型100个周期:

    gcn = GCN(dataset.num_features, 16, dataset.num_classes)
    print(gcn)
    gcn.fit(data, epochs=100)
    
  8. 以下是训练的输出:

    GCN(
      (gcn1): GCNConv(1433, 16)
      (gcn2): GCNConv(16, 7)
    )
    Epoch   0 | Train Loss: 1.963 | Train Acc:  8.57% | Val Loss: 1.96 | Val Acc: 9.80%
    Epoch  20 | Train Loss: 0.142 | Train Acc: 100.00% | Val Loss: 0.82 | Val Acc: 78.40%
    Epoch  40 | Train Loss: 0.016 | Train Acc: 100.00% | Val Loss: 0.77 | Val Acc: 77.40%
    Epoch  60 | Train Loss: 0.015 | Train Acc: 100.00% | Val Loss: 0.76 | Val Acc: 76.40%
    Epoch  80 | Train Loss: 0.018 | Train Acc: 100.00% | Val Loss: 0.75 | Val Acc: 76.60%
    Epoch 100 | Train Loss: 0.017 | Train Acc: 100.00% | Val Loss: 0.75 | Val Acc: 77.20%
    
  9. 最后,让我们在测试集上评估一下:

    acc = gcn.test(data)
    print(f'GCN test accuracy: {acc*100:.2f}%')
    GCN test accuracy: 79.70%
    

如果我们重复进行 100 次这个实验,我们得到的*均准确率为 80.17%(± 0.61%),显著高于我们原始 GNN 得到的 74.98%(± 1.50%)。

完全相同的模型应用于 Facebook 页面-页面数据集,取得了 91.54%(± 0.28%)的*均准确率。再次,它显著高于原始 GNN 得到的 84.85%(± 1.68%)的结果。以下表格总结了带有标准偏差的准确率:

MLP GNN GCN
Cora 53.47%(±1.81%) 74.98%(±1.50%) 80.17%(±0.61%)
Facebook 75.21%(±0.40%) 84.85%(±1.68%) 91.54%(±0.28%)

图 6.4 – 带有标准偏差的准确率汇总

我们可以将这些高分归因于这两个数据集节点度数的广泛范围。通过对特征进行归一化,并考虑中心节点及其邻居的数量,GCN 获得了很大的灵活性,并能很好地处理各种类型的图。

然而,节点分类并不是 GNN 能够执行的唯一任务。在接下来的部分,我们将看到一种文献中很少涉及的新型应用。

使用节点回归预测网页流量

在机器学习中,回归指的是预测连续值。它通常与分类对比,后者的目标是找到正确的类别(这些类别是离散的,不是连续的)。在图数据中,它们的对应关系是节点分类和节点回归。在这一部分,我们将尝试预测每个节点的连续值,而不是类别变量。

我们将使用的数据集是维基百科网络(GNU 通用公共许可证 v3.0),由 Rozemberckzi 等人于 2019 年提出[2]。它由三个页面-页面网络组成:变色龙(2,277 个节点和 31,421 条边),鳄鱼(11,631 个节点和 170,918 条边),以及松鼠(5,201 个节点和 198,493 条边)。在这些数据集中,节点表示文章,边表示它们之间的相互链接。节点特征反映了文章中特定词汇的出现情况。最后,目标是预测 2018 年 12 月的对数*均月流量。

在这一部分,我们将应用 GCN 来预测变色龙数据集上的流量:

  1. 我们导入了维基百科网络并下载了变色龙数据集。我们应用了transform函数,RandomNodeSplit(),来随机创建评估掩码和测试掩码:

    from torch_geometric.datasets import WikipediaNetwork
    import torch_geometric.transforms as T
    dataset = WikipediaNetwork(root=".", name="chameleon", transform = T.RandomNodeSplit(num_val=200, num_test=500))
    data = dataset[0]
    
  2. 我们打印了该数据集的信息:

    print(f'Dataset: {dataset}')
    print('-------------------')
    print(f'Number of graphs: {len(dataset)}')
    print(f'Number of nodes: {data.x.shape[0]}')
    print(f'Number of unique features: {dataset.num_features}')
    print(f'Number of classes: {dataset.num_classes}')
    

这是我们获得的输出:

Dataset: WikipediaNetwork()
-------------------
Number of graphs: 1
Number of nodes: 2277
Number of unique features: 2325
Number of classes: 5
  1. 我们的数据集存在问题:输出显示我们有五个类别。然而,我们希望进行节点回归,而不是分类。那么发生了什么呢?

事实上,这五个类别是我们想要预测的连续值的区间。不幸的是,这些标签不是我们需要的:我们必须手动更改它们。首先,让我们从以下页面下载wikipedia.zip文件:snap.stanford.edu/data/wikipedia-article-networks.xhtml。解压文件后,我们导入pandas并使用它来加载目标值:

import pandas as pd
df = pd.read_csv('wikipedia/chameleon/musae_chameleon_target.csv')
  1. 我们对目标值应用了对数函数,使用np.log10(),因为目标是预测对数*均月流量:

    values = np.log10(df['target'])
    
  2. 我们重新定义data.y为从上一步得到的连续值的张量。请注意,这些值在本例中没有标准化,通常标准化是一个好的做法,但为了便于说明,我们这里不进行标准化:

    data.y = torch.tensor(values)
    tensor([2.2330, 3.9079, 3.9329,  ..., 1.9956, 4.3598, 2.4409], dtype=torch.float64)
    

再次强调,像之前的两个数据集一样,最好可视化节点度。我们使用完全相同的代码来生成以下图形:

图 6.5 – 维基百科网络中具有特定节点度的节点数量

图 6.5 – 维基百科网络中具有特定节点度的节点数量

这个分布的尾部比之前的分布更短,但保持了类似的形状:大多数节点只有一个或几个邻居,但其中一些节点作为“枢纽”,可以连接超过 80 个节点。

在节点回归的情况下,节点度分布不是我们应该检查的唯一分布类型:我们的目标值分布同样至关重要。实际上,非正态分布(如节点度)往往更难预测。我们可以使用 Seaborn 库绘制目标值,并将其与 scipy.stats.norm 提供的正态分布进行比较:

import seaborn as sns
from scipy.stats import norm
df['target'] = values
sns.distplot(df['target'], fit=norm)

这为我们提供了如下图表:

Figure 6.6 – Wikipedia 网络的目标值密度图

图 6.6 – Wikipedia 网络的目标值密度图

这个分布不完全是正态分布,但也不像节点度那样是指数分布。我们可以预期我们的模型在预测这些值时会表现良好。

让我们使用 PyTorch Geometric 一步步实现:

  1. 我们定义了 GCN 类和 __init__() 函数。这一次,我们有三个 GCNConv 层,且每一层的神经元数量逐渐减少。这个编码器架构的目的是迫使模型选择最相关的特征来预测目标值。我们还添加了一个线性层,以输出一个不局限于 0 或 -1 和 1 之间的预测值:

    class GCN(torch.nn.Module):
        def __init__(self, dim_in, dim_h, dim_out):
            super().__init__()
            self.gcn1 = GCNConv(dim_in, dim_h*4)
            self.gcn2 = GCNConv(dim_h*4, dim_h*2)
            self.gcn3 = GCNConv(dim_h*2, dim_h)
            self.linear = torch.nn.Linear(dim_h, dim_out)
    
  2. forward() 方法包括了新的 GCNConvnn.Linear 层。这里不需要使用对数 softmax 函数,因为我们并不预测类别:

    def forward(self, x, edge_index):
        h = self.gcn1(x, edge_index)
        h = torch.relu(h)
        h = F.dropout(h, p=0.5, training=self.training)
        h = self.gcn2(h, edge_index)
        h = torch.relu(h)
        h = F.dropout(h, p=0.5, training=self.training)
        h = self.gcn3(h, edge_index)
        h = torch.relu(h)
        h = self.linear(h)
        return h
    
  3. fit() 方法的主要变化是 F.mse_loss() 函数,它取代了用于分类任务的交叉熵损失。均方误差MSE)将成为我们的主要评估指标。它对应于误差的*方的*均值,可以定义如下:

  1. 在代码中,具体实现如下:

        def fit(self, data, epochs):
            optimizer = torch.optim.Adam(self.parameters(),
                                          lr=0.02,
                                          weight_decay=5e-4)
            self.train()
            for epoch in range(epochs+1):
                optimizer.zero_grad()
                out = self(data.x, data.edge_index)
                loss = F.mse_loss(out.squeeze()[data.train_mask], data.y[data.train_mask].float())
                loss.backward()
                optimizer.step()
                if epoch % 20 == 0:
                    val_loss = F.mse_loss(out.squeeze()[data.val_mask], data.y[data.val_mask])
                    print(f"Epoch {epoch:>3} | Train Loss: {loss:.5f} | Val Loss: {val_loss:.5f}")
    
  2. MSE 也包含在 test() 方法中:

        @torch.no_grad()
        def test(self, data):
            self.eval()
            out = self(data.x, data.edge_index)
            return F.mse_loss(out.squeeze()[data.test_mask], data.y[data.test_mask].float())
    
  3. 我们实例化模型,设置 128 个隐藏维度,并且只有 1 个输出维度(目标值)。模型将在 200 个周期内进行训练:

    gcn = GCN(dataset.num_features, 128, 1)
    print(gcn)
    gcn.fit(data, epochs=200)
    GCN(
      (gcn1): GCNConv(2325, 512)
      (gcn2): GCNConv(512, 256)
      (gcn3): GCNConv(256, 128)
      (linear): Linear(in_features=128, out_features=1, bias=True)
    )
    Epoch   0 | Train Loss: 12.05177 | Val Loss: 12.12162
    Epoch  20 | Train Loss: 11.23000 | Val Loss: 11.08892
    Epoch  40 | Train Loss: 4.59072 | Val Loss: 4.08908
    Epoch  60 | Train Loss: 0.82827 | Val Loss: 0.84340
    Epoch  80 | Train Loss: 0.63031 | Val Loss: 0.71436
    Epoch 100 | Train Loss: 0.54679 | Val Loss: 0.75364
    Epoch 120 | Train Loss: 0.45863 | Val Loss: 0.73487
    Epoch 140 | Train Loss: 0.40186 | Val Loss: 0.67582
    Epoch 160 | Train Loss: 0.38461 | Val Loss: 0.54889
    Epoch 180 | Train Loss: 0.33744 | Val Loss: 0.56676
    Epoch 200 | Train Loss: 0.29155 | Val Loss: 0.59314
    
  4. 我们在测试集上测试模型,计算 MSE:

    loss = gcn.test(data)
    print(f'GCN test loss: {loss:.5f}')
    GCN test loss: 0.43005
    

这个 MSE 损失本身并不是最容易解释的指标。我们可以使用以下两个指标来获得更有意义的结果:

  • RMSE 衡量的是误差的*均大小:

  • *均绝对误差MAE),它给出了预测值与实际值之间的*均绝对差异:

让我们在 Python 中一步步实现:

  1. 我们可以直接从 scikit-learn 库中导入 MSE 和 MAE:

    from sklearn.metrics import mean_squared_error, mean_absolute_error
    
  2. 我们通过 .detach().numpy() 将 PyTorch 张量的预测结果转换为模型给出的 NumPy 数组:

    out = gcn(data.x, data.edge_index)
    y_pred = out.squeeze()[data.test_mask].detach().numpy()
    mse = mean_squared_error(data.y[data.test_mask], y_pred)
    mae = mean_absolute_error(data.y[data.test_mask], y_pred)
    
  3. 我们使用专用函数计算 MSE 和 MAE。RMSE 通过 np.sqrt() 计算 MSE 的*方根:

    print('=' * 43)
    print(f'MSE = {mse:.4f} | RMSE = {np.sqrt(mse):.4f} | MAE = {mae:.4f}')
    print('=' * 43)
    ===========================================
    MSE = 0.4300 | RMSE = 0.6558 | MAE = 0.5073
    ===========================================
    

这些评估指标对于比较不同模型非常有用,但解释 MSE 和 RMSE 可能比较困难。

可视化我们模型结果的最佳工具是散点图,其中横轴表示我们的预测值,纵轴表示真实值。Seaborn 提供了一个专用函数(regplot())来进行这种类型的可视化:

fig = sns.regplot(x=data.y[data.test_mask].numpy(), y=y_pred)

图 6.7 – 实际测试值(x 轴)与预测测试值(y 轴)

图 6.7 – 实际测试值(x 轴)与预测测试值(y 轴)

在这个例子中我们没有基准线可以参考,但这是一个相当不错的预测,只有少量的异常值。尽管数据集较为简约,但在许多应用中都会有效。如果我们想改善这些结果,可以调整超参数并进行更多的错误分析,以了解异常值的来源。

摘要

在本章中,我们改进了我们的基础 GNN 层,以正确归一化特征。这个改进引入了 GCN 层和智能归一化。我们将这种新架构与 Node2Vec 和我们基础的 GNN 在 Cora 和 Facebook Page-Page 数据集上进行了比较。由于这一归一化过程,GCN 在这两种情况下都以较大优势获得了最高的准确率。最后,我们将其应用于 Wikipedia 网络的节点回归,并学习如何处理这一新任务。

第七章图注意力网络中,我们将进一步发展,通过根据邻居节点的重要性来进行区分。我们将看到如何通过一种叫做自注意力的过程自动地对节点特征进行加权。这将提高我们的性能,正如我们将通过与 GCN 架构的比较看到的那样。

进一步阅读

  • [1] T. N. Kipf 和 M. Welling,图卷积网络的半监督分类。arXiv,2016 年。DOI: 10.48550/ARXIV.1609.02907。可用:arxiv.org/abs/1609.02907

  • [2] B. Rozemberczki, C. Allen 和 R. Sarkar,多尺度属性节点嵌入。arXiv,2019 年。DOI: 10.48550/ARXIV.1909.13021。可用:arxiv.org/abs/1909.13021

第七章:图注意力网络

图注意力网络GATs)是 GCN 的理论改进。它们提出了通过一个叫做自注意力的过程来计算加权因子,而不是使用静态归一化系数。这个过程也是一种成功的深度学习架构——Transformer的核心,后者由BERTGPT-3等模型普及。GAT 由 Veličković等人在 2017 年提出,凭借其卓越的开箱即用性能,已经成为最受欢迎的 GNN 架构之一。

在这一章中,我们将通过四个步骤来学习图注意力层是如何工作的。这实际上是理解自注意力如何工作的一 个完美示例。这些理论背景将帮助我们从零开始使用NumPy实现一个图注意力层。我们将自己构建矩阵,以便理解它们在每一步是如何计算值的。

在上一节中,我们将在两个节点分类数据集上使用 GAT:Cora和一个新的数据集CiteSeer。正如上一章所预期的,这将是进一步分析结果的好机会。最后,我们将比较该架构与 GCN 的准确度。

到本章结束时,你将能够从零开始实现图注意力层,并在PyTorch GeometricPyG)中实现一个 GAT。你将学习这种架构与 GCN 之间的区别。此外,你还将掌握一个用于图数据的错误分析工具。

在本章中,我们将涵盖以下主题:

  • 介绍图注意力层

  • 在 NumPy 中实现图注意力层

  • 在 PyTorch Geometric 中实现 GAT

技术要求

本章的所有代码示例可以在 GitHub 上找到,链接为:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter07

在本书的前言部分,你可以找到在本地机器上运行代码所需的安装步骤。

介绍图注意力层

GAT 的主要思想是,有些节点比其他节点更重要。实际上,这一点在图卷积层中就已经存在:具有较少邻居的节点比其他节点更重要,因为它们有归一化系数。这种方法存在局限性,因为它只考虑节点的度数。另一方面,图注意力层的目标是生成考虑节点特征重要性的加权因子。

我们将我们的加权因子称为注意力得分,并注意到,,表示节点之间的注意力得分。我们可以将图注意力算子定义如下:

GATs 的一个重要特征是注意力得分是通过比较输入之间的关系隐式计算的(因此命名为注意力)。在这一节中,我们将看到如何通过四个步骤来计算这些注意力得分,并且如何对图注意力层进行改进:

  • 线性变换

  • 激活函数

  • Softmax 标准化

  • 多头注意力

  • 改进的图注意力层

首先,让我们来看一下线性变换是如何与之前的架构不同的。

线性变换

注意力得分表示一个中心节点 与一个邻居节点 之间的重要性。如前所述,这需要来自两个节点的节点特征。在图注意力层中,它通过隐藏向量 的连接来表示, 。在这里, 是一个经典的共享权重矩阵,用于计算隐藏向量。一个额外的线性变换被应用于这个结果,并使用一个专门的可学习权重矩阵 。在训练过程中,这个矩阵学习权重以生成注意力系数 。这个过程可以通过以下公式总结:

这个输出被传递给像传统神经网络中那样的激活函数。

激活函数

非线性是神经网络中的一个重要组成部分,用于逼*非线性目标函数。这些函数无法仅通过堆叠线性层来捕捉,因为最终的结果仍然会表现得像单一的线性层。

在官方实现中(github.com/PetarV-/GAT/blob/master/utils/layers.py),作者选择了Leaky Rectified Linear UnitReLU)激活函数(见图 7.1)。该函数修复了dying ReLU问题,即 ReLU 神经元仅输出零:

图 7.1 – ReLU 与 Leaky ReLU 函数对比

图 7.1 – ReLU 与 Leaky ReLU 函数对比

这是通过将 Leaky ReLU 函数应用于上一步骤的输出实现的:

然而,我们现在面临一个新问题:结果值没有被标准化!

Softmax 标准化

我们希望比较不同的注意力得分,这意味着我们需要在同一尺度上进行标准化的值。在机器学习中,通常使用 softmax 函数来实现这一点。我们称 为节点 的邻居节点,包括它本身:

该操作的结果给出了我们的最终注意力得分 。但是还有另一个问题:自注意力并不是非常稳定。

多头注意力

这一问题已经在原始的 Transformer 论文中由 Vaswani 等人(2017)提出。他们提出的解决方案是计算多个嵌入并为每个嵌入分配不同的注意力权重,而不是仅计算一个嵌入。这项技术被称为多头注意力。

实现非常简单,因为我们只需要重复前面三个步骤多次。每个实例都会生成一个嵌入!,其中!是注意力头的索引。这里有两种方法来结合这些结果:

  • *均:在这种方法中,我们对不同的嵌入进行求和,并通过注意力头的数量!对结果进行归一化:

  • 拼接:在这种方法中,我们将不同的嵌入拼接在一起,产生一个更大的矩阵:

在实际应用中,有一个简单的规则可以帮助决定使用哪种方式:当它是一个隐藏层时,我们选择拼接方案,当它是网络的最后一层时,我们选择*均方案。整个过程可以通过以下图示来总结:

图 7.2 – 使用多头注意力计算注意力得分

图 7.2 – 使用多头注意力计算注意力得分

这就是图注意力层的理论方面的全部内容。然而,自 2017 年问世以来,已有一种改进方案被提出。

改进的图注意力层

Brody 等人(2021)认为,图注意力层只计算静态类型的注意力。这是一个问题,因为有一些简单的图问题是我们无法用 GAT 来表达的。因此,他们引入了一个改进版本,称为 GATv2,它计算的是严格更具表现力的动态注意力。

他们的解决方案是修改操作的顺序。权重矩阵! 在拼接之后应用,注意力权重矩阵! 在!函数之后应用。总结一下,下面是原始的 图注意力操作符,即 GAT

这是修改后的操作符,GATv2:

我们应该使用哪种方法?根据 Brody 等人(2021)的研究,GATv2 始终优于 GAT,因此应该首选 GATv2。除了理论证明,他们还进行了多次实验,以展示 GATv2 相较于原始 GAT 的表现。在本章的其余部分,我们将同时讨论这两种选择:第二节中的 GAT 和第三节中的 GATv2。

在 NumPy 中实现图注意力层

如前所述,神经网络是通过矩阵乘法来工作的。因此,我们需要将我们的单个嵌入转换为整个图的操作。在这一部分,我们将从零开始实现原始的图注意力层,以便正确理解自注意力的内部工作原理。当然,这一过程可以重复多次,以创建多头注意力。

第一步是将原始的图注意力算子转换为矩阵的形式。这就是我们在上一节中定义它的方式:

通过从图的线性层中获得灵感,我们可以写出以下内容:

其中 是一个矩阵,存储每个

在这个例子中,我们将使用上一章的以下图表:

图 7.3 – 一个简单的图,其中节点具有不同数量的邻居

图 7.3 – 一个简单的图,其中节点具有不同数量的邻居

图必须提供两项重要信息:带有自环的邻接矩阵 和节点特征 。让我们看看如何在 NumPy 中实现它:

  1. 我们可以根据图 7.3中的连接构建邻接矩阵:

    import numpy as np
    np.random.seed(0)
    A = np.array([
        [1, 1, 1, 1],
        [1, 1, 0, 0],
        [1, 0, 1, 1],
        [1, 0, 1, 1]
    ])
    array([[1, 1, 1, 1],
           [1, 1, 0, 0],
           [1, 0, 1, 1],
           [1, 0, 1, 1]])
    
  2. 对于 ,我们使用np.random.uniform()生成一个随机的节点特征矩阵:

    X = np.random.uniform(-1, 1, (4, 4))
    array([[ 0.0976270,  0.4303787,  0.2055267,  0.0897663],
           [-0.1526904,  0.2917882, -0.1248255,  0.783546 ],
           [ 0.9273255, -0.2331169,  0.5834500,  0.0577898],
           [ 0.1360891,  0.8511932, -0.8579278, -0.8257414]])
    
  3. 下一步是定义我们的权重矩阵。实际上,在图注意力层中,有两个权重矩阵:常规权重矩阵 和注意力权重矩阵 。初始化它们的方式有很多种(例如 Xavier 或 He 初始化),但在这个例子中,我们可以简单地重用相同的随机函数。

矩阵 必须精心设计,因为它的维度是 。注意, 已经是固定的,因为它表示 中的节点数。相反, 的值是任意的:我们将在这个例子中选择

W = np.random.uniform(-1, 1, (2, 4))
array([[-0.9595632,  0.6652396,  0.556313 ,  0.740024 ],
       [ 0.9572366,  0.5983171, -0.0770412,  0.5610583]])
  1. 这个注意力矩阵应用于隐藏向量的拼接,生成一个唯一的值。因此,它的大小需要是

    W_att = np.random.uniform(-1, 1, (1, 4))
    array([[-0.7634511,  0.2798420, -0.7132934,  0.8893378]])
    
  2. 我们希望将源节点和目标节点的隐藏向量进行拼接。获取源节点和目标节点对的一种简单方法是查看我们的邻接矩阵 ,它采用 COO 格式:行存储源节点,列存储目标节点。NumPy 提供了一种快速高效的方法,使用np.where()来实现:

    connections = np.where(A > 0)
    (array([0, 0, 0, 0, 1, 1, 2, 2, 2, 3, 3, 3]),
     array([0, 1, 2, 3, 0, 1, 0, 2, 3, 0, 2, 3]))
    
  3. 我们可以使用 np.concatenate 来拼接源节点和目标节点的隐藏向量:

    np.concatenate([(X @ W.T)[connections[0]], (X @ W.T)[connections[1]]], axis=1)
    array([[ 0.3733923,  0.3854852,  0.3733923,  0.3854852],
           [ 0.3733923,  0.3854852,  0.8510261,  0.4776527],
           [ 0.3733923,  0.3854852, -0.6775590,  0.7356658],
           [ 0.3733923,  0.3854852, -0.6526841,  0.2423597],
           [ 0.8510261,  0.4776527,  0.3733923,  0.3854852],
           [ 0.8510261,  0.4776527,  0.8510261,  0.4776527],
           [-0.6775590,  0.7356658,  0.3733923,  0.3854852],
           [-0.6775590,  0.7356658, -0.6775590,  0.7356658],
           [-0.6775590,  0.7356658, -0.6526841,  0.2423597],
           [-0.6526841,  0.2423597,  0.3733923,  0.3854852],
           [-0.6526841,  0.2423597, -0.6775590,  0.7356658],
           [-0.6526841,  0.2423597, -0.6526841,  0.2423597]])
    
  4. 然后我们使用注意力矩阵 对该结果进行线性变换:

    a = W_att @ np.concatenate([(X @ W.T)[connections[0]], (X @ W.T)[connections[1]]], axis=1).T
    array([[-0.1007035 , -0.35942847,  0.96036209,  0.50390318, -0.43956122, -0.69828618,  0.79964181,  1.8607074 ,  1.40424849,  0.64260322, 1.70366881,  1.2472099 ]])
    
  5. 第二步是对前一个结果应用 Leaky ReLU 函数:

    def leaky_relu(x, alpha=0.2):
        return np.maximum(alpha*x, x)
    e = leaky_relu(a)
    array([[-0.0201407 , -0.07188569,  0.96036209,  0.50390318, -0.08791224,  -0.13965724,  0.79964181,  1.8607074 ,  1.40424849,  0.64260322,  1.70366881,  1.2472099 ]])
    
  6. 我们有正确的值,但需要将它们正确地放置在矩阵中。这个矩阵应该看起来像 ,因为当两个节点之间没有连接时,不需要标准化的注意力分数。为了构建这个矩阵,我们通过connections知道源节点 和目标节点 。因此,e中的第一个值对应于 ,第二个值对应于 ,但第七个值对应于 ,而不是 。我们可以按如下方式填充矩阵:

    E = np.zeros(A.shape)
    E[connections[0], connections[1]] = e[0]
    array([[-0.020140 , -0.0718856,  0.9603620,  0.5039031],
           [-0.0879122, -0.1396572,  0.       ,  0.       ],
           [ 0.7996418,  0.       ,  1.8607074,  1.4042484],
           [ 0.6426032,  0.       ,  1.7036688,  1.247209 ]])
    
  7. 下一步是规范化每一行的注意力分数。这需要一个自定义的 softmax 函数来生成最终的注意力分数:

    def softmax2D(x, axis):
        e = np.exp(x - np.expand_dims(np.max(x, axis=axis), axis))
        sum = np.expand_dims(np.sum(e, axis=axis), axis)
        return e / sum
    W_alpha = softmax2D(E, 1)
    array([[0.15862414, 0.15062488, 0.42285965, 0.26789133],
           [0.24193418, 0.22973368, 0.26416607, 0.26416607],
           [0.16208847, 0.07285714, 0.46834625, 0.29670814],
           [0.16010498, 0.08420266, 0.46261506, 0.2930773 ]])
    
  8. 这个注意力矩阵 为网络中每一个可能的连接提供了权重。我们可以用它来计算我们的嵌入矩阵 ,该矩阵应为每个节点提供二维向量:

    H = A.T @ W_alpha @ X @ W.T
    array([[-1.10126376,  1.99749693],
           [-0.33950544,  0.97045933],
           [-1.03570438,  1.53614075],
           [-1.03570438,  1.53614075]])
    

我们的图注意力层现在已经完成!添加多头注意力的过程包括在聚合结果之前使用不同的 重复这些步骤。

图注意力操作符是开发 GNN 的一个重要构建块。在下一节中,我们将使用 PyG 实现创建一个 GAT。

在 PyTorch Geometric 中实现 GAT

现在我们已经完整地了解了图注意力层的工作原理。这些层可以堆叠起来,创建我们选择的新架构:GAT。在本节中,我们将遵循原始 GAT 论文中的指导方针,使用 PyG 实现我们自己的模型。我们将用它在 CoraCiteSeer 数据集上执行节点分类。最后,我们将对这些结果进行评论并进行比较。

让我们从 Cora 数据集开始:

  1. 我们从 PyG 导入 Cora 数据集中的 Planetoid 类:

    from torch_geometric.datasets import Planetoid
    dataset = Planetoid(root=".", name="Cora")
    data = dataset[0]
    Data(x=[2708, 1433], edge_index=[2, 10556], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708])
    
  2. 我们导入必要的库来创建我们自己的 GAT 类,使用 GATv2 层:

    import torch
    import torch.nn.functional as F
    from torch_geometric.nn import GATv2Conv
    from torch.nn import Linear, Dropout
    
  3. 我们实现 accuracy() 函数来评估模型的性能:

    def accuracy(y_pred, y_true):
        return torch.sum(y_pred == y_true) / len(y_true)
    
  4. 该类初始化时包含两个改进的图注意力层。注意,声明多头注意力所使用的头数非常重要。作者指出,八个头对于第一层性能有所提升,但对于第二层没有任何影响:

    class GAT(torch.nn.Module):
        def __init__(self, dim_in, dim_h, dim_out, heads=8):
            super().__init__()
            self.gat1 = GATv2Conv(dim_in, dim_h, heads=heads)
            self.gat2 = GATv2Conv(dim_h*heads, dim_out, heads=1)
    
  5. 与之前实现的 GCN 相比,我们添加了两个 dropout 层来防止过拟合。这些层以预定义的概率(在此情况下为 0.6)随机将一些值从输入张量中置为零。为了符合原论文,我们还使用了 指数线性单元ELU)函数,它是 Leaky ReLU 的指数版本:

        def forward(self, x, edge_index):
            h = F.dropout(x, p=0.6, training=self.training)
            h = self.gat1(h, edge_index)
            h = F.elu(h)
            h = F.dropout(h, p=0.6, training=self.training)
            h = self.gat2(h, edge_index)
            return F.log_softmax(h, dim=1)
    
  6. fit() 函数与 GCN 的相同。根据作者的说法,Adam 优化器的参数已经调整,以匹配 Cora 数据集的最佳值:

        def fit(self, data, epochs):
            criterion = torch.nn.CrossEntropyLoss()
            optimizer = torch.optim.Adam(self.parameters(), lr=0.01, weight_decay=0.01)
            self.train()
            for epoch in range(epochs+1):
                optimizer.zero_grad()
                out = self(data.x, data.edge_index)
                loss = criterion(out[data.train_mask], data.y[data.train_mask])
                acc = accuracy(out[data.train_mask].argmax(dim=1), data.y[data.train_mask])
                loss.backward()
                optimizer.step()
                if(epoch % 20 == 0):
                    val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                    val_acc = accuracy(out[data.val_mask].argmax(dim=1), data.y[data.val_mask])
                    print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc: {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | Val Acc: {val_acc*100:.2f}%')
    
  7. test() 函数完全相同:

        @torch.no_grad()
        def test(self, data):
            self.eval()
            out = self(data.x, data.edge_index)
            acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
            return acc
    
  8. 我们创建一个 GAT 并训练它 100 个周期:

    gat = GAT(dataset.num_features, 32, dataset.num_classes)
    gat.fit(data, epochs=100)
    GAT(
      (gat1): GATv2Conv(1433, 32, heads=8)
      (gat2): GATv2Conv(256, 7, heads=1)
    )
    Epoch 0 | Train Loss: 1.978 | Train Acc: 12.86% | Val Loss: 1.94 | Val Acc: 13.80%
    Epoch 20 | Train Loss: 0.238 | Train Acc: 96.43% | Val Loss: 1.04 | Val Acc: 67.40%
    Epoch 40 | Train Loss: 0.165 | Train Acc: 98.57% | Val Loss: 0.95 | Val Acc: 71.00%
    Epoch 60 | Train Loss: 0.209 | Train Acc: 96.43% | Val Loss: 0.91 | Val Acc: 71.80%
    Epoch 80 | Train Loss: 0.172 | Train Acc: 100.00% | Val Loss: 0.93 | Val Acc: 70.80%
    Epoch 100 | Train Loss: 0.190 | Train Acc: 97.86% | Val Loss: 0.96 | Val Acc: 70.80%
    
  9. 这输出了最终的测试准确度:

    acc = gat.test(data)
    print(f'GAT test accuracy: {acc*100:.2f}%')
    GAT test accuracy: 81.10%
    

这个准确度分数略高于我们用 GCN 获得的*均分数。我们将在将 GAT 架构应用于第二个数据集后做出适当的比较。

我们将使用一个新的流行节点分类数据集 CiteSeer(MIT 许可证)。与 Cora 类似,它代表了一个研究论文的网络,每个连接都是一个引用。CiteSeer 涉及 3327 个节点,这些节点的特征表示论文中 3703 个单词的存在(1)或不存在(0)。该数据集的目标是将这些节点正确分类为六个类别。图 7.4 显示了使用 yEd Live 绘制的 CiteSeer 图:

图 7.4 – CiteSeer 数据集(使用 yEd Live 绘制)

图 7.4 – CiteSeer 数据集(使用 yEd Live 绘制)

Cora 相比,这个数据集在节点数量(从 2,708 到 3,327)和特征维度(从 1,433 到 3,703)上都更大。然而,可以应用完全相同的过程:

  1. 首先,我们加载 CiteSeer 数据集:

    dataset = Planetoid(root=".", name="CiteSeer")
    data = dataset[0]
    Data(x=[3327, 3703], edge_index=[2, 9104], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327])
    
  2. 为了更好地衡量,我们绘制了每个节点度数的节点数,使用了上一章的代码:

    import matplotlib.pyplot as plt
    from torch_geometric.utils import degree
    from collections import Counter
    degrees = degree(dataset[0].edge_index[0]).numpy()
    numbers = Counter(degrees)
    fig, ax = plt.subplots(dpi=300)
    ax.set_xlabel('Node degree')
    ax.set_ylabel('Number of nodes')
    plt.bar(numbers.keys(), numbers.values())
    
  3. 它给出了以下输出:

图 7.5 – 每个节点度数的节点数(CiteSeer)

图 7.5 – 每个节点度数的节点数(CiteSeer)

图 7.5 看起来像是典型的重尾分布,但有所不同:一些节点的度数为零!换句话说,它们没有连接到任何其他节点。我们可以假设它们会比其他节点更难分类。

  1. 我们初始化一个新的 GAT 模型,具有正确的输入和输出节点数,并训练它 100 个周期:

    gat = GAT(dataset.num_features, 16, dataset.num_classes)
    gat.fit(data, epochs=100)
    Epoch   0 | Train Loss: 1.815 | Train Acc: 15.00% | Val Loss: 1.81 | Val Acc: 14.20%
    Epoch  20 | Train Loss: 0.173 | Train Acc: 99.17% | Val Loss: 1.15 | Val Acc: 63.80%
    Epoch  40 | Train Loss: 0.113 | Train Acc: 99.17% | Val Loss: 1.12 | Val Acc: 64.80%
    Epoch  60 | Train Loss: 0.099 | Train Acc: 98.33% | Val Loss: 1.12 | Val Acc: 62.40%
    Epoch  80 | Train Loss: 0.130 | Train Acc: 98.33% | Val Loss: 1.19 | Val Acc: 62.20%
    Epoch 100 | Train Loss: 0.158 | Train Acc: 98.33% | Val Loss: 1.10 | Val Acc: 64.60%
    
  2. 我们获得了以下测试准确度分数:

    acc = gat.test(data)
    print(f'GAT test accuracy: {acc*100:.2f}%')
    GAT test accuracy: 68.10%
    

这是一个好结果吗?这次,我们没有比较的标准。

根据 Schur 等人在 图神经网络评估的陷阱 中的研究,GAT 在 CoraCiteSeer 上的表现略优于 GCN(82.8% ± 0.6% 对比 81.9% ± 0.8%,71.0 ± 0.6% 对比 69.5% ± 0.9%)。作者还指出,准确度分数并非正态分布,这使得标准差的使用变得不那么相关。在这种基准测试中需要记住这一点。

之前,我推测连接较差的节点可能会对性能产生负面影响。我们可以通过绘制每个节点度数的*均准确率来验证这一假设:

  1. 我们获得模型的分类结果:

    out = gat(data.x, data.edge_index)
    
  2. 我们计算每个节点的度数:

    degrees = degree(data.edge_index[0]).numpy()
    
  3. 我们存储了准确率分数和样本大小:

    accuracies = []
    sizes = []
    
  4. 我们使用np.where()计算每个节点度数介于零和五之间的*均准确率:

    for i in range(0, 6):
        mask = np.where(degrees == i)[0]
        accuracies.append(accuracy(out.argmax(dim=1)[mask], data.y[mask]))
        sizes.append(len(mask))
    
  5. 我们对每个度数大于五的节点重复这一过程:

    mask = np.where(degrees > 5)[0]
    accuracies.append(accuracy(out.argmax(dim=1)[mask], data.y[mask]))
    sizes.append(len(mask))
    
  6. 我们绘制了这些准确率分数与对应的节点度数:

    fig, ax = plt.subplots(dpi=300)
    ax.set_xlabel('Node degree')
    ax.set_ylabel('Accuracy score')
    plt.bar(['0','1','2','3','4','5','6+'], accuracies)
    for i in range(0, 7):
        plt.text(i, accuracies[i], f'{accuracies[i]*100:.2f}%', ha='center', color='black')
    for i in range(0, 7):
        plt.text(i, accuracies[i]//2, sizes[i], ha='center', color='white')
    
  7. 它输出以下图形:

图 7.6 – 每个节点度数的准确率(CiteSeer)

图 7.6 – 每个节点度数的准确率(CiteSeer)

图 7.6验证了我们的假设:邻居较少的节点更难正确分类。此外,它甚至显示出通常节点度数越高,准确率越好。这是非常自然的,因为更多的邻居将为 GNN 提供更多的信息,从而帮助它做出预测。

总结

在这一章中,我们介绍了一个新的重要架构:GAT。我们通过四个主要步骤了解了其内部工作原理,从线性变换到多头注意力。我们通过在 NumPy 中实现图注意力层,实际展示了它的工作方式。最后,我们将 GAT 模型(包括 GATv2)应用于CoraCiteSeer数据集,并取得了优异的准确率分数。我们展示了这些分数依赖于邻居的数量,这是进行错误分析的第一步。

第八章使用 GraphSAGE 扩展图神经网络中,我们将介绍一种专门用于管理大规模图的新架构。为了验证这一说法,我们将在一个比我们目前看到的数据集大数倍的全新数据集上多次实现该架构。我们将讨论传导性学习和归纳性学习,这对于图神经网络(GNN)实践者来说是一个重要的区分。

第三部分:高级技术

在本书的第三部分,我们将深入探讨已开发的更多先进和专门化的 GNN 架构,这些架构旨在解决各种与图相关的问题。我们将介绍为特定任务和领域设计的最先进的 GNN 模型,这些模型能更有效地应对挑战和需求。此外,我们还将概述一些可以使用 GNN 解决的新图任务,如链接预测和图分类,并通过实际的代码示例和实现展示它们的应用。

在本部分结束时,您将能够理解并实现先进的 GNN 架构,并将其应用于解决您自己的图相关问题。您将全面了解专业化的 GNN 及其各自的优势,并通过代码示例获得实践经验。这些知识将使您能够将 GNN 应用于实际的使用场景,并有可能为新的创新型 GNN 架构的发展做出贡献。

本部分包括以下章节:

  • 第八章**,使用 GraphSAGE 扩展图神经网络

  • 第九章**,为图分类定义表达能力

  • 第十章**,使用图神经网络预测链接

  • 第十一章**,使用图神经网络生成图

  • 第十二章**,从异构图中学习

  • 第十三章**,时序图神经网络

  • 第十四章**,解释图神经网络

第八章:使用 GraphSAGE 扩展图神经网络

GraphSAGE 是一种 GNN 架构,专为处理大规模图而设计。在技术行业中,可扩展性是推动增长的关键因素。因此,系统通常设计为能够容纳数百万用户。这一能力需要在 GNN 模型的工作方式上与 GCN 和 GAT 进行根本性的转变。因此,GraphSAGE 成为像 Uber Eats 和 Pinterest 这样的科技公司首选的架构也就不足为奇了。

在本章中,我们将学习 GraphSAGE 背后的两个主要思想。首先,我们将描述其邻居采样技术,这是其在可扩展性方面表现优异的核心。然后,我们将探讨用于生成节点嵌入的三种聚合操作符。除了原始方法,我们还将详细介绍 Uber Eats 和 Pinterest 提出的变种。

此外,GraphSAGE 提供了新的训练可能性。我们将实现两种训练 GNN 的方法,分别用于两个任务——使用PubMed进行节点分类和蛋白质-蛋白质相互作用多标签分类。最后,我们将讨论新归纳方法的优点及其应用。

到本章结束时,你将理解邻居采样算法如何以及为什么有效。你将能够实现它,以创建小批量并加速在大多数 GNN 架构上的训练,使用 GPU。更重要的是,你将掌握图上的归纳学习和多标签分类。

本章我们将涵盖以下内容:

  • 介绍 GraphSAGE

  • PubMed 上的节点分类

  • 蛋白质-蛋白质相互作用中的归纳学习

技术要求

本章的所有代码示例可以在 GitHub 上找到,网址为 https://github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter08。

在本书的 前言 章节中,可以找到运行代码所需的安装步骤。

介绍 GraphSAGE

Hamilton 等人在 2017 年提出了 GraphSAGE(见 进一步阅读 部分的第 [1] 项),作为一种针对大规模图(节点超过 100,000)的归纳表示学习框架。其目标是为下游任务(如节点分类)生成节点嵌入。此外,它解决了 GCN 和 GAT 的两个问题——扩展到大规模图和高效地推广到未见过的数据。在本节中,我们将通过描述 GraphSAGE 的两个主要组件来解释如何实现它:

  • 邻居采样

  • 聚合

让我们来看看它们。

邻居采样

到目前为止,我们还没有讨论传统神经网络中的一个重要概念——小批量处理。它的做法是将数据集分成更小的片段,称为批次。批次用于梯度下降,这是一种优化算法,在训练过程中寻找最佳的权重和偏置。梯度下降有三种类型:

  • 批量梯度下降:在整个数据集处理完(每一轮迭代)后更新权重和偏置。这是我们到目前为止实现的技术。然而,它是一个比较慢的过程,需要数据集能够完全加载到内存中。

  • 随机梯度下降:对于数据集中的每一个训练样本,都会更新权重和偏置。这是一个有噪声的过程,因为误差没有被*均。然而,它可以用于执行在线训练。

  • 小批量梯度下降:权重和偏置会在每个小批量的训练样本处理完毕后更新。这个技术速度更快(小批量可以使用 GPU 并行处理),并且导致更稳定的收敛。此外,数据集的大小可以超过可用内存,这对于处理大型图数据是至关重要的。

在实际应用中,我们使用更先进的优化器,如 Adam,它也实现了小批量处理。

对于表格数据集,划分非常简单;它只需选择 样本(行)。然而,对于图数据集来说,这是一个问题——我们如何选择 节点而不破坏重要的连接?如果不小心,可能会导致一个孤立节点的集合,在这个集合中我们无法进行任何聚合。

我们必须考虑 GNN 如何使用数据集。每一层 GNN 都是基于节点的邻居计算节点嵌入。这意味着计算一个嵌入只需要该节点的直接邻居(1 hop)。如果我们的 GNN 有两层,那么我们需要这些邻居及其自身的邻居(2 hops),依此类推(见 图 8.1)。网络的其余部分与计算这些单个节点的嵌入无关。

图 8.1 – 以节点 0 为目标节点,以及 1-hop 和 2-hop 邻居的图

图 8.1 – 以节点 0 为目标节点,以及 1-hop 和 2-hop 邻居的图

该技术允许我们通过计算图来填充批量数据,计算图描述了计算节点嵌入的整个操作序列。图 8.2 展示了节点 0 的计算图,并提供了更直观的表示。

图 8.2 – 节点 0 的计算图

图 8.2 – 节点 0 的计算图

我们需要聚合 2-hop 邻居,以便计算 1-hop 邻居的嵌入。然后,这些嵌入被聚合以获得节点 0 的嵌入。然而,这种设计存在两个问题:

  • 计算图随着跳数的增加呈指数级增长。

  • 具有非常高连接度的节点(例如在线社交网络中的名人,也称为枢纽节点)会生成巨大的计算图。

为了解决这些问题,我们必须限制计算图的大小。在 GraphSAGE 中,作者提出了一种名为邻居采样的技术。我们不再将每个邻居都添加到计算图中,而是从中随机选择一个预定义数量的邻居。例如,在第一次跳跃中,我们只选择最多三个邻居,而在第二次跳跃中,我们选择最多五个邻居。因此,在这种情况下,计算图的节点数不能超过

图 8.3 – 一个计算图,通过邻居采样来保留两个 1 跳邻居和两个 2 跳邻居

图 8.3 – 一个计算图,通过邻居采样来保留两个 1 跳邻居和两个 2 跳邻居

较低的采样数更高效,但会使训练更加随机(方差较大)。此外,GNN 层数(跳数)也必须保持较低,以避免计算图呈指数级增长。邻居采样可以处理大型图,但它通过修剪重要信息带来了权衡,这可能会对性能(如准确度)产生负面影响。请注意,计算图涉及大量冗余计算,这使得整个过程在计算上不那么高效。

然而,这种随机采样并不是唯一可以使用的技术。Pinterest 有自己的 GraphSAGE 版本,称为 PinSAGE,用于支持其推荐系统(见进一步阅读 [2])。它实现了另一种采样解决方案,使用随机游走。PinSAGE 保持了固定数量邻居的想法,但通过随机游走来查看哪些节点是最常被遇到的。这种频率决定了它们的相对重要性。PinSAGE 的采样策略使其能够选择最关键的节点,并在实际应用中证明了更高的效率。

聚合

现在我们已经了解了如何选择邻居节点,接下来我们需要计算嵌入。这个过程由聚合算子(或称聚合器)来执行。在 GraphSAGE 中,作者提出了三种解决方案:

  • 均值聚合器

  • 长短期记忆LSTM)聚合器

  • 一个池化聚合器

我们将重点介绍均值聚合器,因为它是最容易理解的。首先,均值聚合器会取目标节点及其采样邻居的嵌入,将它们进行*均。然后,对这个结果应用一个带有权重矩阵的线性变换,

均值聚合器可以通过以下公式来总结,其中是一个非线性函数,比如 ReLU 或 tanh:

在 PyG 和 Uber Eats 实现 GraphSAGE 的情况下[3],我们使用了两个权重矩阵而不是一个;第一个矩阵用于目标节点,第二个矩阵用于邻居节点。这个聚合器可以写作如下:

LSTM 聚合器基于 LSTM 架构,这是一种流行的递归神经网络类型。与均值聚合器相比,LSTM 聚合器理论上能够区分更多的图结构,从而生成更好的嵌入。问题在于,递归神经网络仅考虑输入序列,例如一个有起始和结束的句子。然而,节点没有任何顺序。因此,我们通过对节点的邻居进行随机排列来解决这个问题。这种解决方案允许我们使用 LSTM 架构,而不依赖于任何输入序列。

最后,池化聚合器分为两个步骤。首先,将每个邻居的嵌入传递给 MLP 以生成一个新的向量。其次,执行逐元素最大操作,仅保留每个特征的最大值。

我们不仅限于这三种选择,还可以在 GraphSAGE 框架中实现其他聚合器。事实上,GraphSAGE 的核心思想就在于其高效的邻居采样。在下一节中,我们将使用它对一个新数据集进行节点分类。

在 PubMed 上对节点进行分类

在本节中,我们将实现一个 GraphSAGE 架构,对 PubMed 数据集进行节点分类(该数据集在 MIT 许可证下可从 github.com/kimiyoung/planetoid 获得)[4]。

之前,我们看到了来自同一个 Planetoid 系列的其他两个引文网络数据集——CoraCiteSeerPubMed 数据集展示了一个类似但更大的图,其中包含 19,717 个节点和 88,648 条边。图 8.3 显示了由 Gephi (gephi.org/) 创建的该数据集的可视化。

图 8.4 – PubMed 数据集的可视化

图 8.4 – PubMed 数据集的可视化

节点特征是 TF-IDF 加权的 500 维词向量。目标是将节点正确分类为三类——糖尿病实验型、糖尿病 1 型和糖尿病 2 型。让我们使用 PyG 按步骤实现它:

  1. 我们从 Planetoid 类加载 PubMed 数据集,并打印一些关于图的信息:

    from torch_geometric.datasets import Planetoid
    dataset = Planetoid(root='.', name="Pubmed")
    data = dataset[0]
    print(f'Dataset: {dataset}')
    print('-------------------')
    print(f'Number of graphs: {len(dataset)}')
    print(f'Number of nodes: {data.x.shape[0]}')
    print(f'Number of features: {dataset.num_features}')
    print(f'Number of classes: {dataset.num_classes}')
    print('Graph:')
    print('------')
    print(f'Training nodes: {sum(data.train_mask).item()}')
    print(f'Evaluation nodes: {sum(data.val_mask).item()}')
    print(f'Test nodes: {sum(data.test_mask).item()}')
    print(f'Edges are directed: {data.is_directed()}')
    print(f'Graph has isolated nodes: {data.has_isolated_nodes()}')
    print(f'Graph has loops: {data.has_self_loops()}')
    
  2. 这将产生以下输出:

    Dataset: Pubmed()
    -------------------
    Number of graphs: 1
    Number of nodes: 19717
    Number of features: 500
    Number of classes: 3
    Graph:
    ------
    Training nodes: 60
    Evaluation nodes: 500
    Test nodes: 1000
    Edges are directed: False
    Graph has isolated nodes: False
    Graph has loops: False
    

如你所见,训练节点仅有 60 个,而测试节点有 1,000 个,这相当具有挑战性(6/94 的分割)。幸运的是,对于仅有 19,717 个节点的 PubMed 数据集,使用 GraphSAGE 处理将会非常快速。

  1. GraphSAGE 框架的第一步是邻居采样。PyG 实现了 NeighborLoader 类来执行这一操作。我们将保留目标节点的 10 个邻居以及它们自己的 10 个邻居。我们会将 60 个目标节点分为 16 个节点一组的批次,这样会得到四个批次:

    from torch_geometric.loader import NeighborLoader
    train_loader = NeighborLoader(
        data,
        num_neighbors=[10,10],
        batch_size=16,
        input_nodes=data.train_mask,
    )
    
  2. 通过打印其信息,让我们验证我们是否获得了四个子图(批次):

    for i, subgraph in enumerate(train_loader):
        print(f'Subgraph {i}: {subgraph}')
    Subgraph 0: Data(x=[400, 500], edge_index=[2, 455], y=[400], train_mask=[400], val_mask=[400], test_mask=[400], batch_size=16)
    Subgraph 1: Data(x=[262, 500], edge_index=[2, 306], y=[262], train_mask=[262], val_mask=[262], test_mask=[262], batch_size=16)
    Subgraph 2: Data(x=[275, 500], edge_index=[2, 314], y=[275], train_mask=[275], val_mask=[275], test_mask=[275], batch_size=16)
    Subgraph 3: Data(x=[194, 500], edge_index=[2, 227], y=[194], train_mask=[194], val_mask=[194], test_mask=[194], batch_size=12)
    
  3. 这些子图包含超过 60 个节点,这是正常的,因为任何邻居都可以被采样。我们甚至可以像绘制图一样使用 matplotlib 的子图进行绘制:

    import numpy as np
    import networkx as nx
    import matplotlib.pyplot as plt
    from torch_geometric.utils import to_networkx
    fig = plt.figure(figsize=(16,16))
    for idx, (subdata, pos) in enumerate(zip(train_loader, [221, 222, 223, 224])):
        G = to_networkx(subdata, to_undirected=True)
        ax = fig.add_subplot(pos)
        ax.set_title(f'Subgraph {idx}', fontsize=24)
        plt.axis('off')
        nx.draw_networkx(G,
                        pos=nx.spring_layout(G, seed=0),
                        with_labels=False,
                        node_color=subdata.y,
                        )
    plt.show()
    
  4. 我们得到如下图表:

图 8.5 – 使用邻居采样获得的子图的图表

图 8.5 – 使用邻居采样获得的子图的图表

由于邻居采样的工作方式,大多数节点的度数为 1。在这种情况下,这不是问题,因为它们的嵌入只在计算图中使用一次,以计算第二层的嵌入。

  1. 我们实现了以下函数来评估模型的准确性:

    def accuracy(pred_y, y):
        return ((pred_y == y).sum() / len(y)).item()
    
  2. 让我们使用两个SAGEConv层创建一个GraphSAGE类(默认选择均值聚合器):

    import torchmport torch.nn.functional as F
    from torch_geometric.nn import SAGEConv
    class GraphSAGE(torch.nn.Module):
        def __init__(self, dim_in, dim_h, dim_out):
            super().__init__()
            self.sage1 = SAGEConv(dim_in, dim_h)
            self.sage2 = SAGEConv(dim_h, dim_out)
    
  3. 嵌入是使用两个均值聚合器计算的。我们还使用了一个非线性函数(ReLU)和一个 dropout 层:

       def forward(self, x, edge_index):
            h = self.sage1(x, edge_index)
            h = torch.relu(h)
            h = F.dropout(h, p=0.5, training=self.training)
            h = self.sage2(h, edge_index)
            return F.log_softmax(h, dim=1)
    
  4. 现在我们需要考虑批量处理,fit()函数必须修改为循环遍历 epoch,再遍历批量。我们想要衡量的指标必须在每个 epoch 重新初始化:

        def fit(self, data, epochs):
            criterion = torch.nn.CrossEntropyLoss()
            optimizer = torch.optim.Adam(self.parameters(), lr=0.01)
            self.train()
            for epoch in range(epochs+1):
                total_loss, val_loss, acc, val_acc = 0, 0, 0, 0
    
  5. 第二个循环对每个批量进行模型训练:

                for batch in train_loader:
                    optimizer.zero_grad()
                    out = self(batch.x, batch.edge_index)
                    loss = criterion(out[batch.train_mask], batch.y[batch.train_mask])
                    total_loss += loss
                    acc += accuracy(out[batch.train_mask].argmax(dim=1), batch.y[batch.train_mask])
                    loss.backward()
                    optimizer.step()
                    # Validation
                    val_loss += criterion(out[batch.val_mask], batch.y[batch.val_mask])
                    val_acc += accuracy(out[batch.val_mask].argmax(dim=1), batch.y[batch.val_mask])
    
  6. 我们还希望打印我们的指标。它们必须除以批量数量,以代表一个 epoch:

           if epoch % 20 == 0:
                    print(f'Epoch {epoch:>3} | Train Loss: {loss/len(train_loader):.3f} | Train Acc: {acc/len(train_loader)*100:>6.2f}% | Val Loss: {val_loss/len(train_loader):.2f} | Val Acc: {val_acc/len(train_loader)*100:.2f}%')
    
  7. test()函数没有变化,因为我们在测试集上不使用批量处理:

        @torch.no_grad()
        def test(self, data):
            self.eval()
            out = self(data.x, data.edge_index)
            acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
            return acc
    
  8. 让我们创建一个隐藏层维度为 64 的模型,并训练 200 个 epoch:

    graphsage = GraphSAGE(dataset.num_features, 64, dataset.num_classes)
    print(graphsage)
    graphsage.fit(data, 200)
    
  9. 这给我们带来了以下输出:

    GraphSAGE(
      (sage1): SAGEConv(500, 64, aggr=mean)
      (sage2): SAGEConv(64, 3, aggr=mean)
    )
    Epoch 0 | Train Loss: 0.317 | Train Acc: 28.77% | Val Loss: 1.13 | Val Acc: 19.55%
    Epoch 20 | Train Loss: 0.001 | Train Acc: 100.00% | Val Loss: 0.62 | Val Acc: 75.07%
    Epoch 40 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.55 | Val Acc: 80.56%
    Epoch 60 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.35 | Val Acc: 86.11%
    Epoch 80 | Train Loss: 0.002 | Train Acc: 100.00% | Val Loss: 0.64 | Val Acc: 73.58%
    Epoch 100 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.79 | Val Acc: 74.72%
    Epoch 120 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.71 | Val Acc: 76.75%
    Epoch 140 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.75 | Val Acc: 67.50%
    Epoch 160 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.63 | Val Acc: 73.54%
    Epoch 180 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.47 | Val Acc: 86.11%
    Epoch 200 | Train Loss: 0.000 | Train Acc: 100.00% | Val Loss: 0.48 | Val Acc: 78.37%
    

请注意,均值聚合器被自动选择用于两个 SAGEConv 层。

  1. 最后,让我们在测试集上测试它:

    acc = graphsage.test(data)
    print(f'GraphSAGE test accuracy: {acc*100:.2f}%')
    GraphSAGE test accuracy: 74.70%
    

考虑到该数据集不利的训练/测试集划分,我们得到了 74.70%的不错的测试准确率。然而,GraphSAGE 在PubMed上获得的*均准确率低于 GCN(-0.5%)和 GAT(-1.4%)。那么,我们为什么还要使用它呢?

答案在训练三个模型时显而易见——GraphSAGE 速度极快。在消费者级 GPU 上,它比 GCN 快 4 倍,比 GAT 快 88 倍。即使 GPU 内存不成问题,GraphSAGE 也能处理更大的图,产生比小型网络更好的结果。

为了完成对 GraphSAGE 架构的深入探讨,我们还必须讨论一个特性——它的归纳能力。

在蛋白质-蛋白质相互作用中的归纳学习

在 GNN 中,我们区分两种学习方式——传递学习归纳学习。它们可以总结如下:

  • 在归纳学习中,GNN 在训练过程中只看到训练集的数据。这是机器学习中典型的监督学习设置。在这种情况下,标签用于调整 GNN 的参数。

  • 在传递学习中,GNN 在训练过程中可以看到训练集和测试集的数据。然而,它只学习训练集的数据。在这种情况下,标签用于信息扩散。

传递性学习的情况应该是熟悉的,因为这是我们迄今为止唯一涉及的情况。实际上,你可以从之前的示例中看到,GraphSAGE 在训练过程中使用整个图来进行预测(self(batch.x, batch.edge_index))。然后,我们将这些预测的一部分进行掩码操作来计算损失,并且仅使用训练数据训练模型(criterion(out[batch.train_mask], batch.y[batch.train_mask]))。

传递性学习只能为固定图生成嵌入;它无法对未见过的节点或图进行泛化。然而,由于邻居采样,GraphSAGE 设计为在本地级别进行预测,并通过修剪的计算图来减少计算。它被认为是一个归纳框架,因为它可以应用于任何具有相同特征模式的计算图。

让我们将其应用于一个新的数据集——蛋白质-蛋白质相互作用(PPI)网络,该网络由 Agrawal 等人 [5] 描述。这个数据集包含 24 个图,其中节点(21,557)代表人类蛋白质,边(342,353)表示人类细胞中蛋白质之间的物理相互作用。图 8.6 显示了使用 Gephi 创建的 PPI 表示图。

图 8.6 – 蛋白质-蛋白质相互作用网络的可视化

图 8.6 – 蛋白质-蛋白质相互作用网络的可视化

该数据集的目标是执行多标签分类,共有 121 个标签。这意味着每个节点可以拥有从 0 到 121 的标签。这与多类分类不同,在多类分类中,每个节点只能有一个类别。

让我们使用 PyG 实现一个新的 GraphSAGE 模型:

  1. 我们加载了具有三种不同划分的 PPI 数据集——训练集、验证集和测试集:

    from torch_geometric.datasets import PPI
    train_dataset = PPI(root=".", split='train')
    val_dataset = PPI(root=".", split='val')
    test_dataset = PPI(root=".", split='test')
    
  2. 训练集包含 20 个图,而验证集和测试集只有 2 个。我们希望对训练集应用邻居采样。为了方便起见,让我们将所有训练图统一为一个集合,使用 Batch.from_data_list(),然后应用邻居采样:

    from torch_geometric.data import Batch
    from torch_geometric.loader import NeighborLoader
    train_data = Batch.from_data_list(train_dataset)
    loader = NeighborLoader(train_data, batch_size=2048, shuffle=True, num_neighbors=[20, 10], num_workers=2, persistent_workers=True)
    
  3. 训练集已准备好。我们可以使用 DataLoader 类创建我们的批次。我们定义一个 batch_size 值为 2,对应每个批次中的图的数量:

    from torch_geometric.loader import DataLoader
    train_loader = DataLoader(train_dataset, batch_size=2)
    val_loader = DataLoader(val_dataset, batch_size=2)
    test_loader = DataLoader(test_dataset, batch_size=2)
    
  4. 这些批次的一个主要优势是它们可以在 GPU 上处理。如果有 GPU 可用,我们可以使用 GPU,否则则使用 CPU:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
  5. 我们可以直接使用 PyTorch Geometric 中 torch_geometric.nn 的实现,而不是自己实现 GraphSAGE。我们用两层和 512 的隐藏维度来初始化它。此外,我们需要使用 to(device) 将模型放置到与数据相同的设备上:

    from torch_geometric.nn import GraphSAGE
    model = GraphSAGE(
        in_channels=train_dataset.num_features,
        hidden_channels=512,
        num_layers=2,
        out_channels=train_dataset.num_classes,
    ).to(device)
    
  6. fit() 函数与我们在前一节中使用的类似,有两个例外。首先,当可能时,我们希望将数据移动到 GPU 上。其次,我们每个批次有两个图,因此我们将单独的损失乘以 2(data.num_graphs):

    criterion = torch.nn.BCEWithLogitsLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=0.005)
    def fit():
        model.train()
        total_loss = 0
        for data in train_loader:
            data = data.to(device)
            optimizer.zero_grad()
            out = model(data.x, data.edge_index)
            loss = criterion(out, data.y)
            total_loss += loss.item() * data.num_graphs
            loss.backward()
            optimizer.step()
        return total_loss / len(train_loader.dataset)
    

test() 函数中,我们利用 val_loadertest_loader 各自有两个图和一个 batch_size 为 2 的事实。这意味着这两个图在同一个批次中;我们不需要像训练时那样遍历加载器。

  1. 我们不使用准确率,而是使用另一种度量——F1 分数。它对应的是精确度和召回率的调和*均数。然而,我们的预测是 121 维的实数向量。我们需要将它们转换为二进制向量,使用 out > 0 将其与 data.y 进行比较:

    from sklearn.metrics import f1_score
    @torch.no_grad()
    def test(loader):
        model.eval()
        data = next(iter(loader))
        out = model(data.x.to(device), data.edge_index.to(device))
        preds = (out > 0).float().cpu()
        y, pred = data.y.numpy(), preds.numpy()
        return f1_score(y, pred, average='micro') if pred.sum() > 0 else 0
    
  2. 让我们训练模型 300 个周期,并在训练过程中打印验证 F1 分数:

    for epoch in range(301):
        loss = fit()
        val_f1 = test(val_loader)
        if epoch % 50 == 0:
            print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Val F1 score: {val_f1:.4f}')
    Epoch 0 | Train Loss: 0.589 | Val F1-score: 0.4245
    Epoch 50 | Train Loss: 0.194 | Val F1-score: 0.8400
    Epoch 100 | Train Loss: 0.143 | Val F1-score: 0.8779
    Epoch 150 | Train Loss: 0.123 | Val F1-score: 0.8935
    Epoch 200 | Train Loss: 0.107 | Val F1-score: 0.9013
    Epoch 250 | Train Loss: 0.104 | Val F1-score: 0.9076
    Epoch 300 | Train Loss: 0.090 | Val F1-score: 0.9154
    
  3. 最后,我们计算测试集上的 F1 得分:

    print(f'Test F1 score: {test(test_loader):.4f}')
    Test F1 score: 0.9360
    

在一个归纳设置下,我们获得了 0.9360 的优秀 F1 得分。当你增加或减少隐藏通道的大小时,这个值会发生显著变化。你可以尝试不同的值,比如 128 和 1,024,替代 512。

如果仔细观察代码,会发现没有涉及掩蔽(masking)。事实上,归纳学习是由 PPI 数据集强制的;训练、验证和测试数据分别位于不同的图和加载器中。当然,我们也可以通过 Batch.from_data_list() 将它们合并,从而回到一个传导学习(transductive)的情况。

我们还可以使用无监督学习训练 GraphSAGE,而无需标签。当标签稀缺或由下游应用程序提供时,这尤其有用。然而,这需要一个新的损失函数,以鼓励相邻节点具有相似的表示,同时确保远程节点有不同的嵌入:

这里, 是在随机游走中与 的邻居, 是 Sigmoid 函数, 的负采样分布,而 是负样本的数量:

最后,PinSAGE 和 Uber Eats 版本的 GraphSAGE 是推荐系统。由于应用场景的不同,它们将无监督设置与不同的损失函数结合使用。它们的目标是为每个用户排序最相关的实体(如食物、餐馆、地理位置等),这是一个完全不同的任务。为了实现这一点,它们实现了一种最大边际排名损失,考虑了嵌入对。

如果需要扩展 GNN,其他解决方案可以考虑。以下是两种标准技术的简短描述:

  • Cluster-GCN [6] 提出了一个不同的关于如何创建小批量(mini-batches)的问题答案。它不是使用邻居采样,而是将图划分为独立的社区。然后,这些社区作为独立的图进行处理,这可能会对最终嵌入的质量产生负面影响。

  • 简化 GNN 可以减少训练和推理时间。在实际应用中,简化包括舍弃非线性激活函数。线性层可以通过线性代数压缩为一次矩阵乘法。自然地,这些简化版本在小型数据集上的准确性不如真正的 GNN,但对于大规模图形(如 Twitter [7])来说,它们更加高效。

如你所见,GraphSAGE 是一个灵活的框架,可以根据你的目标进行调整和微调。即使你不重用它的精确公式,它也引入了许多关键概念,这些概念对 GNN 架构有着深远的影响。

摘要

本章介绍了 GraphSAGE 框架及其两个组成部分——邻居采样算法和三种聚合操作符。邻居采样是 GraphSAGE 能够在短时间内处理大规模图形的核心。它也是其归纳设置的关键,使其能够将预测推广到未见过的节点和图形。我们在 PubMed 上测试了一个传递性情境,并在 PPI 数据集上执行了一个新任务——多标签分类。虽然它的准确性不如 GCN 或 GAT,但 GraphSAGE 仍然是一个受欢迎且高效的框架,用于处理海量数据。

第九章图分类的表达能力定义,我们将尝试定义什么使得 GNN 在表示方面强大。我们将介绍一个著名的图算法,称为 Weisfeiler-Lehman 同构性测试。它将作为基准,评估多个 GNN 架构的理论表现,包括图同构网络。我们将应用这个 GNN 来执行一个新的流行任务——图分类。

进一步阅读

  • [1] W. L. Hamilton, R. Ying, 和 J. Leskovec. 大规模图形上的归纳表示学习。arXiv,2017. DOI: 10.48550/ARXIV.1706.02216。

  • [2] R. Ying, R. He, K. Chen, P. Eksombatchai, W. L. Hamilton 和 J. Leskovec. 面向 Web 规模推荐系统的图卷积神经网络。2018 年 7 月. DOI: 10.1145/3219819.3219890。

  • [3] Ankit Jain. 使用 Uber Eats 进行食品发现:利用图学习推动 推荐www.uber.com/en-US/blog/uber-eats-graph-learning/

  • [4] Galileo Mark Namata, Ben London, Lise Getoor 和 Bert Huang. 面向集体分类的查询驱动主动调查。国际图形挖掘与学习研讨会,2012。

  • [5] M. Agrawal, M. Zitnik 和 J. Leskovec. 人类相互作用组中的疾病通路大规模分析。2017 年 11 月. DOI: 10.1142/9789813235533_0011。

  • [6] W.-L. Chiang, X. Liu, S. Si, Y. Li, S. Bengio 和 C.-J. Hsieh. Cluster-GCN。2019 年 7 月. DOI: 10.1145/3292500.3330925。

  • [7] F. Frasca, E. Rossi, D. Eynard, B. Chamberlain, M. Bronstein 和 F. Monti. SIGN:可扩展的启动图神经网络。arXiv,2020. DOI: 10.48550/ARXIV.2004.11198。

第九章:定义图分类的表达能力

在上一章中,我们牺牲了准确性来换取可扩展性。我们看到这种方法在推荐系统等应用中起到了关键作用。然而,这引发了几个问题:究竟是什么使得 GNNs “准确”?这种精度来自哪里?我们能否利用这些知识来设计更好的 GNN?

本章将通过介绍 Weisfeiler-LemanWL)测试来澄清是什么使得 GNN 强大。这个测试将为我们提供理解 GNN 中一个关键概念——表达能力的框架。我们将利用它来比较不同的 GNN 层,并找出哪一层最具表达能力。这个结果将帮助我们设计出比 GCN、GAT 和 GraphSAGE 更强大的 GNN。

最后,我们将使用 PyTorch Geometric 实现一个新任务——图分类。我们将在PROTEINS数据集上实现一个新的 GNN,该数据集包含 1,113 个表示蛋白质的图。我们将比较不同的图分类方法并分析我们的结果。

到本章结束时,你将理解是什么使得 GNN 有表达能力,以及如何衡量它。你将能够实现基于 WL 测试的新 GNN 架构,并使用多种技术进行图分类。

在本章中,我们将讨论以下主要内容:

  • 定义表达能力

  • 介绍 GIN

  • 使用 GIN 进行图分类

技术要求

本章中的所有代码示例可以在 GitHub 上找到,地址是 github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter09

在本书的 前言 部分可以找到在本地机器上运行代码所需的安装步骤。

定义表达能力

神经网络被用来逼*函数。这一点得到了 普适逼*定理 的支持,该定理指出,具有一层的前馈神经网络可以逼*任何*滑函数。那么,图上的普适函数逼*问题呢?这是一个更复杂的问题,要求能够区分图的结构。

在 GNN 中,我们的目标是生成尽可能好的节点嵌入。这意味着不同的节点必须有不同的嵌入,相似的节点必须有相似的嵌入。那么,我们如何知道两个节点是否相似呢?嵌入是通过节点特征和连接来计算的。因此,我们需要比较它们的特征和邻居,以区分节点。

在图论中,这被称为图的 同构 问题。如果两个图具有相同的连接,而它们的唯一区别是节点的排列方式,那么这两个图就是同构的(“相同”)(见 图 9.1)。1968 年,Weisfeiler 和 Lehman [1] 提出了一个高效的算法来解决这个问题,现在被称为 WL 测试。

图 9.1 – 两个同构图的示例

图 9.1 – 两个同构图的示例

WL 测试旨在构建图的标准形式。我们可以比较两个图的标准形式,以检查它们是否同构。然而,这个测试并不完美,非同构图也可能具有相同的标准形式。这可能令人惊讶,但这是一个复杂的问题,目前仍未完全理解;例如,WL 算法的复杂度尚不清楚。

WL 测试如下进行:

  1. 在开始时,图中的每个节点都会获得相同的颜色。

  2. 每个节点都会聚合自身的颜色以及邻居节点的颜色。

  3. 结果会输入到一个哈希函数中,产生一个新的颜色。

  4. 每个节点聚合其新的颜色和邻居节点的新颜色。

  5. 结果会输入到一个哈希函数中,产生一个新的颜色。

  6. 这些步骤会重复进行,直到没有节点的颜色发生变化。

下图总结了 WL 算法。

图 9.2 – WL 算法应用于获取图的标准形式

图 9.2 – WL 算法应用于获取图的标准形式

结果颜色为我们提供了图的标准形式。如果两个图的颜色不相同,它们就不是同构的。相反,如果它们获得了相同的颜色,我们不能确定它们是同构的。

我们描述的步骤应该是熟悉的;它们与 GNN 执行的操作非常相似。颜色是一种嵌入形式,而哈希函数则是一个聚合器。但它不仅仅是任何一个聚合器;哈希函数特别适合这个任务。如果我们将其替换为另一个函数,例如*均值或最大值聚合器(如在第八章中所见),它仍然会高效吗?

让我们看一下每个操作符的结果:

  • 使用*均值聚合器时,1 个蓝色节点和 1 个红色节点,或者 10 个蓝色节点和 10 个红色节点,会产生相同的嵌入(蓝色和红色各占一半)。

  • 使用最大值聚合器时,上一示例中一半的节点将被忽略;嵌入只会考虑蓝色或红色。

  • 然而,使用和法聚合器时,每个节点都参与最终嵌入的计算;拥有 1 个红色节点和 1 个蓝色节点与拥有 10 个蓝色节点和 10 个红色节点是不同的。

确实,和法聚合器能够区分比其他两种聚合器更多的图结构。如果我们遵循这一逻辑,这只能意味着一件事——我们迄今为止使用的聚合器是次优的,因为它们的表达能力严格低于和法。我们能否利用这一知识构建更好的 GNN?在下一节中,我们将基于这个想法介绍图同构网络GIN)。

引入 GIN

在上一节中,我们看到前几章介绍的 GNN 比 WL 测试的表现差。这是一个问题,因为区分更多图结构的能力似乎与结果嵌入的质量密切相关。在本节中,我们将理论框架转化为一种新的 GNN 架构——GIN。

该方法由 Xu 等人于 2018 年在论文《图神经网络有多强大?》[2]中提出,GIN 的设计旨在具备与 WL 测试相同的表达能力。作者通过将聚合操作分为两个函数,概括了我们在聚合中的观察:

  • 聚合:该函数,,选择 GNN 考虑的邻*节点

  • 合并:该函数,,将选定节点的嵌入合并起来,生成目标节点的新嵌入

节点的嵌入可以写作如下:

在 GCN 的情况下, 函数聚合了 节点的每个邻居,且 应用了特定的均值聚合器。在 GraphSAGE 的情况下,邻域采样是 函数,我们看到了 的三种选项——均值、LSTM 和最大值聚合器。

那么,GIN 中的这些函数是什么呢?Xu 等人认为它们必须是 单射。如 图 9.3 所示,单射函数将不同的输入映射到不同的输出。这正是我们想要区分图结构的原因。如果这些函数不是单射的,我们将得到相同的输出对应不同的输入。在这种情况下,我们的嵌入表示的价值会降低,因为它们包含的信息较少。

图 9.3 – 一个单射函数的映射示意图

图 9.3 – 一个单射函数的映射示意图

GIN 的作者使用了一个巧妙的技巧来设计这两个函数——他们简单地对它们进行了逼*。在 GAT 层,我们学习了自注意力权重。在这个例子中,我们可以通过一个单一的 MLP 来学习这两个函数,得益于普适逼*定理:

这里, 是一个可学习的参数或一个固定的标量,表示目标节点的嵌入与其邻居节点嵌入的相对重要性。作者还强调,MLP 必须具有多层,以便区分特定的图结构。

现在我们有了一个与 WL 测试一样具有表现力的 GNN。我们还能做得更好吗?答案是肯定的。WL 测试可以推广到更高层次的测试层次,称为k-WL。与考虑单个节点不同,-WL 测试查看的是-元组节点。这意味着它们是非局部的,因为它们可以查看远距离的节点。这也是为什么-WL 测试能够区分比-WL 测试更多的图结构的原因。

已提出了多种基于-WL 测试的架构,例如 Morris 等人提出的k-GNN [3]。虽然这些架构帮助我们更好地理解 GNN 的工作原理,但它们在实践中往往比不太具有表现力的模型(如 GNN 或 GAT)表现差[4]。但希望并未完全破灭,我们将在下节中看到,在图分类的特定背景下,情况会有所不同。

使用 GIN 进行图分类

我们可以直接实现一个 GIN 模型来进行节点分类,但该架构对于执行图分类更为有趣。在本节中,我们将看到如何使用PROTEINS数据集将节点嵌入转换为图嵌入,并比较我们使用 GIN 和 GCN 模型的结果。

图分类

图分类是基于 GNN 生成的节点嵌入。这一操作通常称为全局池化或图级读出。实现这一操作有三种简单的方法:

  • 均值全局池化:图嵌入是通过对图中每个节点的嵌入取*均值获得的:

  • 最大全局池化:图嵌入是通过为每个节点维度选择最高值来获得的:

  • 求和全局池化:图嵌入是通过对图中每个节点的嵌入求和来获得的:

根据我们在第一节中看到的,求和全局池化在表现力上严格优于其他两种技术。GIN 的作者也指出,为了考虑所有结构信息,有必要考虑 GNN 每一层生成的嵌入。总之,我们将每一层的节点嵌入的和进行连接:

该方案优雅地将求和运算符的表达能力与每层通过连接提供的记忆相结合。

实现 GIN

我们将实现一个 GIN 模型,并在PROTEINS [5, 6, 7]数据集上使用之前的图级读出函数。

该数据集包含 1,113 个图,表示蛋白质,其中每个节点都是一个氨基酸。当两个节点之间的距离小于 0.6 纳米时,它们之间就有一条边。这个数据集的目标是将每个蛋白质分类为。酶是特定类型的蛋白质,作为催化剂加速细胞中的化学反应。例如,被称为脂肪酶的酶帮助消化食物。图 9.4显示了一个蛋白质的 3D 图。

图 9.4 – 一个蛋白质的 3D 示例

图 9.4 – 一个蛋白质的 3D 示例

让我们在这个数据集上实现一个 GIN 模型:

  1. 首先,我们使用 PyTorch Geometric 中的TUDataset类导入PROTEINS数据集,并打印信息:

    from torch_geometric.datasets import TUDataset
    dataset = TUDataset(root='.', name='PROTEINS').shuffle()
    print(f'Dataset: {dataset}')
    print('-----------------------')
    print(f'Number of graphs: {len(dataset)}')
    print(f'Number of nodes: {dataset[0].x.shape[0]}')
    print(f'Number of features: {dataset.num_features}')
    print(f'Number of classes: {dataset.num_classes}')
    Dataset: PROTEINS(1113)
    -----------------------
    Number of graphs: 1113
    Number of nodes: 30
    Number of features: 0
    Number of classes: 2
    
  2. 我们将数据(图)按照 80/10/10 的比例分为训练集、验证集和测试集:

    from torch_geometric.loader import DataLoader
    train_dataset = dataset[:int(len(dataset)*0.8)]
    val_dataset   = dataset[int(len(dataset)*0.8):int(len(dataset)*0.9)]
    test_dataset  = dataset[int(len(dataset)*0.9):]
    print(f'Training set   = {len(train_dataset)} graphs')
    print(f'Validation set = {len(val_dataset)} graphs')
    print(f'Test set       = {len(test_dataset)} graphs')
    
  3. 这将给我们以下输出:

    Training set   = 890 graphs
    Validation set = 111 graphs
    Test set       = 112 graphs
    
  4. 我们使用DataLoader对象将这些拆分转换为迷你批次,批量大小为 64。这意味着每个批次将包含最多 64 个图:

    train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
    val_loader   = DataLoader(val_dataset, batch_size=64, shuffle=True)
    test_loader  = DataLoader(test_dataset, batch_size=64, shuffle=True)
    
  5. 我们可以通过打印每个批次的信息来验证这一点,如下所示:

    print('\nTrain loader:')
    for i, batch in enumerate(train_loader):
        print(f' - Batch {i}: {batch}')
    print('\nValidation loader:')
    for i, batch in enumerate(val_loader):
        print(f' - Batch {i}: {batch}')
    print('\nTest loader:')
    for i, batch in enumerate(test_loader):
        print(f' - Batch {i}: {batch}')
    Train loader:
     - Batch 0: DataBatch(edge_index=[2, 8622], x=[2365, 0], y=[64], batch=[2365], ptr=[65])
     - Batch 1: DataBatch(edge_index=[2, 6692], x=[1768, 0], y=[64], batch=[1768], ptr=[65])
    …
     - Batch 13: DataBatch(edge_index=[2, 7864], x=[2102, 0], y=[58], batch=[2102], ptr=[59])
    Validation loader:
     - Batch 0: DataBatch(edge_index=[2, 8724], x=[2275, 0], y=[64], batch=[2275], ptr=[65])
     - Batch 1: DataBatch(edge_index=[2, 8388], x=[2257, 0], y=[47], batch=[2257], ptr=[48])
    Test loader:
     - Batch 0: DataBatch(edge_index=[2, 7906], x=[2187, 0], y=[64], batch=[2187], ptr=[65])
     - Batch 1: DataBatch(edge_index=[2, 9442], x=[2518, 0], y=[48], batch=[2518], ptr=[49])
    

让我们开始实现一个 GIN 模型。我们首先需要回答的问题是 GIN 层的组成。我们需要一个至少有两层的 MLP。根据作者的指导,我们还可以引入批量归一化来标准化每个隐藏层的输入,这样可以稳定并加速训练。总而言之,我们的 GIN 层具有以下组成:

在代码中,它被定义如下:

import torch
torch.manual_seed(0)
import torch.nn.functional as F
from torch.nn import Linear, Sequential, BatchNorm1d, ReLU, Dropout
from torch_geometric.nn import GINConv
from torch_geometric.nn import global_add_pool
class GIN(torch.nn.Module):
    def __init__(self, dim_h):
        super(GIN, self).__init__()
        self.conv1 = GINConv(
            Sequential(Linear(dataset.num_node_features, dim_h), BatchNorm1d(dim_h), ReLU(), Linear(dim_h, dim_h), ReLU()))
        self.conv2 = GINConv(
            Sequential(Linear(dim_h, dim_h), BatchNorm1d(dim_h), ReLU(), Linear(dim_h, dim_h), ReLU()))
        self.conv3 = GINConv(
            Sequential(Linear(dim_h, dim_h), BatchNorm1d(dim_h), ReLU(), Linear(dim_h, dim_h), ReLU()))

注意

PyTorch Geometric 还提供了 GINE 层,这是 GIN 层的修改版。它在 2019 年由 Hu 等人在《图神经网络预训练策略》[8]中提出。与之前的 GIN 版本相比,它的主要改进是能够在聚合过程中考虑边缘特征。PROTEINS数据集没有边缘特征,这就是为什么我们将实现经典的 GIN 模型。

  1. 我们的模型还不完整。我们必须记住,我们的目标是进行图分类。图分类要求对每一层中的每个节点嵌入进行求和。换句话说,我们需要为每一层存储一个dim_h大小的向量——在这个例子中是三个。这就是为什么在最终的二分类线性层前,我们会添加一个3*dim_h大小的线性层(data.num_classes = 2):

            self.lin1 = Linear(dim_h*3, dim_h*3)
            self.lin2 = Linear(dim_h*3, dataset.num_classes)
    
  2. 我们必须实现逻辑来连接我们初始化的层。每一层都会生成不同的嵌入张量——h1h2h3。我们使用global_add_pool()函数将它们求和,然后使用torch.cat()将它们连接起来。这就为我们的分类器提供了输入,分类器作为一个普通的神经网络,带有一个 dropout 层:

        def forward(self, x, edge_index, batch):
            # Node embeddings
            h1 = self.conv1(x, edge_index)
            h2 = self.conv2(h1, edge_index)
            h3 = self.conv3(h2, edge_index)
            # Graph-level readout
            h1 = global_add_pool(h1, batch)
            h2 = global_add_pool(h2, batch)
            h3 = global_add_pool(h3, batch)
            # Concatenate graph embeddings
            h = torch.cat((h1, h2, h3), dim=1)
            # Classifier
            h = self.lin1(h)
            h = h.relu()
            h = F.dropout(h, p=0.5, training=self.training)
            h = self.lin2(h)
            return F.log_softmax(h, dim=1)
    
  3. 现在,我们可以实现一个常规的训练循环,使用迷你批次进行 100 个 epoch:

    def train(model, loader):
        criterion = torch.nn.CrossEntropyLoss()
        optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
        epochs = 100
        model.train()
        for epoch in range(epochs+1):
            total_loss = 0
            acc = 0
            val_loss = 0
            val_acc = 0
            # Train on batches
            for data in loader:
                optimizer.zero_grad()
                out = model(data.x, data.edge_index, data.batch)
                loss = criterion(out, data.y)
                total_loss += loss / len(loader)
                acc += accuracy(out.argmax(dim=1), data.y) / len(loader)
                loss.backward()
                optimizer.step()
                # Validation
                val_loss, val_acc = test(model, val_loader)
    
  4. 我们每 20 个 epoch 打印一次训练和验证的准确率,并返回训练好的模型:

            # Print metrics every 20 epochs
            if(epoch % 20 == 0):
                print(f'Epoch {epoch:>3} | Train Loss: {total_loss:.2f} | Train Acc: {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | Val Acc: {val_acc*100:.2f}%')
        return model
    
  5. 与上一章中的 test 函数不同,这个函数还必须包括小批量处理,因为我们的验证和测试加载器包含多个批次:

    @torch.no_grad()
    def test(model, loader):
        criterion = torch.nn.CrossEntropyLoss()
        model.eval()
        loss = 0
        acc = 0
        for data in loader:
            out = model(data.x, data.edge_index, data.batch)
            loss += criterion(out, data.y) / len(loader)
            acc += accuracy(out.argmax(dim=1), data.y) / len(loader)
        return loss, acc
    
  6. 我们定义将用于计算准确率得分的函数:

    def accuracy(pred_y, y):
        return ((pred_y == y).sum() / len(y)).item()
    
  7. 让我们实例化并训练我们的 GIN 模型:

    gin = GIN(dim_h=32)
    gin = train(gin, train_loader)
    Epoch 0 | Train Loss: 1.33 | Train Acc: 58.04% | Val Loss: 0.70 | Val Acc: 59.97%
    Epoch 20 | Train Loss: 0.54 | Train Acc: 74.50% | Val Loss: 0.55 | Val Acc: 76.86%
    Epoch 40 | Train Loss: 0.50 | Train Acc: 76.28% | Val Loss: 0.56 | Val Acc: 74.73%
    Epoch 60 | Train Loss: 0.50 | Train Acc: 76.77% | Val Loss: 0.54 | Val Acc: 72.04%
    Epoch 80 | Train Loss: 0.49 | Train Acc: 76.95% | Val Loss: 0.57 | Val Acc: 73.67%
    Epoch 100 | Train Loss: 0.50 | Train Acc: 76.04% | Val Loss: 0.53 | Val Acc: 69.55%
    
  8. 最后,让我们使用测试加载器进行测试:

    test_loss, test_acc = test(gin, test_loader)
    print(f'Test Loss: {test_loss:.2f} | Test Acc: {test_acc*100:.2f}%')
    Test Loss: 0.44 | Test Acc: 81.77%
    

为了更好地理解这个最终测试得分,我们可以实现一个 GCN,该 GCN 使用简单的全局*均池化(在 PyTorch Geometric 中为 global_mean_pool())执行图分类。在完全相同的设置下,它在 100 次实验中获得了*均准确率 53.72%(± 0.73%)。这远低于 GIN 模型的*均准确率 76.56%(± 1.77%)。

我们可以得出结论,整个 GIN 架构比 GCN 更适合这个图分类任务。根据我们使用的理论框架,这可以通过 GCN 在表达能力上严格低于 GIN 来解释。换句话说,GIN 可以区分比 GCN 更多的图结构,这就是为什么它们更准确的原因。我们可以通过可视化两个模型的错误来验证这个假设:

  1. 我们导入 matplotlibnetworkx 库,以绘制一个 4x4 的蛋白质图:

    import numpy as np
    import networkx as nx
    import matplotlib.pyplot as plt
    from torch_geometric.utils import to_networkx
    fig, ax = plt.subplots(4, 4)
    
  2. 对于每个蛋白质,我们从我们的 GNN(此处为 GIN)中获取最终分类。如果预测正确,我们给它绿色(否则为红色):

    for i, data in enumerate(dataset[-16:]):
        out = gcn(data.x, data.edge_index, data.batch)
        color = "green" if out.argmax(dim=1) == data.y else "red"
    
  3. 我们为了方便将蛋白质转化为 networkx 图。然后,我们可以使用 nx.draw_networkx() 函数绘制它:

        ix = np.unravel_index(i, ax.shape)
        ax[ix].axis('off')
        G = to_networkx(dataset[i], to_undirected=True)
        nx.draw_networkx(G,
                        pos=nx.spring_layout(G, seed=0),
                        with_labels=False,
                        node_size=10,
                        node_color=color,
                        width=0.8,
                        ax=ax[ix]
                        )
    
  4. 我们为 GIN 模型获得了以下图示。

图 9.5 – GIN 模型生成的图分类

图 9.5 – GIN 模型生成的图分类

  1. 我们为 GCN 重复这个过程,并获得以下可视化结果。

图 9.6 – GCN 模型生成的图分类

图 9.6 – GCN 模型生成的图分类

正如预期的那样,GCN 模型犯了更多的错误。要理解哪些图结构没有被充分捕捉,需要对每个被 GIN 正确分类的蛋白质进行广泛分析。然而,我们可以看到,GIN 也会犯不同的错误。这一点很有趣,因为它表明这些模型可以互为补充。

从犯不同错误的模型创建集成是机器学习中的常见技术。我们可以使用不同的方法,例如训练一个新的模型来处理我们的最终分类。由于本章的目标不是创建集成模型,因此我们将实现一个简单的模型*均技术:

  1. 首先,我们将模型设置为评估模式,并定义用于存储准确率得分的变量:

    gcn.eval()
    gin.eval()
    acc_gcn = 0
    acc_gin = 0
    acc_ens = 0
    
  2. 我们获取每个模型的最终分类,并将它们结合起来获得集成模型的预测结果:

    for data in test_loader:
        out_gcn = gcn(data.x, data.edge_index, data.batch)
        out_gin = gin(data.x, data.edge_index, data.batch)
        out_ens = (out_gcn + out_gin)/2
    
  3. 我们计算了三个预测集的准确率得分:

        acc_gcn += accuracy(out_gcn.argmax(dim=1), data.y) / len(test_loader)
        acc_gin += accuracy(out_gin.argmax(dim=1), data.y) / len(test_loader)
        acc_ens += accuracy(out_ens.argmax(dim=1), data.y) / len(test_loader)
    
  4. 最后,让我们打印结果:

    print(f'GCN accuracy:     {acc_gcn*100:.2f}%')
    print(f'GIN accuracy:     {acc_gin*100:.2f}%')
    print(f'GCN+GIN accuracy: {acc_ens*100:.2f}%')
    GCN accuracy: 72.14%
    GIN accuracy: 80.99%
    GCN+GIN accuracy: 81.25%
    

在这个示例中,我们的集成模型的准确率为 81.25%,超越了两个单独模型(GCN 为 72.14%,GIN 为 80.99%)。这个结果非常显著,因为它展示了这种技术所提供的可能性。然而,这并不一定适用于所有情况;即使在这个例子中,集成模型也未能始终超越 GIN。我们可以通过加入其他架构的嵌入,例如 Node2Vec,来丰富该模型,看看是否能提高最终准确率。

总结

在本章中,我们定义了 GNNs 的表达能力。这个定义基于另一种算法——WL 方法,该算法输出图的规范形式。这个算法并不完美,但可以区分大多数图结构。它启发了 GIN 架构的设计,旨在具有与 WL 测试一样强的表达能力,因此,它比 GCN、GAT 或 GraphSAGE 更具表达能力。

我们随后实现了这个架构用于图分类。我们看到了一些不同的方法来将节点嵌入合并成图嵌入。GIN 提供了一种新技术,结合了求和运算符和每个 GIN 层产生的图嵌入的拼接。它显著优于使用 GCN 层获得的经典全局均值池化方法。最后,我们将两种模型的预测结果组合成一个简单的集成模型,进一步提高了准确率。

第十章《使用图神经网络预测链接》中,我们将探讨另一个与 GNNs 相关的流行任务——链接预测。事实上,这并不完全是新鲜的,因为我们之前看到的技术,如 DeepWalkNode2Vec,已经基于这个思想。我们将解释原因,并介绍两个新的 GNN 框架——图(变分)自编码器和 SEAL。最后,我们将在 Cora 数据集上实现并比较它们在链接预测任务上的表现。

进一步阅读

  • [1] Weisfeiler 和 Lehman, A.A. (1968) 图的规范化形式及其在该规范化过程中出现的代数。Nauchno-Technicheskaya Informatsia, 9.

  • [2] K. Xu, W. Hu, J. Leskovec 和 S. Jegelka, 图神经网络有多强大? arXiv, 2018. doi: 10.48550/ARXIV.1810.00826.

  • [3] C. Morris 等, Weisfeiler 和 Leman 走向神经:高阶图神经网络。arXiv, 2018. doi: 10.48550/ARXIV.1810.02244.

  • [4] V. P. Dwivedi 等. 图神经网络基准测试。arXiv, 2020. doi: 10.48550/ARXIV.2003.00982.

  • [5] K. M. Borgwardt, C. S. Ong, S. Schoenauer, S. V. N. Vishwanathan, A. J. Smola 和 H. P. Kriegel. 通过图核进行蛋白质功能预测。Bioinformatics, 21(Suppl 1):i47–i56, 2005 年 6 月。

  • [6] P. D. Dobson 和 A. J. Doig. 区分酶结构与非酶结构,无需对齐。J. Mol. Biol., 330(4):771–783, 2003 年 7 月。

  • [7] Christopher Morris、Nils M. Kriege、Franka Bause、Kristian Kersting、Petra Mutzel 和 Marion Neumann。TUDataset:一个用于图学习的基准数据集集合。发表于 2020 年 ICML 图表示学习及其扩展研讨会。

  • [8] W. Hu 等人,预训练图神经网络的策略。arXiv,2019 年。doi: 10.48550/ARXIV.1905.12265。

第十章:使用图神经网络预测链接

链接预测是图形分析中最常见的任务之一。它被定义为预测两个节点之间是否存在链接的问题。这一能力在社交网络和推荐系统中起着核心作用。一个好的例子是社交媒体网络如何展示你与他人共有的朋友和关注者。如果这个数字很高,你更可能与这些人建立连接。这种可能性正是链接预测试图估算的内容。

在本章中,我们首先将看到如何在没有任何机器学习的情况下执行链接预测。这些传统技术对于理解 GNN 所学的内容至关重要。接着,我们将参考前几章关于DeepWalkNode2Vec的内容,通过矩阵分解进行链接预测。不幸的是,这些技术有显著的局限性,这就是为什么我们将转向基于 GNN 的方法。

我们将探讨来自两个不同范畴的三种方法。第一个范畴基于节点嵌入,并执行基于图神经网络(GNN)的矩阵分解。第二种方法则侧重于子图表示。每个链接(无论真假)周围的邻域被视为输入,用于预测链接的概率。最后,我们将在 PyTorch Geometric 中实现每个范畴的模型。

到本章结束时,你将能够实现各种链接预测技术。给定一个链接预测问题,你将知道哪种技术最适合解决它——启发式方法、矩阵分解、基于 GNN 的嵌入,或基于子图的技术。

在本章中,我们将涵盖以下主要内容:

  • 使用传统方法预测链接

  • 使用节点嵌入预测链接

  • 使用 SEAL 预测链接

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址为 github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter10

在本书的前言部分可以找到在本地机器上运行代码所需的安装步骤。

使用传统方法预测链接

链接预测问题已经存在很长时间,这也是为何提出了许多技术来解决这个问题。本节首先将描述基于局部和全局邻域的常用启发式方法。接着,我们将介绍矩阵分解及其与 DeepWalk 和 Node2Vec 的关系。

启发式技术

启发式技术是一种简单而实用的方式,用于预测节点之间的链接。它们易于实现,并为这一任务提供了强大的基准。我们可以根据它们执行的跳数对它们进行分类(见图 10.1)。其中一些方法只需要考虑与目标节点相邻的 1 跳邻居。而更复杂的技术则考虑 2 跳邻居或整个图。在本节中,我们将它们分为两类——局部(1 跳和 2 跳)和全局启发式方法。

图 10.1 – 包含 1 跳、2 跳和 3 跳邻居的图

图 10.1 – 包含 1 跳、2 跳和 3 跳邻居的图

局部启发式方法通过考虑两个节点的局部邻域来衡量它们之间的相似性。我们用 来表示节点 的邻居。以下是三种流行的局部启发式方法的示例:

  • 共同邻居仅计算两个节点共同拥有的邻居数量(1 跳邻居)。其思想类似于我们之前在社交网络中的示例——你们有共同邻居的数量越多,你们越有可能被连接:

  • Jaccard 系数衡量两个节点共享的邻居(1 跳邻居)的比例。它基于与共同邻居相同的理念,但通过邻居的总数对结果进行归一化。这会奖励邻居数量少的节点,而不是度数高的节点:

  • Adamic–Adar 指数对两个目标节点共享的邻居的逆对数度数进行求和(2 跳邻居)。其思想是,具有大规模邻域的共同邻居不如具有小规模邻域的共同邻居重要。因此,它们在最终评分中的重要性应该较低:

所有这些技术都依赖于邻居节点的度数,无论它们是直接的(共同邻居或 Jaccard 系数)还是间接的(Adamic–Adar 指数)。这对于速度和可解释性有益,但也限制了它们能够捕捉的关系的复杂性。

全局启发式方法通过考虑整个网络而不是局部邻域来解决这个问题。以下是两个著名的例子:

  • Katz 指数计算两个节点之间每条可能路径的加权和。权重对应于折扣因子,(通常在 0.8 到 0.9 之间),用于惩罚较长的路径。根据这个定义,如果两个节点之间有许多(最好是短的)路径,它们更有可能连接。可以使用邻接矩阵的幂来计算任意长度的路径,,这就是为什么 Katz 指数定义如下:

  • DeepWalkNode2Vec 算法。

全局启发式方法通常更准确,但需要了解整个图。尽管如此,这些方法并不是唯一能够通过这些知识预测链接的方式。

矩阵分解

链接预测的矩阵分解受到了推荐系统[2]中先前工作的启发。通过这项技术,我们通过预测整个邻接矩阵!间接预测链接。该操作是通过节点嵌入来完成的——相似的节点,,应该有相似的嵌入,。通过点积,我们可以将其写成如下形式:

  • 如果这些节点相似,应该是最大的

  • 如果这些节点是不同的,应该是最小的

到目前为止,我们假设相似的节点应该是连接的。这就是我们可以使用点积来*似邻接矩阵每个元素(链接)的原因!

就矩阵乘法而言,我们有以下公式:

这里,是节点嵌入矩阵。下图展示了矩阵分解如何工作的可视化解释:

图 10.2 – 使用节点嵌入的矩阵乘法

图 10.2 – 使用节点嵌入的矩阵乘法

这项技术被称为矩阵分解,因为邻接矩阵!被分解为两个矩阵的乘积。目标是学习相关的节点嵌入,以最小化图中真实元素和预测元素之间的 L2 范数!和!

矩阵分解的更高级变种包括拉普拉斯矩阵和的幂。另一种解决方案是使用诸如DeepWalkNode2Vec之类的模型。它们生成的节点嵌入可以配对以创建链接表示。根据 Qiu 等人[3]的研究,这些算法隐式地*似并分解复杂矩阵。例如,这是DeepWalk计算的矩阵:

这里,是负采样的参数。同样的情况也适用于类似的算法,如 LINE 和 PTE。尽管它们能够捕捉更复杂的关系,但它们仍然存在我们在第三章第四章中看到的相同局限性:

  • 它们无法使用节点特征:它们仅使用拓扑信息来创建嵌入

  • 它们没有归纳能力:它们无法对训练集以外的节点进行泛化

  • 它们无法捕捉结构相似性:图中的结构相似节点可能获得完全不同的嵌入

这些局限性促使了基于 GNN(图神经网络)技术的需求,正如我们将在接下来的章节中看到的那样。

使用节点嵌入预测链接

在前面的章节中,我们讨论了如何使用 GNN 生成节点嵌入。一种流行的链路预测技术是使用这些嵌入进行矩阵分解。本节将讨论两种用于链路预测的 GNN 架构——图自编码器GAE)和变分图自编码器VGAE)。

引入图自编码器

这两种架构由 Kipf 和 Welling 在 2016 年[5]的三页论文中介绍。它们代表了两种流行神经网络架构的 GNN 对应物——自编码器和变分自编码器。对这些架构的先验知识是有帮助的,但不是必须的。为了便于理解,我们将首先关注 GAE。

GAE 由两个模块组成:

  • 编码器是一个经典的两层 GCN,通过以下方式计算节点嵌入:

  • 解码器使用矩阵分解和 sigmoid 函数来逼*邻接矩阵,,输出概率:

请注意,我们并不是要对节点或图进行分类。目标是为邻接矩阵的每个元素预测一个概率(在 0 和 1 之间),。这就是为什么 GAE 使用二元交叉熵损失(负对数似然)来训练,即两个邻接矩阵之间的元素:

然而,邻接矩阵通常非常稀疏,这会使 GAE 偏向于预测零值。有两种简单的技术可以解决这个偏差。首先,我们可以在前面的损失函数中添加一个权重,以偏向。其次,我们可以在训练过程中采样更少的零值,从而使标签更加*衡。后一种技术是 Kipf 和 Welling 实施的。

这种架构是灵活的——编码器可以替换为另一种类型的 GNN(例如,GraphSAGE),并且可以用 MLP 来替代解码器。另一个可能的改进是将 GAE 转化为概率变种——变分 GAE。

引入 VGAEs

GAEs 和 VGAEs 之间的区别就像自编码器和变分自编码器之间的区别一样。VGAEs 不是直接学习节点嵌入,而是学习正态分布,然后从中进行采样以生成嵌入。它们也分为两个模块:

  • 编码器由两个共享第一层的 GCN 组成。目标是学习每个潜在正态分布的参数——均值,(由学习),和方差,(实际上,学习)。

  • 解码器从学习到的分布中采样嵌入,,使用重参数化技巧[4]。然后,它使用潜在变量之间的相同内积来*似邻接矩阵,

在 VGAE 中,确保编码器输出符合正态分布是很重要的。这就是为什么我们要在损失函数中添加一个新项——Kullback-LeiblerKL)散度,它衡量两个分布之间的差异。我们得到以下损失,也称为证据下界ELBO):

这里,表示编码器,的先验分布。

模型的性能通常使用两个指标来评估——ROC 曲线下面积(AUROC)和*均精度(AP)。

让我们看看如何使用 PyTorch Geometric 实现 VGAE。

实现 VGAE

与之前的 GNN 实现相比,主要有两个区别:

  • 我们将对数据集进行预处理,以移除那些随机预测的链接。

  • 我们将创建一个编码器模型,并将其输入到VGAE类中,而不是从头开始直接实现 VGAE。

以下代码灵感来自 PyTorch Geometric 的 VGAE 示例:

  1. 首先,我们导入所需的库:

    import numpy as np
    np.random.seed(0)
    import torch
    torch.manual_seed(0)
    import matplotlib.pyplot as plt
    import torch_geometric.transforms as T
    from torch_geometric.datasets import Planetoid
    
  2. 如果 GPU 可用,我们会尝试使用它:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
  3. 我们创建一个transform对象,它会对输入特征进行归一化,直接执行张量设备转换,并随机分割链接。在这个例子中,我们有 85/5/10 的分割比例。add_negative_train_samples参数设置为False,因为模型已经执行了负采样,因此数据集中不需要负样本:

    transform = T.Compose([
        T.NormalizeFeatures(),
        T.ToDevice(device),
        T.RandomLinkSplit(num_val=0.05, num_test=0.1, is_undirected=True, split_labels=True, add_negative_train_samples=False),
    ])
    
  4. 我们使用之前的transform对象加载Cora数据集:

    dataset = Planetoid('.', name='Cora', transform=transform)
    
  5. RandomLinkSplit通过设计生成训练/验证/测试分割。我们将这些分割存储如下:

    train_data, val_data, test_data = dataset[0]
    
  6. 现在,让我们实现编码器。首先,我们需要导入GCNConvVGAE

    from torch_geometric.nn import GCNConv, VGAE
    
  7. 我们声明一个新类。在这个类中,我们需要三个 GCN 层——一个共享层,第二个层来*似均值,,第三个层来*似方差值(实际上是对数标准差,):

    class Encoder(torch.nn.Module):
        def __init__(self, dim_in, dim_out):
            super().__init__()
            self.conv1 = GCNConv(dim_in, 2 * dim_out)
            self.conv_mu = GCNConv(2 * dim_out, dim_out)
            self.conv_logstd = GCNConv(2 * dim_out, dim_out)
        def forward(self, x, edge_index):
            x = self.conv1(x, edge_index).relu()
            return self.conv_mu(x, edge_index), self.conv_logstd(x, edge_index)
    
  8. 我们可以初始化 VGAE 并将编码器作为输入。默认情况下,它将使用内积作为解码器:

    model = VGAE(Encoder(dataset.num_features, 16)).to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
    
  9. train()函数包括两个重要步骤。首先,嵌入矩阵,,是通过model.encode()计算的;这个名字可能有些不直观,但这个函数确实是从学习到的分布中采样嵌入。然后,计算 ELBO 损失,使用model.recon_loss()(二元交叉熵损失)和model.kl_loss()(KL 散度)。解码器会隐式调用来计算交叉熵损失:

    def train():
        model.train()
        optimizer.zero_grad()
        z = model.encode(train_data.x, train_data.edge_index)
        loss = model.recon_loss(z, train_data.pos_edge_label_index) + (1 / train_data.num_nodes) * model.kl_loss()
        loss.backward()
        optimizer.step()
        return float(loss)
    
  10. test()函数只是调用 VGAE 的专用方法:

    @torch.no_grad()
    def test(data):
        model.eval()
        z = model.encode(data.x, data.edge_index)
        return model.test(z, data.pos_edge_label_index, data.neg_edge_label_index)
    
  11. 我们将该模型训练了 301 个 epoch,并输出了两个内置指标——AUC 和 AP:

    for epoch in range(301):
        loss = train()
        val_auc, val_ap = test(val_data)
        if epoch % 50 == 0:
            print(f'Epoch {epoch:>2} | Loss: {loss:.4f} | Val AUC: {val_auc:.4f} | Val AP: {val_ap:.4f}')
    
  12. 我们获得了以下输出:

    Epoch 0 | Loss: 3.4210 | Val AUC: 0.6772 | Val AP: 0.7110
    Epoch 50 | Loss: 1.3324 | Val AUC: 0.6593 | Val AP: 0.6922
    Epoch 100 | Loss: 1.1675 | Val AUC: 0.7366 | Val AP: 0.7298
    Epoch 150 | Loss: 1.1166 | Val AUC: 0.7480 | Val AP: 0.7514
    Epoch 200 | Loss: 1.0074 | Val AUC: 0.8390 | Val AP: 0.8395
    Epoch 250 | Loss: 0.9541 | Val AUC: 0.8794 | Val AP: 0.8797
    Epoch 300 | Loss: 0.9509 | Val AUC: 0.8833 | Val AP: 0.8845
    
  13. 我们在测试集上评估了我们的模型:

    test_auc, test_ap = test(test_data)
    print(f'Test AUC: {test_auc:.4f} | Test AP {test_ap:.4f}')
    Test AUC: 0.8833 | Test AP 0.8845
    
  14. 最后,我们可以手动计算*似的邻接矩阵,!

    z = model.encode(test_data.x, test_data.edge_index)
    Ahat = torch.sigmoid(z @ z.T)
    tensor([[0.8846, 0.5068, ..., 0.5160, 0.8309, 0.8378],
            [0.5068, 0.8741, ..., 0.3900, 0.5367, 0.5495],
            [0.7074, 0.7878, ..., 0.4318, 0.7806, 0.7602],
            ...,
            [0.5160, 0.3900, ..., 0.5855, 0.5350, 0.5176],
            [0.8309, 0.5367, ..., 0.5350, 0.8443, 0.8275],
            [0.8378, 0.5495, ..., 0.5176, 0.8275, 0.8200]
      ], device='cuda:0', grad_fn=<SigmoidBackward0>)
    

训练 VGAE 快速且输出结果易于理解。然而,我们看到 GCN 并不是最具表现力的操作符。为了提高模型的表现力,我们需要结合更好的技术。

使用 SEAL 进行链接预测

上一节介绍了基于节点的方法,这些方法学习相关的节点嵌入来计算链接的可能性。另一种方法是观察目标节点周围的局部邻域。这些技术被称为基于子图的算法,并由SEAL推广(虽然不一定总是如此,可以理解为子图、嵌入和属性用于链接预测的缩写)。在本节中,我们将描述 SEAL 框架,并使用 PyTorch Geometric 实现它。

引入 SEAL 框架

由张和陈在 2018 年提出的[6],SEAL 是一个用于链接预测的框架,旨在学习图结构特征。它定义了由目标节点!及其!跳邻居所构成的子图为封闭子图。每个封闭子图被用作输入(而不是整个图)来预测链接的可能性。另一种看法是,SEAL 自动学习一个本地启发式规则来进行链接预测。

该框架包含三个步骤:

  1. 封闭子图提取,包括采用一组真实链接和一组虚假链接(负采样)来构成训练数据。

  2. 节点信息矩阵构建,涉及三个组成部分——节点标签、节点嵌入和节点特征。

  3. GNN 训练,它以节点信息矩阵作为输入,输出链接的可能性。

这些步骤在下图中进行了总结:

图 10.3 – SEAL 框架

图 10.3 – SEAL 框架

封闭子图提取是一个直接的过程。它包括列出目标节点及其!跳邻居,以提取它们的边和特征。较高的!将提高 SEAL 可以学习到的启发式规则的质量,但也会生成更大的子图,从而增加计算开销。

节点信息构建的第一个组成部分是节点标签。这个过程为每个节点分配一个特定的编号。如果没有这个步骤,GNN 将无法区分目标节点和上下文节点(它们的邻居)。它还嵌入了距离信息,描述节点的相对位置和结构重要性。

在实际应用中,目标节点,,必须共享一个唯一标签,以便将它们识别为目标节点。对于上下文节点,,如果它们与目标节点的距离相同——,则必须共享相同的标签。我们称这种距离为双半径,记作

可以考虑不同的解决方案,但 SEAL 的作者提出了 双半径节点标签化DRNL)算法。其工作原理如下:

  1. 首先,将标签 1 分配给

  2. 将标签 1 分配给半径为 的节点。

  3. 将标签 3 分配给半径为 的节点。

  4. 将标签 4 分配给半径为 等的节点。

DRNL 函数可以写作如下:

这里, 是分别将 除以 2 后得到的整数商和余数。最后,这些节点标签被一热编码。

注意

另外两个组件较易获取。节点嵌入是可选的,但可以使用其他算法(如 Node2Vec)计算。然后,它们与节点特征和一热编码的标签一起连接,以构建最终的节点信息矩阵。

最后,训练一个 GNN 来预测链接,使用封闭子图的信息和邻接矩阵。在此任务中,SEAL 的作者选择了 深度图卷积神经网络DGCNN)[7]。该架构执行三个步骤:

  1. 几个 GCN 层计算节点嵌入,然后将其连接(像 GIN 一样)。

  2. 一个全局排序池化层会在将这些嵌入输入卷积层之前,按照一致的顺序对它们进行排序,因为卷积层不是排列不变的。

  3. 传统的卷积和密集层应用于排序后的图表示,并输出链接概率。

DGCNN 模型使用二元交叉熵损失进行训练,并输出介于 01 之间的概率。

实现 SEAL 框架

SEAL 框架需要大量的预处理来提取和标记封闭子图。我们通过 PyTorch Geometric 来实现它:

  1. 首先,我们导入所有必要的库:

    import numpy as np
    from sklearn.metrics import roc_auc_score, average_precision_score
    from scipy.sparse.csgraph import shortest_path
    import torch
    import torch.nn.functional as F
    from torch.nn import Conv1d, MaxPool1d, Linear, Dropout, BCEWithLogitsLoss
    from torch_geometric.datasets import Planetoid
    from torch_geometric.transforms import RandomLinkSplit
    from torch_geometric.data import Data
    from torch_geometric.loader import DataLoader
    from torch_geometric.nn import GCNConv, aggr
    from torch_geometric.utils import k_hop_subgraph, to_scipy_sparse_matrix
    
  2. 我们加载 Cora 数据集并应用链接级随机拆分,如前一节所示:

    transform = RandomLinkSplit(num_val=0.05, num_test=0.1, is_undirected=True, split_labels=True)
    dataset = Planetoid('.', name='Cora', transform=transform)
    train_data, val_data, test_data = dataset[0]
    
  3. 链接级随机拆分在 Data 对象中创建新字段,用于存储每个正(真实)和负(伪造)边的标签和索引:

    train_data
    Data(x=[2708, 1433], edge_index=[2, 8976], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708], pos_edge_label=[4488], pos_edge_label_index=[2, 4488], neg_edge_label=[4488], neg_edge_label_index=[2, 4488])
    
  4. 我们创建一个函数来处理每个划分,并获得具有独热编码节点标签和节点特征的封闭子图。我们声明一个列表来存储这些子图:

    def seal_processing(dataset, edge_label_index, y):
        data_list = []
    
  5. 对于数据集中的每一对(源节点和目标节点),我们提取 k-hop 邻居(这里,):

        for src, dst in edge_label_index.t().tolist():
            sub_nodes, sub_edge_index, mapping, _ = k_hop_subgraph([src, dst], 2, dataset.edge_index, relabel_nodes=True)
            src, dst = mapping.tolist()
    
  6. 我们使用 DRNL 函数计算距离。首先,我们从子图中移除目标节点:

            mask1 = (sub_edge_index[0] != src) | (sub_edge_index[1] != dst)
            mask2 = (sub_edge_index[0] != dst) | (sub_edge_index[1] != src)
            sub_edge_index = sub_edge_index[:, mask1 & mask2]
    
  7. 我们根据之前的子图计算源节点和目标节点的邻接矩阵:

            src, dst = (dst, src) if src > dst else (src, dst)
            adj = to_scipy_sparse_matrix(sub_edge_index, num_nodes=sub_nodes.size(0)).tocsr()
            idx = list(range(src)) + list(range(src + 1, adj.shape[0]))
            adj_wo_src = adj[idx, :][:, idx]
            idx = list(range(dst)) + list(range(dst + 1, adj.shape[0]))
            adj_wo_dst = adj[idx, :][:, idx]
    
  8. 我们计算每个节点与源/目标节点之间的距离:

            d_src = shortest_path(adj_wo_dst, directed=False, unweighted=True, indices=src)
            d_src = np.insert(d_src, dst, 0, axis=0)
            d_src = torch.from_numpy(d_src)
            d_dst = shortest_path(adj_wo_src, directed=False, unweighted=True, indices=dst-1)
            d_dst = np.insert(d_dst, src, 0, axis=0)
            d_dst = torch.from_numpy(d_dst)
    
  9. 我们为子图中的每个节点计算节点标签z

            dist = d_src + d_dst
            z = 1 + torch.min(d_src, d_dst) + dist // 2 * (dist // 2 + dist % 2 - 1)
            z[src], z[dst], z[torch.isnan(z)] = 1., 1., 0.
            z = z.to(torch.long)
    
  10. 在本例中,我们不会使用节点嵌入,但我们仍然连接特征和独热编码标签来构建节点信息矩阵:

            node_labels = F.one_hot(z, num_classes=200).to(torch.float)
            node_emb = dataset.x[sub_nodes]
            node_x = torch.cat([node_emb, node_labels], dim=1)
    
  11. 我们创建一个Data对象并将其添加到列表中,这就是该函数的最终输出:

            data = Data(x=node_x, z=z, edge_index=sub_edge_index, y=y)
            data_list.append(data)
        return data_list
    
  12. 让我们使用它来为每个数据集提取封闭子图。我们将正负样本分开,以获得正确的预测标签:

    train_pos_data_list = seal_processing(train_data, train_data.pos_edge_label_index, 1)
    train_neg_data_list = seal_processing(train_data, train_data.neg_edge_label_index, 0)
    val_pos_data_list = seal_processing(val_data, val_data.pos_edge_label_index, 1)
    val_neg_data_list = seal_processing(val_data, val_data.neg_edge_label_index, 0)
    test_pos_data_list = seal_processing(test_data, test_data.pos_edge_label_index, 1)
    test_neg_data_list = seal_processing(test_data, test_data.neg_edge_label_index, 0)
    
  13. 接下来,我们合并正负数据列表,以重构训练、验证和测试数据集:

    train_dataset = train_pos_data_list + train_neg_data_list
    val_dataset = val_pos_data_list + val_neg_data_list
    test_dataset = test_pos_data_list + test_neg_data_list
    
  14. 我们创建数据加载器来使用批量训练 GNN:

    train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
    val_loader = DataLoader(val_dataset, batch_size=32)
    test_loader = DataLoader(test_dataset, batch_size=32)
    
  15. 我们为 DGCNN 模型创建一个新类。k参数表示每个子图要保留的节点数:

    class DGCNN(torch.nn.Module):
        def __init__(self, dim_in, k=30):
            super().__init__()
    
  16. 我们创建四个 GCN 层,固定隐藏层维度为 32:

            self.gcn1 = GCNConv(dim_in, 32)
            self.gcn2 = GCNConv(32, 32)
            self.gcn3 = GCNConv(32, 32)
            self.gcn4 = GCNConv(32, 1)
    
  17. 我们在 DGCNN 架构的核心实例化全局排序池化:

            self.global_pool = aggr.SortAggregation(k=k)
    
  18. 全局池化提供的节点顺序使我们能够使用传统的卷积层:

            self.conv1 = Conv1d(1, 16, 97, 97)
            self.conv2 = Conv1d(16, 32, 5, 1)
            self.maxpool = MaxPool1d(2, 2)
    
  19. 最后,预测由 MLP 管理:

            self.linear1 = Linear(352, 128)
            self.dropout = Dropout(0.5)
            self.linear2 = Linear(128, 1)
    
  20. forward()函数中,我们计算每个 GCN 的节点嵌入并连接结果:

        def forward(self, x, edge_index, batch):
            h1 = self.gcn1(x, edge_index).tanh()
            h2 = self.gcn2(h1, edge_index).tanh()
            h3 = self.gcn3(h2, edge_index).tanh()
            h4 = self.gcn4(h3, edge_index).tanh()
            h = torch.cat([h1, h2, h3, h4], dim=-1)
    
  21. 全局排序池化、卷积层和密集层依次应用于该结果:

            h = self.global_pool(h, batch)
            h = h.view(h.size(0), 1, h.size(-1))
            h = self.conv1(h).relu()
            h = self.maxpool(h)
            h = self.conv2(h).relu()
            h = h.view(h.size(0), -1)
            h = self.linear1(h).relu()
            h = self.dropout(h)
            h = self.linear2(h).sigmoid()
            return h
    
  22. 如果 GPU 可用,我们在 GPU 上实例化模型,并使用Adam优化器和二元交叉熵损失进行训练:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = DGCNN(train_dataset[0].num_features).to(device)
    optimizer = torch.optim.Adam(params=model.parameters(), lr=0.0001)
    criterion = BCEWithLogitsLoss()
    
  23. 我们创建一个传统的train()函数进行批量训练:

    def train():
        model.train()
        total_loss = 0
        for data in train_loader:
            data = data.to(device)
            optimizer.zero_grad()
            out = model(data.x, data.edge_index, data.batch)
            loss = criterion(out.view(-1), data.y.to(torch.float))
            loss.backward()
            optimizer.step()
            total_loss += float(loss) * data.num_graphs
        return total_loss / len(train_dataset)
    
  24. test()函数中,我们计算 ROC AUC 分数和*均精度,以比较 SEAL 与 VGAE 的性能:

    @torch.no_grad()
    def test(loader):
        model.eval()
        y_pred, y_true = [], []
        for data in loader:
            data = data.to(device)
            out = model(data.x, data.edge_index, data.batch)
            y_pred.append(out.view(-1).cpu())
            y_true.append(data.y.view(-1).cpu().to(torch.float))
        auc = roc_auc_score(torch.cat(y_true), torch.cat(y_pred))
        ap = average_precision_score(torch.cat(y_true), torch.cat(y_pred))
        return auc, ap
    
  25. 我们训练 DGCNN 共 31 个 epoch:

    for epoch in range(31):
        loss = train()
        val_auc, val_ap = test(val_loader)
        print(f'Epoch {epoch:>2} | Loss: {loss:.4f} | Val AUC: {val_auc:.4f} | Val AP: {val_ap:.4f}')
    Epoch 0 | Loss: 0.6925 | Val AUC: 0.8215 | Val AP: 0.8357
    Epoch 1 | Loss: 0.6203 | Val AUC: 0.8543 | Val AP: 0.8712
    Epoch 2 | Loss: 0.5888 | Val AUC: 0.8783 | Val AP: 0.8877...
    Epoch 29 | Loss: 0.5461 | Val AUC: 0.8991 | Val AP: 0.8973
    Epoch 30 | Loss: 0.5460 | Val AUC: 0.9005 | Val AP: 0.8992
    
  26. 最后,我们在测试数据集上进行测试:

    test_auc, test_ap = test(test_loader)
    print(f'Test AUC: {test_auc:.4f} | Test AP {test_ap:.4f}')
    Test AUC: 0.8808 | Test AP 0.8863
    

我们获得的结果与使用 VGAE 时的结果相似(测试 AUC – 0.8833 和 测试 AP – 0.8845)。理论上,基于子图的方法(如 SEAL)比基于节点的方法(如 VGAE)更具表达力。它们通过明确考虑目标节点周围的整个邻域来捕获更多信息。通过增加k参数考虑的邻居数量,SEAL 的准确性也可以提高。

摘要

本章中,我们探讨了一项新任务——链接预测。我们通过介绍启发式和矩阵分解技术概述了该领域。启发式方法可以根据它们考虑的 k 步邻居进行分类——从仅考虑 1 步邻居的局部方法到考虑整个图的全局方法。相反,矩阵分解则通过节点嵌入来*似邻接矩阵。我们还解释了这种技术如何与前几章中描述的算法(DeepWalkNode2Vec)相关联。

在对链接预测的介绍之后,我们展示了如何使用 GNN 实现该任务。我们概述了两种基于节点嵌入(GAE 和 VGAE)以及子图表示(SEAL)的方法。最后,我们在Cora数据集上实现了 VGAE 和 SEAL,使用边级随机拆分和负采样。尽管 SEAL 的表达能力更强,但两个模型的性能相当。

第十一章**《利用图神经网络生成图》 中,我们将看到不同的策略来生成真实的图形。首先,我们将介绍传统技术及其流行的 Erdős–Rényi 模型。接着,我们将展示深度生成方法是如何通过重用 GVAE 并引入一种新架构——图递归神经网络GraphRNN)来工作的。

深度阅读

  • [1] H. Tong, C. Faloutsos 和 J. -y. Pan. “带重启的快速随机游走及其应用”在 第六届国际数据挖掘会议(ICDM’06),2006 年,第 613-622 页,doi: 10.1109/ICDM.2006.70.

  • [2] Yehuda Koren, Robert Bell 和 Chris Volinsky. 2009 年. 推荐系统的矩阵分解技术。计算机,42 卷,第 8 期(2009 年 8 月),30-37. https://doi.org/10.1109/MC.2009.263.

  • [3] J. Qiu, Y. Dong, H. Ma, J. Li, K. Wang 和 J. Tang. 作为矩阵分解的网络嵌入。2018 年 2 月. doi: 10.1145/3159652.3159706.

  • [4] D. P. Kingma 和 M. Welling. 自编码变分贝叶斯。arXiv,2013 年. doi: 10.48550/ARXIV.1312.6114.

  • [5] T. N. Kipf 和 M. Welling. 变分图自动编码器。arXiv,2016 年. doi: 10.48550/ARXIV.1611.07308.

  • [6] M. Zhang 和 Y. Chen. 基于图神经网络的链接预测。arXiv,2018 年. doi: 10.48550/ARXIV.1802.09691.

  • [7] Muhan Zhang, Zhicheng Cui, Marion Neumann 和 Yixin Chen. 2018 年. 一种用于图分类的端到端深度学习架构。在 第三十二届人工智能 AAAI 会议第三十届人工智能创新应用会议第八届人工智能教育进展 AAAI 研讨会(AAAI’18/IAAI’18/EAAI’18)上发表。AAAI 出版社,文章 544,4438-4445。

第十一章:使用图神经网络生成图

图生成包括寻找创建新图的方法。作为一个研究领域,它为理解图的工作方式和演化过程提供了见解。它在数据增强、异常检测、药物发现等方面有直接应用。我们可以区分两种生成类型:现实图生成,它模仿给定的图(例如,在数据增强中),以及目标导向图生成,它创建优化特定指标的图(例如,在分子生成中)。

在本章中,我们将探索传统技术,以了解图生成的工作原理。我们将重点介绍两种流行的算法:埃尔德什–雷尼模型和小世界模型。它们具有有趣的特性,但也存在一些问题,这些问题促使了基于 GNN 的图生成方法的需求。在第二部分中,我们将描述三种解决方案:变分自编码器VAE)基础的、自动回归的和GAN基础的模型。最后,我们将实现一个基于 GAN 的框架,并结合强化学习RL)生成新的化学化合物。我们将使用DeepChem库与 TensorFlow,而不是 PyTorch Geometric。

在本章结束时,您将能够使用传统方法和基于 GNN 的技术生成图。您将对这一领域以及您可以用它构建的不同应用有一个良好的概览。您将知道如何实现混合架构,以引导生成有效的分子,且具备您所期望的属性。

在本章中,我们将涵盖以下主要内容:

  • 使用传统技术生成图

  • 使用图神经网络生成图

  • 使用 MolGAN 生成分子

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址是github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter11

在本书的前言中可以找到在本地计算机上运行代码所需的安装步骤。

使用传统技术生成图

传统的图生成技术已经研究了数十年。这就是它们被广泛理解并可以在各种应用中作为基准使用的原因。然而,它们在可以生成的图类型上通常有局限性。大多数技术专注于输出特定的拓扑结构,这也是它们无法简单模仿给定网络的原因。

在本节中,我们将介绍两种经典技术:埃尔德什–雷尼模型和小世界模型。

埃尔德什–雷尼模型

Erdős–Rényi 模型是最简单且最流行的随机图模型。它由匈牙利数学家保罗·厄尔多斯和阿尔弗雷德·雷尼于 1959 年提出[1],并由埃德加·吉尔伯特在同一年独立提出[2]。该模型有两种变体:

模型很简单:给定个节点和连接一对节点的概率,我们尝试随机地将每个节点与其他节点连接,形成最终图。这意味着存在种可能的链接。另一种理解概率的方法是将其视为一个参数,用于改变网络的密度。

networkx库对模型有直接的实现:

  1. 我们导入networkx库:

    import networkx as nx
    import matplotlib.pyplot as plt
    
  2. 我们使用nx.erdos_renyi_graph()函数生成一个包含10个节点()和边创建概率为0.5)的G图:

    G = nx.erdos_renyi_graph(10, 0.5, seed=0)
    
  3. 我们使用nx.circular_layout()函数来定位结果节点。虽然可以使用其他布局方式,但这种布局方式对于比较不同的值非常方便:

    pos = nx.circular_layout(G)
    
  4. 我们使用nx.draw()pos布局绘制G图。全局启发式方法通常更为准确,但需要知道整个图的结构。然而,这并不是唯一的方法来使用这些知识预测链接:

    nx.draw(G, pos=pos, with_labels=True)
    

这给我们带来了如下图:

图 11.1 – 一个具有 10 个节点和 p=0.5 的 Erdős–Rényi 图

图 11.1 – 一个具有 10 个节点和 p=0.5 的 Erdős–Rényi 图

我们可以以0.10.9的概率重复此过程,从而得到以下图示:

图 11.2 – 不同边创建概率的 Erdős–Rényi 图

图 11.2 – 不同边创建概率的 Erdős–Rényi 图

我们可以看到,当较低时,许多节点是孤立的,而当较高时,图的互联性较强。

模型中,我们从所有具有个节点和个链接的图中随机选择一个。例如,如果,则有三个可能的图(见图 11.3)。模型将随机选择其中一个。这是解决相同问题的另一种方法,但由于它更难以分析,因此不如模型流行:

图 11.3 – 一组包含三个节点和两个链接的图

图 11.3 – 一组包含三个节点和两个链接的图

我们也可以使用 nx.gnm_random_graph() 函数在 Python 中实现 模型:

G = nx.gnm_random_graph(3, 2, seed=0)
pos = nx.circular_layout(G)
nx.draw(G, pos=pos, with_labels=True)

图 11.4 – 从具有三个节点和两个链接的图集中随机采样的图

图 11.4 – 从具有三个节点和两个链接的图集中随机采样的图

模型提出的最强大且最有趣的假设是,链接是独立的(意味着它们不会相互干扰)。不幸的是,这对于大多数现实世界的图来说并不成立,在这些图中,我们观察到与此规则相矛盾的簇和社区。

小世界模型

小世界模型由 Duncan Watts 和 Steven Strogatz 在 1998 年提出 [3],该模型试图模拟生物、技术和社交网络的行为。其主要概念是,现实世界的网络并非完全随机(如 Erdős–Rényi 模型),也不是完全规则的(如网格)。这种拓扑结构介于两者之间,因此我们可以使用系数进行插值。小世界模型产生的图既具有:

  • 短路径:网络中任意两个节点之间的*均距离相对较小,这使得信息能够快速传播到整个网络

  • 高聚类系数:网络中的节点往往彼此紧密连接,形成密集的节点簇

许多算法展示了小世界特性。接下来,我们将描述原始的Watts–Strogatz模型,该模型在 [3] 中提出。它可以通过以下步骤实现:

  1. 我们初始化一个具有 节点的图。

  2. 每个节点都连接到它的 最*邻(或者如果 是奇数,则连接到 邻居)。

  3. 每个节点之间的链接 都有一个重连的概率 ,重连到 ,其中 是另一个随机节点。

在 Python 中,我们可以通过调用 nx.watts_strogatz_graph() 函数来实现它:

G = nx.watts_strogatz_graph(10, 4, 0.5, seed=0)
pos = nx.circular_layout(G)
nx.draw(G, pos=pos)

这产生了如下的图:

图 11.5 – 使用 Watts–Strogatz 模型获得的小世界网络

图 11.5 – 使用 Watts–Strogatz 模型获得的小世界网络

与 Erdős–Rényi 模型一样,我们可以用不同的重连概率 重复相同的过程,以获得 图 11.6

图 11.6 – 不同重连概率的小世界模型

图 11.6 – 不同重连概率的小世界模型

我们可以看到,当时,图形是完全规则的。而在另一端,当时,图形是完全随机的,因为每个连接都被重新连接。我们通过这些极端之间的*衡,得到一个包含枢纽和局部聚类的图形。

然而,Watts–Strogatz 模型并没有生成现实的度分布。它还要求一个固定数量的节点,这意味着它无法用于网络的增长。一般而言,经典方法无法捕捉到真实世界图形的多样性和复杂性。这促使了新一类技术的诞生,通常被称为深度图生成。

使用图神经网络生成图形

深度图生成模型是基于 GNN 的架构,比传统技术更具表现力。然而,这也有代价:它们通常过于复杂,无法像经典方法那样被分析和理解。我们列出了三种主要的深度图生成架构:VAE、GAN 和自回归模型。虽然还有其他技术,如规范化流或扩散模型,但它们比这三种技术更不流行且不成熟。

本节将描述如何使用 VAE、GAN 和自回归模型生成图形。

图形变分自编码器

如上一章所见,VAE 可以用来逼*邻接矩阵。我们看到的图形变分自编码器GVAE)模型有两个组成部分:编码器和解码器。编码器使用两个共享第一层的 GCN 来学习每个潜在正态分布的均值和方差。解码器然后从学习到的分布中采样,执行潜在变量的内积!。最终,我们得到逼*的邻接矩阵!

在上一章中,我们使用来预测连接。然而,这并不是它唯一的应用:它直接给出了一个模拟训练期间见过的图形的网络邻接矩阵。我们可以使用这个输出生成新的图形,而不是预测连接。以下是 VGAE 模型生成的邻接矩阵示例,来自第十章

z = model.encode(test_data.x, test_data.edge_index)
adj = torch.where((z @ z.T) > 0.9, 1, 0)
adj
tensor([[1, 0, 0,  ..., 0, 1, 1],
        [0, 1, 1,  ..., 0, 0, 0],
        [0, 1, 1,  ..., 0, 1, 1],
        ...,
        [0, 0, 0,  ..., 1, 0, 0],
        [1, 0, 1,  ..., 0, 1, 1],
        [1, 0, 1,  ..., 0, 1, 1]])

自 2016 年以来,这项技术已扩展到 GVAE 模型之外,还可以输出节点和边的特征。一个很好的例子是最受欢迎的基于 VAE 的图生成模型之一:GraphVAE [4]。它由 Simonovsky 和 Komodakis 于 2018 年提出,旨在生成现实的分子。这需要能够区分节点(原子)和边(化学键)。

GraphVAE 考虑图形 ,其中 是邻接矩阵, 是边属性张量, 是节点属性矩阵。它学习一个具有预定义节点数的图形的概率版本 。在这个概率版本中, 包含节点()和边()的概率, 表示边的类别概率, 包含节点的类别概率。与 GVAE 相比,GraphVAE 的编码器是一个前馈网络,具有边条件图卷积ECC),其解码器是一个具有三输出的多层感知机MLP)。整个架构总结如下图:

图 11.7 – GraphVAE 的推理过程

图 11.7 – GraphVAE 的推理过程

还有许多其他基于 VAE 的图生成架构。然而,它们的作用不限于模仿图形:它们还可以嵌入约束,以引导它们生成的图形类型。

添加这些约束的一种流行方法是在解码阶段检查它们,例如受限图变分自编码器CGVAE)[5]。在该架构中,编码器是门控图卷积网络GGCN),解码器是自回归模型。自回归解码器特别适用于这一任务,因为它们可以在过程的每个步骤中验证每个约束。最后,另一种添加约束的技术是使用基于拉格朗日的正则化器,这些正则化器计算速度更快,但在生成方面的严格性较差[6]。

自回归模型

自回归模型也可以单独使用。与其他模型的区别在于,过去的输出成为当前输入的一部分。在这一框架下,图生成变成了一个序列决策过程,同时考虑数据和过去的决策。例如,在每个步骤中,自回归模型可以创建一个新的节点或一个新的连接。然后,将生成的图输入到模型中进行下一步生成,直到我们停止它。下图展示了这个过程:

图 11.8 – 图生成的自回归过程

图 11.8 – 图生成的自回归过程

实际上,我们使用 递归神经网络RNN)来实现这种自回归能力。在这个架构中,先前的输出被用作输入,以计算当前的隐藏状态。此外,RNN 可以处理任意长度的输入,这对于迭代生成图非常重要。然而,这种计算比前馈网络要慢,因为必须处理整个序列才能获得最终输出。最流行的两种 RNN 类型是 门控递归单元GRU)和 长短期记忆LSTM)网络。

GraphRNN 由 You 等人于 2018 年提出,[7] 是这些技术在深度图生成中的直接实现。该架构使用了两个 RNN:

  • 一个 图级 RNN 用于生成一系列节点(包括初始状态)。

  • 一个 边级 RNN 用于预测每个新添加节点的连接。

边级 RNN 将图级 RNN 的隐藏状态作为输入,然后通过自己的输出继续输入。这一机制在推理时的示意图如下:

图 11.9 – GraphRNN 在推理时的架构

图 11.9 – GraphRNN 在推理时的架构

这两个 RNN 实际上在完成一个邻接矩阵:每个由图级 RNN 创建的新节点都会添加一行和一列,并且这些行列由边级 RNN 填充为零和一。总的来说,GraphRNN 执行以下步骤:

  1. 添加新节点:图级 RNN 初始化图形,并将其输出传递给边级 RNN。

  2. 添加新连接:边级 RNN 预测新节点是否与每个先前的节点相连接。

  3. 停止图生成:前两个步骤会重复进行,直到边级 RNN 输出 EOS 令牌,标志着过程的结束。

GraphRNN 可以学习不同类型的图(如网格、社交网络、蛋白质等),并且在性能上完全超越传统技术。它是模仿给定图的首选架构,应优先于 GraphVAE。

生成对抗网络

与 VAE 类似,GAN 是 机器学习(ML) 中一种著名的生成模型。在这个框架中,两个人工神经网络在零和博弈中相互竞争,目标各不相同。第一个神经网络是生成器,用于生成新数据,第二个神经网络是判别器,用于将每个样本分类为真实的(来自训练集)或伪造的(由生成器生成)。

多年来,针对原始架构提出了两项主要改进。第一项被称为 Wasserstein GANWGAN)。它通过最小化两个概率分布之间的 Wasserstein 距离(或地球搬运者距离)来提高学习稳定性。该变种通过引入梯度惩罚,代替了原来的梯度裁剪方案,从而进一步得到改进。

多项研究将这个框架应用于深度图生成。像之前的技术一样,GANs 可以模仿图形或生成优化某些约束的网络。后者选项在诸如发现具有特定属性的新化学化合物等应用中非常有用。这个问题异常庞大(超过 种可能的组合)且复杂,原因在于其离散性质。

分子 GANMolGAN)由 De Cao 和 Kipf 在 2018 年提出[8],是解决这一问题的一个流行方法。它结合了 WGAN 和带有梯度惩罚的网络,直接处理图结构数据,并且通过 RL 目标生成具有所需化学属性的分子。这个 RL 目标基于深度确定性策略梯度DDPG)算法,一种使用确定性策略梯度的离策略演员-评论员模型。MolGAN 的架构总结如下图:

图 11.10 – MolGAN 推理时的架构

图 11.10 – MolGAN 推理时的架构

这个框架分为三个主要组件:

  • 生成器是一个多层感知器(MLP),它输出一个节点矩阵 ,包含原子类型和一个邻接矩阵 ,该矩阵实际上是一个张量,包含了边和键类型。生成器通过 WGAN 和 RL 损失的线性组合进行训练。我们通过类别采样将这些密集表示转换为稀疏对象()。

  • 判别器接收来自生成器和数据集的图,并学习区分它们。它仅通过 WGAN 损失进行训练。

  • 奖励网络为每个图打分。它通过基于外部系统(在此为 RDKit)提供的真实评分,使用均方误差(MSE)损失进行训练。

判别器和奖励网络使用 GNN 模式:关系图卷积网络(Relational-GCN),一种支持多种边类型的 GCN 变种。经过几层图卷积后,节点嵌入被聚合成一个图级别的向量输出:

这里,表示逻辑 sigmoid 函数, 是两个具有线性输出的 MLP,是逐元素乘法。第三个 MLP 进一步处理这个图嵌入,生成一个介于 0 和 1 之间的值用于奖励网络,和一个介于 之间的值用于判别器。

MolGAN 生成有效的化学化合物,优化药物相似性、可合成性和溶解性等属性。我们将在下一节中实现这个架构,以生成新分子。

使用 MolGAN 生成分子

深度图生成在 PyTorch Geometric 中没有得到充分覆盖。药物发现是该子领域的主要应用,这也是为什么生成模型通常会出现在专门的库中。更具体地说,有两个流行的 Python 库用于基于机器学习的药物发现:DeepChemtorchdrug。在这一节中,我们将使用 DeepChem,因为它更为成熟并且直接实现了 MolGAN。

让我们看看如何使用DeepChemtensorflow。以下过程基于 DeepChem 的示例:

  1. 我们安装DeepChemdeepchem.io),它需要以下库:tensorflowjoblibNumPypandasscikit-learnSciPyrdkit

    !pip install deepchem==2.7.1
    
  2. 然后,我们导入所需的包:

    import numpy as np
    import tensorflow as tf
    import pandas as pd
    from tensorflow import one_hot
    import deepchem as dc
    from deepchem.models.optimizers import ExponentialDecay
    from deepchem.models import BasicMolGANModel as MolGAN
    from deepchem.feat.molecule_featurizers.molgan_featurizer import GraphMatrix
    from rdkit import Chem
    from rdkit.Chem import Draw
    from rdkit.Chem import rdmolfiles
    from rdkit.Chem import rdmolops
    from rdkit.Chem.Draw import IpythonConsole
    
  3. 我们下载tox2121 世纪毒理学)数据集,该数据集包含超过 6000 种化学化合物,用于分析它们的毒性。在这个示例中,我们只需要它们的简化分子输入线条表示系统SMILES)表示:

    _, datasets, _ = dc.molnet.load_tox21()
    df = pd.DataFrame(datasets[0].ids, columns=['smiles'])
    
  4. 这里是这些smiles字符串的输出:

    0  CC(O)(P(=O)(O)O)P(=O)(O)O
    1  CC(C)(C)OOC(C)(C)CCC(C)(C)OOC(C)(C)C
    2  OCC@HC@@HC@HCO
    3  CCCCCCCC(=O)[O-].CCCCCCCC(=O)[O-].[Zn+2]
    ... ...
    6260 Cc1cc(CCCOc2c(C)cc(-c3noc(C(F)(F)F)n3)cc2C)on1
    6261 O=C1OC(OC(=O)c2cccnc2Nc2cccc(C(F)(F)F)c2)c2ccc...
    6262 CC(=O)C1(C)CC2=C(CCCC2(C)C)CC1C
    6263 CC(C)CCCC@@H[C@H]1CC(=O)C2=C3CC[C@H]4C[C@...
    
  5. 我们只考虑最多含有 15 个原子的分子。我们过滤数据集并创建一个featurizer,将smiles字符串转换为输入特征:

    max_atom = 15
    molecules = [x for x in df['smiles'].values if Chem.MolFromSmiles(x).GetNumAtoms() < max_atom]
    featurizer = dc.feat.MolGanFeaturizer(max_atom_count=max_atom)
    
  6. 我们手动遍历数据集以转换smiles字符串:

    features = []
    for x in molecules:
        mol = Chem.MolFromSmiles(x)
        new_order = rdmolfiles.CanonicalRankAtoms(mol)
        mol = rdmolops.RenumberAtoms(mol, new_order)
        feature = featurizer.featurize(mol)
        if feature.size != 0:
            features.append(feature[0])
    
  7. 我们从数据集中移除无效的分子:

    features = [x for x in features if type(x) is GraphMatrix]
    
  8. 接下来,我们创建MolGAN模型。它将以具有指数衰减调度的学习率进行训练:

    gan = MolGAN(learning_rate=ExponentialDecay(0.001, 0.9, 5000), vertices=max_atom)
    
  9. 我们创建数据集并以 DeepChem 的格式提供给MolGAN

    dataset = dc.data.NumpyDataset(X=[x.adjacency_matrix for x in features], y=[x.node_features for x in features])
    
  10. MolGAN使用批量训练,这就是我们需要定义一个可迭代对象的原因,如下所示:

    def iterbatches(epochs):
        for i in range(epochs):
            for batch in dataset.iterbatches(batch_size=gan.batch_size, pad_batches=True):
                adjacency_tensor = one_hot(batch[0], gan.edges)
                node_tensor = one_hot(batch[1], gan.nodes)
                yield {gan.data_inputs[0]: adjacency_tensor, gan.data_inputs[1]: node_tensor}
    
  11. 我们训练模型25个周期:

    gan.fit_gan(iterbatches(25), generator_steps=0.2)
    
  12. 我们生成1000个分子:

    generated_data = gan.predict_gan_generator(1000)
    nmols = feat.defeaturize(generated_data)
    
  13. 然后,我们检查这些分子是否有效:

    valid_mols = [x for x in generated_mols if x is not None]
    print (f'{len(valid_mols)} valid molecules (out of {len((generated_mols))} generated molecules)')
    31 valid molecules (out of 1000 generated molecules)
    
  14. 我们将它们进行比较,看看有多少个分子是独特的:

    generated_smiles = [Chem.MolToSmiles(x) for x in valid_mols]
    generated_smiles_viz = [Chem.MolFromSmiles(x) for x in set(generated_smiles)]
    print(f'{len(generated_smiles_viz)} unique valid molecules ({len(generated_smiles)-len(generated_smiles_viz)} redundant molecules)')
    24 unique valid molecules (7 redundant molecules)
    
  15. 我们将生成的分子打印在一个网格中:

    img = Draw.MolsToGridImage(generated_smiles_viz, molsPerRow=6, subImgSize=(200, 200), returnPNG=False)
    

图 11.11 – 使用 MolGAN 生成的分子

图 11.11 – 使用 MolGAN 生成的分子

尽管 GAN 有了改进,这个训练过程仍然相当不稳定,并且可能无法产生任何有意义的结果。我们展示的代码对超参数的变化非常敏感,并且不能很好地泛化到其他数据集,包括原论文中使用的QM9数据集。

尽管如此,MolGAN 将强化学习(RL)和生成对抗网络(GAN)的概念结合的方式,不仅限于药物发现,还可以应用于优化任何类型的图结构,如计算机网络、推荐系统等。

总结

在本章中,我们探讨了生成图结构的不同技术。首先,我们探索了基于概率的传统方法,这些方法具有有趣的数学特性。然而,由于它们表达能力的不足,我们转向了基于图神经网络(GNN)的技术,这些技术更为灵活。我们涵盖了三种类型的深度生成模型:基于变分自编码器(VAE)、自回归和基于 GAN 的方法。我们从每种方法中引入了一个模型,以了解它们在实际中的工作原理。

最后,我们实现了一个基于 GAN 的模型,结合了生成器、判别器和来自强化学习的奖励网络。这个架构不仅仅是模仿训练过程中看到的图形,它还能够优化如溶解度等期望的属性。我们使用 DeepChem 和 TensorFlow 创建了 24 个独特且有效的分子。如今,这种流程在药物发现行业中已经非常普遍,机器学习能够显著加快药物开发进程。

第十二章处理异构图 中,我们将探讨一种新型图,它之前曾出现在推荐系统和分子中。这些异构图包含多种类型的节点和/或链接,需要特定的处理方式。它们比我们之前讨论的常规图更具通用性,特别在知识图谱等应用中非常有用。

进一步阅读

第十二章:从异质图中学习

在上一章中,我们尝试生成包含不同类型节点(原子)和边(键)的真实分子。我们在其他应用中也观察到了这种行为,比如推荐系统(用户和物品)、社交网络(关注者和被关注者)、或者网络安全(路由器和服务器)。我们称这些图为异质图,与只涉及一种类型的节点和一种类型的边的同质图相对。

在本章中,我们将回顾关于同质 GNN 的所有知识。我们将引入消息传递神经网络框架,以概括迄今为止我们所看到的架构。这个总结将帮助我们理解如何扩展我们的框架到异质网络。我们将从创建我们自己的异质数据集开始。然后,我们将同质架构转化为异质架构。

在最后一节中,我们将采取不同的方法,讨论一种专门为处理异质网络设计的架构。我们将描述它的工作原理,以更好地理解这种架构与经典 GAT 之间的差异。最后,我们将在 PyTorch Geometric 中实现它,并将我们的结果与前面的方法进行比较。

在本章结束时,您将深入理解同质图与异质图之间的差异。您将能够创建自己的异质数据集,并将传统模型转换为适用于此情境的模型。您还将能够实现专门为最大化异质网络优势而设计的架构。

在本章中,我们将讨论以下主要内容:

  • 消息传递神经网络框架

  • 引入异质图

  • 将同质 GNN 转换为异质 GNN

  • 实现分层自注意力网络

技术要求

本章中的所有代码示例可以在 GitHub 上找到,网址是 github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter12

在本书的前言中,您可以找到在本地机器上运行代码所需的安装步骤。

消息传递神经网络框架

在探索异质图之前,让我们回顾一下我们对同质 GNN 的了解。在前几章中,我们看到了一些用于聚合和组合来自不同节点的特征的不同函数。如在 第五章中所示,最简单的 GNN 层由对邻*节点(包括目标节点本身)特征的线性组合求和,并使用权重矩阵。前述求和的输出会替代之前的目标节点嵌入。

节点级别的操作符可以写作如下:

节点的邻居节点集合(包括自身), 节点的嵌入, 是一个权重矩阵:

GCN 和 GAT 层为节点特征添加了固定和动态权重,但保持了相同的思想。即便是 GraphSAGE 的 LSTM 操作符或 GIN 的最大聚合器,也没有改变 GNN 层的主要概念。如果我们查看这些变种,可以将 GNN 层概括为一个通用框架,称为 消息传递神经网络MPNNMP-GNN)。这个框架由 Gilmer 等人于 2017 年提出[1],它包含三个主要操作:

  • Message:每个节点使用一个函数为每个邻居创建一个消息。这个消息可以简单地由它自己的特征组成(如前面的例子),也可以考虑邻居节点的特征和边的特征。

  • Aggregate:每个节点使用一个置换等变函数(如前面的例子中的求和)来聚合来自邻居的消息。

  • Update:每个节点使用一个函数更新其特征,将其当前特征与聚合的消息结合。在前面的例子中,我们引入了自环来聚合 节点的当前特征,就像一个邻居一样。

这些步骤可以总结为一个公式:

这里, 节点的嵌入, 链接的边嵌入, 是消息函数, 是聚合函数, 是更新函数。你可以在下图中找到这个框架的示意图:

图 12.1 – MPNN 框架

图 12.1 – MPNN 框架

PyTorch Geometric 直接通过 MessagePassing 类实现了这个框架。例如,以下是如何使用这个类实现 GCN 层:

  1. 首先,我们导入所需的库:

    import torch
    from torch.nn import Linear
    from torch_geometric.nn import MessagePassing
    from torch_geometric.utils import add_self_loops, degree
    
  2. 我们声明继承自 MessagePassing 的 GCN 类:

    class GCNConv(MessagePassing):
    
  3. 这需要两个参数 – 输入的维度和输出(隐藏)维度。MessagePassing 被初始化为“加法”聚合。我们定义了一个没有偏置的单个 PyTorch 线性层:

        def __init__(self, dim_in, dim_h):
            super().__init__(aggr='add')
            self.linear = Linear(dim_in, dim_h, bias=False)
    
  4. forward() 函数包含了逻辑。首先,我们向邻接矩阵中添加自环以考虑目标节点:

        def forward(self, x, edge_index):
            edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
    
  5. 然后,我们使用之前定义的线性层进行线性变换:

            x = self.linear(x)
    
  6. 我们计算归一化因子 –

            row, col = edge_index
            deg = degree(col, x.size(0), dtype=x.dtype)
            deg_inv_sqrt = deg.pow(-0.5)
            deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
            norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
    
  7. 我们调用propagate()方法,传入更新后的edge_index(包括自环)和存储在norm张量中的归一化因子。内部,该方法会调用message()aggregate()update()。我们不需要重新定义update(),因为我们已经包括了自环。aggregate()函数已在步骤 3中通过aggr='add'进行指定:

            out = self.propagate(edge_index, x=x, norm=norm)
            return out
    
  8. 我们重新定义了message()函数,以便使用norm对邻接节点特征x进行归一化:

        def message(self, x, norm):
            return norm.view(-1, 1) * x
    
  9. 现在我们可以初始化并使用这个对象作为 GCN 层:

    conv = GCNConv(16, 32)
    

这个示例展示了如何在 PyTorch Geometric 中创建自己的 GNN 层。你还可以查看 GCN 或 GAT 层在源代码中的实现方式。

MPNN 框架是一个重要的概念,它将帮助我们将 GNN 转化为异构模型。

引入异构图

异构图是一个强大的工具,可以表示不同实体之间的一般关系。拥有不同类型的节点和边创建了更复杂的图结构,但同时也更难以学习。特别是,异构网络的一个主要问题是,来自不同类型的节点或边的特征未必具有相同的含义或维度。因此,合并不同特征会破坏大量信息。而在同质图中,每个维度对每个节点或边都有相同的含义。

异构图是一种更通用的网络,可以表示不同类型的节点和边。形式上,它被定义为一个图,,包含,一组节点,和,一组边。在异构设置中,它与一个节点类型映射函数相关联(其中表示节点类型的集合),以及一个链接类型映射函数(其中表示边类型的集合)。

以下图为异构图的示例。

图 12.2 – 一个包含三种节点类型和三种边类型的异构图示例

图 12.2 – 一个包含三种节点类型和三种边类型的异构图示例

在这个图中,我们看到三种类型的节点(用户、游戏和开发者)和三种类型的边(关注开发)。它表示一个涉及人(用户和开发者)和游戏的网络,可以用于各种应用,例如游戏推荐。如果这个图包含数百万个元素,它可以用作图结构化的知识数据库,或者知识图谱。知识图谱被 Google 或 Bing 用于回答类似“谁玩Dev 1开发的游戏?”这样的问题。

类似的查询可以提取有用的同质图。例如,我们可能只想考虑玩Game 1的用户。输出将是User 1User 2。我们还可以创建更复杂的查询,例如,“谁是玩由Dev 1开发的游戏的用户?”结果是一样的,但我们通过两种关系来获取我们的用户。这种查询被称为元路径(meta-path)。

在第一个例子中,我们的元路径是User → Game → User(通常表示为UGU),在第二个例子中,我们的元路径是User → Game → Dev → Game → User(或UGDGU)。请注意,起始节点类型和终止节点类型是相同的。元路径是异质图中的一个重要概念,常用于度量不同节点之间的相似性。

现在,让我们看看如何使用 PyTorch Geometric 实现前面的图。我们将使用一个叫做HeteroData的特殊数据对象。以下步骤创建一个数据对象,用于存储图 12.2中的图:

  1. 我们从torch_geometric.data导入HeteroData类,并创建一个data变量:

    from torch_geometric.data import HeteroData
    data = HeteroData()
    
  2. 首先,我们来存储节点特征。我们可以访问用户特征,例如data['user'].x。我们将其传入一个具有[num_users, num_features_users]维度的张量。内容在这个例子中不重要,因此我们为user 1创建填充了 1 的特征向量,为user 2创建填充了 2 的特征向量,为user 3创建填充了 3 的特征向量:

    data['user'].x = torch.Tensor([[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3]])
    
  3. 我们使用gamesdevs重复这个过程。请注意,特征向量的维度不相同;这是异质图在处理不同表示时的一个重要优势:

    data['game'].x = torch.Tensor([[1, 1], [2, 2]])
    data['dev'].x = torch.Tensor([[1], [2]])
    
  4. 让我们在节点之间创建连接。连接具有不同的含义,因此我们将创建三组边的索引。我们可以通过一个三元组声明每一组!,例如data['user', 'follows', 'user'].edge_index。然后,我们将连接存储在一个具有[2, number of edges]维度的张量中:

    data['user', 'follows', 'user'].edge_index = torch.Tensor([[0, 1], [1, 2]]) # [2, num_edges_follows]
    data['user', 'plays', 'game'].edge_index = torch.Tensor([[0, 1, 1, 2], [0, 0, 1, 1]])
    data['dev', 'develops', 'game'].edge_index = torch.Tensor([[0, 1], [0, 1]])
    
  5. 边也可以具有特征——例如,plays边可以包括用户玩对应游戏的时长。下面假设user 1玩了game 1 2 小时,user 2玩了game 1 30 分钟,game 2玩了 10 小时,user 3玩了game 2 12 小时:

    data['user', 'plays', 'game'].edge_attr = torch.Tensor([[2], [0.5], [10], [12]])
    
  6. 最后,我们可以打印data对象以查看结果:

    HeteroData(
      user={ x=[3, 4] },
      game={ x=[2, 2] },
      dev={ x=[2, 1] },
      (user, follows, user)={ edge_index=[2, 2] },
      (user, plays, game)={
        edge_index=[2, 4],
        edge_attr=[4, 1]
      },
      (dev, develops, game)={ edge_index=[2, 2] }
    )
    

如你所见,在这个实现中,不同类型的节点和边并不共享相同的张量。实际上,这是不可能的,因为它们的维度也不同。这引出了一个新问题——我们如何使用 GNN 从多个张量中聚合信息?

到目前为止,我们只关注单一类型。实际上,我们的权重矩阵已经具有适当的大小,可以与预定义维度相乘。但是,当我们得到不同维度的输入时,如何实现 GNN?

将同质 GNN 转化为异质 GNN

为了更好地理解问题,让我们以真实数据集为例。DBLP 计算机科学文献提供了一个数据集,[2-3],包含四种类型的节点 – 论文(14,328),术语(7,723),作者(4,057),和会议(20)。该数据集的目标是将作者正确分类为四个类别 – 数据库、数据挖掘、人工智能和信息检索。作者的节点特征是一组词袋(“0”或“1”),包含他们在出版物中可能使用的 334 个关键字。以下图表总结了不同节点类型之间的关系。

图 12.3 – DBLP 数据集中节点类型之间的关系

图 12.3 – DBLP 数据集中节点类型之间的关系

这些节点类型的维度和语义关系并不相同。在异构图中,节点之间的关系至关重要,这就是为什么我们要考虑节点对。例如,我们不再将作者节点直接馈送到 GNN 层,而是考虑一对,如(作者, 论文)。这意味着现在我们需要根据每一种关系创建一个 GNN 层;在这种情况下,“to”关系是双向的,所以我们会得到六层。

这些新层具有独立的权重矩阵,适合每种节点类型的大小。不幸的是,我们只解决了问题的一半。实际上,现在我们有六个不同的层,它们不共享任何信息。我们可以通过引入跳跃连接共享层跳跃知识等方法来解决这个问题 [4]。

在将同质模型转换为异构模型之前,让我们在 DBLP 数据集上实现一个经典的 GAT。GAT 不能考虑不同的关系;我们必须为它提供一个连接作者的唯一邻接矩阵。幸运的是,我们现在有一种技术可以轻松生成这个邻接矩阵 – 我们可以创建一个元路径,如作者-论文-作者,它将连接同一篇论文中的作者。

注意

通过随机游走,我们还可以构建一个良好的邻接矩阵。即使图形是异构的,我们也可以探索并连接在相同序列中经常出现的节点。

代码有点冗长,但我们可以按以下方式实现常规的 GAT:

  1. 我们导入所需的库:

    from torch import nn
    import torch.nn.functional as F
    import torch_geometric.transforms as T
    from torch_geometric.datasets import DBLP
    from torch_geometric.nn import GAT
    
  2. 我们使用特定语法定义我们将使用的元路径:

    metapaths = [[('author', 'paper'), ('paper', 'author')]]
    
  3. 我们使用AddMetaPaths转换函数自动计算我们的元路径。我们使用drop_orig_edge_types=True从数据集中删除其他关系(GAT 只能考虑一个):

    transform = T.AddMetaPaths(metapaths=metapaths, drop_orig_edge_types=True)
    
  4. 我们加载DBLP数据集并打印它:

    dataset = DBLP('.', transform=transform)
    data = dataset[0]
    print(data)
    
  5. 我们获得了以下输出。请注意我们的转换函数创建的(作者, metapath_0, 作者)关系:

    HeteroData(
      metapath_dict={ (author, metapath_0, author)=[2] },
      author={
        x=[4057, 334],
        y=[4057],
        train_mask=[4057],
        val_mask=[4057],
        test_mask=[4057]
      },
      paper={ x=[14328, 4231] },
      term={ x=[7723, 50] },
      conference={ num_nodes=20 },
      (author, metapath_0, author)={ edge_index=[2, 11113] }
    )
    
  6. 我们直接创建一个带有in_channels=-1的单层 GAT 模型来进行惰性初始化(模型将自动计算值),并且out_channels=4,因为我们需要将作者节点分类为四类:

    model = GAT(in_channels=-1, hidden_channels=64, out_channels=4, num_layers=1)
    
  7. 我们实现了Adam优化器,并将模型和数据存储在 GPU 上(如果可能的话):

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    data, model = data.to(device), model.to(device)
    
  8. test()函数衡量预测的准确性:

    @torch.no_grad()
    def test(mask):
        model.eval()
        pred = model(data.x_dict['author'], data.edge_index_dict[('author', 'metapath_0', 'author')]).argmax(dim=-1)
        acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
        return float(acc)
    
  9. 我们创建了一个经典的训练循环,在这个循环中,节点特征(author)和边索引(authormetapath_0author)经过精心选择:

    for epoch in range(101):
        model.train()
        optimizer.zero_grad()
        out = model(data.x_dict['author'], data.edge_index_dict[('author', 'metapath_0', 'author')])
        mask = data['author'].train_mask
        loss = F.cross_entropy(out[mask], data['author'].y[mask])
        loss.backward()
        optimizer.step()
        if epoch % 20 == 0:
            train_acc = test(data['author'].train_mask)
            val_acc = test(data['author'].val_mask)
            print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
    
  10. 我们在测试集上进行测试,输出如下:

    test_acc = test(data['author'].test_mask)
    print(f'Test accuracy: {test_acc*100:.2f}%')
    Test accuracy: 73.29%
    

我们通过元路径将异构数据集转化为同构数据集,并应用了传统的 GAT。我们得到了 73.29%的测试准确率,这为与其他技术的比较提供了一个良好的基准。

现在,让我们创建一个异构版本的 GAT 模型。按照我们之前描述的方法,我们需要六个 GAT 层,而不是一个。我们不必手动完成这项工作,因为 PyTorch Geometric 可以通过to_hetero()to_hetero_bases()函数自动完成。to_hetero()函数有三个重要参数:

  • module:我们想要转换的同构模型

  • metadata:关于图的异构性质的信息,通过元组表示,(``node_types, edge_types)

  • aggr:用于合并由不同关系生成的节点嵌入的聚合器(例如,summaxmean

下图展示了我们的同构 GAT(左)及其异构版本(右),是通过to_hetero()获得的。

图 12.4 – 同构 GAT(左)和异构 GAT(右)在 DBLP 数据集上的架构

图 12.4 – 同构 GAT(左)和异构 GAT(右)在 DBLP 数据集上的架构

如以下步骤所示,异构 GAT 的实现类似:

  1. 首先,我们从 PyTorch Geometric 导入 GNN 层:

    from torch_geometric.nn import GATConv, Linear, to_hetero
    
  2. 我们加载了DBLP数据集:

    dataset = DBLP(root='.')
    data = dataset[0]
    
  3. 当我们打印出这个数据集的信息时,您可能已经注意到会议节点没有任何特征。这是一个问题,因为我们的架构假设每种节点类型都有自己的特征。我们可以通过生成零值作为特征来解决这个问题,方法如下:

    data['conference'].x = torch.zeros(20, 1)
    
  4. 我们创建了自己的 GAT 类,包含 GAT 和线性层。请注意,我们再次使用懒初始化,采用(-1, -1)元组:

    class GAT(torch.nn.Module):
        def __init__(self, dim_h, dim_out):
            super().__init__()
            self.conv = GATConv((-1, -1), dim_h, add_self_loops=False)
            self.linear = nn.Linear(dim_h, dim_out)
        def forward(self, x, edge_index):
            h = self.conv(x, edge_index).relu()
            h = self.linear(h)
            return h
    
  5. 我们实例化模型并通过to_hetero()进行转换:

    model = GAT(dim_h=64, dim_out=4)
    model = to_hetero(model, data.metadata(), aggr='sum')
    
  6. 我们实现了Adam优化器,并将模型和数据存储在 GPU 上(如果可能的话):

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    data, model = data.to(device), model.to(device)
    
  7. 测试过程非常相似。这次,我们不需要指定任何关系,因为模型会考虑所有关系:

    @torch.no_grad()
    def test(mask):
        model.eval()
        pred = model(data.x_dict, data.edge_index_dict)['author'].argmax(dim=-1)
        acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
        return float(acc)
    
  8. 对训练循环来说也是如此:

    for epoch in range(101):
        model.train()
        optimizer.zero_grad()
        out = model(data.x_dict, data.edge_index_dict)['author']
        mask = data['author'].train_mask
        loss = F.cross_entropy(out[mask], data['author'].y[mask])
        loss.backward()
        optimizer.step()
        if epoch % 20 == 0:
            train_acc = test(data['author'].train_mask)
            val_acc = test(data['author'].val_mask)
            print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
    
  9. 我们获得了以下的测试准确率:

    test_acc = test(data['author'].test_mask)
    print(f'Test accuracy: {test_acc*100:.2f}%')
    Test accuracy: 78.42%
    

异构 GAT 获得了 78.42%的测试准确率。这比同构版本提高了 5.13%,但我们能做得更好吗?在接下来的部分,我们将探讨一种专门设计用于处理异构网络的架构。

实现一个层次化自注意力网络

在本节中,我们将实现一个设计用于处理异构图的 GNN 模型——层次自注意力网络HAN)。该架构由 Liu 等人于 2021 年提出[5]。HAN 在两个不同的层次上使用自注意力机制:

  • 节点级别注意力用于理解在给定元路径中邻*节点的重要性(如同质设置下的 GAT)。

  • 在某些任务中,game-user-game 可能比 game-dev-game 更相关,例如预测玩家数量。

在接下来的部分中,我们将详细介绍三个主要组件——节点级别注意力、语义级别注意力和预测模块。该架构如图 12.5所示。

图 12.5 – HAN 的架构及其三个主要模块

图 12.5 – HAN 的架构及其三个主要模块

类似于 GAT,第一步是将节点映射到每个元路径的统一特征空间。接着,我们使用第二个权重矩阵计算在同一元路径中节点对(两个投影节点的连接)的权重。然后对该结果应用非线性函数,并通过 softmax 函数进行归一化。归一化后的注意力分数(重要性)表示为从节点到节点的计算方式如下:

在这里,表示节点的特征,元路径的共享权重矩阵,元路径的注意力权重矩阵,是非线性激活函数(如 LeakyReLU),而节点在元路径中的邻居集合(包括其自身)。

也会执行多头注意力机制以获得最终的嵌入表示:

通过语义级别注意力,我们对每个元路径的注意力分数(记作)执行类似的处理。每个给定元路径中的节点嵌入(记作)输入到 MLP 中,MLP 应用非线性变换。我们将此结果与一个新的注意力向量进行比较,作为相似度度量。然后我们对该结果求*均,以计算给定元路径的重要性:

在这里,(MLP 的权重矩阵)、(MLP 的偏置)和(语义级别的注意力向量)在所有元路径之间共享。

我们必须对这个结果进行归一化,以比较不同的语义级注意力分数。我们使用 softmax 函数来获取我们的最终权重:

结合节点级和语义级注意力的最终嵌入,如下所示:

最后一层,例如 MLP,用于微调模型以完成特定的下游任务,如节点分类或链接预测。

让我们在 PyTorch Geometric 上的 DBLP 数据集中实现这个架构:

  1. 首先,我们导入 HAN 层:

    from torch_geometric.nn import HANConv
    
  2. 我们加载 DBLP 数据集并为会议节点引入虚拟特征:

    dataset = DBLP('.')
    data = dataset[0]
    data['conference'].x = torch.zeros(20, 1)
    
  3. 我们创建了 HAN 类,包括两层 - 使用 HANConvHAN 卷积和用于最终分类的 linear 层:

    class HAN(nn.Module):
        def __init__(self, dim_in, dim_out, dim_h=128, heads=8):
            super().__init__()
            self.han = HANConv(dim_in, dim_h, heads=heads, dropout=0.6, metadata=data.metadata())
            self.linear = nn.Linear(dim_h, dim_out)
    
  4. forward() 函数中,我们必须指定我们对作者感兴趣:

        def forward(self, x_dict, edge_index_dict):
            out = self.han(x_dict, edge_index_dict)
            out = self.linear(out['author'])
            return out
    
  5. 我们使用懒初始化(dim_in=-1)初始化我们的模型,因此 PyTorch Geometric 自动计算每个节点类型的输入尺寸:

    model = HAN(dim_in=-1, dim_out=4)
    
  6. 我们选择了 Adam 优化器,并在可能的情况下将数据和模型转移到 GPU:

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    data, model = data.to(device), model.to(device)
    
  7. test() 函数计算分类任务的准确率:

    @torch.no_grad()
    def test(mask):
        model.eval()
        pred = model(data.x_dict, data.edge_index_dict).argmax(dim=-1)
        acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
        return float(acc)
    
  8. 我们将模型训练了 100 个 epochs。与同质 GNN 的训练循环唯一的区别在于,我们需要指定我们对作者节点类型感兴趣:

    for epoch in range(101):
        model.train()
        optimizer.zero_grad()
        out = model(data.x_dict, data.edge_index_dict)
        mask = data['author'].train_mask
        loss = F.cross_entropy(out[mask], data['author'].y[mask])
        loss.backward()
        optimizer.step()
        if epoch % 20 == 0:
            train_acc = test(data['author'].train_mask)
            val_acc = test(data['author'].val_mask)
            print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
    
  9. 训练给出了以下输出:

    Epoch:   0 | Train Loss: 1.3829 | Train Acc: 49.75% | Val Acc: 37.75%
    Epoch:  20 | Train Loss: 1.1551 | Train Acc: 86.50% | Val Acc: 60.75%
    Epoch:  40 | Train Loss: 0.7695 | Train Acc: 94.00% | Val Acc: 67.50%
    Epoch:  60 | Train Loss: 0.4750 | Train Acc: 97.75% | Val Acc: 73.75%
    Epoch:  80 | Train Loss: 0.3008 | Train Acc: 99.25% | Val Acc: 78.25%
    Epoch: 100 | Train Loss: 0.2247 | Train Acc: 99.50% | Val Acc: 78.75%
    
  10. 最后,我们在测试集上测试我们的解决方案:

    test_acc = test(data['author'].test_mask)
    print(f'Test accuracy: {test_acc*100:.2f}%')
    Test accuracy: 81.58%
    

HAN 在测试中获得了 81.58% 的准确率,比我们在异质 GAT(78.42%)和经典 GAT(73.29%)中获得的要高。这显示了构建良好的表示形式以聚合不同类型的节点和关系的重要性。异质图技术高度依赖于应用程序,但尝试不同的选项是值得的,特别是当网络中描述的关系具有意义时。

摘要

在本章中,我们介绍了 MPNN 框架,以三个步骤 - 消息、聚合和更新,来推广 GNN 层。在本章的其余部分,我们扩展了这个框架以考虑异质网络,由不同类型的节点和边组成。这种特殊类型的图允许我们表示实体之间的各种关系,这比单一类型的连接更具见解。

此外,我们看到如何通过 PyTorch Geometric 将同质 GNN 转换为异质 GNN。我们描述了在我们的异质 GAT 中使用的不同层,这些层将节点对作为输入来建模它们的关系。最后,我们使用 HAN 实现了一种特定于异质的架构,并在 DBLP 数据集上比较了三种技术的结果。这证明了利用这种网络中所表示的异质信息的重要性。

第十三章 时间图神经网络 中,我们将看到如何在图神经网络中考虑时间。这个章节将开启很多新的应用,得益于时间图,比如交通预测。它还将介绍 PyG 的扩展库——PyTorch Geometric Temporal,帮助我们实现专门设计用于处理时间的新模型。

进一步阅读

  • [1] J. Gilmer, S. S. Schoenholz, P. F. Riley, O. Vinyals, 和 G. E. Dahl. 量子化学的神经消息传递. arXiv, 2017. DOI: 10.48550/ARXIV.1704.01212. 可用: arxiv.org/abs/1704.01212.

  • [2] Jie Tang, Jing Zhang, Limin Yao, Juanzi Li, Li Zhang, 和 Zhong Su. ArnetMiner:学术社交网络的提取与挖掘. 载于第十四届 ACM SIGKDD 国际知识发现与数据挖掘大会论文集(SIGKDD'2008). pp.990–998. 可用: dl.acm.org/doi/abs/10.1145/1401890.1402008.

  • [3] X. Fu, J. Zhang, Z. Meng, 和 I. King. MAGNN:用于异构图嵌入的元路径聚合图神经网络. 2020 年 4 月. DOI: 10.1145/3366423.3380297. 可用: arxiv.org/abs/2002.01680.

  • [4] M. Schlichtkrull, T. N. Kipf, P. Bloem, R. van den Berg, I. Titov, 和 M. Welling. 用图卷积网络建模关系数据. arXiv, 2017. DOI: 10.48550/ARXIV.1703.06103. 可用: arxiv.org/abs/1703.06103.

  • [5] J. Liu, Y. Wang, S. Xiang, 和 C. Pan. HAN:一种高效的层次化自注意力网络,用于基于骨架的手势识别. arXiv, 2021. DOI: 10.48550/ARXIV.2106.13391. 可用: arxiv.org/abs/2106.13391.

第十三章:时序图神经网络

在前面的章节中,我们仅考虑了边和特征不发生变化的图。然而,在现实世界中,有许多应用场景并非如此。例如,在社交网络中,人们会关注和取消关注其他用户,帖子会变得病毒式传播,个人资料随着时间变化。这种动态性无法通过我们之前描述的 GNN 架构来表示。相反,我们必须嵌入一个新的时间维度,将静态图转换为动态图。这些动态网络将作为新一类 GNN 的输入:时序图神经网络T-GNNs),也称为时空 GNNs

在本章中,我们将描述两种包含时空信息的动态图。我们将列出不同的应用,并重点介绍时间序列预测,在该领域中,时序 GNNs 得到了广泛应用。第二节将专注于我们之前研究的一个应用:网页流量预测。这次,我们将利用时间信息来提高结果并获得可靠的预测。最后,我们将描述另一种为动态图设计的时序 GNN 架构,并将其应用于疫情预测,预测英国不同地区的 COVID-19 病例数。

到本章结束时,您将了解两种主要类型的动态图之间的区别。这对于将数据建模为正确类型的图非常有用。此外,您将学习两种时序 GNN 的设计和架构,并了解如何使用 PyTorch Geometric Temporal 来实现它们。这是创建自己的时序信息应用程序的关键步骤。

在本章中,我们将讨论以下主要内容:

  • 引入动态图

  • 网页流量预测

  • COVID-19 病例预测

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址是github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter13

在本书的前言部分,您可以找到在本地计算机上运行代码所需的安装步骤。

引入动态图

动态图和时序 GNNs 开启了多种新的应用场景,如交通和网页流量预测、动作分类、流行病预测、链接预测、电力系统预测等。时间序列预测在这种图结构中尤为流行,因为我们可以利用历史数据来预测系统的未来行为。

在本章中,我们将重点讨论具有时间维度的图。它们可以分为两类:

  • 带有时间信号的静态图:底层图结构不变,但特征和标签随着时间推移而变化。

  • 具有时间信号的动态图:图的拓扑结构(节点和边的存在性)、特征和标签随时间演变。

在第一种情况下,图的拓扑结构是静态的。例如,它可以表示一个国家内城市的网络用于交通预测:特征随时间变化,但连接保持不变。

在第二个选项中,节点和/或连接是动态的。它有助于表示一个社交网络,其中用户之间的链接可以随时间出现或消失。这种变体更为通用,但实现起来更加困难。

在接下来的几节中,我们将看到如何使用 PyTorch Geometric Temporal 处理这两种具有时间信号的图形。

预测网络流量

在本节中,我们将使用时间 GNN 来预测维基百科文章的流量(作为具有时间信号的静态图的示例)。这种回归任务已经在第六章引入图卷积网络中进行了讨论。然而,在那个版本的任务中,我们使用静态数据集进行了流量预测,没有时间信号:我们的模型没有任何关于先前实例的信息。这是一个问题,因为它无法理解流量当前是增加还是减少,例如。现在我们可以改进这个模型,以包含关于过去实例的信息。

我们将首先介绍具有其两个变体的时间 GNN 架构,然后使用 PyTorch Geometric Temporal 实现它。

引入 EvolveGCN

对于这个任务,我们将使用EvolveGCN架构。由 Pareja 等人[1]在 2019 年提出,它提议了 GNN 和递归神经网络RNNs)的自然组合。以前的方法,如图卷积递归网络,应用 RNN 与图卷积操作符来计算节点嵌入。相比之下,EvolveGCN 将 RNN 应用于 GCN 参数本身。顾名思义,GCN 随时间演变以生成相关的时间节点嵌入。以下图展示了这个过程的高层视图。

图 13.1 – EvolveGCN 的架构,用于生成具有时间信号的静态或动态图的节点嵌入

图 13.1 – EvolveGCN 的架构,用于生成具有时间信号的静态或动态图的节点嵌入

这种架构有两个变体:

  • EvolveGCN-H,其中递归神经网络考虑先前的 GCN 参数和当前的节点嵌入

  • EvolveGCN-O,其中递归神经网络仅考虑先前的 GCN 参数

EvolveGCN-H 通常使用 门控递归单元 (GRU) 代替普通的 RNN。GRU 是长短期记忆 (LSTM) 单元的简化版,能够在使用更少参数的情况下实现类似的性能。它由重置门、更新门和细胞状态组成。在这种架构中,GRU 在时间 时刻更新 GCN 的权重矩阵,具体过程如下:

表示在层 和时间 生成的节点嵌入,而 是来自上一个时间步的层 的权重矩阵。

生成的 GCN 权重矩阵随后被用来计算下一层的节点嵌入:

其中, 是包含自环的邻接矩阵, 是包含自环的度矩阵。

这些步骤在下图中进行了总结。

图 13.2 – 带有 GRU 和 GNN 的 EvolveGCN-H 架构

图 13.2 – 带有 GRU 和 GNN 的 EvolveGCN-H 架构

EvolveGCN-H 可以通过 GRU 来实现,GRU 接收两个扩展:

  • 输入和隐藏状态是矩阵,而非向量,用来正确存储 GCN 权重矩阵。

  • 输入的列维度必须与隐藏状态的列维度匹配,这要求对节点嵌入矩阵 进行汇总,仅保留合适的列数。

这些扩展对于 EvolveGCN-O 变体来说并非必需。实际上,EvolveGCN-O 基于 LSTM 网络来建模输入输出关系。我们不需要给 LSTM 提供隐藏状态,因为它已经包含了一个记忆先前值的细胞。这个机制简化了更新步骤,可以写成如下形式:

生成的 GCN 权重矩阵以相同方式使用,以产生下一层的节点嵌入:

这个实现更为简单,因为时间维度完全依赖于一个普通的 LSTM 网络。下图展示了 EvolveGCN-O 如何更新权重矩阵 并计算节点嵌入

图 13.3 – 带有 LSTM 和 GCN 的 EvolveGCN-O 架构

图 13.3 – 带有 LSTM 和 GCN 的 EvolveGCN-O 架构

那么我们应该使用哪个版本呢?正如在机器学习中常见的那样,最佳解决方案依赖于数据:

  • 当节点特征至关重要时,EvolveGCN-H 的表现更好,因为它的 RNN 明确地融合了节点嵌入。

  • 当图的结构起重要作用时,EvolveGCN-O 表现得更好,因为它更侧重于拓扑变化。

请注意,这些备注主要是理论性的,因此在您的应用程序中测试这两种变体可能会有所帮助。这正是我们通过实现这些模型来进行网络流量预测时所做的。

实现 EvolveGCN

在本节中,我们希望在带有时间信号的静态图上预测网络流量。WikiMaths数据集由 1,068 篇文章表示为节点。节点特征对应于过去每天的访问数量(默认情况下有八个特征)。边是加权的,权重表示从源页面到目标页面的链接数量。我们希望预测 2019 年 3 月 16 日至 2021 年 3 月 15 日之间这些 Wikipedia 页面的每日用户访问量,共有 731 个快照。每个快照是一个描述系统在特定时间状态的图。

图 13**.4展示了使用 Gephi 制作的 WikiMaths 表示,其中节点的大小和颜色与其连接数成比例。

图 13.4 – WikiMaths 数据集作为无权图(t=0)

图 13.4 – WikiMaths 数据集作为无权图(t=0)

PyTorch Geometric 本身不支持带有时间信号的静态或动态图。幸运的是,一个名为 PyTorch Geometric Temporal 的扩展[2]解决了这个问题,并且实现了多种时间序列 GNN 层。在 PyTorch Geometric Temporal 开发过程中,WikiMaths 数据集也被公开。在本章中,我们将使用这个库来简化代码并专注于应用:

  1. 我们需要在包含 PyTorch Geometric 的环境中安装此库:

    pip install torch-geometric-temporal==0.54.0
    
  2. 我们导入 WikiMaths 数据集,名为WikiMathDatasetLoader,带有temporal_signal_split的时间感知训练-测试划分,以及我们的 GNN 层EvolveGCNH

    from torch_geometric_temporal.signal import temporal_signal_split
    from torch_geometric_temporal.dataset import WikiMathsDatasetLoader
    from torch_geometric_temporal.nn.recurrent import EvolveGCNH
    
  3. 我们加载了 WikiMaths 数据集,这是一个StaticGraphTemporalSignal对象。在这个对象中,dataset[0]描述了时间点的图(在此上下文中也称为快照),而dataset[500]描述了时间点的图。我们还创建了一个训练集和测试集的划分,比例为0.5。训练集由较早时间段的快照组成,而测试集则重新组织了较晚时间段的快照:

    dataset = WikiMathsDatasetLoader().get_dataset() train_dataset, test_dataset = temporal_signal_split(dataset, train_ratio=0.5)
    dataset[0]
    Data(x=[1068, 8], edge_index=[2, 27079], edge_attr=[27079], y=[1068])
    dataset[500]
    Data(x=[1068, 8], edge_index=[2, 27079], edge_attr=[27079], y=[1068])
    
  4. 该图是静态的,因此节点和边的维度不会改变。然而,这些张量中包含的值是不同的。由于有 1,068 个节点,很难可视化每个节点的值。为了更好地理解这个数据集,我们可以计算每个快照的均值和标准差值。移动*均值也有助于*滑短期波动。

    import pandas as pd
    mean_cases = [snapshot.y.mean().item() for snapshot in dataset]
    std_cases = [snapshot.y.std().item() for snapshot in dataset]
    df = pd.DataFrame(mean_cases, columns=['mean'])
    df['std'] = pd.DataFrame(std_cases, columns=['std'])
    df['rolling'] = df['mean'].rolling(7).mean()
    
  5. 我们使用matplotlib绘制这些时间序列,以可视化我们的任务:

    plt.figure(figsize=(15,5))
    plt.plot(df['mean'], 'k-', label='Mean')
    plt.plot(df['rolling'], 'g-', label='Moving average')
    plt.grid(linestyle=':')
    plt.fill_between(df.index, df['mean']-df['std'], df['mean']+df['std'], color='r', alpha=0.1)
    plt.axvline(x=360, color='b', linestyle='--')
    plt.text(360, 1.5, 'Train/test split', rotation=-90, color='b')
    plt.xlabel('Time (days)')
    plt.ylabel('Normalized number of visits')
    plt.legend(loc='upper right')
    

这产生了图 13**.5

图 13.5 – WikiMaths 的*均归一化访问量与移动*均值

图 13.5 – WikiMaths 的*均归一化访问次数与移动*均

我们的数据呈现周期性模式,希望时间 GNN 能够学习到这些模式。现在我们可以实现它并看看它的表现。

  1. 时间 GNN 接收两个参数作为输入:节点数(node_count)和输入维度(dim_in)。GNN 只有两个层次:一个 EvolveGCN-H 层和一个线性层,后者输出每个节点的预测值:

    class TemporalGNN(torch.nn.Module):
        def __init__(self, node_count, dim_in):
            super().__init__()
            self.recurrent = EvolveGCNH(node_count, dim_in)
            self.linear = torch.nn.Linear(dim_in, 1)
    
  2. forward()函数将两个层应用于输入,并使用 ReLU 激活函数:

        def forward(self, x, edge_index, edge_weight):
            h = self.recurrent(x, edge_index, edge_weight).relu()
            h = self.linear(h)
            return h
    
  3. 我们创建一个TemporalGNN实例,并为其提供 WikiMaths 数据集的节点数和输入维度。我们将使用Adam优化器进行训练:

    model = TemporalGNN(dataset[0].x.shape[0], dataset[0].x.shape[1])
    optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
    model.train()
    
  4. 我们可以打印模型,以观察EvolveGCNH中包含的层:

    model
    TemporalGNN(
      (recurrent): EvolveGCNH(
        (pooling_layer): TopKPooling(8, ratio=0.00749063670411985, multiplier=1.0)
        (recurrent_layer): GRU(8, 8)
        (conv_layer): GCNConv_Fixed_W(8, 8)
      )
      (linear): Linear(in_features=8, out_features=1, bias=True)
    )
    

我们看到三个层次:TopKPooling,它将输入矩阵总结为八列;GRU,它更新 GCN 权重矩阵;以及GCNConv,它生成新的节点嵌入。最后,一个线性层输出每个节点的预测值。

  1. 我们创建一个训练循环,在训练集的每个快照上训练模型。对于每个快照,损失都会进行反向传播:

    for epoch in range(50):
        for i, snapshot in enumerate(train_dataset):
            y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
            loss = torch.mean((y_pred-snapshot.y)**2)
            loss.backward()
            optimizer.step()
            optimizer.zero_grad()
    
  2. 同样,我们在测试集上评估模型。MSE 在整个测试集上取*均,以生成最终得分:

    model.eval()
    loss = 0
    for i, snapshot in enumerate(test_dataset):
        y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
        mse = torch.mean((y_pred-snapshot.y)**2)
        loss += mse
    loss = loss / (i+1)
    print(f'MSE = {loss.item():.4f}')
    MSE = 0.7559
    
  3. 我们得到的损失值是 0.7559。接下来,我们将绘制我们模型在之前图表上的*均预测值进行解读。过程很简单:我们需要对预测值取*均并将它们存储在一个列表中。然后,我们可以将它们添加到之前的图表中:

     y_preds = [model(snapshot.x, snapshot.edge_index, snapshot.edge_attr).squeeze().detach().numpy().mean() for snapshot in test_dataset]
    plt.figure(figsize=(10,5))
    plt.plot(df['mean'], 'k-', label='Mean')
    plt.plot(df['rolling'], 'g-', label='Moving average')
    plt.plot(range(360,722), y_preds, 'r-', label='Prediction')
    plt.grid(linestyle=':')
    plt.fill_between(df.index, df['mean']-df['std'], df['mean']+df['std'], color='r', alpha=0.1)
    plt.axvline(x=360, color='b', linestyle='--')
    plt.text(360, 1.5, 'Train/test split', rotation=-90, color='b')
    plt.xlabel('Time (days)')
    plt.ylabel('Normalized number of visits')
    plt.legend(loc='upper right')
    

这给我们带来了图 13**.6

图 13.6 – 预测的*均归一化访问次数

图 13.6 – 预测的*均归一化访问次数

我们可以看到预测值遵循数据中的一般趋势。考虑到数据集的规模有限,这是一个很好的结果。

  1. 最后,让我们创建一个散点图,展示预测值和真实值在单一快照中的差异:

    import seaborn as sns
    y_pred = model(test_dataset[0].x, test_dataset[0].edge_index, test_dataset[0].edge_attr).detach().squeeze().numpy()
    plt.figure(figsize=(10,5))
    sns.regplot(x=test_dataset[0].y.numpy(), y=y_pred)
    

图 13.7 – WikiMaths 数据集的预测值与真实值对比

图 13.7 – WikiMaths 数据集的预测值与真实值对比

我们观察到预测值与真实值之间存在适度的正相关关系。我们的模型虽然没有特别准确,但前面的图表显示它很好地理解了数据的周期性特征。

实现 EvolveGCN-O 变体非常相似。我们不使用 PyTorch Geometric Temporal 中的EvolveGCNH层,而是将其替换为EvolveGCNO。这个层不需要节点数,因此我们只提供输入维度。实现如下:

from torch_geometric_temporal.nn.recurrent import EvolveGCNO
class TemporalGNN(torch.nn.Module):
    def __init__(self, dim_in):
        super().__init__()
        self.recurrent = EvolveGCNO(dim_in, 1)
        self.linear = torch.nn.Linear(dim_in, 1)
    def forward(self, x, edge_index, edge_weight):
        h = self.recurrent(x, edge_index, edge_weight).relu()
        h = self.linear(h)
        return h
model = TemporalGNN(dataset[0].x.shape[1])

*均来说,EvolveGCN-O 模型的结果相似,*均 MSE 为 0.7524。在这种情况下,使用 GRU 或 LSTM 网络不会影响预测。这是可以理解的,因为节点特征(EvolveGCN-H)中包含的过去访问次数和页面之间的连接(EvolveGCN-O)都至关重要。因此,这种 GNN 架构特别适用于此交通预测任务。

现在我们已经看到了一个静态图的例子,让我们来探讨如何处理动态图。

预测 COVID-19 病例

本节将重点介绍一个新的应用——流行病预测。我们将使用英格兰 Covid 数据集,这是一个带有时间信息的动态图,由 Panagopoulos 等人于 2021 年提出[3]。尽管节点是静态的,但节点之间的连接和边的权重随时间变化。该数据集表示 2020 年 3 月 3 日至 5 月 12 日间,英格兰 129 个 NUTS 3 地区报告的 COVID-19 病例数。数据来源于安装了 Facebook 应用并共享其位置历史的手机。我们的目标是预测每个节点(地区)一天内的病例数。

图 13.8 – 英格兰的 NUTS 3 区域以红色标出

图 13.8 – 英格兰的 NUTS 3 区域以红色标出

该数据集将英格兰表示为一个图!。由于该数据集具有时间性,因此它由多个图组成,每个图对应研究期间的每一天!。在这些图中,节点特征表示该地区过去!天的病例数。边是单向的并且加权:边!的权重!表示在时间!从区域!到区域!的人数。这些图还包含自环,表示在同一地区内移动的人。

本节将介绍一种专门为此任务设计的新型 GNN 架构,并展示如何一步步实现它。

介绍 MPNN-LSTM

如其名所示,MPNN-LSTM架构依赖于将 MPNN 和 LSTM 网络相结合。像英格兰 Covid 数据集一样,它也是由 Panagopoulos 等人于 2021 年提出的[3]。

输入的节点特征与相应的边索引和权重被送入 GCN 层。我们对这个输出应用批量归一化层和 dropout。这个过程会在第一次 MPNN 的输出结果基础上重复第二次。它生成一个节点嵌入矩阵 。我们通过对每个时间步应用这些 MPNN,创建一个节点嵌入表示序列 。这个序列被送入一个 2 层 LSTM 网络,以捕捉图中的时间信息。最后,我们对该输出应用线性变换和 ReLU 函数,生成一个在 的预测结果。

以下图显示了 MPNN-LSTM 架构的高级视图。

图 13.9 – MPNN-LSTM 的架构

图 13.9 – MPNN-LSTM 的架构

MPNN-LSTM 的作者指出,它并不是在英国 Covid 数据集上表现最好的模型(带有二级 GNN 的 MPNN 才是)。然而,它是一个有趣的方法,在其他场景中可能表现更好。他们还表示,它更适用于长期预测,比如预测未来 14 天,而不是像我们在此数据集版本中所做的单日预测。尽管存在这个问题,我们还是为了方便使用后者,因为它不影响解决方案的设计。

实现 MPNN-LSTM

首先,重要的是要可视化我们想要预测的病例数。与上一节相同,我们将通过计算均值和标准差来总结组成数据集的 129 个不同时间序列:

  1. 我们从 PyTorch Geometric Temporal 导入 pandasmatplotlib、英国 Covid 数据集以及时间序列训练-测试分割函数:

    import pandas as pd
    import matplotlib.pyplot as plt
    from torch_geometric_temporal.dataset import EnglandCovidDatasetLoader
    from torch_geometric_temporal.signal import temporal_signal_split
    
  2. 我们加载包含 14 个滞后期的数据集,滞后期对应于节点特征的数量:

    dataset = EnglandCovidDatasetLoader().get_dataset(lags=14)
    
  3. 我们执行时间信号分割,训练比例为 0.8

    train_dataset, test_dataset = temporal_signal_split(dataset, train_ratio=0.8)
    
  4. 我们绘制了以下图表,以展示报告病例的均值标准化数量(这些病例大约每天报告一次)。代码可以在 GitHub 上找到,并且适配了我们在上一节中使用的代码片段。

图 13.10 – 英国 Covid 数据集的均值标准化病例数

图 13.10 – 英国 Covid 数据集的均值标准化病例数

这个图表显示了大量的波动性和较少的快照数量。这就是为什么我们在本例中使用 80/20 的训练-测试划分。尽管如此,在这样一个小数据集上获得良好的性能可能会具有挑战性。

现在我们来实现 MPNN-LSTM 架构。

  1. 我们从 PyTorch Geometric Temporal 导入 MPNNLSTM 层:

    From torch_geometric_temporal.nn.recurrent import MPNNLSTM
    
  2. 时间序列 GNN 接受三个参数作为输入:输入维度、隐藏维度和节点数。我们声明了三个层:MPNN-LSTM 层、一个 dropout 层和一个具有正确输入维度的线性层:

    Class TemporalGNN(torch.nn.Module):
        def __init__(self, dim_in, dim_h, num_nodes):
            super().__init__()
            self.recurrent = MPNNLSTM(dim_in, dim_h, num_nodes, 1, 0.5)
            self.dropout = torch.nn.Dropout(0.5)
            self.linear = torch.nn.Linear(2*dim_h + dim_in, 1)
    
  3. forward() 函数考虑了边的权重,这是该数据集中的关键信息。请注意,我们正在处理动态图,因此每个时间步骤都会提供一组新的 edge_indexedge_weight 值。与之前描述的原始 MPNN-LSTM 不同,我们用 tanh 函数替代了最后的 ReLU 函数。主要的动机是,tanh 输出的值在 -1 和 1 之间,而不是 0 和 1,这更接*我们在数据集中观察到的情况:

        Def forward(self, x, edge_index, edge_weight):
            h = self.recurrent(x, edge_index, edge_weight).relu()
            h = self.dropout(h)
            h = self.linear(h).tanh()
            return h
    
  4. 我们创建了一个隐藏维度为 64 的 MPNN-LSTM 模型,并打印它以观察不同的层:

    model = TemporalGNN(dataset[0].x.shape[1], 64, dataset[0].x.shape[0])
    print(model)
    TemporalGNN(
      (recurrent): MPNNLSTM(
        (_convolution_1): GCNConv(14, 64)
        (_convolution_2): GCNConv(64, 64)
        (_batch_norm_1): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (_batch_norm_2): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (_recurrent_1): LSTM(128, 64)
        (_recurrent_2): LSTM(64, 64)
      )
      (dropout): Dropout(p=0.5, inplace=False)
      (linear): Linear(in_features=142, out_features=1, bias=True)
    )
    

我们看到 MPNN-LSTM 层包含两个 GCN、两个批归一化层和两个 LSTM 层(但没有 dropout),这与我们之前的描述一致。

  1. 我们使用 Adam 优化器和学习率为 0.001,将该模型训练了 100 轮。本次,我们在每次快照后反向传播损失,而不是在每个实例后反向传播:

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    model.train()
    for epoch in range(100):
        loss = 0
        for i, snapshot in enumerate(train_dataset):
            y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
            loss = loss + torch.mean((y_pred-snapshot.y)**2)
        loss = loss / (i+1)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
    
  2. 我们在测试集上评估了训练好的模型,并得到了以下的 MSE 损失:

    model.eval()
    loss = 0
    for i, snapshot in enumerate(test_dataset):
        y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
        mse = torch.mean((y_pred-snapshot.y)**2)
        loss += mse
    loss = loss / (i+1)
    print(f'MSE: {loss.item():.4f}')
    MSE: 1.3722
    

MPNN-LSTM 模型的 MSE 损失为 1.3722,似乎相对较高。

我们无法还原对该数据集应用的归一化过程,因此将使用归一化后的病例数。首先,让我们绘制模型预测的*均归一化病例数(代码可在 GitHub 上获取)。

图 13.11 – *均归一化病例数,真实值为黑色,预测值为红色

图 13.11 – *均归一化病例数,真实值为黑色,预测值为红色

如预期的那样,预测值与真实值不太匹配。这可能是由于数据不足:我们的模型学习到了一个最小化 MSE 损失的*均值,但无法拟合曲线并理解其周期性。

让我们查看对应于测试集第一张快照的散点图(代码可在 GitHub 上获取)。

图 13.12 – 英国 Covid 数据集的预测值与真实值对比

图 13.12 – 英国 Covid 数据集的预测值与真实值对比

散点图显示了弱相关性。我们看到预测值(y 轴)大多集中在 0.35 左右,变化很小。这与真实值不一致,真实值的范围从 -1.5 到 0.6。根据我们的实验,添加第二个线性层并没有改善 MPNN-LSTM 的预测结果。

可以实施几种策略来帮助模型。首先,更多的数据点会有很大帮助,因为这是一个小数据集。此外,时间序列包含两个有趣的特征:趋势(随时间持续增加或减少)和季节性(可预测的模式)。我们可以添加一个预处理步骤,去除这些特征,它们会为我们想要预测的信号增加噪声。

除了递归神经网络外,自注意力机制是另一种常用的技术,用于创建时序 GNN[4]。注意力机制可以仅限于时序信息,也可以考虑空间数据,通常通过图卷积来处理。最后,时序 GNN 也可以扩展到前一章中描述的异构设置。不幸的是,这种组合需要更多的数据,目前仍是一个活跃的研究领域。

摘要

本章介绍了一种新型的具有时空信息的图。这种时间成分在许多应用中非常有用,主要与时间序列预测相关。我们描述了两种符合此描述的图:静态图,其中特征随时间演变;动态图,其中特征和拓扑可以发生变化。它们都由 PyTorch Geometric Temporal 处理,PyG 的扩展专门用于时序图神经网络。

此外,我们还介绍了时序 GNN 的两个应用。首先,我们实现了 EvolveGCN 架构,该架构使用 GRU 或 LSTM 网络来更新 GCN 参数。我们通过回顾在第六章引入图卷积网络中遇到的网页流量预测任务,应用了这个架构,并在有限的数据集上取得了出色的结果。其次,我们使用 MPNN-LSTM 架构进行疫情预测。我们将其应用于英格兰 Covid 数据集,使用带有时间信号的动态图,但由于其数据量较小,未能获得可比的结果。

第十四章解释图神经网络中,我们将重点讨论如何解释我们的结果。除了我们迄今为止介绍的各种可视化方法外,我们还将看到如何将可解释人工智能XAI)的技术应用于图神经网络。这个领域是构建稳健 AI 系统和推动机器学习应用的重要组成部分。在该章节中,我们将介绍事后解释方法和新的层,以构建从设计上就可以解释的模型。

进一步阅读

  • [1] A. Pareja 等人,EvolveGCN:用于动态图的演化图卷积网络。arXiv,2019. DOI: 10.48550/ARXIV.1902.10191. 可用:arxiv.org/abs/1902.10191

  • [2] B. Rozemberczki 等人,PyTorch Geometric Temporal:使用神经机器学习模型进行时空信号处理,发表于第 30 届 ACM 国际信息与知识管理大会论文集,2021 年,页 4564–4573. 可用:arxiv.org/abs/2104.07788

  • [3] G. Panagopoulos,G. Nikolentzos 和 M. Vazirgiannis。传递图神经网络在疫情预测中的应用。arXiv,2020. DOI: 10.48550/ARXIV.2009.08388. 可用:arxiv.org/abs/2009.08388

  • [4] Guo, S., Lin, Y., Feng, N., Song, C., & Wan, H. (2019). 基于注意力的时空图卷积网络用于交通流量预测. 《人工智能协会会议论文集》,33(01),922-929. doi.org/10.1609/aaai.v33i01.3301922

第十四章:解释图神经网络

神经网络(NN)最常见的批评之一是它们的输出难以理解。不幸的是,GNN 也不例外:除了解释哪些特征很重要外,还必须考虑邻接节点和连接。为了应对这一问题,可解释性(以可解释人工智能XAI的形式)领域开发了许多技术,以更好地理解预测背后的原因或模型的总体行为。部分技术已经被转移到 GNN 上,另一些则利用图结构提供更精确的解释。

在本章中,我们将探讨一些解释技术,了解为什么给定的预测会被做出。我们将看到不同的技术类型,并重点介绍两种最流行的:MUTAG数据集。接着,我们将介绍Captum,一个提供多种解释技术的 Python 库。最后,利用 Twitch 社交网络,我们将实现集成梯度技术,解释节点分类任务中的模型输出。

到本章结束时,你将能够理解并实施几种 XAI 技术在 GNN 上的应用。更具体地说,你将学习如何使用 GNNExplainer 和Captum库(结合集成梯度)来进行图形和节点分类任务。

在本章中,我们将涵盖以下主要主题:

  • 介绍解释技术

  • 使用 GNNExplainer 解释 GNN

  • 使用 Captum 解释 GNN

技术要求

本章的所有代码示例可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter14

在本书的前言中可以找到在本地计算机上运行代码所需的安装步骤。

介绍解释技术

GNN 解释是一个最*的领域,深受其他 XAI 技术的启发[1]。我们将其分为基于单次预测的局部解释和针对整个模型的全局解释。虽然理解 GNN 模型的行为是有意义的,但我们将重点关注更受欢迎和本质的局部解释,以便深入了解预测结果。

在本章中,我们区分了“可解释”和“可解释性”模型。如果一个模型是“可解释的”,则意味着它从设计上就可以被人类理解,例如决策树。另一方面,当一个模型作为黑箱工作,其预测只能通过解释技术事后理解时,才称之为“可解释性”。这通常适用于神经网络(NN):它们的权重和偏置不像决策树那样提供明确的规则,但其结果可以通过间接方式解释。

本地解释技术有四种主要类别:

  • 基于梯度的方法分析输出的梯度,以估计归因分数(例如,集成梯度

  • 基于扰动的方法遮盖或修改输入特征,以测量输出的变化(例如,GNNExplainer

  • 分解方法将模型的预测分解为多个项,以衡量它们的重要性(例如,图神经网络逐层相关性传播GNN-LRP))

  • 替代方法使用简单且可解释的模型,来*似原始模型在某一区域的预测(例如,GraphLIME

这些技术是互补的:它们有时在边和特征的贡献上存在分歧,这可以用于进一步细化预测的解释。传统上,解释技术使用以下指标进行评估:

  • 保真度,比较原始图像与修改后的图像之间的预测概率。修改后的图像仅保留基于的最重要特征(节点、边、节点特征)。换句话说,保真度衡量的是被认为重要的特征在获得正确预测方面的充分程度。它的正式定义如下:

  • 稀疏性,衡量被认为重要的特征(节点、边、节点特征)所占的比例。过长的解释更难理解,这也是鼓励稀疏性的原因。它的计算方式如下:

在这里,是重要输入特征的数量,是特征的总数量。

除了我们在前几章看到的传统图形外,解释技术通常在合成数据集上进行评估,如BA-ShapesBA-CommunityTree-CyclesTree-Grid [2]。这些数据集是通过图生成算法生成的,用于创建特定的模式。我们在本章中不会使用它们,但它们是一个有趣的替代方案,易于实现和理解。

在接下来的章节中,我们将描述一种基于梯度的方法(集成梯度)和一种基于扰动的技术(GNNExplainer)。

使用 GNNExplainer 解释 GNN

在本节中,我们将介绍我们的第一个 XAI 技术——GNNExplainer。我们将用它来理解 GIN 模型在MUTAG数据集上产生的预测。

引入 GNNExplainer

GNNExplainer 是由 Ying 等人于 2019 年提出的 [2],它是一种旨在解释来自其他 GNN 模型预测的 GNN 架构。在表格数据中,我们希望知道哪些特征对预测最为重要。然而,在图数据中,这还不够:我们还需要知道哪些节点最具影响力。GNNExplainer 通过提供一个子图 和一组节点特征 ,生成包含这两个组件的解释。下图展示了 GNNExplainer 为给定节点提供的解释:

图 14.1 – 节点标签的解释,绿色表示,非排除节点特征

图 14.1 – 节点 的标签解释,绿色表示 ,非排除节点特征

为了预测 ,GNNExplainer 实现了边掩码(用于隐藏连接)和特征掩码(用于隐藏节点特征)。如果一个连接或特征很重要,删除它应该会显著改变预测。另一方面,如果预测没有变化,说明这个信息是冗余的或完全不相关的。这个原理是基于扰动的技术,如 GNNExplainer 的核心。

在实践中,我们必须仔细设计损失函数,以找到最好的掩码。GNNExplainer 测量预测标签分布 之间的互依性,也叫做互信息 (MI)。我们的目标是最大化 MI,这等同于最小化条件交叉熵。GNNExplainer 通过找到变量 ,最大化预测 的概率来进行训练。

除了这个优化框架,GNNExplainer 还学习了一个二进制特征掩码,并实现了几种正则化技术。最重要的技术是一个项,用于最小化解释的大小(稀疏性)。它是通过求和掩码参数的所有元素并将其添加到损失函数中来计算的。这样可以生成更具用户友好性和简洁性的解释,便于理解和解释。

GNNExplainer 可以应用于大多数 GNN 架构以及不同的任务,如节点分类、链接预测或图分类。它还可以生成类标签或整个图的解释。在进行图分类时,模型会考虑图中所有节点的邻接矩阵的并集,而不是单一的矩阵。在下一节中,我们将应用它来解释图分类。

实现 GNNExplainer

在本例中,我们将探索MUTAG数据集[3]。该数据集中的 188 张图每一张代表一个化学化合物,节点表示原子(有七种可能的原子),边表示化学键(有四种可能的化学键)。节点和边的特征分别表示原子和化学键类型的独热编码。目标是根据化合物对细菌沙门氏菌的致突变效应,将每个化合物分类为两类。

我们将重用第九章中介绍的 GIN 模型进行蛋白质分类。在第九章中,我们可视化了模型的正确和错误分类。然而,我们无法解释 GNN 做出的预测。这一次,我们将使用 GNNExplainer 来理解最重要的子图和节点特征,从而解释分类结果。在此示例中,为了简便起见,我们将忽略边特征。以下是步骤:

  1. 我们从 PyTorch 和 PyTorch Geometric 中导入所需的类:

    import matplotlib.pyplot as plt
    import torch.nn.functional as F
    from torch.nn import Linear, Sequential, BatchNorm1d, ReLU, Dropout
    from torch_geometric.datasets import TUDataset
    from torch_geometric.loader import DataLoader
    from torch_geometric.nn import GINConv, global_add_pool, GNNExplainer
    
  2. 我们加载MUTAG数据集并进行洗牌:

    dataset = TUDataset(root='.', name='MUTAG').shuffle()
    
  3. 我们创建训练集、验证集和测试集:

    train_dataset = dataset[:int(len(dataset)*0.8)]
    val_dataset   = dataset[int(len(dataset)*0.8):int(len(dataset)*0.9)]
    test_dataset  = dataset[int(len(dataset)*0.9):]
    
  4. 我们创建数据加载器以实现小批量训练:

    train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
    val_loader   = DataLoader(val_dataset, batch_size=64, shuffle=True)
    test_loader  = DataLoader(test_dataset, batch_size=64, shuffle=True)
    
  5. 我们使用第九章中的代码创建一个具有 32 个隐藏维度的 GIN 模型:

    class GIN(torch.nn.Module):
    ...
    model = GIN(dim_h=32)
    
  6. 我们训练此模型 100 轮,并使用第九章中的代码进行测试:

    def train(model, loader):
     ...
    model = train(model, train_loader)
    test_loss, test_acc = test(model, test_loader)
    print(f'Test Loss: {test_loss:.2f} | Test Acc: {test_acc*100:.2f}%')
    Test Loss: 0.48 | Test Acc: 84.21%
    
  7. 我们的 GIN 模型已经训练完成,并取得了较高的准确率(84.21%)。现在,让我们使用 PyTorch Geometric 中的GNNExplainer类创建一个 GNNExplainer 模型,并进行 100 轮训练:

    explainer = GNNExplainer(model, epochs=100, num_hops=1)
    
  8. GNNExplainer 可用于解释节点的预测(.explain_node())或整个图的预测(.explain_graph())。在本例中,我们将其应用于测试集中的最后一张图:

    data = dataset[-1]
    feature_mask, edge_mask = explainer.explain_graph(data.x, data.edge_index)
    
  9. 最后一步返回了特征和边掩码。让我们打印特征掩码,查看最重要的值:

    feature_mask
    tensor([0.7401, 0.7375, 0.7203, 0.2692, 0.2587, 0.7516, 0.2872])
    

这些值被标准化到 0(较不重要)和 1(较重要)之间。这七个值对应于数据集中按以下顺序找到的七种原子:碳(C)、氮(N)、氧(O)、氟(F)、碘(I)、氯(Cl)和溴(Br)。特征具有类似的重要性:最有用的是最后一个,表示溴(Br),而最不重要的是第五个,表示碘(I)。

  1. 我们可以通过.visualize_graph()方法将边掩码绘制在图形上,而不是直接打印出来。箭头的透明度表示每个连接的重要性:

    ax, G = explainer.visualize_subgraph(-1, data.edge_index, edge_mask, y=data.y)
    plt.show()
    

这给出了图 14.2

图 14.2 – 化学化合物的图表示:边的透明度表示每个连接的重要性

图 14.2 – 化学化合物的图表示:边的透明度表示每个连接的重要性

最后一张图展示了对预测贡献最大的连接。在这种情况下,GIN 模型正确地对图进行了分类。我们可以看到节点之间的连接 data.edge_attr,以获取与其化学键(芳香键、单键、双键或三键)相关的标签。在此示例中,它对应的是 1619 的边,这些边都是单键或双键。

通过打印 data.x,我们还可以查看节点 678 来获取更多信息。节点 6 代表氮原子,而节点 78 代表两个氧原子。这些结果应该报告给具备相关领域知识的人,以便对我们的模型进行反馈。

GNNExplainer 并没有提供关于决策过程的精确规则,而是提供了对 GNN 模型在做出预测时关注的内容的洞察。仍然需要人类的专业知识来确保这些观点是连贯的,并且与传统领域知识相符。

在下一节中,我们将使用 Captum 来解释新社交网络上的节点分类。

使用 Captum 解释 GNN

在本节中,我们将首先介绍 Captum 和应用于图数据的集成梯度技术。然后,我们将在 Twitch 社交网络上使用 PyTorch Geometric 模型实现这一技术。

介绍 Captum 和集成梯度

Captum (captum.ai) 是一个 Python 库,实施了许多用于 PyTorch 模型的最先进的解释算法。这个库并非专门针对 GNN:它也可以应用于文本、图像、表格数据等。它特别有用,因为它允许用户快速测试各种技术,并比较对同一预测的不同解释。此外,Captum 实现了如 LIME 和 Gradient SHAP 等流行算法,用于主特征、层和神经元的归因。

在本节中,我们将使用它来应用图版本的集成梯度 [4]。这一技术旨在为每个输入特征分配一个归因分数。为此,它使用相对于模型输入的梯度。具体来说,它使用输入 和基线输入 (在我们的情况下,所有边的权重为零)。它计算沿着 之间路径的所有点的梯度,并对其进行累加。

从形式上看,沿着 维度的集成梯度,对于输入 的定义如下:

实际上,我们并不是直接计算这个积分,而是通过离散求和来*似它。

集成梯度是与模型无关的,并基于两个公理:

  • 敏感性:每个对预测有贡献的输入必须获得非零的归因

  • 实现不变性:对于所有输入输出相等的两个神经网络(这些网络被称为功能上等效的),它们的归因必须完全相同。

我们将使用的图版本稍有不同:它考虑节点和边而非特征。因此,您可以看到输出与 GNNExplainer 不同,后者同时考虑节点特征边。这就是为什么这两种方法可以互为补充的原因。

现在,让我们实现这个技术并可视化结果。

实现集成梯度

我们将在一个新的数据集上实现集成梯度:Twitch 社交网络数据集(英文版)[5]。该数据集表示一个用户-用户图,其中节点代表 Twitch 主播,连接代表相互友谊。128 个节点特征表示诸如直播习惯、地点、喜欢的游戏等信息。目标是判断一个主播是否使用过激语言(二分类任务)。

我们将使用 PyTorch Geometric 实现一个简单的两层 GCN 来完成此任务。然后我们将模型转换为 Captum,使用集成梯度算法并解释我们的结果。以下是步骤:

  1. 我们安装captum库:

    !pip install captum
    
  2. 我们导入所需的库:

    import numpy as np
    import matplotlib.pyplot as plt
    import torch.nn.functional as F
    from captum.attr import IntegratedGradients
    import torch_geometric.transforms as T
    from torch_geometric.datasets import Twitch
    from torch_geometric.nn import Explainer, GCNConv, to_captum
    
  3. 让我们固定随机种子,以使计算具有确定性:

    torch.manual_seed(0)
    np.random.seed(0)
    
  4. 我们加载 Twitch 玩家网络数据集(英文版):

    dataset = Twitch('.', name="EN")
    data = dataset[0]
    
  5. 这次,我们将使用一个简单的两层 GCN,并加上dropout

    class GCN(torch.nn.Module):
        def __init__(self, dim_h):
            super().__init__()
            self.conv1 = GCNConv(dataset.num_features, dim_h)
            self.conv2 = GCNConv(dim_h, dataset.num_classes)
        def forward(self, x, edge_index):
            h = self.conv1(x, edge_index).relu()
            h = F.dropout(h, p=0.5, training=self.training)
            h = self.conv2(h, edge_index)
            return F.log_softmax(h, dim=1)
    
  6. 我们尝试在有 GPU 的情况下使用Adam优化器训练模型:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = GCN(64).to(device)
    data = data.to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
    
  7. 我们使用负对数似然损失函数训练模型 200 个周期:

    for epoch in range(200):
        model.train()
        optimizer.zero_grad()
        log_logits = model(data.x, data.edge_index)
        loss = F.nll_loss(log_logits, data.y)
        loss.backward()
        optimizer.step()
    
  8. 我们测试训练好的模型。请注意,我们没有指定任何测试,因此在这种情况下我们将评估 GCN 在训练集上的准确度:

    def accuracy(pred_y, y):
        return ((pred_y == y).sum() / len(y)).item()
    @torch.no_grad()
    def test(model, data):
        model.eval()
        out = model(data.x, data.edge_index)
        acc = accuracy(out.argmax(dim=1), data.y)
        return acc
    acc = test(model, data)
    print(f'Accuracy: {acc*100:.2f}%')
    Accuracy: 79.75%
    

模型达到了79.75%的准确率,考虑到它是在训练集上评估的,这个分数相对较低。

  1. 现在我们可以开始实现我们选择的解释方法:集成梯度。首先,我们必须指定我们想要解释的节点(在本例中为节点0),并将 PyTorch Geometric 模型转换为Captum。在这里,我们还指定我们想使用特征和边掩码,mask_type=node_and_feature

    node_idx = 0
    captum_model = to_captum(model, mask_type='node_and_edge', output_idx=node_idx)
    
  2. 让我们使用Captum创建集成梯度对象。我们将上一步的结果作为输入:

    ig = IntegratedGradients(captum_model)
    
  3. 我们已经拥有需要传递给Captum的节点掩码(data.x),但我们需要为边掩码创建一个张量。在这个例子中,我们希望考虑图中的每一条边,因此初始化一个大小为data.num_edges的全 1 张量:

    edge_mask = torch.ones(data.num_edges, requires_grad=True, device=device)
    
  4. .attribute()方法对节点和边掩码的输入格式有特定要求(因此使用.unsqueeze(0)来重新格式化这些张量)。目标对应于我们目标节点的类别。最后,我们将邻接矩阵(data.edge_index)作为额外的前向参数传递:

    attr_node, attr_edge = ig.attribute(
        (data.x.unsqueeze(0), edge_mask.unsqueeze(0)),
        target=int(data.y[node_idx]),
        additional_forward_args=(data.edge_index),
        internal_batch_size=1)
    
  5. 我们将归一化归因分数,使其范围在01之间:

    attr_node = attr_node.squeeze(0).abs().sum(dim=1)
    attr_node /= attr_node.max()
    attr_edge = attr_edge.squeeze(0).abs()
    attr_edge /= attr_edge.max()
    
  6. 使用 PyTorch Geometric 的Explainer类,我们可视化这些归因的图形表示:

    explainer = Explainer(model)
    ax, G = explainer.visualize_subgraph(node_idx, data.edge_index, attr_edge, node_alpha=attr_node, y=data.y)
    plt.show()
    

这给我们带来了以下输出:

图 14.3 – 节点 0 分类的解释,边和节点归因分数以不同的透明度值表示

图 14.3 – 节点 0 分类的解释,边和节点归因分数以不同的透明度值表示

节点 0 的子图由蓝色节点组成,这些节点属于同一类别。我们可以看到,节点 82 是最重要的节点(除了 0 以外),这两个节点之间的连接是最关键的边。这是一个直接的解释:我们有一组使用相同语言的四个直播者。节点 082 之间的互相友谊是这一预测的有力依据。

现在让我们来看一下图 14.4 中展示的另一个图,节点 101 分类的解释:

图 14.4 – 节点 101 分类的解释,边和节点归因分数以不同的透明度值表示

图 14.4 – 节点 101 分类的解释,边和节点归因分数以不同的透明度值表示

在这种情况下,我们的目标节点与不同类别的邻居(节点 53982849)相连。集成梯度赋予与节点 101 同类别的节点更大的重要性。我们还看到,它们的连接是对该分类贡献最大的部分。这个子图更丰富;你可以看到,甚至两跳邻居也有一定的贡献。

然而,这些解释不应被视为灵丹妙药。AI 的可解释性是一个复杂的话题,通常涉及不同背景的人。因此,沟通结果并获得定期反馈尤为重要。了解边、节点和特征的重要性至关重要,但这应该只是讨论的开始。来自其他领域的专家可以利用或完善这些解释,甚至发现可能导致架构变化的问题。

总结

在本章中,我们探讨了应用于图神经网络(GNN)的可解释性人工智能(XAI)领域。可解释性是许多领域的关键组成部分,有助于我们构建更好的模型。我们看到了提供局部解释的不同技术,并重点介绍了 GNNExplainer(一种基于扰动的方法)和集成梯度(一种基于梯度的方法)。我们在两个不同的数据集上使用 PyTorch Geometric 和 Captum 实现了这些方法,以获得图和节点分类的解释。最后,我们对这些技术的结果进行了可视化和讨论。

第十五章使用 A3T-GCN 预测交通流量 中,我们将重新审视时序 GNN,以预测道路网络上的未来交通。在这个实际应用中,我们将看到如何将道路转化为图,并应用一种最新的 GNN 架构来准确预测短期交通。

进一步阅读

  • [1] H. Yuan, H. Yu, S. Gui, 和 S. Ji。图神经网络中的可解释性:一项分类调查。arXiv,2020。DOI: 10.48550/ARXIV.2012.15445。可在arxiv.org/abs/2012.15445获取。

  • [2] R. Ying, D. Bourgeois, J. You, M. Zitnik, 和 J. Leskovec。GNNExplainer:为图神经网络生成解释。arXiv,2019。DOI: 10.48550/ARXIV.1903.03894。可在arxiv.org/abs/1903.03894获取。

  • [3] Debnath, A. K., Lopez de Compadre, R. L., Debnath, G., Shusterman, A. J., 和 Hansch, C.(1991)。突变性芳香族和杂芳香族硝基化合物的结构-活性关系。与分子轨道能量和疏水性的相关性。DOI: 10.1021/jm00106a046。药物化学杂志,34(2),786–797。可在doi.org/10.1021/jm00106a046获取。

  • [4] M. Sundararajan, A. Taly, 和 Q. Yan。深度网络的公理化归因。arXiv,2017。DOI: 10.48550/ARXIV.1703.01365。可在arxiv.org/abs/1703.01365获取。

  • [5] B. Rozemberczki, C. Allen, 和 R. Sarkar。多尺度属性节点嵌入arXiv2019。DOI: 10.48550/ARXIV.1909.13021。可在arxiv.org/pdf/1909.13021.pdf获取。

第四部分:应用

在本书的第四部分,也是最后一部分,我们深入探讨了利用真实世界数据开发全面应用的过程。我们将重点关注前几章中未涉及的方面,如探索性数据分析和数据处理。我们的目标是提供关于机器学习管道的详尽概述,从原始数据到模型输出分析。我们还将强调所讨论技术的优点和局限性。

本节中的项目设计为适应性强且可定制,使读者能够轻松地将其应用于其他数据集和任务。这使其成为希望构建应用程序组合并展示自己工作(如在 GitHub 上的工作)的读者的理想资源。

到本部分结束时,你将学会如何实现 GNNs 用于交通预测、异常检测和推荐系统。这些项目的选择旨在展示 GNNs 在解决现实问题中的多样性和潜力。通过这些项目获得的知识和技能将为读者开发自己的应用程序并为图学习领域作出贡献做好准备。

本部分包括以下章节:

  • 第十五章**,使用 A3T-GCN 预测交通流量

  • 第十六章**,使用异质图神经网络检测异常

  • 第十七章**,使用 LightGCN 推荐书籍

  • 第十八章**,解锁图神经网络在现实世界应用中的潜力

第十五章:使用 A3T-GCN 进行交通预测

我们在第十三章中介绍了 T-GNNs,但没有详细说明它们的主要应用:交通预测。*年来,智能城市的概念越来越受欢迎。这个概念指的是通过数据来管理和改善城市的运营和服务。在这种背景下,一个主要的吸引力来源是创建智能交通系统。准确的交通预测可以帮助交通管理者优化交通信号、规划基础设施并减少拥堵。然而,由于复杂的时空依赖性,交通预测是一个具有挑战性的问题。

在本章中,我们将应用 T-GNNs(时序图神经网络)到交通预测的一个特定案例。首先,我们将探索并处理一个新的数据集,从原始 CSV 文件中创建一个时序图。接着,我们将应用一种新的 T-GNN 模型来预测未来的交通速度。最后,我们将可视化并将结果与基准解决方案进行比较,以验证我们的架构的有效性。

在本章结束时,您将学会如何从表格数据中创建时序图数据集。特别地,我们将展示如何创建一个加权邻接矩阵,以提供边权重。最后,您将学习如何将 T-GNN 应用于交通预测任务并评估结果。

本章将涵盖以下主要内容:

  • 探索 PeMS-M 数据集

  • 处理数据集

  • 实现时序 GNN

技术要求

本章中的所有代码示例可以在 GitHub 上的github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter15找到。

在本书的前言中可以找到运行代码所需的本地安装步骤。本章需要大量 GPU 资源,您可以通过减少训练集的大小来降低需求。

探索 PeMS-M 数据集

在本节中,我们将探索数据集,以寻找有助于解决任务的模式和洞察。

我们将为此应用使用的数据集是PeMSD7数据集的中型变体[1]。原始数据集通过收集 2012 年 5 月和 6 月期间的工作日交通速度数据,这些数据来自加州交通部门(Caltrans)在 39,000 个传感器站点上的测量,采用了绩效测量系统PeMS)。在中型变体中,我们将只考虑加州第七区的 228 个站点。这些站点提供的 30 秒速度测量被聚合成 5 分钟的时间间隔。例如,以下图展示了加州交通部门 PeMS(pems.dot.ca.gov)的交通速度数据:

图 15.1 – 来自 Caltrans PeMS 的交通数据,高速(>60 mph)用绿色表示,低速(<35 mph)用红色表示

图 15.1 – 来自 Caltrans PeMS 的交通数据,速度超过 60 英里每小时(>60 mph)用绿色表示,速度低于 35 英里每小时(<35 mph)用红色表示

我们可以直接从 GitHub 加载数据集并解压:

from io import BytesIO
from urllib.request import urlopen
from zipfile import ZipFile
url = 'https://github.com/VeritasYin/STGCN_IJCAI-18/raw/master/data_loader/PeMSD7_Full.zip'
with urlopen(url) as zurl:
    with ZipFile(BytesIO(zurl.read())) as zfile:
        zfile.extractall('.')

结果文件夹包含两个文件:V_228.csvW_228.csvV_228.csv文件包含了来自 228 个传感器站点收集的交通速度数据,而W_228.csv文件则存储了这些站点之间的距离。

让我们使用pandas加载数据。我们将使用range()重新命名列,以便更方便地访问:

import pandas as pd
speeds = pd.read_csv('PeMSD7_V_228.csv', names=range(0,228))
distances = pd.read_csv('PeMSD7_W_228.csv.csv', names=range(0,228))

我们希望对这个数据集做的第一件事是可视化交通速度的变化。这是时间序列预测中的经典方法,因为像季节性这样的特征可以极大地帮助分析。另一方面,非*稳时间序列可能在使用之前需要进一步处理。

让我们使用matplotlib绘制交通速度随时间变化的图表:

  1. 我们导入NumPymatplotlib

    import numpy as np
    import matplotlib.pyplot as plt
    
  2. 我们使用plt.plot()为数据框中的每一行创建折线图:

    plt.figure(figsize=(10,5))
    plt.plot(speeds)
    plt.grid(linestyle=':')
    plt.xlabel('Time (5 min)')
    plt.ylabel('Traffic speed')
    
  3. 我们得到如下图表:

图 15.2 – 每个 228 个传感器站点的交通速度随时间变化

图 15.2 – 每个 228 个传感器站点的交通速度随时间变化

不幸的是,数据太嘈杂,无法通过这种方法提供任何有价值的见解。相反,我们可以绘制一些传感器站点的数据。但是,这可能并不能代表整个数据集。还有另一种选择:我们可以绘制*均交通速度和标准差。这样,我们可以可视化数据集的摘要。

在实际应用中,我们会同时使用这两种方法,但现在让我们尝试第二种方法:

  1. 我们计算每列(时间步长)的*均交通速度及相应的标准差:

    mean = speeds.mean(axis=1)
    std = speeds.std(axis=1)
    
  2. 我们使用黑色实线绘制*均值:

    plt.plot(mean, 'k-')
    
  3. 我们使用plt.fill_between()在*均值周围绘制标准差,并填充为浅红色:

    plt.fill_between(mean.index, mean-std, mean+std, color='r', alpha=0.1)
    plt.grid(linestyle=':')
    plt.xlabel('Time (5 min)')
    plt.ylabel('Traffic speed')
    
  4. 代码生成了如下图表:

图 15.3 – 随时间变化的*均交通速度及其标准差

图 15.3 – 随时间变化的*均交通速度及其标准差

这个图表更易于理解。我们可以看到时间序列数据中存在明显的季节性(模式),除了大约第 5800 个数据点周围。交通速度有很大的波动,出现了重要的峰值。这是可以理解的,因为这些传感器站点分布在加利福尼亚州第七区:某些传感器可能会发生交通堵塞,而其他传感器则不会。

我们可以通过绘制每个传感器速度值之间的相关性来验证这一点。此外,我们还可以将其与每个站点之间的距离进行比较。相邻的站点应比远距离站点显示出更多的相似值。

让我们在同一图表中比较这两个图:

  1. 我们创建一个包含两个横向子图的图形,并在它们之间添加一些间距:

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 8))
    fig.tight_layout(pad=3.0)
    
  2. 首先,我们使用matshow()函数绘制距离矩阵:

    ax1.matshow(distances)
    ax1.set_xlabel("Sensor station")
    ax1.set_ylabel("Sensor station")
    ax1.title.set_text("Distance matrix")
    
  3. 接着,我们计算每个传感器站的 Pearson 相关系数。我们必须转置速度矩阵,否则我们将得到每个时间步的相关系数。最后,我们将其反转,这样两个图表就更容易比较:

    ax2.matshow(-np.corrcoef(speeds.T))
    ax2.set_xlabel("Sensor station")
    ax2.set_ylabel("Sensor station")
    ax2.title.set_text("Correlation matrix")
    
  4. 我们得到如下图:

图 15.4 – 距离和相关矩阵,颜色较深表示短距离和高相关性,而颜色较浅表示长距离和低相关性

图 15.4 – 距离和相关矩阵,颜色较深表示短距离和高相关性,而颜色较浅表示长距离和低相关性

有趣的是,站点之间的长距离并不意味着它们没有高度相关(反之亦然)。如果我们只考虑这个数据集的一个子集,这一点尤其重要:接*的站点可能有非常不同的输出,这使得交通预测更加困难。在本章中,我们将考虑数据集中的每个传感器站。

数据集处理

现在我们对这个数据集有了更多的了解,是时候处理它,然后将其输入到 T-GNN 中。

第一步是将表格数据集转化为时序图。因此,首先,我们需要从原始数据中创建一个图。换句话说,我们必须以有意义的方式连接不同的传感器站。幸运的是,我们可以访问距离矩阵,它应该是连接站点的一个好方法。

有几种方法可以从距离矩阵计算邻接矩阵。例如,当两个站点之间的距离小于*均距离时,我们可以为它们分配一个连接。相反,我们将执行在[2]中介绍的更高级的处理,计算加权邻接矩阵。我们不使用二进制值,而是通过以下公式计算 0(无连接)和 1(强连接)之间的权重:

在这里,表示从节点 到节点 的边的权重, 是这两个节点之间的距离, 是两个阈值,用来控制邻接矩阵的分布和稀疏度。[2]的官方实现可以在 GitHub 上找到(github.com/VeritasYin/STGCN_IJCAI-18)。我们将重用相同的阈值

让我们用 Python 实现并绘制结果的邻接矩阵:

  1. 我们创建一个函数来计算邻接矩阵,该函数需要三个参数:距离矩阵和两个阈值 。像官方实现一样,我们将距离除以 10,000 并计算

    def compute_adj(distances, sigma2=0.1, epsilon=0.5):
        d = distances.to_numpy() / 10000.
        d2 = d * d
    
  2. 在这里,我们希望在权重值大于或等于 时保留这些权重(否则它们应等于零)。当我们测试权重是否大于或等于 时,结果是TrueFalse语句。这就是为什么我们需要一个全是 1 的掩码(w_mask)来将其转换回 0 和 1 值的原因。我们第二次乘以它,以便只获得大于或等于 的真实权重值:

        n = distances.shape[0]
        w_mask = np.ones([n, n]) - np.identity(n)
        return np.exp(-d2 / sigma2) * (np.exp(-d2 / sigma2) >= epsilon) * w_mask
    
  3. 让我们计算邻接矩阵并打印出一行的结果:

    adj = compute_adj(distances)
    adj[0]
    array(0.       , 0.       , 0.        , 0.       , 0.  ,
           0.       , 0.       , 0.61266012, 0.       , ...
    

我们可以看到0.61266012这个值,代表从节点 1 到节点 2 的边的权重。

  1. 更高效的可视化这个矩阵的方法是再次使用matplotlibmatshow

    plt.figure(figsize=(8, 8))
    cax = plt.matshow(adj, False)
    plt.colorbar(cax)
    plt.xlabel("Sensor station")
    plt.ylabel("Sensor station")
    

我们得到以下图形:

![图 15.5 – PeMS-M 数据集的加权邻接矩阵图 15.5 – PeMS-M 数据集的加权邻接矩阵这是一种很好地总结第一步处理的方法。我们可以将其与之前绘制的距离矩阵进行比较,以查找相似之处。1. 我们还可以直接使用networkx将其绘制为图形。在这种情况下,连接是二进制的,因此我们可以简单地将每个权重大于 0 的连接考虑进去。我们可以使用边标签显示这些值,但图形将变得极其难以阅读: py import networkx as nx def plot_graph(adj):     plt.figure(figsize=(10,5))     rows, cols = np.where(adj > 0)     edges = zip(rows.tolist(), cols.tolist())     G = nx.Graph()     G.add_edges_from(edges)     nx.draw(G, with_labels=True)     plt.show() 1. 即使没有标签,生成的图形也不容易阅读: py plot_graph(adj) 它给出了以下输出:图 15.6 – PeMS-M 数据集作为图形(每个节点代表一个传感器站)

图 15.6 – PeMS-M 数据集作为图形(每个节点代表一个传感器站)

事实上,许多节点相互连接,因为它们彼此非常接*。然而,尽管如此,我们仍然可以区分出几条可能对应于实际道路的分支。

现在我们有了一个图形,我们可以专注于这个问题的时间序列方面。第一步是对速度值进行归一化处理,以便它们可以输入到神经网络中。在交通预测文献中,许多作者选择了 z-score 归一化(或标准化),我们将在这里实现这一方法:

  1. 我们创建一个函数来计算 z-score:

    def zscore(x, mean, std):
        return (x - mean) / std
    
  2. 我们将其应用于我们的数据集,以创建其标准化版本:

    speeds_norm = zscore(speeds, speeds.mean(axis=0), speeds.std(axis=0))
    
  3. 我们可以检查结果:

    speeds_norm.head(1)
    

我们得到了以下输出:

0 1 2 3 4 5 6
0 0.950754 0.548255 0.502211 0.831672 0.793696 1.193806

图 15.7 – 标准化速度值的示例

这些值已经正确标准化。现在,我们可以使用它们为每个节点创建时间序列。我们希望在每个时间步输入数据样本,,以预测时的速度值。较多的输入数据样本也会增加数据集的内存占用。的值,也称为预测范围,取决于我们想要执行的任务:短期或长期交通预测。

在这个示例中,我们取一个较高的值 48 来预测 4 小时后的交通速度:

  1. 我们初始化变量:lags(输入数据样本数量)、horizon、输入矩阵和真实值矩阵:

    lags = 24
    horizon = 48
    xs = []
    ys = []
    
  2. 对于每个时间步,我们将存储之前的 12 个值(lags)在xs中,当前时刻的值存储在ys中:

    for i in range(lags, speeds_norm.shape[0]-horizon):
        xs.append(speeds_norm.to_numpy()[i-lags:i].T)
        ys.append(speeds_norm.to_numpy()[i+horizon-1])
    
  3. 最后,我们可以使用 PyTorch Geometric Temporal 创建时序图。我们需要提供 COO 格式的边索引以及来自加权邻接矩阵的边权重:

    from torch_geometric_temporal.signal import StaticGraphTemporalSignal
    edge_index = (np.array(adj) > 0).nonzero()
    edge_weight = adj[adj > 0]
    dataset = StaticGraphTemporalSignal(edge_index, adj[adj > 0], xs, ys)
    
  4. 让我们打印第一个图的信息,看看一切是否正常:

    dataset[0]
    Data(x=[228, 12], edge_index=[2, 1664], edge_attr=[1664], y=[228])
    
  5. 让我们不要忘记划分训练集和测试集,来完成我们的数据集准备:

    from torch_geometric_temporal.signal import temporal_signal_split
    train_dataset, test_dataset = temporal_signal_split(dataset, train_ratio=0.8)
    

最终的时序图包含 228 个节点,每个节点有 12 个值和 1,664 条连接。我们现在准备应用 T-GNN 来预测交通状况。

实现 A3T-GCN 架构

在这一节中,我们将训练一个注意力时序图卷积网络A3T-GCN),该网络是为交通预测设计的。这种架构使我们能够考虑复杂的空间和时间依赖性:

  • 空间依赖性是指一个位置的交通状况可能受到周围位置交通状况的影响。例如,交通堵塞往往会蔓延到邻*的道路。

  • 时间依赖性是指一个位置在某一时刻的交通状况可能会受到该位置在之前时刻交通状况的影响。例如,如果某条道路在早高峰时段发生拥堵,那么它很可能会持续到晚高峰。

A3T-GCN 是对时序 GCNTGCN)架构的改进。TGCN 是一个结合了 GCN 和 GRU 的架构,从每个输入时间序列中生成隐藏向量。这两层的组合捕捉了输入中的空间和时间信息。然后,使用注意力模型来计算权重并输出上下文向量。最终的预测基于得到的上下文向量。增加这个注意力模型的动机在于需要理解全局趋势。

图 15.8 – A3T-GCN 框架

图 15.8 – A3T-GCN 框架

我们现在将使用 PyTorch Geometric Temporal 库来实现它:

  1. 首先,我们导入所需的库:

    import torch
    from torch_geometric_temporal.nn.recurrent import A3TGCN
    
  2. 我们创建一个带有 A3TGCN 层和 32 个隐藏维度的线性层的 T-GNN。edge_attr 参数将存储我们的边权重:

    class TemporalGNN(torch.nn.Module):
        def __init__(self, dim_in, periods):
            super().__init__()
            self.tgnn = A3TGCN(in_channels=dim_in, out_channels=32, periods=periods)
            self.linear = torch.nn.Linear(32, periods)
        def forward(self, x, edge_index, edge_attr):
            h = self.tgnn(x, edge_index, edge_attr).relu()
            h = self.linear(h)
            return h
    
  3. 我们实例化了 T-GNN 和Adam优化器,学习率设置为0.005。由于实现细节,我们将使用 CPU 而不是 GPU 来训练该模型,因为在这种情况下 CPU 更快:

    model = TemporalGNN(lags, 1).to('cpu')
    optimizer = torch.optim.Adam(model.parameters(), lr=0.005)
    
  4. 我们使用loss函数训练模型 30 个 epoch。在每个 epoch 后,loss值会进行反向传播:

    model.train()
    for epoch in range(30):
        loss = 0
        step = 0
        for i, snapshot in enumerate(train_dataset):
            y_pred = model(snapshot.x.unsqueeze(2), snapshot.edge_index, snapshot.edge_attr)
            loss += torch.mean((y_pred-snapshot.y)**2)
            step += 1
        loss = loss / (step + 1)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        if epoch % 10 == 0:
            print(f"Epoch {epoch+1:>2} | Train MSE: {loss:.4f}")
    
  5. 我们获得了以下输出:

    Epoch  1 | Train MSE: 1.0209
    Epoch 10 | Train MSE: 0.9625
    Epoch 20 | Train MSE: 0.9143
    Epoch 30 | Train MSE: 0.8905
    

现在我们的模型已经训练完成,我们需要对其进行评估。除了经典的指标如均方根误差RMSE)和*均绝对误差MAE),将我们的模型与时间序列数据的基准解决方案进行比较也特别有帮助。在接下来的列表中,我们将介绍两种方法:

  • 使用随机游走RW)作为一个简单的预测器。在这种情况下,RW 指的是使用最后一个观察值作为预测值。换句话说,处的值与处的值相同。

  • 使用历史*均值HA)作为略微更进化的解决方案。在这种情况下,我们计算前个样本的*均交通速度,作为处的值。在这个示例中,我们将使用滞后的数量作为我们的值,但我们也可以采用总体历史*均值。

我们首先评估模型在测试集上的预测:

  1. 我们创建了一个函数来反转 z-score 并返回原始值:

    def inverse_zscore(x, mean, std):
        return x * std + mean
    
  2. 我们使用它从归一化值重新计算我们希望预测的速度。以下循环效率不高,但比更优化的代码更容易理解:

    y_test = []
    for snapshot in test_dataset:
        y_hat = snapshot.y.numpy()
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        y_test = np.append(y_test, y_hat)
    
  3. 我们对 GNN 做出的预测应用相同的策略:

    gnn_pred = []
    model.eval()
    for snapshot in test_dataset:
        y_hat = model(snapshot.x.unsqueeze(2), snapshot.edge_index, snapshot.edge_weight).squeeze().detach().numpy()
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        gnn_pred = np.append(gnn_pred, y_hat)
    
  4. 我们对 RW 和 HA 技术做同样的处理:

    rw_pred = []
    for snapshot in test_dataset:
        y_hat = snapshot.x[:,-1].squeeze().detach().numpy()
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        rw_pred = np.append(rw_pred, y_hat)
    ha_pred = []
    for i in range(lags, speeds_norm.shape[0]-horizon):
        y_hat = speeds_norm.to_numpy()[i-lags:i].T.mean(axis=1)
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        ha_pred.append(y_hat)
    ha_pred = np.array(ha_pred).flatten()[-len(y_test):]
    
  5. 我们创建了计算 MAE、RMSE 和*均绝对百分比误差MAPE)的函数:

    def MAE(real, pred):
        return np.mean(np.abs(pred - real))
    def RMSE(real, pred):
        return np.sqrt(np.mean((pred - real) ** 2))
    def MAPE(real, pred):
        return np.mean(np.abs(pred - real) / (real + 1e-5))
    
  6. 我们在以下模块中评估 GNN 的预测,并对每个技术重复此过程:

    print(f'GNN MAE  = {MAE(gnn_pred, y_test):.4f}')
    print(f'GNN RMSE = {RMSE(gnn_pred, y_test):.4f}')
    print(f'GNN MAPE = {MAPE(gnn_pred, y_test):.4f}')
    

最终,我们得到了以下表格:

RMSE MAE MAPE
A3T-GCN 11.9396 8.3293 14.95%
随机游走 17.6501 11.0469 29.99%
历史*均值 17.9009 11.7308 28.93%

图 15.9 – 预测输出表格

我们看到,在每个指标上,基准技术都被 A3T-GCN 模型超越。这是一个重要的结果,因为基准往往是很难超越的。将这些指标与 LSTM 或 GRU 网络提供的预测进行比较,将有助于衡量拓扑信息的重要性。

最后,我们可以绘制*均预测值,得到类似于图 15.3的可视化:

  1. 我们使用列表推导法获得*均预测值,这种方法比之前的方法稍快(但更难以阅读):

    y_preds = [inverse_zscore(model(snapshot.x.unsqueeze(2), snapshot.edge_index, snapshot.edge_weight).squeeze().detach().numpy(), speeds.mean(axis=0), speeds.std(axis=0)).mean() for snapshot in test_dataset]
    
  2. 我们计算了原始数据集的均值和标准差:

    mean = speeds.mean(axis=1)
    std = speeds.std(axis=1)
    
  3. 我们绘制了带有标准差的*均交通速度,并将其与预测值进行比较(小时):

    plt.figure(figsize=(10,5), dpi=300)
    plt.plot(mean, 'k-', label='Mean')
    plt.plot(range(len(speeds)-len(y_preds), len(speeds)), y_preds, 'r-', label='Prediction')
    plt.grid(linestyle=':')
    plt.fill_between(mean.index, mean-std, mean+std, color='r', alpha=0.1)
    plt.axvline(x=len(speeds)-len(y_preds), color='b', linestyle='--')
    plt.xlabel('Time (5 min)')
    plt.ylabel('Traffic speed to predict')
    plt.legend(loc='upper right')
    

我们得到了以下图形:

图 15.10 – A3T-GCN 模型在测试集上预测的*均交通速度

图 15.10 – A3T-GCN 模型在测试集上预测的*均交通速度

T-GNN 正确预测了交通高峰并跟踪了总体趋势。然而,预测的速度更接*整体*均值,因为模型由于 MSE 损失而更不容易犯重大错误。尽管如此,GNN 仍然相当准确,并可以进一步调整以输出更极端的值。

总结

本章集中讨论了使用 T-GNNs 进行交通预测的任务。首先,我们探索了 PeMS-M 数据集,并将其从表格数据转换为具有时间信号的静态图数据集。在实践中,我们基于输入的距离矩阵创建了加权邻接矩阵,并将交通速度转换为时间序列。最后,我们实现了一个 A3T-GCN 模型,这是一个为交通预测设计的 T-GNN。我们将结果与两个基准进行了比较,并验证了我们模型的预测。

第十六章《使用 LightGCN 构建推荐系统》中,我们将看到 GNNs 最流行的应用。我们将在一个大规模数据集上实现一个轻量级 GNN,并使用推荐系统中的技术对其进行评估。

进一步阅读

  • [1] B. Yu, H. Yin, 和 Z. Zhu. 时空图卷积网络:面向交通预测的深度学习框架. 2018 年 7 月. doi: 10.24963/ijcai.2018/505. 可在arxiv.org/abs/1709.04875查看。

  • [2] Y. Li, R. Yu, C. Shahabi, 和 Y. Liu. 扩散卷积递归神经网络:数据驱动的交通预测. arXiv, 2017 年. doi: 10.48550/ARXIV.1707.01926. 可在arxiv.org/abs/1707.01926查看。

第十六章:使用异构 GNN 检测异常

在机器学习中,异常检测是一个流行的任务,旨在识别数据中偏离预期行为的模式或观察结果。这是一个在许多现实应用中出现的基本问题,例如检测金融交易中的欺诈、识别制造过程中的缺陷产品以及检测计算机网络中的网络攻击。

GNN 可以通过学习网络的正常行为来训练,然后识别偏离该行为的节点或模式。事实上,它们理解复杂关系的能力使得它们特别适合于检测微弱信号。此外,GNN 可以扩展到大型数据集,使其成为处理海量数据的高效工具。

在本章中,我们将构建一个用于计算机网络异常检测的 GNN 应用程序。首先,我们将介绍CIDDS-001数据集,其中包含计算机网络中的攻击和正常流量。接下来,我们将处理数据集,准备将其输入到 GNN 中。然后,我们将实现一个异构 GNN,以处理不同类型的节点和边。最后,我们将使用处理过的数据集训练网络,并评估结果,查看其在检测网络流量中的异常方面的表现。

在本章结束时,你将知道如何实现一个用于入侵检测的 GNN。此外,你将了解如何构建相关特征以检测攻击,并处理它们以输入到 GNN 中。最后,你将学习如何实现并评估一个异构 GNN 以检测稀有攻击。

本章将涵盖以下主要内容:

  • 探索 CIDDS-001 数据集

  • 预处理 CIDDS-001 数据集

  • 实现一个异构 GNN

技术要求

本章中的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter16

运行代码所需的安装步骤可以在本书的前言中找到。本章需要大量的 GPU,你可以通过减少代码中训练集的大小来降低需求。

探索 CIDDS-001 数据集

本节将探索数据集,并深入了解特征的重要性和缩放。

CIDDS-001数据集[1]旨在训练和评估基于异常的网络入侵检测系统。它提供了现实的流量数据,包括最新的攻击,用于评估这些系统。它通过使用 OpenStack 在虚拟环境中收集并标记了 8,451,520 个流量流量数据。具体来说,每一行代表一个 NetFlow 连接,描述互联网协议IP)流量统计信息,例如交换的字节数。

以下图表提供了 CIDDS-001 中模拟网络环境的概览:

图 16.1 – CIDDS-001 模拟的虚拟网络概览

图 16.1 – CIDDS-001 模拟的虚拟网络概览

我们可以看到四个不同的子网(开发者、办公室、管理和服务器),以及它们各自的 IP 地址范围。所有这些子网都连接到一台通过防火墙与互联网相连的服务器。还存在一个外部服务器,提供两个服务:文件同步服务和网页服务器。最后,攻击者被表示在本地网络之外。

CIDDS-001 中的连接来自本地和外部服务器。该数据集的目标是将这些连接正确地分类为五个类别:正常(无攻击)、暴力破解、拒绝服务、ping 扫描和端口扫描。

让我们下载 CIDDS-001 数据集并探索其输入特征:

  1. 我们下载 CIDDS-001

    from io import BytesIO
    from urllib.request import urlopen
    from zipfile import ZipFile
    url = 'https://www.hs-coburg.de/fileadmin/hscoburg/WISENT-CIDDS-001.zip'
    with urlopen(url) as zurl:
        with ZipFile(BytesIO(zurl.read())) as zfile:
            zfile.extractall('.')
    
  2. 我们导入所需的库:

    import numpy as np
    import pandas as pd
    import matplotlib.pyplot as plt
    import itertools
    from sklearn.model_selection import train_test_split
    from sklearn.preprocessing import PowerTransformer
    from sklearn.metrics import f1_score, classification_report, confusion_matrix
    from torch_geometric.loader import DataLoader
    from torch_geometric.data import HeteroData
    from torch.nn import functional as F
    from torch.optim import Adam
    from torch import nn
    import torch
    
  3. 我们使用 pandas 加载数据集:

    df = pd.read_csv("CIDDS-001/traffic/OpenStack/CIDDS-001-internal-week1.csv")
    
  4. 让我们看看前五个连接对应的数据:

    df.head(5)
    Date first seen Duration Proto Src IP Addr Src Pt Dst IP Addr Dst Pt Packets Bytes Flows Flags Tos class attackType attackID attackDescription
    2017-03-15 00:01:16.632 0.000 TCP 192.168.100.5 445 192.168.220.16 58844.0 1 108 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.552 0.000 TCP 192.168.100.5 445 192.168.220.15 48888.0 1 108 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.551 0.004 TCP 192.168.220.15 48888 192.168.100.5 445.0 2 174 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.631 0.004 TCP 192.168.220.16 58844 192.168.100.5 445.0 2 174 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.552 0.000 TCP 192.168.100.5 445 192.168.220.15 48888.0 1 108 1 .AP... 0 normal --- --- ---
    

有一些有趣的特征我们可以用于我们的模型:

  • “首次看到日期”是一个时间戳,我们可以对其进行处理,以提取关于星期几和一天中时间的信息。通常,网络流量是有季节性的,夜间或非常规日子的连接通常是可疑的。

  • IP 地址(如 192.168.100.5)因其不是数值型且遵循复杂规则而著名,处理起来非常困难。我们可以将它们分为几个类别,因为我们知道本地网络的设置。另一个更常见且具有更好泛化性的解决方案是将其转换为二进制表示(例如,“192”变为“11000000”)。

  • 持续时间、数据包数量和字节数通常呈现重尾分布。因此,如果是这种情况,它们将需要特别处理。

让我们检查这一点,并仔细看看该数据集中的攻击分布:

  1. 我们首先删除本项目中不考虑的特征:端口、流数量、服务类型、类别、攻击 ID 和攻击描述:

    df = df.drop(columns=['Src Pt', 'Dst Pt', 'Flows', 'Tos', 'class', 'attackID', 'attackDescription'])
    
  2. 我们重命名正常类别,并将“首次看到日期”特征转换为时间戳数据类型:

    df['attackType'] = df['attackType'].replace('---', 'benign')
    df['Date first seen'] = pd.to_datetime(df['Date first seen'])
    
  3. 我们统计标签并制作一个饼图,显示三个最多类别(另外两个类别的比例低于 0.1%):

    count_labels = df['attackType'].value_counts() / len(df) * 100
    plt.pie(count_labels[:3], labels=df['attackType'].unique()[:3], autopct='%.0f%%')
    
  4. 我们得到以下图表:

图 16.2 – CIDDS-001 数据集中每个类别的比例

图 16.2 – CIDDS-001 数据集中每个类别的比例

如你所见,正常流量占据了数据集的绝大多数。相反,暴力破解攻击和 ping 扫描的样本数量相对较少。这个不*衡的学习设置可能会对模型处理稀有类别时的性能产生负面影响。

  1. 最后,我们可以显示持续时间分布、数据包数量和字节数。这让我们能够查看它们是否确实需要特定的重新缩放处理:

    fig, ((ax1, ax2, ax3)) = plt.subplots(1, 3, figsize=(20,5))
    df['Duration'].hist(ax=ax1)
    ax1.set_xlabel("Duration")
    df['Packets'].hist(ax=ax2)
    ax2.set_xlabel("Number of packets")
    pd.to_numeric(df['Bytes'], errors='coerce').hist(ax=ax3)
    ax3.set_xlabel("Number of bytes")
    plt.show()
    

它输出如下图所示:

图 16.3 – 持续时间、数据包数量和字节数的分布

图 16.3 – 持续时间、数据包数量和字节数的分布

我们可以看到,大多数值接*零,但也有一些稀有值沿 x 轴形成了长尾。我们将使用幂变换使这些特征更接*高斯分布,这应该有助于模型训练。

现在我们已经探索了 CIDDS-001 数据集的主要特征,可以进入预处理阶段。

对 CIDDS-001 数据集进行预处理

在上一节中,我们已经确定了一些需要解决的数据集问题,以提高模型的准确性。

CIDDS-001 数据集包括多种类型的数据:我们有持续时间等数值数据、协议(TCP、UDP、ICMP 和 IGMP)等类别特征,以及时间戳或 IP 地址等其他数据。在接下来的练习中,我们将根据前一节的信息和专家知识,选择如何表示这些数据类型:

  1. 首先,我们可以通过从时间戳中提取星期几的信息来进行独热编码。我们将重命名结果列,使其更易读:

    df['weekday'] = df['Date first seen'].dt.weekday
    df = pd.get_dummies(df, columns=['weekday']).rename(columns = {'weekday_0': 'Monday','weekday_1': 'Tuesday','weekday_2': 'Wednesday', 'weekday_3': 'Thursday','weekday_4': 'Friday','weekday_5': 'Saturday','weekday_6': 'Sunday',})
    
  2. 另一个通过时间戳可以获取的重要信息是一天中的时间。我们还将其归一化到 01 之间:

    df['daytime'] = (df['Date first seen'].dt.second +df['Date first seen'].dt.minute*60 + df['Date first seen'].dt.hour*60*60)/(24*60*60)
    
  3. 我们还没有讨论 TCP 标志。每个标志表示 TCP 连接中的特定状态。例如,FFIN 表示 TCP 对端已经完成数据发送。我们可以提取每个标志,并按如下方式对其进行独热编码:

    df = df.reset_index(drop=True)
    ohe_flags = one_hot_flags(df['Flags'].to_numpy())
    ohe_flags = df['Flags'].apply(one_hot_flags).to_list()
    df[['ACK', 'PSH', 'RST', 'SYN', 'FIN']] = pd.DataFrame(ohe_flags, columns=['ACK', 'PSH', 'RST', 'SYN', 'FIN'])
    
  4. 现在,我们处理 IP 地址。在这个示例中,我们将使用二进制编码。我们不会使用 32 位来编码完整的 IPv4 地址,而是仅保留最后 16 位,因为这部分在这里最为重要。实际上,前 16 位要么表示 192.168,如果主机属于内部网络,要么表示其他值,如果它是外部网络:

    temp = pd.DataFrame()
    temp['SrcIP'] = df['Src IP Addr'].astype(str)
    temp['SrcIP'][~temp['SrcIP'].str.contains('\d{1,3}\.', regex=True)] = '0.0.0.0'
    temp = temp['SrcIP'].str.split('.', expand=True).rename(columns = {2: 'ipsrc3', 3: 'ipsrc4'}).astype(int)[['ipsrc3', 'ipsrc4']]
    temp['ipsrc'] = temp['ipsrc3'].apply(lambda x: format(x, "b").zfill(8)) + temp['ipsrc4'].apply(lambda x: format(x, "b").zfill(8))
    df = df.join(temp['ipsrc'].str.split('', expand=True)
                .drop(columns=[0, 17])
                .rename(columns=dict(enumerate([f'ipsrc_{i}' for i in range(17)])))
                .astype('int32'))
    
  5. 我们对目标 IP 地址重复这个过程:

    temp = pd.DataFrame()
    temp['DstIP'] = df['Dst IP Addr'].astype(str)
    temp['DstIP'][~temp['DstIP'].str.contains('\d{1,3}\.', regex=True)] = '0.0.0.0'
    temp = temp['DstIP'].str.split('.', expand=True).rename(columns = {2: 'ipdst3', 3: 'ipdst4'}).astype(int)[['ipdst3', 'ipdst4']]
    temp['ipdst'] = temp['ipdst3'].apply(lambda x: format(x, "b").zfill(8)) + temp['ipdst4'].apply(lambda x: format(x, "b").zfill(8))
    df = df.join(temp['ipdst'].str.split('', expand=True)
                .drop(columns=[0, 17])
                .rename(columns=dict(enumerate([f'ipdst_{i}' for i in range(17)])))
                .astype('int32'))
    
  6. Bytes 特征存在一个问题:百万单位用 m 表示,而不是数值。我们可以通过将这些非数值的数值部分乘以一百万来修复这个问题:

    m_index = df[pd.to_numeric(df['Bytes'], errors='coerce').isnull() == True].index
    df['Bytes'].loc[m_index] = df['Bytes'].loc[m_index].apply(lambda x: 10e6 * float(x.strip().split()[0]))
    df['Bytes'] = pd.to_numeric(df['Bytes'], errors='coerce', downcast='integer')
    
  7. 我们需要编码的最后一类特征是最简单的:如协议和攻击类型等类别特征。我们使用 pandas 中的 get_dummies() 函数:

    df = pd.get_dummies(df, prefix='', prefix_sep='', columns=['Proto', 'attackType'])
    
  8. 我们创建一个训练/验证/测试的分割,比例为 80/10/10:

    labels = ['benign', 'bruteForce', 'dos', 'pingScan', 'portScan']
    df_train, df_test = train_test_split(df, random_state=0, test_size=0.2, stratify=df[labels])
    df_val, df_test = train_test_split(df_test, random_state=0, test_size=0.5, stratify=df_test[labels])
    
  9. 最后,我们需要处理三个特征的缩放问题:持续时间、数据包数量和字节数。我们使用 scikit-learn 中的 PowerTransformer() 来修改它们的分布:

    scaler = PowerTransformer()
    df_train[['Duration', 'Packets', 'Bytes']] = scaler.fit_transform(df_train[['Duration', 'Packets', 'Bytes']])
    df_val[['Duration', 'Packets', 'Bytes']] = scaler.transform(df_val[['Duration', 'Packets', 'Bytes']])
    df_test[['Duration', 'Packets', 'Bytes']] = scaler.transform(df_test[['Duration', 'Packets', 'Bytes']])
    
  10. 让我们绘制新的分布,看看它们如何比较:

    fig, ((ax1, ax2, ax3)) = plt.subplots(1, 3, figsize=(15,5))
    df_train['Duration'].hist(ax=ax1)
    ax1.set_xlabel("Duration")
    df_train['Packets'].hist(ax=ax2)
    ax2.set_xlabel("Number of packets")
    df_train['Bytes'].hist(ax=ax3)
    ax3.set_xlabel("Number of bytes")
    plt.show()
    

我们得到如下图所示:

图 16.4 – 持续时间、数据包数量和字节数的重新缩放分布

图 16.4 – 持续时间、数据包数量和字节数的重新缩放分布

这些新分布不是高斯分布,但值分布更加分散,这应该有助于模型。

请注意,我们处理的数据集是纯粹的表格数据。在将其输入到图神经网络(GNN)之前,我们仍然需要将其转换为图数据集。在我们的例子中,没有明显的方法将流量转换为节点。理想情况下,同一计算机之间的流量应该是相连的。可以通过使用具有两种类型节点的异构图来实现:

  • 主机,它们对应于计算机,并使用 IP 地址作为特征。如果我们有更多信息,可以添加其他与计算机相关的特征,例如日志或 CPU 利用率。

  • 流量,它们对应于两个主机之间的连接。它们考虑数据集中的所有其他特征。它们还有我们要预测的标签(良性或恶意流量)。

在这个例子中,流量是单向的,这也是我们定义两种类型的边的原因:主机到流量(源)和流量到主机(目标)。单一图形会占用过多内存,因此我们将其分割成子图,并将它们放入数据加载器中:

  1. 我们定义了批量大小以及我们希望考虑的主机和流节点的特征:

    BATCH_SIZE = 16
    features_host = [f'ipsrc_{i}' for i in range(1, 17)] + [f'ipdst_{i}' for i in range(1, 17)]
    features_flow = ['daytime', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Duration', 'Packets', 'Bytes', 'ACK', 'PSH', 'RST', 'SYN', 'FIN', 'ICMP ', 'IGMP ', 'TCP  ', 'UDP  ']
    
  2. 我们定义一个函数来创建数据加载器。它接受两个参数:我们创建的表格数据框和子图大小(在本例中为1024个节点):

    def create_dataloader(df, subgraph_size=1024):
    
  3. 我们初始化一个名为data的列表来存储我们的子图,并计算将要创建的子图数量:

        data = []
        n_subgraphs = len(df) // subgraph_size
    
  4. 对于每个子图,我们从数据框中检索相应的样本、源 IP 地址列表和目标 IP 地址列表:

        for i in range(1, n_batches+1):
            subgraph = df[(i-1)*subgraph_size:i*subgraph_size]
            src_ip = subgraph['Src IP Addr'].to_numpy()
            dst_ip = subgraph['Dst IP Addr'].to_numpy()
    
  5. 我们创建一个字典,将 IP 地址映射到节点索引:

    ip_map = {ip:index for index, ip in enumerate(np.unique(np.append(src_ip, dst_ip)))}
    
  6. 这个字典将帮助我们从主机到流量、以及反向生成边索引。我们使用一个名为get_connections()的函数,在这之后我们会创建它。

    host_to_flow, flow_to_host = get_connections(ip_map, src_ip, dst_ip)
    
  7. 我们使用迄今为止收集的所有数据,为每个子图创建一个异构图,并将其附加到列表中:

            batch = HeteroData()
            batch['host'].x = torch.Tensor(subgraph[features_host].to_numpy()).float()
            batch['flow'].x = torch.Tensor(subgraph[features_flow].to_numpy()).float()
            batch['flow'].y = torch.Tensor(subgraph[labels].to_numpy()).float()
            batch['host','flow'].edge_index = host_to_flow
            batch['flow','host'].edge_index = flow_to_host
            data.append(batch)
    
  8. 最后,我们返回具有适当批量大小的数据加载器:

    return DataLoader(data, batch_size=BATCH_SIZE)
    
  9. 还有一个函数我们需要实现——get_connections()——它根据源 IP 地址和目标 IP 地址的列表以及它们的对应映射,计算出两个边索引:

    def get_connections(ip_map, src_ip, dst_ip):
    
  10. 我们从 IP 地址(源和目标)中获取索引并将它们堆叠起来:

        src1 = [ip_map[ip] for ip in src_ip]
        src2 = [ip_map[ip] for ip in dst_ip]
        src = np.column_stack((src1, src2)).flatten()
    
  11. 这些连接是独特的,因此我们可以轻松地使用适当的数字范围对其进行索引:

        dst = list(range(len(src_ip)))
        dst = np.column_stack((dst, dst)).flatten()
    
  12. 最后,我们返回以下两个边索引:

    return torch.Tensor([src, dst]).int(), torch.Tensor([dst, src]).int()
    
  13. 现在我们拥有所需的一切,可以调用第一个函数来创建训练、验证和测试数据加载器:

    train_loader = create_dataloader(df_train)
    val_loader = create_dataloader(df_val)
    test_loader = create_dataloader(df_test)
    
  14. 现在,我们有三个数据加载器,分别对应于我们的训练集、验证集和测试集。下一步是实现 GNN 模型。

实现异构 GNN

在本节中,我们将使用GraphSAGE操作符实现异构 GNN。该架构将允许我们同时考虑两种节点类型(主机和流)来构建更好的嵌入。通过跨不同层复制和共享消息来实现这一点,如下图所示。

图 16.5 – 异构 GNN 的架构

图 16.5 – 异构 GNN 的架构

我们将为每种节点类型实现三层SAGEConv,并使用LeakyRELU激活函数。最后,一个线性层将输出一个五维向量,其中每个维度对应一个类别。此外,我们将使用交叉熵损失和Adam优化器以监督方式训练该模型:

  1. 我们从 PyTorch Geometric 中导入相关的神经网络层:

    import torch_geometric.transforms as T
    from torch_geometric.nn import Linear, HeteroConv, SAGEConv
    
  2. 我们定义异构 GNN 的三个参数:隐藏维度的数量、输出维度的数量和层数:

    class HeteroGNN(torch.nn.Module):
        def __init__(self, dim_h, dim_out, num_layers):
            super().__init__()
    
  3. 我们为每个层和边类型定义了一个异构版本的GraphSAGE操作符。在这里,我们可以为每种边类型应用不同的 GNN 层,如GCNConvGATConvHeteroConv()包装器管理层之间的消息传递,如图 16.5所示:

            self.convs = torch.nn.ModuleList()
            for _ in range(num_layers):
                conv = HeteroConv({
                    ('host', 'to', 'flow'): SAGEConv((-1,-1), dim_h, add_self_loops=False),
                    ('flow', 'to', 'host'): SAGEConv((-1,-1), dim_h, add_self_loops=False),
                }, aggr='sum')
                self.convs.append(conv)
    
  4. 我们定义一个线性层,将输出最终的分类结果:

            self.lin = Linear(dim_h, dim_out)
    
  5. 我们创建了forward()方法,该方法计算主机节点和流节点的嵌入(存储在x_dict字典中)。然后,流的嵌入用于预测一个类别:

        def forward(self, x_dict, edge_index_dict):
            for conv in self.convs:
                x_dict = conv(x_dict, edge_index_dict)
                x_dict = {key: F.leaky_relu(x) for key, x in x_dict.items()}
            return self.lin(x_dict['flow'])
    
  6. 我们实例化异构 GNN,设置 64 个隐藏维度、5 个输出(即 5 个类别)和 3 层。如果可用,我们将其放置在 GPU 上,并创建一个学习率为0.001Adam优化器:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = HeteroGNN(dim_h=64, dim_out=5, num_layers=3).to(device)
    optimizer = Adam(model.parameters(), lr=0.001)
    
  7. 我们定义test()函数并创建数组来存储预测结果和true标签。我们还希望统计子图的数量和总损失,因此我们创建了相应的变量:

    @torch.no_grad()
    def test(loader):
        model.eval()
        y_pred = []
        y_true = []
        n_subgraphs = 0
        total_loss = 0
    
  8. 我们获取每个批次的模型预测结果,并计算交叉熵损失:

       for batch in loader:
            batch.to(device)
            out = model(batch.x_dict, batch.edge_index_dict)
            loss = F.cross_entropy(out, batch['flow'].y.float())
    
  9. 我们将预测的类别追加到预测列表中,并对true标签执行相同操作:

            y_pred.append(out.argmax(dim=1))
            y_true.append(batch['flow'].y.argmax(dim=1))
    
  10. 我们如下统计子图数量和总损失:

            n_subgraphs += BATCH_SIZE
            total_loss += float(loss) * BATCH_SIZE
    
  11. 批处理循环结束后,我们使用预测和true标签列表计算宏*均F1分数。宏*均F1分数在这种类别不*衡的学习设置中是一个很好的指标,因为它*等对待所有类别,而不管样本数量:

        y_pred = torch.cat(y_pred).cpu()
        y_true = torch.cat(y_true).cpu()
        f1score = f1_score(y_true, y_pred, average='macro')
    
  12. 我们返回最终的损失、宏*均F1分数、预测列表和true标签列表:

        return total_loss/n_subgraphs, f1score, y_pred, y_true
    
  13. 我们创建训练循环,训练模型101个 epochs:

    model.train()
    for epoch in range(101):
        n_subgraphs = 0
        total_loss = 0
    
  14. 我们使用交叉熵损失在每个批次上训练异构 GNN:

        for batch in train_loader:
            optimizer.zero_grad()
            batch.to(device)
            out = model(batch.x_dict, batch.edge_index_dict)
            loss = F.cross_entropy(out, batch['flow'].y.float())
            loss.backward()
            optimizer.step()
            n_subgraphs += BATCH_SIZE
            total_loss += float(loss) * BATCH_SIZE
    
  15. 每经过 10 个 epochs,我们在验证集上评估模型并展示相关指标(训练损失、验证损失和验证的宏*均F1分数):

        if epoch % 10 == 0:
            val_loss, f1score, _, _ = test(val_loader)
            print(f'Epoch {epoch} | Loss: {total_loss/n_subgraphs:.4f} | Val loss: {val_loss:.4f} | Val F1 score: {f1score:.4f}')
    

我们在训练过程中获得如下输出:

Epoch 0 | Loss: 0.1006 | Val loss: 0.0072 | Val F1 score: 0.6044
Epoch 10 | Loss: 0.0020 | Val loss: 0.0021 | Val F1-score: 0.8899
Epoch 20 | Loss: 0.0015 | Val loss: 0.0015 | Val F1-score: 0.9211
...
Epoch 90 | Loss: 0.0004 | Val loss: 0.0008 | Val F1-score: 0.9753
Epoch 100 | Loss: 0.0004 | Val loss: 0.0009 | Val F1-score: 0.9785
  1. 最后,我们在测试集上评估模型。我们还打印scikit-learn的分类报告,其中包括宏*均F1分数:

    _, _, y_pred, y_true = test(test_loader)
    print(classification_report(y_true, y_pred, target_names=labels, digits=4))
                  precision    recall  f1-score   support
          benign     0.9999    0.9999    0.9999    700791
      bruteForce     0.9811    0.9630    0.9720       162
             dos     1.0000    1.0000    1.0000    125164
        pingScan     0.9413    0.9554    0.9483       336
        portScan     0.9947    0.9955    0.9951     18347
        accuracy                         0.9998    844800
       macro avg     0.9834    0.9827    0.9831    844800
    weighted avg     0.9998    0.9998    0.9998    844800
    

我们获得了一个宏观*均的F1分数为0.9831。这个优秀的结果表明我们的模型已经学会了可靠地预测每个类别。

我们采用的方法如果能够获取更多主机相关的特征,将更加相关,但它展示了如何根据需求扩展该方法。GNN 的另一个主要优势是其处理大量数据的能力。当处理数百万条流量时,这种方法显得尤为合理。为了完成这个项目,让我们绘制模型的错误图,以便看到如何改进它。

我们创建了一个数据框来存储预测值(y_pred)和真实标签(y_true)。我们使用这个新的数据框来绘制误分类样本的比例:

df_pred = pd.DataFrame([y_pred.numpy(), y_true.numpy()]).T
df_pred.columns = ['pred', 'true']
plt.pie(df_pred['true'][df_pred['pred'] != df_pred['true']].value_counts(), labels=labels, autopct='%.0f%%')

这给我们带来了以下图表:

图 16.6 – 每个误分类类别的比例

图 16.6 – 每个误分类类别的比例

如果我们将这个饼图与数据集中的原始比例进行比较,可以看到模型在多数类别上的表现更好。这并不令人惊讶,因为少数类别更难学习(样本较少),且未检测到这些类别的惩罚较轻(例如,700,000 个良性流量与 336 个 ping 扫描)。端口和 ping 扫描的检测可以通过过采样和在训练过程中引入类别权重等技术来改进。

我们可以通过查看混淆矩阵获取更多信息(代码可以在 GitHub 上找到)。

图 16.7 – 多类流量分类的混淆矩阵

图 16.7 – 多类流量分类的混淆矩阵

该混淆矩阵显示了有趣的结果,比如对良性类别的偏向,或者在 ping 和端口扫描之间的错误。这些错误可以归因于这些攻击之间的相似性。通过工程化地增加特征,可以帮助模型区分这些类别。

总结

在本章中,我们探讨了使用 GNN(图神经网络)检测新数据集中的异常,具体是CIDDS-001数据集。首先,我们对数据集进行了预处理,并将其转换为图形表示,这使我们能够捕捉网络中不同组件之间的复杂关系。然后,我们实现了一个异质图神经网络(GNN),采用了GraphSAGE操作符。该模型捕捉了图的异质性,并使我们能够将流量分类为良性或恶性。

GNN 在网络安全中的应用已经展示了良好的效果,并为研究开辟了新的方向。随着技术的不断进步和网络数据量的增加,GNN 将成为检测和防止安全漏洞的一个越来越重要的工具。

第十七章,《使用 LightGCN 推荐书籍》中,我们将探讨 GNN 在推荐系统中的最常见应用。我们将在大规模数据集上实现一个轻量级的 GNN,并为特定用户提供书籍推荐。

进一步阅读

  • [1] M. Ring, S. Wunderlich, D. Grüdl, D. Landes, 和 A. Hotho,基于流量的入侵检测基准数据集,载于 第 16 届欧洲网络战争与安全会议论文集(ECCWS),ACPI,2017,页 361–369。

第十七章:使用 LightGCN 构建推荐系统

推荐系统已成为现代在线*台的不可或缺的一部分,旨在根据用户的兴趣和过去的互动提供个性化推荐。这些系统可以在多种应用中找到,包括在电子商务网站上推荐购买的产品,在流媒体服务中推荐观看的内容,以及在社交媒体*台上推荐建立联系的对象。

推荐系统是 GNN 的主要应用之一。事实上,它们可以有效地将用户、项目及其互动之间的复杂关系整合到一个统一的模型中。此外,图结构还允许在推荐过程中融入附加信息,例如用户和项目的元数据。

本章将介绍一种新的 GNN 架构,名为Book-Crossing数据集,该数据集包含用户、书籍以及超过百万条评分。利用该数据集,我们将构建一个基于协同过滤的书籍推荐系统,并应用它为特定用户提供推荐。通过这一过程,我们将展示如何使用 LightGCN 架构构建一个实用的推荐系统。

本章结束时,你将能够使用 LightGCN 创建自己的推荐系统。你将学习如何处理包含用户、项目和评分的数据集,以实现协同过滤方法。最后,你将学习如何实现和评估该架构,并为个别用户提供推荐。

本章将覆盖以下主要内容:

  • 探索 Book-Crossing 数据集

  • 预处理 Book-Crossing 数据集

  • 实现 LightGCN 架构

技术要求

本章的所有代码示例都可以在 GitHub 上找到,地址是github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter17。运行代码所需的安装步骤可以在本书的前言中找到。

本章需要大量的 GPU 资源。你可以通过减少代码中训练集的大小来降低需求。

探索 Book-Crossing 数据集

本节中,我们将对一个新的数据集进行探索性数据分析,并可视化其主要特征。

Book-Crossing数据集[1]是由 278,858 名用户提供的书籍评分集合,来自BookCrossing 社区www.bookcrossing.com)。这些评分既有显式的(1 到 10 之间的评分),也有隐式的(用户与书籍的互动),总计 1,149,780 条,涉及 271,379 本书。该数据集由 Cai-Nicolas Ziegler 于 2004 年 8 月和 9 月的四周爬取收集。我们将在本章中使用Book-Crossing数据集来构建一个书籍推荐系统。

让我们使用以下命令下载数据集并解压:

from io import BytesIO
from urllib.request import urlopen
from zipfile import ZipFile
url = 'http://www2.informatik.uni-freiburg.de/~cziegler/BX/BX-CSV-Dump.zip'
with urlopen(url) as zurl:
    with ZipFile(BytesIO(zurl.read())) as zfile:
        zfile.extractall('.')

这将解压出三个文件:

  • BX-Users.csv 文件包含单个 BookCrossing 用户的数据。用户 ID 已被匿名处理,表示为整数。一些用户的 demographic 信息,如所在地和年龄,也被包括在内。如果这些信息不可用,相应的字段将包含 NULL 值。

  • BX-Books.csv 文件包含数据集中的书籍信息,通过 ISBN 进行标识。无效的 ISBN 已从数据集中删除。除了书籍标题、作者、出版年份和出版社等内容相关信息外,此文件还包括指向三种不同大小封面图像的书籍链接 URL。

  • BX-Book-Ratings.csv 文件包含有关数据集中书籍评分的信息。评分可以是显式的,采用 1 到 10 的等级,较高的值表示更高的评价;或者是隐式的,用 0 表示评分。

下图是使用 Gephi 制作的图形表示,采用了数据集的一个子样本。

图 17.1 – Book-Crossing 数据集的图形表示,书籍表示为蓝色节点,用户表示为红色节点

图 17.1 – Book-Crossing 数据集的图形表示,书籍表示为蓝色节点,用户表示为红色节点

节点的大小与图中的连接数量(度数)成比例。我们可以看到像 达·芬奇密码 这样的热门书籍,它们由于连接数高而充当了枢纽。

现在,让我们探索数据集,获取更多的洞察:

  1. 我们导入 pandas 并加载每个文件,使用 ; 分隔符和 latin-1 编码,以解决兼容性问题。BX-Books.csv 文件还需要 error_bad_lines 参数:

    import pandas as pd
    ratings = pd.read_csv('BX-Book-Ratings.csv', sep=';', encoding='latin-1')
    users = pd.read_csv('BX-Users.csv', sep=';', encoding='latin-1')
    books = pd.read_csv('BX-Books.csv', sep=';', encoding='latin-1', error_bad_lines=False)
    
  2. 让我们打印这些数据框,以查看列和行数:

    ratings
               User-ID    ISBN            Book-Rating
    0          276725     034545104X      0
    1          276726     0155061224      5
    ...        ...        ...             ...
    1149777    276709     0515107662      10
    1149778    276721     0590442449      10
    1149779    276723     05162443314     8
    1149780 rows × 3 columns
    
  3. 让我们重复这个过程,使用 users 数据框:

    users
    User-ID         Location                         Age
    0       1       nyc, new york, usa               NaN
    1       2       stockton, california, usa        18.0
    2       3       moscow, yukon territory, russia  NaN
    ...     ...     ...                              ...
    278855  278856  brampton, ontario, canada        NaN
    278856  278857  knoxville, tennessee, usa  NaN
    278857  278858  dublin, n/a, ireland  NaN
    278858 rows × 3 columns
    
  4. 最后,books 数据框包含的列太多,无法像另外两个数据框那样打印出来。我们可以改为打印列名:

    list(books.columns)
    ['ISBN', 'Book-Title', 'Book-Author', 'Year-Of-Publication', 'Publisher', 'Image-URL-S', 'Image-URL-M', 'Image-URL-L']
    

ratings 数据框通过 User-IDISBN 信息连接了 usersbooks 数据框,并包含评分,这可以视为一种权重。users 数据框包含每个用户的 demographic 信息,如所在地和年龄(如果有的话)。books 数据框包含关于书籍的内容相关信息,如书名、作者、出版年份、出版社以及链接到三种不同大小封面图像的 URL。

  1. 让我们可视化评分分布,以查看是否能利用这些信息。我们可以使用 matplotlibseaborn 按如下方式绘制:

    import matplotlib.pyplot as plt
    import seaborn as sns
    sns.countplot(x=ratings['Book-Rating'])
    
  2. 这为我们提供了以下图表:

图 17.2 – 评分分布(与书籍的互动被表示为零评分,而 1 到 10 之间的评分为真实评分)

图 17.2 – 评分分布(与一本书的互动表现为评分为零,而 1 到 10 之间的评分为真实评分)

  1. 这些评分是否与我们在 booksusers DataFrame 中的数据相对应?我们可以通过比较 ratings 中的唯一 User-IDISBN 条目的数量与这些 DataFrame 中行数的差异,快速检查:

    print(len(ratings['User-ID'].unique()))
    print(len(ratings['ISBN'].unique()))
    105283
    340556
    

有趣的是,与 users(105,283 与 278,858)相比,ratings 中的独立用户较少,但与 books(340,556 与 271,379)相比,独立 ISBN 数量更多。这意味着我们的数据库缺失了很多数据,因此在连接表时需要特别小心。

  1. 最后,我们绘制仅被评分一次、两次等次数的书籍数量。首先,我们使用 groupby()size() 函数计算每本 ISBN 在 ratings DataFrame 中出现的次数:

    isbn_counts = ratings.groupby('ISBN').size()
    

这创建了一个新的 DataFrame,isbn_counts,它包含了每个独立 ISBN 在 ratings DataFrame 中的计数。

  1. 我们使用 value_counts() 函数计算每个计数值的出现次数。这个新的 DataFrame 将包含 isbn_counts 中每个计数值的出现次数。

    count_occurrences = isbn_counts.value_counts()
    
  2. 最后,我们可以使用 pandas.plot() 方法绘制分布图。在这种情况下,我们只绘制前 15 个值:

    count_occurrences[:15].plot(kind='bar')
    plt.xlabel("Number of occurrences of an ISBN number")
    plt.ylabel("Count")
    
  3. 我们得到以下图表:

图 17.3 – 每本书(ISBN)在评分中出现次数的分布(前 15 个值)

图 17.3 – 每本书(ISBN)在评分中出现次数的分布(前 15 个值)

我们看到许多书籍只被评分一次或两次。看到评分很多次的书籍非常罕见,这使得我们的任务更加困难,因为我们依赖于这些连接。

  1. 我们重复相同的过程,以获取每个用户(User-ID)在 ratings 中出现次数的分布:

    userid_counts = ratings.groupby('User-ID').size()
    count_occurrences = userid_counts.value_counts()
    count_occurrences[:15].plot(kind='bar')
    plt.xlabel("Number of occurrences of a User-ID")
    plt.ylabel("Count")
    
  2. 我们得到了一个类似的分布:

图 17.4 – 每个用户(用户 ID)在评分中出现次数的分布(前 15 个值)

图 17.4 – 每个用户(用户 ID)在评分中出现次数的分布(前 15 个值)

这也意味着大多数用户只评分一两本书,但有少数用户评分了很多书。

该数据集存在一些问题,如出版年份或出版商名称的错误,以及其他缺失或不正确的值。然而,在本章中,我们不会直接使用 booksusers DataFrame 中的元数据。我们将依赖 User-IDISBN 值之间的连接,因此不需要在这里清理数据集。

在下一节中,我们将看到如何处理数据集,为将其输入到 LightGCN 做准备。

预处理 Book-Crossing 数据集

我们希望处理数据集以完成特定任务:推荐物品,具体来说是使用协同过滤方法。协同过滤是一种用于向用户提供个性化推荐的技术。它基于这样一个观点:具有相似偏好或行为的用户更有可能有相似的兴趣。协同过滤算法利用这些信息识别模式,并基于相似用户的偏好向用户做出推荐。

这与基于内容的过滤不同,基于内容的过滤是一种依赖于推荐物品特征的推荐方法。它通过识别物品的特征并将其与用户过去喜欢的其他物品的特征进行匹配,从而生成推荐。基于内容的过滤方法通常基于这样一个观点:如果用户喜欢具有某些特征的物品,那么他们也会喜欢具有相似特征的物品。

在本章中,我们将重点讨论协同过滤。我们的目标是根据其他用户的偏好来确定推荐给用户的书籍。这个问题可以通过二分图来表示,如下图所示。

图 17.5 – 用户-物品二分图示例

图 17.5 – 用户-物品二分图示例

知道用户1喜欢物品AB,用户3喜欢物品BD,我们应该推荐物品B给用户2,他也喜欢物品AD

这是我们希望从Book-Crossing数据集中构建的图类型。更准确地说,我们还希望包括负样本。在这种情况下,负样本指的是给定用户未评分的物品。已经评分的物品被称为正样本。我们将在实现loss函数时解释为什么使用这种负采样技术。

在本章的其余部分,LightGCN代码主要基于官方实现以及 Hotta 和 Zhou [2]以及 Li 等人[3]在不同数据集上出色的工作:

  1. 我们导入以下库:

    import numpy as np
    from sklearn.model_selection import train_test_split
    import torch
    import torch.nn.functional as F
    from torch import nn, optim, Tensor
    from torch_geometric.utils import structured_negative_sampling
    from torch_geometric.nn.conv.gcn_conv import gcn_norm
    from torch_geometric.nn import LGConv
    
  2. 我们重新加载数据集:

    df = pd.read_csv('BX-Book-Ratings.csv', sep=';', encoding='latin-1')
    users = pd.read_csv('BX-Users.csv', sep=';', encoding='latin-1')
    books = pd.read_csv('BX-Books.csv', sep=';', encoding='latin-1', error_bad_lines=False)
    
  3. 我们只保留在books数据框中可以找到ISBN信息和在users数据框中可以找到User-ID信息的行:

    df = df.loc[df['ISBN'].isin(books['ISBN'].unique()) & df['User-ID'].isin(users['User-ID'].unique())]
    
  4. 我们只保留高评分(>= 8/10),因此我们创建的连接对应于用户喜欢的书籍。然后,我们进一步筛选样本,只保留有限数量的行(100,000)以加快训练速度:

    df = df[df['Book-Rating'] >= 8].iloc[:100000]
    
  5. 我们创建useritem标识符到整数索引的映射:

    user_mapping = {userid: i for i, userid in enumerate(df['User-ID'].unique())}
    item_mapping = {isbn: i for i, isbn in enumerate(df['ISBN'].unique())}
    
  6. 我们统计数据集中的用户数、物品数和总实体数:

    num_users = len(user_mapping)
    num_items = len(item_mapping)
    num_total = num_users + num_items
    
  7. 我们基于数据集中的用户评分创建useritem索引的张量。通过堆叠这两个张量来创建edge_index张量:

    user_ids = torch.LongTensor([user_mapping[i] for i in df['User-ID']])
    item_ids = torch.LongTensor([item_mapping[i] for i in df['ISBN']])
    edge_index = torch.stack((user_ids, item_ids))
    
  8. 我们使用 scikit-learn 中的 train_test_split() 函数将 edge_index 分割为训练集、验证集和测试集:

    train_index, test_index = train_test_split(range(len(df)), test_size=0.2, random_state=0)
    val_index, test_index = train_test_split(test_index, test_size=0.5, random_state=0)
    
  9. 我们使用 np.random.choice() 函数生成一个随机索引批次。该函数从 0edge_index.shape[1]-1 的范围内生成 BATCH_SIZE 个随机索引。这些索引将用于从 edge_index 张量中选择行:

    def sample_mini_batch(edge_index):
        index = np.random.choice(range(edge_index.shape[1]), size=BATCH_SIZE)
    
  10. 我们使用 PyTorch Geometric 中的 structured_negative_sampling() 函数生成负样本。负样本是用户未与之交互的项。我们使用 torch.stack() 函数在开头添加一个维度:

        edge_index = structured_negative_sampling(edge_index)
        edge_index = torch.stack(edge_index, dim=0)
    
  11. 我们使用 index 数组和 edge_index 张量选择该批次的用户、正样本项和负样本项索引:

        user_index = edge_index[0, index]
        pos_item_index = edge_index[1, index]
        neg_item_index = edge_index[2, index]
        return user_index, pos_item_index, neg_item_index
    

user_index 张量包含该批次的用户索引,pos_item_index 张量包含该批次的正样本项索引,neg_item_index 张量包含该批次的负样本项索引。

现在,我们有三组数据和一个返回小批量数据的函数。接下来的步骤是理解并实现 LightGCN 架构。

实现 LightGCN 架构

LightGCN [4] 架构旨在通过图上的特征*滑来学习节点的表示。它通过图卷积反复执行,其中相邻节点的特征被聚合为目标节点的新表示。整个架构概述见 图 17.6

图 17.6 – 带有卷积和层组合的 LightGCN 模型架构

图 17.6 – 带有卷积和层组合的 LightGCN 模型架构

然而,LightGCN 采用了简单的加权和聚合器,而不是像 GCN 或 GAT 等其他模型中使用的特征变换或非线性激活。轻量级图卷积操作计算 处的用户和项嵌入 ,计算方式如下:

对称归一化项确保嵌入的尺度不会随着图卷积操作而增加。与其他模型不同,LightGCN 仅聚合连接的邻居节点,并不包含自连接。

实际上,它通过使用层组合操作来实现相同的效果。这个机制由每层使用用户和项嵌入的加权和组成。它通过以下方程式产生最终的嵌入

这里,第 层的贡献由变量 加权。LightGCN 的作者建议将其设置为

图 17.6中显示的预测对应于评分或排名分数。它是通过用户和项目最终表示的内积得到的:

现在让我们在 PyTorch Geometric 中实现这个架构:

  1. 我们创建一个带有四个参数的LightGCN类:num_usersnum_itemsnum_layersdim_hnum_usersnum_items参数分别指定数据集中用户和项目的数量。num_layers表示将使用的LightGCN层的数量,而dim_h参数指定嵌入向量的大小(适用于用户和项目):

    class LightGCN(nn.Module):
        def __init__(self, num_users, num_items, num_layers=4, dim_h=64):
            super().__init__()
    
  2. 我们存储用户和项目的数量,并创建用户和项目的嵌入层。emb_users的形状是,而emb_items的形状是

            self.num_users = num_users
            self.num_items = num_items
            self.emb_users = nn.Embedding(num_embeddings=self.num_users, embedding_dim=dim_h)
            self.emb_items = nn.Embedding(num_embeddings=self.num_items, embedding_dim=dim_h)
    
  3. 我们使用 PyTorch Geometric 的LGConv()创建一个包含num_layers(之前称为)个LightGCN层的列表。这将用于执行轻量图卷积操作:

            self.convs = nn.ModuleList(LGConv() for _ in range(num_layers))
    
  4. 我们通过标准差为0.01的正态分布初始化用户和项目嵌入层。这有助于防止模型在训练时陷入较差的局部最优解:

            nn.init.normal_(self.emb_users.weight, std=0.01)
            nn.init.normal_(self.emb_items.weight, std=0.01)
    
  5. forward()方法接收一个边索引张量,并返回最终的用户和项目嵌入向量,。它首先将用户和项目的嵌入层拼接在一起,并将结果存储在emb张量中。然后,它创建一个列表embs,将emb作为其第一个元素:

        def forward(self, edge_index):
            emb = torch.cat([self.emb_users.weight, self.emb_items.weight])
            embs = [emb]
    
  6. 然后,我们在一个循环中应用LightGCN层,并将每一层的输出存储在embs列表中:

            for conv in self.convs:
                emb = conv(x=emb, edge_index=edge_index)
                embs.append(emb)
    
  7. 我们通过计算embs列表中张量在第二维度上的均值来执行层组合,从而得到最终的嵌入向量:

    emb_final = torch.mean(torch.stack(embs, dim=1), dim=1)
    
  8. 我们将emb_final拆分为用户和项目嵌入向量(),并与一起返回:

            emb_users_final, emb_items_final = torch.split(emb_final, [self.num_users, self.num_items])
            return emb_users_final, self.emb_users.weight, emb_items_final, self.emb_items.weight
    
  9. 最终,通过调用具有适当参数的LightGCN()类来创建模型:

    model = LightGCN(num_users, num_items)
    

在我们可以训练模型之前,我们需要一个损失函数。LightGCN架构采用贝叶斯个性化排序BPR)损失,该损失优化模型在给定用户的情况下,将正项排在负项之前的能力。实现如下:

这里,是第层的嵌入矩阵(即初始用户和项目嵌入的连接),表示正则化强度,对应于正项的预测评分,代表负项的预测评分。

我们使用以下函数在 PyTorch 中实现它:

  1. 我们根据存储在LightGCN模型中的嵌入计算正则化损失:

    def bpr_loss(emb_users_final, emb_users, emb_pos_items_final, emb_pos_items, emb_neg_items_final, emb_neg_items):
        reg_loss = LAMBDA * (emb_users.norm().pow(2) +
                            emb_pos_items.norm().pow(2) +
                            emb_neg_items.norm().pow(2))
    
  2. 我们计算正项和负项的评分,即用户嵌入和项目嵌入之间的点积:

        pos_ratings = torch.mul(emb_users_final, emb_pos_items_final).sum(dim=-1)
        neg_ratings = torch.mul(emb_users_final, emb_neg_items_final).sum(dim=-1)
    
  3. 与之前公式中的对数 sigmoid 不同,我们将 BPR 损失计算为应用于正负评分差异的softplus函数的均值。选择这个变体是因为它给出了更好的实验结果:

        bpr_loss = torch.mean(torch.nn.functional.softplus(pos_ratings - neg_ratings))
    
  4. 我们返回 BPR 损失和正则化损失,如下所示:

        return -bpr_loss + reg_loss
    

除了 BPR 损失外,我们使用两个指标来评估模型的表现:

  • Recall@k是所有可能相关项中,前k项中相关推荐项的比例。然而,这个指标不考虑相关项在前k中的顺序:

  • 归一化折扣累积增益NDGC)衡量系统在排序推荐中的有效性,考虑到项目的相关性,其中相关性通常由分数或二进制相关性(相关或不相关)表示。

该实现未包含在本章中,以提高可读性。然而,它可以在 GitHub 仓库中找到,连同其余的代码一起。

我们现在可以创建一个训练循环,并开始训练LightGCN模型:

  1. 我们定义了以下常数。它们可以作为超参数进行调整,以提高模型性能:

    K = 20
    LAMBDA = 1e-6
    BATCH_SIZE = 1024
    
  2. 我们尝试选择一个 GPU,如果有的话。否则,我们使用 CPU。模型和数据会被移动到这个设备上:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = model.to(device)
    edge_index = edge_index.to(device)
    train_edge_index = train_edge_index.to(device)
    val_edge_index = val_edge_index.to(device)
    
  3. 我们创建一个学习率为0.001Adam优化器:

    optimizer = optim.Adam(model.parameters(), lr=0.001)
    
  4. 让我们开始训练循环。首先,我们计算num_batch,即每个周期中的BATCH_SIZE批次数。然后,我们创建两个循环:一个是 31 个周期,另一个是num_batch的长度:

    num_batch = int(len(train_index)/BATCH_SIZE)
    for epoch in range(31):
        model.train()
        for _ in range(num_batch):
    
  5. 模型在训练数据上运行,并返回初始和最终的用户和项目嵌入:

            optimizer.zero_grad()
            emb_users_final, emb_users, emb_items_final, emb_items = model.forward(train_edge_index)
    
  6. 训练数据然后通过sample_mini_batch()函数按小批量进行采样,该函数返回采样的用户、正项和负项嵌入的索引:

            user_indices, pos_item_indices, neg_item_indices = sample_mini_batch(train_edge_index)
    
  7. 然后检索采样的用户、正项和负项的嵌入:

        emb_users_final, emb_users = emb_users_final[user_indices], emb_users[user_indices]
        emb_pos_items_final, emb_pos_items = emb_items_final[pos_item_indices], emb_items[pos_item_indices]
        emb_neg_items_final, emb_neg_items = emb_items_final[neg_item_indices], emb_items[neg_item_indices]
    
  8. 然后使用bpr_loss()函数计算损失:

        train_loss = bpr_loss(emb_users_final, emb_users, emb_pos_items_final, emb_pos_items, emb_neg_items_final, emb_neg_items)
    
  9. 然后使用优化器执行反向传播,并更新模型参数:

        train_loss.backward()
        optimizer.step()
    
  10. 模型的性能每 250 个周期在验证集上使用test()函数进行评估。评估指标会被打印出来:

        if epoch % 5 == 0:
            model.eval()
            val_loss, recall, ndcg = test(model, val_edge_index, [train_edge_index])
            print(f"Epoch {epoch} | Train loss: {train_loss.item():.5f} | Val loss: {val_loss:.5f} | Val recall@{K}: {recall:.5f} | Val ndcg@{K}: {ndcg:.5f}")
    
  11. 这给出了以下输出:

    Epoch 0 | Train loss: -0.69320 | Val loss: -0.69302 | Val recall@20: 0.00700 | Val ndcg@20: 0.00388
    Epoch 5 | Train loss: -0.70283 | Val loss: -0.68329 | Val recall@20: 0.01159 | Val ndcg@20: 0.00631
    Epoch 10 | Train loss: -0.73299 | Val loss: -0.64598 | Val recall@20: 0.01341 | Val ndcg@20: 0.00999
    ...
    Epoch 25 | Train loss: -1.53056 | Val loss: -0.19498 | Val recall@20: 0.01507 | Val ndcg@20: 0.01016
    Epoch 30 | Train loss: -1.95703 | Val loss: 0.06340 | Val recall@20: 0.01410 | Val ndcg@20: 0.00950
    
  12. 我们如下评估模型在测试集上的表现:

    test_loss, test_recall, test_ndcg = test(model, test_edge_index.to(device), [train_edge_index, val_edge_index])
    print(f"Test loss: {test_loss:.5f} | Test recall@{K}: {test_recall:.5f} | Test ndcg@{K}: {test_ndcg:.5f}")
    Test loss: 0.06827 | Test recall@20: 0.01936 | Test ndcg@20: 0.01119
    

我们获得了recall@20值为0.01936ndcg@20值为0.01119,这一结果接*LightGCN的作者在其他数据集上得到的结果。

现在模型已经训练完成,我们想为给定用户获取推荐。我们想要创建的推荐函数包含两个部分:

  1. 首先,我们要获取一个用户喜欢的书籍列表。这将帮助我们为自己的理解提供推荐的背景信息。

  2. 其次,我们要生成一个推荐书单。这些推荐不能是用户已经评分的书籍(即不能是正向项)。

让我们一步一步编写这个函数:

  1. 我们创建一个名为recommend的函数,该函数接受两个参数:user_id(用户标识符)和num_recs(我们想要生成的推荐数量):

    def recommend(user_id, num_recs):
    
  2. 我们通过查找用户标识符在user_mapping字典中的位置来创建user变量,该字典将用户 ID 映射到整数索引:

        user = user_mapping[user_id]
    
  3. 我们检索LightGCN模型为该特定用户学到的dim_h维向量:

        emb_user = model.emb_users.weight[user]
    
  4. 我们可以用它来计算相应的评分。如前所述,我们使用LightGCNemb_items属性中的所有项目的嵌入与emb_user变量的点积来计算评分:

        ratings = model.emb_items.weight @ emb_user
    
  5. 我们将topk()函数应用于ratings张量,该函数返回前 100 个值(模型计算的评分)及其相应的索引:

        values, indices = torch.topk(ratings, k=100)
    
  6. 让我们获取该用户喜欢的书籍列表。我们通过过滤indices列表,只保留在给定用户的user_items字典中出现的书籍,创建一个新的索引列表。换句话说,我们只保留该用户评分的书籍。然后,我们对该列表进行切片,只保留前num_recs个条目:

        ids = [index.cpu().item() for index in indices if index in user_items[user]][:num_recs]
    
  7. 我们将这些书籍 ID 转换为 ISBN:

        item_isbns = [list(item_mapping.keys())[list(item_mapping.values()).index(book)] for book in ids]
    
  8. 现在,我们可以使用这些 ISBN 来获取书籍的更多信息。在这里,我们想获取书籍的标题和作者,以便打印出来:

        titles = [bookid_title[id] for id in item_isbns]
        authors = [bookid_author[id] for id in item_isbns]
    
  9. 我们如下打印这些信息:

        print(f'Favorite books from user n°{user_id}:')
        for i in range(len(item_isbns)):
            print(f'- {titles[i]}, by {authors[i]}')
    
  10. 我们重复这个过程,但使用用户未评分的书籍 ID(not in user_pos_items[user]):

        ids = [index.cpu().item() for index in indices if index not in user_pos_items[user]][:num_recs]
        item_isbns = [list(item_mapping.keys())[list(item_mapping.values()).index(book)] for book in ids]
        titles = [bookid_title[id] for id in item_isbns]
        authors = [bookid_author[id] for id in item_isbns]
        print(f'\nRecommended books for user n°{user_id}')
        for i in range(num_recs):
            print(f'- {titles[i]}, by {authors[i]}')
    
  11. 让我们为我们数据库中的一个用户获取5个推荐。我们使用277427

    recommend(277427, 5)
    
  12. 这是我们得到的输出:

    Favorite books from user n°277427:
    - The Da Vinci Code, by Dan Brown
    - Lord of the Flies, by William Gerald Golding
    - The Cardinal of the Kremlin (Jack Ryan Novels), by Tom Clancy
    - Into the Wild, by Jon Krakauer
    Recommended books for user n°277427
    - The Lovely Bones: A Novel, by Alice Sebold
    - The Secret Life of Bees, by Sue Monk Kidd
    - The Red Tent (Bestselling Backlist), by Anita Diamant
    - Harry Potter and the Sorcerer's Stone (Harry Potter (Paperback)), by J. K. Rowling
    - To Kill a Mockingbird, by Harper Lee
    

现在,我们可以从原始df数据框中为任何用户生成推荐。你可以测试其他 ID 并查看它如何改变推荐结果。

总结

本章详细探讨了如何使用LightGCN进行书籍推荐任务。我们使用了Book-Crossing数据集,对其进行了预处理,形成了一个二分图,并实现了一个带有 BPR 损失的LightGCN模型。我们训练了该模型,并使用recall@20ndcg@20指标进行了评估。通过为给定用户生成推荐,我们展示了该模型的有效性。

总的来说,本章提供了关于在推荐任务中使用 LightGCN 模型的宝贵见解。它是一种最先进的架构,性能优于更复杂的模型。你可以通过尝试我们在前几章讨论的其他技术来扩展这个项目,例如矩阵分解和 node2vec

进一步阅读

第十八章:解锁图神经网络在现实应用中的潜力

感谢您抽出时间阅读《使用 Python 实战图神经网络》。我们希望它为您提供了关于图神经网络及其应用的宝贵见解。

在本书结束时,我们想给您一些关于如何有效使用 GNN 的最后建议。GNN 在合适的条件下可以非常高效,但它们也有与其他深度学习技术相同的优缺点。知道何时何地应用这些模型是掌握的一项关键技能,因为过度设计的解决方案可能会导致性能不佳。

首先,当有大量数据可供训练时,GNN 特别有效。这是因为深度学习算法需要大量数据才能有效地学习复杂的模式和关系。在足够大的数据集上,GNN 可以实现高水*的准确性和泛化能力。

出于类似的原因,GNN 在处理复杂的高维数据(节点和边特征)时最具价值。它们可以自动学习复杂的模式和特征之间的关系,这些是人类很难或无法识别的。传统的机器学习算法,如线性回归或决策树,依赖于手工制作的特征,这些特征通常在捕捉现实世界数据的复杂性方面存在局限性。

最后,在使用 GNN 时,确保图表示能够为特征增加价值非常重要。特别是在图是人工构建的表示时(而非自然图),如社交网络或蛋白质结构时尤其适用。节点之间的连接不应是随意的,而应代表节点之间有意义的关系。

你可能会注意到,本书中的某些示例没有遵循之前的规则。这主要是由于在 Google Colab 中运行代码的技术限制,以及普遍缺乏高质量的数据集。然而,这也反映了现实生活中的数据集,通常是杂乱的,而且难以大量获取。这些数据大多数也是表格数据,优秀的基于树的模型,如 XGBoost,通常难以超越。

更一般而言,可靠的基准解决方案至关重要,因为即使在合适的条件下,它们也可能很难被超越。使用 GNN 时,一个有效的策略是实现多种类型的 GNN 并比较它们的表现。例如,基于卷积的 GNN(如 GCN,见第六章)可能在某些类型的图上表现良好,而基于注意力的 GNN(如 GAT,见第七章)可能更适合其他类型的图。此外,基于消息传递的 GNN(如 MPNN,见第十二章)可能在某些背景下表现出色。请注意,每种方法相比之前的方法表达能力更强,而且每种方法有不同的优点和缺点。

如果你正在解决一个更具体的问题,本书中有几种 GNN 方法可能更加合适。例如,如果你处理的是缺乏节点和边特征的小型图数据,你可能会考虑使用 Node2Vec(见第四章)。相反,如果你处理的是大型图,GraphSAGE 和 LightGCN 可以帮助管理计算时间和内存存储要求(见第八章第十七章)。

此外,GIN 和全局池化层可能适用于图分类任务(见第九章),而变分图自编码器和 SEAL 可以用于链接预测(见第十章)。对于生成新图,你可以探索 GraphRNN 和 MolGAN(见第十一章)。如果你在处理异构图,可能需要考虑使用不同种类的异构 GNN(见第十二章第十六章)。对于时空图,Graph WaveNet、STGraph 及其他时序 GNN 可以派上用场(见第十三章第十五章)。最后,如果你需要解释你的 GNN 做出的预测,你可以参考第十四章中介绍的图可解释性技术。

通过阅读本书,你将深入理解图神经网络(GNN)及其如何应用于解决现实世界中的问题。随着你在该领域的持续工作,我们鼓励你将这些知识付诸实践,尝试新的方法,并不断提升自己的专业技能。机器学习领域在不断发展,随着时间的推移,你的技能将变得越来越有价值。我们希望你能够将所学应用于应对挑战,并对世界产生积极的影响。再次感谢你阅读本书,祝你在未来的工作中一切顺利。

posted @ 2025-07-10 11:38  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报