RNN教程4-实现GRU/LSTM网络

这是RNN教程的第四部分,也是最后一部分。我们将讨论两个RNN模型的变种—LSTM(Long Short Term Memory)和GRU(Gated Recurrent Units)。

RNN翻译教程目录:

英文出处

关于lstm,有一篇不错的教程:http://colah.github.io/posts/2015-08-Understanding-LSTMs/

这是LSTM模型提出的原始论文《LSTM can solve hard long time lag problems
》,作者是S Hochreiter和 J Schmidhuber,这个模型也是在NLP领域里面应用的最广的一个。

而GRU则是在2014年被提出来,《Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation》,是LSTM的一个简单变体,他们有许多相同的属性。

我们先从LSTM开始,然后再看看GRU有什么不同。

LSTM网络

RNN教程第三部分,我们提到了梯度消失的问题,也正是这个问题,导致了RNN无法学习长期依赖。LSTM就是用来解决这个问题的,因为它引入了一个叫做“门”(gating)的机制。为了搞明白这个门是什么,我们先看看LSTM是怎么计算隐藏层st的:

LSTM计算式子
LSTM计算式子

上面的这些式子看上去好像挺复杂的,但它没你想象的那么难。首先,我们需要注意到,LSTM层只是计算隐藏层的另一种方式

在之前,也就是原始的RNN,我们用s_{t}=\tanh(Ux_{t}+Ws_{t-1})这个式子来计算隐藏层,隐藏层的输入单元有两个,一个是当前时间步的输入x_{t},另一个是上一个隐藏状态s_{t-1}LSTM单元干的也是一样的事情,只不过换了个方式而已!这是理解LSTM的关键。现在,你最终可以把LSTM和GRU当作一个黑盒来使用,只要给你一个当前时间的输入以及上一个状态,你就可以计算出下一个状态了。正如下图:

简化LSTM和GRU模型
简化LSTM和GRU模型

