Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(六)

部署运行你感兴趣的模型镜像

原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow

译者:飞龙

协议:CC BY-NC-SA 4.0

第十七章:自编码器、GANs 和扩散模型

自编码器是人工神经网络,能够学习输入数据的密集表示,称为潜在表示编码,而无需任何监督(即,训练集未标记)。这些编码通常比输入数据的维度低得多,使得自编码器在降维方面非常有用(参见第八章),特别是用于可视化目的。自编码器还充当特征检测器,并且可以用于深度神经网络的无监督预训练(正如我们在第十一章中讨论的那样)。最后,一些自编码器是生成模型:它们能够随机生成看起来非常类似于训练数据的新数据。例如,您可以在人脸图片上训练一个自编码器,然后它将能够生成新的人脸。

生成对抗网络(GANs)也是能够生成数据的神经网络。事实上,它们可以生成如此逼真的人脸图片,以至于很难相信它们所代表的人并不存在。您可以通过访问https://thispersondoesnotexist.com来亲自判断,这是一个展示由名为StyleGAN的 GAN 架构生成的人脸的网站。您还可以查看https://thisrentaldoesnotexist.com来查看一些生成的 Airbnb 列表。GANs 现在被广泛用于超分辨率(增加图像的分辨率)、着色、强大的图像编辑(例如,用逼真的背景替换照片炸弹客)、将简单的草图转换为逼真的图像、预测视频中的下一帧、增强数据集(用于训练其他模型)、生成其他类型的数据(如文本、音频和时间序列)、识别其他模型的弱点以加强它们等等。

生成学习领域的一个较新的成员是扩散模型。在 2021 年,它们成功生成了比 GANs 更多样化和高质量的图像,同时训练也更容易。然而,扩散模型运行速度较慢。

自编码器、GANs 和扩散模型都是无监督的,它们都学习潜在表示,它们都可以用作生成模型,并且有许多类似的应用。然而,它们的工作方式非常不同:

  • 自编码器简单地学习将输入复制到输出。这听起来可能是一个琐碎的任务,但正如你将看到的,以各种方式约束网络可能会使其变得相当困难。例如,您可以限制潜在表示的大小,或者您可以向输入添加噪声并训练网络以恢复原始输入。这些约束阻止了自编码器直接将输入轻松复制到输出,迫使其学习表示数据的有效方式。简而言之,编码是自编码器在某些约束下学习身份函数的副产品。

  • GANs 由两个神经网络组成:一个生成器试图生成看起来类似于训练数据的数据,另一个鉴别器试图区分真实数据和假数据。这种架构在深度学习中非常独特,因为生成器和鉴别器在训练过程中相互竞争:生成器经常被比作试图制造逼真的假币的罪犯,而鉴别器则像是试图区分真假货币的警察调查员。对抗训练(训练竞争的神经网络)被广泛认为是 2010 年代最重要的创新之一。2016 年,Yann LeCun 甚至说这是“过去 10 年中机器学习中最有趣的想法”。

  • 去噪扩散概率模型(DDPM)被训练用于从图像中去除一点噪音。如果你拿一张完全充满高斯噪音的图像,然后反复在该图像上运行扩散模型,一个高质量的图像将逐渐出现,类似于训练图像(但不完全相同)。

在本章中,我们将深入探讨自编码器的工作原理以及如何将其用于降维、特征提取、无监督预训练或生成模型。这将自然地引导我们到 GAN。我们将构建一个简单的 GAN 来生成假图像,但我们会看到训练通常相当困难。我们将讨论对抗训练中遇到的主要困难,以及一些解决这些困难的主要技术。最后,我们将构建和训练一个 DDPM,并用它生成图像。让我们从自编码器开始!

高效的数据表示

你觉得以下哪个数字序列最容易记住?

  • 40, 27, 25, 36, 81, 57, 10, 73, 19, 68

  • 50, 48, 46, 44, 42, 40, 38, 36, 34, 32, 30, 28, 26, 24, 22, 20, 18, 16, 14

乍一看,第一个序列似乎更容易,因为它要短得多。然而,如果你仔细看第二个序列,你会注意到它只是从 50 到 14 的偶数列表。一旦你注意到这个模式,第二个序列比第一个容易记忆得多,因为你只需要记住模式(即递减的偶数)和起始和结束数字(即 50 和 14)。请注意,如果你能快速轻松地记住非常长的序列,你就不会太在意第二个序列中的模式。你只需要把每个数字背下来,就这样。难以记忆长序列的事实使得识别模式变得有用,希望这解释清楚了为什么在训练期间对自编码器进行约束会促使其发现和利用数据中的模式。

记忆、感知和模式匹配之间的关系在 20 世纪 70 年代初由威廉·查斯和赫伯特·西蒙著名研究。他们观察到,专业的国际象棋选手能够在只看棋盘五秒钟的情况下记住游戏中所有棋子的位置,这是大多数人会觉得不可能的任务。然而,这只有在棋子被放置在现实位置(来自实际游戏)时才是这样,而不是当棋子被随机放置时。国际象棋专家的记忆力并不比你我好多少;他们只是更容易看到国际象棋的模式,这要归功于他们对游戏的经验。注意到模式有助于他们有效地存储信息。

就像这个记忆实验中的国际象棋选手一样,自编码器查看输入,将其转换为高效的潜在表示,然后输出与输入非常接近的内容(希望如此)。自编码器始终由两部分组成:一个编码器(或识别网络),将输入转换为潜在表示,然后是一个解码器(或生成网络),将内部表示转换为输出(参见图 17-1)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1701.png

图 17-1。国际象棋记忆实验(左)和简单的自编码器(右)

正如您所看到的,自编码器通常具有与多层感知器(MLP;参见第十章)相同的架构,只是输出层中的神经元数量必须等于输入数量。在这个例子中,有一个由两个神经元组成的隐藏层(编码器),以及一个由三个神经元组成的输出层(解码器)。输出通常被称为重构,因为自编码器试图重构输入。成本函数包含一个重构损失,当重构与输入不同时,惩罚模型。

因为内部表示的维度比输入数据低(是 2D 而不是 3D),所以自编码器被称为欠完备。欠完备自编码器不能简单地将其输入复制到编码中,但它必须找到一种输出其输入的方式。它被迫学习输入数据中最重要的特征(并丢弃不重要的特征)。

让我们看看如何实现一个非常简单的欠完备自编码器进行降维。

使用欠完备线性自编码器执行 PCA

如果自编码器仅使用线性激活函数,并且成本函数是均方误差(MSE),那么它最终会执行主成分分析(PCA;参见第八章)。

以下代码构建了一个简单的线性自编码器,用于在 3D 数据集上执行 PCA,将其投影到 2D:

import tensorflow as tf

encoder = tf.keras.Sequential([tf.keras.layers.Dense(2)])
decoder = tf.keras.Sequential([tf.keras.layers.Dense(3)])
autoencoder = tf.keras.Sequential([encoder, decoder])

optimizer = tf.keras.optimizers.SGD(learning_rate=0.5)
autoencoder.compile(loss="mse", optimizer=optimizer)

这段代码与我们在过去章节中构建的所有 MLP 并没有太大的不同,但有几点需要注意:

  • 我们将自编码器组织成两个子组件:编码器和解码器。两者都是常规的Sequential模型,每个都有一个Dense层,自编码器是一个包含编码器后面是解码器的Sequential模型(请记住,模型可以作为另一个模型中的一层使用)。

  • 自编码器的输出数量等于输入数量(即 3)。

  • 为了执行 PCA,我们不使用任何激活函数(即所有神经元都是线性的),成本函数是 MSE。这是因为 PCA 是一种线性变换。很快我们将看到更复杂和非线性的自编码器。

现在让我们在与我们在第八章中使用的相同简单生成的 3D 数据集上训练模型,并使用它对该数据集进行编码(即将其投影到 2D):

X_train = [...]  # generate a 3D dataset, like in Chapter 8
history = autoencoder.fit(X_train, X_train, epochs=500, verbose=False)
codings = encoder.predict(X_train)

请注意,X_train既用作输入又用作目标。图 17-2 显示了原始 3D 数据集(左侧)和自编码器的隐藏层的输出(即编码层,右侧)。正如您所看到的,自编码器找到了最佳的 2D 平面来投影数据,尽可能保留数据中的方差(就像 PCA 一样)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1702.png

图 17-2. 由欠完备线性自编码器执行的近似 PCA
注意

您可以将自编码器视为执行一种自监督学习,因为它基于一种带有自动生成标签的监督学习技术(在本例中简单地等于输入)。

堆叠自编码器

就像我们讨论过的其他神经网络一样,自编码器可以有多个隐藏层。在这种情况下,它们被称为堆叠自编码器(或深度自编码器)。添加更多层有助于自编码器学习更复杂的编码。也就是说,必须小心不要使自编码器过于强大。想象一个如此强大的编码器,它只学习将每个输入映射到一个单一的任意数字(解码器学习反向映射)。显然,这样的自编码器将完美地重构训练数据,但它不会在过程中学习任何有用的数据表示,并且不太可能很好地推广到新实例。

堆叠自编码器的架构通常关于中心隐藏层(编码层)是对称的。简单来说,它看起来像三明治。例如,时尚 MNIST 的自编码器(在第十章介绍)可能有 784 个输入,然后是具有 100 个神经元的隐藏层,然后是具有 30 个神经元的中心隐藏层,然后是具有 100 个神经元的另一个隐藏层,最后是具有 784 个神经元的输出层。这个堆叠自编码器在图 17-3 中表示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1703.png

图 17-3. 堆叠自编码器

使用 Keras 实现堆叠自编码器

您可以实现一个堆叠自编码器,非常类似于常规的深度 MLP:

stacked_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(30, activation="relu"),
])
stacked_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
stacked_ae = tf.keras.Sequential([stacked_encoder, stacked_decoder])

stacked_ae.compile(loss="mse", optimizer="nadam")
history = stacked_ae.fit(X_train, X_train, epochs=20,
                         validation_data=(X_valid, X_valid))

让我们来看看这段代码:

  • 就像之前一样,我们将自编码器模型分成两个子模型:编码器和解码器。

  • 编码器接收 28×28 像素的灰度图像,将它们展平,使每个图像表示为大小为 784 的向量,然后通过两个逐渐减小的“密集”层(100 个单元,然后是 30 个单元)处理这些向量,都使用 ReLU 激活函数。对于每个输入图像,编码器输出大小为 30 的向量。

  • 解码器接收大小为 30 的编码(由编码器输出),并通过两个逐渐增大的“密集”层(100 个单元,然后是 784 个单元)处理它们,并将最终向量重新整形为 28×28 的数组,以便解码器的输出具有与编码器输入相同的形状。

  • 在编译堆叠自编码器时,我们使用 MSE 损失和 Nadam 优化。

  • 最后,我们使用X_train作为输入和目标来训练模型。同样,我们使用X_valid作为验证输入和目标。

可视化重构

确保自编码器得到正确训练的一种方法是比较输入和输出:差异不应太大。让我们绘制一些验证集中的图像,以及它们的重构:

import numpy as np

def plot_reconstructions(model, images=X_valid, n_images=5):
    reconstructions = np.clip(model.predict(images[:n_images]), 0, 1)
    fig = plt.figure(figsize=(n_images * 1.5, 3))
    for image_index in range(n_images):
        plt.subplot(2, n_images, 1 + image_index)
        plt.imshow(images[image_index], cmap="binary")
        plt.axis("off")
        plt.subplot(2, n_images, 1 + n_images + image_index)
        plt.imshow(reconstructions[image_index], cmap="binary")
        plt.axis("off")

plot_reconstructions(stacked_ae)
plt.show()

图 17-4 显示了生成的图像。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1704.png

图 17-4. 原始图像(顶部)及其重构(底部)

重构是可以识别的,但有点丢失。我们可能需要训练模型更长时间,或者使编码器和解码器更深,或者使编码更大。但是,如果我们使网络过于强大,它将能够进行完美的重构,而不必学习数据中的任何有用模式。现在,让我们使用这个模型。

可视化时尚 MNIST 数据集

现在我们已经训练了一个堆叠自编码器,我们可以使用它来降低数据集的维度。对于可视化,与其他降维算法(如我们在第八章中讨论的算法)相比,这并不会产生很好的结果,但自编码器的一个重要优势是它们可以处理具有许多实例和许多特征的大型数据集。因此,一种策略是使用自编码器将维度降低到合理水平,然后使用另一个降维算法进行可视化。让我们使用这种策略来可视化时尚 MNIST。首先,我们将使用堆叠自编码器的编码器将维度降低到 30,然后我们将使用 Scikit-Learn 的 t-SNE 算法实现将维度降低到 2 以进行可视化:

from sklearn.manifold import TSNE

X_valid_compressed = stacked_encoder.predict(X_valid)
tsne = TSNE(init="pca", learning_rate="auto", random_state=42)
X_valid_2D = tsne.fit_transform(X_valid_compressed)

现在我们可以绘制数据集:

plt.scatter(X_valid_2D[:, 0], X_valid_2D[:, 1], c=y_valid, s=10, cmap="tab10")
plt.show()

图 17-5 显示了生成的散点图,通过显示一些图像进行美化。t-SNE 算法识别出几个与类别相匹配的簇(每个类别由不同的颜色表示)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1705.png

图 17-5. 使用自编码器后跟 t-SNE 的时尚 MNIST 可视化

因此,自编码器可以用于降维。另一个应用是无监督的预训练。

使用堆叠自编码器进行无监督预训练

正如我们在第十一章中讨论的,如果你正在处理一个复杂的监督任务,但没有太多标记的训练数据,一个解决方案是找到一个执行类似任务的神经网络,并重复使用其较低层。这样可以使用少量训练数据训练高性能模型,因为你的神经网络不需要学习所有低级特征;它只需重复使用现有网络学习的特征检测器。

同样,如果你有一个大型数据集,但其中大部分是未标记的,你可以首先使用所有数据训练一个堆叠自编码器,然后重复使用较低层来创建一个用于实际任务的神经网络,并使用标记数据进行训练。例如,图 17-6 展示了如何使用堆叠自编码器为分类神经网络执行无监督预训练。在训练分类器时,如果你确实没有太多标记的训练数据,可能需要冻结预训练层(至少是较低的层)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1706.png

图 17-6。使用自编码器进行无监督预训练
注意

拥有大量未标记数据和少量标记数据是很常见的。构建一个大型未标记数据集通常很便宜(例如,一个简单的脚本可以从互联网上下载数百万张图片),但标记这些图片(例如,将它们分类为可爱或不可爱)通常只能由人类可靠地完成。标记实例是耗时且昂贵的,因此通常只有少量人类标记的实例,甚至更少。

实现上没有什么特别之处:只需使用所有训练数据(标记加未标记)训练一个自编码器,然后重复使用其编码器层来创建一个新的神经网络(请参考本章末尾的练习示例)。

接下来,让我们看一些训练堆叠自编码器的技术。

绑定权重

当一个自编码器是整齐对称的,就像我们刚刚构建的那样,一个常见的技术是将解码器层的权重与编码器层的权重绑定在一起。这样可以减半模型中的权重数量,加快训练速度并限制过拟合的风险。具体来说,如果自编码器总共有N层(不包括输入层),而W[L]表示第L层的连接权重(例如,第 1 层是第一个隐藏层,第N/2 层是编码层,第N层是输出层),那么解码器层的权重可以定义为W[L] = W[NL+1]^⊺(其中L = N / 2 + 1, …​, N)。

使用 Keras 在层之间绑定权重,让我们定义一个自定义层:

class DenseTranspose(tf.keras.layers.Layer):
    def __init__(self, dense, activation=None, **kwargs):
        super().__init__(**kwargs)
        self.dense = dense
        self.activation = tf.keras.activations.get(activation)

    def build(self, batch_input_shape):
        self.biases = self.add_weight(name="bias",
                                      shape=self.dense.input_shape[-1],
                                      initializer="zeros")
        super().build(batch_input_shape)

    def call(self, inputs):
        Z = tf.matmul(inputs, self.dense.weights[0], transpose_b=True)
        return self.activation(Z + self.biases)

这个自定义层就像一个常规的Dense层,但它使用另一个Dense层的权重,经过转置(设置transpose_b=True等同于转置第二个参数,但更高效,因为它在matmul()操作中实时执行转置)。然而,它使用自己的偏置向量。现在我们可以构建一个新的堆叠自编码器,与之前的模型类似,但解码器的Dense层与编码器的Dense层绑定:

dense_1 = tf.keras.layers.Dense(100, activation="relu")
dense_2 = tf.keras.layers.Dense(30, activation="relu")

tied_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    dense_1,
    dense_2
])

tied_decoder = tf.keras.Sequential([
    DenseTranspose(dense_2, activation="relu"),
    DenseTranspose(dense_1),
    tf.keras.layers.Reshape([28, 28])
])

tied_ae = tf.keras.Sequential([tied_encoder, tied_decoder])

这个模型实现了与之前模型大致相同的重构误差,使用了几乎一半的参数数量。

一次训练一个自编码器

与我们刚刚做的整个堆叠自编码器一次性训练不同,可以一次训练一个浅层自编码器,然后将它们堆叠成一个单一的堆叠自编码器(因此得名),如图 17-7 所示。这种技术现在不太常用,但你可能仍然会遇到一些论文讨论“贪婪逐层训练”,所以了解其含义是很有必要的。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1707.png

图 17-7。一次训练一个自编码器

在训练的第一阶段,第一个自编码器学习重建输入。然后我们使用这个第一个自编码器对整个训练集进行编码,这给我们一个新的(压缩的)训练集。然后我们在这个新数据集上训练第二个自编码器。这是训练的第二阶段。最后,我们构建一个大的三明治,使用所有这些自编码器,如图 17-7 所示(即,我们首先堆叠每个自编码器的隐藏层,然后反向堆叠输出层)。这给我们最终的堆叠自编码器(请参阅本章笔记本中“逐个训练自编码器”部分以获取实现)。通过这种方式,我们可以轻松训练更多的自编码器,构建一个非常深的堆叠自编码器。

正如我之前提到的,深度学习浪潮的一个触发因素是Geoffrey Hinton 等人在 2006 年发现深度神经网络可以通过无监督的方式进行预训练,使用这种贪婪的逐层方法。他们用受限玻尔兹曼机(RBMs;参见https://homl.info/extra-anns)来实现这一目的,但在 2007 年Yoshua Bengio 等人⁠²表明自编码器同样有效。几年来,这是训练深度网络的唯一有效方式,直到第十一章中引入的许多技术使得可以一次性训练深度网络。

自编码器不仅限于密集网络:你也可以构建卷积自编码器。现在让我们来看看这些。

卷积自编码器

如果你处理的是图像,那么迄今为止我们看到的自编码器效果不佳(除非图像非常小):正如你在第十四章中看到的,卷积神经网络比密集网络更适合处理图像。因此,如果你想为图像构建一个自编码器(例如用于无监督预训练或降维),你将需要构建一个卷积自编码器。⁠³ 编码器是由卷积层和池化层组成的常规 CNN。它通常减少输入的空间维度(即高度和宽度),同时增加深度(即特征图的数量)。解码器必须执行相反操作(放大图像并将其深度降至原始维度),为此你可以使用转置卷积层(或者,你可以将上采样层与卷积层结合)。以下是 Fashion MNIST 的基本卷积自编码器:

conv_encoder = tf.keras.Sequential([
    tf.keras.layers.Reshape([28, 28, 1]),
    tf.keras.layers.Conv2D(16, 3, padding="same", activation="relu"),
    tf.keras.layers.MaxPool2D(pool_size=2),  # output: 14 × 14 x 16
    tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu"),
    tf.keras.layers.MaxPool2D(pool_size=2),  # output: 7 × 7 x 32
    tf.keras.layers.Conv2D(64, 3, padding="same", activation="relu"),
    tf.keras.layers.MaxPool2D(pool_size=2),  # output: 3 × 3 x 64
    tf.keras.layers.Conv2D(30, 3, padding="same", activation="relu"),
    tf.keras.layers.GlobalAvgPool2D()  # output: 30
])
conv_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(3 * 3 * 16),
    tf.keras.layers.Reshape((3, 3, 16)),
    tf.keras.layers.Conv2DTranspose(32, 3, strides=2, activation="relu"),
    tf.keras.layers.Conv2DTranspose(16, 3, strides=2, padding="same",
                                    activation="relu"),
    tf.keras.layers.Conv2DTranspose(1, 3, strides=2, padding="same"),
    tf.keras.layers.Reshape([28, 28])
])
conv_ae = tf.keras.Sequential([conv_encoder, conv_decoder])

还可以使用其他架构类型创建自编码器,例如 RNNs(请参阅笔记本中的示例)。

好的,让我们退后一步。到目前为止,我们已经看过各种类型的自编码器(基本、堆叠和卷积),以及如何训练它们(一次性或逐层)。我们还看过一些应用:数据可视化和无监督预训练。

迄今为止,为了强迫自编码器学习有趣的特征,我们限制了编码层的大小,使其欠完备。实际上还有许多其他类型的约束可以使用,包括允许编码层与输入一样大,甚至更大,从而产生过完备自编码器。接下来,我们将看一些其他类型的自编码器:去噪自编码器、稀疏自编码器和变分自编码器。

去噪自编码器

另一种强制自编码器学习有用特征的方法是向其输入添加噪声,训练它恢复原始的无噪声输入。这个想法自上世纪 80 年代就存在了(例如,Yann LeCun 在 1987 年的硕士论文中提到了这一点)。在2008 年的一篇论文中,Pascal Vincent 等人表明自编码器也可以用于特征提取。在2010 年的一篇论文中,Vincent 等人介绍了堆叠去噪自编码器

噪声可以是添加到输入的纯高斯噪声,也可以是随机关闭的输入,就像 dropout 中一样(在第十一章中介绍)。图 17-8 展示了这两种选项。

实现很简单:这是一个常规的堆叠自编码器,附加了一个Dropout层应用于编码器的输入(或者您可以使用一个GaussianNoise层)。请记住,Dropout层仅在训练期间激活(GaussianNoise层也是如此):

dropout_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dropout(0.5),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(30, activation="relu")
])
dropout_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
dropout_ae = tf.keras.Sequential([dropout_encoder, dropout_decoder])

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1708.png

图 17-8。去噪自编码器,带有高斯噪声(左)或 dropout(右)

图 17-9 显示了一些嘈杂的图像(一半像素关闭),以及基于 dropout 的去噪自编码器重建的图像。请注意,自编码器猜测了实际输入中不存在的细节,例如白色衬衫的顶部(底部行,第四幅图)。正如您所看到的,去噪自编码器不仅可以用于数据可视化或无监督预训练,就像我们迄今讨论过的其他自编码器一样,而且还可以非常简单高效地从图像中去除噪声。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1709.png

图 17-9。嘈杂的图像(顶部)及其重建(底部)

稀疏自编码器

另一种通常导致良好特征提取的约束是稀疏性:通过向成本函数添加适当的项,自编码器被推动减少编码层中活跃神经元的数量。例如,它可能被推动使编码层中平均只有 5%的显著活跃神经元。这迫使自编码器将每个输入表示为少量激活的组合。结果,编码层中的每个神经元通常最终代表一个有用的特征(如果您每个月只能说几个词,您可能会尽量使它们值得倾听)。

一个简单的方法是在编码层中使用 sigmoid 激活函数(将编码限制在 0 到 1 之间),使用一个大的编码层(例如,具有 300 个单元),并向编码层的激活添加一些ℓ[1]正则化。解码器只是一个常规的解码器:

sparse_l1_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(300, activation="sigmoid"),
    tf.keras.layers.ActivityRegularization(l1=1e-4)
])
sparse_l1_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
sparse_l1_ae = tf.keras.Sequential([sparse_l1_encoder, sparse_l1_decoder])

这个ActivityRegularization层只是返回其输入,但作为副作用,它会添加一个训练损失,等于其输入的绝对值之和。这只影响训练。同样,您可以删除ActivityRegularization层,并在前一层中设置activity_regularizer=tf.keras.regularizers.l1(1e-4)。这种惩罚将鼓励神经网络生成接近 0 的编码,但由于如果不能正确重建输入也会受到惩罚,因此它必须输出至少几个非零值。使用ℓ[1]范数而不是ℓ[2]范数将推动神经网络保留最重要的编码,同时消除不需要的编码(而不仅仅是减少所有编码)。

另一种方法,通常会产生更好的结果,是在每次训练迭代中测量编码层的实际稀疏度,并在测量到的稀疏度与目标稀疏度不同时对模型进行惩罚。我们通过计算编码层中每个神经元的平均激活值来实现这一点,整个训练批次上。批次大小不能太小,否则平均值将不准确。

一旦我们有了每个神经元的平均激活,我们希望通过向成本函数添加稀疏损失来惩罚那些激活过多或不足的神经元。例如,如果我们测量到一个神经元的平均激活为 0.3,但目标稀疏度为 0.1,那么它必须受到惩罚以减少激活。一种方法可能是简单地将平方误差(0.3 - 0.1)²添加到成本函数中,但实际上更好的方法是使用 Kullback–Leibler (KL)散度(在第四章中简要讨论),它比均方误差具有更强的梯度,如您可以在图 17-10 中看到的那样。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1710.png

图 17-10. 稀疏损失

给定两个离散概率分布PQ,这些分布之间的 KL 散度,记为DKL,可以使用方程 17-1 计算。

方程 17-1. Kullback–Leibler 散度

D KL ( P ∥ Q ) = ∑ i P ( i ) log P(i) Q(i)

在我们的情况下,我们想要衡量编码层中神经元激活的目标概率p和通过测量整个训练批次上的平均激活来估计的实际概率q之间的差异。因此,KL 散度简化为方程 17-2。

方程 17-2. 目标稀疏度p和实际稀疏度q之间的 KL 散度

D KL ( p ∥ q ) = p log p q + ( 1 - p ) log 1-p 1-q

一旦我们计算了编码层中每个神经元的稀疏损失,我们将这些损失相加并将结果添加到成本函数中。为了控制稀疏损失和重构损失的相对重要性,我们可以将稀疏损失乘以一个稀疏权重超参数。如果这个权重太高,模型将严格遵循目标稀疏度,但可能无法正确重构输入,使模型无用。相反,如果权重太低,模型将主要忽略稀疏目标,并且不会学习任何有趣的特征。

现在我们有了所有需要基于 KL 散度实现稀疏自编码器的东西。首先,让我们创建一个自定义正则化器来应用 KL 散度正则化:

kl_divergence = tf.keras.losses.kullback_leibler_divergence

class KLDivergenceRegularizer(tf.keras.regularizers.Regularizer):
    def __init__(self, weight, target):
        self.weight = weight
        self.target = target

    def __call__(self, inputs):
        mean_activities = tf.reduce_mean(inputs, axis=0)
        return self.weight * (
            kl_divergence(self.target, mean_activities) +
            kl_divergence(1. - self.target, 1. - mean_activities))

现在我们可以构建稀疏自编码器,使用KLDivergenceRegularizer来对编码层的激活进行正则化:

kld_reg = KLDivergenceRegularizer(weight=5e-3, target=0.1)
sparse_kl_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(300, activation="sigmoid",
                          activity_regularizer=kld_reg)
])
sparse_kl_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
sparse_kl_ae = tf.keras.Sequential([sparse_kl_encoder, sparse_kl_decoder])

在 Fashion MNIST 上训练这个稀疏自编码器后,编码层的稀疏度大约为 10%。

变分自编码器

2013 年,Diederik Kingma 和 Max Welling⁠⁶引入了一个重要类别的自编码器,并迅速成为最受欢迎的变体之一:变分自编码器(VAEs)。

VAEs 在这些特定方面与我们迄今讨论过的所有自编码器都有很大不同:

  • 它们是概率自编码器,这意味着它们的输出在训练后部分地由机会决定(与去噪自编码器相反,在训练期间仅使用随机性)。

  • 最重要的是,它们是生成自编码器,这意味着它们可以生成看起来像是从训练集中采样的新实例。

这两个特性使得 VAEs 与 RBM 相当相似,但它们更容易训练,采样过程也更快(对于 RBM,您需要等待网络稳定到“热平衡”状态,然后才能对新实例进行采样)。正如它们的名字所暗示的,变分自编码器执行变分贝叶斯推断,这是进行近似贝叶斯推断的有效方法。回想一下,贝叶斯推断意味着根据新数据更新概率分布,使用从贝叶斯定理推导出的方程。原始分布称为先验,而更新后的分布称为后验。在我们的情况下,我们想要找到数据分布的一个很好的近似。一旦我们有了这个,我们就可以从中进行采样。

让我们看看 VAEs 是如何工作的。图 17-11(左)展示了一个变分自编码器。您可以认出所有自编码器的基本结构,具有一个编码器后面跟着一个解码器(在这个例子中,它们都有两个隐藏层),但有一个转折:编码器不是直接为给定输入产生编码,而是产生一个均值编码 μ 和一个标准差 σ。然后,实际编码随机地从均值 μ 和标准差 σ 的高斯分布中采样。之后解码器正常解码采样的编码。图的右侧显示了一个训练实例通过这个自编码器的过程。首先,编码器产生 μσ,然后一个编码被随机采样(请注意它并不完全位于 μ),最后这个编码被解码;最终输出类似于训练实例。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1711.png

图 17-11. 变分自编码器(左)和通过它的实例(右)

正如您在图中所看到的,尽管输入可能具有非常复杂的分布,但变分自编码器倾向于产生看起来像是从简单的高斯分布中采样的编码:⁠⁷在训练期间,成本函数(下面讨论)推动编码逐渐在编码空间(也称为潜在空间)内迁移,最终看起来像一个高斯点云。一个很大的结果是,在训练变分自编码器之后,您可以非常容易地生成一个新实例:只需从高斯分布中随机采样一个随机编码,解码它,然后就完成了!

现在,让我们看一下成本函数。它由两部分组成。第一部分是通常的重构损失,推动自编码器重现其输入。我们可以使用 MSE 来实现这一点,就像我们之前做的那样。第二部分是潜在损失,推动自编码器具有看起来像是从简单高斯分布中抽样的编码:这是目标分布(即高斯分布)与编码的实际分布之间的 KL 散度。数学比稀疏自编码器更复杂,特别是由于高斯噪声,它限制了可以传输到编码层的信息量。这推动自编码器学习有用的特征。幸运的是,方程简化了,因此可以使用 Equation 17-3 计算潜在损失。⁠⁸

方程 17-3。变分自编码器的潜在损失

L=-12∑i=1n1+log(σi2)-σi2-μi2

在这个方程中,ℒ是潜在损失,n是编码的维度,μ[i]和σ[i]是编码的第i个分量的均值和标准差。向量μσ(包含所有μ[i]和σ[i])由编码器输出,如 Figure 17-11(左)所示。

变分自编码器架构的常见调整是使编码器输出γ = log(σ²)而不是σ。然后可以根据 Equation 17-4 计算潜在损失。这种方法在数值上更稳定,加快了训练速度。

方程 17-4。变分自编码器的潜在损失,使用γ = log(σ²)重写

L=-12∑i=1n1+γi-exp(γi)-μi2

现在,让我们开始为 Fashion MNIST 构建一个变分自编码器(如 Figure 17-11 所示,但使用γ调整)。首先,我们需要一个自定义层来根据μγ抽样编码:

class Sampling(tf.keras.layers.Layer):
    def call(self, inputs):
        mean, log_var = inputs
        return tf.random.normal(tf.shape(log_var)) * tf.exp(log_var / 2) + mean

这个Sampling层接受两个输入:meanμ)和log_varγ)。它使用函数tf.random.normal()从均值为 0,标准差为 1 的高斯分布中抽样一个随机向量(与γ形状相同)。然后将其乘以 exp(γ / 2)(数学上等于σ,您可以验证),最后加上μ并返回结果。这样从均值为μ,标准差为σ的高斯分布中抽样一个编码向量。

接下来,我们可以创建编码器,使用函数式 API,因为模型不是完全顺序的:

codings_size = 10

inputs = tf.keras.layers.Input(shape=[28, 28])
Z = tf.keras.layers.Flatten()(inputs)
Z = tf.keras.layers.Dense(150, activation="relu")(Z)
Z = tf.keras.layers.Dense(100, activation="relu")(Z)
codings_mean = tf.keras.layers.Dense(codings_size)(Z)  # μ
codings_log_var = tf.keras.layers.Dense(codings_size)(Z)  # γ
codings = Sampling()([codings_mean, codings_log_var])
variational_encoder = tf.keras.Model(
    inputs=[inputs], outputs=[codings_mean, codings_log_var, codings])

注意,输出codings_meanμ)和codings_log_varγ)的Dense层具有相同的输入(即第二个Dense层的输出)。然后,我们将codings_meancodings_log_var都传递给Sampling层。最后,variational_encoder模型有三个输出。只需要codings,但我们也添加了codings_meancodings_log_var,以防我们想要检查它们的值。现在让我们构建解码器:

decoder_inputs = tf.keras.layers.Input(shape=[codings_size])
x = tf.keras.layers.Dense(100, activation="relu")(decoder_inputs)
x = tf.keras.layers.Dense(150, activation="relu")(x)
x = tf.keras.layers.Dense(28 * 28)(x)
outputs = tf.keras.layers.Reshape([28, 28])(x)
variational_decoder = tf.keras.Model(inputs=[decoder_inputs], outputs=[outputs])

对于这个解码器,我们可以使用顺序 API 而不是功能 API,因为它实际上只是一个简单的层堆栈,与我们迄今构建的许多解码器几乎相同。最后,让我们构建变分自编码器模型:

_, _, codings = variational_encoder(inputs)
reconstructions = variational_decoder(codings)
variational_ae = tf.keras.Model(inputs=[inputs], outputs=[reconstructions])

我们忽略编码器的前两个输出(我们只想将编码输入解码器)。最后,我们必须添加潜在损失和重构损失:

latent_loss = -0.5 * tf.reduce_sum(
    1 + codings_log_var - tf.exp(codings_log_var) - tf.square(codings_mean),
    axis=-1)
variational_ae.add_loss(tf.reduce_mean(latent_loss) / 784.)

我们首先应用方程 17-4 来计算批处理中每个实例的潜在损失,对最后一个轴求和。然后我们计算批处理中所有实例的平均损失,并将结果除以 784,以确保它具有适当的比例,与重构损失相比。实际上,变分自编码器的重构损失应该是像素重构误差的总和,但是当 Keras 计算"mse"损失时,它计算所有 784 个像素的平均值,而不是总和。因此,重构损失比我们需要的要小 784 倍。我们可以定义一个自定义损失来计算总和而不是平均值,但将潜在损失除以 784(最终损失将比应该的大 784 倍,但这只是意味着我们应该使用更大的学习率)更简单。

最后,我们可以编译和拟合自编码器!

variational_ae.compile(loss="mse", optimizer="nadam")
history = variational_ae.fit(X_train, X_train, epochs=25, batch_size=128,
                             validation_data=(X_valid, X_valid))

生成时尚 MNIST 图像

现在让我们使用这个变分自编码器生成看起来像时尚物品的图像。我们只需要从高斯分布中随机采样编码,并解码它们:

codings = tf.random.normal(shape=[3 * 7, codings_size])
images = variational_decoder(codings).numpy()

图 17-12 显示了生成的 12 张图像。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1712.png

图 17-12. 由变分自编码器生成的时尚 MNIST 图像

这些图像中的大多数看起来相当令人信服,虽然有点模糊。其余的不太好,但不要对自编码器太苛刻——它只有几分钟时间学习!

变分自编码器使得执行语义插值成为可能:不是在像素级别插值两个图像,看起来就像两个图像只是叠加在一起,我们可以在编码级别进行插值。例如,让我们在潜在空间中沿着任意线取几个编码,并解码它们。我们得到一系列图像,逐渐从裤子变成毛衣(见图 17-13):

codings = np.zeros([7, codings_size])
codings[:, 3] = np.linspace(-0.8, 0.8, 7)  # axis 3 looks best in this case
images = variational_decoder(codings).numpy()

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1713.png

图 17-13. 语义插值

现在让我们转向 GANs:它们更难训练,但当你设法让它们工作时,它们会产生非常惊人的图像。

生成对抗网络

生成对抗网络是由 Ian Goodfellow 等人在2014 年的一篇论文中提出的⁠⁹,尽管这个想法几乎立即激发了研究人员的兴趣,但要克服训练 GANs 的一些困难还需要几年时间。就像许多伟大的想法一样,事后看来似乎很简单:让神经网络相互竞争,希望这种竞争能推动它们取得卓越的成就。如图 17-14 所示,GAN 由两个神经网络组成:

生成器

以随机分布(通常是高斯)作为输入,并输出一些数据——通常是图像。您可以将随机输入视为要生成的图像的潜在表示(即编码)。因此,正如您所看到的,生成器提供了与变分自编码器中的解码器相同的功能,并且可以以相同的方式用于生成新图像:只需将一些高斯噪声输入,它就会输出一个全新的图像。但是,它的训练方式非常不同,您很快就会看到。

鉴别器

以生成器的假图像或训练集中的真实图像作为输入,必须猜测输入图像是假还是真。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1714.png

图 17-14. 生成对抗网络

在训练过程中,生成器和鉴别器有着相反的目标:鉴别器试图区分假图像和真实图像,而生成器试图生成看起来足够真实以欺骗鉴别器的图像。由于 GAN 由具有不同目标的两个网络组成,因此无法像训练常规神经网络那样进行训练。每个训练迭代被分为两个阶段:

  • 在第一阶段,我们训练鉴别器。从训练集中抽取一批真实图像,并通过生成器生成相同数量的假图像来完成。标签设置为 0 表示假图像,1 表示真实图像,并且鉴别器在这个带标签的批次上进行一步训练,使用二元交叉熵损失。重要的是,在这个阶段只有鉴别器的权重被优化。

  • 在第二阶段,我们训练生成器。我们首先使用它生成另一批假图像,然后再次使用鉴别器来判断图像是假的还是真实的。这次我们不在批次中添加真实图像,所有标签都设置为 1(真实):换句话说,我们希望生成器生成鉴别器会(错误地)认为是真实的图像!在这一步骤中,鉴别器的权重被冻结,因此反向传播只影响生成器的权重。

注意

生成器实际上从未看到任何真实图像,但它逐渐学会生成令人信服的假图像!它所得到的只是通过鉴别器反向传播的梯度。幸运的是,鉴别器越好,这些二手梯度中包含的关于真实图像的信息就越多,因此生成器可以取得显著进展。

让我们继续构建一个简单的 Fashion MNIST GAN。

首先,我们需要构建生成器和鉴别器。生成器类似于自编码器的解码器,鉴别器是一个常规的二元分类器:它以图像作为输入,最终以包含单个单元并使用 sigmoid 激活函数的Dense层结束。对于每个训练迭代的第二阶段,我们还需要包含生成器后面的鉴别器的完整 GAN 模型:

codings_size = 30

Dense = tf.keras.layers.Dense
generator = tf.keras.Sequential([
    Dense(100, activation="relu", kernel_initializer="he_normal"),
    Dense(150, activation="relu", kernel_initializer="he_normal"),
    Dense(28 * 28, activation="sigmoid"),
    tf.keras.layers.Reshape([28, 28])
])
discriminator = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    Dense(150, activation="relu", kernel_initializer="he_normal"),
    Dense(100, activation="relu", kernel_initializer="he_normal"),
    Dense(1, activation="sigmoid")
])
gan = tf.keras.Sequential([generator, discriminator])

接下来,我们需要编译这些模型。由于鉴别器是一个二元分类器,我们可以自然地使用二元交叉熵损失。gan模型也是一个二元分类器,因此它也可以使用二元交叉熵损失。然而,生成器只会通过gan模型进行训练,因此我们根本不需要编译它。重要的是,在第二阶段之前鉴别器不应该被训练,因此在编译gan模型之前我们将其设置为不可训练:

discriminator.compile(loss="binary_crossentropy", optimizer="rmsprop")
discriminator.trainable = False
gan.compile(loss="binary_crossentropy", optimizer="rmsprop")
注意

trainable属性只有在编译模型时才会被 Keras 考虑,因此在运行此代码后,如果我们调用其fit()方法或train_on_batch()方法(我们将使用),则discriminator是可训练的,而在调用这些方法时gan模型是不可训练的。

由于训练循环是不寻常的,我们不能使用常规的fit()方法。相反,我们将编写一个自定义训练循环。为此,我们首先需要创建一个Dataset来迭代图像:

batch_size = 32
dataset = tf.data.Dataset.from_tensor_slices(X_train).shuffle(buffer_size=1000)
dataset = dataset.batch(batch_size, drop_remainder=True).prefetch(1)

现在我们准备编写训练循环。让我们将其封装在一个train_gan()函数中:

def train_gan(gan, dataset, batch_size, codings_size, n_epochs):
    generator, discriminator = gan.layers
    for epoch in range(n_epochs):
        for X_batch in dataset:
            # phase 1 - training the discriminator
            noise = tf.random.normal(shape=[batch_size, codings_size])
            generated_images = generator(noise)
            X_fake_and_real = tf.concat([generated_images, X_batch], axis=0)
            y1 = tf.constant([[0.]] * batch_size + [[1.]] * batch_size)
            discriminator.train_on_batch(X_fake_and_real, y1)
            # phase 2 - training the generator
            noise = tf.random.normal(shape=[batch_size, codings_size])
            y2 = tf.constant([[1.]] * batch_size)
            gan.train_on_batch(noise, y2)

train_gan(gan, dataset, batch_size, codings_size, n_epochs=50)

正如之前讨论的,您可以在每次迭代中看到两个阶段:

  • 在第一阶段,我们向生成器提供高斯噪声以生成假图像,并通过连接相同数量的真实图像来完成这一批次。目标y1设置为 0 表示假图像,1 表示真实图像。然后我们对这一批次训练鉴别器。请记住,在这个阶段鉴别器是可训练的,但我们不会触及生成器。

  • 在第二阶段,我们向 GAN 提供一些高斯噪声。其生成器将开始生成假图像,然后鉴别器将尝试猜测这些图像是假还是真实的。在这个阶段,我们试图改进生成器,这意味着我们希望鉴别器失败:这就是为什么目标y2都设置为 1,尽管图像是假的。在这个阶段,鉴别器是可训练的,因此gan模型中唯一会改进的部分是生成器。

就是这样!训练后,您可以随机从高斯分布中抽取一些编码,并将它们馈送给生成器以生成新图像:

codings = tf.random.normal(shape=[batch_size, codings_size])
generated_images = generator.predict(codings)

如果显示生成的图像(参见图 17-15),您会发现在第一个时期结束时,它们已经开始看起来像(非常嘈杂的)时尚 MNIST 图像。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1715.png

图 17-15。在训练一个时期后由 GAN 生成的图像

不幸的是,图像从未真正比那更好,甚至可能会出现 GAN 似乎忘记了它学到的东西的时期。为什么会这样呢?原来,训练 GAN 可能是具有挑战性的。让我们看看为什么。

训练 GAN 的困难

在训练过程中,生成器和鉴别器不断试图在零和博弈中互相智胜。随着训练的进行,游戏可能会进入博弈论家称之为纳什均衡的状态,以数学家约翰·纳什命名:这是当没有玩家会因为改变自己的策略而变得更好,假设其他玩家不改变自己的策略。例如,当每个人都在道路的左侧行驶时,就达到了纳什均衡:没有司机会因为成为唯一一个换边的人而变得更好。当然,还有第二种可能的纳什均衡:当每个人都在道路的右侧行驶时。不同的初始状态和动态可能导致一个或另一个均衡。在这个例子中,一旦达到均衡状态(即,与其他人一样在同一侧行驶),就会有一个单一的最佳策略,但是纳什均衡可能涉及多种竞争策略(例如,捕食者追逐猎物,猎物试图逃跑,双方都不会因为改变策略而变得更好)。

那么这如何应用于 GAN 呢?嗯,GAN 论文的作者们证明了 GAN 只能达到单一的纳什均衡:那就是生成器生成完全逼真的图像,鉴别器被迫猜测(50%真实,50%假)。这个事实非常令人鼓舞:似乎只需要训练足够长的时间,它最终会达到这种均衡,为您提供一个完美的生成器。不幸的是,事情并不那么简单:没有任何保证这种均衡会被达到。

最大的困难被称为模式坍塌:这是指生成器的输出逐渐变得不那么多样化。这是如何发生的呢?假设生成器在制作令人信服的鞋子方面比其他任何类别都更擅长。它会用鞋子更多地欺骗鉴别器,这将鼓励它生成更多的鞋子图像。逐渐地,它会忘记如何制作其他任何东西。与此同时,鉴别器将看到的唯一假图像将是鞋子,因此它也会忘记如何鉴别其他类别的假图像。最终,当鉴别器设法将假鞋子与真实鞋子区分开来时,生成器将被迫转向另一个类别。然后它可能擅长衬衫,忘记鞋子,鉴别器也会跟随。GAN 可能逐渐在几个类别之间循环,从未真正擅长其中任何一个。

此外,由于生成器和鉴别器不断相互对抗,它们的参数可能最终会振荡并变得不稳定。训练可能开始正常,然后由于这些不稳定性,突然出现无明显原因的分歧。由于许多因素影响这些复杂的动态,GAN 对超参数非常敏感:您可能需要花费大量精力对其进行微调。实际上,这就是为什么在编译模型时我使用 RMSProp 而不是 Nadam:使用 Nadam 时,我遇到了严重的模式崩溃。

自 2014 年以来,这些问题一直让研究人员忙碌不已:许多论文已经发表在这个主题上,一些论文提出了新的成本函数(尽管谷歌研究人员在2018 年的一篇论文中质疑了它们的效率)或稳定训练或避免模式崩溃问题的技术。例如,一种流行的技术称为经验重播,它包括在每次迭代中存储生成器生成的图像在重播缓冲区中(逐渐删除较旧的生成图像),并使用来自该缓冲区的真实图像加上假图像来训练鉴别器(而不仅仅是当前生成器生成的假图像)。这减少了鉴别器过度拟合最新生成器输出的机会。另一种常见的技术称为小批量鉴别:它测量批次中图像的相似性,并将此统计信息提供给鉴别器,以便它可以轻松拒绝缺乏多样性的整个批次的假图像。这鼓励生成器产生更多样化的图像,减少模式崩溃的机会。其他论文简单地提出了表现良好的特定架构。

简而言之,这仍然是一个非常活跃的研究领域,GAN 的动态仍然没有完全被理解。但好消息是取得了巨大进展,一些结果真的令人惊叹!因此,让我们看一些最成功的架构,从几年前的深度卷积 GAN 开始。然后我们将看一下两个更近期(更复杂)的架构。

深度卷积 GAN

原始 GAN 论文的作者尝试了卷积层,但只尝试生成小图像。不久之后,许多研究人员尝试基于更深的卷积网络生成更大的图像的 GAN。这被证明是棘手的,因为训练非常不稳定,但 Alec Radford 等人最终在 2015 年底成功了,经过许多不同架构和超参数的实验。他们将其架构称为深度卷积 GAN(DCGANs)。以下是他们为构建稳定的卷积 GAN 提出的主要准则:

  • 用步进卷积(在鉴别器中)和转置卷积(在生成器中)替换任何池化层。

  • 在生成器和鉴别器中使用批量归一化,除了生成器的输出层和鉴别器的输入层。

  • 删除更深层次架构的全连接隐藏层。

  • 在生成器的所有层中使用 ReLU 激活,除了输出层应使用 tanh。

  • 在鉴别器的所有层中使用泄漏 ReLU 激活。

这些准则在许多情况下都适用,但并非总是如此,因此您可能仍需要尝试不同的超参数。实际上,仅仅改变随机种子并再次训练完全相同的模型有时会奏效。以下是一个在时尚 MNIST 上表现相当不错的小型 DCGAN:

codings_size = 100

generator = tf.keras.Sequential([
    tf.keras.layers.Dense(7 * 7 * 128),
    tf.keras.layers.Reshape([7, 7, 128]),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Conv2DTranspose(64, kernel_size=5, strides=2,
                                    padding="same", activation="relu"),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Conv2DTranspose(1, kernel_size=5, strides=2,
                                    padding="same", activation="tanh"),
])
discriminator = tf.keras.Sequential([
    tf.keras.layers.Conv2D(64, kernel_size=5, strides=2, padding="same",
                           activation=tf.keras.layers.LeakyReLU(0.2)),
    tf.keras.layers.Dropout(0.4),
    tf.keras.layers.Conv2D(128, kernel_size=5, strides=2, padding="same",
                           activation=tf.keras.layers.LeakyReLU(0.2)),
    tf.keras.layers.Dropout(0.4),
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(1, activation="sigmoid")
])
gan = tf.keras.Sequential([generator, discriminator])

生成器接受大小为 100 的编码,将其投影到 6,272 维度(7 * 7 * 128),并重新整形结果以获得一个 7×7×128 张量。这个张量被批量归一化并馈送到一个步幅为 2 的转置卷积层,将其从 7×7 上采样到 14×14,并将其深度从 128 减少到 64。结果再次进行批量归一化,并馈送到另一个步幅为 2 的转置卷积层,将其从 14×14 上采样到 28×28,并将深度从 64 减少到 1。这一层使用 tanh 激活函数,因此输出将范围从-1 到 1。因此,在训练 GAN 之前,我们需要将训练集重新缩放到相同的范围。我们还需要重新整形以添加通道维度:

X_train_dcgan = X_train.reshape(-1, 28, 28, 1) * 2. - 1. # reshape and rescale

鉴别器看起来很像用于二元分类的常规 CNN,只是不是使用最大池化层来对图像进行下采样,而是使用步幅卷积(strides=2)。请注意,我们使用了泄漏的 ReLU 激活函数。总体而言,我们遵守了 DCGAN 的指导方针,只是将鉴别器中的BatchNormalization层替换为Dropout层;否则,在这种情况下训练会不稳定。随意调整这个架构:您将看到它对超参数非常敏感,特别是两个网络的相对学习率。

最后,要构建数据集,然后编译和训练这个模型,我们可以使用之前的相同代码。经过 50 个训练周期后,生成器产生的图像如图 17-16 所示。它还不完美,但其中许多图像相当令人信服。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1716.png

图 17-16。DCGAN 在训练 50 个周期后生成的图像

如果您扩大这个架构并在大量人脸数据集上进行训练,您可以获得相当逼真的图像。事实上,DCGAN 可以学习相当有意义的潜在表示,如图 17-17 所示:生成了许多图像,手动选择了其中的九个(左上角),包括三个戴眼镜的男性,三个不戴眼镜的男性和三个不戴眼镜的女性。对于这些类别中的每一个,用于生成图像的编码被平均,然后基于结果的平均编码生成图像(左下角)。简而言之,左下角的三幅图像分别代表位于其上方的三幅图像的平均值。但这不是在像素级别计算的简单平均值(这将导致三个重叠的脸),而是在潜在空间中计算的平均值,因此图像看起来仍然像正常的脸。令人惊讶的是,如果您计算戴眼镜的男性,减去不戴眼镜的男性,再加上不戴眼镜的女性——其中每个术语对应于一个平均编码——并生成对应于此编码的图像,您将得到右侧面孔网格中心的图像:一个戴眼镜的女性!其周围的其他八幅图像是基于相同向量加上一点噪音生成的,以展示 DCGAN 的语义插值能力。能够在人脸上进行算术运算感觉像是科幻!

然而,DCGAN 并不完美。例如,当您尝试使用 DCGAN 生成非常大的图像时,通常会出现局部令人信服的特征,但整体上存在不一致,比如一只袖子比另一只长得多的衬衫,不同的耳环,或者眼睛看向相反的方向。您如何解决这个问题?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1717.png

图 17-17。视觉概念的向量算术(DCGAN 论文中的第 7 部分图)⁠¹³
提示

如果将每个图像的类别作为额外输入添加到生成器和鉴别器中,它们将学习每个类别的外观,因此您将能够控制生成器生成的每个图像的类别。这被称为条件 GAN(CGAN)。⁠¹⁴

GAN 的渐进增长

在一篇2018 年的论文,Nvidia 的研究人员 Tero Kerras 等人提出了一项重要技术:他们建议在训练开始时生成小图像,然后逐渐向生成器和鉴别器添加卷积层,以生成越来越大的图像(4×4、8×8、16×16,…,512×512、1,024×1,024)。这种方法类似于贪婪逐层训练堆叠自编码器。额外的层被添加到生成器的末尾和鉴别器的开头,并且之前训练过的层仍然可训练。

例如,当将生成器的输出从 4×4 增加到 8×8 时(参见图 17-18),在现有卷积层(“Conv 1”)中添加了一个上采样层(使用最近邻过滤)以生成 8×8 特征图。这些被馈送到新的卷积层(“Conv 2”),然后馈送到新的输出卷积层。为了避免破坏 Conv 1 的训练权重,我们逐渐淡入两个新的卷积层(在图 17-18 中用虚线表示),并淡出原始输出层。最终输出是新输出(权重为α)和原始输出(权重为 1-α)的加权和,从 0 逐渐增加α到 1。当向鉴别器添加新的卷积层时(后跟一个平均池化层进行下采样),也使用类似的淡入/淡出技术。请注意,所有卷积层都使用"same"填充和步幅为 1,因此它们保留其输入的高度和宽度。这包括原始卷积层,因此它现在产生 8×8 的输出(因为其输入现在是 8×8)。最后,输出层使用核大小为 1。它们只是将它们的输入投影到所需数量的颜色通道(通常为 3)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1718.png

图 17-18。逐渐增长的 GAN:GAN 生成器输出 4×4 彩色图像(左);我们将其扩展到输出 8×8 图像(右)

该论文还介绍了几种旨在增加输出多样性(以避免模式崩溃)并使训练更稳定的技术:

小批量标准差层

添加到鉴别器的末尾附近。对于输入中的每个位置,它计算批次中所有通道和所有实例的标准差(S = tf.math.reduce_std(inputs, axis=[0, -1]))。然后,这些标准差在所有点上进行平均以获得单个值(v = tf.reduce_mean(S))。最后,在批次中的每个实例中添加一个额外的特征图,并填充计算出的值(tf.concat([inputs, tf.fill([batch_size, height, width, 1], v)], axis=-1))。这有什么帮助呢?如果生成器生成具有很少变化的图像,那么在鉴别器的特征图中将会有很小的标准差。由于这一层,鉴别器将更容易访问这个统计数据,使得它不太可能被生成器欺骗,生成器产生的多样性太少。这将鼓励生成器产生更多样化的输出,减少模式崩溃的风险。

均衡学习率

使用均值为 0,标准差为 1 的高斯分布初始化所有权重,而不是使用 He 初始化。然而,在运行时(即每次执行该层时),权重会按照 He 初始化的相同因子进行缩放:它们会被除以2ninputs,其中n[inputs]是该层的输入数量。论文表明,当使用 RMSProp、Adam 或其他自适应梯度优化器时,这种技术显著提高了 GAN 的性能。实际上,这些优化器通过其估计的标准偏差对梯度更新进行归一化(参见第十一章),因此具有较大动态范围的参数将需要更长时间进行训练,而具有较小动态范围的参数可能会更新得太快,导致不稳定性。通过在模型本身中重新缩放权重,而不仅仅在初始化时重新缩放它们,这种方法确保了在整个训练过程中所有参数的动态范围相同,因此它们都以相同的速度学习。这既加快了训练速度,又稳定了训练过程。

像素级归一化层

在生成器的每个卷积层之后添加。它根据同一图像和位置处的所有激活进行归一化,但跨所有通道(除以均方激活的平方根)。在 TensorFlow 代码中,这是inputs / tf.sqrt(tf.reduce_mean(tf.square(X), axis=-1, keepdims=True) + 1e-8)(需要平滑项1e-8以避免除以零)。这种技术避免了由于生成器和鉴别器之间的激烈竞争而导致激活爆炸。

所有这些技术的结合使作者能够生成极具说服力的高清面部图像。但是,“说服力”到底是什么意思呢?评估是在使用 GAN 时面临的一大挑战:尽管可以自动评估生成图像的多样性,但评估其质量是一项更加棘手和主观的任务。一种技术是使用人类评分者,但这既昂贵又耗时。因此,作者提出了一种方法,即考虑生成图像与训练图像之间的局部图像结构的相似性,考虑每个尺度。这个想法引领他们走向另一个开创性的创新:StyleGANs。

StyleGANs

高分辨率图像生成领域的最新技术再次由同一 Nvidia 团队在2018 年的一篇论文中推进,引入了流行的 StyleGAN 架构。作者在生成器中使用风格转移技术,以确保生成的图像在每个尺度上具有与训练图像相同的局部结构,极大地提高了生成图像的质量。鉴别器和损失函数没有被修改,只有生成器。StyleGAN 生成器由两个网络组成(参见图 17-19):

映射网络

一个将潜在表示z(即编码)映射到向量w的八层 MLP。然后,将该向量通过多个仿射变换(即没有激活函数的Dense层,在图 17-19 中用“A”框表示)发送,从而产生多个向量。这些向量控制生成图像的风格在不同层次上,从细粒度纹理(例如头发颜色)到高级特征(例如成人或儿童)。简而言之,映射网络将编码映射到多个风格向量。

合成网络

负责生成图像。它有一个恒定的学习输入(明确地说,这个输入在训练之后将是恒定的,但在训练期间,它会通过反向传播不断调整)。它通过多个卷积和上采样层处理这个输入,就像之前一样,但有两个变化。首先,在输入和所有卷积层的输出(在激活函数之前)中添加了一些噪音。其次,每个噪音层后面都跟着一个自适应实例归一化(AdaIN)层:它独立地标准化每个特征图(通过减去特征图的均值并除以其标准差),然后使用风格向量确定每个特征图的比例和偏移(风格向量包含每个特征图的一个比例和一个偏置项)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1719.png

图 17-19. StyleGAN 的生成器架构(来自 StyleGAN 论文的图 1 的一部分)⁠¹⁸

独立于编码添加噪音的想法非常重要。图像的某些部分是相当随机的,比如每个雀斑或头发的确切位置。在早期的 GAN 中,这种随机性要么来自编码,要么是生成器本身产生的一些伪随机噪音。如果来自编码,这意味着生成器必须将编码的表征能力的相当一部分用于存储噪音,这是相当浪费的。此外,噪音必须能够流经网络并到达生成器的最终层:这似乎是一个不必要的约束,可能会减慢训练速度。最后,一些视觉伪影可能会出现,因为在不同级别使用相同的噪音。如果生成器尝试生成自己的伪随机噪音,这种噪音可能看起来不太令人信服,导致更多的视觉伪影。此外,生成器的一部分权重将被用于生成伪随机噪音,这再次似乎是浪费的。通过添加额外的噪音输入,所有这些问题都可以避免;GAN 能够利用提供的噪音为图像的每个部分添加适量的随机性。

每个级别的添加噪音都是不同的。每个噪音输入由一个充满高斯噪音的单个特征图组成,该特征图被广播到所有特征图(给定级别的)并使用学习的每个特征比例因子进行缩放(这由图 17-19 中的“B”框表示)然后添加。

最后,StyleGAN 使用一种称为混合正则化(或风格混合)的技术,其中一定比例的生成图像是使用两种不同的编码生成的。具体来说,编码c[1]和c[2]被送入映射网络,得到两个风格向量w[1]和w[2]。然后合成网络根据第一级的风格w[1]和剩余级别的风格w[2]生成图像。截断级别是随机选择的。这可以防止网络假设相邻级别的风格是相关的,从而鼓励 GAN 中的局部性,这意味着每个风格向量只影响生成图像中的有限数量的特征。

有这么多种类的 GAN,需要一本整书来覆盖它们。希望这个介绍给您带来了主要思想,最重要的是让您有学习更多的愿望。继续实现您自己的 GAN,如果一开始学习有困难,请不要灰心:不幸的是,这是正常的,需要相当多的耐心才能使其正常运行,但结果是值得的。如果您在实现细节上遇到困难,有很多 Keras 或 TensorFlow 的实现可以参考。实际上,如果您只是想快速获得一些惊人的结果,那么您可以使用预训练模型(例如,有适用于 Keras 的预训练 StyleGAN 模型可用)。

现在我们已经研究了自编码器和 GANs,让我们看看最后一种架构:扩散模型。

扩散模型

扩散模型的理念已经存在多年,但它们首次以现代形式在斯坦福大学和加州大学伯克利分校的 Jascha Sohl-Dickstein 等人的2015 年论文中得到正式形式化。作者们应用了热力学工具来建模扩散过程,类似于一滴牛奶在茶杯中扩散的过程。核心思想是训练一个模型来学习反向过程:从完全混合状态开始,逐渐将牛奶从茶中“分离”。利用这个想法,他们在图像生成方面取得了令人期待的结果,但由于当时 GANs 生成的图像更具说服力,扩散模型并没有得到太多关注。

然后,在 2020 年,也来自加州大学伯克利分校的 Jonathan Ho 等人成功构建了一个能够生成高度逼真图像的扩散模型,他们称之为去噪扩散概率模型(DDPM)。几个月后,OpenAI 研究人员 Alex Nichol 和 Prafulla Dhariwal 的2021 年论文分析了 DDPM 架构,并提出了几项改进,使 DDPM 最终击败了 GANs:DDPM 不仅比 GANs 更容易训练,而且生成的图像更加多样化且质量更高。正如您将看到的那样,DDPM 的主要缺点是生成图像需要很长时间,与 GANs 或 VAEs 相比。

那么 DDPM 究竟是如何工作的呢?假设您从一张猫的图片开始(就像您将在图 17-20 中看到的那样),记为x[0],并且在每个时间步t中向图像添加一点均值为 0,方差为β[t]的高斯噪声。这种噪声对于每个像素都是独立的:我们称之为各向同性。您首先得到图像x[1],然后x[2],依此类推,直到猫完全被噪声隐藏,无法看到。最后一个时间步记为T。在最初的 DDPM 论文中,作者使用T = 1,000,并且他们安排了方差β[t]的方式,使得猫信号在时间步 0 和T之间线性消失。在改进的 DDPM 论文中,T增加到了 4,000,并且方差安排被调整为在开始和结束时变化更慢。简而言之,我们正在逐渐将猫淹没在噪声中:这被称为正向过程

