前言
WordPiece是谷歌开发用于预训练BERT的标记化算法。自那以后,它在许多基于BERT的Transformer模型中得到了重新使用,例如DistilBERT、MobileBERT、Funnel Transformers和MPNET。在训练方面,它非常类似于BPE,但实际的标记化过程有所不同。
💡 本节深入介绍了WordPiece,甚至展示了一个完整的实现。如果您只想了解标记化算法的概述,可以直接跳到结尾。
src link: https://huggingface.co/learn/nlp-course/chapter6/6
Operating System: Ubuntu 22.04.4 LTS
参考文档
训练算法
⚠️ 谷歌从未开源其WordPiece训练算法的实现,所以接下来展示的是我们基于已发表文献做出的最佳猜测。它可能不是100%准确的。
与BPE类似,WordPiece从一个包含模型使用的特殊符号和初始字母表的小词汇表开始。由于它通过添加前缀(例如BERT中的##)来识别子词,所以每个词最初都是通过向词内的所有字符添加该前缀来分割的。例如,“word”就是这样被分割的:
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训练示例中使用的同一个词汇表:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
这里的分割将是:
("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”添加到词汇表中,并在语料库的单词中应用合并:
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”。这使我们得到:
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相比),所以分数最大的第一个词对被合并:
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)
我们继续这样做,直到达到所需的词汇表大小。
现在轮到你了!下一个合并规则会是什么?
标记化算法
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只将词汇表中没有的个别字符标记为未知。
现在轮到你了!单词”pugs”将如何被标记化?
实现WordPiece
现在让我们看一下WordPiece算法的一个实现。与BPE一样,这只是为了教学,你无法在大型语料库上使用它。
我们将使用与BPE示例中相同的语料库:
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标记器进行预处理:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
然后,我们在进行预标记化的同时计算语料库中每个词的频率。
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})
正如我们之前所看到的,字母表是由所有单词的第一个字母以及所有以”##”为前缀的单词中出现的其他字母组成的唯一集合。
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]”]。
vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + alphabet.copy()
接下来,我们需要拆分每个单词,将所有不是首字母的字母前缀加上”##”。
splits = {
word: [c if i == 0 else f"##{c}" for i, c in enumerate(word)]
for word in word_freqs.keys()
}
现在我们已经准备好进行训练,让我们编写一个函数来计算每一对词的得分。我们将在训练的每一步都需要使用这个函数:
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
让我们看一下在初始分割后字典的一部分:
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
现在,找到得分最高的词对只需要一个快速的循环:
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’添加到词汇表中。
vocab.append("ab")
为了继续,我们需要在我们的分割字典中应用这个合并。让我们为此编写另一个函数:
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
然后我们可以看一下第一次合并的结果:
splits = merge_pair("a", "##b", splits)
splits["about"]
['ab', '##o', '##u', '##t']
现在我们有了循环所需的一切,直到我们学完所有想要的合并。让我们将词汇表的大小目标设定为70:
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)
然后我们可以查看生成的词汇表:
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 代替。
要对一个新的文本进行分词,我们首先对其进行预分词,然后分割它,接着对每个单词应用分词算法。也就是说,我们从第一个单词的开始处寻找最大的子词,并将其分割,然后在第二部分重复这个过程,以此类推,直到处理完该单词的剩余部分和文本中的后续单词。
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
让我们测试一个在词汇表中的单词,以及一个不在词汇表中的单词:
print(encode_word("Hugging"))
print(encode_word("HOgging"))
['Hugg', '##i', '##n', '##g']
['[UNK]']
现在,让我们编写一个函数来对文本进行分词:
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, [])
我们可以尝试对任何文本进行分词:
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]']
这就是WordPiece算法的全部内容!现在让我们来看看Unigram算法。
结语
第三百一十四篇博文写完,开心!!!!
今天,也是充满希望的一天。