转载自:https://huggingface.co/learn/nlp-course/zh-CN/

原中文文档有很多地方翻译的太敷衍了,因此才有此系列文章。

NLP课程(六-中)- 三种标记化

字节对编码标记化(BPE)

字节对编码(BPE)最初被开发为一种压缩文本的算法,然后在预训练 GPT 模型时被 OpenAI 用于标记化。许多 Transformer 模型都使用它,包括 GPT、GPT-2、RoBERTa、BART 和 DeBERTa。

1.训练算法

BPE训练算法首先计算语料库中使用的唯一单词集**(在完成标准化和预标记化步骤之后),**然后通过获取用于编写这些单词的所有符号来构建词汇表。一个非常简单的例子,假设我们的语料库使用了这五个词:

1
"hug", "pug", "pun", "bun", "hugs"

基础词汇将是 ["b", "g", "h", "n", "p", "s", "u"]。对于实际情况,基本词汇表将包含所有 ASCII 字符,至少,可能还包含一些 Unicode 字符。

Unicode:计算机基础知识之Unicode-彻底弄懂 Unicode 编码-CSDN博客

如果您正在标记的示例使用不在训练语料库中的字符,则该字符将转换为未知标记。这就是为什么许多 NLP 模型在分析带有表情符号的内容方面非常糟糕的原因之一。

GPT-2 和 RoBERTa 标记器(非常相似)有一个聪明的方法来处理这个问题: 他们不把单词看成是用 Unicode 字符写的,而是用字节写的。这样,基本词汇表的大小很小(256),但你能想到的每个字符仍将被包含在内,而不会最终转换为未知标记。这个技巧被称为 字节级 BPE。

获得这个基本词汇后,我们添加新的标记,直到通过学习合并达到所需的词汇量,这是将现有词汇表的两个元素合并为一个新元素的规则。因此,在开始时,这些合并将创建具有两个字符的标记,然后,随着训练的进行,会创建更长的子词。

在分词器训练期间的任何一步,BPE 算法都会搜索最常见的现有标记对 (“对”,这里我们指的是单词中的两个连续标记)。最频繁的一对将被合并,我们冲洗并重复下一步。

回到我们之前的例子,让我们假设单词具有以下频率:

1
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

意味着 "hug" 在语料库中出现了10次, "pug" 5次, "pun" 12次, "bun" 4次, 以及 "hugs" 5次。我们通过将每个单词拆分为字符(形成我们初始词汇表的字符)来开始训练,这样我们就可以将每个单词视为一个标记列表:

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

然后我们看成对。这对 ("h", "u") 出现在单词 "hug""hugs"中,所以语料库中总共有15次。不过,这并不是最频繁的一对:这个荣誉属于 ("u", "g"),它出现在 "hug", "pug", 以及 "hugs"中,在词汇表中总共 20 次。

因此,标记器学习的第一个合并规则是 ("u", "g") -> "ug",意思就是 "ug" 将被添加到词汇表中,并且这对应该合并到语料库的所有单词中。在这个阶段结束时,词汇表和语料库看起来像这样:

1
2
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)

现在我们有一些导致标记长于两个字符的对: 例如 ("h", "ug"), 在语料库中出现15次。然而,这个阶段最频繁的对是 ("u", "n"),在语料库中出现16次,所以学到的第二个合并规则是 ("u", "n") -> "un"。将其添加到词汇表并合并所有现有的这个对,将出现:

1
2
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)

现在最频繁的一对是 ("h", "ug"),所以我们学习了合并规则 ("h", "ug") -> "hug",这给了我们第一个三个字母的标记。合并后,语料库如下所示:

1
2
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
Corpus: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)

我们继续这样合并,直到达到我们所需的词汇量。

注意,合并后的词汇将会加入到词汇表中,二用于合并的两个词汇不会删除。

2.标记化算法

标记化紧跟训练过程,从某种意义上说,通过应用以下步骤对新输入进行标记:

  1. 规范化
  2. 预标记化
  3. 将单词拆分为单个字符
  4. 将学习的合并规则按顺序应用于这些拆分

让我们以我们在训练期间使用的示例为例,学习三个合并规则:

1
2
3
("u", "g") -> "ug"
("u", "n") -> "un"
("h", "ug") -> "hug"

