TensorFlow 实战(四)(3)https://developer.aliyun.com/article/1522806
噪声对比估计(NCE)
我们将研究驱动这些方法的主要动机,但不会深入探讨具体细节,因为这被认为超出了本书的范围。有关这些主题的详细信息,请参阅ruder.io/word-embeddings-softmax
。NCE 仅使用由真实目标和 k 个随机 logit 样本(称为噪声)索引的 logit 来计算损失。您在噪声样本中匹配真实数据分布的越多,结果就越好。具体来说,如果真实目标是s,并且在索引s处的 logit 称为s[i],则使用以下损失:
在这里,σ表示 sigmoid 激活是神经网络中常用的激活函数,并且计算为σ (x) = 1/(1 + e^(-x)),其中s[i]表示真实目标 i 的 logit 值,j 表示从词汇表P[n]上的预定义分布中采样的索引。
分层 softmax
与标准 softmax 不同,其中每个节点代表词汇表中的一个元素的平坦结构相比,分层 softmax 将词汇表中的单词表示为二叉树中的叶节点,并且任务变成选择向左还是向右以达到正确的节点。以下图示了形成过程
使用分层 softmax 时,计算层的最大层数。显然,为了推断给定前一序列的单词的概率,该层最多只需进行三步计算(由黑色路径表示),而不是评估所有七个可能的单词。
最终层的分层 softmax 表示。黑色路径表示模型必须遵循的路径,以计算 P(home| I went)的概率。
接下来,我们将看到如何在词汇量很大的情况下处理语言。
10.1.3 词汇量太大?N-gram 拯救
在这里,我们开始定义各种文本预处理器和数据流水线的第一步。我们怀疑在大词汇量的情况下继续前进会对我们的建模之旅产生不利影响。让我们找到一些减少词汇量的方法。鉴于儿童故事使用相对简单的语言风格,我们可以将文本表示为 n-gram(以牺牲我们模型的表达能力为代价)。 N-grams 是一种方法,其中单词被分解为固定长度的更细的子单词。例如,句子的二元组(或二元组)
I went to the bookshop
是
"I ", " w", "we", "en", "nt", "t ", " t", "to", "o ", "th", "he", "e ", " b", "bo", "oo", "ok", "ks", "sh", "ho", "op"
三个元组将是
"I w", " we", "wen", "ent", "nt ", "t t", " to", "to ", "o t", " th", "the", "he ", "e b", " bo", "boo", "ook", "oks", "ksh", "sho", "hop"
单元组(或一元组)将简单地是各个字符。换句话说,我们正在移动一个固定长度的窗口(步长为 1),同时每次读取该窗口内的字符。您也可以通过将窗口移动到长度相等的步长来生成没有重叠的 n-gram。例如,没有重叠的二元组将是
"I ", "we", "nt", " t", "o ", "th", "e ", "bo", "ok", "sh", "op"
使用哪种方法取决于您的用例。对于语言建模任务,使用非重叠方法是有意义的。这是因为通过连接我们生成的 n-gram,我们可以轻松生成可读的文本。对于某些用例,非重叠方法可能不利,因为它导致文本的表示更粗糙,因为它没有捕获文本中出现的所有不同的 n-gram。
通过使用二元组而不是单词来开发您的词汇表,您可以将词汇表的大小减少到一个显著的因子。随着我们很快会看到的 n-gram 方法的许多其他优势。我们将编写一个函数来生成给定文本字符串的 n-gram:
def get_ngrams(text, n): return [text[i:i+n] for i in range(0,len(text),n)]
我们在这里所做的一切就是从文本的开头到结尾以步长 n 进行移动,并从位置 i 到 i+n 读取字符序列。我们可以测试这在样本文本上的表现如何:
test_string = "I like chocolates" print("Original: {}".format(test_string)) for i in list(range(3)): print("\t{}-grams: {}".format(i+1, get_ngrams(test_string, i+1)))
这将打印以下输出:
Original: I like chocolates 1-grams: ['I', ' ', 'l', 'i', 'k', 'e', ' ', 'c', 'h', 'o', 'c', ➥ 'o', 'l', 'a', 't', 'e', 's'] 2-grams: ['I ', 'li', 'ke', ' c', 'ho', 'co', 'la', 'te', 's'] 3-grams: ['I l', 'ike', ' ch', 'oco', 'lat', 'es']
现在让我们重复使用 n-gram 而不是单词来分析词汇量的过程:
from itertools import chain from collections import Counter # Create a counter with the bi-grams ngrams = 2 text = chain(*[get_ngrams(s, ngrams) for s in stories]) cnt = Counter(text) # Create a pandas series with the counter results freq_df = pd.Series(list(cnt.values()), index=list(cnt.keys())).sort_values(ascending=False)
现在,如果我们检查文本中至少出现 10 次的单词数
n_vocab = (freq_df>=10).sum() print("Size of vocabulary: {}".format(n_vocab))
我们将会看到
Size of vocabulary: 735
哇!与我们有的 15,000 个单词相比,735 个要小得多,更易于管理。
n-gram 的优势
这是使用 n-gram 而不是单词的主要优势之一:
- 对于小 n 的有限数量的 n-gram 限制了词汇表的大小,从而导致了记忆和计算优势。
- N-grams 导致词汇表外单词的机会减少,因为通常可以使用过去看到的 n-grams 构造看不见的单词。
10.1.4 文本分词
我们现在将对文本进行分词(即,将字符串拆分为一组较小的标记——单词)。在本节结束时,您将已经为文本生成的二元组定义并拟合了一个标记生成器。首先,让我们从 TensorFlow 中导入 Tokenizer:
from tensorflow.keras.preprocessing.text import Tokenizer
我们不需要进行任何预处理,希望将文本按原样转换为单词 ID。我们将定义 num_words 参数来限制词汇表的大小,以及一个 oov_token,该 token 将用于替换训练语料库中出现次数少于 10 次的所有 n-gram:
tokenizer = Tokenizer(num_words=n_vocab, oov_token='unk', lower=False)
让我们从训练数据的故事中生成 n-gram。train_ngram_stories 将是一个字符串列表的列表,其中内部列表表示单个故事的二元组列表,外部列表表示训练数据集中的所有故事:
train_ngram_stories = [get_ngrams(s,ngrams) for s in stories]
我们将在训练故事的二元组上拟合 Tokenizer:
tokenizer.fit_on_texts(train_ngram_stories)
现在使用已经拟合 Tokenizer,该 Tokenizer 使用训练数据的二元组将所有训练、验证和测试故事转换为 ID 序列:
train_data_seq = tokenizer.texts_to_sequences(train_ngram_stories) val_ngram_stories = [get_ngrams(s,ngrams) for s in val_stories] val_data_seq = tokenizer.texts_to_sequences(val_ngram_stories) test_ngram_stories = [get_ngrams(s,ngrams) for s in test_stories] test_data_seq = tokenizer.texts_to_sequences(test_ngram_stories)
通过打印一些测试数据来分析转换为单词 ID 后数据的样子。具体来说,我们将打印前三个故事字符串(test_stories)、n-gram 字符串(test_ngram_stories)和单词 ID 序列(test_data_seq):
Original: the yellow fairy book the cat and the mouse in par n-grams: ['th', 'e ', 'ye', 'll', 'ow', ' f', 'ai', 'ry', ' b', 'oo', 'k ', ➥ 'th', 'e ', 'ca', 't ', 'an', 'd ', 'th', 'e ', 'mo', 'us', 'e ', 'in', ➥ ' p', 'ar'] Word ID sequence: [6, 2, 215, 54, 84, 35, 95, 146, 26, 97, 123, 6, 2, 128, ➥ 8, 15, 5, 6, 2, 147, 114, 2, 17, 65, 52] Original: chapter i. down the rabbit-hole alice was beginnin n-grams: ['ch', 'ap', 'te', 'r ', 'i.', ' d', 'ow', 'n ', 'th', 'e ', 'ra', ➥ 'bb', 'it', '-h', 'ol', 'e ', 'al', 'ic', 'e ', 'wa', 's ', 'be', 'gi', ➥ 'nn', 'in'] Word ID sequence: [93, 207, 57, 19, 545, 47, 84, 18, 6, 2, 126, 344, ➥ 38, 400, 136, 2, 70, 142, 2, 66, 9, 71, 218, 251, 17] Original: a patent medicine testimonial `` you might as well n-grams: ['a ', 'pa', 'te', 'nt', ' m', 'ed', 'ic', 'in', 'e ', 'te', 'st', ➥ 'im', 'on', 'ia', 'l ', '``', ' y', 'ou', ' m', 'ig', 'ht', ' a', 's ', ➥ 'we', 'll'] Word ID sequence: [60, 179, 57, 78, 33, 31, 142, 17, 2, 57, 50, 125, 43, ➥ 266, 56, 122, 92, 29, 33, 152, 149, 7, 9, 103, 54]
10.1.5 定义一个 tf.data pipeline
现在预处理已经完成,我们将文本转换为单词 ID 序列。我们可以定义 tf.data pipeline,该 pipeline 将提供最终处理好的数据,准备好被模型使用。流程中涉及的主要步骤如图 10.1 所示。
图 10.1 数据 pipeline 的高级步骤。首先将单个故事分解为固定长度的序列(或窗口)。然后,从窗口化的序列中生成输入和目标。
与之前一样,让我们将单词 ID 语料库定义为 tf.RaggedTensor 对象,因为语料库中的句子具有可变的序列长度:
text_ds = tf.data.Dataset.from_tensor_slices(tf.ragged.constant(data_seq))
请记住,不规则张量是具有可变大小维度的张量。然后,如果 shuffle 设置为 True(例如,训练时间),我们将对数据进行洗牌,以便故事以随机顺序出现:
if shuffle: text_ds = text_ds.shuffle(buffer_size=len(data_seq)//2)
现在来看棘手的部分。在本节中,我们将看到如何从任意长的文本中生成固定大小的窗口化序列。我们将通过一系列步骤来实现这一点。与 pipeline 的其余部分相比,本节可能略微复杂。这是因为将会有中间步骤导致三级嵌套的数据集。让我们尽可能详细地了解一下这一点。
首先,让我们清楚我们需要实现什么。我们需要为每个单独的故事 S 执行以下步骤:
- 创建一个包含故事 S 的单词 ID 的 tf.data.Dataset() 对象作为其项。
- 使用 n_seq+1 大小的窗口和预定义的移位调用 tf.data.Dataset.window() 函数来窗口化单词 ID,窗口() 函数为每个故事 S 返回一个 WindowDataset 对象。
之后,您将获得一个三级嵌套的数据结构,其规范为
tf.data.Dataset( # <- From the original dataset tf.data.Dataset( # <- From inner dataset containing word IDs of story S only tf.data.WindowDataset(...) # <- Dataset returned by the window() function ) )
我们需要展开这个数据集并解开数据集中的嵌套,最终得到一个平坦的 tf.data.Dataset。您可以使用 tf.data.Dataset.flat_map()函数来去除这些内部嵌套。我们将很快看到如何使用 flat_map()函数。具体来说,我们需要使用两个 flat_map 调用来去除两层嵌套,以便最终得到只包含简单张量作为元素的平坦原始数据集。在 TensorFlow 中,可以使用以下代码行来实现此过程:
text_ds = text_ds.flat_map( lambda x: tf.data.Dataset.from_tensor_slices( x ).window( n_seq+1,shift=shift ).flat_map( lambda window: window.batch(n_seq+1, drop_remainder=True) ) ) )
这里我们所做的是:首先,我们从一个单一的故事(x)创建一个 tf.data.Dataset 对象,然后在此上调用 tf.data.Dataset.window()函数以创建窗口序列。此窗口序列包含窗口,其中每个窗口都是故事 x 中 n_seq+1 个连续元素的序列。
然后我们调用 tf.data.Dataset.flat_map()函数,在每个窗口元素上,我们将所有单独的 ID 作为一个批次。换句话说,单个窗口元素会产生一个包含该窗口中所有元素的单个批次。确保使用 drop_remainder=True;否则,数据集将返回该窗口中包含较少元素的较小子窗口。使用 tf.data.Dataset.flat_map()而不是 map,确保去除最内层的嵌套。这整个过程称为 tf.data.Dataset.flat_map()调用,该调用立即消除了我们所删除的最内层嵌套后面的下一层嵌套。对于一行代码来说,这是一个相当复杂的过程。如果您还没有完全理解这个过程,我建议您再次仔细阅读一下。
您可能会注意到,我们把窗口大小定义为 n_seq+1 而不是 n_seq。稍后会看到这样做的原因,但是当我们需要从窗口序列生成输入和目标时,使用 n_seq+1 会让我们的生活变得更加容易。
tf.data.Dataset 中 map 和 flat_map 的区别
tf.data.Dataset.map()和 tf.data.Dataset.flat_map()这两个函数可以实现同样的结果,但具有不同的数据集规范。例如,假设数据集
ds = tf.data.Dataset.from_tensor_slices([[1,2,3], [5,6,7]])
使用 tf.data.Dataset.map()函数对元素进行平方
ds = ds.map(lambda x: x**2)
将导致一个具有元素的数据集
[[1, 4, 9], [25, 36, 49]]
如您所见,结果与原始张量具有相同的结构。使用 tf.data.Dataset.flat_map()函数对元素进行平方
ds = ds.flat_map(lambda x: x**2)
将导致一个具有的数据集
[1,4,9,25,36,49]
如您所见,该最内层嵌套已被展平,产生了一个平坦的元素序列。
我们数据管道中最困难的部分已经完成。现在,您已经有了一个平坦的数据集,每个项目都是属于单个故事的 n_seq+1 个连续单词 ID。接下来,我们将在数据上执行窗口级别的洗牌。这与我们进行的第一个洗牌不同,因为那是在故事级别上进行的(即不是窗口级别):
# Shuffle the data (shuffle the order of n_seq+1 long sequences) if shuffle: text_ds = text_ds.shuffle(buffer_size=10*batch_size)
然后,我们将批处理数据,以便每次迭代数据集时都会获得一批窗口:
# Batch the data text_ds = text_ds.batch(batch_size)
最后,我们选择序列长度为 n_seq+1 的原因将变得更清晰。现在我们将序列分为两个版本,其中一个序列将是另一个向右移动 1 位的序列。换句话说,该模型的目标将是向右移动 1 位的输入。例如,如果序列是[0,1,2,3,4],那么得到的两个序列将是[0,1,2,3]和[1,2,3,4]。此外,我们将使用预取来加速数据摄取:
# Split each sequence to an input and a target text_ds = tf.data.Dataset.zip( text_ds.map(lambda x: (x[:,:-1], x[:, 1:])) ).prefetch(buffer_size=tf.data.experimental.AUTOTUNE)
最后,完整的代码可以封装在下一个清单中的函数中。
图 10.3 从自由文本序列创建的 tf.data 管道
def get_tf_pipeline(data_seq, n_seq, batch_size=64, shift=1, shuffle=True): """ Define a tf.data pipeline that takes a set of sequences of text and convert them to fixed length sequences for the model """ text_ds = tf.data.Dataset.from_tensor_slices(tf.ragged.constant(data_seq))❶ if shuffle: text_ds = text_ds.shuffle(buffer_size=len(data_seq)//2) ❷ text_ds = text_ds.flat_map( ❸ lambda x: tf.data.Dataset.from_tensor_slices( x ).window( n_seq+1, shift=shift ).flat_map( lambda window: window.batch(n_seq+1, drop_remainder=True) ) ) if shuffle: text_ds = text_ds.shuffle(buffer_size=10*batch_size) ❹ text_ds = text_ds.batch(batch_size) ❺ text_ds = tf.data.Dataset.zip( text_ds.map(lambda x: (x[:,:-1], x[:, 1:])) ).prefetch(buffer_size=tf.data.experimental.AUTOTUNE) ❻ return text_ds
❶ 从数据 _seq 创建的不规则张量定义一个 tf.dataset。
❷ 如果设置了 shuffle,对数据进行洗牌(洗牌故事顺序)。
❸ 在这里,我们从更长的序列中创建窗口,给定窗口大小和偏移量,然后使用一系列 flat_map 操作来删除在此过程中创建的嵌套。
❹ 对数据进行洗牌(洗牌窗口生成的顺序)。
❺ 批量处理数据。
❻ 将每个序列拆分为输入和目标,并启用预取。
所有这些辛勤工作,如果不看生成的数据,就不会有太多意义。
ds = get_tf_pipeline(train_data_seq, 5, batch_size=6) for a in ds.take(1): print(a)
这将向您展示
( <tf.Tensor: shape=(6, 5), dtype=int32, numpy= array([[161, 12, 69, 396, 17], [ 2, 72, 77, 84, 24], [ 87, 6, 2, 72, 77], [276, 484, 57, 5, 15], [ 75, 150, 3, 4, 11], [ 11, 73, 211, 35, 141]])>, <tf.Tensor: shape=(6, 5), dtype=int32, numpy= array([[ 12, 69, 396, 17, 44], [ 72, 77, 84, 24, 51], [ 6, 2, 72, 77, 84], [484, 57, 5, 15, 67], [150, 3, 4, 11, 73], [ 73, 211, 35, 141, 98]])> )
很好,你可以看到我们得到了一个张量元组作为单个批处理:输入和目标。此外,您可以验证结果的正确性,因为我们清楚地看到目标是向右移动 1 位的输入。最后一件事:我们将将相同的超参数保存到磁盘上。
- n-gram 中的 n
- 词汇量大小
- 序列长度
print("n_grams uses n={}".format(ngrams)) print("Vocabulary size: {}".format(n_vocab)) n_seq=100 print("Sequence length for model: {}".format(n_seq)) with open(os.path.join('models', 'text_hyperparams.pkl'), 'wb') as f: pickle.dump({'n_vocab': n_vocab, 'ngrams':ngrams, 'n_seq': n_seq}, f)
在这里,我们定义序列长度 n_seq=100;这是单个输入/标签序列中我们将拥有的二元组数目。
在本节中,我们了解了用于语言建模的数据,并定义了一个强大的 tf.data 管道,该管道可以将文本序列转换为可直接用于训练模型的输入标签序列。接下来,我们将定义一个用于生成文本的机器学习模型。
练习 1
你有一个序列 x,其值为[1,2,3,4,5,6,7,8,9,0]。你被要求编写一个 tf.data 管道,该管道生成一个输入和目标元组,其中目标是将输入向右移动两个元素(即,输入 1 的目标是 3)。你必须这样做,以便一个单独的输入/目标具有三个元素,并且连续输入序列之间没有重叠。对于前面的序列,它应该生成[([1,2,3], [3,4,5]), ([6,7,8], [8,9,0])]。
10.2 仙境中的 GRU:使用深度学习生成文本
现在我们来到了有奖励的部分:实现一个酷炫的机器学习模型。在上一章中,我们讨论了深度时序模型。鉴于数据的时序性,你可能已经猜到我们将使用其中之一深度时序模型,比如 LSTMs。在本章中,我们将使用一个略有不同的模型,称为门控循环单元(GRUs)。驱动该模型计算的原理与 LSTMs 相同。为了保持我们讨论的清晰度,值得提醒自己 LSTM 模型是如何工作的。
LSTM 是一类专门设计用于处理时序数据的深度神经网络。它们逐个输入地处理一系列输入。LSTM 单元从一个输入到下一个输入,同时在每个时间步产生输出(或状态)(图 10.2)。此外,为了产生给定时间步的输出,LSTMs 使用了它产生的先前输出(或状态)。这个属性对于 LSTMs 非常重要,使它们能够随时间记忆事物。
图 10.2 LSTM 模型概述以及它如何处理随时间展开的输入序列
让我们总结一下我们在上一章中学到的关于 LSTMs 的知识,因为这将帮助我们比较 LSTMs 和 GRUs。一个 LSTM 有两个状态,称为单元状态和输出状态。单元状态负责维护长期记忆,而输出状态可以被视为短期记忆。在 LSTM 单元内部,输出和中间结果受三个门的控制:
- 输入门——控制了在给定时间步中当前输入的多少会对最终输出产生影响
- 遗忘门——控制了多少先前的单元状态影响当前单元状态的计算
- 输出门——控制了当前单元状态对 LSTM 模型产生的最终输出的贡献
GRU 模型是在 Cho 等人的论文“Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation”中介绍的(arxiv.org/pdf/1406.1078v3.pdf
)。GRU 模型可以被认为是 LSTM 模型的简化,同时保持了相当的性能。GRU 单元有两个门:
- 更新门(z[t])——控制了多少先前的隐藏状态传递到当前的隐藏状态
- 重置门(r[t])——控制了多少隐藏状态与新输入一起被重置
与 LSTM 单元不同,GRU 单元只有一个状态向量。总的来说,与 LSTM 模型相比,GRU 有两个主要变化:
- 输入门和遗忘门被合并成一个称为更新门的门(z[t])。输入门被计算为(1-z[t]),而遗忘门保持z[t]。
- 与 LSMT 单元中的两个状态(即单元状态和输出状态)相比,这里只有一个状态(h[t])。
图 10.3 描述了 GRU 单元的各个组件。以下是构成 GRU 单元的方程式的完整列表:
r[t] = σ(W[rh]h[t-1] + W[rx]x[t] + b[r])
z[t] = σ(W[zh]h[t-1] + W[zx]x[t] + b[z])
h̃[t] = tanh(W[h](rh[t-1]) + W[x]x[t] + b)
h[t] = (z[th]h[t-1] + (1 - z[t] )h̃[t]
图 10.3 GRU 单元中发生的计算概览
这次讨论对于理解 GRU 模型不仅非常有帮助,还有助于了解它与 LSTM 单元的区别。你可以在 TensorFlow 中如下定义一个 GRU 单元:
tf.keras.layers.GRU(units=1024, return_state=False, return_sequences=True)
参数 units、return_state 和 return_sequences 的含义与 LSTM 单元的上下文中相同。然而,注意 GRU 单元只有一个状态;因此,如果 return_state=true,则会将相同的状态复制以模拟 LSTM 层的输出状态和单元状态。图 10.4 显示了这些参数对于 GRU 层的作用。
图 10.4 根据 GRU 单元的 return_state 和 return_sequences 参数的不同返回结果的变化
我们现在知道了定义最终模型所需的一切(清单 10.4)。我们的最终模型将包括
- 一个嵌入层
- 一个 GRU 层(1,024 个单元),将所有最终状态向量作为一个形状为 [批量大小,序列长度,单元数] 的张量返回
- 一个具有 512 个单元和 ReLU 激活的 Dense 层
- 一个具有 n_vocab 个单元和 softmax 激活的 Dense 层
清单 10.4 实现语言模型
model = tf.keras.models.Sequential([ tf.keras.layers.Embedding( input_dim=n_vocab+1, output_dim=512,input_shape=(None,) ❶ ), tf.keras.layers.GRU(1024, return_state=False, return_sequences=True), ❷ tf.keras.layers.Dense(512, activation='relu'), ❸ tf.keras.layers.Dense(n_vocab, name='final_out'), ❹ tf.keras.layers.Activation(activation='softmax') ❹ ])
❶ 定义一个嵌入层以学习 bigrams 的单词向量。
❷ 定义一个 LSTM 层。
❸ 定义一个 Dense 层。
❹ 定义一个最终的 Dense 层和 softmax 激活。
你会注意到,在 GRU 后的 Dense 层接收到一个三维张量(而不是传递给 Dense 层的典型二维张量)。Dense 层足够智能,能够处理二维和三维输入。如果输入是三维的(就像在我们的情况下),那么将一个接受 [批量大小,单元数] 张量的 Dense 层通过所有步骤传递序列以生成 Dense 层的输出。还要注意我们是如何将 softmax 激活与 Dense 层分开的。这实际上等价于
.Dense(n_vocab, activation=’softmax’, name=’final_out’)
我们不会通过重复在清单 10.4 中显示的内容来拖延对话,因为这已经是不言而喻的。
练习 2
你已经获得了以下模型,并被要求在现有的 GRU 层之上添加另一个具有 512 个单元并返回所有状态输出的 GRU 层。你将对以下代码做出什么改变?
model = tf.keras.models.Sequential([ tf.keras.layers.Embedding( input_dim=n_vocab+1, output_dim=512,input_shape=(None,) ), tf.keras.layers.GRU(1024, return_state=False, return_sequences=True), tf.keras.layers.Dense(n_vocab, activation=’softmax’, name='final_out'), ])
在这一节中,我们学习了门控循环单元(GRUs)以及它们与 LSTMs 的比较。最后,我们定义了一个可以在我们之前下载并处理的数据上进行训练的语言模型。在下一节中,我们将学习用于评估生成文本质量的评估指标。
TensorFlow 实战(四)(5)https://developer.aliyun.com/article/1522809