目录
[Byte-Pair Encoding (BPE)](#Byte-Pair Encoding (BPE))
[Byte-level BPE(BBPE)](#Byte-level BPE(BBPE))
前言
Tokenization(分词) 在自然语言处理(NLP)的任务中是最基本的一步,将文本处理成一串tokens用于后续的处理,把文本处理成token有一系列的方法,基本思想是构建一个词表,通过词表一一映射进行分词,但如何构建合适的词表呢?以下介绍三种Tokenization方法。
以下分别介绍三种分词粒度,并且重点介绍 subword (子词粒度) 的三种分词方法。
1、word (词粒度)
在英文语系中,word(词)粒度的分词很容易实现,因为有天然的空格作为分隔符。而在中文里面 word (词)粒度,需要一些分词工具比如jieba,以下是中文和英文的例子:
中文句子:我喜欢看电影和读书。
分词结果:我 | 喜欢 | 看 | 电影 | 和 | 读书。
英文句子:I enjoy watching movies and reading books.
分词结果:I | enjoy | watching | movies | and | reading | books.
word (词粒度) 的优点:
- 词自身的语义明确:以词为单位进行分词可以更好地保留每个词的语义。
- 词之间的语义关联:以词为粒度进行分词有助于保留词语之间的关联性和上下文信息,从而在语义分析和理解时能够更好地捕捉句子的意图。
缺点:
- 长尾效应:词表可能变得巨大,包含很多不常见的词汇,增加存储成本;稀有词的训练数据有限,难以获得准确的表示。
- OOV(Out-of-Vocabulary): 词粒度分词模型只能使用词表中的词来进行处理,无法处理词表之外的词汇,这就是所谓的OOV问题。
- 形态关系和词缀关系: 无法捕捉同一词的不同形态,比如love和loves在word(词)粒度的词表中会是两个词;也无法学习词缀在不同词汇之间的共通性,限制了模型的语言理解能力。
2、char (字符粒度)
以字符为单位进行分词,即将文本拆分成一个个单独的字符作为最小基本单元,这种字符粒度的分词方法适用于多语言,无论是英文、中文还是其他不同语言,都能够一致地使用字符粒度进行处理,因为英文就26个字母以及其他的一些符号,中文常见字就6000个左右。
中文句子:我喜欢看电影和读书。
分词结果:我 | 喜 | 欢 | 看 | 电 | 影 | 和 | 读 | 书 | 。
英文句子:I enjoy watching movies and reading books.
分词结果:I | | e | n | j | o | y | | w | a | t | c | h | i | n | g | | m | o | v | i | e | s | | a | n | d | | r | e | a | d | i | n | g | | b | o | o | k | s | .
char (字符粒度) 的优点有:
- 统一处理方式:字符粒度分词方法适用于不同语言,无需针对每种语言设计不同的分词规则或工具,具有通用性。
- 解决OOV问题:由于字符粒度分词可以处理任何字符,无需维护词表,因此可以很好地处理一些新创词汇、专有名词等问题。
缺点:
- 语义信息不明确:字符粒度分词无法直接表达词的语义,导致在一些语义分析任务中效果较差。
- 处理效率低:由于文本被拆分为字符,处理的粒度较小,增加后续处理的计算成本和时间。
3、subword (子词粒度)
在很多情况下,既不希望将文本切分成单独的词(太大),也不想将其切分成单个字符(太小),而是希望得到介于词和字符之间的子词单元。这就引入了 subword(子词)粒度的分词方法。
WordPiece
在BERT时代,WordPiece 分词方法被广泛应用,比如 BERT、DistilBERT等。WordPiece 分词方法属于 subword(子词)粒度的一种方法。
WordPiece核心思想是将单词拆分成多个前缀符号(比如BERT中的##)最小单元,再通过子词合并规则将最小单元进行合并为子词级别。例如对于单词"word",拆分如下:
w ##o ##r ##d
然后通过合并规则进行合并,从而循环迭代构建出一个词表,以下是核心步骤:
- 计算初始词表:通过训练语料获得或者最初的英文中26个字母加上各种符号以及常见中文字符,这些作为初始词表。
- 计算合并分数:对训练语料拆分的多个子词单元通过合拼规则计算合并分数。
- 合并子词对:选择分数最高的子词对,将它们合并成一个新的子词单元,并更新词表。
- 重复合并步骤:不断重复步骤 2 和步骤 3,直到达到预定的词表大小、合并次数,或者直到不再有有意义的合并(即,进一步合并不会显著提高词表的效益)。
- 分词:使用最终得到的词汇表对文本进行分词。
简单举例:
有以下的训练语料中的样例,括号中第2位为在训练语料中出现的频率:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
将其拆分为带前缀的形式:
("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)
初始化的词表为:
["b", "h", "p", "##g", "##n", "##s", "##u"]
接下来重要的一步进行计算合并分数,也称作互信息(信息论中衡量两个变量之间的关联程度),简单来说就是以下公式来计算:
分数 = 候选pair的频率 / (第一个元素的频率 × 第二个元素的频率)
对于上述样例中这个pair("##u", "##g")出现的频率是最高的20次,但是"##u"出现的频率是36次, "##g"出现的频率是20次,所以这个pair("##u", "##g")的分数是(20)/(36*20) = 1/36,同理计算这个pair("##g", "##s")的分数为(5)/(20*5) = 1/20,所以最先合并的pair是("##g", "##s")→("##gs")。此时词表和拆分后的的频率将变成以下:
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)
重复上述的操作,直到达到你想要的词表的大小:
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)
代码实现:
python
# 用一些包含中英文的文本作为训练语料,因为英文有天然的分隔符,所以在这个例子中,中文已经进行了分词:
sentences = [
"我",
"喜欢",
"吃",
"苹果",
"他",
"不",
"喜欢",
"吃",
"苹果派",
"I like to eat apples",
"She has a cute cat",
"you are very cute",
"give you a hug",
]
# 初始化词表
from collections import defaultdict
# 构建频率统计
def build_stats(sentences):
stats = defaultdict(int)
for sentence in sentences:
symbols = sentence.split()
for symbol in symbols:
stats[symbol] += 1
return stats
stats = build_stats(sentences)
print("stats:", stats)
alphabet = []
for word in stats.keys():
if word[0] not in alphabet:
alphabet.append(word[0])
for letter in word[1:]:
if f"##{letter}" not in alphabet:
alphabet.append(f"##{letter}")
alphabet.sort()
# 初始词表
vocab = alphabet.copy()
print("alphabet:", alphabet)
# 根据初始词表拆分每个词
splits = {
word: [c if i == 0 else f"##{c}" for i, c in enumerate(word)]
for word in stats.keys()
}
print("splits:", splits)
# 计算合并分数
def compute_pair_scores(splits):
letter_freqs = defaultdict(int)
pair_freqs = defaultdict(int)
for word, freq in stats.items():
split = splits[word]
if len(split) == 1:
letter_freqs[split[0]] += freq
continue
for i in range(len(split) - 1):
pair = (split[i], split[i + 1])
letter_freqs[split[i]] += freq
pair_freqs[pair] += freq
letter_freqs[split[-1]] += freq
scores = {
pair: freq / (letter_freqs[pair[0]] * letter_freqs[pair[1]])
for pair, freq in pair_freqs.items()
}
return scores
pair_scores = compute_pair_scores(splits)
for i, key in enumerate(pair_scores.keys()):
print(f"{key}: {pair_scores[key]}")
if i >= 5:
break
# 看一看分数最高的pair(子词对):
best_pair = ""
max_score = None
for pair, score in pair_scores.items():
if max_score is None or max_score < score:
best_pair = pair
max_score = score
print(best_pair, max_score)
# 合并函数
def merge_pair(a, b, splits):
for word in stats:
split = splits[word]
if len(split) == 1:
continue
i = 0
while i < len(split) - 1:
if split[i] == a and split[i + 1] == b:
merge = a + b[2:] if b.startswith("##") else a + b
split = split[:i] + [merge] + split[i + 2 :]
else:
i += 1
splits[word] = split
return splits
# 循环迭代,直到vocab达到想要的数量
vocab_size = 50
while len(vocab) < vocab_size:
scores = compute_pair_scores(splits)
best_pair, max_score = "", None
for pair, score in scores.items():
if max_score is None or max_score < score:
best_pair = pair
max_score = score
splits = merge_pair(*best_pair, splits)
new_token = (
best_pair[0] + best_pair[1][2:]
if best_pair[1].startswith("##")
else best_pair[0] + best_pair[1]
)
vocab.append(new_token)
# 结果
vocab: ['##a', '##e', '##g', '##h', '##i', '##k', '##l', '##o', '##p', '##r', '##s', '##t', '##u', '##v', '##y', '##果', '##欢', '##派', 'I', 'S', 'a', 'c', 'e', 'g', 'h', 'l', 't', 'v', 'y', '不', '他', '吃', '喜', '我', '苹', 'Sh', '喜欢', '苹果', '苹果派', 'li', 'lik', 'gi', 'giv', '##pl', '##ppl', '##ry', 'to', 'yo', 'ea', 'eat']
一般来说最后会在词表中加上一些特殊词汇、英文中26个字母、各种符号以及常见中文字符,如果训练语料比较大那么这些应该也已经包括了,只需要添加特殊词汇:
all_vocab = vocab + ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + other_alphabet
Byte-Pair Encoding (BPE)
在大语言模型时代,最常用的分词方法是Byte-Pair Encoding (BPE) 和 Byte-level BPE(BBPE),BPE 最初是一种文本压缩算法,,在15年被引入到NLP用于分词,在训练 GPT 时被 OpenAI 用于tokenization,后续好多模型GPT,RoBERTa等都采用了这种分词方法。BBPE是于19年在BPE的基础上提出以Byte-level(字节)为粒度的分词方法,目前GPT2,BLOOM,Llama,Falcon等采用的是该分词方法。
Byte-Pair Encoding (BPE)核心思想是逐步合并出现 频率最高 的子词对,而不是像Wordpiece计算合并分数来构建词汇表,以下是核心步骤:
- 计算初始词表:通过训练语料获得或者最初的英文中26个字母加上各种符号以及常见中文字符,这些作为初始词表。
- 构建频率统计:统计所有子词单元对(两个连续的子词)在文本中的出现频率。
- 合并子词对:选择出现频率最高的子词对,将它们合并成一个新的子词单元,并更新词汇表。
- 重复合并步骤:不断重复步骤 2 和步骤 3,直到达到预定的词汇表大小、合并次数,或者直到不再有有意义的合并(即,进一步合并不会显著提高词汇表的效益)。
- 分词:使用最终得到的词汇表对文本进行分词。
代码:
python
sentences = [
"我",
"喜欢",
"吃",
"苹果",
"他",
"不",
"喜欢",
"吃",
"苹果派",
"I like to eat apples",
"She has a cute cat",
"you are very cute",
"give you a hug",
]
# 初始化词表:
# 构建频率统计
def build_stats(sentences):
stats = defaultdict(int)
for sentence in sentences:
symbols = sentence.split()
for symbol in symbols:
stats[symbol] += 1
return stats
stats = build_stats(sentences)
print("stats:", stats)
alphabet = []
for word in stats.keys():
for letter in word:
if letter not in alphabet:
alphabet.append(letter)
alphabet.sort()
# 初始词表
vocab = alphabet.copy()
print("alphabet:", alphabet)
# 根据初始词表拆分每个词
splits = {word: [c for c in word] for word in stats.keys()}
print("splits:", splits)
def compute_pair_freqs(splits):
pair_freqs = defaultdict(int)
for word, freq in stats.items():
split = splits[word]
if len(split) == 1:
continue
for i in range(len(split) - 1):
pair = (split[i], split[i + 1])
pair_freqs[pair] += freq
return pair_freqs
pair_freqs = compute_pair_freqs(splits)
for i, key in enumerate(pair_freqs.keys()):
print(f"{key}: {pair_freqs[key]}")
if i >= 5:
break
# 找到出现频率最高的pair(子词对):
best_pair = ""
max_freq = None
for pair, freq in pair_freqs.items():
if max_freq is None or max_freq < freq:
best_pair = pair
max_freq = freq
print(best_pair, max_freq)
# 合并函数
def merge_pair(a, b, splits):
for word in stats:
split = splits[word]
if len(split) == 1:
continue
i = 0
while i < len(split) - 1:
if split[i] == a and split[i + 1] == b:
split = split[:i] + [a + b] + split[i + 2 :]
else:
i += 1
splits[word] = split
return splits
# 循环直到vocab达到想要的数量:
# 假设我们想要的词典为50
merges = {}
vocab_size = 50
while len(vocab) < vocab_size:
pair_freqs = compute_pair_freqs(splits)
best_pair = ""
max_freq = None
for pair, freq in pair_freqs.items():
if max_freq is None or max_freq < freq:
best_pair = pair
max_freq = freq
splits = merge_pair(*best_pair, splits)
merges[best_pair] = best_pair[0] + best_pair[1]
vocab.append(best_pair[0] + best_pair[1])
print("merges:", merges)
print("vocab:", vocab)
# 结果
merges: {('喜', '欢'): '喜欢', ('苹', '果'): '苹果', ('a', 't'): 'at', ('c', 'u'): 'cu', ('cu', 't'): 'cut', ('cut', 'e'): 'cute', ('y', 'o'): 'yo', ('yo', 'u'): 'you', ('v', 'e'): 've', ('苹果', '派'): '苹果派', ('l', 'i'): 'li', ('li', 'k'): 'lik', ('lik', 'e'): 'like', ('t', 'o'): 'to', ('e', 'at'): 'eat', ('a', 'p'): 'ap', ('ap', 'p'): 'app', ('app', 'l'): 'appl', ('appl', 'e'): 'apple', ('apple', 's'): 'apples', ('S', 'h'): 'Sh', ('Sh', 'e'): 'She', ('h', 'a'): 'ha'}
vocab: ['I', 'S', 'a', 'c', 'e', 'g', 'h', 'i', 'k', 'l', 'o', 'p', 'r', 's', 't', 'u', 'v', 'y', '不', '他', '吃', '喜', '我', '果', '欢', '派', '苹', '喜欢', '苹果', 'at', 'cu', 'cut', 'cute', 'yo', 'you', 've', '苹果派', 'li', 'lik', 'like', 'to', 'eat', 'ap', 'app', 'appl', 'apple', 'apples', 'Sh', 'She', 'ha']
再加上一些特殊词汇和其他词汇:
all_vocab = vocab + ["[PAD]", "[UNK]", "[BOS]", "[EOS]"] + other_alphabet
BPE理论上还是会出现OOV的,当词汇表的大小受限时,一些较少出现的子词和没有在训练过程中见过的子词,就会无法进入词汇表出现OOV,而Byte-level BPE(BBPE)理论上是不会出现这个情况的。
Byte-level BPE(BBPE)
基础知识:
Unicode: Unicode 是一种字符集,旨在涵盖地球上几乎所有的书写系统和字符。Unicode 码点的取值范围是 U+0000 到 U+10FFFF ,总共覆盖了 1,114,112 个可能的码点。这些码点包括了全球几乎所有的书写系统和符号,可以容纳各种文字、符号、表情符号等。
**UTF-8:**UTF-8(Unicode Transformation Format-8)是一种变长的字符编码方案,它将 Unicode 中的代码点转换为字节序列。在 UTF-8 编码中,字符的表示长度可以是1到4个字节,不同范围的 Unicode 代码点使用不同长度的字节序列表示,这样可以高效地表示整个 Unicode 字符集。
例如,英文字母 "A" 的 Unicode 代码点是U+0041,在 UTF-8 中表示为 0x41(与 ASCII 编码相同);而中文汉字 "你" 的 Unicode 代码点是U+4F60,在 UTF-8 中表示为0xE4 0xBD 0xA0三个字节的序列。
Byte(字节):计算机存储和数据处理的最小单位。一个字节包含8个(Bit)二进制位,每个位可以是0或1,每位的不同排列和组合可以表示不同的数据。
Byte-level BPE (BBPE) 和Byte-Pair Encoding (BPE) 区别是:BPE的最小词汇是字符级别,而BBPE是字节级别的,通过UTF-8的编码方式,理论上可以表示这个世界上的所有字符。
所以实现步骤和BPE是一样的,只是实现的粒度不一样:
- 初始词表:构建初始词表,包含一个字节的所有表示(256)。
- 构建频率统计:统计所有子词单元对(两个连续的子词)在文本中的出现频率。
- 合并子词对:选择出现频率最高的子词对,将它们合并成一个新的子词单元,并更新词汇表。
- 重复合并步骤:不断重复步骤 2 和步骤 3,直到达到预定的词汇表大小、合并次数,或者直到不再有有意义的合并(即,进一步合并不会显著提高词汇表的效益)。
- 分词:使用最终得到的词汇表对文本进行分词。
python
from collections import defaultdict
sentences = [
"我",
"喜欢",
"吃",
"苹果",
"他",
"不",
"喜欢",
"吃",
"苹果派",
"I like to eat apples",
"She has a cute cat",
"you are very cute",
"give you a hug",
]
# 构建初始词汇表,包含一个字节的256个表示
initial_vocab = [bytes([byte]) for byte in range(256)]
vocab = initial_vocab.copy()
print("initial_vocab:", initial_vocab)
# 构建频率统计
def build_stats(sentences):
stats = defaultdict(int)
for sentence in sentences:
symbols = sentence.split()
for symbol in symbols:
stats[symbol.encode("utf-8")] += 1
return stats
stats = build_stats(sentences)
splits = {word: [byte for byte in word] for word in stats.keys()}
def compute_pair_freqs(splits):
pair_freqs = defaultdict(int)
for word, freq in stats.items():
split = splits[word]
if len(split) == 1:
continue
for i in range(len(split) - 1):
pair = (split[i], split[i + 1])
pair_freqs[pair] += freq
return pair_freqs
pair_freqs = compute_pair_freqs(splits)
def merge_pair(pair, splits):
merged_byte = bytes(pair)
for word in stats:
split = splits[word]
if len(split) == 1:
continue
i = 0
while i < len(split) - 1:
if split[i:i+2] == pair: # 检查分割中是否有这对字节
split = split[:i] + [merged_byte] + split[i + 2 :]
else:
i += 1
splits[word] = split
return splits
vocab_size = 50
while len(vocab) < vocab_size:
pair_freqs = compute_pair_freqs(splits)
best_pair = ()
max_freq = None
for pair, freq in pair_freqs.items():
if max_freq is None or max_freq < freq:
best_pair = pair
max_freq = freq
splits = merge_pair(best_pair, splits)
merged_byte = bytes(best_pair)
print("vocab:", vocab)
着重解释一下为什么Byte-level BPE(BBPE)不会出现OOV问题,初始的词表里有256个表示如下:
python
[b'\x00', b'\x01', b'\x02', b'\x03', b'\x04', b'\x05', b'\x06', b'\x07', b'\x08', b'\t', b'\n', b'\x0b', b'\x0c', b'\r', b'\x0e', b'\x0f', b'\x10', b'\x11', b'\x12', b'\x13', b'\x14', b'\x15', b'\x16', b'\x17', b'\x18', b'\x19', b'\x1a', b'\x1b', b'\x1c', b'\x1d', b'\x1e', b'\x1f', b' ', b'!', b'"', b'#', b'$', b'%', b'&', b"'", b'(', b')', b'*', b'+', b',', b'-', b'.', b'/', b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9', b':', b';', b'<', b'=', b'>', b'?', b'@', b'A', b'B', b'C', b'D', b'E', b'F', b'G', b'H', b'I', b'J', b'K', b'L', b'M', b'N', b'O', b'P', b'Q', b'R', b'S', b'T', b'U', b'V', b'W', b'X', b'Y', b'Z', b'[', b'\\', b']', b'^', b'_', b'`', b'a', b'b', b'c', b'd', b'e', b'f', b'g', b'h', b'i', b'j', b'k', b'l', b'm', b'n', b'o', b'p', b'q', b'r', b's', b't', b'u', b'v', b'w', b'x', b'y', b'z', b'{', b'|', b'}', b'~', b'\x7f', b'\x80', b'\x81', b'\x82', b'\x83', b'\x84', b'\x85', b'\x86', b'\x87', b'\x88', b'\x89', b'\x8a', b'\x8b', b'\x8c', b'\x8d', b'\x8e', b'\x8f', b'\x90', b'\x91', b'\x92', b'\x93', b'\x94', b'\x95', b'\x96', b'\x97', b'\x98', b'\x99', b'\x9a', b'\x9b', b'\x9c', b'\x9d', b'\x9e', b'\x9f', b'\xa0', b'\xa1', b'\xa2', b'\xa3', b'\xa4', b'\xa5', b'\xa6', b'\xa7', b'\xa8', b'\xa9', b'\xaa', b'\xab', b'\xac', b'\xad', b'\xae', b'\xaf', b'\xb0', b'\xb1', b'\xb2', b'\xb3', b'\xb4', b'\xb5', b'\xb6', b'\xb7', b'\xb8', b'\xb9', b'\xba', b'\xbb', b'\xbc', b'\xbd', b'\xbe', b'\xbf', b'\xc0', b'\xc1', b'\xc2', b'\xc3', b'\xc4', b'\xc5', b'\xc6', b'\xc7', b'\xc8', b'\xc9', b'\xca', b'\xcb', b'\xcc', b'\xcd', b'\xce', b'\xcf', b'\xd0', b'\xd1', b'\xd2', b'\xd3', b'\xd4', b'\xd5', b'\xd6', b'\xd7', b'\xd8', b'\xd9', b'\xda', b'\xdb', b'\xdc', b'\xdd', b'\xde', b'\xdf', b'\xe0', b'\xe1', b'\xe2', b'\xe3', b'\xe4', b'\xe5', b'\xe6', b'\xe7', b'\xe8', b'\xe9', b'\xea', b'\xeb', b'\xec', b'\xed', b'\xee', b'\xef', b'\xf0', b'\xf1', b'\xf2', b'\xf3', b'\xf4', b'\xf5', b'\xf6', b'\xf7', b'\xf8', b'\xf9', b'\xfa', b'\xfb', b'\xfc', b'\xfd', b'\xfe', b'\xff']
根据训练语料循环迭代合成子词或者词,最后形成词表,比如"苹果"通过UTF-8进行编码后为"\xe8\x8b\xb9\xe6\x9e\x9c",如果词表里面有,那"苹果"就通过词表映射成了1个表示,准确来说是1个token;如果词表里没有,那就用256中的"\xe8+\x8b+\xb9+\xe6+\x9e+\x9c"来表示"苹果"这个词,那就是6个token。
在先前的各种分词方法中,如果词典里没有"苹果"这个词,也没有"苹","果"这样的子词的话,那就变成了[UNK]。所以在现在的大模型中,以Byte-level BPE(BBPE)这种方式进行分词是不会出现OOV,但词表中如果没有word级别的词的话,一些中英文就会分词分的很细碎,比如Llama在中文上就会把一些词分成多个token,其实就是UTF-8后的中文编码,对编码效率以及语义会有影响,于是出现了一些扩充Llama中文词表的工作。
上述分词算法在工程上实现一般使用sentencpiece工具包,谷歌在这个包中实现了上述的一系列算法,扩充Llama中文词表的工作也都是在此上面实现的。
总结
三种分词方法的不同主要在于 初始化词表 和 合并子词对 的方式,理解每一种分词能够更深刻理解不同分词方法在不同任务和模型中的表现。