【DL】第 4 章:自回归模型

在本章中,您将:

  • 了解为什么自回归模型非常适合生成顺序数据,例如文本。

  • 了解如何处理和标记化文本数据

  • 了解循环神经网络 (RNN) 的架构设计

  • 使用 Keras 从头开始构建和训练长短期记忆 (LSTM) 网络

  • 使用 LSTM 生成新文本

  • 了解 RNN 的其他变体,包括门控循环单元 (GRU) 和双向单元。

  • 了解如何将图像数据视为像素序列

  • 了解 PixelCNN 的架构设计

  • 使用 Keras 从头开始构建 PixelCNN

  • 使用 PixelCNN 生成图像

    扫描二维码关注公众号,回复: 14574033 查看本文章

到目前为止,我们已经探索了两个不同的生成模型系列,它们都涉及潜在变量——变分自编码器 (VAE) 和生成对抗网络 (GAN)。在这两种情况下,都会引入一个具有易于从中采样的分布的新变量,并且模型会学习如何将该变量解码回原始图像域。

我们现在将注意力转向不同的模型系列,该系列通过将生成建模问题视为一个顺序过程来简化生成建模问题——也就是说,它不以潜在变量为条件,而是以先前的值为条件。

在本章中,我们将探索两种不同的构建自回归生成模型的方法——长短期记忆网络和 PixelCNN。我们将对文本数据应用 LSTM,对图像数据应用 PixelCNN。

要了解 LSTM 的工作原理,我们将首先参观一个陌生的监狱,那里的囚犯组成了一个文学社团……

The Literary Society for Troublesome Miscreants

爱德华索普讨厌他作为监狱看守的工作。他整天都在看守囚犯,没有时间去追随他对写短篇小说的真正热情。他缺乏灵感,需要找到一种方法来生成新内容。

有一天,他想出了一个绝妙的主意,既可以创作出自己风格的新小说,又可以让狱友们有事可做——让狱友们集体为他写故事!他将这个新社会命名为 LSTM(Literary Society for Troublesome Miscreants)。

监狱特别奇怪,因为它只有一个大牢房,关押着256名囚犯。每个囚犯对爱德华当前的故事应该如何继续都有自己的看法。每天,爱德华都会将他小说中的最新单词发布到牢房中,囚犯的工作是根据新单词和之前囚犯的意见,单独更新他们对故事当前状态的看法天。

每个囚犯都使用特定的思维过程来更新他们自己的观点,这涉及平衡来自新输入单词的信息和其他囚犯的观点与他们自己先前的信念。首先,他们利用来自新词的信息和牢房中其他囚犯的意见,决定他们希望忘记多少昨天的意见。他们还使用这些信息来形成新的想法,并决定他们想要将其中多少混入他们从前一天选择发扬光大的旧信念中。然后,这形成了囚犯当天的新意见。

然而,囚犯们很隐秘,并不总是将他们所相信的一切告诉他们的狱友。他们还使用最近选择的词和其他囚犯的意见来决定他们希望透露多少意见。

当爱德华希望细胞生成序列中的下一个单词时,囚犯们将他们可公开的意见告诉门口有点密集的守卫,守卫结合这些信息最终决定下一个要附加到小说结尾的单词。这个新词然后像往常一样被反馈到细胞中,这个过程一直持续到整个故事完成。

为了训练囚犯和看守,爱德华将他之前写下的简短单词序列输入牢房,并监控囚犯选择的下一个单词是否正确。他更新了他们的准确性,他们逐渐开始学习如何以他自己独特的风格写故事。

在这个过程的多次迭代之后,Edward 发现该系统在生成逼真的文本方面已经相当成熟。虽然它在语义结构上有些欠缺,但它确实显示出与他以前的故事相似的特征。

他对结果很满意,在他的新书《E. Sopp's Fables》中出版了一系列生成的故事。

循环神经网络 (RNN) 简介

索普先生的故事他的众包寓言是对顺序数据最常用和最成功的深度学习技术之一的类比如文:长短期记忆(LSTM)网络。

LSTM 网络是一种特殊类型递归神经网络(RNN)。RNN 包含一个循环层(或cell),它能够通过在特定时间步使其自己的输出成为下一个时间步输入的一部分来处理顺序数据,因此来自过去的信息可以影响当前时间步的预测。我们说LSTM 网络是指具有 LSTM 循环层的神经网络。

首次引入 RNN 时,循环层非常简单,仅由一个 tanh 运算符组成,确保时间步之间传递的信息在 –1 和 1 之间缩放。然而,这被证明会受到梯度消失问题,并且不能很好地扩展到长数据序列。

长短期记忆网络1997 年,Sepp Hochreiter 和 Jürgen Schmidhuber 在一篇论文中首次介绍了细胞。在这篇论文中,作者描述了 LSTM 如何不会遇到与普通 RNN 相同的梯度消失问题,并且可以在数百个时间步长的序列上进行训练。从那时起,LSTM 架构得到了调整和改进,并且出现了诸如作为门控循环单元(GRU) 现在被广泛使用,并可作为 Keras 中的层使用。

处理文本数据

在了解如何在 Keras 中构建 LSTM 网络之前,我们必须先快速了解一下文本数据的结构,以及它与我们目前为止在本书中看到的图像数据有何不同。此示例的代码包含在存储库的 ./notebooks/lstm/train.ipynb 笔记本中。

与图像数据的差异

