训练一个 Tokenizer

2024/06/13 LLM 共 7068 字,约 21 分钟

1、背景

1.1、Tokenizer 的作用

1.2、Tokenizer 的种类

2、HuggingFace Tokenizer 的处理流程

Tokenizer 包括几个步骤:

  • 规范化(任何认为必要的文本清理,例如删除空格或重音符号、Unicode 规范化等)

  • 预标记化(将输入拆分为单词)

  • 通过模型处理输入(使用预先拆分的词来生成一系列标记)

  • 后处理(添加标记器的特殊标记,生成注意力掩码和标记类型 ID)

Tokenizer 的处理流程如图所示:

Tokenizer的处理流程示意图

图片来源:https://jinhanlei.github.io/posts/Transformers%E5%BF%AB%E9%80%9F%E5%85%A5%E9%97%A8-%E4%BA%8C-%E7%94%A8Tokenizer%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E8%AE%AD%E7%BB%83%E8%AF%8D%E8%A1%A8/

HuggingFace Tokenizer的处理流程示意图

图片来源:https://huggingface.co/learn/nlp-course/zh-CN/chapter6/4

我们通常把训练集叫做 “语料库”(Corpus),根据语料库得到一个词表,词表需要涵盖语料库中的所有词。下面详解这一词表的构建流程。

2.1、Normalizers(规范化)

2.2、Pre-tokenizers(预分词)

这一步进行预分词。根据切分粒度的不同,天然可以把英文单词拆成:字符、词,但这样有很大的弊端。

2.2.1、按字符切分(Character-based)

把文本切分为字符,这样就只会产生一个非常小的词表,比如英文就只有 26 个字母和标点等,很少会出现词表外的 tokens。例如对 Attention is all we need! 按字符切分为:

A|t|t|e|n|t|i|o|n|i|s|a|l|l|w|e|n|e|e|d|!

但这样显然不太合理,因为字符本身并没有太多上下文的规律。更重要的是,这样仅仅一个单词就需要一堆向量去表示,遇到长文本时就爆炸了。

划分的 token 太长,计算 attention 时消耗太多时间

2.2.2、按词切分(Word-based)

按词切分几乎是最直观的方法。

Attention|is|all|we|need|!

这种策略也同样存在问题。

  • 对于中文,因为字之间没有空格天然分开成词,分词本身就是一项挑战。

  • 对于英文,会将文本中所有出现过的独立片段都作为不同的 token,从而产生巨大的词表,而实际上词表中很多词是相关的,例如 “dog” 和 “dogs”、“run” 和 “running”,如果视作不同的词,就无法表示出这种关联性。

并且,这样会出现 OOV(Out of vocabulary)问题,在预测时有可能遇到语料库中从没出现过的词,分词器会使用一个专门的 [UNK] token 来表示,如果训练集不够大,词表里就那么几个词,那么用的时候,句子中会包含大量 [UNK],导致大量信息丧失。因此,一个好的分词策略,应该尽可能不出现 [UNK]

2.2.3、按子词切分(Subword-based)

现在,广泛采用的是一种同时结合了按词切分和按字符切分的方式——按子词切分 (Subword tokenization)。

BERT、GPT 都采用这种做法,高频词直接保留,低频词被切分为子词

Subword 算是一种对字符和词折中的办法。不仅子词之间有规律可循、单词不会切的过分长,而且只用一个较小的词表就可以覆盖绝大部分的文本,基本不会产生 [UNK]

在统计词频前,需要预先切割词,切完才能去统计,Pre-tokenizers 就是切割的作用。英文本身就可以根据空格分割,但是对于中日韩这样的连续字符,如果按单字切完,词表将会巨大,几乎需要 130,000+ 个 Unicode 字符,更别说还要继续组词了!


于是,GPT-2 等采用了 ByteLevel 的算法,将中日韩等字符映射到 256 个字符。具体可以参考 fairseqicefall。将 fairseq 里面的代码 copy 下来,加上:

enc = byte_encode("唱、跳、rap、篮球")
print(enc)
print(byte_decode(enc))
print(len(BYTE_TO_BCHAR))

唱、跳、rap、篮球 被编码为 åƔ±ãƀƁè·³ãƀƁrapãƀƁ篮çƐƃ,这啥?我也看不懂,但是找到 rap 在里面了吗?英文是不变的,每个中文字符会被映射成三个 å 这种玩意儿,而这种玩意儿加上空格、英文字母和标点等,一共只有 256 个,他们的排列组合可以构成几乎所有字符。

之后去统计这些玩意儿的共现频率就可,但考虑到可能会有一些无效的组合,比如中间少个 Ɣbyte_decode 就出不来了,于是引入基于动态规划的最佳解码方法 smart_byte_decode

wrong_byte = "å±ãƀƁè·³ãƀƁrapãƀƁ篮çƐƃ"
print(byte_decode(wrong_byte))
print(smart_byte_decode(wrong_byte))

将每个汉字用 空格分隔,就可以按英文那般分词。做完分词,就可以来统计词频了,训练一个词表出来了。

2.3、Tokenizer-Models(构建词表的统计模型)

Tokenizer 的 Models 是最核心的部分,指构建词表的统计模型,有三大子词标记化算法:BPE、WordPiece 和 Unigram。

在下面的部分中,我们将深入研究三种主要的子词标记化算法:BPE(由 GPT-2 和其他人使用)、WordPiece(例如由 BERT 使用)和 Unigram(由 T5 和其他人使用)。在我们开始之前,这里是它们各自工作原理的快速概述。如果您还没有理解,请在阅读下一节后立即回到此表。

