00316 NLP Course - Building a tokenizer, block by block


前言

正如我们在前面的部分所看到的,分词包括几个步骤:

  1. 标准化(对文本进行必要的清理,例如删除空格或重音、Unicode 标准化等)
  2. 预分词(将输入分解为单词)
  3. 通过模型运行输入(使用预分词的单词生成一系列标记)
  4. 后处理(添加分词器的特殊标记,生成注意力掩码和标记类型 ID)

提醒一下,这里是整个过程的另一个视角:

🤗Tokenizers库已经为每个步骤提供了几个选项,您可以将它们混合和匹配在一起。在本节中,我们将看到如何从头开始构建tokenizer,而不是像第2节那样从旧tokenizer训练新tokenizer。然后,您将能够构建您能想到的任何类型的tokenizer!

更准确地说,该库是围绕一个中央Tokenizer类构建的,其构建块在子模块中重新组合:

  • normalizers包含您可以使用的所有可能类型的Normalizer(此处为完整列表)。
  • pre_tokenizers包含您可以使用的所有可能类型的PreTokenizer(完整列表在这里)。
  • models包含您可以使用的各种类型的Model,例如BPE、WordPiece和Unigram(此处为完整列表)。
  • trainers包含所有不同类型的Trainer,您可以使用它们在语料库上训练您的模型(每种类型的模型一个;完整列表在这里)。
  • post_processors包含您可以使用的各种类型的PostProcessor(完整列表在这里)。
  • decoders包含可用于解码标记化输出的各种类型的Decoder(此处为完整列表)。

您可以在此处找到构建块的完整列表。

src link: https://huggingface.co/learn/nlp-course/chapter6/8

Operating System: Ubuntu 22.04.4 LTS

参考文档

  1. NLP Course - Building a tokenizer, block by block

获取语料库

为了训练我们的新tokenizer,我们将使用一个小的文本语料库(因此示例运行速度很快)。获取语料库的步骤与我们在本章开头所采取的步骤相似,但这次我们将使用WikiText-2数据集:

from datasets import load_dataset

dataset = load_dataset("wikitext", name="wikitext-2-raw-v1", split="train")


def get_training_corpus():
    for i in range(0, len(dataset), 1000):
        yield dataset[i : i + 1000]["text"]

函数get_training_corpus()是一个生成器,它会生成1000个文本批次,我们将使用这些文本来训练分词器。

🤗 Tokenizers也可以直接在文本文件上进行训练。下面是如何生成一个包含来自WikiText-2的所有文本/输入的文本文件,以便在本地使用:

with open("wikitext-2.txt", "w", encoding="utf-8") as f:
    for i in range(len(dataset)):
        f.write(dataset[i]["text"] + "\n")

接下来我们将向您展示如何一步步构建自己的BERT、GPT-2和XLNet令牌化器。这将为我们提供三种主要令牌化算法的示例:WordPiece、BPE和Unigram。让我们从BERT开始吧!

从零开始构建 WordPiece 分词器

要使用🤗 Tokenizers 库构建一个分词器,我们首先要实例化一个 Tokenizer 对象并为其指定一个模型,然后将其 normalizer、pre_tokenizer、post_processor 和 decoder 属性设置为我们想要的值。

在这个例子中,我们将创建一个使用 WordPiece 模型的分词器:

from tokenizers import (
    decoders,
    models,
    normalizers,
    pre_tokenizers,
    processors,
    trainers,
    Tokenizer,
)

tokenizer = Tokenizer(models.WordPiece(unk_token="[UNK]"))

我们必须指定unk_token,以便模型知道在遇到未见过的字符时应该返回什么。在这里我们可以设置的其他参数包括模型的词汇表(我们要训练模型,所以不需要设置这个)和max_input_chars_per_word,它指定了每个单词的最大长度(超过传入值的单词将被拆分)。

令牌化的第一步是规范化,所以让我们从那里开始。由于BERT被广泛使用,有一个BertNormalizer,里面有我们可以为BERT设置的经典选项:小写和strip_accents,这些都是不言自明的;clean_text用于删除所有控制字符,并用一个重复的空格替换;以及handle_chinese_chars,它在中文字符周围添加空格。要复制bert-base-uncased令牌化器,我们只需要设置这个规范化器:

tokenizer.normalizer = normalizers.BertNormalizer(lowercase=True)