那里文本和图像数据之间存在几个关键差异,这意味着许多适用于图像数据的方法并不那么容易适用于文本数据。尤其:

  • 文本数据由离散块(字符或单词)组成,而图像中的像素是连续色谱中的点。我们可以很容易地让一个绿色像素变得更蓝,但是我们并不清楚我们应该如何让单词cat更像单词dog,例如。这意味着我们可以轻松地将反向传播应用于图像数据,因为我们可以计算损失函数相对于单个像素的梯度,以确定像素颜色应该改变的方向,以最大限度地减少损失。对于离散的文本数据,我们显然不能以相同的方式应用反向传播,所以我们需要找到解决这个问题的方法。

  • 文本数据有时间维度但没有空间维度,而图像数据有两个空间维度但没有时间维度。单词的顺序在文本数据中非常重要,单词倒过来没有意义,而图像通常可以翻转而不影响内容。此外,模型需要捕获的单词之间通常存在长期的顺序依赖关系:例如,问题的答案或代词的上下文。对于图像数据,可以同时处理所有像素。

  • 文本数据对单个单元(单词或字符)的微小变化高度敏感。图像数据通常对单个像素单元的变化不太敏感——即使某些像素被更改,一张房子的图片仍然可以被识别为房子。但是,对于文本数据,即使更改几个单词也会极大地改变段落的含义,或使其变得毫无意义。这使得训练模型生成连贯的文本变得非常困难,因为每个词对于段落的整体含义都至关重要。

  • 文本数据具有基于规则的语法结构,而图像数据不遵循关于如何分配像素值的既定规则。例如,写“The cat sat on the having”在任何内容中都没有语法意义。还有一些极难建模的语义规则;说“我在海滩上”是没有意义的,尽管从语法上来说,这句话没有错。

直到最近,大多数最复杂的生成深度学习模型都集中在图像数据上,因为上面提出的许多挑战甚至是最先进的技术也无法解决的。然而,在过去 5 年中,基于文本的生成深度学习领域取得了惊人的进步,这要归功于我们将在第 8 章介绍的 Transformer 模型架构的引入。

下载数据

我们将使用可通过 Kaggle 获得的Epicurious Recipes 数据集。这是一套超过 20,000 份食谱,附带元数据,例如营养信息和成分列表。

您可以通过运行图书存储库中的 Kaggle 数据集下载器脚本来下载数据集,如示例4-1 所示。这会将食谱和附带的元数据本地保存到/data文件夹中。

示例 4-1 下载 Epicurious Recipe 数据集
bash scripts/download_kaggle_data.sh hugodarwood epirecipes

然后可以将数据加载到 Jupyter notebook 中,如示例 4-2所示并进行过滤,以便只保留带有标题和描述的食谱。示例 4-2中给出了一个配方字符串的例子。

示例 4-2 加载数据
# Load the full dataset
with open('/app/data/epirecipes/full_format_recipes.json') as json_data:
    recipe_data = json.load(json_data)

# Filter the dataset
filtered_data = ['Recipe for ' + x['title']+ ' | ' + ' '.join(x['directions']) for x in recipe_data
              if 'title' in x
              and x['title'] is not None
              and 'directions' in x
              and x['directions'] is not None
             ]
示例 4-3 Tokenization之前的秘诀
Recipe for Ham Persillade with Mustard Potato Salad and Mashed Peas  | Chop enough parsley leaves to measure 1 tablespoon; reserve. Chop remaining leaves and stems and simmer with broth and garlic in a small saucepan, covered, 5 minutes. Meanwhile, sprinkle gelatin over water in a medium bowl and let soften 1 minute. Strain broth through a fine-mesh sieve into bowl with gelatin and stir to dissolve. Season with salt and pepper. Set bowl in an ice bath and cool to room temperature, stirring. Toss ham with reserved parsley and divide among jars. Pour gelatin on top and chill until set, at least 1 hour. Whisk together mayonnaise, mustard, vinegar, 1/4 teaspoon salt, and 1/4 teaspoon pepper in a large bowl. Stir in celery, cornichons, and potatoes. Pulse peas with marjoram, oil, 1/2 teaspoon pepper, and 1/4 teaspoon salt in a food processor to a coarse mash. Layer peas, then potato salad, over ham.

现在让我们来看看我们需要采取哪些步骤来获得正确形状的文本数据来训练 LSTM 网络。

Tokenization

这第一步是清理和标记文本。标记化是将文本拆分为单个单元(例如单词或字符)的过程。

你如何标记你的文本将取决于你试图用你的文本生成模型实现什么。同时使用单词和字符标记各有利弊,您的选择将影响在建模之前需要如何清理文本以及模型的输出。

如果您使用单词标记:

  • 所有文本都可以转换为小写,以确保句子开头的大写单词与出现在句子中间的相同单词的标记方式相同。然而,在某些情况下,这可能是不可取的;例如,一些专有名词,如名称或地名,可能会受益于保持大写,以便它们被独立标记。

  • 文本词汇(训练集中不同单词的集合)可能非常大,有些单词出现得非常稀疏,或者可能只出现一次。将稀疏词替换为未知词的标记可能是明智的,而不是将它们作为单独的标记包括在内,以减少神经网络需要学习的权重数量。

  • 字可以词干化,这意味着它们被简化为最简单的形式,因此动词的不同时态仍然保持在一起。例如,浏览浏览浏览浏览都将词干化为眉毛

  • 您将需要标记标点符号,或将其完全删除。

  • 使用单词标记化意味着模型将永远无法预测训练词汇表之外的单词。

如果您使用字符标记:

  • 该模型可能会生成在训练词汇表之外形成新词的字符序列——这在某些情况下可能是可取的,但在其他情况下则不然。

  • 大写字母可以转换为小写字母,也可以保留为单独的标记。

  • 使用字符标记化时,词汇量通常要小得多。这有利于模型训练速度,因为在最终输出层中需要学习的权重更少。

对于此示例,我们将使用小写单词标记化,而不使用词干提取。我们还将标记标点符号,因为我们希望模型预测何时应该结束句子或使用逗号,例如。

示例 4-4中的代码清理并标记文本。

示例 4-4 Tokenization
def pad_punctuation(s):
    s = re.sub(f"([{string.punctuation}])", r' \1 ', s)
    s = re.sub(' +', ' ', s)
    return s

