Transformer(2) - embedding 留了一个问题:查表得到的向量只跟“是哪个 token”有关,跟它在句子里的位置无关。这一篇就来看模型是怎么知道“谁在前、谁在后”的,也就是位置编码(positional encoding)。
“狗咬人”和“人咬狗”用的是同样三个 token,经过 embedding 以后也是同样三个向量,只是排列的顺序不同。但这两句话意思完全相反,模型必须分得清。
问题在于,后面要讲的 attention 处理这组向量的方式,是让每个 token 去“看”其他所有 token,然后按相关程度把它们的信息汇总起来。这个过程只看向量的内容,不看它们排在第几位。可以证明,把输入的顺序打乱,输出也只是跟着打乱,每个 token 得到的结果本身不变。也就是说,如果什么都不加,在模型眼里一句话就是一袋打乱了的词。
(这里要修正一下上一篇的说法。decoder 里的 attention 有一个限制,每个 token 只能看到它前面的 token,这本身就带了一点位置信息,第 1 个 token 只能看到自己,第 100 个 token 能看到 100 个。有研究发现只靠这一点模型也能学到一些顺序,但远远不够可靠,所以实际的模型都会显式的加上位置信息。)
所以需要另外想办法,把“第几个”这个信息也告诉模型。
最常见的做法是给每个位置也准备一个 维的向量 ,然后直接加到 token 的 embedding 上:
其中 是第 个位置上那个 token 的 embedding, 是最终交给后面各层的向量。这样同一个“狗”,放在第 1 个位置和第 3 个位置,加上的位置向量不一样,得到的 也就不一样了。
这里很自然会有一个担心:两个向量加在一起,会不会正好等于另一个词的向量?比如“狗”在第 3 个位置,加完以后和“猫”在第 1 个位置一模一样:
这样模型就分不清到底是哪个词、在哪个位置了。答案是实际上不会,原因可以从几个角度来看。
(一) 先看一个二维的例子
假设向量只有 2 维,而且恰好词向量只用第 1 维,位置向量只用第 2 维:
看到 (3, 5),就能直接读出“词是 3,位置是 5”,完全不会混淆。因为两类向量各占一个互相垂直的方向,相加其实就是把两份信息放进了不同的格子里。
当然实际的向量不会这么整齐,但在高维空间里,它们天然就很接近这种状态。
(二) 高维空间里,随便两个方向几乎都是垂直的
这是高维空间一个反直觉的性质。在 维空间里随机取两个向量,它们夹角的余弦值大致在 0 附近波动,波动的幅度大约是
时只有 0.036 左右,也就是说随便两个方向几乎都是垂直的。二维平面上只有 2 个互相垂直的方向,而高维空间里“几乎垂直”的方向多到数不清。所以词向量和位置向量就算没有刻意安排,也很自然的处在互相垂直的方向上,跟上面二维的例子差不多。
我用随机向量做了一个小实验:,随机生成 5000 个词向量,再用下面要讲的正弦公式生成 512 个位置向量
这还只是随机初始化的情况,训练好的模型还会更有意识的把它们分开,见下面的第 (四) 点。
(三) “恰好相等”几乎不可能
把上面担心的那个等式移一下项:
左右两边都是 维的向量,要相等就得每一维都分毫不差。词向量里都是训练出来的任意实数,768 个数全部碰巧对上的概率可以认为是 0。真正值得担心的不是“恰好相等”,而是“离得太近”,而第 (二) 点已经说明了一般也不会。
(四) 模型是带着位置编码一起训练的
这一点最关键。词向量不是先学好、再硬塞一个位置向量进去的,而是从一开始就在“会被加上位置向量”的前提下训练的。如果某个词加上某个位置以后容易和别的词搞混,模型就会预测错,训练时就会把相关的向量往不容易搞混的方向推。时间长了,模型会自己学会把“是什么”和“在哪”放在不同的方向上。有研究观察过 GPT-2 学出来的位置向量,发现位置信息主要集中在少数几个方向上,其余的维度留给了词本身的含义。
而且后面的层拿到向量以后做的第一件事,就是乘上一个矩阵。线性变换对加法是可以拆开的:
所以后面的层可以学出只关心词、不关心位置的变换,也可以学出反过来的变换,按需取用。
为什么不干脆拼接
既然担心混淆,为什么不把位置向量拼在词向量后面,像二维例子那样彻底分开?拼接当然可以,但向量会变长,后面每一层都要跟着变大。而从上面的分析可以看到,在几百上千维的空间里直接相加,效果已经接近“各占各的格子”了,没必要为此多花维度。
剩下的问题就是:位置向量 应该长什么样?
最直接的办法是第 1 个位置加 1,第 2 个位置加 2,以此类推。问题是句子一长,第 1000 个位置就要加 1000。而 embedding 里的数字通常都很小,加上这么大的数,token 本身的信息就被淹没了。
那用序号除以句子长度,把数值压到 0 到 1 之间呢?又有新问题:同样是第 3 个位置,在 10 个 token 的句子里是 0.3,在 100 个 token 的句子里是 0.03。相邻两个位置的差距也随句子长度变化,模型没法从中学到稳定的规律。
从这两个失败的尝试里,可以总结出一个好的位置编码应该满足的几个条件
如果把位置写成二进制,会发现一个有意思的规律
| 位置 | 第 3 位 | 第 2 位 | 第 1 位 |
| 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 1 |
| 2 | 0 | 1 | 0 |
| 3 | 0 | 1 | 1 |
| 4 | 1 | 0 | 0 |
| 5 | 1 | 0 | 1 |
| 6 | 1 | 1 | 0 |
| 7 | 1 | 1 | 1 |
最低位每一步都在 0 和 1 之间切换,第 2 位每 2 步切换一次,第 3 位每 4 步切换一次。每一位都在 0 和 1 之间,数值有界,但合在一起又能区分所有的位置。就像钟表的秒针、分针、时针,转得有快有慢,合起来就能表示任意时刻。
正弦位置编码用的就是这个思路,只是把“在 0 和 1 之间跳”换成了更平滑的正弦、余弦波。
对于第 个位置,位置向量的第 维和第 维分别是:
也就是说, 维的位置向量被分成了 对,每一对是同一个频率的 sin 和 cos。第 对的频率是
时 ,位置每加 1,角度就转 1 弧度,变得最快,相当于秒针。 越大频率越低,到最后一对时几乎不怎么变,相当于时针。所有频率对应的周期从 一直到 ,按等比数列分布。
对照前面的几个条件来看
正弦编码最妙的地方在于,位置 的编码可以由位置 的编码通过一个只和 有关的变换得到。拿其中一对来看,由三角函数的和角公式:
中间那个矩阵就是一个旋转矩阵,只和距离 有关,和 在哪无关。也就是说“往后挤 个位置”在每一对维度上都是转一个固定的角度。第 5 和第 6 个位置之间的关系,跟第 50 和第 51 个位置之间的关系是一模一样的。论文作者认为这能帮助模型学会按相对位置来关注其他 token。
算出来的位置编码是固定的,不需要训练。原版论文里还有一个细节:相加之前会先把 embedding 乘以 :
论文没有细说原因,一种常见的解释是 embedding 里的数值一般比较小,放大一下可以避免被位置编码盖过去。
还有一种更简单粗暴的做法:位置向量也不用公式算了,直接像 embedding 一样建一张表,每个位置占一行,跟着模型一起训练:
其中 是模型支持的最大长度。GPT-2 和 BERT 用的都是这种方式,GPT-2 的 是 1024,BERT 是 512。
它的好处是灵活,位置向量长什么样完全由数据决定。实际效果和正弦编码差不多,原版论文里也试过这种做法,结果几乎一样。
但它有一个硬伤:表只有 行,超过 的位置根本没有对应的向量。GPT-2 最多只能处理 1024 个 token,这就是原因之一。而正弦编码至少在公式上能算出任意位置的编码,虽然实际上模型对没见过的长度往往也处理得不好。
上面两种做法都是把位置信息“加”到 embedding 上,属于绝对位置编码:告诉模型每个 token 在第几位。但仔细想想,理解一句话时更重要的往往是相对位置,比如“这个词在那个词前面两个位置”,而不是“这个词在第 37 位”。
现在 Llama、Qwen 等大多数大模型用的是 RoPE(Rotary Position Embedding,旋转位置编码)。它和前面的做法有两个明显的不同
因为 RoPE 是作用在 attention 内部的,要把它讲清楚得先知道 attention 是怎么算的,所以这里先留个印象,等讲完 attention 以后再单独写一篇。
embedding 只知道“是什么”,不知道“在哪”,而 attention 本身又很难分清顺序,所以要额外加上位置信息。原版 Transformer 用的是正弦位置编码,用不同频率的 sin 和 cos 给每个位置一个独一无二、数值有界的向量,加到 embedding 上;GPT-2、BERT 则是直接学一张位置表;现在的大模型大多用 RoPE,在 attention 里用旋转来表示相对位置。
到这里,输入的准备工作就做完了:一段文字被拆成 token,每个 token 变成一个带着位置信息的 维向量。下一篇就正式进入 Transformer 的核心:self-attention。