分词器 — 模型看到的不是文字
一句话总结
模型看到的既不是字符,也不是单词,而是由 token id 组成的序列。决定这种转换规则的是分词器(tokenizer),采用什么规则会同时左右成本、上下文上限和多语言质量。token 数量无法用字符数估算,必须用该模型自己的分词器来计数。
为什么需要它
假设某个团队在月底收到了 API 账单。同一份公告分别用英文版和韩文版做了摘要,字符数相近,韩文版的输入 token 却多出好几倍。同一天还有人报告,只有韩文文档因为触及上下文上限而被截掉了后半部分。两件事的原因相同:模型把韩文句子切得更碎。要理解为什么会这样,得先看分词器是怎样做出来的。
第一种尝试是按单词切分。问题在于词汇是无限的。词典中没有的单词都会变成未知 token,信息随之丢失。在韩语这类会附加助词和词尾的语言里,仅同一词干的各种变形就足以让词汇量爆炸。
第二种尝试是按字符切分。词汇表变小了,但序列变得太长。在上下文长度受限的模型中,长度就是成本,而且相距很远的关系也更难学习。
BPE(Byte Pair Encoding) 是在两者之间找到的答案。它从字符出发,反复把经常相邻出现的一对合并成一个。常用的词整体成为一个 token,罕见的词则保留为若干片段。它在固定词汇量的同时,几乎消除了未知 token。
它如何工作
训练过程简单得令人惊讶。
- 把语料切分成单词,再把每个单词变成字符列表。为了不丢失词边界,在末尾加上一个特殊标记。
- 在全体数据中找出最常相邻出现的一对。频率相同时,按事先定好的规则(例如字典序)选出一对。
- 把这一对合并成一个 token,并按顺序记录这条合并规则。
- 重复第 2~3 步,直到达到期望的词汇量。最终词汇量等于基础字符数加上合并次数。
平局规则看似琐碎,却至关重要。如果随意打破平局,即使用同一语料训练,每次运行得到的合并顺序也会不同,其后所有规则和 id 都会错位。分词器必须可复现,才是能用的部件。
编码就是把学到的合并规则严格按照记录的顺序应用一遍。用一个小例子就能看出顺序为什么重要。假设合并规则按下面的顺序学得。
merges (in order): 1) e r 2) w e
word: l o w e r _
apply 1 then 2: l o w er _
apply 2 then 1: l o we r _
同样两条规则,只要调换应用顺序,得到的 token 就不同。训练时 er 先被合并出来,这本身就说明“在这份语料里合并成 er 更常见”,所以编码也必须遵循这个顺序,才能得到与训练时相同的切分。解码则反过来很简单:把 token 拼接起来,再把词尾标记还原成空格即可。
实际模型的分词器会在这个骨架上再加几样东西。
- 字节级 BPE。 若把全部 Unicode 字符作为基础词汇表,规模会过大,因此改用 256 个字节值作为基础词汇表。任何字符串都能用字节表示,所以不会出现未知 token。GPT-2 的 50,257 个词汇由 256 个字节、50,000 次合并和 1 个文本结束特殊 token 组成。
- SentencePiece。 把空格替换成
▁这个字符并放进词汇表。对不用空格分词的语言也能直接使用,解码时只需把 token 拼接起来,再把▁换回空格。 - WordPiece。 BERT 系列使用的方式,给词中间的片段加上
##标记,并且不是按频率、而是按似然来选择要合并的一对。
韩文成本问题由此得到解释。在 UTF-8 中,一个韩文音节占 3 个字节(例如 가 是 EA B0 80)。如果字节级分词器在训练语料中没有见过足够多的韩文,把韩文音节合并为一个 token 的规则就会很少,这样的音节在最坏情况下会保留为三个字节,也就是三个 token。英文单词整体成为一个 token 的同时,一个韩文音节却变成了好几个 token。分词器也无法随意更换:词汇表一变,嵌入矩阵就变,模型就得重新训练。
实际项目中会出什么问题
用别的分词器计数。 估算成本或计算上下文预算时,人们常用一个顺手的库来统计所有模型的 token。每个模型的词汇表不同,所以数字会错,症状表现为“算好在预算之内,输出却被截断”。token 数应当用要调用的那个模型的分词器来数,或者记录 API 在响应中返回的用量加以校正。
Unicode 规范化混在一起。 同一个韩文字符有两种表示方法:可以写成一个完整音节(NFC),也可以把初声、中声、终声字母拼接起来写(NFD)。屏幕上看起来一模一样,码位数量却多出两三倍,token 数随之增加,字符串比较和检索也会悄无声息地失败。经过旧版 macOS 文件系统的文件名,或某些文档转换工具输出的文本,就可能是这种形态。如果“同一句话的 token 数特别多”,先怀疑规范化。
特殊 token 字符串混入输入。 如果用户粘贴的文本里含有文本结束标记之类特殊 token 的字符串,就会出现问题:该把它当作普通字符还是控制 token?一旦被解释为控制 token,输入可能表现得像在那里被截断了一样。因此 tiktoken 默认遇到这样的输入会报错。在关闭这个报错之前,先弄清它为什么被拦截。
惊讶于模型数不清字母。 token 边界与字符边界不同。模型在“这个单词的第三个字母”或按位计算的问题上出错,不是因为模型笨,而是它根本看不到那个单位。这类工作不要交给模型,用代码处理。
空白和格式也在消耗 token。 缩进、连续换行、重复的 Markdown 符号占用的 token 比想象中多。精简提示词时要看 token 数而不是字符数,并把 token 直接打印出来,确认哪一部分消耗最多。
如何确认
如果自己做了分词器或更换了分词器,务必检查三件事。
import unicodedata
text = open("corpus.txt", encoding="utf-8").read()
print(unicodedata.is_normalized("NFC", text)) # False means mixed forms
print(len(text), len(text.encode("utf-8"))) # characters vs bytes
# round trip: decode(encode(x)) must equal x for every line
# compression: characters / tokens, compare across languages
第一,往返检查。 编码再解码后的结果必须与原文一字不差。如果漏掉了词尾标记,就无法还原空格,这里会立即暴露出来。哪怕只有一行不同,也要用 diff 比较那一行,找出是在哪个 token 上错位的。
第二,压缩比。 即字符数除以 token 数。数值越大,说明一个 token 承载的字符越多。把内容相同的英文版和韩文版放在一起测量,就能用数字说明韩文成本为什么更高。
第三,阅读合并规则的开头部分。 最前面的几十条合并是语料中最常见的片段。要亲眼确认是否出现了助词、词尾这类预期中的片段,以及是否混入了奇怪的片段(空白字符、控制字符)。看到奇怪的片段,说明预处理出了问题。
接下来阅读
紧接着的理论课会讲把文本变成向量的嵌入,随后在第一个实验中从零实现 BPE。统计字符频率,构建基础词汇表,连平局规则也一起遵守地学习 120 条合并规则,再按记录的顺序应用这些规则对文档编码,最后只用 id 序列还原原文,完成往返检查并计算压缩比。不使用任何库,只用 Python。