词表里的 Ġ 是什么:沿着 GPT-2 源码走完一次编解码
GPT-2 的词表里有 Ġthe、ä½ 这样的字符串。它们不是乱码后的原文,也不是特殊 token。
这份代码把每个字节换成一个可逆的字符别名,再在别名字符串上执行 BPE。
整篇用同一个输入 "Hi 你!":
正则块 UTF-8 字节 字节别名'Hi' [72,105] 'Hi'' 你' [32,228,189,160] 'Ġä½ł''!' [33] '!'
最终模型 ID:[17250, 220, 19526, 254, 0]这里「你」占两个 ID,前面的空格另占一个。不是一个字符固定对应一个 token。
初始化:两份资源,几张职责不同的表
get_encoder() 加载的两个文件是分词器的训练成果,不是 Transformer 的权重:
| 资源 | 存什么 | 用在哪里 |
|---|---|---|
encoder.json | 完整符号字符串 → 模型 token ID | BPE 完成后查编号;反过来查内容 |
vocab.bpe | 每行左右两个符号,行序给出 rank | 编码时决定先合并哪一对 |
源码读取规则时用 split('\n')[1:-1],跳过版本头和最后空行,再对剩下的规则 enumerate。
这依赖官方文件末尾有换行:换一份自制文件而不保留末尾换行,这种切片会把最后一条规则一起删掉。
加载后 Encoder 手里的对象:
| 对象 | 方向 / 作用 |
|---|---|
encoder / decoder | 完整符号 ↔ 模型 ID |
byte_encoder / byte_decoder | 原始单字节值 ↔ 一个字符别名 |
bpe_ranks | 相邻符号对 → 合并优先级 |
pat | 在原文上切正则块 |
cache | 原始别名块 → 已完成的 BPE 返回字符串 |
errors | UTF-8 解码错误策略,默认 'replace' |
最容易混淆的是前两行:字节到别名和完整符号到 ID是两套映射。
ASCII 字节 97 的别名是 a,但 encoder['a'] 是 64;字节 33 的别名是 !,模型 ID 却是 0。
rank 与 ID 恰好相差 256,但用途不同
官方规则开头:
| rank | 左 + 右 | 合并内容的模型 ID |
|---|---|---|
| 0 | Ġ + t | 256 |
| 1 | Ġ + a | 257 |
| 2 | h + e | 258 |
| 6 | Ġt + he | 262 |
rank 3—5 只是没有列在这张小表里。对本地官方表的全部 50,000 条规则核对,合并项都满足:
encoder[left + right] == 256 + rank但 rank 回答的是「先用哪条规则」,ID 回答的是「模型里的哪个 token」。
基础条目没有对应的合并 rank,特殊 ID 也不从这条公式推出来。不能把两者当作同一概念。
bytes_to_unicode:名字容易误导的一张表
它不是拿原始字节做 UTF-8 解码。它建立一个 256 项的双射:一个字节值,恰好换成一个 Unicode 字符。 反表可以逐字符恢复原始字节。
先保留三个区间里可以直接显示的字符:
bs = list(range(ord("!"), ord("~") + 1)) # 33—126:94 个bs += list(range(ord("¡"), ord("¬") + 1)) # 161—172:12 个bs += list(range(ord("®"), ord("ÿ") + 1)) # 174—255:82 个cs = bs[:]共 94 + 12 + 82 = 188 个字节沿用同数值的码点。剩下 68 个是 0—32、127—160 和 173,
按字节值从小到大,依次映射到码点 256 + n。源码最后把 cs 变成 chr(c) 再 zip。
| 原始字节值 | 别名 | 为什么 |
|---|---|---|
| 0 | Ā(U+0100) | 第一个被挪走的字节 |
| 32 | Ġ(U+0120) | 第 33 个被挪走的字节,256 + 32 |
| 160 | ł(U+0142) | 在控制 / 空白区间里,继续占用新码点 |
| 189 | ½ | 落在保留区间,码点不变 |
| 228 | ä | 落在保留区间,码点不变 |
映射让空白和控制字节不必以原样出现在词表字符串里,也不会和 BPE 返回格式的分隔空格撞在一起。
无参数的 bytes_to_unicode() 用 lru_cache 缓存;它缓存的是映射表,和每个 Encoder 的 self.cache 不是一回事。
Ġ 不等于原文里真的写了 Ġ
原文一个空格:UTF-8 [32] → 别名 Ġ原文字符 Ġ: UTF-8 [196,160] → 两个字节分别映射原文字符 你: UTF-8 [228,189,160] → ä½ł看 Ġthe 可以把 Ġ 读作「原始空格的别名」,但不能把整份词表当成经过乱码解码的原文。
这里用 Unicode 字符装字节;这些字符的语义不参与算法。
BPE 有两层循环,各自只管一件事
bpe(token) 的输入是一个正则块的别名字符串,不是整篇原文。
先命中缓存就直接返回;否则 word = tuple(token),get_pairs 列出相邻对。
单符号没有相邻对,直接返回原块,这条提前返回路径不会写缓存。
进入循环后:
- 外层选当前相邻对中 rank 最小的一种对。
- 内层固定这对,从左到右扫描整个
word,替换它的全部不重叠出现。 - 整块扫描完才用
new_word更新word,重新列相邻对,再选下一轮规则。
下面是对应控制流的精简写法;完整历史源码出自 openai/gpt-2 的 src/encoder.py(MIT 许可):
while True: # 外层:选规则 pair = min(pairs, key=lambda p: bpe_ranks.get(p, float("inf"))) if pair not in bpe_ranks: break first, second = pair new_word = [] i = 0 while i < len(word): # 内层:扫描整块 try: j = word.index(first, i) except ValueError: new_word.extend(word[i:]) break new_word.extend(word[i:j]) i = j if i < len(word) - 1 and word[i + 1] == second: new_word.append(first + second) i += 2 else: new_word.append(word[i]) i += 1 word = tuple(new_word) if len(word) == 1: break pairs = get_pairs(word)历史源码这里是裸 except;上面为了只表达「找不到 first」的分支,写成 except ValueError。
为什么不能合并完一处就换规则
人工规则 a+a 最早,输入 aaa:
word: a | a | a本轮输出:aa | a前两个旧位置已经被消费,第三个 a 不能再借用第二个位置。合出来的 aa 写在 new_word,
内层仍然读旧 word,所以新符号只能下一轮参与。
j = word.index(first, i) 找到的只是 first,不是整对。邻居不等于 second 时,当前符号必须原样留下。
word[i:j] 不包含 j,因此复制中间部分也没有把 first 提前消费掉。
三种「结束」不能混在一起
| 条件 | 结束什么 | 接下来 |
|---|---|---|
扫描到末尾,或找不到剩下的 first | 一轮内层扫描 | 接收 new_word,再判断 / 选规则 |
| 当前相邻对都没有规则 | 整个 BPE 合并循环 | 返回现有多个符号 |
word 只剩一个符号 | 整个 BPE 合并循环 | 返回这个符号 |
每个有效外层轮次至少让符号数减少 1。起点 N 个符号,最多 N−1 个有效合并轮次;
一次可能同时合并多处,所以实际轮次更少。这是轮次数的上界,不是整个函数的时间复杂度:每轮还要扫描、列候选、查 rank。
让每一步在动画里发生
默认人工例子 aaababcabxyz 有九条规则,实际用了七轮。规则不是从这个字符串重新训练出来的:
| 轮次 | 选中的对 | rank |
|---|---|---|
| 1 | a + a | 0 |
| 2 | a + b | 1 |
| 3 | ab + c | 2 |
| 4 | x + y | 3 |
| 5 | xy + z | 4 |
| 6 | aa + ab | 5 |
| 7 | aaab + abc | 6 |
最后是 aaababc | ab | xyz。表里的 b+c、c+a 明明存在,后来却没机会被选中:
更早的规则先把它们的成员消费掉了。第 28 篇「表里有却用不上」在真实循环里就是这样发生的。
切到官方 internationalization:全表有 50,000 条,当前输入实际用的 rank 是
3, 5, 7, 9, 26, 39, 85, 97, 272, 503, 608, 1378, 22868, 45353,共十四轮。
最终 international | ization,编号 [45609,1634]。不是从 rank 0 开始把整张表依次扫十四行。
动画另外保留三个边界例子:
aaaaaaa展示重叠候选如何只消费不重叠位置。- 单符号
a不进入循环,也不写缓存。 - 重复输入同一个块,直接命中之前真实执行留下的缓存。
动画使用源码实际运行的轨迹;教学例子与官方例子用的规则表不同,界面会明确标注。
bpe 返回的空格不是原始空格
合并结束后:
word = " ".join(word)self.cache[token] = wordreturn word这里返回值仍然是字符串。"Ġä½ł" 的最终符号是 Ġ、ä½、ł,所以 bpe 返回 "Ġ ä½ ł"。
encode 再 split(' '),查 encoder,得到 [220,19526,254]。
原文空格已经映射成 Ġ,返回格式里的普通空格只用来分隔符号,拆开后丢掉;它本身不会多产生一个模型 ID。
for token in re.findall(self.pat, text): token = "".join(self.byte_encoder[b] for b in token.encode("utf-8")) bpe_tokens.extend(self.encoder[s] for s in self.bpe(token).split(" "))把 Hi、 你、! 的结果按块顺序摊平,得到开头那五个编号。
decode:先拼齐字节,最后只解码一次
text = "".join(self.decoder[token] for token in tokens)raw = bytearray(self.byte_decoder[c] for c in text)return raw.decode("utf-8", errors=self.errors)先按 ID 查完整别名字符串并连起来;再逐别名字符恢复字节;最后统一 UTF-8 解码。 解码不执行 BPE,也不用反向重放合并规则。
19526 存的是 ä½,原始字节 [228,189];254 存的是 ł,原始字节 [160]。
二者分开解码都不是完整 UTF-8 字符,默认各变成 �。先拼成 [228,189,160] 才是 你。
所以不能逐 token 解成字符串,再把字符串拼起来。
错误也要区分:
- ID 根本不在
decoder:查表抛KeyError。 - ID 有效,但整串原始字节不是合法 UTF-8:由
errors决定替换或抛UnicodeDecodeError。
历史源码不会自动识别特殊标记
encoder['<|endoftext|>'] 确实是 50256,但这份 encode 没有 allowed_special 分支。
原文直接写 "<|endoftext|>",结果是:
[27, 91, 437, 1659, 5239, 91, 29] # 七个普通 token调用方若要专用标记,得显式插入 50256。直接 decode([50256]) 又能查表得到标记文字。
「词表里注册了它」「编码时把它当作特殊标记」「生成时遇到它停止」是三个不同环节。
下一篇的 minbpe 会用整数表示 BPE 符号,并增加特殊标记策略。 但不变的部分已经很清楚:块内按固定 rank 合并,编号查完整内容,所有字节拼齐以后才做 UTF-8 解码。