从零开始构建大语言模型(MEAP)(3)https://developer.aliyun.com/article/1510681
2.7 创建标记嵌入
为准备 LLM 训练的输入文本的最后一步是将标记 ID 转换为嵌入向量,如图 2.15 所示,这将是本章最后两个剩余部分的重点。
图 2.15 准备 LLM 输入文本涉及对文本进行标记化、将文本标记转换为标记 ID 和将标记 ID 转换为向量嵌入向量。在本节中,我们考虑前几节中创建的标记 ID 以创建标记嵌入向量。
连续向量表示,或嵌入,是必要的,因为类似 GPT 的 LLM 是使用反向传播算法训练的深度神经网络。如果您不熟悉神经网络如何使用反向传播进行训练,请阅读附录 A 中的第 A.4 节,简化的自动微分。
让我们用一个实际例子说明标记 ID 到嵌入向量转换是如何工作的。假设我们有以下三个带有 ID 5、1、3 和 2 的输入标记:
input_ids = torch.tensor([5, 1, 3, 2])
为了简单起见和说明目的,假设我们只有一个小的词汇表,其中只有 6 个单词(而不是 BPE 标记器词汇表中的 50,257 个单词),我们想创建大小为 3 的嵌入(在 GPT-3 中,嵌入大小为 12,288 维):
vocab_size = 6 output_dim = 3
使用 vocab_size
和 output_dim
,我们可以在 PyTorch 中实例化一个嵌入层,设置随机种子为 123 以便进行再现性:
torch.manual_seed(123) embedding_layer = torch.nn.Embedding(vocab_size, output_dim) print(embedding_layer.weight)
在上述代码示例中的打印语句打印了嵌入层的底层权重矩阵:
Parameter containing: tensor([[ 0.3374, -0.1778, -0.1690], [ 0.9178, 1.5810, 1.3010], [ 1.2753, -0.2010, -0.1606], [-0.4015, 0.9666, -1.1481], [-1.1589, 0.3255, -0.6315], [-2.8400, -0.7849, -1.4096]], requires_grad=True)
我们可以看到嵌入层的权重矩阵包含了小型的随机值。这些值在 LLM 训练过程中作为 LLM 优化的一部分而被优化,我们将在后续章节中看到。此外,我们可以看到权重矩阵有六行和三列。词汇表中的每个可能的标记都有一行。这三个嵌入维度中的每个维度都有一列。
在我们实例化嵌入层之后,现在让我们将其应用到一个标记 ID 上以获取嵌入向量:
print(embedding_layer(torch.tensor([3])))
返回的嵌入向量如下:
tensor([[-0.4015, 0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
如果我们将标记 ID 3 的嵌入向量与先前的嵌入矩阵进行比较,我们会看到它与第四行完全相同(Python 从零索引开始,所以它是与索引 3 对应的行)。换句话说,嵌入层本质上是一个查找操作,它通过标记 ID 从嵌入层的权重矩阵中检索行。
嵌入层与矩阵乘法
对于那些熟悉独热编码的人来说,上面的嵌入层方法实质上只是实施独热编码加上全连接层中的矩阵乘法更高效的一种方式,这在 GitHub 上的补充代码中进行了说明 github.com/rasbt/LLMs-from-scratch/tree/main/ch02/03_bonus_embedding-vs-matmul
。因为嵌入层只是一个更高效的等效实现,等同于独热编码和矩阵乘法方法,它可以看作是一个可以通过反向传播进行优化的神经网络层。
在之前,我们已经看到如何将单个标记 ID 转换为三维嵌入向量。现在让我们将其应用到我们之前定义的四个输入 ID 上 (torch.tensor([5, 1, 3, 2])
):
print(embedding_layer(input_ids))
打印输出显示,结果是一个 4x3 的矩阵:
tensor([[-2.8400, -0.7849, -1.4096], [ 0.9178, 1.5810, 1.3010], [-0.4015, 0.9666, -1.1481], [ 1.2753, -0.2010, -0.1606]], grad_fn=<EmbeddingBackward0>)
此输出矩阵中的每一行都是通过从嵌入权重矩阵中进行查找操作得到的,正如图 2.16 所示。
图 2.16 嵌入层执行查找操作,从嵌入层的权重矩阵中检索与标记 ID 对应的嵌入向量。例如,标记 ID 5 的嵌入向量是嵌入层权重矩阵的第六行(它是第六行而不是第五行,因为 Python 从 0 开始计数)。
本节介绍了如何从标记 ID 创建嵌入向量。本章的下一节也是最后一节,将对这些嵌入向量进行一些小的修改,以编码文本中标记的位置信息。
2.8 编码词的位置
在前一节中,我们将标记 ID 转换为连续的向量表示,即所谓的标记嵌入。从原则上讲,这对于 LLM 来说是一个合适的输入。然而,LLM 的一个小缺陷是,它们的自我注意机制(将详细介绍于第三章中)对于序列中的标记没有位置或顺序的概念。
先前介绍的嵌入层的工作方式是,相同的标记 ID 始终被映射到相同的向量表示,无论标记 ID 在输入序列中的位置如何,如图 2.17 所示。
图 2.17 嵌入层将标记 ID 转换为相同的向量表示,无论其在输入序列中的位置如何。例如,标记 ID 5,无论是在标记 ID 输入向量的第一个位置还是第三个位置,都会导致相同的嵌入向量。
从原则上讲,标记 ID 的确定性、位置无关的嵌入对于可重现性目的很好。然而,由于 LLM 的自我注意机制本身也是位置不可知的,向 LLM 注入额外的位置信息是有帮助的。
为了实现这一点,位置感知嵌入有两个广泛的类别:相对位置嵌入和绝对位置嵌入。
绝对位置嵌入与序列中的特定位置直接相关联。对于输入序列中的每个位置,都会添加一个唯一的嵌入,以传达其确切位置。例如,第一个标记将具有特定的位置嵌入,第二个标记是另一个不同的嵌入,依此类推,如图 2.18 所示。
图 2.18 位置嵌入被添加到标记嵌入向量中,用于创建 LLM 的输入嵌入。位置向量的维度与原始标记嵌入相同。为简单起见,标记嵌入显示为值 1。
相对位置嵌入不是关注一个标记的绝对位置,而是关注标记之间的相对位置或距离。这意味着模型学习的是关于“有多远”而不是“在哪个确切位置”。这里的优势在于,即使模型在训练期间没有看到这样的长度,它也能更好地概括不同长度的序列。
这两种位置嵌入的目标都是增强 LLM 理解标记之间的顺序和关系的能力,确保更准确和能够理解上下文的预测。它们之间的选择通常取决于特定的应用和正在处理的数据的性质。
OpenAI 的 GPT 模型使用的是在训练过程中进行优化的绝对位置嵌入,而不是像原始 Transformer 模型中的位置编码一样是固定或预定义的。这个优化过程是模型训练本身的一部分,我们稍后会在本书中实现。现在,让我们创建初始位置嵌入以创建即将到来的章节的 LLM 输入。
在本章中,我们之前专注于非常小的嵌入尺寸以进行举例说明。现在我们考虑更现实和有用的嵌入尺寸,并将输入令牌编码为 256 维向量表示。这比原始的 GPT-3 模型使用的要小(在 GPT-3 中,嵌入尺寸是 12,288 维),但对于实验仍然是合理的。此外,我们假设令牌 ID 是由我们先前实现的 BPE 标记器创建的,其词汇量为 50,257:
output_dim = 256 vocab_size = 50257 token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
使用上面的token_embedding_layer
,如果我们从数据加载器中取样数据,我们将每个批次中的每个令牌嵌入为一个 256 维的向量。如果我们的批次大小为 8,每个有四个令牌,结果将是一个 8x4x256 的张量。
让我们先从第 2.6 节“使用滑动窗口进行数据抽样”中实例化数据加载器:
max_length = 4 dataloader = create_dataloader( raw_text, batch_size=8, max_length=max_length, stride=5) data_iter = iter(dataloader) inputs, targets = next(data_iter) print("Token IDs:\n", inputs) print("\nInputs shape:\n", inputs.shape)
前面的代码打印如下输出:
Token IDs: tensor([[ 40, 367, 2885, 1464], [ 3619, 402, 271, 10899], [ 257, 7026, 15632, 438], [ 257, 922, 5891, 1576], [ 568, 340, 373, 645], [ 5975, 284, 502, 284], [ 326, 11, 287, 262], [ 286, 465, 13476, 11]]) Inputs shape: torch.Size([8, 4])
如我们所见,令牌 ID 张量是 8x4 维的,这意味着数据批次由 8 个文本样本组成,每个样本有 4 个令牌。
现在让我们使用嵌入层将这些令牌 ID 嵌入到 256 维的向量中:
token_embeddings = token_embedding_layer(inputs) print(token_embeddings.shape)
前面的打印函数调用返回以下内容:
torch.Size([8, 4, 256])
根据 8x4x256 维张量的输出,我们可以看出,现在每个令牌 ID 都嵌入为一个 256 维的向量。
对于 GPT 模型的绝对嵌入方法,我们只需要创建另一个具有与token_embedding_layer
相同维度的嵌入层:
block_size = max_length pos_embedding_layer = torch.nn.Embedding(block_size, output_dim) pos_embeddings = pos_embedding_layer(torch.arange(block_size)) print(pos_embeddings.shape)
如前面的代码示例所示,pos_embeddings 的输入通常是一个占位符向量torch.arange(block_size)
,其中包含一个数字序列 1、2、…、直到最大输入长度。block_size
是代表 LLM 的支持输入尺寸的变量。在这里,我们选择它类似于输入文本的最大长度。在实践中,输入文本可能比支持的块大小更长,在这种情况下,我们必须截断文本。文本还可以比块大小短,在这种情况下,我们填充剩余的输入以匹配块大小的占位符令牌,正如我们将在第三章中看到的。
打印语句的输出如下所示:
torch.Size([4, 256])
如我们所见,位置嵌入张量由四个 256 维向量组成。我们现在可以直接将它们添加到令牌嵌入中,PyTorch 将会将 4x256 维的pos_embeddings
张量添加到 8 个批次中每个 4x256 维的令牌嵌入张量中:
input_embeddings = token_embeddings + pos_embeddings print(input_embeddings.shape)
打印输出如下:
torch.Size([8, 4, 256])
我们创建的input_embeddings
,如图 2.19 所总结的,是嵌入的输入示例,现在可以被主 LLM 模块处理,我们将在第三章中开始实施它
图 2.19 作为输入处理流程的一部分,输入文本首先被分解为单独的标记。然后这些标记使用词汇表转换为标记 ID。标记 ID 转换为嵌入向量,与类似大小的位置嵌入相加,产生用作主 LLM 层输入的输入嵌入。
2.9 总结
- 由于 LLM 不能处理原始文本,所以需要将文本数据转换为数字向量,这些向量被称为嵌入。嵌入将离散数据(如文字或图像)转换为连续的向量空间,使其与神经网络操作兼容。
- 作为第一步,原始文本被分解为标记,这些标记可以是单词或字符。然后,这些标记被转换为整数表示,称为标记 ID。
- 特殊标记,比如
<|unk|>
和<|endoftext|>
,可以增强模型的理解并处理各种上下文,比如未知单词或标记无关文本的边界。 - 用于像 GPT-2 和 GPT-3 这样的 LLM 的字节对编码(BPE)分词器可以通过将未知单词分解为子词单元或单个字符来高效地处理未知单词。
- 我们在标记化数据上使用滑动窗口方法生成用于 LLM 训练的输入-目标对。
- PyTorch 中的嵌入层作为查找操作,检索与标记 ID 相对应的向量。结果嵌入向量提供了标记的连续表示,这对于训练像 LLM 这样的深度学习模型至关重要。
- 虽然标记嵌入为每个标记提供了一致的向量表示,但它缺乏对标记在序列中位置的感知。为了纠正这一点,存在两种主要类型的位置嵌入:绝对和相对。OpenAI 的 GPT 模型利用绝对位置嵌入,这些嵌入被加到标记嵌入向量中,并在模型训练过程中进行优化。
2.10 参考资料和进一步阅读
对嵌入空间和潜空间以及向量表达的一般概念感兴趣的读者,可以在我写的书《机器学习 Q 和 AI》的第一章中找到更多信息:
- 机器学习 Q 和 AI (2023) 由 Sebastian Raschka 著作,
leanpub.com/machine-learning-q-and-ai
- 以下论文更深入地讨论了字节对编码作为分词方法的使用:
- 《稀有词的子词单元神经机器翻译》(2015) 由 Sennrich 等人编写,
arxiv.org/abs/1508.07909
- 用于训练 GPT-2 的字节对编码分词器的代码已被 OpenAI 开源:
github.com/openai/gpt-2/blob/master/src/encoder.py
- OpenAI 提供了一个交互式 Web UI,以说明 GPT 模型中的字节对分词器的工作原理:
platform.openai.com/tokenizer
- 对于对研究其他流行 LLMs 使用的替代分词方案感兴趣的读者,可以在 SentencePiece 和 WordPiece 论文中找到更多信息:
- SentencePiece:一种简单且语言无关的子词分词器和去分词器,用于神经文本处理(2018),作者 Kudo 和 Richardson,
aclanthology.org/D18-2012/
- 快速 WordPiece 分词(2020),作者 Song 等人,
arxiv.org/abs/2012.15524
2.11 练习答案
练习答案的完整代码示例可以在补充的 GitHub 仓库中找到:github.com/rasbt/LLMs-from-scratch
练习 2.1
您可以通过一个字符串逐个提示编码器来获得单个标记 ID:
print(tokenizer.encode("Ak")) print(tokenizer.encode("w")) # ...
这将打印:
[33901] [86] # ...
然后,您可以使用以下代码来组装原始字符串:
print(tokenizer.decode([33901, 86, 343, 86, 220, 959]))
这将返回:
'Akwirw ier'