有了这样的铺垫之后,我们就可以来看看LSTM的隐藏层是怎么来计算的了。Chris Olah有一篇博文介绍的很清楚(http://colah.github.io/posts/2015-08-Understanding-LSTMs/),为了不做重复工作,我在这里会给出比较简洁的解释。当然,想要理解更深刻一些,我建议你还是去拜读一下他的那篇博客。好了,我们开始吧。

LSTM计算式子
LSTM计算式子
  • i,f,o分别被称为input(输入)门,forget(遗忘)门和输出(output)门。注意,他们有着相同结构的等式,只不过参数是不一样的。他们之所以被称为门,是因为sigmoid函数会将这些向量映射到0到1这个区间,然后拿它们和其它的向量相乘,你就能决定究竟让多少其它的向量“通过”这个门。输入门能让你决定放多少当前时间步新计算的信息通过,遗忘门能让你决定放多少上个状态的信息通过,最后的输出门能让你决定放多少信息给到更高一层神经网络或者下一个时间步。这三个门都有同样的维度ds,也就是隐藏层的大小。
  • g是基于当前输入和先前隐藏状态计算的“候选”隐藏状态。这个式子和我们在原始RNN中的式子是一样的,我们只是把参数U,W改成了U^{g}W^{g}而已。在原始RNN中,我们是直接把g的值当作一个状态输出给下一个状态,在LSTM中,我们将用输入门来挑选g的一部分值,再输出给下一个状态。
  • c_{t}就是内部的记忆单元了。它是上一个记忆单元和遗忘门相乘的结果,再加上隐藏候选状态g和输入门相乘的结果。这样,我们既可以完全忘记上一个记忆单元的内容(把遗忘门全部置为0),也可以完全忽略掉新的输入状态(把输入门全部置为0),当然现实情况是我们需要的结果是这两种极端情况之间。
  • 给定了记忆单元c_{t},我们最终要计算出隐藏状态s_{t},由式子可知,s_{t}是由输出门和记忆单元(用tanh处理)相乘得到的。并非所有的内部记忆单元都与其他隐藏状态相关。

下面是出自《Empirical evaluation of gated recurrent neural networks on sequence modeling》这篇论文的一张图,描述LSTM的内部细节:

LSTM内部细节
LSTM内部细节

直观上来看,原始的RNN似乎可以看作是LSTM的一个特例—当你将输入门全部置为1,、遗忘门全部置为0(你总是忽略之前的记忆单元)、输出门全部置为1,你就会几乎得到标准的RNN,只不过多了个tanh压缩了一下输出。正是这个“门机制”让LSTM能够明确的建立长期记忆依赖,通过学习这些门的参数,神经网络能够更好的利用这些记忆。

要注意的是,LSTM的结构也有几种变种。一个最常见的变种就是创建peephole(窥见孔),这个peephole能让门不仅仅依赖于上一个状态s_{t-1},也依赖于上一个内部记忆单元c_{t-1}。在门方程增加附选项即可。这篇论文(LSTM: A Search Space Odyssey)评估了不同的lstm结构。

GRU网络

GRU网络背后的思想和LSTM非常相似,它的式子如下:

GRU计算式子
GRU计算式子

GRU中有两个门,重置(reset)门r和更新(update)门z。从直观上看,重置门决定了新的输入和上一个记忆怎么组合,更新门决定了保留多少前面的记忆信息。如果我们把重置门全部置为1,更新门全部置为0,我们就又重新得到原始的RNN模型了。它的思想和LSTM是一致的,都是用门机制来学习长期记忆以来,但是它有几个关键的区别:

  • GRU有两个门,LSTM有3个。
  • GRU没有内部记忆单元(c_{t}),也没有LSTM中中的输出门。
  • LSTM的输入门和遗忘门,在GRU中被整合成一个更新门z;而重置门r被直接用到前一个隐藏状态上面了。因此,LSTM中重置门的职责实际上被分为GRU中的rz
  • 计算输出的时候,我们不会再多用一次非线性的变换了。

下面是出自《Empirical evaluation of gated recurrent neural networks on sequence modeling》这篇论文的一张图,描述GRU的内部细节:

GRU内部细节
GRU内部细节

GRU和LSTM的对比

现在你已经见识到这两个为了解决RNN梯度消失问题而提出的模型了,你也可能会好奇:我该用哪个?GRU是比较新的模型(2014年提出的),关于它的一些利弊现在还没有探索清楚。从这两篇论文来看: Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling  和 An Empirical Exploration of Recurrent Network Architectures,两个模型难以分出胜负。在许多任务里面,这两个模型有不同的表现,然而,更多情况下,调整超参数会比选择模型更重要

GRU有更少的参数(U、W规模比较小),所以训练起来会更快一些,而且需要的训练数据也少一些。相对应的,如果你有足够的训练数据,LSTM会是更好的选择。

实现

让我们回顾一下RNN教程第2部分提到的语言模型,这次,我们用GRU来实现。(LSTM的实现也差不多,只不过式子不一样而已)

我们通过之前theano版本的代码来修改,还是要记住这一点,GRU(或者LSTM)层只是换了一种方式计算隐藏层而已。所以,我们需要做的,仅仅是在前向转播的代码里,把隐藏层的计算步骤修改一下。

def forward_prop_step(x_t, s_t1_prev):
      # This is how we calculated the hidden state in a simple RNN. No longer!
      # s_t = T.tanh(U[:,x_t] + W.dot(s_t1_prev))
       
      # Get the word vector
      x_e = E[:,x_t]
       
      # GRU Layer
      z_t1 = T.nnet.hard_sigmoid(U[0].dot(x_e) + W[0].dot(s_t1_prev) + b[0])
      r_t1 = T.nnet.hard_sigmoid(U[1].dot(x_e) + W[1].dot(s_t1_prev) + b[1])
      c_t1 = T.tanh(U[2].dot(x_e) + W[2].dot(s_t1_prev * r_t1) + b[2])
      s_t1 = (T.ones_like(z_t1) - z_t1) * c_t1 + z_t1 * s_t1_prev
       
      # Final output calculation
      # Theano's softmax returns a matrix with one row, we only need the row
      o_t = T.nnet.softmax(V.dot(s_t1) + c)[0]
 
      return [o_t, s_t1]

在我们的实现代码里面,我们添加了偏置单元b、c,这在之前的等式里面没有体现。当然我们也要更改U、W的初始化,因为我们现在有这不同的大小了,在这里代码就不贴出来了,在github(https://github.com/dennybritz/rnn-tutorial-gru-lstm)上面有,我还加了一个word embedding层E。

这很简单,但是梯度怎么办?我们仍然可以跟以前一样应用链式求导法则,但实际上,更多人用一些库来完成这个步骤,比如theano。如果你还是想自己动手计算梯度,你可能需要实现不同模块的求导算法。在这里,我们让theano直接帮我们计算:

# Gradients using Theano
dE = T.grad(cost, E)
dU = T.grad(cost, U)
dW = T.grad(cost, W)
db = T.grad(cost, b)
dV = T.grad(cost, V)
dc = T.grad(cost, c)

很好!为了得到更好的结果,我们使用了一些小技巧。下面就来说说看。

使用rmsprop来进行参数的更新

RNN教程第二部分,我们使用了最基本的SGD算法来进行参数的更新,然而它表现的并不是那么好。如果你把学习率设置得很低,SGD确实会让你得到好的训练效果,但是这样就太慢了。为了解决这个问题,有很多SGD算法的变种,包括 (Nesterov) Momentum Method), AdaGradAdaDelta 和 rmsprop,这里有一篇文章总结得很好(http://cs231n.github.io/neural-networks-3/#update)。

在这部分教程,我将使用rmsprop这个方法,它背后的思想是:根据先前的梯度和来调整每个参数的学习率。直观上我们可以这么理解,就是频繁出现的特征获得较小的学习率(因为他们的梯度总和更大),而比较少出现的特征就获得更大的学习率。

Rmsprop算法的实现非常简单,对于每一个参数,我们对应存储一个缓存变量,在梯度下降的过程中我们更新参数以及缓存变量,正如下式(以W为例):

cacheW = decay * cacheW + (1 - decay) * dW ** 2
W = W - learning_rate * dW / np.sqrt(cacheW + 1e-6)

衰减率设置在0.9到0.95之间,另外1e-6是为了避免分母为0

添加embedding层

使用word embedding(词嵌入)层是改进模型精确度的流行方法,其中word2vecGloVe都是比较出名的。和用one-hot表示句子不同,这种词嵌入的方法用低维(通常是几百维)的向量来表示词语,这有个好处,那就是能通过向量来判断两个词的语义是否相近,因为如果他们意思相近的话,他们的向量就很相近。要使用这些向量,需要预训练语料库。从直觉上来看,使用word embedding层,就相当于你告诉神经网络词语的意思,从而神经网络就不需要学习关于这些词语的知识了。

增加第二个GRU层

为我们的神经网络增加多一层,能够使得模型捕捉更高层次的交互信息,你可以加多几层,但我对这个实验并没有做过多的尝试。你可能在2,3层的计算之后,发现计算值逐渐减小,而且如果你没有足够的数据,增加层数是没有效果的,另外可能造成过拟合。

增加一层GRU或者LSTM
增加一层GRU或者LSTM

增加一层GRU或者LSTM是很简单的,我们只需要修改前向传播的计算过程以及初始化函数:

# GRU Layer 1
z_t1 = T.nnet.hard_sigmoid(U[0].dot(x_e) + W[0].dot(s_t1_prev) + b[0])
r_t1 = T.nnet.hard_sigmoid(U[1].dot(x_e) + W[1].dot(s_t1_prev) + b[1])
c_t1 = T.tanh(U[2].dot(x_e) + W[2].dot(s_t1_prev * r_t1) + b[2])
s_t1 = (T.ones_like(z_t1) - z_t1) * c_t1 + z_t1 * s_t1_prev
 
# GRU Layer 2
z_t2 = T.nnet.hard_sigmoid(U[3].dot(s_t1) + W[3].dot(s_t2_prev) + b[3])
r_t2 = T.nnet.hard_sigmoid(U[4].dot(s_t1) + W[4].dot(s_t2_prev) + b[4])
c_t2 = T.tanh(U[5].dot(s_t1) + W[5].dot(s_t2_prev * r_t2) + b[5])
s_t2 = (T.ones_like(z_t2) - z_t2) * c_t2 + z_t2 * s_t2_prev

最后,完整代码戳这里:https://github.com/dennybritz/rnn-tutorial-gru-lstm/blob/master/gru_theano.py

关于RNN教程,就翻译到这里了,过程中有修改、删除部分内容,有什么问题可以评论里一起探讨,谢谢!

RNN教程3-BPTT算法以及消失的梯度

这是RNN教程的第三部分。

RNN翻译教程目录:

英文出处

上一部分,我们从零开始实现了一个RNN,但是并没有对其中的BPTT算法作详细的解释。在这一部分,我们将简要的介绍一下RNN,并解释一下它和传统的反向传播算法有什么不同。接着,我们将会尝试着去理解梯度消失问题,也正是因为存在这个问题,LSTM和GRU才会被提出来,这两个模型在NLP领域相当流行。

为了深刻理解这部分教程,我建议你最好熟悉一下反向传播算法,下面三篇教程可供参考,他们的难度是逐渐增大的:

http://cs231n.github.io/optimization-2/
http://colah.github.io/posts/2015-08-Backprop/
http://neuralnetworksanddeeplearning.com/chap2.html

BPTT算法

让我们快速的回顾一下RNN的基本式子。注意,这里有个小小的改动,那就是o变成了\hat{y},这是为了和要引用的一些文献保持一致:

    \[ s_{t}=\tanh(Ux_{t}+Ws_{t-1}) \]

    \[ \hat{y}_{t}=\rm softmax(Vs_{t}) \]

同样,定义损失函数为交叉熵损失函数如下:

交叉熵损失函数

y_{t}是正确的标签,\hat{y}_{t}是我们的预测。我们以一个句子序列为一个训练样本,所以总的误差就是每一个时间步(单词)的误差和。

RNN反向传播
RNN反向传播

记住,我们的目标是计算误差对应U,V,W的梯度,从而用SGD算法来更新U,V,W。正如我们上面做的—把误差相加,在这里我们也把训练样本中每一个时间步的梯度相加起来:

    \[ \frac{\partial E}{\partial W}=\sum_{t} \frac{\partial E_{t}}{\partial W} \]

RNN结构图
RNN结构图

为了计算这些梯度,我们使用导数的链式法则。这正是反向传播算法中从最后一层将误差向前传播的思想。接下来,为了能有具体的理解,我们将用E3作为例子:

以E3为例子的BPTT算法

在上面的式子中,z_{3}=Vs_{3},\bigotimes是外积的意思。事实上,上面这个式子的推导还是省略了几个步骤的,包括softmax函数与交叉熵结合的求导,关于里面的细节,可以参考这两篇文章:

http://www.jianshu.com/p/ffa51250ba2e
http://blog.csdn.net/u014313009/article/details/51045303

但这里的重点还不在于推导的过程,重点在于\frac{\partial E_{3}}{\partial V}这个值,只取决于当前时间步的一些值:\hat{y}_{3}y_{3}s_{3},如果你有了这些值,计算关于V的梯度只是简单的矩阵乘法而已。

而对于\frac{\partial E_{3}}{\partial W}(还有U)而言,事情就不一样了,我们来看一下链式式子:

链式求导式子1
链式求导式子1

请注意, 这个式子取决于s_{2}的值,而s_{2}由取决于Ws_{1}的值…依此类推。所以,如果我们想得到相对于W的导数,我们就不能把s_{2}当作一个常量。重新应用链式法则,我们得到:

链式求导式子2
链式求导式子2

上面的式子用到了复合函数的链式求导法则。再上个图加深理解:

BPTT复合函数链式求导
BPTT复合函数链式求导

注意,上面的步骤其实和标准的反向传播算法是一样的,关键的不同在于我们将每个时间步对于W的梯度都加了起来。在传统的NN里面,我们不共享权重,所以我们不用做这种加法。和传统的反向传播算法一样,我们仍然可以定义残差,然后计算梯度。

下面上代码:

def bptt(self, x, y):
    T = len(y)
    # Perform forward propagation
    o, s = self.forward_propagation(x)
    # We accumulate the gradients in these variables
    dLdU = np.zeros(self.U.shape)
    dLdV = np.zeros(self.V.shape)
    dLdW = np.zeros(self.W.shape)
    delta_o = o
    delta_o[np.arange(len(y)), y] -= 1.
    # For each output backwards...
    for t in np.arange(T)[::-1]:
        dLdV += np.outer(delta_o[t], s[t].T)
        # Initial delta calculation: dL/dz
        delta_t = self.V.T.dot(delta_o[t]) * (1 - (s[t] ** 2))
        # Backpropagation through time (for at most self.bptt_truncate steps)
        for bptt_step in np.arange(max(0, t-self.bptt_truncate), t+1)[::-1]:
            # print "Backpropagation step t=%d bptt step=%d " % (t, bptt_step)
            # Add to gradients at each previous step
            dLdW += np.outer(delta_t, s[bptt_step-1])              
            dLdU[:,x[bptt_step]] += delta_t
            # Update delta for next step dL/dz at t-1
            delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step-1] ** 2)
    return [dLdU, dLdV, dLdW]

