Python-深度学习第三版-二-

Python 深度学习第三版(二)

原文:deeplearningwithpython.io/chapters/

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:分类和回归

原文:deeplearningwithpython.io/chapters/chapter04_classification-and-regression

这章旨在帮助你开始使用神经网络解决实际问题。你将巩固从第二章和第三章中获得的知识,并将所学应用到三个新的任务中,涵盖神经网络最常见的三个用例——二元分类、分类分类和标量回归:

  • 将电影评论分类为正面或负面(二元分类)

  • 根据主题对新闻通讯进行分类(分类分类)

  • 根据房地产数据估算房屋价格(标量回归)

这些示例将是你第一次接触端到端机器学习工作流程:你将了解数据预处理、基本模型架构原则以及模型评估。

到本章结束时,你将能够使用神经网络处理简单的基于向量数据的分类和回归任务。然后你将准备好开始在第五章中构建一个更原则性、理论驱动的机器学习理解。

对电影评论进行分类:一个二分类示例

二分类,或称为二元分类,是机器学习中最常见的类型之一。在这个例子中,你将学习如何根据评论的文本内容将电影评论分类为正面或负面。

IMDb 数据集

你将使用 IMDb 数据集:来自互联网电影数据库的 50,000 条高度两极化的评论。它们被分为 25,000 条用于训练和 25,000 条用于测试,每个集合包含 50% 的负面评论和 50% 的正面评论。

就像 MNIST 数据集一样,IMDb 数据集已经打包在 Keras 中。它已经被预处理过:评论(单词序列)已经被转换成整数序列,其中每个整数代表字典中的一个特定单词。这使得我们能够专注于模型构建、训练和评估。在第十四章中,你将学习如何从头开始处理原始文本输入。

以下代码将加载数据集(当你第一次运行时,大约 80 MB 的数据将被下载到你的机器上)。

from keras.datasets import imdb

(train_data, train_labels), (test_data, test_labels) = imdb.load_data(
    num_words=10000
) 

列表 4.1:加载 IMDb 数据集

参数 num_words=10000 的意思是,你将只保留训练数据中频率最高的前 10,000 个单词。不常见的单词将被丢弃。这允许你处理可管理的向量数据。如果我们没有设置这个限制,我们将在训练数据中处理 88,585 个独特的单词,这是不必要的大的。其中许多单词只出现在单个样本中,因此不能有意义地用于分类。

变量train_datatest_data是评论的 NumPy 数组;每个评论是一系列单词索引(编码单词序列)。train_labelstest_labels是 0s 和 1s 的 NumPy 数组,其中 0 代表负面,1 代表正面

>>> train_data[0]
[1, 14, 22, 16, ... 178, 32]
>>> train_labels[0]
1

由于你将自己限制在频率最高的前 10,000 个单词上,没有单词索引会超过 10,000:

>>> max([max(sequence) for sequence in train_data])
9999

为了好玩,让我们快速解码其中一个评论回英文单词。

# word_index is a dictionary mapping words to an integer index.
word_index = imdb.get_word_index()
# Reverses it, mapping integer indices to words
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])
# Decodes the review. Note that the indices are offset by 3 because 0,
# 1, and 2 are reserved indices for "padding," "start of sequence," and
# "unknown."
decoded_review = " ".join(
    [reverse_word_index.get(i - 3, "?") for i in train_data[0]]
) 

列表 4.2:将评论解码回文本

让我们看看我们得到了什么:

>>> decoded_review[:100]
? this film was just brilliant casting location scenery story direction everyone

注意,前面的?对应于被添加到每个评论前的起始标记。

准备数据

你不能直接将整数列表输入到神经网络中。它们的长度各不相同,而神经网络期望处理连续的数据批次。你必须将你的列表转换为张量。有两种方法可以做到这一点:

  • 将你的列表填充到相同的长度,然后将其转换为形状为(samples, max_length)的整数张量,然后以一个能够处理此类整数张量的层(Embedding层,我们将在本书的后面详细讨论)开始你的模型。

  • 多热编码你的列表,将它们转换为反映所有可能单词存在或不存在情况的 0s 和 1s 的向量。这意味着,例如,将序列 [8, 5] 转换为一个 10,000 维度的向量,除了索引 5 和 8 之外的所有位置都是 0s,而这两个索引位置是 1s。

让我们选择后者来矢量化数据。当手动完成时,过程如下所示。

import numpy as np

def multi_hot_encode(sequences, num_classes):
    # Creates an all-zero matrix of shape (len(sequences), num_classes)
    results = np.zeros((len(sequences), num_classes))
    for i, sequence in enumerate(sequences):
        # Sets specific indices of results[i] to 1s
        results[i][sequence] = 1.0
    return results

# Vectorized training data
x_train = multi_hot_encode(train_data, num_classes=10000)
# Vectorized test data
x_test = multi_hot_encode(test_data, num_classes=10000) 

列表 4.3:通过多热编码对整数序列进行编码

现在样本看起来是这样的:

>>> x_train[0]
array([ 0.,  1.,  1., ...,  0.,  0.,  0.])

除了矢量化输入序列外,你还应该矢量化它们的标签,这很简单。我们的标签已经是 NumPy 数组,所以只需将类型从 int 转换为 float:

y_train = train_labels.astype("float32")
y_test = test_labels.astype("float32") 

现在,数据已经准备好输入到神经网络中。

构建你的模型

输入数据是向量,标签是标量(1s 和 0s):这是你将遇到的简单问题设置之一。在这样一个问题上表现良好的模型类型是带有relu激活的密集连接(Dense)层的简单堆叠。

在这样一个Dense层堆叠中,有两个关键架构决策需要做出:

  • 使用多少层

  • 每层选择多少个单元

在第五章中,你将学习正式原则来指导你做出这些选择。目前,你必须相信我们以下架构选择:

  • 两个中间层,每个层有 16 个单元

  • 第三层将输出关于当前评论情感的标量预测

图 4.1 显示了模型的外观。这是 Keras 实现,类似于你之前看到的 MNIST 示例。

import keras
from keras import layers

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

列表 4.4:模型定义

图 4.1:三层模型

传递给每个Dense层的第一个参数是该层的单元数量:该层的表示空间维度。你从第二章和第三章中记得,每个这样的Dense层使用relu激活函数实现以下链式张量操作:

output = relu(dot(input, W) + b) 

有 16 个单元意味着权重矩阵W的形状将是(input_dimension, 16):输入数据与W的点积将把输入数据投影到 16 维表示空间(然后你会添加偏置向量b并应用relu操作)。你可以直观地理解你的表示空间的维度为“你在模型学习内部表示时允许多少自由度。”拥有更多的单元(更高维的表示空间)允许你的模型学习更复杂的表示,但它会使模型计算成本更高,并可能导致学习到不希望的模式(这些模式会在训练数据上提高性能,但在测试数据上不会)。

中间层使用relu作为它们的激活函数,而最后一层使用 sigmoid 激活来输出一个概率(一个介于 0 和 1 之间的分数,表示评论可能是正面的可能性)。relu(修正线性单元)是一个旨在将负值置零的函数(见图 4.2),而 sigmoid 函数“压缩”任意值到[0, 1]区间(见图 4.3),输出可以解释为概率的东西。

图 4.2:修正线性单元函数

图 4.3:sigmoid 函数

最后,你需要选择一个损失函数和一个优化器。因为你面临的是一个二元分类问题,并且你的模型输出是一个概率(你的模型以一个具有 sigmoid 激活的单单元层结束),最好使用binary_crossentropy损失。这不是唯一可行的选择:例如,你可以使用mean_squared_error。但是,当处理输出概率的模型时,交叉熵通常是最佳选择。交叉熵是信息论领域的一个量,它衡量概率分布之间的距离,在这种情况下,是真实分布和你的预测之间的距离。

关于优化器的选择,我们将采用adam,这对于几乎任何问题来说通常都是一个很好的默认选择。

这里是配置模型使用adam优化器和binary_crossentropy损失函数的步骤。请注意,你将在训练过程中监控准确度。

model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
) 

列表 4.5:编译模型

验证你的方法

正如您在第三章中学到的,深度学习模型永远不应该在其训练数据上评估——使用“验证集”来监控模型在训练期间的准确率是标准做法。在这里,您将通过从原始训练数据中分出 10,000 个样本来创建一个验证集。

您可能会问,为什么不简单地使用 测试 数据来评估模型?这似乎会更容易。原因是您将想要使用在验证集上得到的结果来指导您下一步的选择以改进训练——例如,您选择使用什么模型大小或训练多少个周期。当您开始这样做时,您的验证分数就不再准确反映模型在全新数据上的性能,因为模型已经被故意修改以在验证数据上表现更好。保留一组从未见过的新样本以完全无偏见地执行最终评估回合是很好的,这正是测试集的作用。我们将在下一章中更多地讨论这一点。

x_val = x_train[:10000]
partial_x_train = x_train[10000:]
y_val = y_train[:10000]
partial_y_train = y_train[10000:] 

列表 4.6:设置验证集

现在,您将使用 20 个周期(在训练数据中的所有样本上迭代 20 次)来训练模型,每次批处理 512 个样本。同时,您将监控您分出的 10,000 个样本的损失和准确率。您可以通过将验证数据作为 validation_data 参数传递给 model.fit() 来做到这一点。

history = model.fit(
    partial_x_train,
    partial_y_train,
    epochs=20,
    batch_size=512,
    validation_data=(x_val, y_val),
) 

列表 4.7:训练您的模型

在 CPU 上,这将在每个周期中花费不到 2 秒——训练在 20 秒内完成。在每个周期的末尾,当模型在验证数据的 10,000 个样本上计算其损失和准确率时,会有轻微的暂停。

注意,model.fit() 的调用返回一个 History 对象,正如您在第三章中看到的。该对象有一个 history 成员,它是一个字典,包含有关训练期间发生的一切的数据。让我们来看看它:

>>> history_dict = history.history
>>> history_dict.keys()
dict_keys(["accuracy", "loss", "val_accuracy", "val_loss"])

该字典包含四个条目:每个条目对应于训练期间和验证期间被监控的指标。在接下来的两个列表中,我们将使用 Matplotlib 并排绘制训练和验证损失(见图 4.4),以及训练和验证准确率(见图 4.5)。请注意,由于您自己的模型随机初始化不同,您自己的结果可能会有所不同。

import matplotlib.pyplot as plt

history_dict = history.history
loss_values = history_dict["loss"]
val_loss_values = history_dict["val_loss"]
epochs = range(1, len(loss_values) + 1)
# "r--" is for "dashed red line."
plt.plot(epochs, loss_values, "r--", label="Training loss")
# "b" is for "solid blue line."
plt.plot(epochs, val_loss_values, "b", label="Validation loss")
plt.title("[IMDB] Training and validation loss")
plt.xlabel("Epochs")
plt.xticks(epochs)
plt.ylabel("Loss")
plt.legend()
plt.show() 

列表 4.8:绘制训练和验证损失

图 4.4:训练和验证损失

# Clears the figure
plt.clf()
acc = history_dict["accuracy"]
val_acc = history_dict["val_accuracy"]
plt.plot(epochs, acc, "r--", label="Training acc")
plt.plot(epochs, val_acc, "b", label="Validation acc")
plt.title("[IMDB] Training and validation accuracy")
plt.xlabel("Epochs")
plt.xticks(epochs)
plt.ylabel("Accuracy")
plt.legend()
plt.show() 

列表 4.9:绘制训练和验证准确率

图 4.5:训练和验证准确率

如你所见,随着每个 epoch 的进行,训练损失减少,训练准确率提高。当你运行梯度下降优化时,你应该期望看到这种情况——你试图最小化的量应该随着每次迭代而减少。但是,验证损失和准确率并不是这样:它们似乎在第 4 个 epoch 达到峰值。这是我们之前警告过的一个例子:在训练数据上表现更好的模型并不一定会在之前未见过的数据上表现更好。精确地说,你所看到的是过拟合:在第 4 个 epoch 之后,你对训练数据进行了过度优化,最终学习到的表示只针对训练数据,不能推广到训练集之外的数据。

在这种情况下,为了防止过拟合,你可以在四个 epoch 后停止训练。一般来说,你可以使用一系列技术来减轻过拟合,我们将在第五章中介绍这些技术。

让我们从零开始训练一个新的模型四个 epoch,然后对测试数据进行评估。

model = keras.Sequential(
    [
        layers.Dense(16, activation="relu"),
        layers.Dense(16, activation="relu"),
        layers.Dense(1, activation="sigmoid"),
    ]
)
model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
model.fit(x_train, y_train, epochs=4, batch_size=512)
results = model.evaluate(x_test, y_test) 

列表 4.10:对模型进行四个 epoch 的训练

最终结果如下:

>>> results
# The first number, 0.29, is the test loss, and the second number,
# 0.88, is the test accuracy.
[0.2929924130630493, 0.88327999999999995]

这种相当简单的方法达到了 88%的准确率。使用最先进的方法,你应该能够接近 95%。

使用训练好的模型对新数据进行预测

在训练好一个模型后,你可能会想在实际环境中使用它。你可以通过使用第三章中学到的predict方法来生成评论为正面的可能性:

>>> model.predict(x_test)
array([[ 0.98006207]
       [ 0.99758697]
       [ 0.99975556]
       ...,
       [ 0.82167041]
       [ 0.02885115]
       [ 0.65371346]], dtype=float32)

如你所见,对于某些样本(0.99 或更多,或 0.01 或更少),模型很有信心,但对于其他样本(0.6,0.4)则不太自信。

进一步实验

以下实验将帮助你相信你所做的架构选择都是相当合理的,尽管仍有改进的空间:

  • 在最终分类层之前,你使用了两个表示层。尝试使用一个或三个表示层,看看这样做如何影响验证和测试准确率。

  • 尝试使用具有更多或更少单元的层:32 个单元,64 个单元,依此类推。

  • 尝试使用mean_squared_error损失函数代替binary_crossentropy

  • 尝试使用tanh激活(在神经网络早期很受欢迎的激活函数)代替relu

总结

从这个例子中,你应该吸取以下教训:

  • 你通常需要对原始数据进行相当多的预处理,以便将其作为张量输入到神经网络中。单词序列可以编码为二进制向量,但还有其他编码选项。

  • 带有relu激活的Dense层堆叠可以解决广泛的问题(包括情感分类),你将经常使用它们。

  • 在二元分类问题(两个输出类别)中,你的模型应该以一个具有一个单元和sigmoid激活的Dense层结束:你的模型输出应该是一个介于 0 和 1 之间的标量,表示一个概率。

  • 在二元分类问题上使用这样的标量 sigmoid 输出时,你应该使用的损失函数是binary_crossentropy

  • adam优化器通常是一个足够好的选择,无论你的问题是什么。这样你就少了一件需要担心的事情。

  • 随着神经网络在训练数据上的表现越来越好,它们最终开始过拟合,并在之前从未见过的数据上获得越来越差的结果。务必始终监控训练集之外的数据的性能!

新闻稿分类:多类分类示例

在上一节中,你看到了如何使用密集连接神经网络将向量输入分类到两个互斥的类别。但是,当你有超过两个类别时会发生什么呢?

在本节中,你将构建一个模型,将路透社新闻稿分类到 46 个互斥的主题中。因为你有很多类别,这个问题是一个多类分类的实例,而且由于每个数据点应该只被分类到单个类别,所以这个问题更具体地是一个单标签多类分类的实例。如果你每个数据点可以属于多个类别(在这种情况下,主题),你将面临一个多标签多类分类的问题。

路透社数据集

你将使用路透社数据集,这是一组由路透社在 1986 年发布的简短新闻稿及其主题。它是一个简单且广泛使用的文本分类玩具数据集。共有 46 个不同的主题;有些主题比其他主题更常见,但每个主题在训练集中至少有 10 个示例。

就像 IMDb 和 MNIST 一样,路透社数据集是 Keras 的一部分。让我们看看。

from keras.datasets import reuters

(train_data, train_labels), (test_data, test_labels) = reuters.load_data(
    num_words=10000
) 

列表 4.11:加载路透社数据集

就像 IMDb 数据集一样,num_words=10000参数限制了数据只包含在数据中出现频率最高的 10,000 个单词。

你有 8,982 个训练示例和 2,246 个测试示例:

>>> len(train_data)
8982
>>> len(test_data)
2246

就像 IMDb 评论一样,每个示例都是一个整数列表(单词索引):

>>> train_data[10]
[1, 245, 273, 207, 156, 53, 74, 160, 26, 14, 46, 296, 26, 39, 74, 2979,
3554, 14, 46, 4689, 4329, 86, 61, 3499, 4795, 14, 61, 451, 4329, 17, 12]

这里是如何将其解码回单词的,以防你感兴趣。

word_index = reuters.get_word_index()
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])
decoded_newswire = " ".join(
    # The indices are offset by 3 because 0, 1, and 2 are reserved
    # indices for "padding," "start of sequence," and "unknown."
    [reverse_word_index.get(i - 3, "?") for i in train_data[10]]
) 

列表 4.12:将新闻稿解码回文本

与示例相关的标签是一个介于 0 到 45 之间的整数——一个主题索引:

>>> train_labels[10]
3

数据准备

你可以使用与上一个示例完全相同的代码来向量化数据。

# Vectorized training data
x_train = multi_hot_encode(train_data, num_classes=10000)
# Vectorized test data
x_test = multi_hot_encode(test_data, num_classes=10000) 

列表 4.13:编码输入数据

为了向量化标签,有两种可能性:你可以保持标签不变作为整数,或者你可以使用 one-hot encoding。One-hot encoding 是一种广泛使用的分类数据格式,也称为 分类编码。在这种情况下,标签的 one-hot encoding 是将每个标签嵌入为一个全零向量,标签索引的位置为 1。以下是一个示例。

def one_hot_encode(labels, num_classes=46):
    results = np.zeros((len(labels), num_classes))
    for i, label in enumerate(labels):
        results[i, label] = 1.0
    return results

# Vectorized training labels
y_train = one_hot_encode(train_labels)
# Vectorized test labels
y_test = one_hot_encode(test_labels) 

列表 4.14:编码标签

注意,在 Keras 中有内置的方式来完成这个操作:

from keras.utils import to_categorical

y_train = to_categorical(train_labels)
y_test = to_categorical(test_labels) 

构建你的模型

这个主题分类问题看起来与先前的电影评论分类问题相似:在两种情况下,你都在尝试对短文本片段进行分类。但这里有一个新的约束:输出类别的数量从 2 增加到 46。输出空间的维度要大得多。

在像你一直在使用的 Dense 层堆叠中,每一层只能访问前一层输出的信息。如果一个层丢失了与分类问题相关的某些信息,这些信息将永远无法被后续层恢复:每一层都可能成为一个信息瓶颈。在先前的例子中,你使用了 16 维的中间层,但 16 维的空间可能太小,无法学习区分 46 个不同的类别:这样的小层可能充当信息瓶颈,永久性地丢失相关信息。

因此,你会使用更大的中间层。让我们使用 64 个单位。

model = keras.Sequential(
    [
        layers.Dense(64, activation="relu"),
        layers.Dense(64, activation="relu"),
        layers.Dense(46, activation="softmax"),
    ]
) 

列表 4.15:模型定义

关于这个架构,还有两件事你应该注意:

  • 你以一个大小为 46 的 Dense 层结束模型。这意味着对于每个输入样本,网络将输出一个 46 维的向量。这个向量中的每个条目(每个维度)将编码一个不同的输出类别。

  • 最后一层使用 softmax 激活函数。你在 MNIST 示例中见过这种模式。这意味着模型将在 46 个不同的输出类别上输出一个 概率分布 —— 对于每个输入样本,模型将产生一个 46 维的输出向量,其中 output[i] 是样本属于类别 i 的概率。这 46 个分数加起来等于 1。

在这个情况下,最佳损失函数是 categorical_crossentropy。它衡量两个概率分布之间的距离——在这里,是模型输出的概率分布和标签真实分布之间的距离。通过最小化这两个分布之间的距离,你训练模型输出尽可能接近真实标签的内容。

和上次一样,我们也会监控准确率。然而,在这个案例中,准确率是一个相当粗略的指标:如果模型对一个样本的第二选择是正确的类别,而第一个选择是错误的,那么模型在这个样本上的准确率仍然是零——即使这样的模型会比随机猜测好得多。在这种情况下,一个更细致的指标是 top-k 准确率,比如 top-3 或 top-5 准确率。它衡量正确的类别是否在模型的 top-k 预测中。让我们将 top-3 准确率添加到我们的模型中。

top_3_accuracy = keras.metrics.TopKCategoricalAccuracy(
    k=3, name="top_3_accuracy"
)
model.compile(
    optimizer="adam",
    loss="categorical_crossentropy",
    metrics=["accuracy", top_3_accuracy],
) 

代码列表 4.16:编译模型

验证你的方法

让我们在训练数据中留出 1,000 个样本作为验证集。

x_val = x_train[:1000]
partial_x_train = x_train[1000:]
y_val = y_train[:1000]
partial_y_train = y_train[1000:] 

代码列表 4.17:留出验证集

现在,让我们训练模型 20 个周期。

history = model.fit(
    partial_x_train,
    partial_y_train,
    epochs=20,
    batch_size=512,
    validation_data=(x_val, y_val),
) 

代码列表 4.18:训练模型

最后,让我们显示其损失和准确率曲线(见图 4.6 和 4.7)。

