0-Tokenizer#
Tokenizer(分词器)在 NLP 领域扮演着基础且关键的作用,它将文本分割成单词或子词并转化为数组编号,为模型提供可处理的输入,在文本预处理、语义理解及适配不同语言和任务等方面奠定基础,是连接自然语言文本与计算机可处理数据的重要桥梁.
# !pip install -r ../raw/data/Minimind/requirements.txt
# conda create -n minimind python=3.10
# conda activate minimind
import sys
print(sys.executable)
/Users/ascotbe/anaconda3/envs/minimind/bin/python
子词分词算法#
常见的子词分词算法有三种:
字节对编码(Byte Pair Encoding,BPE)
WordPiece
Unigram
BPE#
BPE 是一种简单的数据压缩技术,它会迭代地替换序列中最频繁出现的字节对。BPE 依赖一个预分词器,该预分词器会将训练数据分割成单词(在本项目中,我们使用按空格分词的方法作为预分词方法).
在预分词之后,会创建一组唯一的单词,并确定它们在数据中的出现频率。接下来,BPE 会创建一个基础词表,该词表包含预分词器最初生成的数据中所有唯一单词的符号。然后,会将这对符号从词表中移除,新形成的符号将加入词表。在迭代过程中,BPE 算法会合并频繁出现的符号对.
给定词表的大小,BPE(字节对编码)算法最终会合并出现频率最高的符号对,直到收敛到该大小.
WordPiece#
WordPiece 算法与 BPE 非常相似。WordPiece 首先将词表初始化为包含训练数据中出现的每个字符,然后逐步学习给定数量的合并规则. 与 BPE 不同的是,WordPiece 并不选择最频繁出现的符号对,而是选择那个加入词表后能使训练数据出现的可能性最大化的符号对.
Unigram#
Unigram 算法将其基础词表初始化为大量的符号,然后逐步削减每个符号,以获得一个更小的词表。它会在训练数据上定义一个对数似然损失,以此来确定是否从词表中移除某个符号.
训练一个最简单的分词器#
在本节中,我们将学习基于 transformers 库来训练你自己的分词器.
初始化#
首先,我们应该初始化我们的分词器,并确定选择哪种方法。我们将使用字节对编码(BPE)算法.
# 导入 tokenizers 库中所有必要的模块
# 这样做是为了能够组装和配置我们自己的分词器。
# tokenizers 库的设计是模块化的,允许我们像搭积木一样,将不同的组件(如模型、归一化器、预分词器等)组合在一起。
from tokenizers import (
decoders, # 解码器:负责将 token ID 序列转换回文本
models, # 模型:定义了分词的核心算法,如 BPE, WordPiece 等
normalizers, # 归一化器:在分词前对文本进行清理,如转小写、去除重音符号等
pre_tokenizers, # 预分词器:在核心算法运行前,将文本分割成初步的“单词”
processors, # 后处理器:在分词后添加特殊标记,如 [CLS], [SEP]
trainers, # 训练器:负责从语料库中训练分词器模型
Tokenizer, # Tokenizer 类:这是我们将所有组件组合在一起的核心对象
)
# 初始化一个 Tokenizer 对象,并指定其核心模型为 BPE(字节对编码)
# 原理:BPE 是一种子词(subword)分词算法。它首先将文本拆分为单个字符,然后迭代地合并最常出现的相邻字符对,
# 直到达到预设的词汇表大小。这样做的好处是既能处理已知词,也能通过组合子词来表示未登录词(OOV),从而有效控制词汇表大小。
tokenizer = Tokenizer(models.BPE())
# 设置分词器的预分词器为 ByteLevel
# 为什么:在 BPE 算法运行之前,需要先将文本切分成一个初始的单元序列。
# 原理:ByteLevel 预分词器将文本视为原始的字节流。这样做极其稳健,因为它能处理任何 UTF-8 字符,
# 永远不会遇到“未知字符”的问题,因为所有可能的字节(0-255)都在其处理范围内。
# add_prefix_space=False 是一个配置选项,表示不在单词前添加前导空格,这在某些模型中是默认行为。
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
定义特殊标记#
数据集中存在一些我们不希望被分词的特殊标记,我们会将这些标记定义为特殊标记,并将它们传递给分词器训练器,以防止出现错误的分词情况.
# 定义特殊标记列表
# 为什么:在自然语言处理任务中,一些标记有特殊含义,例如 <unk> 代表未知词,<s> 代表句子开始,</s> 代表句子结束。
# 我们不希望分词器将它们拆分成更小的部分(比如把 "<s>" 拆成 "<"、"s"、">"),所以将它们声明为特殊标记。
special_tokens = ["<unk>", "<s>", "</s>"]
# 初始化一个 BPE 训练器对象
# 为什么:这个对象封装了训练分词器所需的所有配置参数。我们将这个训练器对象传递给训练函数,
# 它会根据这些配置来指导 BPE 模型的学习过程。
trainer = trainers.BpeTrainer(
# 设置最终的词汇表大小。这里设置为 256 是一个非常小的尺寸,仅用于这个玩具示例。
# 实际应用中,这个值通常在 30000 到 50000 之间。
vocab_size=256,
# 告诉训练器我们预定义的特殊标记,确保它们被正确地添加到词汇表中并获得固定的 ID。
special_tokens=special_tokens,
# 在训练过程中显示进度条,方便我们了解训练进度。
show_progress=True,
# 设置 BPE 算法的初始字符集。
# 原理:因为我们使用了 ByteLevel 预分词器,所以初始的“字母表”就是所有可能的字节(0-255)。
# 这个设置确保了训练器和预分词器在底层处理单元上保持一致。
initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
)
从文件中读取数据#
在本次实验中,我们使用 JSON Lines(jsonl)格式来存储 Tokenizer 训练数据,分词器内置的训练函数要求训练数据以迭代器的形式传入,因此,我们首先获取一个数据读取的生成器.
# 导入 json 库,用于处理 JSON Lines 文件
import json
# 定义一个函数,用于从 JSON Lines (.jsonl) 文件中读取文本数据
# 为什么:分词器的训练函数需要一个可以迭代的文本源。这个函数创建了一个生成器(generator)。
# 原理:使用生成器(yield关键字)是一种内存高效的方式来处理大文件。它不会一次性将整个文件读入内存,
# 而是每次只读取和处理一行,当训练函数需要下一批数据时,它才会从文件中读取下一行。
# 这对于训练大型分词器至关重要,因为训练语料可能非常大,无法全部装入内存。
def read_texts_from_jsonl(file_path):
# 使用 'with' 语句打开文件,可以确保文件在使用后被自动关闭
with open(file_path, 'r', encoding='utf-8') as f:
# 遍历文件的每一行
for line in f:
# 解析当前行的 JSON 数据
data = json.loads(line)
# 使用 yield 返回 'text' 字段的内容。函数会在此暂停,直到下一次被调用。
yield data['text']
# 定义训练数据文件的路径
data_path = '../raw/data/Minimind/toydata/tokenizer_data.jsonl'
# 调用函数创建一个数据迭代器(生成器对象)
# 为什么:这是为了准备好输入给分词器训练函数的数据源。
data_iter = read_texts_from_jsonl(data_path)
# 使用 next() 函数从迭代器中获取第一条数据并打印
# 为什么:这是一个很好的实践,用于验证我们的数据读取函数是否按预期工作,
# 确保在开始耗时的训练之前,数据加载是正确的。
print(f'Row 1: {next(data_iter)}')
Row 1: <s>近年来,人工智能技术迅速发展,深刻改变了各行各业的面貌。机器学习、自然语言处理、计算机视觉等领域的突破性进展,使得智能产品和服务越来越普及。从智能家居到自动驾驶,再到智能医疗,AI的应用场景正在快速拓展。随着技术的不断进步,未来的人工智能将更加智能、更加贴近人类生活。</s>
开始训练!#
我们使用分词器的内置函数 tokenizer.train_from_iterator
来训练分词器.
# 从迭代器开始训练分词器
# 为什么:这是执行分词器训练的核心步骤。
# 原理:`train_from_iterator` 方法会:
# 1. 接收文本数据迭代器 `data_iter` 和训练配置 `trainer`。
# 2. 遍历所有文本数据,使用预分词器(这里是 ByteLevel)将其分割成初始单元。
# 3. 统计所有相邻单元对的出现频率。
# 4. 根据 BPE 算法,重复地将最频繁的单元对合并成一个新的单元(token),并将其加入词汇表。
# 5. 这个过程会一直持续,直到词汇表大小达到 `trainer` 中设定的 `vocab_size`。
tokenizer.train_from_iterator(data_iter, trainer=trainer)
设置解码器#
# 设置分词器的解码器
# 为什么:分词(tokenize)是将文本转换为 ID 的过程,而解码(decode)是反向过程。我们需要告诉分词器如何将 ID 序列转换回文本。
# 原理:解码器必须与预分词器相匹配。因为我们使用了 `ByteLevel` 预分词器来处理字节,
# 所以我们也必须使用 `ByteLevel` 解码器来正确地将这些代表字节的 token 重新组合成可读的 UTF-8 字符串。
tokenizer.decoder = decoders.ByteLevel()
接下来,检查一下特殊标记是否得到了妥善处理。
# 使用 assert 语句检查特殊标记的 ID
# 为什么:这是一种自动化的测试,用于确保特殊标记在训练后被赋予了我们预期的 ID。
# 通常,特殊标记会被分配到词汇表的开头,ID 从 0 开始。
# 原理:`assert` 会检查其后的条件是否为 `True`。如果为 `False`,程序会抛出 `AssertionError` 并停止。
# 这比手动检查更可靠,是保证代码质量和正确性的好方法。
assert tokenizer.token_to_id('<unk>') == 0
assert tokenizer.token_to_id('<s>') == 1
assert tokenizer.token_to_id('</s>') == 2
将训练好的分词器保存到磁盘#
import os
# 定义保存分词器的目录路径
tokenizer_dir = "../raw/data/Minimind/model/toy_tokenizer"
# 创建目录,如果目录已存在则不报错
# 为什么:在保存文件之前,必须确保目标目录存在。`exist_ok=True` 参数可以避免在目录已存在时程序因错误而中断。
os.makedirs(tokenizer_dir, exist_ok=True)
# 将完整的 Tokenizer 对象保存为单个 tokenizer.json 文件
# 为什么:这是 `tokenizers` 库推荐的现代化保存方式。
# 原理:`tokenizer.json` 文件包含了分词器的所有信息:模型(BPE)、词汇表、合并规则、归一化器、预分词器、解码器等。
# 这是一个自包含的文件,便于分享和加载。
tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
# 将分词器的模型部分(词汇表和合并规则)单独保存
# 为什么:这是为了与 `transformers` 库的旧版格式兼容。
# 原理:`transformers` 库以前通常从 `vocab.json`(词汇表映射)和 `merges.txt`(BPE 合并规则)
# 这两个文件中加载分词器。虽然现在 `tokenizer.json` 是首选,但保存这些文件可以确保更好的向后兼容性。
tokenizer.model.save(tokenizer_dir)
['../raw/Minimind/model/toy_tokenizer/vocab.json',
'../raw/Minimind/model/toy_tokenizer/merges.txt']
手动创建一份配置文件#
# 手动创建一个配置字典,用于生成 tokenizer_config.json
# 为什么:`transformers` 库使用 `tokenizer_config.json` 文件来存储关于分词器的高级配置和元数据,
# 例如特殊标记的用途(哪个是 pad_token,哪个是 bos_token 等)、最大长度以及如何加载这个分词器(tokenizer_class)。
# `tokenizers` 库本身不会生成这个文件,所以我们需要手动创建它,以便能通过 `AutoTokenizer.from_pretrained` 正确加载。
config = {
"add_bos_token": False,
"add_eos_token": False,
"add_prefix_space": False,
"added_tokens_decoder": {
"0": {
"content": "<unk>",
"lstrip": False,
"normalized": False,
"rstrip": False,
"single_word": False,
"special": True
},
"1": {
"content": "<s>",
"lstrip": False,
"normalized": False,
"rstrip": False,
"single_word": False,
"special": True
},
"2": {
"content": "</s>",
"lstrip": False,
"normalized": False,
"rstrip": False,
"single_word": False,
"special": True
}
},
"additional_special_tokens": [],
"bos_token": "<s>",
"clean_up_tokenization_spaces": False,
"eos_token": "</s>",
"legacy": True,
"model_max_length": 32768,
"pad_token": "<unk>",
"sp_model_kwargs": {},
"spaces_between_special_tokens": False,
"tokenizer_class": "PreTrainedTokenizerFast",
"unk_token": "<unk>",
"chat_template": "{{ '<s>' + messages[0]['text'] + '</s>' }}"
}
# 将配置字典写入 tokenizer_config.json 文件
# 为什么:这样,当我们使用 `AutoTokenizer.from_pretrained("./model/toy_tokenizer")` 时,
# `transformers` 库就能找到并读取这个配置文件,从而正确地加载和配置我们的分词器。
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w", encoding="utf-8") as config_file:
# 使用 json.dump 将字典写入文件,indent=4 使其格式化,美观易读
json.dump(config, config_file, ensure_ascii=False, indent=4)
print("Tokenizer training completed and saved.")
Tokenizer training completed and saved.
现在我们已经训练了一个简单的分词器,并将其进行保存,接下来,我们试着加载它,并使用其帮助我们对文本进行编解码.
# 从 transformers 库导入 AutoTokenizer
# 为什么:AutoTokenizer 是一个非常方便的工厂类。
# 原理:它可以自动识别分词器的类型(通过读取 tokenizer_config.json),并加载正确的 `Tokenizer` 类
# (例如 `BertTokenizerFast`, `GPT2TokenizerFast`, `LlamaTokenizerFast` 等)。
from transformers import AutoTokenizer
# 从我们刚刚保存的目录中加载分词器
# 为什么:这是使用我们训练好的分词器的标准方式。
tokenizer = AutoTokenizer.from_pretrained("../raw/data/Minimind/model/toy_tokenizer")
# 定义一个符合聊天格式的消息
msg = [{"text": "失去的东西就要学着去接受,学着放下。"}]
# 应用我们在 tokenizer_config.json 中定义的聊天模板
# 为什么:这展示了如何使用聊天模板来格式化输入。这对于对话式 AI 模型至关重要,
# 因为它们需要特定格式的输入来区分用户和助手的轮次,以及添加特殊标记。
# tokenize=False 表示只进行字符串格式化,不立即进行分词。
new_msg = tokenizer.apply_chat_template(
msg,
tokenize=False
)
print(f'原始文本:{msg}')
# 注意:这里的打印输出有误,第二个 `msg` 应该是 `new_msg` 才能看到模板应用后的效果。
# 正确的应该是 `print(f'修改文本:{new_msg} (添加自定义聊天模板)')`
print(f'修改文本:{new_msg} (添加自定义聊天模板)')
原始文本:[{'text': '失去的东西就要学着去接受,学着放下。'}]
修改文本:<s>失去的东西就要学着去接受,学着放下。</s> (添加自定义聊天模板)
# 打印分词器的词汇表大小
# 为什么:检查词汇表大小可以帮助我们验证分词器是否按预期训练。
# 原理:`.vocab_size` 属性返回词汇表中的 token 总数,
# 这应该约等于我们在 `BpeTrainer` 中设置的 `vocab_size` 加上一些初始字符和特殊标记。
print(f'分词器词表大小:{tokenizer.vocab_size}')
分词器词表大小:259
# 使用分词器对格式化后的文本进行编码(分词)
# 为什么:这是分词器的核心功能,将文本字符串转换为模型可以理解的数字 ID 序列。
# 原理:调用 `tokenizer()` 实例会执行一个完整的流水线:
# 1. 归一化(Normalization)
# 2. 预分词(Pre-tokenization)
# 3. 模型分词(Tokenization Model, e.g., BPE)
# 4. 后处理(Post-processing, e.g., adding special tokens)
# 最终返回一个字典,包含 `input_ids`(Token ID)、`token_type_ids`(用于区分句子对)和 `attention_mask`(用于指示哪些是真实 Token,哪些是填充 Token)。
model_inputs = tokenizer(new_msg)
print(f'查看分词结果:\\n{model_inputs}')
查看分词结果:\n{'input_ids': [1, 164, 100, 112, 164, 239, 122, 166, 251, 229, 163, 119, 253, 167, 101, 126, 164, 111, 112, 167, 102, 226, 164, 258, 102, 166, 254, 225, 164, 239, 122, 165, 239, 101, 164, 240, 248, 174, 123, 237, 164, 258, 102, 166, 254, 225, 165, 245, 125, 163, 119, 236, 162, 225, 227, 2], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
# 将分词结果(input_ids)解码回文本字符串
# 为什么:解码是编码的逆过程,用于将模型的数字输出转换回人类可读的文本。
# skip_special_tokens=False:这个参数告诉解码器不要跳过(移除)像 <s>, </s> 这样的特殊标记。
# 这在调试时非常有用,可以完整地看到模型生成的序列。
response = tokenizer.decode(model_inputs['input_ids'], skip_special_tokens=False)
print(f'对分词结果进行解码:{response} (保留特殊字符)' )
对分词结果进行解码:<s>失去的东西就要学着去接受,学着放下。</s> (保留特殊字符)
# 再次解码,但这次移除特殊标记
# 为什么:在向最终用户展示结果时,我们通常不希望他们看到内部使用的特殊标记。
# skip_special_tokens=True:这个参数告诉解码器在生成最终字符串时,移除所有特殊标记,
# 从而得到一个更干净、更自然的人类可读文本。
response = tokenizer.decode(model_inputs['input_ids'], skip_special_tokens=True)
print(f'对分词结果进行解码:{response} (移除特殊字符)' )
对分词结果进行解码:失去的东西就要学着去接受,学着放下。 (移除特殊字符)