相关说明
这篇文章的大部分内容参考自我的新书《解构大语言模型:从线性回归到通用人工智能》,欢迎有兴趣的读者多多支持。
本文涉及到的代码链接如下:regression2chatgpt/ch10_rnn/char_mlp.ipynb、regression2chatgpt/ch10_rnn/embedding_example.ipynb
本文将介绍如何利用多层感知器(MLP)来自动生成Python代码(Python代码本身也是一种语言),为后续讨论循环神经网络(RNN)做好准备。如果读者对语言数字化、迁移学习、自回归学习等概念不太了解,可以先参考自然语言处理的基本要素。
在本文的基础上,系列的后续文章将着重探讨如何利用循环神经网络(RNN)来改进学习效果:
一、基础准备
自然语言处理的基本要素这篇文章讨论了在迁移学习中,预训练(Pre-training)的三种模式:序列到序列模式、自编码模式和自回归模式。在自回归模式下,我们的目标是根据文本背景来预测下一个词元。在这个过程中,模型需要应对以下两个关键问题。
- 变长输入:随着文本的深入,文本背景的长度逐渐增加,因此模型需要处理不定长的输入数据。
- 文字间的关联:模型必须捕捉文字之间的相互关系,以便得到更准确的预测结果。
然而,作为普通神经网络的代表,传统的多层感知器模型未能满足这两个要求。首先,多层感知器的输入形状是固定的;其次,它的模型结构无法直接捕捉文字间的依赖关系。不过,我们可以通过调整模型输入的方法来巧妙地解决(部分解决)这两个问题,从而成功地将多层感知器用于语言学习。
具体来说,设置一个固定的窗口长度,比如n,然后将模型的输入限制为n个连续的词元。这种方法虽然与自回归模式的要求稍有不同,但仍然能够根据部分文本背景来预测下一个词元。下面将深入讨论这一方法的细节。
二、数据准备
在学习自然语言处理时,首要步骤是获取充足的语言数据,通常称为语料库。本节将使用开源的Python代码作为语料库1,也就是说,尝试使用神经网络来学习如何自动生成Python代码。如图1所示,使用datasets可以方便地获取已经整理好的开源代码。
在处理获取到的Python代码时,需要完成两个关键任务:首先,将文本数字化;其次,根据自回归模式创建训练数据。
- 使用字母分词器实现文本数字化,这意味着训练数据中出现的字符将组成分词器的字典,如程序清单1的第5行所示(完整代码2)。此外,为了标识文本的开头和结尾,引入两个特殊字符“<|b|>”和“<|e|>”分别表示开头和结尾,如第7—9行所示。需要注意的是,这两个特殊字符的具体形式只是为了便于理解,它们并不匹配任何实际的字符。
1 | class char_tokenizer:
2 |
3 | def __init__(self, data, begin_ind=0, end_ind=1):
4 | # 数据中出现的所有字符构成字典
5 | chars = sorted(list(set(''.join(data))))
6 | # 预留两个位置给开头和结尾的特殊字符
7 | self.char2ind = {s : i + 2 for i, s in enumerate(chars)}
8 | self.char2ind['<|b|>'] = begin_ind
9 | self.char2ind['<|e|>'] = end_ind
10 | self.begin_ind = begin_ind
11 | self.end_ind = end_ind
12 | self.ind2char = {i : s for s, i in self.char2ind.items()}
13 | ......
14 |
15 | def autoregressive_trans(text, tokenizer, context_length=10):
16 | inputs, labels = [], []
17 | b_ind = tokenizer.begin_ind
18 | e_ind = tokenizer.end_ind
19 | enc = tokenizer.encode(text)
20 | # 增加开头和结尾的特殊字符
21 | x = [b_ind] * context_length + enc + [e_ind]
22 | for i in range(len(x) - context_length):
23 | inputs.append(x[i: i + context_length])
24 | labels.append(x[i + context_length])
25 | return inputs, labels
26 |
27 | # 举例展示自回归模式的训练数据
28 | tok = char_tokenizer(datasets['whole_func_string'])
29 | example_text = 'def postappend(self):'
30 | inputs, labels = autoregressive_trans(example_text, tok)
31 | for a, b in zip(inputs, labels):
32 | print(''.join(tok.decode(a)), '--->', tok.decode(b))
33 | <|b|><|b|><|b|><|b|><|b|><|b|><|b|><|b|><|b|><|b|> ---> d
34 | <|b|><|b|><|b|><|b|><|b|><|b|><|b|><|b|><|b|>d ---> e
35 | <|b|><|b|><|b|><|b|><|b|><|b|><|b|><|b|>de ---> f
36 | <|b|><|b|><|b|><|b|><|b|><|b|><|b|>def --->
37 | <|b|><|b|><|b|><|b|><|b|><|b|>def ---> p
38 | ......
- 在自回归模式下,创建训练数据的过程涉及将背景文本与预测的词元进行配对。具体的实现方法可以参考第22—24行代码。由于窗口长度是固定的,因此在处理文本开头部分时,需要使用表示开始的特殊字符(<|b|>)来填充文本,以确保模型能够学习文本的最初部分,如第21行所示。此外,在文本的末尾也需要加上一个表示结束的特殊字符(<|e|>),这个字符会让模型学会如何结束一个文本(何时停止生成文本)。值得注意的是,由于自回归模式的工作原理,一个字符串(原始的Python代码)会对应多条训练数据,如第28—37行所示。
在完成上述两项准备工作的基础上,就能够将原始数据转换为适用于模型的训练数据了,如图2所示。尽管模型看到的是数字,输出的也是数字,但通过适当的映射和处理,模型也能够与人进行交流3。这或许更好地诠释了“语言是思想的影子”(Language is the shadow of thought)这一理念。语言背后的思想远比使用的文字更为重要。回到技术层面,在进行数据转换时,由于涉及一对多的关系,需要使用批量映射(Batch Mapping)操作。有关该操作的细节,请参考相关开源工具的官方文档4。
三、文本嵌入
有些读者可能会感到困惑,上篇文章中,二、语言数字化介绍的语言数字化需要将每个词元转换成 1 × n 1 × n 1×n的张量(具体细节请参考上篇文章中的图2,其中 n n n表示字典大小),但是在本文的图2中,只是将它们转换成了数字(数值表示词元在字典中的位置)。这其中的原因是什么呢?
下面通过一个简单的例子来解释这个问题。假设有一个包含26个字母的字典,命名为char2indx,现在需要将文本“love”数字化。
- 使用字典将文本转换为 1 × 4 1×4 1×4的张量,其中每个位置的数值表示字母在字典中的位置,如图3中标记1所示。
- 在此基础上,可以对结果进行独热编码,得到一个 4 × 26 4×26 4×26的张量,如图3中标记2所示。
- 然而,使用上述处理方式得到的结果并不会为模型提供足够的有用信息。为了进一步提升数据的表达能力,假设每个词元的语义可以用一个 1 × 5 1×5 1×5的张量来表示。将这个张量的每个维度想象为某种语言特征,而词元与特征张量之间的映射关系是通过模型学习得来的。这一关键操作被称为文本嵌入(Embedding)。在代码层面,文本嵌入是通过张量乘法来实现的,具体请参考图3中标记3。文本嵌入的目的是提供更具信息量的特征表示,以便神经网络更好地理解和处理文本数据。
对于第2步和第3步,PyTorch提供了一种更方便的实现方式,如图3中标记4所示。这种方法更高效,因此在实际应用中通常倾向于使用这种方式,而不是手动实现独热编码后再完成文本嵌入。
根据前面的讨论,我们可以实现一个简化版本的文本嵌入,示例代码如程序清单2(完整代码)所示。在进行文本嵌入时,只需传入文本在字典中的位置即可,无须进行不必要的独热编码(见第13—15行)。事实上,PyTorch也提供了文本嵌入的封装nn.Embedding,这个封装要求的输入格式与程序清单2一致,这也解释了为什么在程序清单1的实现中没有包含独热编码的步骤。
1 | class Embedding:
2 |
3 | def __init__(self, num_embeddings, embedding_dim):
4 | self.weight = torch.randn((num_embeddings, embedding_dim))
5 |
6 | def __call__(self, idx):
7 | self.out = self.weight[idx]
8 | return self.out
9 |
10 | def parameters(self):
11 | return [self.weight]
12 |
13 | emb = Embedding(num_claz, dims)
14 | idx.shape, emb(idx).shape
15 | (torch.Size([4]), torch.Size([4, 5]))
四、完整代码
在前两节的基础上,利用多层感知器模型学习语言就变得简单和直接了,具体的代码如程序清单3所示。以下是关键的数据转换步骤。
- 如第11行代码所示,输入数据的形状为(B, 10),其中B代表批量大小,10表示窗口长度。在这个维度下,每个分量表示相应词元在字典中的位置,读者可以参考图2中的结果来更好地理解这一点。
- 文本嵌入的长度是30,也就是使用30个特征来刻画一个词元。因此,经过嵌入处理后,数据的形状变为(B, 10, 30),如第12行代码所示。
- 为了将数据传递给多层感知器进行学习,就像在图像识别中一样,将数据转换成形状为(B, 300)的张量,如第13行代码所示。一旦数据被展平,后续的计算就与标准的多层感知器一致了。
- 在构建模型时,需要确定字典的大小,即第3行中的参数vs。这个参数用于构建文本嵌入层(第5行)和最终输出层(第8行)。因此,当字典规模非常庞大时,模型的参数数量会急剧增加,从而导致模型难以训练。这也解释了三、分词器的语言基础中的观点:字典的规模应该适中。
1 | class CharMLP(nn.Module):
2 |
3 | def __init__(self, vs):
4 | super().__init__()
5 | self.embedding = nn.Embedding(vs, 30)
6 | self.hidden1 = nn.Linear(300, 200)
7 | self.hidden2 = nn.Linear(200, 100)
8 | self.out = nn.Linear(100, vs)
9 |
10 | def forward(self, x):
11 | B = x.shape[0] # (B, 10)
12 | emb = self.embedding(x) # (B, 10, 30)
13 | h = emb.view(B, -1) # (B, 300)
14 | h = F.relu(self.hidden1(h)) # (B, 200)
15 | h = F.relu(self.hidden2(h)) # (B, 100)
16 | h = self.out(h) # (B, vs)
17 | return h
有了搭建好的模型,我们就可以循环使用它来生成Python代码了,甚至可以在进行模型训练之前就生成代码,如图4所示。在模型训练之前,生成的Python代码看起来毫无意义。但通过简单的模型训练后,模型生成的结果已经具备了一些代码的雏形。在本例中,模型已经掌握了一些关键字(比如“def ”),以及一些常见的英语单词(比如“name”)。此外,模型还学到了Python代码的基本结构,例如使用缩进来表示代码块。
五、普通神经网络的缺陷
本文的示例中介绍了如何使用普通神经网络来处理序列数据。从上述内容中可以了解到,尽管普通神经网络可以通过一些技巧来处理序列数据,但存在一些明显的限制和缺陷。
首先,由于模型结构的限制,普通神经网络的输入和输出通常是固定大小的张量。在上面的示例中,模型的输入是长度等于10的字符串,而模型的输出是下一个字符的概率分布。这意味着模型难以有效地处理可变长度的输入序列,在某些任务上的表现受限。例如,如果下一个字符的分布概率与窗口之外的字符有关(相关字符在文本中的距离超过10),那么本节搭建的模型就难以捕捉这种长距离的依赖关系。
其次,在普通神经网络中,数据的转换步骤是固定的。例如,在多层感知器模型中,数据的转换步骤由模型的层数决定,这些步骤是预先定义的,无法根据数据或特定任务的属性进行自适应调整,也就无法最优地学习复杂的序列数据。
最后,从学习效率的角度来看,在模型训练过程中,尽管训练数据中的文本窗口是互相重叠的,但模型在处理时却将它们视为独立的。换句话说,窗口重叠部分的信息每次都被重复计算,这会导致大量计算资源的浪费,从而影响模型训练的效率。
这些问题促使我们寻求新的模型结构,以更好地对序列数据进行建模。后续的文章将深入讨论一种强大的神经结构——循环神经网络,它能够有效地解决上述问题。
模型(如ChatGPT)通过学习开源代码获得了极强的推理能力。从书写系统的角度来看,代码类似于英文,这也是它们在英文方面表现更佳的一个原因。
通过这个例子可以看到,在自然语言处理领域,语言的范围已经不再局限于传统的文本,而是包括所有可以用文字记录的内容。我们可以将文中使用的数据从Python代码替换成数学公式,这样模型就能够展现其在数学方面的学习能力。这体现了大语言模型的多功能性和广泛应用性。 ↩︎在进行自然语言处理时,由于计算量较大,模型的训练时间较长,因此本文提供的代码会优先在GPU上运行。如果读者的计算机上没有GPU,由于随机数的影响,模型的结果可能略有不同。 ↩︎
所有进行自然语言处理的模型(比如ChatGPT)都是如此,它们学习和预测的都只是数字而已。 ↩︎
关于批量映射的细节,相应文档的描述其实并不清晰。熟悉大数据处理的读者可以参考PySpark中的mapPartitions和flatMap操作,批量映射实际上可以看作这两种操作的结合。 ↩︎