这个单词 "bug" 将被标记为 ["b", "ug"]。然而 "mug",将被标记为 ["[UNK]", "ug"],因为字母 "m" 不再基本词汇表中。同样,单词"thug" 会被标记为 ["[UNK]", "hug"]: 字母 "t" 不在基本词汇表中,应用合并规则首先导致 "u""g" 被合并,然后是 "hu""g" 被合并。

3.实现BPE

现在让我们看一下 BPE 算法的实现。这不会是你可以在大型语料库上实际使用的优化版本;我们只是想向你展示代码,以便你可以更好地理解算法

创建一个简单的语料库

首先我们需要一个语料库,所以让我们用几句话创建一个简单的语料库:

1
2
3
4
5
6
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.",
]

将该语料库预先标记为单词,计算语料库中每个单词的频率,

接下来,我们需要将该语料库预先标记为单词。由于我们正在复现 BPE 标记器(如 GPT-2),我们将使用 gpt2 标记器作为预标记化的标记器:

1
2
3
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("gpt2")

然后我们在进行预标记化时计算语料库中每个单词的频率:

Python collections模块之defaultdict()详解_from collections import defaultdict-CSDN博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

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

计算基本词汇

下一步是计算基本词汇,由语料库中使用的所有字符组成:

1
2
3
4
5
6
7
8
9
10
11
alphabet = []

for word in word_freqs.keys():
for letter in word:
if letter not in alphabet:
alphabet.append(letter)
alphabet.sort()

print(alphabet)
[ ',', '.', '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', 'Ġ']

我们还在该词汇表的开头添加了模型使用的特殊标记。对于GPT-2,唯一的特殊标记是 "<|endoftext|>":

1
vocab = ["<|endoftext|>"] + alphabet.copy()

将每个单词拆分为单独的字符,并计算每对的频率

我们现在需要将每个单词拆分为单独的字符,以便能够开始训练:

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

现在我们已准备好进行训练,让我们编写一个函数来计算每对的频率。我们需要在训练的每个步骤中使用它:

1
2
3
4
5
6
7
8
9
10
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

让我们来看看这个字典在初始拆分后的一部分:

1
2
3
4
5
6
7
8
9
10
11
12
pair_freqs = compute_pair_freqs(splits)

for i, key in enumerate(pair_freqs.keys()):
print(f"{key}: {pair_freqs[key]}")
if i >= 5:
break
('T', 'h'): 3
('h', 'i'): 3
('i', 's'): 5
('Ġ', 'i'): 2
('Ġ', 't'): 7
('t', 'h'): 3

找到最频繁的对并添加到词汇表

现在, 找到最频繁的对只需要一个快速的循环:

1
2
3
4
5
6
7
8
9
10
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

所以第一个要学习的合并是 ('Ġ', 't') -> 'Ġt', 我们添加 'Ġt' 到词汇表:

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

在分词字典中应用该合并

要继续接下来的步骤,我们需要在我们的分词字典中应用该合并。让我们为此编写另一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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是更新过的:

1
2
3
splits = merge_pair("Ġ", "t", splits)
print(splits["Ġtrained"])
['Ġt', 'r', 'a', 'i', 'n', 'e', 'd']

在词汇量达到50前重复合并操作

现在我们有了循环所需的一切,直到我们学会了我们想要的所有合并。我们的目标是词汇量达到50:

1
2
3
4
5
6
7
8
9
10
11
12
13
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) # 更新splits
merges[best_pair] = best_pair[0] + best_pair[1]
vocab.append(best_pair[0] + best_pair[1])

结果,我们学习了 19 条合并规则(初始词汇表的大小 31 — 30 字母字符,加上特殊标记):

1
2
3
4
5
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'}

词汇表由特殊标记、初始字母和所有合并结果组成:

1
2
3
4
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']

💡 在同一语料库上使用 train_new_from_iterator() 不会产生完全相同的词汇表。这是因为当有最频繁对的选择时,我们选择遇到的第一个, 而 🤗 Tokenizers 库根据内部ID选择第一个。

为了对新文本进行分词,我们对其进行预分词、拆分,然后应用学到的所有合并规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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, [])

我们可以在任何由字母表中的字符组成的文本上尝试这个:

1
2
3
tokenize("This is not a token.")

['This', 'Ġis', 'Ġ', 'n', 'o', 't', 'Ġa', 'Ġtoken', '.']

