先划边界,再学合并:BPE 前面的那条正则
第 28 篇在整份莎士比亚文本上做了 20 次合并,第一条是 e 加空格,后面还有 t 加空格、逗号加空格、冒号加换行。
对 BPE 来说它们都合理:出现次数最多,就合并。
但同一个词后面接句号、感叹号或换行,就会长出不同的组合。词表要花编号记这些搭配,词本身的表示也跟着变。 GPT-2 在 BPE 前加了一步:先按字符类别切成块,合并不能跨过块的边界。开头的一个空格是刻意留下的例外。
原文 → 正则切块 → 每块的 UTF-8 字节 → 每块独立 BPE → 按原顺序拼接 ID这里的「块」不是 token。正则只规定哪些位置禁止合并;一块最终留下几个 token,由已经学到的规则决定。
GPT-2 的正则逐支看
历史 encoder.py 用第三方 regex 包,并把它命名成 re:
import regex as re
pattern = r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
re.findall(pattern, "Hello've world123 how's are you!!!?")# ['Hello', "'ve", ' world', '123', ' how', "'s", ' are', ' you', '!!!?']标准库 re 不支持 \p{L}、\p{N}。这里 L 是 Unicode 字母类别,也包括汉字,不是只认 A—Z;N 是数字类别,\s 是空白,\S 是非空白。
| 按顺序列出几条候选分支。匹配从左到右推进,在当前起点按分支顺序尝试;不是把所有分支结果拼在一起。
| 分支 | 作用 | 例子 |
|---|---|---|
's|'t|'re|'ve|'m|'ll|'d | 先把缩写后缀单独拿出来;只认小写 | how + 's |
?\p{L}+ | 一串字母,前面可以带 一个普通空格 | ' world' |
?\p{N}+ | 一串数字,长度不限;同样可以带一个空格 | ' 1234567' |
?[^\s\p{L}\p{N}]+ | 非空白、非字母、非数字的一串符号 | '!!!?' |
\s+(?!\S) | 尽可能拿空白,但匹配末尾不能紧跟非空白 | 四个缩进空格中的前三个 |
\s+ | 兜底,拿剩余空白 | 没被前面分支接住的空白 |
? 里面是一个字面的空格,加 ? 表示可有可无;它不是「任意多个空白」。Tab 不会被这部分带进单词。
四个缩进空格,为什么变成三加一
把空格记作 ·:
输入:····if结果:['···', '·if']到第一个空格时,前三类分支都不能跳过四个空格去找字母。\s+ 先贪婪地吃掉四个,后面到了 i;
(?!\S) 要求当前位置后面不是非空白,此时失败。
于是 \s+ 回退一个空格,只吃三个。后面还剩一个空格,它不属于 \S,断言成功。
下一次从第四个空格开始, ?\p{L}+ 正好吃到 ' if'。
🔑
(?!\S)检查后面,不消费后面。 它让前一块留下一个空格,交给后面的单词。行末若后面没有非空白,整串空白都能被拿走。
这一点也解释了为什么「GPT-2 的缩进每个空格一个 token」不是正则直接规定的: 正则已经把前三个空格放在同一块里,只是 GPT-2 的词表没有学会把它们合并。
中文不是一个字一块
re.findall(pattern, "分词是大模型许多怪现象的根源。")# ['分词是大模型许多怪现象的根源', '。']中文没有词间空格,整串汉字都属于 \p{L}。正则不会先按词或按字分开;转成 UTF-8 以后,BPE 仍然可能把一个汉字拆成几段,也可能合出跨汉字的字节片段。
字符类别边界、汉字边界、token 边界是三回事。
cl100k_base 改的是边界规则
视频中用于对照 GPT-4 的 cl100k_base 正则是:
pattern = r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"""这是视频里的写法;本地 tiktoken 0.14.0 的 _pat_str 使用了占有量词等优化写法。下面比较的是这些例子的切块行为,不能靠字符串长得不同就判断结果不同。
四处变化:
(?i:...)让缩写忽略大小写。HOW'S在 GPT-2 下是HOW、'、S,这里是HOW、'S。\p{N}{1,3}把数字按最多三位分组,数字分支也不再带前导空格。1234567先切成123、456、7。- 字母前允许一个「非换行、非字母、非数字」字符,不限于空格。
.append、(y可以各自成块。 - 标点分支可带末尾换行,另加空白加换行分支。
)\n\n\n可以留在一起。
最后两处直接影响代码文本:
text = "x = self.append(y)\n\n\nreturn x"| 分词器 | 最终 token 的内容(· 是空格) | 数量 |
|---|---|---|
| gpt2 | x、·=、·self、.、append、(、y、)、\n\n、\n、return、·x | 12 |
| cl100k_base | x、·=、·self、.append、(y、)\n\n\n、return、·x | 8 |
允许留在一块,只是合并的前提;词表里真有对应的合并,最后才会成为一个 token。
数字切块更整齐,不等于 token 数更少
1234567 在 GPT-2 里原本是一个正则块,训练出来的词表把它切成 123 | 45 | 67;
cl100k_base 先划定 123 | 456 | 7 三块,每块恰好又都是一个 token。两边都是三个 token,数位边界不同。
127 + 677 = 804 两边也都是七个 token:
gpt2: 127 | ·+ | ·6 | 77 | ·= | ·8 | 04cl100k: 127 | ·+ | · | 677 | ·= | · | 804不能只数 token 个数,还得看模型看见的是哪些块。
多空格 token 来自训练
两条正则切 " hello world!!!" 的结果完全相同:三个空格、带一个空格的 hello、带空格的 world、!!!。
用公开普通词表检查「内容全是空格,且至少两个空格」的条目:
| 多空格条目 | 这段文字的最终 ID | |
|---|---|---|
| gpt2 | 0 | [220, 220, 220, 23748, 995, 10185] |
| cl100k_base | 85 | [262, 24748, 1917, 12340] |
这里 85 是在本地所用 cl100k_base 词表上的计数。GPT-2 的前三个空格各占一个编号,cl100k_base 把它们合成 262。
切块没变,块内学到的合并变了。
训练:块内统计,全局选一对
第 28 篇的 get_stats 返回一个新字典。现在让它可以往同一个字典里累加:
def get_stats(ids, counts=None): counts = {} if counts is None else counts for pair in zip(ids, ids[1:]): counts[pair] = counts.get(pair, 0) + 1 return counts默认值用 None,不用可变的 {},否则不同调用会共用上一次留下的字典。
接下来仅把「一串 ID」改成「每块一串 ID」:
chunks = re.findall(pattern, text)ids = [list(chunk.encode("utf-8")) for chunk in chunks]merges = {}vocab = {i: bytes([i]) for i in range(256)}
for i in range(vocab_size - 256): stats = {} for chunk_ids in ids: get_stats(chunk_ids, stats) if not stats: break pair = max(stats, key=stats.get) idx = 256 + i ids = [merge(chunk_ids, pair, idx) for chunk_ids in ids] merges[pair] = idx vocab[idx] = vocab[pair[0]] + vocab[pair[1]]merge 还是上一篇从左到右替换所有不重叠出现的函数。max 遇到次数并列,取字典中先插入的那对;语料遍历顺序会影响这类并列的选择。
🔑 不是每块训练一张表。 每块各自数相邻对,次数累加到同一个
stats;在全语料上选一对,再在每块内部替换。 因而共享一个词表、一张合并表,但块末尾和下一块开头从来不会组成候选。
十五个字节,三次合并
the cat the hat 被切成 the、 cat、 the、 hat 四块:
| 轮次 | 全局选中的对 | 新编号 / 内容 | 各块合并后的 ID |
|---|---|---|---|
| 1 | (116,104),th 出现 2 次 | 256 = b'th' | [256,101] / [32,99,97,116] / [32,256,101] / [32,104,97,116] |
| 2 | (256,101),the 出现 2 次 | 257 = b'the' | [257] / [32,99,97,116] / [32,257] / [32,104,97,116] |
| 3 | (97,116),at 出现 2 次 | 258 = b'at' | [257] / [32,99,258] / [32,257] / [32,104,258] |
15 个字节最后剩 9 个 token,压缩率 15 / 9 ≈ 1.67。
如果整段不切块,第三轮会选 the 加空格,跨过单词和空白类别的边界。
同一份莎士比亚,只加边界这一处变化
本地 input.txt 共 1,115,394 字节。相同的 20 次合并:
| 正则块数 | 压缩率 | 最先学到的几条 | |
|---|---|---|---|
| 不切块 | 1 | 1.26 | e·、th、t·、s·、d·、,· |
| GPT-2 正则 | 297,833 | 1.22 | ·t、he、·a、ou、·s、·m |
切块版第一条 (32,116) 出现 23,837 次,第二条 (104,101) 出现 18,203 次;
第十二条把这两个新符号合成 ' the',当轮出现 8,684 次。
不切块的词尾空格消失了,带词首空格的组合留下来。
压缩率略降,是禁止跨界合并的直接代价。换来的是边界约束,不是保证每次都少几个 token。 训练的频次字典每轮重新建,编码新文本时不用它,只按固定 rank 选规则。
把两种差别放在一张图上
图里的 GPT-4、GPT-4o 分别指视频和对照实验中的 cl100k_base、o200k_base,不是给所有当前模型指定同一套分词器。
cl100k_base 有 100,256 个普通条目;图中 100,277 是其编号空间大小,包含特殊编号与空位,并不是 100,277 条普通合并。
o200k_base 还会按字母大小写变化增加边界:
HelloWorld getElementById 在 cl100k_base 下是四个 token,在 o200k_base 下是六个。
同一句中文则从 19 个降到 14 个;GPT-2 要 32 个。新分词器不保证任意输入都更短,边界与词表得一起看。
遇到分词结果不符合预期,检查顺序是:
- 用
findall打印块,确认是在哪一步先被禁止合并。 - 在每块内部查规则与 rank,确认候选有没有、有没有被更早的规则抢走。
- 查完整 token 的原始字节,别把字节片段误当成完整字符。
下一篇进入 GPT-2 的 encoder.py。这里仍用原始字节值当基础 ID;
那份代码会先把字节换成可逆的字符别名,再做字符串 BPE —— 所以词表里的 Ġ、ä 和 ½,要从字节映射这一步解释。