PyTorch 与 Sklearn 机器学习指南(七)

原文:zh.annas-archive.org/md5/2a872f7dd98f6fbe3043a236f689e451

译者:飞龙

协议:CC BY-NC-SA 4.0

第十七章:生成对抗网络用于合成新数据

在上一章中,我们专注于用于建模序列的循环神经网络。在本章中,我们将探讨生成对抗网络GANs)及其在合成新数据样本中的应用。GAN 被认为是深度学习中最重要的突破之一,允许计算机生成新数据(如新图像)。

本章将涵盖以下主题:

  • 引入生成模型用于合成新数据

  • 自编码器、变分自编码器及其与生成对抗网络(GANs)的关系

  • 理解 GAN 的构建模块

  • 实现一个简单的 GAN 模型来生成手写数字

  • 理解转置卷积和批归一化

  • 改进 GAN:深度卷积 GAN 和使用 Wasserstein 距离的 GAN

介绍生成对抗网络

让我们首先看看 GAN 模型的基础。GAN 的总体目标是合成具有与其训练数据集相同分布的新数据。因此,GAN 在其原始形式中被认为是机器学习任务中无监督学习类别的一部分,因为不需要标记数据。然而,值得注意的是,对原始 GAN 的扩展可以同时属于半监督和监督领域。

生成对抗网络(GAN)的一般概念最早由伊恩·古德费洛及其同事于 2014 年提出,作为利用深度神经网络NNs)合成新图像的方法(生成对抗网络,见I. Goodfellow, J. Pouget-Abadie, M. Mirza, B. Xu, D. Warde-Farley, S. Ozair, A. Courville, and Y. Bengio,《神经信息处理系统进展》,第 2672-2680 页,2014 年)。尽管该论文中最初的 GAN 架构基于全连接层,类似于多层感知器结构,并训练生成低分辨率的类似 MNIST 手写数字,但它更像是一个概念验证,旨在展示这种新方法的可行性。

然而,自其引入以来,原始作者及许多其他研究人员已提出了许多改进以及不同领域工程和科学中的各种应用。例如,在计算机视觉中,GAN 被用于图像到图像的转换(学习如何将输入图像映射到输出图像)、图像超分辨率(从低分辨率版本生成高分辨率图像)、图像修补(学习如何重构图像中丢失的部分)等多种应用。例如,最近 GAN 研究的进展导致了能够生成新的高分辨率人脸图像的模型。此类高分辨率图像的例子可以在www.thispersondoesnotexist.com/找到,展示了由 GAN 生成的合成人脸图像。

从自编码器开始

在讨论 GAN 的工作原理之前,我们首先从自编码器开始,它可以压缩和解压训练数据。虽然标准自编码器不能生成新数据,但理解它们的功能将有助于你在下一节中理解 GAN。

自编码器由两个串联的网络组成:一个编码器网络和一个解码器网络。编码器网络接收与示例x相关的d维输入特征向量(即 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传),并将其编码成p维向量z(即 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)。换句话说,编码器的作用是学习如何建模函数z = f(x)。编码后的向量z也称为潜在向量或潜在特征表示。通常,潜在向量的维度小于输入示例的维度;换句话说,p < d。因此,我们可以说编码器充当数据压缩函数。然后,解码器从低维潜在向量z中解压出 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,我们可以将解码器视为一个函数,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传图 17.1展示了一个简单的自编码器架构,其中编码器和解码器部分只包含一个完全连接的层:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.1:自编码器的架构

自编码器与降维的联系

第五章通过降维压缩数据,你学习了一些降维技术,比如主成分分析PCA)和线性判别分析LDA)。自编码器也可以作为一种降维技术。事实上,当两个子网络(编码器和解码器)中没有非线性时,自编码器方法与 PCA 几乎完全相同

在这种情况下,如果我们假设单层编码器的权重(无隐藏层和非线性激活函数)用矩阵U表示,则编码器模型为z = U^Tx。类似地,单层线性解码器模型为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。将这两个组件放在一起,我们有 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。这正是 PCA 所做的事情,唯一的区别在于 PCA 有一个额外的正交规范约束:UU^T = I[n][×][n]。

虽然图 17.1描绘了一个没有隐藏层的自编码器,在编码器和解码器中,我们当然可以添加多个带有非线性的隐藏层(如多层神经网络),以构建一个能够学习更有效数据压缩和重构函数的深度自编码器。此外,注意到本节提到的自编码器使用全连接层。然而,在处理图像时,我们可以用卷积层替换全连接层,正如你在第十四章使用深度卷积神经网络分类图像中学到的那样。

基于潜在空间大小的其他类型的自编码器

正如之前提到的,自编码器的潜在空间的维度通常比输入的维度低(p < d),这使得自编码器适用于降维。因此,潜在向量也经常被称为“瓶颈”,并且这种特定的自编码器配置也称为欠完备。然而,还有一种不同类别的自编码器,称为过完备,在这种情况下,潜在向量z的维度实际上大于输入示例的维度(p > d)。

在训练过程中,当训练一个过完备的自编码器时,存在一个平凡的解决方案,即编码器和解码器可以简单地学习复制(记忆)输入特征到它们的输出层。显然,这种解决方案并不是很有用。然而,通过对训练过程进行一些修改,过完备的自编码器可以用于噪声减少

在这种情况下,训练过程中,随机噪声外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传被添加到输入示例中,网络学习从嘈杂的信号外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传中重构出干净的例子x。然后,在评估时,我们提供自然嘈杂的新例子(即已经存在噪声,因此不需要额外的人工噪声外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)以便从这些例子中去除现有的噪声。这种特殊的自编码器架构和训练方法被称为去噪自编码器

如果你感兴趣,你可以通过Pascal Vincent和他的同事在 2010 年发表的研究文章Stacked denoising autoencoders: Learning useful representations in a deep network with a local denoising criterion了解更多。

生成模型用于合成新数据

自编码器是确定性模型,这意味着在自编码器训练后,给定一个输入x,它将能够从其在较低维空间中的压缩版本重新构建输入。因此,它不能在超出重构其输入之外生成新的数据。

另一方面,一个生成模型可以从一个随机向量z(对应于潜在表示)生成一个新的例子,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。生成模型的示意图如下所示。随机向量z来自具有完全已知特性的分布,因此我们可以轻松地从这样的分布中进行抽样。例如,z的每个元素可以来自于范围为[–1, 1]的均匀分布(我们写成外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传),或者来自于标准正态分布(这种情况下我们写成外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.2:一个生成模型

当我们将注意力从自动编码器转向生成模型时,您可能已经注意到自动编码器的解码器部分与生成模型有些相似。特别是它们都接收潜在向量 z 作为输入,并返回与 x 相同空间的输出。(对于自动编码器,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是输入 x 的重构,对于生成模型,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是一个合成的样本。)

然而,两者之间的主要区别在于我们不知道自动编码器中 z 的分布,而在生成模型中,z 的分布是完全可描述的。虽然可以将自动编码器泛化为生成模型。一种方法是 变分自动编码器VAE)。

在接收输入示例 x 的 VAE 中,编码器网络被修改,以计算潜在向量分布的两个时刻:均值 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 和方差 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。在训练 VAE 期间,网络被强制使这些时刻与标准正态分布(即零均值和单位方差)的时刻匹配。然后,在训练 VAE 模型后,编码器被丢弃,我们可以使用解码器网络通过从“学习到的”高斯分布中提供的随机 z 向量来生成新的示例,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

除了 VAE,还有其他类型的生成模型,例如 自回归模型正规化流模型。然而,在本章中,我们将只关注 GAN 模型,它们是深度学习中最新和最流行的生成模型类型之一。

什么是生成模型?

请注意,生成模型通常被定义为模拟数据输入分布 p(x) 或输入数据及相关目标的联合分布 p(xy) 的算法。按照定义,这些模型也能够从某些特征 x[i] 中进行采样,条件是另一特征 x[j],这被称为 条件推理。然而,在深度学习的语境中,术语 生成模型 通常用来指代能够生成看起来真实的数据的模型。这意味着我们可以从输入分布 p(x) 中采样,但不一定能进行条件推理。

使用 GANs 生成新样本

要简单理解 GANs 的作用,我们首先假设有一个网络,接收来自已知分布的随机向量 z,并生成输出图像 x。我们将这个网络称为 生成器G),并使用符号 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 表示生成的输出。假设我们的目标是生成一些图像,例如人脸图像、建筑物图像、动物图像,甚至是手写数字如 MNIST。

正如以往一样,我们将使用随机权重初始化这个网络。因此,在调整这些权重之前,第一批输出图像看起来像是白噪声。现在,想象一下有一个能够评估图像质量的函数(我们称之为评估函数)。

如果存在这样的函数,我们可以利用该函数的反馈告诉生成器网络如何调整其权重以提高生成图像的质量。通过这种方式,我们可以根据评估函数的反馈训练生成器,使其学习改进其输出以生成看起来真实的图像。

正如上一段所描述的评估函数,如果存在这样一个通用函数来评估图像的质量,那么生成图像的任务将变得非常简单。问题是,是否存在这样一个可以评估图像质量的通用函数,如果存在,它是如何定义的。显然,作为人类,当我们观察网络的输出时,可以轻松评估输出图像的质量;尽管我们目前(还)无法将我们的大脑结果反向传播到网络中。现在,如果我们的大脑能够评估合成图像的质量,那么我们是否可以设计一个神经网络模型来做同样的事情?事实上,这正是 GAN 的一般想法。

图 17.3所示,GAN 模型包括一个名为鉴别器D)的附加神经网络,它是一个分类器,学习如何检测由生成器合成的图像,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,与真实图像x的区别:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.3:鉴别器区分真实图像和生成器创建的图像

在 GAN 模型中,生成器和鉴别器两个网络一起训练。一开始,初始化模型权重后,生成器创建的图像看起来不太真实。同样,鉴别器在区分真实图像和生成器合成图像方面表现不佳。但随着时间的推移(即训练过程中),这两个网络通过相互作用逐渐提升。事实上,这两个网络在进行对抗训练,生成器学习改进其输出以欺骗鉴别器。与此同时,鉴别器变得更加擅长检测合成图像。

理解 GAN 模型中生成器和鉴别器网络的损失函数

GAN 的目标函数,如I. Goodfellow及其同事在原始论文生成对抗网络papers.nips.cc/paper/5423-generative-adversarial-nets.pdf)中描述的那样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传被称为值函数,可以被解释为一种回报:我们希望在鉴别器(D)方面最大化其值,同时在生成器(G)方面最小化其值,即外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传D(x)是指示输入示例x是真实还是生成的概率(即生成的)。表达式外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传指的是对于来自数据分布(真实例子的分布)的示例期望值;外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是指对于输入向量z的分布的期望值。

GAN 模型的一个训练步骤需要两个优化步骤:(1)最大化鉴别器的回报,(2)最小化生成器的回报。训练 GAN 的一个实际方法是在这两个优化步骤之间交替进行:(1)固定一个网络的参数并优化另一个网络的权重,(2)固定第二个网络并优化第一个网络。这个过程应该在每个训练迭代中重复。假设生成器网络被固定,并且我们想要优化鉴别器。值函数外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传中的两项都有助于优化鉴别器,其中第一项对应于真实例子的损失,第二项是虚假例子的损失。因此,当G固定时,我们的目标是最大化外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,这意味着使鉴别器更好地区分真实和生成的图像。

优化鉴别器使用真实和虚假样本的损失项后,我们固定鉴别器并优化生成器。在这种情况下,只有外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传中的第二项对生成器的梯度起作用。因此,当D固定时,我们的目标是最小化外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,可以写成外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。正如 Goodfellow 及其同事在原始 GAN 论文中提到的那样,这个函数外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传在早期训练阶段会出现梯度消失的问题。造成这一现象的原因是在学习过程早期,输出G(z)看起来与真实例子完全不同,因此D(G(z))会非常接近零并且有很高的置信度。这种现象被称为饱和。为了解决这个问题,我们可以通过重写将最小化目标外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传重新表述为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传来重新制定。

这种替换意味着在训练生成器时,我们可以交换真实和虚假示例的标签,并执行常规的函数最小化。换句话说,尽管生成器合成的示例是虚假的,因此标记为 0,我们可以通过将这些示例分配标签 1 来反转标签,并最小化使用这些新标签的二元交叉熵损失,而不是最大化 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在我们已经介绍了训练 GAN 模型的一般优化过程,让我们探讨在训练 GAN 时可以使用的各种数据标签。鉴于判别器是二元分类器(虚假和真实图像的类标签分别为 0 和 1),我们可以使用二元交叉熵损失函数。因此,我们可以确定判别器损失的地面真实标签如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

那么,训练生成器的标签如何呢?由于我们希望生成器合成逼真的图像,当鉴别器不将其输出分类为真实图像时,我们希望惩罚生成器。这意味着在计算生成器的损失函数时,我们将假设生成器输出的地面真实标签为 1。

将所有这些内容整合在一起,下图展示了简单 GAN 模型中的各个步骤:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.4:构建 GAN 模型的步骤

在接下来的部分,我们将从零开始实现 GAN 以生成新的手写数字。

从零开始实现 GAN

在本节中,我们将介绍如何实现和训练 GAN 模型以生成新的图像,如 MNIST 数字。由于在普通中央处理单元CPU)上进行训练可能需要很长时间,因此在下一小节中,我们将介绍如何设置 Google Colab 环境,以便我们可以在图形处理单元GPU)上运行计算。

在 Google Colab 上训练 GAN 模型

本章中的某些代码示例可能需要超出传统笔记本电脑或工作站的常规计算资源。如果您已经有一个安装了 CUDA 和 cuDNN 库的 NVIDIA GPU 计算机,可以使用它来加快计算速度。

然而,由于许多人无法获得高性能计算资源,我们将使用 Google Colaboratory 环境(通常称为 Google Colab),这是一个免费的云计算服务(在大多数国家都可以使用)。

Google Colab 提供在云上运行的 Jupyter Notebook 实例;可以将笔记本保存在 Google Drive 或 GitHub 上。虽然该平台提供各种不同的计算资源,如 CPU、GPU,甚至张量处理单元TPU),但需要强调的是,执行时间目前限制为 12 小时。因此,任何运行超过 12 小时的笔记本将被中断。

本章的代码块最长需要两到三个小时的计算时间,所以这不会成为问题。不过,如果你决定在 Google Colab 上运行其他超过 12 小时的项目,请务必使用检查点和保存中间检查点。

Jupyter Notebook

Jupyter Notebook 是一个用于交互式运行代码、插入文档和图形的图形用户界面(GUI)。由于其多功能性和易用性,它已成为数据科学中最流行的工具之一。

欲了解更多关于 Jupyter Notebook 的一般信息,请查阅官方文档,网址为jupyter-notebook.readthedocs.io/en/stable/。本书中所有代码也以 Jupyter Notebook 形式提供,第一章的代码目录中还附有简短介绍。

最后,我们强烈推荐Adam Rule等人的文章《在 Jupyter Notebooks 中编写和共享计算分析的十个简单规则》,该文章对在科学研究项目中有效使用 Jupyter Notebook 提供了有价值的建议,可在journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1007007免费获取。

访问 Google Colab 非常简单。您可以访问colab.research.google.com,该链接会自动跳转到一个提示窗口,您可以在其中看到现有的 Jupyter 笔记本。在这个提示窗口中,点击如图 17.5所示的Google Drive选项卡,这是您将笔记本保存到 Google Drive 的地方。

