LLM 课程文档
Unigram 分词
并获得增强的文档体验
开始使用
Unigram 分词
Unigram 算法与 SentencePiece 结合使用,后者是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的分词算法。
SentencePiece 解决了并非所有语言都使用空格分隔单词的问题。相反,SentencePiece 将输入视为原始输入流,其中包含要使用的字符集中的空格。然后它可以使用 Unigram 算法构建适当的词汇表。
💡 本节深入讲解 Unigram,甚至展示了完整的实现。如果您只想了解分词算法的总体概述,可以直接跳到末尾。
训练算法
与 BPE 和 WordPiece 相比,Unigram 的工作方向相反:它从一个大型词汇表开始,然后从中删除标记,直到达到所需的词汇表大小。有几种选项可用于构建该基本词汇表:例如,我们可以获取预分词单词中最常见的子字符串,或者对初始语料库应用 BPE,并使用较大的词汇表大小。
在训练的每个步骤中,Unigram 算法根据当前词汇表计算语料库上的损失。然后,对于词汇表中的每个符号,算法计算如果删除该符号,总损失会增加多少,并查找增加最少的符号。这些符号对语料库上的总损失影响较小,因此从某种意义上说,它们是“最不必要的”,是最佳的删除候选者。
这是一个非常耗费资源的操作,所以我们不仅仅删除与最小损失增加相关的单个符号,而是删除(\(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"]
的概率为
相比之下,分词 ["pu", "g"]
的概率为
所以这个可能性更大。一般来说,标记数量最少的分词将具有最高的概率(因为每个标记都重复除以 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 上更新