LLM 课程文档

字节对编码(Byte-Pair Encoding)分词

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

字节对编码分词

Ask a Question Open In Colab Open In Studio Lab

字节对编码(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”(byte-level BPE)。

在获得这个基础词汇表之后,我们通过学习“合并”(merges)来添加新标记,直到达到所需的词汇表大小。合并是将现有词汇表中的两个元素合并为一个新元素的规则。因此,在开始时,这些合并将创建包含两个字符的标记,然后,随着训练的进行,创建更长的子词。

在分词器训练的任何步骤中,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)

我们继续这样,直到达到所需的词汇表大小。

✏️ 轮到你了!你认为下一个合并规则会是什么?

分词算法

分词过程与训练过程密切相关,新输入的分词通过以下步骤进行:

  1. 标准化
  2. 预分词
  3. 将单词拆分为单个字符
  4. 按顺序应用在这些拆分上学到的合并规则

让我们以训练期间使用的示例为例,包含三个学习到的合并规则:

("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。

< > 在 GitHub 上更新