原文:
zh.annas-archive.org/md5/98cfb0b9095f1cf64732abfaa40d7b3a
译者:飞龙
协议:CC BY-NC-SA 4.0
第五章:图像识别
视觉可以说是人类最重要的感官之一。我们依赖视觉来识别食物,逃离危险,认出朋友和家人,以及在熟悉的环境中找到方向。我们甚至依赖视觉来阅读这本书,并识别其中打印的每一个字母和符号。然而,图像识别一直以来一直是计算机科学中最困难的问题之一。因为要教会计算机如何识别不同的物体是非常困难的,因为很难向机器解释构成指定物体的特征。然而,正如我们所看到的,深度学习中的神经网络通过自身学习,也就是学会了构成每个物体的特征,因此非常适合图像识别这样的任务。
在本章中,我们将涵盖以下主题:
-
人造模型和生物模型之间的相似之处
-
CNN 的直觉和理由
-
卷积层
-
池层
-
丢弃
-
深度学习中的卷积层
人造模型和生物模型之间的相似之处
人类视觉是一个复杂且结构严谨的过程。视觉系统通过视网膜、丘脑、视觉皮层和颞下皮质等阶级性地理解现实。视网膜的输入是一个二维的颜色密度数组,通过视神经传递到丘脑。丘脑除了嗅觉系统的感官信息外,还接收从视网膜收集的视觉信息,然后将该信息传递到初级视觉皮层,也就是 V1 区,它提取基本信息,例如线条和运动方向。然后信息流向负责色彩解释和不同光照条件下的颜色恒定性的 V2 区,然后到达 V3 和 V4 区,改善色彩和形态感知。最后,信息传递到颞下皮质(IT),用于物体和面部识别(事实上,IT 区域还进一步细分为三个亚区,即后部 IT、中央 IT 和前部 IT)。因此,大脑通过在不同层级处理信息来处理视觉信息。我们的大脑似乎通过在不同层级上创建简单的抽象现实表示,然后将它们重新组合在一起来解决这个问题(详细参考:J. DiCarlo, D. Zoccolan, and N. Rust, 大脑是如何处理视觉物体识别的?,www.ncbi.nlm.nih.gov/pmc/articles/PMC3306444
)。
我们目前看到的深度学习神经网络通过创建抽象表示来工作,就像我们在 RBM 中看到的那样,但是理解感官信息的重要拼图中还有另一个重要部分:我们从感官输入中提取的信息通常主要由最相关的信息确定。从视觉上看,我们可以假设附近的像素是最相关的,它们的集体信息比我们从彼此非常遥远的像素中得出的信息更相关。在理解语音方面,我们已经讨论过研究三音素的重要性,也就是说,对音频的理解依赖于其前后的声音。要识别字母或数字,我们需要理解附近像素的依赖性,因为这决定了元素的形状,从而区分例如 0 和 1 等之间的差异。总的来说,远离 0 的像素通常对我们理解数字"0"没有或几乎没有影响。卷积网络的构建正是为了解决这个问题:如何使与更近的神经元相关的信息比来自更远的神经元更相关的信息。在视觉问题中,这意味着让神经元处理来自附近像素的信息,并忽略与远离像素相关的信息。
直觉和理解
我们在第三章中已经提到了 Alex Krizhevsky、Ilya Sutskever 和 Geoffrey Hinton 在 2012 年发表的论文:使用深度卷积神经网络进行 ImageNet 分类。尽管卷积的起源可以追溯到 80 年代,但那是第一篇突出卷积网络在图像处理和识别中深刻重要性的论文之一,当前几乎没有用于图像识别的深度神经网络可以在没有某些卷积层的情况下工作。
我们在使用传统前馈网络时遇到的一个重要问题是它们可能会过拟合,特别是在处理中等到大型图像时。这通常是因为神经网络具有非常多的参数,事实上,在经典神经网络中,一层中的所有神经元都连接到下一层中的每一个神经元。当参数数量很大时,过拟合的可能性更大。让我们看以下图片:我们可以通过画一条穿过所有点的线来拟合数据,或者更好的是,一条不完全匹配数据但更可能预测未来示例的线。
图中的点表示输入数据点。虽然它们明显遵循抛物线的形状,但由于数据中的噪声,它们可能不会被精确地绘制到抛物线上。
在两幅图中的第一个例子中,我们对数据进行了过拟合。在第二个例子中,我们已经将我们的预测与数据匹配得更好,这样我们的预测更有可能更好地预测未来的数据。在第一种情况下,我们只需要三个参数来描述曲线:y = ax² + bx + c,而在第二种情况下,我们需要比三个参数多得多的参数来编写该曲线的方程。这直观地解释了为什么有时候拥有太多参数可能不是一件好事,而且可能导致过拟合。对于像 cifar10
示例中那样小的图像(cifar10
是一个经过验证的计算机视觉数据集,由 60000 张 32 x 32 图像组成,分为 10 类,在本章中我们将看到该数据集的几个示例),经典的前馈网络的输入大小为 3 x 32 x 32,已经约为简单 mnist
数字图像的四倍。更大的图像,比如 3 x 64 x 64,将拥有大约 16 倍于输入神经元数量的连接权重:
在左图中,我们画了一条与数据完全匹配的直线。在第二个图中,我们画了一条近似连接数据点形状的直线,但并不完全匹配数据点。尽管第二条曲线在当前输入上不够精确,但比第一张图中的曲线更有可能预测未来的数据点。
卷积网络减少了所需的参数数量,因为它们要求神经元仅在本地与对应于相邻像素的神经元连接,因此有助于避免过拟合。此外,减少参数数量也有助于计算。在下一节中,我们将介绍一些卷积层的示例来帮助理解,然后我们将正式定义它们。
卷积层
卷积层(有时在文献中称为 “滤波器”)是一种特殊类型的神经网络,它操作图像以突出显示某些特征。在深入了解细节之前,让我们使用一些代码和一些示例介绍一个卷积滤波器。这将使直觉更简单,也将更容易理解理论。为此,我们可以使用 keras
数据集,这使得加载数据变得容易。
我们将导入 numpy
,然后是 mnist
数据集,以及 matplotlib
来展示数据:
import numpy
from keras.datasets import mnist
import matplotlib.pyplot as plt
import matplotlib.cm as cm
让我们定义我们的主函数,该函数接受一个整数,对应于 mnist
数据集中的图像,以及一个滤波器,这种情况下我们将定义 blur
滤波器:
def main(image, im_filter):im = X_train[image]
现在我们定义一个新的图像 imC
,大小为 (im.width-2, im.height-2)
:
width = im.shape[0] height = im.shape[1]imC = numpy.zeros((width-2, height-2))
此时我们进行卷积,我们将很快解释(正如我们将看到的,实际上有几种类型的卷积取决于不同的参数,现在我们只是解释基本概念,并稍后详细介绍):
for row in range(1,width-1):for col in range(1,height-1):for i in range(len(im_filter[0])):for j in range(len(im_filter)):imC[row-1][col-1] += im[row-1+i][col-1+j]*im_filter[i][j]if imC[row-1][col-1] > 255:imC[row-1][col-1] = 255elif imC[row-1][col-1] < 0:imC[row-1][col-1] = 0
现在我们准备显示原始图像和新图像:
plt.imshow( im, cmap = cm.Greys_r ) plt.show()plt.imshow( imC/255, cmap = cm.Greys_r ) plt.show()
现在我们准备使用 Keras 加载mnist
数据集,就像我们在第三章中所做的那样,深度学习基础。此外,让我们定义一个滤波器。滤波器是一个小区域(在本例中为 3 x 3),每个条目定义一个实数值。在这种情况下,我们定义一个所有条目值都相同的滤波器:
blur = [[1./9, 1./9, 1./9], [1./9, 1./9, 1./9], [1./9, 1./9, 1./9]]
由于我们有九个条目,我们将值设置为 1/9 以归一化值。
我们可以对任何图像(用一个表示位置的整数表示)调用main
函数在这样一个数据集中:
if __name__ == '__main__': (X_train, Y_train), (X_test, Y_test) = mnist.load_data()blur = [[1./9, 1./9, 1./9], [1./9, 1./9, 1./9], [1./9, 1./9, 1./9]]main(3, blur)
让我们看看我们做了什么。我们将滤波器的每个条目与原始图像的一个条目相乘,然后将它们全部加起来得到一个单一的值。由于滤波器的大小小于图像的大小,我们将滤波器移动 1 像素,并继续执行此过程,直到覆盖整个图像。由于滤波器由所有等于 1/9 的值组成,实际上我们已经用接近它的值的值平均了所有输入值,这就有了模糊图像的效果。
这就是我们得到的:
顶部是原始 mnist 图像,底部是我们应用滤波器后的新图像
在选择滤波器时,我们可以使用任何值;在这种情况下,我们使用的是全部相同的值。但是,我们可以使用不同的值,例如仅查看输入的相邻值,将它们相加,并减去中心输入的值。让我们定义一个新的滤波器,并将其称为边缘,如下所示:
edges = [[1, 1, 1], [1, -8, 1], [1, 1, 1]]
如果我们现在应用此滤波器,而不是之前定义的模糊
滤波器,则会得到以下图像:
顶部是原始 mnist 图像,底部是我们应用滤波器后的新图像
因此很明显,滤波器可以改变图像,并显示可以用于检测和分类图像的“特征”。例如,要对数字进行分类,内部的颜色并不重要,而诸如“边缘”之类的滤波器有助于识别数字的一般形状,这对于正确分类是重要的。
我们可以将滤波器视为与神经网络相同,认为我们定义的滤波器是一组权重,并且最终值表示下一层中神经元的激活值(实际上,尽管我们选择了特定的权重来讨论这些示例,但我们将看到权重将通过反向传播由神经网络学习):
滤波器覆盖了一个固定的区域,对于该区域中的每个神经元,它定义了与下一层中的神经元的连接权重。然后,下一层中的神经元将具有输入值,该输入值等于通过相应的连接权重中介的所有输入神经元的贡献总和计算得到的常规激活值。
然后我们保持相同的权重,滑动滤波器,生成一个新的神经元集,这些神经元对应于过滤后的图像:
我们可以不断重复这个过程,直到我们移动到整个图像上,我们可以使用尽可能多的滤波器重复这个过程,创建一组新的图像,每个图像都会突出显示不同的特征或特性。虽然我们在示例中没有使用偏置,但也可以向滤波器添加偏置,这将添加到神经网络中,我们还可以定义不同的活动函数。在我们的代码示例中,您会注意到我们强制值保持在范围(0, 255)内,这可以被认为是一个简单的阈值函数:
当滤波器在图像上移动时,我们为输出图像中的神经元定义新的激活值。
由于可以定义许多滤波器,因此我们应该将输出视为一组图像,每个滤波器定义一个图像。如果我们仅使用“边缘”和“模糊”滤波器,则输出层将有两个图像,每个选择的滤波器一个。因此,输出将除了宽度和高度外,还具有等于选择的滤波器数的深度。实际上,如果我们使用彩色图像作为输入,输入层也可以具有深度;图像实际上通常由三个通道组成,在计算机图形中用 RGB 表示,红色通道、绿色通道和蓝色通道。在我们的示例中,滤波器由二维矩阵表示(例如模糊
滤波器是一个 3 x 3 矩阵,所有条目都相等于 1/9)。然而,如果输入是彩色图像,则滤波器也将具有深度(在这种情况下等于三,即颜色通道的数量),因此将由三个(颜色通道数)3 x 3 矩阵表示。一般来说,滤波器因此将由一个三维数组表示,具有宽度、高度和深度,有时被称为“体积”。在前面的示例中,由于mnist
图像仅为灰度,因此滤波器的深度为 1。因此,深度为d的通用滤波器由具有相同宽度和高度的d个滤波器组成。这些d个滤波器中的每一个称为“切片”或“叶子”:
类似地,和以前一样,对于每个“叶片”或“片段”,我们连接小的子区域中的每个神经元以及一个偏置到一个神经元,并计算其激活值,其由滤波器中设置的连接权重定义,并滑动滤波器跨整个区域。这样的过程,因为它容易计算,所以需要的参数数量等于滤波器定义的权重数(在我们上面的示例中,这将是 3 x 3 = 9),乘以“叶片”的数量,也就是层的深度,再加上一个偏置。这定义了一个特征图,因为它突出显示了输入的特定特征。在我们上面的代码中,我们定义了两个特征图,一个“模糊”和一个“边缘”。因此,我们需要将参数的数量乘以特征图的数量。请注意,每个滤波器的权重是固定的;当我们滑动滤波器跨区域时,我们不会改变权重。因此,如果我们从尺寸为(宽度,高度,深度)的层开始,以及一个维度为(filter_w,filter_h)
的滤波器,那么应用卷积后的输出层是(width - filter_w + 1,height - filter_h + 1)
。新层的深度取决于我们想要创建多少特征图。在我们之前的mnist
代码示例中,如果我们同时应用了模糊
和边缘
滤波器,我们将拥有一个尺寸为(28 x 28 x 1)的输入层,因为只有一个通道,因为数字是灰度图像,并且一个尺寸为(26 x 26 x 2)的输出层,因为我们的滤波器尺寸为(3 x 3),我们使用了两个滤波器。参数的数量仅为 18(3 x 3 x 2),如果我们添加一个偏置,则为 20(3 x 3 x 2 + 2)。这比我们在传统的前馈网络中所需的要少得多,因为由于输入是 784 像素,一个只有 50 个神经元的简单隐藏层将需要 784 x 50 = 39200 个参数,如果我们添加偏置,则为 39250 个:
我们将滤波器沿着包含在层中的所有“叶片”滑过图像。
此外,卷积层可以更好地工作,因为每个神经元仅从相邻的神经元获得其输入,并且不关心从彼此相距较远的神经元收集输入的情况。
卷积层中的步幅和填充
我们所展示的示例,辅以图片,实际上只讲述了滤波器的一个特定应用(正如我们之前提到的,根据所选参数,有不同类型的卷积)。实际上,滤波器的大小可能会有所不同,以及它在图像上的移动方式以及在图像边缘的行为。在我们的示例中,我们每次将滤波器沿图像移动 1 个像素。我们每次移动滤波器时跳过多少像素(神经元)称为步幅。在上面的示例中,我们使用了步幅为 1,但使用较大的步幅,如 2 甚至更大,也并不罕见。在这种情况下,输出层的宽度和高度将较小:
使用步长为 2 的滤波器应用——滤波器每次移动两个像素。
另外,我们可能也决定部分地在原始图片外应用滤镜。在这种情况下,我们会假设缺失的神经元值为 0。这就是所谓的填充;也就是,在原始图像外部添加值为 0 的神经元。如果我们想要输出图像与输入图像大小相同的话,这可能会很有用。在上面,我们写出了零填充情况下新输出图像大小的公式,即(width - filter_w + 1, height – filter_h + 1
),对应输入大小为(width, height
)和滤波器尺寸为(filter_w, filter_h
)。如果我们在图像的四周使用填充P
,输出大小将为(width + 2P - filter_w + 1, height + 2P – filter_h + 1
)。总结一下,在每个维度上(无论是宽度还是高度),让输入切片的大小称为I=(I[w](I[h]), 滤波器的大小为F=(F[w],F[h]), 步长的大小为S=(S[w],S[h]), 和填充的大小为P=(P[w],P[h]),那么输出切片的大小*O=(O[w], O[h])就由下式给出:
当然,这也确定了S的约束之一,即它必须在宽度方向和高度方向上都能整除*(I + 2P – F)*。最终体积的尺寸通过乘以所需的特征映射数得到。
相反,使用的参数数目W与步长和填充无关,仅仅是滤波器大小的函数,输入的深度D(切片数量),以及选定的特征映射数量M:
使用填充(也称为零填充,因为我们用零填充图像)有时很有用,如果我们希望输出维度与输入维度相同的话。如果我们使用一个大小为(2 x 2)的滤波器,实际上可以清楚地看到通过应用值为 1 的填充和步长为 1,输出切片的尺寸与输入切片的大小相同。
池化层
在前一节中,我们已经推导出了卷积层中每个切片大小的公式。正如我们讨论过的那样,卷积层的优势之一是它减少了所需的参数数量,提升了性能,减少了过拟合。在执行卷积操作后,通常会执行另一个操作——池化。最经典的例子就是最大池化,这意味着在每个切片上创建(2 x 2)的网格,并在每个网格中选择具有最大激活值的神经元,丢弃其他的。很明显,这样的操作会丢弃 75%的神经元,仅保留在每个单元格中贡献最多的神经元。
对于每个汇集层来说有两个参数,类似于卷积层中的步幅和填充参数,它们是单元大小和步幅。一个典型的选择是选择单元大小为 2,步幅为 2,不过选择单元大小为 3,步幅为 2,创建一些重叠也不少见。然而需要注意的是,如果单元大小太大,汇集层可能会丢弃太多信息,这对于帮助并不利。我们可以推导出与我们推导卷积层的公式类似的汇集层输出的公式。\
汇集层不会改变层的体积深度,保持相同数量的片,因为汇集操作是在每个片中独立地进行。
还需要注意的是,类似于我们可以使用不同的激活函数一样,我们也可以使用不同的汇集操作。取最大值是最常见的操作之一,不过取所有值的平均值或者L ²度量也并不少见,这是所有平方的平方根。在实践中,最大汇聚通常表现更好,因为它保留了图像中最相关的结构。
然而需要注意的是,虽然汇集层仍然被广泛使用,有时候只需使用步幅较大的卷积层而不是汇集层,就能达到类似或更好的结果(例如,见 J. Springerberg, A. Dosovitskiy, T. Brox, 和 M. Riedmiller,追求简洁:全卷积网络,(2015),arxiv.org/pdf/1412.6806.pdf
)。
然而,如果使用汇集层,它们通常被用于在几个卷积层中间,通常是在每隔一个卷积操作之后。
还需要注意的是,汇集层不会增加新的参数,因为它们只是提取值(如最大值)而不需要额外的权重或偏置:
最大汇聚层的例子:计算每个 2x2 单元的最大值以生成一个新层。
丢弃
另一个重要的技术是可以在池化层之后应用的,但也通常可以应用于全连接层的技术是随机定期“丢弃”一些神经元及其相应的输入和输出连接。在一个丢弃层中,我们为神经元指定了一个概率p以随机方式“丢弃”。在每个训练周期中,每个神经元都有概率p被从网络中丢弃,概率*(1-p)被保留。这是为了确保没有神经元过多地依赖其他神经元,并且每个神经元都“学到”了对网络有用的东西。这有两个优点:它加快了训练,因为我们每次训练一个较小的网络,还有助于防止过拟合(参见 N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, and R. Salakhutdinov 的Dropout: A Simple Way to Prevent Neural Networks from Overfitting*,刊登于机器学习研究杂志15 (2014), 1929-1958, www.jmlr.org/papers/volume15/srivastava14a.old/source/srivastava14a.pdf
)。
然而,重要的是要注意,丢弃层不仅仅限于卷积层;事实上,丢弃层在不同的神经网络架构中都有应用。丢弃层应被视为减少过拟合的正则化技术,我们提到它们是因为它们将在我们的代码示例中被明确使用。
深度学习中的卷积层
当我们介绍深度学习的概念时,我们讨论了“深度”一词不仅指的是我们在神经网络中使用了许多层,还指的是我们有一个“更深入”的学习过程。这种更深入的学习过程的一部分是神经网络自主学习特征的能力。在前一节中,我们定义了特定的滤波器来帮助网络学习特定的特征。这并不一定是我们想要的。正如我们讨论过的,深度学习的重点在于系统能够自主学习,如果我们不得不教会网络哪些特征或特性是重要的,或者如何通过应用边缘层来学习识别数字的形状,我们将会做大部分的工作,并可能限制网络学习可能对我们有用但对网络本身并不重要的特征,从而降低其性能。深度学习的重点在于系统必须自行学习。
在第二章 神经网络中,我们展示了神经网络中的隐藏层如何通过使用反向传播学习权重; 操作员没有设置权重。 同样,操作员设置滤波器中的权重是毫无意义的,我们希望神经网络通过使用反向传播再次学习滤波器中的权重。 操作员唯一需要做的是设置图层的大小、步长和填充,并决定我们要求网络学习多少个特征图。 通过使用监督学习和反向传播,神经网络将自主设置每个滤波器的权重(和偏差)。
还需要提及的是,虽然使用我们提供的卷积层描述可能更简单,但卷积层仍然可以被认为是我们在第三章 深度学习基础中介绍的普通全连接层。 实际上,卷积层的两个主要特征是每个神经元只连接到输入层的一个小区域,并且对应于相同小区域的不同切片共享相同的权重。 这两个属性可以通过创建一个稀疏的权重矩阵来呈现在普通层中,即具有许多零(由于卷积网络的局部连接性)和许多重复权重(由于切片之间的参数共享特性)。 理解这一点清楚地说明了为什么卷积层的参数要比全连接层少得多; 在卷积层中,权重矩阵主要由零条目组成。 然而,在实践中,将卷积层想象成本章节中描述的方式对直觉有所帮助,因为这样可以更好地欣赏卷积层如何突出显示原始图像的特征,正如我们通过模糊图像或突出我们示例中数字的轮廓来图形化展示的那样。
再要明确的一点是,卷积网络的深度通常应该等于可以通过 2 进行迭代除法的数字,例如 32,64,96,128 等。 这在使用池化层时很重要,比如 max-pool 层,因为池化层(如果其大小为(2,2))将使输入层的大小除以 2,类似于我们如何定义“步进”和“填充”,以使输出图像具有整数尺寸。 另外,可以添加填充以确保输出图像大小与输入相同。
Theano 中的卷积层
现在我们已经知道卷积层是如何工作的,我们将使用 Theano 实现一个卷积层的简单示例。
让我们首先导入所需的模块:
import numpy
import theano
import matplotlib.pyplot as plt
import theano.tensor as T
from theano.tensor.nnet import conv
import skimage.data
import matplotlib.cm as cm
Theano 首先创建我们定义的操作的符号表示。我们稍后将通过另一个使用 Keras 的例子,它提供了一个很好的接口来更轻松地创建神经网络,但是使用 Theano(或者 TensorFlow)直接使用时可能缺少一些灵活性。
我们通过定义所需的变量和神经网络操作来定义特征图的数量(卷积层的深度)和滤波器的大小,然后我们使用 Theano 张量类来符号化地定义输入。Theano 把图像通道视为一个单独的维度,所以我们把输入定义为 tensor4。接下来,我们使用-0.2 和 0.2 之间的随机分布来初始化权重。我们现在可以调用 Theano 卷积操作,然后在输出上应用逻辑 sigmoid 函数。最后,我们定义函数f
,它接受一个输入,并使用所使用的操作来定义一个输出:
depth = 4
filter_shape = (3, 3) input = T.tensor4(name='input') w_shape = (depth, 3, filter_shape[0], filter_shape[1])
dist = numpy.random.uniform(-0.2, 0.2, size=w_shape)
W = theano.shared(numpy.asarray(dist, dtype=input.dtype), name = 'W')
conv_output = conv.conv2d(input, W)
output = T.nnet.sigmoid(conv_output)
f = theano.function([input], output)
我们导入的skimage
模块可以用来加载一个名为lena
的图像,然后在将图像重塑为可传递给我们定义的 Theano 函数后,我们就可以在该图像上调用 Theano 函数:
astronaut = skimage.data.astronaut()
img = numpy.asarray(astronaut, dtype='float32') / 255
filtered_img = f(img.transpose(2, 0, 1).reshape(1, 3, 512, 512))
就是这样。我们现在可以通过这段简单的代码打印出原始图片和经过滤波的图片。
plt.axis('off')
plt.imshow(img)
plt.show()
for img in range(depth):fig = plt.figure() plt.axis( 'off') plt.imshow(filtered_img[0, img, :, :, ], cmap = cm.gray)plt.show()filename = "astro" + str(img)fig.savefig(filename, bbox_inches='tight')
如果读者对可视化所使用的权重感兴趣,在 Theano 中,可以使用print W.get_value()
来打印值。
这段代码的输出如下:(由于我们还没有固定随机种子,并且权重是随机初始化的,读者可能会得到略有不同的图像):
原始图片和滤波后的图片。
一个使用 Keras 识别数字的卷积层示例
在第三章中,我们介绍了使用 Keras 对数字进行分类的简单神经网络,我们得到了 94%的准确率。在本章中,我们将努力使用卷积网络将该准确率提高到 99%以上。由于初始化的变化,实际值可能会略有不同。
首先,我们可以通过使用 400 个隐藏神经元来改进我们之前定义的神经网络,并将其运行 30 个周期;这样就应该已经将准确率提高到了大约 96.5%:
hidden_neurons = 400epochs = 30
接下来,我们可以尝试对输入进行缩放。图像由像素组成,每个像素的整数值在 0 到 255 之间。我们可以使该值成为浮点数,并将其在 0 到 1 之间缩放,只需在定义输入后添加这四行代码即可:
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255
如果我们现在运行我们的网络,我们得到的准确率较低,略高于 92%,但我们不需要担心。通过重新缩放,我们实际上改变了我们函数的梯度值,因此它将收敛得更慢,但有一个简单的解决方法。在我们的代码中,在model.compile
函数内,我们定义了一个优化器等于"sgd"。这是标准的随机梯度下降,它使用梯度收敛到最小值。然而,Keras 允许其他选择,特别是"adadelta",它自动使用动量,并根据梯度调整学习率,使其与梯度成反比地变大或变小,以便网络不会学习得太慢,也不会通过采取太大的步骤跳过最小值。通过使用 adadelta,我们动态调整参数随时间改变(也见:Matthew D. Zeiler,Adadelta:一种自适应学习率方法,arXiv:1212.5701v1 (arxiv.org/pdf/1212.5701v1.pdf
))。
在主函数内部,我们现在将改变我们的编译函数并使用:
model.compile(loss='categorical_crossentropy', metrics=['accuracy'], optimizer='adadelta')
如果我们再次运行我们的算法,现在我们的准确率约为 98.25%。最后,让我们修改我们的第一个密集(全连接)层,使用relu
激活函数而不是sigmoid
:
model.add(Activation('relu'))
这将带来大约 98.4%的准确率。问题在于,现在使用传统的前馈架构变得越来越难以改善我们的结果,由于过拟合,增加迭代次数或修改隐藏神经元的数量将带来任何额外的好处,因为网络将简单地学会对数据进行过度拟合,而不是学会更好地泛化。因此,我们现在将在示例中引入卷积网络。
为了做到这一点,我们保持我们的输入值在 0 和 1 之间。然而,为了被卷积层使用,我们将数据重塑成大小为(28,28,1)的体积=(图像宽度,图像高度,通道数),并将隐藏神经元的数量减少到 200 个,但现在我们在开始处添加了一个简单的卷积层,使用 3 x 3 的滤波器,不填充,步长为 1,然后是一个步幅为 2 且大小为 2 的最大池化层。为了将输出传递给密集层,我们需要将体积(卷积层是体积)拉直以传递给具有 100 个隐藏神经元的常规密集层,使用以下代码:
from keras.layers import Convolution2D, MaxPooling2D, Flatten
hidden_neurons = 200
X_train = X_train.reshape(60000, 28, 28, 1)
X_test = X_test.reshape(10000, 28, 28, 1)
model.add(Convolution2D(32, (3, 3), input_shape=(28, 28, 1)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
我们还可以将迭代次数减少到 8 次,然后我们将得到大约 98.55%的准确率。通常情况下,常用成对的卷积层,所以我们添加了一个类似第一个卷积层的第二个卷积层(在池化层之前):
model.add(Convolution2D(32, (3, 3)))
model.add(Activation('relu'))
现在我们的准确率已经达到了 98.9%。
为了达到 99%,我们按照我们所讨论的方法添加一个辍学层。这不会增加任何新的参数,但能帮助防止过拟合,并且我们将其添加在拉直层之前:
from keras.layers import Dropout
model.add(Dropout(0.25))
在这个例子中,我们使用了约 25%的辍学率,因此每个神经元每四次就会被随机抛弃一次。
这将使我们的准确度达到 99%以上。如果我们想进一步提高(准确度可能因初始化的差异而有所不同),我们还可以添加更多的 dropout 层,例如在隐藏层之后,并增加时期的数量。这将迫使最终密集层中容易过拟的神经元被随机丢弃。我们的最终代码如下:
import numpy as np
np.random.seed(0) #for reproducibility
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers import Dense, Activation, Convolution2D, MaxPooling2D, Flatten, Dropout
from keras.utils import np_utilsinput_size = 784
batch_size = 100
hidden_neurons = 200
classes = 10
epochs = 8 (X_train, Y_train), (X_test, Y_test) = mnist.load_data()
X_train = X_train.reshape(60000, 28, 28, 1)
X_test = X_test.reshape(10000, 28, 28, 1)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255
Y_train = np_utils.to_categorical(Y_train, classes)
Y_test = np_utils.to_categorical(Y_test, classes)
model = Sequential()
model.add(Convolution2D(32, (3, 3), input_shape=(28, 28, 1)))
model.add(Activation('relu'))
model.add(Convolution2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(hidden_neurons))
model.add(Activation('relu'))
model.add(Dense(classes))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', metrics=['accuracy'], optimizer='adadelta')
model.fit(X_train, Y_train, batch_size=batch_size, epochs=epochs, validation_split = 0.1, verbose=1)
score = model.evaluate(X_train, Y_train, verbose=1)
print('Train accuracy:', score[1])
score = model.evaluate(X_test, Y_test, verbose=1)
print('Test accuracy:', score[1])
这个网络可以进一步优化,但这里的重点不是获得一个获奖得分,而是理解过程,并了解我们采取的每一步是如何提高性能的。还要注意,通过使用卷积层,我们实际上也避免了网络的过拟合问题,因为利用了更少的参数。
使用 Keras 进行 cifar10 的卷积层示例
现在我们可以尝试在cifar10
数据集上使用相同的网络。在第三章中的深度学习基础知识中,我们在测试数据上得到了 50%的低准确度,为了测试刚刚在mnist
数据集上使用的新网络,我们只需要对代码进行一些小的修改:我们需要加载cifar10
数据集(不进行任何重新调整,那些行将被删除):
(X_train, Y_train), (X_test, Y_test) = cifar10.load_data()
改变第一个卷积层的输入值:
model.add(Convolution2D(32, (3, 3), input_shape=(32, 32, 3)))
运行这个网络 5 个时期将给我们约 60%的准确度(从约 50%提高)和 10 个时期后的 66%的准确度,但接着网络开始过拟合并停止改善性能。
当然,cifar10
的图像有 32 x 32 x 3 = 3072 个像素,而不是 28 x 28 = 784 个像素,所以在前两层之后,我们可能需要添加几个额外的卷积层:
model.add(Convolution2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(Convolution2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
一般来说,最好将大型卷积层划分为较小尺寸的卷积层。例如,如果我们有两个连续的(3 x 3)卷积层,第一层将具有对输入图像的(3 x 3)视图,第二层将为每个像素提供对输入图像的(5 x 5)视图。然而,每层都会具有非线性特征,这些特征将堆叠起来,创建出比仅仅创建单个(5 x 5)滤波器时更复杂和有趣的输入特征。
如果我们将这个网络运行 3 个时期,我们的准确度也在 60%左右,但是经过 20 个时期后,通过使用简单的网络,我们的准确度达到了 75%。先进的卷积网络可以达到 90%的准确度,但需要更长的训练时间,并且更加复杂。我们将以图形方式展示一个重要的卷积神经网络的架构,称为 VGG-16,在下一段中,用户可以尝试使用 Keras 或其他他们熟悉的语言来实现它,例如 Theano 或 TensorFlow(该网络最初是使用 Caffe 创建的,Caffe 是在伯克利开发的一个重要的深度学习框架,详情请见:caffe.berkeleyvision.org
)。
在使用神经网络时,能够“看到”网络学习的权重是很重要的。这使用户能够了解网络正在学习什么特征,并且能够进行更好的调整。这个简单的代码将输出每个层的所有权重:
index = 0
numpy.set_printoptions(threshold='nan')
for layer in model.layers: filename = "conv_layer_" + str(index) f1 = open(filename, 'w+') f1.write(repr(layer.get_weights())) f1.close() print (filename + " has been opened and closed") index = index+1
例如,如果我们对第 0 层,即第一个卷积层的权重感兴趣,我们可以将它们应用于图像,以查看网络正在突出显示的特征。如果我们将这些滤波器应用于图像lena
,我们会得到:
我们可以看到每个滤波器如何突出显示不同的特征。
预训练
正如我们所见,神经网络,特别是卷积网络,通过调整网络的权重,就像它们是一个大型方程的系数一样来获得给定特定输入的正确输出。调整通过反向传播来移动权重,以使它们朝着给定选择的神经网络架构的最佳解决方案移动。因此,其中一个问题是找到神经网络中权重的最佳初始化值。诸如 Keras 的库可以自动处理这个问题。然而,这个话题足够重要,值得讨论这一点。
通过使用输入作为期望输出来预先训练网络来使用受限玻尔兹曼机,使网络自动学习输入的表示并相应地调整其权重,这个话题已经在第四章中讨论过,无监督特征学习。
此外,存在许多预训练网络提供良好的结果。正如我们所提到的,许多人一直在研究卷积神经网络,并取得了令人印象深刻的结果,通过重新利用这些网络学到的权重并将它们应用于其他项目,通常可以节省时间。
K. Simonyan, A. Zisserman 在Very Deep Convolutional Networks for Large-Scale Image Recognition中使用的 VGG-16 模型,arxiv.org/pdf/1409.1556v6.pdf
,是图像识别中的重要模型。在这个模型中,输入是一个固定的 224 x 224 的 RGB 图像,唯一的预处理是减去在训练集上计算的平均 RGB 值。我们在附图中概述了这个网络的架构,用户可以尝试自己实现这样一个网络,但也要注意运行这样一个网络的计算密集性。在这个网络中,架构如下:
Simonyan 和 Zisserman 的 VGG-16 卷积神经网络架构。
我们还将感兴趣的读者引荐到另一个值得注意的例子,即 AlexNet 网络,包含在 Alex Krizhevsky, Ilya Sutskeve, Geoffrey Hinton 的使用深度卷积神经网络进行 ImageNet 分类中,出自于 Advances in Neural Information Processing Systems 25 (NIPS 2012),papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf
,但我们出于简洁起见,在此不讨论它,但我们邀请感兴趣的读者去查看。我们还邀请感兴趣的读者查看 github.com/fchollet/deep-learning-models
获取 VGG-16 和其他网络的代码示例。
总结
值得注意的是,正如可能已经清楚的,卷积神经网络没有通用的架构。但是,有一些一般性的指导原则。通常,池化层跟在卷积层后面,并且经常习惯于堆叠两个或更多连续的卷积层来检测更复杂的特征,就像在前面展示的 VGG-16 神经网络示例中所做的那样。卷积网络非常强大。然而,它们可能非常耗费资源(例如上面的 VGG-16 示例相对复杂),通常需要长时间的训练,这就是为什么使用 GPU 可以帮助加速性能的原因。它们的优势在于它们不专注于整个图像,而是专注于较小的子区域,以找到组成图像的有趣特征,从而能够找到不同输入之间的区别元素。由于卷积层非常耗费资源,我们引入了池化层来帮助减少参数数量而不增加复杂性,而使用丢弃层有助于确保没有神经元过于依赖其他神经元,因此神经网络中的每个元素都会有助于学习。
在本章中,我们从类比我们的视觉皮层如何工作开始,介绍了卷积层,并随后描述了它们为何有效的直观理解。我们介绍了滤波器,还涵盖了滤波器可以有不同的大小和不同的填充方式,我们还看到了如何设置零填充可以确保结果图像与原始图像具有相同的大小。如上所述,池化层可以帮助减少复杂性,而丢弃层可以使神经网络在识别模式和特征方面更加有效,并且特别适用于减少过拟合的风险。
一般来说,在给定的例子中,特别是在mnist
的例子中,我们已经展示了神经网络中的卷积层在处理图像时,可以比普通的深度神经网络取得更好的准确性,在数字识别方面达到了超过 99%的准确度,而且通过限制参数的使用,避免了模型过拟合的问题。在接下来的章节中,我们将会研究语音识别,然后开始研究使用强化学习而不是监督或非监督学习的模型的例子,介绍在棋盘游戏和视频游戏中使用深度学习的例子。
第六章:循环神经网络和语言模型
我们在前几章讨论的神经网络架构接受固定大小的输入并提供固定大小的输出。即使在图像识别中使用的卷积网络(第五章,“图像识别”)也被展平成一个固定输出向量。本章将通过引入循环神经网络(RNNs)来摆脱这一限制。RNN 通过在这些序列上定义递推关系来帮助我们处理可变长度的序列,因此得名。
处理任意输入序列的能力使得 RNN 可用于诸如语言建模(参见语言建模部分)或语音识别(参见语音识别部分)等任务。事实上,理论上,RNN 可以应用于任何问题,因为已经证明它们是图灵完备的 [1]。这意味着在理论上,它们可以模拟任何常规计算机无法计算的程序。作为这一点的例证,Google DeepMind 提出了一个名为“神经图灵机”的模型,该模型可以学习执行简单的算法,比如排序 [2]。
在本章中,我们将涵盖以下主题:
-
如何构建和训练一个简单的 RNN,基于一个简单的问题
-
RNN 训练中消失和爆炸梯度的问题以及解决方法
-
用于长期记忆学习的 LSTM 模型
-
语言建模以及 RNN 如何应用于这个问题
-
应用深度学习于语音识别的简要介绍
循环神经网络
RNN 之所以得名,是因为它们在序列上重复应用相同的函数。可以通过下面的函数将 RNN 写成递推关系:
这里的 S[t] —第 t 步的状态—是由函数 f 从前一步的状态,即 t-1,和当前步骤的输入 X[t] 计算得出。这种递推关系通过在先前状态上的反馈循环来定义状态如何逐步在序列中演变,如下图所示:
图来自[3]
左:RNN 递推关系的可视化示例:S [t] = S [t-1] ** W + X* [t] ** U*。最终输出将是 o [t] = VS* [t]
右:RNN 状态在序列 t-1, t, t+1 上递归展开。注意参数 U、V 和 W 在所有步骤之间共享。
这里 f 可以是任何可微分函数。例如,一个基本的 RNN 定义如下递推关系:
这里W定义了从状态到状态的线性变换,U是从输入到状态的线性变换。tanh函数可以被其他变换替代,比如 logit,tanh 或者 ReLU。这个关系可以在下图中进行解释,O[t]是网络生成的输出。
例如,在词级语言建模中,输入X将是一个序列的单词编码的输入向量*(X*[1]…X[t]…)。状态S将会是一个状态向量的序列*(S*[1]…S[t]…)。输出O将是下一个序列中的单词的概率向量的序列*(O*[1]…O[t]…)。
需要注意的是,在 RNN 中,每个状态都依赖于其之前的所有计算,通过这种循环关系。这个关系的一个重要含义是,RNN 在时间上有记忆,因为状态S包含了基于先前步骤的信息。理论上,RNN 可以记住任意长时间的信息,但在实践中,它们只能回顾几个步骤。我们将在消失和爆炸梯度部分更详细地讨论这个问题。
因为 RNN 不限于处理固定大小的输入,它们确实扩展了我们可以使用神经网络进行计算的可能性,比如不同长度的序列或不同大小的图像。下图直观地说明了我们可以制作的一些序列的组合。以下是这些组合的简要说明:
-
一对一:这是非顺序处理,比如前馈神经网络和卷积神经网络。请注意,一个前馈网络和 RNN 应用在一个时间步骤上没有太大的区别。一个一对一处理的例子是来自章节的图像分类(参见第五章,图像识别)。
-
一对多:这基于单一的输入生成一个序列,例如,来自图像的标题生成[4]。
-
多对一:这基于一个序列输出一个单一的结果,例如,文本的情感分类。
-
多对多间接:一个序列被编码成一个状态向量,之后这个状态向量被解码成一个新的序列,例如,语言翻译[5],[6]。
-
多对多直接:这对每个输入步骤输出一个结果,例如,语音识别中的帧语素标记(见语音识别部分)。
来自[7]的图片
RNN 扩展了我们可以使用神经网络进行计算的可能性—红色:输入 X,绿色:状态 S,蓝色:输出 O。
RNN — 如何实现和训练
在前面的部分,我们简要讨论了 RNN 是什么以及它们可以解决的问题。让我们深入了解 RNN 的细节,以及如何通过一个非常简单的玩具例子来训练它:在一个序列中计算“1”的个数。
在这个问题中,我们要教会最基本的循环神经网络如何计算输入中 1 的个数,并且在序列结束时输出结果。我们将在 Python 和 NumPy 中展示这个网络的实现。输入和输出的一个示例如下:
In: (0, 0, 0, 0, 1, 0, 1, 0, 1, 0)
Out: 3
我们要训练的网络是一个非常基本的网络,如下图所示:
基本的循环神经网络用于计算输入中的 1 的个数
网络只有两个参数:一个输入权重 U 和一个循环权重 W。输出权重 V 设为 1,所以我们只需读取最后一个状态作为输出 y。这个网络定义的循环关系是 S [t] = S [t-1] ** W + X* [t] ** U*。请注意,这是一个线性模型,因为在这个公式中我们没有应用非线性函数。这个函数的代码定义如下:
def step(s, x, U, W):return x * U + s * W
因为 3 是我们想要输出的数字,而且有三个 1,这个问题的一个很好的解决方案就是简单地对整个序列进行求和。如果我们设置 U=1,那么每当接收到一个输入,我们将得到它的完整值。如果我们设置 W=1,那么我们累积的值将永远不会衰减。所以,对于这个例子,我们会得到期望的输出:3。
尽管,这个神经网络的训练和实现将会很有趣,正如我们将在本节的其余部分中看到的。所以让我们看看我们如何通过反向传播来得到这个结果。
通过时间的反向传播
通过时间的反向传播算法是我们用来训练循环网络的典型算法[8]。这个名字已经暗示了它是基于我们在 第二章 讨论的反向传播算法,神经网络。
如果你了解常规的反向传播,那么通过时间的反向传播就不难理解。主要区别在于,循环网络需要在一定数量的时间步长内进行展开。这个展开如前图所示(基本的循环神经网络用于计算输入中的 1 的个数)。展开完成后,我们得到一个与常规的多层前馈网络非常相似的模型。唯一的区别在于,每层都有多个输入(上一个状态,即 S [t-1]),和当前输入(X [t]),以及参数(这里的 U 和 W)在每层之间是共享的。
前向传播将 RNN 沿着序列展开,并为每个步骤构建一个活动堆栈。批处理输入序列 X 的前向步骤可实现如下:
def forward(X, U, W):# Initialize the state activation for each sample along the sequenceS = np.zeros((number_of_samples, sequence_length+1))# Update the states over the sequencefor t in range(0, sequence_length):S[:,t+1] = step(S[:,t], X[:,t], U, W) # step functionreturn S
在进行这个前向步骤之后,我们有了每一步和每个样本在批处理中的激活,由 S 表示。因为我们想输出更多或更少连续的输出(全部为 1 的总和),我们使用均方误差代价函数来定义我们的输出成本与目标和输出 y
,如下:
cost = np.sum((targets – y)**2)
现在我们有了前向步骤和成本函数,我们可以定义梯度如何向后传播。首先,我们需要得到输出y
相对于成本函数的梯度(??/?y)。
一旦我们有了这个梯度,我们可以通过向后传播堆栈中构建的活动来将其传播到每个时间步长处的误差导数。传播该梯度通过网络的循环关系可以写成以下形式:
参数的梯度通过以下方式累积:
在接下来的实现中,在反向步骤中,分别通过gU
和gW
累积U
和W
的梯度:
def backward(X, S, targets, W):# Compute gradient of outputy = S[:,-1] # Output `y` is last activation of sequence# Gradient w.r.t. cost function at final stategS = 2.0 * (y - targets)# Accumulate gradients backwardsgU, gW = 0, 0 # Set the gradient accumulations to 0 for k in range(sequence_len, 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 layergS = gS * Wreturn gU, gW
现在我们可以尝试使用梯度下降优化我们的网络:
learning_rate = 0.0005
# Set initial parameters
parameters = (-2, 0) # (U, W)
# Perform iterative gradient descent
for i in range(number_iterations):# Perform forward and backward pass to get the gradientsS = forward(X, parameters(0), parameters(1))gradients = backward(X, S, targets, parameters(1))# Update each parameter `p` by p = p - (gradient * learning_rate).# `gp` is the gradient of parameter `p`parameters = ((p - gp * learning_rate) for p, gp in zip(parameters, gradients))
不过存在一个问题。注意,如果尝试运行此代码,最终参数U和W往往会变为不是一个数字(NaN)。让我们尝试通过在错误曲面上绘制参数更新来调查发生了什么,如下图所示。注意,参数慢慢朝着最佳值(U=W=1)移动,直到超过并达到大约(U=W=1.5)。此时,梯度值突然爆炸,使参数值跳出图表。这个问题被称为梯度爆炸。下一节将详细解释为什么会发生这种情况以及如何预防。
参数更新通过梯度下降在错误曲面上绘制。错误曲面以对数颜色比例尺绘制。
梯度消失和梯度爆炸
RNN 相对于前馈或卷积网络更难训练。一些困难源于 RNN 的循环性质,其中同一权重矩阵用于计算所有状态更新[9],[10]。
上一节的结尾,前面的图示了梯度爆炸,由于长期组件的膨胀导致 RNN 训练进入不稳定状态。除了梯度爆炸问题,还存在梯度消失问题,即相反的情况发生。长期组件以指数速度趋于零,模型无法从时间上遥远的事件中学习。在本节中,我们将详细解释这两个问题以及如何应对它们。
爆炸和消失梯度都源于通过时间向后传播梯度的循环关系形成了一个几何序列:
在我们简单的线性 RNN 中,如果*|W| > 1*,梯度会呈指数增长。这就是所谓的梯度爆炸(例如,50 个时间步长下的W=1.5为W**[50]**=1.5˜≈6 * 10⁸*)。如果*|W| < 1*,梯度会呈指数衰减;这就是所谓的梯度消失(例如,20 个时间步长下的W=0.6为W**[20]**=0.6˜≈310**^(-5))。如果权重参数W是矩阵而不是标量,则这种爆炸或消失的梯度与W的最大特征值(ρ)有关(也称为谱半径)。对于梯度消失,ρ < 1足够,使得梯度消失,对于梯度爆炸,ρ > 1是必要的。
以下图形直观地说明了梯度爆炸的概念。发生的情况是我们正在训练的成本表面非常不稳定。使用小步,我们可能会移动到成本函数的稳定部分,梯度很低,并突然遇到成本的跳跃和相应的巨大梯度。因为这个梯度非常巨大,它将对我们的参数产生很大影响。它们最终会落在成本表面上离它们最初的位置很远的地方。这使得梯度下降学习不稳定,甚至在某些情况下是不可能的。
梯度爆炸的插图[11]
我们可以通过控制梯度的大小来对抗梯度爆炸的效果。一些解决方案的例子有:
-
梯度截断,我们将梯度能获得的最大值设定为阈值[11]。
-
二阶优化(牛顿法),我们模拟成本函数的曲率。模拟曲率使我们能够在低曲率情况下迈出大步,在高曲率情况下迈出小步。出于计算原因,通常只使用二阶梯度的近似值[12]。
-
依赖于局部梯度较少的优化方法,例如动量[13]或 RmsProp [14]。
例如,我们可以利用 Rprop [15]重新训练我们无法收敛的网络(参见梯度爆炸的插图)。 Rprop 是一种类似于动量法的方法,仅使用梯度的符号来更新动量参数,因此不受梯度爆炸的影响。如果我们运行 Rprop 优化,可以看到训练收敛于下图。请注意,尽管训练开始于一个高梯度区域(U=-1.5,W=2),但它很快收敛直到找到最佳点(U=W=1)。
通过 Rprop 在误差表面绘制的参数更新。误差表面是以对数刻度绘制的。
消失梯度问题是爆炸梯度问题的逆问题。梯度在步数上呈指数衰减。这意味着早期状态的梯度变得非常小,保留这些状态历史的能力消失了。较早时间步的小梯度被较近时间步的较大梯度所淘汰。Hochreiter 和 Schmidhuber [16] 将其描述如下:通过时间的反向传播对最近的干扰过于敏感。
这个问题更难以检测,因为网络仍然会学习和输出一些东西(不像爆炸梯度的情况)。它只是无法学习长期依赖性。人们已经尝试用类似于我们用于爆炸梯度的解决方案来解决这个问题,例如二阶优化或动量。这些解决方案远非完美,使用简单 RNN 学习长期依赖性仍然非常困难。幸运的是,有一种聪明的解决方案可以解决消失梯度问题,它使用由记忆单元组成的特殊架构。我们将在下一节详细讨论这个架构。
长短期记忆
在理论上,简单的 RNN 能够学习长期依赖性,但在实践中,由于消失梯度问题,它们似乎只限于学习短期依赖性。Hochreiter 和 Schmidhuber 对这个问题进行了广泛的研究,并提出了一种解决方案,称为长短期记忆(LSTM)[16]。由于特别设计的记忆单元,LSTM 可以处理长期依赖性。它们工作得非常好,以至于目前在各种问题上训练 RNN 的大部分成就都归功于使用 LSTM。在本节中,我们将探讨这个记忆单元的工作原理以及它是如何解决消失梯度问题的。
LSTM 的关键思想是单元状态,其中的信息只能明确地写入或删除,以使单元状态在没有外部干扰的情况下保持恒定。下图中时间 t 的单元状态表示为 c [t]。
LSTM 单元状态只能通过特定的门来改变,这些门是让信息通过的一种方式。这些门由 logistic sigmoid 函数和逐元素乘法组成。因为 logistic 函数只输出介于 0 和 1 之间的值,所以乘法只能减小通过门的值。典型的 LSTM 由三个门组成:遗忘门、输入门和输出门。这些在下图中都表示为 f、i 和 o。请注意,单元状态、输入和输出都是向量,因此 LSTM 可以在每个时间步骤保存不同信息块的组合。接下来,我们将更详细地描述每个门的工作原理。
LSTM 单元
x*[t]、c[t]、h[t]* 分别是时间 t 的输入、细胞状态和 LSTM 输出。
LSTM 中的第一个门是遗忘门;因为它决定我们是否要擦除细胞状态,所以被称为遗忘门。这个门不在 Hochreiter 最初提出的 LSTM 中;而是由 Gers 等人提出[17]的。遗忘门基于先前的输出 h [t-1] 和当前的输入 x*[t]* . 它将这些信息组合在一起,并通过逻辑函数压缩它们,以便为细胞的矢量块输出介于 0 和 1 之间的数字。由于与细胞的逐元素乘法,一个输出为 0 的完全擦除特定的细胞块,而输出为 1 会保留该细胞块中的所有信息。这意味着 LSTM 可以清除其细胞状态向量中的不相关信息。
接下来的门决定要添加到内存单元的新信息。这分为两部分进行。第一部分决定是否添加信息。与输入门类似,它基于 h*[t-1]* 和 x*[t]* 进行决策,并通过每个细胞块的矢量的逻辑函数输出 0 或 1。输出为 0 意味着不向该细胞块的内存中添加任何信息。因此,LSTM 可以在其细胞状态向量中存储特定的信息片段:
要添加的输入 a [t] 是由先前的输出 (h [t-1]) 和当前的输入 (x*[t]*) 派生,并通过 tanh 函数变换:
遗忘门和输入门完全决定了通过将旧的细胞状态与新的信息相加来确定新细胞:
最后一个门决定输出结果。输出门将 h [t-1] 和 x*[t]* 作为输入,并通过逻辑函数输出 0 或 1,在每个单元块的内存中均可用。输出为 0 表示该单元块不输出任何信息,而输出为 1 表示整个单元块的内存传递到细胞的输出。因此,LSTM 可以从其细胞状态向量中输出特定的信息块:
最终输出的值是通过 tanh 函数传递的细胞内存:
因为所有这些公式是可导的,我们可以像连接简单的 RNN 状态一样连接 LSTM 单元,并通过时间反向传播来训练网络。
现在问题是 LSTM 如何保护我们免受梯度消失的影响?请注意,如果遗忘门为 1 且输入门为 0,则细胞状态会被逐步地从步骤复制。只有遗忘门才能完全清除细胞的记忆。因此,记忆可以长时间保持不变。还要注意,输入是添加到当前细胞记忆的 tanh 激活;这意味着细胞记忆不会爆炸,并且非常稳定。
实际上,以下图示了 LSTM 如何展开。
初始时,网络的输入被赋值为 4.2;输入门被设置为 1,所以完整的值被存储。接下来的两个时间步骤中,遗忘门被设置为 1。所以在这些步骤中保留了全部信息,并且没有添加新的信息,因为输入门被设置为 0。最后,输出门被设置为 1,4.2 被输出并保持不变。
LSTM 通过时间展开[18]
尽管在前面的图示中描述的 LSTM 网络是大多数应用中使用的典型 LSTM 版本,但有许多变体的 LSTM 网络,它们以不同的顺序组合不同的门[19]。深入了解所有这些不同的架构超出了本书的范围。
语言建模
语言模型的目标是计算单词序列的概率。它们对于许多不同的应用非常关键,例如语音识别、光学字符识别、机器翻译和拼写校正。例如,在美式英语中,短语"wreck a nice beach"和"recognize speech"在发音上几乎相同,但它们的含义完全不同。一个好的语言模型可以根据对话的上下文区分哪个短语最有可能是正确的。本节将概述基于单词和字符的语言模型以及如何使用循环神经网络来构建它们。
基于单词的模型
基于单词的语言模型定义了一个对单词序列的概率分布。给定长度为m的单词序列,它为完整的单词序列赋予了概率P(w [1] , … , w [m] )。这些概率的应用有两个方面。我们可以用它们来估计自然语言处理应用中不同短语的可能性。或者,我们可以用它们来生成新的文本。
N-gram 模型
推断一个长序列(如w [1] , …, w [m])的概率通常是不可行的。通过应用以下链式法则可以计算出P(w [1] , … , w [m] *)*的联合概率:
特别是基于前面的单词给出后面单词的概率将很难从数据中估计出来。这就是为什么这个联合概率通常被一个独立假设近似,即第i个单词只依赖于前n-1个单词。我们只建模n个连续单词称为 n-grams 的联合概率。注意,n-grams 可以用来指代其他长度为n的序列,例如n个字符。
联合分布的推断通过 n-gram 模型进行近似,将联合分布拆分为多个独立部分。注意,n-grams 是多个连续单词的组合,其中n是连续单词的数量。例如,在短语the quick brown fox中,我们有以下 n-grams:
-
1-gram:“The,” “quick,” “brown,” 和 “fox”(也称为 unigram)
-
2-grams:“The quick,” “quick brown,” 和 “brown fox”(也称为 bigram)
-
3-grams:“The quick brown” 和 “quick brown fox”(也称为 trigram)
-
4-grams:“The quick brown fox”
现在,如果我们有一个庞大的文本语料库,我们可以找到直到某个n(通常为 2 到 4)的所有 n-grams,并计算该语料库中每个 n-gram 的出现次数。从这些计数中,我们可以估计每个 n-gram 的最后一个单词在给定前n-1个单词的情况下的概率:
-
1-gram:
-
2-gram:
-
n-gram:
现在可以使用第i个单词仅依赖于前n**-1个单词的独立假设来近似联合分布。
例如,对于一个 unigram,我们可以通过以下方式近似联合分布:
对于 trigram,我们可以通过以下方式近似联合分布:
我们可以看到,基于词汇量,随着n的增加,n-grams 的数量呈指数增长。例如,如果一个小词汇表包含 100 个单词,那么可能的 5-grams 的数量将是100**⁵ = 10,000,000,000个不同的 5-grams。相比之下,莎士比亚的整个作品包含大约30,000个不同的单词,说明使用具有大n值的 n-grams 是不可行的。不仅需要存储所有概率,我们还需要一个非常庞大的文本语料库来为较大的n值创建良好的 n-gram 概率估计。这个问题就是所谓的维度灾难。当可能的输入变量(单词)数量增加时,这些输入值的不同组合数量呈指数增长。当学习算法需要至少一个与相关值组合的示例时,就会出现这种维度灾难,这在 n-gram 建模中是这样的情况。我们的n越大,我们就越能近似原始分布,并且我们需要更多的数据来对 n-gram 概率进行良好的估计。
神经语言模型
在前面的部分中,我们用 n-grams 建模文本时展示了维度灾难。我们需要计算的 n-grams 数量随着n和词汇表中的单词数量呈指数增长。克服这个问题的一种方法是通过学习单词的较低维度、分布式表示来学习一个嵌入函数[20]。这个分布式表示是通过学习一个嵌入函数将单词空间转换为较低维度的单词嵌入空间而创建的,具体如下:
从词汇表中取出的单词被转换为大小为 V 的独热编码向量(V 中的每个单词都被唯一编码)。然后,嵌入函数将这个 V 维空间转换为大小为 D 的分布式表示(这里 D=4)。
这个想法是,学习的嵌入函数会学习关于单词的语义信息。它将词汇表中的每个单词与一个连续值向量表示相关联,即单词嵌入。在这个嵌入空间中,每个单词对应一个点,其中不同的维度对应于这些单词的语法或语义属性。目标是确保在这个嵌入空间中彼此接近的单词应具有相似的含义。这样,一些单词语义上相似的信息可以被语言模型利用。例如,它可能会学习到“fox”和“cat”在语义上相关,并且“the quick brown fox”和“the quick brown cat”都是有效的短语。然后,一系列单词可以转换为一系列捕捉到这些单词特征的嵌入向量。
可以通过神经网络对语言模型进行建模,并隐式地学习这个嵌入函数。我们可以学习一个神经网络,给定一个n-1个单词的序列(w*[t-n+1],…,w*[t-1]),试图输出下一个单词的概率分布,即w**[t]。网络由不同部分组成。
嵌入层接受单词w [i]的独热表示,并通过与嵌入矩阵C相乘将其转换为其嵌入。这种计算可以通过表查找有效地实现。嵌入矩阵C在所有单词上共享,因此所有单词使用相同的嵌入函数。C由一个V * D矩阵表示,其中V是词汇量的大小,D是嵌入的大小。得到的嵌入被连接成一个隐藏层;之后,可以应用一个偏置b和一个非线性函数,比如tanh。隐藏层的输出因此由函数z = tanh(concat(w [t-n+1] , …, w [t-1] ) + b)表示。从隐藏层,我们现在可以通过将隐藏层与U相乘来输出下一个单词w [t]的概率分布。这将隐藏层映射到单词空间,添加一个偏置b并应用 softmax 函数以获得概率分布。最终层计算softmax(zU +b)*。这个网络如下图所示:
给定单词w**[t-1] … w*[t-n+1],输出单词w[t]*的概率分布的神经网络语言模型。C是嵌入矩阵。
这个模型同时学习词汇表中所有单词的嵌入以及单词序列的概率函数模型。由于这些分布式表示,它能够将这个概率函数推广到在训练过程中没有看到的单词序列。测试集中特定的单词组合在训练集中可能没有出现,但是具有类似嵌入特征的序列更有可能在训练过程中出现。
下图展示了一些词嵌入的二维投影。可以看到,在嵌入空间中,语义上接近的单词也彼此接近。
在这个空间中,二维嵌入空间中相关的单词彼此接近 [21]。
单词嵌入可以在大型文本数据语料库上无监督地训练。这样,它们能够捕捉单词之间的一般语义信息。得到的嵌入现在可以用于改进其他任务的性能,其中可能没有大量标记的数据可用。例如,试图对文章的情感进行分类的分类器可能是在使用先前学习的单词嵌入而不是独热编码向量进行训练的。这样,单词的语义信息就变得对情感分类器可用了。因此,许多研究致力于创建更好的单词嵌入,而不是专注于学习单词序列上的概率函数。例如,一种流行的单词嵌入模型是 word2vec [22],[23]。
令人惊讶的是,这些词嵌入可以捕捉单词之间的类比作为差异。例如,它可能捕捉到"女人"和"男人"的嵌入之间的差异编码了性别,并且这个差异在其他与性别相关的单词,如"皇后"和"国王"中是相同的。
词嵌入可以捕捉单词之间的语义差异 [24]。
embed(女人) - embed(男人) ? embed(姑妈) - embed(叔叔)
embed(女人) - embed(男人) ? embed(皇后) - embed(国王)
虽然之前的前馈网络语言模型可以克服模拟大词汇输入的维度诅咒,但仍然仅限于建模固定长度的单词序列。为了克服这个问题,我们可以使用 RNN 来构建一个不受固定长度单词序列限制的 RNN 语言模型 [25]。这些基于 RNN 的模型不仅可以在输入嵌入中聚类相似的单词,还可以在循环状态向量中聚类相似的历史。
这些基于单词的模型的一个问题是计算每个单词在词汇表中的输出概率 P(w [i] | context)。我们通过对所有单词激活进行 softmax 来获得这些输出概率。对于一个包含50,000个单词的小词汇表 V,这将需要一个*|S| * |V|的输出矩阵,其中|V|是词汇表的大小,|S|*是状态向量的大小。这个矩阵非常庞大,在增加词汇量时会变得更大。由于 softmax 通过所有其他激活的组合来归一化单个单词的激活,我们需要计算每个激活以获得单个单词的概率。这两者都说明了在大词汇表上计算 softmax 的困难性;在 softmax 之前需要大量参数来建模线性转换,并且 softmax 本身计算量很大。
有一些方法可以克服这个问题,例如,通过将 softmax 函数建模为一个二叉树,从而只需要 log(|V|) 计算来计算单个单词的最终输出概率 [26]。
不详细介绍这些解决方法,让我们看看另一种语言建模的变体,它不受这些大词汇量问题的影响。
基于字符的模型
在大多数情况下,语言建模是在单词级别进行的,其中分布是在一个固定词汇量为*|V|的词汇表上。在实际任务中,如语音识别中使用的语言模型,词汇表通常超过100,000*个单词。这个巨大的维度使得建模输出分布非常具有挑战性。此外,这些单词级别的模型在建模包含非单词字符串的文本数据时受到相当大的限制,比如多位数字或从未出现在训练数据中的单词(词汇外单词)。
可以克服这些问题的一类模型称为字符级语言模型[27]。这些模型对字符序列的分布建模,而不是单词,从而使您可以计算一个更小的词汇表上的概率。这里的词汇表包括文本语料库中所有可能的字符。然而,这些模型也有一个缺点。通过对字符序列而不是单词进行建模,我们需要对更长的序列进行建模,以在时间上捕获相同的信息。为了捕获这些长期依赖关系,让我们使用 LSTM RNN 语言模型。
本节的后续部分将详细介绍如何在 Tensorflow 中实现字符级 LSTM 以及如何在列夫·托尔斯泰的《战争与和平》上进行训练。这个 LSTM 将建模下一个字符的概率,给定先前看到的字符:P(c [t] | c [t-1] … c [t-n] )。
因为完整文本太长,无法使用时间反向传播(BPTT)训练网络,我们将使用一种批量变体,称为截断的 BPTT。在这种方法中,我们将训练数据分成固定序列长度的批次,并逐批次训练网络。由于批次将相互跟随,我们可以使用最后一批的最终状态作为下一批的初始状态。这样,我们可以利用状态中存储的信息,而无需对完整输入文本进行完整的反向传播。接下来,我们将描述如何读取这些批次并将其馈送到网络中。
预处理和读取数据
要训练一个好的语言模型,我们需要大量的数据。在我们的示例中,我们将了解基于列夫·托尔斯泰的《战争与和平》的英文译本的模型。这本书包含超过500,000个单词,使其成为我们小范例的完美候选者。由于它是公有领域的作品,因此《战争与和平》可以从古腾堡计划免费下载为纯文本。作为预处理的一部分,我们将删除古腾堡许可证、书籍信息和目录。接下来,我们将去除句子中间的换行符,并将允许的最大连续换行数减少到两个。
要将数据馈送到网络中,我们必须将其转换为数字格式。每个字符将与一个整数相关联。在我们的示例中,我们将从文本语料库中提取总共 98 个不同的字符。接下来,我们将提取输入和目标。对于每个输入字符,我们将预测下一个字符。由于我们使用截断的 BPTT 进行训练,我们将使所有批次相互跟随,以利用序列的连续性。将文本转换为索引列表并将其分成输入和目标批次的过程如下图所示:
将文本转换为长度为 5 的整数标签的输入和目标批次。请注意,批次彼此相继。
LSTM 网络
我们要训练的网络将是一个具有 512 个单元的两层 LSTM 网络。我们将使用截断的 BPTT 来训练这个网络,因此我们需要在批处理之间存储状态。
首先,我们需要为输入和目标定义占位符。输入和目标的第一维是批处理大小,即并行处理的示例数。第二维将沿着文本序列的维度。这些占位符接受包含字符索引的序列批次:
inputs = tf.placeholder(tf.int32, (batch_size, sequence_length))
targets = tf.placeholder(tf.int32, (batch_size, sequence_length))
要将字符馈送到网络,我们需要将它们转换成向量。我们将它们转换为独热编码,这意味着每个字符将被转换为一个长度等于数据集中不同字符数量的向量。这个向量将全为零,除了与其索引对应的单元,该单元将被设置为 1。在 TensorFlow 中,可以轻松完成以下代码行:
one_hot_inputs = tf.one_hot(inputs, depth=number_of_characters)
接下来,我们将定义我们的多层 LSTM 架构。首先,我们需要为每一层定义 LSTM 单元(lstm_sizes
是每一层大小的列表,例如(512, 512),在我们的情况下):
cell_list = (tf.nn.rnn_cell.LSTMCell(lstm_size) for lstm_size in lstm_sizes)
然后,使用以下方法将这些单元包装在单个多层 RNN 单元中:
multi_cell_lstm = tf.nn.rnn_cell.MultiRNNCell(cell_list)
为了在批处理之间存储状态,我们需要获取网络的初始状态,并将其包装在要存储的变量中。请注意,出于计算原因,TensorFlow 会将 LSTM 状态存储在两个单独张量的元组中(来自长短期记忆部分的c
和h
)。我们可以使用flatten
方法展平这个嵌套数据结构,将每个张量包装在变量中,并使用pack_sequence``_as
方法重新打包成原始结构:
initial_state = self.multi_cell_lstm.zero_state(batch_size, tf.float32)
# Convert to variables so that the state can be stored between batches
state_variables = tf.python.util.nest.pack_sequence_as(self.initial_state,(tf.Variable(var, trainable=False) for var in tf.python.util.nest.flatten(initial_state)))
现在我们已经将初始状态定义为一个变量,我们可以开始通过时间展开网络。TensorFlow 提供了dynamic_rnn
方法,根据输入的序列长度动态展开网络。该方法将返回一个包含表示 LSTM 输出和最终状态的张量的元组:
lstm_output, final_state = tf.nn.dynamic_rnn(cell=multi_cell_lstm, inputs=one_hot_inputs, initial_state=state_variable)
接下来,我们需要将最终状态存储为下一批处理的初始状态。我们使用变量的assign
方法将每个最终状态存储在正确的初始状态变量中。control_dependencies
方法用于强制状态更新在返回 LSTM 输出之前运行:
store_states = (state_variable.assign(new_state)for (state_variable, new_state) in zip(tf.python.util.nest.flatten(self.state_variables),tf.python.util.nest.flatten(final_state)))
with tf.control_dependencies(store_states):lstm_output = tf.identity(lstm_output)
要从最终 LSTM 输出中获得 logit 输出,我们需要对输出应用线性变换,这样它就可以将batch size * sequence length * number of symbols作为其维度。在应用这个线性变换之前,我们需要将输出展平成大小为*number of outputs ** number of output features的矩阵:
output_flat = tf.reshape(lstm_output, (-1, lstm_sizes(-1)))
然后,我们可以定义并应用线性变换,使用权重矩阵W和偏差b来获得 logits,应用 softmax 函数,并将其重塑为一个尺寸为*batch size ** sequence length * number of characters的张量:
# Define output layer
logit_weights = tf.Variable(tf.truncated_normal((lstm_sizes(-1), number_of_characters), stddev=0.01))
logit_bias = tf.Variable(tf.zeros((number_of_characters)))
# Apply last layer transformation
logits_flat = tf.matmul(output_flat, self.logit_weights) + self.logit_bias
probabilities_flat = tf.nn.softmax(logits_flat)
# Reshape to original batch and sequence length
probabilities = tf.reshape(probabilities_flat, (batch_size, -1, number_of_characters))
LSTM 字符语言模型展开
训练
现在我们已经定义了网络的输入、目标和架构,让我们来定义如何训练它。训练的第一步是定义我们要最小化的损失函数。这个损失函数描述了在给定输入和目标的情况下输出错误序列的成本。因为我们是在考虑前面的字符来预测下一个字符,所以这是一个分类问题,我们将使用交叉熵损失。我们通过使用sparse_softmax_cross_
entropy_with_logits
TensorFlow 函数来实现这一点。该函数将网络的 logits 输出(softmax 之前)和目标作为类标签,计算每个输出与其目标的交叉熵损失。为了减少整个序列和所有批次的损失,我们取所有损失的均值。
请注意,我们首先将目标扁平化为一个一维向量,以使它们与网络的扁平化 logits 输出兼容:
# Flatten the targets to be compatible with the flattened logits
targets_flat = tf.reshape(targets, (-1, ))
# Get the loss over all outputs
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(logits_flat, targets_flat)
# Reduce the loss to single value over all outputs
loss = tf.reduce_mean(loss)
现在我们已经定义了这个损失函数,可以在 TensorFlow 中定义训练操作,来优化我们的输入和目标批次的网络。为了执行优化,我们将使用 Adam 优化器;这有助于稳定梯度更新。Adam 优化器只是在更受控制的方式下执行梯度下降的特定方式 [28]。我们还会裁剪梯度,以防止梯度爆炸:
# Get all variables that need to be optimised
trainable_variables = tf.trainable_variables()
# Compute and clip the gradients
gradients = tf.gradients(loss, trainable_variables)
gradients, _ = tf.clip_by_global_norm(gradients, 5)
# Apply the gradients to those variables with the Adam optimisation algorithm.
optimizer = tf.train.AdamOptimizer(learning_rate=2e-3)
train_op = optimizer.apply_gradients(zip(gradients, trainable_variables))
已经定义了训练所需的所有 TensorFlow 操作,现在我们可以开始用小批量进行优化。如果data_feeder
是一个生成器,返回连续的输入和目标批次,那么我们可以通过迭代地提供输入和目标批次来训练这些批次。我们每 100 个小批量重置一次初始状态,这样网络就能学习如何处理序列开头的初始状态。你可以使用 TensorFlow saver 来保存模型,以便稍后进行采样:
with tf.Session() as session:session.run(tf.initialize_all_variables())for i in range(minibatch_iterations):input_batch, target_batch = next(data_feeder)loss, _ = sess.run((loss, train_op),feed_dict={ inputs: input_batch,targets: target_batch})# Reset initial state every 100 minibatchesif i % 100 == 0 and i != 0:for state in tf.python.util.nest.flatten(state_variables):session.run(state.initializer)
采样
一旦我们的模型训练完成,我们可能想要从该模型中对序列进行采样以生成文本。我们可以使用与训练模型相同的代码初始化我们的采样架构,但我们需要将batch_size
设置为1
,sequence_length
设置为None
。这样,我们可以生成单个字符串并对不同长度的序列进行采样。然后,我们可以使用训练后保存的参数初始化模型的参数。为了开始采样,我们将一个初始字符串(prime_string
)输入网络的状态。输入这个字符串后,我们可以根据 softmax 函数的输出分布对下一个字符进行采样。然后我们可以输入这个采样的字符并获取下一个字符的输出分布。这个过程可以继续进行一定数量的步骤,直到生成指定大小的字符串:
# Initialize state with priming string
for character in prime_string:character_idx = label_map(character)# Get output distribution of next characteroutput_distribution = session.run(probabilities, feed_dict={inputs: np.asarray(((character_idx)))})
# Start sampling for sample_length steps
for _ in range(sample_length):# Sample next character according to output distributionsample_label = np.random.choice(labels, size=(1), p=output_distribution(0, 0))output_sample += sample_label# Get output distribution of next characteroutput_distribution = session.run(probabilities,feed_dict={inputs: np.asarray((label_map(character))))
示例训练
现在我们已经有了用于训练和采样的代码,我们可以对列夫·托尔斯泰的《战争与和平》进行网络训练,并在每个批次迭代之后对网络学到的内容进行采样。让我们用短语“她出生在年份”来激活网络,看看它在训练期间如何完成它。
经过 500 批次,我们得到了这个结果:“她出生在年份 sive 但 us eret tuke Toffhin e feale shoud pille saky doctonas laft the comssing hinder to gam the droved at ay vime”。网络已经学会了一些字符的分布,并且提出了一些看起来像是单词的东西。
经过 5,000 批次,网络掌握了许多不同的词语和名称:“她出生在 年份,他有许多的 Seffer Zsites。现在在 他的冠军-毁灭中,eccention,形成了一个 Veakov 的狼 也因为他是 congrary,他突然有了 首次没有回答。” 它仍然会创造出看起来合理的单词,比如“congrary”和“eccention”。
经过 50,000 批次,网络输出以下文本:“她出生在年份 1813。最后,天空可能会表现出莫斯科的房子 有一个绝佳的机会必须通过 Rostóvs’,所有的时间:坐退休,向他们展示 confure the sovereigns.” 网络似乎已经明白了一个年份是跟随我们激活字符串的一个非常合理的词。短字符串的词组似乎有意义,但是独立的句子还不具备意义。
经过 500,000 批次,我们停止了训练,网络输出了这个:“她出生在 年份 1806,当他在他的名字上表达了他的思想。公社不会牺牲他 :“这是什么?”娜塔莎问。“你还记得吗?” 我们可以看到,网络现在正在尝试构建句子,但这些句子彼此之间并不连贯。值得注意的是,在最后,它模拟了完整句子的小型对话,包括引号和标点符号。
尽管不完美,但 RNN 语言模型能够生成连贯的文本片段令人印象深刻。我们在此鼓励您尝试不同的架构,增加 LSTM 层的大小,将第三个 LSTM 层放入网络中,从互联网上下载更多文本数据,并查看您能够改进当前模型的程度。
到目前为止,我们讨论过的语言模型在许多不同的应用中被使用,从语音识别到创建能够与用户进行对话的智能聊天机器人。在接下来的部分中,我们将简要讨论深度学习语音识别模型,其中语言模型起着重要作用。
语音识别
在之前的章节中,我们看到了循环神经网络可以用来学习许多不同时间序列的模式。在本节中,我们将看看这些模型如何用于识别和理解语音的问题。我们将简要概述语音识别流水线,并提供如何在流水线的每个部分中使用神经网络的高层次视图。为了更多了解本节讨论的方法,我们希望您参考参考文献。
语音识别流水线
语音识别试图找到最有可能的单词序列的转录,考虑到提供的声学观察;这由以下表示:
转录 = argmax( P(单词 | 音频特征))
此概率函数通常由不同部分建模(请注意通常忽略归一化项 P(音频特征)):
P(单词 | 音频特征) = P(音频 特征 | 单词) * P(单词)
= P(音频特征 | 音素) * P(音素 | 单词) * P(单词)
注意
什么是音素?
音素是定义单词发音的基本声音单位。例如,单词“bat”由三个音素/b/
、/ae/
和/t/
组成。每个音素都与特定的声音相关联。英语口语大约由 44 个音素组成。
这些概率函数中的每一个都将由识别系统的不同部分建模。典型的语音识别流水线接收音频信号并执行预处理和特征提取。然后使用这些特征在一个声学模型中,该模型尝试学习如何区分不同的声音和音素:P(音频特征 | 音素)。然后,这些音素将与发音词典的帮助匹配到字符或单词上:P(音素 | 单词)。从音频信号中提取的单词的概率然后与语言模型的概率相结合,P(单词)。然后通过一个解码搜索步骤找到最可能的序列,该步骤搜索最可能的序列(参见解码部分)。此语音识别流水线的高级概述如下图所示:
典型语音识别流水线概述
大型、实际应用的词汇语音识别流水线基于同样的流水线;然而,它们在每个步骤中使用了许多技巧和启发式方法来使问题可解。虽然这些细节超出了本节的范围,但有开源软件可用——Kaldi [29]——允许您使用先进的流水线训练语音识别系统。
在接下来的章节中,我们将简要描述标准流水线中的每个步骤以及深度学习如何帮助改善这些步骤。
语音作为输入数据
语音是一种通常传递信息的声音类型。它是通过介质(如空气)传播的振动。如果这些振动在 20 Hz 和 20 kHz 之间,则对人类是可听见的。这些振动可以被捕捉并转换成数字信号,以便在计算机上用于音频信号处理。它们通常由麦克风捕获,之后连续信号被离散采样。典型的采样率是 44.1 kHz,这意味着每秒对传入音频信号的幅度进行了 44100 次测量。请注意,这大约是最大人类听力频率的两倍。一个人说“hello world”的采样录音如下图所示:
一个人说“hello world”在时域的语音信号
预处理
在前述图像中的音频信号的录制持续了 1.2 秒。为了将音频数字化,它以每秒 44100 次(44.1 kHz)进行采样。这意味着对于这 1.2 秒的音频信号大约采集了 50000 个振幅样本。
即使是一个小例子,这些在时间维度上是很多点。为了减小输入数据的大小,在馈送到语音识别算法之前,这些音频信号通常被预处理以减少时间步数。一个典型的转换将信号转换为谱图,它表示信号中的频率随时间的变化,见下图。
这种频谱转换是通过将时间信号分成重叠窗口并对每个窗口进行傅立叶变换来完成的。傅立叶变换将信号随时间分解为组成信号的频率 [30]。得到的频率响应被压缩到固定的频率箱中。这个频率箱的数组也称为滤波器组。滤波器组是将信号分离到多个频率带中的一组滤波器。
假设前述的“hello world”录音被分成了 25 ms 的重叠窗口,并以 10 ms 的跨度。然后,利用窗口化傅立叶变换将得到的窗口转换为频率空间。这意味着每个时间步的振幅信息被转换为每个频率的振幅信息。最终的频率根据对数尺度(也称为 Mel 尺度)映射到 40 个频率箱中。得到的滤波器组谱图如下图所示。这个转换将时间维度从 50000 减少到 118 个样本,其中每个样本的大小为 40 个向量。
前图中语音信号的 Mel 频谱
特别是在较旧的语音识别系统中,这些 Mel-scale 滤波器组会通过去相关处理来消除线性依赖关系。通常,这是通过对滤波器组的对数进行离散 余弦变换 (DCT)来完成的。这个 DCT 是傅立叶变换的一种变体。这种信号转换也被称为梅尔频率倒谱系数 (MFCC)。
更近期,深度学习方法,如卷积神经网络,已学习了一些这些预处理步骤 [31], [32]。
声学模型
在语音识别中,我们希望将口语变成文本输出。这可以通过学习一个依赖时间的模型来实现,该模型接收一系列音频特征(如前一节所述),并输出可能的被说出的单词的序列分布。这个模型称为声学模型。
声学模型试图模拟一系列音频特征由一系列单词或音素生成的可能性:P (音频 特征 | 单词) = P (音频特征 | 音素) * P (音素 | 单词)。
在深度学习变得流行之前,典型的语音识别声学模型将使用隐马尔可夫模型 (HMMs) 来模拟语音信号的时间变化性 [33], [34]。每个 HMM 状态发射一组高斯混合以模拟音频信号的频谱特征。发射的高斯混合构成高斯混合模型 (GMM),它们确定每个 HMM 状态在短时间段内的声学特征拟合程度。HMMs 被用来模拟数据的序列结构,而 GMMs 则模拟信号的局部结构。
HMM 假设连续帧在给定 HMM 的隐藏状态的情况下是独立的。由于这种强条件独立假设,声学特征通常是去相关的。
深信度网络
在语音识别中使用深度学习的第一步是用深度神经网络 (DNN) 替代 GMMs [35]。DNNs 将一组特征向量作为输入,并输出 HMM 状态的后验概率:P (HMM 状态 | 音频特征)。
在这一步中使用的网络通常是在一组频谱特征上以一个通用模型进行预训练的。通常,深度信度网络 (DBN) 用于预训练这些网络。生成式预训练会创建多层逐渐复杂的特征检测器。一旦生成式预训练完成,网络会被判别性地微调以分类正确的 HMM 状态,基于声学特征。这些混合模型中的 HMMs 用于将由 DNNs 提供的段分类与完整标签序列的时间分类对齐。已经证明这些 DNN-HMM 模型比 GMM-HMM 模型具有更好的电话识别性能 [36]。
循环神经网络
本节描述了如何使用 RNN 模型来对序列数据进行建模。直接应用 RNN 在语音识别上的问题在于训练数据的标签需要与输入完全对齐。如果数据对齐不好,那么输入到输出的映射将包含太多噪音,网络无法学到任何东西。一些早期的尝试试图通过使用混合 RNN-HMM 模型来建模声学特征的序列上下文,其中 RNN 将模拟 HMM 模型的发射概率,很类似 DBNs 的使用 [37] 。
后来的实验尝试训练 LSTM(见长 短期记忆一节)输出给定帧的音素后验概率 [38]。
语音识别的下一步将是摆脱需要对齐标记数据的必要性,并消除混合 HMM 模型的需要。
CTC
标准 RNN 目标函数独立定义了每个序列步骤,每个步骤输出自己独立的标签分类。这意味着训练数据必须与目标标签完全对齐。然而,可以制定一个全局目标函数,最大化完全正确标记的概率。其思想是将网络输出解释为给定完整输入序列的所有可能标记序列的条件概率分布。然后可以通过搜索给定输入序列的最可能标记来将网络用作分类器。
连接主义 时间分类 (CTC) 是一种优化函数,它定义了所有输出序列与所有输出对齐的分布 [39]。它试图优化输出序列与目标序列之间的整体编辑距离。这种编辑距离是将输出标签更改为目标标签所需的最小插入、替换和删除次数。
CTC 网络在每个步骤都有一个 softmax 输出层。这个 softmax 函数输出每个可能标签的标签分布,还有一个额外的空白符(Ø)。这个额外的空白符表示该时间步没有相关标签。因此,CTC 网络将在输入序列的任何点输出标签预测。然后通过从路径中删除所有空白和重复标签,将输出转换为序列标记。这相当于在网络从预测无标签到预测标签,或者从预测一个标签到另一个标签时输出一个新的标签。例如,“ØaaØabØØ”被转换为“aab”。这样做的效果是只需要确保整体标签序列正确,从而消除了对齐数据的需要。
进行这种简化意味着可以将多个输出序列简化为相同的输出标签。为了找到最可能的输出标签,我们必须添加所有与该标签对应的路径。搜索这个最可能的输出标签的任务称为解码(见解码部分)。
在语音识别中这样的标注示例可以输出一系列音素,给定一系列声学特征。基于 LSTM 的 CTC 目标函数的功能是在声学建模上提供最先进的结果,并且消除了使用 HMM 对时间变化进行建模的需要 [40],[41]。
基于注意力的模型
使用 CTC 序列到序列模型的替代方案是基于注意力的模型 [42]。这些注意力模型具有动态关注输入序列部分的能力。这使它们能够自动搜索输入信号的相关部分以预测正确的音素,而无需对部分进行明确的分割。
这些基于注意力的序列模型由一个 RNN 组成,它将输入的表示解码为一系列标签,在这种情况下是音素。在实践中,输入表示将由一个模型生成,该模型将输入序列编码为合适的表示。第一个网络称为解码器网络,而后者称为编码器网络 [43]。
解码器由一个注意力模型引导,该模型在编码的输入上的每一步都集中在一个注意力窗口上。注意力模型可以由上下文(它正在关注的内容)或基于位置的信息(它正在关注的位置)的组合驱动。然后,解码器可以使用先前的信息和注意力窗口的信息来输出下一个标签(音素)。
解码
一旦我们用声学模型对音素分布进行建模并训练了语言模型(参见语言建模部分),我们就可以将它们与发音词典结合起来得到一个单词在音频特征上的概率函数:
P(单词|音频特征)= P(音频特征|音素)* P(音素|单词)* P(单词)*
这个概率函数还没有给出最终的转录结果;我们仍然需要在单词序列的分布上进行搜索,以找到最可能的转录。这个搜索过程被称为解码。解码的所有可能路径可以用格数据结构来表示:
修剪后的词格 [44]
给定一系列音频特征序列,最可能的单词序列是通过搜索所有可能的单词序列来找到的 [33]。一种基于动态规划的流行搜索算法,它保证可以找到最可能的序列是维特比算法 [45]。这个算法是一种广度优先搜索算法,主要与在 HMM 中找到最可能的状态序列相关联。
对于大词汇量的语音识别,维特比算法在实践中变得难以处理。因此,在实践中,启发式搜索算法,如束搜索,被用来尝试找到最可能的序列。束搜索启发式只在搜索过程中保留前 n 个最佳解,并假设其余所有解不会导致最可能的序列。
存在许多不同的解码算法 [46],而从概率函数中找到最佳转录的问题大多被视为未解决。
端到端模型
我们想要通过提及端到端技术来总结本章内容。深度学习方法,例如 CTC [47]、[48]和基于注意力的模型 [49],使我们能够以端到端的方式学习完整的语音识别流程。它们这样做而不需要显式地建模音素。这意味着这些端到端模型将在一个单一模型中学习声学模型和语言模型,并直接输出单词的分布。这些模型通过将所有内容合并到一个模型中展示了深度学习的力量;通过这样做,模型在概念上变得更容易理解。我们推测这将导致语音识别在未来几年被认为是一个已解决的问题。
摘要
在本章的开头,我们学习了什么是 RNN,如何训练它们,在训练过程中可能出现的问题以及如何解决这些问题。在第二部分中,我们描述了语言建模的问题以及 RNN 如何帮助我们解决一些建模语言的困难。第三部分以一个实际示例的形式将这些信息汇集在一起,介绍了如何训练一个基于字符级的语言模型,以生成基于列夫·托尔斯泰的《战争与和平》的文本。最后一节简要概述了深度学习,特别是 RNN 如何应用于语音识别问题。
本章讨论的 RNN 是一种非常强大的方法,当涉及到许多任务时非常有前途,例如语言建模和语音识别。它们特别适用于建模序列问题,可以在序列上发现模式。
参考文献
-
[1] Siegelmann, H.T.(1995)。“超越图灵极限的计算”。《科学》238(28):632–637。URL:
binds.cs.umass.edu/papers/1995_Siegelmann_Science.pdf
-
[2] Alex Graves、Greg Wayne 和 Ivo Danihelka(2014)。“神经图灵机”。CoRR URL:
arxiv.org/pdf/1410.5401v2.pdf
-
[3] Yann LeCun, Yoshua Bengio & Geoffrey Hinton(2015)。“深度学习”。《自然》521。URL:
www.nature.com/nature/journal/v521/n7553/full/nature14539.html
-
[4] Oriol Vinyals 和 Alexander Toshev 和 Samy Bengio 和 Dumitru Erhan (2014). “Show and Tell: {A} Neural Image Caption Generator”. CoRR. URL:
arxiv.org/pdf/1411.4555v2.pdf
-
[5] Kyunghyun Cho 等人 (2014). “Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation”. CoRR. URL:
arxiv.org/pdf/1406.1078v3.pdf
-
[6] Ilya Sutskever 等人 (2014). “Sequence to Sequence Learning with Neural Networks”. NIPS’14. URL:
papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf
-
[7] Andrej Karpathy (2015). “The Unreasonable Effectiveness of Recurrent Neural Networks”. URL:
karpathy.github.io/2015/05/21/rnn-effectiveness/
-
[8] Paul J. Werbos (1990). “Backpropagation Through Time: What It Does and How to Do It” Proceedings of the IEEE. URL:
axon.cs.byu.edu/~martinez/classes/678/Papers/Werbos_BPTT.pdf
-
[9] Razvan Pascanu 和 Tomas Mikolov 和 Yoshua Bengio. (2012). “Understanding the exploding gradient problem”. URL:
proceedings.mlr.press/v28/pascanu13.pdf
-
[10] Yoshua Bengio 等人 (1994). “Learning long-term dependencies with gradient descent is difficult”. URL:
proceedings.mlr.press/v28/pascanu13.pdf
-
[11] Razvan Pascanu 和 Tomas Mikolov 和 Yoshua Bengio. (2012). “Understanding the exploding gradient problem”. URL:
proceedings.mlr.press/v28/pascanu13.pdf
-
[12] James Martens, Ilya Sutskever. (2011). “Learning Recurrent Neural Networks with Hessian-Free Optimization”. URL:
www.icml-2011.org/papers/532_icmlpaper.pdf
-
[13] Ilya Sutskever 等人 (2013). “On the importance of initialization and momentum in deep learning”. URL:
proceedings.mlr.press/v28/sutskever13.pdf
-
[14] Geoffrey Hinton & Tijmen Tieleman. (2014) “Neural Networks for Machine Learning - Lecture 6a - Overview of mini-batch gradient descent”. URL:
www.cs.toronto.edu/~tijmen/csc321/slides/lecture_slides_lec6.pdf
-
[15] Martin Riedmiller 和 Heinrich Braun (1992). “Rprop - 一种快速自适应学习算法” URL:
axon.cs.byu.edu/~martinez/classes/678/Papers/riedmiller92rprop.pdf
-
[16] Sepp Hochreiter 和 Jurgen Schmidhuber (1997). “Long Short-Term Memory”. URL:
www.bioinf.jku.at/publications/older/2604.pdf
-
[17] Gers 等人(2000 年)。“学习遗忘:带有 LSTM 的持续预测” 网址:
pdfs.semanticscholar.org/1154/0131eae85b2e11d53df7f1360eeb6476e7f4.pdf
-
[18] Nikhil Buduma(2015)。“深入研究循环神经网络”。网址:
nikhilbuduma.com/2015/01/11/a-deep-dive-into-recurrent-neural-networks/
-
[19] Klaus Greff 等人(2015)。“LSTM:一场搜索空间的奥德赛”。网址:
arxiv.org/pdf/1503.04069v1.pdf
-
[20] Yoshua Bengio 等人(2003)。“神经概率语言模型”。网址:
papers.nips.cc/paper/1839-a-neural-probabilistic-language-model.pdf
-
[21] Christopher Olah(2014)。“深度学习、自然语言处理和表示”。网址:
colah.github.io/posts/2014-07-NLP-RNNs-Representations/
-
[22] Tomas Mikolov 等人(2013 年)。“单词和短语的分布式表示及其组成性”。网址:
papers.nips.cc/paper/5021-distributedrepresentations-of-words-and-phrases-and-theircompositionality.pdf
-
[23] Tomas Mikolov 等人(2013)。“向量空间中单词表示的高效估计”。网址:
arxiv.org/pdf/1301.3781.pdf
-
[24] Tomas Mikolov 等人(2013)。“连续空间词表示的语言规律”。网址:
www.microsoft.com/en-us/research/wp-content/uploads/2016/02/rvecs.pdf
-
[25] Thomas Mikolov 等人(2010 年)。“基于循环神经网络的语言模型”。网址:
www.fit.vutbr.cz/research/groups/speech/publi/2010/mikolov_interspeech2010_IS100722.pdf
-
[26] Frederic Morin 和 Yoshua Bengio(2005)。“分层概率神经网络语言模型”。网址:
www.iro.umontreal.ca/~lisa/pointeurs/hierarchical-nnlm-aistats05.pdf
-
[27] Alex Graves(2013)。“使用循环神经网络生成序列”。网址:
arxiv.org/pdf/1308.0850.pdf
-
[28] Diederik P. Kingma 和 Jimmy Ba(2014)。“Adam:一种随机优化方法”。网址:
arxiv.org/pdf/1412.6980.pdf
-
[29] Daniel Povey 等人(2011 年)。“Kaldi 语音识别工具包”。网址:
kaldi-asr.org/
-
[30] Hagit Shatkay(1995 年)。“傅里叶变换——入门”。URL:
pdfs.semanticscholar.org/fe79/085198a13f7bd7ee95393dcb82e715537add.pdf
-
[31] Dimitri Palaz 等人(2015 年)。“使用原始语音作为输入进行基于 CNN 的语音识别系统分析”。URL:
ronan.collobert.com/pub/matos/2015_cnnspeech_interspeech
-
[32] Yedid Hoshen 等人(2015 年)“从原始多通道波形进行语音声学建模”。URL:
static.googleusercontent.com/media/research.google.com/en//pubs/archive/43290.pdf
-
[33] Mark Gales 和 Steve Young。(2007)。“隐马尔可夫模型在语音识别中的应用”。URL:
mi.eng.cam.ac.uk/~mjfg/mjfg_NOW.pdf
-
[34] L.R. Rabiner。(1989)。“隐马尔可夫模型及其在语音识别中的应用教程”。URL:
www.cs.ubc.ca/~murphyk/Bayes/rabiner.pdf
-
[35] Abdel-rahman Mohamed 等人(2011 年)。“使用深度置信网络进行声学建模”。URL:
www.cs.toronto.edu/~asamir/papers/speechDBN_jrnl.pdf
-
[36] Geoffrey Hinton 等人(2012 年)“语音识别中声学建模的深度神经网络”。URL:
www.microsoft.com/en-us/research/wp-content/uploads/2016/02/HintonDengYuEtAl-SPM2012.pdf
-
[37] Tony Robinson 等人(1996)“循环神经网络在连续语音识别中的应用”。URL:
www.cstr.ed.ac.uk/downloads/publications/1996/rnn4csr96.pdf
-
[38] Graves A,Schmidhuber J。(2005)“双向 LSTM 和其他神经网络架构的逐帧音素分类”。URL:
www.cs.toronto.edu/~graves/nn_2005.pdf
-
[39] Alex Graves 等人(2006 年)。“使用循环神经网络标记未分段序列数据的连接时序分类法”。URL:
www.cs.toronto.edu/~graves/icml_2006.pdf
-
[40] Alex Graves 等人(2013 年)“使用深度循环神经网络的语音识别”。URL:
arxiv.org/pdf/1303.5778.pdf
-
[41] Dario Amodei 等人(2015 年)。“深度语音 2:英语和普通话端到端语音识别”。URL:
arxiv.org/pdf/1512.02595.pdf
-
[42] Jan Chorowski 等人(2015 年)。“基于注意力的语音识别模型”,URL:
arxiv.org/pdf/1506.07503.pdf
-
[43] Dzmitry Bahdanau et al. (2015) “Neural Machine Translation by Jointly Learning to Align and Translate” URL:
arxiv.org/pdf/1409.0473.pdf
-
[44] The Institute for Signal and Information Processing. “Lattice tools”. URL:
www.isip.piconepress.com/projects/speech/software/legacy/lattice_tools/
-
[45] G.D. Forney. (1973). “The viterbi algorithm”. URL:
www.systems.caltech.edu/EE/Courses/EE127/EE127A/handout/ForneyViterbi.pdf
-
[46] Xavier L. Aubert (2002). “An overview of decoding techniques for large vocabulary continuous speech recognition”. URL:
www.cs.cmu.edu/afs/cs/user/tbergkir/www/11711fa16/aubert_asr_decoding.pdf
-
[47] Alex Graves and Navdeep Jaitly. (2014). “Towards End-To-End Speech Recognition with Recurrent Neural Networks” URL:
proceedings.mlr.press/v32/graves14.pdf
-
[48] Awni Hannun. (2014) “Deep Speech: Scaling up end-to-end speech recognition”. URL:
arxiv.org/pdf/1412.5567.pdf
-
[49] William Chan (2015). “Listen, Attend and Spell” URL:
arxiv.org/pdf/1508.01211.pdf
第七章:棋盘游戏的深度学习
也许你读过五六十年代的科幻小说;它们充满了对 21 世纪生活会是什么样子的设想。他们想象了一个人们拥有个人喷气背包、水下城市、星际旅行、飞行汽车和真正有独立思考能力的机器人的世界。21 世纪现在已经到来了;可悲的是,我们不会得到那些飞行汽车,但由于深度学习,我们可能会得到那些机器人。
这与棋盘游戏的深度学习有什么关系?在接下来的两个章节,包括当前章节,我们将看看如何构建人工智能(AI),以学习游戏环境。现实具有广阔的可能性空间。即使是进行简单的人类任务,如让机器人手臂抓取物体,也需要分析大量的感官数据并控制许多用于移动手臂的连续响应变量。
游戏作为测试通用学习算法的绝佳场所。它们给你一个庞大但可控制的可能性环境。此外,说到电脑游戏,我们知道人类可以仅通过屏幕上可见的像素和最微小的指示就学会玩游戏。如果我们将相同的像素以及一个目标输入到计算机代理中,我们知道我们有一个可解决的问题,只要使用正确的算法。实际上,对于电脑来说,问题更容易,因为人类可以识别出他们在视野中看到的东西实际上是游戏像素,而不是屏幕周围的区域。这就是为什么如此多的研究人员将游戏视为开发真正的人工智能的绝佳起点——能够独立于我们运行的自学习机器。此外,如果你喜欢游戏,它会非常有趣。
在本章中,我们将介绍解决棋盘游戏(如跳棋和国际象棋)的不同工具。最终,我们将积累足够的知识,以便理解并实现构建 AlphaGo 的深度学习解决方案,该解决方案击败了最伟大的人类围棋选手。我们将使用各种深度学习技术来实现这一点。接下来的章节将在此基础知识之上构建,并介绍如何使用深度学习来学习如何玩计算机游戏,如乒乓球和打砖块。
我们将在两个章节中涵盖的概念列表如下:
-
极小极大算法
-
蒙特卡洛树搜索
-
强化学习
-
策略梯度
-
Q 学习
-
演员-评论家
-
基于模型的方法
我们将使用一些不同的术语来描述任务及其解决方案。以下是一些定义。它们都使用基本迷宫游戏的示例,因为它是一个很好的、简单的强化学习环境的例子。在迷宫游戏中,有一组位置,它们之间有路径。在这个迷宫中有一个代理,它可以利用路径在不同的位置之间移动。一些位置与奖励相关联。代理的目标是通过迷宫找到最好的奖励。
图 1
-
Agent 是我们试图学习行动的实体。在游戏中,这是玩家,他将尝试找到迷宫的出口。
-
环境 是代理操作的世界/关卡/游戏,也就是迷宫本身。
-
奖励 是代理在环境中获得的反馈。在这个示例迷宫游戏中,它可能是出口方块或图像中的胡萝卜,代理正在尝试收集的物品。一些迷宫还可能有陷阱,会给予负面奖励,代理应该尽量避免。
-
状态 指的是代理关于其当前环境的所有可用信息。在迷宫中,状态就是代理的位置。
-
行动 是代理可以采取的可能响应或一组响应。在迷宫中,这是代理可以从一个状态到另一个状态的潜在路径。
-
控制策略 确定了代理将采取的行动。在深度学习的背景下,这是我们将要训练的神经网络。其他策略可能是随机选择行动或根据程序员编写的代码选择行动。
这一章大部分内容都是代码密集型的,因此,作为从书中复制所有示例的替代方法,你可以在 GitHub 仓库 github.com/DanielSlater/PythonDeepLearningSamples
中找到完整的代码。章节中的所有示例都是使用 TensorFlow 呈现的,但这些概念可以转化为其他深度学习框架。
早期游戏 AI
50 年代开始,研究人员构建了玩跳棋和国际象棋的程序,从而开始了构建 AI 来玩游戏的工作。这两个游戏有一些共同点:
-
它们是零和游戏。一个玩家获得的任何奖励都对应着另一个玩家的损失,反之亦然。当一个玩家赢了,另一个玩家输了。不存在合作的可能性。例如,考虑一个游戏,比如囚徒困境;在这里,两个玩家可以同意合作,并且都获得较小的奖励。
-
它们都是完全信息游戏。游戏的整个状态对于两个玩家始终是已知的,不像扑克牌这样的游戏,你的对手手中确切的牌是未知的。这个事实减少了人工智能必须处理的复杂性。它还意味着关于什么是最佳移动的决定可以基于当前状态。在扑克中,关于如何打牌的假设最佳决策需要的信息不仅仅是你目前的手牌和每个玩家可用的金额,还有关于对手的打法以及他们在之前位置中的出价。
-
这两个游戏都是确定性的。如果任一玩家采取了某个移动,那么下一个状态将是确切的。在某些游戏中,游戏可能基于掷骰子或从牌堆中随机抽取卡片;在这些情况下,将会有许多可能的下一个状态需要考虑。
在国际象棋和跳棋中完美信息和确定性的组合意味着鉴于当前状态,我们可以确切地知道如果当前玩家采取行动我们将处于什么状态。这个属性也适用于如果我们有一个状态,然后采取行动导致一个新的状态。我们可以再次在这个新状态中采取行动,以保持玩得尽可能长的时间。
为了尝试一些掌握棋盘游戏的方法,我们将使用名为Tic-Tac-Toe的游戏的 Python 实现来举例。也被称为井字游戏,这是一个简单的游戏,玩家轮流在一个 3 乘 3 的网格上做标记。第一个在一行中得到三个标记的玩家获胜。Tic-Tac-Toe是另一种确定性、零和、完全信息游戏,在这里选择它是因为它的 Python 实现比国际象棋简单得多。事实上,整个游戏可以用不到一页的代码来完成,这将在本章后面展示。
使用极大极小算法来评估游戏状态
假设我们想要计算在一个零和、确定性、完全信息游戏中的最佳移动。我们怎么做呢?首先,考虑到我们有完全信息,我们知道确切地哪些移动是可用的。鉴于游戏是确定性的,我们知道每一个移动会导致游戏状态的确切变化。对于对手的移动也是如此;我们也知道他们有哪些可能的移动以及每个移动导致的状态会是怎样。
寻找最佳移动的一种方法是为每个玩家在每个状态下构造每个可能移动的完整树,直到我们到达游戏结束的状态。游戏的最终状态也称为终端状态。我们可以为这个终端状态赋予一个值;赢得的可以是值 1,平局是 0,输掉是-1。这些值反映了对我们来说状态的可取之处。我们宁愿赢也不愿平局,而宁愿平局也不愿输。图 2显示了一个例子:
图 2:井字棋所有状态的树
在一个终止状态中,我们可以回到玩家选择导致终止状态的移动的状态。那位玩家,其目标是找到最佳的可能移动,可以确定他们将从他们将采取的行动中获得的确切值,即他们最终将游戏带入的终止状态。他们显然会选择将导致对自己可能获得的最佳值的移动。如果他们有选择要么导致赢得终止状态要么输掉终止状态的行动,他们将选择导致赢得状态的那个行动。
终止状态被选择的状态的值可以标记为玩家可能采取的最佳行动的值。这给了我们在这种状态下的玩家的价值。但是在这里我们正在玩一个双人游戏,所以如果我们回到一个状态,我们将处于另一位玩家要做出移动的状态。现在在我们的图中,我们有了对手在该状态下将从他们在该状态下的行动中获得的价值。
由于这是一个零和游戏,我们希望我们的对手表现得尽可能糟糕,因此我们将选择导致对方状态值最低的移动。如果我们不断回溯状态图,标记所有状态的值为任何动作可能导致的最佳状态值,我们就可以确定当前状态中的最佳动作。
图 3. 极小极大算法
通过这种方式,可以构建游戏的完整树,显示我们可以在当前状态下进行的最佳移动。这种方法称为极小极大算法,是早期研究者用于国际象棋和跳棋游戏的方法。
尽管这种方法告诉我们任何零和、确定性、完美信息游戏的确切最佳移动,但不幸的是它有一个主要问题。国际象棋平均每回合大约有 30 个可能的移动,并且游戏平均持续 40 回合。因此,要从国际象棋的第一个状态构建到所有终止状态将需要大约 30⁴⁰ 个状态。这比世界上最好的硬件可能的数量要大得多。在谈论游戏时,玩家每回合可以采取的移动数量称为广度,游戏每回合采取的移动数量称为深度。
要使极小极大算法在棋类游戏中可行,我们需要大幅减少搜索的深度。与其计算整个树直到游戏结束,我们可以构建我们的树到一个固定的深度,比如从当前状态开始后的六步。在每个不是实际终止状态的叶子节点上,我们可以使用一个评估函数来估计在该状态下玩家获胜的可能性。
对于国际象棋,一个良好的评估函数是对每个玩家可用的棋子数量进行加权计数。因此,兵的得分为 1 分,主教或骑士的为 3 分,车的为 5 分,后的为 8 分。如果我有三个兵和一个骑士,我得到六分;同样地,如果你有两个兵和一个车,你有七分。因此,你领先一分。通常情况下,在国际象棋中,剩下的棋子更多的玩家往往会取胜。然而,任何曾经与好的交换牺牲对手交战的国际象棋玩家都会知道,这个评估函数是有局限性的。
实现 Python 版的 Tic-Tac-Toe 游戏
让我们构建一个基本的Tic-Tac-Toe实现,这样我们就可以看到 min-max 算法的实现是什么样子的。如果你不想复制所有这些,你可以在 GitHub 仓库github.com/DanielSlater/PythonDeepLearningSamples
的tic_tac_toe.py
文件中找到完整的代码。
在游戏棋盘中,我们将用一个 3 x 3 的整数元组表示。使用元组而不是列表,以便以后能够在匹配的棋盘状态之间得到相等。在这种情况下,0表示一个未被玩过的方格。两名玩家将分别用1和**-1**表示。如果玩家一在一个方格上下了一步,那么该方格将被标记为他们的数字。所以让我们开始:
def new_board():return ((0,0,0),(0,0,0),(0,0,0))
在玩家进行下一步前,将会调用new_board
方法,准备好一个新的棋盘:
def apply_move(board_state, move, side):move_x, move_y = movestate_list = list(list(s) for s in board_state)state_list[move_x][move_y] = sidereturn tuple(tuple(s) for s in state_list)
apply_move
方法接受board_state
的 3 x 3 元组之一,并返回应用了给定方向移动的新的board_state
。移动将是一个包含两个整数坐标的长度为 2 的元组。方向将是代表玩家的整数,要么是 1,要么是-1:
import itertoolsdef available_moves(board_state):for x, y in itertools.product(range(3), range(3)):if board_state[x][y] == 0:yield (x, y)
这个方法为给定的 3 x 3 board_state
列出了合法的移动列表,它就是所有非零方格。现在我们只需要一个方法来确定玩家是否已经连成了三个获胜的标记:
def has_3_in_a_line(line):return all(x==-1 for x in line) | all(x==1 for x in line)
has_3_in_a_line
将获取棋盘上的三个方格的序列。如果所有的方格都是 1 或-1,这意味着其中一名玩家连成了三个获胜的标记,赢得了胜利。然后,我们需要对 Tic-Tac-Toe 棋盘上的每条可能的线运行这个方法,以确定玩家是否已经获胜:
def has_winner(board_state):# check rowsfor x in range(3):if has_3_in_a_line (board_state[x]):return board_state[x][0]# check columnsfor y in range(3):if has_3_in_a_line([i[y] for i in board_state]):return board_state[0][y]# check diagonalsif has_3_in_a_line([board_state[i][i] for i in range(3)]):return board_state[0][0]if has_3_in_a_line([board_state[2 - i][i] for i in range(3)]):return board_state[0][2]return 0 # no one has won
只需这几个功能,你就可以玩一局Tic-Tac-Toe游戏。简单地开始,获取一个新的棋盘,然后让玩家依次选择移动并将这些移动应用到board_state
上。如果我们发现没有剩余可用的移动,游戏就是平局。否则,如果has_winner
返回1
或-1
,这意味着其中一名玩家获胜。接下来,让我们编写一个简单的函数来运行一个 Tic-Tac-Toe 游戏,其中的移动由我们传递的方法来决定,这些方法将会是我们将尝试的不同 AI 玩家的控制策略:
def play_game(plus_player_func, minus_player_func):board_state = new_board()player_turn = 1
我们宣告这个方法,并将其带到将为每个玩家选择动作的函数中。每个player_func
将会有两个参数:第一个是当前的board_state
,第二个是玩家所执的一方,1 或-1。player_turn
变量将为我们跟踪这一切:
while True:_available_moves = list(available_moves(board_state))if len(_available_moves) == 0:print("no moves left, game ended a draw")return 0.
这是游戏的主要循环。首先,我们要检查board_state
上是否还有可用的走法;如果有,游戏还没结束,就是平局:
if player_turn > 0:move = plus_player_func(board_state, 1)else:move = minus_player_func(board_state, -1)
运行与轮到哪个玩家的函数相关联的方法来决定一步棋:
if move not in _avialable_moves:# if a player makes an invalid move the other player winsprint("illegal move ", move)return -player_turn
如果任一玩家走出违规步骤,那就是自动认输。代理应该更明白:
board_state = apply_move(board_state, move, player_turn)print(board_state)winner = has_winner(board_state)if winner != 0:print("we have a winner, side: %s" % player_turn)return winnerplayer_turn = -player_turn
将走法应用到board_state
上,并检查我们是否有获胜者。如果有,结束游戏;如果没有,切换player_turn
到另一个玩家,并重新循环。
以下是我们如何编写一种控制策略的方法,该方法将完全随机选择可用的合法走法:
def random_player(board_state, side):moves = list(available_moves(board_state))return random.choice(moves)
让我们运行两个随机玩家相互对战,然后检查输出是否可能看起来像这样:
play_game(random_player, random_player)((0, 0, 0), (0, 0, 0), [1, 0, 0])
([0, -1, 0], (0, 0, 0), [1, 0, 0])
([0, -1, 0], [0, 1, 0], [1, 0, 0])
([0, -1, 0], [0, 1, 0], [1, -1, 0])
([0, -1, 0], [0, 1, 1], [1, -1, 0])
([0, -1, 0], [0, 1, 1], [1, -1, -1])
([0, -1, 1], [0, 1, 1], [1, -1, -1])
we have a winner, side: 1
现在我们有了一种很好的方法来尝试在棋盘游戏上尝试不同的控制策略,所以让我们写些稍微好一点的东西。我们可以从一个 min-max 函数开始,该函数的水平应该比我们当前的随机玩家高得多。Min-max 函数的完整代码也可以在 GitHub 库的min_max.py
文件中找到。
井字棋是一个可能性空间较小的游戏,所以我们可以简单地从棋盘的起始位置运行整个游戏的 min-max,直到我们遍历了每个玩家的每个可能走法。但是使用一个评估函数是个好习惯,因为对我们玩的大多数其他游戏来说,情况并非如此。这里的评估函数将为我们在后面得到两条线中的一个空位置时给我们一个分数;如果我们的对手实现了这一点,那么他将是相反的。首先,我们将需要一个为我们可能做出的每条线得分的方法。score_line
将使用长度为 3 的序列并对它们进行评分:
def score_line(line):minus_count = line.count(-1)plus_count = line.count(1)if plus_count == 2 and minus_count == 0:return 1elif minus_count == 2 and plus_count == 0:return -1return 0
然后evaluate
方法简单地遍历井字棋棋盘上的每条可能的线,并将它们加总起来:
def evaluate(board_state):score = 0for x in range(3):score += score_line(board_state[x])for y in range(3):score += score_line([i[y] for i in board_state])#diagonalsscore += score_line([board_state[i][i] for i in range(3)])score += score_line([board_state[2-i][i] for i in range(3)])return score
然后,我们来到实际的min_max
算法方法:
def min_max(board_state, side, max_depth):best_score = Nonebest_score_move = None
该方法的前两个参数,我们已经熟悉了,是board_state
和side
;不过,max_depth
是新的。Min-max 是一种递归算法,max_depth
将是我们在停止沿树向下移动并仅评估其以获取结果之前所使用的最大递归调用次数。每次我们递归调用min_max
时,我们将max_depth
减少 1,当我们达到 0 时停止评估:
moves = list(available_moves(board_state))if not moves:return 0, None
如果没有可走的步骤,那么就没有必要评估任何东西;这是平局,所以让我们返回一个分数为 0:
for move in moves:new_board_state = apply_move(board_state, move, side)
现在我们将详细介绍每个合法走法,并创建一个应用了该走法的new_board_state
:
winner = has_winner(new_board_state)if winner != 0:return winner * 10000, move
检查这个new_board_state
是否已经获胜。如果游戏已经获胜,则不需要再进行递归调用。在这里,我们将获胜者的分数乘以 1,000;这只是一个任意的大数字,以便实际的胜利或失败总是被认为比我们可能从对evaluate
的调用中获得的最极端结果更好/更差:
else:if max_depth <= 1:score = evaluate(new_board_state)else:score, _ = min_max(new_board_state, -side, max_depth - 1)
如果您没有获胜位置,那么算法的真正精华就开始了。如果达到max_depth
,那么现在就是评估当前board_state
以获得我们的启发式的时候,这能告诉我们当前位置对第一个玩家有多有利。如果还没有达到max_depth
,则递归调用min_max
,直到达到底部:
if side > 0:if best_score is None or score > best_score:best_score = scorebest_score_move = moveelse:if best_score is None or score < best_score:best_score = scorebest_score_move = movereturn best_score, best_score_move
现在我们对new_board_state
中的评分有了,我们想要获得最佳或最差的得分位置,取决于我们是哪一方。我们通过best_score_move
变量跟踪导致这一点的移动,最终在方法结束时将其与分数一起返回。
现在可以创建一个min_max_player
方法,以便回到我们之前的play_game
方法:
def min_max_player(board_state, side):return min_max(board_state, side, 5)[1]
现在,如果我们让random_player
和min_max
玩家进行一系列游戏,我们会发现min_max
玩家几乎每次都会赢。
尽管重要理解 min-max 算法,但实际上从未被使用,因为有一个更好的版本:带有 alpha-beta 剪枝的 min-max。这利用了树的某些分支可以被忽略或剪枝的事实,而无需完全评估它们。alpha-beta 剪枝将产生与 min-max 相同的结果,但平均搜索时间减少了一半。
要解释 alpha-beta 剪枝背后的思想,让我们考虑在构建我们的 min-max 树时,一半的节点试图做出决策以最大化分数,另一半则试图最小化它。当我们开始评估一些叶子时,我们会得到对 min 和 max 决策都有利的结果。如果通过树的某条路径得分为-6,min 分支知道它可以通过跟随该分支获得这个分数。阻止它使用这个分数的是 max 决策必须做出决策,而且它不能选择对 min 节点有利的叶子。
但随着更多叶子的评估,另一个可能对 max 节点有利的叶子出现,得分为+5。max 节点永远不会选择比这更差的结果。但是现在我们对 min 和 max 都有了得分,我们知道如果开始沿着一个最佳 min 得分比-6 更糟糕,而最佳 max 得分比+5 更糟糕的分支,那么无论 min 还是 max 都不会选择这个分支,我们就可以节省对整个分支的评估。
Alpha beta 剪枝中的 alpha 存储了最大决策可以实现的最佳结果。Beta 存储了最小决策可以实现的最佳结果(最低分数)。如果 alpha 大于或等于 beta,我们知道可以跳过对当前分支的进一步评估。这是因为这两个决策已经有更好的选择。
图 4给出了这一点的一个示例。在这里我们看到,从第一个叶子开始,我们可以将 alpha 值设为 0。这是因为一旦最大玩家在一个分支中找到分数为 0,他们就不需要选择一个更低的分数。接下来,第三个叶子的位置上,分数再次为 0,所以最小玩家可以将他们的 beta 分数设为 0。读取branch ignored的分支不再需要进行评估,因为 alpha 和 beta 都是 0。
要理解这一点,考虑一下从评估分支中可能获得的所有可能结果。如果结果为 +1,则最小玩家只需选择已经获得分数为 0 的现有分支。在这种情况下,分支被忽略的分支向左(left)走。如果分数结果为 -1,那么最大玩家只需选择图像中得分为 0 的最左边分支。最后,如果分数为 0,这意味着没有人发生改进,因此我们的位置的评估保持不变。你永远不会得到一个结果,评估一个分支会改变位置的整体评估。以下是修改后使用 alpha beta 剪枝的 min-max 方法的示例:
import sysdef
图 4:使用 alpha beta 剪枝的 min-max 方法
min_max_alpha_beta(board_state, side, max_depth, alpha=-sys.float_info.max,beta=sys.float_info.max):
现在我们传入 alpha
和 beta
作为参数;我们停止搜索那些小于 alpha 或大于 beta 的分支:
best_score_move = Nonemoves = list(available_moves(board_state))if not moves:return 0, Nonefor move in moves:new_board_state = apply_move(board_state, move, side)winner = has_winner(new_board_state)if winner != 0:return winner * 10000, moveelse:if max_depth <= 1:score = evaluate(new_board_state)else:score, _ = min_max_alpha_beta(new_board_state, -side, max_depth - 1, alpha, beta)
现在,当我们递归调用 min_max_alpha_beta
时,我们传入可能已经更新的新 alpha 和 beta 值作为搜索的一部分:
if side > 0:if score > alpha:alpha = scorebest_score_move = move
side > 0
表达式意味着我们希望最大化我们的分数,所以如果新的分数比我们当前的 alpha 更好,我们会将分数存储在 alpha 变量中:
else:if score < beta:beta = scorebest_score_move = move
如果 side
是 < 0,我们在进行最小化,所以把最低分数存储在 beta 变量中:
if alpha >= beta:break
如果 alpha 大于 beta,那么这个分支不能改善当前的分数,所以我们停止搜索:
return alpha if side > 0 else beta, best_score_move
1997 年,IBM 创建了一个名为深蓝的国际象棋程序。它是第一个击败现任世界象棋冠军加里·卡斯帕罗夫的程序。虽然这是一个了不起的成就,但很难称深蓝具有智能。尽管它具有巨大的计算能力,但其基础算法只是上世纪 50 年代的 min-max 算法。唯一的主要区别是深蓝利用了国际象棋的开局理论。
开局理论由一系列从起始位置开始的走法组成,这些走法被认为会导致有利或不利的局面。例如,如果白方棋手以 e4(王前的兵向前移动两格)开局,那么黑方应该回应 c5,这就是西西里防御,对于这个局面,有许多书籍介绍接下来可能出现的走法。深蓝计算机只是简单地遵循这些开局书籍推荐的最佳走法,并且只在开局走法结束时开始计算最佳的极小极大走法。这样,它既省去了计算时间,也利用了人类在国际象棋开局阶段找到最佳局面所进行的大量研究。
学习一个价值函数
让我们对极小极大算法需要计算的具体数量进行更详细的了解。如果我们的游戏广度为b,深度为d,那么使用极小极大评估一个完整游戏需要构建一棵树,最终有d(*b*)个叶子。如果我们使用最大深度*n*和一个评估函数,它将把我们的树大小减小到*n*(b)。但这是一个指数方程,即使n只有 4,b为 20,你仍然有 1,099,511,627,776 种可能性需要评估。这里的权衡是,随着n的降低,我们的评估函数在较浅的层次上被调用,这可能比局面的预估质量要差得多。再一次,以国际象棋为例,我们的评估函数只是简单地统计棋盘上剩下的棋子数量。在较浅的位置停止可能会忽略最后一步将皇后放在可以在下一步中被吃掉的位置的事实。更大的深度总是意味着更准确的评估。
训练 AI 掌握围棋
国际象棋中的可能性虽然很多,但并不是如此之多,以至于用一台强大的计算机无法击败世界上最伟大的人类棋手。围棋是一种源远流长的中国古老游戏,其起源可以追溯到 5500 多年前,远比国际象棋复杂得多。在围棋中,一子可以放在 19 x 19 的棋盘上的任何地方。首先有 361 个可能的走法。因此,要往前搜索k步,你必须考虑 361k 种可能性。使情况更加困难的是,在国际象棋中,你可以通过计算每一方的棋子数量相对精确地评估一个局势的好坏,但在围棋中,没有找到这样简单的评估函数。 要知道一个局势的价值,你必须计算到游戏结束,再往后走 200 多步。这使得游戏通过极小极大来达到一个良好水平几乎是不可能的。
图 5
要深入了解围棋的复杂性,值得思考人类学习围棋与国际象棋的方式。当初学国际象棋时,新手们会在棋盘向对手方向的一系列移动中前进。在某个时刻,他们会做出一步让自己的棋子暴露给对方吃掉的移动。于是对手就会乐意接受并吃掉这个棋子。这时新手玩家立刻就会意识到他们上一步走得不好,如果想要提高,就不能再犯同样的错误。对于玩家来说很容易找出他们做错了什么,尽管要一直自我纠正可能需要大量的实践。
另一方面,当初学围棋时,它看起来就像是棋盘上一系列几乎是随机的移动。在某个时刻,双方玩家都用完了他们的棋子,然后计算局面以确定谁赢了。初学者发现自己输了,盯着摆在不同位置的一堆棋子,想弄清楚到底发生了什么。对于人类来说,围棋是极其困难的,需要高度的经验和技巧才能理解玩家出错的地方。
另外,围棋没有像国际象棋那样的开局理论书籍。围棋的开局理论不是一系列计算机可以遵循的移动序列,而是许多通用原则,例如要追求的良好形状或者控制棋盘角落的方法。围棋中有一种叫做定式的东西,它是研究出的一系列走法,已知会导致不同的优势。但所有这些都必须在玩家意识到可能存在某种特定局面时应用;它们不是可以盲目遵循的动作。
对于围棋等游戏,评估如此困难的一个方法是蒙特卡罗树搜索(MCTS)。如果你学过贝叶斯概率,你会听说过蒙特卡罗采样。这涉及从概率分布中采样以获得无法计算的值的近似值。MCTS 类似。一个样本包括随机选择每个玩家的动作,直到达到终局状态。我们维护每个样本的统计数据,这样在完成后,我们就可以从当前状态中选择具有最高平均成功率的动作。这是我们之前提到的井字棋游戏的 MCTS 示例。完整的代码也可以在 GitHub 存储库的 monte_carlo.py
文件中找到:
import collectionsdef monte_carlo_sample(board_state, side):result = has_winner(board_state)if result != 0:return result, Nonemoves = list(available_moves(board_state))if not moves:return 0, None
这里的 monte_carlo_sample
方法从给定位置生成一个样本。同样,我们有一个方法,它的参数是 board_state
和 side
。这个方法将被递归调用,直到我们达到一个终局状态,所以要么是平局因为不能再下新的棋了,要么是某一方玩家赢了:
# select a random movemove = random.choice(moves)result, next_move = monte_carlo_sample(apply_move(board_state, move, side), -side)return result, move
将从局面中的合法移动中随机选择一个移动,并递归调用样本方法:
def monte_carlo_tree_search(board_state, side, number_of_samples):results_per_move = collections.defaultdict(lambda: [0, 0])for _ in range(number_of_samples):result, move = monte_carlo_sample(board_state, side)results_per_move[move][0] += resultresults_per_move[move][1] += 1
从这个棋盘状态中取出蒙特卡罗样本,并根据它们更新我们的结果:
move = max(results_per_move, key=lambda x: results_per_move.get(x)[0] /results_per_move[move][1])
获得同样结果最佳走法:
return results_per_move[move][0] / results_per_move[move][1], move
这就是将所有内容整合在一起的方法。我们将调用monte_carlo_sample
方法number_of_samples
次,跟踪每次调用的结果。然后我们返回平均表现最佳的走法。
考虑一下 MCTS 得到的结果与涉及最小最大的结果有多大不同是很有意义的。如果我们以国际象棋为例,以这个局面来说,白方有一个获胜的着法,将车移到后排 c8,将黑方将军。使用最小最大算法,这个局面会被评价为白方获胜的局面。但是使用 MCTS,考虑到这里的所有其他走法都会导致黑方潜在的胜利,这个局面将被评价为对黑方有利。这就是为什么 MCTS 在国际象棋中表现很差,并且应该让你感受到为什么只有在最小最大算法不可行时才应该使用 MCTS。在围棋这类游戏中,传统上使用 MCTS 找到了最佳的人工智能表现。
图 6:一个被蒙特卡洛采样严重低估的国际象棋局面。如果轮到白走,他们有一个获胜的着法;但是,如果随机走子,黑方有获胜的机会。
将置信上界应用到树结构
总结一下,最小最大算法可以给出具体的最佳着法,假设有完美的信息;但是 MCTS 只给出一个平均值;尽管它允许我们处理无法用最小最大算法评估的更大状态空间。有没有办法改进 MCTS,使其在给出足够的评价时能收敛到最小最大算法?是的,置信上界应用到树结构的蒙特卡洛树搜索(UCT)确实可以做到这一点。其背后的想法是把 MCTS 看作是一个多臂老丨虎丨机问题。多臂老丨虎丨机问题是我们有一组老丨虎丨机——单臂老丨虎丨机——每台机器都有一个未确定的赔付和每次游戏的平均赔付金额。每台机器的赔付是随机的,但平均赔付金额可能差异很大。我们该如何确定要玩哪些老丨虎丨机?
在选择老丨虎丨机时需要考虑两个因素。第一点是显而易见的,即利用价值,也就是给定老丨虎丨机预期的回报。为了最大化赔付,我们需要始终玩出预期赔付最高的机器。第二点是探索价值,我们希望我们玩的机器增加我们对不同机器赔付的信息。
如果我们玩机器A三次,你将得到 13、10 和 7 的回报,平均回报为 10。我们也有机器B;我们已经玩了它一次,得到了 9 的回报。在这种情况下,可能更倾向于玩机器B,因为尽管平均回报较低,为 9 对 10。我们只玩了一次的事实意味着较低的支付可能只是运气不佳。如果我们再次玩它并得到 13 的回报,机器 B 的平均为 11。因此,我们应该切换到玩那台机器以获得最佳回报。
多臂老丨虎丨机问题在数学领域得到了广泛研究。如果我们重构我们的 MCTS 评估,使其看起来像一个多臂老丨虎丨机问题,我们就可以利用这些成熟的理论。一种思考方式是,与其将问题视为最大化奖励,不如将其视为最小化遗憾的问题。这里的遗憾定义为我们为我们玩的机器获得的奖励与如果我们从一开始就知道最佳机器会得到的最大可能奖励之间的差异。如果我们遵循一项政策,p(a)每次选择一个能给予奖励的动作。给定r为最佳可能行动的奖励的t次玩后的遗憾如下:
如果我们选择一个始终选取奖励最高的机器的政策,它可能并不是真正的最佳机器。因此,我们的遗憾会随着每次玩而线性增加。同样,如果我们采取一个始终试图探索以找到最佳机器的政策,我们的遗憾也会线性增加。我们希望的是一项*p(a)*的政策,其增长呈次线性时间。
最好的理论解决方案是根据置信区间执行搜索。置信区间是我们期望真实均值落在其中的范围,具有一定的概率。在面对不确定性时,我们想要保持乐观。如果我们不知道某件事,我们想要找出答案。置信区间代表了我们对给定随机变量的真实均值的不确定性。根据你的样本均值加上置信区间选择某样本,这将鼓励你探索可能性的空间,并同时加以利用。
对于 i.i.d 在 0 到 1 范围内的随机变量x,在 n 个样本上,真实均值大于样本均值的概率,即加上常数u,由 Hoeffding 不等式给出:Hoeffding, Wassily (1963). 有界随机变量之和的概率不等式,美国统计协会杂志:
我们希望使用这个方程来找到每台机器的上界置信度。* E {x}, x *, 和 * n *都是我们已经有的统计学的一部分。我们需要解这个方程来计算一个值 * u *。为了做到这一点,把方程的左边化简为 p,并找到它与右边相等的点:
我们可以重排它,让u用n和p表示:
现在我们希望选择一个* p *的值,这样我们的精度随时间增加而提高。如果我们设,那么当 n 趋近无穷大时,我们的损失将趋向于 0。代入这个值,我们可以简化为:
均值加上 u 是我们的上界置信界,所以我们可以用它来得到UCB1(上置信界)算法。我们可以用这些值代替我们之前在多臂老丨虎丨机问题中看到的值,其中* r * [* i ] 是从机器i得到的奖励的总和, n * [* i ]是机器i的玩的次数, n *是所有机器的总玩的次数:
我们总是希望选择能为我们带来最高分数的机器。如果我们这样做,我们的损失将以对数的方式随着玩的次数增加,这是我们可以实现的理论最佳情况。使用这个方程做出我们的行动选择会导致这样的行为,我们在早期会尝试各种各样的机器,但我们越多地尝试单一机器,它就会更鼓励我们最终尝试不同的机器。
还要记住,这一系列方程的假设是在早期方程中的 x 的范围,以及当我们将其应用到多臂老丨虎丨机问题时的* r *,它们的值都在 0 到 1 的范围内。所以,如果我们的工作不在这个范围内,我们需要缩放我们的输入。不过,我们并没有做出任何关于分布性质的假设;它可以是高斯的、二项式的等等。
现在我们已经找到了从一组未知分布中采样的最优解决方案;我们如何将其应用于 MCTS 呢?最简单的方法是将当前棋盘状态的第一个移动视为老丨虎丨机或投币机。尽管这样会稍微提高顶层的估计,但下面的每一步都会完全随机,这意味着* r * [* i *] 的估计将会非常不准确。
或者,我们可以将树的每个分支上的每个移动都视为一个多臂赌博机问题。问题在于,如果我们的树非常深,随着我们的评估越深入,我们将到达我们以前从未遇到过的位置,因此我们将没有样本用于我们需要在其中选择的移动范围。我们将为大范围的位置保留大量统计数据,其中大多数将永远不会被使用。
妥协解决方案称为树的上限置信,是我们接下来要讨论的内容。我们将从当前棋盘状态开始进行连续的模拟。在树的每个分支处,我们有一系列可供选择的操作,如果我们对每个潜在移动都有先前的样本统计数据,我们将使用 UCB1 算法来选择用于模拟的动作。如果我们没有每个移动的样本统计数据,我们将随机选择移动。
我们如何决定保留哪些样本统计数据?对于每次模拟,我们为我们以前没有统计数据的第一个遇到的位置保留新的统计数据。完成模拟后,我们更新我们跟踪的每个位置的统计数据。这样,我们忽略了模拟深处的所有位置。经过 x 次评估,我们应该恰好有 x 个树节点,每次模拟增加一个。更重要的是,我们跟踪的节点可能位于我们最常使用的路径周围,使我们能够通过增加我们在树深处评估的移动的准确性来增加我们的顶层评估准确性。
步骤如下:
-
从当前棋盘状态开始进行一次模拟。当你选择一个移动时,请执行以下操作:
-
如果你对当前位置的每一步都有统计数据,就使用 UCB1 算法来选择移动。
-
否则,随机选择移动。如果这是第一个随机选择的位置,则将其添加到我们正在保留统计数据的位置列表中。
-
-
运行模拟,直到达到终止状态,这将给出这次模拟的结果。
-
更新你正在保留统计数据的每个位置的统计数据,指示你在模拟中经历了什么。
-
重复,直到达到最大模拟次数。应用于树的上限置信边界,每个位置的统计数据显示在方框中:
-
上图说明了这是如何发生的。在位置 A,四个可能的移动都收集了统计数据。因此,我们可以使用 UCB1 算法来选择最佳移动,平衡开发性和探索性的价值。在上图中,选择了最左边的移动。这将我们带到位置 B;在这里,只有三个可能的移动中的两个收集了统计数据。因此,你需要为这次模拟随机选择一个移动。由于巧合,选择了最右边的移动;剩下的移动是随机选择的,直到到达最终的位置 C,在那里圈圈玩家获胜。然后,将这些信息应用于一个图表,如下图所示:
-
我们会为我们经过且已经有统计数据的任何一个位置添加统计信息,因此第一个图表中的 1/2 现在变成了 2/3。我们还会为我们遇到的第一个没有统计信息的位置添加统计信息。在这里,它是第二行中最右边的位置;它现在的分数是 1/1,因为圈圈玩家赢了。如果再次选择这条分支并且你到达位置 D,使用 UCB1 算法来选择移动,而不是随机选择。
-
这是我们的井字棋游戏在 Python 中的实现:
def upper_confidence_bounds(payout, samples_for_this_machine, log_total_samples):return payout / samples_for_this_machine + math.sqrt((2 * log_total_samples)/ samples_for_this_machine)
首先,我们需要一个计算 UCB1 值的方法;这是在 Python 中的 UCB1 公式。唯一的区别是这里我们使用log_total_samples
作为输入,因为它允许我们稍后进行小优化:
def monte_carlo_tree_search_uct(board_state, side, number_of_rollouts):state_results = collections.defaultdict(float)state_samples = collections.defaultdict(float)
声明该方法和两个字典,即state_results
和state_samples
。它们将跟踪我们在模拟期间遇到的不同棋盘状态的统计信息:
for _ in range(number_of_rollouts):current_side = sidecurrent_board_state = board_statefirst_unvisited_node = Truerollout_path = []result = 0
主循环是我们每次模拟都会经历的过程。在模拟开始时,我们需要初始化将跟踪模拟进展的变量。first_unvisited_node
将跟踪我们是否为此次模拟创建了一个新的统计跟踪节点。当遇到第一个没有统计信息的状态时,我们创建新的统计节点,将其添加到state_results
和state_samples
字典中,并将变量设置为False
。rollout_path
将跟踪我们在此次模拟中访问的每个节点,这些节点是我们保留了统计节点的节点。当我们在模拟结束时获得结果时,我们将更新沿路径的所有状态的统计信息:
while result == 0:move_states = {move: apply_move(current_board_state, move, current_side)for move in available_moves(current_board_state)}if not move_states:result = 0break
当result == 0
时,我们进入模拟的循环;这将一直运行,直到一方胜利。在模拟的每个循环中,我们首先构造一个字典move_states
,将每个可用的移动映射到该移动将带我们进入的状态。如果没有可以进行的移动,那么我们处于终止状态,这是一局平局。所以你需要将其记录为结果,并跳出模拟循环:
if all((state in state_samples) for _, state in move_states):log_total_samples = math.log(sum(state_samples[s] for s in move_states.values()))move, state = max(move_states, key=lambda _, s:upper_confidence_bounds(state_results[s], state_samples[s], log_total_samples))else:move = random.choice(list(move_states.keys()))
现在我们需要选择在这次投掷中要采取的棋步。根据 MCTS-UCT 算法的规定,如果我们对每个可能的移动都有统计数据,我们选择具有最佳upper_confidence_bounds
得分的移动;否则,我们随机选择。
current_board_state = move_states[move]
现在我们已经选择了我们的走步,我们可以将current_board_state
更新为移动将我们置于的状态:
if first_unvisited_node:rollout_path.append((current_board_state, current_side))if current_board_state not in state_samples:first_unvisited_node = False
现在我们需要检查我们是否已经到达了 MCTS-UCT 树的末端。我们将向rollout_path
中添加我们访问的每个节点,直到第一个之前未访问的节点。一旦我们从这次投掷中得到结果,我们将更新所有这些节点的统计数据。
current_side = -current_sideresult = has_winner(current_board_state)
我们处于我们的投掷循环的最后,所以下一次迭代时要改变双方的位置,并检查当前状态是否有人获胜。如果是的话,当我们回到while result == 0
语句时,它将导致我们退出投掷循环:
for path_board_state, path_side in rollout_path:state_samples[path_board_state] += 1.result = result*path_side/2.+.5state_results[path_board_state] += result
现在我们已经完成了一次投掷,离开了投掷循环。我们现在需要用结果更新我们的统计数据。rollout_path
中包含要更新的每个节点的path_board_state
和path_side
,因此我们需要遍历其中的每个条目。最后需要指出的两点是,我们的游戏结果介于-1 和 1 之间。但是 UCB1 算法期望其支付在 0 和 1 之间;行result*path_side/2.+.5
就做到了这一点。其次,我们还需要转换结果以代表它们所代表的一方。对我来说一个好棋步是对手的坏棋步的对立面:
move_states = {move: apply_move(board_state, move, side) for move in available_moves(board_state)}move = max(move_states, key=lambda x: state_results[move_states[x]] / state_samples[move_states[x]])return state_results[move_states[move]] / state_samples[move_states[move]], move
最后,一旦完成了所需数量的投掷,我们可以基于最佳预期报酬从当前状态选择最佳的走步。不再需要使用 UCB1 来选择最佳走步。因为这是最终决定,不需要进行额外的探索,最佳走步就是最佳平均报酬。
这就是 MCTS-UCT 算法。它有许多不同的变体,针对特定情况具有不同的优势,但它们都有这个作为核心逻辑。MCTS-UCT 给了我们一种一般的方式来评判类似围棋这样具有庞大搜索空间的游戏的走步。而且,它并不仅限于完全信息的游戏;在具有部分观察状态的游戏中,如扑克牌游戏中,它通常也表现良好。甚至更一般地说,任何我们遇到的可以重新配置适应它的问题,例如,它被用作自动定理证明机器的基础。
蒙特卡洛树搜索中的深度学习
即使使用了 MCTS-UCT 算法,计算机仍然无法与最优秀的围棋选手相提并论;然而,在 2016 年, Google Deep Mind 团队开发了一个名为 AlphaGo 的人工智能。它在五局比赛中击败了世界顶尖的围棋选手李世石,以 4-1 的比分获胜。他们这样做的方式是在标准 MCTS UCT 方法的基础上进行了三项改进。
如果我们思考为什么 MCTS 如此不准确,一个直观的答案可能是,评估中使用的动作是随机选择的,而我们知道某些动作比其他动作更有可能。在围棋中,当争夺角落的控制权时,该区域周围的动作是更好的选择,而不是棋盘另一侧的动作。如果我们有一种良好的方法来选择哪些动作可能被下,我们将大大减少搜索的广度,从而增加我们 MCTS 评估的准确性。如果我们回到前面的国际象棋局面,尽管每个合法的动作理论上都可以被下,但是如果你对手没有任何国际象棋技巧,只会下赢棋的动作,评估其他动作就是在浪费 CPU 周期。
深度学习可以帮助我们解决这个问题。我们可以利用神经网络的模式识别特性来粗略估计在游戏中给定位置的棋子被下的概率。对于 AlphaGo,使用了一个具有 13 层卷积网络和 relu 激活函数的网络。网络的输入是 19 x 19 的棋盘状态,输出是另一个 19 x 19 的 softmax 层,表示每个棋盘方格中下棋的概率。然后,它在大量专家级人类围棋对局的数据库上进行训练。网络将接收一个单一的位置作为输入,以及从该位置下的棋子作为目标。损失函数是网络激活和人类下的棋子之间的均方误差。在充分的训练下,该网络学会了以 57%的准确率预测人类下棋的动作。在这里使用测试集特别重要,因为过度拟合是一个大问题。除非网络能够将对一个位置的理解推广到以前未见过的位置,否则它是无用的。
如果我们想在前面的井字棋示例中实现类似的东西,我们只需将move = random.choice(moves)
这行替换为使用由训练过的神经网络选择的动作的monte_carlo_sample
方法或 UCT 版本。如果你有一个大型的训练集合例子游戏,这种技术将适用于任何离散游戏。
如果你没有例子游戏的数据库,你可以使用另一种方法。如果你有一个稍微有技巧的代理程序,你甚至可以使用该代理程序来生成初始的例子游戏集合。例如,一个好的方法是使用极小-极大或 MCTS UCT 算法来生成例子位置和动作。然后,可以训练一个网络来从该集合中下棋。这是一个很好的方法,可以让网络学会如何以足够高的标准玩游戏,以至于它至少可以探索游戏空间的可能动作,而不是完全随机的动作。
如果我们实现这样的神经网络,用它来选择在蒙特卡洛展开中使用哪些移动,那么我们的评估将更加准确,但我们仍然会遇到这样的问题,即当我们仍然关心我们的移动带来的最佳结果时,我们的 MCTS 将评估平均值。这就是引入强化学习以改进我们的代理的地方。
强化学习快速回顾
我们在第一章中首次遇到强化学习,机器学习 - 简介,当我们研究了三种不同类型的学习过程时:监督,无监督和强化。在强化学习中,代理在环境中接收奖励。例如,代理可能是迷宫中的老鼠,奖励可能是迷宫中的某些食物。强化学习有时会感觉有点像监督循环网络问题。网络获得一系列数据并必须学会响应。
区分任务成为强化学习问题的关键区别是,代理给出的响应会改变它在未来时间步中接收的数据。如果老鼠在迷宫的一个T交叉口向左转而不是向右转,那么它将改变其下一个状态。相比之下,监督循环网络只是预测一系列。它们所做的预测不会影响系列中的未来值。
AlphaGo 网络已经通过监督训练,但现在问题可以重塑为一个强化学习任务,以进一步改进代理。对于 AlphaGo,创建了一个新的网络,该网络与监督网络共享结构和权重。然后,使用强化学习继续其训练,并专门使用称为政策梯度的方法。
用于学习策略函数的政策梯度
政策梯度旨在解决的问题是强化学习问题的更一般版本,即如何在任务上使用反向传播,该任务没有梯度,从奖励到我们参数的输出。为了给出更具体的例子,我们有一个神经网络,它产生采取动作a的概率,给定状态s和一些参数?,这些参数是我们神经网络的权重:
我们还有我们的奖励信号R。行动影响我们采取的奖励信号,但它们与参数之间没有梯度。没有方程式可以插入R;它只是我们从环境中响应a而获得的值。
然而,鉴于我们知道我们选择的a和R之间存在链接,有几件事情我们可以尝试。我们可以从高斯分布中创建一个?的值范围并在环境中运行它们。然后我们可以选择最成功的一部分,并获取它们的平均值和方差。然后,我们使用新的均值和方差在我们的高斯分布中创建一个新的?种群。我们可以反复执行此过程,直到在R中不再看到改进,然后将我们的最终均值作为参数的最佳选择。这种方法被称为交叉熵方法。
尽管它可能非常成功,但它是一种爬山法,不能很好地探索可能性空间。它很容易陷入局部最优解,这在强化学习中非常常见。此外,它仍然没有利用梯度信息。
要使用梯度,我们可以利用* a 和 R 之间虽然没有数学关系,但存在概率关系的事实。在某个 s 中采取特定的 a 往往会比其他 R 获得更多的 R 。我们可以将获得 R 的?对 R *的梯度的问题写成如下形式:
在这里,r [t] 是时间步骤 t 的奖励。这可以重新排列成:
如果我们乘以并除以,我们有以下结果:
使用事实并简化为以下形式:
这实际上是如果我们使参数沿着每个时间步骤的奖励梯度的对数方向推动,我们倾向于向所有时间步骤的奖励梯度移动。要在 Python 中实现这一点,我们需要执行以下步骤:
-
创建一个输出是在给定输入状态下采取不同动作的概率的神经网络。根据先前的方程,它将表示。
-
在我们的代理在其环境中运行的批次中运行若干个训练。根据网络输出的概率分布随机选择其动作。在每个时间步骤,记录输入状态、收到的奖励和实际采取的动作。
-
在每个训练的最后,使用从该点开始的该训练中的奖励总和为每一步分配奖励。在围棋等游戏中,这将只是一个表示最终结果的 1、0 或-1 应用于每一步的值。这将代表方程中的r [t]。对于更动态的游戏,可以使用折扣奖励;折扣奖励将在下一章中详细解释。
-
一旦在我们的情节中存储了一组数量的状态,我们就会通过更新我们的网络参数来训练它们,更新依据是网络输出的对数乘以实际的移动,乘以奖励。这被用作我们神经网络的损失函数。我们对每个时间步执行这一操作,作为单批次更新。
-
然后从步骤 2 开始重复执行,直到达到停止点,要么在一定的迭代次数内,要么在环境内得到一定的分数。
此循环的效果是,如果一个动作与正向奖励相关联,我们会增加导致该状态下此动作的参数。如果奖励是负向的,我们会减少导致该动作的参数。需要注意的是,为了使其工作,我们需要具有一些负值的奖励;否则,随着时间的推移,所有动作都会被简单地提升。如果这种情况没有自然发生,最好的选择是在每个批次中对我们的奖励进行归一化。
已经证明政策梯度方法在学习一系列复杂任务时取得了成功,尽管训练速度可能会非常缓慢,并且对学习速率非常敏感。学习速率过高时,行为将会发生剧烈震荡,永远无法保持稳定,以至于无法学到有意义的东西。学习速率过低时,它永远无法收敛。这就是为什么在下面的示例中,我们使用 RMSProp 作为优化器。标准的梯度下降法带有固定学习率通常会不成功。另外,尽管这里展示的例子是针对棋盘游戏的,但政策梯度在学习更具动态性的游戏,如乒乓球,也表现得非常出色。
现在让我们为井字游戏的play_game
方法创建player_func
;它使用政策梯度来学习最佳策略。我们将建立一个神经网络,以棋盘的九个方格作为输入。数字 1 代表玩家的标记,-1 代表对手的标记,0 代表未标记的方格。在这里,网络将设置为三个隐藏层,每个隐藏层有 100 个隐藏节点和 relu 激活函数。输出层还将包含九个节点,每个代表一个方格。因为我们希望最终的输出是移动是最佳移动的概率,我们希望最后一层的所有节点的输出总和为 1。这意味着使用 softmax 激活函数是一个自然的选择。softmax 激活函数如下:
在这里,x 和 y 是具有相同维数的向量。
这是在 TensorFlow 中创建网络的代码。完整的代码也可以在 GitHub 仓库中的policy_gradients.py
中找到。
import numpy as np
import tensorflow as tfHIDDEN_NODES = (100, 100, 100)
INPUT_NODES = 3 * 3
LEARN_RATE = 1e-4
OUTPUT_NODES = INPUT_NODES
首先,我们导入 NumPy 和 TensorFlow,它将用于网络,并创建一些常量变量,稍后将使用它们。3 * 3 输入节点是棋盘的大小:
input_placeholder = tf.placeholder("float", shape=(None, INPUT_NODES))
input_placeholder
变量是神经网络的输入占位符。在 TensorFlow 中,占位符对象用于向网络提供所有值。在运行网络时,它将设置为游戏的board_state
。此外,input_placeholder
的第一个维度是None
。这是因为在训练时,使用小批量训练会更快。None
将在训练时调整为我们的样本小批量的大小:
hidden_weights_1 = tf.Variable(tf.truncated_normal((INPUT_NODES, HIDDEN_NODES[0]), stddev=1\. / np.sqrt(INPUT_NODES)))
hidden_weights_2 = tf.Variable(
tf.truncated_normal((HIDDEN_NODES[0], HIDDEN_NODES[1]), stddev=1\. / np.sqrt(HIDDEN_NODES[0])))
hidden_weights_3 = tf.Variable(
tf.truncated_normal((HIDDEN_NODES[1], HIDDEN_NODES[2]), stddev=1\. / np.sqrt(HIDDEN_NODES[1])))
output_weights = tf.Variable(tf.truncated_normal((HIDDEN_NODES[-1], OUTPUT_NODES), stddev=1\. / np.sqrt(OUTPUT_NODES)))
在这里,我们创建我们网络三层所需的权重。它们都将使用随机的 Xavier 初始化创建;在本章中会更详细讲解:
hidden_layer_1 = tf.nn.relu(tf.matmul(input_placeholder, hidden_weights_1) +tf.Variable(tf.constant(0.01, shape=(HIDDEN_NODES[0],))))
创建第一个隐藏层,我们的hidden_weights_1
2 维张量,并将其与input_placeholder
进行矩阵相乘。然后添加偏差变量tf.Variable(tf.constant(0.01, shape=(HIDDEN_NODES[0],)))
,这可以使网络在学习模式中具有更大的灵活性。然后通过 relu 激活函数处理输出:tf.nn.relu
。这就是我们在 TensorFlow 中写神经网络层的基本方程。另一点需要注意的是 0.01。使用relu
函数时,添加一小部分正偏差是一个好的实践。这是因为 relu 函数是最大值并且为 0。这意味着值小于 0 将没有梯度,所以在学习过程中不会被调整。如果节点激活始终小于零,那么因为权重初始化不佳的不幸,这将被视为一个死节点,并且永远不会对网络产生影响,并且只会浪费 GPU/CPU 周期。一小部分正偏差极大地减少了网络中完全死节点的机会:
hidden_layer_2 = tf.nn.relu(
tf.matmul(hidden_layer_1, hidden_weights_2) +
tf.Variable(tf.truncated_normal((HIDDEN_NODES[1],), stddev=0.001)))
hidden_layer_3 = tf.nn.relu(
tf.matmul(hidden_layer_2, hidden_weights_3) + tf.Variable(tf.truncated_normal((HIDDEN_NODES[2],), stddev=0.001)))
output_layer = tf.nn.softmax(tf.matmul(hidden_layer_3, output_weights) + tf.Variable(tf.truncated_normal((OUTPUT_NODES,), stddev=0.001)))
接下来的几层以相同的方式创建:
reward_placeholder = tf.placeholder("float", shape=(None,))
actual_move_placeholder = tf.placeholder("float", shape=(None, OUTPUT_NODES))
对于loss
函数,我们需要两个额外的占位符。其中一个用于表示我们从环境中获得的奖励,即井字棋游戏的结果。另一个用于表示每个时间步我们将要采取的实际动作。请记住,我们将根据网络输出的随机策略选择我们的动作。当我们调整参数时,我们需要知道我们实际采取的动作,这样我们就可以根据奖励的正负移动参数的方向:
policy_gradient = tf.reduce_sum(tf.reshape(reward_placeholder, (-1, 1)) *
actual_move_placeholder * output_layer)train_step = tf.train.RMSPropOptimizer(LEARN_RATE).minimize(-policy_gradient)
当激活actual_move_placeholder
时,它将是一个独热向量,例如,[0, 0, 0, 0, 1, 0, 0, 0, 0]
,其中 1 表示实际移动所在的方格。这将作为一个掩码应用到output_layer
上,以便只调整该移动的梯度。到达第一个方格的成功与失败对第二个方格的成功与失败没有影响。将它与reward_placeholder
相乘,可以告诉我们是否要增加导致这个动作的权重还是减少权重。然后将policy_gradient
输入我们的优化器;我们想要最大化我们的奖励,这意味着最小化其的倒数。
最后一点是我们在这里使用了 RMSPropOptimizer
。如前所述,策略梯度对使用的学习率和类型非常敏感。已经证明 RMSProp
效果很好。
在 TensorFlow 中,变量也需要在会话中初始化;然后会话将用于运行我们的计算:
sess = tf.Session()
sess.run(tf.initialize_all_variables())
现在我们需要一个方法来运行我们的网络以选择要传递给之前创建的 play_game
方法的动作:
board_states, actual_moves, rewards = [], [], []def make_move(board_state):board_state_flat = np.ravel(board_state)board_states.append(board_state_flat)probability_of_actions = sess.run(output_layer, feed_dict={input_placeholder: [board_state_flat]})[0]
在 make_move
方法中,我们做了一些不同的事情。首先,我们将 board_state
展开,它起初是一个我们需要用作网络输入的一维数组中的第二个数组。然后,我们将该状态附加到我们的 board_states
列表中,以便稍后在我们拿到该场景奖励后用于训练。然后,我们使用 TensorFlow 会话运行网络: probability_of_actions
。现在会有一个包含九个数字的数组,它们将加起来等于一;这些数字是网络将学习将每个动作设置为当前最有利的概率的数字:
try:move = np.random.multinomial(1, probability_of_actions)
except ValueError:move = np.random.multinomial(1, probability_of_actions / (sum(probability_of_actions) + 1e-7))
现在我们使用 probability_of_actions
作为多项式分布的输入。np.random.multinomial
返回你传递给它的分布的一系列值。因为我们为第一个参数给了 1,所以只会生成一个值;这就是我们将要做出的移动。围绕 multinomial 调用的 try…catch
的存在是因为由于小的舍入误差,probability_of_actions
有时会加起来大于 1。这大约每 10,000 次调用会发生一次,因此我们将pythonic;如果失败了,只需通过一些小的 epsilon 调整它,然后再试一次:
actual_moves.append(move)move_index = move.argmax()return move_index / 3, move_index % 3
make_move
方法的最后一部分是我们需要在训练后存储我们实际使用的移动。然后将移动返回到我们的井字游戏期望的格式中,即作为两个整数的元组:一个是 x 位置,一个是 y 位置。
在训练之前的最后一步是,一旦我们有了一个完整的批次进行训练,我们需要对批次中的奖励进行归一化。这样做有几个优点。首先,在早期训练时,当几乎所有游戏都输了或赢了,我们希望鼓励网络朝着更好的示例迈进。归一化将使我们能够对罕见、更重要的示例施加额外的权重。此外,批量归一化倾向于加速训练,因为它减少了目标的方差:
BATCH_SIZE = 100
episode_number = 1
我们为我们的 BATCH_SIZE
定义了一个大小常量。这定义了我们用于训练的小批量中有多少示例。许多不同的值都可以很好地工作;100 就是其中之一。episode_number
将跟踪我们已经完成了多少个游戏循环。这将追踪我们何时需要启动小批量训练:
while True:reward = play_game(make_move, random_player)
while True
将我们置于主循环中。我们需要在这里迈出的第一步是运行一场比赛,使用我们在本章前面使用过的老朋友play_game
方法。为了简单起见,我们将始终让策略梯度玩家以make_move
方法作为第一玩家,以random_player
作为第二玩家。更改顺序也不难:
last_game_length = len(board_states) - len(rewards)# we scale here reward /= float(last_game_length)rewards += ([reward] * last_game_length)
获取我们刚刚玩的游戏的长度,并将我们收到的奖励附加到rewards
数组中,这样每个棋盘状态都可以得到我们收到的相同的最终奖励。实际上,有些移动可能对最终奖励产生了更大或更小的影响,但我们在这里无法知道。我们希望通过训练,随着类似的好状态更频繁地出现,并且有正向奖励,网络会随着时间学会这一点。我们还通过last_game_length
来缩放奖励,所以快速获胜比慢速获胜更好,慢速失败比快速失败更好。另一个需要注意的是,如果我们运行的游戏奖励分布更不均匀——比如 Pong,大部分帧都没有奖励,只有偶尔才有——这就是我们可能会在剧集的时间步上应用未来的折现的地方:
episode_number += 1if episode_number % BATCH_SIZE == 0:normalized_rewards = rewards - np.mean(rewards)normalized_rewards /= np.std(normalized_rewards)sess.run(train_step, feed_dict={input_placeholder: board_states, reward_placeholder: normalized_rewards, actual_move_placeholder: actual_moves})
增加episode_number
,如果我们有一个BATCH_SIZE
数量的样本,就进入训练代码。我们首先对我们的奖励进行批量归一化。这并不总是必需的,但几乎总是值得推荐的,因为它有很多好处。它倾向于通过减少训练中的差异来改善训练时间。如果我们的所有奖励都是正数/负数,这将解决问题,而无需您再考虑。最后,通过在 TensorFlow 会话对象上运行train_step
操作来启动训练:
del board_states[:]del actual_moves[:]del rewards[:]
最后,清空当前的小批量以为下一个小批量让路。现在让我们看看策略梯度的表现如何:
可以看到,最终它实现了尊重的 85%的获胜率。随着更多的时间和超参数的调整,它可能会做得更好。另外,请注意这一点,这说明了只选择有效移动的随机玩家的获胜率超过 50%的原因。这是因为在这里,被观察到的玩家总是先行动的。
AlphaGo 中的策略梯度
对于使用策略梯度的 AlphaGo,设置网络来与自己对弈。它每一步的奖励都是 0,直到最后一步游戏结束,游戏要么赢要么输,给出 1 或-1 的奖励。然后,将这个最终奖励应用到网络的每一步,并使用策略梯度训练网络,方式与我们的井字棋示例相同。为了防止过拟合,游戏是对抗随机选择的先前版本的网络进行的。如果网络不断地与自己对弈,风险是它可能会得出一些非常特定的策略,这些策略不适用于各种各样的对手,可以说是一种局部最小值。
构建最初的监督学习网络,以预测人类玩家最可能的走法,使得 AlphaGo 能够大幅减少在 MCTS 中需要执行的搜索范围。这使得他们可以更准确地评估每次模拟。问题在于运行一个大型多层神经网络非常慢,而不是只选择一个随机动作。在我们的蒙特卡洛模拟中,我们平均需要选择 100 步,而我们希望在数十万次模拟中完成这一过程以评估一个位置。以这种方式使用网络是不切实际的。我们需要找到一种方法来减少计算时间。
如果我们使用网络选出的最佳走法,而不是手动选择一个走法,那么我们的网络就是确定性的。给定棋盘上的一个位置,棋盘达到的结果也将是确定性的。当使用网络的最佳走法进行评估时,位置要么是白方或黑方的胜利,要么是平局。这个结果是在网络的最优策略下的位置值。因为结果是确定性的,所以我们可以训练一个新的深度神经网络来学习这个位置的值。如果表现良好,就可以仅通过神经网络的一次通行来准确评估一个位置,而不是每一步都要进行一次。
最终,使用与前面网络相同的结构创建了一个监督网络,不同之处在于最终的输出不再是整个棋盘上行动的概率,而是一个表示游戏预期结果的单个节点:白方赢、黑方赢或平局。
该网络的损失函数是其输出与强化学习网络实现的结果之间的均方误差。经过训练后发现,价值网络在训练集和测试集上的均方误差仅为 0.226 和 0.234。这表明它能够以很高的准确性学习结果。
总结一下,在这一点上,Alpha Go 有三种不同训练的深度神经网络:
-
SL:这是一个使用监督学习训练的网络,用于预测从棋盘位置到人类走法的概率。
-
RL:这是一个经过训练的网络,最初使用 SL 网络的权重,然后使用强化学习进一步训练,以选择给定位置的最佳移动。
-
V:这是一个再次通过监督学习训练的网络,用于学习在使用 RL 网络进行游戏时的位置的预期结果。它提供状态的值。
在与李世石进行真实比赛时,Alpha Go 使用了我们之前介绍的 MCTS-UCT 的变体。当从 MCTS 叶子节点模拟模拟时,选择的移动不是随机的,而是使用另一个更小的单层网络选择的。该网络称为快速模拟策略,并且在所有可能的移动上使用 softmax 分类器,其中输入是动作周围的 3 x 3 颜色模式和一系列手工特征,例如自由度计数。在我们的示例中,以下是一行:
move = random.choice(list(move_states.keys()))
可以用类似下面的内容替换:
probability_of_move = fast_rollout_policy.run(board_state)
move = np.random.binomial(1, probability_of_move)
这个小型网络用于运行蒙特卡罗模拟。SL 网络几乎肯定会更好,但速度会过慢。
在评估从叶子节点进行蒙特卡罗模拟的成功值时,分数是使用快速模拟策略的结果和由 V 网络给出的分数的组合来确定的。使用混合参数?来确定这些的相对权重:
这里,s是叶子的状态,f是使用快速模拟策略进行模拟的结果。在尝试了各种值的?之后,发现 0.5 产生了最佳结果,表明这两种评估方法是互补的。
李世石与 Alpha Go 之间的五局比赛于 2016 年 3 月 9 日开始,赛场上有大量观众,胜者将获得 100 万美元的奖金。李世石在备战中非常自信,宣称:“我听说谷歌 DeepMind 的人工智能异常强大且日益强大,但我确信我至少可以这次赢。”遗憾的是,Alpha Go 继续赢得了前三局,每局迫使李世石投降。在这一点上,竞争已经决定,他赢得了第四局,但输掉了第五局,比赛结果为 4-1。
这是人工智能方面的重大进步,标志着人工智能首次在如此复杂的游戏中几乎与顶级人类玩家相媲美。这引发了各种问题,比如在哪些其他领域可能会开发出能够超越最优秀人类的人工智能。比赛对人类的完全意义尚待观察。
摘要
在本章中,我们涵盖了很多内容,并查看了很多 Python 代码。我们简单讨论了离散状态和零和博弈的理论。我们展示了如何使用 Min-max 来评估位置的最佳移动。我们还展示了评估函数如何允许 Min-max 在可能的移动和位置状态空间过大的游戏中运行。
对于没有好的评估函数的游戏,我们展示了如何使用蒙特卡洛树搜索来评估位置,以及如何使用带有置信上界的蒙特卡洛树搜索来让 MCTS 的性能接近 Min-max。这使我们了解了 UCB1 算法。除了允许我们计算 MCTS-UCT 外,它还是一种在不同结果中选择的通用方法。
我们还研究了如何将强化学习与这些方法相结合。我们还看到了如何使用策略梯度来训练深度网络以学习复杂的模式,并在难以评估的状态下找到优势。最后,我们看到了这些技术如何应用在 AlphaGo 中击败了当时的世界冠军。
如果您有兴趣更深入地参与深度学习的棋盘游戏,Alpha Toe 项目(github.com/DanielSlater/AlphaToe
)提供了在更广泛的游戏上运行深度学习的示例,包括在 5 x 5 棋盘上的连连看和井字棋。
尽管这些技术是为棋盘游戏引入的,但它们的应用范围更广。我们遇到的许多问题都可以被形式化,例如为送货公司优化路线、在金融市场上投资以及制定企业战略。我们只是刚刚开始探索所有的可能性。
在下一章中,我们将研究如何使用深度学习来学习电脑游戏。这将在本章的策略梯度知识基础上进行,并介绍处理动态环境的新技术。