BPE(Byte Pair Encoding,字节对编码)

引言

在读RoBERTa的论文时发现其用于一种叫作BPE(Byte Pair Encoding,字节对编码)的子词切分技术。今天就来了解一下这个技术。

一般对于英语这种语言,尽管词语之间已经有了空格分隔符,但是英语的单词往往具有复杂的词形变换,如果只是用空格进行切分,会导致数据稀疏问题

传统的处理方法根据语言学规则,引入词形还原(Lemmatization)词干提取(Stemming)提取出单词的词根,从而一定程度上缓解了数据稀疏的问题。比如’fishing”,'fished”,'fish”和’fisher” 为同一个词根’fish”。但是需要人工编写大量的规则,同时不容易扩展到新的领域。因此,基于统计的无监督子词(Subword)切分任务应运而生,并在现代的预训练模型上大量使用。

子词切分

子词切分是指将一个单词切分为若干连续的片段。本文重点介绍BPE技术。

BPE

BPE的步骤如下:

  1. 初始化语料库
  2. 将语料库中每个单词切分成字符作为子词,并在单词结尾增加一个</w>字符
  3. 用切分的子词构成初始子词词表
  4. 在语料库中统计单词内相邻子词对的频次
  5. 合并频次最高的子词对,合并成新的子词,并将新的子词加入到子词词表
  6. 重复步骤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'}

然后,合并下一个子词对est,新的语料库变成了:

{'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

参考


  1. Neural Machine Translation of Rare Words with Subword Units ↩︎

posted on 2022-06-26 09:34  朴素贝叶斯  阅读(1813)  评论(0编辑  收藏  举报

导航