接下来,要创建一个新的笔记本,请点击提示窗口底部的New notebook链接:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.5:在 Google Colab 中创建一个新的 Python 笔记本

这将为您创建并打开一个新的笔记本。您在此笔记本中编写的所有代码示例都将自动保存,稍后您可以从名为Colab Notebooks的目录中访问笔记本。

在下一步中,我们希望利用 GPU 来运行此笔记本中的代码示例。为此,请在此笔记本菜单栏的Runtime选项中,点击Change runtime type,然后选择GPU,如图 17.6所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.6:在 Google Colab 中利用 GPU

在最后一步中,我们只需安装本章所需的 Python 包。Colab Notebooks 环境已经预装了某些包,如 NumPy、SciPy 和最新稳定版本的 PyTorch。撰写本文时,Google Colab 的最新稳定版本是 PyTorch 1.9。

现在,我们可以通过以下代码来测试安装并验证 GPU 是否可用:

>>> import torch
>>> print(torch.__version__)
1.9.0+cu111
>>> print("GPU Available:", torch.cuda.is_available())
GPU Available: True
>>> if torch.cuda.is_available():
...     device = torch.device("cuda:0")
... else:
...     device = "cpu"
>>> print(device)
cuda:0 

此外,如果你想要将模型保存到个人谷歌驱动器,或者转移或上传其他文件,你需要挂载谷歌驱动器。要做到这一点,在笔记本的新单元格中执行以下操作:

>>> from google.colab import drive
>>> drive.mount('/content/drive/') 

这将提供一个链接来对 Colab 笔记本访问你的谷歌驱动器进行身份验证。按照身份验证的说明操作后,它会提供一个身份验证代码,你需要复制并粘贴到刚刚执行的单元格下方的指定输入字段中。然后,你的谷歌驱动器将被挂载,并可在/content/drive/My Drive路径下访问。或者,你可以通过 GUI 界面挂载,如图 17.7所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.7:挂载你的谷歌驱动器

实现生成器和判别器网络

我们将从一个包含生成器和判别器的两个全连接网络的第一个 GAN 模型实现开始,如图 17.8所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.8:一个包含生成器和判别器的 GAN 模型,两者都是全连接网络

图 17.8 描述了基于全连接层的原始 GAN,我们将其称为香草 GAN

在这个模型中,对于每个隐藏层,我们将应用泄漏线性整流单元(Leaky ReLU)激活函数。使用 ReLU 会导致梯度稀疏化,这在我们希望对输入值的全范围有梯度时可能不太合适。在判别器网络中,每个隐藏层之后还跟有一个 dropout 层。此外,生成器的输出层使用双曲正切(tanh)激活函数。(推荐在生成器网络中使用 tanh 激活函数,因为它有助于学习。)

判别器的输出层没有激活函数(即线性激活),以获取 logits。作为替代,我们可以使用 sigmoid 激活函数以获取概率作为输出。

泄漏修正线性单元(ReLU)激活函数

第十二章,《使用 PyTorch 并行化神经网络训练》,我们介绍了可以在 NN 模型中使用的不同非线性激活函数。如果你还记得,ReLU 激活函数被定义为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,它抑制负(激活前)输入;也就是说,负输入被设置为零。因此,在反向传播过程中使用 ReLU 激活函数可能导致稀疏梯度。稀疏梯度并不总是有害的,甚至可以使分类模型受益。然而,在某些应用中,如 GANs,获得全范围输入值的梯度可能对模型有利。我们可以通过对 ReLU 函数进行轻微修改来实现这一点,使其对负输入输出小值。这种修改版本的 ReLU 函数也被称为泄露的 ReLU。简而言之,泄露的 ReLU 激活函数允许负输入的梯度非零,并因此使网络整体更具表现力。

泄露的 ReLU 激活函数定义如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.9:泄露的 ReLU 激活函数

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传决定了负(激活前)输入的斜率。

我们将为每个网络定义两个辅助函数,从 PyTorch 的nn.Sequential类中实例化模型,并按描述添加层。代码如下:

>>> import torch.nn as nn
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> ## define a function for the generator:
>>> def make_generator_network(
...         input_size=20,
...         num_hidden_layers=1,
...         num_hidden_units=100,
...         num_output_units=784):
...     model = nn.Sequential()
...     for i in range(num_hidden_layers):
...         model.add_module(f'fc_g{i}',
...                          nn.Linear(input_size, num_hidden_units))
...         model.add_module(f'relu_g{i}', nn.LeakyReLU())
...         input_size = num_hidden_units
...     model.add_module(f'fc_g{num_hidden_layers}',
...                      nn.Linear(input_size, num_output_units))
...     model.add_module('tanh_g', nn.Tanh())
...     return model
>>> 
>>> ## define a function for the discriminator:
>>> def make_discriminator_network(
...         input_size,
...         num_hidden_layers=1,
...         num_hidden_units=100,
...         num_output_units=1):
...     model = nn.Sequential()
...     for i in range(num_hidden_layers):
...         model.add_module(
...             f'fc_d{i}',
...             nn.Linear(input_size, num_hidden_units, bias=False)
...         )
...         model.add_module(f'relu_d{i}', nn.LeakyReLU())
...         model.add_module('dropout', nn.Dropout(p=0.5))
...         input_size = num_hidden_units
...     model.add_module(f'fc_d{num_hidden_layers}',
...                      nn.Linear(input_size, num_output_units))
...     model.add_module('sigmoid', nn.Sigmoid())
...     return model 

接下来,我们将指定模型的训练设置。正如您从前几章中记得的那样,MNIST 数据集中的图像大小为 28×28 像素。(因为 MNIST 只包含灰度图像,所以只有一个颜色通道。)我们还将指定输入向量z的大小为 20。由于我们只是为了演示目的而实现了一个非常简单的 GAN 模型,并使用全连接层,我们只会使用每个网络中的一个单隐藏层,每层有 100 个单元。在下面的代码中,我们将指定和初始化这两个网络,并打印它们的摘要信息:

>>> image_size = (28, 28)
>>> z_size = 20
>>> gen_hidden_layers = 1
>>> gen_hidden_size = 100
>>> disc_hidden_layers = 1
>>> disc_hidden_size = 100
>>> torch.manual_seed(1)
>>> gen_model = make_generator_network(
...     input_size=z_size,
...     num_hidden_layers=gen_hidden_layers,
...     num_hidden_units=gen_hidden_size,
...     num_output_units=np.prod(image_size)
... )
>>> print(gen_model)
Sequential(
  (fc_g0): Linear(in_features=20, out_features=100, bias=False)
  (relu_g0): LeakyReLU(negative_slope=0.01)
  (fc_g1): Linear(in_features=100, out_features=784, bias=True)
  (tanh_g): Tanh()
)
>>> disc_model = make_discriminator_network(
...     input_size=np.prod(image_size),
...     num_hidden_layers=disc_hidden_layers,
...     num_hidden_units=disc_hidden_size
... )
>>> print(disc_model)
Sequential(
  (fc_d0): Linear(in_features=784, out_features=100, bias=False)
  (relu_d0): LeakyReLU(negative_slope=0.01)
  (dropout): Dropout(p=0.5, inplace=False)
  (fc_d1): Linear(in_features=100, out_features=1, bias=True)
  (sigmoid): Sigmoid()
) 

定义训练数据集

在下一步中,我们将从 PyTorch 加载 MNIST 数据集并应用必要的预处理步骤。由于生成器的输出层使用 tanh 激活函数,合成图像的像素值将在(-1,1)范围内。然而,MNIST 图像的输入像素在[0,255]范围内(数据类型为PIL.Image.Image)。因此,在预处理步骤中,我们将使用torchvision.transforms.ToTensor函数将输入图像张量转换为张量。因此,除了更改数据类型外,调用此函数还将改变输入像素强度的范围为[0,1]。然后,我们可以通过将它们移动-0.5 并缩放 0.5 的因子来将它们移动到[-1,1]范围内,从而改善基于梯度下降的学习:

>>> import torchvision
>>> from torchvision import transforms
>>> image_path = './'
>>> transform = transforms.Compose([
...     transforms.ToTensor(),
...     transforms.Normalize(mean=(0.5), std=(0.5)),
... ])
>>> mnist_dataset = torchvision.datasets.MNIST(
...     root=image_path, train=True,
...     transform=transform, download=False
... )
>>> example, label = next(iter(mnist_dataset))
>>> print(f'Min: {example.min()} Max: {example.max()}')
>>> print(example.shape)
Min: -1.0 Max: 1.0
torch.Size([1, 28, 28]) 

此外,我们还将根据所需的随机分布(在此代码示例中为均匀分布或正态分布,这是最常见的选择之一)创建一个随机向量z

>>> def create_noise(batch_size, z_size, mode_z):
...     if mode_z == 'uniform':
...         input_z = torch.rand(batch_size, z_size)*2 - 1
...     elif mode_z == 'normal':
...         input_z = torch.randn(batch_size, z_size)
...     return input_z 

让我们检查我们创建的数据集对象。在接下来的代码中,我们将取一个示例批次并打印这些输入向量和图像的数组形状。此外,为了理解我们的 GAN 模型的整体数据流,我们将在下面的代码中为我们的生成器和判别器进行前向传递。

首先,我们将批量输入向量z传递给生成器并获取其输出g_output。这将是一批假例子,将被馈送到判别器模型以获取假例子批次d_proba_fake的概率。此外,我们从数据集对象获取的处理后的图像将被馈送到判别器模型,这将导致真实例子批次d_proba_real的概率。代码如下:

>>> from torch.utils.data import DataLoader
>>> batch_size = 32
>>> dataloader = DataLoader(mnist_dataset, batch_size, shuffle=False)
>>> input_real, label = next(iter(dataloader))
>>> input_real = input_real.view(batch_size, -1)
>>> torch.manual_seed(1)
>>> mode_z = 'uniform'  # 'uniform' vs. 'normal'
>>> input_z = create_noise(batch_size, z_size, mode_z)
>>> print('input-z -- shape:', input_z.shape)
>>> print('input-real -- shape:', input_real.shape)
input-z -- shape: torch.Size([32, 20])
input-real -- shape: torch.Size([32, 784])
>>> g_output = gen_model(input_z)
>>> print('Output of G -- shape:', g_output.shape)
Output of G -- shape: torch.Size([32, 784])
>>> d_proba_real = disc_model(input_real)
>>> d_proba_fake = disc_model(g_output)
>>> print('Disc. (real) -- shape:', d_proba_real.shape)
>>> print('Disc. (fake) -- shape:', d_proba_fake.shape)
Disc. (real) -- shape: torch.Size([32, 1])
Disc. (fake) -- shape: torch.Size([32, 1]) 

两个概率d_proba_faked_proba_real将用于计算训练模型的损失函数。

训练 GAN 模型

作为下一步,我们将创建一个nn.BCELoss的实例作为我们的损失函数,并使用它来计算与我们刚刚处理的批次相关的生成器和判别器的二元交叉熵损失。为此,我们还需要每个输出的地面实况标签。对于生成器,我们将创建一个与包含生成图像预测概率的向量d_proba_fake形状相同的 1 向量。对于判别器损失,我们有两个项:涉及d_proba_fake的检测假例的损失和基于d_proba_real的检测真实例的损失。

对于假术语的地面实况标签将是一个 0 向量,我们可以通过torch.zeros()(或torch.zeros_like())函数生成。类似地,我们可以通过torch.ones()(或torch.ones_like())函数生成真实图像的地面实况值,该函数创建一个 1 向量:

>>> loss_fn = nn.BCELoss()
>>> ## Loss for the Generator
>>> g_labels_real = torch.ones_like(d_proba_fake)
>>> g_loss = loss_fn(d_proba_fake, g_labels_real)
>>> print(f'Generator Loss: {g_loss:.4f}')
Generator Loss: 0.6863
>>> ## Loss for the Discriminator
>>> d_labels_real = torch.ones_like(d_proba_real)
>>> d_labels_fake = torch.zeros_like(d_proba_fake)
>>> d_loss_real = loss_fn(d_proba_real, d_labels_real)
>>> d_loss_fake = loss_fn(d_proba_fake, d_labels_fake)
>>> print(f'Discriminator Losses: Real {d_loss_real:.4f} Fake {d_loss_fake:.4f}')
Discriminator Losses: Real 0.6226 Fake 0.7007 

前面的代码示例展示了逐步计算不同损失项的过程,以便理解训练 GAN 模型背后的整体概念。接下来的代码将设置 GAN 模型并实现训练循环,在其中我们将在for循环中包括这些计算。

我们将从为真实数据集设置数据加载器开始,包括生成器和判别器模型,以及两个模型各自的单独 Adam 优化器:

>>> batch_size = 64
>>> torch.manual_seed(1)
>>> np.random.seed(1)
>>> mnist_dl = DataLoader(mnist_dataset, batch_size=batch_size,
...                       shuffle=True, drop_last=True)
>>> gen_model = make_generator_network(
...     input_size=z_size,
...     num_hidden_layers=gen_hidden_layers,
...     num_hidden_units=gen_hidden_size,
...     num_output_units=np.prod(image_size)
... ).to(device)
>>> disc_model = make_discriminator_network(
...     input_size=np.prod(image_size),
...     num_hidden_layers=disc_hidden_layers,
...     num_hidden_units=disc_hidden_size
... ).to(device)
>>> loss_fn = nn.BCELoss()
>>> g_optimizer = torch.optim.Adam(gen_model.parameters())
>>> d_optimizer = torch.optim.Adam(disc_model.parameters()) 

另外,我们将计算损失相对于模型权重的梯度,并使用两个单独的 Adam 优化器优化生成器和判别器的参数。我们将编写两个实用函数来训练判别器和生成器,如下所示:

>>> ## Train the discriminator
>>> def d_train(x):
...     disc_model.zero_grad()
...     # Train discriminator with a real batch
...     batch_size = x.size(0)
...     x = x.view(batch_size, -1).to(device)
...     d_labels_real = torch.ones(batch_size, 1, device=device)
...     d_proba_real = disc_model(x)
...     d_loss_real = loss_fn(d_proba_real, d_labels_real)
...     # Train discriminator on a fake batch
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     d_proba_fake = disc_model(g_output)
...     d_labels_fake = torch.zeros(batch_size, 1, device=device)
...     d_loss_fake = loss_fn(d_proba_fake, d_labels_fake)
...     # gradient backprop & optimize ONLY D's parameters
...     d_loss = d_loss_real + d_loss_fake
...     d_loss.backward()
...     d_optimizer.step()
...     return d_loss.data.item(), d_proba_real.detach(), \
...            d_proba_fake.detach()
>>>
>>> ## Train the generator
>>> def g_train(x):
...     gen_model.zero_grad()
...     batch_size = x.size(0)
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_labels_real = torch.ones(batch_size, 1, device=device)
... 
...     g_output = gen_model(input_z)
...     d_proba_fake = disc_model(g_output)
...     g_loss = loss_fn(d_proba_fake, g_labels_real)
...     # gradient backprop & optimize ONLY G's parameters
...     g_loss.backward()
...     g_optimizer.step()
...     return g_loss.data.item() 

接下来,我们将在 100 个 epochs 中交替训练生成器和判别器。每个 epoch,我们将记录生成器的损失、判别器的损失以及真实数据和假数据的损失。此外,在每个 epoch 后,我们将使用当前生成器模型调用 create_samples() 函数从固定噪声输入生成一些示例。我们将合成的图像存储在一个 Python 列表中。代码如下:

>>> fixed_z = create_noise(batch_size, z_size, mode_z).to(device)
>>> def create_samples(g_model, input_z):
...     g_output = g_model(input_z)
...     images = torch.reshape(g_output, (batch_size, *image_size))
...     return (images+1)/2.0
>>> 
>>> epoch_samples = []
>>> all_d_losses = []
>>> all_g_losses = []
>>> all_d_real = []
>>> all_d_fake = []
>>> num_epochs = 100
>>> 
>>> for epoch in range(1, num_epochs+1):
...     d_losses, g_losses = [], []
...     d_vals_real, d_vals_fake = [], []
...     for i, (x, _) in enumerate(mnist_dl):
...         d_loss, d_proba_real, d_proba_fake = d_train(x)
...         d_losses.append(d_loss)
...         g_losses.append(g_train(x))
...         d_vals_real.append(d_proba_real.mean().cpu())
...         d_vals_fake.append(d_proba_fake.mean().cpu())
...         
...     all_d_losses.append(torch.tensor(d_losses).mean())
...     all_g_losses.append(torch.tensor(g_losses).mean())
...     all_d_real.append(torch.tensor(d_vals_real).mean())
...     all_d_fake.append(torch.tensor(d_vals_fake).mean())
...     print(f'Epoch {epoch:03d} | Avg Losses >>'
...           f' G/D {all_g_losses[-1]:.4f}/{all_d_losses[-1]:.4f}'
...           f' [D-Real: {all_d_real[-1]:.4f}'
...           f' D-Fake: {all_d_fake[-1]:.4f}]')
...     epoch_samples.append(
...         create_samples(gen_model, fixed_z).detach().cpu().numpy()
...     )

Epoch 001 | Avg Losses >> G/D 0.9546/0.8957 [D-Real: 0.8074 D-Fake: 0.4687]
Epoch 002 | Avg Losses >> G/D 0.9571/1.0841 [D-Real: 0.6346 D-Fake: 0.4155]
Epoch ...
Epoch 100 | Avg Losses >> G/D 0.8622/1.2878 [D-Real: 0.5488 D-Fake: 0.4518] 

在 Google Colab 上使用 GPU,我们在前面的代码块中实现的训练过程应该在一个小时内完成。(如果你有最新和强大的 CPU 和 GPU,甚至可能更快。)模型训练完成后,通常有助于绘制判别器和生成器的损失,分析两个子网络的行为并评估它们是否收敛。

还有助于绘制由判别器在每次迭代中计算的真实和假例子批次的平均概率。我们期望这些概率在 0.5 左右,这意味着判别器不能自信地区分真实和假的图像:

>>> import itertools
>>> fig = plt.figure(figsize=(16, 6))
>>> ## Plotting the losses
>>> ax = fig.add_subplot(1, 2, 1)
>>> plt.plot(all_g_losses, label='Generator loss')
>>> half_d_losses = [all_d_loss/2 for all_d_loss in all_d_losses]
>>> plt.plot(half_d_losses, label='Discriminator loss')
>>> plt.legend(fontsize=20)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Loss', size=15)
>>> 
>>> ## Plotting the outputs of the discriminator
>>> ax = fig.add_subplot(1, 2, 2)
>>> plt.plot(all_d_real, label=r'Real: $D(\mathbf{x})$')
>>> plt.plot(all_d_fake, label=r'Fake: $D(G(\mathbf{z}))$')
>>> plt.legend(fontsize=20)
>>> ax.set_xlabel('Iteration', size=15)
>>> ax.set_ylabel('Discriminator output', size=15)
>>> plt.show() 

图 17.10 显示了结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.10:判别器的性能

正如您在前面的判别器输出中所见,训练早期,判别器能够快速学习准确区分真实和假例子;也就是说,假例子的概率接近 0,真实例子的概率接近 1。这是因为假例子与真实例子完全不同,因此很容易区分真伪。随着训练的进展,生成器将变得更擅长合成逼真图像,这将导致真实和假例子的概率都接近 0.5。

此外,我们还可以看到生成器输出,即合成图像在训练过程中的变化。在接下来的代码中,我们将可视化生成器为一些 epochs 选择生成的一些图像。

>>> selected_epochs = [1, 2, 4, 10, 50, 100]
>>> fig = plt.figure(figsize=(10, 14))
>>> for i,e in enumerate(selected_epochs):
...     for j in range(5):
...         ax = fig.add_subplot(6, 5, i*5+j+1)
...         ax.set_xticks([])
...         ax.set_yticks([])
...         if j == 0:
...             ax.text(
...                 -0.06, 0.5, f'Epoch {e}',
...                 rotation=90, size=18, color='red',
...                 horizontalalignment='right',
...                 verticalalignment='center',
...                 transform=ax.transAxes
...             )
...         
...         image = epoch_samples[e-1][j]
...         ax.imshow(image, cmap='gray_r')
...     
>>> plt.show() 

图 17.11 展示了生成的图像:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.11:生成器生成的图像

正如您从 图 17.11 中可以看到的那样,随着训练的进行,生成器网络生成的图像变得越来越逼真。然而,即使经过 100 个 epochs,生成的图像仍然与 MNIST 数据集中的手写数字非常不同。

在本节中,我们设计了一个非常简单的 GAN 模型,生成器和判别器仅有一个完全连接的隐藏层。在 MNIST 数据集上训练 GAN 模型后,我们能够获得有希望的,尽管还不完全满意的新手写数字结果。

正如我们在《第十四章》“使用深度卷积神经网络对图像进行分类”中学到的,具有卷积层的 NN 体系结构在图像分类时比全连接层具有几个优势。在类似的情况下,将卷积层添加到我们的 GAN 模型中以处理图像数据可能会改善结果。在下一节中,我们将实现一个深度卷积 GAN (DCGAN),它将使用卷积层来构建生成器和鉴别器网络。

使用卷积和 Wasserstein GAN 来改善合成图像的质量

在这一节中,我们将实现一个 DCGAN,这将使我们能够提高我们在前面 GAN 示例中看到的性能。此外,我们还将简要讨论一种额外的关键技术,Wasserstein GAN (WGAN)。

在本节中,我们将涵盖以下技术:

  • 转置卷积

  • 批量归一化(BatchNorm)

  • WGAN

DCGAN 是由A. RadfordL. MetzS. Chintala在他们的文章《无监督表示学习与深度卷积生成对抗网络》中提出的,该文章可以在arxiv.org/pdf/1511.06434.pdf免费获取。在这篇文章中,研究人员建议为生成器和鉴别器网络都使用卷积层。从一个随机向量z开始,DCGAN 首先使用全连接层将z投影到一个新的向量中,使其大小适当,以便可以将其重新塑造为空间卷积表示(h×w×c),这个表示比输出图像大小要小。然后,一系列卷积层(称为转置卷积)被用来将特征图上采样到所需的输出图像大小。

转置卷积

在《第十四章》中,你学习了在一维和二维空间中的卷积操作。特别是,我们看到了如何通过选择填充和步幅来改变输出特征图。虽然卷积操作通常用于对特征空间进行下采样(例如,通过将步幅设置为 2,或在卷积层后添加池化层),而转置卷积操作通常用于对特征空间进行上采样。

要理解转置卷积操作,让我们进行一个简单的思想实验。假设我们有一个大小为n×n的输入特征图。然后,我们对这个n×n的输入应用一个带有特定填充和步幅参数的 2D 卷积操作,得到一个大小为m×m的输出特征图。现在的问题是,我们如何可以应用另一个卷积操作来从这个m×m的输出特征图中获得一个具有初始维度n×n的特征图,同时保持输入和输出之间的连接模式?请注意,只恢复了n×n输入矩阵的形状,而不是实际的矩阵值。

图 17.12 所示,这就是转置卷积的工作原理:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.12:转置卷积

转置卷积与反卷积

转置卷积也称为分数步幅卷积。在深度学习文献中,另一个常用的术语来指代转置卷积的是反卷积。但是需要注意的是,反卷积最初被定义为对特征映射 x 进行逆卷积操作 f,其权重参数为 w,生成特征映射 x′fw = x′。然后可以定义一个反卷积函数 f^(–1),如 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。但需要注意的是,转置卷积仅仅专注于恢复特征空间的维度,而不是实际的数值。

使用转置卷积对特征映射进行上采样是通过在输入特征映射的元素之间插入 0 来实现的。图 17.13 展示了将转置卷积应用于一个大小为 4×4 的输入的示例,步幅为 2×2,卷积核大小为 2×2。中间的 9×9 矩阵显示了在输入特征映射中插入这样的 0 后的结果。然后,使用 2×2 卷积核和步幅为 1 进行普通卷积将产生一个大小为 8×8 的输出。我们可以通过在输出上执行步幅为 2 的常规卷积来验证反向方向,这将产生一个大小为 4×4 的输出特征映射,与原始输入大小相同:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.13:将转置卷积应用于一个 4×4 的输入

图 17.13 概述了转置卷积的工作原理。输入大小、卷积核大小、步幅和填充方式的不同情况会影响输出结果。如果你想了解更多关于这些不同情况的内容,请参考 A Guide to Convolution Arithmetic for Deep Learning,由 Vincent DumoulinFrancesco Visin 撰写,2018 年 (arxiv.org/pdf/1603.07285.pdf.)

批归一化

BatchNorm 是由 Sergey Ioffe 和 Christian Szegedy 在文章 Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift 中于 2015 年提出的,您可以通过 arXiv 访问该文章 arxiv.org/pdf/1502.03167.pdf。BatchNorm 的主要思想之一是对层输入进行归一化,并在训练期间防止其分布的变化,从而加快和改善收敛速度。

BatchNorm 基于其计算出的统计信息来转换一个特征的小批量。假设我们有一个经过卷积层得到的四维张量 Z 的网络预激活特征映射,其形状为 [m×c×h×w],其中 m 是批量大小(即批量大小),h×w 是特征映射的空间维度,c 是通道数。BatchNorm 可以总结为三个步骤:

  1. 计算每个小批次的网络输入的均值和标准差:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    其中 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 都具有大小 c

  2. 对批次中所有示例的网络输入进行标准化:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    其中 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是为了数值稳定性而设定的一个小数(即避免除以零)。

  3. 使用两个可学习参数向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(大小为 c,即通道数)来缩放和偏移标准化的网络输入:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.14 描述了这个过程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.14:批次归一化的过程

在 BatchNorm 的第一步中,计算小批次的均值 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 和标准差 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。这两者都是大小为 c 的向量(其中 c 是通道数)。然后,这些统计数据在 第 2 步 中用于通过 z-score 标准化(标准化)来缩放每个小批次中的示例,得到标准化的网络输入 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。因此,这些网络输入是以均值为中心并具有单位方差的,这通常是基于梯度下降的优化中的一个有用特性。另一方面,总是归一化网络输入,使它们在不同的小批次之间具有相同的属性,这些属性可能是多样的,可能会严重影响神经网络的表示能力。这可以通过考虑特征 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,在进行 sigmoid 激活后到达 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,对接近 0 的值具有线性区域来理解。因此,在第 3 步中,可学习的参数 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,这些参数是大小为 c 的向量(通道数),允许 BatchNorm 控制标准化特征的偏移和扩展。

训练期间计算运行平均值 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 和运行方差 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,这些值与调整后的参数 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 一起用于规范化评估时的测试示例。

为什么 BatchNorm 能帮助优化?

最初,BatchNorm 的开发目的是减少所谓的内部协变量漂移,即由于训练期间更新的网络参数而导致的层激活分布的变化。

举个简单例子来解释,考虑一个固定的批次在第 1 个 epoch 通过网络。我们记录该批次每层的激活。在遍历整个训练数据集并更新模型参数后,我们开始第二个 epoch,之前固定的批次再次通过网络。然后,我们比较第一和第二个 epoch 的层激活。由于网络参数已更改,我们观察到激活也已更改。这种现象称为内部协方差转移,据信会减缓神经网络的训练速度。

然而,2018 年,S. Santurkar, D. Tsipras, A. Ilyas 和 A. Madry 进一步研究了 BatchNorm 如此有效的原因。在他们的研究中,研究人员观察到 BatchNorm 对内部协方差转移的影响微小。基于他们实验的结果,他们假设 BatchNorm 的有效性取决于损失函数表面的平滑度,这使得非凸优化更加健壮。

如果你对了解这些结果更感兴趣,请阅读原始论文How Does Batch Normalization Help Optimization?,可以在papers.nips.cc/paper/7515-how-does-batch-normalization-help-optimization.pdf免费获取。

PyTorch API 提供了一个类nn.BatchNorm2d()(对于 1D 输入是nn.BatchNorm1d()),我们在定义模型时可以将其用作层;它会执行我们描述的所有 BatchNorm 步骤。请注意,更新可学习参数外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的行为取决于模型是否处于训练模式。这些参数仅在训练期间学习,然后在评估期间用于归一化。

实现生成器和鉴别器

到目前为止,我们已经介绍了 DCGAN 模型的主要组成部分,接下来我们将实现它。生成器和鉴别器网络的架构总结如下两个图示。

生成器接受大小为 100 的向量z作为输入。然后,使用nn.ConvTranspose2d()进行一系列转置卷积,直到生成的特征图的空间尺寸达到 28×28。每个转置卷积层在 BatchNorm 和 leaky ReLU 激活函数之后,最后一个仅使用一个输出滤波器以生成灰度图像。每个转置卷积层之后都跟随 BatchNorm 和 leaky ReLU 激活函数,最后一个转置卷积层使用 tanh 激活函数(不使用 BatchNorm)。

生成器的架构(每层后的特征图)如图 17.15所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.15:生成器网络

鉴别器接收大小为 1×28×28 的图像,这些图像通过四个卷积层。前三个卷积层通过增加特征图的通道数同时减少空间维度。每个卷积层后面还跟有 BatchNorm 和泄漏 ReLU 激活函数。最后一个卷积层使用大小为 7×7 的核和单个滤波器,将输出的空间维度减少到 1×1×1。最后,卷积输出通过 sigmoid 函数并压缩为一维:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.16:鉴别器网络

卷积 GAN 的架构设计考虑

请注意,特征图的数量在生成器和鉴别器之间遵循不同的趋势。在生成器中,我们从大量的特征图开始,并随着向最后一层的进展而减少它们。另一方面,在鉴别器中,我们从少量的通道开始,并向最后一层增加它们。这是设计 CNN 时特征图数量和特征图空间尺寸按相反顺序的重要点。当特征图的空间尺寸增加时,特征图的数量减少,反之亦然。

此外,请注意,通常不建议在跟随 BatchNorm 层的层中使用偏置单元。在这种情况下,使用偏置单元将是多余的,因为 BatchNorm 已经有一个偏移参数,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。您可以通过在nn.ConvTranspose2dnn.Conv2d中设置bias=False来省略给定层的偏置单元。

用于创建生成器和鉴别器网络类的辅助函数代码如下:

>>> def make_generator_network(input_size, n_filters):
...     model = nn.Sequential(
...         nn.ConvTranspose2d(input_size, n_filters*4, 4,
...                            1, 0, bias=False),
...         nn.BatchNorm2d(n_filters*4),
...         nn.LeakyReLU(0.2),
...         nn.ConvTranspose2d(n_filters*4, n_filters*2,
...                            3, 2, 1, bias=False),
...         nn.BatchNorm2d(n_filters*2),
...         nn.LeakyReLU(0.2),
...         nn.ConvTranspose2d(n_filters*2, n_filters,
...                            4, 2, 1, bias=False),
...         nn.BatchNorm2d(n_filters),
...         nn.LeakyReLU(0.2),
...         nn.ConvTranspose2d(n_filters, 1, 4, 2, 1,
...                            bias=False),
...         nn.Tanh()
...     )
...     return model
>>> 
>>> class Discriminator(nn.Module):
...     def __init__(self, n_filters):
...         super().__init__()
...         self.network = nn.Sequential(
...             nn.Conv2d(1, n_filters, 4, 2, 1, bias=False),
...             nn.LeakyReLU(0.2),
...             nn.Conv2d(n_filters, n_filters*2,
...                       4, 2, 1, bias=False),
...             nn.BatchNorm2d(n_filters * 2),
...             nn.LeakyReLU(0.2),
...             nn.Conv2d(n_filters*2, n_filters*4,
...                       3, 2, 1, bias=False),
...             nn.BatchNorm2d(n_filters*4),
...             nn.LeakyReLU(0.2),
...             nn.Conv2d(n_filters*4, 1, 4, 1, 0, bias=False),
...             nn.Sigmoid()
...         )
... 
...     def forward(self, input):
...         output = self.network(input)
...         return output.view(-1, 1).squeeze(0) 

借助辅助函数和类,您可以使用我们在实现简单全连接 GAN 时初始化的相同 MNIST 数据集对象构建 DCGAN 模型并训练它。我们可以使用辅助函数创建生成器网络并打印其架构如下:

>>> z_size = 100
>>> image_size = (28, 28)
>>> n_filters = 32
>>> gen_model = make_generator_network(z_size, n_filters).to(device)
>>> print(gen_model)
Sequential(
  (0): ConvTranspose2d(100, 128, kernel_size=(4, 4), stride=(1, 1), bias=False)
  (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (2): LeakyReLU(negative_slope=0.2)
  (3): ConvTranspose2d(128, 64, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
  (4): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (5): LeakyReLU(negative_slope=0.2)
  (6): ConvTranspose2d(64, 32, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
  (7): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (8): LeakyReLU(negative_slope=0.2)
  (9): ConvTranspose2d(32, 1, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
  (10): Tanh()
) 

类似地,我们可以生成鉴别器网络并查看其架构:

>>> disc_model = Discriminator(n_filters).to(device)
>>> print(disc_model)
Discriminator(
  (network): Sequential(
    (0): Conv2d(1, 32, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
    (1): LeakyReLU(negative_slope=0.2)
    (2): Conv2d(32, 64, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
    (3): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (4): LeakyReLU(negative_slope=0.2)
    (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
    (6): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (7): LeakyReLU(negative_slope=0.2)
    (8): Conv2d(128, 1, kernel_size=(4, 4), stride=(1, 1), bias=False)
    (9): Sigmoid()
  )
) 

另外,我们可以像在训练 GAN 模型小节中一样使用相同的损失函数和优化器:

>>> loss_fn = nn.BCELoss()
>>> g_optimizer = torch.optim.Adam(gen_model.parameters(), 0.0003)
>>> d_optimizer = torch.optim.Adam(disc_model.parameters(), 0.0002) 

我们将对训练过程进行几处小修改。用于生成随机输入的create_noise()函数必须更改为输出四维张量而不是向量:

>>> def create_noise(batch_size, z_size, mode_z):
...     if mode_z == 'uniform':
...         input_z = torch.rand(batch_size, z_size, 1, 1)*2 - 1
...     elif mode_z == 'normal':
...         input_z = torch.randn(batch_size, z_size, 1, 1)
...     return input_z 

用于训练鉴别器的d_train()函数不需要重新调整输入图像:

>>> def d_train(x):
...     disc_model.zero_grad()
...     # Train discriminator with a real batch
...     batch_size = x.size(0)
...     x = x.to(device)
...     d_labels_real = torch.ones(batch_size, 1, device=device)
...     d_proba_real = disc_model(x)
...     d_loss_real = loss_fn(d_proba_real, d_labels_real)
...     # Train discriminator on a fake batch
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     d_proba_fake = disc_model(g_output)
...     d_labels_fake = torch.zeros(batch_size, 1, device=device)
...     d_loss_fake = loss_fn(d_proba_fake, d_labels_fake)
...     # gradient backprop & optimize ONLY D's parameters
...     d_loss = d_loss_real + d_loss_fake
...     d_loss.backward()
...     d_optimizer.step()
...     return d_loss.data.item(), d_proba_real.detach(), \
...            d_proba_fake.detach() 

接下来,我们将在 100 个时期内交替训练生成器和鉴别器。每个时期结束后,我们将使用当前生成器模型调用create_samples()函数从固定噪声输入生成一些示例。代码如下:

>>> fixed_z = create_noise(batch_size, z_size, mode_z).to(device)
>>> epoch_samples = []
>>> torch.manual_seed(1)
>>> for epoch in range(1, num_epochs+1):
...     gen_model.train()
...     for i, (x, _) in enumerate(mnist_dl):
...         d_loss, d_proba_real, d_proba_fake = d_train(x)
...         d_losses.append(d_loss)
...         g_losses.append(g_train(x))
...     print(f'Epoch {epoch:03d} | Avg Losses >>'
...           f' G/D {torch.FloatTensor(g_losses).mean():.4f}'
...           f'/{torch.FloatTensor(d_losses).mean():.4f}')
...     gen_model.eval()
...     epoch_samples.append(
...         create_samples(
...             gen_model, fixed_z
...         ).detach().cpu().numpy()
...     )
Epoch 001 | Avg Losses >> G/D 4.7016/0.1035
Epoch 002 | Avg Losses >> G/D 5.9341/0.0438
...
Epoch 099 | Avg Losses >> G/D 4.3753/0.1360
Epoch 100 | Avg Losses >> G/D 4.4914/0.1120 

最后,让我们在一些时期可视化保存的示例,以查看模型的学习情况及合成示例的质量如何随学习过程变化:

>>> selected_epochs = [1, 2, 4, 10, 50, 100]
>>> fig = plt.figure(figsize=(10, 14))
>>> for i,e in enumerate(selected_epochs):
...     for j in range(5):
...         ax = fig.add_subplot(6, 5, i*5+j+1)
...         ax.set_xticks([])
...         ax.set_yticks([])
...         if j == 0:
...             ax.text(-0.06, 0.5,  f'Epoch {e}',
...                     rotation=90, size=18, color='red',
...                     horizontalalignment='right',
...                     verticalalignment='center',
...                     transform=ax.transAxes)
...         
...         image = epoch_samples[e-1][j]
...         ax.imshow(image, cmap='gray_r')
>>> plt.show() 

图 17.17显示了结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.17:DCGAN 生成的图像

我们使用与普通 GAN 部分相同的代码来可视化结果。比较新的示例表明,DCGAN 能够生成质量更高的图像。

你可能想知道我们如何评估 GAN 生成器的结果。最简单的方法是视觉评估,它涉及在目标域和项目目标的背景下评估合成图像的质量。此外,已经提出了几种更复杂的评估方法,这些方法不太主观,并且不受领域知识的限制。有关详细调查,请参阅GAN 评估指标的优缺点:新发展arxiv.org/abs/2103.09396)。该论文总结了生成器评估为定性和定量措施。

有一个理论论点认为,训练生成器应该致力于最小化真实数据观察到的分布与合成示例观察到的分布之间的差异。因此,当使用交叉熵作为损失函数时,我们当前的架构性能不会非常好。

在下一小节中,我们将介绍 WGAN,它使用基于所谓的 Wasserstein-1(或地球运动者)距离的修改损失函数,用于改进训练性能。

两个分布之间的差异度量

我们首先会看到不同的度量方法来计算两个分布之间的差异。然后,我们将看到这些方法中哪些已经嵌入到原始 GAN 模型中。最后,通过在 GAN 中切换这种度量,我们将实现 WGAN 的实现。

正如本章开头提到的,生成模型的目标是学习如何合成具有与训练数据集分布相同分布的新样本。让P(x)和Q(x)代表随机变量x的分布,如下图所示。

首先,让我们看一些测量两个分布PQ之间差异的方法,如图 17.18所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.18:测量分布PQ之间差异的方法

总变差(TV)度量中使用的上确界函数sup(S),指的是大于S中所有元素的最小值。换句话说,sup(S)是S的最小上界。相反,用于 EM 距离中的下确界函数inf(S),指的是小于S中所有元素的最大值(最大下界)。

让我们通过简单的话语来了解这些度量试图实现什么:

  • 第一个是总变差(TV)距离,它测量每个点处两个分布之间的最大差异。

  • EM 距离可以解释为将一个分布转换为另一个分布所需的最小工作量。在 EM 距离中,infimum 函数取自外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,这是所有边缘为PQ的联合分布的集合。然后,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是一个转移计划,指示我们如何将地球从位置u转移到v,在进行这些转移后维持有效的分布约束条件。计算 EM 距离本身就是一个优化问题,即找到最优的转移计划外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • Kullback-LeiblerKL)和Jensen-ShannonJS)散度测量来自信息论领域。请注意,KL 散度不是对称的,即,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,相比之下,JS 散度是对称的。

图 17.18中提供的不相似度方程对应于连续分布,但可以扩展到离散情况。一个计算两个简单离散分布的不同不相似度测量的示例如图 17.19所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.19:计算不同不相似度测量的示例

请注意,在 EM 距离的情况下,对于这个简单的例子,我们可以看到在x=2 处,Q(x)具有超过外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的额外值,而在其他两个x的值则低于 1/3。因此,在这个简单的例子中,将额外值从x=2 转移到x=1 和x=3 会产生最小的工作量,如图 17.19所示。然而,在更复杂的情况下,这可能是不可行的。

KL 散度与交叉熵之间的关系

KL 散度,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,测量分布P相对于参考分布Q的相对熵。KL 散度的表述可以扩展为:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此外,对于离散分布,KL 散度可以写成:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以类似地扩展为:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

基于扩展的表述(无论是离散还是连续),KL 散度被视为PQ之间的交叉熵(上述方程的第一项)减去P的(自)熵(第二项),即,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,回到我们对 GAN 的讨论,让我们看看这些不同的距离度量与 GAN 的损失函数之间的关系。可以在数学上证明,在原始 GAN 中的损失函数确实最小化了真实和虚假示例分布之间的 JS 散度。但是,正如马丁·阿尔乔夫斯基及其同事在一篇文章中讨论的那样(《Wasserstein 生成对抗网络》,proceedings.mlr.press/v70/arjovsky17a/arjovsky17a.pdf),JS 散度在训练 GAN 模型时存在问题,因此为了改善训练,研究人员提出使用 EM 距离作为衡量真实和虚假示例分布不相似性的度量。

使用 EM 距离的优势是什么?

要回答这个问题,我们可以考虑马丁·阿尔乔夫斯基及其同事在之前提到的文章中给出的一个例子。简单来说,假设我们有两个分布,PQ,它们是两条平行线。一条线固定在x = 0 处,另一条线可以沿着x轴移动,但最初位于外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,其中外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以证明 KL、TV 和 JS 差异度量分别为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。这些差异度量都不是参数外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的函数,因此不能相对于外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传进行微分,以使分布PQ相似。另一方面,EM 距离是外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,其相对于外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的梯度存在并可以将Q推向P

现在,让我们专注于如何使用 EM 距离来训练 GAN 模型。假设P[r]是真实示例的分布,P[g]表示虚假(生成的)示例的分布。P[r]和P[g]在 EM 距离方程中替代PQ。正如之前提到的,计算 EM 距离本身就是一个优化问题;因此,这在 GAN 训练循环的每次迭代中重复计算变得计算上难以处理。幸运的是,EM 距离的计算可以通过称为Kantorovich-Rubinstein 对偶的定理简化,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里,最高值取自所有1-Lipschitz连续函数,表示为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Lipschitz continuity

基于 1-Lipschitz 连续性,函数f必须满足以下性质:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此外,一个实函数,f:RR,满足以下性质

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

被称为K-Lipschitz 连续

在 GAN 实践中使用 EM 距离

现在的问题是,我们如何找到一个 1-Lipschitz 连续函数来计算 GAN 中真实(P[r])和虚假(P[g])输出分布之间的 Wasserstein 距离?虽然 WGAN 方法背后的理论概念乍看起来很复杂,但对于这个问题的答案比看起来要简单得多。回想一下我们将深度神经网络视为通用函数逼近器的观点。这意味着我们可以简单地训练一个神经网络模型来近似 Wasserstein 距离函数。正如你在前一节中看到的那样,简单的 GAN 使用的鉴别器是一个分类器。对于 WGAN,鉴别器可以改变行为作为一个评论家,返回一个标量分数而不是概率值。我们可以将这个分数解释为输入图像的真实性(就像艺术评论家在画廊中为艺术品评分一样)。

要使用 Wasserstein 距离训练 GAN,定义鉴别器D和生成器G的损失如下。评论家(即鉴别器网络)返回其对于一批真实图像示例和合成示例的输出。我们使用D(x)和D(G(z))来表示。

然后,可以定义以下损失项:

  • 鉴别器损失的实部:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 鉴别器损失的虚假部分:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 生成器的损失:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这将是关于 WGAN 的所有内容,除非我们需要确保评论家函数的 1-Lipschitz 性质在训练过程中得到保持。为此,WGAN 论文建议将权重夹在一个小范围内,例如[–0.01, 0.01]。

梯度惩罚

在 Arjovsky 和同事的论文中,建议对鉴别器(或评论家)的 1-Lipschitz 性质进行权重修剪。然而,在另一篇名为Ishaan Gulrajani及同事的 2017 年的文章Improved Training of Wasserstein GANs中,可以免费获取arxiv.org/pdf/1704.00028.pdf,Ishaan Gulrajani 及同事指出,修剪权重可能导致梯度爆炸和消失。此外,权重修剪也可能导致能力未被充分利用,这意味着评论家网络仅限于学习一些简单的函数,而不是更复杂的函数。因此,Ishaan Gulrajani 及同事提出了梯度惩罚GP)作为替代解决方案。其结果是带有梯度惩罚的WGANWGAN-GP)。

在每次迭代中添加的 GP 的过程可以总结如下步骤:

  1. 对于给定批次中每对真实和虚假示例 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,选择从均匀分布中随机采样的随机数 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,即 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  2. 计算真实和虚假例子之间的插值:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,得到一批插值例子。

  3. 计算所有插值例子的鉴别器(评论者)输出,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  4. 计算评论者关于每个插值例子的输出的梯度,即外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  5. 计算 GP 如下:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

那么鉴别器的总损失如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是一个可调参数。

实施 WGAN-GP 来训练 DCGAN 模型

我们已经定义了创建 DCGAN 生成器和鉴别器网络的辅助函数和类(make_generator_network()Discriminator())。建议在 WGAN 中使用层归一化而不是批归一化。层归一化在特征维度上归一化输入,而不是在批次维度上。

>>> def make_generator_network_wgan(input_size, n_filters):
...     model = nn.Sequential(
...         nn.ConvTranspose2d(input_size, n_filters*4, 4,
...                            1, 0, bias=False),
...         nn.InstanceNorm2d(n_filters*4),
...         nn.LeakyReLU(0.2),
... 
...         nn.ConvTranspose2d(n_filters*4, n_filters*2,
...                            3, 2, 1, bias=False),
...         nn.InstanceNorm2d(n_filters*2),
...         nn.LeakyReLU(0.2),
... 
...         nn.ConvTranspose2d(n_filters*2, n_filters, 4,
...                            2, 1, bias=False),
...         nn.InstanceNorm2d(n_filters),
...         nn.LeakyReLU(0.2),
... 
...         nn.ConvTranspose2d(n_filters, 1, 4, 2, 1, bias=False),
...         nn.Tanh()
...     )
...     return model
>>> 
>>> class DiscriminatorWGAN(nn.Module):
...     def __init__(self, n_filters):
...         super().__init__()
...         self.network = nn.Sequential(
...             nn.Conv2d(1, n_filters, 4, 2, 1, bias=False),
...             nn.LeakyReLU(0.2),
... 
...             nn.Conv2d(n_filters, n_filters*2, 4, 2, 1,
...                       bias=False),
...             nn.InstanceNorm2d(n_filters * 2),
...             nn.LeakyReLU(0.2),
... 
...             nn.Conv2d(n_filters*2, n_filters*4, 3, 2, 1,
...                       bias=False),
...             nn.InstanceNorm2d(n_filters*4),
...             nn.LeakyReLU(0.2),
... 
...             nn.Conv2d(n_filters*4, 1, 4, 1, 0, bias=False),
...             nn.Sigmoid()
...     )
... 
...     def forward(self, input):
...         output = self.network(input)
...         return output.view(-1, 1).squeeze(0) 

现在我们可以按以下方式初始化网络及其优化器:

>>> gen_model = make_generator_network_wgan(
...     z_size, n_filters
... ).to(device)
>>> disc_model = DiscriminatorWGAN(n_filters).to(device)
>>> g_optimizer = torch.optim.Adam(gen_model.parameters(), 0.0002)
>>> d_optimizer = torch.optim.Adam(disc_model.parameters(), 0.0002) 

接下来,我们将定义计算 GP 组件的函数如下:

>>> from torch.autograd import grad as torch_grad
>>> def gradient_penalty(real_data, generated_data):
...     batch_size = real_data.size(0)
... 
...     # Calculate interpolation
...     alpha = torch.rand(real_data.shape[0], 1, 1, 1,
...                        requires_grad=True, device=device)
...     interpolated = alpha * real_data + \
...                    (1 - alpha) * generated_data
... 
...     # Calculate probability of interpolated examples
...     proba_interpolated = disc_model(interpolated)
... 
...     # Calculate gradients of probabilities
...     gradients = torch_grad(
...         outputs=proba_interpolated, inputs=interpolated,
...         grad_outputs=torch.ones(proba_interpolated.size(),
...                                 device=device),
...         create_graph=True, retain_graph=True
...     )[0]
... 
...     gradients = gradients.view(batch_size, -1)
...     gradients_norm = gradients.norm(2, dim=1)
...     return lambda_gp * ((gradients_norm - 1)**2).mean() 

WGAN 版本的鉴别器和生成器训练函数如下:

>>> def d_train_wgan(x):
...     disc_model.zero_grad()
... 
...     batch_size = x.size(0)
...     x = x.to(device)
... 
...     # Calculate probabilities on real and generated data
...     d_real = disc_model(x)
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     d_generated = disc_model(g_output)
...     d_loss = d_generated.mean() - d_real.mean() + \
...              gradient_penalty(x.data, g_output.data)
...     d_loss.backward()
...     d_optimizer.step()
...     return d_loss.data.item()
>>> 
>>> def g_train_wgan(x):
...     gen_model.zero_grad()
...     
...     batch_size = x.size(0)
...     input_z = create_noise(batch_size, z_size, mode_z).to(device)
...     g_output = gen_model(input_z)
...     
...     d_generated = disc_model(g_output)
...     g_loss = -d_generated.mean()
... 
...     # gradient backprop & optimize ONLY G's parameters
...     g_loss.backward()
...     g_optimizer.step()
...     return g_loss.data.item() 

然后我们将模型训练 100 个 epochs,并记录固定噪声输入的生成器输出:

>>> epoch_samples_wgan = []
>>> lambda_gp = 10.0
>>> num_epochs = 100
>>> torch.manual_seed(1)
>>> critic_iterations = 5
>>> for epoch in range(1, num_epochs+1):
...     gen_model.train()
...     d_losses, g_losses = [], []
...     for i, (x, _) in enumerate(mnist_dl):
...         for _ in range(critic_iterations):
...             d_loss = d_train_wgan(x)
...         d_losses.append(d_loss)
...         g_losses.append(g_train_wgan(x))
...     
...     print(f'Epoch {epoch:03d} | D Loss >>'
...           f' {torch.FloatTensor(d_losses).mean():.4f}')
...     gen_model.eval()
...     epoch_samples_wgan.append(
...         create_samples(
...             gen_model, fixed_z
...         ).detach().cpu().numpy()
...     ) 

最后,让我们在一些 epochs 时可视化保存的例子,看看 WGAN 模型如何学习以及合成例子的质量在学习过程中如何变化。下图显示了结果,显示出比 DCGAN 模型生成的图像质量稍好:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.20:使用 WGAN 生成的图像

模式崩溃

由于 GAN 模型的对抗性质,训练它们非常困难。训练 GAN 失败的一个常见原因是生成器陷入了一个小的子空间并学会生成类似的样本。这被称为模式崩溃,并且在图 17.21中有一个例子。

这个图中的合成例子并非精选。这表明生成器未能学习整个数据分布,而是采取了一种懒惰的方法,集中在一个子空间上:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.21:模式崩溃的示例

除了我们之前看到的梯度消失和梯度爆炸问题外,还有一些其他因素也会使得训练 GAN 模型变得困难(事实上,这是一门艺术)。以下是一些来自 GAN 艺术家的建议技巧。

一种方法称为 小批量区分,它基于一个事实:只有真实或虚假示例组成的批次被分别馈送给鉴别器。在小批量区分中,我们让鉴别器跨这些批次比较示例,以确定一个批次是真实还是虚假。如果模型遭遇模式崩溃,只有真实示例组成的批次的多样性很可能比虚假批次的多样性更高。

另一种常用于稳定 GAN 训练的技术是 特征匹配。在特征匹配中,我们通过向生成器的目标函数添加一个额外项,该项通过鉴别器的中间表示(特征图)来最小化原始图像与合成图像之间的差异。我们鼓励您阅读 王廷春 及其同事撰写的原始文章 High Resolution Image Synthesis and Semantic Manipulation with Conditional GANs,可在 arxiv.org/pdf/1711.11585.pdf 免费获取。

在训练过程中,GAN 模型可能会陷入多个模式中,并在它们之间跳跃。为了避免这种行为,您可以存储一些旧样本,并将它们馈送给鉴别器,以防止生成器重新访问先前的模式。这种技术被称为 经验回放。此外,您还可以使用不同的随机种子训练多个 GAN 模型,使它们的组合覆盖数据分布的更大部分,而不是任何单个模型能够覆盖的部分。

其他 GAN 应用

在本章中,我们主要关注使用 GAN 生成示例,并探讨了一些技巧和方法来提高合成输出的质量。GAN 的应用正在迅速扩展,包括计算机视觉、机器学习甚至其他科学和工程领域。您可以在 github.com/hindupuravinash/the-gan-zoo 找到一个不错的 GAN 模型和应用领域的列表。

值得一提的是,我们以无监督的方式讨论了 GAN;也就是说,在本章涵盖的模型中没有使用类标签信息。然而,GAN 方法可以推广到半监督和监督任务。例如,Mehdi MirzaSimon Osindero 在论文《Conditional Generative Adversarial Nets》(2014)中提出的条件 GANcGAN)使用类标签信息,并学习在给定标签条件下合成新图像,即,arxiv.org/pdf/1411.1784.pdf—应用于 MNIST。这使我们能够有选择地生成 0-9 范围内的不同数字。此外,条件 GAN 还允许进行图像到图像的转换,即学习如何将给定域中的图像转换到另一个域中。在这个背景下,一个有趣的工作是 Pix2Pix 算法,由 Philip Isola 和同事在 2018 年的论文《Image-to-Image Translation with Conditional Adversarial Networks》中发布(arxiv.org/pdf/1611.07004.pdf)。值得一提的是,在 Pix2Pix 算法中,鉴别器为图像中多个补丁提供真/假预测,而不是整个图像的单一预测。

CycleGAN 是另一个建立在 cGAN 之上的有趣的 GAN 模型,也用于图像到图像的转换。然而,请注意,在 CycleGAN 中,来自两个域的训练示例是不配对的,这意味着输入和输出之间没有一对一的对应关系。例如,使用 CycleGAN,我们可以将夏天拍摄的图片改变为冬天的景色。在 Jun-Yan Zhu 和同事于 2020 年的论文《Unpaired Image-to-Image Translation Using Cycle-Consistent Adversarial Networks》中展示了一个令人印象深刻的例子,展示了将马转换为斑马的过程(arxiv.org/pdf/1703.10593.pdf)。

总结

在本章中,您首先学习了深度学习中生成模型及其总体目标:合成新数据。然后,我们讨论了 GAN 模型如何使用生成器网络和鉴别器网络,在对抗训练设置中相互竞争以改进彼此。接下来,我们实现了一个简单的 GAN 模型,仅使用全连接层作为生成器和鉴别器。

我们还讨论了如何改进 GAN 模型。首先,您看到了 DCGAN,它使用深度卷积网络作为生成器和鉴别器。在此过程中,您还学习了两个新概念:转置卷积(用于上采样特征映射的空间维度)和 BatchNorm(用于在训练过程中改善收敛性)。

然后,我们看了一个 WGAN,它使用 EM 距离来衡量真实样本和假样本分布之间的距离。最后,我们讨论了带 GP 的 WGAN,以维持 1-Lipschitz 属性,而不是修剪权重。

在接下来的章节中,我们将探讨图神经网络。之前,我们专注于表格和图像数据集。相比之下,图神经网络是为图结构数据设计的,这使我们能够处理社会科学、工程学和生物学中普遍存在的数据集。图结构数据的流行示例包括社交网络图和由共价键连接的原子组成的分子。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作区,每月进行Ask me Anything与作者的会话:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第十八章:用于捕获图结构数据中依赖关系的图神经网络

在本章中,我们将介绍一类深度学习模型,它们操作的是图数据,即图神经网络GNNs)。近年来,GNNs 已经迅速发展。根据 2021 年的AI 现状报告www.stateof.ai/2021-report-launch.html),GNNs 已经从一种小众领域发展成为 AI 研究中最热门的领域之一。