⚠️ 如果存在未知字符,我们的实现将抛出错误,因为我们没有做任何处理它们。GPT-2 实际上没有未知标记(使用字节级 BPE 时不可能得到未知字符),但这可能发生在这里,因为我们没有在初始词汇表中包含所有可能的字节。 BPE 的这方面超出了本节的范围,因此我们忽略了细节。

WordPiece 标记化

WordPiece 是 Google 为预训练 BERT 而开发的标记化算法。

1.训练算法

⚠️ Google 从未开源 WordPiece 训练算法的实现,因此以下是我们基于已发表文献的最佳猜测。它可能不是 100% 准确的。

与 BPE一样,WordPiece 从一个小词汇表开始,包括模型使用的特殊标记和初始字母表。因为它通过添加前缀来识别子词 (如同 ## 对于 BERT),每个单词最初是通过将该前缀添加到单词内的所有字符来拆分的。所以,例如 "word" ,像这样拆分:

1
w ##o ##r ##d

因此,初始字母表包含出现在单词开头的所有字符以及出现在单词内部的以WordPiece前缀##开头的字符。

然后,再次像 BPE 一样,WordPiece 学习合并规则。主要区别在于选择要合并的对的方式。<WordPiece 不是选择最频繁的对,而是使用以下公式计算每对的分数(每对出现的频率除以第一个词的频率与第二个词的频率的乘积):

score=(freq_of_pair)/(freq_of_first_element × freq_of_second_element)

通过将配对的频率除以其每个部分的频率的乘积, 该算法优先合并单个部分在词汇表中频率较低的对。例如,它不一定会合并 ("un", "##able") 即使这对在词汇表中出现的频率很高,因为 "un""##able" 很可能每个词都出现在很多其他词中并且出现频率很高。相比之下,像 ("hu", "##gging") 可能会更快地合并 (假设 “hugging” 经常出现在词汇表中),因为 "hu""##gging" 这两个词单独出现地频率可能较低。

示例

让我们看看我们在 BPE 训练示例中使用的相同词汇:

1
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

这里的拆分将是:

1
("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)

所以最初的词汇将是 ["b", "h", "p", "##g", "##n", "##s", "##u"] (如果我们暂时忘记特殊标记)。最频繁的一对是 ("##u", "##g") (目前20次),但 "##u" 单独出现的频率非常高,所以它的分数不是最高的(它是 1 / 36)。所有带有 "##u" 的对实际上都有相同的分数(1 / 36),所以分数最高的对是 ("##g", "##s") ——唯一没有 "##u" 的对—— 1 / 20,所以学习的第一个合并是 ("##g", "##s") -> ("##gs")

请注意,当我们合并时,我们删除了两个标记之间的 ##,所以我们添加 "##gs" 到词汇表中,并在语料库的单词中应用该合并:

1
2
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)

在这一点中, "##u" 是在所有可能的对中,因此它们最终都具有相同的分数。假设在这种情况下,第一对被合并, ("h", "##u") -> "hu"。这使得我们:

1
2
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu"]
Corpus: ("hu" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)

然后下一个最高的分数由 ("hu", "##g")("hu", "##gs") 共享(1/15,与其他所有对的 1/21 相比),因此合并得分最高的第一对:

1
2
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)

我们继续这样处理,直到达到我们所需的词汇量。

2.标记化算法

WordPiece 和 BPE 中的标记化的不同在于 WordPiece 只保存最终词汇,而不是学习的合并规则。从要标记的单词开始,WordPiece 找到词汇表中最长的子词,然后对其进行拆分。例如,如果我们使用上面例子中学到的词汇,对于单词 "hugs",词汇表中从头开始的最长子词是 "hug",所以我们在那里拆分并得到 ["hug", "##s"]。 然后我们继续使用词汇表中的 "##s",因此 "hugs" 的标记化是 ["hug", "##s"].

使用 BPE, 我们将按顺序应用学习到的合并并将其标记为 ["hu", "##gs"],因此编码是不同的。

