字节对编码分词
字节对编码 (BPE) 最初被开发为一种压缩文本的算法,然后被 OpenAI 用于在预训练 GPT 模型时进行分词。它被许多 Transformer 模型使用,包括 GPT、GPT-2、RoBERTa、BART 和 DeBERTa。
💡 本节深入探讨 BPE,甚至展示了完整的实现。如果你只想对分词算法有个概览,可以跳到最后。
训练算法
BPE 训练从计算语料库中使用的唯一词集开始(在完成规范化和预分词步骤后),然后通过取所有用于写这些词的符号来构建词汇表。举个简单的例子,假设我们的语料库使用以下五个词
"hug", "pug", "pun", "bun", "hugs"
那么基本词汇表将是 ["b", "g", "h", "n", "p", "s", "u"]
。对于现实世界的情况,该基本词汇表将至少包含所有 ASCII 字符,可能还包含一些 Unicode 字符。如果您要分词的示例使用训练语料库中没有的字符,该字符将被转换为未知标记。例如,这是许多 NLP 模型在分析包含表情符号的内容时效果很差的原因之一。
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")
要继续,我们需要将该合并应用到我们的 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。