GNNs 已被应用于多个领域,包括以下几个方面:

虽然我们无法涵盖这个快速发展空间中的每一个新想法,但我们将提供一个理解 GNNs 如何运作及如何实施它们的基础。此外,我们还将介绍PyTorch Geometric库,该库提供了管理图数据用于深度学习的资源,以及许多不同种类的图层实现,供您在深度学习模型中使用。

本章将涵盖的主题如下:

  • 图数据介绍及其如何在深度神经网络中表示和使用

  • 图卷积的解释,这是常见图神经网络(GNNs)的主要构建模块

  • 一个教程展示如何使用 PyTorch Geometric 实现用于分子属性预测的 GNNs

  • GNN 领域尖端方法概述

图数据介绍

广义来说,图表达了我们描述和捕捉数据关系的某种方式。图是一种非线性和抽象的数据结构。由于图是抽象对象,因此需要定义具体表示形式,以便对图进行操作。此外,图可以定义具有某些属性,这可能需要不同的表示形式。图 18.1总结了常见类型的图表,我们将在接下来的小节中详细讨论它们:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.1:常见类型的图表

无向图

无向图由通过边连接的节点(在图论中通常称为顶点)组成,其中节点的顺序及其连接的顺序并不重要。图 18.2示意了两个典型的无向图示例,一个是朋友关系图,另一个是由化学键连接的原子组成的化学分子图(我们将在后续章节中详细讨论此类分子图):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.2:两个无向图示例

可以表示为无向图的其他常见数据示例包括图像、蛋白质相互作用网络和点云。

从数学上讲,无向图G是一个二元组(VE),其中V是图的节点集合,E是构成节点对的边的集合。然后,可以将图编码为|V|×|V|的邻接矩阵 A。矩阵A中的每个元素x[ij]要么是 1,要么是 0,其中 1 表示节点ij之间有边(反之,0 表示没有边)。由于图是无向的,A的另一个特性是x[ij] = x[ji]。

有向图

有向图与前一节讨论的无向图相比,通过有向边连接节点。在数学上,它们的定义方式与无向图相同,除了边集E是有序对的集合。因此,矩阵A的元素x[ij]不一定等于x[ji]。

有向图的一个示例是引用网络,其中节点是出版物,从一个节点到另一个节点的边是指一篇给定论文引用的其他论文的节点。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.3:一个有向图的示例

标记图

我们感兴趣的许多图形都与每个节点和边相关的附加信息有关。例如,如果考虑前面显示的咖啡因分子,分子可以被表示为图形,其中每个节点都是化学元素(例如,O、C、N 或 H 原子),每条边都是连接其两个节点的键的类型(例如,单键或双键)。这些节点和边的特征需要以某种方式进行编码。给定图形G,由节点集和边集元组(VE)定义,我们定义一个|Vf[V]节点特征矩阵X,其中f[V]是每个节点标签向量的长度。对于边标签,我们定义一个|Ef[E]边特征矩阵X[E],其中f[E]是每个边标签向量的长度。

分子是可以表示为标记图的数据的一个很好的例子,在本章中我们将一直使用分子数据。因此,我们将利用这个机会在下一节详细讨论它们的表示。

将分子表示为图形

作为化学概述,分子可以被看作由化学键结合在一起的原子组。有不同的原子对应于不同的化学元素,例如常见的元素包括碳(C)、氧(O)、氮(N)和氢(H)。此外,有不同类型的化学键形成原子之间的连接,例如单键或双键。

我们可以将分子表示为具有节点标签矩阵的无向图,其中每行是相关节点原子类型的 one-hot 编码。此外,还有一条边标签矩阵,其中每行是相关边键类型的 one-hot 编码。为了简化这种表示,有时会隐含氢原子,因为它们的位置可以通过基本化学规则推断出来。考虑之前看到的咖啡因分子,一个具有隐含氢原子图形表示的示例如 图 18.4 所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.4:咖啡因分子的图形表示

理解图卷积

前一节展示了如何表示图数据。下一个逻辑步骤是讨论我们可以有效利用这些表示的工具。

在接下来的小节中,我们将介绍图卷积,它是构建 GNNs 的关键组成部分。在本节中,我们将看到为什么要在图上使用卷积,并讨论我们希望这些卷积具有哪些属性。然后,我们将通过一个实现示例介绍图卷积。

使用图卷积的动机

为了帮助解释图卷积,让我们简要回顾卷积神经网络(CNNs)中如何使用卷积,我们在 第十四章 使用深度卷积神经网络分类图像 中讨论过。在图像的上下文中,我们可以将卷积视为将卷积滤波器在图像上滑动的过程,其中每一步都在滤波器和接受域(它当前所在的图像部分)之间计算加权和。

