Build a Large Language Model (From Scratch) · Chapter 2 中文译文

处理文本数据

译自 Build a Large Language Model (From Scratch) MEAP V08 第二章 “Working with Text Data”。本文保留原章结构、图、清单、练习、注释、代码块与公式渲染支持;所有图像均以内嵌 data:image/png;base64 形式保存,MathJax 也由本地副本内联。

本章内容

  • 为大型语言模型训练准备文本。
  • 将文本拆分为词级词元和子词词元。
  • 把字节对编码作为一种更高级的文本词元化方法。
  • 使用滑动窗口方法采样训练样本。
  • 将词元转换为可输入大型语言模型的向量。

上一章介绍了大型语言模型(large language model,LLM)的总体结构,并说明它们是在海量文本上预训练得到的。具体来说,我们关注的是基于 Transformer 架构的仅解码器 LLM;ChatGPT 以及其他流行的 GPT 类 LLM 所使用的模型都以这种架构为基础。

在预训练阶段,LLM 一次处理一个词。用下一个词预测任务训练拥有数百万到数十亿参数的 LLM,可以得到能力惊人的模型。之后,这些模型还可以进一步微调,使其遵循通用指令或执行特定目标任务。不过,在后续章节实现并训练 LLM 之前,我们需要先准备训练数据集;这正是本章的重点,如图 2.1 所示。

图 2.1
图 2.1 一个心智模型,展示编写 LLM、在通用文本数据集上预训练 LLM,以及在带标签数据集上微调 LLM 这三个主要阶段。本章将解释并编写数据准备和采样流水线,为 LLM 的预训练提供文本数据。

本章将介绍如何准备输入文本,以便训练 LLM。这包括将文本拆分为单个词元(token),词元可以是词或子词,随后再将其编码为 LLM 可使用的向量表示。你还会了解字节对编码(byte pair encoding,BPE)这类高级词元化方案;GPT 等流行 LLM 就使用了这种方案。最后,我们将实现一种采样与数据加载策略,用来生成后续章节训练 LLM 所需的输入-输出对。

2.1 理解词嵌入

包括 LLM 在内的深度神经网络模型无法直接处理原始文本。文本属于类别型数据,与实现和训练神经网络所需的数学运算并不兼容。因此,我们需要一种方式,把词表示为连续取值的向量。(如果读者不熟悉计算语境中的向量和张量,可以阅读附录 A 的 A2.2 节“理解张量”。)

将数据转换为向量格式的概念通常称为嵌入(embedding)。借助特定的神经网络层,或另一个预训练神经网络模型,我们可以把视频、音频、文本等不同数据类型嵌入为向量,如图 2.2 所示。

图 2.2
图 2.2 深度学习模型无法直接处理视频、音频和文本等原始格式的数据。因此,我们使用嵌入模型,把这些原始数据转换为深度学习架构更容易理解和处理的稠密向量表示。具体来说,本图展示了把原始数据转换为三维数值向量的过程。

如图 2.2 所示,我们可以通过嵌入模型处理多种不同的数据格式。不过,需要注意的是,不同的数据格式需要不同的嵌入模型。例如,为文本设计的嵌入模型并不适合用来嵌入音频或视频数据。

从本质上说,嵌入是从离散对象(例如词、图像,甚至整篇文档)到连续向量空间中点的映射。嵌入的主要目的,是把非数值数据转换为神经网络可以处理的格式。

词嵌入是最常见的文本嵌入形式,但也存在句子、段落或整篇文档的嵌入。句子或段落嵌入是检索增强生成(retrieval-augmented generation,RAG)中的常见选择。检索增强生成把生成(例如生成文本)与检索(例如搜索外部知识库)结合起来,在生成文本时拉取相关信息;这项技术超出了本书范围。由于我们的目标是训练 GPT 类 LLM,而这类模型学习的是一次生成一个词的文本,因此本章将重点放在词嵌入上。

为了生成词嵌入,研究者已经开发出多种算法和框架。较早且非常流行的一个例子是 Word2Vec 方法。Word2Vec 训练一种神经网络架构,通过给定目标词预测其上下文,或反过来给定上下文预测目标词,来生成词嵌入。Word2Vec 背后的核心思想是:出现在相似上下文中的词往往具有相似含义。因此,为了可视化而把词嵌入投影到二维空间时,可以看到相似术语会聚集在一起,如图 2.3 所示。

图 2.3
图 2.3 如果词嵌入是二维的,我们就可以像这里一样在二维散点图中绘制它们以便可视化。使用 Word2Vec 等词嵌入技术时,对应相似概念的词通常会在嵌入空间中彼此接近。例如,与国家和城市相比,不同类型的鸟类在嵌入空间中更接近彼此。

词嵌入的维度可以不同,从一维到数千维不等。如图 2.3 所示,我们可以为了可视化而选择二维词嵌入。更高的维度可能捕捉更细微的关系,但代价是计算效率降低。

虽然我们可以使用 Word2Vec 之类的预训练模型为机器学习模型生成嵌入,但 LLM 通常会生成自己的嵌入:这些嵌入是输入层的一部分,并会在训练过程中更新。与使用 Word2Vec 相比,把嵌入作为 LLM 训练的一部分进行优化,其优势在于嵌入会针对手头的具体任务和数据进行优化。我们将在本章后面实现这样的嵌入层。此外,LLM 还可以创建上下文化的输出嵌入,这一点会在第 3 章讨论。

