前言
字节对编码(Byte-Pair Encoding,BPE)最初是作为一种文本压缩算法开发的,然后被OpenAI用于在预训练GPT模型时进行分词。它被许多Transformer模型使用,包括GPT、GPT-2、RoBERTa、BART和DeBERTa。
💡 本节深入介绍了BPE,甚至展示了一个完整的实现。如果你只想了解分词算法的概览,可以跳到结尾部分。
src link: https://huggingface.co/learn/nlp-course/chapter6/5
Operating System: Ubuntu 22.04.4 LTS
参考文档
训练算法
BPE(字节对编码)训练首先计算语料库中使用的唯一单词集合(在完成标准化和预分词步骤之后),然后通过采用所有用来书写这些单词的符号来构建词汇表。作为一个非常简单的例子,假设我们的语料库使用了这五个单词:
"hug", "pug", "pun", "bun", "hugs"
基础词汇表然后将包括 [“b”, “g”, “h”, “n”, “p”, “s”, “u”]。在现实世界的案例中,这个基础词汇表至少会包含所有ASCII字符,以及可能的一些Unicode字符。如果你正在标记化的例子中使用了训练语料库中没有的字符,那个字符将被转换成未知标记。这就是为什么许多自然语言处理模型在分析包含表情符号的内容时表现得很差的原因之一。
GPT-2和RoBERTa的分词器(它们非常相似)有一种聪明的方法来处理这个问题:它们不是将单词视为用Unicode字符书写的,而是视为用字节书写的。这样,基础词汇表的大小很小(256),但你所能想到的每个字符仍然会被包含在内,而不会最终被转换成未知标记。这个技巧被称为字节级BPE(字节对编码)。
在获得这个基础词汇表之后,我们通过学习合并规则来添加新标记,直到达到所需的词汇表大小。这些合并规则是将现有词汇表中的两个元素合并成一个新的元素。因此,在开始时,这些合并将创建由两个字符组成的标记,然后随着训练的进行,将形成更长的子词。
在分词器训练的任何阶段,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次。
因此,分词器学到的第一条合并规则是(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)
我们继续这样做,直到达到所需的词汇量。
现在轮到你了!你认为下一个合并规则会是什么?
分词算法
分词过程紧跟着训练过程,新输入的分词通过以下步骤完成:
- 标准化
- 预分词
- 将单词拆分为单个字符
- 按学习到的顺序应用合并规则到这些拆分上
让我们以训练期间使用的例子为例,使用学到的三个合并规则:
("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(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 的情况下,唯一的特殊标记是 “<|endoftext|>”。
vocab = ["<|endoftext|>"] + 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")
为了继续,我们需要在我们的分割字典中应用这个合并。让我们为这个功能再写一个函数:
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。
结语
第二百六十六篇博文写完,开心!!!!
今天,也是充满希望的一天。