1 RNN原理
1.1 RNN的时序反向传播原理
RNN中采用时序反向传播算法(BPTT)对参数更新,下面将简单介绍下BPTT原理,并解释其与传统反向传播的区别。我们还将了解梯度消失问题,这也是推动LSTM和GRU发展的原因。
1.1.1 时序反向传播算法
我们以基本RNN结构来说明BPTT算法的原理:
上图为基本的RNN展开结构图,下面公式展示的是RNN的方程式:
同样,将损失函数定义为交叉熵损失函数:
这里$y_t$为t时刻正确的label,$\widehat{y}_t$是我们的预测。通常我们会将一个完整的句子序列视为一个训练样本,因此总误差为各时间步上的误差之和。
别忘了我们的目的是要计算误差对应的参数U、V和W的梯度,然后借助SGD算法来更新参数。当然,我们统计的不只是误差,还包括训练样本在每时间步的梯度:
这一步比较好理解,因为总误差等于每个时刻的误差之和,上面就梯度公式对$W$、$V$、$U$均适用。
我们借助导数的链式求导法则来计算梯度,本文后续部分以$E_3$为例来进行介绍:
上式表明,$\dfrac{\partial{E_3}}{\partial{V}}$的值仅取决于当前时间步的值:$y_t$、$\widehat{y}_t$、$s_3$。同样的,其他时间步对$E$的偏导数也能求出,有了这些值,计算参数$V$的梯度就比较简单了。
接下来再看看$\dfrac{\partial{E_3}}{\partial{W}}$如何求。由上面的公式我们可以推断出$E_3$对$W$的偏导计算公式如下:
上面的计算公式用到了复合函数求导法则,将每个时间步长对梯度的贡献相加。换言之,由于参数$W$时间步长应用于想要的输出,因此需从$t=3$开始通过所有网络路径到$t=0$进行反向传播梯度。
需要注意的是,这与我们在深度神经网络中应用的标准反向传播算法完全一致,主要区别在于我们对每时间步的参数$W$的梯度进行了求和,传统的人工神经网络中,我们不在层与层之间共享参数,也就无需求和。
1.1.2 梯度消失问题
我们知道在传统RNN中,是无法解决长期以来问题的。而文本信息的句意通常取决于相隔较远的单词,例如“The man who wore a wig on his head went inside”的语意重心在于一个人走进屋里,而非男人戴着假发。但标准的RNN难以捕获此类信息。那么不妨通过分析上面计算出的梯度来一探究竟:
上式中,别忘了一点:$\dfrac{\partial{s_3}}{\partial{s_k}}$本身为链式法则,
还需要注意,在对向量函数的向量求导,结果是一个矩阵,逐元素求导,因此,上述梯度可重写为:
tanh、sigmoid函数及其导数的值域如下图所示(参考http://nn.readthedocs.io/en/rtd/transfer/):
tanh函数及其导数
sigmoid函数及其导数
可以看到tanh和sigmoid函数在两端的导数均为0,近乎呈直线状(导数为0,函数图像为直线),此种情况下可称相应的神经元已经饱和。两函数的梯度为0,使前层的其它梯度也趋近于0。由于矩阵元素数值较小,且矩阵相乘数次(t - k次)后,梯度值迅速以指数形式收缩(意思相近于,小数相乘,数值收缩,越来越小),最终在几个时间步长后完全消失。“较远”的时间步长贡献的梯度变为0,这些时间段的状态不会对你的学习有所贡献:你最终还是无法学习长期依赖。梯度消失不仅存在于循环神经网络,也出现在深度前馈神经网络中。区别在于,循环神经网络非常深(本例中,深度与句长相同),因此梯度消失问题更为常见。
不难想象,如果雅克比矩阵的值非常大,参照激活函数及网络参数可能会出现梯度爆炸,即所谓的梯度爆炸问题。相较于梯度爆炸,梯度消失问题更受关注,主要有两个原因:其一,梯度爆炸现象明显,梯度会变成Nan(而并非数字),并出现程序崩溃;其二,在预定义阈值处将梯度截断是一种解决梯度爆炸问题简单有效的方法。而梯度消失问题更为复杂,因为其现象不明显,且解决方案尚不明确。
幸运的是,目前有一些方法可解决梯度消失问题。合理初始化矩阵 W可缓解梯度消失现象。还可采用正则化方法。此外,更好的方法是使用 ReLU,而非tanh或sigmoid激活函数。ReLU函数的导数是个常量,0或1,因此不太可能出现梯度消失现象。
更常用的方法是借助LSTM或GRU架构。1997年,首次提出LSTM ,目前该模型在NLP领域的应用极其广泛。GRU则于2014年问世,是LSTM的简化版。这些循环神经网络旨在解决梯度消失和有效学习长期依赖问题。
1.2 RNN变体之LSTM
LSTM的整体结构如下所示;
接下来我们来逐步拆解下LSTM,看看里面每一个门的原理:
1.2.1 遗忘门
在LSTM中的第一步是决定从cell状态中丢弃什么信息。这个决定是通过一个称为“遗忘门”的结构来完成。这个“门”会读取$h{t-1}$和$x_t$,输出一个0到1之间的数值给每个在cell状态$C{t-1}$中的值,1表示“完全保留”,0表示“完全丢弃”。
1.2.2 输入门
下一步是决定让多少新的信息加入到cell状态中来。实现这个需要包括两个步骤:首先,一个叫做“input gate layer”的sigmoid层决定哪些信息需要更新;一个$tanh$层生成一个向量,也就是备选的用来更新的内容,$C_t$。在下一步,我们把这两部分联合起来,对 cell 的状态进行一个更新。
有了上述的结构,我们就可以按照下图来更新 cell 状态了, 即把$C_{t-1}$更新为 $C_t$,这部分信息就是我们要添加的新内容。
1.2.3 输出门
最后,我们需要来决定输出什么值了。这个输出主要是依赖于 cell 的状态$C_t$,但是又不仅仅依赖于$C_t$,而是需要经过一个过滤的处理。
- 首先,我们还是使用一个sigmoid层来(计算出)决定$C_t$中的哪部分信息会被输出;
- 接着,我们把$C_t$通过一个 tanh 层(把数值都归到 -1 和 1 之间),然后把 tanh 层的输出和 sigmoid 层计算出来的权重相乘,这样就得到了最后输出的结果。
在语言模型例子中,假设我们的模型刚刚接触了一个代词,接下来可能要输出一个动词,这个输出可能就和代词的信息相关了。比如说,这个动词应该采用单数形式还是复数的形式,那么我们就得把刚学到的和代词相关的信息都加入到 cell 状态中来,才能够进行正确的预测。
1.3 RNN变体之GRU
GRU(Gated Recurrent Unit ),这是由 Cho, et al. (2014) 提出。在 GRU 中,如下图所示,只有两个门:重置门(reset gate)和更新门(update gate)。同时在这个结构中,把细胞状态和隐藏状态进行了合并。最后模型比标准的 LSTM 结构要简单,而且这个结构后来也非常流行。
2 Tensorflow中的RNN实现
2.1 tensorflow中的RNN
tensorflow提供了基本的RNN接口:tf.nn.rnn_cell.RNNCell(),我们首先来简单的看下这个接口:
从上面的代码我们得知,RNNCell()其实是不能直接调用的,需要对其中的几个方法做implemention,直接调用RNNCell()会报错:
运行上面代码会报错,具体错误为:
这个Error正是上面RNNCell类中方法抛出来的Error。
可以认为,RNNCell是所有其他RNN的基类,这个从源码中能够很直接的看到:
因此,我们将上面代码中的RNNCell换成BasicRNNCell/BasicLSTMCell/GRUCell都可以正确运行。
上面例子中,在定义cell是,无论是BasicRNNCell、BasicLSTMCell、GRUCell都有一个参数num_units,理解这几个Cell的参数含义十分重要:
BasicLSTMCell
几个参数的含义如下:
- num_uints:官方API里给出的解释是“The number of units in the LSTM cell”,也就是上面LSTM结构图中$h_t$和$C_t$的维度($h_t$和$C_t$具有相同的维度)。指定这个参数之后,RNN内部会对$W$、$b$作自动维度适配;
- forget_bias:没太弄明白这个参数是干嘛的;
- state_is_tuple:决定输出是否是tuple,默认为True,为False根据官方文档会被废弃掉;
- activation:指定激活函数,当activation为None时,默认使用的就是tanh;
BasicRNNCell、GRUCell等参数同BasicLSTMCell,这里不做多述。
2.2 关于output和state
个人认为,RNN最难理解的地方之一就是output和state,output对应的是输出,state对应的是状态,在tensorflow中,dynamic_rnn、static_rnn、bidirectional_dynamic_rnn、static_bidirectional_rnn都是返回(outputs, last_states)元组,注意,last_states是最终的状态,而outputs对应的则是每个时刻的输出。在使用tensorflow做RNN相关任务时,这一点不理解清楚后面就没法儿继续了。
output和state在RNN及其变体中的意义是不一样的,所表示的值也不一样,下面来看下几个最基本的RNN及其变体中的output和state的含义:
BasicRNNCell
基本的RNN结构如下所示:
在基本的RNN结构中,我们可以认为输出就等于隐层状态值。我们来看下以下代码的outputs和last_states的值:
上面代码的输出结果为:
比较下outputs[0][4](第一个样本最后时刻的输出)和last_states[0](第一个样本最后的状态)、以及outputs[1][4](第二个样本最后时刻的输出)和last_states[1](第二个样本最后时刻的输出)的值,不难发现,它们是相等的!这也印证上面的说法。
BasicLSTMCell
LSTM与基本的RNN有些不用(参见1.3节),因为LSTM引入了4个门,多了几个状态,因此LSTM的输出和BasicRNNCell是不同的。我们通过一个例子看看BasicLSTMCell的基本用法:
以上运行的结果为:
从上面结果中我们看到,和BasicRNNCell相同的是,BasicLSTMCell返回的outputs是一样的,都是对应于每个时刻的输出(其实这里的输出也就是每个时刻的隐层状态值;更为一般的做法是,得到outputs值之后,在经过一个全连接层、softmax层做分类任务)。不同的是,last_states的值,BasicLSTMCell的last_states返回的是一个LSTMStateTuple,也就是一个LSTMState结构的元组,元组里面包含两个元素:c和h,c表示的就是最后时刻cell的内部状态值,h表示的就是最后时刻隐层状态值。
GRUCell
从1.4节中GRU原理可知,GRU的输出outputs和LSTM、BasicRNNCell是一样的,last_states和BasicRNNCell一样,只输出最后一个时刻的隐层状态值。同样用个例子来说明:
输出结果为:
2.2 dynamic_rnn和static_rnn
上面代码中,我们用到了一个新的api-dynamic_rnn,具体dynamic_rnn如何用?我们不妨先来看下dynamic_rnn的参数:
我们来结合一个例子来说明这些参数的含义:
假设RNN的输入为[2, 5, 4],其中2位batch_size,5为文本最大长度,4为embedding_size,可以看出,有两个样本,我们假设第二个文本的长度只有3(很容易理解,两个句子的长度不一样,第一个句子长度为5,第二个句子长度为3),那么传统上我们需要对第二个句子做zero-padding。
假设cell的HIDDEN_SIZE=10,dynamic_rnn返回两个参数:outputs, last_states,其中outputs的shape为[batch_size, max_length, HIDDEN_SIZE],也就是最终的输出结果;last_states是最终的状态,是由(c, h)组成的元组,大小均为[batch_size, HIDDEN_SIZE]。
再来看下一个很重要的参数:sequence_length,这个参数用来指定每个文本的长度,比如上面例子中,我们令sequence_length = [5, 3],表示第一个句子的长度为5,第二个句子的长度为3;当我们传入这个参数时,对于第二个样本,tensorflow对3以后的padding就不做计算了,其last_states将重复第3步的last_states结果直到最后一步,而outputs中超过3步的结果将会被置零。
完整的例子如下:
以上运行结果为:
既然有dynamic_rnn,自然也就有static_rnn,来看下static_rnn的参数:
2.3 tensorflow中的双向RNN
tensorflow提供了双向RNN接口:tf.nn.bidirectional_dynamic_rnn(),我们首先来看一下这个API的解释:
函数参数说明:
cell_fw/cell_bw:定义前向和反向的rnn cell;
inputs:输入序列;
sequence_length:序列长度;
initial_state_fw/initial_state_bw:前向、后向rnn_cell的初始化状态,一般初始化为全零状态;
dtype:数据类型;
time_major:一个很重要的参数,决定了输入和输出的格式,如果time_major为True,那么输出就是时序优先级,输入/输出的tensor格式必须为[max_time, batch_size, depth],如果time_major为False,那么就是bacth优先级的,输入/输出的格式必须为[batch_size, max_time, depth];默认情况下,time_major=False,这是因为大多数情况下,tensorflow处理数据都是以batch为单位的,但是当time_major=True时会更高效一些,因为当time_major=True时,可以避免开始和结束时tensor类型的转化开销;其中depth表示输入的词向量的维度(也就是embedding size的大小),max_time可以理解为句子的长度(一般以一个batch中最长的句子为准,不够的需要做padding)。
函数返回值
bidirectional_dynamic_rnn返回一个(outputs, outputs_state)形式的一个元祖。
其中:
- outputs=(outputs_fw, outputs_bw),是一个包含前向cell和后向cell输出tensor组成的元组;如果time_major=False,则两个tensor的shape为[batch_size, max_time, depth],应用在文本中,max_time可以理解为句子的长度(一般以最长的句子为准,短句需要做padding),depth为输入句子词向量的维度;
- outputs_state=(outputs_state_fw, outputs_state_bw),包含了前向和后向最后的隐藏状态组成的元组,outputs_state_fw和outputs_state_bw的类型都是LSTMStateTuple,是由(c, h)组成,分别代表memory cell和hidden state状态;
因为bidirectional_dynamic_rnn返回的是前向、后向的结果,最终的结果还需要对前向、后向结果做拼接,利用tf.concat(outputs, 2)即可。
前向和后向cell的定义
cell_fw和cell_bw的定义完全一样的,如果两个cell都是LSTM的话就是双向LSTM,如果两个cell都是GRU的话,就是双向GRU:
这里有一点需要注意:RNN/LSTM/GRU在声明cell时,只需传入一个HIDDEN_SIZE即可,它会自动匹配输入数据的维度。
tf.contrib模块下的功能是开发者提供的,等功能得到进一步验证成熟之后,会被放入到官方的模块中。
在bidirectional_dynamic_rnn函数内部,会通过array_ops.reverse_sequence函数将输入序列逆序排列,使其达到反向传播的效果。
在实现的时候,只需将定义好的两个cell作为参数传入即可:
需要注意的是,inputs_embedded为输入的tensor,格式为[batch_size, max_time, depth]。
最终的outputs = tf.concat((outputs_fw, outputs_bw), 2)或者直接是outputs = tf.concat(outputs, 2)。
如果还需要用到最后的输出状态,则需要进一步对(outputs_state_fw, outputs_state_bw)做处理:
下面给出一个基本的双向LSTM实现步骤:
以上就是一个基本的双向LSTM实现流程。
参考
[1] https://sthsf.github.io/2017/08/31/Tensorflow基础知识-bidirectional-rnn/
[2] http://www.cnetnews.com.cn/2017/1118/3100705.shtml
[3] http://blog.csdn.net/wuzqChom/article/details/75453327
[4] http://blog.csdn.net/u012436149/article/details/71080601
[5] 深度学习与自然语言处理(7)_斯坦福cs224d 语言模型,RNN,LSTM与GRU:https://www.zybuluo.com/hanxiaoyang/note/438990