在我们学过MLP和CNN的基本原理之后,接下来就是一座我们谁都没有办法跨过的大山了。谷歌在2017年提出的transformer至今还是大模型的主要架构之一。
注意力机制
传统的模型有这样的缺点:MLP虽然可以拟合出大多数的关系,但是他的参数量非常大,一旦层数上升,将会带来巨量的参数增加。而RNN和CNN的缺点就是,他们只能捕捉附近的几个token。
对于RNN来说,他是根据前面的序列预测下一个词的,越远的词对下一个词的预测的权重就越小。CNN捕捉了空间结构,但也是附近的信息。如何让模型能够捕捉到远距离信息,但是又不会有显著的参数量增加呢?
这就是transformer所解决的问题了。
自注意力self-attention
对于自注意力来说,有三个概念必须要清楚,就是Query(查询)、Key(键)、Value(值)。在注意力机制里,每一个词元(也可以叫token)都同时充当这三个数。
以下我将以"我爱你"为例,来解释这三个量是如何作用的。
所谓的查询,就是"查看一下别的词跟我有什么关系",而查询查询的东西,就是键。一旦确认这个键是要查询到的东西,那么这个键所对应的值就会被提取出来。
对于一个生成任务来说,一般查询起效在生成这个token的时候,而键值对起效在生成后续token的时候,为生成后续token进行辅助。
下面将进入具体的分析,但是在此之前,先让我们了解一下模型是如何理解这些文字的。
嵌入层
模型没办法理解单纯的文字,而是要将这个文字转化为一个嵌入。所谓的嵌入呢,就可以理解为一个高维向量。
很多同学一看到高维向量就头疼,但是我要说明的是,所谓的高维向量其实就是很多信息而已。
我之前在MLP的文章里说过,(1,1)是二维向量,(1,1,1)是三维向量,那么如果我说(表皮为红色,味道香甜,被子植物,有苹果的DNA)这是一个描述苹果的四维向量。这里大家应该可以接受吧?
所以,这个向量的维度越多,能描述的就越细致,当然反映到向量空间就是向量空间的大小变得很大。
那么,如何确定这样的一个映射,使其从文字转化为这样的向量呢?
当然不能是自己手动分析一遍,对吧?
作为一个映射,还是不太能够直接理清对应关系的映射,是可以使用神经网络进行模拟的。我在MLP的文章里就说过,神经网络就是用来模拟一种函数关系的。
现在的transformer会在训练的过程中同步更新嵌入(embedding)层,而embedding层就可以正确地表示不同的词语之间的关系。
对于一个训练好的embedding层来说,自定义的embedding维度越多,能够表示的内容就越丰富。毕竟同样是把所有信息放到向量空间里,维度越多就说明能够区分得更仔细。
再拿苹果来举例子,如果维度不够,就只能区分出水果和蔬菜;如果维度再高一点,就能区分出苹果和梨;如果维度更高一些,连黄苹果和红苹果都能区分出来。这是由于他们有了更多描述自己的机会。
同样的,越相近的词语,在embedding层里的位置也会越靠近。比如,黄苹果和红苹果可能只有颜色是有区别的,那么他们在向量空间的所有维度里只有一个维度是不同的;但是苹果和梨的不同之处就多了,所以在向量空间里的位置就远了。
事实上,在NLP的领域里,这些是通过上下文来分析的。比如"我爱你"和"我喜欢你",在大量语料分析过来之后,会发现"爱"和"喜欢"的上下文都是一样的,然后就会把这两个词放在附近。
在进行输出的时候,是根据概率值的大小来决定到底输出"爱"还是"喜欢"的。比如用户要求回答"我爱你",就会导致"爱"的概率比较大,平时呢则是根据随机的概率来进行输出。embedding层只负责维护一个表格,具体输出什么取决于transformer给他的输入是什么。
这里所说的向量空间,其实可以把它看作是一堆点的集合,因为向量可箭头以表示成(0,0)指向一个点
对于nn.Embedding来说,他本身就是一个做了计算优化的nn.Linear,也就是MLP。因为在编码一个汉字的时候一般用的是one-hot编码,也就是把所有的能够表示的词语排一个序,用一个数组表示,这个数组里只有一个位置是1,其余位置是0,也就是用了这个one-hot编码来表示值为1的这个词语。
这样的话,one-hot编码和嵌入矩阵相乘,也就相当于把嵌入之后的矩阵中,某一行的数据直接给取出来嘛,这样在矩阵运算上就能够变得更加简单。
在以前的方式中,也有使用预训练的嵌入矩阵的,前人专门训练出来表示比较全面的嵌入矩阵,后人就可以直接拿过来用,但是这样的方法已经不是主流了,主流依然是在训练transformer的过程中协同训练。
注意力分数
对于一个输入XXX,这个XXX就是我们输入的prompt,然后我们通过线性变换获得我们的QKV:Q=XWQQ=XW_QQ=XWQK=XWKK=XW_KK=XWKV=XWVV=XW_VV=XWV
就是非常简单的线性变换。这三个权重矩阵是在训练过程中逐渐学习的。
然后我们将QQQ和KTK^TKT相乘。
这里解释一下这几步的原因:对于Q,K,VQ,K,VQ,K,V来说,他是输入XXX(形状是[batch_size, seq_len, d_model],batch_size为批次数量,也就是一次性处理几句话;seq_len是每句话的句子长度;d_model是模型的维度)乘一个权重矩阵得到的,这个权重矩阵的形状是[d_model, d_k],对于单头注意力来说,d_model = d_k,对于多头注意力来说,d_model = d_k // n,n为头的数量。
这样,Q,KQ,KQ,K的形状是一样的,都是[batch_size, seq_len, d_k],也就是如果相乘必须要转置一个矩阵。那么,到底转置哪个矩阵呢?
这时候就必须通过意义来给出答案。就像数学上可能解出多个解,但是物理学上合理的答案却只有一个一样,这两个矩阵相乘的关系也绝对不能轻易交换。
两者相乘的含义是,每个单词的查询对每个单词的键的相关情况,所以得到的结果应该一边是每个单词的查询,一边是每个单词的键,因此合理的形状应该是QKTQK^TQKT。
但是,我们在之前的过程中谈过多次,要保证每一层出来的结果的分布不变(不了解的同学可以去看我MLP的那篇文章),为此我们采用在用ReLU的时候使用kaiming初始化,在CNN中增加batchnorm。
在transformer的过程中,最后要经过softmax函数来提供非线性,并且把逻辑值转换成概率(不了解的同学可以去看我softmax函数的那篇文章),如果其中有一个特别大的数,就会导致只有这个数接近1,其余全部数据都接近0,导致梯度消失和爆炸。
那么这样注意力的计算过程中,分布有没有变化呢?答案是有的。
对于每一条查询(在QQQ矩阵里是行)和其对应的一列键(在KTK^TKT里是列),都是服从标准正态分布的,他们的乘积依然是服从标准正态分布的;但是总共有d_k个这样的服从标准正态分布的变量相加,就会导致方差变成dkd_kdk,因此我们要对QKTQK^TQKT再除以一个dk\sqrt{d_k}dk ,从而将数据变换会标准正态分布。
现在我们得到了注意力分数score=QKTdk\text{score}=\frac{QK^T}{\sqrt{d_k}}score=dk QKT下一步就是经过softmax函数,归一化成权重,之后乘以VVV,因为我们是要根据键来取对应的那个值的。
output=softmax(score)⋅V\text{output}=\text{softmax}(\text{score})\cdot Voutput=softmax(score)⋅V
多头注意力
刚才我们讲的都是单头注意力,多头注意力是为了解决单头注意力能够表示的关系有限的问题的。比如,对于一句话的动词,跟他有关的关系包括主谓关系、动宾关系、动状关系等等,单头注意力的表达性能较弱。
多头注意力的最大区别就是将输入XXX转化为Q,K,VQ,K,VQ,K,V的权重矩阵的形状不一样了,之前d_model = d_k,但是现在d_model = d_k // n,n为头的数量。我们在设计头的个数的时候一定要保证这个数能够整除。
在多头注意力中,每个头独立捕捉不同的关系,然后由于他是原本的权重矩阵拆分出去的,所以最后需要把他们再拼接回来。
也就是把原来的[batch_size, seq_len, d_k]改写成[batch_size, head, seq_len, d_k],其中的head是从d_k维度拆分出来的,提到前面来方便进行批量运算。
我们在获得每个头的output之后,就可以把不同头的结果进行拼接了,然后是可以在进行一次线性变换增强表现能力的。不同头学到的语义关系是独立的,这个线性变换(就是再乘以一个矩阵)的作用是融合不同头的信息,让模型能综合利用多种语义关系,输出更强大的特征表示。
位置编码
我们之前说,transformer是可以不考虑临近关系,而是根据注意力来输出结果的。这么做可以让模型看到较远的东西,但是同样有他的后果,就是对他来说没有顺序的概念。比如,"我爱你"和"你爱我"在模型看来是一样的。
为此,我们必须引入位置编码,把位置信息告诉模型。这样的方法目前有很多种,主流方式是可学习的位置编码,我们这里暂且放一放,先讲原文说明的三角函数位置编码方式。
对于从0开始编号的位置pos和从0开始编号的维度i,我们有这样的公式:
- 对于偶数维度,我们使用sin\sinsin函数来进行表示。PE(pos,2i)=sin(pos100002i/dmodel)\text{PE}(pos, 2i)=\sin(\frac{pos}{10000^{2i/d_{model}}})PE(pos,2i)=sin(100002i/dmodelpos)
- 对于奇数维度,我们使用cos\coscos函数来进行表示。PE(pos,2i+1)=cos(pos100002i/dmodel)\text{PE}(pos, 2i+1)=\cos(\frac{pos}{10000^{2i/d_{model}}})PE(pos,2i+1)=cos(100002i/dmodelpos)
针对这些公式,我们需要回答以下问题:
Q1.10000是怎么来的?
A1.超参数,可以自己尝试调整一下。
Q2.为什么奇数偶数维度的公式使用的三角函数类型不同?
A2.对于固定的偏移量kkk,这个公式都能将PE(pos+k)\text{PE}(pos+k)PE(pos+k)表示成PE(pos)\text{PE}(pos)PE(pos)的线性函数。这意味着模型能够轻松学习到第pos个词和第pos+k个词的关系。
根据三角函数的和角公式,有sin(a+b)=sinacosb+cosasinb\sin(a+b)=\sin a\cos b+\cos a \sin bsin(a+b)=sinacosb+cosasinb和cos(a+b)=cosacosb−sinasinb\cos(a+b)=\cos a\cos b-\sin a\sin b cos(a+b)=cosacosb−sinasinb
对于偶数维度的公式PE(pos+k)\text{PE}(pos+k)PE(pos+k),令a=pos/100002i/d,b=k/100002i/da=pos/10000^{2i/d},b=k/10000^{2i/d}a=pos/100002i/d,b=k/100002i/d,就有PE(pos+k,2i)=sin(pos+k100002i/d)=sin(a+b)=sinacosb+cosasinb\text{PE}(pos+k, 2i)=\sin(\frac{pos+k}{10000^{2i/d}})\\=\sin(a+b)\\=\sin a\cos b+\cos a \sin bPE(pos+k,2i)=sin(100002i/dpos+k)=sin(a+b)=sinacosb+cosasinb
这样我们发现sin(a)\sin(a)sin(a)就是PE(pos,2i)\text{PE}(pos,2i)PE(pos,2i),cos(a)\cos(a)cos(a)就是PE(pos,2i+1)\text{PE}(pos,2i+1)PE(pos,2i+1),而他们的系数是一个只与kkk有关的数,从而我们就能实现对于位置的编码。
对于奇数维度的公式,是使用cos\coscos的和角公式来计算的,方法是相同的。
Q3.这么看来,三角函数编码是不能更新的吗?
A3.正确。不过到底可以训练的和无需训练的位置编码哪个更好,还要具体情况具体分析,对于三角函数的位置编码来说,他的好处是哪怕训练的时候只考虑了512个序列长度,在使用的时候也可以利用三角函数的周期性,对更长的范围进行位置编码。
训练技巧
在训练很深的神经元的时候,梯度极有可能回传不畅,就是只有尾段的几层会得到很大的更新,前几层几乎没办法更新的问题。对此,我们可以使用残差连接的方法,具体就是将计算的输出和输入一起作为输出,这样会保证梯度大于1,加速训练。
对于语言的输入来说,我们不确定他到底有多长的输入,不像输入固定大小的图片那样知道每个输入的长度,因此如果输入特别短,对批次进行归一化的思路就不会获得特别好的结果,于是layernorm就出现了,他在特征维度d_model上进行归一化,保证输入输出的分布是一致的,都是标准正态分布。
具体说来,假设有10个句子作为输入,在4个特征上有不同的取值。batchnorm是对于每个特征,将10个句子进行归一化;layernorm是对于每个句子,在所有特征上进行归一化。
解码器与反向传播
对于解码器来说,他和编码器的区别是,在输出第t个词语的时候,需要对t+1, t+2以及以后的词语施加遮罩,将注意力设置为很大的负数,这样经过softmax出来之后就非常接近0,也就是他的注意力不能关注在他后面出来的词语。
解码器还要关注编码器的全部信息。比如你问大模型一个问题,大模型在输出的时候,一边要看自己之前说了什么,一边要看你问的全部内容。
对于解码器来说,QQQ为当前解码器给出,代表当前要生成什么文字;K,VK,VK,V为编码器的结果,代表用户的问题有什么内容。这个机制叫做编码器-解码器注意力(Decoder-Encoder Attention)
其余过程与我们之前讲的是一样的。
对于损失函数,一般交叉熵函数就可以使用;具体的反向传播过程就是正常的反向传播过程,这一点就要在下一篇文章里讲解代码的时候来讲解了。