遗憾的是,高维嵌入给可视化带来了挑战,因为我们的感知能力和常见图形表示天然受限于三维或更低维度,这也是图 2.3 使用二维散点图展示二维嵌入的原因。然而,在使用 LLM 时,我们通常会采用比图 2.3 所示高得多的嵌入维度。对于 GPT-2 和 GPT-3,嵌入大小(常称为模型隐藏状态的维度)会随具体模型变体和规模而变化。这是在性能与效率之间的权衡。举一个具体例子,最小的 GPT-2 模型(117M 和 125M 参数)使用 768 维嵌入。最大的 GPT-3 模型(175B 参数)使用 12,288 维嵌入。

本章接下来的各节将逐步介绍为 LLM 准备嵌入所需的步骤,包括把文本拆成词、把词转换为词元,以及把词元转换为嵌入向量。

2.2 对文本进行词元化

本节介绍如何把输入文本拆分为单个词元。这是为 LLM 创建嵌入所必需的预处理步骤。这些词元可以是单个词,也可以是特殊字符,包括标点符号,如图 2.4 所示。

图 2.4
图 2.4 从 LLM 语境下观察本节介绍的文本处理步骤。这里,我们把输入文本拆分为单个词元,这些词元要么是词,要么是特殊字符(例如标点符号)。在后续各节中,我们会把文本转换为词元 ID,并创建词元嵌入。

我们将为 LLM 训练进行词元化的文本,是 Edith Wharton 的短篇小说 The Verdict。该作品已进入公有领域,因此可以用于 LLM 训练任务。文本可在 Wikisource 上获取:https://en.wikisource.org/wiki/The_Verdict。你可以把它复制粘贴到文本文件中;我把它保存为 the-verdict.txt,然后用 Python 的标准文件读取工具加载:

清单 2.1 把一篇短篇小说作为文本样本读入 Python
with open("the-verdict.txt", "r", encoding="utf-8") as f:
     raw_text = f.read()
print("Total number of character:", len(raw_text))
print(raw_text[:99])

也可以在本书的 GitHub 仓库中找到这个 the-verdict.txt 文件:https://github.com/rasbt/LLMs-from-scratch/tree/main/ch02/01_main-chapter-code

print 命令会先打印该文件的字符总数,然后为了演示打印前 100 个字符:

Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so
it was no

我们的目标是把这篇 20,479 个字符的短篇小说词元化为单个词和特殊字符,以便在后续章节中将其转换为 LLM 训练所需的嵌入。

怎样才能最好地拆分这段文本,从而得到一个词元列表?为此,我们先做一个小插曲,用 Python 的正则表达式库 re 来演示。(注意,你不必学习或记忆任何正则表达式语法,因为本章稍后会转向预构建的词元化器。)

使用一小段示例文本时,我们可以用如下语法调用 re.split,按空白字符拆分文本:

import re
text = "Hello, world. This, is a test."
result = re.split(r'(\s)', text)
print(result)

结果是由单个词、空白和标点符号组成的列表:

['Hello,', ' ', 'world.', ' ', 'This,', ' ', 'is', ' ', 'a', ' ', 'test.']

请注意,上面的简单词元化方案大体上能把示例文本拆成单个词;不过,一些词仍然与标点符号连在一起,而我们希望这些标点符号成为独立的列表项。我们也不会把所有文本都转成小写,因为大小写能帮助 LLM 区分专有名词和普通名词、理解句子结构,并学习生成大小写正确的文本。

接下来修改正则表达式,让它按空白字符(\s)、逗号和句点([,.])进行拆分:

result = re.split(r'([,.]|\s)', text)
print(result)

可以看到,词和标点符号现在都成为了独立的列表项,这正是我们想要的:

['Hello', ',', '', ' ', 'world', '.', '', ' ', 'This', ',', '', ' ', 'is', ' ', 'a', '
', 'test', '.', '']

剩下的一个小问题是,列表中仍包含空白字符。我们可以像下面这样安全地去掉这些冗余字符:

result = [item for item in result if item.strip()]
print(result)

去除空白后的输出如下:

['Hello', ',', 'world', '.', 'This', ',', 'is', 'a', 'test', '.']

上面设计的词元化方案在这个简单样本文本上运行良好。我们再稍微修改它,使其也能处理其他类型的标点,例如问号、引号,以及前面在 Edith Wharton 短篇小说前 100 个字符中看到的双连字符;同时也加入一些其他特殊字符:

text = "Hello, world. Is this-- a test?"
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)

得到的输出如下:

['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']

如图 2.5 汇总的结果所示,我们的词元化方案现在已经可以成功处理文本中的多种特殊字符。

图 2.5
图 2.5 到目前为止实现的词元化方案会把文本拆分为单个词和标点符号。在本图所示的具体例子中,样本文本被拆分为 10 个独立词元。

现在基本的词元化器已经可以工作,让我们把它应用到 Edith Wharton 的整篇短篇小说上:

preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))

上面的 print 语句输出 4690,这就是这段文本中的词元数量(不包含空白)。

为了快速目视检查,我们打印前 30 个词元:

print(preprocessed[:30])

得到的输出表明,我们的词元化器似乎能很好地处理文本,因为所有词和特殊字符都被整齐地分开了:

['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius',
'--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great',
'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']

2.3 将词元转换为词元 ID

上一节中,我们把 Edith Wharton 的短篇小说词元化为单个词元。本节会把这些词元从 Python 字符串转换为整数表示,从而生成所谓的词元 ID(token ID)。这是把词元 ID 转换为嵌入向量之前的中间步骤。