然而,在构建新的分词器时,您可能无法使用🤗 Tokenizers库中已经实现的这种方便的规范化器——所以让我们来看看如何手动创建BERT规范化器。该库提供了一个小写规范化器和一个去除重音规范化器,您可以使用一个序列来组合多个规范化器:

tokenizer.normalizer = normalizers.Sequence(
    [normalizers.NFD(), normalizers.Lowercase(), normalizers.StripAccents()]
)

我们还使用了 NFD Unicode 规范化器,否则 StripAccents 规范化器就无法正确识别带重音的字符,因此也就无法将它们去除。

正如我们之前看到的,我们可以使用归一化器的 normalize_str() 方法来检查它对给定文本的影响:

print(tokenizer.normalizer.normalize_str("Héllò hôw are ü?"))
hello how are u?

如果你在一个包含 Unicode 字符 u”\u0085”的字符串上测试之前的两个规范化器,你肯定会注意到这两个规范化器并不完全等效。为了不让规范化器序列过于复杂,我们没有包含 BertNormalizer 在 clean_text 参数设置为 True 时所需的正则表达式替换——这是默认行为。但不用担心:如果你在规范化器序列中添加两个 normalizers.Replace,就可以获得完全相同的规范化效果,而无需使用方便的 BertNormalizer。

接下来是预分词步骤。同样,我们可以使用预先构建的BertPreTokenizer:

tokenizer.pre_tokenizer = pre_tokenizers.BertPreTokenizer()

或者我们可以从零开始建造它:

tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()

请注意,Whitespace预分词器会根据空格以及所有不是字母、数字或下划线字符的字符进行分词,所以从技术上讲,它是根据空格和标点符号进行分词的。

tokenizer.pre_tokenizer.pre_tokenize_str("Let's test my pre-tokenizer.")
[('Let', (0, 3)), ("'", (3, 4)), ('s', (4, 5)), ('test', (6, 10)), ('my', (11, 13)), ('pre', (14, 17)),
 ('-', (17, 18)), ('tokenizer', (18, 27)), ('.', (27, 28))]

如果你只想根据空格分词,应该使用 WhitespaceSplit 预分词器:

pre_tokenizer = pre_tokenizers.WhitespaceSplit()
pre_tokenizer.pre_tokenize_str("Let's test my pre-tokenizer.")
[("Let's", (0, 5)), ('test', (6, 10)), ('my', (11, 13)), ('pre-tokenizer.', (14, 28))]

就像使用规范化器一样,你可以使用一个序列来组合多个预分词器:

pre_tokenizer = pre_tokenizers.Sequence(
    [pre_tokenizers.WhitespaceSplit(), pre_tokenizers.Punctuation()]
)
pre_tokenizer.pre_tokenize_str("Let's test my pre-tokenizer.")
[('Let', (0, 3)), ("'", (3, 4)), ('s', (4, 5)), ('test', (6, 10)), ('my', (11, 13)), ('pre', (14, 17)),
 ('-', (17, 18)), ('tokenizer', (18, 27)), ('.', (27, 28))]

令牌化管道的下一步是将输入通过模型运行。我们已经在初始化中指定了我们的模型,但我们仍然需要训练它,这需要一个WordPieceTrainer。在🤗 Tokenizers中实例化一个训练器时,需要记住的主要事情是,你需要传递所有你打算使用的特殊令牌——否则它不会将它们添加到词汇表中,因为它们不在训练语料库中:

special_tokens = ["[UNK]", "[PAD]", "[CLS]", "[SEP]", "[MASK]"]
trainer = trainers.WordPieceTrainer(vocab_size=25000, special_tokens=special_tokens)

除了指定vocab_size和special_tokens外,我们还可以设置min_frequency(一个词必须出现的次数才能被纳入词汇)或更改continuing_subword_prefix(如果我们想使用##以外的其他内容)。

要使用我们之前定义的迭代器来训练我们的模型,我们只需要执行这个命令:

tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)

我们也可以使用文本文件来训练我们的分词器,具体操作如下(我们事先用一个空的 WordPiece 重新初始化模型):

tokenizer.model = models.WordPiece(unk_token="[UNK]")
tokenizer.train(["wikitext-2.txt"], trainer=trainer)

在这两种情况下,我们都可以通过调用 encode() 方法来测试分词器对文本的处理:

encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['let', "'", 's', 'test', 'this', 'tok', '##eni', '##zer', '.']

获得的编码是一个 Encoding,它在其各种属性中包含了令牌化器的所有必要输出:ids、type_ids、tokens、offsets、attention_mask、special_tokens_mask 和 overflowing。

令牌化流程的最后一步是后处理。我们需要在开头添加[CLS]令牌,在结尾添加[SEP]令牌(或者如果我们有一对句子的话,就在每句话之后添加)。我们将使用一个模板处理器来完成这项工作,但首先我们需要知道词典中[CLS]和[SEP]令牌的ID:

cls_token_id = tokenizer.token_to_id("[CLS]")
sep_token_id = tokenizer.token_to_id("[SEP]")
print(cls_token_id, sep_token_id)
(2, 3)

为了为模板处理器编写模板,我们必须指定如何处理单个句子和一对句子。对于这两种情况,我们都要写下我们想要使用的特殊标记;第一个(或单个)句子用 $A 表示,而第二个句子(如果编码一对)用 $B 表示。对于这些(特殊标记和句子),我们还要在冒号后指定相应的标记类型 ID。

因此,经典的BERT模板定义如下:

tokenizer.post_processor = processors.TemplateProcessing(
    single=f"[CLS]:0 $A:0 [SEP]:0",
    pair=f"[CLS]:0 $A:0 [SEP]:0 $B:1 [SEP]:1",
    special_tokens=[("[CLS]", cls_token_id), ("[SEP]", sep_token_id)],
)

请注意,我们需要传递特殊标记的 ID,以便标记器可以正确地将它们转换为它们的 ID。

一旦添加了这个,回到我们之前的例子就会得到:

encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['[CLS]', 'let', "'", 's', 'test', 'this', 'tok', '##eni', '##zer', '.', '[SEP]']

在一对句子中,我们得到了正确的结果:

encoding = tokenizer.encode("Let's test this tokenizer...", "on a pair of sentences.")
print(encoding.tokens)
print(encoding.type_ids)
['[CLS]', 'let', "'", 's', 'test', 'this', 'tok', '##eni', '##zer', '...', '[SEP]', 'on', 'a', 'pair', 'of', 'sentences', '.', '[SEP]']
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]

我们几乎已经从零开始完成了这个分词器的构建——最后一步是加入一个解码器:

tokenizer.decoder = decoders.WordPiece(prefix="##")

让我们在之前的编码上测试一下:

tokenizer.decode(encoding.ids)
"let's test this tokenizer... on a pair of sentences."

太棒了!我们可以这样将我们的分词器保存到一个单独的 JSON 文件中:

tokenizer.save("tokenizer.json")

然后我们可以使用 from_file() 方法将该文件重新加载到一个 Tokenizer 对象中:

new_tokenizer = Tokenizer.from_file("tokenizer.json")

在🤗 Transformers 中使用这个令牌化器,我们必须将它封装在一个 PreTrainedTokenizerFast 中。我们可以使用通用类,或者,如果我们的令牌化器对应于现有的模型,就可以使用那个类(在这里是 BertTokenizerFast)。如果你应用这个教程来构建一个全新的令牌化器,你就必须使用第一个选项。

为了将令牌化器封装在PreTrainedTokenizerFast中,我们可以将自己构建的令牌化器作为tokenizer_object传入,或者将保存的令牌化器文件作为tokenizer_file传入。需要记住的关键是,我们必须手动设置所有特殊令牌,因为该类无法从令牌化器对象中推断出哪个令牌是掩码令牌、[CLS]令牌等。

from transformers import PreTrainedTokenizerFast

wrapped_tokenizer = PreTrainedTokenizerFast(
    tokenizer_object=tokenizer,
    # tokenizer_file="tokenizer.json", # You can load from the tokenizer file, alternatively
    unk_token="[UNK]",
    pad_token="[PAD]",
    cls_token="[CLS]",
    sep_token="[SEP]",
    mask_token="[MASK]",
)

如果你使用的是特定的令牌化器类(如BertTokenizerFast),你只需要指定与默认令牌不同的特殊令牌(这里没有):

from transformers import BertTokenizerFast

wrapped_tokenizer = BertTokenizerFast(tokenizer_object=tokenizer)