随着我们在正向过程中不断添加更多高斯噪声,像素值的分布变得越来越高斯。我遗漏的一个重要细节是,每一步像素值都会被稍微重新缩放,缩放因子为1-βt。这确保了像素值的均值逐渐接近 0,因为缩放因子比 1 稍微小一点(想象一下反复将一个数字乘以 0.99)。这也确保了方差将逐渐收敛到 1。这是因为像素值的标准差也会被1-βt缩放,因此方差会被 1 - β[t](即缩放因子的平方)缩放。但是方差不能收缩到 0,因为我们在每一步都添加方差为β[t]的高斯噪声。而且由于当你对高斯分布求和时方差会相加,您可以看到方差只能收敛到 1 - β[t] + β[t] = 1。

前向扩散过程总结在 Equation 17-5 中。这个方程不会教你任何新的关于前向过程的知识,但理解这种数学符号是有用的,因为它经常在机器学习论文中使用。这个方程定义了给定 x[t–1] 的概率分布 qx[t] 的概率分布,其均值为 x[t–1] 乘以缩放因子,并且具有等于 β[t]I 的协方差矩阵。这是由 β[t] 乘以单位矩阵 I 得到的,这意味着噪音是各向同性的,方差为 β[t]。

方程 17-5. 前向扩散过程的概率分布 q

q(xt|xt-1)=N(1-βtxt-1,βtI)

有趣的是,前向过程有一个快捷方式:可以在不必先计算 x[1], x[2], …, x[t–1] 的情况下,给定 x[0] 来采样图像 x[t]。实际上,由于多个高斯分布的和也是一个高斯分布,所有的噪音可以在一次性使用 Equation 17-6 中的公式添加。这是我们将要使用的方程,因为它速度更快。

方程 17-6. 前向扩散过程的快捷方式

q(xt|x0)=Nα¯tx0,(1-α¯t)I

当然,我们的目标不是让猫淹没在噪音中。相反,我们想要创造许多新的猫!我们可以通过训练一个能够执行逆过程的模型来实现这一点:从 x[t] 到 x[t–1]。然后我们可以使用它从图像中去除一点噪音,并重复这个操作多次,直到所有的噪音都消失。如果我们在包含许多猫图像的数据集上训练模型,那么我们可以给它一张完全充满高斯噪音的图片,模型将逐渐使一个全新的猫出现(见 Figure 17-20)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1720.png

图 17-20. 前向过程 q 和逆过程 p

