这篇博文介绍了循环神经网络 (RNN) 如何通过引入 " 记忆 “(隐藏状态)和参数共享来处理序列数据,并探讨了其面临的梯度消失/爆炸问题,最后引出了 LSTM 和 GRU 作为更优的解决方案。
对句子编码
当我们想把一个句子或者一段文字输入模型,第一个需要处理的就是编码。给句子编码有几种候选方法 1。
首先,one hot vector,每个词一个位置, 这就要为所有的词统计和编码,这假设词汇之间无关,并不能包含任何语义信息。
另一种方法是子词嵌入 - Subword Embedding,例如使用 BPE (Byte Pair Encoding) 算法。它不仅远胜于无法表达语义的 one-hot 编码,也比传统的词嵌入(Word Embedding)更能巧妙地处理未登录词(OOV)。例如,对于词库里没有的 “unhappiness”,BPE 可能将其拆解为 “un” 和 “happiness”,从而利用已知的词根推断其含义。
RNN
RNN 作为一种特殊的神经网络架构,首要解决的问题,就是句子中同一个单词可能面临这不同的含义。通过引入记忆元存储从前时刻向量的信息,使得模型再面对相同的输入,由于记忆元中储存的从前信息不同,对应的记忆元状态不同,就会产生不同的结果。考虑当前序列前的信息,这就比 MLP 中只考虑当前输入,从而相同输入只能得到相同输出有了很大的进步。
前馈神经网络 (MLP multi-layer perception - 常规神经网络,有输入层,隐藏层和输出层的另一个名字) 作为最基础的神经网络架构,使其区别于简单线性模型的,就是激活函数。它使得拟合过程非线性化。
在 RNN 中,一个句子被送入网络,每个词都会都会通过具有同一参数的网络,这种设计正是参数共享 - parameters sharing,用同一个网络处理不同时间点的输入。它极大地减少了需要估计的参数数量,使得模型可以增加任意长度的输入而不用增加待估计参数。这也是模型泛化能力的来源 - 这迫使模型去学习某种通用规则。
但值得注意的是,也正是这种参数共享的设计,导致了后来的梯度问题。如果各个时间点不共享参数,梯度问题将可能是不同的形式。
这种参数共享的设计在 CNN 中也有出现,不过它是利用 filter 使得不同的空间输入参数共享。
从数学上说,一个 RNN 单元,就是接受当前输入 $x_{t}$,与上一时刻的隐藏状态 $h_{t-1}$ 一起作为输入,通过线性层和激活层,得到当前时刻的隐藏状态 $h_{t}$。 对于一整个序列,按顺序把 $x_{t}$ 输入得到 $h_{t}$ 遍历整个序列,PyTorch 输出的正是这个 $h_{t}$ 的组合,再次注意,对于同一个序列,不同时间的输入,是参数共享的,即一层 RNN 只有一份参数。
$$ h_{t}=\text{tanh}(W_{hh}h_{t-1}+W_{xh}x_{t}+b_{h}) $$hidden state $h_{t}$ 可能是更一般的关于 memory cell 的表述。从特征工程的角度看,$h_{t}$ 是一个动态的、随时间演变的特征提取器。它不仅 " 记住 " 了过去,更是将整个历史序列压缩成一个固定大小的、对当前预测任务最有用的特征向量。
如果输出当前时间步 $y_{t}=W_{hy}h_{t}+b_{y}$ 。RNN 可以设计很多层,把当前输出 $h_{t}$ 再当作输入给到下一层 RNN ,通过指定 PyTorch 中的 num_layers
即可。
PyTorch 的输出是 $h_{t}$ 矩阵,使得网络设计者可以灵活地利用隐藏状态和其中蕴含的序列信息。
RNN 对于 MLP 的改进,可以概括为三点: hidden state 当作输入;接受序列输入;参数共享;
简单 RNN 只能看到左边 - 上文的信息,而无法得到右边 - 下文的信息。所以有了双向 RNN - Bi_RNN ,即同时训练两个网络,一个从序列左往序列右得到隐藏状态 $h_{t}$,一个从右往左得到隐藏状态 $h_{t}'$。PyTorch 在指定双向的时候,就会输出这两个 hidden state 的横向拼接,一般情况下,我们只要再把这个尺寸为 hidden_size * 2 的输出接上一个线性全连接层即可得到输出。此时,我们的输出,就既可以看到上文,又可以看到下文。这种改进,在绝大部分场景都可以提高准确率。除了例如实时翻译这种,无法得到完整序列而无法应用双向 RNN 的场景。
一般的 RNN 的 hidden state 是把隐藏层的输出进行储存, 是 Elman 网络。还有一种变形,是把隐藏层通过线性层得到的结果储存,叫 Jordan 网络。
但是现在看起来是 Elman 胜利了,因为 PyTorch 支持的 RNN 默认是 Elman。
LSTM
RNN 的遗留
RNN 虽然解决了上下问题,让序列同一输入可以根据不同上下文得到不同输出,但另一个问题 - 记忆长度限制使得 RNN 无法对于太长之前的输入进行回答。
RNN 利用 $h_{t}$ 来储存上文,每次都使用 $[h_{t-1}, x_{t}]$ 的输入并得到 $h_{t}$,从结构上来说,$h_{t}$ 中可以储存上文中的所有信息,但这仅仅存在于理论上。由于实际更新参数使用类似反向传播,需要不断地求梯度并相乘,从数学上看要不断乘矩阵 $W_{hh}$。
这就是问题所在。像所有连乘都会遇到的问题,只要数值略微大于 1 或者略微小于 1,经过大量的连乘,这个结果就会爆炸或者消失,这就是梯度爆炸或梯度消失。
梯度爆炸还是有办法进行控制,而且整个 RNN 在训练过程中为了收敛,会更倾向于训练参数小于 1。所以,RNN 在远期的信息上,几乎学不到任何信息。这也就是他几乎记不住稍早一些的输入,而只能观察到近期输入的信息并做出或许完全相反的预测。
这就是 LSTM 的改进 - 解决梯度连乘带来的梯度消失,以解决 RNN 架构设计但无法实现的对长期的记忆。
LSTM 结构设计
LSTM 的创新在于 gate - 门的设计,提出了输入门、输出门和遗忘门,共同接受 $[h_{t-1},x_{t}]$ 作为输入,分别训练参数,但同个门的对应参数在序列中依然共享。
门输出一个 $[0,1]$ 的数值,0 表示没有通过,1 表示全部通过,在二者之间则意味着部分通过 。
$$ \begin{array}{ll} i_{t} &= \sigma \left(W_{i}[h_{t-1},x_{t}]+b_{i} \right) \\ f_{t} &= \sigma \left(W_{f}[h_{t-1},x_{t}]+b_{f} \right) \\ \tilde{C}_{t} &= \tanh(W_{C}[h_{t-1},x_{t}]+b_{C}) \\ o_{t} &= \sigma(W_{o}[h_{t-1},x_{t}]+b_{o}) \\ C_{t} &= f_{t} \odot C_{t-1}+i_{t} \odot \tilde{C}_{t} \\ h_{t} &= o_{t} \odot \tanh(C_{t}) \end{array} $$其中 $\odot$ 为逐元素相乘(Hadamard product)。
与 PyTorch 的公式 2 有细节上有所不同
详细说有 forget gate - $f_{t}$, input gate - $i_{t}$,待通过 cell $\tilde{C}_{t}$ (有材料写作 $g_{t}$), 通过 input gate 为 $i_{t}\tilde{C}_{t}$。那么新的 cell state - $C_{t}$ ;output gate - $o_{t}$。
正是 $C_{t}$ 更新加法的设计,避免了连乘带来的问题,使得长期参数可以不为零并得到更新。这意味着,LSTM 模型可以记住序列早期的输入,并在提问/验证中得到正确的答案。
梯度爆炸与梯度消失
RNN 的损失函数,是输出向量和目标向量的交叉熵。参数更新方法是随时间的反向传播,与反向传播类似,只是增加了时间维度。实际训练中,RNN 的 error surface 十分崎岖,梯度的变化很大,时常很大时常很小,我们无法设置一个合适的学习率,这就给收敛造成了困难。
为了解决忽然变大梯度造成的收敛困难,提出了梯度裁剪。给梯度设置一个上限,如果计算得到的梯度大于上限,那就截断为上限。这就控制了可能出现的梯度范围,再进行学习率等参数的设置以收敛训练。
梯度消失的问题,在早期的观点中,更多的来自 Sigmoid 两端的平坦部分。但如果我们替换 Sigmoid 并不能观察到改进,可以得到结论,RNN 面临的梯度消失与 Sigmoid 无关,更可能的原因是我们上面讨论过的权重矩阵连乘。
梯度爆炸的现象容易观察, loss 出现 Nan;梯度消失的现象,直接观察梯度本身是个好办法,同时,可能会观察到训练 loss 早早就无法降低。
改进的主要方式,是使用 LSTM,这是目前 RNN 的主流架构。同时还有 GRU,只需要两个门,相比于三个门的 LSTM 训练时更加 robust。
GRU
GRU 的主要改进是合并 forget gate 和 input gate 为 update gate。
只需要训练两个门, reset gate $r_{t}$ - 控制着 $h_{t-1}$ 应该记住多少,如果为 0 那就遗忘;update gate $z_{t}$ - 同时控制着留着多少 $h_{t-1}$ 和 应该加上多少新备选状态 $\tilde{h}_{t}$。
具体来说,备选新状态 $\tilde{h}_{t}=\tanh(W_{n}x_{t} + b_{n} +r_{t} \odot (W_{hn}h_{t-1}+b_{h}))$ ,输出 $h_{t}=(1-z_{t}) \odot\tilde{h}_{t} + z_{t}h_{t-1}$。
此公式与 PyTorch 官方文档保持一致。在这里,更新门 $z_{t}$ 更像一个 " 保留门 “:当其值接近 1 时,模型倾向于保留旧的隐藏状态 $h_{t-1}$。
GRU 在小数据上可能表现更好,在大数据上与 LSTM 相差不大,但优势在于训练的速度快,难度低。
与 Transformer
随着时间的推进,Transformer 已经很大程度对 RNN 系列模型完成替代。
主要有两大改进:
- 顺序处理到并行处理:RNN 必须逐个处理时间步,计算 $h_{t}$ 依赖于 $h_{t-1}$。而 Transformer 的自注意力机制可以一次性计算序列中所有词之间的依赖关系,因此可以大规模并行化,训练速度更快。
- 对前期输入更好的掌握:在 RNN 中,相距很远的两个词需要通过很长的路径传递信息,容易造成信息丢失。而在 Transformer 中,任意两个词之间的路径长度都是 $O(1)$,信息传递更直接。
尽管 Transformer 在许多任务上占优,但 RNN/LSTM 因其较低的计算和内存复杂度,在某些边缘计算设备或对实时流式处理要求高的场景中仍有其价值。