系列文章目录
【阅读记录-章节1】Build a Large Language Model (From Scratch)
【阅读记录-章节2】Build a Large Language Model (From Scratch)
【阅读记录-章节3】Build a Large Language Model (From Scratch)
【阅读记录-章节4】Build a Large Language Model (From Scratch)
【阅读记录-章节5】Build a Large Language Model (From Scratch)
【阅读记录-章节6】Build a Large Language Model (From Scratch)
【阅读记录-章节7】Build a Large Language Model (From Scratch)
目录
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):