基于已有的 tokenizer 训练新的 tokenizer
如果你感兴趣的语言中没有可用的语言模型,或者你的语料库与语言模型训练时所使用的语料库差异很大,你可能需要从零开始重新训练一个适应你的数据的 tokenizer 模型。训练一个新的 tokenizer 是什么意思呢?从我们在 第二章 中第一次看到 tokenizer 开始,我们看到大多数 Transformer 模型使用 子词分词算法
。为了找到语料库中的常见子词,tokenizer 需要深入统计语料库中的所有文本——这个过程我们称之为 训练 (training)
。具体的训练规则取决于使用的 tokenizer 类型,我们将在本章后面的部分详细介绍三种主要算法。
⚠️ 训练 tokenizer 与训练模型不同!模型训练使用随机梯度下降使每个 batch 的 loss 小一点。它本质上是随机的(这意味着在即使两次训练的参数和算法完全相同,你也必须设置一些随机数种子才能获得相同的结果)。训练 tokenizer 是一个统计过程,它试图确定哪些子词最适合为给定的语料库选择,确定的过程取决于分词算法。它是确定性的,这意味着在相同的语料库上使用相同的算法进行训练时,得到的结果总是相同的。
准备语料库
在🤗 Transformers 中,有一个非常简单的 API 可以让你从旧的 tokenizer 训练一个新的 tokenizer 且新的 tokenizer 具有和旧 tokenizer 相同的特性,它就是: AutoTokenizer.train_new_from_iterator()
。为了演示这个功能,我们将尝试从零开始训练 GPT-2 模型,但是在非英语的语言上。我们首先需要做的就是在训练语料库中收集大量的目标语言数据。为了让每个人都能理解,我们不会使用俄语或汉语这样的语言,而是使用一种特殊的英语语言:Python 代码。
🤗 Datasets 库可以帮助我们下载一个 Python 源代码语料库。我们将使用 load_dataset()
功能下载和缓存 CodeSearchNet 数据集。该数据集是为 CodeSearchNet 挑战 而创建的,其中包含了 GitHub 上开源库中的数百万个函数,涵盖了多种编程语言。在这里,我们将加载这个数据集的 Python 部分:
from datasets import load_dataset
# 加载这个可能需要几分钟的时间,你可以趁此喝杯咖啡或茶!
raw_datasets = load_dataset("code_search_net", "python")
我们可以查看训练集部分来看我们可以使用哪些列:
raw_datasets["train"]
Dataset({
features: ['repository_name', 'func_path_in_repository', 'func_name', 'whole_func_string', 'language',
'func_code_string', 'func_code_tokens', 'func_documentation_string', 'func_documentation_tokens', 'split_name',
'func_code_url'
],
num_rows: 412178
})
我们可以看到数据集把函数的文档说明(func_documentation_string)与代码(func_code_string)分开保存,并提供了一个可以参考的分词后的结果(func_code_tokens)。在这里,我们仅使用 whole_func_string
列来训练我们的 tokenizer 我们可以通过索引来查看其中一个函数的示例:
print(raw_datasets["train"][123456]["whole_func_string"])
应该输出以下内容:
def handle_simple_responses(
self, timeout_ms=None, info_cb=DEFAULT_MESSAGE_CALLBACK):
"""Accepts normal responses from the device.
Args:
timeout_ms: Timeout in milliseconds to wait for each response.
info_cb: Optional callback for text sent from the bootloader.
Returns:
OKAY packet's message.
"""
return self._accept_responses('OKAY', info_cb, timeout_ms=timeout_ms)
我们首先需要做的是将数据集转换为一个文本列表的 迭代器
例如,文本列表的列表。使用文本列表会使我们的 tokenizer 运行得更快(这样可以以文本批次为单位进行训练,而不是一次处理一个文本),并且使用迭代器可以不把所有内容都加载到内存中。如果你的语料库很大,你可能会想利用🤗 Datasets 将数据集的元素存储在磁盘上分批加载,而不是将所有内容加载到 RAM 的特性。
下面的操作会创建一个由每个列包含 1000 个文本组成的文本列表,但会将所有内容加载到内存中:
# 除非你的数据集很小,否则不要直接运行下面的代码!
# training_corpus = [
# raw_datasets["train"][i: i + 1000]["whole_func_string"]
# for i in range(0, len(raw_datasets["train"]), 1000)
# ]
通过使用 Python 生成器,我们可以使 Python 只将正在使用的数据加载到内存中。要创建这样一个生成器,你只需要将方括号替换为圆括号:
training_corpus = (
raw_datasets["train"][i : i + 1000]["whole_func_string"]
for i in range(0, len(raw_datasets["train"]), 1000)
)
这行代码不会加载数据集的任何元素;它只创建了一个你可以在 Python for 循环中使用的对象。只有当你使用它们(即,当你在 for
循环尝试访问他们)时,文本才会被加载,而且一次只会加载 1000 个文本。这样,即使你在处理大型数据集,也不会耗尽所有内存。
生成器对象的问题是它只能被使用一次。让我们尝试获取 2 次 10 个数字组成的列表:
gen = (i for i in range(10))
print(list(gen))
print(list(gen))
我们第一次得到了这个列表,第二次我们得到了一个空列表:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[]
这就是为什么我们需要定义一个返回生成器的函数。通过每次调用函数生成一个新的生成器对象,我们可以多次使用生成器而不会遇到只能使用一次的限制。
def get_training_corpus():
return (
raw_datasets["train"][i : i + 1000]["whole_func_string"]
for i in range(0, len(raw_datasets["train"]), 1000)
)
training_corpus = get_training_corpus()
你还可以在一个 for
循环内部使用 yield
关键字定义你的生成器:
def get_training_corpus():
dataset = raw_datasets["train"]
for start_idx in range(0, len(dataset), 1000):
samples = dataset[start_idx : start_idx + 1000]
yield samples["whole_func_string"]
这将得到和上面列表生成器完全相同的生成器,但允许你在迭代器的过程中添加更复杂的逻辑。
训练一个新的 tokenizer
现在我们已经将文本转化为迭代器形式准备好了我们的语料库,我们就可以开始训练新的 tokenizer 了。首先,我们需要加载我们想要与我们的模型匹配的 tokenizer (这我们这个例子中是 GPT-2):
from transformers import AutoTokenizer
old_tokenizer = AutoTokenizer.from_pretrained("gpt2")
尽管我们要训练一个新的 tokenizer,但从旧的 tokenizer 开始初始化依然是个不错的主意,这样,我们就不必指定具体的 tokenization 算法或设置我们想要使用的特殊 tokens;我们新的 tokenizer 将与 GPT-2 完全相同,唯一的区别是词汇表,这将由我们的语料库通过训练来重新确定。
首先让我们看看旧的 tokenizer 将如何处理示例的数据:
example = '''def add_numbers(a, b):
"""Add the two numbers `a` and `b`."""
return a + b'''
tokens = old_tokenizer.tokenize(example)
tokens
['def', 'Ġadd', '_', 'n', 'umbers', '(', 'a', ',', 'Ġb', '):', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġ"""', 'Add', 'Ġthe', 'Ġtwo',
'Ġnumbers', 'Ġ`', 'a', '`', 'Ġand', 'Ġ`', 'b', '`', '."', '""', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġreturn', 'Ġa', 'Ġ+', 'Ġb']
这个 tokenizer 输出了一些特殊的符号,比如 Ċ
和 Ġ
,分别表示空格和换行符。正如我们所看到的,这并不是非常高效:tokenizer 将每个空格视作为单独的 token,其实它可以将缩进级别组合在一起时(因为在代码中经常出现相邻在一起的四个或八个空格)。它也有点奇怪地拆分了函数名称,对使用 _
命名方法的函数并不友好。
让我们训练一个新的 tokenizer 看看它是否能解决这些问题。为此,我们将使用 train_new_from_iterator()
方法:
tokenizer = old_tokenizer.train_new_from_iterator(training_corpus, 52000)
如果你的语料库非常大,此命令可能需要一些时间,但对于这个 1.6 GB 文本数据集,它的速度非常快(在具有 12 个内核的 AMD Ryzen 9 3900X CPU 上仅需 1 分 16 秒)。
注意 AutoTokenizer.train_new_from_iterator()
只有你使用的 tokenizer 是“快速(fast)” tokenizer 时才有效。下一节中,你将在看到,🤗 Transformers 库包含两种类型的 tokenizer 一些(慢速的)完全用 Python 编写,而另一些(快速的)由 🤗 Tokenizers 库支持,该库用 Rust 编程语言编写。Python 是最常用于数据科学和深度学习应用程序的语言,但是当需要并行化以提高速度时,就需要用另一种语言来编写。例如,模型计算核心的矩阵乘法是用 CUDA 编写的,这是一个针对 GPU 优化的 C 语言库。
用纯 Python 训练一个全新的 tokenizer 会非常缓慢,这就是我们开发 🤗 Tokenizers 库的原因。正如你无需学习 CUDA 语言即可在 GPU 上训练你的模型一样,你也无需学习 Rust 即可使用快速 tokenizer。🤗 Tokenizers 库为许多内部调用 Rust 代码的方法提供 Python 语言绑定;例如,并行化训练新的 tokenizer 或者像我们在 第三章 中看到的那样,对一批输入进行 tokenize。
大多数 Transformer 模型都有可用的快速 tokenizer (你可以 在这里 检查一些例外情况),如果 AutoTokenizer
可用,API 默认为你选择快速 tokenizer 在下一节中,我们将看看快速 tokenizer 具有的其他一些特殊功能,这些功能对于 token 分类和问答等任务非常有用。然而,在深入研究之前,让我们尝试在之前的例子上使用我们的全新 tokenizer
tokens = tokenizer.tokenize(example) tokens
['def', 'Ġadd', '_', 'numbers', '(', 'a', ',', 'Ġb', '):', 'ĊĠĠĠ', 'Ġ"""', 'Add', 'Ġthe', 'Ġtwo', 'Ġnumbers', 'Ġ`',
'a', '`', 'Ġand', 'Ġ`', 'b', '`."""', 'ĊĠĠĠ', 'Ġreturn', 'Ġa', 'Ġ+', 'Ġb']
在这里我们再次看到了表示空格和换行符的特殊符号 Ċ
和 Ġ
,但我们也可以看到我们的 tokenizer 学习了一些专属于 Python 函数语料库的 token:例如,有一个 ĊĠĠĠ
token 表示缩进,以及 Ġ
token 表示开始文档字符串的三个引号。tokenizer 也正确地在 _
上拆分了函数名称。这是一个非常紧凑的表示;相比之下,使用简单的英语 tokenizer 会得到一个更长的句子:
print(len(tokens))
print(len(old_tokenizer.tokenize(example)))
27
36
让我们再看一个例子:
example = """class LinearLayer():
def __init__(self, input_size, output_size):
self.weight = torch.randn(input_size, output_size)
self.bias = torch.zeros(output_size)
def __call__(self, x):
return x @ self.weights + self.bias
"""
tokenizer.tokenize(example)
['class', 'ĠLinear', 'Layer', '():', 'ĊĠĠĠ', 'Ġdef', 'Ġ__', 'init', '__(', 'self', ',', 'Ġinput', '_', 'size', ',',
'Ġoutput', '_', 'size', '):', 'ĊĠĠĠĠĠĠĠ', 'Ġself', '.', 'weight', 'Ġ=', 'Ġtorch', '.', 'randn', '(', 'input', '_',
'size', ',', 'Ġoutput', '_', 'size', ')', 'ĊĠĠĠĠĠĠĠ', 'Ġself', '.', 'bias', 'Ġ=', 'Ġtorch', '.', 'zeros', '(',
'output', '_', 'size', ')', 'ĊĊĠĠĠ', 'Ġdef', 'Ġ__', 'call', '__(', 'self', ',', 'Ġx', '):', 'ĊĠĠĠĠĠĠĠ',
'Ġreturn', 'Ġx', 'Ġ@', 'Ġself', '.', 'weights', 'Ġ+', 'Ġself', '.', 'bias', 'ĊĠĠĠĠ']
除了与缩进对应的 token 外,这里我们还可以看到一个与双缩进对应的 token : ĊĠĠĠĠĠĠĠ
。特殊的 Python 关键词如 class
, init
, call
, self
,和 return
每个都被分配了一个 token ,我们还可以看到,除了可以在 _
和 .
上正确拆分,tokenizer 甚至可以正确拆分驼峰法命名的名称: LinearLayer
被分词为 [ĠLinear, Layer]
。
保存 tokenizer
为了确保我们以后可以使用它,我们需要保存我们的新 tokenizer 。像模型一样,是通过 save_pretrained()
方法进行保存:
tokenizer.save_pretrained("code-search-net-tokenizer")
这将创建一个名为的 code-search-net-tokenizer
的新文件夹,它将包含重新加载 tokenizer 所需要的所有文件。如果你想与你的同事和朋友分享这个 tokenizer 你可以通过登录你的帐户将其上传到 Hub。如果你在 notebook 上工作,有一个便捷的功能可以帮助你:
from huggingface_hub import notebook_login
notebook_login()
这将显示一个小部件,你可以在其中输入你的 Hugging Face 账号密码。如果你不是在 notebook 上工作,只需在终端中输入以下行:
huggingface-cli login
登录后,你可以通过执行以下命令来推送你的 tokenizer
tokenizer.push_to_hub("code-search-net-tokenizer")
这将在你的账户中创建一个名为 code-search-net-tokenizer
的新仓库,其中将包含 tokenizer 文件。然后,你可以使用 tokenizer 的 from_pretrained()
方法从任何地方加载 tokenizer 。
# 将下面的 "huggingface-course" 替换为你的用户名来加载你的 tokenizer
tokenizer = AutoTokenizer.from_pretrained("huggingface-course/code-search-net-tokenizer")
你现在已准备好从头开始训练语言模型并根据你手头的任务对其进行微调!我们将在 第七章 进行这部分。在本章的剩余部分,我们将仔细研究快速 tokenizer 并详细探讨调用 train_new_from_iterator()
方法时到底在幕后发生了什么。