【RAG实战】语言模型基础
语言模型赋予了计算机理解和生成人类语言的能力。它结合了统计学原理和深度神经网络技术,通过对大量的样本数据进行复杂的概率分布分析来学习语言结构的内在模式和相关性。具体地,语言模型可根据上下文中已出现的词序列,使用概率推断来预测接下来可能出现的词汇。接下来主要介绍一些基础的语言模型,如Transformer、自动编码器、自回归模型等。
2.1 Transformer
Transformer模型是深度学习,尤其是自然语言处理(NLP)领域的一次重大突破。
从概念上看,Transformer模型可以被视为一个黑盒子,以极其翻译任务为例,它能够接收某种语言的输入文本,并输出对应语言的翻译版本
从内部结构来看,Transformer由编码器(encoder)和解码器(decoder)量大部分构成,这两部分在原始的"Attention is all you need"论文中包含6个模块,但在实际应用中,这个数字可以根据具体任务进行调整。
以机器翻译为例,Transformer的工作流程大致分为以下几个步骤:
-
获取句子中每个单词的表示向量X,X可以通过词嵌入(embedding)得到。X中包含单词数据特征和位置信息。如果以简单的框来表示向量,则每个单词都可以被表示为一个高纬向量,最终的输入句子被表示为一个词向量矩阵。
-
将得到的词向量矩阵传入编码器部分,每个单词的词向量都会经过编码块,在经过6个编码块后可以得到编码矩阵C。单词向量矩阵表示为Xn*d,n代表输入语句中的单词个数,d代表向量的维度。
-
解码器部分也是由6个完全相同的解码块堆叠而成,解码时,解码块会接收编码器部分输出的编码矩阵C和上一个解码块的输出,即解码层会根据当前词i和i之前的词信息翻译下一个单词i+1。在实际的执行过程中,i+1位置后的单词需要被掩盖点以防止i+1知道后面的信息。
Transformer的核心结构由词嵌入、编码器、解码器、输出生成四个部分组成,如下图所示
2.1.1 词嵌入
Transformer中输入单词的词嵌入包含单词编码和位置编码。单词编码用于编码单词的语义,位置编码用于编码单词的位置
1. 单词编码
单词编码(word embedding)是一种以数字方式表示句子中单词的方法,该方法用来表示单词语义特征。这里介绍三种编码方式:神经网络编码、词向量编码和全局词向量表示
(1)神经网络编码
基于神经网络语言模型(Neural Network Language Model,NNLM)的编码方式最早由Bengio等人提出,用于解决统计语言模型中常见的维度灾难问题。该方法是训练一个神经网络,在训练中,每个参与的句子告诉模型由哪些单词在语义上相近,最终模型为每个单词生成一种分布式表示,这种表示能够捕捉并保留单词的语义和语法关系
Bengio的神经网络主要由三个部分组成:一个词嵌入层,用于生成词嵌入表示,且单词之间参数共享;一个或多个隐藏层,用于生成词嵌入的非线性关系;一个激活层,用于生成整个词汇表中每个单词的概率分布
该网络使用损失函数在反向传播过程中更新参数,并尝试找到单词之间相对较好的依赖关系,同时保留语义和语法属性。下面通过简单的代码来理解其训练过程
1)单词索引
对单词建立索引,句子中每个单词都会被分配一个数字
word_list = " ".join(raw_sentence).split()
word_list = list(set(word_list))
word2id = {w: i for i, w in enumerate(word_list)}
id2word = {i: w for i, w in enumerate(word_list)}
n_class = len(word2id)
2)构建模型
class NNLM(nn.Module):def __init__(self):super(NNLM, self).__init__()self.embeddings = nn.Embedding(n_class, m)self.hidden1 = nn.Linear(n_step * m, n_hidden, bias=False)self.ones = nn.Parameter(torch.ones(n_hidden))self.hidden2 = nn.Linear(n_hidden, n_class, bias=False)self.hidden2 = nn.Linear(n_step * m, n_class, bias=False) #final layerself.bias = nn.Parameter(torch.ones(n_class))def forward(self, x):X = self.embedding(X)X = X.view(-1, n_step * m)tanh = torch.tanh(self.bias + self.hidden1(X))output = self.bias + self.hidden3(X) + self.hidden2(tanh)return output
在该过程中,首先初始化词嵌入层。词嵌入层相当于一个查找表,被索引表示的单词通过词嵌入层,然后再通过第一个隐藏层并与偏置量求和,求和结果传递给tanh函数。最后计算输出,代码如下:
output = self.b + self.hidden3(X) + self.hidden2(tanh)
3)损失函数
这里使用交叉熵损失函数,并将模型输出传递给softmax函数获得单词的概率分布
criterion = nn.CrossEntropyLoss()
4)进行训练
经过训练最终得到单词的编码结果
(2)词向量编码
词向量(Word2vector)模型由Mikolov等人在2013年提出,比Bengio的神经网络语言模型的复杂性更小。词向量模型可以在更大的数据集中训练,但缺点是如果数据较少,就无法像神经网络语言模型那样精确地表征数据。词向量模型包含两种模式:词袋(Bag-of-Words)模型和跳跃(Skip-gram)模型。
词袋模型又称为CBOW模型,它基于目标词前后的n个单词来预测目标单词。假设句子为:“她在踢毽子”,以"毽子"作为目标词,取n=2,那么[正,在,毽,子]等前后单词及目标单词踢将被一起输入给模型。CBOW模型通过计算log2V来降低计算词表中单词分布概率的复杂性,V代表词汇表大小。该模型速度更快,效率更高。
同样的,通过代码来理解CBOW模型,其训练过程如下:
1)定义一个窗口函数,该函数提取目标单词的左右各n个单词
def CBOW(raw_text, window_size=2):data = []for i in range(window_size, len(raw_text) - window_size):context = [raw_text[i - window_size], raw_text[i - (window_size - 1)], raw_text[i + (window_size - 1)], raw_text[i + window_size]]target = raw_text[i]data.append((context, target))return data
上述CBOW函数包含两个输入参数:数据和窗口大小。窗口大小定义了应该从单词左侧和右侧提取多少个单词。for循环首先定义了句子中迭代的开始索引和结束索引,即从句子中的第3个单词开始到倒数第3个单词结束。在循环内部将窗口提取到窗口大小为2,此时窗口提取到的单词为"正" “在” “毽"和"子”,目标单词是"踢",当i=2(窗口大小)时,代码如下:
context = [raw_text[2-2], raw_text[2 - (2-1)], raw_text[i + (2-1)], raw_text[i + 2]]
target = raw_text[2]
执行CBOW函数:
data = CBOW(raw_text)
print(data[0])
上述输出为:
Output:
(["正","在","毽","子"], "踢")
2)构造模型
CBOW模型只包含一个词嵌入层、一个经过ReLU层的隐藏层和一个输出层。代码如下:
class CBOW_Model(torch.nn.Module):def __init__(self, vocab_size, embedding_dim):super(CBOW_Model, self).__init__()self.embeddings = nn.Embbeding(vocab_size, embedding_dim)self.linear1 = nn.Linear(embedding_dim, 128)self.activation_function1 = nn.ReLU()self.linear2 = nn.Linear(128, vocab_size)def forward(self, inputs):embeds = sum(self.embeddings(inputs)).view(1, -1)out = self.linear1(embeds)out = self.activation_function1(out)out = self.linear2(out)return out
该模型非常简单,单词索引输入层嵌入层,之后经过隐藏层,隐藏层输出经过一个非线性层ReLU之后经过输出层得到最终结果。
3)损失函数
与神经网络语言模型一样,采用交叉熵损失函数。优化器选择随机梯度下降,代码如下:
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
4)训练模型
训练代码与神经网络语言模型袋代码一致,具体如下:
for epoch in range(50):total_loss = 0for context, target in data:context_vector = make_context_vector(context, word_to_ix)output = model(context_vector)target = torch.tensor([word_to_ix[target]])total_loss += loss_function(output, target)optimizer.zero_grad()total_loss.backward()optimizer.step()
最终将语句单词转化为数字序列
对比词向量模型,跳跃模型是基于目标单词预测目标单词的上下邻近单词。假设目标单词n=2,同样以句子"她正在踢毽子"为例,单词"踢"将被输入模型用于预测目标单词(“正”,“在”,“毽”,“子”)。其结构如下图:
跳跃模型与词袋模型类似,不同点在于创建上下文和目标单词。
相比词袋模型,跳跃模型增加了计算的复杂度,因为它必须根据一定数量的相邻单词来预测邻近单词。在实际的语句中,距离较远的单词相关性往往比较差
(3)全局词向量表示
全局词向量表示(Global Vectors for Word Representation,GloVe)是一种基于全局信息来获得词向量的方法。该方法使用了语料库的全局统计特征,也使用了局部的上下文特征,GloVe通过引入共现矩阵(Co-occurrence Probabilities Matrix)来表征。
定义词到词的共现矩阵为X,Xij代表单词j出现在单词i的上下文的次数,Xi表示出现在单词i的上下文的词的总数。Pij = P(j|i) = Xij/Xi表示单词j出现在单词i的上下文中的概率。
定义词i、j与词k的共现概率为P = Pik / Pjk,词与词之间的关系可以通过共现概率体现,即如果词k与词i相近,与词j较远,则希望Pik / Pjk越大越好,如果词k与词j相近,与词i较远,则希望Pik / Pjk越小越好,如果词k与词i和词j都相近或都较远,则Pik / Pjk趋近于1
共现概率既很好地区分了相关词和不相关词,又反映了相关词的关联程度。GloVe模型就是基于该共现概率的信息构建的,其核心目标是为了每一个词生成一个词向量,这样词向量就能映射出词与词之间的共现概率,以此捕捉它们的语义关系。假设定义GloVe模型为F,则上述共现概率可以使用如下公式来表示:
F(wi, wj, wk) = Pik / Pjk
其中,w 代表词向量
综上,单词词嵌入编码方法的特点如下:
- 神经网络语言模型的性能优于早期的统计模型
- 神经网络语言模型解决了维度灾难问题,并通过其分布式表示保留了上下文语义和句法属性,但计算成本很高
- 词向量模型降低了计算复杂性,比神经网络语言模型效率更高,它可以在大量数据上进行训练,用高维向量表示
- 词向量模型有两种:词袋模型和跳跃模型。前者比后者运算更快
- GloVe纳入了全局信息但无法解决一词多义和陌生词问题
2. 位置编码
位置编码(Position Embedding)用来表示句子中单词的位置,且每个位置被赋予唯一的表示。位置编码要满足以下特点:
- 编码值可以表示单词在句子中的绝对位置,且是有界的
- 句子长度不一致时单词间的相对位置距离也要保持一致
- 可以表示从未见过的句子长度
Transformer使用正余弦函数来进行位置编码。其公式如下:
PE(pos, 2i) = sin(pos/10000^2i/d model)PE(pos, 2i+1) = cos(pos/10000^2i/d model)
其中,pos表示单词在句子中的位置,d表示PE的维度(与单词编码一样),2i表示偶数的维度,2i+1表示奇数维度(即2i<=d, 2i+1 <=d)。使用这种公式计算PE有以下的好处:
- 能够适应比训练集中所有句子更长的句子。假设训练集中最长的句子有20个单词,此时有一个长度为21的句子,则使用公式计算的方法可以计算出第21位的编码
- 能够较容易地计算出相对位置。对于固定长度的间距k,PE(pos+k)可以用PE(pos)计算得到
接下来通过代码来理解位置编码
import numpy as np
import matplotlib.pyplot as plt
def getPositionEncoding(seq_len, d, n=10000):P = np.zeros((seq_len, d))for k in range(seq_len):for i in np.arange(int(d/2)):denominator = np.power(n, 2*i/d)P[k, 2*i] = np.sin(k/denominator)P[k, 2*i+1] = np.cos(k/denominator)return PP = getPositionEncoding(seq_len=4, d=4, n=100)print(P)[
[0. 1. 0. 1. ]
[0.84147098 0.54030231 0.09983342 0.99500417]
[0.90929743 -0.41614684 0.19866933 0.98006658]
[0.14112001 -0.9899925. 0.29552021 0.95533649]
]
看一下n=10000和d=512的不同位置正弦曲线
def plotSinusoid(k, d=512, n=10000):x = np,arange(0, 100, 1)denominator = np.power(n, 2*x/d)y = np.sin(k/denominator)plt.plot(x, y)plt.title('k = ' + str(k))
fig = plt.figure(figsize=(15, 4))
for i in range(4):plt.subplot(141 + i)plotSinusoid(i*4)
每个位置k对应于不同位置的正弦曲线,它将位置编码为向量。
即正弦曲线的波长形成几何级数,并且变化范围为2pai到2pai n,该方案的优点如下:
- 正弦和余弦函数的值在[-1, 1]范围内,即编码值是有界的
- 由于每个位置的正弦曲线不同,因此可以采用独特的方式对每个位置进行编码,即每个位置的编码都是唯一的
- 基于正弦变化可以测量或量化不同位置之间的相似性,从而能够对单词的相对位置进行编码
进一步,通过Matplotlib来可视化位置矩阵
P = getPositionEncoding(seq_len=100, d=512, n=10000)
cat = plt.matshow(P)
plt.gcf().colorbar(cax)
综上,将整个句子所有单词的位置编码向量与单词编码向量相加得到输出矩阵,整个流程如下图:
2.1.2 编码器
编码器由多头注意力、加法和归一化、前馈层组成
1. 多头注意力
在介绍多头注意力之前,首先介绍一下自注意力
自注意力是机器学习使用的一种学习机制,属于仿生学的一种应用,即人类会把注意力放在重点关注的信息上。在自然语言处理任务中,自注意力用于捕获输入序列内的依赖性和关系,让模型通过关注自身来识别和权衡输入序列不同部分的重要性。在编码器中,自注意力的输入参数有三个:查询(query)、键(key)、值(value)
这三个参数在结构上很相似,都是被参数化的向量。每个单词的词嵌入向量都由这三个矩阵向量来表征,帮助计算机理解和处理句子中单词之间的关系。这三个矩阵向量的作用各不相同。
1)查询
该矩阵表示正在评估其上下文的目标单词。系统通过使用查询矩阵转换该目标单词的表示形式,生成一个查询向量。此查询向量用于衡量其与句子中其他单词的关联度
2)键
该矩阵用于生成句子中每个单词对应的键向量。通过对每个键向量和目标单词的查询向量进行比较,计算得出目标单词与句子中其他词之间的相关性。查询向量和关键向量之间的相似度分数越高,表示相应单词之间的关系越紧密
3)值
该矩阵用于生成句子中所有单词的值向量,这些向量保存每个单词的上下文信息。使用查询向量和键向量计算相似度分数后,系统计算值向量的加权和。每个值向量的权重由相似度分数确定,确保最终的上下文表示更多地受到相关单词的影响
如下图所示,语句中单词"一"的查询矩阵向量q3与所有词的键矩阵向量k进行计算,得到注意力得分y31、y32、y33、y34。之后分别与自身的值矩阵向量做乘积,得到每个词抽取信息完毕的向量,最后所有向量求和得到z3,即为单词"一"经过注意力后的结果
Transformer中的自注意力使用的是多头注意力,即通过组合多个类似的注意力计算给予了Transformer更大的辨别能力。多头注意力结构如下:
在多头注意力中,查询、键和值分别通过单独的线性层,每个层都有自己的权重,产生三个结果,分别称为Q、K、V。然后在缩放点积注意力中基于注意力公式进行组合运算。
在上述过程中,Q、K、V携带了序列中每个单词的编码表示,之后注意力计算将每个单词与序列中的其他单词结合起来,以便注意力分数对序列中每个单词的分数进行编码。在该过程中提到了掩码。由于输入的序列可能具有不同的长度,因此需要使用填充标记对句子进行扩展对齐长度,以便可以将固定的向量输入Transformer中,这里的掩码主要是为了填充部分的注意力输出为0,确保填充标记不会对注意力分数产生影响。
2. 加法和归一化
加法过程是一种残差机制,主要为了解决深层神经网络训练过程的不稳定性,即深度神经网络随着层数的增加,损失逐渐减小然后趋于稳定,继续增加层数损失反而增大的现象。简单来说就是用来防止梯度消失。
归一化用来归一化参数,加快训练速度,提高训练的稳定性。
3. 前馈层
前馈层作为注意力层后面的子层,由两个线性层或致密层构成。第一层的大小为(d_model, d_ffn),第二层的大小为(d_ffn,d_model)。
通过以下代码来理解这个过程
class PositionwiseFeedForward(nn.Module):def __init__(self, d_model: int, d_ffn: int, dropout: float = 0.1):"""Args:d_model: dimension of embeddingsd_ffn: dimension of feed-forward networkdropout: probablity of dropout occurring"""super().__init__()self.w_1 = nn.Linear(d_model, d_ffn)self.w_2 = nn.Linear(d_ffn, d_model)self.dropout = nn.Dropout(dropout)def forward(self, x):"""Args:x: output from attention (batch_size, seq_length, d_model)Returns:expanded-and-contracted representation (batch_size, seq_length, d_model)"""return self.w_2(self.dropout(self.w_1(x).relu()))
2.1.3 解码器
解码器也由多头注意力、加法和归一化、前馈层组成,每个解码器包含两层多头注意力,这两个多头注意力与编码器中的多头注意力作用不同。
1. 掩码多头注意力
掩码多头注意力与编码器不一致的地方在于掩盖的步骤,此处的掩码过程主要为了防止解码器在预测下一个单词时"偷看"目标语句的其余部分
2. 多头注意力
解码器中的多头注意力与编码器中的主要区别在于,它的K、V矩阵不是基于上一个解码器的输出计算而来的,而是来自编码器的输入,但Q矩阵还是根据解码器的输出计算得到的。计算过程与编码器中的一样,掩码步骤也与编码器的一致
2.1.4 解码头
解码器将输出传递给解码头,解码头将接收到的解码器输出向量映射为单词分数。词库中的每个单词在句子中的每个位置都会有一个分数。假设最终输出的句子有4个词,词库中有1000个词,那么在这4个词的每个位置都会生成1000个分数,这些分数代表词库中每个词出现在句子中每个位置的可能性。之后这些分数将送入softmax层,softmax将这些分数转换为概率(加起来为1.0)。在每个位置上,找到概率最高的单词的索引,然后将该索引映射到词汇表中相应的单词。最后,这些单词形成Transformer的输出序列。
2.2 自动编码器
自动编码器(Autoencoder)是一类神经网络,通过无监督学习重建其输入数据,同时在这个过程中实现数据的有效压缩。
自动编码器的结构主要包括两个部分:编码器网络和解码器网络。编码器网络负责将高维的输入数据转换成更低维的表示,而解码器网络则从这些低维表示中恢复出原始数据。这种结构不仅能够捕捉数据中的关键特征,而且有助于在解码过程中有效地重建数据。
自动编码器的核心是学习输入数据的有效表示。这一过程涉及编码器将数据编码为低维表示,解码器再基于这些表示重建原始数据。网络通过学习一个接近恒等的映射来实现这一点,其参数优化过程可以通过计算输入与重建输出之间的均方差来量化。
2.2.1 ELMo
前面介绍了一些常用的词嵌入方法,如NNLM、Word2vector、GloVe等,这几种方法本质上都是先训练好一个语言模型,然后输入单词取模型的隐藏层表示来作为单词的词向量表示。这些方法都是静态的表示方法,即一旦模型确定则单词的词向量表示也确定。虽然这些语言模型的词表征方法在下游任务中取得了优异的表现。但大多情况下单词的词义是随语境变化的,因此这些方法得到的词向量有一定的缺陷,尤其无法表达多义词。
与静态词嵌入方法不同ELMo(来自语言模型的嵌入)采用动态方法生成词嵌入,它基于训练好的语言模型,并能根据上下文实时调整词嵌入,有效地缓解了词义歧义的问题。
ELMo模型来自2018年的论文,在论文中提出了深度双向语言模型来表征词向量。该模型的原理是,首先训练好一个静态的语言模型来表征词向量,该模型存在上述提到的缺陷,即无法理解歧义,当执行实际任务时,可实时根据上下文单词的语义去调整之前表征好的词向量,这些经过调整后的词向量更能表达具体语境中的含义,从而解决多义词问题。该方法是一种结合上下文语境的动态调整思路。ELMo模型是采用双向LSTM构建,在一个大型文本语料库中训练而成的。
ELMo的训练过程包含两个阶段,分别是预训练和下游任务。预训练阶段与之前的语言模型训练类似,预计训练的目标是根据目标词的上下文预测目标词。
左侧的双向LSTM代表正向编码器,负责从前往后提取信息,输入为句子序列中从左到右(除目标词外)的上文,右侧的双向LSTM代表逆向编码器,负责从后往前提取信息,输入为句子序列中从右到左的逆序下文。
ELMo的预训练过程完成后,单词经过ELMo模型可以得到三层编码信息,Ei代表最底层的单词特征信息编码,从下往上第一层LSTM包含了句法特征的单词位置编码信息,第二层LSTM包含了语义特征的单词位置编码信息。
在预训练好的网络结构完成后,如何将其应用于特性的下游任务,比如问答问题?假设有一个问句X,首先,将这个句子X输入已经预训练好的ELMo网络中。通过这个过程,句子X中的每个单词在ELMo网络中都会生成对应的三层编码信息。接下来,分别为这三层编码信息分配一个权重a,这些权重是可以通过学习得到的。然后,根据各自的权重进行加权求和,将这三层编码融合成一个词向量,这样句子中的每个单词都有一个对应的词向量。在执行问答任务时,问句X中的每个词对应的词向量作为任务的输入,这样可以利用预训练ELM。网络中的语境信息,从而增强下游任务的性能。