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 所示。
本章将介绍如何准备输入文本,以便训练 LLM。这包括将文本拆分为单个词元(token),词元可以是词或子词,随后再将其编码为 LLM 可使用的向量表示。你还会了解字节对编码(byte pair encoding,BPE)这类高级词元化方案;GPT 等流行 LLM 就使用了这种方案。最后,我们将实现一种采样与数据加载策略,用来生成后续章节训练 LLM 所需的输入-输出对。
2.1 理解词嵌入
包括 LLM 在内的深度神经网络模型无法直接处理原始文本。文本属于类别型数据,与实现和训练神经网络所需的数学运算并不兼容。因此,我们需要一种方式,把词表示为连续取值的向量。(如果读者不熟悉计算语境中的向量和张量,可以阅读附录 A 的 A2.2 节“理解张量”。)
将数据转换为向量格式的概念通常称为嵌入(embedding)。借助特定的神经网络层,或另一个预训练神经网络模型,我们可以把视频、音频、文本等不同数据类型嵌入为向量,如图 2.2 所示。
如图 2.2 所示,我们可以通过嵌入模型处理多种不同的数据格式。不过,需要注意的是,不同的数据格式需要不同的嵌入模型。例如,为文本设计的嵌入模型并不适合用来嵌入音频或视频数据。
从本质上说,嵌入是从离散对象(例如词、图像,甚至整篇文档)到连续向量空间中点的映射。嵌入的主要目的,是把非数值数据转换为神经网络可以处理的格式。
词嵌入是最常见的文本嵌入形式,但也存在句子、段落或整篇文档的嵌入。句子或段落嵌入是检索增强生成(retrieval-augmented generation,RAG)中的常见选择。检索增强生成把生成(例如生成文本)与检索(例如搜索外部知识库)结合起来,在生成文本时拉取相关信息;这项技术超出了本书范围。由于我们的目标是训练 GPT 类 LLM,而这类模型学习的是一次生成一个词的文本,因此本章将重点放在词嵌入上。
为了生成词嵌入,研究者已经开发出多种算法和框架。较早且非常流行的一个例子是 Word2Vec 方法。Word2Vec 训练一种神经网络架构,通过给定目标词预测其上下文,或反过来给定上下文预测目标词,来生成词嵌入。Word2Vec 背后的核心思想是:出现在相似上下文中的词往往具有相似含义。因此,为了可视化而把词嵌入投影到二维空间时,可以看到相似术语会聚集在一起,如图 2.3 所示。
词嵌入的维度可以不同,从一维到数千维不等。如图 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 所示。
我们将为 LLM 训练进行词元化的文本,是 Edith Wharton 的短篇小说 The Verdict。该作品已进入公有领域,因此可以用于 LLM 训练任务。文本可在 Wikisource 上获取:https://en.wikisource.org/wiki/The_Verdict。你可以把它复制粘贴到文本文件中;我把它保存为 the-verdict.txt,然后用 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 汇总的结果所示,我们的词元化方案现在已经可以成功处理文本中的多种特殊字符。
现在基本的词元化器已经可以工作,让我们把它应用到 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 所示。
上一节中,我们对 Edith Wharton 的短篇小说进行了词元化,并把结果赋给名为 preprocessed 的 Python 变量。现在,让我们创建所有唯一词元的列表,并按字母顺序排序,以确定词表大小:
all_words = sorted(set(preprocessed))
vocab_size = len(all_words)
print(vocab_size)
通过上面的代码确定词表大小为 1,130 后,我们创建词表,并打印其前 51 个条目用于说明:
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 所示。
本书后面,当我们希望把 LLM 的输出从数字转换回文本时,还需要一种把词元 ID 转回文本的方法。为此,可以创建一个反向版本的词表,将词元 ID 映射回对应的文本词元。
现在,我们用 Python 实现一个完整的词元化器类。它包含一个 encode 方法,用于把文本拆分为词元,并通过词表执行从字符串到整数的映射,生成词元 ID。此外,我们还实现一个 decode 方法,执行反向的整数到字符串映射,将词元 ID 转回文本。
这个词元化器实现的代码如清单 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
- 把词表保存为类属性,供
encode和decode方法访问。 - #B
- 创建一个反向词表,将词元 ID 映射回原始文本词元。
- #C
- 把输入文本处理成词元 ID。
- #D
- 把词元 ID 转换回文本。
- #E
- 替换指定标点前的空格。
使用上面的 SimpleTokenizerV1 Python 类,我们现在可以通过已有词表实例化新的词元化器对象,并用它编码和解码文本,如图 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 所示。
<|unk|> 词元,用来表示不属于训练数据、因而也不在现有词表中的新词或未知词。此外,我们还添加一个 <|endoftext|> 词元,可用于分隔两个彼此无关的文本来源。如图 2.9 所示,如果词元化器遇到一个不属于词表的词,我们可以修改它,使其使用 <|unk|> 词元。此外,我们会在无关文本之间添加一个词元。例如,在多个独立文档或书籍上训练 GPT 类 LLM 时,通常会在紧跟前一文本来源之后的每个文档或书籍前插入一个词元,如图 2.10 所示。这有助于 LLM 理解:虽然这些文本来源被拼接在一起用于训练,但它们实际上彼此无关。
<|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 所示:
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,不包含 Hello 和 palace 这两个词。
到目前为止,我们已经讨论了词元化,这是把文本作为输入提供给 LLM 时的一个基本步骤。根据不同 LLM 的设计,一些研究者还会考虑如下额外特殊词元:
[BOS](beginning of sequence,序列开始):该词元标记一段文本的开始。它向 LLM 表明一段内容从哪里开始。[EOS](end of sequence,序列结束):该词元位于文本末尾,在拼接多个无关文本时尤其有用,类似于<|endoftext|>。例如,当组合两篇不同的维基百科文章或两本书时,[EOS]词元表示一篇文章在哪里结束,下一篇从哪里开始。[PAD](padding,填充):当用大于 1 的批大小训练 LLM 时,一个批次中可能包含长度不同的文本。为了确保所有文本长度一致,较短的文本会使用[PAD]词元扩展或“填充”到该批次中最长文本的长度。
请注意,GPT 模型使用的词元化器不需要上面提到的任何词元;为简单起见,它只使用一个 <|endoftext|> 词元。<|endoftext|> 与上面提到的 [EOS] 词元类似。此外,<|endoftext|> 也被用于填充。不过,在后续章节研究批量输入训练时,我们通常会使用掩码,也就是说不会关注填充词元。因此,具体选择哪个词元用于填充并不重要。
另外,GPT 模型使用的词元化器也不为词表外词使用 <|unk|> 词元。相反,GPT 模型使用字节对编码词元化器,把词拆成子词单元;下一节将讨论这一点。
2.5 字节对编码
前几节中,我们为了说明概念实现了一个简单词元化方案。本节介绍一种更复杂的词元化方案,它基于字节对编码(byte pair encoding,BPE)这一概念。本节介绍的 BPE 词元化器曾用于训练 GPT-2、GPT-3 以及 ChatGPT 所使用的原始模型等 LLM。
由于实现 BPE 可能相当复杂,我们将使用一个现成的 Python 开源库 tiktoken(https://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 所示。
<|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 所示的输入-目标对。
首先,我们使用上一节介绍的 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:]
为下一个词预测任务创建输入-目标对,最简单且最直观的方法之一是创建两个变量 x 和 y,其中 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 所示。
x 中,其中每一行表示一个输入上下文。第二个张量 y 包含对应的预测目标(下一个词),它们是通过把输入右移一位创建的。虽然图 2.13 为了说明而以字符串格式展示词元,但代码实现会直接对词元 ID 进行操作,因为 BPE 词元化器的 encode 方法会在一个步骤中同时执行词元化和转换为词元 ID。
为了实现高效的数据加载器,我们将使用 PyTorch 内置的 Dataset 和 DataLoader 类。有关安装 PyTorch 的更多信息和指导,请参见附录 A 的 A.1.3 节“安装 PyTorch”。
数据集类的代码如清单 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 Dataset 和 DataLoader 类的一般结构和用法。
下面的代码会使用 GPTDatasetV1,通过 PyTorch DataLoader 以批的形式加载输入:
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 所示。
到目前为止,我们从数据加载器中采样的批大小都是 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 中概述的过程,还需要注意:作为预备步骤,我们会用随机值初始化这些嵌入权重。这种初始化是 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_size 和 output_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 所示。
本节介绍了如何从词元 ID 创建嵌入向量。本章最后一节会对这些嵌入向量做一个小修改,以编码词在文本中的位置信息。
2.8 编码词的位置
上一节中,我们把词元 ID 转换为连续向量表示,也就是所谓的词元嵌入。原则上,这已经是适合作为 LLM 输入的形式。然而,LLM 有一个小缺点:其自注意力机制(第 3 章会详细介绍)本身并没有序列中词元位置或顺序的概念。
前面介绍的嵌入层的工作方式是:无论某个词元 ID 位于输入序列中的什么位置,同一个词元 ID 总是映射到同一个向量表示,如图 2.17 所示。
原则上,词元 ID 的确定性、位置无关嵌入有利于可复现性。然而,由于 LLM 的自注意力机制本身也与位置无关,向 LLM 注入额外的位置信息是有帮助的。
绝对位置嵌入(absolute positional embedding)直接与序列中的具体位置相关联。对于输入序列中的每个位置,都会向词元嵌入添加一个唯一的嵌入,以传达其确切位置。例如,第一个词元会有一个特定的位置嵌入,第二个词元会有另一个不同的位置嵌入,依此类推,如图 2.18 所示。
与关注词元的绝对位置不同,相对位置嵌入(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.9 小结
- LLM 无法处理原始文本,因此需要把文本数据转换为称作嵌入的数值向量。嵌入把离散数据(例如词或图像)转换为连续向量空间,使其与神经网络运算兼容。
- 第一步是把原始文本拆成词元,词元可以是词或字符。随后,这些词元会转换为整数表示,即词元 ID。
- 可以添加
<|unk|>和<|endoftext|>等特殊词元,增强模型理解并处理各种上下文的能力,例如未知词,或标记无关文本之间的边界。 - GPT-2 和 GPT-3 等 LLM 使用的字节对编码(BPE)词元化器,可以通过把未知词拆成子词单元或单个字符来高效处理未知词。
- 我们在词元化后的数据上使用滑动窗口方法,为 LLM 训练生成输入-目标对。
- PyTorch 中的嵌入层以查找操作的形式工作,会取出与词元 ID 对应的向量。得到的嵌入向量为词元提供连续表示,这对于训练 LLM 等深度学习模型至关重要。
- 虽然词元嵌入为每个词元提供一致的向量表示,但它们缺乏词元在序列中所处位置的概念。为了解决这一点,存在两类主要位置嵌入:绝对位置嵌入和相对位置嵌入。OpenAI 的 GPT 模型使用绝对位置嵌入;这些位置嵌入会被加到词元嵌入向量上,并在模型训练过程中优化。
来源页码:PDF 物理页 22-63。图像从本地 PDF 提取并嵌入;未使用网络资源。