系列文章目录
Build a Large Language Model (From Scratch) 学习笔记(一)
前言
本章内容涵盖:为大语言模型训练准备文本;将文本拆分为单词和子词标记;字节对编码作为一种更高级的文本标记化方法;采用滑动窗口方法抽取训练样本;将标记转换为向量以输入到大语言模型中。
原文目录如下
本篇笔记篇幅受限,拆分为两部分内容,本文中包含2.1-2.4内容。
在预训练阶段,LLM会逐个词地处理文本。通过使用包含数百万至数十亿参数的模型进行下一个词预测任务,可以训练出具有令人印象深刻能力的模型。然后,这些模型可以进一步微调,以遵循一般指令或执行特定的目标任务。但是,在我们能够实施和训练LLM之前,需要准备训练数据集。
下图主要展示了LLM编码的三个主要阶段(创建一个大语言模型->基础模型->带类别标签的数据集),本篇主要展示了第一个阶段,即:实现数据样本处理流程。
图1 LLM的三个主要阶段
一、理解词嵌入
注释:词嵌入(word embeddings)是自然语言处理(NLP)中的一个关键概念,它指的是将词汇或短语从词汇表映射到一个连续的向量空间的技术,使得语义相似的词汇在向量空间中距离较近。这种表示方法能够捕捉词汇之间的语义和语法关系,为各种NLP任务提供支持。
图2
深度学习模型无法直接处理原始格式的视频、音频和文本数据。因此,我们使用嵌入模型将这些原始数据转换为深度学习架构能够轻松理解和处理的稠密向量表示。具体来说,图2展示了将原始数据转换为三维数值向量的过程。
从本质上讲,嵌入是一种从离散对象(如单词、图像,甚至是完整的文档)到连续向量空间中的点的映射——嵌入的主要目的是将非数值数据转换为神经网络能够处理的格式。 虽然词嵌入是文本嵌入最常见的形式,但也存在针对句子、段落或整个文档的嵌入。句子或段落嵌入是检索增强生成的常用选择。检索增强生成将生成(比如生成文本)与检索(比如搜索外部知识库)相结合,以便在生成文本时提取相关信息,这是一种超出本书范畴的技术。由于我们的目标是训练类似GPT的大语言模型,这种模型学习一次生成一个单词的文本,所以我们将专注于词嵌入。 已经开发出了几种算法和框架来生成词嵌入。较早且最受欢迎的例子之一是Word2Vec方法。Word2Vec训练神经网络架构,通过给定目标单词来预测该单词的上下文,或者反过来,通过上下文来预测目标单词,以此生成词嵌入。Word2Vec背后的主要思想是,出现在相似上下文中的单词往往具有相似的含义。因此,当为了可视化目的将其投影到二维词嵌入中时,相似的词会聚集在一起,如图2.3所示。 词嵌入的维度可以有所不同,从一维到数千维不等。更高的维度可能会捕捉到更细微的关系,但代价是计算效率会降低。
图3
如果词嵌入是二维的,我们可以将它们绘制在二维散点图中以便进行可视化,就如这里所示的那样。当使用诸如Word2Vec之类的词嵌入技术时,对应于相似概念的单词在嵌入空间中往往会彼此靠近出现。例如,不同种类的鸟类在嵌入空间中彼此之间的距离,会比国家和城市之间的距离更近。
从本质上讲,嵌入是一种从离散对象(如单词、图像,甚至是整篇文档)到连续向量空间中的点的映射——嵌入的主要目的是将非数值数据转换为神经网络能够处理的格式。 虽然词嵌入是文本嵌入最常见的形式,但也存在针对句子、段落或整篇文档的嵌入。句子或段落嵌入是检索增强生成的常用选择。检索增强生成将生成(比如生成文本)与检索(比如搜索外部知识库)相结合,以便在生成文本时提取相关信息,这是一种超出本书范畴的技术。由于我们的目标是训练类似GPT的大语言模型,这种模型学习一次生成一个单词的文本,所以我们将专注于词嵌入。 已经开发出了几种算法和框架来生成词嵌入。较早且最受欢迎的例子之一是Word2Vec方法。Word2Vec通过给定目标词来预测该词的上下文,或者反过来,通过上下文来预测目标词,以此训练神经网络架构来生成词嵌入。Word2Vec背后的主要思想是,出现在相似上下文中的词往往具有相似的含义。因此,当为了可视化目的将其投影到二维词嵌入中时,相似的词会聚集在一起,如图2.3所示。 词嵌入的维度可以各不相同,从一维到数千维不等。更高的维度可能会捕捉到更细微的关系,但代价是计算效率会降低。
二、对文本进行标记化处理
这部分主要是如何将输入文本分割成单个的tokens,这是为大语言模型(LLM)创建嵌入所必需的预处理步骤。这些tokens可以是单个的单词,也可以是特殊字符,其中包括标点符号,如图 4 所示。
图4
我们将用于大语言模型(LLM)训练而进行分词处理的文本是《裁决》(The Verdict),这是伊迪丝・华顿(Edith Wharton)所著的一篇短篇小说,该小说已进入公有领域,因此可被允许用于大语言模型的训练任务。这篇文本可在维基文库(Wikisource)上获取,网址为https://en.wikisource.org/wiki/The_Verdict ,你可以将其复制并粘贴到一个文本文件中,我已将其复制到了一个名为 “the-verdict.txt” 的文本文件里。
或者,你也可以在本书的 GitHub 代码库中找到这个 “the-verdict.txt” 文件,网址为LLMs-from-scratch/ch02/01_main-chapter-code at main · rasbt/LLMs-from-scratch · GitHub 。你可以使用以下 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”文件。
代码清单:将一篇短篇小说作为文本样本读入 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])
为了便于说明,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
我们的目标是将这篇有 20479 个字符的短篇小说分词成单个的单词和特殊字符,然后我们可以将它们转化为嵌入,用于大语言模型的训练。
注意:在处理大语言模型时,处理数百万篇文章和数十万本书籍(即许多 GB 的文本)是很常见的。然而,出于教学目的,使用像一本单独的书这样较小的文本样本就足以说明文本处理步骤背后的主要思想,并且能够在普通消费级硬件上在合理的时间内运行。
我们怎样才能最好地分割这段文本以获得一个tokens列表呢?为此,我们先做一个小尝试,使用 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.']
这种简单的分词方案在将示例文本分割成单个单词方面大多是可行的;然而,一些单词仍然与标点符号连在一起,而我们希望标点符号能作为单独的列表项。我们也避免将所有文本都转换为小写形式,因为大小写区分有助于大语言模型(LLMs)区分专有名词和普通名词,理解句子结构,并学会生成大小写正确的文本。 让我们修改一下基于空白字符(\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)
注意:在开发一个简单的标记化器时,是将空格编码为单独的字符还是删除它们,这取决于应用程序及其需求。删除空白会减少对内存和计算方面的需求。但是,如果我们训练对文本的确切结构敏感的模型(例如,保持空白是有用的,Python代码,它很敏感到压痕和间距)。在这里,为了标记化输出的简单性和简洁性,我们删除了空白。稍后,我们将切换到一个包含空格的标记化方案(tokenization scheme)。
我们在这里设计的标记化方案在简单的示例文本上工作得很好。让我们进一步修改它,这样它也可以处理其他类型的标点符号,如问号,引号marks,以及我们之前在伊迪丝·华顿的短篇小说的前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', '?']
根据上图总结的结果,我们可以看到,我们的标记化方案现在可以成功地处理文本中的各种特殊字符。
迄今为止,我们实现的标记化方案将文本分成单独的单词和标点字符。在这个特定的例子中,示例文本被分成10个单独的标记 .
现在我们有了一个基本的标记器工作,让我们把它应用到伊迪丝·华顿的整个短篇小说中。
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))
此打印语句输出4690,它是此文本中的标记数(没有空格)。让我们打印前30个tokens,以便快速进行视觉检查:
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']
三、词元(tokens)转为词元 ID (token IDs)
接下来,让我们把这些以 Python 字符串形式存在的词元转换为整数表示,从而生成词元 ID (token IDs)。这一转换是将词元 ID (token IDs) 转换为嵌入向量之前的一个中间步骤。 为了将之前生成的词元映射为词元 ID (token IDs),我们首先得构建一个词汇表。如下图所示,这个词汇表定义了我们如何将每个唯一的单词和特殊字符映射到一个唯一的整数上。
我们通过将训练数据集中的整个文本进行分词处理,将其拆分为单个的词元(tokens)来构建词汇表。随后,这些单个标记会按字母顺序进行排序,并去除重复的标记。接着,这些唯一的标记会被整合到一个词汇表中,该词汇表定义了从每个唯一标记到一个唯一整数值的映射。为了简单起见,这里展示的词汇表特意设置得很小,并且不包含标点符号或特殊字符。
既然我们已经对伊迪丝·华顿(Edith Wharton)的短篇小说进行了分词处理,并将其赋值给了一个名为 `preprocessed` 的 Python 变量,那么现在让我们创建一个包含所有唯一词元(tokens)的列表,并按字母顺序对它们进行排序,以确定词汇量的大小:
all_words = sorted(set(preprocessed))
vocab_size = len(all_words)
print(vocab_size)
通过这段代码确定词汇量大小为1130之后,我们创建了词汇表,并且为了便于说明,打印出了词汇表的前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)
正如我们所见,该字典包含了与唯一整数标签相关联的单个词元(token)。我们的下一个目标是运用这个词汇表,将新的文本转换为词元 ID (token IDs)(如下图)。
从一个新的文本样本开始,我们对该文本进行分词处理,然后使用词汇表将文本词元(tokens)转换为词元ID(token IDs)。这个词汇表是根据整个训练集构建的,并且可以应用于训练集本身以及任何新的文本样本。为了简便起见,图中所示的词汇表不包含标点符号或特殊字符。
当我们想把大语言模型(LLM)的输出从数字转换回文本时,我们需要一种方法将 词元ID(token IDs)转换为文本。为此,我们可以创建词汇表的反向版本,它能将 词元ID(token IDs)映射回对应的文本词元(tokens)。
代码清单:实现一个简短的文本分词器(text tokenizer)
class SimpleTokenizerV1:
def __init__(self, vocab):
self.str_to_int = vocab //将词汇表存储为类属性,以便在 `encode` 和 `decode` 方法中进行访问
self.int_to_str = {i:s for s,i in vocab.items()} //创建一个反向词汇表,该词汇表可将词元 ID 映射回原始的文本词元
def encode(self, text): //将输入文本处理为词元 ID
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): //将词元 ID 转换回文本
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) //移除指定标点符号前的空格
return text
使用 SimpleTokenizerV1 Python 类,我们现在可以通过一个已有的词汇表实例化新的分词器对象,然后我们可以使用这个分词器对象对文本进行编码和解码,如下图所示。
上图解释:分词器的实现有两个常见的方法:一个编码方法和一个解码方法。编码方法接收样本文本,将其分割成单个词元,并通过词汇表将这些词元转换为词元 ID。解码方法接收词元 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:
[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.'
根据这一输出结果,我们可以看出,解码方法成功地将词元标识符(token IDs)转换回了原始文本。 到目前为止,进展顺利。我们实现了一个分词器,它能够依据训练集中的一段文本片段对文本进行分词以及逆分词操作。现在,让我们把它应用到一个不在训练集中的新文本样本上:
text = "Hello, do you like tea?"
print(tokenizer.encode(text))
执行这段代码将导致以下错误:
KeyError: 'Hello'
问题在于,“你好(Hello)” 这个词在短篇小说《判决(The Verdict)》中并未被使用过。 因此,它不在词汇表中。这凸显了在处理大语言模型(LLMs)时,需要考虑使用大量且多样的训练集来扩充词汇表。
接下来,我们将在包含未知单词的文本上进一步测试这个分词器,并讨论在训练大语言模型(LLM)时可用于提供更多上下文信息的其他特殊词元。
四、添加特殊的上下文词元
我们需要修改分词器,以便处理未知单词。我们还需要处理特殊上下文词元的使用和添加问题,这些特殊词元能够增强模型对文本中上下文或其他相关信息的理解。例如,这些特殊词元可以包括表示未知单词的标记以及文档边界的标记。具体来说,我们将修改词汇表和分词器(SimpleTokenizerV2),以支持两个新的词元:<|unk|> 和 <|endoftext|>,如下图 所示。
我们向词汇表中添加特殊词元,以便处理特定的上下文情况。例如,我们添加一个<|unk|>词元,用来表示那些不属于训练数据的新的未知单词,因而这些单词也不属于现有的词汇表。此外,我们还添加一个<|endoftext|>词元,我们可以用它来分隔两个不相关的文本来源。
我们可以对分词器进行修改,使其在遇到不属于词汇表的单词时使用<|unk|>词元。此外,我们在不相关的文本之间添加一个词元。 例如,在对多个独立的文档或书籍进行类似GPT的大语言模型(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()))
根据这条打印语句的输出结果,新的词汇表大小为1132(之前的词汇表大小是1130)。 作为一项额外的快速检查,让我们打印出更新后的词汇表的最后五项内容:
for i, item in enumerate(list(vocab.items())[-5:]):
print(item)
代码打印:
('younger', 1127)
('your', 1128)
('yourself', 1129)
('<|endoftext|>', 1130)
('<|unk|>', 1131)
根据代码输出,我们可以确认这两个新的特殊词元确实已成功纳入词汇表中。接下来,我们按照如下代码清单所示,相应地调整代码清单中的分词器(实现一个简短的文本分词器(text tokenizer))。
代码清单:一个能处理未知单词的简单文本分词器
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 //用<|unk|>词元替换未知单词
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) //替换指定标点符号前的空格
return text
与我们在代码清单中(实现一个简短的文本分词器(text tokenizer))实现的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]
我们可以看到,词元标识符(token IDs)列表中,<|endoftext|>分隔词元的值为1130,此外还有两个值为1131的词元标识符,它们是用于表示未知单词的。 让我们对文本进行逆分词操作,以便快速进行合理性检查:
print(tokenizer.decode(tokenizer.encode(text)))
输出是:
<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of
the <|unk|>.
通过将这个逆分词后的文本与原始输入文本进行比较,我们知道,训练数据集,即伊迪丝·华顿(Edith Wharton)的短篇小说《判决》,并不包含“Hello”(你好)和“palace”(宫殿)这两个单词。 根据不同的大语言模型(LLM),一些研究人员还会考虑使用其他特殊词元,如下所示:
- [BOS](序列开始)——这个词元标记一段文本的开头。它向大语言模型表明一段内容从哪里开始。
- [EOS](序列结束)——这个词元位于一段文本的末尾,在连接多个不相关的文本时特别有用,这与<|endoftext|>类似。例如,当合并两篇不同的维基百科文章或书籍时,[EOS]词元指示一篇文章或书籍在哪里结束,下一篇从哪里开始。
- [PAD](填充)——当以大于1的批量大小训练大语言模型时,批次中可能包含长度不同的文本。为了确保所有文本具有相同的长度,较短的文本会使用[PAD]词元进行扩展或“填充”,直至达到批次中最长文本的长度。
用于GPT模型的分词器不需要这些词元中的任何一个;为了简单起见,它只使用<|endoftext|>词元。<|endoftext|>与[EOS]词元类似。<|endoftext|>也用于填充。然而,正如我们将在后续章节中探讨的那样,在对批量输入进行训练时,我们通常会使用掩码,这意味着我们不会关注填充的词元。因此,为填充选择的特定词元就变得无关紧要了。
此外,用于GPT模型的分词器也不会对词汇表外的单词使用<|unk|>词元。相反,GPT模型使用字节对编码分词器,它将单词分解为子词单元,我们接下来会讨论这个内容。
总结
大型语言模型(LLM)需要将文本数据转换为数值向量,即嵌入,因为它们无法处理原始文本。嵌入将离散数据(如单词或图像)转换为连续向量空间,使其与神经网络操作兼容。
第一步,原始文本被拆分为标记(token),这些标记可以是单词或字符。然后,这些标记被转换为整数表示,即标记ID。
为了增强模型的理解能力并处理各种上下文,如未知单词或标记不相关文本之间的边界,可以添加特殊标记,如<|unk|>和<|endoftext|>。
用于GPT-2和GPT-3等大型语言模型的字节对编码(BPE)分词器可以高效处理未知单词,通过将其分解为子词单元或单个字符来实现。
我们在分词后的数据上使用滑动窗口方法来生成大型语言模型训练所需的输入-目标对。
在PyTorch中,嵌入层执行查找操作,检索与标记ID相对应的向量。所得的嵌入向量提供了标记的连续表示,这对于训练像大型语言模型这样的深度学习模型至关重要。
虽然标记嵌入为每个标记提供了一致的向量表示,但它们缺乏标记在序列中的位置感。为了解决这个问题,存在两种主要的位置嵌入类型:绝对位置嵌入和相对位置嵌入。OpenAI的GPT模型使用绝对位置嵌入,这些嵌入被添加到标记嵌入向量中,并在模型训练过程中进行优化。