0 tokenizer综述
- 根据不同的切分粒度可以把tokenizer分为: 基于词的切分,基于字的切分和基于subword的切分。 基于subword的切分是目前的主流切分方式。
- subword的切分包括: BPE(/BBPE), WordPiece 和 Unigram三种分词模型。其中WordPiece可以认为是一种特殊的BPE。
- 完整的分词流程包括:文本归一化,预切分,基于分词模型的切分,后处理。
- SentencePiece是一个分词工具,内置BEP等多种分词方法,基于Unicode编码并且将空格视为特殊的token。这是当前大模型的主流分词方案。
- BPE:GPT, Baichuan, RoBERTa,BART,DeBERTa
- BBPE:ChatGLM
- BPE / BBPE: GPT-2, GPT-J, GPT-Neo, GPT-4O, GPT3, Qwen, Qwen2, Llama, Llama2, Llama3
- WordPiece:BERT, DistilBERT,MobileBERT, MPNET,Funnel Transformers
- Unigram:AlBERT, T5, mBART, XLNet
1 基于subword的切分
基于词和字的切分都会存在一定的问题,直接应用的效果比较差。
基于词的切分,会造成:词表规模过大
一定会存在UNK,造成信息丢失
不能学习到词缀之间的关系,例如:dog与dogs,happy与unhappy基于字的切分,会造成:
每个token的信息密度低
序列过长,解码效率很低所以基于词和基于字的切分方式是两个极端,其优缺点也是互补的。而折中的subword就是一种相对平衡的方案。
基于subword的切分能很好平衡基于词切分和基于字切分的优缺点,也是目前主流最主流的切分方式。
subword的基本切分原则是:高频词依旧切分成完整的整词
低频词被切分成有意义的子词,例如 dogs => [dog, ##s]基于subword的切分可以实现:
词表规模适中,解码效率较高
不存在UNK,信息不丢失
能学习到词缀之间的关系基于subword的切分包括:BPE,WordPiece 和 Unigram 三种分词模型。
1.1 处理流程概述
- 归一化:最基础的文本清洗,包括删除多余的换行和空格,转小写,移除音调等。
HuggingFace tokenizer的实现: https://huggingface.co/docs/tokenizers/api/normalizers- 预分词:把句子切分成更小的“词”单元。可以基于空格或者标点进行切分。 不同的tokenizer的实现细节不一样。例如:
pre-tokenize:
[BERT]: [(‘Hello’, (0, 5)), (‘,’, (5, 6)), (‘how’, (7, 10)), (‘are’, (11, 14)), (‘you’, (16, 19)), (‘?’, (19, 20))]
[GPT2]: [(‘Hello’, (0, 5)), (‘,’, (5, 6)), (‘Ġhow’, (6, 10)), (‘Ġare’, (10, 14)), (‘Ġ’, (14, 15)), (‘Ġyou’, (15, 19)), (‘?’, (19, 20))]
[t5]: [(‘▁Hello,’, (0, 6)), (‘▁how’, (7, 10)), (‘▁are’, (11, 14)), (‘▁you?’, (16, 20))]
可以看到BERT的tokenizer就是直接基于空格和标点进行切分。
GPT2也是基于空格和标签,但是空格会保留成特殊字符“Ġ”。
T5则只基于空格进行切分,标点不会切分。并且空格会保留成特殊字符"▁",并且句子开头也会添加特殊字符"▁"。
预分词的实现: https://huggingface.co/docs/tokenizers/api/pre-tokenizers- 基于分词模型的切分:不同分词模型具体的切分方式。分词模型包括:BPE,WordPiece 和 Unigram 三种分词模型。
分词模型的实现: https://huggingface.co/docs/tokenizers/api/models- 后处理:后处理阶段会包括一些特殊的分词逻辑,例如添加sepcial token:[CLS],[SEP]等。
后处理的实现: https://huggingface.co/docs/tok
1.2 BPE
Byte-Pair Encoding(BPE)是最广泛采用的subword分词器。
训练方法:从字符级的小词表出发,训练产生合并规则以及一个词表
编码方法:将文本切分成字符,再应用训练阶段获得的合并规则
经典模型:GPT, GPT-2, RoBERTa, BART, LLaMA等
因为BPE是从字符级别的小词表,逐步合并成大词表,所以需要先获得字符级别的小词表。
基于word2splits统计vocabs中相邻两个pair的词频pair2count
经过统计,当前频率最高的pair为: (‘Ġ’, ‘t’), 频率为7次。 将(‘Ġ’, ‘t’)合并成一个词并添加到词表中。同时在合并规则中添加(‘Ġ’, ‘t’)这条合并规则。
根据更新后的vocab重新对word2count进行切分。具体实现上,可以直接在旧的word2split上应用新的合并规则(‘Ġ’, ‘t’)
从而获得新的word2split
重复上述循环直到整个词表的大小达到预先设定的词表大小。
在推理阶段,给定一个句子,我们需要将其切分成一个token的序列。 具体实现上需要先对句子进行预分词并切分成字符级别的序列,然后根据合并规则进行合并。
BPE 的适用范围
BPE 一般适用在欧美语言拉丁语系中,因为欧美语言大多是字符形式,涉及前缀、后缀的单词比较多。而中文的汉字一般不用 BPE 进行编码,因为中文是字无法进行拆分。对中文的处理通常只有分词和分字两种。理论上分词效果更好,更好的区别语义。分字效率高、简洁,因为常用的字不过 3000 字,词表更加简短。
1.2.1 SuperBPE
SuperBPE: Space Travel for Language ModelsA Liu, J Hayase, V Hofmann, S Oh…[University of Washington & Allen Institute for AI]
SuperBPE超词词元化算法
要点:
提出了 SuperBPE,一种新的“超词”词元化器,它扩展了字节对编码 (BPE),除了学习子词Token外,还学习跨越空格的Token。挑战了语言模型 (LM) Token应仅限于子词的常见假设,认为空格不是可靠的意义分隔符,并且多词表达在语义上是连贯的单元。
为 SuperBPE 提出了一种两阶段预词元化课程:
首先使用空格预词元化学习子词,然后通过禁用空格预词元化学习超词。
证明了 SuperBPE 比 BPE 实现了显著更高的编码效率,对于固定的词汇量大小(20 万),编码文本使用的Token最多可减少 33%。
实证表明,在 30 个下游任务中,使用 SuperBPE 预训练的 8B Transformer LM 的性能平均比 BPE 基线高出 +4.0%,包括在 MMLU 上高出 +8.2%,同时推理时间所需的计算量减少了 27%。
分析了每个Token的比特每字节 (BPB),发现 SuperBPE 导致Token难度的分布更均匀,与 BPE 相比,减少了非常高和非常低损失的预测。
定性观察到,SuperBPE Token通常捕获常见的多词表达(例如,介词短语),这些表达在语义上充当单个单元,从而有助于提高性能。
通过缩放实验表明,与 BPE 相比,SuperBPE 在不同的模型大小和训练预算下保持了 BPB 的优势。
强调 SuperBPE 是一种直接、局部词元化的改进,可在不改变模型架构或训练框架的情况下,提高编码效率和下游性能。
认为 SuperBPE 为构建更高效、性能更高的语言模型提供了一种引人注目的替代传统子词 BPE 词元化的方法。
主旨:
探索超越子词词元化的潜力,并解决传统 BPE 词元化在编码效率和语言模型性能方面的局限性。为此,论文提出了 SuperBPE 词元化算法,通过引入两阶段预词元化课程,使词元化器能够学习包含多词表达的“超词”Token。
论文的核心目标是证明 SuperBPE 可以显著提高编码效率,并在不增加模型复杂性的前提下,提升语言模型的下游任务性能和推理效率,从而推动更高效、更强大的语言模型的发展。
创新:
提出了 SuperBPE 词元化算法,通过两阶段预词元化课程,突破了传统 BPE 子词词元化的限制,实现了超词Token的学习。
引入“超词”概念,挑战了语言模型词元化应以子词为单位的传统观念,强调了多词表达在语义上的整体性及其作为独立Token的价值。
设计了实用的 SuperBPE 训练方法,通过简单的预词元化课程调整,即可在现有 BPE 算法基础上实现超词学习,易于集成和应用。实验证明 SuperBPE 在编码效率、下游任务性能和推理效率方面均优于传统 BPE,为语言模型词元化技术的发展提供了新的方向和实证支持。
贡献:
方法贡献: 提出了 SuperBPE 词元化算法及相应的两阶段训练课程,为研究人员和工程师提供了一种简单有效的超词词元化方案,丰富了语言模型词元化技术的工具箱。
实证贡献: 通过在 8B Transformer 模型上的大量实验,验证了 SuperBPE 在编码效率和下游任务性能方面的优越性,并量化了其在推理效率上的提升,为实际应用中选择词元化策略提供了重要参考。
分析贡献: 通过对 SuperBPE Token的细致分析,揭示了超词Token能够捕捉多词表达、提高Token难度分布均匀性的特点,为理解 SuperBPE 性能提升的内在原因提供了深入的见解。
资源贡献:开源了 SuperBPE 的代码和模型,促进了该技术的传播和应用,为后续研究者提供了可复现的实验平台和基准。
提升
编码效率: SuperBPE 显著提升了文本编码效率,在相同词汇量下,SuperBPE 词元化器编码文本所需的Token数量比 BPE 最多减少 33%。
下游任务性能: 使用 SuperBPE 词元化器训练的语言模型,在 30 个下游任务上的平均性能优于 BPE 基线,特别是在多项选择题型任务上提升显著。
推理效率: 由于编码效率的提升,使用 SuperBPE 词元化器的语言模型在推理时所需的计算量减少了 27%,从而提高了推理速度和降低了资源消耗。
Token难度分布均匀性: SuperBPE 词元化器生成的Token,其难度分布更加均匀,减少了 BPE 词元化器中常见的极易预测和极难预测的Token比例,有助于模型更均衡地学习语言知识。
不足
BPB 指标与下游任务性能不完全一致: 论文中观察到 SuperBPE 模型的 BPB 值略高于 BPE 基线,但下游任务性能却更优,表明 BPB 指标可能无法完全反映词元化器对下游任务的实际影响,需要更全面的评估指标。
超词Token长度限制: 为了避免生成过长的超词Token,论文对超词Token的长度进行了限制(最多 4 个词),这种限制可能会影响 SuperBPE 捕捉更长多词表达的能力。
tokenizer 训练复杂度略有增加: SuperBPE 的第二阶段训练(禁用空格预词元化)会增加 tokenizer 训练所需的系统内存和 CPU 时间,虽然与 LLM 预训练相比可以忽略不计,但仍需考虑其对 tokenizer 训练效率的影响。
理论解释的深入性: 虽然论文对 SuperBPE 的性能提升进行了分析,但对于超词Token如何更好地捕捉语义信息、提升模型性能的深层理论机制,可以进行更深入的探讨。
心得
重新审视词元化策略在 LLM 中的作用: 本文有力地证明了词元化策略对语言模型性能和效率的巨大影响,提示我们在追求更强大的语言模型时,不能忽视词元化这一基础环节的优化和创新。
超词词元化的潜力值得进一步挖掘: SuperBPE 的成功揭示了超词词元化在提高编码效率和模型性能方面的巨大潜力,预示着未来词元化技术可能会朝着更灵活、更智能的方向发展,例如根据语义单元动态调整词元化粒度。
简单有效的技术创新: SuperBPE 的创新之处在于对 BPE 算法进行了简单而巧妙的改进,通过调整预词元化课程,即可获得显著的性能提升,这启示我们,技术创新并不总是需要复杂的架构变革,有时简单的思路转变也能带来意想不到的效果。
一句话总结: 本文创新性地提出了 SuperBPE 超词词元化算法,通过两阶段预词元化课程,突破了传统 BPE 子词词元化的限制,实现了更高效的文本编码和更优越的语言模型性能,尤其在推理效率方面取得了显著提升,挑战了子词词元化的传统范式,为未来语言模型词元化技术的发展开辟了新的方向。
1.3 BBPE
2019年提出的Byte-level BPE (BBPE)算法是上面BPE算法的进一步升级。具体参见:Neural Machine Translation with Byte-Level Subwords。 核心思想是用byte来构建最基础的词表而不是字符。首先将文本按照UTF-8进行编码,每个字符在UTF-8的表示中占据1-4个byte。 在byte序列上再使用BPE算法,进行byte level的相邻合并。编码形式如下图所示:
通过这种方式可以更好的处理跨语言和不常见字符的特殊问题(例如,颜文字),相比传统的BPE更节省词表空间(同等词表大小效果更好),每个token也能获得更充分的训练。
但是在解码阶段,一个byte序列可能解码后不是一个合法的字符序列,这里需要采用动态规划的算法进行解码,使其能解码出尽可能多的合法字符。具体算法如下: 假定f(k)表示字符序列B(1,k)最大能解码的合法字符数量,f(k)有最优的子结构:
1.3.1 tiktoken – openai
其次在词表长度上,由于openai一直想做的是通用aigc,所以使用了BBPE这种基本不会出现unk的编码形式,去尝试在同一个embedding空间下统一表示人类语言(甚至表示emoji),并且也很大程度上缩减了同等规模下BPE的多语言版本的词表大小。
tictoken对CoreBPE类进行了基于RUST的重写,包括真·多线程,regex,缓存caching,哈希各个方面的优化,具体的下面逐一解释:
- regex
正则是耗时的大头,解决办法是用一些不那么花哨的方法,比如用regex
crate可解析的形式的regex要比寻常方法快3倍以上。
一旦明确了使用crate可解析的regex,可以选择用regex
crate 或者fancy_regex
crate
regex
crate 和fancy_regex
crate也有相互关系,fancy_regex使用的re.find_it会竞争re的可变缓存空间,造成性能下降,而regex的find_iter有不同的代码路径,不会造成这个问题。为此,通过给绝大部分的regex线程做一个克隆线程来避免。
- threading
rayon并没有比python自带的threads和释放GIL的情况下快,所以直接用python线程计数等来完成threads
这里我多说一下,python自带的GIL是指如果没有额外操作,只是使用了类似:
threads = [Thread(target=spam.print_list, args=(group,)) for group in groups]
这种形式去并发执行,那么由于全局GIL的作用,相当于结果还是线性增加的,这是因为cpython是默认使用GIL确保线程安全的,如果对代码有特殊要求,可以通过
Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS宏
来关闭和打开GIL限制,但是很明显,这将会带来线程竞争问题。这时候可以通过手动加锁等方式确保我们的代码是线程安全的。
- caching
tokenizer中使用LRU cache是很有必要的,可以加速查找速度,作者使用了RWLock来保护cache安全,对于RWLock,全称是"reader-writer spin lock",和普通的spinlock不同,它对"read"和"write"的操作进行了区分。如果当前没有writer,那么多个reader可以同时获取这个rwlock。如果当前没有任何的reader,那么一个writer可以获取这个rwlock。
- hash
作者使用了FxHashMap替代传统的HashMap达到了一定的性能提升,简单说明一下,FxHashMap的散列算法质量不高,但速度非常快,特别是对整数键而言,并且发现它的性能优于rustc内的所有其他散列算法。
以上就是tictoken进行的性能优化。核心函数如下:
其中 _get_tl_regex 对应的就是每个线程都维护一个自己的线程克隆空间,接着用的就是fancy_regex的find_iter方法:
其次就是encoder: HashMap<Vec, usize>,这里的HashMap其实就是上文中提到的FxHashMap:
use rustc_hash::FxHashMap as HashMap;
最后就是ret的生成过程,如果是已经在caching也就是self.encoder中,就直接添加piece到ret中,如果不在,就调用byte_pair_encoding,其实就是bpe的过程。再说一下bpe,bpe就是对btye level的表示流进行按频率合并直到满足要求的词表大小范围或者不能继续合并为止,是一种兼顾了速度和数据量的编码方法。
1.4 WordPiece
WordPiece分词与BPE非常类似,只是在训练阶段合并pair的策略不是pair的频率而是互信息。
这里的动机是一个pair的频率很高,但是其中pair的一部分的频率更高,这时候不一定需要进行该pair的合并。 而如果一个pair的频率很高,并且这个pair的两个部分都是只出现在这个pair中,就说明这个pair很值得合并。
训练方法:从字符级的小词表出发,训练产生合并规则以及一个词表
编码方法:将文本切分成词,对每个词在词表中进行最大前向匹配
经典模型:BERT及其系列DistilBERT,MobileBERT等
在训练环节,给定语料,通过训练算法,生成最终的词表。 WordPiece算法也是从一个字符级别的词表为基础,逐步扩充成大词表。合并规则为选择相邻pair互信息最大的进行合并。
def _compute_pair2score(word2splits, word2count):
"""
计算每个pair的分数
score=(freq_of_pair)/(freq_of_first_element×freq_of_second_element)
:return:
"""
vocab2count = defaultdict(int)
pair2count = defaultdict(int)
for word, word_count in word2count.items():
splits = word2splits[word]
if len(splits) == 1:
vocab2count[splits[0]] += word_count
continue
for i in range(len(splits) - 1):
pair = (splits[i], splits[i + 1])
vocab2count[splits[i]] += word_count
pair2count[pair] += word_count
vocab2count[splits[-1]] += word_count
scores = {
pair: freq / (vocab2count[pair[0]] * vocab2count[pair[1]])
for pair, freq in pair2count.items()
}
return scores
1.5 Unigram
Unigram分词与BPE和WordPiece不同,是基于一个大词表逐步裁剪成一个小词表。
通过Unigram语言模型计算删除不同subword造成的损失来衡量subword的重要性,保留重要性较高的子词。
训练方法:从包含字符和全部子词的大词表出发,逐步裁剪出一个小词表,并且每个词都有自己的分数。
编码方法:将文本切分成词,对每个词基于Viterbi算法求解出最佳解码路径。
经典模型:AlBERT, T5, mBART, Big Bird, XLNet
在训练环节,目标是给定语料,通过训练算法,生成最终的词表,并且每个词有自己的概率值。 Unigram算法是从大词表为基础,逐步裁剪成小词表。裁剪规则是根据Unigram语言模型的打分依次裁剪重要度相对较低的词。
首先进行预切分处理。这里采用xlnet的预切分逻辑。具体会按照空格进行切分,标点不会切分。并且空格会保留成特殊字符"▁",句子开头也会添加特殊字符"▁"。
获得的pre_tokenized_corpus如下,每个单元分别为[word, (start_index, end_index)]
统计词表的全部子词和词频,取前300个词,构成最初的大词表。为了避免OOV,char级别的词均需要保留。
进一步统计每个子词的概率,并转换成Unigram里的loss贡献
基于每个子词的loss以及Viterbi算法就可以求解出,输入的一个词的最佳分词路径。即整体语言模型的loss最小。词的长度为N,解码的时间复杂度为O(N^2)。
尝试移除model中的一个子词,并计算移除后新的model在全部语料上的loss,从而获得这个子词的score,即删除这个子词使得loss新增的量。
为了提升迭代效率,批量删除前10%的结果,即让整体loss增量最小的前10%的词。(删除这些词对整体loss的影响不大。)
获得新的词表后,重新计算每个词的概率,获得新的模型。并重复以上步骤,直到裁剪到词表大小符合要求。
初始时,建立一个足够大的词表。一般,可用语料中的所有字符加上常见的子字符串初始化词表,也可以通过BPE算法初始化。
针对当前词表,用EM算法求解每个子词在语料上的概率。
对于每个子词,计算当该子词被从词表中移除时,总的loss降低了多少,记为该子词的loss。
将子词按照loss大小进行排序,丢弃一定比例loss最小的子词(比如20%),保留下来的子词生成新的词表。这里需要注意的是,单字符不能被丢弃,这是为了避免OOV情况。
重复步骤2到4,直到词表大小减少到设定范围。
可以看出,ULM会保留那些以较高频率出现在很多句子的分词结果中的子词,因为这些子词如果被丢弃,其损失会很大。
1.6 SentencePiece
SentencePiece是Google出的一个分词工具:
内置BPE,Unigram,char和word的分词方法无需预分词,以unicode方式直接编码整个句子,空格会被特殊编码为▁
相比传统实现进行优化,分词速度速度更快
当前主流的大模型都是基于sentencepiece实现,例如ChatGLM的tokenizer。
byte回退
当SentencePiece在训练BPE的时开启–byte_fallback, 在效果上类似BBPE,遇到UNK会继续按照byte进行进一步的切分。参见:https://github.com/google/sentencepiece/issues/621 具体实现上是将<0x00> … <0xFF>这256个token添加到词表中。
分析ChatGLM的模型,可以发现ChatGLM就是开启了–byte_fallback
同样的方法,可以验证LLaMA, ChatGLM-6B, Baichuan这些大模型都是基于sentencepiece实现的BPE的分词算法,并且采用byte回退。
参考链接:https://zhuanlan.zhihu.com/p/651430181
1.7 bytepiece
一个理想的Tokenizer应该是怎样的,这样才能判断最终是否达到了预期。照笔者看来,Tokenizer至少应该具备如下基本特性:
1、无损重构:分词结果应该可以无损还原为输入;
2、高压缩率:词表大小相同时,同一批数据的tokens数应该尽可能少;
3、语言无关:基于统计,训练和分词过程都不应引入语言特性;
4、数据驱动:可以直接基于原始语料进行无监督训练;
5、训练友好:能够在合理的时间和配置上完成训练过程。
最后,还有一些加分项,比如分词速度快、代码易读、方便二次拓展等,这些满足自然最好,但笔者认为可以不列入基本特性里边。
对于笔者来说,SentencePiece最大的槽点就是“无损重构”和“训练友好”。
首先,SentencePiece默认会进行NFKC normalization,这会导致“全角逗号转半角逗号”等不可逆变化,所以默认情况下它连“无损重构”都不满足,所以很长时间里它都不在笔者的候选名单中,直到后来发现,在训练时添加参数–normalization_rule_name=identity就可以让它不做任何转换。所以SentencePiece算是支持无损重构,只不过要特别设置。
至于训练方面,就更让人抓狂了。SentencePiece支持BPE和Unigram两种主流算法,Unigram训练速度尚可,但压缩率会稍低一些,BPE的压缩率更高,但是训练速度要比Unigram慢上一个数量级!而且不管是BPE还是Unigram,训练过程都极费内存。总而言之,用较大的语料去训练一个SentencePiece模型真不是一种好的体验。
1.7.1 byte-based
我们知道,Python3的默认字符串类型是Unicode,如果以Unicode为基本单位,我们称之为Char-based。Char-based很直观方便,汉字表现为长度为1的单个字符,但不同语言的Char实在太多,即便只是覆盖单字都需要消耗非常大的vocab_size,更不用说引入Word。所以BytePiece跟主流的Tokenizer一样,以Byte为基本单位。
回到Byte之后,很多问题都“豁然开朗”了。因为不同的单Byte只有256个,所以只要词表里包含了这256个单Byte,那么就可以杜绝OOV(Out of Vocabulary),这是它显而易见的好处。
此外,我们知道汉字的平均信息熵要比英文字母的平均信息熵要大,如果我们选择Char-based,那么虽然每个Char表面看起来长度都是1,但“内在”的颗粒度不一样,这会导致统计结果有所偏置。相比之下,每个Byte的信息熵则更加均匀【比如,大部分汉字的UTF-8编码对应3个Byte,而汉字的平均信息熵正好是英文字母(对应一个Byte)的2~3倍左右】,因此用Byte的统计结果会更加无偏,这将会使得模型更加“语言无关”。
在Byte-based方面,BytePiece比SentencePiece更彻底,SentencePiece是先以Char-based进行处理,然后遇到OOV再以Byte-based处理,BytePiece则是在一开始就将文本通过text.encode()转为Bytes,然后才进行后续操作,相比之下更加纯粹。
1.7.2 分词算法
基于词典进行分词的算法无非就那几种,比如最大匹配、最短路径、最大概率路径等,
跟jieba等中文分词工具一样,BytePiece选择的是最大概率路径分词,也称“一元文法模型”,即Unigram。
选择Unigram有三方面的考虑:
第一,Unigram的最大概率换言之就是最大似然,而LLM的训练目标也是最大似然,两者更加一致;
第二,从压缩的角度看,最大概率实际上就是最短编码长度(也叫最小描述长度),是压缩率最大化的体现,这也跟“压缩就是智能”的信仰一致;
第三,Unigram求最优分词方案可以通过Viterbi算法在线性复杂度内完成,这是理论最优的复杂度了。
当然,既然有“一元文法模型”,自然也有更复杂的“二元文法模型”、“三元文法模型”等,但它们的复杂度增加远大于它能带来的收益,所以我们通常不考虑这些高阶模型。
1.7.3 训练算法
Tokenizer的训练本质上就是以往的“新词发现”,而笔者之前也提了好几种新词发现算法。现在看来,跟Unigram分词算法最契合、最有潜力的,应该是《基于语言模型的无监督分词》,
BytePiece的训练就是基于它实现的,这里称之为Byte-based N-gram Language Model(BNLM)。
具体来说,对于Unigram分词,如果一个长度为l的字节串c1,c2,…,cl,最优分词结果为w1,w2,…,wm,那么概率乘积p(w1)p(w2)…p(wm)应该是所有切分中最大的。
设w1,w2,⋯,wm的长度分别为l1,l2,⋯,lm,那么根据条件分解公式
∏i=1mp(wi)=∏i=1m∏j=Li−1+1j=Li−1+lip(cj|cLi−1+1,⋯,cj−1) (1)
这里Li=l1+l2+⋯+li。只考虑n-gram模型,将j>Li−1+n的p(cj|cLi−1+1,⋯,cj−1)统一用p(cj|cj−n+1,⋯,cj−1)近似,
那么Unigram分词就转化为一个字(节)标注问题,而Tokenizer的训练则转化为n-gram语言模型的训练(推荐n=6),可以直接无监督完成。
(注意:n=6只是说BytePiece的统计信息最多到6-gram,但并非最大只能生成长度为6的piece,因为大于6的n-gram条件概率我们会用6-gram的近似,所以它是可以做到任意阶的,即理论上可以生成任意长度piece。)
1.7.4 代码实现&效果
Github:https://github.com/bojone/bytepiece
代码很简单,单文件,里边就Trainer和Tokenizer两个类,分别对应分词两部分。分词借助pyahocorasick来构建AC自动机来稍微提了一下速,能凑合用,但还是会比SentencePiece慢不少,毕竟速度方面纯Python跟C++确实没法比。
训练则分为四个主要步骤:
1、n-gram计数;
2、n-gram剪枝;
3、预分词;
4、预分词结果剪枝。
其中1、3、4都是计算密集型,并且都是可并行的,所以编写了相应的多进程实现。在开足够多的进程(笔者开了64进程,每个进程的使用率基本上都是满的)下,训练速度能媲美SentencePiece的Unigram训练速度。
这里特别要提一下结果剪枝方面。剪枝最基本的依据自然是频数和vocab_size,但这还不够,因为有时候会出现p(w1)p(w2)>p(w1∘w2)(w1∘w2指两个词拼接)且w1,w2,w1∘w2三个词都在词表中,这种情况下w1∘w2这个词永远不会切分出来,所以将它放在词表中是纯粹浪费空间的,因此剪枝过程也包含了这类结果的排除。
首先做个小规模的测试,从悟道之前开源的数据集里边随机采样10万条作为训练集(导出来的文件大概330MB),然后另外采样1千作为测试集,训练一个vocab_size=50k的词表,结果对比如下:
接下来进行一个更大规模的测试。从中英比例大致为3:5的混合语料库中,抽取出10万条样本训练vocab_size=100k的Tokenizer。这个语料库的文本都比较长,所以这时候10万条导出来的文件已经13GB了,测试集包含两部分,一部分是同样的语料库中采样出1000条(即同源),另一部分是刚才采样出来的1000条悟道数据集(代表不同源)。结果如下: