大语言模型的浪潮已经席卷大地快两年了,但咱对于 Tokenizer 却知之甚少。
稍微花费了一点时间捡起散落的碎片,在此简单记录。
Tokenizer 是什么
对于不熟悉 transformer 及 tokenizer 的朋友,请参考 Huggingface 编写的教程:
看完以上两篇应该对 transformer 及 tokenizer 的有一般认知,可以稍微看看当前使用 tokenizer 前身 BPE 的底层算法实现:
精力充沛的选手可以看看 BBPE 原始论文实现,这也是现在几乎所有 LLM 所使用的 tokenizer 算法:
简单阅读后不难发现,无论是哪一种 tokenizer 算法,从最终效果看都是用作将 Text 在 List[Char] 和 List[Int] 两种形态间转换。
在由 List[Char] 到 List[Int] 的转换过程中,Tokenizer 首先将 Text 递归拆开为 List[Text],再为每一段 Text 分配独一无二的 Int,最终形成 List[Int]。
分词器的作用同样是将句子拆开,因此在很多中文社群里 Tokenizer 也被翻译为「分词器」。
显然,形如 Jieba 的分词器并没有 Tokenizer 中转换为 List[Int] 的步骤,因此下文中将保持 Tokenizer 不翻译。
BPE/BBPE Tokenizer 组成
对于采用 BPE/BBPE 算法的 tokenizer,在训练过程中会产生两个字典:vocab 和 merges。
我们也可以借助附录 2 中的代码,将开源 LLM 的 tokenizer 还原为这两个部分。
其中 vocab 存放了 Text 到 Int 的映射表,而 merges 保存着切分 & 合并 Text 的规则(详细见 Byte-Pair Encoding tokenization)。
vocab 通常有两种格式:
- vocab.txt/List 每一行是一个 Text,行号即为其对应 Int
vocab.json/Dict,包含一个字典,其中 Text 为 key,Int 为 value
显然,对于从 List[Int] 到 List[Char] 的过程(aka. decode),仅有 vocab 就可以实现。
merges 通常就一种格式:
merges.txt/List 每一行一条规则,通常是一个空格隔开的两段 Text,例如:「ク ト」
此处需要注意,BPE 和 BBPE 的 vocab / merges 有着不同的形态:
由于 BBPE 采用 Byte 作为最小单位进行二次编码,因此 vocab / merges 中的 Text 经过了基础词表的 BaseN 编码(通常 N=256)。
例如在 BPE 算法下 vocab 包含「クト」,而 BBPE 下会被编码为「ãĤ¯ãĥĪ」。
编码使用的基础词表通常为 Unicode Table 第一页中的前 256 个可见字符。
解开 BBPE 编码的方法很简单,详见附录 1。
对于一个 Tokenizer,其 vocab 包含的成词越多压缩率越高。
但新加入的词比 vocab 已有的词更不常出现,因此其为压缩率带来的贡献比更小。
同时,vocab 的尺寸对应 transformer 中 head 的宽度,两者保持 O(n^2) 的关系,过大的 vocab 会导致模型占用参数过多。
因此,tokenizer 应与 transformer 模型的尺寸保持合理的大小关系,在后者的工作范围内前者保持最高的压缩效率。
Tokenizer 分析
训练策略
由 BPE 训练算法可知,vocab 中 Text 出现的顺序与训练文本的词频相关,即词频越高排名越靠前。
反过来我们也可以从 vocab 的词语顺序中推断训练 tokenizer 使用的文本构成,进而分析其训练策略。
例如英语中词频最高的是「the」,中文中词频最高的是「的」。
但在 LLaMA 3 的词表中,「 the」出现在 279 位,落后于「in」的 258 位,而「的」更是远远落在了 9554 位。
很显然在作者的训练集中,中文文本量是远少于英文的,因而「的」的排名非常靠后。
而英文的文本显然也不全是自然语言,因而「in」拥有了更靠前的位置。
由此可以发现其训练策略是英文优先,在英文的范围内可能是代码优先。
压缩性能
进一步的,我们可以借助先辈编纂的常用字词表来分析各语言下的压缩性能。
以中文为例,我们有两个常用的标准:
- 『通用规范汉字表』:包含 8105 个常用汉字,合格的中文 tokenizer 应达到 100% 覆盖(即每一行都是单 token 编码),例如 LLaMA 3 仅有 25% 不到的覆盖率,而 Qwen1/2 为 100%。
- 『现代汉语常用词表』:包含 55735 个现代汉语常用词,Baichuan2 系列能达到 35% 左右的覆盖率,而其他的模型通常只有 20% 不到。
对于其他类型,例如 Python 代码或 Markdown 文本,同样可以如法炮制。
由此我们可以窥探训练 tokenizer 的作者是在那些方面做的取舍。
人工痕迹
上文提到的两个分析角度都涉及同一个目的:分析 Tokenizer 的训练数据构成,即「他们究竟用了什么数据来训练 tokenizer?」。
但很显然,除作者以外,没人能真正解答这一问题。
为更进一步接近答案,我们只能从各模型的技术报告及开源 LLM 的 tokenizer 管中窥豹。
或者在真相公布之前,定下暴论:可以认为主流 LLM 的 Tokenizer 训练数据构造方式有两种,基于原始分布 或 基于人工构造。
前者典型代表是 LLaMA、OpenAI 等模型,作者相信互联网的文本分布代表了 LLM 与人类交互的实际分布,能带来最佳压缩率。
后者则选择对互联网文本做仔细挑选清洗,或选择相信例如『现代汉语常用词表』等前人总结的分布,以此进一步提高压缩率。
两者最简单的判断方式是检查『通用规范汉字表』覆盖单 Token 编码字占比,前者通常不超过 30%,而后者往往 80%+。
另一个有意思的判断方式是检查国家领导人名字(例如 242)/ 特定公司名(例如 Alibaba)是否存在于 vocab 之中。
缺陷
典型的 Tokenizer 缺陷有以下三个:
- 编码有损 OOV:一段文本经 Tokenizer 编码解码后丢失了部分内容(例如 emoji、空格、括弧);
- 数字粘连:Tokenizer 不应将任意一个整数编码为单 token,例如 123 应该编码为 [1, 2, 3] 三个独立的 token;
- 分布污染:vocab 反映了训练数据的原始分布,不当的数据会导致 vocab 包含可能有害的文本(参考 GPT-4o)。
对于第一种缺陷,所有基于 Char 的 tokenizer 都存在,部分 BBPE 编码器也存在该问题(例如 LLaMA 3);
对于第二种缺陷,通常是作者训练时未将文本中的数字删除导致的,可能导致模型的数学能力差;
对于第三种缺陷,我们唯一知道的是 LLM 一旦开源就无法撤回了,务必仔细检查。
结语
Tokenizer 是自然语言的代理,可以视作是仅属于语言模型的「语言」,有着牵一发而动全身的地位。
在 Tokenizer 分析上还有一些内容这里尚未提及,例如检查 vocab 血缘关系、融合 vocab 及 扩大 vocab 的继续训练等。
只是良辰吉日未到,今天就先到这里了。
以上。
附录 1:BBPE 解码器
原理很简单,用 tokenizer 库训练一个仅包含基础此表的 BBPE 用于解码即可。
from tokenizers.implementations import ByteLevelBPETokenizer
tokenizer = ByteLevelBPETokenizer()
# 假装训练一下,就会写入基础词表
tokenizer.train(files=[],min_frequency=1,vocab_size=2048)
tokenizer.save("tokenizer.json")
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_file("tokenizer.json")
vocab = tokenizer.get_vocab()
def base256_decode(query, keep_space=False):
result = [
tokenizer.decode([vocab[j] for j in ids])
for ids in query.split(" ")
]
return " ".join(result) if keep_space else "".join(result)
if __name__ == "__main__":
target = "ãĤ¯ ãĥĪ"
print(base256_decode(target))
附录 2:Dump 词表
from transformers import AutoTokenizer
if __name__ == "__main__":
tokenizer = AutoTokenizer.from_pretrained("llama3", trust_remote_code=False)
tokenizer.save_vocabulary(".")
# 此时当前目录下会产生 merges.txt 和 vocab.json