循环神经网络 (RNN, Recurrent Neural Network) 相对于之前的线性模型 (Linear Model) 或者卷积神经网络 (Convolution Neural Network) 能更好地处理具有顺序性的数据。比如说温度,每一个时间点的温度都跟之前若干个时间点的温度有联系。更重要的是,某个地方的温度在一天或者一年的时间内有一定的周期性。又比如说语言,一些单独的字或词需要有序地组织起来才能表达出清晰的意思。这样的数据用 RNN 能更好的归纳出规律。我们在这篇笔记中讨论简单 RNN 的理论以及实现。本文的主要参考文章:The Unreasonable Effectiveness of Recurrent Neural Networks

import numpy as np
import matplotlib.pyplot as plt

%run import_npnet.py
npnet = import_npnet(7)

RNN

RNN 的主要想法是在模型的内部存储一个隐藏的状态来提取之前所有输入的特征。这个隐藏的状态与当前的输入共同决定输出,并根据当前的输入变更。假设在时间点 \(t\) 的输入是 \(x_t\),隐藏状态是 \(h_t\), 输出是 \(y_t\)。这些 \(x_t, y_t\)\(h_t\) 都是横向量。初始时,\(h_0=0\) 为零向量。那么对于 \(t \ge 1\)

$$\begin{align*} h_{t} &= \tanh \left( x_t W_{xh} + h_{t-1}W_{hh} + b_h \right) \\ y_{t} &= h_{t} W_{hy} + b_y. \end{align*}$$


这里的 \(W_{xh}, W_{hh}, W_{hy}, b_h\)\(b_y\) 是模型的参数。

假设最终的损失 (loss) 是 \(L\),而且我们知道 \(\frac{\partial L}{\partial y_t}\) (更准确的数学符号应该是 \(\nabla_{y_t} L\),这里我用 \(\frac{\partial L}{\partial y_t}\) 表示应该不会引起误会),我们想算出 \(t\) 时刻所有参数关于 \(L\) 的偏导数以进行向后传播 (backpropagation)。\(b_y\)\(W_{hy}\) 的偏导数公式是最容易的:

$$\begin{align*} \frac{\partial L}{\partial b_{y}} &= \frac{\partial L}{\partial y_t}, \\ \frac{\partial L}{\partial W_{hy}} &= h_t^T \cdot \frac{\partial L}{\partial y_t}. \end{align*}$$

下一个关键点是 \(h_t\) 的偏导数。我们发现 \(h_t\) 不仅出现在 \(y_t\) 中,还出现 \(h_{t+1}\) 中。而 \(L\)\(y_t\)\(h_{t+1}\) 都相关,所以我们不仅需要 \(\frac{\partial L}{\partial y_t}\),还需要 \(\frac{\partial L}{\partial h_{t+1}}\)。我们先假设现在已经知道 \(\frac{\partial L}{\partial h_{t+1}}\)。我们有

$$\frac{\partial L}{\partial h_t} = \frac{\partial L}{\partial y_t} \cdot W_{hy}^T + \left[\frac{\partial L}{\partial h_{t+1}} \odot \left(1 - h_{t+1} \odot h_{t+1}\right)\right] \cdot W_{hh}^T.$$


这里的 \(\odot\) 指的是向量或者矩阵按位乘法运算。如果我们令 \(H_{t} = \frac{\partial L}{\partial h_{t}} \odot \left(1 - h_{t} \odot h_{t}\right)\),那么上述公式可以简化成

$$\frac{\partial L}{\partial h_t} = \frac{\partial L}{\partial y_t} \cdot W_{hy}^T + H_{t+1} \cdot W_{hh}^T.$$

有了 \(H_t\),我们现在可以给出剩下参数的偏导数

$$\begin{align*} \frac{\partial L}{\partial b_h} &= H_t, \\ \frac{\partial L}{\partial W_{hh}} &= h_{t-1}^T \cdot H_t, \\ \frac{\partial L}{\partial W_{xh}} &= x_t^T \cdot H_t. \\ \end{align*}$$

最后我们还需要输入 \(x_t\) 的偏导数:

$$\frac{\partial L}{\partial x_t} = H_t \cdot W_{xh}^T.$$

在用 numpy 实现的时候我们要特别注意区别向量与矩阵。为了简便,以下的实现中所有行向量都用矩阵来表示:比如长度为 \(n\) 的行向量表示为 \(1 \times n\) 的矩阵。

