LLM 入門筆記-Tokenizer

以下筆記參考huggingface 官方 tutorial: https://huggingface.co/learn/nlp-course/chapter6

下圖展示了完整的 tokenization 流程,接下來會對每個步驟做進一步的介紹。

tokenizer_pipeline

1. Normalization

normalize 其實就是根據不同的需要對文本數據做一下清洗工作,以英文文本爲例可以包括刪除不必要的空白、小寫和/或刪除重音符號。

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
print(tokenizer.backend_tokenizer.normalizer.normalize_str("Héllò hôw are ü?"))

>>> 'hello how are u?'

2. Pre-tokenization

數據清洗好後,我們需要將文本作劃分。對於英語而言,最簡單的劃分邏輯就是以單詞爲單位進行劃分。不過即使是這麼簡單的規則也可以細分出很多不同的劃分方式,下面展示了 3 種劃分方式,它們適用於不同的模型訓練,返回的是一個 list,每個元素是一個tuple。tuple 內第一個元素是劃分後的sub-word,第二個元素是其初始和結尾的索引。

  • bert 的最簡單,真的就是最符合直覺的 huafenfangshi
  • gpt2劃分的不同點是單詞前如果有空格的話,空格會轉換成一個特殊字符,即 Ġ
  • t5 類似 gpt2 也考慮了空格,不過空格被替換成了 _

normalize

3. BPE Tokenization

上面Pre-tokenization展示的是比較簡單的劃分方式,但是他們的缺點是會導致詞表非常大。而且,我們知道英文單詞是有詞根的,並且一個動詞會有不同的時態,簡單的以單詞爲單位劃分,不太便於表示單詞之間的相似性。所以一種可行的辦法是我們尋找單詞間的公約數,即把單詞拆分成若干個 sub-word。爲方便理解,我們可以以 like, liked, liking 爲例,這三者的公約數是 lik, 所以分別可以拆分成如下(實際上的拆分並不一定就是下面的結果,這裏只是爲了方便解釋說明):

  • ["lik", "e"]
  • ["lik", "ed"]
  • ["lik", "ing"]

模型在計算這三個單詞的相似性的時候,因爲他們具有相同的"lik",所以肯定會認爲有很高的相似性。類似的,當模型計算兩個都帶有"ed"的單詞的時候,也會知道這兩個單詞也會有相似性,因爲都表示過去式。

那麼如何尋找公約數呢?大佬們提出了不同的算法,常見的三個算法總結在下表裏了:

BPE-wordpiece-unigram

3.1 BPE 原理解釋

這一小節我們着重介紹一下最常見的算法之一:BPE (Byte-pair Encoding)。huggingface官方tutorial 給出了非常詳細的解釋,這裏做一個簡單的介紹。

BPE 其實是一個統計算法,不同意深度神經網絡,只要給定一個數據集或者一篇文章,BPE 不管運行多少次都會得出同樣的結果。下面我們看看 BPE 到底是在做什麼。

爲了方便理解,我們假設我們的語料庫中只有下面 5 個單詞,數字表示出現的頻率:

語料庫:[("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)]

BPE 根據上述單詞表首先初始化生成基礎詞彙表(base vocabulary),即

詞彙表:["b", "g", "h", "n", "p", "s", "u"]

我們可以將每個單詞看成是一個由多個基礎 token 組成的 list,即

[("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)]

接下來,正如 BPE 的名字 byte-pair 所表示的,它會對每個單詞內部相鄰的 token 逐一進行兩兩匹配,然後找出出現頻率最高的那一對,例如,("h" "u" "g", 10) 匹配結果會到的 ("h", "u", 10)("u", "g", 10),其他單詞同理。

通過遍歷所有單詞我們可以發現出現頻率最高的 ("u", "g"),它在 "hug"、"pug" 和 "hugs" 中出現,總共出現了 20 次,所以 BPE 會將它們進行合併(merge),即 ("u", "g") -> "ug"。這樣基礎詞彙表就可以新增一個 token 了,更新後的詞彙表和語料庫如下:

詞彙表:["b", "g", "h", "n", "p", "s", "u", "ug"]
語料庫:("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)

我們繼續重複上面的 遍歷和合並 操作,每次詞彙表都會新增一個 token。當詞彙表內 token 數量達到預設值的時候就會停止 BPE 算法了,並返回最終的詞彙表和語料庫。

3.2 BPE 代碼實戰

3.2.1. 初始化一個簡單的文本數據集,如下

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

3.2.2. pre-tokenization (初始化語料庫和詞彙表)

  • 語料庫

normalize 步驟就省略了。我們直接先構建一下語料庫,以單詞爲單位對原始文本序列進行劃分,並統計每個單詞的頻率。

from transformers import AutoTokenizer
from collections import defaultdict

tokenizer = AutoTokenizer.from_pretrained("gpt2")
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()
vocab = ["<|endoftext|>"] + alphabet.copy()

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', 'Ġ']

根據詞彙表將語料庫進行進一步的劃分,即把每一個單詞表示成由多個 token(或 sub-word)組成的 list:

splits = {word: [c for c in word] for word in word_freqs.keys()}

3.2.3 BPE 合併字典和詞彙表

遍歷搜索,找到出現頻率最高的 byte-pair

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)
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

更新詞彙表和初始化合並字典,該字典記錄了整個合併的過程;

vocab.append("Ġt")
merges = {("Ġ", "t"): "Ġt"}

根據新增合併規則更新語料庫

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']

總結一下上述步驟,我們找到了出現頻率最高的一組 byte-pair,由此更新了詞彙表和語料庫。接下來,我們重複上述過程,不斷增加詞彙表的大小,直到詞彙表包含 50 個 token 爲止:

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])


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']

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'}

3.2.4 tokenize 文本數據

至此,我們完成了對給定文本數據的 BPE 算法,得到了長度爲 50 的詞彙表和語料庫。那麼該如何利用生成的詞彙表和語料庫對新的文本數據做 tokenization 呢?代碼如下:

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

3.2.5. tokenize 的逆(decode)過程

藉助前面生成的 merge 字典,我們可以實現 tokenize的逆過程,這通常是在處理模型預測結果的時候需要用到,代碼如下:

def detokenize(tokens, merges):
    reconstructed_text = ''.join(tokens)
    for pair, merge in merges.items():
        reconstructed_text = reconstructed_text.replace(merge, pair[0] + pair[1])
    return reconstructed_text.replace('Ġ', ' ')

# 假設 merges 是你之前代碼中使用的 merges 字典
merges = {('u', 'g'): 'ug', ('u', 'n'): 'un', ('h', 'ug'): 'hug'}  # 舉例的 merges 字典

tokens = tokenize("This is not a token.")  # 假設 tokens 是之前 tokenize 函數的輸出結果
original_text = detokenize(tokens, merges)
print(original_text)
>>> This is not a token.

微信公衆號:AutoML機器學習

MARSGGBO原創
如有意合作或學術討論歡迎私戳聯繫~
郵箱:[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章