基于子词的分词方法:BPE算法
了解NLP或LLM的同学应该都对文本嵌入不陌生,例如Word2Vec,GloVe等。这些嵌入方法的第一步都是对文本进行分词,随后以各种各样的方式将分词Token转化为编码。
在这过程中,分词这一步经常会被忽视。如果以字母单位进行分词,就会导致词表过大,推理时速度非常冗长,如果单词单位进行分词,就会无法处理未知的词汇。尤其是词库过大时,在上万个单词中计算概率分布,计算量非常大。今天就介绍一下BPE(Byte Pair Encoding)分词算法。这个算法被各大模型广泛使用,用以构建词表。
完整分词流程
一个完整的分词流程是这样的:输入句子为:"I went to New York last week."
分词器会将这话分为:['i', 'went', 'to', 'New', 'York', 'last', 'week']
这就是最普通的分词。然而它无法泛化变位的词关系。例如,当模型学习了old, older, oldest
,这三个词是不同形态下的一个词。它不知道smart, smarter, smartest
这是和类似的变位关系。
此外,old, older, oldest
本身也会被当作三个独立的词进行学习,这等于丢失了很多语言信息。为了解决这个问题,出现了基于子词的分词方法。
基于子词的分词方法
基于子词(subword)的分词方法很符合逻辑,英语中有词根的定义,相同的词根在语义上类似。因此,人们尝试提取词根来进行分词。再进一步,又将词根进一步细化,把词根又切分成更小的子词。例如unfortunately
可以被拆分为:un
+ for
+ tun
+ ate
+ ly
。`
总结一下,与传统分词对比,基于子词的分词方法的优势在于:
- 可以更好的处理未知或罕见的词汇。
- 可以学习到词缀之间的关系。
- 分词比单词的词根分词更细。
BPE算法
BPE算法本身的名字有点不符合分词这个任务类型:Bytes Pair Encoding。其实它来自于一种数据压缩算法。
BPE算法的核心思想:每一步都将最常见的一对相邻数据单位替换为该数据中没有出现过的一个新单位,反复迭代直到满足停止条件。
假设有需要编码(压缩)的数据aaabdaaabac
。可以注意到相邻字节对aa
最常出现,因此我们将用一个新字节 Z
替换它。
- 现在有了
ZabdZabac
,其中Z
=aa
。下一个常见的字节对是ab
,用Y
替换它。 - 现在有
ZYdZYac
,其中Z
=aa
,Y
=ab
。剩下的唯一字节对是ac
,它只有一个,所以我们不对它进行编码。 - 我们可以递归地使用字节对编码将
ZY
编码为X
。 - 我们的数据现在已转换为
XdXac
,其中X
=ZY
,Y
=ab
,Z
=aa
。它不能被进一步压缩,因为没有出现多次的字节对。 - 该算法不断重复此过程,直到不能进一步压缩 ,比如说没有更多高频 byte 对,或是没有没用过的 byte 来进行替换表示了。最后算法会在写出压缩数据前,写出替换 byte 对的替换表。
这个算法在NLP长时间的发展中被引入。在LLM中,脱离了Bytes的层面,而转移到字符层面。在具实现上,采用了以下的步骤:
- 计算每对相邻字符/子词的频率
- 找到出现频率最高的相邻字符或子词,并将它们合并成一个新的符号
- 在词汇表中添加这个新的符号
- 更新输入文本中的所有相邻字符或子词,用新的符号替换它们
- 重新计算各对相邻字符或子词的频率,回到步骤2
更具体的流程如下:
- 把每个文档 $d$ 变成一个个单词,比如你可以简单用空格分词就好
- 统计每个单词 $w$ 在所有文档中的出现频率,并得到初始的字符集
alphabet
作为一开始的 Vocab(包括后面的</w>
) - 先将每个单词划分为一个个 utf-8 char,称为一个划分,比如
highest -> h, i, g, h, e, s, t
- 然后,在每个单词的划分最后面加上
</w>
,那么现在highest -> h, i, g, h, e, s, t, </w>
- 重复下面步骤直到满足两个条件中的任意一个:1)Vocab 达到上限。2)达到最大迭代次数
- 找到最经常一起出现的 pair,并记录这个合并规则放在 merge table 里面,同时把合并之后的结果放到 Vocab 里面
- 更新所有单词的划分,假设我们发现
(h, i)
最经常一起出现,那么highest -> hi, g, h, e, s, t, </w>
加入
</w>
是为了标记单词之间的边界。
使用BPE编码
完成训练后,将得到一个merge table和一个词表Vocab。当我们需要对一段文本进行处理时,就需要先把文本拆成一个个单词,每个单词拆分成字符。随后遍历merge table,根据规则更新字符的合并策略。
示例
假设我们对一段文本统计词频,得到不同单词的频数,随后把每个单词变成一个个 utf-8 字符然后加上</w>
1 |
|
出现最频繁的字节对是e
和s
,共出现了6+3=9次,因此将它们合并:
1 |
|
现在出现最频繁的字节对是es
和t
,共出现了6+3=9次,因此将它们合并:
1 |
|
现在出现最频繁的字节对是est
和</w>
,共出现了6+3=9次,因此将它们合并:
1 |
|
出现最频繁的字节对是l
和o
,共出现了5+2=7次,因此将它们合并:
1 |
|
出现最频繁的字节对是lo
和w
,共出现了5+2=7次,因此将它们合并:
1 |
|
我们一直迭代到预设的字词词表大小或最高频的字节对的频数都为1,就得到了合适的词表。
中文分词思路
中文的分词思路也是类似的。我们在编码后,可能会得到这么一句话:”W1在W2W3W4里,W5的W6W7W8W9高。”
它对对应的哈希表是:
1 |
|
代码实现
看了一下网上的源码,比较清晰。代码分成了两个部分,一个是统计词频,一个是合并词。
1 |
|
1 |
|
合并高频字符对:
1 |
|
输出如下:
1 |
|
至此,我们通过输入文本,得到了一个分词的词表。下面就需要对文本进行编码和解码。
编码
对于编码,需要对文本中每个单词进行拆分,并遍历词表,寻找是否有token是当前单词的子字符串。从最长的 token 迭代到最短的 token,尝试将每个单词中的子字符串替换为 token。 最终,我们将迭代所有 token,并将所有子字符串替换为 token。 如果仍然有子字符串没被替换但所有 token 都已迭代完毕,则将剩余的子词替换为特殊 token,如
例如:
1 |
|
代码实现:
1 |
|
解码
解码则是将所有Token进行合并:
1 |
|
HuggingFace实现
HF提供了比较快捷的调用接口。
1 |
|
优点和缺点
优点:
BPE的优点前面已经提到,它比单词级别的分词表更细颗粒度,并一定程度保持了语义关系。并且也适用于一些未知的单词。
缺点:
BPE是基于统计的算法,如果语料规模小,效果不一定好。
2024/1/2 于苏州家中