赞
踩
这篇文章的大部分内容参考自我的新书《解构大语言模型:从线性回归到通用人工智能》,欢迎有兴趣的读者多多支持。
本文涉及到的代码链接如下:regression2chatgpt/ch10_rnn/lstm.ipynb
长短期记忆网络(LSTM)是一种应用广泛的循环神经网络。本文将重点讨论如何构建LSTM,并使用它来学习语言。
在阅读本文时,如果遇到不太清楚的基础知识,可以参考以下前序文章:
标准的循环神经网络在处理长距离依赖关系时表现不佳,这限制了它在自然语言处理等领域的应用。从前面的讨论中可以看出,这种模型似乎只能从距离较近的数据中获取信息,学术上将这一特性形象地称为短期记忆。为了改进这一问题,学术界引入了经典的长短期记忆网络(Long Short-Term Memory,LSTM)1。这个名字有点奇怪,经常让人感到困惑,实际上,正确的断句方式应该是“长-短期记忆-网络”。这种断句方式能够准确地表达出这个模型想要解决的问题,即如何在模型中长时间地保留短期记忆,使模型能够更有效地处理长距离依赖关系。
LSTM是一种比较古老的模型,它的设计可以追溯到1995年(正式文章于1997年发表)。这个模型的设计非常巧妙,但由于其结构复杂,难以训练,因此在很长一段时间内只被视为学术界的研究课题,鲜有成功的业界案例。随着GPU计算和模型训练技术的发展,这个模型逐渐在语音识别、文本翻译、游戏等领域取得了广泛的应用。其发展历程充分体现了神经网络是一门非常注重实践的学科。一个模型要想成功,不仅要考虑模型理论结构的优雅,还需要关注如何在工程上高效落地,这在实际场景中往往更加关键。
下面将深入探讨LSTM的细节,并提供代码实现以展示如何在自然语言处理领域应用它。
这篇文章的第六部分已经从反向传播的角度直观地介绍了短期记忆,但由于尚未在数学上对其进行严格的定义和讨论,我们对这个问题的理解仍然不够深刻。实际上,短期记忆问题在神经网络领域至关重要,为了解决这一问题,涌现了一系列著名的模型,其中包括本节讨论的长短期记忆网络,以及当前备受追捧的大语言模型。因此,本小节的核心内容是深入探讨短期记忆的数学原理和可能的解决方法。
在循环神经网络中,隐藏状态是模型进行预测的基础,但它只有相当短的记忆能力。以自然语言处理为例,每一步的隐藏状态理论上应该是从文本到当前位置的特征表示,然而实际上,每一步的隐藏状态只能记录最近几个词元的信息。
由于隐藏状态的更新需要使用激活函数,使得短期记忆问题在数学上变得比较烦琐。然而,忽略激活函数的存在并不会影响我们理解问题的关键或得出结论2。因此,接下来的讨论将不考虑激活函数,以简化数学推导过程。
图1中引入了一些数学记号: X k X_k Xk代表输入数据, H i H_i Hi代表相应的隐藏状态,它们的形状分别为 1 × c 1 × c 1×c和 1 × h 1 × h 1×h。在隐藏状态的更新过程中,模型对前一个隐藏状态和当前输入数据进行张量拼接,然后进行线性变换。
为了更清晰地理解隐藏状态的更新细节,将处理隐藏状态的参数表示为 W h W_h Wh,将处理输入数据的参数表示为 W v W_v Wv。使用矩阵乘法(参考图1中标记1),可以得到隐藏状态的数学表达式,如公式(1)所示。
H i = ∑ k = 1 i X k W v W h i − k (1) H_i= \sum_{k=1}^iX_k W_v W_h^{i-k} \tag{1} Hi=k=1∑iXkWvWhi−k(1)
现在关注图1中的标记2。在计算中,引入了一个新变量 V k V_k Vk,它等于 X k X_k Xk与 W v W_v Wv的乘积。这个新变量只由词元本身所决定,与它在文本中的位置无关,因此可以将新变量看作词元本身的特征表示。基于这一理念,将隐藏状态的数学表达式改写成如下形式:
H i = ∑ k = 1 i V k W h i − k (2) H_i= \sum_{k=1}^iV_k W_h^{i-k} \tag{2} Hi=k=1∑iVkWhi−k(2)
在公式(2)中,可以将 W h i − k W_h^{i-k} Whi−k理解为权重,那么隐藏状态可以被视为所有词元特征的一种加权平均。或者更形象地说,把 V k V_k Vk看作词元所带来的记忆,那么隐藏状态就是所有词元记忆的加和。通常情况下,模型参数 W h W_h Wh分布在接近0的范围内,这导致当距离时间步较远时, W h i − k W_h^{i-k} Whi−k会迅速趋近于0。因此,在隐藏状态中,只有距离较近的词元的记忆会被保留,这就是所谓的短期记忆现象。
从上面的公式可以看出,循环神经网络存在一些不太令人满意的问题。隐藏状态是所有词元记忆的加和,但是每个记忆的权重主要受到距离的影响,这体现为公式(2)中的乘方项。当距离较远时,由于乘方效应,权重会迅速减小。模型难以表达元素之间的相似关系,也就无法有效处理长距离依赖关系。考虑这个句子:我在家里养了一只小猫,每天清晨,我的宠物小猫都会过来叫我起床。当模型处理到“宠物”这个词时,它需要与前面的“小猫”建立强关联。换句话说,对于这一步的隐藏状态,“小猫”的权重应该很大,这样可以帮助模型预测下一个词元也是“小猫”。然而,公式(2)表明模型无法有效处理这种长距离的依赖关系。为了解决这个问题,需要重新设计权重项,使其能够更好地反映词元之间的相关性。这正是注意力机制(Attention Mechanism)的主要改进之处,相关细节将在后续的文章[TODO]中详细讨论。
如果进一步将 W h W_h Wh限定为一个数量矩阵(Scalar Matrix),那么可以用矩阵乘法来表示所有隐藏状态的计算,如图2所示。与循环神经网络中的串行处理相比,这种计算方式可以进行并行计算,更加高效。从另一个角度来看,基于下三角矩阵的矩阵乘法在某种程度上等同于循环的累加过程。按照这个思路,我们可以对循环神经网络中的循环部分进行调整,在保留核心特性的同时提高计算效率。这也是注意力机制的另一个创新点,具体细节请参考后续的文章[TODO]。
在标准循环神经网络中,每个神经元由一个线性变换和一个激活函数组成。神经元接收前一个隐藏状态和当前输入两个张量作为输入,得到当前的隐藏状态并将其输出。隐藏状态有两作用:参与下一个隐藏状态的计算和预测当前数据的标签。
长短期记忆网络的改进主要体现在神经元的结构上。与标准循环神经网络不同,它引入了更复杂的神经元结构,包括两个关键状态:细胞状态(Cell State)和隐藏状态。隐藏状态的作用类似于标准循环神经网络,细胞状态用于长距离信息传递。具体来说,在长短期记忆网络中,神经元接受3个张量作为输入,分别是前一个细胞状态、前一个隐藏状态,以及当前输入。神经元会根据内部算法来更新细胞状态和隐藏状态,并生成相应的输出。需要强调的是,通常只使用隐藏状态来预测当前数据的标签。
长短期记忆网络神经元的运算相对复杂,不再只是简单的线性和非线性叠加。这是因为它需要同时维护和更新两个状态,以确保细胞状态能够有效地传递长距离的信息。为了更好地处理这一任务,模型引入了一个重要的模型组件——门控(Gate)。门控的作用是决定哪些信息应该被保留,哪些信息应该被遗忘。虽然这个概念看起来有些抽象,但在数学上非常清晰。可以将这一过程表示为张量乘法: r = g ∗ x r = g * x r=g∗x。其中, x x x是一个 1 × h 1 × h 1×h的张量,表示备选的信息; g g g同样是一个 1 × h 1 × h 1×h的张量,其值在0到1之间变化。当 g g g的某个分量等于1时, x x x中对应位置上的信息被完全保留;当 g g g的某个分量等于0时,x中对应位置上的信息被完全遗忘;当 g g g的分量处于0和1之间时,表示只保留部分信息。
引入门控的概念后,模型的计算流程可以被概括如下。
将上述的计算流程应用到自然语言处理领域,以便更清晰地理解细胞状态和隐藏状态在学习语言时所扮演的不同角色。细胞状态并不直接与当前输入进行互动,因此它主要用存储文本知识。与此不同,隐藏状态与当前输入密切合作,专注于处理新信息,包括生成新的信息和更新文本知识等。这样明确的分工巧妙地平衡了信息的长期积累和短期处理,使得模型能够高效地处理长距离的依赖关系。
前文对于模型结构的讨论可能会给初学者带来困扰,模型的图示看起来相当复杂,难以理解。此外,烦琐的数学推导也可能让一些读者感到抽象。为了帮助读者更好地理解模型细节,下面将上述的图示翻译成代码。这往往是理解模型的最佳途径,尽管模型看起来复杂,但其代码实现相对更加清晰。
在长短期记忆网络中,神经元的核心包括两个状态,即细胞状态和隐藏状态。在代码实现中,通常使用相同形状的张量来表示它们(也可以选择使用不同形状的张量,但这只会增加代码实现的复杂性,没有任何实际益处)。在这个设定下,图4(完整代码)展示了神经元的核心实现。其结构看起来复杂,但基本要素仍然是线性模型和非线性变换。
首先是实现门控这个核心组件:由于门控的输出范围在0到1之间,所以使用线性模型与Sigmoid函数的组合来实现它。图4中的变量 ingate、forgetgate 和 outgate 分别对应输入门、遗忘门和输出门。这些门控的作用非常关键,有助于控制信息的流动和细胞状态的更新。
接下来讨论细胞状态的更新过程。为了更清晰地理解这一过程,我们将模型置于自然语言处理的背景中。首先利用Tanh函数和线性模型生成备选细胞状态(ncs)。输入门和备选细胞状态的乘积可以被理解为新添加的文本知识,遗忘门和细胞状态(cs)的乘积代表从上一步保留下来的文本知识,将这两者相加,便得到了新的细胞状态。
在这个更新过程中,有两点特别关键的内容。
隐藏状态的更新与细胞状态类似,因此不再详细讨论具体过程。
一旦实现了神经元,搭建长短期记忆网络就变得相对简单了,它与循环神经网络的实现非常相似,在此就不展示具体的实现步骤了。但需要提醒的是,对于复杂的模型实现,确保代码的准确性是具有挑战性的任务。有些数学细节中即使出现错误,程序仍然能够正常运行,因此很难发现问题。一种有效的方法是查找其他人的实现代码,并将两者的计算结果进行比对。对于经典模型,PyTorch等深度学习框架提供了相应的封装,可以通过比对这些封装来确保程序的正确性。此外,通过这个过程,读者还可以加深对模型的理解,提高对开源工具的掌握水平。
为了完成Python语言的学习任务,我们搭建了一个多层长短期记忆网络6,如程序清单1所示。为了提高模型训练的效率,除使用随机失活外,还引入了归一化层(归一化层的基础知识请参考相关文献[TODO]),这些改进可以在第23—25行代码中看到。在模型中还可以增加其他优化模型训练的技术,但对于长短期记忆网络(或更一般的循环神经网络),归一化层是最具讨论价值的。
1 | class LSTM(nn.Module): 2 | ...... 3 | 4 | class CharLSTM(nn.Module): 5 | 6 | def __init__(self, vs): 7 | super().__init__() 8 | self.emb_size = 256 9 | self.hidden_size = 128 10 | self.embedding = nn.Embedding(vs, self.emb_size) 11 | self.dp = nn.Dropout(0.4) 12 | self.lstm1 = LSTM(self.emb_size, self.hidden_size) 13 | self.norm1 = nn.LayerNorm(self.hidden_size) 14 | self.lstm2 = LSTM(self.hidden_size, self.hidden_size) 15 | self.norm2 = nn.LayerNorm(self.hidden_size) 16 | self.lstm3 = LSTM(self.hidden_size, self.hidden_size) 17 | self.norm3 = nn.LayerNorm(self.hidden_size) 18 | self.h2o = nn.Linear(self.hidden_size, vs) 19 | 20 | def forward(self, x): 21 | # x: (B, T) 22 | emb = self.embedding(x) # (B, T, C) 23 | h = self.norm1(self.dp(self.lstm1(emb))) # (B, T, H) 24 | h = self.norm2(self.dp(self.lstm2(h))) # (B, T, H) 25 | h = self.norm3(self.dp(self.lstm3(h))) # (B, T, H) 26 | output = self.h2o(h) # (B, T, vs) 27 | return output
首先,需要明确的是,归一化层分为层归一化和批归一化。在循环神经网络中,通常采用层归一化的方法。这是因为批归一化的做法是对同一批次的数据计算平均值和标准差,再进行归一化处理。这种方法的隐含假设是同一批次的数据具有相似性。然而,在序列数据中,这个假设通常不成立。以自然语言处理为例,相同的文字在文本中的不同位置可能具有完全不同的语义。因此,在不同位置的数据之间进行归一化处理在理论上是不太合适的。即使按照序列的不同位置分别计算统计信息,再做归一化,使用批归一化也会遇到困难:在使用模型时,如果遇到了比所有训练数据更长的序列,那么批归一化就难以处理了。
其次,关于归一化层的使用,可以像上述方法一样,在每一层的后面都添加归一化层。这样确实能提升模型的训练效率,但它与设计归一化层时的初衷有些不符。最初设计归一化层的目的是确保不同层的激活函数具有相似的输入,以加速模型的训练过程。因此,最佳实践是将归一化层直接放在激活函数之前,而不是在不同层之间插入归一化层。
上述的最佳实践建议将归一化操作嵌入神经元内部。具体而言,需要重新设计长短期记忆网络的神经元,示例代码如程序清单2所示。请特别留意第20行代码,在应用非线性变换之前执行归一化操作7。此外,值得注意的是,输入门、遗忘门、备选细胞状态和输出门的输入都是相同的,因此可以将这4个组件的线性变换合并在一起,如第8行和第9行所示。在需要时,可以利用第20行的chunk函数将它们分开。这种实现方式在PyTorch等开源工具中十分常见,能够使代码变得更加清晰和简洁。
1 | class LSTMLayerNormCell(nn.Module): 2 | 3 | def __init__(self, input_size, hidden_size): 4 | super().__init__() 5 | self.input_size = input_size 6 | self.hidden_size = hidden_size 7 | combined_size = self.input_size + self.hidden_size 8 | self.gates = nn.Linear( 9 | combined_size, 4 * self.hidden_size, bias=False) 10 | self.ln_gates = nn.LayerNorm(4 * self.hidden_size) 11 | self.ln_c = nn.LayerNorm(self.hidden_size) 12 | 13 | def forward(self, inputs, state=None): 14 | B, _ = inputs.shape # (B, I) 15 | # state: ((B, H), (B, H)) 16 | if state is None: 17 | state = self.init_state(B, inputs.device) 18 | hs, cs = state 19 | combined = torch.cat((inputs, hs), dim=1) # (B, I + H) 20 | i, f, c, o = self.ln_gates(self.gates(combined)).chunk(4, 1) 21 | ingate = F.sigmoid(i) # (B, H) 22 | forgetgate = F.sigmoid(f) # (B, H) 23 | outgate = F.sigmoid(o) # (B, H) 24 | # 更新细胞状态 25 | ncs = F.tanh(c) # (B, H) 26 | cs = self.ln_c((forgetgate * cs) + (ingate * ncs)) # (B, H) 27 | # 更新隐藏状态 28 | hs = outgate * F.tanh(cs) # (B, H) 29 | return hs, cs 30 | ......
这两个模型对相同的数据进行训练,可以获得相似的结果,如图5所示8。需要注意的是,将归一化操作嵌入神经元内部的效果更佳,更符合归一化层的设计原理。
归一化层的使用反映了神经网络领域中两个看似矛盾的特点。一方面,神经网络的构建非常灵活,只要输入/输出的张量形状能够互相匹配,模型组件可以随意组合。除了极少数情况,几乎不存在绝对正确或错误的结构。因此,在构建模型时,可以充分发挥创造力和想象力。另一方面,每个模型组件都有一些隐含的最佳使用方式,这需要我们深入了解组件的细节。只有尽可能遵循最佳实践,才能充分发挥组件的潜力。
这个模型的创始人是来自德国的学者,模型的名称也反映了德语中喜欢合成复合词的习惯。 ↩︎
通常情况下,循环神经网络采用的激活函数是ReLU。当输入大于0时,ReLU表现为一个恒等变换;当输入小于0时,则将输入映射为0。将小于0的值映射为0代表完全遗忘,这进一步加重了短期记忆问题。如果激活函数是Tanh,由于在0附近的导数等于1,也近似于恒等变换。 ↩︎
Sigmoid函数在模型中的主要作用是进行信息筛选。根据门控的定义,它的输出范围被限制在0到1之间,因此通常情况下,很难将这个函数替换为其他函数。在某些文献中, Sigmoid函数也被称为门控激活函数。 ↩︎
在长短期记忆网络被发明时,ReLU函数并没有出现,不过几乎可以确定,如果在模型中使用ReLU函数,会得到更优的模型性能。 ↩︎
长短期记忆网络在近30年的发展历程中,一直秉持着其核心思想,但其具体结构和组件发生了多次演化和改进。因此,在学习神经网络时,我们应该灵活思考,深入理解其背后的原理,而不是死记各种网络的结构和数学公式。 ↩︎
这个模型包含数十万个参数,如果使用CPU进行计算,需要较长的时间,然而在自然语言处理领域,这仍然是一个很“迷你”的模型。 ↩︎
将归一化操作嵌入神经元还有其他的实现方式,例如,将当前输入和隐藏状态分开处理,分别对它们进行线性变换和归一化操作。 ↩︎
这并不是模型的极限,可以继续训练模型以进一步改善预测效果。另外,尽管应用随机失活技术降低了过拟合的风险,但随着模型规模的增加,过拟合仍然是一个常见问题。在这种情况下,可以考虑在模型损失函数中添加一些惩罚项来解决这个问题。 ↩︎
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。