亲手做一个分词器
目标
只用标准库亲手实现字节层面的 BPE。从 UTF-8 字节出发,数出相邻对,学习合并最常见对的规则,把这些规则按学到的顺序应用来编码,再准确还原成原文。最后改变词表大小,测量令牌(token)数减少的曲线,并把内容相同的韩语段落和英语段落用同一个词表编码,把令牌数并排放在一起。
为什么重要
收费和上下文上限的单位都是令牌,而令牌既不是字符也不是单词。把哪些片段算作一个,是制作词表时确定的,词表因模型而异。所以“字符数乘以多少就行”这种估算,在语言一变的那一刻就失效。一个谚文音节用 UTF-8 是 3 个字节,词表小的话,一个字符会变成三个令牌。 本实验不调用真实模型的分词器。这个 Pod 里没有 transformers、tokenizers、tiktoken,系统 Python 里也没有 numpy。取而代之的是亲手实现同样的算法,只使用用你做的词表测出来的数字。所以“某个模型会把这句话数成多少个令牌”这样的话,这里不说。 算法本身是一句话——把最常相邻出现的两个合并成一个,重复到词表达到想要的大小。难的是细节。重叠的位置怎么数、平局怎么打破、编码时规则按什么顺序应用,如果这些没有确定,即使对同一篇文章运行,词表也会每次都不同。 评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的输入直接检验函数,并与评分器另外计算的值对照。输入每次运行都会变,所以无法把值背下来填进去。
步骤
- 在 /root/work/tf-token/bpe.py 中创建示例段落
SAMPLE_KO、SAMPLE_EN以及to_ids(text)、from_ids(ids)。把文本展开成 UTF-8 字节列表,再还原成文本。 - 增加
count_pairs(ids),统计相邻的两个出现了多少次。键是(앞, 뒤)(占位符依次为前一个与后一个)对,重叠的位置也照样统计。 - 增加
merge(ids, pair, new_id),把这个对出现的位置换成一个新编号。从左边开始,不重叠地走。 - 创建
MIN_PAIR_COUNT = 2和train(text, vocab_size),按学到的顺序收集合并规则。新编号从 256 开始逐个增加。 - 创建
encode(text, merges),按学到的顺序原样应用合并。 - 创建
decode(ids, merges),把编号一直拆到底,还原成原文。即使混进了词表里没有的字符,与原文也不能有一个字符不同。 - 创建
vocab_curve(text, sizes),测量每个词表大小下同一篇文章会变成多少个令牌。返回的值是(어휘크기, 토큰수)(占位符依次为词表大小与令牌数)对的列表。 - 学出两套词表来测量同样的两个段落,并在 /root/work/tf-token/token_report.json 和 /root/work/tf-token/token_report.md 中记录结果。
参考
- 执行契约:评分器会把
/root/work/tf-token/bpe.py当作 Python 模块导入,直接使用SAMPLE_KO、SAMPLE_EN、to_ids、from_ids、count_pairs、merge、MIN_PAIR_COUNT、train、encode、decode、vocab_curve。它不会作为脚本运行,所以可以没有if __name__ == "__main__"。 - 示例段落要用两种语言写同样的内容。
SAMPLE_KO要以谚文为主,不少于 300 个字符;SAMPLE_EN只用 ASCII,不少于 300 个字符。内容自由决定。 to_ids(text)把text.encode("utf-8")的字节作为整数列表返回。不是字符编号(ord)。from_ids(ids)即使收到被截断的字节,也不能抛异常。请使用decode的errors参数。count_pairs([9, 9, 9])是{(9, 9): 2}。重叠的位置照样统计。merge([5, 5, 5], (5, 5), 300)是[300, 5]。吞掉前两个之后,剩下的 5 失去了配对。不要在原处修改传入的列表,要新建一个列表。train的一轮:数出相邻对,挑出现次数最多的对,把这个对合并成新编号。平局时挑选(앞, 뒤)(占位符依次为前一个与后一个)按字典序较小的对。如果挑出的对出现次数少于MIN_PAIR_COUNT,就停下。返回的值是((앞, 뒤), 새번호)(占位符依次为前一个、后一个、新编号)对的列表,顺序就是学习的顺序。train(text, 256)是空列表。0 到 255 已经被字节占用,没有位置可以做新编号了。encode把merges按收到的顺序各应用一次。不要排序,也不要重复。decode把新编号拆成两个,其中如果又有新编号,就再拆。没有剩余时用from_ids读取。- 第 8 步的报告要学两套词表。大小都是 512。一套是用把
SAMPLE_KO和SAMPLE_EN以一个换行连起来的文本学出的共用词表,另一套是只用SAMPLE_EN学出的仅英语词表。用两套词表分别对同样的两个段落编码,比较令牌数。 vocab_size只是上限。可合并的对用完时会在MIN_PAIR_COUNT处停下,所以实际规则数可能少于 512 减 256 的值。因此在shared_rules、en_only_rules中填写实际学到的规则数。- 曲线对连起来的文本,以大小 256、320、384、512 来测量。
- 这个 Pod 没有互联网。
pip install无法使用,transformers、tokenizers、tiktoken 也没有。numpy 只在/opt/onnx-lab/bin/python里,所以在系统 Python 里import numpy是不行的。只用标准库就足够了。 - 官方文档:BPE 原论文 · Hugging Face — Byte-Pair Encoding tokenization · Attention Is All You Need
- 常见错误:用字符编号而不是字节、按每两格跳着数对、合并时只前进一格导致重叠着吞掉、不确定平局规则、编码时给规则排序、解码只拆一层就结束。
把文本展开成字节
在 /root/work/tf-token/bpe.py 中创建示例段落 SAMPLE_KO(以谚文为主,300 个字符以上)、SAMPLE_EN(只用 ASCII,300 个字符以上)以及 to_ids(text)、from_ids(ids)。to_ids 把 UTF-8 字节作为整数列表返回,from_ids 再把它还原成文本。
text.encode("utf-8") 给出字节串,用 list() 包起来就成了 0 到 255 的整数列表。还原时用 bytes(ids) 聚合后 decode。即使收到在字符中间被截断的片段,也不能抛异常,所以请传入 errors 参数。两个示例必须用两种语言写同样的内容,后面才能比较。
统计相邻的两个
增加 count_pairs(ids)。统计相邻的两个出现了多少次,返回 {(앞, 뒤): 횟수}(占位符依次为前一个、后一个、次数)。重叠的位置也照样统计,所以 count_pairs([9, 9, 9]) 是 {(9, 9): 2}。
用 zip(ids, ids[1:]) 可以一次扫完相邻的对。每次跳过两格去数,会漏掉重叠的位置,那样在判断该合并什么才划算时就会出现偏差。列表为空或只有一个元素时,是空字典。
把一对合并成一个
增加 merge(ids, pair, new_id)。把 pair 出现的位置换成一个新编号。从左边开始不重叠地走,所以 merge([5, 5, 5], (5, 5), 300) 是 [300, 5]。传入的列表保持原样,返回一个新列表。
用手动移动下标的 while 语句最准确。找到对就前进两格,否则只前进一格。如果每次只走一格,就会把刚做出的新编号又当作对的前一半,重叠着吞掉。在原处修改列表,调用方手里的值会悄悄改变。
学习合并规则
创建 MIN_PAIR_COUNT = 2 和 train(text, vocab_size)。数出相邻对,挑出现次数最多的对,平局时挑 (앞, 뒤)(占位符依次为前一个与后一个)字典序较小的对,再把这个对合并成新编号,如此重复。新编号从 256 开始逐个增加,返回的值是 ((앞, 뒤), 새번호)(占位符依次为前一个、后一个、新编号)对的列表。
每一轮都必须重新数——因为合并之后相邻关系变了。用 max(counts.items(), key=...) 的 key 可以一次性把平局规则也写进去。如果挑出的对出现次数少于 MIN_PAIR_COUNT,就停下。把只出现一次的对合并,只会让词表变大,令牌一个也不会减少。vocab_size 为 256 时没有位置做新编号,所以是空列表。
按学到的顺序编码
创建 encode(text, merges)。把文本展开成字节后,把 merges 原样按收到的顺序各应用一次。不要排序,也不要重复。
后面学到的规则把前面做出的编号当作材料。所以顺序一变,即使规则列表相同也会得到不同的结果。函数三行就够了——展开成字节,对每条规则调用 merge,返回剩下的列表。
还原得一个字符也不差
创建 decode(ids, merges)。把新编号拆成两个,其中如果又有新编号,就再拆。没有剩余时只剩字节,把它读成文本。即使是混入了训练中没有的字符的文本,与原文也不能有一个字符不同。
先做一张 {새번호: (앞, 뒤)}(占位符依次为新编号、前一个与后一个)表,拆解就只是查这张表。不能只拆一层就结束——拆出来的值可能又是新编号。全部拆完后,from_ids 原样可用。词表里没有的字符,在字节层面也一定能写出来,所以在这种结构下,遇到第一次见到的字符也不会失败。
词表变大,令牌就减少
创建 vocab_curve(text, sizes)。对 sizes 中的每个词表大小,用那篇文章从头学规则,再对同一篇文章编码,测量令牌数。返回的值是 (어휘크기, 토큰수)(占位符依次为词表大小与令牌数)对的列表,顺序与 sizes 相同。
每个大小都必须用那个大小的规则来测。如果用最大的词表学一次,再用那套规则去测所有的格子,曲线就会变平。直接使用前面做的 train 和 encode,函数只有五行。顺便说一下,这个算法只向前看来选择,所以 train(글, 300)(占位符为文本)的结果,就是 train(글, 400) 结果的前半部分——调大大小,只是运行得更久,而不是重新挑选。请亲眼看看减少的幅度怎样变化。
词表是用谁的文章做出来的
学出两套词表。一套是用把 SAMPLE_KO 和 SAMPLE_EN 以一个换行连起来的文本学出的、大小为 512 的共用词表,另一套是只用 SAMPLE_EN 学出的、大小为 512 的仅英语词表。用两套词表分别对同样的两个段落编码之后,在 /root/work/tf-token/token_report.json 中写入 vocab_size、ko_chars、ko_bytes、en_chars、en_bytes、shared_rules、en_only_rules、ko_tokens_shared、en_tokens_shared、ko_tokens_en_only、en_tokens_en_only、ko_bytes_per_token、en_bytes_per_token、curve、roundtrip_ok,在 /root/work/tf-token/token_report.md 中用 ## 무엇을 쟀나(韩文,意为“测量了什么”)、## 어휘를 키우면 토큰이 어떻게 줄었나(韩文,意为“词表变大后令牌是怎样减少的”)、## 한국어가 손해를 보는 이유(韩文,意为“韩语吃亏的原因”)、## 바이트 수준이라 안 깨지는 것(韩文,意为“因为是字节层面而不会崩坏的东西”)四节来写。
数字不要手写,要用实际运行你的代码得到的值来填。shared_rules、en_only_rules 是 train() 实际返回的规则数——即使给大小 512,可合并的对用完了也会提前停下。ko_bytes_per_token、en_bytes_per_token 是以共用词表为准,用字节数除以令牌数得到的值。curve 是对连起来的文本,以大小 256、320、384、512 测得的 [[크기, 토큰수], ...](占位符依次为大小与令牌数)。roundtrip_ok 是两套词表下两个段落是否都能准确还原成原文——即使用仅英语词表对韩语编码也能还原,这就是字节层面的性质。