跳到主要内容

数据截至 (上游 commit 1acefe89412b)

02 · regex 预切分与 GPT-4 模式

这一章讲什么: regex.py 给裸 BPE 加的第一层真实世界复杂度——预切分(pre-tokenization)。读完你会能讲清:为什么 GPT 系分词器绝不允许合并跨过「类别边界」、GPT4_SPLIT_PATTERN 七个分支各自在切什么、以及「数字最多三位一段」这类著名怪癖到底从哪一行 pattern 里长出来。


1. 它要解决的小问题

裸 BPE 只有一个裁判:频率。频率不管文法,于是会焊出一些「不该存在」的 token:

  • 单词被标点污染:语料里 "dog."、"dog,"、"dog!"、"dog" 频繁共现,于是它们各自被焊成不同的 token——词表被同一单词的标点变体吃掉一大块。
  • 数字行为失控:不限长地把数字焊在一起,"1000" 是一个 token、"10000" 可能拆得七零八落——数字的切分取决于训练语料里哪些串常见,毫无规律。
  • 空白不稳定:空格、换行、缩进各自乱焊,"def " 和 "def" 是不同 token。

GPT 系列从 GPT-2 开始的解法:BPE 之前先切一刀。用一个正则把文本按字符类别(字母、数字、标点、空白)切成小块,BPE 只在块内跑——块间永不合并,上面的问题从结构上被消灭。


2. 直觉:先划「选区」,再在选区里合并

把预切分想象成选举划区:合并只能在同一个选区内发生

  • "dog" 和 "." 永远分属两个区,"." 再常跟着 "dog" 出现,也焊不进单词 token。
  • 数字区里只允许数字互相焊;GPT-4 更狠,直接把选区长度卡死:数字每最多 3 位划一个区。
  • 每个区独立做 BPE,最后把各区的 id 序列拼接起来。

代价是 pattern 本身成了分词器资产的一部分:训练用哪把刀切,编码就必须用同一把刀,否则合并表对不上文本。minbpe 的处理是把 pattern 字符串写进 .model 文件随模型走(minbpe/base.py:109),load 时原样读回(minbpe/base.py:152)。


3. GPT4_SPLIT_PATTERN 逐项拆解

常量定义在 minbpe/regex.py:19(GPT-2 版在 minbpe/regex.py:18,对照看):

'(?i:[sdmt]|ll|ve|re)|[^\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+

| 分隔的七个分支按顺序尝试,re.findall 从左到右扫描文本、逐个吐出匹配块。逐分支白话:

#分支白话含义匹配示例
1`'(?i:[sdmt]llve
2[^\r\n\p{L}\p{N}]?+\p{L}+一串 Unicode 字母,可带一个非字母非数字非换行的前缀字符(通常是空格)hello world(abc
3\p{N}{1,3}数字,1~3 位一段123;12345 切成 123+45
4 ?[^\s\p{L}\p{N}]++[\r\n]*一串标点/符号,可带一个空格前缀,可吞尾随换行!!! !!!
5\s*[\r\n]换行符连同它前面的空白行尾缩进+\n
6\s+(?!\S)「后面没有非空白字符」的空白串——文末或行尾的多余空白连续空格除最后一个之外的部分
7\s+兜底:其余一切空白单个空格、制表符

两个记号要认出:\p{L} 是 Unicode 字母类(任何语言的字母,含汉字谚文),\p{N} 是 Unicode 数字类。

拿一句话走一遍(findall 的产出,按各分支语义推出):

原文片段命中分支类别
I don't have 1,000,000 dollars!!!
I2字母
don2(带空格前缀)字母
't1缩写后缀
have2字母
13数字(逗号断开,只取到 1 位)
,4标点
0003数字,满 3 位
,4标点
0003数字
dollars2字母
!!!4标点

两个值得盯住的后果:

  • 1,000,000 被切成 1 , 000 , 000 五段——数字串被逗号和「3 位上限」双重切碎。这就是「LLM 处理大数字很怪」的分词根源。
  • 't 独立成块——缩写后缀不跟词干焊死,词表不必为每个动词的 's/'ll 变体各存一份。

4. 图示:编码时块与 BPE 怎么接力

encode_ordinary 的整体流向,从左到右读:

原文本 ──findall(pattern)──► [块1][块2][块3]…[块N]
│ │ │
▼ ▼ ▼ 每块独立:
各自 UTF-8 字节化
各自跑 _encode_chunk(块内 BPE)
│ │ │
▼ ▼ ▼
ids1 + ids2 + ids3 + …(按序拼接)

关键只有一句:块与块之间没有任何合并机会——merge 的视线从未越过块边界,所以词表里不可能存在跨类别的 token。


5. 原理演示:切块 + 块内 BPE(示意代码)

与真实实现一一对应(# 示意,非源码):

def train(text, vocab_size): # 训练:先切再统
chunks = re.findall(pattern, text) # 同一把刀
ids = [list(ch.encode("utf-8")) for ch in chunks] # 每块一个字节流
for i in range(vocab_size - 256):
stats = {}
for chunk_ids in ids:
get_stats(chunk_ids, stats) # 跨块累加频次……
pair = max(stats, key=stats.get)
ids = [merge(c, pair, 256 + i) for c in ids] # ……但合并逐块进行
merges[pair] = 256 + i

def encode(text): # 编码:同刀切,逐块编
ids = []
for chunk in re.findall(pattern, text):
ids.extend(bpe_encode_chunk(chunk.encode("utf-8"), merges))
return ids

注意训练端那个微妙处:统计是跨块累加的(所有块里的 "th" 算同一个对的频次),但合并永远逐块执行——频率裁判是全球的,焊接施工是本地的。


6. 真实实现

6.1 两个模式常量

minbpe/regex.py:18-19 同时保留 GPT-2 与 GPT-4 两把刀,差异本身就是教材:

维度GPT-2(GPT2_SPLIT_PATTERN)GPT-4(GPT4_SPLIT_PATTERN)
缩写`'(?:[sdmt]ll
字母 ?\p{L}+,只允许空格前缀[^\r\n\p{L}\p{N}]?+\p{L}+,任意一个非字母非数字非换行的前缀字符(空格、括号等均可)
数字 ?\p{N}+,不限长、可带空格\p{N}{1,3},限 3 位、不带空格
空白/换行两个分支细化为四个分支(标点吞换行、行尾空白单列)