text_data = [pad_punctuation(x) for x in filtered_data] 

text_ds = tf.data.Dataset.from_tensor_slices(text_data).batch(32).shuffle(1000) 

vectorize_layer = keras.layers.TextVectorization( 
    standardize = 'lower',
    max_tokens = 10000,
    output_mode = "int",
    output_sequence_length = 200 + 1,
)

# Adapt the layer to the training set
vectorize_layer.adapt(text_ds) 
vocab = vectorize_layer.get_vocabulary() 

1.填充标点符号,将它们视为单独的单词

2.转换为 TensorFlow 数据集

3.创建一个 KerasTextVectorization层,将文本转换为小写,为最流行的 10,000 个单词提供相应的整数4.标记,并将序列修剪/填充为 201 个标记长。

5.将该 TextVectorization层应用于训练数据

该vocab变量存储单词标记的列表

示例 4-5中显示了令牌化后的配方示例。我们用来训练模型的序列长度是训练过程的一个参数。在此示例中,我们选择使用 200 的序列长度,因此我们将配方填充或剪切到比该长度多一个长度,以允许我们创建目标变量(请参阅下一节)。为了达到这个期望的长度,向量的末尾用“0”填充。

示例 4-5 示例 4-3中的配方被标记化

[ 26 16 557 1 8 298 335 189 4 1054 494 27 332 228

235 262 5 594 11 133 22 311 2 332 45 262 4 671

4 70 8 171 4 81 6 9 65 80 3 121 3 59

12 2 299 3 88 650 20 39 6 9 29 21 4 67

529 11 164 2 320 171 102 9 374 13 643 306 25 21

8 650 4 42 5 931 2 63 8 24 4 33 2 114

21 6 178 181 1245 4 60 5 140 112 3 48 2 117

557 8 285 235 4 200 292 980 2 107 650 28 72 4

108 10 114 3 57 204 11 172 2 73 110 482 3 298

3 190 3 11 23 32 142 24 3 4 11 23 32 142

33 6 9 30 21 2 42 6 353 3 3224 3 4 150

2 437 494 8 1281 3 37 3 11 23 15 142 33 3

4 11 23 32 142 24 6 9 291 188 5 9 412 572

2 230 494 3 46 335 189 3 20 557 2 0 0 0

0 0 0 0 0]

在示例 4-6中,我们可以看到标记列表的一个子集映射到它们各自的索引。该层保留0用于填充的标记和用于位于前 10,000 个单词之外的未知1单词的标记(例如 persillade)。其他词按频率顺序分配。词汇表中包含的单词数量是训练过程的一个参数。包含的单词越多,您在文本中看到的未知标记就越少;但是,您的模型需要更大以适应更大的词汇量。

示例 4-6 该层的词汇表TextVectorization- token:word 映射
0:
1: [UNK]
2: .
3: ,
4: and
5: to
6: in
7: the
8: with
9: a

创建训练集

我们的LSTM 网络将被训练以预测序列中的下一个单词,给定该点之前的单词序列。例如,我们可以为模型提供烤鸡和煮鸡的标记,并期望模型输出合适的下一个词(例如,potatoes,而不是bananas)。

因此,我们可以简单地将整个序列移动一个标记,以创建我们的目标变量。

数据集生成步骤可以使用示例 4-7中的代码来实现。

示例 4-7 生成训练数据集
def prepare_inputs(text):
    text = tf.expand_dims(text, -1)
    tokenized_sentences = vectorize_layer(text)
    x = tokenized_sentences[:, :-1]
    y = tokenized_sentences[:, 1:]
    return x, y

train_ds = text_ds.map(prepare_inputs) 1

1.创建由配方标记(输入)和移动一个标记(目标)的相同向量组成的训练集。

长短期记忆网络(LSTM)

这整体模型的架构如图4.1 所示。该模型的输入是一个整数标记序列,输出是词汇表中每个单词出现在序列中下一个的概率。要详细了解其工作原理,我们需要介绍两种新的图层类型,Embedding以及LSTM.

图4.1 LSTM模型架构

嵌入层

一个嵌入层本质上是一个查找表,它将每个标记转换为一个长度向量embedding_size(图4.2 )。模型将查找向量学习为权重。因此,该层学习的权重数等于词汇表的大小乘以嵌入向量的维数。

我们将每个整数标记嵌入到一个连续向量中,因为它使模型能够学习每个能够通过反向传播更新的单词的表示。我们也可以只对每个输入标记进行单热编码,但是使用嵌入层是首选,因为它使嵌入本身可训练,从而使模型在决定如何嵌入每个标记以提高模型性能时具有更大的灵活性。

图4.2 嵌入层是每个整数标记的查找表

因此,该Input层将一个形状为整数序列的张量传递[batch_size, seq_length]给该Embedding层,该层输出一个形状为 的张量[batch_size, seq_length, embedding_size]。然后将其传递到LSTM图层(图4.3 )。

图4.3 流经嵌入层的单个序列

LSTM层

到了解LSTM层,首先要了解一个通用的循环层是如何工作的。

循环层具有能够处理顺序输入数据 [ x 1 ,… , x n ] 的特殊属性。它由一个细胞组成更新其隐藏状态h t,因为序列x t的每个元素都通过它,一次一个时间步。隐藏状态是一个向量,其长度等于单元格中的单元数——它可以被认为是单元格当前对序列的理解。在时间步t,单元使用隐藏状态的先前值h t–1以及来自当前时间步x t的数据来生成更新的隐藏状态向量h t. 这个循环过程一直持续到序列结束。序列完成后,该层输出单元格的最终隐藏状态h n,然后将其传递到网络的下一层。这个过程如图4-4所示。

图4.4 循环层的简单示意图

为了更详细地解释这一点,让我们展开这个过程,以便我们可以准确地看到单个序列是如何通过层的(图4.5 )。

