ChatGPT原理与应用开发
上QQ阅读APP看书,第一时间看更新

1.2 语言模型基础

1.2.1 最小语义单位Token与Embedding

首先,我们需要解释一下如何将自然语言文本表示成计算机所能识别的数字。对于一段文本来说,要做的首先就是把它变成一个个Token。你可以将Token理解为一小块,可以是一个字,也可以是两个字的词,或三个字的词。也就是说,给定一个句子,我们有多种获取不同Token的方式,可以分词,也可以分字。英文现在都使用子词,比如单词annoyingly会被拆成如下两个子词。

["annoying", "##ly"]

子词把不在词表里的词或不常见的词拆成比较常见的片段,“##”表示和前一个Token是直接拼接的,没有空格。中文现在基本使用字+词的方式。我们不直接解释为什么这么做,但你可以想一下完全的字或词的效果,拿英文举例更直观。如果只用26个英文字母,虽然词表很小(加上各种符号可能也就100来个),但粒度太细,每个Token(即每个字母)几乎没法表示语义;如果用词,这个粒度又有点太大,词表很难涵盖所有词。而子词可以同时兼顾词表大小和语义表示,是一种折中的做法。中文稍微简单一些,就是字+词,字能独立表示意义,比如“是”“有”“爱”;词是由一个以上的字组成的语义单位,一般来说,把词拆开可能会丢失语义,比如“长城”“情比金坚”。当然,中文如果非要拆成一个一个字也不是不可以,具体要看任务类型和效果。

当句子能够表示成一个个Token时,我们就可以用数字来表示这个句子了,最简单的方法就是将每个Token用一个数字来表示,但考虑这个数字的大小其实和Token本身没有关系,这种单调的表达方式其实只是一种字面量的转换,并不能表示丰富的语言信息。我们稍微多想一点,因为已经有一个预先设计好的词表,那么是不是可以用词表中的每个Token是否在句子中出现来表示?如果句子中包含某个Token,对应位置为1,否则为0,这样每句话都可以表示成长度(长度等于词表大小)相同的1和0组成的数组。更进一步地,还可以将“是否出现”改成“频率”以凸显高频词。事实上,在很长一段时间里,自然语言都是用这种方法表示的,它有个名字,叫作词袋模型(bag of words,BOW)。从名字来看,词袋模型就像一个大袋子,能把所有的词都装进来。文本中的每个词都被看作独立的,忽略词之间的顺序和语法,只关注词出现的次数。在词袋模型中,每个文本可以表示为一个向量,向量的每个维度对应一个词,维度的值表示这个词在文本中出现的次数。这种表示方法如表1-1所示,每一列表示一个Token,每一行表示一个句子,每个句子可以表示成一个长度(就是词表大小)固定的向量,比如第一个句子可以表示为[3,1,1,0,1,1,0,]

表1-1 词袋模型

这里的词表是按照拼音排序的,但这个顺序其实不重要,读者不妨思考一下为什么。另外,注意这里只显示了7列,也就是词表中的7个Token,但实际上,词表中的Token一般都在“万”这个级别。所以,表1-1中的省略号实际上省略了上万个Token。

这种表示方法很好,不过有两个比较明显的问题。第一,由于词表一般比较大,导致向量维度比较高,而且比较稀疏(大量的0),计算起来不太方便;第二,由于忽略了Token之间的顺序,导致部分语义丢失。比如“你爱我”和“我爱你”的向量表示一模一样,但其实意思不一样。于是,词向量(也叫词嵌入)出现了,它是一种稠密表示方法。简单来说,一个Token可以表示成一定数量的小数(一般可以是任意多个,专业叫法是词向量维度,根据所用的模型和设定的参数而定),一般数字越多,模型越大,表示能力越强,不过即使再大的模型,这个维度也会比词表小很多。如下面的代码示例所示,每一行的若干(词向量维度)的小数就表示对应位置的Token,词向量维度常见的值有200、300、768、1536等。