从代码中我们可以看到,为什么RNN这么难以训练—因为序列太长了,可能超过20个单词,所以你要传播很多层。而在实际上,很多人将反向传播截断成比较少的步骤,正如上面代码中的bptt_truncate参数定义的那样。

梯度消失和梯度爆炸

关于这部分内容,就不翻译了,感兴趣的朋友可以参考我之前写过的【关于梯度消失和梯度爆炸】,而在原始RNN当中,这个问题尤其突出。

下一篇,也是最后一篇教程,我们将关注LSTM模型和GRU模型。

RNN教程2-用python、numpy和theano实现RNN

在这个第2部分,我们将用python实现一个完整的RNN网络。用到numpy和theano这两个库。

RNN翻译教程目录:

英文出处

1.语言模型

我们的目标是用RNN网络来训练出一个语言模型。简单来说下语言模型是怎么回事,假如我们有一个句子,这个句子由m个单词组成,语言模型允许我们,在观测到这个句子时,能够预测下一个单词是什么(也就是一个单词在此基础上出现的概率),也就是条件概率:
条件概率

举个例子。有一个句子“He went to buy some chocolate”,这个句子是怎么生成的呢?我们可以将其看作给定了”He”这个条件之后出现”went”的概率,乘以给定了”He went”这个条件之后出现”to”的概率,乘以…(以此类推)给定了”He went to buy some”这个条件之后出现”chocolate”的概率。

为什么我们要将概率应用到这上面来呢?

首先,这个模型可以用来当作一个评分模型,比如,在机器翻译系统中,通常会生成多个待选的结果,这时候,我们可以选择那个概率最高的句子作为输出。

再者,这个语言模型还有一个很酷的性质,那就是我们可以生成新的文本,Andrej Karparthy的一篇讲述RNN的有效性的博文里面提到,我们可以通过基于RNN的语言模型来生成新的文本,从莎士比亚的诗集到linux的源码,都是可以的。

我们需要注意到,上面提到的概率模型需要用到一个句子里面的所有文字信息,而很多模型是做不到记忆那么多前置信息的。虽然RNN在理论上是为这个而生,但是,它在长期记忆方面的能力还是有所欠缺的。这个后面会继续探讨。

下面开始讲基于RNN的语言模型的代码实现。

2.训练数据和预处理

为了训练这个语言模型,我们需要训练数据。就好像我们学讲话一样,都是通过大量的练习,才会慢慢形成后来的讲话习惯。

幸运的是,这个模型的实现并不需要任何的人工标记。训练数据,我们选择的是reddit上的15000个较长的评论(原文放在bigtable上面数据集链接貌似已经失效,感兴趣的可以去搜一下,也可以用别的语料来训练,原理是一样的)。我们期望能实现一个模型,能够生成类似于reddit的这些评论的文字。好了,先来预处理一下训练数据。

2.1 分词

