跳至内容
7/30第 7 章,共 30 章

构建一个 BPE Tokenizer:模型为什么数不清 R

用 60 行代码训练 byte-pair encoder,看它自己发现「the」,再测量为什么同一段话用西班牙语会多花 39 %。

本页内容

问一个能通过律师资格考试的模型,strawberry 里有多少个字母 r,它很可能回答两个。

常见解释是语言模型「不擅长计数」或「其实不理解」。两种说法都无法证伪,而且都不是原因。原因是机械性的,发生在模型运行之前,你一行就能看到:

TEXT
'strawberry'  ->  3 tokens  [496, 675, 15717]  ['str', 'aw', 'berry']

模型看到的不是十个字母。它看到的是三个数字。要数出 r,它必须仅凭 token 496 的身份就知道一个它看不见的字符串里有多少个 r,然后对 675 和 15717 做同样的事,再把结果加起来。你是在问它一个关于某种表示形式的问题,而它并不能访问那种表示。

本章要构建的,就是产生这三个数字的东西。大约六十行代码,和每个主流模型使用的是同一种算法。写完之后,十几个看似无关的怪现象都会归结到同一个原因。

把文本喂给网络有两种显而易见的方法,而它们都会失败。理解失败的原因很重要,因为失败定义了解法的形状。

单词。 按空格切分,给每个单词分配一个数字。英语有几十万种词形,而模型需要为每个词形准备一行 embedding,所以词表——以及必须为每个词表项输出分数的输出层——会变得巨大。更糟的是推理时会发生什么:模型在训练中从未见过的词没有数字。这就是 out-of-vocabulary 问题,常见补丁是把所有未知内容映射到一个 <UNK> token,这等于把信息丢掉。另外,「单词」并不是一个定义良好的概念:中文和日文不会在词之间加空格,德语还能把一个名词无限复合到另一个名词上。

字符。 没有 out-of-vocabulary 问题,词表也只有一百来个符号。但序列会变得很长,而第 9 章会说明 attention 成本会随序列长度平方增长。一篇 1000 词的文档大约有 5000 个字符——序列长度是必要长度的四到五倍,却要付出平方级代价。而且每个字符本身几乎不携带意义,所以前几层会被用来重新拼装 tokenizer 本可以完整交给模型的单词。

答案在两者之间:子词。常见词变成一个 token,罕见词拆成片段,而且永远不会有未知项,因为片段最终会退化到单个 byte。有意思的是,没有人手工设计这种切分。tokenizer 是训练出来的,训练数据和模型的数据类型相同,它通过统计哪些 byte 序列经常一起出现,学会哪些序列值得拥有自己的编号。

这个算法来自 1994 年,而且当时是一个压缩算法。Philip Gage 在 C Users Journal 上发表了它:通过反复用数据中不存在的一个 byte 替换最常见的相邻 byte 对,从而缩小文件。1 它在那里沉寂了二十二年,直到 2016 年 Sennrich、Haddow 和 Birch 将其改用于机器翻译,以解决 out-of-vocabulary 问题。2 如今,基本上每个大型语言模型都是这样阅读的。

训练循环反复执行四个步骤:

把训练文本编码为 UTF-8。每个 byte 值 0–255 都是一个 token。词表大小:256。

遍历序列,统计每一对相邻 token 出现的频率。

取胜出的那一对,为它铸造一个新的 token id,并替换序列中的每一次出现。词表增加一项;序列变短。

按顺序保存这对 token 以及它变成的 id。这个有序列表就是 tokenizer——它包含了之后编码新文本所需的一切。

下面是完整的训练器:

bpe.pyPYTHON
def get_stats(ids):
    counts = {}
    for a, b in zip(ids, ids[1:]):
        counts[(a, b)] = counts.get((a, b), 0) + 1
    return counts


def merge(ids, pair, idx):
    out, i = [], 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            out.append(idx)
            i += 2
        else:
            out.append(ids[i])
            i += 1
    return out