爱    [0.61048, 0.46032, 0.7194, 0.85409, 0.67275, 0.31967, 0.89993, ...]
不    [0.19444, 0.14302, 0.71669, 0.03338, 0.34856, 0.6991, 0.49111, ...]
对    [0.24061, 0.21482, 0.53269, 0.97885, 0.51619, 0.07808, 0.9278, ...]
古琴  [0.21798, 0.62035, 0.89935, 0.93283, 0.24022, 0.91339, 0.6569, ...]
你    [0.392, 0.13321, 0.00597, 0.74754, 0.45524, 0.23674, 0.7825, ...]
完    [0.26588, 0.1003, 0.40055, 0.09484, 0.20121, 0.32476, 0.48591, ...]
我    [0.07928, 0.37101, 0.94462, 0.87359, 0.55773, 0.13289, 0.22909, ...]
... ......................................................................

细心的读者可能会有疑问:“句子该怎么表示?”这个问题非常关键,其实在深度NLP(deep NLP)早期,往往是对句子的所有词向量直接取平均(或者求和),最终得到一个和每个词向量同样大小的向量——句子向量。这项工作最早要追溯到Yoshua Bengio等人于2003年发表的论文“A neural probabilistic language model”,他们在训练语言模型的同时,顺便得到了词向量这个副产品。不过,最终开始在实际中大规模应用,则要追溯到2013年谷歌公司的Tomas Mikolov发布的Word2Vec。借助Word2Vec,我们可以很容易地在大量语料中训练得到一个词向量模型。也正是从那时开始,深度NLP逐渐崭露头角成为主流。

早期的词向量都是静态的,一旦训练完就固定不变了。随着NLP技术的不断发展,词向量技术逐渐演变成基于语言模型的动态表示。也就是说,当上下文不一样时,同一个词的向量表示将变得不同。而且,句子的表示也不再是先拿到词向量再构造句子向量,而是在模型架构设计上做了考虑。当输入句子时,模型经过一定计算后,就可以直接获得句子向量;而且语言模型不仅可以表示词和句子,还可以表示任意文本。类似这种将任意文本(或其他非文本符号)表示成稠密向量的方法,统称Embedding表示技术。Embedding表示技术可以说是NLP领域(其实也包括图像、语音、推荐等领域)最基础的技术,后面的深度学习模型都基于此。我们甚至可以稍微夸张点说,深度学习的发展就是Embedding表示技术的不断发展。

1.2.2 语言模型是怎么回事

语言模型(language model,LM)简单来说,就是利用自然语言构建的模型。自然语言就是我们日常生活、学习和工作中常用的文字。语言模型就是利用自然语言文本构建的,根据给定文本,输出对应文本的模型。

语言模型具体是如何根据给定文本输出对应文本呢?方法有很多种,比如我们写好一个模板:“XX喜欢YY”。如果XX是我,YY是你,那就是“我喜欢你”,反过来就是“你喜欢我”。我们这里重点要说的是概率语言模型,它的核心是概率,准确来说是下一个Token的概率。这种语言模型的过程就是通过已有的Token预测接下来的Token。举个简单的例子,比如你只告诉模型“我喜欢你”这句话,当你输入“我”的时候,它就已经知道你接下来要输入“喜欢”了。为什么?因为它的“脑子”里就只有这4个字。

好,接下来,我们要升级了。假设我们给了模型很多资料,多到现在网上所能找到的资料都给了它。这时候你再输入“我”,此时它大概不会说“喜欢”了。为什么呢?因为见到了更多不同的文本,它的“脑子”里已经不只有“我喜欢你”这4个字了。不过,如果我们考虑的是最大概率,也就是说,每次都只选择下一个最大概率的Token,那么对于同样的给定输入,我们依然会得到相同的对应输出(可能还是“喜欢你”,也可能不是,具体要看给的语料)。对于这样的结果,语言模型看起来比较“呆”。我们把这种方法叫作贪心搜索(greedy search),因为它只往后看一个词,只考虑下一步最大概率的词!为了让生成的结果更加多样和丰富,语言模型都会在这个地方执行一些策略。比如让模型每一步多看几个可能的词,而不是就看概率最大的那个词。这样到下一步时,上一步最大概率的Token,加上这一步的Token,路径概率(两步概率的乘积)可能就不是最大的了。

