循环神经网络
记录循环神经网络相关知识,包括RNN、GRU、LSTM、Seq2Seq、束搜索等
序列模型
序列模型通常是时序的,这样我们可以通过贝叶斯公式来对条件概率进行建模。
其中的 $X_1,X_2,…X_{T-1}$ 为先前已知的数据,当然也可以有些情况可以通过未来数据去反推先前的数据,但是大部分由于物理上的限制所以基本都需要根据见过的数据进行建模,即自回归模型(Auto Regression)。 关于序列模型,有几个方案。
方案一:马尔可夫假设
马尔可夫假设了当前的数据之和之前/最近的有限个数据相关,这样可以简化计算和分析。并且也是符合逻辑的,比如股价和一个月、三个月之内的比较相关,但是和几年前的关联就不是很大了。方案二:潜变量模型(Latent Variable)
这样 $h’$ 就和前一个 $h$ 以及 $x$ 相关,$x’$ 就和 $x$ 以及 $h’$ 相关。$h_t$ 包含的就是前面时间的信息,很像是一个递归的过程,并且在这个过程中,$h_t$ 是不断更新的,并使用这个潜变量来表示历史信息。
对于序列预测模型,分为单步预测和多步预测。通常对于直到 $x_t$ 的观测序列(已有真实数据),其在时间步 $t+k$ 处的预测输出 $\hat{x}_{t+k}$ 称为 $k$ 步预测($k$-step-ahead-prediction)。
文本处理
文本处理就是序列模型的一个常见的应用场景。
- Tokenize:通常需要将句子拆分成词元(也就是一个单位元素Token),将一段文字转换为Token就是 Tokenize 的过程。
- 映射到索引:划分好词元之后,为了便于训练,token需要符合张量的形状,因此还需要构建将字符串类型映射到下标/索引的词汇表(Vocabulary)。在中文中这不是一件容易的事,因为需要对多种词和文字的组合进行分词来区分不同的token。
划分好token后通常按照频率高低来将每一个token映射到一个索引, (这样的排序并不是必须的,但是很有效,因为按照顺序排序形成的词汇表vocab能让频率较高的词排在一块,这样计算机查找的速度会更快) 对于那些出现次数极少的token(难以预测),可以将其设置为 <unk> 即未知token,最终形成一个索引和token一一映射的字典vocab。训练根据的都是对应的索引。
语言模型
语言模型的目标就是在给定文本序列的情况下进行联合概率的预测。使用计数的方法来建模,也可以理解为是通过统计频率的方法来建模。
但是当序列变得很长的时候,由于文本量的不足,对应长序列的统计频率小于1,如下图。这时我们可以使用N元语法(N-Gram) 来适当简化。一元语法即 $\tau=0$ ,每个词都是互相独立的。二元语法 $\tau=1$ ,即之和前一个词有关,以此类推。
使用N元语法的好处:一段文本(原始序列)有多个、多种子序列,难以存储。每一次进来一个文本,对于不同的序列都要看一遍整个文本,然后计数不同的子序列算概率,看一个子序列就会花费 $O(n)$ 的时间,n为序列长度,并且这个时间是指数级增长的。
N元语法相当于限制了token的组合种类数,只要我将N元token的组合全部存下来(存上面的 $n(x_1,x_2),n(x_1),n(x_2)$ ),那么以后我只需要更新对应序列的统计数值然后计算概率即可。这样可以将时间复杂度控制在 $O(T)$ ,$T$ 为取的子序列长度,可以理解为是 $\tau$。
对一个词汇表为 $1k$ 的一段文本进行2元语法处理,那么我只需要存储 $1k \times 1k=1M$ 个序列就可以实现稳定时间内的预测。
当然如N很大的时候,空间复杂度、存储压力也自然会很大,但是总是比原来高耗时的情况好一点。具体实现和体现见书和代码。 每个batch子序列可以通过两种采样方法:随机采样或者顺序分区来进行划分,前者可以使每个batch之间都是相互独立的,后者则可以让每个batch都是连续的,能获得更多的空间信息。
RNN循环神经网络(Recurrent)
RNN的输出取决于当前观测的输入 $x_t$ 和前一时刻的潜变量 $h_{t-1}$ 潜变量的更新处于一个隐藏状态层(Hidden State)中:
\(\mathbf{H}_t = \phi(\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh} + \mathbf{b}_h)\) 这就是用于循环更新计算潜变量的过程,引入的新权重 $\mathbf{W}_{hh}$ 用来描述如何在当前时间步中使用前一个时间步的潜变量。最终得到输出: \(\mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q\)
可以看出来RNN其实就是比一个单隐藏层MLP多了一个潜变量这个隐藏状态层。
通过cs231n,可以得到有关RNN的更深的理解。 RNN中多个层共享W的参数。$W=\begin{pmatrix} W_{hh} & W_{hx} \end{pmatrix}$ \(h_t = \tanh(W_{hh}h_{t-1} + W_{xh}x_t) = \tanh\left( W \begin{pmatrix} h_{t-1} \\ x_t \end{pmatrix} \right)\) 
RNN中有多种对应关系,多对多、多对一、一对多。在一对多中,实际输入只有一个 $x$,那么后面的 $x_n$ 可以选择全部设置为0,也可以设置为上一层的输出 $y_{n-1}$ 。 
RNN的反向传播是沿着时间步的,但是大量的时间步使得计算很贵,因此采用了截断的BPTT。 
通常还会在输入层和隐藏层之间加入一个embedding层,原因如下:
- 如果vocab非常大,那么独热向量维度也会非常高,但是通过embedding可以显著降维,将大量简单的0,1编码变为更少的,但具有语义信息的embed向量。
- 通过embedding的向量具有语义信息,那么RNN就不仅仅能学到词出现顺序的规律,还能学习到不同词之间的语义关系。
1 2 3 4
cat = [0 0 1 0 0] cat = [0.2 0.8 -0.1] dog = [0 1 0 0 0] ===> dog = [0.25 0.7 -0.2] car = [0 0 0 1 0] car = [-0.9 0.1 0.3] # 前者cat到dog/car的距离是一样的,后者不一样,即语义差别
还可以将CNN提取出的视觉信息输入进RNN,只需要对隐藏状态层进行很小的一个调整。
\(h_t = \tanh(W_{xh}x_t + W_{hh}h_{t-1} + W_{ih}v)\) 新增的 $W_{ih}v$ 即将提取出的视觉特征向量 $v$ 增加进 $h_t$ 。也可以不在每一步都输入,只在首个token中添加图像信息。
语言模型本质上还是一个分类模型,要判断接下来是哪一个词。通过交叉熵再引入困惑度。
困惑度使用了 $e$ 来放大,$exp(\pi)$ 可以理解为判断接下来有哪几个可能的词,$exp(\pi)$ 越大说明约不确定。为1时表示确定就是这一个,为2时表示可能是两个中的一个。
梯度裁剪只对长度进行修正,有效防止梯度 $g$ 太大导致的数值不稳定。 
在传播过程中,经过大量的时间步非常容易导致梯度消失(Vanishing Gradient)。为了数值稳定性,会选择进行梯度裁剪(Gradient Clipping),在d2l中有讲到过,即设置一个threshold,当梯度过大时将其拉回一个范围内。 
1
2
3
grad_norm = np.sum(grad*grad)
if grad_norm > threshold:
grad *= (threshold / grad_norm)
梯度沿着时间步反向传播(Back Propogation Through Time)
时序模型不像之前的神经网络在物理上有很深的网络层,但是在时间步上很长。由于网络的信息在时序上非常重要,因此梯度的计算也需要沿着时间步反向传播,但同样也是基于链式法则的。
但是过长的序列很容易导致时间步上的梯度爆炸以及时间信息的大量堆积、冗余,因此可以采用截断时间步的方法来缓解(还有其他多种方法)。更多的实现细节和理论讲解见此。
下图展示了Vanilla RNN中梯度的反向传播过程,每个时间步都进行一次传播。因为所有 $W (W_{hh}、W_{xh})$ 都共享参数,因此最终更新的都是第一个 $W$ 。 
独热编码(One-Hot)
(代码实现) 将每个字符对应的索引映射为一个独热单位向量,这个向量的长度为vocabulary的大小,这样,模型可以通过矩阵乘法将独热向量与权重矩阵相乘,得到特征表示。 并且我们使用了一个转置来将三维张量的维度进行了交换:
1
2
3
F.one_hot(X.T, 28).shape
# Output
torch.Size([5, 2, 28])
将张量从(批量大小,时间步数,词表大小) 转换为了(时间步数,批量大小,词表大小) ,这样就能更方便地获得最外层的维度,接口形状也能提高读取计算性能。
在拿到输入的真实词后,需要将其输入进模型来初始化模型状态而暂时不需要预测。
训练过程中,如果我们使用的是随机采样(前面提到)出的batch,那么在每一个batch的迭代过程中要不断将状态重新初始化为0,因为这时前后状态之间并不连续,时序上不相关。如果采用的是顺序分区的方式,那么前后状态就是连续的,具有时序相关性,每个batch的状态就可以保留,传给下一个batch,但是在backward时还是需要detach掉。
门控循环单元GRU(Gated)
在前面的RNN中,关注一个序列,我们总是认为每个时间步都是一样重要的,但是很多情况下并不是这样。让模型具有特别关注某些部分,忽略某些部分的能力是很有帮助的。因此想要对观察进行筛选就需要一些额外的控制单元:
- 能关注的机制(更新门)$R_t$
- 能遗忘的机制(重置门)$Z_t$
引入两个新的机制,重置门和更新门的计算方式和RNN一样。同时也引入了新的可学习的的参数矩阵。
然后引入包含重置门的候选隐状态 $\tilde{H_t}$ ,$\odot$ 为按元素乘法。
- 当 $R_t$ 很接近0时,第二项就也会变得很接近0,这样就实现了遗忘掉之前历史信息的功能($H_{t-1} \rightarrow 0$)。
- 当 $R_t$ 很接近1时就会保留前面得到的历史信息。
真正包含更新门 $Z_t$ 的隐状态 $H_t$ ,隐状态中再考虑候选隐状态,道理是一样的。 - $Z_t \rightarrow 0$ 时,基本不看先前的状态,只看当前的候选隐状态( 包含有当前输入以及重置门)。
- $Z_t \rightarrow 1$ 时,几乎保留历史信息,并且不再看当前的输入 $X_t$ 。
当 $R_t$ 和 $Z_t$ 都为0时,等价于一个完全失去记忆的RNN,或者说是一个MLP。 只有 $\tilde{H_t}$ 中含有当前输入 $X_t$ 。
LSTM
- 忘记门 $F_t$:将值向0减少,判断是否要移除(忘记)cell
- 输入门 $I_t$:决定忽略还是写入输入数据
- 输出门 $O_t$:输出结果。决定是不是使用隐状态,以及更新隐状态 $H_t$
但是添加了一个新的门,候选记忆单元 $\tilde{C_t}$ ,和RNN的计算是一样的。 
记忆单元 $C_t$ 和GRU的表示几乎一样,但是这里使用了遗忘门 $F_t$ 和输入门 $I_t$ 两个独立的门来作为调整单元。这样的好处是可以实现“既要又要”的效果。
需要注意:这里的 $C_t$ 是在 [-2, 2] 范围,更大的范围能存储更多的信息,可以理解为是辅助记忆历史信息。但是为了后面隐状态 $H_t$ 计算,又使用了一个tanh来规范到 [-1, 1] 。 
隐状态也跟着记忆单元和输出进行调整,来决定 $H_t$ 中是否需要记住之前的信息。这里具有GRU中重置的功能。 
总结全部:\(I_t = \sigma(X_t W_{xi} + H_{t-1} W_{hi} + b_i)\)\(F_t = \sigma(X_t W_{xf} + H_{t-1} W_{hf} + b_f)\)\(O_t = \sigma(X_t W_{xo} + H_{t-1} W_{ho} + b_o)\)\(\tilde{C}_t = \tanh(X_t W_{xc} + H_{t-1} W_{hc} + b_c)\)\(C_t = F_t \odot C_{t-1} + I_t \odot \tilde{C}_t\)\(H_t = O_t \odot \tanh(C_t)\) 更多来自cs231n的解释: LSTM的结构如下图,和d2l中几乎一样,$g$ 为即d2l中的候选记忆单元 $\tilde{C_t}$ 。 
具体的信息流动和 $c_t$ 梯度传递示意。注意:$c_t$ 是按元素乘法计算的,其梯度和矩阵无关。因此其梯度可以像ResNet一样直接向前传递。

