Transformer(1) - tokenizer 讲了分词器怎么把一段文字拆成一个个词元,拆完以后每个词元会换成它在词表里的编号。词元就是英文里的 token,前两篇为了少引入概念一直叫它词元,从这篇开始直接叫 token。
这一篇来看 Transformer 拿到这串编号以后做的第一件事:把每个 token 变成一个向量,也就是 embedding。
分词器交过来的是一串整数,比如“我爱吃苹果”可能变成 [2769, 4218, 1135, 9033](示意)。这些数字只是词表里的序号,就像学号一样,本身没有任何含义。
如果直接把编号当成数字拿去计算,问题马上就来了。编号 100 和 101 挨着,不代表这两个 token 意思接近;编号 2000 是编号 1000 的两倍,也不代表什么。模型做的是加减乘除,喂给它一个大小毫无意义的数字,它只会被误导。
Transformer(0) - 总览 里用过一个类比:光看“西安”“咸阳”“广州”三个名字,看不出谁离谁近,换成经纬度一算就知道了。embedding 要做的就是这件事:给每个 token 一组有意义的“坐标”,让模型能拿来比较和计算。
在讲 embedding 之前,先看一个最容易想到的办法。词表有 个 token,那就用一个长度为 的向量来表示一个 token,只在它自己编号的那一位写 1,其余全是 0。这种表示叫 one-hot:
这样编号的大小就没有影响了,每个 token 都占自己独立的一个维度。但它有两个明显的问题
词表动不动就几万到十几万,每个 token 都要用一个这么长的向量来表示,而里面只有一个 1,其余全是 0,非常浪费。
任意两个不同的 token,它们的 one-hot 向量点积都是 0,距离都是一样的:
也就是说,在 one-hot 的世界里,“猫”和“狗”的距离,跟“猫”和“汽车”的距离一模一样。所有 token 都是互不相干的,模型没法从表示本身看出任何关系。
embedding 的做法是准备一张大表,词表里每个 token 占一行,每一行是一个长度为 的向量。 是 Transformer 论文里的写法,表示模型的维度,后面会一直用到它。整张表就是一个 的矩阵:
拿到一个 token 的编号 ,就去表里取出第 行,这一行就是这个 token 的向量:
比如 的时候,表里可能长这样(示意,实际的 要大得多)
| token | 编号 | 向量 |
| 我 | 2769 | [0.12, -0.40, 0.33, 0.05] |
| 爱 | 4218 | [-0.21, 0.58, 0.07, -0.36] |
| 吃 | 1135 | [0.44, 0.19, -0.62, 0.28] |
| 苹果 | 9033 | [0.30, -0.15, -0.48, 0.71] |
一句话有 个 token,每个都查一次表,就得到 个向量,排在一起是一个 的矩阵,这就是 Transformer 真正处理的输入:
回到经纬度的类比,这张表就像一本地名词典,每个地名后面记着它的坐标。区别在于经纬度只有 2 个数,而 token 的意思要复杂得多,所以要用几百甚至几千个数来记。
查表看起来和前面的 one-hot 没关系,其实是一回事。用 one-hot 向量去乘这张表,结果恰好就是表里的第 行:
因为 只有第 位是 1,乘出来就是把第 行“挑”出来。所以 embedding 本质上是一个以 one-hot 为输入的线性变换,只是实际实现时没人真的去做这个乘法,直接按编号取那一行就行了,省掉了大量乘 0 的计算。
这个角度也说明了 embedding 解决了 one-hot 的两个问题:向量从 维压缩到了 维,而且每一维都是稠密的实数,不同 token 之间可以有远有近。
这张表不是人填的,而是和模型的其他参数一起训练出来的。
一开始,表里的数字全是随机的,比如从一个均值为 0、方差很小的正态分布里随便抽。这时候的向量没有任何意义,“猫”和“汽车”可能离得很近。
然后开始训练 Transformer。总览里说过,训练就是让模型不断预测下一个 token,猜错了就调整参数。这张表也是参数的一部分,每次调整时,这句话里出现过的那些 token 对应的那几行,都会被往“能让预测更准”的方向推一点。
反复训练以后,经常出现在相似上下文里的 token,向量会被推到相近的位置。比如“猫”和“狗”经常出现在“我家养了一只__”这样的句子里,它们的向量就会越来越近。衡量两个向量像不像,常用的是余弦相似度,也就是看两个向量的夹角:
越接近 1 说明方向越一致,意思越接近。
训练好的向量甚至能做一些“语义上的加减法”。一个经典的例子来自早年的词向量模型 word2vec:
“国王”减去“男人”再加上“女人”,最接近的向量是“女王”。说明向量空间里的某些方向,对应着“性别”这样的语义。大模型里的 embedding 不一定这么规整,但道理是一样的。
这里还有一个细节:每次训练只会更新这句话里出现过的 token 那几行。一个 token 如果在训练数据里几乎没出现过,它那一行就几乎没被更新过,基本还是随机的初始值。这就是 Transformer(1) - tokenizer 里说的“有些词元学不到”,SolidGoldMagikarp 这类 token 会让模型表现异常,根源就在这里。
这里值得分清两件事。
把 token 变成向量,这是设计好的。embedding 从一开始就是一张查表,不管训没训练,它做的都是把编号映射成向量。
向量带上意思,这是训练出来的。训练前表里全是随机数,训练的目标也只是预测下一个 token,没有人要求它学语义。但训练完以后会发现,意思相近的 token 向量也靠得近,某些方向还对应着“性别”“是不是动物”这样的含义。这是模型为了把下一个 token 预测准,在维度有限的情况下自己找到的表示方式。这一点可以用找邻居(看离某个 token 最近的是哪些 token)、做类比题等方法来验证。
是设计模型时定好的,也叫 hidden size。它不只是 embedding 的长度,后面 Transformer 里每一层处理的向量都是这个长度,可以说是整个模型的“宽度”。原版 Transformer 论文里 ,现在的大模型要大得多
| 模型 | 词表大小 | embedding 表的参数量 | |
| GPT-2(最小的版本) | 50257 | 768 | 约 3860 万 |
| Llama 3 8B | 128256 | 4096 | 约 5.3 亿 |
越大,每个 token 能装的信息越多,但参数和计算量也越大。
这也解释了 tokenizer 那篇说的“词表越大,模型越大”:embedding 表的大小就是 ,词表每多一个 token,表里就多一行。而模型的出口,也就是给每个 token 算概率的那一层,同样需要一张 的表。有些模型(比如 GPT-2)让入口和出口共用同一张表来省参数,有些模型(比如 Llama 3)则是两张独立的表。
查表有一个特点:结果只跟“是哪个 token”有关,跟它在什么句子里、在什么位置都没有关系。
总览里举过一个例子,同样是“苹果”,在“我爱吃苹果”和“苹果发布了新手机”里,意思完全不同。但在 embedding 这一步,这两个“苹果”查出来的是同一行,一模一样。
那模型是怎么分清这两个“苹果”的?这就要靠 embedding 之后的那些层了。它们会让每个 token 的向量去“看”句子里的其他 token,根据上下文不断修正自己。经过一层层处理以后,两个“苹果”的向量才会变得不一样。这正是后面要讲的 attention 做的事。
所以可以把 embedding 理解成每个 token 的“初始画像”,只代表它最常见、最一般的意思,具体在这句话里是什么意思,交给后面的层去决定。
embedding 就是一张 的表,每个 token 占一行。拿到 token 的编号,取出对应的那一行,就得到这个 token 的 维向量。这张表一开始是随机的,跟着模型一起训练,最后意思相近的 token 向量也离得近。
不过还有一个问题没解决。查表的结果跟 token 在句子里的位置无关,“狗咬人”和“人咬狗”查出来是同样三个向量,只是顺序不同。而后面的 attention 本身也不关心顺序,那模型怎么知道谁在前谁在后呢?下一篇就来讲位置编码。