Transformer 的输入嵌入和位置编码常常被一带而过,然而它们对于像 GPT 这样的模型理解和处理语言确是至关重要的。
虽然完整的 Transformer 架构庞大且复杂,但本文将只关注最初始的层级——输入部分:
-
单词或标记如何通过嵌入转化为向量
-
模型如何利用位置编码来跟踪单词的顺序
在深入探讨多头注意力机制或更复杂的架构细节之前,理解这两个机制至关重要。
以下是Transformer架构图:
词汇表与标记器
在深入探讨嵌入和编码之前,我们需要了解一个更基础的步骤:构建词汇表。
尽管现代语言模型通常依赖于字节对编码(BPE)、句子片段**(sentencepieces),甚至字节级标记化,但在最初的注意力论文中,作者使用的是单词级标记化。
值得注意的是,词汇表的创建与模型无关,也就是说它与任何特定的模型架构(如 Transformer)无关。
标记器负责构建这个词汇表——它读取原始文本,并创建从标记(如单词)到唯一 ID 的映射,标记器对标记后续如何被神经网络处理一无所知。
可以将其想象为构建一本字典: 你只是在收集和索引单词,至于这本字典后续是用于诗歌、散文还是法律文件,在这个阶段并不重要。
让我们通过一个简单的例子来看看这是如何工作的。给定句子:
“I love cats I like dogs。”
使用单词级粒度的标记器会为其分配如下唯一的 ID:
现在我们有了词汇表——每个单词都与一个唯一的整数 ID 相关联。
在接下来的步骤中,我们将看到这些ID如何作为Transformer 模型的输入。
输入嵌入
如上图所示,一切从输入开始。
模型首先处理这个输入——但计算机无法直接理解单词或图像,它们只能理解数字。
因此,我们的首要任务是将这些输入——比如句子“I like dogs”——转化为机器可以处理的数值表示。
在现代术语中,将原始文本转化为标记的过程称为标记化,其结果通常被称为标记嵌入。
然而,需要明确的是:
-
标记化基于词汇表在文本和标记 ID 之间创建映射。
-
嵌入使用可训练的嵌入矩阵**将这些标记 ID 映射为连续的向量表示。
这两个过程是分开的,标记化是静态的,基于词汇表;而嵌入是动态的,依赖于模型参数(如维度数)。
我们一开始构建的词汇表,它包含 5 个单词:
如果我们的输入句子是“I like dogs”,标记器会将其转化为:[0, 3, 4]。
这些只是标记 ID——但还不够。模型处理的是向量,而不是离散的 ID,这些向量需要有明确的维度数,这就引出了一个非常重要的概念:维度。
什么是维度?
嵌入向量中的维度代表模型可以从数据中学习的抽象特征,这些维度没有明确的解释,神经网络不会告诉你每个维度的具体含义。
我们只需要知道:
-
使用的维度越多(在有足够数据和训练的情况下),表示就越丰富、信息量就越大。
-
嵌入维度是一个模型超参数。你可以在训练前选择它(如 4、128、512)。
嵌入矩阵
假设我们的模型采用4维嵌入。那么,我们会创建一个形状如下的嵌入矩阵:
(5, 4) → (vocabulary size, embedding dimension)
示例:
这些浮点数从何而来?
它们是可学习的参数——在训练开始时随机初始化,并在反向传播过程中更新。
生成的嵌入
矩阵中的每一行都对应于我们词汇表中的一个标记,所以句子“I like dogs” ”就转化为:
我们都知道循环神经网络(RNN)缺乏并行性,而 Transformer(尤其是大型语言模型)则没有这个问题,但是我相信大部分人都没有亲眼见过并行性是如何实际运作的。
并行性
在 Transformer 中,输入嵌入(一切的开始)是绝对的并行处理,Transformer 在训练和推理过程中都完全并行地处理输入嵌入。
让我们回到之前的简单例子:“I like dogs”,为了简化,我们假设标记 ID 是 [0, 1, 2]——与它们在嵌入矩阵中的行位置相匹配。
现在,当我们获取嵌入时,真正的并行处理就开始了:
input_embeddings = embedding_matrix[input_ids]
这一行代码就能一次性获取所有的嵌入向量,无需循环,也没有延迟。这得益于向量化操作——这是由NumPy**、PyTorch和TensorFlow等库提供的强大工具实现的。
并行性是如何实现的?这种并行性并不是由 Python 线程或 for 循环驱动的,相反它是由在 CPU 或 GPU 上执行的矩阵运算驱动的,具体取决于数据存储的位置。
但是Python 不是在 CPU 上运行的吗?在这里有个转折点:Python 代码并不直接在 GPU 上运行,它与库(如 PyTorch 或 TensorFlow)进行交互,这些库在底层调用 C++ 和 CUDA**,这就是 GPU 发挥真正魔力的地方。
幕后究竟发生了什么?
让我们看看运行以下代码时会发生什么:
以下是这一过程背后的具体运作原理:
-
Python 在 CPU 上运行你的脚本
-
PyTorch 调用其 C++ 后端——仍然在 CPU 上
-
C++ 后端启动一个 CUDA 内核——由 CPU 发起
-
CUDA 内核在 GPU 上执行实际的计算
-
结果返回给 PyTorch——再次由 CPU 处理
位置编码
现在我们已经介绍了单词/输入/标记嵌入以及并行性是如何工作的,我们可以进入下一个主题:对句子中单词的位置进行编码。
之所以在介绍位置编码之前先解释并行性,是因为一开始,在并行处理一切的同时跟踪单词顺序的想法似乎有些矛盾。
在没有像 RNN 那样的逐步处理的情况下,模型如何能理解单词的顺序呢?
让我们先来拆解这个问题,不妨看看下面这两个句子:
“The dog chased the cat。”
“The cat chased the dog”
这两个句子使用了相同的单词,但意思却完全相反,这种反转完全是由单词顺序造成的。
所以语言模型需要知道一个单词在句子中的位置——而不仅仅是知道这个单词是什么。
一开始,你可能会想到一个简单的解决方案:直接将位置信息添加到嵌入中。
例如,对于句子“I like dogs”,我们可以为其分配原始位置 [0, 1, 2],并将这些位置添加到嵌入中。
但这里有一个问题:这种方法无法扩展,随着位置数字的增大——比如达到 1000 或更大——添加的值可能会主导嵌入,并破坏训练过程的稳定性。
如果大家还记得反向传播,极端值可能会导致梯度爆炸,这是我们绝对想要避免的。
现在已经探索了一些替代方法(比如为每个位置学习一个单独的嵌入),但它们都有自己的局限性——比如对更长序列的泛化能力差,或者参数开销大,所以我们需要一个更好的解决方案。
在深入探讨 Transformer 实际上是如何解决这个问题的之前,让我们先明确一下我们希望一个好的位置编码系统能具备什么特点:
-
每个位置都应该有唯一的编码
输入序列中的每个位置(比如第一个单词、第二个单词等)都应该是唯一的。这有助于模型理解句子的结构。 -
它应该能捕捉单词之间的相对距离
模型应该能够判断两个单词之间的距离有多远,这对于学习语法依赖和句法模式很重要,(还记得在嵌入部分我们讨论过向量之间的方向吗?比如“猫”与其他复数词共享一个“复数”方向。类似地,位置编码之间的方向也可以携带有用的关系信息。) -
它应该能泛化到比训练时看到的更长的序列
我们不希望当句子比训练数据稍长时,系统就崩溃,一个好的编码方法应该能自然地扩展到未见过的长度。 -
它应该是确定性的,而不是学习的
如果可能的话,我们希望位置编码是从一个固定的规则计算出来的,而不是从数据中学习得到的,这可以避免不必要的参数,并加快训练速度。
二进制编码
在深入探讨更高级的位置编码技术(如正弦编码)之前,让我们先从一个更简单、更熟悉的概念开始:二进制编码。
这是计算机科学中的基础概念——用于 CPU、内存寻址和底层协议——因为计算机从根本上来说只使用两个符号:0 和 1。
现在想象一个基本任务:我们想为句子中的每个单词分配一个唯一的位置标识符,为了简化,我们假设句子的最大长度为 16 个单词。
为了表示 16 个位置(从 0 到 15),我们需要 4 位,因为:2⁴ = 16
这给了我们以下的位置编码:
为了更好地理解每个位(维度)在不同位置上的表现,请看下面的图表:
每一行对应一个位(维度),每一列对应从 0 到 15 的一个位置(以二进制表示)。
需要注意:
-
最上面一行每个位置都翻转:0,1,0,1……
-
下一行每两个位置翻转一次
-
然后是每四个位置翻转一次,最下面一行在中间位置翻转一次
现在让我们用一个例子来编码我们的句子:“I like dogs”
第一步:标记嵌入
为了简化,我们假设使用 4 维嵌入,以下是预训练的标记嵌入:
第二步:二进制位置向量(4 位,支持 16 个标记)
第三步:将位置向量添加到标记嵌入中
为什么二进制编码有效?
确定性和唯一性: 每个位置都有一个唯一的二进制向量,通过一个简单的、确定性的公式计算得出:
对于 4 位编码,它映射为:
位置 0 → [0, 0, 0, 0]
位置 1 → [0, 0, 0, 1]
位置 2 → [0, 0, 1, 0]
梯度友好(大部分情况下): 因为值要么是 0 要么是 1,所以二进制向量不会引入非常大的幅值。
这有助于在反向传播过程中保持相对稳定的梯度流,所以梯度爆炸的风险很小。
为什么二进制编码不够好?
虽然二进制编码提供了确定性和唯一性的表示,但它有几个关键的缺点,使其不适合作为 Transformer 的位置编码方法:
-
不连续性和缺乏平滑性:二进制编码会突然变化,以位置 7(0111)和位置 8(1000)为例。只有一个位翻转了,但整体的二进制向量却截然不同——一个有三个 1,另一个只有一个 1。当这些向量被添加到标记嵌入中时,生成的表示会突然跳跃,而不是平滑变化。这种突然的变化会破坏模型学习平滑位置模式的能力。
-
没有相对距离的概念:二进制编码无法捕捉两个位置之间的距离,例如,位置 7 和 8 是相邻的,但它们的二进制表示在每个位上都不同。模型无法得知它们是相邻的——只知道它们是不同的,这使得模型更难根据距离来泛化顺序关系。
-
对更长序列的泛化能力有限:二进制编码被固定在一个预定义的最大序列长度上,使用 4 位你只能表示 16 个位置(0-15)。如果你的模型后来遇到了一个长度为 32 的序列,你就需要跳转到 5 位编码——实际上改变了输入的维度。这破坏了兼容性,并阻止了在推理过程中对更长序列的平滑泛化。
-
由于突然变化而产生的梯度噪声:尽管二进制值(0 或 1)看起来对梯度是安全的,但它们在位置之间的突然转换(例如,从 7 到 8 翻转多个位)会导致输入的急剧变化。这会破坏稳定学习所需的平滑输入-输出关系,并引入梯度噪声——使优化更加困难、有偏差且不可预测,特别是对于应该表现相似的相邻位置。
如何解决二进制编码的问题?
大家理解 Transformer 如何解决二进制编码的缺点时,可以参考这幅图。
现在我们不再有 0 和 1 之间的突然跳跃,而是有了平滑的过渡。
如果大家仔细观察,你会发现曲线是正弦的——我们基本上用流动的正弦和余弦波替换了锯齿状的二进制步骤。
让我们画一个新的图——这次用正弦线,它可能看起来像这样:
显而易见的是,每个位置仍然有唯一的签名,但与二进制编码(需要多个“位翻转”来表示位置)不同,正弦编码仅用几个连续的波就实现了相同的效果,它既紧凑又平滑。
使正弦编码成为确定性的
现在,我们如何确保这种编码是一致且数学上合理的呢?这就是原论文中神奇公式发挥作用的地方:
该公式确保了以下几点:
-
编码具有确定性:每次进行编码都会得到相同的结果。
-
不同频率的波动:根据嵌入维度,波形的频率会有所不同。
-
正弦与余弦函数结合:二者共同作用,创造出交错排列的模式,从而最大程度地确保位置信息的多样性。
正弦位置编码
让我们重新回顾一下之前讨论的四个问题,看看正弦编码是如何分别解决它们的:
-
解决不连续性:
正弦函数和余弦函数的变化是平缓的。每一个新的位置都会导致向量值发生平滑的转变,不会出现突兀的二进制跳变。这种平滑性有助于改善梯度流动,使训练过程更加稳定。 -
捕捉相对位置:
由于正弦函数和余弦函数具有周期性以及平滑的梯度变化,不同位置之间的差异就变得有意义了。例如,这意味着:模型仅通过将两个位置的编码相减,就能知道这两个位置之间的距离。在二进制编码中,位置7(0111)和位置8(1000)之间没有任何关联,而现在,它们之间的差异是可预测且具有信息量的。
-
适用于任意长度的序列:
对于较长的句子,无需增加更多的位数。该公式适用于位置10或位置10000,无需对模型架构进行任何更改,也无需重新训练。模型能够对之前从未见过的位置进行编码。 -
稳定的优化(无梯度噪声):
由于正弦编码是固定的,并非通过学习获得,因此它不会增加可训练的参数。其数值始终保持在一个平滑、有界的范围内,这有助于防止梯度爆炸或更新过程出现不规律的情况。训练过程因此变得更快、更稳定。
简单来说正弦编码简洁且有效,完全契合Transformer的目标:在并行处理序列的同时,不丢失位置信息。
在结束位置编码的讨论之前,值得一提的是,“Attention Is All You Need”论文中提出的正弦方法如今已不再是应用最广泛的方式。
在现代架构中,旋转位置嵌入(Rotary Position Embeddings,简称RoPE)变得更为流行,不过,新的研究仍在基于相关理念不断推进,例如最近发表的这篇论文:arXiv:2410.06205。
但就目前而言,深入了解位置编码最初是如何引入的,以及它是如何实现并行处理的,就已经足够了。
在本文中,我们从原始文本如何被分词开始,到这些分词如何通过嵌入映射为向量,再到Transformer如何通过位置编码保留单词的顺序,虽然这些机制常常被架构中更引人注目的部分(如注意力机制)所掩盖,但它们对于模型理解语言来说绝对是至关重要的。