词嵌入 入门
一个词变成一小行数字,相似度变成一个角度。本页每一张图,读的都是同一个真训练出来的 skip-gram 模型 —— 41 个 token、每词八个数、60 轮 —— 所以余弦、类比、关掉负采样后的坍缩,以及平面画不出的那 28% 空间,全都是量出来的,不是画出来的。
词必须先变成数
网络只会乘和加,词却是符号。所有人第一次搭的那座桥,恰好也是运不动任何含义的那座。
给词表里每个词一个下标,把它写成在该下标处为 1、其余全为 0 的向量 —— 这就是 one-hot 编码。八个词的词表,整套编码正好画得下一张图。沿着词表滑动,看那唯一的 1沿对角线往下走:
注意这张表本身就是编码:八个词要八个槽位,每一行有 87.5% 是零。那个 1 只承载词的身份,别的什么都没有 —— 没有长度、没有拼写、没有它常和谁一起出现。无论哪两个词,两行都恰好在两个位置上不同。
最后这句话就是全部的毛病所在,而这件事摸一下比读一遍更清楚。选第一个词和第二个词,像点积那样把对应槽位相乘:
盯住乘积那一行。只要两个滑块没落在同一个词上,每个乘积不是 0 × 1 就是 1 × 0 或 0 × 0,于是和为零,余弦也为零。cat 和 kitten 的相似度,与 cat 和 calculator 完全一样:这套编码里没有“差一点”,只有相同和无关。
第二个毛病是算术。真实分词器可不止八项:GPT-2 有 50,257 项,Llama-3 有 128,256 项。下面这一整行是按比例画的,起点就是我们一直在用的那八个词,标记在正在用的那一槽上方 —— 把词表调大,看这一行怎么被填满:
到 ,那一槽只有百分之一个像素宽 —— 标记指着它,而不是画出它 —— 整行占 201 KB 的 float32,其中真正干活的只有四个字节。没人会真去构造这个向量:one_hot(i) @ W 就是 W 的第 i 行,所有框架都直接取行、跳过乘法。
于是问题来了,也正是本页余下部分要回答的:如果我们查到的那一行才是真正的对象,它凭什么又长又空?给每个词一小行可以自由选择的数字,训练出来就是这样 —— 拖过词表,看这八个槽位:
每个槽位都在用,而且没有一个是人手指定的。每词 32 字节,而不是 201 KB —— 更要紧的是,两行现在可以部分相像了。本页余下的内容就在讲:这换来了什么、代价是什么,以及读这些数字最容易出错的三个地方。
相似度是一个角度
词一旦成了方向,“这两个有多相关”就有了带数字的答案 —— 而那个数字并不是距离。
先从两个只有两个槽位的词开始,这样整个空间刚好画得进一页。点积把对应槽位相乘再相加:v · w = v₁w₁ + v₂w₂。让 v 绕着 w 转,盯着余弦看:
注意符号在哪里翻转。两者朝同一侧时余弦为正,落在那条垂线上时恰好为零,v 越过它之后变负。余弦衡量的是方向上的一致程度,无论向量多大都待在 [−1, 1] 里。
点积则不然。它等于 ‖v‖ ‖w‖ cos θ,所以它同时回答了大小的问题。沿着自己的射线把 v 拉长,看这两个数怎么分道扬镳:
看那道影子越拉越长,而余弦纹丝不动。拉到 时点积几乎翻了三倍,夹角却一动没动。这就是检索系统按余弦而不是按原始内积排序的原因:不做这个除法,长文档就会因为“长”压过真正相关的那篇,而且这个 bug 从不抛异常 —— 它只是悄悄把错的那条放在第一位。
现在来看真家伙。从这里开始每一张图都是同一个训练好的空间:一个 skip-gram 模型, 41 个 token,每个词八个数,除了“哪些词常一起出现”之外没有任何监督信号。下面是其中八个词摆在一个平面上。拖动查询点,看它离哪个词最近:
注意查询点是八维空间里一个真正的向量,而不是画面上的一个点:读数里的余弦是拿真实的行算出来的。人在一侧,动物在另一侧,而没有任何人把这件事写在任何地方。
真正有用的操作,是向一个词要它自己的近邻。挑一个词,看最近的三个带着余弦亮起来:
因为 king 和 queen 出现在同一批框架里 —— 王座、宫殿、王冠 —— 模型别无选择,只能给它们 0.78;而八个词里最远的 cat 只有 0.20。这就是分布假设的全部,也是训练信号里仅有的东西。
那张图只是一道影子
你见过的每一张 embedding 散点图,为了挤上纸面都扔掉了空间的大部分。我们这张扔掉了 28%,而这已经足够改变答案。
上一节那个平面并不是那个空间,它是一个投影:八个向量各有八个数,而纸面只放得下两个。用哪两个,是我们做的选择。在两组不同的方向之间切换,看这张地图重画:
注意什么都没动。两幅画里的向量一模一样,变的只是我们选来观察的那两个方向,随之 72% 的离散度变成了 41%。这两根轴并不是性别和王室,它们只是这八行数字碰巧变化最大的方向。
没能挤上纸面的那部分并没有消失,它在纸面背后。把它画成每个点周围的一圈,半径恰好等于平面外那段余量的长度,再问问每个词:空间给它的近邻是谁:
盯住这两条连线的分歧。八个词无一例外:纸面上最近的那个点,都不是空间里最近的那个词 —— 纸面把 king 放在 man 旁边,空间说是 queen。平面只保住了 king 的 60%,而半径 1.33 的那一圈,比区分这两个候选所需的 0.58 还大得多。
那么一个词到底需要几个数?这条曲线上的每一个点,都是同一份语料在不同宽度下的一次独立训练。把 d 往下拖,看所有词对怎么彼此塌到一起:
在时,所有词对都得 1.00:一个数只能编码一个大小,于是十二个词变成同一条射线上的十二个点,排名纯属抛硬币。两个槽位是 0.85,三个是 0.53,到八个时均值稳定在 0.44 附近,再加也不见好。
这个下限由语料决定,而不是由宽度决定:我们的框架只编码了四种区分,所以四五个方向就够了,那条曲线从那里开始就平了。真实语料编码的区分多得多 —— 这正是生产宽度是 300(word2vec)、768(BERT、GPT-2)和 4,096(Llama-3)而不是 12 的原因。
训练真正挪动的是什么
两条规则,反复用上几十万次:把共同出现的那一对拉近,把没有共同出现的那些推开。
skip-gram 取一个词和它在文本中的一个邻居,只问一个是非题:这两个是不是一起出现过?模型的回答是 σ(w · c),每一步都把它往 1 推一点。增加梯度步数,看目标词和上下文词互相摆过去:
注意两支箭都在动:目标词往上摆,上下文词横着摆过去。对 w 的梯度是 (σ − 1)·c,对 c 的梯度是 (σ − 1)·w,所以每个向量都是被对方推着走的。在,模型给这一对真实词对打 0.09 —— 误差很大,所以这一步也很大。
光有“拉”会有一个平凡最优解:把所有向量都变成同一个巨大的向量,于是所有点积都很大。必须有东西来“推”。skip-gram 每一步抽k 个随机词,把它们往反方向推。把 k 从零往上加:
看 时会发生什么。不相关的那个词最后离目标词 0.85,而真正的上下文词是 0.90 —— 模型对一个从没见过的词对相当自信,而损失一路都在下降。这就是那种不抛异常的失败。word2vec 用 5 到 20 个负样本;我们的训练器抽五个。
整个算法就这些。把它跑在语料上 —— 41 个 token、13 个框架、60 轮 —— 第 1 轮时八个词还挤成原点附近的一团。每条轨迹是一个词,轨迹上的点是它此刻的位置,所以请拖过这六十轮,看king 和 queen 与动物们分道扬镳:
看前十轮几乎干掉了全部的活。第 1 轮时所有词对看上去都一样 ——king/queen 是 0.95,king/dog 是 0.99 —— 到第 10 轮 king/dog 已经掉到 0.24,而 king/queen 稳在 0.81。把它们撬开的正是负样本。
下面这条不变量,是本节唯一必须带走的东西:每一次更新只碰词对之间的点积。把整个空间旋转一下,所有点积不变,损失自然也不变。因此解只在“差一个旋转”的意义下确定 —— 这就是为什么任何 embedding 里,任何单个坐标都没有含义。
被最小化的那个数,是这些是非题的负对数似然。沿着损失曲线拖:
注意这条曲线并不单调。每一轮都重新抽负样本,所以它报出的损失,是同一个目标函数的另一份随机子样本:第一轮 3.77,第六十轮 1.19,中间抖得厉害。在负采样下从不抖动的损失曲线,通常是在刚拟合过的那个 minibatch 上算出来的。
没人要求过的那套几何
目标函数里没有一个字提到词与词之间的方向。它们照样冒了出来 —— 而且比那个著名例子暗示的既更有用,也更脆。
取两个词的差 —— woman 减 man —— 再把它加到第三个词上。这个差不过是个向量,所以它可以从任何地方出发。把越来越多的它加到king 上,看这个和落到哪里:
加到时,这个和与 queen 得 1.00,与下一名候选只有 0.44。没有人在这个模型里放进一个“性别方向”。它之所以存在,是因为把 he 和 she 分开的那些框架,对它们碰到的每个词施加了同一个位移。
这个说法的著名版本在两处夸大了,而两处都看得见。第一,这个和从来没有落在胜出词身上。下面是三组类比,余量画在和与胜出词之间:
余量长 0.247,而查询向量长 2.90,所以这个平行四边形差了 8.5% —— 换到动物那一组是 13%。答案是最近的词,而不是对的词;而且这里的“最近”还意味着把三个输入词从名单上划掉之后的最近。把它们留着,king 会以 0.73 回来,排第三。
第二,这个方向需要地方才能存在。下面每一根条,是类比查询与某个词的余弦,取自训练器真正跑过的某个宽度。把 d 调低,看这份排名怎么散架:
在时,类比给出prince,得分 0.999,而 princess、queen 和 king 都落在它两个千分点以内。算术没问题;只是没有地方让性别、地位和年龄同时成为各自独立的方向,于是它们共用,而胜负由噪声决定。
再说整个空间的一条性质,也是最让手里有检索系统的人吃惊的一条。取我们十二个词的全部 66 个词对,把它们的余弦装进直方图 ——那条竖线标的是直角:
注意它左边什么都没有。所有词对都是正的,绝对值的平均是 0.44, 66 个里最低的是 0.071 —— 这十二个词待在一个锥里,全都落在离自身质心 43.8° 到 48.7° 的范围内。在真实的 embedding 空间里,“无关”并不等于正交。
把质心减掉,锥就张开了:在时,词对平均降到 0.36,最低的那一对到了 −0.74。就这一行算术,正是在 embedding 上做最近邻检索之前,先做均值中心化或白化的原因。
“一词一向量”到此为止
查表每个 token 恰好一行。语言可不是每个 token 恰好一个意思,而这道缝正是 Transformer 的起点。
假设一个 token 必须同时背两个不相干的义项,就像 bank 既是河岸又是银行。我们的语料里没有这种词,所以造一个:一个有时表示dog、其余时候表示king 的 token。每次出现都贡献一份梯度,所以它那一行就是按频率加权的平均。把占比从零往上推:
到时,这一行与多数义项仍是 0.97,与少数义项只有 0.50:罕见的读法几乎没有被表示。对半分时是 0.86 和 0.75 —— 两边都靠一点,两边都不像。
这个滑块没有哪个位置能让这一行做对事情,因为表只有一行,而词有两个意思。这不是 word2vec 的 bug,这正是上下文模型存在的理由。 Transformer 把这张查表留作第一层,然后让注意力用周围的句子重写这个向量 —— 于是 bank 离开第 0 层时是个平均值,到第 12 层时成了其中一个义项。
最后值得知道的是这张表要多少钱。每个模型仍然从一张表开始,形状永远是 V × d —— 挑一个模型,从它的位置读出这两个数:
的表是 50,257 × 768 = 38.6 M 参数, float32 下 154 MB,是这个 1.24 亿参数模型里最大的一个矩阵 —— 占 31%。 GPT-2 把这张表和输出投影绑在一起,只付一次钱;Llama-3 8B 没有绑,于是它的 128,256 × 4,096 = 525 M 在账上出现了两次,占 80 亿参数模型的 13%。
这张表也解释了 §04 里那个目标函数为什么长成那样。正确的做法是在整个词表上做 softmax,它对每个 token 都要碰每一行;而负采样只碰其中 k + 1 行。下面两根条是同一张表、同一个比例尺:
softmax 是 O(V·d), GPT-2 一个 token 就要 3860 万次乘加;负采样是 O((k+1)·d),k = 5 时只有 4,608 次。差了 8000 倍 —— 琥珀色那根条看上去空空如也,原因就在这里。
四种不声不响的失败
形状错了会抛异常。这四种会返回一个数。
没归一化的排序。q · v 偏爱最长的向量。被当真的坐标轴。损失在旋转下不变,坐标和 PCA 轴属于图。拿错了矩阵。skip-gram 训两张表,结果用输入表。没平移的锥。原始余弦从没低于 0.07。