【手搓大模型】大模型分词器优化:特殊 token 添加与BPE算法详解,收藏这一篇就够了!!
为分词器添加特殊 token
我们需要在修改我们已经实现的分词器来处理在训练集中没有出现的单词,因此我们需要为标记器添加不在训练集中的未知单词的标记。我们将修改在前一节中实现的词汇表和分词器类。

以上图为例,在文本中加入的了<|unk|> token 和 <|endoftext|> token,我么希望我们的分词器能共正常的工作。以 SimpleTokenizerV1 为例,当我们输入文本 “Hello, do you like tea. Is this-- a test?” 时。会有如下错误:

原因是:我们训练的文本中,不包含 Hello,这个单词。如果您正在创建一个 LLM 并在数万亿个标记上对其进行训练,那么这个词可能会包含其中。当时当我们训练的数据集非常小的时候,一些简单的字符就有可能不会包含其中,比如这里的 Hello。在现在的大模型中,如果遇到没有见过的单词,会按照字符分解,这是一些更加高级的算法(以后再讨论)。这里我们先对我们的分词器做简单的修改。
如果我们拥有所有标记,即预处理的文本标记或单词的列表,那么我们可以做的就是扩展我们的标记。
all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>", "Hello"])
vocab = {token:integer for integer,token in enumerate(all_tokens)}
我们新添加的特殊token确实成功地被纳入了词汇表中,通过如下代码验证。

根据上述代码的输出,我们可以确认这两个新的特殊token确实成功地被纳入了词汇表。接下来,生成v2版本的分词器代码,我们重新修改了 encode 代码,这样我们的分词器,在遇到类似不认识的单词就不会报错了。
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
# 如果它不在词汇表中,则为假 我们将对这个未知标记返回未知
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])
# 添加一个特殊规则
# Replace spaces before the specified punctuations
text = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)
return text
检验我们新的分词器:Hello 可以被正常解析,palaca 在经过先 encode 生成的 tokenId 在 decode 输出后显示为 <|unk|>。

到目前为止,我们已经讨论了分词作为处理文本输入到 LLM 中的重要步骤。根据不同的 LLM,一些研究人员还考虑其他特殊token,例如以下几种:
- [BOS](序列开始):这个token表示文本的起始位置,指示 LLM 内容的开始。
- [EOS](序列结束):这个token位于文本的末尾,在连接多个无关文本时特别有用,类似于 <|endoftext|>。例如,在合并两个不同的维基百科文章或书籍时, [EOS] token指示一篇文章结束和下一篇文章开始。
- [PAD](填充):在使用大于 1 的批量大小数据集训练 LLM 时,批量可能包含不同长度的文本。为了确保所有文本长度一致,较短的文本会用 [PAD] token进行扩展或填充,直到达到批量中最长文本的长度。
字节对编码
字节对编码是将分词器能力提升到更高水平的算法,字节编码已经存在大约 30 年左右的算法,它现在是非常流行的分词器的算法实现,例如 GPT-1、GPT-2、3 和 4,它们都在其标记器中使用这种字节对编码算法,甚至其他公司,例如 Meta AI 在其最近的 Llama 3 型号中也在使用。因此,字节对编码确实帮助我们解决了标记器的一个主要缺点就是它采用将任何类型的单词分解为子标记的方法来处理未知单词。
通过下图来简单了解一下:
可以看到它实际上将其分解成子单词。这就说明即使是GPT-2 训练数据中不包含的内容,分词器也能正常的将其分解成字词,而不是失败,也不会插入占位符。
通过 tiktoken 来理解字节分词器
由于从零开始实现BPE可能相对复杂,我们将使用一个名为tiktoken的现有Python开源库(https://github.com/openai/tiktoken),该库基于Rust中的源代码非常高效地实现了BPE算法。与其他Python库类似,我们可以通过Python的pip安装程序从终端安装tiktoken库:
pip install tiktoken
查看安装的版本
import importlib
import tiktoken
print("tiktoken version:", importlib.metadata.version("tiktoken"))
安装完成后,我们可以按如下方式通过tiktoken实例化BPE分词器:
tokenizer = tiktoken.get_encoding("gpt2")
使用 tiktoken
这个分词器的用法类似于我们之前实现的 SimpleTokenizerV2,都是通过 encode 方法使用:
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, 1659, 617, 34680, 27271, 13]
strings = tokenizer.decode(integers)
print(strings)
# Hello, do you like tea? <|endoftext|> In the sunlit terracesof someunknownPlace.
根据上面的token ID 和解码后的文本,我们可以观察到两点:首先,<|endoftext|> token被分配了一个相对较大的token ID,即 50256。实际上,用于训练诸如 GPT-2、GPT-3 以及最初用于训练 ChatGPT 的模型的 BPE 分词器,总词汇表大小为 50,257,其中 <|endoftext|> 被分配了最大的token ID。
其次,上述BPE分词器能够正确编码和解码未知词汇,例如“someunknownPlace”。BPE分词器可以处理任何未知词汇。它是如何在不使用 <|unk|> token的情况下实现这一点的?
BPE背后的算法将不在其预定义词汇表中的单词分解为更小的子词单元甚至单个字符,使其能够处理超出词汇表的单词。因此,得益于BPE算法,如果分词器在分词过程中遇到一个不熟悉的单词,它可以将其表示为一系列子词token或字符。

使用滑动窗口进行数据采样
通过前面的介绍,我们了解了分词步骤以及将字符串分词成token再转换为整数token ID 的过程。主要过程是下图中红颜色的框圈出来的部分。

在输入 LLM 之前,还有一个步骤就是将 token IDs 转换为向量,进行嵌入,然后将它们传递给 LLM。那么我们如何有效的做到这一点,例如,LLM无法一次性接收所有标记作为输入。因此,我们这里来讨论如何有效地将这些标记ID的小块提供给LLM。
LLM 的本质是一次预测一个标记,例如,如果我们有一个输入文本,LLM学习一次预测一个单词。

因此,训练的目标是教会 LLM 一次预测下一个单词。因为我们有原始的文本,在训练过程中只需要获取文本,隐藏部分文本,然后提供 token 作为输入,下一个 token 就是 LLM 学习预测的目标标签。这里我们先来看看如何将 token 输入到 LLM。
首先,我们将使用前一节中介绍的BPE分词器对我们之前处理的《判决》短篇小说进行分词:
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分词器后,返回的token总数。
接下来,我们从数据集中移除前50个token以便演示,因为这会在接下来的步骤中产生稍微更有趣的文本段落。
enc_sample = enc_text[50:]
创建输入-目标对以进行下一个单词预测任务的最简单和最直观的方法之一是创建两个变量x和y,其中x包含输入token,y包含目标,即输入向右移动1位的结果。
# 取前 4 个 token
context_size = 4
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y: {y}")

上图展示了 token的预测过程:
- 如果 LLM 输入 290 --> 预测输出 4290
- 如果输入【290,4290】–> 预测输出 2241 我们将重复之前的代码,但将token ID 转换为文本:
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))

提高效率
为了提高效率我们使用 PyTorch。PyTorch 是一个非常流行的深度学习框架。 导入它并使用其中的数据加载器和数据集类,因为它们实现得非常高效。
import torch
print("PyTorch version:", torch.__version__)
实现一个高效的数据加载器,该加载器遍历输入数据集并将输入和目标作为 PyTorch 张量返回,这些张量可以视为多维数组。
GPTDatasetV1 类继承自 PyTorch Dataset 类,定义了如何从数据集中提取单行,其中每行由多个token ID(基于 max_length)组成,并赋值给 input_chunk 张量
from torch.utils.data import Dataset, DataLoader
classGPTDatasetV1(Dataset):
def__init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []
# 将整个文本进行分词
token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})
assert len(token_ids) > max_length, "Number of tokenized inputs must at least be equal to max_length+1"
# Use a sliding window to chunk the book into overlapping sequences of max_length
# 使用滑动窗口将书籍分块为最大长度的重叠序列。
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]
以下代码将使用刚创建的 GPTDatasetV1 类,通过 PyTorch DataLoader 以批量方式加载输入
def create_dataloader_v1(txt, batch_size=4, max_length=256,
stride=128, shuffle=True, drop_last=True,
num_workers=0):
# Initialize the tokenizer
tokenizer = tiktoken.get_encoding("gpt2")
# Create dataset 创建GPTDatasetV1类
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
# Create dataloader
dataloader = DataLoader(
dataset,
batch_size=batch_size,
shuffle=shuffle,
drop_last=drop_last,
num_workers=num_workers
)
return dataloader
我们设置 batch_size = 1 和 max_length = 4,观察代码 GPTDatasetV1 类和 create_dataloader_v1 函数如何协同工作:
with open("the-verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
dataloader = create_dataloader_v1(
raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
data_iter = iter(dataloader)
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 相对较小,仅用于演示目的。通常,训练 LLM 的输入大小至少为 256。 为了阐明 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)。步幅设置决定了输入在批次之间移动的位置数,模拟了滑动窗口的方法,如下图所示。

构建词嵌入
为 LLM 准备训练集的最后一步是将token ID 转换为嵌入向量。从令牌 ID 创建这些令牌嵌入 - 本质上是获取一个 ID、一个整数值,并将其转换为嵌入向量。

通过一个实际示例来说明token ID 到嵌入向量转换的工作原理。假设我们有以下四个输入token,它们的 ID 分别为 2、3、5 和 1,创建一个张量作为输入
input_ids = torch.tensor([2, 3, 5, 1])
下一步就是创建一个所谓的嵌入层。为了简化并起到说明的目的,假设我们有一个只有 6 个单词的小词汇表(而不是 BPE 分词器中的 50,257 个单词),并且我们希望创建大小为 3 的嵌入向量(在 GPT-3 中,嵌入大小为 12,288 维),并将随机种子设置为 123。
vocab_size = 6
output_dim = 3
torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)

