[npnet] 循环神经网络
循环神经网络 (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\),
这里的 \(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}\) 的偏导数公式是最容易的:
下一个关键点是 \(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}}\)。我们有
这里的 \(\odot\) 指的是向量或者矩阵按位乘法运算。如果我们令 \(H_{t} = \frac{\partial L}{\partial h_{t}} \odot \left(1 - h_{t} \odot h_{t}\right)\),那么上述公式可以简化成
有了 \(H_t\),我们现在可以给出剩下参数的偏导数
最后我们还需要输入 \(x_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 模型是能“学习”到数据的结构性。