LLM 課程文件

Unigram 分詞

Hugging Face's logo
加入 Hugging Face 社群

並獲得增強的文件體驗

開始使用

Unigram 分詞

Ask a Question Open In Colab Open In Studio Lab

Unigram 演算法與 SentencePiece 結合使用,後者是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的分詞演算法。

SentencePiece 解決了並非所有語言都使用空格分隔單詞的問題。相反,SentencePiece 將輸入視為原始輸入流,其中包含要使用的字元集中的空格。然後它可以使用 Unigram 演算法構建適當的詞彙表。

💡 本節深入講解 Unigram,甚至展示了完整的實現。如果您只想瞭解分詞演算法的總體概述,可以直接跳到末尾。

訓練演算法

與 BPE 和 WordPiece 相比,Unigram 的工作方向相反:它從一個大型詞彙表開始,然後從中刪除標記,直到達到所需的詞彙表大小。有幾種選項可用於構建該基本詞彙表:例如,我們可以獲取預分詞單詞中最常見的子字串,或者對初始語料庫應用 BPE,並使用較大的詞彙表大小。

在訓練的每個步驟中,Unigram 演算法根據當前詞彙表計算語料庫上的損失。然後,對於詞彙表中的每個符號,演算法計算如果刪除該符號,總損失會增加多少,並查詢增加最少的符號。這些符號對語料庫上的總損失影響較小,因此從某種意義上說,它們是“最不必要的”,是最佳的刪除候選者。

這是一個非常耗費資源的操作,所以我們不僅僅刪除與最小損失增加相關的單個符號,而是刪除pp(\(p\) 是您可以控制的超引數,通常為 10 或 20)與最小損失增加相關的符號百分比。然後重複此過程,直到詞彙表達到所需大小。

請注意,我們從不刪除基本字元,以確保任何單詞都可以被分詞。

現在,這仍然有點模糊:演算法的主要部分是計算語料庫上的損失,並檢視當我們從詞彙表中刪除某些標記時它如何變化,但我們還沒有解釋如何做到這一點。此步驟依賴於 Unigram 模型的分詞演算法,因此我們接下來將深入探討這一點。

我們將重用前面示例中的語料庫

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

對於此示例,我們將把所有嚴格的子字串作為初始詞彙表

["h", "u", "g", "hu", "ug", "p", "pu", "n", "un", "b", "bu", "s", "hug", "gs", "ugs"]

分詞演算法

Unigram 模型是一種語言模型,它認為每個標記都獨立於其之前的標記。它是最簡單的語言模型,因為給定先前上下文的標記 X 的機率就是標記 X 的機率。因此,如果我們使用 Unigram 語言模型生成文字,我們總是會預測最常見的標記。

給定標記的機率是它在原始語料庫中的頻率(我們找到它的次數),除以詞彙表中所有標記的所有頻率之和(以確保機率總和為 1)。例如,`"ug"` 存在於 `"hug"`、`"pug"` 和 `"hugs"` 中,因此它在我們的語料庫中的頻率為 20。

以下是詞彙表中所有可能子詞的頻率

("h", 15) ("u", 36) ("g", 20) ("hu", 15) ("ug", 20) ("p", 17) ("pu", 17) ("n", 16)
("un", 16) ("b", 4) ("bu", 4) ("s", 5) ("hug", 15) ("gs", 5) ("ugs", 5)

因此,所有頻率的總和為 210,子詞 `"ug"` 的機率為 20/210。

✏️ 現在輪到你了! 編寫程式碼計算上述頻率,並仔細檢查所示結果以及總和是否正確。

