TensorFlow 实战(二)

原文:zh.annas-archive.org/md5/63f1015b3af62117a4a51b25a6d19428

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:涉足深度学习

本章涵盖

  • 使用 Keras 实现和训练全连接神经网络

  • 实现和训练卷积神经网络以对图像进行分类

  • 实现和训练递归神经网络以解决时间序列问题

在第三章,您了解了 TensorFlow 提供的不同模型构建 API 及其优缺点。您还了解了 TensorFlow 中一些检索和操作数据的选项。在本章中,您将学习如何利用这些知识来构建深度神经网络,并使用它们来解决问题。

深度学习是一个广泛的术语,它包含许多不同的算法。深度学习算法有许多不同的类型和颜色,可以根据许多标准进行分类:它们消耗的数据类型(例如,结构化数据、图像、时间序列数据)、深度(浅层、深层和非常深层)等等。我们将要讨论和实现的主要深度网络类型如下:

  • 全连接网络(FCNs)

  • 卷积神经网络(CNNs)

  • 递归神经网络(RNNs)

能够熟练实现这些神经网络是在该领域取得成功的关键技能,无论你是研究生、数据科学家还是研究科学家。这些知识直接延伸到如何熟练实现更复杂的深度神经网络,这些网络在各种问题领域提供了最先进的性能。

在第二章中,我们讨论了 FCN 和 CNN 中的各种操作,例如卷积和池化操作。在本章中,您将再次看到 FCNs,以及 CNNs 的整体实现,展示了卷积和池化操作如何合并形成 CNN。最后,您将了解一个新类型的模型:RNNs。RNNs 通常用于解决时间序列问题,其中的任务是学习数据随时间变化的模式,以便通过查看过去的模式来预测未来。我们还将看到 RNNs 如何用于解决一个有趣的现实世界时间序列问题。

4.1 全连接网络

当您在阁楼找到一些存储盒时,里面有一些珍贵的祖母的照片。不幸的是,它们已经过时了。大多数照片都被划痕、污迹和甚至撕裂了。您知道最近已经使用了深度网络来恢复旧照片和视频。希望能恢复这些照片,您决定使用 TensorFlow 实现图像恢复模型。您首先将开发一个可以恢复手写数字损坏图像的模型,因为这个数据集是 readily available,以便了解模型和训练过程。您认为自动编码器模型(一种 FCN)将是一个很好的起点。这个自动编码器将具有以下规格:

  • 具有 784 个节点的输入层

  • 具有 64 个节点的隐藏层,采用 ReLU 激活

  • 一个包含 32 个节点的隐藏层,使用 ReLU 激活函数

  • 一个包含 64 个节点的隐藏层,使用 ReLU 激活函数

  • 一个包含 784 个节点的输出层,使用 tanh 激活函数

深度学习的超参数优化

你可能已经注意到,在定义神经网络时,我们选择结构超参数(例如,隐藏层中的单元数)有些是凭空选择的。实际上,这些值是通过几轮试错经验选择的。

通常,在机器学习中,这些超参数是使用基于原则的方法选择的,例如超参数优化。但是,超参数优化是一个昂贵的过程,需要评估具有不同超参数选择的数百个模型,以选择最佳的超参数集。这使得它非常难以用于深度学习方法,因为这些方法通常涉及大型、复杂的模型和大量的数据。

因此,在深度学习中,为了限制在超参数优化上花费的时间,你通常会看到以下趋势:

  • 优化一部分超参数以限制探索空间(例如,激活类型而不是隐藏单元数量,正则化参数等)。

  • 使用健壮的优化器、早停、学习率衰减等方法,旨在减少或预防过拟合

  • 使用已发表的模型规范,这些模型提供了最先进的性能

  • 遵循一些经验法则,例如随着网络深入减少输出大小

在本章中,我们将使用经验选择的模型架构。本章的重点是展示如何使用 TensorFlow 2 实现给定的架构,而不是找到架构本身。

让我们检查一下我们将用于实现 FCN 的数据。

4.1.1 理解数据

对于这种情况,我们将使用 MNIST 数字数据集,这是一个简单的数据集,包含手写数字的黑白图像以及表示数字的对应标签。每个图像都有一个数字,从 0 到 9。因此,数据集有 10 个不同的类别。图 4.1 显示了数据集中的几个样本及其表示的数字。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.1 样本数字图像。每个图像包含一个从 0 到 9 的数字。

在 TensorFlow 中,你可以用一行代码加载 MNIST 数据集。由于其极为常见的用法,加载此数据集已成为各种机器学习库(包括 TensorFlow)的重要组成部分:

from tensorflow.keras.datasets.mnist import load_data
(x_train, y_train), (x_test, y_test) = load_data()

load_data()方法返回两个元组:训练数据和测试数据。在这里,我们只会使用训练图像(即,x_train)数据集。正如我们之前介绍的,这是一个无监督任务。因此,我们不需要图像的标签(即,y_train)来完成这个任务。

比 MNIST 好吗?

请注意,由于过去十年计算机视觉领域的进展,MNIST 被认为过于简单,简单的逻辑回归模型就可以实现超过 92%的测试准确率(mng.bz/j2l9),而最先进的模型则可以达到 99.84%的准确率(mng.bz/d2Pv)。此外,它在计算机视觉社区中被过度使用。因此,一个名为 Fashion-MNIST(github.com/zalandoresearch/fashion-mnist)的新数据集应运而生。这是一个包含属于 10 个类别的图像的黑白数据集。与数字不同,它包含各种时尚类别的图像(例如 T 恤、凉鞋、包等),这比识别数字要困难得多。

你可以打印 x_train 和 y_train 来更好地了解这些数组,使用

print(x_train)
print('x_train has shape: {}'.format(x_train.shape))

这将产生

[[[0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  ...
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]]

 ...

 [[0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  ...
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]]]

x_train has shape: (60000, 28, 28)

对 y_train 执行相同的操作:

print(y_train)
print('y_train has shape: {}'.format(y_train.shape))

这将得到

[5 0 4 ... 5 6 8]

y_train has shape: (60000,)

然后我们将进行一些基本的数据预处理。我们将通过将它们的像素值从[0, 255]归一化到[-1, 1]来规范化数据集中的所有样本。这是通过减去 128 并逐元素除以 128 来完成的。这很重要,因为自动编码器的最后一层具有 tanh 激活函数,其取值范围为(-1, 1)。tanh 是一个非线性激活函数,类似于 sigmoid 函数,对于给定的输入x,计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,我们需要确保向模型提供的内容在最终层可以生成的值范围内。另外,如果您查看 x_train 的形状,您将看到它的形状为(60000, 28, 28)。自动编码器接受一维输入,因此我们需要将图像重塑为大小为 784 的一维向量。这两种转换可以通过以下行来实现:

norm_x_train = ((x_train - 128.0)/128.0).reshape([-1,784])

在这里,reshape([-1, 784])将数据集中的二维图像(大小为 28×28)展开为一个单一维度的向量(大小为 784)。在进行重塑时,您不需要提供重塑张量的所有维度。如果您仅提供除一个维度外的所有维度的大小,NumPy 仍然可以推断出缺失维度的大小,因为它知道原始张量的维度。您希望 NumPy 推断的维度用-1 表示。

也许你会想:“这些图像看起来清晰干净。我们如何训练模型来恢复损坏的图像?” 这很容易解决。我们只需要从原始图像中合成一组相应的损坏图像集。为此,我们将定义 generate_masked_inputs(…)函数:

import numpy as np

def generate_masked_inputs(x, p, seed=None):
    if seed:
        np.random.seed(seed)
    mask = np.random.binomial(n=1, p=p, size=x.shape).astype('float32')
    return x * mask

masked_x_train = generate_masked_inputs(norm_x_train, 0.5)

这个函数将随机(有 50%的概率)将像素设置为零。但让我们更详细地检查我们正在做什么。首先,我们将提供设置随机种子的选项,以便我们可以确定性地改变生成的随机掩码。我们使用二项分布创建一个与 norm_x_train 大小相同的 1 和 0 的掩码。简单来说,二项分布表示如果你多次抛硬币,出现正面(1)或反面(0)的概率。二项分布有几个重要参数:

  • N—试验次数

  • P—成功的概率(1)

  • Size—测试的数量(即,试验集)

在这里,我们有 x.shape 个测试,在每个测试中有一个 50%的成功概率。然后将此掩码与原始张量进行逐元素相乘。这将导致随机分布在图像上的黑色像素(图 4.2)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.2 一些合成损坏的图像

接下来,让我们讨论我们将要实现的全连接网络。它被称为自动编码器模型。

4.1.2 自动编码器模型

自动编码器模型和多层感知机(MLP)模型(来自第一章)都是全连接网络(FCN)。之所以称为 FCN,是因为网络中的每一层都将所有输入节点连接到所有输出节点。自动编码器的操作方式与多层感知机类似。换句话说,在自动编码器中看到的计算(例如,正向传播)与 MLP 中完全相同。然而,两者的最终目标不同。MLP 被训练来解决监督任务(例如,分类花的品种),而自动编码器被训练来解决无监督任务(例如,在给定损坏/嘈杂图像的情况下重建原始图像)。现在让我们深入了解自动编码器实际上是做什么的。

监督学习与无监督学习

在监督学习中,模型使用带标签的数据集进行训练。每个输入(例如,图像/音频/电影评论)都有一个相应的标签(例如,图像的对象类别、评论的情感)或连续值(例如,图像对象的边界框)。监督任务的一些示例包括图像分类、目标检测、语音识别和情感分析。

在无监督学习中,模型使用未标记的数据进行训练(例如,从网站提取的没有任何标签的图像/音频/文本)。训练过程根据最终预期结果而显著变化。例如,自动编码器被训练为重建图像,作为基于图像的监督学习任务的预训练步骤。无监督任务的一些示例包括图像重构、使用生成对抗网络生成图像、文本聚类和语言建模。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.3 一个简单的自动编码器,其中一个层用于压缩,另一个层用于重构。输入图像中的黑色和白色矩形是图像中存在的像素。

图 4.3 描绘了一个具有两层的简单自编码器。自编码器在其功能上有两个阶段:

  • 压缩阶段—将给定图像(即损坏的图像)压缩为压缩的隐藏(即潜在)表示。

  • 重构阶段—从隐藏表示中重构原始图像。

在压缩阶段,计算压缩的隐藏表示如下所示。

h[1] = ReLU(xW[1] + b[1])

其中W[1],b[1]是第一压缩层的权重和偏置,h[1]是层的最终隐藏表示。

类似地,我们计算重构层的输出:

ŷ = ReLU(h[1] W[2] + b[2])

这被称为前向传播,因为您从输入到输出。然后,您计算预期输出(即目标)和预测之间的损失(例如,均方误差[MSE])。例如,单个图像的均方误差计算为

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其中D是数据的维度(在我们的示例中为 784),y[j]是我们图像中的第j个像素,(ŷ[j])是预测图像的第j个像素。我们为每批图像计算此损失,并优化模型参数以最小化计算的损失。这被称为向后传递。

您可以有任意数量的压缩和重构层。在我们的任务中,我们需要两个压缩层和两个重构层(见下一个列表)。

列表 4.1 去噪自编码器模型。

from tensorflow.keras import layers, models

autoencoder = models.Sequential(
    [layers.Dense(64, activation='relu', input_shape=(784,)),  ❶
    layers.Dense(32, activation='relu'),                       ❶
    layers.Dense(64, activation='relu'),                       ❶
    layers.Dense(784, activation='tanh')])
autoencoder.compile(loss='mse', optimizer='adam')              ❷
autoencoder.summary()

❶ 定义四个稠密层,其中三个使用 ReLU 激活,一个使用 tanh 激活。

❷ 使用损失函数和优化器编译模型。

❸ 打印摘要。

让我们更详细地讨论我们所做的事情。您应该注意到的第一件事是,我们在这个任务中使用了 Keras Sequential API。这是有道理的,因为这是一个非常简单的深度学习模型。接下来,我们添加了四个稠密层。第一个稠密层接受具有 784 个特征的输入,并产生一个 64 元素的向量。然后第二层接受 64 元素的向量并产生一个 32 元素的向量。第三个稠密层接受 32 元素的向量并产生一个 64 元素的向量,将其传递给最终层,该层产生一个 784 元素的向量(即输入的大小)。前三层使用 ReLU 激活,最后一层使用 tanh 激活,因为最后一层需要产生在(-1, 1)之间的值。让我们再次提醒自己如何计算 ReLU 和 tanh 激活:

ReLU(x) = max (0, x)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

最后,我们使用均方误差作为损失函数,使用 adam 作为优化器编译模型。我们刚刚描述的模型具有我们在本节开头定义的规格。有了定义好的模型,现在您可以训练模型了。您将使用 64 个大小的批次训练模型 10 个时期:

history = autoencoder.fit(masked_x_train, norm_x_train, batch_size=64, epochs=10)

我们生成的遮罩输入成为输入,原始图像将成为地面真相。当你训练模型时,你会看到随时间推移损失下降:

Train on 60000 samples
Epoch 1/10
60000/60000 [==============================] - 4s 72us/sample - loss: 0.1496
Epoch 2/10
60000/60000 [==============================] - 4s 67us/sample - loss: 0.0992
Epoch 3/10
...
60000/60000 [==============================] - 4s 66us/sample - loss: 0.0821
Epoch 8/10
60000/60000 [==============================] - 4s 66us/sample - loss: 0.0801
Epoch 9/10
60000/60000 [==============================] - 4s 67us/sample - loss: 0.0787
Epoch 10/10
60000/60000 [==============================] - 4s 67us/sample - loss: 0.0777

看起来误差(即,损失值)从大约 0.15 下降到大约 0.078。这是模型正在学习重建图像的一个强有力的指示。你可以通过设置种子来获得类似的结果,使用我们在第二章中使用的 fix_random_seed(…) 函数(提供在笔记本中)。请注意,对于这个任务,我们无法定义像准确度这样的指标,因为这是一个无监督的任务。

去噪自动编码器

通常,自动编码器将给定的输入映射到一个小的潜在空间,然后再返回到原始输入空间以重建原始图像。然而,在这里,我们将自动编码器用于一个特殊目的:还原原始图像或去噪原始图像。这样的自动编码器被称为 去噪。在mng.bz/WxyX上阅读更多关于去噪自动编码器的信息。

现在让我们看看训练好的模型能做什么!它现在应该能够还原一个受损数字的图像了。为了让事情变得有趣,让我们确保我们生成的遮罩是训练数据没有见过的:

x_train_sample = x_train[:10]
y_train_sample = y_train[:10]

masked_x_train_sample = generate_masked_inputs(x_train_sample, 0.5, seed=2048)
norm_masked_x = ((x_train - 128.0)/128.0).reshape(-1, 784)

y_pred = autoencoder.predict(norm_masked_x)

在这里,我们将使用数据集中的前 10 张图像来测试我们刚刚训练的模型。然而,我们通过更改种子确保了随机遮罩不同。你可以使用以下代码显示关于 y_pred 的一些信息

print(y_pred)
print('y_pred has shape: {}'.format(y_pred.shape))

将会给出