再举一个例子,让我们看看 "bugs" 将如何被标记化。 "b" 是从词汇表中单词开头开始的最长子词,所以我们在那里拆分并得到 ["b", "##ugs"]。然后 "##u" 是词汇表中从 "##ugs" 开始的最长的子词,所以我们在那里拆分并得到 ["b", "##u, "##gs"]。最后, "##gs" 在词汇表中,所以最后一个列表是 "bugs" 的标记化。

当分词达到无法在词汇表中找到子词的阶段时, 整个词被标记为未知 — 例如, "mug" 将被标记为 ["[UNK]"],就像 "bum" (即使我们可以以 “b” 和 “##u” 开始, “##m” 不在词汇表中,由此产生的标记将只是 ["[UNK]"], 不是 ["b", "##u", "[UNK]"])。这是与 BPE 的另一个区别,BPE 只会将不在词汇表中的单个字符分类为未知。

3.实现WordPiece

现在让我们看一下 WordPiece 算法的实现。与BPE一样,这只是教学,你将无法在大型语料库中使用它。

我们将使用与 BPE 示例中相同的语料库:

1
2
3
4
5
6
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.",
]

首先,我们需要将语料库预先标记为单词。由于我们正在复制 WordPiece 标记器 (如 BERT),因此我们将使用 bert-base-cased 标记器用于预标记化:

1
2
3
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

然后我们在进行预标记化时计算语料库中每个单词的频率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

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

正如我们之前看到的,字母表是由单词的所有第一个字母组成的唯一集合,以及出现在前缀为 ## 的其他字母:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
alphabet = []
for word in word_freqs.keys():
if word[0] not in alphabet:
alphabet.append(word[0])
for letter in word[1:]:
if f"##{letter}" not in alphabet:
alphabet.append(f"##{letter}")

alphabet.sort()
alphabet

print(alphabet)
['##a', '##b', '##c', '##d', '##e', '##f', '##g', '##h', '##i', '##k', '##l', '##m', '##n', '##o', '##p', '##r', '##s',
'##t', '##u', '##v', '##w', '##y', '##z', ',', '.', 'C', 'F', 'H', 'T', 'a', 'b', 'c', 'g', 'h', 'i', 's', 't', 'u',
'w', 'y']

我们还在该词汇表的开头添加了模型使用的特殊标记。在使用 BERT 的情况下,它是列表 ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"]:

1
vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + alphabet.copy()

接下来我们需要拆分每个单词, 所有不是第一个字母的字母都以 ## 为前缀:

1
2
3
4
splits = {
word: [c if i == 0 else f"##{c}" for i, c in enumerate(word)]
for word in word_freqs.keys()
}

现在我们已经准备好训练了,让我们编写一个函数来计算每对的分数。我们需要在训练的每个步骤中使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def compute_pair_scores(splits):
letter_freqs = defaultdict(int)
pair_freqs = defaultdict(int)
for word, freq in word_freqs.items():
split = splits[word]
if len(split) == 1:
letter_freqs[split[0]] += freq
continue
for i in range(len(split) - 1):
pair = (split[i], split[i + 1])
letter_freqs[split[i]] += freq
pair_freqs[pair] += freq
letter_freqs[split[-1]] += freq

scores = {
pair: freq / (letter_freqs[pair[0]] * letter_freqs[pair[1]])
for pair, freq in pair_freqs.items()
}
return scores

让我们来看看这个字典在初始拆分后的一部分:

1
2
3
4
5
6
7
8
9
10
11
pair_scores = compute_pair_scores(splits)
for i, key in enumerate(pair_scores.keys()):
print(f"{key}: {pair_scores[key]}")
if i >= 5:
break
('T', '##h'): 0.125
('##h', '##i'): 0.03409090909090909
('##i', '##s'): 0.02727272727272727
('i', '##s'): 0.1
('t', '##h'): 0.03571428571428571
('##h', '##e'): 0.011904761904761904

现在,找到得分最高的对只需要一个快速循环:

1
2
3
4
5
6
7
8
9
best_pair = ""
max_score = None
for pair, score in pair_scores.items():
if max_score is None or max_score < score:
best_pair = pair
max_score = score

print(best_pair, max_score)
('a', '##b') 0.2

所以第一个要学习的合并是 ('a', '##b') -> 'ab', 并且我们添加 'ab' 到词汇表中:

1
vocab.append("ab")

要继续接下来的步骤,我们需要在我们的 拆分 字典中应用该合并。让我们为此编写另一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:
merge = a + b[2:] if b.startswith("##") else a + b
split = split[:i] + [merge] + split[i + 2 :]
else:
i += 1
splits[word] = split
return splits

我们可以看看第一次合并的结果:

1
2
3
splits = merge_pair("a", "##b", splits)
splits["about"]
['ab', '##o', '##u', '##t']

现在我们有了循环所需的一切,直到我们学会了我们想要的所有合并。我们的目标词汇量为70:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vocab_size = 70
while len(vocab) < vocab_size:
scores = compute_pair_scores(splits)
best_pair, max_score = "", None
for pair, score in scores.items():
if max_score is None or max_score < score:
best_pair = pair
max_score = score
splits = merge_pair(*best_pair, splits)
new_token = (
best_pair[0] + best_pair[1][2:]
if best_pair[1].startswith("##")
else best_pair[0] + best_pair[1]
)
vocab.append(new_token)

然后我们可以查看生成的词汇表:

1
2
3
4
5
6
print(vocab)
['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]', '##a', '##b', '##c', '##d', '##e', '##f', '##g', '##h', '##i', '##k',
'##l', '##m', '##n', '##o', '##p', '##r', '##s', '##t', '##u', '##v', '##w', '##y', '##z', ',', '.', 'C', 'F', 'H',
'T', 'a', 'b', 'c', 'g', 'h', 'i', 's', 't', 'u', 'w', 'y', 'ab','##fu', 'Fa', 'Fac', '##ct', '##ful', '##full', '##fully',
'Th', 'ch', '##hm', 'cha', 'chap', 'chapt', '##thm', 'Hu', 'Hug', 'Hugg', 'sh', 'th', 'is', '##thms', '##za', '##zat',
'##ut']

正如我们所看到的,与 BPE 相比,这个标记器将单词的一部分作为标记学习得更快一些。

💡 在同一语料库上使用 train_new_from_iterator() 不会产生完全相同的词汇表。这是因为 🤗 Tokenizers 库没有为训练实现 WordPiece(因为我们不完全确定它的内部结构),而是使用 BPE。

为了对新文本进行分词,我们对其进行预分词、拆分,然后对每个单词应用分词算法。也就是说,我们从第一个词的开头寻找最大的子词并将其拆分,然后我们在第二部分重复这个过程,对于该词的其余部分和文本中的以下词,依此类推:

1
2
3
4
5
6
7
8
9
10
11
12
13
def encode_word(word):
tokens = []
while len(word) > 0:
i = len(word)
while i > 0 and word[:i] not in vocab:
i -= 1
if i == 0:
return ["[UNK]"]
tokens.append(word[:i])
word = word[i:]
if len(word) > 0:
word = f"##{word}"
return tokens

让我们用词汇表中的一个单词和另一个不在词汇表中的单词进行测试:

1
2
3
4
print(encode_word("Hugging"))
print(encode_word("HOgging"))
['Hugg', '##i', '##n', '##g']
['[UNK]']

现在,让我们编写一个标记文本的函数:

1
2
3
4
5
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]
encoded_words = [encode_word(word) for word in pre_tokenized_text]
return sum(encoded_words, [])

