单字标记化
单字算法常用于 SentencePiece,SentencePiece 是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的标记化算法。
💡 本节深入探讨了单字,甚至展示了完整的实现。如果您只需要对标记化算法有一个总体概述,可以跳到最后。
训练算法
与 BPE 和 WordPiece 相比,单字算法从另一个方向工作:它从一个大型词汇表开始,并从词汇表中删除标记,直到它达到所需的词汇表大小。有多种选项可用于构建该基础词汇表:例如,我们可以获取预标记单词中最常见的子串,或者在具有大型词汇表大小的初始语料库上应用 BPE。
在训练的每个步骤中,单字算法都会根据当前词汇表计算语料库上的损失。然后,对于词汇表中的每个符号,该算法都会计算如果删除该符号,整体损失会增加多少,并寻找增加最少的符号。这些符号对语料库上的整体损失影响较小,因此在某种意义上,它们“不太需要”,并且是删除的最佳候选者。
这是一个非常昂贵的操作,因此我们不仅仅删除与最低损失增加相关的单个符号,而是删除(\(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"
的标记化具有以下概率
相比之下,分词 ["pu", "g"]
的概率为
因此,前者的可能性要高得多。一般来说,分词越少,概率就越高(因为每个词都除以 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 库的构建块,并向您展示如何使用它们来构建自己的标记器。