TensorFlow 实战(四)(1)https://developer.aliyun.com/article/1522803
让我们看一下 text_to_sequences() 函数转换的一些样本的结果:
Text: ['work', 'perfectly', 'wii', 'gamecube', 'issue', 'compatibility', ➥ 'loss', 'memory'] Sequence: [14, 295, 83, 572, 121, 1974, 2223, 345] Text: ['loved', 'game', 'collectible', 'come', 'well', 'make', 'mask', ➥ 'big', 'almost', 'fit', 'face', 'impressive'] Sequence: [1592, 2, 2031, 32, 23, 16, 2345, 153, 200, 155, 599, 1133] Text: ["'s", 'okay', 'game', 'honest', 'bad', 'type', 'game', '--', "'s", ➥ 'difficult', 'always', 'die', 'depresses', 'maybe', 'skill', 'would', ➥ 'enjoy', 'game'] Sequence: [5, 574, 2, 1264, 105, 197, 2, 112, 5, 274, 150, 354, 1, 290, ➥ 400, 19, 67, 2] Text: ['excellent', 'product', 'describe'] Sequence: [109, 55, 501] Text: ['level', 'detail', 'great', 'feel', 'love', 'car', 'game'] Sequence: [60, 419, 8, 42, 13, 265, 2]
太棒了!我们可以看到文本完美地转换为了 ID 序列。我们现在将继续使用 Keras Tokenizer 返回的数据定义 TensorFlow 管道。
练习 2
给定字符串 s,“a_b_B_c_d_a_D_b_d_d”,你能否定义一个 tokenizer,tok,将文本转换为小写形式,按下划线字符“_”拆分,具有 3 个词汇大小,并将 Tokenizer 拟合到 s 上。如果 Tokenizer 忽略从 1 开始的词汇索引词,那么如果你调用 tok.texts_to_sequences([s]),输出会是什么?
9.3 使用 TensorFlow 定义端到端的 NLP 管道
你已经定义了一个干净的数据集,它是模型期望的数字格式。在这里,我们将定义一个 TensorFlow 数据集管道,以从我们定义的数据中生成数据批次。在数据管道中,你将生成一批数据,其中批次由元组(x,y)组成。x 表示一批文本序列,其中每个文本序列都是一个任意长的标记 ID 序列。y 是与批次中的文本序列对应的标签批次。在生成一批示例时,首先根据序列长度将文本序列分配到桶中。每个桶都有一个预定义的允许序列长度间隔。批次中的示例仅由同一桶中的示例组成。
我们现在处于一个很好的位置。我们已经对数据进行了相当多的预处理,并将文本转换为了机器可读的数字。在下一步中,我们将构建一个 tf.data 管道,将 Tokenizer 的输出转换为适合模型的输出。
作为第一步,我们将目标标签(具有值 0/1)连接到输入。这样,我们可以以任何我们想要的方式对数据进行洗牌,并仍然保持输入和目标标签之间的关系:
data_seq = [[b]+a for a,b in zip(text_seq, labels) ]
接下来,我们将创建一种特殊类型的 tf.Tensor 对象,称为ragged tensor(即 tf.RaggedTensor)。在标准张量中,您具有固定的维度。例如,如果您定义了一个 3×4 大小的张量,则每行都需要有四列(即四个值)。Ragged tensors 是一种支持可变大小的张量的特殊类型。例如,可以将以下数据作为 ragged tensor:
[ [1,2], [3,2,5,9,10], [3,2,3] ]
此张量有三行,其中第一行有两个值,第二行有五个值,最后一行有三个值。换句话说,它具有可变的第二维。由于每篇评论的字数不同,因此导致与每篇评论对应的变大小的 ID 序列,这是我们问题的完美数据结构:
max_length = 50 tf_data = tf.ragged.constant(data_seq)[:,:max_length]
对 tf.RaggedTensor 的初步了解
tf.RaggedTensor 对象是一种特殊类型的张量,可以具有可变大小的维度。您可以在mng.bz/5QZ8
了解更多关于 ragged tensors 的信息。有许多方法来定义一个 ragged tensor。
我们可以通过传递包含值的嵌套列表来定义 ragged tensor,以 tf.ragged.constant()函数:
a = tf.ragged.constant([[1, 2, 3], [1,2], [1]])
您还可以定义一系列值并定义分割行的位置:
b = tf.RaggedTensor.from_row_splits([1,2,3,4,5,6,7], row_splits=[0, 3, 3, 6, 7])
在这里,row_splits 参数中的每个值定义了结果张量中随后的行结束在哪里。例如,第一行将包含从索引 0 到 3(即 0, 1, 2)的元素。 这将输出
<tf.RaggedTensor [[1, 2, 3], [], [4, 5, 6], [7]]>
您可以使用 b.shape 获取张量的形状,这将返回
[4, None]
您甚至可以拥有多维的 ragged tensors,其中有多个可变大小的维度,如下所示:
c = tf.RaggedTensor.from_nested_row_splits( flat_values=[1,2,3,4,5,6,7,8,9], nested_row_splits=([0,2,3],[0,4,6,9]))
在这里,nested_row_splits 是 1D 张量的列表,其中第 i 个张量表示第 i 个维度的行拆分。c 如下所示:
<tf.RaggedTensor [[[1, 2, 3, 4], [5, 6]], [[7, 8, 9]]]>
您可以在 ragged tensors 上执行切片和索引操作,类似于在普通张量上的操作:
print(c[:1, :, :])
这将返回
<tf.RaggedTensor [[[1, 2, 3, 4], [5, 6]]]>
在这里
print(c[:,:1,:])
这将返回
<tf.RaggedTensor [[[1, 2, 3, 4]], [[7, 8, 9]]]>
最后,随着
print(c[:, :, :2])
您将获得
<tf.RaggedTensor [[[1, 2], [5, 6]], [[7, 8]]]>
我们将限制评论的最大长度为 max_length。这是在假设 max_length 个字足以捕获给定评论中的情感的情况下完成的。这样,我们可以避免由于数据中存在一两个极长的评论而导致最终数据过长。最大长度越高,就能更好地捕获评论中的信息。但是,更高的 max_length 值会带来极大的计算开销:
text_ds = tf.data.Dataset.from_tensor_slices(tf_data)
我们将使用 tf.data.Dataset.from_tensor_slices()函数创建一个数据集。该函数在我们刚创建的 ragged tensor 上将逐一提取一行(即一篇评论)。重要的是要记住每行的大小都不同。我们将过滤掉任何空评论。您可以使用 tf.data.Dataset.filter()函数来做到这一点:
text_ds = text_ds.filter(lambda x: tf.size(x)>1)
基本上,我们在这里说的是任何大小小于或等于 1 的评论将被丢弃。记住,每条记录至少会有一个元素(即标签)。这是一个重要的步骤,因为空评论会在模型后续处理中引起问题。
接下来,我们将解决一个极其重要的步骤,以及我们令人印象深刻的数据管道的重点。在序列处理过程中,你可能听过分桶(或分箱)这个术语。分桶是指在批处理数据时,使用相似大小的输入。换句话说,一批数据包括长度相似的评论,不会在同一批中有长度差距悬殊的评论。下面的侧栏更详细地解释了分桶的过程。
分桶:相似长度的序列聚集在一起!
让我们来看一个例子。假设你有一个评论列表,[r1(5), r2(11), r3(6), r4(4), r5(15), r6(18), r7(25), r8(29), r9(30)],其中代码 rx 代表评论 ID,括号内的数字代表评论中的单词数。如果你选择批量大小为 3,那么以下列方式分组数据是有意义的:
[r1, r3, r4] [r2, r5, r6] [r7, r8, r9]
你可以看到长度相近的评论被分组在一起。这实际上是通过一个称为分桶的过程来实现的。首先,我们创建几个预定义边界的桶。例如,在我们的示例中,可能有三个间隔如下的桶:
[[0,11), [11, 21), [21, inf))
然后,根据评论的长度,每个评论被分配到一个桶中。最后,在获取数据批次时,从一个随机选择的桶中随机采样批次数据。
在确定桶之后,我们必须将数据分组,使得最终得到固定序列长度。这是通过在序列的末尾填充零直到我们在该批次中拥有长度相等的所有序列来实现的。假设评论 r1、r3 和 r4 有以下单词 ID 序列:
[10, 12, 48, 21, 5] [ 1, 93, 28, 8, 20, 10] [32, 20, 1, 2]
为了将这些序列分组,我们将在短序列的末尾填充零,结果是
[10, 12, 48, 21, 5, 0] [ 1, 93, 28, 8, 20, 10] [32, 20, 1, 2, 0, 0]
你可以看到,现在我们有一批数据,具有固定序列长度,可以转换为 tf.Tensor。
幸运的是,为了使用桶化,我们需要关心的只是理解一个方便的 TensorFlow 函数 tf.data.experimental.bucket_by_sequence_length()的语法。实验性命名空间是为尚未完全测试的 TensorFlow 功能分配的特殊命名空间。换句话说,可能存在这些函数可能失败的边缘情况。一旦功能经过充分测试,这些情况将从实验性命名空间移出,进入稳定的命名空间。请注意,该函数返回另一个在数据集上执行桶化的函数。因此,你必须将此函数与 tf.data.Dataset.apply()一起使用,以执行返回的函数。这个语法乍一看可能有点晦涩。但当我们深入研究参数时,事情会变得更清晰。你可以看到,当分析评价的序列长度时,我们正在使用我们之前确定的桶边界:
bucket_boundaries=[5,15] batch_size = 64 bucket_fn = tf.data.experimental.bucket_by_sequence_length( element_length_func = lambda x: tf.cast(tf.shape(x)[0],'int32'), bucket_boundaries=bucket_boundaries, bucket_batch_sizes=[batch_size,batch_size,batch_size], padding_values=0, pad_to_bucket_boundary=False )
让我们来检查该函数提供的参数:
- elment_length_func—这是桶函数的核心,因为它告诉函数如何计算单个记录或实例的长度。如果没有记录的长度,桶是无法实现的。
- bucket_boundaries—定义桶边界的上限。该参数接受一个按升序排列的值列表。如果你提供了 bucket_bounderies [x, y, z],其中 x < y < z,那么桶的间隔将是[0, x),[x, y),[y, z),[z, inf)。
- bucket_batch_sizes—每个桶的批次大小。你可以看到,我们为所有桶定义了相同的批次大小。但你也可以使用其他策略,比如更短序列的更大批次大小。
- padded_values—当将序列调整为相同长度时,定义短序列应该用什么填充。用零填充是一种非常常见的方法。我们将坚持使用这种方法。
- pad_to_bucket_boundary—这是一个特殊的布尔参数,将决定每个批次的变量维度的最终大小。例如,假设你有一个区间为[0, 11)的桶和一批序列长度为[4, 8, 5]。如果 pad_to_bucket_boundary=True,最终批次将具有变量维度为 10,这意味着每个序列都被填充到最大限制。如果 pad_to_bucket_boundary=False,你将得到变量维度为 8(即批次中最长序列的长度)。
请记住,最初传递给 tf.data.Dataset.from_tensor_slices 函数的是 tf.RaggedTensor。在返回切片时,它将返回相同的数据类型的切片。不幸的是,tf.RaggedTensor 对象与桶函数不兼容。因此,我们执行以下方法将切片转换回 tf.Tensor 对象。我们只需使用 lambda 函数 lambda x: x 调用 map 函数。通过这样做,你可以使用 tf.data.Dataset.apply()函数,并将桶函数作为参数来调用它:
text_ds = text_ds.map(lambda x: x).apply(bucket_fn)
到这一步,我们已经完成了所有的工作。到目前为止,您已经实现了接受任意长度序列数据集的功能,以及使用分桶策略从中抽取一批序列的功能。这里使用的分桶策略确保我们不会将长度差异很大的序列分组在一起,这将导致过多的填充。
如同我们之前做过很多次一样,让我们打乱数据,以确保在训练阶段观察到足够的随机性:
if shuffle: text_ds = text_ds.shuffle(buffer_size=10*batch_size)
请记住,我们将目标标签和输入结合在一起,以确保输入和目标之间的对应关系。现在,我们可以使用张量切片语法将目标和输入安全地分割成两个独立的张量,如下所示:
text_ds = text_ds.map(lambda x: (x[:,1:], x[:,0]))
现在我们可以松口气了。我们已经完成了从原始杂乱的文本到可以被我们的模型消化的干净半结构化文本的旅程。让我们把它封装成一个名为 get_tf_pipeline()的函数,该函数接受一个 text_seq(单词 ID 列表的列表)、标签(整数列表)、批处理大小(整数)、桶边界(整数列表)、最大长度(整数)和随机混洗(布尔值)的参数(参见下面的列表)。
列表 9.4 tf.data 数据管道
def get_tf_pipeline( text_seq, labels, batch_size=64, bucket_boundaries=[5,15], ➥ max_length=50, shuffle=False ): """ Define a data pipeline that converts sequences to batches of data """ data_seq = [[b]+a for a,b in zip(text_seq, labels) ] ❶ tf_data = tf.ragged.constant(data_seq)[:,:max_length] ❷ text_ds = tf.data.Dataset.from_tensor_slices(tf_data) ❸ bucket_fn = tf.data.experimental.bucket_by_sequence_length( ❹ lambda x: tf.cast(tf.shape(x)[0],'int32'), bucket_boundaries=bucket_boundaries, ❺ bucket_batch_sizes=[batch_size,batch_size,batch_size], padded_shapes=None, padding_values=0, pad_to_bucket_boundary=False ) text_ds = text_ds.map(lambda x: x).apply(bucket_fn) ❻ if shuffle: text_ds = text_ds.shuffle(buffer_size=10*batch_size) ❼ text_ds = text_ds.map(lambda x: (x[:,1:], x[:,0])) ❽ return text_ds
❶ 连接标签和输入序列,以防止洗牌时混乱顺序。
❷ 将变量序列数据集定义为不规则张量。
❸ 从不规则张量创建数据集。
❹ 对数据进行分桶(根据长度将每个序列分配到不同的分桶中)。
❺ 例如,对于分桶边界[5, 15],您可以得到分桶[0, 5]、[5, 15]、[15, 无穷大]。
❻ 应用分桶技术。
❼ 打乱数据。
❽ 将数据分割为输入和标签。
这是一个漫长的旅程。让我们回顾一下我们迄今为止所做的事情。数据管道已经完成并且稳定,现在让我们了解一下可以使用这种类型的顺序数据的模型。接下来,我们��定义情感分析器模型,这是我们一直在等待实现的模型。
练习 3
如果您希望有桶(0, 10]、(10, 25]、(25, 50]、[50, 无穷大)并且始终返回填充到桶的边界上,您将如何修改这个分桶函数?请注意,桶的数量已从文本中的数量发生了变化。
9.4 快乐的评论意味着快乐的顾客:情感分析
想象一下,您已经将评论转换为数字,并定义了一个数据管道,生成输入和标签的批处理。现在是时候使用模型来处理它们,以训练一个能够准确识别发布的评论情感的模型。您听说过长短期记忆模型(LSTMs)是处理文本数据的一个很好的起点。目标是基于 LSTMs 实现一个模型,针对给定的评论产生两种可能的结果之一:负面情感或积极情感。
如果您已经到达这一步,您应该会感到快乐。您已经完成了很多工作。现在是时候奖励自己获取关于一个称为深度顺序模型的引人注目的模型家族的信息。该家族的一些示例模型如下:
- 简单循环神经网络(RNNs)
- 长短期记忆(LSTM)网络
- 门控循环单元(GRUs)
9.4.1 LSTM 网络
之前我们讨论了简单的循环神经网络及其在预测未来 CO2 浓度水平中的应用。在本章中,我们将探讨 LSTM 网络的机制。LSTM 模型在近十年间非常流行。它们是处理序列数据的绝佳选择,通常具有三个重要的维度:
- 一个批量维度
- 一个时间维度
- 一个特征维度
如果你考虑我们讨论的 NLP 管道返回的数据,它具有所有这些维度。批量维度由采样到该批量中的每个不同评论来表示。时间维度由单个评论中出现的词 ID 序列表示。最后,你可以将特征维度看作为 1,因为一个单一的特征由一个单一的数值(即一个 ID)表示(参见图 9.2)。特征维度具有对应于该维度上的特征的值。例如,如果你有一个包含三个特征的天气模型(例如,温度、降水、风速),那么模型的输入将是大小为[<批量大小>, <序列长度>, 3]的输入。
图 9.2 序列数据的三维视图。通常,序列数据具有三个维度:批量大小、序列/时间和特征。
LSTM 采用了讨论的三个维度的输入。让我们更深入地了解一下 LSTM 是如何在这样的数据上运作的。为了简化讨论,假设批量大小为 1 或仅有一个评论。如果我们假设有一个包含n个词的单一评论r,可以表示为
r = w[1,]w[2,…,]w[t,…,]w[n],其中w[t]表示第t个位置的词的 ID。
在时间步t,LSTM 模型从以下状态开始
- 上一个输出状态向量h[t-1]
- 上一个单元状态向量c[t-1]
并计算
- 使用当前输入w[t]和上一个单元状态c[t-1]以及输出状态h[t-1]来计算当前单元状态c[t]
- 使用当前输入w[t]、上一个状态h[t-1]和当前单元状态c[t]来计算当前输出状态h[t]
这样,模型就会持续迭代所有时间步(在我们的示例中,它是序列中的单词 ID),直到到达末尾。在这种迭代过程中,模型会持续产生一个单元状态和一个输出状态(图 9.3)。
图 9.3 LSTM 单元的高层纵向视图。在给定的时间步t,LSTM 单元接收两个先前状态(h[t-1]和c[t-1]),以及输入,并产生两个状态(h[t]和c[t])。
有了对 LSTM 单元的良好高层次理解,让我们来看看推动该模型齿轮的方程式。LSTM 接受三个输入:
- x[t]—时间步t处的输入
- h[t-1]—时间步t-1 处的输出状态
- c[t-1]—时间步t-1 处的单元状态
有了这个,LSTM 会产生两个输出:
- (c[t])—时刻 t 的单元状态
- (h[t])—时刻 t 的输出状态
为了产生这些输出,LSTM 模型利用了一个门控机制。这些门控机制决定了多少信息通过它们流向计算的下一阶段。LSTM 单元有三个门控:
- 一个输入门 (i[t])—确定当前输入对后续计算的影响程度
- 一个遗忘门 (f[t])—确定在计算新单元状态时有多少先前单元状态被丢弃
- 一个输出门 (o[t])—确定当前单元状态对最终输出的贡献程度
这些门由可训练的权重构成。这意味着当在特定任务上训练 LSTM 模型时,门控机制将进行联合优化,以产生解决该任务所需的最佳信息流动。
在 LSTM 中,长期记忆和短期记忆是由什么承载的?
单元状态在模型在时间维度上进展的过程中保留了长期信息和关系。事实上,研究发现 LSTM 在学习时间序列问题时可以记住长达数百个时间步骤。
另一方面,输出状态可以被看作是短期记忆,它会查看输入、存储在单元状态中的长期记忆,并决定计算阶段所需的最佳信息量。
你可能还会想,“这个门控机制到底实现了什么?”让我用一个句子来说明。为了解决几乎所有的自然语言处理任务,捕捉句子中的句法和语义信息以及正确解析依赖关系是至关重要的。让我们看看 LSTM 如何帮助我们实现这一目标。假设你获得了以下句子:
狗追着绿球跑,它累了并朝飞机狂吠
在你的脑海中,想象一个 LSTM 模型从一个词跳到另一个词,逐个处理它们。然后假设你在处理句子的各个阶段向模型提问。例如你问“谁跑了?”当它处理短语“狗跑了”时。模型可能会广泛打开输入门来吸收尽可能多的信息,因为模型开始时对语言的外观一无所知。而且如果你仔细考虑,模型实际上不需要关注它的记忆,因为答案离“跑”这个词只有一个词的距离。
接下来,你问“谁累了?”在处理“它累了”时,模型可能希望利用它的单元状态而不是关注输入,因为这个短语中唯一的线索是“它”。如果模型要识别它和狗之间的关系,它将需要稍微关闭输入门并打开遗忘门,以使更多的信息从过去的记忆(关于狗的记忆)流入当前记忆中。
最后,假设当模型达到“对飞机吠叫”的部分时,你问:“被吠叫了什么?”为了产生最终输出,你不需要太多来自过去记忆的信息,因此你可能会紧缩输出门,以避免过多来自过去记忆的信息。我希望这些演示对理解这些门的目的有所帮助。请记住,这只是一种理解这些门目的的比喻。但在实践中,实际行为可能会有所不同。值得注意的是,这些门不是二进制的;相反,门的输出由一个 S 型函数控制,这意味着它在给定时间上产生软开/关状态,而不是硬开/关状态。
为了完成我们的讨论,让我们检查驱动 LSTM 单元中计算的方程式。但你不必详细记忆或理解这些方程式,因为使用 LSTM 不需要这样做。但为了使我们的讨论全面,让我们来看看它们。第一个计算计算输入门:
i[t] = σ(W[ih]h[t-1] + W[ix]x[t] + b[f])
这里,W[ih] 和 W[ix] 是可训练的权重,产生门值,其中 b[i] 是偏置。这里的计算与完全连接层的计算非常相似。该门产生了一个向量,其值介于 0 和 1 之间。你可以看到它与门的相似性(假设 0 表示关闭,1 表示打开)。其余的门遵循类似的计算模式。遗忘门计算为
f[t] = σ(W[fh]h[t-1] + W[fx]x[t] + b[f])
然后计算单元状态。单元状态是从两重计算中计算得到的:
C̃[t] = tanh(W[ch] h[t][-1] + W[cx]x[t] = b[c])
c[t] = f[t]h[t][-1] + i[t]C̃[t]
计算相当直观。它使用遗忘门来控制先前的单元状态,其中使用输入门来控制使用 x[t](当前输入)计算的 C̃[t]。最后,输出门和状态计算如下
o[t] = σ(W[oh]h[t-1] + W[ox]x[t] + b[0])
h[t] = o[t]tanh(c[t])
这里,c[t] 是使用通过遗忘门和输入门控制的输入计算得到的。因此,在某种程度上,o[t] 控制着当前输入、当前单元状态、上一个单元状态和上一个输出状态对 LSTM 单元最终状态输出的贡献。在 TensorFlow 和 Keras 中,你可以这样定义一个 LSTM:
import tensorflow as tf tf.keras.layers.LSTM(units=128, return_state=False, return_sequences=False)
第一个参数 units 是 LSTM 层的超参数。类似于单位数定义了完全连接层的输出大小,units 参数定义了输出、状态和门向量的维度。这个数字越高,模型的代表能力就越强。接下来,return_state=False 表示当在输入上调用该层时,只返回输出状态。如果 return_state=True,则同时返回细胞状态和输出状态。最后,return_sequences=False 表示只返回处理整个序列后的最终状态。如果 return_sequences=True,则在处理序列中的每个元素时返回所有状态。图 9.4 描述了这些参数结果的差异。
图 9.4 LSTM 层输出的更改导致了 return_state 和 return_sequences 参数的更改。
接下来,让我们定义最终模型。
9.4.2 定义最终模型
我们将使用 Sequential API 定义最终模型。我们的模型将包含以下层(图 9.5):
- 一个屏蔽层 —— 这一层在决定序列中的哪些输入元素将有助于训练方面起着重要作用。我们很快将学到更多相关内容。
- 一个独热编码层 —— 这一层将把单词 ID 转换为独热编码序列。这是在将输入馈送给模型之前必须执行的重要转换。
- 一个 LSTM 层 —— LSTM 层将最终输出状态作为输出返回。
- 一个具有 512 个节点(ReLU 激活)的 Dense 层 —— Dense 层接收 LSTM 单元的输出,并产生一个临时的隐藏输出。
- 一个 Dropout 层 —— Dropout 是一种在训练过程中随机关闭输出的正则化技术。我们在第七章中讨论了 Dropout 的目的和工作原理。
- 具有单个节点(sigmoid 激活)的最终输出层 —— 注意,我们只需要一个节点来表示输出。如果输出值为 0,则是负面情感。如果值为 1,则是正面情感。
图 9.5 情感分析器的高层模型架构
我们的 tf.data pipeline 生成一个 [, ] 形状的二维张量。在实践中,它们都可以是 None。换句话说,它将是一个 [None, None] 大小的张量,因为我们必须支持模型中可变大小的批次和可变大小的序列长度。大小为 None 的维度意味着模型可以在该维度上接受任何大小的张量。例如,对于一个 [None, None] 张量,当实际数据被检索时,它可以是一个 [5, 10]、[12, 54] 或 [102, 14] 大小的张量。作为模型的入口点,我们将使用一个重塑层包装在 lambda 层中,如下所示:
tf.keras.layers.Lambda(lambda x: tf.expand_dims(x, axis=-1), input_shape=(None,)),
这一层接收到数据管道生成的 [None, None] 输入,并将其重新塑造成一个 [None, None, 1] 大小的张量。这种重新塑造对于接下来的层来说是必要的,这也是讨论下一层的一个绝佳机会。接下来的层是一个特殊用途的遮罩层。我们在之前的章节中没有看到过遮罩层的使用。然而,在自然语言处理问题中,遮罩是常用的。遮罩的需求源自我们在输入序列进行桶装过程中执行的填充操作。在自然语言处理数据集中,很少会看到文本以固定长度出现。通常,每个文本记录的长度都不同。为了将这些大小不同的文本记录批量处理给模型,填充起着至关重要的作用。图 9.6 展示了填充后数据的样子。
图 9.6 填充前后的文本序列
但是这会引入额外的负担。填充引入的值(通常为零)不携带任何信息。因此,在模型中进行的任何计算都应该忽略它们。例如,当输入中使用填充时,LSTM 模型应该停止处理,并在遇到填充值之前返回最后一个状态。tf.keras.layers.Masking 层帮助我们做到了这一点。遮罩层的输入必须是一个 [批量大小,序列长度,特征维度] 大小的三维张量。这暗示了我们最后一点,即将我们的 tf.data 管道的输出重新塑造为三维张量。在 TensorFlow 中,您可以按如下方式定义一个遮罩:
tf.keras.layers.Masking(mask_value=0)
遮罩层创建了一个特殊的遮罩,并且这个遮罩被传递到模型中的后续层。像 LSTM 层这样的层知道如果从下面的一层传递了一个遮罩,它应该做什么。更具体地说,如果提供了一个遮罩,LSTM 模型将输出它遇到零值之前的状态值。此外,还值得注意 input_shape 参数。我们模型的输入将是一个二维张量:一个任意大小的批次,以及一个任意大小的序列长度(由于桶装)。因此,我们无法在 input_shape 参数中指定一个序列长度,所以模型期望的输入是一个 (None, None, 1) 大小的张量(额外的 None 自动添加以表示批次维度)。
定义了遮罩后,我们将使用自定义层将单词 ID 转换为独热向量。在将数据馈送到 LSTM 之前,这是一个关键步骤。这可以通过以下方式实现:
class OnehotEncoder(tf.keras.layers.Layer): def __init__(self, depth, **kwargs): super(OnehotEncoder, self).__init__(**kwargs) self.depth = depth def build(self, input_shape): pass def call(self, inputs): inputs = tf.cast(inputs, 'int32') if len(inputs.shape) == 3: inputs = inputs[:,:,0] return tf.one_hot(inputs, depth=self.depth) def compute_mask(self, inputs, mask=None): return mask def get_config(self): config = super().get_config().copy() config.update({'depth': self.depth}) return config
然后使用以下方式调用它
OnehotEncoder(depth=n_vocab),
这个层有点复杂,所以让我们一步步来。首先,您定义一个称为 depth 的用户定义参数。这定义了最终结果的特征维度。接下来,您必须定义 call() 函数。call() 函数接受输入,将它们转换为’int32’,然后如果输入是三维的,则移除最终维度。这是因为我们定义的遮罩层具有大小为 1 的维度来表示特征维度。这个维度在我们用来生成一位热编码向量的 tf.one_hot() 函数中不被理解。因此,它必须被移除。最后,我们返回 tf.one_hot() 函数的结果。记住在使用 tf.one_hot() 时提供 depth 参数。如果没有提供,TensorFlow 尝试自动推断值,这会导致不同批次之间的张量大小不一致。我们定义 compute_mask() 函数来确保我们将遮罩传播到下一层。该层只是获取遮罩并将其传递给下一层。最后,我们定义一个 get_config() 函数来更新该层中的参数。正确返回一组参数对于配置来说是至关重要的;否则,您将在保存模型时遇到问题。我们将 LSTM 层定义为模型的下一层:
tf.keras.layers.LSTM(units=128, return_state=False, return_sequences=False)
关于在模型中传播遮罩的更多信息
当使用遮罩层时,记住一些重要事项是很重要的。首先,在使用遮罩时最好避免使用 lambda 层。这是因为在使用遮罩与 lambda 层同时存在时可能会出现一些问题(例如,github.com/tensorflow/tensorflow/issues/40085
)。最佳选择是编写自定义层,就像我们所做的那样。在定义了自定义层之后,您必须重写 compute_mask() 函数以返回(如果需要的话进行修改的)下一层的遮罩。
我们在这里必须特别小心。根据您在定义此层时提供的参数,您将得到截然不同的输出。为了定义我们的情感分析器,我们只想要模型的最终输出状态。这意味着我们对单元状态不感兴趣,也不关心在处理序列期间计算的所有输出状态。因此,我们必须相应地设置参数。根据我们的要求,我们必须设置 return_state=False 和 return_sequences=False。最后,最终状态输出进入一个具有 512 个单位和 ReLU 激活的密集层:
tf.keras.layers.Dense(512, activation='relu'),
密集层后跟一个 Dropout 层,该层在训练期间会丢弃前一个密集层的 50% 输入。
tf.keras.layers.Dropout(0.5)
最后,模型被一个具有单个单元和 sigmoid 激活的密集层加冕,这将产生最终的预测。如果生成的值小于 0.5,则被认为是标签 0,否则为标签 1:
tf.keras.layers.Dense(1, activation='sigmoid')
我们可以按照下一个清单中所示定义完整的模型。
清单 9.5 完整情感分析模型的实现
model = tf.keras.models.Sequential([ tf.keras.layers.Masking(mask_value=0.0, input_shape=(None,1)), ❶ OnehotEncoder(depth=n_vocab), ❷ tf.keras.layers.LSTM(128, return_state=False, return_sequences=False), ❸ tf.keras.layers.Dense(512, activation='relu'), ❹ tf.keras.layers.Dropout(0.5), ❺ tf.keras.layers.Dense(1, activation='sigmoid') ❻ ])
❶ 创建一个遮罩来屏蔽零输入。
❷ 创建掩码后,将输入转换为 one-hot 编码的输入。
❸ 定义一个 LSTM 层,返回最后一个状态输出向量(从未掩码输入中)。
❹ 用 ReLU 激活函数来定义一个 Dense 层。
❺ 用 50% 的 dropout 率定义一个 Dropout 层。
❻ 用一个节点和 sigmoid 激活函数来定义最终的预测层。
接下来,我们要编译模型。再次,我们必须小心使用的损失函数。到目前为止,我们使用的是 categorical_crossentropy 损失函数。该损失函数用于多类别分类问题(大于两类)。由于我们解决的是二分类问题,我们必须改用 binary_crossentropy 损失函数。使用错误的损失函数可能导致数值不稳定和训练不准确的模型:
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
最后,让我们通过运行 model.summary() 来查看模型的概述:
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= masking (Masking) (None, None) 0 _________________________________________________________________ lambda (Lambda) (None, None, 11865) 0 _________________________________________________________________ lstm (LSTM) (None, 128) 6140928 _________________________________________________________________ dense (Dense) (None, 512) 66048 _________________________________________________________________ dropout (Dropout) (None, 512) 0 _________________________________________________________________ dense_1 (Dense) (None, 1) 513 ================================================================= Total params: 6,207,489 Trainable params: 6,207,489 Non-trainable params: 0 _________________________________________________________________
这是我们第一次遇到顺序模型。让我们更详细地复习一下模型概述。首先,我们有一个返回与输入相同大小的输出的掩码层(即,[None, None] 大小的张量)。然后一个 one-hot 编码层返回一个具有 11865 个特征维度(即词汇表大小)的张量。这是因为,与单个整数表示的输入不同,one-hot 编码将其转换为一个大小为词汇表大小的零向量,并将由单词 ID 索引的值设置为 1。LSTM 层返回一个 [None, 128] 大小的张量。记住,我们只获取最终状态输出向量,它将是一个大小为 [None, 128] 的张量,其中 128 是单元数。LSTM 返回的最后一个输出传递到一个具有 512 个节点和 ReLU 激活函数的 Dense 层。接下来是一个 50% 的 dropout 层。最后,一个具有一个节点的 Dense 层产生最终的预测结果:一个介于 0 和 1 之间的值。
在接下来的部分,我们将在训练数据上训练模型,并在验证和测试数据上评估模型的性能。
练习 4
定义一个模型,该模型只有一个 LSTM 层和一个 Dense 层。LSTM 模型有 32 个单元,并接受一个大小为 (None, None, 30) 的输入(包括批次维度),并输出所有状态输出(而不是最终输出)。接下来,一个 lambda 层应该在时间维度上对状态进行求和,得到一个大小为 (None, 32) 的输出。这个输出传递到具有 10 个节点和 softmax 激活函数的 Dense 层。你可以使用 tf.keras.layers.Add 层对状态向量进行求和。你需要使用功能 API 来实现这个模型。
9.5 训练和评估模型
我们已经准备好训练刚刚定义的模型了。作为第一步,让我们定义两个 pipeline:一个用于训练数据,一个用于验证数据。记住,我们分割了数据并创建了三个不同的集合:训练集(tr_x 和 tr_y),验证集(v_x 和 v_y)和测试集(ts_x 和 ts_y)。我们将使用批量大小为 128:
# Using a batch size of 128 batch_size =128 train_ds = get_tf_pipeline(tr_x, tr_y, batch_size=batch_size, shuffle=True) valid_ds = get_tf_pipeline(v_x, v_y, batch_size=batch_size)
然后是一个非常重要的计算。实际上,做或不做这个计算可以决定你的模型是否能够工作。记住,在第 9.1 节我们注意到数据集中存在显着的类别不平衡。具体来说,数据集中的正类比负类更多。在这里,我们将定义一个加权因子,以在计算损失和更新模型权重时为负样本分配更大的权重。为此,我们将定义加权因子:
weight[neg]= count(正样本)/count(负样本)
这将导致一个 > 1 的因子,因为正样本比负样本多。我们可以使用以下逻辑轻松计算:
neg_weight = (tr_y==1).sum()/(tr_y==0).sum()
这导致 weight[neg]~6(即约为 6)。接下来,我们将定义训练步骤如下:
model.fit( x=train_ds, validation_data=valid_ds, epochs=10, class_weight={0:neg_weight, 1:1.0} )
这里,train_ds 被传递给 x,但实际上包含了输入和目标。valid_ds,包含验证样本,被传递给 validation_data 参数。我们将运行这个 10 次迭代。最后,注意我们使用 class_weight 参数告诉模型负样本必须优先于正样本(因为数据集中的不足表示)。class_weight 被定义为一个字典,其中键是类标签,值表示给定该类样本的权重。当传递时,在损失计算期间,由于负类而导致的损失将乘以 neg_weight 因子,导致在优化过程中更多地关注负样本。实践中,我们将遵循与其他章节相同的模式并使用三个回调运行训练过程:
- CSV 记录器
- 学习率调度程序
- 早停
完整代码如下所示。
列表 9.6 情感分析器的训练过程
os.makedirs('eval', exist_ok=True) csv_logger = tf.keras.callbacks.CSVLogger( os.path.join('eval','1_sentiment_analysis.log')) ❶ monitor_metric = 'val_loss' mode = 'min' print("Using metric={} and mode={} for EarlyStopping".format(monitor_metric, mode)) lr_callback = tf.keras.callbacks.ReduceLROnPlateau( monitor=monitor_metric, factor=0.1, patience=3, mode=mode, min_lr=1e-8 ❷ ) es_callback = tf.keras.callbacks.EarlyStopping( monitor=monitor_metric, patience=6, mode=mode, restore_best_weights=False ❸ ) model.fit( train_ds, ❹ validation_data=valid_ds, epochs=10, class_weight={0:neg_weight, 1:1.0}, callbacks=[es_callback, lr_callback, csv_logger])
❶ 将性能指标记录到 CSV 文件中。
❷ 学习率降低回调
❸ 早停回调
❹ 训练模型。
你应该得到类似的结果:
Using metric=val_loss and mode=min for EarlyStopping Epoch 1/10 2427/2427 [==============================] - 72s 30ms/step - loss: 0.7640 - accuracy: 0.7976 - val_loss: 0.4061 - val_accuracy: 0.8193 - lr: 0.0010 ... Epoch 7/10 2427/2427 [==============================] - 73s 30ms/step - loss: 0.2752 - accuracy: 0.9393 - val_loss: 0.7474 - val_accuracy: 0.8026 - lr: 1.0000e-04 Epoch 8/10 2427/2427 [==============================] - 74s 30ms/step - loss: 0.2576 - accuracy: 0.9439 - val_loss: 0.8398 - val_accuracy: 0.8041 - lr: 1.0000e-04
看起来在训练结束时,我们已经达到了超过 80% 的验证准确率。这是个好消息,因为我们确保验证数据集是一个平衡的数据集。但我们不能太肯定。我们需要在模型没有见过的数据集上测试我们的模型:测试集。在此之前,让我们保存模型:
os.makedirs('models', exist_ok=True) tf.keras.models.save_model(model, os.path.join('models', '1_sentiment_analysis.h5'))
我们已经创建了测试数据集,并已经定义了处理数据的 NLP 管道,所以只需调用 get_tf_pipeline() 函数与数据:
test_ds = get_tf_pipeline(ts_x, ts_y, batch_size=batch_size)
现在只需调用以下一行代码即可获得模型的测试性能:
model.evaluate(test_ds)
最终结果如下所示:
87/87 [==============================] - 2s 27ms/step - loss: 0.8678 - accuracy: 0.8038
我们现在可以安心地睡觉,知道我们的模型在未见数据上的性能与训练期间看到的验证性能相当。
仅仅好的准确率是我们追求的吗?
简短的答案是否定的。解决一个机器学习任务涉及到许多任务的和谐工作。在执行这些任务的过程中,我们对输入和输出进行各种转换/计算。整个过程的复杂性意味着出错的机会更多。因此,在整个过程中我们应该尽可能检查尽可能多的事情。
单单谈论测试,我们必须确保测试数据在通过数据管道时被正确处理。此外,我们还应该检查最终的预测结果。除了许多其他检查之外,你可以检查最顶部的正面预测和负面预测,以确保模型的决策是合理的。你只需简单地视觉检查输入文本和相应的预测。我们将在下一节中讨论具体内容。
这只会略微增加你的模型时间。但它也可以为你节省数小时的调试时间,以及因发布不准确的模型而导致的尴尬或声誉损失。
在接下来的章节中,我们将通过使用词向量来表示输入模型的标记来进一步增强我们的模型。词向量有助于机器学习模型更好地理解语言。
练习 5
假设你的训练数据集中有三个类别:A、B 和 C。你有 10 条 A 类的记录,25 条 B 类的记录和 50 条 C 类的记录。你认为这三个类别的权重会是多少?记住,大多数类别应该获得较小的权重。.
TensorFlow 实战(四)(3)https://developer.aliyun.com/article/1522806