我们可以在任何文本上尝试:

1
2
3
tokenize("This is the Hugging Face course!")
['Th', '##i', '##s', 'is', 'th', '##e', 'Hugg', '##i', '##n', '##g', 'Fac', '##e', 'c', '##o', '##u', '##r', '##s',
'##e', '[UNK]']

Unigram标记化

在 SentencePiece 中经常使用 Unigram 算法,该算法是 AlBERT、T5、mBART、Big Bird 和 XLNet 等模型使用的标记化算法。

1.训练算法

与 BPE 和 WordPiece 相比,Unigram 在另一个方向上工作:它从一个较大的词汇表开始,然后从中删除标记,直到达到所需的词汇表大小。有多种选项可用于构建基本词汇表:例如,我们可以采用预标记化单词中最常见的子串,或者在具有大词汇量的初始语料库上应用BPE。

在训练的每一步,Unigram 算法都会在给定当前词汇的情况下计算语料库的损失。然后,对于词汇表中的每个符号,算法计算如果删除该符号,整体损失会增加多少,并寻找增加最少的符号。这些符号对语料库的整体损失影响较小,因此从某种意义上说,它们“不太需要”并且是移除的最佳候选者。

这都是一个非常昂贵的操作,因为我们不仅删除与最低损失增加相关的单个符号,而且还删除p((p)是您可以控制的超参数)通常是与最低损失增加相关的符号的10或20%。然后重复这个过程,直到词汇量达到所需的大小。

