03 循环神经网络
循环神经网络(Recurrent Neural Network)是自然语言处理领域的入门级深度学习算法,也是序列数据处理方法的经典代表作,它开创了“记忆”方式、让神经网络可以学习样本之间的关联、它可以处理时间、文字、音频数据,也可以执行NLP领域最为经典的情感分析、机器翻译等工作。在NLP领域,循环神经网络是GRU、LSTM以及许多经典算法的基础、更对我们理解transformer结构有巨大的帮助,因此即便在Transformer和大语言模型统治前沿算法战场的今天,我们依然需要学习RNN算法。RNN就仿佛机器学习中的逻辑回归算法一般,是打开NLP领域大门的钥匙。今天我们就一起来看看RNN的基本逻辑和实现手段。
【注意】在学习循环网络之前,请确保你已对基础的深度神经网络(DNN)有深刻的理解,确保你了解所有经典优化层、理解梯度下降过程、理解正向反向传播过程、理解链式法则、神经网络如何训练、深度神经网络如何输入输出等基础知识。1 循环神经网络的基本架构
如果你去找寻网络上的各种资源,你会惊讶地发现循环神经网络有各种各样复杂的公式表示和图像表示方法。然而,光从网络架构来说,循环神经网络与深度神经网络是完全一致的。
首先,循环神经网络由输入层、隐藏层和输出层构成,并且这三类层都是线性层。和深度神经网络中的线性层一样,输入层的神经元个数由输入数据的特征数量决定,隐藏层数量和隐藏层上神经元的个数都可自己设置,而输出层的神经元数量则需要根据输出的任务目标进行设置。以下面的数据为例,现在我们将每个单词都编码成了5个特征构成的词向量,因此输入层就会需要5个神经元,我们将该文字数据输入循环神经网络执行三分类的“情感分类”任务(三分类分别是[积极,消极,中性]),那输出层就会需要三个神经元。假设有一个隐藏层,而隐藏层上有2个神经元,一个最为简单的循环网络的网络结构如下:

在这个结构中,激活函数的设置、神经元的连接方式等都与深度神经网络一致,因此循环神经网络在网络构建方面没有太多可以深究的内容,循环网络真正精彩的地方在于其创造了全新的数据流,我们来具体看一下——
2 循环神经网络的数据流
当我们将数据输入深度神经网络时,一个神经元会一次性处理一列数据,5个神经元会涵盖整张表的数据,在一次正向传播中深度神经网络就会接触到完整的一张数据表。这种方式计算效率很高,同时矩阵计算也很简单:输入结构为(9,5),中间层输出为(9,2),最终输出结果为(9,3),整个计算过程完全只考虑每个单词的特征之间的转换(5➡️2➡️3),而完全忽略“单词与单词之间的联系”,毕竟输入9个单词,输出9个单词,并没有对单词之间的关系进行任何学习。

但是,在循环神经网络当中就不一样了。虽然是一模一样的网络结构,但当我们将数据输入到循环神经网络时,一个神经元一次性只会处理一个单词的一个数据,5个神经元会覆盖当前单词的5个特征,在一次正向传播中,循环神经网络只会接触到一个单词的全部信息,而不会接触到整张表。

如果这样的话,岂不是要一行一行处理数据了?没错,虽然非常颠覆神经网络当中对效率的根本追求,但循环神经网络是一个单词、一个单词处理文本数据,一个时间点、一个时间点处理时序数据的。具体过程如下:



如果一次正向传播只处理一行数据,那对于结构为(vocab_size,input_dimension)的文字数据来说,就需要在同一个网络上进行 vocab_size 次正向传播。同样的,对于结构为(time_step,input_dimension)的时间序列数据来说,就需要在同一个网络上进行time_step 次正向传播。在循环神经网络中,vocab_size 和 time_step 这个维度可以统称为 sequence_length,同时还有一个更常见的名字叫做时间步,对任意数据来说,循环神经网络都需要进行时间步次正向传播,而每个时间步上是一个单词或一个时间点的数据。
基于这样的数据流设置,循环神经网络构建了自己的灵魂结构:循环数据流。在多次进行正向传播的过程中,循环神经网络会将每个单词的信息向下传递给下一个单词,从而让网络在处理下一个单词时还能够“记得”上一个单词的信息。
如下图所示,在$ T_{t-1} $ 时间步上时,循环网络处理了一个单词,此时隐藏层上输出的中间变量 \(H_{t-1}\) 会走向两条数据流,一条数据流是继续向输出层的方向正向传播,另一条则流向了下一个时间步的隐藏层。在 \(T_{t}\) 时间步时,隐藏层会结合当前正向传播的输入层传入的 \(X_t\) 和上个时间不的隐藏层传来的中间变量 \(H_{t-1}\) 共同计算当前隐藏层的输出\(H_{t}\)。如此,\(H_{t}\) 当中就包含了上一个单词的信息。

