第9课:深度学习之循环神经网络

语言模型

RNN是在自然语言处理领域中最先被用起来的,比如,RNN可以为语言模型来建模。那么,什么是语言模型呢?

我们可以和电脑玩一个游戏,我们写出一个句子前面的一些词,然后,让电脑帮我们写下接下来的一个词。比如下面这句:

我昨天上学迟到了,老师批评了____。

我们给电脑展示了这句话前面这些词,然后,让电脑写下接下来的一个词。在这个例子中,接下来的这个词最有可能是『我』,而不太可能是『小明』,甚至是『吃饭』。

语言模型就是这样的东西:给定一个一句话前面的部分,预测接下来最有可能的一个词是什么。

语言模型是对一种语言的特征进行建模,它有很多很多用处。比如在语音转文本(STT)的应用中,声学模型输出的结果,往往是若干个可能的候选词,这时候就需要语言模型来从这些候选词中选择一个最可能的。当然,它同样也可以用在图像到文本的识别中(OCR)。

使用RNN之前,语言模型主要是采用N-Gram。N可以是一个自然数,比如2或者3。它的含义是,假设一个词出现的概率只与前面N个词相关。我们以2-Gram为例。首先,对前面的一句话进行切词:

我 昨天 上学 迟到 了 ,老师 批评 了 ____。

如果用2-Gram进行建模,那么电脑在预测的时候,只会看到前面的『了』,然后,电脑会在语料库中,搜索『了』后面最可能的一个词。不管最后电脑选的是不是『我』,我们都知道这个模型是不靠谱的,因为『了』前面说了那么一大堆实际上是没有用到的。如果是3-Gram模型呢,会搜索『批评了』后面最可能的词,感觉上比2-Gram靠谱了不少,但还是远远不够的。因为这句话最关键的信息『我』,远在9个词之前!

现在读者可能会想,可以提升继续提升N的值呀,比如4-Gram、5-Gram.......。实际上,这个想法是没有实用性的。因为我们想处理任意长度的句子,N设为多少都不合适;另外,模型的大小和N的关系是指数级的,4-Gram模型就会占用海量的存储空间。

所以,该轮到RNN出场了,RNN理论上可以往前看(往后看)任意多个词。

循环神经网络是啥

循环神经网络种类繁多,我们先从最简单的基本循环神经网络开始吧。

基本循环神经网络

下图是一个简单的循环神经网络如,它由输入层、一个隐藏层和一个输出层组成:


1.jpg


纳尼?!相信第一次看到这个玩意的读者内心和我一样是崩溃的。因为循环神经网络实在是太难画出来了,网上所有大神们都不得不用了这种抽象艺术手法。不过,静下心来仔细看看的话,其实也是很好理解的。如果把上面有W的那个带箭头的圈去掉,它就变成了最普通的全连接神经网络。x是一个向量,它表示输入层的值(这里面没有画出来表示神经元节点的圆圈);s是一个向量,它表示隐藏层的值(这里隐藏层面画了一个节点,你也可以想象这一层其实是多个节点,节点数与向量s的维度相同);U是输入层到隐藏层的权重矩阵;o也是一个向量,它表示输出层的值;V是隐藏层到输出层的权重矩阵。那么,现在我们来看看W是什么。循环神经网络隐藏层的值s不仅仅取决于当前这次的输入x,还取决于上一次隐藏层的值s。权重矩阵 W就是隐藏层上一次的值作为这一次的输入的权重


如果我们把上面的图展开,循环神经网络也可以画成下面这个样子:


2.jpg


2.png


双向循环神经网络

对于语言模型来说,很多时候光看前面的词是不够的,比如下面这句话:

我的手机坏了,我打算____一部新手机。

可以想象,如果我们只看横线前面的词,手机坏了,那么我是打算修一修?换一部新的?还是大哭一场?这些都是无法确定的。但如果我们也看到了横线后面的词是『一部新手机』,那么,横线上的词填『买』的概率就大得多了。