如在 CNN 章节中讨论的那样,滤波器可以看作是特定特征的检测器。这种特征检测方法对图像非常适用,原因有几点,例如我们可以对图像数据施加以下先验:

  1. 平移不变性:我们可以在图像中任意位置识别特征(例如平移后)。猫可以被识别为猫,无论它位于图像的左上角、右下角还是其他部位。

  2. 局部性:附近的像素是密切相关的。

  3. 层次结构:图像的较大部分通常可以分解为相关较小部分的组合。猫有头和腿;头部有眼睛和鼻子;眼睛有瞳孔和虹膜。

对于对这些先验和 GNNs 所假设的先验有更正式描述感兴趣的读者,可以参考 2019 年文章 理解图神经网络在学习图拓扑中的表示能力,作者是 N. DehmamyA.-L. BarabasiR. Yu (arxiv.org/abs/1907.05008)。

另一个卷积适合处理图像的原因是可训练参数的数量不依赖于输入的维度。例如,你可以在 256×256 或 9×9 的图像上训练一系列 3×3 的卷积滤波器。(然而,如果同一图像以不同分辨率呈现,则感受野和因此提取的特征将不同。对于更高分辨率的图像,我们可能希望选择更大的核或添加额外的层以有效提取有用的特征。)

像图像一样,图也有自然的先验,可以证明卷积方法的合理性。图像数据和图数据都共享局部性先验。但是,我们如何定义局部性是不同的。在图像中,局部性是在二维空间中定义的,而在图中,它是结构上的局部性。直觉上,这意味着距离一个边的节点更有可能相关,而距离五个边的节点不太可能相关。例如,在引文图中,直接引用的出版物,即距离一个边的出版物,更有可能与相似主题的出版物相关,而与多度分离的出版物则不太相关。

图数据的一个严格先验是置换不变性,这意味着节点的排序不会影响输出。这在图 18.5中有所说明,改变图的节点排序不会改变图的结构:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.5:表示同一图的不同邻接矩阵

由于同一图可以用多个邻接矩阵表示,正如图 18.5所示,因此任何图卷积都需要是置换不变的。

对于图来说,卷积方法也是理想的,因为它可以使用固定的参数集处理不同大小的图。这个性质对于图而言可能比图像更为重要。例如,有许多具有固定分辨率的图像数据集,可以使用全连接方法(例如使用多层感知机),正如我们在第十一章从头开始实现多层人工神经网络中所见。相反,大多数图数据集包含不同大小的图。

虽然图像卷积运算符是标准化的,但是图卷积有许多不同种类,并且新图卷积的开发是一个非常活跃的研究领域。我们的重点是提供一般的思路,以便读者可以理解他们希望利用的图神经网络。为此,接下来的小节将展示如何在 PyTorch 中实现基本的图卷积。然后,在下一节中,我们将从头开始在 PyTorch 中构建一个简单的 GNN。

实现基本的图卷积

在本小节中,我们将介绍一个基本的图卷积函数,并看看当它应用于一个图时会发生什么。考虑以下图及其表示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.6: 图的表示

图 18.6 描述了一个无向图,节点标签由 n×n 邻接矩阵 An×f[in] 节点特征矩阵 X 指定,其中唯一的特征是每个节点的颜色的 one-hot 表示—绿色(G)、蓝色(B)或橙色(O)。

图形处理和可视化中最多功能的库之一是 NetworkX,我们将使用它来说明如何从标签矩阵 X 和节点矩阵 A 构建图形。

安装 NetworkX

NetworkX 是一个方便的 Python 库,用于处理和可视化图形。可以通过 pip 安装:

pip install networkx 

我们使用版本 2.6.2 来创建本章中的图形可视化。更多信息,请访问官方网站 networkx.org

使用 NetworkX,我们可以按如下方式构造图形:

>>> import numpy as np
>>> import networkx as nx
>>> G = nx.Graph()
... # Hex codes for colors if we draw graph
>>> blue, orange, green = "#1f77b4", "#ff7f0e", "#2ca02c"
>>> G.add_nodes_from([
...     (1, {"color": blue}),
...     (2, {"color": orange}),
...     (3, {"color": blue}),
...     (4, {"color": green})
... ])
>>> G.add_edges_from([(1,2), (2,3), (1,3), (3,4)])
>>> A = np.asarray(nx.adjacency_matrix(G).todense())
>>> print(A)
[[0 1 1 0]
[1 0 1 0]
[1 1 0 1]
[0 0 1 0]]
>>> def build_graph_color_label_representation(G, mapping_dict):
...     one_hot_idxs = np.array([mapping_dict[v] for v in
...         nx.get_node_attributes(G, 'color').values()])
>>>     one_hot_encoding = np.zeros(
...         (one_hot_idxs.size, len(mapping_dict)))
>>>     one_hot_encoding[
...         np.arange(one_hot_idxs.size), one_hot_idxs] = 1
>>>     return one_hot_encoding
>>> X = build_graph_color_label_representation(
...     G, {green: 0, blue: 1, orange: 2})
>>> print(X)
[[0., 1., 0.],
[0., 0., 1.],
[0., 1., 0.],
[1., 0., 0.]] 

要绘制前述代码构造的图形,我们可以使用以下代码:

>>> color_map = nx.get_node_attributes(G, 'color').values()
>>> nx.draw(G,with_labels=True, node_color=color_map) 

在上述代码示例中,我们首先从 NetworkX 初始化了一个新的 Graph 对象。然后,我们添加了节点 1 到 4,并指定了用于可视化的颜色规范。在添加节点后,我们指定了它们的连接(边)。使用 NetworkX 的 adjacency_matrix 构造函数,我们创建了邻接矩阵 A,并且我们的自定义 build_graph_color_label_representation 函数从我们之前添加到 Graph 对象的信息创建了节点标签矩阵 X

通过图卷积,我们可以将 X 的每一行解释为存储在相应节点上的信息的嵌入。图卷积根据其邻居节点和自身的嵌入更新每个节点的嵌入。对于我们的示例实现,图卷积将采用以下形式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是节点 i 的更新嵌入;W[1] 和 W[2] 是 f[in]×f[out] 的可学习滤波器权重矩阵;b 是长度为 f[out] 的可学习偏置向量。

两个权重矩阵 W[1] 和 W[2] 可以被视为滤波器组,其中每一列都是一个单独的滤波器。请注意,当图数据上的局部性先验成立时,这种滤波器设计是最有效的。如果一个节点的值与另一个节点的值高度相关,而这两个节点之间存在许多边,单个卷积将无法捕捉到这种关系。堆叠卷积将捕捉更远的关系,如图 18.7 所示(为简化起见,我们将偏置设为零):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.7: 从图中捕获关系

图 18.7 中展示的图卷积的设计符合我们对图数据的先验假设,但如何以矩阵形式实现邻居节点之间的求和可能不是很明确。这就是我们利用邻接矩阵 A 的地方。这个卷积的矩阵形式是 XW[1] + AXW[2]。在 NumPy 中,初始化这一层并在前一个图上进行前向传播可以写成如下形式:

>>> f_in, f_out = X.shape[1], 6
>>> W_1 = np.random.rand(f_in, f_out)
>>> W_2 = np.random.rand(f_in, f_out)
>>> h = np.dot(X, W_1)+ np.dot(np.dot(A,X), W_2) 

计算图卷积的前向传播就是这么简单。

最终,我们希望图卷积层通过利用由 A 提供的结构信息(连接性)来更新编码在 X 中的节点信息的表示。有许多潜在的方法可以做到这一点,这在已开发的许多类型的图卷积中体现出来。

要讨论不同的图卷积,通常最好是它们具有一个统一的框架。幸运的是,Justin Gilmer和他的同事在 2017 年的《神经信息传递用于量子化学》中提出了这样一个框架,arxiv.org/abs/1704.01212

在这个消息传递框架中,图中的每个节点都有一个关联的隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,其中 i 是节点在时间步 t 的索引。初始值 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 定义为 X[i],即与节点 i 相关的 X 的行。

每个图卷积可以分为消息传递阶段和节点更新阶段。设 N(i) 为节点 i 的邻居。对于无向图,N(i) 是与节点 i 共享边的节点集合。对于有向图,N(i) 是具有以节点 i 为端点的边的节点集合。消息传递阶段可以表述如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,M[t] 是一个消息函数。在我们的示例层中,我们将此消息函数定义为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。使用更新函数 U[t] 的节点更新阶段为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。在我们的示例层中,此更新为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.8 展示了消息传递的思想并总结了我们实现的卷积:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.8:在图上实现的卷积和消息形式

在下一节中,我们将把这个图卷积层整合到一个在 PyTorch 中实现的 GNN 模型中。

在 PyTorch 中从零开始实现 GNN

上一节重点介绍了理解和实现图卷积操作。在本节中,我们将通过一个基本的图神经网络实现来演示如何从头开始应用这些方法到图形中。如果这种方法看起来复杂,不用担心;GNN 是相对复杂的模型。因此,我们将在后面的章节中介绍 PyTorch Geometric,它提供了工具来简化图神经网络的实现和数据管理。

定义 NodeNetwork 模型

我们将从头开始展示一个 PyTorch 实现的 GNN 的部分。我们将采取自顶向下的方法,从主神经网络模型开始,我们称之为NodeNetwork,然后填充具体细节:

import networkx as nx
import torch
from torch.nn.parameter import Parameter
import numpy as np
import math
import torch.nn.functional as F
class NodeNetwork(torch.nn.Module):
    def __init__(self, input_features):
        super().__init__()
        self.conv_1 = BasicGraphConvolutionLayer (
            input_features, 32)
        self.conv_2 = BasicGraphConvolutionLayer(32, 32)
        self.fc_1 = torch.nn.Linear(32, 16)
        self.out_layer = torch.nn.Linear(16, 2)
    def forward(self, X, A, batch_mat):
        x = F.relu(self.conv_1(X, A))
        x = F.relu(self.conv_2(x, A))
        output = global_sum_pool(x, batch_mat)
        output = self.fc_1(output)
        output = self.out_layer(output)
        return F.softmax(output, dim=1) 