loss = history.history["loss"]
val_loss = history.history["val_loss"]
epochs = range(1, len(loss) + 1)
plt.plot(epochs, loss, "r--", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.xlabel("Epochs")
plt.xticks(epochs)
plt.ylabel("Loss")
plt.legend()
plt.show() 

代码列表 4.19:绘制训练和验证损失

图片

图 4.6:训练和验证损失

plt.clf()
acc = history.history["accuracy"]
val_acc = history.history["val_accuracy"]
plt.plot(epochs, acc, "r--", label="Training accuracy")
plt.plot(epochs, val_acc, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.xlabel("Epochs")
plt.xticks(epochs)
plt.ylabel("Accuracy")
plt.legend()
plt.show() 

代码列表 4.20:绘制训练和验证 top-3 准确率

图片

图 4.7:训练和验证准确率

plt.clf()
acc = history.history["top_3_accuracy"]
val_acc = history.history["val_top_3_accuracy"]
plt.plot(epochs, acc, "r--", label="Training top-3 accuracy")
plt.plot(epochs, val_acc, "b", label="Validation top-3 accuracy")
plt.title("Training and validation top-3 accuracy")
plt.xlabel("Epochs")
plt.xticks(epochs)
plt.ylabel("Top-3 accuracy")
plt.legend()
plt.show() 

代码列表 4.21:绘制训练和验证 top-3 准确率

图片

图 4.8:训练和验证准确率

模型在第九个周期后开始过拟合。让我们从头开始训练一个新的模型九个周期,然后在测试集上评估它。

model = keras.Sequential(
    [
        layers.Dense(64, activation="relu"),
        layers.Dense(64, activation="relu"),
        layers.Dense(46, activation="softmax"),
    ]
)
model.compile(
    optimizer="adam",
    loss="categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    x_train,
    y_train,
    epochs=9,
    batch_size=512,
)
results = model.evaluate(x_test, y_test) 

代码列表 4.22:从头开始重新训练模型

这里是最终结果:

>>> results
[0.9565213431445807, 0.79697239536954589]

这种方法达到了大约 80% 的准确率。对于一个平衡的二分类问题,纯随机分类器达到的准确率将是 50%。但在这个案例中,我们有 46 个类别,它们可能并不均衡地被表示。随机基线的准确率会是多少?我们可以快速实现一个来验证这一点:

>>> import copy
>>> test_labels_copy = copy.copy(test_labels)
>>> np.random.shuffle(test_labels_copy)
>>> hits_array = np.array(test_labels == test_labels_copy)
>>> hits_array.mean()
0.18655387355298308

如您所见,一个随机分类器的大约分类准确率为 19%,因此从这个角度看,我们模型的成果看起来相当不错。

在新数据上生成预测

在新样本上调用模型的 predict 方法返回每个样本对所有 46 个主题的类别概率分布。让我们为所有测试数据生成主题预测:

predictions = model.predict(x_test) 

“预测”中的每个条目都是一个长度为 46 的向量:

>>> predictions[0].shape
(46,)

这个向量中的系数之和为 1,因为它们形成了一个概率分布:

>>> np.sum(predictions[0])
1.0

最大的条目是预测的类别——概率最高的类别:

>>> np.argmax(predictions[0])
4

处理标签和损失的不同方法

我们之前提到,另一种编码标签的方法是保持它们不变,作为整数张量,如下所示:

y_train = train_labels
y_test = test_labels 

这种方法唯一改变的是损失函数的选择。列表 4.22 中使用的损失函数categorical_crossentropy期望标签遵循分类编码。对于整数标签,您应使用sparse_categorical_crossentropy

model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
) 

这个新的损失函数在数学上仍然与categorical_crossentropy相同;它只是有一个不同的接口。

拥有足够大的中间层的重要性

我们之前提到,由于最终输出是 46 维的,您应该避免具有少于 46 个单位的中间层。现在让我们看看当您通过具有显著小于 46 维的中间层引入信息瓶颈时会发生什么:例如,4 维。

model = keras.Sequential(
    [
        layers.Dense(64, activation="relu"),
        layers.Dense(4, activation="relu"),
        layers.Dense(46, activation="softmax"),
    ]
)
model.compile(
    optimizer="adam",
    loss="categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    partial_x_train,
    partial_y_train,
    epochs=20,
    batch_size=128,
    validation_data=(x_val, y_val),
) 

列表 4.23:具有信息瓶颈的模型

模型现在在约 71%的验证准确率处达到峰值,下降了 8%。这种下降主要是由于您试图将大量信息(足够的信息来恢复 46 个类的分离超平面)压缩到一个维度太低的中间空间。模型能够将这些必要信息中的大部分压缩到这些 4 维表示中,但并非全部。

进一步实验

与前一个示例类似,我们鼓励您尝试以下实验,以训练您对这些模型必须做出的配置决策的直觉:

  • 尝试使用更大或更小的层:32 个单位,128 个单位,等等。

  • 您在最终的 softmax 分类层之前使用了两个中间层。现在尝试使用一个中间层,或者三个中间层。

总结

您应该从这个示例中吸取以下教训:

  • 如果您试图在N个类别中分类数据点,您的模型应以大小为NDense层结束。

  • 在单标签多类分类问题中,您的模型应以softmax激活结束,以便它将输出一个关于N个输出类别的概率分布。

  • 分类交叉熵几乎总是您应该用于此类问题的损失函数。它最小化模型输出的概率分布与目标真实分布之间的距离。

  • 在多类分类中处理标签有两种方法:

    • 通过分类编码(也称为独热编码)对标签进行编码,并使用categorical_crossentropy作为损失函数

    • 将标签编码为整数并使用sparse_categorical_crossentropy损失函数

  • 如果您需要将数据分类到大量类别中,您应该避免由于中间层太小而在模型中创建信息瓶颈。

预测房价:回归示例

前两个例子被认为是分类问题,目标是预测输入数据点的单个离散标签。另一种常见的机器学习问题是 回归,它由预测连续值而不是离散标签组成:例如,根据气象数据预测明天的温度,或者根据软件项目的规格预测完成项目所需的时间。

加利福尼亚房价数据集

您将尝试根据 1990 年人口普查的数据预测加利福尼亚不同地区的房屋中位数价格。

数据集中的每个数据点代表一个“街区组”的信息,这是一个位于同一区域的住宅群组。您可以将其视为一个区域。这个数据集有两个版本,一个是只有 600 个区域的“小”版本,另一个是包含 20,640 个区域的“大”版本。让我们使用小版本,因为现实世界的数据集通常可能非常小,您需要知道如何处理这种情况。

对于每个区域,我们知道

  • 该区域大约地理中心的经纬度。

  • 该区域房屋的中位数年龄。

  • 该区域的人口。这些区域相当小:平均人口为 1,425.5。

  • 家庭总数。

  • 这些家庭的中位数收入。

  • 该区域所有房屋的总房间数。这通常在几千以下。

  • 该区域卧室总数。

总共有八个变量(经纬度算作两个变量)。目标是使用这些变量来预测该区域房屋的中位数价格。让我们通过加载数据开始吧。

from keras.datasets import california_housing

# Make sure to pass version="small" to get the right dataset.
(train_data, train_targets), (test_data, test_targets) = (
    california_housing.load_data(version="small")
) 

列表 4.24:加载加利福尼亚房价数据集

让我们看看数据:

>>> train_data.shape
(480, 8)
>>> test_data.shape
(120, 8)

如您所见,我们有 480 个训练样本和 120 个测试样本,每个样本包含 8 个数值特征。目标是预测该区域考虑的房屋的中位数价格,单位为美元:

>>> train_targets
array([252300., 146900., 290900., ..., 140500., 217100.],
      dtype=float32)

价格在 60,000 美元到 500,000 美元之间。如果听起来很便宜,请记住这是在 1990 年,这些价格没有考虑通货膨胀。

准备数据

将所有范围差异很大的值输入神经网络可能会出现问题。模型可能能够自动适应这种异构数据,但这肯定会使学习更加困难。处理此类数据的一个普遍最佳实践是进行特征归一化:对于输入数据中的每个特征(输入数据矩阵中的一列),您从特征的平均值中减去,然后除以标准差,这样特征就围绕 0 对齐,并且具有单位标准差。这可以在 NumPy 中轻松完成。

mean = train_data.mean(axis=0)
std = train_data.std(axis=0)
x_train = (train_data - mean) / std
x_test = (test_data - mean) / std 

列表 4.25:数据归一化

注意,用于归一化测试数据的量是通过训练数据计算的。你永远不应该在你的工作流程中使用在测试数据上计算的任何量,即使是像数据归一化这样简单的事情。

此外,我们还应该缩放目标值。我们的归一化输入值在一个接近 0 的小范围内,我们的模型权重是用小的随机值初始化的。这意味着当我们开始训练时,我们的模型预测也将是小的值。如果目标值在 60,000–500,000 的范围内,模型将需要非常大的权重值来输出这些值。使用小的学习率,到达那里将需要非常长的时间。最简单的修复方法是除以所有目标值 100,000,这样最小的目标值变为 0.6,最大的变为 5。然后我们可以通过相应地乘以 100,000 将模型的预测值转换回美元值。

y_train = train_targets / 100000
y_test = test_targets / 100000 

列表 4.26:缩放目标值

构建你的模型

由于可用的样本非常少,你将使用一个非常小的模型,包含两个中间层,每个层有 64 个单元。一般来说,你拥有的训练数据越少,过拟合的风险就越大,使用小模型是减轻过拟合的一种方法。

def get_model():
    # Because you need to instantiate the same model multiple times,
    # you use a function to construct it.
    model = keras.Sequential(
        [
            layers.Dense(64, activation="relu"),
            layers.Dense(64, activation="relu"),
            layers.Dense(1),
        ]
    )
    model.compile(
        optimizer="adam",
        loss="mean_squared_error",
        metrics=["mean_absolute_error"],
    )
    return model 

列表 4.27:模型定义

模型以单个单元和没有激活函数结束:它将是一个线性层。这是标量回归的典型设置——你试图预测一个单一连续值。应用激活函数会限制输出可以取的范围;例如,如果你在最后一层应用 sigmoid 激活函数,模型只能学习预测介于 0 和 1 之间的值。在这里,因为最后一层完全是线性的,模型可以自由地学习预测任何范围内的值。

注意,你使用 mean_squared_error 损失函数来编译模型——均方误差,预测值与目标值之间差异的平方。这是回归问题中广泛使用的损失函数。

你还正在监控训练过程中的一个新指标:平均绝对误差(MAE)。它是预测值与目标值之间差异的绝对值。例如,这个问题上的 MAE 为 0.5 意味着你的预测平均偏离目标值 50,000(记住目标缩放因子为 100,000)。

使用 K 折交叉验证来验证你的方法

当你调整模型的参数(如训练使用的 epoch 数量)时,评估你的模型,你可以将数据分成训练集和验证集,就像之前的例子中做的那样。但由于数据点很少,验证集最终会非常小(例如,大约 100 个示例)。结果,验证分数可能会根据你选择用于验证和训练的数据点而大幅变化:验证分数可能会与验证分割有很大的 方差。这会阻止你可靠地评估你的模型。

在这种情况下,最佳实践是使用 K 折 交叉验证(见图 4.9)。它包括将可用数据分成 K 个分区(通常 K = 4 或 5),实例化 K 个相同的模型,并在 K - 1 个分区上训练每个模型,同时在剩余的分区上进行评估。然后,用于模型的验证分数是获得的 K 个验证分数的平均值。从代码的角度来看,这是直截了当的。

图 4.9:三折交叉验证

k = 4
num_val_samples = len(x_train) // k
num_epochs = 50
all_scores = []
for i in range(k):
    print(f"Processing fold #{i + 1}")
    # Prepares the validation data: data from partition #k
    fold_x_val = x_train[i * num_val_samples : (i + 1) * num_val_samples]
    fold_y_val = y_train[i * num_val_samples : (i + 1) * num_val_samples]
    # Prepares the training data: data from all other partitions
    fold_x_train = np.concatenate(
        [x_train[: i * num_val_samples], x_train[(i + 1) * num_val_samples :]],
        axis=0,
    )
    fold_y_train = np.concatenate(
        [y_train[: i * num_val_samples], y_train[(i + 1) * num_val_samples :]],
        axis=0,
    )
    # Builds the Keras model (already compiled)
    model = get_model()
    # Trains the model
    model.fit(
        fold_x_train,
        fold_y_train,
        epochs=num_epochs,
        batch_size=16,
        verbose=0,
    )
    # Evaluates the model on the validation data
    scores = model.evaluate(fold_x_val, fold_y_val, verbose=0)
    val_loss, val_mae = scores
    all_scores.append(val_mae) 

代码列表 4.28:K 折验证

使用 num_epochs = 50 运行此代码得到以下结果:

>>> [round(value, 3) for value in all_scores]
[0.298, 0.349, 0.232, 0.305]
>>> round(np.mean(all_scores), 3)
0.296

不同的运行确实显示了有意义的不同的验证分数,从 0.232 到 0.349。平均数(0.296)比任何单个分数都更可靠——这正是 K 折交叉验证的全部意义。在这种情况下,平均误差为 $29,600,考虑到价格范围在 $60,000 到 $500,000 之间,这是一个显著的数值。

让我们尝试训练模型更长一些:200 个 epoch。为了记录模型在每个 epoch 的表现,你需要修改训练循环以保存每个 epoch 的验证分数日志。

k = 4
num_val_samples = len(x_train) // k
num_epochs = 200
all_mae_histories = []
for i in range(k):
    print(f"Processing fold #{i + 1}")
    # Prepares the validation data: data from partition #k
    fold_x_val = x_train[i * num_val_samples : (i + 1) * num_val_samples]
    fold_y_val = y_train[i * num_val_samples : (i + 1) * num_val_samples]
    # Prepares the training data: data from all other partitions
    fold_x_train = np.concatenate(
        [x_train[: i * num_val_samples], x_train[(i + 1) * num_val_samples :]],
        axis=0,
    )
    fold_y_train = np.concatenate(
        [y_train[: i * num_val_samples], y_train[(i + 1) * num_val_samples :]],
        axis=0,
    )
    # Builds the Keras model (already compiled)
    model = get_model()
    # Trains the model
    history = model.fit(
        fold_x_train,
        fold_y_train,
        validation_data=(fold_x_val, fold_y_val),
        epochs=num_epochs,
        batch_size=16,
        verbose=0,
    )
    mae_history = history.history["val_mean_absolute_error"]
    all_mae_histories.append(mae_history) 

代码列表 4.29:在每个折叠中保存验证日志

然后,你可以计算所有折叠的每个 epoch 均值绝对误差(MAE)分数的平均值。

average_mae_history = [
    np.mean([x[i] for x in all_mae_histories]) for i in range(num_epochs)
] 

代码列表 4.30:构建连续的 K 折验证平均分数的历史记录

让我们绘制这个图表;见图 4.10。

epochs = range(1, len(average_mae_history) + 1)
plt.plot(epochs, average_mae_history)
plt.xlabel("Epochs")
plt.ylabel("Validation MAE")
plt.show() 

代码列表 4.31:绘制验证分数

图 4.10:按 epoch 验证的 MAE

由于缩放问题,可能难以阅读图表:前几个 epoch 的验证 MAE 比后续的值高得多。让我们省略前 10 个数据点,这些数据点与曲线的其余部分处于不同的尺度。

truncated_mae_history = average_mae_history[10:]
epochs = range(10, len(truncated_mae_history) + 10)
plt.plot(epochs, truncated_mae_history)
plt.xlabel("Epochs")
plt.ylabel("Validation MAE")
plt.show() 

代码列表 4.32:绘制验证分数,排除前 10 个数据点

图 4.11:按 epoch 验证的 MAE,排除前 10 个数据点

根据这个图(见图 4.11),验证 MAE 在 120-140 个周期后(这个数字包括我们省略的 10 个周期)不再显著提高。超过这个点,你开始过拟合。

一旦你完成了模型其他参数的调整(除了周期数,你还可以调整中间层的大小),你可以在所有训练数据上使用最佳参数训练一个最终的生产模型,然后查看其在测试数据上的性能。

# Gets a fresh, compiled model
model = get_model()
# Trains it on the entirety of the data
model.fit(x_train, y_train, epochs=130, batch_size=16, verbose=0)
test_mean_squared_error, test_mean_absolute_error = model.evaluate(
    x_test, y_test
) 

列表 4.33:训练最终模型

这是最终结果:

>>> round(test_mean_absolute_error, 3)
0.31

我们平均偏差约为 $31,000。

在新数据上生成预测

当在二分类模型上调用 predict() 时,我们为每个输入样本检索了一个介于 0 和 1 之间的标量分数。对于我们的多类分类模型,我们为每个样本检索了所有类别的概率分布。现在,使用这个标量回归模型,predict() 返回模型对样本价格的猜测,以十万美元为单位:

>>> predictions = model.predict(x_test)
>>> predictions[0]
array([2.834494], dtype=float32)

测试集中的第一个区域预测的平均房价约为 $283,000。

总结

这是你应该从这个标量回归示例中吸取的教训:

  • 回归使用与分类不同的损失函数。均方误差(MSE)是回归中常用的损失函数。

  • 类似地,用于回归的评估指标与用于分类的指标不同;自然地,准确性的概念不适用于回归。MAE 是一个常见的回归指标。

  • 当输入数据中的特征具有不同范围的价值时,每个特征应该作为预处理步骤独立缩放。

  • 当可用的数据很少时,使用 K 折验证是可靠评估模型的好方法。

  • 当可用的训练数据很少时,最好使用具有少量中间层(通常只有一个或两个)的小型模型,以避免严重的过拟合。

摘要

  • 在向量数据上最常见的三种机器学习任务是二分类、多类分类和标量回归。每个任务使用不同的损失函数:

    • binary_crossentropy 用于二分类

    • categorical_crossentropy 用于多类分类

    • mean_squared_error 用于标量回归

  • 在将原始数据输入神经网络之前,你通常需要预处理原始数据。

  • 当你的数据具有不同范围的特征时,作为预处理的一部分,独立地对每个特征进行缩放。

  • 随着训练的进行,神经网络最终开始过拟合,并在从未见过的数据上获得更差的结果。

  • 如果你没有太多训练数据,使用只有一个或两个中间层的较小模型,以避免严重的过拟合。

  • 如果你的数据分为许多类别,如果你使中间层太小,可能会导致信息瓶颈。

  • 当你处理少量数据时,K 折交叉验证可以帮助你可靠地评估你的模型。

第五章:机器学习基础

原文:deeplearningwithpython.io/chapters/chapter05_fundamentals-of-ml

在第四章的三个实际例子之后,你应该开始熟悉如何使用神经网络来处理分类和回归问题,并且你已经见证了机器学习的核心问题:过拟合。本章将把你对机器学习的新直觉正式化为一个坚实的概念框架,强调准确模型评估和训练与泛化之间平衡的重要性。

泛化:机器学习的目标

在第四章中提出的三个例子——预测电影评论、主题分类和房价回归中,我们将数据分为训练集、验证集和测试集。不要在训练模型的数据上评估模型的原因很快变得明显:仅仅经过几个 epoch,从未见过的数据上的性能就开始与训练数据上的性能分离,而训练数据随着训练的进行总是提高。模型开始过拟合。过拟合发生在每个机器学习问题中。

机器学习的核心问题是优化和泛化之间的张力。优化指的是调整模型以在训练数据上获得最佳性能的过程(在机器学习中的学习),而泛化指的是训练好的模型在之前未见过的数据上的表现。当然,游戏的目标是获得良好的泛化,但你无法控制泛化;你只能使模型适应其训练数据。如果你做得太好了,过拟合就会发生,泛化就会受到影响。

但是什么导致了过拟合?我们如何实现良好的泛化?

欠拟合和过拟合

对于你在上一章中看到的所有模型,随着训练的进行,在保留的验证数据上的性能最初会提高,然后不可避免地会在一段时间后达到顶峰。这种模式(如图 5.1 所示)是普遍存在的。你会在任何模型类型和任何数据集上看到它。

图 5.1:典型的过拟合行为

在训练的初期,优化和泛化是相关的:训练数据上的损失越低,测试数据上的损失也越低。在这个过程中,你的模型被认为是欠拟合的:还有进步的空间;网络还没有对训练数据中的所有相关模式进行建模。但经过一定次数的训练数据迭代后,泛化不再提高,验证指标停滞并开始下降:模型开始过拟合。也就是说,它开始学习特定于训练数据的模式,但这些模式在处理新数据时可能是误导性的或不相关的。

当数据存在噪声、涉及不确定性或包含罕见特征时,过拟合尤其可能发生。让我们看看具体的例子。

噪声训练数据

在现实世界的数据集中,某些输入无效的情况相当常见。例如,MNIST 数字可能是一张全黑的图像,或者类似于图 5.2 中的某些东西。

图 5.2:一些非常奇怪的 MNIST 训练样本

这些是什么?我们也不知道。但它们都是 MNIST 训练集的一部分。然而,更糟糕的是,有些完全有效的输入最终被错误标注,如图 5.3 中所示。

图 5.3:错误标注的 MNIST 训练样本

如果一个模型特意包含这样的异常值,其泛化性能将下降,如图 5.4 所示。例如,一个看起来非常接近图 5.3 中错误标注的 4 的 4,最终可能被归类为 9。

图 5.4:处理异常值:鲁棒拟合与过拟合

模糊特征

并非所有数据噪声都源于不准确——即使非常干净且标签清晰的 数据,当问题涉及不确定性和模糊性时也可能存在噪声(见图 5.5)。在分类任务中,输入特征空间的一些区域可能同时与多个类别相关联。假设你正在开发一个模型,该模型接收香蕉的图像并预测香蕉是否未成熟、成熟或腐烂。这些类别没有客观的边界,因此同一张图片可能被不同的标注人员分别归类为未成熟或成熟。同样,许多问题涉及随机性。你可以使用大气压力数据来预测明天是否会下雨,但确切的测量结果有时会伴随着降雨,有时则是晴朗的天空——带有一定的概率。

图 5.5:鲁棒拟合与过拟合在特征空间中给出的模糊区域

一个模型可能会通过过于自信地处理特征空间的模糊区域(如图 5.6 所示)而对这样的概率数据进行过拟合。更鲁棒的拟合将忽略个别数据点,并关注更大的图景。

罕见特征和虚假相关性

如果你一生中只见过两只橙色虎斑猫,而且它们都极其反社会,你可能会推断橙色虎斑猫通常可能很反社会。这是过拟合:如果你接触过更多种类的猫,包括更多橙色猫,你会了解到猫的颜色与性格并不密切相关。

同样,在包含罕见特征值的训练数据集上训练的机器学习模型很容易过拟合。在一个情感分类任务中,如果单词“cherimoya”(一种安第斯山脉的本土水果)仅在训练数据中的一篇文本中出现,而这篇文本恰好是负面的情感,那么一个欠规范的模型可能会给这个单词赋予非常高的权重,并总是将提及 cherimoyas 的新文本分类为负面,而实际上,cherimoya 并没有任何负面之处。^([1])

重要的是,一个特征值不需要只出现几次就会导致虚假相关性。考虑一个在训练数据中出现 100 次的单词,54% 的时间与正面情感相关,46% 的时间与负面情感相关。这种差异可能完全是完全的统计巧合,但你的模型很可能会学会使用这个特征来进行其分类任务。这是过拟合最常见的原因之一。

这里有一个引人注目的例子。以 MNIST 为例。通过将 784 个白噪声维度连接到现有数据的 784 个维度上创建一个新的训练集——因此现在一半的数据是噪声。为了比较,还创建了一个通过连接 784 个全零维度等效的数据集。我们添加的无意义特征并没有影响数据的信含量:我们只是在添加无关的数据点。人类的分类准确率根本不会受到这些转换的影响。

from keras.datasets import mnist
import numpy as np

(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255

train_images_with_noise_channels = np.concatenate(
    [train_images, np.random.random((len(train_images), 784))], axis=1
)

train_images_with_zeros_channels = np.concatenate(
    [train_images, np.zeros((len(train_images), 784))], axis=1
) 

列表 5.1:向 MNIST 添加白噪声通道或全零通道

现在,让我们在这两个训练集上训练第二章中的模型。

import keras
from keras import layers

def get_model():
    model = keras.Sequential(
        [
            layers.Dense(512, activation="relu"),
            layers.Dense(10, activation="softmax"),
        ]
    )
    model.compile(
        optimizer="adam",
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model

model = get_model()
history_noise = model.fit(
    train_images_with_noise_channels,
    train_labels,
    epochs=10,
    batch_size=128,
    validation_split=0.2,
)

model = get_model()
history_zeros = model.fit(
    train_images_with_zeros_channels,
    train_labels,
    epochs=10,
    batch_size=128,
    validation_split=0.2,
) 

列表 5.2:在 MNIST 数据上使用噪声通道或全零通道训练相同的模型

尽管两种情况下的数据都包含相同的信息,但使用噪声通道训练的模型的验证准确率最终会低约一个百分点——纯粹是通过虚假相关性的影响(图 5.6)。你添加的噪声通道越多,准确率下降得越厉害。

图 5.6:噪声通道对验证准确率的影响

噪声特征不可避免地会导致过拟合。因此,在你不确定你拥有的特征是有信息量还是分散注意力的特征时,在训练之前进行特征选择是很常见的。例如,将 IMDB 数据限制在最常见的 10,000 个单词就是一种粗略的特征选择方法。进行特征选择的典型方法是为每个可用的特征计算一些有用性分数——这是特征相对于任务的信度度量,例如特征与标签之间的互信息——并且只保留高于某个阈值的特征。这样做将过滤掉前面例子中的白噪声通道。

深度学习中泛化的本质

深度学习模型的一个显著事实是,只要它们有足够的表达能力,就可以训练它们去适应任何事物。

你不相信吗?试着打乱 MNIST 标签的顺序,并在那个数据上训练一个模型。即使输入和打乱后的标签之间没有任何关系,训练损失也会相应下降,即使是一个相对较小的模型也是如此。自然地,随着时间的推移,验证损失根本不会提高,因为在这种情况下没有泛化的可能性。

(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255

# Copies train_labels
random_train_labels = train_labels[:]
np.random.shuffle(random_train_labels)

model = keras.Sequential(
    [
        layers.Dense(512, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    train_images,
    random_train_labels,
    epochs=100,
    batch_size=128,
    validation_split=0.2,
) 

列表 5.3:使用随机打乱标签拟合 MNIST 模型

实际上,你甚至不需要用 MNIST 数据来做这件事——你只需要生成白噪声输入和随机标签。只要模型有足够的参数,你也能在上面拟合一个模型。它最终会记住特定的输入,就像 Python 字典一样。

如果是这样的话,那么为什么深度学习模型能够泛化呢?它们不应该只是学习训练输入和目标之间的一个临时的映射,就像一个花哨的dict吗?我们对此映射能够适用于新输入有什么期望?

实际上,深度学习中泛化的本质与深度学习模型本身关系不大,而与现实世界中信息结构有很大关系。让我们看看这里真正发生了什么。

流形假设

MNIST 分类器的输入(在预处理之前)是一个 28 × 28 的整数数组,其值介于 0 到 255 之间。因此,可能的输入值的总数是 256 的 784 次方——远大于宇宙中的原子数。然而,其中只有极少数的输入看起来像有效的 MNIST 样本:实际的手写数字只占据了所有可能的 28 x 28 uint8数组父空间的一个微小的子空间。更重要的是,这个子空间不仅仅是在父空间中随机散布的点集:它具有高度的结构性。

首先,有效手写数字的子空间是连续的:如果你取一个样本并稍作修改,它仍然可以被识别为相同的手写数字。进一步地,所有有效子空间中的样本都通过在子空间中运行的平滑路径连接在一起。这意味着如果你取两个随机的 MNIST 数字 A 和 B,存在一系列“中间”图像,将 A 逐渐变形为 B,使得两个连续的数字非常接近(见图 5.7)。也许在两个类别之间的边界附近会有一些模糊的形状,但这些形状仍然非常像数字。

流形假设图

图 5.7:不同的 MNIST 数字逐渐变形为彼此,显示了手写数字空间形成“流形”。此图像使用第十七章中的代码生成。

从技术角度来说,你会说手写数字在可能的 28 × 28 uint8数组空间中形成一个流形。这是一个很大的词,但概念相当直观。流形是某个父空间中的低维子空间,在局部上与线性(欧几里得)空间相似。例如,平面上的光滑曲线是二维空间中的一维流形,因为对于曲线上的每一个点,你都可以画一条切线(曲线可以在每个点被近似为一条线)。三维空间中的光滑表面是一个二维流形。以此类推。

更普遍地说,流形假设认为所有自然数据都位于其编码的高维空间中的低维流形上。这是一个关于宇宙中信息结构的相当强烈的陈述。据我们所知,这是准确的,也是深度学习之所以有效的原因。这适用于 MNIST 数字,也适用于人脸、树木形态、人类的声音,甚至自然语言。

流形假设意味着

  • 机器学习模型只需要在其潜在输入空间(潜在流形)中拟合相对简单、低维、高度结构化的子空间。

  • 在这些流形中的任何一个,总是在两个输入之间进行插值——也就是说,通过一条所有点都位于流形上的连续路径,将一个形态转换为另一个形态。

在样本之间进行插值的能力是理解深度学习中泛化的关键。

插值作为泛化的来源

如果你处理的是可以进行插值的数据点,你可以通过将它们与流形上靠近的其他点相关联,开始理解你以前从未见过的点。换句话说,你只需要使用空间的一个样本就能理解空间的整体。你可以使用插值来填补空白。

注意,潜在流形上的插值与父空间中的线性插值不同,如图 5.8 所示。例如,两个 MNIST 数字之间的像素平均值通常不是一个有效的数字。

图 5.8:线性插值与潜在流形插值的区别。数字的潜在流形上的每个点都是一个有效的数字,但两个数字的平均值通常不是。

关键的是,虽然深度学习通过在数据流形的近似上插值来实现泛化,但认为插值就是泛化的全部是错误的。这只是冰山一角。插值只能帮助你理解非常接近你之前所见的事物:它实现了局部泛化。但令人惊讶的是,人类经常处理极端的新奇事物,而且他们做得很好。你不需要提前在无数种你将遇到的情境的例子上进行训练。你每一天都是不同于你之前经历过的任何一天,也不同于自人类诞生以来任何人经历过的任何一天。你可以在这三个城市中任意切换:在纽约市度过一周,在上海度过一周,在班加罗尔度过一周,而不需要为每个城市进行数千年的学习和排练。

人类能够进行极端泛化,这是由除了插值之外的认知机制所实现的——抽象、世界的符号模型、推理、逻辑、常识、关于世界的先验知识——我们通常称之为理性,与直觉和模式识别相对。后者在很大程度上是插值的,但前者不是。两者对智能都是必不可少的。我们将在第十九章中更多地讨论这一点。

为什么深度学习有效

记得第二章中的皱巴巴的纸团隐喻吗?一张纸代表三维空间中的一个二维流形(图 5.9)。深度学习模型是展开纸团的工具——也就是说,是解开潜在流形的工具。

图片

图 5.9:解开复杂数据流形

深度学习模型基本上是一个非常高维的曲线。这条曲线是平滑且连续的(其结构受到模型架构先验的额外约束),因为它需要可微。而且这条曲线是通过梯度下降来拟合数据点的——平滑且逐步地。按照构造,深度学习是关于取一个大而复杂的曲线——一个流形——并逐步调整其参数,直到它适合某些训练数据点。

这条曲线包含足够的参数,可以拟合任何事物。事实上,如果你让你的模型训练足够长的时间,它实际上最终会纯粹地记住其训练数据,而不会进行任何泛化。然而,你正在拟合的数据不是由在潜在空间中稀疏分布的孤立点组成的。你的数据在输入空间中形成一个高度结构化、低维的流形——这就是流形假设。而且因为随着梯度下降的进行,拟合模型曲线到数据的过程是逐渐且平滑的,所以在训练过程中会有一个中间点,此时模型大致近似数据的自然流形,如图 5.10 所示。

图片

图 5.10:从随机模型过渡到过拟合模型,并实现作为中间状态的鲁棒拟合

沿着模型在该点学习的曲线移动将接近沿着数据的实际潜在流形移动。因此,该模型将能够通过在训练输入之间进行插值来理解从未见过的输入。

除了它们有足够的表示能力这一显而易见的事实之外,深度学习模型还有一些特性使它们特别适合学习潜在流形:

  • 深度学习模型实现了从输入到输出的平滑、连续映射。它必须平滑且连续,因为它是可微分的(否则无法进行梯度下降)。这种平滑性有助于近似潜在流形,这些流形遵循相同的属性。

  • 深度学习模型往往以反映其训练数据中“形状”的方式(通过架构先验)进行结构化。这尤其适用于图像处理模型(参见第 8-12 章)和序列处理模型(参见第十三章)。更普遍地说,深度神经网络以分层和模块化的方式结构化其学习到的表示,这与自然数据的组织方式相呼应。

训练数据至关重要

虽然深度学习确实非常适合进行流形学习,但泛化的能力更多的是数据自然结构的结果,而不是模型任何属性的结果。只有当你的数据形成一个点可以插值的流形时,你才能进行泛化。你的特征越有信息性、噪声越少,你将能够更好地进行泛化,因为你的输入空间将更简单、结构更好。数据整理和特征工程对于泛化至关重要。

此外,由于深度学习是曲线拟合,为了模型能够表现良好,它需要在输入空间的密集采样上进行训练。在这个上下文中,“密集采样”意味着训练数据应该密集地覆盖整个输入数据流形(参见图 5.11)。这在决策边界附近尤其如此。在足够密集的采样下,通过在过去的训练输入之间进行插值,可以理解新的输入,而无需使用常识、抽象推理或关于世界的知识——所有这些都是机器学习模型无法获取的。

图片

图 5.11:为了学习能够进行准确泛化的模型,对输入空间进行密集采样是必要的。

因此,你应该始终牢记,提高深度学习模型的最佳方式是在更多或更好的数据上训练它(当然,添加过度嘈杂或不准确的数据会损害泛化)。对输入数据流形的更密集覆盖将产生泛化能力更强的模型。你永远不要期望深度学习模型能够执行比其在训练样本之间进行粗略插值更复杂的事情,因此,你应该尽一切可能使插值尽可能简单。你将在深度学习模型中找到的只有你放入其中的东西:其架构中编码的先验知识和其训练所使用的数据。

当无法获取更多数据时,下一个最佳解决方案是调节模型允许存储的信息量,或者对模型曲线的平滑性添加约束。如果一个网络只能负担得起记住少量模式,或者非常规则的模式,优化过程将迫使它专注于最突出的模式,这些模式有更好的机会进行良好的泛化。通过这种方式与过拟合作斗争的过程称为正则化。我们将在第 5.4.4 节中深入探讨正则化技术。

在你开始调整模型以帮助其更好地泛化之前,你需要一种方法来评估你的模型目前的表现。在接下来的章节中,你将了解如何在模型开发过程中监控泛化:模型评估。

评估机器学习模型

你只能控制你能观察到的。由于你的目标是开发能够成功推广到新数据的模型,因此能够可靠地衡量你模型的泛化能力至关重要。在本节中,我们将正式介绍你可以用来评估机器学习模型的多种方式。你已经在上一章中看到了其中大部分的实际应用。

训练集、验证集和测试集

评估一个模型始终归结为将可用数据分成三个集合:训练集、验证集和测试集。你在训练数据上训练,并在验证数据上评估你的模型。一旦你的模型准备就绪,你最后一次在测试数据上对其进行测试,测试数据应尽可能与生产数据相似。然后你可以在生产环境中部署该模型。

你可能会问,为什么不设置两组数据:一组训练集和一组测试集?你在训练数据上训练,在测试数据上评估。这要简单得多!

原因是开发模型总是涉及调整其配置:例如,选择层数或层的尺寸(称为模型的超参数,以区分它们与参数,参数是网络的权重)。你通过使用作为反馈信号的模型在验证数据上的性能来进行这种调整。本质上,这种调整是一种学习:在某个参数空间中寻找良好配置的过程。因此,基于模型在验证集上的性能调整模型的配置可以迅速导致过度拟合验证集,即使你的模型从未直接在它上面训练过。

这一现象的核心是信息泄露的概念。每次你根据模型在验证集上的性能调整模型的超参数时,一些关于验证数据的信息就会泄露到模型中。如果你只做一次,针对一个参数,那么泄露的信息量会非常少,你的验证集将保持可靠,用于评估模型。但如果你重复多次——运行一个实验,在验证集上评估,并根据结果修改你的模型——那么你将越来越多地泄露关于验证集的信息到模型中。

最后,你将得到一个在验证数据上表现人工良好的模型,因为那是你优化它的目标。你关心的是在完全新的数据上的性能,而不是验证数据,所以你需要使用一个完全不同、从未见过的数据集来评估模型:测试数据集。你的模型不应该接触到任何关于测试集的信息,即使是间接的。如果模型的任何方面是基于测试集的性能进行调整的,那么你的泛化度量将会是有缺陷的。

将你的数据分为训练集、验证集和测试集看似简单,但有一些高级方法可以实现,当数据量较少时这些方法可能非常有用。让我们回顾三种经典的评估方法:简单的保留验证、K 折验证和带有洗牌的迭代 K 折验证。我们还将讨论使用常识性基线来检查你的训练是否有所进展。

简单的保留验证

将你数据的一部分作为测试集。在剩余的数据上训练,并在测试集上评估。正如你在前面的章节中看到的,为了防止信息泄露,你不应该根据测试集调整你的模型,因此你也应该同样保留一个验证集。

概括来说,保留验证看起来就像图 5.12 所示。以下列表展示了一个简单的实现。

图 5.12:简单的保留验证分割

num_validation_samples = 10000
# Shuffling the data is usually appropriate.
np.random.shuffle(data)
# Defines the validation set
validation_data = data[:num_validation_samples]
# Defines the training set
training_data = data[num_validation_samples:]
# Trains a model on the training data and evaluates it on the
# validation data
model = get_model()
model.fit(training_data, ...)
validation_score = model.evaluate(validation_data, ...)

# At this point, you can tune your model, retrain it, evaluate it, tune
# it again, and so on.
...

# Once you've tuned your hyperparameters, it's common to train your
# final model from scratch on all non-test data available.
model = get_model()
model.fit(
    np.concatenate([training_data, validation_data]),
    ...,
)
test_score = model.evaluate(test_data, ...) 

列表 5.4:保留验证(注意为了简单起见省略了标签)

这是最简单的评估协议,但它有一个缺点:如果数据很少,那么你的验证集和测试集可能包含的样本太少,无法在统计上代表手头的数据。这很容易识别:如果在分割数据之前的随机洗牌轮次结束后,模型性能的度量非常不同,那么你就有这个问题。K 折验证和迭代 K 折验证是两种解决方法,如后文所述。

K 折验证

使用这种方法,你将数据分成K个大小相等的部分。对于每个部分i,在剩余的K - 1个部分上训练一个模型,并在部分i上评估它。你的最终得分是 K 个得分的平均值。当你的模型性能基于你的训练/测试分割显示出显著的方差时,这种方法很有帮助。就像保留法验证一样,这种方法不会让你免除为模型校准使用一个独立的验证集。

概括地看,K 折交叉验证看起来像图 5.13。列表 5.6 展示了简单实现。

图片

图 5.13:三折验证

k = 3
num_validation_samples = len(data) // k
np.random.shuffle(data)
validation_scores = []
for fold in range(k):
    # Selects the validation-data partition
    validation_data = data[
        num_validation_samples * fold : num_validation_samples * (fold + 1)
    ]
    # Uses the remainder of the data as training data.
    training_data = np.concatenate(
        data[: num_validation_samples * fold],
        data[num_validation_samples * (fold + 1) :],
    )
    # Creates a brand-new instance of the model (untrained)
    model = get_model()
    model.fit(training_data, ...)
    validation_score = model.evaluate(validation_data, ...)
    validation_scores.append(validation_score)
# Validation score: average of the validation scores of the k folds
validation_score = np.average(validation_scores)
# Trains the final model on all non-test data available
model = get_model()
model.fit(data, ...)
test_score = model.evaluate(test_data, ...) 

列表 5.5:K 折交叉验证(为了简单起见,省略了标签)

带洗牌的迭代 K 折验证

这种方法适用于你只有相对较少数据可用,并且需要尽可能精确地评估你的模型的情况。我发现它在 Kaggle 比赛中非常有帮助。它包括多次应用 K 折验证,每次在分割数据成K部分之前都进行数据洗牌。最终得分是每次 K 折验证运行得到的得分的平均值。请注意,你最终会训练和评估P * K个模型(其中P是你使用的迭代次数),这可能会非常昂贵。

打败常识性基线

除了你拥有的不同评估协议外,还有最后一件事你应该知道,那就是常识性基线的使用。

训练一个深度学习模型有点像按下在一个平行世界中发射火箭的按钮。你听不到它或看到它。你无法观察到流形学习过程——它在一个有数千维度的空间中发生,即使你将其投影到 3D,你也无法解释它。你唯一能得到的反馈是你的验证指标——就像你无形火箭上的高度计。

一个特别重要的点是能够判断你是否真的有所进展。你起始的高度是多少?你的模型似乎有 15%的准确率,这算好吗?在你开始使用数据集之前,你应该始终选择一个简单的基线,并尝试超越它。如果你超过了那个阈值,你就知道你正在做正确的事情:你的模型实际上正在使用输入数据中的信息来做出泛化的预测——你可以继续前进。这个基线可以是随机分类器的性能,或者你可以想象到的最简单的非机器学习技术的性能。

例如,在 MNIST 数字分类示例中,一个简单的基线可以是验证准确率大于 0.1(随机分类器);在 IMDB 示例中,它将是验证准确率大于 0.5。在 Reuters 示例中,它将大约是 0.18–0.19,由于类别不平衡。如果你有一个二分类问题,其中 90%的样本属于类别 A,10%属于类别 B,那么一个总是预测 A 的分类器在验证准确率上已经达到 0.9,你需要做得更好。

当你开始解决一个以前没有人解决的问题时,有一个可以参考的常识性基线是至关重要的。如果你无法超越一个简单的解决方案,你的模型就没有价值——也许你使用了错误的模型,也许你正在解决的问题根本就不能用机器学习来接近。是时候回到画板上了。

关于模型评估需要注意的事项

选择评估协议时要注意以下几点:

  • 数据代表性 — 你希望你的训练集和测试集都能代表手头的数据。例如,如果你试图分类数字图像,并且你从一个按类别排序的样本数组开始,将数组的第一个 80%作为训练集,剩下的 20%作为测试集,那么你的训练集将只包含类别 0–7,而你的测试集将只包含类别 8–9。这似乎是一个荒谬的错误,但出人意料地常见。因此,你通常应该在将数据分割成训练集和测试集之前随机打乱你的数据。

  • 时间之箭 — 如果你试图根据过去预测未来(例如,明天的天气、股票走势等),在分割数据之前不要随机打乱你的数据,因为这样做将创建一个时间泄露:你的模型实际上将基于未来的数据进行训练。在这种情况下,你应该始终确保测试集中的所有数据都在训练集数据之后。

  • 数据冗余 — 如果你的数据中某些数据点出现了两次(在现实世界数据中相当常见),那么对数据进行洗牌并将其分为训练集和验证集将导致训练集和验证集之间的冗余。实际上,你将测试你的一部分训练数据,这是最糟糕的事情!确保你的训练集和验证集是不相交的。

有一个可靠的方式来评估你模型的性能,这样你将能够监控机器学习核心的紧张关系——在优化和泛化、欠拟合和过度拟合之间。

提高模型拟合度

要达到完美的拟合,你必须首先过度拟合。由于你事先不知道边界在哪里,你必须跨越它来找到它。因此,当你开始处理问题时,你的初始目标是实现一个显示出一些泛化能力的模型,并且能够过度拟合。一旦你有了这样的模型,你将专注于通过对抗过度拟合来细化泛化。

在这个阶段,你将遇到三个常见的问题:

  • 训练没有开始:你的训练损失并没有随时间下降。

  • 训练开始得很顺利,但你的模型并没有真正地泛化:你无法超越你设定的常识性基线。

  • 训练和验证损失都随时间下降,你可以打败你的基线,但你似乎无法过度拟合,这表明你仍然欠拟合。

让我们看看你如何解决这些问题,以实现机器学习项目的第一个重大里程碑:得到一个具有一些泛化能力的模型(它可以打败一个平凡的基线)并且能够过度拟合。

调整关键梯度下降参数

有时,训练没有开始或者过早停滞。你的损失被卡住了。这总是可以克服的:记住你可以将模型拟合到随机数据。即使你的问题没有任何意义,你也应该仍然能够训练一些东西——至少通过记住训练数据。

当这种情况发生时,通常是由于梯度下降过程的配置问题:你的优化器选择、模型权重中初始值的分布、你的学习率,或者你的批量大小。所有这些参数都是相互依赖的,因此通常只需要调整学习率和批量大小,同时保持其他参数不变。

让我们看看一个具体的例子:让我们用第二章中不适当大的学习率(值为 1)来训练 MNIST 模型。

(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255

model = keras.Sequential(
    [
        layers.Dense(512, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
)
model.compile(
    optimizer=keras.optimizers.RMSprop(learning_rate=1.0),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    train_images, train_labels, epochs=10, batch_size=128, validation_split=0.2
) 

列表 5.6:使用过高学习率的 MNIST 模型进行训练

模型很快达到训练和验证准确率在 20% 到 40% 的范围内,但无法突破这个范围。让我们尝试将学习率降低到一个更合理的值 1e-2

model = keras.Sequential(
    [
        layers.Dense(512, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
)
model.compile(
    optimizer=keras.optimizers.RMSprop(learning_rate=1e-2),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    train_images, train_labels, epochs=10, batch_size=128, validation_split=0.2
) 

列表 5.7:具有更合适学习率的相同模型

模型现在能够进行训练了。

如果你发现自己处于类似的情况,可以尝试

  • 降低或提高学习率。学习率过高可能导致更新远远超出适当的拟合,就像之前的例子中那样,而学习率过低可能会使训练变得非常缓慢,看起来像是停滞不前。

  • 增加批量大小。包含更多样本的批量将导致更具有信息量和更少噪声的梯度(方差更低)。

你最终会找到一个配置,使训练开始。

使用更好的架构先验

你有一个拟合的模型,但出于某种原因,你的验证指标根本没有任何改善。它们与随机分类器所能达到的指标一样好:你的模型在训练,但没有泛化。发生了什么?

这可能是你可能会遇到的最糟糕的机器学习情况。这表明“你的方法存在根本性的问题”,并且可能不容易判断出是什么问题。以下是一些建议。

首先,可能是因为你使用的输入数据本身就不包含足够的信息来预测你的目标:按照这种表述的问题是不可解决的。这就是我们之前尝试拟合一个 MNIST 模型,其中标签被随机打乱时发生的情况:模型可以很好地训练,但验证准确率会一直停留在 10%,因为使用这样的数据集显然无法进行泛化。

也可能是因为你使用的模型不适合当前的问题。例如,在第十三章中,你将看到一个时间序列预测问题的例子,其中密集连接的架构无法击败一个平凡的基线,而一个更合适的循环架构确实能够很好地泛化。使用对问题做出正确假设的模型对于实现泛化至关重要:你应该使用正确的架构先验。

在接下来的章节中,你将了解适用于各种数据模态的最佳架构——图像、文本、时间序列等。一般来说,你应该确保阅读针对你正在攻击的任务的架构最佳实践——很可能你不是第一个尝试这样做的人。

提高模型容量

如果你设法得到一个拟合的模型,其中验证指标正在下降,并且似乎达到了至少一定程度的泛化能力,恭喜你:你几乎成功了。接下来,你需要让你的模型开始过拟合。

考虑以下小型模型——一个简单的逻辑回归——它是在 MNIST 像素上训练的。

model = keras.Sequential([layers.Dense(10, activation="softmax")])
model.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
history_small_model = model.fit(
    train_images, train_labels, epochs=20, batch_size=128, validation_split=0.2
) 

列表 5.8:MNIST 上的简单逻辑回归

你会得到类似这样的损失曲线(见图 5.14):

import matplotlib.pyplot as plt

val_loss = history_small_model.history["val_loss"]
epochs = range(1, 21)
plt.plot(epochs, val_loss, "b-", label="Validation loss")
plt.title("Validation loss for a model with insufficient capacity")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend()
plt.show() 

图片

图 5.14:模型容量不足对损失曲线的影响

验证指标似乎停滞不前或改善非常缓慢,而不是达到峰值然后逆转。验证损失下降到 0.26 并保持在那里。你可以拟合,但你无法清楚地过拟合,即使是在多次迭代训练数据之后。在你的职业生涯中,你很可能会经常遇到类似的曲线。

记住,总是有可能过拟合。这与“训练损失不下降”的问题类似,这是一个可以始终解决的问题。如果你似乎无法过拟合,那么很可能是你模型的表示能力问题:你需要一个更大的模型,一个具有更多容量的模型——也就是说,能够存储更多信息。你可以通过添加更多层、使用更大的层(具有更多参数的层)或使用更适合当前问题的层(更好的架构先验)来增加表示能力。

让我们尝试训练一个更大的模型,一个包含两个中间层,每个层有 128 个单元的模型:

model = keras.Sequential(
    [
        layers.Dense(128, activation="relu"),
        layers.Dense(128, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
history_large_model = model.fit(
    train_images,
    train_labels,
    epochs=20,
    batch_size=128,
    validation_split=0.2,
) 

现在的训练曲线看起来完全符合预期:模型拟合速度快,在八个 epoch 后开始过拟合(见图 5.15):

图片

图 5.15:具有适当容量的模型的验证损失

注意,虽然对于当前问题来说,使用过度参数化的模型是标准的,但确实存在“过度记忆”容量的问题。如果你的模型一开始就立即开始过拟合,你就知道你的模型太大。以下是一个具有三个中间层,每个层有 2,048 个单元的 MNIST 模型的例子(见图 5.16):

model = keras.Sequential(
    [
        layers.Dense(2048, activation="relu"),
        layers.Dense(2048, activation="relu"),
        layers.Dense(2048, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
history_very_large_model = model.fit(
    train_images,
    train_labels,
    epochs=20,
    # When training larger models, you can reduce the batch size to
    # limit memory consumption.
    batch_size=32,
    validation_split=0.2,
) 

图片

图 5.16:过度模型容量对验证损失的影响

提高泛化能力

一旦你的模型显示出一定的泛化能力和过拟合的能力,是时候将你的重点转向最大化泛化。

数据集整理

你已经了解到,在深度学习中,泛化能力源于你数据的潜在结构。如果你的数据可以在样本之间平滑插值,那么你将能够训练出一个泛化的深度学习模型。如果你的问题过于嘈杂或本质上离散,比如列表排序,深度学习将无法帮助你。深度学习是曲线拟合,而不是魔法。

因此,确保你使用的是适当的数据库至关重要。在数据收集上投入更多努力和资金,几乎总是比在开发更好的模型上投入相同的资金带来更大的投资回报:

  • 确保你有足够的数据。记住,你需要对输入-输出空间进行密集采样。更多的数据将产生更好的模型。有时,一开始看似不可能解决的问题,通过更大的数据集就能解决。

  • 最小化标签错误——可视化你的输入以检查异常,并校对你的标签。

  • 清理你的数据并处理缺失值(我们将在下一章中介绍)。

  • 如果你有很多特征,但你不确定哪些是有用的,那么进行特征选择。

你可以通过 特征工程 来提高数据的泛化潜力。对于大多数机器学习问题,特征工程 是成功的关键因素。让我们来看看。

特征工程

特征工程 是一个过程,即利用你对数据以及当前机器学习算法(在这种情况下,是一个神经网络)的知识,通过在数据进入模型之前应用硬编码(非学习)的转换来使算法工作得更好。在许多情况下,期望机器学习模型能够从完全任意的数据中学习是不合理的。数据需要以使模型的工作更容易的方式进行呈现。

让我们来看一个直观的例子。假设你正在尝试开发一个模型,该模型可以接受一个钟表的图像作为输入,并输出一天中的时间(见图 5.17)。如果你选择使用图像的原始像素作为输入数据,那么你将面临一个困难的机器学习问题。你需要一个卷积神经网络来解决它,并且你需要投入相当多的计算资源来训练网络。

图 5.17:读取钟表时间的特征工程

但如果你已经从高层次上理解了问题(你理解人类是如何读取钟表面的时间的),那么你可以为机器学习算法提出更好的输入特征:例如,编写一个五行的 Python 脚本来跟踪钟表的黑色指针并输出每个指针尖端的 (x, y) 坐标很容易。然后一个简单的机器学习算法可以学会将这些坐标与适当的时间联系起来。

你甚至可以更进一步:进行坐标变换,将 (x, y) 坐标表示为以图像中心为极坐标。你的输入将变为每个钟表的指针的角度 theta。在这个阶段,你的特征使得问题变得如此简单,以至于不需要机器学习;简单的四舍五入操作和字典查找就足以恢复一天中的大约时间。

这就是特征工程的本质:通过以更简单的方式表达问题来使问题更容易。使潜在流形更平滑、更简单、更有组织。这通常需要深入理解问题。

在深度学习之前,特征工程曾是机器学习工作流程中最重要的一部分,因为经典浅层算法没有足够丰富的假设空间来自动学习有用的特征。你向算法呈现数据的方式对其成功至关重要。例如,在卷积神经网络在 MNIST 数字分类问题中取得成功之前,解决方案通常是基于硬编码的特征,如数字图像中的环数、图像中每个数字的高度、像素值直方图等。

幸运的是,现代深度学习消除了对大多数特征工程的需求,因为神经网络能够自动从原始数据中提取有用的特征。这意味着只要使用深度神经网络,你就不必担心特征工程吗?不,有两个原因:

  • 良好的特征仍然允许你在使用更少资源的情况下更优雅地解决问题。例如,使用卷积神经网络来解决读取时钟面的问题将是荒谬的。

  • 良好的特征让你可以用更少的数据解决问题。深度学习模型能够自行学习特征的能力依赖于大量可用训练数据;如果你只有少量样本,那么它们特征中的信息价值变得至关重要。

使用提前停止

在深度学习中,我们总是使用大量过参数化的模型:它们的自由度远远超过拟合数据潜在流形所需的最小值。这种过参数化并不是问题,因为你永远不会完全拟合一个深度学习模型。这样的拟合根本无法泛化。你总是在达到最小可能训练损失之前就中断了训练。

在训练过程中找到你达到最泛化拟合的确切点——即欠拟合曲线和过拟合曲线之间的确切边界——是你可以做的最有效的事情之一,以改善泛化能力。

在上一章的例子中,我们首先会训练我们的模型超过所需时间,以确定产生最佳验证指标的 epoch 数,然后我们会重新训练一个恰好为此数量的 epoch 的新模型。这是相当标准的做法。然而,这需要你做重复性的工作,有时可能会很昂贵。自然地,你可以在每个 epoch 结束时保存你的模型,然后一旦找到最佳 epoch,就重新使用最接近的已保存模型。在 Keras 中,通常使用EarlyStopping回调来实现这一点,该回调会在验证指标停止改进时中断训练,同时记住最佳已知模型状态。你将在第七章学习如何使用回调。

规范化你的模型

正则化技术是一套最佳实践,它积极地阻碍模型完美拟合训练数据的能力,目的是使模型在验证期间表现更好。这被称为“正则化”模型,因为它往往会使模型更简单,更“规则”,其曲线更平滑,更“通用”——因此对训练集更不具体,并且能够通过更接近地逼近数据的潜在流形来更好地泛化。请记住,“正则化”模型是一个应该始终由准确的评估程序指导的过程。只有当你能够衡量它时,你才能实现泛化。

让我们回顾一些最常用的正则化技术,并在实践中应用它们来改进第四章中的电影分类模型。

减小网络的大小

你已经了解到,一个过小的模型不会过拟合。减轻过拟合的最简单方法就是减小模型的大小(模型中可学习的参数数量,由层数和每层的单元数决定)。如果模型有限的记忆资源,它将无法简单地记住其训练数据。为了最小化其损失,它将不得不求助于学习具有预测能力的压缩表示——这正是我们感兴趣的类型。同时,请记住,你应该使用具有足够参数的模型,这样它们就不会欠拟合:你的模型不应该缺乏记忆资源。在“过多容量”和“容量不足”之间需要找到一个折衷方案。

不幸的是,没有神奇的公式可以确定正确的层数或每层的正确大小。你必须评估一系列不同的架构(当然是在你的验证集上,而不是测试集上),以找到适合你数据的正确模型大小。找到适当模型大小的一般工作流程是从相对较少的层和参数开始,增加层的大小或添加新层,直到你在验证损失方面看到收益递减。

让我们在电影评论分类模型上尝试这个。这是第四章中模型的简化版本。

from keras.datasets import imdb

(train_data, train_labels), _ = imdb.load_data(num_words=10000)

def vectorize_sequences(sequences, dimension=10000):
    results = np.zeros((len(sequences), dimension))
    for i, sequence in enumerate(sequences):
        results[i, sequence] = 1.0
    return results

train_data = vectorize_sequences(train_data)

model = keras.Sequential(
    [
        layers.Dense(16, activation="relu"),
        layers.Dense(16, activation="relu"),
        layers.Dense(1, activation="sigmoid"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history_original = model.fit(
    train_data,
    train_labels,
    epochs=20,
    batch_size=512,
    validation_split=0.4,
) 

列表 5.9:原始模型

现在,让我们尝试用这个更小的模型来替换它。

model = keras.Sequential(
    [
        layers.Dense(4, activation="relu"),
        layers.Dense(4, activation="relu"),
        layers.Dense(1, activation="sigmoid"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history_smaller_model = model.fit(
    train_data,
    train_labels,
    epochs=20,
    batch_size=512,
    validation_split=0.4,
) 

列表 5.10:容量较低的模型版本

图 5.18 显示了原始模型和较小模型验证损失的对比。

图片

图 5.18:IMDb 评论分类中的原始模型与较小模型的比较

如你所见,较小的模型比参考模型更晚开始过拟合(在六次而不是四次迭代后),一旦开始过拟合,其性能下降得更慢。

现在,让我们将一个具有更多容量的模型添加到我们的基准中——远远超过问题所需的容量。

model = keras.Sequential(
    [
        layers.Dense(512, activation="relu"),
        layers.Dense(512, activation="relu"),
        layers.Dense(1, activation="sigmoid"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history_larger_model = model.fit(
    train_data,
    train_labels,
    epochs=20,
    batch_size=512,
    validation_split=0.4,
) 

列表 5.11:具有更高容量的模型版本

图 5.19 展示了较大模型与参考模型相比的表现。较大的模型几乎在一轮训练后就开始过拟合,并且过拟合程度更加严重。其验证损失也更加嘈杂。它迅速将训练损失降至接近零。模型容量越大,它越能快速地建模训练数据(导致训练损失较低),但它对过拟合的敏感性也越高(导致训练损失和验证损失之间差异较大)。

图 5.19:IMDB 评论分类中的原始模型与较大模型对比

添加权重正则化

你可能熟悉奥卡姆剃刀原理:对于某种事物的两种解释,最可能正确的是最简单的一种——即做出较少假设的那一种。这种想法也适用于神经网络学习到的模型:给定一些训练数据和网络架构,多组权重值(多个模型)可以解释数据。简单模型比复杂模型更不容易过拟合。

在这个背景下,简单模型是指参数值分布的熵较低(或者与上一节中看到的相比,参数更少的模型)。因此,一种常见的减轻过拟合的方法是通过强制权重只取较小的值来对模型的复杂性施加约束,这使得权重值的分布更加规则。这被称为权重正则化,它通过向模型的损失函数中添加与权重大的成本相关联的成本来实现。这种成本有两种形式:

  • L1 正则化 — 添加的成本与权重系数的绝对值(权重的L1 范数)成比例。

  • L2 正则化 — 添加的成本与权重系数的平方值(权重的L2 范数)成比例。在神经网络背景下,L2 正则化也称为权重衰减。不要让不同的名称混淆你:权重衰减在数学上与 L2 正则化相同。

在 Keras 中,通过将权重正则化实例作为关键字参数传递给层来添加权重正则化。让我们将 L2 权重正则化添加到电影评论分类模型中。

from keras.regularizers import l2

model = keras.Sequential(
    [
        layers.Dense(16, kernel_regularizer=l2(0.002), activation="relu"),
        layers.Dense(16, kernel_regularizer=l2(0.002), activation="relu"),
        layers.Dense(1, activation="sigmoid"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history_l2_reg = model.fit(
    train_data,
    train_labels,
    epochs=20,
    batch_size=512,
    validation_split=0.4,
) 

列表 5.12:将 L2 权重正则化添加到模型中

l2(0.002) 表示层中每个权重矩阵的系数都将添加 0.002 * weight_coefficient_value ** 2 到模型的总体损失中。请注意,由于这种惩罚仅在训练时添加,因此该模型的损失在训练时比测试时高得多。

图 5.20 展示了 L2 正则化惩罚的效果。如您所见,具有 L2 正则化的模型比参考模型对过拟合的抵抗力更强,尽管两个模型具有相同数量的参数:见图 5.20:

图片

图 5.20:L2 权重正则化对验证损失的影响

作为 L2 正则化的替代方案,您可以使用以下 Keras 权重正则化器之一。

from keras import regularizers

# L1 regularization
regularizers.l1(0.001)
# Simultaneous L1 and L2 regularization
regularizers.l1_l2(l1=0.001, l2=0.001) 

代码列表 5.13:Keras 中可用的不同权重正则化器

注意,权重正则化通常用于较小的深度学习模型。大型深度学习模型往往过度参数化,对权重值施加约束对模型容量和泛化能力的影响不大。在这些情况下,更倾向于使用不同的正则化技术:dropout

添加 dropout

Dropout,由多伦多大学的 Geoffrey Hinton 及其学生开发,是神经网络中最有效且最常用的正则化技术之一。Dropout 在层中的应用包括在训练期间随机 丢弃(置零)一定数量的层输出特征。假设一个给定的层在训练期间对于一个给定的输入样本通常会返回一个向量 [0.2, 0.5, 1.3, 0.8, 1.1]。在应用 dropout 后,这个向量将会有一些随机分布的零值:例如,[0, 0.5, 1.3, 0, 1.1]dropout 率 是被置零的特征的比例;它通常设置在 0.2 到 0.5 之间。在测试时,不丢弃任何单元;相反,层的输出值按 dropout 率的因子进行缩放,以平衡训练时比测试时更活跃的单元数量。

考虑一个包含层输出 layer_output 的 NumPy 矩阵,其形状为 (batch_size, features)。在训练时,我们随机将矩阵中的一部分值置零:

# At training time, drops out 50% of the units in the output
layer_output *= np.random.randint(low=0, high=2, size=layer_output.shape) 

在测试时,我们将输出按 dropout 率进行缩放。这里,我们按 0.5 缩放(因为我们之前丢弃了一半的单元):

# At test time
layer_output *= 0.5 

注意,这个过程可以通过在训练时执行这两个操作,并在测试时保持输出不变来实现,这在实践中通常是这样做的方式(见图 5.21):

# At training time
layer_output *= np.random.randint(low=0, high=2, size=layer_output.shape)
# Note that we're scaling up rather scaling down in this case.
layer_output /= 0.5 

图片

图 5.21:在训练时应用于激活矩阵的 dropout,训练过程中进行缩放。在测试时,激活矩阵保持不变。

这种技术可能看起来很奇怪且随意。为什么这有助于减少过拟合?Hinton 表示,他受到了,包括但不限于,银行使用的欺诈预防机制的启发:

我去银行。出纳员一直在更换,我询问其中一位为什么。他说他不知道,但他们经常被调动。我想这肯定是因为这需要员工之间的合作才能成功欺诈银行。这让我意识到,在每一个例子中随机移除不同子集的神经元可以防止阴谋,从而减少过拟合。

核心思想是在层的输出值中引入噪声可以打破不重要的偶然模式(Hinton 称之为阴谋),如果没有噪声,模型将开始记忆这些模式。

在 Keras 中,你可以通过Dropout层在模型中引入 dropout,该层应用于层之前的输出。让我们在 IMDB 模型中添加两个Dropout层,看看它们在减少过拟合方面做得如何。

model = keras.Sequential(
    [
        layers.Dense(16, activation="relu"),
        layers.Dropout(0.5),
        layers.Dense(16, activation="relu"),
        layers.Dropout(0.5),
        layers.Dense(1, activation="sigmoid"),
    ]
)
model.compile(
    optimizer="rmsprop",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
history_dropout = model.fit(
    train_data,
    train_labels,
    epochs=20,
    batch_size=512,
    validation_split=0.4,
) 

列表 5.14:在 IMDB 模型中添加 dropout

图 5.22 显示了结果的图表。这比参考模型有明显的改进。它似乎比 L2 正则化工作得更好,因为最低的验证损失已经得到改善:

图 5.22:dropout 对验证损失的影响

总结一下,这些是在神经网络中最大化泛化并防止过拟合的最常见方法:

  • 获取更多或更好的训练数据

  • 开发更好的特征

  • 降低模型的容量

  • 添加权重正则化(适用于较小的模型)

  • 添加 dropout

摘要

  • 机器学习模型的目的在于泛化:在从未见过的输入上执行准确。这比看起来要难。

  • 深度神经网络通过学习一个可以成功在训练样本之间进行插值的参数模型来实现泛化。这样的模型可以说已经学会了训练数据的潜在流形。这就是为什么深度学习模型只能理解在训练期间所见到的非常接近的输入。

  • 机器学习中的基本问题是优化与泛化之间的张力:为了实现泛化,你必须首先对训练数据有一个良好的拟合,但提高你的模型对训练数据的拟合最终会损害泛化。每一个深度学习最佳实践都涉及管理这种张力。

  • 深度学习模型能够泛化的能力源于它们能够学会近似其数据的潜在流形,因此可以通过插值理解新的输入。

  • 在开发模型的过程中,能够准确评估模型的一般化能力至关重要。你可以使用一系列评估方法,从简单的保留验证到 K 折交叉验证和带洗牌的迭代 K 折交叉验证。请记住,始终为最终模型评估保留一个完全独立的测试集,因为你的验证数据可能已经泄露到模型中。

  • 当你开始构建模型时,你的目标是首先实现一个具有一定一般化能力且可以过拟合的模型。实现这一目标的最佳实践包括调整学习率和批量大小、使用更好的架构先验、增加模型容量或简单地延长训练时间。

  • 当你的模型开始过拟合时,你的目标转变为通过模型正则化来提高一般化能力。你可以减少模型容量,添加 dropout 或权重正则化,并使用提前停止。当然,更大的或更好的数据集始终是帮助模型实现一般化的首要方法。

脚注

  1. 马克·吐温甚至称其为“人类已知最美味的水果。” [↩]

第六章:机器学习的通用工作流程

deeplearningwithpython.io/chapters/chapter06_universal-workflow-of-ml

我们之前的例子假设我们已经有了一个标记的数据集来开始,并且我们可以立即开始训练一个模型。在现实世界中,这通常不是情况。你不是从一个数据集开始的;你是从一个问题开始的。

想象一下你正在启动自己的机器学习咨询店。你注册公司,建立了一个华丽的网站,通知了你的网络。项目开始滚滚而来:

  • 为图片分享社交网络的个性化照片搜索引擎 — 输入“婚礼”并检索你在婚礼上拍摄的所有照片,无需任何手动标记。

  • 在新兴的聊天应用帖子中标记垃圾邮件和攻击性文本内容。

  • 为在线电台的用户构建音乐推荐系统。

  • 为电子商务网站检测信用卡欺诈。

  • 预测展示广告的点击通过率,以决定在特定时间向特定用户展示哪个广告。

  • 在饼干制造线的传送带上标记异常饼干。

  • 使用卫星图像预测尚未发现的考古遗址的位置。

如果你能从 keras.datasets 导入正确的数据集并开始拟合一些深度学习模型,那将非常方便。不幸的是,在现实世界中,你必须从头开始。

在本章中,你将了解可以用来接近和解决任何机器学习问题(如之前所列)的通用逐步蓝图。这个模板将汇集和巩固你在第四章和第五章中学到的所有内容,并为你提供更广泛的背景,这将为你将在下一章中学习的内容提供锚点。

机器学习的通用工作流程大致分为三个部分:

  • 定义任务 — 理解问题域和客户所提要求背后的业务逻辑。收集数据集,理解数据代表什么,并选择你将如何衡量任务上的成功。

  • 开发一个模型 — 准备你的数据以便机器学习模型可以处理,选择一个模型评估协议和一个简单的基线来超越,训练一个具有泛化能力但可能过拟合的第一个模型,然后正则化和调整你的模型,直到你达到最佳可能的泛化性能。

  • 部署模型 — 向利益相关者展示你的工作,将模型部署到网络服务器、移动应用、网页或嵌入式设备上,监控模型在野外的性能,并开始收集你将需要用于构建下一个模型生成所需的数据。

让我们深入探讨。

定义任务

没有对你所做事情的背景有深入的理解,你无法做好工作。为什么你的客户想要解决这个特定问题?他们将从解决方案中获得什么价值?你的模型将如何被使用?它将如何融入客户的企业流程?有什么类型的数据可用或可以收集?可以将哪种机器学习任务映射到商业问题?

构建问题

构建机器学习问题通常涉及与利益相关者的许多详细讨论。以下是你应该放在首位的问题:

  • 你的输入数据将是什么?你试图预测什么?只有在你有可用训练数据的情况下,你才能学会预测某物:例如,只有在你有电影评论和情感注释可用的情况下,你才能学会分类电影评论的情感。因此,数据可用性通常是这一阶段的限制因素。在许多情况下,你将不得不自己收集和注释新的数据集(我们将在下一节中介绍)。

  • 你正在面对哪种机器学习任务?是二分类?多分类?标量回归?向量回归?多分类、多标签分类?图像分割?排序?其他,比如聚类、生成或强化学习?在某些情况下,可能机器学习甚至不是理解你的数据的最佳方式,你应该使用其他方法,例如传统的统计分析:

    • 图片搜索引擎项目是一个多分类、多标签分类任务。

    • 垃圾邮件检测项目是一个二分类任务。如果你将“攻击性内容”作为一个单独的类别,它就是一个三分类任务。

    • 音乐推荐引擎的解决方案不是通过深度学习,而是通过矩阵分解(协同过滤)来处理的。

    • 信用卡欺诈检测项目是一个二分类任务。

    • 点击率预测项目是一个标量回归任务。

    • 异常饼干检测是一个二分类任务,但它还需要一个作为第一阶段的对象检测模型来正确裁剪原始图像中的饼干。请注意,被称为“异常检测”的机器学习技术集合在这个设置中可能并不适用!

    • 从卫星图像中寻找新考古遗址的项目是一个图像相似性排序任务:你需要检索出看起来最像已知考古遗址的新图像。

  • 现有的解决方案是什么样的?也许你的客户已经有一个手工制作的算法来处理垃圾邮件过滤或信用卡欺诈检测——包含大量的嵌套if语句。也许有人目前负责手动处理这个过程——在饼干工厂监控传送带并手动移除坏饼干,或者为喜欢特定艺术家的用户制作歌曲推荐播放列表。你应该确保理解已经存在的系统,以及它们是如何工作的。

  • 你需要处理特定的约束条件吗?例如,你可能发现你正在为构建垃圾邮件检测系统的应用,该应用是严格端到端加密的,因此垃圾邮件检测模型将不得不运行在终端用户的手机上,并且必须在外部数据集上训练。也许 cookie 过滤模型有如此大的延迟限制,它需要在工厂的嵌入式设备上运行,而不是在远程服务器上。你应该理解你的工作将适应的完整背景。

一旦你完成了研究,你应该知道你的输入将是什么,你的目标将是什么,以及问题映射到的广泛类型的机器学习任务。在这个阶段,要意识到你正在提出的假设:

  • 你假设给定你的输入,可以预测你的目标。

  • 你假设可用的数据(或你将很快收集的数据)足够信息量,可以学习输入和目标之间的关系。

在你有一个工作模型之前,这些只是假设,等待被验证或证伪。并不是所有问题都可以用机器学习来解决;仅仅因为你有输入 X 和目标 Y 的例子,并不意味着 X 包含足够的信息来预测 Y。例如,如果你试图根据最近的股价历史预测股市中股票的走势,你不太可能成功,因为价格历史并不包含很多预测信息。

收集数据集

一旦你理解了任务的性质,并且知道你的输入和目标将是什么,那么就是数据收集的时候了——这是大多数机器学习项目中最为艰巨、耗时且昂贵的部分:

  • 照片搜索引擎项目要求你首先选择你想要分类的标签集——你确定了 10,000 个常见的图像类别。然后,你需要手动将成千上万你过去用户上传的图像用这个集合中的标签进行标记。

  • 对于聊天应用垃圾邮件检测项目,因为用户聊天是端到端加密的,你不能使用它们的内文来训练模型。你需要获取一个包含数万条未过滤社交媒体帖子的独立数据集,并手动将其标记为垃圾邮件、冒犯性或可接受。

  • 对于音乐推荐引擎,你可以直接使用用户的“喜欢”。不需要收集新的数据。同样,对于点击率预测项目,你有一份广泛的点击率记录,这些记录可以追溯到多年前的广告。

  • 对于饼干标记模型,你需要在输送带上方安装摄像头来收集数万张图像,然后有人需要手动标注这些图像。目前知道如何做这项工作的人正在饼干工厂工作——但这似乎并不太难,你应该能够训练人们来做这件事。

  • 卫星图像项目将需要一支考古学家团队来收集现有兴趣点的数据库,并且对于每个地点,你需要找到在不同天气条件下拍摄的存在卫星图像。为了得到一个好的模型,你需要数千个不同的地点。

你在第五章中学到,一个模型泛化能力几乎完全来自其训练数据的特点——你拥有的数据点的数量,你标签的可靠性,你特征的质量。一个好的数据集是一个值得关注和投资的资产。如果你有额外的 50 小时可以用于项目,那么最有效的方式可能是收集更多数据,而不是寻找增量模型改进。

数据比算法更重要这一点在 2009 年一篇由谷歌研究人员撰写的论文中被最著名地提出,该论文题为“数据的不可思议有效性”(标题是对尤金·维格纳 1960 年出版的著名书籍《数学在自然科学中的不可思议有效性》的改编)。这在大规模深度学习流行之前,但令人惊讶的是,深度学习的兴起反而增加了数据的重要性。

如果你正在进行监督学习,那么一旦你收集了输入(如图像),你将需要为它们提供标注(如图像的标签):你将训练模型预测的目标。

有时,标注可以自动检索——例如,在我们的音乐推荐任务或点击率预测任务中。但通常,你必须手动标注你的数据。这是一个劳动密集型的过程。

投资数据标注基础设施

你的数据标注过程将决定你的目标质量,这反过来又决定了你的模型质量。仔细考虑你拥有的选项:

  • 你是否应该自己标注数据?

  • 你是否应该使用像 Mechanical Turk 这样的众包平台来收集标签?

  • 你是否应该使用专门的数据标注公司的服务?

外包可能可以节省你的时间和金钱,但会失去控制。使用像 Mechanical Turk 这样的服务可能是低成本的,并且可以很好地扩展,但你的标注可能最终会相当嘈杂。

为了选择最佳选项,考虑你正在工作的限制条件:

  • 数据标注员需要是领域专家吗,或者任何人都可以标注数据?猫狗图像分类问题的标签可以被任何人选择,但对于狗品种分类任务则需要专业知识。同时,标注骨折的 CT 扫描几乎需要医学学位。

  • 如果标注数据需要专业知识,你能培训人员来做吗?如果不能,你如何获取相关专家的帮助?

  • 你自己是否理解专家是如何生成标注的?如果你不理解,你将不得不将你的数据集视为一个黑盒,你将无法进行手动特征工程——这虽然不是关键,但可能会有限制。

如果你决定在内部标注数据,问问自己你将使用什么软件来记录标注。你可能需要自己开发那个软件。高效的数据标注软件可以为你节省大量时间,所以在项目早期就值得投资。

警惕非代表性数据

机器学习模型只能理解与它们之前所见相似的输入。因此,用于训练的数据应该能够代表生产数据,这一点至关重要,应该是你所有数据收集工作的基础。

假设你正在开发一个应用程序,用户可以拍照来查找菜肴的名称。你使用一个受美食爱好者欢迎的图片分享社交网络的图片来训练模型。到了部署时间,愤怒的用户反馈开始涌入:你的应用程序有 8 次错误。发生了什么?你的测试集准确率远超过 90%!快速查看用户上传的数据揭示,随机餐馆随机智能手机拍摄的随机菜肴的移动图片与你训练模型时所用的专业质量、光线充足、令人垂涎的图片大相径庭:你的训练数据并不代表生产数据。这是一个严重的错误——欢迎来到机器学习地狱。

如果可能的话,直接从你的模型将使用的环境中收集数据。一个电影评论情感分类模型应该用于新的 IMDb 评论,而不是 Yelp 餐厅评论,也不是 Twitter 状态更新。如果你想对推文的情感进行评分,首先收集并标注实际的推文——来自与你预期在生产中使用的类似用户群体。如果无法在生产数据上训练,那么确保你完全理解你的训练数据和生产数据之间的差异,并且你正在积极纠正这些差异。

你应该注意的一个相关现象是概念漂移。你几乎会在所有现实世界的问题中遇到概念漂移,尤其是那些处理用户生成数据的问题。当生产数据的属性随时间变化时,就会发生概念漂移,导致模型精度逐渐下降。2013 年训练的音乐推荐引擎可能今天并不太有效。同样,你使用的 IMDB 数据集是在 2011 年收集的,基于它的模型在 2020 年的评论上可能不如 2012 年的评论表现得好,因为词汇、表达和电影类型随着时间的推移而演变。在对抗性环境(如信用卡欺诈检测)中,概念漂移尤其严重,欺诈模式几乎每天都在变化。处理快速概念漂移需要持续的数据收集、标注和模型重新训练。

请记住,机器学习只能用来记忆训练数据中存在的模式。你只能识别你之前看到过的。使用基于过去数据的机器学习来预测未来是假设未来会像过去一样表现。这通常并不成立。

理解你的数据

将数据集视为黑盒是不良的做法。在你开始训练模型之前,你应该探索和可视化你的数据,以了解使其具有预测性的因素——这将指导特征工程——并筛选潜在的问题:

  • 如果你的数据包含图像或自然语言文本,直接查看一些样本(及其标签)。

  • 如果你的数据包含数值特征,绘制特征值的直方图是一个好主意,以了解值的范围和不同值的频率。

  • 如果你的数据包含位置信息,将其绘制在地图上。是否有任何明显的模式出现?

  • 是否有一些样本缺少某些特征值?如果是这样,你需要在准备数据时处理这个问题(我们将在下一节中介绍如何做)。

  • 如果你的任务是分类问题,打印出数据中每个类别的实例数量。类别是否大致均匀分布?如果不均匀,你需要考虑这种不平衡。

  • 检查是否存在目标泄露——数据中存在提供关于目标的信息的特征,而这些信息在生产环境中可能不可用。如果你正在使用医疗记录来训练模型以预测某人将来是否会接受癌症治疗,并且记录中包含特征“这个人已被诊断出患有癌症”,那么你的目标就被人为地泄露到数据中。始终问自己,数据中的每个特征在生产环境中是否将以相同的形式可用?

选择成功度量

要控制某物,你需要能够观察它。要在项目中取得成功,你必须首先定义你所说的成功是什么。准确率?精确率和召回率?客户保留率?你的成功指标将指导你在整个项目中做出的所有技术选择。它应该直接与你的高级目标对齐,例如你客户的商业成功。

对于平衡的分类问题,其中每个类别的可能性相等,准确率和曲线下面积(AUC)以及接收者操作特征(ROC)是常见的指标。对于类别不平衡问题、排名问题或多标签分类,你可以使用精确率和召回率或一个计算假阳性、真阳性、假阴性和真阴性的指标。而且,定义自己的自定义指标来衡量成功并不罕见。为了了解机器学习成功指标的多样性以及它们如何与不同的问题域相关,浏览 Kaggle 上的数据科学竞赛(kaggle.com)很有帮助;它展示了广泛的问题和评估指标。

开发模型

一旦你知道如何衡量你的进度,你就可以开始模型开发了。大多数教程和研究项目都假设这是唯一的一步——跳过问题定义和数据集收集,这些被认为是已经完成的,并且跳过模型部署和维护,这些被认为是其他人负责的。实际上,模型开发只是机器学习工作流程中的一步,如果问我,这并不是最困难的一步。机器学习中最困难的事情是定义问题、收集、标注和清理数据。所以加油,接下来与比较起来将会容易得多!

准备数据

如你之前所学,深度学习模型通常不会直接摄入原始数据。数据预处理的目标是使手头的原始数据更适合神经网络处理。这包括向量化、归一化或处理缺失值。许多预处理技术是特定领域的(例如,特定于文本数据或图像数据);我们将在遇到实际示例时介绍这些技术。现在,我们将回顾适用于所有数据域的基本内容。

向量化

神经网络中的所有输入和目标通常必须是浮点数据张量(或在特定情况下,整数或字符串的张量)。无论你需要处理什么数据——声音、图像、文本——你都必须首先将其转换为张量,这一步称为数据向量化。例如,在第四章的两个先前的文本分类示例中,我们从表示为整数列表(代表单词序列)的文本开始,我们使用多热编码将它们转换为float32数据张量。在分类数字和预测房价的示例中,数据已经以向量化的形式提供,因此你可以跳过这一步。

值归一化

在第二章的 MNIST 数字分类示例中,你从 0-255 范围内的整数编码图像数据开始,编码灰度值。在你将此数据输入网络之前,你必须将其转换为float32并除以 255,以便最终得到 0-1 范围内的浮点值。同样,在预测房价时,你从具有各种范围的特性开始——一些特征具有小的浮点值,而其他特征则具有相当大的整数值。在你将此数据输入网络之前,你必须独立归一化每个特征,使其具有标准差为 1 和均值为 0。

通常,将相对较大的值(例如,多位整数,这些值远大于网络的初始权重值)或异构数据(例如,一个特征的范围在 0-1 之间,另一个特征的范围在 100-200 之间)输入神经网络是不安全的。这样做可能会触发大的梯度更新,从而阻止网络收敛。为了使网络的学习更容易,你的数据应具有以下特征:

  • 取小值——通常,大多数值应在 0-1 范围内。

  • 保持同质性——也就是说,所有特征应取大致相同的值范围。

此外,以下更严格的归一化实践很常见,并且可能有所帮助,尽管并不总是必要的(例如,在数字分类示例中你没有这样做):

  • 独立归一化每个特征,使其均值为 0。

  • 独立归一化每个特征,使其标准差为 1。

这可以通过 NumPy 数组轻松完成:

# Assuming x is a 2D data matrix of shape (samples, features)
x -= x.mean(axis=0)
x /= x.std(axis=0) 

处理缺失值

你可能有时会在数据中遇到缺失值。例如,在房价示例中,第二个特征是该地区房屋的中位年龄。如果这个特征对于所有样本都不可用怎么办?那么在训练或测试数据中就会出现缺失值。

你可以完全丢弃该特征,但并不一定必须这样做:

  • 如果特征是分类的,创建一个表示“值缺失”的新类别是安全的。模型将自动学习这与目标之间的关系。

  • 如果特征是数值型的,避免输入一个任意的值,比如 0,因为这可能会在你的特征形成的潜在空间中造成不连续性,使得在它上面训练的模型更难泛化。相反,考虑用数据集中该特征的均值或中位数来替换缺失值。你也可以训练一个模型来预测给定其他特征值的特征值。

注意,如果你预计测试数据中会有缺失的类别特征,但网络是在没有缺失值的数据上训练的,那么网络就没有学会忽略缺失值!在这种情况下,你应该人工生成带有缺失条目的训练样本:复制一些训练样本多次,并丢弃你预计在测试数据中可能缺失的某些类别特征。

选择一个评估协议

正如你在上一章所学,模型的目的在于实现泛化,你在整个模型开发过程中所做的每一个建模决策都将由寻求衡量泛化性能的验证指标所指导。你的验证协议的目标是准确估计你选择的成功指标(如准确率)在实际生产数据上的表现。这个过程的可信度对于构建一个有用的模型至关重要。

在第五章中,我们回顾了三种常见的评估协议:

  • 维护一个保留验证集 — 当你拥有大量数据时的可行方法

  • 执行 K 折交叉验证 — 当你拥有的样本太少,保留验证集不可靠时的正确选择

  • 执行迭代 K 折验证 — 当数据很少时进行高度精确的模型评估

只需从中选择一个。在大多数情况下,第一个就足够好了。正如你之前所学的,始终要关注你的验证集(s)的代表性,并小心不要在训练集和验证集(s)之间有重复的样本。

打败基线

当你开始着手构建模型本身时,你的初始目标是实现统计功效,正如你在第五章所见——也就是说,开发一个能够打败简单基线的小型模型。

在这个阶段,你应该关注以下三个最重要的方面:

  • 特征工程 — 过滤掉无信息特征(特征选择)并利用你对问题的了解来开发可能有用的新特征。

  • 选择正确的架构先验 — 你将使用哪种类型的模型架构?一个密集连接的网络、一个卷积神经网络(ConvNet)、一个循环神经网络、一个 Transformer?深度学习对于这个任务来说甚至是一个好的方法吗,或者你应该使用其他方法?

  • 选择一个足够好的训练配置 — 你应该使用什么损失函数?什么批大小和学习率?

对于大多数问题,你可以从现有的模板开始。你不是第一个尝试构建垃圾邮件检测器、音乐推荐引擎或图像分类器的人。确保研究先前的艺术,以确定最有可能在你的任务上表现良好的特征工程技术和模型架构。

注意,并不总是能够实现统计能力。如果你尝试了多个合理的架构后仍然无法击败一个简单的基线,那么你可能问的问题的答案可能不在输入数据中。记住,你正在做出两个假设:

  • 你假设,给定你的输入,可以预测你的输出。

  • 你假设可用的数据足够有信息量,可以学习输入和输出之间的关系。

这些假设可能是不正确的,在这种情况下,你必须回到起点。

扩大规模:开发一个过拟合的模型

一旦你获得了一个具有统计能力的模型,问题就变成了,你的模型是否足够强大?它是否有足够的层和参数来正确地模拟当前的问题?例如,逻辑回归模型在 MNIST 上具有统计能力,但不足以很好地解决这个问题。记住,机器学习中的普遍张力在于优化和泛化之间;理想模型是那种正好位于欠拟合和过拟合、欠容量和过容量之间的模型。为了找出这个边界的位置,首先你必须跨越它。

为了找出你需要多大的模型,你必须开发一个过拟合的模型。这相当容易,正如你在第五章中学到的:

  • 添加层。

  • 使层更大。

  • 训练更多的轮次。

总是监控训练损失和验证损失,以及你关心的任何指标的训练和验证值。当你看到模型在验证数据上的性能开始下降时,你就实现了过拟合。

规范化和调整你的模型

一旦你获得了统计能力并且能够过拟合,你就知道你走在了正确的道路上。此时,你的目标变成了最大化泛化性能。

这个阶段将花费最多的时间:你将反复修改你的模型,训练它,在验证数据上评估(此时不是测试数据),再次修改,然后重复,直到模型尽可能好。以下是一些你应该尝试的事情:

  • 尝试不同的架构;添加或移除层。

  • 添加 dropout。

  • 如果你的模型很小,添加 L1 或 L2 正则化。

  • 尝试不同的超参数(例如每层的单元数或优化器的学习率)以找到最佳配置。

  • 可选地,迭代数据整理或特征工程:收集和标注更多数据,开发更好的特征,或者移除似乎没有信息量的特征。

可以通过使用自动超参数调整软件,如 KerasTuner,来自动化大部分这项工作。我们将在第十八章中介绍这一点。

请注意以下几点:每次你使用验证过程中的反馈来调整你的模型时,你都会将有关验证过程的信息泄露到模型中。重复几次是无害的;然而,在许多迭代中系统地这样做,最终会导致你的模型过度拟合验证过程(即使没有模型直接在验证数据上训练)。这使得评估过程变得不太可靠。

一旦你开发出一个令人满意的模型配置,你就可以在所有可用数据(训练和验证)上训练你的最终生产模型,并在测试集上最后一次评估它。如果测试集上的性能明显低于验证数据上的性能,这可能意味着你的验证程序最终并不可靠,或者你在调整模型参数时开始过度拟合验证数据。在这种情况下,你可能想切换到一个更可靠的评估协议(例如迭代 K 折验证)。

部署你的模型

在你的模型成功通过测试集的最终评估后,它就准备好部署并开始其生产生活。

向利益相关者解释你的工作并设定期望

成功和客户信任关乎始终满足或超越人们的期望;你实际交付的系统只是这幅图的一半。另一半是在发布前设定适当的期望。

非专业人士对人工智能系统的期望往往是不切实际的。例如,他们可能期望系统“理解”其任务,并在任务背景下具备类似人类的常识。为了解决这个问题,你应该考虑展示一些你模型(例如,展示被错误分类的样本看起来是什么样子,特别是那些误分类似乎令人惊讶的样本)的故障模式

他们可能还期望达到人类水平的表现,尤其是对于以前由人类处理的流程。大多数机器学习模型,因为它们(不完美地)被训练来近似人类生成的标签,远远达不到这个水平。您应该清楚地传达模型性能预期。避免使用像“该模型准确率为 98%”这样的抽象陈述(大多数人会在心里将其四舍五入到 100%),而更倾向于谈论假阴性率和假阳性率。例如,您可以说:“在这些设置下,欺诈检测模型的假阴性率为 5%,假阳性率为 2.5%。每天,平均有 200 笔有效交易会被标记为欺诈并送交人工审查,平均有 14 笔欺诈交易会被遗漏。平均有 266 笔欺诈交易会被正确捕获。”明确地将模型性能指标与业务目标联系起来。

您还应该确保与利益相关者讨论关键发布参数的选择——例如,在哪个概率阈值下应标记交易(不同的阈值会产生不同的假阴性率和假阳性率)。这类决策涉及权衡,只有深入了解业务背景才能处理。

部署推理模型

机器学习项目并不在你到达一个可以保存训练模型的 Colab 笔记本时就结束了。你很少会将训练期间操作的精确 Python 模型对象投入生产。

首先,您可能希望将模型导出为 Python 以外的其他格式:

  • 您的生产环境可能根本不支持 Python——例如,如果它是一个移动应用或嵌入式系统。

  • 如果应用程序的其他部分不在 Python 中(它可能是 JavaScript、C++等),使用 Python 来提供服务可能会引起显著的开销。

其次,由于您的生产模型仅用于输出预测(称为推理阶段),而不是用于训练,您有空间进行各种优化,可以使模型运行更快并减少其内存占用。

让我们快速看一下您可用的不同模型部署选项。

将模型作为 REST API 部署

将模型转化为产品的最简单方法可能是通过 REST API 在线提供服务。有许多库可以帮助实现这一点。Keras 支持两种最流行的方法——TensorFlow ServingONNX(即 Open Neural Network Exchange)。这两个库都是通过将所有模型权重和计算图从 Python 程序中提取出来来运行的,因此您可以从多个不同的环境中提供服务(例如,一个 C++服务器)。如果这听起来很像第三章中讨论的编译机制,您就完全正确了。TensorFlow Serving 本质上是一个用于通过特定保存权重的tf.function计算图提供服务的库。

Keras 允许通过所有 Keras 模型上可用的易于使用的 export() 方法访问 TensorFlow Serving 和 ONNX。以下是一个代码片段,展示了它是如何为 TensorFlow Serving 工作的:

# Exports the model as a TensorFlow SavedModel artifact
model.export("path/to/location", format="tf_saved_model")

# Loads the artifact in a different process, environment, or
# programming language
reloaded_artifact = tf.saved_model.load("path/to/location")
predictions = reloaded_artifact.serve(input_data) 

对于 ONNX 也存在类似的流程:

model.export("path/to/location", format="onnx")

ort_session = onnxruntime.InferenceSession("path/to/location")
predictions = ort_session.run(None, input_data) 

您应该在以下情况下使用此部署设置:

  • 将会消费模型预测的应用程序将能够可靠地访问互联网(显然)。例如,如果您的应用程序是一个移动应用程序,从远程 API 提供预测意味着在飞行模式或低连接性环境中应用程序将不可用。

  • 应用程序没有严格的延迟要求:请求、推理和回答往返通常需要大约 500 毫秒。

  • 用于推理的输入数据不太敏感:数据将以解密形式在服务器上可用,因为模型需要查看它(但请注意,您应该使用 SSL 加密 HTTP 请求和响应)。

例如,图像搜索引擎项目、音乐推荐系统、信用卡欺诈检测项目和卫星图像项目都非常适合通过 REST API 提供服务。

当将模型作为 REST API 部署时,一个重要的问题是您是想自己托管代码还是想使用完全管理的第三方云服务。例如,Google 产品 Cloud AI Platform 允许您简单地将您的 TensorFlow 模型上传到 Google Cloud Storage (GCS),并提供一个 API 端点来查询它。它负责许多实际细节,例如批处理预测、负载均衡和扩展。

在设备上部署模型

有时,您可能需要您的模型与运行使用它的应用程序的同一设备上运行——可能是一台智能手机、机器人上的嵌入式 ARM CPU 或小型设备上的微控制器。例如,也许您已经看到过一种能够自动检测您指向的场景中的人脸和人物的相机:那可能是一个直接在相机上运行的微型深度学习模型。

您应该在以下情况下使用此设置:

  • 您的模型有严格的延迟限制,或者需要在低连接性环境中运行。如果您正在构建沉浸式增强现实应用程序,查询远程服务器不是一个可行的选项。

  • 您可以将模型做得足够小,使其能够在目标设备的内存和功耗限制下运行。

  • 对于您的任务来说,获得尽可能高的准确度不是至关重要的:运行时间和准确度之间总是存在权衡,因此内存和功耗限制通常要求您发送一个模型,它的性能不如您在大型 GPU 上运行的最好模型。

  • 输入数据非常敏感,因此不应在远程服务器上解密。

例如,我们的垃圾邮件检测模型需要作为聊天应用的一部分在终端用户的智能手机上运行,因为消息是端到端加密的,所以根本无法由远程托管模型读取。同样,坏 cookie 检测模型有严格的延迟限制,需要在工厂运行。幸运的是,在这种情况下,我们没有电力或空间限制,因此实际上可以在 GPU 上运行该模型。

要在智能手机或嵌入式设备上部署 Keras 模型,你可以再次使用export()方法创建包含计算图的 TensorFlow 或 ONNX 保存的模型。TensorFlow Lite(www.tensorflow.org/lite)是一个在 Android 和 iOS 智能手机、ARM CPU、Raspberry Pi 或某些微控制器上运行的框架,用于高效的设备上深度学习推理。它使用与 TensorFlow Serving 相同的 TensorFlow 保存模型格式。ONNX 运行时也可以在移动设备上运行。

在浏览器中部署模型

深度学习通常用于基于浏览器或桌面基于 JavaScript 的应用程序。虽然通常可以通过 REST API 让应用程序查询远程模型,但让模型直接在浏览器中、用户的电脑上运行(如果可用,利用 GPU 资源)可能会有关键优势。

在以下情况下使用此设置:

  • 你希望将计算任务卸载给终端用户,这可以显著降低服务器成本。

  • 输入数据需要保持在终端用户的电脑或手机上。例如,在我们的垃圾邮件检测项目中,聊天应用的网页版和桌面版(作为用 JavaScript 编写的跨平台应用实现)应使用本地运行的模型。

  • 你的应用程序有严格的延迟限制:虽然运行在终端用户笔记本电脑或智能手机上的模型可能比运行在你自己服务器上大型 GPU 上的模型要慢,但你没有额外的 100 毫秒的网络往返时间。

  • 在模型下载并缓存后,你需要确保你的应用在没有连接的情况下也能继续工作。

当然,只有当你的模型足够小,不会占用用户笔记本电脑或智能手机的 CPU、GPU 或 RAM 时,你才应该选择这个选项。此外,由于整个模型将下载到用户的设备上,你应该确保模型中没有任何需要保密的内容。请注意,给定一个训练好的深度学习模型,通常可以恢复一些关于训练数据的信息:如果模型是在敏感数据上训练的,最好不要将其公开。

在 JavaScript 中部署模型时,TensorFlow 生态系统包括 TensorFlow.js (www.tensorflow.org/js),并且 ONNX 支持原生 JavaScript 运行时。TensorFlow.js 甚至实现了几乎所有的 Keras API(它最初是在 WebKeras 这个工作名称下开发的)以及许多低级别的 TensorFlow API。你可以轻松地将保存的 Keras 模型导入 TensorFlow.js,以便在基于浏览器的 JavaScript 应用或桌面 Electron 应用中查询它。

推理模型优化

在对推理环境进行优化时,特别是在对可用功率和内存有严格限制的环境(如智能手机和嵌入式设备)或对低延迟有要求的场景中,优化你的模型尤为重要。在将模型导入 TensorFlow.js 或导出到 TensorFlow Lite 之前,你应该始终寻求优化模型。

你可以应用两种流行的优化技术:

  • 权重剪枝 — 并非权重张量中的每个系数都对预测贡献相同。通过仅保留最重要的系数,你可以显著减少模型层中的参数数量。这会在性能指标上带来轻微的代价,但可以减少模型的内存和计算占用。通过调整你想要应用的剪枝程度,你可以控制大小和准确度之间的权衡。

  • 权重量化 — 深度学习模型使用单精度浮点数(float32)权重进行训练。然而,可以将权重量化到 8 位有符号整数(int8),以获得一个仅用于推理的模型,其大小是原来的四分之一,但仍然接近原始模型的准确度。Keras 模型自带内置的quantize() API,可以帮助实现这一点。只需调用model.quantize("int8")即可将模型中的每个权重压缩到一个字节。

监控野外的模型

你已经导出了推理模型,将其集成到你的应用中,并在生产数据上进行了测试运行——模型的行为完全符合你的预期。你已经编写了单元测试以及日志和状态监控代码——完美。现在是你按下那个大红色按钮并将模型部署到生产环境的时候了。

即使这样也不是终点。一旦部署了模型,你需要持续监控其行为、在新数据上的性能、与其他应用的交互,以及最终对业务指标的影响:

  • 在部署新的音乐推荐系统后,你的在线广播的用户参与度是上升还是下降?在切换到新的点击率预测模型后,平均广告点击率是否有所增加?考虑使用随机 A/B 测试来隔离模型本身的影响与其他变化:一部分案例应该通过新的模型,而另一部分控制案例应该坚持旧的过程。一旦处理了足够多的案例,两个结果之间的差异很可能是模型的影响。

  • 如果可能的话,对模型在生产数据上的预测进行定期手动审计。通常可以重用数据标注的基础设施:将一部分生产数据发送进行手动标注,并将模型的预测与新的标注进行比较。例如,你绝对应该为图像搜索引擎和不良 cookie 标记系统做这件事。

  • 当手动审计不可能时,考虑其他评估途径,例如用户调查(例如,在垃圾邮件和违规内容标记系统中)。

维护你的模型

最后,没有模型是永恒的。你已经了解了概念漂移:随着时间的推移,你的生产数据的特征会发生变化,逐渐降低模型的性能和相关性。你的音乐推荐系统的寿命将以周计算。对于信用卡欺诈检测系统,可能是几天;在最佳情况下,图像搜索引擎可能是几年。

一旦你的模型上线,你就应该开始准备训练下一代将取代它的模型:

  • 注意生产数据的变化。是否有新特征可用?你是否应该扩展或修改标签集?

  • 继续收集和标注数据,并随着时间的推移不断改进你的标注流程。特别是,你应该特别注意收集那些似乎难以分类的样本,因为当前模型最有可能帮助提高性能。

这就结束了机器学习的通用工作流程——这有很多事情需要记住。成为专家需要时间和经验,但不用担心,你已经比几章前聪明多了。你现在已经熟悉了大局——机器学习项目涵盖的整个范围。虽然本书的大部分内容将关注模型开发部分,但你现在已经意识到,这仅仅是整个工作流程的一部分。始终牢记大局!

摘要

  • 当你承担一个新的机器学习项目时,首先,定义手头的问题:

    • 理解你着手要做的事情的更广泛背景——最终目标是什么,有什么限制?

    • 收集并标注数据集;确保你深入理解你的数据。

    • 选择你将如何衡量你问题的成功。你将在验证数据上监控哪些指标?

  • 一旦你理解了问题,并且拥有合适的数据集,开发一个模型:

    • 准备你的数据。

    • 选择你的评估协议。保留验证?K 折验证?你应该使用数据集的哪一部分进行验证?

    • 实现统计功效:击败简单的基线。

    • 规模化:开发一个可以过拟合的模型。

    • 根据验证数据上的性能,对模型进行正则化并调整其超参数。许多机器学习研究往往只关注这一步——但请记住大局。

  • 当模型准备就绪,并在测试数据上表现出良好的性能时,就到了部署的时候:

    • 首先,确保与利益相关者设定适当的期望。

    • 优化用于推理的最终模型,并将其部署到所选的部署环境中——例如,Web 服务器、移动设备、浏览器、嵌入式设备等。

    • 监控模型在生产环境中的性能,并持续收集数据,以便开发下一代的模型。

第七章:深入探讨 Keras

原文:deeplearningwithpython.io/chapters/chapter07_deep-dive-keras

你已经开始对 Keras 有了一些经验。你对 Sequential 模型、Dense 层以及用于训练、评估和推理的内置 API(compile()fit()evaluate()predict())都很熟悉。你甚至在第三章学习了如何从 Layer 类继承以创建自定义层,以及如何使用 TensorFlow、JAX 和 PyTorch 中的梯度 API 来实现逐步训练循环。

在接下来的章节中,我们将深入研究计算机视觉、时间序列预测、自然语言处理和生成式深度学习。这些复杂的应用将需要比 Sequential 架构和默认的 fit() 循环多得多的东西。所以,让我们首先把你培养成一个 Keras 专家!在本章中,你将获得使用 Keras API 的关键方法的全面概述:你将需要的一切来处理你接下来会遇到的高级深度学习用例。

工作流程的谱系

Keras API 的设计遵循了 渐进式复杂性披露 原则:易于上手,同时又能处理高复杂度用例,只需在每一步进行增量学习。简单的用例应该易于接近,任意高级的工作流程应该是 可行的:无论你想要做的事情多么小众和复杂,都应该有一条清晰的路径,一条基于你从更简单的工作流程中学到的各种知识的路径。这意味着你可以从初学者成长为专家,同时仍然使用相同的工具——只是以不同的方式。

因此,没有一种“真正”使用 Keras 的方法。相反,Keras 提供了一组 工作流程的谱系,从非常简单到非常灵活。有不同方式来构建 Keras 模型,以及不同的方式来训练它们,满足不同的需求。

例如,你有多种构建模型的方法和多种训练它们的方法,每种方法都代表了可用性和灵活性之间的一定权衡。你可以像使用 scikit-learn 一样使用 Keras——只是调用 fit() 并让框架做它的事情——或者你可以像使用 NumPy 一样使用它——完全控制每一个细节。

由于所有这些工作流程都基于共享的 API,如 LayerModel,任何工作流程的组件都可以用于任何其他工作流程:它们都可以相互通信。这意味着你现在在学习的一切,一旦你成为专家,仍然会相关。你可以轻松上手,然后逐渐深入到需要从头编写更多逻辑的工作流程。在你从学生到研究人员,或从数据科学家到深度学习工程师的过程中,你不需要切换到完全不同的框架。

这种理念与 Python 本身非常相似!一些语言只提供一种编写程序的方式——例如,面向对象编程或函数式编程。而 Python 是一种多范式语言:它提供了一系列可能的用法模式,它们可以很好地协同工作。这使得 Python 适用于广泛的非常不同的用例:系统管理、数据科学、机器学习工程、Web 开发,或者仅仅是学习如何编程。同样,您可以将 Keras 视为深度学习的 Python:一个用户友好的深度学习语言,为不同的用户配置文件提供各种工作流程。

构建 Keras 模型的不同方法

Keras 中有三种构建模型的 API,如图 7.1 所示:

  • Sequential 模型是最易于接近的 API——它基本上是一个 Python 列表。因此,它仅限于简单的层堆叠。

  • 功能 API,专注于类似图的结构化模型架构。它代表了可用性和灵活性之间的一个很好的平衡点,因此,它是使用最广泛的模型构建 API。

  • 模型子类化,这是一个低级选项,您需要从头开始编写一切。如果您希望对每个细节都拥有完全的控制权,这是理想的。然而,您将无法访问许多内置的 Keras 功能,并且更容易犯错。

图 7.1:模型构建的复杂性逐步揭示

Sequential 模型

构建 Keras 模型最简单的方式是Sequential模型,您已经了解过它。

import keras
from keras import layers

model = keras.Sequential(
    [
        layers.Dense(64, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
) 

列表 7.1:Sequential

注意,可以通过add()方法逐步构建相同的模型,类似于 Python 列表的append()方法。

model = keras.Sequential()
model.add(layers.Dense(64, activation="relu"))
model.add(layers.Dense(10, activation="softmax")) 

列表 7.2:逐步构建Sequential模型

您在第三章中看到,层只有在第一次被调用时才会构建(也就是说,创建它们的权重)。这是因为层的权重形状取决于它们的输入形状:直到输入形状已知,它们才能被创建。

因此,之前的Sequential模型在您实际在数据上调用它或使用输入形状调用其build()方法之前没有任何权重。

>>> # At that point, the model isn't built yet.
>>> model.weights
[]

列表 7.3:尚未构建的模型没有权重

>>> # Builds the model. Now the model will expect samples of shape
>>> # (3,). The None in the input shape signals that the batch size
>>> # could be anything.
>>> model.build(input_shape=(None, 3))
>>> # Now you can retrieve the model's weights.
>>> model.weights
[<Variable shape=(3, 64), dtype=float32, path=sequential/dense_2/kernel ...>,
 <Variable shape=(64,), dtype=float32, path=sequential/dense_2/bias ...>,
 <Variable shape=(64, 10), dtype=float32, path=sequential/dense_3/kernel ...>,
 <Variable shape=(10,), dtype=float32, path=sequential/dense_3/bias ...>>]

列表 7.4:首次调用模型以构建它

模型构建完成后,您可以通过summary()方法显示其内容,这对于调试非常有用。

>>> model.summary()
Model: "sequential_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense_2 (Dense)                   │ (None, 64)               │           256 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_3 (Dense)                   │ (None, 10)               │           650 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 906 (3.54 KB)
 Trainable params: 906 (3.54 KB)
 Non-trainable params: 0 (0.00 B)

列表 7.5:summary方法

如您所见,您的模型恰好命名为sequential_1。实际上,您可以在 Keras 中为一切命名——每个模型,每个层。

>>> model = keras.Sequential(name="my_example_model")
>>> model.add(layers.Dense(64, activation="relu", name="my_first_layer"))
>>> model.add(layers.Dense(10, activation="softmax", name="my_last_layer"))
>>> model.build((None, 3))
>>> model.summary()
Model: "my_example_model"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ my_first_layer (Dense)            │ (None, 64)               │           256 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ my_last_layer (Dense)             │ (None, 10)               │           650 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 906 (3.54 KB)
 Trainable params: 906 (3.54 KB)
 Non-trainable params: 0 (0.00 B)

列表 7.6:使用name参数命名模型和层

当逐步构建 Sequential 模型时,能够在添加每个层后打印当前模型的外观摘要非常有用。但是,您必须在模型构建后才能打印摘要!实际上有一种方法可以让您的 Sequential 模型即时构建:只需提前声明模型输入的形状。您可以通过 Input 类来完成此操作。

model = keras.Sequential()
# Use an Input to declare the shape of the inputs. Note that the shape
# argument must be the shape of each sample, not the shape of one
# batch.
model.add(keras.Input(shape=(3,)))
model.add(layers.Dense(64, activation="relu")) 

列表 7.7:提前指定模型输入的形状

现在您可以使用 summary() 来跟踪随着您添加更多层,模型输出形状的变化:

>>> model.summary()
Model: "sequential_2"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense_4 (Dense)                   │ (None, 64)               │           256 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 256 (1.00 KB)
 Trainable params: 256 (1.00 KB)
 Non-trainable params: 0 (0.00 B) 
>>> model.add(layers.Dense(10, activation="softmax"))
>>> model.summary()
Model: "sequential_2"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense_4 (Dense)                   │ (None, 64)               │           256 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_5 (Dense)                   │ (None, 10)               │           650 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 906 (3.54 KB)
 Trainable params: 906 (3.54 KB)
 Non-trainable params: 0 (0.00 B)

这是在处理像第八章中将要学习的卷积层这样的层以复杂方式转换其输入时的一个相当常见的调试工作流程。

功能 API

Sequential 模型易于使用,但其适用性极其有限:它只能表达具有单个输入和单个输出的模型,以顺序方式逐层应用。在实践中,遇到具有多个输入(例如,图像及其元数据)、多个输出(您想要预测的数据的不同方面)或非线性拓扑的模型是很常见的。

在这种情况下,您将使用功能 API 构建模型。这是您在野外遇到的绝大多数 Keras 模型所使用的。它很有趣且功能强大——感觉就像在玩乐高积木。

一个简单的例子

让我们从简单的东西开始:我们在上一节中使用过的两层堆叠。其功能 API 版本如下所示。

inputs = keras.Input(shape=(3,), name="my_input")
features = layers.Dense(64, activation="relu")(inputs)
outputs = layers.Dense(10, activation="softmax")(features)
model = keras.Model(inputs=inputs, outputs=outputs, name="my_functional_model") 

列表 7.8:一个包含两个 Dense 层的简单功能模型

让我们一步一步地过一遍。我们首先声明了一个 Input(请注意,您也可以像其他所有对象一样给这些输入对象命名):

inputs = keras.Input(shape=(3,), name="my_input") 

这个 inputs 对象包含有关模型将处理的数据的形状和 dtype 的信息:

>>> # The model will process batches where each sample has shape (3,).
>>> # The number of samples per batch is variable (indicated by the
>>> # None batch size).
>>> inputs.shape
(None, 3)
>>> # These batches will have dtype float32.
>>> inputs.dtype
"float32"

我们称这样的对象为 符号张量。它不包含任何实际数据,但它编码了模型在您使用它时将看到的实际数据张量的规格。它 代表未来的数据张量。

接下来,我们创建了一个层并在输入上调用它:

features = layers.Dense(64, activation="relu")(inputs) 

所有 Keras 层都可以在真实张量数据或这些符号张量上调用。在后一种情况下,它们返回一个新的符号张量,包含更新后的形状和 dtype 信息:

>>> features.shape
(None, 64)

在获得最终输出后,我们通过在 Model 构造函数中指定其输入和输出实例化了模型:

outputs = layers.Dense(10, activation="softmax")(features)
model = keras.Model(inputs=inputs, outputs=outputs, name="my_functional_model") 

这是我们的模型摘要:

>>> model.summary()
Model: "my_functional_model"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ my_input (InputLayer)             │ (None, 3)                │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_8 (Dense)                   │ (None, 64)               │           256 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_9 (Dense)                   │ (None, 10)               │           650 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 906 (3.54 KB)
 Trainable params: 906 (3.54 KB)
 Non-trainable params: 0 (0.00 B)

多输入、多输出模型

与这个玩具模型不同,大多数深度学习模型看起来不像列表——它们看起来像图。例如,它们可能有多个输入或多个输出。正是为了这种类型的模型,功能 API 才真正闪耀。

假设你正在构建一个系统,根据优先级对客户支持工单进行排序并将它们路由到适当的部门。你的模型有三个输入:

  • 工单的标题(文本输入)

  • 工单的文本正文(文本输入)

  • 用户添加的任何标签(分类输入,假设为多热编码)

我们可以将文本输入编码为大小为vocabulary_size的 1s 和 0s 的数组(有关文本编码技术的详细信息,请参阅第十四章)。

你的模型也有两个输出:

  • 票据的优先级分数,介于 0 和 1 之间的标量(sigmoid 输出)

  • 应该处理票据的部门(部门集合上的 softmax)

你可以使用功能 API 在几行代码内构建这个模型。

vocabulary_size = 10000
num_tags = 100
num_departments = 4

# Defines model inputs
title = keras.Input(shape=(vocabulary_size,), name="title")
text_body = keras.Input(shape=(vocabulary_size,), name="text_body")
tags = keras.Input(shape=(num_tags,), name="tags")

# Combines input features into a single tensor, features, by
# concatenating them
features = layers.Concatenate()([title, text_body, tags])
# Applies intermediate layer to recombine input features into richer
# representations
features = layers.Dense(64, activation="relu", name="dense_features")(features)

# Defines model outputs
priority = layers.Dense(1, activation="sigmoid", name="priority")(features)
department = layers.Dense(
    num_departments, activation="softmax", name="department"
)(features)

# Creates the model by specifying its inputs and outputs
model = keras.Model(
    inputs=[title, text_body, tags],
    outputs=[priority, department],
) 

列表 7.9:一个多输入、多输出的功能模型

功能 API 是一种简单、类似乐高且非常灵活的方式来定义任意层图,如这些。

训练多输入、多输出模型

你可以通过调用fit()并传递输入和输出数据的列表来以与训练Sequential模型相同的方式训练你的模型。这些数据列表应遵循与传递给Model()构造函数的输入相同的顺序。

import numpy as np

num_samples = 1280

# Dummy input data
title_data = np.random.randint(0, 2, size=(num_samples, vocabulary_size))
text_body_data = np.random.randint(0, 2, size=(num_samples, vocabulary_size))
tags_data = np.random.randint(0, 2, size=(num_samples, num_tags))

# Dummy target data
priority_data = np.random.random(size=(num_samples, 1))
department_data = np.random.randint(0, num_departments, size=(num_samples, 1))

model.compile(
    optimizer="adam",
    loss=["mean_squared_error", "sparse_categorical_crossentropy"],
    metrics=[["mean_absolute_error"], ["accuracy"]],
)
model.fit(
    [title_data, text_body_data, tags_data],
    [priority_data, department_data],
    epochs=1,
)
model.evaluate(
    [title_data, text_body_data, tags_data], [priority_data, department_data]
)
priority_preds, department_preds = model.predict(
    [title_data, text_body_data, tags_data]
) 

列表 7.10:通过提供输入和目标数组的列表来训练模型

如果你不想依赖于输入顺序(例如,因为你有很多输入或输出),你也可以使用你给Input对象和输出层起的名字,并通过字典传递数据。

model.compile(
    optimizer="adam",
    loss={
        "priority": "mean_squared_error",
        "department": "sparse_categorical_crossentropy",
    },
    metrics={
        "priority": ["mean_absolute_error"],
        "department": ["accuracy"],
    },
)
model.fit(
    {"title": title_data, "text_body": text_body_data, "tags": tags_data},
    {"priority": priority_data, "department": department_data},
    epochs=1,
)
model.evaluate(
    {"title": title_data, "text_body": text_body_data, "tags": tags_data},
    {"priority": priority_data, "department": department_data},
)
priority_preds, department_preds = model.predict(
    {"title": title_data, "text_body": text_body_data, "tags": tags_data}
) 

列表 7.11:通过提供输入和目标数组的字典来训练模型

功能 API 的强大之处:访问层连接性

功能模型是一个显式的图数据结构。这使得可以检查层是如何连接的,并且可以将之前的图节点(层输出)作为新模型的一部分进行重用。它也很好地符合大多数研究人员在思考深度神经网络时使用的“心智模型”:层图。

这使得两个重要的用例成为可能:模型可视化和特征提取。让我们看看。

绘制层连接性

让我们可视化我们刚刚定义的模型的连接性(模型的拓扑结构)。你可以使用plot_model()实用工具将功能模型作为图形绘制出来,如图 7.2 所示:

keras.utils.plot_model(model, "ticket_classifier.png") 

图 7.2:plot_model()在我们票据分类器模型上生成的绘图

你可以向此图添加模型中每一层的输入和输出形状,以及层名(而不仅仅是层类型),这在调试期间可能很有帮助(图 7.3):

keras.utils.plot_model(
    model,
    "ticket_classifier_with_shape_info.png",
    show_shapes=True,
    show_layer_names=True,
) 

图 7.3:添加了形状信息的模型绘图

张量形状中的None表示批处理大小:此模型允许任何大小的批处理。

使用功能模型进行特征提取

访问层连接性还意味着你可以检查和重用图中的单个节点(层调用)。模型属性model.layers提供了构成模型的层列表,对于每一层,你可以查询layer.inputlayer.output

>>> model.layers
[<InputLayer name=title, built=True>,
 <InputLayer name=text_body, built=True>,
 <InputLayer name=tags, built=True>,
 <Concatenate name=concatenate, built=True>,
 <Dense name=dense_10, built=True>,
 <Dense name=priority, built=True>,
 <Dense name=department, built=True>] 
>>> model.layers[3].input
[<KerasTensor shape=(None, 10000), dtype=float32, sparse=None, name=title>,
 <KerasTensor shape=(None, 10000), dtype=float32, sparse=None, name=text_body>,
 <KerasTensor shape=(None, 100), dtype=float32, sparse=None, name=tags>] 
>>> model.layers[3].output
<KerasTensor shape=(None, 20100), dtype=float32, sparse=False>

代码列表 7.12:在功能模型中检索层的输入或输出

这使你能够进行特征提取:创建重用另一个模型中间特征的模型。

假设你想向之前定义的模型添加另一个输出——你想要预测给定问题工单解决所需的时间估计,一种难度评级。你可以通过三个类别——“快速”、“中等”和“困难”——的分类层来实现这一点。你不需要从头开始重新创建和重新训练模型!你可以从之前模型的中间特征开始,因为你已经可以访问它们。

# layers[4] is our intermediate Dense layer.
features = model.layers[4].output
difficulty = layers.Dense(3, activation="softmax", name="difficulty")(features)

new_model = keras.Model(
    inputs=[title, text_body, tags], outputs=[priority, department, difficulty]
) 

代码列表 7.13:通过重用中间层输出创建新模型

让我们绘制我们的新模型,如图 7.4 所示:

keras.utils.plot_model(
    new_model,
    "updated_ticket_classifier.png",
    show_shapes=True,
    show_layer_names=True,
) 

图 7.4:我们新模型的绘图

子类化Model

你应该了解的最后一种模型构建模式是最先进的:Model子类化。你已经在第三章学习了如何子类化Layer类来创建自定义层。子类化Model非常相似:

  • __init__方法中,定义模型将使用的层。

  • call方法中,定义模型的正向传递,重用之前创建的层。

  • 实例化你的子类,并在数据上调用它以创建其权重。

将我们的前一个示例重写为子类化模型

让我们看看一个简单的例子:我们将使用Model子类重新实现客户支持工单管理模型。

class CustomerTicketModel(keras.Model):
    def __init__(self, num_departments):
        # Don't forget to call the super constructor!
        super().__init__()
        # Defines sublayers in the constructor
        self.concat_layer = layers.Concatenate()
        self.mixing_layer = layers.Dense(64, activation="relu")
        self.priority_scorer = layers.Dense(1, activation="sigmoid")
        self.department_classifier = layers.Dense(
            num_departments, activation="softmax"
        )

    # Defines the forward pass in the call() method
    def call(self, inputs):
        title = inputs["title"]
        text_body = inputs["text_body"]
        tags = inputs["tags"]

        features = self.concat_layer([title, text_body, tags])
        features = self.mixing_layer(features)
        priority = self.priority_scorer(features)
        department = self.department_classifier(features)
        return priority, department 

代码列表 7.14:一个简单的子类化模型

一旦你定义了模型,你就可以实例化它。注意,它只会在你第一次在某个数据上调用它时创建其权重——就像Layer子类一样:

model = CustomerTicketModel(num_departments=4)

priority, department = model(
    {"title": title_data, "text_body": text_body_data, "tags": tags_data}
) 

到目前为止,一切看起来都非常类似于Layer子类化,这是你在第三章中已经遇到的工作流程。那么,Layer子类和Model子类之间的区别是什么?很简单:是你用来创建模型的基本构建块,而模型是你将实际训练、导出用于推理等的顶级对象。简而言之,Modelfit()evaluate()predict()方法。层没有。除此之外,这两个类在功能上几乎相同(另一个区别是你可以将模型保存到磁盘上的文件——我们将在接下来的几节中介绍)。

你可以像对 Sequential 或 Functional 模型一样编译和训练Model子类:

model.compile(
    optimizer="adam",
    # The structure of what you pass as the loss and metrics must match
    # exactly what gets returned by call() — since we returned a list
    # of two elements, so should loss and metrics be lists of two
    # elements.
    loss=["mean_squared_error", "sparse_categorical_crossentropy"],
    metrics=[["mean_absolute_error"], ["accuracy"]],
)
model.fit(
    # The structure of the input data must match exactly what is
    # expected by the call() method, and the structure of the target
    # data must match exactly what gets returned by the call() method.
    # Here, the input data must be a dict with three keys (title,
    # text_body, and tags) and the target data must be a list of two
    # elements.
    {"title": title_data, "text_body": text_body_data, "tags": tags_data},
    [priority_data, department_data],
    epochs=1,
)
model.evaluate(
    {"title": title_data, "text_body": text_body_data, "tags": tags_data},
    [priority_data, department_data],
)
priority_preds, department_preds = model.predict(
    {"title": title_data, "text_body": text_body_data, "tags": tags_data}
) 

Model子类化工作流程是构建模型最灵活的方式:它允许你构建无法表示为层的有向无环图(DAG)的模型——例如,想象一个call()方法在for循环中使用层,或者甚至递归调用它们的模型。任何可能的事情都是可能的——你说了算。

注意:子类化模型不支持的功能

这种自由是有代价的:使用子类化模型,你需要负责更多的模型逻辑,这意味着你的潜在错误面要大得多。因此,你将需要进行更多的调试工作。你正在开发一个新的 Python 对象,而不仅仅是拼接乐高积木。

函数模型和子类化模型在本质上也有很大不同:函数模型是一个显式的数据结构——一个层的图,你可以查看、检查和修改。与此同时,子类化模型是一段字节码——一个包含call()方法的 Python 类,其中包含原始代码。这是子类化工作流程灵活性的来源——你可以编写任何你喜欢的功能——但它也引入了新的限制。

例如,因为层之间的连接方式隐藏在call()方法的主体中,你无法访问这些信息。调用summary()不会显示层连接,你也不能通过plot_model()来绘制模型拓扑。同样,如果你有一个子类化模型,你无法访问层的图来执行特征提取——因为根本不存在这样的图。一旦模型被实例化,其前向传递就变成了一个完全的黑盒。

混合和匹配不同的组件

关键的是,选择这些模式之一——Sequential模型、函数 API、Model子类化——并不会将你排除在其他模式之外。Keras API 中的所有模型都可以与其他模型无缝交互,无论是Sequential模型、函数模型,还是从头开始编写的子类模型。它们都是同一工作流程谱系的一部分。例如,你可以在函数模型中使用子类化的层或模型。

class Classifier(keras.Model):
    def __init__(self, num_classes=2):
        super().__init__()
        if num_classes == 2:
            num_units = 1
            activation = "sigmoid"
        else:
            num_units = num_classes
            activation = "softmax"
        self.dense = layers.Dense(num_units, activation=activation)

    def call(self, inputs):
        return self.dense(inputs)

inputs = keras.Input(shape=(3,))
features = layers.Dense(64, activation="relu")(inputs)
outputs = Classifier(num_classes=10)(features)
model = keras.Model(inputs=inputs, outputs=outputs) 

列表 7.15:创建一个包含子类模型的函数模型

相反,你还可以将函数模型用作子类化层或模型的一部分。

inputs = keras.Input(shape=(64,))
outputs = layers.Dense(1, activation="sigmoid")(inputs)
binary_classifier = keras.Model(inputs=inputs, outputs=outputs)

class MyModel(keras.Model):
    def __init__(self, num_classes=2):
        super().__init__()
        self.dense = layers.Dense(64, activation="relu")
        self.classifier = binary_classifier

    def call(self, inputs):
        features = self.dense(inputs)
        return self.classifier(features)

model = MyModel() 

列表 7.16:创建一个包含函数模型的子类模型

记住:使用合适的工具来完成工作

你已经了解了构建 Keras 模型的工作流程谱系,从最简单的Sequential模型到最复杂的模型子类化。何时应该使用一个而不是另一个?每个都有其优缺点——选择最适合当前任务的那个。

通常,函数 API 在易用性和灵活性之间提供了一个相当不错的权衡。它还提供了对层连接的直接访问,这对于模型绘图或特征提取等用例非常强大。如果你可以使用函数 API——也就是说,如果你的模型可以表示为一个层的有向无环图——我们建议你使用它而不是模型子类化。

从现在起,本书中的所有示例都将使用功能 API——仅仅因为我们将要处理的模型都可以表示为层的图。然而,我们将会频繁地使用派生层。一般来说,使用包含派生层的功能模型可以提供两全其美的效果:高开发灵活性同时保留功能 API 的优势。

使用内置的训练和评估循环

渐进式披露复杂性的原则——逐步访问从非常简单到任意灵活的工作流程范围——也适用于模型训练。Keras 为您提供了不同的模型训练工作流程——它可以简单到在您的数据上调用fit(),也可以复杂到从头编写新的训练算法。

您已经熟悉compile()fit()evaluate()predict()工作流程。作为提醒,它看起来如下所示。

from keras.datasets import mnist

# Creates a model. (We factor this into a separate function so as to
# reuse it later.)
def get_mnist_model():
    inputs = keras.Input(shape=(28 * 28,))
    features = layers.Dense(512, activation="relu")(inputs)
    features = layers.Dropout(0.5)(features)
    outputs = layers.Dense(10, activation="softmax")(features)
    model = keras.Model(inputs, outputs)
    return model

# Loads your data, reserving some for validation
(images, labels), (test_images, test_labels) = mnist.load_data()
images = images.reshape((60000, 28 * 28)).astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28)).astype("float32") / 255
train_images, val_images = images[10000:], images[:10000]
train_labels, val_labels = labels[10000:], labels[:10000]

model = get_mnist_model()
# Compiles the model by specifying its optimizer, the loss function to
# minimize, and metrics to monitor
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
# Uses `fit()` to train the model, optionally providing validation data
# to monitor performance on unseen data
model.fit(
    train_images,
    train_labels,
    epochs=3,
    validation_data=(val_images, val_labels),
)
# Uses `evaluate()` to compute the loss and metrics on new data
test_metrics = model.evaluate(test_images, test_labels)
# Uses `predict()` to compute classification probabilities on new data
predictions = model.predict(test_images) 

列表 7.17:标准工作流程:compile()fit()evaluate()predict()

有几种方法可以自定义这个简单的流程:

  • 通过提供您自己的自定义指标

  • 通过将回调传递给fit()方法来安排在训练过程中的特定点执行的操作

让我们来看看这些。

编写您自己的指标

指标是衡量模型性能的关键——特别是衡量其在训练数据和测试数据上的性能差异。常用的分类和回归指标已经包含在内置的keras.metrics模块中——大多数情况下,您将使用它。但如果您正在做任何非同寻常的事情,您将需要能够编写自己的指标。这很简单!

Keras 指标是keras.metrics.Metric类的子类。与层类似,指标有一个存储在 Keras 变量中的内部状态。与层不同,这些变量不是通过反向传播更新的,因此您必须自己编写状态更新逻辑——这发生在update_state()方法中。例如,这里有一个简单的自定义指标,用于衡量均方根误差(RMSE)。

from keras import ops

# Subclasses the Metric class
class RootMeanSquaredError(keras.metrics.Metric):
    # Defines the state variables in the constructor. Like for layers,
    # you have access to the add_weight() method.
    def __init__(self, name="rmse", **kwargs):
        super().__init__(name=name, **kwargs)
        self.mse_sum = self.add_weight(name="mse_sum", initializer="zeros")
        self.total_samples = self.add_weight(
            name="total_samples", initializer="zeros"
        )

    # Implements the state update logic in update_state(). The y_true
    # argument is the targets (or labels) for one batch, while y_pred
    # represents the corresponding predictions from the model. To match
    # our MNIST model, we expect categorical predictions and integer
    # labels. You can ignore the sample_weight argument; we won't use
    # it here.
    def update_state(self, y_true, y_pred, sample_weight=None):
        y_true = ops.one_hot(y_true, num_classes=ops.shape(y_pred)[1])
        mse = ops.sum(ops.square(y_true - y_pred))
        self.mse_sum.assign_add(mse)
        num_samples = ops.shape(y_pred)[0]
        self.total_samples.assign_add(num_samples) 

列表 7.18:通过继承Metric类实现自定义指标

您可以使用result()方法来返回当前指标值:

 def result(self):
        return ops.sqrt(self.mse_sum / self.total_samples) 

同时,您还需要提供一个方法来重置指标状态,而无需重新实例化它——这允许相同的指标对象在不同的训练时期或训练和评估之间使用。您可以在reset_state()方法中这样做:

 def reset_state(self):
        self.mse_sum.assign(0.)
        self.total_samples.assign(0.) 

自定义指标可以像内置指标一样使用。让我们测试一下我们自己的指标:

model = get_mnist_model()
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy", RootMeanSquaredError()],
)
model.fit(
    train_images,
    train_labels,
    epochs=3,
    validation_data=(val_images, val_labels),
)
test_metrics = model.evaluate(test_images, test_labels) 

您现在可以看到fit()进度条显示您模型的 RMSE。

使用回调

使用 model.fit() 在大型数据集上运行数十个 epoch 的训练可能有点像发射纸飞机:在初始推动之后,您对其轨迹或着陆点没有任何控制。如果您想避免不良结果(以及因此浪费的纸飞机),更明智的做法是使用,不是纸飞机,而是一个能够感知其环境的无人机,将数据发送回其操作员,并根据其当前状态自动做出转向决策。Keras 回调 API 将帮助您将 model.fit() 的调用从纸飞机转变为一个智能、自主的无人机,它可以自我反思并动态采取行动。

回调 是一个对象(一个实现特定方法的类实例),在调用 fit() 时传递给模型,并在训练的各个阶段由模型调用。它有权访问有关模型状态及其性能的所有可用数据,并且可以采取行动:中断训练、保存模型、加载不同的权重集,或者以其他方式改变模型的状态。

这里有一些您可以使用回调的例子:

  • 模型检查点 — 在训练的不同阶段保存模型的当前状态。

  • 早期停止 — 当验证损失不再改善时(当然,保存训练期间获得的最佳模型)中断训练。

  • 在训练期间动态调整某些参数的值 — 例如优化器的学习率。

  • 在训练期间记录训练和验证指标,或者可视化模型在更新过程中学习到的表示 — 您熟悉的 fit() 进度条实际上是一个回调!

keras.callbacks 模块包含了许多内置回调(这并不是一个详尽的列表):

keras.callbacks.ModelCheckpoint
keras.callbacks.EarlyStopping
keras.callbacks.LearningRateScheduler
keras.callbacks.ReduceLROnPlateau
keras.callbacks.CSVLogger 

让我们回顾其中两个,以便您了解如何使用它们:EarlyStoppingModelCheckpoint

EarlyStopping 和 ModelCheckpoint 回调

当您训练一个模型时,在开始时有很多事情是无法预测的。特别是,您无法知道需要多少个 epoch 才能达到最佳的验证损失。我们之前的例子已经采用了训练足够多的 epoch,以至于您开始过拟合的策略,使用第一次运行来确定最佳的 epoch 数量,然后最终从头开始使用这个最佳数量启动新的训练运行。当然,这种方法是浪费的。一个更好的处理方法是,当您测量到验证损失不再改善时停止训练。这可以通过使用 EarlyStopping 回调来实现。

EarlyStopping回调在目标监控指标停止改进固定数量的 epochs 后中断训练。例如,这个回调允许你在开始过拟合时立即中断训练,从而避免需要重新训练模型以更少的 epochs。这个回调通常与ModelCheckpoint一起使用,它允许你在训练过程中持续保存模型(并且可选地只保存迄今为止的最佳模型:在 epoch 结束时实现最佳性能的模型版本)。

# Callbacks are passed to the model via the callbacks argument in
# fit(), which takes a list of callbacks. You can pass any number of
# callbacks.
callbacks_list = [
    # Interrupts training when improvement stops
    keras.callbacks.EarlyStopping(
        # Monitors the model's validation accuracy
        monitor="accuracy",
        # Interrupts training when accuracy has stopped improving for
        # more than one epoch (that is, two epochs)
        patience=1,
    ),
    # Saves the current weights after every epoch
    keras.callbacks.ModelCheckpoint(
        # Path to the destination model file
        filepath="checkpoint_path.keras",
        # These two arguments mean you won't overwrite the model file
        # unless val_loss has improved, which allows you to keep the
        # best model seen during training.
        monitor="val_loss",
        save_best_only=True,
    ),
]
model = get_mnist_model()
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    # You monitor accuracy, so it should be part of the model's
    # metrics.
    metrics=["accuracy"],
)
# Because the callback will monitor validation loss and validation
# accuracy, you need to pass validation_data to the call to fit().
model.fit(
    train_images,
    train_labels,
    epochs=10,
    callbacks=callbacks_list,
    validation_data=(val_images, val_labels),
) 

列表 7.19:在fit()方法中使用callbacks参数

注意,你可以在训练后手动保存模型——只需调用model.save("my_checkpoint_path.keras")。要重新加载你保存的模型,使用

model = keras.models.load_model("checkpoint_path.keras") 

编写你自己的回调

如果你需要在训练期间执行一个不是由内置回调覆盖的特定操作,你可以编写自己的回调。回调是通过继承keras.callbacks.Callback类实现的。然后你可以透明地实现以下方法,这些方法在训练的不同阶段被调用:

# Called at the start of every epoch
on_epoch_begin(epoch, logs)
# Called at the end of every epoch
on_epoch_end(epoch, logs)
# Called right before processing each batch
on_batch_begin(batch, logs)
# Called right after processing each batch
on_batch_end(batch, logs)
# Called at the start of training
on_train_begin(logs)
# Called at the end of training
on_train_end(logs) 

这些方法都是使用logs参数调用的,它是一个包含有关先前批次、epoch 或训练运行信息的字典:训练和验证指标等。on_epoch_*on_batch_*方法也接受 epoch 或批次索引作为第一个参数(一个整数)。

这里有一个简单的示例回调,它在训练过程中保存每个批次的损失值列表,并在每个 epoch 结束时绘制这些值。

from matplotlib import pyplot as plt

class LossHistory(keras.callbacks.Callback):
    def on_train_begin(self, logs):
        self.per_batch_losses = []

    def on_batch_end(self, batch, logs):
        self.per_batch_losses.append(logs.get("loss"))

    def on_epoch_end(self, epoch, logs):
        plt.clf()
        plt.plot(
            range(len(self.per_batch_losses)),
            self.per_batch_losses,
            label="Training loss for each batch",
        )
        plt.xlabel(f"Batch (epoch {epoch})")
        plt.ylabel("Loss")
        plt.legend()
        plt.savefig(f"plot_at_epoch_{epoch}", dpi=300)
        self.per_batch_losses = [] 

列表 7.20:通过继承Callback类创建自定义回调

让我们试驾一下:

model = get_mnist_model()
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    train_images,
    train_labels,
    epochs=10,
    callbacks=[LossHistory()],
    validation_data=(val_images, val_labels),
) 

我们得到如图 7.5 所示的图表。

图 7.5:我们自定义的历史绘图回调的输出

使用 TensorBoard 进行监控和可视化

要进行良好的研究或开发良好的模型,你需要在你实验过程中对模型内部发生情况的丰富、频繁的反馈。这就是进行实验的目的:获取有关模型表现如何的信息——尽可能多的信息。取得进步是一个迭代过程,一个循环:你从一个想法开始,将其表达为一个实验,试图验证或证伪你的想法。你运行这个实验并处理它生成的信息,如图 7.6 所示。这激发了你下一个想法。你能运行这个循环的迭代次数越多,你的想法就越精细、越强大。Keras 帮助你以最短的时间从想法到实验,快速的 GPU 可以帮助你尽可能快地从实验到结果。但是,如何处理实验结果呢?这就是 TensorBoard 发挥作用的地方。

图 7.6:进步的循环

TensorBoard 是一个基于浏览器的应用程序,您可以在本地运行。它是监控训练过程中模型内部发生的一切的最佳方式。使用 TensorBoard,您可以

  • 在训练期间可视化指标

  • 可视化您的模型架构

  • 可视化激活和梯度的直方图

  • 探索 3D 中的嵌入

如果您监控的信息不仅仅是模型的最终损失,您可以对模型做什么以及不做什么有一个更清晰的了解,并且可以更快地取得进展。

使用 TensorBoard 与 Keras 模型和fit()方法的最简单方法是keras.callbacks.TensorBoard回调。在最简单的情况下,只需指定回调要写入日志的位置,然后就可以开始了:

model = get_mnist_model()
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

tensorboard = keras.callbacks.TensorBoard(
    log_dir="/full_path_to_your_log_dir",
)
model.fit(
    train_images,
    train_labels,
    epochs=10,
    validation_data=(val_images, val_labels),
    callbacks=[tensorboard],
) 

一旦模型开始运行,它将在目标位置写入日志。如果您在本地机器上运行 Python 脚本,您可以使用以下命令启动本地 TensorBoard 服务器(注意,如果通过pip安装了 TensorFlow,则tensorboard可执行文件应该已经可用;如果没有,您可以通过pip install tensorboard手动安装 TensorBoard):

tensorboard --logdir /full_path_to_your_log_dir 

然后,您可以导航到命令返回的 URL 以访问 TensorBoard 界面。

如果您在 Colab 笔记本中运行脚本,您可以使用以下命令作为笔记本的一部分运行嵌入的 TensorBoard 实例:

%load_ext tensorboard
%tensorboard --logdir /full_path_to_your_log_dir 

在 TensorBoard 界面中,您将能够监控训练和评估指标的实时图表,如图 7.7 所示。

图片

图 7.7:TensorBoard 可以用于轻松监控训练和评估指标。

编写自己的训练和评估循环

fit()工作流程在易用性和灵活性之间取得了很好的平衡。您将大部分时间都会使用它。然而,它并不旨在支持深度学习研究人员可能想要做的所有事情——即使有自定义指标、自定义损失和自定义回调。

毕竟,内置的fit()工作流程仅专注于监督学习:这是一种存在已知目标(也称为标签注释)与您的输入数据相关联的设置,并且您将损失计算为目标函数和模型预测的函数。然而,并非所有形式的机器学习都属于这一类别。还有其他设置,其中不存在显式的目标,例如生成学习(我们将在第十六章中介绍),自监督学习(目标从输入中获取)或强化学习(学习由偶尔的“奖励”驱动——就像训练狗一样)。即使您正在进行常规的监督学习,作为研究人员,您可能希望添加一些需要低级灵活性的新颖功能。

无论何时你发现自己处于内置的fit()不足以应对的情况,你将需要编写自己的自定义训练逻辑。你已经在第二章和第三章中看到了低级训练循环的简单示例。作为提醒,典型训练循环的内容如下:

  1. 运行“前向传递”(计算模型的输出)以获得当前批次数据的损失值。

  2. 检索关于模型权重的损失梯度。

  3. 更新模型的权重,以降低当前批次数据的损失值。

这些步骤会重复进行,直到达到必要的批次数量。这本质上就是fit()在底层所做的事情。在本节中,你将学习从头开始重新实现fit(),这将为你提供编写任何可能想到的训练算法所需的所有知识。

让我们详细了解一下。在接下来的几节中,你将逐步学习如何在 TensorFlow、PyTorch 和 JAX 中编写一个功能齐全的自定义训练循环。

训练与推理

在你迄今为止看到的低级训练循环示例中,步骤 1(前向传递)是通过predictions = model(inputs)完成的,而步骤 2(通过后端特定的 API 检索由梯度记录计算出的梯度)是通过以下方式完成的:

  • TensorFlow 中的gradients = tape.gradient(loss, model.weights)

  • PyTorch 中的loss.backward()

  • JAX 中的jax.value_and_grad()

在一般情况中,实际上有两个细微之处你需要考虑。

一些 Keras 层,如Dropout层,在训练期间和推理期间(当你使用它们生成预测时)有不同的行为。这些层在其call()方法中暴露一个training布尔参数。调用dropout(inputs, training=True)将丢弃一些激活条目,而调用dropout(inputs, training=False)则不执行任何操作。通过扩展,Functional 模型和 Sequential 模型也在它们的call()方法中暴露了这个training参数。记住,在正向传递时调用 Keras 模型时,要传递training=True!因此,我们的正向传递变为predictions = model(inputs, training=True)

此外,请注意,当你检索模型权重的梯度时,不应使用model.weights,而应使用model.trainable_weights。确实,层和模型拥有两种类型的权重:

  • 可训练权重,意味着通过反向传播来最小化模型损失,例如Dense层的核和偏置。

  • 不可训练权重,意味着在正向传递过程中由拥有它们的层进行更新。例如,如果你想让一个自定义层保持一个计数器,记录它已经处理了多少批次,那么这个信息将存储在一个不可训练的权重中,并且在每个批次中,你的层将计数器增加一。

在 Keras 内置层中,唯一具有非可训练权重的层是 BatchNormalization 层,我们将在第九章中介绍。BatchNormalization 层需要非可训练的权重来跟踪通过它的数据的均值和标准差信息,以便执行 特征归一化(你在第四章和第六章中学到的概念)的在线近似。

编写自定义训练步骤函数

考虑这两个细节,监督学习训练步骤在伪代码中看起来是这样的:

def train_step(inputs, targets):
    # Runs the forward pass
    predictions = model(inputs, training=True)
    # Computes the loss for the current batch
    loss = loss_fn(targets, predictions)
    # Retrieves the gradients of the loss with regard to the model's
    # trainable weights This function doesn't actually exist!
    gradients = get_gradients_of(loss, wrt=model.trainable_weights)
    # Updates the model's trainable weights based on the gradients
    optimizer.apply(gradients, model.trainable_weights) 

这段代码是伪代码而不是真实代码,因为它包含了一个想象中的函数,get_gradients_of()。实际上,检索梯度是以你当前后端(JAX、TensorFlow 或 PyTorch)特有的方式完成的。

让我们利用第三章中关于每个框架的知识来实现这个 train_step() 函数的真正版本。我们将从 TensorFlow 和 PyTorch 开始,因为这两个框架使这项工作相对容易,所以这是一个好的起点。我们将以 JAX 结尾,它相当复杂。

TensorFlow 训练步骤函数

TensorFlow 允许你编写看起来几乎与我们的伪代码片段相同的代码。唯一的区别是,你的前向传播应该在 GradientTape 范围内进行。然后你可以使用 tape 对象来检索梯度:

import tensorflow as tf

model = get_mnist_model()
loss_fn = keras.losses.SparseCategoricalCrossentropy()
optimizer = keras.optimizers.Adam()

def train_step(inputs, targets):
    # Opens a GradientTape
    with tf.GradientTape() as tape:
        # Runs the forward pass
        predictions = model(inputs, training=True)
        loss = loss_fn(targets, predictions)
    # Retrieves the gradients from the tape
    gradients = tape.gradient(loss, model.trainable_weights)
    # Updates the model's trainable weights based on the gradients
    optimizer.apply(gradients, model.trainable_weights)
    return loss 

让我们运行一个单步操作:

batch_size = 32
inputs = train_images[:batch_size]
targets = train_labels[:batch_size]
loss = train_step(inputs, targets) 

很简单!让我们接下来做 PyTorch。

PyTorch 训练步骤函数

当你使用 PyTorch 后端时,所有的 Keras 层和模型都继承自 PyTorch 的 torch.nn.Module 类,并暴露了原生的 Module API。因此,你的模型、其可训练的权重以及你的损失张量都彼此了解,并通过三种方法进行交互:loss.backward()weight.value.gradmodel.zero_grad()

作为第三章的提醒,你必须记住的思考模式是这样的:

  • 每次前向传播时,PyTorch 都会构建一个一次性的计算图,该图跟踪刚刚发生的计算。

  • 在此图的任何给定标量节点(如你的损失)上调用 .backward() 将从该节点开始运行图的反向传播,自动在所有涉及的张量上填充 tensor.grad 属性(如果它们满足 requires_grad=True),包含输出节点相对于该张量的梯度。特别是,它将填充你的可训练参数的 grad 属性。

  • 要清除 tensor.grad 属性的内容,你应该在所有你的张量上调用 tensor.grad = None。因为逐个对模型变量执行此操作可能会有些繁琐,你可以在模型级别通过 model.zero_grad() 来完成——zero_grad() 调用将传播到模型跟踪的所有变量。清除梯度是至关重要的,因为 backward() 调用是累加的:如果你不在每一步清除梯度,梯度值将累积,训练将无法进行。

让我们链接这些步骤:

import torch

model = get_mnist_model()
loss_fn = keras.losses.SparseCategoricalCrossentropy()
optimizer = keras.optimizers.Adam()

def train_step(inputs, targets):
    # Runs the forward pass
    predictions = model(inputs, training=True)
    loss = loss_fn(targets, predictions)
    # Runs the backward pass, populating gradient values
    loss.backward()
    # Recovers the gradient associated with each trainable variable.
    # That weight.value is the PyTorch tensor that contains the
    # variable's value.
    gradients = [weight.value.grad for weight in model.trainable_weights]
    # Updates the model's trainable weights based on the gradients.
    # This must be done in a no_grad() scope.
    with torch.no_grad():
        optimizer.apply(gradients, model.trainable_weights)
    # Don't forget to clear the gradients!
    model.zero_grad()
    return loss 

让我们运行一个单步操作:

batch_size = 32
inputs = train_images[:batch_size]
targets = train_labels[:batch_size]
loss = train_step(inputs, targets) 

这并不太难!现在,让我们继续学习 JAX。

一个 JAX 训练步骤函数

当涉及到低级训练代码时,由于其完全无状态的性质,JAX 通常是三个后端中最复杂的。无状态使得 JAX 高度性能和可扩展,使其适合编译和自动性能优化。然而,编写无状态代码需要你跳过一些障碍。

由于梯度函数是通过元编程获得的,因此你首先需要定义一个返回你的损失函数。此外,这个函数需要是无状态的,因此它需要接受所有它将要使用的变量作为参数,并且它需要返回任何它已更新的变量的值。还记得那些在正向传播过程中可以修改的非训练权重吗?这些就是我们需要的变量。

为了使它更容易与 JAX 的无状态编程范式一起工作,Keras 模型提供了一个无状态的前向传递方法:stateless_call() 方法。它的行为就像 __call__,除了

  • 它接受模型的训练权重和非训练权重作为输入,以及 inputstraining 参数。

  • 它除了返回模型的输出外,还返回模型更新的非训练权重。

它的工作原理是这样的:

outputs, non_trainable_weights = model.stateless_call(
    trainable_weights, non_trainable_weights, inputs
) 

我们可以使用 stateless_call() 来实现我们的 JAX 损失函数。由于损失函数还计算所有非训练变量的更新,我们将其命名为 compute_loss_and_updates()

model = get_mnist_model()
loss_fn = keras.losses.SparseCategoricalCrossentropy()

# Gradients are computed for the entries in the first argument
# (trainable_variables here)
def compute_loss_and_updates(
    trainable_variables, non_trainable_variables, inputs, targets
):
    # Calls stateless_call
    outputs, non_trainable_variables = model.stateless_call(
        trainable_variables, non_trainable_variables, inputs, training=True
    )
    loss = loss_fn(targets, outputs)
    # Returns the scalar loss value and the updated non-trainable
    # weights
    return loss, non_trainable_variables 

一旦我们有了这个 compute_loss_and_updates() 函数,我们就可以将其传递给 jax.value_and_grad 来获得梯度计算:

import jax

grad_fn = jax.value_and_grad(fn)
loss, gradients = grad_fn(...) 

现在,有一个小问题。jax.grad()jax.value_and_grad() 都需要 fn 只返回一个标量值。我们的 compute_loss_and_updates() 函数作为其第一个输出返回一个标量值,但它还返回非训练权重的新的值。还记得你在第三章中学到的吗?解决方案是将 has_aux 参数传递给 grad()value_and_grad(),如下所示:

import jax

grad_fn = jax.value_and_grad(compute_loss_and_updates, has_aux=True) 

你会这样使用它:

(loss, non_trainable_weights), gradients = grad_fn(
    trainable_variables, non_trainable_variables, inputs, targets
) 

好吧,这有很多 JAX 特性。但现在我们几乎已经拥有了组装 JAX 训练步骤所需的一切。我们只需要最后一部分:optimizer.apply()

当你在第二章开始时编写你的第一个基本训练步骤时,你编写了一个看起来像这样的更新步骤函数:

learning_rate = 1e-3

def update_weights(gradients, weights):
    for g, w in zip(gradients, weights):
        w.assign(w - g * learning_rate) 

这对应于优化器 keras.optimizers.SGD 会执行的操作。然而,Keras API 中的每个其他优化器都比这复杂一些,并跟踪有助于加速训练的辅助变量——特别是,大多数优化器使用某种形式的 动量,这在第二章中你已经学到了。这些额外的变量在训练的每一步都会更新,在 JAX 的世界里,这意味着你需要得到一个无状态函数,这些变量作为参数,并返回它们的新值。

为了使这变得容易,Keras 在所有优化器上提供了 stateless_apply() 方法。它的工作方式如下:

trainable_variables, optimizer_variables = optimizer.stateless_apply(
    optimizer_variables, grads, trainable_variables
) 

现在,我们已经有了组装端到端训练步骤所需的一切:

optimizer = keras.optimizers.Adam()
optimizer.build(model.trainable_variables)

# The state is part of the function arguments.
def train_step(state, inputs, targets):
    # Unpacks the state
    (trainable_variables, non_trainable_variables, optimizer_variables) = state
    # Computes gradients and updates to non-trainable variables
    (loss, non_trainable_variables), grads = grad_fn(
        trainable_variables, non_trainable_variables, inputs, targets
    )
    # Updates trainable variables and optimizer variables
    trainable_variables, optimizer_variables = optimizer.stateless_apply(
        optimizer_variables, grads, trainable_variables
    )
    return loss, (
        # Returns the updated state alongside the loss
        trainable_variables,
        non_trainable_variables,
        optimizer_variables,
    ) 

让我们运行一个单步操作:

batch_size = 32
inputs = train_images[:batch_size]
targets = train_labels[:batch_size]

trainable_variables = [v.value for v in model.trainable_variables]
non_trainable_variables = [v.value for v in model.non_trainable_variables]
optimizer_variables = [v.value for v in optimizer.variables]

state = (trainable_variables, non_trainable_variables, optimizer_variables)
loss, state = train_step(state, inputs, targets) 

这确实比 TensorFlow 和 PyTorch 要多做一些工作,但 JAX 的速度和可扩展性优势足以弥补这一点。

接下来,让我们看看自定义训练循环的另一个重要元素:指标

指标的高级使用

在低级训练循环中,你可能想使用 Keras 指标(无论是自定义的还是内置的)。你已经了解了指标 API:只需为每个目标批次和预测批次调用 update_state(y_true, y_pred),然后使用 result() 查询当前指标值:

from keras import ops

metric = keras.metrics.SparseCategoricalAccuracy()
targets = ops.array([0, 1, 2])
predictions = ops.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
metric.update_state(targets, predictions)
current_result = metric.result()
print(f"result: {current_result:.2f}") 

你可能还需要跟踪标量值的平均值,例如模型的损失。你可以通过 keras.metrics.Mean 指标来完成这项工作:

values = ops.array([0, 1, 2, 3, 4])
mean_tracker = keras.metrics.Mean()
for value in values:
    mean_tracker.update_state(value)
print(f"Mean of values: {mean_tracker.result():.2f}") 

记得在你想重置当前结果时(在训练周期的开始或评估的开始)使用 metric.reset_state()

现在,如果你使用 JAX,则不能在无状态函数内部使用修改状态的方法,如 update_state()reset()。相反,你可以使用无状态指标 API,这与你已经了解的 model.stateless_call()optimizer.stateless_apply() 方法类似。以下是它的工作方式:

metric = keras.metrics.SparseCategoricalAccuracy()
targets = ops.array([0, 1, 2])
predictions = ops.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])

# Gets the metric's state variables
metric_variables = metric.variables
# Gets updated values for the metric's state
metric_variables = metric.stateless_update_state(
    metric_variables, targets, predictions
)
# Computes the metric value corresponding to the current state
current_result = metric.stateless_result(metric_variables)
print(f"result: {current_result:.2f}")

# Gets blank variable values for the metric
metric_variables = metric.stateless_reset_state() 

使用 fit() 和自定义训练循环

在前面的章节中,我们完全从头开始编写自己的训练逻辑。这样做为你提供了最大的灵活性,但最终你会编写大量的代码,同时也会错过 fit() 的许多便利功能,例如回调、性能优化或内置的分布式训练支持。

如果你需要一个自定义训练算法,但仍然想使用内置 Keras 训练循环的强大功能,那么在 fit() 和从头开始编写的训练循环之间实际上有一个折衷方案:你可以提供一个自定义训练步骤函数,让框架完成其余的工作。

你可以通过重写 Model 类的 train_step() 方法来实现这一点。这是 fit() 为每个数据批次调用的函数。然后你将能够像往常一样调用 fit()——并且它将在底层运行你自己的学习算法。

这是它的工作方式:

  • 创建一个新的类,该类继承自 keras.Model

  • 重写 train_step() 方法。其内容几乎与我们之前使用的内容相同。

  • 返回一个字典,将指标名称(包括损失)映射到它们的当前值。

注意以下事项:

  • 这种模式不会阻止你使用功能 API 构建模型。无论你是构建 Sequential 模型、功能 API 模型还是子类化模型,你都可以这样做。

  • 当你重写 train_step() 方法时,不需要使用 @tf.function@jax.jit 装饰器——框架会为你完成这项工作。

使用 TensorFlow 自定义 fit()

让我们从编写自定义 TensorFlow 训练步骤开始:

import keras
from keras import layers

loss_fn = keras.losses.SparseCategoricalCrossentropy()
# This metric object will be used to track the average of per-batch
# losses during training and evaluation.
loss_tracker = keras.metrics.Mean(name="loss")

class CustomModel(keras.Model):
    # Overrides the train_step() method
    def train_step(self, data):
        inputs, targets = data
        with tf.GradientTape() as tape:
            # We use self(inputs, training=True) instead of
            # model(inputs, training=True) since our model is the class
            # itself.
            predictions = self(inputs, training=True)
            loss = loss_fn(targets, predictions)
        gradients = tape.gradient(loss, self.trainable_weights)
        self.optimizer.apply(gradients, self.trainable_weights)

        # Updates the loss tracker metric that tracks the average of
        # the loss
        loss_tracker.update_state(loss)
        # Returns the average loss so far by querying the loss tracker
        # metric
        return {"loss": loss_tracker.result()}

    # Listing the loss tracker metric in the model.metrics property
    # enables the model to automatically call reset_state() on it at
    # the start of each epoch and at the start of a call to evaluate()
    # — so you don't have to do it by hand. Any metric you would like
    # to reset across epochs should be listed here.
    @property
    def metrics(self):
        return [loss_tracker] 

列表 7.21:自定义fit():TensorFlow 版本

现在,我们可以实例化我们的自定义模型,像往常一样使用fit()进行编译(我们只传递优化器,因为损失已经在模型外部定义了),并使用fit()进行训练。

让我们将模型定义放在一个可重用的函数中:

def get_custom_model():
    inputs = keras.Input(shape=(28 * 28,))
    features = layers.Dense(512, activation="relu")(inputs)
    features = layers.Dropout(0.5)(features)
    outputs = layers.Dense(10, activation="softmax")(features)
    model = CustomModel(inputs, outputs)
    model.compile(optimizer=keras.optimizers.Adam())
    return model 

让我们试试:

model = get_custom_model()
model.fit(train_images, train_labels, epochs=3) 

使用 PyTorch 自定义 fit()

接下来是 PyTorch 版本:

import keras
from keras import layers

loss_fn = keras.losses.SparseCategoricalCrossentropy()
loss_tracker = keras.metrics.Mean(name="loss")

class CustomModel(keras.Model):
    def train_step(self, data):
        inputs, targets = data
        # Runs the forward pass
        predictions = self(inputs, training=True)
        loss = loss_fn(targets, predictions)

        # Retrieves the gradients
        loss.backward()
        trainable_weights = [v for v in self.trainable_weights]
        gradients = [v.value.grad for v in trainable_weights]

        with torch.no_grad():
            # Updates weights
            self.optimizer.apply(gradients, trainable_weights)

        # Updates loss tracker metric
        loss_tracker.update_state(loss)
        # Returns the average loss so far by querying the loss tracker
        # metric
        return {"loss": loss_tracker.result()}

    @property
    def metrics(self):
        return [loss_tracker] 

让我们试试:

model = get_custom_model()
model.fit(train_images, train_labels, epochs=3) 

使用 JAX 自定义 fit()

最后,让我们编写 JAX 版本。首先,我们需要定义一个compute_loss_and_updates()方法,类似于我们在自定义训练步骤示例中使用的compute_loss_and_updates()函数:

import keras
from keras import layers

loss_fn = keras.losses.SparseCategoricalCrossentropy()

class CustomModel(keras.Model):
    def compute_loss_and_updates(
        self,
        trainable_variables,
        non_trainable_variables,
        inputs,
        targets,
        training=False,
    ):
        predictions, non_trainable_variables = self.stateless_call(
            trainable_variables,
            non_trainable_variables,
            inputs,
            training=training,
        )
        loss = loss_fn(targets, predictions)
        # Returns both the loss and the updated non-trainable variables
        return loss, non_trainable_variables 

注意,我们不像在其他两个后端中那样计算损失的移动平均值。相反,我们只返回每个批次的损失值,这不太有用。我们这样做是为了简化示例中的指标状态管理:如果包括它,代码会变得非常冗长(你将在下一节中了解指标管理):

 def train_step(self, state, data):
        # Unpacks the state. metrics_variables are part of it, although
        # we won't use them here.
        (
            trainable_variables,
            non_trainable_variables,
            optimizer_variables,
            metrics_variables,
        ) = state
        inputs, targets = data

        # Gets the gradient function
        grad_fn = jax.value_and_grad(
            self.compute_loss_and_updates, has_aux=True
        )

        # Computes gradients and updates to non-trainable variables
        (loss, non_trainable_variables), grads = grad_fn(
            trainable_variables,
            non_trainable_variables,
            inputs,
            targets,
            training=True,
        )

        # Updates trainable variables and optimizer variables
        (
            trainable_variables,
            optimizer_variables,
        ) = self.optimizer.stateless_apply(
            optimizer_variables, grads, trainable_variables
        )

        # We aren't computing a moving average of the loss, instead
        # returning the per-batch value.
        logs = {"loss": loss}
        state = (
            trainable_variables,
            non_trainable_variables,
            optimizer_variables,
            metrics_variables,
        )
        # Returns metric logs and updated state variables
        return logs, state 

让我们试试:

model = get_custom_model()
model.fit(train_images, train_labels, epochs=3) 

在自定义 train_step()中处理指标

最后,关于您可以传递给compile()lossmetrics是什么?在您调用compile()之后,您将获得访问权限

  • self.compute_loss — 这将您传递给compile()的损失函数与可能由某些层添加的正则化损失结合起来。

  • self.metrics — 您传递给compile()的指标列表。请注意,它还包括一个跟踪损失的指标。

使用 TensorFlow 处理 train_step()指标

这是使用 TensorFlow 时的样子:

import keras
from keras import layers

class CustomModel(keras.Model):
    def train_step(self, data):
        inputs, targets = data
        with tf.GradientTape() as tape:
            predictions = self(inputs, training=True)
            # Computes the loss via self.compute_loss
            loss = self.compute_loss(y=targets, y_pred=predictions)

        gradients = tape.gradient(loss, self.trainable_weights)
        self.optimizer.apply(gradients, self.trainable_weights)

        # Updates the model's metrics, including the one that tracks
        # the loss
        for metric in self.metrics:
            if metric.name == "loss":
                metric.update_state(loss)
            else:
                metric.update_state(targets, predictions)

        # Returns a dict mapping metric names to their current value
        return {m.name: m.result() for m in self.metrics} 

让我们试试:

def get_custom_model():
    inputs = keras.Input(shape=(28 * 28,))
    features = layers.Dense(512, activation="relu")(inputs)
    features = layers.Dropout(0.5)(features)
    outputs = layers.Dense(10, activation="softmax")(features)
    model = CustomModel(inputs, outputs)
    model.compile(
        optimizer=keras.optimizers.Adam(),
        loss=keras.losses.SparseCategoricalCrossentropy(),
        metrics=[keras.metrics.SparseCategoricalAccuracy()],
    )
    return model

model = get_custom_model()
model.fit(train_images, train_labels, epochs=3) 

使用 PyTorch 处理 train_step()指标

这是使用 PyTorch 时的样子——代码更改完全相同!

import keras
from keras import layers

class CustomModel(keras.Model):
    def train_step(self, data):
        inputs, targets = data
        predictions = self(inputs, training=True)
        loss = self.compute_loss(y=targets, y_pred=predictions)

        loss.backward()
        trainable_weights = [v for v in self.trainable_weights]
        gradients = [v.value.grad for v in trainable_weights]

        with torch.no_grad():
            self.optimizer.apply(gradients, trainable_weights)

        for metric in self.metrics:
            if metric.name == "loss":
                metric.update_state(loss)
            else:
                metric.update_state(targets, predictions)

        return {m.name: m.result() for m in self.metrics} 

让我们看看它是如何运行的:

def get_custom_model():
    inputs = keras.Input(shape=(28 * 28,))
    features = layers.Dense(512, activation="relu")(inputs)
    features = layers.Dropout(0.5)(features)
    outputs = layers.Dense(10, activation="softmax")(features)
    model = CustomModel(inputs, outputs)
    model.compile(
        optimizer=keras.optimizers.Adam(),
        loss=keras.losses.SparseCategoricalCrossentropy(),
        metrics=[keras.metrics.SparseCategoricalAccuracy()],
    )
    return model

model = get_custom_model()
model.fit(train_images, train_labels, epochs=3) 

使用 JAX 处理 train_step()指标

最后,这是使用 JAX 时的样子。首先,您可以在compute_loss_and_updates()方法中使用compute_loss()来获取传递给compile()的损失:

import keras
from keras import layers

class CustomModel(keras.Model):
    def compute_loss_and_updates(
        self,
        trainable_variables,
        non_trainable_variables,
        inputs,
        targets,
        training=False,
    ):
        predictions, non_trainable_variables = self.stateless_call(
            trainable_variables,
            non_trainable_variables,
            inputs,
            training=training,
        )
        loss = self.compute_loss(y=targets, y_pred=predictions)
        return loss, (predictions, non_trainable_variables) 

接下来:指标管理。由于 JAX 的无状态要求,这通常要复杂一些:

 def train_step(self, state, data):
        (
            trainable_variables,
            non_trainable_variables,
            optimizer_variables,
            # Metric variables are part of the state.
            metrics_variables,
        ) = state
        inputs, targets = data

        grad_fn = jax.value_and_grad(
            self.compute_loss_and_updates, has_aux=True
        )

        (loss, (predictions, non_trainable_variables)), grads = grad_fn(
            trainable_variables,
            non_trainable_variables,
            inputs,
            targets,
            training=True,
        )
        (
            trainable_variables,
            optimizer_variables,
        ) = self.optimizer.stateless_apply(
            optimizer_variables, grads, trainable_variables
        )

        new_metrics_vars = []
        logs = {}
        # Iterates over metrics
        for metric in self.metrics:
            num_prev = len(new_metrics_vars)
            num_current = len(metric.variables)
            # Grabs the variables of the current metrics
            current_vars = metrics_variables[num_prev : num_prev + num_current]
            # Updates the metric's state
            if metric.name == "loss":
                current_vars = metric.stateless_update_state(current_vars, loss)
            else:
                current_vars = metric.stateless_update_state(
                    current_vars, targets, predictions
                )
            # Stores the results in the logs dict
            logs[metric.name] = metric.stateless_result(current_vars)
            new_metrics_vars += current_vars

        state = (
            trainable_variables,
            non_trainable_variables,
            optimizer_variables,
            # Returns the new metrics variables as part of the state
            new_metrics_vars,
        )
        return logs, state 

虽然信息量很大,但到目前为止,您已经知道足够多的知识来使用 Keras 做几乎所有的事情!

概述

  • Keras 提供了一系列不同的工作流程,基于渐进式复杂性披露的原则。它们都可以无缝互操作。

  • 您可以通过Sequential类、功能 API 或通过子类化Model类来构建模型。大多数情况下,您将使用功能 API。

  • 训练和评估模型的最简单方法是使用默认的fit()evaluate()方法。

  • Keras 回调提供了一种简单的方法来在调用fit()期间监控模型,并根据模型的状态自动采取行动。

  • 您也可以通过重写train_step()方法,使用您选择的后端 API(JAX、TensorFlow 或 PyTorch)来完全控制fit()函数的行为。

  • 除了fit()之外,您还可以从头开始完全编写自己的训练循环,以原生后端的方式。这对于实施全新训练算法的研究人员来说很有用。

posted @ 2025-12-08 20:08  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报