举个例子,如图1-3所示,先看第一步,如果只选概率最大的那个词,那就变成“我想”了。但是别急,我们给“喜欢”一点机会,同时考虑它们两个。再往下看一步,“喜欢”和“想”后面最大概率的都是“你”,最后就有了下面几句(我们附上了它们的概率)。

“我喜欢你”,概率为0.3×0.8=0.24。

“我喜欢吃”,概率为0.3×0.1=0.03。

“我想你”,概率为0.4×0.5=0.2。

“我想去”,概率为0.4×0.3=0.12。

图1-3 语言模型如何预测下一个词

多看一步大不一样!看看概率最大的成谁了,变成了“我喜欢你”。上面这种方法叫作集束搜索(beam search),简单来说,就是一步多看几个词,看最终句子(比如生成到句号、感叹号或其他停止符号)的概率。在上面的例子中,num_beams=2(只看了两个词),看得越多,越不容易生成固定的文本。

好了,其实在最开始的语言模型中,基本就到这里,上面介绍的两种不同搜索方法(贪心搜索和集束搜索)也叫解码策略。当时更多被研究的还是模型本身,我们经历了从简单模型到复杂模型,再到巨大复杂模型的变迁过程。简单模型就是把一句话拆成一个个Token,然后统计概率,这类模型有个典型代表——N-Gram模型,它也是最简单的语言模型。这里的N表示每次用到的上下文Token的个数。举个例子,看下面这句话:“人工智能让世界变得更美好”。N-Gram模型中的N通常等于2或3,等于2的叫Bi-Gram,等于3的叫Tri-Gram。

Bi-Gram:人工智能/让 让/世界 世界/变得 变得/更 更/美好

Tri-Gram:人工智能/让/世界 让/世界/变得 世界/变得/更 变得/更/美好

Bi-Gram和Tri-Gram的区别是,前者的下一个Token是根据上一个Token来的,而后者的下一个Token是根据前两个Token来的。在N-Gram模型中,Token的表示是离散的,实际上就是词表中的一个个单词。这种表示方式比较简单,再加上N不能太大,导致难以学到丰富的上下文知识。事实上,它并没有用到深度学习和神经网络,只是一些统计出来的概率值。以Bi-Gram为例,在给定很多语料的情况下,统计的是从“人工智能”开始,下个词出现的频率。假设“人工智能/让”出现了5次,“人工智能/是”出现了3次,将它们出现的频率除以所有的Gram数就是概率。

训练N-Gram模型的过程其实是统计频率的过程。如果给定“人工智能”,N-Gram模型就会找基于“人工智能”下个最大概率的词,然后输出“人工智能让”。接下来就是给定“让”,继续往下走了。当然,我们也可以用上面提到的不同解码策略往下走。

接下来,让每个Token成为一个Embedding向量。我们简单解释一下在这种情况下怎么预测下一个Token。其实还是计算概率,但这次和刚才的稍微有点不一样。在刚才离散的情况下,用统计出来的对应Gram数除以Gram总数就是出现概率。但是稠密向量要稍微换个方式,也就是说,给你一个d维的向量(某个给定的Token),你最后要输出一个长度为N的向量,N是词表大小,其中的每一个值都是一个概率值,表示下一个Token出现的概率,概率值加起来为1。按照贪心搜索解码策略,下一个Token就是概率最大的那个,写成简单的计算表达式如下。

# d维,加起来和1没关系,大小是1×d,表示给定的Token
X = [0.001, 0.002, 0.0052, ..., 0.0341]
# N个,加起来为1,大小是1×N,表示下一个Token就是每个Token出现的概率
Y = [0.1, 0.5, ..., 0.005, 0.3]
# W是模型参数,也可以叫模型
X·W = Y  # W可以是 d×N 大小的矩阵

上面的W就是模型参数,其实X也可以被看作模型参数(自动学习到的)。因为我们知道了输入和输出的大小,所以中间其实可以经过任意的计算,也就是说,W可以包含很多运算。总之各种张量(三维以上数组)运算,只要保证最后的输出形式不变就行。各种不同的计算方式就意味着各种不同的模型。

