单字词元化
单字算法通常用于 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。
✏️ **现在轮到你了!**编写代码计算上述频率,并仔细检查显示的结果是否正确,以及总和。
现在,要对给定单词进行词元化,我们查看所有可能的词元分割,并根据单字模型计算每个分割的概率。由于所有词元都被认为是独立的,因此该概率只是每个词元概率的乘积。例如,"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"]”,取决于哪个分割先被遇到(请注意,在一个更大的语料库中,像这样的相等情况将很少见)。
在这个例子中,很容易找到所有可能的分割并计算它们的概率,但一般来说会稍微复杂一些。有一个经典的算法用于此,称为 *维特比算法*。本质上,我们可以构建一个图来检测给定单词的可能分割,方法是如果从字符 *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 库的基础构建块,并向你展示如何使用它们来构建自己的标记器。