在上一小节中的基本循环神经网络是无法对此进行建模的,因此,我们需要双向循环神经网络,如下图所示:


3.png


4.png


深度循环神经网络

前面我们介绍的循环神经网络只有一个隐藏层,我们当然也可以堆叠两个以上的隐藏层,这样就得到了深度循环神经网络。如下图所示:


5.png


循环神经网络的训练

循环神经网络的训练算法:BPTT

BPTT算法是针对循环层的训练算法,它的基本原理和BP算法是一样的,也包含同样的三个步骤:

  1. 前向计算每个神经元的输出值;

  2. 反向计算每个神经元的误差项值,它是误差函数E对神经元j的加权输入的偏导数;

  3. 计算每个权重的梯度。

最后再用随机梯度下降算法更新权重。

循环层如下图所示:


6.png


RNN的实现

为了加深我们对前面介绍的知识的理解,我们来动手实现一个RNN层。我们复用了上一篇文章零基础入门深度学习(4) - 卷积神经网络中的一些代码,所以先把它们导入进来


import numpy as npfrom cnn 
import ReluActivator, IdentityActivator, element_wise_op

我们用RecurrentLayer类来实现一个循环层。下面的代码是初始化一个循环层,可以在构造函数中设置卷积层的超参数。我们注意到,循环层有两个权重数组,U和W。

  1. class RecurrentLayer(object):

  2.    def __init__(self, input_width, state_width,

  3.                 activator, learning_rate):


  4.        self.input_width = input_width

  5.        self.state_width = state_width

  6.        self.activator = activator

  7.        self.learning_rate = learning_rate

  8.        self.times = 0       # 当前时刻初始化为t0

  9.        self.state_list = [] # 保存各个时刻的state

  10.        self.state_list.append(np.zeros(

  11.            (state_width, 1)))           # 初始化s0

  12.        self.U = np.random.uniform(-1e-4, 1e-4,

  13.            (state_width, input_width))  # 初始化U

  14.        self.W = np.random.uniform(-1e-4, 1e-4,

  15.            (state_width, state_width))  # 初始化W

在forward方法中,实现循环层的前向计算,这部分比较简单。

    def forward(self, input_array):        '''        根据『式2』进行前向计算        '''        self.times += 1        state = (np.dot(self.U, input_array) +                 np.dot(self.W, self.state_list[-1]))        element_wise_op(state, self.activator.forward)        self.state_list.append(state)

在backword方法中,实现BPTT算法。

  1.    def backward(self, sensitivity_array,

  2.                 activator):

  3.        '''

  4.        实现BPTT算法

  5.        '''

  6.        self.calc_delta(sensitivity_array, activator)

  7.        self.calc_gradient()


  8.    def calc_delta(self, sensitivity_array, activator):

  9.        self.delta_list = []  # 用来保存各个时刻的误差项

  10.        for i in range(self.times):

  11.            self.delta_list.append(np.zeros(

  12.                (self.state_width, 1)))

  13.        self.delta_list.append(sensitivity_array)

  14.        # 迭代计算每个时刻的误差项

  15.        for k in range(self.times - 1, 0, -1):

  16.            self.calc_delta_k(k, activator)


  17.    def calc_delta_k(self, k, activator):

  18.        '''

  19.        根据k+1时刻的delta计算k时刻的delta

  20.        '''

  21.        state = self.state_list[k+1].copy()

  22.        element_wise_op(self.state_list[k+1],

  23.                    activator.backward)

  24.        self.delta_list[k] = np.dot(

  25.            np.dot(self.delta_list[k+1].T, self.W),

  26.            np.diag(state[:,0])).T


  27.    def calc_gradient(self):

  28.        self.gradient_list = [] # 保存各个时刻的权重梯度

  29.        for t in range(self.times + 1):

  30.            self.gradient_list.append(np.zeros(

  31.                (self.state_width, self.state_width)))

  32.        for t in range(self.times, 0, -1):

  33.            self.calc_gradient_t(t)

  34.        # 实际的梯度是各个时刻梯度之和

  35.        self.gradient = reduce(

  36.            lambda a, b: a + b, self.gradient_list,

  37.            self.gradient_list[0]) # [0]被初始化为0且没有被修改过


  38.    def calc_gradient_t(self, t):

  39.        '''

  40.        计算每个时刻t权重的梯度

  41.        '''

  42.        gradient = np.dot(self.delta_list[t],

  43.            self.state_list[t-1].T)

  44.        self.gradient_list[t] = gradient

