第11课:深度学习之递归神经网络

递归神经网络是啥

因为神经网络的输入层单元个数是固定的,因此必须用循环或者递归的方式来处理长度可变的输入。循环神经网络实现了前者,通过将长度不定的输入分割为等长度的小块,然后再依次的输入到网络中,从而实现了神经网络对变长输入的处理。一个典型的例子是,当我们处理一句话的时候,我们可以把一句话看作是词组成的序列,然后,每次向循环神经网络输入一个词,如此循环直至整句话输入完毕,循环神经网络将产生对应的输出。如此,我们就能处理任意长度的句子了。入下图所示:


1.png


然而,有时候把句子看做是词的序列是不够的,比如下面这句话『两个外语学院的学生』:


2.png


上图显示了这句话的两个不同的语法解析树。可以看出来这句话有歧义,不同的语法解析树则对应了不同的意思。一个是『两个外语学院的/学生』,也就是学生可能有许多,但他们来自于两所外语学校;另一个是『两个/外语学院的学生』,也就是只有两个学生,他们是外语学院的。为了能够让模型区分出两个不同的意思,我们的模型必须能够按照树结构去处理信息,而不是序列,这就是递归神经网络的作用。当面对按照树/图结构处理信息更有效的任务时,递归神经网络通常都会获得不错的结果。

递归神经网络可以把一个树/图结构信息编码为一个向量,也就是把信息映射到一个语义向量空间中。这个语义向量空间满足某类性质,比如语义相似的向量距离更近。也就是说,如果两句话(尽管内容不同)它的意思是相似的,那么把它们分别编码后的两个向量的距离也相近;反之,如果两句话的意思截然不同,那么编码后向量的距离则很远。如下图所示:


3.png


从上图我们可以看到,递归神经网络将所有的词、句都映射到一个2维向量空间中。句子『the country of my birth』和句子『the place where I was born』的意思是非常接近的,所以表示它们的两个向量在向量空间中的距离很近。另外两个词『Germany』和『France』因为表示的都是地点,它们的向量与上面两句话的向量的距离,就比另外两个表示时间的词『Monday』和『Tuesday』的向量的距离近得多。这样,通过向量的距离,就得到了一种语义的表示。

上图还显示了自然语言可组合的性质:词可以组成句、句可以组成段落、段落可以组成篇章,而更高层的语义取决于底层的语义以及它们的组合方式。递归神经网络是一种表示学习,它可以将词、句、段、篇按照他们的语义映射到同一个向量空间中,也就是把可组合(树/图结构)的信息表示为一个个有意义的向量。比如上面这个例子,递归神经网络把句子"the country of my birth"表示为二维向量[1,5]。有了这个『编码器』之后,我们就可以以这些有意义的向量为基础去完成更高级的任务(比如情感分析等)。如下图所示,递归神经网络在做情感分析时,可以比较好的处理否定句,这是胜过其他一些模型的:


4.png


在上图中,蓝色表示正面评价,红色表示负面评价。每个节点是一个向量,这个向量表达了以它为根的子树的情感评价。比如"intelligent humor"是正面评价,而"care about cleverness wit or any other kind of intelligent humor"是中性评价。我们可以看到,模型能够正确的处理doesn't的含义,将正面评价转变为负面评价。

尽管递归神经网络具有更为强大的表示能力,但是在实际应用中并不太流行。其中一个主要原因是,递归神经网络的输入是树/图结构,而这种结构需要花费很多人工去标注。想象一下,如果我们用循环神经网络处理句子,那么我们可以直接把句子作为输入。然而,如果我们用递归神经网络处理句子,我们就必须把每个句子标注为语法解析树的形式,这无疑要花费非常大的精力。很多时候,相对于递归神经网络能够带来的性能提升,这个投入是不太划算的。

我们已经基本了解了递归神经网络是做什么用的,接下来,我们将探讨它的算法细节


递归神经网络的前向计算