class BPE:
    def __init__(self):
        self.merges = {}
        self.vocab = {i: bytes([i]) for i in range(256)}

    def train(self, text, vocab_size):
        ids = list(text.encode("utf-8"))
        for i in range(vocab_size - 256):
            stats = get_stats(ids)
            if not stats:
                break
            pair = max(stats, key=stats.get)      
            idx = 256 + i                         
            ids = merge(ids, pair, idx)           
            self.merges[pair] = idx               
            self.vocab[idx] = self.vocab[pair[0]] + self.vocab[pair[1]]
        return ids

在 151,191 byte 的英文散文上运行它,并打印前十二次合并发生的过程。这一段值得慢慢读,因为没有人告诉算法任何关于英语的知识:

TEXT
merge   1: b'e' + b' '   -> b'e '     (occurred 4433 times)
merge   2: b' ' + b't'   -> b' t'     (occurred 3302 times)
merge   3: b'\xe2' + b'\x80' -> b'\xe2\x80'  (occurred 3247 times)
merge   4: b' ' + b'a'   -> b' a'     (occurred 2335 times)
merge   5: b' t' + b'h'  -> b' th'    (occurred 2253 times)
merge   6: b'i' + b'n'   -> b'in'     (occurred 2011 times)
merge   7: b't' + b' '   -> b't '     (occurred 1904 times)
merge   8: b'e' + b'r'   -> b'er'     (occurred 1813 times)
merge   9: b'd' + b' '   -> b'd '     (occurred 1703 times)
merge  10: b'o' + b'u'   -> b'ou'     (occurred 1554 times)
merge  11: b' ' + b's'   -> b' s'     (occurred 1467 times)
merge  12: b' th' + b'e '-> b' the '  (occurred 1270 times)

这个列表里有三件事值得指出。

第 12 次合并是单词「the」——连同它前面的空格和后面的空格,作为一个单元,在一个只会统计 pair 的循环第十二次迭代中被发现。没有人提供词典。它会出现,是因为这五个 byte 在英语里共同出现的频率高于其他任何五个 byte。

第 3 次合并根本不是文本。 \xe2\x80 是排版标点 UTF-8 编码的前两个 byte——em dash、弯引号。算法完全不知道 UTF-8 的存在,却刚刚重新发现了它结构的一部分,因为多 byte 编码按构造就是总会一起出现的 byte 序列。

大多数早期合并都涉及空格,而且空格通常在左侧。这就是实践中最令人困惑的行为之一的起源,我们很快会回到这里。

每一次合并都会让序列更短、词表更大。推进到什么程度是一个真实决策,而且可以测量——这里仍然是在同样的 151,191 byte 上:

词表大小结果 token 数压缩率(每个 token 的 byte 数)
300101,0651.50
51268,2492.22
1,02450,3693.00
2,04839,3063.85
4,09630,7574.92

收益递减,一眼可见。从 512 翻倍到 1024,每个 token 多买到 0.78 byte;从 2048 翻倍到 4096,买到 1.07——这里更好,只是因为这个语料足够小,较长的合并仍然有回报。在真实语料上,曲线会迅速变平。

更大的词表带来的成本不只是内存。每个 token 需要一行 embedding;更昂贵的是,模型的输出层必须在每一步为词表中的每个条目产生一个分数,所以最后的矩阵乘法会随词表大小缩放。真实模型介于 32,000 到 200,000 之间:GPT-2 使用 50,257,GPT-4 的 cl100k 使用 100,277,GPT-4o 的 o200k 大约又翻了一倍。趋势是向上,而原因在下一节。

下面是同一段话的译文,用 OpenAI 实际发布的 tokenizer 测量:

语言字符数token(cl100ktoken(o200ktoken/字符相比英语的额外开销
英语16431310.189
西班牙语16943360.254+39 %
俄语17878430.438+152 %
日语7279581.097+155 %

相同内容、相同含义,而使用 cl100k 时,俄语版本消耗的 tokens 是英语的两倍半。由于 API 按 token 计费,context window 也按 token 衡量,这就不是语言学趣闻了——它同时是一条预算项目、更短的有效 context window,以及更慢的响应;对所有不使用英语工作的人来说,三者同时发生。

机制来自训练数据。主要在英语上训练的 tokenizer,会把合并预算花在英语 byte 序列上。西班牙语共享拉丁字母,所以仍然能得到一些好处;俄语几乎得不到,因为西里尔字符在 UTF-8 中占两个 byte,而这些 pair 很少在训练语料中常见到足以获得一次合并。日语更糟:每个字符三个 byte,72 个字符变成 79 个 token——token 比字符还多

o200k 这一列说明这个问题可以解决,而且正在被解决。把词表翻倍并重新平衡训练数据,会把西班牙语的额外开销从 +39 % 降到 +16 %,把俄语从 +152 % 降到 +39 %。这才是词表持续变大的真正原因:不是为了压缩本身,而是因为上一代系统一直在悄悄让世界上一大部分人付额外费用。

编码,以及为什么合并顺序很重要

链接到此部分:编码,以及为什么合并顺序很重要

训练产生了一个有序合并列表。编码新文本时会重放它——而且必须按相同顺序重放,因为第 12 次合并会组合第 5 次和第 1 次合并的结果。如果按不同顺序应用,就会得到一种不同且错误的 tokenization,它无法匹配模型在训练中见过的任何东西。

bpe.py (continued)PYTHON
    def encode(self, text):
        ids = list(text.encode("utf-8"))
        while len(ids) >= 2:
            stats = get_stats(ids)
            # the pair whose merge came FIRST during training wins   
            pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))   
            if pair not in self.merges:
                break
            ids = merge(ids, pair, self.merges[pair])
        return ids

    def decode(self, ids):
        return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")

相比之下,解码很简单:查出每个 id 对应的 byte,拼接,然后按 UTF-8 解码。注意 errors="replace":模型可以输出一个在字符中间结束的 token 序列,这不是假设——当 streaming response 在一个 emoji 中间被截断时就会发生这种情况,所以 streaming API 会缓冲不完整的 byte,而不是逐 token 解码。

任何内容都能往返,这是 byte-level BPE 的承诺:

TEXT
'strawberry'                            -> 6 tokens, decode == original: True
'Alice was beginning to get very tired' -> 14 tokens, decode == original: True
'café — naïve — 日本語'                   -> 23 tokens, decode == original: True

一旦机制清楚,一组看似无关的抱怨就会变成同一个抱怨。

算术。 数字不会以任何一致方式切分:

TEXT
1234     -> 2 tokens  ['123', '4']
12345    -> 2 tokens  ['123', '45']
1000000  -> 3 tokens  ['100', '000', '0']
3.14159  -> 4 tokens  ['3', '.', '141', '59']
2024     -> 2 tokens  ['202', '4']

要把 123412345 相加,模型必须先弄明白 ['123','4']['123','45'] 是数字,而且它们的数字位以某种特定方式对齐——而这种对齐对每一对数字都不同。一个数的各位数字在不同数字之间并不位于相同位置。一些更新的 tokenizer 会强制把数字切成一致的三位一组,正是为了移除这个障碍;用这些 tokenizer 训练的模型在算术上可测地更好。

Python 缩进。

TEXT
'    x = 1'      -> 5 tokens  ['   ', ' x', ' =', ' ', '1']
'        x = 1'  -> 5 tokens  ['       ', ' x', ' =', ' ', '1']
'\tx = 1'        -> 4 tokens  ['\tx', ' =', ' ', '1']

四个空格和八个空格是不同的单个 token,而 tab 会和它后面的字符融合。缩进在 Python 中是语法,却被不一致地表示——这很大程度上解释了为什么模型过去会生成缩进有细微错误的 Python,也解释了为什么面向代码的 tokenizer 会为常见缩进长度添加显式 token。

拼写和反转。 原因和数 r 一样:让模型反转 strawberry,是在要求它重新排列三个不透明 id 内部的字母。模型靠训练中记住的拼写来做,而不是靠看见字母来做;所以常见词它做得好,罕见词就做得差。