現在,為了對給定單詞進行分詞,我們檢視所有可能的標記分割,並根據 Unigram 模型計算每個分割的機率。由於所有標記都被認為是獨立的,因此該機率只是每個標記機率的乘積。例如,`"pug"` 的分詞 ["p", "u", "g"] 的機率為P([p",u",g"])=P(p")×P(u")×P(g")=5210×36210×20210=0.000389P([``p", ``u", ``g"]) = P(``p") \times P(``u") \times P(``g") = \frac{5}{210} \times \frac{36}{210} \times \frac{20}{210} = 0.000389

相比之下,分詞 ["pu", "g"] 的機率為P([pu",g"])=P(pu")×P(g")=5210×20210=0.0022676P([``pu", ``g"]) = P(``pu") \times P(``g") = \frac{5}{210} \times \frac{20}{210} = 0.0022676

所以這個可能性更大。一般來說,標記數量最少的分詞將具有最高的機率(因為每個標記都重複除以 210),這與我們直觀上想要的相符:將一個單詞分割成儘可能少的標記。

用 Unigram 模型對單詞進行分詞,就是指具有最高機率的分詞。在 `"pug"` 的例子中,以下是每種可能分割的機率

["p", "u", "g"] : 0.000389
["p", "ug"] : 0.0022676
["pu", "g"] : 0.0022676

因此,`"pug"` 將被分詞為 `["p", "ug"]` 或 `["pu", "g"]`,具體取決於首先遇到哪個分割(請注意,在更大的語料庫中,此類相等情況將很少見)。

在這種情況下,很容易找到所有可能的分割並計算它們的機率,但通常會更難。有一個經典的演算法用於此,稱為 *Viterbi 演算法*。本質上,我們可以構建一個圖來檢測給定單詞的可能分割,方法是說如果從字元 *a* 到字元 *b* 的子詞在詞彙表中,則存在從 *a* 到 *b* 的分支,並將子詞的機率歸因於該分支。

為了找到該圖中得分最高的路徑,維特比演算法確定,對於單詞中的每個位置,在該位置結束的最佳分割。由於我們從頭到尾進行,可以透過遍歷以當前位置結束的所有子詞,然後使用該子詞開始位置的最佳分詞分數來找到最佳分數。然後,我們只需展開所採用的路徑即可到達終點。

讓我們看一個使用我們的詞彙表和單詞 `"unhug"` 的例子。對於每個位置,以該位置結束的最佳子詞得分如下

Character 0 (u): "u" (score 0.171429)
Character 1 (n): "un" (score 0.076191)
Character 2 (h): "un" "h" (score 0.005442)
Character 3 (u): "un" "hu" (score 0.005442)
Character 4 (g): "un" "hug" (score 0.005442)

因此,`"unhug"` 將被分詞為 `["un", "hug"]`。

✏️ 現在輪到你了! 確定單詞 `"huggun"` 的分詞及其得分。

回到訓練

現在我們已經瞭解了分詞的工作原理,我們可以更深入地研究訓練中使用的損失。在任何給定階段,此損失是透過使用當前詞彙表和由語料庫中每個標記的頻率確定的 Unigram 模型(如前所述)對語料庫中的每個單詞進行分詞來計算的。

語料庫中的每個詞都有一個分數,損失是這些分數的負對數似然——即語料庫中所有詞的 `—log(P(詞))` 之和。

讓我們回到我們使用以下語料庫的例子

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

每個單詞的分詞及其相應的分數是

"hug": ["hug"] (score 0.071428)
"pug": ["pu", "g"] (score 0.007710)
"pun": ["pu", "n"] (score 0.006168)
"bun": ["bu", "n"] (score 0.001451)
"hugs": ["hug", "s"] (score 0.001701)

所以損失是

10 * (-log(0.071428)) + 5 * (-log(0.007710)) + 12 * (-log(0.006168)) + 4 * (-log(0.001451)) + 5 * (-log(0.001701)) = 169.8

現在我們需要計算刪除每個標記如何影響損失。這相當繁瑣,所以我們這裡只對兩個標記進行操作,並將整個過程留到我們有程式碼幫助時再進行。在這個(非常)特殊的情況下,我們對所有單詞有兩種等效的分詞:正如我們之前看到的,例如,`"pug"` 可以以相同的分數被分詞為 `["p", "ug"]`。因此,從詞彙表中刪除 `"pu"` 標記將給出完全相同的損失。

另一方面,刪除`"hug"`將使損失更糟,因為`"hug"`和`"hugs"`的分詞將變為

"hug": ["hu", "g"] (score 0.006802)
"hugs": ["hu", "gs"] (score 0.001701)

這些更改將導致損失增加

- 10 * (-log(0.071428)) + 10 * (-log(0.006802)) = 23.5

因此,標記 `"pu"` 很可能會從詞彙表中刪除,但 `"hug"` 不會。

實現 Unigram

現在,讓我們用程式碼實現迄今為止所學的一切。與 BPE 和 WordPiece 一樣,這不是 Unigram 演算法的有效實現(恰恰相反),但它應該能幫助您更好地理解它。

我們將繼續使用之前的語料庫作為示例

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.",
]

這次,我們將使用 `xlnet-base-cased` 作為我們的模型

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("xlnet-base-cased")

與 BPE 和 WordPiece 一樣,我們首先計算語料庫中每個單詞的出現次數

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

word_freqs

然後,我們需要將詞彙表初始化為大於我們最終想要的詞彙表大小。我們必須包含所有基本字元(否則我們將無法對每個單詞進行分詞),但對於較大的子字串,我們只保留最常見的子字串,因此我們按頻率對其進行排序

char_freqs = defaultdict(int)
subwords_freqs = defaultdict(int)
for word, freq in word_freqs.items():
    for i in range(len(word)):
        char_freqs[word[i]] += freq
        # Loop through the subwords of length at least 2
        for j in range(i + 2, len(word) + 1):
            subwords_freqs[word[i:j]] += freq

# Sort subwords by frequency
sorted_subwords = sorted(subwords_freqs.items(), key=lambda x: x[1], reverse=True)
sorted_subwords[:10]
[('▁t', 7), ('is', 5), ('er', 5), ('▁a', 5), ('▁to', 4), ('to', 4), ('en', 4), ('▁T', 3), ('▁Th', 3), ('▁Thi', 3)]

我們將字元與最佳子詞分組,以獲得大小為 300 的初始詞彙表

token_freqs = list(char_freqs.items()) + sorted_subwords[: 300 - len(char_freqs)]
token_freqs = {token: freq for token, freq in token_freqs}

💡 SentencePiece 使用一種更高效的演算法,稱為增強字尾陣列 (ESA) 來建立初始詞彙表。

接下來,我們計算所有頻率的總和,將頻率轉換為機率。對於我們的模型,我們將儲存機率的對數,因為新增對數比乘以小數在數值上更穩定,這將簡化模型損失的計算

from math import log

total_sum = sum([freq for token, freq in token_freqs.items()])
model = {token: -log(freq / total_sum) for token, freq in token_freqs.items()}

現在,主要函式是使用維特比演算法對單詞進行分詞的函式。正如我們之前所見,該演算法計算單詞每個子字串的最佳分割,我們將將其儲存在名為 `best_segmentations` 的變數中。我們將為單詞中的每個位置(從 0 到其總長度)儲存一個字典,其中包含兩個鍵:最佳分割中最後一個標記開始的索引,以及最佳分割的分數。透過最後一個標記開始的索引,一旦列表完全填充,我們將能夠檢索完整的分割。

填充列表只需兩個迴圈:主迴圈遍歷每個起始位置,第二個迴圈嘗試從該起始位置開始的所有子字串。如果子字串在詞彙表中,我們就會得到一個單詞的直至該結束位置的新分割,然後我們將其與 `best_segmentations` 中的內容進行比較。

主迴圈結束後,我們只需從末尾開始,從一個起始位置跳到下一個,一路記錄標記,直到到達單詞的起始位置

def encode_word(word, model):
    best_segmentations = [{"start": 0, "score": 1}] + [
        {"start": None, "score": None} for _ in range(len(word))
    ]
    for start_idx in range(len(word)):
        # This should be properly filled by the previous steps of the loop
        best_score_at_start = best_segmentations[start_idx]["score"]
        for end_idx in range(start_idx + 1, len(word) + 1):
            token = word[start_idx:end_idx]
            if token in model and best_score_at_start is not None:
                score = model[token] + best_score_at_start
                # If we have found a better segmentation ending at end_idx, we update
                if (
                    best_segmentations[end_idx]["score"] is None
                    or best_segmentations[end_idx]["score"] > score
                ):
                    best_segmentations[end_idx] = {"start": start_idx, "score": score}

    segmentation = best_segmentations[-1]
    if segmentation["score"] is None:
        # We did not find a tokenization of the word -> unknown
        return ["<unk>"], None

    score = segmentation["score"]
    start = segmentation["start"]
    end = len(word)
    tokens = []
    while start != 0:
        tokens.insert(0, word[start:end])
        next_start = best_segmentations[start]["start"]
        end = start
        start = next_start
    tokens.insert(0, word[start:end])
    return tokens, score

我們已經可以在一些單詞上嘗試我們的初始模型

print(encode_word("Hopefully", model))
print(encode_word("This", model))
(['H', 'o', 'p', 'e', 'f', 'u', 'll', 'y'], 41.5157494601402)
(['This'], 6.288267030694535)

現在計算模型在語料庫上的損失就很容易了!

def compute_loss(model):
    loss = 0
    for word, freq in word_freqs.items():
        _, word_loss = encode_word(word, model)
        loss += freq * word_loss
    return loss

我們可以檢查它是否在我們擁有的模型上執行

compute_loss(model)
413.10377642940875

計算每個標記的分數也不難;我們只需要計算透過刪除每個標記而獲得的模型的損失

import copy


def compute_scores(model):
    scores = {}
    model_loss = compute_loss(model)
    for token, score in model.items():
        # We always keep tokens of length 1
        if len(token) == 1:
            continue
        model_without_token = copy.deepcopy(model)
        _ = model_without_token.pop(token)
        scores[token] = compute_loss(model_without_token) - model_loss
    return scores

我們可以在給定標記上嘗試一下

scores = compute_scores(model)
print(scores["ll"])
print(scores["his"])

由於 `"ll"` 在 `"Hopefully"` 的分詞中使用,並且刪除它可能會導致我們改用兩次標記 `"l"`,因此我們預計它會產生正損失。`"his"` 僅在單詞 `"This"` 中使用,該單詞本身被分詞,因此我們預計它會產生零損失。以下是結果

6.376412403623874
0.0

💡 這種方法效率很低,因此 SentencePiece 使用模型在沒有標記 X 的情況下損失的近似值:它不是從頭開始,而是簡單地用詞彙表中剩下的其分割替換標記 X。這樣,所有分數都可以與模型損失同時計算。

所有這些都準備就緒後,我們需要做的最後一件事是將模型使用的特殊標記新增到詞彙表中,然後迴圈直到我們從詞彙表中修剪足夠的標記以達到我們所需的大小

percent_to_remove = 0.1
while len(model) > 100:
    scores = compute_scores(model)
    sorted_scores = sorted(scores.items(), key=lambda x: x[1])
    # Remove percent_to_remove tokens with the lowest scores.
    for i in range(int(len(model) * percent_to_remove)):
        _ = token_freqs.pop(sorted_scores[i][0])

    total_sum = sum([freq for token, freq in token_freqs.items()])
    model = {token: -log(freq / total_sum) for token, freq in token_freqs.items()}

然後,要對一些文字進行分詞,我們只需要應用預分詞,然後使用我們的 `encode_word()` 函式

def tokenize(text, model):
    words_with_offsets = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(text)
    pre_tokenized_text = [word for word, offset in words_with_offsets]
    encoded_words = [encode_word(word, model)[0] for word in pre_tokenized_text]
    return sum(encoded_words, [])


tokenize("This is the Hugging Face course.", model)
['▁This', '▁is', '▁the', '▁Hugging', '▁Face', '▁', 'c', 'ou', 'r', 's', 'e', '.']

XLNetTokenizer 使用 SentencePiece,這就是為什麼包含字元“_”的原因。要使用 SentencePiece 進行解碼,請將所有標記連線起來,並將“_”替換為空格。

Unigram 就到這裡!希望您現在已經對分詞器的一切都瞭如指掌。在下一節中,我們將深入探討 🤗 Tokenizers 庫的構建塊,並向您展示如何使用它們來構建自己的分詞器。

< > 在 GitHub 上更新

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