一、概述
1.1 循环神经网络 (RNN)
循环神经网络(Recurrent Neural Networks, RNN) 是一类具有短期记忆能力的神经网络,其特点是在处理序列数据时,能够记录历史信息。RNN 已广泛地用于序列相关的任务上,例如语言模型、自然语言生成、机器翻译、文本分类、序列标注、语音识别、时间序列分析等。
RNN 可以被理解为一种迭代地利用当前时刻信息及历史信息来更新短期记忆的网络。通过数据的循环,RNN 能一边记住历史的信息,一边更新到最新的信息。
RNN 通常按时间展开其计算图以便于理解其计算过程。具体地,RNN 是一个非常简单的循环神经网络,它只有一个隐藏层。RNN 在时刻 $t$ 的更新公式如下所示:
$$\mathbf{h}t = \tanh \left( \mathbf{h}{t-1}\mathbf{W}_{\text{h}} + \mathbf{x}t \mathbf{W}{\text{x}} + \mathbf{b} \right)$$
其中,向量 $\mathbf{h}_t \in \mathbb{R}^{1 \times d}$ 为 RNN 的隐藏状态,而 $\mathbf{x}t \in \mathbb{R}^{1 \times m}$ 是网络在时刻 $t$ 的输入,则 $\mathbf{h}t$ 不仅和当前时刻的输入 $\mathbf{x}t$ 相关,也和上一个时刻的隐藏状态 $\mathbf{h}{t-1}$ 相关。$\mathbf{W}{\text{h}} \in \mathbb{R}^{d \times d}$ 和 $\mathbf{W}{\text{x}} \in \mathbb{R}^{m \times d}$ 分别表示状态 - 状态权重矩阵和状态 - 输入权重矩阵。RNN 可被进一步表示为 $\mathbf{h}t = \tanh \left( \mathbf{z}{t} \right)$,其中 $\mathbf{z}t$ 为 RNN 在时刻 $t$ 的净输入,有 $\mathbf{z}t = \mathbf{h}{t-1}\mathbf{W}{\text{h}} + \mathbf{x}t \mathbf{W}{\text{x}} +\mathbf{b}$。注意,本文为了便于编程实现,将向量均表示为行向量。
理论上,一个完全连接的 RNN 是任何非线性动力系统的近似器。RNN 可以处理任意长的序列。但是在实际中,简单的 RNN 通常会面临梯度消失和梯度爆炸问题,使其不能建模好序列的长期依赖关系。
1.2 RNN 的应用
RNN 可以应用到很多不同类型的机器学习任务。根据这些任务的特点,其主要可被分为:序列到类别模式、序列到序列模式。
1.2.1 序列到类别
序列到类别模式主要用于输入为序列、输出为类别的序列数据的分类问题,例如时间序列分类、文本分类。比如在文本分类中,输入数据为单词序列,输出为该文本的类别。
假设一个实例 $\mathbf{X} = [\mathbf{x}{1} , \mathbf{x}{2}, \ldots,\mathbf{x}{T}] \in \mathbb{R}^{T \times m}$ 为一个长度为 $T$ 的序列,输出为一个类别 $\mathcal{Y} = {1, \ldots, C}$。可将实例 $\mathbf{X}$ 按不同时刻输入到 RNN 中,并得到不同时刻的隐藏状态 $\mathbf{h}{1} , \mathbf{h}{2}, \ldots,\mathbf{h}{T}$。
这时,可选择将最后时刻的状态 $\mathbf{h}_{T}$ 作为整个序列的表示,也可对所有的隐藏状态进行汇聚,把这个汇聚后的状态 $\mathbf{s} \in \mathbb{R}^{1 \times d}$ 作为整个序列的最终表示。
具体地,有 $\mathbf{s} = \operatorname{Pooling} \big( \mathbf{h}{1} , \mathbf{h}{2}, \ldots,\mathbf{h}_{T}\big)$。其中,汇聚操作可使用平均或注意力机制等操作。然后,可将序列的最终表示 $\mathbf{s}$ 送入线性层或多层前馈神经网络 FFN 中用于分类。
当然,也可将其用于回归任务中,例如单步时间序列预测。此外,这一架构稍加改造,便可以用于语言模型或者序列化推荐中(通常是自回归的形式,即将历史的序列信息作为输入信息,来预测序列的下一时间步的状态或标签。)
1.2.2 序列到序列
序列到序列(Sequence-to-Sequence)可分为同步的序列到序列和异步的序列到序列。
同步的序列到序列模式 主要用于序列标注(Sequence Labeling)任务,即每一时刻都有输入和输出,输入序列和输出序列的长度相同。其具体应用有,视频分类、词性标注等。比如,在词性标注(Part-of-Speech Tagging)中,每一个单词都需要标注其对应的词性标签。
异步的序列到序列模式也称为编码器 - 解码器(Encoder-Decoder)模型,即输入序列和输出序列不需要有严格的对应关系,也不需要保持相同的长度。其具体应用有,文本摘要、图像理解(Image Caption)、问答系统(Question Answering system)、多步时间序列预测、机器翻译等。比如在机器翻译中,输入为源语言的单词序列,输出为目标语言的单词序列。
1.3 模型学习
RNN 的参数可以通过梯度下降方法,例如 SGD,来进行学习。以同步的序列到序列模式为例,给定一个训练样本 $(\mathbf{X}, \mathbf{y})$,其中 $\mathbf{y} \in \mathbb{R}^{1 \times T}$ 是长度为 $T$ 的标签序列。
RNN 的参数关于某一时间步 $t$ 的损失 $L_t$(交叉熵损失),那么整个序列的损失函数为 $L=\sum_{i=1}^{T} L_t$ 。整个序列的损失函数 $L$ 关于参数的梯度为:$\frac{\partial L} {\partial \mathbf{W}h}=\sum{t=1}^{T} \frac{\partial L_t} {\partial \mathbf{W}_h}$ 即每个时刻损失 $L_t$ 对参数 $\mathbf{W}_h$ 的偏导数之和。由于,RNN 存在循环的计算过程,其计算梯度的方式和 FFN 不同。通常,需展开其计算图,然后按时间来进行梯度反向传播(Backpropagation Through Time,BPTT)。
1.3.1 按时间反向传播 BPTT
BPTT 算法将 RNN 看作一个展开的多层 FFN,其中'每一层'对应 RNN 中的每个时间步,此时 RNN 可视为在水平方向上延伸的神经网络(相当于 T 层参数共享的 FFN),它可按 FFN 中的反向传播算法计算参数的梯度。
如上图所示,将循环展开后的 RNN 可使用反向传播。换句话说,可通过先进行正向传播,再进行反向传播的方式求目标梯度(直观地,只需要从损失开始,将其梯度不断反向传播回去,已求解各个参数的梯度)。
在'展开'的 RNN 中所有层的参数是共享的,因此参数的真实梯度是所有展开层(时间步)的参数的梯度之和。
以时间步 t 时的损失为例,其参数的梯度是参数在所有的****时间步的梯度之和**。
1.3.2 截断 BPTT
尽管通过 BPTT,RNN 的学习似乎可以进行,但在这之前还有一个须解决的问题,那就是学习长时序数据的问题。因为随着时序数据的时间跨度的增大,BPTT 消耗的计算机资源也会成比例地增大。另外,反向传播的梯度也会变得不稳定。
二、RNN 实现
2.1 简单 RNN
这里以 RNN 用于语言模型为例(RNNLM),其试图建模马尔科夫链对应的条件概率分布。
基础模块
import numpy as np
class Embedding:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.idx = None
def forward(self, idx):
W, = self.params
self.idx = idx
out = W[idx]
return out
def backward(self, dout):
dW, = self.grads
dW[...] = 0
if 'GPU' in globals():
np.scatter_add(dW, self.idx, dout)
else:
np.add.at(dW, self.idx, dout)
return None
class TimeEmbedding:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.layers = None
self.W = W
def forward(self, xs):
N, T = xs.shape
V, D = self.W.shape
out = np.empty((N, T, D), dtype='f')
self.layers = []
for t (T):
layer = Embedding(.W)
out[:, t, :] = layer.forward(xs[:, t])
.layers.append(layer)
out
():
N, T, D = dout.shape
grad =
t (T):
layer = .layers[t]
layer.backward(dout[:, t, :])
grad += layer.grads[]
.grads[][...] = grad
:
():
.params = [W, b]
.grads = [np.zeros_like(W), np.zeros_like(b)]
.x =
():
N, T, D = x.shape
W, b = .params
rx = x.reshape(N*T, -)
out = np.dot(rx, W) + b
.x = x
out.reshape(N, T, -)
():
x = .x
N, T, D = x.shape
W, b = .params
dout = dout.reshape(N*T, -)
rx = x.reshape(N*T, -)
db = np.(dout, axis=)
dW = np.dot(rx.T, dout)
dx = np.dot(dout, W.T)
dx = dx.reshape(*x.shape)
.grads[][...] = dW
.grads[][...] = db
dx
:
():
.params, .grads = [], []
.cache =
.ignore_label = -
():
N, T, V = xs.shape
ts.ndim == :
ts = ts.argmax(axis=)
mask = (ts != .ignore_label)
xs = xs.reshape(N * T, V)
ts = ts.reshape(N * T)
mask = mask.reshape(N * T)
ys = softmax(xs)
ls = np.log(ys[np.arange(N * T), ts])
ls *= mask
loss = -np.(ls)
loss /= mask.()
.cache = (ts, ys, mask, (N, T, V))
loss
():
ts, ys, mask, (N, T, V) = .cache
dx = ys
dx[np.arange(N * T), ts] -=
dx *= dout
dx /= mask.()
dx *= mask[:, np.newaxis]
dx = dx.reshape((N, T, V))
dx
RNN
class RNN:
def __init__(self, Wx, Wh, b):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.cache = None
def forward(self, x, h_prev):
Wx, Wh, b = self.params
t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b
h_next = np.tanh(t)
self.cache = (x, h_prev, h_next)
return h_next
def backward(self, dh_next):
Wx, Wh, b = self.params
x, h_prev, h_next = self.cache
dt = dh_next * (1 - h_next ** 2)
db = np.sum(dt, axis=0)
dWh = np.dot(h_prev.T, dt)
dh_prev = np.dot(dt, Wh.T)
dWx = np.dot(x.T, dt)
dx = np.dot(dt, Wx.T)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
return dx, dh_prev
class TimeRNN:
def __init__(self, Wx, Wh, b, stateful=False):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.layers = None
self.h, self.dh = None,
.stateful = stateful
():
Wx, Wh, b = .params
N, T, D = xs.shape
D, H = Wx.shape
.layers = []
hs = np.empty((N, T, H), dtype=)
.stateful .h :
.h = np.zeros((N, H), dtype=)
t (T):
layer = RNN(*.params)
.h = layer.forward(xs[:, t, :], .h)
hs[:, t, :] = .h
.layers.append(layer)
hs
():
Wx, Wh, b = .params
N, T, H = dhs.shape
D, H = Wx.shape
dxs = np.empty((N, T, D), dtype=)
dh =
grads = [, , ]
t ((T)):
layer = .layers[t]
dx, dh = layer.backward(dhs[:, t, :] + dh)
dxs[:, t, :] = dx
i, grad (layer.grads):
grads[i] += grad
i, grad (grads):
.grads[i][...] = grad
.dh = dh
dxs
():
.h = h
():
.h =
基于 RNN 搭建语言模型
class SimpleRnnlm:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
rnn_Wx = (rn(D, H) / np.sqrt(D)).astype('f')
rnn_Wh = (rn(H, H) / np.sqrt(H)).astype('f')
rnn_b = np.zeros(H).astype('f')
affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
self.layers = [
TimeEmbedding(embed_W),
TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True),
TimeAffine(affine_W, affine_b)
]
self.loss_layer = TimeSoftmaxWithLoss()
self.rnn_layer = self.layers[1]
self.params, self.grads = [], []
for layer in self.layers:
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, ts):
for layer in self.layers:
xs = layer.forward(xs)
loss = self.loss_layer.forward(xs, ts)
return loss
def backward(self, dout=1):
dout = self.loss_layer.backward(dout)
for layer (.layers):
dout = layer.backward(dout)
dout
():
.rnn_layer.reset_state()
三、RNN 存在的问题及其原因
RNN 的特点在于使用了上一时刻的隐藏状态,由此,RNN 可以继承过去的信息。
RNN 单元的计算图如上所示。RNN 层的正向传播进行的计算由矩阵乘积、矩阵加法和基于激活函数 tanh 的变换构成。
3.1 梯度消失和梯度爆炸
语言模型的任务是根据已经出现的单词预测下一个将要出现的单词。借着探讨 RNNLM 问题的机会,我们再来考虑一下上述任务。
如前所述,填入'?'中的单词应该是 Tom。要正确回答这个问题,RNNLM 需要记住'Tom 在房间看电视,Mary 进了房间'这些信息。这些信息必须被编码并保存在 RNN 层的隐藏状态中。
站在 RNNLM 进行学习的角度来考虑上述问题。在正确解标签为 Tom 时,RNNLM 中的梯度是如何传播的呢?这里我们使用 BPTT 进行学习,因此梯度将从正确解标签 Tom 出现的地方向过去的方向传播。
**RNN 层通过向过去传递'有意义的梯度',能够学习时间方向上的依赖关系。**此时梯度(理论上)包含了那些应该学到的有意义的信息。
通过将这些信息向过去传递,RNN 层学习长期的依赖关系。但是,如果这个梯度在中途变弱(甚至没有包含任何信息),则权重参数将不会被更新。也就是说,RNN 层无法学习长期的依赖关系(当前时刻参数的梯度只由局部时间步的回传的梯度决定,长范围的梯度信号很微弱)。
换句话说,随着梯度的回传,距离当前时间步损失越远的输入或隐状态对参数的梯度的贡献越小(梯度信号弱),它对模型学习的影响也微弱。
相反,如果梯度过大,也难以正常学习。
3.2 梯度消失和梯度爆炸的原因
如上图所示,这里考虑长度为 $T$ 的时序数据,关注从第 $T$ 个正确解标签传递出的梯度如何变化。就上面的问题来说,这相当于第 $T$ 个正确解标签是 Tom 的情形。
此时,关注时间方向上的梯度,可知反向传播的梯度流经 tanh、'+'和 MatMul(矩阵乘积)运算。'+'的反向传播将上游传来的梯度原样传给下游,因此梯度的值不变。
先来看一下 tanh。此时,将 $y = \tanh(x)$ 的值(实线)及其导数的值(虚线)分别画在图上。从上图可以看出,它的值小于 1.0,并且随着 x 远离 0,它的值在变小。这意味着,当反向传播的梯度经过 tanh 节点时,它的值会越来越小。因此,如果经过 tanh 函数 $T$ 次,则梯度也会减小 $T$ 次。
接下来,关注图中的 MatMul(矩阵乘积)节点。简单起见,这里省略 tanh 节点。如此一来,RNN 层的反向传播的梯度就仅取决于 MatMul 运算。在上图中,假定从上游传来梯度 $dh$,此时 MatMul 节点的反向传播通过矩阵乘积 $dh \mathbf{W}_h^{T}$ 计算梯度。之后,根据时序数据的时间步长,将这个计算重复相应次数。
需注意的是,每一次矩阵乘积计算都使用相同的权重 $\mathbf{W}_h$。那么,反向传播时梯度的值通过 MatMul 节点时会呈指数级增加,或者呈指数级减小。因为矩阵 $\mathbf{W}_h$ 被反复乘了 $T$ 次。
如果 $\mathbf{W}_h$ 是标量,则问题将很简单:当 $\mathbf{W}_h$ 大于 1 时,梯度呈指数级增加;当 $\mathbf{W}_h$ 小于 1 时,梯度呈指数级减小。那么,如果 $\mathbf{W}_h$ 不是标量,而是矩阵呢?此时,矩阵的谱半径将成为指标。简单而言,矩阵的谱半径(最大的特征值,表明矩阵是否收敛)表示数据的离散程度。根据这个谱半径是否大于 1,可以预测梯度大小的变化(这是必要条件,并非充分条件)。
四、LSTM
在面临梯度消失或梯度爆炸时(梯度爆炸可以通过梯度裁剪,主要还是梯度消失问题),RNN 难以学习序列的长期依赖关系。为此,长短期记忆网络 LSTM 引入了门控机制来缓解梯度消失问题。
LSTM 提出了用门控网络来控制信息的流动,以缓解 RNN 在处理长序列时会面临梯度消失。LSTM 引入了基于门控机制的输入门、输出门和遗忘门,且相比 RNN 多了一个外部记忆单元 Cell Memory。其中记忆单元的计算采用了哈达玛 (Hadamard) 积。
可通过观察记忆单元 Cell Memory 的反向传播来了解 LSTM 缓解梯度消失的原因。
哈达玛积 $\otimes$ 使 LSTM 的反向传播计算过程中的不仅是矩阵乘积计算,而跟多涉及对应元素乘积(Element-wise Product)(偏导数为恒等矩阵),而且每次都会基于不同的门值进行对应元素的乘积计算。这就是它不会发生梯度消失(或梯度爆炸)的原因。
节点的计算由遗忘门控制(每次输出不同的门控值):
- 遗忘门认为应该忘记的记忆单元的元素,其梯度会变小;
- 遗忘门认为不能忘记的元素,其梯度在向过去的方向流动时不会退化;
因此,可以期待记忆单元的梯度(应该长期记住的信息)能在不发生梯度消失的情况下传播。
总结,LSTM 通过基于哈达玛积的门控机制的记忆单元,构建了一条更有效的梯度回传通道,从而能缓解梯度消失。
4.1 LSTM 代码实现
为了完整展示 LSTM 的实现,以下提供基于 Numpy 的 LSTM 核心类代码,结构与 RNN 保持一致。
def sigmoid(x):
return 1 / (1 + np.exp(-x))
class LSTMCell:
def __init__(self, Wx, Wh, b):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.cache = None
def forward(self, x, c_prev, h_prev):
Wx, Wh, b = self.params
hx = np.concatenate((x, h_prev), axis=1)
gates = np.dot(hx, Wx) + np.dot(h_prev, Wh) + b
f, i, o, g = np.split(gates, 4, axis=1)
f = sigmoid(f)
i = sigmoid(i)
o = sigmoid(o)
g = np.tanh(g)
c_next = f * c_prev + i * g
h_next = o * np.tanh(c_next)
self.cache = (x, c_prev, h_prev, f, i, o, g, c_next, h_next)
return h_next, c_next
def backward(self, dh_next, dc_next):
Wx, Wh, b = self.params
x, c_prev, h_prev, f, i, o, g, c_next, h_next = self.cache
do = dc_next * np.tanh(c_next) + dh_next
dc = dc_next * o * (1 - np.tanh(c_next)**2) + dh_next * o * (1 - np.tanh(c_next)**2)
df = dc * c_prev
di = dc * g
dg = dc * i
df *= f * ( - f)
di *= i * ( - i)
do *= o * ( - o)
dg *= ( - g**)
dgates = np.concatenate((df, di, do, dg), axis=)
hx = np.concatenate((x, h_prev), axis=)
dWx = np.dot(hx.T, dgates)
dWh = np.dot(h_prev.T, dgates)
db = np.(dgates, axis=)
dhx = np.dot(dgates, Wx.T)
dh_prev = np.dot(dgates, Wh.T)
dx = dhx[:, :x.shape[]]
.grads[][...] = dWx
.grads[][...] = dWh
.grads[][...] = db
dx, dh_prev, dc
:
():
.params = [Wx, Wh, b]
.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
.layers =
.c, .h, .dc, .dh = , , ,
.stateful = stateful
():
Wx, Wh, b = .params
N, T, D = xs.shape
D, H = Wx.shape
.layers = []
hs = np.empty((N, T, H), dtype=)
cs = np.empty((N, T, H), dtype=)
.stateful .h :
.h = np.zeros((N, H), dtype=)
.c = np.zeros((N, H), dtype=)
t (T):
layer = LSTMCell(*.params)
.h, .c = layer.forward(xs[:, t, :], .c, .h)
hs[:, t, :] = .h
cs[:, t, :] = .c
.layers.append(layer)
hs
():
Wx, Wh, b = .params
N, T, H = dhs.shape
D, H = Wx.shape
dxs = np.empty((N, T, D), dtype=)
dh =
dc =
grads = [, , ]
t ((T)):
layer = .layers[t]
dx, dh, dc = layer.backward(dhs[:, t, :] + dh, dc)
dxs[:, t, :] = dx
i, grad (layer.grads):
grads[i] += grad
i, grad (grads):
.grads[i][...] = grad
.dh = dh
.dc = dc
dxs
():
.h = h
.c = c
():
.h =
.c =
五、总结
本文详细介绍了基于 Numpy 实现的循环神经网络(RNN)及其改进模型长短期记忆网络(LSTM)。内容涵盖了 RNN 的基本结构、前向与反向传播公式、按时间反向传播(BPTT)算法原理,以及梯度消失和梯度爆炸问题的成因分析。文章提供了完整的 Python 代码示例,包括嵌入层、时间全连接层、Softmax 损失函数以及 RNN 和 LSTM 的核心模块实现,展示了如何构建简单的语言模型。
虽然 LSTM 有效缓解了梯度消失问题,但其计算复杂度高于 GRU 等变体。在实际工程中,PyTorch 或 TensorFlow 等框架提供了高度优化的实现。理解底层 Numpy 实现有助于深入掌握深度学习框架的运作机制,为后续优化模型性能打下坚实基础。