请注意,我们从不删除基本字符,以确保可以标记任何单词。

现在,这仍然有点模糊:算法的主要部分是计算语料库的损失,并查看当我们从词汇表中删除一些标记时它会如何变化,但我们还没有解释如何做到这一点。这一步依赖于 Unigram 模型的标记化算法,因此我们接下来将深入研究。

我们将重用前面示例中的语料库:

1
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

对于此示例,我们将采用初始词汇表的所有严格子字符串(一个字符到所有字符的字符串全部包括):

1
["h", "u", "g", "hu", "ug", "p", "pu", "n", "un", "b", "bu", "s", "hug", "gs", "ugs"]

2.标记化算法

Unigram 模型是一种语言模型,它认为每个标记都独立于它之前的标记。它是最简单的语言模型,从某种意义上说, 给定先前上下文的标记 X 的概率就是标记 X 的概率。因此,如果我们使用 Unigram 语言模型生成文本,我们将始终预测最常见的标记。

给定标记的概率是它在原始语料库中的频率(我们找到它的次数),除以词汇表中所有标记的所有频率的总和(以确保概率总和为 1)。例如, "ug""hug""pug" 以及 "hugs" 中,所以它在我们的语料库中的频率为 20。

以下是词汇表中所有可能的子词的出现频率:

1
2
("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概率的乘积。例如, "pug" 的标记化 ["p", "u", "g"] 的概率为:

P([p,u,g])=P(p)×P(u)×P(g)=5210×36210×20210=0.000389P(['p', 'u', 'g']) = P('p') \times P('u') \times P('g') = \frac{5}{210} \times \frac{36}{210} \times \frac{20}{210} = 0.000389

相比之下,标记化 ["pu", "g"] 的概率为:

P([pu,g])=P(pu)×P(g)=5210×20210=0.0022676P(['pu','g']) = P('pu') \times P('g') = \frac{5}{210} \times \frac{20}{210} = 0.0022676

所以一个更有可能。一般来说,具有尽可能少的标记的标记化将具有最高的概率(因为每个标记重复除以 210),这对应于我们直观想要的:将一个单词分成尽可能少的标记。

那么用 Unigram 模型对单词进行标记化就是概率最高的标记化。在"pug"的示例中,以下是我们为每个可能的分割得到的概率:

1
2
3
["p", "u", "g"] : 0.000389
["p", "ug"] : 0.0022676
["pu", "g"] : 0.0022676

所以, "pug" 将被标记为 ["p", "ug"] 或者 ["pu", "g"], 取决于首先遇到这些分割中的哪一个(请注意,在更大的语料库中,这样的相等的情况很少见)。

(感觉上面的概率算错了,P(‘p’)应该是17/210,P(‘pu’)应该是17/210)

在这种情况下,很容易找到所有可能的分割并计算它们的概率,但一般来说会有点困难。有一种用于此的经典算法,称为 维特比(Viterbi)算法。本质上,我们可以构建一个图来检测给定单词的可能分割,如果从a到b的子词在词汇表中,则表示存在从字符a到字符b的分支,并将子词的概率归因于该分支。

为了在该图中找到将具有最佳分数的路径,维特比算法为单词中的每个位置确定在该位置结束的具有最佳分数的分段。由于我们从头到尾,可以通过循环遍历以当前位置结尾的所有子词,然后使用该子词开始位置的最佳标记化分数来找到最佳分数。然后,我们只需展开到达终点的路径即可。(比如对于一个五个字符的单词,在第四个位置上,看怎么分割前四个字母能得到最高的分数)

让我们看一个使用我们的词汇表和单词 "unhug" 的例子。对于每个位置,以最好的分数结尾的子词如下:

1
2
3
4
5
Character 0 (u): "u" (score 0.171429) # 36/210
Character 1 (n): "un" (score 0.076191) # 16/210
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"]

3.回到训练

现在我们已经了解了标记化的工作原理,我们可以更深入地研究训练期间使用的损失。在任何给定的阶段,这个损失是通过对语料库中的每个单词进行标记来计算的,使用当前词汇表和由语料库中每个标记的频率确定的 Unigram 模型(如前所述)。

语料库中的每个词都有一个分数,损失是这些分数的负对数似然 — 即所有词的语料库中所有词的总和 -log(P(word))

让我们用以下语料库回到我们的例子:

1
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

每个单词的标记化及其各自的分数是:

1
2
3
4
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)

