Tokenizer

1 为什么LLM需要Tokenization

人类的幼崽在学会说话后,就已经有了对于这个世界的先验知识。比如,它知道“香蕉”是一种黄色的好吃的水果,它知道“水果”是什么,它能够利用已经学会的词汇解释新的词汇,它知道“香蕉”和“苹果”在潜在的表意空间中很接近。

但是,如果我们想要教会一个一无所知的机器去阅读文本、理解语义,我们是否还需要从单词开始教起?机器暂时还没有眼睛,所以我们无法拿着香蕉告诉它这是香蕉。但是,我们至少可以告诉它,“香蕉”是一个单独的词汇,这个句子应该这样读:“香蕉”是一种水果,而不是:香“蕉是”一种水果。

同时,我们希望尽可能多的告诉它人类语言的基本规律。

例如,“香蕉”可能经常和“苹果”一起出现(或者“牛奶”)。

对于没有任何语言结构知识的模型来说,直接输入一段文本无疑于输入一段乱码。

而我们希望像 BERT 或 GPT 这样的模型,在理解语义之前,首先需要在一个较低级别的层次上学习语法知识,这一点可以利用特殊的结构设计实现。

分词让我们更方便地做到以下两点:

  • 将输入的长串文本转为更细粒度的划分,即token

  • 将输入转为向量,通过向量之间的关系表示词与词之间的关系,可以作为更高级别 NLP 任务(例如文本分类)的输入

这样,使得输入的一段话,成为拥有上下文语义背景的一段话。例如:”香蕉是一种水果“。我们知道香蕉是什么,水果是什么(或者类似什么)。

2 分词粒度

在教会LLM说话之前,需要准备一个词表,要尽可能高效地表征人类语言的基础用法。因此,在评价分词粒度好坏的时候,一般是以下几个角度:

  • 词表大小是否合适?

  • 词表能否有效表征语义?

2.1 字符级别

一个字母(英文)或者一个字(中文)就是一个字符,每个字符作为一个token。

字符级别

优点:

  • 词表很小。像英文只有26个字符,中文用5000个常用汉字也能适用于大部分场景

缺点:

  • 为每个字符学习嵌入向量时,每个向量容纳了太多的语义在内,表征能力不足

  • 基本上没有利用词汇的边界信息和语义信息(然而对于中文来说一个字是有表意功能的,因此可以使用字符级别的分词,有时可能效果更好)

  • 长文本需要一个超长的向量表示,容易遇到性能瓶颈

2.2 word级别

对于英文及拉丁语系的语言来说,词与词之间有着天然的空格作为分割。而对于中文来说,词与词之间连在一起,需要首先经过分词算法。

优点:

  • 符合人类的阅读习惯,保留了词汇的边界信息和语义信息

缺点:

  • 由于长尾效应,词汇表可能过于庞大

  • 词表中的低频词/稀疏词在模型训练过程中无法得到充分训练,进而模型不能充分理解这些词的语义

  • 仍然有很多新词,容易OOV(Out-of-vocabulary)

  • 不同词性的词,比如”banana”和“bananas”很难得到有效的表示(无法理解关联性)

2.3 子词级别

subword/子词级,它介于字符和单词之间。是当前最广泛的使用方法。

统计每个词的词频。

  1. 对于频率高的词,不进行拆分

  2. 对于频率低的词,拆分成小的有意义的词

  3. 对于后缀,一般前面会增加一个特殊标记,例如将tokenization 实际会拆成 token##ization##标记ization是一个后缀

优点:

  1. 用一个适量的词表大小几乎就可以表示所有词

  2. 模型甚至可以理解没见过的词

缺点:

  1. 无法解决单词拼写错误的问题

3 常用Tokenize算法

3.1 BPE

Byte Pair Encoding

步骤:

  1. 准备足够大的语料库C

  2. 定义好需要的词表大小V_size

  3. 将单词拆分成字符序列,在每个单词末尾添加后缀</w>,并统计单词频率

    1. 停止符</w>表明subword是词后缀,如st 不加 </w> 可以出现在词首,如 st ar;加了 </w> 表明该子词位于词尾,如 we st</w>,二者意义截然不同

  4. 将语料库中所有单词拆分为单个字符,用所有单个字符建立最初的词典,并统计每个字符的频率,本阶段的 subword 的粒度是字符

  5. 挑出频次最高的符号对 ,比如说 t 和 h 组成的 th,将新字符加入词表,然后将语料中所有该字符对融合,即所有 t 和 h 都变为 th。新字符依然可以参与后续的 merge,有点类似哈夫曼树,BPE 实际上就是一种贪心算法

  6. 重复5操作,直到词表中单词数达到设定量V_size或下一个最高频数为 1

例子

  1. 获取语料库,这样一段话为例:“ FloydHub is the fastest way to build, train and deploy deep learning models. Build deep learning models in the cloud. Train deep learning models. ”

  2. 拆分,加后缀,统计词频:

    词频
  3. 建立词表,统计字符频率,排序:

    字符频率
  4. 以第一次迭代为例,将字符频率最高的de替换为de,后面依次迭代:

    第一次迭代
  5. 更新词表

    更新词表

如果将词表大小设置为 10,最终的结果为:

优点

  • 可以很有效地平衡词典大小和编码步骤数。

随着合并的次数增加,词表大小通常先增加后减小。迭代次数太小,大部分还是字母,几乎就是字符级别的分词;迭代次数多,又变成了word级别的分词。所以词表大小要取一个中间值。

词表大小变化

BPE 一般适用在欧美语言拉丁语系中,因为欧美语言大多是字符形式,涉及前缀、后缀的单词比较多。而中文的汉字一般不用 BPE 进行编码,因为中文是字无法进行拆分。对中文的处理通常只有分词和分字两种。理论上分词效果更好,更好的区别语义。分字效率高、简洁,因为常用的字不过 5000 字,词表更加简短。