Glitch tokens。 最醒目的例子是 SolidGoldMagikarp 以及一组类似字符串,它们曾让 GPT-2 和 GPT-3 表现得很诡异——拒绝重复它们、产生无关输出,有时还会辱骂用户。解释很平淡,而且直接来自 tokenizer 与模型分开训练这一事实:这些字符串在 tokenizer 的训练语料中很常见(它们是 Reddit 用户名),所以获得了自己的 token,但在模型训练语料中很少出现或根本没有出现。结果就是一行 embedding 被随机初始化后几乎从未更新。模型拥有一个它基本从未见过的符号,而它在那里的行为就取决于随机初始化碰巧是什么。

WordPiece 被 BERT 使用,它和 BPE 的区别在于选择规则:不是合并最频繁的 pair,而是合并最能提高训练数据似然的 pair——这会按各部分本身的常见程度进行归一化,所以两个罕见片段组成的 pair 可以胜过两个常见片段组成的 pair。

Unigram 来自 Kudo,方向相反:从一个很大的候选词表开始,迭代地删除那些删除后对语料似然伤害最小的片段。它还会给每种切分一个概率,从而允许对同一个字符串采样不同 tokenization,作为一种正则化。

SentencePiece 是大多数非英语模型使用的实现。它的贡献是把输入视为没有任何预 tokenization 的原始流,把空格编码成一个可见字符,这意味着它对不使用空格分隔单词的语言也能以完全相同的方式工作。底层可以运行 BPE,也可以运行 Unigram。

tokenizer 是文本与数字之间的有损接口,本章中的每一种奇怪行为,都是这个接口显露出来。值得说清楚的是,这种交换是有意为之:byte-level BPE 意味着没有任何输入无法表示,序列比字符级短四到五倍,常见词会完整到达。

代价是,模型的原子不是我们的原子。它用并非自己见过的语料上的频率统计选出的单位,推理它无法拼写的文本,并承担一种从未协商过的按语言而异的成本。

你现在有了一串整数。这就是第二部分其余所有内容的输入格式。

你还没有的是:为什么一个整数应该跟在另一个整数后面。下一章会介绍每个语言模型训练时使用的目标,而且它简单得惊人:给定目前为止的 tokens,预测下一个。这个单一目标——没有标签、没有标注,只有文本把自己的未来当作目标——把整个互联网变成训练数据,也让模型最早真正的表示从这里产生。

它还要求第 2 章中的概率链式法则完全正确,因为「一次预测一个 token 等价于建模整篇文档」这个主张是一种因式分解,而不是一个比喻。

第 8 章将讨论 autoregressive 目标、embeddings,以及模型第一次学会某种无人放进去的东西的地方。


Kudo, T. Subword Regularization: Improving Neural Network Translation Models with Multiple Subword CandidatesarXiv:1804.10959)介绍了 Unigram 模型;Kudo and Richardson, SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text ProcessingarXiv:1808.06226)是大多数多语言模型使用的实现;Schuster and Nakajima, Japanese and Korean Voice Search (ICASSP 2012) 是 WordPiece 的起源。Andrej Karpathy 的 Let's build the GPT Tokenizer 及其配套的 karpathy/minbpe 仓库,是本章代码的直接祖先,并且走得更远,包括 GPT-4 正则和 special-token 处理。Hugging Face LLM Course 第 6 章用完整示例并排讲解了这三种算法。

  1. Gage, P. A New Algorithm for Data Compression. The C Users Journal 12(2), pp. 23–38 (1994). Byte-pair encoding 作为压缩方案,比任何人将它用于语言模型早了二十二年。

  2. Sennrich, R., Haddow, B. and Birch, A. Neural Machine Translation of Rare Words with Subword Units. arXiv:1508.07909 (2015; ACL 2016). 这篇论文把 BPE 带入 NLP,动机是翻译中的 out-of-vocabulary 词。

  3. Radford, A., Wu, J., Child, R., Luan, D., Amodei, D. and Sutskever, I. Language Models are Unsupervised Multitask Learners (2019). 第 2.2 节介绍了 byte-level BPE,以及上文讨论的预 tokenization 正则。

准备好让 LIA 替你选模型了吗?

所有 AI 模型都在一处——今天就免费开始。