图4.5 单个序列如何流经循环层

在这里,我们通过在每个时间步绘制单元格的副本来表示循环过程,并显示隐藏状态在流经单元格时如何不断更新。我们可以清楚地看到先前的隐藏状态如何与当前顺序数据点(即当前嵌入的词向量)混合以产生下一个隐藏状态。在处理输入序列中的每个单词之后,该层的输出是单元的最终隐藏状态。请务必记住,此图中的所有单元格共享相同的权重(因为它们实际上是相同的单元格)。此图与图 4-4没有区别;这只是绘制循环层机制的一种不同方式。

笔记

事实上,单元格的输出被称为隐藏状态是一种不幸的命名约定——它并不是真正隐藏的,你不应该这样想。实际上,最后一个隐藏状态是层的整体输出,我们将利用这一事实,即我们可以在本章后面的每个单独时间步访问隐藏状态。

LSTM 细胞

现在我们已经了解了通用循环层的工作原理,让我们看一下单个 LSTM 单元的内部。

LSTM单元的工作是输出一个新的隐藏状态ht,给定其先前的隐藏状态ht –1和当前词嵌入xt回顾一下, ht长度等于 LSTM 中的单元数。这是定义层时设置的参数,与序列长度无关。确保不要混淆术语单元格单位. LSTM 层中有一个单元,由它包含的单元数定义,就像我们之前故事中的囚室包含许多囚犯一样。我们经常将循环层绘制为展开的单元链,因为它有助于可视化隐藏状态在每个时间步如何更新。

一个 LSTM 单元维护一个单元状态C t,它可以被认为是单元对序列当前状态的内部信念。这与隐藏状态ht不同, ht最终由细胞在最终时间步后输出。单元状态的长度与隐藏状态(单元中的单元数)相同。

让我们更仔细地看看单个单元格以及隐藏状态是如何更新的(图4.6 )。

图4.6 一个 LSTM 单元

隐藏状态的更新分为六个步骤:

  1. 前一个时间步的隐藏状态h t–1和当前词嵌入x t被连接起来并通过遗忘门。这个门只是一个密集层,具有权重矩阵W f、偏差b f和一个 sigmoid 激活函数。生成的向量f t的长度等于单元格中的单元数,并且包含 0 和 1 之间的值,这些值确定应保留多少先前的单元格状态C t–1 。

  1. 连接后的向量也通过一个输入门,与遗忘门一样,它是一个密集层,具有权重矩阵W i、偏置b i和 S 形激活函数。此门的输出i t的长度等于单元格中的单元数,并且包含 0 到 1 之间的值,这些值确定将向先前的单元格状态C t–1添加多少新信息。

  1. 连接后的向量通过具有权重矩阵W C、偏差b Ctanh 激活函数的密集层传递以生成向量C˜t包含细胞想要考虑保留的新信息。它的长度也等于单元格中的单位数,并且包含介于 –1 和 1 之间的值。

  1. f tC t–1按元素相乘并添加到i t和的按元素乘法C˜t. 这表示遗忘部分先前的细胞状态,然后添加新的相关信息以产生更新的细胞状态C t

  1. 原始连接向量也通过输出门:一个具有权重矩阵W o、偏差b o和 sigmoid 激活的密集层。生成的向量o t的长度等于单元格中的单元数,并存储 0 和 1 之间的值,这些值确定从单元格输出多少更新的单元格状态C t 。

  1. 在应用 tanh 激活以产生新的隐藏状态 h t 之后,o t更新的单元状态C t逐元素相乘。

这示例 4-8中给出了构建、编译和训练 LSTM 网络的代码。

示例 4-8 构建、编译和训练 LSTM 网络
inputs = keras.layers.Input(shape=(None,), dtype="int32") 
x = keras.layers.Embedding(10000, 100)(inputs) 
x = keras.layers.LSTM(128, return_sequences=True)(x) 
outputs = keras.layers.Dense(10000, activation = 'softmax')(x) 
model = keras.models.Model(inputs, outputs) 

loss_fn = keras.losses.SparseCategoricalCrossentropy()
model.compile("adam", loss_fn) 
model.fit(train_ds, epochs=25) 

1.该Input层不需要我们预先指定序列长度(它可以灵活),所以我们用作None占位符

2.该Embedding层需要两个参数 - 词汇表的大小(10,000 个标记)和嵌入向量的维数 (100)。

3.LSTM 层要求我们指定隐藏向量 (128) 的维数。我们还选择返回隐藏状态的完整序列,而不仅仅是最后一个时间步的隐藏状态。

4.密集层将每个时间步的隐藏状态转换为下一个标记的概率向量。

5.给定输入的标记序列,整体Model预测下一个标记。它为序列中的每个标记执行此操作。

6.模型是有SparseCategoricalCrossentropy损失编译的

7.该模型适合训练数据集

在图4.7 中,您可以看到 LSTM 训练过程的前几个时期——注意示例输出如何随着损失指标下降而变得更容易理解。图4.8 显示了在训练过程中下降的交叉熵损失指标。

图4.7 LSTM 训练过程的前几个 epoch

图4.8 按时期划分的 LSTM 训练过程的交叉熵损失度量

分析 LSTM

现在我们已经编译和训练了 LSTM 网络,我们可以开始使用它通过应用以下过程来生成长文本字符串:

  1. 向网络提供现有的单词序列,并要求它预测下一个单词。

  1. 将这个词附加到现有序列并重复。

网络将为我们可以从中采样的每个单词输出一组概率。因此,我们可以使文本生成是随机的,而不是确定性的。此外,我们可以temperature在采样过程中引入一个参数来指示我们希望该过程的确定性程度。

这是通过示例 4-9中的代码实现的,该代码创建了一个回调函数,可用于在每个训练周期结束时生成文本。

