从零开始构建大型语言模型——处理文本数据

453 阅读37分钟

本章内容包括:

  • 为大型语言模型训练准备文本数据
  • 将文本拆分为单词和子词词元
  • 使用字节对编码(Byte Pair Encoding)作为更高级的文本标记化方法
  • 通过滑动窗口方法对训练示例进行采样
  • 将词元转换为输入大型语言模型的向量

到目前为止,我们已经介绍了大型语言模型(LLM)的一般结构,并了解到它们是在大量文本上进行预训练的。具体来说,我们重点关注了基于Transformer架构的仅解码器LLM,这也是ChatGPT和其他流行的类似GPT的LLM的基础。

在预训练阶段,LLM一次处理一个单词。使用下一个词预测任务训练具有数百万到数十亿参数的LLM,能够生成功能强大的模型。这些模型随后可以进一步微调,用于执行一般指令或特定的目标任务。但是,在实现和训练LLM之前,我们需要准备训练数据集,如图2.1所示。

image.png

你将学习如何为LLM的训练准备输入文本。这包括将文本拆分为单个单词和子词词元,然后将其编码为LLM所需的向量表示。你还将了解高级的标记化方案,例如字节对编码(Byte Pair Encoding),这种方法被像GPT这样的流行LLM所使用。最后,我们将实现一个采样和数据加载策略,以生成训练LLM所需的输入输出对。

理解词嵌入

深度神经网络模型,包括LLM,无法直接处理原始文本。由于文本是分类数据,无法与实现和训练神经网络所用的数学运算兼容。因此,我们需要一种方法将单词表示为连续值的向量。

注意 不熟悉计算上下文中向量和张量的读者可以参考附录A的第A.2.2节了解更多内容。

将数据转换为向量格式的概念通常称为嵌入。通过使用特定的神经网络层或另一个预训练的神经网络模型,我们可以嵌入不同的数据类型——例如视频、音频和文本,如图2.2所示。然而,值得注意的是,不同的数据格式需要不同的嵌入模型。例如,专为文本设计的嵌入模型不适合用于嵌入音频或视频数据。

image.png

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

尽管词嵌入是最常见的文本嵌入形式,也有句子、段落或整篇文档的嵌入。句子或段落嵌入在检索增强生成(retrieval-augmented generation)中非常受欢迎。检索增强生成将生成(例如生成文本)与检索(例如搜索外部知识库)相结合,在生成文本时提取相关信息,但这超出了本书的讨论范围。由于我们的目标是训练类似GPT的LLM,它们学习一次生成一个单词的文本,因此我们将专注于词嵌入。

已经开发了多种算法和框架来生成词嵌入。其中一个早期且最流行的例子是Word2Vec方法。Word2Vec通过预测目标单词的上下文或反过来预测上下文中的目标单词,训练神经网络架构来生成词嵌入。Word2Vec背后的主要思想是,出现在相似上下文中的单词往往具有相似的含义。因此,当将词嵌入投影到二维空间进行可视化时,类似的词汇会聚集在一起,如图2.3所示。

image.png

词嵌入的维度可以从一维到数千维不等。较高的维度可能会捕捉到更细微的关系,但会以计算效率为代价。

尽管我们可以使用像Word2Vec这样的预训练模型为机器学习模型生成嵌入,LLM通常会在输入层生成自己的嵌入,并在训练过程中不断更新。与使用Word2Vec相比,将嵌入作为LLM训练的一部分进行优化的优势在于,嵌入能够针对特定任务和数据进行优化。我们将在本章后面实现这种嵌入层。(LLM还可以生成上下文化的输出嵌入,我们将在第3章中讨论。)

不幸的是,高维嵌入在可视化上面临挑战,因为我们的感官感知和常见的图形表示方式本质上都限制在三维或更少的维度。因此,图2.3展示了二维散点图中的二维嵌入。然而,在使用LLM时,我们通常会使用更高维度的嵌入。例如,GPT-2和GPT-3的嵌入维度(通常称为模型隐状态的维度大小)根据具体的模型变体和大小有所不同,这是性能与效率之间的权衡。具体来说,最小的GPT-2模型(117M和125M参数)使用了768维的嵌入大小,而最大的GPT-3模型(175B参数)使用了12,288维的嵌入大小。