可以看到,嵌入层的权重矩阵由比较小的随机值组成。这些值将在LLM训练过程中作为LLM优化的一部分被优化 。此外,权重矩阵有六行三列。嵌入矩阵的每一行代表词汇表中的一个token(每个token都有一个唯一的向量表示),而每一列代表嵌入空间中的一个维度(在这个例子中,嵌入维度为3,即每个token被表示为一个3维向量)。
print(embedding_layer(input_ids))
输出是一个4x3 的矩阵:

输出矩阵中的每一行都是通过从嵌入权重矩阵进行查找操作获得的

位置编码(添加位置信息 )
我们将token ID 转换为连续的向量表示,即所谓的token嵌入。原则上,这适合作为 LLM 的输入。然而,LLM的一个小缺点是它们的自注意力机制对序列中token的位置或顺序没有概念。
之前引入的嵌入层的工作方式是,无论token ID 在输入序列中的位置如何,相同的token ID 始终映射到相同的向量表示。

上图中文本 “fox jumps over fox”,其中 fox 的 token index 为 2,在权重矩阵中为第三行。但是最后一个单词 fox 也具有相同的 token Id,和相同的权重矩阵。
从原则上讲,确定性的、与位置无关的token ID 嵌入对于可重复性是有益的。然而,由于LLM的自注意力机制本身也是与位置无关的,因此向LLM注入额外的位置信息是有帮助的。
绝对位置嵌入与序列中的特定位置直接相关。对于输入序列中的每个位置,都会将一个唯一的绝对位置嵌入向量添加到token的嵌入向量中,以传达其确切位置。例如,第一个token将具有特定的位置嵌入,第二个token将具有另一个不同的嵌入,依此类推。

与关注token在序列中的绝对位置不同,相对位置嵌入强调的是token之间的相对位置或距离。这意味着模型学习的是“相隔多远”的关系,而不是“在什么确切位置”。这样的优势在于,即使模型在训练时没有接触过不同的长度,它也可以更好地适应各种长度的序列。
这两种类型的位置嵌入旨在增强 LLM 理解token顺序与关系的能力,从而确保在预测时能对上下文具有更准确的感知。选择哪种类型的位置嵌入通常取决于特定的应用和所处理数据的性质。
之前,我们以非常小的嵌入大小以便于说明。我们现在考虑更现实和有用的嵌入大小,并将输入token编码为256维的向量表示。这比原始的GPT-3模型使用的要小(在GPT-3中,嵌入大小为12,288维),但对于实验仍然是合理的。此外,我们假设token ID 是由我们之前实现的BPE分词器创建的,该分词器的词汇量为50,257:
ocab_size = 50257
output_dim = 256
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
使用上面的 token_embedding_layer,如果我们从数据加载器中采样数据,我们将每个批次中的每个token嵌入到一个 256 维的向量中。如果我们的批次大小为 8,每个批次有四个token,那么结果将是一个形状为 8 x 4 x 256 的张量。
max_length = 4
dataloader = create_dataloader_v1(
raw_text, batch_size=8, max_length=max_length, stride=max_length, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)
输出如下:

我们可以看到,tokenID张量是8x4维的,这意味着数据批次由8个文本样本组成,每个样本有4个token。
现在,让我们使用嵌入层将这些token ID 转换为 256 维的向量:
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
从 8x4x256 维的张量输出中,我们可以看到,每个token ID 现在被嵌入为一个 256 维的向量。
对于 GPT 模型所使用的绝对嵌入方法,我们只需创建另一个嵌入层,其维度与 token_embedding_layer 的维度相同:
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
如前面的代码所示, pos_embeddings 的输入通常是一个占位符向量torch.arange(context_length),它包含一个从0到最大输入长度-1的数字序列。context_length 是一个表示LLM支持的输入大小的变量。在这里,我们设置它与输入文本的最大长度相同。在实际应用中,输入文本可能会超过支持的上下文长度,此时我们需要对文本进行截断。
torch.Size([4, 256])
正如我们所见,位置嵌入张量由四个 256 维向量组成。我们现在可以将这些直接添加到token嵌入中,在每个批次中,PyTorch 会将 4x256 维的 pos_embeddings 张量添加到每个 4x256 维的token嵌入张量中:
pos_embeddings = pos_embedding_layer(torch.arange(max_length))
print(pos_embeddings.shape)
# torch.Size([8, 4, 256])
我们创建的 input_embeddings,现在可作为LLM的核心模块的输入嵌入。如下图展示:

最后
为什么要学AI大模型
当下,⼈⼯智能市场迎来了爆发期,并逐渐进⼊以⼈⼯通⽤智能(AGI)为主导的新时代。企业纷纷官宣“ AI+ ”战略,为新兴技术⼈才创造丰富的就业机会,⼈才缺⼝将达 400 万!
DeepSeek问世以来,生成式AI和大模型技术爆发式增长,让很多岗位重新成了炙手可热的新星,岗位薪资远超很多后端岗位,在程序员中稳居前列。

与此同时AI与各行各业深度融合,飞速发展,成为炙手可热的新风口,企业非常需要了解AI、懂AI、会用AI的员工,纷纷开出高薪招聘AI大模型相关岗位。
最近很多程序员朋友都已经学习或者准备学习 AI 大模型,后台也经常会有小伙伴咨询学习路线和学习资料,我特别拜托北京清华大学学士和美国加州理工学院博士学位的鲁为民老师给大家这里给大家准备了一份涵盖了AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频 全系列的学习资料,这些学习资料不仅深入浅出,而且非常实用,让大家系统而高效地掌握AI大模型的各个知识点。
这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】
AI大模型系统学习路线
在面对AI大模型开发领域的复杂与深入,精准学习显得尤为重要。一份系统的技术路线图,不仅能够帮助开发者清晰地了解从入门到精通所需掌握的知识点,还能提供一条高效、有序的学习路径。

但知道是一回事,做又是另一回事,初学者最常遇到的问题主要是理论知识缺乏、资源和工具的限制、模型理解和调试的复杂性,在这基础上,找到高质量的学习资源,不浪费时间、不走弯路,又是重中之重。
AI大模型入门到实战的视频教程+项目包
看视频学习是一种高效、直观、灵活且富有吸引力的学习方式,可以更直观地展示过程,能有效提升学习兴趣和理解力,是现在获取知识的重要途径

光学理论是没用的,要学会跟着一起敲,要动手实操,才能将自己的所学运用到实际当中去,这时候可以搞点实战案例来学习。
海量AI大模型必读的经典书籍(PDF)
阅读AI大模型经典书籍可以帮助读者提高技术水平,开拓视野,掌握核心技术,提高解决问题的能力,同时也可以借鉴他人的经验。对于想要深入学习AI大模型开发的读者来说,阅读经典书籍是非常有必要的。
600+AI大模型报告(实时更新)
这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。
AI大模型面试真题+答案解析
我们学习AI大模型必然是想找到高薪的工作,下面这些面试题都是总结当前最新、最热、最高频的面试题,并且每道题都有详细的答案,面试前刷完这套面试题资料,小小offer,不在话下

这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】

更多推荐


所有评论(0)