利用神经网络学习语言(二)——利用多层感知器(MLP)学习语言

相关说明

这篇文章的大部分内容参考自我的新书《解构大语言模型:从线性回归到通用人工智能》,欢迎有兴趣的读者多多支持。
本文涉及到的代码链接如下:regression2chatgpt/ch10_rnn/char_mlp.ipynbregression2chatgpt/ch10_rnn/embedding_example.ipynb

本文将介绍如何利用多层感知器(MLP)来自动生成Python代码(Python代码本身也是一种语言),为后续讨论循环神经网络(RNN)做好准备。如果读者对语言数字化、迁移学习、自回归学习等概念不太了解,可以先参考自然语言处理的基本要素

在本文的基础上,系列的后续文章将着重探讨如何利用循环神经网络(RNN)来改进学习效果:

一、基础准备

自然语言处理的基本要素这篇文章讨论了在迁移学习中,预训练(Pre-training)的三种模式:序列到序列模式、自编码模式和自回归模式。在自回归模式下,我们的目标是根据文本背景来预测下一个词元。在这个过程中,模型需要应对以下两个关键问题。

  • 变长输入:随着文本的深入,文本背景的长度逐渐增加,因此模型需要处理不定长的输入数据。
  • 文字间的关联:模型必须捕捉文字之间的相互关系,以便得到更准确的预测结果。

然而,作为普通神经网络的代表,传统的多层感知器模型未能满足这两个要求。首先,多层感知器的输入形状是固定的;其次,它的模型结构无法直接捕捉文字间的依赖关系。不过,我们可以通过调整模型输入的方法来巧妙地解决(部分解决)这两个问题,从而成功地将多层感知器用于语言学习。

具体来说,设置一个固定的窗口长度,比如n,然后将模型的输入限制为n个连续的词元。这种方法虽然与自回归模式的要求稍有不同,但仍然能够根据部分文本背景来预测下一个词元。下面将深入讨论这一方法的细节。

二、数据准备

在学习自然语言处理时,首要步骤是获取充足的语言数据,通常称为语料库。本节将使用开源的Python代码作为语料库1,也就是说,尝试使用神经网络来学习如何自动生成Python代码。如图1所示,使用datasets可以方便地获取已经整理好的开源代码。

图1

图1

在处理获取到的Python代码时,需要完成两个关键任务:首先,将文本数字化;其次,根据自回归模式创建训练数据。

  1. 使用字母分词器实现文本数字化,这意味着训练数据中出现的字符将组成分词器的字典,如程序清单1的第5行所示(完整代码2)。此外,为了标识文本的开头和结尾,引入两个特殊字符“<|b|>”和“<|e|>”分别表示开头和结尾,如第7—9行所示。需要注意的是,这两个特殊字符的具体形式只是为了便于理解,它们并不匹配任何实际的字符。
程序清单1 创建训练数据
 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 |  ......
  1. 在自回归模式下,创建训练数据的过程涉及将背景文本与预测的词元进行配对。具体的实现方法可以参考第22—24行代码。由于窗口长度是固定的,因此在处理文本开头部分时,需要使用表示开始的特殊字符(<|b|>)来填充文本,以确保模型能够学习文本的最初部分,如第21行所示。此外,在文本的末尾也需要加上一个表示结束的特殊字符(<|e|>),这个字符会让模型学会如何结束一个文本(何时停止生成文本)。值得注意的是,由于自回归模式的工作原理,一个字符串(原始的Python代码)会对应多条训练数据,如第28—37行所示。

在完成上述两项准备工作的基础上,就能够将原始数据转换为适用于模型的训练数据了,如图2所示。尽管模型看到的是数字,输出的也是数字,但通过适当的映射和处理,模型也能够与人进行交流3。这或许更好地诠释了“语言是思想的影子”(Language is the shadow of thought)这一理念。语言背后的思想远比使用的文字更为重要。回到技术层面,在进行数据转换时,由于涉及一对多的关系,需要使用批量映射(Batch Mapping)操作。有关该操作的细节,请参考相关开源工具的官方文档4

图2

图2

三、文本嵌入

有些读者可能会感到困惑,上篇文章中,二、语言数字化介绍的语言数字化需要将每个词元转换成 1 × n 1 × n 1×n的张量(具体细节请参考上篇文章中的图2,其中 n n n表示字典大小),但是在本文的图2中,只是将它们转换成了数字(数值表示词元在字典中的位置)。这其中的原因是什么呢?

下面通过一个简单的例子来解释这个问题。假设有一个包含26个字母的字典,命名为char2indx,现在需要将文本“love”数字化。

  1. 使用字典将文本转换为 1 × 4 1×4 1×4的张量,其中每个位置的数值表示字母在字典中的位置,如图3中标记1所示。
  2. 在此基础上,可以对结果进行独热编码,得到一个 4 × 26 4×26 4×26的张量,如图3中标记2所示。
  3. 然而,使用上述处理方式得到的结果并不会为模型提供足够的有用信息。为了进一步提升数据的表达能力,假设每个词元的语义可以用一个 1 × 5 1×5 1×5的张量来表示。将这个张量的每个维度想象为某种语言特征,而词元与特征张量之间的映射关系是通过模型学习得来的。这一关键操作被称为文本嵌入(Embedding)。在代码层面,文本嵌入是通过张量乘法来实现的,具体请参考图3中标记3。文本嵌入的目的是提供更具信息量的特征表示,以便神经网络更好地理解和处理文本数据。