示例 4-9 文本生成器回调函数
class TextGenerator(keras.callbacks.Callback):
    def __init__(self, index_to_word, top_k=10):
        self.index_to_word = index_to_word
        self.word_to_index = {word: index for index, word in enumerate(index_to_word)} 

    def sample_from(self, probs, temperature): 
        probs = probs ** (1 / temperature)
        probs = probs / np.sum(probs)
        return np.random.choice(len(probs), p=probs), probs

    def generate(self, start_prompt, max_tokens, temperature):
        start_tokens = [self.word_to_index.get(x, 1) for x in start_prompt.split()] 
        sample_token = None
        info = []
        while len(start_tokens) < max_tokens and sample_token != 0: 
            x = np.array([start_tokens])
            y = self.model.predict(x) 
            sample_token, probs = self.sample_from(y[0][-1], temperature) 
            info.append({'prompt': start_prompt , 'word_probs': probs})
            start_tokens.append(sample_token) 
            start_prompt = start_prompt + ' ' + self.index_to_word[sample_token]
        print(f"\ngenerated text:\n{start_prompt}\n")
        return info

    def on_epoch_end(self, epoch, logs=None):
        self.generate("recipe for", max_tokens = 100, temperature = 1.0)

1.创建一个反向词汇表映射(从单词到标记)。

2.此函数使用比例因子更新概率temperature。接近零的温度使采样更具确定性(即,最有可能选择概率最高的词),而温度为 1 意味着每个词都以模型输出的概率被选择。

3.开始提示是您希望给模型以启动生成过程的一串单词(例如 的食谱)。这些词首先被转换成一个标记列表。

4.生成序列,直到它变max_tokens长或到达停止标记。

5.该模型输出每个单词在序列中是下一个的概率

6.概率通过采样器传递以输出下一个单词,参数化为temperature。

7.我们将新词附加到种子文本,为生成过程的下一次迭代做好准备。

让我们看一下两个不同temperature值的实际效果(图4.9 )。

图4.9 在temperatur = 1.0 和temperatur = 0.2 时生成输出。

关于这两段经文,有几点需要注意。首先,两者在风格上都与原始训练集中的食谱相似。它们都以菜谱的标题开头,并且通常包含语法正确的结构。不同之处在于,生成的温度为 1.0 的文本比温度为 0.2 的示例更具冒险性,因此准确性较低。因此,生成多个温度为 1.0 的样本将导致更多的变化,因为模型是从具有更大方差的概率分布中采样的。

为了证明这一点,图4.10 显示了对于两个温度值的一系列提示具有最高概率的前 5 个标记。

图4.10 温度值 1.0 和 0.2 后各种序列的单词概率分布

该模型能够在一系列上下文中为下一个最有可能的词生成合适的分布。例如,即使模型从未被告知词性,如名词、动词或数字,它通常能够将单词分成这些类,并以语法正确的方式使用它们。

此外,该模型能够根据前面的标题选择适当的动词来开始食谱说明。对于烤蔬菜,它选择preheat, prepare, heat,put或combine作为最可能的可能性,而对于冰淇淋,它选择in, combine, stir。whisk和mix。这表明该模型根据其成分对食谱之间的差异有一定的上下文理解。

还要注意示例的概率如何temperature = 0.2更偏向于首选标记。这就是为什么温度较低时世代变化通常较少的原因。

虽然我们的基本 LSTM 模型在生成逼真的文本方面做得很好,但很明显它仍然难以理解它生成的单词的某些语义含义。它介绍了不太可能一起使用的成分(例如,日本酸土豆、山核桃屑和冰糕)!在某些情况下,如果我们希望我们的 LSTM 生成有趣且独特的单词模式,这可能是可取的,但在其他情况下,我们需要我们的模型更深入地理解单词可以组合在一起的方式,并且需要更长的时间对本文前面介绍的想法的记忆。

在下一节中,我们将探索一些可以改进基本 LSTM 网络的方法。在第十章中,我们还将介绍一种新的自回归模型——Transformer——它将语言建模提升到一个新的水平。

循环神经网络扩展

这上一节中的网络是一个简单示例,说明如何训练 LSTM 网络以学习如何生成给定样式的文本。在本节中,我们将探索这个想法的几个扩展。

堆叠循环网络

这我们刚刚看到的网络包含单个 LSTM 层,但我们也可以训练具有堆叠 LSTM 层的网络,以便可以从文本中学习更深层次的特征。

为此,我们只需LSTM在第一层之后引入另一层。然后,第二个 LSTM 层可以使用第一层的隐藏状态作为其输入数据。如图4.11 所示,整体模型架构如图4.12 所示。

图4.11 多层 RNN 示意图:g t表示第一层的隐藏状态,h t表示第二层的隐藏状态

图4.12 堆叠式 LSTM 网络

示例 4-10给出了构建堆叠式 LSTM 网络的代码。

示例 4-10 构建堆叠式 LSTM 网络
text_in = Input(shape = (None,))
    embedding = Embedding(total_words, embedding_size)
    x = embedding(text_in)
    x = LSTM(n_units, return_sequences = True)(x)
    x = LSTM(n_units, return_sequences = True)(x)
    text_out = Dense(total_words, activation = 'softmax')(x)

    model = Model(text_in, text_out)

门控循环单元(GRU)

其他常用的 RNN 层类型是门控循环单元(GRU) 。与 LSTM 单元的主要区别如下:

  1. 忘记和输入门被重置和更新门取代。

  1. 没有单元状态输出门,只有从单元输出的隐藏状态。

隐藏状态的更新分为四个步骤,如图4.13 所示。

图 4.13 单个 GRU 单元

过程如下:

1.前一个时间步的隐藏状态h t–1和当前词嵌入x t被连接起来并用于创建重置门。这个门是一个密集层,具有权重矩阵W r和一个 sigmoid 激活函数。结果向量r t的长度等于单元格中的单元数,并存储 0 到 1 之间的值,这些值决定了之前的隐藏状态h t–1中有多少应该被带入计算细胞的新信念。

