Skip to content
BaiRuic
Go back

动手写NumpyNN RNN

目录
NumpyNN 系列导航

基础理论

在前馈神经网络中,当前时刻的输出只依赖于当前时刻的输入,可以看作是一个复杂的函数,每次的输出只和当前时刻的输入有关系。但在现实问题中,网络的输出不仅和当前的输入有关系,还和过去一段时间的输出有关系。

比如当我们在理解一句话意思时,孤立的理解这句话的每个词不足以理解整体意思,我们通常需要处理这些词连接起来的整个序列; 当我们处理视频的时候,我们也不能只单独的去分析每一帧,而要分析这些帧连接起来的整个序列。为了解决这样类似的问题,能够更好的处理序列的信息,RNN就由此诞生了。

在全连接网络,当前的输出只依赖于当前的输入,参考下图:

全链接网络

本质上,全连接层就是在输入空间和输出空间建立一个映射关系。这就启发我们:想要在两个空间建立映射关系,只需要做一次仿射变换,即经过一次全连接层即可。

那现在我们想要之前时刻的输出和当前时刻的输出也建立联系,是不是只需要在这两者之间增加一层全连接网络呢?答案是肯定的,循环网络就是这么做的,参考下图:

循环神经网络

上图展示了两个时刻的循环神经网络,如果序列很长,则形如下图所示:

循环神经网络具体展示

形式上,一般将上图绘制为如下形式:

循环神经网络形象展示

可见,循环神经网络是由一个个单元串联组成,每个单元包括两个仿射变换,为了增加表达能力,再增加一次非线性变化。单元如下:

NumpyNN - Untitled 5

其中, f(⋅)f(\cdot) 表示 非线性变换,xtx_t 为当前时刻的输入,yt−1y_{t-1}表示上一时刻的输出。

最后,单元 在不同时刻是共享 放射变换的参数的,这也是循环神经网络名字的由来。共享参数的好处包括:

  • 能够使得模型扩展到不同长度的样本并进行泛化
  • 参数共享可以减少模型复杂度
  • 当信息的特定部分在序列内多个位置出现时,共享尤为重要

代码实现

循环神经网络是由一个单元不断循环计算得到输出的,那么一种容易想到的实现方式就是先实现一个单元,然后通过for循环来计算结果。

RNNCell 单元实现

按照上文的描述,每个单元包括两个放射变换 和一个非线性变换,公式如下,因此和全连接层相差不大

h=tanh(Whx@x+Whh@h+b)h = \text{tanh}(W_{hx}@x + W_{hh} @ h + b)
import numpy as np


class RNNCell:
    def __init__(self, input_size, hidden_size, bias, nonlinearity='tanh'):
        """
        parameters:
            input_size: The number of expected features in the input x
            hidden_size: The number of features in the hidden state h
            bias: If False, then the layer does not use bias weights b. Default: True
            nonlinearity: The non-linearity to use. Can be either 'tanh' or 'relu'. Default: 'tanh'
        """
        # init_param 
        self.W_hh = np.random.normal(loc=0, scale=np.sqrt(1/hidden_size))
        self.W_hx = np.random.normal(loc=0, scale=np.sqrt(2/(input_size+hidden_size)))
        self.bias = bias
        if self.bias:
            self.b = np.zeros((hidden_size))
        self.nonlinearity = nonlinearity


    def forward(self, x, prev_h):
        """ parameters:
							x: [batch_size, input_size]
							prev_h: [hidden_size, hidden_size]
						return:
							a: [batch_size, hidden_size]
        """
        a = prev_h @ self.W_hh + x @ self.W_hx
        if self.bias:
            a += self.b
        if self.nonlinearity == "tanh":
            a = np.tanh(a)
        elif self.nonlinearity == "relu":
            a = self.relu(a)
        return a


    def __call__(self, *arg):
        return self.forward(*arg)     

    def backward(self, pre_grad):
        pass

    def relu(self, x):
        mask = x < 0
        x[mask] = 0
        return x

循环神经网络就是循环上述RNNCell,得到每次计算的结果

class RNN:
    def __init__(self, input_size, hidden_size, bias, nonlinearity):
        self.input_size = input_size
        self.hidden_size = hidden_size

        self.cell = RNNCell(self.input_size, self.hidden_size, bias, nonlinearity)
        
    def forward(self, input_, prev_h):
        """
        parameters:
            input_ :[batch_size, seq_len, input_size]
            prev_h: [batch_size, seq_len, hidden_size]
        return :
            outputs: [batch_size, seq_len, hidden_size]
            cur_h: [1, batch_size, hidden_size]
        """
        batch_size, seq_len, input_size = input_.shape

        outputs = np.zeros((batch_size, seq_len, self.hidden_size))

        for i in range(0, seq_len):
            cur_x = input_[:, i, :]
            cur_h = self.cell(cur_x, prev_h)
            
            outputs[:, i, :] = cur_h
            prev_h = cur_h

        return outputs, cur_h.reshape(1, batch_size, self.hidden_size)  

    def __call__(self, *arg):
        return self.forward(*arg)

    def backward(self, pre_grad):
        pass

小结

本文简单介绍了循环网络,且用Numpy实现了循环神经网络的前向传播。

与之相关的长短期记忆网络(LSTM)是为了缓解RNN中梯度消失的问题而提出来的,核心的思路是通过引入门控机制和一个新的外部状态来使得梯度信息传递的更加平滑,进而缓解了梯度消失。