第十六章:使用 RNN 和注意力进行自然语言处理
当艾伦·图灵在 1950 年想象他著名的Turing 测试时,他提出了一种评估机器匹配人类智能能力的方法。他本可以测试许多事情,比如识别图片中的猫、下棋、创作音乐或逃离迷宫,但有趣的是,他选择了一项语言任务。更具体地说,他设计了一个聊天机器人,能够愚弄对话者以为它是人类。这个测试确实有其弱点:一组硬编码规则可以愚弄毫无戒心或天真的人类(例如,机器可以对某些关键词给出模糊的预定义答案,可以假装在回答一些最奇怪的问题时开玩笑或喝醉,或者可以通过用自己的问题回答难题来逃避困难的问题),并且许多人类智能的方面完全被忽视(例如,解释非言语交流,如面部表情,或学习手动任务的能力)。但这个测试确实突显了掌握语言可能是智人最伟大的认知能力。
我们能否构建一台能够掌握书面和口头语言的机器?这是自然语言处理研究的终极目标,但实际上研究人员更专注于更具体的任务,比如文本分类、翻译、摘要、问答等等。
自然语言任务的一种常见方法是使用循环神经网络。因此,我们将继续探索循环神经网络(在第十五章中介绍),首先是字符 RNN或char-RNN,训练以预测句子中的下一个字符。这将使我们能够生成一些原创文本。我们将首先使用无状态 RNN(在每次迭代中学习文本的随机部分,没有关于文本其余部分的信息),然后我们将构建有状态 RNN(在训练迭代之间保留隐藏状态,并继续阅读离开的地方,使其能够学习更长的模式)。接下来,我们将构建一个 RNN 来执行情感分析(例如,阅读电影评论并提取评价者对电影的感受),这次将句子视为单词序列,而不是字符。然后我们将展示如何使用 RNN 来构建一个编码器-解码器架构,能够执行神经机器翻译(NMT),将英语翻译成西班牙语。
在本章的第二部分,我们将探索注意机制。正如它们的名字所示,这些是神经网络组件,它们学习选择模型在每个时间步应该关注的输入部分。首先,我们将通过注意机制提高基于 RNN 的编码器-解码器架构的性能。接下来,我们将完全放弃 RNN,并使用一个非常成功的仅注意架构,称为transformer,来构建一个翻译模型。然后,我们将讨论过去几年自然语言处理中一些最重要的进展,包括基于 transformer 的 GPT 和 BERT 等非常强大的语言模型。最后,我将向您展示如何开始使用 Hugging Face 出色的 Transformers 库。
让我们从一个简单而有趣的模型开始,这个模型可以像莎士比亚一样写作(某种程度上)。
使用字符 RNN 生成莎士比亚文本
在一篇著名的2015 年博客文章中,安德烈·卡帕西展示了如何训练一个 RNN 来预测句子中的下一个字符。然后可以使用这个char-RNN逐个字符生成新文本。以下是一个经过训练所有莎士比亚作品后由 char-RNN 模型生成的文本的小样本:
潘达鲁斯:
唉,我想他将会被接近并且这一天
当一点点智慧被获得而从未被喂养时,
而谁不是一条链,是他死亡的主题,
我不应该睡觉。
虽然不是杰作,但仍然令人印象深刻,模型能够学习单词、语法、正确的标点符号等,只是通过学习预测句子中的下一个字符。这是我们的第一个语言模型示例;本章后面讨论的类似(但更强大)的语言模型是现代自然语言处理的核心。在本节的其余部分,我们将逐步构建一个 char-RNN,从创建数据集开始。
创建训练数据集
首先,使用 Keras 方便的 tf.keras.utils.get_file()
函数,让我们下载所有莎士比亚的作品。数据是从 Andrej Karpathy 的char-rnn 项目加载的:
import tensorflow as tf shakespeare_url = "https://homl.info/shakespeare" # shortcut URL filepath = tf.keras.utils.get_file("shakespeare.txt", shakespeare_url) with open(filepath) as f: shakespeare_text = f.read()
让我们打印前几行:
>>> print(shakespeare_text[:80]) First Citizen: Before we proceed any further, hear me speak. All: Speak, speak.
看起来像是莎士比亚的作品!
接下来,我们将使用 tf.keras.layers.TextVectorization
层(在第十三章介绍)对此文本进行编码。我们设置 split="character"
以获得字符级别的编码,而不是默认的单词级别编码,并且我们使用 standardize="lower"
将文本转换为小写(这将简化任务):
text_vec_layer = tf.keras.layers.TextVectorization(split="character", standardize="lower") text_vec_layer.adapt([shakespeare_text]) encoded = text_vec_layer([shakespeare_text])[0]
现在,每个字符都映射到一个整数,从 2 开始。TextVectorization
层将值 0 保留给填充标记,将值 1 保留给未知字符。目前我们不需要这两个标记,所以让我们从字符 ID 中减去 2,并计算不同字符的数量和总字符数:
encoded -= 2 # drop tokens 0 (pad) and 1 (unknown), which we will not use n_tokens = text_vec_layer.vocabulary_size() - 2 # number of distinct chars = 39 dataset_size = len(encoded) # total number of chars = 1,115,394
接下来,就像我们在第十五章中所做的那样,我们可以将这个非常长的序列转换为一个窗口的数据集,然后用它来训练一个序列到序列的 RNN。目标将类似于输入,但是向“未来”移动了一个时间步。例如,数据集中的一个样本可能是代表文本“to be or not to b”(没有最后的“e”)的字符 ID 序列,相应的目标是代表文本“o be or not to be”(有最后的“e”,但没有开头的“t”)的字符 ID 序列。让我们编写一个小型实用函数,将字符 ID 的长序列转换为输入/目标窗口对的数据集:
def to_dataset(sequence, length, shuffle=False, seed=None, batch_size=32): ds = tf.data.Dataset.from_tensor_slices(sequence) ds = ds.window(length + 1, shift=1, drop_remainder=True) ds = ds.flat_map(lambda window_ds: window_ds.batch(length + 1)) if shuffle: ds = ds.shuffle(buffer_size=100_000, seed=seed) ds = ds.batch(batch_size) return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1)
这个函数开始得很像我们在第十五章中创建的 to_windows()
自定义实用函数:
- 它以一个序列作为输入(即编码文本),并创建一个包含所需长度的所有窗口的数据集。
- 它将长度增加一,因为我们需要下一个字符作为目标。
- 然后,它会对窗口进行洗牌(可选),将它们分批处理,拆分为输入/输出对,并激活预取。
图 16-1 总结了数据集准备步骤:它展示了长度为 11 的窗口,批量大小为 3。每个窗口的起始索引在其旁边标出。
图 16-1. 准备一个洗牌窗口的数据集
现在我们准备创建训练集、验证集和测试集。我们将大约使用文本的 90%进行训练,5%用于验证,5%用于测试:
length = 100 tf.random.set_seed(42) train_set = to_dataset(encoded[:1_000_000], length=length, shuffle=True, seed=42) valid_set = to_dataset(encoded[1_000_000:1_060_000], length=length) test_set = to_dataset(encoded[1_060_000:], length=length)
提示
我们将窗口长度设置为 100,但您可以尝试调整它:在较短的输入序列上训练 RNN 更容易更快,但 RNN 将无法学习任何长于 length
的模式,所以不要将其设置得太小。
就是这样!准备数据集是最困难的部分。现在让我们创建模型。
构建和训练 Char-RNN 模型
由于我们的数据集相当大,而建模语言是一个相当困难的任务,我们需要不止一个简单的具有几个循环神经元的 RNN。让我们构建并训练一个由 128 个单元组成的 GRU
层的模型(如果需要,稍后可以尝试调整层数和单元数):
model = tf.keras.Sequential([ tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16), tf.keras.layers.GRU(128, return_sequences=True), tf.keras.layers.Dense(n_tokens, activation="softmax") ]) model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam", metrics=["accuracy"]) model_ckpt = tf.keras.callbacks.ModelCheckpoint( "my_shakespeare_model", monitor="val_accuracy", save_best_only=True) history = model.fit(train_set, validation_data=valid_set, epochs=10, callbacks=[model_ckpt])
让我们仔细看看这段代码:
- 我们使用一个
Embedding
层作为第一层,用于编码字符 ID(嵌入在第十三章中介绍)。Embedding
层的输入维度数是不同字符 ID 的数量,输出维度数是一个可以调整的超参数,我们暂时将其设置为 16。Embedding
层的输入将是形状为[批量大小, 窗口长度]的 2D 张量,Embedding
层的输出将是形状为[批量大小, 窗口长度, 嵌入大小]的 3D 张量。 - 我们使用一个
Dense
层作为输出层:它必须有 39 个单元(n_tokens
),因为文本中有 39 个不同的字符,并且我们希望在每个时间步输出每个可能字符的概率。39 个输出概率应该在每个时间步加起来为 1,因此我们将 softmax 激活函数应用于Dense
层的输出。 - 最后,我们编译这个模型,使用
"sparse_categorical_crossentropy"
损失和 Nadam 优化器,然后训练模型多个 epoch,使用ModelCheckpoint
回调来保存训练过程中验证准确性最好的模型。
提示
如果您在启用 GPU 的 Colab 上运行此代码,则训练大约需要一到两个小时。如果您不想等待那么长时间,可以减少 epoch 的数量,但当然模型的准确性可能会降低。如果 Colab 会话超时,请确保快速重新连接,否则 Colab 运行时将被销毁。
这个模型不处理文本预处理,所以让我们将其包装在一个最终模型中,包含tf.keras.layers.TextVectorization
层作为第一层,加上一个tf.keras.layers.Lambda
层,从字符 ID 中减去 2,因为我们暂时不使用填充和未知标记:
shakespeare_model = tf.keras.Sequential([ text_vec_layer, tf.keras.layers.Lambda(lambda X: X - 2), # no <PAD> or <UNK> tokens model ])
现在让我们用它来预测句子中的下一个字符:
>>> y_proba = shakespeare_model.predict(["To be or not to b"])[0, -1] >>> y_pred = tf.argmax(y_proba) # choose the most probable character ID >>> text_vec_layer.get_vocabulary()[y_pred + 2] 'e'
太好了,模型正确预测了下一个字符。现在让我们使用这个模型假装我们是莎士比亚!
生成虚假的莎士比亚文本
使用 char-RNN 模型生成新文本时,我们可以将一些文本输入模型,让模型预测最有可能的下一个字母,将其添加到文本末尾,然后将扩展后的文本提供给模型猜测下一个字母,依此类推。这被称为贪婪解码。但在实践中,这经常导致相同的单词一遍又一遍地重复。相反,我们可以随机采样下一个字符,概率等于估计的概率,使用 TensorFlow 的tf.random.categorical()
函数。这将生成更多样化和有趣的文本。categorical()
函数会根据类别对数概率(logits)随机采样随机类别索引。例如:
>>> log_probas = tf.math.log([[0.5, 0.4, 0.1]]) # probas = 50%, 40%, and 10% >>> tf.random.set_seed(42) >>> tf.random.categorical(log_probas, num_samples=8) # draw 8 samples <tf.Tensor: shape=(1, 8), dtype=int64, numpy=array([[0, 1, 0, 2, 1, 0, 0, 1]])>
为了更好地控制生成文本的多样性,我们可以将 logits 除以一个称为温度的数字,我们可以根据需要进行调整。接近零的温度偏好高概率字符,而高温度使所有字符具有相等的概率。在生成相对严格和精确的文本(如数学方程式)时,通常更喜欢较低的温度,而在生成更多样化和创意性的文本时,更喜欢较高的温度。以下next_char()
自定义辅助函数使用这种方法选择要添加到输入文本中的下一个字符:
def next_char(text, temperature=1): y_proba = shakespeare_model.predict([text])[0, -1:] rescaled_logits = tf.math.log(y_proba) / temperature char_id = tf.random.categorical(rescaled_logits, num_samples=1)[0, 0] return text_vec_layer.get_vocabulary()[char_id + 2]
接下来,我们可以编写另一个小的辅助函数,它将重复调用next_char()
以获取下一个字符并将其附加到给定的文本中:
def extend_text(text, n_chars=50, temperature=1): for _ in range(n_chars): text += next_char(text, temperature) return text
现在我们准备生成一些文本!让我们尝试不同的温度值:
>>> tf.random.set_seed(42) >>> print(extend_text("To be or not to be", temperature=0.01)) To be or not to be the duke as it is a proper strange death, and the >>> print(extend_text("To be or not to be", temperature=1)) To be or not to behold? second push: gremio, lord all, a sistermen, >>> print(extend_text("To be or not to be", temperature=100)) To be or not to bef ,mt'&o3fpadm!$ wh!nse?bws3est--vgerdjw?c-y-ewznq
莎士比亚似乎正在遭受一场热浪。为了生成更具说服力的文本,一个常见的技术是仅从前 k 个字符中采样,或者仅从总概率超过某个阈值的最小一组顶级字符中采样(这被称为核心采样)。另外,您可以尝试使用波束搜索,我们将在本章后面讨论,或者使用更多的GRU
层和更多的神经元每层,训练更长时间,并在需要时添加一些正则化。还要注意,模型目前无法学习比length
更长的模式,length
只是 100 个字符。您可以尝试将此窗口扩大,但这也会使训练更加困难,即使 LSTM 和 GRU 单元也无法处理非常长的序列。另一种替代方法是使用有状态的 RNN。
有状态的 RNN
到目前为止,我们只使用了无状态的 RNN:在每次训练迭代中,模型从一个全零的隐藏状态开始,然后在每个时间步更新这个状态,在最后一个时间步之后,将其丢弃,因为不再需要。如果我们指示 RNN 在处理训练批次后保留这个最终状态,并将其用作下一个训练批次的初始状态,会怎样呢?这样,模型可以学习长期模式,尽管只通过短序列进行反向传播。这被称为有状态的 RNN。让我们看看如何构建一个。
首先,注意到有状态的 RNN 只有在批次中的每个输入序列从上一个批次中对应序列的确切位置开始时才有意义。因此,我们构建有状态的 RNN 需要做的第一件事是使用顺序且不重叠的输入序列(而不是我们用来训练无状态 RNN 的洗牌和重叠序列)。在创建tf.data.Dataset
时,因此在调用window()
方法时必须使用shift=length
(而不是shift=1
)。此外,我们必须不调用shuffle()
方法。
不幸的是,为有状态的 RNN 准备数据集时,批处理比为无状态的 RNN 更加困难。实际上,如果我们调用batch(32)
,那么 32 个连续窗口将被放入同一个批次中,接下来的批次将不会继续每个窗口的位置。第一个批次将包含窗口 1 到 32,第二个批次将包含窗口 33 到 64,因此如果您考虑,比如说,每个批次的第一个窗口(即窗口 1 和 33),您会发现它们不是连续的。这个问题的最简单解决方案就是只使用批量大小为 1。以下的to_dataset_for_stateful_rnn()
自定义实用函数使用这种策略来为有状态的 RNN 准备数据集:
def to_dataset_for_stateful_rnn(sequence, length): ds = tf.data.Dataset.from_tensor_slices(sequence) ds = ds.window(length + 1, shift=length, drop_remainder=True) ds = ds.flat_map(lambda window: window.batch(length + 1)).batch(1) return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1) stateful_train_set = to_dataset_for_stateful_rnn(encoded[:1_000_000], length) stateful_valid_set = to_dataset_for_stateful_rnn(encoded[1_000_000:1_060_000], length) stateful_test_set = to_dataset_for_stateful_rnn(encoded[1_060_000:], length)
图 16-2 总结了这个函数的主要步骤。
图 16-2。为有状态的 RNN 准备连续序列片段的数据集
批处理更加困难,但并非不可能。例如,我们可以将莎士比亚的文本分成 32 个等长的文本,为每个文本创建一个连续输入序列的数据集,最后使用tf.data.Dataset.zip(datasets).map(lambda *windows: tf.stack(windows))
来创建正确的连续批次,其中批次中的第n个输入序列从上一个批次中的第n个输入序列结束的地方开始(请参阅笔记本获取完整代码)。
现在,让我们创建有状态的 RNN。在创建每个循环层时,我们需要将stateful
参数设置为True
,因为有状态的 RNN 需要知道批量大小(因为它将为批次中的每个输入序列保留一个状态)。因此,我们必须在第一层中设置batch_input_shape
参数。请注意,我们可以将第二维度留空,因为输入序列可以具有任意长度:
model = tf.keras.Sequential([ tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16, batch_input_shape=[1, None]), tf.keras.layers.GRU(128, return_sequences=True, stateful=True), tf.keras.layers.Dense(n_tokens, activation="softmax") ])
在每个时期结束时,我们需要在回到文本开头之前重置状态。为此,我们可以使用一个小的自定义 Keras 回调:
class ResetStatesCallback(tf.keras.callbacks.Callback): def on_epoch_begin(self, epoch, logs): self.model.reset_states()
现在我们可以编译模型并使用我们的回调函数进行训练:
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam", metrics=["accuracy"]) history = model.fit(stateful_train_set, validation_data=stateful_valid_set, epochs=10, callbacks=[ResetStatesCallback(), model_ckpt])
提示
训练完这个模型后,只能用它来对与训练时相同大小的批次进行预测。为了避免这个限制,创建一个相同的无状态模型,并将有状态模型的权重复制到这个模型中。
有趣的是,尽管 char-RNN 模型只是训练来预测下一个字符,但这看似简单的任务实际上也需要它学习一些更高级的任务。例如,要找到“Great movie, I really”之后的下一个字符,了解到这句话是积极的是有帮助的,所以下一个字符更有可能是“l”(代表“loved”)而不是“h”(代表“hated”)。事实上,OpenAI 的 Alec Radford 和其他研究人员在一篇 2017 年的论文中描述了他们如何在大型数据集上训练了一个类似于大型 char-RNN 模型,并发现其中一个神经元表现出色地作为情感分析分类器:尽管该模型在没有任何标签的情况下进行了训练,但他们称之为情感神经元达到了情感分析基准测试的最新性能。这预示并激励了 NLP 中的无监督预训练。
但在探索无监督预训练之前,让我们将注意力转向单词级模型以及如何在监督方式下用它们进行情感分析。在这个过程中,您将学习如何使用掩码处理可变长度的序列。
情感分析
生成文本可能很有趣且有教育意义,但在实际项目中,自然语言处理的最常见应用之一是文本分类,尤其是情感分析。如果在 MNIST 数据集上进行图像分类是计算机视觉的“Hello world!”,那么在 IMDb 评论数据集上进行情感分析就是自然语言处理的“Hello world!”。IMDb 数据集包含了来自著名的互联网电影数据库的 50,000 条英文电影评论(25,000 条用于训练,25,000 条用于测试),每条评论都有一个简单的二进制目标,表示其是否为负面(0)或正面(1)。就像 MNIST 一样,IMDb 评论数据集之所以受欢迎是有充分理由的:它足够简单,可以在笔记本电脑上在合理的时间内处理,但足够具有挑战性和有趣。
让我们使用 TensorFlow Datasets 库加载 IMDb 数据集(在第十三章中介绍)。我们将使用训练集的前 90%进行训练,剩下的 10%用于验证:
import tensorflow_datasets as tfds raw_train_set, raw_valid_set, raw_test_set = tfds.load( name="imdb_reviews", split=["train[:90%]", "train[90%:]", "test"], as_supervised=True ) tf.random.set_seed(42) train_set = raw_train_set.shuffle(5000, seed=42).batch(32).prefetch(1) valid_set = raw_valid_set.batch(32).prefetch(1) test_set = raw_test_set.batch(32).prefetch(1)
提示
如果您愿意,Keras 还包括一个用于加载 IMDb 数据集的函数:tf.keras.datasets.imdb.load_data()
。评论已经被预处理为单词 ID 的序列。
让我们检查一些评论:
>>> for review, label in raw_train_set.take(4): ... print(review.numpy().decode("utf-8")) ... print("Label:", label.numpy()) ... This was an absolutely terrible movie. Don't be lured in by Christopher [...] Label: 0 I have been known to fall asleep during films, but this is usually due to [...] Label: 0 Mann photographs the Alberta Rocky Mountains in a superb fashion, and [...] Label: 0 This is the kind of film for a snowy Sunday afternoon when the rest of the [...] Label: 1
有些评论很容易分类。例如,第一条评论中的第一句话包含“terrible movie”这几个词。但在许多情况下,事情并不那么简单。例如,第三条评论一开始是积极的,尽管最终是一个负面评论(标签 0)。
为了为这个任务构建一个模型,我们需要预处理文本,但这次我们将其分成单词而不是字符。为此,我们可以再次使用tf.keras.layers.TextVectorization
层。请注意,它使用空格来识别单词边界,在某些语言中可能效果不佳。例如,中文书写不使用单词之间的空格,越南语甚至在单词内部也使用空格,德语经常将多个单词连接在一起,没有空格。即使在英语中,空格也不总是分词的最佳方式:想想“San Francisco”或“#ILoveDeepLearning”。
幸运的是,有解决这些问题的解决方案。在2016 年的一篇论文,爱丁堡大学的 Rico Sennrich 等人探索了几种在子词级别对文本进行标记和去标记化的方法。这样,即使您的模型遇到了以前从未见过的罕见单词,它仍然可以合理地猜测它的含义。例如,即使模型在训练期间从未见过单词“smartest”,如果它学会了单词“smart”并且还学会了后缀“est”表示“最”,它可以推断出“smartest”的含义。作者评估的技术之一是字节对编码(BPE)。BPE 通过将整个训练集拆分为单个字符(包括空格),然后重复合并最频繁的相邻对,直到词汇表达到所需大小。
Google 的 Taku Kudo 在 2018 年发表的一篇论文进一步改进了子词标记化,通常消除了标记化之前需要进行特定于语言的预处理的需要。此外,该论文提出了一种称为子词正则化的新型正则化技术,通过在训练期间在标记化中引入一些随机性来提高准确性和稳健性:例如,“New England”可以被标记为“New”+“England”,或“New”+“Eng”+“land”,或简单地“New England”(只有一个标记)。Google 的SentencePiece项目提供了一个开源实现,该实现在 Taku Kudo 和 John Richardson 的一篇论文中有描述。
TensorFlow Text库还实现了各种标记化策略,包括WordPiece(BPE 的变体),最后但同样重要的是,Hugging Face 的 Tokenizers 库实现了一系列极快的标记化器。
然而,对于英语中的 IMDb 任务,使用空格作为标记边界应该足够好。因此,让我们继续创建一个TextVectorization
层,并将其调整到训练集。我们将词汇表限制为 1,000 个标记,包括最常见的 998 个单词以及一个填充标记和一个未知单词的标记,因为很少见的单词不太可能对这个任务很重要,并且限制词汇表大小将减少模型需要学习的参数数量:
vocab_size = 1000 text_vec_layer = tf.keras.layers.TextVectorization(max_tokens=vocab_size) text_vec_layer.adapt(train_set.map(lambda reviews, labels: reviews))
最后,我们可以创建模型并训练它:
embed_size = 128 tf.random.set_seed(42) model = tf.keras.Sequential([ text_vec_layer, tf.keras.layers.Embedding(vocab_size, embed_size), tf.keras.layers.GRU(128), tf.keras.layers.Dense(1, activation="sigmoid") ]) model.compile(loss="binary_crossentropy", optimizer="nadam", metrics=["accuracy"]) history = model.fit(train_set, validation_data=valid_set, epochs=2)
第一层是我们刚刚准备的TextVectorization
层,接着是一个Embedding
层,将单词 ID 转换为嵌入。嵌入矩阵需要每个词汇表中的标记一行(vocab_size
),每个嵌入维度一列(此示例使用 128 维,但这是一个可以调整的超参数)。接下来我们使用一个GRU
层和一个具有单个神经元和 sigmoid 激活函数的Dense
层,因为这是一个二元分类任务:模型的输出将是评论表达对电影积极情绪的估计概率。然后我们编译模型,并在我们之前准备的数据集上进行几个时期的拟合(或者您可以训练更长时间以获得更好的结果)。
遗憾的是,如果运行此代码,通常会发现模型根本无法学习任何东西:准确率保持接近 50%,不比随机机会好。为什么呢?评论的长度不同,因此当TextVectorization
层将它们转换为标记 ID 序列时,它使用填充标记(ID 为 0)填充较短的序列,使它们与批次中最长序列一样长。结果,大多数序列以许多填充标记结尾——通常是几十甚至几百个。即使我们使用的是比SimpleRNN
层更好的GRU
层,它的短期记忆仍然不太好,因此当它经过许多填充标记时,它最终会忘记评论的内容!一个解决方案是用等长的句子批次喂给模型(这也加快了训练速度)。另一个解决方案是让 RNN 忽略填充标记。这可以通过掩码来实现。
掩码
使用 Keras 让模型忽略填充标记很简单:在创建Embedding
层时简单地添加mask_zero=True
。这意味着所有下游层都会忽略填充标记(其 ID 为 0)。就是这样!如果对先前的模型进行几个时期的重新训练,您会发现验证准确率很快就能达到 80%以上。
这种工作方式是,Embedding
层创建一个等于tf.math.not_equal(inputs, 0)
的掩码张量:它是一个布尔张量,形状与输入相同,如果标记 ID 为 0,则等于False
,否则等于True
。然后,该掩码张量会被模型自动传播到下一层。如果该层的call()
方法有一个mask
参数,那么它会自动接收到掩码。这使得该层能够忽略适当的时间步。每个层可能会以不同的方式处理掩码,但通常它们只是忽略被掩码的时间步(即掩码为False
的时间步)。例如,当循环层遇到被掩码的时间步时,它只是复制前一个时间步的输出。
接下来,如果该层的supports_masking
属性为True
,那么掩码会自动传播到下一层。只要层具有supports_masking=True
,它就会继续这样传播。例如,当return_sequences=True
时,循环层的supports_masking
属性为True
,但当return_sequences=False
时,它为False
,因为在这种情况下不再需要掩码。因此,如果您有一个具有多个return_sequences=True
的循环层,然后是一个return_sequences=False
的循环层的模型,那么掩码将自动传播到最后一个循环层:该层将使用掩码来忽略被掩码的步骤,但不会进一步传播掩码。同样,如果在我们刚刚构建的情感分析模型中创建Embedding
层时设置了mask_zero=True
,那么GRU
层将自动接收和使用掩码,但不会进一步传播,因为return_sequences
没有设置为True
。
提示
一些层在将掩码传播到下一层之前需要更新掩码:它们通过实现compute_mask()
方法来实现,该方法接受两个参数:输入和先前的掩码。然后计算更新后的掩码并返回。compute_mask()
的默认实现只是返回先前的掩码而没有更改。
许多 Keras 层支持掩码:SimpleRNN
、GRU
、LSTM
、Bidirectional
、Dense
、TimeDistributed
、Add
等(都在tf.keras.layers
包中)。然而,卷积层(包括Conv1D
)不支持掩码——它们如何支持掩码并不明显。
如果掩码一直传播到输出,那么它也会应用到损失上,因此被掩码的时间步将不会对损失产生贡献(它们的损失将为 0)。这假设模型输出序列,这在我们的情感分析模型中并不是这样。
警告
LSTM
和GRU
层具有基于 Nvidia 的 cuDNN 库的优化实现。但是,此实现仅在所有填充标记位于序列末尾时支持遮罩。它还要求您使用几个超参数的默认值:activation
、recurrent_activation
、recurrent_dropout
、unroll
、use_bias
和reset_after
。如果不是这种情况,那么这些层将退回到(速度慢得多的)默认 GPU 实现。
如果要实现支持遮罩的自定义层,应在call()
方法中添加一个mask
参数,并显然使方法使用该遮罩。此外,如果遮罩必须传播到下一层,则应在构造函数中设置self.supports_masking=True
。如果必须在传播之前更新遮罩,则必须实现compute_mask()
方法。
如果您的模型不以Embedding
层开头,可以使用tf.keras.layers.Masking
层代替:默认情况下,它将遮罩设置为tf.math.reduce_any(tf.math.not_equal(X, 0), axis=-1)
,意味着最后一个维度全是零的时间步将在后续层中被遮罩。
使用遮罩层和自动遮罩传播对简单模型效果最好。对于更复杂的模型,例如需要将Conv1D
层与循环层混合时,并不总是适用。在这种情况下,您需要显式计算遮罩并将其传递给适当的层,可以使用函数式 API 或子类 API。例如,以下模型与之前的模型等效,只是使用函数式 API 构建,并手动处理遮罩。它还添加了一点辍学,因为之前的模型略微过拟合:
inputs = tf.keras.layers.Input(shape=[], dtype=tf.string) token_ids = text_vec_layer(inputs) mask = tf.math.not_equal(token_ids, 0) Z = tf.keras.layers.Embedding(vocab_size, embed_size)(token_ids) Z = tf.keras.layers.GRU(128, dropout=0.2)(Z, mask=mask) outputs = tf.keras.layers.Dense(1, activation="sigmoid")(Z) model = tf.keras.Model(inputs=[inputs], outputs=[outputs])
遮罩的最后一种方法是使用不规则张量来向模型提供输入。实际上,您只需在创建TextVectorization
层时设置ragged=True
,以便将输入序列表示为不规则张量:
>>> text_vec_layer_ragged = tf.keras.layers.TextVectorization( ... max_tokens=vocab_size, ragged=True) ... >>> text_vec_layer_ragged.adapt(train_set.map(lambda reviews, labels: reviews)) >>> text_vec_layer_ragged(["Great movie!", "This is DiCaprio's best role."]) <tf.RaggedTensor [[86, 18], [11, 7, 1, 116, 217]]>
将这种不规则张量表示与使用填充标记的常规张量表示进行比较:
>>> text_vec_layer(["Great movie!", "This is DiCaprio's best role."]) <tf.Tensor: shape=(2, 5), dtype=int64, numpy= array([[ 86, 18, 0, 0, 0], [ 11, 7, 1, 116, 217]])>
Keras 的循环层内置支持不规则张量,因此您无需执行其他操作:只需在模型中使用此TextVectorization
层。无需传递mask_zero=True
或显式处理遮罩——这一切都已为您实现。这很方便!但是,截至 2022 年初,Keras 中对不规则张量的支持仍然相对较新,因此存在一些问题。例如,目前无法在 GPU 上运行时将不规则张量用作目标(但在您阅读这些内容时可能已解决)。
无论您喜欢哪种遮罩方法,在训练此模型几个时期后,它将变得非常擅长判断评论是积极的还是消极的。如果使用tf.keras.callbacks.TensorBoard()
回调,您可以在 TensorBoard 中可视化嵌入,看到诸如“棒极了”和“惊人”的词逐渐聚集在嵌入空间的一侧,而诸如“糟糕”和“可怕”的词聚集在另一侧。有些词并不像您可能期望的那样积极(至少在这个模型中),比如“好”这个词,可能是因为许多负面评论包含短语“不好”。
重用预训练的嵌入和语言模型
令人印象深刻的是,这个模型能够基于仅有 25,000 条电影评论学习到有用的词嵌入。想象一下,如果我们有数十亿条评论来训练,这些嵌入会有多好!不幸的是,我们没有,但也许我们可以重用在其他(非常)大型文本语料库上训练的词嵌入(例如,亚马逊评论,可在 TensorFlow 数据集上找到)?毕竟,“amazing”这个词无论是用来谈论电影还是其他事物,通常都有相同的含义。此外,也许即使它们是在另一个任务上训练的,嵌入也对情感分析有用:因为“awesome”和“amazing”这样的词有相似的含义,它们很可能会在嵌入空间中聚集,即使是用于预测句子中的下一个词这样的任务。如果所有积极词和所有消极词形成簇,那么这对情感分析将是有帮助的。因此,我们可以不训练词嵌入,而是下载并使用预训练的嵌入,例如谷歌的Word2vec 嵌入,斯坦福的GloVe 嵌入,或 Facebook 的FastText 嵌入。
使用预训练词嵌入在几年内很受欢迎,但这种方法有其局限性。特别是,一个词无论上下文如何,都有一个表示。例如,“right”这个词在“left and right”和“right and wrong”中以相同的方式编码,尽管它们表示两个非常不同的含义。为了解决这个限制,Matthew Peters 在 2018 年引入了来自语言模型的嵌入(ELMo):这些是从深度双向语言模型的内部状态中学习到的上下文化词嵌入。与仅在模型中使用预训练嵌入不同,您可以重用预训练语言模型的一部分。
大约在同一时间,Jeremy Howard 和 Sebastian Ruder 的通用语言模型微调(ULMFiT)论文展示了无监督预训练在 NLP 任务中的有效性:作者们使用自监督学习(即从数据自动生成标签)在庞大的文本语料库上训练了一个 LSTM 语言模型,然后在各种任务上进行微调。他们的模型在六个文本分类任务上表现优异(在大多数情况下将错误率降低了 18-24%)。此外,作者们展示了一个仅使用 100 个标记示例进行微调的预训练模型可以达到与从头开始训练 10,000 个示例相同的性能。在 ULMFiT 论文之前,使用预训练模型只是计算机视觉中的常态;在 NLP 领域,预训练仅限于词嵌入。这篇论文标志着 NLP 的一个新时代的开始:如今,重用预训练语言模型已成为常态。
例如,让我们基于通用句子编码器构建一个分类器,这是由谷歌研究人员团队在 2018 年介绍的模型架构。这个模型基于 transformer 架构,我们将在本章后面讨论。方便的是,这个模型可以在 TensorFlow Hub 上找到。
import os import tensorflow_hub as hub os.environ["TFHUB_CACHE_DIR"] = "my_tfhub_cache" model = tf.keras.Sequential([ hub.KerasLayer("https://tfhub.dev/google/universal-sentence-encoder/4", trainable=True, dtype=tf.string, input_shape=[]), tf.keras.layers.Dense(64, activation="relu"), tf.keras.layers.Dense(1, activation="sigmoid") ]) model.compile(loss="binary_crossentropy", optimizer="nadam", metrics=["accuracy"]) model.fit(train_set, validation_data=valid_set, epochs=10)
提示
这个模型非常庞大,接近 1GB 大小,因此下载可能需要一些时间。默认情况下,TensorFlow Hub 模块保存在临时目录中,并且每次运行程序时都会重新下载。为了避免这种情况,您必须将TFHUB_CACHE_DIR
环境变量设置为您选择的目录:模块将保存在那里,只会下载一次。
请注意,TensorFlow Hub 模块 URL 的最后部分指定我们想要模型的第 4 个版本。这种版本控制确保如果 TF Hub 上发布了新的模块版本,它不会破坏我们的模型。方便的是,如果你只在 Web 浏览器中输入这个 URL,你将得到这个模块的文档。
还要注意,在创建hub.KerasLayer
时,我们设置了trainable=True
。这样,在训练期间,预训练的 Universal Sentence Encoder 会进行微调:通过反向传播调整一些权重。并非所有的 TensorFlow Hub 模块都是可微调的,所以确保查看你感兴趣的每个预训练模块的文档。
训练后,这个模型应该能达到超过 90%的验证准确率。这实际上非常好:如果你尝试自己执行这个任务,你可能只会稍微好一点,因为许多评论中既包含积极的评论,也包含消极的评论。对这些模棱两可的评论进行分类就像抛硬币一样。
到目前为止,我们已经看过使用 char-RNN 进行文本生成,以及使用基于可训练嵌入的单词级 RNN 模型进行情感分析,以及使用来自 TensorFlow Hub 的强大预训练语言模型。在接下来的部分中,我们将探索另一个重要的 NLP 任务:神经机器翻译(NMT)。
Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(七)(2)https://developer.aliyun.com/article/1482449