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 上更新

© . This site is unofficial and not affiliated with Hugging Face, Inc.