Byte-level BPE

  • Byte-level BPE使用原始字节作为基本 token,能够表示任意文本,避免OOV的情况。

  • 可以与各种文本编码兼容,确保广泛的适用性

实现

  • 调包

  • 手撕

3.2 WordPiece

与BPE思想类似,WordPiece在开始时也是按照字符级别初始化一个词表。

最大的区别在于选择两个子词进行合并的规则: BPE 按频率,WordPiece 按能够使得 LM 概率最大的相邻子词加入词表

概率计算方式:

score=freqpairfreqfirst_element×freqsecond_elementscore = \frac{freq_{pair}}{freq_{first\_element} \times freq_{second\_element}}

WordPiece倾向于融合那些,各自的子部分在词表中更少出现的组合。

例如:尽管("un", "##able")出现的次数很多,但是WordPiece不会选择融合,因为有很多un和很多able在其它地方出现,保持它们的独立能够更好地表征语意。相反,像("hu", "##gging")这样的组合更容易被融合。

和BPE的另一个区别在于推理阶段的分词方式。 WordPiece最后仅仅保存最终的词表,而BPE保留了融合的规则。WordPiece会根据首字母找到最长的subword进行分割,而BPE则是根据保留的规则进行分割。 举个例子,对于hugs,最长的subword是hug,因此WordPiece会将其分割为hugs,而BPE则会将其分割为hugs

实现

  • 调包

  • 手撕

3.3 Unigram

与BPE和WordPiece不同的是,Unigram的词表是从大到小变化的。 即先初始化一个大词表(可以用词频或者BPE初始化),根据评估准则不断丢弃词表,直到满足限定条件。Unigram 算法考虑了句子的不同分词可能,因而能够输出带概率的多个子词分段。 评判标准:Unigram loss 尝试删去一个 token,并计算对应的 unigram loss,删除使得 loss 增加最少的 token,因为它们可能是“最不被需要的”。 这个过程的计算量可能很大,所以我们可以一次移除p%的token,p是一个可以设置的超参,通常为10或20。 假设我们已经算得了词频:

并获得了所有substring

在Unigram模型中,我们假设每个token都是独立的,依据前面n个token推测第n+1个token的概率就是第n+1个token的概率。 概率则是由词频决定的,因此

P(token)=freq(token)total_freqP(token) = \frac{freq(token)}{total\_freq}

其中, total_freqtotal\_freq是所有token的词频之和。 给定一个单词,我们可以计算其概率:

P(["p","u","g"])=P("p")×P("u")×P("g")P(["p", "u", "g"]) = P("p") \times P("u") \times P("g")
P["pu","g"]=P("pu")×P("g")P["pu", "g"] = P("pu") \times P("g")

一个单词会被tokenize成概率最高的情况。

那么,"pug"会被分成"p"和"ug"或者"pu"和"g"。

loss计算

loss=i=1nlog(P(tokeni))loss = -\sum_{i=1}^{n} log(P(token_i))

所有loss是:

10(log(0.071428))+5(log(0.007710))+12(log(0.006168))+4(log(0.001451))+5(log(0.001701))=169.810 * (-log(0.071428)) + 5 * (-log(0.007710)) + 12 * (-log(0.006168)) + 4 * (-log(0.001451)) + 5 * (-log(0.001701)) = 169.8

我们需要计算移除每个token后对于loss的影响,然后移除使得loss增加最少的token。

实现

  • 调包

  • 手撕

3.4 SentencePiece

BPE,WordPiece,Unigram的缺点:

  • 假设输入文本使用空格来分隔单词,如果不是则需要提前分词

为了解决这个问题:

  • SentencePiece将输入视为输入字节流,包括空格

  • 然后使用 Byte-level BPE 或 unigram 算法来构建适当的词汇表

4 应用与实践:训练古汉语Tokenizer并与Qwen Tokenizer融合

4.1 为什么要词表拓展

  • 词表拓展可以提高模型的泛化能力,使得模型能够处理更多的未知词汇

  • 词表拓展可以提高模型的性能,使得模型能够更好地处理特定领域的文本

例如,Llama3的Tokenizer使用的是SentencePiece,词表大小为128256,且大部分是英文。如果用Llama3的Tokenizer处理古汉语文本,基本上都会退化成字节级别的分词,这样会导致丢失部分语义信息。

以《离骚》中的一句为例:

输出:

表现形式就是出现'�'。

如果我们需要训练一个古汉语专用的LLM(例如XunziALLM),可能需要对它的Tokenizer进行拓展。

4.2 古汉语词表拓展

4.2.1 准备语料

作为toy dataset,我们使用《离骚》全篇作为语料库,将其保存为corpus.txt

4.2.2 训练Tokenizer

4.2.3 与Llama3 Tokenizer融合

4.2.4 性能测试

使用融合后的Tokenizer对《离骚》进行分词,输出为:

可以看到,'�'少了很多。

5 总结

  • Transformer的输入是什么?

  • Tokenize/分词的作用?

  • 三种不同级别的Tokenizers及其优缺点

    • 字符级别

    • word级别

    • subword级别

  • 四种Subword-based Tokenizers(拆分方法)

    • BPE/BBPE

    • WordPiece

    • Unigram

    • SentencePiece(使用BBPE或者Unigram)

6 参考资料

  1. https://cloud.tencent.com/developer/article/2317900

  2. https://jinhanlei.github.io/posts/Transformers快速入门-二-用Tokenizer从零开始训练词表/

  3. https://huggingface.co/learn/nlp-course/chapter6

  4. https://github.com/QwenLM/Qwen/blob/main/tokenization_note_zh.md

Last updated