赞
踩
这篇文章的大部分内容参考自我的新书《解构大语言模型:从线性回归到通用人工智能》,欢迎有兴趣的读者多多支持。
本文涉及到的代码链接如下:regression2chatgpt/ch10_rnn/char_rnn.ipynb、regression2chatgpt/ch10_rnn/bptt_example.ipynb
系列文章:
让我们暂时抛开具体的结构细节,从更宏观的角度来审视人工智能领域的模型。在这个角度下,模型就像一个不透明的黑盒子,将张量输入这个黑盒子,然后从中获得一些输出张量。根据输入和输出张量的形状,可以将常见的应用场景分为以下4种,如图11所示。
普通神经网络(Vanilla Neural Networks)仅适用于处理第一种场景。本节将讨论的循环神经网络(Recurrent Neural Networks)能处理上述的所有场景。更令人兴奋的是,它可以自适应地调整数据转换步骤,更高效地从数据中提取信息,因而在众多任务中展现出令人惊叹的性能和预测效果。接下来,我们将深入研究这类模型,并探讨如何将其应用于自然语言处理。
普通神经网络的一大特点是采用分层的结构来组织神经元。在同一层中,神经元是相互独立的,它们之间没有直接的神经连接。为了清晰地展示这一结构特点,可以将同一层中的神经元表示为一个方块(称为神经块),如图2所示。每个神经块具有两个箭头,分别表示该神经块的输入和输出。
循环神经网络打破了普通神经网络的限制,它允许同一层的神经元通过相互连接来传递信息。从图示上来看,循环神经网络与普通神经网络的不同之处在于多了一条循环箭头。循环神经网络通常用于处理序列数据,这个箭头表示神经块不仅接收当前数据的输入,还会处理前序数据传递过来的信息。需要特别强调的是,在典型的循环神经网络图示中,输出箭头和循环箭头表示的张量实际上是一致的(如图中的h),通常被称为隐藏状态(Hidden State)。这些隐藏状态构成了模型的核心,也是下一节的主要讨论内容。
图2可能并不够直观,初看时,我们可能会困惑这样单一的神经块如何处理不定长的输入和输出。另外,循环箭头所代表的神经连接到底是什么样的呢?因此,有必要更细致地研究一下模型的图示。实际上,循环神经网络的结构并不是静态的,而是会随着输入的数据进行动态调整。具体来说,假设输入的序列数据长度为3,那么循环神经网络会自动展开成由3个神经块组成的网络。这种展开的、没有循环的网络结构更加直观和易于理解,如图3左侧所示。
在展开的循环神经网络中,每个神经块都是按照相同的方式“重复”构建的,这意味着它们共享参数(类似于卷积神经网络中的卷积层[TODO])。为了更好地理解这一点,下面进一步放大神经块,看看其中隐藏的神经元是如何连接的。为了简化图示,假设输入数据的张量长度为2,而每个神经块只有1个神经元。在处理序列数据时,循环神经网络的工作方式可以用以下简洁的步骤来描述。
在图3中,有两个要点值得注意。
要想掌握循环神经网络,理解隐藏状态是关键。神经网络是一门工程学科,通过代码实现模型是理解隐藏状态的最佳途径。现在的任务是将循环神经网络的展开形式转化为代码。与之前讨论的普通神经网络不同,循环神经网络的图示(见图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所示,图中展示了模型训练之后的效果,训练流程的技术讨论见下文。然而,对比这两张图的结果,会发现循环神经网络的效果并不理想。这样的结果主要有以下两个原因:首先,当前模型的学习效率较低,因此训练轮次有限;其次,循环神经网络在结构上仍有改进的空间。这两个问题将在后续的文章中[TODO]进行探讨,这里先将重点关注模型的训练流程。
模型训练前需要准备数据。在自回归模式下,循环神经网络每次只接收当前词元和上一步生成的隐藏状态作为输入,因此数据准备相对简单,如图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。读者可以选择直接使用它们提供的封装,或者深入阅读它们的源代码。然而,这些实现通常包含复杂的抽象、层级和继承结构,不但初学者难以理解,甚至经验丰富的专家也可能在代码的迷宫中迷失方向。
前面的讨论中提供了一个直观的代码实现,旨在帮助读者更好地理解模型的细节。然而,这种实现的计算效率相对较低,极大地限制了模型的使用。想象一下,如果一个模型需要花费一年的时间进行训练,那么即使其设计结构再精妙,也将失去实际应用的价值。如何更高效地实现模型需要较长篇幅的讨论,因此,相关的内容将在本系列后续的文章中[TODO]呈现。接下来将讨论循环神经网络的学习原理。
普通神经网络在训练模型时使用反向传播算法来计算模型参数的梯度,反向传播算法的细节可以参考其他网上的文章[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计算依赖于此)兼容。类似的处理方式也在其他循环神经网络模型中存在,比如长短期记忆网络。 ↩︎
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。