LLM 课程文档
Unigram 分词
并获得增强的文档体验
开始使用
Unigram 分词
Unigram 算法常用于 SentencePiece,SentencePiece 是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的分词算法。
💡 本节深入探讨 Unigram,甚至展示完整的实现。如果您只想大致了解分词算法,可以跳到末尾。
训练算法
与 BPE 和 WordPiece 相比,Unigram 的工作方向相反:它从一个大的词汇表开始,从中删除 token,直到达到所需的词汇表大小。有几种选项可用于构建基本词汇表:例如,我们可以采用预分词单词中最常见的子字符串,或者在初始语料库上应用具有大词汇表大小的 BPE。
在训练的每个步骤中,Unigram 算法都会根据当前的词汇表计算语料库的损失。然后,对于词汇表中的每个符号,算法计算如果删除该符号,总体损失会增加多少,并查找那些增加最少的符号。这些符号对语料库的总体损失影响较小,因此在某种意义上,它们是“不太需要的”,并且是删除的最佳候选者。
这是一个非常耗费资源的操作,因此我们不只是删除与最低损失增加相关的单个符号,而是删除(\(p\) 是您可以控制的超参数,通常为 10 或 20)百分比的与最低损失增加相关的符号。然后重复此过程,直到词汇表达到所需的大小。
请注意,我们永远不会删除基本字符,以确保任何单词都可以被分词。
现在,这仍然有点模糊:算法的主要部分是计算语料库的损失,并查看当我们从词汇表中删除一些 token 时损失如何变化,但我们尚未解释如何执行此操作。此步骤依赖于 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 模型是一种语言模型,它认为每个 token 都独立于其之前的 token。它是最简单的语言模型,因为给定先前上下文的 token X 的概率只是 token X 的概率。因此,如果我们使用 Unigram 语言模型生成文本,我们将始终预测最常见的 token。
给定 token 的概率是其在原始语料库中的频率(我们找到它的次数),除以词汇表中所有 token 的所有频率之和(以确保概率总和为 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。
✏️ 现在轮到你了! 编写代码以计算上面的频率,并仔细检查显示的结果是否正确,以及总和。
现在,要对给定的单词进行分词,我们查看所有可能的 token 分割,并根据 Unigram 模型计算每个分割的概率。由于所有 token 都被认为是独立的,因此此概率只是每个 token 概率的乘积。例如,"pug"
的 token 化 ["p", "u", "g"]
的概率为
相比之下,token 化 ["pu", "g"]
的概率为
因此,后者的可能性要大得多。一般来说,token 数量最少的 token 化将具有最高的概率(因为每个 token 都重复除以 210),这与我们直观上想要的结果相符:将单词拆分为尽可能少的 token。
使用 Unigram 模型对单词进行 token 化的结果是概率最高的 token 化。在 "pug"
的示例中,以下是我们为每个可能的分割获得的概率
["p", "u", "g"] : 0.000389
["p", "ug"] : 0.0022676
["pu", "g"] : 0.0022676
因此,"pug"
将被 token 化为 ["p", "ug"]
或 ["pu", "g"]
,具体取决于首先遇到哪个分割(请注意,在更大的语料库中,像这样的相等情况将很少见)。
在这种情况下,很容易找到所有可能的分割并计算它们的概率,但一般来说,这会有点困难。有一种经典的算法用于此目的,称为 Viterbi 算法。本质上,我们可以构建一个图来检测给定单词的可能分割,方法是说如果从 a 到 b 的子词在词汇表中,则存在从字符 a 到字符 b 的分支,并将子词的概率归因于该分支。
为了找到图中得分最高的路径,Viterbi 算法确定,对于单词中的每个位置,在该位置结束的最佳得分的分割。由于我们从头到尾进行,因此可以通过循环遍历在当前位置结束的所有子词,然后使用该子词开始位置的最佳 token 化得分来找到最佳得分。然后,我们只需要展开到达终点所采取的路径即可。
让我们看一个使用我们的词汇表和单词 "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"
将被 token 化为 ["un", "hug"]
。
✏️ 现在轮到你了! 确定单词 "huggun"
的 token 化及其得分。
回到训练
现在我们已经了解了 token 化是如何工作的,我们可以更深入地研究训练期间使用的损失。在任何给定阶段,此损失都是通过使用当前词汇表和由语料库中每个 token 的频率确定的 Unigram 模型(如前所述)对语料库中的每个单词进行 token 化来计算的。
语料库中的每个单词都有一个得分,而损失是这些得分的负对数似然——即语料库中所有单词的所有 -log(P(word))
的总和。
让我们回到我们示例中使用的以下语料库
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
每个单词的 token 化及其各自的得分是
"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
现在我们需要计算删除每个 token 如何影响损失。这相当繁琐,所以我们在这里只对两个 token 执行此操作,并将整个过程保存下来,以便在我们有代码帮助我们时进行。在这种(非常)特殊的情况下,我们对所有单词进行了两次等效的 token 化:正如我们之前看到的,例如,"pug"
可以被 token 化为 ["p", "ug"]
,得分相同。因此,从词汇表中删除 "pu"
token 将产生完全相同的损失。
另一方面,删除 "hug"
会使损失更糟,因为 "hug"
和 "hugs"
的 token 化将变为
"hug": ["hu", "g"] (score 0.006802)
"hugs": ["hu", "gs"] (score 0.001701)
这些更改将导致损失增加
- 10 * (-log(0.071428)) + 10 * (-log(0.006802)) = 23.5
因此,token "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
然后,我们需要将我们的词汇表初始化为大于我们最终想要的词汇表大小。我们必须包含所有基本字符(否则我们将无法 token 化每个单词),但对于较大的子字符串,我们将仅保留最常见的子字符串,因此我们按频率对它们进行排序
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()}
现在,主要函数是使用 Viterbi 算法对单词进行 token 化的函数。正如我们之前看到的,该算法计算单词的每个子字符串的最佳分割,我们将将其存储在名为 best_segmentations
的变量中。我们将为单词中的每个位置(从 0 到其总长度)存储一个字典,其中包含两个键:最佳分割中最后一个 token 的起始索引,以及最佳分割的得分。使用最后一个 token 的起始索引,我们可以在列表完全填充后检索完整分割。
填充列表只需两个循环即可完成:主循环遍历每个起始位置,第二个循环尝试以该起始位置开头的所有子字符串。如果子字符串在词汇表中,我们就会得到单词直到该结束位置的新分割,我们将其与 best_segmentations
中的内容进行比较。
主循环完成后,我们只需从末尾开始,从一个起始位置跳到下一个起始位置,并在行进过程中记录 token,直到到达单词的开头
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
计算每个 token 的得分也不是很困难;我们只需要计算通过删除每个 token 获得的模型的损失
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
我们可以在给定的 token 上尝试它
scores = compute_scores(model)
print(scores["ll"])
print(scores["his"])
由于 "ll"
用于 "Hopefully"
的 token 化,并且删除它可能会使我们改为两次使用 token "l"
,因此我们预计它会产生正损失。"his"
仅在单词 "This"
中使用,而 "This"
本身被 token 化,因此我们预计它会产生零损失。以下是结果
6.376412403623874
0.0
💡 这种方法效率非常低下,因此 SentencePiece 使用模型损失的近似值,而无需 token X:它不是从头开始,而是用其在剩余词汇表中的分割替换 token X。这样,所有得分都可以与模型损失同时一次性计算出来。
完成所有这些之后,我们需要做的最后一件事是将模型使用的特殊 token 添加到词汇表中,然后循环直到我们从词汇表中修剪掉足够的 token 以达到我们期望的大小
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()}
然后,要 token 化一些文本,我们只需要应用预 token 化,然后使用我们的 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 就到此为止!希望到现在您感觉自己像是 token 化方面的专家。在下一节中,我们将深入研究 🤗 Tokenizers 库的构建块,并向您展示如何使用它们来构建您自己的 token 化器。
< > 在 GitHub 上更新