在深度学习早期,最著名的语言模型是使用循环神经网络(recurrent neural network,RNN)训练的,RNN是一种比N-Gram模型复杂得多的模型。RNN与其他神经网络的不同之处在于,RNN的节点之间存在循环连接,这使得它能够记住之前的信息,并将它们应用于当前的输入。这种记忆能力使得 RNN 在处理时间序列数据时特别有用,例如预测未来的时间序列数据、进行自然语言的处理等。通俗地说,RNN就像具有记忆能力的人,它可以根据之前的经验和知识对当前的情况做出反应,并预测未来的发展趋势,如图1-4所示。

图1-4 RNN(摘自Colah的博客文章“Understanding LSTM Networks”)

在图1-4中,右边是左边的展开,A就是参数,x是输入,h就是输出。自然语言是一个Token接着一个Token(Token by Token)的,从而形成一个序列。参数怎么学习呢?这就要稍微解释一下学习(训练)过程。

如图1-5所示,第一行就是输入X,第二行就是输出Y,SOS(start of sentence)表示句子开始,EOS(end of sentence)表示句子结束。注意,图1-4中的h并不是那个输出的概率,而是隐向量。如果需要概率,可以再对h执行张量运算,归一化到整个词表即可。

图1-5 语言模型学习(训练)时的输入输出

import torch
import torch.nn as nn
 
rnn = nn.RNN(32, 64)
input = torch.randn(4, 32)
h0 = torch.randn(1, 64)
output, hn  = rnn(input, h0)
output.shape, hn.shape
# (torch.Size([4, 64]), torch.Size([1, 64]))

上面的nn.RNN就是RNN模型。输入是一个4×32的向量,换句话说,输入是4个Token,维度d=32h0就是随机初始化的输出,也就是4个Token中第1个Token的输出,这里output的4个64维的向量分别表示4个输出。hn就是最后一个Token的输出(它和output的最后一个64维向量是一样的),也可以看成整个句子的表示。注意,这里的output和图1-5中的输出Y还没有关系。别急,继续往下看。如果要输出词的概率,就需要先扩充到词表大小,再进行归一化。

# 假设词表大小N=1000
wo = torch.randn(64, 1000)
# 得到4×1000的概率矩阵,每一行概率和为1
probs = nn.Softmax(dim=1)(output @ wo)
probs.shape, probs.sum(dim=1)
# torch.Size([4, 1000]), tensor([1.0000, 1.0000, 1.0000, 1.0000],
# grad_fn=<SumBackward1>)

这里的probs的每一行就是词表大小的概率分布,概率和为1,意思是根据当前Token生成下一个Token的概率,下一个Token有可能是词表中的任意一个Token,但它们的概率和一定为1。因为我们知道接下来每个位置的Token是什么(也就是图1-5中的输出Y)。这里得到最大概率的那个Token,如果正好是这个Token,则说明预测对了,参数就不用怎么调整;反之,模型就会调整前面的参数(RNNh0inputwo)。你可能会疑惑为什么input也是参数,其实前面我们偷懒了,本来的参数是一个1000×32的大矩阵,但我们使用了4个Token对应位置的向量。这个1000×32的大矩阵其实就是词向量(每个词一行),开始时全部随机初始化,然后通过训练调整参数。

训练完成后,这些参数就不变了,然后就可以用前面同样的步骤来预测了,也就是给定一个Token,预测下一个Token。如果使用贪心搜索,则每次给定同样的Token时,生成的结果就一样。其余的就和前面讲的接上了。随着深度学习的不断发展,出现了更多比RNN还复杂的网络结构,而且模型变得更大,参数更多,但逻辑和方法是一样的。

好了,语言模型就介绍到这里。上面的代码看不懂没关系,你只需要大致了解每个Token是怎么表示、怎么训练和预测出来的就行。简单直观地说,构建(训练)语言模型的过程就是学习词、句内在的“语言关系”;而推理(预测)就是在给定上下文后,让构建好的模型根据不同的解码策略输出对应的文本。无论是训练还是预测,都以Token为粒度进行。