2.重置门应用于隐藏状态h t–1,并与当前词嵌入x t连接。然后将该向量馈送到具有权重矩阵W和 tanh 激活函数的密集层以生成向量,ℎ˜t,它存储了细胞的新信念。它的长度等于单元格中的单位数,并存储介于 –1 和 1 之间的值。

3.前一个时间步的隐藏状态ht –1和当前词嵌入xt的串联也用于创建更新门。该门是一个密集层,具有权重矩阵W z和 sigmoid 激活。生成的向量z t的长度等于单元格中的单位数,并存储 0 到 1 之间的值,这些值用于确定新信念的数量,ℎ˜t, 融入当前隐藏状态h t–1

4.细胞的新信念ℎ˜t和当前隐藏状态h t–1以更新门z t确定的比例混合,以产生更新的隐藏状态h t,即从单元输出。

双向细胞

为了整个文本在推理时可供模型使用的预测问题,没有理由只在前向处理序列——它也可以向后处理。一个Bidirectional层通过存储两组隐藏状态来利用这一点:一组是由于序列在通常的正向处理中产生的结果而产生的,另一组是在序列向后处理时产生的。这样,该层可以从给定时间步之前和之后的信息中学习。

在 Keras 中,这是作为循环层的包装器实现的,如下所示:

layer = Bidirectional(GRU(100))

结果层中的隐藏状态是长度等于包装单元中单位数量两倍的向量(前向和后向隐藏状态的串联)。因此,在这个例子中,层的隐藏状态是长度为 200 的向量。

到目前为止,我们仅将自回归模型 (LSTM) 应用于文本数据。在下一节中,我们将了解如何使用自回归模型来生成图像。

PixelCNN

2016 年,van den Oord 等人。引入了一个模型,该模型通过基于之前的像素预测下一个像素的可能性来逐像素生成图像。该模型称为 PixelCNN,可以训练它使用自回归方法根据训练集生成图像。

我们需要引入两个新概念来理解 PixelCNN- masked 卷积层残差块。此示例的代码包含在图书存储库的chapter05/pixelcnn/fashion/01_train_from_scratch.ipynb笔记本中。

Masked Convolutional Layers

正如我们在第 2 章中看到的,卷积层可用于通过应用一系列过滤器从图像中提取特征。该层在特定像素处的输出是滤波器权重乘以以该像素为中心的小方块上的前一层值的加权和。这种方法可以检测边缘和纹理以及更深层次的形状和更高层次的特征。

虽然卷积层对于特征检测非常有用,但它们不能直接用于自回归意义上,因为像素没有排序。它们依赖于所有像素都被平等对待的事实——没有像素被视为图像的开始结束。这与我们在本章中已经看到的文本数据形成对比,其中对标记有明确的排序,因此可以轻松应用循环模型(例如 LSTM)。

为了能够在自回归意义上将卷积层应用于图像生成,我们必须首先对像素进行排序,并确保过滤器只能看到相关像素之前的像素。这样,我们就可以一次生成一个像素的图像,方法是将卷积滤波器应用于当前图像,以根据所有先前像素预测下一个像素的值。

我们首先需要为像素选择一个顺序——一个明智的建议是从左上角到右下角对像素进行排序,首先沿着行移动,然后沿着列向下移动。

然后我们屏蔽卷积滤波器,使每个像素层的输出仅受相关像素之前的像素值的影响。这是通过将 1 和 0 的掩码(图4.14 )与过滤器权重矩阵相乘来实现的,以便将目标像素之后的任何像素的值归零。