我们需要把原始语料用的每一段文字切分成句子,然后每一个句子再切分成单词。英文的分词比较简单,可以直接用NLTK(http://www.nltk.org/)的分词系统,里面word_tokenizesent_tokenize这两个方法就足够了。当然NLTK也支持中文接口,可以参看这篇文章:在NLTK中使用斯坦福中文分词器

2.2 过滤掉低频词

在我们的语料库里面,有的单词只出现一到两次,我们最好把这些低频词给去掉,因为如果词汇太多的话,训练会非常慢。而且,对于这些低频词来说,我们没有足够的上下文信息来支撑他们的训练。这类似我们人类的学习,要学习一个词的意义,我们需要在更多的语境里面看到它们。

在代码里面vocabulary_size代表着词汇表的规模(我将词汇表的大小设置为8000,代表着8000个最常出现的单词,当然你也可以更改啦)。对于词汇表里没有的单词,我们将它设置为UNKNOWN_TOKEN。举个例子,如果我们的词汇表里面没有nonlinearities这个单词,那么句子“nonlineraties are important in neural networks”就会被表示成“UNKNOWN_TOKEN are important in Neural Networks”,UNKNOWN_TOKEN这个词也是在词汇表里面的。最后,当我们需要预测单词的时候,如果预测出来的单词是UNKNOWN_TOKEN的话,我们可以用选择词汇表之外的任意一个单词来替代它,又或者,干脆我们就不要生成含有UNKNOWN_TOKEN的文本。

2.3 开始和结束标记

这个模型是用来生成文本的,文本该怎么开头,又怎么结束呢?为了解决这个问题,我们可以用两个特殊的标记来代表开头和结束。对于每一组训练数据,我们在句子的开头增加SENTENCE_START这个标记,在句子的结尾增加SENTENCE_END这个标记。

2.4 建立训练数据矩阵

循环神经网络RNN的输入都是向量,而不是我们数据集里面的字符串,所以我们需要将数据集的字符串映射成向量。在代码里,用的是这两个方法:index_to_wordword_to_index,单词和索引之间可以相互映射。比如,”how”,”are”,”you”这3个词可能处于词汇表的第4,100,7733个位置,那么一个训练输入句子“how are you“就会被表达成[0,4,100,7733],其中的0是上面提到的开始标记SENTENCE_START的位置。由于我们是为了训练语言模型,那么对应的输出应该是每个单词往后移动一个位置,对应为[4,100,7733,1],其中的1是上面提到的结束标记SENTENCE_END。下面是实现的代码片段:

vocabulary_size = 8000
unknown_token = "UNKNOWN_TOKEN"
sentence_start_token = "SENTENCE_START"
sentence_end_token = "SENTENCE_END"
 
# Read the data and append SENTENCE_START and SENTENCE_END tokens
print "Reading CSV file..."
with open('data/reddit-comments-2015-08.csv', 'rb') as f:
    reader = csv.reader(f, skipinitialspace=True)
    reader.next()
    # Split full comments into sentences
    sentences = itertools.chain(*[nltk.sent_tokenize(x[0].decode('utf-8').lower()) for x in reader])
    # Append SENTENCE_START and SENTENCE_END
    sentences = ["%s %s %s" % (sentence_start_token, x, sentence_end_token) for x in sentences]
print "Parsed %d sentences." % (len(sentences))
     
# Tokenize the sentences into words
tokenized_sentences = [nltk.word_tokenize(sent) for sent in sentences]
 
# Count the word frequencies
word_freq = nltk.FreqDist(itertools.chain(*tokenized_sentences))
print "Found %d unique words tokens." % len(word_freq.items())
 
# Get the most common words and build index_to_word and word_to_index vectors
vocab = word_freq.most_common(vocabulary_size-1)
index_to_word = [x[0] for x in vocab]
index_to_word.append(unknown_token)
word_to_index = dict([(w,i) for i,w in enumerate(index_to_word)])
 
print "Using vocabulary size %d." % vocabulary_size
print "The least frequent word in our vocabulary is '%s' and appeared %d times." % (vocab[-1][0], vocab[-1][1])
 
# Replace all words not in our vocabulary with the unknown token
for i, sent in enumerate(tokenized_sentences):
    tokenized_sentences[i] = [w if w in word_to_index else unknown_token for w in sent]
 
print "\nExample sentence: '%s'" % sentences[0]
print "\nExample sentence after Pre-processing: '%s'" % tokenized_sentences[0]
 
# Create the training data
X_train = np.asarray([[word_to_index[w] for w in sent[:-1]] for sent in tokenized_sentences])
y_train = np.asarray([[word_to_index[w] for w in sent[1:]] for sent in tokenized_sentences])

给个输入和输出的具体例子:

x:
SENTENCE_START what are n’t you understanding about this ? !
[0, 51, 27, 16, 10, 856, 53, 25, 34, 69]

y:
what are n’t you understanding about this ? ! SENTENCE_END
[51, 27, 16, 10, 856, 53, 25, 34, 69, 1]

3.构建RNN

关于RNN的基本介绍可以看第一篇教程

RNN结构图
RNN结构图

让我们具体的看看RNN怎么应用在我们的语言模型上。输入x就是一系列单词,每一个x_{t}就是一个单独的单词。但是,为了矩阵乘法运算能够起作用,我们不能使用上面例子所说的单词索引表达方法(比如这个[51, 27, 16, 10, 856, 53, 25, 34, 69, 1]
)。那么我们用什么呢?依旧是one-hot表达方法。one-hot很简单,比如我们上面说的单词表的大小为8000,某个词的索引是36,那么用one-hot来表示这个词的话,我们可以表示成一个长度为8000的数组,其中第36位为1,其它位均为0.

因此,每一个x_{t}(单词)都会是一个长度为8000的数组向量,而每一个x就会是一个矩阵,每一行代表一个单词。我们将会在构建神经网络的代码里展示这种变形,而不是在预处理的时候。对应的,o_{t}也是类似的形式,是个8000维的数组,理论上,o_{t}也是一个one-hot表示,而实际上,在我们的神经网络里,训练的结果是–o_{t}会被表示成8000个概率值,每一个概率对应着输出这个词的可能性,我们要选择概率最大的词语作为输出。

让我们重新看看公式:

    \[ s_{t}=\tanh(Ux_{t}+Ws_{t-1}) \]

    \[ o_{t}=\rm softmax(Vs_{t}) \]

我发现一个有用的经验,那就是把每一层的矩阵、向量大小给写出来,有助于你理解整个神经网络的结构。我们假设记忆单元之间连接的神经元数量H=100(称之为隐藏层)。隐藏层越大,可以学到的模式就更复杂,当然需要的计算量也更大。下面就是整个结构的大小:

结构大小

请记住,U,V和W是我们这个模型的核心,也就是神经网络的权重参数,它们最终就是我们从训练数据中学习到的东西。因此,我们需要学习的参数数量为2HC+H^2,在具体的这个网络中,C=8000,H=100,代进去结果就是1610000。注意一下,由于xt是one-hot向量(一位为1,其它均为0),所以第一步它和U的全连接乘法,本质上没什么计算量,只是一个选择Ucolumn的过程。因此,主要的矩阵乘法计算在Vs_{t}这一步。这也是我们希望词汇表越小越好的原因。

有了以上的准备,我们开始实现过程吧。

3.1 初始化

我们从一个初始化所有权重的RNN类开始,我将其命名为RNNNumpy,因为我们使用Theano来实现的。初始化U,V,W是有一点小技巧(tricky)的,我们不能将它们全都置为0,因为这样的话,所有层的计算都会变成对称的。我们需要先随机赋值。很多研究表明,一个初始化数值是会对最后的训练结果产生影响。

事实证明,最好的初始化方法,取决于我们用的是哪种激活函数(在我们的例子里是tanh),另外,还有一种推荐的方法,那就是将权重随机初始化,范围在[-\frac{1}{\sqrt{n}},\frac{1}{\sqrt{n}}]之内,n代表着上一层的连接数。听起来好像很复杂,不用担心,反正只要你将你的参数初始化为一个比较小的值,它通常都会挺奏效的。

下面是代码:

class RNNNumpy:
     
    def __init__(self, word_dim, hidden_dim=100, bptt_truncate=4):
        # Assign instance variables
        self.word_dim = word_dim
        self.hidden_dim = hidden_dim
        self.bptt_truncate = bptt_truncate
        # Randomly initialize the network parameters
        self.U = np.random.uniform(-np.sqrt(1./word_dim), np.sqrt(1./word_dim), (hidden_dim, word_dim))
        self.V = np.random.uniform(-np.sqrt(1./hidden_dim), np.sqrt(1./hidden_dim), (word_dim, hidden_dim))
        self.W = np.random.uniform(-np.sqrt(1./hidden_dim), np.sqrt(1./hidden_dim), (hidden_dim, hidden_dim))

在上面的代码中,word_dim是我们词汇表的大小,hidden_dim是我们隐藏层的大小,还有bptt_truncate,先不用管它,我们在后面会解释。

3.2 前向传播

接下来,让我们实现前向传播算法:

def forward_propagation(self, x):
    # The total number of time steps
    T = len(x)
    # During forward propagation we save all hidden states in s because need them later.
    # We add one additional element for the initial hidden, which we set to 0
    s = np.zeros((T + 1, self.hidden_dim))
    s[-1] = np.zeros(self.hidden_dim)
    # The outputs at each time step. Again, we save them for later.
    o = np.zeros((T, self.word_dim))
    # For each time step...
    for t in np.arange(T):
        # Note that we are indxing U by x[t]. This is the same as multiplying U with a one-hot vector.
        s[t] = np.tanh(self.U[:,x[t]] + self.W.dot(s[t-1]))
        o[t] = softmax(self.V.dot(s[t]))
    return [o, s]

注意,在函数的最后,不仅仅返回了输出层,还返回了隐藏状态层。因为我们需要用他们来计算梯度。每一个o_{t}都是一个8000维的向量,代表着输出每一个词的概率,我们只想要概率最高的那个词,所以,下面用一个predict函数来实现:

def predict(self, x):
    # Perform forward propagation and return index of the highest score
    o, s = self.forward_propagation(x)
return np.argmax(o, axis=1)

让我们来试运行一下

np.random.seed(10)
model = RNNNumpy(vocabulary_size)
o, s = model.forward_propagation(X_train[10])
print o.shape
print o

输出如下:

(45, 8000)
[[ 0.00012408 0.0001244 0.00012603 …, 0.00012515 0.00012488
0.00012508]
[ 0.00012536 0.00012582 0.00012436 …, 0.00012482 0.00012456
0.00012451]
[ 0.00012387 0.0001252 0.00012474 …, 0.00012559 0.00012588
0.00012551]
…,
[ 0.00012414 0.00012455 0.0001252 …, 0.00012487 0.00012494
0.0001263 ]
[ 0.0001252 0.00012393 0.00012509 …, 0.00012407 0.00012578
0.00012502]
[ 0.00012472 0.0001253 0.00012487 …, 0.00012463 0.00012536
0.00012665]]

对于句子里的每一个词语(上面的这个句子有45个词),我们的模型输出了8000个值,对应着词典中的每一个词可能是下一个单词的概率。当然,现在还没有开始训练,所有的值都是随机的。

然后下面看下predict函数:

predictions = model.predict(X_train[10])
print predictions.shape
print predictions

它给我们返回了45个结果,对应着词典的索引:

(45,)
[1284 5221 7653 7430 1013 3562 7366 4860 2212 6601 7299 4556 2481 238 2539
21 6548 261 1780 2005 1810 5376 4146 477 7051 4832 4991 897 3485 21
7291 2007 6006 760 4864 2182 6569 2800 2752 6821 4437 7021 7875 6912 3575]

3.3 计算误差

为了训练我们的网络,我们需要需要量化误差,我们将这种量化的函数称为损失函数L,而我们的目标则是找到最优的U,V,W,使得误差最小。一个经常使用的误差函数叫做 cross-entropy loss(交叉熵损失函数)(关于交叉熵损失函数有一篇中文博客可以看下:http://blog.csdn.net/u012162613/article/details/44239919)。假设我们有N个训练样本(注意,这里不是指N个句子,而是训练样本中所有的单词的个数,因为对于这个模型而言,每输入一个单词,会对应一个输出),还有C个类别(也就是词典的大小,8000),损失函数里面有两个参数:预测输出o和实际标注y,如下式:

    \[ L(y,o)=-\frac{1}{N}\sum_{n\subseteq N}y_n\log{o_n} \]

上面的式子看上去好像有点复杂,但我们仔细看一下,它本质其实就是在度量预测输出值o和实际标注值的差距。原始的交叉熵损失函数
也是这么干的,他有一个重要的结论就是输出值和实际值越接近,整个损失函数就越小。(再重新具体看一下这个式子,yn是一个8000维的one-hot向量,和上面【构建RNN】步骤里面红色字一样,它只是起一个选择的作用,而o_{n}是一个概率,假如o_{n}越接近1,那么\log{o_{n}}就越接近0,损失函数的值就越小,这和上面的解释是吻合的。举个例子,比如y_{n}=[0,0,1],o_{n}=[0.1,0.4,0.5],\sum_{n\subseteq N}y_n\log{o_n}=1\times \log{0.5}= \log{0.5},看到没有,其他的0我们是可以忽略的,而且如果预测越精准(概率趋于1),\sum_{n\subseteq N}y_n\log{o_n}\approx \log{1}=0,也就是损失几乎为0)

下面两个函数就是具体总损失,以及平均损失。

def calculate_total_loss(self, x, y):
    L = 0
    # For each sentence...
    for i in np.arange(len(y)):
        o, s = self.forward_propagation(x[i])
        # We only care about our prediction of the "correct" words
        correct_word_predictions = o[np.arange(len(y[i])), y[i]]
        # Add to the loss based on how off we were
        L += -1 * np.sum(np.log(correct_word_predictions))
    return L
 
def calculate_loss(self, x, y):
    # Divide the total loss by the number of training examples
    N = np.sum((len(y_i) for y_i in y))
    return self.calculate_total_loss(x,y)/N

3.4 用SGD算法和BPTT算法训练RNN模型

记住,我们想要通过训练数据,训练出能够使得损失最小的U,V,W。最常见的做法是SGD(Stochastic gradient descent)随机梯度下降。SGD背后的原理非常简单,我们循环遍历所有的训练样本,在每一次的循环当中,我们使得参数(也就是这里的U,V,W)的沿着某个方向下降,从而能让损失减少。这里所说的方向,就是由损失的梯度决定的:\frac{\partial L}{\partial U},\frac{\partial L}{\partial V},\frac{\partial L}{\partial W}

SGD算法还需要一个学习率( learning rate),这个学习率定义了我们在每一次迭代中,要跨多大的步子(学习率越大,学习速度越快,但容易“穿越“,导致找不到最优解;学习率越小,学习速度越慢)。这个算法不仅仅是应用在神经网络上,它在很多传统的机器学习算法上面都有大量的应用。关于这个算法,你可以深入的去了解,也有很多研究是针对它的,这是一篇教程:http://cs231n.github.io/optimization-1/

再回到我们的问题上来,我们该如何计算上面所提到的梯度呢?在传统的神经网络中,我们使用的是反向传播算法,而在RNN中,我们使用BPTT(Backpropagation Through Time )算法,用中文直白的理解就是,跨越时间的反向传播算法。为什么在RNN里面就不一样了呢?因为在这个模型当中,所有时间步的参数是共享权重的,而每一个输出的梯度,不仅仅取决于当前时间步的计算结果,还取决于在此之前所有时间步的计算结果。当然了,我们解决这个问题也是使用链式法则。关于反向传播算法,可以参看我之前写的【从梯度下降到反向传播(附计算例子)】,也可以参看这两篇英文博客:

http://cs231n.github.io/optimization-2/
http://colah.github.io/posts/2015-08-Backprop/

现在,暂且把BPTT算法当作一个黑盒子来使用吧,在下一篇教程中,我会详细介绍这个算法的。我们来看下代码实现,bptt函数最后会返回三个梯度,用于更新权重:

def bptt(self, x, y):
    T = len(y)
    # Perform forward propagation
    o, s = self.forward_propagation(x)
    # We accumulate the gradients in these variables
    dLdU = np.zeros(self.U.shape)
    dLdV = np.zeros(self.V.shape)
    dLdW = np.zeros(self.W.shape)
    delta_o = o
    delta_o[np.arange(len(y)), y] -= 1.
    # For each output backwards...
    for t in np.arange(T)[::-1]:
        dLdV += np.outer(delta_o[t], s[t].T)
        # Initial delta calculation
        delta_t = self.V.T.dot(delta_o[t]) * (1 - (s[t] ** 2))
        # Backpropagation through time (for at most self.bptt_truncate steps)
        for bptt_step in np.arange(max(0, t-self.bptt_truncate), t+1)[::-1]:
            # print "Backpropagation step t=%d bptt step=%d " % (t, bptt_step)
            dLdW += np.outer(delta_t, s[bptt_step-1])              
            dLdU[:,x[bptt_step]] += delta_t
            # Update delta for next step
            delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step-1] ** 2)
    return [dLdU, dLdV, dLdW]

