NLP(自然语言处理),对于它来说,如何有效地编码一段文本,是它首先要考虑的问题。而在编码文本之前,要先把它切割成小块,这些小块叫做 tokens,这个过程叫做分词(tokenization)。所谓“千里之行,始于足下”,分词算法是NLP的起点,下面这一类算法做个总结。
单词、字符与子单词
第一个想法,可以以单词为单位进行切割,每个单词都是一个 token,这个想法叫做 Word Tokenization
;第二个想法,可以以字符为单位进行切割,每个字母都是一个 token,这个想法叫做 Character Tokenization
。
举个例子,要对 “I love you.” 进行分词:
- Word Tokenization gives: [‘I’, ‘love’, ‘you’, ‘.’]
- Character Tokenization gives:[‘i’, ‘l’, ‘o’, ‘v’, ‘e’, ‘y’, ‘o’ ‘u’, ‘.’]
Word Tokenization 的问题在于,它无法处理 Out Of Vocabulary (OOV) words。 拿过一段新的文本,如果其中包含词库中未出现的词,只能把它标记为 Unknown (UNK),这样无疑会影响神经网络的准确度。而 Character Tokenization 虽然解决了 OOV 问题,但是它过于冗长。把一些字母组合成一个有意义的单词已经很费功夫了,更遑论把单词组成有意义的句子。
于是人们想,能不能想个中间的法子?——这就是 Subword Tokenization
。它把文本切割成一些“子单词”。举个例子,unhappy 可以切割成 [un, happy];disable 可以切割成 [dis, able] 。这样有什么好处呢?在训练 word embedding (更准确地说,token embedding)的时候,可以更容易捕捉单词前缀、后缀的含义,避免了重复捕捉信息。
更重要的一点,它在压缩数据的同时,减少了 UNK 的使用。
我们要按照什么标准切割子单词呢?下面介绍NLP领域常用的 Subword Tokenization 算法。
BPE
Hugging Face: Byte-Pair Encoding tokenization
Byte-Pair Encoding (BPE) 被广泛地用于 Transformer 架构中,如 GPT、GPT-2、RoBERTa 等模型。
训练阶段:
从训练文本构成的字符集出发,在训练的每一步,寻找出现频率最高的 token pair (by “pair,” here we mean two consecutive tokens in a word),将它们合并;不断进行,直到 token 的数量达到预设的值。简单来说,BPE 最终得到的是 character n-grams
,所以它更应该叫做 Character-pair Encoding
分词阶段:
训练过程中,那些被合并的 token pair 被记录下来,用来对新来的文本进行分词。
举个栗子:
假设训练语料库如下,单词后的数字表示出现的次数
Vocabulary:["hug", "pug", "pun", "bun", "hugs"]
Corpus: ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
把每个单词都分解成字母,形成一个Base Vocabulary:
Base Vocabulary: ["b", "g", "h", "n", "p", "s", "u"]
Corpus: ("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
寻找出现频率最高的 token pair,发现是(“u”, “g”),它出现了20次。于是进行合并:(“u”, “g”) -> “ug”
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
重复上述步骤,这次(“u”, “n”) 的频率最高:
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)
两次合并后,这次(“h”, “ug”) 频率最高:
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
Corpus: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
假设算法到这里结束了,看一下学习到的“合并规则”:
("u", "g") -> "ug"
("u", "n") -> "un"
("h", "ug") -> "hug"
当有新的文本需要进行分词时,先将它分解成单个字母的序列,然后对照着这个合并规则进行合并。
比如单词 bug,分解成 [b, u, g];比对合并规则,发现 u, g 可以合并,且 b, ug 不能合并;因此 bug 的 分词结果是 [b, ug].
再比如单词 mug,它的分词结果是 [UNK, ug],因为 m 不在 Base Vocabulary 之中。
再比如单词 thug,它的结果是 [UNK, hug]
BBPE(重点)
对于西方文字系统的编码来说,BPE 算法绰绰有余。而对于中文、日文等文字系统,BPE 会让 vocabulary set 变得很大,并且容易出现 out-of-vocabulary (OOV) 问题。
2019 年的一篇论文 Neural Machine Translation with Byte-Level Subwords 提出了 Byte-level BPE,即 BBPE。之前,BPE 把句子看作是字符的序列,然后对字符进行合并;而 BBPE 把句子看作是字节的序列,然后对字节进行合并。缺点就是它不知道哪些字节可以构成合法的Unicode码位、合法的字符或是词。
举个例子(非实际情况),对于“一只”的 UTF-8 字节序列 b’\xe4\xb8\x80\xe5\x8f\xaa’,中间两个字节 b’\x80\xe5’ 可能会先合并为一个 token,跨越了 一(b'\xe4\xb8\x80')
和 只(b'\xe5\x8f\xaa')
的码位边界。 这对于已登录token不会有什么影响(最后总会合并为"一只"),但对于未登录的,可能会产生一些不同寻常的合并/token。 这些token序列可能对于预训练模型是陌生的。
注:不熟悉字符编码方式的话,可以参考 ASCII, Unicode 以及 UTF-8
Almost all existing machine translation models are built on top of character-based vocabularies: characters, subwords or words. Rare characters from noisy text or character-rich languages such as Japanese and Chinese however can unnecessarily take up vocabulary slots and limit its compactness. BBPE is compacter than character vocabulary and has no out-of-vocabulary tokens.
We consider UTF-8 encoding of text, which encodes each Unicode character into 1 to 4 bytes. This allows us to model a sentence as a sequence of bytes instead of characters.
A byte sequence representation of text is often much longer (up to 4x) than a character sequence. Thus, we look into byte-level “subwords” that are used to tokenize text into variable-length byte n-grams, as opposed to character-level subwords in which we represent text as a sequence of character n-grams.
实际上,GPT-2、RoBERTa 使用的是 BBPE,而不是 BPE。
WordPiece
WordPiece 是 BERT 网络的分词算法,它和 BPE 很像,但有些许区别。BPE 只关心 token pair 的出现频率,即 freq_of_pair;WordPiece 还考虑了每个 token 的出现频率。
ps:WordPiece 的算法细节并没有开源,下面的算法过程是 Hugging Face 从论文描述中推断出来的:Hugging Face: WordPiece tokenization
训练阶段:
和 BPE 一样,WordPiece 的训练过程也是一个逐渐合并 token pair 的过程。但它用的合并准则有一些不一样。
score= (freq_of_pair) / (freq_of_first_element × freq_of_second_element)
BPE 只关心 token pair 的出现频率,即 freq_of_pair;WordPiece 还考虑了每个 token 的出现频率。
For instance, it won’t necessarily merge (“un”, “##able”) even if that pair occurs very frequently in the vocabulary, because the two pairs “un” and “##able” will likely each appear in a lot of other words and have a high frequency.
这里举了一个例子,即使 unable 出现频率很高,但如果 un 和 able 单个 token 的出现频率都很高,也不会合并它们,这也符合我们的预期。
训练阶段的其他部分与 BPE 相同。
分词阶段:
与 BPE 不同,WordPiece 不会保存训练阶段学到的“合并规则”,它只会保存最终的 Vocabulary。对于一个单词,从第一个字母开始,它会寻找 Vocabulary 中最长的子单词,进行切割;直到剩下的字母组成的 token 存在于 Vocabulary 中。
假设最终的 Vocabulary 如下(以 ## 开头的 token 表示该 token 不是单词的开头):
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
单词 hugs 的分词结果是 [“hug”, “##s”]
单词 bugs 的分词结果是 [“b”, "##u, “##gs”]
如果在某一步无法在 Vocabulary 中找到子单词,那么整个单词会被标记为 UNK,这一点和 BPE 不同,后者只会将不在 Vocabulary 中的 token 标记为 UNK;
比如 mug 就会被标记为 UNK,因为无法找到以 m 开头的 token。
Unigram
Unigram 是一种用于 AlBERT, T5, mBART 等模型的分词算法。它的思路和 BPE、WordPiece完全不同。
Unigram 是 1-gram 语言模型,假设每个 token 的出现都是独立的。如果用 1-gram 语言模型生成文本,那么将会一直预测出现次数最多的 token
In contrast to BPE or WordPiece, Unigram initializes its base vocabulary to a large number of symbols and progressively trims down each symbol to obtain a smaller vocabulary.
At each training step, the Unigram algorithm defines the negative log-likelihood as loss over the training data given the current vocabulary and a unigram language model. Then, for each symbol in the vocabulary, the algorithm computes how much the overall loss would increase if the symbol was to be removed from the vocabulary. Unigram then removes p (with p usually being 10% or 20%) percent of the symbols whose loss increase is the lowest, i.e. those symbols that least affect the overall loss over the training data. This process is repeated until the vocabulary has reached the desired size.
细节不再展开了,建议读 Hugging Face 的 Tutorial:Hugging Face: Unigram tokenization & Hugging Face: Summary of tokenizers
SentencePiece
2018 年,SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing 提出 SentencePiece 分词算法,后来用于 ALBERT,XLNet,DeBERTa v2&v3 以及 T5 等模型中。
BPE, WordPiece 和 Unigram 方法均假设输入的文本是已经切分的,通常直接通过空格切分,这一步叫做 pre-tokenization。这样做有两个坏处:不是所有的语言都是以空格分割的,比如中文和日文。并且切分时破坏了原始输入,decode 时会有信息损失。
SentencePiece 把包括空格在内的所有字符视作输入流,即一串 Unicode 编码。在此基础上使用 BPE 或 unigram 算法进行分词。SentencePiece 编码空格时用_
表示它。分词时,默认 _
不会出现在 token 的中间位置,而只会出现在 token 的开头位置(即默认情况下,它不会把两个词的前后部分组合起来,组成一个 token)
论文的作者开发了 SentencePiece 的同名库,有 C++ 以及 Python 的实现。它没有提供预训练好的分词器。使用之前,必须先进行训练,这要求我们有一个较大的训练集。
import sentencepiece as spm
# train sentencepiece model from `botchan.txt` and makes `my_model.model` and `my_model.vocab`
# `my_model.vocab` is just a reference. not used in the segmentation.
spm.SentencePieceTrainer.train('--input=botchan.txt --model_prefix=my_model --vocab_size=2000')
# makes segmenter instance and loads the model file (my_model.model)
sp = spm.SentencePieceProcessor()
sp.load('my_model.model')
# encode: text => id
print(sp.encode_as_pieces('This is a test'))
print(sp.encode_as_ids('This is a test'))
# # decode: id => text
print(sp.decode_pieces(['▁This', '▁is', '▁a', '▁t', 'est']))
print(sp.decode_ids([209, 31, 9, 375, 586]))
输出结果为:
['▁This', '▁is', '▁a', '▁t', 'est']
[209, 31, 9, 375, 586]
This is a test
This is a test
由于 SentencePiece 在分词时保留了空格的位置信息,所以它的分词是可逆的:从编码后的 token list 可以轻松地还原原来的句子(只需要把 _
替换为空格即可)。
注意到,训练时,我们指定了 --vocab_size
,这是指目标 Vocabulary 的 token 数量。BPE 通过不断合并,会产生新的 token,而一旦 token 的数量达到我们设定的vocab_size
,训练就会停止;而 Unigram 通过不断去除 token,最终从另一个方向达到 vocab_size
。
如果训练文件太大,可以指定 --input_sentence_size
,只选取一定数量的句子进行训练。
更多的细节,可以参考这个 colab tuto:Google colab page to run sentencepiece
值得一提的是,SentencePiece 是支持中文分词的。有人测试了在 SentencePiece 上的中文分词:SentencePiece的中文测试实践
另外,Hugging Face 提供了一些基于 SentencePiece 算法、已经预训练好的分词器。如:
tokenizer = AutoTokenizer.from_pretrained("albert-base-v2")
tokenizer = AutoTokenizer.from_pretrained("t5-base")
tokenizer = AutoTokenizer.from_pretrained("xlnet-base-cased")
tiktoken
tiktoken 是 OpenAI 用于 GPT 系列模型的分词算法库。下面是 tiktoken 支持的、不同 GPT 模型用的词表。
现在国内模型厂商的常见做法是,基于 cl100k_base
词表,扩充一些中文 tokens。这样可以提高中文的 compression rate(平均来说一个 token 对应的中文字符数更多),一个显而易见的好处就是加速中文的生成速度。例如 InternLM 就是这么干的,只不过它为了控制词表大小在 10k 之内, 并没有照搬 cl100k_base
,而是在其中精心挑选了一些常见 token.
InternLM 词表节选:
深入到算法层面,tiktoken 其实也是基于 BBPE 分词算法。它和 SentencePiece 的区别在哪里呢?
Qwen的 tokenizer(依赖 tiktoken)是直接从 UTF-8 编码的字节序列开始处理的,这与其它 tokenizer 比如 SentencePiece 是很不一样的。SentencePiece 是从Unicode码位(可以理解为一个字符)开始处理,遇到未登录的再用 UTF-8 编码成字节。——Qwen/tokenization_note_zh.md
另外这里可以拓展一下:Qwen 系列的 tokenizer 为什么是乱码?
如果你去看 Qwen 的 tokenizer 词表,会发现很多看起来像乱码的东西。实际上,id=108386 对应的 token 是“你好”。
“你好”和 “ä½łå¥½” 是如何对应起来的呢?
- 首先“你好”对应的 UTF-8 编码用十六进制表示为 ‘0xe4 0xbd 0xa0 0xe5 0xa5 0xbd’
- 用十进制表示就是 228, 189, 160, 229, 165, 189
- 利用 bytes_to_unicode 函数 将十进制数(0~255)映射为 Unicode 字符,也就是 “ä” 这种字符。
这也是从 GPT-2 继承来的传统了。至于为什么要这么做,笔者也没想明白(╮(╯▽╰)╭)
总结对比
在 2024 年这个时间节点来看,现在 LLM 的主流分词工具库,是 SentencePiece 和 tiktoken,而分词算法大多基于 BBPE。例如LLaMA 系列用的是基于 UTF-8 的 BBPE 算法。国内的话,Qwen, InternLM 等在 tiktoken 提供的词表基础上扩充中文 token。
但要注意,LLaMA 尽管能支持中文,但是效率很低:一个中文字符在 UTF-8 可能表示为 3 个字节,意味着最差情况下,1 个汉字要编码成 3 个 token。
如果在中文语料上训练分词器,很多 2 个汉字组成的词组会被编码为 1 个 token,提高了 compression rate。这就能解释为什么羊驼家族有一个流派在中文上扩充词表再继续预训练。这种情况下,中文效果提升未必是词表扩充导致的,但是生成中文的速度变快就是因为扩充词表。——大模型面试八股答案(一)——基础知识