接下来,我们详细介绍一下递归神经网络是如何处理树/图结构的信息的。在这里,我们以处理树型信息为例进行介绍。

递归神经网络的输入是两个子节点(也可以是多个),输出就是将这两个子节点编码后产生的父节点,父节点的维度和每个子节点是相同的。如下图所示:


5.png


6.png


7.png


递归神经网络的实现


现在,我们实现一个处理树型结构的递归神经网络

在文件的开头,加入如下代码:


  1. #!/usr/bin/env python

  2. # -*- coding: UTF-8 -*-

  3. import numpy as np

  4. from cnn import IdentityActivator


上述四行代码非常简单,没有什么需要解释的。IdentityActivator激活函数是在我们介绍卷积神经网络时写的,现在引用一下它。

我们首先定义一个树节点结构,这样,我们就可以用它保存卷积神经网络生成的整棵树:

接下来,我们把递归神经网络的实现代码都放在RecursiveLayer类中,下面是这个类的构造函数:


# 递归神经网络实现class RecursiveLayer(object):    def __init__(self, node_width, child_count,                  activator, learning_rate):        '''        递归神经网络构造函数        node_width: 表示每个节点的向量的维度        child_count: 每个父节点有几个子节点        activator: 激活函数对象        learning_rate: 梯度下降算法学习率        '''        self.node_width = node_width        self.child_count = child_count        self.activator = activator        self.learning_rate = learning_rate        # 权重数组W        self.W = np.random.uniform(-1e-4, 1e-4,            (node_width, node_width * child_count))        # 偏置项b        self.b = np.zeros((node_width, 1))        # 递归神经网络生成的树的根节点        self.root = None


下面是前向计算的实现:


    def forward(self, *children):        '''        前向计算        '''        children_data = self.concatenate(children)        parent_data = self.activator.forward(            np.dot(self.W, children_data) + self.b        )        self.root = TreeNode(parent_data, children                            , children_data)


forward函数接收一系列的树节点对象作为输入,然后,递归神经网络将这些树节点作为子节点,并计算它们的父节点。最后,将计算的父节点保存在self.root变量中。

上面用到的concatenate函数,是将各个子节点中的数据拼接成一个长向量,其代码如下:


    def concatenate(self, tree_nodes):        '''        将各个树节点中的数据拼接成一个长向量        '''        concat = np.zeros((0,1))        for node in tree_nodes:            concat = np.concatenate((concat, node.data))        return concat


下面是反向传播算法BPTS的实现:


  1.    def backward(self, parent_delta):

  2.        '''

  3.        BPTS反向传播算法

  4.        '''

  5.        self.calc_delta(parent_delta, self.root)

  6.        self.W_grad, self.b_grad = self.calc_gradient(self.root)


  7.    def calc_delta(self, parent_delta, parent):

  8.        '''

  9.        计算每个节点的delta

  10.        '''

  11.        parent.delta = parent_delta

  12.        if parent.children:

  13.            # 根据式2计算每个子节点的delta

  14.            children_delta = np.dot(self.W.T, parent_delta) * (

  15.                self.activator.backward(parent.children_data)

  16.            )

  17.            # slices = [(子节点编号,子节点delta起始位置,子节点delta结束位置)]

  18.            slices = [(i, i * self.node_width,

  19.                        (i + 1) * self.node_width)

  20.                        for i in range(self.child_count)]

  21.            # 针对每个子节点,递归调用calc_delta函数

  22.            for s in slices:

  23.                self.calc_delta(children_delta[s[1]:s[2]],

  24.                                parent.children[s[0]])


  25.    def calc_gradient(self, parent):

  26.        '''

  27.        计算每个节点权重的梯度,并将它们求和,得到最终的梯度

  28.        '''

  29.        W_grad = np.zeros((self.node_width,

  30.                            self.node_width * self.child_count))

  31.        b_grad = np.zeros((self.node_width, 1))

  32.        if not parent.children:

  33.            return W_grad, b_grad

  34.        parent.W_grad = np.dot(parent.delta, parent.children_data.T)

  35.        parent.b_grad = parent.delta

  36.        W_grad += parent.W_grad

  37.        b_grad += parent.b_grad

  38.        for child in parent.children:

  39.            W, b = self.calc_gradient(child)

  40.            W_grad += W

  41.            b_grad += b

  42.        return W_grad, b_grad


