NLP 課程文件

單字標記化

Hugging Face's logo
加入 Hugging Face 社群

並獲得增強文件體驗

開始吧

單字標記化

Ask a Question Open In Colab Open In Studio Lab

單字演算法常用於 SentencePiece,SentencePiece 是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的標記化演算法。

💡 本節深入探討了單字,甚至展示了完整的實現。如果您只需要對標記化演算法有一個總體概述,可以跳到最後。

訓練演算法

與 BPE 和 WordPiece 相比,單字演算法從另一個方向工作:它從一個大型詞彙表開始,並從詞彙表中刪除標記,直到它達到所需的詞彙表大小。有多種選項可用於構建該基礎詞彙表:例如,我們可以獲取預標記單詞中最常見的子串,或者在具有大型詞彙表大小的初始語料庫上應用 BPE。

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

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

請注意,我們永遠不會刪除基本字元,以確保可以標記化任何單詞。

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

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

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

標記化演算法

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

給定標記的機率是在原始語料庫中出現的頻率(我們發現它的次數),除以詞彙表中所有標記的頻率總和(以確保機率總和為 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。

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

現在,要標記化給定單詞,我們檢視所有可能的標記分割,並根據單字模型計算每個分割的機率。由於所有標記都被認為是獨立的,因此該機率只是每個標記機率的乘積。例如,["p", "u", "g"]"pug" 的標記化具有以下機率 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,而分詞越少,除以 210 的次數就越少),這與我們的直覺相符:將一個詞拆分成儘可能少的詞。

使用 Unigram 模型對一個詞進行分詞,就是找到機率最高的分詞。例如,對於 `“pug”`,我們可以得到以下每個可能分詞的機率

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

因此,`“pug”` 將被分詞為 `["p", "ug"]` 或 `["pu", "g"]`,取決於哪種分詞方式首先出現(注意,在一個更大的語料庫中,像這樣的相等情況很少見)。

在本例中,很容易找到所有可能的分詞並計算其機率,但在一般情況下,這會更難一些。有一個經典的演算法可以用於此,稱為 *維特比演算法*。從本質上講,我們可以構建一個圖來檢測給定詞的所有可能分詞,方法是如果從字元 *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(word))` 的總和。

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

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

這就是 Unigram 的全部內容!希望到目前為止,您已經感覺自己成為了標記器方面的專家。在下一節中,我們將深入研究 🤗 Tokenizers 庫的構建塊,並向您展示如何使用它們來構建自己的標記器。

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