从旧分词器训练新分词器
如果您感兴趣的语言没有可用的语言模型,或者您的语料库与语言模型训练所用的语料库差异很大,您很可能希望使用适应您数据的分词器从头开始重新训练模型。这将需要在您的数据集上训练一个新的分词器。但究竟这意味着什么?当我们第一次在第 2 章中查看分词器时,我们看到大多数 Transformer 模型使用子词分词算法。为了识别哪些子词是感兴趣的并且在当前语料库中最常出现,分词器需要仔细查看语料库中的所有文本——我们称之为训练的过程。控制此训练的确切规则取决于所使用的分词器类型,我们将在本章后面介绍三种主要的算法。
⚠️ 训练分词器与训练模型不同!模型训练使用随机梯度下降来使每个批次的损失稍微减小一点。它本质上是随机的(这意味着您必须设置一些种子才能在两次执行相同的训练时获得相同的结果)。训练分词器是一个统计过程,它试图识别哪些子词最适合给定的语料库,并且用于选择它们的精确规则取决于分词算法。它是确定性的,这意味着当使用相同的算法在相同的语料库上进行训练时,您总是会得到相同的结果。
构建语料库
🤗 Transformers 中有一个非常简单的 API,您可以使用它来训练一个与现有分词器具有相同特征的新分词器:AutoTokenizer.train_new_from_iterator()
。为了了解其工作原理,假设我们想从头开始训练 GPT-2,但使用除英语以外的语言。我们的首要任务是收集该语言的大量数据以构建训练语料库。为了提供每个人都能理解的示例,我们这里不使用俄语或中文等语言,而是使用一种专门的英语:Python 代码。
🤗 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 中,而是将数据集的元素存储在磁盘上的这一事实。
执行以下操作将创建每个包含 1000 个文本的文本列表的列表,但会将所有内容都加载到内存中
# 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
循环步骤时),并且一次只会加载 1000 个文本。这样,即使您正在处理大型数据集,也不会耗尽所有内存。
生成器对象的问题在于它只能使用一次。因此,与其这样给我们两次提供前 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 库为许多方法提供了 Python 绑定,这些方法在内部调用 Rust 中的一些代码;例如,并行化新分词器的训练或,正如我们在 第 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()
方法时实际发生的情况。