Byte-Pair Encoding(BPE)最初是作为一种文本压缩算法开发的,后来被OpenAI用于GPT模型的预训练时的tokenization。许多Transformer模型,如GPT、GPT-2、RoBERTa、BART和DeBERTa,都使用了BPE。
💡 本节将深入讲解BPE,甚至会展示完整的实现。如果你只想了解tokenization算法的概要,可以跳到最后。
训练算法
BPE训练首先计算语料库中(在标准化和预tokenization步骤完成后)使用的独特单词集,然后通过使用书写这些单词的所有符号来构建词汇表。以一个简单的例子来说,假设我们的语料库包含以下五个单词:
"hug", "pug", "pun", "bun", "hugs"
初始词汇表将是["b", "g", "h", "n", "p", "s", "u"]
。在实际情况下,初始词汇表将至少包含所有ASCII字符,可能还包括一些Unicode字符。如果正在tokenizing的示例中使用了训练语料库中未包含的字符,该字符将被转换为未知令牌。这就是为什么许多NLP模型在分析带有表情符号的内容时表现不佳的一个原因。
GPT-2和RoBERTa的tokenizer(它们非常相似)有一个巧妙的处理方式:它们不将单词视为由Unicode字符组成的,而是由字节组成。这样,初始词汇表的大小较小(256),但你想到的任何字符都会被包含在内,不会被转换为未知令牌。这种技巧称为字节级BPE。
在获取了基础词汇表后,我们通过学习合并(merges)来添加新的令牌,直到达到所需的词汇表大小。合并是将现有词汇表中的两个元素合并成一个新的规则。起初,这些合并会创建包含两个字符的令牌,随着训练的进行,会生成更长的子词。
在tokenizer训练的任何阶段,BPE算法都会搜索现有词汇表中最频繁的两个令牌对(这里的“对”是指单词中的连续两个令牌)。最频繁的这对将被合并,然后我们继续进行下一轮。
回到之前的例子,假设这些单词的频率如下:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
这意味着“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)
然后我们查看对。对("h", "u")
在“hug”和“hugs”中出现,总共在语料库中出现了15次。但这不是最频繁的对:最频繁的是("u", "g")
,它在“hug”、“pug”和“hugs”中出现,总共在词汇表中出现了20次。
因此,tokenizer学习的第一个合并规则是("u", "g") -> "ug"
,这意味着“ug”将被添加到词汇表中,并在语料库中的所有单词中合并。经过这一阶段,词汇表和语料库看起来像这样:
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
现在我们有一些产生超过两个字符的令牌的对:例如对("h", "ug")
(在语料库中出现15次)。现阶段最频繁的对是("u", "n")
,它在语料库中出现了16次,所以我们学习到的第二个合并规则是("u", "n") -> "un"
。将这个规则添加到词汇表中,并合并所有现有的出现,我们得到:
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)
现在最频繁的对是("h", "ug")
,所以我们学习到的合并规则是("h", "ug") -> "hug"
,从而得到了我们的第一个三个字符的令牌。合并后,语料库看起来像这样:
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
Corpus: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
我们继续这个过程,直到达到所需的词汇表大小。
📝 现在轮到你了! 你觉得下一个合并规则会是什么?
Tokenization algorithm
令牌化过程紧密地遵循训练过程,新输入通过以下步骤进行令牌化:
- 标准化
- 预令牌化
- 将单词分割成单个字符
- 按照学习到的合并规则对这些分割进行处理
让我们以训练过程中使用的三个合并规则为例:
("u", "g") -> "ug"
("u", "n") -> "un"
("h", "ug") -> "hug"
单词"bug"
会被令牌化为["b", "ug"]
。然而,"mug"
会被令牌化为["[UNK]", "ug"]
,因为字母"m"
不在基础词汇表中。同样,单词"thug"
会被令牌化为["[UNK]", "hug"]
:字母"t"
不在基础词汇表中,应用合并规则首先将"u"
和"g"
合并,然后将"h"
和"ug"
合并。
📝 现在轮到你了! 你觉得单词"unhug"
会被如何令牌化?
实现BPE
现在让我们来看看BPE算法的一个实现。这不会是一个你可以在大语料库上使用的优化版本,我们只是想让你更好地理解算法。
首先,我们需要一个语料库,让我们创建一个简单的语料库,包含几句话:
corpus = [
"This is the Hugging Face Course.",
"This chapter is about tokenization.",
"This section shows several tokenizer algorithms.",
"Hopefully, you will be able to understand how they are trained and generate tokens.",
]
接下来,我们需要对这个语料库进行预令牌化。由于我们正在复制一个BPE分词器(如GPT-2),我们将使用gpt2
分词器进行预令牌化:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("gpt2")
然后,我们在预令牌化过程中计算每个单词在语料库中的频率:
from collections import defaultdict
word_freqs = defaultdict(int)
for text in corpus:
words_with_offsets = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(text)
new_words = [word for word, offset in words_with_offsets]
for word in new_words:
word_freqs[word] += 1
print(word_freqs)
defaultdict(<class 'int'>, {'This': 3, 'Ġis': 2, 'Ġthe': 1, 'ĠHugging': 1, 'ĠFace': 1, 'ĠCourse': 1, '.': 4, 'Ġchapter': 1,
'Ġabout': 1, 'Ġtokenization': 1, 'Ġsection': 1, 'Ġshows': 1, 'Ġseveral': 1, 'Ġtokenizer': 1, 'Ġalgorithms': 1,
'Hopefully': 1, ',': 1, 'Ġyou': 1, 'Ġwill': 1, 'Ġbe': 1, 'Ġable': 1, 'Ġto': 1, 'Ġunderstand': 1, 'Ġhow': 1,
'Ġthey': 1, 'Ġare': 1, 'Ġtrained': 1, 'Ġand': 1, 'Ġgenerate': 1, 'Ġtokens': 1})
下一步是计算基本词汇表,由语料库中使用的所有字符组成:
alphabet = []
for word in word_freqs.keys():
for letter in word:
if letter not in alphabet:
alphabet.append(letter)
alphabet.sort()
print(alphabet)
[ ',', '.', 'C', 'F', 'H', 'T', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's',
't', 'u', 'v', 'w', 'y', 'z', 'Ġ']
我们还需要在词汇表的开头添加模型使用的特殊标记。对于GPT-2,唯一的特殊标记是"
:
vocab = [""] + alphabet.copy()
现在我们需要将每个单词拆分为单个字符,以便开始训练:
splits = {word: [c for c in word] for word in word_freqs.keys()}
准备好训练后,我们需要编写一个函数来计算每对字符的频率。在训练的每一步中,我们都需要使用这个函数:
def compute_pair_freqs(splits):
pair_freqs = defaultdict(int)
for word, freq in word_freqs.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
('T', 'h'): 3
('h', 'i'): 3
('i', 's'): 5
('Ġ', 'i'): 2
('Ġ', 't'): 7
('t', 'h'): 3
现在,找到最频繁的对只需要快速遍历:
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)
('Ġ', 't') 7
因此,第一个要学习的合并是('Ġ', 't') -> 'Ġt'
,我们将'Ġt'
添加到词汇表中:
merges = {("Ġ", "t"): "Ġt"}
vocab.append("Ġt")
将这些合并应用到我们的splits
字典中,我们需要编写另一个函数:
def merge_pair(a, b, splits):
for word in word_freqs:
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
让我们看看第一次合并的结果:
splits = merge_pair("Ġ", "t", splits)
print(splits["Ġtrained"])
['Ġt', 'r', 'a', 'i', 'n', 'e', 'd']
现在我们有了继续学习所有想要合并规则所需的一切。让我们目标词汇大小设为50:
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])
结果,我们学习了19条合并规则(初始词汇大小为31 - 字母表中的30个字符,加上特殊标记):
print(merges)
{('Ġ', 't'): 'Ġt', ('i', 's'): 'is', ('e', 'r'): 'er', ('Ġ', 'a'): 'Ġa', ('Ġt', 'o'): 'Ġto', ('e', 'n'): 'en',
('T', 'h'): 'Th', ('Th', 'is'): 'This', ('o', 'u'): 'ou', ('s', 'e'): 'se', ('Ġto', 'k'): 'Ġtok',
('Ġtok', 'en'): 'Ġtoken', ('n', 'd'): 'nd', ('Ġ', 'is'): 'Ġis', ('Ġt', 'h'): 'Ġth', ('Ġth', 'e'): 'Ġthe',
('i', 'n'): 'in', ('Ġa', 'b'): 'Ġab', ('Ġtoken', 'i'): 'Ġtokeni'}
词汇由特殊标记、初始字母表和所有合并结果组成:
print(vocab)
['<|endoftext|>', ',', '.', 'C', 'F', 'H', 'T', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'k', 'l', 'm', 'n', 'o',
'p', 'r', 's', 't', 'u', 'v', 'w', 'y', 'z', 'Ġ', 'Ġt', 'is', 'er', 'Ġa', 'Ġto', 'en', 'Th', 'This', 'ou', 'se',
'Ġtok', 'Ġtoken', 'nd', 'Ġis', 'Ġth', 'Ġthe', 'in', 'Ġab', 'Ġtokeni']
💡 使用train_new_from_iterator()
在相同语料库上进行训练不会得到完全相同的词汇。这是因为当有多条最频繁的合并规则可以选择时,我们选择了遇到的第一个,而🤗 Tokenizers库是基于内部ID选择的。
要对新文本进行分词,我们首先预处理它,然后分割,最后应用所有学到的合并规则:
def tokenize(text):
pre_tokenize_result = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text)
pre_tokenized_text = [word for word, offset in pre_tokenize_result]
splits = [[l for l in word] for word in pre_tokenized_text]
for pair, merge in merges.items():
for idx, split in enumerate(splits):
i = 0
while i < len(split) - 1:
if split[i] == pair[0] and split[i + 1] == pair[1]:
split = split[:i] + [merge] + split[i + 2 :]
else:
i += 1
splits[idx] = split
return sum(splits, [])
我们可以尝试在包含字母表字符的任何文本上运行它:
tokenize("This is not a token.")
['This', 'Ġis', 'Ġ', 'n', 'o', 't', 'Ġa', 'Ġtoken', '.']
警告:我们的实现如果遇到未知字符会抛出错误,因为我们没有处理它们。GPT-2实际上并没有未知令牌(在使用字节级BPE时不可能遇到未知字符),但这可能在这里发生,因为我们没有在初始词汇中包含所有可能的字节。BPE的这一方面超出了本节的范围,所以我们省略了细节。
BPE算法就到这里!接下来,我们将介绍WordPiece。