从零做出 BPE 分词器
目标
不依赖任何库,仅使用 Python 实现 BPE 分词器,完成从训练到编码、解码的完整循环。
为什么重要
分词器是 LLM 流水线中最先执行、却最少被理解的组件,但许多关键行为都由它决定。提示词成本按 token 数而非字符数计算,上下文长度限制也以 token 为单位。模型在处理单词拼写问题时格外薄弱,也是因为 token 边界与字符边界不同。
亲手实现后,你会发现这些特性并非抽象理论,而是实现方式的自然结果。尤其在最后一步测量压缩比时,可以用数字确认为什么韩语通常比英语消耗更多 token。
本实验不会下载模型,也不需要互联网或 GPU。语料库是实验数据库 docs 表中的 30 篇韩语文档。
步骤
工作目录为 /root/llm。必须严格遵守规则才能通过评分。
- 将
docs表的body按id升序逐行保存到/root/llm/corpus.txt,共 30 行。 - 将语料库中字符的频率保存到
/root/llm/char_freq.tsv。格式为문자<탭>빈도,并且不统计空格字符。按频率降序排列;频率相同时按字符升序排列。 - 将基础词表逐行保存到
/root/llm/vocab_base.txt。把语料库中的字符(排除空格)与词尾标记字符_组成集合,再按码点升序排列。 - 将 120 条 BPE 合并规则保存到
/root/llm/merges.txt。每行用空格分隔写成앞토큰 뒤토큰。训练规则如下。- 按空格拆分每一行得到单词,每个单词以
문자들 + ['_']列表开始。 - 每一步统计所有单词中的相邻 token 对频率,并合并频率最高的 token 对。
- 频率相同时,选择
(앞토큰, 뒤토큰)元组中字典序较小的 token 对。 - 在每个单词中从左到右应用合并,且不得重叠。
- 按空格拆分每一行得到单词,每个单词以
- 将最终词表保存到
/root/llm/vocab.tsv,格式为토큰<탭>id。先按第 3 步的顺序从编号 0 开始加入基础字符,再按合并发生的顺序追加生成的 token。 - 把每篇文档转换为 token id 序列,保存到
/root/llm/encoded.tsv。每行格式为docs.id<탭>id id id ...,共 30 行。编码时必须按记录的顺序应用已训练的合并规则。 - 仅使用
vocab.tsv和encoded.tsv还原原文,并保存到/root/llm/decoded.txt。连接 token,把_替换为空格,再删除行尾空格,结果必须与原文一致。 - 在
/root/llm/stats.tsv中以一行保存문자수<탭>토큰수<탭>압축비。字符数是语料库各行长度之和(包括空格),token 数是全部 token 的总数;压缩比是字符数除以 token 数并四舍五入到小数点后三位。
参考
- 提取语料库:
PGPASSWORD=lab psql -tA -h 127.0.0.1 -U lab -d labdb -c "select body from docs order by id" - 只使用 Python。
collections.Counter很方便,但不是必需的。 - 常见错误 1:忽略合并频率相同的处理规则,会改变规则顺序,导致后续步骤全部偏离。
- 常见错误 2:省略词尾标记后,解码时将无法恢复空格。
下载语料库
将 docs 表的 body 按 id 升序逐行保存到 /root/llm/corpus.txt,共 30 行。
将 docs 表的 body 按 id 顺序逐行写入文件。使用 psql 只输出查询结果的选项会更方便。
统计字符频率
将语料库中字符的频率保存到 /root/llm/char_freq.tsv。格式为 문자<탭>빈도,并且不统计空格字符。按频率降序排列;频率相同时按字符升序排列。
不要统计空格。请注意有两个排序条件。
创建基础字符词表
将基础词表逐行保存到 /root/llm/vocab_base.txt。把语料库中的字符(排除空格)与词尾标记字符 _组成集合,再按码点升序排列。
除了语料库中出现的字符,表示词尾的标记字符也必须加入词表。
训练 120 条合并规则
将 120 条 BPE 合并规则保存到 /root/llm/merges.txt。每行用空格分隔写成 앞토큰 뒤토큰。训练规则如下。
- 按空格拆分每一行得到单词,每个单词以
문자들 + ['_']列表开始。 - 每一步统计所有单词中的相邻 token 对频率,并合并频率最高的 token 对。
- 频率相同时,选择
(앞토큰, 뒤토큰)元组中字典序较小的 token 对。 - 在每个单词中从左到右应用合并,且不得重叠。
每一步都必须重新统计相邻 token 对的频率。请严格遵守并列时的处理规则。
创建最终词表
将最终词表保存到 /root/llm/vocab.tsv,格式为 토큰<탭>id。先按第 3 步的顺序从编号 0 开始加入基础字符,再按合并发生的顺序追加生成的 token。
先加入基础字符,再按合并发生的顺序追加。id 从 0 开始连续编号。
将文档转换为 token id 序列
把每篇文档转换为 token id 序列,保存到 /root/llm/encoded.tsv。每行格式为 docs.id<탭>id id id ...,共 30 行。编码时必须按记录的顺序应用已训练的合并规则。
必须按训练时的相同顺序应用合并规则,才能得到一致结果。
仅用 id 序列还原原文
仅使用 vocab.tsv 和 encoded.tsv 还原原文,并保存到 /root/llm/decoded.txt。连接 token,把 _ 替换为空格,再删除行尾空格,结果必须与原文一致。
连接 token 后,把词尾标记还原为空格,并删除行尾多余空格。
计算压缩比
在 /root/llm/stats.tsv 中以一行保存 문자수<탭>토큰수<탭>압축비。字符数是语料库各行长度之和(包括空格),token 数是全部 token 的总数;压缩比是字符数除以 token 数并四舍五入到小数点后三位。
用字符数除以 token 数。字符数中也包含空格。