然后你可以像使用其他Transformers令牌器一样使用这个令牌器。你可以使用save_pretrained()方法保存它,或者使用push_to_hub()方法将它上传到Hub。

现在我们已经了解了如何构建一个 WordPiece 分词器,让我们来做一个 BPE 分词器。我们会加快一些速度,因为你已经知道所有步骤了,只需要强调一下不同之处。

从零开始构建 BPE 分词器

现在让我们构建一个GPT-2令牌化器。和BERT令牌化器一样,我们首先用一个BPE模型初始化一个令牌化器:

tokenizer = Tokenizer(models.BPE())

和BERT一样,如果我们有词典的话,可以用词典来初始化这个模型(在这种情况下,我们需要传入词典和合并信息),但由于我们将从零开始训练,所以不需要这样做。我们也不需要指定unk_token,因为GPT-2使用的是字节级BPE,不需要它。

GPT-2不使用归一化器,所以我们跳过那个步骤,直接进行预分词:

tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

我们在这里为 ByteLevel 添加的选项是不在句子开头添加空格(否则这是默认设置)。我们可以像之前一样查看一个示例文本的预分词结果:

tokenizer.pre_tokenizer.pre_tokenize_str("Let's test pre-tokenization!")
[('Let', (0, 3)), ("'s", (3, 5)), ('Ġtest', (5, 10)), ('Ġpre', (10, 14)), ('-', (14, 15)),
 ('tokenization', (15, 27)), ('!', (27, 28))]

接下来是模型,它需要进行训练。对于GPT-2,唯一的特殊标记是文本结尾标记:

trainer = trainers.BpeTrainer(vocab_size=25000, special_tokens=["<|endoftext|>"])
tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)

就像使用WordPieceTrainer一样,我们可以指定vocab_size和special_tokens,还可以指定min_frequency,或者如果有单词结尾后缀(如),我们可以使用end_of_word_suffix来设置它。

这个标记器也可以使用文本文件进行训练:

tokenizer.model = models.BPE()
tokenizer.train(["wikitext-2.txt"], trainer=trainer)

让我们来看看一个样本文本的标记化过程:

encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['L', 'et', "'", 's', 'Ġtest', 'Ġthis', 'Ġto', 'ken', 'izer', '.']

我们对GPT-2令牌化器应用了以下的字节级后处理:

tokenizer.post_processor = processors.ByteLevel(trim_offsets=False)

Trim_offsets = False 选项告诉后处理器,我们应该保留以 ‘Ġ’ 开头的标记的偏移量:这样,偏移量的起始位置将指向单词前的空格,而不是单词的第一个字符(因为空格在技术上是标记的一部分)。让我们来看看我们刚刚编码的文本的结果,其中 ‘Ġtest’ 是索引为 4 的标记:

sentence = "Let's test this tokenizer."
encoding = tokenizer.encode(sentence)
start, end = encoding.offsets[4]
sentence[start:end]
' test'

最后,我们添加了一个字节级解码器:

tokenizer.decoder = decoders.ByteLevel()

我们可以再次检查一下是否正常工作:

tokenizer.decode(encoding.ids)
"Let's test this tokenizer."

太好了!现在我们完成了,我们可以像之前一样保存标记器,如果想在🤗 Transformers中使用它,可以将其封装在PreTrainedTokenizerFast或GPT2TokenizerFast中。

from transformers import PreTrainedTokenizerFast

wrapped_tokenizer = PreTrainedTokenizerFast(
    tokenizer_object=tokenizer,
    bos_token="<|endoftext|>",
    eos_token="<|endoftext|>",
)

或者:

from transformers import GPT2TokenizerFast

wrapped_tokenizer = GPT2TokenizerFast(tokenizer_object=tokenizer)

作为最后一个例子,我们将向你展示如何从零开始构建一个 Unigram 分词器。

从零开始构建 Unigram 分词器

现在让我们构建一个 XLNet 分词器。和之前的分词器一样,我们首先用 Unigram 模型初始化一个 Tokenizer:

tokenizer = Tokenizer(models.Unigram())

再次,如果我们有词汇表的话,我们可以用它来初始化这个模型。

对于规范化,XLNet使用了一些替换(这些来自SentencePiece):

from tokenizers import Regex

tokenizer.normalizer = normalizers.Sequence(
    [
        normalizers.Replace("``", '"'),
        normalizers.Replace("''", '"'),
        normalizers.NFKD(),
        normalizers.StripAccents(),
        normalizers.Replace(Regex(" {2,}"), " "),
    ]
)