3.5 梯度检查

当你在实现一个反向传播算法的时候,你可以同时做一个梯度检查,来检验你的算法是否正确。检查的背后原理也很简单,那就是从导数的定义出发,也就是下面的这个式子:

导数的定义

下面是实现代码:

def gradient_check(self, x, y, h=0.001, error_threshold=0.01):
    # Calculate the gradients using backpropagation. We want to checker if these are correct.
    bptt_gradients = self.bptt(x, y)
    # List of all parameters we want to check.
    model_parameters = ['U', 'V', 'W']
    # Gradient check for each parameter
    for pidx, pname in enumerate(model_parameters):
        # Get the actual parameter value from the mode, e.g. model.W
        parameter = operator.attrgetter(pname)(self)
        print "Performing gradient check for parameter %s with size %d." % (pname, np.prod(parameter.shape))
        # Iterate over each element of the parameter matrix, e.g. (0,0), (0,1), ...
        it = np.nditer(parameter, flags=['multi_index'], op_flags=['readwrite'])
        while not it.finished:
            ix = it.multi_index
            # Save the original value so we can reset it later
            original_value = parameter[ix]
            # Estimate the gradient using (f(x+h) - f(x-h))/(2*h)
            parameter[ix] = original_value + h
            gradplus = self.calculate_total_loss([x],[y])
            parameter[ix] = original_value - h
            gradminus = self.calculate_total_loss([x],[y])
            estimated_gradient = (gradplus - gradminus)/(2*h)
            # Reset parameter to original value
            parameter[ix] = original_value
            # The gradient for this parameter calculated using backpropagation
            backprop_gradient = bptt_gradients[pidx][ix]
            # calculate The relative error: (|x - y|/(|x| + |y|))
            relative_error = np.abs(backprop_gradient - estimated_gradient)/(np.abs(backprop_gradient) + np.abs(estimated_gradient))
            # If the error is to large fail the gradient check
            if relative_error > error_threshold:
                print "Gradient Check ERROR: parameter=%s ix=%s" % (pname, ix)
                print "+h Loss: %f" % gradplus
                print "-h Loss: %f" % gradminus
                print "Estimated_gradient: %f" % estimated_gradient
                print "Backpropagation gradient: %f" % backprop_gradient
                print "Relative Error: %f" % relative_error
                return
            it.iternext()
        print "Gradient check for parameter %s passed." % (pname)

