从旧的标记器训练新的标记器
如果语言模型在您感兴趣的语言中不可用,或者您的语料库与语言模型训练所用的语料库有很大差异,您很可能需要从头开始重新训练模型,使用适应您的数据的标记器。这将需要在您的数据集上训练新的标记器。但这究竟意味着什么呢?当我们在 第 2 章 中首次考察标记器时,我们看到大多数 Transformer 模型使用 *子词标记化算法*。为了识别哪些子词是有趣的,并且在当前语料库中出现频率最高,标记器需要仔细查看语料库中的所有文本——这个过程我们称之为 *训练*。控制这种训练的确切规则取决于所使用的标记器类型,我们将在本章后面介绍三种主要算法。
⚠️ 训练标记器与训练模型不同!模型训练使用随机梯度下降来使每个批次的损失略微减小。它本质上是随机的(这意味着您必须设置一些种子才能在两次执行相同训练时获得相同的结果)。训练标记器是一个统计过程,它试图识别哪些子词最适合给定的语料库,用于选择它们的精确规则取决于标记化算法。它是确定性的,这意味着在使用相同算法对相同语料库进行训练时,您始终会获得相同的结果。
构建语料库
🤗 Transformers 中有一个非常简单的 API 可用于使用与现有标记器相同的特征训练新的标记器:AutoTokenizer.train_new_from_iterator()
。为了演示其工作原理,假设我们想要从头开始训练 GPT-2,但使用英语以外的语言。我们的首要任务是收集该语言的训练语料库。为了提供每个人都能理解的示例,我们这里不会使用俄语或汉语等语言,而是使用一种专业化的英语:Python 代码。
The 🤗 Datasets 库可以帮助我们构建 Python 源代码的语料库。我们将使用常用的 load_dataset()
函数下载并缓存 CodeSearchNet 数据集。该数据集是为 CodeSearchNet 挑战 创建的,包含来自 GitHub 上开源库的数百万个函数,涵盖多种编程语言。在这里,我们将加载该数据集的 Python 部分
from datasets import load_dataset
# This can take a few minutes to load, so grab a coffee or tea while you wait!
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
})
我们可以看到数据集将文档字符串与代码分开,并建议对两者进行标记化。在这里,我们只使用 whole_func_string
列来训练我们的标记器。我们可以通过索引到 train
集中查看其中一个函数的示例
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)
我们需要做的第一件事是将数据集转换为文本列表的 *迭代器*——例如,文本列表的列表。使用文本列表将使我们的标记器能够更快地运行(在文本批次上训练,而不是逐个处理文本),如果我们想要避免将所有内容都加载到内存中,它应该是一个迭代器。如果您的语料库非常庞大,您将想要利用 🤗 Datasets 不会将所有内容都加载到 RAM 中,而是将数据集的元素存储在磁盘上的这一事实。
执行以下操作将创建每个包含 1,000 个文本的文本列表,但会将所有内容都加载到内存中
# Don't uncomment the following line unless your dataset is small!
# 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
循环步骤时),并且一次只加载 1,000 个文本。这样,即使您正在处理庞大的数据集,也不会耗尽所有内存。
生成器对象的问题是它只能使用一次。因此,与这会给我们两次返回前 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()
您还可以使用 yield
语句在 for
循环内定义生成器
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"]
这将生成与之前完全相同的生成器,但允许您使用比列表推导更复杂的逻辑。
训练新的标记器
现在我们已经以文本批次的迭代器形式获得了语料库,我们就可以训练新的标记器了。为此,我们首先需要加载我们想要与模型配对的标记器(这里为 GPT-2)
from transformers import AutoTokenizer
old_tokenizer = AutoTokenizer.from_pretrained("gpt2")
尽管我们要训练一个新的分词器,但这样做是一个好主意,可以避免从头开始。这样,我们就不需要指定任何关于分词算法或我们要使用的特殊标记的信息;我们的新分词器将与 GPT-2 完全相同,唯一改变的是词汇表,它将由我们语料库上的训练决定。
首先,让我们看看这个分词器将如何处理一个示例函数。
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']
这个分词器有一些特殊符号,比如 Ġ
和 Ċ
,分别表示空格和换行符。正如我们所看到的,这效率不高:分词器会为每个空格返回单个标记,而它可以将缩进级别组合在一起(因为在代码中会有四到八个空格的集合非常常见)。它还以一种奇怪的方式分割了函数名,不习惯看到带有 _
字符的单词。
让我们训练一个新的分词器,看看它是否解决了这些问题。为此,我们将使用 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()
仅在您使用的分词器是“快速”分词器时才有效。正如您将在下一节中看到的那样,🤗 Transformers 库包含两种类型的分词器:一些完全用 Python 编写,而另一些(快速的)则由 🤗 Tokenizers 库支持,该库是用 Rust 编程语言编写的。Python 是数据科学和深度学习应用程序最常用的语言,但当任何东西都需要并行化才能快速执行时,它必须用另一种语言编写。例如,模型计算的核心矩阵乘法是用 CUDA 编写的,CUDA 是针对 GPU 优化的 C 库。
用纯 Python 训练一个全新的分词器会非常慢,这就是我们开发 🤗 Tokenizers 库的原因。请注意,就像您不需要学习 CUDA 语言就可以在 GPU 上对一批输入执行模型一样,您也不需要学习 Rust 才能使用快速分词器。 🤗 Tokenizers 库为许多内部调用 Rust 代码片段的方法提供了 Python 绑定;例如,并行化新分词器的训练或,正如我们在 第 3 章 中看到的那样,对一批输入进行分词。
大多数 Transformer 模型都提供了一个快速分词器(有一些例外,您可以 在这里 查看),AutoTokenizer
API 始终为您选择快速分词器(如果可用)。在下一节中,我们将看看快速分词器的一些其他特殊功能,这些功能对于标记分类和问答等任务非常有用。但是,在深入了解这些功能之前,让我们尝试使用我们全新的分词器来处理之前的示例。
tokens = tokenizer.tokenize(example) tokens
['def', 'Ġadd', '_', 'numbers', '(', 'a', ',', 'Ġb', '):', 'ĊĠĠĠ', 'Ġ"""', 'Add', 'Ġthe', 'Ġtwo', 'Ġnumbers', 'Ġ`',
'a', '`', 'Ġand', 'Ġ`', 'b', '`."""', 'ĊĠĠĠ', 'Ġreturn', 'Ġa', 'Ġ+', 'Ġb']
这里我们再次看到了表示空格和换行符的特殊符号 Ġ
和 Ċ
,但我们也可以看到我们的分词器学习了一些非常特定于 Python 函数语料库的标记:例如,有一个 ĊĠĠĠ
标记表示缩进,一个 Ġ"""
标记表示开始文档字符串的三引号。分词器还正确地将函数名拆分为 _
。这是一个相当紧凑的表示;相比之下,对同一个示例使用纯英文分词器将得到一个更长的句子。
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', 'ĊĠĠĠĠ']
除了对应于缩进的标记之外,这里我们还可以看到一个表示双重缩进的标记:ĊĠĠĠĠĠĠĠ
。诸如 class
、init
、call
、self
和 return
之类的特殊 Python 词语每个都被标记为一个标记,我们可以看到,除了在 _
和 .
上分割之外,分词器还正确地分割了驼峰命名法:LinearLayer
被标记为 ["ĠLinear", "Layer"]
。
保存分词器
为了确保我们以后可以使用它,我们需要保存我们新的分词器。与模型一样,这可以通过 save_pretrained()
方法完成
tokenizer.save_pretrained("code-search-net-tokenizer")
这将创建一个名为 code-search-net-tokenizer 的新文件夹,其中将包含分词器需要重新加载的所有文件。如果您想与同事和朋友共享此分词器,您可以通过登录您的帐户将其上传到 Hub。如果您在笔记本中工作,有一个方便的函数可以帮助您完成此操作
from huggingface_hub import notebook_login
notebook_login()
这将显示一个窗口,您可以在其中输入您的 Hugging Face 登录凭据。如果您不在笔记本中工作,只需在您的终端中键入以下行
huggingface-cli login
登录后,您可以通过执行以下命令推送您的分词器
tokenizer.push_to_hub("code-search-net-tokenizer")
这将在您的命名空间中创建一个名为 code-search-net-tokenizer
的新存储库,其中包含分词器文件。然后,您可以使用 from_pretrained()
方法从任何地方加载分词器
# Replace "huggingface-course" below with your actual namespace to use your own tokenizer
tokenizer = AutoTokenizer.from_pretrained("huggingface-course/code-search-net-tokenizer")
您现在已准备好从头开始训练语言模型,并根据您的任务对其进行微调!我们将在 第 7 章 中介绍这一点,但首先,在本章的其余部分,我们将仔细研究快速分词器,并详细探讨调用 train_new_from_iterator()
方法时实际发生的事情。