教学 BPE:字符怎样合并成 Token
观察一组简单语料如何决定字符对合并。训练规则与应用规则分开,保留原文本可逆。例子刻意不实现真实模型的字节编码、特殊Token和预处理。
沿着对象与状态理解本章
先沿着实际对象追踪:用户文字不是消息包,消息包不是Token数组,编号也不是Embedding。把这四种对象分别认出来,后面的shape和API角色才有落点。下图的词表是独立教学约定;不要把它当成所有Tokenizer的真实结果。
下方图解的固定输入在“输入与适用条件”中声明。它用于解释本章机制,不与本章其他独立例子的数值暗中接续。
文字、字节、Token、编号是四件事
为什么不能把“一个汉字”当成“一次生成”?
Token是分词规则选出来的片段,编号是它在词表中的地址。一个Token可以跨越多个字符,也可能只覆盖字符编码的一部分。教学中把“学习”合成一个Token,只是明确的演示规则,不能当成所有模型的实际分词。
输入与适用条件:本图使用人为词表:PAD0、我1、爱2、学习3、你4、!5;不演示真实BPE训练。
1 / 6 · 原文字
窄屏可左右滑动图形,或点击“放大图形”。文字解释紧随图后。
先确定待编码原文,两句之间不能直接接起来当作同一人的上下文。
本步状态由图形与说明共同给出。默认静止。用“下一步”看完解释再继续;播放到最后一步自动停止。键盘聚焦播放器后,可用左右键逐步查看、空格播放/暂停。图中颜色辅助区分,含义同时用文字注明。
为什么需要
弄清计数单位,可避免上下文预算、输出长度和界面刷新混为一谈。
怎样实现
用目标模型配套分词器编码和解码,并分开观察字符数、字节数、Token数。
可能的代价与误解
更换分词器后沿用旧编号,会读到错误的词表项。
如何判断是否做对
对实际输入做一次往返编解码,并按所用分词器规则处理特殊Token。
怎样观察与验证
先运行默认样例,再改变语料或合并次数。相同文本可能得到不同教学Token数,说明计数依赖分词器。不要把结果用于商业模型计费。
运行命令
python3 reading-notes/experiments/01_tokenization.py完整教学代码
"""教学级字符 BPE:只演示合并与编码,不是商业模型的 Tokenizer。"""
from collections import Counter
from typing import Iterable
def apply_merge(tokens: list[str], pair: tuple[str, str]) -> list[str]:
out, i = [], 0
while i < len(tokens):
if i + 1 < len(tokens) and (tokens[i], tokens[i + 1]) == pair:
out.append(tokens[i] + tokens[i + 1])
i += 2
else:
out.append(tokens[i])
i += 1
return out
def train_bpe(texts: Iterable[str], num_merges: int = 8) -> list[tuple[str, str]]:
if isinstance(texts, (str, bytes)):
raise TypeError("texts 必须是字符串集合,不是单个字符串")
if not isinstance(num_merges, int) or isinstance(num_merges, bool) or num_merges < 0:
raise ValueError("num_merges 必须是非负整数")
data = list(texts)
if any(not isinstance(t, str) for t in data):
raise TypeError("训练样本必须是字符串")
corpus = [list(t) for t in data]
merges = []
for _ in range(num_merges):
counts = Counter(pair for seq in corpus for pair in zip(seq, seq[1:]))
if not counts:
break
# 相同频次时固定排序,保证演示可复现。
pair = min(counts, key=lambda p: (-counts[p], p))
if counts[pair] < 2:
break
merges.append(pair)
corpus = [apply_merge(seq, pair) for seq in corpus]
return merges
def encode(text: str, merges: list[tuple[str, str]]) -> list[str]:
if not isinstance(text, str):
raise TypeError("text 必须是字符串")
result = list(text)
for pair in merges:
result = apply_merge(result, pair)
return result
def self_test() -> None:
merges = train_bpe(["路径路径", "路径过滤", "路径规范"], 5)
for text in ["路径过滤", "未知字符 x\n", ""]:
assert "".join(encode(text, merges)) == text
assert len(encode("路径路径", merges)) < len("路径路径")
assert train_bpe([], 3) == []
try:
train_bpe("错误输入")
except TypeError:
pass
else:
raise AssertionError("应拒绝单个字符串作为语料集合")
def run_demo() -> dict:
texts = ["exclude_paths", "exclude_path", "exclude_files", "include_paths"]
merges = train_bpe(texts, 10)
sample = "exclude_paths"
tokens = encode(sample, merges)
return {"性质": "字符级教学 BPE,非真实模型计费", "合并规则": merges,
"文本": sample, "字符数": len(sample), "教学Token": tokens,
"教学Token数": len(tokens), "可逆": "".join(tokens) == sample}
if __name__ == "__main__":
import json
self_test()
print(json.dumps(run_demo(), ensure_ascii=False, indent=2))
运行结果示例
以下结果由随包脚本产生。它们不是LLM或产品性能数据。
{
"性质": "字符级教学 BPE,非真实模型计费",
"合并规则": [
[
"c",
"l"
],
[
"cl",
"u"
],
[
"clu",
"d"
],
[
"clud",
"e"
],
[
"clude",
"_"
],
[
"a",
"t"
],
[
"at",
"h"
],
[
"clude_",
"p"
],
[
"clude_p",
"ath"
],
[
"e",
"x"
]
],
"文本": "exclude_paths",
"字符数": 13,
"教学Token": [
"ex",
"clude_path",
"s"
],
"教学Token数": 3,
"可逆": true
}