图4.14 左 - 卷积过滤器掩码。A 型遮盖中心像素,B 型不遮盖中心像素。右 - 应用于一组像素以预测中心像素值分布的掩码(来源:Conditional Image Generation with PixelCNN Decoders,van den Oord 等人,https: //arxiv.org/pdf/1606.05328)。

PixelCNN 中实际上有两种不同的掩码:

  • A中心像素值被屏蔽的类型

  • 键入B中心像素值未被屏蔽的位置。

初始屏蔽卷积层(即直接应用于输入图像的那个)不能使用中心像素,因为这正是我们希望网络猜测的像素!然而,后续层可以使用中心像素,因为这将仅根据原始输入图像中先前像素的信息计算得出。

我们可以在示例 4-11 MaskedConvLayer中看到如何使用 Keras 构建a 。

示例 4-11 蒙面卷积层
class MaskedConvLayer(keras.layers.Layer):
    def __init__(self, mask_type, **kwargs):
        super(MaskedConvLayer, self).__init__()
        self.mask_type = mask_type
        self.conv = keras.layers.Conv2D(**kwargs) 

    def build(self, input_shape):
        self.conv.build(input_shape)
        kernel_shape = self.conv.kernel.get_shape()
        self.mask = np.zeros(shape=kernel_shape) 
        self.mask[: kernel_shape[0] // 2, ...] = 1.0 
        self.mask[kernel_shape[0] // 2, : kernel_shape[1] // 2, ...] = 1.0 
        if self.mask_type == "B":
            self.mask[kernel_shape[0] // 2, kernel_shape[1] // 2, ...] = 1.0 

    def call(self, inputs):
        self.conv.kernel.assign(self.conv.kernel * self.mask) 
        return self.conv(inputs)

1.基于MaskedConvLayer普通Conv2D层

2.掩码用全零初始化

3.前几行中的像素未被屏蔽。

4.同一行中前面列中的像素未被屏蔽。

5.如果遮罩类型为 B,则中心像素使用 1 取消遮罩。

6.掩码乘以过滤器权重

请注意,这个简化的示例假定灰度图像(即具有一个通道)。如果我们有彩色图像,我们也有三个颜色通道,我们也可以对其进行排序,例如,红色通道在蓝色通道之前,蓝色通道在绿色通道之前。

残差块

现在我们已经了解了如何屏蔽卷积层,我们可以开始构建我们的 PixelCNN。我们需要引入的核心构建块是残差块。

残差是一组层,其中输出在传递到网络的其余部分之前被添加到输入。换句话说,输入有一条到输出的快速路径,无需经过中间层——这称为跳跃连接。包含跳跃连接的基本原理是,如果最佳转换只是为了保持输入相同,那么这可以通过简单地将中间层的权重归零来实现。如果没有跳过连接,网络将不得不通过中间层找到恒等映射,这要困难得多。

我们的 PixelCNN 中的残差块图如图4.15 所示。

图4.15 PixelCNN 中的残差块。过滤器的数量显示在箭头旁边,过滤器大小显示在层的旁边。

我们可以使用示例 4-12 ResidualBlock中所示的代码构建一个。

示例 4-12 残差块
class ResidualBlock(keras.layers.Layer):
    def __init__(self, filters, **kwargs):
        super(ResidualBlock, self).__init__(**kwargs)
        self.conv1 = keras.layers.Conv2D(
            filters=filters // 2, kernel_size=1, activation="relu"
        ) 
        self.pixel_conv = MaskedConv2D(
            mask_type="B",
            filters=filters // 2,
            kernel_size=3,
            activation="relu",
            padding="same",
        ) 
        self.conv2 = keras.layers.Conv2D(
            filters=filters, kernel_size=1, activation="relu"
        ) 

    def call(self, inputs):
        x = self.conv1(inputs)
        x = self.pixel_conv(x)
        x = self.conv2(x)
        return keras.layers.add([inputs, x]) 

1.初始Conv2D层将通道数减半

2.内核大小为 3 的B 型MaskedConv2D层仅使用来自五个像素的信息 - 焦点像素上方一行中的三个像素,左侧一个像素和焦点像素本身

3.最后Conv2D一层将通道数量加倍以再次匹配输入形状

4.卷积层的输出被添加到输入中——这就是跳跃连接。

构建 PixelCNN

在示例 4-13中,我们将整个 PixelCNN 网络放在一起,大致遵循原始论文中列出的结构。

在原始论文中,输出层是一个 256 个过滤器Conv2D层,具有 softmax 激活。换句话说,网络试图通过预测正确的像素值来重新创建其输入,有点像自动编码器。不同之处在于 PixelCNN 受到限制,因此由于网络使用层的设计方式,来自早期像素的信息无法流过影响每个像素的预测MaskedConv2D。

这种方法的一个挑战是网络无法理解像素值 200 与像素值 201 非常接近。它必须独立学习每个像素输出值,因此训练速度可能非常慢,即使对于最简单的数据集。因此,在我们的实现中,我们改为简化输入,使每个像素只能取 4 个值中的一个。这样,我们可以使用 4 个过滤器Conv2D输出层而不是 256 个。

示例 4-13 PixelCNN 架构
inputs = keras.Input(shape=(16, 16, 1)) 
x = MaskedConv2D(mask_type="A"
                   , filters=128
                   , kernel_size=7
                   , activation="relu"
                   , padding="same")(inputs)

for _ in range(5):
    x = ResidualBlock(filters=128)(x) 

for _ in range(2):
    x = MaskedConv2D(
        mask_type="B",
        filters=128,
        kernel_size=1,
        strides=1,
        activation="relu",
        padding="valid",
    )(x) 

out = keras.layers.Conv2D(
    filters=4, kernel_size=1, strides=1, activation="softmax", padding="valid"
)(x) 

pixel_cnn = keras.Model(inputs, out) 

adam = keras.optimizers.Adam(learning_rate=0.0005)
pixel_cnn.compile(optimizer=adam, loss="sparse_categorical_crossentropy")

pixel_cnn.fit(
    input_data
    , output_data
    , batch_size=128
    , epochs=150
) 

1.该模型Input是大小为 16x16x1 的灰度图像,输入在 0 到 1 之间缩放

2.内核大小为 7 的第一个 A 类MaskedConv2D层使用来自 24 个像素的信息 - 焦点像素上方三行中的 21 个像素和左侧的 3 个像素(不使用焦点像素本身)。

3.五层ResidualBlock组依次堆叠

4.MaskedConv2D内核大小为 1 的两个 B 型层作为Dense每个像素的通道数的层

5.最后Conv2D一层将通道数减少到 4——本例中的像素级数。

6.构建Model为接受图像并输出相同尺寸的图像。

7.适合模型 -input_data在 [0,1] 范围内缩放(浮点数);output_data在 [0,3](整数)范围内缩放

分析 PixelCNN

我们可以在第 3 章中遇到的 Fashion MNIST 数据集的图像上训练 PixelCNN。要生成新图像,我们需要让模型在给定所有先前像素的情况下预测下一个像素,一次一个像素。与变分自动编码器等模型相比,这是一个非常缓慢的过程!对于 32x32 灰度图像,我们需要使用模型顺序进行 1024 次预测,而 VAE 需要进行一次预测。这是 PixelCNN 等自回归模型的主要缺点之一——由于采样过程的顺序性质,它们的采样速度很慢。

出于这个原因,我们使用 16x16 而不是 32x32 的图像大小来加速新图像的生成。生成回调类如示例 4-14所示。

示例 4-14 使用 PixelCNN 生成新图像
class ImageGenerator(keras.callbacks.Callback):
    def __init__(self, num_img):
        self.num_img = num_img

    def sample_from(self, probs, temperature):
        probs = probs ** (1 / temperature)
        probs = probs / np.sum(probs)
        return np.random.choice(len(probs), p=probs)

    def generate(self, temperature):
        generated_images = np.zeros(shape=(self.num_img,) + (pixel_cnn.input_shape)[1:]) 
        batch, rows, cols, channels = generated_images.shape

        for row in range(rows):
            for col in range(cols):
                for channel in range(channels):
                    probs = self.model.predict(generated_images)[:, row, col, :] 
                    pixel_int = [self.sample_from(x, temperature) for x in probs] 
                    generated_images[:, row, col, channel] = pixel_int / 4 

        return generated_images

    def on_epoch_end(self, epoch, logs=None):
        generated_images = self.generate(temperature = 1.0)
        display(generated_images, save_to = "./output/generated_img_%03d.png" % (epoch))

img_generator_callback = ImageGenerator(num_img=10)

1.从一批空图像开始(全为零)

2.循环当前图像的行、列和通道,预测下一个像素值的分布。

3.从预测分布中采样像素级别(对于我们的示例,[0,3] 范围内的级别)。

4.将像素级别转换为范围[0,1]并覆盖当前图像中的像素值,为循环的下一次迭代做好准备

在图4.16 中,我们可以看到来自原始训练集的几张图像,以及由 PixelCNN 生成的图像。

图4.16 来自训练集的示例图像和由 PixelCNN 模型创建的生成图像

该模型很好地再现了原始图像的整体形状和风格!令人惊奇的是,我们可以将图像视为一系列标记(像素值)并应用 PixelCNN 等自回归模型来生成逼真的样本。

自回归模型的一个缺点是它们的采样速度很慢,这就是为什么我们在本书中提供了一个简单的应用示例。然而,正如我们将在第 8 章中看到的那样,其他更复杂形式的自回归模型可以应用于图像以产生最先进的输出,因此在某些情况下,它们缓慢的生成速度是作为回报的必要代价卓越的质量输出。

自原始论文以来,对 PixelCNN 的架构和训练过程进行了多项改进。下面我们描述了其中一个变化——使用混合分布——并展示了如何使用内置的 TensorFlow 函数来训练具有这种改进的 PixelCNN 模型。

混合分布

对于我们之前的示例,我们将 PixelCNN 的输出减少到仅 4 个像素级别,以确保网络不必学习超过 256 个独立像素值的分布,这会减慢训练过程。然而,这远非理想 - 对于彩色图像,不希望我们的画布仅限于少数几种可能的颜色。

为了解决这个问题,我们可以按照 Salimans 等人提出的想法,使网络的输出成为混合分布,而不是超过 256 个离散像素值的 softmax 。混合分布是两种或多种其他概率分布的简单混合。例如,我们可以有五个逻辑分布的混合分布,每个都有不同的参数。混合分布还需要一个离散的分类分布,它表示选择混合中包含的每个分布的概率。示例如图4.17 所示。

图4.17 具有不同参数的三个正态分布的混合分布 - 三个正态分布的分类分布为 [0.5, 0.3, 0.2]。

要从混合分布中抽样,我们首先从分类分布中抽样以选择特定的子分布,然后以通常的方式从中抽样。这样,我们可以用相对较少的参数创建复杂的分布。例如,图4.17 中的混合分布只需要 8 个参数——两个用于分类分布,一个均值和方差用于三个正态分布中的每一个。这与将在整个像素范围内定义分类分布的 255 个参数相比较。

方便的是,TensorFlow 提供了一个函数,允许我们在一行中创建具有混合分布输出的 PixelCNN。在示例4-15 中,我们展示了如何使用此函数构建 PixelCNN。此示例的代码包含在存储库的chapter05/pixelcnn/fashion/01_train_using_tensorflow_distributions.ipynb笔记本中。

示例 4-15 使用 TensorFlow 函数构建 PixelCNN
import tensorflow_probability as tfp

dist = tfp.distributions.PixelCNN(
    image_shape=(32, 32, 1),
    num_resnet=1,
    num_hierarchies=2,
    num_filters=32,
    num_logistic_mix=5,
    dropout_p=.3,
) 

image_input = keras.Input(shape=(32, 32, 1)) 

log_prob = dist.log_prob(image_input)

model = keras.Model(inputs=image_input, outputs=log_prob) 
model.add_loss(-tf.reduce_mean(log_prob)) 

1.将 PixelCNN 定义为分布——即输出层是由五个逻辑分布组成的混合分布

2.这Input是一个大小为 32x32x1 的灰度图像

3.以Model灰度图像为输入,输出图像在PixelCNN计算的混合分布下的对数似然

4.损失函数是输入图像批次的平均负对数似然。

该模型的训练方式与之前相同,但这次接受范围为 [0,255] 的整数像素值作为输入。可以使用实体4-16 sample中所示的函数从分布中生成输出。

示例 4-16 从 PixelCNN 混合分布中采样
dist.sample(10).numpy()

示例生成的图像如图4.18 所示。与我们之前示例的不同之处在于,现在正在使用整个像素值范围。

图 4.18 使用混合分布输出的 PixelCNN 输出

概括

在本章中,我们了解了如何应用递归神经网络等自回归模型来生成模仿特定写作风格的文本序列,以及 PixelCNN 如何以顺序方式生成图像,一次一个像素。

我们探索了两种不同类型的循环层,即长短期记忆 (LSTM) 和 GRU,并了解了如何堆叠这些单元或使它们双向形成更复杂的网络架构。我们构建了一个 LSTM 以使用 Keras 生成逼真的食谱,并了解了如何操纵采样过程的温度来增加或减少输出的随机性。

我们还看到了如何使用 PixelCNN 以自回归方式生成图像。我们使用 Keras 从头开始构建 PixelCNN,对屏蔽的卷积层和残差块进行编码以允许信息在网络中流动,以便只有前面的像素可以用来生成当前像素。我们还介绍了 TensorFlow 如何提供一个独立的PixelCNN函数来实现混合分布作为输出层,从而使我们能够进一步改进学习过程。

在下一章中,我们将探索生成架构家族的最后成员——规范化流和基于扩散的模型。

猜你喜欢

转载自blog.csdn.net/sikh_0529/article/details/129338479
今日推荐