[[-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 ...
 [-0.99999976 -0.99999976 -0.9999996  ... -0.99999946 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]]

y_pred has shape: (60000, 784)

最后,你可以通过绘制图像来可视化模型的作用(在笔记本中提供的代码)。图 4.4 说明了损坏的图像(顶行)和模型的输出(底行)。虽然你还没有恢复你祖母的真实照片,但这是一个很好的开始,因为现在你知道了要遵循的方法。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.4 模型恢复的图像。看起来我们的模型做得很好。

你可能会想,“自动编码器通常能帮你实现什么?”自动编码器是从未标记数据中学习无监督特征的好工具,这在解决更有趣的下游任务时非常方便,比如图像分类。当自动编码器在无监督任务上进行训练时,它们学习了其他任务(例如,图像分类)的有用特征。因此,训练一个自动编码器模型来对图像进行分类将比从头开始训练模型更快地获得性能良好的模型,并且所需的标记数据更少。正如你可能知道的,世界上的未标记数据要比标记数据多得多,因为标记通常需要人为干预,这是耗时且昂贵的。自动编码器的另一个用途是它产生的隐藏表示可以用作聚类图像的低维代理。

在本节中,你学习了自动编码器模型,它是一种 FCN 类型,用于以无监督的方式重构/恢复损坏的图像。这是一种利用大量未标记数据来预训练模型的好方法,这在更下游的有趣任务(如图像分类)中非常有用。你首先学习了架构,然后学习了如何使用 Keras Sequential API 实现自动编码器模型。最后,你对手写图像数据集(MNIST)进行了模型训练以重构数据集中的图像。在训练过程中,为了确保模型在学习,你监控了损失以确保随着时间的推移减少。最后,你使用模型预测了损坏图像的恢复,并确保模型表现良好。

在下一节中,我们将讨论一种不同类型的深度学习网络,它彻底改变了计算机视觉领域:CNN。

练习 1

实现一个接受 512 元素长向量的自动编码器模型。网络有一个 32 节点层,一个 16 节点层,最后是一个输出层。总共有三层。所有这些层都具有 sigmoid 激活。

4.2 卷积神经网络

你一直在一家初创公司担任数据科学家,试图对道路上的交通拥堵建模。公司解决方案中的一个重要模型是构建一个模型,以预测在给定的图像或图像块中是否存在车辆,作为更大计划的一部分。你计划首先在 cifar-10 数据集上开发一个模型,并查看它在分类车辆方面的效果如何。这是一个很好的主意,因为它将在最小的时间和金钱上为自定义数据标记提供一个粗略的近似值。如果我们在这个数据集上能够达到较高的准确度,那是一个非常积极的信号。你了解到 CNN 对于计算机视觉任务非常有效。因此,你计划实现一个 CNN。

4.2.1 理解数据

我们将使用的是 cifar-10 数据集。我们在上一章节简要地查看过这个数据集,它是这项任务的重要基石。它包含各种交通工具(如汽车、卡车)和其他物体(如狗、猫)作为类别。图 4.5 展示了一些类别及其对应的样本。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.5 cifar-10 数据集的样本图像及其标签

数据集包含 50,000 个训练实例和 10,000 个测试实例。每个实例是一个 32 × 32 的 RGB 图像。这个数据集中有 10 个不同的对象类别。

让我们首先通过执行以下行来加载数据:

import tensorflow_datasets as tfds
data = tfds.load('cifar10')

执行 print(data) 将产生

{'test': <PrefetchDataset 
➥ shapes: {id: (), image: (32, 32, 3), label: ()}, 
➥ types: {id: tf.string, image: tf.uint8, label: tf.int64}>, 'train': <PrefetchDataset 
➥ shapes: {id: (), image: (32, 32, 3), label: ()}, 
➥ types: {id: tf.string, image: tf.uint8, label: tf.int64}>}

如果你稍微探索一下数据,你会意识到

  • 图像以无符号的八位整数类型提供。

  • 标签以整数标签提供(即,未进行 one-hot 编码)。

因此,我们将编写一个非常简单的函数,将图像转换为 float32 数据类型(使数据类型与模型参数一致),并将标签转换为独热编码向量:

import tensorflow as tf

def format_data(x, depth):
    return (tf.cast(x["image"], 'float32'), tf.one_hot(x["label"], depth=depth))

最后,我们将通过将此函数应用于所有训练数据来创建一个批处理数据集:

tr_data = data["train"].map(lambda x: format_data(x, depth=10)).batch(32)

我们可以再次查看数据

for d in tr_data.take(1):
    print(d)

这将产生

(
    <tf.Tensor: shape=(32, 32, 32, 3), dtype=float32, numpy=
    array(
        [[[[143.,  96.,  70.],
           [141.,  96.,  72.],
           [135.,  93.,  72.],
           ...,     
           [ 52.,  34.,  31.],
           [ 91.,  74.,  59.],
           [126., 110.,  88.]]]], 
        dtype=float32)
    >, 
    <tf.Tensor: shape=(32, 10), dtype=float32, numpy=
    array(
        [[0., 0., 0., 0., 0., 0., 0., 1., 0., 0.],
         [0., 0., 0., 0., 0., 0., 0., 0., 1., 0.],
         [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
         ... 
         [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
         [0., 0., 1., 0., 0., 0., 0., 0., 0., 0.]], 
        dtype=float32)
    >
)

现在我们的数据准备好输入模型了。

4.2.2 实现网络

为了对这些图像进行分类,我们将采用 CNN。CNN 以解决计算机视觉任务而闻名,并且是处理图像相关任务的流行选择,原因有两个主要方面:

  • CNN 在处理图像时保留它们的空间信息(即保持高度和宽度维度不变),而全连接层则需要将高度和宽度维度展开为一个单一维度,从而丢失宝贵的局部信息。

  • 不同于全连接层,其中每个输入都连接到每个输出,卷积操作将一个较小的核移动到整个图像上,每层只需少量参数,使得 CNN 非常高效。

CNN 由一组交错的卷积和池化层以及几个全连接层组成。这意味着 CNN 中有三种主要类型的层:

  • 卷积层

  • 池化层

  • 全连接层

一个卷积层由多个滤波器(即卷积核)组成,这些滤波器在图像上进行卷积以生成特征图。特征图是一个表示给定滤波器在图像中存在程度的表示。例如,如果滤波器表示垂直边缘,则特征图表示图像中垂直边缘存在的位置(以及强度)。再举一个例子,想象一个训练用于识别人脸的神经网络。一个滤波器可能表示眼睛的形状,并且在给定图像中存在眼睛时会高度激活相应区域的输出(见图 4.6)。我们将在本章后面更详细地讨论卷积操作。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.6 卷积操作的结果,非常抽象。如果我们有一个人脸图像和一个表示眼睛形状/颜色的卷积核,那么卷积结果可以粗略地被认为是该特征(即眼睛)在图像中存在的热图。

卷积层的另一个重要特性是,网络越深(即离输入越远),层学习的高级特征就越多。回到我们的人脸识别例子,较低的层可能学习到各种边缘的存在;下一层学习到眼睛、耳朵和鼻子的形状;下一层学习到两只眼睛的位置、鼻子和嘴巴的对齐等(见图 4.7)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.7 卷积神经网络学习到的特征。较低的层(离输入最近)学习到的是边缘/线条,而较高的层(离输入最远)学习到的是更高级的特征。(来源:mng.bz/8MPg

接下来,池化层接收卷积层生成的特征图并减少它们的高度和宽度维度。为什么减少特征图的高度和宽度有用?它帮助模型在机器学习任务中具有平移不变性。例如,如果任务是图像分类,即使物体在训练期间看到的几个像素偏移,该网络仍然能够识别出物体。

最终,为了获得最终的概率分布,你有多个全连接层。但你可能已经怀疑我们在这里面临的问题。卷积/池化层产生三维输出(即高度、宽度和通道维度)。但全连接层接受一维输入。我们如何将卷积/池化层的三维输出连接到一维的全连接层呢?这个问题有一个简单的答案。你将所有三个维度压缩成一个维度。换句话说,这类似于将二维的 RGB 图像展开成一维向量。这为全连接层提供了一维输入。最后,对最后一个全连接层的输出(即网络的得分)应用 softmax 激活,以获得有效的概率分布。图 4.8 描述了一个简单的 CNN。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.8 一个简单的 CNN。首先,我们有一个带有高度、宽度和通道维度的图像,然后是一个卷积和池化层。最后,最后一个卷积/池化层的输出被展平,并输入到一组全连接层中。

通过深入了解 CNN 的构成,我们将使用 Keras Sequential API 创建以下 CNN。然而,如果你运行此代码,你将会收到一个错误。我们将在接下来的部分调查并修复这个错误(参见下一个列表)。

列表 4.2 使用 Keras Sequential API 定义 CNN

from tensorflow.keras import layers, models
import tensorflow.keras.backend as K

K.clear_session()                                                          ❶
cnn = models.Sequential(
    [layers.Conv2D(
        filters=16, kernel_size= (9,9), strides=(2,2), activation='relu',  ❷
           padding=’valid’, input_shape=(32,32,3)
        ),                                                                 ❷
     layers.Conv2D(
         filters=32, kernel_size= (7,7), activation='relu', padding=’valid’
     ), 
     layers.Conv2D(
         filters=64, kernel_size= (7,7), activation='relu', padding=’valid’
     ), 
     layers.Flatten(),                                                     ❸
     layers.Dense(64, activation='relu'),                                  ❹
     layers.Dense(10, activation='softmax')])

❶ 清除任何现有的 Keras 状态(例如模型)以重新开始

❷ 定义卷积层;它接受过滤器、内核大小、步幅、激活和填充等参数。

❸ 在将数据输入全连接层之前,我们需要展平最后一个卷积层的输出。

❹ 创建一个中间的全连接层

❺ 最终预测层

你可以看到,该网络由三个卷积层和两个全连接层组成。Keras 提供了你实现 CNN 所需的所有层。如你所见,我们的图像分类网络只需一行代码即可完成。让我们更详细地探索一下这个模型中发生了什么。第一层定义如下:

layers.Conv2D(filters=16,kernel_size=(9,9), strides=(2,2), activation='relu', input_shape=(32,32,3))

卷积神经网络的超参数

在列表 4.2 中的 CNN 网络中,Conv2D 层的 filters、kernel_size 和 strides,Dense 层(除了输出层)中的隐藏单元数以及激活函数被称为模型的超参数。理想情况下,这些超参数需要使用超参数优化算法进行选择,该算法会运行数百(如果不是数千)个具有不同超参数值的模型,并选择最大化预定义度量(例如,模型准确性)的那个。然而,这里我们已经根据经验选择了这些超参数的值,并且不会使用超参数优化。

首先,Conv2D 层是 2D 卷积操作的 Keras 实现。正如您在第一章中记得的那样,我们使用了 tf.nn.convolution 操作来实现这一点。Conv2D 层在幕后执行相同的功能。但是,它隐藏了一些直接使用 tf.nn.convolution 操作时遇到的复杂性(例如,显式定义层参数)。您需要为这一层提供几个重要的参数:

  • 过滤器—输出中将存在的通道数。

  • 核大小—高度和宽度维度上的卷积窗口大小,按顺序。

  • 步长—表示在每次卷积窗口在输入上移动时,跳过的高度和宽度像素数量(按顺序)。在这里有较高的值有助于随着深入,快速减小卷积输出的尺寸。

  • 激活—卷积层的非线性激活。

  • 填充—在执行卷积操作时用于边界的填充类型。填充边界可以更好地控制输出的大小。

  • input_shape—表示(高度,宽度,通道)维度上的输入大小的三维元组。请记住,当使用此参数指定数据的形状时,Keras 会自动添加一个未指定的批量维度。

现在让我们更详细地介绍卷积函数及其参数。我们已经知道,卷积操作将一个卷积窗口(即一个核)在图像上移动,同时取得与图像部分与核重叠的元素的乘积之和(图 4.9)。从数学上讲,卷积操作可以表示为

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其中xn × n的输入矩阵,fm × m的过滤器,y是输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.9 在移动窗口时进行卷积操作的计算

除了卷积操作期间发生的计算外,在使用 Keras 中的 Conv2D 层时产生的大小和值时,还有四个重要的超参数:

  • 滤波器数量

  • 核高度和宽度

  • 核步长(高度和宽度)

  • 填充类型

我们将讨论的第一个方面是层中滤波器的数量。通常,单个卷积层有多个滤波器。例如,想象一个训练用于识别人脸的神经网络。网络中的一个层可能会学习识别眼睛的形状,鼻子的形状等等。每个这些特征可能由层中的单个滤波器学习。

卷积层接收一个图像,这是一个具有某些高度、宽度和通道的三维张量。例如,如果图像是 RGB 图像,则会有三个通道。如果图像是灰度图像,则通道数将为一。然后,将该张量与 n 个滤波器卷积将导致一个具有某些高度、宽度和 n 个通道的三维输出。这在图 4.10 中显示。在 CNN 中使用时,滤波器是卷积层的参数。这些滤波器被随机初始化,随着时间的推移,它们会演变成有助于解决手头任务的有意义特征。

正如我们之前所说,深度神经网络以批量方式处理数据。CNN 也不例外。您可以看到,我们将 input_shape 参数设置为 (32, 32, 3),其中自动添加了一个未指定的批量维度,使其为 (None, 32, 32, 3)。未指定的维度用 None 表示,意味着模型可以在该维度上取任意数量的项目。这意味着在向模型提供数据时,一个数据批次可以有 3、4、100 或任意数量的图像(根据计算机内存的情况)。因此,Conv2D 层的输入/输出实际上是一个四维张量,具有批量、高度、宽度和通道维度。然后,滤波器将是另一个四维张量,具有核高度、宽度、输入通道和输出通道维度。表 4.1 总结了这些信息。

表 4.1 卷积层的输入、滤波器和输出的维度

维度示例
输入[批量大小,高度,宽度,输入通道][32, 64, 64, 3](即,一批 32 个,64 × 64 的 RGB 图像)
卷积滤波器[高度,宽度,输入通道,输出通道][5, 5, 3, 16](即,大小为 5 × 5 的 16 个输入通道的卷积滤波器)
输出[批量大小,高度,宽度,输出通道][32, 64, 64, 16](即,一批 32 个,64 × 64 × 16 的张量)

图 4.10 描述了卷积层中输入和输出的外观。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.10 多个滤波器(随机初始化)的卷积层的计算。我们保留了张量表示的批量维度以避免混乱。

接下来,内核的高度和宽度是在高度和宽度维度上的滤波器大小。图 4.11 描述了不同内核大小导致不同输出的情况。通常,在实现 CNN 时,我们保持内核的高度和宽度相等。因此,我们将内核的高度和宽度维度统称为内核大小。我们可以将输出大小计算为内核和输入大小的函数,如下所示:

size(y) = size(x) - size(f) + 1

例如,如果图像是一个 7 × 7 的矩阵,滤波器是一个 3 × 3 的矩阵,那么输出将是一个 (7 - 3 + 1, 7 - 3 + 1) = 5 × 5 的矩阵。或者,如果图像是一个 7 × 7 的矩阵,滤波器是一个 5 × 5 的矩阵,那么输出将是一个 3 × 3 的矩阵。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.11 使用 2 和 3 的内核大小的卷积操作。增加内核大小会导致减小输出大小。

从建模的角度来看,增加内核大小(即滤波器大小)意味着增加参数的数量。通常,您应该尝试减少网络中的参数数量并针对较小的内核大小。使用小内核大小鼓励模型使用少量参数学习更健壮的特征,从而更好地泛化模型。

下一个重要参数是步幅。与内核大小类似,步幅有两个组成部分:高度和宽度。直觉上,步幅定义了在进行卷积操作时跳过多少像素/值。图 4.12 说明了没有步幅和步幅 = 2 之间的区别。与之前一样,我们可以将输出大小指定为输入大小、内核大小和步幅的函数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.12 步幅为 1(即无步幅)与步幅为 2 的卷积操作。增加步幅会导致较小的输出。

从建模的角度来看,步幅是有益的,因为它帮助您控制输出中需要减少的量。您可能已经注意到,即使在没有步幅的情况下,卷积过程中仍会自动减少维度。但是,当使用步幅时,您可以控制要获得的减少而不影响内核大小。

最终,填充决定了图像边界附近发生的情况。正如你已经看到的,当你对图像进行卷积时,你得不到与输入尺寸相同的输出。例如,如果你有一个 4 × 4 的矩阵和一个 2 × 2 的核,你会得到一个 3 × 3 的输出(即,根据我们之前看到的方程size(y) = size(x) - size(f ) + 1,其中x是输入尺寸,f是滤波器尺寸)。这种自动降维会在创建深度模型时产生问题。具体来说,它限制了你可以拥有的层数,因为在某些时候,输入会由于这种自动尺寸减小而变成 1 × 1 像素。因此,这将在将信息传递给随后的全连接层时创建一个非常窄的瓶颈,导致大量信息丢失。

你可以使用填充来缓解这个问题。通过填充,你在图像周围创建一个零边框,以便获得与输入相同大小的输出。更具体地说,你在周围附加一个大小为size(f ) - 1 的零边框,以获得与输入相同大小的输出。例如,如果你有一个大小为 4 × 4 的输入和一个大小为 2 × 2 的核,那么你将垂直和水平应用大小为 2 - 1 = 1 的边框。这意味着核实际上正在处理一个 5 × 5 的输入(即,(4 + 1) × (4 + 1)-大小的输入),结果是一个 4 × 4 的输出。这被称为same padding。注意,你填充的不总是零。虽然目前 Keras 不支持,但有不同的填充策略(一些示例可以在这里找到:www.tensorflow.org/api_docs/python/tf/pad),例如填充

  • 一个常量值

  • 输入的反射

  • 最近的值

如果你不应用填充,那就是valid padding。不应用填充会导致我们之前讨论过的标准卷积操作。填充的差异如图 4.13 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.13 有效填充与相同填充。有效填充导致输出尺寸减小,而相同填充导致输出与输入尺寸相等。

通过这个,我们结束了对 Conv2D 层的各种超参数的讨论。现在让我们回到我们实现的网络。不幸的是,如果你尝试运行我们讨论过的代码,你会看到一个有些晦涩的错误,就像这样:

---------------------------------------------------------------------------
...

InvalidArgumentError: Negative dimension size caused by subtracting 7 from 6 for 'conv2d_2/Conv2D' (op: 'Conv2D') with input shapes: [?,6,6,32], [7,7,32,64].

我们在这里做错了什么?TensorFlow 似乎在尝试计算卷积层输出时抱怨负尺寸。由于我们已经学会了在各种情况下计算输出大小(例如,带有步幅,带有填充等),我们将计算卷积层的最终输出。我们有以下层:

layers.Conv2D(16,(9,9), strides=(2,2), activation='relu', padding=’valid’, input_shape=(32,32,3))

我们从尺寸为 32×32×3 的输入开始。然后,经过具有 16 个过滤器、卷积核尺寸为 9 和步幅为 2 的卷积操作后,我们得到一个尺寸为(高度和宽度)的输出。

((32 - 9) / 2+ 1 = 12

这里,我们只关注高度和宽度维度。下一层有 32 个过滤器,卷积核尺寸为 7,没有步幅:

     layers.Conv2D(32, (7,7), activation='relu', padding=’valid’)

该层产生一个尺寸为的输出。

12 - 7 + 1 = 6

最后的卷积层有 64 个过滤器,卷积核尺寸为 7,没有步幅。

     layers.Conv2D(64, (7,7), activation='relu', padding=’valid’), 

这将产生一个尺寸为的输出。

6 - 7 + 1 = 0

我们找到了解决办法!通过我们选择的配置,我们的 CNN 产生了一个无效的零尺寸输出。错误中的“负尺寸”一词指的是产生具有无效尺寸(即小于 1)的输出。输出总是需要大于或等于 1。

让我们通过确保输出永远不会具有负尺寸来修正这个网络。此外,我们将在 CNN 中引入几个交错的最大池化层,这有助于网络学习平移不变特征(参见下一列表)。

列表 4.3 已修正的具有正尺寸的 CNN 模型。

from tensorflow.keras import layers, models
import tensorflow.keras.backend as K

K.clear_session()

cnn = models.Sequential([
     layers.Conv2D(                                                      ❶
         filters=16,kernel_size=(3,3), strides=(2,2), activation='relu', ❶
         padding='same', input_shape=(32,32,3)),                         ❶
     layers.MaxPool2D(pool_size=(2,2), strides=(2,2), padding='same'),   ❷
     layers.Conv2D(32, (3,3), activation='relu', padding='same'),        ❸
     layers.MaxPool2D(pool_size=(2,2), strides=(2,2), padding='same'),   ❹
     layers.Flatten(),                                                   ❺
     layers.Dense(64, activation='relu'),                                ❻
     layers.Dense(32, activation='relu'),                                ❻
     layers.Dense(10, activation='softmax')])

❶ 第一个卷积层。输出尺寸从 32 减小到 16。

❷ 第一个最大池化层。输出尺寸从 16 减小到 8。

❸ 第二个卷积层。由于没有步幅,输出尺寸保持不变。

❹ 第二个最大池化层。输出尺寸从 8 减小到 4。

❺ 将高度、宽度和通道维度压缩为单一维度。

❻ 两个中间的具有 ReLU 激活函数的密集层。

❼ 使用 softmax 激活的最终输出层。

最大池化由 tensorflow.keras.layers.MaxPool2D 层提供。该层的超参数与 tensorflow.keras.layers.Conv2D 非常相似:

  • pool_size——这类似于 Conv2D 层的卷积核尺寸参数。它是一个表示(窗口高度,窗口宽度)的元组,按照那个顺序。

  • 步幅——这类似于 Conv2D 层的步幅参数。它是一个表示(高度步幅,宽度步幅)的元组,按照那个顺序。

  • 填充——填充可以是 same 或 valid,并且具有与 Conv2D 层中相同的效果。

让我们分析一下我们对 CNN 所做的更改:

  • 我们对所有 Conv2D 和 MaxPool2D 层都使用了 padding=‘same’,这意味着不会自动减小输出尺寸。这消除了意外进入负尺寸的风险。

  • 我们使用步幅参数来控制随着模型深入而输出尺寸的减小。

您可以按照列表 4.1 中的输出尺寸,并确保对于我们拥有的输入图像,输出永远不会小于或等于零。

在 Conv2D 和 MaxPool2D 层之后,我们必须至少有一个全连接层,因为我们正在解决图像分类任务。为了获得最终的预测概率(即给定输入属于输出类的概率),一个全连接层是必不可少的。但在拥有全连接层之前,我们需要将 Conv2D 或 MaxPool2D 层的四维输出(即[batch, height, width, channel]形状)展平为全连接层的二维输入(即[batch, features]形状)。也就是说,除了批处理维度之外,其他所有维度都被压缩为单个维度。为此,我们使用由 Keras 提供的 tensorflow.keras.layers.Flatten 层。例如,如果我们最后一个 Conv2D 层的输出是[None, 4, 4, 64],那么 Flatten 层将这个输出展平为一个[None, 1024]大小的张量。最后,我们添加三个全连接层,其中前两个全连接层具有 64 和 32 个输出节点,并且使用 ReLU 类型的激活函数。最后一个全连接层将有 10 个节点(每个类一个)和 softmax 激活函数。

CNN 的性能瓶颈

通常,在 CNN 中,卷积/池化层之后的第一个全连接层被认为是性能瓶颈。这是因为这一层通常包含网络参数的很大一部分。假设您有一个 CNN,其中最后一个池化层产生一个 8 × 8 × 256 的输出,后面是一个具有 1,024 个节点的全连接层。这个全连接层将包含 16,778,240(超过 1600 万)个参数。如果您不注意 CNN 的第一个全连接层,您很容易在运行模型时遇到内存不足的错误。

是时候在数据上测试我们的第一个 CNN 了。但在此之前,我们必须用适当的参数编译模型。在这里,我们将监视模型的训练准确率:

cnn.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

最后,您可以使用我们之前创建的训练数据,并通过调用数据训练模型。

history = cnn.fit(tr_data,epochs=25)

您应该得到以下输出:

Epoch 1/25
1563/1563 [==============================] - 23s 15ms/step - loss: 2.0566 - acc: 0.3195
Epoch 2/25
1563/1563 [==============================] - 13s 8ms/step - loss: 1.4664 - acc: 0.4699
...
Epoch 24/25
1563/1563 [==============================] - 13s 8ms/step - loss: 0.8070 - acc: 0.7174
Epoch 25/25
1563/1563 [==============================] - 13s 8ms/step - loss: 0.7874 - acc: 0.7227

看起来我们在训练准确率(以 acc 表示)方面做得不错,并且在识别车辆任务的训练损失(以 loss 表示)上保持稳定的降低(约为 72.2% 的准确率)。但是通过采用各种技术,我们可以获得更好的准确率,您将在后面的章节中看到。对于团队来说,这是一个非常令人兴奋的消息,因为这意味着他们可以继续努力完成他们的全面解决方案。

在本节中,我们研究了 CNNs。CNNs 在解决计算机视觉问题时表现得非常好。在这个实例中,我们研究了使用 CNN 对图像进行分类到各种类别(例如,动物、车辆等)作为模型检测车辆能力的可行性研究。我们详细研究了 CNN 的技术方面,同时仔细检查了各种操作,如卷积和池化,以及与这些操作相关的参数的影响(例如,窗口大小、步长、填充)。我们发现,如果我们在使用这些参数时不注意输出的变化,可能会导致代码错误。接下来,我们修复了错误并在数据集上训练了模型。最后,我们发现模型显示出有希望的结果,迅速达到了 70%以上的训练准确度。接下来,我们将讨论 RNNs,它们在解决时间序列问题方面投入了大量的投资。

练习 2

考虑以下网络:

from tensorflow.keras import layers, models

models.Sequential([
layers.Conv2D(                                                     
         filters=16, kernel_size=(5,5), padding='valid', input_shape=(64,64,3)
),                        
layers.MaxPool2D(pool_size=(3,3), strides=(2,2), padding='same'),  
layers.Conv2D(32, (3,3), activation='relu', padding='same'),       
layers.MaxPool2D(pool_size=(2,2), strides=(2,2), padding='same'),  
layers.Conv2D(32, (3,3), strides=(2,2), activation='relu', padding='same')
])       

最终的输出大小是多少(忽略批次维度)?

4.3 一步一步地:递归神经网络(RNNs)

您是国家气象局的机器学习顾问。他们拥有过去三十年的 CO2 浓度数据。您被委托开发一个机器学习模型,预测未来五年的 CO2 浓度。您计划实现一个简单的 RNN,它接受 CO2 浓度序列(在本例中,过去 12 个月的值)并预测序列中的下一个值。

很明显,我们面对的是一个时间序列问题。这与我们以往解决的任务非常不同。在以前的任务中,一个输入不依赖于先前的输入。换句话说,您认为每个输入都是 i.i.d(独立同分布)的输入。然而,在这个问题中,情况并非如此。今天的 CO2 浓度将取决于过去几个月的 CO2 浓度。

典型的前馈网络(即全连接网络、CNNs)在没有特殊适应的情况下无法从时间序列数据中学习。然而,有一种特殊类型的神经网络专门设计用于从时间序列数据中学习。这些网络通常被称为 RNNs。RNNs 不仅使用当前输入进行预测,而且在给定时间步长时还使用网络的记忆,从过去的时间步长。图 4.14 描述了前馈网络和 RNN 在预测几个月内 CO2 浓度时的差异。正如您所看到的,如果您使用前馈网络,它必须仅基于上个月来预测下个月的 CO2 水平,而 RNN 则会查看所有以前的月份。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.14 以 CO2 浓度水平预测任务为例,前馈网络和 RNN 之间的操作差异

4.3.1 理解数据

数据集非常简单(从datahub.io/core/co2-ppm/r/co2-mm-gl.csv下载)。每个数据点都有一个日期(YYYY-MM-DD 格式)和一个浮点值,表示 CSV 格式中的 CO2 浓度。数据以 CSV 文件的形式提供给我们。让我们按如下方式下载文件:

import requests
import os

def download_data():
    """ This function downloads the CO2 data from 
    https:/ /datahub.io/core/co2-ppm/r/co2-mm-gl.csv
    if the file doesn't already exist
    """
    save_dir = "data"
    save_path = os.path.join(save_dir, 'co2-mm-gl.csv')

    # Create directories if they are not there
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)

    # Download the data and save
    if not os.path.exists(save_path):
        url = "https:/ /datahub.io/core/co2-ppm/r/co2-mm-gl.csv"
        r = requests.get(url)
        with open(save_path, 'wb') as f:
            f.write(r.content)
    else:
        print("co2-mm-gl.csv already exists. Not downloading.")
    return save_path

# Downloading the data
save_path = download_data()

我们可以使用 pandas 轻松加载这个数据集:

import pandas as pd
data = pd.read_csv(save_path)

现在我们可以看一下数据的样子,使用 head() 操作,它将提供数据框中的前几个条目:

data.head()

这将得到类似图 4.15 的东西。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.15 数据集中的示例数据

在这个数据集中,我们唯一感兴趣的两列是日期列和平均列。其中,日期列仅用于可视化目的。让我们将日期列设置为数据框的索引。这样,当我们绘制数据时,x 轴将自动注释相应的日期:

data = data.set_index('Date')

现在我们可以通过创建一条线图来可视化数据(图 4.16):

data[["Average"]].plot(figsize=(12,6))

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.16 CO2 浓度随时间变化的图示

数据的明显特征是它呈上升趋势和短周期性重复。让我们看看我们可以对这些数据做什么样的改进。数据明显的上升趋势构成了一个问题。这意味着数据在一个一致的范围内没有分布。随着时间线的推移,范围不断增加。如果你把数据直接输入模型,通常模型的性能会下降,因为模型必须预测的任何新数据都超出了训练期间看到的数据范围。但是如果你忘记绝对值,思考这些数据与前一个值的相对关系,你会发现它在一个非常小的值范围内波动(大约为-2.0 到+1.5)。事实上,我们可以很容易地测试这个想法。我们将创建一个名为 Average Diff 的新列,其中将包含两个连续时间步之间的相对差异:

data["Average Diff"]=data["Average"] - data["Average"].shift(1).fillna(method='bfill')

如果你在这个阶段执行 data.head(),你会看到类似表 4.2 的东西。

表 4.2 引入平均差异列后数据集中的示例数据

日期十进制日期平均值趋势平均差异
1980-01-011980.042338.45337.830.00
1980-02-011980.125339.15338.100.70
1980-03-011980.208339.48338.130.33
1980-04-011980.292339.87338.250.39
1980-05-011980.375340.30338.780.43

这里,我们正在从原始平均列中减去一个平移一个时间步长的值的版本的平均列。图 4.17 在视觉上描述了这个操作。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.17 从原始平均系列到平均差异系列的转换

最后,我们可以可视化值的行为(图 4.18)使用 data[“Average Diff”].plot(figsize=(12,6)) 行。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.18 CO2 浓度值的相对变化(即,Average[t]-Average[t-1])随时间的变化

你能看到区别吗?从不断增长的数据流中,我们已经转变成了在短时间内发生变化的数据流。下一步是为模型创建数据批处理。我们如何为时间序列问题创建数据批处理呢?请记住,我们不能简单地随机采样数据,因为每个输入都取决于其前序输入。

假设我们想要使用过去 12 个 CO2 浓度值(即 12 个时间步)来预测当前的 CO2 浓度值。时间步数是一个必须仔细选择的超参数。为了自信地选择这个超参数,你必须对数据和所使用的模型的内存限制有扎实的了解。

我们首先随机选择序列中的一个位置,并从该位置开始取 12 个值作为输入,并将第 13 个值作为我们感兴趣的要预测的输出,以便每次采样的总序列长度(n_seq)为 13。如果你这样做 10 次,你将得到一个具有 10 个元素的数据批处理。正如你所看到的,这个过程利用了随机性,同时保留了数据的时间特性,并向模型提供数据。图 4.19 对这个过程进行了可视化描述。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.19 批处理时间序列数据。n_seq 表示我们在给定时间内看到的时间步数,以创建单个输入和输出。

要在 Python 中执行此操作,让我们编写一个函数,以单个数据集的形式给出所有位置的数据。换句话说,该函数返回所有可能的具有 12 个元素的连续序列作为 x,并将每个序列的相应下一个值作为 y。在将数据提供给模型时可以执行洗牌操作,如下一个清单所示。

清单 4.4 用于为模型生成时间序列数据序列的代码

import numpy as np

def generate_data(co2_arr,n_seq):
    x, y = [],[]
    for i in range(co2_arr.shape[0]-n_seq):
        x.append(co2_arr[i:i+n_seq-1])         ❶
        y.append(co2_arr[i+n_seq-1:i+n_seq])   ❷
    x = np.array(x)                            ❸
    y = np.array(y)return x,y

❶ 提取长度为 n_seq 的值序列

❷ 将序列中的下一个值提取为输出

❸ 将所有内容组合成一个数组

4.3.2 实现模型

了解数据后,我们可以开始实现网络。我们将实现一个具有以下内容的网络:

  • 具有 64 个隐藏单元的 rnn 层

  • 具有 64 个隐藏单元和 ReLU 激活的密集层

  • 具有单输出和线性激活的密集层

from tensorflow.keras import layers, models

rnn = models.Sequential([
    layers.SimpleRNN(64),
    layers.Dense(64, activation='relu'),
    layers.Dense(1)
])

请注意,网络的超参数(例如,隐藏单元的数量)已经经验性地选择,以便在给定问题上良好地工作。第一层是网络中最关键的组件,因为它是从时间序列数据中学习的要素。SimpleRNN 层封装了图 4.20 中所示的功能。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.20 SimpleRNN 单元的功能。该单元在每个时间步长产生一个内存,从一个输入到另一个输入。下一步会消耗当前输入以及上一个时间步长的内存。

在 RNN 中发生的计算比在 FCN 中更复杂。RNN 按给定顺序(即 x1、x2、x3)从一个输入到另一个输入。在每个步骤中,递归层产生一个输出(即 o1、o2、o3),并将隐藏计算(h0、h1、h2、h3)传递到下一个时间步长。在这里,第一个隐藏状态(h0)通常设为零。

在给定的时间步长上,递归层计算一个隐藏状态,就像 Dense 层一样。然而,涉及的具体计算更加复杂,超出了本书的范围。隐藏状态的大小是递归层的另一个超参数。递归层接受当前输入以及细胞计算的先前隐藏状态。更大尺寸的隐藏状态有助于保持更多内存,但增加了网络的内存需求。由于隐藏状态依赖于上一个时间步长的自身,这些网络被称为 RNNs。

使用 SimpleRNN 的算法

SimpleRNN 层模仿的计算也称为Elman 网络。要了解递归层中发生的具体计算,你可以阅读 J.L. Elman(1990)的论文“Finding Structure in Time”。要了解 RNN 的后续变体及其区别的更高级概述,请参阅mng.bz/xnJgmng.bz/Ay2g

默认情况下,SimpleRNN 不会将隐藏状态暴露给开发者,并且会在时间步长之间自动传播。对于这个任务,我们只需要每个时间步长产生的最终输出,这默认情况下是该层的输出。因此,你可以简单地将 SimpleRNN 连接到 Sequential API 中的一个 Dense 层,而无需进行任何额外的工作。

你是否注意到我们没有为第一层提供 input_shape?只要你在模型拟合期间提供正确形状的数据即可。Keras 会懒惰地构建层,因此在你向模型提供数据之前,模型不需要知道输入大小。但为了避免错误,最好在模型的第一层设置 input_shape 参数。例如,在我们定义的模型中,第一层(即 SimpleRNN 层)可以更改为 layers.SimpleRNN(64, input_shape=x),其中 x 是包含模型接受的数据形状的元组。

这个模型的另一个重要区别是它是一个回归模型,而不是分类模型。在分类模型中,有不同的类别(由输出节点表示),我们尝试将给定的输入与不同的类别(或节点)关联起来。回归模型预测一个连续的值作为输出。在我们的回归模型中,输出中没有类的概念,而是表示 CO2 浓度的实际连续值。因此,我们必须适当地选择损失函数。在这种情况下,我们将使用均方误差(MSE)作为损失。MSE 是回归问题的非常常见的损失函数。我们将使用 MSE 损失和 adam 优化器编译 rnn:

rnn.compile(loss='mse', optimizer='adam')

让我们祈祷并训练我们的模型:

x, y = generate_data(data[“Average Diff”], n_seq=13)
rnn.fit(x, y, shuffle=True, batch_size=64, epochs=25)

你将得到以下异常:

ValueError: 
➥ Input 0 of layer sequential_1 is incompatible with the layer: 
➥ expected ndim=3, found ndim=2\. Full shape received: [None, 12]

看起来我们做错了什么。我们刚刚运行的那行导致了一个异常,它说给层 sequential_1(即 SimpleRNN 层)提供的数据的维度出了问题。具体来说,sequential_1 层期望一个三维输入,但却有一个二维输入。我们需要调查这里发生了什么,并解决这个问题。

问题在于 SimpleRNN(或 tf.keras 中的任何其他顺序层)只接受非常特定格式的数据。数据需要是三维的,按照以下顺序的维度:

  1. 批处理维度

  2. 时间维度

  3. 特征维度

即使对于这些维度中的任何一个,你只有一个元素,它们也需要以大小为 1 的维度存在于数据中。让我们通过打印 x.shape 来查看 x 的维度。你将会得到 x.shape = (429, 12)。现在我们知道了问题所在。我们尝试传递一个二维数据集,但我们应该传递一个三维数据集。在这种情况下,我们需要将 x 重塑为形状为 (492, 12, 1) 的张量。让我们修改我们的 generate_data(…) 函数以反映以下清单中的这种变化。

列表 4.5 具有正确形状数据的先前 generate_data() 函数

import numpy as np

def generate_data(co2_arr,n_seq):
    x, y = [],[]for i in range(co2_arr.shape[0]-n_seq):   ❷
        x.append(co2_arr[i:i+n_seq-1])        ❸
        y.append(co2_arr[i+n_seq-1:i+n_seq])  ❸
    x = np.array(x).reshape(-1,n_seq-1,1)     ❹
    y = np.array(y) 
    return x,y

❶ 创建两个列表来保存输入序列和标量输出目标。

❷ 遍历数据中所有可能的起始点,以用作输入序列。

❸ 创建第 i 个位置的输入序列和输出目标。

❹ 将 x 从列表转换为数组,并使 x 成为 RNN 可接受的 3D 张量。

现在让我们尝试训练我们的模型:

x, y = generate_data(data[“Average Diff”], n_seq=13)
rnn.fit(x, y, shuffle=True, batch_size=64, epochs=25)

你应该看到模型的 MSE 在下降:

Train on 429 samples
Epoch 1/25
429/429 [==============================] - 1s 2ms/sample - loss: 0.4951
Epoch 2/25
429/429 [==============================] - 0s 234us/sample - loss: 0.0776
...
Epoch 24/25
429/429 [==============================] - 0s 234us/sample - loss: 0.0153
Epoch 25/25
429/429 [==============================] - 0s 234us/sample - loss: 0.0152

我们从大约 0.5 的损失开始,最终损失大约为 0.015。这是一个非常积极的迹象,因为它表明模型正在学习数据中存在的趋势。

4.3.3 使用经过训练的模型预测未来的 CO2 值

到目前为止,我们已经专注于分类任务。对于分类任务,评估模型要比回归任务容易得多。在分类任务中(假设数据集平衡),通过计算数据的总体准确性,我们可以得到一个体现模型表现的不错的代表性数字。在回归任务中,情况并不那么简单。我们无法对回归值进行准确度测量,因为预测的是实际值,而不是类别。例如,均方损失的大小取决于我们正在回归的值,这使它们难以客观解释。为了解决这个问题,我们预测未来五年的数值,并直观地检查模型的预测情况(见下一列表)。

列表 4.6 使用训练模型的未来 CO2 水平预测逻辑

history = data["Average Diff"].values[-12:].reshape(1,-1,1)     ❶
true_vals = []
prev_true = data["Average"].values[-1]for i in range(60):                                             ❸
    p_diff = rnn.predict(history).reshape(1,-1,1)               ❹
    history = np.concatenate((history[:,1:,:],p_diff),axis=1)   ❺
    true_vals.append(prev_true+p_diff[0,0,0])                   ❻
    prev_true = true_vals[-1]

❶ 从中获取开始预测的第一个数据序列,重塑为 SimpleRNN 接受的正确形状

❷ 保存最后一个绝对 CO2 浓度值,以计算相对预测的实际值。

❸ 预测接下来的 60 个月。

❹ 使用数据序列进行预测。

❺ 修改历史记录,以包括最新的预测。

❻ 计算绝对 CO2 浓度。

❼ 更新 prev_true,以便在下一个时间步骤中计算绝对 CO2 浓度。

让我们回顾一下我们所做的事情。首先,我们从我们的训练数据中提取最后 12 个 CO2 值(从平均差值列中)来预测第一个未来的 CO2 值,并将其重塑为模型期望数据的正确形状:

history = data["Average Diff"].values[-12:].reshape(1,-1,1)

然后,我们将预测的 CO2 值记录在 true_vals 列表中。请记住,我们的模型只预测 CO2 值相对于先前 CO2 值的相对运动。因此,在模型预测之后,为了得到绝对 CO2 值,我们需要最后一个 CO2 值。prev_true 捕获了这一信息,最初包含数据的平均列中的最后一个值:

prev_true = data["Average"].values[-1]

现在,接下来的 60 个月(或 5 年),我们可以递归预测 CO2 值,同时使最后预测的值成为网络的下一个输入。要做到这一点,我们首先使用 Keras 提供的 predict(…)方法预测一个值。然后,我们需要确保预测也是一个三维张量(尽管它只是一个单一值)。然后我们修改 history 变量:

history = np.concatenate((history[:,1:,:],p_diff),axis=1)

我们把历史中除了第一个值之外的所有值,并将最后预测的值附加到末尾。然后,我们通过添加 prev_true 值到 p_diff 来附加绝对预测的 CO2 值:

true_vals.append(prev_true+p_diff[0,0,0])

最后,我们将 prev_true 更新为我们预测的最后一个绝对 CO2 值:

prev_true = true_vals[-1]

通过递归执行这组操作,我们可以获得接下来 60 个月的预测值(保存在 true_vals 变量中)。如果我们可视化预测的值,它们应该看起来像图 4.21。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.21 在接下来的五年里预测的 CO2 浓度。虚线代表当前数据的趋势,实线代表预测的趋势。

做得好!考虑到模型的简单性,预测看起来非常有前景。该模型肯定捕捉到了二氧化碳浓度的年度趋势,并学会了二氧化碳水平将继续上升。你现在可以去找你的老板,事实性地解释为什么我们应该担心未来气候变化和危险水平的二氧化碳。我们在这里结束了对不同神经网络的讨论。

练习 3

受到你在预测二氧化碳浓度方面工作的印象,你的老板给了你数据,并要求你改进模型以预测二氧化碳和温度值。保持其他超参数不变,你会如何改变模型以完成这个任务?确保指定第一层的 input_shape 参数。

总结

  • 完全连接网络(FCNs)是最简单直接的神经网络之一。

  • FCNs 可以使用 Keras Dense 层来实现。

  • 卷积神经网络(CNNs)是计算机视觉任务的热门选择。

  • TensorFlow 提供了各种层,如 Conv2D、MaxPool2D 和 Flatten,这些层帮助我们快速实现 CNNs。

  • CNNs 有一些参数,如卷积核大小、步幅和填充,必须小心设置。如果不小心,这可能导致张量形状不正确和运行时错误。

  • 循环神经网络(RNNs)主要用于学习时间序列数据。

  • 典型的 RNN 期望数据组织成具有批次、时间和特征维度的三维张量。

  • RNN 看的时间步数是一个重要的超参数,应该根据数据进行选择。

练习答案

练习 1: 你可以使用 Sequential API 来做到这一点,你只需要使用 Dense 层。

练习 2

autoencoder = models.Sequential(
    [layers.Dense(32, activation='sigmoid', input_shape=(512,)),
    layers.Dense(16, activation='sigmoid'),
    layers.Dense(512, activation='sigmoid')]
)

练习 3

rnn = models.Sequential([
    layers.SimpleRNN(64, input_shape=(12, 2)),
    layers.Dense(64, activation='relu'),
    layers.Dense(2)
])

第五章:深度学习的最新技术:Transformer

本章内容包括:

  • 为机器学习模型以数值形式表示文本

  • 使用 Keras sub-classing API 构建 Transformer 模型

到目前为止,我们已经看到了许多不同的深度学习模型,包括全连接网络、卷积神经网络和循环神经网络。我们使用全连接网络来重建受损图像,使用卷积神经网络来对车辆进行分类,最后使用 RNN 来预测未来的 CO2 浓度值。在本章中,我们将讨论一种新型的模型,即 Transformer。

Transformer 是最新一代的深度网络。瓦斯瓦尼等人在他们的论文《Attention Is All You Need》(arxiv.org/pdf/1706.03762.pdf)中普及了这个想法。他们创造了“Transformer”这个术语,并解释了它在未来有很大潜力。在随后的几年里,谷歌、OpenAI 和 Facebook 等领先的科技公司实施了更大更好的 Transformer 模型,这些模型在 NLP 领域显著优于其他模型。在这里,我们将参考瓦斯瓦尼等人在论文中介绍的模型来学习它。虽然 Transformer 也存在于其他领域(例如计算机视觉),我们将重点介绍 Transformer 在 NLP 领域中的应用,特别是在机器翻译任务中(即使用机器学习模型进行语言翻译)。本章将省略原始 Transformer 论文中的一些细节,以提高清晰度,但这些细节将在后面的章节中进行介绍。

想要在使用深度学习模型解决实际问题时出类拔萃,了解 Transformer 模型的内部工作原理是必不可少的。如前所述,Transformer 模型在机器学习领域迅速普及。这主要是因为它在解决复杂机器学习问题方面展现出的性能。

5.1 将文本表示为数字

假设你正在参加一个游戏节目。游戏中的一个挑战叫做单词盒子。有一个由透明盒子组成的矩阵(3 行,5 列,10 深度)。你也有一些上面涂有 0 或 1 的球。你被给予了三个句子,你的任务是用 1 和 0 填充所有的盒子来表示这些句子。此外,你可以在一分钟内写一条简短的信息,帮助其他人在以后破译这一信息。之后,另一个队员看着盒子,写下最初给你的原始句子中的尽可能多的单词。

这个挑战本质上是如何将文本转换成数字,用于机器翻译模型。这也是在了解任何 NLP 模型之前需要解决的重要问题。到目前为止我们看到的数据都是数值型数据结构。例如,一张图像可以被表示为一个 3D 数组(高度,宽度和通道维度),其中每个值表示像素强度(即,取值范围在 0 至 255 之间)。但文本呢?我们怎么让计算机理解字符、单词或句子呢?我们将在自然语言处理(NLP)的情境中学习如何用 Transformer 完成这一点。

您有以下一组句子:

  • 我去了海滩。

  • 天气很冷。

  • 我回到了房子。

你要做的第一件事是给词汇表中的每个单词分配一个从 1 开始的 ID。我们将保留数字 0 给我们稍后会看到的特殊标记。假设你分配了以下 ID:

  • I → 1

  • went → 2

  • to → 3

  • the → 4

  • beach → 5

  • 它 → 6

  • was → 7

  • cold → 8

  • came → 9

  • back → 10

  • house → 11

将单词映射到相应的 ID 后,我们的句子变为了下面这个样子:

  • [1, 2, 3, 4, 5]

  • [6, 7, 8]

  • [1, 9, 10, 3, 4, 11]

请记住,您需要填写所有方框,并且最多长度为 5。请注意我们的最后一句有六个单词。这意味着所有句子都需要表示为固定长度。深度学习模型面临类似的问题。它们以批处理的方式处理数据,并且为了高效处理数据,批处理的序列长度需要是固定的。真实世界的句子在长度上可能差异很大。因此,我们需要

  • 用特殊标记(ID 为 0)填充短句

  • 截断长句

使它们具有相同的长度。如果我们填充短句并截断长句,使长度为 5,我们得到以下结果:

  • [1, 2, 3, 4, 5]

  • [6, 7, 8, 0, 0]

  • [1, 9, 10, 3, 4]

这里,我们有一个大小为 3×5 的 2D 矩阵,它表示我们的一批句子。最后要做的一件事是将这些 ID 表示为向量。因为我们的球有 1 和 0,你可以用 11 个球(我们有 10 个不同的单词和特殊标记)代表每个单词,其中由单词 ID 指示的位置上的球为 1,其余为 0。这种方法称为 one-hot 编码。例如,

以下分别代表着各自的 ID:

1 → [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]

。 。 。

10 → [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0]

11 → [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]

现在你可以用 1 和 0 填写方框,使得你得到类似图 5.1 的结果。这样,任何一位有这些 ID 映射的人(提供在一张纸上)都可以解密最初所提供的大部分单词(除了被截断的单词)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.1 词盒游戏中的方框。阴影方框代表一个词(即第一个句子中的第一个词“I”,它的 ID 是 1)。你可以看到它被一个 1 和九个 0 所表示。

同样,这是在 NLP 问题中对单词进行的转换。你可能会问:“为什么不直接提供单词 ID?”存在两个问题:

  • 神经网络看到的值范围非常大(0-100,000+)对于一个现实世界的问题。这会导致不稳定性并使训练困难。

  • 输入 ID 会错误地表明具有相似 ID 的单词应该是相似的(例如,单词 ID 4 和 5)。这从未发生过,会使模型混淆并导致性能下降。

因此,将单词转换为某种向量表示是很重要的。有许多将单词转换为向量的方法,例如独热编码和词嵌入。你已经看到了独热编码的工作原理,我们将稍后详细讨论词嵌入。当我们将单词表示为向量时,我们的 2D 矩阵变为 3D 矩阵。例如,如果我们将向量长度设置为 4,你将得到一个 3 × 6 × 4 的 3D 张量。图 5.2 描述了最终矩阵的外观。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.2 表示一个单词序列批次的 3D 矩阵,其中每个单词由一个向量表示(即矩阵中的阴影块)。有三个维度:批次、序列(时间)和特征。

接下来,我们将讨论流行的 Transformer 模型的各个组成部分,这将为我们提供对这些模型内部执行的基础。

5.2 理解 Transformer 模型

你目前是一名深度学习研究科学家,并最近受邀在当地 TensorFlow 大会上进行有关 Transformer 的研讨会。Transformer 是一类新型的深度学习模型,在众多任务中已经超越了它们的老对手。你计划首先解释 Transformer 网络的架构,然后带领参与者完成几个练习,在这些练习中,他们将使用 Keras 的子类化层实现在 Transformers 中找到的基本计算,最后使用这些计算来实现一个基本的小规模 Transformer。

5.2.1 Transformer 的编码器-解码器视图

Transformer 网络基于编码器-解码器架构。编码器-解码器模式在某些类型的深度学习任务中很常见(例如,机器翻译、问答、无监督图像重建)。其思想是编码器将输入映射到某种潜在(或隐藏)表示(通常较小),而解码器使用潜在表示构建有意义的输出。例如,在机器翻译中,语言 A 的句子被映射到一个潜在向量,解码器从中构建语言 B 中该句子的翻译。你可以将编码器和解码器视为两个独立的机器学习模型,其中解码器依赖于编码器的输出。这个过程如图 5.3 所示。在给定的时间点,编码器和解码器同时处理一批词序列(例如,一批句子)。由于机器学习模型不理解文本,因此该批次中的每个单词都由一个数字向量表示。这是通过一种过程来实现的,例如独热编码,类似于我们在第 5.1 节中讨论的内容。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.3 机器翻译任务的编码器-解码器架构

编码器-解码器模式在现实生活中也很常见。假设你是法国的导游,带着一群游客去一家餐厅。服务员用法语解释菜单,你需要为团队将其翻译成英语。想象一下你会如何做。当服务员用法语解释菜肴时,你处理这些词语并创建出菜肴的心理图像,然后将该心理图像翻译成一系列英语词语。

现在让我们更深入地了解各个组件及其构成。

5.2.2 更深入地探讨

自然地,你可能会问自己:“编码器和解码器由什么组成?”这是本节的主题。请注意,此处讨论的编码器和解码器与你在第三章中看到的自编码器模型有很大不同。正如之前所述,编码器和解码器分别像多层深度神经网络一样工作。它们由多个层组成,每个层包含子层,封装了对输入进行的某些计算以产生输出。前一层的输出作为下一层的输入。还需要注意的是,编码器和解码器的输入和输出是序列,例如句子。这些模型中的每个层都接收一个元素序列并输出另一个元素序列。那么,编码器和解码器中的单个层包含什么?

每个编码器层包含两个子层:

  • 自注意力层

  • 全连接层

自注意力层的最终输出与全连接层类似(即使用矩阵乘法和激活函数)。典型的全连接层将处理输入序列中的所有元素,并分别处理它们,然后输出一个元素以替换每个输入元素。但自注意力层可以选择和组合输入序列中的不同元素以输出给定元素。这使得自注意力层比典型的全连接层更加强大(见图 5.4)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.4 自注意力子层和全连接子层之间的区别。自注意力子层查看序列中的所有输入,而全连接子层只查看正在处理的输入。

为什么以这种方式选择和组合不同的输入元素有好处?在自然语言处理的上下文中,自注意力层使模型在处理某个单词时能够查看其他单词。但这对模型意味着什么?这意味着在编码器处理句子“I kicked the ball and it disappeared”中的单词“it”时,模型可以关注单词“ball”。通过同时看到“ball”和“it”两个单词(学习依赖关系),消除歧义的单词变得更容易。这样的能力对语言理解至关重要。

我们可以通过一个现实世界的例子了解自注意力如何方便地帮助我们解决任务。假设你正在和两个人玩游戏:A 和 B。A 手持写有问题的板子,你需要说出答案。假设 A 一次只透露一个单词,直到问题的最后一个单词被揭示,才揭示你正在回答问题。对于长而复杂的问题,这是具有挑战性的,因为你不能物理上看到完整的问题,必须严重依赖记忆来回答问题。这就是没有自注意力时的感觉。另一方面,假设 B 将整个问题一次性展示在板上,而不是逐字逐句地展示。现在回答问题要容易得多,因为你可以一次看到整个问题。如果问题很复杂,需要复杂的答案,你可以在提供完整答案的各个部分时查看问题的不同部分。这就是自注意力层的作用。

接下来,全连接层以逐元素的方式接受自注意力子层产生的输出元素,并为每个输出元素生成一个隐藏表示。这使得模型更加深入,从而表现更好。

让我们更详细地看一下数据如何通过模型流动,以更好地理解层和子层的组织。假设要将句子“Dogs are great”(英语)翻译成“Les chiens sont super”(法语)。首先,编码器接收完整的句子“Dogs are great”,并为句子中的每个单词生成一个输出。自注意力层选择每个位置的最重要的单词,计算一个输出,并将该信息发送到全连接层以产生更深层的表示。解码器迭代地生成输出单词,一个接着一个。为此,解码器查看编码器的最终输出序列以及解码器预测的所有先前单词。假设最终预测是 les chiens sont super 。这里, 标记了句子的开始, 标记了句子的结束。它接收的第一个输入是一个特殊标记,表示句子的开始(),以及编码器的输出,并产生翻译中的下一个单词:“les”。然后解码器消耗 和 “les” 作为输入,生成单词“chiens”,并继续直到模型达到翻译的末尾(由 标记)。图 5.5 描述了这个过程。

在原始的 Transformer 论文中,编码器有六个层,并且一个单层有一个自注意力子层和一个全连接子层,按顺序排列。首先,自注意力层将英文单词作为时间序列输入。然而,在将这些单词馈送到编码器之前,您需要为每个单词创建一个数值表示,如前面所讨论的。在论文中,词嵌入(附加一些编码)用于表示这些单词。每个嵌入都是一个 512 长的向量。然后自注意力层计算输入句子中每个单词的隐藏表示。如果我们忽略一些实现细节,这个时间步长 t 的隐藏表示可以被看作是所有输入的加权和(在一个单一序列中),其中输入位置 i 的权重由在处理编码器输入中的单词 ew[t] 时选择(或关注)编码器单词 ew[i] 在输入序列中的重要性来确定。编码器在输入序列中的每个位置 t 上都做出这样的决定。例如,在处理句子“我踢了 并且 消失了”中的单词“它”时,编码器需要更多地关注单词“球”而不是单词“the”。自注意力子层中的权重被训练以展示这样的属性。这样,自注意力层为每个编码器输入生成了一个隐藏表示。我们称之为 关注表示/输出

全连接子层然后接管并且非常直观。它有两个线性层,并且在这两个层之间有一个 ReLU 激活函数。它接收自注意力层的输出,并将其转换为隐藏输出使用。

h[1] = ReLU(xW[1] + b[1])

h[2] = h[1]W[2] + b[2]

请注意,第二层没有非线性激活函数。接下来,解码器也有六个层,每个层都有三个子层:

  • 一个掩码自注意力层

  • 一个编码器-解码器注意力层

  • 一个全连接层

掩码自注意力层的操作方式与自注意力层类似。然而,在处理第 s 个单词(即dw[s])时,它会屏蔽 dw[s] 之前的单词。例如,在处理单词“chiens”时,它只能关注单词“”和“les”。这很重要,因为解码器必须能够预测正确的单词,只给出它先前预测的单词,所以强制解码器只关注它已经看到的单词是有意义的。

接下来,编码器-解码器注意力层获取编码器输出和掩码自注意力层产生的输出,并产生一系列输出。该层的目的是计算时间 s 处的隐藏表示(即一个受关注的表示),作为编码器输入的加权和,其中位置 j 的权重由处理解码器单词 dw[s] 时关注编码器输入 e w[j]的重要性确定。

最后,与编码器层相同的全连接层接收自注意力层的输出以生成层的最终输出。图 5.5 以高层次描述了本节讨论的层和操作。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.5 编码器和解码器中的各个层以及编码器内部、解码器内部和编码器与解码器之间形成的各种连接。方框表示模型的输入和输出。长方形阴影框表示子层的临时输出。

在下一节中,我们将讨论自注意力层的外观。

5.2.3 自注意力层

我们已经在抽象级别上介绍了自注意力层的目的。在处理时间步 t 的单词w[t]时,其目的是确定关注输入序列中的第 i 个单词(即w[i])对理解当前单词有多重要。换句话说,该层需要确定对于每个单词(由 t 索引)所有其他单词(由 i 索引)的重要性。现在让我们以更细粒度的方式理解涉及此过程的计算。

首先,计算涉及三个不同的实体:

  • 查询 — 查询的目的是表示当前正在处理的单词。

  • — 键的目的是表示在处理当前单词时要关注的候选单词。

  • — 值的目的是计算序列中所有单词的加权和,其中每个单词的权重基于它对理解当前单词的重要性。

对于给定的输入序列,需要为输入的每个位置计算查询、键和值。这些是由与每个实体相关联的权重矩阵计算的。

请注意,这是它们关系的简化,实际关系有些复杂和混乱。但这种理解为什么我们需要三个不同的实体来计算自注意力输出提供了动机。

接下来,我们将了解自注意力层如何从输入序列到查询、键和值张量,最终到输出序列。首先,将输入的单词序列使用单词嵌入查找转换为数值表示。单词嵌入本质上是一个巨大的矩阵,其中词汇表中的每个单词都有一个浮点向量(即嵌入向量)。通常,这些嵌入是几百个元素长。对于给定的输入序列,我们假设输入序列的长度为 n 元素,并且每个单词向量的长度为 d[model] 元素。然后我们有一个 n × d[model] 矩阵。在原始 Transformer 论文中,单词向量长度为 512 个元素。

自注意力层中有三个权重矩阵:查询权重(W[q])、键权重(W[k])和值权重(W[v]),分别用于计算查询、键和值向量。W[q] 是 d[model] × d[q],W[k] 是 d[model] × d[k],W[v] 是 d[model] × d[v]。让我们假设这些元素在 TensorFlow 中的维度为 512,就像原始 Transformer 论文中一样。即,

d[model] = d[q] = d[k] = d[v] = 512

我们首先将我们的输入 x 定义为一个 tf.constant,它有三个维度(批量、时间、特征)。Wq、Wk 和 Wv 声明为 tf.Variable 对象,因为这些是自注意力层的参数。

import tensorflow as tf
import numpy as np

n_seq = 7
x = tf.constant(np.random.normal(size=(1,n_seq,512)))
Wq = tf.Variable(np.random.normal(size=(512,512)))
Wk = tf.Variable (np.random.normal(size=(512,512)))
Wv = tf.Variable (np.random.normal(size=(512,512)))

其形状为

>>> x.shape=(1, 7, 512)
>>> Wq.shape=(1, 512)
>>> Wk.shape=(1, 512)
>>> Wv.shape=(1, 512)

接下来,qkv 计算如下:

q = xW[q];形状变换:n × d[model]。d[model] × d[q] = n × d[q]

k = xW[k];形状变换:n × d[model]。d[model] × d[k] = n × d[k]

v = xW[v];形状变换:n × d[model]。d[model] × d[v] = n × d[v]

很明显,计算 qkv 只是一个简单的矩阵乘法。请记住,所有输入(即 x)和输出张量(即 q、k 和 v)前面都有一个批处理维度,因为我们处理数据批次。但为了避免混乱,我们将忽略批处理维度。然后我们按以下方式计算自注意力层的最终输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,组件 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(将被称为 P)是一个概率矩阵。这就是自注意力层的全部内容。使用 TensorFlow 实现自注意力非常简单。作为优秀的数据科学家,让我们将其创建为可重复使用的 Keras 层,如下所示。

列表 5.1 自注意力子层

import tensorflow as tf
import tensorflow.keras.layers as layers

class SelfAttentionLayer(layers.Layer):

    def __init__(self, d):
        super(SelfAttentionLayer, self).__init__()
        self.d = d                                                                ❶

    def build(self, input_shape):
        self.Wq = self.add_weight(                                                ❷
            shape=(input_shape[-1], self.d), initializer='glorot_uniform',        ❷
            trainable=True, dtype='float32')        
        self.Wk = self.add_weight(                                                ❷
            shape=(input_shape[-1], self.d), initializer='glorot_uniform',        ❷
            trainable=True, dtype='float32')
        self.Wv = self.add_weight(                                                ❷
            shape=(input_shape[-1], self.d), initializer='glorot_uniform',        ❷
            trainable=True, dtype='float32')

    def call(self, q_x, k_x, v_x):
        q = tf.matmul(q_x,self.Wq)                                                ❸
        k = tf.matmul(k_x,self.Wk)                                                ❸
        v = tf.matmul(v_x,self.Wv)                                                ❸

        p = tf.nn.softmax(tf.matmul(q, k, transpose_b=True)/math.sqrt(self.d))    ❹
        h = tf.matmul(p, v)return h,p

❶ 定义自注意力输出的输出维度

❷ 定义计算查询、键和值实体的变量

❸ 计算查询、键和值张量

❹ 计算概率矩阵

❺ 计算最终输出

这是一个快速的复习:

  • init(self, d)—定义层的任何超参数

  • build(self, input_shape)—创建层的参数作为变量

  • call(self, v_x, k_x, q_x)—定义层中发生的计算

如果你看一下 call(self, v_x, k_x, q_x) 函数,它接受三个输入:分别用于计算值、键和查询。在大多数情况下,这些输入是相同的。然而,也有一些情况下,不同的输入被用于这些计算(例如,解码器中的一些计算)。此外,请注意我们同时返回 h(即最终输出)和 p(即概率矩阵)。概率矩阵是一个重要的视觉辅助工具,它帮助我们理解模型何时以及在哪里关注了单词。如果你想获取层的输出,可以执行以下操作

layer = SelfAttentionLayer(512)
h, p = layer(x, x, x)
print(h.shape)

将返回

>>> (1, 7, 512)

练习 1

给定以下输入

x = tf.constant(np.random.normal(size=(1,10,256)))

并假设我们需要一个大小为 512 的输出,编写代码创建 Wq、Wk 和 Wv 作为 tf.Variable 对象。使用 np.random.normal() 函数设置初始值。

5.2.4 使用标量理解自注意力

目前还不太清楚为什么设计了这样的计算方式。为了理解和可视化这个层正在做什么,我们将假设特征维度为 1. 也就是说,一个单词由一个值(即标量)表示。图 5.6 可视化了如果我们假设单一输入序列和输入的维度(d[model])、查询长度(d[q])、键长度(d[k])和值长度(d[v])的维度都为 1. 在我们所做的假设下,W[q]、W[k] 和 W[v] 将是标量。用于计算 qkv 的矩阵乘法本质上变成了标量乘法:

q = (q[1], q[2],…, q[7]),其中 q[i] = x[i] W[q]

k = (k[1], k[2],…, k[7]),其中 k[i] = x[i] W[k]

v = (v[1], v[2],…, v[7]),其中 v[i] = x[i] W[v]

接下来,我们需要计算 P = softmax ((Q.K^T) / √(d[k])) 组件。Q.K^T 本质上是一个 n × n 的矩阵,它代表了每个查询和键组合的项(图 5.6)。Q.K[(i,j)]^T 的第 i 行和第 j 列是按如下计算的

Q.K[(i,j)]^T =q [i] × k [j]

然后,通过应用 softmax 函数,该矩阵被转换为行向量的概率分布。你可能已经注意到 softmax 转换中出现了一个常数 √(d[k])。这是一个归一化常数,有助于防止梯度值过大并实现稳定的梯度。在我们的示例中,你可以忽略这个因为 √(d[k]) = 1。

最后,我们计算最终输出 h = (h[1],h[2],…,h[7]),其中

h[i] = P[(i],[1)] v[1] + P[(i],[2)] v[2] +…+ P[(i],[7)] v[7]

在这里,我们可以更清楚地看到 qkv 之间的关系。当计算最终输出时,qk 被用于计算 v 的软索引机制。例如,当计算第四个输出(即 h[4])时,我们首先对第四行进行硬索引(跟随 q[4]),然后根据该行的列(即 k 值)给出的软索引(即概率),混合各种 v 值。现在更清楚 qkv 的作用是什么了:

  • 查询—帮助构建最终用于索引值(v)的概率矩阵。查询影响矩阵的行,并表示正在处理的当前单词的索引。

  • —帮助构建最终用于索引值(v)的概率矩阵。键影响矩阵的列,并表示根据查询单词需要混合的候选单词。

  • —通过使用查询和键创建的概率矩阵进行索引,用于计算最终输出的隐藏(即关注)表示。

您可以轻松地将图 5.6 中的大灰色框放置在自注意子层上,并仍然产生输出形状(如图 5.5 中所示)(图 5.7)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.6 自注意层中的计算。自注意层从输入序列开始,并计算查询、键和值向量的序列。然后将查询和键转换为概率矩阵,该矩阵用于计算值的加权和。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.7(顶部)和图 5.6(底部)。您可以从底部获取灰色框,并将其插入到顶部的自注意子层中,然后可以看到产生相同的输出序列。

现在让我们扩展我们的自注意层,并重新审视其背后的具体计算及其重要性。回到我们先前的表示法,我们从一个具有 n 个元素的单词序列开始。然后,在嵌入查找之后,为每个单词检索一个嵌入向量,我们有一个大小为 n × d[model] 的矩阵。接下来,我们有权重和偏差来计算每个查询、键和值向量:

q = xW[q],其中 x ∈ ℝ^(n×dmodel)。W[q] ∈ ℝ^(dmodel×dq),而 q ∈ ℝ^(n×d)q

k = xW[k],其中 x ∈ ℝ^(n×dmodel)。W[k] ∈ ℝ^(dmodel×dk),而 k ∈ ℝ^(n×d)k

v = xW[v],其中 x ∈ ℝ^(n×dmodel)。W[v] ∈ ℝ^(dmodel×dv),而 v ∈ ℝ^(n×d)v

例如,查询,或 q,是一个大小为 n × d[q] 的向量,通过将大小为 n × d[model] 的输入 x 与大小为 d[model] × d[q] 的权重矩阵 W[q] 相乘获得。还要记住,正如在原始 Transformer 论文中一样,我们确保查询、键和值向量的所有输入嵌入大小相同。换句话说,

d[model] = d[q] = d[k] = d[v] = 512

接下来,我们使用我们获得的 q 和 k 值计算概率矩阵:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

最后,我们将这个概率矩阵与我们的值矩阵相乘,以获得自注意力层的最终输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

自注意力层接受一批词序列(例如,一批具有固定长度的句子),其中每个词由一个向量表示,并产生一批隐藏输出序列,其中每个隐藏输出是一个向量。

自注意力与循环神经网络(RNNs)相比如何?

在 Transformer 模型出现之前,RNNs 主导了自然语言处理的领域。RNNs 在 NLP 问题中很受欢迎,因为大多数问题本质上都是时间序列问题。你可以将句子/短语视为一系列单词(即每个单词由一个特征向量表示)在时间上的分布。RNN 通过这个序列,一次消耗一个单词(同时保持一个记忆/状态向量),并在最后产生一些输出(或一系列输出)。但是你会发现,随着序列长度的增加,RNN 的表现越来越差。这是因为当 RNN 到达序列末尾时,它可能已经忘记了开始时看到的内容。

你可以看到,自注意力机制缓解了这个问题,它允许模型在给定时间内查看完整的序列。这使得 Transformer 模型比基于 RNN 的模型表现得好得多。

5.2.5 自注意力作为烹饪比赛

自注意力的概念可能仍然有点难以捉摸,这使得理解自注意力子层中究竟发生了什么变得困难。以下类比可能会减轻负担并使其更容易理解。假设你参加了一个与其他六位选手(总共七位选手)一起的烹饪节目。游戏如下。

你在超市里拿到一件印有号码(从 1 到 7)的 T 恤和一个手推车。超市有七个过道。你必须飞奔到印有 T 恤上号码的过道,墙上会贴着某种饮料的名称(例如,苹果汁,橙汁,酸橙汁)。你需要挑选制作该饮料所需的物品,然后飞奔到你分配的桌子上,制作那种饮料。

假设你是号码 4 并且拿到了橙汁,所以你会前往 4 号过道并收集橙子,一点盐,一颗酸橙,糖等等。现在假设你旁边的对手(编号 3)要制作酸橙汁;他们会挑选酸橙,糖和盐。正如你所看到的,你们选取了不同的物品以及相同物品的不同数量。例如,你的对手没有选择橙子,但你选择了,并且你可能选择了较少的酸橙,与你正在制作酸橙汁的对手相比。

这与自注意力层中发生的情况非常相似。你和你的竞争对手是模型的输入(在一个时间步上)。通道是查询,你需要选择的货品是键。就像通过查询和键来索引概率矩阵以获得“混合系数”(即注意力权重)来获取值一样,你可以通过分配给你的通道号(即查询)和通道中每个货品的数量(即键)来索引你所需要的货品。最后,你制作的饮料就是值。请注意,这个类比并不完全对应于自注意力子层中的计算。然而,你可以在抽象层面上发现这两个过程之间的显著相似之处。我们发现的相似之处如图 5.8 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.8 以烹饪比赛为背景描述的自注意力。选手是查询,货品是你需要选择的食材,值是你制作的最终饮料。

接下来我们将讨论什么是蒙版自注意力层。

5.2.6 蒙版自注意力层

正如你已经看到的,解码器有一个特殊的额外的自注意子层,称为蒙版自注意力。正如我们已经提到的,这个想法是防止模型通过关注不应关注的单词(也就是模型预测位置之前的单词)来“作弊”。为了更好地理解这一点,假设有两个人在教一个学生从英语翻译成法语。第一个人给出一个英语句子,要求学生逐词翻译,同时给出到目前为止已经翻译的反馈。第二个人给出一个英语句子,要求学生翻译,但提前提供完整的翻译。在第二种情况下,学生很容易作弊,提供高质量的翻译,虽然对语言几乎一无所知。现在让我们从机器学习的角度来理解关注不应关注的单词的潜在危险。

假设我们要将句子 “dogs are great” 翻译为 “les chiens sont super。” 当处理句子 “Dogs are great” 时,模型应该能够关注该句子中的任何单词,因为这是模型在任何给定时间完全可用的输入。但是,在处理句子 “Les chiens sont super” 时,我们需要注意向模型展示什么和不展示什么。例如,在训练模型时,我们通常一次性提供完整的输出序列,而不是逐字节地提供单词,以增强计算效率。在向解码器提供完整输出序列时,我们必须屏蔽当前正在处理的单词之前的所有单词,因为让模型在看到该单词之后的所有内容后预测单词 “chiens” 是不公平的。这是必须做的。如果不这样做,代码会正常运行。但最终,当你将其带到现实世界时,性能会非常差。强制执行这一点的方法是将概率矩阵 p 设为下三角矩阵。这将在注意力/输出计算期间基本上为混合输入的任何内容赋予零概率。标准自注意力和蒙版自注意力之间的差异如图 5.9 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.9 标准自注意力与蒙版自注意力方法。在标准注意力方法中,给定步骤可以看到来自当前时间步之前或之后的任何其他时间步的输入。然而,在蒙版自注意力方法中,当前时间步只能看到当前输入和之前的时间步。

让我们学习如何在 TensorFlow 中实现这一点。我们对 call() 函数进行了非常简单的更改,引入了一个新参数 mask,该参数表示模型不应该看到的项目,用 1 表示,其余项目用 0 表示。然后,对于模型不应该看到的元素,我们添加一个非常大的负数(即 - 10⁹),以便在应用 softmax 时它们变成零(见清单 5.2)。

清单 5.2 蒙版自注意力子层

import tensorflow as tf

class SelfAttentionLayer(layers.Layer):

    def __init__(self, d):
        ...

    def build(self, input_shape):
        ...

    def call(self, q_x, k_x, v_x, mask=None):   ❶
        q = tf.matmul(x,self.Wq)
        k = tf.matmul(x,self.Wk)
        v = tf.matmul(x,self.Wv)

        p = tf.matmul(q, k, transpose_b=True)/math.sqrt(self.d)
        p = tf.squeeze(p)
        if mask is None:
            p = tf.nn.softmax(p)else:
            p += mask * -1e9                    ❸
            p = tf.nn.softmax(p)                ❸

        h = tf.matmul(p, v)
        return h,p

❶ call 函数接受额外的蒙版参数(即 0 和 1 的矩阵)。

❷ 现在,SelfAttentionLayer 支持蒙版和非蒙版输入。

❸ 如果提供了蒙版,添加一个大的负值以使最终概率为零,以防止看到的单词。

创建蒙版很容易;您可以使用 tf.linalg.band_part() 函数创建三角矩阵

mask = 1 - tf.linalg.band_part(tf.ones((7, 7)), -1, 0)

给出

>>> tf.Tensor(
    [[0\. 1\. 1\. 1\. 1\. 1\. 1.]
     [0\. 0\. 1\. 1\. 1\. 1\. 1.]
     [0\. 0\. 0\. 1\. 1\. 1\. 1.]
     [0\. 0\. 0\. 0\. 1\. 1\. 1.]
     [0\. 0\. 0\. 0\. 0\. 1\. 1.]
     [0\. 0\. 0\. 0\. 0\. 0\. 1.]
     [0\. 0\. 0\. 0\. 0\. 0\. 0.]], shape=(7, 7), dtype=float32)

我们可以通过查看概率矩阵 p 来轻松验证屏蔽是否起作用。它必须是一个下三角矩阵

layer = SelfAttentionLayer(512)
h, p = layer(x, x, x, mask)
print(p.numpy())

给出

>>> [[1\.    0\.    0\.    0\.    0\.    0\.    0\.   ]
     [0.37  0.63  0\.    0\.    0\.    0\.    0\.   ]
     [0.051 0.764 0.185 0\.    0\.    0\.    0\.   ]
     [0.138 0.263 0.072 0.526 0\.    0\.    0\.   ]
     [0.298 0.099 0.201 0.11  0.293 0\.    0\.   ]
     [0.18  0.344 0.087 0.25  0.029 0.108 0\.   ]
     [0.044 0.044 0.125 0.284 0.351 0.106 0.045]]

现在,在计算值时,模型无法看到或关注到它在处理当前单词时尚未看到的单词。

5.2.7 多头注意力

原始 Transformer 论文中讨论了一种称为多头注意力的方法,它是自注意力层的扩展。一旦理解了自注意机制,这个想法就很简单。多头注意力创建多个并行的自注意力头。这样做的动机是,当模型有机会为输入序列学习多个注意力模式(即多组权重)时,它的性能更好。

记住,在单个注意力头中,所有的查询、键和值的维度都设置为 512。换句话说,

d[q] = d[k] = d[v] = 512

使用多头注意力,假设我们使用八个注意力头,

d[q] = d[k] = d[v] = 512/8 = 64

然后将所有注意力头的最终输出连接起来,形成最终输出,它的维度将为 64 × 8 = 512

H = Concat (h¹, h², … , h⁸)

其中h^i 是第i个注意力头的输出。使用刚刚实现的 SelfAttentionLayer,代码变为

multi_attn_head = [SelfAttentionLayer(64) for i in range(8)]
outputs = [head(x, x, x)[0] for head in multi_attn_head]
outputs = tf.concat(outputs, axis=-1)
print(outputs.shape)

得到

>>> (1, 7, 512)

如你所见,它仍然具有之前的相同形状(没有多个头)。然而,此输出是使用多个头进行计算的,这些头的维度比原始的自注意层要小。

5.2.8 全连接层

与我们刚刚学习的内容相比,全连接层更加简单。到目前为止,自注意力层产生了一个n×d[v]大小的输出(忽略批处理维度)。全连接层将输入数据进行以下转换

h[1] = ReLU(xW[1] + b[1])

这里,W[1]是一个d[v] × d[ff1]的矩阵,b[1]是一个d[ff1]大小的向量。因此,这个操作产生一个n×d[ff1]大小的张量。结果输出传递到另一层,进行以下计算

h[2] = h[1] W[2] + b [2]

这里W[2]是一个d[ff1] × d[ff2]大小的矩阵,b[2]是一个d[ff2]大小的向量。该操作得到一个大小为n×d[ff2]的张量。在 TensorFlow 中,我们可以将这些计算再次封装成一个可重用的 Keras 层(见下一个列表)。

列表 5.3 全连接子层

import tensorflow as tf

class FCLayer(layers.Layer):
    def __init__(self, d1, d2):
        super(FCLayer, self).__init__()
        self.d1 = d1                                                       ❶
        self.d2 = d2                                                       ❷

    def build(self, input_shape):
        self.W1 = self.add_weight(                                         ❸
            shape=(input_shape[-1], self.d1), initializer='glorot_uniform',❸
            trainable=True, dtype='float32')
        self.b1 = self.add_weight(                                         ❸
            shape=(self.d1,), initializer='glorot_uniform',                ❸
            trainable=True, dtype='float32')        
        self.W2 = self.add_weight(                                         ❸
            shape=(input_shape[-1], self.d2), initializer='glorot_uniform',❸
            trainable=True, dtype='float32')
        self.b2 = self.add_weight(                                         ❸
            shape=(self.d2,), initializer='glorot_uniform',                ❸
            trainable=True, dtype='float32')  

    def call(self, x):
        ff1 = tf.nn.relu(tf.matmul(x,self.W1)+self.b1)                     ❹
        ff2 = tf.matmul(ff1,self.W2)+self.b2                               ❺
        return ff2

❶ 第一个全连接计算的输出维度

❷ 第二个全连接计算的输出维度

❸ 分别定义 W1、b1、W2 和 b2。我们使用 glorot_uniform 作为初始化器。

❹ 计算第一个全连接计算

❺ 计算第二个全连接计算

在这里,你可以使用 tensorflow.keras.layers.Dense()层来实现此功能。然而,我们将使用原始的 TensorFlow 操作进行练习,以熟悉低级 TensorFlow。在这个设置中,我们将改变 FCLayer,如下面的列表所示。

列表 5.4 使用 Keras Dense 层实现的全连接层

import tensorflow as tf
import tensorflow.keras.layers as layers

class FCLayer(layers.Layer):

    def __init__(self, d1, d2):
        super(FCLayer, self).__init__()
        self.dense_layer_1 = layer.Dense(d1, activation='relu')  ❶
        self.dense_layer_2 = layers.Dense(d2)def call(self, x):
        ff1 = self.dense_layer_1(x)                              ❸
        ff2 = self.dense_layer_2(ff1)return ff2

❶ 在子类化层的 init 函数中定义第一个全连接层

❷ 定义第二个稠密层。注意我们没有指定激活函数。

❸ 调用第一个稠密层以获取输出

❹ 使用第一个稠密层的输出调用第二个稠密层以获取最终输出

现在你知道了 Transformer 架构中进行的计算以及如何使用 TensorFlow 实现它们。但请记住,原始 Transformer 论文中解释了各种细微的细节,我们还没有讨论。这些细节大多将在后面的章节中讨论。

练习 2

假设你被要求尝试一种新型的多头注意力机制。与其将较小头的输出(大小为 64)连接起来,而是将输出(大小为 512)相加。使用 SelfAttentionLayer 编写 TensorFlow 代码以实现此效果。您可以使用 tf.math.add_n() 函数按元素对张量列表求和。

将所有内容放在一起 5.2.9

让我们将所有这些元素放在一起创建一个 Transformer 网络。首先让我们创建一个编码器层,其中包含一组 SelfAttentionLayer 对象(每个头一个)和一个 FCLayer(请参阅下一个列表)。

列表 5.5 编码器层

import tensorflow as tf

class EncoderLayer(layers.Layer):

    def __init__(self, d, n_heads):
        super(EncoderLayer, self).__init__()
        self.d = d
        self.d_head = int(d/n_heads) 
        self.n_heads = n_heads
        self.attn_heads = [
            SelfAttentionLayer(self.d_head) for i in range(self.n_heads)
        ]                                           ❶
        self.fc_layer = FCLayer(2048, self.d)def call(self, x):
        def compute_multihead_output(x):            ❸
            outputs = [head(x, x, x)[0] for head in self.attn_heads] 
            outputs = tf.concat(outputs, axis=-1)
            return outputs

        h1 = compute_multihead_output(x)            ❹
        y = self.fc_layer(h1)return y

❶ 创建多个注意力头。每个注意力头具有 d/n_heads 大小的特征维度。

❷ 创建完全连接的层,其中中间层有 2,048 个节点,最终子层有 d 个节点。

❸ 创建一个函数,给定一个输入来计算多头注意力输出。

❹ 使用定义的函数计算多头注意力。

❺ 获取层的最终输出。

在初始化 EncoderLayer 时,EncoderLayer 接受两个参数:d(输出的维度)和 n_heads(注意力头的数量)。然后,在调用层时,传递一个单一的输入 x。首先计算注意力头(SelfAttentionLayer)的关注输出,然后是完全连接层(FCLayer)的输出。这就包装了编码器层的关键点。接下来,我们创建一个解码器层(请参阅下一个列表)。

列表 5.6 解码器层

import tensorflow as tf

class DecoderLayer(layers.Layer):

    def __init__(self, d, n_heads):
        super(DecoderLayer, self).__init__()
        self.d = d
        self.d_head = int(d/n_heads)
        self.dec_attn_heads = [
            SelfAttentionLayer(self.d_head) for i in range(n_heads)
        ]                                                           ❶
        self.attn_heads = [
            SelfAttentionLayer(self.d_head) for i in range(n_heads)
        ]                                                           ❷
        self.fc_layer = FCLayer(2048, self.d)def call(self, de_x, en_x, mask=None):
        def compute_multihead_output(de_x, en_x, mask=None):        ❹
            outputs = [
                head(en_x, en_x, de_x, mask)[0] for head in 
➥ self.attn_heads]                                                 ❺
            outputs = tf.concat(outputs, axis=-1)
            return outputs

        h1 = compute_multihead_output(de_x, de_x, mask)             ❻
        h2 = compute_multihead_output(h1, en_x)                     ❼
        y = self.fc_layer(h2)return y

❶ 创建处理解码器输入的注意力头。

❷ 创建同时处理编码器输出和解码器输入的注意力头。

❸ 最终完全连接的子层

❹ 计算多头注意力的函数。此函数接受三个输入(解码器的先前输出、编码器输出和可选的掩码)。

❺ 每个头将函数的第一个参数作为查询和键,并将函数的第二个参数作为值。

❻ 计算第一个受关注的输出。这仅查看解码器输入。

❼ 计算第二个受关注的输出。这将查看先前的解码器输出和编码器输出。

❽ 通过完全连接的子层将输出计算为层的最终输出。

解码器层与编码器层相比有几个不同之处。它包含两个多头注意力层(一个被屏蔽,一个未被屏蔽)和一个全连接层。首先计算第一个多头注意力层(被屏蔽)的输出。请记住,我们会屏蔽任何超出当前已处理的解码器输入的解码器输入。我们使用解码器输入来计算第一个注意力层的输出。然而,第二层中发生的计算有点棘手。做好准备!第二个注意力层将编码器网络的最后一个被关注的输出作为查询和键;然后,为了计算值,使用第一个注意力层的输出。将这一层看作是一个混合器,它混合了被关注的编码器输出和被关注的解码器输入。

有了这个,我们可以用两个编码器层和两个解码器层创建一个简单的 Transformer 模型)。我们将使用 Keras 函数式 API(见下一个列表)。

列表 5.7 完整的 Transformer 模型

import tensorflow as tf

n_steps = 25                                                              ❶
n_en_vocab = 300                                                          ❶
n_de_vocab = 400                                                          ❶
n_heads = 8                                                               ❶
d = 512                                                                   ❶
mask = 1 - tf.linalg.band_part(tf.ones((n_steps, n_steps)), -1, 0)        ❷

en_inp = layers.Input(shape=(n_steps,))                                   ❸
en_emb = layers.Embedding(n_en_vocab, 512, input_length=n_steps)(en_inp)  ❹
en_out1 = EncoderLayer(d, n_heads)(en_emb)                                ❺
en_out2 = EncoderLayer(d, n_heads)(en_out1)

de_inp = layers.Input(shape=(n_steps,))                                   ❻
de_emb = layers.Embedding(n_de_vocab, 512, input_length=n_steps)(de_inp)  ❼
de_out1 = DecoderLayer(d, n_heads)(de_emb, en_out2, mask)                 ❽
de_out2 = DecoderLayer(d, n_heads)(de_out1, en_out2, mask)
de_pred = layers.Dense(n_de_vocab, activation='softmax')(de_out2)         ❾

transformer = models.Model(
    inputs=[en_inp, de_inp], outputs=de_pred, name='MinTransformer')
transformer.compile(
    loss='categorical_crossentropy', optimizer='adam', metrics=['acc']
)

❶ Transformer 模型的超参数

❷ 用于屏蔽解码器输入的掩码

❸ 编码器的输入层。它接受一个批量的单词 ID 序列。

❹ 嵌入层将查找单词 ID 并返回该 ID 的嵌入向量。

❺ 计算第一个编码器层的输出。

❻ 解码器的输入层。它接受一个批量的单词 ID 序列。

❼ 解码器的嵌入层

❽ 计算第一个解码器层的输出。

❾ 预测正确输出序列的最终预测层

❿ 定义模型。注意我们为模型提供了一个名称。

在深入细节之前,让我们回顾一下 Transformer 架构的外观(图 5.10)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5.10 Transformer 模型架构

由于我们已经相当深入地探讨了底层元素,因此网络应该非常易于理解。我们所要做的就是设置编码器模型,设置解码器模型,并通过创建一个 Model 对象来适当地组合这些内容。最初我们定义了几个超参数。我们的模型接受长度为 n_steps 的句子。这意味着如果给定句子的长度小于 n_steps,则我们将填充一个特殊的标记使其长度为 n_steps。如果给定句子的长度大于 n_steps,则我们将截断句子至 n_steps 个词。n_steps 值越大,句子中保留的信息就越多,但模型消耗的内存也越多。接下来,我们有编码器输入的词汇表大小(即,馈送给编码器的数据集中唯一单词的数量)(n_en_vocab)、解码器输入的词汇表大小(n_de_vocab)、头数(n_heads)和输出维度(d)。

有了这个,我们定义了编码器输入层,它接受一个批次的 n_steps 长句子。在这些句子中,每个词都将由一个唯一的 ID 表示。例如,句子“The cat sat on the mat”将被转换为[1, 2, 3, 4, 1, 5]。接下来,我们有一个称为嵌入(Embedding)的特殊层,它为每个词提供了一个 d 元素长的表示(即,词向量)。在这个转换之后,您将得到一个大小为(批量大小,n_steps,d)的输出,这是应该进入自注意力层的输出格式。我们在第三章(第 3.4.3 节)中简要讨论了这种转换。嵌入层本质上是一个查找表。给定一个唯一的 ID(每个 ID 代表一个词),它会给出一个 d 元素长的向量。换句话说,这一层封装了一个大小为(词汇量大小,d)的大矩阵。当定义嵌入层时,您可以看到:

layers.Embedding(n_en_vocab, 512, input_length=n_steps)

我们需要提供词汇量大小(第一个参数)和输出维度(第二个参数),最后,由于我们正在处理长度为 n_steps 的输入序列,我们需要指定 input_length 参数。有了这个,我们就可以将嵌入层的输出(en_emb)传递给一个编码器层。您可以看到我们的模型中有两个编码器层。

下一步,转向解码器,从高层面看,一切都与编码器相同,除了两个不同之处:

  • 解码器层将编码器输出(en_out2)和解码器输入(de_emb 或 de_out1)作为输入。

  • 解码器层还有一个最终的稠密层,用于生成正确的输出序列(例如,在机器翻译任务中,这些将是每个时间步长的翻译词的概率)。

您现在可以定义和编译模型为

transformer = models.Model(
    inputs=[en_inp, de_inp], outputs=de_pred, name=’MinTransformer’
)
transformer.compile(
    loss='categorical_crossentropy', optimizer='adam', metrics=['acc']
)

请注意,在定义模型时,我们可以为其提供一个名称。我们将我们的模型命名为“MinTransformer”。作为最后一步,让我们看一下模型摘要,

transformer.summary()

这将提供以下输出:

Model: "MinTransformer"
_____________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                
=============================================================================================
input_1 (InputLayer)            [(None, 25)]         0                                       
_____________________________________________________________________________________________
embedding (Embedding)           (None, 25, 512)      153600      input_1[0][0]               
_____________________________________________________________________________________________
input_2 (InputLayer)            [(None, 25)]         0                                       
_____________________________________________________________________________________________
encoder_layer (EncoderLayer)    (None, 25, 512)      2886144     embedding[0][0]             
_____________________________________________________________________________________________
embedding_1 (Embedding)         (None, 25, 512)      204800      input_2[0][0]               
_____________________________________________________________________________________________
encoder_layer_1 (EncoderLayer)  (None, 25, 512)      2886144     encoder_layer[0][0]         
_____________________________________________________________________________________________
decoder_layer (DecoderLayer)    (None, 25, 512)      3672576     embedding_1[0][0]           
                                                                 encoder_layer_1[0][0]       
_____________________________________________________________________________________________
decoder_layer_1 (DecoderLayer)  (None, 25, 512)      3672576     decoder_layer[0][0]         
                                                                 encoder_layer_1[0][0]       
_____________________________________________________________________________________________
dense (Dense)                   (None, 25, 400)      205200      decoder_layer_1[0][0]       
=============================================================================================
Total params: 13,681,040
Trainable params: 13,681,040
Non-trainable params: 0
_____________________________________________________________________________________________

工作坊参与者将高高兴兴地离开这个工作坊。您已经介绍了 Transformer 网络的基本要点,同时教导参与者实现自己的网络。我们首先解释了 Transformer 具有编码器-解码器架构。然后,我们看了编码器和解码器的组成,它们由自注意力层和全连接层组成。自注意力层允许模型在处理给定输入词时关注其他输入词,这在处理自然语言时非常重要。我们还看到,在实践中,模型在单个注意力层中使用多个注意力头以提高性能。接下来,全连接层创建了所关注输出的非线性表示。在理解基本要素之后,我们使用我们为自注意力层(SelfAttentionLayer)和全连接层(FCLayer)创建的可重用自定义层实现了一个基本的小规模 Transformer 网络。

下一步是在 NLP 数据集上训练这个模型(例如机器翻译)。然而,训练这些模型是一个单独章节的主题。 Transformers 比我们讨论的还要复杂得多。例如,有预训练的基于 Transformer 的模型,你可以随时使用它们来解决 NLP 任务。我们将在后面的章节再次讨论 Transformers。

总结

  • Transformer 网络在几乎所有 NLP 任务中都表现优于其他模型。

  • Transformer 是一种主要用于学习 NLP 任务的编码器 - 解码器型神经网络。

  • 使用 Transformer,编码器和解码器由两个计算子层组成:自我注意层和完全连接层。

  • 自我注意层根据处理当前位置时,与序列中其他位置之间的相对重要性产生一个给定时间步长的输入的加权和。

  • 完全连接层对自我注意层产生的注意输出进行了非线性表示。

  • 解码器在其自我注意层中使用掩码,以确保在产生当前预测时,解码器不会看到任何未来的预测。

练习答案

练习 1

Wq = tf.Variable(np.random.normal(size=(256,512)))
Wk = tf.Variable (np.random.normal(size=(256,512)))
Wv = tf.Variable (np.random.normal(size=(256,512)))

练习 2

multi_attn_head = [SelfAttentionLayer(512) for i in range(8)]
outputs = [head(x)[0] for head in multi_attn_head]
outputs = tf.math.add_n(outputs)

第二部分:瞧,无需双手!深度网络在现实世界中

一个精通机器学习的从业者是一个多面手。他们不仅需要对现代深度学习框架如 TensorFlow 有很好的理解,还需要能够熟练运用其提供的复杂 API 来实现复杂的深度学习模型,以解决计算机视觉和自然语言处理等领域常见的一些机器学习问题。

在第二部分,我们将看一下计算机视觉和自然语言处理中的真实世界问题。首先,我们来看图像分类和图像分割,这是两个流行的计算机视觉任务。对于这些任务,我们分析了在给定问题上表现良好的现代复杂深度学习模型。我们不仅会从头开始实现这些模型,还会理解核心设计决策背后的推理和它们带来的优势。

接下来,我们转向自然语言处理。我们首先看一下情感分析任务以及深度学习如何解决它。我们还探讨解决方案的各个角落,例如基本的 NLP 预处理步骤以及使用词向量来提升性能。然后,我们看一下语言建模:这是一个预训练任务,为现代 NLP 模型带来了巨大的语言理解能力。在这次讨论中,我们再次探讨了语言建模中融入的各种技术,以提高预测质量。

第六章:教机器看图像分类和 CNN

本章涵盖内容

  • 在 Python 中对图像数据进行探索性数据分析

  • 预处理和通过图像流水线提供数据

  • 使用 Keras 功能 API 实现复杂的 CNN 模型

  • 训练和评估 CNN 模型

我们已经对 CNN 做了相当多的工作。CNN 是一种可以处理二维数据(如图像)的网络类型。CNN 使用卷积操作通过在图像上移动一个核(即一个小的值网格)来创建图像(即像素的网格)的特征图,从而产生新的值。CNN 具有多个这样的层,随着它们的深入,它们生成越来越高级的特征图。您还可以在卷积层之间使用最大或平均汇聚层来减少特征图的维数。汇聚层也会在特征图上移动核以创建输入的较小表示。最终的特征图连接到一系列完全连接的层,其中最后一层产生预测结果(例如,图像属于某个类别的概率)。

我们使用 Keras Sequential API 实现了 CNN。我们使用了各种 Keras 层,如 Conv2D、MaxPool2D 和 Dense,以便轻松地实现 CNN。我们已经学习了与 Conv2D 和 MaxPool2D 层相关的各种参数,如窗口大小、步幅和填充方式。

在本章中,我们将更接近地看到卷积神经网络(CNN)在解决令人兴奋的问题时在真实世界数据上的表现。机器学习不仅仅是实现一个简单的 CNN 来学习高度策划的数据集,因为真实世界的数据往往是杂乱无序的。您将学习到探索性数据分析,这是机器学习生命周期的核心。您将探索一个图像数据集,目标是识别图像中的对象(称为图像分类)。然后,我们将深入研究计算机视觉领域的一个最先进的模型,即 inception 模型。在深度学习中,广泛认可的神经网络架构(或模板)在特定任务上表现良好。inception 模型是一种在图像数据上表现出色的模型之一。我们将研究模型的架构以及其中使用的几个新颖设计概念的动机。最后,我们将训练在我们探索过的数据集上的模型,并依靠准确性等指标分析模型的性能。

我们走了很长一段路。我们理解了那里存在的主要深度学习算法的技术方面,并且对我们正确执行探索性数据分析的能力充满信心,因此以信心进入模型阶段。然而,深度网络很快就会变得非常庞大。复杂的网络会牵扯到各种计算和性能问题。因此,任何希望在实际问题中使用这些算法的人都需要学习那些在复杂学习任务中已被证明执行良好的现有模型。

6.1 将数据置于显微镜下:探索性数据分析

你正在与一组数据科学家合作构建一个多才多艺的图像分类模型。最终目标是将此模型用作智能购物助手的一部分。用户可以上传家里内部的照片,助手将根据他们的风格找到合适的产品。团队决定从图像分类模型开始。你需要回到团队,拿到一个很棒的数据集并解释数据的样子以及为什么这个数据集很棒。数据集包含在现实世界中拍摄的日常物品,你将进行探索性数据分析并查看数据集的各种属性(例如,可用类别,数据集大小,图像属性)来了解数据,并识别和解决潜在问题。

探索性数据分析(EDA)是数据科学项目中你将要做的技术发展的基石。该过程的主要目标是通过消除离群值和噪音等烦人问题,最终获得高质量干净的数据集。为了拥有这样的数据集,你需要仔细审查数据,并找出是否存在

  • 类别不平衡(在分类问题中)

  • 损坏的数据

  • 缺失的特征

  • 离群值

  • 需要各种转换的特征(例如,标准化,独热编码)

这绝不是一份详尽的需要注意的事项清单。你进行的探索越多,数据质量就会越好。

在进行探索性数据分析之前发生了什么?

机器学习问题总是源于业务问题。一旦问题得到适当的确认和理解,你可以开始考虑数据:我们有什么数据?我们训练模型来预测什么?这些预测如何转化为为公司带来好处的可操作见解?在勾选这些问题之后,你可以通过探索性数据分析来检索并开始处理数据。毕竟,机器学习项目中的每一步都需要有目的性地完成。

你已经花了几天时间研究,找到了一个适合你问题的数据集。为了开发一个能够理解客户风格偏好的智能购物助手,它应该能够从客户上传的照片中识别尽可能多的家居物品。为此,你计划使用 tiny-imagenet-200 (www.kaggle.com/c/tiny-imagenet)数据集。

ImageNet 数据集

Tiny ImageNet 是原始 ImageNet 数据集(www.kaggle.com/competitions/imagenet-object-localization-challenge)的一个规模较小的重制版,它是年度 ImageNet 大规模视觉识别挑战(ILSVRC)的一部分。每年,全球各地的研究团队竞争开发最先进的图像分类和检测模型。这个数据集拥有大约 1.2 百万张标记的图像,分布在 1,000 个类别中,已成为计算机视觉领域最大的标记图像数据集之一。

这个数据集包含属于 200 个不同类别的图像。图 6.1 展示了一些可用类别的图像。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.1 tiny-imagenet-200 的一些样本图像。你可以看到这些图像属于各种不同的类别。

首先,我们需要下载数据集。下面的代码将在你的工作目录中创建一个名为 data 的文件夹,下载包含数据的 zip 文件,并为你解压缩。最终,你应该在 data 文件夹中有一个名为 tiny-imagenet-200 的文件夹:

import os
import requests
import zipfile
if not os.path.exists(os.path.join('data','tiny-imagenet-200.zip')):
    url = "http:/ /cs231n.stanford.edu/tiny-imagenet-200.zip"
    r = requests.get(url)

    if not os.path.exists('data'):
        os.mkdir('data')

    with open(os.path.join('data','tiny-imagenet-200.zip'), 'wb') as f:
        f.write(r.content)

    with zipfile.ZipFile(
        os.path.join('data','tiny-imagenet-200.zip'), 'r'
    ) as zip_ref:
        zip_ref.extractall('data')
else:
    print("The file already exists.")

6.1.1 文件夹/文件结构

数据现在应该在 Ch06/data 文件夹中可用了。现在是时候探索数据集了。我们将首先手动浏览提供给我们的文件夹中的数据。你会注意到有三个文件夹和两个文件(图 6.2)。四处看看并探索一下。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.2 tiny-imagenet-200 数据集中找到的文件夹和文件

文件 wnids.txt 包含一组 200 个 ID(称为wnids或 WordNet IDs,基于词汇数据库 WordNet [wordnet.princeton.edu/]; 图 6.3)。每个 ID 代表一个图像类别(例如,金鱼类)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.3 来自 wnids.txt 的示例内容。每行包含一个 wnid(WordNet ID)。

文件 words.txt 以制表符分隔值(TSV)格式提供了对这些 ID 的人性化描述(表 6.1)。请注意,这个文件包含超过 82,000 行(远超过我们的 200 个类别)并来自一个更大的数据集。

表 6.1 来自 words.txt 的示例内容。其中包含数据集中的 wnids 以及它们的描述。

n00001740entity
n00001930physical entity
n00002137抽象,抽象实体
n00002452东西
n00002684物体,实物
n00003553整体,单位
n00003993同种异体
n00004258生物,有机物
n00004475有机体,存在
n00005787底栖生物
n00005930矮人
n00006024异养生物
n00006150父母
n00006269生命
n00006400生物体

训练文件夹包含训练数据。它包含一个名为 images 的子文件夹,在其中,您可以找到 200 个文件夹,每个都有一个标签(即 wnid)。在每个这些子文件夹中,您将找到代表该类别的一系列图像。每个以其名称作为 wnid 的子文件夹包含每类 500 张图像,总共有 100,000 张(在所有子文件夹中)。图 6.4 描述了这种结构,以及训练文件夹中找到的一些数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.4 tiny-imagenet-200 数据集的总体结构。它有三个文本文件(wnids.txt、words.txt 和 val/val_annotations.txt)和三个文件夹(train、val 和 test)。我们只使用 train 和 val 文件夹。

val 文件夹包含一个名为 images 的子文件夹和一组图像(这些图像不像在 train 文件夹中那样被进一步分成子文件夹)。这些图像的标签(或 wnids)可以在 val 文件夹中的 val_annotations.txt 文件中找到。

最后一个文件夹称为测试文件夹,在本章中我们将忽略它。该数据集是竞赛的一部分,数据用于评分提交的模型。我们没有这个测试集的标签。

6.1.2 理解数据集中的类别

我们已经了解了我们拥有的数据的类型以及其可用性。接下来,让我们识别一些数据中的类别。为此,我们将定义一个名为 get_tiny_imagenet_classes() 的函数,该函数读取 wnids.txt 和 words.txt 文件,并创建一个包含两列的 pd.DataFrame(即 pandas DataFrame):wnid 及其相应的类别描述(见下一个列表)。

列表 6.1 获取数据集中类别的类别描述

import pandas as pd                                                      ❶
import os                                                                ❶

data_dir = os.path.join('data', 'tiny-imagenet-200')                     ❷
wnids_path = os.path.join(data_dir, 'wnids.txt')                         ❷
words_path = os.path.join(data_dir, 'words.txt')def get_tiny_imagenet_classes(wnids_path, words_path):                   ❸
    wnids = pd.read_csv(wnids_path, header=None, squeeze=True)           ❹
    words = pd.read_csv(words_path, sep='\t', index_col=0, header=None)  ❹
    words_200 = words.loc[wnids].rename({1:'class'}, axis=1)             ❺
    words_200.index.name = 'wnid'return words_200.reset_index()                                       ❼

labels = get_tiny_imagenet_classes(wnids_path, words_path)               ❽
labels.head(n=25)

❶ 导入 pandas 和 os 包

❷ 定义数据目录、wnids.txt 和 words.txt 文件的路径

❸ 定义一个函数来读取 tiny_imagenet 类别的类别描述

❹ 使用 pandas 读取 wnids.txt 和 words.txt 作为 CSV 文件

❺ 仅获取 tiny-imagenet-200 数据集中存在的类别

❻ 将数据框的索引名称设置为“wnid”

❼ 重置索引,使其成为数据框中的一列(该列的列名为“wnid”)

❽ 执行函数以获取类别描述

❾ 检查数据框的头部(前 25 个条目)

此函数首先读取包含 wnids 列表的 wnids.txt 文件,该列表对应于数据集中可用的类别,作为 pd.Series(即 pandas series)对象。 接下来,它将 words.txt 文件读取为 pd.DataFrame(即 pandas DataFrame),其中包含 wnid 到类别描述的映射,并将其分配给 words。 然后,它选择在 wnids pandas 系列中存在 wnid 的项目。 这将返回一个包含 200 行的 pd.DataFrame(表 6.2)。 请记住,words.txt 中的项数远远大于实际数据集,因此我们只需要选择与我们相关的项。

表 6.2 使用 get_tiny_imagenet_classes()函数生成的标签 ID 及其描述的示例

课程
0n02124075埃及猫
1n04067472卷轴
2n04540053排球
3n04099969摇椅,摇椅
4n07749582柠檬
5n01641577牛蛙,美洲牛蛙
6n02802426篮球
7n09246464悬崖,跌落,坠落
8n07920052浓缩咖啡
9n03970156吸盘,管道工的助手
10n03891332停车计时器
11n02106662德国牧羊犬,德国牧羊犬,德国牧羊犬…
12n03201208餐桌,板
13n02279972帝王蝴蝶,帝王蝴蝶,小米蝴蝶
14n02132136棕熊,棕熊,北极熊
15n041146614校车

然后我们将计算每个类别的数据点(即图像)的数量:

def get_image_count(data_dir):    
    # Get the count of JPEG files in a given folder (data_dir)
    return len(
        [f for f in os.listdir(data_dir) if f.lower().endswith('jpeg')]
    )

   # Apply the function above to all the subdirectories in the train folder 
labels["n_train"] = labels["wnid"].apply(
    lambda x: get_image_count(os.path.join(data_dir, 'train', x, 'images'))
)
# Get the top 10 entries in the labels dataframe
labels.head(n=10)

此代码创建一个名为 n_train 的新列,显示每个 wnid 找到了多少个数据点(即图像)。 这可以通过 pandas pd.Series .apply()函数来实现,该函数将 get_image_count()应用于系列 labels[“wnid”]中的每个项目。 具体来说,get_image_count()接受一个路径并返回该文件夹中找到的 JPEG 文件的数量。 当您将此 get_image_count()函数与 pd.Series.apply()结合使用时,它会进入 train 文件夹中的每个文件夹,并计算图像的数量。 一旦运行了标签.head(n=10)行,您应该会得到表 6.3 中显示的结果。

表 6.3 计算了 n_train(训练样本数)的数据示例

课程n_train
0n02124075埃及猫500
1n04067472卷轴500
2n04540053排球500
3n04099969摇椅,摇椅500
4n07749582柠檬500
5n01641577牛蛙,美洲牛蛙500
6n02802426篮球500
7n09246464悬崖,跌落,坠落500
8n07920052浓缩咖啡500
9n03970156吸盘,管道工的助手500

让我们快速验证结果是否正确。 进入 train 文件夹中的 n02802426 子目录,其中应该包含篮球的图像。 图 6.5 显示了几个示例图像。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.5 wnid 类别 n02802426(即篮球)的样本图像

你可能会发现这些图像与你预期的截然不同。你可能期望看到清晰放大的篮球图像。但在现实世界中,永远不会出现这种情况。真实数据集是有噪声的。你可以看到以下图像:

  • 篮球几乎看不见(左上角)。

  • 篮球是绿色的(左下角)。

  • 篮球在婴儿旁边(即上下文无关)(中间上方)。

这会让你更加欣赏深度网络,因为这对一堆堆叠的矩阵乘法(即深度网络)是一个困难的问题。需要精确的场景理解才能成功解决此任务。尽管困难,但奖励很大。我们开发的模型最终将用于识别各种背景和上下文中的物体,例如客厅、厨房和室外。这正是这个数据集为模型训练的目的:在各种情境中理解/检测物体。你可能可以想象为什么现代 CAPTCHA 越来越聪明,并且可以跟上能够更准确地分类对象的算法。对于受过适当训练的 CNN 来说,识别具有混乱背景或小遮挡的 CAPTCHA 并不困难。

你还可以快速检查我们生成的 n_train 列的摘要统计数据(例如,平均值、标准差等)。这提供了比查看所有 200 行更容易消化的列的摘要。这是使用 pandas 描述() 函数完成的:

labels["n_train"].describe()

执行此操作将返回以下系列:

count    200.0
mean     500.0
std        0.0
min      500.0
25%      500.0
50%      500.0
75%      500.0
max      500.0
Name: n_train, dtype: float64

你可以看到它返回了列的重要统计信息,如平均值、标准差、最小值和最大值。每个类别都有 500 张图像,这意味着数据集完美地平衡了类别。这是验证我们有一个类平衡数据集的有用方法。

6.1.3 计算数据集上的简单统计量

分析数据的各种属性也是一个重要步骤。根据你处理的数据类型,分析类型会发生变化。在这里,我们将找出图像的平均大小(甚至是 25/50/75 百分位数)。

在实际模型中准备好这些信息可以节省很多时间,因为你必须了解图像大小(高度和宽度)的基本统计信息,以裁剪或填充图像到固定大小,因为图像分类 CNN 只能处理固定大小的图像(见下一个列表)。

列表 6.2 计算图像宽度和高度统计数据

import os                                                                 ❶
from PIL import Image                                                     ❶
import pandas as pd                                                       ❶

image_sizes = []for wnid in labels["wnid"].iloc[:25]:                                     ❸
    img_dir = os.path.join(
        'data', 'tiny-imagenet-200', 'train', wnid, 'images'
    )for f in os.listdir(img_dir):if f.endswith('JPEG'):                                            ❺
            image_sizes.append(Image.open(os.path.join(img_dir, f)).size) ❻

img_df = pd.DataFrame.from_records(image_sizes)                           ❼
img_df.columns = ["width", "height"]                                      ❽
img_df.describe()

❶ 导入 os、PIL 和 pandas 包

❷ 定义一个列表来保存图像大小

❸ 在数据集中循环前 25 类

❹ 在循环中为特定类别定义图像目录

❺ 在该目录中循环所有具有扩展名 JPEG 的图像

❻ 将每个图像的大小(即 (宽度、高度) 元组)添加到 image_sizes 中

❼ 从 image_sizes 中的元组创建数据框架

❽ 适当设置列名

❾ 获取我们获取的图像的宽度和高度的摘要统计信息

在这里,我们从之前创建的标签 DataFrame 中获取前 25 个 wnid(处理所有 wnid 会花费太多时间)。然后,对于每个 wnid,我们进入包含属于它的数据的子文件夹,并使用以下方法获取每个图像的宽度和高度信息

Image.open(os.path.join(img_dir, f)).size 

使用Image.open(<path>).size函数返回给定图像的元组(宽度,高度)。我们将遇到的所有图像的宽度和高度记录在image_sizes列表中。最后,image_sizes列表如下所示:

image_sizes = [(image_1.width, image_1.height), (image_2.width, image_2.height), ..., (image_n.width, image_n.height)]

对于这种格式的数据,我们可以使用pd.DataFrame.from_records()函数将此列表创建为pd.DataFrameimage_sizes中的单个元素是一条记录。例如,(image_1.width, image_1.height)是一条记录。因此,image_sizes是一组记录的列表。当您从记录列表创建pd.DataFrame时,每条记录都变为pandas DataFrame中的一行,其中每条记录中的每个元素都变为列。例如,由于每条记录中都有图像宽度和图像高度作为元素,因此宽度和高度成为pandas DataFrame中的列。最后,我们执行img_df.describe()以获取我们读取的图像的宽度和高度的基本统计信息(表 6.4)。

表 6.4 图像的宽度和高度统计信息

宽度高度
count12500.012500.0
mean64.064.0
std0.00.0
min64.064.0
25%64.064.0
50%64.064.0
75%64.064.0
max64.064.0

接下来,我们将讨论如何创建数据管道来摄取我们刚刚讨论的图像数据。

练习 1

假设在浏览数据集时,您遇到了一些损坏的图像(即,它们具有负值像素)。假设您已经有了一个名为dfpd.DataFrame(),其中包含一个带有图像文件路径的单列(称为filepath),请使用pandas apply()函数读取每个图像的最小值,并将其分配给名为minimum的列。要读取图像,您可以假设已完成from PIL import Imageimport numpy as np,您还可以使用np.array(<Image>)PIL.Image转换为数组。

6.2 使用 Keras ImageDataGenerator 创建数据管道

您已经很好地探索了数据集,并了解了诸如有多少类别、存在什么样的对象以及图像的大小等信息。现在,您将为三个不同的数据集创建三个数据生成器:训练、验证和测试。这些数据生成器以批量从磁盘中检索数据,并执行任何所需的预处理。这样,数据就可以被模型轻松消耗。为此,我们将使用方便的tensorflow.keras.preprocessing.image.ImageDataGenerator

我们将从定义一个 Keras ImageDataGenerator()开始,以在构建模型时提供数据:

from tensorflow.keras.preprocessing.image import ImageDataGenerator
import os

random_seed = 4321
batch_size = 128
image_gen = ImageDataGenerator(samplewise_center=True, validation_split=0.1)

设置 samplewise_center=True,生成的图像将具有归一化的值。每个图像将通过减去该图像的平均像素值来居中。validation_split 参数在训练数据中扮演着重要的角色。这让我们将训练数据分成两个子集,训练集和验证集,通过从训练数据中分离出一部分(在本例中为 10%)。在机器学习问题中,通常应该有三个数据集:

  • 训练数据—通常是最大的数据集。我们用它来训练模型。

  • 验证数据—保留数据集。它不用于训练模型,而是用于在训练过程中监视模型的性能。请注意,此验证集在训练过程中必须保持固定(不应更改)。

  • 测试数据—保留数据集。与验证数据集不同,这仅在模型训练完成后使用。这表示模型在未见的真实世界数据上的表现。这是因为模型在测试时间之前没有以任何方式与测试数据集交互(与训练和验证数据集不同)。

我们还将为稍后的数据生成定义一个随机种子和批量大小。

创建一个 ImageDataGenerator 后,您可以使用其中的一个 flow 函数来读取来自异构源的数据。例如,Keras 目前提供了以下方法:

  • flow()—从 NumPy 数组或 pandas DataFrame 中读取数据

  • flow_from_dataframe()—从包含文件名和它们关联标签的文件中读取数据

  • flow_from_directory()—从文件夹中读取数据,该文件夹中的图像根据它们所属的类别组织到子文件夹中。

首先,我们将查看 flow_from_directory(),因为我们的训练目录以 flow_from_directory()函数期望数据的确切格式存储。具体来说,flow_from_directory()期望数据的格式如图 6.6 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.6 流从目录方法所预期的文件夹结构

流方法返回数据生成器,这些生成器是 Python 生成器。生成器本质上是一个返回迭代器(称为generator-iterator)的函数。但为了保持我们的讨论简单,我们将生成器和迭代器都称为生成器。您可以像处理列表一样迭代生成器,并以顺序方式返回项目。这里是一个生成器的例子:

def simple_generator():
    for i in range(0, 100):
        yield (i, i*2)

请注意使用关键字 yield,您可以将其视为 return 关键字。但是,与 return 不同,yield 不会在执行该行后立即退出函数。现在您可以将迭代器定义为

iterator = simple_generator()

您可以将迭代器视为包含[(0, 0), (1, 2), (2, 4), …,(98, 196), (99, 198)]的列表。然而,在幕后,生成器比列表对象更节省内存。在我们的情况下,数据生成器将在单次迭代中返回一批图像和目标(即,图像和标签的元组)。您可以直接将这些生成器提供给像tf.keras.models.Model.fit()这样的方法,以训练模型。flow_from_directory()方法用于检索数据:

target_size = (56,56)

train_gen = image_gen.flow_from_directory(
    directory=os.path.join('data','tiny-imagenet-200', 'train'), 
    target_size=target_size, classes=None,
    class_mode='categorical', batch_size=batch_size, 
    shuffle=True, seed=random_seed, subset='training'
)
valid_gen = image_gen.flow_from_directory (
    directory=os.path.join('data','tiny-imagenet-200', 'train'), 
    target_size=target_size, classes=None,
    class_mode='categorical', batch_size=batch_size, 
    shuffle=True, seed=random_seed, subset='validation'
)

您可以看到已为这些函数设置了许多参数。需要注意的最重要的参数是subset参数,对于train_gen设置为“training”,对于valid_gen设置为“validation”。其他参数如下:

  • 目录(string)—父目录的位置,在这里数据进一步分成表示类别的子文件夹。

  • 目标大小(int 元组)—图像的目标大小,表示为(高度,宽度)的元组。图像将被调整为指定的高度和宽度。

  • 类别模式(string)—我们将要提供给模型的目标类型。因为我们希望目标是表示每个类别的独热编码向量,所以我们将其设置为’categorical’。可用类型包括“categorical”(默认值)、“binary”(对于只有两类(0 或 1)的数据集)、“sparse”(数值标签而不是独热编码向量)、“input”或 None(没有标签)、以及“raw”或“multi_output”(仅在特殊情况下可用)。

  • 批量大小(int)—单个数据批次的大小。

  • 是否在获取时对数据进行洗牌(bool)—是否在获取时对数据进行洗牌。

  • 随机种子(int)—数据洗牌的随机种子,因此我们每次运行时都能获得一致的结果。

  • 子集(string)—如果validation_split > 0,则需要哪个子集。这需要设置为“training”或“validation”之一。

请注意,即使我们有 64 × 64 的图像,我们也将它们调整为 56 × 56。这是因为我们将使用的模型设计用于 224 × 224 的图像。具有 224 × 224 尺寸的图像使得将模型适应我们的数据变得更加容易。

我们可以让我们的解决方案变得更加闪亮!您可以看到,在train_genvalid_gen之间,使用的参数有很多重复。实际上,除了subset之外,所有参数都相同。这种重复会使代码变得凌乱,并为错误留下余地(如果需要更改参数,则可能会设置一个而忘记另一个)。您可以在 Python 中使用偏函数来创建具有重复参数的偏函数,然后使用它来创建train_genvalid_gen

from functools import partial
target_size = (56,56)

partial_flow_func = partial(
        image_gen.flow_from_directory, 
        directory=os.path.join('data','tiny-imagenet-200', 'train'), 
        target_size=target_size, classes=None,
        class_mode='categorical', batch_size=batch_size, 
        shuffle=True, seed=random_seed)

train_gen = partial_flow_func(subset='training')
valid_gen = partial_flow_func(subset='validation')

这里,我们首先创建一个partial_flow_function(一个 Python 函数),它实质上是flow_from_directory函数,有一些参数已经填充。然后,为了创建train_genvalid_gen,我们只传递了subset参数。这样可以使代码更加清晰。

验证数据检查:不要期望框架为您处理事务

现在我们有了一个训练数据生成器和一个验证数据生成器,我们不应该盲目地承诺使用它们。我们必须确保我们从训练数据随机采样的验证数据在每次遍历训练数据集时保持一致。这似乎是一个应该由框架本身处理的微不足道的事情,但最好不要认为这是理所当然的。如果你这样做

如果不执行此检查,最终你会付出代价,因此最好确保我们在不同试验中获得一致的结果。

为此,你可以对验证数据生成器的输出进行多次迭代,进行固定次数的迭代,并确保每次试验中都获得相同的标签序列。此代码在笔记本中可用(在“验证验证数据的一致性”部分下)。

我们还没有完成。我们需要对 flow_from_directory() 函数返回的生成器进行轻微修改。如果你查看数据生成器中的项,你会看到它是一个元组(x,y),其中 x 是一批图像,y 是一批 one-hot 编码的目标。我们在这里使用的模型有一个最终预测层和两个额外的辅助预测层。总共,该模型有三个输出层,因此我们需要返回(x,(y,y,y))而不是一个元组(x,y),通过三次复制 y。我们可以通过定义一个新的生成器 data_gen_aux() 来修复这个问题,该生成器接受现有的生成器并修改其输出,如所示。这需要对训练数据生成器和验证数据生成器都进行修复:

def data_gen_aux(gen):
    for x,y in gen:
        yield x,(y,y,y)

train_gen_aux = data_gen_aux(train_gen)
valid_gen_aux = data_gen_aux(valid_gen)

是时候为测试数据创建一个数据生成器了。回想一下,我们说过我们正在使用的测试数据(即 val 目录)的结构与训练和 tran_val 数据文件夹不同。因此,它需要特殊处理。类标签存储在一个名为 val_annotations.txt 的文件中,并且图像放置在一个具有扁平结构的单个文件夹中。不用担心;Keras 也为这种情况提供了一个函数。在这种情况下,我们将首先使用 get_test_labels_df() 函数将 val_annotations.txt 读取为一个 pd.DataFrame。该函数简单地读取 val_annotations.txt 文件,并创建一个具有两列的 pd.DataFrame,即图像的文件名和类标签:

def get_test_labels_df(test_labels_path):
    test_df = pd.read_csv(test_labels_path, sep='\t', index_col=None, header=None)
    test_df = test_df.iloc[:,[0,1]].rename({0:"filename", 1:"class"}, axis=1)
    return test_df

test_df = get_test_labels_df(os.path.join('data','tiny-imagenet-200',  'val', 'val_annotations.txt'))

接下来,我们将使用 flow_from_dataframe() 函数创建我们的测试数据生成器。你只需要传递我们之前创建的 test_df(作为 dataframe 参数)和指向图像所在目录的目录参数。请注意,我们为测试数据设置了 shuffle=False,因为我们希望以相同的顺序输入测试数据,以便我们监视的性能指标将保持不变,除非我们更改模型:

    test_gen = image_gen.flow_from_dataframe(
        dataframe=test_df, directory=os.path.join('data','tiny-imagenet-200',  'val', 'images'), target_size=target_size, 
➥ class_mode='categorical', batch_size=batch_size, shuffle=False
    )

接下来,我们将使用 Keras 定义一个复杂的计算机视觉模型,并最终在我们准备好的数据上对其进行训练。

练习 2

作为测试过程的一部分,假设你想要查看模型对训练数据中损坏标签的鲁棒性如何。为此,你计划创建一个生成器,以 50% 的概率将标签设置为 0。你将如何修改以下生成器以实现此目的?你可以使用 np.random.normal() 从具有零均值和单位方差的正态分布中随机抽取一个值:

def data_gen_corrupt(gen):
    for x,y in gen:
        yield x,(y,y,y)

6.3 Inception net:实现最先进的图像分类器

你已经分析了数据集,并对数据的外观有了全面的了解。对于图像,你无疑会转向卷积神经网络(CNNs),因为它们是业内最好的。现在是构建一个模型来学习客户个人喜好的时候了。在这里,我们将使用 Keras functional API 复制一个最先进的 CNN 模型(称为 Inception net)。

Inception 网络是一个复杂的 CNN,以其提供的最先进性能而著称。Inception 网络的名字来源于流行的互联网梗“我们需要更深入”,该梗以电影 Inception 中的莱昂纳多·迪卡普里奥为特色。

Inception 模型在短时间内推出了六个不同版本(大约在 2015-2016 年之间)。这证明了该模型在计算机视觉研究人员中有多受欢迎。为了纪念过去,我们将实现首个推出的 Inception 模型(即 Inception 网络 v1),并随后将其与其他模型进行比较。由于这是一个高级 CNN,对其架构和一些设计决策的深入了解至关重要。让我们来看看 Inception 模型,它与典型 CNN 有何不同,最重要的是,它为什么不同。

Inception 模型(或 Inception 网络)不是典型的 CNN。它的主要特点是复杂性,因为模型越复杂(即参数越多),准确率就越高。例如,Inception 网络 v1 几乎有 20 层。但是当涉及到复杂模型时,会出现两个主要问题:

  • 如果你没有足够大的数据集用于一个复杂模型,那么很可能模型会对训练数据过拟合,导致在真实世界数据上的整体性能不佳。

  • 复杂的模型导致更多的训练时间和更多的工程努力来将这些模型适配到相对较小的 GPU 内存中。

这要求以更加务实的方式来解决这个问题,比如回答“我们如何在深度模型中引入稀疏性,以减少过拟合风险以及对内存的需求?”这是 Inception 网络模型中回答的主要问题。

什么是过拟合?

过拟合是机器学习中的一个重要概念,而且常常难以避免。过拟合是指模型学习很好地表示训练数据(即高训练精度),但在未见过的数据上表现不佳(即低测试精度)的现象。当模型试图记住训练样本而不是从数据中学习可泛化的特征时,就会发生这种情况。这在深度网络中很普遍,因为它们通常比数据量更多的参数。过拟合将在下一章中更详细地讨论。

让我们再次回顾 CNN 的基础知识。

6.3.1 CNN 回顾

CNN 主要用于处理图像和解决计算机视觉问题(例如图像分类、目标检测等)。如图 6.7 所示,CNN 有三个组成部分:

  • 卷积层

  • 池化层全连接层

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.7 一个简单的卷积神经网络。首先,我们有一个具有高度、宽度和通道维度的图像,然后是一个卷积和一个池化层。最后,最后一个卷积/池化层的输出被展平并馈送到一组全连接层。

卷积操作将一个固定大小的小核(也称为过滤器)沿输入的宽度和高度维度移动。在这样做时,它在每个位置产生一个单一值。卷积操作使用具有一定宽度、高度和若干通道的输入,并产生具有一定宽度、高度和单一通道的输出。为了产生多通道输出,卷积层堆叠许多这些过滤器,导致与过滤器数量相同数量的输出。卷积层具有以下重要参数:

  • 过滤器数量 — 决定卷积层产生的输出的通道深度(或特征图的数量)

  • 核大小 — 也称为感受野,它决定了过滤器的大小(即高度和宽度)。核大小越大,模型在一次观察中看到的图像部分就越多。但更大的过滤器会导致更长的训练时间和更大的内存需求。

  • 步长 — 决定在卷积图像时跳过多少像素。更高的步长导致较小的输出大小(步长通常仅用于高度和宽度维度)。

  • 填充 — 通过添加零值的虚拟边界来防止卷积操作期间自动降低维度,从而使输出具有与输入相同的高度和宽度。

图 6.8 展示了卷积操作的工作原理。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.8 在卷积操作中移动窗口时发生的计算

当处理输入时,池化操作表现出与卷积操作相同的行为。但是,所涉及的确切计算是不同的。池化有两种不同的类型:最大池化和平均池化。最大池化在图 6.9 中显示的深灰色框中找到的最大值作为窗口移过输入时的输出。平均池化在窗口移过输入时取深灰色框的平均值作为输出。

注意 CNNs 在输出处使用平均池化,并在其他地方使用最大池化层。已发现该配置提供了更好的性能。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.9 池化操作如何计算输出。它查看一个小窗口,并将该窗口中的输入最大值作为相应单元的输出。

池化操作的好处在于它使得 CNN 具有平移不变性。平移不变性意味着模型可以识别物体,而不管它出现在何处。由于最大池化的计算方式,生成的特征图是相似的,即使对象/特征与模型训练的位置相差几个像素。这意味着,如果你正在训练一个分类狗的模型,网络将对狗出现的确切位置具有弹性(只有在一定程度上)。

最后,你有一个全连接层。由于我们目前主要关注分类模型,我们需要为任何给定的图像输出一个类别的概率分布。我们通过将少量的全连接层连接到 CNNs 的末尾来实现这一点。全连接层将最后的卷积/池化输出作为输入,并在分类问题中生成类别的概率分布。

正如你所见,CNNs 有许多超参数(例如,层数、卷积窗口大小、步幅、全连接隐藏层大小等)。为了获得最佳结果,需要使用超参数优化技术(例如,网格搜索、随机搜索)来选择它们。

6.3.2 Inception 网络 v1

Inception 网络 v1(也称为 GoogLeNet)(mng.bz/R4GD) 将 CNNs 带入了另一个层次。它不是一个典型的 CNN,与标准 CNN 相比,需要更多的实现工作。乍一看,Inception 网络可能看起来有点可怕(见图 6.10)。但是你只需要理解几个新概念,就可以理解这个模型。主要是这些概念的重复应用使模型变得复杂。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.10 Inception 网络 v1 的抽象架构。Inception 网络从一个称为干扰的起始开始,这是一个在典型 CNN 中找到的普通卷积/池化层序列。然后,Inception 网络引入了一个称为 Inception 块的新组件。最后,Inception 网络还使用了辅助输出层。

让我们首先在宏观层面理解 Inception 模型中的内容,如图 6.10 所示,暂时忽略诸如层和它们的参数之类的细节。我们将在开发出强大的宏观水平理解后详细阐述这些细节。

Inception 网络以称为stem的东西开始。stem 包含与典型 CNN 的卷积和池化层相同的卷积和池化层。换句话说,stem 是按特定顺序组织的卷积和池化层的序列。

接下来,你有几个Inception blocks,这些块被 max pooling 层交错。一个 Inception block 包含一组并行的具有不同核大小的子卷积层。这使得模型能够在给定深度上以不同大小的感受野查看输入。我们将详细研究这背后的细节和动机。

最后,你有一个全连接层,它类似于典型 CNN 中的最终预测层。你还可以看到还有两个更多的临时全连接层。这些被称为辅助输出层。与最终预测层一样,它们由全连接层和 softmax 激活组成,输出数据集中类别的概率分布。尽管它们与最终预测层具有相同的外观,但它们不会对模型的最终输出做出贡献,但在训练过程中起着重要作用,稳定训练变得越来越艰难,因为模型变得越来越深(主要是由于计算机中数值的有限精度)。

让我们从头开始实现原始的 Inception 网络的一个版本。在此过程中,我们将讨论我们遇到的任何新概念。

注意!我们将构建一个略有不同的 Inception 网络 v1。

我们正在实现与原始 Inception 网络 v1 模型略有不同的东西,以应对某种实际限制。原始 Inception 网络设计用于处理尺寸为 224 × 224 × 3 的输入,属于 1,000 个类别,而我们有尺寸为 64 × 64 × 3 的输入,属于 200 个类别,我们将其调整为 56 × 56 × 3,以便其是 224 的因数(即,56 × 4 = 224)。因此,我们将对原始 Inception 网络进行一些修改。如果你愿意,你可以暂时忽略以下细节。但是如果你感兴趣,我们具体进行以下更改:

  • 使前三个具有步长 2 的层(在 stem 中)的步长为 1,以便我们在拥有较小输入图像时享受模型的全部深度。

  • 将最后一个全连接分类层的大小从 1,000 更改为 200,因为我们只有 200 个类别。

  • 移除一些正则化(即,dropout、loss weighting;这些将在下一章重新引入)。

如果你对这里讨论的模型感到舒适,理解原始的 Inception v1 模型将不会有问题。

首先,我们定义一个创建 Inception net v1 干部结构的函数。干部结构是 Inception 网络的前几层,看起来不过是典型卷积/池化层,但有一个新的层(称为 lambda 层),执行一些称为 局部响应归一化(LRN)的功能。我们将在稍后更详细地讨论该层的目的(请参见下一个清单)。

代码清单 6.3 Inception 网络中的干部结构的定义

def stem(inp):
    conv1 = Conv2D(
        64, (7,7), strides=(1,1), activation='relu', padding='same'
    )(inp)                                                                ❶
maxpool2 = MaxPool2D((3,3), strides=(2,2), padding='same')(conv1)         ❷
lrn3 = Lambda(
    lambda x: tf.nn.local_response_normalization(x)
)(maxpool2)                                                               ❸

conv4 = Conv2D(
    64, (1,1), strides=(1,1), padding='same'
)(lrn3)                                                                   ❹
conv5 = Conv2D(
    192, (3,3), strides=(1,1), activation='relu', padding='same'
)(conv4)                                                                  ❹
    lrn6 = Lambda(lambda x: tf.nn.local_response_normalization(x))(conv5) ❺

    maxpool7 = MaxPool2D((3,3), strides=(1,1), padding='same')(lrn6)return maxpool7                                                       ❼

❶ 第一个卷积层的输出

❷ 第一个最大池化层的输出

❸ 第一个局部响应归一化层。我们定义一个封装了 LRN 功能的 lambda 函数。

❹ 后续的卷积层

❺ 第二个 LRN 层

❻ 最大池化层

❼ 返回最终输出(即最大池化层的输出)

到目前为止,这段代码中的大部分应该已经非常熟悉了。它是一系列层,从输入开始生成输出。

Lambda 层(tf.keras.layers.Lambda)

Keras 中的 lambda 层与标准的 Python lambda 函数具有相似的目的。当用标准 lambda 函数编写时,它们封装了一些通常不可用作 Keras 标准层的计算。例如,您可以如下定义一个 Keras 层,该层取轴上的最大值。但是,您只能在 Keras lambda 函数中使用 TensorFlow / Keras 计算:

x = tf.keras.layers.Input(shape=(10,))
max_out = tf.keras.layers.Lambda(lambda x: tf.reduce_max(x, axis=1))(x)

您可能会注意到 lambda 层的作用与 Keras 的子类化 API 几乎相同。是的,但是 lambda 层不需要子类化 API 中所需的代码支架。对于具有复杂操作的图层(例如 if-else 条件,for 循环等),您可能会发现子类化 API 更容易。

具体来说,我们定义了以下层:

  • 一个卷积层

    • 64 个过滤器,(7,7) 卷积核大小,(2,2) 步长,激活 ReLU,相同填充
  • 一个局部响应归一化层

    • 这是通过使用 tf.keras.layers.Lambda 层来指定的。该层为您提供了一种方便的方法,可以定义一个封装了不容易获得的 TensorFlow / Keras 计算的 Keras 层。局部响应归一化是一种归一化给定输入的技术。
  • 第二个卷积层

    • 192 个过滤器,(3,3) 卷积核大小,(2,2) 步长,ReLU 激活,相同填充
  • 一个局部响应归一化层

  • 一个最大池化层

    • (3,3) 卷积核大小,(2,2) 步长以及相同填充

局部响应归一化

局部响应归一化(LRN)是早期的归一化技术,介绍在论文 “ImageNet Classification with Deep CNNs” (mng.bz/EWPr) 中。

这项技术受到了生物系统中表现出的横向抑制(mng.bz/N6PX)的启发。这指的是激活的神经元抑制邻近神经元的活动的现象(例如,在视网膜感受器中观察到)。本质上,LRN 层通过将卷积输出的每个值除以其邻域中的值(邻域由半径参数化,这是该层的超参数)来标准化每个值。这种规范化创建了神经元之间的竞争,并导致略微更好的性能。我们将不讨论涉及此计算的确切方程,因为这种方法已经过时,并且更好、更有前途的正则化技术,如批量标准化,已经取代了它。

更深入地了解 Inception 块

正如前面所述,Inception 网中的主要突破之一是 Inception 块。与具有固定核大小的典型卷积层不同,Inception 块是具有不同核大小的并行卷积层的集合。具体来说,在 Inception v1 中的 Inception 块包含 1 × 1 卷积、3 × 3 卷积、5 × 5 卷积和池化。图 6.11 显示了 Inception 块的架构。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.11 Inception 块中的计算,本质上是一组具有不同核大小的并行卷积/池化层

让我们了解为什么这些并行卷积层比具有相同核大小的巨型卷积滤波器块更好。主要优势在于 Inception 块与单个卷积块相比具有高度参数效率。我们可以通过一些数字来确保这一点。假设我们有两个卷积块:一个是 Inception 块,一个是标准卷积块。假设 Inception 块具有以下参数:

  • 一个具有 32 个滤波器的 1 × 1 卷积层

  • 一个具有 16 个滤波器的 3 × 3 卷积层

  • 一个具有 16 个滤波器的 5 × 5 卷积层

如果你要设计一个具有 Inception 块表示能力的标准卷积层,你会需要

  • 一个具有 64 个滤波器的 5 × 5 卷积层

假设我们正在处理一个单通道的输入,Inception 块的参数为 576,由以下给出

1 × 1 × 1 × 32 + 3 × 3 × 1 × 16 + 5 × 5 × 1 × 16 = 576

标准卷积块具有 1,600 个参数:

5 × 5 × 1 × 64 = 1,600

换句话说,与具有 Inception 块表示能力的标准卷积层相比,Inception 块减少了 64% 的参数数量。

Inception 块与稀疏性之间的联系

对于好奇的人们,可能还有一个持续存在的问题:Inception 块是如何引入稀疏性的?想象一下以下两种情况,你有三个卷积滤波器。在一个场景中,你有三个 5 × 5 卷积滤波器,而在另一个场景中,你有一个 1 × 1、3 × 3 和 5 × 5 卷积滤波器。图 6.12 展示了这两种情景之间的差异。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.12 Inception 块如何促进模型的稀疏性。你可以将 1 × 1 卷积看作是一个高度稀疏的 5 × 5 卷积。

不难看出,当你有三个 5 × 5 卷积滤波器时,它会在卷积层和输入之间创建非常密集的连接。然而,当你有一个 1 × 1、3 × 3 和 5 × 5 卷积层时,输入和层之间的连接更加稀疏。另一种思考方式是,1 × 1 卷积本质上是一个 5 × 5 卷积层,其中除了中心元素外,所有元素都关闭了。因此,1 × 1 卷积是一个高度稀疏的 5 × 5 卷积层。类似地,3 × 3 卷积是一个稀疏的 5 × 5 卷积层。通过引入稀疏性,我们使 CNN 参数高效,并减少了过拟合的可能性。这个解释受到了 mng.bz/Pn8g 中讨论的启发。

1 × 1 卷积作为降维方法

通常,你的模型越深,性能越高(假设你有足够的数据)。正如我们已经知道的,CNN 的深度是有代价的。层数越多,参数就越多。因此,你需要特别注意深度模型的参数数量。

作为一个深度模型,Inception 网络利用 Inception 块内的 1 × 1 卷积滤波器来抑制参数的大幅增加。通过使用 1 × 1 卷积层,将较大的输入产生较小的输出,并将这些较小的输出作为输入传递给 Inception 块中的卷积子层(图 6.13)。例如,如果你有一个 10 × 10 × 256 大小的输入,通过将其与具有 32 个滤波器的 1 × 1 卷积层进行卷积,你将得到一个 10 × 10 × 32 大小的输出。这个输出比原始输入小了八倍。换句话说,1 × 1 卷积减小了大输入的通道深度/维度。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.13 1 × 1 卷积的计算以及它如何实现输入通道维度的降维

因此,它被认为是一种降维方法。这些 1 × 1 卷积的权重可以被视为网络的参数,并且让网络学习这些滤波器的最佳值来解决给定的任务。

现在是时候定义一个函数,代表这个新的、改进的 Inception 块了,如下清单所示。

代码清单 6.4 定义 Inception 网络的 Inception 块

def inception(inp, n_filters):

    # 1x1 layer
    out1 = Conv2D(
        n_filters[0][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same'
    )(inp)

    # 1x1 followed by 3x3
    out2_1 = Conv2D(
        n_filters[1][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same')
(inp)
    out2_2 = Conv2D(
        n_filters[1][1], (3,3), strides=(1,1), activation='relu', 
➥ padding='same'
)(out2_1)

# 1x1 followed by 5x5
out3_1 = Conv2D(
    n_filters[2][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same'
)(inp)
out3_2 = Conv2D(
    n_filters[2][1], (5,5), strides=(1,1), activation='relu', 
➥ padding='same'
)(out3_1)

# 3x3 (pool) followed by 1x1
out4_1 = MaxPool2D(
    (3,3), strides=(1,1), padding='same'
)(inp)
out4_2 = Conv2D(
    n_filters[3][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same'
)(out4_1)

out = Concatenate(axis=-1)([out1, out2_2, out3_2, out4_2])
return out

inception() 函数接受一些输入(四维:批次、高度、宽度、通道、维度)和 Inception 块中卷积子层的过滤器尺寸列表。 此列表应按照以下格式具有过滤器尺寸:

[(1x1 filters), (1x1 filters, 3x3 filters), (1x1 filters, 5x5 filters), (1x1 filters)]

外循环对应 Inception 块中的垂直柱,内循环对应每个柱中的卷积层(图 6.14)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.14 Inception 块与 Inception 网模型的完整架构并排

然后,我们定义了四个垂直计算流,最后在末端连接到一个:

  • 1 × 1 卷积

  • 1 × 1 卷积接着是一个 3 × 3 卷积

  • 1 × 1 卷积接着是一个 5 × 5 卷积

  • 3 × 3 池化层接着是一个 1 × 1 卷积

使用 1 × 1 卷积进行尺寸缩减的数学视图

如果您不喜欢生动的方法,这里是更简洁和数学化的视图,说明 1 × 1 卷积如何减少维度。 假设您有尺寸为 10 × 10 × 256 的输入。 假设您有尺寸为 1 × 1 × 32 的卷积层:

  • 尺寸(输入)= 10 × 10 × 256

  • 尺寸(层)= 1 × 1 × 32

您可以将您的卷积层表示为 1 × 32 矩阵。 接下来,在 axis = 0(即行维度)上重复列,重复 256 次,得到我们

  • 尺寸(输入)= 10 × 10 × 256

  • 尺寸(层)= 256 × 32

现在您可以将输入与卷积滤波器相乘

  • 尺寸(输出)=(10 × 10 × 256)(256 × 32)

这给我们一个尺寸为

  • 尺寸(输出)= 10 × 10 × 32

比原始输入小得多。

最后,我们将所有这些流的输出连接到最后一个轴上(由 axis = -1 表示)。 请注意,最后一个维度是所有输出的通道维度。 换句话说,我们在通道维度上堆叠这些输出。 图 6.14 说明了 Inception 块如何在整体 Inception 网模型中定位。 接下来,我们将讨论 Inception 网模型的另一个组件,称为辅助输出层。

辅助输出层

最后,我们有两个辅助输出层,帮助稳定我们的深度 CNN。 正如前面提到的,辅助输出存在是为了稳定深度网络的训练。 在 Inception 网中,辅助输出层具有以下(图 6.15)。

  • 一个 5 × 5 平均池化层

  • 一个 1 × 1 卷积层

  • 一个具有 ReLU 激活的 Dense 层,从 1 × 1 卷积层接收平铺输出

  • 一个具有 softmax 的 Dense 层,输出类别的概率

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.15 辅助输出层与完整的 Inception 网架构并排

我们定义一个函数,按如下方式产生辅助输出预测(清单 6.5)。

清单 6.5 定义辅助输出作为 Python 函数

def aux_out(inp,name=None):    
    avgpool1 = AvgPool2D((5,5), strides=(3,3), padding='valid')(inp)       ❶
    conv1 = Conv2D(128, (1,1), activation='relu', padding='same')(avgpool1)❷
    flat = Flatten()(conv1)                                                ❸
    dense1 = Dense(1024, activation='relu')(flat)                          ❹
    aux_out = Dense(200, activation='softmax', name=name)(dense1)return aux_out

❶ 平均池化层的输出。 请注意,它使用有效池化,这导致下一层的输出为 4 × 4 大小。

❷ 1 × 1 卷积层的输出

❸ 将卷积层的输出展平,以便馈送到 Dense 层中

❹ 第一个 Dense 层的输出

❺ 最终预测 Dense 层的输出

aux_out() 函数定义了辅助输出层。它以一个核大小为(5,5) 和步长为(3,3) 的平均池化层开始,以及有效的填充。这意味着该层不会尝试纠正池化引入的维度减少(与相同填充相反)。然后,它后面跟着一个具有 128 个滤波器的卷积层,(1,1) 核大小,ReLU 激活和相同填充。然后,需要一个 Flatten() 层,然后将输出馈送到一个 Dense 层。请记住,Flatten() 层将高度、宽度和通道维度展平为一个单一维度。最后,应用一个具有 200 个节点和 softmax 激活的 Dense 层。有了这些,我们就有了构建自己的 Inception 网络的所有构建模块。

6.3.3 将所有内容整合在一起

我们已经走了很长的路。让我们喘口气,回顾一下我们迄今为止取得的成就:

  • Inception 网络模型的抽象架构和组件包括一个干扰块、Inception 块和辅助输出。

  • 这些组件的详细信息。干扰块类似于标准 CNN 的干扰块(除了全连接层)。Inception 块携带具有不同核大小的子卷积层,这些卷积层鼓励稀疏性并减少过拟合。

  • 辅助输出使网络训练更加平滑,并消除了训练过程中的任何不良数值错误。

我们还定义了封装这些的方法,以便我们可以调用这些方法并构建完整的 Inception 网络。现在我们可以定义完整的 Inception 模型(请参阅下一个列表)。此外,您可以在表 6.5 中找到精确的 Inception 块规范(按照原始论文)的摘要。

列表 6.6 定义完整的 Inception 网络模型

def inception_v1():

    K.clear_session()

    inp = Input(shape=(56,56,3))                                          ❶
    stem_out = stem(inp)                                                  ❷
    inc_3a = inception(stem_out, [(64,),(96,128),(16,32),(32,)])          ❸
    inc_3b = inception(inc_3a, [(128,),(128,192),(32,96),(64,)])          ❸

    maxpool = MaxPool2D((3,3), strides=(2,2), padding='same')(inc_3b)

    inc_4a = inception(maxpool, [(192,),(96,208),(16,48),(64,)])          ❸
    inc_4b = inception(inc_4a, [(160,),(112,224),(24,64),(64,)])          ❸

    aux_out1 = aux_out(inc_4a, name='aux1')                               ❹

    inc_4c = inception(inc_4b, [(128,),(128,256),(24,64),(64,)])
    inc_4d = inception(inc_4c, [(112,),(144,288),(32,64),(64,)])
    inc_4e = inception(inc_4d, [(256,),(160,320),(32,128),(128,)])

    maxpool = MaxPool2D((3,3), strides=(2,2), padding='same')(inc_4e)

    aux_out2 = aux_out(inc_4d, name='aux2')                               ❹

    inc_5a = inception(maxpool, [(256,),(160,320),(32,128),(128,)])
    inc_5b = inception(inc_5a, [(384,),(192,384),(48,128),(128,)])
    avgpool1 = AvgPool2D((7,7), strides=(1,1), padding='valid')(inc_5b)   ❺

    flat_out = Flatten()(avgpool1)                                        ❻
    out_main = Dense(200, activation='softmax', name='final')(flat_out)   ❼

    model = Model(inputs=inp, outputs=[out_main, aux_out1, aux_out2])   
    model.compile(loss='categorical_crossentropy', 
                       optimizer='adam', metrics=['accuracy'])return model

❶ 定义一个输入层。它接收一个大小为 64 × 64 × 3 的批处理输入。

❷ 要定义干扰块,我们使用了之前定义的干扰块() 函数。

❸ 定义 Inception 块。请注意,每个 Inception 块具有不同数量的滤波器。

❹ 定义辅助输出

❺ 最终池化层被定义为一个平均池化层。

❻ Flatten 层将平均池化层展平,并为全连接层做好准备。

❼ 最终预测层,具有 200 个输出节点(每个类别一个)

❽ 在编译模型时,我们对所有输出层和优化器 adam 使用分类交叉熵损失。

您可以看到该模型按照原始论文的规定有九个 Inception 块。此外,它还具有干扰块、辅助输出和最终输出层。层的具体规格列在表 6.5 中。

表 6.5 Inception 网络 v1 模型中 Inception 模块的滤波器计数摘要。C(nxn) 表示 nxn 卷积层,而 MaxP(mxm) 表示 mxm 最大池化层。

Inception 层C(1 × 1)C(1 × 1); 在 C(3 × 3) 之前C(3 × 3)C(1 × 1); 在 C(5 × 5) 之前C(5 × 5)C(1 × 1); 在 MaxP(3 × 3) 之后
Inc_3a6496128163232
Inc_3b128128192329664
Inc_4a19296208164864
Inc_4b160112224246464
Inc_4c128128256246464
Inc_4d112144288326464
Inc_4e25616032032128128
Inc_5a25616032032128128
Inc_5b38419238448128128

层的定义将与您已经看到的相当相似。然而,我们定义模型和编译模型的方式对于一些人来说可能是新的。正如我们讨论的那样,Inception 网络是一个多输出模型。您可以通过传递输出列表而不是单个输出来定义具有多个输出的 Keras 模型:

model = Model(inputs=inp, outputs=[out_main, aux_out1, aux_out2])

在编译模型时,您可以将损失定义为一个字符串列表。如果定义一个字符串,那么该损失将用于所有输出。我们使用分类交叉熵损失(对于最终输出层和辅助输出层)和优化器 adam 来编译模型,adam 是一种广泛用于优化模型的先进优化器,它可以随着模型训练适当地调整学习率。此外,我们将检查模型的准确性:

model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

通过定义 inception_v1() 函数,您可以创建一个模型如下:

model = inception_v1()

让我们花点时间回顾一下我们迄今为止所取得的成就。我们已经下载了数据,剖析了数据,并分析了数据以了解具体情况。然后,我们使用 tensorflow.keras.preprocessing.image.ImageDataGenerator 创建了一个图像数据管道。我们将数据分成了三部分:训练、验证和测试。最后,我们定义了我们的模型,这是一个被称为 Inception 网络的最先进的图像分类器。现在我们将看看多年来出现的其他 Inception 模型。

6.3.4 其他 Inception 模型

我们成功地实现了一个 Inception 网络模型,它涵盖了我们需要理解其他 Inception 模型的大部分基础知识。自 v1 模型以来已经有五个更多的 Inception 网络。让我们简要地了解一下 Inception 网络的演变。

Inception v1

我们已经深入讨论了 Inception 网络 v1。Inception 网络 v1 中引入的最大突破如下:

  • Inception 块的概念允许 CNN 在模型的相同深度拥有不同的感受野(即,卷积核大小)。这鼓励模型的稀疏性,导致参数更少,过拟合的机会也更少。

  • 在 Inception 模型的 20 层中,如果不小心,现代 GPU 的内存可能会被耗尽。Inception 网络通过使用 1 × 1 卷积层来减少输出通道深度,以缓解这个问题。

  • 网络越深,在模型训练过程中,梯度不稳定的可能性就越大。这是因为梯度必须经过很长的路径(从顶部到最底部),这可能导致梯度不稳定。在网络中间引入辅助输出层作为正则化器可以缓解这个问题,从而导致梯度稳定。

Inception v2

Inception net v2 出现在 Inception net v1 发布后不久(“Rethinking the Inception Architecture for Computer Vision”,arxiv.org/pdf/1512.00567.pdf)。这个模型的主要贡献如下。

当层的容量(即参数)不足以学习输入的良好表示时,就会发生表示瓶颈。如果在深度递减时层的大小减小得太快,这种情况可能会发生。Inception v2 重新设计了架构,以确保模型中不存在表示瓶颈。这主要通过改变层大小而保持其他细节不变来实现。

进一步减少网络参数以减少过拟合被强化。这是通过用 3 × 3 卷积(也称为因式分解大卷积层)替换更高阶的卷积(例如,5 × 5 和 7 × 7)来实现的。这是如何可能的?让我为您说明一下(图 6.16)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.16 一个 5 × 5 卷积层(左)和两个 3 × 3 卷积层(右)

将 5 × 5 卷积表示为两个更小的 3 × 3 卷积操作,我们可以减少 28% 的参数。图 6.17 对比了 Inception v1 块和 Inception v2 块。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.17 Inception net v1 中的 Inception 块(左)与 Inception net v2 中的 Inception 块(右)

TensorFlow 代码如下:

# 1x1 layer
out1 = Conv2D(64, (1,1), strides=(1,1), activation='relu', padding='same')(inp)
# 1x1 followed by 3x3
out2_1 = Conv2D(
    96, (1,1), strides=(1,1), activation='relu', padding='same'
)(inp)
out2_2 = Conv2D(
    128, (3,3), strides=(1,1), activation='relu', padding='same'
)(out2_1)

# 1x1 followed by 5x5
# Here 5x5 is represented by two 3x3 convolution layers
out3_1 = Conv2D(
    16, (1,1), strides=(1,1), activation='relu', padding='same'
)(inp)
out3_2 = Conv2D(
    32, (3,3), strides=(1,1), activation='relu', padding='same'
)(out3_1)
out3_3 = Conv2D(
    32, (3,3), strides=(1,1), activation='relu', padding='same'
)(out3_2)

# 3x3 (pool) followed by 1x1
out4_1 = MaxPool2D((3,3), strides=(1,1), padding='same')(inp)
out4_2 = Conv2D(
    32, (1,1), strides=(1,1), activation='relu', padding='same'
)(out4_1)

out = Concatenate(axis=-1)([out1, out2_2, out3_3, out4_2])

但我们不必止步于此。我们可以将任何 n × n 卷积操作因式分解为两个 1 × n 和 n × 1 卷积层,例如,对于 3 × 3 卷积层,可以减少 33% 的参数(图 6.18)。经验上发现,将 n × n 操作分解为两个 1 × n 和 n × 1 操作仅在更高层中有用。您可以参考论文以了解这些类型的因式分解何时以及在哪里使用。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.18 一个 3 × 3 卷积层(左)和一个 3 × 1 和一个 1 × 3 卷积层(右)

Inception v3

Inception v3 是在同一篇论文中引入的 Inception net v2. 与 v2 不同的主要贡献是使用批量标准化层。批量标准化(“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”,proceedings.mlr.press/v37/ioffe15.pdf)通过减去给定层 x 的平均值(E(x))和标准差(√(Var(x)))来标准化给定层的输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个过程帮助网络稳定其输出值,而不让它们变得太大或太小。接下来,它有两个可训练参数,γβ,用于缩放和偏移归一化输出:

y = γ + β

这样,网络就可以通过学习 γβ 的最佳化来学习归一化的自己变化,以防 不是最佳的归一化配置。此时,你只需要理解批归一化归一化给定层的输出。我们将在下一章更详细地讨论批归一化在 Inception 网模型中的使用方式。

Inception v4

Inception-v4 是在论文“Inception-v4, Inception-ResNet and the Impact of Residual Connections on Learning”(mng.bz/J28P) 中引入的,并没有引入任何新的概念,而是专注于使模型更简单,而不会牺牲性能。主要是,v4 简化了网络的干部和其他元素。由于这主要是为了更好地调整网络的超参数以获得更好的性能,而不是引入任何新的概念,所以我们不会深入研究这个模型。

Inception-ResNet v1 和 Inception-ResNet v2

Inception-ResNet v1 和 v2 在同一篇论文中被介绍,并且是其主要贡献。Inception-ResNet 简化了该模型中使用的 Inception 块,并删除了一些杂乱的细节。更重要的是,它引入了残差连接。残差连接(或 跳过连接)是由 Kaiming He 等人在题为“Deep Residual Learning for Image Recognition”的论文中介绍的 (arxiv.org/ pdf/1512.03385.pdf)。这是一个简单而优雅的概念,却非常强大,它已经成为许多不同领域中表现最佳的模型的原因之一。

如图 6.19 所示,残差连接简单地将较低层(靠近输入)的输入添加到较高层(远离输入)的输入中。这样就在较低层和较高层之间创建了一条捷径,实质上是在结果输出与较低层之间创建了另一个捷径。我们不会在这里深入讨论太多细节,因为我们将在下一章详细讨论 Inception-ResNet 模型。接下来,我们将训练我们刚刚定义的模型,使用我们准备的图像数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6.19 残差连接是如何引入到网络中的。这是一个简单的操作,其中你将一层的较低输出(更接近输入)添加到一层的较高输出(远离输入)中。跳过连接可以设计成跳过您喜欢的任意数量的层。图还突出了梯度的流动;您可以看到跳过连接如何允许梯度绕过某些层并传播到较低层。

练习 3

作为研究的一部分,您正在测试一种称为poolception的新技术。概念上类似于 Inception 块,poolception 具有三个并行的池化层,具有以下规格:

  • 一个带有步长为 2 和相同填充的 3×3 最大池化层

  • 一个带有步长为 2 和相同填充的 5×5 最大池化层

  • 一个带有步长为 2 和相同填充的 3×3 平均池化层

最后,这些层的输出在通道轴上串联起来。您能将此实现为一个名为 poolception 的 Python 函数,该函数以前一层的输入 x 作为参数吗?

6.4 训练模型和评估性能

很棒!您已经定义了一个在类似(更大)数据集上表现良好的最先进模型架构之一。您的下一个任务是训练此模型并分析其性能。

模型训练是一个必不可少的步骤,如果您需要一个性能良好的模型,一旦到了使用它的时候。训练模型会优化(即更改)模型的参数,使其能够在给定输入时产生正确的预测。通常,模型训练是在多个时期进行的,其中每个时期可以包含数千个迭代。这个过程可能需要数小时甚至数周,这取决于数据集的大小和模型。正如我们已经讨论过的,由于其众所周知的内存需求,深度神经网络以小批量方式消耗数据。优化模型与单个数据批次的步骤称为迭代。当您以这种批量方式遍历整个数据集时,它被称为时期

最后,一旦训练完成,您需要确保模型在未见过的数据上表现良好。这些未见过的数据在训练过程中不得与模型发生任何交互。深度学习网络最常见的评估指标是准确率。因此,我们测量测试准确率以确保模型的稳健性。

为了训练模型,让我们首先定义一个函数,该函数计算每个时期的步数或迭代次数,给定数据集的大小和批量大小。对于每个时期都运行预定义数量的步骤总是一个好主意。有些情况下,Keras 无法确定步骤的数量,这种情况下,它可能会使模型运行,直到您停止它:

def get_steps_per_epoch(n_data, batch_size):
    if n_data%batch_size==0:
        return int(n_data/batch_size)
    else:
        return int(n_data*1.0/batch_size)+1

这是一个非常简单的计算。每个时期的步数是数据点数(n_data)除以批量大小(batch_size)。如果 n_data 不可被 batch_size 整除,则需要将返回值加 1,以确保不会丢失任何数据。现在让我们在下面的列表中训练模型。

列表 6.7 训练 Inception 网络

from tensorflow.keras.callbacks import CSVLogger
import time
import os

if not os.path.exists('eval'):
    os.mkdir('eval')                                               ❶

csv_logger = CSVLogger(os.path.join('eval','1_eval_base.log'))     ❷

history = model.fit(
    x=train_gen_aux,                                               ❸
    validation_data=valid_gen_aux,                                 ❸
    steps_per_epoch=get_steps_per_epoch(0.9*500*200,batch_size),   ❸
    validation_steps=get_steps_per_epoch(0.1*500*200,batch_size),  ❸
    epochs=50, 
    callbacks=[csv_logger])if not os.path.exists('models'):
    os.mkdir("models")
model.save(os.path.join('models', 'inception_v1_base.h5'))

❶ 创建一个名为 eval 的目录来存储性能结果

❷ 这是一个您传递给 fit() 函数的 Keras 回调函数。它将指标数据写入 CSV 文件。

❸ 通过拟合模型,您可以看到我们正在将训练和验证数据生成器传递给函数。

❹ 将模型保存到磁盘上,以便在需要时重新加载

训练模型时,通常会遵循以下步骤:

  • 为一定数量的周期训练模型。

  • 每个训练周期结束时,在验证数据集上测量性能。

  • 所有训练周期结束后,对测试集的性能进行测量。

当在代码中调用 model.fit()时,它会处理前两个步骤。我们将更详细地查看 model.fit()函数。我们向函数传递以下参数:

  • X—将训练数据生成器传递给模型,其中包含输入(x)和目标(y)。

  • y—通常接收目标值。在这里,我们不指定 y,因为 x 已经包含了目标。

  • validation_data—接收验证数据生成器。

  • steps_per_epoch—训练中每个周期的步数(迭代次数)。

  • validation_steps—验证中每个周期的步数(迭代次数)。

  • epochs—周期数。

  • 回调函数—需要传递给模型的任何回调函数(有关回调函数的完整列表,请访问mng.bz/woEW)。

在训练模型后,您应该会得到以下结果之类的内容:

Train for 704 steps, validate for 79 steps
Epoch 1/50
704/704 [==============================] - 196s 279ms/step - loss: 14.6223- final_loss: 4.9449 - aux1_loss: 4.8074 - aux2_loss: 4.8700 - 
➥ final_accuracy: 0.0252 - aux1_accuracy: 0.0411 - aux2_accuracy: 0.0347- val_loss: 13.3207 - val_final_loss: 4.5473 - val_aux1_loss: 4.3426 - 
➥ val_aux2_loss: 4.4308 - val_final_accuracy: 0.0595 - val_aux1_accuracy:0.0860 - val_aux2_accuracy: 0.0765
...
Epoch 50/50
704/704 [==============================] - 196s 279ms/step - loss: 0.6361 - 
➥ final_loss: 0.2271 - aux1_loss: 0.1816 - aux2_loss: 0.2274 - 
➥ final_accuracy: 0.9296 - aux1_accuracy: 0.9411 - aux2_accuracy: 0.9264- val_loss: 27.6959 - val_final_loss: 7.9506 - val_aux1_loss: 10.4079 - 
➥ val_aux2_loss: 9.3375 - val_final_accuracy: 0.2703 - val_aux1_accuracy:0.2318 - val_aux2_accuracy: 0.2361

注意 在一台配备 Intel Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练大约需要 2 小时 45 分钟。您可以通过减少训练周期的数量来减少训练时间。

最后,我们将在测试数据上测试训练好的模型(即 val 文件夹中的数据)。您可以通过调用 model.evaluate()并传递测试数据生成器(test_gen_aux)和测试集的步数(迭代次数)来轻松获取模型的测试性能:

model = load_model(os.path.join('models','inception_v1_base.h5'))
test_res = model.evaluate(test_gen_aux, steps=get_steps_per_epoch(200*50, 
➥ batch_size))
test_res_dict = dict(zip(model.metrics_names, test_res))

您将会得到以下输出:

196/196 [==============================] - 17s 88ms/step - loss: 27.7303 - 
➥ final_loss: 7.9470 - aux1_loss: 10.3892 - aux2_loss: 9.3941 - 
➥ final_accuracy: 0.2700 - aux1_accuracy: 0.2307 - aux2_accuracy: 0.2367

我们可以看到模型达到了约 30%的验证和测试准确率以及惊人的约 94%的训练准确率。这清楚地表明我们没有完全避免过拟合。但这并不完全是坏消息。三十%的准确率意味着模型在验证和测试集中识别了约 3,000/10,000 张图像。就纯粹的数据量而言,这相当于 200 个类别中的 60 个。

注意 过拟合的模型就像一个把所有答案都背下来的学生,而泛化的模型是一个努力理解将在考试中测试的概念的学生。背诵答案的学生只会在考试中表现良好,在现实世界中会失败,而理解概念的学生可以将他们的知识推广到考试和现实世界中。

过度拟合可能会出现几种原因:

  • 模型架构对我们拥有的数据集来说并不是最佳的。

  • 需要更多的正则化来减少过拟合,比如 dropout 和批归一化。

  • 我们没有使用已经在类似数据上训练过的预训练模型。

我们将在下一章中解决这些问题,看到事情会有多大改善将会令人兴奋。

练习 4

如果用一个包含 50,000 个样本且批次大小为 250 的数据集训练模型 10 个周期,那么你会训练模型多少次迭代?假设输入和标签分别存储在变量 x 和 y 中,填写 model.fit() 中的必要参数以根据这个规范训练模型。在不使用数据生成器时,可以使用 batch_size 参数设置批次大小,并在 model.fit() 中忽略 steps_per_epoch 参数(自动推断)。

总结

  • 探索性数据分析(EDA)是机器学习生命周期中必须在开始任何建模之前执行的关键步骤。

  • 分析数据的方面越多,效果越好。

  • Keras 数据生成器可以用来从磁盘读取图像并将它们加载到内存中以训练模型。

  • Inception 网络 v1 是用于图像分类的最新计算机视觉模型之一,旨在减少过拟合和深度模型的内存需求。

  • Inception 网络 v1 包括一个 stem、若干 inception 块和辅助输出。

  • Inception 块是 Inception 网络中的一层,包含多个具有不同卷积核大小的子卷积层,而辅助输出确保模型训练的平稳性。

  • 训练模型时,会使用三个数据集:训练集、验证集和测试集。

  • 通常情况下,我们会在训练数据上训练模型多个周期,在每个周期结束时在验证集上评估性能。最后,在训练结束后,我们会在测试数据集上评估性能。

  • 过拟合的模型就像一个把所有答案都记住的学生,它在训练数据上表现很好,但在将知识概括应用于未见数据分析时效果很差。

练习答案

练习 1

def get_img_minimum(path):
    img = np.array(Image.open(path))
    return np.min(img)

df[“minimum”] = df[“filepath”].apply(lambda x: get_img_minimum(x))

练习 2

def data_gen_corrupt(gen):
    for x,y in gen:
        if np.random.normal()>0:
            y = 0
        yield x,(y,y,y)

练习 3

def poolception(x):
    out1 = MaxPool2D(pool_size=(3,3), strides=(2,2), padding=’same’)(x)
    out2 = MaxPool2D(pool_size=(5,5), strides=(2,2), padding=’same’)(out1)
    out3 = AvgPool2D(pool_size=(3,3), strides=(2,2), padding=’same’)(out2)
    out = Concatenate(axis=-1)([out1, out2, out3])
    return out

练习 4: 总迭代次数 = (数据集大小/批次大小)* 周期数 = (50,000/250)* 10 = 2,000

model.fit(x=x, y=y, batch_size=250, epochs=10)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值