BPE算法详解
在NLP模型中,输入通常是一个句子,例如"I went to New York last week"
,一句话中包含很多单词(token)。传统的做法是将这些单词以空格进行分隔,例如['i', 'went', 'to', 'New', 'York', 'last', 'week']
。然而这种做法存在很多问题,例如模型无法通过old, older, oldest
之间的关系学到smart, smarter, smartest
之间的关系。如果我们能使用将一个 token 分成多个 subtokens ,上面的问题就能很好的解决。本文将详述目前比较常用的subtokens算法——BPE(Byte-Pair Encoding)。
BPE(Byte Pair Encoding)
算法流程如下:
- 设定最大subwords个数V
- 将所有单词拆分为单个字符,并在最后添加一个停止符
</w>
,同时标记出该单词出现的次数。例如,"low"
这个单词出现了 5 次,那么它将会被处理为{'l o w </w>': 5}
- 统计每一个连续字节对的出现频率,选择最高频者合并成新的subword
- 重复第3步直到达到第1步设定的subwords词表大小或下一个最高频的字节对出现频率为1
例如:
1 | {'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} |
出现最频繁的字节对是e
和s
,共出现了 6+3=9 次,因此将它们合并
1 | {'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} |
出现最频繁的字节对是es
和t
,共出现了 6+3=9 次,因此将它们合并
1 | {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3} |
出现最频繁的字节对是est
和</w>
,共出现了 6+3=9 次,因此将它们合并
1 | {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3} |
出现最频繁的字节对是l
和o
,共出现了 5+2=7 次,因此将它们合并
1 | {'lo w </w>': 5, 'lo w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3} |
出现最频繁的字节对是lo
和w
,共出现了 5+2=7 次,因此将它们合并
1 | {'low </w>': 5, 'low e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3} |
...... 继续迭代直到达到预设的subwords词表大小或下一个最高频的字节对出现频率为1。这样我们就得到了更加合适的词表,这个词表可能会出现一些不是单词的组合,但是其本身有意义的一种形式。
停止符</w>
的意义在于表示subword是词后缀。举例来说:st
不加</w>
可以出现在词首,如st ar
;加了</w>
表明改字词位于词尾,如wide st</w>
,二者意义截然不同。
1 | # BPE词表构建代码实现 |
编码和解码
编码
在之前的算法中,我们已经得到了 subword 的词表,对该词表按照字符个数由多到少排序。编码时,对于每个单词,遍历排好序的子词词表寻找是否有 token 是当前单词的子字符串,如果有,则该 token 是表示单词的 tokens 之一。
我们从最长的 token 迭代到最短的 token,尝试将每个单词中的子字符串替换为 token。 最终,我们将迭代所有 tokens,并将所有子字符串替换为 tokens。 如果仍然有子字符串没被替换但所有 token 都已迭代完毕,则将剩余的子词替换为特殊 token,如<unk>
。
例如
1 | # 给定单词序列 |
解码 将所有的 tokens 拼在一起即可,例如
1 | # 编码序列 |
1 | # 在构建词表代码中加入编码和解码过程的代码 |
GPT1中的BPE
GPT中的BPE代码实现在编码部分和上述的有些不同,这里展示GPT1的实现:
1 | import re |
一些细节上的区别:
- 最后一个字符和停止符
</w>
之间不加空格。
词表构建
可以看到代码中有两个文件,一个是encoder_path
,一个是bpe_path
。 其中bpe_path
是上述步骤中的每次迭代的best pair,即每次迭代出现频率最高的字节对,其形式如下:
1 | t h |
而encoder_path
是最终得到的词表,形式如下:
1 | {".": 1, ",": 2, "t": 3, "h": 4, "e": 5, "\"": 6, "o": 7, "a": 8, "n": 9, "d": 10, "i": 11, "f": 12, "w": 13, "s": 14, "y": 15, "u": 16, "r": 17, "'": 18, "?": 19, "m": 20, ......, "bib</w>": 40474, "benteley</w>": 40475, "bachelorette</w>": 40476, "\n</w>": 40477, "<unk>": 0} |
编码
按照bpe_path
中的pair顺序,将输入中的字符合并,无法合并后得到最终的输出。具体参考bpe
和encode
两个函数。
例如:
1 | # 给定输入 |
GPT2中的BBPE(Byte-Level BPE)
从GPT2开始使用了Byte-Level BPE,包括以后得GPT3、ChatGPT等方法中都使用了这种tokenize方法。
Byte-Level BPE的优点:
- 减小词表大小。
- 统一所有语言的字符编码,不存在OOV的问题。
Byte-level BPE和BPE的区别就是BPE最小词汇是字符级别,而BBPE是字节级别,其他实现步骤完全一致。
下面展示GPT2的实现:
1 | """Byte pair encoding utilities""" |