接下来,我们将逐步介绍准备LLM使用的嵌入所需的步骤,包括将文本拆分为单词、将单词转换为词元,并将词元转换为嵌入向量。

文本词元化

让我们讨论如何将输入文本拆分为单独的词元,这是为LLM创建嵌入所必需的预处理步骤。这些词元可以是单个单词或特殊字符,包括标点符号,如图2.4所示。

image.png

接下来我们讨论如何将输入文本拆分为单独的词元,这是为LLM创建嵌入所必需的预处理步骤。这些词元可以是单词或特殊字符,包括标点符号,如图2.4所示。

我们将对《判决》(The Verdict)这篇由Edith Wharton撰写的短篇小说进行词元化处理。这部作品已进入公共领域,因此可以用于LLM的训练任务。该文本可以在Wikisource上找到,链接为:en.wikisource.org/wiki/The_Ve…,您可以将其复制并粘贴到一个文本文件中,我已经将其保存为"the-verdict.txt"。

或者,您可以在本书的GitHub仓库中找到该文件,地址为:mng.bz/Adng。您可以使用以下Python代码下载该文件:

import urllib.request
url = ("https://raw.githubusercontent.com/rasbt/"
       "LLMs-from-scratch/main/ch02/01_main-chapter-code/"
       "the-verdict.txt")
file_path = "the-verdict.txt"
urllib.request.urlretrieve(url, file_path)

接下来,我们可以使用Python的标准文件读取工具加载"the-verdict.txt"文件。

代码示例 2.1 将短篇小说作为文本示例加载到Python中

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
print("Total number of characters:", len(raw_text))
print(raw_text[:99])

这段代码将打印文本中的字符总数,并输出前100个字符作为示例:

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

我们的目标是将这篇20479字符的短篇小说词元化为单词和特殊字符,之后将它们转换为LLM训练所需的嵌入向量。

注意 在使用LLM时,通常处理数百万篇文章和数十万本书——这些文本的总量可以达到数GB。然而,出于教学目的,使用较小的文本样本(如一本书)就足以说明文本处理步骤的主要思想,并能够在普通消费级硬件上合理运行。

那么,我们如何最好地拆分文本以获得词元列表?为此,我们将进行一个小练习,使用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', '.']

注意 在开发简单的词元化工具时,是否应将空白字符作为独立字符编码或将其删除,取决于具体的应用需求。删除空白字符可以减少内存和计算要求。然而,如果我们训练的是对文本结构(例如,Python代码中的缩进和空格)敏感的模型,保留空白字符可能很有用。这里,我们为了简化和缩短词元化输出,选择删除空白字符。稍后我们将切换到包含空白字符的词元化方案。

我们设计的词元化方案在简单的示例文本上效果不错。让我们进一步修改它,以便它也可以处理其他类型的标点符号,例如问号、引号以及我们在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所总结的那样,我们的词元化方案现在能够成功处理文本中的各种特殊字符。

image.png

现在我们已经有了一个基本的词元化工具,让我们将其应用于Edith Wharton的整篇短篇小说:

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

这条打印语句输出了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']

将词元转换为词元ID

接下来,我们将这些词元从Python字符串转换为整数表示形式,以生成词元ID。这一步是将词元ID转换为嵌入向量之前的中间步骤。

要将之前生成的词元映射为词元ID,我们首先需要构建一个词汇表。这个词汇表定义了我们如何将每个唯一的单词和特殊字符映射到一个唯一的整数,如图2.6所示。

image.png

现在我们已经对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所示)。

image.png

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

让我们用Python实现一个完整的词元化类,其中包含一个encode方法,该方法将文本拆分为词元,并通过词汇表进行字符串到整数的映射,以生成词元ID。此外,我们还将实现一个decode方法,该方法执行相反的整数到字符串的映射,将词元ID转换回文本。以下是该词元化工具的代码实现。

代码示例 2.3 实现一个简单的文本词元化工具

class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab            #1
        self.int_to_str = {i:s for s,i in vocab.items()}        #2

    def encode(self, text):         #3
        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):         #4
        text = " ".join([self.int_to_str[i] for i in ids]) 

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

代码说明:

  1. 将词汇表作为类属性存储,以便在encodedecode方法中访问。
  2. 创建一个逆词汇表,将词元ID映射回原始的文本词元。
  3. 处理输入文本并将其转换为词元ID。
  4. 将词元ID转换回文本。
  5. 移除指定标点符号前的空格。

