自然语言处理的深度学习(二)
原文:
annas-archive.org/md5/79edb699aa9642b682a3d7113b128c41译者:飞龙
第六章:第五章
循环神经网络
学习目标
在本章结束时,你将能够:
-
描述经典的前馈神经网络
-
区分前馈神经网络和循环神经网络
-
评估通过时间反向传播应用于循环神经网络的效果
-
描述循环神经网络的缺点
-
使用 Keras 中的循环神经网络解决作者归属问题
本章旨在介绍循环神经网络及其应用,并讨论它们的缺点。
介绍
我们在日常生活中会遇到不同种类的数据,其中一些数据具有时间依赖性(随时间变化的依赖关系),而另一些则没有。例如,一张图片本身就包含了它想要传达的信息。然而,音频和视频等数据形式则具有时间依赖性。如果只考虑一个固定的时间点,它们无法传递信息。根据问题的描述,解决问题所需的输入可能会有所不同。如果我们有一个模型来检测某个特定人物在一帧中的出现,那么可以使用单张图片作为输入。然而,如果我们需要检测他们的动作,我们需要一系列连续的图像作为输入。我们可以通过分析这些图像来理解一个人的动作,但不能仅仅通过单独的图像来分析。
在观看电影时,某个特定场景之所以能够理解,是因为已知其上下文,并且我们记住了电影中之前收集的所有信息,来理解当前场景。这一点非常重要,而我们作为人类之所以能够做到这一点,是因为我们的脑袋能够存储记忆,分析过去的数据,并提取有用信息以理解当前的场景。
像多层感知机和卷积神经网络这样的网络缺乏这种能力。它们对每个输入都视为独立处理,并且不会存储任何来自过去输入的信息来分析当前输入,因为它们在架构上缺乏记忆功能。在这种情况下,也许我们可以让神经网络具有记忆功能。我们可以尝试让它们存储过去的有用信息,并从过去获取有助于分析当前输入的信息。这是完全可能的,其架构被称为循环神经网络(RNN)。
在深入了解 RNN 的理论之前,我们先来看一下它们的应用。目前,RNN 被广泛应用。以下是一些应用:
-
语音识别:无论是亚马逊的 Alexa,苹果的 Siri,谷歌的语音助手,还是微软的 Cortana,它们的所有语音识别系统都使用 RNN。
-
时间序列预测:任何具有时间序列数据的应用程序,如股市数据、网站流量、呼叫中心流量、电影推荐、Google Maps 路线等等,都使用 RNN 来预测未来数据、最佳路径、最佳资源分配等。
-
自然语言处理:机器翻译(例如 Google Translate)、聊天机器人(如 Slack 和 Google 的聊天机器人)以及问答系统等应用都使用 RNN 来建模依赖关系。
神经网络的早期版本
大约 40 年前,人们发现前馈神经网络(FFNNs)无法捕捉时间变化的依赖关系,而这对于捕捉信号的时间变化特性至关重要。建模时间变化的依赖关系在许多涉及现实世界数据的应用中非常重要,例如语音和视频,这些数据具有时间变化的特性。此外,人类生物神经网络具有递归关系,因此这是最明显的发展方向。如何将这种递归关系添加到现有的前馈网络中呢?
实现这一目标的首次尝试之一是通过添加延迟元素,网络被称为时延神经网络,简称TDNN。
在这个网络中,正如下图所示,延迟元素被添加到网络中,过去的输入与当前时刻一起作为网络的输入。与传统的前馈网络相比,这种方法无疑具有优势,但也有一个缺点,即只能接收来自过去的有限输入,这取决于窗口的大小。如果窗口太大,网络随着参数的增加而增长,计算复杂度也随之增加。
图 5.1:TDNN 结构
随后出现了 Elman 网络,或称为简单 RNN(Simple RNN)。Elman 网络与前馈网络非常相似,不同之处在于其输出的隐藏层会被存储并用于下一个输入。这样,前一个时刻的信息可以在这些隐藏状态中被捕获。
观察 Elman 网络的一种方式是,在每个输入时,我们将前一个隐藏层的输出与当前输入一起附加,并将它们作为网络的输入。因此,如果输入大小是m,隐藏层大小是n,则有效的输入层大小变为m+n。
下图显示了一个简单的三层网络,其中之前的状态被反馈到网络中以存储上下文,因此称之为SimpleRNN。这种架构有其他变种,例如 Jordan 网络,我们在本章中不会学习这些变种。对于那些对 RNN 早期历史感兴趣的人来说,阅读更多关于 Elman 网络和 Jordan 网络的资料可能是一个很好的起点。
图 5.2:SimpleRNN 结构
然后就出现了 RNN,这是本章的主题。我们将在接下来的章节中详细探讨 RNN。需要注意的是,在递归网络中,由于存在与这些单位相关的记忆单元和权重,这些内容需要在反向传播中学习。由于这些梯度也通过时间进行反向传播,因此我们称之为 通过时间的反向传播(BPTT)。我们将在后续章节中详细讨论 BPTT。然而,由于 BPTT,TDNN、Elman 网络和 RNN 存在一个主要缺陷,这个问题被称为梯度消失。梯度消失是指梯度在反向传播时越来越小,在这些网络中,随着时间步长的增加,反向传播的梯度越来越小,最终导致梯度消失。捕捉超过 20 个时间步长的时间依赖性几乎是不可能的。
为了解决这个问题,引入了一种名为 长短期记忆(LSTM)的架构。这里的关键思想是保持一些细胞状态不变,并根据需要将其引入到未来的时间步长中。这些决策由门控机制完成,包括遗忘门和输出门。LSTM 的另一种常见变体叫做 门控递归单元(GRU),简称 GRU。如果你还没有完全理解这些概念,不用太担心。接下来有两章内容专门讲解这些概念。
RNN
"递归"通常意味着反复发生。RNN 的递归部分简单来说就是在输入序列中的所有输入上执行相同的任务(对于 RNN,我们将时间步长序列作为输入序列)。前馈网络和 RNN 之间的一个主要区别是,RNN 拥有称为状态的记忆单元,用于捕获来自前一个输入的信息。因此,在这个架构中,当前的输出不仅依赖于当前输入,还依赖于当前的状态,而该状态则考虑了过去的输入。
RNN 通过输入序列而不是单一输入进行训练;同样,我们也可以将 RNN 的每个输入视为时间步长的序列。RNN 中的状态单元包含有关过去输入的信息,以处理当前的输入序列。
图 5.3:RNN 结构
对于输入序列中的每个输入,RNN 获取一个状态,计算其输出,并将其状态传递给序列中的下一个输入。对于序列中的所有元素,都会重复执行相同的任务。
通过将 RNN 与前馈网络进行比较,我们可以更容易理解 RNN 及其运作方式。现在就让我们来进行这样的比较。
到目前为止,很明显,在前馈神经网络中,输入彼此之间是独立的,因此我们通过随机抽取输入和输出的配对来训练网络。序列本身没有任何重要性。在任何给定的时刻,输出只是输入和权重的函数。
图 5.4:RNN 的输出表达式
在 RNN 中,时间 t 的输出不仅依赖于当前输入和权重,还依赖于之前的输入。在这种情况下,时间 t 的输出定义如下:
图 5.5:RNN 在时间 t 的输出表达式
让我们看看一个简单的 RNN 结构,称为折叠模型。在下图中,St 状态向量从前一个时间步反馈到网络。这个表示法的一个重要启示是,RNN 在各个时间步之间共享相同的权重矩阵。通过增加时间步,我们并不是在学习更多的参数,而是在查看更大的序列。
图 5.6:RNN 的折叠模型
这是 RNN 的折叠模型:
Xt:输入序列中的当前输入向量
Yt:输出序列中的当前输出向量
St:当前状态向量
Wx:连接输入向量到状态向量的权重矩阵
Wy:连接状态向量到输出向量的权重矩阵
Ws:连接前一时间步的状态向量到下一时间步的权重矩阵
由于输入 x 是一个时间步序列,并且我们对该序列中的每个元素执行相同的任务,因此我们可以展开该模型。
图 5.7:RNN 的展开
例如,时间 t+1 的输出,yt+1,依赖于时间 t+1 的输入、权重矩阵以及之前的所有输入。
图 5.8:展开的 RNN
由于 RNN 是 FFNN 的扩展,理解这两种架构之间的差异非常重要。
图 5.9:FFNN 和 RNN 的差异
FFNN 和 RNN 的输出表达式如下:
图 5.10:FFNN 和 RNN 的输出表达式
从前面的图和公式可以明显看出,这两种架构之间有很多相似之处。事实上,如果 Ws=0,它们是相同的。显然是这种情况,因为 Ws 是与反馈到网络的状态相关的权重。没有 Ws 就没有反馈,这是 RNN 的基础。
在 FFNN(前馈神经网络)中,输出依赖于t时刻的输入和权重矩阵。在 RNN 中,输出不仅依赖于t时刻的输入,还依赖于t-1、t-2等时刻的输入,以及权重矩阵。这可以通过进一步计算隐藏向量h(对于 FFNN)和s(对于 RNN)来解释。乍一看,似乎t时刻的状态依赖于t时刻的输入、t-1时刻的状态和权重矩阵;而t-1时刻的状态依赖于t-1时刻的输入、t-2时刻的状态,依此类推,形成一个从第一时刻开始回溯的链条。不过,FFNN 和 RNN 的输出计算是相同的。
RNN 架构
RNN(循环神经网络)可以有多种形式,具体使用哪种架构需要根据我们要解决的问题来选择。
图 5.11 不同架构的 RNN
一对多:在这种架构中,给定一个单一的输入,输出是一个序列。一个例子是图像描述,其中输入是单一的图像,输出是一系列描述图像的单词。
多对一:在这种架构中,给定一个输入序列,但期望一个单一的输出。一个例子是时间序列预测,其中需要预测下一个时刻的值,基于之前的时刻。
多对多:在这种架构中,输入序列被提供给网络,网络输出一个序列。在这种情况下,序列可以是同步的,也可以是不同步的。例如,在机器翻译中,整个句子需要先输入网络,然后网络才开始进行翻译。有时,输入和输出不是同步的;例如,在语音增强中,输入是一个音频帧,而输出是该音频帧的清晰版本。在这种情况下,输入和输出是同步的。
RNN 也可以堆叠在一起。需要注意的是,每个堆叠中的 RNN 都有自己的权重矩阵。因此,权重矩阵在横向(时间轴)上是共享的,而不是在纵向(RNN 数量轴)上共享的。
图 5.12: 堆叠的 RNN
BPTT
RNN 可以处理不同长度的序列,能以不同形式使用,且可以堆叠在一起。之前,你已经遇到过反向传播技术,用于反向传播损失值以调整权重。对于 RNN,也可以进行类似的操作,不过稍有不同,那就是通过时间传递的门控损失。它被称为BPTT(反向传播通过时间)。
根据反向传播的基本理论,我们知道以下内容:
图 5.13: 权重更新的表达式
更新值是通过链式法则的梯度计算得出的:
图 5.14 误差相对于权重的偏导数
这里,α 是学习率。误差(损失)相对于权重矩阵的偏导数是主要的计算。获得新的矩阵后,调整权重矩阵就是将这个新矩阵按学习因子缩放后加到原矩阵上。
在计算 RNN 的更新值时,我们将使用 BPTT。
让我们通过一个例子来更好地理解这一点。考虑一个损失函数,例如均方误差(常用于回归问题):
图 5.15:损失函数
在时间步 t = 3 时,计算得到的损失如图所示:
图 5.16 时间 t=3 时的损失
这个损失需要进行反向传播,Wy、Wx 和 Ws 权重需要更新。
如前所述,我们需要计算更新值来调整这些权重,这个更新值可以通过偏导数和链式法则来计算。
完成此操作有三个部分:
-
通过计算误差相对于 Wy 的偏导数来更新权重 Wy
-
通过计算误差相对于 Ws 的偏导数来更新权重 Ws
-
通过计算误差相对于 Wx 的偏导数来更新权重 Wx
在我们查看这些更新之前,先将模型展开,并保留对我们计算有实际意义的网络部分。
图 5.17 展开后的 RNN,时间 t=3 时的损失
由于我们关注的是时间 t=3 时的损失如何影响权重矩阵,时间 t=2 及之前的损失值不再相关。现在,我们需要理解如何将损失反向传播通过网络。
让我们来逐个查看这些更新,并展示前图中每个更新的梯度流动。
更新与梯度流
更新可以列出如下:
-
调整权重矩阵 Wy
-
调整权重矩阵 Ws
-
更新 Wx 的过程
调整权重矩阵 Wy
该模型可以通过如下方式进行可视化:
图 5.18:通过权重矩阵 Wy 对损失进行反向传播
对于 Wy,更新非常简单,因为 Wy 和误差之间没有其他路径或变量。该矩阵可以按以下方式表示:
图 5.19:权重矩阵 Wy 的表达式
调整权重矩阵 Ws
图 5.20:通过权重矩阵 Ws 对损失进行反向传播,关于 S3
我们可以使用链式法则计算误差关于Ws的偏导数,如前面的图所示。看起来这就是所需的,但重要的是要记住,S****t依赖于S****t-1,因此S****3依赖于S****2,所以我们还需要考虑S****2,如图所示:
图 5.21:通过权重矩阵 Ws 对 S2 进行损失的反向传播
同样,S****2依赖于S****1,因此也需要考虑S****1,如图所示:
图 5.22:通过权重矩阵 Ws 对 S1 进行损失的反向传播
在t=3时,我们必须考虑状态S****3对误差的贡献,状态S****2对误差的贡献,以及状态S****1对误差的贡献,E****3。最终的值如下所示:
图 5.23:t=3 时关于 Ws 的所有误差导数之和
一般来说,对于时间步N,需要考虑之前时间步的所有贡献。因此,一般公式如下所示:
图 5.24:关于 Ws 的误差导数的一般表达式
用于更新Wx
我们可以使用链式法则计算误差关于Wx的偏导数,如接下来的几张图所示。基于S****t依赖于S****t-1的相同推理,误差关于Wx的偏导数计算可以分为三个阶段,在t=3时进行。
图 5.25:通过权重矩阵 Wx 对 S2 进行损失的反向传播
通过权重矩阵 Wx 对 S2 进行损失的反向传播:
图 5.26:通过权重矩阵 Wx 对 S2 进行损失的反向传播
通过权重矩阵 Wx 对 S1 进行损失的反向传播:
图 5.27:通过权重矩阵 Wx 对 S1 进行损失的反向传播
类似于前面的讨论,在t=3时,我们必须考虑状态S****3对误差的贡献,状态S****2对误差的贡献,以及状态S****1对误差的贡献,E****3。最终的值如下所示:
图 5.28: 在 t=3 时关于 Wx 的所有误差导数之和
一般来说,对于时间步 N,需要考虑前面所有时间步的贡献。因此,通用公式如下所示:
图 5.29: 关于 Wx 的误差导数的通用表达式
由于链式导数在t=3时已经有 5 个相乘项,到第 20 时间步时,这个数量增长到了 22 个相乘项。每一个导数可能大于 0 或小于 0。由于连续乘法和更长的时间步,总导数会变得更小或更大。这个问题即为消失梯度或爆炸梯度。
梯度
已识别的两种梯度类型是:
-
爆炸梯度
-
消失梯度
爆炸梯度
正如名称所示,当梯度爆炸到更大的值时,就会发生这种情况。这可能是 RNN 架构在较大时间步时遇到的问题之一。当每个偏导数大于 1 时,乘法会导致一个更大的值。这些更大的梯度值每次通过反向传播调整权重时,会导致权重发生剧烈变化,从而使网络无法很好地学习。
有一些技术可以缓解这个问题,比如梯度裁剪,当梯度超过设定的阈值时会进行归一化处理。
消失梯度
无论是 RNN 还是 CNN,如果计算出的损失需要反向传播很长时间,消失梯度可能会成为问题。在 CNN 中,当有很多层并且激活函数是 sigmoid 或 tanh 时,这个问题可能会出现。损失需要反向传播到最初的层,而这些激活函数通常会在损失到达最初的层时将其稀释,意味着初始层几乎没有权重更新,导致欠拟合。即使是在 RNN 中也很常见,因为即使网络只有一个 RNN 层但时间步长较多,损失也需要通过时间反向传播穿越所有的时间步。由于梯度是相乘的,如前面所见的广义导数表达式,这些值往往变得较小,且在某个时间步后权重不会被更新。这意味着即使给网络显示更多的时间步,网络也无法受益,因为梯度无法完全反向传播。这种 RNN 的限制是由消失梯度引起的。
如其名称所示,当梯度变得过小时,就会发生这种情况。当每个偏导数小于 1 时,这种情况可能发生,并且这些偏导数的乘积会导致一个更小的值。由于信息的几何衰减,网络无法正确学习。权重值几乎没有变化,这会导致欠拟合。
必须有一种更好的机制来知道应记住前面时刻的哪些部分,哪些部分该忘记,等等。为了解决这个问题,像 LSTM 网络和 GRU 这样的架构应运而生。
使用 Keras 构建 RNN
到目前为止,我们已经讨论了 RNN 的理论背景,但有许多可用的框架可以抽象出实现的细节。只要我们知道如何使用这些框架,我们就能成功地让项目运行。TensorFlow、Theano、Keras、PyTorch 和 CNTK 都是这些框架中的一部分。在这一章中,让我们更详细地了解最常用的框架——Keras。它使用 Tensorflow 或 Theano 作为后端,这意味着它创建了比其他框架更高的抽象级别。它是最适合初学者的工具。一旦熟悉了 Keras,像 TensorFlow 这样的工具可以在实现自定义函数时提供更大的能力。
你将在接下来的几章中学习到许多 RNN 的变种,但它们都使用相同的基类,称为 RNN:
keras.layers.RNN(cell, return_sequences=False, return_state=False, go_backwards=False, stateful=False, unroll=False)
在这一章中,我们讨论了 RNN 的简单形式,它在 Keras 中称为 SimpleRNN:
keras.layers.SimpleRNN(units, activation='tanh', use_bias=True, kernel_initializer='glorot_uniform', recurrent_initializer='orthogonal', bias_initializer='zeros', kernel_regularizer=None, recurrent_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, recurrent_constraint=None, bias_constraint=None, dropout=0.0, recurrent_dropout=0.0, return_sequences=False, return_state=False, go_backwards=False, stateful=False, unroll=False)
正如你从这里的参数所看到的,有两种类型:一种是常规的卷积核,用于计算层的输出,另一种是用于计算状态的循环卷积核。不要太担心约束、正则化器、初始化器和丢弃法。你可以在 keras.io/layers/recurrent/ 找到更多信息。它们主要用于避免过拟合。激活函数在这里的作用与任何其他层的激活函数作用相同。
单元数是指特定层中递归单元的数量。单元数量越多,需要学习的参数就越多。
return_sequences 是指定 RNN 层是否应返回整个序列还是仅返回最后一个时刻的参数。如果 return_sequences 为 False,则 RNN 层的输出仅为最后一个时刻,因此无法将其与另一个 RNN 层堆叠。换句话说,如果一个 RNN 层需要堆叠到另一个 RNN 层,则 return_sequences 需要为 True。如果 RNN 层连接到 Dense 层,这个参数可以是 True 或 False,具体取决于应用需求。
return_state 参数指定是否需要返回 RNN 的最后状态以及输出结果。根据应用需求,可以将其设置为 True 或 False。
go_backwards 可以用来处理输入序列的反向处理。如果由于某种原因需要反向处理输入序列,设置为 True 即可。请注意,如果将其设置为 True,返回的序列也会被反转。
stateful 是一个参数,如果需要在批次之间传递状态,可以将其设置为 true。如果将此参数设置为 true,数据需要谨慎处理;我们有一个话题会详细讲解这个问题。
unroll 是一个参数,若设置为 true,会使网络展开,这样可以加速操作,但根据时间步长的不同,可能会非常消耗内存。通常,对于短序列,这个参数会设置为 true。
时间步长不是某一层的参数,因为它对整个网络保持一致,这在输入形状中有所表示。这引出了使用 RNN 时网络形状的一个重要点:
Input_shape
3D tensor with shape (batch_size, timesteps, input_dim)
Output_shape
If return_sequences is true, 3D tensor with shape (batch_size, timesteps, units)
If return_sequences is false, 2D tensor with shape (batch_size, units)
If return_state is True, a list of 2 tensors, 1 is output tensor same as above depending on return_sequences, the other is state tensor of shape (batch_size, units)
注
如果你开始构建一个包含 RNN 层的网络,必须指定input_shape。
构建模型后,可以使用model.summary()查看每一层的形状以及总参数数量。
练习 23:构建一个 RNN 模型,以展示参数随时间的稳定性
让我们构建一个简单的 RNN 模型,展示参数在时间步长上保持不变。注意,提到input_shape参数时,除非需要,否则无需提及batch_size。在状态保持网络中需要batch_size,我们接下来将讨论这个问题。训练模型时,batch_size会在使用 fit()或fit_generator()函数时提及。
以下步骤将帮助你解决这个问题:
-
导入必要的 Python 包。我们将使用 Sequential、SimpleRNN 和 Dense。
from keras.models import Sequential from keras.layers import SimpleRNN, Dense -
接下来,我们定义模型及其各层:
model = Sequential() # Recurrent layer model.add(SimpleRNN(64, input_shape=(10,100), return_sequences=False)) # Fully connected layer model.add(Dense(64, activation='relu')) # Output layer model.add(Dense(100, activation='softmax')) -
你可以查看模型的摘要:
model.summary()model.summary()会给出以下输出:图 5.30:模型层的模型摘要
在这种情况下,
batch_size参数将由fit()函数提供。由于不返回序列,RNN 层的输出形状是 (None, 64)。 -
让我们看一下返回序列的模型:
model = Sequential() # Recurrent layer model.add(SimpleRNN(64, input_shape=(10,100), return_sequences=True)) # Fully connected layer model.add(Dense(64, activation='relu')) # Output layer model.add(Dense(100, activation='softmax')) model.summary()返回序列的模型摘要如下所示:
图 5.31:返回序列模型的模型摘要
现在,RNN 层返回的是一个序列,因此它的输出形状是 3D,而不是之前看到的 2D。此外,请注意,Dense 层会自动适应其输入的变化。当前版本的 Keras 中,Dense 层能够自动适应来自之前 RNN 层的时间步长。在之前的 Keras 版本中,TimeDistributed(Dense) 用于实现这一点。
-
我们之前讨论过 RNN 如何在时间步长上共享参数。让我们实际看一下,并将之前模型的时间步长从 10 改为 1,000:
model = Sequential() # Recurrent layer model.add(SimpleRNN(64, input_shape=(1000,100), return_sequences=True)) # Fully connected layer model.add(Dense(64, activation='relu')) # Output layer model.add(Dense(100, activation='softmax')) model.summary()
图 5.32:时间步长的模型摘要
很明显,网络的输出形状已经改变为这个新的 time_steps。然而,两个模型之间的参数没有变化。
这表明参数是随时间共享的,并且不会受到时间步数变化的影响。请注意,当在多个时间步上操作时,同样适用于Dense层。
有状态与无状态
RNN 有两种考虑状态的操作模式:无状态模式和有状态模式。如果参数 stateful=True,则您正在使用有状态模式,False表示无状态模式。
无状态模式基本上是指批次中的一个示例与下一个批次中的任何示例无关;也就是说,每个示例在给定情况下是独立的。每个示例后的状态会被重置。每个示例具有根据模型架构确定的时间步数。例如,我们看到的上一个模型有 1,000 个时间步,在这 1,000 个时间步之间,状态向量会被计算并从一个时间步传递到下一个时间步。然而,在示例的结尾或下一个示例的开始时,没有状态被传递。每个示例是独立的,因此无需考虑数据如何洗牌。
在有状态模式下,批次 1的示例i的状态会传递到批次 2的i+1示例。这意味着状态会在批次之间的示例中传递。因此,示例必须在批次之间是连续的,而不能是随机的。下图解释了这一情况。示例i、i+1、i+2等是连续的,j、j+1、j+2等也是连续的,k、k+1、k+2等也是如此。
图 5.33 状态 RNN 的批量格式
练习 24:通过仅更改参数将无状态网络转变为有状态网络
为了通过更改参数将网络从无状态转变为有状态,应该采取以下步骤。
-
首先,我们需要导入所需的 Python 包:
from keras.models import Sequential from keras.layers import SimpleRNN, Dense -
接下来,使用
Sequential构建模型并定义层:model = Sequential() # Recurrent layer model.add(SimpleRNN(64, input_shape=(1000,100), return_sequences=True, stateful=False)) # Fully connected layer model.add(Dense(64, activation='relu')) # Output layer model.add(Dense(100, activation='softmax')) model.summary() -
将优化器设置为
Adam,将categoricalcrossentropy设置为损失函数参数,并设置指标来拟合模型。编译模型并在 100 个周期内拟合模型:model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) model.fit(X, Y, batch_size=32, epochs=100, shuffle=True) -
假设
X和Y是作为连续示例的训练数据。将此模型转为有状态模型:model = Sequential() # Recurrent layer model.add(SimpleRNN(64, input_shape=(1000,100), return_sequences=True, stateful=True)) # Fully connected layer model.add(Dense(64, activation='relu')) # Output layer model.add(Dense(100, activation='softmax')) -
将优化器设置为
Adam,将categoricalcrossentropy设置为损失函数参数,并设置指标来拟合模型。编译模型并在 100 个周期内拟合模型:model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) model.fit(X, Y, batch_size=1, epochs=100, shuffle=False) -
您可以使用箱型图来可视化输出。
results.boxplot() pyplot.show()预期输出:
图 5.34:有状态与无状态的箱型图
注意
输出可能会根据使用的数据而有所不同。
从有状态模型的概念中,我们了解到,按批次输入的数据需要是连续的,因此需要关闭随机化OFF。然而,即使batch_size >1,批次之间的数据也不会是连续的,因此需要设置batch_size=1。通过将网络设置为stateful=True并使用上述参数进行拟合,实际上我们是在以有状态的方式正确地训练模型。
然而,我们没有使用小批量梯度下降的概念,也没有对数据进行打乱。因此,需要实现一个生成器,以便小心地训练有状态的网络,但这超出了本章的范围。
model.compile是一个函数,用于为网络分配优化器、损失函数和我们关心的评估指标。
model.fit()是一个用于训练模型的函数,通过指定训练数据、验证数据、训练轮数、批次大小、打乱模式等来进行训练。
活动 6:使用 RNN 解决问题——作者身份归属。
作者身份归属是一个经典的文本分类问题,属于自然语言处理(NLP)范畴。作者身份归属是一个研究深入的问题,它催生了风格计量学领域。
在这个问题中,我们给定了一组来自特定作者的文档。我们需要训练一个模型来理解作者的写作风格,并使用该模型来识别未知文档的作者。与许多其他自然语言处理(NLP)问题一样,这个问题得益于计算能力、数据和先进机器学习技术的提升。这使得作者身份归属成为使用**深度学习(DL)**的一个自然选择。特别是,我们可以利用深度学习自动提取与特定问题相关的特征的能力。
在本次活动中,我们将专注于以下内容:
-
从每个作者的文本中提取字符级别的特征(以获取每个作者的写作风格)。
-
使用这些特征构建一个分类模型来进行作者身份归属。
-
将模型应用于识别一组未知文档的作者。
注意
你可以在 github.com/TrainingByP… 找到本次活动所需的数据。
以下步骤将帮助你解决问题。
-
导入必要的 Python 包。
-
上传要使用的文本文件。然后,通过将所有文本转换为小写、将所有换行符和多个空格转换为单个空格,并去除任何提到作者姓名的部分来预处理文本文件,否则我们可能会导致数据泄露。
-
为了将长文本分割成较小的序列,我们使用 Keras 框架中的
Tokenizer类。 -
接下来创建训练集和验证集。
-
我们构建模型图并执行训练过程。
-
将模型应用于未知的文档。对Unknown文件夹中的所有文档进行此操作。
预期输出:
图 5.35:作者归属的输出
注意
活动的解答可以在第 309 页找到。
总结
在本章中,我们介绍了循环神经网络(RNN),并讨论了 RNN 与前馈神经网络(FFNN)架构之间的主要区别。我们学习了反向传播通过时间(BPTT)以及权重矩阵的更新方法。我们通过 Keras 学习了如何使用 RNN,并通过 Keras 解决了一个作者归属的问题。我们通过观察梯度消失和梯度爆炸问题,分析了 RNN 的不足之处。在接下来的章节中,我们将深入研究解决这些问题的架构。
第七章:第六章
门控递归单元(GRU)
学习目标
本章结束时,你将能够:
-
评估简单递归神经网络(RNNs)的缺点
-
描述门控递归单元(GRU)的架构
-
使用 GRU 进行情感分析
-
应用 GRU 进行文本生成
本章旨在为现有 RNN 架构的缺点提供解决方案。
介绍
在前几章中,我们学习了文本处理技术,如词嵌入、分词和词频-逆文档频率(TFIDF)。我们还了解了一种特定的网络架构——递归神经网络(RNN),其存在消失梯度的问题。
在本章中,我们将研究一种通过在网络中添加记忆的有序方法来处理消失梯度的机制。本质上,GRU 中使用的门控是决定哪些信息应该传递到网络下一个阶段的向量。反过来,这有助于网络相应地生成输出。
一个基本的 RNN 通常由输入层、输出层和几个相互连接的隐藏层组成。下图展示了 RNN 的基本架构:
图 6.1:一个基本的 RNN
RNN 在其最简单的形式中存在一个缺点,即无法保留序列中的长期关系。为了纠正这个缺陷,需要向简单的 RNN 网络中添加一个特殊的层,叫做门控递归单元(GRU)。
在本章中,我们将首先探讨简单 RNN 无法保持长期依赖关系的原因,然后介绍 GRU 层及其如何尝试解决这个特定问题。接着,我们将构建一个包含 GRU 层的网络。
简单 RNN 的缺点
让我们通过一个简单的例子来回顾一下消失梯度的概念。
本质上,你希望使用 RNN 生成一首英文诗歌。在这里,你设置了一个简单的 RNN 来完成任务,结果它生成了以下句子:
"这些花,尽管已是秋天,却像星星一样盛开。"
很容易发现这里的语法错误。单词'blooms'应该是'bloom',因为句子开头的单词'flowers'表示应该使用'bloom'的复数形式,以使主谓一致。简单的 RNN 无法完成这一任务,因为它无法保留句子开头出现的单词'flowers'和后面出现的单词'blooms'之间的依赖关系(理论上,它应该能够做到!)。
GRU(门控循环单元)通过消除“消失梯度”问题来帮助解决这一问题,该问题妨碍了网络的学习能力,在长时间的文本关系中,网络未能保留这些关系。在接下来的部分中,我们将专注于理解消失梯度问题,并详细讨论 GRU 如何解决这个问题。
现在让我们回顾一下神经网络是如何学习的。在训练阶段,输入逐层传播,直到输出层。由于我们知道在训练过程中,对于给定输入,输出应该产生的确切值,我们计算预期输出和实际输出之间的误差。然后,这个误差被输入到一个成本函数中(这个成本函数会根据问题和网络开发者的创意而有所不同)。接下来的步骤是计算该成本函数相对于网络每个参数的梯度,从离输出层最近的层开始,一直到最底层的输入层:
图 6.2:一个简单的神经网络
考虑一个非常简单的神经网络,只有四层,并且每一层之间只有一个连接,并且只有一个单一的输出,如前图所示。请注意,实际应用中你不会使用这样的网络;它这里只是用来演示消失梯度问题的概念。
现在,为了计算成本函数相对于第一隐藏层偏置项 b[1] 的梯度,需要进行以下计算:
图 6.3:使用链式法则计算梯度
这里,每个元素的解释如下:
grad(x, y) = x 关于 y 的梯度
d(var) = 'var' 变量的 'sigmoid' 导数
w[i] = 第 'i' 层的权重
b[i] = 第 'i' 层的偏置项
a[i] = 第 'i' 层的激活函数
z[j] = w[j]*a[j-1] + b[j]
上述表达式可以归因于微分链式法则。
上述方程涉及多个项的乘法。如果这些项中的大多数值是在 -1 和 1 之间的分数,那么这些分数的乘积最终会得到一个非常小的值。在上述例子中,grad(C,b[1]) 的值将是一个非常小的分数。问题在于,这个梯度是将在下一次迭代中用来更新 b[1] 值的项:
![图 6.4:使用梯度更新 b[1] 的值
图 6.4:使用梯度更新 b[1] 的值
注意
使用不同优化器执行更新可能有几种方式,但概念本质上是相同的。
这种情况的结果是,b[1]的值几乎没有从上一次迭代中改变,这导致了非常缓慢的学习进展。在一个现实世界的网络中,可能有很多层,这种更新会变得更加微小。因此,网络越深,梯度问题就越严重。这里还观察到,靠近输出层的层学习得比靠近输入层的层更快,因为前者的乘法项更少。这也导致了学习的不对称性,进而引发了梯度的不稳定性。
那么,这个问题对简单 RNN 有什么影响呢?回顾一下 RNN 的结构;它本质上是一个随着时间展开的层次结构,层数与单词数量相同(对于建模问题)。学习过程通过时间反向传播(BPTT)进行,这与之前描述的机制完全相同。唯一的区别是,每一层的相同参数都会被更新。后面的层对应的是句子中后出现的单词,而前面的层对应的是句子中先出现的单词。由于梯度消失,前面的层与初始值的变化很小,因此它们对后续层的影响也很小。距离当前时间点't'越远的层,对该时间点层输出的影响就越小。因此,在我们的示例句子中,网络很难学习到“flowers”是复数形式,这导致了错误的“bloom”形式被使用。
梯度爆炸问题
事实证明,梯度不仅会消失,还可能会爆炸——也就是说,早期层可能学习得过快,训练迭代之间的值偏差较大,而后期层的梯度变化则不那么迅速。这是如何发生的呢?好吧,回顾一下我们的方程式,如果单个项的值远大于 1 的数量级,乘法效应就会导致梯度变得非常大。这会导致梯度的不稳定并引发学习问题。
最终,这个问题是梯度不稳定的问题。实际上,梯度消失问题比梯度爆炸问题更常见,也更难解决。
幸运的是,梯度爆炸问题有一个强有力的解决方案:裁剪(clipping)。裁剪仅仅是指停止梯度值的增长,防止其超过预设的值。如果梯度值没有被裁剪,你将开始看到梯度和网络权重出现 NaN(不是一个数字),这是由于计算机的表示溢出。为梯度值设定上限可以帮助避免这个问题。请注意,裁剪只限制梯度的大小,而不限制其方向。因此,学习过程仍然沿着正确的方向进行。梯度裁剪效果的简单可视化可以在下图中看到:
图 6.5:裁剪梯度以应对梯度爆炸
门控循环单元(GRUs)
GRUs 帮助网络以显式的方式记住长期依赖关系。这是通过在简单的 RNN 结构中引入更多的变量实现的。
那么,什么可以帮助我们解决梯度消失问题呢?直观地说,如果我们允许网络从前一个时间步的激活函数中转移大部分知识,那么误差可以比简单的 RNN 情况更忠实地反向传播。如果你熟悉用于图像分类的残差网络,你会发现这个函数与跳跃连接(skip connection)非常相似。允许梯度反向传播而不消失,能够使网络在各层之间更加均匀地学习,从而消除了梯度不稳定的问题:
图 6.6:完整的 GRU 结构
上图中不同符号的含义如下:
图 6.7:GRU 图中不同符号的含义
注意
哈达玛积运算是逐元素矩阵乘法。
上图展示了 GRU 所利用的所有组件。你可以观察到不同时间步(h[t]、h[t-1])下的激活函数 h。**r[t]**项表示重置门,**z[t]**项表示更新门。**h'[t]项表示候选函数,为了明确表达,我们将在方程中用h_candidate[t]**变量表示它。GRU 层使用更新门来决定可以传递给下一个激活的先前信息量,同时使用重置门来决定需要忘记的先前信息量。在本节中,我们将详细检查这些术语,并探讨它们如何帮助网络记住文本结构中的长期关系。
下一层的激活函数(隐藏层)的表达式如下:
图 6.8:下一层激活函数的表达式,以候选激活函数为基础
因此,激活函数是上一时间步的激活与当前时间步的候选激活函数的加权和。z[t] 函数是一个 sigmoid 函数,因此其取值范围在 0 和 1 之间。在大多数实际情况下,值更接近 0 或 1。在深入探讨前述表达式之前,让我们稍微观察一下引入加权求和方案更新激活函数的效果。如果z[t] 在多个时间步中保持为 1,则表示非常早期时间步的激活函数的值仍然可以传递到更晚的时间步。这反过来为网络提供了记忆能力。
此外,请观察它与简单 RNN 的不同之处,在简单 RNN 中,激活函数的值在每个时间步都会被覆盖,而不会显式地加权前一时间步的激活(在简单 RNN 中,前一激活的贡献是通过非线性操作隐含存在的)。
门类型
现在让我们在接下来的部分中展开讨论前述的激活更新方程。
更新门
更新门由下图表示。正如你从完整的 GRU 图中看到的,只有相关部分被突出显示。更新门的目的是确定从前一时间步传递到下一步激活的必要信息量。为了理解该图和更新门的功能,请考虑以下计算更新门的表达式:
图 6.9:计算更新门的表达式
以下图展示了更新门的图形表示:
图 6.10:完整 GRU 图中的更新门
隐藏状态的数量是n_h(h的维度),而输入维度的数量是 n_x。时间步 t 的输入(x[t])将与一组新权重 W_z 相乘,维度为(n_h, n_x)。上一时间步的激活函数(h[t-1])将与另一组新权重 U_z 相乘,维度为(n_h, n_h)。
请注意,这里的乘法是矩阵乘法。然后将这两个项相加,并通过 sigmoid 函数将输出 z[t] 压缩到 [0,1] 范围内。z[t] 输出具有与激活函数相同的维度,即(n_h, 1)。W_z 和 U_z 参数也需要通过 BPTT 进行学习。让我们编写一个简单的 Python 代码片段来帮助我们理解更新门:
import numpy as np
# Write a sigmoid function to be used later in the program
def sigmoid(x):
return 1 / (1 + np.exp(-x))
n_x = 5 # Dimensionality of input vector
n_h = 3 # Number of hidden units
# Define an input at time 't' having a dimensionality of n_x
x_t = np.random.randn(n_x, 1)
# Define W_z, U_z and h_prev (last time step activation)
W_z = np.random.randn(n_h, n_x) # n_h = 3, n_x=5
U_z = np.random.randn(n_h, n_h) # n_h = 3
h_prev = np.random.randn(n_h, 1)
图 6.11:显示权重和激活函数的截图
以下是更新门表达式的代码片段:
# Calculate expression for update gate
z_t = sigmoid(np.matmul(W_z, x_t) + np.matmul(U_z, h_prev))
在前面的代码片段中,我们初始化了 x[t]、W_z、U_z 和 h_prev 的随机值,以演示 z[t] 的计算。在真实的网络中,这些变量将具有更相关的值。
重置门
重置门由以下图示表示。从完整的 GRU 图示中可以看到,只有相关部分被突出显示。重置门的目的是确定在计算下一个步骤的激活时,应该遗忘多少来自前一个时间步的信息。为了理解图示和重置门的功能,考虑以下用于计算重置门的表达式:
图 6.12:计算重置门的表达式
以下图示展示了重置门的图形表示:
图 6.13:重置门
在时间步 t 处的输入与权重 W_r 相乘,使用维度(n_h, n_x)。然后,将前一个时间步的激活函数(h[t-1])与另一组新权重 U_r 相乘,使用维度(n_h, n_h)。请注意,这里的乘法是矩阵乘法。然后将这两个项相加,并通过 sigmoid 函数将 r[t] 输出压缩到 [0,1] 范围内。r[t] 输出具有与激活函数相同的维度,即(n_h, 1)。
W_r 和 U_r 参数也需要通过 BPTT 进行学习。让我们来看一下如何在 Python 中计算重置门的表达式:
# Define W_r, U_r
W_r = np.random.randn(n_h, n_x) # n_h = 3, n_x=5
U_r = np.random.randn(n_h, n_h) # n_h = 3
# Calculate expression for update gate
r_t = sigmoid(np.matmul(W_r, x_t) + np.matmul(U_r, h_prev))
在前面的代码片段中,已经使用了来自更新门代码片段的 x_t、h_prev、n_h 和 n_x 变量的值。请注意,r_t 的值可能不会特别接近 0 或 1,因为这只是一个示例。在经过良好训练的网络中,值应该接近 0 或 1:
图 6.14:显示权重值的截图
图 6.15:显示 r_t 输出的截图
候选激活函数
在每个时间步长还计算了一个候选激活函数,用于替换前一个时间步长的激活函数。顾名思义,候选激活函数表示下一个时间步长激活函数应该采取的替代值。看看以下计算候选激活函数的表达式:
图 6.16: 计算候选激活函数表达式
以下图显示了候选激活函数的图形表示:
图 6.17: 候选激活函数
在时间步 t 处的输入与权重 W 进行了维度 (n_h, n_x) 的乘法。W 矩阵的作用与简单 RNN 中使用的矩阵相同。然后,重置门与上一个时间步长的激活函数 (h[t-1]) 进行逐元素乘法。这个操作被称为 'Hadamard 乘法'。乘法的结果通过维度为 (n_h, n_h) 的 U 矩阵进行矩阵乘法。U 矩阵与简单 RNN 中使用的矩阵相同。然后将这两项加在一起,并通过双曲正切函数进行处理,以使输出 h_candidate[t] 被压缩到 [-1,1] 的范围内。h_candidate[t] 输出的维度与激活函数相同,即 (n_h, 1):
# Define W, U
W = np.random.randn(n_h, n_x) # n_h = 3, n_x=5
U = np.random.randn(n_h, n_h) # n_h = 3
# Calculate h_candidate
h_candidate = np.tanh(np.matmul(W, x_t) + np.matmul(U,np.multiply(r_t, h_prev)))
再次使用与更新门和重置门计算中相同的变量值。请注意,Hadamard 矩阵乘法是使用 NumPy 函数 'multiply' 实现的:
图 6.18: 展示了 W 和 U 权重如何定义的截图
以下图显示了 h_candidate 函数的图形表示:
图 6.19: 展示了 h_candidate 值的截图
现在,由于已经计算出更新门、重置门和候选激活函数的值,我们可以编写用于传递到下一层的当前激活函数表达式:
# Calculate h_new
h_new = np.multiply(z_t, h_prev) + np.multiply((1-z_t), h_candidate)
图 6.20: 展示了当前激活函数的值的截图
从数学角度讲,更新门的作用是选择前一个激活函数和候选激活函数之间的加权。因此,它负责当前时间步的激活函数的最终更新,并决定多少前一个激活函数和候选激活函数将传递到下一层。复位门的作用是选择或取消选择前一个激活函数的部分。这就是为什么要对前一个激活函数和复位门向量进行逐元素乘法的原因。考虑我们之前提到的诗歌生成句子的例子:
“尽管是秋天,花朵像星星一样绽放。”
一个复位门将用于记住单词“flowers”对单词“bloom”复数形式的影响,这种影响发生在句子的后半部分。因此,复位门向量中特定的值负责记住单词的复数或单数形式,该值将接近 0 或 1。如果 0 表示单数,那么在我们的例子中,复位门将保持值为 1,以记住单词“bloom”应采用复数形式。复位门向量中的不同值将记住句子复杂结构中的不同关系。
作为另一个例子,请考虑以下句子:
“来自法国的食物很美味,但法国人也非常热情好客。”
在分析句子结构时,我们可以看到有几个复杂的关系需要记住:
-
单词“food”与单词“delicious”相关(这里,“delicious”只能在“food”的语境中使用)。
-
单词“France”与“French”人相关。
-
单词“people”和“were”是相互关联的;也就是说,单词“people”的使用决定了“was”的正确形式。
在一个训练良好的网络中,复位门的向量中会包含所有这些关系的条目。这些条目的值将根据需要记住哪些来自先前激活的关系,哪些需要遗忘,适当地被设置为“关闭”或“开启”。在实践中,很难将复位门或隐藏状态的条目归因于某个特定功能。因此,深度学习网络的可解释性仍然是一个热门的研究话题。
GRU 的变种
上面描述的 GRU 的形式是完整的 GRU。许多独立的研究者已使用不同形式的 GRU,比如完全移除复位门或使用激活函数。然而,完整的 GRU 仍然是最常用的方法。
基于 GRU 的情感分析
情感分析是应用自然语言处理技术的一个热门用例。情感分析的目的是判断给定的文本是否可以被视为传达“正面”情绪或“负面”情绪。例如,考虑以下文本对一本书的评论:
"这本书有过辉煌的时刻,但似乎经常偏离了要点。这样水准的作者,肯定比这本书所呈现的要更多。"
对于人类读者来说,显然提到的这本书评传达了一个负面情绪。那么,如何构建一个机器学习模型来进行情感分类呢?和往常一样,使用监督学习方法时,需要一个包含多个样本的文本语料库。语料库中的每一篇文本应该有一个标签,指示该文本是可以映射到正面情绪还是负面情绪。下一步是使用这些数据构建一个机器学习模型。
观察这个例子,你已经可以看到这个任务对于机器学习模型来说可能是具有挑战性的。如果使用简单的分词或 TFIDF 方法,像‘辉煌’和‘水准’这样的词语可能会被分类器误认为是传达正面情绪。更糟糕的是,文本中没有任何词语可以直接解读为负面情绪。这一观察也揭示了需要连接文本中不同部分结构的必要性,以便从句子中提取出真正的意义。例如,第一句话可以被分解为两部分:
-
"这本书有过辉煌的时刻"
-
",但似乎经常偏离了要点。"
仅仅看句子的第一部分,可能会让你得出评论是积极的结论。只有在考虑到第二句话时,句子的含义才真正能被理解为表达负面情感。因此,这里需要保留长期依赖关系。简单的 RNN 模型显然无法胜任这项任务。那么我们来尝试在情感分类任务中应用 GRU,并看看它的表现。
练习 25:计算情感分类模型的验证准确率和损失
在本练习中,我们将使用 imdb 数据集编写一个简单的情感分类系统。imdb 数据集包含 25,000 个训练文本序列和 25,000 个测试文本序列——每个序列都包含一篇电影评论。输出变量是一个二元变量,如果评论为负面,则值为 0;如果评论为正面,则值为 1:
注意
所有练习和活动都应该在 Jupyter notebook 中运行。用于创建 Python 环境以运行此 notebook 的 requirements.txt 文件内容如下:h5py==2.9.0,keras==2.2.4,numpy==1.16.1,tensorflow==1.12.0。
解决方案:
我们首先加载数据集,如下所示:
from keras.datasets import imdb
-
我们还将定义生成训练序列时要考虑的最大频率最高的单词数为 10,000。我们还将限制序列长度为 500:
max_features = 10000 maxlen = 500 -
现在让我们按如下方式加载数据:
(train_data, y_train), (test_data, y_test) = imdb.load_data(num_words=max_features) print('Number of train sequences: ', len(train_data)) print('Number of test sequences: ', len(test_data))图 6.21:显示训练和测试序列的截图
-
可能存在长度小于 500 的序列,因此我们需要对其进行填充,使其长度恰好为 500。我们可以使用 Keras 函数来实现这一目的:
from keras.preprocessing import sequence train_data = sequence.pad_sequences(train_data, maxlen=maxlen) test_data = sequence.pad_sequences(test_data, maxlen=maxlen) -
让我们检查训练和测试数据的形状,如下所示:
print('train_data shape:', train_data.shape) print('test_data shape:', test_data.shape)验证两个数组的形状是否为(25,000, 500)。
-
现在让我们构建一个带有 GRU 单元的 RNN。首先,我们需要导入必要的包,如下所示:
from keras.models import Sequential from keras.layers import Embedding from keras.layers import Dense from keras.layers import GRU -
由于我们将使用 Keras 的顺序 API 来构建模型,因此需要从 Keras 模型中导入顺序模型 API。嵌入层本质上将输入向量转换为固定大小,然后可以将其馈送到网络的下一层。如果使用,它必须作为网络的第一层添加。我们还导入了一个 Dense 层,因为最终正是这个层提供了目标变量(0 或 1)的分布。
最后,我们导入 GRU 单元;让我们初始化顺序模型 API 并添加嵌入层,如下所示:
model = Sequential() model.add(Embedding(max_features, 32))嵌入层接受 max_features 作为输入,我们定义它为 10,000。32 的值在此处设置,因为下一个 GRU 层期望从嵌入层获取 32 个输入。
-
接下来,我们将添加 GRU 和 Dense 层,如下所示:
model.add(GRU(32)) model.add(Dense(1, activation='sigmoid')) -
32 的值是随意选择的,并可以作为设计网络时调整的超参数之一。它表示激活函数的维度。Dense 层只输出 1 个值,即评论(即我们的目标变量)为 1 的概率。我们选择 sigmoid 作为激活函数。
接下来,我们使用二元交叉熵损失和 rmsprop 优化器来编译模型:
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc']) -
我们选择将准确率(训练和验证)作为度量指标。接下来,我们将模型拟合到我们的序列数据上。请注意,我们还将从训练数据中分配 20%的样本作为验证数据集。我们还设置了 10 个周期和 128 的批次大小——即在一次前向反向传递中,我们选择在一个批次中传递 128 个序列:
history = model.fit(train_data, y_train, epochs=10, batch_size=128, validation_split=0.2图 6.22:显示训练模型的变量历史输出的截图
变量历史可以用来跟踪训练进度。上一个函数将触发一个训练会话,在本地 CPU 上训练需要几分钟时间。
-
接下来,让我们通过绘制损失和准确率来查看训练进展的具体情况。为此,我们将定义一个绘图函数,如下所示:
import matplotlib.pyplot as plt def plot_results(history): acc = history.history['acc'] val_acc = history.history['val_acc'] loss = history.history['loss'] val_loss = history.history['val_loss'] epochs = range(1, len(acc) + 1) plt.plot(epochs, acc, 'bo', label='Training Accuracy') plt.plot(epochs, val_acc, 'b', label='Validation Accuracy') plt.title('Training and validation Accuracy') plt.legend() plt.figure() plt.plot(epochs, loss, 'bo', label='Training Loss') plt.plot(epochs, val_loss, 'b', label='Validation Loss') plt.title('Training and validation Loss') plt.legend() plt.show() -
让我们在作为 'fit' 函数输出的 history 变量上调用我们的函数:
plot_results(history) -
当作者运行时,前面的代码输出如下面的图示所示:
期望输出:
](tos-cn-i-73owjymdk6/162acb0eb23144388806101182cddebe)
图 6.23:情感分类任务的训练和验证准确率
以下图示展示了训练和验证的损失:
](tos-cn-i-73owjymdk6/2ad6f34a836b4db891403924914028f0)
图 6.24:情感分类任务的训练和验证损失
注意
在最佳时期,验证准确率相当高(约 87%)。
活动 7:使用简单 RNN 开发情感分类模型
在这个活动中,我们的目标是使用一个简单的 RNN 生成一个情感分类模型。这样做是为了判断 GRU 相较于简单 RNN 的效果。
-
加载数据集。
-
填充序列,使每个序列具有相同数量的字符。
-
定义并编译模型,使用带有 32 个隐藏单元的简单 RNN。
-
绘制验证和训练准确率与损失。
注意
该活动的解决方案可以在第 317 页找到。
使用 GRU 进行文本生成
文本生成问题需要一个算法来根据训练语料库生成新文本。例如,如果你将莎士比亚的诗歌输入学习算法,那么该算法应该能够以莎士比亚的风格生成新文本(逐字符或逐词生成)。接下来,我们将展示如何使用本章所学的内容来处理这个问题。
练习 26:使用 GRU 生成文本
那么,让我们回顾一下本章前面部分提出的问题。也就是说,你希望使用深度学习方法生成一首诗。我们将使用 GRU 来解决这个问题。我们将使用莎士比亚的十四行诗来训练我们的模型,以便我们的输出诗歌呈现莎士比亚风格:
-
让我们先导入所需的 Python 包,如下所示:
import io import sys import random import string import numpy as np from keras.models import Sequential from keras.layers import Dense from keras.layers import GRU from keras.optimizers import RMSprop每个包的使用将在接下来的代码片段中变得清晰。
-
接下来,我们定义一个函数,从包含莎士比亚十四行诗的文件中读取内容并打印出前 200 个字符:
def load_text(filename): with open(filename, 'r') as f: text = f.read() return text file_poem = 'shakespeare_poems.txt' # Path of the file text = load_text(file_poem) print(text[:200])图 6.25:莎士比亚十四行诗截图
-
接下来,我们将进行一些数据准备步骤。首先,我们将从读取的文件中获取所有不同字符的列表。然后,我们将创建一个字典,将每个字符映射到一个整数索引。最后,我们将创建另一个字典,将整数索引映射到字符:
chars = sorted(list(set(text))) print('Number of distinct characters:', len(chars)) char_indices = dict((c, i) for i, c in enumerate(chars)) indices_char = dict((i, c) for i, c in enumerate(chars)) -
现在,我们将从文本中生成训练数据的序列。我们会为模型输入每个固定长度为 40 个字符的序列。这些序列将按滑动窗口的方式生成,每个序列滑动三步。考虑诗歌中的以下部分:
"从最美的生物中,我们渴望增长,
这样美丽的玫瑰就永远不会凋谢,"
我们的目标是从前面的文本片段中得到以下结果:
图 6.26:训练序列的屏幕截图
这些序列的长度均为 40 个字符。每个后续字符串都比前一个字符串向右滑动了三步。这样安排是为了确保我们得到足够的序列(而不会像步长为 1 时那样得到太多序列)。通常,我们可以有更多的序列,但由于这个例子是演示用的,因此将运行在本地 CPU 上,输入过多的序列会使训练过程变得比预期要长得多。
此外,对于这些序列中的每一个,我们需要有一个输出字符,作为文本中的下一个字符。本质上,我们是在教模型观察 40 个字符,然后学习下一个最可能的字符是什么。为了理解输出字符是什么,可以考虑以下序列:
这样美丽的玫瑰就永远不会凋谢
该序列的输出字符将是 i 字符。这是因为在文本中,i 是下一个字符。以下代码片段实现了相同的功能:
max_len_chars = 40
step = 3
sentences = []
next_chars = []
for i in range(0, len(text) - max_len_chars, step):
sentences.append(text[i: i + max_len_chars])
next_chars.append(text[i + max_len_chars])
print('nb sequences:', len(sentences))
现在我们有了希望训练的序列及其对应的字符输出。接下来,我们需要为样本获得一个训练矩阵,并为输出字符获得另一个矩阵,这些矩阵将被输入到模型中进行训练:
x = np.zeros((len(sentences), max_len_chars, len(chars)), dtype=np.bool)
y = np.zeros((len(sentences), len(chars)), dtype=np.bool)
for i, sentence in enumerate(sentences):
for t, char in enumerate(sentence):
x[i, t, char_indices[char]] = 1
y[i, char_indices[next_chars[i]]] = 1
这里,x 是存储输入训练样本的矩阵。x 数组的形状是序列的数量、最大字符数和不同字符的数量。因此,x 是一个三维矩阵。所以,对于每个序列,也就是对于每个时间步(=最大字符数),我们都有一个与文本中不同字符数量相同长度的独热编码向量。这个向量在给定时间步的字符位置上为 1,其他所有位置为 0。y 是一个二维矩阵,形状为序列数和不同字符数。因此,对于每个序列,我们都有一个与不同字符数量相同长度的独热编码向量。除了与当前输出字符对应的位置为 1,其他所有位置都是 0。独热编码是通过我们在之前步骤中创建的字典映射完成的。
-
我们现在准备好定义我们的模型,如下所示:
model = Sequential() model.add(GRU(128, input_shape=(max_len_chars, len(chars)))) model.add(Dense(len(chars), activation='softmax')) optimizer = RMSprop(lr=0.01) model.compile(loss='categorical_crossentropy', optimizer=optimizer) -
我们使用顺序 API,添加一个具有 128 个隐藏参数的 GRU 层,然后添加一个全连接层。
注意
Dense 层的输出数量与不同字符的数量相同。这是因为我们本质上是在学习我们词汇表中可能字符的分布。从这个意义上讲,这本质上是一个多类分类问题,这也解释了我们为何选择分类交叉熵作为代价函数。
-
我们现在继续将模型拟合到数据上,如下所示:
model.fit(x, y,batch_size=128,epochs=10) model.save("poem_gen_model.h5")在这里,我们选择了 128 个序列的批量大小,并训练了 10 个周期。我们还将模型保存在 hdf5 格式文件中,以便以后使用:
图 6.27:显示训练周期的截图
注意
你应该增加 GRU 的数量和训练周期。它们的值越高,训练模型所需的时间就越长,但可以期待更好的结果。
-
接下来,我们需要能够使用模型实际生成一些文本,如下所示:
from keras.models import load_model model_loaded = load_model('poem_gen_model.h5') -
我们还定义了一个采样函数,根据字符的概率分布选择一个候选字符:
def sample(preds, temperature=1.0): # helper function to sample an index from a probability array preds = np.asarray(preds).astype('float64') preds = np.log(preds) / temperature exp_preds = np.exp(preds) preds = exp_preds / np.sum(exp_preds) probas = np.random.multinomial(1, preds, 1) return np.argmax(probas) -
我们使用多项分布进行采样;温度参数有助于为概率分布添加偏差,使得较不可能的词可以有更多或更少的表示。你也可以简单地尝试对 preds 变量使用 argmax 参数,但这可能会导致单词的重复:
def generate_poem(model, num_chars_to_generate=400): start_index = random.randint(0, len(text) - max_len_chars - 1) generated = '' sentence = text[start_index: start_index + max_len_chars] generated += sentence print("Seed sentence: {}".format(generated)) for i in range(num_chars_to_generate): x_pred = np.zeros((1, max_len_chars, len(chars))) for t, char in enumerate(sentence): x_pred[0, t, char_indices[char]] = 1. preds = model.predict(x_pred, verbose=0)[0] next_index = sample(preds, 1) next_char = indices_char[next_index] generated += next_char sentence = sentence[1:] + next_char return generated -
我们传入加载的模型和希望生成的字符数。然后,我们传入一个种子文本供模型作为输入(记住,我们教模型在给定 40 个字符的序列长度的情况下预测下一个字符)。这一过程发生在 for 循环开始之前。在循环的第一次迭代中,我们将种子文本传入模型,生成输出字符,并将输出字符附加到‘generated’变量中。在下一次迭代中,我们将刚更新的序列(在第一次迭代后有 41 个字符)右移一个字符,这样模型就可以将这个包含 40 个字符的新输入作为输入,其中最后一个字符是我们刚刚生成的新字符。现在,可以按如下方式调用函数:
generate_poem(model_loaded, 100)看!你已经写出了一首莎士比亚风格的诗歌。一个示例输出如下所示:
图 6.28:显示生成的诗歌序列输出的截图
你会立刻注意到诗歌并没有真正有意义。这可以归因于两个原因:
-
上述输出是在使用非常少量的数据或序列时生成的。因此,模型无法学到太多。在实践中,你会使用一个更大的数据集,从中生成更多的序列,并使用 GPU 进行训练以确保合理的训练时间(我们将在最后一章 9-‘组织中的实际 NLP 项目工作流’中学习如何在云 GPU 上训练)。
-
即使使用大量数据进行训练,也总会有一些错误,因为模型的学习能力是有限的。
然而,我们仍然可以看到,即使在这个基本设置下,模型是一个字符生成模型,仍然有些词语是有意义的。像“我有喜好”这样的短语,作为独立短语是有效的。
注意
空白字符、换行符等也被模型学习。
活动 8:使用你选择的数据集训练你自己的字符生成模型
我们刚刚使用了一些莎士比亚的作品来生成我们自己的诗歌。你不必局限于诗歌生成,也可以使用任何一段文本来开始生成自己的写作。基本的步骤和设置与之前的示例相同。
注意
使用 requirements 文件创建一个 Conda 环境并激活它。然后,在 Jupyter notebook 中运行代码。别忘了输入一个包含你希望生成新文本的作者风格的文本文件。
-
加载文本文件。
-
创建字典,将字符映射到索引,反之亦然。
-
从文本中创建序列。
-
创建输入和输出数组以供模型使用。
-
使用 GRU 构建并训练模型。
-
保存模型。
-
定义采样和生成函数。
-
生成文本。
注意
活动的解决方案可以在第 320 页找到。
摘要
GRU 是简单 RNN 的扩展,它通过让模型学习文本结构中的长期依赖关系来帮助解决梯度消失问题。各种用例可以从这个架构单元中受益。我们讨论了情感分类问题,并了解了 GRU 如何优于简单 RNN。接着我们看到如何使用 GRU 来生成文本。
在下一章中,我们将讨论简单 RNN 的另一项进展——长短期记忆(LSTM)网络,并探讨它们通过新架构带来的优势。
第八章:第七章
长短期记忆(LSTM)
学习目标
本章结束时,您将能够:
-
描述 LSTM 的目的
-
详细评估 LSTM 的架构
-
使用 LSTM 开发一个简单的二分类模型
-
实现神经语言翻译,并开发一个英德翻译模型
本章简要介绍了 LSTM 架构及其在自然语言处理领域的应用。
介绍
在前面的章节中,我们学习了递归神经网络(RNN)以及一种专门的架构——门控递归单元(GRU),它有助于解决梯度消失问题。LSTM 提供了另一种解决梯度消失问题的方法。在本章中,我们将仔细研究 LSTM 的架构,看看它们如何使神经网络以忠实的方式传播梯度。
此外,我们还将看到 LSTM 在神经语言翻译中的有趣应用,这将使我们能够构建一个模型,用于将一种语言的文本翻译成另一种语言。
LSTM
梯度消失问题使得梯度很难从网络的后层传播到前层,导致网络的初始权重不会从初始值发生太大变化。因此,模型无法很好地学习,导致性能不佳。LSTM 通过引入“记忆”到网络中解决了这个问题,这使得文本结构中的长期依赖得以保持。然而,LSTM 的记忆添加方式与 GRU 的方式不同。在接下来的章节中,我们将看到 LSTM 是如何完成这一任务的。
LSTM 帮助网络以明确的方式记住长期依赖性。与 GRU 相似,这是通过在简单 RNN 的结构中引入更多变量来实现的。
使用 LSTM,我们允许网络从先前时间步的激活中转移大部分知识,这是简单 RNN 难以实现的壮举。
回忆一下简单 RNN 的结构,它本质上是相同单元的展开,可以通过以下图示表示:
图 7.1:标准 RNN 中的重复模块
图中块"A"的递归表示它是随着时间重复的相同结构。每个单元的输入是来自先前时间步的激活(由字母"h"表示)。另一个输入是时间"t"时的序列值(由字母"x"表示)。
与简单 RNN 的情况类似,LSTM 也具有固定的、时间展开的重复结构,但重复的单元本身具有不同的结构。每个 LSTM 单元有几种不同类型的模块,它们共同作用,为模型提供记忆。LSTM 的结构可以通过以下图示表示:
图 7.2:LSTM 单元
让我们也来熟悉一下在图示中使用的符号:
图 7.3:模型中使用的符号
LSTM 的最核心组件是单元状态,以下简称为字母“C”。单元状态可以通过下图中方框上端的粗线表示。通常,我们可以将这条线视为一条传送带,贯穿不同的时间步,并传递一些信息。尽管有多个操作可以影响传播通过单元状态的值,但实际上,来自前一个单元状态的信息很容易到达下一个单元状态。
图 7.4:单元状态
从修改单元状态的角度理解 LSTM 会非常有用。与 GRU 一样,LSTM 中允许修改单元状态的组件被称为“门”。
LSTM 在多个步骤中操作,具体步骤将在接下来的章节中描述。
遗忘门
遗忘门负责确定应该从前一个时间步中忘记的单元状态内容。遗忘门的表达式如下:
图 7.5:遗忘门的表达式
在时间步 t,输入与一组新的权重 W_f 相乘,维度为(n_h,n_x)。来自前一个时间步的激活值(h[t-1])与另一组新的权重 U_f 相乘,维度为(n_h,n_h)。请注意,这些乘法是矩阵乘法。然后这两个项相加,并通过 Sigmoid 函数进行压缩,使输出 f[t] 的值保持在 [0,1] 范围内。输出的维度与单元状态向量 C 的维度相同(n_h,1)。遗忘门为每个维度输出 '1' 或 '0'。值为 '1' 表示该维度的前一个单元状态的所有信息应通过并保留,而值为 '0' 表示该维度的前一个单元状态的所有信息应被忘记。图示如下:
图 7.6:遗忘门
那么,遗忘门的输出如何影响句子的构建呢?让我们来看一下生成的句子:
"Jack goes for a walk when his daughter goes to bed"。
句子中的第一个主语是 'Jack',表示男性性别。表示主语性别的单元状态值对应于 'Male'(这可以是 0 或 1)。直到句子中的 'his',主语没有改变,主语性别的单元状态继续保持 'male' 值。然而,接下来的单词 'daughter' 是新的主语,因此需要遗忘表示性别的旧值。注意,即使旧的性别状态是女性,仍然需要遗忘该值,以便使用与新主语对应的值。
遗忘门通过将主语性别值设置为 0 来完成“遗忘”操作(也就是说,f[t] 在该维度上输出 0)。
在 Python 中,可以使用以下代码片段计算遗忘门:
# Importing packages and setting the random seed to have a fixed output
import numpy as np
np.random.seed(0)
# A sigmoid needs to be defined to be used later
def sigmoid(x):
return 1 / (1 + np.exp(-x))
# Simulating dummy values for the previous state and current input
h_prev = np.random.randn(3, 1)
x = np.random.randn(5, 1)
这段代码为 h_prev 和 x 生成以下输出:
图 7.7:先前状态 'h_prev' 和当前输入 'x' 的输出
我们可以为 W_f 和 U_f 初始化一些虚拟值:
# Initialize W_f and U_f with dummy values
W_f = np.random.randn(3, 5) # n_h = 3, n_x=5
U_f = np.random.randn(3, 3) # n_h = 3
这将生成以下值:
图 7.8:矩阵值的输出
现在可以计算遗忘门:
f = sigmoid(np.matmul(W_f, x) + np.matmul(U_f, h_prev)
这会生成 f[t] 的以下值:
![图 7.9:遗忘门的输出,f[t]
图 7.9:遗忘门的输出,f[t]
输入门和候选单元状态
在每个时间步,新的候选单元状态也通过以下表达式计算:
图 7.10:候选单元状态的表达式
时间步 t 的输入与一组新的权重 W_c 相乘,维度为 (n_h, n_x)。来自前一时间步的激活值 (h[t-1]) 与另一组新的权重 U_c 相乘,维度为 (n_h, n_h)。注意,这些乘法是矩阵乘法。然后,这两个项相加并通过双曲正切函数进行压缩,输出 f[t] 在 [-1,1] 范围内。输出 C_candidate 的维度为 (n_h,1)。在随后的图中,候选单元状态由 C 波浪线表示:
图 7.11:输入门和候选状态
候选值旨在计算它从当前时间步推断的单元状态。在我们的示例句子中,这对应于计算新的主语性别值。这个候选单元状态不会直接传递给下一个单元状态进行更新,而是通过输入门进行调节。
输入门决定了候选单元状态中的哪些值将传递到下一个单元状态。可以使用以下表达式来计算输入门的值:
图 7.12:输入门值的表达式
时间步 t 的输入被乘以一组新的权重,W_i,其维度为 (n_h, n_x)。上一时间步的激活值 (h[t-1]) 被乘以另一组新的权重,U_i,其维度为 (n_h, n_h)。请注意,这些乘法是矩阵乘法。然后,这两个项相加并通过一个 sigmoid 函数来压缩输出,i[t],使其范围在 [0,1] 之间。输出的维度与单元状态向量 C 的维度相同 (n_h, 1)。在我们的示例句子中,在到达词语“daughter”后,需要更新单元状态中与主语性别相关的值。通过候选单元状态计算出新的主语性别候选值后,仅将与主语性别对应的维度设置为 1,输入门向量中的其他维度不变。
用于候选单元状态和输入门的 Python 代码片段如下:
# Initialize W_i and U_i with dummy values
W_i = np.random.randn(3, 5) # n_h = 3, n_x=5
U_i = np.random.randn(3, 3) # n_h = 3
这会为矩阵产生以下值:
图 7.13:候选单元状态和输入门的矩阵值截图
输入门可以按如下方式计算:
i = sigmoid(np.matmul(W_i, x) + np.matmul(U_i, h_prev))
这会输出以下的i值:
图 7.14:输入门输出的截图
为了计算候选单元状态,我们首先初始化W_c和U_c矩阵:
# Initialize W_c and U_c with dummy values
W_c = np.random.randn(3, 5) # n_h = 3, n_x=5
U_c = np.random.randn(3, 3) # n_h = 3
这些矩阵所产生的值如下所示:
图 7.15:矩阵 W_c 和 U_c 值的截图
我们现在可以使用候选单元状态的更新方程:
c_candidate = np.tanh(np.matmul(W_c, x) + np.matmul(U_c, h_prev))
候选单元状态产生以下值:
图 7.16:候选单元状态的截图
单元状态更新
在这一点,我们已经知道应该忘记旧单元状态中的哪些内容(遗忘门),应该允许哪些内容影响新的单元状态(输入门),以及候选单元状态的变化应该是什么值(候选单元状态)。现在,可以按以下方式计算当前时间步的单元状态:
图 7.17:单元状态更新的表达式
在前面的表达式中,'hadamard'表示逐元素相乘。因此,遗忘门与旧的单元状态逐元素相乘,允许它在我们的示例句子中忘记主语的性别。另一方面,输入门允许新的候选性别值影响新的单元状态。这两个项逐元素相加,使得当前单元状态现在具有与'female'对应的性别值。
下图展示了该操作
图 7.18:更新后的单元状态
下面是生成当前单元状态的代码片段。
首先,为之前的单元状态初始化一个值:
# Initialize c_prev with dummy value
c_prev = np.random.randn(3,1)
c_new = np.multiply(f, c_prev) + np.multiply(i, c_candidate)
计算结果如下:
图 7.19:更新后的单元状态输出截图
输出门和当前激活值
请注意,到目前为止,我们所做的只是更新单元状态。我们还需要为当前状态生成激活值;即(h[t])。这是通过输出门来实现的,输出门的计算方式如下:
图 7.20:输出门的表达式
时间步t的输入与一组新的权重W_o相乘,权重的维度为(n_h,n_x)。上一时间步的激活值(h[t-1])与另一组新的权重U_o相乘,权重的维度为(n_h,n_h)。请注意,这些乘法是矩阵乘法。然后,将这两个项相加,并通过 sigmoid 函数压缩输出值o[t],使其落在[0,1]范围内。输出的维度与单元状态向量h的维度相同(n_h,1)。
输出门负责调节当前单元状态对时间步的激活值的影响程度。在我们的示例句子中,值得传播的信息是描述主语是单数还是复数,以便使用正确的动词形式。例如,如果“daughter”后面的单词是动词“goes”,那么使用正确的形式“go”就显得非常重要。因此,输出门允许相关信息传递给激活值,这个激活值随后作为输入传递到下一个时间步。在下图中,输出门表示为o_t:
图 7.21:输出门和当前激活值
以下代码片段展示了如何计算输出门的值:
# Initialize dummy values for W_o and U_o
W_o = np.random.randn(3, 5) # n_h = 3, n_x=5
U_o = np.random.randn(3, 3) # n_h = 3
这将生成如下输出:
图 7.22:矩阵 W_o 和 U_o 输出的截图
现在可以计算输出:
o = np.tanh(np.matmul(W_o, x) + np.matmul(U_o, h_prev))
输出门的值如下:
图 7.23:输出门值的截图
一旦输出门被评估,就可以计算下一个激活的值:
图 7.24:计算下一个激活值的表达式
首先,应用一个双曲正切函数到当前单元状态。这将限制向量中的值在 -1 和 1 之间。然后,将此值与刚计算出的输出门值做逐元素乘积。
让我们来看一下计算当前时间步激活的代码片段:
h_new = np.multiply(o, np.tanh(c_new))
这最终会生成如下结果:
图 7.25:当前时间步激活的截图
现在让我们构建一个非常简单的二元分类器来演示 LSTM 的使用。
练习 27:构建一个基于 LSTM 的模型来将电子邮件分类为垃圾邮件或非垃圾邮件(正常邮件)
在本练习中,我们将构建一个基于 LSTM 的模型,帮助我们将电子邮件分类为垃圾邮件或真实邮件:
-
我们将从导入所需的 Python 包开始:
import pandas as pd import numpy as np from keras.models import Model, Sequential from keras.layers import LSTM, Dense,Embedding from keras.preprocessing.text import Tokenizer from keras.preprocessing import sequence注:
LSTM 单元已经按照你为简单的 RNN 或 GRU 导入的方式导入。
-
我们现在可以读取包含文本列和另一列标签的输入文件,该标签指示文本是否为垃圾邮件。
注
df = pd.read_csv("spam.csv", encoding="latin") df.head() -
数据看起来如下所示:
图 7.26:垃圾邮件分类输出的截图
-
还有一些无关的列,但我们只需要包含文本数据和标签的列:
df = df[["v1","v2"]] df.head() -
输出应该如下所示:
图 7.27:带有文本和标签的列的截图
-
我们可以检查标签分布:
df["v1"].value_counts()标签分布看起来如下:
图 7.28:标签分布的截图
-
我们现在可以将标签分布映射为 0/1,以便它可以被馈送到分类器中。同时,还会创建一个数组来存储文本:
lab_map = {"ham":0, "spam":1} Y = df["v1"].map(lab_map).values X = df["v2"].values -
这将生成如下的输出 X 和 Y:
图 7.29:输出 X 的截图
图 7.30:输出 Y 的截图
-
接下来,我们将限制为 100 个最常见单词生成的最大标记数。我们将初始化一个分词器,为文本语料库中使用的每个单词分配一个整数值:
max_words = 100 mytokenizer = Tokenizer(nb_words=max_words,lower=True, split=" ") mytokenizer.fit_on_texts(X) text_tokenized = mytokenizer.texts_to_sequences(X) -
这将生成一个
text_tokenized值:图 7.31:分词后值的输出截图
请注意,由于我们限制了最大单词数为 100,因此只有文本中排名前 100 的最常见单词会被分配整数索引。其余的单词将被忽略。因此,即使 X 中的第一个序列有 20 个单词,在该句子的标记化表示中也只有 6 个索引。
-
接下来,我们将允许每个序列的最大长度为 50 个单词,并填充那些短于此长度的序列。而较长的序列则会被截断:
max_len = 50 sequences = sequence.pad_sequences(text_tokenized, maxlen=max_len)输出如下:
图 7.32:填充序列的截图
请注意,填充是在“pre”模式下进行的,这意味着序列的初始部分会被填充,以使序列长度等于 max_len。
-
接下来,我们定义模型,LSTM 层具有 64 个隐藏单元,并将其拟合到我们的序列数据和相应的目标值:
model = Sequential() model.add(Embedding(max_words, 20, input_length=max_len)) model.add(LSTM(64)) model.add(Dense(1, activation="sigmoid")) model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy']) model.fit(sequences,Y,batch_size=128,epochs=10, validation_split=0.2)在这里,我们从一个嵌入层开始,确保输入到网络的固定大小(20)。我们有一个包含单个 sigmoid 输出的全连接层,该输出表示目标变量是 0 还是 1。然后,我们用二元交叉熵作为损失函数,并使用 Adam 作为优化策略来编译模型。之后,我们以批大小为 128 和 epoch 数为 10 的配置拟合模型。请注意,我们还保留了 20% 的训练数据作为验证数据。这开始了训练过程:
图 7.33:模型拟合到 10 个 epoch 的截图
在 10 个 epoch 后,达到了 96% 的验证准确率。这是一个非常好的表现。
现在我们可以尝试一些测试序列并获得该序列是垃圾邮件的概率:
inp_test_seq = "WINNER! U win a 500 prize reward & free entry to FA cup final tickets! Text FA to 34212 to receive award"
test_sequences = mytokenizer.texts_to_sequences(np.array([inp_test_seq]))
test_sequences_matrix = sequence.pad_sequences(test_sequences,maxlen=max_len)
model.predict(test_sequences_matrix)
预期输出:
图 7.34:模型预测输出的截图
测试文本是垃圾邮件的概率非常高。
活动 9:使用简单 RNN 构建垃圾邮件或非垃圾邮件分类器
我们将使用与之前相同的超参数,基于简单的 RNN 构建一个垃圾邮件或非垃圾邮件分类器,并将其性能与基于 LSTM 的解决方案进行比较。对于像这样的简单数据集,简单的 RNN 性能非常接近 LSTM。然而,对于更复杂的模型,情况通常并非如此,正如我们将在下一节看到的那样。
注意
在 github.com/TrainingByP… 找到输入文件。
-
导入所需的 Python 包。
-
读取包含文本的列和包含文本标签的另一列的输入文件,该标签表示文本是否为垃圾邮件。
-
转换为序列。
-
填充序列。
-
训练序列。
-
构建模型。
-
对新测试数据进行邮件类别预测。
预期输出:
图 7.35:邮件类别预测输出
注
该活动的解决方案可以在第 324 页找到。
神经语言翻译
前一节描述的简单二分类器是自然语言处理(NLP)领域的基本用例,并不足以证明使用比简单 RNN 或更简单技术更复杂的技术的必要性。然而,确实存在许多复杂的用例,在这些用例中,必须使用更复杂的单元,例如 LSTM。神经语言翻译就是这样的一个应用。
神经语言翻译任务的目标是构建一个模型,能够将一段文本从源语言翻译成目标语言。在开始编写代码之前,让我们讨论一下该系统的架构。
神经语言翻译代表了一种多对多的自然语言处理(NLP)应用,这意味着系统有多个输入,并且系统也会产生多个输出。
此外,输入和输出的数量可能不同,因为相同的文本在源语言和目标语言中的单词数量可能不同。解决此类问题的 NLP 领域被称为序列到序列建模。该体系结构由编码器块和解码器块组成。以下图表示该体系结构:
图 7.36:神经翻译模型
体系结构的左侧是编码器块,右侧是解码器块。该图尝试将一个英文句子翻译成德文,如下所示:
英文:我想去游泳
德文:Ich möchte schwimmen gehen
注
为了演示的目的,前面的句子省略了句号。句号也被视为有效的标记。
编码器块在给定的时间步长内将英文(源语言)句子的每个单词作为输入。编码器块的每个单元是一个 LSTM。编码器块的唯一输出是最终的单元状态和激活值。它们合起来被称为思维向量。思维向量用于初始化解码器块的激活和单元状态,解码器块也是一个 LSTM 块。在训练阶段,在每个时间步长中,解码器的输出是句子中的下一个单词。它通过一个密集的 softmax 层表示,该层为下一个单词标记的值为 1,而对于向量中的其他所有条目,值为 0。
英语句子逐词输入到编码器中,生成最终的单元状态和激活。在训练阶段,解码器在每个时间步的真实输出是已知的,这实际上就是句子中的下一个德语单词。注意,句子开头插入了‘BEGIN_’标记,句子结尾插入了‘_END’标记。‘BEGIN_’标记的输出是德语句子的第一个单词。从最后一个图可以看到这一点。在训练过程中,网络被训练学习逐字翻译。
在推理阶段,英语输入句子被送入编码器模块,生成最终的单元状态和激活。解码器在第一个时间步输入‘BEGIN_’标记,以及单元状态和激活。利用这三个输入,会生成一个 softmax 输出。在一个训练良好的网络中,softmax 值对于正确单词的对应条目是最大的。然后将这个下一个单词作为输入送入下一个时间步。这个过程会一直继续,直到采样到‘_END’标记或达到最大句子长度。
现在让我们来逐步解析模型的代码。
我们首先读取包含句对的文件。为了演示目的,我们将句对数量限制为 20,000:
import os
import re
import numpy as np
with open("deu.txt", 'r', encoding='utf-8') as f:
lines = f.read().split('\n')
num_samples = 20000 # Using only 20000 pairs for this example
lines_to_use = lines[: min(num_samples, len(lines) - 1)]
print(lines_to_use)
输出:
图 7.37:英德翻译句对的截图
每一行首先是英语句子,后面跟着一个制表符,然后是该句子的德语翻译。接下来,我们将所有的数字映射到占位符词‘NUMBER_PRESENT’,并将‘BEGIN_’和‘_END’标记附加到每个德语句子中,正如之前讨论的那样:
for l in range(len(lines_to_use)):
lines_to_use[l] = re.sub("\d", " NUMBER_PRESENT ",lines_to_use[l])
input_texts = []
target_texts = []
input_words = set()
target_words = set()
for line in lines_to_use:
input_text, target_text = line.split('\t')
target_text = 'BEGIN_ ' + target_text + ' _END'
input_texts.append(input_text)
target_texts.append(target_text)
for word in input_text.split():
if word not in input_words:
input_words.add(word)
for word in target_text.split():
if word not in target_words:
target_words.add(word)
在前面的代码片段中,我们获得了输入和输出文本。它们如下所示:
图 7.38:映射后的输入和输出文本截图
接下来,我们获取输入和输出序列的最大长度,并获得输入和输出语料库中的所有单词列表:
max_input_seq_length = max([len(i.split()) for i in input_texts])
max_target_seq_length = max([len(i.split()) for i in target_texts])
input_words = sorted(list(input_words))
target_words = sorted(list(target_words))
num_encoder_tokens = len(input_words)
num_decoder_tokens = len(target_words)
input_words 和 target_words 如下图所示:
图 7.39:输入文本和目标词汇的截图
接下来,我们为输入和输出单词的每个标记生成一个整数索引:
input_token_index = dict(
[(word, i) for i, word in enumerate(input_words)])
target_token_index = dict([(word, i) for i, word in enumerate(target_words)])
这些变量的值如下所示:
图 7.40:每个标记的整数索引输出截图
我们现在定义编码器输入数据的数组,这是一个二维矩阵,行数等于句子对的数量,列数等于最大输入序列长度。同样,解码器输入数据也是一个二维矩阵,行数等于句子对的数量,列数等于目标语料库中最大序列长度。我们还需要目标输出数据,这是训练阶段所必需的。它是一个三维矩阵,第一维的大小与句子对的数量相同。第二维的大小与最大目标序列长度相同。第三维表示解码器令牌的数量(即目标语料库中不同单词的数量)。我们将这些变量初始化为零:
encoder_input_data = np.zeros(
(len(input_texts), max_input_seq_length),
dtype='float32')
decoder_input_data = np.zeros(
(len(target_texts), max_target_seq_length),
dtype='float32')
decoder_target_data = np.zeros(
(len(target_texts), max_target_seq_length, num_decoder_tokens),
dtype='float32')
现在我们填充这些矩阵:
for i, (input_text, target_text) in enumerate(zip(input_texts, target_texts)):
for t, word in enumerate(input_text.split()):
encoder_input_data[i, t] = input_token_index[word]
for t, word in enumerate(target_text.split()):
decoder_input_data[i, t] = target_token_index[word]
if t > 0:
# decoder_target_data is ahead of decoder_input_data by one timestep
decoder_target_data[i, t - 1, target_token_index[word]] = 1.
这些值如下所示:
](tos-cn-i-73owjymdk6/76ce7736c634461898b67fc6bed9f9b7)
图 7.41:矩阵填充截图
我们现在定义一个模型。对于这个练习,我们将使用 Keras 的功能性 API:
from keras.layers import Input, LSTM, Embedding, Dense
from keras.models import Model
embedding_size = 50 # For embedding layer
让我们来看一下编码器模块:
encoder_inputs = Input(shape=(None,))
encoder_after_embedding = Embedding(num_encoder_tokens, embedding_size)(encoder_inputs)
encoder_lstm = LSTM(50, return_state=True)
_, state_h, state_c = encoder_lstm(encoder_after_embedding)
encoder_states = [state_h, state_c]
首先,定义一个具有灵活输入数量的输入层(使用 None 属性)。然后,定义并应用一个嵌入层到编码器输入。接下来,定义一个具有 50 个隐藏单元的 LSTM 单元并应用于嵌入层。注意,LSTM 定义中的 return_state 参数设置为 True,因为我们希望获取最终的编码器状态,用于初始化解码器的细胞状态和激活值。然后将编码器 LSTM 应用于嵌入层,并将状态收集到变量中。
现在让我们定义解码器模块:
decoder_inputs = Input(shape=(None,))
decoder_after_embedding = Embedding(num_decoder_tokens, embedding_size)(decoder_inputs)
decoder_lstm = LSTM(50, return_sequences=True, return_state=True)
decoder_outputs, _, _ = decoder_lstm(decoder_after_embedding,
initial_state=encoder_states)
decoder_dense = Dense(num_decoder_tokens, activation='softmax')
decoder_outputs = decoder_dense(decoder_outputs)
解码器接收输入,并以类似于编码器的方式定义嵌入层。然后定义一个 LSTM 模块,并将 return_sequences 和 return_state 参数设置为 True。这样做是因为我们希望使用序列和状态来进行解码。接着定义一个具有 softmax 激活函数的全连接层,输出数量等于目标语料库中不同令牌的数量。我们现在可以定义一个模型,它以编码器和解码器的输入为输入,生成解码器输出作为最终输出:
model = Model([encoder_inputs, decoder_inputs], decoder_outputs)
model.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['acc'])
model.summary()
以下是模型总结的显示:
](tos-cn-i-73owjymdk6/61ebccd74f6b4f83a8cd8ab05911a7e5)
图 7.42:模型总结截图
我们现在可以为输入和输出拟合模型:
model.fit([encoder_input_data, decoder_input_data], decoder_target_data,
batch_size=128,
epochs=20,
validation_split=0.05)
我们设置了一个批次大小为 128,训练了 20 个 epochs:
图 7.43:模型拟合截图,训练了 20 个 epochs
模型现在已经训练完成。正如我们在神经语言翻译部分所描述的那样,推理阶段的架构与训练阶段使用的架构略有不同。我们首先定义编码器模型,它以编码器输入(包含嵌入层)作为输入,并生成编码器状态作为输出。这是有意义的,因为编码器模块的输出是细胞状态和激活值:
encoder_model = Model(encoder_inputs, encoder_states)
接下来,定义解码器推理模型:
decoder_state_input_h = Input(shape=(50,))
decoder_state_input_c = Input(shape=(50,))
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]
decoder_outputs_inf, state_h_inf, state_c_inf = decoder_lstm(decoder_after_embedding, initial_state=decoder_states_inputs)
先前训练过的decoder_lstm的初始状态被设置为decoder_states_inputs变量,稍后将被设置为编码器的状态输出。然后,我们将解码器的输出通过密集的 softmax 层,以获取预测单词的索引,并定义解码器推理模型:
decoder_states_inf = [state_h_inf, state_c_inf]
decoder_outputs_inf = decoder_dense(decoder_outputs_inf)
# Multiple input, multiple output
decoder_model = Model(
[decoder_inputs] + decoder_states_inputs,
[decoder_outputs_inf] + decoder_states_inf)
解码器模型接收多个输入,形式包括带嵌入的decoder_input和解码器状态。输出也是一个多变量,其中包含密集层的输出和解码器状态返回的内容。这里需要状态,因为它们需要作为输入状态传递,以便在下一个时间步采样单词。
由于密集层的输出将返回一个向量,我们需要一个反向查找字典来将生成的单词的索引映射到实际单词:
# Reverse-lookup token index to decode sequences
reverse_input_word_index = dict(
(i, word) for word, i in input_token_index.items())
reverse_target_word_index = dict(
(i, word) for word, i in target_token_index.items())
字典中的值如下:
](tos-cn-i-73owjymdk6/e2e83c18d95449b09f9cc6cd292dd9fe)
图 7.44:字典值的截图
我们现在需要开发一个采样逻辑。给定输入句子中每个单词的标记表示,我们首先使用这些单词标记作为编码器的输入,从encoder_model获取输出。我们还将解码器的第一个输入单词初始化为'BEGIN_'标记。然后,我们使用这些值来采样一个新的单词标记。下一个时间步的解码器输入就是这个新生成的标记。我们以这种方式继续,直到我们采样到'_END'标记或达到最大允许的输出序列长度。
第一步是将输入编码为状态向量:
def decode_sequence(input_seq):
states_value = encoder_model.predict(input_seq)
然后,我们生成一个长度为 1 的空目标序列:
target_seq = np.zeros((1,1))
接下来,我们将目标序列的第一个字符填充为开始字符:
target_seq[0, 0] = target_token_index['BEGIN_']
然后,我们为一批序列创建一个采样循环:
stop_condition = False
decoded_sentence = ''
while not stop_condition:
output_tokens, h, c = decoder_model.predict(
[target_seq] + states_value)
接下来,我们采样一个标记:
sampled_token_index = np.argmax(output_tokens)
sampled_word = reverse_target_word_index[sampled_token_index]
decoded_sentence += ' ' + sampled_word
然后,我们声明退出条件“either hit max length”(达到最大长度):
# or find stop character.
if (sampled_word == '_END' or
len(decoded_sentence) > 60):
stop_condition = True
# Update the target sequence (of length 1).
target_seq = np.zeros((1,1))
target_seq[0, 0] = sampled_token_index
然后,我们更新状态:
states_value = [h, c]
return decoded_sentence
在这个实例中,您可以通过将用户定义的英语句子翻译为德语来测试模型:
text_to_translate = "Where is my car?"
encoder_input_to_translate = np.zeros(
(1, max_input_seq_length),
dtype='float32')
for t, word in enumerate(text_to_translate.split()):
encoder_input_to_translate[0, t] = input_token_index[word]
decode_sequence(encoder_input_to_translate)
输出如图所示:
图 7.45:英语到德语翻译器的截图
这是正确的翻译。
因此,即使是一个仅在 20,000 个序列上训练了 20 轮的模型,也能产生良好的翻译。在当前设置下,训练时长约为 90 分钟。
活动 10:创建法语到英语的翻译模型
在这个活动中,我们的目标是生成一个语言翻译模型,将法语文本转换为英语。
注意
您可以在 github.com/TrainingByPackt/Deep-Learning-for-Natural-Language-Processing/tree/master/Lesson%2007/activity 找到与此活动相关的文件。
-
读取句子对(请查看 GitHub 仓库中的文件)。
-
使用带有'BEGIN_'和'_END'单词的输出句子生成输入和输出文本。
-
将输入和输出文本转换为输入和输出序列矩阵。
-
定义编码器和解码器训练模型,并训练网络。
-
定义用于推理的编码器和解码器架构。
-
创建用户输入文本(法语:' Où est ma voiture?')。英文的示例输出文本应该是 'Where is my car?'。请参考 GitHub 仓库中的'French.txt'文件,获取一些示例法语单词。
预期输出:
图 7.46:法语到英语翻译器模型的输出
注意
该活动的解决方案可以在第 327 页找到。
总结
我们介绍了 LSTM 单元,作为解决梯度消失问题的一个可能方法。接着,我们详细讨论了 LSTM 架构,并使用它构建了一个简单的二元分类器。随后,我们深入研究了一个使用 LSTM 单元的神经机器翻译应用,并使用我们探讨的技术构建了一个法语到英语的翻译器模型。在下一章中,我们将讨论 NLP 领域的最新进展。
第九章:第八章
最先进的自然语言处理
学习目标
在本章结束时,你将能够:
-
评估长句中的梯度消失问题
-
描述作为最先进自然语言处理领域的注意力机制模型
-
评估一种特定的注意力机制架构
-
使用注意力机制开发神经机器翻译模型
-
使用注意力机制开发文本摘要模型
本章旨在让你了解当前自然语言处理领域的实践和技术。
引言
在上一章中,我们学习了长短期记忆单元(LSTMs),它们有助于解决梯度消失问题。我们还详细研究了 GRU,它有自己处理梯度消失问题的方式。尽管与简单的循环神经网络相比,LSTM 和 GRU 减少了这个问题,但梯度消失问题在许多实际案例中仍然存在。问题本质上还是一样:长句子和复杂的结构依赖性使得深度学习算法难以进行封装。因此,当前最普遍的研究领域之一就是社区尝试减轻梯度消失问题的影响。
在过去几年中,注意力机制尝试为梯度消失问题提供解决方案。注意力机制的基本概念依赖于在得到输出时能够访问输入句子的所有部分。这使得模型能够对句子的不同部分赋予不同的权重(注意力),从而推断出依赖关系。由于其在学习这种依赖关系方面的非凡能力,基于注意力机制的架构代表了自然语言处理领域的最先进技术。
在本章中,我们将学习注意力机制,并使用基于注意力机制的特定架构解决神经机器翻译任务。我们还将提及一些当前业界正在使用的其他相关架构。
注意力机制
在上一章中,我们解决了一个神经语言翻译任务。我们采用的翻译模型架构由两部分组成:编码器和解码器。请参阅以下图示了解架构:
图 8.1:神经语言翻译模型
对于神经机器翻译任务,句子逐字输入到编码器中,生成一个单一的思想向量(在前面的图中表示为'S'),它将整个句子的意义嵌入到一个单一的表示中。解码器随后使用该向量初始化隐藏状态,并逐字生成翻译。
在简单的编码器-解码器模式下,只有一个向量(思维向量)包含整个句子的表示。句子越长,单个思维向量保持长期依赖关系的难度越大。使用 LSTM 单元只能在某种程度上减轻这个问题。为了进一步缓解梯度消失问题,提出了一个新概念,这个概念就是 注意力机制。
注意力机制旨在模仿人类学习依赖关系的方式。我们用以下示例句子来说明这一点:
"最近我们社区发生了许多盗窃事件,这迫使我考虑雇佣一家安保公司在我家安装防盗系统,以便我能保护自己和家人安全。"
请注意单词“my”,“I”,“me”,“myself”和“our”的使用。它们出现在句子中的不同位置,但彼此紧密相连,共同表达句子的含义。
在尝试翻译前述句子时,传统的编码器-解码器功能如下:
-
将句子逐词传递给编码器。
-
编码器生成一个单一的思维向量,表示整个句子的编码。对于像前面的长句子,即使使用 LSTM,也很难让编码器嵌入所有依赖关系。因此,句子的前半部分编码不如后半部分强,这意味着后半部分对编码的影响占主导地位。
-
解码器使用思维向量来初始化隐藏状态向量,以生成输出翻译。
更直观的翻译方法是,在确定目标语言中特定单词时,注意输入句子中单词的正确位置。举个例子,考虑以下句子:
'那只动物无法走在街上,因为它受伤严重。'
在这个句子中,'it' 这个词指的是谁?是指动物还是街道?如果将整个句子一起考虑,并对句子的不同部分赋予不同的权重,就能够回答这个问题。注意力机制就完成了这一点,如下所示:
图 8.2:注意力机制的示例
图表显示了在理解句子中每个单词时,每个单词所获得的权重。如图所示,单词 'it_' 从 'animal_' 获得了非常强的权重,而从 'street_' 获得了相对较弱的权重。因此,模型现在可以回答“it”指代句子中的哪个实体的问题。
对于翻译的编码器-解码器模型,在生成逐词输出时,在某个特定的时刻,并不是输入句子中的所有单词对输出词的确定都很重要。注意力机制实现了一个正是做这件事的方案:在每个输出词的确定时,对输入句子的不同部分进行加权,考虑到所有的输入词。一个经过良好训练的带有注意力机制的网络,会学会对句子的不同部分施加适当的加权。这个机制使得输入句子的全部内容在每次确定输出时都能随时使用。因此,解码器不再仅仅依赖一个思维向量,而是可以访问到每个输出词对应的“思维”向量。这种能力与传统的 LSTM/GRU/RNN 基础的编码器-解码器模型形成鲜明对比。
注意力机制是一个通用概念。它可以通过几种不同的架构实现,这些架构将在本章后面部分讨论。
注意力机制模型
让我们看看带有注意力机制的编码器-解码器架构可能是怎样的:
图 8.3:注意力机制模型
前面的图展示了带有注意力机制的语言翻译模型的训练阶段。与基本的编码器-解码器机制相比,我们可以注意到几个不同之处,如下所示:
-
解码器的初始状态会被初始化为最后一个编码器单元的编码器输出状态。使用一个初始的NULL词开始翻译,生成的第一个词是‘Er’。这与之前的编码器-解码器模型相同。
-
对于第二个词,除了来自前一个词的输入和前一个解码器时间步的隐藏状态外,还会有另一个向量作为输入传递给单元。这个向量通常被认为是‘上下文向量’,它是所有编码器隐藏状态的一个函数。从前面的图中来看,它是编码器在所有时间步上的隐藏状态的加权求和。
-
在训练阶段,由于每个解码器时间步的输出是已知的,我们可以学习网络的所有参数。除了与所使用的 RNN 类型相关的常规参数外,还会学习与注意力函数相关的特定参数。如果注意力函数只是对隐藏状态编码器向量的简单求和,则可以学习每个编码器时间步的隐藏状态的权重。
-
在推理阶段,在每个时间步,解码器单元可以将上一时间步的预测词、前一个解码器单元的隐藏状态以及上下文向量作为输入。
让我们看一下神经机器翻译中注意力机制的一个具体实现。在上一章中,我们构建了一个神经语言翻译模型,这是一个更广泛的自然语言处理(NLP)领域中的子问题,叫做神经机器翻译。在接下来的部分中,我们将尝试解决一个日期规范化问题。
使用注意力机制的数据规范化
假设你正在维护一个数据库,其中有一张表格包含了一个日期列。日期的输入由你的客户提供,他们填写表单并在日期字段中输入日期。前端工程师不小心忘记了对该字段进行验证,使得只有符合“YYYY-MM-DD”格式的日期才会被接受。现在,你的任务是规范化数据库表中的日期列,将用户以多种格式输入的日期转换为标准的“YYYY-MM-DD”格式。
作为示例,用户输入的日期及其对应的正确规范化如下所示:
图 8.4:日期规范化表格
你可以看到,用户输入日期的方式有很大的变化。除了表格中的示例外,还有许多其他方式可以指定日期。
这个问题非常适合通过神经机器翻译模型来解决,因为输入具有顺序结构,其中输入的不同组成部分的意义需要被学习。该模型将包含以下组件:
-
编码器
-
解码器
-
注意力机制
编码器
这是一个双向 LSTM,它将日期的每个字符作为输入。因此,在每个时间步,编码器的输入是日期输入的单个字符。除此之外,隐藏状态和记忆状态也作为输入从上一个编码器单元传递过来。由于这是一个双向结构,因此与 LSTM 相关的有两组参数:一组用于正向,另一组用于反向。
解码器
这是一个单向 LSTM。它的输入是当前时间步的上下文向量。由于在日期规范化的情况下,每个输出字符不严格依赖于上一个输出字符,因此我们不需要将前一个时间步的输出作为当前时间步的输入。此外,由于它是一个 LSTM 单元,上一时间步解码器的隐藏状态和记忆状态也会作为输入传递给当前时间步单元,用于确定当前时间步的解码器输出。
注意力机制
本节将解释注意力机制。在给定时间步确定解码器输入时,计算一个上下文向量。上下文向量是所有时间步的编码器隐藏状态的加权总和。其计算方式如下:
图 8.5:上下文向量的表达式
点积操作是一个点积运算,它将权重(由alpha表示)与每个时间步的相应隐藏状态向量相乘并求和。alpha 向量的值是为每个解码器输出时间步单独计算的。alpha 值 encapsulates 了注意力机制的本质,即确定在当前时间步的输出中,应该给予输入的哪一部分“关注”。这可以通过一个图示来实现,如下所示:
图 8.6:输入的注意力权重的确定
举个例子,假设编码器输入有一个固定长度 30 个字符,而解码器输出有一个固定的输出长度 10 个字符。对于日期规范化问题,这意味着用户输入的最大长度固定为 30 个字符,而模型输出的长度固定为 10 个字符(即 YYYY-MM-DD 格式的字符数,包括中划线)。
假设我们希望确定在输出时间步=4 时的解码器输出(这是为了说明概念而选择的一个任意数字;它只需要小于等于 10,即输出时间步的数量)。在这一步骤中,会计算出权重向量 alpha。这个向量的维度等于编码器输入的时间步数(因为需要为每个编码器输入时间步计算一个权重)。因此,在我们的例子中,alpha 的维度为 30。
现在,我们已经得到了来自每个编码器时间步的隐藏状态向量,因此一共有 30 个隐藏状态向量可用。隐藏状态向量的维度同时考虑了双向编码器 LSTM 的前向和反向组件。对于给定的时间步,我们将前向隐藏状态和反向隐藏状态合并成一个单一的向量。因此,如果前向和反向隐藏状态的维度都是 32,我们将它们合并成一个 64 维的向量,如[h_forward, h_backward]。这只是一个简单的拼接函数。我们称之为编码器隐藏状态向量。
现在我们有一个单一的 30 维权重向量 alpha,以及 30 个 64 维的隐藏状态向量。因此,我们可以将这 30 个隐藏状态向量分别与 alpha 向量中的对应项相乘。此外,我们还可以将这些加权后的隐藏状态表示求和,得到一个单一的 64 维上下文向量。这本质上是点积操作所执行的运算。
Alpha 的计算
权重可以通过多层感知器(MLP)进行建模,它是一个由多个隐藏层组成的简单神经网络。我们选择使用两个密集层和 softmax 输出。密集层和单元的数量可以作为超参数处理。该 MLP 的输入由两个部分组成:这些部分是编码器双向 LSTM 所有时间步的隐藏状态向量,如上一步所解释的,以及来自解码器前一时间步的隐藏状态。这些向量被连接形成一个单一向量。因此,MLP 的输入是:[编码器隐藏状态向量,来自解码器的前一状态向量]。这是一种张量的连接操作:[H,S_prev]。S_prev 指的是解码器从前一时间步输出的隐藏状态。如果 S_prev 的维度是 64(表示解码器 LSTM 的隐藏状态维度为 64),且编码器的隐藏状态向量的维度也是 64(如上一点所述),那么这两个向量的连接将生成一个维度为 128 的向量。
因此,MLP 接收来自单个编码器时间步的 128 维输入。由于我们已将编码器的输入长度固定为 30 个字符,我们将得到一个大小为 [30,128] 的矩阵(更准确地说,是一个张量)。该 MLP 的参数使用与学习模型其他参数相同的反向传播通过时间(BPTT)机制来学习。因此,整个模型(编码器 + 解码器 + 注意力函数 MLP)的所有参数是一起学习的。可以通过以下图示查看:
图 8.7:Alpha 的计算
在前一步中,我们学习了用于确定解码器输出的权重(alpha 向量)(我们假设这一时间步是 4)。因此,单步解码器输出的确定需要输入:S_prev 和编码器隐藏状态,用于计算上下文向量、解码器隐藏状态以及解码器前一时间步的记忆,这些将作为输入传递给解码器单向 LSTM。进入下一个解码器时间步时,需要计算一个新的 alpha 向量,因为对于这个下一步,输入序列的不同部分与上一个时间步相比,可能会被赋予不同的权重。
由于模型的架构,训练和推理步骤是相同的。唯一的区别是,在训练过程中,我们知道每个解码器时间步的输出,并利用这些输出来训练模型参数(这种技术称为“教师强制”)。
相比之下,在推理阶段,我们预测输出字符。请注意,在训练和推理期间,我们都不会将上一个时间步的解码器输出字符作为输入传递给当前时间步的解码器单元。需要注意的是,这里提出的架构是针对这个问题特定的。实际上有很多架构和定义注意力函数的方法。我们将在本章的后续部分简要了解其中一些。
练习 28: 为数据库列构建日期规范化模型
一个数据库列接受来自多个用户的日期输入,格式多样。在本练习中,我们旨在规范化数据库表的日期列,使得用户以不同格式输入的日期能够转换为标准的“YYYY-MM-DD”格式:
注意
运行代码所需的 Python 依赖项如下:
Babel==2.6.0
Faker==1.0.2
Keras==2.2.4
numpy==1.16.1
pandas==0.24.1
scipy==1.2.1
tensorflow==1.12.0
tqdm==4.31.1
Faker==1.0.2
-
我们导入所有必要的模块:
from keras.layers import Bidirectional, Concatenate, Permute, Dot, Input, LSTM, Multiply from keras.layers import RepeatVector, Dense, Activation, Lambda from keras.optimizers import Adam from keras.utils import to_categorical from keras.models import load_model, Model import keras.backend as K import numpy as np from babel.dates import format_date from faker import Faker import random from tqdm import tqdm -
接下来,我们定义一些辅助函数。我们首先使用
'faker'和babel模块生成用于训练的数据。babel中的format_date函数生成特定格式的日期(使用FORMATS)。此外,日期也以人类可读的格式返回,模拟我们希望规范化的非正式用户输入日期:fake = Faker() fake.seed(12345) random.seed(12345) -
定义我们希望生成的数据格式:
FORMATS = ['short', 'medium', 'long', 'full', 'full', 'full', 'full', 'full', 'full', 'full', 'full', 'full', 'full', 'd MMM YYY', 'd MMMM YYY', 'dd MMM YYY', 'd MMM, YYY', 'd MMMM, YYY', 'dd, MMM YYY', 'd MM YY', 'd MMMM YYY', 'MMMM d YYY', 'MMMM d, YYY', 'dd.MM.YY'] # change this if you want it to work with another language LOCALES = ['en_US'] def load_date(): """ Loads some fake dates :returns: tuple containing human readable string, machine readable string, and date object """ dt = fake.date_object() human_readable = format_date(dt, format=random.choice(FORMATS), locale='en_US') # locale=random.choice(LOCALES)) human_readable = human_readable.lower() human_readable = human_readable.replace(',','') machine_readable = dt.isoformat() return human_readable, machine_readable, dt -
接下来,我们生成并编写一个函数来加载数据集。在此函数中,使用之前定义的
load_date()函数创建示例。除了数据集外,函数还返回用于映射人类可读和机器可读标记的字典,以及逆向机器词汇表:def load_dataset(m): """ Loads a dataset with m examples and vocabularies :m: the number of examples to generate """ human_vocab = set() machine_vocab = set() dataset = [] Tx = 30 for i in tqdm(range(m)): h, m, _ = load_date() if h is not None: dataset.append((h, m)) human_vocab.update(tuple(h)) machine_vocab.update(tuple(m)) human = dict(zip(sorted(human_vocab) + ['<unk>', '<pad>'], list(range(len(human_vocab) + 2)))) inv_machine = dict(enumerate(sorted(machine_vocab))) machine = {v:k for k,v in inv_machine.items()} return dataset, human, machine, inv_machine上述辅助函数用于生成一个数据集,使用
babelPython 包。此外,它还返回输入和输出的词汇字典,就像我们在以前的练习中所做的那样。 -
接下来,我们使用这些辅助函数生成一个包含 10,000 个样本的数据集:
m = 10000 dataset, human_vocab, machine_vocab, inv_machine_vocab = load_dataset(m)变量存储值,如下所示:
图 8.8: 显示变量值的截图
human_vocab是一个字典,将输入字符映射到整数。以下是human_vocab的值映射:图 8.9: 显示 human_vocab 字典的截图
machine_vocab字典包含输出字符到整数的映射。图 8.10: 显示 machine_vocab 字典的截图
inv_machine_vocab是machine_vocab的逆映射,用于将预测的整数映射回字符:图 8.11: 显示 inv_machine_vocab 字典的截图
-
接下来,我们预处理数据,使得输入序列的形状为(
10000,30,len(human_vocab))。因此,矩阵中的每一行代表 30 个时间步和对应于给定时间步的字符的独热编码向量。同样,Y 输出的形状为(10000,10,len(machine_vocab))。这对应于 10 个输出时间步和相应的独热编码输出向量。我们首先定义一个名为'string_to_int'的函数,它接收一个用户日期作为输入,并返回一个可以输入到模型中的整数序列:def string_to_int(string, length, vocab): """ Converts all strings in the vocabulary into a list of integers representing the positions of the input string's characters in the "vocab" Arguments: string -- input string, e.g. 'Wed 10 Jul 2007' length -- the number of timesteps you'd like, determines if the output will be padded or cut vocab -- vocabulary, dictionary used to index every character of your "string" Returns: rep -- list of integers (or '<unk>') (size = length) representing the position of the string's character in the vocabulary """ -
将大小写转换为小写,以标准化文本
string = string.lower() string = string.replace(',','') if len(string) > length: string = string[:length] rep = list(map(lambda x: vocab.get(x, '<unk>'), string)) if len(string) < length: rep += [vocab['<pad>']] * (length - len(string)) return rep -
现在我们可以利用这个辅助函数来生成输入和输出的整数序列,正如之前所解释的那样:
def preprocess_data(dataset, human_vocab, machine_vocab, Tx, Ty): X, Y = zip(*dataset) print("X shape before preprocess: {}".format(X)) X = np.array([string_to_int(i, Tx, human_vocab) for i in X]) Y = [string_to_int(t, Ty, machine_vocab) for t in Y] print("X shape from preprocess: {}".format(X.shape)) print("Y shape from preprocess: {}".format(Y)) Xoh = np.array(list(map(lambda x: to_categorical(x, num_classes=len(human_vocab)), X))) Yoh = np.array(list(map(lambda x: to_categorical(x, num_classes=len(machine_vocab)), Y))) return X, np.array(Y), Xoh, Yoh Tx = 30 Ty = 10 X, Y, Xoh, Yoh = preprocess_data(dataset, human_vocab, machine_vocab, Tx, Ty) -
打印矩阵的形状。
print("X.shape:", X.shape) print("Y.shape:", Y.shape) print("Xoh.shape:", Xoh.shape) print("Yoh.shape:", Yoh.shape)这一阶段的输出如下所示:
图 8.12:矩阵形状的截图
-
我们可以进一步检查
X、Y、Xoh和Yoh向量的形状:index = 0 print("Source date:", dataset[index][0]) print("Target date:", dataset[index][1]) print() print("Source after preprocessing (indices):", X[index].shape) print("Target after preprocessing (indices):", Y[index].shape) print() print("Source after preprocessing (one-hot):", Xoh[index].shape) print("Target after preprocessing (one-hot):", Yoh[index].shape)输出应如下所示:
图 8.13:处理后矩阵形状的截图
-
我们现在开始定义一些构建模型所需的函数。首先,我们定义一个计算 softmax 值的函数,给定张量作为输入:
def softmax(x, axis=1): """Softmax activation function. # Arguments x : Tensor. axis: Integer, axis along which the softmax normalization is applied. # Returns Tensor, output of softmax transformation. # Raises ValueError: In case 'dim(x) == 1'. """ ndim = K.ndim(x) if ndim == 2: return K.softmax(x) elif ndim > 2: e = K.exp(x - K.max(x, axis=axis, keepdims=True)) s = K.sum(e, axis=axis, keepdims=True) return e / s else: raise ValueError('Cannot apply softmax to a tensor that is 1D') -
接下来,我们可以开始组装模型:
# Defined shared layers as global variables repeator = RepeatVector(Tx) concatenator = Concatenate(axis=-1) densor1 = Dense(10, activation = "tanh") densor2 = Dense(1, activation = "relu") activator = Activation(softmax, name='attention_weights') dotor = Dot(axes = 1) -
RepeatVector的作用是重复给定的张量多次。在我们的例子中,这是重复Tx次,也就是 30 个输入时间步。重复器用于将S_prev重复 30 次。回想一下,为了计算上下文向量以确定单个时间步的解码器输出,需要将S_prev与每个输入编码器时间步进行连接。Concatenate的keras函数完成了下一步,即将重复的S_prev和每个时间步的编码器隐藏状态向量连接在一起。我们还定义了 MLP 层,它包括两个全连接层(densor1,densor2)。接下来,MLP 的输出通过一个softmax层。这个softmax分布就是一个 alpha 向量,其中每个条目对应于每个连接向量的权重。最后,定义了一个dotor函数,它负责计算上下文向量。整个流程对应于一个步骤的注意力机制(因为它是针对单个解码器输出时间步的):def one_step_attention(h, s_prev): """ Performs one step of attention: Outputs a context vector computed as a dot product of the attention weights "alphas" and the hidden states "h" of the Bi-LSTM. Arguments: h -- hidden state output of the Bi-LSTM, numpy-array of shape (m, Tx, 2*n_h) s_prev -- previous hidden state of the (post-attention) LSTM, numpy-array of shape (m, n_s) Returns: context -- context vector, input of the next (post-attetion) LSTM cell """ -
使用
repeator将s_prev重复至形状(m,Tx,n_s),以便与所有隐藏状态'h'进行连接:s_prev = repeator(s_prev) -
使用
concatenator在最后一个轴上将a和s_prev连接起来:concat = concatenator([h, s_prev]) -
使用
densor1通过一个小型全连接神经网络传播concat,以计算中间能量变量e:e = densor1(concat) -
使用
densor2通过一个小型全连接神经网络传播e,以计算能量变量:energies = densor2(e) -
使用
activator作用于energies,以计算注意力权重alphas:alphas = activator(energies) -
使用
dotor结合alphas和a来计算上下文向量,以供下一个(注意力后)LSTM 单元使用:context = dotor([alphas, h]) return context -
到目前为止,我们仍然没有定义编码器和解码器 LSTM 的隐藏状态单元数量。我们还需要定义解码器 LSTM,这是一个单向 LSTM:
n_h = 32 n_s = 64 post_activation_LSTM_cell = LSTM(n_s, return_state = True) output_layer = Dense(len(machine_vocab), activation=softmax) -
现在我们定义编码器和解码器模型:
def model(Tx, Ty, n_h, n_s, human_vocab_size, machine_vocab_size): """ Arguments: Tx -- length of the input sequence Ty -- length of the output sequence n_h -- hidden state size of the Bi-LSTM n_s -- hidden state size of the post-attention LSTM human_vocab_size -- size of the python dictionary "human_vocab" machine_vocab_size -- size of the python dictionary "machine_vocab" Returns: model -- Keras model instance """ -
定义模型的输入形状(
Tx,)。定义s0和c0,以及解码器 LSTM 的初始隐藏状态,形状为(n_s,):X = Input(shape=(Tx, human_vocab_size), name="input_first") s0 = Input(shape=(n_s,), name='s0') c0 = Input(shape=(n_s,), name='c0') s = s0 c = c0 -
初始化一个空的
outputs列表:outputs = [] -
定义你的预注意力 Bi-LSTM。记得使用
return_sequences=True:h = Bidirectional(LSTM(n_h, return_sequences=True))(X) -
迭代
Ty步:for t in range(Ty): -
执行一次注意力机制步骤,以获得步骤
t的上下文向量:context = one_step_attention(h, s) -
将后注意力 LSTM 单元应用于
context向量。同时,传递initial_state= [隐藏状态,细胞状态]:s, _, c = post_activation_LSTM_cell(context, initial_state = [s,c]) -
将
Dense层应用于后注意力 LSTM 的隐藏状态输出:out = output_layer(s) # Append "out" to the "outputs" list outputs.append(out) -
通过接收三个输入并返回输出列表来创建模型实例:
model = Model(inputs=[X, s0, c0], outputs=outputs) return model model = model(Tx, Ty, n_h, n_s, len(human_vocab), len(machine_vocab)) model.summary()输出可能如下面的图所示:
图 8.14:模型总结的截图
-
现在我们将使用
categorical_crossentropy作为损失函数,Adam优化器作为优化策略来编译模型:opt = Adam(lr = 0.005, beta_1=0.9, beta_2=0.999, decay = 0.01) model.compile(loss='categorical_crossentropy', optimizer=opt, metrics=['accuracy']) -
在拟合模型之前,我们需要初始化解码器 LSTM 的隐藏状态向量和记忆状态:
s0 = np.zeros((m, n_s)) c0 = np.zeros((m, n_s)) outputs = list(Yoh.swapaxes(0,1)) model.fit([Xoh, s0, c0], outputs, epochs=1, batch_size=100)这开始了训练:
图 8.15:训练周期的截图
-
模型现在已训练完成,可以进行推理调用:
EXAMPLES = ['3 May 1979', '5 April 09', '21th of August 2016', 'Tue 10 Jul 2007', 'Saturday May 9 2018', 'March 3 2001', 'March 3rd 2001', '1 March 2001'] for example in EXAMPLES: source = string_to_int(example, Tx, human_vocab) source = np.array(list(map(lambda x: to_categorical(x, num_classes=len(human_vocab)), source)))#.swapaxes(0,1) source = source[np.newaxis, :] prediction = model.predict([source, s0, c0]) prediction = np.argmax(prediction, axis = -1) output = [inv_machine_vocab[int(i)] for i in prediction] print("source:", example) print("output:", ''.join(output))预期输出:
图 8.16:标准化日期输出的截图
其他架构与发展
上一节描述的注意力机制架构只是构建注意力机制的一种方式。近年来,提出了几种其他架构,它们在深度学习 NLP 领域构成了最先进技术。在这一节中,我们将简要提及其中一些架构。
变换器
2017 年末,谷歌在其开创性论文《Attention is all you need》中提出了一种注意力机制架构。该架构被认为是自然语言处理(NLP)社区中的最先进技术。变换器架构利用一种特殊的多头注意力机制,在不同的层次生成注意力。此外,它还采用残差连接,进一步确保梯度消失问题对学习的影响最小。变换器的特殊架构还允许在训练阶段大幅加速,同时提供更高质量的结果。
最常用的带有变换器架构的包是tensor2tensor。Keras 的变换器代码通常很笨重且难以维护,而tensor2tensor允许使用 Python 包和简单的命令行工具来训练变换器模型。
注意
欲了解更多关于 tensor2tensor 的信息,请参考 github.com/tensorflow/tensor2tensor/#t2t-overview
有兴趣了解架构的读者可以阅读提到的论文以及相关的 Google 博客:Transformer: A Novel Neural Network
BERT
2018 年底,Google 再次开源了一个突破性的架构,名为BERT(Bidirectional Encoder Representations from Transformers)。深度学习社区在自然语言处理(NLP)领域已经很久没有看到适合的转移学习机制了。转移学习方法在深度学习中一直是图像相关任务(如图像分类)的最前沿技术。图像在基本结构上是通用的,无论地理位置如何,图像的结构都是一致的。这使得可以在通用图像上训练深度学习模型。这些预训练的模型可以进一步微调以应对特定任务。这节省了训练时间,也减少了对大量数据的需求,能够在较短的时间内达到令人满意的模型表现。
不幸的是,语言因地理位置的不同而差异很大,且往往没有共同的基本结构。因此,转移学习在自然语言处理(NLP)任务中并不是一个可行的选项。BERT 通过其新的注意力机制架构,基于基础的 Transformer 架构,使这一切成为可能。
注意
关于 BERT 的更多信息,请参考:BERT GitHub
有兴趣了解 BERT 的读者应该查看 Google 关于 BERT 的博客:Open Sourcing BERT。
OpenAI GPT-2
OpenAI 还开源了一个名为GPT-2的架构,它在他们之前的架构 GPT 基础上进行了改进。GPT-2 架构的核心特点是它在文本生成任务中表现出色。GPT-2 模型同样基于 Transformer 架构,包含约 15 亿个参数。
注意
有兴趣了解更多的读者可以参考 OpenAI 的博客:Better Language Models。
活动 11:构建文本摘要模型
我们将使用我们为神经机器翻译构建的注意力机制模型架构来构建文本摘要模型。文本摘要的目标是编写给定大规模文本语料的摘要。你可以想象使用文本摘要工具来总结书籍内容或为新闻文章生成标题。
作为一个示例,使用给定的输入文本:
“梅赛德斯-奔驰印度在庆祝其 25 周年之际,将通过发布全新 V-Class 重新定义印度汽车行业的豪华空间。V-Class 搭载 2.1 升 BS VI 柴油发动机,产生 120kW 的功率和 380Nm 的扭矩,0-100km/h 加速仅需 10.9 秒。它配备了 LED 前大灯、多功能方向盘和 17 英寸铝合金轮毂。”
一个好的文本摘要模型应该能够生成有意义的摘要,例如:
“梅赛德斯-奔驰印度发布全新 V-Class”
从架构的角度来看,文本摘要模型与翻译模型完全相同。模型的输入是文本,它会按字符(或按词)逐个输入到编码器中,而解码器则输出与源文本相同语言的字符。
注意
输入文本可以在此链接找到:github.com/TrainingByPackt/Deep-Learning-for-Natural-Language-Processing/tree/master/Lesson%2008。
以下步骤将帮助你解决问题:
-
导入所需的 Python 包,并制作人类与机器的词汇字典。
-
定义输入和输出字符的长度以及模型功能(Repeator,Concatenate,Densors 和 Dotor)。
-
定义一个一步注意力函数,并为解码器和编码器定义隐状态的数量。
-
定义模型架构并运行它以获得模型。
-
定义模型的损失函数和其他超参数。同时,初始化解码器的状态向量。
-
将模型拟合到我们的数据上。
-
运行新文本的推理步骤。
预期输出:
图 8.17:文本摘要输出
注意
活动的解决方案可以在第 333 页找到。
总结
在本章中,我们学习了注意力机制的概念。基于注意力机制,已经提出了几种架构,这些架构构成了 NLP 领域的最新技术。我们学习了一种特定的模型架构,用于执行神经机器翻译任务。我们还简要提到了其他先进的架构,如 Transformer 和 BERT。
到目前为止,我们已经看到了许多不同的自然语言处理(NLP)模型。在下一章,我们将讨论一个实际 NLP 项目在组织中的流程以及相关技术。