(转载)深入理解NLP Subword算法:BPE、WordPiece、ULM
目录
前言
Subword算法如今已经成为了一个重要的NLP模型性能提升方法。自从2018年BERT横空出世横扫NLP界各大排行榜之后,各路预训练语言模型如同雨后春笋般涌现,其中Subword算法在其中已经成为标配。且与传统空格分隔tokenization技术的对比有很大的优势
- 传统词表示方法无法很好的处理未知或罕见的词汇(OOV问题)
- 传统词tokenization方法不利于模型学习词缀之前的关系 E.g. 模型学到的“old”, “older”, and “oldest”之间的关系无法泛化到“smart”, “smarter”, and “smartest”。
- Character embedding作为OOV的解决方法粒度太细
- Subword粒度在词与字符之间,能够较好的平衡OOV问题
话不多说,下面一起来看一下当下最热最火的三个subword算法
Byte Pair Encoding介绍
BPE(字节对)编码或二元编码是一种简单的数据压缩形式,其中最常见的一对连续字节数据被替换为该数据中不存在的字节。后期使用时需要一个替换表来重建原始数据。OpenAI GPT-2 与Facebook RoBERTa均采用此方法构建subword vector.
-
优点
-
可以有效地平衡词汇表大小和步数(编码句子所需的token数量)。
-
缺点
-
基于贪婪和确定的符号替换,不能提供带概率的多个分片结果。
在NLP模型中,输入通常是一个句子,例如 "I went to New York last week." ,一句话中包含很多单词(token)。传统的做法是将这些单词以空格进行分隔,例如['i', 'went', 'to', 'New', 'York', 'last', 'week']。然而这种做法存在很多问题,例如模型无法通过walked, walking,walker之间的关系学到talked,talking,talker之间的关系。如果我们能使用将一个token分成多个subtokens,上面的问题就能很好的解决。
现在性能比较流行的NLP模型,例如GPT、BERT、RoBERTa等,在数据预处理的时候都会有WordPiece的过程,其主要的实现方式就是BPE(Byte-Pair Encoding)。具体来说,例如['loved', 'loving', 'loves']这三个单词。其实本身的语义都是"爱"的意思,但是如果我们以词为单位,那它们就算不一样的词,在英语中不同后缀的词非常的多,就会使得词表变的很大,训练速度变慢,训练的效果也不是太好。BPE算法通过训练,能够把上面的3个单词拆分成["lov","ed","ing","es"]几部分,这样可以把词的本身的意思和时态分开,有效的减少了词表的数量。
算法流程如下:
- 准备足够大的训练语料
- 确定期望的subword词表大小
- 将单词拆分为字符序列并在末尾添加后缀“ </ w>”,统计单词频率。 本阶段的subword的粒度是字符。 例如,“ low”的频率为5,那么我们将其改写为“ l o w </ w>”:5
- 统计每一个连续字节对的出现频率,选择最高频者合并成新的subword
- 重复第4步直到达到第2步设定的subword词表大小或下一个最高频的字节对出现频率为1
停止符</w>的意义在于表示subword是词后缀。举例来说:st不加</w>可以出现在词首,如st ar;加了</w>表明该子词位于词尾,如wide st</w>,二者意义截然不同。
每次合并后词表可能出现3种变化:
- +1,表明加入合并后的新子词,同时原来的2个子词还保留(2个子词不是完全同时连续出现,这两个子词都有单独的出现)
- +0,表明加入合并后的新子词,同时原来2个子词中一个保留,一个被消解(一个字词完全随着另一个字词的出现而紧跟着出现,也就是说一个字词单独出现,另一个子词不单独出现,只是随着另一个子词的出现而紧跟着出现)
- -1,表明加入合并后的新子词,同时原来2个子词都被消解(2个字词同时连续出现)
实际上,随着合并的次数增加,词表大小通常先增加后减小。
举例来说:
# 原始词表 {'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 </w>': 5}
- Step1:
# 出现最频繁的序列 ('e', 's') 9 # 合并最频繁的序列后的词表 {'n e w es t </w>': 6, 'l o w e r </w>': 2, 'w i d es t </w>': 3, 'l o w </w>': 5}
- Step2:
# 出现最频繁的序列 ('es', 't') 9 # 合并最频繁的序列后的词表 {{'n e w est </w>': 6, 'l o w e r </w>': 2, 'w i d est </w>': 3, 'l o w </w>': 5}
- Step3:
# 出现最频繁的序列 ('est', '</w>') 9 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'l o w </w>': 5}
- Step4:
# 出现最频繁的序列 ('l', 'o') 7 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'lo w e r </w>': 2, 'n e w est</w>': 6, 'lo w </w>': 5}
- Step5:
# 出现最频繁的序列 ('lo', 'w') 7 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'low e r </w>': 2, 'n e w est</w>': 6, 'low </w>': 5}
- Step6:
# 出现最频繁的序列 ('n', 'e') 6 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'low e r </w>': 2, 'ne w est</w>': 6, 'low </w>': 5}
- Step7:
# 出现最频繁的序列 ('ne', 'w') 6 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'low e r </w>': 2, 'new est</w>': 6, 'low </w>': 5}
- Step8:
# 出现最频繁的序列 ('new', 'est</w>') 6 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'low e r </w>': 2, 'newest</w>': 6, 'low </w>': 5}
- Step9:
# 出现最频繁的序列 ('low', '</w>') 5 # 合并最频繁的序列后的词表 {'w i d est</w>': 3, 'low e r </w>': 2, 'newest</w>': 6, 'low</w>': 5}
- Step10:
# 出现最频繁的序列 ('w', 'i') 3 # 合并最频繁的序列后的词表 {'wi d est</w>': 3, 'newest</w>': 6, 'low</w>': 5, 'low e r </w>': 2}
- Step11:
# 出现最频繁的序列 ('wi', 'd') 3 # 合并最频繁的序列后的词表 {'wid est</w>': 3, 'newest</w>': 6, 'low</w>': 5, 'low e r </w>': 2}
- Step12:
# 出现最频繁的序列 ('wid', 'est</w>') 3 # 合并最频繁的序列后的词表 {'widest</w>': 3, 'newest</w>': 6, 'low</w>': 5, 'low e r </w>': 2}
- Step13:
# 出现最频繁的序列 ('low', 'e') 2 # 合并最频繁的序列后的词表 {'widest</w>': 3, 'newest</w>': 6, 'low</w>': 5, 'lowe r </w>': 2}
- Step14:
# 出现最频繁的序列 ('lowe', 'r') 2 # 合并最频繁的序列后的词表 {'widest</w>': 3, 'newest</w>': 6, 'low</w>': 5, 'lower </w>': 2}
- Step15:
# 出现最频繁的序列 ('lower', '</w>') 2 # 合并最频繁的序列后的词表 {'widest</w>': 3, 'newest</w>': 6, 'low</w>': 5, 'lower</w>': 2}
代码:
import re, collections def get_stats(vocab): pairs = collections.defaultdict(int) for word, freq in vocab.items(): symbols = word.split() for i in range(len(symbols)-1): pairs[symbols[i],symbols[i+1]] += freq return pairs def merge_vocab(pair, v_in): v_out = {} bigram = re.escape(' '.join(pair)) p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)') for word in v_in: w_out = p.sub(''.join(pair), word) v_out[w_out] = v_in[word] return v_out vocab = {'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} num_merges = 1000 for i in range(num_merges): pairs = get_stats(vocab) if not pairs: break best = max(pairs, key=pairs.get) vocab = merge_vocab(best, vocab) print(i+1, best)
编码:
在之前的算法中,我们已经得到了subword的词表,对该词表按照子词长度由大到小排序。编码时,对于每个单词,遍历排好序的子词词表,寻找是否有token是当前单词的子字符串,如果有,则该token是表示单词的tokens之一。我们从最长的token迭代到最短的token,尝试将每个单词中的子字符串替换为token。 最终,我们将迭代所有tokens,并将所有子字符串替换为tokens。 如果仍然有子字符串没被替换但所有token都已迭代完毕,则将剩余的子词替换为特殊token,如<unk> 。
# 给定单词序列 ["the</w>", "highest</w>", "mountain</w>"] # 排好序的subword表 # 长度 6 5 4 4 4 4 2 ["errrr</w>", "tain</w>", "moun", "est</w>", "high", "the</w>", "a</w>"] # 迭代结果 "the</w>" -> ["the</w>"] "highest</w>" -> ["high", "est</w>"] "mountain</w>" -> ["moun", "tain</w>"]
编码的计算量很大。在实践中,我们可以pre-tokenize所有单词,并在词典中保存单词tokenize的方式。如果我们看到字典中不存在的未知单词。我们应用上述编码方法对单词进行tokenize,然后将新单词的tokenization添加到字典中备用。
解码:
将所有的tokens拼在一起即可,例如:
# 编码序列 ["the</w>", "high", "est</w>", "moun", "tain</w>"] # 解码序列 "the</w> highest</w> mountain</w>"
这样就得到了原来的单词序列。
WordPiece Model介绍
WordPiece算法可以看作是BPE的变种。不同点在于,WordPiece基于概率生成新的subword而不是下一最高频字节对。
算法流程如下:
- 准备足够大的训练语料
- 确定期望的subword词表大小
- 将单词拆分成字符序列
- 基于第3步数据训练语言模型
- 从所有可能的subword单元中选择加入语言模型后能最大程度地增加训练数据概率的单元作为新的单元
- 重复第5步直到达到第2步设定的subword词表大小或概率增量低于某一阈值
WPM和BPE的主要区别在第5步,BPE选择最频繁的组合,而WPM选择最有可能的组合。
Unigram Language Model介绍
ULM是另外一种subword分隔算法,它能够输出带概率的多个子词分段。它引入了一个假设:所有subword的出现都是独立的,并且subword序列由subword出现概率的乘积产生。WordPiece和ULM都利用语言模型建立subword词表。
算法
- 准备足够大的训练语料
- 确定期望的subword词表大小
- 给定词序列优化下一个词出现的概率
- 计算每个subword的损失
- 基于损失对subword排序并保留前X%。为了避免OOV,建议保留字符级的单元
- 重复第3至第5步直到达到第2步设定的subword词表大小或第5步的结果不再变化
直观理解Subword模型处理OOV问题
如果使用Subword方法分词,OOV问题几乎不可能发生,任何没有出现在词表中的单词都会被分解成subword单元,对于一些罕见的单词会被分解成subword单元表示。设想一下模型是如何学习单词walking的。假设单词walking在训练集的出现次数很少,模型并不能很好的学习这个单词。同时假设单词walked,walker,walks出现次数都很少,如果不使用Subword方法,每个单词都会被独立看待,模型对那些出现次数少的单词学习不佳。然后,如果使用Subword方法,经过分词后,我们会得到walk,ing,ed,er,s等的。可以看出这时walk出现的次数比较频繁,模型能更好的学习。
总结
- subword可以平衡词汇量和对未知词的覆盖。 极端的情况下,我们只能使用26个token(即字符)来表示所有英语单词。一般情况,建议使用16k或32k子词足以取得良好的效果,Facebook RoBERTa甚至建立的多达50k的词表。
- 对于包括中文在内的许多亚洲语言,单词不能用空格分隔。 因此,初始词汇量需要比英语大很多。