Python-高级深度学习-全-

Python 高级深度学习(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是基于应用领域的新进化深度学习模型、方法论和实现的集合。在本书的第一部分,你将学习深度学习的构建块及其背后的数学原理,特别是神经网络NNs)。在第二部分,你将专注于卷积神经网络CNNs)及其在计算机视觉CV)中的高级应用。你将学习如何将最流行的 CNN 架构应用于目标检测和图像分割。最后,你将讨论变分自编码器和生成对抗网络。

在第三部分,你将专注于自然语言和序列处理。你将使用神经网络(NN)来提取单词的复杂向量表示。你将讨论各种类型的递归网络,如长短期记忆LSTM)和门控递归单元(GRU)。最后,你将介绍注意力机制,它可以在没有递归网络帮助的情况下处理序列数据。最后一部分,你将学习如何使用图神经网络(GNN)来处理结构化数据。你将涵盖元学习,这使得你能够通过较少的训练样本训练神经网络。最后,你将学习如何将深度学习应用于自动驾驶车辆。

在本书结束时,你将掌握与深度学习相关的关键概念,并学会采用进化方法来监控和管理深度学习模型。

本书适合谁阅读

本书面向数据科学家、深度学习工程师、研究人员以及希望掌握深度学习并构建创新和独特深度学习项目的人工智能开发者。本书也适合那些希望通过实际案例深入了解深度学习领域中的高级应用案例和方法论的读者。假设读者对深度学习有基本的概念理解,并具备一定的 Python 编程基础。

本书内容

第一章,神经网络的基础,将简要介绍什么是深度学习,并讨论神经网络的数学基础。本章将把神经网络视为数学模型,更具体地说,我们将重点讨论向量、矩阵和微积分。我们还将深入讨论一些梯度下降的变种,如 Momentum、Adam 和 Adadelta。我们还将讨论如何处理不平衡的数据集。

第二章,理解卷积网络,将简要介绍卷积神经网络(CNN)。我们将讨论 CNN 及其在计算机视觉(CV)中的应用。

第三章,高级卷积网络,将讨论一些先进且广泛使用的神经网络架构,包括 VGG、ResNet、MobileNets、GoogleNet、Inception、Xception 和 DenseNets。我们还将使用 PyTorch 实现 ResNet 和 Xception/MobileNets。

第四章,目标检测与图像分割,将讨论两个重要的视觉任务:目标检测和图像分割。我们将提供这两者的实现。

第五章,生成模型,将开始讨论生成模型。特别是,我们将讨论生成对抗网络和神经风格迁移。具体的风格迁移将在后续实现。

第六章,语言建模,将介绍基于单词和字符级的语言模型。我们还将讨论词向量(word2vec、Glove 和 fastText),并使用 Gensim 来实现它们。我们还将带你走过准备文本数据的复杂技术过程,以便为机器学习应用程序(如主题建模和情感建模)做准备,并借助 自然语言工具包NLTK)的文本处理技术。

第七章,理解循环神经网络,将讨论基本的循环神经网络、LSTM 和 GRU 单元。我们将为所有网络提供详细的解释和纯 Python 实现。

第八章,序列到序列模型与注意力机制,将讨论序列模型和注意力机制,包括双向 LSTM,以及一种新的架构叫做 transformer,具有编码器和解码器。

第九章,新兴神经网络设计,将讨论图神经网络和具有记忆功能的神经网络,如 神经图灵机NTM)、可微分神经计算机和 MANN。

第十章,元学习,将讨论元学习——一种教会算法如何学习的方法。我们还将尝试通过赋予深度学习算法以较少的训练样本学习更多信息的能力来改进深度学习算法。

第十一章,深度学习与自动驾驶汽车,将探讨深度学习在自动驾驶汽车中的应用。我们将讨论如何使用深度网络帮助汽车理解其周围环境。

为了从本书中获得最大收益

为了从本书中获得最大收益,你应该熟悉 Python,并对机器学习有一定的了解。本书包含了主要类型的神经网络的简短介绍,但如果你已经熟悉神经网络的基础知识,将会更有帮助。

下载示例代码文件

你可以从你在 www.packt.com 上的帐户下载本书的示例代码文件。如果你是在其他地方购买本书的,可以访问 www.packtpub.com/support 并注册,以便直接通过电子邮件获得文件。

你可以按照以下步骤下载代码文件:

  1. 登录或注册 www.packt.com

  2. 选择支持标签。

  3. 点击代码下载。

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

下载文件后,请确保使用最新版本解压或提取文件夹:

  • Windows 版的 WinRAR/7-Zip

  • Mac 版的 Zipeg/iZip/UnRarX

  • Linux 版的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python。如果代码有更新,GitHub 上的现有仓库也会进行更新。

我们的丰富书籍和视频目录中,还有其他代码包可供下载,网址为 github.com/PacktPublishing/。快来查看吧!

下载彩色图片

我们还提供了包含本书中截图/图表彩色图片的 PDF 文件,您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789956177_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"通过包含generatordiscriminatorcombined网络来构建完整的 GAN 模型。"

一段代码的设置方式如下:

import matplotlib.pyplot as plt
from matplotlib.markers import MarkerStyle
import numpy as np
import tensorflow as tf
from tensorflow.keras import backend as K
from tensorflow.keras.layers import Lambda, Input, Dense

粗体:表示新术语、重要词汇或您在屏幕上看到的文字。例如,菜单或对话框中的文字会像这样显示在文本中。举个例子:“实验的所有可能结果(事件)的集合称为样本 空间。”

警告或重要提示将以这种方式呈现。

小贴士和技巧将以这种方式呈现。

联系我们

我们欢迎读者的反馈意见。

一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 与我们联系。

勘误:虽然我们已经尽力确保内容的准确性,但错误仍然可能发生。如果您发现本书中的错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击“勘误提交表单”链接,并填写相关信息。

盗版:如果您在互联网上发现任何形式的非法复制品,我们将非常感激您能提供其位置地址或网站名称。请通过copyright@packt.com与我们联系,并附上相关材料的链接。

如果你有兴趣成为一名作者:如果你在某个领域拥有专业知识,并且有兴趣写作或为书籍做贡献,请访问 authors.packtpub.com

评价

请留下评论。一旦你阅读并使用了本书,为什么不在你购买书籍的网站上留下评论呢?潜在的读者可以查看并根据你的公正意见做出购买决策,Packt 能够了解你对我们产品的看法,而我们的作者也能看到你对他们书籍的反馈。谢谢!

要了解更多关于 Packt 的信息,请访问 packt.com

第一部分:核心概念

本节将讨论一些核心的深度学习DL)概念:DL 到底是什么,DL 算法的数学基础,以及使得快速开发 DL 算法成为可能的库和工具。

本节包含以下章节:

  • 第一章,神经网络的基本原理

第一章:神经网络的基本构成

在这一章中,我们将讨论神经网络(NNs)的一些复杂细节——它是深度学习DL)的基石。我们将讨论它们的数学工具、结构和训练。我们的主要目标是为你提供系统性的神经网络理解。通常,我们从计算机科学的角度来接近它们——作为一种机器学习(ML)算法(或甚至一个特殊的实体),由多个不同的步骤/组件组成。我们通过思考神经元、层等来获得直觉(至少当我第一次学习这个领域时,我是这样做的)。这种方式是完全有效的,我们仍然能在这个理解层次上做出令人印象深刻的事情。然而,这可能不是正确的途径。

神经网络(NNs)有着坚实的数学基础,如果我们从这一角度来接近它们,就能以更基本和优雅的方式定义和理解它们。因此,在这一章中,我们将试图强调从数学和计算机科学角度看神经网络的类比。如果你已经熟悉这些话题,你可以跳过这一章。不过,我希望你能发现一些你之前没有了解的有趣内容(我们会尽力让这一章保持有趣!)。

在这一章中,我们将讨论以下内容:

  • 神经网络的数学工具

  • 神经网络简介

  • 训练神经网络

神经网络的数学工具

在接下来的几节中,我们将讨论与神经网络相关的数学分支。完成这些内容后,我们将把它们与神经网络本身联系起来。

线性代数

线性代数处理线性方程,如![],以及线性变换(或线性函数)及其表示形式,如矩阵和向量。

线性代数识别以下数学对象:

  • 标量:一个单独的数字。

  • 向量:一个一维的数字(或分量)数组。数组的每个分量都有一个索引。在文献中,我们会看到向量要么用上标箭头表示(![]),要么用粗体(x)表示。以下是一个向量的示例:

在本书中,我们大部分时间将使用粗体(x)图形符号。但在某些情况下,我们会使用来自不同来源的公式,并尽力保留它们的原始符号。

我们可以通过一个n维向量来直观地表示为一个点在n维欧几里得空间中的坐标,![](相当于一个坐标系)。在这种情况下,向量被称为欧几里得向量,每个向量分量表示沿着相应轴的坐标,如下图所示:

![] 空间中的向量表示

然而,欧几里得向量不仅仅是一个点,我们也可以通过以下两个特性来表示它:

  • 大小(或长度)是毕达哥拉斯定理在n维空间中的推广:

  • 方向是向量在向量空间中沿每个坐标轴的角度。

  • 矩阵:这是一个二维的数字数组。每个元素由两个索引(行和列)标识。矩阵通常用加粗的大写字母表示,例如,A。每个矩阵元素用小写矩阵字母和下标表示,例如,a[ij]。让我们看一下以下公式中的矩阵表示法示例:

我们可以将一个向量表示为一个单列的n×1矩阵(称为列矩阵)或一个单行的1×n矩阵(称为行矩阵)。

  • 张量:在我们解释张量之前,我们必须先做个声明。张量最初来源于数学和物理学,在我们开始将其应用于机器学习之前,它们早已存在于这些领域。在这些领域中的张量定义与机器学习中的定义不同。为了本书的目的,我们只考虑机器学习中的张量。在这里,张量是一个多维数组,具有以下特性:

    • :表示数组的维数。例如,秩为 2 的张量是一个矩阵,秩为 1 的张量是一个向量,秩为 0 的张量是一个标量。然而,张量在维度数上没有限制。实际上,一些类型的神经网络使用秩为 4 的张量。

    • 形状:每个维度的大小。

    • 张量元素的数据类型。这些数据类型在不同的库中可能有所不同,但通常包括 16 位、32 位和 64 位浮动数,以及 8 位、16 位、32 位和 64 位整数。

当代深度学习库,如 TensorFlow 和 PyTorch,使用张量作为它们的主要数据结构。

你可以在这里找到关于张量性质的详细讨论:stats.stackexchange.com/questions/198061/why-the-sudden-fascination-with-tensors。你还可以查看 TensorFlow (www.tensorflow.org/guide/tensors) 和 PyTorch (pytorch.org/docs/stable/tensors.html) 的张量定义。

现在我们已经介绍了线性代数中的对象类型,在接下来的部分,我们将讨论可以应用于它们的一些运算。

向量和矩阵运算

在本节中,我们将讨论与神经网络(NNs)相关的向量和矩阵运算。让我们开始吧:

  • 向量加法是将两个或多个向量相加得到一个输出向量和的运算。输出是另一个向量,其计算公式如下:

  • 或标量对两个向量进行运算,输出一个标量值。我们可以通过以下公式计算点积:

这里,|a|和|b|是向量的大小,θ是两个向量之间的角度。假设这两个向量是n维的,它们的分量分别是a[1]b[1]a[2]b[2],依此类推。此时,前面的公式等价于以下公式:

两个二维向量ab的点积在以下图中示例:

向量的点积。上:向量分量;下:两个向量的点积

点积作为两个向量之间的一种相似性度量——如果两个向量之间的角度θ较小(即向量方向相似),那么它们的点积会较高,因为![]。

基于这一思想,我们可以定义两个向量之间的余弦相似度如下:

  • 或向量对两个向量进行运算,输出一个垂直于这两个初始向量的向量。我们可以通过以下公式计算叉积输出向量的大小:

以下图展示了两个二维向量之间的叉积示例:

两个二维向量的叉积

正如我们之前提到的,输出向量垂直于输入向量,这也意味着该向量是包含它们的平面的法向量。输出向量的大小等于以向量ab为边的平行四边形的面积(如前图所示)。

我们还可以通过向量空间来定义一个向量,向量空间是由可以相加和与标量相乘的对象(在我们这里是向量)组成的集合。向量空间将允许我们定义一个线性变换,作为一个函数,f,该函数可以将向量空间V中的每个向量(点)转换为另一个向量空间W中的向量(点):![]。f必须满足以下要求,对于任何两个向量,![]:

  • 加法性:![]

  • 齐次性:![],其中c是标量

  • 矩阵转置:这里,我们沿着矩阵的主对角线翻转矩阵(主对角线是矩阵元素 a[ij] 的集合,其中 i = j)。转置操作用上标表示,^()。为明确起见, 处的单元格等于 ![] 处的单元格,后者位于 ![] 中:

一个 m×n 矩阵的转置是一个 n×m 矩阵。以下是几个转置示例:

  • 矩阵-标量乘法是矩阵与标量值的乘法。在以下示例中,![] 是一个标量:

  • 矩阵-矩阵加法是两个矩阵按元素逐一相加的操作。为了使此操作有效,两个矩阵的大小必须相同。以下是一个示例:

  • 矩阵-向量乘法是矩阵与向量的乘法。为了使这个操作有效,矩阵的列数必须等于向量的长度。m×n 矩阵与 n 维向量相乘的结果是一个 m 维向量。以下是一个示例:

我们可以将矩阵的每一行看作一个独立的 n 维向量。在这里,输出向量的每个元素是对应矩阵行与 x 的点积。以下是一个数值示例:

  • 矩阵乘法是一个矩阵与另一个矩阵的乘法。为了使操作有效,第一个矩阵的列数必须等于第二个矩阵的行数(这是一个非交换操作)。我们可以将此操作视为多个矩阵-向量乘法,其中第二个矩阵的每一列都是一个向量。一个 m×n 矩阵与一个 n×p 矩阵相乘的结果是一个 m×p 矩阵。以下是一个示例:

如果我们将两个向量视为行矩阵,我们可以将向量的点积表示为矩阵乘法,即!

这部分是我们对线性代数的介绍。接下来,我们将介绍概率论。

概率简介

在这一部分,我们将讨论一些与神经网络相关的概率和统计方面的内容。

我们首先介绍统计实验的概念,其具有以下特性:

  • 由多个独立的试验组成。

  • 每次试验的结果是非确定性的;也就是说,它是由机会决定的。

  • 它有多个可能的结果,这些结果称为事件(我们将在接下来的部分中讨论集合中的事件)。

  • 所有可能的实验结果在实验前都是已知的。

一个统计实验的例子是硬币投掷,它有两个可能的结果——正面或反面。另一个例子是投掷骰子,它有六个可能的结果:1、2、3、4、5 和 6。

我们将概率定义为某个事件e发生的可能性,并用P(e)表示它。概率是一个介于 [0, 1] 范围内的数字,其中 0 表示事件无法发生,1 表示事件一定会发生。如果P(e) = 0.5,则表示该事件有 50-50 的机会发生,依此类推。

我们可以通过两种方式来处理概率:

  • 理论:我们关心的事件与所有可能事件的总数相比。所有事件的发生概率是相等的:

为了理解这个问题,让我们用一个硬币投掷的例子,其中有两个可能的结果。每个可能结果的理论概率是 P(正面) = P(反面) = 1/2。投掷骰子的每一面出现的理论概率是 1/6。

  • 经验:这是我们关心的事件发生的次数与总实验次数之比:

实验的结果可能显示事件的发生概率不相等。例如,假设我们投掷硬币 100 次,观察到正面朝上 56 次。在这种情况下,正面的经验概率是 P(正面) = 56 / 100 = 0.56。实验次数越多,计算出的概率越精确(这就是大数法则)。

在接下来的部分,我们将讨论在集合上下文中的概率。

概率与集合

实验的所有可能结果(事件)集合叫做样本空间。我们可以将样本空间看作一个数学集合。它通常用大写字母表示,所有的集合结果可以用 {} 来列出(与 Python 集合相同)。例如,硬币投掷事件的样本空间是 S[c] = {正面, 反面},而骰子投掷的样本空间是 S[d] = {1, 2, 3, 4, 5, 6}。集合中的单个结果(例如正面)叫做样本点事件是样本空间中的一个结果(样本点)或一组结果(子集)。例如,骰子投掷结果为偶数的组合事件是 {2, 4, 6}。

假设我们有一个样本空间 S = {1, 2, 3, 4, 5},以及两个子集(事件)A = {1, 2, 3} 和 B = {3, 4, 5}。在这种情况下,我们可以进行以下操作:

  • 交集:结果是一个新集合,包含两个集合中都出现的元素:

相交为空集 {} 的集合是不相交的。

  • 补集: 结果是一个新的集合,包含样本空间中所有不包含在给定集合中的元素:

  • 并集: 结果是一个新的集合,包含可以在任一集合中找到的元素:

以下维恩图展示了这些不同的集合关系:

可能的集合关系的维恩图

我们可以将集合的性质转移到事件及其概率上。我们假设这些事件是独立的——一个事件的发生不会影响另一个事件发生的概率。例如,不同掷硬币的结果是相互独立的。话虽如此,接下来我们来学习如何将集合运算转化到事件领域中:

  • 两个事件的交集是这两个事件共同包含的结果子集。交集的概率称为联合概率,通过以下公式计算:

假设我们要计算一张卡片既是红色(红心或方块)又是杰克的概率。红色的概率是 P(red) = 26/52 = 1/2。抽到杰克的概率是 P(Jack) = 4/52 = 1/13。因此,联合概率是 P(red, Jack) = (1/2) * (1/13) = 1/26。在这个例子中,我们假设这两个事件是独立的。然而,两个事件同时发生(我们抽了一张牌)。如果它们是依次发生的,例如抽两张卡片,其中一张是杰克,另一张是红色的,我们将进入条件概率的领域。这个联合概率也可以表示为 P(A, B) 或 P(AB)。

单一事件发生的概率 P(A) 也称为边际概率(与联合概率相对)。

  • 两个事件是互斥的(或互斥事件),如果它们没有共同的结果。也就是说,它们各自的样本空间子集是互斥的。例如,奇数或偶数掷骰子的事件是互斥的。关于互斥事件的概率,以下内容是成立的:

    • 互斥事件的联合概率(这些事件同时发生的概率)是 P(A∩B) = 0。

    • 互斥事件的概率之和是![]。

  • 如果多个事件的子集包含了整个样本空间,它们是联合完全的。前面例子中的事件 A 和 B 是联合完全的,因为它们一起填充了整个样本空间(1 到 5)。关于联合完全事件的概率,以下内容是成立的:

如果我们只有两个事件,它们同时是互斥且完全互补的,那么这两个事件是补集事件。例如,奇数和偶数的掷骰子事件是补集事件。

  • 我们将来自 A 或 B 的结果(不一定同时)称为 A 和 B 的并集。这个并集的概率如下:

到目前为止,我们已经讨论了独立事件。接下来,我们将专注于依赖事件。

条件概率和贝叶斯规则

如果事件 A 的发生改变了事件 B 的发生概率,其中 A 在 B 之前发生,那么两者是依赖的。为了说明这个概念,让我们想象从牌组中依次抽取多张卡牌。当牌组完整时,抽到红心的概率为 P(红心) = 13/52 = 0.25。但是一旦我们抽出第一张牌,第二次抽取红心的概率就会改变。现在,我们只剩下 51 张牌和一个少了的红心。我们称第二次抽取的概率为条件概率,并用 P(B|A) 表示。这是事件 B(第二次抽取),在事件 A(第一次抽取)发生的条件下的概率。继续我们的例子,第二次抽取红心的概率变为 P(红心[2]|红心[1]) = 12/51 = 0.235

接下来,我们可以扩展联合概率公式(在前一节介绍的基础上),以涉及依赖事件。公式如下:

然而,上述方程只是两个事件的特例。我们可以进一步扩展到多个事件 A[1], A[2], ..., A[n]。这个新的通用公式被称为概率的链式法则:

例如,三个事件的链式规则如下:

我们还可以推导出条件概率本身的公式:

这个公式有以下几个原因:

  • P(A ∩ B) 表示我们对 B 的发生感兴趣,已知 A 已经发生。换句话说,我们对事件的联合发生感兴趣,因此是联合概率。

  • P(A) 表示我们只关注事件 A 发生后的子集结果。我们已经知道 A 已经发生,因此我们将观察限制在这些结果上。

对于依赖事件,以下内容成立:

使用这个方程,我们可以在条件概率公式中替换 P(A∩B) 的值,得到如下结果:

上述公式使我们能够计算条件概率 P(B|A),如果我们知道相反的条件概率 P(B|A)。这个方程被称为贝叶斯规则,在机器学习中经常使用。在贝叶斯统计学的背景下,P(A) 和 P(B|A) 分别称为先验概率和后验概率。

贝叶斯定理可以通过医学测试来进行说明。假设我们想要确定一个患者是否患有某种特定疾病。我们进行了一项医学测试,结果呈阳性。但这并不一定意味着患者真的患有该疾病。大多数测试都有一个可靠性值,即当对患有某种特定疾病的人进行测试时,测试结果呈阳性的概率。利用这些信息,我们将应用贝叶斯定理来计算患者在测试结果为阳性的情况下,实际患病的概率。我们得出如下结果:

这里,P(has disease) 是没有任何先验条件下的疾病一般概率。可以将其看作是普通人群中患病的概率。

接下来,我们假设一些关于疾病和测试准确性的条件:

  • 该测试的可靠性为 98%,也就是说,如果测试结果为阳性,在 98%的情况下结果也为阳性:P(test=positive|has disease) = 0.98。

  • 50 岁以下只有 2%的人患有这种疾病:P(has disease) = 0.02。

  • 对 50 岁以下的人群,测试结果呈阳性的人群只占 3.9%:P(test=positive) = 0.039。

我们可以提出以下问题:如果一个测试对癌症的准确性为 98%,并且一个 45 岁的人进行了测试,结果为阳性,那么他患病的概率是多少?利用前述公式,我们可以计算出以下结果:

在下一节中,我们将超越概率的讨论,探讨随机变量和概率分布。

随机变量和概率分布

在统计学中,我们定义变量为描述某一实体的属性。该属性的值在不同实体之间可能有所不同。例如,我们可以用一个变量来描述一个人的身高,而这个值在不同的人之间会有所不同。但假设我们多次测量同一个人的身高。由于一些随机因素,如人的姿势或我们自身测量的不准确性,我们可以预期每次测量的结果会有轻微的差异。因此,尽管我们在测量相同的东西,变量“身高”的值也会有所不同。为了考虑这些变化,我们引入了随机变量。随机变量是其值由某些随机事件决定的变量。与常规变量不同,随机变量可以取多个值,并且每个值都与某个概率相关联。

随机变量有两种类型:

  • 离散型,只能取特定的离散值。例如,足球比赛中的进球数是一个离散变量。

  • 连续型,可以在给定区间内取任何值。例如,身高测量是一个连续变量。

随机变量用大写字母表示,某个随机变量 X 取值 x 的概率表示为 P(X = x)p(x)。所有可能值的概率集合称为 概率分布。根据变量类型,我们有两种概率分布类型:

  • 概率质量函数 (PMF) 用于离散变量。以下是一个 PMF 示例。x 轴 显示可能的值,y 轴 显示每个值的概率:

PMF 示例

PMF 仅对随机变量的可能值定义。PMF 的所有值都是非负的,且它们的总和为 1。也就是说,PMF 事件是互斥且共同完备的。我们用 P(X) 表示 PMF,其中 X 是随机变量。

  • 概率密度函数 (PDF) 用于连续变量。与 PMF 不同,PDF 在两个值之间的区间内是连续的(定义了所有可能值),从而反映了连续变量的特性。以下是一个 PDF 示例:

PDF 示例

在 PDF 中,概率是为某个值区间计算的,并由该区间下的曲线下的面积表示(这是前面图示中标出的区域)。曲线下的总面积为 1。我们用 f[X] 表示 PDF,其中 X 是随机变量。

接下来,我们来关注随机变量的一些属性:

  • 均值(或 期望值)是实验在多次观察中的预期结果。我们用 μ 或 ![] 表示它。对于离散变量,均值是所有可能值的加权和,每个值乘以它们的概率:

让我们使用之前的离散变量示例,其中我们定义了一个有六个可能值(0, 1, 2, 3, 4, 5)和它们各自概率(0.1, 0.2, 0.3, 0.2, 0.1, 0.1)的随机变量。在这里,均值是 μ = 00.1 + 10.2 + 20.3 + 30.2 + 40.1 + 50.1 = 2.3

连续变量的均值定义如下:

对于离散变量,我们可以将 PMF 看作查找表,而 PDF 可能更复杂(是一个实际的函数或方程),这就是它们之间符号不同的原因。我们不会进一步探讨连续变量的均值。

  • 方差 定义为一个随机变量与其均值 μ 之间的平方偏差的期望值:

换句话说,方差衡量的是随机变量的值与其均值的偏离程度。

离散随机变量的方差如下:

让我们使用前面的例子,其中我们计算得出均值为 2.3。新的方差为Var(X) = (0 - 2.3)² * 0 + (1 - 2.3)² * 1 + ... + (5- 2.3)² * 5 = 2.01

连续变量的方差定义如下:

  • 标准差衡量随机变量的值与期望值的差异程度。如果这个定义听起来像方差,那是因为它确实如此。事实上,标准差的公式如下:

我们还可以通过标准差定义方差:

标准差和方差的区别在于,标准差使用与均值相同的单位表示,而方差使用平方单位。

在本节中,我们定义了什么是概率分布。接下来,让我们讨论不同类型的概率分布。

概率分布

我们将从二项分布开始,适用于离散变量的二项实验。二项实验只有两个可能的结果:成功或失败。它还满足以下要求:

  • 每次试验相互独立。

  • 成功的概率始终相同。

一个二项实验的例子是抛硬币实验。

现在,假设实验包含n次试验,其中x次成功,每次试验的成功概率为p。变量 X 的二项 PMF 公式(不要与x混淆)如下所示:

这里是二项式系数![]。这是x次成功试验的组合数,我们可以从n次总试验中选择。如果n=1,那么我们有一个特殊的二项分布案例,称为伯努利分布

接下来,让我们讨论适用于连续变量的正态(或高斯)分布,它 closely approximates 很多自然过程。正态分布使用以下指数 PDF 公式定义,称为正态方程(最常见的表示法之一):

这里,x是随机变量的值,μ是均值,σ是标准差,σ²是方差。前述公式产生了一个钟形曲线,显示如下图所示:

正态分布

让我们讨论一些正态分布的性质,顺序不分先后:

  • 曲线是对称的,围绕其中心,这也是最大值所在。

  • 曲线的形状和位置完全由均值和标准差描述,公式如下:

    • 曲线的中心(及其最大值)等于均值。也就是说,均值决定了曲线在 x 轴上的位置。

    • 曲线的宽度由标准差决定。

在下图中,我们可以看到具有不同 μσ 值的正态分布示例:

具有不同 μσ 值的正态分布示例

  • 正态分布在正负无穷大处趋近于 0,但永远不会变为 0。因此,服从正态分布的随机变量可以取任何值(尽管某些值的概率非常小)。

  • 曲线下的面积等于 1,这由常数![]确保,它位于指数前面。

  • ![](位于指数中)被称为标准分数(或 z 分数)。标准化的正态变量具有 0 的均值和 1 的标准差。转换后,随机变量以标准化的形式参与方程。

在下一部分,我们将介绍信息论这一跨学科领域,它将帮助我们在神经网络(NNs)的背景下使用概率论。

信息论

信息论试图确定一个事件所包含的信息量。信息量由以下原则指导:

  • 事件的概率越高,事件的含信息量越少。相反,如果概率较低,事件则携带更多的信息量。例如,掷硬币的结果(概率为 1/2)提供的信息量少于掷骰子的结果(概率为 1/6)。

  • 独立事件携带的信息是它们各自信息量的总和。例如,两个骰子掷出的相同点数(假设是 4)比单个点数的两倍信息量。

我们将事件 x 的信息量(或自信息)定义如下:

这里, log 是自然对数。例如,如果事件的概率是 P(x) = 0.8,那么 I(x) = 0.22。或者,如果 P(x) = 0.2,则 I(x) = 1.61。我们可以看到,事件的信息量与事件的概率是相反的。自信息量 I(x) 是以自然信息单位(nat)来度量的。我们也可以使用以 2 为底的对数 ![] 来计算 I(x),在这种情况下我们以比特为单位度量。两者之间没有本质的区别。为了本书的目的,我们将坚持使用自然对数版本。

让我们讨论一下为什么在前面的公式中使用对数,尽管负概率也能满足自信息和概率之间的互惠关系。主要原因是对数的乘法和除法规则:

这里,x[1]x[2]是标量值。无需过多细节,注意这些属性使得我们在网络训练过程中可以轻松地最小化误差函数。

到目前为止,我们已经定义了单一结果的信息量。那么其他结果呢?为了衡量它们,我们必须衡量整个随机变量的概率分布的信息量。我们用 I(X)来表示其中的量,其中X是一个离散的随机变量(我们这里重点讨论离散变量)。回想一下,在随机变量和概率分布部分,我们定义了离散随机变量的均值(或期望值)为所有可能值的加权和,乘以它们的概率。我们在这里也会做类似的事情,但我们将每个事件的信息量乘以该事件的概率。

这个度量称为香农熵(或简称熵),其定义如下:

在这里,x[i]表示离散变量的值。相较于低概率事件,具有较高概率的事件会有更大的权重。我们可以将熵理解为概率分布中事件(结果)信息量的期望(均值)。为了理解这一点,假设我们计算一个熟悉的抛硬币实验的熵。我们将计算两个示例:

  • 首先,假设P(正面) = P(反面) = 0.5。在这种情况下,熵如下:

  • 接下来,假设由于某些原因,事件的结果概率并不相等,且概率分布为P(正面) = 0.2 和 P(反面) = 0.8。熵如下:

我们可以看到,当所有结果的概率相等时,熵最大;而当某个结果变得更加常见时,熵减少。从某种意义上说,我们可以将熵视为不确定性或混乱的度量。以下图示显示了在二元事件(如抛硬币)中,熵H(X)相对于两个结果的概率分布的变化:

左侧:使用自然对数计算熵;右侧:使用以 2 为底的对数计算熵

接下来,假设我们有一个离散的随机变量X,并且它有两个不同的概率分布。通常这种情况发生在神经网络产生某个输出概率分布Q(X),并且在训练过程中将其与目标分布P(X)进行比较时。我们可以通过交叉熵来衡量这两个分布之间的差异,其定义如下:

例如,假设我们计算前述投硬币场景中两个概率分布之间的交叉熵。我们有预测分布Q(正面) = 0.2, Q(反面) = 0.8和目标(或真实)分布P(正面) = 0.5, P(反面) = 0.5。交叉熵如下:

衡量两个概率分布之间差异的另一个方法是Kullback–Leibler 散度KL 散度):

对数的乘积法则帮助我们将第一行的公式转化为第二行的更直观形式。这样我们可以更清楚地看到,KL 散度衡量的是目标与预测对数概率之间的差异。如果我们进一步推导这个方程,还可以看到熵、交叉熵与 KL 散度之间的关系。

投硬币的例子场景的 KL 散度如下:

在下一节中,我们将讨论微积分领域,这将有助于我们训练神经网络(NN)。

微积分

在机器学习(ML)中,我们通常关注的是如何通过调整机器学习算法的参数来逼近某个目标函数。如果我们将机器学习算法本身视为一个数学函数(对于神经网络来说就是如此),我们就想知道当我们改变它的一些参数(权重)时,这个函数的输出如何变化。幸运的是,微积分正是研究函数相对于其所依赖的变量的变化率的工具。以下是导数的(非常)简短介绍。

假设我们有一个函数,f(x),它有一个单一的参数,x,其图形如下:

f(x)的图形和斜率(红色虚线)

我们可以通过计算函数在某一点的斜率,得到f(x)相对于x在任意值处的变化情况。如果斜率为正,说明函数在增大;相反,如果斜率为负,说明函数在减小。我们可以通过以下方程计算斜率:

这里的思路很简单——我们计算fxx+Δx这两个值之间的差异:Δy = f(x + Δx) - f(x)。然后,我们计算ΔyΔx的比值来得到斜率。但如果Δx太大,测量结果就不太准确,因为在xx+Δx之间包含的函数图形部分可能会发生剧烈变化。我们可以使用更小的Δx来最小化这个误差;这样,我们就可以关注图形的更小部分。如果Δx趋近于 0,我们可以假设斜率反映了图形的某一个点。在这种情况下,我们称斜率为f(x)一阶导数。我们可以通过以下方程用数学语言来表示:

这里, f'(x)dy/dx 分别是拉格朗日和莱布尼茨的导数表示法。![] 是极限的数学概念——我们可以将其视为 Δx 趋近于 0 的过程。求 f 的导数过程称为 微分。以下图展示了不同 x 值下的斜率:

我们可以看到,f局部最小值局部最大值 处的斜率为 0——在这些点(称为鞍点),fx 变化时既不增加也不减少。

接下来,假设我们有一个多个参数的函数,![]。对于任何参数 x[i]f 关于它的导数称为偏导数,并表示为 ![]。计算偏导数时,我们假设所有其他参数,![],是常数。我们将用 ![] 表示向量各分量的偏导数。

最后,我们来介绍一些有用的求导规则:

  • 链式法则:假设 f 和 g 是一些函数,且 h(x)= f(g(x))。 在这里,任何 xfx 的导数如下:

  • 和规则:假设 f 和 g 是某些函数,且 h(x) = f(x) + g(x)。和规则表示以下内容:

  • 常见函数

    • x' = 1

    • (ax)' = a,其中 a 是常数

    • a' = 0,其中 a 是常数

    • x² = 2x

    • (e^x)' = e^x

神经网络(NNs)及其数学工具形成了一种知识层次结构。如果我们把实现一个神经网络看作是建造一座房子,那么数学工具就像是混合混凝土。我们可以独立地学习如何混合混凝土,而不需要了解如何建造房子。事实上,我们可以将混凝土用于除建房之外的多种用途。然而,在建房之前,我们需要知道如何混合混凝土。继续我们的类比,现在我们知道如何混合混凝土(数学工具),接下来我们将专注于实际建造房子(神经网络)。

神经网络简介

神经网络是一个函数(我们用 f 表示),它试图逼近另一个目标函数 g。我们可以用以下方程描述这种关系:

这里,x 是输入数据,θ 是神经网络的参数(权重)。目标是找到最佳近似的 θ 参数,使其接近 g。这个通用定义适用于回归(逼近 g 的精确值)和分类(将输入分配到多个可能类别中的一个)任务。或者,神经网络函数可以表示为

我们将从神经网络最小的构建块——神经元开始讨论。

神经元

上述定义是神经网络(NN)的俯瞰图。现在,让我们讨论神经网络的基本构建块,即神经元(或单元)。单元是可以定义为以下内容的数学函数:

在这里,我们有以下内容:

  • y是单元的输出(单个值)。

  • f是非线性可微的激活函数。激活函数是神经网络中的非线性来源——如果神经网络完全是线性的,它只能近似其他线性函数。

  • 激活函数的参数是所有单元输入的加权和(权重为w[i]),其中输入x[i](总共有n个输入)以及偏置权重b。输入x[i]可以是数据输入值,也可以是其他单元的输出。

另外,我们可以用向量表示替代x[i]w[i],其中![]和![]。在这里,公式将使用两个向量的点积:

以下图(左)展示了一个单元:

左侧:一个单元及其等效公式;右侧:感知机的几何表示。

如果输入向量x与权重向量w垂直,则x•w = 0。因此,所有满足x•w = 0的向量x定义了向量空间中的超平面![],其中nx的维度。在二维输入(x[1], x[2])的情况下,我们可以将超平面表示为一条直线。这可以通过感知机(或二分类器)来说明——一个具有阈值激活函数的单元!,它将输入分类为两个类别之一。感知机的几何表示是带有两个输入(x[1], x[2])的直线(或决策边界),将两个类别分开(如前图所示的右侧)。这对神经元造成了一个严重的限制,因为它无法分类线性不可分的问题——甚至是像 XOR 这样的简单问题。

具有恒等激活函数(f(x) = x)的单元等同于多元线性回归,而具有 Sigmoid 激活函数的单元则等同于逻辑回归。

接下来,让我们学习如何将神经元组织成层。

层作为操作

神经网络组织结构的下一级是单元的层,其中我们将多个单元的标量输出组合成一个输出向量。层中的单元之间没有相互连接。这种组织结构有以下几方面的意义:

  • 我们可以将多元回归推广到层,而不仅仅是单一单元的线性或逻辑回归。换句话说,我们可以通过一个层来逼近多个值,而不是通过单个单元来逼近一个值。这发生在分类输出的情况下,其中每个输出单元表示输入属于某个类别的概率。

  • 单元能够传递的信息有限,因为其输出是一个标量。通过组合单元的输出,而不是单一的激活,我们现在可以考虑整个向量。这样,我们可以传递更多的信息,不仅因为向量有多个值,还因为它们之间的相对比例传递了额外的意义。

  • 由于层中的单元之间没有相互连接,我们可以并行计算它们的输出(从而提高计算速度)。这种能力是深度学习近年来成功的主要原因之一。

在经典神经网络(即深度学习之前的神经网络,当时它们仅是众多机器学习算法之一)中,主要的层类型是 全连接FC)层。在该层中,每个单元都从输入向量 x 的所有组件接收加权输入。假设输入向量的大小为 m,而该全连接层有 n 个单元,并且每个单元都有相同的激活函数 f。每个 n 个单元将有 m 个权重:每个输入对应一个权重。以下是我们可以用于单个全连接层单元 j 输出的公式。它与我们在 神经元 部分定义的公式相同,但这里我们包括了单元索引:

这里,w[ij] 是第 j 层单元与第 i 输入组件之间的权重。我们可以将连接输入向量到单元的权重表示为一个 m×n 的矩阵 W。每列矩阵表示一个层单元的所有输入的权重向量。在这种情况下,层的输出向量是矩阵-向量乘法的结果。然而,我们也可以将多个输入样本 x[i] 组合成一个输入矩阵(或 batchX,它将同时通过该层。在这种情况下,我们有矩阵-矩阵乘法,层的输出也是一个矩阵。下图展示了一个全连接(FC)层的示例,以及在批处理和单个样本场景中的等效公式:

带有向量/矩阵输入和输出的全连接(FC)层及其等效公式

我们已经明确分开了偏置和输入权重矩阵,但在实际应用中,底层实现可能会使用共享的权重矩阵,并向输入数据中附加一行额外的 1。

当代深度学习(DL)不仅仅局限于全连接层(FC)。我们有许多其他类型,例如卷积层、池化层等等。某些层有可训练的权重(例如 FC、卷积层),而其他层则没有(例如池化层)。我们还可以用函数或操作来替代层这个术语。例如,在 TensorFlow 和 PyTorch 中,我们刚刚描述的全连接层实际上是两个顺序操作的组合。首先,我们执行权重和输入的加权和,然后将结果作为输入馈送到激活函数操作中。在实际应用中(即在使用深度学习库时),神经网络的基本构建块不是单元,而是一个接受一个或多个张量作为输入并输出一个或多个张量的操作:

一个具有输入和输出张量的函数

接下来,让我们讨论如何在神经网络中结合层操作。

神经网络(NNs)

神经元一节中,我们展示了一个神经元(同样适用于一个层)只能分类线性可分的类别。为了克服这一局限性,我们需要在神经网络中组合多个层。我们将神经网络定义为一个操作(或层)有向图。图中的节点是操作,而节点之间的边决定了数据流向。如果两个操作连接在一起,那么第一个操作的输出张量将作为第二个操作的输入,这由边的方向决定。一个神经网络可以有多个输入和输出——输入节点只有输出边,而输出节点只有输入边。

基于这个定义,我们可以识别两种主要类型的神经网络:

  • 前馈网络,它们由非循环图表示。

  • 递归网络RNN),它们由循环图表示。递归是时间上的;图中的循环连接传播操作在时刻t-1的输出,并将其反馈到下一个时刻t的网络中。RNN 维护一个内部状态,它代表所有先前网络输入的某种总结。这个总结与最新的输入一起被输入到 RNN 中。网络产生一些输出,同时更新其内部状态并等待下一个输入值。通过这种方式,RNN 可以处理具有可变长度的输入,如文本序列或时间序列。

以下是这两种网络类型的示例:

左侧:前馈网络;右侧:递归网络

假设,当一个操作接收来自多个操作的输入时,我们使用逐元素求和将多个输入张量结合起来。然后,我们可以将神经网络表示为一系列嵌套的函数/操作。我们用![]表示一个神经网络操作,其中i是一个帮助我们区分多个操作的索引。例如,左侧前馈网络的等效公式如下:

右边的 RNN 公式如下:

我们还将使用与操作本身相同的索引来表示操作的参数(权重)。让我们考虑一个带有索引l的全连接(FC)网络层,它的输入来自前一层,前一层的索引为l-1。以下是单个单元和向量/矩阵层表示的层公式以及层索引:

现在我们已经熟悉了完整的神经网络架构,让我们来讨论一下不同类型的激活函数。

激活函数

让我们从经典的激活函数开始,讨论不同类型的激活函数:

  • Sigmoid:它的输出被限制在 0 和 1 之间,可以通过概率的角度理解为神经元被激活的概率。由于这些特性,sigmoid 曾是最流行的激活函数。然而,它也有一些不太理想的特性(稍后会详细讨论),这导致它的流行程度下降。下图展示了 sigmoid 的公式、它的导数及其图形(导数将在我们讨论反向传播时用到):

Sigmoid 激活函数

  • 双曲正切tanh):名字就已经说明了一切。与 sigmoid 的主要区别在于,tanh 的输出范围是(-1,1)。下图展示了 tanh 的公式、它的导数及其图形:

双曲正切激活函数

接下来,让我们关注一下新兴的激活函数——*LU(LU代表线性单元)家族。我们将从 2011 年首次成功使用的修正线性单元ReLU)开始(参见《深度稀疏修正神经网络》,proceedings.mlr.press/v15/glorot11a/glorot11a.pdf)。下图展示了 ReLU 的公式、它的导数及其图形:

ReLU 激活函数

如我们所见,ReLU 在x > 0时会重复其输入,否则保持为 0。这个激活函数相较于 sigmoid 和 tanh 有几个重要的优势:

  • 它的导数有助于防止梯度消失(有关这一点,请参见权重初始化部分)。严格来说,ReLU 在 0 值处的导数是未定义的,这使得 ReLU 仅为半可微函数(更多信息可以参考en.wikipedia.org/wiki/Semi-differentiability)。但在实践中,它的表现已经足够好。

  • 它是幂等的——如果我们通过任意次数的 ReLU 激活传递一个值,它不会改变;例如,ReLU(2) = 2ReLU(ReLU(2)) = 2,依此类推。这与 sigmoid 不同,后者在每次传递时会将值压缩σ(**σ(2)) = 0.707。以下是三个连续 sigmoid 激活的例子:

连续的 sigmoid 激活会“压缩”数据

ReLU 的幂等性使得与 sigmoid 相比,它理论上可以创建更多层次的网络。

  • 它创造了稀疏的激活——假设网络的权重是通过正态分布随机初始化的。在这种情况下,每个 ReLU 单元的输入有 0.5 的概率小于 0。因此,大约一半的激活输出也将为 0。这种稀疏激活具有许多优点,我们可以大致总结为神经网络中的奥卡姆剃刀原理——用更简单的数据表示来实现相同的结果,比复杂的表示更好。

  • 在前向和反向传递中计算速度更快。

然而,在训练过程中,网络权重可能会被更新到某些 ReLU 单元总是接收到小于 0 的输入,这反过来会导致它们永久地输出 0。这种现象被称为 ReLU。为了解决这个问题,提出了许多 ReLU 的变种。以下是一个非详尽的列表:

  • Leaky ReLU:当输入大于 0 时,Leaky ReLU 会像普通 ReLU 一样重复其输入。然而,当 x < 0 时,Leaky ReLU 输出 x 乘以某个常数 α (0 < α < 1),而不是 0。以下图展示了 Leaky ReLU 的公式、其导数及 α=0.2 时的图形:

Leaky ReLU 激活函数

  • 参数化 ReLUPReLU深入探讨整流函数:在 ImageNet 分类上超越人类水平的性能arxiv.org/abs/1502.01852):该激活函数与 Leaky ReLU 相同,但参数 α 是可调的,并且在训练过程中进行调整。

  • 指数线性单元ELU通过指数线性单元(ELU)实现快速准确的深度网络学习arxiv.org/abs/1511.07289):当输入大于 0 时,ELU 会像 ReLU 一样重复其输入。然而,当 x < 0 时,ELU 输出变为 ![],其中 α 是一个可调参数。以下图展示了 ELU 的公式、其导数及 α=0.2 时的图形:

ELU 激活函数

  • 缩放指数线性单元SELU自归一化神经网络arxiv.org/abs/1706.02515):该激活函数类似于 ELU,不同之处在于输出(无论大于还是小于 0)通过一个附加的训练参数λ进行缩放。SELU 是一个更大概念的一部分,叫做自归一化神经网络(SNNs),该概念在源论文中有所描述。以下是 SELU 的公式:

最后,我们将提到softmax,它是分类问题中输出层的激活函数。假设最终网络层的输出是一个向量,![],其中每个n分量表示输入数据属于n个可能类别之一的概率。这里,每个向量分量的 softmax 输出如下:

该公式中的分母充当归一化器。softmax 输出具有一些重要性质:

  • 每个值f(z[i])都在[0, 1]范围内。

  • z的总和为 1:![.]

  • 一个额外的奖励(实际上,是强制性的)是该函数是可微分的。

换句话说,我们可以将 softmax 输出解释为离散随机变量的概率分布。然而,它还有一个更为微妙的性质。在我们对数据进行归一化之前,我们对每个向量分量进行指数变换,![]。假设两个向量分量分别为z[1] = 1z[2] = 2。在这里,我们得到exp(1) = 2.7exp(2) = 7.39。如我们所见,变换前后各分量之间的比例差异很大——0.5 和 0.36。实际上,softmax 通过增加较高分数的概率来相对于较低分数进行调整。

在下一节中,我们将从神经网络的基本构件转向其整体,具体来说,我们将展示神经网络如何逼近任何函数。

通用逼近定理

通用逼近定理首次在 1989 年为具有 sigmoid 激活函数的神经网络(NN)证明,1991 年则为具有任意非线性激活函数的神经网络证明。该定理指出,任何在![]的紧致子集上的连续函数,都可以通过至少有一个隐藏层的前馈神经网络逼近到任意精度,且该隐藏层包含有限数量的单元并具有非线性激活。虽然一个只有单一隐藏层的神经网络在许多任务中表现不佳,但该定理依然告诉我们,神经网络在理论上没有无法逾越的限制。定理的正式证明过于复杂,无法在此解释,但我们将尝试使用一些基本数学知识提供直观的解释。

以下示例的灵感来自 Michael A. Nielsen 的书籍Neural Networks and **Deep Learningneuralnetworksanddeeplearning.com/)。

我们将实现一个 NN,来近似箱形函数(如下图右侧所示),这是一种简单的阶跃函数类型。由于一系列阶跃函数可以近似任意连续函数在R的紧凑子集上的表现,这将帮助我们理解为什么普适逼近定理成立:

左侧的图示描绘了使用一系列阶跃函数进行的连续函数近似,而右侧的图示则说明了单一的箱形阶跃函数。

为了理解这种近似是如何工作的,我们将从一个具有单一标量输入x和 Sigmoid 激活的单一单位开始。以下是该单位及其等效公式的可视化:

在以下图示中,我们可以看到不同值的bw下,输入在[-10: 10]范围内的公式图形:

基于不同的wb值的神经元输出。网络输入x在 x 轴上表示。

通过仔细检查公式和图形,我们可以看到,Sigmoid 函数的陡峭程度由权重w决定。同时,函数在x轴上的平移由公式t = -b/w决定。我们来讨论一下前面图示中的不同情况:

  • 左上图展示了常规的 Sigmoid 函数。

  • 右上图展示了一个较大的权重w如何将输入x放大到一定程度,直到单位输出类似于阈值激活。

  • 左下图展示了偏置b如何沿x轴平移单位激活。

  • 右下图显示了我们可以通过负权重w同时反转激活,并通过偏置b沿x轴平移激活。

我们可以直观地看到,前面的图形包含了箱形函数的所有组成部分。我们可以结合不同的情况,通过一个隐藏层的 NN 来实现,其中包含前述的两个单元。以下图示展示了网络架构,以及单元的权重和偏置,以及网络生成的箱形函数:

以下是其工作原理:

  • 首先,顶部单元激活用于函数的上阶跃,并保持激活状态。

  • 底部单位在函数的底部阶跃之后激活并保持激活状态。隐藏单元的输出因输出层中的权重而相互抵消,这些权重相同但符号相反。

  • 输出层的权重决定了箱形矩形的高度。

这个网络的输出不是 0,但仅在(-5, 5)区间内。因此,我们可以通过向隐藏层添加更多单元来以类似的方式近似额外的盒子。

既然我们已经熟悉了神经网络的结构,让我们集中精力研究训练过程。

训练神经网络

在本节中,我们将训练神经网络定义为调整其参数(权重)θ的过程,目的是以最小化成本函数 J(θ)。成本函数是对训练集的某种性能度量,训练集由多个样本组成,并表示为向量。每个向量有一个关联标签(监督学习)。最常见的做法是,成本函数度量网络输出与标签之间的差异。

本节将以简短回顾梯度下降优化算法开始。如果你已经熟悉它,可以跳过这一部分。

梯度下降

在本节中,我们将使用一个具有单一回归输出和均方误差MSE)成本函数的神经网络(NN),该函数定义如下:

在这里,我们有以下内容:

  • f[θ](x^((i))) 是神经网络的输出。

  • n 是训练集中的样本总数。

  • x^((i)) 是训练样本的向量,其中上标 i 表示数据集中的第 i 个样本。我们使用上标是因为 x^((i)) 是一个向量,且下标通常用于表示每个向量的分量。例如, ![] 是第 i 个训练样本的 j 组件。

  • t^((i)) 是与样本 x^((i)) 关联的标签。

我们不应该将 i(第 i 个训练样本的上标)与 (l) 上标混淆,后者代表神经网络的层索引。在 梯度下降成本函数 部分中,我们只会使用 i 样本索引的符号,其他地方则使用 (l) 符号表示层索引。

首先,梯度下降计算 J(θ) 关于所有网络权重的导数(梯度)。梯度为我们提供了 J(θ) 如何随每个权重的变化而变化的指示。然后,算法利用这些信息更新权重,以便在未来的相同输入/目标对中最小化 J(θ)。目标是逐渐达到成本函数的全局最小值。以下是均方误差(MSE)和具有单一权重的神经网络的梯度下降可视化:

梯度下降的均方误差(MSE)可视化

让我们一步步地回顾梯度下降的执行过程:

  1. 使用随机值初始化网络权重 θ

  2. 重复直到成本函数降到某一阈值以下:

    1. 正向传播:使用前述公式计算所有训练集样本的均方误差(MSE)J(θ) 成本函数。

    2. 反向传播:使用链式法则计算 J(θ) 关于所有网络权重的导数:

让我们分析导数![]。Jθ[j]的函数,因为它是网络输出的函数。因此,它也是 NN 函数本身的函数,即![]。然后,通过链式法则,我们得到![]。

    1. 使用这些导数来更新每个网络权重:

这里,η是学习率。

梯度下降通过积累所有训练样本中的误差来更新权重。在实践中,我们会使用它的两个修改:

  • 随机(或在线)梯度下降SGD)在每个训练样本后更新权重。

  • 小批量梯度下降累积每个n个样本(一个小批量)的误差,并执行一次权重更新。

接下来,让我们讨论与 SGD 一起使用的不同代价函数。

代价函数

除了 MSE 之外,在回归问题中通常还会使用其他几种损失函数。以下是非穷尽列表:

  • 平均绝对误差MAE)是网络输出与目标之间绝对差异(未平方)的均值。以下是 MAE 的图表和公式:

MAE 相对于 MSE 的一个优势是更好地处理异常样本。使用 MSE 时,如果一个样本的差异是![], 它会呈指数增长(因为是平方的)。这会导致这个样本的权重相对于其他样本过大,可能会使结果产生偏差。使用 MAE 时,差异不是指数增长,这个问题较不明显。

另一方面,MAE 的梯度在达到最小值之前将保持相同值,然后立即变为 0。这使得算法难以预测成本函数最小值的接近程度。相比之下,MSE 的斜率随着接近成本最小值而逐渐减小,这使得 MSE 更容易优化。总之,除非训练数据受到异常值的干扰,通常建议使用 MSE 而不是 MAE。

  • Huber 损失试图修正 MAE 和 MSE 的问题,通过结合它们的特性。简而言之,当输出与目标数据之间的绝对差小于固定参数δ时,Huber 损失的行为类似于 MSE。相反,当差值大于δ时,它类似于 MAE。这样,它对异常值(差值较大时)不太敏感,并且函数的最小值适当可微。以下是三个δ值的 Huber 损失图表及单个训练样本的公式,反映了它的二元性质:

Huber 损失

接下来,让我们专注于分类问题的代价函数。以下是一个非详尽的列表:

  • 交叉熵损失:我们在这里有不少工作要做,因为我们已经在信息理论部分定义了交叉熵。这个损失通常应用于 softmax 函数的输出。两者非常配合。首先,softmax 将网络输出转换为概率分布。然后,交叉熵衡量网络输出(Q)与真实分布(P)之间的差异,后者作为训练标签提供。另一个优点是,H(P, Q[softmax]) 的导数相当直接(尽管计算过程并不简单):

这里,x((i))/t((i)) 是第 i 个输入/标签训练对。

  • KL 散度损失:像交叉熵损失一样,我们已经在信息理论部分完成了繁重的工作,在那里我们推导了 KL 散度与交叉熵损失之间的关系。从它们的关系中,我们可以得出结论,如果我们使用其中一个作为损失函数,我们隐式地也在使用另一个。

有时候,我们可能会遇到“损失函数”和“代价函数”这两个术语被交替使用的情况。通常认为它们之间有些微小的区别。我们将损失函数定义为网络输出和目标数据之间的差异,针对训练集中的单个样本。代价函数则是同样的概念,不过它是对训练集多个样本(批次)进行平均(或求和)后的结果。

现在我们已经看过了不同的代价函数,接下来让我们专注于通过反向传播将误差梯度传播到网络中。

反向传播

在本节中,我们将讨论如何更新网络权重,以最小化代价函数。正如我们在梯度下降部分演示的那样,这意味着要找出代价函数 J(θ) 关于每个网络权重的导数我们已经借助链式法则朝着这个方向迈出了第一步:

这里,f(θ) 是网络输出,而 θ[j] 是第 j 个网络权重在本节中,我们将进一步扩展,我们将学习如何推导出所有网络权重的神经网络(NN)函数(提示:链式法则)。我们将通过将误差梯度反向传播通过网络来实现这一点(因此得名)。让我们从一些假设开始:

  • 为了简化起见,我们将使用顺序前馈神经网络。顺序意味着每一层从前一层获取输入,并将输出传递给后一层。

  • 我们定义 w[ij] 为层 l 中第 i 个神经元和层 l+1 中第 j 个神经元之间的权重。换句话说,我们使用下标 ij,其中下标为 i 的元素属于前一层,即包含下标为 j 元素的层。在一个多层网络中,ll+1 可以是任何两个连续的层,包括输入层、隐藏层和输出层。

  • 我们用 ![] 来表示层 l 中第 i 个单元的输出,用 ![] 来表示层 l+1 中第 j 个单元的输出。

  • 我们用 a[j]^((l)) 来表示层 l 中单元 j 的激活函数输入(即激活前输入的加权和)。

下图显示了我们引入的所有符号:

在这里,层 l 代表输入,层 l+1 代表输出,w[ij] 将层 l 中的 y[i]^([(l)]) 激活值与层 l+1 中第 j 个神经元的输入连接起来。

拥有了这些宝贵的知识,让我们开始实际操作:

  1. 首先,我们假设 ll+1 分别是倒数第二层和最后一层(输出层)。知道这一点后,J 相对于 w[ij] 的导数如下:

  1. 让我们关注 ![]。在这里,我们计算层 l 输出的加权和相对于其中一个权重 w[ij] 的偏导数。正如我们在 微积分 部分讨论的那样,在偏导数中,我们会将除 w[ij] 之外的所有函数参数视为常数。当我们对 a[j]^([(l+1)]) 求导时,其他部分都会变成 0,我们只剩下 ![]。因此,我们得到以下结果:

  1. 第 1 点中的公式适用于网络中任何两个连续的隐藏层 ll+1。我们知道 ![],并且我们也知道 ![] 是激活函数的导数,我们可以计算出来(见 激活函数 部分)。我们需要做的就是计算导数 ![](回想一下,这里 l+1 是某个隐藏层)。需要注意的是,这是相对于层 l+1 中激活函数的误差导数。现在我们可以从最后一层开始,向后计算所有的导数,因为以下情况适用:
  • 我们可以计算最后一层的这个导数。

  • 我们有一个公式,可以计算一个层的导数,前提是我们可以计算下一个层的导数。

  1. 牢记这些点后,我们通过应用链式法则得到以下方程:

j的求和反映了在网络的前馈部分,输出* 被馈送到所有l+1层的神经元。因此,它们都在误差反向传播时对y[i]^([(l)])*作出贡献。

再次,我们可以计算![]。按照我们在步骤 3中遵循的相同逻辑,我们可以计算出![]。因此,一旦我们知道![],我们就可以计算出![]。由于我们可以计算出最后一层的,我们可以向后计算并计算出任何一层的![],因此对于任何一层,计算出![]。

  1. 总结一下,假设我们有一系列层,其中如下所示:

这里,我们有以下基本方程:

通过使用这两个方程,我们可以计算每一层关于成本的导数。

  1. 如果我们设置,那么δ[j]([(l+1)])*表示相对于激活值的成本变化,我们可以将δ*[j]([(l+1)])视为神经元y[j]^([(l+1)])的误差。我们可以将这些方程改写如下:

接下来,我们可以写出以下方程:

这两个方程为我们提供了反向传播的另一种视角,因为成本相对于激活值的变化。它们为我们提供了一种方法,可以在知道下一层l+1的变化后,计算任何一层l的变化。

  1. 我们可以将这些方程合并,得到以下结果:

  1. 每一层的权重更新规则由以下方程给出:

现在我们已经熟悉了反向传播,让我们讨论训练过程中的另一个组件:权重初始化。

权重初始化

训练深度网络的一个关键组件是随机权重初始化。这很重要,因为一些激活函数,如 sigmoid 和 ReLU,当其输入在某个范围内时,能够产生有意义的输出和梯度。

一个著名的例子是梯度消失问题。为了理解它,我们来看看一个使用 sigmoid 激活的全连接层(这个例子对于 tanh 也适用)。我们已经在 激活函数 部分看到过 sigmoid 的图形(蓝色)和其导数(绿色)。如果输入的加权和大致超出 (-5, 5) 范围,sigmoid 激活会有效地变为 0 或 1。实质上,它会饱和。在反向传播过程中,我们通过对 sigmoid 进行求导(公式为 σ' = **σ(1 - σ))可以看到这一点。我们可以看到,在同一个 (-5, 5) 输入范围内,导数大于 0。因此,无论我们试图将多少误差传播回上一层,如果激活值不在此范围内,误差将消失(因此称之为梯度消失)。

除了 sigmoid 导数的紧致有效范围外,值得注意的是,即使在最佳条件下,其最大值为 0.25。当我们通过 sigmoid 导数传播梯度时,经过传播后,其值最大也会缩小四倍。正因为如此,梯度可能在仅仅几层后就会消失,即使我们没有超出所需范围。这是 sigmoid 相对于 LU 函数族 的主要缺点之一,在后者中,梯度在大多数情况下为 1。

解决这个问题的一种方法是使用 LU 激活函数。但即便如此,使用更好的权重初始化方法仍然是有意义的,因为它可以加速训练过程。一个常见的技术是 Xavier/Glorot 初始化器(通常以以下两种名称之一出现:proceedings.mlr.press/v9/glorot10a/glorot10a.pdf)。简而言之,该技术考虑了单位的输入和输出连接数。它有两种变体:

  • Xavier 均匀初始化器,它从范围为[-a, a]的均匀分布中抽取样本。参数 a 定义如下:

在这里,n[in]n[out] 分别是输入和输出的数量(即将输出发送到当前单元的单元数,以及当前单元将输出发送到的单元数)。

  • Xavier 正态初始化器,它从正态分布中抽取样本(参见 概率分布 部分),均值为 0,方差定义如下:

推荐在 sigmoid 或 tanh 激活函数中使用 Xavier/Glorot 初始化。论文 深入探讨激活函数:超越 ImageNet 分类中的人类水平表现arxiv.org/abs/1502.01852)提出了一种类似的技术,更适合用于 ReLU 激活函数。同样,它也有两种变体:

  • He 均匀初始化器,它从范围为[-a, a]的均匀分布中抽取样本。参数 a 定义如下:

  • He 正态初始化器,它从一个均值为 0 的正态分布中抽取样本,方差如下:

ReLU 的输出在输入为负时始终为 0。如果我们假设 ReLU 单元的初始输入围绕 0 分布,那么其中一半的输出将是 0。He 初始化器通过将方差增加两倍来补偿这个问题,相较于 Xavier 初始化。

在接下来的部分,我们将讨论权重更新规则相较于标准 SGD 的一些改进。

SGD 改进

我们从动量开始,它通过将当前权重更新与前一次权重更新的值结合来扩展基本 SGD。也就是说,如果 t-1 步骤的权重更新较大,它也会增加 t 步骤的权重更新。我们可以用一个类比来解释动量。想象一下,损失函数表面就像是一座山丘的表面。现在,假设我们把一个球放在山顶(最大值)上。如果我们把球放下,由于地球引力,它会开始向山底(最小值)滚动。它滚动的距离越长,速度就会越快。换句话说,它会获得动量(这也是优化名称的由来)。

现在,我们来看一下如何在权重更新规则中实现动量。回顾我们在梯度下降部分中介绍的更新规则,即 ![]。假设我们正处于训练过程的第 t 步:

  1. 首先,我们通过考虑前一次更新的速度 v[t-1],计算当前的权重更新值 v[t]

这里,μ 是一个范围在 [0:1] 之间的超参数,称为动量率。v[t] 在第一次迭代时初始化为 0。

  1. 然后,我们执行实际的权重更新:

相较于基本动量,Nesterov 动量提出了一种改进。它依赖于这样一个观察:来自 t-1 步骤的动量可能无法反映 t 步骤时的条件。例如,假设在 t-1 时刻的梯度很陡峭,因此动量很大。然而,在 t-1 的权重更新后,我们实际上达到了成本函数的最小值,并且在 t 时刻只需要进行一次小的权重更新。尽管如此,我们仍然会得到来自 t-1 的大动量,这可能会导致调整后的权重跳过最小值。Nesterov 动量提出了一种新的计算权重更新速度的方法。我们将基于成本函数梯度计算 v[t],该梯度是通过未来潜在的权重值 θ[j] 计算得出的。以下是更新后的速度公式:

如果在 t-1 时刻的动量与 t 时刻不匹配,修改后的梯度将在同一次更新步骤中弥补这个误差。

接下来,让我们讨论 Adam 自适应学习率算法(Adam: A Method for Stochastic Optimizationarxiv.org/abs/1412.6980)。它根据之前的权重更新(动量)为每个权重计算个别的自适应学习率。我们来看看它是如何工作的:

  1. 首先,我们需要计算梯度的第一矩(或均值)和第二矩(或方差):

这里,β[1]和β[2]是超参数,默认值分别为 0.9 和 0.999。m[t]v[t]充当梯度的移动平均值,类似于动量。它们在第一次迭代时初始化为 0。

  1. 由于m[t]v[t]初始值为 0,它们在训练的初期阶段会有偏向于 0 的偏差。例如,假设在t=1, β1=0.9,且=10。那么,m1 = 0.9 * 0 + (1 - 0.9) * 10 = 1,这比实际的梯度 10 要小得多。为了补偿这个偏差,我们将计算m[t]v[t]的偏差校正版本:

  1. 最后,我们需要使用以下公式进行权重更新:

这里,η是学习率,ε是一个小值,用来防止除以 0。

总结

我们从本章开始时,通过一个关于神经网络(NN)基础数学工具的教程,然后回顾了神经网络及其架构。在此过程中,我们尝试明确地将数学概念与神经网络的各个组成部分连接起来。我们特别关注了各种类型的激活函数。最后,我们全面回顾了神经网络训练过程,讨论了梯度下降、代价函数、反向传播、权重初始化和 SGD 优化技术。

在下一章,我们将讨论卷积网络的复杂性及其在计算机视觉领域的应用。

第二部分:计算机视觉

本节将讨论深度学习DL)在计算机视觉领域的应用。我们将讨论卷积网络、物体检测与图像分割、生成模型(GANs)以及神经风格迁移。

本节包含以下章节:

  • 第二章,理解卷积网络

  • 第三章,高级卷积网络

  • 第四章,物体检测与图像分割

  • 第五章,生成模型

第二章:理解卷积网络

本章将讨论卷积神经网络CNNs)及其在计算机视觉CV)中的应用。CNNs 启动了现代深度学习的革命。它们是几乎所有最近 CV 进展的基础,包括生成对抗网络GANs)、目标检测、图像分割、神经风格迁移等。因此,我们认为 CNNs 值得深入探讨,超越我们对它们的基本理解。

为此,我们将从简短回顾 CNN 的构建块开始,也就是卷积层和池化层。我们将讨论今天使用的各种类型的卷积,因为它们在大量的 CNN 应用中有所体现。我们还将学习如何可视化 CNN 的内部状态。接着,我们将重点讲解正则化技术并实现一个迁移学习的例子。

本章将涵盖以下主题:

  • 理解 CNN

  • 引入迁移学习

理解 CNN

在第一章中,《神经网络的基础》,我们讨论了许多 NN 操作都有坚实的数学基础,卷积也不例外。让我们从定义数学卷积开始:

在这里,我们有以下内容:

  • 卷积运算用*表示。

  • fg是具有共同参数t的两个函数。

  • 卷积的结果是第三个函数,s(t)(而不仅仅是一个单一的值)。

fg在值t处的卷积是f(t)g(t-τ)的逆向(镜像)和位移后的值的乘积的积分,其中t-τ表示位移。也就是说,对于时间tf的单个值,我们在范围内平移g ,并且由于积分的缘故,我们不断计算乘积f(t)**g(t-τ)。积分(因此卷积)等同于两个函数乘积曲线下的面积。

下面的图示最能说明这一点:

左:一个卷积,其中g被平移并反转;右:卷积运算的逐步演示

在卷积运算中,为了保持运算的交换性,g被平移并反转。在 CNN 的上下文中,我们可以忽略这个特性,并且我们可以在不反转g的情况下实现它。在这种情况下,这个运算称为互相关。这两个术语是可以互换使用的。

我们可以用以下公式为离散(整数)值t定义卷积(这与连续情况非常相似):

我们还可以将其推广到具有两个共享输入参数ij的函数卷积:

我们可以以类似的方式推导出三个参数的公式。

在卷积神经网络(CNNs)中,函数 f 是卷积操作的输入(也称为卷积层)。根据输入维度的数量,我们有 1D、2D 或 3D 卷积。时间序列输入是 1D 向量,图像输入是 2D 矩阵,3D 点云是 3D 张量。另一方面,函数 g 被称为核(或滤波器)。它的维度与输入数据相同,并由一组可学习的权重定义。例如,2D 卷积的大小为 n 的滤波器是一个 n×n 矩阵。下图展示了 2D 卷积,使用 2×2 滤波器应用于单个 3×3 切片:

2D 卷积,使用 2×2 滤波器应用于单个 3×3 切片

卷积的工作原理如下:

  1. 我们将滤波器滑动通过输入张量的所有维度。

  2. 在每个输入位置,我们将每个滤波器权重与给定位置对应的输入张量单元相乘。贡献到单个输出单元的输入单元称为 感受野。我们将所有这些值相加,得到单个输出单元的值。

与全连接层不同,在全连接层中,每个输出单元都从所有输入中收集信息,而卷积输出单元的激活是由其感受野中的输入决定的。这个原理最适用于层次结构数据,例如图像。例如,邻近的像素形成有意义的形状和物体,但图像一端的像素与另一端的像素之间不太可能有关系。使用全连接层将所有输入像素与每个输出单元连接,就像是让网络在大海捞针。它无法知道某个输入像素是否在输出单元的感受野内。

该滤波器在感受野中突出显示某些特定特征。操作的输出是一个张量(称为特征图),标记了检测到特征的位置。由于我们在整个输入张量上应用相同的滤波器,卷积是平移不变的;也就是说,它可以检测到相同的特征,无论它们在图像中的位置如何。然而,卷积既不是旋转不变的(如果特征旋转,它不保证检测到该特征),也不是尺度不变的(它不保证在不同尺度下检测到相同的特征)。

在下图中,我们可以看到 1D 和 3D 卷积的示例(我们已经介绍了 2D 卷积的一个示例):

1D 卷积:滤波器(用多色线表示)沿单一轴滑动;3D 卷积:滤波器(用虚线表示)沿三个轴滑动

CNN 卷积可以有多个滤波器,用来突出不同的特征,这样就会生成多个输出特征图(每个滤波器一个)。它也可以从多个特征图中收集输入,例如,来自先前卷积的输出。特征图(输入或输出)的组合称为体积。在这个上下文中,我们也可以将特征图称为切片。虽然这两个术语指的是同一事物,但我们可以把切片看作是体积的一部分,而特征图则突出了它作为特征图的角色。

正如我们在本节前面提到的,每个体积(以及滤波器)都是通过张量表示的。例如,一个红、绿、蓝(RGB)图像通过一个包含三个 2D 切片(每个颜色通道一个切片)的 3D 张量表示。但是,在 CNN 的上下文中,我们为每个样本的 mini-batch 添加了一个维度。在这种情况下,1D 卷积将具有 3D 输入和输出张量。它们的轴可以是 NCWNWC 顺序,其中 N 是 mini-batch 中样本的索引,C 是体积中深度切片的索引,W 是每个样本的向量大小。以相同的方式,2D 卷积将由 NCHWNHWC 张量表示,其中 HW 分别是切片的高度和宽度。3D 卷积将采用 NCLHWNLHWC 顺序,其中 L 表示切片的深度。

我们使用 2D 卷积来处理 RGB 图像。然而,我们也可以将三种颜色视为一个额外的维度,从而将 RGB 图像视为 3D。那么为什么我们不使用 3D 卷积呢?原因是,尽管我们可以把输入看作是 3D 的,但输出仍然是一个 2D 网格。如果我们使用 3D 卷积,输出也会是 3D 的,而在 2D 图像的情况下,这没有任何意义。

假设我们有 n 个输入切片和 m 个输出切片。在这种情况下,我们会对 n 个输入切片应用 m 个滤波器。每个滤波器将生成一个独特的输出切片,突出显示该滤波器检测到的特征(nm 的关系)。

根据输入和输出切片的关系,我们可以得到跨通道卷积和深度卷积,如下图所示:

左图:跨通道卷积;右图:深度卷积

让我们讨论它们的属性:

  • 跨通道卷积:一个输出切片从所有输入切片接收输入(n 到一的关系)。当有多个输出切片时,关系变为 nm。换句话说,每个输入切片都会对每个输出切片的输出产生贡献。每对输入/输出切片都会使用一个与该对独特的滤波器切片。我们用 F 来表示滤波器的大小(宽度和高度相等),用 C[in] 来表示输入体积的深度,用 C[out] 来表示输出体积的深度。有了这些,我们可以通过以下公式计算 2D 卷积的总权重数 W

在这里,+1 表示每个滤波器的偏置权重。假设我们有三个切片,并且希望对它们应用四个 5×5 的滤波器。如果这样做,卷积滤波器将有总计 (355 + 1) * 4 = 304 个权重,四个输出切片(深度为 4 的输出体积),每个切片有一个偏置。每个输出切片的滤波器将对每个输入切片应用三个 5×5 的滤波器补丁,并有一个偏置,总共有 355 + 1 = 76 个权重。

  • Depthwise convolutions:每个输出切片仅接收来自单个输入切片的输入。这是之前情况的逆转。在最简单的形式中,我们对单个输入切片应用一个滤波器,生成一个单一的输出切片。在这种情况下,输入和输出的体积具有相同的深度,即 C。我们还可以指定一个 通道倍增器(一个整数,m),我们在单个输出切片上应用 m 个滤波器,从而生成 m 个输出切片。这是一个一对-m 的关系。在这种情况下,输出切片的总数是 n * m。我们可以用以下公式计算 2D 深度卷积中的权重 W

在这里,m 是通道倍增器,+C 表示每个输出切片的偏置。

卷积操作还通过其他两个参数来描述:

  • Stride 是在每一步中,我们滑动滤波器在输入切片上的位置数。默认情况下,步幅为 1。如果大于 1,则称之为 步幅卷积。较大的步幅增加了输出神经元的感受野。步幅为 2 时,输出切片的大小大约是输入的四分之一。换句话说,步幅为 1 的卷积中,一个输出神经元的感受野是步幅为 2 时的四倍。后续层中的神经元将逐渐捕获来自输入图像较大区域的信息。

  • Padding 在卷积操作之前,用零填充输入切片的边缘,增加行和列。最常见的填充方式是使输出的尺寸与输入相同。新增的零将参与与切片的卷积操作,但不会影响结果。

知道输入的尺寸和滤波器的大小后,我们可以计算输出切片的尺寸。假设输入切片的大小为 I(高度和宽度相等),滤波器的大小为 F,步幅为 S,填充为 P。此时,输出切片的大小 O 由以下公式给出:

除了步幅卷积,我们还可以使用池化操作来增加深层神经元的感受野,并减少未来切片的大小。池化将输入切片划分为网格,每个网格单元代表多个神经元的感受野(就像卷积操作一样)。然后,在网格的每个单元上应用池化操作。类似于卷积,池化通过步幅,S,和感受野的大小,F,来描述。如果输入切片的大小为 I,则池化输出大小的公式如下:

在实践中,通常只使用两种组合。第一种是步幅为 2 的 2×2 感受野,第二种是步幅为 2 的 3×3 感受野(重叠)。最常见的池化操作如下:

  • 最大池化: 这个操作传播感受野内输入值的最大值。

  • 平均池化: 这个操作传播感受野内输入值的平均值。

  • 全局平均池化 (GAP): 这与平均池化相同,但池化区域与特征图的大小相同,I×I。GAP 执行一种极端的降维操作:输出是一个单一的标量,表示整个特征图的平均值。

通常,我们会交替使用一个或多个卷积层与一个池化(或步幅卷积)层。通过这种方式,卷积层可以在每个感受野大小的层次上检测特征,因为较深层的感受野大小要大于网络初始层的感受野。深层的层也比最初的层拥有更多的滤波器(因此,具有更高的体积深度)。网络初始阶段的特征检测器工作在较小的感受野上,它只能检测到有限的特征,如边缘或线条,这些特征在所有类别中都有共享。

另一方面,较深的层会检测到更复杂和更多的特征。例如,如果我们有多个类别,如汽车、树木或人,每个类别都会有一组特征,如轮胎、车门、树叶和面孔。这将需要更多的特征检测器。最终卷积(或池化)的输出通过添加一个或多个全连接层“翻译”到目标标签。

现在我们已经对卷积、池化操作和卷积神经网络(CNN)有了一个概述,在下一节中,我们将重点讨论不同类型的卷积操作。

卷积类型

到目前为止,我们讨论了最常见的卷积类型。在接下来的章节中,我们将讨论它的一些变体。

转置卷积

在我们目前讨论的卷积操作中,输出的维度要么与输入相等,要么小于输入的维度。与此相反,反卷积(最早由 Matthew D. Zeiler、Dilip Krishnan、Graham W. Taylor 和 Rob Fergus 在《反卷积网络》中提出:www.matthewzeiler.com/mattzeiler/deconvolutionalnetworks.pdf)允许我们对输入数据进行上采样(其输出大于输入)。这种操作也被称为反卷积分数步幅卷积子像素卷积。这些名称有时会导致混淆。为了澄清这一点,请注意,反卷积实际上是一个常规卷积,只是对输入切片或卷积滤波器进行稍微修改。

对于更长的解释,我们将从一个 1D 常规卷积开始,考虑单个输入和输出切片:

1D 常规卷积

它使用一个大小为 4、步幅为 2、填充为 2 的滤波器(在上面的图中用灰色表示)。输入是一个大小为 6 的向量,输出是一个大小为 4 的向量。滤波器是一个向量f = [1, 2, 3, 4],它始终是相同的,但我们根据应用它的位置使用不同的颜色表示。对应的输出单元也用相同的颜色表示。箭头表示哪些输入单元贡献给一个输出单元。

本节讨论的示例灵感来源于论文《反卷积层是否与卷积层相同?》(arxiv.org/abs/1609.07009)。

接下来,我们将讨论相同的示例(1D,单个输入和输出切片,滤波器大小为 4,填充为 2,步幅为 2),但对于反卷积。下图展示了我们可以实现它的两种方式:

左:步幅为 2 的卷积,应用了反卷积滤波器f。输出的开始和结束处的 2 个像素被裁剪;右:步幅为 0.5 的卷积,应用于输入数据,并通过子像素填充。输入被填充为 0 值像素(灰色)。

让我们详细讨论它们:

  • 在第一个案例中,我们有一个步幅为 2 的常规卷积,滤波器表示为反转的行矩阵(等同于列矩阵),大小为 4:(如上图左所示)。请注意,步幅是应用于输出层的,而常规卷积则是应用于输入层的。通过将步幅设置为大于 1,我们可以增加输出的大小,相对于输入。在这里,输入切片的大小为I,滤波器的大小为F,步幅为S,输入的填充为P。因此,反卷积的输出切片大小O可以通过以下公式计算:

    在这种情况下,大小为 4 的输入产生的输出大小为2(4 - 1) + 4 - 22 = 6。我们还会裁剪输出向量的开始和结束部分的两个单元,因为它们只收集来自单个输入单元的信息。

  • 在第二种情况下,输入填充了存在的像素之间的虚拟 0 值子像素(如前面的图所示,右侧)。这就是“子像素卷积”这一名称的由来。可以将其视为一种填充,但它发生在图像内部,而不仅仅是在边缘上。一旦输入以这种方式被转换,就会应用常规卷积。

让我们比较两种情况中的两个输出单元,o[1]和o[3]。如前面的图所示,在这两种情况下,o[1]接收来自第一个和第二个输入单元的信息,o[3]接收来自第二个和第三个单元的信息。实际上,这两种情况之间唯一的区别是参与计算的权重索引。然而,权重是在训练过程中学习的,因此,索引并不重要。因此,这两种操作是等效的。

接下来,我们从子像素的角度来看一个 2D 转置卷积(输入在底部)。与 1D 情况类似,我们在输入切片中插入 0 值像素和填充,以实现上采样:

具有填充为 1 和步幅为 2 的 2D 转置卷积的前三个步骤:来源:github.com/vdumoulin/conv_arithmeticarxiv.org/abs/1603.07285

常规卷积的反向传播操作是转置卷积。

1×1 卷积

1×1(或逐点)卷积是卷积的一种特殊情况,其中卷积滤波器的每个维度的大小为 1(2D 卷积中的 1×1 和 3D 卷积中的 1×1×1)。起初,这似乎没有意义——1×1 的滤波器并不会增加输出神经元的感受野大小。此类卷积的结果将是逐点缩放。但它在另一个方面是有用的——我们可以用它们来改变输入和输出体积之间的深度。

为了理解这一点,我们回顾一下,通常情况下,我们有一个深度为D切片的输入体积,以及M个滤波器用于生成M个输出切片。每个输出切片都是通过将唯一的滤波器应用于所有输入切片生成的。如果我们使用 1×1 的滤波器且D != M,我们会得到相同大小的输出切片,但深度不同。同时,我们不会改变输入和输出之间的感受野大小。最常见的用例是减少输出体积,或D > M(降维),俗称“瓶颈”层。

深度可分离卷积

在跨通道卷积中,一个输出切片接收来自所有输入切片的输入,使用一个滤波器。该滤波器试图在三维空间中学习特征,其中两个维度是空间维度(切片的高度和宽度),第三个维度是通道。因此,滤波器映射了空间和跨通道的相关性。

深度可分离卷积DSCXception: Deep Learning with Depthwise Separable Convolutionsarxiv.org/abs/1610.02357)可以完全解耦跨通道和空间相关性。DSC 结合了两种操作:深度卷积和 1×1 卷积。在深度卷积中,一个输入切片产生一个输出切片,因此它只映射空间相关性(而不是跨通道相关性)。而在 1×1 卷积中,我们恰恰相反。以下图示表示了 DSC:

深度可分离卷积

深度可分离卷积(DSC)通常在第一次(深度)操作后没有非线性激活。

让我们比较标准卷积和深度可分离卷积。假设我们有 32 个输入和输出通道,以及一个大小为 3×3 的滤波器。在标准卷积中,一个输出切片是对 32 个输入切片中每一个应用一个滤波器的结果,总共需要32 * 3 * 3 = 288个权重(不包括偏置)。在一个可比的深度卷积中,滤波器只有3 * 3 = 9个权重,而 1×1 卷积的滤波器有32 * 1 * 1 = 32个权重。总权重数是32 + 9 = 41。因此,深度可分离卷积相对于标准卷积来说,速度更快且更节省内存。

空洞卷积

回顾我们在《CNN 快速回顾》章节开始时介绍的离散卷积公式。为了说明空洞卷积(Multi-Scale Context Aggregation by Dilated Convolutionsarxiv.org/abs/1511.07122),我们从以下公式开始:

我们用**[l]表示空洞卷积,其中l是一个正整数,称为膨胀因子。关键在于我们如何在输入上应用滤波器。不同于在n×n感受野上应用n×n滤波器,我们将相同的滤波器稀疏地应用于一个大小为(nl-1)×(nl-1)的感受野。我们依然将每个滤波器权重与一个输入切片单元相乘,但这些单元之间相隔l的距离。常规卷积是空洞卷积的一个特例,当l=1*时。以下图示可以最好地说明这一点:

膨胀因子 l=2 的空洞卷积:这里显示了操作的前两步。底层是输入层,而顶层是输出层。来源:github.com/vdumoulin/conv_arithmetic

膨胀卷积可以在不损失分辨率或覆盖范围的情况下指数级地增加感受野的大小。我们也可以通过步长卷积或池化来增加感受野,但代价是分辨率和/或覆盖范围的损失。为了理解这一点,我们假设有一个步长为s>1的步长卷积。在这种情况下,输出切片比输入小s倍(分辨率损失)。如果我们进一步增加s>nn为池化或卷积核的大小),则会损失覆盖范围,因为输入切片的某些区域将完全不参与输出。此外,膨胀卷积不会增加计算和内存成本,因为滤波器使用的权重数量与常规卷积相同。

提高卷积神经网络(CNNs)的效率

最近深度学习DL)取得进展的主要原因之一是它能够非常快速地运行神经网络NNs)。这在很大程度上是因为神经网络算法的性质与图形处理单元GPU)的具体特点非常契合。在第一章《神经网络的基本原理》中,我们强调了矩阵乘法在神经网络中的重要性。对此的证明是,卷积也可以转化为矩阵乘法。矩阵乘法是极其并行的(相信我,这是一个术语——你可以在 Google 上查找它!)。每个输出单元的计算与其他任何输出单元的计算无关。因此,我们可以并行计算所有输出。

并非巧合,GPU 非常适合执行像这样的高并行操作。一方面,GPU 的计算核心数量远高于中央处理单元CPU)。尽管 GPU 核心的速度比 CPU 核心快,但我们仍然可以并行计算更多的输出单元。但更重要的是,GPU 在内存带宽方面进行了优化,而 CPU 则在延迟方面进行了优化。这意味着 CPU 可以非常快速地获取小块内存,但在获取大块内存时会变得缓慢。GPU 则相反。因此,在像神经网络(NNs)的大矩阵乘法这样的任务中,GPU 具有优势。

除了硬件方面的优化,我们也可以在算法方面优化 CNN。CNN 中的大部分计算时间都花费在卷积操作上。尽管卷积的实现本身相对简单,但实际上有更高效的算法可以实现相同的结果。尽管现代的深度学习库如 TensorFlow 或 PyTorch 能够将开发者从这些细节中抽象出来,但在本书中,我们旨在更深入(这可是双关语哦)地理解深度学习。

因此,在接下来的部分,我们将讨论两种最流行的快速卷积算法。

卷积作为矩阵乘法

在本节中,我们将描述将卷积转化为矩阵乘法的算法,正如它在 cuDNN 库中实现的那样(cuDNN: 高效的深度学习原语arxiv.org/abs/1410.0759)。为了理解这一点,我们假设对 RGB 输入图像执行跨通道二维卷积。让我们查看下面的表格,了解卷积的参数:

参数 符号
小批量大小 N 1
输入特征图(体积深度) C 3(每个 RGB 通道一个)
输入图像高度 H 4
输入图像宽度 W 4
输出特征图(体积深度) K 2
滤波器高度 R 2
滤波器宽度 S 2
输出特征图高度 P 2(基于输入/滤波器的大小)
输出特征图宽度 Q 2(基于输入/滤波器的大小)

为了简化,我们假设使用零填充和步幅为 1。我们将输入张量表示为D,卷积滤波器张量表示为F。矩阵卷积按以下方式工作:

  1. 我们将张量DF分别展开为 矩阵。

  2. 然后,我们通过矩阵相乘来得到输出矩阵,

我们在第一章中讨论了矩阵乘法,神经网络的基础。现在,让我们关注如何将张量展开成矩阵。下面的图示展示了如何做到这一点:

卷积作为矩阵乘法;灵感来源于 https://arxiv.org/abs/1410.0759

每个特征图有不同的颜色(R,G,B)。在常规卷积中,滤波器是方形的,并且我们将其应用于方形的输入区域。在变换中,我们将D中的每个可能的方形区域展开成D[m]的一个列。然后,我们将F的每个方形组件展开为F[m]的一个行。通过这种方式,每个输出单元的输入和滤波器数据都位于矩阵D[m]F[m]的单独列/行中。这使得通过矩阵乘法计算输出值成为可能。变换后的输入/滤波器/输出的维度如下:

  • dim(D[m]) = CRS×NPQ = 12×4

  • dim(F[m]) = K×CRS = 2×12

  • dim(O[m]) = K×NPQ = 2×4

为了理解这个变换,让我们学习如何使用常规卷积算法计算第一个输出单元格:

接下来,让我们观察相同的公式,不过这次是以矩阵乘法的形式:

如果我们比较两个方程的组成部分,会发现它们完全相同。即,D[0,0,0,0] = D[m][0,0],F[0,0,0,0] = F[m][0,0],D[0,0,0,1] = D[m][0,1],F[0,0,0,1] = F[m][0,1],以此类推。我们可以对其余的输出单元进行相同的操作。因此,两种方法的输出是相同的。

矩阵卷积的一个缺点是增加了内存使用量。在前述图示中,我们可以看到一些输入元素被多次重复(最多可达 RS = 4 次,例如 D4)。

Winograd 卷积

Winograd 算法(卷积神经网络的快速算法arxiv.org/abs/1509.09308)可以提供相较于直接卷积 2 或 3× 的加速。为了解释这一点,我们将使用在 卷积作为矩阵乘法 部分中相同的符号,但使用 3×3R=S=3)滤波器。我们还假设输入切片大于 4×4H>4, W>4)。

下面是计算 Winograd 卷积的方法:

  1. 将输入图像划分为 4×4 个重叠的块,步长为 2,如下图所示:

输入被划分为多个块

块大小可以有所变化,但为了简便起见,我们只关注 4×4 的块。

  1. 使用以下两个矩阵乘法变换每个块:

在前面的公式中,矩阵 D 是输入切片(带有圆形值的那个),而 B 是一个特殊矩阵,它源于 Winograd 算法的特性(您可以在本节开头的论文中找到更多相关信息)。

  1. 使用以下两个矩阵乘法变换滤波器:

在前面的公式中,矩阵 F(带有点值的那个)是一个输入和输出切片之间的 3×3 卷积滤波器。G 及其转置 是 Winograd 算法的特殊矩阵。请注意,变换后的滤波器矩阵 F[t] 与输入块 D[t] 具有相同的维度。

  1. 通过对变换后的输入和滤波器进行 元素级 相乘(即 符号),计算变换后的输出:

  1. 将输出转换回其原始形式:

A 是一个变换矩阵,它使得将 转换回从直接卷积得到的形式成为可能。正如前述公式和下图所示,Winograd 卷积允许我们同时计算 2×2 输出块(四个输出单元):

Winograd 卷积允许我们同时计算四个输出单元。

乍一看,似乎 Winograd 算法执行的操作比直接卷积多得多。那么,它为什么更快呢?为了找出答案,让我们集中关注 转换。关键在于,我们只需要执行一次 ,然后 D[t] 可以参与所有 K(按照符号表示)输出切片的输出。因此, 在所有输出中进行摊销,并且它对性能的影响不大。接下来,让我们看看 转换。这一步甚至更好,因为一旦我们计算出 F[t],我们可以将其应用于 N×P×Q 次(遍历输出切片的所有单元格以及批次中的所有图像)。因此,这一转换的性能损失可以忽略不计。同样,输出转换 也在输入通道数 C 上进行摊销。

最后,我们将讨论逐元素乘法,,它应用于输出切片的所有单元格,P×Q 次,并占据了大部分计算时间。它由 16 次标量乘法操作组成,允许我们计算 2×2 的输出瓦片,这意味着每个输出单元需要进行四次乘法操作。与直接卷积相比,我们需要执行 33=9* 次标量乘法(每个滤波器元素与每个感受野输入单元相乘),才能得到一个输出。因此,Winograd 卷积比直接卷积减少了 9/4 = 2.25 次操作。

Winograd 卷积在使用较小的滤波器尺寸时(例如,3×3)效果最佳。使用较大的滤波器(例如,11×11)的卷积可以通过快速傅里叶变换(FFT)卷积高效实现,这超出了本书的讨论范围。

在下一节中,我们将尝试通过可视化 CNN 的内部状态来理解其内部工作原理。

可视化 CNN

神经网络(NN)的一个批评是它们的结果不可解释。通常认为神经网络是一个黑盒,其内部逻辑对我们是隐藏的。这可能是一个严重的问题。一方面,我们不太可能信任一个我们不了解其工作原理的算法;另一方面,如果我们不知道神经网络是如何工作的,就很难提高 CNN 的准确性。正因为如此,在接下来的章节中,我们将讨论两种可视化 CNN 内部层的方法,这两种方法将帮助我们深入了解它们的学习方式。

引导反向传播

引导反向传播(Striving for Simplicity: The All Convolutional Net, arxiv.org/abs/1412.6806)使我们能够可视化 CNN 中某一层的单个单元所学到的特征。下图展示了算法的工作原理:

引导反向传播可视化;灵感来自于 arxiv.org/abs/1412.6806

下面是逐步执行的过程:

  1. 首先,我们从一个常规的 CNN 开始(例如,AlexNet、VGG 等),并使用 ReLU 激活函数。

  2. 然后,我们将一个单一图像 f^((0)) 输入网络,并向前传播,直到我们到达感兴趣的层 l。这可以是任何网络层——隐藏层或输出层,卷积层或全连接层。

  3. 将该层输出张量 f^((l)) 的所有激活值设置为 0,除了一个。例如,如果我们对分类网络的输出层感兴趣,我们将选择激活值最大(等同于预测类别)的单元,并将其值设置为 1,其余单元设置为 0。通过这种方式,我们可以隔离出感兴趣的单元,查看哪些输入图像部分对其影响最大。

  4. 最后,我们将选定单元的激活值向后传播,直到到达输入层并恢复图像 R^((0))。反向传播过程与常规反向传播非常相似(但并不完全相同),即我们仍然使用转置卷积作为前向卷积的反向操作。然而,在这种情况下,我们关注的是图像恢复特性,而不是误差传播。因此,我们不受限于传播一阶导数(梯度)的要求,能够以一种改善可视化的方式修改信号。

为了理解反向传播,我们将使用一个示例卷积,输入和输出都是单个 3×3 的切片。假设我们使用一个 1×1 的滤波器,并且滤波器的权重为 1(我们重复输入)。下图展示了这个卷积操作,以及实现反向传播的三种不同方式:

卷积和三种不同的图像重构方式;灵感来自于 arxiv.org/abs/1412.6806

让我们详细讨论这三种不同的反向传播实现方式:

  • 常规反向传播:反向信号依赖于输入图像,因为它也依赖于前向激活(见 第一章,神经网络的基本原理,在 反向传播 部分)。我们的网络使用 ReLU 激活函数,因此信号只会通过前向传播中激活值为正的单元。

  • 反卷积网络deconvnetarxiv.org/abs/1311.2901):层 l 的反向信号仅依赖于层 l+1 的反向信号。deconvnet 只会将 l+1 的正值路由到 l,不管正向激活如何。理论上,信号完全不依赖于输入图像。在这种情况下,deconvnet 会根据其内部知识和图像类别尝试恢复图像。然而,这并不完全正确——如果网络包含最大池化层,deconvnet 会存储每个池化层的所谓 开关。每个开关代表正向传播中最大激活值的单元的映射。该映射决定了如何在反向传播中路由信号(你可以在源论文中阅读更多内容)。

  • 引导反向传播:这是 deconvnet 和常规反向传播的结合。它只会将正向激活在 l 层中以及正向反向激活在 l+1 层中的信号传递。这为常规反向传播增加了额外的引导信号(因此得名),来自更高层次的指导信号。在本质上,这一步骤防止了负梯度在反向传播中流动。其原理是,作为抑制我们起始单元的单位将被阻止,重建的图像将不再受其影响。引导反向传播表现得如此出色,以至于它不需要使用 deconvnet 开关,而是将信号传递给每个池化区域中的所有单元。

以下截图显示了使用引导反向传播和 AlexNet 生成的重建图像:

从左到右:原始图像、颜色重建、以及使用引导反向传播在 AlexNet 上的灰度重建;这些图像是通过 github.com/utkuozbulak/pytorch-cnn-visualizations 生成的。

梯度加权类激活映射

要理解梯度加权类激活映射(Grad-CAM: Visual Explanations from Deep Networks via Gradient-Based Localizationarxiv.org/abs/1610.02391),让我们引用源论文本身:

"Grad-CAM 使用任何目标概念(例如,‘狗’的逻辑回归值或甚至一个标题)的梯度,这些梯度流入最终的卷积层,以生成一个粗略的定位图,突出显示图像中对于预测该概念的重要区域。"

以下截图显示了 Grad-CAM 算法:

Grad-CAM 方案;来源:arxiv.org/abs/1610.02391

现在,让我们看看它是如何工作的:

  1. 首先,你从一个分类 CNN 模型开始(例如,VGG)。

  2. 然后,你将一张单一的图像输入到 CNN 中,并将其传播到输出层。

  3. 如同我们在引导反向传播中所做的那样,我们选取具有最大激活的输出单元(等价于预测类别 c),将其值设为 1,并将其他所有输出设为 0。换句话说,创建一个预测的 one-hot 编码向量 y^c

  4. 接下来,使用反向传播计算 y^c 相对于最终卷积层的特征图 A^k 的梯度, 。其中 ij 是特征图中的单元坐标。

  5. 然后,计算标量权重 ,它衡量特征图 k 对预测类别 c 的“重要性”:

  1. 最后,计算标量权重与最终卷积层的前向激活特征图之间的加权组合,并随后使用 ReLU:

请注意,我们将标量重要性权重  乘以张量特征图 A^k。结果是一个与特征图相同维度的热力图(对于 VGG 和 AlexNet,维度为 14×14)。它会突出显示对类别 c 最重要的特征图区域。ReLU 会丢弃负激活,因为我们只关心那些能增加 y^c 的特征。我们可以将这个热力图上采样回输入图像的大小,然后叠加在图像上,如下图所示:

从左到右:输入图像;上采样后的热力图;热力图叠加在输入图像上(RGB);灰度热力图。图像是使用 https://github.com/utkuozbulak/pytorch-cnn-visualizations 生成的。

Grad-CAM 的一个问题是将热力图从 14×14 上采样到 224×224,因为它无法提供每个类别的重要特征的细粒度视角。为了缓解这个问题,论文的作者提出了 Grad-CAM 和引导反向传播(在本节开始的 Grad-CAM 示意图中显示)的结合。我们将上采样的热力图与引导反向传播的可视化进行逐元素相乘。输入图像包含两种物体:一只狗和一只猫。因此,我们可以对这两种类别运行 Grad-CAM(图示的两行)。这个例子展示了不同类别在同一图像中检测不同相关特征的方法。

在下一节中,我们将讨论如何借助正则化来优化 CNN。

CNN 正则化

正如我们在第一章,神经网络的基本原理中讨论的那样,神经网络(NN)可以逼近任何函数。但强大的能力伴随着巨大的责任。神经网络可能会学会逼近目标函数的噪声部分,而不是有用的成分。例如,假设我们正在训练一个神经网络来分类图像是否包含汽车,但由于某种原因,训练集中的大多数汽车是红色的。结果可能是神经网络将红色与汽车关联起来,而不是它的形状。现在,如果网络在推理模式下看到一辆绿色汽车,可能会因为颜色不匹配而无法识别它。这种问题被称为过拟合,是机器学习中的核心问题(在深度网络中尤其如此)。在本节中,我们将讨论几种防止过拟合的方法。这些技术统称为正则化

在神经网络的上下文中,这些正则化技术通常会对训练过程施加一些人工的限制或障碍,以防止网络过于精确地逼近目标函数。它们试图引导网络学习目标函数的通用而非特定的逼近方式,希望这种表示方法能够很好地泛化到测试数据集中之前未见过的示例。你可能已经熟悉其中的许多技术,因此我们会简要介绍:

  • 输入特征缩放。此操作将所有输入缩放到[0, 1]范围内。例如,一个强度为 125 的像素会有一个缩放值为。特征缩放实施起来既快速又简单。

  • 输入标准分数。这里,μ和σ是所有训练数据的均值和标准差。它们通常是针对每个输入维度单独计算的。例如,在 RGB 图像中,我们会为每个通道计算均值μσ。我们需要注意的是,μσ必须在训练数据上计算,然后应用到测试数据上。或者,如果不便于在整个数据集上计算,我们可以按样本计算μσ

  • 数据增强:这是通过在将训练样本输入网络之前,应用随机修改(如旋转、扭曲、缩放等)来人为地增加训练集的大小。

  • L2 正则化(或权重衰减):在这里,我们在成本函数中添加一个特殊的正则化项。假设我们使用的是均方误差(MSE)(第一章,神经网络的基本原理梯度下降部分)。这里,MSE + L2 正则化的公式如下:

在这里,w[j] 是总共 k 个网络权重中的一个,λ 是权重衰减系数。其基本原理是,如果网络权重 w[j] 较大,那么成本函数也会增加。实际上,权重衰减对大权重进行惩罚(因此得名)。这可以防止网络过于依赖与这些权重相关的少数特征。当网络被迫使用多个特征时,过拟合的可能性较小。在实际应用中,当我们计算权重衰减成本函数(前述公式)相对于每个权重的导数,并将其传播到权重本身时,权重更新规则从 变为

  • Dropout(丢弃法):在此过程中,我们随机并定期从网络中移除一些神经元(连同它们的输入输出连接)。在训练的小批次期间,每个神经元有 p 的概率被随机丢弃。这是为了确保没有神经元过于依赖其他神经元,而是“学习”对网络有用的东西。

  • 批量归一化BNBatch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shiftarxiv.org/abs/1502.03167):这是一种应用数据处理的方法,类似于标准分数,适用于网络的隐藏层。它对每个小批次的隐藏层输出进行归一化(因此得名),使得其均值接近 0,标准差接近 1。假设  是一个大小为 n 的小批次。每个 D 的样本是一个向量,,而 是该向量的索引为 k 的单元。为了清晰起见,以下公式中我们将省略 (k) 上标;也就是说,我们写 x[i],但指代的是 。我们可以按照以下方式计算每个激活值 k 在整个小批次上的 BN:

    1. ![]:这是小批次均值。我们分别为每个位置 k 计算 μ,然后对所有样本求平均。

    2. :这是小批次标准差。我们分别为每个位置 k 计算 σ,然后对所有样本求平均。

    3. :我们对每个样本进行归一化处理。ε 是一个常数,用于保证数值稳定性。

    4. : γβ 是可学习的参数,我们在每个位置上计算它们,k (γ^((k))β^((k))),在所有小批量样本中进行计算(μσ 也适用)。在卷积层中,每个样本,x,是一个包含多个特征图的张量。为了保持卷积特性,我们在所有样本的每个位置上计算 μσ,但在所有特征图中的匹配位置使用相同的 μσ。另一方面,我们在每个特征图上计算 γβ,而不是每个位置上。

本节结束了我们对卷积神经网络(CNN)结构和内部工作原理的分析。到这时,我们通常会继续进行一些 CNN 编程示例。但是在本书中,我们想做得稍微不同一些。因此,我们不会实现一个传统的前馈 CNN,您可能之前已经做过。相反,在下一节中,您将会接触到迁移学习技术——一种使用预训练的 CNN 模型处理新任务的方法。但请放心,我们仍然会从零开始实现一个 CNN。我们将在第三章 高级卷积网络 中实现。通过这种方式,我们将能够利用该章的知识构建一个更复杂的网络架构。

介绍迁移学习

假设我们要在一个没有像 ImageNet 那样现成的标签训练数据的任务上训练模型。标记训练样本可能会非常昂贵、耗时且容易出错。那么,当工程师想用有限的资源解决实际的机器学习问题时,该怎么办呢?这时就有了迁移学习Transfer Learning,简称TL)。

迁移学习是将一个已训练好的机器学习模型应用到一个新的但相关的问题的过程。例如,我们可以将一个在 ImageNet 上训练的网络重新用于分类超市商品。或者,我们可以使用驾驶模拟器游戏训练一个神经网络来驾驶模拟车,然后再用这个网络驾驶一辆真实的车(但不要在家尝试!)。迁移学习是一个通用的机器学习概念,适用于所有机器学习算法,但在这个上下文中,我们将讨论卷积神经网络(CNN)。它是如何工作的呢?

我们从一个已有的预训练网络开始。最常见的做法是从 ImageNet 获取一个预训练的网络,但也可以是任何数据集。TensorFlow 和 PyTorch 都有流行的 ImageNet 预训练神经网络架构供我们使用。或者,我们也可以使用自己选择的数据集训练一个网络。

CNN 末尾的全连接层充当网络语言(在训练过程中学习到的抽象特征表示)和我们语言(每个样本的类别)之间的转换器。你可以将迁移学习看作是翻译成另一种语言。我们从网络的特征开始,即最后一层卷积或池化层的输出。然后,我们将它们翻译成新任务的不同类别。我们可以通过去掉现有预训练网络的最后一层全连接层(或所有全连接层),并用另一个层替代它,后者代表新问题的类别。

看看下图所示的迁移学习(TL)场景:

在迁移学习中,我们可以替换预训练网络的全连接层,并将其重新用于新问题。

然而,我们不能机械地进行操作并期待新网络能够工作,因为我们仍然需要用与新任务相关的数据训练新层。在这里,我们有两个选项:

  • 将原网络部分作为特征提取器,只训练新层:在这种情况下,我们将网络输入一批新的训练数据并前向传播,以查看网络的输出。这一部分就像常规训练一样工作。但是在反向传播时,我们锁定原始网络的权重,只更新新层的权重。当我们在新问题上有有限的训练数据时,推荐使用这种方法。通过锁定大部分网络权重,我们可以防止在新数据上出现过拟合。

  • 微调整个网络:在这种情况下,我们将训练整个网络,而不仅仅是最后添加的新层。我们可以更新所有网络的权重,但也可以锁定前几层的部分权重。这里的思路是,初始层检测的是一般特征——与特定任务无关——因此重复使用这些层是有意义的。另一方面,深层可能会检测到与任务相关的特定特征,因此更新这些层会更好。当我们有更多训练数据并且不需要担心过拟合时,可以使用这种方法。

使用 PyTorch 实现迁移学习

现在我们知道了什么是迁移学习,让我们来看看它在实践中是否有效。在本节中,我们将使用PyTorch 1.3.1torchvision 0.4.2 包,将一个高级的 ImageNet 预训练网络应用于 CIFAR-10 图像。我们将使用两种类型的迁移学习。最好在 GPU 上运行此示例。

这个示例部分基于github.com/pytorch/tutorials/blob/master/beginner_source/transfer_learning_tutorial.py

我们开始吧:

  1. 进行以下导入:
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
from torchvision import models, transforms
  1. 为了方便起见,定义batch_size
batch_size = 50
  1. 定义训练数据集。我们需要考虑以下几个方面:

    • CIFAR-10 的图像大小是 32×32,而 ImageNet 网络期望输入为 224×224。由于我们使用的是基于 ImageNet 的网络,我们将 CIFAR 的 32×32 图像上采样到 224×224。

    • 使用 ImageNet 的均值和标准差对 CIFAR-10 数据进行标准化,因为网络需要这些数据格式。

    • 我们还将添加一些数据增强,形式是随机的水平或垂直翻转:

# training data
train_data_transform = transforms.Compose([
    transforms.Resize(224),
    transforms.RandomHorizontalFlip(),
    transforms.RandomVerticalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4821, 0.4465), (0.2470, 
    0.2435, 0.2616))
])

train_set = torchvision.datasets.CIFAR10(root='./data',
                           train=True, download=True,
                           transform=train_data_transform)

train_loader = torch.utils.data.DataLoader(train_set,
                           batch_size=batch_size,
                           shuffle=True, num_workers=2)
  1. 对验证/测试数据执行相同的步骤,但这次不进行数据增强:
val_data_transform = transforms.Compose([
    transforms.Resize(224),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4821, 0.4465), (0.2470, 0.2435, 
    0.2616))
])

val_set = torchvision.datasets.CIFAR10(root='./data',
                                  train=False, download=True,
                                  transform=val_data_transform)

val_order = torch.utils.data.DataLoader(val_set,
                                  batch_size=batch_size,
                                  shuffle=False, num_workers=2)
  1. 选择device,最好选择 GPU,如果没有则使用 CPU:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
  1. 定义模型的训练过程。与 TensorFlow 不同,在 PyTorch 中,我们需要手动遍历训练数据。这个方法会遍历整个训练集一次(一个 epoch),并在每次前向传播后应用优化器:
def train_model(model, loss_function, optimizer, data_loader):
    # set model to training mode
    model.train()

    current_loss = 0.0
    current_acc = 0

    # iterate over the training data
    for i, (inputs, labels) in enumerate(data_loader):
        # send the input/labels to the GPU
        inputs = inputs.to(device)
        labels = labels.to(device)

        # zero the parameter gradients
        optimizer.zero_grad()

        with torch.set_grad_enabled(True):
            # forward
            outputs = model(inputs)
            _, predictions = torch.max(outputs, 1)
            loss = loss_function(outputs, labels)

            # backward
            loss.backward()
            optimizer.step()

        # statistics
        current_loss += loss.item() * inputs.size(0)
        current_acc += torch.sum(predictions == labels.data)

    total_loss = current_loss / len(data_loader.dataset)
    total_acc = current_acc.double() / len(data_loader.dataset)

    print('Train Loss: {:.4f}; Accuracy: {:.4f}'.format(total_loss, 
    total_acc))
  1. 定义模型的测试/验证过程。这与训练阶段非常相似,但我们将跳过反向传播部分:
def test_model(model, loss_function, data_loader):
    # set model in evaluation mode
    model.eval()

    current_loss = 0.0
    current_acc = 0

    # iterate over  the validation data
    for i, (inputs, labels) in enumerate(data_loader):
        # send the input/labels to the GPU
        inputs = inputs.to(device)
        labels = labels.to(device)

        # forward
        with torch.set_grad_enabled(False):
            outputs = model(inputs)
            _, predictions = torch.max(outputs, 1)
            loss = loss_function(outputs, labels)

        # statistics
        current_loss += loss.item() * inputs.size(0)
        current_acc += torch.sum(predictions == labels.data)

    total_loss = current_loss / len(data_loader.dataset)
    total_acc = current_acc.double() / len(data_loader.dataset)

    print('Test Loss: {:.4f}; Accuracy: {:.4f}'.format(total_loss, 
    total_acc))

    return total_loss, total_acc
  1. 定义第一个 TL 场景,我们将预训练网络作为特征提取器:

    • 我们将使用一个流行的网络架构——ResNet-18。我们将在高级网络架构部分详细讨论这个内容。PyTorch 会自动下载预训练的权重。

    • 用具有 10 个输出的新层替换网络的最后一层(每个 CIFAR-10 类别一个输出)。

    • 排除现有的网络层的反向传播,只将新添加的全连接层传递给 Adam 优化器。

    • 训练epochs次数,并在每个 epoch 后评估网络的准确度。

    • 使用plot_accuracy函数绘制测试准确度。该函数的定义非常简单,你可以在本书的代码库中找到它。

以下是tl_feature_extractor函数,它实现了所有这些功能:

def tl_feature_extractor(epochs=5):
    # load the pretrained model
    model = torchvision.models.resnet18(pretrained=True)

    # exclude existing parameters from backward pass
    # for performance
    for param in model.parameters():
        param.requires_grad = False

    # newly constructed layers have requires_grad=True by default
    num_features = model.fc.in_features
    model.fc = nn.Linear(num_features, 10)

    # transfer to GPU (if available)
    model = model.to(device)

    loss_function = nn.CrossEntropyLoss()

    # only parameters of the final layer are being optimized
    optimizer = optim.Adam(model.fc.parameters())

    # train
    test_acc = list()  # collect accuracy for plotting
    for epoch in range(epochs):
        print('Epoch {}/{}'.format(epoch + 1, epochs))

        train_model(model, loss_function, optimizer, train_loader)
        _, acc = test_model(model, loss_function, val_order)
        test_acc.append(acc)

    plot_accuracy(test_acc)
  1. 实现微调方法。这个函数类似于tl_feature_extractor,但在这里,我们训练整个网络:
def tl_fine_tuning(epochs=5):
    # load the pretrained model
    model = models.resnet18(pretrained=True)

    # replace the last layer
    num_features = model.fc.in_features
    model.fc = nn.Linear(num_features, 10)

    # transfer the model to the GPU
    model = model.to(device)

    # loss function
    loss_function = nn.CrossEntropyLoss()

    # We'll optimize all parameters
    optimizer = optim.Adam(model.parameters())

    # train
    test_acc = list()  # collect accuracy for plotting
    for epoch in range(epochs):
        print('Epoch {}/{}'.format(epoch + 1, epochs))

        train_model(model, loss_function, optimizer, train_loader)
        _, acc = test_model(model, loss_function, val_order)
        test_acc.append(acc)

    plot_accuracy(test_acc)
  1. 最后,我们可以通过两种方式之一运行整个过程:

    • 调用tl_fine_tuning()来使用微调的迁移学习方法,训练五个 epochs。

    • 调用tl_feature_extractor()来使用特征提取方法训练网络,训练五个 epochs。

这是网络在五个 epochs 后在两种场景下的准确度:

左:特征提取 TL 准确度;右:微调 TL 准确度

由于选择的ResNet18预训练模型较大,在特征提取场景中,网络开始出现过拟合。

使用 TensorFlow 2.0 进行迁移学习

在这一节中,我们将再次实现这两种迁移学习场景,但这次使用TensorFlow 2.0.0 (TF)。通过这种方式,我们可以比较这两个库。我们将使用ResNet50V2架构(更多内容参见第三章,高级卷积网络)。除了 TF,示例还需要 TF Datasets 1.3.0 包(www.tensorflow.org/datasets),这是一个包含各种流行机器学习数据集的集合。

这个例子部分基于github.com/tensorflow/docs/blob/master/site/en/tutorials/images/transfer_learning.ipynb

这样,让我们开始吧:

  1. 和往常一样,首先,我们需要进行导入:
import matplotlib.pyplot as plt
import tensorflow as tf
import tensorflow_datasets as tfds 
  1. 然后,我们将定义小批量和输入图像的大小(图像大小由网络架构决定):
IMG_SIZE = 224
BATCH_SIZE = 50

  1. 接下来,我们将借助 TF 数据集加载 CIFAR-10 数据集。repeat()方法允许我们在多个周期中重用数据集:
data, metadata = tfds.load('cifar10', with_info=True, as_supervised=True)
raw_train, raw_test = data['train'].repeat(), data['test'].repeat()
  1. 接下来,我们将定义train_format_sampletest_format_sample函数,这些函数将把输入图像转换为适合 CNN 的输入。这些函数起着与我们在使用 PyTorch 实现迁移学习章节中定义的transforms.Compose对象相同的作用。输入经过以下转换:

    • 图像被调整为 96×96 的大小,这是期望的网络输入大小。

    • 每个图像都通过转换其值进行标准化,使其位于(-1;1)区间内。

    • 标签会转换为独热编码。

    • 训练图像会被随机水平和垂直翻转。

让我们看看实际的实现:

def train_format_sample(image, label):
    """Transform data for training"""
    image = tf.cast(image, tf.float32)
    image = tf.image.resize(image, (IMG_SIZE, IMG_SIZE))
    image = (image / 127.5) - 1
    image = tf.image.random_flip_left_right(image)
    image = tf.image.random_flip_up_down(image)

    label = tf.one_hot(label, metadata.features['label'].num_classes)

    return image, label

def test_format_sample(image, label):
    """Transform data for testing"""
    image = tf.cast(image, tf.float32)
    image = tf.image.resize(image, (IMG_SIZE, IMG_SIZE))
    image = (image / 127.5) - 1

    label = tf.one_hot(label, 
    metadata.features['label'].num_classes)

    return image, label
  1. 接下来是一些模板代码,它将这些转换器分配给训练/测试数据集,并将它们分割成小批量:
# assign transformers to raw data
train_data = raw_train.map(train_format_sample)
test_data = raw_test.map(test_format_sample)

# extract batches from the training set
train_batches = train_data.shuffle(1000).batch(BATCH_SIZE)
test_batches = test_data.batch(BATCH_SIZE)
  1. 接下来,我们需要定义特征提取模型:

    • 我们将使用 Keras 来定义预训练网络和模型,因为它是 TF 2.0 的重要组成部分。

    • 我们加载ResNet50V2预训练网络,排除最后的全连接层。

    • 然后,我们调用base_model.trainable = False,这将冻结所有网络权重,并防止它们参与训练。

    • 最后,我们添加一个GlobalAveragePooling2D操作,并在网络末尾添加一个新的可训练全连接层。

以下代码实现了这一点:

def build_fe_model():
    # create the pretrained part of the network, excluding FC 
    layers
    base_model = tf.keras.applications.ResNet50V2(input_shape=(IMG_SIZE,
    IMG_SIZE, 3), include_top=False, weights='imagenet')

    # exclude all model layers from training
    base_model.trainable = False

    # create new model as a combination of the pretrained net
    # and one fully connected layer at the top
    return tf.keras.Sequential([
        base_model,
        tf.keras.layers.GlobalAveragePooling2D(),
        tf.keras.layers.Dense(
            metadata.features['label'].num_classes,
            activation='softmax')
    ])
  1. 接下来,我们将定义微调模型。它与特征提取模型的唯一区别是,我们仅冻结一些底层的预训练网络层(而不是全部冻结)。以下是实现:
def build_ft_model():
    # create the pretrained part of the network, excluding FC 
    layers
    base_model = tf.keras.applications.ResNet50V2(input_shape=(IMG_SIZE, 
    IMG_SIZE, 3), include_top=False, weights='imagenet')

    # Fine tune from this layer onwards
    fine_tune_at = 100

    # Freeze all the layers before the `fine_tune_at` layer
    for layer in base_model.layers[:fine_tune_at]:
        layer.trainable = False

    # create new model as a combination of the pretrained net
    # and one fully connected layer at the top
    return tf.keras.Sequential([
        base_model,
        tf.keras.layers.GlobalAveragePooling2D(),
        tf.keras.layers.Dense(
            metadata.features['label'].num_classes,
            activation='softmax')
    ])
  1. 最后,我们将实现train_model函数,该函数训练和评估由build_fe_modelbuild_ft_model函数创建的模型:
def train_model(model, epochs=5):
    # configure the model for training
    model.compile(optimizer=tf.keras.optimizers.Adam(lr=0.0001),
                  loss='categorical_crossentropy',
                  metrics=['accuracy'])

    # train the model
    history = model.fit(train_batches,
                        epochs=epochs,
                        steps_per_epoch=metadata.splits['train'].num_examples // BATCH_SIZE,
                        validation_data=test_batches,
                        validation_steps=metadata.splits['test'].num_examples // BATCH_SIZE,
                        workers=4)

    # plot accuracy
    test_acc = history.history['val_accuracy']

    plt.figure()
    plt.plot(test_acc)
    plt.xticks(
        [i for i in range(0, len(test_acc))],
        [i + 1 for i in range(0, len(test_acc))])
    plt.ylabel('Accuracy')
    plt.xlabel('Epoch')
    plt.show()
  1. 我们可以使用以下代码运行特征提取或微调迁移学习(TL):

    • train_model(build_ft_model())

    • train_model(build_fe_model())

如果机器有 GPU,TF 将自动使用 GPU;否则,它将回退到 CPU。下图展示了两种场景下网络在五个训练周期后的准确度:

左侧:特征提取迁移学习;右侧:微调迁移学习

总结

本章一开始我们快速回顾了 CNN,并讨论了转置卷积、深度可分离卷积和膨胀卷积。接着,我们介绍了通过将卷积表示为矩阵乘法或使用 Winograd 卷积算法来提高 CNN 性能。然后,我们重点讲解了如何借助引导反向传播和 Grad-CAM 可视化 CNN。接下来,我们讨论了最受欢迎的正则化技术。最后,我们学习了迁移学习,并通过 PyTorch 和 TF 实现了相同的迁移学习任务,以便比较这两个库。

在下一章中,我们将讨论一些最受欢迎的高级 CNN 架构。

第三章:高级卷积网络

在第二章,《理解卷积神经网络》中,我们讨论了卷积神经网络CNN)的基本构件及其一些特性。在本章中,我们将更进一步,讨论一些最受欢迎的 CNN 架构。这些网络通常将多个基础卷积和/或池化操作结合在一个新的构件中,作为复杂架构的基础。这使得我们能够构建非常深(有时也很宽)的网络,具有较高的表示能力,能够在复杂任务中表现良好,如 ImageNet 分类、图像分割、语音识别等。许多这些模型最初是作为 ImageNet 挑战赛的参与者发布的,并且通常获得了胜利。为了简化任务,我们将在图像分类的背景下讨论所有架构。我们仍会讨论更复杂的任务,但会在第四章,《目标检测与图像分割》中进行讨论。

本章将涵盖以下主题:

  • 介绍 AlexNet

  • Visual Geometry Group 简介

  • 理解残差网络

  • 理解 Inception 网络

  • 介绍 Xception

  • 介绍 MobileNet

  • DenseNet 简介

  • 神经架构搜索的工作原理

  • 介绍胶囊网络

介绍 AlexNet

我们将讨论的第一个模型是 2012 年ImageNet 大规模视觉识别挑战ILSVRC,简称 ImageNet)的冠军。这个模型被称为 AlexNet(通过深度卷积神经网络进行 ImageNet 分类papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf),以其作者之一 Alex Krizhevsky 命名。虽然现在这个模型很少使用,但它是当代深度学习中的一个重要里程碑。

以下图表展示了网络架构:

AlexNet 架构。原始模型被拆分为两个部分,以便它可以适应两张 GPU 的内存

该模型有五层交叉相关的卷积层,三层重叠的最大池化层,三层全连接层,以及 ReLU 激活。输出是一个 1,000 维的 softmax(每个对应一个 ImageNet 类别)。第一层和第二层卷积使用局部响应归一化——这是一种归一化方法,类似于批量归一化。全连接层的 dropout 率为 0.5。为了防止过拟合,网络使用随机 227×227 的裁剪图像(来自 256×256 的输入图像)进行训练。该网络在测试集上实现了 37.5%的 top-1 误差率和 17.0%的 top-5 误差率。

在下一部分中,我们将讨论由牛津大学 Visual Geometry Group 于 2014 年提出的一个神经网络架构,该架构在当年的 ImageNet 挑战赛中获得了亚军。

Visual Geometry Group(视觉几何组)简介

我们接下来要讨论的架构是视觉几何组VGG)(来自牛津大学的视觉几何组,非常深的卷积网络用于大规模图像识别arxiv.org/abs/1409.1556)。VGG 系列网络至今仍然非常流行,且常常作为新的架构的基准。VGG 之前的网络(例如,LeNet-5:yann.lecun.com/exdb/lenet/和 AlexNet)中,网络的初始卷积层使用的是大感受野的滤波器,如 11×11。此外,网络通常由交替的单一卷积层和池化层组成。论文的作者观察到,大滤波器的卷积层可以被堆叠的多个小滤波器的卷积层所替代(因式分解卷积)。例如,我们可以用两层 3×3 的卷积层代替一个 5×5 的层,或者用三层 3×3 的卷积层代替一个 7×7 的层。

这种结构有几个优点,具体如下:

  • 堆叠层的最后一个神经元具有与单层大滤波器相同的感受野大小。

  • 与单一大滤波器的层相比,堆叠层的权重和操作数较少。假设我们想用两个 3×3 层替代一个 5×5 层。假设所有层的输入和输出通道数(切片)相同,记为M。5×5 层的总权重数(不包括偏置)为55MM = 25。另一方面,单个 3×3 层的总权重为33MM = 9,两个层的权重总数为2(33MM) = 18M²*,使得这种安排比单层大滤波器更高效 28%(18/25 = 0.72)。当滤波器更大时,效率会进一步提高。

  • 堆叠多个层使得决策函数更具判别力。

VGG 网络由多个堆叠的两层、三层或四层卷积层和最大池化层组成。我们可以在下表中看到两种最流行的变体,VGG16VGG19

VGG16 和 VGG19 网络的架构,命名来源于每个网络中的加权层数

随着 VGG 网络深度的增加,卷积层的宽度(滤波器的数量)也增加。我们有多个交叉通道卷积对,卷积深度为 128/256/512,连接到其他相同深度的层。此外,我们还有两个 4,096 单元的完全连接层,后接一个 1,000 单元的完全连接层和一个 softmax(每个 ImageNet 类一个)。因此,VGG 网络有大量的参数(权重),这使得它们在内存使用上效率低下,且计算成本高昂。尽管如此,这仍然是一个流行且简单的网络架构,后来通过加入批归一化(batch normalization)得到了进一步的改进。

在下一节中,我们将使用 VGG 作为示例,展示如何使用 TensorFlow 和 PyTorch 加载预训练的网络模型。

使用 PyTorch 和 TensorFlow 的 VGG

PyTorch 和 TensorFlow 都有预训练的 VGG 模型。我们来看一下如何使用它们。

Keras 是 TensorFlow 2 的官方部分,因此我们将使用它来加载模型:

import tensorflow as tf

# VGG16
vgg16 = tf.keras.applications.vgg16.VGG16(include_top=True,
                                          weights='imagenet',
                                          input_tensor=None,
                                          input_shape=None,
                                          pooling=None,
                                          classes=1000)

# VGG19 
vgg19 = tf.keras.applications.vgg19.VGG19(include_top=True,
                                          weights='imagenet',
                                          input_tensor=None,
                                          input_shape=None,
                                          pooling=None,
                                          classes=1000)

通过设置weights='imagenet'参数,网络将加载预训练的 ImageNet 权重(它们会自动下载)。你可以将include_top设置为False,以排除完全连接层,适用于迁移学习场景。在这种情况下,你还可以通过设置input_shape为一个元组值,来使用任意输入大小——卷积层将自动调整以匹配所需的输入形状。这之所以可行,是因为卷积滤波器在整个特征图中是共享的。因此,我们可以在具有不同大小的特征图上使用相同的滤波器。

我们将继续使用 PyTorch,你可以选择是否使用预训练模型(同样会自动下载):

import torchvision.models as models
model = models.vgg16(pretrained=True)

你可以尝试其他预训练模型,使用我们描述的相同流程。为了避免重复,我们不会在这一节中为其他架构提供相同的代码示例。

在下一节中,我们将讨论 VGG 发布后最流行的卷积神经网络(CNN)架构之一。

理解残差网络

残差网络(ResNets深度残差学习用于图像识别arxiv.org/abs/1512.03385)于 2015 年发布,并在当年赢得了 ImageNet 挑战赛的五个类别的冠军。在第一章,神经网络的基本原理中,我们提到神经网络的层并不受限于顺序排列,而是形成了一个图结构。这是我们将要学习的第一个架构,它利用了这种灵活性。这也是第一个成功训练了超过 100 层深度网络的网络架构。

由于更好的权重初始化、新的激活函数以及归一化层,现在可以训练深度网络了。但是,论文的作者进行了一些实验,观察到一个 56 层的网络在训练和测试误差上都高于一个 20 层的网络。他们认为这种情况不应该发生。从理论上讲,我们可以使用一个浅层网络,并在其上堆叠恒等层(这些层的输出仅重复输入),来构造一个深度网络,该网络的表现与浅层网络完全相同。然而,他们的实验未能使深度网络的表现达到浅层网络的水平。

为了解决这个问题,他们提出了由残差模块构成的网络。一个残差模块由两个或三个连续的卷积层和一个独立的并行恒等(重复器)快捷连接组成,它连接了第一个层的输入和最后一个层的输出。我们可以在以下截图中看到三种类型的残差模块:

从左到右:原始残差模块;原始瓶颈残差模块;预激活残差模块;预激活瓶颈残差模块

每个模块有两个并行路径。左侧路径与我们见过的其他网络类似,由连续的卷积层 + 批归一化组成。右侧路径包含了恒等快捷连接(也称为跳跃连接)。这两个路径通过逐元素相加的方式进行合并。也就是说,左侧和右侧的张量具有相同的形状,第一个张量的一个元素会加到第二个张量中相同位置的元素上。输出是一个形状与输入相同的单一张量。实际上,我们向前传播的是模块学习到的特征,但也包括了原始的未修改信号。通过这种方式,我们可以更接近原始场景,正如作者所描述的那样。网络可以通过跳跃连接决定跳过一些卷积层,实际上减少了它自身的深度。残差模块使用了填充技术,使得输入和输出的尺寸相同。得益于此,我们可以堆叠任意数量的模块,从而实现任意深度的网络。

现在,让我们看看图中不同模块的区别:

  • 第一个模块包含两个 3×3 的卷积层。这是原始的残差模块,但如果层数较宽,堆叠多个模块会变得计算上很昂贵。

  • 第二个块与第一个块相同,但它使用了所谓的瓶颈层。首先,我们使用 1×1 卷积来下采样输入体积的深度(我们在第二章,理解卷积网络中讨论了这一点)。然后,我们对缩小后的输入应用 3×3(瓶颈)卷积。最后,我们使用另一个 1×1 卷积将输出恢复到所需的深度。该层的计算开销比第一个要小。

  • 第三个块是该思想的最新修订版,由同一作者于 2016 年发布(深度残差网络中的身份映射arxiv.org/abs/1603.05027)。它使用了预激活,批量归一化和激活函数位于卷积层之前。这一设计起初可能显得有些奇怪,但正是由于这种设计,跳跃连接路径能够在整个网络中不间断地运行。这与其他残差块不同,后者至少有一个激活函数处于跳跃连接的路径上。堆叠的残差块组合仍然保持了正确的层级顺序。

  • 第四个块是第三层的瓶颈版本。它遵循与瓶颈残差层 v1 相同的原则。

在下表中,我们可以看到论文作者提出的网络系列:

最受欢迎的残差网络系列。残差块用圆角矩形表示

它们的一些特性如下:

  • 它们从一个 7×7 卷积层开始,步幅为 2,接着是 3×3 的最大池化层。该层还充当了下采样步骤——网络的其余部分以一个更小的 56×56 切片开始,相较于输入的 224×224。

  • 网络其余部分的下采样是通过具有步幅 2 的修改残差块实现的。

  • 平均池化在所有残差块之后、1,000 单元全连接 softmax 层之前进行下采样。

ResNet 系列网络不仅因其准确性而流行,还因为它们相对简单,并且残差块具有高度的通用性。正如我们之前提到的,残差块的输入和输出形状可以相同,这是由于填充操作。我们可以以不同的配置堆叠残差块,以解决各种问题,适应不同大小的训练集和输入维度。正因为这种通用性,我们将在下一节中实现一个 ResNet 示例。

实现残差块

在本节中,我们将实现一个预激活 ResNet,用于使用 PyTorch 1.3.1 和torchvision 0.4.2 分类 CIFAR-10 图像。让我们开始吧:

  1. 像往常一样,我们从导入库开始。请注意,我们将使用 PyTorch 功能模块的简写Fpytorch.org/docs/stable/nn.html#torch-nn-functional):
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
from torchvision import transforms
  1. 接下来,让我们定义预激活常规(非瓶颈)残差块。我们将其实现为nn.Module——所有神经网络模块的基类。我们从类定义和__init__方法开始:
class PreActivationBlock(nn.Module):
    expansion = 1
    def __init__(self, in_slices, slices, stride=1):
        super(PreActivationBlock, self).__init__()

        self.bn_1 = nn.BatchNorm2d(in_slices)

                                out_channels=slices,kernel_size=3, 
                                stride=stride, padding=1,
                                bias=False)

        self.bn_2 = nn.BatchNorm2d(slices)
        self.conv_2 = nn.Conv2d(in_channels=slices, 
                                out_channels=slices,kernel_size=3, 
                                stride=1, padding=1,
                                bias=False)

        # if the input/output dimensions differ use convolution for 
        the shortcut
        if stride != 1 or in_slices != self.expansion * slices:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels=in_slices,
                          out_channels=self.expansion * slices,
                          kernel_size=1,
                          stride=stride,
                          bias=False)
            )

我们将在__init__方法中仅定义可学习的块组件——这些组件包括卷积和批量归一化操作。另外,请注意我们如何实现shortcut连接。如果输入维度与输出维度相同,我们可以直接使用输入张量作为捷径连接。然而,如果维度不同,我们必须借助一个 1×1 卷积进行转换,卷积的步幅和输出通道与主路径中的卷积相同。维度的不同可能来源于高度/宽度(stride != 1)或者深度(in_slices != self.expansion * slices)。self.expansion是一个超参数,原始 ResNet 实现中包含了该参数,它允许我们扩展残差块的输出深度。

  1. 实际的数据传播在forward方法中实现(请注意缩进,因为它是PreActivationBlock的成员):
def forward(self, x):
    out = F.relu(self.bn_1(x))

    # reuse bn+relu in downsampling layers
    shortcut = self.shortcut(out) if hasattr(self, 'shortcut')
    else x

    out = self.conv_1(out)

    out = F.relu(self.bn_2(out))
    out = self.conv_2(out)

    out += shortcut

    return out

我们使用函数式的F.relu作为激活函数,因为它没有可学习的参数。然后,如果捷径连接是卷积而不是恒等(也就是说,块的输入/输出维度不同),我们会重用F.relu(self.bn_1(x))来为捷径连接增加非线性和批量归一化。否则,我们只需重复输入。

  1. 然后,让我们实现残差块的瓶颈版本。我们将使用与非瓶颈实现相同的蓝图。我们从类定义和__init__方法开始:
class PreActivationBottleneckBlock(nn.Module):
    expansion = 4
    def __init__(self, in_slices, slices, stride=1):
        super(PreActivationBottleneckBlock, self).__init__()

        self.bn_1 = nn.BatchNorm2d(in_slices)
        self.conv_1 = nn.Conv2d(in_channels=in_slices, 
                                out_channels=slices, kernel_size=1,
                                bias=False)

        self.bn_2 = nn.BatchNorm2d(slices)
        self.conv_2 = nn.Conv2d(in_channels=slices, 
                                out_channels=slices, kernel_size=3, 
                                stride=stride, padding=1,
                                bias=False)

        self.bn_3 = nn.BatchNorm2d(slices)
        self.conv_3 = nn.Conv2d(in_channels=slices,
                                out_channels=self.expansion * 
                                slices,
                                kernel_size=1,
                                bias=False)

        # if the input/output dimensions differ use convolution for the shortcut
        if stride != 1 or in_slices != self.expansion * slices:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels=in_slices,
                          out_channels=self.expansion * slices,
                          kernel_size=1, stride=stride,
                          bias=False)
            )

expansion参数在原始实现中为4self.conv_1卷积操作表示 1×1 下采样瓶颈连接,self.conv_2是实际的卷积,self.conv_3是上采样的 1×1 卷积。捷径机制遵循与PreActivationBlock中相同的逻辑。

  1. 接下来,让我们实现PreActivationBottleneckBlock.forward方法。它的逻辑与PreActivationBlock中的方法相同:
def forward(self, x):
    out = F.relu(self.bn_1(x))

    #  reuse bn+relu in downsampling layers
    shortcut = self.shortcut(out) if hasattr(self, 'shortcut') 
    else x

    out = self.conv_1(out)

    out = F.relu(self.bn_2(out))
    out = self.conv_2(out)

    out = F.relu(self.bn_3(out))
    out = self.conv_3(out)

    out += shortcut

    return out
  1. 接下来,让我们实现残差网络本身。我们将从类定义开始(它继承自nn.Module)和__init__方法:
class PreActivationResNet(nn.Module):
    def __init__(self, block, num_blocks, num_classes=10):
        """
        :param block: type of residual block (regular or 
        bottleneck)
        :param num_blocks: a list with 4 integer values.
            Each value reflects the number of residual blocks in 
            the group
        :param num_classes: number of output classes
        """

        super(PreActivationResNet, self).__init__()

        self.in_slices = 64

        self.conv_1 = nn.Conv2d(in_channels=3, out_channels=64,
                                kernel_size=3, stride=1, padding=1,
                                bias=False)

        self.layer_1 = self._make_group(block, 64, num_blocks[0], 
        stride=1)
        self.layer_2 = self._make_group(block, 128, num_blocks[1], 
        stride=2)
        self.layer_3 = self._make_group(block, 256, num_blocks[2], 
        stride=2)
        self.layer_4 = self._make_group(block, 512, num_blocks[3], 
        stride=2)
        self.linear = nn.Linear(512 * block.expansion, num_classes)

网络包含四组残差块,和原始实现一样。每组中的块数由num_blocks参数指定。初始卷积使用 3×3 的滤波器,步幅为 1,而原始实现中使用的是 7×7,步幅为 2。这是因为 32×32 的 CIFAR-10 图像比 224×224 的 ImageNet 图像要小得多,因此不需要下采样。

  1. 然后,我们将实现PreActivationResNet._make_group方法,该方法创建一个残差块组。组中的所有块的步幅为 1,只有第一个块的步幅由参数stride指定:
def _make_group(self, block, slices, num_blocks, stride):
    """Create one residual group"""

    strides = [stride] + [1] * (num_blocks - 1)
    layers = []
    for stride in strides:
        layers.append(block(self.in_slices, slices, stride))
        self.in_slices = slices * block.expansion

    return nn.Sequential(*layers)
  1. 接下来,我们将实现PreActivationResNet.forward方法,该方法通过网络传播数据。我们可以看到全连接层前的下采样平均池化:
def forward(self, x):
    out = self.conv_1(x)
    out = self.layer_1(out)
    out = self.layer_2(out)
    out = self.layer_3(out)
    out = self.layer_4(out)
    out = F.avg_pool2d(out, 4)
    out = out.view(out.size(0), -1)
    out = self.linear(out)

    return out
  1. 一旦我们完成了网络的构建,就可以实现多种 ResNet 配置。以下是ResNet34,它有 34 层卷积层,分组为[3, 4, 6, 3]非瓶颈残差块:
def PreActivationResNet34():
    return PreActivationResNet(block=PreActivationBlock,
                               num_blocks=[3, 4, 6, 3])
  1. 最后,我们可以训练网络。我们将首先定义训练和测试数据集。由于我们已经在第二章 理解卷积网络中看过类似的场景,这里不再详细讲解实现。我们将通过给样本填充四个像素来扩增训练集,然后从中随机裁剪出 32×32 的图像。以下是实现:
# training data transformation
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4821, 0.4465), (0.2470, 0.2435, 
    0.2616))
])

# training data loader
train_set = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, 
                                        transform=transform_train)

train_loader = torch.utils.data.DataLoader(dataset=train_set, 
                                        batch_size=100,
                                        shuffle=True, 
                                        num_workers=2)

# test data transformation
transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4821, 0.4465), (0.2470, 0.2435, 
    0.2616))
])

# test data loader
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                        download=True, 
                                        transform=transform_test)

test_loader = torch.utils.data.DataLoader(dataset=testset, 
                                        batch_size=100,
                                        shuffle=False,
                                        num_workers=2)
  1. 接下来,我们将实例化网络模型和训练参数——交叉熵损失和 Adam 优化器:
# load the pretrained model
model = PreActivationResNet34()

# select gpu 0, if available
# otherwise fallback to cpu
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# transfer the model to the GPU
model = model.to(device)

# loss function
loss_function = nn.CrossEntropyLoss()

# We'll optimize all parameters
optimizer = optim.Adam(model.parameters())
  1. 现在我们可以将网络训练EPOCHS轮。train_modeltest_modelplot_accuracy函数与我们在第二章 实现 PyTorch 迁移学习部分中定义的相同,我们不会在此重复它们的实现。以下是代码:
# train
EPOCHS = 15

test_acc = list()  # collect accuracy for plotting
for epoch in range(EPOCHS):
    print('Epoch {}/{}'.format(epoch + 1, EPOCHS))

    train_model(model, loss_function, optimizer, train_loader)
    _, acc = test_model(model, loss_function, test_loader)
    test_acc.append(acc)

plot_accuracy(test_acc)

在下图中,我们可以看到 15 次迭代中的测试准确率(训练可能需要一些时间):

ResNet34 CIFAR 在 15 轮训练中的准确率

本节中的代码部分基于github.com/kuangliu/pytorch-cifar中的预激活 ResNet 实现。

本节我们讨论了不同类型的 ResNet,并用 PyTorch 实现了一个。在下一节中,我们将讨论 Inception 网络——另一类网络,它们将并行连接的使用提升到了一个新层次。

了解 Inception 网络

Inception 网络(通过卷积深入arxiv.org/abs/1409.4842)是在 2014 年提出的,当时它们赢得了当年的 ImageNet 挑战(这里似乎有个规律)。从那时起,作者们发布了该架构的多个改进版本。

有趣的事实:Inception 这个名字部分来源于我们需要更深入的网络迷因,这与电影盗梦空间有关。

Inception 网络背后的思想源于一个基本前提:图像中的物体具有不同的尺度。一个远处的物体可能占据图像的一个小区域,但当同一个物体靠近时,它可能占据图像的大部分。这对于标准的 CNN 来说是一个难题,因为不同层中的神经元对输入图像的感受野大小是固定的。一个常规网络可能能很好地检测到某个尺度的物体,但在其他情况下可能会漏掉它们。为了解决这个问题,论文的作者提出了一种新型架构:由 Inception 块组成的网络。Inception 块从一个共同的输入开始,然后将其分割成不同的并行路径(或塔)。每条路径包含不同大小滤波器的卷积层,或池化层。通过这种方式,我们对相同的输入数据应用不同的感受野。在 Inception 块的末尾,不同路径的输出会被连接起来。在接下来的几个部分中,我们将讨论 Inception 网络的不同变种。

Inception v1

下图展示了 Inception 块的第一个版本,它是 GoogLeNet 网络架构的一部分(arxiv.org/abs/1409.4842)。GoogLeNet 包含九个这样的 Inception 块:

Inception v1 块,灵感来自于 arxiv.org/abs/1409.4842

v1 块有四条路径:

  • 1×1 卷积,作为输入的某种中继器

  • 1×1 卷积,后接 3×3 卷积

  • 1×1 卷积,后接 5×5 卷积

  • 3×3 最大池化,步幅为 1

该块中的层使用填充方式,使输入和输出具有相同的形状(但深度不同)。填充也是必要的,因为每条路径会根据滤波器大小产生不同形状的输出。这对所有版本的 Inception 块都适用。

这个 Inception 块的另一个主要创新是使用下采样的 1×1 卷积。它们是必要的,因为所有路径的输出会被连接起来生成该块的最终输出。连接的结果是输出深度增加了四倍。如果接下来的 Inception 块继续跟随当前块,它的输出深度将再次增加四倍。为了避免这种指数增长,该块使用 1×1 卷积来减少每条路径的深度,从而降低该块的输出深度。这使得可以创建更深的网络,而不会耗尽资源。

GoogLeNet 还利用了辅助分类器——也就是说,它在不同的中间层有两个额外的分类输出(具有相同的真实标签)。在训练过程中,总的损失值是辅助损失和真实损失的加权和。

Inception v2 和 v3

Inception v2 和 v3 是一起发布的,并提出了相较于原始 Inception 块的若干改进(重新思考计算机视觉中的 Inception 架构arxiv.org/abs/1512.00567)。第一个改进是将 5×5 卷积分解为两个堆叠的 3×3 卷积。我们在视觉几何组简介部分讨论了这种方法的优势。

我们可以在以下图表中看到新的 Inception 块:

Inception 块 A,灵感来源于arxiv.org/abs/1512.00567

下一个改进是将n×n卷积分解为两个堆叠的不对称 1×nn×1 卷积。例如,我们可以将单个 3×3 卷积分解为两个 1×3 和 3×1 卷积,其中 3×1 卷积应用于 1×3 卷积的输出。在第一种情况下,滤波器的大小为 33 = 9,而在第二种情况下,我们将得到(31)+(1*3)= 3 + 3 = 6 的组合大小,从而实现了 33%的效率,具体如图所示:

3×3 卷积的因式分解为 1×3 和 3×1 卷积。灵感来源于arxiv.org/abs/1512.00567

作者引入了两个新块,这些块使用了因式分解卷积。第一个新块(也是第二个块)相当于 Inception 块 A:

Inception 块 B。当 n=3 时,它相当于块 A。灵感来源于arxiv.org/abs/1512.00567

第二个(总共是第三个)块相似,但不对称的卷积是并行的,导致输出深度更高(更多的拼接路径)。这里的假设是,网络拥有的特征(不同的滤波器)越多,它学习得越快(我们也在第二章《理解卷积网络》中讨论了更多滤波器的需求)。另一方面,较宽的层会占用更多内存和计算时间。作为折中,这个块仅在网络的较深部分使用,在其他块之后:

Inception 块 C,灵感来源于arxiv.org/abs/1512.00567

使用这些新块,作者提出了两个新的 Inception 网络:v2 和 v3。此版本的另一个重大改进是采用批量归一化,由同一作者提出。

Inception v4 和 Inception-ResNet

在最新修订版的 Inception 网络中,作者引入了三种新的精简版 Inception 块,这些块基于先前版本的思想(Inception-v4,Inception-ResNet 以及残差连接对学习的影响arxiv.org/abs/1602.07261)。他们引入了 7×7 的非对称因式分解卷积,并用平均池化代替最大池化。更重要的是,他们创建了一个残差/Inception 混合网络,称为 Inception-ResNet,其中 Inception 块也包含了残差连接。我们可以在下面的示意图中看到这样的一个块:

带有残差跳跃连接的 Inception 块

在本节中,我们讨论了不同类型的 Inception 网络以及在不同 Inception 块中使用的不同原理。接下来,我们将讨论一种更新的 CNN 架构,它将 Inception 概念带入了一个新的深度(或者宽度,正如它应该是的那样)。

介绍 Xception

到目前为止,所有 Inception 块都从将输入拆分成几个并行路径开始。每个路径接着进行维度缩减的 1×1 跨通道卷积,然后是常规的跨通道卷积。一方面,1×1 连接映射跨通道相关性,但不映射空间相关性(因为 1×1 的滤波器大小)。另一方面,随后的跨通道卷积映射两种类型的相关性。让我们回忆一下在第二章,理解卷积网络中,我们介绍了深度可分离卷积DSC),它结合了以下两种操作:

  • 深度卷积:在深度卷积中,单一的输入切片产生一个单一的输出切片,因此它仅映射空间(而非跨通道)相关性。

  • 1×1 跨通道卷积:通过 1×1 卷积,我们得到的是相反的情况,即它们仅映射跨通道相关性。

Xception 的作者(Xception:深度学习与深度可分离卷积arxiv.org/abs/1610.02357)认为,事实上,我们可以把 DSC 看作是 Inception 块的极端版本(因此命名为 Xception),其中每个深度输入/输出切片对代表一个并行路径。我们有与输入切片数量相等的并行路径。下图展示了一个简化的 Inception 块及其转化为 Xception 块的过程:

左:简化的 Inception 模块。右:Xception 块。灵感来源于arxiv.org/abs/1610.02357

Xception 块和 DSC 有两个不同点:

  • 在 Xception 中,1×1 卷积是首先进行的,而不是像 DSC 那样最后进行。不过,这些操作无论如何都会堆叠在一起,我们可以假设顺序并不重要。

  • Xception 块在每次卷积后使用 ReLU 激活,而 DSC 在通道间卷积后则不使用非线性激活。根据作者的实验,缺少非线性激活的深度卷积网络收敛更快且更准确。

以下图示展示了 Xception 网络的架构:

从左到右:入口流;中间流,重复八次;出口流。来源: arxiv.org/abs/1610.02357

它由线性堆叠的 DSC 组成,且其一些特性如下:

  • 该网络包含 36 个卷积层,结构分为 14 个模块,所有模块都有线性残差连接,除了第一个和最后一个模块。模块被分成三个顺序的虚拟流——入口流、中间流和出口流。

  • 在入口和出口流中使用 3×3 最大池化进行降采样;中间流不进行降采样;在全连接层之前使用全局平均池化。

  • 所有卷积和 DSC 都会在后面跟上批量归一化(Batch Normalization)。

  • 所有 DSC 的深度乘子为 1(没有深度扩展)。

本节结束了基于 Inception 的模型系列。在下一节中,我们将关注一个特殊的模型,它优先考虑小的模型体积和计算效率。

介绍 MobileNet

本节我们将讨论一种轻量级 CNN 模型,称为 MobileNet(MobileNetV2: 倒置残差和线性瓶颈arxiv.org/abs/1801.04381)。我们将重点讨论该想法的第二个版本(MobileNetV1 在 MobileNets: 高效的卷积神经网络用于移动视觉应用 中首次提出,arxiv.org/abs/1704.04861)。

MobileNet 旨在面向内存和计算能力有限的设备,如手机(名字也透露了这一点)。为了减小网络的体积,它采用了 DSC、线性瓶颈和倒置残差结构。

我们已经熟悉了 DSC,接下来我们讨论另外两个:

  • 线性瓶颈:为了理解这一概念,我们引用论文中的内容:

“考虑一个由nL[i]组成的深度神经网络,每一层都有一个维度为![]的激活张量。在本节中,我们将讨论这些激活张量的基本属性,我们将其视为包含![]“像素”的容器,这些像素的维度为d[i]。非正式地说,对于一组真实图像输入,我们说该层激活集(对于任何一层L[i])形成一个“感兴趣的流形”。长期以来,人们认为神经网络中的感兴趣流形可以嵌入到低维子空间中。换句话说,当我们查看深度卷积层的所有单独的d通道像素时,这些值编码的信息实际上位于某个流形中,可以嵌入到低维子空间。”

一种方法是使用 1×1 瓶颈卷积。但论文的作者认为,如果这个卷积后接像 ReLU 这样的非线性激活,可能会导致流形信息的丢失。如果 ReLU 输入大于 0,那么这个单元的输出相当于输入的线性变换。但如果输入较小,ReLU 会崩溃,导致该单元的信息丢失。因此,MobileNet 使用没有非线性激活的 1×1 瓶颈卷积。

  • 倒残差:在残差网络部分,我们介绍了瓶颈残差块,其中非快捷路径中的数据流为输入 -> 1×1 瓶颈卷积 -> 3×3 卷积 -> 1×1 反采样卷积。换句话说,它遵循宽 -> 窄 -> 宽的数据表示。作者认为,瓶颈实际上包含了所有必要的信息,而扩展层仅仅是一个实现细节,伴随着张量的非线性变换。因此,他们提议在瓶颈连接之间使用快捷连接。

基于这些属性,MobileNet 模型由以下构建块组成:

上:带步幅为 1 的倒残差块。下:步幅为 2 的块。

该模型使用 ReLU6 非线性:ReLU6 = min(max(输入,0),6)。最大激活值限制为 6——通过这种方式,非线性在低精度浮点计算中更加稳健。因为 6 最多可以占用 3 位,剩余的用于浮点数部分。

除了步幅外,这些块还通过扩展因子t进行描述,扩展因子决定了瓶颈卷积的扩展比。

下表显示了块的输入和输出维度之间的关系:

输入和输出维度关系。来源:https://arxiv.org/abs/1801.04381

在前表中,hw分别是输入的高度和宽度,s是步幅,kk'是输入和输出的通道数。

最后,这是完整的模型架构:

MobileNetV2 架构。来源:arxiv.org/abs/1801.04381

每一行描述了一组相同的块,重复n次。组内所有层具有相同的输出通道数,c。每个序列的第一层有步幅,s,其余层的步幅为 1。所有空间卷积使用 3×3 的卷积核。扩展因子,t,总是应用于输入大小,如前表所述。

我们接下来要讨论的模型是一种具有新型构建块的网络模型,其中所有层是互相连接的。

DenseNet 简介

DenseNet(密集连接卷积网络arxiv.org/abs/1608.06993)旨在缓解梯度消失问题,改善特征传播,同时减少网络参数的数量。我们已经看到 ResNet 如何引入残差块并通过跳跃连接来解决这个问题。DenseNet 从这个思路中汲取了一些灵感,并通过引入密集块将其进一步发展。一个密集块由连续的卷积层组成,其中每一层都与所有后续层直接连接。换句话说,一个网络层,l,将从所有前面的网络层接收输入,x[l]。

这里,是前面网络层的拼接输出特征图。这与 ResNet 不同,后者通过元素级加法组合不同层。H[l]是一个复合函数,它定义了三种类型的 DenseNet 块(这里只展示了两种):

一个密集块:降维层(虚线部分)是 DenseNet-B 架构的一部分,而 DenseNet-A 没有这些层。DenseNet-C 未显示

让我们来定义它们:

  • DenseNet-A:这是基础块,其中H[l]包括批量归一化,接着是激活函数,然后是 3×3 卷积:

  • DenseNet-B:作者还引入了第二种类型的密集块 DenseNet-B,在每次拼接之后应用一个降维的 1×1 卷积:

  • DenseNet-C:进一步修改,在每个密集块之后添加一个下采样的 1×1 卷积。B 和 C 的组合被称为 DenseNet-BC。

一个密集块由其卷积层的数量和每层输出的体积深度指定,在此上下文中称为 增长率。假设密集块的输入体积深度为 k[0],每个卷积层的输出体积深度为 k。然后,由于连接,l 层的输入体积深度将为 k[0]+kx。尽管密集块的后续层有较大的输入体积深度(由于许多连接),DenseNet 仍然可以使用低至 12 的增长率,这样可以减少总的参数数量。为了理解为什么这样有效,我们可以把特征图看作是网络的 集体知识(或全局状态)。每一层将自己的 k 个特征图添加到这个状态中,增长率决定了每一层对该状态的贡献量。由于密集结构,网络内的任何地方都可以访问全局状态(因此被称为全局)。换句话说,与传统网络架构不同,不需要从一层复制到下一层,这使得我们可以从较少的特征图开始。

为了使连接成为可能,密集块使用填充操作,确保所有输出切片的高度和宽度在整个块中保持一致。但由于这个原因,密集块内无法进行下采样。因此,密集网络由多个连续的密集块组成,块与块之间通过下采样池化操作分隔。

论文的作者提出了一类 DenseNet 网络,其整体架构类似于 ResNet:

DenseNet 网络族。来源:arxiv.org/abs/1608.06993

它们具有以下特性:

  • 从 7×7 步幅为 2 的下采样卷积开始。

  • 进一步下采样 3×3 最大池化,步幅为 2。

  • 四组 DenseNet-B 块。该网络族的区别在于每组内密集块的数量。

  • 下采样由过渡层处理,该层使用 2×2 池化操作,步幅为 2,位于密集块之间。

  • 过渡层包含进一步的 1×1 瓶颈卷积,以减少特征图的数量。该卷积的压缩比由超参数 θ 指定,其中 0 < θ1。如果输入特征图的数量为 m,那么输出特征图的数量为 θm

  • 密集块最终通过 7×7 全局平均池化,接着是一个包含 1,000 单元的全连接 softmax 层。

DenseNet 的作者还发布了一个改进版的 DenseNet 模型,称为 MSDNet(多尺度密集网络用于资源高效的图像分类arxiv.org/abs/1703.09844),正如其名字所示,它使用了多尺度的密集块。

随着 DenseNet 的介绍,我们结束了对常规 CNN 架构的讨论。在下一节中,我们将讨论是否有可能自动化寻找最佳神经网络架构的过程。

神经网络架构搜索的工作原理

我们至今讨论的神经网络(NN)模型都是由其作者设计的。但是,如果我们能让计算机自己设计神经网络呢?这就是神经网络架构搜索NAS)——一种自动化设计神经网络的技术。

在我们继续之前,先看看网络架构包含了什么:

  • 操作图,表示网络。如我们在第一章中讨论的,神经网络的基本原理,操作包括(但不限于)卷积、激活函数、全连接层、归一化等。

  • 每个操作的参数。例如,卷积的参数包括:类型(跨通道卷积、深度卷积等)、输入维度、输入和输出切片的数量、步幅和填充。

在本节中,我们将讨论基于梯度的 NAS 与强化学习(使用强化学习的神经网络架构搜索arxiv.org/abs/1611.01578)。在这一部分,我们不会讨论强化学习,而是专注于算法本身。其前提是我们可以将网络定义表示为一个字符串(令牌序列)。假设我们将生成一个只包含卷积的顺序 CNN。

然后,字符串定义的部分将如下所示:

我们不必指定层类型,因为我们只使用卷积。为了简化起见,我们排除了填充操作。第一行的下标文本是为了说明,但在算法版本中不会包含。

我们可以在以下图表中看到算法概述:

NAS 概述。来源:arxiv.org/abs/1611.01578

我们从控制器开始。它是一个 RNN,任务是生成新的网络架构。虽然我们还没有讨论 RNN(这个话题要等到第七章,理解循环网络),但我们仍会尝试解释它是如何工作的。在第一章,神经网络的基本原理中,我们提到过 RNN 保持一个内部状态——所有先前输入的总结。基于该内部状态和最新的输入样本,网络生成新的输出,更新其内部状态,然后等待下一个输入。

在这里,控制器将生成描述网络架构的字符串序列。控制器的输出是序列中的一个标记。这可能是滤波器的高度/宽度、步幅的宽度/高度,或输出滤波器的数量。标记的类型取决于当前生成的架构的长度。一旦得到这个标记,我们将它作为输入反馈给 RNN 控制器。然后,网络生成序列中的下一个标记。

这个过程在下图中有所描述:

使用 RNN 控制器生成网络架构。输出标记被反馈给控制器作为输入,以生成下一个标记。来源: arxiv.org/abs/1611.01578

图中的白色垂直方块表示 RNN 控制器,包含一个两层长短时记忆LSTM)单元(沿着y-轴)。尽管图中显示了多个 RNN 实例(沿着x-轴),但实际上它们是同一个网络;只是它在时间上被展开,以表示序列生成过程。也就是说,沿x-轴的每一步代表网络定义中的一个标记。第t步的标记预测是通过一个 softmax 分类器完成的,然后作为控制器输入传递到第t+1步。我们继续这个过程,直到生成的网络长度达到某个值。最初,这个值很小(一个短网络),但随着训练的进行,它会逐渐增加(变成一个长网络)。

为了更好地理解 NAS,我们来看一下算法的逐步执行:

  1. 控制器生成一个新的架构,A

  2. 它构建并训练一个新网络,使用所述架构直到它收敛。

  3. 它在保留的训练集部分上测试新网络,并测量误差R

  4. 它利用这个误差来更新控制器参数,θ[c]。由于我们的控制器是 RNN,这意味着训练网络并调整其权重。模型参数会以减少未来架构误差R的方式进行更新。这个过程是通过一个名为 REINFORCE 的强化学习算法实现的,超出了本节的讨论范围。

  5. 它重复这些步骤,直到生成的网络的误差R降到某个阈值以下。

控制器可以生成有一些限制的网络架构。如我们在本节前面提到的,最严格的限制是生成的网络仅由卷积层组成。为了简化,每个卷积层自动包括批量归一化和 ReLU 激活。但理论上,控制器可以生成更复杂的架构,包含其他层,如池化层或归一化层。我们可以通过在架构序列中添加额外的控制器步骤来实现这一点,以指定层的类型。

论文的作者实现了一种技术,允许我们在生成的架构中添加残差跳跃连接。它通过一种特殊类型的控制步骤称为锚点来工作。第N层的锚点具有基于内容的 Sigmoid 函数。Sigmoid 函数的输出 j (j = 1, 2, 3, ..., N-1) 表示当前层是否与第j层存在残差连接的概率。

修改后的控制器如下面的图所示:

带有锚点的 RNN 控制器,用于残差连接。来源:arxiv.org/abs/1611.01578

如果一层有多个输入层,所有输入会沿着通道(深度)维度进行拼接。跳跃连接可能会在网络设计中产生一些问题:

  • 网络的第一层隐藏层(即没有连接到任何其他输入层的层)使用输入图像作为输入层。

  • 在网络的末端,所有未连接的层输出被拼接到最终的隐藏状态中,该状态被发送到分类器。

  • 可能会出现待拼接的输出具有不同大小的情况。在这种情况下,较小的特征图将用零进行填充,以匹配较大特征图的大小。

在他们的实验中,作者使用了一个具有 2 层 LSTM 单元的控制器,每层包含 35 个单元。对于每次卷积,控制器必须从{1, 3, 5, 7}中选择一个滤波器的高度和宽度,并从{24, 36, 48, 64}中选择一个滤波器的数量。此外,他们进行了两组实验——一组允许控制器选择步幅为{1, 2, 3},另一组则固定步幅为 1。

一旦控制器生成了一个架构,新的网络将在 CIFAR-10 数据集的 45,000 张图像上训练 50 个 epoch。剩余的 5,000 张图像用于验证。在训练过程中,控制器从一个 6 层的架构开始,并且每进行 1,600 次迭代后,架构的深度增加 2 层。表现最佳的模型的验证准确率为 3.65%。在使用 800 个 GPU(哇!)的 12,800 次架构后发现了这一点。这些巨大的计算需求的原因在于每一个新的网络都从头开始训练,仅仅为了生成一个准确率值,这个值可以用于训练控制器。最近,新的 ENAS 算法(Efficient Neural Architecture Search via Parameter Sharingarxiv.org/abs/1802.03268)使得通过在生成的模型之间共享权重,显著减少了 NAS 的计算资源。

在下一节中,我们将讨论一种新型神经网络,它试图克服我们至今讨论的卷积神经网络的一些局限性。

引入胶囊网络

胶囊网络(胶囊间动态路由arxiv.org/abs/1710.09829)作为一种克服标准 CNN 某些局限性的方式被提出。为了理解胶囊网络背后的理念,我们需要首先理解它们的局限性,这将在下一节中讨论。

卷积网络的局限性

让我们从 Hinton 教授的一句名言开始:

"卷积神经网络中使用的池化操作是一个大错误,而它之所以能如此有效,正是灾难的根源。"

正如我们在第二章《理解卷积神经网络》中提到的,CNN 是平移不变的。让我们假设有一张包含面孔的图片,面孔位于图片的右半部分。平移不变性意味着 CNN 非常擅长告诉我们图片中包含面孔,但它无法告诉我们面孔是在图像的左侧还是右侧。这种行为的主要原因是池化层。每一层池化都会引入一点平移不变性。例如,最大池化仅将输入神经元中的一个激活传递给下一层,但后续层并不知道是哪一个神经元被传递。

通过堆叠多个池化层,我们逐渐增大了感受野的大小。但由于没有任何池化层传递这类信息,检测到的物体可能出现在新的感受野中的任何位置。因此,我们也增加了平移不变性。最初,这似乎是件好事,因为最终的标签必须是平移不变的。但是,这也带来了问题,因为 CNN 无法识别一个物体相对于另一个物体的位置。CNN 会将下列两张图片都识别为面孔,因为它们都包含面孔的组成部分(鼻子、嘴巴和眼睛),无论它们之间的相对位置如何。

这也被称为毕加索问题,如下面的图示所示:

一个卷积网络会将这两张图片都识别为面孔。

但这还不是全部。即使面孔的朝向发生了变化,例如它被倒转了,CNN 也会感到困惑。克服这一问题的一种方式是在训练过程中进行数据增强(旋转)。但这只展示了网络的局限性。我们必须明确地展示物体在不同朝向下的样子,并告诉 CNN,这实际上是同一个物体。

到目前为止,我们已经看到,CNN 会丢弃平移信息(平移不变性),并且无法理解物体的方向。在计算机视觉中,平移和方向的结合被称为姿势。姿势足以在坐标系中唯一标识物体的特征。我们可以通过计算机图形学来说明这一点。一个三维物体,比如一个立方体,完全由其姿势和边长来定义。将三维物体的表示转换为屏幕上的图像的过程叫做渲染。只知道立方体的姿势和边长,我们就可以从任何我们喜欢的角度渲染它。

因此,如果我们能够以某种方式训练网络来理解这些特性,我们就不需要为同一个物体提供多个增强版本。CNN 做不到这一点,因为它的内部数据表示不包含物体姿势的信息(仅包含物体的类型)。相反,胶囊网络保留信息,包括物体的类型和姿势。因此,它们能够检测出可以相互转换的物体,这就是所谓的等变性。我们也可以把它看作是反向图形学,即根据物体的渲染图像重建物体的特性。

为了解决这些问题,论文的作者提出了一种新的网络构建块,叫做胶囊,代替传统的神经元。我们将在下一节讨论这个概念。

胶囊

胶囊的输出是一个向量,而神经元的输出是一个标量值。胶囊输出的向量承载以下含义:

  • 向量的元素表示物体的姿势和其他特性。

  • 向量的长度位于(0, 1)范围内,表示在该位置检测到特征的概率。提醒一下,向量的长度是 ![],其中 v[i] 是向量元素。

假设我们有一个用于检测面部的胶囊。如果我们开始在图像中移动面部,胶囊向量的值将发生变化,以反映位置的变化。然而,向量的长度始终保持不变,因为面部的概率与位置无关。

胶囊被组织成互联的层,就像一个常规网络一样。一个层中的胶囊作为输入传递给下一个层的胶囊。而且,像 CNN 一样,浅层检测基本特征,深层则将它们组合成更抽象和复杂的特征。但现在,胶囊还传递位置相关信息,而不仅仅是检测到的对象。这使得深层胶囊不仅能够分析特征的存在,还能分析它们之间的关系。例如,一个胶囊层可能会检测到嘴巴、面部、鼻子和眼睛。随后胶囊层不仅能验证这些特征的存在,还能验证它们是否具有正确的空间关系。只有在这两个条件都满足时,后续的层才能验证面部的存在。这是胶囊网络的高级概述。现在,让我们看看胶囊到底是如何工作的。

我们可以在下图中看到一个胶囊的示意图:

一个胶囊

让我们分以下几个步骤来分析:

  1. 胶囊的输入是来自上一层胶囊的输出向量,u[1]u[2]、...、u[n]

  2. 我们将每个向量,u[i],与其对应的权重矩阵,W[ij],相乘,得到预测向量,![]。权重矩阵,W,编码了来自上一层胶囊的低层特征与当前层的高层特征之间的空间关系和其他关系。例如,假设当前层的胶囊检测的是面部,而上一层的胶囊分别检测到嘴巴(u[1])、眼睛(u[2])和鼻子(u[3])。那么,![] 就是根据嘴巴的位置预测的面部位置。同样,![] 根据眼睛的位置预测面部的位置,而 ![] 则根据鼻子的位置预测面部的位置。如果三个低层胶囊向量都在相同的位置达成一致,那么当前的胶囊就可以确信面部确实存在。我们在这个例子中只使用了位置,但向量还可以编码其他特征之间的关系,如尺度和方向。权重W通过反向传播进行学习。

  3. 接下来,我们将![]向量与标量耦合系数c[ij]相乘。这些系数是与权重矩阵不同的一组独立参数。它们存在于任何两个胶囊之间,并指示哪个高层胶囊将接收来自低层胶囊的输入。但与通过反向传播调整的权重矩阵不同,耦合系数是在前向传播过程中通过称为动态路由的过程动态计算的。我们将在下一节中描述它。

  4. 然后,我们执行加权输入向量的求和。此步骤类似于神经元中的加权和,但它处理的是向量:

  1. 最后,我们将通过压缩向量s[j]来计算胶囊的输出v[j]。在这个上下文中,压缩意味着以一种方式转换向量,使其长度处于(0, 1)范围内,而不改变其方向。如前所述,胶囊向量的长度表示检测到的特征的概率,将其压缩到(0, 1)范围内反映了这一点。为此,作者提出了一个新公式:

现在我们知道了胶囊的结构,在接下来的部分,我们将描述计算不同层胶囊之间耦合系数的算法。也就是说,它们如何相互传递信号的机制。

动态路由

让我们描述动态路由过程,以计算下图所示的耦合系数c[ij]

动态路由示例。分组的点表示与彼此一致的低层胶囊

我们有一个低层胶囊I,它必须决定是否将其输出发送到两个高层胶囊之一,JK。深色和浅色的点分别代表预测向量,,这两个胶囊JK已经从其他低层胶囊接收了这些向量。来自I胶囊的箭头指向JK胶囊的预测向量。这些聚集的预测向量(较浅的点)表示与高层特征一致的低层胶囊。例如,如果K胶囊描述的是面部,那么聚集的预测将表示低层特征,如嘴巴、鼻子和眼睛。相反,分散的(较暗的)点表示不一致。如果I胶囊预测的是车胎,它将与K中的聚集预测不一致。

然而,如果聚类预测中的 J 代表特征,如车头灯、挡风玻璃或车翼,那么 I 的预测会与它们一致。低层胶囊有办法确定它们是否属于每个高层胶囊的聚类组或分散组。如果它们属于聚类组,它们将增加与该胶囊的耦合系数,并将它们的向量朝该方向路由。相反,如果它们属于分散组,则系数将减少。

我们通过一个逐步的算法来形式化这些知识,算法由作者提出:

  1. 对于所有在 l 层中的 i 胶囊和在 (l + 1) 层中的 j 胶囊,我们将初始化 ![],其中 b[ij] 是一个临时变量,相当于 c[ij]。所有 b[ij] 的向量表示为 b[i]。在算法开始时, i 胶囊有相等的机会将输出路由到 (l + 1) 层的任一胶囊。

  2. 重复进行 r 次迭代,其中 r 是一个参数:

    1. 对于所有在 l 层中的 i 胶囊:![]。一个胶囊所有外部耦合系数的总和 c[i] 为 1(它们具有概率性质),因此使用 softmax。

    2. 对于所有在 (l + 1) 层中的 j 胶囊:![]。也就是说,我们将计算 (l + 1) 层中所有未压缩的输出向量。

    3. 对于所有在 (l + 1) 层中的 j 胶囊,我们将计算压缩向量:![]。

    4. 对于所有在 l 层中的 i 胶囊和在 (l + 1) 层中的 j 胶囊:![]。这里,![]是低层 i 胶囊的预测向量与高层 j 胶囊输出向量的点积。如果点积较高,则说明 i 胶囊与其他低层胶囊一致,它们将输出传递给 j 胶囊,耦合系数增加。

作者最近发布了一个更新的动态路由算法,使用了一种名为期望最大化的聚类技术。你可以在原始论文《带有 EM 路由的矩阵胶囊》(ai.google/research/pubs/pub46653)中了解更多信息。

胶囊网络的结构

在这一部分中,我们将描述胶囊网络的结构,作者用它来对 MNIST 数据集进行分类。网络的输入是 28×28 的 MNIST 灰度图像,以下是步骤:

  1. 我们从一个卷积层开始,包含 256 个 9×9 的过滤器,步幅为 1,激活函数为 ReLU。输出体积的形状为(256, 20, 20)。

  2. 我们有另一个卷积层,包含 256 个 9×9 的过滤器,步幅为 2。输出体积的形状为(256, 6, 6)。

  3. 使用该层的输出作为第一个胶囊层的基础,称为 PrimaryCaps。将 (256, 6, 6) 的输出体积分成 32 个独立的 (8, 6, 6) 块。也就是说,每个块包含 8 个 6×6 的切片。从每个切片中取一个具有相同坐标的激活值,并将这些值合并成一个向量。例如,我们可以取切片 1 的激活值 (3, 7),切片 2 的 (3, 7),依此类推,并将它们合并成一个长度为 8 的向量。我们将得到 36 个这样的向量。然后,我们将转换每个向量为一个胶囊,总共得到 36 个胶囊。PrimaryCaps 层的输出体积形状是 (32, 8, 6, 6)。

  4. 第二个胶囊层称为 DigitCaps。它包含 10 个胶囊(每个数字一个),其输出是一个长度为 16 的向量。DigitCaps 层的输出体积形状是 (10, 16)。在推理过程中,我们计算每个 DigitCaps 胶囊向量的长度。然后,我们取长度最长的胶囊向量作为网络的预测结果。

  5. 在训练过程中,网络在 DigitCaps 后包括三个额外的全连接层,最后一层有 784 个神经元(28×28)。在前向训练过程中,最长的胶囊向量作为这些层的输入。它们尝试从该向量开始重建原始图像。然后,重建的图像与原始图像进行比较,差异作为反向传播过程中的额外正则化损失。

胶囊网络是一种新兴且有前景的计算机视觉方法。然而,它们目前尚未被广泛采用,并且在本书中讨论的任何深度学习库中都没有官方实现,但你可以找到多个第三方实现。

总结

在本章中,我们讨论了一些流行的 CNN 架构:首先介绍了经典的 AlexNet 和 VGG。接着,我们特别关注了 ResNet,因为它是最著名的网络架构之一。我们还讨论了 Inception 网络的各种变种,以及与它们相关的 Xception 和 MobileNetV2 模型。我们还讲述了神经网络架构搜索这一令人兴奋的机器学习新领域。最后,我们讨论了胶囊网络——一种新的计算机视觉网络类型,它试图克服 CNN 固有的某些局限性。

我们已经在第二章中看到了如何应用这些模型,理解卷积网络,在该章中我们使用了 ResNet 和 MobileNet 进行迁移学习,解决分类任务。在下一章中,我们将看到如何将其中一些模型应用于更复杂的任务,如目标检测和图像分割。

第四章:物体检测与图像分割

在第三章《高级卷积网络》中,我们讨论了一些最受欢迎且性能最佳的卷积神经网络CNN)模型。为了专注于每个网络的架构细节,我们以分类问题为背景来查看这些模型。在计算机视觉任务的世界里,分类相对简单,因为它给图像分配一个标签。在本章中,我们将焦点转向两个更有趣的计算机视觉任务——物体检测和语义分割,而网络架构则会退居其次。我们可以说,这些任务比分类更复杂,因为模型需要对图像有更全面的理解。它必须能够检测不同的物体以及它们在图像中的位置。同时,任务的复杂性也为更具创意的解决方案提供了空间。在本章中,我们将讨论其中的一些。

本章将涵盖以下主题:

  • 物体检测介绍:

  • 物体检测方法

  • YOLO

  • Faster R-CNN

  • 图像分割:

  • U-Net

  • Mask R-CNN

物体检测介绍

物体检测是找到某个类别物体实例的过程,比如人脸、汽车和树木,适用于图像或视频。与分类不同,物体检测可以检测多个物体及其在图像中的位置。

物体检测器将返回一个已检测物体的列表,并为每个物体提供以下信息:

  • 物体的类别(人、车、树等)。

  • [0, 1] 范围内的概率(或置信度分数),表示检测器对物体在该位置存在的信心。这类似于常规分类器的输出。

  • 图像中物体所在矩形区域的坐标。这个矩形称为边界框

我们可以在下面的照片中看到物体检测算法的典型输出。每个边界框上方显示了物体类型和置信度分数:

物体检测器的输出

接下来,让我们概述一下不同的物体检测任务解决方法。

物体检测方法

在本节中,我们将概述三种方法:

  • 经典滑动窗口:在这里,我们将使用常规的分类网络(分类器)。这种方法可以与任何类型的分类算法配合使用,但它相对较慢且容易出错:

    1. 构建图像金字塔:这是同一图像的不同尺度的组合(参见下图)。例如,每个缩放后的图像可以比前一个小两倍。通过这种方式,我们将能够检测到无论大小如何的物体。

    2. 将分类器滑动整个图像:也就是说,我们将图像的每个位置作为分类器的输入,结果将决定该位置的物体类型。该位置的边界框就是我们用作输入的图像区域。

    3. 对于每个物体,我们将有多个重叠的边界框:我们将使用一些启发式方法将它们合并成一个单一的预测结果。

这是滑动窗口方法的示意图:

滑动窗口加图像金字塔物体检测

  • 两阶段检测方法:这些方法非常精确,但相对较慢。顾名思义,它们涉及两个步骤:

    1. 一种特殊类型的 CNN,称为区域建议网络RPN),扫描图像并提出若干可能的边界框或兴趣区域(RoI),这些区域可能包含物体。然而,这个网络并不检测物体的类型,而仅仅判断区域内是否存在物体。

    2. 感兴趣的区域会被送到第二阶段进行物体分类,该阶段确定每个边界框中的实际物体。

  • 一阶段或一击式检测方法:在这种方法中,单个 CNN 同时输出物体类型和边界框。这些方法通常速度较快,但相较于两阶段方法,精度较低。

在下一节中,我们将介绍 YOLO——一种精确而高效的一阶段检测算法。

使用 YOLOv3 进行物体检测

在这一节中,我们将讨论一种最受欢迎的检测算法,称为 YOLO。这个名字是流行格言you only live once的缩写,反映了该算法的一阶段特性。作者已经发布了三版该算法,随着版本的更新逐步改进。我们将仅讨论最新版本 v3(更多详情,请参见YOLOv3: An Incremental Improvementarxiv.org/abs/1804.02767)。

该算法从所谓的主干网络Darknet-53开始(根据卷积层的数量命名)。它经过训练以对 ImageNet 数据集进行分类,类似于第三章中的网络,高级卷积神经网络。它是完全卷积的(没有池化层),并使用残差连接。

下图展示了主干网络架构:

Darknet-53 模型(来源:arxiv.org/abs/1804.02767

一旦网络训练完成,它将作为后续目标检测训练阶段的基础。这是特征提取迁移学习的一个例子,我们在第二章中描述了这一过程,理解卷积神经网络。主干的全连接层将被新的随机初始化的卷积层和全连接层替换。新的全连接层将仅通过一次传递输出所有检测到的物体的边界框、物体类别和置信度得分。

例如,本节开始时行人在人行道上的图像中的边界框是通过单次网络传递生成的。YOLOv3 在三种不同的尺度上预测边界框。该系统使用类似于特征金字塔网络的概念从这些尺度中提取特征(有关更多信息,请参见特征金字塔网络用于目标检测arxiv.org/abs/1612.03144)。在检测阶段,网络使用常见的上下文对象进行训练(Microsoft COCO: 常见上下文中的对象arxiv.org/abs/1405.0312cocodataset.org)目标检测数据集。

接下来,让我们看看 YOLO 是如何工作的:

  1. 将图像分割成S×S单元格(在下图中,我们可以看到一个 3×3 的网格):

    • 网络将每个网格单元的中心视为该区域的中心,其中可能会有一个物体。

    • 一个物体可能完全位于一个单元格内。那么,它的边界框将小于该单元格。或者,它可以跨越多个单元格,边界框会更大。YOLO 涵盖了这两种情况。

    • 该算法可以借助锚框(稍后详细介绍)在一个网格单元中检测多个物体,但每个物体只与一个单元格相关联(一个一对n的关系)。也就是说,如果物体的边界框覆盖了多个单元格,我们会将该物体与边界框中心所在的单元格关联。例如,以下图中的两个物体跨越了多个单元格,但它们都分配给了中心单元格,因为它们的边界框中心位于该单元格中。

    • 一些单元格可能包含物体,其他的则可能没有。我们只关心那些包含物体的单元格。

下图显示了一个 3×3 单元格网格,包含 2 个物体及其边界框(虚线)。这两个物体都与中间的单元格相关联,因为它们的边界框中心位于该单元格内:

一个目标检测的 YOLO 示例,使用 3x3 的单元格网格和 2 个物体

  1. 网络将为每个网格单元输出多个可能的检测对象。例如,如果网格是 3×3,那么输出将包含 9 个可能的检测对象。为了清晰起见,我们先讨论单个网格单元/检测对象的输出数据(及其相应标签)。它是一个数组,包含值 [b[x], b[y], b[h], b[w], p[c], c[1], c[2], ..., c[n]],其中各个值的含义如下:

    • b[x], b[y], b[h], b[w] 描述了对象的边界框。如果存在对象,那么 b[x]b[y] 是框的中心坐标。它们在 [0, 1] 范围内相对于图像的大小进行归一化。也就是说,如果图像的大小是 100 x 100,而 b[x] = 20b[y] = 50,那么它们的归一化值将是 0.2 和 0.5。基本上,b[h]b[w] 代表框的高度和宽度。它们相对于网格单元进行归一化。如果边界框大于单元格,则其值将大于 1。预测框的参数是一个回归任务。

    • p[c] 是一个置信度分数,范围为 [0, 1]。置信度分数的标签要么为 0(不存在),要么为 1(存在),因此这部分输出是一个分类任务。如果对象不存在,我们可以丢弃数组中的其他值。

    • c[1], c[2], ..., c[n] 是对象类别的独热编码。例如,如果我们有汽车、行人、树木、猫和狗这几类,并且当前对象是猫类,那么它的编码将是 [0, 0, 0, 1, 0]。如果我们有 n 个可能的类别,则每个单元格输出数组的大小将是 5 + n (在我们的例子中是 9)。

网络输出/标签将包含 S×S 这样的数组。例如,对于一个 3×3 单元格网格和四个类别,YOLO 输出的长度将是 339 = 81

  1. 让我们来看一下同一单元格中有多个对象的情况。幸运的是,YOLO 提出了一个优雅的解决方案。每个单元格将与多个候选框(称为锚框或先验框)关联,每个锚框具有略微不同的形状。在下面的图示中,我们可以看到网格单元(方形,实线)和两个锚框——竖直的和水平的(虚线)。如果一个单元格内有多个对象,我们将把每个对象与其中一个锚框关联。相反,如果一个锚框没有关联对象,它的置信度分数将为零。这个安排也将改变网络的输出。每个网格单元将有多个输出数组(每个锚框一个输出数组)。为了扩展我们之前的例子,假设我们有一个 3×3 的单元格网格,4 个类别,并且每个单元格有 2 个锚框。那么我们将有 332 = 18 个输出边界框,总输出长度为 3329 = 162。由于我们有固定数量的单元格 (S×S*) 和每个单元格固定数量的锚框,因此网络输出的大小不会因检测到的对象数量而变化。相反,输出将指示对象是否存在于所有可能的锚框中。

在下面的图示中,我们可以看到一个网格单元格和两个锚框:

网格单元格(方形,未中断的线)和两个锚框(虚线)

现在唯一的问题是如何在训练过程中选择物体的正确锚框(在推理过程中,网络会自己选择)。我们将通过交并比IoU)来完成这个任务。这只是物体边界框/锚框的交集面积与它们的并集面积之间的比率:

交并比

我们将比较每个物体的边界框与所有锚框,并将物体分配给 IoU 最高的锚框。由于锚框具有不同的大小和形状,IoU 保证物体将被分配给最符合其在图像上足迹的锚框。

  1. 现在我们(希望)已经了解 YOLO 是如何工作的,我们可以用它来进行预测。然而,网络的输出可能会很嘈杂——也就是说,输出包括每个单元格的所有可能的锚框,无论其中是否存在物体。许多框会重叠,并且实际上预测相同的物体。我们将通过非极大值抑制来去除噪声。它是这样工作的:

    1. 丢弃所有置信度得分小于或等于 0.6 的边界框。

    2. 从剩余的边界框中,选择具有最高置信度得分的框。

    3. 丢弃与我们在上一步中选择的框的 IoU >= 0.5 的任何框。

如果你担心网络输出/真实值数据会变得过于复杂或庞大,别担心。卷积神经网络(CNN)在 ImageNet 数据集上表现良好,该数据集有 1,000 个类别,因此有 1,000 个输出。

想了解更多关于 YOLO 的信息,请查阅原始的系列论文:

  • 你只看一次:统一的实时物体检测arxiv.org/abs/1506.02640),由 Joseph Redmon、Santosh Divvala、Ross Girshick 和 Ali Farhadi 提出

  • YOLO9000:更好、更快、更强arxiv.org/abs/1612.08242),由 Joseph Redmon 和 Ali Farhadi 提出

  • YOLOv3:一种增量改进arxiv.org/abs/1804.02767),由 Joseph Redmon 和 Ali Farhadi 提出

现在我们已经介绍了 YOLO 算法的理论,在接下来的部分,我们将讨论如何在实际中使用它。

使用 OpenCV 的 YOLOv3 代码示例

在本节中,我们将演示如何使用 YOLOv3 物体检测器与 OpenCV。对于这个示例,你需要opencv-python 4.1.1 或更高版本,并且需要 250MB 的磁盘空间来存储预训练的 YOLO 网络。让我们从以下步骤开始:

  1. 从导入开始:
import os.path

import cv2  # opencv import
import numpy as np
import requests
  1. 添加一些模板代码,用于下载和存储多个配置和数据文件。我们从 YOLOv3 网络配置yolo_configweights开始,并用它们初始化net网络。我们将使用 YOLO 作者的 GitHub 和个人网站来完成这一步:
# Download YOLO net config file
# We'll it from the YOLO author's github repo
yolo_config = 'yolov3.cfg'
if not os.path.isfile(yolo_config):
   url = 'https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg'
    r = requests.get(url)
    with open(yolo_config, 'wb') as f:
        f.write(r.content)

# Download YOLO net weights
# We'll it from the YOLO author's website
yolo_weights = 'yolov3.weights'
if not os.path.isfile(yolo_weights):
    url = 'https://pjreddie.com/media/files/yolov3.weights'
    r = requests.get(url)
    with open(yolo_weights, 'wb') as f:
        f.write(r.content)

# load the network
net = cv2.dnn.readNet(yolo_weights, yolo_config)
  1. 接下来,我们将下载网络可以检测的 COCO 数据集类别的名称。我们还将从文件中加载它们。COCO 论文中展示的数据集包含 91 个类别。然而,网站上的数据集仅包含 80 个类别。YOLO 使用的是 80 类别版本:
# Download class names file
# Contains the names of the classes the network can detect
classes_file = 'coco.names'
if not os.path.isfile(classes_file):
    url = 'https://raw.githubusercontent.com/pjreddie/darknet/master/data/coco.names'
    r = requests.get(url)
    with open(classes_file, 'wb') as f:
        f.write(r.content)

# load class names
with open(classes_file, 'r') as f:
    classes = [line.strip() for line in f.readlines()]
  1. 然后,从 Wikipedia 下载一张测试图像。我们还将从文件中加载图像到blob变量中:
# Download object detection image
image_file = 'source_1.png'
if not os.path.isfile(image_file):
    url = "https://github.com/ivan-vasilev/advanced-deep-learning-with-python/blob/master/chapter04-detection-segmentation/source_1.png"
    r = requests.get(url)
    with open(image_file, 'wb') as f:
        f.write(r.content)

# read and normalize image
image = cv2.imread(image_file)
blob = cv2.dnn.blobFromImage(image, 1 / 255, (416, 416), (0, 0, 0), True, crop=False)
  1. 将图像输入网络并进行推理:
# set as input to the net
net.setInput(blob)

# get network output layers
layer_names = net.getLayerNames()
output_layers = [layer_names[i[0] - 1] for i in net.getUnconnectedOutLayers()]

# inference
# the network outputs multiple lists of anchor boxes,
# one for each detected class
outs = net.forward(output_layers)
  1. 遍历类别和锚框,并为下一步做好准备:
# extract bounding boxes
class_ids = list()
confidences = list()
boxes = list()

# iterate over all classes
for out in outs:
    # iterate over the anchor boxes for each class
    for detection in out:
        # bounding box
        center_x = int(detection[0] * image.shape[1])
        center_y = int(detection[1] * image.shape[0])
        w, h = int(detection[2] * image.shape[1]), int(detection[3] * image.shape[0])
        x, y = center_x - w // 2, center_y - h // 2
        boxes.append([x, y, w, h])

        # confidence
        confidences.append(float(detection[4]))

        # class
        class_ids.append(np.argmax(detection[5:]))
  1. 使用非最大抑制去除噪声。你可以尝试不同的score_thresholdnms_threshold值,看看检测到的物体是如何变化的:
# non-max suppression
ids = cv2.dnn.NMSBoxes(boxes, confidences, score_threshold=0.75, nms_threshold=0.5)
  1. 在图像上绘制边界框及其标签:
for i in ids:
    i = i[0]
    x, y, w, h = boxes[i]
    class_id = class_ids[i]

    color = colors[class_id]

    cv2.rectangle(img=image,
                  pt1=(round(x), round(y)),
                  pt2=(round(x + w), round(y + h)),
                  color=color,
                  thickness=3)

    cv2.putText(img=image,
                text=f"{classes[class_id]}: {confidences[i]:.2f}",
                org=(x - 10, y - 10),
                fontFace=cv2.FONT_HERSHEY_SIMPLEX,
                fontScale=0.8,
                color=color,
                thickness=2)
  1. 最后,我们可以使用以下代码显示检测到的物体:
cv2.imshow("Object detection", image)
cv2.waitKey()

如果一切顺利,这段代码将生成与我们在目标检测介绍部分开始时看到的相同图像。

这就是我们关于 YOLO 的讨论。在下一部分,我们将介绍一种名为 Faster R-CNN 的两阶段目标检测器(R-CNN 代表区域与 CNN)。

Faster R-CNN 的目标检测

在这一部分,我们将讨论一种名为 Faster R-CNN 的两阶段目标检测算法(Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networksarxiv.org/abs/1506.01497)。它是早期两阶段检测器 Fast R-CNN(arxiv.org/abs/1504.08083)和 R-CNN(Rich feature hierarchies for accurate object detection and semantic segmentationarxiv.org/abs/1311.2524)的演变。

我们将首先概述 Faster R-CNN 的一般结构,如下图所示:

Faster R-CNN 的结构;来源: arxiv.org/abs/1506.01497

在我们解释算法时,请记住这个图。像 YOLO 一样,Faster R-CNN 从在 ImageNet 上训练的主干分类网络开始,作为模型各个模块的基础。论文的作者使用了 VGG16 和 ZF 网络(Visualizing and Understanding Convolutional Networkscs.nyu.edu/~fergus/papers/zeilerECCV2014.pdf)作为主干。然而,近期的实现使用了更现代的架构,如 ResNets。主干网络作为模型的两个其他组件——区域提议网络RPN)和检测网络的支撑部分。在下一部分,我们将讨论 RPN。

区域提议网络

在第一阶段,RPN 接收一张图像(任意大小)作为输入,并输出一组矩形兴趣区域(RoIs),其中可能包含物体。RPN 本身是通过提取主干模型的前* p (VGG 的情况下是 13,ZF 网是 5)卷积层创建的(见前图)。一旦输入图像传播到最后的共享卷积层,算法就会获取该层的特征图,并在每个特征图位置滑动另一个小网络。小网络输出在每个位置上的k*锚框中是否存在物体(锚框的概念与 YOLO 中相同)。这一概念在下图的左侧图像中得以体现,显示了 RPN 在最后一个卷积层的单个特征图上滑动的一个位置:

左:RPN 提议在单个位置上的结果;右:使用 RPN 提议的检测示例(标签经过人工增强)。来源:arxiv.org/abs/1506.01497

小网络与所有输入特征图上同一位置的n×n区域完全连接(根据论文,n = 3)。例如,如果最后的卷积层有 512 个特征图,那么在某个位置的小网络输入大小就是 512 x 3 x 3 = 4,608。每个滑动窗口被映射到一个低维(VGG 为 512,ZF 网为 256)的向量。这个向量本身作为输入,传递给以下两个并行的全连接层:

  1. 一个分类层,包含2k单元,组织为k 2 单元的二元 softmax 输出。每个 softmax 的输出表示该物体是否位于每个k锚框中的置信度分数。论文将置信度分数称为物体性(objectness),它衡量锚框的内容是否属于一组物体,而非背景。在训练过程中,物体会根据 IoU 公式分配给锚框,这与 YOLO 中的方法相同。

  2. 一个回归层,包含* 4k 单元,组织为k* 4 单元的 RoI 坐标。4 个单元中的 2 个表示相对于整张图像的 RoI 中心坐标,范围为[0:1]。另外两个坐标表示区域的高度和宽度,相对于整张图像(同样类似于 YOLO)。

论文的作者实验了三种尺度和三种纵横比,结果在每个位置上得到了九个可能的锚框。最终特征图的典型 H×W 大小约为 2,400,这就导致了 2,400*9 = 21,600 个锚框。

理论上,我们将小网络滑动到最后一个卷积层的特征图上。然而,小网络的权重在所有位置上是共享的。因此,滑动操作可以实现为跨通道卷积。因此,网络可以在一次图像传递中对所有锚框进行输出。这相对于 Fast R-CNN 是一个改进,后者需要为每个锚框进行单独的网络传递。

RPN 通过反向传播和随机梯度下降进行训练(多么令人惊讶!)。共享的卷积层以主干网络的权重初始化,其余层随机初始化。每个小批量的样本来自单一图像,该图像包含许多正样本(物体)和负样本(背景)锚框。两种样本之间的采样比例为 1:1。每个锚框都会被分配一个二进制类别标签(表示是否为物体)。有两种带有正标签的锚框:与一个真实框(groundtruth)具有最大 IoU 重叠的锚框,或与任意真实框的 IoU 重叠大于 0.7 的锚框。如果锚框的 IoU 比率低于 0.3,则该框被分配为负标签。既不是正样本也不是负样本的锚框不会参与训练。

由于 RPN 有两个输出层(分类和回归),因此训练使用以下复合成本函数:

让我们详细讨论一下:

  • i 是小批量中锚框的索引。

  • p[i] 是分类输出,表示锚框 i 是物体的预测概率。注意 p[i]^* 是相同的目标数据(0 或 1)。

  • t[i] 是大小为 4 的回归输出向量,表示 RoI 参数。与 YOLO 一样, t[i]^* 是相同的目标向量。

  • L[cls] 是分类层的交叉熵损失。N[cls] 是一个归一化项,等于小批量的大小。

  • L[reg] 是回归损失。,其中 R 是平均绝对误差(请参阅第一章中的成本函数部分,神经网络的基本原理)。 N[reg] 是一个归一化项,等于锚框位置的总数(大约 2400 个)。

最后,分类和回归的成本函数部分通过λ参数结合。由于N[reg] ~ 2400 和 N[cls] = 256,λ 被设定为 10,以保持两者损失之间的平衡。

检测网络

现在我们已经讨论了 RPN,让我们聚焦于检测网络。为了做到这一点,我们将回到Faster R-CNN 结构的图示,这在使用 Faster R-CNN 进行目标检测部分的开头。让我们回顾一下,在第一阶段,RPN 已经生成了 RoI 坐标。检测网络是一个常规的分类器,它决定当前 RoI 中的物体类型(或背景)。RPN 和检测网络共享它们的第一个卷积层,这些层是从主干网络借用的。但检测网络还结合了来自 RPN 的提议区域,以及最后一个共享层的特征图。

那么我们如何将输入组合起来呢?我们可以借助感兴趣区域RoI)最大池化来实现,这是检测网络第二部分的第一层。这个操作的一个示例如下图所示:

2×2的 RoI 最大池化示例,使用 10×7 的特征图和一个 5×5 的兴趣区域(蓝色矩形)

为了简化起见,我们假设有一个单一的10×7特征图和一个单一的 RoI。正如我们在区域提议网络部分学到的那样,RoI 由其坐标、宽度和高度定义。该操作将这些参数转换为特征图上的实际坐标。在此示例中,区域大小为h×w = 5×5。RoI 最大池化进一步由其输出高度H和宽度W定义。在这个例子中,H×W = 2×2,但在实际应用中,这些值可能会更大,如 7×7。该操作将h×w的 RoI 划分为一个网格,网格的大小为(h / H)×(w / W)子区域。

正如我们从示例中看到的,子区域可能具有不同的大小。一旦完成,每个子区域将通过获取该区域的最大值,降采样为单个输出单元。换句话说,RoI 池化可以将任意大小的输入转换为固定大小的输出窗口。通过这种方式,转化后的数据可以以一致的格式在网络中传播。

正如我们在使用 Faster R-CNN 进行目标检测部分中提到的,RPN 和检测网络共享它们的初始层。然而,它们一开始是独立的网络。训练过程在两者之间交替进行,按照四步流程进行:

  1. 训练 RPN,初始化时使用骨干网络的 ImageNet 权重。

  2. 训练检测网络,使用从步骤 1中训练好的 RPN 提议。训练也从 ImageNet 骨干网络的权重开始。此时,两个网络并不共享权重。

  3. 使用检测网络的共享层来初始化 RPN 的权重。然后,再次训练 RPN,但冻结共享层,只微调 RPN 特定的层。此时,两个网络共享权重。

  4. 通过冻结共享层,仅微调检测网络特定层来训练检测网络。

现在我们已经介绍了 Faster R-CNN,在接下来的章节中,我们将讨论如何在实际中使用它,借助预训练的 PyTorch 模型。

使用 PyTorch 实现 Faster R-CNN

在本节中,我们将使用一个预训练的 PyTorch Faster R-CNN,搭载 ResNet50 骨干网络进行目标检测。此示例需要 PyTorch 1.3.1、torchvision 0.4.2 和python-opencv 4.1.1:

  1. 我们将从导入开始:
import os.path

import cv2
import numpy as np
import requests
import torchvision
import torchvision.transforms as transforms
  1. 接下来,我们将继续下载输入图像,并定义 COCO 数据集中的类别名称。这个步骤与我们在 YOLOv3 与 OpenCV 的代码示例 部分实现的相同。下载的图像路径存储在 image_file = 'source_2.png' 变量中,类别名称存储在 classes 列表中。该实现使用了完整的 91 个 COCO 类别。

  2. 我们将加载预训练的 Faster R-CNN 模型,并将其设置为评估模式:

# load the pytorch model
model = torchvision.models.detection.fasterrcnn_resnet50_fpn(pretrained=True)

# set the model in evaluation mode
model.eval()
  1. 然后,我们将使用 OpenCV 读取图像文件:
img = cv2.imread(image_file)
  1. 我们将定义 PyTorch transform 序列,将图像转换为 PyTorch 兼容的张量,并将其传递给网络。网络的输出存储在 output 变量中。正如我们在 区域提议网络 部分讨论的,output 包含三个部分:boxes 是边界框参数,classes 是物体类别,scores 是置信度分数。模型内部应用了 NMS,因此代码中无需再执行此操作:
transform = transforms.Compose([transforms.ToPILImage(), transforms.ToTensor()])
nn_input = transform(img)
output = model([nn_input])
  1. 在我们继续展示检测到的物体之前,我们将为 COCO 数据集的每个类别定义一组随机颜色:
colors = np.random.uniform(0, 255, size=(len(classes), 3))
  1. 我们遍历每个边界框,并将其绘制在图像上:
# iterate over the network output for all boxes
for box, box_class, score in zip(output[0]['boxes'].detach().numpy(),
                                 output[0]['labels'].detach().numpy(),
                                 output[0]['scores'].detach().numpy()):

    # filter the boxes by score
    if score > 0.5:
        # transform bounding box format
        box = [(box[0], box[1]), (box[2], box[3])]

        # select class color
        color = colors[box_class]

        # extract class name
        class_name = classes[box_class]

        # draw the bounding box
        cv2.rectangle(img=img, pt1=box[0], pt2=box[1], color=color, thickness=2)

        # display the box class label
        cv2.putText(img=img, text=class_name, org=box[0], 
                    fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, color=color, thickness=2)

绘制边界框涉及以下步骤:

  • 过滤掉置信度低于 0.5 的框,以防止出现噪声检测。

  • 边界框 box 参数(从 output['boxes'] 提取)包含图像中边界框的左上角和右下角的绝对(像素)坐标。它们只是以元组的形式转换,以适应 OpenCV 格式。

  • 提取类别名称和边界框的颜色。

  • 绘制边界框和类别名称。

  1. 最后,我们可以使用以下代码显示检测结果:
cv2.imshow("Object detection", image)
cv2.waitKey()

这段代码将产生以下结果(公交车上的乘客也被检测到):

Faster R-CNN 物体检测

本节关于物体检测的内容已结束。总结来说,我们讨论了两种最流行的检测模型——YOLO 和 Faster R-CNN。在下一部分,我们将讨论图像分割——你可以将它视为像素级别的分类。

引入图像分割

图像分割是将一个类标签(如人、车或树)分配给图像的每个像素的过程。你可以将其视为分类,但它是在像素级别——而不是对整个图像进行分类,而是单独对每个像素进行分类。图像分割有两种类型:

  • 语义分割:这为每个像素分配一个类别,但不会区分物体实例。例如,下面截图中的中间图像显示了一个语义分割掩码,其中每个车辆的像素值相同。语义分割可以告诉我们某个像素是车辆的一部分,但不能区分两辆车。

  • 实例分割:这为每个像素分配一个类别,并区分物体实例。例如,以下截图右侧的图像展示了一个实例分割掩码,其中每辆车被分割为一个独立的物体。

以下截图展示了语义分割和实例分割的示例:

左侧:输入图像;中间:语义分割;右侧:实例分割;来源: http://sceneparsing.csail.mit.edu/

要训练一个分割算法,我们需要一种特殊类型的真实数据,其中每张图像的标签是图像的分割版本。

最简单的图像分割方法是使用我们在目标检测方法部分中描述的滑动窗口技术。也就是说,我们将使用常规分类器,并以步幅为 1 的方式在任意方向滑动它。在我们得到某个位置的预测后,我们会取输入区域中间的像素,并将其分配给预测类别。可以预见,这种方法非常慢,因为图像中像素的数量非常庞大(即使是 1024×1024 的图像,也有超过 100 万个像素)。幸运的是,已经有更快且更准确的算法,我们将在接下来的部分讨论这些算法。

使用 U-Net 进行语义分割

我们将讨论的第一种分割方法叫做 U-Net(U-Net: 用于生物医学图像分割的卷积网络arxiv.org/abs/1505.04597)。这个名字来源于网络架构的可视化。U-Net 是一种全卷积网络FCN),之所以叫这个名字,是因为它只包含卷积层,没有全连接层。FCN 将整张图像作为输入,并在一次传递中输出其分割图。我们可以将 FCN 分为两个虚拟组件(实际上这只是一个网络):

  • 编码器是网络的第一部分。它类似于常规的卷积神经网络(CNN),只是最后没有全连接层。编码器的作用是学习输入图像的高度抽象表示(这里没有什么新东西)。

  • 解码器是网络的第二部分。它在编码器之后开始,并使用编码器的输出作为输入。解码器的作用是将这些抽象表示转换为分割的真实数据。为此,解码器使用与编码器操作相反的操作。这包括转置卷积(卷积的相反操作)和反池化(池化的相反操作)。

介绍完这些,接下来是 U-Net 的全部精华:

U-Net 架构;来源: https://arxiv.org/abs/1505.04597

每个蓝色框表示一个多通道特征图。框上方表示通道数量,框的左下角表示特征图的大小。白色框表示复制的特征图。箭头表示不同的操作(图例中也有显示)。U的左侧是编码器,右侧是解码器。

接下来,让我们来分割(理解了吗?)U-Net 模块:

  • 编码器:网络以一个 572×572 的 RGB 图像作为输入。从这里开始,它像一个常规的卷积神经网络(CNN),交替进行卷积和最大池化层操作。编码器由以下四个模块组成。

    • 两个连续的跨通道未填充 3×3 卷积,步幅为 1。

    • 一个 2×2 的最大池化层。

    • ReLU 激活。

    • 每个下采样步骤都会将特征图的数量翻倍。

    • 最后的编码器卷积结束时得到 1,024 个 28×28 的特征图。

  • 解码器:它与编码器对称。解码器以最内层的 28×28 特征图作为输入,并同时进行上采样,将其转换为一个 388×388 的分割图。它包含四个上采样模块:

    • 上采样通过 2×2 的转置卷积(步幅为 2)进行(第二章,理解卷积网络),用绿色垂直箭头表示。

    • 每个上采样步骤的输出与对应编码器步骤的裁剪高分辨率特征图进行拼接(灰色水平箭头)。裁剪是必要的,因为每个卷积步骤都会丢失边缘像素。

    • 每个转置卷积后面跟着两个常规卷积,用于平滑扩展后的图像。

    • 上采样步骤将特征图的数量减半。最终输出使用 1×1 瓶颈卷积将 64 个分量的特征图张量映射到所需的类别数量。论文的作者展示了细胞医学图像的二分类分割。

    • 网络输出是对每个像素的 softmax 运算。也就是说,输出包含与像素数量相同的独立 softmax 运算。一个像素的 softmax 输出决定了该像素的类别。U-Net 的训练方式与常规分类网络相同。然而,损失函数是所有像素的 softmax 输出的交叉熵损失的组合。

我们可以看到,由于网络有效(未填充)卷积的作用,输出的分割图像比输入图像要小(388 对 572)。然而,输出图像并不是输入图像的缩放版本。相反,它与输入图像具有一对一的尺度,但仅覆盖输入图像的中央部分。

这在下图中有所示意:

一种用于分割大图像的重叠拼块策略;来源:arxiv.org/abs/1505.04597

必须使用无填充卷积,以避免网络在分割图的边缘产生噪声伪影。这使得能够使用所谓的重叠瓦片策略对任意大小的图像进行分割。输入图像被分割成重叠的输入瓦片,就像前面图示中左侧的瓦片一样。右侧图像中小的光亮区域的分割图需要左侧图像中大的光亮区域(一个瓦片)作为输入。

下一个输入瓦片与前一个瓦片重叠,使得它们的分割图覆盖图像的相邻区域。为了预测图像边缘区域的像素,通过镜像输入图像来推算缺失的上下文。在下一部分中,我们将讨论 Mask R-CNN——一个扩展 Faster R-CNN 用于实例分割的模型。

使用 Mask R-CNN 进行实例分割

Mask R-CNN (arxiv.org/abs/1703.06870) 是 Faster R-CNN 在实例分割上的扩展。Faster R-CNN 为每个候选对象输出两个结果:边界框参数和类别标签。除了这些,Mask R-CNN 增加了第三个输出——一个全卷积网络(FCN),为每个 RoI 生成二值分割掩码。下图展示了 Mask R-CNN 的结构:

Mask R-CNN

RPN 在五种尺度和三种长宽比下生成锚框。分割和分类路径都使用 RPN 的 RoI 预测,但它们之间是独立的。分割路径为每个 I 类生成 I m×m 二值分割掩码。在训练或推理时,仅考虑与分类路径预测的类别相关的掩码,其他掩码会被丢弃。类别预测和分割是并行且解耦的——分类路径预测分割对象的类别,而分割路径则确定掩码。

Mask R-CNN 用更精确的 RoI 对齐层替代了 RoI 最大池化操作。RPN 输出锚框中心及其高度和宽度,作为四个浮动点数值。然后,RoI 池化层将这些数值转换为整数特征图单元格坐标(量化)。此外,RoI 被划分为 H×W 个 bins,也涉及到量化问题。来自 使用 Faster R-CNN 进行物体检测 部分的 RoI 示例展示了这些 bins 大小不同(3×3、3×2、2×3、2×2)。这两种量化级别可能会导致 RoI 与提取特征之间的错位。下图展示了 RoI 对齐如何解决这个问题:

RoI 对齐示例;来源:arxiv.org/abs/1703.06870

虚线代表特征图的单元格。中间实线区域是一个 2×2 的 RoI,叠加在特征图上。请注意,它并没有完全匹配单元格,而是根据 RPN 预测的位置没有量化。以相同的方式,RoI 的一个单元(黑点)并不匹配特征图的某个特定单元。RoI 对齐操作通过双线性插值计算 RoI 单元的值,从而比 RoI 池化更加精确。

在训练中,如果 RoI 与一个真实框的交并比(IoU)至少为 0.5,则该 RoI 被分配为正标签,否则为负标签。掩码目标是 RoI 与其关联的真实掩码的交集。只有正 RoI 参与分割路径的训练。

使用 PyTorch 实现 Mask R-CNN

在这一部分,我们将使用一个具有 ResNet50 主干的预训练 PyTorch Mask R-CNN 进行实例分割。此示例需要 PyTorch 1.1.0、torchvision 0.3.0 和 OpenCV 3.4.2。本示例与我们在 使用 PyTorch 实现 Faster R-CNN 部分中实现的示例非常相似。因此,为了避免重复,我们将省略一些代码部分。开始吧:

  1. 导入、classesimage_file 与 Faster R-CNN 示例相同。

  2. 这两个示例之间的第一个区别是我们将加载 Mask R-CNN 预训练模型:

model = torchvision.models.detection.maskrcnn_resnet50_fpn(pretrained=True)
model.eval()
  1. 我们将输入图像传递给网络并获得 output 变量:
# read the image file
img = cv2.imread(image_file)

# transform the input to tensor
transform = transforms.Compose([transforms.ToPILImage(), transforms.ToTensor()])
nn_input = transform(img)
output = model([nn_input])

除了 boxesclassesscoresoutput 还包含一个额外的 masks 组件,用于预测的分割掩码。

  1. 我们遍历掩码并将其叠加到图像上。图像和掩码是 numpy 数组,我们可以将叠加操作实现为向量化操作。我们将显示边界框和分割掩码:
# iterate over the network output for all boxes
for mask, box, score in zip(output[0]['masks'].detach().numpy(),
                            output[0]['boxes'].detach().numpy(),
                            output[0]['scores'].detach().numpy()):

    # filter the boxes by score
    if score > 0.5:
        # transform bounding box format
        box = [(box[0], box[1]), (box[2], box[3])]

        # overlay the segmentation mask on the image with random color
        img[(mask > 0.5).squeeze(), :] = np.random.uniform(0, 255, size=3)

        # draw the bounding box
        cv2.rectangle(img=img,
                      pt1=box[0],
                      pt2=box[1],
                      color=(255, 255, 255),
                      thickness=2)
  1. 最后,我们可以如下显示分割结果:
cv2.imshow("Object detection", img)
cv2.waitKey()

这个示例将产生右侧的图像,如下所示(左侧的原始图像用于比较):

Mask R-CNN 实例分割

我们可以看到每个分割掩码只在其边界框内定义,所有掩码的值都大于零。为了获得属于物体的实际像素,我们只对分割置信度得分大于 0.5 的像素应用掩码(这段代码是 Mask R-CNN 代码示例中的第 4 步的一部分):

img[(mask > 0.5).squeeze(), :] = np.random.uniform(0, 255, size=3)

这就结束了本章关于图像分割的部分(事实上,也结束了整章内容)。

总结

在本章中,我们讨论了目标检测和图像分割。我们从单次检测算法 YOLO 开始,然后继续讨论了两阶段的 Faster R-CNN 算法。接下来,我们讨论了语义分割网络架构 U-Net。最后,我们谈到了 Mask R-CNN —— Faster R-CNN 的扩展,用于实例分割。

在下一章,我们将探讨一种新的机器学习算法类型,称为生成模型。我们可以利用它们生成新的内容,如图像。敬请期待——这将会很有趣!

第五章:生成模型

在前两章(第四章,高级卷积网络,和第五章,物体检测与图像分割)中,我们专注于监督学习的计算机视觉问题,如分类和物体检测。在本章中,我们将讨论如何借助无监督神经网络来生成新图像。毕竟,知道不需要标注数据是非常有优势的。更具体地说,我们将讨论生成模型。

本章将涵盖以下主题:

  • 生成模型的直觉与理论依据

  • 变分自编码器VAEs)介绍

  • 生成对抗网络GANs)介绍

  • GAN 的类型

  • 艺术风格迁移介绍

生成模型的直觉与理论依据

到目前为止,我们已经将神经网络作为判别模型使用。这意味着,给定输入数据,判别模型将其映射到某个标签(换句话说,就是分类)。一个典型的例子是将 MNIST 图像分类到 10 个数字类别中,神经网络将输入数据特征(像素强度)映射到数字标签。我们也可以换一种方式来说:判别模型给我们的是![](类别)的概率,给定![](输入)。以 MNIST 为例,这就是给定图像的像素强度时,数字的概率。

另一方面,生成模型学习的是类别如何分布。你可以把它看作是与判别模型所做的事情相反的过程。生成模型不是预测类别概率,而是给定某些输入特征时,尝试预测在给定类别下输入特征的概率, ![], ![] - ![]。例如,当给定数字类别时,生成模型能够生成手写数字的图像。由于我们只有 10 个类别,它将只能生成 10 张图像。然而,我们仅用这个例子来说明这一概念。实际上, ![] 类别可以是任意值的张量,模型将能够生成具有不同特征的无限数量的图像。如果你现在不理解这点,不用担心;我们将在本章中查看许多示例。

在本章中,我们将用小写的p表示概率分布,而不是之前章节中使用的通常的大写P。这样做是为了遵循在变分自编码器(VAE)和生成对抗网络(GANs)中已建立的惯例。写这本书时,我没有找到明确的理由使用小写字母,但一个可能的解释是,P表示事件的概率,而p表示随机变量的质量(或密度)函数的概率。

使用神经网络进行生成的最流行方法之一是通过 VAE 和 GAN。接下来,我们将介绍 VAE。

VAE 简介

要理解 VAE,我们需要谈谈常规的自编码器。自编码器是一个前馈神经网络,试图重建其输入。换句话说,自编码器的目标值(标签)等于输入数据,y^i = x^i,其中i是样本索引。我们可以正式地说,它试图学习一个恒等函数,![](一个重复其输入的函数)。由于我们的标签只是输入数据,自编码器是一种无监督算法。

下图表示了一个自编码器:

自编码器

自编码器由输入层、隐藏(或瓶颈)层和输出层组成。类似于 U-Net(第四章,目标检测和图像分割),我们可以将自编码器视为两个组件的虚拟组合:

  • 编码器:将输入数据映射到网络的内部表示。为了简单起见,在这个例子中,编码器是一个单一的、全连接的瓶颈隐藏层。内部状态就是它的激活向量。一般来说,编码器可以有多个隐藏层,包括卷积层。

  • 解码器:试图从网络的内部数据表示中重建输入。解码器也可以有一个复杂的结构,通常与编码器相对称。虽然 U-Net 试图将输入图像转换为另一个领域的目标图像(例如,分割图),但自编码器只是简单地试图重建其输入。

我们可以通过最小化损失函数来训练自编码器,这个损失函数称为重建 误差,![]。它衡量原始输入和其重建之间的距离。我们可以像通常那样,通过梯度下降和反向传播来最小化它。根据我们使用的方法,既可以使用均方误差MSE),也可以使用二元交叉熵(比如交叉熵,但只有两个类别)作为重建误差。

此时,您可能会想,既然自编码器只是重复它的输入,那么它的意义何在?然而,我们并不关心网络的输出,而是它的内部数据表示(也被称为潜在空间中的表示)。潜在空间包含那些不是直接观察到的隐藏数据特征,而是由算法推断出来的。关键在于瓶颈层的神经元数量少于输入/输出层的神经元数量。这样做有两个主要原因:

  • 因为网络尝试从较小的特征空间中重建输入数据,它学习到了数据的紧凑表示。你可以将其视为压缩(但不是无损的)。

  • 通过使用更少的神经元,网络被迫仅学习数据中最重要的特征。为了说明这一概念,我们来看去噪自编码器(denoising autoencoder),在训练过程中,我们故意使用损坏的输入数据,但目标数据保持不受损坏。例如,如果我们训练一个去噪自编码器来重建 MNIST 图像,我们可以通过将最大强度(白色)设置为图像中的随机像素来引入噪声(如下图所示)。为了最小化与无噪声目标之间的损失,去噪自编码器被迫超越输入中的噪声,只学习数据的关键特征。然而,如果网络的隐藏神经元比输入神经元多,它可能会过拟合噪声。通过减少隐藏神经元的数量这一额外约束,网络只能尝试忽略噪声。训练完成后,我们可以使用去噪自编码器从真实图像中去除噪声:

去噪自编码器输入和目标

编码器将每个输入样本映射到潜在空间,在那里潜在表示的每个属性都有一个离散的值。这意味着一个输入样本只能拥有一个潜在表示。因此,解码器只能以一种可能的方式重建输入。换句话说,我们只能生成一个输入样本的单一重建。但我们并不想要这样。相反,我们希望生成与原始图像不同的新图像。变分自编码器(VAE)是实现这一任务的一个可能解决方案。

VAE 可以用概率术语描述潜在表示。也就是说,我们将为每个潜在属性生成一个概率分布,而不是离散值,从而使潜在空间变得连续。这使得随机采样和插值变得更加容易。我们通过一个例子来说明这一点。假设我们正在尝试编码一辆车辆的图像,而我们的潜在表示是一个向量z,它包含n个元素(瓶颈层中的n个神经元)。每个元素表示车辆的一个属性,例如长度、高度和宽度(如下图所示)。

假设平均车辆长度是四米。VAE 并不是用固定值表示,而是将这个属性解码为均值为 4 的正态分布(其他属性也适用)。然后,解码器可以选择从该分布的范围内采样一个潜在变量。例如,它可以重建一个比输入更长、更低的车辆。通过这种方式,VAE 可以生成输入的无限多个修改版本:

一个变分编码器从潜在变量的分布范围中采样不同值的示例

让我们正式化这个过程:

  • 编码器的目标是近似真实的概率分布,,其中z是潜在空间的表示。然而,编码器通过从不同样本的条件概率分布推断间接地实现这一目标,,其中x是输入数据。换句话说,编码器试图学习在给定输入数据x的情况下,z的概率分布。我们将编码器对的近似表示为,其中φ是网络的权重。编码器的输出是一个概率分布(例如,高斯分布),表示可能由x生成的z的所有值。在训练过程中,我们不断更新权重φ,使更接近真实的*。

  • 解码器的目标是近似真实的概率分布,。换句话说,解码器试图学习在给定潜在表示z的情况下,数据x的条件概率分布。我们将解码器对真实概率分布的近似表示为,其中θ是解码器的权重。这个过程从随机(或称为随机采样)地从概率分布(例如高斯分布)中采样z开始。然后,z会传递到解码器,通过解码器的输出生成可能的对应x值的概率分布。在训练过程中,我们不断更新权重θ,使更接近真实的*。

VAE 使用一种特殊类型的损失函数,该函数包含两个项:

第一个是 Kullback-Leibler 散度(第一章,神经网络的基本原理)在概率分布之间,,和预期的概率分布,之间的差异。在这个背景下,它衡量了当我们使用表示时丢失了多少信息(换句话说,两者分布的接近程度)。它鼓励自编码器探索不同的重建方式。第二个是重建损失,衡量原始输入和其重建之间的差异。差异越大,损失越大。因此,它鼓励自编码器以更好的方式重建数据。

为了实现这一点,瓶颈层不会直接输出潜在状态变量。相反,它将输出两个向量,这两个向量描述了每个潜在变量的分布的均值方差

变分编码器采样

一旦我们获得均值和方差分布,就可以从潜在变量分布中采样一个状态z,并将其传递通过解码器进行重建。但我们还不能庆祝,这给我们带来了另一个问题:反向传播无法应用于像我们这里这种随机过程。幸运的是,我们可以通过所谓的重参数化技巧来解决这个问题。首先,我们将从一个高斯分布中采样一个与z维度相同的随机向量ε(前面图中的ε圆)。然后,我们将其偏移潜在分布的均值μ,并按潜在分布的方差σ进行缩放:

通过这种方式,我们将能够优化均值和方差(红色箭头),并且我们将在反向传播中省略随机生成器。同时,采样数据将具有原始分布的属性。现在我们已经介绍了 VAE,接下来我们将学习如何实现一个 VAE。

使用 VAE 生成新的 MNIST 数字

在这一节中,我们将学习如何使用 VAE 生成 MNIST 数据集的新数字。我们将使用 TF 2.0.0 下的 Keras 来实现。我们选择 MNIST 是因为它能够很好地展示 VAE 的生成能力。

本节中的代码部分基于github.com/keras-team/keras/blob/master/examples/variational_autoencoder.py

让我们一步一步地走过实现过程:

  1. 我们从导入开始。我们将使用集成在 TF 中的 Keras 模块:
import matplotlib.pyplot as plt
from matplotlib.markers import MarkerStyle
import numpy as np
import tensorflow as tf
from tensorflow.keras import backend as K
from tensorflow.keras.layers import Lambda, Input, Dense
from tensorflow.keras.losses import binary_crossentropy
from tensorflow.keras.models import Model
  1. 现在,我们将实例化 MNIST 数据集。回想一下,在 第二章 中的 理解卷积网络 部分,我们使用 TF/Keras 实现了一个迁移学习的示例,并使用 tensorflow_datasets 模块加载了 CIFAR-10 数据集。在这个例子中,我们将使用 keras.datasets 模块加载 MNIST,这同样适用:
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()

image_size = x_train.shape[1] * x_train.shape[1]
x_train = np.reshape(x_train, [-1, image_size])
x_test = np.reshape(x_test, [-1, image_size])
x_train = x_train.astype('float32') / 255
x_test = x_test.astype('float32') / 255

  1. 接下来,我们将实现 build_vae 函数,该函数将构建 VAE:

    • 我们将分别访问编码器、解码器和完整的网络。该函数将它们作为元组返回。

    • 瓶颈层将只有 2 个神经元(即,我们将只有 2 个潜在变量)。这样,我们就能够将潜在分布显示为二维图。

    • 编码器/解码器将包含一个具有 512 个神经元的单个中间(隐藏)全连接层。这不是一个卷积网络。

    • 我们将使用交叉熵重建损失和 KL 散度。

以下展示了这一全球实现方式:

def build_vae(intermediate_dim=512, latent_dim=2):
   # encoder first
    inputs = Input(shape=(image_size,), name='encoder_input')
    x = Dense(intermediate_dim, activation='relu')(inputs)

    # latent mean and variance
    z_mean = Dense(latent_dim, name='z_mean')(x)
    z_log_var = Dense(latent_dim, name='z_log_var')(x)

    # Reparameterization trick for random sampling
    # Note the use of the Lambda layer
    # At runtime, it will call the sampling function
    z = Lambda(sampling, output_shape=(latent_dim,), 
    name='z')([z_mean, z_log_var])

    # full encoder encoder model
    encoder = Model(inputs, [z_mean, z_log_var, z], name='encoder')
    encoder.summary()

    # decoder
    latent_inputs = Input(shape=(latent_dim,), name='z_sampling')
    x = Dense(intermediate_dim, activation='relu')(latent_inputs)
    outputs = Dense(image_size, activation='sigmoid')(x)

    # full decoder model
    decoder = Model(latent_inputs, outputs, name='decoder')
    decoder.summary()

    # VAE model
    outputs = decoder(encoder(inputs)[2])
    vae = Model(inputs, outputs, name='vae')

    # Loss function
    # we start with the reconstruction loss
    reconstruction_loss = binary_crossentropy(inputs, outputs) *
    image_size

    # next is the KL divergence
    kl_loss = 1 + z_log_var - K.square(z_mean) - K.exp(z_log_var)
    kl_loss = K.sum(kl_loss, axis=-1)
    kl_loss *= -0.5

    # we combine them in a total loss
    vae_loss = K.mean(reconstruction_loss + kl_loss)
    vae.add_loss(vae_loss)

    return encoder, decoder, vae
  1. 与网络定义直接相关的是 sampling 函数,它实现了从高斯单位随机采样潜在向量 z(这是我们在 VAE 简介 部分介绍的重参数化技巧):
def sampling(args: tuple):
    """
    :param args: (tensor, tensor) mean and log of variance of 
    q(z|x)
    """

    # unpack the input tuple
    z_mean, z_log_var = args

    # mini-batch size
    mb_size = K.shape(z_mean)[0]

    # latent space size
    dim = K.int_shape(z_mean)[1]

    # random normal vector with mean=0 and std=1.0
    epsilon = K.random_normal(shape=(mb_size, dim))

    return z_mean + K.exp(0.5 * z_log_var) * epsilon
  1. 现在,我们需要实现 plot_latent_distribution 函数。它收集所有测试集图像的潜在表示,并将其显示在二维图上。我们之所以能够这样做,是因为我们的网络只有两个潜在变量(对应图的两个轴)。请注意,为了实现这一点,我们只需要 encoder
def plot_latent_distribution(encoder, x_test, y_test, batch_size=128):
    z_mean, _, _ = encoder.predict(x_test, batch_size=batch_size)
    plt.figure(figsize=(6, 6))

    markers = ('o', 'x', '^', '<', '>', '*', 'h', 'H', 'D', 'd',
    'P', 'X', '8', 's', 'p')

    for i in np.unique(y_test):
        plt.scatter(z_mean[y_test == i, 0], z_mean[y_test == i, 1],
                                marker=MarkerStyle(markers[i], 
                                fillstyle='none'),
                                edgecolors='black')

    plt.xlabel("z[0]")
    plt.ylabel("z[1]")
    plt.show()
  1. 接下来,我们将实现 plot_generated_images 函数。它将在 [-4, 4] 范围内为每个潜在变量 z 采样 n*n 个向量。然后,它将基于这些采样的向量生成图像,并在二维网格中显示。请注意,为了做到这一点,我们只需要 decoder
def plot_generated_images(decoder):
    # display a nxn 2D manifold of digits
    n = 15
    digit_size = 28

    figure = np.zeros((digit_size * n, digit_size * n))
    # linearly spaced coordinates corresponding to the 2D plot
    # of digit classes in the latent space
    grid_x = np.linspace(-4, 4, n)
    grid_y = np.linspace(-4, 4, n)[::-1]

    # start sampling z1 and z2 in the ranges grid_x and grid_y
    for i, yi in enumerate(grid_y):
        for j, xi in enumerate(grid_x):
            z_sample = np.array([[xi, yi]])
            x_decoded = decoder.predict(z_sample)
            digit = x_decoded[0].reshape(digit_size, digit_size)
            slice_i = slice(i * digit_size, (i + 1) * digit_size)
            slice_j = slice(j * digit_size, (j + 1) * digit_size)
            figure[slice_i, slice_j] = digit

    # plot the results
    plt.figure(figsize=(6, 5))
    start_range = digit_size // 2
    end_range = n * digit_size + start_range + 1
    pixel_range = np.arange(start_range, end_range, digit_size)
    sample_range_x = np.round(grid_x, 1)
    sample_range_y = np.round(grid_y, 1)
    plt.xticks(pixel_range, sample_range_x)
    plt.yticks(pixel_range, sample_range_y)
    plt.xlabel("z[0]")
    plt.ylabel("z[1]")
   plt.imshow(figure, cmap='Greys_r')
    plt.show()
  1. 现在,运行整个代码。我们将使用 Adam 优化器(在 第一章中介绍的,神经网络的基础)训练网络 50 个周期:
if __name__ == '__main__':
    encoder, decoder, vae = build_vae()

    vae.compile(optimizer='adam')
    vae.summary()

    vae.fit(x_train,
            epochs=50,
            batch_size=128,
            validation_data=(x_test, None))

    plot_latent_distribution(encoder, x_test, y_test,
                                      batch_size=128)

    plot_generated_images(decoder)
  1. 如果一切顺利,训练完成后,我们将看到每个数字类别的潜在分布图,适用于所有测试图像。左轴和底轴表示 z[1]z[2] 潜在变量。不同的标记形状代表不同的数字类别:

MNIST 测试图像的潜在分布

  1. 接下来,我们将查看由 plot_generated_images 生成的图像。坐标轴代表用于每张图像的特定潜在分布 z

VAE 生成的图像

这就是我们对 VAE 的描述的结束。接下来,我们将讨论 GANs——可以说是最流行的生成模型家族。

GANs 简介

在本节中,我们将讨论今天最受欢迎的生成模型之一:GAN 框架。它首次出现在 2014 年的标志性论文《生成对抗网络》中(papers.nips.cc/paper/5423-generative-adversarial-nets.pdf)。GAN 框架可以处理任何类型的数据,但它最受欢迎的应用无疑是生成图像,我们也将在这个背景下讨论它的应用。让我们看看它是如何工作的:

一个 GAN 系统

GAN 是由两个组件(神经网络)组成的系统:

  • 生成器:这就是生成模型本身。它以一个概率分布(随机噪声)作为输入,并尝试生成一个逼真的输出图像。它的目的类似于 VAE 的解码器部分。

  • 判别器:它接受两个交替的输入:训练数据集中的真实图像或生成器生成的假样本。它试图判断输入图像是来自真实图像还是生成的图像。

这两个网络是作为一个系统一起训练的。一方面,判别器尝试更好地分辨真实和虚假的图像。另一方面,生成器尝试输出更逼真的图像,以便能够欺骗判别器,让判别器认为生成的图像是真的。用原论文中的类比,你可以将生成器想象成一群伪造货币的人,试图制造假币。相反,判别器就像一名警察,试图抓住假币,而这两者在不断地互相欺骗(因此有了“对抗”这个名字)。系统的最终目标是使生成器变得如此出色,以至于判别器无法区分真实和虚假的图像。即使判别器执行分类任务,GAN 仍然是无监督的,因为我们不需要为图像提供标签。在下一节中,我们将讨论在 GAN 框架下的训练过程。

训练 GAN

我们的主要目标是让生成器生成逼真的图像,GAN 框架是实现这个目标的工具。我们将分别和顺序地训练生成器和判别器(一个接一个),并多次交替进行这两个阶段。

在详细介绍之前,让我们使用以下图示来介绍一些符号:

  • 我们用来表示生成器,其中是网络权重,z是潜在向量,它作为生成器的输入。可以把它看作是启动图像生成过程的随机种子值。它与变分自编码器(VAE)中的潜在向量相似。z具有一个概率分布,通常是随机正态分布或随机均匀分布。生成器输出虚假样本x,其概率分布为。你可以把看作是生成器根据真实数据的概率分布。

  • 我们用来表示判别器,其中是网络权重。它的输入可以是真实数据,具有分布,或是生成的样本。判别器是一个二分类器,输出输入图像是否属于真实数据(网络输出 1)或生成的数据(网络输出 0)。

  • 在训练过程中,我们分别用表示判别器和生成器的损失函数。

以下是 GAN 框架的更详细的图示:

一个详细的 GAN 示例

GAN 训练与训练常规的深度神经网络(DNN)不同,因为我们有两个网络。我们可以把它看作是一个顺序的极小极大零和游戏,涉及两名玩家(生成器和判别器):

  • 顺序:这意味着玩家们依次进行轮流,就像象棋或井字游戏一样(与同时进行相对)。首先,判别器尝试最小化,但它只能通过调整权重来实现这一点。接下来,生成器尝试最小化,但它只能通过调整权重来实现。我们会多次重复这个过程。

  • 零和:这意味着一个玩家的收益或损失由对方玩家的收益或损失来平衡。也就是说,生成器的损失和判别器的损失之和始终为 0:

  • 最小化最大化:这意味着第一方(生成器)的策略是最小化对手(判别器)的最大分数(因此得名)。当我们训练判别器时,它变得更擅长区分真实样本和虚假样本(最小化!)。接下来,当我们训练生成器时,它试图达到新改进的判别器的水平(我们最小化!,这等同于最大化!)。这两个网络在不断竞争。我们用以下公式表示最小化最大化游戏,其中!是损失函数:

假设在经过一系列训练步骤后, 都会达到某个局部最小值。这里,最小化最大化游戏的解被称为纳什均衡。纳什均衡发生在其中一个参与者的行为不再变化,无论另一个参与者如何行动。在生成对抗网络(GAN)框架中,当生成器变得足够优秀,以至于判别器无法再区分生成的样本和真实样本时,就会发生纳什均衡。也就是说,判别器的输出将始终为一半,无论输入是什么。

现在我们已经对 GAN 有了一个概览,接下来讨论如何训练它们。我们将从判别器开始,然后继续讨论生成器。

训练判别器

判别器是一个分类神经网络,我们可以像往常一样训练它,即使用梯度下降和反向传播。然而,训练集由真实样本和生成样本组成。让我们学习如何将这一点融入训练过程中:

  1. 根据输入样本(真实或虚假),我们有两条路径:

    • 从真实数据中选择样本,,并用它来生成

    • 生成一个虚假样本,。在这里,生成器和判别器作为一个单一网络工作。我们从一个随机向量z开始,利用它生成生成样本,。然后,我们将其作为输入传递给判别器,生成最终输出,

  2. 接下来,我们计算损失函数,反映了训练数据的二重性(稍后会详细介绍)。

  3. 最后,我们反向传播误差梯度并更新权重。尽管两个网络是一起工作的,但生成器的权重 将被锁定,我们只会更新判别器的权重 。这样可以确保我们通过改善判别器的性能来提高其效果,而不是使生成器变得更差。

为了理解判别器损失,让我们回顾一下交叉熵损失的公式:

这里, 是输出属于第 i 类(在 n 个总类中)的估计概率, 是实际概率。为了简化起见,我们假设我们在单个训练样本上应用该公式。在二分类的情况下,公式可以简化如下:

当目标概率是![](独热编码)时,损失项总是 0

我们可以扩展该公式以适应一个包含 m 个样本的小批量:

了解这些之后,让我们定义判别器损失:

虽然看起来很复杂,但这实际上只是一个二分类器的交叉熵损失,并且加上了一些 GAN 特定的调整项。让我们来讨论一下:

  • 损失的两个组成部分反映了两种可能的类别(真实或伪造),这两种类别在训练集中数量相等。

  • 是当输入来自真实数据时的损失。理想情况下,在这种情况下,我们会有 

  • 在这种情况下,期望项  意味着 x 是从 中采样的。本质上,这部分损失意味着,当我们从 中采样x时,我们期望判别器输出 。最后,0.5 是实际数据的累积类概率 ,因为它正好占整个数据集的一半。

  • 是当输入来自生成数据时的损失。在这种情况下,我们可以做出与真实数据部分相同的观察。然而,当 时,这个项是最大化的。

总结来说,当对于所有以及对于所有生成的(或)时,判别器的损失将为零。

训练生成器

我们将通过提高生成器欺骗判别器的能力来训练生成器。为了实现这一点,我们需要同时使用两个网络,类似于我们用假样本训练判别器的方式:

  1. 我们从一个随机的潜在向量z开始,并将其传递给生成器和判别器,以生成输出

  2. 损失函数与判别器的损失函数相同。然而,我们在这里的目标是最大化它,而不是最小化它,因为我们希望欺骗判别器。

  3. 在反向传播中,判别器的权重被锁定,我们只能调整。这迫使我们通过改善生成器来最大化判别器的损失,而不是让判别器变得更差。

你可能注意到,在这个阶段,我们只使用生成的数据。由于判别器的权重被锁定,我们可以忽略处理真实数据的损失函数部分。因此,我们可以将其简化为以下形式:

该公式的导数(梯度)是,在下图中可以看到它是连续不断的线。这对训练施加了限制。在初期,当判别器能够轻松区分真假样本()时,梯度将接近零。这将导致权重的学习几乎没有(这是消失梯度问题的另一种表现):

两个生成器损失函数的梯度

我们可以通过使用不同的损失函数来解决这个问题:

该函数的导数在前面的图中用虚线表示。当且梯度较大时,损失仍然会最小化;也就是说,当生成器表现不佳时。在这种损失下,博弈不再是零和博弈,但这对 GAN 框架不会产生实际影响。现在,我们拥有定义 GAN 训练算法所需的所有要素。我们将在下一节中进行定义。

将所有内容整合在一起

通过我们新获得的知识,我们可以完整地定义最小最大目标:

简而言之,生成器试图最小化目标,而判别器则试图最大化它。请注意,虽然判别器应该最小化其损失,但极小极大目标是判别器损失的负值,因此判别器必须最大化它。

以下逐步训练算法由 GAN 框架的作者介绍。

对此进行多次迭代:

  1. 重复进行k步,其中k是一个超参数:

    • 从潜在空间中采样一个包含m个随机样本的小批量,

    • 从真实数据中采样一个包含m个样本的小批量,

    • 通过上升其成本的随机梯度来更新判别器权重,

  1. 从潜在空间中采样一个包含m个随机样本的小批量,

  2. 通过下降其成本的随机梯度来更新生成器:

或者,我们可以使用在训练生成器部分介绍的更新后的成本函数:

现在我们知道如何训练 GANs 了,让我们来讨论一些在训练过程中可能遇到的问题。

训练 GAN 时的问题

训练 GAN 模型时有一些主要的陷阱:

  • 梯度下降算法旨在找到损失函数的最小值,而不是纳什均衡,两者并不相同。因此,有时训练可能无法收敛,反而会发生震荡。

  • 记住,判别器的输出是一个 Sigmoid 函数,表示示例为真实或伪造的概率。如果判别器在这项任务上表现得太好,那么每个训练样本的概率输出将会收敛到 0 或 1。这意味着错误梯度将始终为 0,从而阻止生成器学习任何东西。另一方面,如果判别器在识别真实和伪造图像方面表现不佳,它会将错误的信息反向传播给生成器。因此,判别器不能太强也不能太弱,才能保证训练成功。实际上,这意味着我们不能在训练时让其收敛。

  • 模式崩溃是一个问题,其中生成器无论输入的潜在向量值如何,都只能生成有限数量的图像(甚至可能只有一张)。为了理解为什么会发生这种情况,我们可以专注于一个单一的生成器训练过程,该过程试图最小化![],同时保持判别器的权重不变。换句话说,生成器试图生成一个假图像,x*,使得![]。然而,损失函数并没有强迫生成器为不同的潜在输入向量值创建独特的图像,**x**。也就是说,训练过程可能会改变生成器,使其完全脱离潜在向量值来生成图像,同时仍然最小化损失函数。例如,一个生成新 MNIST 图像的 GAN 可能只会生成数字 4,而不管输入是什么。一旦我们更新了判别器,之前的图像x^可能不再是最优的,这将迫使生成器生成新的不同图像。然而,模式崩溃可能会在训练过程的不同阶段再次出现。

现在我们已经熟悉了 GAN 框架,接下来我们将讨论几种不同类型的 GAN。

GAN 的类型

自从 GAN 框架首次被提出以来,已经出现了许多新的变种。事实上,现在有很多新的 GAN,为了突出特色,作者们提出了一些富有创意的 GAN 名称,例如 BicycleGAN、DiscoGAN、GANs for LIFE 和 ELEGANT。在接下来的几个部分,我们将讨论其中的一些。所有示例都已经使用 TensorFlow 2.0 和 Keras 实现。

DCGAN、CGAN、WGAN 和 CycleGAN 的代码部分灵感来自于 github.com/eriklindernoren/Keras-GAN。你可以在 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter05 找到本章所有示例的完整实现。

深度卷积生成对抗网络

在本节中,我们将实现深度卷积生成对抗网络DCGAN基于深度卷积生成对抗网络的无监督表示学习arxiv.org/abs/1511.06434)。在原始的 GAN 框架提案中,作者仅使用了全连接网络。相比之下,在 DCGAN 中,生成器和判别器都是卷积神经网络(CNN)。它们有一些约束,有助于稳定训练过程。你可以将这些约束视为 GAN 训练的一般准则,而不仅仅是针对 DCGAN 的:

  • 判别器使用步幅卷积(strided convolutions)代替池化层(pooling layers)。

  • 生成器使用转置卷积(transpose convolutions)将潜在向量  上采样到生成图像的大小。

  • 两个网络都使用批归一化(batch normalization)。

  • 除了判别器的最后一层外,不使用全连接层。

  • 对生成器和判别器的所有层使用 LeakyReLU 激活函数,除了它们的输出层。生成器的输出层使用 Tanh 激活函数(其范围为(-1, 1)),以模拟真实数据的特性。判别器的输出层只有一个 sigmoid 输出(记住,它的范围是(0, 1)),因为它衡量样本是真实的还是伪造的概率。

在以下图示中,我们可以看到 DCGAN 框架中的一个示例生成器网络:

使用反卷积的生成器网络

实现 DCGAN

在本节中,我们将实现 DCGAN,它生成新的 MNIST 图像。这个例子将作为后续所有 GAN 实现的模板。让我们开始吧:

  1. 让我们从导入必要的模块和类开始:
import matplotlib.pyplot as plt
import numpy as np
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import \
    Conv2D, Conv2DTranspose, BatchNormalization, Dropout, Input,
    Dense, Reshape, Flatten
from tensorflow.keras.layers import LeakyReLU
from tensorflow.keras.models import Sequential, Model
from tensorflow.keras.optimizers import Adam
  1. 实现build_generator函数。我们将遵循本节开头概述的指导原则——使用反卷积进行上采样、批量归一化和 LeakyReLU 激活。模型从一个全连接层开始,用于上采样 1D 潜在向量。然后,向量通过一系列Conv2DTranspose进行上采样。最后一个Conv2DTranspose使用tanh激活,生成的图像只有 1 个通道:
def build_generator(latent_input: Input):
    model = Sequential([
        Dense(7 * 7 * 256, use_bias=False,
        input_shape=latent_input.shape[1:]),
        BatchNormalization(), LeakyReLU(),

        Reshape((7, 7, 256)),

        # expand the input with transposed convolutions
        Conv2DTranspose(filters=128, kernel_size=(5, 5), 
                        strides=(1, 1), 
                        padding='same', use_bias=False),
        BatchNormalization(), LeakyReLU(),

        # gradually reduce the volume depth
        Conv2DTranspose(filters=64, kernel_size=(5, 5),
                        strides=(2, 2),
                        padding='same', use_bias=False),
        BatchNormalization(), LeakyReLU(),

        Conv2DTranspose(filters=1, kernel_size=(5, 5), 
                        strides=(2, 2), padding='same', 
                        use_bias=False, activation='tanh'),
    ])

    # this is forward phase
    generated = model(latent_input)

    return Model(z, generated)
  1. 构建判别器。再次说明,这是一个简单的 CNN,使用步幅卷积:
def build_discriminator():
    model = Sequential([
        Conv2D(filters=64, kernel_size=(5, 5), strides=(2, 2),
               padding='same', input_shape=(28, 28, 1)),
        LeakyReLU(), Dropout(0.3),
        Conv2D(filters=128, kernel_size=(5, 5), strides=(2, 2),
               padding='same'),
        LeakyReLU(), Dropout(0.3),
        Flatten(),
        Dense(1, activation='sigmoid'),
    ])

    image = Input(shape=(28, 28, 1))
    output = model(image)

    return Model(image, output)
  1. 实现train函数,进行实际的 GAN 训练。这个函数实现了在Training GANs部分的Putting it all together小节中概述的过程。我们将从函数声明和变量初始化开始:
def train(generator, discriminator, combined, steps, batch_size):
    # Load the dataset
    (x_train, _), _ = mnist.load_data()

    # Rescale in [-1, 1] interval
    x_train = (x_train.astype(np.float32) - 127.5) / 127.5
    x_train = np.expand_dims(x_train, axis=-1)

    # Discriminator ground truths
    real = np.ones((batch_size, 1))
    fake = np.zeros((batch_size, 1))

    latent_dim = generator.input_shape[1]

我们将继续训练循环,其中我们交替进行一次判别器训练和一次生成器训练。首先,我们在一批real_images和一批generated_images上训练discriminator。然后,我们在同一批generated_images上训练生成器(其中也包括discriminator)。注意,我们将这些图像标记为真实图像,因为我们希望最大化discriminator的损失。以下是实现代码(请注意缩进,这仍然是train函数的一部分):

for step in range(steps):
    # Train the discriminator

    # Select a random batch of images
    real_images = x_train[np.random.randint(0, x_train.shape[0],
    batch_size)]

    # Random batch of noise
    noise = np.random.normal(0, 1, (batch_size, latent_dim))

    # Generate a batch of new images
    generated_images = generator.predict(noise)

    # Train the discriminator
    discriminator_real_loss = discriminator.train_on_batch
    (real_images, real)
    discriminator_fake_loss = discriminator.train_on_batch
    (generated_images, fake)
    discriminator_loss = 0.5 * np.add(discriminator_real_loss,
    discriminator_fake_loss)

    # Train the generator
    # random latent vector z
    noise = np.random.normal(0, 1, (batch_size, latent_dim))

    # Train the generator
    # Note that we use the "valid" labels for the generated images
    # That's because we try to maximize the discriminator loss
    generator_loss = combined.train_on_batch(noise, real)

    # Display progress
    print("%d [Discriminator loss: %.4f%%, acc.: %.2f%%] [Generator
    loss: %.4f%%]" % (step, discriminator_loss[0], 100 *
    discriminator_loss[1], generator_loss))
  1. 实现一个模板函数plot_generated_images,在训练完成后显示一些生成的图像:

    1. 创建一个nxn网格(figure变量)。

    2. 创建nxn随机潜在向量(noise变量)——每个生成的图像对应一个潜在向量。

    3. 生成图像并将它们放置在网格单元中。

    4. 显示结果。

以下是实现代码:

def plot_generated_images(generator):
    n = 10
    digit_size = 28

    # big array containing all images
    figure = np.zeros((digit_size * n, digit_size * n))

    latent_dim = generator.input_shape[1]

    # n*n random latent distributions
    noise = np.random.normal(0, 1, (n * n, latent_dim))

    # generate the images
    generated_images = generator.predict(noise)

    # fill the big array with images
    for i in range(n):
        for j in range(n):
            slice_i = slice(i * digit_size, (i + 1) * digit_size)
            slice_j = slice(j * digit_size, (j + 1) * digit_size)
            figure[slice_i, slice_j] = np.reshape
                          (generated_images[i * n + j], (28, 28))

    # plot the results
    plt.figure(figsize=(6, 5))
    plt.axis('off')
    plt.imshow(figure, cmap='Greys_r')
    plt.show()
  1. 通过包含generatordiscriminatorcombined网络来构建完整的 GAN 模型。我们将使用大小为 64 的潜在向量(latent_dim变量),并使用 Adam 优化器运行 50,000 个批次的训练(这可能需要一段时间)。然后,我们将绘制结果:
latent_dim = 64

# Build the generator
# Generator input z
z = Input(shape=(latent_dim,))

generator = build_generator(z)

generated_image = generator(z)

# we'll use Adam optimizer
optimizer = Adam(0.0002, 0.5)

# Build and compile the discriminator
discriminator = build_discriminator()
discriminator.compile(loss='binary_crossentropy',
                      optimizer=optimizer,
                      metrics=['accuracy'])

# Only train the generator for the combined model
discriminator.trainable = False

# The discriminator takes generated image as input and determines validity
real_or_fake = discriminator(generated_image)

# Stack the generator and discriminator in a combined model
# Trains the generator to deceive the discriminator
combined = Model(z, real_or_fake)
combined.compile(loss='binary_crossentropy', optimizer=optimizer)

train(generator, discriminator, combined, steps=50000, batch_size=100)

plot_generated_images(generator)

如果一切顺利,我们应该会看到类似以下的结果:

新生成的 MNIST 图像

这部分是我们对 DCGAN 的讨论的结束。在下一部分,我们将讨论另一种 GAN 模型,称为条件 GAN。

条件 GAN

条件生成对抗网络(CGAN,Conditional Generative Adversarial Netsarxiv.org/abs/1411.1784)是 GAN 模型的扩展,其中生成器和判别器都接收一些额外的条件输入信息。这些信息可以是当前图像的类别或其他某些特征:

条件 GAN。Y代表生成器和判别器的条件输入。

例如,如果我们训练一个 GAN 来生成新的 MNIST 图像,我们可以添加一个额外的输入层,输入的是独热编码的图像标签。CGAN 的缺点是它们不是严格意义上的无监督学习,我们需要某种标签才能使其工作。然而,它们也有其他一些优点:

  • 通过使用更结构化的信息进行训练,模型可以学习更好的数据表示,并生成更好的样本。

  • 在常规 GAN 中,所有的图像信息都存储在潜在向量z中。这就带来了一个问题:由于  可能是复杂的,我们无法很好地控制生成图像的属性。例如,假设我们希望我们的 MNIST GAN 生成某个特定的数字,比如 7。我们必须尝试不同的潜在向量,直到达到想要的输出。但在 CGAN 中,我们只需将 7 的独热向量与一些随机的z结合,网络就会生成正确的数字。我们仍然可以尝试不同的z值,模型会生成不同版本的数字,也就是 7。简而言之,CGAN 为我们提供了一种控制(条件)生成器输出的方法。

由于条件输入,我们将修改最小最大目标函数,使其也包含条件 y

实现 CGAN

CGAN 的实现蓝图与实现 DCGAN部分中的 DCGAN 示例非常相似。也就是说,我们将实现 CGAN,以生成 MNIST 数据集的新图像。为了简化(和多样化),我们将使用全连接的生成器和判别器。为了避免重复,我们将仅展示与 DCGAN 相比修改过的部分代码。完整的示例可以在本书的 GitHub 仓库中找到。

第一个显著的区别是生成器的定义:

def build_generator(z_input: Input, label_input: Input):
    model = Sequential([
        Dense(128, input_dim=latent_dim),
        LeakyReLU(alpha=0.2), BatchNormalization(momentum=0.8),
        Dense(256),
        LeakyReLU(alpha=0.2), BatchNormalization(momentum=0.8),
        Dense(512),
        LeakyReLU(alpha=0.2), BatchNormalization(momentum=0.8),
        Dense(np.prod((28, 28, 1)), activation='tanh'),
        # reshape to MNIST image size
        Reshape((28, 28, 1))
    ])
    model.summary()

    # the latent input vector z
    label_embedding = Embedding(input_dim=10, 
    output_dim=latent_dim)(label_input)
    flat_embedding = Flatten()(label_embedding)

    # combine the noise and label by element-wise multiplication
    model_input = multiply([z_input, flat_embedding])
    image = model(model_input)

    return Model([z_input, label_input], image)

虽然这是一个全连接网络,但我们仍然遵循在深度卷积生成对抗网络(DCGAN)部分中定义的 GAN 网络设计准则。让我们来讨论如何将潜在向量z_input与条件标签label_input(一个值从 0 到 9 的整数)结合。我们可以看到,label_input通过Embedding层进行转换。该层执行两项操作:

  • 将整数值label_input转换为长度为input_dim的独热编码表示。

  • 将独热编码表示作为输入传递给大小为output_dim的全连接层。

嵌入层允许我们为每个可能的输入值获取独特的向量表示。在这种情况下,label_embedding的输出与潜在向量和z_input的大小相同。label_embedding与潜在向量z_input通过model_input变量进行逐元素相乘,后者作为网络其余部分的输入。

接下来,我们将重点讨论判别器,它也是一个全连接网络,并使用与生成器相同的嵌入机制。这次,嵌入输出的大小是np.prod((28, 28, 1)),等于 784(MNIST 图像的大小):

def build_discriminator():
    model = Sequential([
        Flatten(input_shape=(28, 28, 1)),
        Dense(256),
        LeakyReLU(alpha=0.2),
        Dense(128),
        LeakyReLU(alpha=0.2),
        Dense(1, activation='sigmoid'),
    ], name='discriminator')
    model.summary()

    image = Input(shape=(28, 28, 1))
    flat_img = Flatten()(image)

    label_input = Input(shape=(1,), dtype='int32')
    label_embedding = Embedding(input_dim=10, output_dim=np.prod(
    (28, 28, 1)))(label_input)
    flat_embedding = Flatten()(label_embedding)

    # combine the noise and label by element-wise multiplication
    model_input = multiply([flat_img, flat_embedding])

    validity = model(model_input)

    return Model([image, label_input], validity)

其余的示例代码与 DCGAN 的示例非常相似。唯一的其他区别是微不足道的——它们考虑了网络的多个输入(潜在向量和嵌入)。plot_generated_images函数有一个额外的参数,允许它为随机潜在向量和特定的条件标签(在本例中为数字)生成图像。以下是条件标签 3、8 和 9 的最新生成图像:

条件标签 3、8 和 9 的 CGAN

这就是我们关于 CGAN 的讨论。接下来的部分,我们将讨论另一种类型的 GAN 模型——Wasserstein GAN。

Wasserstein GAN

为了理解 Wasserstein GAN(WGAN,arxiv.org/abs/1701.07875),我们回顾一下,在训练 GANs部分,我们将生成器的概率分布表示为,真实数据的概率分布表示为。在训练 GAN 模型的过程中,我们更新生成器的权重,从而改变。GAN 框架的目标是将收敛到(这对于其他类型的生成模型,如 VAE 也是有效的),即生成的图像的概率分布应与真实图像相同,从而得到逼真的图像。WGAN 使用一种新的方法来度量两个分布之间的距离,称为 Wasserstein 距离(或地球搬运工距离EMD))。为了理解它,让我们从以下图表开始:

EMD 的一个示例。左:初始分布和目标分布;右:两种不同的方法将转换为

为了简化问题,我们假设是离散分布(对于连续分布也适用相同的规则)。我们可以通过沿* x 轴将列(a、b、c、d、e)向左或向右移动,将转换为。每次移动 1 个位置的成本是 1。例如,将列a从其初始位置 2 移动到位置 6 的成本是 4。前面图表的右侧显示了两种不同的做法。在第一种情况下,我们的总成本 = 成本(a:2->6) + 成本(e:6->3) + 成本(b:3->2) = 4 + 3 + 1 = 8。在第二种情况下,我们的总成本 = 成本(a:2->3) + 成本(b:2->1) = 1 + 1 = 2*。EMD 是将一个分布转换为另一个分布所需的最小总成本。因此,在这个例子中,我们有 EMD = 2。

我们现在对 EMD 有了基本的了解,但我们仍然不知道为什么在 GAN 模型中使用这个度量是必要的。WGAN 论文提供了一个详细但有些复杂的答案。在这一节中,我们将尝试解释它。首先,注意到生成器从一个低维的潜在向量开始, ,然后将其转换为一个高维的生成图像(例如,在 MNIST 的情况下是 784)。图像的输出大小也暗示了生成数据的高维分布, 。然而,它的内在维度(潜在向量, )要低得多。正因为如此, 将被排除在高维特征空间的大部分区域之外。另一方面, 是实际的高维的,因为它不是从潜在向量开始的;相反,它通过其完整的丰富性表示了真实数据。因此,很可能 在特征空间中没有交集。

为了理解这个问题为什么重要,我们注意到可以将生成器和判别器的代价函数(见 训练 GAN 部分)转化为 KL 散度和 Jensen–Shannon (JS, en.wikipedia.org/wiki/Jensen%E2%80%93Shannon_divergence) 的函数。这些度量的问题在于,当两个分布不交集时,它们提供零梯度。也就是说,无论两个分布之间的距离是小还是大,如果它们没有交集,度量将无法提供关于它们实际差异的任何信息。然而,正如我们刚才解释的,很可能这些分布是不会交集的。与此相反,Wasserstein 距离无论分布是否交集都能正常工作,这使得它成为 GAN 模型的更好选择。我们可以用以下图示直观地说明这个问题:

Wasserstein 距离相较于常规 GAN 判别器的优势。来源:arxiv.org/abs/1701.07875

在这里,我们可以看到两个不相交的高斯分布,(分别位于左侧和右侧)。常规 GAN 判别器的输出是 sigmoid 函数(范围为(0, 1)),它告诉我们输入是真还是假。在这种情况下,sigmoid 输出在非常狭窄的范围内(围绕 0 居中)有意义,并且在其他区域收敛到 0 或 1。这是我们在GAN 训练中的问题部分中所描述问题的表现,导致梯度消失,从而阻止了错误的反向传播到生成器。相比之下,WGAN 不会给出图像是真还是假的二元反馈,而是提供了两个分布之间的实际距离度量(也可以在前面的图中看到)。这个距离比二分类更有用,因为它能更好地指示如何更新生成器。为此,论文的作者将判别器重命名为critic

以下截图展示了论文中描述的 WGAN 算法:

在这里,f[w] 表示 critic,g[w] 是 critic 权重更新,g[θ] 是生成器权重更新。尽管 WGAN 背后的理论很复杂,但在实践中我们可以通过对常规 GAN 模型进行相对少量的修改来实现它:

  • 移除判别器的输出 sigmoid 激活函数。

  • 用 EMD 衍生的损失函数替换对数生成器/判别器损失函数。

  • 每次处理完一个小批量后,裁剪判别器的权重,使其绝对值小于一个常数,c。这一要求强制执行了所谓的Lipschitz约束,这使得我们可以使用 Wasserstein 距离(关于这一点,论文中会有更多解释)。不深入细节,我们仅提到,权重裁剪可能会导致一些不良行为。解决这些问题的一个成功方案是梯度惩罚(WGAN-GP,改进的 Wasserstein GAN 训练arxiv.org/abs/1704.00028),它没有出现相同的问题。

  • 论文的作者报告称,未使用动量的优化方法(如 SGD,RMSProp)比带有动量的方法效果更好。

实现 WGAN

现在我们对 Wasserstein GAN 的基本工作原理有了初步了解,让我们来实现它。我们将再次使用 DCGAN 的框架,并省略重复的代码段,以便我们可以专注于不同之处。build_generatorbuild_critic函数分别实例化生成器和判别器。为了简化起见,这两个网络只包含全连接层。所有隐藏层都使用 LeakyReLU 激活函数。根据论文的指导,生成器使用 Tanh 输出激活函数,而判别器则有一个单一的标量输出(没有 sigmoid 激活)。接下来,让我们实现train方法,因为它包含一些 WGAN 的特定内容。我们将从方法的声明和训练过程的初始化开始:

def train(generator, critic, combined, steps, batch_size, n_critic, clip_value):
    # Load the dataset
    (x_train, _), _ = mnist.load_data()

    # Rescale in [-1, 1] interval
    x_train = (x_train.astype(np.float32) - 127.5) / 127.5

    # We use FC networks, so we flatten the array
    x_train = x_train.reshape(x_train.shape[0], 28 * 28)

    # Discriminator ground truths
    real = np.ones((batch_size, 1))
    fake = -np.ones((batch_size, 1))

    latent_dim = generator.input_shape[1]

然后,我们将继续训练循环,按照我们在本节中前面描述的 WGAN 算法步骤进行。内循环每训练generator的每个训练步骤时,训练criticn_critic步骤。事实上,这是训练critic和在实现 DCGAN部分的训练函数中训练discriminator的主要区别,后者在每一步都交替进行生成器和判别器的训练。此外,每个小批次后,criticweights都会被裁剪。以下是实现(请注意缩进;这段代码是train函数的一部分):

    for step in range(steps):
        # Train the critic first for n_critic steps
        for _ in range(n_critic):
            # Select a random batch of images
            real_images = x_train[np.random.randint(0, x_train.shape[0], 
            batch_size)]

            # Sample noise as generator input
            noise = np.random.normal(0, 1, (batch_size, latent_dim))

            # Generate a batch of new images
            generated_images = generator.predict(noise)

            # Train the critic
            critic_real_loss = critic.train_on_batch(real_images, real)
            critic_fake_loss = critic.train_on_batch(generated_images,
            fake)
            critic_loss = 0.5 * np.add(critic_real_loss, critic_fake_loss)

            # Clip critic weights
            for l in critic.layers:
                weights = l.get_weights()
                weights = [np.clip(w, -clip_value, clip_value) for w in
                weights]
                l.set_weights(weights)

        # Train the generator
        # Note that we use the "valid" labels for the generated images
        # That's because we try to maximize the discriminator loss
        generator_loss = combined.train_on_batch(noise, real)

        # Display progress
        print("%d [Critic loss: %.4f%%] [Generator loss: %.4f%%]" %
              (step, critic_loss[0], generator_loss))

接下来,我们将实现 Wasserstein 损失本身的导数。这是一个 TensorFlow 操作,表示网络输出与标签(真实或伪造)乘积的均值:

def wasserstein_loss(y_true, y_pred):
    """The Wasserstein loss implementation"""
    return tensorflow.keras.backend.mean(y_true * y_pred)

现在,我们可以构建完整的 GAN 模型。这个步骤类似于其他 GAN 模型:

latent_dim = 100

# Build the generator
# Generator input z
z = Input(shape=(latent_dim,))

generator = build_generator(z)

generated_image = generator(z)

# we'll use RMSprop optimizer
optimizer = RMSprop(lr=0.00005)

# Build and compile the discriminator
critic = build_critic()
critic.compile(optimizer, wasserstein_loss,
               metrics=['accuracy'])

# The discriminator takes generated image as input and determines validity
real_or_fake = critic(generated_image)

# Only train the generator for the combined model
critic.trainable = False

# Stack the generator and discriminator in a combined model
# Trains the generator to deceive the discriminator
combined = Model(z, real_or_fake)
combined.compile(loss=wasserstein_loss, optimizer=optimizer)

最后,让我们开始训练和评估:

# train the GAN system
train(generator, critic, combined,
      steps=40000, batch_size=100, n_critic=5, clip_value=0.01)

# display some random generated images
plot_generated_images(generator)

一旦运行这个示例,WGAN 将在训练 40,000 个小批次后生成以下图像(这可能需要一些时间):

WGAN MNIST 生成器结果

这部分结束了我们对 WGAN 的讨论。在下一节中,我们将讨论如何使用 CycleGAN 实现图像到图像的转换。

使用 CycleGAN 进行图像到图像转换

在这一部分,我们将讨论循环一致性对抗网络CycleGAN使用循环一致性对抗网络进行无配对图像到图像的转换arxiv.org/abs/1703.10593)及其在图像到图像转换中的应用。引用论文中的话,图像到图像转换是一类视觉与图形学问题,其目标是利用对齐的图像对训练集,学习输入图像和输出图像之间的映射。例如,如果我们有同一图像的灰度图和 RGB 版本,我们可以训练一个机器学习算法来为灰度图上色,或反之亦然。

另一个例子是图像分割(第三章,物体检测与图像分割),其中输入图像被转换成与该图像相同的分割图。在后一种情况下,我们使用图像/分割图配对训练模型(U-Net,Mask R-CNN)。然而,许多任务可能无法获得配对的训练数据。CycleGAN 为我们提供了一种方法,能够在没有配对样本的情况下,将源领域X中的图像转换为目标领域Y中的图像。下图展示了一些配对和未配对图像的例子:

左:配对的训练样本,包含相应的源图像和目标图像;右:未配对的训练样本,其中源图像和目标图像不对应。来源: https://arxiv.org/abs/1703.10593

同一个团队的图像到图像的转换与条件对抗网络(即 Pix2Pix,arxiv.org/abs/1611.07004)论文也针对配对训练数据进行图像到图像的转换。

那么,CycleGAN 是如何做到这一点的呢?首先,算法假设,尽管在这两个集合中没有直接的配对,但这两个领域之间仍然存在某种关系。例如,这些图像可能是同一场景的不同角度拍摄的照片。CycleGAN 的目标是学习这种集合级别的关系,而不是学习不同配对之间的关系。从理论上讲,GAN 模型非常适合这个任务。我们可以训练一个生成器,将 映射为生成图像,,而判别器无法将其与目标图像区分开,。更具体地说,最佳的G应该将领域 X 转换为一个与领域Y具有相同分布的领域 。在实践中,论文的作者发现,尽管如此样的转换并不能保证一个特定的输入x和输出y在有意义的方式上配对——有无数种映射G,可以产生相同的分布。它们还发现,这个 GAN 模型会遭遇熟悉的模式崩溃问题。

CycleGAN 尝试通过所谓的循环一致性来解决这些问题。为了理解这是什么意思,假设我们将一句话从英语翻译成德语。如果我们从德语翻译回英语,并且回到了我们最初的原始句子,那么这次翻译就被认为是循环一致的。在数学上下文中,如果我们有一个翻译器,,还有另一个翻译器,,这两个翻译器应该是彼此的逆。

为了说明 CycleGAN 如何实现循环一致性,让我们从下图开始:

左:整体 CycleGAN 结构图;中:前向循环一致性损失;右:后向循环一致性损失。来源:arxiv.org/abs/1703.10593

该模型有两个生成器,![] 和 ![],以及两个相关的判别器,分别是 D[x]D[y](在前面的图中位于左侧)。首先让我们来看一下 G。它接收一张输入图像,,并生成 ,这些图像与域 Y 中的图像相似。D[y] 旨在区分真实图像,,和生成的 。这部分模型的功能类似于常规的 GAN,使用常规的极小极大 GAN 对抗损失:

第一个项表示原始图像,y,第二个项表示由 G 生成的图像。相同的公式对生成器 F 也有效。正如我们之前提到的,这个损失仅确保 将具有与 Y 中图像相同的分布,但并未创建一个有意义的 xy 配对。引用论文中的话:具有足够大容量的网络可以将同一组输入图像映射到目标域中任何随机排列的图像,任何学习到的映射都能引起一个与目标分布匹配的输出分布。因此,仅靠对抗损失无法保证学习到的函数能够将单个输入 x[i] 映射到期望的输出 y[i]。

论文的作者认为,学习到的映射函数应该是循环一致的(前面的图,中)。对于每张图像,,图像翻译的循环应该能够将 x 恢复到原始图像(这叫做前向循环一致性)。G 生成一张新图像,,它作为 F 的输入,F 进一步生成一张新图像,,其中 GF 还应满足后向循环一致性(前面的图,右):

这条新路径创建了额外的循环一致性损失项:

这衡量的是原始图像(即xy)与其生成对比图像之间的绝对差异,分别为。请注意,这些路径可以视为联合训练两个自编码器,。每个自编码器都有一个特殊的内部结构:它借助中间表示将图像映射到自身——即将图像转化为另一个领域。

完整的 CycleGAN 目标是循环一致性损失和FG的对抗损失的结合:

在这里,系数λ控制两个损失之间的相对重要性。CycleGAN 旨在解决以下最小最大目标:

实现 CycleGAN

这个例子包含了几个源文件,位于github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter05/cyclegan。除了 TensorFlow,代码还依赖于tensorflow_addonsimageio包。你可以使用pip包管理器安装它们。我们将为多个训练数据集实现 CycleGAN,这些数据集均由论文的作者提供。在运行示例之前,你需要通过download_dataset.sh可执行脚本下载相关数据集,该脚本使用数据集名称作为参数。可用数据集的列表已包含在文件中。下载完成后,你可以借助DataLoader类访问图像,该类位于data_loader.py模块中(我们不会在此包含其源代码)。简单来说,DataLoader类可以加载numpy数组形式的 mini-batches 和整个归一化图像数据集。我们还将省略常见的导入语句。

构建生成器和判别器

首先,我们将实现build_generator函数。到目前为止,我们看到的 GAN 模型都是从某种潜在向量开始的。但在这里,生成器的输入是来自其中一个领域的图像,输出是来自另一个领域的图像。按照论文中的指南,生成器采用 U-Net 风格的网络。它有一个下采样的编码器,一个上采样的解码器,以及在相应的编码器/解码器块之间的快捷连接。我们将从build_generator的定义开始:

def build_generator(img: Input) -> Model:

U-Net 的下采样编码器由多个卷积层和 LeakyReLU 激活组成,后跟 InstanceNormalization。批量归一化和实例归一化的区别在于,批量归一化计算它的参数是跨整个小批量的,而实例归一化是单独为小批量中的每个图像计算参数。为了更清晰,我们将实现一个单独的子程序,名为 downsampling2d,它定义了这样一层。我们将使用此函数在构建网络编码器时构建所需数量的层(请注意这里的缩进;downsampling2d 是在 build_generator 中定义的子程序):

    def downsampling2d(layer_input, filters: int):
        """Layers used in the encoder"""
        d = Conv2D(filters=filters,
                   kernel_size=4,
                   strides=2,
                   padding='same')(layer_input)
        d = LeakyReLU(alpha=0.2)(d)
        d = InstanceNormalization()(d)
        return d

接下来,让我们关注解码器,它不是通过转置卷积来实现的。相反,输入数据通过 UpSampling2D 操作进行上采样,这只是将每个输入像素复制为一个 2×2 的块。接着是一个常规卷积操作,以平滑这些块。这个平滑后的输出会与来自相应编码器块的捷径(或 skip_input)连接进行拼接。解码器由多个这样的上采样块组成。为了更清晰,我们将实现一个单独的子程序,名为 upsampling2d,它定义了这样一个块。我们将使用它来构建网络解码器所需的多个块(请注意这里的缩进;upsampling2d 是在 build_generator 中定义的子程序):

    def upsampling2d(layer_input, skip_input, filters: int):
        """
        Layers used in the decoder
        :param layer_input: input layer
        :param skip_input: another input from the corresponding encoder block
        :param filters: number of filters
        """
        u = UpSampling2D(size=2)(layer_input)
        u = Conv2D(filters=filters,
                   kernel_size=4,
                   strides=1,
                   padding='same',
                   activation='relu')(u)
        u = InstanceNormalization()(u)
        u = Concatenate()([u, skip_input])
        return u

接下来,我们将使用刚刚定义的子程序实现 U-Net 的完整定义(请注意这里的缩进;代码是 build_generator 的一部分):

    # Encoder
    gf = 32
    d1 = downsampling2d(img, gf)
    d2 = downsampling2d(d1, gf * 2)
    d3 = downsampling2d(d2, gf * 4)
    d4 = downsampling2d(d3, gf * 8)

    # Decoder
    # Note that we concatenate each upsampling2d block with
    # its corresponding downsampling2d block, as per U-Net
    u1 = upsampling2d(d4, d3, gf * 4)
    u2 = upsampling2d(u1, d2, gf * 2)
    u3 = upsampling2d(u2, d1, gf)

    u4 = UpSampling2D(size=2)(u3)
    output_img = Conv2D(3, kernel_size=4, strides=1, padding='same',
    activation='tanh')(u4)

    model = Model(img, output_img)

    model.summary()

    return model

接着,我们应该实现 build_discriminator 函数。我们在这里省略实现,因为它是一个相当直接的 CNN,类似于前面示例中展示的(你可以在本书的 GitHub 仓库中找到此实现)。唯一的不同是,它使用实例归一化,而不是批量归一化。

综合起来

此时,我们通常会实现 train 方法,但由于 CycleGAN 有更多的组件,我们将展示如何构建整个模型。首先,我们实例化 data_loader 对象,你可以指定训练集的名称(可以随意尝试不同的数据集)。所有图像将被调整为 img_res=(IMG_SIZE, IMG_SIZE),作为网络输入,其中 IMG_SIZE = 256(你也可以尝试 128 以加速训练过程):

# Input shape
img_shape = (IMG_SIZE, IMG_SIZE, 3)

# Configure data loader
data_loader = DataLoader(dataset_name='facades',
                         img_res=(IMG_SIZE, IMG_SIZE))

然后,我们将定义优化器和损失权重:

lambda_cycle = 10.0  # Cycle-consistency loss
lambda_id = 0.1 * lambda_cycle  # Identity loss

optimizer = Adam(0.0002, 0.5)

接下来,我们将创建两个生成器,g_XYg_YX,以及它们各自的判别器,d_Yd_X。我们还将创建 combined 模型,以同时训练这两个生成器。然后,我们将创建复合损失函数,其中包含一个额外的身份映射项。你可以在相关论文中了解更多内容,但简而言之,它有助于在将图像从绘画领域转换到照片领域时,保持输入和输出之间的颜色组合:

# Build and compile the discriminators
d_X = build_discriminator(Input(shape=img_shape))
d_Y = build_discriminator(Input(shape=img_shape))
d_X.compile(loss='mse', optimizer=optimizer, metrics=['accuracy'])
d_Y.compile(loss='mse', optimizer=optimizer, metrics=['accuracy'])

# Build the generators
img_X = Input(shape=img_shape)
g_XY = build_generator(img_X)

img_Y = Input(shape=img_shape)
g_YX = build_generator(img_Y)

# Translate images to the other domain
fake_Y = g_XY(img_X)
fake_X = g_YX(img_Y)

# Translate images back to original domain
reconstr_X = g_YX(fake_Y)
reconstr_Y = g_XY(fake_X)

# Identity mapping of images
img_X_id = g_YX(img_X)
img_Y_id = g_XY(img_Y)

# For the combined model we will only train the generators
d_X.trainable = False
d_Y.trainable = False

# Discriminators determines validity of translated images
valid_X = d_X(fake_X)
valid_Y = d_Y(fake_Y)

# Combined model trains both generators to fool the two discriminators
combined = Model(inputs=[img_X, img_Y],
                 outputs=[valid_X, valid_Y,
                          reconstr_X, reconstr_Y,
                          img_X_id, img_Y_id])

接下来,让我们配置用于训练的combined模型:

combined.compile(loss=['mse', 'mse',
                       'mae', 'mae',
                       'mae', 'mae'],
                 loss_weights=[1, 1,
                               lambda_cycle, lambda_cycle,
                               lambda_id, lambda_id],
                 optimizer=optimizer)

一旦模型准备好,我们便通过train函数启动训练过程。根据论文的指导方针,我们将使用大小为 1 的小批量:

train(epochs=200, batch_size=1, data_loader=data_loader,
      g_XY=g_XY,
      g_YX=g_YX,
      d_X=d_X,
      d_Y=d_Y,
      combined=combined,
      sample_interval=200)

最后,我们将实现train函数。它与之前的 GAN 模型有些相似,但也考虑到了两对生成器和判别器:

def train(epochs: int, data_loader: DataLoader,
          g_XY: Model, g_YX: Model, d_X: Model, d_Y: Model, 
          combined:Model, batch_size=1, sample_interval=50):
    start_time = datetime.datetime.now()

    # Calculate output shape of D (PatchGAN)
    patch = int(IMG_SIZE / 2 ** 4)
    disc_patch = (patch, patch, 1)

    # GAN loss ground truths
    valid = np.ones((batch_size,) + disc_patch)
    fake = np.zeros((batch_size,) + disc_patch)

    for epoch in range(epochs):
        for batch_i, (imgs_X, imgs_Y) in
        enumerate(data_loader.load_batch(batch_size)):
            # Train the discriminators

            # Translate images to opposite domain
            fake_Y = g_XY.predict(imgs_X)
            fake_X = g_YX.predict(imgs_Y)

            # Train the discriminators (original images = real /
            translated = Fake)
            dX_loss_real = d_X.train_on_batch(imgs_X, valid)
            dX_loss_fake = d_X.train_on_batch(fake_X, fake)
            dX_loss = 0.5 * np.add(dX_loss_real, dX_loss_fake)

            dY_loss_real = d_Y.train_on_batch(imgs_Y, valid)
            dY_loss_fake = d_Y.train_on_batch(fake_Y, fake)
            dY_loss = 0.5 * np.add(dY_loss_real, dY_loss_fake)

            # Total discriminator loss
            d_loss = 0.5 * np.add(dX_loss, dY_loss)

            # Train the generators
            g_loss = combined.train_on_batch([imgs_X, imgs_Y],
                                             [valid, valid,
                                              imgs_X, imgs_Y,
                                              imgs_X, imgs_Y])

            elapsed_time = datetime.datetime.now() - start_time

            # Plot the progress
            print("[Epoch %d/%d] [Batch %d/%d] [D loss: %f, acc: %3d%%]
            [G loss: %05f, adv: %05f, recon: %05f, id: %05f] time: %s " \ 
            % (epoch, epochs, batch_i, data_loader.n_batches, d_loss[0], 
            100 * d_loss[1], g_loss[0], np.mean(g_loss[1:3]),
            np.mean(g_loss[3:5]), np.mean(g_loss[5:6]), elapsed_time))

            # If at save interval => save generated image samples
            if batch_i % sample_interval == 0:
                sample_images(epoch, batch_i, g_XY, g_YX, data_loader)

训练可能需要一些时间才能完成,但该过程将在每个sample_interval批次后生成图像。以下是通过机器感知中心外立面数据库生成的图像示例(cmp.felk.cvut.cz/~tylecr1/facade/)。该数据库包含建筑物的外立面,其中每个像素都标记为与外立面相关的多个类别之一,如窗户、门、阳台等:

CycleGAN 图像到图像的转换示例

这部分内容结束了我们对 GAN 的讨论。接下来,我们将重点介绍另一种生成模型——艺术风格迁移。

引入艺术风格迁移

在这一最终部分,我们将讨论艺术风格迁移。类似于 CycleGAN 的一种应用,它允许我们使用一张图像的风格(或纹理)来再现另一张图像的语义内容。尽管它可以通过不同的算法来实现,但最流行的方法是在 2015 年通过论文《艺术风格的神经算法》中提出的(arxiv.org/abs/1508.06576)。它也被称为神经风格迁移,并且使用了(你猜对了!)卷积神经网络(CNN)。基本算法在过去几年中得到了改进和调整,但在本节中,我们将探讨其原始形式,因为这将为理解最新版本奠定良好的基础。

该算法接受两张图像作为输入:

  • 我们想要重新绘制的内容图像(C

  • 我们将用来重新绘制C的风格图像(I),其风格(纹理)来自此图像

该算法的结果是一张新图像:G = C + S。以下是神经风格迁移的示例:

神经风格迁移的示例

为了理解神经风格迁移是如何工作的,让我们回顾一下 CNN 如何学习其特征的层次化表示。我们知道,初始的卷积层学习基本特征,如边缘和线条。而较深的层则学习更复杂的特征,如面孔、汽车和树木。了解这一点后,让我们来看一下算法本身:

  1. 就像许多其他任务一样(例如,第三章,物体检测与图像分割),该算法从一个预训练的 VGG 网络开始。

  2. 将内容图像C输入网络。提取并存储网络中间层一个或多个隐藏卷积层的输出激活(或特征图或切片)。我们用A[c]^l表示这些激活,其中l是层的索引。我们关注中间层,因为它们编码的特征抽象层次最适合这个任务。

  3. 对风格图像S做同样的处理。这一次,用A[s]^l表示风格图像在l层的激活。我们选择的内容和风格层不一定相同。

  4. 生成一张单一的随机图像(白噪声),G。这张随机图像将逐渐转变为算法的最终结果。我们将对其进行多次迭代:

    1. G传播通过网络。这是我们在整个过程中唯一使用的图像。像之前一样,我们将存储所有l层的激活(这里,l是我们用于内容和风格图像的所有层的组合)。我们用A[g]^l表示这些激活。

    2. 计算随机噪声激活A[g]l*与*A[c]lA[s]^l之间的差异。这些将是我们损失函数的两个组成部分:

      • ,称为内容损失:这只是所有l层两个激活之间逐元素差异的均方误差(MSE)。

      • ,称为风格损失:这与内容损失相似,但我们不会直接比较原始激活,而是比较它们的格拉姆矩阵(我们不会深入讨论这一点)。

    1. 使用内容和风格损失计算总损失,,这只是两者的加权和。α 和 β 系数决定了哪个组件将占据更大权重。

    2. 反向传播梯度至网络的起始位置,并更新生成的图像,。通过这种方式,我们使G更接近内容和风格图像,因为损失函数是两者的组合。

该算法使我们能够利用卷积神经网络(CNN)强大的表达能力进行艺术风格迁移。它通过一种新颖的损失函数和智能的反向传播方法实现这一点。

如果你有兴趣实现神经风格迁移,请查看官方的 PyTorch 教程:pytorch.org/tutorials/advanced/neural_style_tutorial.html。或者,访问www.tensorflow.org/beta/tutorials/generative/style_transfer查看 TF 2.0 实现。

这个算法的一个缺点是相对较慢。通常,我们需要重复几百次伪训练过程才能得到一个视觉上令人满意的结果。幸运的是,论文《实时风格迁移与超分辨率的感知损失》(arxiv.org/abs/1603.08155) 基于原始算法提出了一个解决方案,使其速度提高了三个数量级。

摘要

在本章中,我们讨论了如何使用生成模型创建新图像,这是目前深度学习领域最令人兴奋的方向之一。我们了解了变分自编码器(VAE)的理论基础,然后实现了一个简单的 VAE 来生成新的 MNIST 数字。接着,我们介绍了 GAN 框架,并讨论并实现了多种类型的 GAN,包括 DCGAN、CGAN、WGAN 和 CycleGAN。最后,我们提到了神经风格迁移算法。本章是专门讲解计算机视觉的四章系列中的最后一章,希望你喜欢这些内容。

在接下来的几章中,我们将讨论自然语言处理和递归网络。

第三部分:自然语言和序列处理

在本节中,我们将讨论循环神经网络、自然语言和序列处理。我们将讲解自然语言处理中的最先进技术,如序列模型和注意力模型,以及谷歌的 BERT。

本节包含以下章节:

  • 第六章,语言建模

  • 第七章,理解循环神经网络

  • 第八章,序列到序列模型与注意力

第六章:语言建模

本章是几章中的第一章,我们将讨论不同的神经网络算法在自然语言处理NLP)中的应用。NLP 教会计算机处理和分析自然语言数据,以执行诸如机器翻译、情感分析、自然语言生成等任务。但要成功解决这些复杂的问题,我们必须以计算机能够理解的方式表示自然语言,而这并非一项简单的任务。

为了理解原因,让我们回到图像识别。神经网络的输入相当直观——一个二维张量,包含预处理后的像素强度,保留了图像的空间特征。我们以 28 x 28 的 MNIST 图像为例,它包含 784 个像素。关于图像中数字的所有信息都包含在这些像素中,我们不需要任何外部信息来分类图像。我们还可以安全地假设每个像素(也许除了靠近图像边缘的像素)承载着相同的信息量。因此,我们将所有像素输入到网络中,让它进行处理,并让结果自己证明。

现在,让我们关注文本数据。与图像不同,我们有 1D(而非 2D)数据——一长串单词序列。一般来说,单倍行距的 A4 纸上大约有 500 个单词。为了向网络(或任何机器学习算法)输入与单个 MNIST 图像等价的信息,我们需要 1.5 页的文本。文本结构有多个层级;从字符开始,然后是单词、句子和段落,这些都可以容纳在 1.5 页文本中。图像的所有像素与一个数字相关;然而,我们无法确定所有单词是否与同一主题相关。为了避免这种复杂性,NLP 算法通常处理较短的序列。尽管一些算法使用递归神经网络RNNs),它们考虑了所有先前的输入,但实际上,它们仍然局限于相对较短的前一个单词窗口。因此,NLP 算法必须在较少的输入信息下做更多的工作(表现得更好)。

为了帮助我们解决这个问题,我们将使用一种特殊类型的向量词表示(语言模型)。我们将讨论的语言模型利用一个词的上下文(即周围的词)来创建与该词相关的独特嵌入向量。与例如单热编码相比,这些向量包含了更多关于该词的信息。它们为各种 NLP 任务提供了基础。

本章将涵盖以下主题:

  • 理解n-grams

  • 引入神经语言模型:

    • 神经概率语言模型

    • Word2Vec 和 fastText

    • 用于词表示的全局向量

  • 实现语言模型

理解 n-grams

基于单词的语言模型定义了一个关于单词序列的概率分布。给定一个长度为m的单词序列(例如,一个句子),它为整个单词序列分配一个概率Pw1, ... , w[m])。我们可以按如下方式使用这些概率:

  • 用于估计不同短语在自然语言处理应用中的可能性。

  • 作为一种生成模型来创建新的文本,基于单词的语言模型可以计算给定单词跟随一系列单词的概率。

对于一个长序列,如w[1], ..., w[m],推断其概率通常是不可行的。我们可以通过联合概率的链式法则计算联合概率Pw[1], ... , w[m])(第一章,神经网络的基本原理):

给定前面的单词,后面的单词的概率尤其难以从数据中估计。这就是为什么这个联合概率通常通过独立性假设来近似,即第i个单词仅依赖于前面n-1个单词。我们只会对n个连续单词的联合概率进行建模,称为n-gram。例如,在短语the quick brown fox中,我们有以下n-gram:

  • 1-gramThequickbrown,和fox(也称为一元组)。

  • 2-gramThe quickquick brown,和brown fox(也称为二元组)。

  • 3-gramThe quick brownquick brown fox(也称为三元组)。

  • 4-gramThe quick brown fox

联合分布的推断通过n-gram 模型来近似,这些模型将联合分布拆分为多个独立部分。

n-gram 一词可以指其他类型的长度为n的序列,例如n个字符。

如果我们有一个大规模的文本语料库,我们可以找到所有的n-gram,直到某个n(通常是 2 到 4),并计算每个n-gram 在语料库中的出现次数。通过这些计数,我们可以估计给定前面n-1个单词时每个n-gram 的最后一个单词的概率:

  • 1-gram:![]

  • 2-gram:![]

  • N-gram:![]

假设第i个单词仅依赖于前面n-1个单词的独立性假设现在可以用于近似联合分布。

例如,对于一元组,我们可以使用以下公式来近似联合分布:

对于三元组,我们可以使用以下公式来近似联合分布:

我们可以看到,基于词汇大小,n-gram 的数量会随着n的增加呈指数增长。例如,如果一个小词汇表包含 100 个单词,那么可能的 5-gram 数量将是100⁵ = 10,000,000,000种不同的 5-gram。相比之下,莎士比亚的所有作品包含大约 30,000 个不同的单词,这说明使用大型nn-gram 是不可行的。我们不仅面临着存储所有概率的问题,而且还需要非常大的文本语料库来为更大值的n创建合理的n-gram 概率估计。

这个问题被称为维度灾难。当可能的输入变量(单词)数量增加时,这些输入值的不同组合数量会呈指数增长。维度灾难出现在学习算法需要至少一个示例来表示每种相关的值组合时,这正是n-gram 模型的情况。我们的n越大,就能越好地近似原始分布,同时我们需要更多的数据来对n-gram 概率进行良好的估计。

现在我们已经熟悉了n-gram 模型和维度灾难,让我们来讨论如何借助神经语言模型来解决这个问题。

引入神经语言模型

克服维度灾难的一种方法是通过学习单词的低维分布式表示(A Neural Probabilistic Language Modelwww.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf)。这种分布式表示是通过学习一个嵌入函数来创建的,该函数将单词空间转化为一个低维的单词嵌入空间,具体如下:

单词 -> 独热编码 -> 单词嵌入向量

来自大小为V的词汇表的单词被转化为大小为V的独热编码向量(每个单词都被唯一编码)。然后,嵌入函数将这个V维空间转化为一个大小为D的分布式表示(此处,D=4)。

这个思想是,嵌入函数学习关于单词的语义信息。它将词汇表中的每个单词与一个连续值的向量表示关联,也就是单词嵌入。每个单词对应于嵌入空间中的一个点,不同的维度对应于这些单词的语法或语义属性。

目标是确保在嵌入空间中相近的词语具有相似的意义。通过这种方式,语言模型可以利用一些词语在语义上的相似性。例如,它可能会学到foxcat在语义上是相关的,并且the quick brown foxthe quick brown cat都是有效的短语。然后,一组词语可以被一组嵌入向量替换,这些嵌入向量捕捉到这些词语的特征。我们可以将这个序列作为各种自然语言处理任务的基础。例如,一个试图分类文章情感的分类器,可能会使用先前学到的词嵌入,而不是独热编码向量。通过这种方式,词语的语义信息变得易于被情感分类器使用。

词嵌入是解决自然语言处理任务时的核心范式之一。我们可以使用它们来提高在标注数据稀缺的任务中的表现。接下来,我们将讨论 2001 年引入的第一个神经语言模型(这个例子表明,深度学习中的许多概念并不新颖)。

我们通常用粗体非斜体的小写字母来表示向量,如w。但在神经语言模型中,约定使用斜体小写字母,如w。在本章中,我们将遵循这一约定。

在下一节中,我们将介绍神经概率语言模型NPLM)。

神经概率语言模型

可以通过一个前馈全连接网络来学习语言模型,并隐式地学习嵌入函数。给定一个由n-1个词语(w[t-n+1], ..., w[t-1])组成的序列,它会尝试输出下一个词语w[t]的概率分布(以下图基于www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf):

一个神经网络语言模型,根据给定的词语w[t-n+1] ... w[t-1],输出词语w[t]的概率分布。C是嵌入矩阵。

网络层扮演着不同的角色,具体如下:

  1. 嵌入层将词语w[i]的独热表示转换为词语的嵌入向量,通过与嵌入矩阵C相乘来实现。这个计算可以通过表查找高效实现。嵌入矩阵C在所有词语间共享,因此所有词语使用相同的嵌入函数。C是一个V * D矩阵,其中V是词汇表的大小,D是嵌入的维度。换句话说,矩阵C表示隐藏层tanh的网络权重。

  2. 生成的嵌入被连接起来并作为输入传递给隐藏层,隐藏层使用tanh激活函数。因此,隐藏层的输出由![]函数表示,其中H表示嵌入到隐藏层的权重,d表示隐藏偏置。

  3. 最后,我们有一个带有权重的输出层,U,偏置,b,以及 softmax 激活函数,将隐藏层映射到词空间概率分布:![]*。

该模型同时学习了词汇表中所有词的嵌入(嵌入层)以及词序列的概率函数模型(网络输出)。它能够将此概率函数推广到训练过程中未出现的词序列。测试集中的特定词组合可能未在训练集中出现,但具有相似嵌入特征的序列在训练过程中更可能出现。由于我们可以基于词的位置(这些位置已经存在于文本中)来构建训练数据和标签,因此训练该模型是一项无监督学习任务。接下来,我们将讨论 word2vec 语言模型,它于 2013 年推出,并在神经网络的 NLP 领域引发了广泛的关注。

Word2Vec

许多研究致力于创建更好的词嵌入模型,特别是通过省略学习词序列的概率函数。其中一种最流行的方法是使用 word2vec(papers.nips.cc/paper/5021-distributed-representations-of-words-and-phrases-and-their-compositionality.pdfarxiv.org/abs/1301.3781arxiv.org/abs/1310.4546)。类似于 NPLM,word2vec 基于词的上下文(周围的词)创建嵌入向量。它有两种形式:连续词袋模型CBOW)和Skip-gram。我们将从 CBOW 开始,然后讨论 Skip-gram。

CBOW

CBOW 根据上下文(周围的词)预测最可能的词。例如,给定序列The quick _____ fox jumps,模型将预测brown。上下文是指在关注的词前后n个词(与 NPLM 不同,NPLM 仅参与前面的词)。以下截图展示了上下文窗口在文本中滑动的过程:

一个带有n = 2的 word2vec 滑动上下文窗口。相同类型的上下文窗口适用于 CBOW 和 Skip-gram。

CBOW 将上下文中的所有单词赋予相同的权重,并且不考虑它们的顺序(因此名字中有bag)。它与 NPLM 有些相似,但由于它只学习词嵌入向量,因此我们将通过以下简单的神经网络来训练模型:

一个 CBOW 模型网络

其工作原理如下:

  • 网络有输入层、隐藏层和输出层。

  • 输入是通过一热编码表示的词。这些词的一热编码向量大小等于词汇表的大小,V

  • 嵌入向量由网络的输入到隐藏层权重W[V×D]表示。它们是V × D-形状的矩阵,其中D是嵌入向量的长度(即隐藏层单元的数量)。和 NPLM 一样,我们可以将权重看作查找表,其中每一行表示一个词的嵌入向量。因为每个输入词是通过一热编码表示的,它总是激活权重的某一行。也就是说,对于每个输入样本(词),只有该词的嵌入向量会参与。

  • 所有上下文词的嵌入向量被平均以生成隐藏层网络的输出(没有激活函数)。

  • 隐藏层的激活值作为输入传递到输出的 softmax 层,大小为V(其权重向量为W^'[D×V]),该层预测在输入词的上下文(邻近)中最有可能找到的词。激活值最高的索引表示一热编码的相关词。

我们将通过梯度下降和反向传播来训练网络。训练集由(上下文和标签)的一对一热编码词组成,这些词在文本中彼此靠近。例如,如果文本的一部分是序列[the, quick, brown, fox, jumps],并且n = 2,则训练元组将包括([quick, brown], the)([the, brown, fox], quick)([the, quick, fox jumps], brown)等。由于我们只关心词嵌入W[V×D],当训练完成时,我们会丢弃网络中其余的权重W^'[V×D]

CBOW 将告诉我们在给定上下文中最有可能出现哪个词。这对于稀有词来说可能是一个问题。例如,给定上下文The weather today is really____,模型会预测词beautiful,而不是fabulous(嘿,这只是一个例子)。与 Skip-gram 相比,CBOW 的训练速度快几倍,并且在频繁出现的单词上略微提高了准确性。

Skip-gram

给定一个输入词,Skip-gram 模型可以预测其上下文(与 CBOW 相反)。例如,词brown将预测词The quick fox jumps。与 CBOW 不同,输入是一个单一的 one-hot 词。但我们如何表示输出中的上下文词呢?Skip-gram 并不是尝试同时预测整个上下文(所有周围词),而是将上下文转化为多个训练对,如(fox, the)(fox, quick)(fox, brown)(fox, jumps)。再次地,我们可以通过一个简单的单层网络来训练模型:

一个 Skip-gram 模型网络

与 CBOW 一样,输出是一个 softmax,它表示最可能的上下文词的 one-hot 编码。输入到隐藏层的权重W[V×D]表示词嵌入查找表,隐藏到输出的权重W^'[D×V]仅在训练过程中相关。隐藏层没有激活函数(也就是说,它使用线性激活)。

我们将通过反向传播训练模型(这没有惊讶)。给定一个单词序列w[1], ..., w[M],Skip-gram 模型的目标是最大化平均对数概率,其中n是窗口大小:

模型定义了概率![],如下所示:

在这个例子中,w[I]w[O]分别是输入和输出词,v[w]v'[w]*分别是输入和输出权重**W***[V×D]*和**W'*[D×V]中的对应词向量(我们保持了论文中的原始符号)。由于网络没有隐藏激活函数,它对于一个输入/输出词对的输出值仅仅是输入词向量![]与输出词向量![]的乘积(因此需要进行转置操作)。

word2vec 论文的作者指出,词向量无法表示那些不是个别单词组合的习惯用语。例如,New York Times是一个报纸,而不仅仅是NewYorkTimes意思的自然组合。为了克服这个问题,可以将模型扩展为包括完整的短语。然而,这会显著增加词汇表的大小。而且,正如我们从前面的公式中可以看到的,softmax 分母需要计算词汇表中所有词的输出向量。此外,W^'[D×V]矩阵的每个权重在每次训练步骤中都会更新,这会减慢训练过程。

为了解决这个问题,我们可以用所谓的负采样NEG)来替代 softmax。对于每个训练样本,我们将使用正向训练对(例如,(fox, brown)),以及k个额外的负向对(例如,(fox, puzzle)),其中k通常在[5,20]的范围内。与其预测与输入词最匹配的词(softmax),我们干脆预测当前的词对是否真实。实际上,我们将多项式分类问题(分类为多类之一)转化为二元逻辑回归(或二元分类)问题。通过学习正向和负向词对的区别,分类器最终将像多项式分类一样学习词向量。在 word2vec 中,负向词对的词是从一个特殊的分布中抽取的,这个分布倾向于抽取不太常见的词,而不是常见的词。

一些最常见的词汇携带的信息量比稀有词汇要少。例如,定冠词和不定冠词aanthe就是这样的词。模型通过观察词对Londoncity比观察thecity更能获益,因为几乎所有的词都与the频繁共现。相反的情况也是如此——频繁词汇的词向量在经过大量示例的训练后变化不大。为了应对稀有词和常见词之间的不平衡,论文的作者提出了一种下采样方法,其中训练集中的每个词w[i],都有一定的概率被丢弃,该概率通过启发式公式计算,其中f(w[i])是词w[i]的频率,t是阈值(通常约为 10^(-5)):

它积极地对出现频率大于t的词进行下采样,同时保持词频的排名。

总结来说,我们可以说,通常情况下,Skip-gram 在处理稀有词汇时比 CBOW 表现更好,但它的训练时间更长。

fastText

fastText (fasttext.cc/) 是一个由Facebook AI ResearchFAIR)团队创建的用于学习词嵌入和文本分类的库。Word2Vec 将语料库中的每个词视为一个原子实体,并为每个词生成一个向量,但这种方法忽略了词的内部结构。与此相对,fastText 将每个词w分解为字符n-gram 的袋子。例如,如果n = 3,我们可以将词there分解为字符 3-gram,并为整个词生成特殊序列

<th, the, her, ere, re>

注意使用特殊字符<>来标示词的开始和结束。这是为了避免来自不同词的n-grams 发生错配。例如,词her将被表示为,它不会与词there中的n-gramher混淆。fastText 的作者建议3 ≤ n ≤ 6

回顾我们在Skip-gram部分介绍的 softmax 公式。我们通过用通用评分函数s替代 word2vec 网络中的向量乘法操作来推广它,其中w[t]是输入词,w[c]是上下文词:

在 fastText 的情况下,我们通过将词的n-grams 的向量表示相加来表示一个词。我们用G[w] = {1 ... G}表示出现在词w中的n-grams 集合,用v[g]表示n-gram 的向量表示,用v'[c]**表示上下文词的潜在向量。那么,fastText 定义的评分函数如下:

实际上,我们使用 Skip-gram 类型的词对训练 fastText 模型,但输入词通过n-grams 的方式表示。

使用字符n-grams 相比传统的 word2vec 模型有几个优点:

  • 如果一个词与模型中其他熟悉的词共享n-grams,它可以分类未知或拼写错误的词。

  • 它可以为稀有词生成更好的词向量。即使一个词很稀有,它的字符n-grams 仍然与其他词共享,因此词向量仍然可以很好。

现在我们已经熟悉了 word2vec,接下来我们将介绍全局词向量表示语言模型,这个模型改进了 word2vec 的一些不足之处。

全局词向量表示模型

word2vec 的一个缺点是它只使用词的局部上下文,而没有考虑它们的全局共现。这样,模型丧失了一个现成的、宝贵的信息来源。如其名字所示,全局词向量表示GloVe)模型试图解决这个问题(nlp.stanford.edu/pubs/glove.pdf)。

算法从全局的词-词共现矩阵X开始。一个单元格,X[ij],表示词j在词i的上下文中出现的频率。以下表格展示了序列I like DL. I like NLP. I enjoy cycling,窗口大小n=2 的共现矩阵:

序列 I like DL. I like NLP. I enjoy cycling 的共现矩阵

我们设定任何词在词i上下文中出现的次数为![],词j在词i上下文中出现的概率为![]。为了更好地理解这如何帮助我们,我们将使用一个示例,展示来自 60 亿词汇的语料库中目标词蒸汽与选定上下文词的共现概率:

目标词冰和蒸汽与选定上下文词的共现概率(来自 60 亿词汇的语料库):来源:nlp.stanford.edu/pubs/glove.pdf

最底行显示的是概率比率。固体(第一列)与相关,但与蒸汽的相关性较低,因此它们的概率比率较大。相反,气体蒸汽的相关性高于,因此它们的概率比率非常小。时尚这两个词与这两个目标词的相关性相等,因此它们的概率比率接近 1。与原始概率相比,这一比率在区分相关词((固体气体)和不相关词((时尚)时表现更好。此外,它还更能区分两个相关词之间的差异。

根据前面的论述,GloVe 的作者建议从共现概率的比率开始进行词向量学习,而不是从概率本身开始。以此为起点,并且记住比率![]依赖于三个词——ijk——我们可以将 GloVe 模型的最一般形式定义如下,其中![]是词向量,![]是一个特殊的上下文向量,我们稍后会讨论(![]是D*维实数向量空间):

换句话说,F是这样一个函数,当用这三个特定的向量计算时(我们假设我们已经知道它们),将输出概率比率。此外,F应该编码概率比率的信息,因为我们已经识别出它的重要性。由于向量空间本质上是线性的,一种编码这些信息的方法是通过目标词的向量差异。因此,函数变成了如下形式:

接下来,我们注意到函数的参数是向量,但概率比率是标量。为了解决这个问题,我们可以计算参数的点积:

然后,我们可以观察到,单词与其上下文单词之间的区分是任意的,我们可以自由地交换这两者的角色。因此,我们应该有![],但前面的方程并不满足这一条件。长话短说(论文中有更详细的解释),为了满足这个条件,我们需要引入另一个限制,形式如下的方程,其中,![] 和 ![] 是偏差标量值:

这个公式的一个问题是,log(0)是未定义的,但大多数的 X[ik] 条目将是 0。此外,它对所有共现赋予相同的权重,但稀有的共现通常噪声较多,所携带的信息量小于频繁的共现。为了解决这些问题,作者提出了一种最小二乘回归模型,并为每个共现引入了加权函数 f(X[ij])。该模型具有以下成本函数:

最后,加权函数 f 应满足几个性质。首先,f(0) = 0。然后,f(x) 应该是单调不减的,以避免稀有共现被过度加权。最后,对于较大的 x 值,f(x) 应该相对较小,以避免频繁共现被过度加权。根据这些性质和他们的实验,作者提出了以下函数:

以下图表展示了 f(x)

加权函数 f(X[ij]),其截止值为 x[max]= 100,且 α = 3/4。作者的实验表明,这些参数效果最佳;来源:nlp.stanford.edu/pubs/glove.pdf

该模型生成两组单词向量:W。当 X 对称时,W 是等价的,仅由于它们的随机初始化有所不同。但作者指出,训练多个网络并对它们的结果进行平均通常有助于防止过拟合。为了模拟这种行为,他们选择使用总和![]作为最终的单词向量,并观察到性能略有提高。

这就是我们关于神经语言模型的讨论。在下一节中,我们将看到如何训练和可视化一个 word2vec 模型。

实现语言模型

在这一节中,我们将实现一个简短的流程,用于预处理文本序列并使用处理后的数据训练 word2vec 模型。我们还将实现另一个示例来可视化嵌入向量,并检查它们的一些有趣特性。

本节代码需要以下 Python 包:

  • Gensim(版本 3.80,radimrehurek.com/gensim/)是一个开源 Python 库,专注于无监督主题建模和自然语言处理。它支持我们迄今为止讨论的所有三种模型(word2vec、GloVe 和 fastText)。

  • 自然语言工具包NLTKwww.nltk.org/,ver 3.4.4)是一个用于符号和统计自然语言处理的 Python 库和程序套件。

  • Scikit-learn(ver 0.19.1,scikit-learn.org/)是一个开源 Python 机器学习库,包含多种分类、回归和聚类算法。更具体地说,我们将使用 t-分布随机邻居嵌入t-SNElvdmaaten.github.io/tsne/)来可视化高维嵌入向量(稍后会详细介绍)。

通过这一介绍,我们继续进行语言模型的训练。

训练嵌入模型

在第一个示例中,我们将对列夫·托尔斯泰的经典小说 战争与和平 进行 word2vec 模型训练。该小说作为常规文本文件存储在代码库中。让我们开始吧:

  1. 按照惯例,我们将进行导入:
import logging
import pprint  # beautify prints

import gensim
import nltk
  1. 然后,我们将设置日志级别为 INFO,以便跟踪训练进度:
logging.basicConfig(level=logging.INFO)
  1. 接下来,我们将实现文本标记化流水线。标记化是指将文本序列分解为若干部分(或 tokens),如单词、关键词、短语、符号和其他元素。tokens 可以是单个单词、短语甚至整个句子。我们将实现两级标记化;首先将文本拆分为句子,然后再将每个句子拆分为单独的单词:
class TokenizedSentences:
    """Split text to sentences and tokenize them"""

    def __init__(self, filename: str):
        self.filename = filename

    def __iter__(self):
        with open(self.filename) as f:
            corpus = f.read()

        raw_sentences = nltk.tokenize.sent_tokenize(corpus)
        for sentence in raw_sentences:
            if len(sentence) > 0:
                yield gensim.utils.simple_preprocess(sentence, min_len=2, max_len=15)

TokenizedSentences 迭代器以文本文件名作为参数,文件中包含小说的内容。以下是它的工作原理:

  1. 迭代从读取文件的完整内容开始,并将其存储在 corpus 变量中。

  2. 原始文本通过 NLTK 的 nltk.tokenize.sent_tokenize(corpus) 函数被拆分为句子列表(raw_sentences 变量)。例如,对于输入列表 'I like DL. I like NLP. I enjoy cycling.',它将返回 ['I like DL.', 'I like NLP.', 'I enjoy cycling.']

  3. 接下来,每个 sentence 使用 gensim.utils.simple_preprocess(sentence, min_len=2, max_len=15) 函数进行预处理。该函数将文档转换为小写的 token 列表,并忽略过短或过长的 token。例如,'I like DL' 句子将被标记为 ['like', 'dl'] 列表。标点符号也会被移除。处理后的句子作为最终结果返回。

  4. 然后,我们将实例化 TokenizedSentences

sentences = TokenizedSentences('war_and_peace.txt')
  1. 接下来,我们将实例化 Gensim 的 word2vec 训练模型:
model = gensim.models.word2vec. \
    Word2Vec(sentences=sentences,
             sg=1,  # 0 for CBOW and 1 for Skip-gram
             window=5,  # the size of the context window
             negative=5,  # negative sampling word count
             min_count=5,  # minimal word occurrences to include
             iter=5,  # number of epochs
             )

模型以 sentences 作为训练数据集。Word2Vec 支持我们在本章中讨论的所有参数和模型变体。例如,你可以通过 sg 参数在 CBOW 和 Skip-gram 之间切换。你还可以设置上下文窗口大小、负采样数量、训练轮次等参数。你可以在代码本身中探索所有参数。

或者,你可以通过将 gensim.models.word2vec.Word2Vec 替换为 gensim.models.fasttext.FastText 来使用 fastText 模型(它与相同的输入参数一起工作)。

  1. Word2Vec 构造函数也启动了训练。在短时间内(你不需要 GPU,因为训练数据集很小),生成的嵌入向量会存储在 model.wv 对象中。一方面,它像字典一样,你可以通过 model.wv['WORD_GOES_HERE'] 访问每个词的向量,然而,它也支持一些其他有趣的功能。你可以通过 model.wv.most_similar 方法来衡量不同词语之间的相似性。首先,它将每个词向量转换为单位向量(长度为 1 的向量)。然后,它计算目标词的单位向量与所有其他词的单位向量之间的点积。两个向量的点积越大,它们的相似性越高。例如,pprint.pprint(model.wv.most_similar(positive='mother', topn=5)) 将输出与词语 'mother' 最相似的五个词及其点积:
[('sister', 0.9024157524108887),
 ('daughter', 0.8976515531539917),
 ('brother', 0.8965438008308411),
 ('father', 0.8935455679893494),
 ('husband', 0.8779271245002747)]

结果证明了词向量正确地编码了词语的含义。词语'mother' 确实在意义上与 'sister''daughter' 等相关联。

我们还可以找到与目标词组合最相似的词。例如,model.wv.most_similar(positive=['woman', 'king'], topn=5) 将计算 'woman''king' 的词向量的均值,然后找到与这个新均值最相似的词:

[('heiress', 0.9176832437515259), ('admirable', 0.9104862213134766), ('honorable', 0.9047746658325195), ('creature', 0.9040032625198364), ('depraved', 0.9013445973396301)]

我们可以看到一些词是相关的('heiress'),但大多数不是('creature''admirable')。也许我们的训练数据集太小,无法捕捉到像这样的复杂关系。

可视化嵌入向量

为了获得比训练嵌入模型部分更好的词向量,我们将训练另一个 word2vec 模型。然而,这次我们将使用一个更大的语料库——text8 数据集,它由维基百科的前 1 亿字节的纯文本组成。该数据集已包含在 Gensim 中,并且它被标记为一个包含单词的长列表。现在,让我们开始吧:

  1. 一如既往,首先是导入。我们还将日志设置为 INFO 级别,以便更好地查看:
import logging
import pprint  # beautify prints

import gensim.downloader as gensim_downloader
import matplotlib.pyplot as plt
import numpy as np
from gensim.models.word2vec import Word2Vec
from sklearn.manifold import TSNE

logging.basicConfig(level=logging.INFO)
  1. 接下来,我们将训练 Word2vec 模型。这一次,我们将使用 CBOW 来加速训练。我们将通过 gensim_downloader.load('text8') 加载数据集:
model = Word2Vec(
    sentences=gensim_downloader.load('text8'),  # download and load the text8 dataset
    sg=0, size=100, window=5, negative=5, min_count=5, iter=5)
  1. 为了判断这个模型是否更好,我们可以尝试找到与'woman''king'最相似但与'man'最不相似的词。理想情况下,其中一个词应该是'queen'。我们可以使用表达式pprint.pprint(model.wv.most_similar(positive=['woman', 'king'], negative=['man']))来实现。输出结果如下:
[('queen', 0.6532326936721802), ('prince', 0.6139929294586182), ('empress', 0.6126195192337036), ('princess', 0.6075714230537415), ('elizabeth', 0.588543176651001), ('throne', 0.5846244692802429), ('daughter', 0.5667101144790649), ('son', 0.5659586191177368), ('isabella', 0.5611927509307861), ('scots', 0.5606790781021118)]

确实,最相似的词是'queen',但其余的词也相关。

  1. 接下来,我们将利用 t-SNE 可视化模型,在收集到的词向量上展示这些词在 2D 图中的分布。t-SNE 将每个高维嵌入向量映射到二维或三维空间中的一个点,使得相似的对象被映射到附近的点,不相似的对象则被映射到远离的点,并且这种映射具有较高的概率。我们将从几个target_words开始,然后收集与每个目标词最相似的n个词(及其词向量)。以下是执行此操作的代码:
target_words = ['mother', 'car', 'tree', 'science', 'building', 'elephant', 'green']
word_groups, embedding_groups = list(), list()

for word in target_words:
    words = [w for w, _ in model.most_similar(word, topn=5)]
    word_groups.append(words)

    embedding_groups.append([model.wv[w] for w in words])
  1. 然后,我们将使用以下参数训练一个 t-SNE 可视化模型,基于收集到的聚类结果:

    • perplexity与在匹配每个点的原始向量和降维向量时所考虑的最近邻居数量有关。换句话说,它决定了算法是否会关注数据的局部特性或全局特性。

    • n_components=2指定了输出向量的维度数。

    • n_iter=5000是训练迭代的次数。

    • init='pca'使用主成分分析PCA)进行初始化。

该模型以embedding_groups聚类为输入,输出带有 2D 嵌入向量的embeddings_2d数组。以下是实现代码:

# Train the t-SNE algorithm
embedding_groups = np.array(embedding_groups)
m, n, vector_size = embedding_groups.shape
tsne_model = TSNE(perplexity=8, n_components=2, init='pca', n_iter=5000)

# generate 2d embeddings from the original 100d ones
embeddings_2d = tsne_model.fit_transform(embedding_groups.reshape(m * n, vector_size))
embeddings_2d = np.array(embeddings_2d).reshape(m, n, 2)
  1. 接下来,我们将展示新的 2D 嵌入。为此,我们将初始化图表及其某些属性,以提高可视性:
# Plot the results
plt.figure(figsize=(16, 9))
# Different color and marker for each group of similar words
color_map = plt.get_cmap('Dark2')(np.linspace(0, 1, len(target_words)))
markers = ['o', 'v', 's', 'x', 'D', '*', '+']
  1. 然后,我们将遍历每个similar_words聚类,并将其词语作为点展示在散点图上。每个聚类使用唯一的标记。点将标注对应的词语:
# Iterate over all groups
for label, similar_words, emb, color, marker in \
        zip(target_words, word_groups, embeddings_2d, color_map, markers):
    x, y = emb[:, 0], emb[:, 1]

    # Plot the points of each word group
    plt.scatter(x=x, y=y, c=color, label=label, marker=marker)

    # Annotate each point with its corresponding caption
    for word, w_x, w_y in zip(similar_words, x, y):
        plt.annotate(word, xy=(w_x, w_y), xytext=(0, 15),
                     textcoords='offset points', ha='center', va='top', size=10)
  1. 最后,我们将展示图表:
plt.legend()
plt.grid(True)
plt.show()

我们可以看到,每个相关词的聚类被分组在 2D 图的一个接近区域中:

t-SNE 可视化目标词及其最相似词的聚类

该图再次证明,获得的词向量包含了词语的相关信息。随着这个示例的结束,我们也总结了本章内容。

总结

这是专门讨论 NLP 的第一章。恰如其分,我们从当今大多数 NLP 算法的基本构建模块——词语及其基于上下文的向量表示开始。我们从n-gram 和将词表示为向量的需求开始。然后,我们讨论了 word2vec、fastText 和 GloVe 模型。最后,我们实现了一个简单的管道来训练嵌入模型,并使用 t-SNE 可视化了词向量。

在下一章,我们将讨论 RNN——一种自然适用于 NLP 任务的神经网络架构。

第七章:理解循环网络

在第一章《神经网络的基础》和第二章《理解卷积网络》中,我们深入探讨了普通前馈网络的特性以及其专业化形式——卷积神经网络CNNs)。在本章中,我们将通过循环神经网络RNNs)结束这一故事线。我们在前几章讨论的神经网络架构采用固定大小的输入并提供固定大小的输出,而 RNN 通过定义这些序列上的递归关系,突破了这一限制,从而能够处理可变长度的输入序列(因此得名)。如果你已经熟悉本章将讨论的某些主题,可以跳过它们。

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

  • RNN 简介

  • 引入长短期记忆(LSTM)

  • 引入门控循环单元

  • 实现文本分类

RNN 简介

RNN 是可以处理具有可变长度序列数据的神经网络。这类数据的例子包括一个句子的单词或股市在不同时间点的价格。通过使用“序列”一词,我们意味着序列中的元素是彼此相关的,并且它们的顺序是重要的。例如,如果我们把一本书的所有单词随机打乱,文本就会失去其含义,尽管我们仍然能知道各个单词的意思。自然地,我们可以使用 RNN 来解决与序列数据相关的任务。这类任务的例子包括语言翻译、语音识别、预测时间序列的下一个元素等。

RNN 得名于其对序列应用相同的函数进行递归运算。我们可以将 RNN 定义为一个递归关系:

在这里, f 是一个可微分函数,s[t] 是一个值的向量,称为内部网络状态(在第t步),x[t] 是第t步的网络输入。与普通网络不同,普通网络中的状态仅依赖于当前输入(和网络权重),而在这里,s[t] 是当前输入和前一个状态s[t-1]的函数。你可以将s[t-1]看作是网络对所有先前输入的总结。这与常规的前馈网络(包括 CNN)不同,后者只将当前输入样本作为输入。递归关系定义了状态如何通过对先前状态的反馈循环,逐步在序列中演化,如下图所示:

左:RNN 递归关系的可视化说明:s[t] =Ws[t-1] + Ux[t;]* 最终输出为 y[t] =Vs*[t] 。右:RNN 状态在序列 t-1, t, t+1 上递归展开。注意,参数 U、V 和 W 在所有步骤之间是共享的

RNN 有三组参数(或权重):

  • U 将输入 x[t] 转换为状态 s[t]

  • W 将前一个状态 s[t-1] 转换为当前状态 s[t]

  • V 将新计算的内部状态 s[t] 转换为输出 y*[t]。

UVW 对其各自的输入进行线性变换。最基本的这种变换案例是我们熟悉的加权和。我们现在可以定义内部状态和网络输出如下:

在这里,f 是非线性激活函数(如 tanh、sigmoid 或 ReLU)。

例如,在一个单词级别的语言模型中,输入,x,将是一个由输入向量((x[1] ... x[t] ...))编码的单词序列。状态,s,将是一个状态向量序列((s[1] ... s[t] ...))。最后,输出,y,将是一个表示下一个单词的概率向量序列((y[1] ... y[t] ...))。

请注意,在 RNN 中,每个状态都依赖于通过递归关系得到的所有先前计算结果。这一点的一个重要含义是,RNN 在时间上具有记忆,因为状态 s 包含基于前一步骤的信息。从理论上讲,RNN 可以记住信息任意长的时间,但在实际应用中,它们只能回顾到前几步。我们将在“消失与爆炸梯度”一节中详细讨论这个问题。

我们在这里描述的 RNN 在某种程度上等同于一个单层的常规神经网络(并且具有额外的递归关系)。正如我们现在从第一章《神经网络的基本原理》中所知,单层网络存在一些严重的限制。别担心!像常规网络一样,我们可以堆叠多个 RNN,形成堆叠 RNN。在时间步t,位于第l层的 RNN 单元的细胞状态,s^l[t],将接收来自第l-1层 RNN 单元的输出,y[t](l-1)*,以及该层第`l`单元在时间步`t-1`的前一时刻的细胞状态,**s***l[t-1],作为输入:

在下图中,我们可以看到一个展开的堆叠 RNN:

堆叠 RNN

迄今为止,我们讨论的 RNN 利用序列的前一个元素来产生输出。这对于时间序列预测等任务是有意义的,我们希望根据前面的元素预测序列中的下一个元素。但它也在其他任务上施加了不必要的限制,例如自然语言处理领域的任务。正如我们在第六章《语言建模》中所看到的,我们可以通过上下文获得关于一个词的很多信息,因此从前后词语中提取上下文是有意义的。

我们可以扩展常规的 RNN,使用所谓的双向 RNN来覆盖这种情况,如下图所示:

双向 RNN

该网络有两个传播循环,分别朝两个方向工作,也就是说,从步长 tt+1 的从左到右传播和从步长 t+1t 的从右到左传播。我们用符号‘来表示从右到左的传播(不要与导数混淆)。在每一个时间步长 t,网络保持两个内部状态向量:s[t]用于从左到右的传播,s'[t]用于从右到左的传播。右到左阶段有自己的输入权重集,U'W',它们对应左到右阶段的权重UW。右到左隐藏状态向量的公式如下:

网络的输出,y[t],是内部状态s[t]s[t+1]的组合。一种组合它们的方式是通过连接。在这种情况下,我们将连接后的状态的权重矩阵表示为V。这里,输出的公式如下:

或者,我们可以简单地将两个状态向量相加:

因为 RNN 不局限于处理固定大小的输入,它们真正扩展了我们使用神经网络计算的可能性,比如不同长度的序列或大小不同的图像。

让我们回顾一些不同的组合方式:

  • 一对一:这是非顺序处理,例如前馈神经网络和卷积神经网络(CNN)。需要注意的是,前馈网络和将 RNN 应用于单一时间步长之间并没有太大区别。一对一处理的一个例子是图像分类,我们在第二章《理解卷积网络》和第三章《高级卷积网络》中有讨论。

  • 一对多:这种处理方式基于单一输入生成一个序列,例如,从图像生成描述(Show and Tell: A Neural Image Caption Generatorarxiv.org/abs/1411.4555)。

  • 多对一:这种处理方式基于一个序列输出单一结果,例如文本情感分类。

  • 多对多间接:一个序列被编码成一个状态向量,之后这个状态向量被解码成一个新的序列,例如,语言翻译(使用 RNN 编码器-解码器进行短语表示学习用于统计机器翻译,arxiv.org/abs/1406.1078使用神经网络的序列到序列学习papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf)。

  • 多对多直接:对于每个输入步骤,输出一个结果,例如,语音识别中的帧音素标注*。

多对多模型通常被称为序列到序列seq2seq)模型。

以下是前述输入输出组合的图形表示:

RNN 的输入输出组合:灵感来自karpathy.github.io/2015/05/21/rnn-effectiveness/

现在我们已经介绍了 RNN,在接下来的部分中,我们将从头开始实现一个简单的 RNN 示例,以加深我们的理解。

RNN 的实现和训练

在前面的一节中,我们简要讨论了什么是 RNN 以及它们能解决哪些问题。接下来我们将深入探讨 RNN 的细节以及如何通过一个非常简单的玩具示例来训练它:计算序列中的 1 的数量。

在这个问题中,我们将教一个基本的 RNN 如何计算输入中 1 的数量,然后在序列的最后输出结果。这是一个多对一关系的示例,我们在前一部分中已经定义过。

我们将用 Python(不使用深度学习库)和 NumPy 来实现这个示例。输入和输出的示例如下:

In: (0, 0, 0, 0, 1, 0, 1, 0, 1, 0) 
Out: 3

我们将使用的 RNN 在下图中有所示例:

用于计算输入中 1 的基本 RNN

网络将只有两个参数:一个输入权重,U,和一个递归权重,W。输出权重,V,被设置为 1,以便我们只读取最后一个状态作为输出,y

由于s[t]x[t]UW是标量值,我们在RNN 实现和训练部分及其子部分中将不使用矩阵符号(粗体大写字母)。然而,请注意,这些公式的通用版本使用矩阵和向量参数。

在我们继续之前,先添加一些代码以使我们的示例能够执行。我们将导入numpy并定义我们的训练数据x和标签yx是二维的,因为第一维表示小批量中的样本。为了简化起见,我们将使用一个只包含一个样本的小批量:

import numpy as np

# The first dimension represents the mini-batch
x = np.array([[0, 0, 0, 0, 1, 0, 1, 0, 1, 0]])

y = np.array([3])

由此网络定义的递归关系是![]。请注意,这是一个线性模型,因为我们在这个公式中没有应用非线性函数。我们可以按如下方式实现递归关系:

def step(s, x, U, W):
   return x * U + s * W

状态 s[t] 和权重 W 以及 U 是单一的标量值。一个好的解决方案是直接对序列中的输入进行求和。如果我们设置 U=1,那么每当输入被接收时,我们将获得其完整的值。如果我们设置 W=1,那么我们累积的值将永远不会衰减。因此,对于这个示例,我们将得到期望的输出:3。

然而,让我们用这个简单的示例来训练和实现这个神经网络。这将非常有趣,因为我们将在本节的其余部分看到。首先,让我们看看如何通过反向传播得到这个结果。

时间反向传播

时间反向传播是我们用来训练递归网络的典型算法(Backpropagation Through Time: What It Does and How to Do Itaxon.cs.byu.edu/~martinez/classes/678/Papers/Werbos_BPTT.pdf)。顾名思义,它基于我们在第一章中讨论的反向传播算法,神经网络的基础与核心

正常的反向传播和时间反向传播的主要区别在于,递归网络是通过时间展开的,展开的时间步数是有限的(如前面的图示所示)。一旦展开完成,我们将得到一个与常规多层前馈网络非常相似的模型,即该网络的一个隐藏层代表了一个时间步。唯一的区别是,每一层都有多个输入:前一个状态 s[t-1] 和当前输入 x[t]。参数 UW 在所有隐藏层之间共享。

前向传播解开了 RNN 在序列中的展开,并为每个步骤构建了状态栈。在以下的代码块中,我们可以看到一个前向传播的实现,它返回每个递归步骤和批次中每个样本的激活值 s

def forward(x, U, W):
    # Number of samples in the mini-batch
    number_of_samples = len(x)

    # Length of each sample
    sequence_length = len(x[0])

    # Initialize the state activation for each sample along the sequence
    s = np.zeros((number_of_samples, sequence_length + 1))

    # Update the states over the sequence
    for t in range(0, sequence_length):
        s[:, t + 1] = step(s[:, t], x[:, t], U, W)  # step function

    return s

现在我们有了前向步骤和损失函数,我们可以定义如何反向传播梯度。由于展开后的 RNN 等同于常规的前馈网络,我们可以使用在第一章中介绍的反向传播链式法则,神经网络的基础与核心

因为权重 WU 在各层之间共享,我们将为每个递归步骤累积误差导数,最后,我们将使用累积的值来更新权重。

首先,我们需要获取输出 s[t] 关于损失函数的梯度(∂J/∂s)。一旦我们得到它,我们将通过在前向步骤中构建的活动栈向后传播它。这个反向传播过程将从栈中弹出活动,以在每个时间步骤累积它们的误差导数。通过网络传播这个梯度的递归关系可以写成如下(链式法则):

这里,J 是损失函数。

权重的梯度,UW,是通过以下方式累积的:

以下是反向传播的实现:

  1. UW 的梯度分别在 gUgW 中累积:
def backward(x, s, y, W):
    sequence_length = len(x[0])

    # The network output is just the last activation of sequence
    s_t = s[:, -1]

    # Compute the gradient of the output w.r.t. MSE loss function 
      at final state
    gS = 2 * (s_t - y)

    # Set the gradient accumulations to 0
    gU, gW = 0, 0

    # Accumulate gradients backwards
    for k in range(sequence_length, 0, -1):
        # Compute the parameter gradients and accumulate the
          results
        gU += np.sum(gS * x[:, k - 1])
        gW += np.sum(gS * s[:, k - 1])

        # Compute the gradient at the output of the previous layer
        gS = gS * W

    return gU, gW
  1. 我们现在可以尝试使用梯度下降来优化我们的网络。我们通过backward函数计算gradients(使用均方误差),并利用它们来更新weights值:
def train(x, y, epochs, learning_rate=0.0005):
    """Train the network"""

    # Set initial parameters
    weights = (-2, 0) # (U, W)

    # Accumulate the losses and their respective weights
    losses = list()
    gradients_u = list()
    gradients_w = list()

    # Perform iterative gradient descent
    for i in range(epochs):
        # Perform forward and backward pass to get the gradients
        s = forward(x, weights[0], weights[1])

        # Compute the loss
        loss = (y[0] - s[-1, -1]) ** 2

        # Store the loss and weights values for later display
        losses.append(loss)

        gradients = backward(x, s, y, weights[1])
        gradients_u.append(gradients[0])
        gradients_w.append(gradients[1])

        # Update each parameter `p` by p = p - (gradient *
          learning_rate).
        # `gp` is the gradient of parameter `p`
        weights = tuple((p - gp * learning_rate) for p, gp in
        zip(weights, gradients))

    print(weights)

    return np.array(losses), np.array(gradients_u),
    np.array(gradients_w)
  1. 接下来,我们将实现相关的plot_training函数,该函数在每个周期显示loss函数和每个权重的梯度:
def plot_training(losses, gradients_u, gradients_w):
    import matplotlib.pyplot as plt

    # remove nan and inf values
    losses = losses[~np.isnan(losses)][:-1]
    gradients_u = gradients_u[~np.isnan(gradients_u)][:-1]
    gradients_w = gradients_w[~np.isnan(gradients_w)][:-1]

    # plot the weights U and W
    fig, ax1 = plt.subplots(figsize=(5, 3.4))

    ax1.set_ylim(-3, 20)
    ax1.set_xlabel('epochs')
    ax1.plot(gradients_u, label='grad U', color='blue',
    linestyle=':')
    ax1.plot(gradients_w, label='grad W', color='red', linestyle='--
    ')
    ax1.legend(loc='upper left')

    # instantiate a second axis that shares the same x-axis
    # plot the loss on the second axis
    ax2 = ax1.twinx()

    # uncomment to plot exploding gradients
    ax2.set_ylim(-3, 10)
    ax2.plot(losses, label='Loss', color='green')
    ax2.tick_params(axis='y', labelcolor='green')
    ax2.legend(loc='upper right')

    fig.tight_layout()

    plt.show()
  1. 最后,我们可以运行以下代码:
losses, gradients_u, gradients_w = train(x, y, epochs=150)
plot_training(losses, gradients_u, gradients_w)

上述代码将生成以下图示:

RNN 的损失:不中断的线表示损失,其中虚线表示训练过程中的权重梯度。

现在我们已经学习了时间反向传播,接下来我们讨论一下熟悉的梯度消失和梯度爆炸问题是如何影响它的。

梯度消失与梯度爆炸

然而,前面的示例存在一个问题。让我们使用更长的序列运行训练过程:

x = np.array([[0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0]])

y = np.array([12])

losses, gradients_u, gradients_w = train(x, y, epochs=150)
plot_training(losses, gradients_u, gradients_w)

输出如下:

Sum of ones RNN from scratch
chapter07-rnn/simple_rnn.py:5: RuntimeWarning: overflow encountered in multiply
  return x * U + s * W
chapter07-rnn/simple_rnn.py:40: RuntimeWarning: invalid value encountered in multiply
  gU += np.sum(gS * x[:, k - 1])
chapter07-rnn/simple_rnn.py:41: RuntimeWarning: invalid value encountered in multiply
  gW += np.sum(gS * s[:, k - 1])
(nan, nan)

这些警告的原因是,最终的参数,UW,变成了不是一个数字NaN)。为了正确显示梯度,我们需要在plot_training函数中将梯度轴的尺度从ax1.set_ylim(-3, 20)改为ax1.set_ylim(-3, 600),并将损失轴的尺度从ax2.set_ylim(-3, 10)改为ax2.set_ylim(-3, 200)

现在,程序将生成以下新损失和梯度的图示:

梯度爆炸场景中的参数和损失函数

在初始的训练周期中,梯度逐渐增大,类似于它们在较短序列中增加的方式。然而,当达到第 23 个周期时(确切的周期并不重要),梯度变得非常大,以至于超出了float变量的范围并变成了 NaN(如图中的跳跃所示)。这个问题称为梯度爆炸。我们可能会在常规的前馈神经网络(NN)中遇到梯度爆炸问题,但在循环神经网络(RNN)中尤为明显。为了理解为什么会这样,我们回顾一下在时间反向传播部分中定义的两个连续序列步骤的循环梯度传播链规则:

根据序列的长度,展开的 RNN 比常规网络可能更深。同时,RNN 的权重W在所有步骤中是共享的。因此,我们可以将这个公式推广到计算序列中两个非连续步骤之间的梯度。由于W是共享的,方程形成了几何级数:

在我们简单的线性 RNN 中,如果|W| > 1(梯度爆炸),梯度会呈指数增长,其中W是一个单一的标量权重,例如,50 个时间步长时,W=1.5 时,W⁵⁰ ≈ 637621500。如果|W| <1(梯度消失),梯度会呈指数衰减,例如,10 个时间步长时,W=0.6 时,W²⁰ = 0.00097。如果权重参数W是一个矩阵而非标量,那么这种梯度爆炸或梯度消失与W的最大特征值(ρ)(也称为谱半径)相关。如果ρ < 1,则梯度会消失,而如果ρ > 1,则梯度会爆炸。

梯度消失问题,我们在第一章《神经网络的基本原理》中首次提到,在 RNN 中有另一种更加微妙的影响。梯度会随着步数的增加呈指数衰减,直到变得极其微小,特别是在较早的状态中。实际上,它们会被来自更近期时间步的较大梯度所掩盖,导致网络无法保持这些较早状态的历史信息。这个问题更难以检测,因为训练仍然可以进行,网络也会产生有效的输出(不同于梯度爆炸)。它只是无法学习长期的依赖关系。

现在,我们已经熟悉了 RNN 的一些问题。这些知识对我们非常有用,因为在下一节中,我们将讨论如何借助一种特殊类型的 RNN 来解决这些问题。

引入长短期记忆

Hochreiter 和 Schmidhuber 广泛研究了梯度消失和梯度爆炸问题,并提出了一个叫做长短期记忆LSTMwww.bioinf.jku.at/publications/older/2604.pdf)的解决方案。LSTM 可以通过特别设计的记忆单元处理长期依赖关系。事实上,它们工作得非常好,以至于目前大多数 RNN 在各种问题上的成功都归功于 LSTM 的使用。在本节中,我们将探索这个记忆单元是如何工作的,以及它是如何解决梯度消失问题的。

LSTM 的关键思想是单元状态 c[t](除了隐藏的 RNN 状态 h[t]),在没有外部干扰时,只能明确写入或移除信息,使状态保持恒定。 单元状态只能通过特定的门进行修改,这些门是信息传递的一种方式。 这些门由 Sigmoid 函数和逐元素乘法组成。 由于 Sigmoid 仅输出介于 0 和 1 之间的值,乘法只能减少通过门传递的值。 典型的 LSTM 由三个门组成:遗忘门、输入门和输出门。 单元状态、输入和输出都是向量,因此 LSTM 可以在每个时间步骤中保存不同信息块的组合。

以下是 LSTM 单元的图示:

顶部:LSTM 单元;底部:展开的 LSTM 单元:灵感源于 colah.github.io/posts/2015-08-Understanding-LSTMs/

在我们继续之前,让我们介绍一些符号。 x[t]c[t]h[t] 是 LSTM 在时刻 t 的输入、单元记忆状态和输出(或隐藏状态)向量。 c'[t] 是候选单元状态向量(稍后详细介绍)。输入 x[t] 和上一个单元输出 h[t-] 通过完全连接的权重集 WU 分别连接到每个门和候选单元向量。 f[t]i[t]o[t] 是 LSTM 单元的遗忘、输入和输出门。 这些门是具有 Sigmoid 激活的全连接层。

让我们从遗忘门 f[t] 开始。 正如其名称所示,它决定我们是否要擦除现有单元状态的部分内容。 它基于上一个单元的加权向量之和输出,h[t-1],以及当前输入,x[t]

从前面的图表中,我们可以看到遗忘门在上一个状态向量 c[t-1] 的每个元素上应用元素级 Sigmoid 激活:f[t] c[t-1]。 再次注意,因为操作是元素级的,因此该向量的值被压缩到 [0, 1] 范围内。 输出为 0 将完全擦除特定 c[t-1] 单元块,输出为 1 则允许该单元块中的信息通过。 这意味着 LSTM 可以在其单元状态向量中去除不相关的信息。

遗忘门不是由 Hochreiter 最初提出的 LSTM 中的一部分。 相反,它是在Learning to Forget: Continual Prediction with LSTM (citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.55.5709&rep=rep1&type=pdf) 中提出的。

输入门i[t]决定要将什么新信息添加到记忆单元中,这是一个多步过程。第一步决定是否添加任何信息。像遗忘门一样,它基于h[t-1]x[t]来做决策:它通过 sigmoid 函数输出 0 或 1,表示候选状态向量的每个单元。输出为 0 意味着不会向该单元块的记忆中添加任何信息。因此,LSTM 可以在其单元状态向量中存储特定的信息:

在下一步中,我们计算新的候选单元状态,c'[t]。它基于前一个输出h[t-1]和当前输入x[t],并通过tanh函数进行转换:

接下来,c'[t]与输入门的 sigmoid 输出通过逐元素相乘进行组合,![]。

总结一下,遗忘门和输入门分别决定了从先前和候选单元状态中忘记和包含哪些信息。新的单元状态c[t]的最终版本只是这两个组件的逐元素相加:

接下来,我们关注输出门,它决定总的单元输出是什么。它以h[t-1]x[t]作为输入,并输出 0 或 1(通过 sigmoid 函数),用于每个单元记忆块。如之前所述,0 意味着该块不会输出任何信息,1 意味着该块可以作为单元的输出。因此,LSTM 可以从其单元状态向量中输出特定的块信息:

最后,LSTM 单元的输出通过tanh函数进行转换:

因为所有这些公式是可导的,我们可以将 LSTM 单元链在一起,就像我们将简单的 RNN 状态链在一起并通过时间反向传播训练网络一样。

那么,LSTM 是如何保护我们免受梯度消失的呢?我们从前向传播阶段开始。请注意,如果遗忘门为 1 且输入门为 0,则单元状态会在每一步中完全复制:![]。只有遗忘门才能完全擦除单元的记忆。因此,记忆可以在长时间内保持不变。另外,注意输入是一个tanh激活函数,它被添加到当前单元的记忆中。这意味着单元的记忆不会爆炸,并且相当稳定。

让我们通过一个例子来演示 LSTM 单元是如何展开的。为了简化问题,我们假设它具有一维(单标量值)输入、状态和输出向量。由于值是标量,我们在这个例子中不会使用向量符号:

随时间展开 LSTM:灵感来自于 nikhilbuduma.com/2015/01/11/a-deep-dive-into-recurrent-neural-networks/

过程如下:

  1. 首先,我们有一个 3 的值作为候选状态。输入门设置为 f[i] = 1,而忘记门设置为 f[t] = 0。这意味着先前的状态 c[t-1] = N 被擦除,并被新的状态 ![] 替代。

  2. 对于接下来的两个时间步骤,忘记门设置为 1,而输入门设置为 0。这样,所有信息在这两个步骤中都保持不变,因为输入门被设置为 0:![]。

  3. 最后,输出门设置为 o[t] = 1,输出 3 并保持不变。我们已经成功演示了如何在多个步骤中存储内部状态。

接下来,让我们关注反向传播阶段。通过忘记门(f[t])的帮助,单元状态 c[t] 可以减轻梯度消失/爆炸的问题。与常规的 RNN 一样,我们可以使用链式法则计算两个连续步骤的偏导数,![]。根据公式 ![],不展开细节,其偏导数如下:

我们也可以将其推广到非连续步骤:

如果忘记门的值接近 1,梯度信息几乎可以不变地通过网络状态反向传播。这是因为 f[t] 使用了 sigmoid 激活,信息流仍然受到特定于 sigmoid 激活的梯度消失问题的影响(第一章,神经网络的基本构件)。但与常规 RNN 中的梯度不同,f[t] 在每个时间步都有不同的值。因此,这不是几何级数,梯度消失效应不那么明显。

我们可以像堆叠常规 RNN 一样堆叠 LSTM 单元,唯一的不同是步长 t 的单元状态在一个层级上作为该层级在步长 t+1 的单元状态的输入。以下图示展示了展开的堆叠 LSTM:

堆叠 LSTM

现在我们已经介绍了 LSTM,接下来让我们通过实现它来巩固我们的知识。

实现 LSTM

在这一部分,我们将使用 PyTorch 1.3.1 实现一个 LSTM 单元。首先,我们要注意,PyTorch 已经有一个 LSTM 实现,位于torch.nn.LSTM。但是,我们的目标是理解 LSTM 单元的工作原理,所以我们将从头开始实现自己的版本。该单元将是torch.nn.Module的子类,我们将把它作为更大模型的构建模块。本示例的源代码可以在github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter07/lstm_cell.py找到。让我们开始吧:

  1. 首先,我们进行导入:
import math
import typing

import torch
  1. 接下来,我们将实现类和__init__方法:
class LSTMCell(torch.nn.Module):

    def __init__(self, input_size: int, hidden_size: int):
        """
        :param input_size: input vector size
        :param hidden_size: cell state vector size
        """

        super(LSTMCell, self).__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size

        # combine all gates in a single matrix multiplication
        self.x_fc = torch.nn.Linear(input_size, 4 * hidden_size)
        self.h_fc = torch.nn.Linear(hidden_size, 4 * hidden_size)

        self.reset_parameters()

为了理解全连接层self.x_fcself.h_fc的作用,让我们回顾一下,候选细胞状态以及输入、遗忘和输出门都依赖于输入x[t]和前一个细胞输出h[t-1]的加权向量和。因此,我们不需要为每个细胞执行八个独立的![]和![]操作,而是可以将它们合并,做成两个大的全连接层self.x_fcself.h_fc,每个层的输出大小为4 * hidden_size。当我们需要某个特定门的输出时,我们可以从这两个全连接层的任意一个张量输出中提取必要的切片(我们将在forward方法的实现中看到如何做到这一点)。

  1. 让我们继续讨论reset_parameters方法,该方法使用 LSTM 特定的 Xavier 初始化器初始化网络的所有权重(如果你直接复制并粘贴此代码,可能需要检查缩进):
def reset_parameters(self):
    """Xavier initialization """
    size = math.sqrt(3.0 / self.hidden_size)
    for weight in self.parameters():
        weight.data.uniform_(-size, size)
  1. 接下来,我们将开始实现forward方法,该方法包含我们在介绍长短期记忆部分中描述的所有 LSTM 执行逻辑。它接收当前时间步t的 mini-batch,以及一个包含时间步t-1时刻的细胞输出和细胞状态的元组作为输入:
def forward(self,
            x_t: torch.Tensor,
            hidden: typing.Tuple[torch.Tensor, torch.Tensor] =      (None, None)) \
        -> typing.Tuple[torch.Tensor, torch.Tensor]:
    h_t_1, c_t_1 = hidden # t_1 is equivalent to t-1

    # in case of more than 2-dimensional input
    # flatten the tensor (similar to numpy.reshape)
    x_t = x_t.view(-1, x_t.size(1))
    h_t_1 = h_t_1.view(-1, h_t_1.size(1))
    c_t_1 = c_t_1.view(-1, c_t_1.size(1))
  1. 我们将继续同时计算三个门和候选状态的激活。做法很简单,像这样:
gates = self.x_fc(x_t) + self.h_fc(h_t_1)
  1. 接下来,我们将为每个门分离输出:
i_t, f_t, candidate_c_t, o_t = gates.chunk(4, 1)
  1. 然后,我们将对它们应用activation函数:
i_t, f_t, candidate_c_t, o_t = \
    i_t.sigmoid(), f_t.sigmoid(), candidate_c_t.tanh(), o_t.sigmoid()
  1. 接下来,我们将计算新的细胞状态,c[t]:
c_t = torch.mul(f_t, c_t_1) + torch.mul(i_t, candidate_c_t)
  1. 最后,我们将计算细胞输出ht,并将其与新的细胞状态c[t]一起返回:
h_t = torch.mul(o_t, torch.tanh(c_t))
return h_t, c_t

一旦我们有了 LSTM 单元,就可以将其应用于与常规 RNN 相同的任务——计算序列中 1 的数量。我们只会包含源代码中最相关的部分,但完整的示例可以在github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter07/lstm_gru_count_1s.py中找到。这次,我们将使用一个包含 10,000 个二进制序列的完整训练集,每个序列的长度为 20(这些是随意设定的数字)。实现的前提与 RNN 示例类似:我们以递归的方式将二进制序列输入到 LSTM 中,单元输出作为单一标量值的预测 1 的数量(回归任务)。然而,我们的LSTMCell实现有两个限制:

  • 它仅涵盖序列的单个步骤。

  • 它输出单元状态和网络输出向量。这是一个回归任务,我们有一个单一的输出值,但单元状态和网络输出有更多维度。

为了解决这些问题,我们将实现一个自定义的LSTMModel类,它扩展了LSTMCell。它将整个序列的所有元素输入给LSTMCell实例,并处理单元状态和网络输出从序列的一个元素过渡到下一个元素的过程。

一旦最终输出产生,它将被传递给一个全连接层,转换为一个单一的标量值,表示网络预测的 1 的数量。以下是其实现:

class LSTMModel(torch.nn.Module):
    def __init__(self, input_dim, hidden_size, output_dim):
        super(LSTMModel, self).__init__()
        self.hidden_size = hidden_size

        # Our own LSTM implementation
        self.lstm = LSTMCell(input_dim, hidden_size)

        # Fully connected output layer
        self.fc = torch.nn.Linear(hidden_size, output_dim)

    def forward(self, x):
        # Start with empty network output and cell state to initialize the sequence
        c_t = torch.zeros((x.size(0), self.hidden_size)).to(x.device)
        h_t = torch.zeros((x.size(0), self.hidden_size)).to(x.device)

        # Iterate over all sequence elements across all sequences of the mini-batch
        for seq in range(x.size(1)):
            h_t, c_t = self.lstm(x[:, seq, :], (h_t, c_t))

        # Final output layer
        return self.fc(h_t)

现在,我们将直接跳到训练/测试设置阶段(请记住,这只是完整源代码的一部分):

  1. 首先,我们将生成训练和测试数据集。generate_dataset函数返回一个torch.utils.data.TensorDataset实例。它包含TRAINING_SAMPLES = 10000个长度为SEQUENCE_LENGTH = 20的二进制序列的二维张量,以及每个序列中 1 的数量的标量值标签:
train = generate_dataset(SEQUENCE_LENGTH, TRAINING_SAMPLES)
train_loader = torch.utils.data.DataLoader(train, batch_size=BATCH_SIZE, shuffle=True)

test = generate_dataset(SEQUENCE_LENGTH, TEST_SAMPLES)
test_loader = torch.utils.data.DataLoader(test, batch_size=BATCH_SIZE, shuffle=True)
  1. 我们将使用HIDDEN_UNITS = 20来实例化模型。模型接受一个输入(每个序列元素),并输出一个值(1 的数量):
model = LSTMModel(input_size=1, hidden_size=HIDDEN_UNITS, output_size=1)
  1. 接下来,我们将实例化MSELoss函数(因为这是回归问题)和 Adam 优化器:
loss_function = torch.nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters())
  1. 最后,我们可以运行EPOCHS = 10的训练/测试周期。train_modeltest_model函数与我们在第二章《理解卷积网络》节中实现的函数相同:
for epoch in range(EPOCHS):
    print('Epoch {}/{}'.format(epoch + 1, EPOCHS))

    train_model(model, loss_function, optimizer, train_loader)
    test_model(model, loss_function, test_loader)

如果我们运行这个示例,网络将在 5 到 6 个 epoch 内达到 100%的测试准确率。

既然我们已经学习了 LSTM,现在让我们将注意力转向门控循环单元(GRU)。这是一种尝试复制 LSTM 特性但结构更简化的另一种循环块。

介绍门控循环单元

门控循环单元(GRU)是一种递归单元块,首次提出于 2014 年(使用 RNN 编码器-解码器进行统计机器翻译的学习短语表示arxiv.org/abs/1406.1078门控递归神经网络在序列建模中的经验评估arxiv.org/abs/1412.3555),它是对 LSTM 的改进。GRU 单元通常具有与 LSTM 相似或更好的性能,但它以更少的参数和操作实现:

一个 GRU 单元

类似于 经典 RNN,GRU 单元有一个隐藏状态,h[t]。你可以将其视为 LSTM 的隐藏状态和单元状态的组合。GRU 单元有两个门:

  • 一个更新门,z[t],它结合了输入门和忘记门。它根据网络输入 x[t] 和前一个单元隐藏状态 h[t-1] 来决定丢弃哪些信息,以及替换哪些新信息。通过结合这两个门,我们可以确保单元忘记信息,但只有在我们打算用新信息替代时:

  • 一个重置门,r[t],它利用前一个单元状态 h[t-1] 和网络输入 x[t] 来决定通过多少前一状态:

接下来,我们有候选状态,h'[t]:

最后,GRU 在时间 t 的输出,h[t],是前一输出 h[t−1] 和候选输出 h'[t] 之间的逐元素和:

由于更新门允许我们同时忘记和存储数据,因此它直接作用于先前的输出,h[t][−1],并作用于候选输出,h'[t]。

实现 GRU

在本节中,我们将通过遵循 实现 LSTM 部分的蓝图,使用 PyTorch 1.3.1 实现 GRU 单元。让我们开始吧:

  1. 首先,我们将进行导入:
import math
import torch
  1. 接下来,我们将编写类定义和 init 方法。在 LSTM 中,我们能够为所有门创建共享的全连接层,因为每个门都需要相同的输入组合:x[t] 和 h[t-1]。GRU 的门使用不同的输入,因此我们将为每个 GRU 门创建单独的全连接操作:
class GRUCell(torch.nn.Module):

    def __init__(self, input_size: int, hidden_size: int):
        """
        :param input_size: input vector size
        :param hidden_size: cell state vector size
        """

        super(GRUCell, self).__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size

        # x to reset gate r
        self.x_r_fc = torch.nn.Linear(input_size, hidden_size)

        # x to update gate z
        self.x_z_fc = torch.nn.Linear(input_size, hidden_size)

        # x to candidate state h'(t)
        self.x_h_fc = torch.nn.Linear(input_size, hidden_size)

        # network output/state h(t-1) to reset gate r
        self.h_r_fc = torch.nn.Linear(hidden_size, hidden_size)

        # network output/state h(t-1) to update gate z
        self.h_z_fc = torch.nn.Linear(hidden_size, hidden_size)

        # network state h(t-1) passed through the reset gate r towards candidate state h(t)
        self.hr_h_fc = torch.nn.Linear(hidden_size, hidden_size)

我们将省略 reset_parameters 的定义,因为它与 LSTMCell 中相同。

  1. 然后,我们将按照 门控循环单元 部分中描述的步骤,使用单元实现 forward 方法。该方法将当前输入向量,x[t],和先前的单元状态/输出,h[t-1],作为输入。首先,我们将计算忘记门和更新门,类似于我们在 LSTM 单元中计算门的方式:
def forward(self,
            x_t: torch.Tensor,
            h_t_1: torch.Tensor = None) \
        -> torch.Tensor:

    # compute update gate vector
    z_t = torch.sigmoid(self.x_z_fc(x_t) + self.h_z_fc(h_t_1))

    # compute reset gate vector
    r_t = torch.sigmoid(self.x_r_fc(x_t) + self.h_r_fc(h_t_1))
  1. 接下来,我们将计算新的候选状态/输出,它使用重置门:
candidate_h_t = torch.tanh(self.x_h_fc(x_t) + self.hr_h_fc(torch.mul(r_t, h_t_1)))
  1. 最后,我们将根据候选状态和更新门计算新的输出:
h_t = torch.mul(z_t, h_t_1) + torch.mul(1 - z_t, candidate_h_t)

我们可以像实现 LSTM 一样,使用 GRU 单元来实现计数任务。为了避免重复,我们在此不包括实现,但可以在github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter07/lstm_gru_count_1s.py找到相应的代码。

这也结束了我们关于各种 RNN 类型的讨论。接下来,我们将通过实现一个文本情感分析的例子来运用这些知识。

实现文本分类

让我们回顾一下目前为止的内容。我们首先使用numpy实现了一个 RNN。接着,我们继续使用原始的 PyTorch 操作实现了一个 LSTM。我们将通过训练默认的 PyTorch 1.3.1 LSTM 实现来解决文本分类问题,从而结束这一部分的内容。这个例子还需要torchtext 0.4.0 包。文本分类(或标记)是指根据内容为文本分配类别(或标签)的任务。文本分类任务包括垃圾邮件检测、主题标签和情感分析。这类问题是一个多对一关系的例子,我们在RNN 简介部分有过定义。

在本节中,我们将在大型电影评论数据集上实现情感分析示例(ai.stanford.edu/~amaas/data/sentiment/),该数据集包含 25,000 个训练评论和 25,000 个测试评论,均来自流行电影。每个评论都有一个二进制标签,指示其是正面还是负面。除了 PyTorch,我们还将使用torchtext包(torchtext.readthedocs.io/)。它包含数据处理工具和自然语言处理的流行数据集。你还需要安装spacy开源软件库(spacy.io)以进行高级 NLP 处理,我们将使用它来对数据集进行分词。

情感分析算法如下图所示:

使用词嵌入和 LSTM 进行情感分析

让我们描述算法的步骤(这些步骤适用于任何文本分类算法):

  1. 序列中的每个单词都用其嵌入向量进行替换(第六章,语言建模)。这些嵌入可以通过 word2vec、fastText、GloVe 等方法生成。

  2. 词嵌入作为输入提供给 LSTM 单元。

  3. 单元格输出,h[t],作为输入传递给一个具有单一输出单元的全连接层。该单元使用 sigmoid 激活函数,表示评论为正面(1)或负面(0)的概率。如果问题是多项式的(而非二分类问题),我们可以用 softmax 替换 sigmoid。

  4. 序列的最后一个元素的网络输出被视为整个序列的结果。

现在我们已经概述了算法,让我们开始实现它。我们只会包括代码中的有趣部分,完整的实现可以在 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter07/sentiment_analysis.py

这个示例部分基于 github.com/bentrevett/pytorch-sentiment-analysis

让我们开始吧:

  1. 首先,我们将添加导入:
import torch
import torchtext
  1. 接下来,我们将实例化一个 torchtext.data.Field 对象:
TEXT = torchtext.data.Field(
    tokenize='spacy',  # use SpaCy tokenizer
    lower=True,  # convert all letters to lower case
    include_lengths=True,  # include the length of the movie review
)

该对象声明了一个文本处理流水线,从原始文本开始,输出文本的张量表示。更具体地说,它使用 spacy 分词器,将所有字母转换为小写,并包括每个电影评论的单词数(长度)。

  1. 然后,我们将对标签(正面或负面)做同样的处理:
LABEL = torchtext.data.LabelField(dtype=torch.float)
  1. 接下来,我们将实例化训练和测试数据集的拆分:
train, test = torchtext.datasets.IMDB.splits(TEXT, LABEL)

电影评论数据集已包含在 torchtext 中,我们无需做任何额外的工作。splits 方法将 TEXTLABEL 字段作为参数。这样,指定的流水线将应用于选定的数据集。

  1. 接着,我们将实例化词汇表:
TEXT.build_vocab(train, vectors=torchtext.vocab.GloVe(name='6B', dim=100))
LABEL.build_vocab(train)

词汇表提供了一个将单词转换为数字表示的机制。在此情况下,TEXT 字段的数字表示为预训练的 100d GloVe 向量。另一方面,数据集中的标签具有 posneg 的字符串值。词汇表在这里的作用是为这两个标签分配数字(0 和 1)。

  1. 接下来,我们将为训练和测试数据集定义迭代器,其中 device 代表 GPU 或 CPU。每次调用时,迭代器将返回一个小批次:
train_iter, test_iter = torchtext.data.BucketIterator.splits(
    (train, test), sort_within_batch=True, batch_size=64, device=device)
  1. 接下来,我们将实现并实例化 LSTMModel 类。这是程序的核心,执行我们在本节开头的图表中定义的算法步骤:
class LSTMModel(torch.nn.Module):
    def __init__(self, vocab_size, embedding_size, hidden_size, output_size, pad_idx):
        super().__init__()

        # Embedding field
        self.embedding=torch.nn.Embedding(num_embeddings=vocab_size,
        embedding_dim=embedding_size,padding_idx=pad_idx)

        # LSTM cell
        self.rnn = torch.nn.LSTM(input_size=embedding_size,
        hidden_size=hidden_size)

        # Fully connected output
        self.fc = torch.nn.Linear(hidden_size, output_size)

    def forward(self, text_sequence, text_lengths):
        # Extract embedding vectors
        embeddings = self.embedding(text_sequence)

        # Pad the sequences to equal length
        packed_sequence =torch.nn.utils.rnn.pack_padded_sequence
        (embeddings, text_lengths)

        packed_output, (hidden, cell) = self.rnn(packed_sequence)

        return self.fc(hidden)

model = LSTMModel(vocab_size=len(TEXT.vocab),
                  embedding_size=EMBEDDING_SIZE,
                  hidden_size=HIDDEN_SIZE,
                  output_size=1,
                  pad_idx=TEXT.vocab.stoi[TEXT.pad_token])

LSTMModel 处理一个具有不同长度的序列小批量(在这个例子中是电影评论)。然而,mini-batch 是一个张量,它为每个序列分配了等长的切片。因此,所有序列都会预先使用特殊符号进行填充,以达到批次中最长序列的长度。torch.nn.Embedding 构造函数中的 padding_idx 参数表示词汇表中填充符号的索引。但是,使用填充序列会导致对填充部分进行不必要的计算。为了避免这一点,模型的前向传播接受 text mini-batch 和每个序列的 text_lengths 作为参数。它们会被传递给 pack_padded_sequence 函数,该函数将它们转换为 packed_sequence 对象。我们之所以这样做,是因为 self.rnn 对象(即 torch.nn.LSTM 的实例)有一个专门处理打包序列的例程,从而优化了填充部分的计算。

  1. 接下来,我们将 GloVe 词嵌入向量复制到模型的嵌入层:
model.embedding.weight.data.copy_(TEXT.vocab.vectors)
  1. 然后,我们将把填充和未知标记的嵌入项设置为零,以避免它们对传播产生影响:
model.embedding.weight.data[TEXT.vocab.stoi[TEXT.unk_token]] = torch.zeros(EMBEDDING_SIZE)
model.embedding.weight.data[TEXT.vocab.stoi[TEXT.pad_token]] = torch.zeros(EMBEDDING_SIZE)
  1. 最后,我们可以运行以下代码来执行整个过程(train_modeltest_model 函数与之前相同):
optimizer = torch.optim.Adam(model.parameters())
loss_function = torch.nn.BCEWithLogitsLoss().to(device)

model = model.to(device)

for epoch in range(5):
    print(f"Epoch {epoch + 1}/5")
    train_model(model, loss_function, optimizer, train_iter)
    test_model(model, loss_function, test_iter)

如果一切按预期工作,模型将达到大约 88% 的测试准确率。

总结

在本章中,我们讨论了 RNN。首先,我们从 RNN 和时间反向传播理论开始。然后,我们从头实现了一个 RNN,以巩固我们对该主题的知识。接下来,我们使用相同的模式,逐步深入到更复杂的 LSTM 和 GRU 单元:先进行理论解释,再进行 PyTorch 实现。最后,我们将第六章《语言建模》中的知识与本章的新内容结合,完成了一个功能齐全的情感分析任务实现。

在下一章中,我们将讨论 seq2seq 模型及其变种——序列处理中的一个令人兴奋的新发展。

第八章:序列到序列模型与注意力机制

在第七章,理解递归网络中,我们概述了几种类型的递归模型,取决于输入输出的组合方式。其中之一是间接多对多序列到序列seq2seq),其中输入序列被转化为另一个不同的输出序列,输出序列不一定与输入序列的长度相同。机器翻译是最常见的 seq2seq 任务类型。输入序列是一个语言中的句子单词,输出序列是相同句子的翻译,转换为另一种语言。例如,我们可以将英语序列tourist attraction翻译为德语touristenattraktion。输出句子不仅长度不同,而且输入和输出序列的元素之间没有直接对应关系。特别地,一个输出元素可能对应输入元素的两个组合。

使用单一神经网络实现的机器翻译称为神经机器翻译NMT)。其他类型的间接多对多任务包括语音识别,其中我们将音频输入的不同时间帧转化为文本转录;问答聊天机器人,其中输入序列是文本问题的单词,输出序列是该问题的答案;以及文本摘要,其中输入是文本文档,输出是该文档内容的简短总结。

在本章中,我们将介绍注意力机制——一种用于 seq2seq 任务的新型算法。它允许直接访问输入序列中的任何元素。这不同于递归神经网络RNN),后者将整个序列总结为一个隐藏状态向量,并优先处理最近的序列元素,而忽略较早的元素。

本章将涵盖以下主题:

  • 介绍 seq2seq 模型

  • 带注意力机制的 Seq2seq

  • 理解变换器(transformers)

  • 变换器语言模型:

    • 来自变换器的双向编码器表示

    • Transformer-XL

    • XLNet

介绍 seq2seq 模型

Seq2seq,或称编码器-解码器(参见神经网络的序列到序列学习arxiv.org/abs/1409.3215),模型使用 RNN 的方式,特别适合解决输入和输出之间存在间接多对多关系的任务。类似的模型也在另一篇开创性论文中提出,使用 RNN 编码器-解码器学习短语表示用于统计机器翻译(更多信息请访问arxiv.org/abs/1406.1078)。以下是 seq2seq 模型的示意图。输入序列[A, B, C, ]被解码为输出序列[W, X, Y, Z, ]:

一个由arxiv.org/abs/1409.3215提供的 seq2seq 模型案例

该模型由两部分组成:编码器和解码器。下面是推理部分的工作方式:

  • 编码器是一个 RNN。原始论文使用的是 LSTM,但 GRU 或其他类型的 RNN 也可以使用。单独来看,编码器按常规方式工作——它逐步读取输入序列,并在每步后更新其内部状态。当编码器读取到一个特殊的(序列结束)符号时,它就停止读取输入序列。如果我们假设使用的是文本序列,则在每一步时我们会使用词嵌入向量作为编码器的输入,而符号表示句子的结束。编码器的输出被丢弃,在 seq2seq 模型中没有作用,因为我们只关注隐藏的编码器状态。

  • 一旦编码器完成,我们会向解码器发送信号,让它通过特殊的输入信号开始生成输出序列。编码器同样是一个 RNN(LSTM 或 GRU)。编码器和解码器之间的联系是最新的编码器内部状态向量h[t](也称为思维向量),它作为递归关系在第一个解码器步骤中被送入。解码器在第t+1步的输出y[t+1]是输出序列中的一个元素。我们会将其作为第t+2步的输入,然后生成新的输出,依此类推(这种类型的模型被称为自回归)。对于文本序列,解码器输出是对词汇表中所有词的 softmax。在每一步中,我们会选择概率最高的词,并将其作为输入送入下一步。一旦成为最可能的符号,解码过程就结束了。

模型的训练是有监督的,模型需要知道输入序列及其对应的目标输出序列(例如,同一文本的多种语言)。我们将输入序列送入解码器,生成思维向量h[t],并用它来启动解码器的输出序列生成。然而,解码器采用一种叫做教师强制(teacher forcing)的方法——在第t步时,解码器的输入不是第t-1步的解码器输出。而是第t步的输入始终是目标序列第t-1步的正确字符。例如,假设目标序列在第t步时的正确内容为[WXY],但当前解码器生成的输出序列为[WXZ]。使用教师强制时,第t+1步的解码器输入将是Y,而不是Z。换句话说,解码器学会根据目标值[...,t]来生成目标值[t+1,...]。我们可以这样理解:解码器的输入是目标序列,而它的输出(目标值)是相同的序列,只不过右移了一个位置。

总结来说,seq2seq 模型通过将输入序列编码为固定长度的状态向量,然后利用该向量作为基础生成输出序列,解决了输入/输出序列长度变化的问题。我们可以将其形式化为,模型尝试最大化以下概率:

这等价于以下内容:

让我们更详细地看一下这个公式的各个元素:

  • 是条件概率,其中是长度为T的输入序列,是长度为T'的输出序列。

  • 元素v是输入序列的固定长度编码(思想向量)。

  • 是给定前置单词y和向量v时,输出词汇y[T']的概率。

原始的 seq2seq 论文介绍了一些技巧来增强模型的训练和性能:

  • 编码器和解码器是两个独立的 LSTM。在 NMT 的情况下,这使得可以使用相同的编码器训练不同的解码器。

  • 论文作者的实验表明,堆叠的 LSTM 比单层 LSTM 效果更好。

  • 输入序列反向馈送给解码器。例如,ABC -> WXYZ会变成CBA -> WXYZ。目前没有明确的解释说明为什么这种方法有效,但作者分享了他们的直觉:由于这是一个逐步生成的模型,如果序列是正常顺序,那么源句子中的每个单词都会远离输出句子中相应的单词。如果我们反转输入序列,输入/输出单词之间的平均距离不会改变,但第一个输入单词将非常接近第一个输出单词。这将帮助模型在输入和输出序列之间建立更好的沟通

  • 除了,模型还使用以下两个特殊符号(我们已经在第七章的实现文本分类部分中遇到过它们,理解递归网络):

    • 未知:用于替换稀有词汇,以避免词汇表过大。

    • :出于性能考虑,我们必须使用固定长度的序列来训练模型。然而,这与实际训练数据相矛盾,因为这些序列可能有任意长度。为了解决这个问题,较短的序列会用特殊的< PAD >符号填充。

现在我们已经介绍了基本的 seq2seq 模型架构,接下来我们将学习如何用注意力机制扩展它。

带注意力机制的 Seq2seq

解码器必须仅基于思维向量生成整个输出序列。为了实现这一点,思维向量必须编码输入序列的所有信息;然而,编码器是一个 RNN,我们可以预期它的隐藏状态会比最早的序列元素携带更多关于最新序列元素的信息。使用 LSTM 单元并反转输入有一定帮助,但不能完全防止这种情况。因此,思维向量成为了某种瓶颈。结果,seq2seq 模型在短句子上表现良好,但对于更长的句子性能会下降。

巴赫达瑙注意力机制

我们可以通过借助注意力机制(详见《神经机器翻译:通过联合学习对齐与翻译》),这是 seq2seq 模型的一个扩展,来解决这个问题,它提供了一种方式,让解码器不仅能使用最后一个编码器隐藏状态,而是能与所有编码器隐藏状态一起工作。

本节中的注意力机制称为巴赫达瑙注意力机制(Bahdanau attention),以原始论文的作者命名。

除了能够解决瓶颈问题,注意力机制还有一些其他的优势。首先,立即访问所有之前的状态有助于防止梯度消失问题。它还允许结果的某些可解释性,因为我们可以看到解码器在处理输入时集中注意的部分。

以下图示展示了注意力机制是如何工作的:

注意力机制

不用担心——它看起来比实际复杂。我们将从上到下逐步解释这个图示:注意力机制通过在编码器和解码器之间插入一个额外的上下文向量 c[t] 来工作。在时间 t 时,隐藏的解码器状态 s[t] 不仅是步骤 t-1 的隐藏状态和解码器输出的函数,还包括上下文向量 c[t]

每个解码器步骤都有一个独特的上下文向量,而一个解码器步骤的上下文向量仅仅是所有编码器隐藏状态的加权和。通过这种方式,编码器可以在每个输出步骤 t 访问所有输入序列的状态,这就不再需要像常规的 seq2seq 模型那样将源序列的所有信息编码成一个固定长度的向量。

让我们更详细地讨论这个公式:

  • c[t] 是解码器输出步骤 t(从 T' 总输出中)的上下文向量。

  • h[i] 是编码器步骤 i(从 T 个总输入步骤中)的隐藏状态。

  • α[t,i] 是与 h[i] 相关的标量权重,在当前解码器步骤 t 的上下文中。

请注意,α[t,i] 对于编码器和解码器步骤是唯一的——也就是说,输入序列的状态将在不同的输出步骤中具有不同的权重。例如,如果输入和输出序列的长度为 10,则权重将由一个 10 × 10 的矩阵表示,总共有 100 个权重。这意味着注意力机制将根据当前输出序列的状态,将解码器的注意力集中在输入序列的不同部分。如果 α**[t,i] 较大,则解码器会在步骤 t 时非常关注 h[i]

但我们如何计算权重 α**[t,i] 呢?首先,我们应该提到,解码器在步骤 t 时,所有 α**[t,i] 的总和为 1。我们可以通过在注意力机制上应用 softmax 操作来实现这一点:

这里,e[t,k] 是一个对齐模型,表示输入序列中位置 k 附近的元素与输出序列中位置 t 的匹配(或对齐)程度。这个得分(由权重 α**[t,i] 表示)基于先前的解码器状态 s[t-1](我们使用 s[t-1],因为我们还没有计算 s[t]),以及编码器状态 h[i]

这里,a(而不是 alpha)是一个可微分的函数,经过反向传播训练,和系统的其他部分一起学习。满足这些要求的函数有很多,但论文的作者选择了所谓的 加性注意力,它通过加法将 s[t-1]h[i] 结合起来。它有两种变体:

在第一个公式中,W 是一个权重矩阵,应用于拼接后的向量 s[t-1]h[i],而 v 是一个权重向量。第二个公式类似,但这一次我们有了独立的全连接层(权重矩阵 W[1]W[2]),并且我们对 s[t-1]h[i] 进行了求和。在这两种情况下,对齐模型可以表示为一个简单的前馈网络,包含一个隐藏层。

现在我们知道了 c[t]α**[t,i] 的公式,让我们将后者代入前者:

总结一下,下面是按步骤列出的注意力算法:

  1. 将输入序列传给编码器,并计算隐藏状态集 ![]。

  2. 计算对齐分数 ![],该分数使用来自前一步的解码器状态 s[t-1]。如果 t = 1,我们将使用最后的编码器状态 h[T] 作为初始隐藏状态。

  3. 计算权重 ![]。

  4. 计算上下文向量 ![]。

  5. 基于拼接后的向量s[t-1]c[t]以及前一时刻的解码器输出y[t-1],计算隐藏状态![]。此时,我们可以计算最终输出y[t]。在需要分类下一个词的情况下,我们将使用 softmax 输出![],其中W[y]是一个权重矩阵。

  6. 重复步骤 2 到 6 直到序列结束。

接下来,我们将介绍一个稍微改进的注意力机制,称为 Luong 注意力。

Luong 注意力

Luong 注意力(参见《基于注意力的神经机器翻译的有效方法》)在 Bahdanau 注意力上引入了几个改进。最显著的变化是对齐分数e[t]依赖于解码器隐藏状态s[t],而不是 Bahdanau 注意力中的s[t-1]。为了更好地理解这一点,我们来比较这两种算法:

左侧:Bahdanau 注意力;右侧:Luong 注意力

让我们逐步执行 Luong 注意力的过程:

  1. 将输入序列输入到编码器,并计算编码器隐藏状态集![]。

  2. 基于前一时刻的解码器隐藏状态s[t-1]和前一时刻的解码器输出y[t-1](但不是上下文向量),计算解码器隐藏状态![]。

  3. 计算对齐分数![],它使用当前步骤的解码器状态s[t]。除了加法注意力,Luong 注意力的论文还提出了两种乘法注意力

    • ![]: 基本的点积没有任何参数。在这种情况下,向量sh需要具有相同的大小。

    • ![]: 这里,W[m]是注意力层的可训练权重矩阵。

向量的乘积作为对齐分数度量具有直观的解释——正如我们在第一章《神经网络基础》一节中提到的,点积作为向量间相似度的度量。因此,如果向量相似(即对齐),乘积的结果将是一个大值,注意力将集中在当前的t,i关系上。

  1. 计算权重

  2. 计算上下文向量

  3. 基于拼接后的向量c[t]s[t],计算向量 。此时,我们可以计算最终输出y[t]。在分类的情况下,我们将使用 softmax ,其中W[y]是一个权重矩阵。

  4. 重复步骤 2-7 直到序列结束。

接下来,让我们讨论一些更多的注意力变体。我们将从硬注意力软注意力开始,这与我们计算上下文向量 **c[t] 的方式有关。到目前为止,我们描述了软注意力,其中 **c[t] 是输入序列所有隐藏状态的加权和。在硬注意力中,我们仍然计算权重 α[t,i],但只选择具有最大相关权重 α[t,imax] 的隐藏状态 **h[imax]。然后,选定的状态 **h[imax] 作为上下文向量。起初,硬注意力似乎有点违反直觉——毕竟为了使解码器能够访问所有输入状态,为什么再次限制到单个状态?然而,硬注意力最初是在图像识别任务的背景下引入的,在这些任务中,输入序列表示同一图像的不同区域。在这种情况下,选择多个区域或单个区域更有意义。与软注意力不同,硬注意力是一个随机过程,是不可微分的。因此,反向阶段使用一些技巧来工作(这超出了本书的范围)。

本地注意力代表了软注意力和硬注意力之间的一种折衷。而这些机制要么考虑所有输入隐藏向量(全局),要么只考虑单个输入向量,本地注意力则会取一个窗口向量,包围给定的输入序列位置,然后只在此窗口上应用软注意力。但是我们如何确定窗口的中心 p[t](称为对齐位置),基于当前输出步骤 t?最简单的方式是假设源序列和目标序列大致单调对齐,即设定 p[t] = t,这是因为输入和输出序列位置相关联的逻辑。

接下来,我们将通过介绍注意力机制的一般形式来总结我们到目前为止所学到的内容。

一般注意力

尽管我们在 NMT 的背景下讨论了注意力机制,但它是一种通用的深度学习技术,可应用于任何序列到序列的任务。让我们假设我们正在使用硬注意力。在这种情况下,我们可以将向量 s[t-1] 视为针对键-值对数据库执行的查询,其中是向量,隐藏状态 h[i]。这些通常缩写为 QKV,您可以将它们视为向量矩阵。Luong 和 Bahdanau 注意力的键 Q 和值 V 是相同的向量——也就是说,这些注意力模型更像是 Q/V,而不是 Q/K/V。一般的注意力机制使用了所有三个组件。

下图说明了这种新的一般注意力:

一般注意力

当我们对数据库执行查询(q = s[t-1])时,我们将收到一个单一的匹配项——具有最大权重α**[t,imax]的键k[imax]。隐藏在这个键背后的是向量v[imax] = h[imax],它是我们真正感兴趣的实际值。那么,什么是软注意力,所有值都参与其中呢?我们可以用相同的查询/键/值的方式思考,但不同的是,查询结果是所有值,且它们具有不同的权重。我们可以使用新的符号写出一个广义的注意力公式(基于上下文向量c[t]公式):

在这个通用的注意力机制中,查询、键和数据库中的向量不一定按顺序相关。换句话说,数据库不必由不同步骤中的隐藏 RNN 状态组成,而可以包含任何类型的信息。这就结束了我们对 seq2seq 模型背后理论的介绍。我们将在接下来的部分中应用这些知识,那里我们将实现一个简单的 seq2seq NMT 示例。

实现带有注意力机制的 seq2seq

在本节中,我们将使用 PyTorch 1.3.1,借助 seq2seq 注意力模型实现一个简单的 NMT 示例。为澄清起见,我们将实现一个 seq2seq 注意力模型,就像我们在引入 seq2seq 模型部分中介绍的那样,并将其扩展为 Luong 注意力。模型的编码器将作为输入处理一个语言中的文本序列(句子),而解码器将输出翻译成另一种语言的相应序列。

我们只展示代码中最相关的部分,但完整示例可以在github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter08/nmt_rnn_attention找到。这个示例部分基于 PyTorch 教程github.com/pytorch/tutorials/blob/master/intermediate_source/seq2seq_translation_tutorial.py

我们从训练集开始。它包含一大批法语和英语的句子,存储在文本文件中。NMTDataset类(是torch.utils.data.Dataset的子类)实现了必要的数据预处理。它创建了一个包含数据集中所有可能单词的整数索引的词汇表。为了简化,我们不使用嵌入向量,而是将单词的数字表示形式输入到网络中。此外,我们不会将数据集拆分为训练集和测试集,因为我们的目标是展示 seq2seq 模型的工作原理。NMTDataset类输出源-目标句子的元组,其中每个句子由该句子中单词索引的 1D 张量表示。

实现编码器

接下来,我们继续实现编码器。

我们将从构造函数开始:

class EncoderRNN(torch.nn.Module):
    def __init__(self, input_size, hidden_size):
        super(EncoderRNN, self).__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size

        # Embedding for the input words
        self.embedding = torch.nn.Embedding(input_size, hidden_size)

        # The actual rnn sell
        self.rnn_cell = torch.nn.GRU(hidden_size, hidden_size)

入口点是self.embedding模块。它将获取每个单词的索引,并返回其对应的嵌入向量。我们不会使用预训练的词向量(如 GloVe),但嵌入向量的概念是相同的——只是我们将使用随机值初始化它们,并在训练过程中与模型的其余部分一起训练。然后,我们使用torch.nn.GRU RNN 单元。

接下来,让我们实现EncoderRNN.forward方法(请注意缩进):

def forward(self, input, hidden):
    # Pass through the embedding
    embedded = self.embedding(input).view(1, 1, -1)
    output = embedded

    # Pass through the RNN
    output, hidden = self.rnn_cell(output, hidden)
    return output, hidden

它表示序列元素的处理。首先,我们获得embedded单词向量,然后将其输入到 RNN 单元中。

我们还将实现EncoderRNN.init_hidden方法,该方法创建一个与隐藏 RNN 状态大小相同的空张量。这个张量作为序列开始时的第一个 RNN 隐藏状态(请注意缩进):

def init_hidden(self):
    return torch.zeros(1, 1, self.hidden_size, device=device)

现在我们已经实现了编码器,接下来继续实现解码器。

实现解码器

让我们实现DecoderRNN类——一个没有注意力的基础解码器。同样,我们从构造函数开始:

class DecoderRNN(torch.nn.Module):

    def __init__(self, hidden_size, output_size):
        super(DecoderRNN, self).__init__()
        self.hidden_size = hidden_size
        self.output_size = output_size

        # Embedding for the current input word
        self.embedding = torch.nn.Embedding(output_size, hidden_size)

        # decoder cell
        self.gru = torch.nn.GRU(hidden_size, hidden_size)

        # Current output word
        self.out = torch.nn.Linear(hidden_size, output_size)
        self.log_softmax = torch.nn.LogSoftmax(dim=1)

它与编码器类似——我们有初始的self.embedding词嵌入和self.gru GRU 单元。我们还有全连接的self.out层,配合self.log_softmax激活函数,将输出序列中预测的单词。

我们将继续实现DecoderRNN.forward方法(请注意缩进):

    def forward(self, input, hidden, _):
        # Pass through the embedding
        embedded = self.embedding(input).view(1, 1, -1)
        embedded = torch.nn.functional.relu(embedded)

        # Pass through the RNN cell
        output, hidden = self.rnn_cell(embedded, hidden)

        # Produce output word
        output = self.log_softmax(self.out(output[0]))
        return output, hidden, _

它从embedded向量开始,作为 RNN 单元的输入。该模块返回它的新hidden状态和output张量,后者表示预测的单词。该方法接受空参数_,以便与我们将在下一节中实现的注意力解码器接口匹配。

实现带注意力的解码器

接下来,我们将实现带 Luong 注意力的AttnDecoderRNN解码器。它也可以与EncoderRNN一起使用。

我们从AttnDecoderRNN.__init__方法开始:

class AttnDecoderRNN(torch.nn.Module):
    def __init__(self, hidden_size, output_size, max_length=MAX_LENGTH,
    dropout=0.1):
        super(AttnDecoderRNN, self).__init__()
        self.hidden_size = hidden_size
        self.output_size = output_size
        self.max_length = max_length

        # Embedding for the input word
        self.embedding = torch.nn.Embedding(self.output_size,
        self.hidden_size)

        self.dropout = torch.nn.Dropout(dropout)

        # Attention portion
        self.attn = torch.nn.Linear(in_features=self.hidden_size,
                                    out_features=self.hidden_size)

        self.w_c = torch.nn.Linear(in_features=self.hidden_size * 2,
                                   out_features=self.hidden_size)

        # RNN
        self.rnn_cell = torch.nn.GRU(input_size=self.hidden_size,
                                     hidden_size=self.hidden_size)

        # Output word
        self.w_y = torch.nn.Linear(in_features=self.hidden_size,
                                   out_features=self.output_size)

像往常一样,我们有self.embedding,但这次我们还将添加self.dropout来防止过拟合。全连接层self.attnself.w_c与注意力机制相关,接下来我们将在查看AttnDecoderRNN.forward方法时学习如何使用它们。AttnDecoderRNN.forward实现了我们在带注意力机制的 Seq2seq部分中描述的 Luong 注意力算法。我们从方法声明和参数预处理开始:

def forward(self, input, hidden, encoder_outputs):
    embedded = self.embedding(input).view(1, 1, -1)
    embedded = self.dropout(embedded)

接下来,我们将计算当前的隐藏状态(hidden = s[t])。请注意缩进,因为这段代码仍然是AttnDecoderRNN.forward方法的一部分:

    rnn_out, hidden = self.rnn_cell(embedded, hidden)

然后,我们将计算对齐分数(alignment_scores = e[t,i]),遵循乘法注意力公式。在这里,torch.mm是矩阵乘法,encoder_outputs是编码器的输出(惊讶吧!):

    alignment_scores = torch.mm(self.attn(hidden)[0], encoder_outputs.t())

接下来,我们将对分数计算 softmax 以产生注意力权重(attn_weights = a[t,i]):

    attn_weights = torch.nn.functional.softmax(alignment_scores, dim=1)

然后,我们将根据注意力公式计算上下文向量(c_t = c[t]):

    c_t = torch.mm(attn_weights, encoder_outputs)

接下来,我们将通过连接当前的隐藏状态和上下文向量来计算修改后的状态向量(hidden_s_t = ):

    hidden_s_t = torch.cat([hidden[0], c_t], dim=1)
    hidden_s_t = torch.tanh(self.w_c(hidden_s_t))

最后,我们将计算下一个预测词:

    output = torch.nn.functional.log_softmax(self.w_y(hidden_s_t), dim=1)

我们应该注意到,torch.nn.functional.log_softmax在常规 softmax 之后应用对数。这个激活函数与负对数似然损失函数torch.nn.NLLLoss配合使用。

最后,方法返回outputhiddenattn_weights。稍后,我们将使用attn_weights来可视化输入和输出句子之间的注意力(方法AttnDecoderRNN.forward在此结束):

    return output, hidden, attn_weights

接下来,让我们看看训练过程。

训练和评估

接下来,让我们实现train函数。它类似于我们在前几章中实现的其他函数;然而,它考虑了输入的顺序性和我们在Seq2eq with attention部分描述的教师强制原则。为了简化,我们将一次只训练一个序列(大小为 1 的小批量)。

首先,我们将启动训练集的迭代,设置初始序列张量,并重置梯度:

def train(encoder, decoder, loss_function, encoder_optimizer, decoder_optimizer, data_loader, max_length=MAX_LENGTH):
    print_loss_total = 0

    # Iterate over the dataset
    for i, (input_tensor, target_tensor) in enumerate(data_loader):
        input_tensor = input_tensor.to(device).squeeze(0)
        target_tensor = target_tensor.to(device).squeeze(0)

        encoder_hidden = encoder.init_hidden()

        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()

        input_length = input_tensor.size(0)
        target_length = target_tensor.size(0)

        encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)

        loss = torch.Tensor([0]).squeeze().to(device)

编码器和解码器的参数是EncoderRNNAttnDecoderRNN(或DecoderRNN)的实例,loss_function表示损失(在我们这里是torch.nn.NLLLoss),encoder_optimizerdecoder_optimizer(名称不言自明)是torch.optim.Adam的实例,而data_loader是一个torch.utils.data.DataLoader,它包装了一个NMTDataset实例。

接下来,我们将进行实际的训练:

with torch.set_grad_enabled(True):
    # Pass the sequence through the encoder and store the hidden states
    at each step
    for ei in range(input_length):
        encoder_output, encoder_hidden = encoder(
            input_tensor[ei], encoder_hidden)
        encoder_outputs[ei] = encoder_output[0, 0]

    # Initiate decoder with the GO_token
    decoder_input = torch.tensor([[GO_token]], device=device)

    # Initiate the decoder with the last encoder hidden state
    decoder_hidden = encoder_hidden

    # Teacher forcing: Feed the target as the next input
    for di in range(target_length):
        decoder_output, decoder_hidden, decoder_attention = decoder(
            decoder_input, decoder_hidden, encoder_outputs)
        loss += loss_function(decoder_output, target_tensor[di])
        decoder_input = target_tensor[di]  # Teacher forcing

    loss.backward()

    encoder_optimizer.step()
    decoder_optimizer.step()

让我们更详细地讨论一下:

  • 我们将完整的序列输入给编码器,并将隐藏状态保存在encoder_outputs列表中。

  • 我们通过GO_token作为输入启动解码器序列。

  • 我们使用解码器生成序列的新元素。遵循教师强制原则,decoder在每个步骤的输入来自实际的目标序列decoder_input = target_tensor[di]

  • 我们分别使用encoder_optimizer.step()decoder_optimizer.step()训练编码器和解码器。

类似于train,我们有一个evaluate函数,它接受一个输入序列并返回其翻译结果和相应的注意力分数。我们不会在这里包括完整的实现,而是关注编码器/解码器部分。不同于教师强制,decoder在每个步骤的输入是前一步的输出词:

# Initiate the decoder with the last encoder hidden state
decoder_input = torch.tensor([[GO_token]], device=device)  # GO

# Initiate the decoder with the last encoder hidden state
decoder_hidden = encoder_hidden

decoded_words = []
decoder_attentions = torch.zeros(max_length, max_length)

# Generate the output sequence (opposite to teacher forcing)
for di in range(max_length):
    decoder_output, decoder_hidden, decoder_attention = decoder(
        decoder_input, decoder_hidden, encoder_outputs)
    decoder_attentions[di] = decoder_attention.data

    # Obtain the output word index with the highest probability
    _, topi = decoder_output.data.topk(1)
    if topi.item() != EOS_token:
        decoded_words.append(dataset.output_lang.index2word[topi.item()])
    else:
        break

    # Use the latest output word as the next input
    decoder_input = topi.squeeze().detach()

当我们运行完整的程序时,它将显示几个示例翻译。它还会显示输入和输出序列之间元素的注意力分数图,像下面这样:

翻译注意力分数

例如,我们可以看到输出单词 she 将注意力集中在输入单词 elle 上(she 在法语中是“她”)。如果没有注意力机制,仅依赖最后的编码器隐藏状态来发起翻译,输出可能也会是 She's five years younger than me。由于单词 elle 离句子的末尾最远,单靠最后一个编码器隐藏状态很难编码它。

在接下来的章节中,我们将告别 RNN,并引入 Transformer——一个完全基于注意力机制的 seq2seq 模型。

理解 Transformer

在本章的大部分时间里,我们强调了注意力机制的优势。但我们依然在 RNN 的背景下使用注意力——从这个意义上讲,注意力机制是对这些模型核心递归特性的补充。既然注意力机制这么强大,是否可以单独使用它,而不需要 RNN 部分呢?事实证明,答案是肯定的。论文 Attention is all you needarxiv.org/abs/1706.03762)介绍了一种全新的架构——Transformer,它依赖于仅有的注意力机制,包含编码器和解码器。首先,我们将重点关注 Transformer 注意力机制(此处有双关意味)。

Transformer 注意力机制

在专注于整个模型之前,先来看看 Transformer 注意力是如何实现的:

左图:缩放点积(乘法)注意力;右图:多头注意力;来源:arxiv.org/abs/1706.03762

Transformer 使用点积注意力(前面图示的左侧图),遵循我们在 带有注意力机制的 Seq2seq 章节中介绍的通用注意力过程(正如我们已经提到的,这并不限于 RNN 模型)。我们可以通过以下公式定义它:

实际上,我们将同时对一组查询进行注意力计算,并将它们打包成一个矩阵 Q。在这种情况下,键 K、值 V 和结果也都是矩阵。让我们更详细地讨论公式中的步骤:

  1. 通过矩阵乘法将查询 Q 与数据库(键 K)匹配,生成对齐得分 。假设我们要将 m 个不同的查询与包含 n 个值的数据库进行匹配,并且查询-键向量的长度是 d[k]。那么,我们得到矩阵 ,其中每行包含一个 d[k] 维的查询,共 m 行。类似地,我们得到矩阵 ,每行包含一个 d[k] 维的键,共 n 行。然后,输出矩阵将是 ,其中每行包含一个查询在所有数据库键上的对齐得分。

换句话说,我们可以通过一次矩阵-矩阵乘法将多个查询与多个数据库键匹配。在 NMT 的背景下,我们可以用相同的方式计算目标句子所有词与源句子所有词之间的对齐分数。

  1. 使用来缩放对齐分数,其中d[k]是矩阵K中键向量的向量大小,它也等于Q中查询向量的大小(类似地,d[v]是键向量V的向量大小)。论文的作者怀疑,对于较大的d[k]值,点积的幅度会增大,导致 softmax 在极小梯度的区域内,这就引发了著名的梯度消失问题,因此需要对结果进行缩放。

  2. 使用 softmax 操作计算注意力分数(稍后我们会讨论掩码操作):

  1. 通过将注意力分数与值V相乘来计算最终的注意力向量V:

我们可以调整这一机制,使其同时适用于硬注意力和软注意力。

作者还提出了多头注意力(见前面图示的右侧图)。我们不是使用单一的注意力函数和d[model]维度的键,而是将键、查询和值线性投影h次,产生h个不同的d[k]d[k]d[v]维度的这些值的投影。然后,我们对新创建的向量应用独立的并行注意力函数(或头部),每个头部产生一个d[v]维度的输出。最后,我们将头部输出拼接在一起,产生最终的注意力结果。多头注意力使每个头部可以关注序列的不同元素,同时,模型将头部的输出结合成一个单一的、统一的表示。更准确地说,我们可以用以下公式定义它:

让我们更详细地看一下这个过程,从头部开始:

  1. 每个头部接收初始QKV的线性投影版本。投影是通过可学习的权重矩阵W [i]^QW [i]^KW [i]^V分别计算的。注意,我们为每个组件(QKV)和每个头部i都有一组独立的权重。为了满足从d[model]d[k]d[v]的转换,这些矩阵的维度是

  2. 一旦QKV被转换,我们就可以使用我们在本节开头描述的常规注意力模型来计算每个头部的注意力。

  3. 最终的注意力结果是对连接的头输出 head[i] 进行线性投影(可学习权重的权重矩阵 W^O)。

到目前为止,我们已经展示了不同输入和输出序列的注意力。例如,我们已经看到,在神经机器翻译(NMT)中,翻译句子的每个词与源句子的词相关。Transformer 模型也依赖于自注意力(或内部注意力),其中查询 Q 来自与键 K 和查询数据库的向量 V 相同的数据集。换句话说,在自注意力中,源和目标是相同的序列(在我们的例子中,是同一句话)。自注意力的好处并不立即显现,因为没有直接的任务可以应用它。从直观层面上看,它让我们能够看到同一序列中词与词之间的关系。例如,以下图示展示了动词 making 的多头自注意力(不同颜色代表不同的头)。许多注意力头关注到 making 的远程依赖,完成了短语 making ... more difficult

多头自注意力的示例。来源: arxiv.org/abs/1706.03762

Transformer 模型使用自注意力来替代编码器/解码器 RNNs,但更多内容将在下一节中介绍。

Transformer 模型

现在我们已经熟悉了多头注意力,让我们集中关注完整的 Transformer 模型,从以下图示开始:

Transformer 模型架构。左侧显示编码器,右侧显示解码器;来源:arxiv.org/abs/1706.03762

它看起来很复杂,但别担心——它比看起来要简单。我们从编码器(前图的左侧组件)开始:

  • 它从一个经过独热编码的词序列开始,这些词被转化为d[model]维的嵌入向量。嵌入向量进一步与相乘。

  • Transformer 不使用 RNNs,因此它必须以其他方式传递每个序列元素的位置信息。我们可以通过显式地将每个嵌入向量与位置编码相结合来做到这一点。简而言之,位置编码是一个与嵌入向量具有相同长度d[model]的向量。位置向量与嵌入向量按元素相加,结果在编码器中进一步传播。论文的作者为位置向量的每个元素 i 引入了以下函数,当当前词在序列中的位置为 pos 时:

位置编码的每个维度对应一个正弦波。波长从 2π 到 10000 · 2π 形成一个几何级数。作者假设这个函数可以让模型轻松地学会按相对位置进行注意力操作,因为对于任何固定的偏移量 kPE[pos+k] 可以表示为 PE[pos] 的线性函数。

  • 编码器的其余部分由一堆 N = 6 个相同的块组成。每个块有两个子层:

    • 一个多头自注意力机制,类似我们在《Transformer 注意力》一节中描述的。由于自注意力机制跨整个输入序列工作,因此编码器是双向的设计。一些算法仅使用编码器部分,并被称为Transformer 编码器

    • 一个简单的全连接前馈网络,其定义公式如下:

网络应用于每个序列元素 x,并独立处理。它在不同位置使用相同的参数集(W[1]W[2]b[1],和 b[2]),但在不同的编码器块中使用不同的参数。

每个子层(无论是多头注意力还是前馈网络)都有一个围绕其自身的残差连接,并以归一化结束,归一化的对象是该连接及其自身输出与残差连接的和。因此,每个子层的输出如下:

归一化技术在论文《层归一化》中有详细描述(arxiv.org/abs/1607.06450)。

接下来,让我们看看解码器,它与编码器有些相似:

  • 步骤 t 的输入是解码器在步骤 t-1 的预测输出词。输入词使用与编码器相同的嵌入向量和位置编码。

  • 解码器继续使用一堆 N = 6 个相同的块,这些块与编码器块有些相似。每个块由三个子层组成,且每个子层都采用残差连接和归一化。子层包括:

    • 一个多头自注意力机制。编码器的自注意力可以关注序列中的所有元素,无论它们是出现在目标元素之前还是之后。但解码器只有部分生成的目标序列。因此,这里的自注意力只能关注前面的序列元素。这通过屏蔽(将输入中的非法连接设置为 −∞)实现,屏蔽的是 softmax 输入中所有非法连接对应的值:

屏蔽操作使解码器变为单向(与双向编码器不同)。与解码器相关的算法被称为Transformer 解码器算法

    • 一个常规的注意力机制,其中查询来自前一个解码器层,键和值来自前一个子层,表示在步骤t-1时处理过的解码器输出。这使得解码器中的每个位置都能关注输入序列中的所有位置。这模拟了典型的编码器-解码器注意力机制,我们在Seq2seq with attention部分中讨论过。

    • 一个前馈网络,类似于编码器中的网络。

  • 解码器以一个全连接层结束,接着是一个 softmax 层,用于生成最有可能的下一个词。

Transformer 使用 dropout 作为正则化技术。在每个子层的输出添加 dropout 之前,它会先将 dropout 添加到子层输入,并进行归一化。它还会对编码器和解码器堆栈中嵌入向量和位置编码的和应用 dropout。

最后,让我们总结一下自注意力相较于我们在Seq2seq with attention部分讨论的 RNN 注意力模型的优势。自注意力机制的关键优点是可以直接访问输入序列的所有元素,而不像 RNN 模型那样有瓶颈的思维向量。此外——以下是来自论文的直接引用——自注意力层通过恒定数量的顺序执行操作连接所有位置,而递归层则需要O(n)次顺序操作。

从计算复杂度上来看,当序列长度n小于表示维度d时,自注意力层比递归层更快,这在机器翻译中,像 word-piece(见Google's Neural Machine Translation System: Bridging the Gap between Human and Machine Translationarxiv.org/abs/1609.08144)和 byte-pair(见Neural Machine Translation of Rare Words with Subword Unitsarxiv.org/abs/1508.07909)表示法的句子表示中最为常见。为了提高处理非常长序列的计算性能,自注意力机制可以限制只考虑输入序列中围绕各自输出位置的大小为r的邻域。

这就结束了我们对 transformer 的理论描述。在下一部分,我们将从头实现一个 transformer。

实现 transformers

在本节中,我们将借助 PyTorch 1.3.1 实现 transformer 模型。由于示例相对复杂,我们将通过使用一个基本的训练数据集来简化它:我们将训练模型复制一个随机生成的整数值序列——也就是说,源序列和目标序列是相同的,transformer 将学习将输入序列复制为输出序列。我们不会包括完整的源代码,但你可以在 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter08/transformer.py 找到它。

本示例基于 github.com/harvardnlp/annotated-transformer。还需要注意的是,PyTorch 1.2 已经引入了原生的 transformer 模块(文档可以在 pytorch.org/docs/master/nn.html#transformer-layers 中找到)。尽管如此,在本节中,我们将从头开始实现 transformer 以便更好地理解它。

首先,我们从实用函数clone开始,该函数接受torch.nn.Module的实例并生成n个相同的深拷贝(不包括原始源实例):

def clones(module: torch.nn.Module, n: int):
    return torch.nn.ModuleList([copy.deepcopy(module) for _ in range(n)])

通过这简短的介绍,让我们继续实现多头注意力。

多头注意力

在本节中,我们将按照Transformer Attention部分的定义实现多头注意力。我们将从实现常规的缩放点积注意力开始:

def attention(query, key, value, mask=None, dropout=None):
    """Scaled Dot Product Attention"""
    d_k = query.size(-1)

    # 1) and 2) Compute the alignment scores with scaling
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)

    # 3) Compute the attention scores (softmax)
    p_attn = torch.nn.functional.softmax(scores, dim=-1)

    if dropout is not None:
        p_attn = dropout(p_attn)

    # 4) Apply the attention scores over the values
    return torch.matmul(p_attn, value), p_attn

作为提醒,这个函数实现了公式 ![],其中 Q = queryK = keyV = value。如果有 mask,它也会被应用。

接下来,我们将作为 torch.nn.Module 实现多头注意力机制。作为提醒,实现遵循以下公式:

我们将从 __init__ 方法开始:

class MultiHeadedAttention(torch.nn.Module):
    def __init__(self, h, d_model, dropout=0.1):
        """
        :param h: number of heads
        :param d_model: query/key/value vector length
        """
        super(MultiHeadedAttention, self).__init__()
        assert d_model % h == 0
        # We assume d_v always equals d_k
        self.d_k = d_model // h
        self.h = h

        # Create 4 fully connected layers
        # 3 for the query/key/value projections
        # 1 to concatenate the outputs of all heads
        self.fc_layers = clones(torch.nn.Linear(d_model, d_model), 4)
        self.attn = None
        self.dropout = torch.nn.Dropout(p=dropout)

请注意,我们使用 clones 函数来创建四个相同的全连接 self.fc_layers。我们将使用其中三个进行 Q/K/V 的线性投影——![]。第四个全连接层用于合并不同头的输出结果的拼接 W^O。我们将当前的注意力结果存储在 self.attn 属性中。

接下来,我们实现MultiHeadedAttention.forward方法(请注意缩进):

def forward(self, query, key, value, mask=None):
    if mask is not None:
        # Same mask applied to all h heads.
        mask = mask.unsqueeze(1)

    batch_samples = query.size(0)

    # 1) Do all the linear projections in batch from d_model => h x d_k
    projections = list()
    for l, x in zip(self.fc_layers, (query, key, value)):
        projections.append(
            l(x).view(batch_samples, -1, self.h, self.d_k).transpose(1, 2)
        )

    query, key, value = projections

    # 2) Apply attention on all the projected vectors in batch.
    x, self.attn = attention(query, key, value,
                             mask=mask,
                             dropout=self.dropout)

    # 3) "Concat" using a view and apply a final linear.
    x = x.transpose(1, 2).contiguous() \
        .view(batch_samples, -1, self.h * self.d_k)

    return self.fc_layers-1

我们遍历 Q/K/V 向量及其参考投影 self.fc_layers,并使用以下代码片段生成 Q/K/Vprojections

l(x).view(batch_samples, -1, self.h, self.d_k).transpose(1, 2)

然后,我们使用我们首次定义的 attention 函数对投影应用常规注意力,最后,我们将多个头的输出拼接并返回结果。现在我们已经实现了多头注意力,让我们继续实现编码器。

编码器

在这一部分,我们将实现编码器,编码器由多个不同的子组件组成。我们先从主要定义开始,然后深入更多的细节:

class Encoder(torch.nn.Module):
    def __init__(self, block: EncoderBlock, N: int):
        super(Encoder, self).__init__()
        self.blocks = clones(block, N)
        self.norm = LayerNorm(block.size)

    def forward(self, x, mask):
        """Iterate over all blocks and normalize"""
        for layer in self.blocks:
            x = layer(x, mask)

        return self.norm(x)

这相当简单:编码器由 self.blocks 组成:N 个堆叠的 EncoderBlock 实例,每个实例作为下一个的输入。它们后面跟着 LayerNorm 归一化 self.norm(我们在 Transformer 模型 部分讨论过这些概念)。forward 方法的输入是数据张量 xmask 的实例,后者会屏蔽掉一些输入序列元素。正如我们在 Transformer 模型 部分讨论的那样,mask 仅与模型的解码器部分相关,其中序列的未来元素尚不可用。在编码器中,mask 仅作为占位符存在。

我们将省略 LayerNorm 的定义(只需知道它是编码器末尾的归一化),我们将专注于 EncoderBlock

class EncoderBlock(torch.nn.Module):
    def __init__(self,
                 size: int,
                 self_attn: MultiHeadedAttention,
                 ffn: PositionwiseFFN,
                 dropout=0.1):
        super(EncoderBlock, self).__init__()
        self.self_attn = self_attn
        self.ffn = ffn

        # Create 2 sub-layer connections
        # 1 for the self-attention
        # 1 for the FFN
        self.sublayers = clones(SublayerConnection(size, dropout), 2)
        self.size = size

    def forward(self, x, mask):
        x = self.sublayers0)
        return self.sublayers1

提醒一下,每个编码器块由两个子层组成(self.sublayers 使用熟悉的 clones 函数实例化):一个多头自注意力 self_attnMultiHeadedAttention 的实例),后跟一个简单的全连接网络 ffnPositionwiseFFN 的实例)。每个子层都被它的残差连接包装,残差连接是通过 SublayerConnection 类实现的:

class SublayerConnection(torch.nn.Module):
    def __init__(self, size, dropout):
        super(SublayerConnection, self).__init__()
        self.norm = LayerNorm(size)
        self.dropout = torch.nn.Dropout(dropout)

    def forward(self, x, sublayer):
        return x + self.dropout(sublayer(self.norm(x)))

残差连接还包括归一化和丢弃(根据定义)。作为提醒,它遵循公式 ![],但为了代码简洁,self.norm 放在前面而不是最后。SublayerConnection.forward 函数的输入是数据张量 xsublayer,后者是 MultiHeadedAttentionPositionwiseFFN 的实例。我们可以在 EncoderBlock.forward 方法中看到这个动态。

唯一尚未定义的组件是 PositionwiseFFN,它实现了公式 ![]。让我们添加这个缺失的部分:

class PositionwiseFFN(torch.nn.Module):
    def __init__(self, d_model: int, d_ff: int, dropout=0.1):
        super(PositionwiseFFN, self).__init__()
        self.w_1 = torch.nn.Linear(d_model, d_ff)
        self.w_2 = torch.nn.Linear(d_ff, d_model)
        self.dropout = torch.nn.Dropout(dropout)

    def forward(self, x):
        return self.w_2(self.dropout(torch.nn.functional.relu(self.w_1(x))))

现在我们已经实现了编码器及其所有构建块。在下一部分,我们将继续解码器的定义。

解码器

在这一部分,我们将实现解码器。它遵循与编码器非常相似的模式:

class Decoder(torch.nn.Module):
    def __init__(self, block: DecoderBlock, N: int, vocab_size: int):
        super(Decoder, self).__init__()
        self.blocks = clones(block, N)
        self.norm = LayerNorm(block.size)
        self.projection = torch.nn.Linear(block.size, vocab_size)

    def forward(self, x, encoder_states, source_mask, target_mask):
        for layer in self.blocks:
            x = layer(x, encoder_states, source_mask, target_mask)

        x = self.norm(x)

        return torch.nn.functional.log_softmax(self.projection(x), dim=-1)

它由self.blocks组成:NDecoderBlock实例,每个块的输出作为下一个块的输入。这些后面是self.norm归一化(LayerNorm的一个实例)。最后,为了生成最可能的单词,解码器有一个额外的全连接层,并使用 softmax 激活。注意,Decoder.forward方法接受一个额外的参数encoder_states,它表示编码器的注意力向量。然后,encoder_states会传递给DecoderBlock实例。

接下来,让我们实现DecoderBlock

class DecoderBlock(torch.nn.Module):
    def __init__(self,
                 size: int,
                 self_attn: MultiHeadedAttention,
                 encoder_attn: MultiHeadedAttention,
                 ffn: PositionwiseFFN,
                 dropout=0.1):
        super(DecoderBlock, self).__init__()
        self.size = size
        self.self_attn = self_attn
        self.encoder_attn = encoder_attn
        self.ffn = ffn

        # Create 3 sub-layer connections
        # 1 for the self-attention
        # 1 for the encoder attention
        # 1 for the FFN
        self.sublayers = clones(SublayerConnection(size, dropout), 3)

    def forward(self, x, encoder_states, source_mask, target_mask):
        x = self.sublayers0)
        x = self.sublayers1)
        return self.sublayers2

这与EncoderBlock类似,但有一个显著的区别:EncoderBlock仅依赖于自注意力机制,而这里我们将自注意力与来自编码器的常规注意力结合在一起。这体现在encoder_attn模块中,以及之后forward方法中的encoder_states参数,和用于编码器注意力值的额外SublayerConnection。我们可以在DecoderBlock.forward方法中看到多个注意力机制的结合。注意,self.self_attnx用作查询/键/值,而self.encoder_attnx作为查询,encoder_states作为键和值。通过这种方式,常规注意力机制在编码器和解码器之间建立了联系。

这就完成了解码器的实现。接下来,我们将在下一节构建完整的 transformer 模型。

将所有内容结合在一起

我们将继续实现主EncoderDecoder类:

class EncoderDecoder(torch.nn.Module):
    def __init__(self,
                 encoder: Encoder,
                 decoder: Decoder,
                 source_embeddings: torch.nn.Sequential,
                 target_embeddings: torch.nn.Sequential):
        super(EncoderDecoder, self).__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.source_embeddings = source_embeddings
        self.target_embeddings = target_embeddings

    def forward(self, source, target, source_mask, target_mask):
        encoder_output = self.encoder(
            x=self.source_embeddings(source),
            mask=source_mask)

        return self.decoder(
            x=self.target_embeddings(target),
            encoder_states=encoder_output,
            source_mask=source_mask,
            target_mask=target_mask)

它结合了EncoderDecodersource_embeddings/target_embeddings(我们稍后会专注于嵌入层)。EncoderDecoder.forward方法接受源序列并将其传递给self.encoder。然后,self.decoder从前一步输出x=self.target_embeddings(target)获取输入,使用编码器状态encoder_states=encoder_output以及源和目标的掩码。使用这些输入,它生成序列中下一个最可能的元素(单词),即forward方法的返回值。

接下来,我们将实现build_model函数,它将我们迄今为止实现的所有内容结合成一个连贯的模型:

def build_model(source_vocabulary: int,
                target_vocabulary: int,
                N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):
    """Build the full transformer model"""
    c = copy.deepcopy
    attn = MultiHeadedAttention(h, d_model)
    ff = PositionwiseFFN(d_model, d_ff, dropout)
    position = PositionalEncoding(d_model, dropout)

    model = EncoderDecoder(
        encoder=Encoder(EncoderBlock(d_model, c(attn), c(ff), dropout), N),
        decoder=Decoder(DecoderBlock(d_model, c(attn), c(attn),c(ff),
                                    dropout), N, target_vocabulary),
        source_embeddings=torch.nn.Sequential(
            Embeddings(d_model, source_vocabulary), c(position)),
        target_embeddings=torch.nn.Sequential(
            Embeddings(d_model, target_vocabulary), c(position)))

    # This was important from their code.
    # Initialize parameters with Glorot / fan_avg.
    for p in model.parameters():
        if p.dim() > 1:
            torch.nn.init.xavier_uniform_(p)

    return model

除了熟悉的MultiHeadedAttentionPositionwiseFFN,我们还创建了position变量(PositionalEncoding类的一个实例)。该类实现了我们在Transformer 模型部分描述的正弦位置编码(这里不包括完整实现)。现在让我们聚焦于EncoderDecoder的实例化:我们已经熟悉了编码器和解码器,所以在这方面没有什么意外。但嵌入层稍微有些不同。以下代码实例化了源嵌入(但对于目标嵌入也是有效的):

source_embeddings=torch.nn.Sequential(Embeddings(d_model, source_vocabulary), c(position))

我们可以看到它们是由两个组件按顺序排列的列表:

  • Embeddings 类的一个实例,它只是 torch.nn.Embedding 的组合,并进一步与 ![] 相乘(这里省略了类的定义)

  • 位置编码 c(position),它将位置的正弦波数据添加到嵌入向量中

一旦我们以这种方式预处理了输入数据,它就可以作为编码器/解码器核心部分的输入。

这就结束了我们对 Transformer 的实现。我们通过这个示例的目的是为了补充 Transformer 注意力Transformer 模型 这两个章节的理论基础。因此,我们专注于代码中最相关的部分,省略了一些 普通 的代码部分,主要是 RandomDataset 随机数列生成器和实现训练的 train_model 函数。然而,我鼓励读者一步一步地执行完整示例,以便更好地理解 Transformer 的工作原理。

在下一节中,我们将讨论一些基于我们迄今为止介绍的注意力机制的最先进的语言模型。

Transformer 语言模型

在第六章,语言模型中,我们介绍了几种不同的语言模型(word2vec、GloVe 和 fastText),它们使用词语的上下文(周围的词语)来创建词向量(嵌入)。这些模型具有一些共同的特性:

  • 它们是无上下文的(我知道这与之前的说法相矛盾),因为它们根据词在训练文本中出现的所有情况创建每个词的单一全局词向量。例如,lead 在短语 lead the waylead atom 中可能有完全不同的含义,但模型会尝试将这两种含义嵌入到同一个词向量中。

  • 它们是无位置的,因为它们在训练嵌入向量时没有考虑上下文词语的顺序。

相比之下,基于 Transformer 的语言模型可以同时依赖上下文和位置。这些模型会根据每个词的独特上下文生成不同的词向量,同时考虑当前上下文中的词和它们的位置。这就导致了经典模型与基于 Transformer 的模型之间的概念性差异。由于像 word2vec 这样的模型创建的是静态、与上下文和位置无关的嵌入向量,我们可以抛弃该模型,仅在后续的下游任务中使用这些向量。但 Transformer 模型会根据上下文生成动态向量,因此我们必须将其作为任务管道的一部分。

在接下来的章节中,我们将讨论一些最新的基于 Transformer 的模型。

双向编码器表示来自 Transformer

双向编码器表示的变换器BERT)(参见 BERT:用于语言理解的深度双向变换器预训练arxiv.org/abs/1810.04805)模型有一个非常描述性的名称。让我们来看一下文中提到的一些元素:

  • 编码器表示:该模型只使用我们在 变换器模型 部分描述的变换器架构中的多层编码器部分的输出。

  • 双向:编码器具有固有的双向特性。

为了更好地理解,我们可以用 L 表示变换器块的数量,用 H 表示隐藏层大小(之前表示为 d[model]),用 A 表示自注意力头的数量。论文的作者尝试了两种 BERT 配置:BERT[BASE](L = 12,H = 768,A = 12,总参数量 = 110M)和 BERT[LARGE](L = 24,H = 1024,A = 16,总参数量 = 340M)。

为了更好地理解 BERT 框架,我们将从训练开始,训练分为两个步骤:

  1. 预训练:该模型在不同的预训练任务上对未标记数据进行训练。

  2. 微调:该模型使用预训练参数初始化,然后在特定下游任务的标注数据集上对所有参数进行微调。

我们可以在以下图表中看到这些步骤:

左:预训练;右:微调 来源:arxiv.org/abs/1810.04805

这些图表将作为接下来章节的参考,所以请继续关注更多细节。现在,我们只需要知道 Tok N 代表经过一热编码的输入标记,E 代表标记嵌入,T 代表模型输出向量。

现在我们对 BERT 有了一个概览,让我们来看一下它的组成部分。

输入数据表示

在深入讨论每个训练步骤之前,让我们先讨论输入和输出数据表示,这些表示是两个步骤共有的。与 fastText(参见第六章,语言建模)有些相似,BERT 使用一种数据驱动的分词算法叫做 WordPiece(arxiv.org/abs/1609.08144)。这意味着,BERT 并不是使用完整单词的词汇表,而是通过迭代过程创建一个子词标记的词汇表,直到该词汇表达到预定的大小(在 BERT 中,大小为 30,000 个标记)。这种方法有两个主要优点:

  • 它使我们能够控制词典的大小。

  • 它通过将未知单词分配给最接近的现有字典子词标记来处理未知词汇。

BERT 可以处理多种下游任务。为此,作者引入了一种特殊的输入数据表示,可以明确无误地将以下内容表示为一个单一输入标记序列:

  • 单个句子(例如,在分类任务中,如情感分析)

  • 一对句子(例如,在问答问题中)

这里,句子不仅指语言学上的句子,还可以指任何长度的连续文本。

模型使用两个特殊词元:

  • 每个序列的第一个词元总是一个特殊的分类词元([CLS])。与该词元对应的隐藏状态被用作分类任务的序列总表示。例如,如果我们想对序列应用情感分析,则对应于[CLS]输入词元的输出将表示模型的情感(正面/负面)输出。

  • 句子对被组合成一个单一的序列。第二个特殊词元([SEP])标记了两个输入句子之间的边界(如果我们有两个句子)。我们还通过为每个词元引入一个额外的学习过的分割嵌入来区分句子,指示该词元属于句子 A 还是句子 B。因此,输入嵌入是词元嵌入、分割嵌入和位置嵌入的总和。在这里,词元和位置嵌入的作用与常规变换器中的作用相同。

下图显示了特殊词元以及输入嵌入:

BERT 输入表示;输入的嵌入是词元嵌入、分割嵌入和位置嵌入的总和。来源:arxiv.org/abs/1810.04805

现在我们知道了输入是如何处理的,让我们来看看预训练步骤。

预训练

预训练步骤在图表左侧的基于变换器的双向编码器表示部分进行了说明。论文的作者使用了两个无监督训练任务来训练 BERT 模型:掩码语言模型MLM)和下一句预测NSP)。

我们从 MLM 开始,模型会接收一个输入序列,其目标是预测该序列中缺失的词。在这种情况下,BERT 充当去噪自编码器,意思是它试图重建其故意损坏的输入。MLM 本质上类似于 word2vec 模型的 CBOW 目标(见第六章,语言建模)。为了解决这个任务,BERT 编码器的输出通过一个带有 softmax 激活的全连接层进行扩展,以根据输入序列产生最可能的词。每个输入序列通过随机掩码 15%的 WordPiece 词元进行修改(根据论文)。为了更好地理解这一点,我们将使用论文中的一个例子:假设未标记的句子是my dog is hairy,并且在随机掩码过程中,我们选择了第四个词元(即hairy),我们的掩码过程可以通过以下几点进一步说明:

  • 80% 的时间:将单词替换为 [MASK] 标记,例如,我的狗毛茸茸的我的狗是 [MASK]

  • 10% 的时间:将单词替换为随机单词,例如,我的狗毛茸茸的我的狗是苹果

  • 10% 的时间:保持单词不变,例如,我的狗毛茸茸的我的狗毛茸茸的。这样做的目的是使表示向实际观察到的词偏置。

因为模型是双向的,[MASK] 标记可以出现在输入序列的任何位置。同时,模型将使用完整序列来预测缺失的单词。这与单向自回归模型相反(我们将在接下来的部分讨论),后者总是试图从所有前面的单词中预测下一个单词,从而避免使用 [MASK] 标记。

我们需要这种 80/10/10 的分布有两个主要原因:

  • [MASK] 标记在预训练和微调之间创建了不匹配(我们将在下一节讨论这一点),因为它仅出现在前者中而不出现在后者中——即,微调任务将向模型呈现不带 [MASK] 标记的输入序列。然而,模型被预训练为期望带有 [MASK] 标记的序列,这可能导致未定义的行为。

  • BERT 假设预测的标记彼此独立。为了理解这一点,让我们想象模型试图重构输入序列 I went [MASK] with my [MASK]。BERT 可以预测句子 I went cycling with my bicycle,这是一个有效的句子。但是因为模型不关联两个被掩盖的单词,它也可以预测 I went swimming with my bicycle,这是无效的。

在 80/10/10 的分布下,transformer 编码器不知道它将被要求预测哪些单词或者哪些单词已被替换为随机单词,因此它被迫保持每个输入标记的分布上下文表示。此外,由于随机替换仅在所有标记的 1.5% 中发生(即 15% 的 10%),这似乎并不会损害模型的语言理解能力。

MLM 的一个缺点是,因为模型仅预测每批次中 15% 的单词,它可能比使用所有单词的预训练模型收敛速度更慢。

接下来,让我们继续进行 NSP。作者认为许多重要的下游任务,如问答 (QA) 和自然语言推理 (NLI),基于对两个句子关系的理解,而这不是语言建模直接捕捉到的。

自然语言推理确定一句话(表示一个假设)在给定另一句话(称为前提)时是真实的(蕴含),错误的(矛盾)还是未确定的(中性)。以下表格展示了一些例子:

前提 假设 标签
我在跑步 我在睡觉 矛盾
我在跑步 我在听音乐 中性
我正在跑步 我正在训练 推理

为了训练一个能够理解句子关系的模型,我们预训练了一个下一个句子预测任务,这个任务可以从任何单语语料库中轻松生成。具体来说,每个输入序列由一个起始的[CLS]标记,后跟两个连接在一起的句子 A 和 B,句子之间用[SEP]标记分隔(请参见基于变换器的双向编码器表示部分的图示)。在为每个预训练示例选择句子 A 和 B 时,50%的时间,B 是实际跟随 A 的下一个句子(标记为IsNext),而 50%的时间,它是来自语料库的随机句子(标记为NotNext)。如前所述,模型会在与[CLS]对应的输入上输出IsNext/NotNext标签。

NSP 任务使用以下示例进行说明:

  • [CLS] 那个人去了 [MASK] 商店 [SEP] 他买了一加仑 [MASK] 牛奶 [SEP],标签为IsNext

  • [CLS] 那个人 [MASK] 去了商店 [SEP] 企鹅 [MASK] 是不会飞的鸟 [SEP],标签为NotNext。请注意##less标记的使用,这是 WordPiece 分词算法的结果。

接下来,让我们看一下微调步骤。

微调

微调任务是在预训练任务的基础上进行的,除了输入预处理,两个步骤非常相似。与创建掩码序列不同,我们只是将任务特定的未修改输入和输出直接输入到 BERT 模型中,并以端到端的方式微调所有参数。因此,在微调阶段使用的模型与我们在实际生产环境中使用的模型是相同的。

以下图示展示了如何使用 BERT 解决几种不同类型的任务:

BERT 在不同任务中的应用;来源:arxiv.org/abs/1810.04805

让我们来讨论一下:

  • 左上角的场景说明了如何使用BERT进行句对分类任务,如自然语言推理(NLI)。简而言之,我们将两个连接在一起的句子输入模型,并只查看[CLS]标记输出的分类结果,该输出将给出模型的结果。例如,在 NLI 任务中,目标是预测第二个句子相对于第一个句子是蕴含、矛盾还是中立。

  • 右上角的场景说明了如何使用BERT进行单句分类任务,如情感分析。这与句对分类非常相似。

  • 左下方的场景展示了如何在 斯坦福问答数据集SQuAD v1.1,rajpurkar.github.io/SQuAD-explorer/explore/1.1/dev/)上使用 BERT。假设序列 A 是一个问题,序列 B 是来自维基百科的段落,包含答案,目标是预测答案在该段落中的文本范围(起始位置和结束位置)。我们引入两个新的向量:一个起始向量 和一个结束向量 ,其中 H 是模型的隐藏层大小。每个单词 i 作为答案范围的起始(或结束)位置的概率通过其输出向量 T[i]S(或 E)的点积计算,然后对序列 B 中所有单词进行 softmax 操作:。从位置 i 开始并延伸到位置 j 的候选范围的得分计算为 。输出候选范围是具有最高得分的那个,其中

  • 右下方的场景展示了如何在 BERT 中进行 命名实体识别NER),其中每个输入标记都会被分类为某种类型的实体。

这部分内容就介绍完了 BERT 模型。作为提醒,它是基于变换器编码器的。下一部分我们将讨论变换器解码器模型。

Transformer-XL

在本节中,我们将讨论一个对传统变换器的改进,叫做 transformer-XL,其中 XL 代表额外的长(见 Transformer-X**L: 超越固定长度上下文的注意力语言模型arxiv.org/abs/1901.02860)。为了理解为什么需要改进常规的变换器,我们先来讨论一下它的一些局限性,其中之一来源于变换器本身的特点。基于 RNN 的模型具有(至少在理论上)传递任意长度序列信息的能力,因为其内部的 RNN 状态会根据所有先前的输入进行调整。但变换器的自注意力机制没有这样的递归组件,它完全限制在当前输入序列的范围内。如果我们有无限的内存和计算能力,一个简单的解决方案是处理整个上下文序列。但在实践中,我们的资源是有限的,因此我们将整个文本划分为更小的片段,并且只在每个片段内训练模型,如下图中的(a)所示:

普通变换器训练(a)和评估(b)的示意图,输入序列长度为 4;注意使用的是单向变换器解码器。来源:https://arxiv.org/abs/1901.02860

横坐标表示输入序列[x[1], ..., x[4]],纵坐标表示堆叠的解码器块。请注意,元素x[i]只能关注元素 。这是因为 transformer-XL 基于变压器解码器(不包括编码器),而与 BERT 不同,BERT 基于编码器。因此,transformer-XL 的解码器与编码器-解码器变压器中的解码器不同,因为它无法访问编码器状态,而常规解码器是可以访问的。从这个意义上讲,transformer-XL 的解码器非常类似于一般的变压器编码器,唯一的区别是它是单向的,因为输入序列掩码的存在。Transformer-XL 是一个自回归模型的例子。

如前面的图示所示,最大的依赖长度是由片段长度上限的,尽管注意力机制通过允许立即访问序列中的所有元素帮助防止梯度消失,但由于输入片段的限制,变压器(transformer)无法充分利用这一优势。此外,文本通常通过选择连续的符号块来拆分,而不考虑句子或任何其他语义边界,论文的作者将其称为上下文碎片化。引用论文中的话,模型缺乏正确预测前几个符号所需的上下文信息,导致优化效率低下和性能不佳。

传统变压器的另一个问题出现在评估过程中,如前面图示的右侧所示。在每一步,模型将完整序列作为输入,但只做出一个预测。为了预测下一个输出,变压器右移一个位置,但新的片段(与最后一个片段相同,唯一不同的是最后的值)必须从头开始在整个输入序列上处理。

现在我们已经识别出变压器模型中的一些问题,接下来让我们看看如何解决这些问题。

片段级别的递归与状态重用

Transformer-XL 在变压器模型中引入了递归关系。在训练过程中,模型会缓存当前片段的状态,当处理下一个片段时,它可以访问该缓存(但固定)值,正如我们在以下图示中看到的那样:

训练(a)和评估(b)变压器-XL 的示意图,输入序列长度为 4。来源:https://arxiv.org/abs/1901.02860

在训练过程中,梯度不会通过缓存的片段传播。我们来正式化这个概念(我们将使用论文中的符号,可能与本章之前的符号稍有不同)。我们用表示两个连续片段的长度为L,用表示另一个片段,用表示第τ个片段的第n层隐藏状态,其中d是隐藏维度(等同于d[model])。为了更清楚,是一个具有L行的矩阵,其中每一行包含输入序列中每个元素的d维自注意力向量。然后,τ+1个片段的第n层隐藏状态是通过以下步骤生成的:

在这里,表示停止梯度,W[*]表示模型参数(之前用W^表示),而表示沿长度维度拼接的两个隐藏序列。为了澄清,拼接后的隐藏序列是一个具有2L行的矩阵,其中每一行包含一个组合输入序列τ和τ+1 的元素的d维自注意力向量。论文很好地解释了前面公式的复杂性,因此以下的解释包含一些直接引用。与标准的 transformer 相比,关键的不同之处在于键和值是基于扩展上下文来条件化的,因此是从前一个片段缓存的(如前面图中的绿色路径所示)。通过将这种递归机制应用于语料库中的每两个连续片段,它实际上在隐藏状态中创建了一个片段级递归。因此,所使用的有效上下文可以远远超出仅仅两个片段。然而,需要注意的是,之间的递归依赖关系在每个片段中都会向下偏移一层。因此,最大的依赖长度会随着层数和片段长度的增加而线性增长——也就是说,O(N* × L),如前面图中的阴影区域所示。

除了实现超长上下文和解决碎片化问题之外,递归机制的另一个好处是显著加快了评估速度。具体来说,在评估过程中,之前片段的表示可以被重复使用,而不是像传统模型那样从头开始计算。

最后,注意到递归方案不需要仅限于前一个片段。从理论上讲,我们可以根据 GPU 内存的大小缓存尽可能多的先前片段,并在处理当前片段时将它们作为额外的上下文进行重用。

递归方案将需要一种新的方法来编码序列元素的位置。接下来,我们将讨论这个话题。

相对位置编码

普通 Transformer 输入会加上正弦位置编码(参见Transformer 模型部分),这些编码仅在当前片段内有效。以下公式展示了如何通过当前的位置信息编码来简要计算状态  和

这里,s[τ]的词嵌入序列,f是转换函数。我们可以看到,我们对  和 使用了相同的位置信息编码 。正因为如此,模型无法区分两个相同位置的元素在不同序列中的位置  和 。为了解决这个问题,论文的作者提出了一种新的相对位置编码方案。他们观察到,当查询向量(或查询矩阵)  注意到键向量 时,查询向量不需要知道每个键向量的绝对位置来识别片段的时间顺序。相反,知道每个键向量  与其自身 之间的相对距离,即i−j,就足够了。

提议的解决方案是创建一组相对位置编码 ,其中第i行的每个单元格表示第i个元素与序列中其他元素之间的相对距离。R使用与之前相同的正弦公式,但这次使用的是相对位置而非绝对位置。这个相对距离是动态注入的(与输入预处理部分不同),这使得查询向量能够区分  和 的位置信息。为了理解这一点,让我们从Transformer 注意力部分的绝对位置注意力公式开始,这个公式可以分解如下:

让我们讨论一下这个公式的组成部分:

  1. 表示词汇i在多大程度上关注词汇j,不考虑它们当前的位置(基于内容的寻址)——例如,词汇tire与词汇car的关系。

  2. 反映了词汇i在多大程度上关注位置j的词,无论那个词是什么(基于内容的定位偏置)——例如,如果词汇icream,我们可能希望检查词汇j = i - 1是否是ice

  3. 这一步是步骤 2 的反向操作。

  4. 表示位置i的词应多大程度上关注位置j的词,无论这两个词是什么(全局定位偏置)——例如,对于相距较远的位置,这个值可能较低。

在 transformer-XL 中,这个公式被修改为包含相对位置嵌入:

让我们概述一下相对于绝对位置公式的变化:

  • 将公式(2)和(4)中用于计算关键向量的绝对位置嵌入U[j]替换为其相对位置嵌入R[i−j]

  • 在公式(3)中,将查询替换为一个可训练的参数。这样做的原因是查询向量对于所有查询位置都是相同的;因此,对不同词汇的注意力偏置应保持一致,无论查询位置如何。类似地,可训练参数替代了公式(4)中的

  • W[K]分为两个权重矩阵W[K,E]W[K,R],以生成分别基于内容和位置的关键向量。

总结一下,segment-level 的循环和相对位置编码是 transformer-XL 相较于普通 transformer 的主要改进。在接下来的部分中,我们将探讨 transformer-XL 的另一个改进。

XLNet

作者指出,具有去噪自编码预训练(如 BERT)的双向模型,相较于单向自回归模型(如 transformer-XL),表现更好。但正如我们在双向编码器表示来自 transformers部分的预训练小节中提到的,[MASK]标记在预训练和微调步骤之间引入了差异。为了解决这些问题,transformer-XL 的作者提出了 XLNet(参见XLNet: 用于语言理解的广义自回归预训练,详见arxiv.org/abs/1906.08237):一种广义的自回归预训练机制,通过最大化所有因式分解顺序的排列期望似然,来实现双向上下文学习。为澄清,XLNet 建立在 transformer-XL 的 transformer 解码器模型之上,并引入了一种基于排列的智能机制,用于在自回归预训练步骤中实现双向上下文流动。

下图展示了模型如何使用不同的因式分解顺序处理相同的输入序列。具体来说,它展示了一个具有两个堆叠块和分段级别递归(mem 字段)的变压器解码器:

在相同的输入序列上,预测不同因式分解顺序下的 x[3]。来源: arxiv.org/abs/1906.08237

对于一个长度为 T 的序列,存在 T! 种有效的自回归因式分解顺序。假设我们有一个长度为 4 的输入序列 [x[1], x[2], x[3], x[4]]。图中展示了该序列的四种可能的 4! = 24 种因式分解顺序(从左上角顺时针开始):[x[3], x[2], x[4], x[1]],[x[2], x[4], x[3], x[1]],[x[4], x[3], x[1], x[2]],以及 [x[1], x[4], x[2], x[3]]。记住,自回归模型允许当前元素只关注序列中的前一个元素。因此,在正常情况下,x[3] 只能关注 x[1]x[2]。但 XLNet 算法不仅用常规的序列训练模型,还用该序列的不同因式分解顺序进行训练。因此,模型将“看到”所有四种因式分解顺序以及原始输入。例如,在 [x[3], x[2], x[4], x[1]] 中,x[3] 将无法关注其他任何元素,因为它是第一个元素。或者,在 [x[2], x[4], x[3], x[1]] 中,x[3] 将能够关注 x[2]x[4]。在之前的情况下,x[4] 是无法访问的。图中的黑色箭头指示了 x[3] 根据因式分解顺序可以关注的元素(不可用的元素没有箭头)。

但是,这怎么可能有效?当序列如果不按自然顺序排列会失去意义时,训练的意义又何在呢?为了回答这个问题,我们需要记住,变压器没有隐式的递归机制,而是通过显式的位置信息编码来传递元素的位置。还要记住,在常规的变压器解码器中,我们使用自注意力掩码来限制对当前序列元素后续元素的访问。当我们输入一个具有其他因式分解顺序的序列,例如 [x[2], x[4], x[3], x[1]],序列的元素将保持其原始的位置编码,变压器不会丢失它们的正确顺序。事实上,输入仍然是原始序列 [x[1], x[2], x[3], x[4]],但有一个修改过的注意力掩码,只允许访问元素 x[2]x[4]

为了形式化这个概念,我们引入一些符号:是长度为T的索引序列[1, 2, . . . , T]的所有可能排列的集合;的一个排列;是该排列的第t个元素;是该排列的前t-1个元素,而是给定当前排列下,下一单词的概率分布(自回归任务,即模型的输出),其中θ是模型参数。然后,排列语言建模目标如下:

它一次性采样输入序列的不同因式分解顺序,并试图最大化概率—也就是说,增加模型预测正确单词的概率。参数θ在所有因式分解顺序中是共享的;因此,模型能够看到每一个可能的元素,从而模拟双向上下文。同时,这仍然是一个自回归函数,不需要[MASK]标记。

为了充分利用基于排列的预训练,我们还需要一个组成部分。我们首先定义给定当前排列(自回归任务,即模型的输出)下,下一单词的概率分布,这简单来说就是模型的 softmax 输出:

这里,作为查询,是经过适当掩蔽处理后由变换器生成的隐藏表示,充当键值数据库。

接下来,假设我们有两种因式分解顺序  和 ,其中前两个元素相同,后两个元素交换。我们还假设 t = 3 ——也就是说,模型需要预测序列中的第三个元素。由于 ,我们可以看到  在两种情况下是相同的。因此, 。但是这是一个无效的结果,因为在第一种情况下,模型应该预测 x[4],而在第二种情况下,应该预测 x[1]。让我们记住,尽管我们在位置 3 预测了 x[1] 和 x[4],但它们仍然保持其原始的位置编码。因此,我们可以修改当前的公式,加入预测元素的位置相关信息(对于 x[1] 和 x[4],这个信息是不同的),但排除实际单词。换句话说,我们可以将模型的任务从 预测下一个单词 修改为 预测下一个单词,前提是我们知道它的位置。这样,两种样本因式分解顺序的公式将不同。修改后的公式如下:

在这里,  是新的变换器函数,它还包括位置相关信息 。论文的作者提出了一种名为“两流自注意力”的特殊机制来解决这个问题。顾名思义,它由两种结合的注意力机制组成:

  • 内容表示 ![],这就是我们已经熟悉的注意力机制。该表示既编码上下文,也编码内容本身 ![]。

  • 查询表示 ,它只访问上下文信息  和位置 ,但无法访问内容 ![],正如我们之前提到的。

我建议你查阅原文以了解更多详细信息。在下一节中,我们将实现一个变换器语言模型的基本示例。

使用变换器语言模型生成文本

在这一节中,我们将借助transformers 2.1.1 库(huggingface.co/transformers/),由 Hugging Face 发布,实现一个基本的文本生成示例。这个库是一个维护良好的流行开源包,包含了不同的 Transformer 语言模型,如 BERT、transformer-XL、XLNet、OpenAI GPT、GPT-2 等。我们将使用一个预训练的 transformer-XL 模型,根据初始输入序列生成新的文本。目标是让你简要了解这个库:

  1. 我们从导入开始:
import torch
from transformers import TransfoXLLMHeadModel, TransfoXLTokenizer

TransfoXLLMHeadModelTransfoXLTokenizer是 transformer-XL 语言模型及其相应分词器的实现。

  1. 接下来,我们将初始化设备并实例化modeltokenizer。请注意,我们将使用库中可用的transfo-xl-wt103预训练参数集:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# Instantiate pre-trained model-specific tokenizer and the model itself
tokenizer = TransfoXLTokenizer.from_pretrained('transfo-xl-wt103')
model = TransfoXLLMHeadModel.from_pretrained('transfo-xl-wt103').to(device)
  1. 然后,我们将指定初始序列,对其进行分词,并将其转化为一个与模型兼容的输入tokens_tensor,该输入包含一个 token 列表:
text = "The company was founded in"
tokens_tensor = \
    torch.tensor(tokenizer.encode(text)) \
        .unsqueeze(0) \
        .to(device)
  1. 接下来,我们将使用这个 token 来启动一个循环,在这个循环中,模型将生成序列的新 token:
mems = None  # recurrence mechanism

predicted_tokens = list()
for i in range(50):  # stop at 50 predicted tokens
    # Generate predictions
    predictions, mems = model(tokens_tensor, mems=mems)

    # Get most probable word index
    predicted_index = torch.topk(predictions[0, -1, :], 1)[1]

    # Extract the word from the index
    predicted_token = tokenizer.decode(predicted_index)

    # break if [EOS] reached
    if predicted_token == tokenizer.eos_token:
        break

    # Store the current token
    predicted_tokens.append(predicted_token)

    # Append new token to the existing sequence
    tokens_tensor = torch.cat((tokens_tensor, predicted_index.unsqueeze(1)), dim=1)

我们从初始的 token 序列tokens_tensor开始循环。模型利用它生成predictions(对词汇表中所有 token 的 softmax)和mems(存储先前隐藏解码器状态的变量,用于递归关系)。我们提取出最可能的词的索引predicted_index,并将其转换为一个词汇表 tokenpredicted_token。然后,我们将其附加到现有的tokens_tensor中,并使用新序列重新启动循环。循环将在生成 50 个 token 后结束,或者当遇到特殊的[EOS] token 时结束。

  1. 最后,我们将展示结果:
print('Initial sequence: ' + text)
print('Predicted output: ' + " ".join(predicted_tokens))

程序的输出如下:

Initial sequence: The company was founded in
Predicted output: the United States .

通过这个示例,我们结束了关于注意力模型的长篇讨论。

摘要

在本章中,我们重点讨论了 seq2seq 模型和注意力机制。首先,我们讨论并实现了一个常规的循环编码器-解码器 seq2seq 模型,并学习了如何用注意力机制来补充它。接着,我们讨论并实现了一种纯粹基于注意力机制的模型,叫做Transformer。我们还在其背景下定义了多头注意力。然后,我们讨论了 transformer 语言模型(如 BERT、transformerXL 和 XLNet)。最后,我们实现了一个简单的文本生成示例,使用transformers库。

本章结束了我们关于自然语言处理的系列章节。在下一章中,我们将讨论一些尚未完全成熟,但对未来充满巨大潜力的深度学习新趋势。

第四部分:展望未来

在本节中,我们将讨论一些尚未广泛应用的最新深度学习技术,但它们仍然具有很大的潜力。

本节包含以下章节:

  • 第九章,新兴神经网络设计

  • 第十章,元学习

  • 第十一章,深度学习与自动驾驶车辆

第九章:新兴的神经网络设计

本章将介绍一些新兴的神经网络NN)设计。这些设计尚未成熟,但由于它们尝试解决现有深度学习算法中的基本局限性,因此具有未来潜力。如果这些技术在某一天能够证明在实际应用中是成功且有用的,我们可能会更接近人工通用智能。

我们需要牢记的一点是结构化数据的性质。到目前为止,在本书中,我们专注于处理图像或文本——换句话说,就是非结构化数据。这并非巧合,因为神经网络擅长于从像素或文本序列的组合中找到结构,而这一任务看似复杂。另一方面,机器学习算法,如梯度提升树或随机森林,在处理结构化数据(如社交网络图或大脑连接)时,表现似乎与神经网络相当,甚至更好。在本章中,我们将介绍图神经网络,用以处理任意结构化的图。

另一个神经网络的局限性在于递归神经网络RNN)。理论上,它们是最强大的神经网络模型之一,因为它们是图灵完备的,这意味着 RNN 理论上可以解决任何计算问题。然而,实际中往往并非如此。RNN(即使是长短期记忆网络LSTM))在长时间跨度上保持信息的能力往往较弱。一个可能的解决方案是通过外部可寻址内存扩展 RNN。本章将介绍如何做到这一点。

本章中的主题与本书其他部分的内容是紧密相关的。事实上,我们将看到,接下来要探讨的新的网络架构是基于我们之前已经讨论过的许多算法。这些算法包括卷积、RNN 和注意力模型等。

本章将涵盖以下主题:

  • 引入图神经网络

  • 引入内存增强神经网络

引入图神经网络

在学习图神经网络GNN)之前,让我们先了解一下我们为什么需要图网络。我们从定义图开始,图是由一组对象(也称为节点顶点)组成的,其中一些对象之间存在连接(或)。

在本节中,我们将使用几篇调查论文作为参考资料,最著名的是《图神经网络的综合调查》(arxiv.org/abs/1901.00596),其中包含了一些引用和图片。

一个图具有以下属性:

  • 我们将图表示为 ,其中 V 是节点集合,E 是边集合。

  • 表达式  描述了两个节点之间的边, 

  • 邻接矩阵,,其中 n 是图的节点数。如果存在边 ,则写作 ;如果没有,则写作

  • 图可以是循环的非循环的。顾名思义,循环图包含至少一个环路,即一个非空的节点路径,其中只有第一个节点和最后一个节点是相同的。非循环图不包含环路。

  • 图可以是有向的无向的。顾名思义,有向图的边是有方向的,而无向图的边没有方向。无向图的邻接矩阵是对称的——即 。有向图的邻接矩阵是非对称的——即

  • 图的边和节点都可以有相关联的属性,称为特征向量。我们用 来表示节点 vd 维特征向量。如果图有 n 个节点,我们可以将它们表示为一个矩阵 。类似地,每条边的属性是一个 c 维的特征向量,表示为 ,其中 vu 是节点。我们可以将图的所有边的属性表示为一个矩阵

以下图示显示了一个有五个节点的有向图及其对应的邻接矩阵:

一个有五个节点的有向图及其对应的邻接矩阵

图是一种多功能的数据结构,非常适合现实世界中数据的组织方式。以下是一些非详尽的示例:

  • 我们可以使用图来表示社交网络中的用户(节点)及其朋友关系(边)。事实上,这正是 Facebook 用其社交图谱(The Anatomy of the Facebook Social Grapharxiv.org/abs/1111.4503)所做的。

  • 我们可以将分子表示为图,其中节点是原子,边是它们之间的化学键。

  • 我们可以将街道网络(一个经典例子)表示为图,其中街道是边,交叉口是节点。

  • 在在线商业中,我们可以将用户和商品表示为节点,它们之间的关系则表示为边。

接下来,让我们讨论一下可以用图形解决的任务类型。它们大致分为三类:

  • 节点聚焦:对单个节点进行分类和回归。例如,在著名的扎卡里武术俱乐部问题中(en.wikipedia.org/wiki/Zachary%27s_karate_club),我们有多个武术俱乐部成员(节点)以及他们之间的友谊(边)。最初,俱乐部只有一名教练,所有成员都在该教练的带领下作为一个小组进行训练。后来,俱乐部分裂成两个小组,每个小组有一名教练。假设除了最后一名成员外,所有人都选择加入这两个小组中的一个,目标是根据该成员与其他成员的友谊来确定他会选择加入哪个小组(分类任务)。

  • 边聚焦:对图的单个边进行分类和回归。例如,我们可以预测社交网络中两个人彼此认识的可能性。换句话说,任务是确定两个图节点之间是否存在边。

  • 图聚焦:对整个图进行分类和回归。例如,给定一个表示分子结构的图,我们可以预测这个分子是否有毒。

接下来,让我们概述 GNN 的主要训练框架:

  • 监督学习:所有训练数据都有标签。我们可以在节点、边和图级别应用监督学习。

  • 无监督学习:目标是学习某种形式的图嵌入——例如,使用自编码器(我们将在本章稍后讨论这种情况)。我们可以在节点、边和图级别应用无监督学习。

  • 半监督学习:这通常应用于节点级别,其中一些图节点是已标注的,而另一些则不是。半监督学习特别适用于图,因为我们可以做一个简单(但往往正确)的假设:相邻的节点很可能具有相同的标签。例如,假设我们有两个相邻的连接节点,其中一个包含一辆汽车的图片,另一个包含一辆卡车的图片。假设卡车节点被标记为“车辆”,而汽车节点没有标注。我们可以安全地假设汽车节点也是“车辆”,因为它靠近另一个车辆节点(卡车)。我们可以在图神经网络(GNNs)中利用这一图特性有多种方法。我们将概述其中的两种方法(它们并非相互排斥):

    • 隐式地利用这一特性,通过将图的邻接矩阵作为输入提供给网络。网络将发挥其魔力,并希望推断出相邻节点很可能具有相同的标签,从而利用附加信息提高预测的准确性。我们将在本章讨论的大多数 GNN 都使用这一机制。

    • 标签传播,我们可以使用已标注的节点作为种子,基于它们与已标注节点的接近关系为未标注节点分配标签。我们可以通过以下步骤以迭代方式执行,直到收敛:

      1. 从种子标签开始。

      2. 对所有图节点(除了种子节点),根据其邻居节点的标签分配标签。此步骤会为整个图创建一个新的标签配置,其中某些节点可能需要根据修改后的邻居标签重新分配标签。

      3. 如果满足收敛准则,则停止标签传播;否则,重复步骤 2。

我们将利用这段简短的图介绍作为基础,进入接下来的几节内容,我们将讨论各种类型的图集中神经网络模型。GNN 领域相对较新,目前尚未出现与计算机视觉中的卷积网络CNNs)类似的完美模型。相反,我们有不同的模型,它们具有各种不同的属性。大多数模型可归为几类,并且有人尝试创建一个足够通用的框架来将它们结合在一起。本书的目的是介绍一些现有的模型,而非发明新的模型或模型分类法。

循环图神经网络(Recurrent GNNs)

我们将通过介绍图神经网络GraphNNs;参见图神经网络模型ieeexplore.ieee.org/document/4700287)来开始本节内容。尽管论文的作者将该模型缩写为 GNN,但为了避免与 GNN 这一通用图网络类别的缩写发生冲突,我们将使用 GraphNN 这一缩写。该模型是最早提出的 GNN 模型之一,它将现有的神经网络扩展到图结构数据的处理。就像我们利用单词的上下文(即它的周围词)来创建词嵌入向量(第六章,语言建模)一样,我们也可以利用一个节点的邻居图节点来做到这一点。GraphNNs 旨在基于节点的邻居创建一个s维的节点状态向量 。类似于语言建模,向量状态可以作为其他任务的输入,例如节点分类。

节点的状态通过递归交换邻居信息来更新,直到达到稳定的平衡状态。我们用 表示邻居节点的集合,用u表示该邻居集合中的一个单独节点。节点的隐状态通过以下公式递归更新:

这里,f 是一个参数化函数(例如,前馈神经网络FFNN)),每个状态 是随机初始化的。参数化函数 fv 的特征向量 为输入,u 的邻居特征向量 ,连接 uv 的边特征向量 ,以及 u 在第 t-1 步的状态向量 。换句话说,f 使用所有关于 v 邻域的已知信息。表达式 是对所有邻近节点应用 f 的总和,这使得 GraphNN 不依赖于邻居的数量和顺序。f 函数在整个过程的每个步骤中都是相同的(即具有相同的权重)。

注意,我们有一个迭代(或递归)过程,其中第 t 步的状态基于直到 t-1 步的步骤数,如下所示:

特征向量状态更新的递归过程;Grec 递归层在所有步骤中都是相同的(即具有相同的权重);来源:https://arxiv.org/abs/1901.00596

该过程将继续,直到达到稳定平衡。为了使其有效,函数 f 必须是一个收缩映射。让我们澄清一下:当应用于任意两个点(或值)A 和 B 时,收缩映射函数 f 满足条件 ,其中 γ 是一个标量值,且 。换句话说,收缩映射在映射后缩小两个点之间的距离。这确保了系统会(以指数速度)收敛到平衡状态向量 ,对于任何初始值 。我们可以修改一个神经网络成为一个收缩函数,但这超出了本书的范围。

现在我们有了隐藏状态,可以将其用于节点分类等任务。我们可以用以下公式表示:

在这个方程中, 是达到平衡时的状态,而 g 是一个参数化函数——例如,一个带有 softmax 激活的全连接层,用于分类任务。

接下来,让我们看一下如何训练 GraphNN,给定一些或所有图节点的训练标签 t[i] 以及大小为 m 的小批量。训练 GraphNN 的步骤如下:

  1. 计算 o[v] 对所有 m 个节点,遵循我们刚刚描述的递归过程。

  2. 计算代价函数(t[i] 是节点 i 的标签):

  1. 反向传播代价。请注意,通过将第 1 步的节点状态更新与当前步骤的梯度传播交替,可以让 GraphNN 处理循环图。

  2. 更新组合网络的权重g(f)

GraphNN 有几个局限性,其中之一是计算平衡状态向量效率不高。此外,正如我们在本节之前提到的,GraphNN 在所有步骤t中使用相同的参数(权重)来更新。相比之下,其他 NN 模型可以使用多个堆叠的层,每一层都有不同的权重集合,这使我们能够捕捉数据的层次结构。它还允许我们在单次前向传播中计算。最后,值得一提的是,尽管计算是一个递归过程,但 GraphNN 并不是一个递归网络。

门控图神经网络GGNNarxiv.org/abs/1511.05493)试图通过门控递归单元GRU;更多信息请参见第七章,理解递归网络)单元来克服这些局限性,作为递归函数。我们可以将 GGNN 定义为以下形式:

在前面的公式中,。为澄清,GGNN 基于同一步骤t的邻接状态和前一个隐藏状态更新状态。

从历史的角度来看,GraphNN 是最早的 GNN 模型之一。但正如我们之前提到的,它们存在一些局限性。在下一节中,我们将讨论卷积图神经网络,它是更近期的发展。

卷积图神经网络

卷积图神经网络ConvGNN)使用一组特殊的图卷积层(Gconv)在更新状态向量时对图的节点进行卷积。与 GraphNN 类似,图卷积通过节点的邻居生成其向量表示。但与 GraphNN 在计算的所有步骤t*中使用相同的层(即相同的权重集合)不同,ConvGNN 在每个步骤中使用不同的层。两者方法的区别在下面的图中进行了说明:

上图:GraphNN 在所有步骤 t 上使用相同的 Grec 递归层;下图:GCN 为每个步骤使用不同的 Gconv[*]层;来源:arxiv.org/abs/1901.00596

使用 ConvGNN,步骤数 t 由网络的深度定义。尽管我们将从略有不同的角度讨论这个问题,ConvGNN 行为上与常规的 FFNN 类似,但进行了图卷积。通过堆叠多个层,每个节点的最终隐藏表示从更远的邻域接收信息,就像下图所示:

上图:节点级分类 GraphCN;下图:图级分类 GraphCN。来源:arxiv.org/abs/1901.00596

图示展示了两种场景:

  • 节点级(上图),每个卷积层(包括最后一个)的输出是图中每个节点的一个向量。我们可以对这些向量执行节点级操作。

  • 图级(下图),交替进行图卷积和池化操作,并以读取层结束,之后是几个全连接层,这些层汇总整个图形并产生单一输出。

现在我们已经对 ConvGNN 有了高层次的了解,在接下来的部分中,我们将讨论图卷积(之后会讨论读取和池化层)。

基于谱的卷积

图卷积有多种类型(可以查看 图神经网络的综合调查),但在本节中,我们将讨论来自 半监督分类与图卷积网络 的算法(arxiv.org/abs/1609.02907)。为了避免与一般的 ConvGNN 表示法混淆,我们将此卷积称为 GCN。GCN 是所谓的 基于谱的 ConvGNN 类别的代表。这些算法通过从图信号处理的角度引入滤波器来定义图卷积,其中图卷积操作被解释为从图信号中去除噪声。

图神经网络 部分,我们定义了隐藏节点状态 ,并注意到在 GGNN 的情况下 。我们通过将图中所有节点的隐藏向量状态堆叠成一个矩阵 ,其中 n 是图中节点的总数,d 是特征向量的大小。矩阵的每一行表示单个节点的隐藏状态。然后,我们可以为单个 GCN 层在步骤 l+1 定义通用公式如下:

这里,A 是邻接矩阵,f 是一个非线性激活函数,例如 ReLU,(特征向量矩阵)。由于 的大小相同, 具有与节点特征矩阵 X 相同的维度(见 图神经网络 部分)。然而,,其中 z 是隐状态向量 的大小,并不一定与初始的 d 相同。

让我们继续用一个简化但具体的 GCN 版本。

这里, 是权重矩阵,σ 是 sigmoid 函数。由于邻接矩阵 A 以矩阵形式表示图形,我们可以通过一次运算计算该层的输出。 运算允许每个节点接收来自其邻居节点的输入(这也使得 GCN 可以处理有向图和无向图)。让我们通过一个例子来看看它是如何工作的。我们将使用在 图神经网络 部分介绍的五节点图。为了易于阅读,我们为每个节点分配一个一维向量隐状态 ,其值等于节点编号 。然后,我们可以用以下公式计算该例子:

我们可以看到 ,因为它接收来自节点 2、3 和 5 的输入。如果 有更多维度,那么输出向量的每个单元格将是输入节点的状态向量对应单元格的总和:

这里, 是邻接矩阵的单元格。

尽管这个解决方案很优雅,但它有两个限制:

  • 并非所有节点都接收来自自身前一状态的输入。在前面的例子中,只有节点 2 从自身接收输入,因为它有一个环路边(这是将节点与自身连接的边)。解决这个问题的方法是通过将邻接矩阵主对角线上的所有值设置为 1,来人为地为所有节点创建环路边:。在这个方程中,I 是单位矩阵,它的主对角线为 1,其它单元格为 0。

  • 由于A没有归一化,邻居数量较多的节点的状态向量会与邻居数量较少的节点发生不同的尺度变化。在之前的例子中,我们可以看到,相对于其他节点来说较大,因为节点 4 的邻域有 3 个节点。解决这个问题的方法是对邻接矩阵进行归一化,使得一行中所有元素的和为 1:。我们可以通过将A与逆度矩阵D^(-1)相乘来实现这一点。度矩阵D是一个对角矩阵(即,除了主对角线外,所有其他元素均为零),其中包含每个节点的度数信息。我们将节点的邻居数量称为该节点的度数。例如,我们示例图的度矩阵如下所示:

因此,D^(-1)A变为以下形式:

这个机制给每个邻居节点分配相同的权重。实际上,论文的作者发现,使用对称归一化的效果更好。

在我们结合了这两个改进后,GCN 公式的最终形式可以写成如下:

请注意,我们刚刚描述的 GCN 仅包括节点的直接邻域作为上下文。每一层堆叠都有效地将节点的感受野扩展到其直接邻居以外的区域 1 层。ConvGNN 第二层的感受野包括直接邻居,第二层的感受野包括当前节点两跳远的节点,依此类推。

在下一节中,我们将讨论图卷积操作的第二大类,即基于空间的卷积。

带注意力的基于空间的卷积

第二类 ConvGNN 是基于空间的方法,它们的灵感来源于计算机视觉中的卷积(第二章,理解卷积神经网络)。我们可以把图像看作一个图,其中每个像素都是一个节点,直接连接到其相邻的像素(下图中的左侧图像)。例如,如果我们使用 3 × 3 作为滤波器,每个像素的邻域由八个像素组成。在图像卷积中,这个 3 × 3 的加权滤波器被应用到 3 × 3 的区域,结果是所有九个像素强度的加权和。同样,基于空间的图卷积将中心节点的表示与其邻居的表示进行卷积,从而得到中心节点的更新表示,如下图右侧所示:

左图:在像素网格上的二维卷积;右图:空间图卷积。来源:arxiv.org/abs/1901.00596

通用的基于空间的卷积在某种程度上类似于 GCN,因为这两种操作都依赖于图的邻居。GCN 使用逆度矩阵为每个邻居分配权重,而空间卷积则使用卷积滤波器来完成同样的任务。两者的主要区别在于,GCN 的权重是固定的并且已经归一化,而空间卷积的滤波器权重是可学习的,并且没有归一化。从某种意义上说,我们也可以将 GCN 视为一种基于空间的方法。

我们将在本节继续讨论一种特定类型的基于空间的模型,称为图注意力网络GAT)(更多信息,请访问 arxiv.org/abs/1710.10903),它通过一个特殊的图自注意力层实现图卷积。与学习卷积滤波器或使用平均邻接矩阵作为 GCN 不同,GAT 使用自注意力机制的注意力得分为每个邻近节点分配权重。GAT 层是图注意力网络的主要构建模块,图注意力网络由多个堆叠的 GAT 层组成。与 GCN 一样,每增加一层,目标节点的感受野也会增加。

类似于 GCN,GAT 层输入一组节点特征向量 ,并输出一组不同的特征向量 ,这些输出特征向量的个数不一定与输入的相同。按照我们在第八章《序列到序列模型与注意力》一节中概述的过程,操作开始时会计算特征向量之间的对齐得分 ,这些向量对应着每两个节点的邻域关系:

在这里, 是一个权重矩阵,它将输入向量转换为输出向量的个数,并提供必要的可学习参数。表达式 f[a] 是一个简单的前馈神经网络(FFN),具有单层和 LeakyReLU 激活函数,它由权重向量 参数化,并实现加性注意力机制:

这里,表示拼接。如果我们不施加任何限制,每个节点将能够关注图中所有其他节点,无论它们与目标节点的距离如何;然而,我们只对邻居节点感兴趣。GAT 的作者建议通过使用掩蔽注意力来解决这个问题,其中掩蔽覆盖了所有不是目标节点直接邻居的节点。我们将节点i的直接邻居表示为

接下来,我们通过使用 softmax 计算注意力得分。以下是通用公式和带有f[a]的公式(仅应用于直接邻居):

一旦我们得到注意力得分,就可以使用它们来计算每个节点的最终输出特征向量(我们在第八章,序列到序列模型与注意力中称之为上下文向量),它是所有邻居输入特征向量的加权组合:

这里,σ是 sigmoid 函数。论文的作者还发现多头注意力有利于模型的性能:

这里,k是每个头的索引(总共有K个头),是每个注意力头的注意力得分,是每个注意力头的权重矩阵。由于是拼接的结果,它的基数将是k × z[l+1]。因此,在网络的最终注意力层中无法进行拼接。为了解决这个问题,论文的作者建议在最后一层中平均注意力头的输出(用索引L表示):

下图展示了在 GAT 上下文中常规注意力和多头注意力的比较。在左侧的图像中,我们可以看到应用于两个节点ij之间的常规注意力机制。在右侧的图像中,我们可以看到节点1与其邻域之间的多头注意力,k = 3表示有 3 个头。聚合后的特征可以是拼接(对于所有隐藏的 GAT 层)或者平均(对于最终的 GAT 层):

左:两个节点之间的常规注意力;右:节点 1 与其邻域之间的多头注意力。来源:https://arxiv.org/abs/1710.10903

一旦我们得到最终 GAT 层的输出,就可以将其作为输入传递到下一个特定任务的层。例如,这可能是一个带有 softmax 激活的全连接层,用于节点分类。

在我们总结这一节关于 ConvGNN 的内容之前,让我们讨论一下两个我们尚未涉及的最后组成部分。第一个是我们在卷积 网络部分开头的图级分类示例中介绍的读出层。它的输入是最后一层图卷积层的所有节点状态H^((L)),并输出一个总结整个图的单一向量。我们可以将其正式定义如下:

在这里,G表示图的节点集合,R是读出函数。实现它的方法有很多种,但最简单的方法是对所有节点状态进行逐元素求和或求平均。

我们将要看待的下一个(也是最后一个)ConvGNN 组件是池化操作。再次强调,有多种方法可以使用池化操作,但最简单的一种是像我们在计算机视觉卷积中使用的最大池化/平均池化操作:

在这里,p表示池化窗口的大小。如果池化窗口包含整个图,则池化操作变得类似于读出操作。

这就结束了我们关于 ConvGNN 的讨论。在下一节中,我们将讨论图自编码器,它提供了一种生成新图的方法。

图自编码器

让我们快速回顾一下自编码器,我们在第五章的生成模型中首次介绍过。自编码器是一个前馈神经网络(FFN),它试图重建其输入(更准确地说,它试图学习一个恒等函数,)。我们可以将自编码器看作是两个组件的虚拟组合——编码器,它将输入数据映射到网络的内部潜在特征空间(表示为向量z),以及解码器,它试图从网络的内部数据表示中重建输入。我们可以通过最小化一个损失函数(称为重建误差)以无监督的方式训练自编码器,该损失函数衡量原始输入与其重建之间的距离。

图自编码器GAE)类似于自编码器,区别在于编码器将图节点映射到自编码器的潜在特征空间,然后解码器尝试从中重建特定的图特征。在这一节中,我们将讨论一种 GAE 的变体,该变体在《变分图自编码器》(arxiv.org/abs/1611.07308)中介绍,同时也概述了 GAE 的变分版本(VGAE)。下图展示了一个 GAE 的示例:

图自编码器的示例。来源:arxiv.org/abs/1901.00596

编码器是我们在 基于谱的卷积 部分定义的 GCN 模型,用来计算图节点的网络嵌入!,其中每个 n 个节点的嵌入是一个 d 维向量 z。它以邻接矩阵 A 和节点特征向量集合 X 作为输入(像本章讨论的其他 GNN 模型一样)。编码器由以下公式表示:

在这里,W[1]W[2] 是两个 GCN 图卷积层的可学习参数(权重),f 是一个非线性激活函数,如 ReLU。论文的作者使用了两个图卷积层,尽管所提出的算法可以适用于任何数量的层。

解码器试图重建图的邻接矩阵

在这里,σ 是 sigmoid 函数。它首先计算 Z 和其转置之间的点积(或内积):。为了明确,这个操作计算了图中每个节点 i 的向量嵌入 z[i] 与所有其他节点 j 的向量嵌入 z[j] 之间的点积,如下例所示:

正如我们在 第一章 《神经网络的基础》 中提到的,我们可以将点积看作是向量之间的相似度度量。因此, 测量了每对节点之间的距离。这些距离为重建过程提供了基础。之后,解码器应用一个非线性激活函数,继续重建图的邻接矩阵。我们可以通过最小化实际重建邻接矩阵之间的差异来训练 GAE。

接下来,让我们关注 变分图自编码器VGAE)。与我们在 第五章 《生成模型》 中讨论的 变分自编码器VAE)类似,VGAE 是一种生成模型,可以生成新的图(更具体地说,是新的邻接矩阵)。为了理解这一点,我们先回顾一下 VAE。与常规自编码器不同,VAE 的瓶颈层不会直接输出潜在状态向量。而是会输出两个向量,描述潜在向量 z 的分布的 均值 μ 和 方差 σ。我们将使用它们从高斯分布中采样一个与 z 相同维度的随机向量 ε。更具体地说,我们将通过潜在分布的均值 μ 来偏移 ε,并通过潜在分布的方差 σ 来缩放它:

这项技术被称为重参数化技巧,它使得随机向量具有与原始数据集相同的均值和方差。

我们可以将 VGAE 看作 GAE 和 VAE 的结合体,因为它处理图输入(类似于 GAE),并遵循相同的原理生成新数据(像 VAE 一样)。首先,让我们关注编码器,它被分成两个路径:

这里,权重W[0]在各路径之间共享,是对称归一化的邻接矩阵,μ是均值向量μ[i]的矩阵,σ是每个图节点的方差矩阵σ[i]。然后,完整图的编码器推理步骤被定义为所有图节点i的潜在表示的内积:

在这个公式中,n是图中的节点数,表示解码器对真实概率分布的近似,其中φ是网络参数(在这里,我们保留了第五章《生成模型》中的符号表示)。该近似是一个高斯分布,节点特定的均值为μ[i],对角协方差值为

接下来,我们定义生成步骤,生成新的邻接矩阵。它是随机潜在向量的内积:

这里,表示节点i和节点j之间是否存在边,表示解码器对真实概率的近似。我们可以使用已经熟悉的 VAE 代价来训练 VGAE:

这里,第一项是 Kullback–Leibler 散度,第二项是重构代价。

这就是我们对 GAE 和 VGAE 的描述。在接下来的章节中,我们将讨论另一种图学习范式,它使得可以将结构化和非结构化数据作为网络输入进行混合。

神经图学习

在本节中,我们将描述神经图学习范式(NGL)(更多信息请参见Neural Graph Learning: Training Neural Networks Using Graphs,链接:storage.googleapis.com/pub-tools-public-publication-data/pdf/bbd774a3c6f13f05bf754e09aa45e7aa6faa08a8.pdf),它使得基于非结构化数据的训练能够与结构化信号相结合。更具体地说,我们将讨论基于 TensorFlow 2.0 的神经结构化学习框架(NSL)(更多信息,请访问www.tensorflow.org/neural_structured_learning/),该框架实现了这些原理。

为了理解 NGL 是如何工作的,我们将使用 CORA 数据集(relational.fit.cvut.cz/dataset/CORA),该数据集由 2,708 篇科学出版物组成,分为 7 个类别之一(这是数据集的非结构化部分)。数据集中所有出版物中的唯一词汇量(即词汇表)为 1,433 个。每篇出版物都被描述为一个multihot编码的向量。这个向量的大小为 1,433(与词汇表大小相同),其中单元格的值为 0 或 1。如果某篇出版物包含词汇表中的第i个单词,那么该出版物的 one-hot 编码向量的第i个单元格被设为 1。如果该词汇不出现在该出版物中,则单元格为 0。此机制保留了文章中存在的单词信息,但不保留单词的顺序信息。数据集还包含一个有向图,表示 5,429 个引用关系,其中节点是出版物,节点之间的边表示出版物v是否引用出版物u(这是数据集的结构化部分)。

接下来,让我们聚焦于 NGL 本身,从以下图表开始:

NGL 框架:绿色实线表示非结构化输入数据流;黄色虚线表示结构化信号数据流;灵感来源:www.tensorflow.org/neural_structured_learning/framework

它作为常规神经网络训练框架的一种封装,可以应用于任何类型的网络,包括 FFN 和 RNN。例如,我们可以使用常规的 FFN,它将multihot编码的出版物向量作为输入,尝试将其分类为 7 个类别之一,使用 softmax 输出,正如前面图表中绿色实线所示。NGL 使我们能够通过引用提供的结构化数据来扩展这个网络,如黄色虚线所示。

让我们看看这个是如何工作的。我们从假设图中的相邻节点是有些相似的开始。我们可以将这个假设转移到神经网络领域,假设样本 i 通过神经网络生成的嵌入向量(嵌入是最后一层隐藏层的输出)应该与样本 j 的嵌入向量相似,前提是这两个样本在关联图中是邻居。在我们的例子中,我们可以假设,如果出版物 i 引用了出版物 j(即它们在引用图中是邻居),那么出版物 i 的嵌入向量应该与出版物 j 的嵌入向量相似。实际上,我们可以通过以下步骤来实现这一点:

  1. 从一个包含非结构化数据(多热编码出版物)和结构化数据(引用图)的数据集开始。

  2. 构建特殊类型的复合训练样本(按批组织),其中每个复合样本由单一常规输入样本(一个多热编码出版物)和 K 个邻居样本(引用或被引用的多热编码出版物)组成。

  3. 将复合样本输入到神经网络中,生成初始样本及其邻居的嵌入向量。尽管前面的图示显示这两条路径是并行运行的,但实际情况并非如此。该图旨在说明网络处理了中央样本及其邻居,但实际的神经网络并不清楚这种安排——它只是将所有多热编码的输入作为单个批次的一部分进行处理。相反,位于常规神经网络上方的 NSL 部分区分了这两个组件。

  4. 计算一种特殊类型的复合损失函数,由两部分组成:常规监督损失和正则化邻居损失,使用度量来衡量初始样本嵌入与其邻居嵌入之间的距离。邻居损失是我们用结构化信号增强非结构化训练数据的机制。复合损失定义如下:

该公式具有以下特点:

  • n 是小批量中的复合样本数量。

  • 是监督损失函数。

  • f[θ] 是具有权重 θ 的神经网络函数。

  • α 是一个标量参数,确定两种损失组件之间的相对权重。

  • 是样本 x[i] 的图邻居集合。请注意,邻居损失遍历图中所有节点的所有邻居(两个求和)。

  • 是样本 ij 之间图边的权重。如果任务没有权重的概念,我们可以假设所有权重都为 1。

  • 是样本 ij 之间嵌入向量的距离度量。

由于邻居损失的正则化特性,NGL 也被称为 图正则化

  1. 反向传播误差并更新网络权重 θ

现在我们已经对图正则化有了一个概览,让我们开始实现它。

实现图正则化

在本节中,我们将在 NSL 框架的帮助下实现对 Cora 数据集的图正则化。这个示例基于www.tensorflow.org/neural_structured_learning/tutorials/graph_keras_mlp_cora中的教程。在开始实现之前,我们需要满足一些前提条件。首先,我们需要 TensorFlow 2.0 和 neural-structured-learning 1.1.0 包(可以通过 pip 安装)。

一旦满足这些要求,我们就可以继续实现:

  1. 我们将首先进行包的导入:
import neural_structured_learning as nsl
import tensorflow as tf
  1. 我们将继续定义程序中的一些常量参数(希望常量名称和注释能够自解释):
# Cora dataset path
TRAIN_DATA_PATH = 'data/train_merged_examples.tfr'
TEST_DATA_PATH = 'data/test_examples.tfr'
# Constants used to identify neighbor features in the input.
NBR_FEATURE_PREFIX = 'NL_nbr_'
NBR_WEIGHT_SUFFIX = '_weight'
# Dataset parameters
NUM_CLASSES = 7
MAX_SEQ_LENGTH = 1433
# Number of neighbors to consider in the composite loss function
NUM_NEIGHBORS = 1
# Training parameters
BATCH_SIZE = 128

位于 TRAIN_DATA_PATHTEST_DATA_PATH 下的文件包含经过 TensorFlow 友好格式处理的 Cora 数据集和标签。

  1. 接下来,让我们加载数据集。这个过程通过两个函数实现:make_dataset,它构建整个数据集,以及 parse_example,它解析单个复合样本(make_dataset 内部使用 parse_example)。我们将从 make_dataset 开始:
def make_dataset(file_path: str, training=False) -> tf.data.TFRecordDataset:
    dataset = tf.data.TFRecordDataset([file_path])
    if training:
        dataset = dataset.shuffle(10000)
    dataset = dataset.map(parse_example).batch(BATCH_SIZE)

    return dataset

请注意,dataset.map(parse_example) 内部会对数据集中的所有样本应用 parse_example。我们将继续定义 parse_example,从声明开始:

def parse_example(example_proto: tf.train.Example) -> tuple:

该函数创建了 feature_spec 字典,表示单个复合样本的模板,稍后将用来自数据集的实际数据填充。首先,我们用 tf.io.FixedLenFeature 的占位符实例填充 feature_spec'words' 代表一个多热编码的出版物,'label' 代表出版物的类别(请注意缩进,因为这段代码仍然是 parse_example 的一部分):

    feature_spec = {
        'words':
            tf.io.FixedLenFeature(shape=[MAX_SEQ_LENGTH],
                                  dtype=tf.int64,
                                  default_value=tf.constant(
                                      value=0,
                                      dtype=tf.int64,
                                      shape=[MAX_SEQ_LENGTH])),
        'label':
            tf.io.FixedLenFeature((), tf.int64, default_value=-1),
    }

然后,我们迭代前 NUM_NEIGHBORS 个邻居,并将它们的多热向量和边权重分别添加到 feature_spec 中的 nbr_feature_keynbr_weight_key 键下:

    for i in range(NUM_NEIGHBORS):
        nbr_feature_key = '{}{}_{}'.format(NBR_FEATURE_PREFIX, i, 'words')
        nbr_weight_key = '{}{}{}'.format(NBR_FEATURE_PREFIX, i, NBR_WEIGHT_SUFFIX)
        feature_spec[nbr_feature_key] = tf.io.FixedLenFeature(
            shape=[MAX_SEQ_LENGTH],
            dtype=tf.int64,
            default_value=tf.constant(
                value=0, dtype=tf.int64, shape=[MAX_SEQ_LENGTH]))

        feature_spec[nbr_weight_key] = tf.io.FixedLenFeature(
            shape=[1], dtype=tf.float32, default_value=tf.constant([0.0]))

    features = tf.io.parse_single_example(example_proto, feature_spec)

    labels = features.pop('label')
    return features, labels

请注意,我们使用以下代码片段将模板填充为来自数据集的真实样本:

    features = tf.io.parse_single_example(example_proto, feature_spec)
  1. 现在,我们可以实例化训练和测试数据集:
train_dataset = make_dataset(TRAIN_DATA_PATH, training=True)
test_dataset = make_dataset(TEST_DATA_PATH)
  1. 接下来,让我们实现模型,它是一个简单的 FFN,具有两个隐藏层和 softmax 输出。该模型将多热编码的出版物向量作为输入,并输出出版物的类别。它独立于 NSL,可以以简单的监督方式作为分类进行训练:
def build_model(dropout_rate):
    """Creates a sequential multi-layer perceptron model."""
    return tf.keras.Sequential([
        # one-hot encoded input.
        tf.keras.layers.InputLayer(
            input_shape=(MAX_SEQ_LENGTH,), name='words'),

        # 2 fully connected layers + dropout
        tf.keras.layers.Dense(64, activation='relu'),
        tf.keras.layers.Dropout(dropout_rate),
        tf.keras.layers.Dense(64, activation='relu'),
        tf.keras.layers.Dropout(dropout_rate),

        # Softmax output
        tf.keras.layers.Dense(NUM_CLASSES, activation='softmax')
    ])
  1. 接下来,让我们实例化模型:
model = build_model(dropout_rate=0.5)
  1. 我们已经准备好了进行图正则化所需的所有要素。我们将首先使用 NSL 包装器包装 model
graph_reg_config = nsl.configs.make_graph_reg_config(
    max_neighbors=NUM_NEIGHBORS,
    multiplier=0.1,
    distance_type=nsl.configs.DistanceType.L2,
    sum_over_axis=-1)
graph_reg_model = nsl.keras.GraphRegularization(model,
                                                graph_reg_config)

我们实例化graph_reg_config对象(nsl.configs.GraphRegConfig的实例),并配置图正则化参数:max_neighbors=NUM_NEIGHBORS是使用的邻居数,multiplier=0.1相当于我们在神经结构学习章节中介绍的复合损失的参数α,distance_type=nsl.configs.DistanceType.L2是邻居节点嵌入之间的距离度量。

  1. 接下来,我们可以构建一个训练框架,并开始进行 100 个 epoch 的训练:
graph_reg_model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy'])

# run eagerly to prevent epoch warnings
graph_reg_model.run_eagerly = True

graph_reg_model.fit(train_dataset, epochs=100, verbose=1)
  1. 一旦训练完成,我们可以在测试数据集上运行已训练的模型:
eval_results = dict(
    zip(graph_reg_model.metrics_names,
        graph_reg_model.evaluate(test_dataset)))
print('Evaluation accuracy: {}'.format(eval_results['accuracy']))
print('Evaluation loss: {}'.format(eval_results['loss']))

如果一切顺利,程序的输出应为:

Evaluation accuracy: 0.8137432336807251
Evaluation loss: 1.1235489577054978

这就结束了我们对 GNN 的讨论。如我们所提到的,GNN 有多种类型,而我们这里只包含了一个小的集合。如果你有兴趣了解更多,我建议你参考我们在本节开头提到的综述论文,或者查看以下整理好的 GNN 相关论文列表:github.com/thunlp/GNNPapers

在下一节中,我们将讨论一种新型的神经网络(NN),它使用外部存储来保存信息。

介绍记忆增强神经网络

我们已经在神经网络中看到过记忆的概念(尽管以一种奇特的形式存在)——例如,LSTM 单元可以借助输入门和遗忘门在其隐藏单元状态中添加或删除信息。另一个例子是注意力机制,其中代表编码源序列的向量集合可以视为外部记忆,编码器写入其中,解码器从中读取。但是这种能力也有一些局限性。例如,编码器只能写入一个单一的记忆位置,即当前序列元素,也无法更新先前写入的向量。另一方面,解码器只能从数据库中读取,无法写入。

在本节中,我们将进一步探讨记忆的概念,介绍记忆增强神经网络MANNs),它解决了这些局限性。这是一类新的算法,仍处于早期阶段,不像更为主流的神经网络类型(如卷积神经网络和 RNN),这些类型已经存在了几十年。我们将讨论的第一个 MANN 网络是神经图灵机。

神经图灵机

MANN 的概念首次通过神经图灵机NTM)引入(更多信息,请访问arxiv.org/abs/1410.5401)。NTM 有两个组件:

  • 一个神经网络控制器。

  • 一个外部记忆,表示为一个矩阵!。该矩阵包含nd维向量。

下图提供了 NTM 架构的概述:

NTM 来源:arxiv.org/abs/1410.5401

NTM 以顺序方式工作(像 RNN 一样),控制器接受输入向量并产生响应的输出向量。它还通过多个并行的读/写头帮助读取和写入记忆。

让我们集中讨论读取操作,它与我们在第八章中讨论的注意力机制非常相似,序列到序列模型与注意力。读取头总是读取完整的记忆矩阵,但它通过以不同的强度关注不同的记忆向量来实现这一点。为此,读取头发出一个 n 维向量 (在步骤 t 处),并满足以下约束:

实现了一个注意力机制,其中向量的每个单元 i 表示形成输出时第 i 个记忆向量(即矩阵 M 的第 i 行)的权重。在步骤 t 处的读取操作的输出是一个 d 维向量 r[t],定义为所有记忆向量的加权和:

这一操作类似于我们在第八章中讨论的软注意力机制,序列到序列模型与注意力。与硬注意力不同,软注意力是可微的,这个操作也具有相同的特性。通过这种方式,整个 NTM(控制器和记忆)是一个可微分的系统,这使得我们可以通过梯度下降和反向传播对其进行训练。

接下来,让我们集中讨论写入操作,它由两个步骤组成:擦除操作,接着是加法操作。写头发出与读取头相同类型的注意力向量 。它还发出另一个擦除向量 ,其值都在 (0, 1) 范围内。我们可以将步骤 t 上的擦除操作定义为这两个向量和步骤 t-1 时的记忆状态 的函数:

在这里,1 是一个 d 维的全为 1 的向量, 和擦除组件的乘法是逐元素相乘。根据这个公式,只有当权重 e[t] 都不为零时,才能擦除一个记忆位置。这个机制可以与多个注意力头一起工作,按任意顺序进行写入,因为乘法是可交换的。

擦除操作之后是加法操作。写头生成一个加法向量 ,它在擦除之后被加到记忆中,生成步骤 t 的最终记忆状态:

我们现在已经熟悉了读写操作,但我们仍然不知道如何生成注意力向量 (我们将省略上标索引,因为以下描述适用于读头和写头)。NTM 使用两种互补的寻址机制来完成这一点:基于内容的和基于位置的。

我们从内容基础的寻址开始,其中每个头(包括读取和写入)都会发出一个键向量 。这个向量与每个记忆向量  使用相似度度量 进行比较。NTM 的作者建议使用余弦相似度,定义如下:

然后,我们将内容基础的寻址向量的单个单元定义为所有记忆向量相似度结果的 softmax:

这里,  是一个标量值键强度,它决定了焦点的范围。对于小值的 ,注意力会扩展到所有记忆向量,而对于大值的 ,注意力则会聚焦于最相似的记忆向量。

NTM 的作者认为,在一些问题中,内容基础的注意力是不够的,因为一个变量的内容可以是任意的,但其地址必须是可识别的。他们以算术问题为例:两个变量,xy,可以取任何两个值,但过程 f(x, y) = x × y 仍然应该被定义。这个任务的控制器可以获取变量 xy 的值,将它们存储在不同的地址中,然后检索它们并执行乘法算法。在这种情况下,变量是按位置寻址的,而不是按内容寻址的,这就引出了基于位置的寻址机制。它既可以与随机访问内存跳跃一起工作,也可以在位置之间进行简单的迭代。它通过将注意力权重向前或向后移动一步来实现这一点。

例如,如果当前的加权完全集中在一个位置,那么旋转 1 将把焦点移到下一个位置。负向旋转会将加权移动到相反方向。

内容和位置寻址是结合工作的,如下图所示:

寻址机制的流程图。来源:arxiv.org/abs/1410.5401

让我们一步一步看看它是如何工作的:

  1. 内容寻址生成内容寻址向量 ,基于记忆 ,键向量 ,以及键强度 

  2. 插值是定位寻址机制中的三个步骤之一,它发生在实际的权重平移之前。每个头(读取或写入)会发出一个标量插值门 g[t],其值范围在(0,1)之间。g[t] 决定是保留在步骤 t-1 中由头部生成的权重 ,还是用当前步骤 t 的基于内容的权重 来替换它。插值定义如下:

如果 g[t] = 0,那么我们将完全保留先前的寻址向量。或者,如果 g[t] = 1,我们将只使用基于内容的寻址向量。

  1. 下一步是卷积平移,它将插值注意力 用于确定如何进行平移。假设头部注意力可以前移(+1)、后移(-1)或保持不变(0)。每个头部会发出一个平移加权 s[t],它定义了允许平移的归一化分布。在这种情况下,s[t] 将有三个元素,表示执行 -1、0 和 1 移动的程度。如果假设内存向量索引是从 0 开始的(从 0 到 n-1),那么我们可以通过 s[t] 定义 的旋转为循环卷积:

请注意,尽管我们遍历了所有内存索引,s[t] 只有在允许的位置才会有非零值。

  1. 最后的寻址步骤是锐化步骤。一个副作用是,能够同时在多个方向上以不同的程度进行平移可能导致注意力模糊。例如,假设我们以 0.6 的概率向前平移(+1),以 0.2 的概率向后平移(-1),并以 0.2 的概率不平移(0)。当我们应用平移时,原始聚焦的注意力将在这三个位置之间模糊。为了解决这个问题,NTM 的作者建议修改每个头部,使其发出另一个标量 ,通过以下公式来锐化最终结果:

现在我们知道了寻址的工作原理,接下来我们来关注控制器,在控制器中我们可以使用 RNN(例如,LSTM)或 FFN。NTM 的作者认为 LSTM 控制器具有内存,这与外部内存是互补的,并且还允许控制器混合来自多个时间步骤的信息。然而,在 NTM 的背景下,FFN 控制器可以通过在每个步骤读取和写入相同的内存位置来模拟 RNN 控制器。此外,FFN 更具透明性,因为它的读写模式比内部的 RNN 状态更容易解释。

论文的作者通过多个任务展示了 NTM 的工作原理,其中一个任务是复制操作,NTM 必须将输入序列复制为输出。该任务展示了模型在长时间内存储和访问信息的能力。输入序列的长度在 1 到 20 之间随机变化。序列中的每个元素是一个包含八个二进制元素的向量(表示一个字节)。首先,模型逐步处理输入序列,直到遇到特殊的分隔符。然后,它开始生成输出序列。在生成阶段,不会提供额外的输入,以确保模型能够在没有中间辅助的情况下生成整个序列。作者将 NTM 和 LSTM 模型的性能进行了比较,指出 NTM 在训练期间收敛得更快,并且能够复制比 LSTM 更长的序列。基于这些结果,并在检查控制器与记忆之间的交互后,作者得出结论,NTM 不仅仅是记忆输入序列;相反,它学习了一种复制算法。我们可以用以下伪代码描述该算法的操作顺序:

NTM 模型学习了一种复制算法:来源:arxiv.org/abs/1410.5401

接下来,我们从控制器与记忆之间的交互角度关注复制算法,正如下图所示:

复制算法中的控制器/记忆交互;来源:arxiv.org/abs/1410.5401

左列显示了输入阶段。左上方的图像表示 8 位二进制向量的输入序列,左中方的图像表示添加到记忆中的向量,左下方的图像表示每一步的记忆写入注意力权重。右列显示了输出阶段。右上方的图像表示生成的 8 位二进制向量输出序列,右中方的图像表示从记忆中读取的向量,右下方的图像表示每一步的记忆读取注意力权重。底部的图像展示了写入和读取操作期间头位置的逐步变化。请注意,注意力权重显著集中在单一的记忆位置。同时,输入和输出序列在每个时间步读取相同的位置,读取的向量与写入的向量相等。这表明输入序列的每个元素都存储在单一的记忆位置中。

在我们结束这一部分之前,值得提到的是,NTM 的作者发布了一种改进的记忆网络架构,称为 差分神经计算机 (DNC)(有关更多信息,请参阅 Hybrid computing using a neural network with dynamic external memory,在 www.nature.com/articles/nature20101)。DNC 相较于 NTM 引入了若干改进:

  • 该模型只使用基于内容的寻址(与 NTM 中的内容和位置寻址不同)。

  • 该模型通过维持可用记忆位置的列表,动态分配记忆空间,向链表中添加或移除位置(这仍然是可微的)。这一机制使得模型只在标记为空闲的位置写入新数据。

  • 该模型通过维持控制器写入的记忆位置的顺序信息,使用时间记忆链接,这使得它可以在不同的记忆位置存储顺序数据。

这就结束了我们对 NTM 架构的描述。在下一部分,我们将讨论在 One-shot Learning with Memory-Augmented Neural Networks 论文中介绍的 NTM 改进(arxiv.org/abs/1605.06065)。为了避免与代表一般记忆网络类的 MANN 缩写混淆,我们将改进后的架构称为 MANN*。

MANN*

MANN* 的读取操作与 NTM 的读取操作非常相似,唯一的不同是它不包括键强度参数 。另一方面,MANN* 引入了一种基于内容的写入寻址机制,称为 最近最少使用访问 (LRUA),以替代 NT 的内容/位置结合寻址机制。LRUA 写入操作要么写入最少使用的记忆位置,要么写入最近使用的记忆位置。实现这一机制有两个原因:一是通过将新记忆写入最少使用的记忆位置来保存最近存储的信息;二是通过将新数据写入最后使用的位置,使新信息作为对之前写入状态的更新。那么模型如何知道使用哪种方式呢?MANN* 的寻址机制通过引入一个使用权重向量 在这两种选项之间进行插值。每个时间步,使用权重 会与当前的读写注意力权重结合,从而更新这些权重。

在这里,标量 γ 是一个衰减参数,用于决定方程中两个分量之间的平衡。MANN* 还引入了最近最少使用的权重向量 ,其中向量的每个元素定义如下:

在这里, 是向量 n 第小元素,n 等于读取的内存数量。此时,我们可以计算写入权重,它是读取权重与步骤 t-1 时最近未使用权重之间的插值:

在这里,σ 是 sigmoid 函数,α 是一个可学习的标量参数,表示如何在两个输入权重之间进行平衡。现在,我们可以将新数据写入内存,这可以分为两步:第一步是计算最近最少使用的位置,使用权重 。第二步是实际写入:

在这里, 是我们在讨论 NTM 时定义的关键向量。

MANN* 论文比原始的 NTM 论文更详细地讨论了控制器如何与输入数据和读写头交互。论文的作者指出,他们表现最好的模型使用了 LSTM 控制器(参见 第七章,理解递归神经网络)。因此,以下是 LSTM 控制器如何接入 MANN* 系统:

  • 控制器在步骤 t 的输入是连接起来的向量 ,其中  是输入数据, 是步骤 t-1 时系统的输出。在分类任务中,输出  是经过 one-hot 编码的类别表示。

  • 控制器在步骤 t 的输出是连接起来的 ,其中  是 LSTM 单元的隐藏状态, 是读取操作的结果。对于分类任务,我们可以使用  作为全连接层的输入,带有 softmax 输出,从而得到表达式 ,其中  是全连接层的权重。

  • 关键向量 ,作为读取/写入操作的注意力权重的基础,是 LSTM 单元状态 

这结束了我们对 MANN 的讨论,实际上也结束了这一章。

摘要

在本章中,我们介绍了两类新兴的神经网络模型——图神经网络(GNNs)和记忆增强神经网络(MANNs)。我们首先简要介绍了图结构,然后探讨了几种不同类型的图神经网络,包括 GraphNN、图卷积网络、图注意力网络和图自编码器。我们通过研究 NGL 并使用基于 TensorFlow 的 NSL 框架实现了一个 NGL 示例,结束了图部分的内容。接着,我们专注于记忆增强网络,探讨了 NTM 和 MANN*架构。

在下一章中,我们将探讨新兴的元学习领域,它涉及到使机器学习算法学会如何学习。

第十章:元学习

在第九章,《新兴神经网络设计》中,我们介绍了新的神经网络NN)架构,以解决现有深度学习DL)算法的一些局限性。我们讨论了用于处理结构化数据的图神经网络,这些数据表示为图形。我们还介绍了增强记忆的神经网络,允许网络使用外部记忆。在本章中,我们将探讨如何通过赋予 DL 算法更多的学习能力,以便在使用更少的训练样本的情况下学习更多的信息。

让我们通过一个例子来说明这个问题。假设一个人从未见过某种类型的物体,比如一辆车(我知道——这不太可能)。他们只需要看到一辆车一次,就能够识别其他的车。但是,这对于深度学习(DL)算法并非如此。一个深度神经网络(DNN)需要大量的训练样本(有时还需要数据增强),才能识别某一类物体。即便是相对较小的 CIFAR-10 (www.cs.toronto.edu/~kriz/cifar.html) 数据集,也包含了 50,000 张用于 10 类物体的训练图片,相当于每类 5,000 张图片。

元学习,也称为“学习如何学习”,使得机器学习ML)算法能够利用并传递在多个训练任务中获得的知识,从而提高其在新任务上的训练效率。希望通过这种方式,算法在学习新任务时所需的训练样本更少。用更少的样本进行训练有两个优势:缩短训练时间和在训练数据不足时仍能取得良好的性能。在这方面,元学习的目标与我们在第二章《理解卷积网络》中介绍的迁移学习机制类似。实际上,我们可以将迁移学习视为一种元学习算法。但元学习有多种方法。在本章中,我们将讨论其中的一些方法。

本章将涵盖以下主题:

  • 元学习简介

  • 基于度量的元学习

  • 基于优化的元学习

元学习简介

正如我们在介绍中提到的,元学习的目标是让机器学习算法(在我们的例子中是神经网络)相较于标准的监督学习,通过较少的训练样本进行学习。一些元学习算法通过在已知任务领域的现有知识与新任务领域之间找到映射来实现这一目标。其他算法则是从零开始设计,旨在通过较少的训练样本进行学习。还有一些算法引入了新的优化训练技术,专门为元学习设计。但在我们讨论这些主题之前,先介绍一些基本的元学习范式。在标准的机器学习监督学习任务中,我们的目标是通过更新模型参数θ(在神经网络中是网络权重),在训练数据集D上最小化成本函数J(θ)。正如我们在介绍中提到的,在元学习中,我们通常会处理多个数据集。因此,在元学习情景下,我们可以通过以下方式扩展这个定义:我们旨在通过这些数据集的分布P(D)最小化J(θ)

在这里,是最优模型参数,是成本函数,现在它依赖于当前数据集和模型参数。换句话说,目标是找到模型参数,使得成本在所有数据集上的期望值(如在第一章中所描述,神经网络的基础部分,随机变量和概率分布章节)被最小化。我们可以将这种情景看作是在单个数据集上进行训练,而这个数据集的训练样本本身也是数据集。

接下来,让我们继续扩展我们在介绍中使用的表达式较少的训练样本。在监督训练中,我们可以将这种稀缺训练数据的情景称为k-shot 学习,其中k可以是 0、1、2,依此类推。假设我们的训练数据集包含分布在n个类别中的标记样本。在k-shot 学习中,我们为每个n个类别提供k个标记训练样本(总的标记样本数为n × k)。我们将这个数据集称为支持集,用S表示。我们还有一个查询集 Q,其中包含属于n个类别之一的未标记样本。我们的目标是正确分类查询集中的样本。有三种类型的k-shot 学习:零-shot、one-shot 和 few-shot。让我们从零-shot 学习开始。

零-shot 学习

我们将从零-shot 学习(k = 0)开始,在这种情况下,我们知道某个特定类别存在,但我们没有该类别的任何标记样本(即没有支持集)。一开始,这听起来不可能——我们如何分类我们从未见过的东西?但在元学习中,情况并非完全如此。回想一下,我们通过以前学过的任务的知识(我们用a表示它们)来处理当前任务(b)。从这个角度来看,零-shot 学习是一种迁移学习。为了理解它是如何工作的,假设一个人从未见过大象(另一个极不可能的例子),但他们必须在看到大象的图片时进行识别(新任务b)。然而,这个人在书中读到大象是很大、灰色的,四条腿、大耳朵,还有一个象鼻(先前任务a)。根据这个描述,当他们看到大象时,他们会很容易识别出来。在这个例子中,这个人将他们在以前学到的任务领域(读书)中的知识应用到新任务(图像分类)领域。

在机器学习的背景下,这些特征可以编码为非人类可读的嵌入向量。我们可以通过使用语言建模技术(如 word2vec 或 transformers)来编码单词elephant的基于上下文的嵌入向量,从而在神经网络领域复制象识别的示例。我们还可以使用卷积神经网络(CNN)来生成一张象的图像的嵌入向量h[b]。让我们一步步来看如何实现:

  1. 对标记和未标记样本ab应用编码器f和 g(神经网络),分别生成嵌入h[a]和h[b]。

  2. 使用映射函数将h[b]转换到已知样本的嵌入h[a]的向量空间。映射函数也可以是一个神经网络。此外,编码器和映射函数可以结合在一个模型中并联合学习。

  3. 一旦我们得到了查询样本的转化表示,我们可以将其与所有表示h[a]*进行比较,使用相似度度量(例如余弦相似度)。然后,我们假设查询样本的类别与与查询最相关的支持样本的类别相同。下图说明了这一场景:

零-shot 学习之所以可能,是因为迁移学习。灵感来源于《深度学习》第十五章,网址为 www.deeplearningbook.org/

让我们正式化零次学习场景。在传统的分类任务中,使用单一数据集,神经网络表示条件概率 ,其中y是输入样本x的标签,θ是模型参数。在元学习中,xy属于传统数据集,但我们引入了一个随机变量T,它描述了我们感兴趣的新任务。在我们的例子中,x将是词语elephant的上下文(周围的词),而标签y是类别 elephant 的独热编码。另一方面,T将是我们感兴趣的一张图片;因此,元学习模型表示一个新的条件概率 。我们刚才描述的零次学习场景是所谓的基于度量的元学习的一部分(稍后我们将在本章中看到更多示例)。现在,让我们继续讨论单次学习。

单次学习

在本节中,我们将讨论单次学习k = 1)及其推广的少量学习k > 1)。在这种情况下,支持集不是空的,我们有每个类别的一或多个标记样本。这相比于零次学习场景是一种优势,因为我们可以依赖来自同一领域的标记样本,而不是使用来自另一个领域的标记样本映射。因此,我们只需要一个编码器f,无需额外的映射。

一种典型的单次学习任务是公司的面部识别系统。该系统应该能够基于单张照片识别员工的身份。同样,也应该能够通过单张照片添加新员工。值得注意的是,在这种情况下,添加新员工相当于添加一个已经见过的新类别(照片本身),但其他方面是未知的。这与零次学习相对,后者是我们有未见过的但已知的类别。解决这个任务的一种简单方法是使用分类前馈神经网络FFN),该网络将照片作为输入,并通过一个 softmax 输出,其中每个类别代表一个员工。这个系统将有两个主要缺点。首先,每次添加新员工时,我们都必须使用所有员工的完整数据集重新训练整个模型。其次,我们需要每个员工的多张图片来训练模型。

以下描述基于在Matching Networks for One Shot Learningarxiv.org/abs/1606.04080)中提出的方法。该论文有两个主要贡献:一种新的单次训练过程和一种特殊的网络架构。在本节中,我们将讨论训练过程,并在Matching networks部分描述网络架构。

我们也可以在一-shot 学习框架内解决这个任务。我们首先需要的是一个经过预训练的网络,该网络能够生成员工图像的嵌入向量。我们假设预训练使得网络可以为每张照片生成一个足够独特的嵌入 h。我们还将所有员工的照片存储在某个外部数据库中。为了提高性能,我们可以将网络应用于所有照片,然后将每张图像的嵌入也存储下来。我们将重点关注这样一种使用场景:系统必须识别一个现有员工,当他或她尝试用一张新照片进行身份验证时。我们将使用网络生成该照片的嵌入,然后将其与数据库中的嵌入进行比较。通过将数据库中的嵌入与当前照片的嵌入进行最匹配的比较,我们就能识别出该员工。

接下来,让我们看一下在系统中添加新员工时的使用案例。在这里,我们只需要拍一张该员工的照片并将其存储在数据库中。这样,每次员工尝试进行身份验证时,系统都会将他们当前的照片与初始照片进行对比(以及所有其他照片)。通过这种方式,我们在不改变网络的情况下添加了一个新类别(员工)。我们可以将员工照片/身份数据库视为一个支持集 。任务的目标是将这个支持集映射到一个分类器 ,该分类器会根据之前未见过的查询样本 输出一个标签的概率分布 。在我们的例子中, 配对表示之前不在系统中的新员工(即新的查询样本和新类别)。

换句话说,我们希望能够在现有支持集的帮助下预测以前从未见过的类别。我们将映射 定义为一个条件概率 ,由一个具有权重 θ 的神经网络实现。此外,我们还可以将一个新的支持集 输入到同一个网络中,这将导致一个新的概率分布 。通过这种方式,我们可以在不改变网络权重 θ 的情况下对输出进行条件化,以适应新的训练数据。

现在我们已经熟悉了 k-shot 学习,让我们来看一下如何在少量样本的数据集上训练一个算法。

元训练和元测试

我们在零-shot 学习单-shot 学习部分描述的场景被称为元测试****阶段。在这个阶段,我们利用预训练网络的知识,并通过仅使用少量支持集(或者根本不使用支持集)来预测以前未见过的标签。我们还有一个元训练阶段,在这个阶段我们在少-shot 环境下从头开始训练一个网络。《匹配网络用于单-shot 学习》的作者们介绍了一种与元测试密切匹配的元训练算法。这是必要的,因为我们需要在期望的测试阶段条件下训练模型。由于我们从头开始训练网络,训练集(记作D)不是一个少-shot 数据集,而是包含每个类别的足够多的标注样本。尽管如此,训练过程仍然模拟了一个少-shot 数据集。

以下是其工作原理:

  1. 从标签集中采样一个标签集 ,其中TD中所有标签的集合。为了澄清,L仅包含T中部分标签。这样,训练过程在模型仅看到少量样本时就能模拟测试。例如,向人脸识别系统中添加一名新员工只需要一张图片和一个标签。

  2. 从支持集采样 ,其中所有样本的标签来自于,并且只包含 L 的一部分 。支持集包含每个标签的k个样本。

  3. 从训练批次采样 ,其中 (与支持集相同)。的组合表示一次训练回合。我们可以将回合视为一个独立的学习任务,并对应一个数据集。或者,在监督学习中,一次回合仅是一个训练样本。

  4. 在回合中优化网络权重。网络表示概率 ,并使用作为输入。为了澄清,集合元组组成,这些元组以支持集为条件。这是训练过程中的“元”部分,因为模型学会了从支持集学习,以最小化整个批次的损失。模型使用以下交叉熵目标:

在这里, 和  分别反映了标签和示例的采样。我们将其与经典的监督学习场景中的相同任务进行比较。在这种情况下,我们从数据集D中采样小批量B,并且没有支持集。采样是随机的,不依赖于标签。然后,前面的公式将转化为以下形式:

元学习算法可以分为三大类:基于度量的、基于模型的和基于优化的。在本章中,我们将专注于基于度量和基于优化的方法(不包括基于模型的)。基于模型的元学习算法对实现概率 的机器学习算法类型没有任何限制。也就是说,不要求编码器和映射函数。相反,它们依赖于专门适配的小样本标注的网络架构。你可能还记得在第九章,《新兴神经网络设计》中,我们讨论了当时我们在分析使用记忆增强神经网络进行单次学习论文时引入的一个模型(arxiv.org/abs/1605.06065)。正如其名所示,这篇论文展示了在单次学习框架中使用记忆增强神经网络的应用。由于我们已经讨论过网络架构,而且训练过程与本节描述的相似,因此我们不会在本章中再提供一个基于模型的示例。

既然我们已经介绍了元学习的基本概念,接下来的部分将专注于基于度量的学习算法。

基于度量的元学习

在我们讨论元学习简介章节中的单次学习场景时提到了基于度量的方法,但这种方法通常适用于k-次学习。其思想是衡量无标签查询样本  与支持集中的所有其他样本  之间的相似度。利用这些相似度得分,我们可以计算概率分布 。以下公式反映了这一机制:

在这里,α 是查询样本与 之间的相似度度量,表示带有 n 类别和每个类别 k 个样本的支持集的大小。为了明确,查询样本的标签仅仅是支持集所有样本的线性组合。与查询样本相似度更高的样本类别会对查询样本标签的分布产生更大的贡献。我们可以将 α 实现为聚类算法(例如 k-最近邻)或注意力模型(正如我们在接下来的部分中将看到的)。在零-shot 学习的情况下,这个过程有两个正式步骤:计算样本的嵌入表示,然后计算嵌入表示之间的相似度。但前面的公式是这两个步骤的广义组合,它直接从查询样本中计算相似度(尽管在内部,这些步骤可能仍然是分开的)。两步度量基础学习(包括编码器 fg)在下图中进行了说明:

通用度量基础学习算法

在接下来的几部分中,我们将讨论一些更受欢迎的度量元学习算法。

一次性学习的匹配网络

我们已经讨论了在《元学习介绍》部分中与匹配网络一起引入的训练过程。现在,让我们关注实际的模型,从我们在度量基础元学习部分概述的相似度度量开始。实现这一点的一种方法是使用余弦相似度(记作 c),然后是 softmax:

这里,fg 分别是新任务样本和支持集样本的编码器(正如我们讨论的,fg 可能是相同的函数)。这些编码器可以是用于图像输入的卷积神经网络(CNN)或在自然语言处理任务中使用的词嵌入,例如 word2vec。这个公式与我们在第八章《序列到序列模型与注意力机制》中介绍的注意力机制非常相似。

按照当前定义,编码器 g 每次仅对一个支持样本进行编码,独立于支持集中的其他样本。然而,ij 两个样本的嵌入 可能在嵌入特征空间中非常接近,但这两个样本可能具有不同的标签。论文的作者建议修改 g,使其将整个支持集 S 作为额外输入:。通过这种方式,编码器可以基于 S 条件化 的嵌入向量,避免这个问题。我们也可以对编码器 f 应用类似的逻辑。论文将这种新的嵌入函数称为 完整上下文嵌入

让我们看看如何在 f 上实现完整的上下文嵌入。首先,我们将引入一个新函数 ,它类似于旧的编码器(在包括 S 作为输入之前)——也就是说,f' 可以是 CNN 或词嵌入模型,它独立于支持集创建样本嵌入。 的结果将作为完整嵌入函数 的输入。我们将支持集视为一个序列,这使我们可以使用长短期记忆(LSTM)对其进行嵌入。因此,计算嵌入向量是一个多步骤的顺序过程。

然而,S 是一个集合,这意味着序列中样本的顺序并不重要。为了反映这一点,算法还使用了一个特殊的注意力机制来处理支持集中的元素。通过这种方式,嵌入函数可以关注序列中所有先前的元素,而不管它们的顺序如何。

让我们看看编码器的一个步骤是如何工作的:

  1. ,其中 t 是输入序列的当前元素, 是一个中间隐藏状态, 是步骤 t-1 时的隐藏状态, 是细胞状态。注意力机制通过一个向量 实现,它与隐藏状态 连接。

  2. ,其中 是步骤 t 时的最终隐藏状态。

  3. ,其中 是支持集的大小,g 是支持集的嵌入函数,α 是一个相似性度量,定义为乘法注意力,随后进行 softmax:

这个过程持续进行T步(T是一个参数)。我们可以用以下公式总结:

接下来,让我们关注g的完整上下文嵌入。像f一样,我们将引入一个新函数,,它类似于旧的编码器(在包括S作为输入之前)。作者建议使用双向 LSTM 编码器,定义如下:

这里,是两个方向上的单元隐藏状态。我们可以按如下方式定义它们:

在下一节中,我们将讨论另一种基于度量的学习方法——孪生网络。

孪生网络

在本节中,我们将讨论孪生神经网络用于一次性图像识别论文(www.cs.cmu.edu/~rsalakhu/papers/oneshot1.pdf)。孪生网络是由两个相同的基础网络组成的系统,如下图所示:

孪生网络

这两个网络在共享相同架构和相同参数(权重)方面是相同的。每个网络接收一个输入样本,最后一个隐藏层生成该样本的嵌入向量。这两个嵌入向量被输入到一个距离度量中。这个距离经过进一步处理,产生系统的最终输出,该输出为二进制值,表示两个样本是否来自同一类。该距离度量本身是可微的,这使我们能够将这两个网络作为一个整体进行训练。论文的作者建议使用L1距离:

这里,是基础网络。在一次性学习场景中使用孪生网络遵循我们在元训练和元测试部分中描述的相同基本思路,但在这种情况下,任务被简化,因为我们始终只有两个类别(相同或不同),无论数据集中的实际类别数是多少。在元训练阶段,我们使用一个大型标注数据集来训练系统。我们通过生成图像对和二进制标签样本来完成这一点,标签可以是相同的或不同的类别。在元测试阶段,我们有一个查询样本和一个支持集。然后,我们创建多个图像对,每对图像包含查询样本和支持集中的一个样本。图像对的数量与支持集的大小相同。接着,我们将所有图像对输入到孪生系统中,并选择距离最小的那一对。查询图像的类别由该对的支持样本的类别决定。

实现孪生网络

在这一部分,我们将使用 Keras 实现一个简单的孪生网络示例,验证两张 MNIST 图像是否属于同一类别。它部分基于 github.com/keras-team/keras/blob/master/examples/mnist_siamese.py

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

  1. 我们将从导入语句开始:
import random

import numpy as np
import tensorflow as tf
  1. 接下来,我们将实现 create_pairs 函数来创建训练/测试数据集(用于训练和测试):
def create_pairs(inputs: np.ndarray, labels: np.ndarray):
    num_classes = 10

    digit_indices = [np.where(labels == i)[0] for i in range(num_classes)]
    pairs = list()
    labels = list()
    n = min([len(digit_indices[d]) for d in range(num_classes)]) - 1
    for d in range(num_classes):
        for i in range(n):
            z1, z2 = digit_indices[d][i], digit_indices[d][i + 1]
            pairs += [[inputs[z1], inputs[z2]]]
            inc = random.randrange(1, num_classes)
            dn = (d + inc) % num_classes
            z1, z2 = digit_indices[d][i], digit_indices[dn][i]
            pairs += [[inputs[z1], inputs[z2]]]
            labels += [1, 0]

    return np.array(pairs), np.array(labels, dtype=np.float32)

每个数据集样本由一对 MNIST 图像和一个二进制标签组成,标签表示它们是否来自同一类别。该函数创建了一个在所有类别(数字)中分布的相等数量的真/假样本。

  1. 接下来,让我们实现 create_base_network 函数,它定义了孪生网络的一个分支:
def create_base_network():
    return tf.keras.models.Sequential([
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(128, activation='relu'),
        tf.keras.layers.Dropout(0.1),
        tf.keras.layers.Dense(128, activation='relu'),
        tf.keras.layers.Dropout(0.1),
        tf.keras.layers.Dense(64, activation='relu'),
    ])

该分支表示从输入到最后一个隐藏层的基础网络,然后进行距离度量。我们将使用一个由三个全连接层组成的简单神经网络。

  1. 接下来,让我们从 MNIST 数据集开始,构建整个训练系统:
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
x_train = x_train.astype(np.float32)
x_test = x_test.astype(np.float32)
x_train /= 255
x_test /= 255
input_shape = x_train.shape[1:]
  1. 我们将使用原始数据集来创建实际的训练和测试验证数据集:
train_pairs, tr_labels = create_pairs(x_train, y_train)
test_pairs, test_labels = create_pairs(x_test, y_test)
  1. 然后,我们将构建孪生网络的基础部分:
base_network = create_base_network()

base_network 对象在孪生系统的两个分支之间共享。通过这种方式,我们确保两个分支中的权重是相同的。

  1. 接下来,让我们创建两个分支:
# Create first half of the siamese system
input_a = tf.keras.layers.Input(shape=input_shape)

# Note how we reuse the base_network in both halfs
encoder_a = base_network(input_a)

# Create the second half of the siamese system
input_b = tf.keras.layers.Input(shape=input_shape)
encoder_b = base_network(input_b)
  1. 接下来,我们将创建 L1 距离,它使用 encoder_aencoder_b 的输出。它作为 tf.keras.layers.Lambda 层实现:
l1_dist = tf.keras.layers.Lambda(
    lambda embeddings: tf.keras.backend.abs(embeddings[0] - embeddings[1])) \
    ([encoder_a, encoder_b])
  1. 然后,我们将创建最终的全连接层,该层接受距离的输出并将其压缩为一个单一的 sigmoid 输出:
flattened_weighted_distance = tf.keras.layers.Dense(1, activation='sigmoid') \
    (l1_dist)
  1. 最后,我们可以构建模型并开始训练 20 个周期:
# Build the model
model = tf.keras.models.Model([input_a, input_b], flattened_weighted_distance)

# Train
model.compile(loss='binary_crossentropy',
              optimizer=tf.keras.optimizers.Adam(),
              metrics=['accuracy'])

model.fit([train_pairs[:, 0], train_pairs[:, 1]], tr_labels,
          batch_size=128,
          epochs=20,
          validation_data=([test_pairs[:, 0], test_pairs[:, 1]], test_labels))

如果一切顺利,模型将达到大约 98% 的准确率。

接下来,我们将讨论另一种称为原型网络的度量学习方法。

原型网络

在少-shot 学习场景中,高容量模型(具有许多层和参数的神经网络)很容易发生过拟合。原型网络(如在 Prototypical Networks for Few-shot Learning 论文中讨论的,arxiv.org/abs/1703.05175)通过计算每个标签的特殊原型向量来解决这个问题,该向量是基于该标签的所有样本的。相同的原型网络还计算查询样本的嵌入。然后,我们测量查询嵌入与原型之间的距离,并根据此距离分配查询类别(有关更多细节,请参见本节后面)。

原型网络适用于零-shot 和少-shot 学习,如下图所示:

左图:少-shot 学习;右图:零-shot 学习。来源:arxiv.org/abs/1703.05175

让我们从少样本学习场景开始,其中每个类k的原型向量是该类所有样本的逐元素均值:

这里,是类k在支持集中的样本数量,是带有参数θ的原型网络。在零-shot 学习场景下,原型的计算方式如下:

这里,是元数据向量,它提供了标签的高层描述,是该向量的嵌入函数(编码器)。元数据向量可以提前给定或计算得出。

每个新的查询样本都通过样本嵌入与所有原型之间的距离进行 softmax 分类:

这里,d是距离度量(例如,线性欧氏距离)。

现在我们已经了解了原型网络背后的主要思想,接下来让我们聚焦于如何训练它们(这个过程类似于我们在元学习简介部分中概述的训练方法)。

在开始之前,我们先介绍一些符号:

  • D是少样本训练集。

  • D[k]是类kD中的训练样本。

  • T是数据集中的类总数。

  • 是每个训练轮次中选定的标签子集。

  • N[S]是每一轮每个类的支持样本数量。

  • N[Q]是每一轮查询样本的数量。

算法从训练集D开始,并输出代价函数J的结果。让我们逐步看看它是如何工作的:

  1. 从标签集中采样!

  2. 对于L中的每个类k,执行以下操作:

    1. 采样支持集,其中

    2. 采样查询集,其中

    3. 从支持集计算类原型:

  1. 初始化代价函数

  2. 对于L中的每个类k,执行以下操作:

    1. 对于每个查询样本,更新代价函数如下:

直观上,第一个组成部分(方括号中的部分)最小化查询样本与其对应类的原型之间的距离。第二项则最大化查询样本与其他类原型之间距离的和。

论文的作者们在 Omniglot 数据集上展示了他们的工作(github.com/brendenlake/omniglot),该数据集包含 1,623 张手写字符的图像,收集自 50 种字母表。每个字符有 20 个示例,其中每个示例由不同的人类主试绘制。目标是将一个新的字符分类为 1,623 个类别中的一个。他们使用欧几里得距离训练原型网络,采用一次性学习和五次性学习的场景,并使用 60 个类别和每个类别 5 个查询点的训练集。以下截图展示了由原型网络学习的同一字母表的一个相似(但不相同)字符子集的t-SNE 可视化(lvdmaaten.github.io/tsne/)。

即使这些可视化的字符只是彼此的微小变化,网络仍能将手绘字符紧密地聚类到类别原型周围。几个被误分类的字符已被用矩形框出,并且箭头指向正确的原型:

由网络学习到的相似字符子集的t-SNE 可视化;来源: arxiv.org/abs/1703.05175

这也结束了我们对原型网络和基于度量的元学习的描述。接下来,我们将重点讨论基于模型的方法。

基于优化的学习

到目前为止,我们讨论了基于度量的学习方法,它使用一种特殊的相似度度量(这种度量难以过拟合)来调整神经网络的表示能力,并且能够从少量训练样本的数据集中学习。另一种方法是基于模型的方式,依靠改进的网络架构(例如,增强记忆网络)来解决相同的问题。在这一部分,我们将讨论基于优化的方法,它通过调整训练框架以适应少样本学习的需求。更具体地说,我们将重点介绍一种叫做模型无关元学习(MAML;Model-Agnostic Meta-Learning for Fast Adaptation of Deep Networksarxiv.org/abs/1703.03400)的算法。顾名思义,MAML 可以应用于任何通过梯度下降训练的学习问题和模型。

引用原文:

我们方法的核心思想是训练模型的初始参数,使得在通过少量数据从新任务中进行一次或多次梯度更新后,模型能够在新任务上实现最佳性能。

...

训练模型参数的过程,使得通过少量的梯度更新,甚至单次梯度更新,就能在新任务上取得良好结果,可以从特征学习的角度看作是构建一个对多任务广泛适用的内部表示。如果这个内部表示适用于多任务,那么仅通过微调参数(例如,主要修改前馈模型中的顶层权重)就能得到良好的结果。实际上,我们的过程是优化那些容易且快速微调的模型,允许在适合快速学习的空间内发生适应。从动态系统的角度来看,我们的学习过程可以被视为最大化新任务的损失函数对参数的敏感度:当敏感度高时,对参数的小的局部变化可以导致任务损失的大幅改善。

本工作的主要贡献是一个简单的、与任务无关的元学习算法,它训练模型的参数,使得少量的梯度更新就能在新任务上快速学习。我们在不同的模型类型(包括全连接网络和卷积网络)以及多个不同领域(包括少样本回归、图像分类和强化学习)中展示了该算法。我们的评估表明,尽管使用较少的参数,我们的元学习算法与专门为监督分类设计的一次学习方法相比较具有优势,并且还可以轻松应用于回归,并在任务变异性存在时加速强化学习,显著超越作为初始化的直接预训练。

为了理解 MAML,我们将介绍一些特定于论文的符号(其中一些与前面章节中的符号重叠,但我更倾向于保留论文中的原始符号):

  • 我们用,表示模型(神经网络),它将输入x映射到输出a

  • 我们用表示完整的训练集(等同于数据集D)。与元训练和元测试部分的元训练类似,我们在训练过程中从中采样任务(等同于回合)。该过程被定义为一个关于任务的分布

  • 我们用表示一个任务(一个回合)。它通过损失函数(等同于损失J)、初始观察分布、转移分布和长度H来定义。

为了理解 MAML 任务定义的一些组成部分,我们需要注意,除了监督学习问题外,MAML 也可以应用于强化学习RL)任务。在强化学习框架中,我们有一个环境和一个代理,它们不断相互作用。在每个步骤中,代理采取一个动作(从多个可能的动作中选择),环境则提供反馈。反馈包括奖励(可能是负奖励)以及代理动作后的新环境状态。然后,代理再采取新的动作,以此类推,如下图所示:

强化学习框架

许多现实世界的任务可以表示为强化学习(RL)问题,包括游戏,其中代理是玩家,环境是游戏宇宙。

我们可以在监督学习和强化学习的背景下看待任务!。在监督学习任务中,我们有标注的训练样本!,这些样本的顺序没有特定要求。但在强化学习的背景下,我们可以将输入x视为环境状态,将输出a视为代理的动作。在这种情况下,任务是顺序的——状态x[1]导致动作a[1],进而导致状态x[2],依此类推。环境的初始状态表示为!。这意味着!是给定前一个状态x[t]和代理的动作a[t]时,新环境状态x[t+1]的概率。损失函数!在这两种情况下也可以这样理解:在监督学习场景中是误分类损失,而在强化学习场景中则是成本函数(即提供奖励的函数)。

现在我们已经熟悉了符号表示,让我们聚焦于 MAML 算法。为了理解它是如何工作的,我们将看一下原始论文中的另一个引用:

我们提出了一种方法,通过元学习可以学习任何标准模型的参数,以便为该模型的快速适应做准备。该方法背后的直觉是,一些内部表示比其他表示更具可迁移性。例如,神经网络可能会学习到一些广泛适用于所有任务的内部特征,而不仅仅是针对某个特定任务。我们如何鼓励此类通用表示的出现?我们采取了一种明确的方法来解决这个问题:由于模型将使用基于梯度的学习规则在新任务上进行微调,我们的目标是以这样的方式学习模型,使得该基于梯度的学习规则能够在从 中抽取的新任务上迅速取得进展,而不至于过拟合。实际上,我们的目标是找到对任务变化敏感的模型参数,使得当参数朝着该损失函数的梯度方向变化时,任何从 中抽取的任务的损失函数都会大幅改进。

经过这些悬念后,让我们看看 MAML 算法,其伪代码如下所示:

MAML 算法:来源:arxiv.org/abs/1703.03400

该算法有一个外部循环(第 2 行)和一个内部循环(第 4 行)。我们将从内部循环开始,它遍历一系列任务,这些任务是从任务分布中抽样得到的 。让我们关注单次循环迭代,它处理一个单独的任务 ,该任务有 训练样本,其中 是该任务的支持集。训练样本将在以下步骤中作为批处理进行处理(参见前面的截图第 4 行至第 7 行):

  1. 将样本通过模型传播并计算损失

  2. 计算相对于初始参数 θ 的误差梯度

  3. 向后传播梯度并计算更新后的模型参数 ,其中 α 是学习率。请注意,参数 是辅助参数,特定于每个任务。为了澄清,每当内部循环为一个新任务 开始一个新迭代时,模型总是从相同的初始参数 θ* 开始。与此同时,每个任务会将其更新的权重存储为一个附加变量 ,而不会实际修改初始参数 θ(我们将在外部循环中更新原始模型)。

  4. 我们可以对同一任务执行多次这样的梯度更新。可以把它当作在多个批次上训练,只不过在内部循环中额外嵌套了一个循环。在这种情况下,算法在每次迭代 i 时,使用的是上一次迭代的权重 ,而不是初始参数 θ,如下公式所示:

在多次迭代的情况下,仅保留最新的权重

只有在内部循环完成后,我们才能基于所有任务的反馈 来更新原始模型的初始参数 θ。为了理解为什么这是必要的,下面是一个图示:

MAML 优化能够快速适应新任务的参数 θ:来源: arxiv.org/abs/1703.03400

这显示了三个任务 沿全局误差梯度的误差梯度。假设我们不使用内部/外部循环机制,而是按顺序遍历每个任务,并在每个小批次后简单更新原始模型参数 θ。我们可以看到,不同损失函数的梯度会将模型推向完全不同的方向;例如,任务 2 的误差梯度与任务 1 的梯度会相互对立。MAML 通过汇总(但不应用)来自内部循环的每个任务的更新权重(辅助参数 )来解决这个问题。然后,我们可以计算外部循环的代价函数(称为元目标),结合所有任务的更新权重一次性进行优化(这是跨任务的元优化):

我们使用以下公式来更新主模型参数的权重:

这里,β 是学习率。外部循环任务 (MAML 伪代码程序的第 8 行)与内部循环任务(第 3 行)不同。我们可以将内部循环任务看作训练集,外部循环任务看作验证集。需要注意的是,我们使用特定任务的参数 来计算损失,但我们计算的损失梯度是相对于初始参数 θ 的。为了澄清,这意味着我们需要通过外部循环和内部循环进行反向传播。这被称为二阶梯度,因为我们计算的是梯度的梯度(二阶导数)。这使得即使经过多次更新,模型仍然可以学习到能够泛化到任务的参数。

MAML 的一个缺点是通过完整计算图(外循环和内循环)进行反向传播是计算密集型的。此外,由于反向传播步骤众多,它可能会遭遇梯度消失或爆炸的问题。为了更好地理解这一点,我们假设有一个单一任务(我们将在公式中省略它);我们对该任务执行一次梯度步骤(一次内循环迭代),内循环更新后的参数是。即,我们将损失函数的符号从改为。然后,外循环的参数更新规则变为:

我们可以借助链式法则计算损失相对于初始参数θ的梯度(见第一章,神经网络的基础):

我们可以看到公式中包含了二阶导数。MAML 的作者提出了所谓的一阶 MAMLFOMAML),即简单地忽略项。这样,我们得到,FOMAML 的梯度变为:

这个简化的公式排除了计算量大的二阶梯度。

到目前为止,我们已经了解了通用的 MAML 算法,它适用于监督学习和强化学习环境。接下来,让我们专注于监督学习版本。我们可以回顾一下,在监督学习的情况下,每个任务都是一组不相关的输入/标签对,且每个任务的回合长度H1。我们可以在以下伪代码中看到少样本监督学习的 MAML 算法(它与通用算法相似):

MAML 用于少样本监督学习:来源:arxiv.org/abs/1703.03400

在之前的代码中,方程式 2 和 3 都指的是分类任务的交叉熵损失或回归任务的均方误差,表示内部循环训练集,表示外部循环的验证集。

最后,让我们讨论一下强化学习场景,如下伪代码所示:

MAML 用于少样本强化学习:来源:arxiv.org/abs/1703.03400

每个样本  代表一场游戏回合的轨迹,其中环境在步骤 t 处展示了代理的当前状态 。然后,代理(NN)使用 策略  将状态  映射到一个动作的分布 。该模型使用一种特殊类型的损失函数,旨在训练网络以最大化整个回合步骤中的奖励。

摘要

本章中,我们研究了元学习领域,这可以描述为学习如何学习。我们从元学习的介绍开始。更具体地,我们讨论了零-shot 学习和少-shot 学习,以及元训练和元测试。然后,我们重点介绍了几种基于度量的学习方法。我们研究了匹配网络,实施了一个孪生网络的示例,并介绍了原型网络。接下来,我们集中讨论了基于优化的学习方法,并介绍了 MAML 算法。

在下一章中,我们将学习一个激动人心的主题:自动驾驶车辆。

第十一章:自动驾驶车辆的深度学习

让我们思考一下自动驾驶汽车AVs)将如何影响我们的生活。首先,除了集中精力开车,我们可以在旅行过程中做些其他事情。迎合这种旅行者的需求可能会催生一个全新的产业。但这只是附加的好处。如果我们能在旅途中提高生产力或仅仅放松身心,旅行可能会变得更加频繁,更不用说对那些无法自己驾驶的人群的好处。使交通这一基本而重要的商品变得更加可及,具有改变我们生活的潜力。而这只是对个人的影响——自动驾驶汽车对经济的影响也可能深远,从送货服务到即时制造。简而言之,自动驾驶汽车的成功实施是一场高风险的游戏。难怪近年来该领域的研究已经从学术界转向了现实经济。Waymo、Uber、NVIDIA 以及几乎所有主要的汽车制造商都在争相开发自动驾驶汽车。

然而,我们还没有到达目标。原因之一是自动驾驶是一项复杂的任务,由多个子问题组成,每个子问题本身都是一项重大任务。为了成功导航,车辆的程序需要准确的环境 3D 模型。构建该模型的方法是将来自多个传感器的信号结合起来。一旦我们拥有模型,仍然需要解决实际的驾驶任务。想想司机必须克服的许多意外和独特的情况,而不发生碰撞。但即使我们创建了一个驾驶策略,它几乎需要每次都做到 100%的准确度。假设我们的自动驾驶汽车能够成功地在 100 个红灯中停下 99 次。99%的准确率对于任何其他机器学习ML)任务来说都是一个巨大成功;但对于自动驾驶而言,即使是一次错误也可能导致事故。

在本章中,我们将探讨深度学习在自动驾驶汽车中的应用。我们将了解如何利用深度网络帮助车辆理解周围的环境。我们还将看到它们如何实际应用于控制车辆。

本章将涉及以下主题:

  • 自动驾驶汽车简介

  • 自动驾驶汽车系统的组成部分

  • 3D 数据处理简介

  • 模仿驾驶策略

  • 使用 ChauffeurNet 的驾驶策略

自动驾驶汽车简介

本部分将从自动驾驶汽车研究的简史开始(令人惊讶的是,这一研究始于很久以前)。我们还将尝试根据汽车工程师协会SAE)定义自动驾驶汽车的不同自动化等级。

自动驾驶汽车研究的简史

第一次认真尝试实现自动驾驶汽车始于 1980 年代的欧洲和美国。从 2000 年代中期起,进展迅速加速。该领域的第一次重大努力是 Eureka Prometheus 项目 (en.wikipedia.org/wiki/Eureka_Prometheus_Project),该项目从 1987 年持续到 1995 年。1995 年,该项目达到了高潮,一辆自动驾驶的奔驰 S 级车完成了从慕尼黑到哥本哈根及其返回的 1,600 公里旅程,使用的是计算机视觉。在某些时段,该车在德国的高速公路上以最高时速达 175 公里/小时行驶(有趣的是:德国的某些高速公路没有限速)。这辆车能够自主超车。人类干预的平均距离为 9 公里,在某个时刻,它驾驶了 158 公里没有任何干预。

1989 年,卡内基梅隆大学的 Dean Pomerleau 发表了ALVINN: 一种神经网络中的自主陆地车辆 (papers.nips.cc/paper/95-alvinn-an-autonomous-land-vehicle-in-a-neural-network.pdf),这是一篇关于神经网络在自动驾驶车辆(AV)中应用的开创性论文。这项工作尤其有趣,因为它在 30 年前就将本书中讨论的许多主题应用到了自动驾驶车辆中。让我们来看看 ALVINN 的最重要特性:

  • 它使用一个简单的神经网络来决定车辆的转向角度(它不控制加速和刹车)。

  • 网络完全连接,包含一个输入层、一个隐藏层和一个输出层。

  • 输入包含以下内容:

    • 来自车载前视相机的一个 30×32 的单色图像(他们使用了 RGB 图像中的蓝色通道)。

    • 来自激光测距仪的一个 8×32 的图像。这只是一个网格,其中每个单元包含到该单元视场内最近障碍物的距离。

    • 一个标量输入,表示道路强度——即,路面是否比图像中来自相机的非路面区域更亮或更暗。这个值是递归地从网络输出中获得的。

  • 一个包含 29 个神经元的单一全连接隐藏层。

  • 一个包含 46 个神经元的全连接输出层。道路的曲率由 45 个神经元表示,方式类似于独热编码——也就是说,如果中间的神经元激活度最高,则表示道路是直的。相反,左侧和右侧的神经元表示增加的道路曲率。最终的输出单元表示道路强度。

  • 网络在一个包含 1,200 张图像的数据集上进行了 40 轮训练:

ALVINN 的网络架构。来源:ALVINN 论文

接下来,我们来看一下(主要是)商业自动驾驶车辆进展的更近期时间线:

  • DARPA 大挑战 (en.wikipedia.org/wiki/DARPA_Grand_Challenge) 于 2004 年、2005 年和 2007 年举办。在第一年,参赛队伍的自动驾驶车辆(AVs)需要在莫哈维沙漠中完成 240 公里的路线。表现最好的自动驾驶车辆仅行驶了 11.78 公里,最终因卡在一块岩石上而停下。2005 年,队伍们需要在加利福尼亚州和内华达州克服 212 公里的越野路线。这一次,有五辆车成功完成了整个路线。2007 年的挑战是在一个模拟城市环境中导航,该环境建在一个空军基地内。总路线长度为 89 公里,参赛者需要遵守交通规则。六辆车完成了整个赛程。

  • 2009 年,谷歌开始开发自动驾驶技术。这项工作最终促成了谷歌母公司字母表(Alphabet)旗下子公司 Waymo 的成立 (waymo.com/)。2018 年 12 月,他们在亚利桑那州凤凰城启动了首个商业化按需自动驾驶打车服务。2019 年 10 月,Waymo 宣布启动首个真正的无人驾驶汽车服务,这是他们机器人出租车服务的一部分(之前,始终有一名安全司机在场)。

  • Mobileye (www.mobileye.com/) 使用深度神经网络提供驾驶辅助系统(例如车道保持辅助)。该公司开发了一系列专门优化低能耗运行神经网络的系统级芯片SOC)设备,满足汽车应用的需求。其产品已被许多主要汽车制造商使用。2017 年,Mobileye 被英特尔以 153 亿美元收购。从那时起,宝马、英特尔、菲亚特-克莱斯勒、上汽、大众、蔚来和汽车供应商德尔福(现为 Aptiv)开始在自动驾驶技术的联合开发上进行合作。2019 年前三个季度,Mobileye 的总销售额为 8.22 亿美元,而 2016 年四个季度的总销售额为 3.58 亿美元。

  • 2016 年,通用汽车收购了自动驾驶技术开发公司 Cruise Automation (getcruise.com/),交易金额超过 5 亿美元(具体数字未知)。自那时以来,Cruise Automation 已经测试并展示了多个自动驾驶原型车,并在旧金山进行了测试。2018 年 10 月,宣布本田将通过投资 7.5 亿美元获得 5.7%的股权,加入这一合作项目。2019 年 5 月,Cruise 获得了一笔来自新老投资者的 11.5 亿美元追加投资。

  • 2017 年,福特汽车公司收购了自动驾驶初创公司 Argo AI 的多数股权。2019 年,大众宣布将投资 26 亿美元于 Argo AI,作为与福特达成的更大交易的一部分。大众将提供 10 亿美元的资金,并将其拥有的价值 16 亿美元、位于慕尼黑、拥有超过 150 名员工的自动智能驾驶子公司贡献给该项目。

自动化等级

当我们谈论自动驾驶汽车时,通常会想象完全无人驾驶的车辆。但实际上,我们也有一些车辆虽然仍需驾驶员,但也提供某些自动化功能。

SAE 制定了六个级别的自动化等级:

  • Level 0:驾驶员负责车辆的转向、加速和制动。该级别的功能只能提供对驾驶员行为的警告和即时帮助。此级别的功能包括以下几种:

    • 车道偏离警告仅在车辆越过车道标线时提醒驾驶员。

    • 盲点警告在车辆的盲区区域(车辆后端的左侧或右侧区域)有其他车辆时提醒驾驶员。

  • Level 1:提供转向或加速/制动辅助功能的系统。当前车辆中最常见的这种功能包括:

    • 车道保持辅助LKA):车辆能够检测车道标线,并通过转向使自己保持在车道中央。

    • 自适应巡航控制ACC):车辆可以检测其他车辆,并通过制动和加速来保持预设的速度,或根据情况降低速度。

    • 自动紧急制动AEB):如果车辆检测到障碍物且驾驶员没有反应,车辆可以自动停止。

  • Level 2:为驾驶员提供转向和制动/加速辅助的功能。一个典型的例子是车道保持辅助(LKA)与自适应巡航控制的结合。在这个级别,车辆可以随时将控制权交还给驾驶员,而无需提前警告。因此,驾驶员必须始终保持对道路情况的关注。例如,如果车道标线突然消失,LKA 系统会提示驾驶员立即接管转向。

  • Level 3:这是我们可以讨论真正自动驾驶的第一个级别。它与 Level 2 的相似之处在于,车辆在某些有限的条件下可以自动驾驶,并且可以提示驾驶员接管控制;然而,这一过程会提前并给予足够时间,以便注意力不集中的人能及时熟悉道路状况。例如,假设车辆在高速公路上自动驾驶,但云连接的导航系统获取了前方道路施工的信息。驾驶员会在到达施工区域之前提前收到提示,提醒其接管驾驶。

  • Level 4:Level 4 的车辆在更多的情况下具有完全的自动化能力,相较于 Level 3。例如,本地限区域的出租车服务可能是 Level 4。此时不需要驾驶员接管控制。如果车辆驶出该区域,它应能够安全地中止行程。

  • Level 5:在所有情况下的完全自动驾驶。此时,方向盘是可选的。

今天所有市面上销售的车辆最多只有 2 级功能(即使是特斯拉的自动驾驶)。唯一的例外(根据制造商的说法)是 2018 款奥迪 A8,它拥有一个名为 AI 交通堵塞驾驶员的 3 级功能。该系统在车速最高达到 60 km/h 时,能够在有物理隔离带的多车道道路上自动驾驶。驾驶员会在 10 秒前收到提示,要求其接管控制。这项功能在该车型发布时曾展示过,但截至本章撰写时,奥迪因受限于法规,未在所有市场提供此功能。我没有相关信息显示该功能是否或在哪里可用。

在下一节中,我们将研究构成自动驾驶系统的各个组件。

自动驾驶系统的组件

本节将从软件架构的角度概述两种自动驾驶系统。第一种类型采用顺序架构,包含多个组件,如下图所示:

自动驾驶系统的组件

该系统类似于我们在第十章《元学习》中简要讨论过的强化学习框架。我们有一个反馈循环,其中环境(无论是物理世界还是仿真)提供给代理(车辆)其当前状态。代理则决定其新的行驶轨迹,环境对此作出反应,依此类推。让我们从环境感知子系统开始,它包含以下模块(我们将在接下来的章节中详细讨论):

  • 传感器:物理设备,如摄像头和雷达。

  • 定位:确定车辆在高清地图中的精确位置(精确到厘米级)。

  • 移动物体检测与跟踪:检测并跟踪其他交通参与者,如车辆和行人。

感知系统的输出将来自不同模块的数据进行合成,生成一个中级的周围环境虚拟表示。这种表示通常是一个从上方(鸟瞰)看的 2D 环境视图,称为占用图。以下截图展示了 ChauffeurNet 系统的一个占用图示例,我们将在本章后面详细讨论。它包括路面(白色和黄色线条)、交通信号灯(红色线条)以及其他车辆(白色矩形)。图片最好使用彩色显示:

ChauffeurNet 的占用图。来源: https://arxiv.org/abs/1812.03079

占用图作为路径规划模块的输入,后者利用它来确定车辆的未来行驶轨迹。控制模块将期望的轨迹转化为对车辆的低级控制指令。

中级表示方法有几个优点。首先,它非常适合路径规划和控制模块的功能。此外,除了使用传感器数据来创建自上而下的图像,我们还可以使用模拟器来生成图像。这样,我们可以更容易地收集训练数据,因为我们不必驾驶真实的汽车。更重要的是,我们将能够模拟现实世界中很少发生的情况。例如,我们的自动驾驶系统必须避免任何形式的碰撞,但现实世界中的训练数据几乎没有或根本没有碰撞。如果我们仅使用真实的传感器数据,最重要的驾驶情况将被严重低估。

第二种类型的自动驾驶系统使用单一的端到端组件,该组件将原始传感器数据作为输入,并以转向控制的形式生成驾驶策略,如下图所示:

端到端自动驾驶系统

事实上,当我们在自动驾驶研究简史章节讨论 ALVINN 时,已经提到过端到端系统。接下来,我们将重点讨论顺序系统的不同模块。稍后我们将在本章中更详细地介绍端到端系统。

环境感知

为了使任何自动化功能正常工作,车辆需要对周围环境有良好的感知。环境感知系统必须识别移动物体的确切位置、距离和方向,如行人、自行车骑行者和其他车辆。此外,它还需要创建路面和车辆在该路面以及整个环境中的确切位置的精确映射。接下来,我们将讨论帮助自动驾驶系统创建这种虚拟环境模型的硬件和软件组件。

感知

构建良好环境模型的关键是车辆传感器。以下是最重要的传感器列表:

  • 摄像头:它的图像用于检测路面、车道标线、行人、自行车骑行者、其他车辆等。在汽车领域中,摄像头的一个重要特性(除了分辨率)是视场角。视场角测量摄像头在任何给定时刻可以看到多少可观察到的世界。例如,具有 180^o 视场角的摄像头可以看到它前方的所有内容,但看不见后方。具有 360^o 视场角的摄像头则可以看到它前方和后方的所有内容(全方位观察)。以下是几种不同类型的摄像头系统:

    • 单目摄像头:使用一个朝前的单一摄像头,通常安装在挡风玻璃的顶部。大多数自动化功能都依赖这种类型的摄像头来工作。单目摄像头的典型视场角为 125^o。

    • 立体相机:由两个略微分开安装的前向摄像头组成。两个相机之间的距离使得它们可以从略有不同的角度捕捉到同一幅画面,并将其合成成 3D 图像(与我们使用眼睛的方式相同)。立体系统可以测量图像中某些物体的距离,而单目相机只能依靠启发式方法来完成这一任务。

    • 360^o 环视系统:一些车辆配备了由四个相机(前、后、左、右)组成的系统。

    • 夜视相机:一种系统,车辆配备了一种特殊类型的前照灯,除了常规功能外,还能发射红外光谱中的光。这些光会被红外相机记录,并能向驾驶员显示增强的图像,帮助其在夜间识别障碍物。

  • 雷达:一种系统,通过发射器向不同方向发射电磁波(在无线电或微波谱中)。当这些波到达物体时,通常会被反射,其中一部分波会朝向雷达本身的方向反射回来。雷达可以通过特殊的接收天线探测到这些反射波。因为我们知道无线电波以光速传播,所以通过测量发射和接收信号之间的时间差,我们可以计算出到反射物体的距离。我们还可以通过测量发射波和接收波的频率差(多普勒效应),来计算物体的速度(例如,另一辆车)。与相机图像相比,雷达的“图像”噪声更多,范围更窄,分辨率较低。例如,长距离雷达可以检测到 160 米远的物体,但视野仅为 12^o。雷达可以探测其他车辆和行人,但无法检测到路面或车道标线。雷达通常用于 ACC(自适应巡航控制)和 AEB(自动紧急制动),而 LKA 系统则使用相机。大多数车辆配备一个或两个前向雷达,偶尔也会配备后向雷达。

  • 激光雷达光学探测与测距):这个传感器有点类似于雷达,但它不是使用无线电波,而是发射近红外光谱中的激光束。因此,一次发射的脉冲可以精确测量到一个单独点的距离。激光雷达非常快速地以一种模式发射多个信号,这样就能创建一个环境的 3D 点云(该传感器可以非常快速地旋转)。下面是一个车辆如何通过激光雷达看到世界的示意图:

车辆如何通过激光雷达看到世界的示意图

  • 声呐声波导航与测距):这种传感器发射超声波脉冲,通过听取波在周围物体上反射回来的回声来绘制环境地图。与雷达相比,声呐成本较低,但其有效探测范围有限。因此,它们通常用于停车辅助功能。

多个传感器的数据可以通过一种叫做传感器融合的过程合并成一个统一的环境模型。传感器融合通常通过使用卡尔曼滤波器(en.wikipedia.org/wiki/Kalman_filter)来实现。

定位

定位是确定车辆在地图上精确位置的过程。为什么这很重要?像 HERE(www.here.com/)这样的公司专注于创建极为精确的道路地图,这些地图中的道路表面区域的精度可达几厘米。这些地图还可能包括关于静态物体的信息,如车道标记、交通标志、交通信号灯、限速、斑马线、减速带等。因此,如果我们知道车辆在道路上的精确位置,那么计算最优轨迹就不再困难。

一个显而易见的解决方案是使用 GPS;然而,在完美条件下,GPS 的精度通常只能达到 1-2 米。在高楼大厦或山区等区域,GPS 的精度可能会受到影响,因为 GPS 接收器无法从足够数量的卫星接收到信号。解决这个问题的一种方法是使用同步定位与地图构建SLAM)算法。这些算法超出了本书的范围,但我鼓励你自行研究这个话题。

移动物体检测与跟踪

现在我们已经了解了车辆使用的传感器,并且简要提到过了解其在地图上精确位置的重要性。通过这些知识,车辆理论上可以通过简单地跟随一系列细粒度的点来导航到目的地。然而,自动驾驶的任务并没有那么简单,因为环境是动态的,包括移动的物体,如车辆、行人、自行车和其他物体。自动驾驶车辆必须时刻了解这些移动物体的位置,并在规划其轨迹时进行跟踪。这是我们可以将深度学习算法应用于原始传感器数据的一个领域。首先,我们将针对摄像头进行这一应用。在第五章,物体检测与图像分割中,我们讨论了如何使用卷积网络CNNs)进行两项先进的视觉任务——物体检测和语义分割。

总结一下,物体检测会在图像中不同类别的物体周围创建一个边界框。语义分割会为图像的每个像素分配一个类别标签。我们可以使用分割来检测路面和车道标记的确切形状。我们可以使用物体检测来分类和定位环境中感兴趣的移动物体;不过,我们已经在第五章中介绍了这些话题,物体检测与图像分割。本章将重点讨论激光雷达传感器,并讨论如何在该传感器生成的 3D 点云上应用 CNN。

现在我们已经概述了感知子系统的组成部分,在下一节中,我们将介绍路径规划子系统。

路径规划

路径规划(或驾驶策略)是计算车辆轨迹和速度的过程。尽管我们可能拥有准确的地图和车辆的精确位置,但我们仍然需要牢记环境的动态变化。汽车周围是其他移动的车辆、行人、交通信号灯等等。如果前方的车辆突然停下来会怎样?或者如果它行驶得太慢呢?我们的自动驾驶汽车必须做出超车的决策,并执行这一操作。这是机器学习和深度学习尤其能够发挥作用的领域,我们将在本章讨论两种实现方法。更具体地说,我们将讨论在端到端学习系统中使用模仿驾驶策略,以及 Waymo 开发的名为 ChauffeurNet 的驾驶策略算法。

自动驾驶研究中的一个障碍是,构建一个自动驾驶汽车并获得必要的测试许可非常昂贵且耗时。幸运的是,我们仍然可以借助自动驾驶模拟器来训练我们的算法。

以下是一些最受欢迎的模拟器:

这就是我们对自动驾驶系统各个组成部分的描述。接下来,我们将讨论如何处理 3D 空间数据。

3D 数据处理介绍

激光雷达(LiDAR)生成点云——一个三维空间中的数据点集。请记住,激光雷达发射激光束。激光束从一个表面反射并返回接收器时,会生成点云的一个数据点。如果我们假设激光雷达设备是坐标系的中心,并且每个激光束是一个向量,那么一个点由向量的方向和大小来定义。因此,点云是一个无序集合的向量。或者,我们可以通过它们在空间中的笛卡尔坐标来定义这些点,如下图左侧所示。在这种情况下,点云是一个向量集,每个向量包含该点的三个坐标。为了清晰起见,每个点被表示为一个立方体:

左图:3D 空间中的点(表示为立方体);右图:体素的 3D 网格

接下来,让我们关注神经网络的输入数据格式,特别是卷积神经网络(CNN)。一张 2D 彩色图像表示为一个张量,具有三个切片(每个通道一个),每个切片是一个由像素组成的矩阵(2D 网格)。CNN 使用 2D 卷积(参见第二章,理解卷积网络)。直观上,我们可能会认为可以使用类似的 3D 网格体素(体素是 3D 像素)来处理 3D 点云,如前述图示的右侧所示。假设点云的点没有颜色,我们可以将网格表示为一个 3D 张量,并将其作为输入传递给具有 3D 卷积的 CNN。

然而,如果我们仔细观察这个 3D 网格,就会发现它是稀疏的。例如,在前面的图示中,我们有一个包含 8 个点的点云,但网格包含 4 x 4 x 4 = 64 个单元。在这个简单的例子中,我们将数据的内存占用增加了 8 倍,但在现实世界中,情况可能更糟。 在这一部分,我们将介绍 PointNet(参见PointNet: 深度学习在点集上的 3D 分类与分割arxiv.org/abs/1612.00593),它提供了解决此问题的方案。

PointNet 的输入是点云向量集p[i],而不是它们的 3D 网格表示。为了理解其架构,我们将从导致网络设计的点云向量集的特性开始(以下要点摘自原始论文):

  • 无序:与图像中的像素数组或 3D 网格中的体素数组不同,点云是一个没有特定顺序的点集。因此,消耗N 3D 点集的网络需要对输入集在数据馈送顺序中的N!排列不变。

  • 点之间的交互:类似于图像的像素,3D 点之间的距离可以表示它们之间的关系水平——即,附近的点更可能属于同一物体,而远离的点则可能不属于同一物体。因此,模型需要能够捕捉来自附近点的局部结构及其局部结构之间的组合交互。

  • 变换下的不变性:作为一个几何对象,点集的学习表示应该对某些变换具有不变性。例如,旋转和移动所有点不应改变全局点云类别,也不应改变点的分割。

现在我们已经了解了这些前提条件,让我们来看看 PointNet 如何解决这些问题。我们将从网络架构开始,然后更详细地讨论其组件:

PointNet 架构。来源:https://arxiv.org/abs/1612.00593

PointNet 是一个多层感知器MLP)。这是一个前馈网络,仅由全连接层组成(还有最大池化层,但稍后会详细介绍)。正如我们所提到的,输入点云向量集合 p[i] 被表示为 n × 3 张量。需要注意的是,网络(直到最大池化层)是共享的,应用于集合中所有点。也就是说,尽管输入大小是 n × 3,我们可以认为 PointNet 是对大小为 1 × 3 的 n 个输入向量应用相同的网络 n 次。换句话说,网络权重在所有点云点之间是共享的。这种顺序安排还允许输入点的数量是任意的。

输入经过输入变换(稍后会详细介绍),输出另一个 n × 3 张量,其中每个 n 点由三个分量定义(类似于输入张量)。这个张量被输入到一个上采样全连接层,该层将每个点编码为一个 64 维向量,输出为 n × 64。网络继续进行另一次变换,类似于输入变换。然后,结果通过 64、128 和最终 1,024 个全连接层逐渐上采样,产生最终的 n × 1024 输出。这个张量作为输入传递到一个最大池化层,该层从所有 n 点中相同位置的元素中取最大值,并生成一个 1,024 维的输出向量。这个向量是整个点集的聚合表示。

但为什么一开始就使用最大池化呢?请记住,最大池化是一个对称操作——也就是说,无论输入的顺序如何,它都会产生相同的输出。同时,点的集合本身也是无序的。使用最大池化可以确保网络无论点的顺序如何,都会产生相同的结果。论文的作者选择最大池化而非其他对称函数(如平均池化和求和),是因为在基准数据集中,最大池化表现出了最高的准确率。

在最大池化后,网络根据任务类型分成两条网络(见前面的图示):

  • 分类:1024 维的聚合向量作为输入进入多个全连接层,最终输出k类的 softmax,其中k是类别的数量。这是一个标准的分类流程。

  • 分割:该任务为集合中的每个点分配一个类别。作为分类网络的扩展,这个任务需要结合局部和全局知识。如图所示,我们将每个n个 64 维的中间点表示与全局 1024 维向量进行拼接,形成一个n × 1088 的张量。与网络的初始部分一样,这条路径也在所有点之间共享。每个点的向量通过一系列全连接层(从 1088 到 512,再到 256,最终到 128)被下采样到 128 维。最后的全连接层有m个单元(每个类别一个)并使用 softmax 激活函数。

到目前为止,我们已经通过最大池化操作明确处理了输入数据的无序性,但我们仍然需要解决点与点之间的变换不变性和相互作用。这就是输入和特征变换发挥作用的地方。我们先从输入变换开始(在前面的图示中,这是 T-net)。T-net 是一个多层感知机(MLP),与完整的 PointNet 类似(它被称为迷你 PointNet),如下图所示:

输入(和特征)变换 T-net

输入变换 T-net 将n × 3 点集(与全网络相同的输入)作为输入。与完整的 PointNet 一样,T-net 在所有点上共享。首先,输入通过 64 单元、128 单元和最终 1024 单元的全连接层上采样到n × 1024。上采样后的输出送入最大池化操作,输出 1 × 1024 向量。然后,该向量通过两个 512 单元和 256 单元的全连接层下采样为 1 × 256。该 1 × 256 向量与 256 × 9 的全局(共享)可学习权重矩阵相乘,结果被重新调整为 3 × 3 矩阵,再与原始输入点p[i]逐点相乘,最终生成n × 3 的输出张量。中间的 3 × 3 矩阵充当点集上的可学习仿射变换矩阵。通过这种方式,点相对于网络进行规范化——也就是说,网络在变换下保持不变。第二个 T-net(特征变换)与第一个几乎相同,唯一不同的是输入张量为n × 64,输出为 64 × 64 矩阵。

尽管全局最大池化层确保网络不受数据顺序的影响,但它也有另一个缺点,因为它创建了整个输入点集的单一表示;然而,这些点可能属于不同的物体(例如,车辆和行人)。在这种情况下,全局聚合可能会带来问题。为了解决这个问题,PointNet 的作者提出了 PointNet++(详见PointNet++: Deep Hierarchical Feature Learning on Point Sets in a Metric Space,可访问arxiv.org/abs/1706.02413),这是一种分层神经网络,通过对输入点集的嵌套划分递归应用 PointNet。

在本节中,我们探讨了在自动驾驶环境感知系统中处理 3D 数据的内容。在下一节中,我们将把注意力转向模仿驾驶策略的路径规划系统。

模仿驾驶策略

自动驾驶系统的组件部分,我们概述了构建自动驾驶系统所必需的几个模块。在本节中,我们将介绍如何通过深度学习实现其中之一——驾驶策略。实现这种方法的一种方式是使用强化学习(RL),其中汽车是智能体,环境就是环境。另一种流行的方法是模仿学习,其中模型(网络)学习模仿专家(人类)的行为。我们来看一下在自动驾驶场景中模仿学习的特性:

  • 我们将使用一种模仿学习方法,称为行为克隆。这意味着我们将以监督的方式训练我们的网络。另一种选择是,在强化学习(RL)场景中使用模仿学习,这种方法被称为逆强化学习(inverse RL)。

  • 网络的输出是驾驶策略,表示为期望的转向角度和/或加速度或刹车。例如,我们可以为转向角度设置一个回归输出神经元,为加速度或刹车设置一个神经元(因为不能同时控制两者)。

  • 网络输入可以是以下任意一种:

    • 端到端系统的原始传感器数据——例如来自前向摄像头的图像。AV 系统中,单一模型使用原始传感器输入并输出驾驶策略,称为端到端

    • 用于顺序复合系统的中级环境表示。

  • 我们将借助专家创建训练数据集。我们让专家手动驾驶车辆,无论是在真实世界还是模拟器中。在每一步旅程中,我们将记录以下内容:

    • 当前的环境状态。这可以是原始传感器数据或自上而下的视图表示。我们将使用当前状态作为模型的输入。

    • 在当前环境状态下专家的行为(转向角度和刹车/加速度)。这将是网络的目标数据。在训练过程中,我们只需通过常见的梯度下降方法最小化网络预测与驾驶员行为之间的误差。通过这种方式,我们将教会网络模仿驾驶员。

行为克隆场景如以下图所示:

行为克隆场景

如我们之前提到的,ALVINN(来自自动驾驶研究简史章节)是一个行为克隆的端到端系统。最近,论文端到端自驾车学习(arxiv.org/abs/1604.07316)介绍了一个类似的系统,使用具有五个卷积层的 CNN,取代了完全连接的网络。在他们的实验中,车载前向摄像头的图像被输入到 CNN 中。CNN 的输出是一个标量值,表示汽车的期望转向角度。网络不控制加速和刹车。为了构建训练数据集,论文的作者收集了大约 72 小时的真实世界驾驶视频。在评估过程中,车辆能够在郊区区域自行驾驶 98%的时间(不包括变道和从一条路转到另一条路)。此外,它还成功地在一条多车道的高速公路上行驶了 16 公里而没有干预。在接下来的章节中,我们将实现一些有趣的内容——一个使用 PyTorch 的行为克隆示例。

使用 PyTorch 进行行为克隆

在本节中,我们将实现一个基于 PyTorch 1.3.1 的行为克隆示例。为了帮助我们完成这一任务,我们将使用 OpenAI Gym(gym.openai.com/),这是一个开源工具包,用于强化学习算法的开发和比较。它允许我们训练智能体完成各种任务,如走路或玩 Pong、Pinball 等 Atari 游戏,甚至 Doom。

我们可以通过 pip 安装它:

pip install gym[box2d]

在这个示例中,我们将使用 CarRacing-v0 OpenAI Gym 环境,如下图所示:

在 CarRacing-v0 环境中,智能体是一个赛车;始终使用俯视图。

这个示例包含多个 Python 文件。在本节中,我们将提及最重要的部分。完整的源代码可以在 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter11/imitation_learning 找到。

目标是让红色赛车(称为智能体)尽可能快速地绕过赛道,同时避免滑出路面。我们可以通过四个动作来控制赛车:加速、刹车、左转和右转。每个动作的输入是连续的——例如,我们可以用 1.0 表示全油门,用 0.5 表示半油门(其他控制也是如此)。

为了简化问题,我们假设只能指定两个离散的动作值:0 表示不做任何动作,1 表示完全动作。由于最初这是一个强化学习环境,智能体会在每一步进行奖励,但我们不使用奖励,因为智能体将直接从我们的动作中学习。我们将执行以下步骤:

  1. 创建一个训练数据集,通过自己驾驶汽车绕赛道(我们将使用键盘箭头控制)。换句话说,我们将成为代理试图模仿的专家。在每一集的每一步中,我们将记录当前游戏帧(状态)和当前按下的键,并将它们存储在一个文件中。这一步的完整代码可在 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/blob/master/Chapter11/imitation_learning/keyboard_agent.py 找到。你只需运行该文件,游戏即将开始。在你玩耍的过程中,每隔五集,就会在 imitation_learning/data/data.gzip 文件中记录一集。如果你想重新开始,只需简单地删除它。你可以通过按 Escape 退出游戏,按 Spacebar 暂停游戏。你还可以通过按 Enter 开始新一集。在这种情况下,当前集将被丢弃,其序列不会被存储。我们建议你至少玩 20 集,以获得足够大的训练数据集。建议经常使用制动,因为否则数据集会变得不平衡。在正常游戏中,加速度比制动或转向频繁使用得多。或者,如果你不想玩,仓库中已经包含了现有的数据文件。

  2. 代理由 CNN 表示。我们将使用刚刚生成的数据集以监督方式对其进行训练。输入将是单个游戏帧,输出将是方向盘转向和制动/加速的组合。目标(标签)将是人类操作者记录的动作。如果你想跳过这一步骤,github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/tree/master/Chapter11/imitation_learning/data/model.pt 仓库中已经有一个训练好的 PyTorch 网络。

  3. 让 CNN 代理通过使用网络输出来决定发送到环境的下一步动作来进行游戏。你可以通过简单运行 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/blob/master/Chapter11/imitation_learning/nn_agent.py 文件来实现这一点。如果之前两个步骤你都没有执行过,这个文件将使用现有的代理。

介绍完毕,让我们继续准备训练数据集。

生成训练数据集

在本节中,我们将介绍如何生成训练数据集并将其加载为 PyTorch 的 torch.utils.data.DataLoader 类的实例。我们会重点讲解代码中最相关的部分,但完整的源代码位于 github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/blob/master/Chapter11/imitation_learning/train.py

我们将分几步创建训练数据集:

  1. read_data 函数读取 imitation_learning/data/data.gzip 中的两个 numpy 数组:一个用于游戏帧,另一个用于与之相关的键盘组合。

  2. 环境接受由三元素数组组成的动作,满足以下条件:

    • 第一个元素的值范围在 [-1, 1] 之间,表示转向角度(-1 表示右转,1 表示左转)。

    • 第二个元素的值在 [0, 1] 范围内,表示油门。

    • 第三个元素的值在 [0, 1] 范围内,表示刹车力度。

  3. 我们将使用七种最常见的键位组合:[0, 0, 0] 表示没有操作(汽车滑行),[0, 1, 0] 表示加速,[0, 0, 1] 表示刹车,[-1, 0, 0] 表示左转,[-1, 0, 1] 表示左转和刹车的组合,[1, 0, 0] 表示右转,以及 [1, 0, 1] 表示右转和刹车的组合。我们故意避免了同时使用加速和左转或右转,因为那样会导致汽车非常不稳定。其他的组合是不可能的。read_data 函数会将这些数组转换为一个从 06 的单一类别标签。这样,我们就简单地解决了一个具有七个类别的分类问题。

  4. read_data 函数还将平衡数据集。如我们所提到的,加速是最常见的键位组合,而其他一些,比如刹车,则是最少见的。因此,我们将移除一些加速样本,并且会对一些刹车(以及左/右+刹车)样本进行倍增。然而,作者是通过尝试多种删除/倍增比例的组合,并选择表现最佳的方式来进行操作的。如果你记录自己的数据集,你的驾驶风格可能会有所不同,你可能需要调整这些比例。

一旦我们得到了训练样本的 numpy 数组,我们将使用 create_datasets 函数将它们转换为 torch.utils.data.DataLoader 实例。这些类简单地让我们能够以小批量的方式提取数据并应用数据增强。

但首先,让我们实现data_transform转换列表,这些转换会在将图像输入网络之前修改图像。完整实现请参见github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/blob/master/Chapter11/imitation_learning/util.py。我们将把图像转换为灰度图,规范化颜色值到[0, 1]范围,并裁剪图像的底部(黑色矩形框,显示奖励和其他信息)。实现如下:

data_transform = torchvision.transforms.Compose([
   torchvision.transforms.ToPILImage(),
    torchvision.transforms.Grayscale(1),
    torchvision.transforms.Pad((12, 12, 12, 0)),
    torchvision.transforms.CenterCrop(84),
    torchvision.transforms.ToTensor(),
    torchvision.transforms.Normalize((0,), (1,)),
])

接下来,让我们将注意力转回到create_datasets函数。我们将从声明开始:

def create_datasets():

然后,我们将实现TensorDatasetTransforms辅助类,以便对输入图像应用data_transform转换。实现如下(请注意缩进,因为这段代码仍然是create_datasets函数的一部分):

    class TensorDatasetTransforms(torch.utils.data.TensorDataset):
        def __init__(self, x, y):
            super().__init__(x, y)

        def __getitem__(self, index):
            tensor = data_transform(self.tensors[0][index])
            return (tensor,) + tuple(t[index] for t in self.tensors[1:])

接下来,我们将完全读取之前生成的数据集:

    x, y = read_data()
    x = np.moveaxis(x, 3, 1)  # channel first (torch requirement)

然后,我们将创建训练和验证数据加载器(train_loaderval_loader)。最后,我们将它们作为create_datasets函数的结果返回:

    # train dataset
    x_train = x[:int(len(x) * TRAIN_VAL_SPLIT)]
    y_train = y[:int(len(y) * TRAIN_VAL_SPLIT)]

    train_set = TensorDatasetTransforms(torch.tensor(x_train), torch.tensor(y_train))

    train_loader = torch.utils.data.DataLoader(train_set, batch_size=BATCH_SIZE,
                                               shuffle=True, num_workers=2)

    # test dataset
    x_val, y_val = x[int(len(x_train)):], y[int(len(y_train)):]

    val_set = TensorDatasetTransforms(torch.tensor(x_val), torch.tensor(y_val))

    val_loader = torch.utils.data.DataLoader(val_set, batch_size=BATCH_SIZE,
                                             shuffle=False, num_workers=2)

    return train_loader, val_loader

接下来,让我们关注代理的神经网络架构。

实现代理神经网络

代理由一个 CNN 表示,具有以下属性:

  • 一个单输入的 84 × 84 切片。

  • 三个卷积层,带有步幅用于下采样。

  • ELU 激活函数。

  • 两个全连接层。

  • 七个输出神经元(每个神经元一个)。

  • 批量归一化和丢弃法(dropout),应用于每一层之后(即使是卷积层),以防止过拟合。在这个任务中,过拟合尤为严重,因为我们无法使用任何有意义的数据增强技术。例如,假设我们随机水平翻转了图像。在这种情况下,我们还需要修改标签,反转方向盘值。因此,我们将尽可能依赖正则化。

以下代码块展示了网络实现:

def build_network():
    return torch.nn.Sequential(
        torch.nn.Conv2d(1, 32, 8, 4),
        torch.nn.BatchNorm2d(32),
        torch.nn.ELU(),
        torch.nn.Dropout2d(0.5),
        torch.nn.Conv2d(32, 64, 4, 2),
        torch.nn.BatchNorm2d(64),
        torch.nn.ELU(),
        torch.nn.Dropout2d(0.5),
        torch.nn.Conv2d(64, 64, 3, 1),
        torch.nn.ELU(),
        torch.nn.Flatten(),
        torch.nn.BatchNorm1d(64 * 7 * 7),
        torch.nn.Dropout(),
        torch.nn.Linear(64 * 7 * 7, 120),
        torch.nn.ELU(),
        torch.nn.BatchNorm1d(120),
        torch.nn.Dropout(),
        torch.nn.Linear(120, len(available_actions)),
    )

在实现了训练数据集和代理之后,我们可以继续进行训练。

训练

我们将通过train函数实现训练,该函数接受网络和cuda设备作为参数。我们将使用交叉熵损失函数和 Adam 优化器(这是分类任务的常用组合)。该函数简单地迭代EPOCHS次数,并对每个周期调用train_epochtest函数。以下是实现:

def train(model: torch.nn.Module, device: torch.device):
    loss_function = torch.nn.CrossEntropyLoss()

    optimizer = torch.optim.Adam(model.parameters())

    train_loader, val_order = create_datasets()  # read datasets

    # train
    for epoch in range(EPOCHS):
        print('Epoch {}/{}'.format(epoch + 1, EPOCHS))

        train_epoch(model, device, loss_function, optimizer, train_loader)

        test(model, device, loss_function, val_order)

        # save model
        model_path = os.path.join(DATA_DIR, MODEL_FILE)
        torch.save(model.state_dict(), model_path)

然后,我们将实现train_epoch函数进行单轮训练。该函数遍历所有的小批量并对每个小批量执行前向和反向传播。以下是实现:

def train_epoch(model, device, loss_function, optimizer, data_loader):
    model.train() # set model to training mode
    current_loss, current_acc = 0.0, 0.0

    for i, (inputs, labels) in enumerate(data_loader):
        inputs, labels = inputs.to(device), labels.to(device) # send to device

        optimizer.zero_grad() # zero the parameter gradients
        with torch.set_grad_enabled(True):
            outputs = model(inputs) # forward
            _, predictions = torch.max(outputs, 1)
            loss = loss_function(outputs, labels)

            loss.backward() # backward
            optimizer.step()

        current_loss += loss.item() * inputs.size(0) # statistics
        current_acc += torch.sum(predictions == labels.data)

    total_loss = current_loss / len(data_loader.dataset)
    total_acc = current_acc / len(data_loader.dataset)

    print('Train Loss: {:.4f}; Accuracy: {:.4f}'.format(total_loss, total_acc))

train_epochtest函数类似于我们在第二章中为迁移学习代码示例实现的函数,理解卷积网络。为了避免重复,我们在此不实现test函数,尽管它可以在 GitHub 仓库中找到。

我们将训练大约 100 个 epoch,但你可以将其缩短为 20 或 30 个 epoch 以进行快速实验。使用默认训练集时,一个 epoch 通常少于一分钟。既然我们已经熟悉了训练过程,让我们看看如何使用智能体神经网络在模拟环境中驾驶赛车。

让智能体驾驶

我们将从实现nn_agent_drive函数开始,该函数允许智能体玩游戏(定义在github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/blob/master/Chapter11/imitation_learning/nn_agent.py)中。该函数将以初始状态(游戏帧)启动env环境,并将其作为输入传递给网络。然后,我们将把 softmax 网络输出从 one-hot 编码转换为基于数组的动作,并将其发送到环境中以执行下一步。我们将重复这些步骤,直到一集结束。nn_agent_drive函数还允许用户通过按Escape键退出。请注意,我们仍然使用与训练时相同的data_transform变换。

首先,我们将实现初始化部分,将Esc键与环境初始化绑定:

def nn_agent_drive(model: torch.nn.Module, device: torch.device):
    env = gym.make('CarRacing-v0')

    global human_wants_exit  # use ESC to exit
    human_wants_exit = False

    def key_press(key, mod):
        """Capture ESC key"""
        global human_wants_exit
        if key == 0xff1b:  # escape
            human_wants_exit = True

    state = env.reset()  # initialize environment
    env.unwrapped.viewer.window.on_key_press = key_press

接下来,我们将实现主循环,其中智能体(车辆)采取action,环境返回新的state,以此类推。这个动态反映在无限while循环中(请注意缩进,因为这段代码仍然是nn_agent_play的一部分):

    while 1:
        env.render()

        state = np.moveaxis(state, 2, 0) # channel first image
        state = torch.from_numpy(np.flip(state, axis=0).copy()) # np to tensor
        state = data_transform(state).unsqueeze(0) # apply transformations
        state = state.to(device) # add additional dimension

        with torch.set_grad_enabled(False): # forward
            outputs = model(state)

        normalized = torch.nn.functional.softmax(outputs, dim=1)

        # translate from net output to env action
        max_action = np.argmax(normalized.cpu().numpy()[0])
        action = available_actions[max_action]
        action[2] = 0.3 if action[2] != 0 else 0 # adjust brake power

        state, _, terminal, _ = env.step(action) # one step

        if terminal:
            state = env.reset()

        if human_wants_exit:
            env.close()
            return

现在我们有了运行程序的所有必要成分,接下来我们将在下一部分中运行它。

将所有内容整合在一起

最后,我们可以运行整个程序。完整代码可以在github.com/PacktPublishing/Advanced-Deep-Learning-with-Python/blob/master/Chapter11/imitation_learning/main.py找到。

以下代码片段构建并恢复(如果可用)网络,运行训练并评估网络:

# create cuda device
dev = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# create the network
model = build_network()

# if true, try to restore the network from the data file
restore = False
if restore:
    model_path = os.path.join(DATA_DIR, MODEL_FILE)
    model.load_state_dict(torch.load(model_path))

# set the model to evaluation (and not training) mode
model.eval()

# transfer to the gpu
model = model.to(dev)

# train
train(model, dev)

# agent play
nn_agent_drive(model, dev)

虽然我们不能在这里展示代理的实际操作,但你可以通过本节中的说明轻松看到它的操作。不过,我们可以说它学习得很好,并且能够定期完成赛车赛道的全程(但并非总是如此)。有趣的是,网络的驾驶风格与生成数据集的操作员的风格非常相似。这个例子也表明,我们不应低估监督学习。我们能够利用一个小数据集,在相对较短的训练时间内创建出一个表现相当不错的代理。

至此,我们完成了模仿学习的示例。接下来,我们将讨论一个更加复杂的驾驶策略算法——ChauffeurNet。

使用 ChauffeurNet 的驾驶策略

在本节中,我们将讨论一篇名为ChauffeurNet: Learning to Drive by Imitating the Best and Synthesizing the Worst的最新论文(arxiv.org/abs/1812.03079)。该论文于 2018 年 12 月由 Waymo 发布,Waymo 是自动驾驶领域的领军企业之一。让我们来看看 ChauffeurNet 模型的一些特性:

  • 它是两个相互连接的网络的组合。第一个是称为 FeatureNet 的卷积神经网络(CNN),用于从环境中提取特征。这些特征作为输入提供给第二个递归网络 AgentRNN,后者决定驾驶策略。

  • 它以类似于我们在模仿驾驶策略章节中描述的算法的方式使用模仿监督学习。训练集是基于真实世界驾驶记录生成的。ChauffeurNet 能够处理复杂的驾驶场景,如变道、交通信号灯、交通标志、从一条街道变到另一条街道等。

本文由 Waymo 发布在 arxiv.org,仅用于参考目的。Waymo 与 arxiv.org 并无隶属关系,且不对本书或其作者表示支持。

我们将从输入和输出数据表示开始讨论 ChauffeurNet。

输入和输出表示

端到端方法将原始传感器数据(例如,摄像头图像)输入到机器学习算法(神经网络,NN)中,后者生成驾驶策略(转向角度和加速度)。相比之下,ChauffeurNet 使用我们在自动驾驶系统组成部分章节中介绍的中间层输入和输出。让我们先来看一下机器学习算法的输入。这是一系列从上到下(鸟瞰视角)400 × 400 的图像,类似于CarRacing-v0环境中的图像,但要复杂得多。时刻t由多张图像表示,每张图像包含环境中的不同元素。

我们可以在下图中看到 ChauffeurNet 输入/输出组合的一个例子:

ChauffeurNet 输入。来源:https://arxiv.org/abs/1812.03079

让我们按字母顺序依次查看输入元素(从(a)到(g)):

  • (a) 是道路地图的精确表示。它是一张 RGB 图像,使用不同的颜色来表示各种道路特征,如车道、人行道、交通标志和路缘。

  • (b) 是交通信号灯的灰度图像时间序列。与 (a) 的特征不同,交通信号灯是动态的——也就是说,它们可以在不同的时间是绿灯、红灯或黄灯。为了正确地表达它们的动态,算法使用一系列图像,显示过去 T[scene] 秒内每个车道的交通信号灯状态,直到当前时刻。每个图像中线条的颜色表示每个交通信号灯的状态,其中最亮的颜色是红色,居中的颜色是黄色,最暗的颜色是绿色或未知。

  • (c) 是一张灰度图像,表示每个车道的已知限速。不同的颜色强度表示不同的限速。

  • (d) 是起点和终点之间的预定路线。可以将其视为 Google Maps 生成的路线指引。

  • (e) 是一张灰度图像,表示代理的当前位置(显示为白色方框)。

  • (f) 是一系列灰度图像,表示环境的动态元素(显示为方框)。这些可能是其他车辆、行人或骑行者。随着这些物体的位置随时间变化,算法通过一系列快照图像来传达它们的轨迹,表示它们在过去 T[scene] 秒内的位置。这与交通信号灯 (b) 的表现方式相同。

  • (g) 是一张灰度图像,表示过去 T[pose] 秒内代理的轨迹,直到当前时刻。代理的位置显示为图像中的一系列点。请注意,我们将它们显示在一张单独的图像中,而不是像其他动态元素那样以时间序列的形式显示。时刻 t 处的代理以相同的俯视图环境表示,属性为 ,其中 是坐标, 是方向(或航向),而 是速度。

  • (h) 是算法的中间输出:代理的未来轨迹,表示为一系列的点。这些点与过去的轨迹 (g) 具有相同的含义。时间 t+1 处的未来位置是通过使用从过去轨迹 (g) 到当前时刻 t 的信息生成的。我们将 ChauffeurNet 表示为:

这里,I是所有前面的输入图像,p[t]是时间t时代理的位置,δt是 0.2 秒的时间差。δt的值是任意的,由论文的作者选择。一旦我们得到了t+δt,我们可以将其添加到过去的轨迹(g)中,并且可以通过递归方式生成步骤t+2δt*时的下一个位置。新生成的轨迹会被输入到车辆的控制模块中,控制模块尽力通过车辆控制(转向、加速和刹车)来执行它。

正如我们在自动驾驶系统的组成部分部分中提到的,这种中级输入表示使我们能够轻松地使用不同来源的训练数据。它可以通过将真实世界的驾驶数据与车辆传感器输入(如摄像头和激光雷达)和地图数据(如街道、交通灯、交通标志等)融合生成。但我们也可以通过模拟环境生成相同格式的图像。中级输出同样适用,控制模块可以连接到各种类型的物理车辆或模拟车辆。使用模拟使得我们可以从现实世界中少见的情况中学习,比如紧急刹车甚至碰撞。为了帮助代理学习这些情况,论文的作者明确地通过模拟合成了多种罕见的场景。

现在我们已经熟悉了数据表示,接下来我们将重点关注模型的核心组件。

模型架构

以下图示说明了 ChauffeurNet 模型架构:

(a)ChauffeurNet 架构和(b)迭代过程中的记忆更新 来源:arxiv.org/abs/1812.03079

首先,我们有 FeatureNet(在前面的图中,这由(a)标示)。这是一个带有残差连接的卷积神经网络(CNN),其输入是我们在输入和输出表示部分看到的自上而下的图像。FeatureNet 的输出是一个特征向量F,它表示合成网络对当前环境的理解。这个向量作为递归网络 AgentRNN 的输入之一,AgentRNN 会迭代地预测驾驶轨迹的后续点。假设我们想预测代理在步骤k时的下一个轨迹点。在这种情况下,AgentRNN 有以下输出:

  • p[k]* 是在该步骤中预测的驾驶轨迹的下一个点。从图中可以看到,AgentRNN 的输出实际上是与输入图像具有相同维度的热图。它表示空间坐标上的概率分布Pk,指示每个热图单元(像素)上下一个路径点的概率。我们使用arg-max操作从这个热图中获得粗略的姿态预测p[k]*。

  • B[k] 是步骤 k 时代理的预测边界框。像航点输出一样,B[k] 也是一个热图,但这里每个单元格使用 sigmoid 激活,表示代理占据该特定像素的概率。

  • 还有两个额外的输出未在图中显示:θ[k] 代表代理的朝向(或方向),s[k] 代表期望的速度。

ChauffeurNet 还包括一个加性记忆,记作 M(在前述图中,这由 (b) 标记)。M 是我们在 输入与输出表示 部分定义的单通道输入图像(g)。它表示过去步骤 k 的航点预测(p[k], p[k-1], ...., p[0])。当前航点 p[k] 会在每一步加入记忆,如前述图所示。

输出 p[k]B[k] 会作为输入递归地反馈给 AgentRNN,用于下一步 k+1。AgentRNN 输出的公式如下:

接下来,让我们看看 ChauffeurNet 如何在顺序的自动驾驶管道中进行集成:

ChauffeurNet 在完整的端到端驾驶管道中的应用。来源:https://arxiv.org/abs/1812.03079

该系统类似于我们在 自动驾驶系统的组成部分 部分介绍的反馈回路。我们来看看它的组成部分:

  • 数据渲染器:接收来自环境和动态路由器的输入。其作用是将这些信号转换为我们在 输入与输出表示 部分定义的自上而下的输入图像。

  • 动态路由器:提供预期的路线,并根据代理是否能够到达前一个目标坐标动态更新。可以把它想象成一个导航系统,你输入一个目的地,它就会提供一条到目标的路线。你开始沿着这条路线导航,如果偏离路线,系统会根据你当前的位置和目标动态计算出新的路线。

  • 神经网络:ChauffeurNet 模块,输出期望的未来轨迹。

  • 控制优化:接收未来轨迹,并将其转换为驱动车辆的低级控制信号。

ChauffeurNet 是一个相当复杂的系统,所以现在我们来看看如何训练它。

训练

ChauffeurNet 使用 3000 万个专家驾驶示例进行模仿监督学习训练。模型输入是我们在 输入与输出表示 部分定义的自上而下的图像,如下图所示的扁平化(聚合)输入图像:

图像最好以彩色查看。来源:https://arxiv.org/abs/1812.03079

接下来,让我们看看 ChauffeurNet 训练过程的组成部分:

ChauffeurNet 训练组件:(a)模型本身,(b)附加网络,和(c)损失函数。来源: https://arxiv.org/abs/1812.03079

我们已经熟悉了 ChauffeurNet 模型本身(在前图中标记为(a))。现在让我们关注过程中涉及的另外两个网络(在前图中标记为(b)):

  • 道路掩码 Net:输出当前输入图像上道路表面确切区域的分割掩码。为了更好地理解这一点,以下图片展示了目标道路掩码(左)和网络预测的道路掩码(右):

来源: https://arxiv.org/abs/1812.03079

  • PerceptionRNN:输出一个分割掩码,包含环境中每个其他动态物体的预测未来位置(例如,车辆、骑行者、行人等)。PerceptionRNN 的输出在下图中进行了说明,展示了其他车辆的预测位置(浅色矩形):

来源: https://arxiv.org/abs/1812.03079

这些网络不参与最终的车辆控制,仅在训练过程中使用。使用它们的目标是,FeatureNet 网络在接收来自三个任务(AgentRNN、道路掩码网和 PerceptionRNN)的反馈时,将比单独从 AgentRNN 接收反馈时学到更好的表示。

现在,让我们关注各种损失函数(ChauffeurNet 架构的底部部分(c))。我们从模仿损失开始,这反映了模型预测的未来代理位置与人类专家地面真值之间的差异。以下列表显示了 AgentRNN 的输出及其相应的损失函数:

  • 一个关于预测路径点p[k]的空间坐标的概率分布Pk*。我们将使用以下损失函数来训练这个组件:

在这里,是交叉熵损失,是预测的分布,是地面真值分布。

  • 代理边界框B[k]的热图。我们可以使用以下损失(应用于热图的各个单元)来训练它:

这里,WH是输入图像的尺寸,是预测的热图,是地面真值热图。

  • 代理的朝向(方向)θ[k],其损失如下:

在这里,θ[k]是预测的方向,是地面真值方向。

论文的作者还介绍了过去运动的丢弃。我们可以通过引用论文来最好地解释这一点:

在训练过程中,模型会将过去的运动历史作为输入之一(图像(g)在“输入和输出表示”一节中的示意图)。由于训练过程中使用的过去运动历史来源于专家演示,因此网络可以通过仅仅从过去外推来“作弊”,而不是去寻找行为的潜在原因。在闭环推理过程中,这种方法会失败,因为过去的历史来自于网络自身的过去预测。例如,这样训练出来的网络可能只会在看到过去历史中的减速时才为停车标志停车,因此在闭环推理中将永远不会为停车标志停车。为了解决这个问题,我们在过去的姿态历史上引入了一个丢弃机制,对于 50%的样本,我们只保留智能体在过去姿态通道中的当前位置(u[0], v[0]),这样迫使网络通过环境中的其他线索来解释训练样本中的未来运动轮廓。

他们还观察到,当驾驶情境与专家驾驶训练数据没有显著差异时,模仿学习方法效果良好。然而,智能体必须为许多训练数据中没有的驾驶情境做好准备,比如碰撞。如果智能体仅依赖于训练数据,它将不得不隐性地学习碰撞知识,这并不容易。为了解决这个问题,论文提出了针对最重要情境的显式损失函数。这些情境包括:

  • 航点损失:地面真实值与预测的智能体未来位置p[k]之间的误差。

  • 速度损失:地面真实值与预测的智能体未来速度s[k]之间的误差。

  • 目标损失:地面真实值与预测的智能体未来方向θ[k]之间的误差。

  • 智能体边框损失:地面真实值与预测的智能体边界框B[k]之间的误差。

  • 几何损失:强制智能体明确地遵循目标轨迹,与速度轮廓无关。

  • 道路损失:强制智能体仅在道路表面区域导航,避免进入环境中的非道路区域。如果智能体的预测边界框与由道路掩码网络预测的非道路区域重叠,该损失将增大。

  • 碰撞损失:明确地强制智能体避免碰撞。如果智能体预测的边界框与环境中任何其他动态物体的边界框重叠,则该损失将增大。

ChauffeurNet 在各种真实世界的驾驶情况中表现良好。你可以在medium.com/waymo/learning-to-drive-beyond-pure-imitation-465499f8bcb2查看一些结果。

摘要

本章我们探讨了深度学习在自动驾驶汽车(AVs)中的应用。我们从自动驾驶研究的简要历史回顾开始,并讨论了不同的自主性等级。接着,我们描述了自动驾驶系统的各个组件,并确定了何时使用深度学习技术。然后,我们研究了 3D 数据处理和 PointNet。接着,我们介绍了使用行为克隆实现驾驶策略的主题,并用 PyTorch 实现了一个模仿学习的示例。最后,我们看了 Waymo 的 ChauffeurNet 系统。

本章结束了我们的书籍。希望你喜欢这次阅读!

posted @ 2025-07-08 21:22  绝不原创的飞龙  阅读(71)  评论(0)    收藏  举报