随机分词与tokenizer(BPE->BBPE->Wordpiece->Unigram->sentencepiece->bytepiece)

0 tokenizer综述

  • 根据不同的切分粒度可以把tokenizer分为: 基于词的切分,基于字的切分和基于subword的切分。 基于subword的切分是目前的主流切分方式。
  • subword的切分包括: BPE(/BBPE), WordPiece 和 Unigram三种分词模型。其中WordPiece可以认为是一种特殊的BPE。
  • 完整的分词流程包括:文本归一化,预切分,基于分词模型的切分,后处理。
  • SentencePiece是一个分词工具,内置BEP等多种分词方法,基于Unicode编码并且将空格视为特殊的token。这是当前大模型的主流分词方案。
    BPE:GPT, GPT-2, GPT-J, GPT-Neo, RoBERTa, BART, LLaMA, ChatGLM-6B, Baichuan

WordPiece:BERT, DistilBERT,MobileBERT

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, ChatGLM等
因为BPE是从字符级别的小词表,逐步合并成大词表,所以需要先获得字符级别的小词表。

基于word2splits统计vocabs中相邻两个pair的词频pair2count

经过统计,当前频率最高的pair为: ('Ġ', 't'), 频率为7次。 将('Ġ', 't')合并成一个词并添加到词表中。同时在合并规则中添加('Ġ', 't')这条合并规则。

根据更新后的vocab重新对word2count进行切分。具体实现上,可以直接在旧的word2split上应用新的合并规则('Ġ', 't')

从而获得新的word2split

重复上述循环直到整个词表的大小达到预先设定的词表大小。
在推理阶段,给定一个句子,我们需要将其切分成一个token的序列。 具体实现上需要先对句子进行预分词并切分成字符级别的序列,然后根据合并规则进行合并。
BPE 的适用范围

BPE 一般适用在欧美语言拉丁语系中,因为欧美语言大多是字符形式,涉及前缀、后缀的单词比较多。而中文的汉字一般不用 BPE 进行编码,因为中文是字无法进行拆分。对中文的处理通常只有分词和分字两种。理论上分词效果更好,更好的区别语义。分字效率高、简洁,因为常用的字不过 3000 字,词表更加简短。

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.4 WordPiece

WordPiece分词与BPE非常类似,只是在训练阶段合并pair的策略不是pair的频率而是互信息。

这里的动机是一个pair的频率很高,但是其中pair的一部分的频率更高,这时候不一定需要进行该pair的合并。 而如果一个pair的频率很高,并且这个pair的两个部分都是只出现在这个pair中,就说明这个pair很值得合并。
训练方法:从字符级的小词表出发,训练产生合并规则以及一个词表

编码方法:将文本切分成词,对每个词在词表中进行最大前向匹配

经典模型:BERT及其系列DistilBERT,MobileBERT等
在训练环节,给定语料,通过训练算法,生成最终的词表。 WordPiece算法也是从一个字符级别的词表为基础,逐步扩充成大词表。合并规则为选择相邻pair互信息最大的进行合并。

cpp 复制代码
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条悟道数据集(代表不同源)。结果如下:

相关推荐
周圣贤19 天前
我的食物信使女友
中文分词
晨曦54321021 天前
中文分词与数据可视化02
自然语言处理·中文分词
晨曦54321021 天前
中文分词与数据可视化
信息可视化·中文分词
老马啸西风1 个月前
敏感词 v0.25.0 新特性之 wordCheck 策略支持用户自定义
人工智能·ai·nlp·中文分词·openai·deepseek·mcp
真的睡不醒ovo2 个月前
elasticsearch中文分词器插件下载教程
大数据·elasticsearch·中文分词
多吃轻食2 个月前
Jieba分词的原理及应用(三)
人工智能·深度学习·自然语言处理·中文分词·分词·jieba·隐马尔可夫
李昊哲小课2 个月前
垃圾短信分类
人工智能·python·机器学习·自然语言处理·分类·数据挖掘·中文分词
机器学习Zero3 个月前
自然语言处理(5)—— 中文分词
人工智能·自然语言处理·中文分词
郝开3 个月前
ElasticSearch 分词器介绍及测试:Standard(标准分词器)、English(英文分词器)、Chinese(中文分词器)、IK(IK 分词器)
elasticsearch·中文分词·ik·ik analyzer
爱刘温柔的小猪3 个月前
Java不用模型,直接实现中文分词(HanLP)
java·ai·中文分词