循环神经网络#
在上一模块中,我们讨论了文本的丰富语义表示。我们使用的架构能够捕捉句子中词语的聚合意义,但它没有考虑词语的顺序,因为嵌入后的聚合操作会丢失原始文本中的这一信息。由于这些模型无法表示词语的顺序,它们无法解决更复杂或更模糊的任务,例如文本生成或问答。
为了捕捉文本序列的意义,我们将使用一种称为循环神经网络(RNN)的神经网络架构。在使用 RNN 时,我们会将句子逐个标记传递给网络,网络会生成某种状态,然后我们将该状态与下一个标记一起再次传递给网络。

给定输入标记序列 $X_0,\dots,X_n$,RNN 会创建一系列神经网络块,并通过反向传播对这一序列进行端到端训练。每个网络块以 $(X_i,S_i)$ 为输入,并生成 $S_{i+1}$ 作为结果。最终状态 $S_n$ 或输出 $Y_n$ 会进入线性分类器以生成结果。所有网络块共享相同的权重,并通过一次反向传播进行端到端训练。
上图展示了循环神经网络的展开形式(左侧)和更紧凑的循环表示形式(右侧)。需要注意的是,所有 RNN 单元都具有相同的可共享权重。
由于状态向量 $S_0,\dots,S_n$ 会通过网络传递,RNN 能够学习词语之间的顺序依赖关系。例如,当单词 not 出现在序列中的某处时,它可以学习在状态向量中否定某些元素。
在内部,每个 RNN 单元包含两个权重矩阵:$W_H$ 和 $W_I$,以及偏置 $b$。在每个 RNN 步骤中,给定输入 $X_i$ 和输入状态 $S_i$,输出状态计算公式为 $S_{i+1} = f(W_H\times S_i + W_I\times X_i+b)$,其中 $f$ 是一个激活函数(通常为 $\tanh$)。
对于像文本生成(我们将在下一单元中讨论)或机器翻译这样的问题,我们还希望在每个 RNN 步骤中获得一些输出值。在这种情况下,还会有另一个矩阵 $W_O$,输出计算公式为 $Y_i=f(W_O\times S_i+b_O)$。
让我们看看循环神经网络如何帮助我们对新闻数据集进行分类。
在沙盒环境中,我们需要运行以下单元以确保所需库已安装,并预取数据。如果您在本地运行,可以跳过以下单元。
import sys
!{sys.executable} -m pip install --quiet tensorflow_datasets==4.4.0
!cd ~ && wget -q -O - https://mslearntensorflowlp.blob.core.windows.net/data/tfds-ag-news.tgz | tar xzimport tensorflow as tf
from tensorflow import keras
import tensorflow_datasets as tfds
import numpy as np
# We are going to be training pretty large models. In order not to face errors, we need
# to set tensorflow option to grow GPU memory allocation when required
physical_devices = tf.config.list_physical_devices('GPU')
if len(physical_devices)>0:
tf.config.experimental.set_memory_growth(physical_devices[0], True)
ds_train, ds_test = tfds.load('ag_news_subset').values()在训练大型模型时,GPU 内存分配可能会成为一个问题。我们还可能需要尝试不同的 minibatch 大小,以便数据能够适应 GPU 内存,同时确保训练速度足够快。如果您在自己的 GPU 机器上运行此代码,可以尝试调整 minibatch 大小来加快训练速度。
Note: 某些版本的 NVidia 驱动程序已知在训练模型后不会释放内存。我们在这个 notebook 中运行了多个示例,这可能会在某些设置中导致内存耗尽,特别是如果您在同一个 notebook 中进行自己的实验时。如果在开始训练模型时遇到一些奇怪的错误,您可能需要重启 notebook 的内核。
batch_size = 16
embed_size = 64简单RNN分类器#
对于简单RNN,每个循环单元都是一个简单的线性网络,它接收一个输入向量和状态向量,并生成一个新的状态向量。在Keras中,这可以通过SimpleRNN层来表示。
虽然我们可以直接将独热编码的标记传递给RNN层,但这并不是一个好主意,因为它们的维度过高。因此,我们将使用嵌入层来降低词向量的维度,接着是一个RNN层,最后是一个Dense分类器。
注意: 在维度不是特别高的情况下,例如使用字符级标记化时,直接将独热编码的标记传递给RNN单元可能是合理的。
vocab_size = 20000
vectorizer = keras.layers.experimental.preprocessing.TextVectorization(
max_tokens=vocab_size,
input_shape=(1,))
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size),
keras.layers.SimpleRNN(16),
keras.layers.Dense(4,activation='softmax')
])
model.summary()Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
text_vectorization (TextVect (None, None) 0
_________________________________________________________________
embedding (Embedding) (None, None, 64) 1280000
_________________________________________________________________
simple_rnn (SimpleRNN) (None, 16) 1296
_________________________________________________________________
dense (Dense) (None, 4) 68
=================================================================
Total params: 1,281,364
Trainable params: 1,281,364
Non-trainable params: 0
_________________________________________________________________
注意: 这里我们为了简化使用了未训练的嵌入层,但为了获得更好的效果,可以使用上一单元中提到的 Word2Vec 预训练嵌入层。这是一个很好的练习,你可以尝试将代码改为使用预训练的嵌入层。
现在让我们开始训练 RNN。一般来说,RNN 的训练难度较大,因为当 RNN 单元沿着序列长度展开时,参与反向传播的层数会非常多。因此,我们需要选择较小的学习率,并在更大的数据集上训练网络以获得良好的结果。这可能需要较长的时间,因此建议使用 GPU。
为了加快速度,我们将仅使用新闻标题来训练 RNN 模型,省略描述部分。你可以尝试加入描述进行训练,看看是否能让模型成功训练。
def extract_title(x):
return x['title']
def tupelize_title(x):
return (extract_title(x),x['label'])
print('Training vectorizer')
vectorizer.adapt(ds_train.take(2000).map(extract_title))Training vectorizer
model.compile(loss='sparse_categorical_crossentropy',metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize_title).batch(batch_size),validation_data=ds_test.map(tupelize_title).batch(batch_size))7500/7500 [==============================] - 82s 11ms/step - loss: 0.6629 - acc: 0.7623 - val_loss: 0.5559 - val_acc: 0.7995
<tensorflow.python.keras.callbacks.History at 0x7f3e0030d350>注意,由于我们仅在新闻标题上进行训练,这里的准确性可能会较低。
重新审视变量序列#
请记住,TextVectorization 层会自动使用填充标记对小批量中长度不一的序列进行填充。然而,这些填充标记也会参与训练,并可能使模型的收敛变得复杂。
我们可以采取几种方法来减少填充的数量。其中一种方法是根据序列长度重新排序数据集,并按大小对所有序列进行分组。这可以通过使用 tf.data.experimental.bucket_by_sequence_length 函数来实现(参见文档)。
另一种方法是使用掩码。在 Keras 中,某些层支持额外的输入,用于指示在训练时应该考虑哪些标记。为了在模型中加入掩码,我们可以选择添加一个单独的 Masking 层(文档),或者在 Embedding 层中指定 mask_zero=True 参数。
Note: 完成整个数据集的一次训练周期大约需要 5 分钟。如果你没有耐心,可以随时中断训练。你还可以通过在
ds_train和ds_test数据集后添加.take(...)子句来限制用于训练的数据量。
def extract_text(x):
return x['title']+' '+x['description']
def tupelize(x):
return (extract_text(x),x['label'])
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size,embed_size,mask_zero=True),
keras.layers.SimpleRNN(16),
keras.layers.Dense(4,activation='softmax')
])
model.compile(loss='sparse_categorical_crossentropy',metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize).batch(batch_size),validation_data=ds_test.map(tupelize).batch(batch_size))7500/7500 [==============================] - 371s 49ms/step - loss: 0.5401 - acc: 0.8079 - val_loss: 0.3780 - val_acc: 0.8822
<tensorflow.python.keras.callbacks.History at 0x7f3dec118850>现在我们使用掩码技术,可以在整个标题和描述的数据集上训练模型。
注意: 你是否注意到我们一直在使用基于新闻标题训练的向量化器,而不是整篇文章的内容?这可能会导致某些词元被忽略,因此重新训练向量化器会更好。不过,这可能只会带来很小的影响,所以为了简化流程,我们将继续使用之前预训练的向量化器。
LSTM: 长短期记忆#
RNN 的主要问题之一是梯度消失。RNN 可能会非常长,在反向传播过程中,梯度可能很难一直传播回网络的第一层。当这种情况发生时,网络无法学习远距离的标记之间的关系。为了解决这个问题,可以通过使用门控机制引入显式状态管理。最常见的两种引入门控机制的架构是长短期记忆(LSTM)和门控循环单元(GRU)。这里我们将讨论 LSTM。
LSTM 网络的组织方式与 RNN 类似,但有两个状态会从一层传递到下一层:实际状态 $c$ 和隐藏向量 $h$。在每个单元中,隐藏向量 $h_{t-1}$ 与输入 $x_t$ 结合在一起,共同控制状态 $c_t$ 和输出 $h_t$ 的变化,这一过程通过门控机制完成。每个门都有 sigmoid 激活函数(输出范围为 $[0,1]$),可以将其视为与状态向量相乘的位掩码。LSTM 包含以下几种门(从上图从左到右):
- 遗忘门:决定向量 $c_{t-1}$ 的哪些部分需要遗忘,哪些部分需要保留。
- 输入门:决定输入向量和前一个隐藏向量中的信息有多少需要被合并到状态向量中。
- 输出门:接收新的状态向量,并决定其哪些部分将用于生成新的隐藏向量 $h_t$。
状态 $c$ 的各个组成部分可以被视为可以开关的标志。例如,当我们在序列中遇到名字 Alice 时,我们猜测它指的是一个女性,并在状态中设置一个标志,表示句子中有一个女性名词。当我们进一步遇到单词 and Tom 时,我们会设置另一个标志,表示句子中有复数名词。因此,通过操控状态,我们可以跟踪句子的语法属性。
Note: 这里有一个很棒的资源可以帮助理解 LSTM 的内部机制:Understanding LSTM Networks,作者是 Christopher Olah。
虽然 LSTM 单元的内部结构看起来很复杂,但 Keras 将这些实现隐藏在 LSTM 层中,因此在上面的示例中,我们只需要替换循环层即可:
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, embed_size),
keras.layers.LSTM(8),
keras.layers.Dense(4,activation='softmax')
])
model.compile(loss='sparse_categorical_crossentropy',metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize).batch(8),validation_data=ds_test.map(tupelize).batch(8))15000/15000 [==============================] - 188s 13ms/step - loss: 0.5692 - acc: 0.7916 - val_loss: 0.3441 - val_acc: 0.8870
<tensorflow.python.keras.callbacks.History at 0x7f3d6af5c350>双向和多层 RNN#
在我们之前的示例中,循环网络从序列的开头运行到结尾。这对我们来说很自然,因为它遵循了我们阅读或听语音的方向。然而,对于需要随机访问输入序列的场景来说,同时在两个方向上运行循环计算会更合理。允许在两个方向上进行计算的 RNN 被称为双向 RNN,可以通过将循环层包装在一个特殊的 Bidirectional 层中来创建。
Note:
Bidirectional层会在其内部创建该层的两个副本,并将其中一个副本的go_backwards属性设置为True,使其沿着序列的相反方向运行。
无论是单向还是双向的循环网络,都能够捕获序列中的模式,并将其存储到状态向量中或作为输出返回。与卷积网络类似,我们可以在第一个循环层之后再构建另一个循环层,以捕获更高级别的模式,这些模式是由第一个层提取的低级模式构建而成的。这引出了多层 RNN的概念,它由两个或更多的循环网络组成,其中前一层的输出作为输入传递给下一层。