所以损失是:

1
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" 的标记化会变成:

1
2
"hug": ["hu", "g"] (score 0.006802)
"hugs": ["hu", "gs"] (score 0.001701)

这些变化将导致损失增加:

1
- 10 * (-log(0.071428)) + 10 * (-log(0.006802)) = 23.5

因此, 标记 "pu"可能会从词汇表中删除,但不会删除 "hug".

4.实现Unigram

现在让我们在代码中实现我们迄今为止看到的所有内容。与 BPE 和 WordPiece 一样,这不是 Unigram 算法的有效实现(恰恰相反),但它应该可以帮助你更好地理解它。

选择语料库

我们将使用与之前相同的语料库作为示例:

1
2
3
4
5
6
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.",
]

选择tokenizer

这一次,我们将使用 xlnet-base-cased 作为我们的模型:

1
2
3
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("xlnet-base-cased")

计算语料库中每个单词的出现次数

与 BPE 和 WordPiece 一样,我们首先计算语料库中每个单词的出现次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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

defaultdict(int,
{'▁This': 3,
'▁is': 2,
'▁the': 1,
'▁Hugging': 1,
'▁Face': 1,
'▁Course.': 1,
'▁chapter': 1,
'▁about': 1,
'▁tokenization.': 1,
'▁section': 1,
'▁shows': 1,
'▁several': 1,
'▁tokenizer': 1,
'▁algorithms.': 1,
'▁Hopefully,': 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})

词汇表初始化,按频率进行排序

然后,我们需要将我们的词汇表初始化为大于我们最终想要的词汇量。我们必须包含所有基本字符(否则我们将无法标记每个单词),但对于较大的子字符串,我们将只保留最常见的字符,因此我们按频率对它们进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 的初始词汇表:

1
2
token_freqs = list(char_freqs.items()) + sorted_subwords[: 300 - len(char_freqs)] # 单个字符加上频率由高到低的多字符,总共300
token_freqs = {token: freq for token, freq in token_freqs}

💡 SentencePiece 使用一种称为增强后缀数组(ESA)的更高效算法来创建初始词汇表。

计算所有频率的总和,将频率转换为概率

接下来,我们计算所有频率的总和,将频率转换为概率。对于我们的模型,我们将存储概率的对数,因为对数相加比小数相乘在数值上更稳定(好像在位置编码那篇博客里看过),这将简化模型损失的计算:

1
2
3
4
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中的内容进行比较。

主循环完成后,我们只需从末尾开始,从一个起始位置跳到下一个起始位置,同时记录标记,直到到达单词的开头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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

我们已经可以在一些词上尝试我们的初始模型:

1
2
3
4
print(encode_word("Hopefully", model))
print(encode_word("This", model))
(['H', 'o', 'p', 'e', 'f', 'u', 'll', 'y'], 41.5157494601402)
(['This'], 6.288267030694535)

计算模型在语料库上的损失

现在很容易计算模型在语料库上的损失!

1
2
3
4
5
6
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

我们可以检查它是否适用于我们拥有的模型:

1
2
3
compute_loss(model)

413.10377642940875

计算通过删除每个标记获得的模型的损失

计算每个标记的分数也不是很难;我们只需要计算通过删除每个标记获得的模型的损失:

1
2
3
4
5
6
7
8
9
10
11
12
13
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

我们可以在给定的标记上尝试:

1
2
3
scores = compute_scores(model)
print(scores["ll"])
print(scores["his"])

自从 "ll" 用于标记化 "Hopefully", 删除它可能会让我们使用标记 "l" 两次相反,我们预计它将产生正损失。 "his" 仅在单词"This" 内使用,它被标记为自身,所以我们期望它的损失为零。结果如下:

1
2
6.376412403623874
0.0

💡 这种方法非常低效,因此 SentencePiece 使用了没有token X 的模型损失的近似值:它不是从头开始,而是通过其在剩余词汇表中的分段替换标记 X。这样,所有分数可以与模型损失同时计算。

完成所有这些后,我们需要做的最后一件事是将模型使用的特殊标记添加到词汇表中,然后循环直到我们从词汇表中修剪了足够的标记以达到我们想要的大小:

1
2
3
4
5
6
7
8
9
10
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() 函数:

1
2
3
4
5
6
7
8
9
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', '.']