如果你对感兴趣的语言没有预训练的语言模型,或者你的语料库与模型训练的语料库差异很大,你可能需要从头开始重新训练模型,并使用适应你的数据的分词器。这需要在你的数据集上训练一个新的分词器。但这具体意味着什么呢?在第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
# 这可能需要几分钟时间加载,所以你可以趁此时间喝杯咖啡或茶!
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
列来训练我们的分词器。我们可以查看一个函数的示例,通过索引训练集:
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不会一次性加载所有内容到内存,而是将数据集元素存储在磁盘上的特性。
执行以下操作会创建一个包含1000个文本的嵌套列表,但会将所有内容加载到内存中:
# 如果您的数据集较小,请勿取消注释以下行!
# training_corpus = [raw_datasets["train"][i: i + 1000]["whole_func_string"] for i in range(0, len(raw_datasets["train"]), 1000)]
使用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()
您还可以在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"]
这将产生与之前相同的生成器,但允许您使用列表推导式中无法实现的更复杂的逻辑。
训练新的分词器
现在,我们已经将语料库转换为文本批次的迭代器,我们准备好训练新的分词器。首先,我们需要加载要与模型配对的分词器(这里使用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 处理器的计算机上,只需要 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', 'ĊĠĠĠĠ']
除了表示缩进的令牌外,我们还可以看到表示两个缩进的令牌:ĊĠĠĠĠĠĠĠ
。特殊Python词汇,如class
、init
、call
、self
和return
,都被分词为一个令牌,我们可以看到,分词器不仅会在下划线和点号处分割,还能正确地处理驼峰式命名: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
的新存储库,包含tokenizer文件。然后,你可以使用from_pretrained()
方法从任何地方加载tokenizer:
# 将 "huggingface-course" 替换为你的实际命名空间,以使用你自己的tokenizer
tokenizer = AutoTokenizer.from_pretrained("huggingface-course/code-search-net-tokenizer")
现在,你已经准备好从头开始训练语言模型,并针对你的任务进行微调!我们将在第7章(https://huggingface.co/course/chapter7)中深入探讨这一点。但在本章的剩余部分,我们将更深入地了解快速tokenizer,并详细研究当我们调用train_new_from_iterator()
方法时会发生什么。