图3

图3

对于第2步和第3步,PyTorch提供了一种更方便的实现方式,如图3中标记4所示。这种方法更高效,因此在实际应用中通常倾向于使用这种方式,而不是手动实现独热编码后再完成文本嵌入。

根据前面的讨论,我们可以实现一个简化版本的文本嵌入,示例代码如程序清单2(完整代码)所示。在进行文本嵌入时,只需传入文本在字典中的位置即可,无须进行不必要的独热编码(见第13—15行)。事实上,PyTorch也提供了文本嵌入的封装nn.Embedding,这个封装要求的输入格式与程序清单2一致,这也解释了为什么在程序清单1的实现中没有包含独热编码的步骤。

程序清单2 文本嵌入
 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所示。以下是关键的数据转换步骤。

  1. 如第11行代码所示,输入数据的形状为(B, 10),其中B代表批量大小,10表示窗口长度。在这个维度下,每个分量表示相应词元在字典中的位置,读者可以参考图2中的结果来更好地理解这一点。
  2. 文本嵌入的长度是30,也就是使用30个特征来刻画一个词元。因此,经过嵌入处理后,数据的形状变为(B, 10, 30),如第12行代码所示。
  3. 为了将数据传递给多层感知器进行学习,就像在图像识别中一样,将数据转换成形状为(B, 300)的张量,如第13行代码所示。一旦数据被展平,后续的计算就与标准的多层感知器一致了。
  4. 在构建模型时,需要确定字典的大小,即第3行中的参数vs。这个参数用于构建文本嵌入层(第5行)和最终输出层(第8行)。因此,当字典规模非常庞大时,模型的参数数量会急剧增加,从而导致模型难以训练。这也解释了三、分词器的语言基础中的观点:字典的规模应该适中。
程序清单3 多层感知器学习语言
 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代码的基本结构,例如使用缩进来表示代码块。

图4

图4

五、普通神经网络的缺陷

本文的示例中介绍了如何使用普通神经网络来处理序列数据。从上述内容中可以了解到,尽管普通神经网络可以通过一些技巧来处理序列数据,但存在一些明显的限制和缺陷。

首先,由于模型结构的限制,普通神经网络的输入和输出通常是固定大小的张量。在上面的示例中,模型的输入是长度等于10的字符串,而模型的输出是下一个字符的概率分布。这意味着模型难以有效地处理可变长度的输入序列,在某些任务上的表现受限。例如,如果下一个字符的分布概率与窗口之外的字符有关(相关字符在文本中的距离超过10),那么本节搭建的模型就难以捕捉这种长距离的依赖关系。

其次,在普通神经网络中,数据的转换步骤是固定的。例如,在多层感知器模型中,数据的转换步骤由模型的层数决定,这些步骤是预先定义的,无法根据数据或特定任务的属性进行自适应调整,也就无法最优地学习复杂的序列数据。

最后,从学习效率的角度来看,在模型训练过程中,尽管训练数据中的文本窗口是互相重叠的,但模型在处理时却将它们视为独立的。换句话说,窗口重叠部分的信息每次都被重复计算,这会导致大量计算资源的浪费,从而影响模型训练的效率。

这些问题促使我们寻求新的模型结构,以更好地对序列数据进行建模。后续的文章将深入讨论一种强大的神经结构——循环神经网络,它能够有效地解决上述问题。


  1. 模型(如ChatGPT)通过学习开源代码获得了极强的推理能力。从书写系统的角度来看,代码类似于英文,这也是它们在英文方面表现更佳的一个原因。
    通过这个例子可以看到,在自然语言处理领域,语言的范围已经不再局限于传统的文本,而是包括所有可以用文字记录的内容。我们可以将文中使用的数据从Python代码替换成数学公式,这样模型就能够展现其在数学方面的学习能力。这体现了大语言模型的多功能性和广泛应用性。 ↩︎

  2. 在进行自然语言处理时,由于计算量较大,模型的训练时间较长,因此本文提供的代码会优先在GPU上运行。如果读者的计算机上没有GPU,由于随机数的影响,模型的结果可能略有不同。 ↩︎

  3. 所有进行自然语言处理的模型(比如ChatGPT)都是如此,它们学习和预测的都只是数字而已。 ↩︎

  4. 关于批量映射的细节,相应文档的描述其实并不清晰。熟悉大数据处理的读者可以参考PySpark中的mapPartitions和flatMap操作,批量映射实际上可以看作这两种操作的结合。 ↩︎

  • 9
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值