tokenizers小结

  • 2021 年 3 月 29 日
  • AI

计算机处理文本的first step,就是使用tokenizer对文本进行分token,然后对每一个token进行label encoder转化为vocabulary dict,而不同的tokenize的方法对于后续任务也会产生不同的影响.这就好比你去饭店吃饭,你不可能直接吃原始的未经加工过的食材,而相同的食材在不同人的手里会转化为不同的菜品,在米其林大厨的手里,就是一道美味,在我妈的手里,跟原材料差不多.

粗糙来说,整体的过程大概是:

1 将输入分块,

从本质上来说,文本数据整体上先是文档集合,然后是每一篇文档,然后是每一个段落,然后是每一个句子,然后是每一个短语,然后是每一个词,然后是每一个子词,最后是每一个字符(但是后续研究人员又提出了对将字符转化为utf编码,从而得到更加细粒度的切分),而目前基本上大家都是基本会划分到词粒度(最粗糙)而不会更粗糙的划分为句粒度,因为句粒度层面,将句子划分为一个单独的输入,则句子中蕴含的语法结构\指代结构\语序关系等信息都将不复存在,但是对于nlp中的很多任务,这些都是非常重要的,例如文本分类对于语序关系比较敏感,词性标注对于语法结构比较敏感等等;

不同粒度的切分(词粒度,子词粒度,字符粒度,字符编码粒度)对后续的任务会造成不同程度的影响,但是最终我们都是形成一个vocabulary dict用于存放每一个token,大概的形式是这样:

{token1:0,token2:1,token3:2,token4:4……..}

2 embedding层,无论是传统的基于统计学的nlp的处理,例如tfidf,lsa,lda等,还是深度学习时代使用的嵌入层,都可以看作通过一个映射函数 map function,将每一个token转化为对应的某个n维的向量,这一点,一直以来的范式都是一样的,只不过embeddinng的过程中逐渐从简单的上下文无关进化到上下文相关.

tokenize的主要难点在于:

1 vocabulary dict的size太大,这是简单的word-level的分tokenize面临的主要问题,当使用word作为切分粒度往往面临着海量的word需要存储,而大型的vocabulary dict意味着超大的embedding层,无论是空间复杂度还是训练时候的时间复杂度都是比较高的;

2 oov问题,对于早期的分token的方法和对应的embedding算法,oov问题是一个严重的问题,例如我们在训练地过程中得到了 cat 的embedding,而预测的数据集存在cats 这样含义完全相同只不过是复数形式的token. 这意味着我们无法为out of vocabulary的token生成恰当的embedding,当oov很多的时候,下游的任务难以准确地完成;

3 太粗粒度的 tokenize难以让模型学到细粒度的规律(例如buy,buying,二者虽然都意味着买,但是代表了不同的时态,而word-level的tokenize则会将二者当作两个word来处理),就好比一本对理论泛泛而谈的书,我们怎么看都难以学习到coding的方法;

4 不同语言遵循着不同的语法,分隔符,句子结构等等,这常常意味着对于不同的语言要使用不同的分词策略来进行分词(英文分词真简单,很多时候按照空格切割就行,但是中文分词就没有所谓空格的结构);


下面整体概括介绍一下tokenize的类型:

Python/latest/” data-draft-node=”block” data-draft-type=”link-card” class=”LinkCard old LinkCard–noImage”>Tokenizers – tokenizers documentationhuggingface.co

特点:

1、rust实现,速度飞快,只需不到20秒即可用服务器CPU进行1GB文本处理;

2、用起来很简单,api平易近人;

3、截断、补0padding、特殊的token添加等一站式解决

Summary of the tokenizershuggingface.co

目前huggingface实现了BPE、wordpeice和sentencepiece等分词方法。

char-level和word-level的切分方式,我们使用nltk\spacy\pytext\torchtext 等这类过去非常流行的nlp library of python就可以,这类nlp 库实在是太多了,,nlp的理论基础比较复杂,但是nlp的应用确非常简单,因为工具实在是太齐全了~