有意思的是,BPTT算法虽然数学推导的过程很麻烦,但是写成代码却并不复杂。

在update方法中,实现梯度下降算法。

    def update(self):        '''        按照梯度下降,更新权重        '''        self.W -= self.learning_rate * self.gradient

上面的代码不包含权重U的更新。这部分实际上和全连接神经网络是一样的,留给感兴趣的读者自己来完成吧。

循环层是一个带状态的层,每次forword都会改变循环层的内部状态,这给梯度检查带来了麻烦。因此,我们需要一个reset_state方法,来重置循环层的内部状态。

    def reset_state(self):        self.times = 0       # 当前时刻初始化为t0        self.state_list = [] # 保存各个时刻的state        self.state_list.append(np.zeros(            (self.state_width, 1)))      # 初始化s0

最后,是梯度检查的代码。

  1. def gradient_check():

  2.    '''

  3.    梯度检查

  4.    '''

  5.    # 设计一个误差函数,取所有节点输出项之和

  6.    error_function = lambda o: o.sum()


  7.    rl = RecurrentLayer(3, 2, IdentityActivator(), 1e-3)


  8.    # 计算forward值

  9.    x, d = data_set()

  10.    rl.forward(x[0])

  11.    rl.forward(x[1])


  12.    # 求取sensitivity map

  13.    sensitivity_array = np.ones(rl.state_list[-1].shape,

  14.                                dtype=np.float64)

  15.    # 计算梯度

  16.    rl.backward(sensitivity_array, IdentityActivator())


  17.    # 检查梯度

  18.    epsilon = 10e-4

  19.    for i in range(rl.W.shape[0]):

  20.        for j in range(rl.W.shape[1]):

  21.            rl.W[i,j] += epsilon

  22.            rl.reset_state()

  23.            rl.forward(x[0])

  24.            rl.forward(x[1])

  25.            err1 = error_function(rl.state_list[-1])

  26.            rl.W[i,j] -= 2*epsilon

  27.            rl.reset_state()

  28.            rl.forward(x[0])

  29.            rl.forward(x[1])

  30.            err2 = error_function(rl.state_list[-1])

  31.            expect_grad = (err1 - err2) / (2 * epsilon)

  32.            rl.W[i,j] += epsilon

  33.            print 'weights(%d,%d): expected - actural %f - %f' % (

  34.                i, j, expect_grad, rl.gradient[i,j])

需要注意,每次计算error之前,都要调用reset_state方法重置循环层的内部状态。下面是梯度检查的结果,没问题!



至此,我们讲完了基本的循环神经网络、它的训练算法:BPTT,以及在语言模型上的应用。RNN比较烧脑,相信拿下前几篇文章的读者们搞定这篇文章也不在话下吧!然而,循环神经网络这个话题并没有完结。我们在前面说到过,基本的循环神经网络存在梯度爆炸和梯度消失问题,并不能真正的处理好长距离的依赖(虽然有一些技巧可以减轻这些问题)。事实上,真正得到广泛的应用的是循环神经网络的一个变体:长短时记忆网络。它内部有一些特殊的结构,可以很好的处理长距离的依赖,我们将在下一篇文章中详细的介绍它。现在,让我们稍事休息,准备挑战更为烧脑的长短时记忆网络