LSTM 架构使得 RNN 更容易在多个时间步中保存信息——例如,如果 $f = 1$ 且 $i = 0$,那么该单元的信息将被无限期保留。相比之下,普通的 RNN 更难学习一个能够在隐藏状态中保留信息的循环权重矩阵 $W_h$。LSTM 并不保证不存在梯度消失或爆炸,但它确实为模型学习长距离依赖提供了一种更容易的方法。
深度循环神经网络
- 对于第一个隐藏层,$\mathbf{H}t^1 = f_1(\mathbf{H}{t-1}^1, \mathbf{X}_t)$
- 更深的隐藏层 $\mathbf{H}t^j = f_j(\mathbf{H}{t-1}^j, \mathbf{H}_t^{j-1})$
- 最终输出层 $\mathbf{O}_t = g(\mathbf{H}_t^L)$
双向循环神经网络
目前RNN都是只看过去,但是如果未来的内容也能对当前需要的预测产生很大影响。
- I am happy.
- I am not very hungry.
- I am very very hungry, I could eat half a pig.
一个前向隐藏RNN层、一个反向隐藏RNN层,合并这两个隐状态得到输出。
Tips: 但是双向RNN,比如双向LSTM非常不适合做推理。在推理预测过程中只有过往的信息,而训练使用的是历史和未来的信息,通常用来对序列抽取特征或者填空,而非预测未来。
机器翻译与数据集
机器翻译过程中新增了三个token <pad>, <bos>, <eos> 用于标记填充、句子开始、句子结束。
因为需要对文本样本进行小批量处理,一个batch中一般序列样本都需要一个固定的长度,所以我们需要对文本序列进行截断(Truncate)或填充(Padding)。 因为可能会进行填充,因此需要记录一个 validlen 有效长度值来记录需要学习的句子部分。
机器翻译和之前不一样的地方,这里不再是预测一个标号而是预测一个序列(句子)。对应代码实现。
编码器-解码器架构
可以将特征抽取的过程看成编码,让机器更好第学习。抽取完通过分类器输出标号就是解码。
在RNN中,我们也可以形成一个这样的编解码过程。
一种更通用的表示形式:
序列到序列学习(Seq2Seq)
翻译过程可以看成是一个编码到解码的过程。并且encoder通常可以是双向的,因为总是可以拿到完整的输入。但是Decoder需要做预测所以不是双向的。 输入和输出都可以是变长的,不需要对应长度,只要decoder可以返回一个 <eos> 来终止。 
编码器一般不需要输出,只需要输入最后的隐藏状态到解码器即可。 
在训练过程中,输入的都是真实且正确的句子,因此如果前一步预测输出错误,下一个收到的输入也仍旧是正确的。但是预测中,就会将误差累积。 
衡量生成序列的精度使用BLEU,$p_n$ 为预测精度。 只看单个词的话,预测序列的5个词中有4个是真实序列中的,得到 $p_1$ 。 看连续两个词,预测序列的4个2-gram词中有3个是真实序列中的(AB、BC、CD),得到 $p_2$ 。 以此类推。
BLEU越大($\rightarrow 1$)越好,但是过短的预测虽然能得到很好n-gram精度,但是通常都是错误的预测,所以需要惩罚过短的预测。然后通过指数函数的性质对于长匹配的精度设置高权重进行奖励。
在代码实现中,我们假设编码器和解码器的隐藏层大小 hidden size 是一样的。
decoder的输入是最后一个时刻embedding的输出和encoder的最后一个隐藏层状态,形成了上下文+最新时刻输入的一个综合输入。这也是为什么RNN的输入大小是 embed_size + num_hidden 。
通过零值化屏蔽不相关的项,也就是屏蔽前面 padding 的部分,保留 validlen 。
1
2
3
4
5
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2])) # 第一个 validlen 为 1,第二个为 2
# Output:
tensor([[1, 0, 0],
[4, 5, 0]])
同样地,也需要对交叉熵损失函数进行有效值保留的处理,详见代码。
束搜索(Beam Search)
回顾在Seq2Seq中,我们对序列的预测通常是基于贪心算法实现。 
贪心虽然效率最高,但是并不一定是最优的。为了找出最优,就一定要用到枚举。对于所有可能的序列算概率,取最好的。
如果输出字典大小为 $n$ ,序列最长为 $T$ ,那么我们就要考虑 $N^T$ 个序列。
可以看出枚举的计算量非常大,几乎不可能实现。需要在准确度和效率之间找到一个平衡点。即为束搜索。 束搜索可以理解为是一个更多选择的贪心($k=1$ 为贪心,$k=n$ 接近于穷举),束搜索每一步都会重新评估所有扩展路径,选择概率最高的 k 个,而不是直接选唯一最优。更多的好选择能尽可能地去找到更优的方案,同时也兼顾了搜索效率。 



