从零实现ChatGPT:第一章构建大规模语言模型的数据准备
准备深入学习transformer,并参考一些资料和论文实现一个大语言模型,顺便做一个教程,今天是第一部分。
本系列禁止转载,主要是为了有不同见解的同学可以方便联系我,我的邮箱 fanzexuan135@163.com
构建大规模语言模型的数据准备
在前一章中,我们讨论了大规模语言模型(LLMs)的基本结构,以及它们如何基于海量文本数据进行预训练。本章将重点介绍为LLM训练准备输入数据的关键步骤,为后续章节中从头开始实现和训练LLM奠定基础。
理解词嵌入
深度神经网络模型,包括LLMs,无法直接处理原始文本。由于文本是离散的类别型数据,与神经网络所使用的数学运算不兼容,因此我们需要将单词表示为连续值的向量[1]。这种将数据转换为向量格式的概念通常被称为嵌入(Embedding)。使用特定的神经网络层或其他预训练的神经网络模型,我们可以将视频、音频、文本等不同数据类型嵌入为密集向量表示,如图1所示。
图1 将原始数据转换为深度学习模型可以理解和处理的密集向量表示的示意图
如图1所示,我们可以通过嵌入模型处理各种不同的数据格式。然而,需要注意的是,不同的数据格式需要不同的嵌入模型。例如,为文本设计的嵌入模型不适用于嵌入音频或视频数据。
从本质上讲,嵌入是从离散对象(如单词、图像,甚至整个文档)到连续向量空间中的点的映射。嵌入的主要目的是将非数值数据转换为神经网络可以处理的格式。尽管词嵌入是最常见的文本嵌入形式,但也有句子、段落或整个文档的嵌入。
一些流行的词嵌入算法和框架包括Word2Vec [2],它通过预测目标词的上下文或反之来训练神经网络生成词嵌入。Word2Vec背后的主要思想是,在相似上下文中出现的词往往具有相似的含义。因此,当映射到二维词嵌入空间进行可视化时,可以看到相似的词聚集在一起,如图2所示。
图2 词嵌入的二维散点图可视化示例
词嵌入可以有不同的维度,从一维到数千维不等。如图2所示,我们可以选择二维词嵌入进行可视化。更高的维度可能捕捉更多的细微关系,但以计算效率为代价。
尽管我们可以使用预训练的模型(如Word2Vec)来为机器学习模型生成嵌入,但LLMs通常会生成自己的嵌入,作为输入层的一部分,并在训练过程中进行更新。将嵌入作为LLM训练的一部分进行优化的优点是,嵌入被优化用于手头的特定任务和数据。我们将在本章后面实现此类嵌入层。此外,LLMs还可以创建上下文化的输出嵌入,我们将在第4章中讨论。
标记化文本
本节介绍如何将输入文本拆分为单个标记(Token),这是为LLM训练创建嵌入的必要预处理步骤。这些标记可以是单个单词或特殊字符,包括标点符号,如图3所示。
图3 本节所涵盖的文本处理步骤在LLM上下文中的示意图
我们将标记化用于LLM训练的文本是Edith Wharton的一个名为The Verdict的短篇小说,该小说已进入公共领域,因此允许用于LLM训练任务。该文本可在维基文库(Wikisource)上获得[3],您可以将其复制并粘贴到一个文本文件中,我将其复制到一个名为the_verdict.txt的文本文件中,以使用Python的标准文件读取实用程序加载它。
with open('the_verdict.txt', 'r', encoding='utf-8') as f:
raw_text = f.read()
print(f"Total number of character: {len(raw_text)}")
print(raw_text[:100])
输出:
Total number of character: 43152
I HAD always thought Jack Gisburn rather a cheap genius—though a good fellow enough—so it was wi
我们的目标是将这篇43,152个字符的短篇小说标记化为单个单词和特殊字符,然后我们可以在接下来的章节中将它们转换为LLM训练的嵌入。为此,我们可以使用Python的re正则表达式库,将文本拆分为单个单词、空白字符和标点符号。例如:
import re
text = "Hello world! This is a test."
result = re.split(r'(\s+|[,.!?])', text)
result = [item.strip() for item in result if item.strip()]
print(result)
输出:
['Hello', 'world', '!', 'This', 'is', 'a', 'test', '.']
上面的代码使用一些简单的正则表达式模式将文本拆分为单个单词和标点符号,同时删除了多余的空白字符。让我们将此标记化方案应用于整个The Verdict短篇小说:
preprocessed = re.split(r'(\s+|[,.!?])', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))
上面的print语句输出10,605,这是应用我们的基本标记化器后文本中的标记数。让我们打印前15个标记进行快速目视检查:
print(preprocessed[:15])
结果输出表明,我们的标记化器似乎能够很好地处理文本,因为所有单词和特殊字符都被整齐地分开:
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', ',', 'though', 'a', 'good', 'fellow']
将标记转换为标记ID
在前一节中,我们将Edith Wharton的短篇小说标记化为单个标记。在本节中,我们将把这些标记从Python字符串转换为整数表示,以生成所谓的标记ID(Token ID)。这种转换是在将标记ID转换为嵌入向量之前的中间步骤。
为了将之前生成的标记映射到标记ID,我们首先必须构建一个所谓的词汇表(Vocabulary)。此词汇表定义了如何将每个唯一的单词和特殊字符映射到唯一的整数,如图4所示。
图4 通过对训练数据集中的整个文本进行标记化来构建词汇表的示意图
在上一节中,我们将Edith Wharton的短篇小说标记化并将其分配给一个名为preprocessed的Python变量。让我们现在创建一个所有唯一标记的列表,并按字母顺序对它们进行排序,以确定词汇量大小:
all_words = sorted(list(set(preprocessed)))
vocab_size = len(all_words)
print(vocab_size)
在确定词汇量大小为4,720之后,我们创建词汇表并打印其前5个条目以供说明:
vocab = {token: integer for integer, token in enumerate(all_words)}
for i, item in enumerate(list(vocab.items())[:5]):
print(item)
if i > 5:
break
输出:
(' ', 0)
('!', 1)
('"', 2)
('$', 3)
("'", 4)
如我们所见,该字典包含与唯一整数标签相关联的单个标记。我们的下一个目标是应用此词汇表将新文本转换为标记ID,如图5所示。
图5 使用词汇表将文本标记转换为标记ID的示意图
稍后在本书中,当我们想要将LLM的输出从数字转换回文本时,我们还需要一种将标记ID转换为文本的方法。为此,我们可以创建词汇表的反向版本,将标记ID映射回相应的文本标记。
让我们在Python中实现一个完整的标记化器类,其中包含一个encode方法,该方法将文本拆分为标记并通过词汇表执行字符串到整数的映射以生成标记ID。此外,我们实现了一个decode方法,该方法执行反向整数到字符串的映射,以将标记ID转换回文本。
class SimpleTokenizer:
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()]
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'', text)
return text
使用上面的SimpleTokenizer类,我们现在可以通过现有词汇表实例化新的标记化器对象,然后使用它们对文本进行编码和解码
让我们从SimpleTokenizer类实例化一个新的标记化器对象,并标记化Edith Wharton短篇小说中的一段文字,以实际尝试一下:
tokenizer = SimpleTokenizer(vocab)
text = "It's the last he painted, you know, Mrs. Gisburn said, with pardonable pride."
ids = tokenizer.encode(text)
print(ids)
上面的代码打印出以下标记ID:
[4, 15, 5, 16, 17, 18, 19, 6, 20, 21, 6, 2056, 0, 1241, 0, 22, 6, 23, 24, 25, 26, 0, 27, 28, 29, 4]
接下来,让我们看看我们是否可以使用decode方法将这些标记ID转换回文本:
print(tokenizer.decode(ids))
这将输出以下文本:
It's the last he painted, you know, Mrs. Gisburn said, with pardonable pride.
到目前为止一切顺利。我们实现了一个能够标记化和解标记化文本的标记化器,基于训练集中的一个片段。现在让我们将其应用于一个新的文本样本,该样本不包含在训练集中:
text = "Hello do you like tea?"
tokenizer.encode(text)
执行上面的代码将导致以下错误:
KeyError: 'Hello'
问题在于单词”Hello”没有在The Verdict短篇小说中使用。因此,它不包含在词汇表中。这突出表明,在处理LLM时,需要考虑大型和多样化的训练集以扩展词汇表。
在下一节中,我们将进一步测试标记化器处理包含未知单词的文本的能力,并讨论可用于为LLM提供进一步上下文的其他特殊标记。
添加特殊上下文标记
在上一节中,我们实现了一个简单的标记化器,并将其应用于训练集中的一个片段。在本节中,我们将修改此标记化器以处理未知单词。
我们还将讨论特殊上下文标记的使用和添加,这些标记可以增强模型对文本中上下文或其他相关信息的理解。这些特殊标记可以包括未知单词的标记和文档边界等。
具体而言,我们将修改我们在上一节中实现的词汇表和标记化器SimpleTokenizer,以支持两个新标记:unk>和_endoftext_>
我们可以修改标记化器,以便在遇到词汇表中不存在的单词时使用_unk_>标记。此外,我们在不相关的文本之间添加了一个_endoftext_>标记。例如,在多个独立的文档或书籍上训练GPT等LLM时,通常会在前一个文本源之后的每个文档或书籍之前插入一个_endoftext_>标记,如图8所示。这有助于LLM理解,尽管这些文本源是为训练而连接的,但实际上它们是不相关的。
图8 处理多个独立文本源时在文本之间添加_endoftext_>标记的示意图
让我们现在修改词汇表以包含这两个特殊标记_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语句的输出,新词汇表的大小为4,722(上一节中的词汇量大小为4,720)。
作为额外的快速检查,让我们打印更新后的词汇表的最后5个条目:
for i, item in enumerate(list(vocab.items())[-5:]):
print(item)
上面的代码打印出以下内容:
('younger', 4717)
('your', 4718)
('yourself', 4719)
('_endoftext_>', 4720)
('_unk_>', 4721)
根据上面的代码输出,我们可以确认这两个新的特殊标记确实已成功合并到词汇表中。接下来,我们相应地调整代码清单3中的标记化器,如代码清单4所示。
代码清单4 处理未知单词的简单文本标记化器
class SimpleTokenizer2:
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
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'', text)
return text
与代码清单3中我们在上一节实现的SimpleTokenizer相比,新的SimpleTokenizer2用_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
接下来,让我们使用我们之前在清单3中创建的词汇表上的SimpleTokenizer2标记化示例文本:
tokenizer = SimpleTokenizer2(vocab)
print(tokenizer.encode(text))
这将打印以下标记ID:
[4721, 4721, 20, 21, 1009, 2114, 28, 4720, 1826, 5, 16, 30, 32, 33, 5, 34]
在上面,我们可以看到标记ID列表包含4720作为_endoftext_>分隔符标记,以及两个4721标记,用于未知单词。
让我们进行快速完整性检查,将文本解码:
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] (序列开始):这个标记标志着文本的开头。它向LLM表明一段内容从何处开始。
- [EOS] (序列结束):这个标记位于文本的末尾,在连接多个不相关文本时特别有用,类似于_endoftext_>。例如,当组合两个不同的维基百科文章或书籍时,[EOS]标记表示一篇文章在哪里结束,下一篇文章从哪里开始。
- [PAD] (填充):在批量大小大于1的情况下训练LLM时,批次中的文本可能具有不同的长度。为了确保所有文本具有相同的长度,使用[PAD]标记将较短的文本扩展或填充到批次中最长文本的长度。
请注意,用于GPT模型的标记化器不需要上述任何标记,而只是为了简单起见使用_endoftext_>标记。endoftext>类似于上面提到的[EOS]标记。此外,endoftext>也用于填充。但是,正如我们将在后续章节中探讨的那样,在对批量输入进行训练时,我们通常使用遮罩,这意味着我们不关注填充的标记。因此,为填充选择的特定标记变得无关紧要。
此外,用于GPT模型的标记化器也不使用_unk_>标记来表示词汇表之外的单词。相反,GPT模型使用字节对编码(Byte Pair Encoding, BPE)标记化器,将单词分解为子词单元,我们将在下一节讨论。
字节对编码(Byte Pair Encoding)
在前几节中,我们出于说明目的实现了一个简单的标记化方案。本节介绍一种基于字节对编码(BPE)概念的更复杂的标记化方案。本节中介绍的BPE标记化器用于训练GPT、GPT-2和ChatGPT中使用的原始模型等LLM。
由于实现BPE可能相对复杂,我们将使用一个名为tiktoken的现有Python开源库[4],它基于Rust中的源代码非常高效地实现了BPE算法。与其他Python库类似,我们可以通过Python的pip安装程序从终端安装tiktoken库:
pip install tiktoken
安装后,我们可以从tiktoken中实例化BPE标记化器,如下所示:
tokenizer = tiktoken.get_encoding('gpt2')
此标记化器的用法类似于我们之前通过encode方法实现的SimpleTokenizer2:
text = "Hello do you like tea? _endoftext_>In the sunlit terraces of some unknown Place."
integers = tokenizer.encode(text, allowed_special={'_endoftext_>'})
print(integers)
上面的代码打印出以下标记ID:
[15496, 2382, 345, 1988, 7968, 30, 50256, 1986, 262, 25921, 10592, 286, 1061, 69657, 38081, 13]
然后,我们可以使用decode方法将标记ID转换回文本,类似于我们之前的SimpleTokenizer2:
strings = tokenizer.decode(integers)
print(strings)
上面的代码输出以下内容:
Hello do you like tea? _endoftext_>In the sunlit terraces of some unknown Place.
基于上面的标记ID和解码文本,我们可以做出两个值得注意的观察。首先,endoftext>标记被分配了一个相对较大的标记ID,即50256。事实上,用于训练GPT、GPT-2和ChatGPT中使用的原始模型的BPE标记化器具有50257的总词汇量,其中_endoftext_>被分配了最大的标记ID。
其次,上面的BPE标记化器正确地编码和解码未知单词,如”some unknown Place”。BPE标记化器可以处理任何未知单词。它是如何实现的,而不使用_unk_>标记?
BPE底层算法将词汇表中不存在的单词分解为较小的子词单元,甚至是单个字符,使其能够处理词汇表外的单词。因此,感谢BPE算法,如果标记化器在标记化过程中遇到不熟悉的单词,它可以将其表示为子词标记或字符序列,如图9所示。
图9 BPE标记化器将未知单词分解为子词和单个字符的示意图
如图9所示,将未知单词分解为单个字符的能力确保标记化器(以及随后使用它训练的LLM)可以处理任何文本,即使它包含在其训练数据中不存在的单词。
练习:未知单词的字节对编码
尝试在未知单词”Akwirwier”上使用tiktoken库中的BPE标记化器,并打印各个标记ID。然后,在结果整数列表中的每个整数上调用decode函数,以重现图9所示的映射。最后,在标记ID上调用decode方法,检查它是否可以重构原始输入”Akwirwier”。
BPE的详细讨论和实现超出了本书的范围,但简而言之,它通过迭代地将频繁的字符合并为子词,将频繁的子词合并为单词,来构建其词汇表。例如,BPE从向其词汇表添加所有单个字符开始(a、b、c…)。在下一阶段,它将经常一起出现的字符组合合并为子词。例如,d和e可能合并为子词”de”,这在许多英语单词中很常见,如define、depend、made和hidden。合并由频率截止值确定。
使用滑动窗口进行数据采样
前面的章节详细介绍了标记化步骤以及从字符串标记到整数标记ID的转换。在将标记ID转换为LLM嵌入向量之前的最后一步是,生成训练LLM所需的输入-目标对。
这些输入-目标对看起来像什么?正如我们在第1章中了解到的,LLM通过一次预测文本中的下一个单词来进行预训练,如图10所示。
图10 从给定文本样本中提取输入块作为子样本的示意图
在本节中,我们实现了一个数据加载器,它使用滑动窗口方法从训练数据集中获取图10中描述的输入-目标对。
为了开始,我们将首先使用上一节中介绍的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标记化器后训练集中的总标记数:12,420。
接下来,出于演示目的,我们从数据集中删除前1000个标记,因为它在接下来的步骤中会产生一个稍微更有趣的文本段落:
enc_sample = enc_text[1000:]
创建下一个单词预测任务所需的输入-目标对的最简单和最直观的方法之一是创建两个变量x和y,其中x包含输入标记,y包含目标,即移位1的输入:
context_size = 4
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y: {y}")
运行上面的代码打印出以下输出:
x: [4883, 286, 262, 25921]
y: [286, 262, 25921, 10592]
通过处理输入以及移位1位置的目标,我们可以创建图10中描述的下一个单词预测任务,如下所示:
for i in range(context_size):
context = enc_sample[i:i+1]
desired = enc_sample[i+1:i+2]
print(f"{context} -> {desired}")
上面的代码打印出以下内容:
[4883] -> [286]
[286] -> [262]
[262] -> [25921]
[25921] -> [10592]
箭头(->)左侧的所有内容都是LLM将收到的输入,箭头右侧的标记ID表示LLM应预测的目标标记ID。
为了说明,让我们重复前面的代码,但将标记ID转换为文本:
for i in range(context_size):
context = enc_sample[i:i+1]
desired = enc_sample[i+1:i+2]
print(f"{tokenizer.decode(context)} -> {tokenizer.decode(desired)}")
以下输出显示了输入和输出在文本格式下的样子:
and -> established
and established -> himself
and established himself -> in
and established himself in -> a
我们现在已经创建了可用于后续章节中LLM训练的输入-目标对。
在我们可以将标记转换为嵌入之前,本章的最后两个部分的重点,还有一个任务要做:实现一个高效的数据加载器,它遍历输入数据集并返回PyTorch张量形式的输入和目标,可以将其视为多维数组。
具体来说,我们对返回两个张量感兴趣:一个输入张量,包含LLM看到的文本,以及一个目标张量,包含LLM要预测的目标,如图11所示。
图11 我们将输入收集到张量x中的示意图
虽然图11为了说明目的以字符串格式显示了标记,但代码实现将直接对标记ID进行操作,因为BPE标记化器的encode方法将标记化和转换为标记ID作为单个步骤执行。
对于高效数据加载器实现,我们将使用PyTorch的内置Dataset和DataLoader类。有关安装PyTorch的更多信息和指导,请参阅附录A中的A.1节。
数据集类的代码如代码清单5所示。
代码清单5 用于批量输入和目标的数据集
import torch
from torch.utils.data import Dataset, DataLoader
class GPTDataset(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.tokenizer = tokenizer
self.input_ids = []
self.target_ids = []
token_ids = tokenizer.encode(txt)
for i in range(0, len(token_ids) - max_length, stride):
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):
return len(self.input_ids)
def __getitem__(self, idx):
return self.input_ids[idx], self.target_ids[idx]
清单5中的GPTDataset类基于PyTorch Dataset类,并定义了如何从数据集中获取单个行,其中每行由分配给input_chunk张量的多个标记ID组成。target_chunk张量包含相应的目标。我建议继续阅读,看看将这个数据集与PyTorch DataLoader结合使用时,返回的数据是什么样子的;这将带来额外的直觉和清晰度。
如果您不熟悉PyTorch Dataset类的结构,如清单5所示,请阅读附录A中的A.4节,其中解释了PyTorch Dataset和DataLoader类的一般结构和用法。
以下代码将使用GPTDataset通过PyTorch DataLoader以批次加载输入:
代码清单6 生成具有输入-目标对的批次的数据加载器
def create_dataloader(txt, batch_size,
max_length=128, stride=128,
shuffle=True, drop_last=True):
tokenizer = tiktoken.get_encoding('gpt2')
dataset = GPTDataset(txt, tokenizer, max_length, stride)
dataloader = DataLoader(dataset,
batch_size=batch_size,
shuffle=shuffle,
drop_last=drop_last)
return dataloader
让我们用batch_size=8测试数据加载器,对于具有上下文大小为4的LLM,以开发数据加载器如何工作的直觉:
with open('the_verdict.txt', 'r', encoding='utf-8') as f:
raw_text = f.read()
dataloader = create_dataloader(
raw_text, batch_size=8, max_length=4, stride=4, shuffle=False)
data_iter = iter(dataloader)
first_batch = next(data_iter)
print(first_batch)
上面的代码打印出以下内容:
[tensor([[4883, 286, 262, 25921]]), tensor([[ 286, 262, 25921, 10592]])]
first_batch变量包含两个张量:第一个张量存储输入标记ID,第二个张量存储目标标记ID。由于max_length设置为4,两个张量中的每一个都包含4个标记ID。请注意,输入大小为4相对较小,仅出于说明目的选择。训练LLM时,输入大小通常至少为1024。
为了说明stride的含义,让我们从这个数据集中获取另一个批次:
second_batch = next(data_iter)
print(second_batch)
第二批具有以下内容:
[tensor([[ 286, 262, 25921, 10592]]), tensor([[ 262, 25921, 10592, 286]])]
如果我们将第一批与第二批进行比较,我们可以看到第二批的标记ID与第一批相比移位了一个位置(例如,第一批输入中的第二个ID为286,这是第二批输入中的第一个ID)。stride设置决定了在创建下一批时输入移动的位置数,模拟滑动窗口方法,如图12所示。
图12 当从输入数据集创建多个批次时,我们在文本上滑动输入窗口的示意图
练习:具有不同stride和上下文大小的数据加载器
为了对数据加载器的工作原理有更多的直觉,尝试用不同的设置运行它,例如max_length=8和stride=1,以及max_length=4和stride=4。
到目前为止,我们已经从数据加载器中采样了batch_size=8等小批量大小,这对于说明目的很有用。如果您以前有深度学习经验,您可能知道小批量大小在训练期间需要更少的内存,但会导致模型更新更多噪声。就像在常规深度学习中一样,批量大小在训练LLM时是一个权衡和超参数。
在我们进入本章的最后两节(重点是从标记ID创建嵌入向量)之前,让我们简要了解一下如何使用数据加载器对大于8的批量大小进行采样:
dataloader = create_dataloader(raw_text, batch_size=16, max_length=32, stride=32)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print(f"Inputs:
{inputs}")
print(f"
Targets:
{targets}")
这将打印出以下内容:
Inputs:
tensor([[4883, 286, 262, 25921, 10592, 286, 481, 262, 35137, 10592, 286,
481, 27413, 12419, 286, 285, 10948, 30142, 509, 254, 1073,
2517, 21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986,
262],
[25921, 10592, 286, 481, 262, 35137, 10592, 286, 481, 27413,
12419, 286, 285, 10948, 30142, 509, 254, 1073, 2517, 21195,
509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262, 17626,
13267, 286],
[ 481, 262, 35137, 10592, 286, 481, 27413, 12419, 286, 285,
10948, 30142, 509, 254, 1073, 2517, 21195, 509, 4151, 26360,
19661, 5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351,
13, 220],
[27413, 12419, 286, 285, 10948, 30142, 509, 254, 1073, 2517,
21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262,
17626, 13267, 286, 31351, 13, 220, 11, 539, 49863, 5385,
4883, 539],
[10948, 30142, 509, 254, 1073, 2517, 21195, 509, 4151, 26360,
19661, 5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351,
13, 220, 11, 539, 49863, 5385, 4883, 539, 49863, 1051,
9062, 318],
[21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262,
17626, 13267, 286, 31351, 13, 220, 11, 539, 49863, 5385,
4883, 539, 49863, 1051, 9062, 318, 1177, 539, 8404, 257,
3425, 12859],
[ 5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351, 13,
220, 11, 539, 49863, 5385, 4883, 539, 49863, 1051, 9062,
318, 1177, 539, 8404, 257, 3425, 12859, 2231, 286, 18686,
3996, 1783],
[17626, 13267, 286, 31351, 13, 220, 11, 539, 49863, 5385,
4883, 539, 49863, 1051, 9062, 318, 1177, 539, 8404, 257,
3425, 12859, 2231, 286, 18686, 3996, 1783, 2563, 286, 31351,
517, 1857]])
Targets:
tensor([[ 286, 262, 25921, 10592, 286, 481, 262, 35137, 10592, 286, 481,
27413, 12419, 286, 285, 10948, 30142, 509, 254, 1073, 2517,
21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262,
17626],
[10592, 286, 481, 262, 35137, 10592, 286, 481, 27413, 12419,
286, 285, 10948, 30142, 509, 254, 1073, 2517, 21195, 509,
4151, 26360, 19661, 5979, 1444, 50256, 1986, 262, 17626, 13267,
286, 31351],
[ 262, 35137, 10592, 286, 481, 27413, 12419, 286, 285, 10948,
30142, 509, 254, 1073, 2517, 21195, 509, 4151, 26360, 19661,
5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351, 13,
220, 11],
[12419, 286, 285, 10948, 30142, 509, 254, 1073, 2517, 21195,
509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262, 17626,
13267, 286, 31351, 13, 220, 11, 539, 49863, 5385, 4883,
539, 49863],
[30142, 509, 254, 1073, 2517, 21195, 509, 4151, 26360, 19661,
5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351, 13,
220, 11, 539, 49863, 5385, 4883, 539, 49863, 1051, 9062,
318, 1177],
[ 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262, 17626,
13267, 286, 31351, 13, 220, 11, 539, 49863, 5385, 4883,
539, 49863, 1051, 9062, 318, 1177, 539, 8404, 257, 3425,
12859, 2231],
[ 1444, 50256, 1986, 262, 17626, 13267, 286, 31351, 13, 220,
11, 539, 49863, 5385, 4883, 539, 49863, 1051, 9062, 318,
1177, 539, 8404, 257, 3425, 12859, 2231, 286, 18686, 3996,
1783, 2563],
[13267, 286, 31351, 13, 220, 11, 539, 49863, 5385, 4883,
539, 49863, 1051, 9062, 318, 1177, 539, 8404, 257, 3425,
12859, 2231, 286, 18686, 3996, 1783, 2563, 286, 31351, 517,
1857, 15676]])
请注意,我们将stride增加到32。这是为了充分利用数据集(我们不会跳过任何单词),但也避免了批次之间的任何重叠,因为更多的重叠可能导致过拟合增加。
在本章的最后两节中,我们将实现嵌入层,将标记ID转换为连续向量表示,作为LLM的输入数据格式。
创建标记嵌入
将文本准备为LLM训练输入的最后一步是将标记ID转换为嵌入向量,如图13所示,这将是本章最后两节的重点。
图13 为LLM准备输入文本的步骤示意图
除了图13中概述的过程外,重要的是要注意,我们用随机值初始化这些嵌入权重作为初步步骤。这个初始化作为LLM学习过程的起点。我们将在后面作为LLM训练本身的一部分优化嵌入权重。现在,让我们创建初始位置嵌入,以便在接下来的章节中创建LLM输入。
在本章前面,我们为了说明的目的专注于非常小的嵌入大小。我们现在考虑更现实和有用的嵌入大小,并将输入标记编码为768维向量表示。这比原始GPT模型使用的小(在GPT中嵌入大小为12288维),但仍然足够用于实验。此外,我们假设标记ID是由我们之前实现的BPE标记化器创建的,它有50257的词汇量大小。
output_dim = 768
vocab_size = 50257
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
使用上面的token_embedding_layer,如果我们从之前的数据加载器中采样数据,我们将每个批次中的每个标记嵌入到768维向量中。如果我们有一个批量大小为8,每个批量有4个标记,结果将是一个8x4x768张量。
让我们首先实例化在数据采样部分创建的数据加载器:
max_length = 32
dataloader = create_dataloader(
raw_text, batch_size=8, max_length=max_length, stride=max_length, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print(f"Token IDs:
{inputs}")
print(f"
Inputs shape:
{inputs.shape}")
上面的代码打印出以下输出:
Token IDs:
tensor([[4883, 286, 262, 25921, 10592, 286, 481, 262, 35137, 10592, 286,
481, 27413, 12419, 286, 285, 10948, 30142, 509, 254, 1073,
2517, 21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986,
262],
[25921, 10592, 286, 481, 262, 35137, 10592, 286, 481, 27413,
12419, 286, 285, 10948, 30142, 509, 254, 1073, 2517, 21195,
509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262, 17626,
13267, 286],
[ 481, 262, 35137, 10592, 286, 481, 27413, 12419, 286, 285,
10948, 30142, 509, 254, 1073, 2517, 21195, 509, 4151, 26360,
19661, 5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351,
13, 220],
[27413, 12419, 286, 285, 10948, 30142, 509, 254, 1073, 2517,
21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262,
17626, 13267, 286, 31351, 13, 220, 11, 539, 49863, 5385,
4883, 539],
[10948, 30142, 509, 254, 1073, 2517, 21195, 509, 4151, 26360,
19661, 5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351,
13, 220, 11, 539, 49863, 5385, 4883, 539, 49863, 1051,
9062, 318],
[21195, 509, 4151, 26360, 19661, 5979, 1444, 50256, 1986, 262,
17626, 13267, 286, 31351, 13, 220, 11, 539, 49863, 5385,
4883, 539, 49863, 1051, 9062, 318, 1177, 539, 8404, 257,
3425, 12859],
[ 5979, 1444, 50256, 1986, 262, 17626, 13267, 286, 31351, 13,
220, 11, 539, 49863, 5385, 4883, 539, 49863, 1051, 9062,
318, 1177, 539, 8404, 257, 3425, 12859, 2231, 286, 18686,
3996, 1783],
[17626, 13267, 286, 31351, 13, 220, 11, 539, 49863, 5385,
4883, 539, 49863, 1051, 9062, 318, 1177, 539, 8404, 257,
3425, 12859, 2231, 286, 18686, 3996, 1783, 2563, 286, 31351,
517, 1857]])
Inputs shape:
torch.Size([8, 32])
如我们所见,标记ID张量是8×32维的,这意味着数据批次由8个文本样本组成,每个样本有32个标记。
现在让我们使用嵌入层将这些标记ID嵌入到768维向量中:
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
上面的print函数调用返回以下内容:
torch.Size([8, 32, 768])
如我们所见,基于8x32x768维输出张量,每个标记ID现在被嵌入为768维向量。
对于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),其中包含一个数字序列,最大为支持的输入长度。
context_length是表示LLM支持的输入大小的变量。在这里,我们将其选择为类似于输入文本的最大长度。在实践中,输入文本可能比支持的上下文长度更长,在这种情况下,我们必须截断文本。
print语句的输出如下:
torch.Size([32, 768])
如我们所见,位置嵌入张量由32个768维向量组成。我们现在可以将它们直接添加到标记嵌入中,其中PyTorch将将32×768维pos_embeddings张量添加到每个批次中的每个32×768维token_embedding张量。
input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)
print输出如下:
torch.Size([8, 32, 768])
我们创建的input_embeddings,如图14所总结的,是现在可以由主LLM模块处理的嵌入式输入样本,我们将在下一章开始实现。
图14 输入处理管道的示意图
总结
LLM需要将文本数据转换为数值向量,称为嵌入,因为它们无法处理原始文本。嵌入将离散数据(如单词或图像)转换为连续向量空间,使其与神经网络运算兼容。
作为第一步,原始文本被分解为标记,可以是单词或字符。然后,标记被转换为称为标记ID的整数表示。
可以添加特殊标记,如_unk_>和_endoftext_>,以增强模型的理解并处理各种上下文,例如未知单词或标记不相关文本之间的边界。
GPT和GPT-2等LLM使用的字节对编码(BPE)标记化器可以通过将未知单词分解为子词单元或单个字符来有效处理未知单词。
我们在标记化数据上使用滑动窗口方法生成LLM训练的输入-目标对。
PyTorch中的嵌入层充当查找操作,检索与标记ID对应的向量。结果嵌入向量提供了标记的连续表示,这对于像LLM这样的深度学习模型的训练至关重要。
虽然标记嵌入为每个标记提供一致的向量表示,但它们缺乏序列中标记位置的概念。为了纠正这一点,存在两种主要类型的位置嵌入:绝对和相对。OpenAI的GPT模型利用绝对位置嵌入,这些嵌入被添加到标记嵌入向量中,并在模型训练期间进行优化。
敬请期待下一章。
参考文献:
Mikolov, T., Chen, K., Corrado, G., & Dean, J. (2013). Efficient estimation of word representations in vector space. arXiv preprint arXiv:1301.3781.
Mikolov, T., Sutskever, I., Chen, K., Corrado, G. S., & Dean, J. (2013). Distributed representations of words and phrases and their compositionality. In Advances in neural information processing systems (pp. 3111-3119).
Wharton, E. (1908). The Verdict. Available at https://en.wikisource.org/wiki/The_Verdict
Tiktoken. (2023). tiktoken: a fast BPE tokeniser for use with OpenAI’s models. GitHub repository. https://github.com/openai/tiktoken