HuggingFace课程-6. 🤗 Tokenizers库 BPE tokenization 算法
BPE tokenization 算法
字节对编码(BPE)最初被开发为一种压缩文本的算法,然后在预训练 GPT 模型时被 OpenAI 用于 tokenization。许多 Transformer 模型都使用它,包括 GPT、GPT-2、RoBERTa、BART 和 DeBERTa。
💡 本节深入介绍了 BPE,甚至展示了一个完整的实现。如果你只想大致了解 tokenization 算法,可以跳到最后。
BPE 训练
BPE 训练首先计算语料库中使用的唯一单词集合(在完成标准化和预分词步骤之后),然后取出用来编写这些词的所有符号来构建词汇表。举一个非常简单的例子,假设我们的语料库使用了这五个词:
"hug", "pug", "pun", "bun", "hugs"
基础单词集合将是 ["b", "g", "h", "n", "p", "s", "u"] 。在实际应用中,基本词汇表将至少包含所有 ASCII 字符,可能还包含一些 Unicode 字符。如果你正在 tokenization 不在训练语料库中的字符,则该字符将转换为未知 tokens,这就是为什么许多 NLP 模型在分析带有表情符号的内容的结果非常糟糕的原因之一。
GPT-2 和 RoBERTa (这两者非常相似)的 tokenizer 有一个巧妙的方法来处理这个问题:他们不把单词看成是用 Unicode 字符编写的,而是用字节编写的。这样,基本词汇表的大小很小(256),但是能包含几乎所有你能想象的字符,而不会最终转换为未知 tokens 这个技巧被称为 字节级(byte-level) BPE 。
获得这个基础单词集合后,我们通过学习 合并(merges) 来添加新的 tokens 直到达到期望的词汇表大小。合并是将现有词汇表中的两个元素合并为一个新元素的规则。所以,一开始会创建出含有两个字符的 tokens 然后,随着训练的进展,会产生更长的子词。
在分词器训练期间的任何一步,BPE 算法都会搜索最常见的现有 tokens 对 (在这里,“对”是指一个词中的两个连续 tokens )。最常见的这一对会被合并,然后我们重复这个过程。
回到我们之前的例子,让我们假设单词具有以下频率:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
意思是 "hug" 在语料库中出现了 10 次, "pug" 出现了 5 次, "pun" 出现了 12 次, "bun" 出现了 4 次, "hugs" 出现了 5 次。我们通过将每个单词拆分为字符(形成我们初始词汇表的字符)来开始训练,这样我们就可以将每个单词视为一个 tokens 列表:
("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 次。
因此,tokenizer 学习的第一个合并规则是 ("u", "g") -> "ug" ,意思就是 "ug" 将被添加到词汇表中,且应在语料库的所有词中合并这一对。在这个阶段结束时,词汇表和语料库看起来像这样:
词汇表: ["b", "g", "h", "n", "p", "s", "u", "ug"] 语料库: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
现在我们有一些对,继续合并的话会产生一个比两个字符长的 tokens 例如 ("h", "ug") ,在语料库中出现 15 次。然而,这个阶段出现频率最高的对是 ("u", "n") ,在语料库中出现 16 次,所以学到的第二个合并规则是 ("u", "n") -> "un" 。将其添加到词汇表并合并所有现有的这个对,将出现:
词汇表: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"] 语料库: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)
现在最频繁的一对是 ("h", "ug") ,所以我们学习了合并规则 ("h", "ug") -> "hug" ,这形成了我们第一个三个字母的 tokens 合并后,语料库如下所示:
词汇表: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"] 语料库: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
我们继续这样合并,直到达到我们所需的词汇量。
✏️ 现在轮到你了! 你认为下一个合并规则是什么?
tokenization
完成训练之后就可以对新的输入 tokenization 了,从某种意义上说,新的输入会依照以下步骤对新输入进行 tokenization:
- 标准化
- 预分词
- 将单词拆分为单个字符
- 根据学习的合并规则,按顺序合并拆分的字符
让我们以我们在训练期间使用的示例为例,Tokenizer 学习到了三个合并规则:
("u", "g") -> "ug" ("u", "n") -> "un" ("h", "ug") -> "hug"
在这种情况下,单词 "bug" 将被转化为 ["b", "ug"] 。然而 "mug" ,将被转换为 ["[UNK]", "ug"] ,因为字母 "m" 不再基本词汇表中。同样,单词 "thug" 会被转换为 ["[UNK]", "hug"] :字母 "t" 不在基本词汇表中,使用合并规则首先会将 "u" 和 "g" 合并,然后将 "h" 和 "ug" 合并。
✏️ 现在轮到你了! 你认为这个词 "unhug" 将如何被 tokenization?
实现 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.", ]
接下来,我们需要将该语料库预分词为单词。由于我们正在复现一个 BPE tokenizer (例如 GPT-2),我们将使用 gpt2 分词器进行预分词:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("gpt2")
然后,我们在进行预分词的同时计算语料库中每个单词的频率:
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})
下一步是计算基础词汇表,这由语料库中使用的所有字符组成:
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', 'Ġ']
我们还在该词汇表的开头添加了模型使用的特殊 tokens 对于 GPT-2,唯一的特殊 tokens 是 "<|endoftext|>" :
vocab = ["<|endoftext|>"] + alphabet.copy()
我们现在需要将每个单词拆分为单独的字符,以便能够开始训练:
splits = {word: [c for c in word] for word in word_freqs.keys()}
{'This': ['T', 'h', 'i', 's'],
'Ġis': ['Ġ', 'i', 's'],
'Ġthe': ['Ġ', 't', 'h', 'e'],
'ĠHugging': ['Ġ', 'H', 'u', 'g', 'g', 'i', 'n', 'g'],
'ĠFace': ['Ġ', 'F', 'a', 'c', 'e'],
'ĠCourse': ['Ġ', 'C', 'o', 'u', 'r', 's', 'e'],
'.': ['.'],
'Ġchapter': ['Ġ', 'c', 'h', 'a', 'p', 't', 'e', 'r'],
'Ġabout': ['Ġ', 'a', 'b', 'o', 'u', 't'],
'Ġtokenization': ['Ġ',
't',
'o',
'k',
'e',
'n',
'i',
'z',
'a',
't',
'i',
'o',
'n'],
'Ġsection': ['Ġ', 's', 'e', 'c', 't', 'i', 'o', 'n'],
'Ġshows': ['Ġ', 's', 'h', 'o', 'w', 's'],
'Ġseveral': ['Ġ', 's', 'e', 'v', 'e', 'r', 'a', 'l'],
'Ġtokenizer': ['Ġ', 't', 'o', 'k', 'e', 'n', 'i', 'z', 'e', 'r'],
'Ġalgorithms': ['Ġ', 'a', 'l', 'g', 'o', 'r', 'i', 't', 'h', 'm', 's'],
'Hopefully': ['H', 'o', 'p', 'e', 'f', 'u', 'l', 'l', 'y'],
',': [','],
'Ġyou': ['Ġ', 'y', 'o', 'u'],
'Ġwill': ['Ġ', 'w', 'i', 'l', 'l'],
'Ġbe': ['Ġ', 'b', 'e'],
'Ġable': ['Ġ', 'a', 'b', 'l', 'e'],
'Ġto': ['Ġ', 't', 'o'],
'Ġunderstand': ['Ġ', 'u', 'n', 'd', 'e', 'r', 's', 't', 'a', 'n', 'd'],
'Ġhow': ['Ġ', 'h', 'o', 'w'],
'Ġthey': ['Ġ', 't', 'h', 'e', 'y'],
'Ġare': ['Ġ', 'a', 'r', 'e'],
'Ġtrained': ['Ġ', 't', 'r', 'a', 'i', 'n', 'e', 'd'],
'Ġand': ['Ġ', 'a', 'n', 'd'],
'Ġgenerate': ['Ġ', 'g', 'e', 'n', 'e', 'r', 'a', 't', 'e'],
'Ġtokens': ['Ġ', 't', 'o', 'k', 'e', 'n', 's']}
现在我们已准备好进行训练,让我们编写一个函数来计算每对字符的频率。我们需要在训练的每个步骤中使用它:
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
让我们来看看这个字典在初始合并后的一些结果:
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
现在,只需要一个简单的循环就可以找到出现频率最高的对:
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' 添加到词汇表:
merges = {("Ġ", "t"): "Ġt"}
vocab.append("Ġt")
接下来,我们需要在我们的 splits 字典中进行这个合并。让我们为此编写另一个函数:
def merge_pair(a, b, splits): ''' 函数作用 merge_pair(a, b, splits) 用于在所有词的分割结果中,将连续出现的字符对 (a, b) 合并成一个新的字符 a+b,并更新分割结果。 参数说明 a 和 b:需要被合并的两个连续字符(例如,如果 a="t",b="h",则会将所有 "t" 后接 "h" 的情况合并为 "th") splits:一个字典,键是单词(word),值是该单词的当前分割结果(列表形式,例如 {"this": ["t", "h", "i", "s"]}) word_freqs:代码中引用的外部变量,是一个包含单词及其频率的字典(用于遍历所有需要处理的单词) ''' # 遍历 word_freqs 中的所有单词 for word in word_freqs: # 对每个单词,获取其当前的分割结果 split(列表形式) split = splits[word] # 如果分割结果只有一个元素(无法再合并),则跳过 if len(split) == 1: continue i = 0 while i < len(split) - 1: #遍历分割结果中的每一个位置,检查是否存在连续的 a 和 b if split[i] == a and split[i + 1] == b: # 如果找到 split[i] == a 且 split[i+1] == b,则将这两个元素合并为 a+b,并更新分割列表 split = split[:i] + [a + b] + split[i + 2:] # 如果没找到,则继续检查下一个位置 else: i += 1 splits[word] = split return splits
我们可以观察一下第一次合并的结果:
splits = merge_pair("Ġ", "t", splits) print(splits["Ġtrained"])
['Ġt', 'r', 'a', 'i', 'n', 'e', 'd']
现在我们有了我们需要的所有代码,可以循环直到我们学习到我们想要的所有合并。让我们把目标词汇表的大小设定为 50:
# 目标词汇表大小(最终要生成 50 个词汇)。 vocab_size = 50 # 只要当前词汇表的大小还没达到 50,就持续执行合并操作。 while len(vocab) < vocab_size: # 统计所有单词的splits中连续出现的字符对的频率。例如,若单词分割后有["h", "e", "l", "l", "o"],则会统计("h","e")、("e","l")、("l","l")、("l","o")这些对的出现次数。 pair_freqs = compute_pair_freqs(splits) best_pair = "" max_freq = None for pair, freq in pair_freqs.items(): # 遍历pair_freqs,找到出现频率最高的字符对best_pair(如("l", "l"))。 if max_freq is None or max_freq < freq: best_pair = pair max_freq = freq # 在所有单词的splits中,将best_pair(如("l", "l"))合并为新的子词(如"ll"),并更新splits。 splits = merge_pair(*best_pair, splits) merges[best_pair] = best_pair[0] + best_pair[1] vocab.append(best_pair[0] + best_pair[1])
最终,我们学习了 19 条合并规则(初始词汇量为 31 —— 字母表中的 30 个字符,加上特殊 token ):
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'}
词汇表由特殊 token 初始字母和所有合并结果组成:
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 选择第一个。
为了对新文本进行分词,我们对其进行预分词、拆分,然后使用学到的所有合并规则:
def tokenize(text): ''' 文本预处理(Pre-tokenization): 调用分词器的基础预处理工具,将原始文本分割为 “初始单词单元”(如按空格、标点拆分)。 pre_tokenize_result 包含拆分后的单词及其在原始文本中的位置偏移(offset),这里只提取单词部分。 示例:输入 "Hello, world!" 可能被预处理为 ["Hello", ",", "world", "!"]。 ''' pre_tokenize_result = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text) pre_tokenize_text = [word for word, offset in pre_tokenize_result] ''' 初始分割为单个字符: 将每个预处理后的单词拆分为单个字符的列表,作为 BPE 合并的初始状态。 示例:["Hello", "world"] 会变成 [["H", "e", "l", "l", "o"], ["w", "o", "r", "l", "d"]]。 ''' splits = [[l for l in word] for word in pre_tokenize_text] # 遍历所有训练好的合并规则(merges),按顺序将每个规则应用到当前的分割结果中。 for pair, merge in merges.items(): for idx, split in enumerate(splits): i = 0 while i < len(split) - 1: # 对每个单词的分割列表(split),检查是否存在需要合并的字符对(pair),如果找到则合并为 merge(如将 ["t", "h"] 合并为 ["th"])。 # 注意:合并规则的顺序很重要(通常按训练时的合并优先级排序),确保高频对先合并。 if split[i] == pair[0] and split[i + 1] == pair[1]: split = split[:i] + [merge] + split[i+2:] else: i += 1 splits[idx] = split ''' 返回最终 tokens: 将所有单词的分割结果(子词列表)拼接成一个扁平的 tokens 列表。 示例:经过合并后,可能返回 ["He", "ll", "o", ",", "wor", "ld", "!"]。 ''' # sum(splits, []) 是一个 Python 中用于将嵌套列表展开为扁平列表的简洁写法,在这段分词代码中用于将多个单词的子词列表合并成一个连续的 token 序列。 # sum() 函数的第一个参数是要累加的序列,第二个参数是初始值。当处理列表时,sum() 的逻辑是:用初始值依次与序列中的元素进行 “加法” 操作(对列表而言,“加法” 就是拼接)。 return sum(splits, [])
我们可以尝试在任何由字母表中的字符组成的文本上进行此操作:
tokenize("This is not a token.")
['This', 'Ġis', 'Ġ', 'n', 'o', 't', 'Ġa', 'Ġtoken', '.']
⚠️ 如果存在未知字符,我们的实现将抛出错误,因为我们没有做任何处理它们。GPT-2 实际上没有未知 tokens (使用字节级 BPE 时不可能得到未知字符),但这里的代码可能会出现这个错误,因为我们并未在初始词汇中包含所有可能的字节。BPE 的这一部分已超出了本节的范围,因此我们省略了一些细节。
至此,BPE 算法的介绍就到此结束!接下来,我们将研究 WordPiece 算法。

浙公网安备 33010602011771号