在上述算法中,calc_delta函数和calc_gradient函数分别计算各个节点的误差项以及最终的梯度。它们都采用递归算法,先序遍历整个树,并逐一完成每个节点的计算。


下面是梯度下降算法的实现(没有weight decay),这个非常简单:


    def update(self):        '''        使用SGD算法更新权重        '''        self.W -= self.learning_rate * self.W_grad        self.b -= self.learning_rate * self.b_grad


以上就是递归神经网络的实现,总共100行左右,和上一篇文章的LSTM相比简单多了。

最后,我们用梯度检查来验证程序的正确性:

  1. def gradient_check():

  2.    '''

  3.    梯度检查

  4.    '''

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

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


  7.    rnn = RecursiveLayer(2, 2, IdentityActivator(), 1e-3)


  8.    # 计算forward值

  9.    x, d = data_set()

  10.    rnn.forward(x[0], x[1])

  11.    rnn.forward(rnn.root, x[2])


  12.    # 求取sensitivity map

  13.    sensitivity_array = np.ones((rnn.node_width, 1),

  14.                                dtype=np.float64)

  15.    # 计算梯度

  16.    rnn.backward(sensitivity_array)


  17.    # 检查梯度

  18.    epsilon = 10e-4

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

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

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

  22.            rnn.reset_state()

  23.            rnn.forward(x[0], x[1])

  24.            rnn.forward(rnn.root, x[2])

  25.            err1 = error_function(rnn.root.data)

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

  27.            rnn.reset_state()

  28.            rnn.forward(x[0], x[1])

  29.            rnn.forward(rnn.root, x[2])

  30.            err2 = error_function(rnn.root.data)

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

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

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

  34.                i, j, expect_grad, rnn.W_grad[i,j])

  35.    return rnn


递归神经网络的应用


自然语言和自然场景解析


在自然语言处理任务中,如果我们能够实现一个解析器,将自然语言解析为语法树,那么毫无疑问,这将大大提升我们对自然语言的处理能力。解析器如下所示:



可以看出,递归神经网络能够完成句子的语法分析,并产生一个语法解析树。

除了自然语言之外,自然场景也具有可组合的性质。因此,我们可以用类似的模型完成自然场景的解析,如下图所示:



两种不同的场景,可以用相同的递归神经网络模型来实现。我们以第一个场景,自然语言解析为例。

我们希望将一句话逐字输入到神经网络中,然后,神经网络返回一个解析好的树。为了做到这一点,我们需要给神经网络再加上一层,负责打分。分数越高,说明两个子节点结合更加紧密,分数越低,说明两个子节点结合更松散。如下图所示:



一旦这个打分函数训练好了(也就是矩阵U的各项值变为合适的值),我们就可以利用贪心算法来实现句子的解析。第一步,我们先将词按照顺序两两输入神经网络,得到第一组打分:



我们发现,现在分数最高的是第一组,The cat,说明它们的结合是最紧密的。这样,我们可以先将它们组合为一个节点。然后,再次两两计算相邻子节点的打分:



现在,分数最高的是最后一组,the mat。于是,我们将它们组合为一个节点,再两两计算相邻节点的打分:



这时,我们发现最高的分数是on the mat,把它们组合为一个节点,继续两两计算相邻节点的打分......最终,我们就能够得到整个解析树:



现在,我们困惑这样牛逼的打分函数score是怎样训练出来的呢?我们需要定义一个目标函数。这里,我们使用Max-Margin目标函数。