编号已经有了:minbpe 怎么恢复规则,又怎么处理特殊 Token
前一篇的 GPT-2 要先把字节换成字符别名,做完字符串 BPE 再查 encoder 得到编号。
minbpe 的 GPT4Tokenizer 直接在整数 ID上合并,复现的是 cl100k_base,不用再训练一份自己的词表。
它多处理了两件事:
<|endoftext|>这样的已注册标记,要由调用方决定识别、当普通文字还是拒绝。- 已有词表只给出完整字节内容和 rank / ID;整数 BPE 需要的「左 ID、右 ID → 新 ID」,得恢复出来。
特殊标记:文字、注册编号和停止行为
"<|endoftext|>" 首先只是一个长 13 个字符的 Python 字符串。它恰好也是已注册特殊 token 的拼写:
GPT-2 的专用 ID 是 50256,cl100k_base 的是 100257。编号属于各自词表,不能跨词表使用。
同一个字面字符串,在 tiktoken 0.14.0 下:
enc = tiktoken.get_encoding("cl100k_base")
enc.encode("<|endoftext|>") # ValueError:默认禁止enc.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}) # [100257]enc.encode("<|endoftext|>", disallowed_special=()) # [27,91,8862,728,428,91,29]默认报错来自编码入口的特殊标记检查,不是 BPE 合并失败。 允许时先识别整段标记,直接取注册 ID;作为普通文字时,照样正则切块、转字节、做 BPE。
第三层是生成循环如何解释这个 ID。分词器认识一个特殊 ID,不等于模型生成它就自动停止; 停止 ID 列表、消息边界和调用协议由上层决定。这里不把某个标记的拼写当成一条自动执行的指令。
cl100k_base 注册的五个标记:
| 标记 | ID |
|---|---|
<|endoftext|> | 100257 |
<|fim_prefix|> | 100258 |
<|fim_middle|> | 100259 |
<|fim_suffix|> | 100260 |
<|endofprompt|> | 100276 |
普通词表有 100,256 项:256 个单字节项,加 100,000 个合并项。
特殊 ID 中间留有空位;n_vocab = 100277 是最大 ID 加一,不能据此说有这么多实际条目。
官方表保存的是完整内容,不是左右关系
.tiktoken 文件一行一个条目:Base64 编码的原始字节串,加一个 rank / ID。
IQ== 0 → b'!'ZQ== 68 → b'e'aA== 71 → b'h'bA== 75 → b'l'bw== 78 → b'o'aGVsbG8= 15339 → b'hello'读取后是 mergeable_ranks: dict[bytes, int]。
知道 b'hello' → 15339,只知道它的完整内容和已有编号,还不知道最后一次是 h+ello,还是 he+llo。
recover_merges 恢复的就是这一层组成关系。
byte_shuffle 只重编号,不换位置
第 28 篇自己的 BPE 让基础 ID 等于字节值。这里官方基础 ID 是一份排列:
byte_shuffle = { b: mergeable_ranks[bytes([b])] for b in range(256)}inverse_byte_shuffle = {idx: b for b, idx in byte_shuffle.items()}hello 的原始字节: [104,101,108,108,111]重编号后的基础 ID:[ 71, 68, 75, 75, 78]
ab:[97,98] → [64,65] → BPE → [370]shuffle 这个名字容易让人想到打乱顺序,实际上它只逐元素换编号,序列位置不变。
也不能把任意 token ID 丢进 inverse_byte_shuffle:反表只覆盖 256 个基础编号,不覆盖 15339。
★ 恢复 hello:为什么停在 15339 之前
恢复的目标是 b'hello',它的 rank r = 15339 一开始就有。先拆成单字节:
[h] [e] [l] [l] [o]辅助 bpe 查询相邻两块拼接后的已有 rank,每轮挑最小的一个相邻位置合并。
同 rank 时取最左位置;只允许 rank < r。
| 状态 | 当前合法候选 | 选哪一处 | 合并后 |
|---|---|---|---|
h | e | l | l | o | he:383, el:301, ll:657, lo:385 | el:301 | h | el | l | o |
h | el | l | o | hel:50222, ell:616, lo:385 | lo:385 | h | el | lo |
h | el | lo | hel:50222, ello:4896 | ello:4896 | h | ello |
h | ello | hello:15339 | 15339 >= r,停止 | 保留两块 |
这样重放了目标形成前的旧规则,恰好留下它的左右两侧:
left_id = mergeable_ranks[b"h"] # 71right_id = mergeable_ranks[b"ello"] # 4896merges[(71, 4896)] = 15339如果允许等于目标 rank 的最后一次合并,parts 就只剩 [hello],拿不到两个组成部分。
因此停止条件是 min_rank >= max_rank,允许条件是严格的 <,不是 <=。
这不是在当前文本上重新统计频次、选最常见的对,也没有创造新编号。它利用已有 rank,恢复每个已有合并项的最后一次连接。
下面把关键控制流写在一起;依据 karpathy/minbpe 的 gpt4.py(MIT 许可),为便于对照做了精简:
def earlier_parts(ranks, token, max_rank): parts = [bytes([b]) for b in token] while len(parts) > 1: i = min(range(len(parts) - 1), key=lambda i: ranks.get(parts[i] + parts[i + 1], float("inf"))) rank = ranks.get(parts[i] + parts[i + 1], float("inf")) if rank >= max_rank: break parts[i:i + 2] = [parts[i] + parts[i + 1]] return parts
merges = {}for token, rank in mergeable_ranks.items(): if len(token) == 1: # 跳过 256 个基础项 continue parts = earlier_parts(mergeable_ranks, token, max_rank=rank) assert len(parts) == 2 left, right = (mergeable_ranks[p] for p in parts) merges[(left, right)] = rank这个精简函数用于有限的目标 rank;原辅助函数还支持 max_rank=None,以便不设截止。
如果恢复结果不是两块,就应当报错,不该默默保存一条不完整的规则。
恢复辅助函数与普通 encode 不完全一样
恢复时的辅助 bpe | 普通整数 BPE | |
|---|---|---|
| 输入 | 一个已有 token 的原始字节 | 一块新文本的基础 ID |
| 一轮合并 | 一个最小 rank 的相邻位置 | 一种最小 rank 的对,替换所有不重叠出现 |
| 截止 | 不许达到目标 rank | 不设目标 rank 截止 |
| 产物 | 恰好两块,恢复最后一次关系 | 最终 token ID 序列 |
两个函数都利用固定规则,目的不同;不能把恢复阶段的截止条件带进正常编码。
两张表:merges 选规则,vocab 存完整内容
恢复完有 100,000 条 merges。随后提前展开词表:
vocab = {idx: bytes([idx]) for idx in range(256)}for (left, right), new_id in sorted(merges.items(), key=lambda item: item[1]): vocab[new_id] = vocab[left] + vocab[right]排序按合并编号,也就是这里的 rank,保证子项先存在。原实现依赖官方表的迭代顺序;这里显式排好,便于看清依赖。
注意:初始 bytes([idx]) 装的是内部基础 ID,不一定是原始字节值。
vocab[71] == bytes([71])vocab[4896] == bytes([68,75,75,78])vocab[15339] == bytes([71,68,75,75,78]) # 直接打印是 b'GDKKN'b'GDKKN' 不是把 hello 解错了,而是这五个内部编号恰好在 ASCII 下能打印成那些字符。
逐元素逆映射成 [104,101,108,108,111],才是原始 b'hello'。
| 表 | 保存的例子 | 用途 |
|---|---|---|
merges | (71,4896) → 15339 | 编码时判断哪对能合、先合谁 |
vocab | 15339 → bytes([71,68,75,75,78]) | 解码时直接取完整展开内容 |
解码不递归拆合并树。 初始化已经把每个条目完全展开并存好,之后拿到 ID 就查完整内容。
实际 encode:最后一步继续合成 hello
[71,68,75,75,78] el:301 → [71,301,75,78] lo:385 → [71,301,385] ello:4896 → [71,4896] hello:15339 → [15339]前三步和恢复过程一样;正常 encode 没有 max_rank=15339 的截止,所以第四步也执行。
规则已经恢复到 merges,每轮挑当前合法候选里最小的 rank,合并全部不重叠出现,直到没有规则或只剩一个 ID。
混合文本也走同一条路径:
输入:Hi 你! 😊正则块:['Hi', ' 你', '!', ' 😊']最终 ID:[13347,220,57668,0,27623,232]emoji 仍可跨 token;不能因为中文在这个词表里已经合成了一个 token,就推断所有字符都完整。
动画初始化段始终用 hello 展示一条恢复记录;它属于Tokenizer 实例的初始化,不是随着选中的输入重新训练。
输入可切换 hello、internationalization、重复 ab、中英加 emoji、带特殊标记的文字。
每步候选表里的次数只是辅助显示;编码选择依据仍然是 rank。
特殊策略与解码:区分原实现和学习实现
RegexTokenizer 的 allowed_special 接口:
| 策略 | 已注册特殊标记怎么处理 |
|---|---|
"none_raise"(默认) | 发现标记就报错 |
"none" | 全当普通文字 |
"all" | 全部识别成专用 ID |
| 指定集合 | 集合里的识别,其余按普通文字 |
这个「其余按普通文字」和 tiktoken 默认禁止其余标记不同。做一致性对照时必须同时设置:
enc.encode(text, allowed_special=chosen, disallowed_special=())否则可能在比较 ID 之前,就因禁止标记而报错。
原 RegexTokenizer 的默认拒绝用 assert,抛 AssertionError;本地学习实现改成显式 ValueError,不能把两个异常混写。
原 GPT4Tokenizer 的 decode 有一处缺口
原 gpt4.py 的解码只查 self.vocab[idx],再统一逆映射。注册特殊 token 并不会把它加进这张普通词表。
因此原 GPT4Tokenizer.decode([100257]) 会 KeyError。
笔记中的 tokenizer_lab 学习实现补了分支:
parts = []for idx in ids: if idx in vocab: internal = vocab[idx] parts.append(bytes(inverse_byte_shuffle[b] for b in internal)) elif idx in inverse_special_tokens: parts.append(inverse_special_tokens[idx].encode("utf-8")) else: raise ValueError(f"unknown token ID: {idx}")return b"".join(parts).decode("utf-8", errors="replace")普通 token 先取内部完整内容,再逐基础编号逆映射。 特殊 token 直接把注册文字变成 UTF-8 字节,不走基础编号逆映射;最后把所有片段拼齐,再解码一次。
tokenizer.encode("<|endoftext|>hello", allowed_special="all")# [100257,15339]tokenizer.decode([100257,15339])# '<|endoftext|>hello'这里的往返由补齐特殊解码分支的学习实现完成,不是原 gpt4.py 已经具备的行为。
验证了什么,以及没由测试证明什么
本地 tokenizer_lab/verify.py 的五项测试重新执行通过:
| 范围 | 数量 | 核对内容 |
|---|---|---|
| 普通输入 | 123 例 | 与 tiktoken 的完整 ID 序列相同,解码回原文 |
| 特殊策略 | 66 例 | 默认拒绝、普通文字、全部允许、指定集合,使用相应的 tiktoken 参数 |
| 普通词表 | 100,256 项 | 展开内部内容并逆映射后,与官方原始字节逐项相同 |
普通例子含中文、组合字符、emoji、零字节、Tab、换行、重复片段,以及固定随机种子的生成字符串; 另有未知 ID 和跨 token UTF-8 片段的边界检查。比较的是 ID 与原始字节,不能只看最终文字一样。
这些结果说明本次环境、词表和测试范围一致,不是靠 123 个例子证明任意字符串都已经覆盖。
实现也直接读取 _mergeable_ranks 等私有属性;复用实验时要保留版本,并在依赖变化后重新核对。
这三篇把 BPE 周围的几层约定补齐了:正则管边界,固定 rank 管合并顺序,ID 管词表成员,特殊策略管标记识别, 完整词表管解码内容。初始化做的工作可以多,之后编码与解码各查自己需要的表;混用这些职责,才会出现「编号对了,内容却还原不出来」。