好的,让我们开始编码!我们需要做的第一件事是编写前向过程的代码。为此,我们首先需要实现方差计划。我们如何控制猫消失的速度?最初,100%的方差来自原始猫图像。然后在每个时间步t,方差会按照 1 - β[t]乘以,如前所述,并添加噪声。因此,来自初始分布的方差部分在每一步都会缩小 1 - β[t]倍。如果我们定义α[t] = 1 - β[t],那么经过t个时间步骤后,猫信号将被乘以α̅[t] = α[1α[2]×…​×α[t] = α¯t=∏i=1tαt。这个“猫信号”因子α̅[t]是我们希望安排的,使其在时间步 0 和T之间逐渐从 1 缩小到 0。在改进的 DDPM 论文中,作者根据方程 17-7 安排α̅[t]。这个计划在图 17-21 中表示。

方程 17-7。前向扩散过程的方差计划方程

βt=1-α¯tα¯t-1,其中 α¯t=f(t)f(0) 和 f(t)=cos(t/T+s1+s·π2)2

在这些方程中:

  • s是一个微小值,防止β[t]在t = 0 附近太小。在论文中,作者使用了s = 0.008。

  • β[t]被剪切为不大于 0.999,以避免在t = T附近的不稳定性。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1721.png

图 17-21。噪声方差计划β[t],以及剩余信号方差α̅[t]

让我们创建一个小函数来计算α[t],β[t]和α̅[t],并使用T = 4,000 调用它:

def variance_schedule(T, s=0.008, max_beta=0.999):
    t = np.arange(T + 1)
    f = np.cos((t / T + s) / (1 + s) * np.pi / 2) ** 2
    alpha = np.clip(f[1:] / f[:-1], 1 - max_beta, 1)
    alpha = np.append(1, alpha).astype(np.float32)  # add α₀ = 1
    beta = 1 - alpha
    alpha_cumprod = np.cumprod(alpha)
    return alpha, alpha_cumprod, beta  # αₜ , α̅ₜ , βₜ for t = 0 to T

T = 4000
alpha, alpha_cumprod, beta = variance_schedule(T)

为了训练我们的模型来逆转扩散过程,我们需要来自前向过程不同时间步的嘈杂图像。为此,让我们创建一个prepare_batch()函数,它将从数据集中获取一批干净图像并准备它们:

def prepare_batch(X):
    X = tf.cast(X[..., tf.newaxis], tf.float32) * 2 - 1  # scale from –1 to +1
    X_shape = tf.shape(X)
    t = tf.random.uniform([X_shape[0]], minval=1, maxval=T + 1, dtype=tf.int32)
    alpha_cm = tf.gather(alpha_cumprod, t)
    alpha_cm = tf.reshape(alpha_cm, [X_shape[0]] + [1] * (len(X_shape) - 1))
    noise = tf.random.normal(X_shape)
    return {
        "X_noisy": alpha_cm ** 0.5 * X + (1 - alpha_cm) ** 0.5 * noise,
        "time": t,
    }, noise

让我们看一下这段代码:

  • 为简单起见,我们将使用 Fashion MNIST,因此函数必须首先添加一个通道轴。将像素值从-1 缩放到 1 也会有所帮助,这样它更接近均值为 0,方差为 1 的最终高斯分布。

  • 接下来,该函数创建t,一个包含每个图像批次中随机时间步长的向量,介于 1 和T之间。

  • 然后它使用tf.gather()来获取向量t中每个时间步长的alpha_cumprod的值。这给我们了向量alpha_cm,其中包含每个图像的一个α̅[t]值。

  • 下一行将alpha_cm从[批次大小]重塑为[批次大小, 1, 1, 1]。这是为了确保alpha_cm可以与批次X进行广播。

  • 然后我们生成一些均值为 0,方差为 1 的高斯噪声。

  • 最后,我们使用方程 17-6 将扩散过程应用于图像。请注意,x ** 0.5等于x的平方根。该函数返回一个包含输入和目标的元组。输入表示为一个 Python dict,其中包含嘈杂图像和用于生成每个图像的时间步。目标是用于生成每个图像的高斯噪声。

注意

通过这种设置,模型将预测应从输入图像中减去的噪声,以获得原始图像。为什么不直接预测原始图像呢?嗯,作者尝试过:它简单地效果不如预期。

接下来,我们将创建一个训练数据集和一个验证集,将prepare_batch()函数应用于每个批次。与之前一样,X_trainX_valid包含像素值从 0 到 1 的时尚 MNIST 图像:

def prepare_dataset(X, batch_size=32, shuffle=False):
    ds = tf.data.Dataset.from_tensor_slices(X)
    if shuffle:
        ds = ds.shuffle(buffer_size=10_000)
    return ds.batch(batch_size).map(prepare_batch).prefetch(1)

train_set = prepare_dataset(X_train, batch_size=32, shuffle=True)
valid_set = prepare_dataset(X_valid, batch_size=32)

现在我们准备构建实际的扩散模型本身。它可以是您想要的任何模型,只要它将嘈杂的图像和时间步骤作为输入,并预测应从输入图像中减去的噪声:

def build_diffusion_model():
    X_noisy = tf.keras.layers.Input(shape=[28, 28, 1], name="X_noisy")
    time_input = tf.keras.layers.Input(shape=[], dtype=tf.int32, name="time")
    [...]  # build the model based on the noisy images and the time steps
    outputs = [...]  # predict the noise (same shape as the input images)
    return tf.keras.Model(inputs=[X_noisy, time_input], outputs=[outputs])

DDPM 的作者使用了一个修改过的U-Net 架构,它与我们在第十四章中讨论的 FCN 架构有许多相似之处,用于语义分割:它是一个卷积神经网络,逐渐对输入图像进行下采样,然后再逐渐对其进行上采样,跳跃连接从每个下采样部分的每个级别跨越到相应的上采样部分的级别。为了考虑时间步长,他们使用了与Transformer架构中的位置编码相同的技术对其进行编码(参见第十六章)。在 U-Net 架构的每个级别上,他们通过Dense层传递这些时间编码,并将它们馈送到 U-Net 中。最后,他们还在各个级别使用了多头注意力层。查看本章的笔记本以获取基本实现,或者https://homl.info/ddpmcode以获取官方实现:它基于已弃用的 TF 1.x,但非常易读。

现在我们可以正常训练模型了。作者指出,使用 MAE 损失比 MSE 效果更好。您也可以使用 Huber 损失:

model = build_diffusion_model()
model.compile(loss=tf.keras.losses.Huber(), optimizer="nadam")
history = model.fit(train_set, validation_data=valid_set, epochs=100)

一旦模型训练完成,您可以使用它生成新图像。不幸的是,在反向扩散过程中没有捷径,因此您必须从均值为 0,方差为 1 的高斯分布中随机抽样x[T],然后将其传递给模型预测噪声;使用方程 17-8 从图像中减去它,然后您会得到x[T–1]。重复这个过程 3999 次,直到得到x[0]:如果一切顺利,它应该看起来像一个常规的时尚 MNIST 图像!

方程 17-8。在扩散过程中向后走一步

xt-1=1αtxt-βt1-α¯tϵθ(xt,t)+βtz

在这个方程中,ϵ[θ](x[t], t)代表模型给定输入图像x[t]和时间步长t预测的噪声。θ代表模型参数。此外,z是均值为 0,方差为 1 的高斯噪声。这使得反向过程是随机的:如果您多次运行它,您将得到不同的图像。

让我们编写一个实现这个反向过程的函数,并调用它生成一些图像:

def generate(model, batch_size=32):
    X = tf.random.normal([batch_size, 28, 28, 1])
    for t in range(T, 0, -1):
        noise = (tf.random.normal if t > 1 else tf.zeros)(tf.shape(X))
        X_noise = model({"X_noisy": X, "time": tf.constant([t] * batch_size)})
        X = (
            1 / alpha[t] ** 0.5
            * (X - beta[t] / (1 - alpha_cumprod[t]) ** 0.5 * X_noise)
            + (1 - alpha[t]) ** 0.5 * noise
        )
    return X

X_gen = generate(model)  # generated images

这可能需要一两分钟。这是扩散模型的主要缺点:生成图像很慢,因为模型需要被多次调用。通过使用较小的T值或者同时使用相同模型预测多个步骤,可以加快这一过程,但生成的图像可能不那么漂亮。尽管存在这种速度限制,扩散模型确实生成高质量和多样化的图像,正如您在图 17-22 中所看到的。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1722.png

图 17-22。DDPM 生成的图像

最近,扩散模型取得了巨大的进展。特别是,2021 年 12 月由Robin Rombach, Andreas Blattmann 等人发表的一篇论文⁠²³介绍了潜在扩散模型,其中扩散过程发生在潜在空间,而不是在像素空间中。为了实现这一点,使用强大的自编码器将每个训练图像压缩到一个更小的潜在空间中,扩散过程发生在这里,然后自编码器用于解压缩最终的潜在表示,生成输出图像。这极大地加快了图像生成速度,大大减少了训练时间和成本。重要的是,生成的图像质量非常出色。

此外,研究人员还采用了各种调节技术来引导扩散过程,使用文本提示、图像或任何其他输入。这使得快速生成一个漂亮的高分辨率图像成为可能,比如一只读书的蝾螈,或者你可能喜欢的其他任何东西。您还可以使用输入图像来调节图像生成过程。这使得许多应用成为可能,比如外部绘制——在输入图像的边界之外扩展——或内部绘制——填充图像中的空洞。

最后,一个名为稳定扩散的强大预训练潜在扩散模型于 2022 年 8 月由慕尼黑大学 LMU 与包括 StabilityAI 和 Runway 在内的几家公司合作开源,得到了 EleutherAI 和 LAION 的支持。2022 年 9 月,它被移植到 TensorFlow,并包含在KerasCV中,这是由 Keras 团队构建的计算机视觉库。现在任何人都可以在几秒钟内免费生成令人惊叹的图像,即使是在普通笔记本电脑上(请参阅本章的最后一个练习)。可能性是无限的!

在下一章中,我们将转向深度强化学习的一个完全不同的分支。

练习

  1. 自编码器主要用于哪些任务?

  2. 假设你想要训练一个分类器,你有大量未标记的训练数据,但只有几千个标记实例。自编码器如何帮助?你会如何继续?

  3. 如果自编码器完美地重建输入,它一定是一个好的自编码器吗?你如何评估自编码器的性能?

  4. 什么是欠完备和过完备自编码器?过度欠完备自编码器的主要风险是什么?过度完备自编码器的主要风险又是什么?

  5. 如何在堆叠自编码器中绑定权重?这样做的目的是什么?

  6. 什么是生成模型?你能说出一种生成自编码器吗?

  7. 什么是 GAN?你能说出几个 GAN 可以发挥作用的任务吗?

  8. 训练 GAN 时的主要困难是什么?

  9. 扩散模型擅长什么?它们的主要限制是什么?

  10. 尝试使用去噪自编码器预训练图像分类器。您可以使用 MNIST(最简单的选项),或者如果您想要更大的挑战,可以使用更复杂的图像数据集,比如CIFAR10。无论您使用的数据集是什么,都要遵循以下步骤:

    1. 将数据集分割成训练集和测试集。在完整的训练集上训练一个深度去噪自编码器。

    2. 检查图像是否被相当好地重建。可视化激活编码层中每个神经元的图像。

    3. 构建一个分类 DNN,重用自编码器的较低层。仅使用训练集中的 500 张图像进行训练。它在有无预训练的情况下表现更好吗?

  11. 在您选择的图像数据集上训练一个变分自编码器,并使用它生成图像。或者,您可以尝试找到一个您感兴趣的无标签数据集,看看是否可以生成新样本。

  12. 训练一个 DCGAN 来处理您选择的图像数据集,并使用它生成图像。添加经验重放,看看这是否有帮助。将其转换为条件 GAN,您可以控制生成的类别。

  13. 浏览 KerasCV 出色的稳定扩散教程,并生成一幅漂亮的图画,展示一只读书的蝾螈。如果您在 Twitter 上发布您最好的图画,请在@ aureliengeron 处标记我。我很想看看您的创作!

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ William G. Chase 和 Herbert A. Simon,“国际象棋中的感知”,认知心理学 4,第 1 期(1973 年):55-81。

² Yoshua Bengio 等,“深度网络的贪婪逐层训练”,第 19 届神经信息处理系统国际会议论文集(2006):153-160。

³ Jonathan Masci 等,“用于分层特征提取的堆叠卷积自编码器”,第 21 届国际人工神经网络会议论文集 1(2011):52-59。

⁴ Pascal Vincent 等,“使用去噪自编码器提取和组合稳健特征”,第 25 届国际机器学习会议论文集(2008):1096-1103。

⁵ Pascal Vincent 等,“堆叠去噪自编码器:使用局部去噪标准在深度网络中学习有用的表示”,机器学习研究杂志 11(2010):3371-3408。

⁶ Diederik Kingma 和 Max Welling,“自编码变分贝叶斯”,arXiv 预印本 arXiv:1312.6114(2013)。

⁷ 变分自编码器实际上更通用;编码不限于高斯分布。

⁸ 要了解更多数学细节,请查看有关变分自编码器的原始论文,或查看 Carl Doersch 的优秀教程(2016)。

⁹ Ian Goodfellow 等,“生成对抗网络”,第 27 届神经信息处理系统国际会议论文集 2(2014):2672-2680。

¹⁰ 要了解主要 GAN 损失的良好比较,请查看 Hwalsuk Lee 的这个GitHub 项目

¹¹ Mario Lucic 等,“GAN 是否平等?大规模研究”,第 32 届神经信息处理系统国际会议论文集(2018):698-707。

¹² Alec Radford 等,“使用深度卷积生成对抗网络进行无监督表示学习”,arXiv 预印本 arXiv:1511.06434(2015)。

¹³ 在作者的亲切授权下再现。

¹⁴ Mehdi Mirza 和 Simon Osindero,“有条件生成对抗网络”,arXiv 预印本 arXiv:1411.1784(2014)。

¹⁵ Tero Karras 等,“用于改善质量、稳定性和变化的 GAN 的渐进增长”,国际学习表示会议论文集(2018)。

¹⁶ 变量的动态范围是其可能取的最高值和最低值之间的比率。

¹⁷ Tero Karras 等人,“基于风格的生成对抗网络架构”,arXiv 预印本 arXiv:1812.04948(2018)。

¹⁸ 在作者的亲切授权下复制。

¹⁹ Jascha Sohl-Dickstein 等人,“使用非平衡热力学进行深度无监督学习”,arXiv 预印本 arXiv:1503.03585(2015)。

²⁰ Jonathan Ho 等人,“去噪扩散概率模型”(2020)。

²¹ Alex Nichol 和 Prafulla Dhariwal,“改进的去噪扩散概率模型”(2021)。

²² Olaf Ronneberger 等人,“U-Net:用于生物医学图像分割的卷积网络”,arXiv 预印本 arXiv:1505.04597(2015)。

²³ Robin Rombach,Andreas Blattmann 等人,“使用潜在扩散模型进行高分辨率图像合成”,arXiv 预印本 arXiv:2112.10752(2021)。

第十八章:强化学习

强化学习(RL)是当今最激动人心的机器学习领域之一,也是最古老的之一。自上世纪 50 年代以来一直存在,多年来产生了许多有趣的应用,特别是在游戏(例如 TD-Gammon,一个下棋程序)和机器控制方面,但很少成为头条新闻。然而,一场革命发生在2013 年,当时来自英国初创公司 DeepMind 的研究人员展示了一个系统,可以从头开始学习玩几乎任何 Atari 游戏,最终在大多数游戏中超越人类,只使用原始像素作为输入,而不需要任何关于游戏规则的先验知识。这是一系列惊人壮举的开始,最终在 2016 年 3 月,他们的系统 AlphaGo 在围棋比赛中击败了传奇职业选手李世石,并在 2017 年 5 月击败了世界冠军柯洁。没有任何程序曾经接近击败这个游戏的大师,更不用说世界冠军了。如今,整个强化学习领域充满了新的想法,具有广泛的应用范围。

那么,DeepMind(2014 年被 Google 以超过 5 亿美元的价格收购)是如何实现所有这些的呢?回顾起来似乎相当简单:他们将深度学习的力量应用于强化学习领域,而且效果超出了他们最疯狂的梦想。在本章中,我将首先解释什么是强化学习以及它擅长什么,然后介绍深度强化学习中最重要的两种技术:策略梯度和深度 Q 网络,包括对马尔可夫决策过程的讨论。让我们开始吧!

学习优化奖励

在强化学习中,软件 代理 在一个 环境 中进行 观察行动,并从环境中获得 奖励。其目标是学会以一种方式行动,以最大化其随时间的预期奖励。如果您不介意有点拟人化,您可以将积极奖励视为快乐,将负面奖励视为痛苦(在这种情况下,“奖励”这个术语有点误导)。简而言之,代理在环境中行动,并通过试错学习来最大化其快乐并最小化其痛苦。

这是一个非常广泛的设置,可以应用于各种任务。以下是一些示例(参见 图 18-1):

  1. 代理程序可以是控制机器人的程序。在这种情况下,环境是真实世界,代理通过一组传感器(如摄像头和触摸传感器)观察环境,其行动包括发送信号以激活电机。它可能被编程为在接近目标位置时获得积极奖励,而在浪费时间或走错方向时获得负面奖励。

  2. 代理可以是控制 Ms. Pac-Man 的程序。在这种情况下,环境是 Atari 游戏的模拟,行动是九种可能的摇杆位置(左上、下、中心等),观察是屏幕截图,奖励只是游戏得分。

  3. 同样,代理可以是玩围棋等棋盘游戏的程序。只有在赢得比赛时才会获得奖励。

  4. 代理不必控制物理(或虚拟)移动的东西。例如,它可以是一个智能恒温器,每当接近目标温度并节省能源时获得积极奖励,当人类需要调整温度时获得负面奖励,因此代理必须学会预测人类需求。

  5. 代理可以观察股市价格并决定每秒买入或卖出多少。奖励显然是货币收益和损失。

请注意,可能根本没有任何正面奖励;例如,代理可能在迷宫中四处移动,在每个时间步都会获得负面奖励,因此最好尽快找到出口!还有许多其他适合强化学习的任务示例,例如自动驾驶汽车、推荐系统、在网页上放置广告,或者控制图像分类系统应该关注的位置。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1801.png

图 18-1. 强化学习示例:(a) 机器人,(b) Ms. Pac-Man,© 围棋选手,(d) 恒温器,(e) 自动交易员⁠⁵

策略搜索

软件代理用来确定其行动的算法称为其策略。策略可以是一个神经网络,将观察作为输入并输出要采取的行动(见图 18-2)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1802.png

图 18-2。使用神经网络策略的强化学习

策略可以是你能想到的任何算法,并且不必是确定性的。实际上,在某些情况下,它甚至不必观察环境!例如,考虑一个机器人吸尘器,其奖励是在 30 分钟内吸尘的量。它的策略可以是每秒以概率p向前移动,或者以概率 1 - p随机向左或向右旋转。旋转角度将是- r和+ r之间的随机角度。由于这个策略涉及一些随机性,它被称为随机策略。机器人将有一个不规则的轨迹,这保证了它最终会到达它可以到达的任何地方并清理所有的灰尘。问题是,在 30 分钟内它会吸尘多少?

你会如何训练这样的机器人?你只能调整两个策略参数:概率p和角度范围r。一个可能的学习算法是尝试许多不同的参数值,并选择表现最好的组合(参见图 18-3)。这是一个策略搜索的例子,这种情况下使用了一种蛮力方法。当策略空间太大时(这通常是情况),通过这种方式找到一组好的参数就像在一个巨大的草堆中寻找一根针。

探索政策空间的另一种方法是使用遗传算法。例如,您可以随机创建第一代 100 个政策并尝试它们,然后“淘汰”最差的 80 个政策,并让 20 个幸存者每人产生 4 个后代。后代是其父母的副本加上一些随机变化。幸存的政策及其后代一起构成第二代。您可以继续通过这种方式迭代生成,直到找到一个好的政策。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1803.png

图 18-3。政策空间中的四个点(左)和代理的相应行为(右)

另一种方法是使用优化技术,通过评估奖励相对于策略参数的梯度,然后通过沿着梯度朝着更高奖励的方向调整这些参数。我们将在本章后面更详细地讨论这种方法,称为策略梯度(PG)。回到吸尘器机器人,您可以稍微增加p,并评估这样做是否会增加机器人在 30 分钟内吸尘的量;如果是,那么再增加p一些,否则减少p。我们将使用 TensorFlow 实现一个流行的 PG 算法,但在此之前,我们需要为代理创建一个环境——现在是介绍 OpenAI Gym 的时候了。

OpenAI Gym 简介

强化学习的一个挑战是,为了训练一个代理程序,您首先需要一个可用的环境。如果您想编写一个代理程序来学习玩 Atari 游戏,您将需要一个 Atari 游戏模拟器。如果您想编写一个行走机器人,那么环境就是现实世界,您可以直接在该环境中训练您的机器人。然而,这也有其局限性:如果机器人掉下悬崖,您不能简单地点击撤销。您也不能加快时间——增加计算能力不会使机器人移动得更快——而且通常来说,同时训练 1000 个机器人的成本太高。简而言之,在现实世界中训练是困难且缓慢的,因此您通常至少需要一个模拟环境来进行引导训练。例如,您可以使用类似PyBulletMuJoCo的库进行 3D 物理模拟。

OpenAI Gym是一个工具包,提供各种模拟环境(Atari 游戏,棋盘游戏,2D 和 3D 物理模拟等),您可以用它来训练代理程序,比较它们,或者开发新的 RL 算法。

OpenAI Gym 在 Colab 上预先安装,但是它是一个较旧的版本,因此您需要用最新版本替换它。您还需要安装一些它的依赖项。如果您在自己的机器上编程而不是在 Colab 上,并且按照https://homl.info/install上的安装说明进行操作,那么您可以跳过这一步;否则,请输入以下命令:

# Only run these commands on Colab or Kaggle!
%pip install -q -U gym
%pip install -q -U gym[classic_control,box2d,atari,accept-rom-license]

第一个%pip命令将 Gym 升级到最新版本。-q选项代表quiet:它使输出更简洁。-U选项代表upgrade。第二个%pip命令安装了运行各种环境所需的库。这包括来自控制理论(控制动态系统的科学)的经典环境,例如在小车上平衡杆。它还包括基于 Box2D 库的环境——一个用于游戏的 2D 物理引擎。最后,它包括基于 Arcade Learning Environment(ALE)的环境,这是 Atari 2600 游戏的模拟器。几个 Atari 游戏的 ROM 会被自动下载,通过运行这段代码,您同意 Atari 的 ROM 许可证。

有了这个,您就可以使用 OpenAI Gym 了。让我们导入它并创建一个环境:

import gym

env = gym.make("CartPole-v1", render_mode="rgb_array")

在这里,我们创建了一个 CartPole 环境。这是一个 2D 模拟,其中一个小车可以被加速向左或向右,以平衡放在其顶部的杆(参见图 18-4)。这是一个经典的控制任务。

提示

gym.envs.registry字典包含所有可用环境的名称和规格。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1804.png

图 18-4。CartPole 环境

在创建环境之后,您必须使用reset()方法对其进行初始化,可以选择性地指定一个随机种子。这将返回第一个观察结果。观察结果取决于环境的类型。对于 CartPole 环境,每个观察结果都是一个包含四个浮点数的 1D NumPy 数组,表示小车的水平位置(0.0 = 中心),其速度(正数表示向右),杆的角度(0.0 = 垂直),以及其角速度(正数表示顺时针)。reset()方法还返回一个可能包含额外环境特定信息的字典。这对于调试或训练可能很有用。例如,在许多 Atari 环境中,它包含剩余的生命次数。然而,在 CartPole 环境中,这个字典是空的。

>>> obs, info = env.reset(seed=42)
>>> obs
array([ 0.0273956 , -0.00611216,  0.03585979,  0.0197368 ], dtype=float32)
>>> info
{}

让我们调用render()方法将这个环境渲染为图像。由于在创建环境时设置了render_mode="rgb_array",图像将作为一个 NumPy 数组返回:

>>> img = env.render()
>>> img.shape  # height, width, channels (3 = Red, Green, Blue)
(400, 600, 3)

然后,您可以使用 Matplotlib 的imshow()函数来显示这个图像,就像往常一样。

现在让我们询问环境有哪些可能的动作:

>>> env.action_space
Discrete(2)

Discrete(2)表示可能的动作是整数 0 和 1,分别代表向左或向右加速。其他环境可能有额外的离散动作,或其他类型的动作(例如连续动作)。由于杆向右倾斜(obs[2] > 0),让我们加速小车向右:

>>> action = 1  # accelerate right
>>> obs, reward, done, truncated, info = env.step(action)
>>> obs
array([ 0.02727336,  0.18847767,  0.03625453, -0.26141977], dtype=float32)
>>> reward
1.0
>>> done
False
>>> truncated
False
>>> info
{}

step() 方法执行所需的动作并返回五个值:

obs

这是新的观察。小车现在向右移动(obs[1] > 0)。杆仍然向右倾斜(obs[2] > 0),但它的角速度现在是负的(obs[3] < 0),所以在下一步之后它可能会向左倾斜。

reward

在这个环境中,无论你做什么,每一步都会获得 1.0 的奖励,所以目标是尽可能让情节运行更长时间。

done

当情节结束时,这个值将是True。当杆倾斜得太多,或者离开屏幕,或者经过 200 步后(在这种情况下,你赢了),情节就会结束。之后,环境必须被重置才能再次使用。

truncated

当一个情节被提前中断时,这个值将是True,例如通过一个强加每个情节最大步数的环境包装器(请参阅 Gym 的文档以获取有关环境包装器的更多详细信息)。一些强化学习算法会将截断的情节与正常结束的情节(即doneTrue时)区别对待,但在本章中,我们将对它们进行相同处理。

info

这个特定于环境的字典可能提供额外的信息,就像reset()方法返回的那样。

提示

当你使用完一个环境后,应该调用它的close()方法来释放资源。

让我们硬编码一个简单的策略,当杆向左倾斜时加速向左,当杆向右倾斜时加速向右。我们将运行此策略,以查看它在 500 个情节中获得的平均奖励:

def basic_policy(obs):
    angle = obs[2]
    return 0 if angle < 0 else 1

totals = []
for episode in range(500):
    episode_rewards = 0
    obs, info = env.reset(seed=episode)
    for step in range(200):
        action = basic_policy(obs)
        obs, reward, done, truncated, info = env.step(action)
        episode_rewards += reward
        if done or truncated:
            break

    totals.append(episode_rewards)

这段代码是不言自明的。让我们看看结果:

>>> import numpy as np
>>> np.mean(totals), np.std(totals), min(totals), max(totals)
(41.698, 8.389445512070509, 24.0, 63.0)

即使尝试了 500 次,这个策略也从未成功让杆连续保持直立超过 63 步。不太好。如果你看一下本章笔记本中的模拟,你会看到小车左右摆动得越来越强烈,直到杆倾斜得太多。让我们看看神经网络是否能提出一个更好的策略。

神经网络策略

让我们创建一个神经网络策略。这个神经网络将以观察作为输入,并输出要执行的动作,就像我们之前硬编码的策略一样。更准确地说,它将为每个动作估计一个概率,然后我们将根据估计的概率随机选择一个动作(参见图 18-5)。在 CartPole 环境中,只有两种可能的动作(左或右),所以我们只需要一个输出神经元。它将输出动作 0(左)的概率p,当然动作 1(右)的概率将是 1 - p。例如,如果它输出 0.7,那么我们将以 70%的概率选择动作 0,或者以 30%的概率选择动作 1。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1805.png

图 18-5. 神经网络策略

你可能会想为什么我们根据神经网络给出的概率随机选择一个动作,而不是只选择得分最高的动作。这种方法让代理人在探索新动作和利用已知效果良好的动作之间找到平衡。这里有一个类比:假设你第一次去一家餐馆,所有菜看起来都一样吸引人,所以你随机挑选了一个。如果它很好吃,你可以增加下次点它的概率,但你不应该将这个概率增加到 100%,否则你永远不会尝试其他菜,其中一些可能比你尝试的这个更好。这个探索/利用的困境在强化学习中是核心的。

还要注意,在这种特定环境中,过去的动作和观察可以安全地被忽略,因为每个观察包含了环境的完整状态。如果有一些隐藏状态,那么您可能需要考虑过去的动作和观察。例如,如果环境只透露了小车的位置而没有速度,那么您不仅需要考虑当前观察,还需要考虑上一个观察以估计当前速度。另一个例子是当观察是嘈杂的;在这种情况下,通常希望使用过去几个观察来估计最可能的当前状态。因此,CartPole 问题非常简单;观察是无噪声的,并且包含了环境的完整状态。

以下是使用 Keras 构建基本神经网络策略的代码:

import tensorflow as tf

model = tf.keras.Sequential([
    tf.keras.layers.Dense(5, activation="relu"),
    tf.keras.layers.Dense(1, activation="sigmoid"),
])

我们使用Sequential模型来定义策略网络。输入的数量是观察空间的大小——在 CartPole 的情况下是 4——我们只有五个隐藏单元,因为这是一个相当简单的任务。最后,我们希望输出一个单一的概率——向左移动的概率——因此我们使用具有 sigmoid 激活函数的单个输出神经元。如果有超过两种可能的动作,每种动作将有一个输出神经元,并且我们将使用 softmax 激活函数。

好的,现在我们有一个神经网络策略,它将接收观察并输出动作概率。但是我们如何训练它呢?

评估动作:信用分配问题

如果我们知道每一步的最佳行动是什么,我们可以像平常一样训练神经网络,通过最小化估计概率分布与目标概率分布之间的交叉熵来实现,这将只是常规的监督学习。然而,在强化学习中,智能体得到的唯一指导是通过奖励,而奖励通常是稀疏和延迟的。例如,如果智能体设法在 100 步内平衡杆,它如何知道这 100 个动作中哪些是好的,哪些是坏的?它只知道在最后一个动作之后杆倒了,但肯定不是这个最后一个动作完全负责。这被称为信用分配问题:当智能体获得奖励时,它很难知道哪些动作应该得到赞扬(或责备)。想象一只狗表现良好几个小时后才得到奖励;它会明白为什么会得到奖励吗?

为了解决这个问题,一个常见的策略是基于之后所有奖励的总和来评估一个动作,通常在每一步应用一个折扣因子γ(gamma)。这些折扣后的奖励之和被称为动作的回报。考虑图 18-6 中的例子。如果一个智能体连续三次向右移动,并在第一步后获得+10 奖励,在第二步后获得 0 奖励,最后在第三步后获得-50 奖励,那么假设我们使用一个折扣因子γ=0.8,第一个动作的回报将是 10 + γ × 0 + γ² × (–50) = –22。如果折扣因子接近 0,那么未来的奖励与即时奖励相比不会占据很大比重。相反,如果折扣因子接近 1,那么未来的奖励将几乎和即时奖励一样重要。典型的折扣因子从 0.9 到 0.99 不等。使用折扣因子 0.95,未来 13 步的奖励大约相当于即时奖励的一半(因为 0.95¹³ ≈ 0.5),而使用折扣因子 0.99,未来 69 步的奖励相当于即时奖励的一半。在 CartPole 环境中,动作具有相当短期的影响,因此选择折扣因子 0.95 似乎是合理的。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1806.png

图 18-6。计算动作的回报:折扣未来奖励之和

当然,一个好的行动可能会被几个导致杆迅速倒下的坏行动跟随,导致好的行动获得较低的回报。同样,一个好的演员有时可能会出演一部糟糕的电影。然而,如果我们玩足够多次游戏,平均而言好的行动将获得比坏行动更高的回报。我们想要估计一个行动相对于其他可能行动的平均优势有多大。这被称为行动优势。为此,我们必须运行许多情节,并通过减去均值并除以标准差来标准化所有行动回报。之后,我们可以合理地假设具有负优势的行动是坏的,而具有正优势的行动是好的。现在我们有了一种评估每个行动的方法,我们准备使用策略梯度来训练我们的第一个代理。让我们看看如何。

策略梯度

正如前面讨论的,PG 算法通过沿着梯度朝着更高奖励的方向优化策略的参数。一种流行的 PG 算法类别称为REINFORCE 算法,由 Ronald Williams 于 1992 年提出。这里是一个常见的变体:

  1. 首先,让神经网络策略玩游戏多次,并在每一步计算使选择的行动更有可能的梯度,但暂时不应用这些梯度。

  2. 在运行了几个情节之后,使用前一节中描述的方法计算每个行动的优势。

  3. 如果一个行动的优势是正的,这意味着这个行动可能是好的,你希望应用之前计算的梯度,使这个行动在未来更有可能被选择。然而,如果一个行动的优势是负的,这意味着这个行动可能是坏的,你希望应用相反的梯度,使这个行动在未来略微减少。解决方案是将每个梯度向量乘以相应行动的优势。

  4. 最后,计算所有结果梯度向量的平均值,并用它执行一步梯度下降。

让我们使用 Keras 来实现这个算法。我们将训练之前构建的神经网络策略,使其学会在小车上平衡杆。首先,我们需要一个函数来执行一步。我们暂时假设无论采取什么行动都是正确的,以便我们可以计算损失及其梯度。这些梯度将暂时保存一段时间,我们稍后会根据行动的好坏来修改它们:

def play_one_step(env, obs, model, loss_fn):
    with tf.GradientTape() as tape:
        left_proba = model(obs[np.newaxis])
        action = (tf.random.uniform([1, 1]) > left_proba)
        y_target = tf.constant([[1.]]) - tf.cast(action, tf.float32)
        loss = tf.reduce_mean(loss_fn(y_target, left_proba))

    grads = tape.gradient(loss, model.trainable_variables)
    obs, reward, done, truncated, info = env.step(int(action))
    return obs, reward, done, truncated, grads

让我们来看看这个函数:

  • GradientTape块中(参见第十二章),我们首先调用模型,给它一个观察值。我们将观察值重塑为包含单个实例的批次,因为模型期望一个批次。这将输出向左移动的概率。

  • 接下来,我们随机抽取一个介于 0 和 1 之间的浮点数,并检查它是否大于left_probaaction将以left_proba的概率为False,或以1 - left_proba的概率为True。一旦我们将这个布尔值转换为整数,行动将以适当的概率为 0(左)或 1(右)。

  • 现在我们定义向左移动的目标概率:它是 1 减去行动(转换为浮点数)。如果行动是 0(左),那么向左移动的目标概率将是 1。如果行动是 1(右),那么目标概率将是 0。

  • 然后我们使用给定的损失函数计算损失,并使用 tape 计算损失相对于模型可训练变量的梯度。同样,这些梯度稍后会在应用之前进行调整,取决于行动的好坏。

  • 最后,我们执行选择的行动,并返回新的观察值、奖励、该情节是否结束、是否截断,当然还有我们刚刚计算的梯度。

现在让我们创建另一个函数,它将依赖于play_one_step()函数来玩多个回合,返回每个回合和每个步骤的所有奖励和梯度:

def play_multiple_episodes(env, n_episodes, n_max_steps, model, loss_fn):
    all_rewards = []
    all_grads = []
    for episode in range(n_episodes):
        current_rewards = []
        current_grads = []
        obs, info = env.reset()
        for step in range(n_max_steps):
            obs, reward, done, truncated, grads = play_one_step(
                env, obs, model, loss_fn)
            current_rewards.append(reward)
            current_grads.append(grads)
            if done or truncated:
                break

        all_rewards.append(current_rewards)
        all_grads.append(current_grads)

    return all_rewards, all_grads

这段代码返回了一个奖励列表的列表:每个回合一个奖励列表,每个步骤一个奖励。它还返回了一个梯度列表的列表:每个回合一个梯度列表,每个梯度列表包含每个步骤的一个梯度元组,每个元组包含每个可训练变量的一个梯度张量。

该算法将使用play_multiple_episodes()函数多次玩游戏(例如,10 次),然后它将回头查看所有奖励,对其进行折扣,并对其进行归一化。为此,我们需要几个额外的函数;第一个将计算每个步骤的未来折扣奖励总和,第二个将通过减去均值并除以标准差来对所有这些折扣奖励(即回报)在许多回合中进行归一化:

def discount_rewards(rewards, discount_factor):
    discounted = np.array(rewards)
    for step in range(len(rewards) - 2, -1, -1):
        discounted[step] += discounted[step + 1] * discount_factor
    return discounted

def discount_and_normalize_rewards(all_rewards, discount_factor):
    all_discounted_rewards = [discount_rewards(rewards, discount_factor)
                              for rewards in all_rewards]
    flat_rewards = np.concatenate(all_discounted_rewards)
    reward_mean = flat_rewards.mean()
    reward_std = flat_rewards.std()
    return [(discounted_rewards - reward_mean) / reward_std
            for discounted_rewards in all_discounted_rewards]

让我们检查一下这是否有效:

>>> discount_rewards([10, 0, -50], discount_factor=0.8)
array([-22, -40, -50])
>>> discount_and_normalize_rewards([[10, 0, -50], [10, 20]],
...                                discount_factor=0.8)
...
[array([-0.28435071, -0.86597718, -1.18910299]),
 array([1.26665318, 1.0727777 ])]

调用discount_rewards()返回了我们预期的结果(见图 18-6)。您可以验证函数discount_and_normalize_rewards()确实返回了两个回合中每个动作的归一化优势。请注意,第一个回合比第二个回合差得多,因此它的归一化优势都是负数;第一个回合的所有动作都被认为是不好的,反之第二个回合的所有动作都被认为是好的。

我们几乎准备好运行算法了!现在让我们定义超参数。我们将运行 150 次训练迭代,每次迭代玩 10 个回合,每个回合最多持续 200 步。我们将使用折扣因子 0.95:

n_iterations = 150
n_episodes_per_update = 10
n_max_steps = 200
discount_factor = 0.95

我们还需要一个优化器和损失函数。一个常规的 Nadam 优化器,学习率为 0.01,将会很好地完成任务,我们将使用二元交叉熵损失函数,因为我们正在训练一个二元分类器(有两种可能的动作——左或右):

optimizer = tf.keras.optimizers.Nadam(learning_rate=0.01)
loss_fn = tf.keras.losses.binary_crossentropy

现在我们准备构建和运行训练循环!

for iteration in range(n_iterations):
    all_rewards, all_grads = play_multiple_episodes(
        env, n_episodes_per_update, n_max_steps, model, loss_fn)
    all_final_rewards = discount_and_normalize_rewards(all_rewards,
                                                       discount_factor)
    all_mean_grads = []
    for var_index in range(len(model.trainable_variables)):
        mean_grads = tf.reduce_mean(
            [final_reward * all_grads[episode_index][step][var_index]
             for episode_index, final_rewards in enumerate(all_final_rewards)
                 for step, final_reward in enumerate(final_rewards)], axis=0)
        all_mean_grads.append(mean_grads)

    optimizer.apply_gradients(zip(all_mean_grads, model.trainable_variables))

让我们逐步走过这段代码:

  • 在每次训练迭代中,此循环调用play_multiple_episodes()函数,该函数播放 10 个回合,并返回每个步骤中每个回合的奖励和梯度。

  • 然后我们调用discount_and_normalize_rewards()函数来计算每个动作的归一化优势,这在这段代码中称为final_reward。这提供了一个衡量每个动作实际上是好还是坏的指标。

  • 接下来,我们遍历每个可训练变量,并对每个变量计算所有回合和所有步骤中该变量的梯度的加权平均,权重为final_reward

  • 最后,我们使用优化器应用这些均值梯度:模型的可训练变量将被微调,希望策略会有所改善。

我们完成了!这段代码将训练神经网络策略,并成功学会在小车上平衡杆。每个回合的平均奖励将非常接近 200。默认情况下,这是该环境的最大值。成功!

我们刚刚训练的简单策略梯度算法解决了 CartPole 任务,但是它在扩展到更大更复杂的任务时效果不佳。事实上,它具有很高的样本效率低,这意味着它需要很长时间探索游戏才能取得显著进展。这是因为它必须运行多个回合来估计每个动作的优势,正如我们所见。然而,它是更强大算法的基础,比如演员-评论家算法(我们将在本章末简要讨论)。

提示

研究人员试图找到即使代理最初对环境一无所知也能很好运行的算法。然而,除非你在写论文,否则不应该犹豫向代理注入先验知识,因为这将极大加快训练速度。例如,由于你知道杆应该尽可能垂直,你可以添加与杆角度成比例的负奖励。这将使奖励变得不那么稀疏,加快训练速度。此外,如果你已经有一个相当不错的策略(例如硬编码),你可能希望在使用策略梯度来改进之前,训练神经网络来模仿它。

现在我们将看一下另一个流行的算法家族。PG 算法直接尝试优化策略以增加奖励,而我们现在要探索的算法则不那么直接:代理学习估计每个状态的预期回报,或者每个状态中每个动作的预期回报,然后利用这些知识来决定如何行动。要理解这些算法,我们首先必须考虑马尔可夫决策过程(MDPs)。

马尔可夫决策过程

20 世纪初,数学家安德烈·马尔可夫研究了没有记忆的随机过程,称为马尔可夫链。这样的过程具有固定数量的状态,并且在每一步中随机从一个状态演变到另一个状态。它从状态s演变到状态s′的概率是固定的,仅取决于对(s, s′)这一对,而不取决于过去的状态。这就是为什么我们说该系统没有记忆。

图 18-7 显示了一个具有四个状态的马尔可夫链的示例。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1807.png

图 18-7. 马尔可夫链示例

假设过程从状态s[0]开始,并且有 70%的概率在下一步保持在该状态。最终,它必定会离开该状态并永远不会回来,因为没有其他状态指向s[0]。如果它进入状态s[1],那么它很可能会进入状态s[2](90%的概率),然后立即返回到状态s[1](100%的概率)。它可能在这两个状态之间交替多次,但最终会陷入状态s[3]并永远留在那里,因为没有出路:这被称为终止状态。马尔可夫链的动态可能非常不同,并且在热力学、化学、统计学等领域被广泛使用。

马尔可夫决策过程是在 20 世纪 50 年代由理查德·贝尔曼首次描述的。⁠¹² 它们类似于马尔可夫链,但有一个区别:在每一步中,代理可以选择几种可能的动作之一,转移概率取决于所选择的动作。此外,一些状态转移会产生一些奖励(正面或负面),代理的目标是找到一个能够随时间最大化奖励的策略。

例如,MDP 在图 18-8 中表示有三个状态(由圆圈表示),并且在每一步最多有三种可能的离散动作(由菱形表示)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1808.png

图 18-8. 马尔可夫决策过程示例

如果代理从状态 s[0] 开始,可以在行动 a[0]、a[1] 或 a[2] 之间选择。如果选择行动 a[1],它就会肯定留在状态 s[0],没有任何奖励。因此,如果愿意,它可以决定永远留在那里。但如果选择行动 a[0],它有 70%的概率获得+10 的奖励并留在状态 s[0]。然后它可以一次又一次地尝试获得尽可能多的奖励,但最终会进入状态 s[1]。在状态 s[1] 中,它只有两种可能的行动:a[0] 或 a[2]。它可以通过反复选择行动 a[0] 来保持原地,或者选择移动到状态 s[2] 并获得-50 的负奖励(疼)。在状态 s[2] 中,它别无选择,只能采取行动 a[1],这很可能会将其带回状态 s[0],在途中获得+40 的奖励。你明白了。通过观察这个 MDP,你能猜出哪种策略会随着时间获得最多的奖励吗?在状态 s[0] 中,很明显行动 a[0] 是最佳选择,在状态 s[2] 中,代理别无选择,只能采取行动 a[1],但在状态 s[1] 中,不明显代理应该保持原地(a[0])还是冒险前进(a[2])。

贝尔曼找到了一种估计任何状态 s最优状态值 V(s) 的方法,这是代理在到达该状态后可以期望的所有折扣未来奖励的总和,假设它采取最优行动。他表明,如果代理采取最优行动,那么贝尔曼最优性方程适用(参见方程 18-1)。这个递归方程表明,如果代理采取最优行动,那么当前状态的最优值等于在采取一个最优行动后平均获得的奖励,再加上这个行动可能导致的所有可能下一个状态的期望最优值。

方程 18-1. 贝尔曼最优性方程

V ( s ) = max a ∑ s‘ T ( s , a , s’ ) [ R ( s , a , s' ) + γ · V ( s' ) ] for all s

在这个方程中:

  • T(s, a, s′) 是从状态 s 转移到状态 s′ 的转移概率,假设代理选择行动 a。例如,在图 18-8 中,T(s[2], a[1], s[0]) = 0.8。

  • R(s, a, s′) 是代理从状态 s 转移到状态 s′ 时获得的奖励,假设代理选择行动 a。例如,在图 18-8 中,R(s[2], a[1], s[0]) = +40。

  • γ 是折扣因子。

这个方程直接导致了一个算法,可以精确估计每个可能状态的最优状态值:首先将所有状态值估计初始化为零,然后使用值迭代算法进行迭代更新(参见方程 18-2)。一个显著的结果是,给定足够的时间,这些估计将收敛到最优状态值,对应于最优策略。

方程 18-2. 值迭代算法

V k+1 ( s ) ← max a ∑ s‘ T ( s , a , s’ ) [ R ( s , a , s‘ ) + γ · V k ( s’ ) ] for all s

在这个方程中,V**k是算法的第k次迭代中状态s的估计值。

注意

这个算法是动态规划的一个例子,它将一个复杂的问题分解成可迭代处理的可解子问题。

知道最优状态值可能很有用,特别是用于评估策略,但它并不能为代理提供最优策略。幸运的是,贝尔曼找到了一个非常相似的算法来估计最优状态-动作值,通常称为Q 值(质量值)。状态-动作对(s, a)的最优 Q 值,记为Q**(s, a),是代理在到达状态s并选择动作a*后,在看到此动作结果之前,可以期望平均获得的折现未来奖励的总和,假设在此动作之后它表现最佳。

让我们看看它是如何工作的。再次,您首先将所有 Q 值的估计初始化为零,然后使用Q 值迭代算法进行更新(参见方程 18-3)。

方程 18-3。Q 值迭代算法

Q k+1 ( s , a ) ← ∑ s‘ T ( s , a , s’ ) [ R ( s , a , s‘ ) + γ · max a’ Q k ( s‘ , a’ ) ] for all ( s , a )

一旦您有了最优的 Q 值,定义最优策略π**(s)是微不足道的;当代理处于状态s时,它应该选择具有该状态最高 Q 值的动作:π(s)=argmaxaQ*(s,a)。

让我们将这个算法应用到图 18-8 中表示的 MDP 中。首先,我们需要定义 MDP:

transition_probabilities = [  # shape=[s, a, s']
    [[0.7, 0.3, 0.0], [1.0, 0.0, 0.0], [0.8, 0.2, 0.0]],
    [[0.0, 1.0, 0.0], None, [0.0, 0.0, 1.0]],
    [None, [0.8, 0.1, 0.1], None]
]
rewards = [  # shape=[s, a, s']
    [[+10, 0, 0], [0, 0, 0], [0, 0, 0]],
    [[0, 0, 0], [0, 0, 0], [0, 0, -50]],
    [[0, 0, 0], [+40, 0, 0], [0, 0, 0]]
]
possible_actions = [[0, 1, 2], [0, 2], [1]]

例如,要知道在执行动作a[1]后从s[2]到s[0]的转移概率,我们将查找transition_probabilities[2][1][0](为 0.8)。类似地,要获得相应的奖励,我们将查找rewards[2][1][0](为+40)。要获取s[2]中可能的动作列表,我们将查找possible_actions[2](在这种情况下,只有动作a[1]是可能的)。接下来,我们必须将所有 Q 值初始化为零(对于不可能的动作,我们将 Q 值设置为-∞):

Q_values = np.full((3, 3), -np.inf)  # -np.inf for impossible actions
for state, actions in enumerate(possible_actions):
    Q_values[state, actions] = 0.0  # for all possible actions

现在让我们运行 Q 值迭代算法。它重复应用方程 18-3,对每个状态和每个可能的动作的所有 Q 值进行计算:

gamma = 0.90  # the discount factor

for iteration in range(50):
    Q_prev = Q_values.copy()
    for s in range(3):
        for a in possible_actions[s]:
            Q_values[s, a] = np.sum([
                    transition_probabilities[s][a][sp]
                    * (rewards[s][a][sp] + gamma * Q_prev[sp].max())
                for sp in range(3)])

就是这样!得到的 Q 值看起来像这样:

>>> Q_values
array([[18.91891892, 17.02702702, 13.62162162],
 [ 0\.        ,        -inf, -4.87971488],
 [       -inf, 50.13365013,        -inf]])

例如,当代理处于状态s[0]并选择动作a[1]时,预期的折现未来奖励总和约为 17.0。

对于每个状态,我们可以找到具有最高 Q 值的动作:

>>> Q_values.argmax(axis=1)  # optimal action for each state
array([0, 0, 1])

这给出了在使用折扣因子为 0.90 时这个 MDP 的最优策略:在状态s[0]选择动作a[0],在状态s[1]选择动作a[0](即保持不动),在状态s[2]选择动作a[1](唯一可能的动作)。有趣的是,如果将折扣因子增加到 0.95,最优策略会改变:在状态s[1]中,最佳动作变为a[2](冲过火!)。这是有道理的,因为你越重视未来的奖励,你就越愿意忍受现在的一些痛苦,以换取未来的幸福。

时间差异学习

具有离散动作的强化学习问题通常可以建模为马尔可夫决策过程,但代理最初不知道转移概率是多少(它不知道T(s, a, s′)),也不知道奖励将会是什么(它不知道R(s, a, s′))。它必须至少体验每个状态和每个转换一次才能知道奖励,如果要对转移概率有合理的估计,它必须多次体验它们。

时间差异(TD)学习算法与 Q 值迭代算法非常相似,但经过调整以考虑代理只有对 MDP 的部分知识这一事实。通常我们假设代理最初只知道可能的状态和动作,什么也不知道。代理使用一个探索策略——例如,一个纯随机策略——来探索 MDP,随着探索的进行,TD 学习算法根据实际观察到的转换和奖励更新状态值的估计(参见方程 18-4)。

方程 18-4. TD 学习算法

V k+1 (s)←(1-α) Vk (s)+α r+γ· Vk (s‘) 或者,等价地: Vk+1 (s)←Vk (s)+α· δk (s,r, s’) 其中δk (s,r,s′ )=r+γ · Vk (s')- Vk(s)

在这个方程中:

  • α是学习率(例如,0.01)。

  • r + γ · V**k被称为TD 目标

  • δk 被称为TD 误差

写出这个方程的第一种形式的更简洁方法是使用符号a←αb,意思是a[k+1] ← (1 - α) · a[k] + α ·b[k]。因此,方程 18-4 的第一行可以重写为:V(s)←αr+γ·V(s')。

提示

TD 学习与随机梯度下降有许多相似之处,包括一次处理一个样本。此外,就像 SGD 一样,只有逐渐降低学习率,它才能真正收敛;否则,它将继续在最优 Q 值周围反弹。

对于每个状态s,该算法跟踪代理离开该状态后获得的即时奖励的平均值,以及它期望获得的奖励,假设它采取最优行动。

Q-Learning

同样,Q-learning 算法是 Q 值迭代算法在转移概率和奖励最初未知的情况下的一种适应。Q-learning 通过观察代理玩(例如,随机玩)并逐渐改进其对 Q 值的估计来工作。一旦它有准确的 Q 值估计(或足够接近),那么最优策略就是选择具有最高 Q 值的动作(即,贪婪策略)。

方程 18-5. Q-learning 算法

Q(s,a) ←α r+γ· maxa‘ Q(s’, a')

对于每个状态-动作对(sa),该算法跟踪代理离开状态s并采取动作a后获得的奖励r的平均值,以及它期望获得的折现未来奖励的总和。为了估计这个总和,我们取下一个状态s′的 Q 值估计的最大值,因为我们假设目标策略将从那时开始最优地行动。

让我们实现 Q-learning 算法。首先,我们需要让代理探索环境。为此,我们需要一个步骤函数,以便代理可以执行一个动作并获得结果状态和奖励:

def step(state, action):
    probas = transition_probabilities[state][action]
    next_state = np.random.choice([0, 1, 2], p=probas)
    reward = rewards[state][action][next_state]
    return next_state, reward

现在让我们实现代理的探索策略。由于状态空间相当小,一个简单的随机策略就足够了。如果我们运行足够长的时间,代理将多次访问每个状态,并且还将多次尝试每种可能的动作:

def exploration_policy(state):
    return np.random.choice(possible_actions[state])

接下来,在我们像之前一样初始化 Q 值之后,我们准备使用学习率衰减的 Q-learning 算法运行(使用幂调度,引入于第十一章):

alpha0 = 0.05  # initial learning rate
decay = 0.005  # learning rate decay
gamma = 0.90  # discount factor
state = 0  # initial state

for iteration in range(10_000):
    action = exploration_policy(state)
    next_state, reward = step(state, action)
    next_value = Q_values[next_state].max()  # greedy policy at the next step
    alpha = alpha0 / (1 + iteration * decay)
    Q_values[state, action] *= 1 - alpha
    Q_values[state, action] += alpha * (reward + gamma * next_value)
    state = next_state

这个算法将收敛到最优的 Q 值,但需要很多迭代,可能需要相当多的超参数调整。正如您在图 18-9 中看到的那样,Q 值迭代算法(左侧)收敛得非常快,在不到 20 次迭代中,而 Q-learning 算法(右侧)需要大约 8000 次迭代才能收敛。显然,不知道转移概率或奖励使得找到最优策略变得更加困难!

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1809.png

图 18-9. Q 值迭代算法与 Q-learning 算法的学习曲线

Q-learning 算法被称为离策略算法,因为正在训练的策略不一定是训练过程中使用的策略。例如,在我们刚刚运行的代码中,执行的策略(探索策略)是完全随机的,而正在训练的策略从未被使用过。训练后,最优策略对应于系统地选择具有最高 Q 值的动作。相反,策略梯度算法是在策略算法:它使用正在训练的策略探索世界。令人惊讶的是,Q-learning 能够通过观察代理随机行动来学习最优策略。想象一下,在一只被蒙住眼睛的猴子是你的老师时学习打高尔夫球。我们能做得更好吗?

探索策略

当然,只有当探索策略足够彻底地探索 MDP 时,Q 学习才能起作用。尽管纯随机策略保证最终会访问每个状态和每个转换多次,但这可能需要非常长的时间。因此,更好的选择是使用ε-贪心策略(ε是 epsilon):在每一步中,它以概率ε随机行动,或以概率 1-ε贪婪地行动(即选择具有最高 Q 值的动作)。ε-贪心策略的优势(与完全随机策略相比)在于,随着 Q 值估计变得越来越好,它将花费越来越多的时间探索环境的有趣部分,同时仍然花费一些时间访问 MDP 的未知区域。通常会从较高的ε值(例如 1.0)开始,然后逐渐降低它(例如降至 0.05)。

另一种方法是鼓励探索策略尝试之前尝试过的动作,而不仅仅依赖于机会。这可以作为添加到 Q 值估计中的奖励来实现,如方程 18-6 所示。

方程 18-6. 使用探索函数的 Q 学习

Q(s,a) ←α r+γ·max a‘ f Q(s’,a‘ ),N(s’, a')

在这个方程中:

  • N(s′, a′)计算动作a′在状态s′中被选择的次数。

  • f(Q, N)是一个探索函数,例如f(Q, N) = Q + κ/(1 + N),其中κ是一个好奇心超参数,衡量了代理对未知的吸引力。

近似 Q 学习和深度 Q 学习

Q 学习的主要问题是它在具有许多状态和动作的大型(甚至中等大小)MDP 中无法很好地扩展。例如,假设您想使用 Q 学习来训练一个代理玩《Ms. Pac-Man》(见图 18-1)。Ms. Pac-Man 可以吃约 150 个豆子,每个豆子可以存在或不存在(即已经被吃掉)。因此,可能的状态数量大于 2¹⁵⁰ ≈ 10⁴⁵。如果您考虑所有鬼和 Ms. Pac-Man 的所有可能位置组合,可能的状态数量将大于地球上的原子数量,因此绝对无法跟踪每个单个 Q 值的估计。

解决方案是找到一个函数Qθ,它用可管理数量的参数(由参数向量θ给出)来近似任何状态-动作对(s, a)的 Q 值。这被称为近似 Q 学习。多年来,人们建议使用从状态中提取的手工制作的特征的线性组合(例如,最近的鬼的距离、它们的方向等)来估计 Q 值,但在 2013 年,DeepMind表明使用深度神经网络可以工作得更好,特别是对于复杂问题,而且不需要任何特征工程。用于估计 Q 值的 DNN 称为深度 Q 网络(DQN),并且使用 DQN 进行近似 Q 学习称为深度 Q 学习

现在,我们如何训练一个 DQN 呢?考虑 DQN 计算给定状态-动作对(s, a)的近似 Q 值。由于贝尔曼,我们知道我们希望这个近似 Q 值尽可能接近我们在状态s中执行动作a后实际观察到的奖励r,加上从那时开始最优地玩的折现值。为了估计未来折现奖励的总和,我们只需在下一个状态s′上执行 DQN,对所有可能的动作a′。我们得到每个可能动作的近似未来 Q 值。然后我们选择最高的(因为我们假设我们将最优地玩),并对其进行折现,这给我们一个未来折现奖励总和的估计。通过将奖励r和未来折现值估计相加,我们得到状态-动作对(s, a)的目标 Q 值y(s, a),如方程 18-7 所示。

方程 18-7. 目标 Q 值

y(s,a)=r+γ·maxa‘Qθ(s’,a')

有了这个目标 Q 值,我们可以使用任何梯度下降算法运行一个训练步骤。具体来说,我们通常试图最小化估计的 Q 值Qθ和目标 Q 值y(s, a)之间的平方误差,或者使用 Huber 损失来减少算法对大误差的敏感性。这就是深度 Q 学习算法!让我们看看如何实现它来解决 CartPole 环境。

实施深度 Q 学习

我们需要的第一件事是一个深度 Q 网络。理论上,我们需要一个神经网络,将状态-动作对作为输入,并输出一个近似 Q 值。然而,在实践中,使用一个只接受状态作为输入,并为每个可能动作输出一个近似 Q 值的神经网络要高效得多。为了解决 CartPole 环境,我们不需要一个非常复杂的神经网络;几个隐藏层就足够了:

input_shape = [4]  # == env.observation_space.shape
n_outputs = 2  # == env.action_space.n

model = tf.keras.Sequential([
    tf.keras.layers.Dense(32, activation="elu", input_shape=input_shape),
    tf.keras.layers.Dense(32, activation="elu"),
    tf.keras.layers.Dense(n_outputs)
])

使用这个 DQN 选择动作时,我们选择预测 Q 值最大的动作。为了确保代理程序探索环境,我们将使用ε-贪婪策略(即,我们将以概率ε选择一个随机动作):

def epsilon_greedy_policy(state, epsilon=0):
    if np.random.rand() < epsilon:
        return np.random.randint(n_outputs)  # random action
    else:
        Q_values = model.predict(state[np.newaxis], verbose=0)[0]
        return Q_values.argmax()  # optimal action according to the DQN

我们将不再仅基于最新经验训练 DQN,而是将所有经验存储在一个重放缓冲区(或重放内存)中,并在每次训练迭代中从中随机抽取一个训练批次。这有助于减少训练批次中经验之间的相关性,从而极大地帮助训练。为此,我们将使用一个双端队列(deque):

from collections import deque

replay_buffer = deque(maxlen=2000)
提示

deque是一个队列,可以高效地在两端添加或删除元素。从队列的两端插入和删除项目非常快,但当队列变长时,随机访问可能会很慢。如果您需要一个非常大的重放缓冲区,您应该使用循环缓冲区(请参阅笔记本中的实现),或查看DeepMind 的 Reverb 库

每个体验将由六个元素组成:一个状态s,代理程序执行的动作a,产生的奖励r,它达到的下一个状态s′,一个指示该点是否结束的布尔值(done),最后一个指示该点是否截断的布尔值。我们将需要一个小函数从重放缓冲区中随机抽取一批体验。它将返回六个对应于六个体验元素的 NumPy 数组:

def sample_experiences(batch_size):
    indices = np.random.randint(len(replay_buffer), size=batch_size)
    batch = [replay_buffer[index] for index in indices]
    return [
        np.array([experience[field_index] for experience in batch])
        for field_index in range(6)
    ]  # [states, actions, rewards, next_states, dones, truncateds]

让我们还创建一个函数,该函数将使用ε-贪婪策略执行一个单步操作,然后将结果体验存储在重放缓冲区中:

def play_one_step(env, state, epsilon):
    action = epsilon_greedy_policy(state, epsilon)
    next_state, reward, done, truncated, info = env.step(action)
    replay_buffer.append((state, action, reward, next_state, done, truncated))
    return next_state, reward, done, truncated, info

最后,让我们创建一个最后一个函数,该函数将从重放缓冲区中抽取一批体验,并通过在该批次上执行单个梯度下降步骤来训练 DQN:

batch_size = 32
discount_factor = 0.95
optimizer = tf.keras.optimizers.Nadam(learning_rate=1e-2)
loss_fn = tf.keras.losses.mean_squared_error

def training_step(batch_size):
    experiences = sample_experiences(batch_size)
    states, actions, rewards, next_states, dones, truncateds = experiences
    next_Q_values = model.predict(next_states, verbose=0)
    max_next_Q_values = next_Q_values.max(axis=1)
    runs = 1.0 - (dones | truncateds)  # episode is not done or truncated
    target_Q_values = rewards + runs * discount_factor * max_next_Q_values
    target_Q_values = target_Q_values.reshape(-1, 1)
    mask = tf.one_hot(actions, n_outputs)
    with tf.GradientTape() as tape:
        all_Q_values = model(states)
        Q_values = tf.reduce_sum(all_Q_values * mask, axis=1, keepdims=True)
        loss = tf.reduce_mean(loss_fn(target_Q_values, Q_values))

    grads = tape.gradient(loss, model.trainable_variables)
    optimizer.apply_gradients(zip(grads, model.trainable_variables))

这段代码中发生了什么:

  • 首先我们定义一些超参数,然后创建优化器和损失函数。

  • 然后我们创建training_step()函数。它首先对经验进行批量采样,然后使用 DQN 来预测每个经验的下一个状态中每个可能动作的 Q 值。由于我们假设代理将会最优地进行游戏,我们只保留每个下一个状态的最大 Q 值。接下来,我们使用 Equation 18-7 来计算每个经验的状态-动作对的目标 Q 值。

  • 我们希望使用 DQN 来计算每个经验状态-动作对的 Q 值,但是 DQN 还会输出其他可能动作的 Q 值,而不仅仅是代理实际选择的动作。因此,我们需要屏蔽掉所有我们不需要的 Q 值。tf.one_hot()函数使得将动作索引数组转换为这样的屏蔽变得可能。例如,如果前三个经验包含动作 1、1、0,那么屏蔽将以[[0, 1], [0, 1], [1, 0], ...]开始。然后我们可以将 DQN 的输出与这个屏蔽相乘,这将将我们不想要的所有 Q 值置零。然后我们沿着轴 1 求和,去除所有零,只保留经验状态-动作对的 Q 值。这给我们了Q_values张量,包含批量中每个经验的一个预测 Q 值。

  • 接下来,我们计算损失:它是经验状态-动作对的目标和预测 Q 值之间的均方误差。

  • 最后,我们执行梯度下降步骤,以最小化损失与模型可训练变量的关系。

这是最困难的部分。现在训练模型就很简单了:

for episode in range(600):
    obs, info = env.reset()
    for step in range(200):
        epsilon = max(1 - episode / 500, 0.01)
        obs, reward, done, truncated, info = play_one_step(env, obs, epsilon)
        if done or truncated:
            break

    if episode > 50:
        training_step(batch_size)

我们运行 600 个 episode,每个最多 200 步。在每一步中,我们首先计算ε-贪婪策略的epsilon值:它将从 1 线性下降到 0.01,在不到 500 个 episode 内。然后我们调用play_one_step()函数,该函数将使用ε-贪婪策略选择一个动作,然后执行它并记录经验到重放缓冲区。如果 episode 结束或被截断,我们退出循环。最后,如果我们超过第 50 个 episode,我们调用training_step()函数从重放缓冲区中采样一个批次来训练模型。我们之所以在没有训练的情况下运行多个 episode,是为了给重放缓冲区一些时间来填充(如果我们不等待足够长的时间,那么重放缓冲区中将没有足够的多样性)。就是这样:我们刚刚实现了深度 Q 学习算法!

Figure 18-10 显示了代理在每个 episode 中获得的总奖励。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1810.png

图 18-10. 深度 Q 学习算法的学习曲线

正如你所看到的,该算法花了一段时间才开始学习任何东西,部分原因是ε在开始时非常高。然后它的进展是不稳定的:它首先在第 220 集左右达到了最大奖励,但立即下降,然后上下几次反弹,不久后看起来它终于稳定在最大奖励附近,大约在第 320 集左右,它的得分再次急剧下降。这被称为灾难性遗忘,这是几乎所有 RL 算法面临的一个大问题之一:当代理探索环境时,它更新其策略,但它在环境的一个部分学到的东西可能会破坏它在环境的其他部分早期学到的东西。经验是相当相关的,学习环境不断变化——这对于梯度下降来说并不理想!如果增加回放缓冲区的大小,算法将不太容易受到这个问题的影响。调整学习率也可能有所帮助。但事实是,强化学习很难:训练通常不稳定,您可能需要尝试许多超参数值和随机种子,才能找到一个表现良好的组合。例如,如果您尝试将激活函数从"elu"更改为"relu",性能将大大降低。

注意

强化学习因训练不稳定性和对超参数值和随机种子选择的极度敏感性而臭名昭著。正如研究人员 Andrej Karpathy 所说,“[监督学习]想要工作。[…]强化学习必须被迫工作”。您需要时间、耐心、毅力,也许还需要一点运气。这是 RL 不像常规深度学习(例如,卷积网络)那样被广泛采用的一个主要原因。但除了 AlphaGo 和 Atari 游戏之外,还有一些真实世界的应用:例如,谷歌使用 RL 来优化其数据中心成本,并且它被用于一些机器人应用、超参数调整和推荐系统中。

你可能会想为什么我们没有绘制损失。事实证明,损失是模型性能的一个很差的指标。损失可能会下降,但代理可能表现更差(例如,当代理陷入环境的一个小区域时,DQN 开始过度拟合这个区域时可能会发生这种情况)。相反,损失可能会上升,但代理可能表现更好(例如,如果 DQN 低估了 Q 值并开始正确增加其预测,代理可能表现更好,获得更多奖励,但损失可能会增加,因为 DQN 还设置了目标,这也会更大)。因此,最好绘制奖励。

到目前为止,我们一直在使用的基本深度 Q 学习算法对于学习玩 Atari 游戏来说太不稳定了。那么 DeepMind 是如何做到的呢?嗯,他们调整了算法!

深度 Q 学习变体

让我们看看一些可以稳定和加速训练的深度 Q 学习算法的变体。

固定 Q 值目标

在基本的深度 Q 学习算法中,模型既用于进行预测,也用于设置自己的目标。这可能导致类似于狗追逐自己尾巴的情况。这种反馈循环可能使网络不稳定:它可能发散、振荡、冻结等。为了解决这个问题,在他们 2013 年的论文中,DeepMind 的研究人员使用了两个 DQN 而不是一个:第一个是在线模型,它在每一步学习并用于移动代理,另一个是目标模型,仅用于定义目标。目标模型只是在线模型的一个克隆:

target = tf.keras.models.clone_model(model)  # clone the model's architecture
target.set_weights(model.get_weights())  # copy the weights

然后,在training_step()函数中,我们只需要更改一行,使用目标模型而不是在线模型来计算下一个状态的 Q 值:

next_Q_values = target.predict(next_states, verbose=0)

最后,在训练循环中,我们必须定期将在线模型的权重复制到目标模型中(例如,每 50 个 episode):

if episode % 50 == 0:
    target.set_weights(model.get_weights())

由于目标模型更新的频率远低于在线模型,Q 值目标更加稳定,我们之前讨论的反馈循环被减弱,其影响也变得不那么严重。这种方法是 DeepMind 研究人员在 2013 年的一篇论文中的主要贡献之一,使代理能够从原始像素学习玩 Atari 游戏。为了稳定训练,他们使用了非常小的学习率 0.00025,他们每 10000 步才更新一次目标模型(而不是 50 步),并且他们使用了一个非常大的重放缓冲区,包含 100 万个经验。他们非常缓慢地减小了epsilon,在 100 万步内从 1 减小到 0.1,并让算法运行了 5000 万步。此外,他们的 DQN 是一个深度卷积网络。

现在让我们来看看另一个 DQN 变体,它再次超越了现有技术水平。

双重 DQN

在一篇 2015 年的论文中,DeepMind 研究人员调整了他们的 DQN 算法,提高了性能并在一定程度上稳定了训练。他们将这个变体称为双重 DQN。更新基于这样一个观察:目标网络容易高估 Q 值。实际上,假设所有动作都是同样好的:目标模型估计的 Q 值应该是相同的,但由于它们是近似值,一些可能略大于其他值,纯粹是偶然的。目标模型将始终选择最大的 Q 值,这个值将略大于平均 Q 值,很可能高估真实的 Q 值(有点像在测量池的深度时计算最高随机波浪的高度)。为了解决这个问题,研究人员建议在选择下一个状态的最佳动作时使用在线模型而不是目标模型,并且仅使用目标模型来估计这些最佳动作的 Q 值。以下是更新后的training_step()函数:

def training_step(batch_size):
    experiences = sample_experiences(batch_size)
    states, actions, rewards, next_states, dones, truncateds = experiences
    next_Q_values = model.predict(next_states, verbose=0)  # ≠ target.predict()
    best_next_actions = next_Q_values.argmax(axis=1)
    next_mask = tf.one_hot(best_next_actions, n_outputs).numpy()
    max_next_Q_values = (target.predict(next_states, verbose=0) * next_mask
                        ).sum(axis=1)
    [...]  # the rest is the same as earlier

仅仅几个月后,DQN 算法的另一个改进被提出;我们接下来将看看这个改进。

优先经验回放

与从重放缓冲区中均匀采样经验不同,为什么不更频繁地采样重要经验呢?这个想法被称为重要性采样(IS)或优先经验回放(PER),并且是由 DeepMind 研究人员在 2015 年的一篇论文中介绍的(再次!)。

更具体地说,如果经验很可能导致快速学习进展,那么这些经验被认为是“重要的”。但是我们如何估计这一点呢?一个合理的方法是测量 TD 误差的大小δ = r + γ·V(s′) – V(s)。较大的 TD 误差表明一个转换(s, a, s′)非常令人惊讶,因此可能值得学习。当一个经验被记录在重放缓冲区中时,其优先级被设置为一个非常大的值,以确保至少被采样一次。然而,一旦被采样(并且每次被采样时),TD 误差δ被计算,并且这个经验的优先级被设置为p = |δ|(再加上一个小常数,以确保每个经验有非零的采样概率)。具有优先级p的经验被采样的概率Pp^(ζ)成正比,其中ζ是一个控制我们希望重要性采样有多贪婪的超参数:当ζ = 0 时,我们只得到均匀采样,当ζ = 1 时,我们得到完全的重要性采样。在论文中,作者使用了ζ = 0.6,但最佳值将取决于任务。

然而,有一个问题:由于样本将偏向于重要经验,我们必须在训练过程中通过根据其重要性降低经验的权重来补偿这种偏差,否则模型将只是过度拟合重要经验。明确地说,我们希望重要经验被更频繁地抽样,但这也意味着我们必须在训练过程中给它们更低的权重。为了做到这一点,我们将每个经验的训练权重定义为w = (n P)^(–β),其中n是回放缓冲区中的经验数量,β是一个超参数,控制我们想要补偿重要性抽样偏差的程度(0 表示根本不补偿,而 1 表示完全补偿)。在论文中,作者在训练开始时使用β = 0.4,并在训练结束时线性增加到β = 1。再次强调,最佳值将取决于任务,但如果你增加一个值,通常也会想要增加另一个值。

现在让我们看一下 DQN 算法的最后一个重要变体。

决斗 DQN

决斗 DQN算法(DDQN,不要与双重 DQN 混淆,尽管这两种技术可以很容易地结合在一起)是由 DeepMind 研究人员在另一篇2015 年的论文中介绍的。要理解它的工作原理,我们首先必须注意到一个状态-动作对(s, a)的 Q 值可以表示为Q(s, a) = V(s) + A(s, a),其中V(s)是状态s的值,A(s, a)是在状态s中采取动作a优势,与该状态下所有其他可能的动作相比。此外,一个状态的值等于该状态的最佳动作a^的 Q 值(因为我们假设最优策略将选择最佳动作),所以V*(s) = Q(s, a^),这意味着A*(s, a^*) = 0。在决斗 DQN 中,模型估计了状态的值和每个可能动作的优势。由于最佳动作应该具有优势为 0,模型从所有预测的优势中减去了最大预测的优势。这里是一个使用功能 API 实现的简单 DDQN 模型:

input_states = tf.keras.layers.Input(shape=[4])
hidden1 = tf.keras.layers.Dense(32, activation="elu")(input_states)
hidden2 = tf.keras.layers.Dense(32, activation="elu")(hidden1)
state_values = tf.keras.layers.Dense(1)(hidden2)
raw_advantages = tf.keras.layers.Dense(n_outputs)(hidden2)
advantages = raw_advantages - tf.reduce_max(raw_advantages, axis=1,
                                            keepdims=True)
Q_values = state_values + advantages
model = tf.keras.Model(inputs=[input_states], outputs=[Q_values])

算法的其余部分与之前完全相同。事实上,你可以构建一个双重决斗 DQN 并将其与优先经验重放结合起来!更一般地说,许多 RL 技术可以结合在一起,正如 DeepMind 在一篇2017 年的论文中展示的:论文的作者将六种不同的技术结合到一个名为Rainbow的代理中,这在很大程度上超越了现有技术水平。

正如你所看到的,深度强化学习是一个快速发展的领域,还有很多东西等待探索!

一些流行 RL 算法的概述

在我们结束本章之前,让我们简要看一下其他几种流行的算法:

AlphaGo

AlphaGo 使用基于深度神经网络的蒙特卡洛树搜索(MCTS)的变体,在围棋比赛中击败人类冠军。MCTS 是由 Nicholas Metropolis 和 Stanislaw Ulam 于 1949 年发明的。它在运行许多模拟之后选择最佳移动,重复地探索从当前位置开始的搜索树,并在最有希望的分支上花费更多时间。当它到达一个以前未访问过的节点时,它会随机播放直到游戏结束,并更新每个访问过的节点的估计值(排除随机移动),根据最终结果增加或减少每个估计值。AlphaGo 基于相同的原则,但它使用策略网络来选择移动,而不是随机播放。这个策略网络是使用策略梯度进行训练的。原始算法涉及另外三个神经网络,并且更加复杂,但在AlphaGo Zero 论文中被简化,使用单个神经网络来选择移动和评估游戏状态。AlphaZero 论文推广了这个算法,使其能够处理不仅是围棋,还有国际象棋和将棋(日本象棋)。最后,MuZero 论文继续改进这个算法,即使代理开始时甚至不知道游戏规则,也能胜过以前的迭代!

Actor-critic 算法

Actor-critics 是一类将策略梯度与深度 Q 网络结合的 RL 算法。一个 actor-critic 代理包含两个神经网络:一个策略网络和一个 DQN。DQN 通过从代理的经验中学习来进行正常训练。策略网络学习方式不同(并且比常规 PG 快得多):代理不是通过多个情节估计每个动作的价值,然后为每个动作总结未来折现奖励,最后对其进行归一化,而是依赖于 DQN 估计的动作值(评论家)。这有点像运动员(代理)在教练(DQN)的帮助下学习。

异步优势 actor-critic(A3C)⁠²³

这是 DeepMind 研究人员在 2016 年引入的一个重要的 actor-critic 变体,其中多个代理并行学习,探索环境的不同副本。定期但异步地(因此得名),每个代理将一些权重更新推送到主网络,然后从该网络中拉取最新的权重。因此,每个代理都有助于改进主网络,并从其他代理学到的知识中受益。此外,DQN 估计每个动作的优势,而不是估计 Q 值(因此名称中的第二个 A),这有助于稳定训练。

优势 actor-critic(A2C)

A2C 是 A3C 算法的一个变体,它去除了异步性。所有模型更新都是同步的,因此梯度更新是在更大的批次上执行的,这使模型能够更好地利用 GPU 的性能。

软 actor-critic(SAC)⁠²⁴

SAC 是由 Tuomas Haarnoja 和其他加州大学伯克利分校研究人员于 2018 年提出的 actor-critic 变体。它不仅学习奖励,还要最大化其动作的熵。换句话说,它试图尽可能不可预测,同时尽可能获得更多奖励。这鼓励代理探索环境,加快训练速度,并使其在 DQN 产生不完美估计时不太可能重复执行相同的动作。这个算法展示了惊人的样本效率(与所有以前的算法相反,学习速度非常慢)。

近端策略优化(PPO)

这个由 John Schulman 和其他 OpenAI 研究人员开发的算法基于 A2C,但它剪切损失函数以避免过大的权重更新(这经常导致训练不稳定)。PPO 是前一个信任区域策略优化(TRPO)算法的简化版本,也是由 OpenAI 开发的。OpenAI 在 2019 年 4 月的新闻中以其基于 PPO 算法的 AI OpenAI Five 而闻名,该 AI 在多人游戏Dota 2中击败了世界冠军。

基于好奇心的探索

在强化学习中经常出现的问题是奖励的稀疏性,这使得学习变得非常缓慢和低效。加州大学伯克利分校的 Deepak Pathak 和其他研究人员提出了一种激动人心的方法来解决这个问题:为什么不忽略奖励,只是让代理人对探索环境感到极大的好奇心呢?奖励因此变得内在于代理人,而不是来自环境。同样,激发孩子的好奇心更有可能取得好的结果,而不仅仅是因为孩子取得好成绩而奖励他。这是如何运作的呢?代理人不断尝试预测其行动的结果,并寻找结果与其预测不符的情况。换句话说,它希望受到惊喜。如果结果是可预测的(无聊),它会去其他地方。然而,如果结果是不可预测的,但代理人注意到自己无法控制它,那么它也会在一段时间后感到无聊。只有好奇心,作者们成功地训练了一个代理人玩了很多视频游戏:即使代理人输掉了也没有惩罚,游戏重新开始,这很无聊,所以它学会了避免这种情况。

开放式学习(OEL)

OEL 的目标是训练代理人能够不断学习新颖有趣的任务,通常是通过程序生成的。我们还没有达到这一目标,但在过去几年中取得了一些惊人的进展。例如,Uber AI 团队在 2019 年发表的一篇论文介绍了POET 算法,该算法生成多个带有凸起和洞的模拟 2D 环境,并为每个环境训练一个代理人:代理人的目标是尽可能快地行走,同时避开障碍物。该算法从简单的环境开始,但随着时间的推移逐渐变得更加困难:这被称为课程学习。此外,尽管每个代理人只在一个环境中接受训练,但它必须定期与其他代理人竞争,跨所有环境。在每个环境中,获胜者被复制并取代之前的代理人。通过这种方式,知识定期在环境之间传递,并选择最具适应性的代理人。最终,这些代理人比单一任务训练的代理人更擅长行走,并且能够应对更加困难的环境。当然,这个原则也可以应用于其他环境和任务。如果您对 OEL 感兴趣,请务必查看增强 POET 论文,以及 DeepMind 在这个主题上的2021 年论文

提示

如果您想了解更多关于强化学习的知识,请查看 Phil Winder(O’Reilly)的书籍强化学习

本章涵盖了许多主题:策略梯度、马尔可夫链、马尔可夫决策过程、Q 学习、近似 Q 学习、深度 Q 学习及其主要变体(固定 Q 值目标、双重 DQN、对决 DQN 和优先经验重放),最后我们简要介绍了一些其他流行算法。强化学习是一个庞大且令人兴奋的领域,每天都会涌现出新的想法和算法,因此希望本章引发了您的好奇心:有一个整个世界等待您去探索!

练习

  1. 您如何定义强化学习?它与常规监督学习或无监督学习有何不同?

  2. 你能想到本章未提及的三种强化学习的可能应用吗?对于每一种,环境是什么?代理是什么?可能的行动有哪些?奖励是什么?

  3. 什么是折扣因子?如果修改折扣因子,最优策略会改变吗?

  4. 如何衡量强化学习代理的表现?

  5. 什么是信用分配问题?它何时发生?如何缓解它?

  6. 使用重放缓冲区的目的是什么?

  7. 什么是离策略强化学习算法?

  8. 使用策略梯度来解决 OpenAI Gym 的 LunarLander-v2 环境。

  9. 使用双重对决 DQN 训练一个代理,使其在著名的 Atari Breakout 游戏("ALE/Breakout-v5")中达到超人水平。观察结果是图像。为了简化任务,您应该将它们转换为灰度图像(即在通道轴上取平均),然后裁剪和降采样,使它们足够大以进行游戏,但不要过大。单个图像无法告诉您球和挡板的移动方向,因此您应该合并两到三个连续图像以形成每个状态。最后,DQN 应该主要由卷积层组成。

  10. 如果您有大约 100 美元可以花费,您可以购买一个树莓派 3 加上一些廉价的机器人组件,在树莓派上安装 TensorFlow,然后尽情玩耍!例如,可以查看 Lukas Biewald 的这篇 有趣的帖子,或者看看 GoPiGo 或 BrickPi。从简单的目标开始,比如让机器人转身找到最亮的角度(如果有光传感器)或最近的物体(如果有声纳传感器),然后朝着那个方向移动。然后您可以开始使用深度学习:例如,如果机器人有摄像头,可以尝试实现一个目标检测算法,使其检测到人并朝他们移动。您还可以尝试使用强化学习,让代理学习如何独立使用电机来实现这个目标。玩得开心!

这些练习的解答可在本章笔记本的末尾找到,网址为 https://homl.info/colab3

(1)想了解更多细节,请务必查看 Richard Sutton 和 Andrew Barto 的关于强化学习的书籍 Reinforcement Learning: An Introduction(麻省理工学院出版社)。

(2)Volodymyr Mnih 等人,“使用深度强化学习玩 Atari 游戏”,arXiv 预印本 arXiv:1312.5602(2013)。

(3)Volodymyr Mnih 等人,“通过深度强化学习实现人类水平控制”,自然 518(2015):529–533。

(4)查看 DeepMind 系统学习 Space InvadersBreakout 和其他视频游戏的视频,网址为 https://homl.info/dqn3

(5)图像(a)、(d)和(e)属于公共领域。图像(b)是来自 Ms. Pac-Man 游戏的截图,由 Atari 版权所有(在本章中属于合理使用)。图像(c)是从维基百科复制的;由用户 Stevertigo 创建,并在 知识共享署名-相同方式共享 2.0 下发布。

(6)通常更好地给予表现不佳者一点生存的机会,以保留“基因池”中的一些多样性。

⁷ 如果只有一个父母,这被称为无性繁殖。有两个(或更多)父母时,这被称为有性繁殖。后代的基因组(在这种情况下是一组策略参数)是随机由其父母的基因组的部分组成的。

⁸ 用于强化学习的遗传算法的一个有趣例子是增强拓扑的神经进化(NEAT)算法。

⁹ 这被称为梯度上升。它就像梯度下降一样,但方向相反:最大化而不是最小化。

¹⁰ OpenAI 是一家人工智能研究公司,部分资金来自埃隆·马斯克。其宣称的目标是推广和发展有益于人类的友好人工智能(而不是消灭人类)。

¹¹ Ronald J. Williams,“用于连接主义强化学习的简单统计梯度跟随算法”,机器学习8(1992):229–256。

¹² Richard Bellman,“马尔可夫决策过程”,数学与力学杂志6,第 5 期(1957):679–684。

¹³ Alex Irpan 在 2018 年发表的一篇很棒的文章很好地阐述了强化学习的最大困难和局限性。

¹⁴ Hado van Hasselt 等人,“双 Q 学习的深度强化学习”,第 30 届 AAAI 人工智能大会论文集(2015):2094–2100。

¹⁵ Tom Schaul 等人,“优先经验重放”,arXiv 预印本 arXiv:1511.05952(2015)。

¹⁶ 也可能只是奖励有噪音,此时有更好的方法来估计经验的重要性(请参阅论文中的一些示例)。

¹⁷ Ziyu Wang 等人,“用于深度强化学习的对抗网络架构”,arXiv 预印本 arXiv:1511.06581(2015)。

¹⁸ Matteo Hessel 等人,“彩虹:深度强化学习改进的结合”,arXiv 预印本 arXiv:1710.02298(2017):3215–3222。

¹⁹ David Silver 等人,“用深度神经网络和树搜索掌握围棋”,自然529(2016):484–489。

²⁰ David Silver 等人,“在没有人类知识的情况下掌握围棋”,自然550(2017):354–359。

²¹ David Silver 等人,“通过自我对弈掌握国际象棋和将棋的一般强化学习算法”,arXiv 预印本 arXiv:1712.01815。

²² Julian Schrittwieser 等人,“通过学习模型计划掌握 Atari、围棋、国际象棋和将棋”,arXiv 预印本 arXiv:1911.08265(2019)。

²³ Volodymyr Mnih 等人,“深度强化学习的异步方法”,第 33 届国际机器学习会议论文集(2016):1928–1937。

²⁴ Tuomas Haarnoja 等人,“软演员-评论家:带有随机演员的离策略最大熵深度强化学习”,第 35 届国际机器学习会议论文集(2018):1856–1865。

²⁵ John Schulman 等人,“近端策略优化算法”,arXiv 预印本 arXiv:1707.06347(2017)。

²⁶ John Schulman 等人,“信任区域策略优化”,第 32 届国际机器学习会议论文集(2015):1889–1897。

²⁷ Deepak Pathak 等,“由自监督预测驱动的好奇心探索”,第 34 届国际机器学习会议论文集(2017):2778–2787。

²⁸ 王锐等,“配对开放式先驱者(POET):不断生成越来越复杂和多样化的学习环境及其解决方案”,arXiv 预印本 arXiv:1901.01753(2019)。

²⁹ 王锐等,“增强 POET:通过无限创造学习挑战及其解决方案的开放式强化学习”,arXiv 预印本 arXiv:2003.08536(2020)。

³⁰ Open-Ended Learning Team 等,“开放式学习导致普遍能力代理”,arXiv 预印本 arXiv:2107.12808(2021)。

第十九章:规模化训练和部署 TensorFlow 模型

一旦您拥有一个能够做出惊人预测的美丽模型,您会怎么处理呢?嗯,您需要将其投入生产!这可能只是在一批数据上运行模型,也许编写一个每晚运行该模型的脚本。然而,通常情况下会更加复杂。您的基础设施的各个部分可能需要在实时数据上使用该模型,这种情况下,您可能会希望将模型封装在一个 Web 服务中:这样,您的基础设施的任何部分都可以随时使用简单的 REST API(或其他协议)查询模型,正如我们在第二章中讨论的那样。但随着时间的推移,您需要定期使用新数据对模型进行重新训练,并将更新后的版本推送到生产环境。您必须处理模型版本控制,优雅地从一个模型过渡到另一个模型,可能在出现问题时回滚到上一个模型,并可能并行运行多个不同的模型来执行A/B 实验。如果您的产品变得成功,您的服务可能会开始每秒收到大量查询(QPS),并且必须扩展以支持负载。如您将在本章中看到的,一个很好的扩展服务的解决方案是使用 TF Serving,无论是在您自己的硬件基础设施上还是通过诸如 Google Vertex AI 之类的云服务。它将有效地为您提供模型服务,处理优雅的模型过渡等。如果您使用云平台,您还将获得许多额外功能,例如强大的监控工具。

此外,如果你有大量的训练数据和计算密集型模型,那么训练时间可能会变得过长。如果你的产品需要快速适应变化,那么长时间的训练可能会成为一个阻碍因素(例如,想象一下一个新闻推荐系统在推广上周的新闻)。更重要的是,长时间的训练会阻止你尝试新想法。在机器学习(以及许多其他领域),很难事先知道哪些想法会奏效,因此你应该尽可能快地尝试尽可能多的想法。加快训练的一种方法是使用硬件加速器,如 GPU 或 TPU。为了更快地训练,你可以在多台配备多个硬件加速器的机器上训练模型。TensorFlow 的简单而强大的分布策略 API 使这一切变得容易,你将会看到。

在这一章中,我们将学习如何部署模型,首先使用 TF Serving,然后使用 Vertex AI。我们还将简要介绍如何将模型部署到移动应用程序、嵌入式设备和 Web 应用程序。然后我们将讨论如何使用 GPU 加速计算,以及如何使用分布策略 API 在多个设备和服务器上训练模型。最后,我们将探讨如何使用 Vertex AI 规模化训练模型并微调其超参数。这是很多要讨论的话题,让我们开始吧!

为 TensorFlow 模型提供服务

一旦您训练了一个 TensorFlow 模型,您可以在任何 Python 代码中轻松地使用它:如果它是一个 Keras 模型,只需调用它的predict()方法!但随着基础设施的增长,会出现一个更好的选择,即将您的模型封装在一个小型服务中,其唯一作用是进行预测,并让基础设施的其余部分查询它(例如,通过 REST 或 gRPC API)。这样可以将您的模型与基础设施的其余部分解耦,从而可以轻松地切换模型版本或根据需要扩展服务(独立于您的基础设施的其余部分),执行 A/B 实验,并确保所有软件组件依赖于相同的模型版本。这也简化了测试和开发等工作。您可以使用任何您想要的技术(例如,使用 Flask 库)创建自己的微服务,但为什么要重新发明轮子,当您可以直接使用 TF Serving 呢?

使用 TensorFlow Serving

TF Serving 是一个非常高效、经过实战验证的模型服务器,用 C++编写。它可以承受高负载,为您的模型提供多个版本,并监视模型存储库以自动部署最新版本,等等(参见图 19-1)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1901.png

图 19-1。TF Serving 可以为多个模型提供服务,并自动部署每个模型的最新版本。

假设您已经使用 Keras 训练了一个 MNIST 模型,并且希望将其部署到 TF Serving。您需要做的第一件事是将此模型导出为 SavedModel 格式,该格式在第十章中介绍。

导出 SavedModels

您已经知道如何保存模型:只需调用model.save()。现在要对模型进行版本控制,您只需要为每个模型版本创建一个子目录。很简单!

from pathlib import Path
import tensorflow as tf

X_train, X_valid, X_test = [...]  # load and split the MNIST dataset
model = [...]  # build & train an MNIST model (also handles image preprocessing)

model_name = "my_mnist_model"
model_version = "0001"
model_path = Path(model_name) / model_version
model.save(model_path, save_format="tf")

通常最好将所有预处理层包含在最终导出的模型中,这样一旦部署到生产环境中,模型就可以以其自然形式摄取数据。这样可以避免在使用模型的应用程序中单独处理预处理工作。将预处理步骤捆绑在模型中也使得以后更新它们更加简单,并限制了模型与所需预处理步骤之间不匹配的风险。

警告

由于 SavedModel 保存了计算图,因此它只能用于基于纯粹的 TensorFlow 操作的模型,不包括tf.py_function()操作,该操作包装任意的 Python 代码。

TensorFlow 带有一个小的saved_model_cli命令行界面,用于检查 SavedModels。让我们使用它来检查我们导出的模型:

$ saved_model_cli show --dir my_mnist_model/0001
The given SavedModel contains the following tag-sets:
'serve'

这个输出是什么意思?嗯,一个 SavedModel 包含一个或多个metagraphs。一个 metagraph 是一个计算图加上一些函数签名定义,包括它们的输入和输出名称、类型和形状。每个 metagraph 都由一组标签标识。例如,您可能希望有一个包含完整计算图的 metagraph,包括训练操作:您通常会将这个标记为"train"。您可能有另一个包含经过修剪的计算图的 metagraph,只包含预测操作,包括一些特定于 GPU 的操作:这个可能被标记为"serve", "gpu"。您可能还想要其他 metagraphs。这可以使用 TensorFlow 的低级SavedModel API来完成。然而,当您使用 Keras 模型的save()方法保存模型时,它会保存一个标记为"serve"的单个 metagraph。让我们检查一下这个"serve"标签集:

$ saved_model_cli show --dir 0001/my_mnist_model --tag_set serve
The given SavedModel MetaGraphDef contains SignatureDefs with these keys:
SignatureDef key: "__saved_model_init_op"
SignatureDef key: "serving_default"

这个元图包含两个签名定义:一个名为"__saved_model_init_op"的初始化函数,您不需要担心,以及一个名为"serving_default"的默认服务函数。当保存一个 Keras 模型时,默认的服务函数是模型的call()方法,用于进行预测,这一点您已经知道了。让我们更详细地了解这个服务函数:

$ saved_model_cli show --dir 0001/my_mnist_model --tag_set serve \
                       --signature_def serving_default
The given SavedModel SignatureDef contains the following input(s):
 inputs['flatten_input'] tensor_info:
 dtype: DT_UINT8
 shape: (-1, 28, 28)
 name: serving_default_flatten_input:0
The given SavedModel SignatureDef contains the following output(s):
 outputs['dense_1'] tensor_info:
 dtype: DT_FLOAT
 shape: (-1, 10)
 name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

请注意,函数的输入被命名为"flatten_input",输出被命名为"dense_1"。这些对应于 Keras 模型的输入和输出层名称。您还可以看到输入和输出数据的类型和形状。看起来不错!

现在您已经有了一个 SavedModel,下一步是安装 TF Serving。

安装和启动 TensorFlow Serving

有许多安装 TF Serving 的方法:使用系统的软件包管理器,使用 Docker 镜像,从源代码安装等。由于 Colab 运行在 Ubuntu 上,我们可以像这样使用 Ubuntu 的apt软件包管理器:

url = "https://storage.googleapis.com/tensorflow-serving-apt"
src = "stable tensorflow-model-server tensorflow-model-server-universal"
!echo 'deb {url} {src}' > /etc/apt/sources.list.d/tensorflow-serving.list
!curl '{url}/tensorflow-serving.release.pub.gpg' | apt-key add -
!apt update -q && apt-get install -y tensorflow-model-server
%pip install -q -U tensorflow-serving-api

这段代码首先将 TensorFlow 的软件包存储库添加到 Ubuntu 的软件包源列表中。然后它下载 TensorFlow 的公共 GPG 密钥,并将其添加到软件包管理器的密钥列表中,以便验证 TensorFlow 的软件包签名。接下来,它使用apt来安装tensorflow-model-server软件包。最后,它安装tensorflow-serving-api库,这是我们与服务器通信所需的库。

现在我们想要启动服务器。该命令将需要基本模型目录的绝对路径(即my_mnist_model的路径,而不是0001),所以让我们将其保存到MODEL_DIR环境变量中:

import os

os.environ["MODEL_DIR"] = str(model_path.parent.absolute())

然后我们可以启动服务器:

%%bash --bg
tensorflow_model_server \
     --port=8500 \
     --rest_api_port=8501 \
     --model_name=my_mnist_model \
     --model_base_path="${MODEL_DIR}" >my_server.log 2>&1

在 Jupyter 或 Colab 中,%%bash --bg魔术命令将单元格作为 bash 脚本执行,在后台运行。>my_server.log 2>&1部分将标准输出和标准错误重定向到my_server.log文件。就是这样!TF Serving 现在在后台运行,其日志保存在my_server.log中。它加载了我们的 MNIST 模型(版本 1),现在正在分别等待 gRPC 和 REST 请求,端口分别为 8500 和 8501。

现在服务器已经启动运行,让我们首先使用 REST API,然后使用 gRPC API 进行查询。

通过 REST API 查询 TF Serving

让我们从创建查询开始。它必须包含您想要调用的函数签名的名称,当然还有输入数据。由于请求必须使用 JSON 格式,我们必须将输入图像从 NumPy 数组转换为 Python 列表:

import json

X_new = X_test[:3]  # pretend we have 3 new digit images to classify
request_json = json.dumps({
    "signature_name": "serving_default",
    "instances": X_new.tolist(),
})

请注意,JSON 格式是 100%基于文本的。请求字符串如下所示:

>>> request_json
'{"signature_name": "serving_default", "instances": [[[0, 0, 0, 0, ... ]]]}'

现在让我们通过 HTTP POST 请求将这个请求发送到 TF Serving。这可以使用requests库来完成(它不是 Python 标准库的一部分,但在 Colab 上是预安装的):

import requests

server_url = "http://localhost:8501/v1/models/my_mnist_model:predict"
response = requests.post(server_url, data=request_json)
response.raise_for_status()  # raise an exception in case of error
response = response.json()

如果一切顺利,响应应该是一个包含单个"predictions"键的字典。相应的值是预测列表。这个列表是一个 Python 列表,所以让我们将其转换为 NumPy 数组,并将其中包含的浮点数四舍五入到第二位小数:

>>> import numpy as np
>>> y_proba = np.array(response["predictions"])
>>> y_proba.round(2)
array([[0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 1\.  , 0\.  , 0\.  ],
 [0\.  , 0\.  , 0.99, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ],
 [0\.  , 0.97, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0.01, 0\.  , 0\.  ]])

万岁,我们有了预测!模型几乎 100%确信第一张图片是 7,99%确信第二张图片是 2,97%确信第三张图片是 1。这是正确的。

REST API 简单易用,当输入和输出数据不太大时效果很好。此外,几乎任何客户端应用程序都可以在没有额外依赖的情况下进行 REST 查询,而其他协议并不总是那么容易获得。然而,它基于 JSON,这是基于文本且相当冗长的。例如,我们不得不将 NumPy 数组转换为 Python 列表,每个浮点数最终都表示为一个字符串。这非常低效,无论是在序列化/反序列化时间方面——我们必须将所有浮点数转换为字符串然后再转回来——还是在有效载荷大小方面:许多浮点数最终使用超过 15 个字符来表示,这相当于 32 位浮点数超过 120 位!这将导致在传输大型 NumPy 数组时出现高延迟和带宽使用。因此,让我们看看如何改用 gRPC。

提示

在传输大量数据或延迟重要时,最好使用 gRPC API,如果客户端支持的话,因为它使用紧凑的二进制格式和基于 HTTP/2 framing 的高效通信协议。

通过 gRPC API 查询 TF Serving

gRPC API 期望一个序列化的PredictRequest协议缓冲区作为输入,并输出一个序列化的PredictResponse协议缓冲区。这些 protobufs 是tensorflow-serving-api库的一部分,我们之前安装过。首先,让我们创建请求:

from tensorflow_serving.apis.predict_pb2 import PredictRequest

request = PredictRequest()
request.model_spec.name = model_name
request.model_spec.signature_name = "serving_default"
input_name = model.input_names[0]  # == "flatten_input"
request.inputs[input_name].CopyFrom(tf.make_tensor_proto(X_new))

这段代码创建了一个PredictRequest协议缓冲区,并填充了必需的字段,包括模型名称(之前定义的),我们想要调用的函数的签名名称,最后是输入数据,以Tensor协议缓冲区的形式。tf.make_tensor_proto()函数根据给定的张量或 NumPy 数组创建一个Tensor协议缓冲区,这里是X_new

接下来,我们将向服务器发送请求并获取其响应。为此,我们将需要grpcio库,该库已预先安装在 Colab 中:

import grpc
from tensorflow_serving.apis import prediction_service_pb2_grpc

channel = grpc.insecure_channel('localhost:8500')
predict_service = prediction_service_pb2_grpc.PredictionServiceStub(channel)
response = predict_service.Predict(request, timeout=10.0)

代码非常简单:在导入之后,我们在 TCP 端口 8500 上创建一个到localhost的 gRPC 通信通道,然后我们在该通道上创建一个 gRPC 服务,并使用它发送一个带有 10 秒超时的请求。请注意,调用是同步的:它将阻塞,直到收到响应或超时期限到期。在此示例中,通道是不安全的(没有加密,没有身份验证),但 gRPC 和 TF Serving 也支持通过 SSL/TLS 的安全通道。

接下来,让我们将PredictResponse协议缓冲区转换为张量:

output_name = model.output_names[0]  # == "dense_1"
outputs_proto = response.outputs[output_name]
y_proba = tf.make_ndarray(outputs_proto)

如果您运行此代码并打印y_proba.round(2),您将获得与之前完全相同的估计类概率。这就是全部内容:只需几行代码,您现在就可以远程访问您的 TensorFlow 模型,使用 REST 或 gRPC。

部署新的模型版本

现在让我们创建一个新的模型版本并导出一个 SavedModel,这次导出到my_mnist_model/0002目录:

model = [...]  # build and train a new MNIST model version

model_version = "0002"
model_path = Path(model_name) / model_version
model.save(model_path, save_format="tf")

在固定的时间间隔(延迟可配置),TF Serving 会检查模型目录是否有新的模型版本。如果找到一个新版本,它会自动优雅地处理过渡:默认情况下,它会用前一个模型版本回答待处理的请求(如果有的话),同时用新版本处理新请求。一旦每个待处理的请求都得到回答,之前的模型版本就会被卸载。您可以在 TF Serving 日志(my_server.log)中看到这个过程:

[...]
Reading SavedModel from: /models/my_mnist_model/0002
Reading meta graph with tags { serve }
[...]
Successfully loaded servable version {name: my_mnist_model version: 2}
Quiescing servable version {name: my_mnist_model version: 1}
Done quiescing servable version {name: my_mnist_model version: 1}
Unloading servable version {name: my_mnist_model version: 1}

提示

如果 SavedModel 包含assets/extra目录中的一些示例实例,您可以配置 TF Serving 在开始使用它来处理请求之前在这些实例上运行新模型。这称为模型预热:它将确保一切都被正确加载,避免第一次请求的长响应时间。

这种方法提供了平稳的过渡,但可能会使用过多的 RAM,特别是 GPU RAM,通常是最有限的。在这种情况下,您可以配置 TF Serving,使其处理所有挂起的请求与先前的模型版本,并在加载和使用新的模型版本之前卸载它。这种配置将避免同时加载两个模型版本,但服务将在短时间内不可用。

正如您所看到的,TF Serving 使部署新模型变得简单。此外,如果您发现第二个版本的效果不如预期,那么回滚到第一个版本就像删除my_mnist_model/0002目录一样简单。

提示

TF Serving 的另一个重要特性是其自动批处理能力,您可以在启动时使用--enable_batching选项来激活它。当 TF Serving 在短时间内接收到多个请求时(延迟可配置),它会在使用模型之前自动将它们批处理在一起。通过利用 GPU 的性能,这将显著提高性能。一旦模型返回预测结果,TF Serving 会将每个预测结果分发给正确的客户端。通过增加批处理延迟(参见--batching_parameters_file选项),您可以在一定程度上牺牲一点延迟以获得更大的吞吐量。

如果您希望每秒获得许多查询,您将希望在多台服务器上部署 TF Serving 并负载平衡查询(请参见图 19-2)。这将需要在这些服务器上部署和管理许多 TF Serving 容器。处理这一问题的一种方法是使用诸如Kubernetes之类的工具,它是一个简化跨多台服务器容器编排的开源系统。如果您不想购买、维护和升级所有硬件基础设施,您将希望在云平台上使用虚拟机,如 Amazon AWS、Microsoft Azure、Google Cloud Platform、IBM Cloud、Alibaba Cloud、Oracle Cloud 或其他平台即服务(PaaS)提供商。管理所有虚拟机,处理容器编排(即使借助 Kubernetes 的帮助),照顾 TF Serving 配置、调整和监控——所有这些都可能成为一项全职工作。幸运的是,一些服务提供商可以为您处理所有这些事务。在本章中,我们将使用 Vertex AI:它是今天唯一支持 TPUs 的平台;它支持 TensorFlow 2、Scikit-Learn 和 XGBoost;并提供一套不错的人工智能服务。在这个领域还有其他几家提供商也能够提供 TensorFlow 模型的服务,比如 Amazon AWS SageMaker 和 Microsoft AI Platform,所以请确保也查看它们。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1902.png

图 19-2。使用负载平衡扩展 TF Serving

现在让我们看看如何在云上提供我们出色的 MNIST 模型!

在 Vertex AI 上创建一个预测服务

Vertex AI 是 Google Cloud Platform(GCP)内的一个平台,提供各种与人工智能相关的工具和服务。您可以上传数据集,让人类对其进行标记,将常用特征存储在特征存储中,并将其用于训练或生产中,使用多个 GPU 或 TPU 服务器进行模型训练,并具有自动超参数调整或模型架构搜索(AutoML)功能。您还可以管理已训练的模型,使用它们对大量数据进行批量预测,为数据工作流程安排多个作业,通过 REST 或 gRPC 以规模化方式提供模型服务,并在名为Workbench的托管 Jupyter 环境中对数据和模型进行实验。甚至还有一个Matching Engine服务,可以非常高效地比较向量(即,近似最近邻)。GCP 还包括其他 AI 服务,例如计算机视觉、翻译、语音转文本等 API。

在我们开始之前,有一些设置需要处理:

  1. 登录您的 Google 账户,然后转到Google Cloud Platform 控制台(参见图 19-3)。如果您没有 Google 账户,您将需要创建一个。

  2. 如果这是您第一次使用 GCP,您将需要阅读并接受条款和条件。新用户可以获得免费试用,包括价值 300 美元的 GCP 信用,您可以在 90 天内使用(截至 2022 年 5 月)。您只需要其中的一小部分来支付本章中将使用的服务。注册免费试用后,您仍然需要创建一个付款配置文件并输入您的信用卡号码:这是用于验证目的——可能是为了避免人们多次使用免费试用,但您不会被收取前 300 美元的费用,之后只有在您选择升级到付费账户时才会收费。

    https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1903.png

    图 19-3. Google Cloud Platform 控制台
  3. 如果您以前使用过 GCP 并且您的免费试用已经过期,那么您在本章中将使用的服务将会花费一些钱。这不应该太多,特别是如果您记得在不再需要这些服务时关闭它们。在运行任何服务之前,请确保您理解并同意定价条件。如果服务最终花费超出您的预期,我在此不承担任何责任!还请确保您的计费账户是活动的。要检查,请打开左上角的☰导航菜单,点击计费,然后确保您已设置付款方式并且计费账户是活动的。

  4. GCP 中的每个资源都属于一个 项目。这包括您可能使用的所有虚拟机、存储的文件和运行的训练作业。当您创建一个帐户时,GCP 会自动为您创建一个名为“我的第一个项目”的项目。如果您愿意,可以通过转到项目设置来更改其显示名称:在 ☰ 导航菜单中,选择“IAM 和管理员 → 设置”,更改项目的显示名称,然后单击“保存”。请注意,项目还有一个唯一的 ID 和编号。您可以在创建项目时选择项目 ID,但以后无法更改。项目编号是自动生成的,无法更更改。如果您想创建一个新项目,请单击页面顶部的项目名称,然后单击“新项目”并输入项目名称。您还可以单击“编辑”来设置项目 ID。确保此新项目的计费处于活动状态,以便可以对服务费用进行计费(如果有免费信用)。

    警告

    请始终设置提醒,以便在您知道只需要几个小时时关闭服务,否则您可能会让其运行数天或数月,从而产生潜在的显著成本。

  5. 现在您已经拥有 GCP 帐户和项目,并且计费已激活,您必须激活所需的 API。在☰导航菜单中,选择“API 和服务”,确保启用了 Cloud Storage API。如果需要,点击+启用 API 和服务,找到 Cloud Storage,并启用它。还要启用 Vertex AI API。

您可以继续通过 GCP 控制台完成所有操作,但我建议改用 Python:这样您可以编写脚本来自动化几乎任何您想要在 GCP 上完成的任务,而且通常比通过菜单和表单点击更方便,特别是对于常见任务。

在您使用任何 GCP 服务之前,您需要做的第一件事是进行身份验证。在使用 Colab 时最简单的解决方案是执行以下代码:

from google.colab import auth

auth.authenticate_user()

认证过程基于 OAuth 2.0:一个弹出窗口会要求您确认您希望 Colab 笔记本访问您的 Google 凭据。如果您接受,您必须选择与 GCP 相同的 Google 帐户。然后,您将被要求确认您同意授予 Colab 对 Google Drive 和 GCP 中所有数据的完全访问权限。如果您允许访问,只有当前笔记本将具有访问权限,并且仅在 Colab 运行时到期之前。显然,只有在您信任笔记本中的代码时才应接受此操作。

警告

如果您使用来自 https://github.com/ageron/handson-ml3 的官方笔记本,则应格外小心:如果笔记本的作者心怀不轨,他们可能包含代码来对您的数据进行任何操作。

现在让我们创建一个 Google Cloud Storage 存储桶来存储我们的 SavedModels(GCS 的存储桶是您数据的容器)。为此,我们将使用预先安装在 Colab 中的google-cloud-storage库。我们首先创建一个Client对象,它将作为与 GCS 的接口,然后我们使用它来创建存储桶:

from google.cloud import storage

project_id = "my_project"  # change this to your project ID
bucket_name = "my_bucket"  # change this to a unique bucket name
location = "us-central1"

storage_client = storage.Client(project=project_id)
bucket = storage_client.create_bucket(bucket_name, location=location)
提示

如果您想重用现有的存储桶,请将最后一行替换为bucket = storage_client.bucket(bucket_name)。确保location设置为存储桶的地区。

GCS 使用单个全球命名空间用于存储桶,因此像“machine-learning”这样的简单名称很可能不可用。确保存储桶名称符合 DNS 命名约定,因为它可能在 DNS 记录中使用。此外,存储桶名称是公开的,因此不要在名称中放入任何私人信息。通常使用您的域名、公司名称或项目 ID 作为前缀以确保唯一性,或者只需在名称中使用一个随机数字。

如果您想要,可以更改区域,但请确保选择支持 GPU 的区域。此外,您可能需要考虑到不同区域之间价格差异很大,一些区域产生的 CO₂比其他区域多得多,一些区域不支持所有服务,并且使用单一区域存储桶可以提高性能。有关更多详细信息,请参阅Google Cloud 的区域列表Vertex AI 的位置文档。如果您不确定,最好选择"us-central1"

接下来,让我们将my_mnist_model目录上传到新的存储桶。在 GCS 中,文件被称为blobs(或objects),在幕后它们都只是放在存储桶中,没有任何目录结构。Blob 名称可以是任意的 Unicode 字符串,甚至可以包含斜杠(/)。GCP 控制台和其他工具使用这些斜杠来产生目录的幻觉。因此,当我们上传my_mnist_model目录时,我们只关心文件,而不是目录。

def upload_directory(bucket, dirpath):
    dirpath = Path(dirpath)
    for filepath in dirpath.glob("**/*"):
        if filepath.is_file():
            blob = bucket.blob(filepath.relative_to(dirpath.parent).as_posix())
            blob.upload_from_filename(filepath)

upload_directory(bucket, "my_mnist_model")

这个函数现在运行良好,但如果有很多文件要上传,它会非常慢。通过多线程可以很容易地大大加快速度(请参阅笔记本中的实现)。或者,如果您有 Google Cloud CLI,则可以使用以下命令:

!gsutil -m cp -r my_mnist_model gs://{bucket_name}/

接下来,让我们告诉 Vertex AI 关于我们的 MNIST 模型。要与 Vertex AI 通信,我们可以使用google-cloud-aiplatform库(它仍然使用旧的 AI Platform 名称而不是 Vertex AI)。它在 Colab 中没有预安装,所以我们需要安装它。之后,我们可以导入该库并进行初始化——只需指定一些项目 ID 和位置的默认值——然后我们可以创建一个新的 Vertex AI 模型:我们指定一个显示名称,我们模型的 GCS 路径(在这种情况下是版本 0001),以及我们希望 Vertex AI 使用的 Docker 容器的 URL 来运行此模型。如果您访问该 URL 并向上导航一个级别,您将找到其他可以使用的容器。这个支持带有 GPU 的 TensorFlow 2.8:

from google.cloud import aiplatform

server_image = "gcr.io/cloud-aiplatform/prediction/tf2-gpu.2-8:latest"

aiplatform.init(project=project_id, location=location)
mnist_model = aiplatform.Model.upload(
    display_name="mnist",
    artifact_uri=f"gs://{bucket_name}/my_mnist_model/0001",
    serving_container_image_uri=server_image,
)

现在让我们部署这个模型,这样我们就可以通过 gRPC 或 REST API 查询它以进行预测。为此,我们首先需要创建一个端点。这是客户端应用程序在想要访问服务时连接的地方。然后我们需要将我们的模型部署到这个端点:

endpoint = aiplatform.Endpoint.create(display_name="mnist-endpoint")

endpoint.deploy(
    mnist_model,
    min_replica_count=1,
    max_replica_count=5,
    machine_type="n1-standard-4",
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=1
)

这段代码可能需要几分钟才能运行,因为 Vertex AI 需要设置一个虚拟机。在这个例子中,我们使用一个相当基本的 n1-standard-4 类型的机器(查看https://homl.info/machinetypes 获取其他类型)。我们还使用了一个基本的 NVIDIA_TESLA_K80 类型的 GPU(查看https://homl.info/accelerators 获取其他类型)。如果您选择的区域不是 "us-central1",那么您可能需要将机器类型或加速器类型更改为该区域支持的值(例如,并非所有区域都有 Nvidia Tesla K80 GPU)。

注意

Google Cloud Platform 实施各种 GPU 配额,包括全球范围和每个地区:您不能在未经 Google 授权的情况下创建成千上万个 GPU 节点。要检查您的配额,请在 GCP 控制台中打开“IAM 和管理员 → 配额”。如果某些配额太低(例如,如果您需要在特定地区更多的 GPU),您可以要求增加它们;通常需要大约 48 小时。

Vertex AI 将最初生成最少数量的计算节点(在这种情况下只有一个),每当每秒查询次数变得过高时,它将生成更多节点(最多为您定义的最大数量,这种情况下为五个),并在它们之间负载均衡查询。如果一段时间内 QPS 速率下降,Vertex AI 将自动停止额外的计算节点。因此,成本直接与负载、您选择的机器和加速器类型以及您在 GCS 上存储的数据量相关。这种定价模型非常适合偶尔使用者和有重要使用高峰的服务。对于初创公司来说也是理想的:价格保持低延迟到公司真正开始运营。

恭喜,您已经将第一个模型部署到云端!现在让我们查询这个预测服务:

response = endpoint.predict(instances=X_new.tolist())

我们首先需要将要分类的图像转换为 Python 列表,就像我们之前使用 REST API 向 TF Serving 发送请求时所做的那样。响应对象包含预测结果,表示为 Python 浮点数列表的列表。让我们将它们四舍五入到两位小数并将它们转换为 NumPy 数组:

>>> import numpy as np
>>> np.round(response.predictions, 2)
array([[0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 1\.  , 0\.  , 0\.  ],
 [0\.  , 0\.  , 0.99, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ],
 [0\.  , 0.97, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0.01, 0\.  , 0\.  ]])

是的!我们得到了与之前完全相同的预测结果。我们现在在云上有一个很好的预测服务,我们可以从任何地方安全地查询,并且可以根据 QPS 的数量自动扩展或缩小。当您使用完端点后,请不要忘记将其删除,以避免无谓地支付费用:

endpoint.undeploy_all()  # undeploy all models from the endpoint
endpoint.delete()

现在让我们看看如何在 Vertex AI 上运行作业,对可能非常大的数据批次进行预测。

在 Vertex AI 上运行批量预测作业

如果我们需要进行大量预测,那么我们可以请求 Vertex AI 为我们运行预测作业,而不是重复调用我们的预测服务。这不需要端点,只需要一个模型。例如,让我们在测试集的前 100 张图像上运行一个预测作业,使用我们的 MNIST 模型。为此,我们首先需要准备批处理并将其上传到 GCS。一种方法是创建一个文件,每行包含一个实例,每个实例都格式化为 JSON 值——这种格式称为 JSON Lines——然后将此文件传递给 Vertex AI。因此,让我们在一个新目录中创建一个 JSON Lines 文件,然后将此目录上传到 GCS:

batch_path = Path("my_mnist_batch")
batch_path.mkdir(exist_ok=True)
with open(batch_path / "my_mnist_batch.jsonl", "w") as jsonl_file:
    for image in X_test[:100].tolist():
        jsonl_file.write(json.dumps(image))
        jsonl_file.write("\n")

upload_directory(bucket, batch_path)

现在我们准备启动预测作业,指定作业的名称、要使用的机器和加速器的类型和数量,刚刚创建的 JSON Lines 文件的 GCS 路径,以及 Vertex AI 将保存模型预测的 GCS 目录的路径:

batch_prediction_job = mnist_model.batch_predict(
    job_display_name="my_batch_prediction_job",
    machine_type="n1-standard-4",
    starting_replica_count=1,
    max_replica_count=5,
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=1,
    gcs_source=[f"gs://{bucket_name}/{batch_path.name}/my_mnist_batch.jsonl"],
    gcs_destination_prefix=f"gs://{bucket_name}/my_mnist_predictions/",
    sync=True  # set to False if you don't want to wait for completion
)
提示

对于大批量数据,您可以将输入拆分为多个 JSON Lines 文件,并通过gcs_source参数列出它们。

这将需要几分钟的时间,主要是为了在 Vertex AI 上生成计算节点。一旦这个命令完成,预测将会以类似prediction.results-00001-of-00002的文件集合中可用。这些文件默认使用 JSON Lines 格式,每个值都是包含实例及其对应预测(即 10 个概率)的字典。实例按照输入的顺序列出。该作业还会输出prediction-errors文件,如果出现问题,这些文件对于调试可能会有用。我们可以使用batch_prediction_job.iter_outputs()迭代所有这些输出文件,所以让我们遍历所有的预测并将它们存储在y_probas数组中:

y_probas = []
for blob in batch_prediction_job.iter_outputs():
    if "prediction.results" in blob.name:
        for line in blob.download_as_text().splitlines():
            y_proba = json.loads(line)["prediction"]
            y_probas.append(y_proba)

现在让我们看看这些预测有多好:

>>> y_pred = np.argmax(y_probas, axis=1)
>>> accuracy = np.sum(y_pred == y_test[:100]) / 100
0.98

很好,98%的准确率!

JSON Lines 格式是默认格式,但是当处理大型实例(如图像)时,它太冗长了。幸运的是,batch_predict()方法接受一个instances_format参数,让您可以选择另一种格式。它默认为"jsonl",但您可以将其更改为"csv""tf-record""tf-record-gzip""bigquery""file-list"。如果将其设置为"file-list",那么gcs_source参数应指向一个文本文件,其中每行包含一个输入文件路径;例如,指向 PNG 图像文件。Vertex AI 将读取这些文件作为二进制文件,使用 Base64 对其进行编码,并将生成的字节字符串传递给模型。这意味着您必须在模型中添加一个预处理层来解析 Base64 字符串,使用tf.io.decode_base64()。如果文件是图像,则必须使用类似tf.io.decode_image()tf.io.decode_png()的函数来解析结果,如第十三章中所讨论的。

当您完成使用模型后,如果需要,可以通过运行mnist_model.delete()来删除它。您还可以删除在您的 GCS 存储桶中创建的目录,可选地删除存储桶本身(如果为空),以及批量预测作业。

for prefix in ["my_mnist_model/", "my_mnist_batch/", "my_mnist_predictions/"]:
    blobs = bucket.list_blobs(prefix=prefix)
    for blob in blobs:
        blob.delete()

bucket.delete()  # if the bucket is empty
batch_prediction_job.delete()

您现在知道如何将模型部署到 Vertex AI,创建预测服务,并运行批量预测作业。但是如果您想将模型部署到移动应用程序,或者嵌入式设备,比如加热控制系统、健身追踪器或自动驾驶汽车呢?

将模型部署到移动设备或嵌入式设备

机器学习模型不仅限于在拥有多个 GPU 的大型集中式服务器上运行:它们可以更接近数据源运行(这被称为边缘计算),例如在用户的移动设备或嵌入式设备中。去中心化计算并将其移向边缘有许多好处:它使设备即使未连接到互联网时也能智能化,通过不必将数据发送到远程服务器来减少延迟并减轻服务器负载,并且可能提高隐私性,因为用户的数据可以保留在设备上。

然而,将模型部署到边缘也有其缺点。与强大的多 GPU 服务器相比,设备的计算资源通常很少。一个大模型可能无法适应设备,可能使用过多的 RAM 和 CPU,并且可能下载时间过长。结果,应用可能变得无响应,设备可能会发热并迅速耗尽电池。为了避免这一切,您需要制作一个轻量级且高效的模型,而不会牺牲太多准确性。 TFLite库提供了几个工具,帮助您将模型部署到边缘,主要有三个目标:

  • 减小模型大小,缩短下载时间并减少 RAM 使用量。

  • 减少每次预测所需的计算量,以减少延迟、电池使用量和发热。

  • 使模型适应特定设备的限制。

为了减小模型大小,TFLite 的模型转换器可以接受 SavedModel 并将其压缩为基于 FlatBuffers 的更轻量级格式。这是一个高效的跨平台序列化库(有点像协议缓冲区),最初由谷歌为游戏创建。它设计成可以直接将 FlatBuffers 加载到 RAM 中,无需任何预处理:这样可以减少加载时间和内存占用。一旦模型加载到移动设备或嵌入式设备中,TFLite 解释器将执行它以进行预测。以下是如何将 SavedModel 转换为 FlatBuffer 并保存为 .tflite 文件的方法:

converter = tf.lite.TFLiteConverter.from_saved_model(str(model_path))
tflite_model = converter.convert()
with open("my_converted_savedmodel.tflite", "wb") as f:
    f.write(tflite_model)
提示

您还可以使用 tf.lite.TFLiteConverter.from_keras_model(model) 将 Keras 模型直接保存为 FlatBuffer 格式。

转换器还优化模型,既缩小模型大小,又减少延迟。它修剪所有不需要进行预测的操作(例如训练操作),并在可能的情况下优化计算;例如,3 × a + 4 ×_ a_ + 5 × a 将被转换为 12 × a。此外,它尝试在可能的情况下融合操作。例如,如果可能的话,批量归一化层最终会合并到前一层的加法和乘法操作中。要了解 TFLite 可以对模型进行多少优化,可以下载其中一个预训练的 TFLite 模型,例如Inception_V1_quant(点击tflite&pb),解压缩存档,然后打开优秀的Netron 图形可视化工具并上传*.pb文件以查看原始模型。这是一个庞大而复杂的图形,对吧?接下来,打开优化后的.tflite*模型,惊叹于其美丽!

除了简单地使用较小的神经网络架构之外,您可以减小模型大小的另一种方法是使用较小的位宽:例如,如果您使用半精度浮点数(16 位)而不是常规浮点数(32 位),模型大小将缩小 2 倍,代价是(通常很小的)准确度下降。此外,训练速度将更快,您将使用大约一半的 GPU 内存。

TFLite 的转换器可以进一步将模型权重量化为固定点、8 位整数!与使用 32 位浮点数相比,这导致了四倍的大小减小。最简单的方法称为后训练量化:它只是在训练后量化权重,使用一种相当基本但高效的对称量化技术。它找到最大绝对权重值m,然后将浮点范围–m到+m映射到固定点(整数)范围–127 到+127。例如,如果权重范围从–1.5 到+0.8,则字节–127、0 和+127 将分别对应于浮点–1.5、0.0 和+1.5(参见图 19-5)。请注意,当使用对称量化时,0.0 始终映射为 0。还请注意,在此示例中不会使用字节值+68 到+127,因为它们映射到大于+0.8 的浮点数。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1905.png

图 19-5。从 32 位浮点数到 8 位整数,使用对称量化

要执行这种训练后的量化,只需在调用convert()方法之前将DEFAULT添加到转换器优化列表中:

converter.optimizations = [tf.lite.Optimize.DEFAULT]

这种技术显著减小了模型的大小,使得下载速度更快,占用的存储空间更少。在运行时,量化的权重在使用之前会被转换回浮点数。这些恢复的浮点数与原始浮点数并不完全相同,但也不会相差太远,因此精度损失通常是可以接受的。为了避免一直重新计算浮点值,这样会严重减慢模型的速度,TFLite 会对其进行缓存:不幸的是,这意味着这种技术并不会减少 RAM 的使用量,也不会加快模型的速度。它主要用于减小应用程序的大小。

减少延迟和功耗的最有效方法是对激活进行量化,使得计算可以完全使用整数,而无需任何浮点运算。即使使用相同的位宽(例如,32 位整数而不是 32 位浮点数),整数计算使用的 CPU 周期更少,消耗的能量更少,产生的热量也更少。如果还减少位宽(例如,降至 8 位整数),可以获得巨大的加速。此外,一些神经网络加速器设备(如 Google 的 Edge TPU)只能处理整数,因此权重和激活的完全量化是强制性的。这可以在训练后完成;它需要一个校准步骤来找到激活的最大绝对值,因此您需要向 TFLite 提供代表性的训练数据样本(不需要很大),它将通过模型处理数据并测量量化所需的激活统计信息。这一步通常很快。

量化的主要问题是它会失去一点准确性:这类似于在权重和激活中添加噪声。如果准确性下降太严重,那么您可能需要使用量化感知训练。这意味着向模型添加虚假量化操作,以便它在训练过程中学会忽略量化噪声;最终的权重将更加稳健地适应量化。此外,校准步骤可以在训练过程中自动处理,这简化了整个过程。

我已经解释了 TFLite 的核心概念,但要完全编写移动或嵌入式应用程序需要一本专门的书。幸运的是,一些书籍存在:如果您想了解有关为移动和嵌入式设备构建 TensorFlow 应用程序的更多信息,请查看 O’Reilly 的书籍TinyML: Machine Learning with TensorFlow on Arduino and Ultra-Low Power Micro-Controllers,作者是 Pete Warden(TFLite 团队的前负责人)和 Daniel Situnayake,以及AI and Machine Learning for On-Device Development,作者是 Laurence Moroney。

那么,如果您想在网站中使用您的模型,在用户的浏览器中直接运行呢?

在网页中运行模型

在客户端,即用户的浏览器中运行您的机器学习模型,而不是在服务器端运行,可以在许多场景下非常有用,例如:

  • 当您的网络应用经常在用户的连接不稳定或缓慢的情况下使用(例如,徒步者的网站),因此在客户端直接运行模型是使您的网站可靠的唯一方法。

  • 当您需要模型的响应尽可能快时(例如,用于在线游戏)。消除查询服务器进行预测的需要肯定会减少延迟,并使网站更加响应。

  • 当您的网络服务基于一些私人用户数据进行预测,并且您希望通过在客户端进行预测来保护用户的隐私,以便私人数据永远不必离开用户的设备。

对于所有这些场景,您可以使用TensorFlow.js(TFJS)JavaScript 库。该库可以在用户的浏览器中加载 TFLite 模型并直接进行预测。例如,以下 JavaScript 模块导入了 TFJS 库,下载了一个预训练的 MobileNet 模型,并使用该模型对图像进行分类并记录预测结果。您可以在https://homl.info/tfjscode上尝试这段代码,使用 Glitch.com,这是一个允许您免费在浏览器中构建 Web 应用程序的网站;点击页面右下角的预览按钮查看代码的运行情况:

import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest";
import "https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0";

const image = document.getElementById("image");

mobilenet.load().then(model => {
    model.classify(image).then(predictions => {
        for (var i = 0; i < predictions.length; i++) {
            let className = predictions[i].className
            let proba = (predictions[i].probability * 100).toFixed(1)
            console.log(className + " : " + proba + "%");
        }
    });
});

甚至可以将这个网站转变成一个渐进式 Web 应用程序(PWA):这是一个遵守一系列标准的网站,使其可以在任何浏览器中查看,甚至可以在移动设备上作为独立应用程序安装。例如,在移动设备上尝试访问https://homl.info/tfjswpa:大多数现代浏览器会询问您是否想要将 TFJS 演示添加到主屏幕。如果您接受,您将在应用程序列表中看到一个新图标。点击此图标将在其自己的窗口中加载 TFJS 演示网站,就像常规移动应用程序一样。PWA 甚至可以配置为离线工作,通过使用服务工作者:这是一个在浏览器中以自己独立线程运行的 JavaScript 模块,拦截网络请求,使其可以缓存资源,从而使 PWA 可以更快地运行,甚至完全离线运行。它还可以传递推送消息,在后台运行任务等。PWA 允许您管理 Web 和移动设备的单个代码库。它们还使得更容易确保所有用户运行您应用程序的相同版本。您可以在 Glitch.com 上玩这个 TFJS 演示的 PWA 代码,网址是https://homl.info/wpacode

提示

https://tensorflow.org/js/demos上查看更多在您的浏览器中运行的机器学习模型的演示。

TFJS 还支持在您的网络浏览器中直接训练模型!而且速度相当快。如果您的计算机有 GPU 卡,那么 TFJS 通常可以使用它,即使它不是 Nvidia 卡。实际上,TFJS 将在可用时使用 WebGL,由于现代网络浏览器通常支持各种 GPU 卡,TFJS 实际上支持的 GPU 卡比常规的 TensorFlow 更多(后者仅支持 Nvidia 卡)。

在用户的网络浏览器中训练模型可以特别有用,可以确保用户的数据保持私密。模型可以在中央进行训练,然后在浏览器中根据用户的数据进行本地微调。如果您对这个话题感兴趣,请查看联邦学习

再次强调,要全面涵盖这个主题需要一本完整的书。如果您想了解更多关于 TensorFlow.js 的内容,请查看 O’reilly 图书《云端、移动和边缘的实用深度学习》(Anirudh Koul 等著)或《学习 TensorFlow.js》(Gant Laborde 著)。

现在您已经看到如何将 TensorFlow 模型部署到 TF Serving,或者通过 Vertex AI 部署到云端,或者使用 TFLite 部署到移动和嵌入式设备,或者使用 TFJS 部署到 Web 浏览器,让我们讨论如何使用 GPU 加速计算。

使用 GPU 加速计算

在第十一章中,我们看了几种可以显著加快训练速度的技术:更好的权重初始化、复杂的优化器等等。但即使使用了所有这些技术,使用单个 CPU 的单台机器训练大型神经网络可能需要几个小时、几天,甚至几周,具体取决于任务。由于 GPU 的出现,这种训练时间可以缩短到几分钟或几小时。这不仅节省了大量时间,还意味着您可以更轻松地尝试各种模型,并经常使用新数据重新训练您的模型。

在之前的章节中,我们在 Google Colab 上使用了启用 GPU 的运行时。您只需从运行时菜单中选择“更改运行时类型”,然后选择 GPU 加速器类型;TensorFlow 会自动检测 GPU 并使用它加速计算,代码与没有 GPU 时完全相同。然后,在本章中,您看到了如何将模型部署到 Vertex AI 上的多个启用 GPU 的计算节点:只需在创建 Vertex AI 模型时选择正确的启用 GPU 的 Docker 镜像,并在调用endpoint.deploy()时选择所需的 GPU 类型。但是,如果您想购买自己的 GPU 怎么办?如果您想在单台机器上的 CPU 和多个 GPU 设备之间分发计算(参见图 19-6)?这是我们现在将讨论的内容,然后在本章的后面部分我们将讨论如何在多个服务器上分发计算。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1906.png

图 19-6。在多个设备上并行执行 TensorFlow 图

获取自己的 GPU

如果你知道你将会长时间大量使用 GPU,那么购买自己的 GPU 可能是经济上合理的。你可能也想在本地训练模型,因为你不想将数据上传到云端。或者你只是想购买一张用于游戏的 GPU 卡,并且想将其用于深度学习。

如果您决定购买 GPU 卡,那么请花些时间做出正确的选择。您需要考虑您的任务所需的 RAM 数量(例如,图像处理或 NLP 通常至少需要 10GB),带宽(即您可以将数据发送到 GPU 和从 GPU 中发送数据的速度),核心数量,冷却系统等。Tim Dettmers 撰写了一篇优秀的博客文章来帮助您选择:我鼓励您仔细阅读。在撰写本文时,TensorFlow 仅支持具有 CUDA Compute Capability 3.5+的 Nvidia 卡(当然还有 Google 的 TPU),但它可能会将其支持扩展到其他制造商,因此请务必查看TensorFlow 的文档以了解今天支持哪些设备。

如果您选择 Nvidia GPU 卡,您将需要安装适当的 Nvidia 驱动程序和几个 Nvidia 库。这些包括计算统一设备架构库(CUDA)工具包,它允许开发人员使用支持 CUDA 的 GPU 进行各种计算(不仅仅是图形加速),以及CUDA 深度神经网络库(cuDNN),一个 GPU 加速的常见 DNN 计算库,例如激活层、归一化、前向和反向卷积以及池化(参见第十四章)。cuDNN 是 Nvidia 的深度学习 SDK 的一部分。请注意,您需要创建一个 Nvidia 开发者帐户才能下载它。TensorFlow 使用 CUDA 和 cuDNN 来控制 GPU 卡并加速计算(参见图 19-7)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1907.png

图 19-7. TensorFlow 使用 CUDA 和 cuDNN 来控制 GPU 并加速 DNNs

安装了 GPU 卡和所有必需的驱动程序和库之后,您可以使用nvidia-smi命令来检查一切是否正确安装。该命令列出了可用的 GPU 卡,以及每张卡上运行的所有进程。在这个例子中,这是一张 Nvidia Tesla T4 GPU 卡,大约有 15GB 的可用内存,并且当前没有任何进程在运行:

$ nvidia-smi
Sun Apr 10 04:52:10 2022
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 460.32.03    Driver Version: 460.32.03    CUDA Version: 11.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   34C    P8     9W /  70W |      3MiB / 15109MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

要检查 TensorFlow 是否真正看到您的 GPU,请运行以下命令并确保结果不为空:

>>> physical_gpus = tf.config.list_physical_devices("GPU")
>>> physical_gpus
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]

管理 GPU 内存

默认情况下,TensorFlow 在第一次运行计算时会自动占用几乎所有可用 GPU 的 RAM,以限制 GPU RAM 的碎片化。这意味着如果您尝试启动第二个 TensorFlow 程序(或任何需要 GPU 的程序),它将很快耗尽 RAM。这种情况并不像您可能认为的那样经常发生,因为通常您会在一台机器上运行一个单独的 TensorFlow 程序:通常是一个训练脚本、一个 TF Serving 节点或一个 Jupyter 笔记本。如果出于某种原因需要运行多个程序(例如,在同一台机器上并行训练两个不同的模型),那么您需要更均匀地在这些进程之间分配 GPU RAM。

如果您的机器上有多个 GPU 卡,一个简单的解决方案是将每个 GPU 卡分配给单个进程。为此,您可以设置CUDA_VISIBLE_DEVICES环境变量,以便每个进程只能看到适当的 GPU 卡。还要设置CUDA_DEVICE_ORDER环境变量为PCI_BUS_ID,以确保每个 ID 始终指向相同的 GPU 卡。例如,如果您有四个 GPU 卡,您可以启动两个程序,将两个 GPU 分配给每个程序,通过在两个单独的终端窗口中执行以下命令来实现:

$ CUDA_DEVICE_ORDER=PCI_BUS_IDCUDA_VISIBLE_DEVICES=0,1python3program_1.py*`#` `and``in``another``terminal:`*$ CUDA_DEVICE_ORDER=PCI_BUS_IDCUDA_VISIBLE_DEVICES=3,2python3program_2.py

程序 1 将只看到 GPU 卡 0 和 1,分别命名为"/gpu:0""/gpu:1",在 TensorFlow 中,程序 2 将只看到 GPU 卡 2 和 3,分别命名为"/gpu:1""/gpu:0"(注意顺序)。一切都将正常工作(参见图 19-8)。当然,您也可以在 Python 中通过设置os.environ["CUDA_DEVICE_ORDER"]os.environ["CUDA_VISIBLE_DEVICES"]来定义这些环境变量,只要在使用 TensorFlow 之前这样做。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1908.png

图 19-8。每个程序获得两个 GPU

另一个选项是告诉 TensorFlow 只获取特定数量的 GPU RAM。这必须在导入 TensorFlow 后立即完成。例如,要使 TensorFlow 只在每个 GPU 上获取 2 GiB 的 RAM,您必须为每个物理 GPU 设备创建一个逻辑 GPU 设备(有时称为虚拟 GPU 设备),并将其内存限制设置为 2 GiB(即 2,048 MiB):

for gpu in physical_gpus:
    tf.config.set_logical_device_configuration(
        gpu,
        [tf.config.LogicalDeviceConfiguration(memory_limit=2048)]
    )

假设您有四个 GPU,每个 GPU 至少有 4 GiB 的 RAM:在这种情况下,可以并行运行两个像这样的程序,每个程序使用所有四个 GPU 卡(请参见图 19-9)。如果在两个程序同时运行时运行nvidia-smi命令,则应该看到每个进程在每张卡上占用 2 GiB 的 RAM。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1909.png

图 19-9。每个程序都可以获得四个 GPU,但每个 GPU 只有 2 GiB 的 RAM

另一个选项是告诉 TensorFlow 只在需要时获取内存。同样,在导入 TensorFlow 后必须立即执行此操作:

for gpu in physical_gpus:
    tf.config.experimental.set_memory_growth(gpu, True)

另一种方法是将TF_FORCE_GPU_ALLOW_GROWTH环境变量设置为true。使用这个选项,TensorFlow 一旦分配了内存就不会释放它(再次,为了避免内存碎片化),除非程序结束。使用这个选项很难保证确定性行为(例如,一个程序可能会崩溃,因为另一个程序的内存使用量激增),因此在生产环境中,您可能会选择之前的选项之一。然而,有一些情况下它非常有用:例如,当您使用一台机器运行多个 Jupyter 笔记本时,其中几个使用了 TensorFlow。在 Colab 运行时,TF_FORCE_GPU_ALLOW_GROWTH环境变量被设置为true

最后,在某些情况下,您可能希望将一个 GPU 分成两个或更多逻辑设备。例如,如果您只有一个物理 GPU,比如在 Colab 运行时,但您想要测试一个多 GPU 算法,这将非常有用。以下代码将 GPU#0 分成两个逻辑设备,每个设备有 2 GiB 的 RAM(同样,在导入 TensorFlow 后立即执行):

tf.config.set_logical_device_configuration(
    physical_gpus[0],
    [tf.config.LogicalDeviceConfiguration(memory_limit=2048),
     tf.config.LogicalDeviceConfiguration(memory_limit=2048)]
)

这两个逻辑设备被称为"/gpu:0""/gpu:1", 你可以像使用两个普通 GPU 一样使用它们。你可以像这样列出所有逻辑设备:

>>> logical_gpus = tf.config.list_logical_devices("GPU")
>>> logical_gpus
[LogicalDevice(name='/device:GPU:0', device_type='GPU'),
 LogicalDevice(name='/device:GPU:1', device_type='GPU')]

现在让我们看看 TensorFlow 如何决定应该使用哪些设备来放置变量和执行操作。

将操作和变量放在设备上

Keras 和 tf.data 通常会很好地将操作和变量放在它们应该在的位置,但如果您想要更多控制,您也可以手动将操作和变量放在每个设备上:

  • 通常,您希望将数据预处理操作放在 CPU 上,并将神经网络操作放在 GPU 上。

  • GPU 通常具有相对有限的通信带宽,因此重要的是要避免不必要的数据传输进出 GPU。

  • 向机器添加更多的 CPU RAM 是简单且相对便宜的,因此通常有很多,而 GPU RAM 是内置在 GPU 中的:它是一种昂贵且有限的资源,因此如果一个变量在接下来的几个训练步骤中不需要,它可能应该放在 CPU 上(例如,数据集通常应该放在 CPU 上)。

默认情况下,所有变量和操作都将放置在第一个 GPU 上(命名为"/gpu:0"),除非变量和操作没有 GPU 内核:这些将放置在 CPU 上(始终命名为"/cpu:0")。张量或变量的device属性告诉您它被放置在哪个设备上。

>>> a = tf.Variable([1., 2., 3.])  # float32 variable goes to the GPU
>>> a.device
'/job:localhost/replica:0/task:0/device:GPU:0'
>>> b = tf.Variable([1, 2, 3])  # int32 variable goes to the CPU
>>> b.device
'/job:localhost/replica:0/task:0/device:CPU:0'

您现在可以安全地忽略前缀/job:localhost/replica:0/task:0;我们将在本章后面讨论作业、副本和任务。正如您所看到的,第一个变量被放置在 GPU#0 上,这是默认设备。但是,第二个变量被放置在 CPU 上:这是因为整数变量没有 GPU 内核,或者涉及整数张量的操作没有 GPU 内核,因此 TensorFlow 回退到 CPU。

如果您想在与默认设备不同的设备上执行操作,请使用tf.device()上下文:

>>> with tf.device("/cpu:0"):
...     c = tf.Variable([1., 2., 3.])
...
>>> c.device
'/job:localhost/replica:0/task:0/device:CPU:0'
注意

CPU 始终被视为单个设备("/cpu:0"),即使您的计算机有多个 CPU 核心。放置在 CPU 上的任何操作,如果具有多线程内核,则可能在多个核心上并行运行。

如果您明确尝试将操作或变量放置在不存在或没有内核的设备上,那么 TensorFlow 将悄悄地回退到默认选择的设备。当您希望能够在不具有相同数量的 GPU 的不同机器上运行相同的代码时,这是很有用的。但是,如果您希望获得异常,可以运行tf.config.set_soft_device_placement(False)

现在,TensorFlow 如何在多个设备上执行操作呢?

跨多个设备并行执行

正如我们在第十二章中看到的,使用 TF 函数的一个好处是并行性。让我们更仔细地看一下这一点。当 TensorFlow 运行一个 TF 函数时,它首先分析其图形,找到需要评估的操作列表,并计算每个操作的依赖关系数量。然后 TensorFlow 将每个具有零依赖关系的操作(即每个源操作)添加到该操作设备的评估队列中(参见图 19-10)。一旦一个操作被评估,依赖于它的每个操作的依赖计数器都会减少。一旦一个操作的依赖计数器达到零,它就会被推送到其设备的评估队列中。一旦所有输出都被计算出来,它们就会被返回。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1910.png

图 19-10. TensorFlow 图的并行执行

CPU 的评估队列中的操作被分派到一个称为inter-op 线程池的线程池中。如果 CPU 有多个核心,那么这些操作将有效地并行评估。一些操作具有多线程 CPU 内核:这些内核将其任务分割为多个子操作,这些子操作被放置在另一个评估队列中,并分派到一个称为intra-op 线程池的第二线程池中(由所有多线程 CPU 内核共享)。简而言之,多个操作和子操作可能在不同的 CPU 核心上并行评估。

对于 GPU 来说,情况要简单一些。GPU 的评估队列中的操作是按顺序评估的。然而,大多数操作都有多线程 GPU 内核,通常由 TensorFlow 依赖的库实现,比如 CUDA 和 cuDNN。这些实现有自己的线程池,它们通常会利用尽可能多的 GPU 线程(这就是为什么 GPU 不需要一个跨操作线程池的原因:每个操作已经占用了大部分 GPU 线程)。

例如,在图 19-10 中,操作 A、B 和 C 是源操作,因此它们可以立即被评估。操作 A 和 B 被放置在 CPU 上,因此它们被发送到 CPU 的评估队列,然后被分派到跨操作线程池并立即并行评估。操作 A 恰好有一个多线程内核;它的计算被分成三部分,在操作线程池中并行执行。操作 C 进入 GPU #0 的评估队列,在这个例子中,它的 GPU 内核恰好使用 cuDNN,它管理自己的内部操作线程池,并在许多 GPU 线程之间并行运行操作。假设 C 先完成。D 和 E 的依赖计数器被减少到 0,因此两个操作都被推送到 GPU #0 的评估队列,并按顺序执行。请注意,即使 D 和 E 都依赖于 C,C 也只被评估一次。假设 B 接下来完成。然后 F 的依赖计数器从 4 减少到 3,由于不为 0,它暂时不运行。一旦 A、D 和 E 完成,那么 F 的依赖计数器达到 0,它被推送到 CPU 的评估队列并被评估。最后,TensorFlow 返回请求的输出。

TensorFlow 执行的另一个神奇之处是当 TF 函数修改状态资源(例如变量)时:它确保执行顺序与代码中的顺序匹配,即使语句之间没有显式依赖关系。例如,如果您的 TF 函数包含v.assign_add(1),然后是v.assign(v * 2),TensorFlow 将确保这些操作按照这个顺序执行。

提示

您可以通过调用tf.config.threading.set_inter_op_parallelism_threads()来控制 inter-op 线程池中的线程数。要设置 intra-op 线程数,请使用tf.config.threading.set_intra_op_parallelism_threads()。如果您不希望 TensorFlow 使用所有 CPU 核心,或者希望它是单线程的,这将非常有用。⁠¹²

有了这些,您就拥有了在任何设备上运行任何操作并利用 GPU 的能力所需的一切!以下是您可以做的一些事情:

  • 您可以并行训练多个模型,每个模型都在自己的 GPU 上:只需为每个模型编写一个训练脚本,并在并行运行时设置CUDA_DEVICE_ORDERCUDA_VISIBLE_DEVICES,以便每个脚本只能看到一个 GPU 设备。这对于超参数调整非常有用,因为您可以并行训练具有不同超参数的多个模型。如果您有一台具有两个 GPU 的单台机器,并且在一个 GPU 上训练一个模型需要一个小时,那么并行训练两个模型,每个模型都在自己专用的 GPU 上,只需要一个小时。简单!

  • 您可以在单个 GPU 上训练一个模型,并在 CPU 上并行执行所有预处理操作,使用数据集的prefetch()方法提前准备好接下来的几批数据,以便在 GPU 需要时立即使用(参见第十三章)。

  • 如果您的模型接受两个图像作为输入,并在使用两个 CNN 处理它们之前将它们连接起来,那么如果您将每个 CNN 放在不同的 GPU 上,它可能会运行得更快。

  • 您可以创建一个高效的集成:只需在每个 GPU 上放置一个不同训练过的模型,这样您就可以更快地获得所有预测结果,以生成集成的最终预测。

但是如果您想通过使用多个 GPU 加速训练呢?

在多个设备上训练模型

训练单个模型跨多个设备有两种主要方法:模型并行,其中模型在设备之间分割,和数据并行,其中模型在每个设备上复制,并且每个副本在不同的数据子集上进行训练。让我们看看这两种选择。

模型并行

到目前为止,我们已经在单个设备上训练了每个神经网络。如果我们想要在多个设备上训练单个神经网络怎么办?这需要将模型分割成单独的块,并在不同的设备上运行每个块。不幸的是,这种模型并行化实际上非常棘手,其有效性确实取决于神经网络的架构。对于全连接网络,从这种方法中通常无法获得太多好处。直觉上,似乎将模型分割的一种简单方法是将每一层放在不同的设备上,但这并不起作用,因为每一层都需要等待前一层的输出才能执行任何操作。也许你可以垂直切割它——例如,将每一层的左半部分放在一个设备上,右半部分放在另一个设备上?这样稍微好一些,因为每一层的两半确实可以并行工作,但问题在于下一层的每一半都需要上一层两半的输出,因此会有大量的跨设备通信(由虚线箭头表示)。这很可能会完全抵消并行计算的好处,因为跨设备通信速度很慢(当设备位于不同的机器上时更是如此)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1911.png

图 19-11。拆分完全连接的神经网络

一些神经网络架构,如卷积神经网络(参见第十四章),包含仅部分连接到较低层的层,因此更容易以有效的方式在设备之间分发块(参见图 19-12)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1912.png

图 19-12。拆分部分连接的神经网络

深度递归神经网络(参见第十五章)可以更有效地跨多个 GPU 进行分割。如果将网络水平分割,将每一层放在不同的设备上,并将输入序列输入网络进行处理,那么在第一个时间步中只有一个设备会处于活动状态(处理序列的第一个值),在第二个时间步中两个设备会处于活动状态(第二层将处理第一层的输出值,而第一层将处理第二个值),当信号传播到输出层时,所有设备将同时处于活动状态(图 19-13)。尽管设备之间仍然存在大量的跨设备通信,但由于每个单元可能相当复杂,理论上并行运行多个单元的好处可能会超过通信惩罚。然而,在实践中,在单个 GPU 上运行的常规堆叠LSTM层实际上运行得更快。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1913.png

图 19-13。拆分深度递归神经网络

简而言之,模型并行可能会加快某些类型的神经网络的运行或训练速度,但并非所有类型的神经网络都适用,并且需要特别注意和调整,例如确保需要进行通信的设备在同一台机器上运行。接下来我们将看一个更简单且通常更有效的选择:数据并行。

数据并行

另一种并行训练神经网络的方法是在每个设备上复制它,并在所有副本上同时运行每个训练步骤,为每个副本使用不同的小批量。然后对每个副本计算的梯度进行平均,并将结果用于更新模型参数。这被称为数据并行,有时也称为单程序,多数据(SPMD)。这个想法有许多变体,让我们看看最重要的几种。

使用镜像策略的数据并行

可以说,最简单的方法是在所有 GPU 上完全镜像所有模型参数,并始终在每个 GPU 上应用完全相同的参数更新。这样,所有副本始终保持完全相同。这被称为镜像策略,在使用单台机器时特别高效(参见图 19-14)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1914.png

图 19-14. 使用镜像策略的数据并行

使用这种方法的棘手部分是高效地计算所有 GPU 的所有梯度的平均值,并将结果分布到所有 GPU 上。这可以使用AllReduce算法来完成,这是一类算法,多个节点合作以高效地执行reduce 操作(例如计算平均值、总和和最大值),同时确保所有节点获得相同的最终结果。幸运的是,有现成的实现这种算法,您将会看到。

集中式参数的数据并行

另一种方法是将模型参数存储在执行计算的 GPU 设备之外(称为工作器);例如,在 CPU 上(参见图 19-15)。在分布式设置中,您可以将所有参数放在一个或多个仅称为参数服务器的 CPU 服务器上,其唯一作用是托管和更新参数。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1915.png

图 19-15. 集中式参数的数据并行

镜像策略强制所有 GPU 上的权重更新同步进行,而这种集中式方法允许同步或异步更新。让我们来看看这两种选择的优缺点。

同步更新

同步更新中,聚合器会等待所有梯度可用后再计算平均梯度并将其传递给优化器,优化器将更新模型参数。一旦一个副本完成计算其梯度,它必须等待参数更新后才能继续下一个小批量。缺点是一些设备可能比其他设备慢,因此快速设备将不得不在每一步等待慢速设备,使整个过程与最慢设备一样慢。此外,参数将几乎同时复制到每个设备上(在梯度应用后立即),这可能会饱和参数服务器的带宽。

提示

为了减少每个步骤的等待时间,您可以忽略最慢几个副本(通常约 10%)的梯度。例如,您可以运行 20 个副本,但每个步骤只聚合来自最快的 18 个副本的梯度,并忽略最后 2 个的梯度。一旦参数更新,前 18 个副本可以立即开始工作,而无需等待最慢的 2 个副本。这种设置通常被描述为有 18 个副本加上 2 个备用副本

异步更新

使用异步更新时,每当一个副本完成梯度计算后,梯度立即用于更新模型参数。没有聚合(它删除了“均值”步骤在图 19-15 中)和没有同步。副本独立于其他副本工作。由于不需要等待其他副本,这种方法每分钟可以运行更多的训练步骤。此外,尽管参数仍然需要在每一步复制到每个设备,但对于每个副本,这发生在不同的时间,因此带宽饱和的风险降低了。

使用异步更新的数据并行是一个吸引人的选择,因为它简单、没有同步延迟,并且更好地利用了带宽。然而,尽管在实践中它表现得相当不错,但它能够工作几乎令人惊讶!事实上,当一个副本基于某些参数值计算梯度完成时,这些参数将已经被其他副本多次更新(如果有N个副本,则平均更新N - 1 次),并且无法保证计算出的梯度仍然指向正确的方向(参见图 19-16)。当梯度严重过时时,它们被称为过时梯度:它们可以减慢收敛速度,引入噪声和摆动效应(学习曲线可能包含临时振荡),甚至可能使训练算法发散。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1916.png

图 19-16。使用异步更新时的过时梯度

有几种方法可以减少陈旧梯度的影响:

  • 降低学习率。

  • 丢弃陈旧的梯度或将其缩小。

  • 调整小批量大小。

  • 在开始的几个时期只使用一个副本(这被称为热身阶段)。在训练开始阶段,梯度通常很大,参数还没有稳定在成本函数的谷底,因此陈旧的梯度可能会造成更大的损害,不同的副本可能会将参数推向完全不同的方向。

2016 年,Google Brain 团队发表的一篇论文对各种方法进行了基准测试,发现使用同步更新和一些备用副本比使用异步更新更有效,不仅收敛更快,而且产生了更好的模型。然而,这仍然是一个活跃的研究领域,所以你不应该立刻排除异步更新。

带宽饱和

无论您使用同步还是异步更新,具有集中参数的数据并行仍然需要在每个训练步骤开始时将模型参数从参数服务器传递到每个副本,并在每个训练步骤结束时将梯度传递到另一个方向。同样,当使用镜像策略时,每个 GPU 生成的梯度将需要与每个其他 GPU 共享。不幸的是,通常会出现这样一种情况,即添加额外的 GPU 将不会改善性能,因为将数据移入和移出 GPU RAM(以及在分布式设置中跨网络)所花费的时间将超过通过分割计算负载获得的加速效果。在那一点上,添加更多的 GPU 将只会加剧带宽饱和,并实际上减慢训练速度。

饱和对于大型密集模型来说更严重,因为它们有很多参数和梯度需要传输。对于小型模型来说,饱和程度较轻(但并行化增益有限),对于大型稀疏模型也较轻,因为梯度通常大部分为零,可以有效传输。Google Brain 项目的发起人和负责人 Jeff Dean 报告 在将计算分布到 50 个 GPU 上时,密集模型的典型加速为 25-40 倍,而在 500 个 GPU 上训练稀疏模型时,加速为 300 倍。正如你所看到的,稀疏模型确实更好地扩展。以下是一些具体例子:

  • 神经机器翻译:在 8 个 GPU 上加速 6 倍

  • Inception/ImageNet:在 50 个 GPU 上加速 32 倍

  • RankBrain:在 500 个 GPU 上加速 300 倍

有很多研究正在进行,以缓解带宽饱和问题,目标是使训练能够与可用的 GPU 数量成线性比例扩展。例如,卡内基梅隆大学、斯坦福大学和微软研究团队在 2018 年提出了一个名为PipeDream的系统,成功将网络通信减少了 90%以上,使得可以在多台机器上训练大型模型成为可能。他们使用了一种称为管道并行的新技术来实现这一目标,该技术结合了模型并行和数据并行:模型被切分成连续的部分,称为阶段,每个阶段在不同的机器上进行训练。这导致了一个异步的管道,所有机器都在很少的空闲时间内并行工作。在训练过程中,每个阶段交替进行一轮前向传播和一轮反向传播:它从输入队列中提取一个小批量数据,处理它,并将输出发送到下一个阶段的输入队列,然后从梯度队列中提取一个小批量的梯度,反向传播这些梯度并更新自己的模型参数,并将反向传播的梯度推送到前一个阶段的梯度队列。然后它一遍又一遍地重复整个过程。每个阶段还可以独立地使用常规的数据并行(例如使用镜像策略),而不受其他阶段的影响。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1917.png

图 19-17。PipeDream 的管道并行性

然而,正如在这里展示的那样,PipeDream 不会工作得那么好。要理解原因,考虑在 Figure 19-17 中的第 5 个小批次:当它在前向传递过程中经过第 1 阶段时,来自第 4 个小批次的梯度尚未通过该阶段进行反向传播,但是当第 5 个小批次的梯度流回到第 1 阶段时,第 4 个小批次的梯度将已经被用来更新模型参数,因此第 5 个小批次的梯度将有点过时。正如我们所看到的,这可能会降低训练速度和准确性,甚至使其发散:阶段越多,这个问题就会变得越糟糕。论文的作者提出了缓解这个问题的方法:例如,每个阶段在前向传播过程中保存权重,并在反向传播过程中恢复它们,以确保相同的权重用于前向传递和反向传递。这被称为权重存储。由于这一点,PipeDream 展示了令人印象深刻的扩展能力,远远超出了简单的数据并行性。

这个研究领域的最新突破是由谷歌研究人员在一篇2022 年的论文中发表的:他们开发了一个名为Pathways的系统,利用自动模型并行、异步团队调度等技术,实现了数千个 TPU 几乎 100%的硬件利用率!调度意味着组织每个任务必须运行的时间和位置,团队调度意味着同时并行运行相关任务,并且彼此靠近,以减少任务等待其他任务输出的时间。正如我们在第十六章中看到的,这个系统被用来在超过 6,000 个 TPU 上训练一个庞大的语言模型,几乎实现了 100%的硬件利用率:这是一个令人惊叹的工程壮举。

在撰写本文时,Pathways 尚未公开,但很可能在不久的将来,您将能够使用 Pathways 或类似系统在 Vertex AI 上训练大型模型。与此同时,为了减少饱和问题,您可能会希望使用一些强大的 GPU,而不是大量的弱 GPU,如果您需要在多台服务器上训练模型,您应该将 GPU 分组在少数且连接非常良好的服务器上。您还可以尝试将浮点精度从 32 位(tf.float32)降低到 16 位(tf.bfloat16)。这将减少一半的数据传输量,通常不会对收敛速度或模型性能产生太大影响。最后,如果您正在使用集中式参数,您可以将参数分片(分割)到多个参数服务器上:增加更多的参数服务器将减少每个服务器上的网络负载,并限制带宽饱和的风险。

好的,现在我们已经讨论了所有的理论,让我们实际在多个 GPU 上训练一个模型!

使用分布策略 API 进行规模训练

幸运的是,TensorFlow 带有一个非常好的 API,它负责处理将模型分布在多个设备和机器上的所有复杂性:分布策略 API。要在所有可用的 GPU 上(暂时只在单台机器上)使用数据并行性和镜像策略训练一个 Keras 模型,只需创建一个MirroredStrategy对象,调用它的scope()方法以获取一个分布上下文,并将模型的创建和编译包装在该上下文中。然后正常调用模型的fit()方法:

strategy = tf.distribute.MirroredStrategy()

with strategy.scope():
    model = tf.keras.Sequential([...])  # create a Keras model normally
    model.compile([...])  # compile the model normally

batch_size = 100  # preferably divisible by the number of replicas
model.fit(X_train, y_train, epochs=10,
          validation_data=(X_valid, y_valid), batch_size=batch_size)

在底层,Keras 是分布感知的,因此在这个MirroredStrategy上下文中,它知道必须在所有可用的 GPU 设备上复制所有变量和操作。如果你查看模型的权重,它们是MirroredVariable类型的:

>>> type(model.weights[0])
tensorflow.python.distribute.values.MirroredVariable

请注意,fit() 方法会自动将每个训练批次在所有副本之间进行分割,因此最好确保批次大小可以被副本数量(即可用的 GPU 数量)整除,以便所有副本获得相同大小的批次。就是这样!训练通常会比使用单个设备快得多,而且代码更改确实很小。

训练模型完成后,您可以使用它高效地进行预测:调用predict()方法,它会自动将批处理在所有副本之间分割,以并行方式进行预测。再次强调,批处理大小必须能够被副本数量整除。如果调用模型的save()方法,它将被保存为常规模型,而不是具有多个副本的镜像模型。因此,当您加载它时,它将像常规模型一样运行,在单个设备上:默认情况下在 GPU#0 上,如果没有 GPU 则在 CPU 上。如果您想加载一个模型并在所有可用设备上运行它,您必须在分发上下文中调用tf.keras.models.load_model()

with strategy.scope():
    model = tf.keras.models.load_model("my_mirrored_model")

如果您只想使用所有可用 GPU 设备的子集,您可以将列表传递给MirroredStrategy的构造函数:

strategy = tf.distribute.MirroredStrategy(devices=["/gpu:0", "/gpu:1"])

默认情况下,MirroredStrategy类使用NVIDIA Collective Communications Library(NCCL)进行 AllReduce 均值操作,但您可以通过将cross_device_ops参数设置为tf.distribute.HierarchicalCopyAllReduce类的实例或tf.distribute.ReductionToOneDevice类的实例来更改它。默认的 NCCL 选项基于tf.distribute.NcclAllReduce类,通常更快,但这取决于 GPU 的数量和类型,因此您可能想尝试一下其他选择。

如果您想尝试使用集中式参数的数据并行性,请将MirroredStrategy替换为CentralStorageStrategy

strategy = tf.distribute.experimental.CentralStorageStrategy()

您可以选择设置compute_devices参数来指定要用作工作器的设备列表-默认情况下将使用所有可用的 GPU-您还可以选择设置parameter_device参数来指定要存储参数的设备。默认情况下将使用 CPU,或者如果只有一个 GPU,则使用 GPU。

现在让我们看看如何在一组 TensorFlow 服务器上训练模型!

在 TensorFlow 集群上训练模型

TensorFlow 集群是一组在并行运行的 TensorFlow 进程,通常在不同的机器上,并相互通信以完成一些工作,例如训练或执行神经网络模型。集群中的每个 TF 进程被称为任务TF 服务器。它有一个 IP 地址,一个端口和一个类型(也称为角色工作)。类型可以是"worker""chief""ps"(参数服务器)或"evaluator"

  • 每个worker执行计算,通常在一台或多台 GPU 的机器上。

  • 首席执行计算任务(它是一个工作者),但也处理额外的工作,比如编写 TensorBoard 日志或保存检查点。集群中只有一个首席。如果没有明确指定首席,则按照惯例第一个工作者就是首席。

  • 参数服务器只跟踪变量值,并且通常在仅有 CPU 的机器上。这种类型的任务只能与ParameterServerStrategy一起使用。

  • 评估者显然负责评估。这种类型并不经常使用,当使用时,通常只有一个评估者。

要启动一个 TensorFlow 集群,必须首先定义其规范。这意味着定义每个任务的 IP 地址、TCP 端口和类型。例如,以下集群规范定义了一个有三个任务的集群(两个工作者和一个参数服务器;参见图 19-18)。集群规范是一个字典,每个作业对应一个键,值是任务地址(IP:port)的列表:

cluster_spec = {
    "worker": [
        "machine-a.example.com:2222",     # /job:worker/task:0
        "machine-b.example.com:2222"      # /job:worker/task:1
    ],
    "ps": ["machine-a.example.com:2221"]  # /job:ps/task:0
}

通常每台机器上会有一个任务,但正如这个示例所示,如果需要,您可以在同一台机器上配置多个任务。在这种情况下,如果它们共享相同的 GPU,请确保 RAM 适当分配,如前面讨论的那样。

警告

默认情况下,集群中的每个任务可以与其他任务通信,因此请确保配置防火墙以授权这些机器之间这些端口上的所有通信(如果每台机器使用相同的端口,则通常更简单)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1918.png

图 19-18。一个示例 TensorFlow 集群

当您开始一个任务时,您必须给它指定集群规范,并且还必须告诉它它的类型和索引是什么(例如,worker #0)。一次性指定所有内容的最简单方法(包括集群规范和当前任务的类型和索引)是在启动 TensorFlow 之前设置TF_CONFIG环境变量。它必须是一个 JSON 编码的字典,包含集群规范(在"cluster"键下)和当前任务的类型和索引(在"task"键下)。例如,以下TF_CONFIG环境变量使用我们刚刚定义的集群,并指定要启动的任务是 worker #0:

os.environ["TF_CONFIG"] = json.dumps({
    "cluster": cluster_spec,
    "task": {"type": "worker", "index": 0}
})
提示

通常您希望在 Python 之外定义TF_CONFIG环境变量,这样代码就不需要包含当前任务的类型和索引(这样可以在所有工作节点上使用相同的代码)。

现在让我们在集群上训练一个模型!我们将从镜像策略开始。首先,您需要为每个任务适当设置TF_CONFIG环境变量。集群规范中不应该有参数服务器(删除集群规范中的"ps"键),通常每台机器上只需要一个工作节点。确保为每个任务设置不同的任务索引。最后,在每个工作节点上运行以下脚本:

import tempfile
import tensorflow as tf

strategy = tf.distribute.MultiWorkerMirroredStrategy()  # at the start!
resolver = tf.distribute.cluster_resolver.TFConfigClusterResolver()
print(f"Starting task {resolver.task_type} #{resolver.task_id}")
[...] # load and split the MNIST dataset

with strategy.scope():
    model = tf.keras.Sequential([...])  # build the Keras model
    model.compile([...])  # compile the model

model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=10)

if resolver.task_id == 0:  # the chief saves the model to the right location
    model.save("my_mnist_multiworker_model", save_format="tf")
else:
    tmpdir = tempfile.mkdtemp()  # other workers save to a temporary directory
    model.save(tmpdir, save_format="tf")
    tf.io.gfile.rmtree(tmpdir)  # and we can delete this directory at the end!

这几乎是您之前使用的相同代码,只是这次您正在使用MultiWorkerMirroredStrategy。当您在第一个工作节点上启动此脚本时,它们将在 AllReduce 步骤处保持阻塞,但是一旦最后一个工作节点启动,训练将开始,并且您将看到它们以完全相同的速度前进,因为它们在每一步都进行同步。

警告

在使用MultiWorkerMirroredStrategy时,重要的是确保所有工作人员做同样的事情,包括保存模型检查点或编写 TensorBoard 日志,即使您只保留主要写入的内容。这是因为这些操作可能需要运行 AllReduce 操作,因此所有工作人员必须保持同步。

这个分发策略有两种 AllReduce 实现方式:基于 gRPC 的环形 AllReduce 算法用于网络通信,以及 NCCL 的实现。要使用哪种最佳算法取决于工作人员数量、GPU 数量和类型,以及网络情况。默认情况下,TensorFlow 会应用一些启发式方法为您选择合适的算法,但您可以强制使用 NCCL(或 RING)如下:

strategy = tf.distribute.MultiWorkerMirroredStrategy(
    communication_options=tf.distribute.experimental.CommunicationOptions(
        implementation=tf.distribute.experimental.CollectiveCommunication.NCCL))

如果您希望使用参数服务器实现异步数据并行处理,请将策略更改为ParameterServerStrategy,添加一个或多个参数服务器,并为每个任务适当配置TF_CONFIG。请注意,虽然工作人员将异步工作,但每个工作人员上的副本将同步工作。

最后,如果您可以访问Google Cloud 上的 TPU——例如,如果您在 Colab 中设置加速器类型为 TPU——那么您可以像这样创建一个TPUStrategy

resolver = tf.distribute.cluster_resolver.TPUClusterResolver()
tf.tpu.experimental.initialize_tpu_system(resolver)
strategy = tf.distribute.experimental.TPUStrategy(resolver)

这需要在导入 TensorFlow 后立即运行。然后您可以正常使用这个策略。

提示

如果您是研究人员,您可能有资格免费使用 TPU;请查看https://tensorflow.org/tfrc获取更多详细信息。

现在您可以跨多个 GPU 和多个服务器训练模型:给自己一个鼓励!然而,如果您想训练一个非常大的模型,您将需要许多 GPU,跨多个服务器,这将要求要么购买大量硬件,要么管理大量云虚拟机。在许多情况下,使用一个云服务来为您提供所有这些基础设施的配置和管理会更方便、更经济,只有在您需要时才会提供。让我们看看如何使用 Vertex AI 来实现这一点。

在 Vertex AI 上运行大型训练作业

Vertex AI 允许您使用自己的训练代码创建自定义训练作业。实际上,您可以几乎使用与在自己的 TF 集群上使用的相同的训练代码。您必须更改的主要内容是首席应该保存模型、检查点和 TensorBoard 日志的位置。首席必须将模型保存到 GCS,使用 Vertex AI 在AIP_MODEL_DIR环境变量中提供的路径,而不是将模型保存到本地目录。对于模型检查点和 TensorBoard 日志,您应该分别使用AIP_CHECKPOINT_DIRAIP_TENSORBOARD_LOG_DIR环境变量中包含的路径。当然,您还必须确保训练数据可以从虚拟机访问,例如在 GCS 上,或者从另一个 GCP 服务(如 BigQuery)或直接从网络上访问。最后,Vertex AI 明确设置了"chief"任务类型,因此您应该使用resolved.task_type == "chief"来识别首席,而不是使用resolved.task_id == 0

import os
[...]  # other imports, create MultiWorkerMirroredStrategy, and resolver

if resolver.task_type == "chief":
    model_dir = os.getenv("AIP_MODEL_DIR")  # paths provided by Vertex AI
    tensorboard_log_dir = os.getenv("AIP_TENSORBOARD_LOG_DIR")
    checkpoint_dir = os.getenv("AIP_CHECKPOINT_DIR")
else:
    tmp_dir = Path(tempfile.mkdtemp())  # other workers use temporary dirs
    model_dir = tmp_dir / "model"
    tensorboard_log_dir = tmp_dir / "logs"
    checkpoint_dir = tmp_dir / "ckpt"

callbacks = [tf.keras.callbacks.TensorBoard(tensorboard_log_dir),
             tf.keras.callbacks.ModelCheckpoint(checkpoint_dir)]
[...]  # build and  compile using the strategy scope, just like earlier
model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=10,
          callbacks=callbacks)
model.save(model_dir, save_format="tf")
提示

如果您将训练数据放在 GCS 上,您可以创建一个tf.data.TextLineDatasettf.data.TFRecordDataset来访问它:只需将 GCS 路径作为文件名(例如,gs://my_bucket/data/001.csv)。这些数据集依赖于tf.io.gfile包来访问文件:它支持本地文件和 GCS 文件。

现在您可以在 Vertex AI 上基于这个脚本创建一个自定义训练作业。您需要指定作业名称、训练脚本的路径、用于训练的 Docker 镜像、用于预测的镜像(训练后)、您可能需要的任何其他 Python 库,以及最后 Vertex AI 应该使用作为存储训练脚本的暂存目录的存储桶。默认情况下,这也是训练脚本将保存训练模型、TensorBoard 日志和模型检查点(如果有的话)的地方。让我们创建这个作业:

custom_training_job = aiplatform.CustomTrainingJob(
    display_name="my_custom_training_job",
    script_path="my_vertex_ai_training_task.py",
    container_uri="gcr.io/cloud-aiplatform/training/tf-gpu.2-4:latest",
    model_serving_container_image_uri=server_image,
    requirements=["gcsfs==2022.3.0"],  # not needed, this is just an example
    staging_bucket=f"gs://{bucket_name}/staging"
)

现在让我们在两个拥有两个 GPU 的工作节点上运行它:

mnist_model2 = custom_training_job.run(
    machine_type="n1-standard-4",
    replica_count=2,
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=2,
)

这就是全部内容:Vertex AI 将为您请求的计算节点进行配置(在您的配额范围内),并在这些节点上运行您的训练脚本。一旦作业完成,run()方法将返回一个经过训练的模型,您可以像之前创建的那样使用它:您可以部署到端点,或者用它进行批量预测。如果在训练过程中出现任何问题,您可以在 GCP 控制台中查看日志:在☰导航菜单中,选择 Vertex AI → 训练,点击您的训练作业,然后点击查看日志。或者,您可以点击自定义作业选项卡,复制作业的 ID(例如,1234),然后从☰导航菜单中选择日志记录,并查询resource.labels.job_id=1234

提示

要可视化训练进度,只需启动 TensorBoard,并将其--logdir指向日志的 GCS 路径。它将使用应用程序默认凭据,您可以使用gcloud auth application-default login进行设置。如果您喜欢,Vertex AI 还提供托管的 TensorBoard 服务器。

如果您想尝试一些超参数值,一个选项是运行多个作业。您可以通过在调用run()方法时设置args参数将超参数值作为命令行参数传递给您的脚本,或者您可以使用environment_variables参数将它们作为环境变量传递。

然而,如果您想在云上运行一个大型的超参数调整作业,一个更好的选择是使用 Vertex AI 的超参数调整服务。让我们看看如何做。

Vertex AI 上的超参数调整

Vertex AI 的超参数调整服务基于贝叶斯优化算法,能够快速找到最佳的超参数组合。要使用它,首先需要创建一个接受超参数值作为命令行参数的训练脚本。例如,您的脚本可以像这样使用argparse标准库:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--n_hidden", type=int, default=2)
parser.add_argument("--n_neurons", type=int, default=256)
parser.add_argument("--learning_rate", type=float, default=1e-2)
parser.add_argument("--optimizer", default="adam")
args = parser.parse_args()

超参数调整服务将多次调用您的脚本,每次使用不同的超参数值:每次运行称为trial,一组试验称为study。然后,您的训练脚本必须使用给定的超参数值来构建和编译模型。如果需要,您可以使用镜像分发策略,以便每个试验在多 GPU 机器上运行。然后脚本可以加载数据集并训练模型。例如:

import tensorflow as tf

def build_model(args):
    with tf.distribute.MirroredStrategy().scope():
        model = tf.keras.Sequential()
        model.add(tf.keras.layers.Flatten(input_shape=[28, 28], dtype=tf.uint8))
        for _ in range(args.n_hidden):
            model.add(tf.keras.layers.Dense(args.n_neurons, activation="relu"))
        model.add(tf.keras.layers.Dense(10, activation="softmax"))
        opt = tf.keras.optimizers.get(args.optimizer)
        opt.learning_rate = args.learning_rate
        model.compile(loss="sparse_categorical_crossentropy", optimizer=opt,
                      metrics=["accuracy"])
        return model

[...]  # load the dataset
model = build_model(args)
history = model.fit([...])
提示

您可以使用我们之前提到的AIP_*环境变量来确定在哪里保存检查点、TensorBoard 日志和最终模型。

最后,脚本必须将模型的性能报告给 Vertex AI 的超参数调整服务,以便它决定尝试哪些超参数。为此,您必须使用hypertune库,在 Vertex AI 训练 VM 上自动安装:

import hypertune

hypertune = hypertune.HyperTune()
hypertune.report_hyperparameter_tuning_metric(
    hyperparameter_metric_tag="accuracy",  # name of the reported metric
    metric_value=max(history.history["val_accuracy"]),  # metric value
    global_step=model.optimizer.iterations.numpy(),
)

现在您的训练脚本已准备就绪,您需要定义要在其上运行的机器类型。为此,您必须定义一个自定义作业,Vertex AI 将使用它作为每个试验的模板:

trial_job = aiplatform.CustomJob.from_local_script(
    display_name="my_search_trial_job",
    script_path="my_vertex_ai_trial.py",  # path to your training script
    container_uri="gcr.io/cloud-aiplatform/training/tf-gpu.2-4:latest",
    staging_bucket=f"gs://{bucket_name}/staging",
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=2,  # in this example, each trial will have 2 GPUs
)

最后,您准备好创建并运行超参数调整作业:

from google.cloud.aiplatform import hyperparameter_tuning as hpt

hp_job = aiplatform.HyperparameterTuningJob(
    display_name="my_hp_search_job",
    custom_job=trial_job,
    metric_spec={"accuracy": "maximize"},
    parameter_spec={
        "learning_rate": hpt.DoubleParameterSpec(min=1e-3, max=10, scale="log"),
        "n_neurons": hpt.IntegerParameterSpec(min=1, max=300, scale="linear"),
        "n_hidden": hpt.IntegerParameterSpec(min=1, max=10, scale="linear"),
        "optimizer": hpt.CategoricalParameterSpec(["sgd", "adam"]),
    },
    max_trial_count=100,
    parallel_trial_count=20,
)
hp_job.run()

在这里,我们告诉 Vertex AI 最大化名为 "accuracy" 的指标:这个名称必须与训练脚本报告的指标名称匹配。我们还定义了搜索空间,使用对数尺度来设置学习率,使用线性(即均匀)尺度来设置其他超参数。超参数的名称必须与训练脚本的命令行参数匹配。然后我们将最大试验次数设置为 100,同时最大并行运行的试验次数设置为 20。如果你将并行试验的数量增加到(比如)60,总搜索时间将显著减少,最多可减少到 3 倍。但前 60 个试验将同时开始,因此它们将无法从其他试验的反馈中受益。因此,您应该增加最大试验次数来补偿,例如增加到大约 140。

这将需要相当长的时间。一旦作业完成,您可以使用 hp_job.trials 获取试验结果。每个试验结果都表示为一个 protobuf 对象,包含超参数值和结果指标。让我们找到最佳试验:

def get_final_metric(trial, metric_id):
    for metric in trial.final_measurement.metrics:
        if metric.metric_id == metric_id:
            return metric.value

trials = hp_job.trials
trial_accuracies = [get_final_metric(trial, "accuracy") for trial in trials]
best_trial = trials[np.argmax(trial_accuracies)]

现在让我们看看这个试验的准确率,以及其超参数值:

>>> max(trial_accuracies)
0.977400004863739
>>> best_trial.id
'98'
>>> best_trial.parameters
[parameter_id: "learning_rate" value { number_value: 0.001 },
 parameter_id: "n_hidden" value { number_value: 8.0 },
 parameter_id: "n_neurons" value { number_value: 216.0 },
 parameter_id: "optimizer" value { string_value: "adam" }
]

就是这样!现在您可以获取这个试验的 SavedModel,可选择性地再训练一下,并将其部署到生产环境中。

提示

Vertex AI 还包括一个 AutoML 服务,完全负责为您找到合适的模型架构并为您进行训练。您只需要将数据集以特定格式上传到 Vertex AI,这取决于数据集的类型(图像、文本、表格、视频等),然后创建一个 AutoML 训练作业,指向数据集并指定您愿意花费的最大计算小时数。请参阅笔记本中的示例。

现在你拥有了所有需要创建最先进的神经网络架构并使用各种分布策略进行规模化训练的工具和知识,可以在自己的基础设施或云上部署它们,然后在任何地方部署它们。换句话说,你现在拥有超能力:好好利用它们!

练习

  1. SavedModel 包含什么?如何检查其内容?

  2. 什么时候应该使用 TF Serving?它的主要特点是什么?有哪些工具可以用来部署它?

  3. 如何在多个 TF Serving 实例上部署模型?

  4. 在查询由 TF Serving 提供的模型时,何时应该使用 gRPC API 而不是 REST API?

  5. TFLite 通过哪些不同的方式减小模型的大小,使其能在移动设备或嵌入式设备上运行?

  6. 什么是量化感知训练,为什么需要它?

  7. 什么是模型并行和数据并行?为什么通常推荐后者?

  8. 在多台服务器上训练模型时,您可以使用哪些分发策略?您如何选择使用哪种?

  9. 训练一个模型(任何您喜欢的模型)并部署到 TF Serving 或 Google Vertex AI。编写客户端代码,使用 REST API 或 gRPC API 查询它。更新模型并部署新版本。您的客户端代码现在将查询新版本。回滚到第一个版本。

  10. 在同一台机器上使用MirroredStrategy在多个 GPU 上训练任何模型(如果您无法访问 GPU,可以使用带有 GPU 运行时的 Google Colab 并创建两个逻辑 GPU)。再次使用CentralStorageStrategy训练模型并比较训练时间。

  11. 在 Vertex AI 上微调您选择的模型,使用 Keras Tuner 或 Vertex AI 的超参数调整服务。

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

谢谢!

在我们结束这本书的最后一章之前,我想感谢您读到最后一段。我真诚地希望您阅读这本书和我写作时一样开心,并且它对您的项目,无论大小,都有用。

如果您发现错误,请发送反馈。更一般地,我很想知道您的想法,所以请不要犹豫通过 O’Reilly、ageron/handson-ml3 GitHub 项目或 Twitter 上的@aureliengeron 与我联系。

继续前进,我给你的最好建议是练习和练习:尝试完成所有的练习(如果你还没有这样做),玩一下笔记本电脑,加入 Kaggle 或其他机器学习社区,观看机器学习课程,阅读论文,参加会议,与专家会面。事情发展迅速,所以尽量保持最新。一些 YouTube 频道定期以非常易懂的方式详细介绍深度学习论文。我特别推荐 Yannic Kilcher、Letitia Parcalabescu 和 Xander Steenbrugge 的频道。要了解引人入胜的机器学习讨论和更高层次的见解,请务必查看 ML Street Talk 和 Lex Fridman 的频道。拥有一个具体的项目要去做也会极大地帮助,无论是为了工作还是为了娱乐(最好两者兼顾),所以如果你一直梦想着建造某样东西,就试一试吧!逐步工作;不要立即朝着月球开火,而是专注于你的项目,一步一步地构建它。这需要耐心和毅力,但当你拥有一个行走的机器人,或一个工作的聊天机器人,或者其他你喜欢的任何东西时,这将是极其有益的!

我最大的希望是这本书能激发你构建一个美妙的 ML 应用程序,使我们所有人受益。它会是什么样的?

Aurélien Géron

¹ A/B 实验包括在不同的用户子集上测试产品的两个不同版本,以检查哪个版本效果最好并获得其他见解。

² Google AI 平台(以前称为 Google ML 引擎)和 Google AutoML 在 2021 年合并为 Google Vertex AI。

³ REST(或 RESTful)API 是一种使用标准 HTTP 动词(如 GET、POST、PUT 和 DELETE)以及使用 JSON 输入和输出的 API。gRPC 协议更复杂但更高效;数据使用协议缓冲区进行交换(参见第十三章)。

如果您对 Docker 不熟悉,它允许您轻松下载一组打包在Docker 镜像中的应用程序(包括所有依赖项和通常一些良好的默认配置),然后使用Docker 引擎在您的系统上运行它们。当您运行一个镜像时,引擎会创建一个保持应用程序与您自己系统良好隔离的Docker 容器,但如果您愿意,可以给它一些有限的访问权限。它类似于虚拟机,但速度更快、更轻,因为容器直接依赖于主机的内核。这意味着镜像不需要包含或运行自己的内核。

还有 GPU 镜像可用,以及其他安装选项。有关更多详细信息,请查看官方安装说明

公平地说,这可以通过首先序列化数据,然后将其编码为 Base64,然后创建 REST 请求来减轻。此外,REST 请求可以使用 gzip 进行压缩,从而显著减少有效负载大小。

还要查看 TensorFlow 的Graph Transform Tool,用于修改和优化计算图。

例如,PWA 必须包含不同移动设备大小的图标,必须通过 HTTPS 提供,必须包含包含应用程序名称和背景颜色等元数据的清单文件。

请查看 TensorFlow 文档,获取详细和最新的安装说明,因为它们经常更改。

¹⁰ 正如我们在第十二章中所看到的,内核是特定数据类型和设备类型的操作实现。例如,float32 tf.matmul() 操作有一个 GPU 内核,但 int32 tf.matmul() 没有 GPU 内核,只有一个 CPU 内核。

¹¹ 您还可以使用 tf.debugging.set_log_device_placement(True) 来记录所有设备放置情况。

¹² 如果您想要保证完美的可重现性,这可能很有用,正如我在这个视频中所解释的,基于 TF 1。

¹³ 在撰写本文时,它只是将数据预取到 CPU RAM,但使用 tf.data.experimental.pre⁠fetch​_to_device() 可以使其预取数据并将其推送到您选择的设备,以便 GPU 不必等待数据传输而浪费时间。

如果两个 CNN 相同,则称为孪生神经网络

如果您对模型并行性感兴趣,请查看Mesh TensorFlow

这个名字有点令人困惑,因为听起来好像有些副本是特殊的,什么也不做。实际上,所有副本都是等价的:它们都努力成为每个训练步骤中最快的,失败者在每一步都会变化(除非某些设备真的比其他设备慢)。但是,这意味着如果一个或两个服务器崩溃,训练将继续进行得很好。

Jianmin Chen 等人,“重新审视分布式同步 SGD”,arXiv 预印本 arXiv:1604.00981(2016)。

¹⁸ Aaron Harlap 等人,“PipeDream: 快速高效的管道并行 DNN 训练”,arXiv 预印本 arXiv:1806.03377(2018)。

¹⁹ Paul Barham 等人,“Pathways: 异步分布式数据流 ML”,arXiv 预印本 arXiv:2203.12533(2022)。

²⁰ 有关 AllReduce 算法的更多详细信息,请阅读 Yuichiro Ueno 的文章,该文章介绍了深度学习背后的技术,以及 Sylvain Jeaugey 的文章,该文章介绍了如何使用 NCCL 大规模扩展深度学习训练。

您可能感兴趣的与本文相关的镜像

TensorFlow-v2.9

TensorFlow-v2.9

TensorFlow

TensorFlow 是由Google Brain 团队开发的开源机器学习框架,广泛应用于深度学习研究和生产环境。 它提供了一个灵活的平台,用于构建和训练各种机器学习模型

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值