使用数学公式表示如下:
使用架构图表示,则可表示如下:

利用这种方式,只要进行 vocal_size 次向前传播,并且每次都将上一个时间步中隐藏层上诞生的中间变量传递给下一个时间步的隐藏层,整个网络就能在全部的正向传播完成后获得整个句子上的全部信息。在这个过程中,我们在同一个网络上不断运行正向传播,此过程在神经网络结构上是循环,在数学逻辑上是递归,这也是循环神经网络名称的由来。
3 效率问题与RNN的权值共享
现在你已经知道循环网络的数据流和基本结构了,但我们还面临一个巨大的问题——效率。刚才我们以一张表为例讲解了循环神经网络的迭代过程,但循环网络在实际应用时可能面临 batch_size 张表单,如果每张表单都需要一行一行进行向前传播的话,那循环神经网络运行一次需要(batch_size $ \times $ sequence_length)次向前传播,这样整个网络的运行效率必然是非常非常低的。


幸运的是,事实上这个问题并不存在。在现实中使用循环神经网络的时候,我们所使用的输入数据结构往往是三维时间或三维文字数据,也就是说数据中大概率会包括不止一张时序二维表、会包括不止一个句子或一个段落。之前我们提到过,循环神经网络要顺利运行的前提是所有的句子/时间序列被处理成同等的长度,因此实际上每张二维表需要循环的时间步数量是相等的,因此在实际训练的时候循环神经网络是会一次性将所有的 batch_size 张二维表的第一行数据都放入神经元进行处理,故而 RNN 并不需要对每张表单一 一处理,而是对全部表单的每一行进行一 一处理,所以最终循环神经网络只会进行 sequence_length 次向前传播,所有的 batch 是共享权重的。

如果将三维数据看作是一个立方体,那循环神经网络就是一次性处理位于最上层的一整个平面的数据,因此循环神经网络一次性处理的数据结构与深度神经网络一样都是二维的,只不过这个二维数据不是(vocal_size,input_dimension)结构,而是(batch_size,input_dimension)结构罢了。

4. 循环网络的输入数据结构
讲到这里,我相信你已经非常了解循环神经网络的基本结构和巧妙之处了,在开始进行循环神经网络的实现之前,我们还需要解决最后一个问题:明确循环神经网络的输入数据结构。在之前的课程中我们提到过,循环神经网络是为数不多的、能够在不改变网络结构情况下同时处理二维数据和三维数据的网络,但在 PyTorch 或 tensorflow 这样的深度学习框架的要求下,循环神经网络的输入结构一律为三维数据。通常来说,最常见的结构就是之前我们提到过无数次的(batch_size,vocal_size,input_dimension),且循环是在 vocal_size 维度进行。不过,如果你曾经自学循环神经网络,或找寻过其他相关的材料,那你可能会发现,在某些材料当中,循环神经网络的输入被描述为(vocal_size,batch_size,input_dimension)结构。事实上,这两种结构是同一种结构,我们来看:

普通的结构(batch_size,vocal_size,input_dimension)如左图,此时循环神经网络会在vocal_size 这一维度上循环,执行 vocal_size 个时间步的正向传播、即从上至下不断处理面向上方的二维表单(虚线标注处)。但立方体是可以被旋转的,当我们将立方体旋转一个角度,即需要处理的二维表单由正上方专向正前方时,我们就得到了(vocal_size,batch_size,input_dimension)的数据结构,此时循环神经网络依然是在 vocal_size 方向进行循环,只不过我们需要处理的表单方向由从上到下变成了从前往后。因此不难发现,本质上这两种结构是一模一样的,但无论是哪种结构,循环神经网络都必须在时间步的方向,也就是 vocal_size 的方向进行循环。在 PyTorch 中,我们可以通过调节参数「batch_first」帮助 RNN 认知正确的维度,从而让 RNN 能够在正确的 vocal_size 维度上循环。
这一点可以由代码佐证:
import torch
from torch import nn
import time
inputs = torch.randn((3, 500, 10)) #(batch_size,vocal_size,input_dimension)
rnn1 = nn.RNN(input_size=10,hidden_size=20,batch_first=True)
# 此时vocal_size是500,因此会循环500次
start = time.time()
outputs1, hn1 = rnn1(inputs)
spend = time.time() - start
print(spend) # 0.03563952445983887
# 此时 RNN 会认知数据结构为(vocal_size,batch_size,input_dimension)
inputs = torch.randn((3, 500, 10))
rnn2 = nn.RNN(input_size=10, hidden_size=20, batch_first=False)
# 此时 vocal_size 是 3,因此会循环 3 次
start = time.time()
outputs2, hn2 = rnn2(inputs)
spend = time.time() - start
print(spend) # 0.004506111145019531
从代码可知,当 batch_first = True 时,此时会将传入的第一个参数认为 batch_size;
当 batch_first = False 时,会将传入的第二个参数认为 batch_size.

浙公网安备 33010602011771号