tokenizers 总结

tokenizer

目前的机器学习模型都是数学模型,其对应的输入要求必须是数字形式(number)的,而我们处理的真实场景往往会包含许多非数字形式的输入(有时候即使原始输入是数字形式,我们也需要转换),最典型的就是NLP 中的文字(string),为了让文字能够作为输入参与到模型的计算中去,我们就需要构建一个映射关系(mapping):将对应的文字映射到一个数字形式上去,而其对应的数字就是token。而对应的这个映射关系,就是我们的tokenizer:他可以将文字映射到其对应的数字上去(encode),也可以将数字映射回对应的文字上(decode).

word level

那如何构建这个映射关系呢?最简单的想法:一个词对应一个id 不就好了。也就是”word level”。然后,”词”缺不是那么好分的:对于中文,词到底分成什么粒度,”苹果手机“到底是一个词还是二个词呢?”武汉市/长江/大桥/欢迎/你“与”武汉/市长/江大桥/欢迎/你“应该选择哪个方案呢?此外,对于”虚坤“/”鸡你太美“等这些新词怎么识别呢?对这部分感兴趣的可以看我更早期关于分词总结的博客:分词算法综述.

中文有分词的问题,那英语总没有了吧,空格+标点就是天然分隔符,然而事情也没那么简单:

1
Don't cry!

按照空格与标点切分,得到的结果是:

1
["Don", "'", "t", "cry", "!"]

哈,有点怪,我们需要把”Don’t” -> “Do”/“n’t”,这样才更合理一点。即:

1
["Do", "n't", "cry", "!"]

除了如何切分成词外,还有一个最大的问题是结果集太大。比如TransformerXL 中,使用标点和空格,切分后的词表大小有267K,如此大的词表,不管是对存储还是计算都有压力。

此外,随着文化发展,每天都有大批的新词出现,用词粒度做映射就越来越不理想了。

char level

既然分词这么麻烦,我不分不好了,我就按“字”(char) 来做最小单元做映射。这样词表就小多了:英文只需要26个字母即可,中文根据2013年中华人民共和国教育部《通用规范汉字表》定义“规范汉字”,国家规定的通用规范汉字一共为8105个,相比之下也不算大。

然而char level 的主要问题是切分的太细:

1
2
3
pneumonoultramicroscopicsilicovolcanoconiosis

(医)肺尘病、矽肺病

上面这个单词是我找到的最长的英语单词,他有45个字母组成,而中文中也存在大量的成语/歇后语/专用名词等,如:

1
只许州官放火不许百姓点灯

目前我们NLP 的主要思路是对句子进行,即:
$$
P(S)=P(w_1,w_2,..w_n)=P(w_1)∗P(w_2|w_1)∗P(w_3|w_1,w_2)∗…∗P(w_n|w_1,w_2,..w_{n−1})
$$
而切分太细,则对应$S$ 的长度会变长,无疑大大增加建模难度(通过字来学习词的语义),也常常导致模型效果不理想。

subword level

为了缓解两种方式的问题,一个想法是取长补短,即目前的主流方案:subword level.

subword level 的主要出发点是:我们应该尽量保留高频词,对低频词进行逐级切分,以获得一个词表大小合适,又对后续建模友好的方案。

目前subword level 的tokenizer 方法主要有BPE, Bytes BPE, WordPiece, Unigram, SentencePiece,下面简单总结一下各个方法。

BPE

bpe 的方案是通过统计词频来确定两个相邻的pair subwords 要不要合并,具体做法:
1.统计pre-tokenize 的word 的词频;

1
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

2.使用词典对word 进行切分:

1
2
# base vocabulary: ["b", "g", "h", "n", "p", "s", "u"]
("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)

3.统计相邻两个subword pair 词频,将top-k 高的pair 合并生成新的subword,添加进vocabulary,同时,如果当前的subword 只会同pair 一起出现,则同时将vocabulary 中对应subword 删除。

1
2
3
4
5
6
7
8
9
10
11
# count pair
h + u = 10 + 5 = 15
u + g = 10 + 5 + = 20
...
# merge top k
set k = 1
ug -> vocabulary
base vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]

loop until vocabulary match vocab_size

Bytes BPE

Bytes BPE 与BPE基本相同,唯一不同的是:BPE 中会存在UNK 的情况,为了解决unk 的问题,一个非常天才的想法是将所有text 先转为bytes ,这样就不会存在unk 的问题,尤其是在多语言中,这种方式可以大大缩减词表大小;此外即使不是目标语言训练的模型也可以拿来使用。通常词表大小包括256 个基本bytes + <end|of|text> + vocab-size,如gpt2 的词表为50257: 256 base bytes tokens + <end|of|text> + 50,000 merges.
此外,训练bytes bpe 时,通常我们还会选择先将文本进行normalize,这部分后面会进一步说明。

WordPiece

WordPiece 与BPE 也非常相似,区别主要在于merge 的策略:BPE 中选择频率最高的pair 进行合并,WordPiece 则选择使用语言模型来进行选择:
$$
L = logP(S) = \sum^Nlog(P_i)
$$
对于两个subword: $t_x$, $t_y$ ,合并后为 $t_z$ ,则合并前后的增益:
$$
Loss = logP(t_z) - (logP(t_x) + logP(t_y))
$$
通过计算合并增益是否增大来决定是否合并subword pair.

Unigram

Unigram 与 上述的方法都略有不同:Unigram 不再是通过合并base vocabulary 中的subword 来新增,他选择在初始化时初始化一个非常大的subword set,通过计算是否需要将一个subword 切分为多个base subword (remove 这个subword)来减小vocabulary size 直到达到vocab size。
这里有一个假设:句子之间是独立的,subword 与 subword 之间是独立的。对应的句子的语言模型似然值就是其subword 的概率的乘积。目标是保存vocab size 的同时语言模型似然值最大。
$$
x^* = argmax_{x \in U}P(\overrightarrow{x})
$$
整个求解过程是一个简单的EM 或者说一个迭代过程:
0.建立一个足够大的种子subword vocabulary,可以用字典树构建可以是所有字符的组合,也可以用bpe 构建;
1.(期望E)统计vocabulary 中每个subword 的频率,计算其对应概率值;
2.(最大化M)根据其概率,使用维特比算法返回其语言模型似然值最大化下的最佳分割方案;
3.计算最佳分割方案下每个新子词的loss,这里的loss 是指将当前subword 从vocabulary 中移除时,对应的语言模型似然值,即
$$
L = − \sum^Nlog (\sum_{x∈S(x_i)}p(x))
$$
4.丢弃掉loss 前x% 对应的subword;
5.重复2-4阶段,直到vocabulary 达到指定的vocab size。

SentencePiece

SentencePiece 其实并不是一个新的tokenizer 方法,他其实是一个实现了BPE/Unigram tokenizer 的一个集合,不过他有一些创新的地方。
上述方法中有一些问题:
1.都有字,子词或词的概念,然而在很多语言中并没有这样的概念;
2.都默认需要自己进行pre-tokenize,如英语则利用“空格”作为词的分割符,中文则一般选择jieba 进行pre-tokenize,这个过程不同的语言有自己的一套做法,不统一;
3.token 格式不统一。以英文为例,表示token 时会有 ##xx, xx/s 这种,表示subword 是否出现在词的首尾,然而中文中是没有这种概念的;
4.解码困难,如BPE解码时需要进行一些标准化,最常见的是去除标点符号。而我们解码后是 [new] [york]两个token,我们并不知道原来的词是 newyork/new york/new-york 中的哪一个.
SentencePiece 的做法:
SentencePiece treats the input text just as a sequence of Unicode characters. Whitespace is also handled as a normal symbol. To handle the whitespace as a basic token explicitly, SentencePiece first escapes the whitespace with a meta symbol “▁” (U+2581) as follows.

即首先将空格转换为一个标准的字符”▁”,然后将text 转换为unicode,其实这里的unicode 是NFKC-based normalization后的unicode,至于unicode 标准化,可以参考unicode文本标准化 ,虽然通常我们使用NFKC 标准化,但sentencepiece 内部四种方法都实现了。
通过上述的空格转换加normalize,所有的语言经过转换后就有统一的格式了,这样多语言的问题就彻底的与token 切分解偶了,tokenizer 就有了一个完全端到端的解决方案。

train from scratch

训练一个tokenizer model 主要有两个仓库可以参考: huggingface/tokenizersgoogle/sentencepiece.
其中tokenizers 支持bpe/bytes bpe/unigram/wordpiece, sentencepiece 支持bpe/unigram.两者都支持四种标注化方法。
此外,tokenizers 不支持自定义的pre_tokenizer的保存,如中文时我们常用的jieba.lcut ;bytes bpe不支持big dataset 的训练,1T 内存训练100G 文本也会因内存不足被killed,(一个办法是缩小语料训练,因为没有oov 的问题,基本上小语料下训练也能用)。
在实际使用时,通常会结合huggingface/transformers 一起使用,这里也看了一下transformers/tokenizers 的实现。原始的GPT2 中的tokenizer 是没有做normalize 的,所以 transformers中的GPT2Tokenizer 也是没有做normalize 的,而通常我们自己训练的bytes bpe 是会加一个normalize 的过程,所以如果是通过huggingface/tokenizers 训练的tokenier,迁移到transformers 时需要注意normalize 是否实现。

推荐

当目标语言为中文时,推荐使用WordPiece + jieba 的方案;而是多语言场景时,推荐使用SentencePieceBPE/SentencePieceUnigram.
无论哪种合并/切分 subword 的策略,我们的初衷是:
尽量不切分常用词,而是将不常用词切分为常用的子词.
而中文中,有明确的字/词概念,却没有子词的概念(如英文中有”app”, “##le”, 中文却没有”苹” “##果”),而转bytes 后对子词更友好,此外,中文通常需要3个bytes(GBK)或者4个bytes(Chinese-Japanese character set),对于一个中文的字,很有可能需要大于1个token 来表示,反而会增加tokenize 后序列的长度,对模型的训练与使用不利;此外,中文中空格也没有切分词/句子 的语义,保留空格反而会由于各种空格的错误使用带来问题,最终的推荐方案就是jieba + Word Piece/SentencePieceUnigram。
而多语言场景下,推荐使用SentencePieceBPE,他提供一个端到端的方案,而不需要再根据不同语言进行不同的pre-tokenize/subword 格式,此外,SentencePiece 都是bytes 粒度的,这样既能大大缩减词表又能避免unk 的情况。

补充阅读

tokenizer summary-huggingface
Difference Between NFD, NFC, NFKD, and NFKC Explained with Python Code-medium
深入理解NLP Subword算法:BPE、WordPiece、ULM-知乎
natural_language_understanding/subword_units/subword_units-github.io

关于头图

本人真实写照🐶

训练加速篇(3)horovod之多机多卡
From zero to ChatGPT