【阅读记录-章节2】Build a Large Language Model (From Scratch)
目录
- 2.Working with text data
- 2.1 Understanding word embeddings
- 2.2 Tokenizing text
- 通过一个简单的实验来理解文本的词元化概念
- 关键概念
- 2.3 Converting tokens into token IDs
- 实现分词器类(Tokenizer Class)
- 应用分词器
- 测试文本的编码与解码
- 通过分词器的 `encode` 方法,将其转换为 token IDs:
- 然后,使用 `decode` 方法将这些 token IDs 转回文本:
- 处理未知词汇
- 2.4 Adding special context tokens
- 代码示例
- 常用标记
- 2.5 Byte pair encoding
- BPE Tokenizer 的特点
- 使用 Python 库 `tiktoken`
- 安装 `tiktoken`
- 示例代码:编码和解码
- 编码文本为 Token IDs
- 解码 Token IDs 为文本
- 小实验:处理未知单词
- 编码未知单词
- 逐步解码
- 完整解码
- 2.6 Data sampling with a sliding window
- 1. 生成输入–目标对
- 2. 使用BPE分词器对文本进行分词
- 3. 创建滑动窗口输入–目标对
- 4. 利用滑动窗口生成多个输入–目标对
- 5. 使用Dataset和DataLoader创建高效数据加载器
- 6. 创建DataLoader并进行测试
- 7. 测试数据加载器的滑动窗口效果
- stride(步长)
- stride 设置的作用:
- 实验不同的 stride 和 max_length 设置:
- 批次大小与内存使用的权衡:
- 大批次的例子:
- 2.7 Creating token embeddings
- 2.8 Encoding word positions
- 示例
- 总结:
2.Working with text data
大型语言模型(LLMs)是如何构建和训练的,重点关注基于Transformer架构的、仅使用解码器(decoder-only)的GPT类语言模型:
-
模型预训练:LLMs预训练阶段会处理大量文本数据,通过一个接一个单词的方式来学习语言结构。预训练的任务是预测下一个单词。使用这一预测任务来训练包含数百万甚至数十亿参数的模型,使其获得出色的生成和理解能力。
-
微调(fine-tuning):完成预训练的模型可以进一步微调,以执行一般性指令或某些特定任务,使其在应用时更符合目标需求。
-
数据集准备:为了实现并训练LLMs,首先需要准备训练数据。训练数据处理包括:
- 文本分词:将句子拆分成单词或子词,以便模型更好地处理不同长度和复杂性的词汇。
- 向量表示:将这些分词编码为向量表示,以输入给模型进行训练。
-
高级分词方法:使用像字节对编码(Byte Pair Encoding, BPE)等高级分词方案,这种方法在GPT等流行模型中常用,能够更高效地处理词汇。
-
采样与数据加载策略:最后,需要构建采样和数据加载策略,以生成训练模型所需的输入输出对,确保在训练时数据以合适的方式送入模型。
总体而言,这部分介绍了从文本分词到数据准备、再到模型训练的流程,为进一步了解LLMs的实现和训练奠定了基础。
2.1 Understanding word embeddings
-
为什么需要转换文本:
深度神经网络(包括大语言模型,LLMs)无法直接处理原始文本,因为文本是离散的(分类型数据),而神经网络需要连续值来进行计算。因此,文本中的单词需要被转换成模型能够理解的数值向量。
-
什么是“嵌入”:
嵌入(embedding)就是将单词(或图像、音频等其他类型的数据)转换成连续的向量表示。这种方法让神经网络可以处理非数字数据,并且能够捕捉到单词之间的关系和含义。- 单词嵌入是最常用的文本嵌入方法,将每个单词映射到向量空间中。
- 还有句子嵌入和文档嵌入,适用于更复杂的任务,比如结合检索的生成任务(从知识库中检索信息并生成文本),但这里的重点在于单词级别的嵌入。
-
早期的嵌入方法:
- 例如Word2Vec方法,通过分析词汇的上下文来生成嵌入。具有相似含义的单词往往出现在相似的上下文中,因此在向量空间中可以聚集在一起。
- 例如Word2Vec方法,通过分析词汇的上下文来生成嵌入。具有相似含义的单词往往出现在相似的上下文中,因此在向量空间中可以聚集在一起。
-
嵌入的维度:
- 嵌入的维度可以不同,维度越高往往能捕捉到更丰富的语义关系,但计算成本也更高。例如,较小的模型如GPT-2的嵌入维度为768,而更大的GPT-3模型则高达12,288维度。
2.2 Tokenizing text
实现LLM生成和更新专属嵌入的关键步骤如下:
-
构建嵌入层:在LLM的输入层设计一个嵌入层(通常是
nn.Embedding
层)。这个嵌入层会将输入的词或子词的离散表示(如词汇表索引)映射为连续的高维向量。不同于Word2Vec的固定嵌入,这里的嵌入向量是初始化后会不断调整的。 -
随机初始化嵌入向量:嵌入层的向量最初是随机初始化的,随着训练不断优化。这个随机初始化使得LLM在训练初期不会带有任何偏见,也让它能更灵活地学习特定任务的语义关系。
-
训练过程中的自适应优化:在LLM的训练中(如在预测下一个词的过程中),通过反向传播不断调整嵌入层的权重。每次迭代时,嵌入会根据损失函数更新,使得嵌入向量逐步对当前任务的需求和数据结构更敏感,从而提升模型在具体任务上的表现。
-
上下文敏感的词嵌入:LLM还会生成上下文敏感的嵌入,意味着词的表示会随着不同的上下文而动态调整,使模型能够在不同语境下理解词的不同含义。这些嵌入在训练中不仅适用于当前数据,还能够适应与此任务相似的其他任务。
这种方法相比于使用Word2Vec的固定嵌入,能生成专门针对当前任务和数据优化的词嵌入,从而提升了模型的表现。
为什么LLMs使用自己生成的嵌入:
- 尽管可以使用像Word2Vec这样的预训练模型生成通用的嵌入,但LLMs在训练过程中会生成并更新其专属嵌入。这种方法让嵌入更适合模型的特定任务和数据,提升了模型的任务表现。
通过一个简单的实验来理解文本的词元化概念
-
数据来源:我们将使用Edith Wharton的短篇小说《The Verdict》作为LLM训练的示例数据,该文本已进入公共领域,可以合法使用。可以从在本书的GitHub库中找到该文件(
the-verdict.txt
)。 -
文本读取:使用Python的标准文件读取功能将文本加载到程序中,并打印字符总数和文件的前100个字符。
# 导入操作系统模块(os),用于检查文件是否存在
import os
# 导入urllib.request模块,用于下载文件
import urllib.request
# 检查当前目录下是否已经存在 "the-verdict.txt" 文件
if not os.path.exists("the-verdict.txt"):
# 如果文件不存在,定义文件的下载链接
url = ("https://raw.githubusercontent.com/rasbt/"
"LLMs-from-scratch/main/ch02/01_main-chapter-code/"
"the-verdict.txt")
# 定义文件保存路径,即当前目录下的 "the-verdict.txt"
file_path = "the-verdict.txt"
# 使用urllib.request.urlretrieve方法从指定的URL下载文件并保存到指定路径
urllib.request.urlretrieve(url, file_path)
# 使用 'with' 语句打开文件 'the-verdict.txt',以只读模式 ("r") 打开,并指定文件编码为 'utf-8'
with open("the-verdict.txt", "r", encoding="utf-8") as f:
# 使用 read() 方法读取文件内容并将其存储在变量 raw_text 中
raw_text = f.read()
# 打印文件内容的总字符数,即 raw_text 的长度
print("Total number of character:", len(raw_text))
# 打印文件的前 99 个字符,帮助我们快速查看文件的开头部分
print(raw_text[:99])
-
词元化(Tokenization)概念:为便于LLM处理,需将文本分割成独立的词和符号。大规模的LLM训练通常需要数百万篇文章和大量文本,但这里我们以一个小文本示例说明文本处理的主要步骤。
-
初步分词方法:使用Python的正则表达式库
re
进行简单的词元化,将文本按空格和标点符号分割。例如:re.split(r'(\s)', text)
可以将文本按空格切分为单词、空格和标点符号列表。此过程展示了如何使用正则表达式进行简单的文本拆分。
# 导入 Python 的正则表达式库 're',用于处理正则表达式相关的操作
import re
# 定义一个包含标点符号的文本字符串
text = "Hello, world. This, is a test."
# 使用 re.split() 函数根据空白字符(\s)分割文本,并保留空白字符作为结果的一部分
# 正则表达式 (\s) 会匹配空白字符(如空格、制表符等),并且括号() 表示保留匹配的空白字符
result = re.split(r'(\s)', text)
# 打印分割后的结果
# 输出的列表包含分割后的单词和空白字符,空白字符也作为单独的元素保留在列表中
print(result)
- 进一步优化分词:通过扩展正则表达式,如
r'([,.]|\s)'
和r'([,.:;?_!"()\']|--|\s)'
,可以分离更多类型的标点符号(例如逗号、句号、引号、问号等),从而创建更准确的分词方案。并且删除列表中的多余空白字符,使输出的词元更加清晰。
# 导入 Python 的正则表达式库 're',用于处理正则表达式相关的操作
import re
# 定义一个包含标点符号的文本字符串
text = "Hello, world. This, is a test."
# 使用正则表达式分割文本,匹配逗号、句点、空格等字符
result = re.split(r'([,.]|\s)', text)
# 打印分割后的结果
# 结果会显示分割后的文本,空格和标点符号也作为单独的元素出现在列表中
print(result)
# 使用列表推导式去除空白字符并过滤掉空字符串
# .strip() 方法去除每个元素前后的空格,确保不会将空字符串保留下来
# item.strip() 确保去除掉不需要的空格
result = [item for item in result if item.strip()]
# 打印处理后的结果,空格已被去除,保留单词和标点
print(result)
# 示例文本,包含更多的标点符号和特殊字符
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)
- 应用到完整文本:将优化后的分词方法应用到整篇文本《The Verdict》中,生成了4,690个词元,不包含空白字符。分词后的前30个词元显示出分词器可以有效地将单词与标点符号分开。
# 读取原始文本并应用相同的分割方法
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
# 使用列表推导式去除空白字符并过滤空字符串
preprocessed = [item.strip() for item in preprocessed if item.strip()]
# 打印文本处理后的前30个词
# 这部分文本已经被处理为 token(词和标点符号的分割列表)
print(preprocessed[:30])
关键概念
- 嵌入准备:通过分词器将文本分割为单词或标点符号,使后续的嵌入模型能够将这些分词转换为数值向量,供LLM进行进一步训练。
- 正则表达式分词器:实现了一种基本的分词方法,能够处理多种标点符号,并生成适合用于嵌入的文本序列。
2.3 Converting tokens into token IDs
如何将文本中的词语(tokens)转换为整数表示(token IDs),并进一步应用该映射生成词汇表:
- 构建词汇表(Vocabulary)
首先,在将文本中的每个 token(词语)映射为唯一的整数 ID 之前,我们需要创建一个词汇表。这一词汇表定义了如何将文本中的每个唯一单词和特殊字符映射到一个唯一的整数。
all_words = sorted(set(preprocessed)) # 通过去重和排序生成所有唯一的词汇
vocab_size = len(all_words) # 获取词汇表的大小
print(vocab_size) # 打印词汇表大小
通过这段代码,可以得到文本中的唯一 token 列表,并计算词汇表的大小。
- 创建词汇表字典
然后,使用enumerate()
函数和词汇表中的所有唯一 token 构建一个映射关系:token 到唯一整数 ID。词汇表的前几个条目将显示为如下字典格式:
# 创建一个词汇表,将每个唯一的 token 映射到一个唯一的整数值
vocab = {token: integer for integer, token in enumerate(all_words)}
# 遍历 vocab 字典中的每个 (token, integer) 对
for i, item in enumerate(vocab.items()):
# 打印当前的 token 和对应的整数 ID
print(item)
# 如果已经打印了 50 个 token,则停止打印
if i >= 50:
break
这个字典将每个 token 映射到唯一的整数 ID,从而形成词汇表。输出示例如下:
('!', 0)
('"', 1)
("'", 2)
...
('Her', 49)
('Hermia', 50)
这样每个 token 都会被分配一个唯一的整数标签。
- 将文本转换为 token IDs
现在,我们可以通过构建的词汇表,将新文本中的 tokens 转换为整数 ID。通过分词,文本被拆分成 tokens,接着根据词汇表的映射将这些 tokens 转换为对应的整数 ID。
实现分词器类(Tokenizer Class)
为了实现这一过程,代码中定义了一个简单的分词器类 SimpleTokenizerV1
,包括两个方法:
encode
: 将文本转化为 token IDs。decode
: 将 token IDs 转化回文本。
class SimpleTokenizerV1:
# 构造函数,初始化词汇表和反向词汇表
def __init__(self, vocab):
self.str_to_int = vocab # 将词汇表(token -> integer)作为类的属性
# 创建一个反向词汇表,用于将整数转换回对应的词(integer -> token)
self.int_to_str = {i: s for s, i in vocab.items()} # 反向映射
# encode方法,将文本转换为token IDs
def encode(self, text):
# 使用正则表达式分词,拆分文本为一个个token
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text) # 按标点符号和空格分词
# 去除每个token两端的空格,并且过滤掉空字符串
preprocessed = [item.strip() for item in preprocessed if item.strip()]
# 使用词汇表将每个token映射为对应的token ID
ids = [self.str_to_int[s] for s in preprocessed] # 将tokens转换为token IDs
return ids # 返回token IDs列表
# decode方法,将token IDs转换回文本
def decode(self, ids):
# 将token IDs转换为对应的tokens,加入空格进行拼接
text = " ".join([self.int_to_str[i] for i in ids]) # 将token IDs转换为tokens
# 使用正则表达式去掉多余的空格,使标点符号与前后的词连接
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) # 去除空格后的标点符号
return text # 返回恢复后的文本
应用分词器
我们可以通过创建 SimpleTokenizerV1
的实例,并使用 encode
方法将文本转换为 token IDs,随后使用 decode
方法将这些 token IDs 转回文本。
测试文本的编码与解码
例如,对于以下文本:
text = """It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
通过分词器的 encode
方法,将其转换为 token IDs:
ids = tokenizer.encode(text)
print(ids)
输出的 token IDs 如下:
[1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]
然后,使用 decode
方法将这些 token IDs 转回文本:
print(tokenizer.decode(ids))
输出结果是:
'" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.'
处理未知词汇
当我们尝试对不在训练集中的文本进行编码时(例如新文本“Hello, do you like tea?”),由于这些词汇不在词汇表中,就会出现 KeyError
错误。这提醒我们需要通过更大的训练集来扩展词汇表,从而覆盖更多的单词。
2.4 Adding special context tokens
在本节中主要分析了如何修改分词器(Tokenizer)以处理未知单词,并引入了一些特殊的上下文标记(special context tokens),这些标记有助于增强模型对上下文或其他相关信息的理解。
-
处理未知单词
为了处理文本中出现的未知单词(即词汇表中没有的单词),我们将遇到的每个未知单词用特殊的<|unk|>
标记替代。这使得模型能够更好地处理未在训练数据中见过的词汇。
-
添加特殊的上下文标记
特殊标记有助于模型更好地理解文本中的上下文。例如,<|endoftext|>
标记用来表示文档或文本的结束,特别是当多个独立的文档或文本被合并到一起时。它帮助模型区分不同的文本块。例如,在训练GPT类型的大型语言模型时,多个文档或书籍可以连接在一起。在每个文档或书籍前插入
<|endoftext|>
标记,表示每个文档的开始,以帮助模型理解它们之间的关系。
-
修改词汇表
通过将<|unk|>
和<|endoftext|>
这两个特殊标记添加到所有唯一词汇的列表中,更新词汇表,并重新生成词汇到整数的映射。新词汇表的大小是1132(相比之前的1130)。 -
更新分词器
新的分词器SimpleTokenizerV2
在遇到未知单词时,会用<|unk|>
标记替换掉这些未知单词。此外,还会处理特殊的标点符号,确保标点符号后没有不必要的空格。
代码示例
- 修改后的词汇表
all_tokens = sorted(list(set(preprocessed))) # 去除重复词汇,并按字母顺序排序
all_tokens.extend(["<|endoftext|>", "<|unk|>"]) # 添加特殊标记 "<|endoftext|>" 和 "<|unk|>"
vocab = {token: integer for integer, token in enumerate(all_tokens)} # 为每个词汇分配唯一整数ID
print(len(vocab.items())) # 输出新的词汇表大小
-
打印词汇表的最后五个项
for i, item in enumerate(list(vocab.items())[-5:]): print(item)
-
修改后的分词器(
SimpleTokenizerV2
)
class SimpleTokenizerV2:
def __init__(self, vocab):
self.str_to_int = vocab # 将词汇表保存在类中,用于 token 到 ID 的映射
self.int_to_str = {i: s for s, i in vocab.items()} # 创建反向词汇表,用于 ID 到 token 的映射
def encode(self, text):
# 使用正则表达式分割文本,保留标点符号
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
# 去除多余的空格字符,保留非空 token
preprocessed = [item.strip() for item in preprocessed if item.strip()]
# 将每个 token 转换为词汇表中的 ID,如果不在词汇表中则用 "<|unk|>" 表示
preprocessed = [item if item in self.str_to_int else "<|unk|>" for item in preprocessed]
# 将预处理后的 token 转换为对应的 ID 列表
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids):
# 将 token IDs 转换回对应的 token,并用空格连接成字符串
text = " ".join([self.int_to_str[i] for i in ids])
# 调整空格与标点符号的格式,去除标点符号前的空格
text = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)
return text
- 使用新分词器处理文本
text1 = "Hello, do you like tea?" # 定义第一个独立文本
text2 = "In the sunlit terraces of the palace." # 定义第二个独立文本
# 将两个文本用特殊标记 "<|endoftext|>" 连接,表示它们是独立的内容
text = " <|endoftext|> ".join((text1, text2))
print(text) # 输出合并后的文本以便检查
# 初始化词汇表 tokenizer 的实例
tokenizer = SimpleTokenizerV2(vocab)
# 将文本转换为对应的 token IDs 并打印结果
print(tokenizer.encode(text))
# 将 token IDs 解码回文本并打印,验证编码和解码过程
print(tokenizer.decode(tokenizer.encode(text)))
输出:
Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
[1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]
<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.
常用标记
-
词汇缺失检测:在编码和解码过程中发现训练数据集(如小说 The Verdict)不包含某些词(如 “Hello” 和 “palace”),因此这些词被替换成特殊的
<|unk|>
词元,表明这是训练集中未见过的词汇。 -
常用特殊标记:
- [BOS] (序列开始标记):标记文本的起始,用于明确内容的开头。
- [EOS] (序列结束标记):用于表示文本结尾。通常在拼接无关文本时(如多个独立文档)插入,帮助模型区分内容边界。
- [PAD] (填充标记):用于调整批处理中的不同文本长度,使得批中所有文本长度一致。
-
GPT 模型的特殊标记使用:
- <|endoftext|> 词元:在 GPT 模型中既用于指示文本结束,也用于填充。相比 [EOS] 词元,GPT 模型简化了词元需求,只使用
<|endoftext|>
。 - 掩码处理:在批量处理时,GPT 使用掩码忽略填充部分,从而使填充标记的具体内容不再重要。
- 词汇分割:GPT 不使用
<|unk|>
来处理未见词,而是采用子词单元分割(如字节对编码 Byte pair encoding),将词拆分为更小的部分(子词)进行编码。
- <|endoftext|> 词元:在 GPT 模型中既用于指示文本结束,也用于填充。相比 [EOS] 词元,GPT 模型简化了词元需求,只使用
2.5 Byte pair encoding
BPE 是一种用于处理文本的技术,专注于将文本拆分为子词或字符,从而让模型能够处理未在训练数据中出现的单词。
-
普通词表的问题:
- 如果训练数据中没有出现某些单词(比如“someunknownPlace”),模型可能无法理解。
- 通常方法是用
<|unk|>
替代这些未知单词,但这会丢失信息。
-
BPE 的解决方案:
- 将未知单词拆分成更小的部分,比如子词或单个字符。
- 比如,
someunknownPlace
可以被拆成some
、unknown
、Place
。
BPE Tokenizer 的特点
-
处理未知单词:
- 不需要
<|unk|>
标记。 - 使用子词和字符来表示未在词汇表中的单词。
- 不需要
-
灵活性:
- 即使出现从未见过的单词,BPE 也能用其小单位表示,确保模型能理解所有输入。
-
特殊标记:
<|endoftext|>
:表示文本结束,ID 是词汇表中最大的一个(50256)。- 不需要
<|unk|>
标记,因为未知单词会被拆分。
使用 Python 库 tiktoken
tiktoken
是一个专门用于实现 BPE 的库,效率很高。
安装 tiktoken
在终端运行以下命令:
pip install tiktoken
检查版本
from importlib.metadata import version
import tiktoken
print("tiktoken version:", version("tiktoken"))
初始化 BPE Tokenizer
tokenizer = tiktoken.get_encoding("gpt2") # 使用 GPT-2 的词汇表
示例代码:编码和解码
编码文本为 Token IDs
将文本拆分为对应的 Token IDs:
text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)
输出:
[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]
解码 Token IDs 为文本
将 Token IDs 转换回原始文本:
decoded_text = tokenizer.decode(integers)
print(decoded_text)
输出:
Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.
小实验:处理未知单词
使用 BPE 对未知单词 Akwirw ier
编码和解码。
编码未知单词
text = "Akwirw ier"
integers = tokenizer.encode(text)
print(integers) # 输出单词的 Token IDs
逐步解码
逐个解码每个 Token ID:
for i in integers:
print(i, tokenizer.decode([i]))
完整解码
验证能否还原原始输入:
decoded_text = tokenizer.decode(integers)
print(decoded_text) # 应输出 "Akwirw ier"
2.6 Data sampling with a sliding window
为LLM 生成输入–目标对是训练数据准备中的重要一步。这些输入–目标对表示LLM在每个训练步中从文本中学习预测下一个词的任务。通过以下步骤,我们可以逐步理解如何为LLM创建这些输入–目标对,并实现一个高效的数据加载器。
1. 生成输入–目标对
LLM通过学习预测下一个词来进行训练。我们可以将文本按单词分成小块,分成“输入块”和“目标块”,然后逐步训练模型让它学会预测下一个单词。如下图所示(以figure 2.12为例),给定一段文本,模型输入的是一个输入块,目标是预测紧接其后的下一个单词:
这个输入–目标对需要先通过分词(例如BPE分词器)进行处理,转化成模型可以接受的Token ID表示。
2. 使用BPE分词器对文本进行分词
首先,我们需要使用BPE分词器将整段文本转化成Token ID,以便后续处理:
# 打开文件 "the-verdict.txt" 以只读模式,并指定编码为 UTF-8
with open("the-verdict.txt", "r", encoding="utf-8") as f:
# 读取文件的全部内容到 raw_text 字符串中
raw_text = f.read()
# 使用 tokenizer 将原始文本进行编码,转换为 token 的数字表示
enc_text = tokenizer.encode(raw_text)
# 输出编码后 token 的数量,即编码后的文本长度
print(len(enc_text))
执行这段代码后,会得到分词后的Token ID序列长度(例如,这里返回5145,表示数据集中总共包含5145个token)。
3. 创建滑动窗口输入–目标对
为了演示,我们从数据集中移除前50个token,然后定义一个context_size
(上下文大小)来生成x
和y
变量,分别作为输入和目标。
# 从 enc_text 中删除前50个 token,得到一个新的子样本
enc_sample = enc_text[50:]
# 设置上下文块的大小为 4
context_size = 4
# 选择前 4 个 token 作为输入块(x)
x = enc_sample[:context_size]
# 选择从第 2 个 token 开始的 4 个 token 作为目标块(y)
y = enc_sample[1:context_size+1]
# 打印输入块(x)
print(f"x: {x}")
# 打印目标块(y)
print(f"y: {y}")
这段代码会打印如下输出:
x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]
此时x
是输入块,y
是目标块,目标是让模型预测出x
的下一个Token ID。
4. 利用滑动窗口生成多个输入–目标对
滑动窗口方法会逐步扩大上下文块,每次将目标token右移一位:
# 遍历从 1 到 context_size(包括 context_size)
for i in range(1, context_size+1):
# 对于每个 i,取 enc_sample 中前 i 个 token 作为上下文(context)
context = enc_sample[:i]
# 取第 i 个 token 作为目标 token(desired)
desired = enc_sample[i]
# 打印当前的上下文和对应的目标 token
print(context, "---->", desired)
输出示例:
[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
箭头前面的部分表示模型输入的内容,右侧的token ID是模型要预测的目标。让我们重复之前的代码,将 token ID 转换为文本:
# 遍历上下文大小范围(从1到context_size,包括context_size)
for i in range(1, context_size+1):
# 获取当前上下文,包含从开始到第i个token
context = enc_sample[:i]
# 获取目标token,即紧接着上下文后面的第i个token
desired = enc_sample[i]
# 解码上下文和目标token,并打印它们,以便查看实际的文本输出
print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))
以下输出展示了输入和输出在文本格式下的样子:
and ----> established
and established ----> himself
and established himself ----> in
and established himself in ----> a
现在,我们已经创建了可以用于 LLM 训练的输入–目标对。
5. 使用Dataset和DataLoader创建高效数据加载器
接下来,我们将实现一个基于PyTorch Dataset
和DataLoader
的自定义数据集类GPTDatasetV1
。此类利用滑动窗口方法对数据进行分块,生成多个上下文–目标对。
import torch
from torch.utils.data import Dataset, DataLoader
# 定义一个自定义的数据集类GPTDatasetV1,继承自torch.utils.data.Dataset
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
# 初始化时传入文本、tokenizer、最大长度max_length和步幅stride
self.input_ids = [] # 用来存储输入序列的列表
self.target_ids = [] # 用来存储目标序列的列表
# 使用tokenizer将输入文本转化为token ID列表
token_ids = tokenizer.encode(txt)
# 使用滑动窗口方式来创建输入和目标序列
for i in range(0, len(token_ids) - max_length, stride):
# 从token_ids中截取max_length个token作为一个输入块
input_chunk = token_ids[i:i + max_length]
# 对应的目标块是输入块向右平移一个位置
target_chunk = token_ids[i + 1: i + max_length + 1]
# 将输入块和目标块转换为PyTorch张量并添加到列表中
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]
6. 创建DataLoader并进行测试
下面代码将加载数据,并为LLM生成一批大小为4的训练对。
def create_dataloader_v1(txt, batch_size=4, max_length=256,
stride=128, shuffle=True, drop_last=True,
num_workers=0):
# 使用 tiktoken 库获取 GPT-2 编码器,这个编码器用于将文本转换为 token ID
tokenizer = tiktoken.get_encoding("gpt2")
# 使用 GPTDatasetV1 类将文本转换为数据集
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
# 创建一个 PyTorch DataLoader 实例,负责将数据集分批次加载,并进行相关配置
dataloader = DataLoader(
dataset, # 数据集对象
batch_size=batch_size, # 每个批次的样本数
shuffle=shuffle, # 是否在每个 epoch 开始时打乱数据
drop_last=drop_last, # 如果数据集大小不能整除 batch_size,是否丢弃最后一个不完整的批次
num_workers=num_workers # 数据加载时使用的子进程数
)
# 返回构建好的 DataLoader 对象
return dataloader
7. 测试数据加载器的滑动窗口效果
使用数据加载器时,stride参数决定输入窗口在每批次之间的移动距离。我们可以设置不同的stride值来测试模型的输入。
以下代码展示了当stride=1
时如何加载第一批数据并打印内容:
# 打开一个名为 "the-verdict.txt" 的文本文件,使用 utf-8 编码
with open("the-verdict.txt", "r", encoding="utf-8") as f:
# 读取文件中的全部内容并存储在 raw_text 变量中
raw_text = f.read()
# 使用 create_dataloader_v1 函数创建数据加载器,传入文本、批次大小、最大长度、步长等配置
# 参数说明:
# raw_text: 待处理的文本数据
# batch_size=1: 每个批次只加载 1 个样本
# max_length=4: 每个输入块的最大 token 数量为 4
# stride=1: 每次移动一个 token,生成下一个输入-目标对
# shuffle=False: 不打乱数据(由于是单个文本,通常不需要打乱)
dataloader = create_dataloader_v1(
raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
# 将 dataloader 转换为可迭代对象(使用 iter 函数)
data_iter = iter(dataloader)
# 获取数据加载器的第一个批次数据
first_batch = next(data_iter)
# 打印第一个批次的数据,查看输出内容
print(first_batch)
输出:
[tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
可以看到,这里每个batch包含一个大小为4的上下文块,目标是预测接下来的token ID。
stride(步长)
决定了生成数据时,输入数据窗口移动的步幅。步长越大,每个批次的起始位置就越远,减少了批次之间的重叠。
代码示例:
second_batch = next(data_iter)
print(second_batch)
假设第二个批次打印结果是:
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
- 如果与第一个批次对比,第二批次的 token ID 相较第一个批次右移了一个位置。例如,第一个批次输入的第二个 token 是
367
,而第二批次输入的第一个 token 就是367
。 - 步长为 1时,批次之间的 token ID 只会移动一个位置,即“滑动窗口”方式。
stride 设置的作用:
滑动窗口的概念:
- stride=1:每次生成新的批次时,输入窗口(token ID 列表)向右滑动一个位置,这会导致批次之间有部分重叠。例如,在第一个批次和第二个批次之间,我们会看到相同的 token
367
和2885
,这导致了部分数据重复。 - stride=4:如果步长设为4,输入窗口每次向右移动4个位置,这样就可以避免批次之间的重叠。
在例子中的图示:
- stride=1时,窗口从文本中按一个token的步幅滑动。
- stride=4时,窗口则按4个token的步幅滑动。
实验不同的 stride 和 max_length 设置:
通过设置不同的 max_length
(输入块的最大长度)和 stride
,你可以控制每个批次的生成方式。例如:
- max_length=2,stride=2:每个输入块有两个 token,步长为两个 token,每次滑动两个位置,避免了重叠。
- max_length=8,stride=2:输入块有八个 token,步长为两个 token,每次滑动两个位置,可能会出现部分重叠。
这些设置有助于调节模型训练过程中的内存使用和计算量。
批次大小与内存使用的权衡:
- 小批次(batch size=1):每次只处理一个样本,适合用于演示和测试,但小批次的训练可能会导致更新噪声较大。
- 大批次(batch size > 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
来避免批次之间的重叠,因为每次滑动 4 个位置。max_length=4
定义了每个输入块的最大 token 数量。
通过调整步长(stride)和输入块大小(max_length),你可以控制数据加载器的行为,优化内存使用、计算速度和训练稳定性。步长越小,批次之间的重叠越多;步长越大,批次之间的重叠越少,但可能导致训练的效率降低。
2.7 Creating token embeddings
为了训练LLM(大语言模型),我们需要将Token ID转换为嵌入向量。这一步的关键是初始化嵌入权重,并将其赋予随机值。这个初始化的嵌入矩阵将作为LLM学习的起点,随着训练的进行,这些嵌入权重会被优化。
嵌入层(Embedding Layer)是一个非常基础的神经网络层,它通过查表的方式将每个Token ID映射到一个向量空间中。GPT类的LLM属于深度神经网络,使用反向传播算法进行训练,嵌入层的向量表示会参与这一过程。
- 示例:
- 假设输入的Token ID为
[2, 3, 5, 1]
,并且词汇表大小为6,嵌入维度为3(GPT-3中是12,288维度)。 - 通过PyTorch的
torch.nn.Embedding
类,我们可以创建一个嵌入层,初始化一个随机权重矩阵来表示Token的嵌入。
- 假设输入的Token ID为
- 使用
torch.manual_seed(123)
来设定随机种子,使得结果可复现。 - 通过
torch.nn.Embedding(vocab_size, output_dim)
来创建嵌入层,其中vocab_size
表示词汇表大小,output_dim
表示嵌入向量的维度。
# 定义一个包含 token ID 的张量,模拟输入文本的 token IDs
input_ids = torch.tensor([2, 3, 5, 1])
# 设置词汇表大小(vocab_size)为6,表示有6个不同的token
vocab_size = 6
# 设置嵌入维度(output_dim)为3,表示每个token ID会映射到一个3维的嵌入向量
output_dim = 3
# 设置随机数种子为123,确保每次运行代码时,初始化的权重是相同的
torch.manual_seed(123)
# 创建一个嵌入层(Embedding layer),该层将会把输入的token ID转换为对应的嵌入向量
# 输入参数:vocab_size表示词汇表大小,output_dim表示嵌入向量的维度
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
# 打印嵌入层的权重矩阵,查看该层的初始化权重
# 每一行代表词汇表中的一个token的嵌入向量(大小为output_dim维度)
print(embedding_layer.weight)
-
打印出的嵌入层权重矩阵包含6行3列,每一行表示词汇表中的一个Token的嵌入向量。
- 示例权重矩阵:
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)
- 示例权重矩阵:
-
这些小的随机值在训练过程中将被优化。
-
给定一个Token ID(例如3),嵌入层会查找权重矩阵中的对应行,返回该Token的嵌入向量。
-
示例:
print(embedding_layer(torch.tensor([3])))
输出:tensor([[-0.4015, 0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
-
从输出可以看到,返回的嵌入向量对应的是权重矩阵的第4行(索引从0开始)。
-
如果我们有多个Token ID(例如
[2, 3, 5, 1]
),嵌入层会将这些ID的每个对应行组合成一个矩阵,得到一个多维的嵌入矩阵。 -
示例:
print(embedding_layer(input_ids))
输出: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>)
-
这个4×3的矩阵中,每一行都是通过查找对应的Token ID在权重矩阵中的位置而得到的嵌入向量。
-
一旦我们得到了Token的嵌入向量,接下来的步骤是加入位置编码,即对每个Token在文本中的位置进行编码。位置编码有助于模型理解Token在序列中的相对位置(例如,第一个Token与第二个Token的位置关系)。
2.8 Encoding word positions
-
Token Embeddings(词元嵌入)
-
Token Embeddings: 词元嵌入是LLM的输入,它将每个token(如词语、字符或子词)映射为一个固定大小的向量。这些嵌入是通过词汇表的token ID进行索引的。每个token ID总是映射到相同的向量表示,无论它在输入序列中的位置如何。例如,token ID为3的token无论在句子中出现在哪个位置,它的嵌入表示都是相同的。
-
问题: 这种方法有一个问题,即缺乏位置信息。LLM的自注意力机制(Self-Attention Mechanism,见第3章)是位置无关的,它无法感知token在序列中的顺序。这意味着,即使是同一个token,位置不同,其表示也完全相同,模型无法区分token在句子中的顺序。
-
-
Position-Aware Embeddings(位置感知嵌入)
为了让模型能够理解token在序列中的位置,我们需要将位置编码(Positional Embedding)加入到词元嵌入中。这可以通过以下两种方式实现:
-
Absolute Positional Embeddings(绝对位置嵌入):
- 每个位置都有一个唯一的嵌入向量,并且这个向量与token的词元嵌入进行加法操作,以便提供该token在序列中的具体位置信息。
- 例如,序列中的第一个token将拥有一个位置嵌入,第二个token将拥有另一个位置嵌入,依此类推。
- 优点: 这种方法直观且容易实现,模型能够明确知道每个token的精确位置。
-
Relative Positional Embeddings(相对位置嵌入):
- 这种方法的重点不在于token的绝对位置,而是在于tokens之间的相对位置或距离。模型学习的是“token之间有多远”,而不是“token位于哪里”。
- 这种方法的优点是它能更好地推广到不同长度的序列,即使模型在训练中未见过这些长度的序列,也能够处理。
- 区别: 这种方法让模型专注于token之间的关系,而非每个token的确切位置。
-
-
位置嵌入的目标
- 目标: 无论是绝对位置嵌入还是相对位置嵌入,它们的最终目标都是增强LLM理解tokens之间顺序和关系的能力,从而提升模型的上下文理解和预测准确性。
- 选择依据: 选择哪种位置编码方法通常取决于具体应用和数据的性质。如果任务中的顺序信息非常重要,可能会选择绝对位置编码;如果任务需要更好的泛化能力,可能会选择相对位置编码。
-
GPT模型中的位置编码
- GPT模型使用的绝对位置编码: OpenAI的GPT模型使用的是绝对位置嵌入,这些嵌入是在训练过程中优化的,而不是像原始Transformer模型中的位置编码那样是固定的或预定义的。
- 优化过程: GPT中的位置编码会在训练过程中进行优化,以适应任务的需求。
示例
在之前的示例中,为了简化说明,我们使用了非常小的嵌入维度。现在,我们将考虑更现实和有用的嵌入维度,并将输入的token编码成一个256维的向量表示。虽然GPT-3模型的嵌入维度是12,288,但256维的嵌入对于实验来说是一个合理的选择。
- 定义嵌入层:
- 假设token ID是通过之前实现的BPE(字节对编码)分词器生成的,该分词器的词汇表大小为50,257。
- 使用
torch.nn.Embedding
定义一个嵌入层,其中:vocab_size = 50257
(词汇表大小)。output_dim = 256
(每个token的嵌入维度)。
vocab_size = 50257
output_dim = 256
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
- 数据批处理:
- 假设我们有一个批量大小为8,每个样本有4个token的批次。使用数据加载器从原始文本生成批次数据:
# 设置最大序列长度为4,即每个输入文本的token数为4
max_length = 4
# 创建数据加载器(dataloader),从原始文本(raw_text)中加载数据。
dataloader = create_dataloader_v1(
raw_text, batch_size=8, max_length=max_length,
stride=max_length, shuffle=False
)
# 创建一个迭代器对象,从数据加载器中迭代批次数据
data_iter = iter(dataloader)
# 使用迭代器获取下一个批次的输入(inputs)和目标(targets)数据
# 假设每个批次包括token IDs作为输入和目标值(通常是下一个token或标签)作为targets。
inputs, targets = next(data_iter)
# 打印token IDs,即当前批次中每个文本样本的token ID矩阵
# token IDs 是一个二维tensor,形状为(batch_size, max_length)
print("Token IDs:\n", inputs)
# 打印输入数据的形状,用于检查每个批次的大小及每个文本样本的token数量
# 这应该输出一个形状为(batch_size, max_length)的tensor
print("\nInputs shape:\n", inputs.shape)
- 输出的token ID是一个
8 × 4
维度的tensor,表示一个包含8个文本样本,每个样本包含4个token的批次:
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])
-
将token ID转换为嵌入向量:
- 使用
token_embedding_layer
将这些token ID嵌入为256维的向量。结果是一个形状为8 × 4 × 256
的tensor:
token_embeddings = token_embedding_layer(inputs) print(token_embeddings.shape)
输出:
torch.Size([8, 4, 256])
- 使用
-
添加位置嵌入(Positional Embedding):
- 为了让模型知道token在序列中的位置,我们需要为每个位置创建一个嵌入层。假设输入的最大长度为4,我们为每个位置创建一个256维的嵌入向量:
# 将最大长度 max_length 设置为上下文长度 context_length
# context_length 代表的是模型支持的输入序列的最大长度,这里与 max_length 相同
context_length = max_length
# 创建一个位置嵌入层 pos_embedding_layer,输入大小为 context_length(即输入序列最大长度),
# 输出大小为 output_dim(每个位置的嵌入向量的维度)
# pos_embedding_layer 是一个学习的位置嵌入层,类似于token嵌入层,将位置编号映射到嵌入空间
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
# 创建一个从 0 到 context_length-1 的整数序列张量,用于表示序列中的每个位置。
# torch.arange(context_length) 生成一个长度为 context_length 的向量,内容是 0, 1, ..., context_length-1。
# 这个向量将作为位置编号传递给位置嵌入层,得到每个位置的嵌入向量
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
# 打印位置嵌入张量的形状,验证每个位置是否已经得到了一个 output_dim 维度的嵌入向量
# 输出的形状应该是 (context_length, output_dim),即每个位置都有一个 output_dim 维度的嵌入向量
print(pos_embeddings.shape)
输出:
torch.Size([4, 256])
context_length
表示支持的最大输入长度,这里选择为4,等于最大输入文本的长度。我们用torch.arange(context_length)
生成一个从0到context_length-1
的整数序列来表示位置。
-
合并token嵌入和位置嵌入:
- 通过将位置嵌入与token嵌入相加,我们得到了带有位置信息的输入嵌入。这是通过PyTorch的广播机制完成的,即
4 × 256
的pos_embeddings
会加到每个4 × 256
的token_embeddings
上。
input_embeddings = token_embeddings + pos_embeddings print(input_embeddings.shape)
输出:
torch.Size([8, 4, 256])
- 通过将位置嵌入与token嵌入相加,我们得到了带有位置信息的输入嵌入。这是通过PyTorch的广播机制完成的,即
-
总结:
- 通过将token嵌入和位置嵌入相加,我们得到了一个形状为
8 × 4 × 256
的输入嵌入张量(每个token有一个256维的向量表示,并且每个token都包括其在序列中的位置信息)。 - 这些
input_embeddings
是可以作为后续LLM模型模块的输入的嵌入表示。接下来,我们将在下一章开始实现主LLM模块。
- 通过将token嵌入和位置嵌入相加,我们得到了一个形状为
总结:
-
文本转换为数值向量(嵌入):
- 嵌入是LLMs(大规模语言模型)处理文本数据的关键。因为LLMs不能直接处理原始文本数据,所以需要将文本转换为数值表示,这些数值表示就是嵌入。嵌入将离散数据(例如词语或图像)转换为连续的向量空间,使得这些数据可以与神经网络操作兼容。
-
从文本到词元的转换:
- 首先,原始文本会被分割成词元(tokens)。这些词元可以是单词或字符。
- 然后,词元会被转换为整数表示,称为词元ID。这就是模型处理文本时的基础输入。
-
特殊词元:
- 特殊的词元(如
<|unk|>
和<|endoftext|>
)用于增强模型的理解,处理不同的上下文。例如,<|unk|>
可以用来表示未知的单词,而<|endoftext|>
用于标记不同文本之间的边界。
- 特殊的词元(如
-
Byte Pair Encoding(BPE):
- LLMs(如GPT-2和GPT-3)使用**字节对编码(BPE)**分词器来高效处理未知的单词。BPE通过将未知单词拆分成子词单位或单个字符来解决该问题,从而提高对文本的表示能力。
-
生成输入–目标对:
- 在训练LLM时,我们通常使用滑动窗口方法对标记化后的数据进行处理。通过这种方法生成输入–目标对,供模型进行训练。
-
PyTorch中的嵌入层:
- 在PyTorch中,嵌入层执行的是一个查找操作,根据词元ID从嵌入矩阵中检索相应的向量。得到的嵌入向量为词元提供了连续的表示,这对于训练深度学习模型如LLMs至关重要。
-
位置嵌入:
- 词元嵌入提供了每个词元的固定向量表示,但它们没有考虑词元在序列中的位置。为了弥补这一点,存在两种主要的位置嵌入方法:
- 绝对位置嵌入:与序列中词元的位置直接相关。每个位置都对应一个唯一的嵌入向量,并且这些嵌入向量会在训练过程中优化。
- 相对位置嵌入:侧重于词元之间的相对位置关系,而不是每个词元的绝对位置。
- OpenAI的GPT模型使用的是绝对位置嵌入,这些位置嵌入与词元嵌入向量相加,在训练过程中进行优化。
- 词元嵌入提供了每个词元的固定向量表示,但它们没有考虑词元在序列中的位置。为了弥补这一点,存在两种主要的位置嵌入方法: