原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow
译者:飞龙
第十四章:使用卷积神经网络进行深度计算机视觉
尽管 IBM 的 Deep Blue 超级计算机在 1996 年击败了国际象棋世界冠军加里·卡斯帕罗夫,但直到最近计算机才能可靠地执行看似微不足道的任务,比如在图片中检测小狗或识别口语。为什么这些任务对我们人类来说如此轻松?答案在于感知主要发生在我们的意识领域之外,在我们大脑中专门的视觉、听觉和其他感觉模块中。当感觉信息达到我们的意识时,它已经被赋予高级特征;例如,当你看到一张可爱小狗的图片时,你无法选择不看到小狗,不注意到它的可爱。你也无法解释如何识别一个可爱的小狗;对你来说这是显而易见的。因此,我们不能信任我们的主观经验:感知并不是微不足道的,要理解它,我们必须看看我们的感觉模块是如何工作的。
卷积神经网络(CNNs)起源于对大脑视觉皮层的研究,自上世纪 80 年代以来就被用于计算机图像识别。在过去的 10 年里,由于计算能力的增加、可用训练数据的增加,以及第十一章中介绍的用于训练深度网络的技巧,CNNs 已经成功在一些复杂的视觉任务上实现了超人类表现。它们驱动着图像搜索服务、自动驾驶汽车、自动视频分类系统等。此外,CNNs 并不局限于视觉感知:它们在许多其他任务上也取得了成功,比如语音识别和自然语言处理。然而,我们现在将专注于视觉应用。
在本章中,我们将探讨 CNNs 的起源,它们的构建模块是什么样的,以及如何使用 Keras 实现它们。然后我们将讨论一些最佳的 CNN 架构,以及其他视觉任务,包括目标检测(对图像中的多个对象进行分类并在其周围放置边界框)和语义分割(根据对象所属的类别对每个像素进行分类)。
视觉皮层的结构
David H. Hubel 和 Torsten Wiesel 在1958 年对猫进行了一系列实验¹,1959 年(以及几年后对猴子进行的实验^(3)),为视觉皮层的结构提供了关键见解(这两位作者因其工作于 1981 年获得了诺贝尔生理学或医学奖)。特别是,他们表明视觉皮层中许多神经元具有小的局部感受野,这意味着它们只对位于视觉场有限区域内的视觉刺激做出反应(见图 14-1,其中五个神经元的局部感受野由虚线圈表示)。不同神经元的感受野可能重叠,它们共同覆盖整个视觉场。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1401.png
图 14-1. 视觉皮层中的生物神经元对视觉场中称为感受野的小区域中的特定模式做出反应;随着视觉信号通过连续的大脑模块,神经元对更大感受野中的更复杂模式做出反应
此外,作者们表明,一些神经元只对水平线的图像做出反应,而另一些只对具有不同方向的线做出反应(两个神经元可能具有相同的感受野,但对不同的线方向做出反应)。他们还注意到一些神经元具有更大的感受野,它们对更复杂的模式做出反应,这些模式是低级模式的组合。这些观察结果导致了这样一个想法,即高级神经元基于相邻低级神经元的输出(在图 14-1 中,注意到每个神经元只连接到前一层附近的神经元)。这种强大的架构能够在视觉领域的任何区域检测各种复杂的模式。
这些对视觉皮层的研究启发了 1980 年引入的neocognitron,逐渐演变成我们现在称之为卷积神经网络的东西。一个重要的里程碑是 Yann LeCun 等人在 1998 年发表的一篇论文,介绍了著名的LeNet-5架构,这种架构被银行广泛用于识别支票上的手写数字。这种架构具有一些你已经了解的构建块,如全连接层和 Sigmoid 激活函数,但它还引入了两个新的构建块:卷积层和池化层。现在让我们来看看它们。
注意
为什么不简单地使用具有全连接层的深度神经网络来进行图像识别任务呢?不幸的是,尽管这对于小图像(例如 MNIST)效果很好,但对于较大的图像来说,由于需要的参数数量巨大,它会崩溃。例如,一个 100×100 像素的图像有 10,000 个像素,如果第一层只有 1,000 个神经元(这已经严重限制了传递到下一层的信息量),这意味着总共有 1 千万个连接。而这只是第一层。CNN 通过部分连接的层和权重共享来解决这个问题。
卷积层
CNN 最重要的构建块是卷积层:第一个卷积层中的神经元不与输入图像中的每个像素相连接(就像在前几章讨论的层中那样),而只与其感受野中的像素相连接(参见图 14-2)。反过来,第二个卷积层中的每个神经元只与第一层中一个小矩形内的神经元相连接。这种架构允许网络在第一个隐藏层集中于小的低级特征,然后在下一个隐藏层中将它们组合成更大的高级特征,依此类推。这种分层结构在现实世界的图像中很常见,这也是 CNN 在图像识别方面表现出色的原因之一。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1402.png
图 14-2。具有矩形局部感受野的 CNN 层
注意
到目前为止,我们看到的所有多层神经网络都由一长串神经元组成,我们必须在将输入图像馈送到神经网络之前将其展平为 1D。在 CNN 中,每一层都以 2D 表示,这使得更容易将神经元与其对应的输入匹配。
给定层中位于第i行,第j列的神经元连接到前一层中位于第i到第i + f[h] – 1 行,第j到第j + f[w] – 1 列的神经元的输出,其中f[h]和f[w]是感受野的高度和宽度(参见图 14-3)。为了使一层具有与前一层相同的高度和宽度,通常在输入周围添加零,如图中所示。这称为零填充。
还可以通过间隔感受野来将大输入层连接到一个较小的层,如图 14-4 所示。这显着降低了模型的计算复杂性。从一个感受野到下一个感受野的水平或垂直步长称为步幅。在图中,一个 5×7 的输入层(加上零填充)连接到一个 3×4 的层,使用 3×3 的感受野和步幅为 2(在这个例子中,步幅在两个方向上是相同的,但不一定要这样)。上层中位于第i行,第j列的神经元连接到前一层中位于第i×s[h]到第i×s[h]+f[h]–1 行,第j×s[w]到第j×s[w]+f[w]–1 列的神经元的输出,其中s[h]和s[w]是垂直和水平步幅。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1403.png
图 14-3。层与零填充之间的连接
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1404.png
图 14-4。使用步幅为 2 降低维度
滤波器
一个神经元的权重可以表示为一个与感受野大小相同的小图像。例如,图 14-5 显示了两组可能的权重,称为滤波器(或卷积核,或只是内核)。第一个滤波器表示为一个黑色正方形,中间有一条垂直白线(它是一个 7×7 的矩阵,除了中间列全是 1,其他都是 0);使用这些权重的神经元将忽略其感受野中的所有内容,除了中间的垂直线(因为所有输入将被乘以 0,除了中间的垂直线)。第二个滤波器是一个黑色正方形,中间有一条水平白线。使用这些权重的神经元将忽略其感受野中的所有内容,除了中间的水平线。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1405.png
图 14-5。应用两个不同的滤波器以获得两个特征图
现在,如果一个层中的所有神经元使用相同的垂直线滤波器(和相同的偏置项),并且您将输入图像输入到网络中,如图 14-5 所示(底部图像),该层将输出左上角的图像。请注意,垂直白线得到增强,而其余部分变得模糊。类似地,如果所有神经元使用相同的水平线滤波器,则会得到右上角的图像;请注意,水平白线得到增强,而其余部分被模糊化。因此,一个充满使用相同滤波器的神经元的层会输出一个特征图,突出显示激活滤波器最多的图像区域。但不用担心,您不必手动定义滤波器:相反,在训练期间,卷积层将自动学习其任务中最有用的滤波器,上面的层将学会将它们组合成更复杂的模式。
堆叠多个特征图
到目前为止,为了简单起见,我已经将每个卷积层的输出表示为一个 2D 层,但实际上,卷积层有多个滤波器(您决定有多少个),并且每个滤波器输出一个特征图,因此在 3D 中更准确地表示(请参见图 14-6)。每个特征图中的每个像素都有一个神经元,并且给定特征图中的所有神经元共享相同的参数(即相同的内核和偏置项)。不同特征图中的神经元使用不同的参数。神经元的感受野与之前描述的相同,但它跨越了前一层的所有特征图。简而言之,卷积层同时将多个可训练滤波器应用于其输入,使其能够在其输入的任何位置检测多个特征。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1406.png
图 14-6。两个具有多个滤波器(内核)的卷积层,处理具有三个颜色通道的彩色图像;每个卷积层输出一个特征图每个滤波器
注意
所有特征图中的所有神经元共享相同的参数,这显著减少了模型中的参数数量。一旦 CNN 学会在一个位置识别模式,它就可以在任何其他位置识别它。相比之下,一旦全连接的神经网络学会在一个位置识别模式,它只能在那个特定位置识别它。
输入图像也由多个子层组成:每个颜色通道一个。如第九章中所述,通常有三个:红色、绿色和蓝色(RGB)。灰度图像只有一个通道,但有些图像可能有更多通道,例如捕捉额外光频率(如红外线)的卫星图像。
具体来说,在给定卷积层l中特征图k中第i行、第j列的神经元与前一层l – 1 中位于第i × s[h]至i × s[h] + f[h] – 1 行和第j × s[w]至j × s[w] + f[w] – 1 列的神经元的输出相连,跨所有特征图(在第l – 1 层)。请注意,在同一层中,位于相同行i和列j但在不同特征图中的所有神经元与前一层中相同位置的神经元的输出相连。
方程 14-1 总结了前面的解释,用一个大数学方程表示:它展示了如何计算卷积层中给定神经元的输出。由于所有不同的索引,它看起来有点丑陋,但它的作用只是计算所有输入的加权和,再加上偏置项。
方程 14-1。计算卷积层中神经元的输出
z i,j,k = b k + ∑ u=0 f h -1 ∑ v=0 f w -1 ∑ k‘=0 f n ’ -1 x i ‘ ,j ’ ,k ‘ × w u,v,k ’ ,k with i ‘ = i × s h + u j ’ = j × s w + v
在这个方程中:
-
z[i,] [j,] [k] 是位于卷积层(第l层)特征图k中第i行、第j列的神经元的输出。
-
如前所述,s[h] 和 s[w] 是垂直和水平步幅,f[h] 和 f[w] 是感受野的高度和宽度,f[n′] 是前一层(第l – 1 层)中特征图的数量。
-
x[i′,] [j′,] [k′] 是位于第l – 1 层,第i′行、第j′列、特征图k′(或通道k′,如果前一层是输入层)的神经元的输出。
-
b[k] 是特征图k(在第l层)的偏置项。您可以将其视为微调特征图k的整体亮度的旋钮。
-
w[u,] [v,] [k′,] [k]是层l中特征图k中的任何神经元与其输入之间的连接权重,该输入位于行u、列v(相对于神经元的感受野),以及特征图k′。
让我们看看如何使用 Keras 创建和使用卷积层。
使用 Keras 实现卷积层
首先,让我们加载和预处理一些样本图像,使用 Scikit-Learn 的load_sample_image()函数和 Keras 的CenterCrop和Rescaling层(这些都是在第十三章中介绍的):
from sklearn.datasets import load_sample_images
import tensorflow as tf
images = load_sample_images()["images"]
images = tf.keras.layers.CenterCrop(height=70, width=120)(images)
images = tf.keras.layers.Rescaling(scale=1 / 255)(images)
让我们看一下images张量的形状:
>>> images.shape
TensorShape([2, 70, 120, 3])
哎呀,这是一个 4D 张量;我们以前从未见过这个!所有这些维度是什么意思?嗯,有两个样本图像,这解释了第一个维度。然后每个图像是 70×120,因为这是我们在创建CenterCrop层时指定的大小(原始图像是 427×640)。这解释了第二和第三维度。最后,每个像素在每个颜色通道上保存一个值,有三个颜色通道——红色、绿色和蓝色,这解释了最后一个维度。
现在让我们创建一个 2D 卷积层,并将这些图像输入其中,看看输出是什么。为此,Keras 提供了一个Convolution2D层,别名为Conv2D。在幕后,这个层依赖于 TensorFlow 的tf.nn.conv2d()操作。让我们创建一个具有 32 个滤波器的卷积层,每个滤波器大小为 7×7(使用kernel_size=7,相当于使用kernel_size=(7 , 7)),并将这个层应用于我们的两个图像的小批量:
conv_layer = tf.keras.layers.Conv2D(filters=32, kernel_size=7)
fmaps = conv_layer(images)
注意
当我们谈论 2D 卷积层时,“2D”指的是空间维度(高度和宽度),但正如你所看到的,该层接受 4D 输入:正如我们所看到的,另外两个维度是批量大小(第一个维度)和通道数(最后一个维度)。
现在让我们看一下输出的形状:
>>> fmaps.shape
TensorShape([2, 64, 114, 32])
输出形状与输入形状类似,有两个主要区别。首先,有 32 个通道而不是 3 个。这是因为我们设置了filters=32,所以我们得到 32 个输出特征图:在每个位置的红色、绿色和蓝色的强度代替,我们现在有每个位置的每个特征的强度。其次,高度和宽度都减小了 6 个像素。这是因为Conv2D层默认不使用任何零填充,这意味着我们在输出特征图的两侧丢失了一些像素,取决于滤波器的大小。在这种情况下,由于卷积核大小为 7,我们水平和垂直各丢失 6 个像素(即每侧 3 个像素)。
警告
默认选项令人惊讶地被命名为padding="valid",实际上意味着根本没有零填充!这个名称来自于这样一个事实,即在这种情况下,每个神经元的感受野严格位于输入内部的有效位置(不会超出边界)。这不是 Keras 的命名怪癖:每个人都使用这种奇怪的命名法。
如果我们设置padding="same",那么输入将在所有侧面填充足够的零,以确保输出特征图最终与输入具有相同大小(因此这个选项的名称):
>>> conv_layer = tf.keras.layers.Conv2D(filters=32, kernel_size=7,
... padding="same")
...
>>> fmaps = conv_layer(images)
>>> fmaps.shape
TensorShape([2, 70, 120, 32])
这两种填充选项在图 14-7 中有所说明。为简单起见,这里只显示了水平维度,但当然相同的逻辑也适用于垂直维度。
如果步幅大于 1(在任何方向上),那么输出大小将不等于输入大小,即使padding="same"。例如,如果设置strides=2(或等效地strides=(2, 2)),那么输出特征图将是 35×60:垂直和水平方向都减半。图 14-8 展示了当strides=2时会发生什么,两种填充选项都有。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1407.png
图 14-7。当strides=1时的两种填充选项
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1408.png
图 14-8。当步长大于 1 时,即使使用"same"填充(和"valid"填充可能会忽略一些输入),输出也会小得多
如果您感兴趣,这是输出大小是如何计算的:
-
当
padding="valid"时,如果输入的宽度为i[h],那么输出宽度等于(i[h] - f[h] + s[h]) / s[h],向下取整。请记住f[h]是卷积核的宽度,s[h]是水平步长。除法中的余数对应于输入图像右侧被忽略的列。同样的逻辑也可以用来计算输出高度,以及图像底部被忽略的行。 -
当
padding="same"时,输出宽度等于i[h] / s[h],向上取整。为了实现这一点,在输入图像的左右两侧填充适当数量的零列(如果可能的话,数量相等,或者在右侧多一个)。假设输出宽度为o[w],那么填充的零列数为(o[w] - 1) × s[h] + f[h] - i[h]。同样的逻辑也可以用来计算输出高度和填充行数。
现在让我们来看一下层的权重(在方程 14-1 中被标记为w[u,] [v,] [k’,] [k]和b[k])。就像Dense层一样,Conv2D层保存所有层的权重,包括卷积核和偏置。卷积核是随机初始化的,而偏置初始化为零。这些权重可以通过weights属性作为 TF 变量访问,也可以通过get_weights()方法作为 NumPy 数组访问:
>>> kernels, biases = conv_layer.get_weights()
>>> kernels.shape
(7, 7, 3, 32)
>>> biases.shape
(32,)
kernels数组是 4D 的,其形状为[kernel_height, kernel_width, input_channels, output_channels]。biases数组是 1D 的,形状为[output_channels]。输出通道的数量等于输出特征图的数量,也等于滤波器的数量。
最重要的是,需要注意输入图像的高度和宽度不会出现在卷积核的形状中:这是因为输出特征图中的所有神经元共享相同的权重,正如之前解释的那样。这意味着您可以将任何大小的图像馈送到这一层,只要它们至少与卷积核一样大,并且具有正确数量的通道(在这种情况下为三个)。
最后,通常情况下,您会希望在创建Conv2D层时指定一个激活函数(如 ReLU),并指定相应的内核初始化器(如 He 初始化)。这与Dense层的原因相同:卷积层执行线性操作,因此如果您堆叠多个卷积层而没有任何激活函数,它们都等同于单个卷积层,它们将无法学习到真正复杂的内容。
正如您所看到的,卷积层有很多超参数:filters、kernel_size、padding、strides、activation、kernel_initializer等。通常情况下,您可以使用交叉验证来找到正确的超参数值,但这是非常耗时的。我们将在本章后面讨论常见的 CNN 架构,以便让您了解在实践中哪些超参数值效果最好。
内存需求
CNN 的另一个挑战是卷积层需要大量的 RAM。这在训练过程中尤为明显,因为反向传播的反向传递需要在前向传递期间计算的所有中间值。
例如,考虑一个具有 200 个 5×5 滤波器的卷积层,步幅为 1,使用"same"填充。如果输入是一个 150×100 的 RGB 图像(三个通道),那么参数数量为(5×5×3+1)×200=15,200(+1 对应于偏置项),与全连接层相比相当小。然而,这 200 个特征图中的每一个包含 150×100 个神经元,每个神经元都需要计算其 5×5×3=75 个输入的加权和:总共有 2.25 亿次浮点乘法。虽然不像全连接层那么糟糕,但仍然相当计算密集。此外,如果使用 32 位浮点数表示特征图,那么卷积层的输出将占用 200×150×100×32=9600 万位(12 MB)的 RAM。而这只是一个实例的情况——如果一个训练批次包含 100 个实例,那么这一层将使用 1.2 GB 的 RAM!
在推断(即对新实例进行预测时),一个层占用的 RAM 可以在计算下一层后立即释放,因此你只需要两个连续层所需的 RAM。但在训练期间,前向传播期间计算的所有内容都需要保留以进行反向传播,因此所需的 RAM 量至少是所有层所需 RAM 的总量。
提示
如果由于内存不足错误而导致训练崩溃,你可以尝试减小小批量大小。或者,你可以尝试使用步幅减少维度,去掉一些层,使用 16 位浮点数代替 32 位浮点数,或者将 CNN 分布在多个设备上(你将在第十九章中看到如何做)。
现在让我们来看看 CNN 的第二个常见构建块:池化层。
池化层
一旦你理解了卷积层的工作原理,池化层就很容易理解了。它们的目标是对输入图像进行子采样(即缩小),以减少计算负载、内存使用和参数数量(从而限制过拟合的风险)。
就像在卷积层中一样,池化层中的每个神经元连接到前一层中有限数量的神经元的输出,这些神经元位于一个小的矩形感受野内。你必须像以前一样定义它的大小、步幅和填充类型。然而,池化神经元没有权重;它所做的只是使用聚合函数(如最大值或平均值)聚合输入。图 14-9 展示了最大池化层,这是最常见的池化层类型。在这个例子中,我们使用了一个 2×2 的池化核,步幅为 2,没有填充。在图 14-9 中的左下角感受野中,输入值为 1、5、3、2,因此只有最大值 5 传播到下一层。由于步幅为 2,输出图像的高度和宽度都是输入图像的一半(向下取整,因为我们没有使用填充)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1409.png
图 14-9。最大池化层(2×2 池化核,步幅 2,无填充)
注意
池化层通常独立地处理每个输入通道,因此输出深度(即通道数)与输入深度相同。
除了减少计算、内存使用和参数数量之外,最大池化层还引入了一定程度的不变性,如图 14-10 所示。在这里,我们假设亮像素的值低于暗像素的值,并考虑三个图像(A、B、C)通过一个 2×2 内核和步幅 2 的最大池化层。图像 B 和 C 与图像 A 相同,但向右移动了一个和两个像素。正如您所看到的,图像 A 和 B 的最大池化层的输出是相同的。这就是平移不变性的含义。对于图像 C,输出是不同的:向右移动一个像素(但仍然有 50%的不变性)。通过在 CNN 中的几层之间插入一个最大池化层,可以在更大的尺度上获得一定程度的平移不变性。此外,最大池化还提供了一定程度的旋转不变性和轻微的尺度不变性。这种不变性(即使有限)在预测不应该依赖这些细节的情况下可能是有用的,比如在分类任务中。
然而,最大池化也有一些缺点。显然,它非常破坏性:即使使用一个微小的 2×2 内核和步幅为 2,输出在两个方向上都会变小两倍(因此其面积会变小四倍),简单地丢弃了输入值的 75%。在某些应用中,不变性并不理想。以语义分割为例(根据像素所属的对象对图像中的每个像素进行分类的任务,我们将在本章后面探讨):显然,如果输入图像向右平移一个像素,输出也应该向右平移一个像素。在这种情况下的目标是等变性,而不是不变性:对输入的微小变化应导致输出的相应微小变化。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1410.png
图 14-10。对小平移的不变性
使用 Keras 实现池化层
以下代码创建了一个MaxPooling2D层,别名为MaxPool2D,使用一个 2×2 内核。步幅默认为内核大小,因此此层使用步幅为 2(水平和垂直)。默认情况下,它使用"valid"填充(即根本不填充):
max_pool = tf.keras.layers.MaxPool2D(pool_size=2)
要创建一个平均池化层,只需使用AveragePooling2D,别名为AvgPool2D,而不是MaxPool2D。正如您所期望的那样,它的工作方式与最大池化层完全相同,只是计算均值而不是最大值。平均池化层曾经非常流行,但现在人们大多使用最大池化层,因为它们通常表现更好。这可能看起来令人惊讶,因为计算均值通常比计算最大值丢失的信息更少。但另一方面,最大池化仅保留最强的特征,摆脱了所有无意义的特征,因此下一层得到了一个更干净的信号来处理。此外,最大池化比平均池化提供更强的平移不变性,并且需要稍少的计算。
请注意,最大池化和平均池化可以沿深度维度而不是空间维度执行,尽管这不太常见。这可以让 CNN 学习对各种特征具有不变性。例如,它可以学习多个滤波器,每个滤波器检测相同模式的不同旋转(例如手写数字;参见图 14-11),深度最大池化层将确保输出不管旋转如何都是相同的。CNN 也可以学习对任何东西具有不变性:厚度、亮度、倾斜、颜色等等。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1411.png
图 14-11。深度最大池化可以帮助 CNN 学习旋转不变性(在这种情况下)
Keras 不包括深度最大池化层,但实现一个自定义层并不太困难:
class DepthPool(tf.keras.layers.Layer):
def __init__(self, pool_size=2, **kwargs):
super().__init__(**kwargs)
self.pool_size = pool_size
def call(self, inputs):
shape = tf.shape(inputs) # shape[-1] is the number of channels
groups = shape[-1] // self.pool_size # number of channel groups
new_shape = tf.concat([shape[:-1], [groups, self.pool_size]], axis=0)
return tf.reduce_max(tf.reshape(inputs, new_shape), axis=-1)
这一层将其输入重塑为所需大小的通道组(pool_size),然后使用tf.reduce_max()来计算每个组的最大值。这种实现假定步幅等于池大小,这通常是你想要的。或者,您可以使用 TensorFlow 的tf.nn.max_pool()操作,并在Lambda层中包装以在 Keras 模型中使用它,但遗憾的是,此操作不实现 GPU 的深度池化,只实现 CPU 的深度池化。
在现代架构中经常看到的最后一种类型的池化层是全局平均池化层。它的工作方式非常不同:它只是计算每个整个特征图的平均值(就像使用与输入具有相同空间维度的池化核的平均池化层)。这意味着它只输出每个特征图和每个实例的一个数字。尽管这当然是极其破坏性的(大部分特征图中的信息都丢失了),但它可以在输出层之前非常有用,稍后您将在本章中看到。要创建这样的层,只需使用GlobalAveragePooling2D类,别名GlobalAvgPool2D:
global_avg_pool = tf.keras.layers.GlobalAvgPool2D()
这等同于以下Lambda层,它计算空间维度(高度和宽度)上的平均值:
global_avg_pool = tf.keras.layers.Lambda(
lambda X: tf.reduce_mean(X, axis=[1, 2]))
例如,如果我们将这一层应用于输入图像,我们将得到每个图像的红色、绿色和蓝色的平均强度:
>>> global_avg_pool(images)
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0.64338624, 0.5971759 , 0.5824972 ],
[0.76306933, 0.26011038, 0.10849128]], dtype=float32)>
现在您知道如何创建卷积神经网络的所有构建模块了。让我们看看如何组装它们。
CNN 架构
典型的 CNN 架构堆叠了几个卷积层(每个通常后面跟着一个 ReLU 层),然后是一个池化层,然后又是几个卷积层(+ReLU),然后是另一个池化层,依此类推。随着图像通过网络的传递,图像变得越来越小,但也通常变得越来越深(即具有更多的特征图),这要归功于卷积层(参见图 14-12)。在堆栈的顶部,添加了一个常规的前馈神经网络,由几个全连接层(+ReLUs)组成,最后一层输出预测(例如,一个 softmax 层,输出估计的类别概率)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1412.png
图 14-12. 典型的 CNN 架构
提示
一个常见的错误是使用太大的卷积核。例如,不要使用一个 5×5 的卷积层,而是堆叠两个 3×3 的卷积层:这将使用更少的参数,需要更少的计算,并且通常表现更好。一个例外是第一个卷积层:它通常可以有一个大的卷积核(例如 5×5),通常具有 2 或更大的步幅。这将减少图像的空间维度,而不会丢失太多信息,而且由于输入图像通常只有三个通道,因此成本不会太高。
这是如何实现一个基本的 CNN 来处理时尚 MNIST 数据集的(在第十章介绍):
from functools import partial
DefaultConv2D = partial(tf.keras.layers.Conv2D, kernel_size=3, padding="same",
activation="relu", kernel_initializer="he_normal")
model = tf.keras.Sequential([
DefaultConv2D(filters=64, kernel_size=7, input_shape=[28, 28, 1]),
tf.keras.layers.MaxPool2D(),
DefaultConv2D(filters=128),
DefaultConv2D(filters=128),
tf.keras.layers.MaxPool2D(),
DefaultConv2D(filters=256),
DefaultConv2D(filters=256),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(units=128, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(units=64, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(units=10, activation="softmax")
])
让我们来看一下这段代码:
-
我们使用
functools.partial()函数(在第十一章介绍)来定义DefaultConv2D,它的作用就像Conv2D,但具有不同的默认参数:一个小的 3 的内核大小,"same"填充,ReLU 激活函数,以及相应的 He 初始化器。 -
接下来,我们创建
Sequential模型。它的第一层是一个具有 64 个相当大的滤波器(7×7)的DefaultConv2D。它使用默认的步幅 1,因为输入图像不是很大。它还设置input_shape=[28, 28, 1],因为图像是 28×28 像素,具有单个颜色通道(即灰度)。当您加载时尚 MNIST 数据集时,请确保每个图像具有这种形状:您可能需要使用np.reshape()或np.expanddims()来添加通道维度。或者,您可以在模型中使用Reshape层作为第一层。 -
然后我们添加一个使用默认池大小为 2 的最大池化层,因此它将每个空间维度除以 2。
-
然后我们重复相同的结构两次:两个卷积层后面跟着一个最大池化层。对于更大的图像,我们可以多次重复这个结构。重复次数是一个可以调整的超参数。
-
请注意,随着我们向 CNN 向输出层上升,滤波器的数量会翻倍(最初为 64,然后为 128,然后为 256):这是有道理的,因为低级特征的数量通常相当低(例如,小圆圈,水平线),但有许多不同的方法可以将它们组合成更高级别的特征。在每个池化层后将滤波器数量翻倍是一种常见做法:由于池化层将每个空间维度除以 2,我们可以在下一层中加倍特征图的数量,而不用担心参数数量、内存使用或计算负载的激增。
-
接下来是全连接网络,由两个隐藏的密集层和一个密集输出层组成。由于这是一个有 10 个类别的分类任务,输出层有 10 个单元,并且使用 softmax 激活函数。请注意,我们必须在第一个密集层之前扁平化输入,因为它期望每个实例的特征是一个 1D 数组。我们还添加了两个 dropout 层,每个的 dropout 率为 50%,以减少过拟合。
如果您使用"sparse_categorical_crossentropy"损失编译此模型,并将模型拟合到 Fashion MNIST 训练集,它应该在测试集上达到超过 92%的准确率。这并不是最先进的,但是相当不错,显然比我们在第十章中使用密集网络取得的成绩要好得多。
多年来,这种基本架构的变体已经被开发出来,导致了该领域的惊人进步。这种进步的一个很好的衡量标准是在 ILSVRC(ImageNet 挑战)等比赛中的错误率。在这个比赛中,图像分类的前五错误率,即系统的前五个预测中没有包括正确答案的测试图像数量,从超过 26%下降到不到 2.3%仅仅用了六年。这些图像相当大(例如,高度为 256 像素),有 1000 个类别,其中一些非常微妙(尝试区分 120 种狗品种)。查看获胜作品的演变是了解 CNN 如何工作以及深度学习研究如何进展的好方法。
我们将首先看一下经典的 LeNet-5 架构(1998 年),然后看一下几位 ILSVRC 挑战的获胜者:AlexNet(2012),GoogLeNet(2014),ResNet(2015)和 SENet(2017)。在此过程中,我们还将看一些其他架构,包括 Xception,ResNeXt,DenseNet,MobileNet,CSPNet 和 EfficientNet。
LeNet-5
LeNet-5 架构可能是最广为人知的 CNN 架构。正如前面提到的,它是由 Yann LeCun 在 1998 年创建的,并且被广泛用于手写数字识别(MNIST)。它由表 14-1 中显示的层组成。
表 14-1. LeNet-5 架构
| 层 | 类型 | 特征图 | 尺寸 | 核大小 | 步幅 | 激活函数 |
|---|---|---|---|---|---|---|
| Out | 全连接 | – | 10 | – | – | RBF |
| F6 | 全连接 | – | 84 | – | – | tanh |
| C5 | 卷积 | 120 | 1 × 1 | 5 × 5 | 1 | tanh |
| S4 | 平均池化 | 16 | 5 × 5 | 2 × 2 | 2 | tanh |
| C3 | 卷积 | 16 | 10 × 10 | 5 × 5 | 1 | tanh |
| S2 | 平均池化 | 6 | 14 × 14 | 2 × 2 | 2 | tanh |
| C1 | 卷积 | 6 | 28 × 28 | 5 × 5 | 1 | tanh |
| In | 输入 | 1 | 32 × 32 | – | – | – |
正如您所看到的,这看起来与我们的时尚 MNIST 模型非常相似:一堆卷积层和池化层,然后是一个密集网络。也许与更现代的分类 CNN 相比,主要的区别在于激活函数:今天,我们会使用 ReLU 而不是 tanh,使用 softmax 而不是 RBF。还有一些其他不太重要的差异,但如果您感兴趣,可以在本章的笔记本中找到https://homl.info/colab3。Yann LeCun 的网站还展示了 LeNet-5 对数字进行分类的精彩演示。
AlexNet
AlexNet CNN 架构¹¹在 2012 年 ILSVRC 挑战赛中大幅领先:它实现了 17%的前五错误率,而第二名竞争对手仅实现了 26%!AlexaNet 由 Alex Krizhevsky(因此得名)、Ilya Sutskever 和 Geoffrey Hinton 开发。它类似于 LeNet-5,只是更大更深,它是第一个直接将卷积层堆叠在一起的模型,而不是将池化层堆叠在每个卷积层之上。表 14-2 展示了这种架构。
表 14-2. AlexNet 架构
| 层 | 类型 | 特征图 | 大小 | 核大小 | 步幅 | 填充 | 激活函数 |
|---|---|---|---|---|---|---|---|
| Out | 全连接 | – | 1,000 | – | – | – | Softmax |
| F10 | 全连接 | – | 4,096 | – | – | – | ReLU |
| F9 | 全连接 | – | 4,096 | – | – | – | ReLU |
| S8 | 最大池化 | 256 | 6 × 6 | 3 × 3 | 2 | valid | – |
| C7 | 卷积 | 256 | 13 × 13 | 3 × 3 | 1 | same | ReLU |
| C6 | 卷积 | 384 | 13 × 13 | 3 × 3 | 1 | same | ReLU |
| C5 | 卷积 | 384 | 13 × 13 | 3 × 3 | 1 | same | ReLU |
| S4 | 最大池化 | 256 | 13 × 13 | 3 × 3 | 2 | valid | – |
| C3 | 卷积 | 256 | 27 × 27 | 5 × 5 | 1 | same | ReLU |
| S2 | 最大池化 | 96 | 27 × 27 | 3 × 3 | 2 | valid | – |
| C1 | 卷积 | 96 | 55 × 55 | 11 × 11 | 4 | valid | ReLU |
| In | 输入 | 3(RGB) | 227 × 227 | – | – | – | – |
为了减少过拟合,作者使用了两种正则化技术。首先,他们在训练期间对 F9 和 F10 层的输出应用了 50%的 dropout 率的 dropout(在第十一章中介绍)。其次,他们通过随机移动训练图像的各种偏移量、水平翻转它们和改变光照条件来执行数据增强。
AlexNet 还在 C1 和 C3 层的 ReLU 步骤之后立即使用了一个竞争性归一化步骤,称为局部响应归一化(LRN):最强烈激活的神经元抑制了位于相邻特征图中相同位置的其他神经元。这种竞争性激活已经在生物神经元中观察到。这鼓励不同的特征图专门化,将它们分开并迫使它们探索更广泛的特征,最终提高泛化能力。方程 14-2 展示了如何应用 LRN。
方程 14-2. 局部响应归一化(LRN)
b i = a i k+α∑ j=j low j high a j 2 -β with j high = min i + r 2 , f n - 1 j low = max 0 , i - r 2
在这个方程中:
-
b[i] 是位于特征图i中的神经元的归一化输出,在某一行u和列v(请注意,在这个方程中,我们只考虑位于这一行和列的神经元,因此u和v没有显示)。
-
a[i] 是 ReLU 步骤后,但规范化之前的神经元的激活。
-
k、α、β和r是超参数。k称为偏置,r称为深度半径。
-
f[n] 是特征图的数量。
例如,如果r = 2,并且一个神经元具有强烈的激活,则它将抑制位于其上下特征图中的神经元的激活。
在 AlexNet 中,超参数设置为:r = 5,α = 0.0001,β = 0.75,k = 2。您可以使用tf.nn.local_response_normalization()函数来实现这一步骤(如果要在 Keras 模型中使用它,可以将其包装在Lambda层中)。
由 Matthew Zeiler 和 Rob Fergus 开发的 AlexNet 的一个变体称为ZF Net¹²,并赢得了 2013 年 ILSVRC 挑战赛。它本质上是 AlexNet,只是调整了一些超参数(特征图数量、卷积核大小、步幅等)。
GoogLeNet
GoogLeNet 架构由 Google Research 的 Christian Szegedy 等人开发,¹³,并通过将前五错误率降低到 7%以下赢得了 ILSVRC 2014 挑战。这一出色的性能在很大程度上来自于该网络比以前的 CNN 更深(如您将在图 14-15 中看到的)。这得益于称为inception 模块的子网络,¹⁴,它允许 GoogLeNet 比以前的架构更有效地使用参数:实际上,GoogLeNet 的参数比 AlexNet 少 10 倍(大约 600 万个而不是 6000 万个)。
图 14-14 显示了 Inception 模块的架构。符号“3×3 + 1(S)”表示该层使用 3×3 内核,步幅 1 和"same"填充。输入信号首先并行输入到四个不同的层中。所有卷积层使用 ReLU 激活函数。请注意,顶部卷积层使用不同的内核大小(1×1、3×3 和 5×5),使它们能够捕获不同尺度的模式。还要注意,每个单独的层都使用步幅 1 和"same"填充(即使是最大池化层),因此它们的输出与它们的输入具有相同的高度和宽度。这使得可以在最终的深度连接层(即将来自所有四个顶部卷积层的特征图堆叠在一起)中沿深度维度连接所有输出。可以使用 Keras 的Concatenate层来实现,使用默认的axis=-1。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1414.png
图 14-14。Inception 模块
您可能会想知道为什么 Inception 模块具有具有 1×1 内核的卷积层。毕竟,这些层不能捕获任何特征,因为它们一次只查看一个像素,对吧?实际上,这些层有三个目的:
-
尽管它们不能捕获空间模式,但它们可以捕获沿深度维度(即跨通道)的模式。
-
它们被配置为输出比它们的输入更少的特征图,因此它们充当瓶颈层,意味着它们降低了维度。这降低了计算成本和参数数量,加快了训练速度并提高了泛化能力。
-
每对卷积层([1×1、3×3]和[1×1、5×5])就像一个强大的卷积层,能够捕获更复杂的模式。卷积层等效于在图像上扫过一个密集层(在每个位置,它只查看一个小的感受野),而这些卷积层对等于在图像上扫过两层神经网络。
简而言之,您可以将整个 Inception 模块视为一个超级卷积层,能够输出捕获各种尺度复杂模式的特征图。
现在让我们来看看 GoogLeNet CNN 的架构(参见图 14-15)。每个卷积层和每个池化层输出的特征图数量在内核大小之前显示。该架构非常深,以至于必须用三列来表示,但实际上 GoogLeNet 是一个高高的堆叠,包括九个 Inception 模块(带有旋转顶部的方框)。Inception 模块中的六个数字表示模块中每个卷积层输出的特征图数量(与图 14-14 中的顺序相同)。请注意,所有卷积层都使用 ReLU 激活函数。
让我们来看看这个网络:
-
前两层将图像的高度和宽度分别除以 4(因此其面积除以 16),以减少计算负载。第一层使用大的内核大小,7×7,以便保留大部分信息。
-
然后,本地响应归一化层确保前面的层学习到各种各样的特征(如前面讨论的)。
-
接下来是两个卷积层,其中第一个充当瓶颈层。正如前面提到的,您可以将这一对看作一个更聪明的单个卷积层。
-
再次,本地响应归一化层确保前面的层捕获各种各样的模式。
-
接下来,一个最大池化层将图像的高度和宽度减少了一半,以加快计算速度。
-
然后是 CNN 的骨干:一个高高的堆叠,包括九个 Inception 模块,交替使用一对最大池化层来降低维度并加快网络速度。
-
接下来,全局平均池化层输出每个特征图的平均值:这会丢弃任何剩余的空间信息,这没关系,因为在那一点上剩下的空间信息并不多。事实上,GoogLeNet 的输入图像通常期望为 224×224 像素,因此经过 5 个最大池化层后,每个将高度和宽度除以 2,特征图缩小到 7×7。此外,这是一个分类任务,而不是定位任务,因此物体在哪里并不重要。由于这一层带来的降维,不需要在 CNN 的顶部有几个全连接层(就像在 AlexNet 中那样),这大大减少了网络中的参数数量,并限制了过拟合的风险。
-
最后几层很容易理解:用于正则化的 dropout,然后是一个具有 1,000 个单元的全连接层(因为有 1,000 个类别),以及一个 softmax 激活函数来输出估计的类别概率。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1415.png
图 14-15。GoogLeNet 架构
原始的 GoogLeNet 架构包括两个辅助分类器,插在第三和第六个 inception 模块的顶部。它们都由一个平均池化层、一个卷积层、两个全连接层和一个 softmax 激活层组成。在训练过程中,它们的损失(缩小了 70%)被添加到整体损失中。目标是解决梯度消失问题并对网络进行正则化,但后来证明它们的效果相对较小。
后来,Google 的研究人员提出了 GoogLeNet 架构的几个变体,包括 Inception-v3 和 Inception-v4,使用略有不同的 inception 模块以实现更好的性能。
VGGNet
在 ILSVRC 2014 挑战赛中的亚军是VGGNet,Karen Simonyan 和 Andrew Zisserman,来自牛津大学视觉几何组(VGG)研究实验室,开发了一个非常简单和经典的架构;它有 2 或 3 个卷积层和一个池化层,然后再有 2 或 3 个卷积层和一个池化层,依此类推(达到 16 或 19 个卷积层,取决于 VGG 的变体),再加上一个最终的具有 2 个隐藏层和输出层的密集网络。它使用小的 3×3 滤波器,但数量很多。
ResNet
Kaiming He 等人在 ILSVRC 2015 挑战赛中使用Residual Network (ResNet)赢得了冠军,其前五错误率令人惊叹地低于 3.6%。获胜的变体使用了一个由 152 层组成的极深 CNN(其他变体有 34、50 和 101 层)。它证实了一个普遍趋势:计算机视觉模型变得越来越深,参数越来越少。能够训练如此深的网络的关键是使用跳跃连接(也称为快捷连接):输入到一个层的信号也被添加到堆栈中更高的层的输出中。让我们看看为什么这很有用。
在训练神经网络时,目标是使其模拟目标函数h(x)。如果将输入x添加到网络的输出中(即添加一个跳跃连接),那么网络将被迫模拟f(x) = h(x) - x而不是h(x)。这被称为残差学习。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1416.png
图 14-16。残差学习
当初始化一个常规的神经网络时,它的权重接近于零,因此网络只会输出接近于零的值。如果添加一个跳跃连接,结果网络将只输出其输入的副本;换句话说,它最初模拟的是恒等函数。如果目标函数与恒等函数相当接近(这通常是情况),这将大大加快训练速度。
此外,如果添加许多跳跃连接,即使有几个层尚未开始学习,网络也可以开始取得进展(参见图 14-17)。由于跳跃连接,信号可以轻松地在整个网络中传播。深度残差网络可以看作是一堆残差单元(RUs),其中每个残差单元是一个带有跳跃连接的小型神经网络。
现在让我们看一下 ResNet 的架构(参见图 14-18)。它非常简单。它的开头和结尾与 GoogLeNet 完全相同(除了没有丢弃层),中间只是一个非常深的残差单元堆栈。每个残差单元由两个卷积层组成(没有池化层!),使用 3×3 的卷积核和保持空间维度(步幅 1,"same"填充)的批量归一化(BN)和 ReLU 激活。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1417.png
图 14-17。常规深度神经网络(左)和深度残差网络(右)
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1418.png
图 14-18。ResNet 架构
请注意,每隔几个残差单元,特征图的数量会加倍,同时它们的高度和宽度会减半(使用步幅为 2 的卷积层)。当这种情况发生时,输入不能直接添加到残差单元的输出中,因为它们的形状不同(例如,这个问题影响了由虚线箭头表示的跳跃连接在图 14-18 中的情况)。为了解决这个问题,输入通过一个步幅为 2 的 1×1 卷积层,并具有正确数量的输出特征图(参见图 14-19)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1419.png
图 14-19。更改特征图大小和深度时的跳跃连接
存在不同变体的架构,具有不同数量的层。ResNet-34 是一个具有 34 层的 ResNet(仅计算卷积层和全连接层),包含 3 个输出 64 个特征图的 RU,4 个输出 128 个特征图的 RU,6 个输出 256 个特征图的 RU,以及 3 个输出 512 个特征图的 RU。我们将在本章后面实现这个架构。
注意
Google 的Inception-v4¹⁸架构融合了 GoogLeNet 和 ResNet 的思想,并在 ImageNet 分类中实现了接近 3%的前五错误率。
比 ResNet-152 更深的 ResNet,例如 ResNet-152,使用略有不同的残差单元。它们不是使用两个具有 256 个特征图的 3×3 卷积层,而是使用三个卷积层:首先是一个只有 64 个特征图的 1×1 卷积层(少了 4 倍),它充当瓶颈层(如前所述),然后是一个具有 64 个特征图的 3×3 层,最后是另一个具有 256 个特征图的 1×1 卷积层(4 倍 64),恢复原始深度。ResNet-152 包含 3 个输出 256 个映射的这样的 RU,然后是 8 个输出 512 个映射的 RU,一个令人惊叹的 36 个输出 1024 个映射的 RU,最后是 3 个输出 2048 个映射的 RU。
Xception
值得注意的是 GoogLeNet 架构的另一个变种:Xception(代表Extreme Inception)由 Keras 的作者 François Chollet 于 2016 年提出,并在一个庞大的视觉任务(3.5 亿张图片和 1.7 万个类别)上明显优于 Inception-v3。就像 Inception-v4 一样,它融合了 GoogLeNet 和 ResNet 的思想,但是用一个特殊类型的层称为深度可分离卷积层(或简称可分离卷积层)替换了 inception 模块。这些层在一些 CNN 架构中之前已经被使用过,但在 Xception 架构中并不像现在这样核心。常规卷积层使用滤波器,试图同时捕捉空间模式(例如,椭圆)和跨通道模式(例如,嘴+鼻子+眼睛=脸),而可分离卷积层则做出了空间模式和跨通道模式可以分别建模的强烈假设(见图 14-20)。因此,它由两部分组成:第一部分对每个输入特征图应用一个单一的空间滤波器,然后第二部分专门寻找跨通道模式——这只是一个具有 1×1 滤波器的常规卷积层。
由于可分离卷积层每个输入通道只有一个空间滤波器,所以应避免在通道较少的层之后使用它们,比如输入层(尽管图 14-20 中是这样的,但那只是为了说明目的)。因此,Xception 架构以 2 个常规卷积层开始,然后剩下的架构只使用可分离卷积(总共 34 个),再加上一些最大池化层和通常的最终层(一个全局平均池化层和一个密集输出层)。
你可能会想为什么 Xception 被认为是 GoogLeNet 的一个变种,因为它根本不包含任何 inception 模块。嗯,正如之前讨论的那样,一个 inception 模块包含有 1×1 滤波器的卷积层:这些滤波器专门寻找跨通道模式。然而,位于它们之上的卷积层是常规卷积层,既寻找空间模式又寻找跨通道模式。因此,你可以将一个 inception 模块看作是一个常规卷积层(同时考虑空间模式和跨通道模式)和一个可分离卷积层(分别考虑它们)之间的中间层。实际上,可分离卷积层通常表现更好。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1420.png
图 14-20。深度可分离卷积层
提示
可分离卷积层使用更少的参数、更少的内存和更少的计算量比常规卷积层,通常表现更好。考虑默认使用它们,除了在通道较少的层之后(比如输入通道)。在 Keras 中,只需使用SeparableConv2D代替Conv2D:这是一个即插即用的替代。Keras 还提供了一个DepthwiseConv2D层,实现深度可分离卷积层的第一部分(即,对每个输入特征图应用一个空间滤波器)。
SENet
在 ILSVRC 2017 挑战中获胜的架构是Squeeze-and-Excitation Network (SENet)。这个架构扩展了现有的架构,如 inception 网络和 ResNets,并提升了它们的性能。这使得 SENet 以惊人的 2.25%的前五错误率赢得了比赛!扩展版本的 inception 网络和 ResNets 分别称为SE-Inception和SE-ResNet。提升来自于 SENet 在原始架构的每个 inception 模块或残差单元中添加了一个小型神经网络,称为SE 块,如图 14-21 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1421.png
图 14-21. SE-Inception 模块(左)和 SE-ResNet 单元(右)
一个 SE 块分析其所附加的单元的输出,专注于深度维度(不寻找任何空间模式),并学习哪些特征通常是最活跃的。然后,它使用这些信息来重新校准特征映射,如图 14-22 所示。例如,一个 SE 块可能学习到嘴巴、鼻子和眼睛通常一起出现在图片中:如果你看到嘴巴和鼻子,你应该期望也看到眼睛。因此,如果该块在嘴巴和鼻子特征映射中看到强烈的激活,但在眼睛特征映射中只有轻微的激活,它将增强眼睛特征映射(更准确地说,它将减少不相关的特征映射)。如果眼睛有些混淆,这种特征映射的重新校准将有助于解决模糊性。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1422.png
图 14-22. 一个 SE 块执行特征映射重新校准
一个 SE 块由三层组成:一个全局平均池化层,一个使用 ReLU 激活函数的隐藏密集层,以及一个使用 sigmoid 激活函数的密集输出层(见图 14-23)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1423.png
图 14-23. SE 块架构
与之前一样,全局平均池化层计算每个特征映射的平均激活:例如,如果其输入包含 256 个特征映射,它将输出 256 个数字,表示每个滤波器的整体响应水平。接下来的层是“挤压”发生的地方:这一层的神经元数量明显少于 256 个——通常比特征映射的数量少 16 倍(例如,16 个神经元)——因此 256 个数字被压缩成一个小向量(例如,16 维)。这是特征响应分布的低维向量表示(即嵌入)。这个瓶颈步骤迫使 SE 块学习特征组合的一般表示(当我们讨论自编码器时,我们将再次看到这个原则在第十七章中)。最后,输出层接受嵌入并输出一个包含每个特征映射的重新校准向量(例如,256 个),每个数字在 0 到 1 之间。然后特征映射乘以这个重新校准向量,因此不相关的特征(具有低重新校准分数)被缩小,而相关的特征(具有接近 1 的重新校准分数)被保留。
其他值得注意的架构
还有许多其他 CNN 架构可以探索。以下是一些最值得注意的简要概述:
ResNeXt²²
ResNeXt 改进了 ResNet 中的残差单元。而最佳 ResNet 模型中的残差单元只包含 3 个卷积层,ResNeXt 的残差单元由许多并行堆栈组成(例如,32 个堆栈),每个堆栈有 3 个卷积层。然而,每个堆栈中的前两层只使用少量滤波器(例如,只有四个),因此总参数数量与 ResNet 中的相同。然后,所有堆栈的输出相加,并将结果传递给下一个残差单元(以及跳跃连接)。
DenseNet²³
DenseNet 由几个密集块组成,每个块由几个密集连接的卷积层组成。这种架构在使用相对较少的参数的同时实现了出色的准确性。什么是“密集连接”?每一层的输出被馈送为同一块内每一层之后的每一层的输入。例如,块中的第 4 层以该块中第 1、2 和 3 层的输出的深度级联作为输入。密集块之间由几个过渡层分隔。
MobileNet²⁴
MobileNets 是精简的模型,旨在轻量且快速,因此在移动和 Web 应用程序中很受欢迎。它们基于深度可分离卷积层,类似于 Xception。作者提出了几个变体,以牺牲一点准确性换取更快速和更小的模型。
CSPNet²⁵
交叉阶段部分网络(CSPNet)类似于 DenseNet,但是每个密集块的部分输入直接连接到该块的输出,而不经过该块。
EfficientNet²⁶
EfficientNet 可以说是这个列表中最重要的模型。作者提出了一种有效地扩展任何 CNN 的方法,通过以原则性的方式同时增加深度(层数)、宽度(每层的滤波器数量)和分辨率(输入图像的大小)。这被称为复合缩放。他们使用神经架构搜索来找到一个适合 ImageNet 的缩小版本(具有更小和更少的图像)的良好架构,然后使用复合缩放来创建这种架构的越来越大的版本。当 EfficientNet 模型推出时,它们在所有计算预算中都远远超过了所有现有的模型,并且它们仍然是当今最好的模型之一。
理解 EfficientNet 的复合缩放方法有助于更深入地理解 CNN,特别是如果您需要扩展 CNN 架构。它基于计算预算的对数度量,标记为ϕ:如果您的计算预算翻倍,则ϕ增加 1。换句话说,用于训练的浮点运算数量与 2^(ϕ)成比例。您的 CNN 架构的深度、宽度和分辨率应分别按α(*ϕ*)、*β*(ϕ)和γ^(ϕ)缩放。因子α、β和γ必须大于 1,且α + β² + γ²应接近 2。这些因子的最佳值取决于 CNN 的架构。为了找到 EfficientNet 架构的最佳值,作者从一个小的基线模型(EfficientNetB0)开始,固定ϕ = 1,然后简单地运行了一个网格搜索:他们发现α = 1.2,β = 1.1,γ = 1.1。然后,他们使用这些因子创建了几个更大的架构,命名为 EfficientNetB1 到 EfficientNetB7,对应不断增加的ϕ值。
选择正确的 CNN 架构
有这么多 CNN 架构,您如何选择最适合您项目的架构?这取决于您最关心的是什么:准确性?模型大小(例如,用于部署到移动设备)?在 CPU 上的推理速度?在 GPU 上的推理速度?表 14-3 列出了目前在 Keras 中可用的最佳预训练模型(您将在本章后面看到如何使用它们),按模型大小排序。您可以在https://keras.io/api/applications找到完整列表。对于每个模型,表格显示要使用的 Keras 类名(在tf.keras.applications包中)、模型的大小(MB)、在 ImageNet 数据集上的 Top-1 和 Top-5 验证准确率、参数数量(百万)以及在 CPU 和 GPU 上使用 32 张图像的推理时间(毫秒),使用性能较强的硬件。²⁷ 对于每列,最佳值已突出显示。正如您所看到的,通常较大的模型更准确,但并非总是如此;例如,EfficientNetB2 在大小和准确性上均优于 InceptionV3。我之所以将 InceptionV3 保留在列表中,是因为在 CPU 上它几乎比 EfficientNetB2 快一倍。同样,InceptionResNetV2 在 CPU 上速度很快,而 ResNet50V2 和 ResNet101V2 在 GPU 上速度极快。
表 14-3。Keras 中可用的预训练模型
| 类名 | 大小(MB) | Top-1 准确率 | Top-5 准确率 | 参数 | CPU(ms) | GPU(ms) |
|---|---|---|---|---|---|---|
| MobileNetV2 | 14 | 71.3% | 90.1% | 3.5M | 25.9 | 3.8 |
| MobileNet | 16 | 70.4% | 89.5% | 4.3M | 22.6 | 3.4 |
| NASNetMobile | 23 | 74.4% | 91.9% | 5.3M | 27.0 | 6.7 |
| EfficientNetB0 | 29 | 77.1% | 93.3% | 5.3M | 46.0 | 4.9 |
| EfficientNetB1 | 31 | 79.1% | 94.4% | 7.9M | 60.2 | 5.6 |
| EfficientNetB2 | 36 | 80.1% | 94.9% | 9.2M | 80.8 | 6.5 |
| EfficientNetB3 | 48 | 81.6% | 95.7% | 12.3M | 140.0 | 8.8 |
| EfficientNetB4 | 75 | 82.9% | 96.4% | 19.5M | 308.3 | 15.1 |
| InceptionV3 | 92 | 77.9% | 93.7% | 23.9M | 42.2 | 6.9 |
| ResNet50V2 | 98 | 76.0% | 93.0% | 25.6M | 45.6 | 4.4 |
| EfficientNetB5 | 118 | 83.6% | 96.7% | 30.6M | 579.2 | 25.3 |
| EfficientNetB6 | 166 | 84.0% | 96.8% | 43.3M | 958.1 | 40.4 |
| ResNet101V2 | 171 | 77.2% | 93.8% | 44.7M | 72.7 | 5.4 |
| InceptionResNetV2 | 215 | 80.3% | 95.3% | 55.9M | 130.2 | 10.0 |
| EfficientNetB7 | 256 | 84.3% | 97.0% | 66.7M | 1578.9 | 61.6 |
希望您喜欢这次对主要 CNN 架构的深入探讨!现在让我们看看如何使用 Keras 实现其中一个。
使用 Keras 实现 ResNet-34 CNN
到目前为止,大多数描述的 CNN 架构可以很自然地使用 Keras 实现(尽管通常您会加载一个预训练网络,正如您将看到的)。为了说明这个过程,让我们使用 Keras 从头开始实现一个 ResNet-34。首先,我们将创建一个ResidualUnit层:
DefaultConv2D = partial(tf.keras.layers.Conv2D, kernel_size=3, strides=1,
padding="same", kernel_initializer="he_normal",
use_bias=False)
class ResidualUnit(tf.keras.layers.Layer):
def __init__(self, filters, strides=1, activation="relu", **kwargs):
super().__init__(**kwargs)
self.activation = tf.keras.activations.get(activation)
self.main_layers = [
DefaultConv2D(filters, strides=strides),
tf.keras.layers.BatchNormalization(),
self.activation,
DefaultConv2D(filters),
tf.keras.layers.BatchNormalization()
]
self.skip_layers = []
if strides > 1:
self.skip_layers = [
DefaultConv2D(filters, kernel_size=1, strides=strides),
tf.keras.layers.BatchNormalization()
]
def call(self, inputs):
Z = inputs
for layer in self.main_layers:
Z = layer(Z)
skip_Z = inputs
for layer in self.skip_layers:
skip_Z = layer(skip_Z)
return self.activation(Z + skip_Z)
正如您所看到的,这段代码与图 14-19 非常接近。在构造函数中,我们创建所有需要的层:图中右侧的主要层和左侧的跳过层(仅在步幅大于 1 时需要)。然后在call()方法中,我们让输入经过主要层和跳过层(如果有的话),然后我们添加两个输出并应用激活函数。
现在我们可以使用Sequential模型构建一个 ResNet-34,因为它实际上只是一长串的层——现在我们有了ResidualUnit类,可以将每个残差单元视为一个单独的层。代码与图 14-18 非常相似:
model = tf.keras.Sequential([
DefaultConv2D(64, kernel_size=7, strides=2, input_shape=[224, 224, 3]),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation("relu"),
tf.keras.layers.MaxPool2D(pool_size=3, strides=2, padding="same"),
])
prev_filters = 64
for filters in [64] * 3 + [128] * 4 + [256] * 6 + [512] * 3:
strides = 1 if filters == prev_filters else 2
model.add(ResidualUnit(filters, strides=strides))
prev_filters = filters
model.add(tf.keras.layers.GlobalAvgPool2D())
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(10, activation="softmax"))
这段代码中唯一棘手的部分是将ResidualUnit层添加到模型的循环:正如前面解释的,前 3 个 RU 有 64 个滤波器,然后接下来的 4 个 RU 有 128 个滤波器,依此类推。在每次迭代中,当滤波器的数量与前一个 RU 中的数量相同时,我们必须将步幅设置为 1;否则,我们将其设置为 2;然后我们添加ResidualUnit,最后我们更新prev_filters。
令人惊讶的是,我们只需大约 40 行代码,就可以构建赢得 ILSVRC 2015 挑战的模型!这既展示了 ResNet 模型的优雅之处,也展示了 Keras API 的表现力。实现其他 CNN 架构会有点长,但并不难。不过,Keras 内置了几种这些架构,为什么不直接使用呢?
使用 Keras 中的预训练模型
通常,您不必手动实现标准模型,如 GoogLeNet 或 ResNet,因为在tf.keras.applications包中只需一行代码即可获得预训练网络。
例如,您可以使用以下代码加载在 ImageNet 上预训练的 ResNet-50 模型:
model = tf.keras.applications.ResNet50(weights="imagenet")
就这些!这将创建一个 ResNet-50 模型,并下载在 ImageNet 数据集上预训练的权重。要使用它,您首先需要确保图像的尺寸正确。ResNet-50 模型期望 224×224 像素的图像(其他模型可能期望其他尺寸,如 299×299),因此让我们使用 Keras 的Resizing层(在第十三章中介绍)来调整两个示例图像的大小(在将它们裁剪到目标纵横比之后):
images = load_sample_images()["images"]
images_resized = tf.keras.layers.Resizing(height=224, width=224,
crop_to_aspect_ratio=True)(images)
预训练模型假定图像以特定方式预处理。在某些情况下,它们可能期望输入被缩放为 0 到 1,或者从-1 到 1 等等。每个模型都提供了一个preprocess_input()函数,您可以用它来预处理您的图像。这些函数假设原始像素值的范围是 0 到 255,这在这里是正确的:
inputs = tf.keras.applications.resnet50.preprocess_input(images_resized)
现在我们可以使用预训练模型进行预测:
>>> Y_proba = model.predict(inputs)
>>> Y_proba.shape
(2, 1000)
像往常一样,输出Y_proba是一个矩阵,每行代表一个图像,每列代表一个类别(在本例中有 1,000 个类别)。如果您想显示前K个预测结果,包括类别名称和每个预测类别的估计概率,请使用decode_predictions()函数。对于每个图像,它返回一个包含前K个预测结果的数组,其中每个预测结果表示为一个包含类别标识符、其名称和相应置信度分数的数组:
top_K = tf.keras.applications.resnet50.decode_predictions(Y_proba, top=3)
for image_index in range(len(images)):
print(f"Image #{image_index}")
for class_id, name, y_proba in top_K[image_index]:
print(f" {class_id} - {name:12s}{y_proba:.2%}")
输出如下所示:
Image #0
n03877845 - palace 54.69%
n03781244 - monastery 24.72%
n02825657 - bell_cote 18.55%
Image #1
n04522168 - vase 32.66%
n11939491 - daisy 17.81%
n03530642 - honeycomb 12.06%
正确的类别是 palace 和 dahlia,因此模型对第一张图像是正确的,但对第二张图像是错误的。然而,这是因为 dahlia 不是 1,000 个 ImageNet 类之一。考虑到这一点,vase 是一个合理的猜测(也许这朵花在花瓶里?),daisy 也不是一个坏选择,因为 dahlias 和 daisies 都属于同一菊科家族。
正如您所看到的,使用预训练模型创建一个相当不错的图像分类器非常容易。正如您在表 14-3 中看到的,tf.keras.applications中提供了许多其他视觉模型,从轻量级快速模型到大型准确模型。
但是,如果您想要为不属于 ImageNet 的图像类别使用图像分类器,那么您仍然可以通过使用预训练模型来进行迁移学习获益。
用于迁移学习的预训练模型
如果您想构建一个图像分类器,但没有足够的数据来从头开始训练它,那么通常可以重用预训练模型的较低层,正如我们在第十一章中讨论的那样。例如,让我们训练一个模型来对花的图片进行分类,重用一个预训练的 Xception 模型。首先,我们将使用 TensorFlow Datasets(在第十三章中介绍)加载花卉数据集:
import tensorflow_datasets as tfds
dataset, info = tfds.load("tf_flowers", as_supervised=True, with_info=True)
dataset_size = info.splits["train"].num_examples # 3670
class_names = info.features["label"].names # ["dandelion", "daisy", ...]
n_classes = info.features["label"].num_classes # 5
请注意,您可以通过设置with_info=True来获取有关数据集的信息。在这里,我们获取数据集的大小和类的名称。不幸的是,只有一个"train"数据集,没有测试集或验证集,所以我们需要拆分训练集。让我们再次调用tfds.load(),但这次将前 10%的数据集用于测试,接下来的 15%用于验证,剩下的 75%用于训练:
test_set_raw, valid_set_raw, train_set_raw = tfds.load(
"tf_flowers",
split=["train[:10%]", "train[10%:25%]", "train[25%:]"],
as_supervised=True)
所有三个数据集都包含单独的图像。我们需要对它们进行批处理,但首先我们需要确保它们都具有相同的大小,否则批处理将失败。我们可以使用Resizing层来实现这一点。我们还必须调用tf.keras.applications.xception.preprocess_input()函数,以适当地预处理图像以供 Xception 模型使用。最后,我们还将对训练集进行洗牌并使用预取:
batch_size = 32
preprocess = tf.keras.Sequential([
tf.keras.layers.Resizing(height=224, width=224, crop_to_aspect_ratio=True),
tf.keras.layers.Lambda(tf.keras.applications.xception.preprocess_input)
])
train_set = train_set_raw.map(lambda X, y: (preprocess(X), y))
train_set = train_set.shuffle(1000, seed=42).batch(batch_size).prefetch(1)
valid_set = valid_set_raw.map(lambda X, y: (preprocess(X), y)).batch(batch_size)
test_set = test_set_raw.map(lambda X, y: (preprocess(X), y)).batch(batch_size)
现在每个批次包含 32 个图像,所有图像都是 224×224 像素,像素值范围从-1 到 1。完美!
由于数据集不是很大,一点数据增强肯定会有所帮助。让我们创建一个数据增强模型,将其嵌入到我们的最终模型中。在训练期间,它将随机水平翻转图像,稍微旋转它们,并调整对比度:
data_augmentation = tf.keras.Sequential([
tf.keras.layers.RandomFlip(mode="horizontal", seed=42),
tf.keras.layers.RandomRotation(factor=0.05, seed=42),
tf.keras.layers.RandomContrast(factor=0.2, seed=42)
])
提示
tf.keras.preprocessing.image.ImageDataGenerator类使从磁盘加载图像并以各种方式增强它们变得容易:您可以移动每个图像,旋转它,重新缩放它,水平或垂直翻转它,剪切它,或者应用任何您想要的转换函数。这对于简单的项目非常方便。然而,tf.data 管道并不复杂,通常更快。此外,如果您有 GPU 并且将预处理或数据增强层包含在模型内部,它们将在训练过程中受益于 GPU 加速。
接下来让我们加载一个在 ImageNet 上预训练的 Xception 模型。通过设置include_top=False来排除网络的顶部。这将排除全局平均池化层和密集输出层。然后我们添加自己的全局平均池化层(将其输入设置为基础模型的输出),然后是一个具有每个类别一个单元的密集输出层,使用 softmax 激活函数。最后,我们将所有这些包装在一个 Keras Model中:
base_model = tf.keras.applications.xception.Xception(weights="imagenet",
include_top=False)
avg = tf.keras.layers.GlobalAveragePooling2D()(base_model.output)
output = tf.keras.layers.Dense(n_classes, activation="softmax")(avg)
model = tf.keras.Model(inputs=base_model.input, outputs=output)
如第十一章中解释的,通常冻结预训练层的权重是一个好主意,至少在训练开始时是这样的:
for layer in base_model.layers:
layer.trainable = False
警告
由于我们的模型直接使用基础模型的层,而不是base_model对象本身,设置base_model.trainable=False不会产生任何效果。
最后,我们可以编译模型并开始训练:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.1, momentum=0.9)
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=3)
警告
如果你在 Colab 上运行,请确保运行时正在使用 GPU:选择运行时→“更改运行时类型”,在“硬件加速器”下拉菜单中选择“GPU”,然后点击保存。可以在没有 GPU 的情况下训练模型,但速度会非常慢(每个时期几分钟,而不是几秒)。
在训练模型几个时期后,其验证准确率应该达到 80%以上,然后停止提高。这意味着顶层现在已经训练得相当好,我们准备解冻一些基础模型的顶层,然后继续训练。例如,让我们解冻第 56 层及以上的层(这是 14 个残差单元中第 7 个的开始,如果列出层名称,你会看到):
for layer in base_model.layers[56:]:
layer.trainable = True
不要忘记在冻结或解冻层时编译模型。还要确保使用更低的学习率以避免破坏预训练权重:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, momentum=0.9)
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=10)
这个模型应该在测试集上达到大约 92%的准确率,在几分钟的训练时间内(使用 GPU)。如果调整超参数,降低学习率,并进行更长时间的训练,应该能够达到 95%至 97%的准确率。有了这个,你可以开始在自己的图像和类别上训练出色的图像分类器!但计算机视觉不仅仅是分类。例如,如果你还想知道图片中花朵的位置在哪里?让我们现在来看看。
分类和定位
在图片中定位一个对象可以被表达为一个回归任务,如第十章中讨论的:预测一个对象周围的边界框,一个常见的方法是预测对象中心的水平和垂直坐标,以及它的高度和宽度。这意味着我们有四个数字要预测。对模型不需要太多改变;我们只需要添加一个具有四个单元的第二个密集输出层(通常在全局平均池化层之上),并且可以使用 MSE 损失进行训练:
base_model = tf.keras.applications.xception.Xception(weights="imagenet",
include_top=False)
avg = tf.keras.layers.GlobalAveragePooling2D()(base_model.output)
class_output = tf.keras.layers.Dense(n_classes, activation="softmax")(avg)
loc_output = tf.keras.layers.Dense(4)(avg)
model = tf.keras.Model(inputs=base_model.input,
outputs=[class_output, loc_output])
model.compile(loss=["sparse_categorical_crossentropy", "mse"],
loss_weights=[0.8, 0.2], # depends on what you care most about
optimizer=optimizer, metrics=["accuracy"])
但是现在我们有一个问题:花卉数据集中没有围绕花朵的边界框。因此,我们需要自己添加。这通常是机器学习项目中最困难和最昂贵的部分之一:获取标签。花时间寻找合适的工具是个好主意。要用边界框注释图像,您可能想使用开源图像标注工具,如 VGG Image Annotator、LabelImg、OpenLabeler 或 ImgLab,或者商业工具如 LabelBox 或 Supervisely。您还可以考虑众包平台,如亚马逊机械土耳其,如果您有大量图像需要注释。然而,设置众包平台、准备发送给工人的表格、监督他们并确保他们产生的边界框的质量是好的,这是相当多的工作,所以确保这是值得的。Adriana Kovashka 等人撰写了一篇非常实用的论文关于计算机视觉中的众包。我建议您查看一下,即使您不打算使用众包。如果只有几百张甚至几千张图像需要标记,并且您不打算经常这样做,最好自己做:使用合适的工具,只需要几天时间,您还将更好地了解您的数据集和任务。
现在假设您已经为花卉数据集中的每个图像获得了边界框(暂时假设每个图像只有一个边界框)。然后,您需要创建一个数据集,其项目将是经过预处理的图像的批次以及它们的类标签和边界框。每个项目应该是一个形式为(images, (class_labels, bounding_boxes))的元组。然后您就可以开始训练您的模型!
提示
边界框应该被归一化,使得水平和垂直坐标以及高度和宽度的范围都在 0 到 1 之间。此外,通常预测高度和宽度的平方根,而不是直接预测高度和宽度:这样,对于大边界框的 10 像素误差不会受到与小边界框的 10 像素误差一样多的惩罚。
均方误差通常作为训练模型的成本函数效果相当不错,但不是评估模型如何预测边界框的好指标。这方面最常见的度量是交并比(IoU):预测边界框与目标边界框之间的重叠区域除以它们的并集的面积(参见图 14-24)。在 Keras 中,它由tf.keras.metrics.MeanIoU类实现。
对单个对象进行分类和定位是很好的,但是如果图像中包含多个对象(通常在花卉数据集中是这种情况),怎么办呢?
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1424.png
图 14-24。边界框的 IoU 度量
目标检测
在图像中对多个对象进行分类和定位的任务称为目标检测。直到几年前,一种常见的方法是采用一个 CNN,该 CNN 经过训练,可以对图像中大致位于中心的单个对象进行分类和定位,然后在图像上滑动这个 CNN,并在每一步进行预测。通常,CNN 被训练来预测不仅类别概率和边界框,还有一个对象性分数:这是估计的概率,即图像确实包含一个位于中心附近的对象。这是一个二元分类输出;它可以通过具有单个单元的密集输出层产生,使用 sigmoid 激活函数并使用二元交叉熵损失进行训练。
注意
有时会添加一个“无对象”类,而不是对象性分数,但总的来说,这并不起作用得很好:最好分开回答“是否存在对象?”和“对象的类型是什么?”这两个问题。
这种滑动 CNN 方法在图 14-25 中有所说明。在这个例子中,图像被切成了一个 5×7 的网格,我们看到一个 CNN——厚厚的黑色矩形——在所有 3×3 区域上滑动,并在每一步进行预测。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1425.png
图 14-25。通过在图像上滑动 CNN 来检测多个对象
在这个图中,CNN 已经对这三个 3×3 区域进行了预测:
-
当查看左上角的 3×3 区域(位于第二行第二列的红色阴影网格单元中心)时,它检测到了最左边的玫瑰。请注意,预测的边界框超出了这个 3×3 区域的边界。这完全没问题:即使 CNN 看不到玫瑰的底部部分,它仍能合理猜测它可能在哪里。它还预测了类别概率,给“玫瑰”类别一个很高的概率。最后,它预测了一个相当高的物体得分,因为边界框的中心位于中心网格单元内(在这个图中,物体得分由边界框的厚度表示)。
-
当查看下一个 3×3 区域,向右移动一个网格单元(位于阴影蓝色正方形中心)时,它没有检测到任何位于该区域中心的花朵,因此预测的物体得分非常低;因此,可以安全地忽略预测的边界框和类别概率。您可以看到,预测的边界框也不好。
-
最后,当查看下一个 3×3 区域,再向右移动一个网格单元(位于阴影绿色单元中心)时,它检测到了顶部的玫瑰,尽管不完美:这朵玫瑰没有很好地位于该区域中心,因此预测的物体得分并不是很高。
您可以想象,将 CNN 滑动到整个图像上会给您总共 15 个预测的边界框,以 3×5 的网格组织,每个边界框都伴随着其估计的类别概率和物体得分。由于对象的大小可能不同,您可能希望再次在更大的 4×4 区域上滑动 CNN,以获得更多的边界框。
这种技术相当简单,但正如您所看到的,它经常会在稍微不同的位置多次检测到相同的对象。需要一些后处理来摆脱所有不必要的边界框。一个常见的方法是称为非极大值抑制。下面是它的工作原理:
-
首先,摆脱所有物体得分低于某个阈值的边界框:因为 CNN 认为该位置没有对象,所以边界框是无用的。
-
找到具有最高物体得分的剩余边界框,并摆脱所有与其重叠很多的其他剩余边界框(例如,IoU 大于 60%)。例如,在图 14-25 中,具有最大物体得分的边界框是覆盖最左边的玫瑰的厚边界框。与这朵相同玫瑰接触的另一个边界框与最大边界框重叠很多,因此我们将摆脱它(尽管在这个例子中,它在上一步中已经被移除)。
-
重复步骤 2,直到没有更多需要摆脱的边界框。
这种简单的目标检测方法效果相当不错,但需要多次运行 CNN(在这个例子中为 15 次),因此速度相当慢。幸运的是,有一种更快的方法可以在图像上滑动 CNN:使用全卷积网络(FCN)。
全卷积网络
FCN 的概念最初是由 Jonathan Long 等人在2015 年的一篇论文中提出的,用于语义分割(根据对象所属的类别对图像中的每个像素进行分类的任务)。作者指出,可以用卷积层替换 CNN 顶部的密集层。为了理解这一点,让我们看一个例子:假设一个具有 200 个神经元的密集层位于一个输出 100 个大小为 7×7 的特征图的卷积层的顶部(这是特征图的大小,而不是卷积核的大小)。每个神经元将计算来自卷积层的所有 100×7×7 激活的加权和(加上一个偏置项)。现在让我们看看如果我们用 200 个大小为 7×7 的滤波器和"valid"填充的卷积层来替换密集层会发生什么。这一层将输出 200 个大小为 1×1 的特征图(因为卷积核恰好是输入特征图的大小,而且我们使用"valid"填充)。换句话说,它将输出 200 个数字,就像密集层一样;如果你仔细观察卷积层执行的计算,你会注意到这些数字将与密集层产生的数字完全相同。唯一的区别是密集层的输出是一个形状为[批量大小, 200]的张量,而卷积层将输出一个形状为[批量大小, 1, 1, 200]的张量。
提示
要将密集层转换为卷积层,卷积层中的滤波器数量必须等于密集层中的单元数量,滤波器大小必须等于输入特征图的大小,并且必须使用"valid"填充。步幅可以设置为 1 或更多,稍后您将看到。
为什么这很重要?嗯,密集层期望特定的输入大小(因为它对每个输入特征有一个权重),而卷积层将愉快地处理任何大小的图像(但是,它期望其输入具有特定数量的通道,因为每个卷积核包含每个输入通道的不同权重集)。由于 FCN 只包含卷积层(和具有相同属性的池化层),它可以在任何大小的图像上进行训练和执行!
例如,假设我们已经训练了一个用于花卉分类和定位的 CNN。它是在 224×224 的图像上训练的,并输出 10 个数字:
-
输出 0 到 4 通过 softmax 激活函数发送,这给出了类别概率(每个类别一个)。
-
输出 5 通过 sigmoid 激活函数发送,这给出了物体得分。
-
输出 6 和 7 代表边界框的中心坐标;它们也经过 sigmoid 激活函数,以确保它们的范围在 0 到 1 之间。
-
最后,输出 8 和 9 代表边界框的高度和宽度;它们不经过任何激活函数,以允许边界框延伸到图像的边界之外。
现在我们可以将 CNN 的密集层转换为卷积层。实际上,我们甚至不需要重新训练它;我们可以直接将密集层的权重复制到卷积层!或者,在训练之前,我们可以将 CNN 转换为 FCN。
现在假设在输出层之前的最后一个卷积层(也称为瓶颈层)在网络输入 224×224 图像时输出 7×7 特征图(参见图 14-26 的左侧)。如果我们将 FCN 输入 448×448 图像(参见图 14-26 的右侧),瓶颈层现在将输出 14×14 特征图。³² 由于密集输出层被使用大小为 7×7 的 10 个滤波器的卷积层替换,使用"valid"填充和步幅 1,输出将由 10 个特征图组成,每个大小为 8×8(因为 14-7+1=8)。换句话说,FCN 将仅处理整个图像一次,并输出一个 8×8 的网格,其中每个单元包含 10 个数字(5 个类别概率,1 个物体性分数和 4 个边界框坐标)。这就像拿着原始 CNN 并在图像上每行移动 8 步,每列移动 8 步。为了可视化这一点,想象将原始图像切成一个 14×14 的网格,然后在这个网格上滑动一个 7×7 的窗口;窗口将有 8×8=64 个可能的位置,因此有 8×8 个预测。然而,FCN 方法要更有效,因为网络只看一次图像。事实上,You Only Look Once(YOLO)是一个非常流行的目标检测架构的名称,我们将在接下来看一下。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1426.png
图 14-26。相同的全卷积网络处理小图像(左)和大图像(右)
只看一次
YOLO 是由 Joseph Redmon 等人在2015 年的一篇论文中提出的一种快速准确的目标检测架构。³³ 它非常快速,可以在视频上实时运行,就像在 Redmon 的演示中看到的那样。YOLO 的架构与我们刚讨论的架构非常相似,但有一些重要的区别:
-
对于每个网格单元,YOLO 只考虑边界框中心位于该单元内的对象。边界框坐标是相对于该单元的,其中(0, 0)表示单元的左上角,(1, 1)表示右下角。然而,边界框的高度和宽度可能远远超出单元。
-
它为每个网格单元输出两个边界框(而不仅仅是一个),这使得模型能够处理两个对象非常接近,以至于它们的边界框中心位于同一个单元格内的情况。每个边界框还附带自己的物体性分数。
-
YOLO 还为每个网格单元输出一个类别概率分布,每个网格单元预测 20 个类别概率,因为 YOLO 是在包含 20 个类别的 PASCAL VOC 数据集上训练的。这产生了一个粗糙的类别概率图。请注意,模型为每个网格单元预测一个类别概率分布,而不是每个边界框。然而,可以在后处理期间估计每个边界框的类别概率,方法是测量每个边界框与类别概率图中的每个类别匹配的程度。例如,想象一张图片中有一个人站在一辆车前面。将会有两个边界框:一个大的水平边界框用于车,一个较小的垂直边界框用于人。这些边界框的中心可能位于同一个网格单元内。那么我们如何确定应该为每个边界框分配哪个类别呢?嗯,类别概率图将包含一个“车”类占主导地位的大区域,里面将有一个“人”类占主导地位的较小区域。希望车的边界框大致匹配“车”区域,而人的边界框大致匹配“人”区域:这将允许为每个边界框分配正确的类别。
YOLO 最初是使用 Darknet 开发的,Darknet 是由 Joseph Redmon 最初用 C 开发的开源深度学习框架,但很快就被移植到了 TensorFlow、Keras、PyTorch 等。多年来不断改进,包括 YOLOv2、YOLOv3 和 YOLO9000(再次由 Joseph Redmon 等人开发)、YOLOv4(由 Alexey Bochkovskiy 等人开发)、YOLOv5(由 Glenn Jocher 开发)和 PP-YOLO(由 Xiang Long 等人开发)。
每个版本都带来了一些令人印象深刻的速度和准确性改进,使用了各种技术;例如,YOLOv3 在一定程度上提高了准确性,部分原因在于锚先验,利用了某些边界框形状比其他形状更有可能的事实,这取决于类别(例如,人们倾向于具有垂直边界框,而汽车通常不会)。他们还增加了每个网格单元的边界框数量,他们在不同数据集上进行了训练,包含更多类别(YOLO9000 的情况下最多达到 9,000 个类别,按层次结构组织),他们添加了跳跃连接以恢复在 CNN 中丢失的一些空间分辨率(我们将很快讨论这一点,当我们看语义分割时),等等。这些模型也有许多变体,例如 YOLOv4-tiny,它经过优化,可以在性能较弱的机器上进行训练,并且可以运行得非常快(每秒超过 1,000 帧!),但平均精度均值(mAP)略低。
许多目标检测模型都可以在 TensorFlow Hub 上找到,通常具有预训练权重,例如 YOLOv5、SSD、Faster R-CNN和EfficientDet。
SSD 和 EfficientDet 是“一次查看”检测模型,类似于 YOLO。EfficientDet 基于 EfficientNet 卷积架构。Faster R-CNN 更复杂:图像首先经过 CNN,然后输出传递给区域建议网络(RPN),该网络提出最有可能包含对象的边界框;然后为每个边界框运行分类器,基于 CNN 的裁剪输出。使用这些模型的最佳起点是 TensorFlow Hub 的出色目标检测教程。
到目前为止,我们只考虑在单个图像中检测对象。但是视频呢?对象不仅必须在每一帧中被检测到,还必须随着时间进行跟踪。现在让我们快速看一下目标跟踪。
目标跟踪
目标跟踪是一项具有挑战性的任务:对象移动,它们可能随着接近或远离摄像机而变大或变小,它们的外观可能会随着转身或移动到不同的光照条件或背景而改变,它们可能会被其他对象暂时遮挡,等等。
最受欢迎的目标跟踪系统之一是DeepSORT。它基于经典算法和深度学习的组合:
-
它使用Kalman 滤波器来估计给定先前检测的对象最可能的当前位置,并假设对象倾向于以恒定速度移动。
-
它使用深度学习模型来衡量新检测和现有跟踪对象之间的相似度。
-
最后,它使用匈牙利算法将新检测映射到现有跟踪对象(或新跟踪对象):该算法有效地找到最小化检测和跟踪对象预测位置之间距离的映射组合,同时最小化外观差异。
例如,想象一个红色球刚从相反方向移动的蓝色球上弹起。根据球的先前位置,卡尔曼滤波器将预测球会相互穿过:实际上,它假设对象以恒定速度移动,因此不会预期弹跳。如果匈牙利算法只考虑位置,那么它会愉快地将新的检测结果映射到错误的球上,就好像它们刚刚相互穿过并交换了颜色。但由于相似度度量,匈牙利算法会注意到问题。假设球不太相似,算法将新的检测结果映射到正确的球上。
提示
在 GitHub 上有一些 DeepSORT 的实现,包括 YOLOv4 + DeepSORT 的 TensorFlow 实现:https://github.com/theAIGuysCode/yolov4-deepsort。
到目前为止,我们已经使用边界框定位了对象。这通常足够了,但有时您需要更精确地定位对象,例如在视频会议中去除人物背后的背景。让我们看看如何降到像素级别。
语义分割
在语义分割中,每个像素根据其所属对象的类别进行分类(例如,道路、汽车、行人、建筑等),如图 14-27 所示。请注意,同一类别的不同对象不被区分。例如,分割图像右侧的所有自行车最终会成为一个大块像素。这项任务的主要困难在于,当图像经过常规 CNN 时,由于步幅大于 1 的层,它们逐渐失去空间分辨率;因此,常规 CNN 可能只会知道图像左下角某处有一个人,但不会比这更精确。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1427.png
图 14-27. 语义分割
与目标检测一样,有许多不同的方法来解决这个问题,有些方法相当复杂。然而,在 Jonathan Long 等人于 2015 年提出的一篇关于完全卷积网络的论文中提出了一个相当简单的解决方案。作者首先采用了一个预训练的 CNN,并将其转换为 FCN。CNN 对输入图像应用了总步幅为 32(即,如果将所有大于 1 的步幅相加),这意味着最后一层输出的特征图比输入图像小 32 倍。这显然太粗糙了,因此他们添加了一个单一的上采样层,将分辨率乘以 32。
有几种可用的上采样解决方案(增加图像的大小),例如双线性插值,但这只能在×4 或×8 的范围内工作得相当好。相反,他们使用转置卷积层:³⁹这相当于首先通过插入空行和列(全是零)来拉伸图像,然后执行常规卷积(参见图 14-28)。或者,有些人更喜欢将其视为使用分数步幅的常规卷积层(例如,图 14-28 中的步幅为 1/2)。转置卷积层可以初始化为执行接近线性插值的操作,但由于它是一个可训练的层,在训练期间会学习做得更好。在 Keras 中,您可以使用Conv2DTranspose层。
注意
在转置卷积层中,步幅定义了输入将被拉伸多少,而不是滤波器步长的大小,因此步幅越大,输出就越大(与卷积层或池化层不同)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1428.png
图 14-28. 使用转置卷积层进行上采样
使用转置卷积层进行上采样是可以的,但仍然太不精确。为了做得更好,Long 等人从较低层添加了跳跃连接:例如,他们将输出图像上采样了 2 倍(而不是 32 倍),并添加了具有这种双倍分辨率的较低层的输出。然后,他们将结果上采样了 16 倍,导致总的上采样因子为 32(参见图 14-29)。这恢复了在较早的池化层中丢失的一些空间分辨率。在他们最好的架构中,他们使用了第二个类似的跳跃连接,以从更低的层中恢复更精细的细节。简而言之,原始 CNN 的输出经过以下额外步骤:上采样×2,添加较低层的输出(适当比例),上采样×2,添加更低层的输出,最后上采样×8。甚至可以将缩放超出原始图像的大小:这可以用于增加图像的分辨率,这是一种称为超分辨率的技术。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1429.png
图 14-29。跳跃层从较低层恢复一些空间分辨率
实例分割类似于语义分割,但不是将同一类别的所有对象合并成一个大块,而是将每个对象与其他对象区分开来(例如,它识别每辆自行车)。例如,由 Kaiming He 等人在2017 年的一篇论文中提出的Mask R-CNN架构,通过为每个边界框额外生成一个像素掩码来扩展 Faster R-CNN 模型。因此,您不仅可以获得围绕每个对象的边界框,以及一组估计的类别概率,还可以获得一个像素掩码,该掩码定位属于对象的边界框中的像素。该模型可在 TensorFlow Hub 上获得,预训练于 COCO 2017 数据集。尽管该领域发展迅速,但如果您想尝试最新和最优秀的模型,请查看https://paperswithcode.com的最新技术部分。
正如您所看到的,深度计算机视觉领域广阔且快速发展,每年都会涌现出各种架构。几乎所有这些架构都基于卷积神经网络,但自 2020 年以来,另一种神经网络架构已进入计算机视觉领域:Transformer(我们将在第十六章中讨论)。过去十年取得的进步令人瞩目,研究人员现在正专注于越来越困难的问题,例如对抗学习(试图使网络更具抗干扰性,以防止被设计用来欺骗它的图像)、可解释性(了解网络为何做出特定分类)、现实图像生成(我们将在第十七章中回顾)、单次学习(一个系统只需看到一次对象就能识别该对象)、预测视频中的下一帧、结合文本和图像任务等等。
现在进入下一章,我们将看看如何使用递归神经网络和卷积神经网络处理序列数据,例如时间序列。
练习
-
相比于完全连接的 DNN,CNN 在图像分类方面有哪些优势?
-
考虑一个由三个卷积层组成的 CNN,每个卷积层都有 3×3 的内核,步幅为 2,且具有
"same"填充。最底层输出 100 个特征映射,中间层输出 200 个,顶层输出 400 个。输入图像是 200×300 像素的 RGB 图像:-
CNN 中的参数总数是多少?
-
如果我们使用 32 位浮点数,那么在对单个实例进行预测时,这个网络至少需要多少 RAM?
-
当在一个包含 50 张图像的小批量上进行训练时呢?
-
-
如果您的 GPU 在训练 CNN 时内存不足,您可以尝试哪五种方法来解决这个问题?
-
为什么要添加最大池化层而不是具有相同步幅的卷积层?
-
何时要添加局部响应归一化层?
-
您能否列出 AlexNet 相对于 LeNet-5 的主要创新?GoogLeNet、ResNet、SENet、Xception 和 EfficientNet 的主要创新又是什么?
-
什么是全卷积网络?如何将密集层转换为卷积层?
-
语义分割的主要技术难点是什么?
-
从头开始构建自己的 CNN,并尝试在 MNIST 上实现最高可能的准确性。
-
使用大型图像分类的迁移学习,经过以下步骤:
-
创建一个包含每类至少 100 张图像的训练集。例如,您可以根据位置(海滩、山脉、城市等)对自己的图片进行分类,或者您可以使用现有数据集(例如来自 TensorFlow 数据集)。
-
将其分为训练集、验证集和测试集。
-
构建输入管道,应用适当的预处理操作,并可选择添加数据增强。
-
在这个数据集上微调一个预训练模型。
-
-
按照 TensorFlow 的风格转移教程进行操作。这是使用深度学习生成艺术的有趣方式。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
(1)David H. Hubel,“不受限制的猫条纹皮层单元活动”,《生理学杂志》147 卷(1959 年):226-238。
(2)David H. Hubel 和 Torsten N. Wiesel,“猫条纹皮层单个神经元的感受野”,《生理学杂志》148 卷(1959 年):574-591。
(3)David H. Hubel 和 Torsten N. Wiesel,“猴子条纹皮层的感受野和功能结构”,《生理学杂志》195 卷(1968 年):215-243。
(4)福岛邦彦,“Neocognitron:一种不受位置偏移影响的模式识别机制的自组织神经网络模型”,《生物控制论》36 卷(1980 年):193-202。
(5)Yann LeCun 等人,“基于梯度的学习应用于文档识别”,《IEEE 会议录》86 卷,第 11 期(1998 年):2278-2324。
(6)卷积是一种数学操作,它将一个函数滑动到另一个函数上,并测量它们逐点乘积的积分。它与傅里叶变换和拉普拉斯变换有深刻的联系,并且在信号处理中被广泛使用。卷积层实际上使用交叉相关,这与卷积非常相似(有关更多详细信息,请参见https://homl.info/76)。
(7)为了产生相同大小的输出,一个全连接层需要 200×150×100 个神经元,每个神经元连接到所有 150×100×3 个输入。它将有 200×150×100×(150×100×3+1)≈1350 亿个参数!
(8)在国际单位制(SI)中,1 MB = 1,000 KB = 1,000×1,000 字节 = 1,000×1,000×8 位。而 1 MiB = 1,024 kiB = 1,024×1,024 字节。所以 12 MB ≈ 11.44 MiB。
(9)我们迄今讨论过的其他内核具有权重,但池化内核没有:它们只是无状态的滑动窗口。
(10)Yann LeCun 等人,“基于梯度的学习应用于文档识别”,《IEEE 会议录》86 卷,第 11 期(1998 年):2278-2324。
(11)Alex Krizhevsky 等人,“使用深度卷积神经网络对 ImageNet 进行分类”,《第 25 届国际神经信息处理系统会议论文集》1 卷(2012 年):1097-1105。
¹² Matthew D. Zeiler 和 Rob Fergus,“可视化和理解卷积网络”,欧洲计算机视觉会议论文集(2014):818-833。
¹³ Christian Szegedy 等人,“使用卷积深入”,IEEE 计算机视觉和模式识别会议论文集(2015):1-9。
¹⁴ 在 2010 年的电影Inception中,角色们不断深入多层梦境;因此这些模块的名称。
¹⁵ Karen Simonyan 和 Andrew Zisserman,“用于大规模图像识别的非常深的卷积网络”,arXiv 预印本 arXiv:1409.1556(2014)。
¹⁶ Kaiming He 等人,“用于图像识别的深度残差学习”,arXiv 预印本 arXiv:1512:03385(2015)。
¹⁷ 描述神经网络时,通常只计算具有参数的层。
¹⁸ Christian Szegedy 等人,“Inception-v4,Inception-ResNet 和残差连接对学习的影响”,arXiv 预印本 arXiv:1602.07261(2016)。
¹⁹ François Chollet,“Xception:深度学习与深度可分离卷积”,arXiv 预印本 arXiv:1610.02357(2016)。
²⁰ 这个名称有时可能会有歧义,因为空间可分离卷积通常也被称为“可分离卷积”。
²¹ Jie Hu 等人,“挤压激励网络”,IEEE 计算机视觉和模式识别会议论文集(2018):7132-7141。
²² Saining Xie 等人,“聚合残差变换用于深度神经网络”,arXiv 预印本 arXiv:1611.05431(2016)。
²³ Gao Huang 等人,“密集连接卷积网络”,arXiv 预印本 arXiv:1608.06993(2016)。
²⁴ Andrew G. Howard 等人,“MobileNets:用于移动视觉应用的高效卷积神经网络”,arXiv 预印本 arxiv:1704.04861(2017)。
²⁵ Chien-Yao Wang 等人,“CSPNet:一种可以增强 CNN 学习能力的新骨干”,arXiv 预印本 arXiv:1911.11929(2019)。
²⁶ Mingxing Tan 和 Quoc V. Le,“EfficientNet:重新思考卷积神经网络的模型缩放”,arXiv 预印本 arXiv:1905.11946(2019)。
²⁷ 一款 92 核心的 AMD EPYC CPU,带有 IBPB,1.7 TB 的 RAM 和一款 Nvidia Tesla A100 GPU。
²⁸ 在 ImageNet 数据集中,每个图像都映射到WordNet 数据集中的一个单词:类别 ID 只是一个 WordNet ID。
²⁹ Adriana Kovashka 等人,“计算机视觉中的众包”,计算机图形学和视觉基础与趋势 10,第 3 期(2014):177-243。
³⁰ Jonathan Long 等人,“用于语义分割的全卷积网络”,IEEE 计算机视觉和模式识别会议论文集(2015):3431-3440。
³¹ 有一个小例外:使用"valid"填充的卷积层会在输入大小小于核大小时报错。
³² 这假设我们在网络中只使用了"same"填充:"valid"填充会减小特征图的大小。此外,448 可以被 2 整除多次,直到达到 7,没有任何舍入误差。如果任何一层使用不同于 1 或 2 的步幅,那么可能会有一些舍入误差,因此特征图最终可能会变小。
³³ Joseph Redmon 等人,“You Only Look Once: Unified, Real-Time Object Detection”,《IEEE 计算机视觉与模式识别会议论文集》(2016):779–788。
³⁴ 您可以在 TensorFlow Models 项目中找到 YOLOv3、YOLOv4 及其微小变体,网址为https://homl.info/yolotf。
³⁵ Wei Liu 等人,“SSD: Single Shot Multibox Detector”,《第 14 届欧洲计算机视觉会议论文集》1(2016):21–37。
³⁶ Shaoqing Ren 等人,“Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks”,《第 28 届国际神经信息处理系统会议论文集》1(2015):91–99。
³⁷ Mingxing Tan 等人,“EfficientDet: Scalable and Efficient Object Detection”,arXiv 预印本 arXiv:1911.09070(2019)。
³⁸ Nicolai Wojke 等人,“Simple Online and Realtime Tracking with a Deep Association Metric”,arXiv 预印本 arXiv:1703.07402(2017)。
³⁹ 这种类型的层有时被称为反卷积层,但它不执行数学家所说的反卷积,因此应避免使用这个名称。
⁴⁰ Kaiming He 等人,“Mask R-CNN”,arXiv 预印本 arXiv:1703.06870(2017)。
第十五章:使用 RNNs 和 CNNs 处理序列
预测未来是你经常做的事情,无论是在结束朋友的句子还是预期早餐时咖啡的味道。在本章中,我们将讨论循环神经网络(RNNs)-一类可以预测未来的网络(嗯,至少在一定程度上)。RNNs 可以分析时间序列数据,例如您网站上每日活跃用户的数量,您所在城市的每小时温度,您家每日的用电量,附近汽车的轨迹等等。一旦 RNN 学习了数据中的过去模式,它就能利用自己的知识来预测未来,当然前提是过去的模式在未来仍然成立。
更一般地说,RNNs 可以处理任意长度的序列,而不是固定大小的输入。例如,它们可以将句子、文档或音频样本作为输入,使它们非常适用于自然语言处理应用,如自动翻译或语音转文本。
在本章中,我们将首先介绍 RNNs 的基本概念以及如何使用时间反向传播来训练它们。然后,我们将使用它们来预测时间序列。在此过程中,我们将研究常用的 ARMA 模型系列,通常用于预测时间序列,并将它们用作与我们的 RNNs 进行比较的基准。之后,我们将探讨 RNNs 面临的两个主要困难:
-
不稳定的梯度(在第十一章中讨论),可以通过各种技术来缓解,包括循环丢失和循环层归一化。
-
(非常)有限的短期记忆,可以使用 LSTM 和 GRU 单元进行扩展。
RNNs 并不是处理序列数据的唯一类型的神经网络。对于小序列,常规的密集网络可以胜任,而对于非常长的序列,例如音频样本或文本,卷积神经网络也可以表现得相当不错。我们将讨论这两种可能性,并通过实现 WaveNet 来结束本章-一种能够处理数万个时间步的 CNN 架构。让我们开始吧!
循环神经元和层
到目前为止,我们已经专注于前馈神经网络,其中激活仅在一个方向中流动,从输入层到输出层。循环神经网络看起来非常像前馈神经网络,只是它还有指向后方的连接。
让我们看看最简单的 RNN,由一个神经元组成,接收输入,产生输出,并将该输出发送回自身,如图 15-1(左)所示。在每个时间步 t(也称为帧),这个循环神经元接收输入x[(t)]以及来自上一个时间步的自己的输出ŷ[(t–1)]。由于在第一个时间步没有先前的输出,通常将其设置为 0。我们可以沿着时间轴表示这个小网络,如图 15-1(右)所示。这被称为将网络展开到时间轴(每个时间步表示一个循环神经元)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1501.png
图 15-1. 一个循环神经元(左)在时间轴上展开(右)
您可以轻松创建一个循环神经元层。在每个时间步t,每个神经元都接收来自输入向量x[(t)]和上一个时间步的输出向量ŷ[(t–1)],如图 15-2 所示。请注意,现在输入和输出都是向量(当只有一个神经元时,输出是标量)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1502.png
图 15-2. 一个循环神经元层(左)在时间轴上展开(右)
每个递归神经元有两组权重:一组用于输入x[(t)],另一组用于上一个时间步的输出ŷ[(t–1)]。让我们称这些权重向量为w[x]和w[ŷ]。如果我们考虑整个递归层而不仅仅是一个递归神经元,我们可以将所有权重向量放入两个权重矩阵:W[x]和W[ŷ]。
整个递归层的输出向量可以按照你所期望的方式计算,如方程 15-1 所示,其中b是偏置向量,ϕ(·)是激活函数(例如,ReLU¹)。
方程 15-1. 单个实例的递归层输出
ŷ(t)=ϕWx⊺x(t)+Wŷ⊺ŷ(t-1)+b
就像前馈神经网络一样,我们可以通过将时间步t的所有输入放入输入矩阵X[(t)](参见方程 15-2)来一次性计算整个小批量的递归层输出。
方程 15-2. 一次传递中递归神经元层的所有实例的输出:[小批量
Ŷ (t) = ϕ X (t) W x + Ŷ (t-1) W ŷ + b = ϕ X (t) Ŷ (t-1) W + b with W = W x W ŷ
在这个方程中:
-
Ŷ[(t)]是一个m×n[neurons]矩阵,包含小批量中每个实例在时间步t的层输出(m是小批量中实例的数量,n[neurons]是神经元的数量)。
-
X[(t)]是一个m×n[inputs]矩阵,包含所有实例的输入(n[inputs]是输入特征的数量)。
-
W[x]是一个n[inputs]×n[neurons]矩阵,包含当前时间步输入的连接权重。
-
W[ŷ]是一个n[neurons]×n[neurons]矩阵,包含上一个时间步输出的连接权重。
-
b是一个大小为n[neurons]的向量,包含每个神经元的偏置项。
-
权重矩阵W[x]和W[ŷ]通常垂直连接成一个形状为(n[inputs] + n[neurons]) × n[neurons]的单个权重矩阵W(参见方程 15-2 的第二行)。
-
符号[X[(t)] Ŷ[(t–1)]]表示矩阵X[(t)]和Ŷ[(t–1)]的水平连接。
注意,Ŷ[(t)]是X[(t)]和Ŷ[(t–1)]的函数,X[(t–1)]和Ŷ[(t–2)]的函数,X[(t–2)]和Ŷ[(t–3)]的函数,依此类推。这使得Ŷ[(t)]是自时间t=0(即X[(0)], X[(1)], …, X[(t)])以来所有输入的函数。在第一个时间步骤,t=0 时,没有先前的输出,因此通常假定它们都是零。
记忆单元
由于递归神经元在时间步骤t的输出是前几个时间步骤的所有输入的函数,因此可以说它具有一种记忆形式。在时间步骤之间保留一些状态的神经网络的一部分称为记忆单元(或简称单元)。单个递归神经元或一层递归神经元是一个非常基本的单元,只能学习短模式(通常约为 10 个步骤长,但这取决于任务)。在本章后面,我们将看一些更复杂和强大的单元类型,能够学习更长的模式(大约长 10 倍,但这也取决于任务)。
时间步骤t时的单元状态,表示为h[(t)](“h”代表“隐藏”),是该时间步骤的一些输入和上一个时间步骤的状态的函数:h[(t)] = f(x[(t)], h[(t–1)])。在时间步骤t的输出,表示为ŷ[(t)],也是前一个状态和当前输入的函数。在我们迄今讨论的基本单元的情况下,输出只等于状态,但在更复杂的单元中,情况并非总是如此,如图 15-3 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1503.png
图 15-3。单元的隐藏状态和输出可能不同
输入和输出序列
RNN 可以同时接受一系列输入并产生一系列输出(参见图 15-4 左上方的网络)。这种序列到序列网络对于预测时间序列非常有用,例如您家每天的用电量:您向其提供过去N天的数据,并训练它输出将来一天的用电量(即从N – 1 天前到明天)。
或者,您可以向网络提供一系列输入并忽略除最后一个之外的所有输出(参见图 15-4 右上方的网络)。这是一个序列到向量网络。例如,您可以向网络提供与电影评论相对应的一系列单词,网络将输出情感分数(例如,从 0 [讨厌]到 1 [喜爱])。
相反,您可以在每个时间步骤反复向网络提供相同的输入向量,并让它输出一个序列(参见图 15-4 左下方的网络)。这是一个向量到序列网络。例如,输入可以是一幅图像(或 CNN 的输出),输出可以是该图像的标题。
最后,您可以有一个序列到向量网络,称为编码器,后面是一个向量到序列网络,称为解码器(参见图 15-4 的右下方网络)。例如,这可以用于将一种语言的句子翻译成另一种语言。您将向网络提供一种语言的句子,编码器将把这个句子转换成一个单一的向量表示,然后解码器将把这个向量解码成另一种语言的句子。这种两步模型,称为编码器-解码器²比尝试使用单个序列到序列的 RNN 实时翻译要好得多(就像左上角表示的那种):一个句子的最后几个词可能会影响翻译的前几个词,因此您需要等到看完整个句子后再进行翻译。我们将在第十六章中介绍编码器-解码器的实现(正如您将看到的,它比图 15-4 所暗示的要复杂一些)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1504.png
图 15-4. 序列到序列(左上)、序列到向量(右上)、向量到序列(左下)和编码器-解码器(右下)网络
这种多功能性听起来很有前途,但如何训练循环神经网络呢?
训练 RNNs
要训练 RNN,关键是将其通过时间展开(就像我们刚刚做的那样),然后使用常规的反向传播(参见图 15-5)。这种策略称为通过时间的反向传播(BPTT)。
就像常规反向传播一样,首先通过展开的网络进行第一次前向传递(由虚线箭头表示)。然后使用损失函数ℒ(Y[(0)], Y[(1)], …, Y[(T)]; Ŷ[(0)], Ŷ[(1)], …, Ŷ[(T)])评估输出序列(其中Y[(i)]是第i个目标,Ŷ[(i)]是第i个预测,T是最大时间步长)。请注意,此损失函数可能会忽略一些输出。例如,在序列到向量的 RNN 中,除了最后一个输出之外,所有输出都会被忽略。在图 15-5 中,损失函数仅基于最后三个输出计算。然后,该损失函数的梯度通过展开的网络向后传播(由实线箭头表示)。在这个例子中,由于输出Ŷ[(0)]和Ŷ[(1)]没有用于计算损失,梯度不会通过它们向后传播;它们只会通过Ŷ[(2)]、Ŷ[(3)]和Ŷ[(4)]向后传播。此外,由于在每个时间步骤中使用相同的参数W和b,它们的梯度将在反向传播过程中被多次调整。一旦反向阶段完成并计算出所有梯度,BPTT 可以执行梯度下降步骤来更新参数(这与常规反向传播没有区别)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1505.png
图 15-5. 通过时间反向传播
幸运的是,Keras 会为您处理所有这些复杂性,您将看到。但在我们到达那里之前,让我们加载一个时间序列,并开始使用传统工具进行分析,以更好地了解我们正在处理的内容,并获得一些基准指标。
预测时间序列
好了!假设您刚被芝加哥交通管理局聘为数据科学家。您的第一个任务是构建一个能够预测明天公交和轨道乘客数量的模型。您可以访问自 2001 年以来的日常乘客数据。让我们一起看看您将如何处理这个问题。我们将从加载和清理数据开始:
import pandas as pd
from pathlib import Path
path = Path("datasets/ridership/CTA_-_Ridership_-_Daily_Boarding_Totals.csv")
df = pd.read_csv(path, parse_dates=["service_date"])
df.columns = ["date", "day_type", "bus", "rail", "total"] # shorter names
df = df.sort_values("date").set_index("date")
df = df.drop("total", axis=1) # no need for total, it's just bus + rail
df = df.drop_duplicates() # remove duplicated months (2011-10 and 2014-07)
我们加载 CSV 文件,设置短列名,按日期对行进行排序,删除多余的total列,并删除重复行。现在让我们看看前几行是什么样子的:
>>> df.head()
day_type bus rail
date
2001-01-01 U 297192 126455
2001-01-02 W 780827 501952
2001-01-03 W 824923 536432
2001-01-04 W 870021 550011
2001-01-05 W 890426 557917
在 2001 年 1 月 1 日,芝加哥有 297,192 人乘坐公交车,126,455 人乘坐火车。day_type列包含W表示工作日,A表示周六,U表示周日或假期。
现在让我们绘制 2019 年几个月的公交和火车乘客量数据,看看它是什么样子的(参见图 15-6):
import matplotlib.pyplot as plt
df["2019-03":"2019-05"].plot(grid=True, marker=".", figsize=(8, 3.5))
plt.show()
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1506.png
图 15-6。芝加哥的日常乘客量
请注意,Pandas 在范围中包括起始月份和结束月份,因此这将绘制从 3 月 1 日到 5 月 31 日的数据。这是一个时间序列:在不同时间步长上具有值的数据,通常在规则间隔上。更具体地说,由于每个时间步长有多个值,因此称为多变量时间序列。如果我们只看bus列,那将是一个单变量时间序列,每个时间步长有一个值。在处理时间序列时,预测未来值(即预测)是最典型的任务,这也是我们将在本章中重点关注的内容。其他任务包括插补(填补缺失的过去值)、分类、异常检测等。
查看图 15-6,我们可以看到每周明显重复的类似模式。这被称为每周季节性。实际上,在这种情况下,季节性非常强,通过简单地复制一周前的值来预测明天的乘客量将产生相当不错的结果。这被称为天真预测:简单地复制过去的值来进行预测。天真预测通常是一个很好的基准,有时甚至在某些情况下很难超越。
注意
一般来说,天真预测意味着复制最新已知值(例如,预测明天与今天相同)。然而,在我们的情况下,复制上周的值效果更好,因为存在强烈的每周季节性。
为了可视化这些天真预测,让我们将两个时间序列(公交和火车)以及相同时间序列向右移动一周(即向右移动)的时间序列叠加使用虚线。我们还将绘制两者之间的差异(即时间t处的值减去时间t - 7 处的值);这称为差分(参见图 15-7):
diff_7 = df[["bus", "rail"]].diff(7)["2019-03":"2019-05"]
fig, axs = plt.subplots(2, 1, sharex=True, figsize=(8, 5))
df.plot(ax=axs[0], legend=False, marker=".") # original time series
df.shift(7).plot(ax=axs[0], grid=True, legend=False, linestyle=":") # lagged
diff_7.plot(ax=axs[1], grid=True, marker=".") # 7-day difference time series
plt.show()
不错!注意滞后时间序列如何紧密跟踪实际时间序列。当一个时间序列与其滞后版本相关联时,我们说该时间序列是自相关的。正如您所看到的,大多数差异都相当小,除了五月底。也许那时有一个假期?让我们检查day_type列:
>>> list(df.loc["2019-05-25":"2019-05-27"]["day_type"])
['A', 'U', 'U']
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1507.png
图 15-7。与 7 天滞后时间序列叠加的时间序列(顶部),以及t和t - 7 之间的差异(底部)
事实上,那时有一个长周末:周一是阵亡将士纪念日假期。我们可以使用这一列来改进我们的预测,但现在让我们只测量我们任意关注的三个月期间(2019 年 3 月、4 月和 5 月)的平均绝对误差,以获得一个大致的概念:
>>> diff_7.abs().mean()
bus 43915.608696
rail 42143.271739
dtype: float64
我们的天真预测得到大约 43,916 名公交乘客和约 42,143 名火车乘客的 MAE。一眼看去很难判断这是好是坏,所以让我们将预测误差放入透视中,通过将它们除以目标值来进行评估:
>>> targets = df[["bus", "rail"]]["2019-03":"2019-05"]
>>> (diff_7 / targets).abs().mean()
bus 0.082938
rail 0.089948
dtype: float64
我们刚刚计算的是平均绝对百分比误差(MAPE):看起来我们的天真预测为公交大约为 8.3%,火车为 9.0%。有趣的是,火车预测的 MAE 看起来比公交预测的稍好一些,而 MAPE 则相反。这是因为公交乘客量比火车乘客量大,因此自然预测误差也更大,但当我们将误差放入透视时,结果表明公交预测实际上略优于火车预测。
提示
MAE、MAPE 和 MSE 是评估预测的最常见指标之一。与往常一样,选择正确的指标取决于任务。例如,如果您的项目对大误差的影响是小误差的平方倍,那么 MSE 可能更可取,因为它会严厉惩罚大误差。
观察时间序列,似乎没有明显的月度季节性,但让我们检查一下是否存在年度季节性。我们将查看 2001 年至 2019 年的数据。为了减少数据窥探的风险,我们暂时忽略更近期的数据。让我们为每个系列绘制一个 12 个月的滚动平均线,以可视化长期趋势(参见图 15-8):
period = slice("2001", "2019")
df_monthly = df.resample('M').mean() # compute the mean for each month
rolling_average_12_months = df_monthly[period].rolling(window=12).mean()
fig, ax = plt.subplots(figsize=(8, 4))
df_monthly[period].plot(ax=ax, marker=".")
rolling_average_12_months.plot(ax=ax, grid=True, legend=False)
plt.show()
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1508.png
图 15-8。年度季节性和长期趋势
是的!确实存在一些年度季节性,尽管比每周季节性更嘈杂,对于铁路系列而言更为明显,而不是公交系列:我们看到每年大致相同日期出现高峰和低谷。让我们看看如果绘制 12 个月的差分会得到什么(参见图 15-9):
df_monthly.diff(12)[period].plot(grid=True, marker=".", figsize=(8, 3))
plt.show()
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1509.png
图 15-9。12 个月的差分
注意,差分不仅消除了年度季节性,还消除了长期趋势。例如,2016 年至 2019 年时间序列中存在的线性下降趋势在差分时间序列中变为大致恒定的负值。事实上,差分是一种常用的技术,用于消除时间序列中的趋势和季节性:研究平稳时间序列更容易,这意味着其统计特性随时间保持不变,没有任何季节性或趋势。一旦您能够对差分时间序列进行准确的预测,只需将先前减去的过去值添加回来,就可以将其转换为实际时间序列的预测。
您可能会认为我们只是试图预测明天的乘客量,因此长期模式比短期模式更不重要。您是对的,但是,通过考虑长期模式,我们可能能够稍微提高性能。例如,2017 年 10 月,每日公交乘客量减少了约 2500 人,这代表每周减少约 570 名乘客,因此如果我们处于 2017 年 10 月底,通过从上周复制数值,减去 570,来预测明天的乘客量是有道理的。考虑趋势将使您的平均预测略微更准确。
现在您熟悉了乘客量时间序列,以及时间序列分析中一些最重要的概念,包括季节性、趋势、差分和移动平均,让我们快速看一下一个非常流行的统计模型家族,通常用于分析时间序列。
ARMA 模型家族
我们将从上世纪 30 年代由赫尔曼·沃尔德(Herman Wold)开发的自回归移动平均(ARMA)模型开始:它通过对滞后值的简单加权和添加移动平均来计算其预测,非常类似我们刚刚讨论的。具体来说,移动平均分量是通过最近几个预测误差的加权和来计算的。方程 15-3 展示了该模型如何进行预测。
第 15-3 方程。使用 ARMA 模型进行预测
y^(t)=∑i=1pαiy(t-i)+∑i=1qθiϵ(t-i)with ϵ(t)=y(t)-y^(t)
在这个方程中:
-
ŷ[(t)]是模型对时间步t的预测。
-
y[(t)]是时间步t的时间序列值。
-
第一个总和是时间序列过去p个值的加权和,使用学习到的权重α[i]。数字p是一个超参数,它决定模型应该查看过去多远。这个总和是模型的自回归组件:它基于过去的值执行回归。
-
第二个总和是过去q个预测误差ε[(t)]的加权和,使用学习到的权重θ[i]。数字q是一个超参数。这个总和是模型的移动平均组件。
重要的是,这个模型假设时间序列是平稳的。如果不是,那么差分可能有所帮助。在一个时间步上使用差分将产生时间序列的导数的近似值:实际上,它将给出每个时间步的系列斜率。这意味着它将消除任何线性趋势,将其转换为一个常数值。例如,如果你对系列[3, 5, 7, 9, 11]应用一步差分,你会得到差分系列[2, 2, 2, 2]。
如果原始时间序列具有二次趋势而不是线性趋势,那么一轮差分将不足够。例如,系列[1, 4, 9, 16, 25, 36]经过一轮差分后变为[3, 5, 7, 9, 11],但如果你再进行第二轮差分,你会得到[2, 2, 2, 2]。因此,进行两轮差分将消除二次趋势。更一般地,连续运行d轮差分计算时间序列的d阶导数的近似值,因此它将消除多项式趋势直到d阶。这个超参数d被称为积分阶数。
差分是 1970 年由乔治·博克斯和格威林·詹金斯在他们的书《时间序列分析》(Wiley)中介绍的自回归积分移动平均(ARIMA)模型的核心贡献:这个模型运行d轮差分使时间序列更平稳,然后应用常规 ARMA 模型。在进行预测时,它使用这个 ARMA 模型,然后将差分减去的项加回来。
ARMA 家族的最后一个成员是季节性 ARIMA(SARIMA)模型:它以与 ARIMA 相同的方式对时间序列建模,但另外还为给定频率(例如每周)建模一个季节性组件,使用完全相同的 ARIMA 方法。它总共有七个超参数:与 ARIMA 相同的p、d和q超参数,再加上额外的P、D和Q超参数来建模季节性模式,最后是季节性模式的周期,标记为s。超参数P、D和Q就像p、d和q一样,但它们用于模拟时间序列在t – s、t – 2s、t – 3s等时刻。
让我们看看如何将 SARIMA 模型拟合到铁路时间序列,并用它来预测明天的乘客量。我们假设今天是 2019 年 5 月的最后一天,我们想要预测“明天”,也就是 2019 年 6 月 1 日的铁路乘客量。为此,我们可以使用statsmodels库,其中包含许多不同的统计模型,包括由ARIMA类实现的 ARMA 模型及其变体:
from statsmodels.tsa.arima.model import ARIMA
origin, today = "2019-01-01", "2019-05-31"
rail_series = df.loc[origin:today]["rail"].asfreq("D")
model = ARIMA(rail_series,
order=(1, 0, 0),
seasonal_order=(0, 1, 1, 7))
model = model.fit()
y_pred = model.forecast() # returns 427,758.6
在这个代码示例中:
-
我们首先导入
ARIMA类,然后我们从 2019 年初开始到“今天”获取铁路乘客数据,并使用asfreq("D")将时间序列的频率设置为每天:在这种情况下,这不会改变数据,因为它已经是每天的,但如果没有这个,ARIMA类将不得不猜测频率,并显示警告。 -
接下来,我们创建一个
ARIMA实例,将所有数据传递到“今天”,并设置模型超参数:order=(1, 0, 0)表示p=1,d=0,q=0,seasonal_order=(0, 1, 1, 7)表示P=0,D=1,Q=1,s=7。请注意,statsmodelsAPI 与 Scikit-Learn 的 API 有些不同,因为我们在构建时将数据传递给模型,而不是将数据传递给fit()方法。 -
接下来,我们拟合模型,并用它为“明天”,也就是 2019 年 6 月 1 日,做出预测。
预测为 427,759 名乘客,而实际上有 379,044 名。哎呀,我们偏差 12.9%——这相当糟糕。实际上,这比天真预测稍微糟糕,天真预测为 426,932,偏差为 12.6%。但也许那天我们只是运气不好?为了检查这一点,我们可以在循环中运行相同的代码,为三月、四月和五月的每一天进行预测,并计算该期间的平均绝对误差:
origin, start_date, end_date = "2019-01-01", "2019-03-01", "2019-05-31"
time_period = pd.date_range(start_date, end_date)
rail_series = df.loc[origin:end_date]["rail"].asfreq("D")
y_preds = []
for today in time_period.shift(-1):
model = ARIMA(rail_series[origin:today], # train on data up to "today"
order=(1, 0, 0),
seasonal_order=(0, 1, 1, 7))
model = model.fit() # note that we retrain the model every day!
y_pred = model.forecast()[0]
y_preds.append(y_pred)
y_preds = pd.Series(y_preds, index=time_period)
mae = (y_preds - rail_series[time_period]).abs().mean() # returns 32,040.7
啊,好多了!平均绝对误差约为 32,041,比我们用天真预测得到的平均绝对误差(42,143)显著低。因此,虽然模型并不完美,但平均而言仍然远远超过天真预测。
此时,您可能想知道如何为 SARIMA 模型选择良好的超参数。有几种方法,但最简单的方法是粗暴的方法:进行网格搜索。对于要评估的每个模型(即每个超参数组合),您可以运行前面的代码示例,仅更改超参数值。通常p、q、P和Q值较小(通常为 0 到 2,有时可达 5 或 6),d和D通常为 0 或 1,有时为 2。至于s,它只是主要季节模式的周期:在我们的情况下是 7,因为有强烈的每周季节性。具有最低平均绝对误差的模型获胜。当然,如果它更符合您的业务目标,您可以用另一个指标替换平均绝对误差。就是这样!
为机器学习模型准备数据
现在我们有了两个基线,天真预测和 SARIMA,让我们尝试使用迄今为止涵盖的机器学习模型来预测这个时间序列,首先从基本的线性模型开始。我们的目标是根据过去 8 周(56 天)的数据来预测明天的乘客量。因此,我们模型的输入将是序列(通常是生产中的每天一个序列),每个序列包含从时间步t - 55 到t的 56 个值。对于每个输入序列,模型将输出一个值:时间步t + 1 的预测。
但我们将使用什么作为训练数据呢?嗯,这就是诀窍:我们将使用过去的每个 56 天窗口作为训练数据,每个窗口的目标将是紧随其后的值。
Keras 实际上有一个很好的实用函数称为tf.keras.utils.timeseries_dataset_from_array(),帮助我们准备训练集。它以时间序列作为输入,并构建一个 tf.data.Dataset(在第十三章中介绍)包含所需长度的所有窗口,以及它们对应的目标。以下是一个示例,它以包含数字 0 到 5 的时间序列为输入,并创建一个包含所有长度为 3 的窗口及其对应目标的数据集,分组成大小为 2 的批次:
import tensorflow as tf
my_series = [0, 1, 2, 3, 4, 5]
my_dataset = tf.keras.utils.timeseries_dataset_from_array(
my_series,
targets=my_series[3:], # the targets are 3 steps into the future
sequence_length=3,
batch_size=2
)
让我们检查一下这个数据集的内容:
>>> list(my_dataset)
[(<tf.Tensor: shape=(2, 3), dtype=int32, numpy=
array([[0, 1, 2],
[1, 2, 3]], dtype=int32)>,
<tf.Tensor: shape=(2,), dtype=int32, numpy=array([3, 4], dtype=int32)>),
(<tf.Tensor: shape=(1, 3), dtype=int32, numpy=array([[2, 3, 4]], dtype=int32)>,
<tf.Tensor: shape=(1,), dtype=int32, numpy=array([5], dtype=int32)>)]
数据集中的每个样本是长度为 3 的窗口,以及其对应的目标(即窗口后面的值)。窗口是[0, 1, 2],[1, 2, 3]和[2, 3, 4],它们各自的目标是 3,4 和 5。由于总共有三个窗口,不是批次大小的倍数,最后一个批次只包含一个窗口而不是两个。
另一种获得相同结果的方法是使用 tf.data 的Dataset类的window()方法。这更复杂,但它给了您完全的控制,这将在本章后面派上用场,让我们看看它是如何工作的。window()方法返回一个窗口数据集的数据集:
>>> for window_dataset in tf.data.Dataset.range(6).window(4, shift=1):
... for element in window_dataset:
... print(f"{element}", end=" ")
... print()
...
0 1 2 3
1 2 3 4
2 3 4 5
3 4 5
4 5
5
在这个例子中,数据集包含六个窗口,每个窗口相对于前一个窗口向前移动一个步骤,最后三个窗口较小,因为它们已经到达系列的末尾。通常情况下,您会希望通过向window()方法传递drop_remainder=True来摆脱这些较小的窗口。
window()方法返回一个嵌套数据集,类似于一个列表的列表。当您想要通过调用其数据集方法(例如,对它们进行洗牌或分批处理)来转换每个窗口时,这将非常有用。然而,我们不能直接使用嵌套数据集进行训练,因为我们的模型将期望张量作为输入,而不是数据集。
因此,我们必须调用flat_map()方法:它将嵌套数据集转换为平坦数据集(包含张量而不是数据集)。例如,假设{1, 2, 3}表示包含张量 1、2 和 3 序列的数据集。如果展平嵌套数据集{{1, 2}, {3, 4, 5, 6}},您将得到平坦数据集{1, 2, 3, 4, 5, 6}。
此外,flat_map()方法接受一个函数作为参数,允许您在展平之前转换嵌套数据集中的每个数据集。例如,如果您将函数lambda ds: ds.batch(2)传递给flat_map(),那么它将把嵌套数据集{{1, 2}, {3, 4, 5, 6}}转换为平坦数据集{[1, 2], [3, 4], [5, 6]}:这是一个包含 3 个大小为 2 的张量的数据集。
考虑到这一点,我们准备对数据集进行展平处理:
>>> dataset = tf.data.Dataset.range(6).window(4, shift=1, drop_remainder=True)
>>> dataset = dataset.flat_map(lambda window_dataset: window_dataset.batch(4))
>>> for window_tensor in dataset:
... print(f"{window_tensor}")
...
[0 1 2 3]
[1 2 3 4]
[2 3 4 5]
由于每个窗口数据集恰好包含四个项目,对窗口调用batch(4)会产生一个大小为 4 的单个张量。太棒了!现在我们有一个包含连续窗口的数据集,表示为张量。让我们创建一个小助手函数,以便更容易地从数据集中提取窗口:
def to_windows(dataset, length):
dataset = dataset.window(length, shift=1, drop_remainder=True)
return dataset.flat_map(lambda window_ds: window_ds.batch(length))
最后一步是使用map()方法将每个窗口拆分为输入和目标。我们还可以将生成的窗口分组成大小为 2 的批次:
>>> dataset = to_windows(tf.data.Dataset.range(6), 4) # 3 inputs + 1 target = 4
>>> dataset = dataset.map(lambda window: (window[:-1], window[-1]))
>>> list(dataset.batch(2))
[(<tf.Tensor: shape=(2, 3), dtype=int64, numpy=
array([[0, 1, 2],
[1, 2, 3]])>,
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([3, 4])>),
(<tf.Tensor: shape=(1, 3), dtype=int64, numpy=array([[2, 3, 4]])>,
<tf.Tensor: shape=(1,), dtype=int64, numpy=array([5])>)]
正如您所看到的,我们现在得到了与之前使用timeseries_dataset_from_array()函数相同的输出(稍微费劲一些,但很快就会值得)。
现在,在开始训练之前,我们需要将数据分为训练期、验证期和测试期。我们现在将专注于铁路乘客量。我们还将通过一百万分之一的比例缩小它,以确保值接近 0-1 范围;这与默认的权重初始化和学习率很好地配合:
rail_train = df["rail"]["2016-01":"2018-12"] / 1e6
rail_valid = df["rail"]["2019-01":"2019-05"] / 1e6
rail_test = df["rail"]["2019-06":] / 1e6
注意
处理时间序列时,通常希望按时间划分。但在某些情况下,您可能能够沿其他维度划分,这将使您有更长的时间段进行训练。例如,如果您有关于 2001 年至 2019 年间 10,000 家公司财务状况的数据,您可能能够将这些数据分割到不同的公司。然而,很可能这些公司中的许多将强相关(例如,整个经济部门可能一起上涨或下跌),如果在训练集和测试集中有相关的公司,那么您的测试集将不会那么有用,因为其泛化误差的度量将是乐观偏倚的。
接下来,让我们使用timeseries_dataset_from_array()为训练和验证创建数据集。由于梯度下降期望训练集中的实例是独立同分布的(IID),正如我们在第四章中看到的那样,我们必须设置参数shuffle=True来对训练窗口进行洗牌(但不洗牌其中的内容):
seq_length = 56
train_ds = tf.keras.utils.timeseries_dataset_from_array(
rail_train.to_numpy(),
targets=rail_train[seq_length:],
sequence_length=seq_length,
batch_size=32,
shuffle=True,
seed=42
)
valid_ds = tf.keras.utils.timeseries_dataset_from_array(
rail_valid.to_numpy(),
targets=rail_valid[seq_length:],
sequence_length=seq_length,
batch_size=32
)
现在我们已经准备好构建和训练任何回归模型了!
使用线性模型进行预测
让我们首先尝试一个基本的线性模型。我们将使用 Huber 损失,通常比直接最小化 MAE 效果更好,如第十章中讨论的那样。我们还将使用提前停止:
tf.random.set_seed(42)
model = tf.keras.Sequential([
tf.keras.layers.Dense(1, input_shape=[seq_length])
])
early_stopping_cb = tf.keras.callbacks.EarlyStopping(
monitor="val_mae", patience=50, restore_best_weights=True)
opt = tf.keras.optimizers.SGD(learning_rate=0.02, momentum=0.9)
model.compile(loss=tf.keras.losses.Huber(), optimizer=opt, metrics=["mae"])
history = model.fit(train_ds, validation_data=valid_ds, epochs=500,
callbacks=[early_stopping_cb])
该模型达到了约 37,866 的验证 MAE(结果可能有所不同)。这比天真的预测要好,但比 SARIMA 模型要差。⁵
我们能用 RNN 做得更好吗?让我们看看!
使用简单 RNN 进行预测
让我们尝试最基本的 RNN,其中包含一个具有一个循环神经元的单个循环层,就像我们在图 15-1 中看到的那样:
model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(1, input_shape=[None, 1])
])
Keras 中的所有循环层都期望形状为[批量大小,时间步长,维度]的 3D 输入,其中维度对于单变量时间序列为 1,对于多变量时间序列为更多。请记住,input_shape参数忽略第一个维度(即批量大小),由于循环层可以接受任意长度的输入序列,因此我们可以将第二个维度设置为None,表示“任意大小”。最后,由于我们处理的是单变量时间序列,我们需要最后一个维度的大小为 1。这就是为什么我们指定输入形状为[None, 1]:它表示“任意长度的单变量序列”。请注意,数据集实际上包含形状为[批量大小,时间步长]的输入,因此我们缺少最后一个维度,大小为 1,但在这种情况下,Keras 很友好地为我们添加了它。
这个模型的工作方式与我们之前看到的完全相同:初始状态h[(init)]设置为 0,并传递给一个单个的循环神经元,以及第一个时间步的值x[(0)]。神经元计算这些值加上偏置项的加权和,并使用默认的双曲正切函数对结果应用激活函数。结果是第一个输出y[0]。在简单 RNN 中,这个输出也是新状态h[0]。这个新状态传递给相同的循环神经元,以及下一个输入值x[(1)],并且这个过程重复直到最后一个时间步。最后,该层只输出最后一个值:在我们的情况下,序列长度为 56 步,因此最后一个值是y[55]。所有这些都同时为批次中的每个序列执行,本例中有 32 个序列。
注意
默认情况下,Keras 中的循环层只返回最终输出。要使它们返回每个时间步的一个输出,您必须设置return_sequences=True,如您将看到的。
这就是我们的第一个循环模型!这是一个序列到向量的模型。由于只有一个输出神经元,输出向量的大小为 1。
现在,如果您编译、训练和评估这个模型,就像之前的模型一样,您会发现它一点用也没有:其验证 MAE 大于 100,000!哎呀。这是可以预料到的,有两个原因:
-
该模型只有一个循环神经元,因此在每个时间步进行预测时,它只能使用当前时间步的输入值和上一个时间步的输出值。这不足以进行预测!换句话说,RNN 的记忆极为有限:只是一个数字,它的先前输出。让我们来数一下这个模型有多少参数:由于只有一个循环神经元,只有两个输入值,整个模型只有三个参数(两个权重加上一个偏置项)。这对于这个时间序列来说远远不够。相比之下,我们之前的模型可以一次查看所有 56 个先前的值,并且总共有 57 个参数。
-
时间序列包含的值从 0 到约 1.4,但由于默认激活函数是 tanh,循环层只能输出-1 到+1 之间的值。它无法预测 1.0 到 1.4 之间的值。
让我们解决这两个问题:我们将创建一个具有更大的循环层的模型,其中包含 32 个循环神经元,并在其顶部添加一个密集的输出层,其中只有一个输出神经元,没有激活函数。循环层将能够在一个时间步到下一个时间步传递更多信息,而密集输出层将把最终输出从 32 维投影到 1 维,没有任何值范围约束:
univar_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, input_shape=[None, 1]),
tf.keras.layers.Dense(1) # no activation function by default
])
现在,如果您像之前那样编译、拟合和评估这个模型,您会发现其验证 MAE 达到了 27,703。这是迄今为止我们训练过的最佳模型,甚至击败了 SARIMA 模型:我们做得相当不错!
提示
我们只对时间序列进行了归一化,没有去除趋势和季节性,但模型仍然表现良好。这很方便,因为这样可以快速搜索有前途的模型,而不用太担心预处理。然而,为了获得最佳性能,您可能希望尝试使时间序列更加平稳;例如,使用差分。
使用深度 RNN 进行预测
通常会堆叠多层单元,如图 15-10 所示。这给你一个深度 RNN。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1510.png
图 15-10. 深度 RNN(左)在时间轴上展开(右)
使用 Keras 实现深度 RNN 很简单:只需堆叠循环层。在下面的示例中,我们使用三个SimpleRNN层(但我们也可以使用任何其他类型的循环层,如LSTM层或GRU层,我们将很快讨论)。前两个是序列到序列层,最后一个是序列到向量层。最后,Dense层生成模型的预测(您可以将其视为向量到向量层)。因此,这个模型就像图 15-10 中表示的模型一样,只是忽略了Ŷ[(0)]到Ŷ[(t–1_)]的输出,并且在Ŷ[(t)]之上有一个密集层,输出实际预测:
deep_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 1]),
tf.keras.layers.SimpleRNN(32, return_sequences=True),
tf.keras.layers.SimpleRNN(32),
tf.keras.layers.Dense(1)
])
警告
确保对所有循环层设置return_sequences=True(除非您只关心最后的输出,最后一个循环层除外)。如果您忘记为一个循环层设置此参数,它将输出一个 2D 数组,其中仅包含最后一个时间步的输出,而不是包含所有时间步输出的 3D 数组。下一个循环层将抱怨您没有以预期的 3D 格式提供序列。
如果您训练和评估这个模型,您会发现它的 MAE 约为 31,211。这比两个基线都要好,但它并没有击败我们的“更浅”的 RNN。看起来这个 RNN 对我们的任务来说有点太大了。
多变量时间序列预测
神经网络的一个很大的优点是它们的灵活性:特别是,它们几乎不需要改变架构就可以处理多变量时间序列。例如,让我们尝试使用公交和铁路数据作为输入来预测铁路时间序列。事实上,让我们也加入日期类型!由于我们总是可以提前知道明天是工作日、周末还是假日,我们可以将日期类型系列向未来推移一天,这样模型就会将明天的日期类型作为输入。为简单起见,我们将使用 Pandas 进行此处理:
df_mulvar = df[["bus", "rail"]] / 1e6 # use both bus & rail series as input
df_mulvar["next_day_type"] = df["day_type"].shift(-1) # we know tomorrow's type
df_mulvar = pd.get_dummies(df_mulvar) # one-hot encode the day type
现在 df_mulvar 是一个包含五列的 DataFrame:公交和铁路数据,以及包含下一天类型的独热编码的三列(请记住,有三种可能的日期类型,W、A 和 U)。接下来我们可以像之前一样继续。首先,我们将数据分为三个时期,用于训练、验证和测试:
mulvar_train = df_mulvar["2016-01":"2018-12"]
mulvar_valid = df_mulvar["2019-01":"2019-05"]
mulvar_test = df_mulvar["2019-06":]
然后我们创建数据集:
train_mulvar_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_train.to_numpy(), # use all 5 columns as input
targets=mulvar_train["rail"][seq_length:], # forecast only the rail series
[...] # the other 4 arguments are the same as earlier
)
valid_mulvar_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_valid.to_numpy(),
targets=mulvar_valid["rail"][seq_length:],
[...] # the other 2 arguments are the same as earlier
)
最后我们创建 RNN:
mulvar_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, input_shape=[None, 5]),
tf.keras.layers.Dense(1)
])
请注意,与我们之前构建的 univar_model RNN 唯一的区别是输入形状:在每个时间步骤,模型现在接收五个输入,而不是一个。这个模型实际上达到了 22,062 的验证 MAE。现在我们取得了很大的进展!
事实上,让 RNN 预测公交和铁路乘客量并不太难。您只需要在创建数据集时更改目标,将其设置为训练集的 mulvar_train[["bus", "rail"]][seq_length:],验证集的 mulvar_valid[["bus", "rail"]][seq_length:]。您还必须在输出 Dense 层中添加一个额外的神经元,因为现在它必须进行两次预测:一次是明天的公交乘客量,另一次是铁路乘客量。就是这样!
正如我们在第十章中讨论的那样,对于多个相关任务使用单个模型通常比为每个任务使用单独的模型效果更好,因为为一个任务学习的特征可能对其他任务也有用,而且因为在多个任务中表现良好可以防止模型过拟合(这是一种正则化形式)。然而,这取决于任务,在这种特殊情况下,同时预测公交和铁路乘客量的多任务 RNN 并不像专门预测其中一个的模型表现得那么好(使用所有五列作为输入)。尽管如此,它对铁路的验证 MAE 达到了 25,330,对公交达到了 26,369,这还是相当不错的。
提前预测多个时间步
到目前为止,我们只预测了下一个时间步的值,但我们也可以通过适当更改目标来预测几个步骤之后的值(例如,要预测两周后的乘客量,我们只需将目标更改为比 1 天后提前 14 天的值)。但是如果我们想预测接下来的 14 个值呢?
第一种选择是取我们之前为铁路时间序列训练的 univar_model RNN,让它预测下一个值,并将该值添加到输入中,就好像预测的值实际上已经发生了;然后我们再次使用模型来预测下一个值,依此类推,如下面的代码所示:
import numpy as np
X = rail_valid.to_numpy()[np.newaxis, :seq_length, np.newaxis]
for step_ahead in range(14):
y_pred_one = univar_model.predict(X)
X = np.concatenate([X, y_pred_one.reshape(1, 1, 1)], axis=1)
在这段代码中,我们取验证期间前 56 天的铁路乘客量,并将数据转换为形状为 [1, 56, 1] 的 NumPy 数组(请记住,循环层期望 3D 输入)。然后我们重复使用模型来预测下一个值,并将每个预测附加到输入系列中,沿着时间轴(axis=1)。生成的预测在图 15-11 中绘制。
警告
如果模型在一个时间步骤上出现错误,那么接下来的时间步骤的预测也会受到影响:错误往往会累积。因此,最好只在少数步骤中使用这种技术。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1511.png
图 15-11。提前 14 步预测,一次预测一步
第二个选项是训练一个 RNN 一次性预测接下来的 14 个值。我们仍然可以使用一个序列到向量模型,但它将输出 14 个值而不是 1。然而,我们首先需要改变目标,使其成为包含接下来 14 个值的向量。为此,我们可以再次使用timeseries_dataset_from_array(),但这次要求它创建没有目标(targets=None)的数据集,并且具有更长的序列,长度为seq_length + 14。然后我们可以使用数据集的map()方法对每个序列批次应用自定义函数,将其分成输入和目标。在这个例子中,我们使用多变量时间序列作为输入(使用所有五列),并预测未来 14 天的铁路乘客量。
def split_inputs_and_targets(mulvar_series, ahead=14, target_col=1):
return mulvar_series[:, :-ahead], mulvar_series[:, -ahead:, target_col]
ahead_train_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_train.to_numpy(),
targets=None,
sequence_length=seq_length + 14,
[...] # the other 3 arguments are the same as earlier
).map(split_inputs_and_targets)
ahead_valid_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_valid.to_numpy(),
targets=None,
sequence_length=seq_length + 14,
batch_size=32
).map(split_inputs_and_targets)
现在我们只需要将输出层的单元数从 1 增加到 14:
ahead_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
训练完这个模型后,你可以像这样一次性预测接下来的 14 个值:
X = mulvar_valid.to_numpy()[np.newaxis, :seq_length] # shape [1, 56, 5]
Y_pred = ahead_model.predict(X) # shape [1, 14]
这种方法效果相当不错。它对于第二天的预测显然比对未来 14 天的预测要好,但它不会像之前的方法那样累积误差。然而,我们仍然可以做得更好,使用一个序列到序列(或seq2seq)模型。
使用序列到序列模型进行预测
不是只在最后一个时间步训练模型来预测接下来的 14 个值,而是在每一个时间步都训练它来预测接下来的 14 个值。换句话说,我们可以将这个序列到向量的 RNN 转变为一个序列到序列的 RNN。这种技术的优势在于损失函数将包含 RNN 在每一个时间步的输出,而不仅仅是最后一个时间步的输出。
这意味着会有更多的误差梯度通过模型流动,它们不需要像以前那样通过时间流动,因为它们将来自每一个时间步的输出,而不仅仅是最后一个时间步。这将使训练更加稳定和快速。
明确一点,在时间步 0,模型将输出一个包含时间步 1 到 14 的预测的向量,然后在时间步 1,模型将预测时间步 2 到 15,依此类推。换句话说,目标是连续窗口的序列,每个时间步向后移动一个时间步。目标不再是一个向量,而是一个与输入相同长度的序列,每一步包含一个 14 维向量。
准备数据集并不是简单的,因为每个实例的输入是一个窗口,输出是窗口序列。一种方法是连续两次使用我们之前创建的to_windows()实用函数,以获得连续窗口的窗口。例如,让我们将数字 0 到 6 的系列转换为包含 4 个连续窗口的数据集,每个窗口长度为 3:
>>> my_series = tf.data.Dataset.range(7)
>>> dataset = to_windows(to_windows(my_series, 3), 4)
>>> list(dataset)
[<tf.Tensor: shape=(4, 3), dtype=int64, numpy=
array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4],
[3, 4, 5]])>,
<tf.Tensor: shape=(4, 3), dtype=int64, numpy=
array([[1, 2, 3],
[2, 3, 4],
[3, 4, 5],
[4, 5, 6]])>]
现在我们可以使用map()方法将这些窗口的窗口分割为输入和目标:
>>> dataset = dataset.map(lambda S: (S[:, 0], S[:, 1:]))
>>> list(dataset)
[(<tf.Tensor: shape=(4,), dtype=int64, numpy=array([0, 1, 2, 3])>,
<tf.Tensor: shape=(4, 2), dtype=int64, numpy=
array([[1, 2],
[2, 3],
[3, 4],
[4, 5]])>),
(<tf.Tensor: shape=(4,), dtype=int64, numpy=array([1, 2, 3, 4])>,
<tf.Tensor: shape=(4, 2), dtype=int64, numpy=
array([[2, 3],
[3, 4],
[4, 5],
[5, 6]])>)]
现在数据集包含长度为 4 的输入序列,目标是包含下两个步骤的序列,每个时间步。例如,第一个输入序列是[0, 1, 2, 3],对应的目标是[[1, 2], [2, 3], [3, 4], [4, 5]],这是每个时间步的下两个值。如果你和我一样,可能需要几分钟来理解这个概念。慢慢来!
注意
也许令人惊讶的是,目标值包含在输入中出现的值。这是不是作弊?幸运的是,完全不是:在每一个时间步,RNN 只知道过去的时间步;它无法向前看。它被称为因果模型。
让我们创建另一个小型实用函数来为我们的序列到序列模型准备数据集。它还会负责洗牌(可选)和分批处理:
def to_seq2seq_dataset(series, seq_length=56, ahead=14, target_col=1,
batch_size=32, shuffle=False, seed=None):
ds = to_windows(tf.data.Dataset.from_tensor_slices(series), ahead + 1)
ds = to_windows(ds, seq_length).map(lambda S: (S[:, 0], S[:, 1:, 1]))
if shuffle:
ds = ds.shuffle(8 * batch_size, seed=seed)
return ds.batch(batch_size)
现在我们可以使用这个函数来创建数据集:
seq2seq_train = to_seq2seq_dataset(mulvar_train, shuffle=True, seed=42)
seq2seq_valid = to_seq2seq_dataset(mulvar_valid)
最后,我们可以构建序列到序列模型:
seq2seq_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
这几乎与我们之前的模型完全相同:唯一的区别是在SimpleRNN层中设置了return_sequences=True。这样,它将输出一个向量序列(每个大小为 32),而不是在最后一个时间步输出单个向量。Dense层足够聪明,可以处理序列作为输入:它将在每个时间步应用,以 32 维向量作为输入,并输出 14 维向量。实际上,获得完全相同结果的另一种方法是使用具有核大小为 1 的Conv1D层:Conv1D(14, kernel_size=1)。
提示
Keras 提供了一个TimeDistributed层,允许您将任何向量到向量层应用于输入序列中的每个向量,在每个时间步。它通过有效地重新塑造输入来实现这一点,以便将每个时间步视为单独的实例,然后重新塑造层的输出以恢复时间维度。在我们的情况下,我们不需要它,因为Dense层已经支持序列作为输入。
训练代码与往常一样。在训练期间,使用所有模型的输出,但在训练后,只有最后一个时间步的输出才重要,其余可以忽略。例如,我们可以这样预测未来 14 天的铁路乘客量:
X = mulvar_valid.to_numpy()[np.newaxis, :seq_length]
y_pred_14 = seq2seq_model.predict(X)[0, -1] # only the last time step's output
如果评估此模型对t + 1 的预测,您将发现验证 MAE 为 25,519。对于t + 2,它为 26,274,随着模型试图进一步预测未来,性能会逐渐下降。在t + 14 时,MAE 为 34,322。
提示
您可以结合两种方法来预测多步:例如,您可以训练一个模型,预测未来 14 天,然后将其输出附加到输入,然后再次运行模型,以获取接下来 14 天的预测,并可能重复该过程。
简单的 RNN 在预测时间序列或处理其他类型的序列时可能表现得很好,但在长时间序列或序列上表现不佳。让我们讨论一下原因,并看看我们能做些什么。
处理长序列
要在长序列上训练 RNN,我们必须在许多时间步上运行它,使展开的 RNN 成为一个非常深的网络。就像任何深度神经网络一样,它可能会遇到不稳定的梯度问题,如第十一章中讨论的:可能需要很长时间来训练,或者训练可能不稳定。此外,当 RNN 处理长序列时,它将逐渐忘记序列中的第一个输入。让我们从不稳定的梯度问题开始,看看这两个问题。
解决不稳定梯度问题
许多我们在深度网络中用来缓解不稳定梯度问题的技巧也可以用于 RNN:良好的参数初始化,更快的优化器,辍学等。然而,非饱和激活函数(例如 ReLU)在这里可能不会有太大帮助。实际上,它们可能会导致 RNN 在训练过程中更加不稳定。为什么?嗯,假设梯度下降以一种增加第一个时间步输出的方式更新权重。由于相同的权重在每个时间步使用,第二个时间步的输出也可能略有增加,第三个时间步也是如此,直到输出爆炸——而非饱和激活函数无法阻止这种情况。
您可以通过使用较小的学习率来减少这种风险,或者可以使用饱和激活函数,如双曲正切(这解释了为什么它是默认值)。
同样,梯度本身也可能爆炸。如果注意到训练不稳定,可以监控梯度的大小(例如,使用 TensorBoard),并可能使用梯度裁剪。
此外,批量归一化不能像深度前馈网络那样有效地与 RNN 一起使用。实际上,您不能在时间步之间使用它,只能在循环层之间使用。
更准确地说,从技术上讲,可以在内存单元中添加一个 BN 层(您很快就会看到),以便在每个时间步上应用它(既在该时间步的输入上,也在上一个步骤的隐藏状态上)。然而,相同的 BN 层将在每个时间步上使用相同的参数,而不考虑输入和隐藏状态的实际比例和偏移。实际上,这并不会产生良好的结果,如 César Laurent 等人在2015 年的一篇论文中所证明的:作者发现,只有当 BN 应用于层的输入时才略有益处,而不是应用于隐藏状态。换句话说,当应用于循环层之间(即在图 15-10 中垂直地)时,它略好于什么都不做,但不适用于循环层内部(即水平地)。在 Keras 中,您可以通过在每个循环层之前添加一个 BatchNormalization 层来简单地在层之间应用 BN,但这会减慢训练速度,并且可能帮助不大。
另一种规范化方法在 RNN 中通常效果更好:层归一化。这个想法是由 Jimmy Lei Ba 等人在2016 年的一篇论文中提出的:它与批归一化非常相似,但不同的是,层归一化是在特征维度上进行归一化,而不是在批次维度上。一个优点是它可以在每个时间步上独立地为每个实例计算所需的统计数据。这也意味着它在训练和测试期间的行为是相同的(与 BN 相反),它不需要使用指数移动平均来估计训练集中所有实例的特征统计数据,就像 BN 那样。与 BN 类似,层归一化为每个输入学习一个比例和偏移参数。在 RNN 中,它通常在输入和隐藏状态的线性组合之后立即使用。
让我们使用 Keras 在简单内存单元中实现层归一化。为此,我们需要定义一个自定义内存单元,它就像一个常规层一样,只是它的 call() 方法接受两个参数:当前时间步的 inputs 和上一个时间步的隐藏 states。
请注意,states 参数是一个包含一个或多个张量的列表。在简单的 RNN 单元中,它包含一个张量,等于上一个时间步的输出,但其他单元可能有多个状态张量(例如,LSTMCell 有一个长期状态和一个短期状态,您很快就会看到)。一个单元还必须有一个 state_size 属性和一个 output_size 属性。在简单的 RNN 中,两者都简单地等于单元的数量。以下代码实现了一个自定义内存单元,它将表现得像一个 SimpleRNNCell,但它还会在每个时间步应用层归一化:
class LNSimpleRNNCell(tf.keras.layers.Layer):
def __init__(self, units, activation="tanh", **kwargs):
super().__init__(**kwargs)
self.state_size = units
self.output_size = units
self.simple_rnn_cell = tf.keras.layers.SimpleRNNCell(units,
activation=None)
self.layer_norm = tf.keras.layers.LayerNormalization()
self.activation = tf.keras.activations.get(activation)
def call(self, inputs, states):
outputs, new_states = self.simple_rnn_cell(inputs, states)
norm_outputs = self.activation(self.layer_norm(outputs))
return norm_outputs, [norm_outputs]
让我们来看一下这段代码:
-
我们的
LNSimpleRNNCell类继承自tf.keras.layers.Layer类,就像任何自定义层一样。 -
构造函数接受单位数和所需的激活函数,并设置
state_size和output_size属性,然后创建一个没有激活函数的SimpleRNNCell(因为我们希望在线性操作之后但在激活函数之前执行层归一化)。然后构造函数创建LayerNormalization层,最后获取所需的激活函数。 -
call()方法首先应用simpleRNNCell,它计算当前输入和先前隐藏状态的线性组合,并返回结果两次(实际上,在SimpleRNNCell中,输出就等于隐藏状态:换句话说,new_states[0]等于outputs,因此我们可以在call()方法的其余部分安全地忽略new_states)。接下来,call()方法应用层归一化,然后是激活函数。最后,它将输出返回两次:一次作为输出,一次作为新的隐藏状态。要使用此自定义细胞,我们只需要创建一个tf.keras.layers.RNN层,将其传递给一个细胞实例:
custom_ln_model = tf.keras.Sequential([
tf.keras.layers.RNN(LNSimpleRNNCell(32), return_sequences=True,
input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
同样,您可以创建一个自定义细胞,在每个时间步之间应用 dropout。但有一个更简单的方法:Keras 提供的大多数循环层和细胞都有dropout和recurrent_dropout超参数:前者定义要应用于输入的 dropout 率,后者定义隐藏状态之间的 dropout 率,即时间步之间。因此,在 RNN 中不需要创建自定义细胞来在每个时间步应用 dropout。
通过这些技术,您可以缓解不稳定梯度问题,并更有效地训练 RNN。现在让我们看看如何解决短期记忆问题。
提示
在预测时间序列时,通常有必要在预测中包含一些误差范围。为此,一种方法是使用 MC dropout,介绍在第十一章中:在训练期间使用recurrent_dropout,然后在推断时通过使用model(X, training=True)来保持 dropout 处于活动状态。多次重复此操作以获得多个略有不同的预测,然后计算每个时间步的这些预测的均值和标准差。
解决短期记忆问题
由于数据在经过 RNN 时经历的转换,每个时间步都会丢失一些信息。过一段时间后,RNN 的状态几乎不包含最初输入的任何痕迹。这可能是一个停滞不前的问题。想象一下多莉鱼试图翻译一句长句子;当她读完时,她已经不记得它是如何开始的。为了解决这个问题,引入了各种具有长期记忆的细胞类型。它们已经被证明非常成功,以至于基本细胞不再被广泛使用。让我们首先看看这些长期记忆细胞中最受欢迎的:LSTM 细胞。
LSTM 细胞
长短期记忆(LSTM)细胞是由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1997 年提出的,并在多年来逐渐得到了几位研究人员的改进,如 Alex Graves,Haşim Sak 和 Wojciech Zaremba。如果将 LSTM 细胞视为黑匣子,它可以被用作基本细胞,只是它的性能会更好;训练会更快收敛,并且它会检测数据中更长期的模式。在 Keras 中,您可以简单地使用LSTM层而不是SimpleRNN层:
model = tf.keras.Sequential([
tf.keras.layers.LSTM(32, return_sequences=True, input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
或者,您可以使用通用的tf.keras.layers.RNN层,将LSTMCell作为参数传递给它。但是,当在 GPU 上运行时,LSTM层使用了优化的实现(请参阅第十九章),因此通常最好使用它(RNN层在定义自定义细胞时非常有用,就像我们之前做的那样)。
那么 LSTM 细胞是如何工作的呢?其架构显示在图 15-12 中。如果不看盒子里面的内容,LSTM 细胞看起来与常规细胞完全相同,只是其状态分为两个向量:h[(t)]和c[(t)](“c”代表“cell”)。您可以将h[(t)]视为短期状态,将c[(t)]视为长期状态。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1512.png
图 15-12. LSTM 单元
现在让我们打开盒子!关键思想是网络可以学习将什么存储在长期状态中,什么丢弃,以及从中读取什么。当长期状态c[(t–1)]从左到右穿过网络时,您可以看到它首先经过一个遗忘门,丢弃一些记忆,然后通过加法操作添加一些新的记忆(通过输入门选择的记忆)。结果c[(t)]直接发送出去,没有进一步的转换。因此,在每个时间步骤,一些记忆被丢弃,一些记忆被添加。此外,在加法操作之后,长期状态被复制并通过 tanh 函数传递,然后结果由输出门过滤。这产生了短期状态h[(t)](这等于此时间步骤的单元输出y[(t))。现在让我们看看新记忆来自哪里以及门是如何工作的。
首先,当前输入向量x[(t)]和先前的短期状态h[(t–1)]被馈送到四个不同的全连接层。它们各自有不同的作用:
-
主要层是输出g[(t)]的层。它通常的作用是分析当前输入x[(t)]和先前(短期)状态h[(t–1)]。在基本单元中,除了这一层外没有其他内容,其输出直接发送到y[(t)]和h[(t)]。但在 LSTM 单元中,这一层的输出不会直接输出;相反,其最重要的部分存储在长期状态中(其余部分被丢弃)。
-
其他三个层是门控制器。由于它们使用逻辑激活函数,输出范围从 0 到 1。正如您所看到的,门控制器的输出被馈送到逐元素乘法操作:如果它们输出 0,则关闭门,如果它们输出 1,则打开门。具体来说:
-
遗忘门(由f[(t)]控制)控制着应该擦除长期状态的哪些部分。
-
输入门(由i[(t)]控制)控制着应该将g[(t)]的哪些部分添加到长期状态中。
-
最后,输出门(由o[(t)]控制)控制着长期状态的哪些部分应该在此时间步骤被读取并输出,既输出到h[(t)],也输出到y[(t)]。
-
简而言之,LSTM 单元可以学习识别重要的输入(这是输入门的作用),将其存储在长期状态中,保留它直到需要(这是遗忘门的作用),并在需要时提取它。这解释了为什么这些单元在捕捉时间序列、长文本、音频记录等长期模式方面取得了惊人的成功。
方程 15-4 总结了如何计算单元的长期状态、短期状态以及每个时间步骤的输出,针对单个实例(整个小批量的方程非常相似)。
方程 15-4. LSTM 计算
i (t) = σ ( W xi ⊺ x (t) + W hi ⊺ h (t-1) + b i ) f (t) = σ ( W xf ⊺ x (t) + W hf ⊺ h (t-1) + b f ) o (t) = σ ( W xo ⊺ x (t) + W ho ⊺ h (t-1) + b o ) g (t) = tanh ( W xg ⊺ x (t) + W hg ⊺ h (t-1) + b g ) c (t) = f (t) ⊗ c (t-1) + i (t) ⊗ g (t) y (t) = h (t) = o (t) ⊗ tanh ( c (t) )
在这个方程中:
-
W[xi]、W[xf]、W[xo]和W[xg]是每个四层的权重矩阵,用于它们与输入向量x[(t)]的连接。
-
W[hi]、W[hf]、W[ho]和W[hg]是每个四层的权重矩阵,用于它们与先前的短期状态h[(t–1)]的连接。
-
b[i]、b[f]、b[o]和b[g]是每个四层的偏置项。请注意,TensorFlow 将b[f]初始化为一个全为 1 的向量,而不是 0。这可以防止在训练开始时忘记所有内容。
LSTM 单元有几个变体。一个特别流行的变体是 GRU 单元,我们现在将看一下。
GRU 单元
门控循环单元 (GRU)单元(见图 15-13)由 Kyunghyun Cho 等人在2014 年的一篇论文中提出,该论文还介绍了我们之前讨论过的编码器-解码器网络。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1513.png
图 15-13. GRU 单元
GRU 单元是 LSTM 单元的简化版本,看起来表现同样出色(这解释了它日益增长的受欢迎程度)。这些是主要的简化:
-
两个状态向量合并成一个单一向量h[(t)]。
-
一个单一的门控制器z[(t)]控制遗忘门和输入门。如果门控制器输出 1,则遗忘门打开(= 1),输入门关闭(1 - 1 = 0)。如果输出 0,则相反发生。换句话说,每当必须存储一个记忆时,将首先擦除将存储它的位置。这实际上是 LSTM 单元的一个常见变体。
-
没有输出门;完整状态向量在每个时间步输出。然而,有一个新的门控制器r[(t)],控制哪部分先前状态将被显示给主层(g[(t)])。
方程 15-5 总结了如何计算每个时间步的单个实例的单元状态。
方程 15-5. GRU 计算
z (t) = σ ( W xz ⊺ x (t) + W hz ⊺ h (t-1) + bz ) r (t) = σ ( W xr ⊺ x (t) + W hr ⊺ h (t-1) + br ) g (t) = tanh W xg ⊺ x (t) + W hg ⊺ ( r (t) ⊗ h (t-1) ) + bg h (t) = z (t) ⊗ h (t-1) + ( 1 - z (t) ) ⊗ g (t)
Keras 提供了一个tf.keras.layers.GRU层:使用它只需要将SimpleRNN或LSTM替换为GRU。它还提供了一个tf.keras.layers.GRUCell,以便根据 GRU 单元创建自定义单元。
LSTM 和 GRU 单元是 RNN 成功的主要原因之一。然而,虽然它们可以处理比简单 RNN 更长的序列,但它们仍然有相当有限的短期记忆,并且很难学习 100 个时间步或更多的序列中的长期模式,例如音频样本、长时间序列或长句子。解决这个问题的一种方法是缩短输入序列;例如,使用 1D 卷积层。
使用 1D 卷积层处理序列
在第十四章中,我们看到 2D 卷积层通过在图像上滑动几个相当小的卷积核(或滤波器),产生多个 2D 特征图(每个卷积核一个)。类似地,1D 卷积层在序列上滑动多个卷积核,每个卷积核产生一个 1D 特征图。每个卷积核将学习检测单个非常短的连续模式(不超过卷积核大小)。如果使用 10 个卷积核,则该层的输出将由 10 个 1D 序列组成(长度相同),或者您可以将此输出视为单个 10D 序列。这意味着您可以构建一个由循环层和 1D 卷积层(甚至 1D 池化层)组成的神经网络。如果使用步幅为 1 和"same"填充的 1D 卷积层,则输出序列的长度将与输入序列的长度相同。但是,如果使用"valid"填充或大于 1 的步幅,则输出序列将短于输入序列,因此请确保相应调整目标。
例如,以下模型与之前的模型相同,只是它以一个 1D 卷积层开始,通过步幅为 2 对输入序列进行下采样。卷积核的大小大于步幅,因此所有输入都将用于计算该层的输出,因此模型可以学习保留有用信息,仅丢弃不重要的细节。通过缩短序列,卷积层可能有助于GRU层检测更长的模式,因此我们可以将输入序列长度加倍至 112 天。请注意,我们还必须裁剪目标中的前三个时间步:实际上,卷积核的大小为 4,因此卷积层的第一个输出将基于输入时间步 0 到 3,第一个预测将是时间步 4 到 17(而不是时间步 1 到 14)。此外,由于步幅,我们必须将目标下采样一半:
conv_rnn_model = tf.keras.Sequential([
tf.keras.layers.Conv1D(filters=32, kernel_size=4, strides=2,
activation="relu", input_shape=[None, 5]),
tf.keras.layers.GRU(32, return_sequences=True),
tf.keras.layers.Dense(14)
])
longer_train = to_seq2seq_dataset(mulvar_train, seq_length=112,
shuffle=True, seed=42)
longer_valid = to_seq2seq_dataset(mulvar_valid, seq_length=112)
downsampled_train = longer_train.map(lambda X, Y: (X, Y[:, 3::2]))
downsampled_valid = longer_valid.map(lambda X, Y: (X, Y[:, 3::2]))
[...] # compile and fit the model using the downsampled datasets
如果您训练和评估此模型,您会发现它的性能优于之前的模型(略有优势)。事实上,实际上可以仅使用 1D 卷积层并完全放弃循环层!
WaveNet
在2016 年的一篇论文,¹⁶ Aaron van den Oord 和其他 DeepMind 研究人员介绍了一种名为WaveNet的新颖架构。他们堆叠了 1D 卷积层,每一层的扩张率(每个神经元的输入间隔)都加倍:第一个卷积层一次只能看到两个时间步,而下一个卷积层则看到四个时间步(其感受野为四个时间步),下一个卷积层看到八个时间步,依此类推(参见图 15-14)。通过加倍扩张率,较低层学习短期模式,而较高层学习长期模式。由于加倍扩张率,网络可以非常高效地处理极大的序列。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1514.png
图 15-14。WaveNet 架构
论文的作者实际上堆叠了 10 个具有 1、2、4、8、…、256、512 扩张率的卷积层,然后他们又堆叠了另一组 10 个相同的层(扩张率也是 1、2、4、8、…、256、512),然后再次堆叠了另一组相同的 10 层。他们通过指出,具有这些扩张率的单个 10 个卷积层堆栈将像具有大小为 1,024 的卷积核的超高效卷积层一样(速度更快,更强大,参数数量显著减少)。他们还在每一层之前用与扩张率相等的零填充输入序列,以保持整个网络中相同的序列长度。
以下是如何实现一个简化的 WaveNet 来处理与之前相同的序列的方法:¹⁷
wavenet_model = tf.keras.Sequential()
wavenet_model.add(tf.keras.layers.Input(shape=[None, 5]))
for rate in (1, 2, 4, 8) * 2:
wavenet_model.add(tf.keras.layers.Conv1D(
filters=32, kernel_size=2, padding="causal", activation="relu",
dilation_rate=rate))
wavenet_model.add(tf.keras.layers.Conv1D(filters=14, kernel_size=1))
这个Sequential模型从一个明确的输入层开始——这比仅在第一层上设置input_shape要简单。然后,它继续使用“因果”填充的 1D 卷积层,这类似于“相同”填充,只是零值仅附加在输入序列的开头,而不是两侧。这确保了卷积层在进行预测时不会窥视未来。然后,我们添加使用不断增长的扩张率的类似对层:1、2、4、8,再次是 1、2、4、8。最后,我们添加输出层:一个具有 14 个大小为 1 的滤波器的卷积层,没有任何激活函数。正如我们之前看到的那样,这样的卷积层等效于具有 14 个单元的Dense层。由于因果填充,每个卷积层输出与其输入序列相同长度的序列,因此我们在训练期间使用的目标可以是完整的 112 天序列:无需裁剪或降采样。
我们在本节讨论的模型对乘客量预测任务提供了类似的性能,但它们在任务和可用数据量方面可能会有很大差异。在 WaveNet 论文中,作者在各种音频任务(因此该架构的名称)上实现了最先进的性能,包括文本转语音任务,在多种语言中产生令人难以置信的逼真声音。他们还使用该模型逐个音频样本生成音乐。当您意识到一秒钟的音频可能包含成千上万个时间步时,这一壮举就显得更加令人印象深刻——即使是 LSTM 和 GRU 也无法处理如此长的序列。
警告
如果您在测试期间评估我们最佳的芝加哥乘客量模型,从 2020 年开始,您会发现它们的表现远远不如预期!为什么呢?嗯,那时候是 Covid-19 大流行开始的时候,这对公共交通产生了很大影响。正如前面提到的,这些模型只有在它们从过去学到的模式在未来继续时才能很好地工作。无论如何,在将模型部署到生产环境之前,请验证它在最近的数据上表现良好。一旦投入生产,请确保定期监控其性能。
有了这个,您现在可以处理各种时间序列了!在第十六章中,我们将继续探索 RNN,并看看它们如何处理各种 NLP 任务。
练习
-
您能想到一些序列到序列 RNN 的应用吗?序列到向量 RNN 和向量到序列 RNN 呢?
-
RNN 层的输入必须具有多少维度?每个维度代表什么?输出呢?
-
如果您想构建一个深度序列到序列 RNN,哪些 RNN 层应该具有
return_sequences=True?序列到向量 RNN 呢? -
假设您有一个每日单变量时间序列,并且您想要预测接下来的七天。您应该使用哪种 RNN 架构?
-
在训练 RNN 时主要的困难是什么?您如何处理它们?
-
您能勾画出 LSTM 单元的架构吗?
-
为什么要在 RNN 中使用 1D 卷积层?
-
您可以使用哪种神经网络架构来对视频进行分类?
-
为 SketchRNN 数据集训练一个分类模型,该数据集可在 TensorFlow Datasets 中找到。
-
下载巴赫赞美诗数据集并解压缩。这是由约翰·塞巴斯蒂安·巴赫创作的 382 首赞美诗。每首赞美诗长 100 至 640 个时间步长,每个时间步长包含 4 个整数,其中每个整数对应于钢琴上的一个音符的索引(除了值为 0,表示没有播放音符)。训练一个模型——循环的、卷积的,或两者兼而有之——可以预测下一个时间步长(四个音符),给定来自赞美诗的时间步长序列。然后使用这个模型生成类似巴赫的音乐,一次一个音符:您可以通过给模型提供赞美诗的开头并要求它预测下一个时间步长来实现这一点,然后将这些时间步长附加到输入序列并要求模型预测下一个音符,依此类推。还要确保查看谷歌的 Coconet 模型,该模型用于关于巴赫的一个不错的谷歌涂鸦。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 请注意,许多研究人员更喜欢在 RNN 中使用双曲正切(tanh)激活函数,而不是 ReLU 激活函数。例如,参见 Vu Pham 等人的2013 年论文“Dropout Improves Recurrent Neural Networks for Handwriting Recognition”。基于 ReLU 的 RNN 也是可能的,正如 Quoc V. Le 等人的2015 年论文“初始化修正线性单元的循环网络的简单方法”中所示。
² Nal Kalchbrenner 和 Phil Blunsom,“循环连续翻译模型”,2013 年经验方法自然语言处理会议论文集(2013):1700–1709。
³ 芝加哥交通管理局的最新数据可在芝加哥数据门户上找到。
⁴ 有其他更有原则的方法来选择好的超参数,基于分析自相关函数(ACF)和偏自相关函数(PACF),或最小化 AIC 或 BIC 指标(在第九章中介绍)以惩罚使用太多参数的模型并减少过拟合数据的风险,但网格搜索是一个很好的起点。有关 ACF-PACF 方法的更多详细信息,请查看 Jason Brownlee 的这篇非常好的文章。
⁵ 请注意,验证期从 2019 年 1 月 1 日开始,因此第一个预测是 2019 年 2 月 26 日,八周后。当我们评估基线模型时,我们使用了从 3 月 1 日开始的预测,但这应该足够接近。
⁶ 随意尝试这个模型。例如,您可以尝试预测接下来 14 天的公交和轨道乘客量。您需要调整目标,包括两者,并使您的模型输出 28 个预测,而不是 14 个。
⁷ César Laurent 等人,“批量归一化循环神经网络”,IEEE 国际声学、语音和信号处理会议论文集(2016):2657–2661。
⁸ Jimmy Lei Ba 等人,“层归一化”,arXiv 预印本 arXiv:1607.06450(2016)。
⁹ 更简单的方法是继承自SimpleRNNCell,这样我们就不必创建内部的SimpleRNNCell或处理state_size和output_size属性,但这里的目标是展示如何从头开始创建自定义单元。
¹⁰ 动画电影海底总动员和海底奇兵中一个患有短期记忆丧失的角色。
¹¹ Sepp Hochreiter 和 Jürgen Schmidhuber,“长短期记忆”,神经计算 9,第 8 期(1997 年):1735–1780。
¹² Haşim Sak 等,“基于长短期记忆的大词汇语音识别循环神经网络架构”,arXiv 预印本 arXiv:1402.1128(2014 年)。
¹³ Wojciech Zaremba 等,“循环神经网络正则化”,arXiv 预印本 arXiv:1409.2329(2014 年)。
¹⁴ Kyunghyun Cho 等,“使用 RNN 编码器-解码器学习短语表示进行统计机器翻译”,2014 年经验方法自然语言处理会议论文集(2014 年):1724–1734。
¹⁵ 请参阅 Klaus Greff 等的“LSTM:搜索空间奥德赛”,IEEE 神经网络与学习系统交易 28,第 10 期(2017 年):2222–2232。这篇论文似乎表明所有 LSTM 变体表现大致相同。
¹⁶ Aaron van den Oord 等,“WaveNet:原始音频的生成模型”,arXiv 预印本 arXiv:1609.03499(2016 年)。
¹⁷ 完整的 WaveNet 使用了更多技巧,例如类似于 ResNet 中的跳过连接和类似于 GRU 单元中的门控激活单元。有关更多详细信息,请参阅本章的笔记本。
第十六章:使用 RNN 和注意力进行自然语言处理
当艾伦·图灵在 1950 年想象他著名的Turing 测试时,他提出了一种评估机器匹配人类智能能力的方法。他本可以测试许多事情,比如识别图片中的猫、下棋、创作音乐或逃离迷宫,但有趣的是,他选择了一项语言任务。更具体地说,他设计了一个聊天机器人,能够愚弄对话者以为它是人类。这个测试确实有其弱点:一组硬编码规则可以愚弄毫无戒心或天真的人类(例如,机器可以对某些关键词给出模糊的预定义答案,可以假装在回答一些最奇怪的问题时开玩笑或喝醉,或者可以通过用自己的问题回答难题来逃避困难的问题),并且许多人类智能的方面完全被忽视(例如,解释非言语交流,如面部表情,或学习手动任务的能力)。但这个测试确实突显了掌握语言可能是智人最伟大的认知能力。
我们能否构建一台能够掌握书面和口头语言的机器?这是自然语言处理研究的终极目标,但实际上研究人员更专注于更具体的任务,比如文本分类、翻译、摘要、问答等等。
自然语言任务的一种常见方法是使用循环神经网络。因此,我们将继续探索循环神经网络(在第十五章中介绍),首先是字符 RNN或char-RNN,训练以预测句子中的下一个字符。这将使我们能够生成一些原创文本。我们将首先使用无状态 RNN(在每次迭代中学习文本的随机部分,没有关于文本其余部分的信息),然后我们将构建有状态 RNN(在训练迭代之间保留隐藏状态,并继续阅读离开的地方,使其能够学习更长的模式)。接下来,我们将构建一个 RNN 来执行情感分析(例如,阅读电影评论并提取评价者对电影的感受),这次将句子视为单词序列,而不是字符。然后我们将展示如何使用 RNN 来构建一个编码器-解码器架构,能够执行神经机器翻译(NMT),将英语翻译成西班牙语。
在本章的第二部分,我们将探索注意机制。正如它们的名字所示,这些是神经网络组件,它们学习选择模型在每个时间步应该关注的输入部分。首先,我们将通过注意机制提高基于 RNN 的编码器-解码器架构的性能。接下来,我们将完全放弃 RNN,并使用一个非常成功的仅注意架构,称为transformer,来构建一个翻译模型。然后,我们将讨论过去几年自然语言处理中一些最重要的进展,包括基于 transformer 的 GPT 和 BERT 等非常强大的语言模型。最后,我将向您展示如何开始使用 Hugging Face 出色的 Transformers 库。
让我们从一个简单而有趣的模型开始,这个模型可以像莎士比亚一样写作(某种程度上)。
使用字符 RNN 生成莎士比亚文本
在一篇著名的2015 年博客文章中,安德烈·卡帕西展示了如何训练一个 RNN 来预测句子中的下一个字符。然后可以使用这个char-RNN逐个字符生成新文本。以下是一个经过训练所有莎士比亚作品后由 char-RNN 模型生成的文本的小样本:
潘达鲁斯:
唉,我想他将会被接近并且这一天
当一点点智慧被获得而从未被喂养时,
而谁不是一条链,是他死亡的主题,
我不应该睡觉。
虽然不是杰作,但仍然令人印象深刻,模型能够学习单词、语法、正确的标点符号等,只是通过学习预测句子中的下一个字符。这是我们的第一个语言模型示例;本章后面讨论的类似(但更强大)的语言模型是现代自然语言处理的核心。在本节的其余部分,我们将逐步构建一个 char-RNN,从创建数据集开始。
创建训练数据集
首先,使用 Keras 方便的 tf.keras.utils.get_file() 函数,让我们下载所有莎士比亚的作品。数据是从 Andrej Karpathy 的char-rnn 项目加载的:
import tensorflow as tf
shakespeare_url = "https://homl.info/shakespeare" # shortcut URL
filepath = tf.keras.utils.get_file("shakespeare.txt", shakespeare_url)
with open(filepath) as f:
shakespeare_text = f.read()
让我们打印前几行:
>>> print(shakespeare_text[:80])
First Citizen:
Before we proceed any further, hear me speak.
All:
Speak, speak.
看起来像是莎士比亚的作品!
接下来,我们将使用 tf.keras.layers.TextVectorization 层(在第十三章介绍)对此文本进行编码。我们设置 split="character" 以获得字符级别的编码,而不是默认的单词级别编码,并且我们使用 standardize="lower" 将文本转换为小写(这将简化任务):
text_vec_layer = tf.keras.layers.TextVectorization(split="character",
standardize="lower")
text_vec_layer.adapt([shakespeare_text])
encoded = text_vec_layer([shakespeare_text])[0]
现在,每个字符都映射到一个整数,从 2 开始。TextVectorization 层将值 0 保留给填充标记,将值 1 保留给未知字符。目前我们不需要这两个标记,所以让我们从字符 ID 中减去 2,并计算不同字符的数量和总字符数:
encoded -= 2 # drop tokens 0 (pad) and 1 (unknown), which we will not use
n_tokens = text_vec_layer.vocabulary_size() - 2 # number of distinct chars = 39
dataset_size = len(encoded) # total number of chars = 1,115,394
接下来,就像我们在第十五章中所做的那样,我们可以将这个非常长的序列转换为一个窗口的数据集,然后用它来训练一个序列到序列的 RNN。目标将类似于输入,但是向“未来”移动了一个时间步。例如,数据集中的一个样本可能是代表文本“to be or not to b”(没有最后的“e”)的字符 ID 序列,相应的目标是代表文本“o be or not to be”(有最后的“e”,但没有开头的“t”)的字符 ID 序列。让我们编写一个小型实用函数,将字符 ID 的长序列转换为输入/目标窗口对的数据集:
def to_dataset(sequence, length, shuffle=False, seed=None, batch_size=32):
ds = tf.data.Dataset.from_tensor_slices(sequence)
ds = ds.window(length + 1, shift=1, drop_remainder=True)
ds = ds.flat_map(lambda window_ds: window_ds.batch(length + 1))
if shuffle:
ds = ds.shuffle(buffer_size=100_000, seed=seed)
ds = ds.batch(batch_size)
return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1)
这个函数开始得很像我们在第十五章中创建的 to_windows() 自定义实用函数:
-
它以一个序列作为输入(即编码文本),并创建一个包含所需长度的所有窗口的数据集。
-
它将长度增加一,因为我们需要下一个字符作为目标。
-
然后,它会对窗口进行洗牌(可选),将它们分批处理,拆分为输入/输出对,并激活预取。
图 16-1 总结了数据集准备步骤:它展示了长度为 11 的窗口,批量大小为 3。每个窗口的起始索引在其旁边标出。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1601.png
图 16-1. 准备一个洗牌窗口的数据集
现在我们准备创建训练集、验证集和测试集。我们将大约使用文本的 90%进行训练,5%用于验证,5%用于测试:
length = 100
tf.random.set_seed(42)
train_set = to_dataset(encoded[:1_000_000], length=length, shuffle=True,
seed=42)
valid_set = to_dataset(encoded[1_000_000:1_060_000], length=length)
test_set = to_dataset(encoded[1_060_000:], length=length)
提示
我们将窗口长度设置为 100,但您可以尝试调整它:在较短的输入序列上训练 RNN 更容易更快,但 RNN 将无法学习任何长于 length 的模式,所以不要将其设置得太小。
就是这样!准备数据集是最困难的部分。现在让我们创建模型。
构建和训练 Char-RNN 模型
由于我们的数据集相当大,而建模语言是一个相当困难的任务,我们需要不止一个简单的具有几个循环神经元的 RNN。让我们构建并训练一个由 128 个单元组成的 GRU 层的模型(如果需要,稍后可以尝试调整层数和单元数):
model = tf.keras.Sequential([
tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16),
tf.keras.layers.GRU(128, return_sequences=True),
tf.keras.layers.Dense(n_tokens, activation="softmax")
])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
metrics=["accuracy"])
model_ckpt = tf.keras.callbacks.ModelCheckpoint(
"my_shakespeare_model", monitor="val_accuracy", save_best_only=True)
history = model.fit(train_set, validation_data=valid_set, epochs=10,
callbacks=[model_ckpt])
让我们仔细看看这段代码:
-
我们使用一个
Embedding层作为第一层,用于编码字符 ID(嵌入在第十三章中介绍)。Embedding层的输入维度数是不同字符 ID 的数量,输出维度数是一个可以调整的超参数,我们暂时将其设置为 16。Embedding层的输入将是形状为[批量大小, 窗口长度]的 2D 张量,Embedding层的输出将是形状为[批量大小, 窗口长度, 嵌入大小]的 3D 张量。 -
我们使用一个
Dense层作为输出层:它必须有 39 个单元(n_tokens),因为文本中有 39 个不同的字符,并且我们希望在每个时间步输出每个可能字符的概率。39 个输出概率应该在每个时间步加起来为 1,因此我们将 softmax 激活函数应用于Dense层的输出。 -
最后,我们编译这个模型,使用
"sparse_categorical_crossentropy"损失和 Nadam 优化器,然后训练模型多个 epoch,使用ModelCheckpoint回调来保存训练过程中验证准确性最好的模型。
提示
如果您在启用 GPU 的 Colab 上运行此代码,则训练大约需要一到两个小时。如果您不想等待那么长时间,可以减少 epoch 的数量,但当然模型的准确性可能会降低。如果 Colab 会话超时,请确保快速重新连接,否则 Colab 运行时将被销毁。
这个模型不处理文本预处理,所以让我们将其包装在一个最终模型中,包含tf.keras.layers.TextVectorization层作为第一层,加上一个tf.keras.layers.Lambda层,从字符 ID 中减去 2,因为我们暂时不使用填充和未知标记:
shakespeare_model = tf.keras.Sequential([
text_vec_layer,
tf.keras.layers.Lambda(lambda X: X - 2), # no <PAD> or <UNK> tokens
model
])
现在让我们用它来预测句子中的下一个字符:
>>> y_proba = shakespeare_model.predict(["To be or not to b"])[0, -1]
>>> y_pred = tf.argmax(y_proba) # choose the most probable character ID
>>> text_vec_layer.get_vocabulary()[y_pred + 2]
'e'
太好了,模型正确预测了下一个字符。现在让我们使用这个模型假装我们是莎士比亚!
生成虚假的莎士比亚文本
使用 char-RNN 模型生成新文本时,我们可以将一些文本输入模型,让模型预测最有可能的下一个字母,将其添加到文本末尾,然后将扩展后的文本提供给模型猜测下一个字母,依此类推。这被称为贪婪解码。但在实践中,这经常导致相同的单词一遍又一遍地重复。相反,我们可以随机采样下一个字符,概率等于估计的概率,使用 TensorFlow 的tf.random.categorical()函数。这将生成更多样化和有趣的文本。categorical()函数会根据类别对数概率(logits)随机采样随机类别索引。例如:
>>> log_probas = tf.math.log([[0.5, 0.4, 0.1]]) # probas = 50%, 40%, and 10%
>>> tf.random.set_seed(42)
>>> tf.random.categorical(log_probas, num_samples=8) # draw 8 samples
<tf.Tensor: shape=(1, 8), dtype=int64, numpy=array([[0, 1, 0, 2, 1, 0, 0, 1]])>
为了更好地控制生成文本的多样性,我们可以将 logits 除以一个称为温度的数字,我们可以根据需要进行调整。接近零的温度偏好高概率字符,而高温度使所有字符具有相等的概率。在生成相对严格和精确的文本(如数学方程式)时,通常更喜欢较低的温度,而在生成更多样化和创意性的文本时,更喜欢较高的温度。以下next_char()自定义辅助函数使用这种方法选择要添加到输入文本中的下一个字符:
def next_char(text, temperature=1):
y_proba = shakespeare_model.predict([text])[0, -1:]
rescaled_logits = tf.math.log(y_proba) / temperature
char_id = tf.random.categorical(rescaled_logits, num_samples=1)[0, 0]
return text_vec_layer.get_vocabulary()[char_id + 2]
接下来,我们可以编写另一个小的辅助函数,它将重复调用next_char()以获取下一个字符并将其附加到给定的文本中:
def extend_text(text, n_chars=50, temperature=1):
for _ in range(n_chars):
text += next_char(text, temperature)
return text
现在我们准备生成一些文本!让我们尝试不同的温度值:
>>> tf.random.set_seed(42)
>>> print(extend_text("To be or not to be", temperature=0.01))
To be or not to be the duke
as it is a proper strange death,
and the
>>> print(extend_text("To be or not to be", temperature=1))
To be or not to behold?
second push:
gremio, lord all, a sistermen,
>>> print(extend_text("To be or not to be", temperature=100))
To be or not to bef ,mt'&o3fpadm!$
wh!nse?bws3est--vgerdjw?c-y-ewznq
莎士比亚似乎正在遭受一场热浪。为了生成更具说服力的文本,一个常见的技术是仅从前 k 个字符中采样,或者仅从总概率超过某个阈值的最小一组顶级字符中采样(这被称为核心采样)。另外,您可以尝试使用波束搜索,我们将在本章后面讨论,或者使用更多的GRU层和更多的神经元每层,训练更长时间,并在需要时添加一些正则化。还要注意,模型目前无法学习比length更长的模式,length只是 100 个字符。您可以尝试将此窗口扩大,但这也会使训练更加困难,即使 LSTM 和 GRU 单元也无法处理非常长的序列。另一种替代方法是使用有状态的 RNN。
有状态的 RNN
到目前为止,我们只使用了无状态的 RNN:在每次训练迭代中,模型从一个全零的隐藏状态开始,然后在每个时间步更新这个状态,在最后一个时间步之后,将其丢弃,因为不再需要。如果我们指示 RNN 在处理训练批次后保留这个最终状态,并将其用作下一个训练批次的初始状态,会怎样呢?这样,模型可以学习长期模式,尽管只通过短序列进行反向传播。这被称为有状态的 RNN。让我们看看如何构建一个。
首先,注意到有状态的 RNN 只有在批次中的每个输入序列从上一个批次中对应序列的确切位置开始时才有意义。因此,我们构建有状态的 RNN 需要做的第一件事是使用顺序且不重叠的输入序列(而不是我们用来训练无状态 RNN 的洗牌和重叠序列)。在创建tf.data.Dataset时,因此在调用window()方法时必须使用shift=length(而不是shift=1)。此外,我们必须不调用shuffle()方法。
不幸的是,为有状态的 RNN 准备数据集时,批处理比为无状态的 RNN 更加困难。实际上,如果我们调用batch(32),那么 32 个连续窗口将被放入同一个批次中,接下来的批次将不会继续每个窗口的位置。第一个批次将包含窗口 1 到 32,第二个批次将包含窗口 33 到 64,因此如果您考虑,比如说,每个批次的第一个窗口(即窗口 1 和 33),您会发现它们不是连续的。这个问题的最简单解决方案就是只使用批量大小为 1。以下的to_dataset_for_stateful_rnn()自定义实用函数使用这种策略来为有状态的 RNN 准备数据集:
def to_dataset_for_stateful_rnn(sequence, length):
ds = tf.data.Dataset.from_tensor_slices(sequence)
ds = ds.window(length + 1, shift=length, drop_remainder=True)
ds = ds.flat_map(lambda window: window.batch(length + 1)).batch(1)
return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1)
stateful_train_set = to_dataset_for_stateful_rnn(encoded[:1_000_000], length)
stateful_valid_set = to_dataset_for_stateful_rnn(encoded[1_000_000:1_060_000],
length)
stateful_test_set = to_dataset_for_stateful_rnn(encoded[1_060_000:], length)
图 16-2 总结了这个函数的主要步骤。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1602.png
图 16-2。为有状态的 RNN 准备连续序列片段的数据集
批处理更加困难,但并非不可能。例如,我们可以将莎士比亚的文本分成 32 个等长的文本,为每个文本创建一个连续输入序列的数据集,最后使用tf.data.Dataset.zip(datasets).map(lambda *windows: tf.stack(windows))来创建正确的连续批次,其中批次中的第n个输入序列从上一个批次中的第n个输入序列结束的地方开始(请参阅笔记本获取完整代码)。
现在,让我们创建有状态的 RNN。在创建每个循环层时,我们需要将stateful参数设置为True,因为有状态的 RNN 需要知道批量大小(因为它将为批次中的每个输入序列保留一个状态)。因此,我们必须在第一层中设置batch_input_shape参数。请注意,我们可以将第二维度留空,因为输入序列可以具有任意长度:
model = tf.keras.Sequential([
tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16,
batch_input_shape=[1, None]),
tf.keras.layers.GRU(128, return_sequences=True, stateful=True),
tf.keras.layers.Dense(n_tokens, activation="softmax")
])
在每个时期结束时,我们需要在回到文本开头之前重置状态。为此,我们可以使用一个小的自定义 Keras 回调:
class ResetStatesCallback(tf.keras.callbacks.Callback):
def on_epoch_begin(self, epoch, logs):
self.model.reset_states()
现在我们可以编译模型并使用我们的回调函数进行训练:
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
metrics=["accuracy"])
history = model.fit(stateful_train_set, validation_data=stateful_valid_set,
epochs=10, callbacks=[ResetStatesCallback(), model_ckpt])
提示
训练完这个模型后,只能用它来对与训练时相同大小的批次进行预测。为了避免这个限制,创建一个相同的无状态模型,并将有状态模型的权重复制到这个模型中。
有趣的是,尽管 char-RNN 模型只是训练来预测下一个字符,但这看似简单的任务实际上也需要它学习一些更高级的任务。例如,要找到“Great movie, I really”之后的下一个字符,了解到这句话是积极的是有帮助的,所以下一个字符更有可能是“l”(代表“loved”)而不是“h”(代表“hated”)。事实上,OpenAI 的 Alec Radford 和其他研究人员在一篇 2017 年的论文中描述了他们如何在大型数据集上训练了一个类似于大型 char-RNN 模型,并发现其中一个神经元表现出色地作为情感分析分类器:尽管该模型在没有任何标签的情况下进行了训练,但他们称之为情感神经元达到了情感分析基准测试的最新性能。这预示并激励了 NLP 中的无监督预训练。
但在探索无监督预训练之前,让我们将注意力转向单词级模型以及如何在监督方式下用它们进行情感分析。在这个过程中,您将学习如何使用掩码处理可变长度的序列。
情感分析
生成文本可能很有趣且有教育意义,但在实际项目中,自然语言处理的最常见应用之一是文本分类,尤其是情感分析。如果在 MNIST 数据集上进行图像分类是计算机视觉的“Hello world!”,那么在 IMDb 评论数据集上进行情感分析就是自然语言处理的“Hello world!”。IMDb 数据集包含了来自著名的互联网电影数据库的 50,000 条英文电影评论(25,000 条用于训练,25,000 条用于测试),每条评论都有一个简单的二进制目标,表示其是否为负面(0)或正面(1)。就像 MNIST 一样,IMDb 评论数据集之所以受欢迎是有充分理由的:它足够简单,可以在笔记本电脑上在合理的时间内处理,但足够具有挑战性和有趣。
让我们使用 TensorFlow Datasets 库加载 IMDb 数据集(在第十三章中介绍)。我们将使用训练集的前 90%进行训练,剩下的 10%用于验证:
import tensorflow_datasets as tfds
raw_train_set, raw_valid_set, raw_test_set = tfds.load(
name="imdb_reviews",
split=["train[:90%]", "train[90%:]", "test"],
as_supervised=True
)
tf.random.set_seed(42)
train_set = raw_train_set.shuffle(5000, seed=42).batch(32).prefetch(1)
valid_set = raw_valid_set.batch(32).prefetch(1)
test_set = raw_test_set.batch(32).prefetch(1)
提示
如果您愿意,Keras 还包括一个用于加载 IMDb 数据集的函数:tf.keras.datasets.imdb.load_data()。评论已经被预处理为单词 ID 的序列。
让我们检查一些评论:
>>> for review, label in raw_train_set.take(4):
... print(review.numpy().decode("utf-8"))
... print("Label:", label.numpy())
...
This was an absolutely terrible movie. Don't be lured in by Christopher [...]
Label: 0
I have been known to fall asleep during films, but this is usually due to [...]
Label: 0
Mann photographs the Alberta Rocky Mountains in a superb fashion, and [...]
Label: 0
This is the kind of film for a snowy Sunday afternoon when the rest of the [...]
Label: 1
有些评论很容易分类。例如,第一条评论中的第一句话包含“terrible movie”这几个词。但在许多情况下,事情并不那么简单。例如,第三条评论一开始是积极的,尽管最终是一个负面评论(标签 0)。
为了为这个任务构建一个模型,我们需要预处理文本,但这次我们将其分成单词而不是字符。为此,我们可以再次使用tf.keras.layers.TextVectorization层。请注意,它使用空格来识别单词边界,在某些语言中可能效果不佳。例如,中文书写不使用单词之间的空格,越南语甚至在单词内部也使用空格,德语经常将多个单词连接在一起,没有空格。即使在英语中,空格也不总是分词的最佳方式:想想“San Francisco”或“#ILoveDeepLearning”。
幸运的是,有解决这些问题的解决方案。在2016 年的一篇论文,爱丁堡大学的 Rico Sennrich 等人探索了几种在子词级别对文本进行标记和去标记化的方法。这样,即使您的模型遇到了以前从未见过的罕见单词,它仍然可以合理地猜测它的含义。例如,即使模型在训练期间从未见过单词“smartest”,如果它学会了单词“smart”并且还学会了后缀“est”表示“最”,它可以推断出“smartest”的含义。作者评估的技术之一是字节对编码(BPE)。BPE 通过将整个训练集拆分为单个字符(包括空格),然后重复合并最频繁的相邻对,直到词汇表达到所需大小。
Google 的 Taku Kudo 在 2018 年发表的一篇论文进一步改进了子词标记化,通常消除了标记化之前需要进行特定于语言的预处理的需要。此外,该论文提出了一种称为子词正则化的新型正则化技术,通过在训练期间在标记化中引入一些随机性来提高准确性和稳健性:例如,“New England”可以被标记为“New”+“England”,或“New”+“Eng”+“land”,或简单地“New England”(只有一个标记)。Google 的SentencePiece项目提供了一个开源实现,该实现在 Taku Kudo 和 John Richardson 的一篇论文中有描述。
TensorFlow Text库还实现了各种标记化策略,包括WordPiece(BPE 的变体),最后但同样重要的是,Hugging Face 的 Tokenizers 库实现了一系列极快的标记化器。
然而,对于英语中的 IMDb 任务,使用空格作为标记边界应该足够好。因此,让我们继续创建一个TextVectorization层,并将其调整到训练集。我们将词汇表限制为 1,000 个标记,包括最常见的 998 个单词以及一个填充标记和一个未知单词的标记,因为很少见的单词不太可能对这个任务很重要,并且限制词汇表大小将减少模型需要学习的参数数量:
vocab_size = 1000
text_vec_layer = tf.keras.layers.TextVectorization(max_tokens=vocab_size)
text_vec_layer.adapt(train_set.map(lambda reviews, labels: reviews))
最后,我们可以创建模型并训练它:
embed_size = 128
tf.random.set_seed(42)
model = tf.keras.Sequential([
text_vec_layer,
tf.keras.layers.Embedding(vocab_size, embed_size),
tf.keras.layers.GRU(128),
tf.keras.layers.Dense(1, activation="sigmoid")
])
model.compile(loss="binary_crossentropy", optimizer="nadam",
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=2)
第一层是我们刚刚准备的TextVectorization层,接着是一个Embedding层,将单词 ID 转换为嵌入。嵌入矩阵需要每个词汇表中的标记一行(vocab_size),每个嵌入维度一列(此示例使用 128 维,但这是一个可以调整的超参数)。接下来我们使用一个GRU层和一个具有单个神经元和 sigmoid 激活函数的Dense层,因为这是一个二元分类任务:模型的输出将是评论表达对电影积极情绪的估计概率。然后我们编译模型,并在我们之前准备的数据集上进行几个时期的拟合(或者您可以训练更长时间以获得更好的结果)。
遗憾的是,如果运行此代码,通常会发现模型根本无法学习任何东西:准确率保持接近 50%,不比随机机会好。为什么呢?评论的长度不同,因此当TextVectorization层将它们转换为标记 ID 序列时,它使用填充标记(ID 为 0)填充较短的序列,使它们与批次中最长序列一样长。结果,大多数序列以许多填充标记结尾——通常是几十甚至几百个。即使我们使用的是比SimpleRNN层更好的GRU层,它的短期记忆仍然不太好,因此当它经过许多填充标记时,它最终会忘记评论的内容!一个解决方案是用等长的句子批次喂给模型(这也加快了训练速度)。另一个解决方案是让 RNN 忽略填充标记。这可以通过掩码来实现。
掩码
使用 Keras 让模型忽略填充标记很简单:在创建Embedding层时简单地添加mask_zero=True。这意味着所有下游层都会忽略填充标记(其 ID 为 0)。就是这样!如果对先前的模型进行几个时期的重新训练,您会发现验证准确率很快就能达到 80%以上。
这种工作方式是,Embedding层创建一个等于tf.math.not_equal(inputs, 0)的掩码张量:它是一个布尔张量,形状与输入相同,如果标记 ID 为 0,则等于False,否则等于True。然后,该掩码张量会被模型自动传播到下一层。如果该层的call()方法有一个mask参数,那么它会自动接收到掩码。这使得该层能够忽略适当的时间步。每个层可能会以不同的方式处理掩码,但通常它们只是忽略被掩码的时间步(即掩码为False的时间步)。例如,当循环层遇到被掩码的时间步时,它只是复制前一个时间步的输出。
接下来,如果该层的supports_masking属性为True,那么掩码会自动传播到下一层。只要层具有supports_masking=True,它就会继续这样传播。例如,当return_sequences=True时,循环层的supports_masking属性为True,但当return_sequences=False时,它为False,因为在这种情况下不再需要掩码。因此,如果您有一个具有多个return_sequences=True的循环层,然后是一个return_sequences=False的循环层的模型,那么掩码将自动传播到最后一个循环层:该层将使用掩码来忽略被掩码的步骤,但不会进一步传播掩码。同样,如果在我们刚刚构建的情感分析模型中创建Embedding层时设置了mask_zero=True,那么GRU层将自动接收和使用掩码,但不会进一步传播,因为return_sequences没有设置为True。
提示
一些层在将掩码传播到下一层之前需要更新掩码:它们通过实现compute_mask()方法来实现,该方法接受两个参数:输入和先前的掩码。然后计算更新后的掩码并返回。compute_mask()的默认实现只是返回先前的掩码而没有更改。
许多 Keras 层支持掩码:SimpleRNN、GRU、LSTM、Bidirectional、Dense、TimeDistributed、Add等(都在tf.keras.layers包中)。然而,卷积层(包括Conv1D)不支持掩码——它们如何支持掩码并不明显。
如果掩码一直传播到输出,那么它也会应用到损失上,因此被掩码的时间步将不会对损失产生贡献(它们的损失将为 0)。这假设模型输出序列,这在我们的情感分析模型中并不是这样。
警告
LSTM和GRU层具有基于 Nvidia 的 cuDNN 库的优化实现。但是,此实现仅在所有填充标记位于序列末尾时支持遮罩。它还要求您使用几个超参数的默认值:activation、recurrent_activation、recurrent_dropout、unroll、use_bias和reset_after。如果不是这种情况,那么这些层将退回到(速度慢得多的)默认 GPU 实现。
如果要实现支持遮罩的自定义层,应在call()方法中添加一个mask参数,并显然使方法使用该遮罩。此外,如果遮罩必须传播到下一层,则应在构造函数中设置self.supports_masking=True。如果必须在传播之前更新遮罩,则必须实现compute_mask()方法。
如果您的模型不以Embedding层开头,可以使用tf.keras.layers.Masking层代替:默认情况下,它将遮罩设置为tf.math.reduce_any(tf.math.not_equal(X, 0), axis=-1),意味着最后一个维度全是零的时间步将在后续层中被遮罩。
使用遮罩层和自动遮罩传播对简单模型效果最好。对于更复杂的模型,例如需要将Conv1D层与循环层混合时,并不总是适用。在这种情况下,您需要显式计算遮罩并将其传递给适当的层,可以使用函数式 API 或子类 API。例如,以下模型与之前的模型等效,只是使用函数式 API 构建,并手动处理遮罩。它还添加了一点辍学,因为之前的模型略微过拟合:
inputs = tf.keras.layers.Input(shape=[], dtype=tf.string)
token_ids = text_vec_layer(inputs)
mask = tf.math.not_equal(token_ids, 0)
Z = tf.keras.layers.Embedding(vocab_size, embed_size)(token_ids)
Z = tf.keras.layers.GRU(128, dropout=0.2)(Z, mask=mask)
outputs = tf.keras.layers.Dense(1, activation="sigmoid")(Z)
model = tf.keras.Model(inputs=[inputs], outputs=[outputs])
遮罩的最后一种方法是使用不规则张量来向模型提供输入。实际上,您只需在创建TextVectorization层时设置ragged=True,以便将输入序列表示为不规则张量:
>>> text_vec_layer_ragged = tf.keras.layers.TextVectorization(
... max_tokens=vocab_size, ragged=True)
...
>>> text_vec_layer_ragged.adapt(train_set.map(lambda reviews, labels: reviews))
>>> text_vec_layer_ragged(["Great movie!", "This is DiCaprio's best role."])
<tf.RaggedTensor [[86, 18], [11, 7, 1, 116, 217]]>
将这种不规则张量表示与使用填充标记的常规张量表示进行比较:
>>> text_vec_layer(["Great movie!", "This is DiCaprio's best role."])
<tf.Tensor: shape=(2, 5), dtype=int64, numpy=
array([[ 86, 18, 0, 0, 0],
[ 11, 7, 1, 116, 217]])>
Keras 的循环层内置支持不规则张量,因此您无需执行其他操作:只需在模型中使用此TextVectorization层。无需传递mask_zero=True或显式处理遮罩——这一切都已为您实现。这很方便!但是,截至 2022 年初,Keras 中对不规则张量的支持仍然相对较新,因此存在一些问题。例如,目前无法在 GPU 上运行时将不规则张量用作目标(但在您阅读这些内容时可能已解决)。
无论您喜欢哪种遮罩方法,在训练此模型几个时期后,它将变得非常擅长判断评论是积极的还是消极的。如果使用tf.keras.callbacks.TensorBoard()回调,您可以在 TensorBoard 中可视化嵌入,看到诸如“棒极了”和“惊人”的词逐渐聚集在嵌入空间的一侧,而诸如“糟糕”和“可怕”的词聚集在另一侧。有些词并不像您可能期望的那样积极(至少在这个模型中),比如“好”这个词,可能是因为许多负面评论包含短语“不好”。
重用预训练的嵌入和语言模型
令人印象深刻的是,这个模型能够基于仅有 25,000 条电影评论学习到有用的词嵌入。想象一下,如果我们有数十亿条评论来训练,这些嵌入会有多好!不幸的是,我们没有,但也许我们可以重用在其他(非常)大型文本语料库上训练的词嵌入(例如,亚马逊评论,可在 TensorFlow 数据集上找到)?毕竟,“amazing”这个词无论是用来谈论电影还是其他事物,通常都有相同的含义。此外,也许即使它们是在另一个任务上训练的,嵌入也对情感分析有用:因为“awesome”和“amazing”这样的词有相似的含义,它们很可能会在嵌入空间中聚集,即使是用于预测句子中的下一个词这样的任务。如果所有积极词和所有消极词形成簇,那么这对情感分析将是有帮助的。因此,我们可以不训练词嵌入,而是下载并使用预训练的嵌入,例如谷歌的Word2vec 嵌入,斯坦福的GloVe 嵌入,或 Facebook 的FastText 嵌入。
使用预训练词嵌入在几年内很受欢迎,但这种方法有其局限性。特别是,一个词无论上下文如何,都有一个表示。例如,“right”这个词在“left and right”和“right and wrong”中以相同的方式编码,尽管它们表示两个非常不同的含义。为了解决这个限制,Matthew Peters 在 2018 年引入了来自语言模型的嵌入(ELMo):这些是从深度双向语言模型的内部状态中学习到的上下文化词嵌入。与仅在模型中使用预训练嵌入不同,您可以重用预训练语言模型的一部分。
大约在同一时间,Jeremy Howard 和 Sebastian Ruder 的通用语言模型微调(ULMFiT)论文展示了无监督预训练在 NLP 任务中的有效性:作者们使用自监督学习(即从数据自动生成标签)在庞大的文本语料库上训练了一个 LSTM 语言模型,然后在各种任务上进行微调。他们的模型在六个文本分类任务上表现优异(在大多数情况下将错误率降低了 18-24%)。此外,作者们展示了一个仅使用 100 个标记示例进行微调的预训练模型可以达到与从头开始训练 10,000 个示例相同的性能。在 ULMFiT 论文之前,使用预训练模型只是计算机视觉中的常态;在 NLP 领域,预训练仅限于词嵌入。这篇论文标志着 NLP 的一个新时代的开始:如今,重用预训练语言模型已成为常态。
例如,让我们基于通用句子编码器构建一个分类器,这是由谷歌研究人员团队在 2018 年介绍的模型架构。这个模型基于 transformer 架构,我们将在本章后面讨论。方便的是,这个模型可以在 TensorFlow Hub 上找到。
import os
import tensorflow_hub as hub
os.environ["TFHUB_CACHE_DIR"] = "my_tfhub_cache"
model = tf.keras.Sequential([
hub.KerasLayer("https://tfhub.dev/google/universal-sentence-encoder/4",
trainable=True, dtype=tf.string, input_shape=[]),
tf.keras.layers.Dense(64, activation="relu"),
tf.keras.layers.Dense(1, activation="sigmoid")
])
model.compile(loss="binary_crossentropy", optimizer="nadam",
metrics=["accuracy"])
model.fit(train_set, validation_data=valid_set, epochs=10)
提示
这个模型非常庞大,接近 1GB 大小,因此下载可能需要一些时间。默认情况下,TensorFlow Hub 模块保存在临时目录中,并且每次运行程序时都会重新下载。为了避免这种情况,您必须将TFHUB_CACHE_DIR环境变量设置为您选择的目录:模块将保存在那里,只会下载一次。
请注意,TensorFlow Hub 模块 URL 的最后部分指定我们想要模型的第 4 个版本。这种版本控制确保如果 TF Hub 上发布了新的模块版本,它不会破坏我们的模型。方便的是,如果你只在 Web 浏览器中输入这个 URL,你将得到这个模块的文档。
还要注意,在创建hub.KerasLayer时,我们设置了trainable=True。这样,在训练期间,预训练的 Universal Sentence Encoder 会进行微调:通过反向传播调整一些权重。并非所有的 TensorFlow Hub 模块都是可微调的,所以确保查看你感兴趣的每个预训练模块的文档。
训练后,这个模型应该能达到超过 90%的验证准确率。这实际上非常好:如果你尝试自己执行这个任务,你可能只会稍微好一点,因为许多评论中既包含积极的评论,也包含消极的评论。对这些模棱两可的评论进行分类就像抛硬币一样。
到目前为止,我们已经看过使用 char-RNN 进行文本生成,以及使用基于可训练嵌入的单词级 RNN 模型进行情感分析,以及使用来自 TensorFlow Hub 的强大预训练语言模型。在接下来的部分中,我们将探索另一个重要的 NLP 任务:神经机器翻译(NMT)。
神经机器翻译的编码器-解码器网络
让我们从一个简单的NMT 模型开始,它将英语句子翻译成西班牙语(参见图 16-3)。
简而言之,架构如下:英语句子作为输入馈送给编码器,解码器输出西班牙语翻译。请注意,西班牙语翻译也在训练期间作为解码器的输入使用,但是向后移动了一步。换句话说,在训练期间,解码器被给予上一步应该输出的单词作为输入,而不管它实际输出了什么。这被称为“教师强迫”——一种显著加速训练并提高模型性能的技术。对于第一个单词,解码器被给予序列开始(SOS)标记,期望解码器以序列结束(EOS)标记结束句子。
每个单词最初由其 ID 表示(例如,单词“soccer”的 ID 为854)。接下来,一个Embedding层返回单词嵌入。然后这些单词嵌入被馈送给编码器和解码器。
在每一步中,解码器为输出词汇表(即西班牙语)中的每个单词输出一个分数,然后 softmax 激活函数将这些分数转换为概率。例如,在第一步中,“Me”这个词可能有 7%的概率,“Yo”可能有 1%的概率,依此类推。具有最高概率的单词被输出。这非常类似于常规的分类任务,事实上你可以使用"sparse_categorical_crossentropy"损失来训练模型,就像我们在 char-RNN 模型中所做的那样。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1603.png
图 16-3。一个简单的机器翻译模型
请注意,在推断时(训练后),你将没有目标句子来馈送给解码器。相反,你需要将它刚刚输出的单词作为上一步的输入,如图 16-4 所示(这将需要一个在图中未显示的嵌入查找)。
提示
在一篇2015 年的论文,Samy Bengio 等人提出逐渐从在训练期间将前一个“目标”标记馈送给解码器转变为将前一个“输出”标记馈送给解码器。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1604.png
图 16-4。在推断时,解码器作为输入接收它刚刚在上一个时间步输出的单词
让我们构建并训练这个模型!首先,我们需要下载一个英语/西班牙语句子对的数据集:
url = "https://storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip"
path = tf.keras.utils.get_file("spa-eng.zip", origin=url, cache_dir="datasets",
extract=True)
text = (Path(path).with_name("spa-eng") / "spa.txt").read_text()
每行包含一个英语句子和相应的西班牙语翻译,用制表符分隔。我们将从删除西班牙字符“¡”和“¿”开始,TextVectorization层无法处理这些字符,然后我们将解析句子对并对它们进行洗牌。最后,我们将它们分成两个单独的列表,每种语言一个:
import numpy as np
text = text.replace("¡", "").replace("¿", "")
pairs = [line.split("\t") for line in text.splitlines()]
np.random.shuffle(pairs)
sentences_en, sentences_es = zip(*pairs) # separates the pairs into 2 lists
让我们看一下前三个句子对:
>>> for i in range(3):
... print(sentences_en[i], "=>", sentences_es[i])
...
How boring! => Qué aburrimiento!
I love sports. => Adoro el deporte.
Would you like to swap jobs? => Te gustaría que intercambiemos los trabajos?
接下来,让我们创建两个TextVectorization层——每种语言一个,并对文本进行调整:
vocab_size = 1000
max_length = 50
text_vec_layer_en = tf.keras.layers.TextVectorization(
vocab_size, output_sequence_length=max_length)
text_vec_layer_es = tf.keras.layers.TextVectorization(
vocab_size, output_sequence_length=max_length)
text_vec_layer_en.adapt(sentences_en)
text_vec_layer_es.adapt([f"startofseq {s} endofseq" for s in sentences_es])
这里有几件事需要注意:
-
我们将词汇表大小限制为 1,000,这相当小。这是因为训练集不是很大,并且使用较小的值将加快训练速度。最先进的翻译模型通常使用更大的词汇表(例如 30,000),更大的训练集(几千兆字节)和更大的模型(数百甚至数千兆字节)。例如,查看赫尔辛基大学的 Opus-MT 模型,或 Facebook 的 M2M-100 模型。
-
由于数据集中的所有句子最多有 50 个单词,我们将
output_sequence_length设置为 50:这样输入序列将自动填充为零,直到它们都是 50 个标记长。如果训练集中有任何超过 50 个标记的句子,它将被裁剪为 50 个标记。 -
对于西班牙文本,我们在调整
TextVectorization层时为每个句子添加“startofseq”和“endofseq”:我们将使用这些词作为 SOS 和 EOS 标记。您可以使用任何其他单词,只要它们不是实际的西班牙单词。
让我们检查两种词汇表中的前 10 个标记。它们以填充标记、未知标记、SOS 和 EOS 标记(仅在西班牙语词汇表中)、然后按频率递减排序的实际单词开始:
>>> text_vec_layer_en.get_vocabulary()[:10]
['', '[UNK]', 'the', 'i', 'to', 'you', 'tom', 'a', 'is', 'he']
>>> text_vec_layer_es.get_vocabulary()[:10]
['', '[UNK]', 'startofseq', 'endofseq', 'de', 'que', 'a', 'no', 'tom', 'la']
接下来,让我们创建训练集和验证集(如果需要,您也可以创建一个测试集)。我们将使用前 100,000 个句子对进行训练,其余用于验证。解码器的输入是西班牙语句子加上一个 SOS 标记前缀。目标是西班牙语句子加上一个 EOS 后缀:
X_train = tf.constant(sentences_en[:100_000])
X_valid = tf.constant(sentences_en[100_000:])
X_train_dec = tf.constant([f"startofseq {s}" for s in sentences_es[:100_000]])
X_valid_dec = tf.constant([f"startofseq {s}" for s in sentences_es[100_000:]])
Y_train = text_vec_layer_es([f"{s} endofseq" for s in sentences_es[:100_000]])
Y_valid = text_vec_layer_es([f"{s} endofseq" for s in sentences_es[100_000:]])
好的,现在我们准备构建我们的翻译模型。我们将使用功能 API,因为模型不是顺序的。它需要两个文本输入——一个用于编码器,一个用于解码器——所以让我们从这里开始:
encoder_inputs = tf.keras.layers.Input(shape=[], dtype=tf.string)
decoder_inputs = tf.keras.layers.Input(shape=[], dtype=tf.string)
接下来,我们需要使用我们之前准备的TextVectorization层对这些句子进行编码,然后为每种语言使用一个Embedding层,其中mask_zero=True以确保自动处理掩码。嵌入大小是一个您可以调整的超参数,像往常一样:
embed_size = 128
encoder_input_ids = text_vec_layer_en(encoder_inputs)
decoder_input_ids = text_vec_layer_es(decoder_inputs)
encoder_embedding_layer = tf.keras.layers.Embedding(vocab_size, embed_size,
mask_zero=True)
decoder_embedding_layer = tf.keras.layers.Embedding(vocab_size, embed_size,
mask_zero=True)
encoder_embeddings = encoder_embedding_layer(encoder_input_ids)
decoder_embeddings = decoder_embedding_layer(decoder_input_ids)
提示
当语言共享许多单词时,您可能会获得更好的性能,使用相同的嵌入层用于编码器和解码器。
现在让我们创建编码器并传递嵌入输入:
encoder = tf.keras.layers.LSTM(512, return_state=True)
encoder_outputs, *encoder_state = encoder(encoder_embeddings)
为了保持简单,我们只使用了一个LSTM层,但您可以堆叠几个。我们还设置了return_state=True以获得对层最终状态的引用。由于我们使用了一个LSTM层,实际上有两个状态:短期状态和长期状态。该层分别返回这些状态,这就是为什么我们必须写*encoder_state来将两个状态分组在一个列表中。现在我们可以使用这个(双重)状态作为解码器的初始状态:
decoder = tf.keras.layers.LSTM(512, return_sequences=True)
decoder_outputs = decoder(decoder_embeddings, initial_state=encoder_state)
接下来,我们可以通过具有 softmax 激活函数的Dense层将解码器的输出传递,以获得每个步骤的单词概率:
output_layer = tf.keras.layers.Dense(vocab_size, activation="softmax")
Y_proba = output_layer(decoder_outputs)
就是这样!我们只需要创建 KerasModel,编译它并训练它:
model = tf.keras.Model(inputs=[encoder_inputs, decoder_inputs],
outputs=[Y_proba])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
metrics=["accuracy"])
model.fit((X_train, X_train_dec), Y_train, epochs=10,
validation_data=((X_valid, X_valid_dec), Y_valid))
训练后,我们可以使用该模型将新的英语句子翻译成西班牙语。但这并不像调用model.predict()那样简单,因为解码器期望的输入是上一个时间步预测的单词。一种方法是编写一个自定义记忆单元,跟踪先前的输出并在下一个时间步将其馈送给编码器。但为了保持简单,我们可以多次调用模型,每轮预测一个额外的单词。让我们为此编写一个小型实用程序函数:
def translate(sentence_en):
translation = ""
for word_idx in range(max_length):
X = np.array([sentence_en]) # encoder input
X_dec = np.array(["startofseq " + translation]) # decoder input
y_proba = model.predict((X, X_dec))[0, word_idx] # last token's probas
predicted_word_id = np.argmax(y_proba)
predicted_word = text_vec_layer_es.get_vocabulary()[predicted_word_id]
if predicted_word == "endofseq":
break
translation += " " + predicted_word
return translation.strip()
该函数只是逐步预测一个单词,逐渐完成翻译,并在达到 EOS 标记时停止。让我们试试看!
>>> translate("I like soccer")
'me gusta el fútbol'
万岁,它起作用了!嗯,至少对于非常短的句子是这样。如果您尝试使用这个模型一段时间,您会发现它还不是双语的,特别是在处理更长的句子时会遇到困难。例如:
>>> translate("I like soccer and also going to the beach")
'me gusta el fútbol y a veces mismo al bus'
翻译说:“我喜欢足球,有时甚至喜欢公共汽车”。那么你如何改进呢?一种方法是增加训练集的大小,并在编码器和解码器中添加更多的LSTM层。但这只能让你走得更远,所以让我们看看更复杂的技术,从双向循环层开始。
双向 RNN
在每个时间步骤,常规循环层在生成输出之前只查看过去和现在的输入。换句话说,它是因果的,这意味着它不能预测未来。这种类型的 RNN 在预测时间序列时或在序列到序列(seq2seq)模型的解码器中是有意义的。但对于文本分类等任务,或者在 seq2seq 模型的编码器中,通常最好在编码给定单词之前查看下一个单词。
例如,考虑短语“右臂”,“正确的人”和“批评的权利”:要正确编码单词“right”,您需要向前查看。一个解决方案是在相同的输入上运行两个循环层,一个从左到右读取单词,另一个从右到左读取单词,然后在每个时间步骤组合它们的输出,通常通过连接它们。这就是双向循环层的作用(参见图 16-5)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1605.png
图 16-5. 双向循环层
在 Keras 中实现双向循环层,只需将循环层包装在tf.keras.layers.Bidirectional层中。例如,以下Bidirectional层可以用作我们翻译模型中的编码器:
encoder = tf.keras.layers.Bidirectional(
tf.keras.layers.LSTM(256, return_state=True))
注意
Bidirectional层将创建GRU层的克隆(但是在相反方向),并且将同时运行并连接它们的输出。因此,尽管GRU层有 10 个单元,Bidirectional层将在每个时间步输出 20 个值。
只有一个问题。这一层现在将返回四个状态而不是两个:前向LSTM层的最终短期和长期状态,以及后向LSTM层的最终短期和长期状态。我们不能直接将这个四重状态用作解码器的LSTM层的初始状态,因为它只期望两个状态(短期和长期)。我们不能使解码器双向,因为它必须保持因果关系:否则在训练过程中会作弊,而且不起作用。相反,我们可以连接两个短期状态,并连接两个长期状态:
encoder_outputs, *encoder_state = encoder(encoder_embeddings)
encoder_state = [tf.concat(encoder_state[::2], axis=-1), # short-term (0 & 2)
tf.concat(encoder_state[1::2], axis=-1)] # long-term (1 & 3)
现在让我们看看另一种在推理时可以极大提高翻译模型性能的流行技术:束搜索。
束搜索
假设您已经训练了一个编码器-解码器模型,并且您使用它将句子“I like soccer”翻译成西班牙语。您希望它会输出正确的翻译“me gusta el fútbol”,但不幸的是它输出了“me gustan los jugadores”,意思是“我喜欢球员”。看着训练集,您注意到许多句子如“I like cars”,翻译成“me gustan los autos”,所以模型在看到“I like”后输出“me gustan los”并不荒谬。不幸的是,在这种情况下是一个错误,因为“soccer”是单数。模型无法回头修正,所以它尽力完成句子,这种情况下使用了“jugadores”这个词。我们如何让模型有机会回头修正之前的错误呢?最常见的解决方案之一是beam search:它跟踪一个最有希望的句子列表(比如说前三个),在每个解码器步骤中尝试扩展它们一个词,只保留* k 个最有可能的句子。参数k被称为beam width*。
例如,假设您使用模型来翻译句子“I like soccer”,使用 beam search 和 beam width 为 3(参见图 16-6)。在第一个解码器步骤中,模型将为翻译句子中每个可能的第一个词输出一个估计概率。假设前三个词是“me”(75%的估计概率),“a”(3%)和“como”(1%)。这是我们目前的短列表。接下来,我们使用模型为每个句子找到下一个词。对于第一个句子(“me”),也许模型为“gustan”这个词输出 36%的概率,“gusta”这个词输出 32%的概率,“encanta”这个词输出 16%的概率,依此类推。请注意,这些实际上是条件概率,假设句子以“me”开头。对于第二个句子(“a”),模型可能为“mi”这个词输出 50%的条件概率,依此类推。假设词汇表有 1,000 个词,我们将得到每个句子 1,000 个概率。
接下来,我们计算我们考虑的 3,000 个两个词的句子的概率(3 × 1,000)。我们通过将每个词的估计条件概率乘以它完成的句子的估计概率来做到这一点。例如,“me”的句子的估计概率为 75%,而“gustan”这个词的估计条件概率(假设第一个词是“me”)为 36%,所以“me gustan”的估计概率为 75% × 36% = 27%。在计算了所有 3,000 个两个词的句子的概率之后,我们只保留前 3 个。在这个例子中,它们都以“me”开头:“me gustan”(27%),“me gusta”(24%)和“me encanta”(12%)。目前,“me gustan”这个句子领先,但“me gusta”还没有被淘汰。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1606.png
图 16-6。beam search,beam width 为 3
然后我们重复相同的过程:我们使用模型预测这三个句子中的下一个词,并计算我们考虑的所有 3,000 个三个词的句子的概率。也许现在前三个是“me gustan los”(10%),“me gusta el”(8%)和“me gusta mucho”(2%)。在下一步中,我们可能得到“me gusta el fútbol”(6%),“me gusta mucho el”(1%)和“me gusta el deporte”(0.2%)。请注意,“me gustan”已经被淘汰,正确的翻译现在领先。我们在没有额外训练的情况下提高了我们的编码器-解码器模型的性能,只是更明智地使用它。
提示
TensorFlow Addons 库包含一个完整的 seq2seq API,让您可以构建带有注意力的编码器-解码器模型,包括 beam search 等等。然而,它的文档目前非常有限。实现 beam search 是一个很好的练习,所以试一试吧!查看本章的笔记本,了解可能的解决方案。
通过这一切,您可以为相当短的句子获得相当不错的翻译。不幸的是,这种模型在翻译长句子时会表现得非常糟糕。问题再次出在 RNN 的有限短期记忆上。注意力机制是解决这个问题的划时代创新。
注意力机制
考虑一下从单词“soccer”到其翻译“fútbol”的路径,回到图 16-3:这是相当长的!这意味着这个单词的表示(以及所有其他单词)需要在实际使用之前经过许多步骤。我们难道不能让这条路径变短一点吗?
这是 Dzmitry Bahdanau 等人在一篇具有里程碑意义的2014 年论文中的核心思想,作者在其中介绍了一种技术,允许解码器在每个时间步关注适当的单词(由编码器编码)。例如,在解码器需要输出单词“fútbol”的时间步,它将把注意力集中在单词“soccer”上。这意味着从输入单词到其翻译的路径现在要短得多,因此 RNN 的短期记忆限制对其影响要小得多。注意机制彻底改变了神经机器翻译(以及深度学习一般)的方式,显著改进了技术水平,特别是对于长句子(例如,超过 30 个单词)。
注意
NMT 中最常用的度量标准是双语评估助手(BLEU)分数,它将模型产生的每个翻译与人类产生的几个好翻译进行比较:它计算出现在任何目标翻译中的n-gram(n个单词序列)的数量,并调整分数以考虑在目标翻译中产生的n-gram 的频率。
图 16-7 展示了我们带有注意力机制的编码器-解码器模型。在左侧,您可以看到编码器和解码器。现在,我们不仅在每一步将编码器的最终隐藏状态和前一个目标单词发送给解码器(尽管这仍在进行,但在图中没有显示),还将所有编码器的输出发送给解码器。由于解码器无法一次处理所有这些编码器的输出,因此它们需要被聚合:在每个时间步,解码器的记忆单元计算所有编码器输出的加权和。这决定了它在这一步将关注哪些单词。权重α[(t,i)]是第t个解码器时间步的第i个编码器输出的权重。例如,如果权重α[(3,2)]远大于权重α[(3,0)]和α[(3,1)],那么解码器将在这个时间步更多地关注第 2 个单词(“soccer”)的编码器输出,而不是其他两个输出。解码器的其余部分与之前的工作方式相同:在每个时间步,记忆单元接收我们刚刚讨论的输入,以及来自上一个时间步的隐藏状态,最后(尽管在图中没有表示)它接收来自上一个时间步的目标单词(或在推断时,来自上一个时间步的输出)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1607.png
图 16-7。使用带有注意力模型的编码器-解码器网络的神经机器翻译
但是这些α[(t,i)]权重是从哪里来的呢?嗯,它们是由一个称为对齐模型(或注意力层)的小型神经网络生成的,该模型与其余的编码器-解码器模型一起进行训练。这个对齐模型在图 16-7 的右侧进行了说明。它以一个由单个神经元组成的Dense层开始,处理每个编码器的输出,以及解码器的先前隐藏状态(例如h[(2)])。这一层为每个编码器输出(例如e[(3,] [2)])输出一个分数(或能量):这个分数衡量每个输出与解码器先前隐藏状态的对齐程度。例如,在图 16-7 中,模型已经输出了“me gusta el”(意思是“我喜欢”),所以现在期望一个名词:单词“soccer”是与当前状态最匹配的,所以它得到了一个高分。最后,所有分数都通过 softmax 层,以获得每个编码器输出的最终权重(例如α[(3,2)])。给定解码器时间步的所有权重加起来等于 1。这种特定的注意力机制被称为Bahdanau 注意力(以 2014 年论文的第一作者命名)。由于它将编码器输出与解码器的先前隐藏状态连接起来,因此有时被称为连接注意力(或加性注意力)。
注意
如果输入句子有n个单词,并假设输出句子长度大致相同,那么这个模型将需要计算大约n²个权重。幸运的是,这种二次计算复杂度仍然可行,因为即使是长句子也不会有成千上万个单词。
另一个常见的注意力机制,称为Luong 注意力或乘法注意力,是在2015 年提出的,由 Minh-Thang Luong 等人提出¹⁹。因为对齐模型的目标是衡量编码器输出和解码器先前隐藏状态之间的相似性,作者建议简单地计算这两个向量的点积(参见第四章),因为这通常是一个相当好的相似性度量,而现代硬件可以非常高效地计算它。为了实现这一点,两个向量必须具有相同的维度。点积给出一个分数,所有分数(在给定的解码器时间步长上)都通过 softmax 层,以给出最终的权重,就像 Bahdanau 注意力中一样。Luong 等人提出的另一个简化是使用当前时间步的解码器隐藏状态,而不是上一个时间步(即h[(t)]而不是h[(t–1)]),然后直接使用注意力机制的输出(标记为h~(t))来计算解码器的预测,而不是用它来计算解码器当前的隐藏状态。研究人员还提出了一种点积机制的变体,其中编码器输出首先经过一个全连接层(没有偏置项),然后再计算点积。这被称为“一般”点积方法。研究人员将两种点积方法与连接注意力机制(添加一个重新缩放参数向量v)进行了比较,他们观察到点积变体的性能优于连接注意力。因此,连接注意力现在使用较少。这三种注意力机制的方程式总结在方程式 16-1 中。
方程式 16-1。注意力机制
h~(t)=∑iα(t,i)y(i) with α(t,i)=expe(t,i)∑i‘expe(t,i’) and e(t,i)=h(t)⊺y(i)doth(t)⊺Wy(i)generalv⊺tanh(W[h(t);y(i)])concat
Keras 为 Luong attention 提供了tf.keras.layers.Attention层,为 Bahdanau attention 提供了AdditiveAttention层。让我们将 Luong attention 添加到我们的编码器-解码器模型中。由于我们需要将所有编码器的输出传递给Attention层,所以在创建编码器时,我们首先需要设置return_sequences=True:
encoder = tf.keras.layers.Bidirectional(
tf.keras.layers.LSTM(256, return_sequences=True, return_state=True))
接下来,我们需要创建注意力层,并将解码器的状态和编码器的输出传递给它。然而,为了在每一步访问解码器的状态,我们需要编写一个自定义的记忆单元。为简单起见,让我们使用解码器的输出而不是其状态:实际上这也很有效,并且编码更容易。然后我们直接将注意力层的输出传递给输出层,就像 Luong 注意力论文中建议的那样:
attention_layer = tf.keras.layers.Attention()
attention_outputs = attention_layer([decoder_outputs, encoder_outputs])
output_layer = tf.keras.layers.Dense(vocab_size, activation="softmax")
Y_proba = output_layer(attention_outputs)
就是这样!如果训练这个模型,你会发现它现在可以处理更长的句子。例如:
>>> translate("I like soccer and also going to the beach")
'me gusta el fútbol y también ir a la playa'
简而言之,注意力层提供了一种让模型集中注意力于输入的一部分的方法。但是还有另一种方式来思考这个层:它充当了一个可微分的记忆检索机制。
例如,假设编码器分析了输入句子“I like soccer”,并且成功理解了单词“I”是主语,单词“like”是动词,因此在这些单词的输出中编码了这些信息。现在假设解码器已经翻译了主语,并且认为接下来应该翻译动词。为此,它需要从输入句子中提取动词。这类似于字典查找:就好像编码器创建了一个字典{“subject”: “They”, “verb”: “played”, …},解码器想要查找与键“verb”对应的值。
然而,模型没有离散的令牌来表示键(如“主语”或“动词”);相反,它具有这些概念的矢量化表示,这些表示在训练期间学习到,因此用于查找的查询不会完全匹配字典中的任何键。解决方案是计算查询与字典中每个键之间的相似度度量,然后使用 softmax 函数将这些相似度分数转换为总和为 1 的权重。正如我们之前看到的那样,这正是注意力层所做的。如果代表动词的键与查询最相似,那么该键的权重将接近 1。
接下来,注意力层计算相应值的加权和:如果“动词”键的权重接近 1,那么加权和将非常接近单词“played”的表示。
这就是为什么 Keras 的Attention和AdditiveAttention层都期望输入一个包含两个或三个项目的列表:queries,keys,以及可选的values。如果不传递任何值,则它们会自动等于键。因此,再次查看前面的代码示例,解码器输出是查询,编码器输出既是键也是值。对于每个解码器输出(即每个查询),注意力层返回与解码器输出最相似的编码器输出(即键/值)的加权和。
关键是,注意力机制是一个可训练的内存检索系统。它非常强大,以至于您实际上可以仅使用注意力机制构建最先进的模型。进入Transformer架构。
注意力就是你所需要的:原始Transformer架构
在一篇开创性的2017 年论文,²⁰一组谷歌研究人员建议“注意力就是你所需要的”。他们创建了一种称为Transformer的架构,显著改进了 NMT 的最新技术,而不使用任何循环或卷积层,仅使用注意力机制(加上嵌入层、稠密层、归一化层和其他一些部分)。由于该模型不是循环的,所以不像 RNN 那样容易受到梯度消失或梯度爆炸问题的困扰,可以在较少的步骤中训练,更容易在多个 GPU 上并行化,并且可以比 RNN 更好地捕捉长距离模式。原始的 2017 年Transformer架构在图 16-8 中表示。
简而言之,图 16-8 的左侧是编码器,右侧是解码器。每个嵌入层输出一个形状为[批量大小,序列长度,嵌入大小]的 3D 张量。之后,随着数据流经Transformer,张量逐渐转换,但形状保持不变。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1608.png
图 16-8。原始的 2017 年Transformer架构²²
如果您将Transformer用于 NMT,则在训练期间必须将英语句子馈送给编码器,将相应的西班牙语翻译馈送给解码器,并在每个句子开头插入额外的 SOS 令牌。在推理时,您必须多次调用Transformer,逐字产生翻译,并在每轮将部分翻译馈送给解码器,就像我们之前在translate()函数中所做的那样。
编码器的作用是逐渐转换输入——英文句子的单词表示——直到每个单词的表示完美地捕捉到单词的含义,在句子的上下文中。例如,如果你用句子“I like soccer”来喂给编码器,那么单词“like”将以一个相当模糊的表示开始,因为这个单词在不同的上下文中可能有不同的含义:想想“I like soccer”和“It’s like that”。但是经过编码器后,单词的表示应该捕捉到给定句子中“like”的正确含义(即喜欢),以及可能需要用于翻译的任何其他信息(例如,它是一个动词)。
解码器的作用是逐渐将翻译句子中的每个单词表示转换为翻译中下一个单词的单词表示。例如,如果要翻译的句子是“I like soccer”,解码器的输入句子是“ me gusta el fútbol”,那么经过解码器后,“el”的单词表示将最终转换为“fútbol”的表示。同样,“fútbol”的表示将被转换为 EOS 标记的表示。
经过解码器后,每个单词表示都经过一个带有 softmax 激活函数的最终Dense层,希望能够输出正确下一个单词的高概率和所有其他单词的低概率。预测的句子应该是“me gusta el fútbol ”。
那是大局观;现在让我们更详细地走一遍图 16-8:
-
首先,注意到编码器和解码器都包含被堆叠N次的模块。在论文中,N = 6。整个编码器堆栈的最终输出在每个这些N级别上被馈送到解码器。
-
放大一下,你会发现你已经熟悉大部分组件:有两个嵌入层;几个跳跃连接,每个连接后面跟着一个层归一化层;几个由两个密集层组成的前馈模块(第一个使用 ReLU 激活函数,第二个没有激活函数);最后输出层是使用 softmax 激活函数的密集层。如果需要的话,你也可以在注意力层和前馈模块之后添加一点 dropout。由于所有这些层都是时间分布的,每个单词都独立于其他所有单词。但是我们如何通过完全分开地查看单词来翻译句子呢?嗯,我们不能,这就是新组件发挥作用的地方:
-
编码器的多头注意力层通过关注同一句子中的所有其他单词来更新每个单词的表示。这就是单词“like”的模糊表示变得更丰富和更准确的表示的地方,捕捉了它在给定句子中的确切含义。我们将很快讨论这是如何工作的。
-
解码器的掩码多头注意力层做同样的事情,但当处理一个单词时,它不会关注在它之后的单词:这是一个因果层。例如,当处理单词“gusta”时,它只会关注“ me gusta”这几个单词,而忽略“el fútbol”这几个单词(否则那就是作弊了)。
-
解码器的上层多头注意力层是解码器关注英文句子中的单词的地方。这被称为交叉注意力,在这种情况下不是自我注意力。例如,当解码器处理单词“el”并将其表示转换为“fútbol”的表示时,解码器可能会密切关注单词“soccer”。
-
位置编码是密集向量(类似于单词嵌入),表示句子中每个单词的位置。第n个位置编码被添加到每个句子中第n个单词的单词嵌入中。这是因为Transformer架构中的所有层都忽略单词位置:没有位置编码,您可以对输入序列进行洗牌,它只会以相同方式洗牌输出序列。显然,单词的顺序很重要,这就是为什么我们需要以某种方式向Transformer提供位置信息的原因:将位置编码添加到单词表示是实现这一点的好方法。
-
注意
图 16-8 中每个多头注意力层的前两个箭头代表键和值,第三个箭头代表查询。在自注意力层中,所有三个都等于前一层输出的单词表示,而在解码器的上层注意力层中,键和值等于编码器的最终单词表示,查询等于前一层输出的单词表示。
让我们更详细地了解Transformer架构中的新颖组件,从位置编码开始。
位置编码
位置编码是一个密集向量,用于编码句子中单词的位置:第i个位置编码被添加到句子中第i个单词的单词嵌入中。实现这一点的最简单方法是使用Embedding层,并使其对批处理中从 0 到最大序列长度的所有位置进行编码,然后将结果添加到单词嵌入中。广播规则将确保位置编码应用于每个输入序列。例如,以下是如何将位置编码添加到编码器和解码器输入的方法:
max_length = 50 # max length in the whole training set
embed_size = 128
pos_embed_layer = tf.keras.layers.Embedding(max_length, embed_size)
batch_max_len_enc = tf.shape(encoder_embeddings)[1]
encoder_in = encoder_embeddings + pos_embed_layer(tf.range(batch_max_len_enc))
batch_max_len_dec = tf.shape(decoder_embeddings)[1]
decoder_in = decoder_embeddings + pos_embed_layer(tf.range(batch_max_len_dec))
请注意,此实现假定嵌入表示为常规张量,而不是不规则张量。²³ 编码器和解码器共享相同的Embedding层用于位置编码,因为它们具有相同的嵌入大小(这通常是这种情况)。
Transformer论文的作者选择使用基于正弦和余弦函数在不同频率下的固定位置编码,而不是使用可训练的位置编码。位置编码矩阵P在方程 16-2 中定义,并在图 16-9 的顶部(转置)表示,其中P[p,i]是句子中位于第p位置的单词的编码的第i个分量。
方程 16-2。正弦/余弦位置编码
Pp,i=sin(p/10000i/d)如果i是偶数cos(p/10000(i-1)/d)如果i是奇数https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1609.png
图 16-9。正弦/余弦位置编码矩阵(转置,顶部)关注两个i值(底部)
这个解决方案可以提供与可训练位置编码相同的性能,并且可以扩展到任意长的句子,而不需要向模型添加任何参数(然而,当有大量预训练数据时,通常会优先选择可训练位置编码)。在这些位置编码添加到单词嵌入之后,模型的其余部分可以访问句子中每个单词的绝对位置,因为每个位置都有一个唯一的位置编码(例如,句子中位于第 22 个位置的单词的位置编码由图 16-9 左上角的垂直虚线表示,您可以看到它是唯一的)。此外,选择振荡函数(正弦和余弦)使模型能够学习相对位置。例如,相距 38 个单词的单词(例如,在位置p=22 和p=60 处)在编码维度i=100 和i=101 中始终具有相同的位置编码值,如图 16-9 所示。这解释了为什么我们需要每个频率的正弦和余弦:如果我们只使用正弦(i=100 处的蓝色波),模型将无法区分位置p=22 和p=35(由十字标记)。
在 TensorFlow 中没有PositionalEncoding层,但创建一个并不太困难。出于效率原因,我们在构造函数中预先计算位置编码矩阵。call()方法只是将这个编码矩阵截断到输入序列的最大长度,并将其添加到输入中。我们还设置supports_masking=True以将输入的自动掩码传播到下一层:
class PositionalEncoding(tf.keras.layers.Layer):
def __init__(self, max_length, embed_size, dtype=tf.float32, **kwargs):
super().__init__(dtype=dtype, **kwargs)
assert embed_size % 2 == 0, "embed_size must be even"
p, i = np.meshgrid(np.arange(max_length),
2 * np.arange(embed_size // 2))
pos_emb = np.empty((1, max_length, embed_size))
pos_emb[0, :, ::2] = np.sin(p / 10_000 ** (i / embed_size)).T
pos_emb[0, :, 1::2] = np.cos(p / 10_000 ** (i / embed_size)).T
self.pos_encodings = tf.constant(pos_emb.astype(self.dtype))
self.supports_masking = True
def call(self, inputs):
batch_max_length = tf.shape(inputs)[1]
return inputs + self.pos_encodings[:, :batch_max_length]
让我们使用这个层将位置编码添加到编码器的输入中:
pos_embed_layer = PositionalEncoding(max_length, embed_size)
encoder_in = pos_embed_layer(encoder_embeddings)
decoder_in = pos_embed_layer(decoder_embeddings)
现在让我们更深入地看一下Transformer模型的核心,即多头注意力层。
多头注意力
要理解多头注意力层的工作原理,我们首先必须了解它基于的缩放点积注意力层。它的方程式在方程式 16-3 中以矢量化形式显示。它与 Luong 注意力相同,只是有一个缩放因子。
方程式 16-3. 缩放点积注意力
注意力(Q,K,V)=softmaxQK⊺<mi d<mi k<mi e<mi y<mi sV
在这个方程中:
-
Q是包含每个查询的一行的矩阵。其形状为[n[queries], d[keys]],其中n[queries]是查询的数量,d[keys]是每个查询和每个键的维度数量。
-
K是包含每个键的一行的矩阵。其形状为[n[keys], d[keys]],其中n[keys]是键和值的数量。
-
V是包含每个值的一行的矩阵。其形状为[n[keys], d[values]],其中d[values]是每个值的维度数量。
-
Q K^⊺的形状为[n[queries], n[keys]]:它包含每个查询/键对的一个相似度分数。为了防止这个矩阵过大,输入序列不能太长(我们将在本章后面讨论如何克服这个限制)。softmax 函数的输出具有相同的形状,但所有行的总和为 1。最终输出的形状为[n[queries], d[values]]:每个查询有一行,其中每行代表查询结果(值的加权和)。
-
缩放因子 1 / (<mi d <mi keys)将相似度分数缩小,以避免饱和 softmax 函数,这会导致梯度很小。
-
可以通过在计算 softmax 之前,向相应的相似性分数添加一个非常大的负值来屏蔽一些键/值对,这在掩码多头注意力层中非常有用。
如果在创建tf.keras.layers.Attention层时设置use_scale=True,那么它将创建一个额外的参数,让该层学习如何正确地降低相似性分数。Transformer模型中使用的缩放后的点积注意力几乎相同,只是它总是将相似性分数按相同因子缩放,即 1 / (d keys)。
请注意,Attention层的输入就像Q、K和V一样,只是多了一个批处理维度(第一个维度)。在内部,该层仅通过一次调用tf.matmul(queries, keys)计算批处理中所有句子的所有注意力分数:这使得它非常高效。实际上,在 TensorFlow 中,如果A和B是具有两个以上维度的张量,比如形状为[2, 3, 4, 5]和[2, 3, 5, 6],那么tf.matmul(A, B)将把这些张量视为 2×3 数组,其中每个单元格包含一个矩阵,并将相应的矩阵相乘:A中第i行和第j列的矩阵将与B中第i行和第j列的矩阵相乘。由于一个 4×5 矩阵与一个 5×6 矩阵的乘积是一个 4×6 矩阵,tf.matmul(A, B)将返回一个形状为[2, 3, 4, 6]的数组。
现在我们准备看一下多头注意力层。其架构如图 16-10 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1610.png
图 16-10. 多头注意力层架构
正如您所看到的,它只是一堆缩放后的点积注意力层,每个层之前都有一个值、键和查询的线性变换(即没有激活函数的时间分布密集层)。所有输出都简单地连接在一起,并通过最终的线性变换(再次是时间分布的)。
但是为什么?这种架构背后的直觉是什么?好吧,再次考虑一下句子“I like soccer”中的单词“like”。编码器足够聪明,能够编码它是一个动词的事实。但是单词表示还包括其在文本中的位置,这要归功于位置编码,它可能还包括许多其他对其翻译有用的特征,比如它是现在时。简而言之,单词表示编码了单词的许多不同特征。如果我们只使用一个缩放后的点积注意力层,我们只能一次性查询所有这些特征。
这就是为什么多头注意力层应用多个不同的线性变换值、键和查询:这使得模型能够将单词表示的许多不同特征投影到不同的子空间中,每个子空间都专注于单词的某些特征。也许其中一个线性层将单词表示投影到一个只剩下单词是动词信息的子空间,另一个线性层将提取出它是现在时的事实,依此类推。然后缩放后的点积注意力层实现查找阶段,最后我们将所有结果连接起来并将它们投影回原始空间。
Keras 包括一个tf.keras.layers.MultiHeadAttention层,因此我们现在拥有构建Transformer其余部分所需的一切。让我们从完整的编码器开始,它与图 16-8 中的完全相同,只是我们使用两个块的堆叠(N = 2)而不是六个,因为我们没有一个庞大的训练集,并且我们还添加了一点辍学:
N = 2 # instead of 6
num_heads = 8
dropout_rate = 0.1
n_units = 128 # for the first dense layer in each feedforward block
encoder_pad_mask = tf.math.not_equal(encoder_input_ids, 0)[:, tf.newaxis]
Z = encoder_in
for _ in range(N):
skip = Z
attn_layer = tf.keras.layers.MultiHeadAttention(
num_heads=num_heads, key_dim=embed_size, dropout=dropout_rate)
Z = attn_layer(Z, value=Z, attention_mask=encoder_pad_mask)
Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
skip = Z
Z = tf.keras.layers.Dense(n_units, activation="relu")(Z)
Z = tf.keras.layers.Dense(embed_size)(Z)
Z = tf.keras.layers.Dropout(dropout_rate)(Z)
Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
这段代码应该大多数都很简单,除了一个问题:掩码。在撰写本文时,MultiHeadAttention层不支持自动掩码,因此我们必须手动处理。我们该如何做?
MultiHeadAttention层接受一个attention_mask参数,这是一个形状为[batch size, max query length, max value length]的布尔张量:对于每个查询序列中的每个标记,这个掩码指示应该关注对应值序列中的哪些标记。我们想告诉MultiHeadAttention层忽略值中的所有填充标记。因此,我们首先使用tf.math.not_equal(encoder_input_ids, 0)计算填充掩码。这将返回一个形状为[batch size, max sequence length]的布尔张量。然后我们使用[:, tf.newaxis]插入第二个轴,得到形状为[batch size, 1, max sequence length]的掩码。这使我们能够在调用MultiHeadAttention层时将此掩码用作attention_mask:由于广播,相同的掩码将用于每个查询中的所有标记。这样,值中的填充标记将被正确忽略。
然而,该层将为每个单独的查询标记计算输出,包括填充标记。我们需要掩盖与这些填充标记对应的输出。回想一下,在Embedding层中我们使用了mask_zero,并且在PositionalEncoding层中我们将supports_masking设置为True,因此自动掩码一直传播到MultiHeadAttention层的输入(encoder_in)。我们可以利用这一点在跳过连接中:实际上,Add层支持自动掩码,因此当我们将Z和skip(最初等于encoder_in)相加时,输出将自动正确掩码。天啊!掩码需要比代码更多的解释。
现在开始解码器!再次,掩码将是唯一棘手的部分,所以让我们从那里开始。第一个多头注意力层是一个自注意力层,就像在编码器中一样,但它是一个掩码多头注意力层,这意味着它是因果的:它应该忽略未来的所有标记。因此,我们需要两个掩码:一个填充掩码和一个因果掩码。让我们创建它们:
decoder_pad_mask = tf.math.not_equal(decoder_input_ids, 0)[:, tf.newaxis]
causal_mask = tf.linalg.band_part( # creates a lower triangular matrix
tf.ones((batch_max_len_dec, batch_max_len_dec), tf.bool), -1, 0)
填充掩码与我们为编码器创建的掩码完全相同,只是基于解码器的输入而不是编码器的。因果掩码使用tf.linalg.band_part()函数创建,该函数接受一个张量并返回一个将对角线带外的所有值设置为零的副本。通过这些参数,我们得到一个大小为batch_max_len_dec(批处理中输入序列的最大长度)的方阵,左下三角形中为 1,右上角为 0。如果我们将此掩码用作注意力掩码,我们将得到我们想要的:第一个查询标记只会关注第一个值标记,第二个只会关注前两个,第三个只会关注前三个,依此类推。换句话说,查询标记不能关注未来的任何值标记。
现在让我们构建解码器:
encoder_outputs = Z # let's save the encoder's final outputs
Z = decoder_in # the decoder starts with its own inputs
for _ in range(N):
skip = Z
attn_layer = tf.keras.layers.MultiHeadAttention(
num_heads=num_heads, key_dim=embed_size, dropout=dropout_rate)
Z = attn_layer(Z, value=Z, attention_mask=causal_mask & decoder_pad_mask)
Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
skip = Z
attn_layer = tf.keras.layers.MultiHeadAttention(
num_heads=num_heads, key_dim=embed_size, dropout=dropout_rate)
Z = attn_layer(Z, value=encoder_outputs, attention_mask=encoder_pad_mask)
Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
skip = Z
Z = tf.keras.layers.Dense(n_units, activation="relu")(Z)
Z = tf.keras.layers.Dense(embed_size)(Z)
Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
对于第一个注意力层,我们使用causal_mask & decoder_pad_mask来同时掩盖填充标记和未来标记。因果掩码只有两个维度:它缺少批处理维度,但这没关系,因为广播确保它在批处理中的所有实例中被复制。
对于第二个注意力层,没有特别之处。唯一需要注意的是我们使用encoder_pad_mask而不是decoder_pad_mask,因为这个注意力层使用编码器的最终输出作为其值。
我们快要完成了。我们只需要添加最终的输出层,创建模型,编译它,然后训练它:
Y_proba = tf.keras.layers.Dense(vocab_size, activation="softmax")(Z)
model = tf.keras.Model(inputs=[encoder_inputs, decoder_inputs],
outputs=[Y_proba])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
metrics=["accuracy"])
model.fit((X_train, X_train_dec), Y_train, epochs=10,
validation_data=((X_valid, X_valid_dec), Y_valid))
恭喜!您已经从头开始构建了一个完整的 Transformer,并对其进行了自动翻译的训练。这变得相当高级了!
提示
Keras 团队创建了一个新的Keras NLP 项目,其中包括一个 API,可以更轻松地构建Transformer。您可能还对新的Keras CV 项目(用于计算机视觉)感兴趣。
但领域并没有就此停止。现在让我们来探讨一些最近的进展。
Transformer模型的大量涌现
2018 年被称为 NLP 的“ImageNet 时刻”。从那时起,进展一直令人震惊,基于巨大数据集训练的基于Transformer的架构越来越大。
首先,Alec Radford 和其他 OpenAI 研究人员的GPT 论文再次展示了无监督预训练的有效性,就像 ELMo 和 ULMFiT 论文之前一样,但这次使用了类似Transformer的架构。作者们预训练了一个由 12 个Transformer模块堆叠而成的大型但相当简单的架构,只使用了像原始Transformer解码器中的掩码多头注意力层。他们在一个非常庞大的数据集上进行了训练,使用了我们用于莎士比亚 char-RNN 的相同自回归技术:只需预测下一个标记。这是一种自监督学习形式。然后,他们对各种语言任务进行了微调,每个任务只进行了轻微的调整。这些任务非常多样化:它们包括文本分类、蕴涵(句子 A 是否对句子 B 施加、涉及或暗示必要的后果)、相似性(例如,“今天天气很好”与“阳光明媚”非常相似)和问答(给定一些提供一些背景的文本段落,模型必须回答一些多项选择题)。
然后谷歌的BERT 论文出现了:它也展示了在大型语料库上进行自监督预训练的有效性,使用了与 GPT 类似的架构,但只使用了非掩码多头注意力层,就像原始Transformer的编码器中一样。这意味着模型是自然双向的;因此 BERT 中的 B(来自Transformer的双向编码器表示)。最重要的是,作者提出了两个预训练任务,解释了模型大部分的强度:
掩码语言模型(MLM)
句子中的每个单词有 15%的概率被掩盖,模型经过训练,以预测被掩盖的单词。例如,如果原始句子是“她在生日聚会上玩得很开心”,那么模型可能会得到句子“她在聚会上玩得很开心”,它必须预测单词“had”和“birthday”(其他输出将被忽略)。更准确地说,每个选择的单词有 80%的概率被掩盖,10%的概率被替换为随机单词(为了减少预训练和微调之间的差异,因为模型在微调过程中不会看到标记),以及 10%的概率被保留(以偏向模型正确答案)。
下一个句子预测(NSP)
该模型经过训练,以预测两个句子是否连续。例如,它应该预测“狗在睡觉”和“它打呼噜”是连续的句子,而“狗在睡觉”和“地球绕着太阳转”不是连续的。后来的研究表明,NSP 并不像最初认为的那么重要,因此在大多数后来的架构中被放弃了。
该模型同时在这两个任务上进行训练(参见图 16-11)。对于 NSP 任务,作者在每个输入的开头插入了一个类标记(),相应的输出标记表示模型的预测:句子 B 跟在句子 A 后面,或者不是。这两个输入句子被连接在一起,只用一个特殊的分隔标记()分开,然后作为输入提供给模型。为了帮助模型知道每个输入标记属于哪个句子,每个标记的位置嵌入上面添加了一个段嵌入:只有两种可能的段嵌入,一个用于句子 A,一个用于句子 B。对于 MLM 任务,一些输入词被屏蔽(正如我们刚才看到的),模型试图预测这些词是什么。损失仅在 NSP 预测和被屏蔽的标记上计算,而不是在未被屏蔽的标记上。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1611.png
图 16-11. BERT 训练和微调过程
在对大量文本进行无监督预训练阶段之后,该模型然后在许多不同的任务上进行微调,每个任务的变化都很小。例如,对于文本分类(如情感分析),所有输出标记都被忽略,除了第一个,对应于类标记,一个新的输出层取代了以前的输出层,以前的输出层只是一个用于 NSP 的二元分类层。
2019 年 2 月,就在 BERT 发布几个月后,Alec Radford、Jeffrey Wu 和其他 OpenAI 研究人员发表了GPT-2 论文,提出了一个与 GPT 非常相似但规模更大的架构(超过 15 亿个参数!)。研究人员展示了新改进的 GPT 模型可以进行零样本学习(ZSL),这意味着它可以在许多任务上取得良好的表现而无需任何微调。这只是朝着更大更大模型的竞赛的开始:谷歌的Switch Transformers(2021 年 1 月推出)使用了 1 万亿个参数,很快就会推出更大的模型,比如 2021 年 6 月北京人工智能学会(BAII)宣布的 Wu Dao 2.0 模型。
巨型模型的这种趋势不幸地导致只有经济实力雄厚的组织才能负担得起训练这样的模型:成本很容易就能达到几十万美元甚至更高。训练单个模型所需的能量相当于一个美国家庭几年的电力消耗;这一点根本不环保。许多这些模型甚至太大,无法在常规硬件上使用:它们无法适应内存,速度也会非常慢。最后,有些模型成本如此之高,以至于不会公开发布。
幸运的是,聪明的研究人员正在找到新的方法来缩小Transformer并使其更具数据效率。例如,2019 年 10 月由 Hugging Face 的 Victor Sanh 等人推出的DistilBERT 模型是基于 BERT 的一个小型快速Transformer模型。它可以在 Hugging Face 出色的模型中心找到,其中包括成千上万个其他模型——本章后面将会看到一个示例。
DistilBERT 是使用蒸馏(因此得名)进行训练的:这意味着将知识从一个教师模型转移到一个通常比教师模型小得多的学生模型。通常通过使用教师对每个训练实例的预测概率作为学生的目标来实现。令人惊讶的是,蒸馏通常比在相同数据集上从头开始训练学生更有效!事实上,学生受益于教师更加微妙的标签。
在 BERT 之后,还有更多的 transformer 架构陆续推出,几乎每个月都有,通常在所有 NLP 任务的最新技术上有所改进:XLNet(2019 年 6 月),RoBERTa(2019 年 7 月),StructBERT(2019 年 8 月),ALBERT(2019 年 9 月),T5(2019 年 10 月),ELECTRA(2020 年 3 月),GPT3(2020 年 5 月),DeBERTa(2020 年 6 月),Switch Transformers(2021 年 1 月),Wu Dao 2.0(2021 年 6 月),Gopher(2021 年 12 月),GPT-NeoX-20B(2022 年 2 月),Chinchilla(2022 年 3 月),OPT(2022 年 5 月),等等。每个模型都带来了新的想法和技术,但我特别喜欢谷歌研究人员的T5 论文:它将所有 NLP 任务都框定为文本到文本,使用编码器-解码器 transformer。例如,要将“I like soccer”翻译成西班牙语,您只需用输入句子“translate English to Spanish: I like soccer”调用模型,它会输出“me gusta el fútbol”。要总结一段文字,您只需输入“summarize:”后跟段落,它会输出摘要。对于分类,只需将前缀更改为“classify:”,模型会输出类名,作为文本。这简化了使用模型,也使其能够在更多任务上进行预训练。
最后但并非最不重要的是,在 2022 年 4 月,谷歌研究人员使用了一个名为Pathways的新大规模训练平台(我们将在第十九章中简要讨论),来训练一个名为Pathways 语言模型(PaLM),拥有惊人的 5400 亿个参数,使用了超过 6000 个 TPU。除了其令人难以置信的规模之外,这个模型是一个标准的 transformer,只使用解码器(即,带有掩码多头注意力层),只有一些微调(详细信息请参阅论文)。这个模型在各种 NLP 任务中取得了令人难以置信的表现,特别是在自然语言理解(NLU)方面。它能够完成令人印象深刻的壮举,比如解释笑话,给出详细的逐步回答问题的答案,甚至编码。这在一定程度上归功于模型的规模,也归功于一种称为思维链提示的技术,这种技术是几个月前由另一个谷歌研究团队引入的。
在问答任务中,常规提示通常包括一些问题和答案的示例,例如:“Q: Roger 有 5 个网球。他买了 2 罐网球。每罐有 3 个网球。他现在有多少网球?A: 11。”然后提示继续提出实际问题,比如“Q: John 照顾 10 只狗。每只狗每天需要 0.5 小时散步和照顾自己的事务。他每周花多少时间照顾狗?A:”,模型的任务是附加答案:在这种情况下是“35”。
但是通过思维链提示,示例答案包括导致结论的所有推理步骤。例如,不是“A: 11”,提示包含“A: Roger 从 5 个球开始。2 罐每罐 3 个网球,总共 6 个网球。5 + 6 = 11。”这鼓励模型给出对实际问题的详细答案,比如“John 照顾 10 只狗。每只狗每天需要 0.5 小时散步和照顾自己的事务。所以是 10 × 0.5 = 5 小时每天。5 小时每天 × 7 天每周 = 35 小时每周。答案是每周 35 小时。”这是论文中的一个实际例子!
这个模型不仅比使用常规提示更频繁地给出正确答案——我们鼓励模型深思熟虑——而且还提供了所有推理步骤,这对于更好地理解模型答案背后的原理是有用的。
transformers 已经在 NLP 领域占据了主导地位,但它们并没有止步于此:它们很快也扩展到了计算机视觉领域。
视觉 transformers
注意机制在 NMT 之外的第一个应用是使用视觉注意力生成图像字幕:一个卷积神经网络首先处理图像并输出一些特征图,然后一个带有注意力机制的解码器 RNN 逐个单词生成字幕。
在每个解码器时间步骤(即每个单词),解码器使用注意力模型专注于图像的正确部分。例如,在图 16-12 中,模型生成了字幕“A woman is throwing a frisbee in a park”,您可以看到当解码器准备输出单词“frisbee”时,它的注意力集中在输入图像的哪个部分:显然,它的大部分注意力集中在飞盘上。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1612.png
图 16-12。视觉注意力:输入图像(左)和生成单词“frisbee”之前模型的焦点(右)
当 transformers 在 2017 年问世并且人们开始在 NLP 之外进行实验时,它们最初是与 CNN 一起使用的,而不是取代它们。相反,transformers 通常用来取代 RNN,例如在图像字幕模型中。在2020 年的一篇论文中,Facebook 的研究人员提出了一个混合 CNN-transformer 架构用于目标检测。再次,CNN 首先处理输入图像并输出一组特征图,然后这些特征图被转换为序列并馈送到 transformer 中,transformer 输出边界框预测。但是,大部分视觉工作仍然由 CNN 完成。
然后,在 2020 年 10 月,一组谷歌研究人员发布了一篇论文,介绍了一种完全基于 transformer 的视觉模型,称为vision transformer(ViT)。这个想法非常简单:只需将图像切成小的 16×16 的方块,并将方块序列视为单词表示的序列。更准确地说,方块首先被展平为 16×16×3=768 维向量——3 代表 RGB 颜色通道——然后这些向量经过一个线性层进行转换但保留其维度。然后产生的向量序列可以像单词嵌入序列一样处理:这意味着添加位置嵌入,并将结果传递给 transformer。就是这样!这个模型在 ImageNet 图像分类上击败了现有技术,但公平地说,作者们必须使用超过 3 亿张额外的图像进行训练。这是有道理的,因为 transformer 没有像卷积神经网络那样多的归纳偏差,所以它们需要额外的数据来学习 CNN 隐含假设中的东西。
注意
归纳偏差是模型由于其架构而做出的隐含假设。例如,线性模型隐含地假设数据是线性的。CNN 隐含地假设在一个位置学习到的模式在其他位置也可能有用。RNN 隐含地假设输入是有序的,并且最近的标记比较重要。模型具有的归纳偏差越多,假设它们是正确的,模型所需的训练数据就越少。但是,如果隐含的假设是错误的,那么即使在大型数据集上训练,模型也可能表现不佳。
仅仅两个月后,Facebook 的一个研究团队发布了一篇论文,介绍了数据高效图像变换器(DeiTs)。他们的模型在 ImageNet 上取得了竞争性的结果,而无需额外的训练数据。该模型的架构与原始 ViT 几乎相同,但作者使用了一种蒸馏技术,将来自最先进的 CNN 模型的知识转移到他们的模型中。
然后,2021 年 3 月,DeepMind 发布了一篇重要的论文,介绍了Perceiver架构。这是一种多模态Transformer,意味着您可以向其提供文本、图像、音频或几乎任何其他模态。直到那时,Transformer由于注意力层中的性能和 RAM 瓶颈而被限制在相当短的序列中。这排除了音频或视频等模态,并迫使研究人员将图像视为补丁序列,而不是像素序列。瓶颈是由于自我注意力,其中每个标记必须关注每个其他标记:如果输入序列有M个标记,那么注意力层必须计算一个M×M矩阵,如果M非常大,这可能会很大。Perceiver 通过逐渐改进由N个标记组成的输入的相当短的潜在表示来解决这个问题——通常只有几百个。 (潜在一词表示隐藏或内部。)该模型仅使用交叉注意力层,将潜在表示作为查询输入,并将(可能很大的)输入作为值输入。这只需要计算一个M×N矩阵,因此计算复杂度与M线性相关,而不是二次的。经过几个交叉注意力层后,如果一切顺利,潜在表示最终会捕捉到输入中的所有重要内容。作者还建议在连续的交叉注意力层之间共享权重:如果这样做,那么 Perceiver 实际上就变成了一个 RNN。实际上,共享的交叉注意力层可以被看作是不同时间步的相同记忆单元,而潜在表示对应于单元的上下文向量。相同的输入会在每个时间步骤中重复馈送到记忆单元。看来 RNN 并没有完全消亡!
仅仅一个月后,Mathilde Caron 等人介绍了DINO,一个令人印象深刻的视觉变换器,完全不使用标签进行训练,使用自我监督,并能够进行高精度的语义分割。该模型在训练期间被复制,其中一个网络充当教师,另一个充当学生。梯度下降仅影响学生,而教师的权重只是学生权重的指数移动平均值。学生被训练以匹配教师的预测:由于它们几乎是相同的模型,这被称为自蒸馏。在每个训练步骤中,输入图像以不同方式增强教师和学生,因此它们不会看到完全相同的图像,但它们的预测必须匹配。这迫使它们提出高级表示。为了防止模式坍塌,即学生和教师总是输出相同的内容,完全忽略输入,DINO 跟踪教师输出的移动平均值,并调整教师的预测,以确保它们平均保持在零点上。DINO 还迫使教师对其预测具有高置信度:这被称为锐化。这些技术共同保留了教师输出的多样性。
在一篇 2021 年的论文中,Google 研究人员展示了如何根据数据量来扩展或缩小 ViTs。他们成功创建了一个庞大的 20 亿参数模型,在 ImageNet 上达到了超过 90.4%的 top-1 准确率。相反,他们还训练了一个缩小模型,在 ImageNet 上达到了超过 84.8%的 top-1 准确率,只使用了 1 万张图像:每类只有 10 张图像!
视觉 transformers 的进展一直在稳步进行。例如,2022 年 3 月,Mitchell Wortsman 等人的一篇论文展示了首先训练多个 transformers,然后平均它们的权重以创建一个新的改进模型是可能的。这类似于集成(见第七章),只是最终只有一个模型,这意味着没有推理时间惩罚。
transformers 领域的最新趋势在于构建大型多模态模型,通常能够进行零样本或少样本学习。例如,OpenAI 的 2021 年 CLIP 论文提出了一个大型 transformer 模型,预训练以匹配图像的标题:这个任务使其能够学习出色的图像表示,然后该模型可以直接用于诸如使用简单文本提示进行图像分类的任务,比如“一张猫的照片”。不久之后,OpenAI 宣布了DALL·E,能够根据文本提示生成惊人的图像。DALL·E 2生成更高质量的图像,使用扩散模型(见第十七章)。
2022 年 4 月,DeepMind 发布了Flamingo paper,介绍了一系列在多种任务和多种模态下预训练的模型,包括文本、图像和视频。一个模型可以用于非常不同的任务,比如问答、图像描述等。不久之后,2022 年 5 月,DeepMind 推出了GATO,一个多模态模型,可以作为强化学习代理的策略(强化学习将在第十八章介绍)。同一个 transformer 可以与您聊天,为图像加注释,玩 Atari 游戏,控制(模拟的)机械臂等,所有这些只需“仅有”12 亿个参数。冒险还在继续!
注意
这些惊人的进步使一些研究人员认为人类水平的 AI 已经近在眼前,认为“规模就是一切”,并且一些模型可能“稍微有意识”。其他人指出,尽管取得了惊人的进步,这些模型仍然缺乏人类智能的可靠性和适应性,我们推理的符号能力,基于单个例子进行泛化的能力等等。
正如您所看到的,transformers 无处不在!好消息是,通常您不必自己实现 transformers,因为许多优秀的预训练模型可以通过 TensorFlow Hub 或 Hugging Face 的模型中心轻松下载。您已经看到如何使用 TF Hub 中的模型,所以让我们通过快速查看 Hugging Face 的生态系统来结束本章。
Hugging Face 的 Transformers 库
今天谈论 transformers 时不可能不提到 Hugging Face,这是一家为 NLP、视觉等构建了一整套易于使用的开源工具的人工智能公司。他们生态系统的核心组件是 Transformers 库,它允许您轻松下载一个预训练模型,包括相应的分词器,然后根据需要在自己的数据集上进行微调。此外,该库支持 TensorFlow、PyTorch 和 JAX(使用 Flax 库)。
使用 Transformers 库的最简单方法是使用transformers.pipeline()函数:只需指定您想要的任务,比如情感分析,它会下载一个默认的预训练模型,准备好使用——真的再简单不过了:
from transformers import pipeline
classifier = pipeline("sentiment-analysis") # many other tasks are available
result = classifier("The actors were very convincing".)
结果是一个 Python 列表,每个输入文本对应一个字典:
>>> result
[{'label': 'POSITIVE', 'score': 0.9998071789741516}]
在此示例中,模型正确地发现句子是积极的,置信度约为 99.98%。当然,您也可以将一批句子传递给模型:
>>> classifier(["I am from India.", "I am from Iraq."])
[{'label': 'POSITIVE', 'score': 0.9896161556243896},
{'label': 'NEGATIVE', 'score': 0.9811071157455444}]
pipeline()函数使用给定任务的默认模型。例如,对于文本分类任务,如情感分析,在撰写本文时,默认为distilbert-base-uncased-finetuned-sst-2-english——一个在英文维基百科和英文书籍语料库上训练的带有小写标记器的 DistilBERT 模型,并在斯坦福情感树库 v2(SST 2)任务上进行了微调。您也可以手动指定不同的模型。例如,您可以使用在多种自然语言推理(MultiNLI)任务上进行微调的 DistilBERT 模型,该任务将两个句子分类为三类:矛盾、中性或蕴含。以下是如何操作:
>>> model_name = "huggingface/distilbert-base-uncased-finetuned-mnli"
>>> classifier_mnli = pipeline("text-classification", model=model_name)
>>> classifier_mnli("She loves me. [SEP] She loves me not.")
[{'label': 'contradiction', 'score': 0.9790192246437073}]
提示
您可以在https://huggingface.co/models找到可用的模型,以及在https://huggingface.co/tasks找到任务列表。
pipeline API 非常简单方便,但有时您需要更多控制。对于这种情况,Transformers 库提供了许多类,包括各种标记器、模型、配置、回调等。例如,让我们使用TFAutoModelForSequenceClassification和AutoTokenizer类加载相同的 DistilBERT 模型及其对应的标记器:
from transformers import AutoTokenizer, TFAutoModelForSequenceClassification
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = TFAutoModelForSequenceClassification.from_pretrained(model_name)
接下来,让我们标记一对句子。在此代码中,我们激活填充,并指定我们希望使用 TensorFlow 张量而不是 Python 列表:
token_ids = tokenizer(["I like soccer. [SEP] We all love soccer!",
"Joe lived for a very long time. [SEP] Joe is old."],
padding=True, return_tensors="tf")
提示
在将"Sentence 1 [SEP] Sentence 2"传递给标记器时,您可以等效地传递一个元组:("Sentence 1", "Sentence 2")。
输出是BatchEncoding类的类似字典实例,其中包含标记 ID 序列,以及包含填充标记的掩码为 0:
>>> token_ids
{'input_ids': <tf.Tensor: shape=(2, 15), dtype=int32, numpy=
array([[ 101, 1045, 2066, 4715, 1012, 102, 2057, 2035, 2293, 4715, 999,
102, 0, 0, 0],
[ 101, 3533, 2973, 2005, 1037, 2200, 2146, 2051, 1012, 102, 3533,
2003, 2214, 1012, 102]], dtype=int32)>,
'attention_mask': <tf.Tensor: shape=(2, 15), dtype=int32, numpy=
array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], dtype=int32)>}
当调用标记器时设置return_token_type_ids=True,您还将获得一个额外的张量,指示每个标记属于哪个句子。这对某些模型是必需的,但对 DistilBERT 不是。
接下来,我们可以直接将这个BatchEncoding对象传递给模型;它返回一个包含其预测类 logits 的TFSequenceClassifierOutput对象:
>>> outputs = model(token_ids)
>>> outputs
TFSequenceClassifierOutput(loss=None, logits=[<tf.Tensor: [...] numpy=
array([[-2.1123817 , 1.1786783 , 1.4101017 ],
[-0.01478387, 1.0962474 , -0.9919954 ]], dtype=float32)>], [...])
最后,我们可以应用 softmax 激活函数将这些 logits 转换为类概率,并使用argmax()函数预测每个输入句子对的具有最高概率的类:
>>> Y_probas = tf.keras.activations.softmax(outputs.logits)
>>> Y_probas
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0.01619702, 0.43523544, 0.5485676 ],
[0.08672056, 0.85204804, 0.06123142]], dtype=float32)>
>>> Y_pred = tf.argmax(Y_probas, axis=1)
>>> Y_pred # 0 = contradiction, 1 = entailment, 2 = neutral
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([2, 1])>
在此示例中,模型正确将第一对句子分类为中性(我喜欢足球并不意味着每个人都喜欢),将第二对句子分类为蕴含(乔确实应该很老)。
如果您希望在自己的数据集上微调此模型,您可以像通常使用 Keras 一样训练模型,因为它只是一个常规的 Keras 模型,具有一些额外的方法。但是,由于模型输出的是 logits 而不是概率,您必须使用tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)损失,而不是通常的"sparse_categorical_crossentropy"损失。此外,模型在训练期间不支持BatchEncoding输入,因此您必须使用其data属性来获取一个常规字典:
sentences = [("Sky is blue", "Sky is red"), ("I love her", "She loves me")]
X_train = tokenizer(sentences, padding=True, return_tensors="tf").data
y_train = tf.constant([0, 2]) # contradiction, neutral
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
model.compile(loss=loss, optimizer="nadam", metrics=["accuracy"])
history = model.fit(X_train, y_train, epochs=2)
Hugging Face 还构建了一个 Datasets 库,您可以使用它轻松下载标准数据集(如 IMDb)或自定义数据集,并用它来微调您的模型。它类似于 TensorFlow Datasets,但还提供了在运行时执行常见预处理任务的工具,如掩码。数据集列表可在https://huggingface.co/datasets上找到。
这应该让您开始使用 Hugging Face 的生态系统。要了解更多信息,您可以前往https://huggingface.co/docs查看文档,其中包括许多教程笔记本、视频、完整 API 等。我还建议您查看 O’Reilly 图书使用 Hugging Face 构建自然语言处理应用的Transformer,作者是来自 Hugging Face 团队的 Lewis Tunstall、Leandro von Werra 和 Thomas Wolf。
在下一章中,我们将讨论如何使用自编码器以无监督的方式学习深度表示,并使用生成对抗网络来生成图像等!
练习
-
使用有状态 RNN 与无状态 RNN 的优缺点是什么?
-
为什么人们使用编码器-解码器 RNN 而不是普通的序列到序列 RNN 进行自动翻译?
-
如何处理可变长度的输入序列?可变长度的输出序列呢?
-
什么是波束搜索,为什么要使用它?可以使用什么工具来实现它?
-
什么是注意力机制?它如何帮助?
-
Transformer架构中最重要的层是什么?它的目的是什么?
-
什么时候需要使用采样 softmax?
-
嵌入 Reber 语法被 Hochreiter 和 Schmidhuber 在关于 LSTMs 的论文中使用。它们是产生诸如“BPBTSXXVPSEPE”之类字符串的人工语法。查看 Jenny Orr 的关于这个主题的很好介绍,然后选择一个特定的嵌入 Reber 语法(例如 Orr 页面上表示的那个),然后训练一个 RNN 来识别一个字符串是否符合该语法。您首先需要编写一个能够生成包含约 50%符合语法的字符串和 50%不符合语法的字符串的训练批次的函数。
-
训练一个能够将日期字符串从一种格式转换为另一种格式的编码器-解码器模型(例如,从“2019 年 4 月 22 日”到“2019-04-22”)。
-
浏览 Keras 网站上关于“使用双编码器进行自然语言图像搜索”的示例。您将学习如何构建一个能够在同一嵌入空间内表示图像和文本的模型。这使得可以使用文本提示搜索图像,就像 OpenAI 的 CLIP 模型一样。
-
使用 Hugging Face Transformers 库下载一个预训练的语言模型,能够生成文本(例如,GPT),并尝试生成更具说服力的莎士比亚文本。您需要使用模型的
generate()方法-请参阅 Hugging Face 的文档以获取更多详细信息。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
艾伦·图灵,“计算机机器和智能”,心灵 49(1950 年):433-460。
当然,单词chatbot出现得更晚。图灵称其测试为模仿游戏:机器 A 和人类 B 通过文本消息与人类审问者 C 聊天;审问者提出问题以确定哪一个是机器(A 还是 B)。如果机器能够愚弄审问者,那么它通过了测试,而人类 B 必须尽力帮助审问者。
由于输入窗口重叠,因此在这种情况下时代的概念并不那么清晰:在每个时代(由 Keras 实现),模型实际上会多次看到相同的字符。
Alec Radford 等人,“学习生成评论和发现情感”,arXiv 预印本 arXiv:1704.01444(2017 年)。
Rico Sennrich 等人,“使用子词单元进行稀有词的神经机器翻译”,计算语言学年会第 54 届年会论文集 1(2016 年):1715-1725。
⁶ Taku Kudo,“子词规范化:改进神经网络翻译模型的多个子词候选”,arXiv 预印本 arXiv:1804.10959(2018)。
⁷ Taku Kudo 和 John Richardson,“SentencePiece:用于神经文本处理的简单且语言无关的子词标记器和去标记器”,arXiv 预印本 arXiv:1808.06226(2018)。
⁸ Yonghui Wu 等人,“谷歌的神经机器翻译系统:弥合人类和机器翻译之间的差距”,arXiv 预印本 arXiv:1609.08144(2016)。
⁹ 不规则张量在第十二章中被介绍,详细内容在附录 C 中。
¹⁰ Matthew Peters 等人,“深度上下文化的词表示”,2018 年北美计算语言学分会年会论文集:人类语言技术 1(2018):2227–2237。
¹¹ Jeremy Howard 和 Sebastian Ruder,“文本分类的通用语言模型微调”,计算语言学年会第 56 届年会论文集 1(2018):328–339。
¹² Daniel Cer 等人,“通用句子编码器”,arXiv 预印本 arXiv:1803.11175(2018)。
¹³ Ilya Sutskever 等人,“使用神经网络进行序列到序列学习”,arXiv 预印本(2014)。
¹⁴ Samy Bengio 等人,“使用循环神经网络进行序列预测的计划抽样”,arXiv 预印本 arXiv:1506.03099(2015)。
¹⁵ 这个数据集由Tatoeba 项目的贡献者创建的句子对组成。网站作者选择了约 120,000 个句子对https://manythings.org/anki。该数据集在创作共用署名 2.0 法国许可下发布。其他语言对也可用。
¹⁶ 在 Python 中,如果运行a, *b = [1, 2, 3, 4],那么a等于1,b等于[2, 3, 4]。
¹⁷ Sébastien Jean 等人,“在神经机器翻译中使用非常大的目标词汇”,计算语言学年会第 53 届年会和亚洲自然语言处理联合国际会议第 7 届年会论文集 1(2015):1–10。
¹⁸ Dzmitry Bahdanau 等人,“通过联合学习对齐和翻译的神经机器翻译”,arXiv 预印本 arXiv:1409.0473(2014)。
¹⁹ Minh-Thang Luong 等人,“基于注意力的神经机器翻译的有效方法”,2015 年自然语言处理经验方法会议论文集(2015):1412–1421。
²⁰ Ashish Vaswani 等人,“注意力就是一切”,第 31 届国际神经信息处理系统会议论文集(2017):6000–6010。
²¹ 由于Transformer使用时间分布密集层,可以说它使用了核大小为 1 的 1D 卷积层。
²² 这是“注意力就是一切”论文中的图 1,经作者的亲切许可再现。
²³ 如果您使用最新版本的 TensorFlow,可以使用不规则张量。
²⁴ 这是“注意力机制是你所需要的一切”中图 2 的右侧部分,经作者亲切授权复制。
²⁵ 当您阅读本文时,这很可能会发生变化;请查看Keras 问题#16248获取更多详细信息。当这种情况发生时,将不需要设置attention_mask参数,因此也不需要创建encoder_pad_mask。
²⁶ 目前Z + skip不支持自动屏蔽,这就是为什么我们不得不写tf.keras.layers.Add()([Z, skip])的原因。再次强调,当您阅读本文时,情况可能已经发生变化。
²⁷ Alec Radford 等人,“通过生成式预训练改进语言理解”(2018 年)。
²⁸ 例如,“简在朋友的生日派对上玩得很开心”意味着“简喜欢这个派对”,但与“每个人都讨厌这个派对”相矛盾,与“地球是平的”无关。
²⁹ Jacob Devlin 等人,“BERT:深度双向 Transformer 的预训练”,2018 年北美计算语言学协会会议论文集:人类语言技术 1(2019 年)。
³⁰ 这是论文中的图 1,经作者亲切授权复制。
³¹ Alec Radford 等人,“语言模型是无监督多任务学习者”(2019 年)。
³² William Fedus 等人,“Switch Transformers: 通过简单高效的稀疏性扩展到万亿参数模型”(2021 年)。
³³ Victor Sanh 等人,“DistilBERT,Bert 的精简版本:更小、更快、更便宜、更轻”,arXiv 预印本 arXiv:1910.01108(2019 年)。
³⁴ Mariya Yao 在这篇文章中总结了许多这些模型:https://homl.info/yaopost。
³⁵ Colin Raffel 等人,“探索统一文本到文本 Transformer 的迁移学习极限”,arXiv 预印本 arXiv:1910.10683(2019 年)。
³⁶ Aakanksha Chowdhery 等人,“PaLM: 使用路径扩展语言建模”,arXiv 预印本 arXiv:2204.02311(2022 年)。
³⁷ Jason Wei 等人,“思维链提示引发大型语言模型的推理”,arXiv 预印本 arXiv:2201.11903(2022 年)。
³⁸ Kelvin Xu 等人,“展示、关注和叙述:带有视觉注意力的神经图像字幕生成”,第 32 届国际机器学习会议论文集(2015 年):2048–2057。
³⁹ 这是论文中图 3 的一部分。经作者亲切授权复制。
⁴⁰ Marco Tulio Ribeiro 等人,“‘为什么我应该相信你?’:解释任何分类器的预测”,第 22 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集(2016 年):1135–1144。
⁴¹ Nicolas Carion 等人,“使用 Transformer 进行端到端目标检测”,arXiv 预印本 arxiv:2005.12872(2020 年)。
⁴² Alexey Dosovitskiy 等人,“一幅图像价值 16x16 个词:大规模图像识别的 Transformer”,arXiv 预印本 arxiv:2010.11929(2020 年)。
⁴³ Hugo Touvron 等人,“训练数据高效的图像 Transformer 和通过注意力蒸馏”,arXiv 预印本 arxiv:2012.12877(2020 年)。
⁴⁴ Andrew Jaegle 等人,“Perceiver: 带有迭代注意力的通用感知”,arXiv 预印本 arxiv:2103.03206(2021)。
⁴⁵ Mathilde Caron 等人,“自监督视觉 Transformer 中的新兴属性”,arXiv 预印本 arxiv:2104.14294(2021)。
⁴⁶ Xiaohua Zhai 等人,“缩放视觉 Transformer”,arXiv 预印本 arxiv:2106.04560v1(2021)。
⁴⁷ Mitchell Wortsman 等人,“模型汤:多个微调模型的权重平均提高准确性而不增加推理时间”,arXiv 预印本 arxiv:2203.05482v1(2022)。
⁴⁸ Alec Radford 等人,“从自然语言监督中学习可转移的视觉模型”,arXiv 预印本 arxiv:2103.00020(2021)。
⁴⁹ Aditya Ramesh 等人,“零样本文本到图像生成”,arXiv 预印本 arxiv:2102.12092(2021)。
⁵⁰ Aditya Ramesh 等人,“具有 CLIP 潜变量的分层文本条件图像生成”,arXiv 预印本 arxiv:2204.06125(2022)。
⁵¹ Jean-Baptiste Alayrac 等人,“Flamingo:用于少样本学习的视觉语言模型”,arXiv 预印本 arxiv:2204.14198(2022)。
⁵² Scott Reed 等人,“通用主体代理”,arXiv 预印本 arxiv:2205.06175(2022)。
1123

被折叠的 条评论
为什么被折叠?