现在,通过SimpleTokenizerV1类,我们可以通过已有的词汇表实例化新的词元化工具对象,进而用它来对文本进行编码和解码,如图2.8所示。

image.png

让我们从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:

[1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]

接下来,让我们看看是否可以使用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'

问题在于,短篇小说《The Verdict》中未使用过单词“Hello”,因此它不在词汇表中。这凸显了在处理LLM时需要考虑大规模且多样化的训练集以扩展词汇表的重要性。

接下来,我们将进一步测试该词元化工具处理包含未知单词的文本,并讨论可以在训练过程中为LLM提供更多上下文的额外特殊词元。

添加特殊上下文词元

我们需要修改词元化工具以处理未知单词,同时还需要解决使用和添加特殊上下文词元的问题,这些词元可以增强模型对上下文或文本中其他相关信息的理解。这些特殊词元可以包括标记未知单词和文档边界的词元。例如,我们将修改词汇表和词元化工具SimpleTokenizerV2,以支持两个新词元:<|unk|><|endoftext|>,如图2.9所示。

image.png

我们可以修改词元化工具,使其在遇到不在词汇表中的单词时使用<|unk|>词元。此外,我们还可以在不相关的文本之间添加一个特殊词元。例如,当在多个独立文档或书籍上训练类似GPT的LLM时,通常在每个文档或书籍之前插入一个词元,用以标记该文档或书籍与前一个文本来源无关,如图2.10所示。这有助于LLM理解,尽管这些文本来源在训练过程中被连接在一起,但它们实际上是彼此独立的。

image.png

现在我们将修改词汇表,添加两个特殊词元<|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()))

通过这段代码的输出,新词汇表的大小为1132(之前词汇表大小为1130)。

为了进一步验证,我们打印更新后的词汇表最后五个条目:

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

输出如下:

('younger', 1127)
('your', 1128)
('yourself', 1129)
('<|endoftext|>', 1130)
('<|unk|>', 1131)

从输出中可以确认,两个新的特殊词元已经成功加入到词汇表中。接下来,我们根据代码示例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            #1
                        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)    #2
        return text

与代码示例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.

接下来,让我们使用之前创建的词汇表,通过SimpleTokenizerV2对这个样本文本进行词元化:

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

输出的词元ID为:

[1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]

可以看到,列表中的1130对应于<|endoftext|>分隔词元,而两个1131词元用于表示未知单词。

让我们对词元化的文本进行去词元化,以进行快速检查:

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,研究人员有时还会考虑使用其他特殊词元,例如:

  • [BOS] (序列开始)——标记文本的开始,指示LLM内容从哪里开始。
  • [EOS] (序列结束)——标记文本的结束,在连接多个不相关的文本时尤其有用,类似于<|endoftext|>。例如,结合两篇不同的维基百科文章或书籍时,[EOS]词元表明一篇文章结束,下一篇文章开始。
  • [PAD] (填充)——在批量大小大于1的情况下训练LLM时,批量中的文本长度可能不同。为了确保所有文本长度一致,较短的文本使用[PAD]词元填充到最长文本的长度。

GPT模型的词元化工具不需要这些特殊词元;它只使用一个<|endoftext|>词元来保持简单。<|endoftext|>等同于[EOS]词元,并且也用于填充。然而,在后续章节中,我们将探讨在批量输入上训练时通常使用掩码,这意味着我们不会关注填充词元。因此,用于填充的具体词元变得无关紧要。

此外,GPT模型的词元化工具也不使用<|unk|>词元来处理超出词汇表的单词。相反,GPT模型使用字节对编码(BPE)词元化工具,它将单词拆分为子词单元,我们将在接下来讨论。

字节对编码(Byte Pair Encoding, BPE)

让我们来看一种更复杂的词元化方案,它基于字节对编码(BPE)的概念。BPE词元化工具被用于训练如GPT-2、GPT-3以及ChatGPT原始模型的LLM。

由于实现BPE相对复杂,我们将使用一个现成的开源Python库tiktokengithub.com/openai/tikt…),它基于Rust代码高效实现了BPE算法。与其他Python库类似,我们可以通过终端使用Python的pip安装器来安装tiktoken库:

pip install tiktoken

我们将使用的代码基于tiktoken 0.7.0。可以使用以下代码检查当前安装的版本:

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

安装完成后,我们可以通过tiktoken实例化BPE词元化工具,如下所示:

tokenizer = tiktoken.get_encoding("gpt2")

该词元化工具的使用方法与我们之前实现的SimpleTokenizerV2类似,使用encode方法:

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]

然后,我们可以使用decode方法将词元ID转换回文本,类似于我们的SimpleTokenizerV2

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

代码输出:

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

基于这些词元ID和解码文本,我们可以做出两点值得注意的观察。首先,<|endoftext|>词元被分配了一个较大的词元ID,即50256。事实上,BPE词元化工具的词汇表大小为50257,<|endoftext|>被分配了最大的词元ID。

其次,BPE词元化工具能够正确编码和解码未知单词,如someunknownPlace。BPE词元化工具能够处理任何未知单词。那么,它是如何在不使用<|unk|>词元的情况下实现这一点的呢?

BPE算法的核心是将不在预定义词汇表中的单词拆分为更小的子词单元,甚至是单个字符,从而能够处理超出词汇表范围的单词。因此,借助BPE算法,当词元化工具在词元化过程中遇到不熟悉的单词时,它可以将其表示为一系列子词词元或字符,如图2.11所示。

image.png

将未知单词拆分为单个字符的能力确保了词元化工具以及使用该工具训练的LLM可以处理任何文本,即使其中包含训练数据中不存在的单词。

练习2.1:未知单词的字节对编码

尝试使用tiktoken库中的BPE词元化工具处理未知单词“Akwirw ier”,并打印各个词元ID。然后,分别对列表中的每个整数调用decode函数,重现图2.11中显示的映射。最后,调用decode方法对词元ID进行解码,检查它是否能够重建原始输入“Akwirw ier”。

尽管本书不深入讨论和实现BPE算法,但简而言之,它通过迭代地将高频字符合并为子词,以及将高频子词合并为单词来构建词汇表。例如,BPE首先将所有单个字符添加到词汇表中(如“a”,“b”等)。在下一阶段,它将频繁一起出现的字符组合合并为子词。例如,“d”和“e”可以合并为子词“de”,它在许多英文单词中都常见,如“define”、“depend”、“made”和“hidden”。这些合并是根据频率阈值来确定的。

滑动窗口的数据采样

为LLM生成嵌入的下一步是生成训练LLM所需的输入-目标对。那么这些输入-目标对是什么样子的呢?正如我们已经了解到的,LLM的预训练是通过预测文本中的下一个词来进行的,如图2.12所示。

image.png

接下来,我们将实现一个数据加载器,它使用滑动窗口的方法从训练数据集中获取输入-目标对,如图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))

执行这段代码后,BPE词元化工具处理后的总词元数量为5145。

接下来,为了演示的目的,我们移除数据集中的前50个词元,这将在后续步骤中生成一个稍微更有趣的文本片段:

enc_sample = enc_text[50:]

创建下一个词预测任务的输入-目标对最简单、最直观的方法之一是创建两个变量xy,其中x包含输入词元,y包含目标词元,即将输入右移1个位置:

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

输出如下:

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

通过处理输入及其右移一个位置的目标,我们可以创建下一个词预测任务,如下所示:

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张量的形式返回输入和目标。PyTorch张量可以看作是多维数组。我们特别需要返回两个张量:一个输入张量,包含LLM看到的文本,另一个目标张量,包含LLM需要预测的目标,如图2.13所示。虽然图中为了说明使用了字符串形式的词元,但代码实现将直接对词元ID进行操作,因为BPE词元化工具的encode方法已经同时完成了词元化和词元ID的转换。

image.png

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

以下是数据集类的代码。

代码示例 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)    #1

        for i in range(0, len(token_ids) - max_length, stride):     #2
            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):    #3
        return len(self.input_ids)

    def __getitem__(self, idx):         #4
        return self.input_ids[idx], self.target_ids[idx]
  1. 对整个文本进行词元化处理。
  2. 使用滑动窗口将文本分割成最大长度的重叠序列。
  3. 返回数据集中总的行数。
  4. 返回数据集中的单行数据。

GPTDatasetV1类基于PyTorch的Dataset类,定义了如何从数据集中获取单个行,其中每行由指定max_length的词元ID组成的input_chunk张量。target_chunk张量包含相应的目标。接下来我们将使用PyTorch的DataLoader类来查看数据返回的具体情况,这将使概念更直观。

注意 如果你是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")                         #1
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)   #2
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last,     #3
        num_workers=num_workers     #4
    )

    return dataloader
  1. 初始化词元化工具。
  2. 创建数据集。
  3. drop_last=True意味着如果最后一个批次的大小小于指定的batch_size,则丢弃该批次,以避免训练时的损失峰值。
  4. 指定用于预处理的CPU进程数量。

让我们测试带有上下文大小为4的批处理数据加载器,以直观了解代码示例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)      #1
first_batch = next(data_iter)
print(first_batch)
  1. 将数据加载器转换为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相对于第一个批次向右偏移了一个位置(例如,第一个批次输入中的第二个ID是367,而这是第二个批次输入的第一个ID)。stride设置决定了批次之间输入的偏移数量,模拟了滑动窗口的方法,如图2.14所示。

image.png

练习2.2 不同步长和上下文大小的数据加载器

为了更好地理解数据加载器的工作原理,尝试使用不同的设置运行它,例如max_length=2stride=2,以及max_length=8stride=2

目前我们从数据加载器中提取的批次大小为1,这对于演示非常有用。如果你有深度学习的经验,可能知道较小的批次大小在训练期间需要更少的内存,但可能导致更多噪声的模型更新。与常规的深度学习一样,批次大小是一种权衡,并且是训练LLM时需要实验的超参数。

让我们简要看看如何使用数据加载器采样批次大小大于1的情况:

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

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]])

我们将步长(stride)增加到4,以充分利用数据集(不跳过任何单词)。这样可以避免批次之间的重叠,因为更多的重叠可能会导致过拟合的增加。

创建词元嵌入

为LLM训练准备输入文本的最后一步是将词元ID转换为嵌入向量,如图2.15所示。作为初步步骤,我们需要用随机值初始化这些嵌入权重。这种初始化作为LLM学习过程的起点。在第5章中,我们将通过LLM训练来优化这些嵌入权重。

image.png

连续向量表示(或嵌入)是必要的,因为类似GPT的LLM是通过反向传播算法训练的深度神经网络。

注意 如果你不熟悉神经网络如何通过反向传播进行训练,请参阅附录A的B.4节。

让我们通过一个实际示例来了解词元ID到嵌入向量的转换是如何工作的。假设我们有以下四个输入词元ID:2、3、5和1:

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

为了简化,假设我们只有一个包含6个词的词汇表(而不是BPE词元化工具的50257个词),并且我们希望创建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)

打印语句输出了嵌入层的底层权重矩阵:

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训练过程中会作为模型优化的一部分被调整。此外,我们可以看到该权重矩阵有6行3列,对应于词汇表中的6个可能词元,以及3个嵌入维度的每一列。

现在,让我们将其应用于词元ID,以获取嵌入向量:

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

返回的嵌入向量为:

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

如果我们将词元ID 3的嵌入向量与之前的嵌入矩阵进行对比,会发现它与矩阵的第四行相同(Python的索引从0开始,所以这是索引为3的行)。换句话说,嵌入层本质上是一种查找操作,它通过词元ID从嵌入层的权重矩阵中检索行。

注意 对于熟悉独热编码(one-hot encoding)的人来说,这里的嵌入层方法实际上是一种更高效的实现方式,它等价于独热编码加上全连接层中的矩阵乘法。相关代码可以在GitHub的补充代码中找到:mng.bz/ZEB5。由于嵌入层只是独热编码加矩阵乘法的更高效实现,因此可以将其视为通过反向传播优化的神经网络层。

我们已经看到了如何将单个词元ID转换为三维的嵌入向量。现在,让我们将其应用到所有四个输入ID上:

print(embedding_layer(input_ids))

输出显示结果为一个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所示。

image.png

现在我们已经从词元ID创建了嵌入向量,接下来我们将对这些嵌入向量进行一个小的修改,以编码词元在文本中的位置信息。

编码词元位置