6.2 构造与训练

RegexTokenizer.__init__(minbpe/regex.py:24-34):不传 pattern 就默认 GPT-4 版,立即 re.compileself.compiled_pattern

RegexTokenizer.train(minbpe/regex.py:36-70)与 BasicTokenizer.train 几乎逐行同构,差别只在两处:

  • 入口先 re.findall(self.compiled_pattern, text) 切块,ids 从「一条整数流」变成「一个整数流的列表」(minbpe/regex.py:41-44)。
  • 统计时把 stats dict 传进 get_stats(chunk_ids, stats) 跨块累加(minbpe/regex.py:52-54)——base.py:13 那个可选的 counts 参数就是为这里准备的;合并时列表推导逐块 merge(minbpe/regex.py:60)。

6.3 编码两级:encode_ordinary_encode_chunk

encode_ordinary(minbpe/regex.py:111-121):同一把刀切块,逐块编码后 extend 拼接。

_encode_chunk(minbpe/regex.py:92-109):块内的 BPE 重演循环——与 BasicTokenizer.encode(minbpe/basic.py:61-73)逐行相同,包括 inf 技巧和 pair not in self.merges 的终止检测。教学上这是刻意的重复:让你看清「regex 层只加了切块,核心循环一个字符都没改」。


7. 关键细节与坑

  1. 必须用第三方 regex 库,标准库 re 不够。 minbpe/regex.py:12import regex as re:\p{L}/\p{N} 这类 Unicode 类别、以及 ?+/++ 占有量词(possessive quantifier,匹配后不吐回、不回溯),标准库 re 都不支持。requirements.txt:1 唯一为此引入的依赖就是 regex
  2. 占有量词不是装饰。 ?+++ 锁死回溯,保证「前缀字符一旦被吃掉就绝不退还」——这让每个文本位置的分支选择是确定性的,切分结果与 tiktoken 的 Rust 实现逐块一致(依据:pattern 语义 + 对拍测试 tests/test_tokenizer.py:62-69 的存在;具体回溯差异代码里看不出更多)。
  3. BasicTokenizerRegexTokenizer 是兄弟,不是父子。 两者都直接继承 Tokenizer(minbpe/basic.py:15minbpe/regex.py:22)。Basic 不是 Regex 的「关掉 pattern」模式,而是刻意保留的教学纯净版——对照读能看出「加切分」到底改了哪几行。
  4. pattern 与 merges 是连体资产。 换 pattern 而不重训,合并表立刻失效(块切法变了,对不上)。所以 pattern 进 .model 文件(minbpe/base.py:109),load 原样读回(minbpe/base.py:152)。
  5. encode 的默认入口不是 encode_ordinary RegexTokenizer.encode 先处理 special tokens 再回落到 encode_ordinary(minbpe/regex.py:123-146)——那是 03 章的内容;本章只需记住:没有 special tokens 时,encodeencode_ordinary
  6. 怪癖即 pattern。 数字三位一段(\p{N}{1,3})、空格跟着后面的单词走(分支 2/6 的配合)、缩写后缀独立(分支 1)——这些常被当成「模型的毛病」,其实全是这一条 110 字符的 pattern 决定的。改 pattern 重训,怪癖就换人。

上一章: 01 · BPE 算法本体 · 下一章: 03 · special tokens 与 GPT-4 精确复刻——控制符怎么不进 BPE 也能编解码,以及 minbpe 怎么把 tiktoken 的参数「逆向」成自己的合并表。