3.6 SGD算法的实现

有了上面的准备工作,我们就可以使用SGD算法来更新权重了。我倾向于用两个步骤来实现它:1. sgd_step方法:在一个batch上更新权重。2.用一个外循环来遍历所有的训练样本,并动态更改学习率。下面是实现代码:

# Performs one step of SGD.
def numpy_sdg_step(self, x, y, learning_rate):
    # Calculate the gradients
    dLdU, dLdV, dLdW = self.bptt(x, y)
    # Change parameters according to gradients and learning rate
    self.U -= learning_rate * dLdU
    self.V -= learning_rate * dLdV
    self.W -= learning_rate * dLdW
 
RNNNumpy.sgd_step = numpy_sdg_step
# Outer SGD Loop
# - model: The RNN model instance
# - X_train: The training data set
# - y_train: The training data labels
# - learning_rate: Initial learning rate for SGD
# - nepoch: Number of times to iterate through the complete dataset
# - evaluate_loss_after: Evaluate the loss after this many epochs
def train_with_sgd(model, X_train, y_train, learning_rate=0.005, nepoch=100, evaluate_loss_after=5):
    # We keep track of the losses so we can plot them later
    losses = []
    num_examples_seen = 0
    for epoch in range(nepoch):
        # Optionally evaluate the loss
        if (epoch % evaluate_loss_after == 0):
            loss = model.calculate_loss(X_train, y_train)
            losses.append((num_examples_seen, loss))
            time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            print "%s: Loss after num_examples_seen=%d epoch=%d: %f" % (time, num_examples_seen, epoch, loss)
            # Adjust the learning rate if loss increases
            if (len(losses) > 1 and losses[-1][1] > losses[-2][1]):
                learning_rate = learning_rate * 0.5 
                print "Setting learning rate to %f" % learning_rate
            sys.stdout.flush()
        # For each training example...
        for i in range(len(y_train)):
            # One SGD step
            model.sgd_step(X_train[i], y_train[i], learning_rate)
            num_examples_seen += 1

搞定!

4.用TheanoGPU来训练我们的神经网络

原文作者在之前有写过关于Theano的一个教程(http://www.wildml.com/2015/09/speeding-up-your-neural-network-with-theano-and-the-gpu/),这里就不多赘述了,作者用theano实现了上面的神经网络,可以在github上面淘到:https://github.com/dennybritz/rnn-tutorial-rnnlm

使用示例:

np.random.seed(10)
model = RNNTheano(vocabulary_size)
%timeit model.sgd_step(X_train[10], y_train[10], 0.005)

显然速度会得到大量的提升。

如果你的电脑性能不太好的话,可能训练上好几天都不行。为此,我放出我自己预训练的theano模型:https://github.com/dennybritz/rnn-tutorial-rnnlm/blob/master/data/trained-model-theano.npz

使用方法:

from utils import load_model_parameters_theano, save_model_parameters_theano
 
model = RNNTheano(vocabulary_size, hidden_dim=50)
# losses = train_with_sgd(model, X_train, y_train, nepoch=50)
# save_model_parameters_theano('./data/trained-model-theano.npz', model)
load_model_parameters_theano('./data/trained-model-theano.npz', model)

5.生成文本

现在已经有了模型了,我们来看看生成文本的效果:

def generate_sentence(model):
    # We start the sentence with the start token
    new_sentence = [word_to_index[sentence_start_token]]
    # Repeat until we get an end token
    while not new_sentence[-1] == word_to_index[sentence_end_token]:
        next_word_probs = model.forward_propagation(new_sentence)
        sampled_word = word_to_index[unknown_token]
        # We don't want to sample unknown words
        while sampled_word == word_to_index[unknown_token]:
            samples = np.random.multinomial(1, next_word_probs[-1])
            sampled_word = np.argmax(samples)
        new_sentence.append(sampled_word)
    sentence_str = [index_to_word[x] for x in new_sentence[1:-1]]
    return sentence_str
 
num_sentences = 10
senten_min_length = 7
 
for i in range(num_sentences):
    sent = []
    # We want long sentences, not sentences with one or two words
    while len(sent) < senten_min_length:
        sent = generate_sentence(model)
    print " ".join(sent)

下面是我挑选的几个生成的句子(我人工为首字母加上了大写)

  • Anyway, to the city scene you’re an idiot teenager.
  • What ? ! ! ! ! ignore!
  • Screw fitness, you’re saying: https
  • Thanks for the advice to keep my thoughts around girls.
  • Yep, please disappear with the terrible generation.

瞧瞧上面生成的句子,有一些有意思的东西值得注意。这个模型成功的学习到了语法,逗号和句号都基本放对位置了,有时候它还能模仿一些网络用语还有符号表情。

然而!!大部分生成的句子都是没有什么实际意义的,又或者有一些语法错误(上面几句是我挑的比较好的了)。分析一下原因。

首先可能是因为我们的训练时间还不够,或者训练数据不够。看上去这个原因很充分,但它其实并不是最主要的原因。

最主要的原因在于模型本身:我们的这个原始RNN模型不能学习到相隔几个词之外的依赖关系。这很奇怪,理论上这个模型就是为了长期依赖而生的,但实际上它还是表现不佳。

幸运的是,对于为什么RNN的训练那么困难已经不太难理解了(可以看这论文:http://arxiv.org/abs/1211.5063)。

下一篇教程,我们将会详细的探索BPTT算法,还会阐述一下梯度消失问题。这给了我们动力去探索更复杂的RNN模型,比如LSTM,这个NLP任务中表现很好的模型。别担心,至此为止你学到的东西,应用到LSTM上面也是一样的!

RNN教程1-RNN的基本介绍

了解RNN循环神经网络,我是从wildml的博客开始的。为了加深印象和理解,将其博客的系列教程作一个翻译,加上自己的理解,和大家分享。原文在这里:http://www.wildml.com/2015/09/recurrent-neural-networks-tutorial-part-1-introduction-to-rnns/

RNN翻译教程目录:

英文出处

这是教程系列第一篇:RNN的基本介绍。

Recurrent Neural Networks (RNNs),也就是循环神经网络,在NLP任务上非常流行。

在这个教程里,我们将会使用RNN来实现一个语言模型。语言模型的应用是有两方面,第一方面,它允许我们给任意句子打分,打分的依据则是这些句子在现实世界中出现的可能性,这给了我们语法和语义上的度量,在机器翻译领域应用较多。第二方面,一个语言模型可以让我们生成新的文本,Andrej Karpathy的这篇博文(http://karpathy.github.io/2015/05/21/rnn-effectiveness/)就展示了RNN的强大和有效性,它可以训练莎士比亚的诗集,从而生成类似莎士比亚诗集的文本。

以下内容是基于你已经对基本的神经网络和反向传播算法有了一定的了解,如果没有,可以先行了解。

1.什么是RNN

RNN背后的思想,是要利用时序信息。在传统的神经玩咯中,我们假设所有的输入(包括输出)都是相互独立的,然而对于许多任务而言,这太局限了。比如,你想根据一句没说完的话,来预测下一个词语,那你就需要上文的信息了。RNN中的Recurrent之所以称为循环,是因为它们对序列的每个元素执行相同的任务,输出取决于先前的计算。从另一个角度来思考RNN,我们可以想象它有一个“记忆”模块,储存着之前计算的信息。理论上,RNN是可以利用任意长的序列信息的,然而实际上,它只能利用有限的前几步的信息,下面是一个典型的RNN模型:典型的RNN模型

左边是RNN模型,展开之后,就是右边的样子。

举个简单的例子,如果我们关注的是由5个单词组成的句子,那么这个神经网络可以横向展开成5层,一层代表一个单词。下面解释一下图里的字母符号代表的意义:

x_{t}:在时刻t时的输入。

s_{t}:在时刻t时的隐藏状态,也就是“记忆模块”,它的值,由当前的输入x_{t}和前一个状态s_{t-1}决定,计算公式为s_{t}=f(Ux_{t}+Ws_{t-1}),f通常是非线性的激活函数,比如tanh或者ReLU函数。

o_{t}:在时刻t时的输出。比如,如果我们想要预测一句话里的下一个单词,那么在这里的输出就可以表示为一个词典序列,值为每一个词的概率。 o_{t}=softmax(Vs_{t})

需要注意的几点:

  • 你可以将s_{t}认为是神经网络的记忆单元,s_{t}存储了前面步骤的信息。在时刻t时的输出o_{t}仅仅是通过当前的记忆单元算出来的。虽然这个模型看起来比较理想化,但事实上它无法存储太多前面步骤的信息。
  • 传统的DNN模型一般对于每一层都使用不同的参数权重,而RNN横向的每一步都是共享参数权重的,也就是上面的U、V、W。这反映出一个事实,那就是这个模型对于每一步都在做同样的事情,只不过他们的输入不同而已。这样的好处是减少了很多计算量。
  • 上面的图片里的每一个时间节点,都会有一个输出,对于一些任务来说这是多余的。比如在情感分析里,我们只关心这个句子最终表达的情绪,而不是每一个单词表达的情绪。同样的,也不是必须得在每一个时间点都有输入。

2.RNN 能干嘛

RNN在许多NLP任务中取得了成功,当然这里也得提一下,通常用的最多的是LSTM模型,它能比原始的RNN存储更长的时序信息,而且你不用担心,LSTM只是RNN的一个改进,在之后的教程会介绍,它们的区别在于,LSTM计算隐藏状态s_{t}的方式不同。

好了,来看看RNN 能干嘛。

2.1 语言模型和文本生成

通过训练RNN模型,我们可以基于给定的一个单词序列,预测下一个单词是是什么。这对语言模型和文本生成来说是很有用的。

语言模型能让我们去度量生成一个句子的概率,这在机器翻译里面是非常重要的(因为如果能算出来一个高概率的句子,那么这个句子通常来讲是比较准确的)。

文本生成就更不用说了,通过训练大量的样本,我们可以用一个模型来生成新的文本。
下面是三篇论文,都是关于语言模型和文本生成的。

2.2 机器翻译

机器翻译和语言建模异曲同工,对于机器翻译而言,输入就是某一种语言的单词序列,输出是另一种语言的单词序列。区别在于,机器翻译需要读取了所有的输入之后,才能生成完整的输出,毕竟完整的上下文才能推导出最终的意思。

下图是应用于机器翻译的一个RNN模型,图片来源:http://cs224d.stanford.edu/lectures/CS224d-Lecture8.pdf:
应用于机器翻译的一个RNN模型

下面是关于机器翻译的一些论文:

2.3 语音识别

根据输入的语音信号,输出生成的文字。相关论文:

Towards End-to-End Speech Recognition with Recurrent Neural Networks

2.4 生成图片的文字描述

RNN还可以和CNN(convolutional Neural Network即卷积神经网络)组合一起做模型,为没有标记的图片生成文字描述,并取得了惊人的效果。这个组合模型甚至还能将生成的文字和图片中特征的位置对应。如下图:
生成图片的文字描述

以下是相关研究:
http://cs.stanford.edu/people/karpathy/deepimagesent/

3.训练RNN模型

RNN模型的训练过程和传统神经网络训练过程是类似的,我们同样的使用反向传播算法,当然了,具体的过程会有点不同,因为在RNN中,横向展开的每一层都是共享权重的,每一个输出的梯度(gradient)不仅仅依赖于当下这个时间点,还依赖于过去的时间点。举个例子,想要计算时间点t=4的梯度,我们需要反向传播3个时间点,并把梯度相加。这个算法就叫做BPTT(Backpropagation Through Time),后面会介绍。现在只需要知道,BPTT算法本身是有局限性的,它不能长期记忆,还会引起梯度消失和梯度爆炸问题,LSTM就是用来解决这个问题的,后面也会介绍LSTM。

4.扩展的RNN模型

过去这些年,许多学者提出了一些复杂的RNN模型的变体,用来克服原始RNN(vanilla RNN)的缺点。下面是一些典型的模型。

4.1 双向RNN( Bidirectional RNNs )

双向RNN从字面上就比较好理解,它的特点是:某个时间点的输出不仅仅依赖于过去的记忆,也依赖于后面发生的事情。最简单的例子那就是我们常做的英文完形填空了,填写缺失的单词,需要综合上下文的意思。

这个模型实现起来也很简单,只需要在原始RNN的基础上,加一个反方向的RNN即可。如下图:
双向RNN模型

4.2 多层(双向)RNN

在双向RNN的基础上,加多几层神经网络,就是多层RNN了,它有更强的学习能力,也需要更多的训练数据,如下图:
多层(双向)RNN

4.3 LSTM网络

LSTM在业界非常流行。它和基本RNN的结构是一样的,只不过它用不同的函数来计算隐藏层而已,LSTM的记忆被称为“cell”,我们暂且把它当作黑盒吧,从本质上来讲,这一些cell决定了要从前一段记忆中“记住”或者“忘记”什么东西。对于长期的记忆来说,这种模型非常奏效,这里有一篇很好的文章解释LSTM:
http://colah.github.io/posts/2015-08-Understanding-LSTMs/

5.总结

通过这第一篇教程,希望你能对RNN有一个初步的了解,并且知道它能干什么。在下一篇教程,我们将用python来实现一个简单的、基于RNN的语言模型。

关于梯度消失以及梯度爆炸

上一篇介绍了梯度下降法,对于一些浅层的神经网络来说,可以很容易看到效果,因为每一次迭代,我们都可以将误差降低。而对于深层次的神经网络来说,容易出现一个问题,那就是梯度消失以及梯度爆炸。

来看看这两个问题是怎么产生的。

以下内容参考了哈工大翻译的神经网络教程

为了弄清楚为何会出现消失的梯度,来看看一个极简单的深度神经网络:每一层都只有一个单一的神经元。下图就是有三层隐藏层的神经网络:

简单神经网络

这里,w_1,w_2... 是权重,而 b_1,b_2... 是偏置,C 则是某个代价函数。第j个神经元的输出 ,其中a_j=f(z_j)是激活函数,z_j = w_j*a_{j-1}+b_j 是神经元的带权输入,\delta_{i}为对应节点的残差。

为了揭示梯度消失和梯度爆炸的问题,我们计算\frac{\partial C}{\partial b_1},看看会发生什么。

接下来是计算过程。

结合上一篇文章【从梯度下降到反向传播(附计算例子)】的式子(4)、(6):

    \[ \frac{\partial J(W,b)}{\partial b_{i}^{l}}=\delta _{i}^{(l+1)}\qquad(4) \]

    \[ \delta _{i}^{(l)} = (\sum_{j=1}^{s_{l+1}}W_{ji}^{(l)}\delta_{j}^{(l+1)})\cdot {f}'(z_{i}^{(l)})\qquad(6) \]

式子(4)里面的J(W,b),也就是我们上面所说的代价函数C

由式子(4),可以得到:

    \[ \frac{\partial C}{\partial b_1}=\delta_2 \]

由式子(6),继续进行迭代计算

    \[ \begin{aligned} \frac{\partial C}{\partial b_1}&=\delta_2\\ &={f}'(z_1)w_2\delta_3\\ &={f}'(z_1)w_2{f}'(z_2)w_3\delta_4\\ &={f}'(z_1)w_2{f}'(z_2)w_3{f}'(z_3)w_4\delta_5 \end{aligned} \]

最后的delta_5,就是最后一个节点的残差,由链式求导法则可知:

    \[ delta_5= \frac{\partial C}{\partial a_4}{f}'(z_4) \]

代入上式,得到最终结果:

    \[ \frac{\partial C}{\partial b_1}={f}'(z_1)w_2{f}'(z_2)w_3{f}'(z_3)w_4\frac{\partial C}{\partial a_4}{f}'(z_4) \]

除了最后一项,该表达式是一系列形如w_j{f}'(z_j)的乘积,我们假设,这里的激活函数f用的是sigmoid函数,sigmoid的图像如下:

sigmoid函数图像

我们关注的是它的导数,其导数的图像为:

sigmoid函数导数图像

该导数在{f}'(0)=\frac{1}{4}时达到最高。现在,如果我们使用标准方法来初始化网络中的权重,那么会使用一个均值0标准差为1的高斯分布。因此所有的权重通常会满足\left | w_j \right |<1。有了这些信息,我们发现会有w_j{f}'(z_j)< \frac{1}{4}。并且在我们进行了所有这些项的乘积时,最终结果肯定会指数级下降:项越多,乘积的下降的越快。 这,就是梯度消失出现的原因。 同样的,如果我们选择不同的权重初始化方法、以及不同的激活函数,我们也有可能得到w_j{f}'(z_j)>1的结果,经过多层累乘,梯度会迅速增长,造成梯度爆炸。

因此,不稳定的梯度问题(梯度消失和梯度爆炸)产生的原因是:在前面的层上的梯度是来自后面的层上项的乘积。

当存在过多的层次时,就出现了内在本质上的不稳定场景。唯一让所有层都接近相同的学习速度的方式是所有这些项的乘积都能得到一种平衡。如果没有某种机制或者更加本质的保证来达成平衡,那网络就很容易不稳定了。简而言之,真实的问题就是神经网络受限于不稳定梯度的问题。所以,如果我们使用标准的基于梯度的学习算法,在网络中的不同层会出现按照不同学习速度学习的情况。

为了解决这个问题,有很多的策略,比如,nlp领域常用的lstm模型,能够解决梯度消失的问题。之后会继续介绍。

神经网络和深度学习的学习路线

从去年暑假开始接触神经网络,自己研究的方向是基于深度学习的自然语言处理,在这个过程中,接触了许多教程、论文、编程框架,断断续续,今天把前段时间学习过的内容、学习路线稍微总结一下,也为刚刚接触神经网络的朋友提供一些可借鉴的参考。

入门级教程

Neural Network and Deep Learning

说到神经网络入门的教程,首推哈工大SCIR的微信公众号的一个教程,这是他们团队从国外翻译过来的:【《神经网络与深度学习》连载】,已经连载更新完毕。如果你英文程度不错,也可以直接阅读英文原版【Neural Network and Deep Learning

这个教程的好处在于,它有实打实的源码例子供我们运行:https://github.com/mnielsen/neural-networks-and-deep-learning

它的例子是针对手写字体识别的,数据集是经典的MNIST数据。

UFLDL教程

UFLDL,全称为Unsupervised Feature Learning and Deep Learning,即无监督的特征学习和深度学习。这个教程是斯坦福大学出品,吴恩达主持出品,良心保证,有中英文。

英文教程地址:http://ufldl.stanford.edu/wiki/index.php/UFLDL_Tutorial

中文教程地址:http://ufldl.stanford.edu/wiki/index.php/UFLDL教程

内容比较多、比较杂,我个人是重点看了【神经网络】、【反向传导算法】、【softmax回归】以及【处理大型图像】的内容,【处理大型图像】主要讲的是卷积神经网络CNN,而CNN也能应用于自然语言处理。

总之,哈工大和斯坦福的这两个教程我是结合着一起看的,入门也花了点时间,结合代码看可能会理解更透彻。

自然语言处理教程

用深度学习来弄自然语言处理,首先要向量化文本,这里比较出名的是google出品的word2vec。它可以根据你给的大量语料,训练出一种词语表达方式,用一个几百维的向量来表示一个词语,这样,就方便了词语的数学化表征。

52NLP给出了用word2vec训练中英文维基百科的详细步骤:http://www.52nlp.cn/中英文维基百科语料上的Word2Vec实验

这个博客挺好的,有很多自然语言处理相关的文章。

深度学习教程

有了神经网络的基础,就可以继续看深度学习的模型了。基于自然语言处理,我看了cnn和rnn、lstm和gru这些模型。

CNN模型

我看了这篇论文:《Convolutional Neural Networks for Sentence Classification》,里面有实验,代码在这里:https://github.com/yoonkim/CNN_sentence,跑的是关于影评句子情感分类的例子。需要用到训练好的word2vec数据,https://code.google.com/archive/p/word2vec/ 有的下载,即页面上【Pre-trained word and phrase vectors】,是300维的、谷歌用100万亿左右单词的新闻预料训练好的词向量。

网上很多中文教程基本上都是翻译这篇论文的,可以结合着一起看。

RNN模型

rnn模型是公认的比较适合自然语言处理的模型。

首推【The Unreasonable Effectiveness of Recurrent Neural Networks】这篇博文,一个简单的rnn就能生成效果良好的语言模型,即,通过你给出的语料,训练出和你语料风格类似的句子、语言。作者用他的模型,训练了莎士比亚的作品、linux源码等等,都有令人惊讶的效果。

对应的源码在这里:https://github.com/karpathy/char-rnn  是用lua实现的,如果你对python比较熟悉,可以看:https://gist.github.com/karpathy/d4dee566867f8291f086 (貌似要翻墙)。由于是这个模型是char by char的,直接处理中文可能有问题,我作了一些更改,可以跑中文,训练了一下唐诗,挺好玩的,下次贴上来。

其次可以看RNN的系列教程:【RECURRENT NEURAL NETWORKS TUTORIAL】,有RNN,LSTM和GRU,也有对应的代码实现:https://github.com/dennybritz/rnn-tutorial-rnnlm ,是基于python的,有theano的实现过程。

另外,关于LSTM,【Understanding LSTM Networks】 这篇博文讲的很棒。

深度学习框架

我平时用python比较多,所以接触的框架基本上是python的。

theano

官方文档:http://deeplearning.net/software/theano/

配置是个麻烦事,多搜索,多尝试,总能配成功。

我对这个框架还不是很熟悉,它的编程模式和一般的不太一样,不过熟悉了这种框架之后,能很方便的对你的模型进行修改。

TensorFlow

官方文档:https://www.tensorflow.org/

中文文档:http://www.tensorfly.cn/

谷歌出品,没用过,暂不评价。不过强大。

keras

theano和tensorflow的高级封装,用python实现,适合快速实现模型。

官方文档:https://keras.io/

中文文档:http://keras-cn.readthedocs.io/en/latest/

 

还有很多别的框架,就不一一介绍了。