原则上,词元嵌入是LLM的合适输入。然而,LLM的一个小缺陷在于其自注意力机制(详见第3章)并没有关于序列中词元位置或顺序的概念。先前介绍的嵌入层工作方式是相同的词元ID始终映射到相同的向量表示,而不管该词元ID在输入序列中的位置,如图2.17所示。

image.png

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

为实现这一点,我们可以使用两种主要类别的位置信息嵌入:相对位置嵌入绝对位置嵌入绝对位置嵌入与序列中的特定位置直接相关。对于输入序列中的每个位置,会有一个唯一的嵌入添加到词元的嵌入中,以表示它的确切位置。例如,第一个词元会有一个特定的位置嵌入,第二个词元则会有另一个不同的位置嵌入,依此类推,如图2.18所示。

image.png

与绝对位置嵌入不同,相对位置嵌入的重点是词元之间的相对位置或距离。这意味着模型学习的是词元之间的距离关系,而不是确切的位置。这种方式的优势在于,模型能够更好地泛化到不同长度的序列,即使在训练时没有遇到过这些长度。

两种类型的位置信息嵌入都旨在增强LLM理解词元顺序和关系的能力,确保更准确且具备上下文感知的预测。选择哪种嵌入类型通常取决于具体应用和数据的性质。OpenAI的GPT模型使用的是在训练过程中优化的绝对位置嵌入,而不是像原始Transformer模型那样固定或预定义的位置编码。这一优化过程是模型训练的一部分。

现在,让我们创建初始位置嵌入来生成LLM的输入。

之前我们为了简化,选择了非常小的嵌入维度。现在,我们将考虑更现实且有用的嵌入大小,并将输入词元编码为256维向量表示,这虽然比GPT-3的嵌入大小(12,288维)要小,但对于实验仍然合理。此外,我们假设词元ID是由我们先前实现的BPE词元化工具生成的,其词汇表大小为50257:

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

使用这个token_embedding_layer,如果我们从数据加载器中采样数据,就可以将每个批次中的每个词元嵌入为256维向量。如果我们有一个批次大小为8,每个批次包含四个词元,那么结果将是一个8 × 4 × 256的张量。

首先实例化数据加载器:

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 × 4,表示数据批次由八个文本样本组成,每个样本有四个词元。

接下来,我们使用嵌入层将这些词元ID嵌入为256维向量:

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

输出结果为:

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

这个8 × 4 × 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的序列数字。context_length表示LLM支持的输入大小。在实践中,输入文本可以比支持的上下文长度更长,这种情况下我们需要截断文本。

输出为:

torch.Size([4, 256])

我们可以看到,位置嵌入张量由四个256维的向量组成。现在,我们可以将这些向量直接添加到词元嵌入中,PyTorch会将4 × 256的pos_embeddings张量添加到每个批次中4 × 256的词元嵌入张量中:

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)

输出结果为:

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

我们创建的input_embeddings,如图2.19所总结的,现在可以被主LLM模块处理,我们将在下一章开始实现这些模块。

image.png

总结

LLMs(大型语言模型)需要将文本数据转换为数值向量(即嵌入),因为它们无法处理原始文本。嵌入将离散数据(如单词或图像)转换为连续的向量空间,使其能够与神经网络操作兼容。

第一步是将原始文本拆分为词元(可以是单词或字符),然后将这些词元转换为整数表示,称为词元ID。

可以添加特殊词元(如<|unk|><|endoftext|>),以增强模型的理解能力并处理各种上下文,例如处理未知词元或标记不相关文本之间的边界。

用于LLMs(如GPT-2和GPT-3)的字节对编码(BPE)词元化工具能够通过将未知词分解为子词单元或单个字符,来有效处理未知单词。

我们使用滑动窗口方法对词元化后的数据生成输入-目标对,供LLM训练使用。

PyTorch中的嵌入层执行查找操作,检索与词元ID对应的向量。生成的嵌入向量为词元提供了连续的表示,这是训练像LLM这样的深度学习模型的关键。

虽然词元嵌入为每个词元提供了一致的向量表示,但它们缺乏词元在序列中的位置感。为了解决这个问题,主要存在两种类型的位置嵌入:绝对位置嵌入相对位置嵌入。OpenAI的GPT模型使用绝对位置嵌入,这些嵌入向量与词元嵌入向量相加,并在模型训练过程中进行优化。