相关说明
这篇文章的大部分内容参考自我的新书《解构大语言模型:从线性回归到通用人工智能》,欢迎有兴趣的读者多多支持。
本文涉及到的代码链接如下:regression2chatgpt/ch10_rnn/char_rnn.ipynb、regression2chatgpt/ch10_rnn/bptt_example.ipynb
根据《自然语言处理的基本要素》的讨论,普通神经网络如多层感知器(MLP)在处理自然语言时会遇到巨大的瓶颈。为了提升学习效果,我们需要设计新的神经网络模型。这就是本文将重点讨论循环神经网络(RNN)。
理解本文的内容依赖于这两篇基础文章:《自然语言处理的基本要素》和《利用多层感知器(MLP)学习语言》。另外,本文的主要关注点在于理解模型,因此提供的代码实现并不是最高效的。在接下来的几篇文章中,我们将探讨如何更高效地实现模型,并对其进行优化。
一、普通神经网络 v.s. 循环神经网络
让我们暂时抛开具体的结构细节,从更宏观的角度来审视人工智能领域的模型。在这个角度下,模型就像一个不透明的黑盒子,将张量输入这个黑盒子,然后从中获得一些输出张量。根据输入和输出张量的形状,可以将常见的应用场景分为以下4种,如图11所示。
- 定长输入和定长输出,见标记1。图像识别、多层感知器学习语言就属于这一类。
- 不定长输入和定长输出,见标记2。理想的文本分类就是这种情况,文本的长度不一,模型需要将其分类到预定的固定类别中。同样,理想的自回归模式也属于这一类。
- 不定长输入和不定长输出,见标记3。这对应着自然语言处理中的序列到序列模式,在这种场景下,输入和输出的文本长度都是不固定的。需要注意的是,标记3的图示可能让人误以为输入和输出是同时产生的,但实际上它们之间可以是异步的。也就是说,在输入和输出之间可能存在明显的间隔,就像标记5所示的情况一样。文本翻译是一个典型的例子,模型需要阅读完整个输入文本后才开始生成翻译输出。
- 定长输入和不定长输出,见标记4。典型的例子是图像描述生成任务,即给定一张图片,模型需要生成对图片的文字描述,而描述文本的长度是不固定的。
普通神经网络(Vanilla Neural Networks)仅适用于处理第一种场景。本节将讨论的循环神经网络(Recurrent Neural Networks)能处理上述的所有场景。更令人兴奋的是,它可以自适应地调整数据转换步骤,更高效地从数据中提取信息,因而在众多任务中展现出令人惊叹的性能和预测效果。接下来,我们将深入研究这类模型,并探讨如何将其应用于自然语言处理。
二、图示与结构
普通神经网络的一大特点是采用分层的结构来组织神经元。在同一层中,神经元是相互独立的,它们之间没有直接的神经连接。为了清晰地展示这一结构特点,可以将同一层中的神经元表示为一个方块(称为神经块),如图2所示。每个神经块具有两个箭头,分别表示该神经块的输入和输出。
循环神经网络打破了普通神经网络的限制,它允许同一层的神经元通过相互连接来传递信息。从图示上来看,循环神经网络与普通神经网络的不同之处在于多了一条循环箭头。循环神经网络通常用于处理序列数据,这个箭头表示神经块不仅接收当前数据的输入,还会处理前序数据传递过来的信息。需要特别强调的是,在典型的循环神经网络图示中,输出箭头和循环箭头表示的张量实际上是一致的(如图中的h),通常被称为隐藏状态(Hidden State)。这些隐藏状态构成了模型的核心,也是下一节的主要讨论内容。
图2可能并不够直观,初看时,我们可能会困惑这样单一的神经块如何处理不定长的输入和输出。另外,循环箭头所代表的神经连接到底是什么样的呢?因此,有必要更细致地研究一下模型的图示。实际上,循环神经网络的结构并不是静态的,而是会随着输入的数据进行动态调整。具体来说,假设输入的序列数据长度为3,那么循环神经网络会自动展开成由3个神经块组成的网络。这种展开的、没有循环的网络结构更加直观和易于理解,如图3左侧所示。
在展开的循环神经网络中,每个神经块都是按照相同的方式“重复”构建的,这意味着它们共享参数(类似于卷积神经网络中的卷积层[TODO])。为了更好地理解这一点,下面进一步放大神经块,看看其中隐藏的神经元是如何连接的。为了简化图示,假设输入数据的张量长度为2,而每个神经块只有1个神经元。在处理序列数据时,循环神经网络的工作方式可以用以下简洁的步骤来描述。
- 当神经网络接收到输入序列的第一个数据时,神经网络的形态如图3中的A所示。在这个阶段,神经块A中的神经元接收第一个数据的输入,并经过计算后输出信号2。同时,它准备好信号3,等待序列的下一个数据。
- 当输入序列的第二个数据到来时,神经网络会增加一个新的神经块,如图3中的B所示。这时,整个神经网络的形态就等于A+B。在神经块B中,神经元将接收3个神经元的输入,其中两个来自第二个数据的输入,另一个是前一个神经块A的信号3。神经元的输出部分与神经块A的输出部分类似。
- 上述的计算步骤将会一直重复,直至序列数据的结束。
在图3中,有两个要点值得注意。
- 输出共享和循环连接:与普通神经网络类似,同一神经元发出的信号是一样的,这意味着对于循环神经网络,对外输出和指向自身的张量是相同的(比如信号2和信号3)。
- 共享参数:信号1的三条边共享同一个模型参数,这种设计与卷积层中的共享参数类似。这是循环神经网络的一个关键特性,它不仅有效地减少了模型参数,提高了训练效率,还有助于模型捕捉序列数据中的模式和特征。
三、模型的关键:隐藏状态
要想掌握循环神经网络,理解隐藏状态是关键。神经网络是一门工程学科,通过代码实现模型是理解隐藏状态的最佳途径。现在的任务是将循环神经网络的展开形式转化为代码。与之前讨论的普通神经网络不同,循环神经网络的图示(见图3)具有两个可能让初学者感到困惑的特点。
- 水平传递箭头:在循环神经网络中,除了向上传递的箭头,还存在指向自身的水平箭头。在数学角度上,普通神经网络中神经元的计算仅涉及当前输入的数据。而在循环神经网络中,需要将当前数据与隐藏状态结合在一起进行计算。那么,如何实现这种结合呢?
- 初始神经块的特殊性:第一个神经块与后续的神经块不同,它是序列的起始点,因此没有隐藏状态的输入。这使得它的代码实现相对比较特殊。
这两个特点的描述可能会让人感到困惑,似乎它们的实现很复杂。然而,一旦看到实际的代码并理解其原理,就会惊讶地发现它们实际上是如此简单和直观。
针对第一点,在图示中将神经块B和神经块C抬高,使它们的输入神经元与前一个隐藏状态神经元平行,如图4中标记1所示。这一调整使图示与普通神经网络非常相似,看上去更直观。根据这个图示,只需将前一个隐藏状态和当前数据进行张量拼接,然后将拼接好的张量传递给当前的隐藏状态神经元。
此外,第一个神经块A仅接收输入数据,没有隐藏状态的输入。为了与其他神经块保持一致,为其设置一个初始的隐藏状态,其中隐藏状态的所有元素都被初始化为0。这一调整并不改变计算结果,但消除了初始神经块的特殊性,从而简化了整个模型的结构,为代码实现提供了便利。
将上述两点翻译成代码,就得到了如图4中标记2所示的模型实现(完整代码2)。在处理序列数据时,需要循环调用这个模型。假设序列的长度为n,那么模型将被调用n次。每次调用时,前一次返回的隐藏状态将作为其中一个参数传递给模型,具体的代码示例如图4中标记3所示。从模型结构的角度来看,每次调用都会在模型中增加一个神经块3,这也是循环神经网络得名的原因。
四、利用循环神经网络学习语言
要将循环神经网络应用于自然语言的自回归学习,还需要其他模型组件的协助。
首先,从张量的形状来看,循环神经网络输出的张量形状为 ( 1 , H ) (1,H) (1,H),这里的 H H H代表隐藏状态的长度。但是,如果模型要预测下一个词元是什么,那么模型输出的张量形状应该是 ( 1 , V S ) (1,VS) (1,VS),其中 V S VS VS表示字典的大小,即所有可能词元的数量。通常情况下, H H H和 V S VS VS这两个数值并不相等,这就导致不能直接使用标准的循环神经网络进行自然语言的自回归学习。
其次,我们在阅读文本时会在心中建立对文本意思的整体理解。这个理解一方面随着阅读过程中新出现的内容而不断更新,另一方面也是执行众多任务的基础,比如分析文本的情感色彩或者预测作者接下来可能讲述的内容等。循环神经网络的运行方式正是对这一认知过程的模拟。模型的隐藏状态对应着对文本的理解,更准确地说,隐藏状态是文本的特征表示。基于这个隐藏状态,可以构建模型来预测接下来的内容。这个用于预测的模型通常被称为语言建模头(Language Modeling Head)。通常情况下,它是一个相当简单的线性模型,将形状为 ( 1 , H ) (1,H) (1,H)的隐藏状态向量转换为形状为 ( 1 , V S ) (1,VS) (1,VS)的张量,后者蕴含着词元出现的概率(通过使用Softmax函数将其转化为词元的概率分布)。上述代码实现如程序清单1所示。
1 | class CharRNN(nn.Module):
2 |
3 | def __init__(self, vs):
4 | super().__init__()
5 | self.emb_size = 30
6 | self.hidden_size = 50
7 | self.embedding = nn.Embedding(vs, self.emb_size)
8 | self.rnn = RNNCell(self.emb_size, self.hidden_size)
9 | self.h2o = nn.Linear(self.hidden_size, vs)
10 |
11 | def forward(self, x, hidden=None):
12 | # x: (1); hidden: (1, 50)
13 | emb = self.embedding(x) # (1, 30)
14 | hidden = self.rnn(emb, hidden) # (1, 50)
15 | output = self.h2o(hidden) # (1, vs)
16 | return output, hidden
17 |
18 | c_model = CharRNN(len(tok.char2ind)).to(device)
19 | inputs = torch.tensor(tok.encode('d'), device=device)
20 | hidden = None
21 | logits, hidden = c_model(inputs, hidden)
22 | logits.shape, hidden.shape
23 | (torch.Size([1, 98]), torch.Size([1, 50]))
在模型的实现细节方面,模型的输入是一个形状为 ( 1 ) (1) (1)的张量,表示当前词元在字典中的位置。首先,模型会对输入进行文本嵌入(假设嵌入特征的长度为30),如第13行所示。然后,将嵌入后的张量和上一个隐藏状态传递给循环神经网络,以获取当前的隐藏状态,如第14行所示。这个隐藏状态经过语言建模头的转换,最终生成了词元分布的logits,如第15行所示。
在构建模型的过程中,我们需时刻关注每次转换后的张量形状,这对于调试程序和确保模型计算的准确性来说至关重要。当模型构建完成后,可以输入一个随机生成的数据来验证模型输出的形状是否符合预期,具体实现如第18—23行所示。有了完整的模型后,接下来将讨论如何准备训练数据、进行模型训练,以及如何使用模型生成文本。
五、模型训练与文本生成
模型搭建完成后,随时可以投入使用。在实际应用中,通常会在进行模型训练之前就利用它生成文本,以验证模型的实现是否存在问题。
与普通神经网络(这篇文章中的图4)不同,循环神经网络能够自动处理文本的背景信息,无须复杂的人工处理。整个文本生成的过程如图5所示,图中展示了模型训练之后的效果,训练流程的技术讨论见下文。然而,对比这两张图的结果,会发现循环神经网络的效果并不理想。这样的结果主要有以下两个原因:首先,当前模型的学习效率较低,因此训练轮次有限;其次,循环神经网络在结构上仍有改进的空间。这两个问题将在后续的文章中进行探讨,这里先将重点关注模型的训练流程。
模型训练前需要准备数据。在自回归模式下,循环神经网络每次只接收当前词元和上一步生成的隐藏状态作为输入,因此数据准备相对简单,如图6所示。具体来说,模型训练所需的输入数据和预测标签的形状是相同的。尽管每次输入的数据看起来雷同,但随着输入文本的推进,它们隐含的含义却大不相同。这正是循环神经网络的强大之处,它能够自动处理复杂的背景依赖关系,即使是不定长的背景信息,模型也能够有效捕捉和处理。
循环神经网络的训练方式与普通神经网络有显著不同。在普通神经网络中,各个数据之间相互独立,可以利用张量进行并行计算,因此代码比较简洁且优雅,几乎没有循环结构。在循环神经网络中,数据之间存在相互依赖,无法进行并行计算。因此,在训练模型时,必须逐步生成预测结果,计算模型损失,以进行反向传播和梯度下降。具体的实现可以参考程序清单2,其中最关键的步骤是循环累加模型损失,如第11—13行所示。
1 | epochs = 1
2 | optimizer = optim.Adam(c_model.parameters(), lr=learning_rate)
3 |
4 | for epoch in range(epochs):
5 | for data in datasets:
6 | inputs, labels = encoding(data['whole_func_string'])
7 | hidden = None
8 | loss = torch.tensor([0.], device=device)
9 | optimizer.zero_grad()
10 | lens = inputs.shape[0]
11 | for i in range(lens):
12 | logits, hidden = c_model(inputs[i].unsqueeze(0), hidden)
13 | loss += F.cross_entropy(logits, labels[i].unsqueeze(0)) / lens
14 | loss.backward()
15 | optimizer.step()
循环神经网络是一种十分经典和常用的模型,开源算法库已经提供了成熟而高效的封装4。读者可以选择直接使用它们提供的封装,或者深入阅读它们的源代码。然而,这些实现通常包含复杂的抽象、层级和继承结构,不但初学者难以理解,甚至经验丰富的专家也可能在代码的迷宫中迷失方向。
前面的讨论中提供了一个直观的代码实现,旨在帮助读者更好地理解模型的细节。然而,这种实现的计算效率相对较低,极大地限制了模型的使用。想象一下,如果一个模型需要花费一年的时间进行训练,那么即使其设计结构再精妙,也将失去实际应用的价值。如何更高效地实现模型需要较长篇幅的讨论,因此,相关的内容将在本系列后续的文章中呈现。接下来将讨论循环神经网络的学习原理。
六、RNN的学习原理:通过时间的反向传播
普通神经网络在训练模型时使用反向传播算法来计算模型参数的梯度,反向传播算法的细节可以参考其他网上的文章[TODO]。那么对于循环神经网络,模型参数的梯度计算方法又是什么呢?答案仍然是使用反向传播算法,或者更确切地说,算法的核心仍然是反向传播。具体来说,为了计算循环神经网络的参数梯度,首先根据输入的序列数据将模型展开成没有循环的网络,然后在这个展开的网络上应用反向传播算法。学术界通常将这种算法称为随时间反向传播(Back Propagation Through Time,BPTT)。
由于循环神经网络会根据序列的长度自动展开成庞大的网络结构,因此在直觉上,我们担心它将面临严重的梯度不稳定问题。这是因为根据反向传播算法的讨论,梯度的传播路径越长,就越容易出现梯度消失或爆炸的情况。事实上,循环神经网络确实会遇到梯度不稳定的问题,但与普通神经网络的情况有所区别,通常被形象地称为“部分不稳定”。
为了更清晰地理解这一问题,下面通过一个简单的示例来可视化这一现象(完整代码)。图7定义了一个序列数据x1、x2、x3及其对应的隐藏状态h1、h2、h3(为了图示简洁,暂时忽略激活函数)。当对h3执行反向传播时,由于x1距离输出较远,梯度传播需要经过更长的路径,导致x1的梯度贡献急剧减小(从1减少到0.25)。随着序列长度的增加,这一现象会变得更加明显。
简而言之,当数据与输出之间的距离较远时,其贡献的梯度可以忽略不计,它对模型的优化几乎不起作用。这也表明,尽管循环神经网络从结构上可以处理任意长度的序列,但它就像一个健忘的人只有短期记忆,对于时间久远的数据,虽然它处理过,但几乎都忘记了。
插图参考自Andrej Karpathy的文章The Unreasonable Effectiveness of Recurrent Neural Networks。读者可能会对图中的水平传递箭头感到困惑,但不用担心,这是本文将要讨论的循环神经网络的图示。 ↩︎
循环神经网络的激活函数除了代码中使用的ReLU,还可以使用其他激活函数,比如常见的Tanh。 ↩︎
在计算机中,神经网络以计算图的形式进行存储。因此,每次调用模型都会向计算图中添加一个神经块。简而言之,对于长度为n的输入序列,循环神经网络会自动展开,生成包含n个“重复”神经块的网络结构。 ↩︎
PyTorch为循环神经网络提供的封装是nn.RNN。在这个封装中,线性变换部分包含两个截距项。从概念上来说只需要一个截距项,这里设置两个是为了与CuDNN(CUDA Deep Neural Network,神经网络的GPU计算依赖于此)兼容。类似的处理方式也在其他循环神经网络模型中存在,比如长短期记忆网络。 ↩︎