序列模型 sequence model
在前面已经讲到不同的神经网络架构在处理不同类型的任务时具有不同的表现,循环神经网络 Recurrent neural network 在处理带有前后序列性质的数据如自然语言处理、机器翻译、连续动作识别等应用具有非常明显的先天优势,RNN 之所以如此命名是因为我们会对序列中的每一个元素执行同样的操作,因此可以视作一种循环机制。
The RNN performs the same task for each element in the sequence.
在 RNN 中,记忆被定义为前序隐藏层的输出。
Memory is defined as the output of hidden layer neurons, which will serve as additional input to the network during next training step.
符号约定
为了便于统一符号注解,在后续的课程中,对于一个输入序列 x ,x<i> 表示其中的第 i 个元素,y<i> 表示对应输出序列的第 i 个元素,在很多论文中也用 h 来表示输出。在时序数据中,由于引入了长度的概念,因此用 Tx 来表示一个输入序列的长度,用 Ty 来表示一个输出序列的长度。对于多个序列来说,x(i)<t> 表示第 i 个输入中的第 t 个元素,相应地,对于第 i 个输入序列的长度用 Tx(i) 来表示,对于第 i 个输出序列的长度用 Ty(i) 来表示,值得注意的是在实际应用中 Tx(i) 与 Ty(i) 不一定相等。
在自然语言相关的时序数据处理中,为了便于统一的表示序列中的数据,在处理具体的样本前要先建立一个词汇表 Vocabulary 或称字典 Dictionary,再根据各个单词在词汇表中的索引位置来对输入样本中的词汇进行 one-hot encoding,具体的操作过程在前面的 情感分析 的练习中已经有过接触和体会,这里就不再赘述。
另外需要注意的一点是如果最终在输入中出现字典中没有的输入词汇,则可以创建一个符号 Token 如 <UNK> 来标识这些未知词汇,部分应用中还会用 <EOS> 来代表句子的结束。
循环神经网络模型建立
在前面的情感分析练习中已经看到使用标准形式的多层神经网络在处理类似的语言类任务时表现并不突出,普通形式的神经网络在处理序列性质的数据时面临的主要问题有:
对于不同的输入和输出来说,样本可能会有不同的长度
与在 CNN 中的情形类似,标准的多层神经网络不同单元之间无法共享在语句的不同位置学习到的特征
典型循环神经网络的工作机制
假设我们按照从左到右的方式处理一个语句,对于输入语句的第一个单词或称元素 x<1>,网络的第一步会计算相应的预测 ŷ<1>,此后对于语句中的第二个输入元素 x<2> 来说,网络在预测 ŷ<2> 时会接收上一步的激活信息 a<1>,以此类推。由于网络遵循从左到右的顺序逐个对语句中的元素进行处理,因此不同单词的处理之间存在一个时间步 time step 的概念,但对于同一层来说不同时间步之间的参数是相同的,也即图中不同时间步中的 Wax 为同一套参数。由于对于下图所示的循环神经网络来说,时间步在后的网络只参考时间步在前的信息,因此这些参考信息的流动是单向的,对于双向传递的循环神经网络 Bidirectional Recurrent Neural Network,BRNN 后续会做介绍。
循环神经网络的前向传播
在实际的 RNN 实现中,每一个时间步的 RNN 都可以被看作是一个 2 层的全连接的神经网络,在此基础上除了可以横向的多个时间步进行传递,还可以将输出沿纵向传递给更深的层次,进而实现 RNN 的堆叠。
对于第一个单词的前续输入 a<0> 一般设置为元素全 0 的向量,此后对于输入的第一个元素有:
- a<1> = g(Waaa<0> + Waxx<1> + ba)
- ŷ<1> = g'(Wyaa<1> + by)
更一般地,对于第 t 个时间步来说:
- a<t> = g(Waaa<t-1> + Waxx<t> + ba)
- ŷ<t> = g'(Wyaa<t> + by)
为了使得前向传播的公式更加简洁,上式可以改写为:
- a<t> = g(Wa [a<t-1>, x<t>] + ba)
- ŷ<t> = g'(Wya<t> + by)
其中 Wa 为将 Waa 和 Wax 水平并列放置的结果,即 Wa = [Waa ¦ Wax],而 [a<t-1>, x<t>] 为上下放置的结果,相应的在 Numpy 中可以采用 hstack((Waa, Wax)) 和 vstack((a<t-1>, x<t>)) 来实现。
与在 CNN 中常用 ReLU 做激活函数不同的是在 RNN 中常用的激活函数是 tanh,对于预测部分的激活函数则可以根据实际应用使用 Sigmoid 或 Softmax 等。
循环神经网络的反向传播
在讨论反向传播之前需要针对任意一个时间步定义一个损失函数,在这里仍然采用交叉熵函数:
- L<t>(ŷ<t>, y<t>) = -y<t>logŷ<t> - (1 - y<t>)log(1 - ŷ<t>)
相应地,对于整个输入序列来说则可以定义成本函数如下:
- L(ŷ, y) = ΣL<t>(ŷ<t>, y<t>), t = 1, 2, 3, ... , Tx
具体的计算过程类似前面讲到的神经网络的反向传播计算,先计算前向传播(绿色箭头),并计算各个节点的损失函数,最终再通过计算图计算反向传播(红色箭头),通过梯度下降来完成参数的更新。由于在序列类型的输入中引入了时间的概念,因此这个过程中的反向传播称为 Backpropagation through time。
不同类型的循环神经网络
上面讨论的 RNN 中 Tx 与 Ty 是等长的,而现实中的情形不总是如此。对于下面的这些不同类型的序列数据来说,有些时候输入仅是一维的,而对应的输出则是多维的,在另一些情形下输入有些时候是多维的,而输出则是一维的。
如果将之前的循环神经网络称为 many-to-many 的架构,那么对于类似电影情感分析类的多个输入、单个输出的 RNN 则可以称为 many-to-one RNN:
反之对于音乐生成类单个输入、多个输出的架构则可以称为 one-to-many RNN:
同时对于 many-to-many RNN 也存在输入和输出均为多个但又不相等的情形:
语言建模和序列生成
简单说来语言建模的目的就是希望可以对于多个不同的语句组合给予一个概率评价,从而确定一个最高概率的语句组合。通过 RNN 进行语言模型的构建过程如下:
首先需要获取一个大型的语言信息训练集,这个集合在自然语言处理的语境中常被称为语料库 corpus
在此基础上需要针对训练集中的词汇通过 one-hot encoding 的形式将其符号化,以此作为网络的输入
通过对于网络的训练确定不同语句及组合方式在寻常的表达中出现的概率,此时的输出激活函数需要采用 softmax
使用循环神经网络进行语言模型训练时的一个具体的细节是会令 x<t> = y<t-1>,也即不断的在后续的节点告知前面语句的正确输入信息,在此基础上计算在已知输入为某个词语的条件下语料库中所有词汇出现的概率,其本质上是通过网络计算一系列的条件概率。
抽样获取新序列
在训练完成后,可以通过采用 np.random.choice( ) 在词汇表中随机选取词汇构成新的序列组合来对模型进行测试,以直观的了解模型学到了什么。
这里需要注意的是在构建语言模型的时候,可以构建基于词汇水平的模型,也可以构建基于字母水平的模型。在构建字母水平的模型时由于会对每一个字母进行分拆因此序列会变得非常的长,并且不如词汇水平的模型对于句子不同位置的词汇间的相关关系的捕捉能力。
RNN 中的梯度消失问题
由于有时在自然语言中前后词汇之间的相关关系经常要间隔较长的词汇才会出现,例如对于主语使用复数形式时,在一个从句当中其谓语可能会间隔了几十个单词才需要出现,此时由于深层网络存在的梯度消失问题,前面讲到的标准形式的 RNN 就比较难将前面的信息传递到后面。
Gated RNN Unit, GRU
为了解决长间隔的序列信息传递问题,GRU 单元引入了一个记忆单元 memory cell,记做 C 来在处理信息的同时对重要的信息进行记忆,且在第 t 个时间步有 C<t> = a<t>,在网络计算中用 Ĉ<t> = tanh(Wc [C<t-1>, x<t>] + bc) 来做为 C<t> 的备选值,在决定是否采用 Ĉ<t> 来对 C<t> 进行更新时,可以设置一个 Γu = σ(Wu [C<t-1>, x<t>] + bu) 来计算相应的概率值,由于这个 Γ 的取值范围是 (0, 1) ,并且在实际计算中取值基本为 0 或 1,因此对于是否对 C<t> 进行更新的判断公式可以总结为 C<t> = Γu * Ĉ<t> + (1 - Γu) * C<t-1>,最后这一步为基于元素的向量乘法 elementwise。
上述 GRU 单元实际上是一个简化的版本,在实际实施中更为完整的版本如下:
Ĉ<t> = tanh(Wc [ ΓrC<t-1>, x<t>] + bc)
Γr = σ(Wr [C<t-1>, x<t>] + br)
Γu = σ(Wu [C<t-1>, x<t>] + bu)
C<t> = Γu * Ĉ<t> + (1 - Γu) * C<t-1>
公式中添加了 Γr 项以确定 c<t-1> 的重要性 relevance。
长短时记忆 LSTM
另一个专门针对间隔信息传递的架构设计是长短时记忆 Long short-term memory,其与 GRU 在实现中最显著的区别是 C<t> ≠ a<t>,其主要组成部分及判断逻辑如下:
Forget Gate - 对应下图中最左侧的逻辑门,其通过对前一个时间步传递过来的激活信息 a<t-1> 和当前时间步的输入 x<t> 的组合信息进行一个逻辑激活来生成一个所有元素处于 0 - 1 之间的向量 Γf = σ(Wf [a<t-1>, x<t>] + bf),再通过这个向量来与从前一个时间步传递过来的长期记忆 C<t-1> 进行逐个元素的乘积来判断 C<t-1> 中有哪些是需要忘记的 Γf * C<t-1>,以此完成对于长期记忆的部分更新
Use Gate - 对应下图中最中间部分的由一个 σ 激活判断和一个 tanh 激活构成的逻辑门,其结合前一个时间步传递过来的激活信息 a<t-1> 和当前时间步的输入信息 x<t> 来判断如何进一步更新从前一个时间步传递过来的长期记忆 C<t-1>,其实现原理为:
根据 Ĉ<t> = tanh(Wc [ a<t-1>, x<t>] + bc) 来计算一个潜在的可以加入长期记忆的储备信息
再利用 Γu = σ(Wu [a<t-1>, x<t>] + bu) 进一步同 Ĉ<t> 通过元素相乘判断这个储备信息中有多少可以被加入到长期记忆中
最后结合前两个逻辑门的工作成果,最终此时间步的长期记忆为:C<t> = Γu * Ĉ<t> + Γf * C<t-1>
Output Gate - 对应下图中最右侧部分的由一个 σ 激活判断和一个 tanh 激活构成的逻辑门,其主要职责为根据前一个时间步传递过来的激活信息 a<t-1> 和当前时间步的输入信息 x<t> 来判断本时间步的长期记忆的激活信息 C<t> 有哪些是需要进行输出的,相应的计算公式为:
Γo = σ(Wo [a<t-1>, x<t>] + bo)
a<t> = Γo * tanh(C<t>)
在文献中 C<t-1> 和 C<t> 常被称为 cell state 或 long term memory,而上一个时间步的激活结果 a<t-1> 则被称为 working memory。
在实际使用中由于 GRU 的实现更加简洁,因此也更加适合规模化,但 LSTM 由于采用了 3 个逻辑门因而更加的灵活,并不能简单的评价哪一个单元优于另外一个,需要根据应用具体选择。
双向 RNN
前面已经讲到在词汇判断时,单向 RNN 在某一个时间步只能利用其之前的时间步的信息,而这经常是不够的,双向 RNN 就是为了解决这个问题而出现的。在双向 RNN 中输出预测结合了前向传播和后向传播的激活信息,二者共同参与输出判断。
但 BRNN 的一个重要缺陷就是模型需要整个序列的信息才能做出判断,这在诸如实时语音识别类的应用中就不太适用。
Deep RNNs
如同标准的神经网络一样,我们可以把前面的标准 RNN 单元、GRU 单元、LSTM 单元和 BRNN 单元层叠在一起构成深度更深的 DRNN 网络,但由于时间序列的存在,一般 3 层的 DRNN 就已经算是比较复杂的架构了。
这里Andrew 为了区分不同的层,在注释中用 [ ] 添加了层数,与标准 RNN 单元一致的是在同一层中的参数是相同的。
RNN in TensorFlow
在 TensorFlow 中,在构建 RNN 时有两个函数:tf.nn.rnn 和 tf.nn.dynamic_rnn,这两个函数的区别是 tf.nn.rnn 创建的是一个固定时间步的非展开状态的 RNN 计算图,因此其要求每一个批次的输入必须具有相同的步长,而 tf.nn.dynamic_rnn 中不同批次的长度则可以不同,因此在可能的情况下尽量选择后者。
RNN Cells & Wrappers & Layers
RNN 的基本构成单元是一系列的 RNN,GRU 和 LSTM 单元,这一部分很多不同的参考来源对于 cell 和 unit 并没有严格的定义。在 TensorFlow 中 cell 本身可以包含多个基本的 LSTM,GRU 或 RNN 单元,例如在 BasicLSTMCell 定义中有一个参数 num_units 就是定义一个大的、处于同一层中的 LSTM cell 里可以包含多少个基本的 LSTM 单元,并列的多个(取决于时间步的多少) LSTM cell 构成一个 LSTM 层,在此基础上可以通过 MultiRNNCell 定义多个 LSTM 层。
-
BasicRNNCell
– A vanilla RNN cell. -
GRUCell
– A Gated Recurrent Unit cell. -
BasicLSTMCell
– An LSTM cell based on Recurrent Neural Network Regularization. No peephole connection or cell clipping. -
LSTMCell
– A more complex LSTM cell that allows for optional peephole connections and cell clipping. -
MultiRNNCell
– A wrapper to combine multiple cells into a multi-layer cell. -
DropoutWrapper
– A wrapper to add dropout to input and/or output connections of a cell.
cell = tf.contrib.rnn.BasicLSTMCell(num_units=64, state_is_tuple=True) # 包含 64 个 LSTM 基本单元
cell = tf.contrib.rnn.DropoutWrapper(cell=cell, output_keep_prob=0.5)
cell = tf.contrib.rnn.MultiRNNCell(cells=[cell] * 4, state_is_tuple=True) # 4 个 LSTM 层
在完成基本单元构造后,推荐使用 dynamic_rnn 来构造计算图,其性能要优于 static_rnn:
outputs, state = tf.nn.dynamic_rnn(cell=cell, inputs=data, dtype=tf.float32)
#根据 inputs 中的时间步来展开构造计算图
在 dynamic_rnn 中还有一个参数 sequence_length,其需要指定的是在同一批次的输入中因为长度不同而采用 padding 时,每一个序列在未添加 padding 时的时间步长,这点在文档中的解释是含糊的。