从零开始构建大语言模型(MEAP)(2)https://developer.aliyun.com/article/1510680
2.4 添加特殊上下文标记
在上一节中,我们实现了一个简单的标记器,并将其应用于训练集中的一个段落。在本节中,我们将修改这个标记器来处理未知单词。
我们还将讨论使用和添加特殊上下文标记的用法,这些标记可以增强模型对文本中上下文或其他相关信息的理解。这些特殊标记可以包括未知单词和文档边界的标记,例如。
具体来说,我们将修改上一节中实现的词汇表和标记器 SimpleTokenizerV2,以支持两个新的标记<|unk|>
和<|endoftext|>
,如图 2.8 所示。
图 2.9 我们向词汇表中添加特殊标记来处理特定上下文。 例如,我们添加一个<|unk|>标记来表示训练数据中没有出现过的新单词,因此不是现有词汇表的一部分。 此外,我们添加一个<|endoftext|>标记,用于分隔两个无关的文本源。
如图 2.9 所示,我们可以修改标记器,在遇到不在词汇表中的单词时使用<|unk|>
标记。 此外,我们在无关的文本之间添加一个标记。 例如,在训练多个独立文档或书籍的 GPT-like LLM 时,通常会在每个文档或书籍之前插入一个标记,用于指示这是前一个文本源的后续文档或书籍,如图 2.10 所示。 这有助于 LLM 理解,尽管这些文本源被连接起来进行训练,但实际上它们是无关的。
图 2.10 当处理多个独立的文本源时,我们在这些文本之间添加<|endoftext|>
标记。 这些<|endoftext|>
标记充当标记,标志着特定段落的开始或结束,让 LLM 更有效地处理和理解。
现在让我们修改词汇表,以包括这两个特殊标记和
<|endoftext|>
,通过将它们添加到我们在上一节中创建的所有唯一单词列表中:
all_words.extend(["<|endoftext|>", "<|unk|>"]) vocab = {token:integer for integer,token in enumerate(all_tokens)} print(len(vocab.items()))
根据上述打印语句的输出,新的词汇表大小为 1161(上一节的词汇表大小为 1159)。
作为额外的快速检查,让我们打印更新后词汇表的最后 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
与我们在上一节代码清单 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:
[1160, 5, 362, 1155, 642, 1000, 10, 1159, 57, 1013, 981, 1009, 738, 1013, 1160, 7]
从上面可以看到,令牌 ID 列表包含 1159 个<|endoftext|>分隔符令牌,以及两个用于未知单词的 160 个令牌。
让我们对文本进行反标记,做一个快速的检查:
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”。
到目前为止,我们已经讨论了分词作为将文本处理为 LLMs 输入的基本步骤。根据 LLM,一些研究人员还考虑其他特殊标记,如下所示:
[BOS]
(序列开始):该标记标志着文本的开始。它向 LLM 表示内容的开始位置。[EOS]
(序列结束):该标记位于文本末尾,当连接多个不相关的文本时特别有用,类似于<|endoftext|>
。例如,当合并两篇不同的维基百科文章或书籍时,[EOS]
标记指示一篇文章的结束和下一篇文章的开始位置。[PAD]
(填充):当使用大于一的批次大小训练 LLMs 时,批次可能包含不同长度的文本。为确保所有文本具有相同长度,较短的文本将使用[PAD]
标记进行扩展或“填充”,直到批次中最长文本的长度。
请注意,用于 GPT 模型的分词器不需要上述提到的任何这些标记,而仅使用<|endoftext|>
标记简化。<|endoftext|>
类似于上述的[EOS]
标记。此外,<|endoftext|>
也用于填充。然而,在后续章节中,当在批量输入上训练时,我们通常使用掩码,意味着我们不关注填充的标记。因此,所选择的特定填充标记变得不重要。
此外,用于 GPT 模型的分词器也不使用<|unk|>
标记来表示词汇表中没有的单词。相反,GPT 模型使用字节对编码分词器,将单词拆分为子词单元,我们将在下一节中讨论。
2.5 字节对编码
我们在前几节中实现了一个简单的分词方案,用于说明目的。本节介绍基于称为字节对编码(BPE)的概念的更复杂的分词方案。本节介绍的 BPE 分词器用于训练 LLMs,如 GPT-2、GPT-3 和 ChatGPT。
由于实现 BPE 可能相对复杂,我们将使用一个名为tiktoken(github.com/openai/tiktoken
)的现有 Python 开源库,该库基于 Rust 中的源代码非常有效地实现了 BPE 算法。与其他 Python 库类似,我们可以通过 Python 的终端上的pip
安装程序安装 tiktoken 库:
pip install tiktoken
本章中的代码基于 tiktoken 0.5.1。您可以使用以下代码检查当前安装的版本:
import importlib import tiktoken print("tiktoken version:", importlib.metadata.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]
然后,我们可以使用解码方法将标记 ID 转换回文本,类似于我们之前的SimpleTokenizerV2
:
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 分词器具有总共 50257 个词汇,其中<|endoftext|>
被分配了最大的标记 ID。
第二,上述的 BPE 分词器可以正确地对未知单词进行编码和解码,例如"someunknownPlace"。BPE 分词器可以处理任何未知单词。它是如何在不使用<|unk|>
标记的情况下实现这一点的?
BPE 算法的基础是将不在其预定义词汇表中的单词分解为更小的子词单元甚至是单个字符,使其能够处理词汇表之外的词汇。因此,多亏了 BPE 算法,如果分词器在分词过程中遇到陌生的单词,它可以将其表示为一系列子词标记或字符,如图 2.11 所示。
图 2.11 BPE 分词器将未知单词分解为子词和单个字符。这样,BPE 分词器可以解析任何单词,无需用特殊标记(如<|unk|>
)替换未知单词。
如图 2.11 所示,将未知单词分解为单个字符的能力确保了分词器以及随之训练的 LLM 可以处理任何文本,即使其中包含了其训练数据中未出现的单词。
练习 2.1 未知单词的字节对编码
尝试从 tiktoken 库中使用 BPE 分词器对未知单词"Akwirw ier",并打印各个标记的 ID。然后,在此列表中的每个生成的整数上调用解码函数,以重现图 2.1 中显示的映射。最后,在标记 ID 上调用解码方法以检查是否可以重建原始输入,即"Akwirw ier"。
本书不讨论 BPE 的详细讨论和实现,但简而言之,它通过迭代地将频繁出现的字符合并为子词和频繁出现的子词合并为单词来构建其词汇表。例如,BPE 从将所有单个字符添加到其词汇表开始(“a”,“b”,…)。在下一阶段,它将经常一起出现的字符组合成子词。例如,“d"和"e"可能会合并成子词"de”,在许多英文单词中很常见,如"define",“depend”,“made"和"hidden”。合并是由频率截止确定的。
2.6 滑动窗口数据采样
前一节详细介绍了标记化步骤以及将字符串标记转换为整数标记 ID 之后,我们最终可以为 LLM 生成所需的输入-目标对,以用于训练 LLM。
这些输入-目标对是什么样子?正如我们在第一章中学到的那样,LLMs 是通过预测文本中的下一个单词来进行预训练的,如图 2.12 所示。
图 2.12 给定一个文本样本,提取作为 LLM 输入的子样本的输入块,并且在训练期间,LLM 的预测任务是预测跟随输入块的下一个单词。在训练中,我们屏蔽所有超过目标的单词。请注意,在 LLM 可处理文本之前,此图中显示的文本会进行 tokenization;但为了清晰起见,该图省略了 tokenization 步骤。
在此部分中,我们实现了一个数据加载器,使用滑动窗口方法从训练数据集中提取图 2.12 中所示的输入-目标对。
为了开始,我们将使用前面介绍的 BPE tokenizer 对我们之前使用的《裁决》短篇小说进行标记化处理:
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 tokenizer 后训练集中的总标记数。
接下来,为了演示目的,让我们从数据集中删除前 50 个标记,因为这会使接下来的文本段落稍微有趣一些:
enc_sample = enc_text[50:]
创建下一个单词预测任务的输入-目标对最简单直观的方法之一是创建两个变量,x
和 y
,其中 x
包含输入标记,y
包含目标,即将输入向后移动一个位置的输入:
context_size = 4 #A 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]
处理输入以及目标(即向后移动了一个位置的输入),我们可以创建如图 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 为了实现高效的数据加载器,我们将输入都收集到一个张量 x 中,其中每一行代表一个输入上下文。第二个张量 y 包含对应的预测目标(下一个单词),它们是通过将输入向后移动一个位置来创建的。
虽然图 2.13 展示了字符串格式的 token 以进行说明,但代码实现将直接操作 token ID,因为 BPE 标记器的 encode 方法执行了 tokenization 和转换为 token ID 为单一步骤。
对于高效的数据加载器实现,我们将使用 PyTorch 内置的 Dataset 和 DataLoader 类。有关安装 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.tokenizer = tokenizer 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]
图 2.5 中的GPTDatasetV1
类基于 PyTorch 的Dataset
类,定义了如何从数据集中获取单独的行,其中每一行都包含一系列基于max_length
分配给input_chunk
张量的 token ID。target_chunk
张量包含相应的目标。我建议继续阅读,看看当我们将数据集与 PyTorch 的DataLoader
结合使用时,这个数据集返回的数据是什么样的——这将带来额外的直觉和清晰度。
如果您对 PyTorch 的Dataset
类的结构(如图 2.5 所示)是新手,请阅读附录 A 的A.6,设置高效的数据加载器一节,其中解释了 PyTorch 的Dataset
和DataLoader
类的一般结构和用法。
以下代码将使用GPTDatasetV1
通过 PyTorch 的DataLoader
来批量加载输入:
图 2.6 用于生成带输入对的批次的数据加载器
def create_dataloader(txt, batch_size=4, max_length=256, stride=128): tokenizer = tiktoken.get_encoding("gpt2") #A dataset = GPTDatasetV1(txt, tokenizer, max_length, stride) #B dataloader = DataLoader(dataset, batch_size=batch_size) #C return dataloader
让我们测试dataloader
,将一个上下文大小为 4 的 LLM 的批量大小设为 1,以便理解图 2.5 的GPTDatasetV1
类和图 2.6 的create_dataloader
函数如何协同工作。
with open("the-verdict.txt", "r", encoding="utf-8") as f: raw_text = f.read() dataloader = create_dataloader(raw_text, batch_size=1, max_length=4, stride=1) data_iter = iter(dataloader) #A first_batch = next(data_iter) print(first_batch)
执行前面的代码将打印以下内容:
[tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
first_batch
变量包含两个张量:第一个张量存储输入 token ID,第二个张量存储目标 token ID。由于max_length
设置为 4,这两个张量每个都包含 4 个 token ID。值得注意的是,输入大小为 4 相对较小,仅用于说明目的。通常会用至少 256 的输入大小来训练 LLMs。
为了说明stride=1
的含义,让我们从这个数据集中获取另一个批次:
second_batch = next(data_iter) print(second_batch)
第二批的内容如下:
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
如果我们比较第一批和第二批,我们会发现相对于第一批,第二批的 token ID 向后移动了一个位置(例如,第一批输入中的第二个 ID 是 367,这是第二批输入中的第一个 ID)。stride
设置规定了输入在批次之间移动的位置数,模拟了一个滑动窗口的方法,如图 2.14 所示。
图 2.14 在从输入数据集创建多个批次时,我们在文本上滑动一个输入窗口。如果将步幅设置为 1,则在创建下一个批次时,将输入窗口向右移动 1 个位置。如果我们将步幅设置为等于输入窗口大小,我们可以防止批次之间的重叠。
练习 2.2 具有不同步幅和上下文大小的数据加载器
要更好地理解数据加载器的工作原理,请尝试以不同设置运行,如 max_length=2 和 stride=2 以及 max_length=8 和 stride=2。
与我们到目前为止从数据加载器中抽样的批次大小为 1 一样,这对于说明目的非常有用。如果您有深度学习的经验,您可能知道,较小的批次大小在训练期间需要更少的内存,但会导致更多的噪声模型更新。就像在常规深度学习中一样,批次大小是一个需要在训练 LLM 时进行实验的权衡和超参数。
在我们继续本章的最后两个重点部分,这些部分侧重于从标记 ID 创建嵌入向量之前,让我们简要了解如何使用数据加载器进行批量大小大于 1 的抽样:
dataloader = create_dataloader(raw_text, batch_size=8, max_length=4, stride=5) data_iter = iter(dataloader) inputs, targets = next(data_iter) print("Inputs:\n", inputs) print("\nTargets:\n", targets)
这将输出以下内容:
Inputs: 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]]) Targets: tensor([[ 367, 2885, 1464, 1807], [ 402, 271, 10899, 2138], [ 7026, 15632, 438, 2016], [ 922, 5891, 1576, 438], [ 340, 373, 645, 1049], [ 284, 502, 284, 3285], [ 11, 287, 262, 6001], [ 465, 13476, 11, 339]])
请注意,我们将步幅增加到 5,这是最大长度+1。这是为了充分利用数据集(我们不跳过任何单词),同时避免批次之间的任何重叠,因为更多的重叠可能导致过拟合增加。例如,如果我们将步幅设置为与最大长度相等,那么每行中最后一个输入标记 ID 的目标 ID 将成为下一行中第一个输入标记 ID。
在本章的最后两个部分中,我们将实现将标记 ID 转换为连续向量表示的嵌入层,这将作为 LLM 的输入数据格式。
从零开始构建大语言模型(MEAP)(4)https://developer.aliyun.com/article/1510682