常见而直观的英文或者中文分词的方式,往往是以word为基础的,例如:

"Don't you love   Transformers? We sure do."
分词为
["Don", "'", "t", "you", "love", " ", "Transformers", "?", "We", "sure", "do", "."]

“我来自中国”
分词为:
“我”,“来”,“ 自 ”,“中国”

word-level

这些分词的方法都是将句子拆分为词,即word-level,这么做的优缺点是:

优点:能够保存较为完整的语义信息

缺点:

1、词汇表会非常大,大的词汇表对应模型需要使用很大的embedding层,这既增加了内存,又增加了时间复杂度。通常,transformer模型的词汇量很少会超过50,000,特别是如果仅使用一种语言进行预训练的话,而transformerxl使用了常规的分词方式,词汇表高达267735;

2、 word-level级别的分词略显粗糙,无法发现更加细节的语义信息,例如模型学到的“old”, “older”, and “oldest”之间的关系无法泛化到“smart”, “smarter”, and “smartest”。

3、word-level级别的分词对于拼写错误等情况的鲁棒性不好;

char-level

一个简单的方法就是将word-level的分词方法改成 char-level的分词方法,对于英文来说,就是字母界别的,比如 “China”拆分为”C”,”h”,”i”,”n”,”a”,对于中文来说,”中国”拆分为”中”,”国”,

优点:

1、这可以大大降低embedding部分计算的内存和时间复杂度,以英文为例,英文字母总共就26个。。。。,中文常用字也就几千个。

2、char-level的文本中蕴含了一些word-level的文本所难以描述的模式,因此一方面出现了可以学习到char-level特征的词向量FastText,另一方面在有监督任务中开始通过浅层CNN、HIghwayNet、RNN等网络引入char-level文本的表示;

缺点:

1、但是这样使得任务的难度大大增加了,毕竟使用字符大大扭曲了词的意义,一个字母或者一个单中文字实际上并没有任何语义意义,单纯使用char-level往往伴随着模型性能的下降;

2、增加了输入的计算压力,原本”I love you“是3个embedding进入后面的cnn、rnn之类的网络结构,而进行char-level拆分之后则变成 8个embedding进入后面的cnn或者rnn之类的网络结构,这样计算起来非常慢;

subword-level

为了两全其美,transformer使用了混合了char-level和word-level的分词方式,称之为subword-level的分词方式。

subword-level的分词方式遵循的原则是:尽量不分解常用词,而是将不常用此分解为常用的子词,例如,”annoyingly”可能被认为是一个罕见的单词,并且可以分解为”annoying”和”ly”。”annoying”并”ly”作为独立的子词会更频繁地出现,同时,”annoyingly”是由”annoying”和”ly”这两个子词的复合含义构成的复杂含义,这在诸如土耳其语之类的凝集性语言中特别有用,在该语言中,可以通过将子词串在一起来形成(几乎)任意长的复杂词。

subword-level的分词方式使模型相对合理的词汇量(不会太多也不会太少),同时能够学习有意义的与上下文无关的表示形式(另外,subword-level的分词方式通过将模型分解成已知的子词,使模型能够处理以前从未见过的词(oov)。

subword-level又分为不同的切法,这里就到huggingface的tokenizers的实现部分了,常规的char-level或者word-level的分词用spacy,nltk之类的工具就可以胜任了。

本来打算按照分词的方式先对不同的bert based model进行一个切分,但是在研究的过程中发现,这样的划分方式并不是一个合理的划分方式,整体来说,后bert时代的诸多模型都在各个层面进行了改进,而且不同model的改进方式其实是可以互相进行组合的,例如:

训练使用的语料\分词算法\预训练任务\mask的策略\position的向量化处理\模型结构组件的替换和修改\transformer的encoder,decoder,encoder+decoder的不同的应用\batchsize和epochs的训练超参数的调整…….等等

我们其实可以进行各种排列组合得到不同的模型,简单来说,大部分的bert based model都是在各个层面进行的一些转换,这也是我进行整理的一个核心原因, 一定有一种便捷的方式弄够将不同的model系统性地组织起来,并且后续的应用可以根据这样的组织进行灵活的搭配,例如我们可以使用bert的模型结构,但是使用不同的mask策略和预训练任务进行组合,然后使用不同的语料得到适用于不同问题的专门的预训练模型.除此之外,这类策略对于bert前时代的model也具有很好的借鉴价值,例如 分词的算法就是一种典型的应用意义很强的东西,我们完全可以用bpe之类的算法来分词然后使用word2vec来训练新的简单的language model等.

下面细致介绍一下:

subword的部分,因为word-level和char-level的分词算法都比较简单了不用再多废话.

subword的分词往往包含了两个阶段,一个是encode阶段,形成subword的vocabulary dict,一个是decode阶段,将原始的文本通过subword的vocabulary dict 转化为 token的index然后进入embedding层.

1、BPE;

tk=kenizers.CharBPETokenizer()

Luke:深入理解NLP Subword算法:BPE、WordPiece、ULMzhuanlan.zhihu.com图标

参考自上文:

以语料:

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

为例,假设我们对原始的文本进行分词之后只有上面4个词,则:

1、对每个词进行词频统计,并且对每个词末尾的字符转化为 末尾字符和</w>两个字符,停止符”</w>”的意义在于表示subword是词后缀。举例来说:”st”字词不加”</w>”可以出现在词首如”st ar”,加了”</w>”表明改字词位于词尾,如”wide st</w>”,二者意义截然不同。

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

此时我们的词表为

{“l”,”o”,’w’,”e”,”r”,”</w>”,”n”,”s”,”t”,”i”}

2、统计每一个连续字节对的出现频率,选择最高频者合并成新的subword

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}

最高频连续字节对”e”和”s”出现了6+3=9次,合并成”es”

需要注意的是,”es”生成后会消除”s”,因为上述语料中 “s”总和”es”共同出现,但是s除了”es”外就没有其它的字符组合出现了,所以”s”被消除,但是”e”在”lower”中出现过有”er”或”we”这样的组合,所以”e”没有被消除,此时词表变化为:

{“l”,”o”,’w’,”e”,”r”,”</w>”,”n”,”es”,”t”,”i”}

(补充另外两种情况:

如果es中的e和s都是和es一起出现,除此之外没有单独再出现其它的字符组合,则es一起雄消除;

如果es中的e和s都各自有和其它字符的组合,则e和s都不会被消除,但是多了个新词es)

3、继续上述过程:

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3}

此时最高频连续字节对”es”和”t”出现了6+3=9次, 合并成”est”。输出:

同理,上一步的词表中”t”被消除,”est”加入此表

。。。。。

继续迭代直到达到预设的subword词表大小或下一个最高频的字节对出现频率为1。

上述就完成了编码过程,得到了词表,然后要把原始的word-level的分词结果进行相应转化:

1、将此表按照其中token的长度,从长到短排列;

例如排序好之后的词表为:

[“errrr</w>”, “tain</w>”, “moun”, “est</w>”, “high”, “the</w>”, “a</w>”]

2、对原始的word-level的分词结果进行转化,例如原始的语料为:

[“the</w>”, “highest</w>”, “mountain</w>”]

则转化为:

"the</w>" -> ["the</w>"]
"highest</w>" -> ["high", "est</w>"]
"mountain</w>" -> ["moun", "tain</w>"]

这样就完成了BPE的分词了;

迭代的终止条件是subword词表大小或下一个最高频的字节对出现频率为1。

  • 优点
  • 可以有效地平衡词汇表大小和步数(编码句子所需的token数量)。
  • 缺点
  • 基于贪婪和确定的符号替换,不能提供带概率的多个分片结果(相对于unigram来说),最终会导致decode的时候面临含糊不清的问题.

看下面的例子:

假设我们得到了subword的vocabulary dict为:

则对于 “deep learning”这个词的decode,我们可以用多种选择:

尽管输入文本相同,但可以用三种不同的编码表示。对于模型来说,这是一个问题,因为不同的编码表示对deep learning生成的嵌入会有所不同,从而影响模型训练的准确性.

2 unigram

这部分感觉还是有点迷糊,算了明天看看论文好好研究一下


我们已经看到,使用bpe进行tokenize可能会导致最终编码的模棱两可的问题。这个问题的核心缺陷在于,在对任何新的输入文本进行编码时,我们无法预测哪个特定的decode方式更可能是最佳方案

在理解unigram之前需要明确两个基本概念:

1 语言模型概率

假设句子 S=(t_{1},t_{2},...,t_{n})由n个子词组成,t_{i}表示子词,且假设各个子词之间是独立存在的,则句子S的语言模型似然值(语言模型概率)等价于所有子词概率的乘积:

假设把相邻位置的x和y两个子词进行合并,合并后产生的子词记为z,此时句子S似然值的变化可表示为:

从上面的公式,很容易发现,似然值的变化就是两个子词之间的互信息。简而言之,WordPiece每次选择合并的两个子词,他们具有最大的互信息值,也就是两子词在语言模型上具有较强的关联性,它们经常在语料中以相邻方式同时出现。

可以看到,子词结合的互信息的计算过程和决策树是相似但相反的,决策树是越切分越细,而子词的结合则是越结合越粗.

2 维特比算法

关于维特比算法可见:

如何通俗地讲解 viterbi 算法?www.zhihu.com图标

维特比算法的思路整体也不复杂,就是在穷举的基础上对每t步的选择进行剪枝从而使得t+1需要考虑的决策路径大大减少从而提高了计算的效率.

unigram算法

//everdark.github.io/k9/notebooks/ml/natural_language_understanding/subword_units/subword_units.nb.html#121_expectation-maximizationeverdark.github.io

终于在google上找到了比较完整的解答.

1 [期望,em中的e步]通过词汇表中相应的频率计数来估计每个子词的概率

2 [最大化,em中的m步]使用维特比算法分割语料,返回最佳分割

3 计算最佳分段中每个新子词的损失

4 通过丢弃损失最小前X%的子词来缩小词汇量

5 重复步骤2到5,直到词汇量达到所需的数量

感觉这里说的em比高斯混合模型中提到的em要简单好理解不少,就是代表了一种迭代计算的思路.其中计算每个新子词的损失的过程和bpe一样,用的也是句子的语言模型概率和互信息的内容.

看上图,我们可以将上图当作一个进行tokenize的过程,

1 从S到Ax的过程,基于BPE的算法在这个过程中会直接选择S-A3然后进入下一步的计算过程,而基于unigram的算法则会保留S-A1,S-A2,S-A3的计算结果进入下一步计算过程;

2 从Ax到Bx的过程,基于BPE的算法在上一阶段直接按照最高频率保留了S-A3舍弃了S-A1和S-A2,因此基于BPE的方式只能选择

3、wordpiece

对应的接口为:

from tokenizers import BertWordPieceTokenizer

wordpiece是BPE算法的变种,整体的计算思路和BPE类似,仅仅在生成subword的时候不同,见下面的例子

wordpice整体上和BPE的计算思路类似,只不过下面的这个阶段不一样

(2).(统计每一个连续字节对的出现频率,选择最高频者合并成新的subword)BPE使用的方法是k括号中的,而wordpiece则使用了另一种方法

最高频连续字节对”e”和”s”出现了6+3=9次,合并成”es”

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}

而wordpiece则是从整个句子的层面出发去确认subword的合并结果,假设有个句子是:

“see you next week”初始拆分为字符之后是

“s”,”e”,”e”……. “e”,”k”

则语言模型概率为:

n表示这个句子拆分成字符之后的长度(继续迭代的话就是拆分成subword的长度了),P(ti)表示”ti”这个字符或者subword在词表中占比的概率值,不过我们只需要计算下面的式子就可以:

可以看到,这里和决策树的分裂过层非常类似,两个两个相邻字符或subword之间进行分裂判断分裂增益是否增大,增大则合并。

从上面的公式,很容易发现,似然值的变化就是两个子词之间的互信息。简而言之,WordPiece每次选择合并的两个子词,他们具有最大的互信息值,也就是两子词在语言模型上具有较强的关联性,它们经常在语料中以相邻方式同时出现。

from

阿北:NLP三大Subword模型详解:BPE、WordPiece、ULMzhuanlan.zhihu.com图标

上述的BPE和wordpiece在huggingface 中的实现稍有不同,

huggingface的bert中额外使用”##”用于表示某个subword 不是一个单词的开头.

2、ByteLevelBPE

tk=tokenizers.ByteLevelBPETokenizer()

前面提到bpe以词频top-k数量建立的词典;但是针对字符相对杂乱的日文和字符较丰富的中文,往往他们的罕见词难以表示,就中文来说,字符级别就是到单个中文字,例如“窃位素餐”(指高级官员饱食终日,无所用心,贬义词)这样生僻的词语最后很可能都没法纳入词典之中,但是,举个例子,在情感分析中,这个词对于句子的情感分析又可能是决定性作用的。

为了理解这个算法,看了一下原论文:

//arxiv.org/pdf/1909.03341.pdfarxiv.org

BBPE整体和BPE的逻辑类似,不同的是,粒度更细致,BPE最多做到字符级别,但是BBPE是做到byte级别:

日文看不懂,看英文吧,“ask”这个单词变成字符是”a”,”s”,”k”,然后对”a”,”s”,”k”做utf8编码得到 “41 73 6B E2 96 81 71 75 65 73 74 69 6F 6E 73”。。。。这样得到了byte级别的初始的分词结果,这样,原来的一个”a“变成了”41 73 6B E2 96 “。。。然后以”41″这样的字节为单位,更加细致的做BPE了。。。wtf

然后使用BPE算法来处理,这样下来encode截断我们最终的词表就是一大堆的utf8编码的组合了。。。。encode部分到这里结束

比较麻烦的是decode部分,因为字节码的组合可能会得到无意义的组合,也就是得到的utf8的编码组合可能无法映射回一个有意义的字符,所以作者使用了:

下面的公式来进行矫正。

(huggingface里提到gpt2用的是bpe

Summary of the tokenizers

Summary of the tokenizershuggingface.co

)

具体可见:

CaesarEX:浅谈Byte-Level BPEzhuanlan.zhihu.com图标

不想研究。。。

5 sentencepiece

sentencepiece不是一种分词算法,

它是谷歌推出的子词开源工具包,其中集成了BPE、ULM子词等算法。除此之外,SentencePiece还能支持字符和词级别的分词。更进一步,为了能够处理多语言问题,sentencePiece将句子视为Unicode编码序列,从而子词算法不用依赖于语言的表示。

在huggingface中我们使用的是tokenizers来作为子词的分词开源工具来使用.下面是tokenizers中的模块名称展示可以看到实现了大部分的分词算法.

根据huggingface官方文档的描述:

到目前为止描述的所有标记化算法都存在相同的问题:假定输入文本使用空格分隔单词。但是,并非所有语言都使用空格来分隔单词。一种可能的解决方案是使用特定于语言的预令牌,例如 XLM使用特定的中文,日语和泰语预令牌。为了更广泛地解决此问题,

SentencePiece:用于神经文本处理的简单且独立于语言的子词标记器和解标记器(Kudo等人,2018)arxiv.org

将输入视为原始输入流,因此在要使用的字符集中包含空格。然后,它使用BPE或unigram算法来构建适当的词汇表

XLNetTokenizer例如使用SentencePiece,这也是为什么在上述所举例子的 "▁"角色被列入词汇。使用SentencePiece进行解码非常容易,因为所有令牌都可以串联在一起"▁"并被空格替换。

库中所有使用SentencePiece的转换器模型都将它与unigram结合使用。使用SentencePiece的模型示例包括ALBERTXLNetMarianT5

简单来说,sentencepiece是为了应付多语音的问题,统一将原始的文本的每个字符进行utf-8之类的语言无关的编码然后使用bpe或者unigram来进行后续的分词.

参考自:

Tokenizers: How machines read