我们刚刚定义的NodeNetwork模型可以总结如下:

  1. 执行两个图卷积(self.conv_1self.conv_2

  2. 通过global_sum_pool汇总所有节点嵌入,稍后我们将定义

  3. 通过两个全连接层(self.fc_1self.out_layer)运行池化嵌入

  4. 通过 softmax 输出类成员概率

网络结构以及每个层所做的可视化总结在Figure 18.9中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.9: 每个神经网络层的可视化

各个方面,如图卷积层和全局池化,将在接下来的小节中讨论。

编码 NodeNetwork 的图卷积层

现在,让我们定义图卷积操作(BasicGraphConvolutionLayer),这在之前的NodeNetwork类中使用过*😗

class BasicGraphConvolutionLayer(torch.nn.Module):
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.W2 = Parameter(torch.rand(
            (in_channels, out_channels), dtype=torch.float32))
        self.W1 = Parameter(torch.rand(
            (in_channels, out_channels), dtype=torch.float32))

        self.bias = Parameter(torch.zeros(
                out_channels, dtype=torch.float32))
    def forward(self, X, A):
        potential_msgs = torch.mm(X, self.W2)
        propagated_msgs = torch.mm(A, potential_msgs)
        root_update = torch.mm(X, self.W1)
        output = propagated_msgs + root_update + self.bias
        return output 

与全连接层和图像卷积层一样,我们添加偏置项,以便调整层输出的线性组合的截距(在应用非线性如 ReLU 之前)。forward()方法实现了前向传播的矩阵形式,我们在前一小节中讨论过,同时加入了一个偏置项。

要尝试BasicGraphConvolutionLayer,让我们将其应用到我们之前在实现基本图卷积节中定义的图和邻接矩阵上:

>>> print('X.shape:', X.shape)X.shape: (4, 3)
>>> print('A.shape:', A.shape)
A.shape: (4, 4)
>>> basiclayer = BasicGraphConvolutionLayer(3, 8)
>>> out = basiclayer(
...     X=torch.tensor(X, dtype=torch.float32),
...     A=torch.tensor(A, dtype=torch.float32)
... )
>>> print('Output shape:', out.shape)
Output shape: torch.Size([4, 8]) 

基于上述代码示例,我们可以看到我们的BasicGraphConvolutionLayer将由三个特征组成的四节点图转换为具有八个特征的表示形式。

添加全局池化层以处理不同大小的图

接下来,我们定义了在NodeNetwork类中使用的global_sum_pool()函数,其中global_sum_pool()实现了一个全局池化层。全局池化层将图的所有节点嵌入聚合为一个固定大小的输出。如图图 18.9所示,global_sum_pool()对图的所有节点嵌入求和。我们注意到,这种全局池化与 CNN 中使用的全局平均池化相对类似,后者在数据通过全连接层之前使用,正如我们在第十四章中看到的,用深度卷积神经网络对图像进行分类

对所有节点嵌入求和会导致信息丢失,因此更好的做法是重新整形数据,但由于图可以具有不同的大小,这是不可行的。全局池化可以使用任何排列不变的函数,例如summaxmean。这里是global_sum_pool()的实现:

def global_sum_pool(X, batch_mat):
    if batch_mat is None or batch_mat.dim() == 1:
        return torch.sum(X, dim=0).unsqueeze(0)
    else:
        return torch.mm(batch_mat, X) 

如果数据没有批处理或批处理大小为一,此函数只是对当前节点嵌入求和。否则,嵌入将与batch_mat相乘,其结构基于图数据的批处理方式。

当数据集中的所有数据具有相同的维度时,批处理数据就像通过堆叠数据添加一个维度一样简单。(附注:在 PyTorch 中默认的批处理函数中调用的函数实际上称为stack。)由于图的大小各不相同,除非使用填充,否则这种方法在处理图数据时是不可行的。然而,在图的大小差异显著时,填充可能效率低下。通常,处理不同大小的图的更好方法是将每个批次视为单个图,其中每个批次中的图是与其余图断开连接的子图。这在图 18.10中有所说明:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.10: 处理不同大小的图的方法

为了更正式地描述图 18.10,假设我们有大小为n[1],…,n[k]的图G[1],…,G[k],每个节点有f个特征。此外,我们还有相应的邻接矩阵A[1],…,A[k]和特征矩阵X[1],…,X[k]。设N为节点的总数,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传s[1] = 0,且对于外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传s[i] = s[i-1] + n[i-1]。如图所示,我们定义了一个具有N×N邻接矩阵A[B]和N×f特征矩阵X[B]的图G[B]。使用 Python 索引表示,A[B][s[i]:s[i]+n[i], s[i]:s[i]+n[i]] = A[i],并且A[B]的其他元素在这些索引集之外都是 0。此外,X[B][s[i]:s[i]+n[i], :] = X[i]。

根据设计,断开连接的节点永远不会在图卷积的同一接收场中。因此,当通过图卷积反向传播G[B]的梯度时,批次中每个图附加的梯度将是独立的。这意味着,如果我们将一组图卷积视为函数f,如果h[B] = f(X[B], A[B])和h[i] = f(X[i], A[i]),那么h[B][s[i]:s[i] + n, :] = h[i]。如果总和全局池从h[B]中提取h[i]的各自向量的总和,并通过完全连接的层传递该向量堆栈,则在整个反向传播过程中将保持批次中每个项目的梯度独立。

这就是global_sum_pool()batch_mat的目的——作为一个图选择掩码,用于保持批次中的图分开。我们可以使用以下代码为大小为n[1], …, n[k]的图生成此掩码:

def get_batch_tensor(graph_sizes):
    starts = [sum(graph_sizes[:idx])
              for idx in range(len(graph_sizes))]
    stops = [starts[idx] + graph_sizes[idx]
             for idx in range(len(graph_sizes))]
    tot_len = sum(graph_sizes)
    batch_size = len(graph_sizes)
    batch_mat = torch.zeros([batch_size, tot_len]).float()
    for idx, starts_and_stops in enumerate(zip(starts, stops)):
        start = starts_and_stops[0]
        stop = starts_and_stops[1]
        batch_mat[idx,start:stop] = 1
    return batch_mat 

因此,给定批次大小bbatch_mat是一个b×N矩阵,其中 batch_mat[i–1, s[i]:s[i] + n[i]] = 1 对于外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,并且在这些索引集之外的元素为 0。以下是构建某些G[B]表示和相应的批次矩阵的整理函数:

# batch is a list of dictionaries each containing
# the representation and label of a graph
def collate_graphs(batch):
    adj_mats = [graph['A'] for graph in batch]
    sizes = [A.size(0) for A in adj_mats]
    tot_size = sum(sizes)
    # create batch matrix
    batch_mat = get_batch_tensor(sizes)
    # combine feature matrices
    feat_mats = torch.cat([graph['X'] for graph in batch], dim=0)
    # combine labels
    labels = torch.cat([graph['y'] for graph in batch], dim=0)
    # combine adjacency matrices
    batch_adj = torch.zeros([tot_size, tot_size], dtype=torch.float32)
    accum = 0
    for adj in adj_mats:
        g_size = adj.shape[0]
        batch_adj[accum:accum+g_size,accum:accum+g_size] = adj
        accum = accum + g_size
    repr_and_label = {'A': batch_adj,
            'X': feat_mats, 'y': labels,
            'batch': batch_mat}
    return repr_and_label 

准备 DataLoader

在本节中,我们将看到前面各小节中的代码如何结合在一起。首先,我们将生成一些图并将它们放入 PyTorch Dataset中。然后,我们将在我们的 GNN 中使用collate函数在DataLoader中使用它。

但在我们定义图之前,让我们实现一个函数来构建一个字典表示,稍后我们将使用它:

def get_graph_dict(G, mapping_dict):
    # Function builds dictionary representation of graph G
    A = torch.from_numpy(
        np.asarray(nx.adjacency_matrix(G).todense())).float()
    # build_graph_color_label_representation()
    # was introduced with the first example graph
    X = torch.from_numpy(
      build_graph_color_label_representation(
               G, mapping_dict)).float()
    # kludge since there is not specific task for this example
    y = torch.tensor([[1,0]]).float()
    return {'A': A, 'X': X, 'y': y, 'batch': None} 

这个函数接受一个 NetworkX 图,并返回一个包含其邻接矩阵A,节点特征矩阵X和一个二进制标签y的字典。由于我们实际上不会在真实任务中训练这个模型,所以我们只是任意设置标签。然后,nx.adjacency_matrix()接受一个 NetworkX 图并返回一个稀疏表示,我们使用todense()将其转换为稠密的np.array形式。

现在我们将构造图,并使用get_graph_dict函数将 NetworkX 图转换为我们的网络可以处理的格式:

>>> # building 4 graphs to treat as a dataset
>>> blue, orange, green = "#1f77b4", "#ff7f0e","#2ca02c"
>>> mapping_dict= {green:0, blue:1, orange:2}
>>> G1 = nx.Graph()
>>> G1.add_nodes_from([
...     (1,{"color": blue}),
...     (2,{"color": orange}),
...     (3,{"color": blue}),
...     (4,{"color": green})
... ])
>>> G1.add_edges_from([(1, 2), (2, 3), (1, 3), (3, 4)])
>>> G2 = nx.Graph()
>>> G2.add_nodes_from([
...     (1,{"color": green}),
...     (2,{"color": green}),
...     (3,{"color": orange}),
...     (4,{"color": orange}),
...     (5,{"color": blue})
... ])
>>> G2.add_edges_from([(2, 3),(3, 4),(3, 1),(5, 1)])
>>> G3 = nx.Graph()
>>> G3.add_nodes_from([
...     (1,{"color": orange}),
...     (2,{"color": orange}),
...     (3,{"color": green}),
...     (4,{"color": green}),
...     (5,{"color": blue}),
...     (6,{"color":orange})
... ])
>>> G3.add_edges_from([(2,3), (3,4), (3,1), (5,1), (2,5), (6,1)])
>>> G4 = nx.Graph()
>>> G4.add_nodes_from([
...     (1,{"color": blue}),
...     (2,{"color": blue}),
...     (3,{"color": green})
... ])
>>> G4.add_edges_from([(1, 2), (2, 3)])
>>> graph_list = [get_graph_dict(graph, mapping_dict) for graph in
...     [G1, G2, G3, G4]] 

此代码生成的图在Figure 18.11中可视化:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.11:生成的四个图

这段代码块构造了四个 NetworkX 图并将它们存储在一个列表中。在这里,nx.Graph()的构造函数初始化了一个空图,而add_nodes_from()从一个元组列表中将节点添加到空图中。每个元组中的第一个项目是节点的名称,第二个项目是该节点属性的字典。

图的add_edges_from()方法接受一个元组列表,其中每个元组定义其元素(节点)之间的边。现在,我们可以为这些图构建一个 PyTorch Dataset

from torch.utils.data import Dataset
class ExampleDataset(Dataset):
    # Simple PyTorch dataset that will use our list of graphs
    def __init__(self, graph_list):
        self.graphs = graph_list
    def __len__(self):
        return len(self.graphs)
    def __getitem__(self,idx):
        mol_rep = self.graphs[idx]
        return mol_rep 

尽管使用自定义Dataset可能看起来是不必要的工作,但它允许我们展示如何在DataLoader中使用collate_graphs()

>>> from torch.utils.data import DataLoader
>>> dset = ExampleDataset(graph_list)
>>> # Note how we use our custom collate function
>>> loader = DataLoader(
...     dset, batch_size=2, shuffle=False,
...     collate_fn=collate_graphs) 

使用 NodeNetwork 进行预测

在我们定义了所有必要的函数并设置了 DataLoader 后,我们现在初始化一个新的 NodeNetwork 并将其应用于我们的图数据:

>>> node_features = 3
>>> net = NodeNetwork(node_features)
>>> batch_results = []
>>> for b in loader:
...     batch_results.append(
...         net(b['X'], b['A'], b['batch']).detach()) 

注意,为简洁起见,我们没有包括训练循环;然而,可以通过计算预测和真实类标签之间的损失,通过 .backward() 反向传播损失,并通过基于梯度下降的优化器更新模型权重来以常规方式训练 GNN 模型。我们将此留作读者的可选练习。在下一节中,我们将展示如何使用 PyTorch Geometric 实现 GNN,该库实现了更复杂的 GNN 代码。

要继续我们之前的代码,现在让我们直接向模型提供一个单一的输入图,而不使用 DataLoader

>>> G1_rep = dset[1]
>>> G1_single = net(
...     G1_rep['X'], G1_rep['A'], G1_rep['batch']).detach() 

现在,我们可以比较将 GNN 应用于单个图 (G1_single) 和从 DataLoader 中获取的第一个图(也就是第一个图 G1,因为我们设置了 shuffle=False),以确保批处理加载器工作正常。通过使用 torch.isclose()(以考虑四舍五入误差),我们可以看到结果是等价的,这是我们希望看到的:

>>> G1_batch = batch_results[0][1]
>>> torch.all(torch.isclose(G1_single, G1_batch))
tensor(True) 

恭喜!现在您了解如何构建、设置和运行基本的 GNN。但是,从本介绍中,您可能意识到管理和操作图数据可能有些繁琐。而且,我们甚至没有构建使用边标签的图卷积,这将进一步复杂化事务。幸运的是,PyTorch Geometric 提供了许多 GNN 层的实现,使这一切变得更加简单。在下一小节中,我们将通过在分子数据上实现和训练更复杂的 GNN 的端到端示例来介绍这个库。

使用 PyTorch Geometric 库实现 GNN

在本节中,我们将使用 PyTorch Geometric 库实现 GNN,该库简化了训练 GNN 的过程。我们将 GNN 应用于 QM9 数据集,该数据集由小分子组成,以预测各向同性极化率,这是分子在电场中电荷畸变倾向的一种度量。

安装 PyTorch Geometric

可以通过 conda 或 pip 安装 PyTorch Geometric。我们建议您访问官方文档网站 pytorch-geometric.readthedocs.io/en/latest/notes/installation.html 选择适合您操作系统的安装命令。在本章中,我们使用 pip 安装了版本 2.0.2 以及其 torch-scattertorch-sparse 依赖:

pip install torch-scatter==2.0.9
pip install torch-sparse==0.6.12
pip install torch-geometric==2.0.2 

让我们从加载小分子数据集开始,并看看 PyTorch Geometric 如何存储数据:

>>> # For all examples in this section we use the following imports.
>>> # Note that we are using torch_geometric's DataLoader.
>>> import torch
>>> from torch_geometric.datasets import QM9
>>> from torch_geometric.loader import DataLoader
>>> from torch_geometric.nn import NNConv, global_add_pool
>>> import torch.nn.functional as F
>>> import torch.nn as nn
>>> import numpy as np
>>> # let's load the QM9 small molecule dataset
>>> dset = QM9('.')
>>> len(dset)
130831
>>> # Here's how torch geometric wraps data
>>> data = dset[0]
>>> data
Data(edge_attr=[8, 4], edge_index=[2, 8], idx=[1], name="gdb_1", pos=[5, 3], x=[5, 11], y=[1, 19], z=[5])
>>> # can access attributes directly
>>> data.z
tensor([6, 1, 1, 1, 1])
>>> # the atomic number of each atom can add attributes
>>> data.new_attribute = torch.tensor([1, 2, 3])
>>> data
Data(edge_attr=[8, 4], edge_index=[2, 8], idx=[1], name="gdb_1", new_attribute=[3], pos=[5, 3], x=[5, 11], y=[1, 19], z=[5])
>>> # can move all attributes between devices
>>> device = torch.device(
...     "cuda:0" if torch.cuda.is_available() else "cpu"
... )
>>> data.to(device)
>>> data.new_attribute.is_cuda
True 

Data对象是图数据的方便、灵活的包装器。请注意,许多 PyTorch Geometric 对象要求数据对象中包含某些关键字才能正确处理它们。具体来说,x应包含节点特征,edge_attr应包含边特征,edge_index应包括边列表,而y应包含标签。QM9 数据还包含一些值得注意的附加属性:pos,分子中每个原子在 3D 网格中的位置,以及z,分子中每个原子的原子序数。QM9 中的标签是分子的一些物理属性,如偶极矩、自由能、焓或各向同性极化率。我们将实现一个 GNN,并在 QM9 上训练它来预测各向同性极化率。

QM9 数据集

QM9 数据集包含 133,885 个小有机分子,标有几何、能量、电子和热力学性质。 QM9 是开发预测化学结构-性质关系和混合量子力学/机器学习方法的常见基准数据集。有关数据集的更多信息,请访问quantum-machine.org/datasets/

分子的键类型很重要;即通过某种键类型连接的哪些原子,例如单键或双键,都很重要。因此,我们将使用能够利用边特征的图卷积,例如torch_geometric.nn.NNConv层。 (如果您对实现细节感兴趣,可以在pytorch-geometric.readthedocs.io/en/latest/_modules/torch_geometric/nn/conv/nn_conv.html#NNConv找到其源代码。)

NNConv层中,这种卷积采用以下形式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,h是由一组权重外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传参数化的神经网络,而W是节点标签的权重矩阵。这种图卷积与我们之前从头实现的非常相似:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

唯一的实际区别在于等效的W[2],即神经网络h,是基于边标签参数化的,这允许权重因不同的边标签而变化。通过以下代码,我们实现了一个使用两个这样的图卷积层(NNConv)的 GNN:

class ExampleNet(torch.nn.Module):
    def __init__(self, num_node_features, num_edge_features):
        super().__init__()
        conv1_net = nn.Sequential(
            nn.Linear(num_edge_features, 32),
            nn.ReLU(),
            nn.Linear(32, num_node_features*32))
        conv2_net = nn.Sequential(
            nn.Linear(num_edge_features, 32),
            nn.ReLU(),
            nn.Linear(32, 32*16))
        self.conv1 = NNConv(num_node_features, 32, conv1_net)
        self.conv2 = NNConv(32,16, conv2_net)
        self.fc_1 = nn.Linear(16, 32)
        self.out = nn.Linear(32, 1)
    def forward(self, data):
        batch, x, edge_index, edge_attr = (
            data.batch, data.x, data.edge_index, data.edge_attr)
        # First graph conv layer
        x = F.relu(self.conv1(x, edge_index, edge_attr))
        # Second graph conv layer
        x = F.relu(self.conv2(x, edge_index, edge_attr))
        x = global_add_pool(x,batch)
        x = F.relu(self.fc_1(x))
        output = self.out(x)
        return output 

我们将训练这个 GNN 来预测分子的各向同性极化率,这是衡量分子电荷分布相对于外部电场扭曲倾向的相对指标。我们将把 QM9 数据集分为训练、验证和测试集,并使用 PyTorch Geometric DataLoader。请注意,这些不需要特殊的整理函数,但需要一个具有适当命名属性的Data对象。

接下来,让我们分割数据集:

>>> from torch.utils.data import random_split
>>> train_set, valid_set, test_set = random_split(
...     dset,[110000, 10831, 10000])
>>> trainloader = DataLoader(train_set, batch_size=32, shuffle=True)
>>> validloader = DataLoader(valid_set, batch_size=32, shuffle=True)
>>> testloader = DataLoader(test_set, batch_size=32, shuffle=True) 

下面的代码将在 GPU 上初始化并训练网络(如果可用):

>>> # initialize a network
>>> qm9_node_feats, qm9_edge_feats = 11, 4
>>> net = ExampleNet(qm9_node_feats, qm9_edge_feats)
>>> # initialize an optimizer with some reasonable parameters
>>> optimizer = torch.optim.Adam(
...     net.parameters(), lr=0.01)
>>> epochs = 4
>>> target_idx = 1 # index position of the polarizability label
>>> device = torch.device("cuda:0" if
...                       torch.cuda.is_available() else "cpu")
>>> net.to(device) 

训练循环如下面的代码所示,遵循我们在前几个 PyTorch 章节中遇到的熟悉模式,因此我们可以跳过详细的解释。然而,值得强调的一个细节是,这里我们计算的是均方误差(MSE)损失,而不是交叉熵,因为极化率是一个连续的目标,而不是一个类标签。

>>> for total_epochs in range(epochs):
...     epoch_loss = 0
...     total_graphs = 0
...     net.train()
...     for batch in trainloader:
...         batch.to(device)
...         optimizer.zero_grad()
...         output = net(batch)
...         loss = F.mse_loss(
...             output,batch.y[:, target_idx].unsqueeze(1))
...         loss.backward()
...         epoch_loss += loss.item()
...         total_graphs += batch.num_graphs
...         optimizer.step()
...     train_avg_loss = epoch_loss / total_graphs
...     val_loss = 0
...     total_graphs = 0
...     net.eval()
...     for batch in validloader:
...         batch.to(device)
...         output = net(batch)
...         loss = F.mse_loss(
...             output,batch.y[:, target_idx].unsqueeze(1))
...         val_loss += loss.item()
...         total_graphs += batch.num_graphs
...     val_avg_loss = val_loss / total_graphs
...     print(f"Epochs: {total_epochs} | "
...           f"epoch avg. loss: {train_avg_loss:.2f} | "
...           f"validation avg. loss: {val_avg_loss:.2f}")
Epochs: 0 | epoch avg. loss: 0.30 | validation avg. loss: 0.10
Epochs: 1 | epoch avg. loss: 0.12 | validation avg. loss: 0.07
Epochs: 2 | epoch avg. loss: 0.10 | validation avg. loss: 0.05
Epochs: 3 | epoch avg. loss: 0.09 | validation avg. loss: 0.07 

在前四个训练时期,训练和验证损失都在减少。数据集很大,在 CPU 上训练可能需要一点时间,因此我们在四个时期后停止训练。但是,如果进一步训练模型,损失将继续改善。您可以继续训练模型以查看如何改进性能。

下面的代码预测了测试数据上的值并收集了真实标签:

>>> net.eval()
>>> predictions = []
>>> real = []
>>> for batch in testloader:
...     output = net(batch.to(device))
...     predictions.append(output.detach().cpu().numpy())
...     real.append(
...             batch.y[:,target_idx] .detach().cpu().numpy())
>>> real = np.concatenate(real)
>>> predictions = np.concatenate(predictions) 

现在我们可以用测试数据的子集制作散点图。由于测试数据集相对较大(10,000 个分子),结果可能有些混乱,为简单起见,我们仅绘制前 500 个预测和目标:

>>> import matplotlib.pyplot as plt
>>> plt.scatter(real[:500], predictions[:500])
>>> plt.xlabel('Isotropic polarizability')
>>> plt.ylabel('Predicted isotropic polarizability') 

结果的图示如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.12:预测的各向同性极化率与实际各向同性极化率的图示

根据图,考虑到点相对靠近对角线,我们简单的 GNN 似乎在预测各向同性极化值时表现不错,即使没有超参数调整。

TorchDrug – 基于 PyTorch 的药物发现库

PyTorch Geometric 是一个全面的通用图形库,用于处理图形,包括分子,正如您在本节中看到的。如果您对更深入的分子工作和药物发现感兴趣,我们还建议考虑最近开发的 TorchDrug 库,该库提供了许多方便的工具来处理分子。您可以在这里了解更多关于 TorchDrug 的信息:torchdrug.ai/

其他 GNN 层和最新发展

本节将介绍一些您可以在 GNN 中使用的额外层次,此外还将提供该领域最新发展的高层次概述。虽然我们将为这些层背后的直觉和实现提供背景,这些概念在数学上可能有些复杂,但不要气馁。这些是可选的主题,不必掌握所有这些实现的细微之处。理解层背后的一般思想将足以使用我们引用的 PyTorch Geometric 实现进行实验。

接下来的小节将介绍谱图卷积层、图池化层和图归一化层。最后,最终的小节将对一些更高级的图神经网络进行总览。

谱图卷积

到目前为止,我们使用的图卷积都是空间性质的。这意味着它们根据与图相关的拓扑空间聚合信息,这只是说空间卷积在节点的局部邻域上操作的一种花哨方式。因此,如果利用空间卷积的 GNN 需要捕捉图数据中复杂的全局模式,那么网络就需要堆叠多个空间卷积。在这些全局模式很重要但需要限制网络深度的情况下,谱图卷积是一种可以考虑的替代卷积类型。

谱图卷积的操作方式与空间图卷积不同。谱图卷积通过利用图的频谱—其特征值集合—通过计算称为图拉普拉斯的图的归一化版本的特征分解来操作。最后一句可能看起来很复杂,所以让我们逐步分解并讨论它。

对于无向图,图的拉普拉斯矩阵定义为 L = D - A,其中 A 是图的邻接矩阵,D 是度矩阵。度矩阵是一个对角矩阵,其中对角线上的元素在与邻接矩阵的第 i 行相关的节点的进出边数。

L 是一个实对称矩阵,已经证明实对称矩阵可以分解为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,其中 Q 是正交矩阵,其列是 L 的特征向量,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是对角矩阵,其元素是 L 的特征值。你可以把 Q 看作提供了图结构的底层表示。与使用由 A 定义的图的局部邻域的空间卷积不同,谱卷积利用来自 Q 的替代结构表示来更新节点嵌入。

下面的谱卷积示例利用了 对称归一化图拉普拉斯 的特征分解,对于一个图,它定义如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里,I 是单位矩阵。这是因为图拉普拉斯归一化可以帮助稳定基于梯度的训练过程,类似于特征标准化。

鉴于 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传L[sym] 的特征分解,图卷积定义如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里,W 是一个可训练的权重矩阵。括号里的内容实质上是通过一个编码图结构关系的矩阵来乘以 XW。这里的 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 运算符表示内部项的逐元素乘法,而外部的 Q 将结果映射回原始基础。这种卷积有一些不良特性,因为计算图的特征分解具有 O(n³) 的计算复杂度。这意味着它速度较慢,并且如其结构所示,W 取决于图的大小。因此,谱卷积只能应用于相同大小的图。此外,该卷积的感受野是整个图,当前的配方不能进行调整。然而,已经开发出各种技术和卷积来解决这些问题。

例如,Bruna 和同事 (arxiv.org/abs/1312.6203) 引入了一种平滑方法,通过一组函数的逼近来解决 W 的大小依赖性,每个函数都乘以它们自己的标量参数,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。也就是说,给定函数集 f[1], …, f[n],外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。这组函数的维度可以变化。然而,由于 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 保持标量,卷积参数空间可以独立于图的大小。

值得一提的其他谱卷积包括 Chebyshev 图卷积 (arxiv.org/abs/1606.09375),它可以在更低的时间复杂度下近似原始谱卷积,并且可以具有不同大小的感受野。Kipf 和 Welling (arxiv.org/abs/1609.02907) 引入了一个与 Chebyshev 卷积类似的卷积,但减少了参数负担。这两种的实现都可以在 PyTorch Geometric 中找到,分别是 torch_geometric.nn.ChebConvtorch_geometric.nn.GCNConv,如果你想尝试谱卷积,这是个合理的起点。

池化

我们将简要讨论一些为图形开发的池化层的例子。虽然池化层提供的下采样在 CNN 架构中是有益的,但在 GNN 中下采样的好处并不是很明显。

图像数据的池化层滥用了空间局部性,而图形则没有。如果提供图中节点的聚类,我们可以定义图池化层如何对节点进行池化。然而,如何定义最佳聚类仍不明确,并且不同的聚类方法可能适合不同的情境。即使确定了聚类,如果节点被降采样,剩余节点如何连接仍不清楚。尽管这些问题仍然是开放性的研究问题,我们将介绍几种图池化层,并指出它们解决上述问题的方法。

与 CNN 类似,可以应用于 GNN 的均值和最大池化层。如图 18.13 所示,给定节点的聚类后,每个聚类成为新图中的一个节点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 18.13:将最大池化应用于图形

每个聚类的嵌入等于该聚类中节点嵌入的均值或最大值。为了解决连接性问题,将为该聚类分配包含该聚类中所有边索引的联合。例如,如果将节点 i, j, k 分配给聚类 c[1],那么与 i, jk 共享边的任何节点或包含该节点的聚类,将与 c[1] 共享边。

更复杂的池化层 DiffPool (arxiv.org/abs/1806.08804) 试图同时解决聚类和降采样问题。该层学习一个软聚类分配矩阵 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,将 n 个节点嵌入分配到 c 个聚类中。(关于软聚类与硬聚类的区别,请参阅《第十章》,《使用未标记数据 - 聚类分析》中的章节《硬聚类与软聚类》。)通过这种方式,X 更新为 X′ = S^TXA 更新为 A′ = ST**A**TS。值得注意的是,A′ 不再包含离散值,可以视为一种边权重矩阵。随着时间的推移,DiffPool 收敛到几乎硬聚类分配,具有可解释的结构。

另一种池化方法,top-k 池化,不是聚合图中的节点,而是删除它们,从而避免了聚类和连接性问题。虽然看似会损失删除节点中的信息,但在网络的背景下,只要在池化之前进行卷积,网络就能学会避免这种情况。被删除的节点是根据可学习向量 p 的投影分数来选择的。计算(X′, A′) 的实际公式如在《向稀疏分层图分类器迈进》(arxiv.org/abs/1811.01287) 中所述:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,top-k 选择y 的前 k 个值的索引,使用索引向量 i 来删除XA 的行。PyTorch Geometric 实现了 top-k 池化为 torch_geometric.nn.TopKPooling。此外,最大池化和平均池化分别实现为 torch_geometric.nn.max_pool_xtorch_geometric.nn.avg_pool_x

标准化

标准化技术被广泛应用于许多类型的神经网络中,以帮助稳定和/或加速训练过程。许多方法,如批标准化(在第十七章 生成对抗网络用于合成新数据中讨论),可以适用于具有适当记账的 GNNs。在本节中,我们将简要描述一些专为图数据设计的标准化层。

作为标准化的快速回顾,我们认为给定一组特征值 x[1], …, x[n],我们使用 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 来更新这些值,其中 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是均值,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是标准差。通常,大多数神经网络标准化方法采用通用形式 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,其中 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是可学习参数,而标准化方法之间的差异在于应用标准化的特征集。

GraphNorm: 加速图神经网络训练的原则方法,由Tianle Cai及其同事在 2020 年提出(arxiv.org/abs/2009.03294),展示了在图卷积中聚合后的均值统计可能包含有意义的信息,因此完全丢弃它可能不是一个理想的选择。为了解决这个问题,他们引入了GraphNorm

借用原始手稿的符号,设h 为节点嵌入矩阵。设h[i][, ][j] 为节点v[i] 的第 j 个特征值,其中 i = 1, …, nj = 1, …, dGraphNorm 的形式如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。关键新增部分是可学习参数 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,它可以控制要丢弃的均值统计量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的程度。

另一种图标准化技术是MsgNorm,由Guohao Li及其同事在 2020 年的手稿DeeperGCN: All You Need to Train Deeper GCNs中描述(arxiv.org/abs/2006.07739)。MsgNorm 对应于前面章节中提到的图卷积的消息传递形式。使用消息传递网络命名法(在实施基本图卷积子节的末尾定义),在图卷积对M[t] 求和并产生m[i] 但在使用U[t] 更新节点嵌入之前,MsgNorm 通过以下公式对m[i] 进行标准化:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,s是一个可学习的缩放因子,这种方法的直觉是对图卷积中聚合消息的特征进行标准化。虽然没有理论支持这种标准化方法,但在实践中效果显著。

我们讨论过的标准化层都通过 PyTorch Geometric 实现并可用,如BatchNormGroupNormMessageNorm。更多信息,请访问 PyTorch Geometric 文档:pytorch-geometric.readthedocs.io/en/latest/modules/nn.html#normalization-layers

与可能需要额外聚类设置的图池化层不同,图归一化层可以更容易地插入现有的 GNN 模型中。在模型开发和优化过程中测试各种标准化方法是一种合理且推荐的方法。

高级图神经网络文献的指引

着眼于图的深度学习领域正在迅速发展,有许多方法我们在这个入门章节无法详尽介绍。因此,在结束本章之前,我们希望为有兴趣的读者提供一些显著文献的指引,以便深入研究这一主题。

正如你可能还记得的在第十六章变压器——通过注意力机制改进自然语言处理中,注意力机制可以通过提供额外的上下文来改进模型的能力。在这方面,已开发出多种用于 GNN 的注意力方法。例如,Petar Veličković及其同事于 2017 年提出的图注意力网络arxiv.org/abs/1710.10903)以及Dan Busbridge及其同事于 2019 年提出的关系图注意力网络arxiv.org/abs/1904.05811)。

近年来,这些注意力机制还被 2020 年Seongjun Yun及其同事提出的图变换器和 2020 年Ziniu Hu及其同事提出的异构图变换器所利用(arxiv.org/abs/1911.06455arxiv.org/abs/2003.01332)。

除了上述的图转换器之外,还开发了其他专门用于图形的深度生成模型。例如,图变分自动编码器,如KipfWelling在 2016 年提出的《变分图自动编码器》(arxiv.org/abs/1611.07308),以及 2018 年刘琦等人提出的《分子设计的约束图变分自动编码器》(arxiv.org/abs/1805.09076),以及SimonovskyKomodakis在 2018 年提出的《GraphVAE: 使用变分自动编码器生成小型图形》(arxiv.org/abs/1802.03480)。另一个显著的应用于分子生成的图变分自动编码器是Wengong Jin和同事在 2019 年提出的《分子图生成的联结树变分自动编码器》(arxiv.org/abs/1802.04364)。

一些 GAN 已被设计用于生成图数据,尽管截至本文撰写时,GAN 在图领域的表现远不如在图像领域那般令人信服。例如,Hongwei Wang和同事在 2017 年提出的《GraphGAN: 使用生成对抗网络进行图表示学习》(arxiv.org/abs/1711.08267),以及CaoKipf在 2018 年提出的《MolGAN: 用于小分子图的隐式生成模型》(arxiv.org/abs/1805.11973)。

GNNs 也已纳入深度强化学习模型中——你将在下一章节详细学习强化学习。例如,Jiaxuan You和同事在 2018 年提出的《用于目标导向分子图生成的图卷积策略网络》(arxiv.org/abs/1806.02473),以及Zhenpeng Zhou和同事在 2018 年提出的《通过深度强化学习优化分子》(arxiv.org/abs/1810.08678),利用了应用于分子生成任务的 GNN。

最后,虽然技术上不属于图数据,但有时将 3D 点云表示为图数据,使用距离截断来创建边。图网络在这一领域的应用包括Weijing Shi和同事在 2020 年提出的《Point-GNN: 用于 LiDAR 点云中 3D 物体检测的图神经网络》(arxiv.org/abs/2003.01251),该网络可以在 LiDAR 点云中检测 3D 物体。此外,Can Chen和同事在 2019 年设计的《GAPNet: 基于图注意力的点神经网络,用于利用点云的局部特征》(arxiv.org/abs/1905.08705),旨在解决其他深度架构难以处理的点云局部特征检测问题。

总结

随着我们可以访问的数据量不断增加,我们需要理解数据内部的相互关系的需求也会增加。虽然我们会以多种方式来实现这一点,但图表作为这些关系的精炼表示,可用的图表数据量只会增加。

在本章中,我们通过从零开始实现图卷积层和 GNN 来逐步解释图神经网络。我们看到,由于图数据的性质,实现 GNN 实际上是非常复杂的。因此,为了将 GNN 应用于实际示例,例如预测分子极化,我们学习了如何利用 PyTorch Geometric 库,该库提供了我们需要的许多构建模块的实现。最后,我们回顾了一些深入研究 GNN 领域的重要文献。

希望本章介绍了如何利用深度学习来学习图形。这一领域的方法目前是研究的热点领域,我们提到的许多方法都是最近几年发表的。通过这本书作为起点,也许你可以在这个领域取得下一个进展。

在下一章中,我们将探讨强化学习,这是与本书迄今为止涵盖的机器学习完全不同的一类。

加入我们书籍的 Discord 空间

加入书籍的 Discord 工作空间,参加每月的问我任何事与作者的会话:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值