BPE(Byte Pair Encoding,字节对编码)
引言
在读RoBERTa的论文时发现其用于一种叫作BPE(Byte Pair Encoding,字节对编码)的子词切分技术。今天就来了解一下这个技术。
一般对于英语这种语言,尽管词语之间已经有了空格分隔符,但是英语的单词往往具有复杂的词形变换,如果只是用空格进行切分,会导致数据稀疏问题。
传统的处理方法根据语言学规则,引入词形还原(Lemmatization)或词干提取(Stemming),提取出单词的词根,从而一定程度上缓解了数据稀疏的问题。比如’fishing”,'fished”,'fish”和’fisher” 为同一个词根’fish”。但是需要人工编写大量的规则,同时不容易扩展到新的领域。因此,基于统计的无监督子词(Subword)切分任务应运而生,并在现代的预训练模型上大量使用。
子词切分
子词切分是指将一个单词切分为若干连续的片段。本文重点介绍BPE技术。
BPE
BPE的步骤如下:
- 初始化语料库
- 将语料库中每个单词切分成字符作为子词,并在单词结尾增加一个
</w>
字符 - 用切分的子词构成初始子词词表
- 在语料库中统计单词内相邻子词对的频次
- 合并频次最高的子词对,合并成新的子词,并将新的子词加入到子词词表
- 重复步骤4和5直到进行了设定的合并次数或达到了设定的子词词表大小
我们以BPE论文1中的例子为例。
假设语料库中存在4个单词,并且我们已经统计了每个单词对应的频次。每个单词结尾增加了一个</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}
初始化的子词词表为4个单词包含的全部字符:
{'l','o', 'w', '</w>', 'e', 'r', 'n', 's', 't', 'i', 'd'}
然后统计单词内相邻的两个子词的频次:
{'lo': 7, 'ow': 7, 'w</w>': 5, 'we': 8, 'er': 2, 'r</w>': 2, 'ne': 6, 'ew': 6, 'es': 9, 'st': 9, 't</w>': 9, 'wi': 3, 'id': 3, 'de': 3}
并选取频次最高的子词对’e’和’s’,合并成新的子词’es’。其中es在newest</w>中出现了6次,在widest</w>总出现了3次,一共是9次。但是可以看到,其中st和t</w>也是出现了9次,这里选取的是第一个,es。
然后加入子词词表中,并将语料库中不再存在的子词s
从子词词表中删除。此时,语料库变为:
{'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}
子词词表:
{'l','o', 'w', '</w>', 'e', 'r', 'n', 't', 'i', 'd', 'es'}
然后,合并下一个子词对es
和t
,新的语料库变成了:
{'l o w </w>' : 5, 'l o w e r </w>' : 2,'n e w est </w>':6, 'w i d est </w>':3}
子词词表(删除了语料库中不存在的t
):
{'l','o', 'w', '</w>', 'e', 'r', 'n', 'i', 'd', 'est'}
重复以上过程,直到子词词表大小达到一个期望的词表大小为止。
最终得到的语料库为:
{'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'wi d est</w>': 3}
子词词表为:
{'low</w>', 'low', 'e', 'r', '</w>', 'newest</w>', 'wi', 'd', 'est</w>'}
源码分析
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中 pairs[symbols[i], symbols[i + 1]] += freq return pairs # 用频次最高的子词对,合并成新的子词 # 并返回新的语料库 def merge_vocab(pair, v_in): v_out = {} # 返回新的语料库(删除了不存在的子词) # re.escape 对非字母字符进行转义,比如'e s'转义为'e\\ s' bigram = re.escape(' '.join(pair)) # (?<!\S)bigram(?!\S) : 匹配前面是空白符,且后面是空白符的bigram p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)') for word in v_in: # 比如,将word中出现类似 ' e s '(前面和后面都是空白符)替换为' es ' 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 = 10 for i in range(num_merges): pairs = get_stats(vocab) # print(pairs) best = max(pairs, key=pairs.get) vocab = merge_vocab(best, vocab) print(best)
应用
构造好子词词表后,如何将一个单词切分成子词序列呢?一般采用贪心算法,
- 将子词词表按照子词的长度由大到小进行排序;
- 然后从前向后遍历子词词表,依次判断一个子词是否为单词的子串,
- 若是,则将该单词切分,然后继续遍历子词词表。
- 如果子词词表遍历结束,单词中仍然有子串没有被切分,那么这些子串一定为低频词,则使用统一的标记
<UNK>
进行替换。
编码
给定句子(单词序列),根据子词词表对单词序列进行子词切分的过程,叫作对句子编码。
比如给定句子为”the highest mountain”,然后切分句子中的单词并增加</w>
标志:
['the</w>', 'highest</w>', 'mountain</w>']
假设已有的排好序的子词词表为:
['errrr</w>', 'tain</w>', 'moun', 'est</w>', 'high', 'the</w>', 'a</w>']
那么根据上面描述的过程,子词切分的结果为:
['the</w>', 'high', 'est</w>', 'moun', 'tain</w>']
解码
解码就是编码的逆过程,即将子词切分的结果还原成句子。此时</w>
就发挥作用了,只要将编码的结果进行拼接,再将</w>
替换为空格,就还原成原始的句子了。
比如上面的编码结果拼接为:the</w>highest</w>mountain</w>
然后替换空格:the highest mountain