为了把前面生成的词元映射为词元 ID,我们首先必须构建一个所谓的词表(vocabulary)。词表定义了如何把每个唯一的词和特殊字符映射到一个唯一整数,如图 2.6 所示。

图 2.6
图 2.6 我们通过把训练数据集中的整段文本词元化为单个词元来构建词表。随后对这些词元按字母顺序排序,并移除重复词元。唯一词元会被汇总为一个词表,该词表定义了从每个唯一词元到唯一整数值的映射。图中词表有意设置得很小,仅用于说明;为简单起见,它不包含标点或特殊字符。

上一节中,我们对 Edith Wharton 的短篇小说进行了词元化,并把结果赋给名为 preprocessed 的 Python 变量。现在,让我们创建所有唯一词元的列表,并按字母顺序排序,以确定词表大小:

all_words = sorted(set(preprocessed))
vocab_size = len(all_words)
print(vocab_size)

通过上面的代码确定词表大小为 1,130 后,我们创建词表,并打印其前 51 个条目用于说明:

清单 2.2 创建词表
vocab = {token:integer for integer,token in enumerate(all_words)}
for i, item in enumerate(vocab.items()):
      print(item)
      if i > 50:
           break

输出如下:

('!', 0)
('"', 1)
("'", 2)
...
('Her', 49)
('Hermia', 50)

从上面的输出可以看到,字典包含了单个词元以及与之关联的唯一整数标签。接下来的目标是应用这个词表,把新文本转换为词元 ID,如图 2.7 所示。

图 2.7
图 2.7 从一个新的文本样本开始,我们先对文本进行词元化,然后使用词表把文本词元转换为词元 ID。词表由整个训练集构建而来,可以应用于训练集本身以及任何新的文本样本。为简单起见,图中的词表不包含标点或特殊字符。

本书后面,当我们希望把 LLM 的输出从数字转换回文本时,还需要一种把词元 ID 转回文本的方法。为此,可以创建一个反向版本的词表,将词元 ID 映射回对应的文本词元。

现在,我们用 Python 实现一个完整的词元化器类。它包含一个 encode 方法,用于把文本拆分为词元,并通过词表执行从字符串到整数的映射,生成词元 ID。此外,我们还实现一个 decode 方法,执行反向的整数到字符串映射,将词元 ID 转回文本。

这个词元化器实现的代码如清单 2.3 所示:

清单 2.3 实现一个简单文本词元化器
class SimpleTokenizerV1:
     def __init__(self, vocab):
          self.str_to_int = vocab                                                      #A
          self.int_to_str = {i:s for s,i in vocab.items()}                             #B


     def encode(self, text):                                                           #C
          preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
          preprocessed = [item.strip() for item in preprocessed if item.strip()]
          ids = [self.str_to_int[s] for s in preprocessed]
          return ids


     def decode(self, ids):                                                            #D
          text = " ".join([self.int_to_str[i] for i in ids])


          text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)                              #E
          return text

#A Store the vocabulary as a class attribute for access in the encode and decode methods
#B Create an inverse vocabulary that maps token IDs back to the original text tokens
#C Process input text into token IDs
#D Convert token IDs back into text
#E Replace spaces before the specified punctuation
#A
把词表保存为类属性,供 encodedecode 方法访问。
#B
创建一个反向词表,将词元 ID 映射回原始文本词元。
#C
把输入文本处理成词元 ID。
#D
把词元 ID 转换回文本。
#E
替换指定标点前的空格。

使用上面的 SimpleTokenizerV1 Python 类,我们现在可以通过已有词表实例化新的词元化器对象,并用它编码和解码文本,如图 2.8 所示。

图 2.8
图 2.8 词元化器实现通常共享两个方法:encode 方法和 decode 方法。encode 方法接收样本文本,将其拆分为单个词元,并通过词表把词元转换为词元 ID。decode 方法接收词元 ID,把它们转换回文本词元,并将文本词元拼接成自然文本。

让我们从 SimpleTokenizerV1 类实例化一个新的词元化器对象,并对 Edith Wharton 短篇小说中的一段文字进行词元化,实际试用一下:

tokenizer = SimpleTokenizerV1(vocab)
text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable
pride."""
ids = tokenizer.encode(text)
print(ids)

上面的代码会打印对应的词元 ID。接下来,让我们看看能否使用 decode 方法把这些词元 ID 转回文本:

print(tokenizer.decode(ids))

这会输出如下文本:

'" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.'

根据上面的输出可以看到,decode 方法成功把词元 ID 转回了原始文本。

到目前为止进展顺利。我们实现了一个词元化器,能够基于训练集中的片段进行词元化和反词元化。现在把它应用到一个不包含在训练集中的新文本样本上:

text = "Hello, do you like tea?"
print(tokenizer.encode(text))

执行上面的代码会得到如下错误:

...
KeyError: 'Hello'

问题在于,Hello 这个词没有出现在 The Verdict 这篇短篇小说中。因此,它不在词表里。这凸显出,在使用 LLM 时需要考虑规模大且多样的训练集,以扩展词表。

下一节中,我们会在包含未知词的文本上进一步测试词元化器,并讨论一些额外的特殊词元;这些词元可用于在训练期间为 LLM 提供更多上下文。

2.4 添加特殊上下文词元

上一节中,我们实现了一个简单词元化器,并把它应用到训练集中的一段文字。本节会修改这个词元化器,使其能够处理未知词。

具体来说,我们会修改上一节实现的词表和词元化器,得到 SimpleTokenizerV2,让它支持两个新词元:<|unk|><|endoftext|>,如图 2.9 所示。

图 2.9
图 2.9 我们向词表中添加特殊词元,以处理某些上下文。例如,添加 <|unk|> 词元,用来表示不属于训练数据、因而也不在现有词表中的新词或未知词。此外,我们还添加一个 <|endoftext|> 词元,可用于分隔两个彼此无关的文本来源。

如图 2.9 所示,如果词元化器遇到一个不属于词表的词,我们可以修改它,使其使用 <|unk|> 词元。此外,我们会在无关文本之间添加一个词元。例如,在多个独立文档或书籍上训练 GPT 类 LLM 时,通常会在紧跟前一文本来源之后的每个文档或书籍前插入一个词元,如图 2.10 所示。这有助于 LLM 理解:虽然这些文本来源被拼接在一起用于训练,但它们实际上彼此无关。

图 2.10
图 2.10 处理多个独立文本来源时,我们会在这些文本之间添加 <|endoftext|> 词元。这些 <|endoftext|> 词元充当标记,表示某个片段的开始或结束,从而让 LLM 更有效地处理和理解文本。

现在,让我们修改词表,把这两个特殊词元 <|unk|><|endoftext|> 加进去。做法是把它们添加到上一节创建的所有唯一词列表中:

all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])
vocab = {token:integer for integer,token in enumerate(all_tokens)}


print(len(vocab.items()))

根据上面 print 语句的输出,新词表大小是 1,161(上一节中的词表大小是 1,159)。

作为额外的快速检查,让我们打印更新后词表的最后 5 个条目:

for i, item in enumerate(list(vocab.items())[-5:]):
     print(item)

上面的代码会打印如下内容:

('younger', 1156)
('your', 1157)
('yourself', 1158)
('<|endoftext|>', 1159)
('<|unk|>', 1160)

根据上面的代码输出可以确认,这两个新的特殊词元确实已经成功加入词表。接下来,我们相应地调整清单 2.3 中的词元化器,如清单 2.4 所示:

清单 2.4 一个能处理未知词的简单文本词元化器
class SimpleTokenizerV2:
    def __init__(self, vocab):
         self.str_to_int = vocab
         self.int_to_str = { i:s for s,i in vocab.items()}


    def encode(self, text):
         preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
         preprocessed = [item.strip() for item in preprocessed if item.strip()]
         preprocessed = [item if item in self.str_to_int                        #A
                             else "<|unk|>" for item in preprocessed]


         ids = [self.str_to_int[s] for s in preprocessed]
         return ids


    def decode(self, ids):
         text = " ".join([self.int_to_str[i] for i in ids])


         text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)                        #B
         return text

#A replace unknown words by <|unk|> tokens
#B Replace spaces before the specified punctuations
#A
<|unk|> 词元替换未知词。
#B
替换指定标点前的空格。

与上一节清单 2.3 中实现的 SimpleTokenizerV1 相比,新的 SimpleTokenizerV2 会用 <|unk|> 词元替换未知词。

text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
text = " <|endoftext|> ".join((text1, text2))
print(text)

输出如下:

'Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.'

接下来,让我们使用前面在清单 2.2 中创建的词表,通过 SimpleTokenizerV2 对样本文本进行词元化:

tokenizer = SimpleTokenizerV2(vocab)
print(tokenizer.encode(text))

这会打印如下词元 ID:

[1160, 5, 362, 1155, 642, 1000, 10, 1159, 57, 1013, 981, 1009, 738, 1013, 1160, 7]

上面可以看到,词元 ID 列表中包含表示 <|endoftext|> 分隔词元的 1159,以及两个 1160 词元,后者用于未知词。

print(tokenizer.decode(tokenizer.encode(text)))

输出如下:

'<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.'

把上面的反词元化文本与原始输入文本比较后可知,训练数据集,也就是 Edith Wharton 的短篇小说 The Verdict,不包含 Hellopalace 这两个词。

到目前为止,我们已经讨论了词元化,这是把文本作为输入提供给 LLM 时的一个基本步骤。根据不同 LLM 的设计,一些研究者还会考虑如下额外特殊词元:

请注意,GPT 模型使用的词元化器不需要上面提到的任何词元;为简单起见,它只使用一个 <|endoftext|> 词元。<|endoftext|> 与上面提到的 [EOS] 词元类似。此外,<|endoftext|> 也被用于填充。不过,在后续章节研究批量输入训练时,我们通常会使用掩码,也就是说不会关注填充词元。因此,具体选择哪个词元用于填充并不重要。

另外,GPT 模型使用的词元化器也不为词表外词使用 <|unk|> 词元。相反,GPT 模型使用字节对编码词元化器,把词拆成子词单元;下一节将讨论这一点。

2.5 字节对编码

前几节中,我们为了说明概念实现了一个简单词元化方案。本节介绍一种更复杂的词元化方案,它基于字节对编码(byte pair encoding,BPE)这一概念。本节介绍的 BPE 词元化器曾用于训练 GPT-2、GPT-3 以及 ChatGPT 所使用的原始模型等 LLM。

由于实现 BPE 可能相当复杂,我们将使用一个现成的 Python 开源库 tiktokenhttps://github.com/openai/tiktoken)。它基于 Rust 源代码非常高效地实现了 BPE 算法。与其他 Python 库类似,我们可以在终端中通过 Python 的 pip 安装器安装 tiktoken

pip install tiktoken

本章代码基于 tiktoken 0.5.1。你可以使用下面的代码检查当前安装的版本:

from importlib.metadata import version
import tiktoken
print("tiktoken version:", version("tiktoken"))

安装完成后,可以像下面这样从 tiktoken 实例化 BPE 词元化器:

tokenizer = tiktoken.get_encoding("gpt2")

这个词元化器的用法与前面通过 encode 方法实现的 SimpleTokenizerV2 类似:

text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of
someunknownPlace."
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)

上面的代码会打印如下词元 ID:

[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286,
617, 34680, 27271, 13]

随后可以像前面的 SimpleTokenizerV2 那样,使用 decode 方法把词元 ID 转回文本:

strings = tokenizer.decode(integers)
print(strings)

上面的代码会打印如下内容:

'Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.'

基于上面的词元 ID 和解码文本,我们可以观察到两点值得注意的现象。首先,<|endoftext|> 词元被分配了一个相对较大的词元 ID,即 50256。事实上,用于训练 GPT-2、GPT-3 以及 ChatGPT 原始模型等模型的 BPE 词元化器总词表大小为 50,257,其中 <|endoftext|> 被分配了最大的词元 ID。

其次,上面的 BPE 词元化器能够正确编码和解码未知词,例如 someunknownPlace。BPE 词元化器可以处理任何未知词。它是如何在不使用 <|unk|> 词元的情况下做到这一点的?

BPE 底层算法会把不在其预定义词表中的词拆分为更小的子词单元,甚至拆成单个字符,从而让它能够处理词表外词。因此,借助 BPE 算法,如果词元化器在词元化过程中遇到陌生词,它可以把该词表示为一串子词词元或字符,如图 2.11 所示。

图 2.11
图 2.11 BPE 词元化器会把未知词拆分为子词和单个字符。这样,BPE 词元化器就可以解析任何词,而不需要用 <|unk|> 等特殊词元替换未知词。

如图 2.11 所示,把未知词拆分为单个字符的能力,确保词元化器以及用它训练的 LLM 能够处理任何文本,即便文本中包含训练数据中没有出现过的词。

对 BPE 的详细讨论和实现超出了本书范围。简而言之,它通过迭代地把高频字符合并为子词、再把高频子词合并为词来构建词表。例如,BPE 首先会把所有单个字符加入词表("a""b",……)。下一阶段,它会把经常一起出现的字符组合合并成子词。例如,"d""e" 可能被合并为子词 "de",这个子词在许多英语词中都很常见,如 "define""depend""made""hidden"。这些合并由频率阈值决定。

2.6 使用滑动窗口进行数据采样

上一节非常详细地介绍了词元化步骤,以及从字符串词元到整数词元 ID 的转换。在最终能够为 LLM 创建嵌入之前,下一步是生成训练 LLM 所需的输入-目标对。

这些输入-目标对是什么样的?正如第 1 章所学,LLM 通过预测文本中的下一个词进行预训练,如图 2.12 所示。

图 2.12
图 2.12 给定一个文本样本,从中提取输入块作为子样本,提供给 LLM;LLM 在训练期间的预测任务是预测紧随该输入块之后的下一个词。训练时,我们会遮蔽目标之后的所有词。请注意,图中显示的文本在 LLM 能够处理之前会先经过词元化;但为了清楚起见,本图省略了词元化步骤。

本节将使用滑动窗口方法实现一个数据加载器,从训练数据集中取出图 2.12 所示的输入-目标对。

首先,我们使用上一节介绍的 BPE 词元化器,对之前使用的整篇 The Verdict 短篇小说进行词元化:

with open("the-verdict.txt", "r", encoding="utf-8") as f:
     raw_text = f.read()


enc_text = tokenizer.encode(raw_text)
print(len(enc_text))

执行上面的代码会返回 5145,也就是应用 BPE 词元化器后训练集中的词元总数。

接下来,出于演示目的,我们从数据集中移除前 50 个词元,因为这样会让后续步骤中的文本片段稍微更有意思:

enc_sample = enc_text[50:]

为下一个词预测任务创建输入-目标对,最简单且最直观的方法之一是创建两个变量 xy,其中 x 包含输入词元,而 y 包含目标,也就是把输入右移 1 位后的结果:

context_size = 4 #A
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y:         {y}")

#A The context size determines how many tokens are included in the input
#A
上下文大小决定输入中包含多少个词元。

运行上面的代码会打印如下输出:

x: [290, 4920, 2241, 287]
y:        [4920, 2241, 287, 257]

把输入与目标(即右移一位后的输入)一起处理,就可以创建前面图 2.12 中展示的下一个词预测任务,如下所示:

for i in range(1, context_size+1):
      context = enc_sample[:i]
      desired = enc_sample[i]
      print(context, "---->", desired)

上面的代码会打印如下内容:

[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257

箭头(---->)左侧的所有内容都表示 LLM 会接收的输入,右侧的词元 ID 表示 LLM 应该预测的目标词元 ID。

为了说明这一点,让我们重复前面的代码,但把词元 ID 转换为文本:

for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))

下面的输出展示了输入和输出在文本格式下是什么样子:

and ---->   established
and established ---->     himself
and established himself ---->       in
and established himself in ---->         a

现在我们已经创建了输入-目标对,后续章节可以把这些输入-目标对用于 LLM 训练。

在把词元转换为嵌入之前,还剩一个任务:实现一个高效的数据加载器,使其能够遍历输入数据集,并返回输入和目标的 PyTorch 张量。可以把张量理解为多维数组。

具体来说,我们希望返回两个张量:一个输入张量,包含 LLM 所看到的文本;一个目标张量,包含 LLM 要预测的目标,如图 2.13 所示。

图 2.13
图 2.13 为了实现高效的数据加载器,我们把输入收集到张量 x 中,其中每一行表示一个输入上下文。第二个张量 y 包含对应的预测目标(下一个词),它们是通过把输入右移一位创建的。

虽然图 2.13 为了说明而以字符串格式展示词元,但代码实现会直接对词元 ID 进行操作,因为 BPE 词元化器的 encode 方法会在一个步骤中同时执行词元化和转换为词元 ID。

为了实现高效的数据加载器,我们将使用 PyTorch 内置的 DatasetDataLoader 类。有关安装 PyTorch 的更多信息和指导,请参见附录 A 的 A.1.3 节“安装 PyTorch”。

数据集类的代码如清单 2.5 所示:

清单 2.5 用于批量输入和目标的数据集
import torch
from torch.utils.data import Dataset, DataLoader


class GPTDatasetV1(Dataset):
     def __init__(self, txt, tokenizer, max_length, stride):
          self.input_ids = []
          self.target_ids = []


          token_ids = tokenizer.encode(txt)                                      #A


          for i in range(0, len(token_ids) - max_length, stride):                #B
               input_chunk = token_ids[i:i + max_length]
               target_chunk = token_ids[i + 1: i + max_length + 1]
               self.input_ids.append(torch.tensor(input_chunk))
               self.target_ids.append(torch.tensor(target_chunk))


     def __len__(self):                                                          #C
          return len(self.input_ids)


     def __getitem__(self, idx):                                                 #D
          return self.input_ids[idx], self.target_ids[idx]

#A Tokenize the entire text
#B Use a sliding window to chunk the book into overlapping sequences of max_length
#C Return the total number of rows in the dataset
#D Return a single row from the dataset
#A
对整段文本进行词元化。
#B
使用滑动窗口,把这本书切分成长度为 max_length 的重叠序列。
#C
返回数据集中的总行数。
#D
返回数据集中的单行。

清单 2.5 中的 GPTDatasetV1 类基于 PyTorch 的 Dataset 类,并定义如何从数据集中取出单行。每一行由若干词元 ID 组成(由 max_length 决定),并被赋给 input_chunk 张量。target_chunk 张量包含对应的目标。我建议继续读下去,看看当我们把这个数据集与 PyTorch 的 DataLoader 结合时,返回的数据是什么样的;这会带来更多直觉和清晰度。

如果你不熟悉清单 2.5 所示这类 PyTorch Dataset 类的结构,请阅读附录 A 的 A.6 节“搭建高效数据加载器”,其中解释了 PyTorch DatasetDataLoader 类的一般结构和用法。

下面的代码会使用 GPTDatasetV1,通过 PyTorch DataLoader 以批的形式加载输入:

清单 2.6 用于生成输入-目标对批次的数据加载器
def create_dataloader_v1(txt, batch_size=4, max_length=256,
          stride=128, shuffle=True, drop_last=True, num_workers=0):
     tokenizer = tiktoken.get_encoding("gpt2")                                        #A
     dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)                         #B
     dataloader = DataLoader(
          dataset,
          batch_size=batch_size,
          shuffle=shuffle,
          drop_last=drop_last,                                                          #C
          num_workers=0                                                                 #D
     )


     return dataloader

#A Initialize the tokenizer
#B Create dataset
#C drop_last=True drops the last batch if it is shorter than the specified batch_size to prevent loss spikes
during training
#D The number of CPU processes to use for preprocessing
#A
初始化词元化器。
#B
创建数据集。
#C
drop_last=True 会在最后一个批次短于指定 batch_size 时丢弃它,以防训练期间损失出现尖峰。
#D
用于预处理的 CPU 进程数量。

让我们用批大小为 1、上下文大小为 4 的 LLM 来测试数据加载器,以培养对清单 2.5 中 GPTDatasetV1 类和清单 2.6 中 create_dataloader_v1 函数如何协同工作的直觉:

with open("the-verdict.txt", "r", encoding="utf-8") as f:
     raw_text = f.read()


dataloader = create_dataloader_v1(
     raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
data_iter = iter(dataloader)                                                            #A
first_batch = next(data_iter)
print(first_batch)

#A convert dataloader into a Python iterator to fetch the next entry via Python's built-in next() function
#A
把数据加载器转换为 Python 迭代器,以便通过 Python 内置的 next() 函数取出下一个条目。

执行前面的代码会打印如下内容:

[tensor([[        40,   367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]

first_batch 变量包含两个张量:第一个张量存储输入词元 ID,第二个张量存储目标词元 ID。由于 max_length 设置为 4,这两个张量各包含 4 个词元 ID。请注意,输入大小 4 相对很小,这里只是为了说明。训练 LLM 时,常见做法是使用至少 256 的输入大小。

为了说明 stride=1 的含义,让我们从这个数据集中再取一个批次:

second_batch = next(data_iter)
print(second_batch)

第二个批次包含如下内容:

[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]

如果比较第一个批次和第二个批次,可以看到第二个批次的词元 ID 相比第一个批次右移了一个位置(例如,第一个批次输入中的第二个 ID 是 367,它也是第二个批次输入中的第一个 ID)。stride 设置决定输入在不同批次之间移动多少个位置,从而模拟滑动窗口方法,如图 2.14 所示。

图 2.14
图 2.14 从输入数据集创建多个批次时,我们会让一个输入窗口在文本上滑动。如果步幅设为 1,创建下一个批次时输入窗口就移动 1 个位置。如果把步幅设为输入窗口大小,就可以避免批次之间发生重叠。

到目前为止,我们从数据加载器中采样的批大小都是 1,这有助于说明概念。如果你以前有深度学习经验,可能知道较小批大小在训练时需要的内存更少,但会导致模型更新噪声更大。与常规深度学习一样,批大小也是一个需要在训练 LLM 时实验的权衡项和超参数。

在进入本章最后两节,也就是从词元 ID 创建嵌入向量之前,我们先简要看看如何使用数据加载器以大于 1 的批大小进行采样:

dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4)


data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)

这会打印如下内容:

Inputs:
 tensor([[       40,     367,    2885,    1464],
           [ 1807,     3619,     402,    271],
           [10899,     2138,     257,    7026],
           [15632,     438,     2016,    257],
           [   922,    5891,    1576,    438],
           [   568,    340,      373,    645],
           [ 1049,     5975,     284,    502],
           [   284,    3285,     326,     11]])


Targets:
 tensor([[      367,    2885,    1464,    1807],
           [ 3619,     402,      271, 10899],
           [ 2138,     257,     7026, 15632],
           [   438,    2016,     257,    922],
           [ 5891,     1576,     438,    568],
           [   340,    373,      645,    1049],
           [ 5975,     284,      502,    284],
           [ 3285,     326,       11,    287]])

请注意,我们把步幅增加到 4。这样做是为了充分利用数据集(不跳过任何一个词),同时也避免批次之间出现重叠,因为更多重叠可能导致过拟合增加。

在本章最后两节中,我们将实现嵌入层,把词元 ID 转换为连续向量表示;这些向量会作为 LLM 的输入数据格式。

2.7 创建词元嵌入

为 LLM 训练准备输入文本的最后一步,是把词元 ID 转换为嵌入向量,如图 2.15 所示。这也是本章最后两节的重点。

图 2.15
图 2.15 为 LLM 准备输入文本包括:对文本进行词元化、把文本词元转换为词元 ID,以及把词元 ID 转换为向量嵌入。本节会使用前面各节创建的词元 ID 来生成词元嵌入向量。

除了图 2.15 中概述的过程,还需要注意:作为预备步骤,我们会用随机值初始化这些嵌入权重。这种初始化是 LLM 学习过程的起点。第 5 章中,我们会把嵌入权重作为 LLM 训练的一部分进行优化。

连续向量表示,即嵌入,是必要的,因为 GPT 类 LLM 是用反向传播算法训练的深度神经网络。如果你不熟悉神经网络如何通过反向传播训练,请阅读附录 A 的 A.4 节“让自动微分变得简单”。

让我们通过一个动手示例说明词元 ID 到嵌入向量的转换是如何工作的。假设我们有如下 4 个输入词元,它们的 ID 分别为 2、3、5 和 1:

input_ids = torch.tensor([2, 3, 5, 1])

为了简单和说明起见,假设我们有一个仅包含 6 个词的小词表(而不是 BPE 词元化器词表中的 50,257 个词),并且希望创建大小为 3 的嵌入(在 GPT-3 中,嵌入大小是 12,288 维):

vocab_size = 6
output_dim = 3

使用 vocab_sizeoutput_dim,我们可以在 PyTorch 中实例化一个嵌入层,并把随机种子设置为 123 以便结果可复现:

torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)

前面代码示例中的 print 语句会打印嵌入层底层的权重矩阵:

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 优化本身的一部分被优化,后续章节会看到这一点。此外,我们还可以看到权重矩阵有 6 行、3 列。词表中 6 个可能词元中的每一个都对应一行;3 个嵌入维度中的每一个都对应一列。

实例化嵌入层之后,现在把它应用到一个词元 ID 上,得到嵌入向量:

print(embedding_layer(torch.tensor([3])))

返回的嵌入向量如下:

tensor([[-0.4015,   0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)

如果把词元 ID 3 对应的嵌入向量与前面的嵌入矩阵比较,会发现它与第 4 行完全相同(Python 从 0 开始索引,所以这是索引 3 对应的行)。换句话说,嵌入层本质上是一个查找操作:通过词元 ID 从嵌入层的权重矩阵中取出对应行。

前面我们已经看到如何把单个词元 ID 转换为三维嵌入向量。现在把它应用到之前定义的全部 4 个输入 ID(torch.tensor([2, 3, 5, 1])):

print(embedding_layer(input_ids))

print 输出显示,结果是一个 4×3 矩阵:

tensor([[ 1.2753, -0.2010, -0.1606],
        [-0.4015,   0.9666, -1.1481],
        [-2.8400, -0.7849, -1.4096],
        [ 0.9178,   1.5810,   1.3010]], grad_fn=<EmbeddingBackward0>)

这个输出矩阵中的每一行,都是通过从嵌入权重矩阵中执行查找操作得到的,如图 2.16 所示。

图 2.16
图 2.16 嵌入层执行查找操作:从嵌入层的权重矩阵中取出与词元 ID 对应的嵌入向量。例如,词元 ID 5 的嵌入向量是嵌入层权重矩阵的第 6 行(之所以是第 6 行而不是第 5 行,是因为 Python 从 0 开始计数)。为便于说明,我们假设这些词元 ID 由第 2.3 节中使用的小词表生成。

本节介绍了如何从词元 ID 创建嵌入向量。本章最后一节会对这些嵌入向量做一个小修改,以编码词在文本中的位置信息。

2.8 编码词的位置

上一节中,我们把词元 ID 转换为连续向量表示,也就是所谓的词元嵌入。原则上,这已经是适合作为 LLM 输入的形式。然而,LLM 有一个小缺点:其自注意力机制(第 3 章会详细介绍)本身并没有序列中词元位置或顺序的概念。

前面介绍的嵌入层的工作方式是:无论某个词元 ID 位于输入序列中的什么位置,同一个词元 ID 总是映射到同一个向量表示,如图 2.17 所示。

图 2.17
图 2.17 无论词元 ID 位于输入序列中的什么位置,嵌入层都会把它转换为相同的向量表示。例如,词元 ID 5 无论位于词元 ID 输入向量的第 1 个位置还是第 3 个位置,都会得到相同的嵌入向量。

原则上,词元 ID 的确定性、位置无关嵌入有利于可复现性。然而,由于 LLM 的自注意力机制本身也与位置无关,向 LLM 注入额外的位置信息是有帮助的。

绝对位置嵌入(absolute positional embedding)直接与序列中的具体位置相关联。对于输入序列中的每个位置,都会向词元嵌入添加一个唯一的嵌入,以传达其确切位置。例如,第一个词元会有一个特定的位置嵌入,第二个词元会有另一个不同的位置嵌入,依此类推,如图 2.18 所示。

图 2.18
图 2.18 把位置嵌入加到词元嵌入向量上,形成 LLM 的输入嵌入。位置向量与原始词元嵌入具有相同维度。为了简单起见,图中的词元嵌入以数值 1 表示。

与关注词元的绝对位置不同,相对位置嵌入(relative positional embedding)强调词元之间的相对位置或距离。这意味着模型学习的是“相隔多远”这样的关系,而不是“位于哪个确切位置”。这种方法的优势在于,即使模型在训练期间没有见过某些长度的序列,也能更好地泛化到不同长度的序列。

这两类位置嵌入都旨在增强 LLM 理解词元顺序和词元之间关系的能力,从而得到更准确、上下文感知更强的预测。二者之间的选择通常取决于具体应用和所处理数据的性质。

OpenAI 的 GPT 模型使用绝对位置嵌入;这些位置嵌入会在训练过程中被优化,而不是像原始 Transformer 模型中的位置编码那样固定或预先定义。这个优化过程是模型训练本身的一部分,本书后面会实现它。现在,我们先创建初始位置嵌入,为后续章节创建 LLM 输入。

前面为了说明,本章一直关注很小的嵌入大小。现在我们考虑更真实、更有用的嵌入大小,把输入词元编码为 256 维向量表示。这比原始 GPT-3 模型使用的维度小(GPT-3 中嵌入大小为 12,288 维),但仍适合实验。此外,我们假设词元 ID 是由前面实现的 BPE 词元化器创建的,该词元化器的词表大小为 50,257:

vocab_size = 50257
output_dim = 256
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

使用上面的 token_embedding_layer 时,如果我们从数据加载器中采样数据,就会把每个批次中的每个词元都嵌入为一个 256 维向量。如果批大小为 8,且每个样本有 4 个词元,那么结果将是一个 \(8 \times 4 \times 256\) 张量。

首先,让我们实例化第 2.6 节“使用滑动窗口进行数据采样”中的数据加载器:

max_length = 4
dataloader = create_dataloader_v1(
     raw_text, batch_size=8, max_length=max_length, stride=max_length, shuffle=False)
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],
         [ 1807,      3619,     402,    271],
         [10899,      2138,     257,    7026],
         [15632,      438,     2016,    257],
         [   922,     5891,    1576,    438],
         [   568,     340,      373,    645],
         [ 1049,      5975,     284,    502],
         [   284,     3285,     326,     11]])


Inputs shape:
 torch.Size([8, 4])

可以看到,词元 ID 张量是 \(8 \times 4\) 维,这意味着该数据批次由 8 个文本样本组成,每个样本包含 4 个词元。

现在使用嵌入层,把这些词元 ID 嵌入为 256 维向量:

token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)

前面的 print 函数调用会返回如下内容:

torch.Size([8, 4, 256])

\(8 \times 4 \times 256\) 维张量输出可以看出,每个词元 ID 现在都被嵌入为一个 256 维向量。

对于 GPT 模型的绝对嵌入方法,我们只需要再创建一个与 token_embedding_layer 维度相同的嵌入层:

context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)

如前面的代码示例所示,pos_embeddings 的输入通常是一个占位向量 torch.arange(context_length),其中包含从 0、1、……一直到最大输入长度减 1 的数字序列。context_length 是一个变量,表示 LLM 支持的输入大小。这里,我们选择让它与输入文本的最大长度相同。在实践中,输入文本可能长于支持的上下文长度,在这种情况下我们必须截断文本。

print 语句的输出如下:

torch.Size([4, 256])

可以看到,位置嵌入张量由 4 个 256 维向量组成。现在可以直接把它们加到词元嵌入上;PyTorch 会把 \(4 \times 256\) 维的 pos_embeddings 张量,分别加到 8 个批次中每个 \(4 \times 256\) 维的词元嵌入张量上:

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)
torch.Size([8, 4, 256])

我们创建的 input_embeddings 如图 2.19 所总结,它们是经过嵌入后的输入示例,现在可以由主要的 LLM 模块处理;第 3 章将开始实现这些模块。

图 2.19
图 2.19 作为输入处理流水线的一部分,输入文本首先被拆分为单个词元。随后使用词表把这些词元转换为词元 ID。词元 ID 再被转换为嵌入向量,并加上大小相近的位置嵌入,最终得到输入嵌入,作为主要 LLM 层的输入。

2.9 小结

来源页码:PDF 物理页 22-63。图像从本地 PDF 提取并嵌入;未使用网络资源。