ModelBPEWordPieceUnigram
Training从少量词汇开始,学习合并 token 的规则从少量词汇开始,学习合并 token 的规则从大量词汇开始,学习删除 token 的规则
Training step合并最常见的相邻 token 对根据对的频率合并与具有最佳分数的对相对应的令牌,使每个单独 token 的频率较低(频率低的做为单独的 token)删除词汇表中的所有 token,这些 token 将最大程度地减少在整个语料库上计算的损失
Learns合并规则和词汇表只是一个词汇包含每个 token 分数的词汇表
Encoding将单词拆分为多个字符(characters),并使用在训练期间学到的合并查找从词汇表中的开头开始的最长的子字(subword),然后对单词的其余部分执行相同的操作使用训练期间学到的分数找到最有可能被拆分的 token

2.3.1、Byte Pair Encoding(BPE)

参考:

  • HuggingFace:https://huggingface.co/learn/nlp-course/zh-CN/chapter6/5?fw=pt

字节对编码(BPE)最初被开发为一种压缩文本的算法,然后在预训练 GPT 模型时被 OpenAI 用于标记化。许多 Transformer 模型都使用它,包括 GPT、GPT-2、RoBERTa、BART 和 DeBERTa。

BPE 简单有效,是目前最流行的方法之一,GPT-2 和 RoBERTa 使用的 Subword 算法都是 BPE。BPE 的流程如下:

  • 根据分词结果统计词频,得到{词 </w>: 词频},词后加上末尾符是为了区分 “estimate” 和 “highest” 这类词;

  • 统计字符的个数(比如那 256 个玩意),得到 {“字符”: 字符频} 表;

  • 拿字符频最高的字符与下一字符合并,统计合并后的 Subword 频率;

  • 将 Subword 频添加到{“字符”: 字符频}表,这时词表会扩大;

  • 继续拿表中频率最高的去合并,到末尾符时停止这个词的合并;

  • 重复直到预设的词表大小或最高频数为 1。

更详尽的推导可以参考 这篇

2.3.2、WordPiece

参考:

  • HuggingFace:https://huggingface.co/learn/nlp-course/zh-CN/chapter6/6?fw=pt

WordPiece 是 Google 为预训练 BERT 而开发的标记化算法。此后,它在不少基于 BERT 的 Transformer 模型中得到重用,例如 DistilBERT、MobileBERT、Funnel Transformers 和 MPNET。它在训练方面与 BPE 非常相似,但实际标记化的方式不同。

WordPiece 主要在 BERT 类模型中使用。与 BPE 选择 频数最高的相邻子词合并 不同的是,WordPiece 选择 能够提升语言模型概率最大的相邻子词 加入词表。

2.3.3、Unigram

参考:

  • HuggingFace:https://huggingface.co/learn/nlp-course/zh-CN/chapter6/7?fw=pt

在 SentencePiece 中经常使用 Unigram 算法,该算法是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的标记化算法。

Unigram 的操作是和前两者反向的。不同于拼词,Unigram 是割词,首先初始一个大词表,接着通过概率模型不断拆出子词,直到限定词汇量。

可以从 WordPiece 的公式去理解。由于刚开始都是长词,词表是巨大的,通过拆概率小的词,保留概率大的词,从而缩小词表。

根据这些方法,可以根据自己的语料,训练一个垂直领域的词表,这些方法能够很好地将高频词、术语等统计出来。

2.4、Post-Processors(后处理)

在训练词表后,还可能需要对句子进行后处理。例如一些模型当我们分完词,还想给句子加入特殊的标记,例如 BERT 会给句子加入分类向量和分隔符 [CLS] My horse is amazing [SEP],这时就需要Post-Processors。

3、继承一个已有的 Tokenizer

参考:

  • HuggingFace:https://huggingface.co/learn/nlp-course/zh-CN/chapter6/2?fw=pt

3.1、准备语料库

3.2、训练新的 Tokenizer

3.3、保存 Tokenizer

4、从头开始训练 HuggingFace Tokenizer

参考:https://huggingface.co/learn/nlp-course/zh-CN/chapter6/8?fw=pt

4.0、构建语料库

使用 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")

4.1、WordPiece Tokenizer

4.2、BPE Tokenizer

现在让我们构建一个 GPT-2 标记器。与 BERT 标记器一样,我们首先使用 Tokenizer 初始化一个BPE 模型:

tokenizer = Tokenizer(models.BPE())

和 BERT 一样,如果我们有一个词汇表,我们可以用一个词汇表来初始化这个模型(在这种情况下,我们需要传递 vocab 和 merges),但是由于我们将从头开始训练,所以我们不需要这样去做。 我们也不需要指定 “unk_token”,因为 GPT-2 使用的字节级 BPE,不需要 “unk_token”。


GPT-2 不使用归一化器,因此我们跳过该步骤并直接进入预标记化:

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

我们在此处添加到 ByteLevel 的选项 add_prefix_space=False 是不在句子开头添加空格(默认为 True)。 我们可以看一下使用这个标记器对之前示例文本的预标记:

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."

现在我们完成了,我们可以像以前一样保存标记器,并将它包装在一个 PreTrainedTokenizerFast 或者 GPT2TokenizerFast 如果我们想在 🤗 Transformers中使用它:

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)

4.3、Unigram Tokenizer

5、从零开始训练 BPE Tokenizer

6、关于 Tokenizer 的思考

更多

  • tiktokenizer:https://tiktokenizer.vercel.app

  • tiktoken from OpenAI: https://github.com/openai/tiktoken

  • sentencepiece from Google https://github.com/google/sentencepiece

参考

文档信息

-->

Search

    Table of Contents