LLM 课程文档

逐块构建分词器

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

逐块构建分词器

Ask a Question Open In Colab Open In Studio Lab

正如我们在前几节中看到的,分词包括几个步骤:

  • 规范化(对文本进行任何必要的清理,例如删除空格或重音符号,Unicode 规范化等)
  • 预分词(将输入文本分割成单词)
  • 通过模型运行输入(使用预分词的单词生成一系列词元)
  • 后处理(添加分词器的特殊词元,生成注意力掩码和词元类型 ID)

提醒一下,这是整体过程的另一个视图:

The tokenization pipeline.

🤗 Tokenizers 库旨在为这些步骤中的每个步骤提供多种选项,您可以将它们混合搭配使用。在本节中,我们将看到如何从头开始构建分词器,而不是像第 2 节中所做的那样从旧分词器训练新分词器。然后,您将能够构建您能想到的任何类型的分词器!

更准确地说,该库围绕一个核心 Tokenizer 类构建,其构建块在子模块中进行分组:

  • normalizers 包含所有可能类型的 Normalizer(完整列表在此)。
  • pre_tokenizers 包含所有可能类型的 PreTokenizer(完整列表在此)。
  • models 包含您可以使用的各种类型的 Model,例如 BPEWordPieceUnigram(完整列表在此)。
  • trainers 包含您可以用来在语料库上训练模型的各种类型的 Trainer(每种模型类型一个;完整列表在此)。
  • post_processors 包含您可以使用的各种类型的 PostProcessor(完整列表在此)。
  • decoders 包含您可以用来解码分词输出的各种类型的 Decoder(完整列表在此)。

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

获取语料库

为了训练我们的新分词器,我们将使用一小段文本语料库(这样示例运行速度快)。获取语料库的步骤与我们在本章开头采取的步骤类似,但这次我们将使用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() 是一个生成器,它将生成 1,000 个文本批次,我们将用它来训练分词器。

🤗 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 库构建分词器,我们首先实例化一个带有 modelTokenizer 对象,然后将其 normalizerpre_tokenizerpost_processordecoder 属性设置为我们想要的值。

在此示例中,我们将使用 WordPiece 模型创建一个 Tokenizer

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

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

我们必须指定 unk_token,以便模型知道在遇到以前从未见过的字符时返回什么。我们可以在此处设置的其他参数包括模型的 vocab(我们将训练模型,因此不需要设置此参数)和 max_input_chars_per_word,它指定每个单词的最大长度(长于此值的单词将被分割)。

分词的第一步是标准化,所以我们从它开始。由于 BERT 广泛使用,因此有一个 BertNormalizer,其中包含我们可以为 BERT 设置的经典选项:lowercasestrip_accents,这些是自解释的;clean_text 用于删除所有控制字符并将重复的空格替换为单个空格;以及 handle_chinese_chars,它在中文字符周围放置空格。要复制 bert-base-uncased 分词器,我们只需设置此标准化器:

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

然而,一般来说,在构建新的分词器时,您将无法访问 🤗 Tokenizers 库中已经实现的这种方便的标准化器——所以让我们看看如何手动创建 BERT 标准化器。该库提供了一个 Lowercase 标准化器和一个 StripAccents 标准化器,您可以使用 Sequence 组合多个标准化器:

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

我们还使用了 NFD Unicode 标准化器,否则 StripAccents 标准化器将无法正确识别带重音的字符,从而无法去除它们。

正如我们之前所见,我们可以使用 normalizernormalize_str() 方法来查看它对给定文本的影响:

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

更进一步 如果您在包含 unicode 字符 u"\u0085" 的字符串上测试之前两个规范化器的版本,您肯定会注意到这两个规范化器并不完全等效。为了不过度复杂化 normalizers.Sequence 版本,我们没有包含 BertNormalizerclean_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))]

与标准化器一样,您可以使用 Sequence 组合多个预分词器:

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_sizespecial_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 是一个 Encoding,它包含了分词器所有必要的输出在其各种属性中:idstype_idstokensoffsetsattention_maskspecial_tokens_maskoverflowing

分词管道的最后一步是后处理。我们需要在开头添加 [CLS] 标记,在结尾添加 [SEP] 标记(如果是一对句子,则在每个句子之后)。我们将为此使用 TemplateProcessor,但首先我们需要知道词汇表中 [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)

为了编写 TemplateProcessor 的模板,我们必须指定如何处理单个句子和一对句子。对于这两种情况,我们写入要使用的特殊标记;第一个(或单个)句子由 $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="##")

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

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 传入。关键是要记住,我们必须手动设置所有特殊标记,因为该类无法从 tokenizer 对象推断出哪个标记是掩码标记、[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 = Tokenizer(models.BPE())

同样,如果有一个词汇表,我们可以用它来初始化这个模型(在这种情况下,我们需要传递 vocabmerges),但由于我们将从头开始训练,所以我们不需要这样做。我们也不需要指定 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_sizespecial_tokens 之外,我们还可以根据需要指定 min_frequency,或者如果我们有词尾后缀(例如 </w>),我们可以使用 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 中使用它,可以将其包装在 PreTrainedTokenizerFastGPT2TokenizerFast 中:

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 的一个特点是它将 <cls> 标记放在句子的末尾,类型 ID 为 2(以区别于其他标记)。结果,它在左侧填充。我们可以使用模板处理所有特殊标记和标记类型 ID,就像 BERT 一样,但首先我们必须获取 <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)
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 中使用它,可以将其包装在 PreTrainedTokenizerFastXLNetTokenizerFast 中。在使用 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 中使用它。

< > 在 GitHub 上更新