图片来源于 这篇精彩的文章,作者 Fernando López。
Keras 使构建这些网络变得非常简单,因为你只需要向模型中添加更多的循环层即可。对于最后一层以外的所有层,我们需要指定 return_sequences=True 参数,因为我们需要该层返回所有中间状态,而不仅仅是循环计算的最终状态。
让我们为分类问题构建一个两层双向 LSTM。
Note 这段代码运行时间较长,但它提供了迄今为止我们见过的最高准确率。所以也许值得等待并查看结果。
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, 128, mask_zero=True),
keras.layers.Bidirectional(keras.layers.LSTM(64,return_sequences=True)),
keras.layers.Bidirectional(keras.layers.LSTM(64)),
keras.layers.Dense(4,activation='softmax')
])
model.compile(loss='sparse_categorical_crossentropy',metrics=['acc'], optimizer='adam')
model.fit(ds_train.map(tupelize).batch(batch_size),
validation_data=ds_test.map(tupelize).batch(batch_size))5044/7500 [===================>..........] - ETA: 2:33 - loss: 0.3709 - acc: 0.8706
5045/7500 [===================>..........] - ETA: 2:33 - loss: 0.3709 - acc: 0.8706RNN用于其他任务#
到目前为止,我们主要关注使用RNN对文本序列进行分类。但它们还能处理许多其他任务,例如文本生成和机器翻译——我们将在下一单元中讨论这些任务。
免责声明:
本文档使用AI翻译服务Co-op Translator进行翻译。尽管我们努力确保翻译的准确性,但请注意,自动翻译可能包含错误或不准确之处。原始语言的文档应被视为权威来源。对于关键信息,建议使用专业人工翻译。我们不对因使用此翻译而产生的任何误解或误读承担责任。