这会将“ 和 ”替换为”,并将任意两个或多个连续空格替换为单个空格,同时去除待分词文本中的重音符号。

用于任何SentencePiece分词器的预分词器是Metaspace:

tokenizer.pre_tokenizer = pre_tokenizers.Metaspace()

我们可以像之前那样看看一个示例文本的预分词过程:

tokenizer.pre_tokenizer.pre_tokenize_str("Let's test the pre-tokenizer!")
[("▁Let's", (0, 5)), ('▁test', (5, 10)), ('▁the', (10, 14)), ('▁pre-tokenizer!', (14, 29))]

接下来是模型,它需要进行训练。XLNet有相当多的特殊标记:

special_tokens = ["<cls>", "<sep>", "<unk>", "<pad>", "<mask>", "<s>", "</s>"]
trainer = trainers.UnigramTrainer(
    vocab_size=25000, special_tokens=special_tokens, unk_token="<unk>"
)
tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)

对于UnigramTrainer来说,一个非常重要的参数是unk_token。我们还可以传递其他特定于Unigram算法的参数,例如每次移除令牌时使用的shrinking_factor(默认值为0.75)或指定给定令牌最大长度的max_piece_length(默认值为16)。

这个标记器也可以在文本文件上进行训练:

tokenizer.model = models.Unigram()
tokenizer.train(["wikitext-2.txt"], trainer=trainer)

让我们来看看一个样本文本的分词过程:

encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['▁Let', "'", 's', '▁test', '▁this', '▁to', 'ken', 'izer', '.']

XLNet的一个特点是将标记放在句子的末尾,其类型ID为2(以便将其与其他标记区分开来)。因此,它是在左侧进行填充。我们可以像处理BERT一样,用一个模板来处理所有特殊标记和标记类型ID,但首先我们需要获取标记的ID:

cls_token_id = tokenizer.token_to_id("<cls>")
sep_token_id = tokenizer.token_to_id("<sep>")
print(cls_token_id, sep_token_id)
0 1

模板看起来是这样的:

tokenizer.post_processor = processors.TemplateProcessing(
    single="$A:0 <sep>:0 <cls>:2",
    pair="$A:0 <sep>:0 $B:1 <sep>:1 <cls>:2",
    special_tokens=[("<sep>", sep_token_id), ("<cls>", cls_token_id)],
)

我们可以通过编码一对句子来测试它是否有效:

encoding = tokenizer.encode("Let's test this tokenizer...", "on a pair of sentences!")
print(encoding.tokens)
print(encoding.type_ids)
['▁Let', "'", 's', '▁test', '▁this', '▁to', 'ken', 'izer', '.', '.', '.', '<sep>', '▁', 'on', '▁', 'a', '▁pair', 
  '▁of', '▁sentence', 's', '!', '<sep>', '<cls>']
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2]

最后,我们添加了一个Metaspace解码器:

tokenizer.decoder = decoders.Metaspace()

我们已经完成了这个分词器!我们可以像之前一样保存这个分词器,如果我们想在🤗 Transformers 中使用它,可以将它封装在 PreTrainedTokenizerFast 或 XLNetTokenizerFast 中。使用 PreTrainedTokenizerFast 时需要注意的一点是,除了特殊的标记外,我们还需要告诉🤗 Transformers 库在左边进行填充:

from transformers import PreTrainedTokenizerFast

wrapped_tokenizer = PreTrainedTokenizerFast(
    tokenizer_object=tokenizer,
    bos_token="<s>",
    eos_token="</s>",
    unk_token="<unk>",
    pad_token="<pad>",
    cls_token="<cls>",
    sep_token="<sep>",
    mask_token="<mask>",
    padding_side="left",
)

或替代:

from transformers import XLNetTokenizerFast

wrapped_tokenizer = XLNetTokenizerFast(tokenizer_object=tokenizer)

现在你已经了解了如何使用各种构建块来构建现有的令牌化器,你应该能够使用🤗 Tokenizers库编写任何你想要的令牌化器,并在🤗 Transformers中使用它。

结语

第三百一十六篇博文写完,开心!!!!

今天,也是充满希望的一天。


文章作者: LuYF-Lemon-love
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 LuYF-Lemon-love !
  目录