# import
class RNN(npnet.Model):

    def __init__(self, input_size, hidden_size, output_size):
        super().__init__(input_size=input_size,
                         hidden_size=hidden_size,
                         output_size=output_size)
        params = self.parameters
        n, k, m = input_size, hidden_size, output_size
        params['Wxh'] = np.random.randn(n, k) * np.sqrt(2.0 / (n + k))
        params['Whh'] = np.random.randn(k, k) * np.sqrt(1.0 / k)
        params['Why'] = np.random.randn(k, m) * np.sqrt(2.0 / (k + m))
        params['bh'] = np.random.randn(1, k)
        params['by'] = np.random.randn(1, m)

        # 隐藏状态,因为 h_0 的存在,我们要特别注意指标的使用
        self.hiddens = [np.zeros_like(params['bh'])]

        self._init_gradients()

    def forward(self, input):
        self.input = input
        hs = [self.hiddens[-1]] # 只保留上次计算的最后一个隐藏状态
        params = self.parameters
        output = []
        for x in input:
            h = x @ params['Wxh'] + hs[-1] @ params['Whh'] + params['bh']
            h = np.tanh(h)
            hs.append(h)
            y = h @ params['Why'] + params['by']
            output.append(y[0])
        self.hiddens = hs
        return np.array(output)

    def backward(self, grad):
        params = self.parameters
        pgrad = self.gradients
        x = self.input
        dx = [None] * len(x)
        h = self.hiddens
        H = np.zeros_like(h[0])

        for t in range(len(grad)-1, -1, -1):
            dy = grad[t: t+1]
            pgrad['by'] += dy
            # 注意隐藏状态数组的指标使用
            pgrad['Why'] += h[t+1].T @ dy

            dh = dy @ params['Why'].T + H @ params['Whh'].T
            H = dh * (1 - h[t+1] * h[t+1]) # H_t
            pgrad['bh'] += H
            pgrad['Whh'] += h[t].T @ H
            pgrad['Wxh'] += x[t: t+1].T @ H
            dx[t] = (H @ params['Wxh'].T)[0]

        return np.array(dx)

    def clear_hidden_states(self):
        self.hiddens = [np.zeros_like(self.parameters['bh'])]

我们不能直接用以前的 npnet.GradientCheck 来验证 RNN 的偏导数了,这是因为 RNN 的输出跟隐藏状态相关。因此在做偏导数验证的时候,我们需要在每次计算前清除隐藏状态:

# import
class RNNGradientCheck(npnet.GradientCheck):

    def forward(self, *args, **kwargs):
        self.model.clear_hidden_states()
        return self.model.forward(*args, **kwargs)

现在我们可以验证 RNN 偏导数:

RNNGradientCheck(RNN(10, 20, 10), input_shape=(10, 10)).check()
True

拟合 sin 函数

我们用 RNN 做一个简单的例子:拟合 sin 函数。我们首先用 numpy.sin 生成 [sin 0, sin 1, ..., sin 199]。我们的目标是用 RNN “学习”这些数据的结构。因为这些数据具有顺序性,并且具有周期性,所以 RNN 在这里很合适。

x = np.arange(200).reshape(-1, 1)
y = np.sin(x)

sin = RNN(input_size=1, hidden_size=100, output_size=1)
train_x = y[:-1]
train_y = y[1:]

epoch = 100
learning_rate = 1e-4
optim = npnet.RMSprop(sin, lr=learning_rate)
criterion = npnet.MSELoss()
batch_size = 2
batch_num = len(train_x) // batch_size
if len(train_x) % batch_size != 0:
    batch_num += 1

losses = []
for ep in range(epoch):
    loss = 0
    for i in range(batch_num):
        b = i * batch_size
        e = b + batch_size
        inp = train_x[b: e]
        tar = train_y[b: e]
        out = sin.forward(inp)
        loss += criterion.forward(out, tar)
        grad = criterion.backward()
        optim.zero_grad()
        sin.backward(grad)
        optim.step()
    losses.append(loss)

plt.plot(np.arange(len(losses)), losses)
plt.title('Loss vs Epoch')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.show()

我们检测一下用 RNN 得到的 sin 模型。

我们可以从另一个角度去看这个模型的好坏:画出 (准确值,预测值) 的点图,所有点越靠近直线 \(y=x\) 模型就越好。

plt.scatter(test_y, pred_y)
plt.plot(np.arange(-1.2, 1.2, 0.1), np.arange(-1.2, 1.2, 0.1), color='r')
plt.xlabel('true sin values')
plt.ylabel('predicted sin values')
plt.show()

这个 sin 模型有点不稳定,有时候拟合得很准确,有时候又相差很大。但是总起来看,sin 模型是能“学习”到数据的结构性。