Tokenizer 分词器详解:BPE、WordPiece 与编解码流程

系统讲解分词器:编码与解码两个阶段中分词、映射、反映射与文本重组的四步流程,对比 GPT 系列常用的 BPE 与 BERT 系列常用的 WordPiece,并给出使用 GPT-2 分词器做编码解码的代码示例。

常见的子词分割方法:

  • BPE(Byte-Pair Encoding):用于 GPT、GPT-2、RoBERTa、BART 和 DeBERTa 等模型。
  • WordPiece:用于 DistilBERT、MobileBERT、Funnel Transformers 和 MPNET 等模型。

    什么是 Tokenizer?

Tokenizer(分词器)可以将原始文本(raw text)转换为模型能够理解的数字序列,在模型输入和输出的两个主要阶段中发挥重要作用:

编码Encode阶段

  1. 分词(Tokenize)

  2. 映射(Mapping)

    解码Decode阶段

  3. 反映射(De-mapping)

  4. 文本重组

    实际使用

在进一步讲解之前,我们先通过 Transformers 库中的 AutoTokenizer 类来使用 Tokenizer。

安装库

1
pip install transformers

BPE 分词器示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from transformers import AutoTokenizer

# 使用 GPT-2 的分词器(BPE)
tokenizer = AutoTokenizer.from_pretrained("gpt2")

text = "Hello, world!"

# 编码
# 1. 将文本分词为 Tokens
tokens = tokenizer.tokenize(text)
print("Tokens:", tokens)

# 2. 将 Tokens 转换为 Token IDs
token_ids = tokenizer.convert_tokens_to_ids(tokens)
print("Token IDs:", token_ids)

# 解码
# 1. Token IDs 转换为 Tokens
tokens = tokenizer.convert_ids_to_tokens(token_ids)
print("Tokens:", tokens)

# 2. Tokens 拼接为文本
decoded_text = tokenizer.convert_tokens_to_string(tokens)
print("Decoded Text:", decoded_text)

输出

1
2
3
4
Tokens: ['Hello', ',', 'Ġworld', '!']
Token IDs: [15496, 11, 995, 0]
Tokens: ['Hello', ',', 'Ġworld', '!']
Decoded Text: Hello, world!

Note

实际上 GPT-2 用的是 Byte-level 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
from transformers import AutoTokenizer

# 使用 BERT 的分词器(WordPiece)
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")

text = "Hello, world!"

# 编码
# 1. 将文本分词为 Tokens
tokens = tokenizer.tokenize(text)
print("Tokens:", tokens)

# 2. 将 Tokens 转换为 Token IDs
token_ids = tokenizer.convert_tokens_to_ids(tokens)
print("Token IDs:", token_ids)

# 解码
# 1. Token IDs 转换为 Tokens
tokens = tokenizer.convert_ids_to_tokens(token_ids)
print("Tokens:", tokens)

# 2. Tokens 拼接为文本
decoded_text = tokenizer.convert_tokens_to_string(tokens)
print("Decoded Text:", decoded_text)

输出

1
2
3
4
Tokens: ['hello', ',', 'world', '!']
Token IDs: [7592, 1010, 2088, 999]
Tokens: ['hello', ',', 'world', '!']
Decoded Text: hello, world!

encode() 和 decode() 方法

更简洁且常见的使用方式是直接使用 encode()decode() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from transformers import AutoTokenizer

# 取消注释以对比两种分词器的输出差异
tokenizer = AutoTokenizer.from_pretrained("gpt2")
# tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")

text = "Hello, world!"

# 使用 encode() 将文本直接转换为 Token IDs
token_ids = tokenizer.encode(text)
print("Token IDs:", token_ids)

# 使用 decode() 将 Token IDs 转换回文本
decoded_text = tokenizer.decode(token_ids)
print("Decoded Text:", decoded_text)

输出

1
2
Token IDs: [15496, 11, 995, 0]
Decoded Text: Hello, world!

构造词汇表

我们需要将语料库(corpus)的文本拆分为单词,假设当前语料库包含的单词和对应频次如下:

1
("low", 5), ("lower", 2), ("newest", 6), ("widest", 3)

有些论文也用 vocab 来表述,知道后面是频次即可,命名不用纠结。

Byte-Pair Encoding (BPE)

参考文献:

BPE 每次的迭代目标是找到频率最高的相邻字符对,定义 Score 以与 WordPiece 作对比:

$$
Score_{BPE}(x,y)=freq(x,y)
$$

其中, $freq(x,y)$ 表示字符对$ (x,y) $在语料库中的出现频次。

步骤

  1. 初始化词汇表 V
  2. 统计字符对的频次
  3. 找到频次(Score)最高的字符对并合并
  4. 更新词汇表并重复步骤 2 到 4

示例

步骤 1:初始化词汇表

  • 将单词拆分为字符序列
  • 词汇表 V

步骤 2:统计字符对的频次

编写一个函数,根据给定的单词和其频次,自动统计字符对的频次。

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
from collections import defaultdict

def count_char_pairs(word_freq):
"""
计算字符对的频次。

参数:
word_freq: Lst of tuples, 每个元组包含单词和其频次

返回:
字符对频次的字典
"""
pair_freq = defaultdict(int)
for word, freq in word_freq:
chars = list(word)
for i in range(len(chars) - 1):
pair = (chars[i], chars[i + 1])
pair_freq[pair] += freq
return pair_freq

# 示例词汇表和单词频次
word_freq = [
("low", 5),
("lower", 2),
("newest", 6),
("widest", 3)
]

pair_freq = count_char_pairs(word_freq)
print("字符对频次统计结果:")
for pair, freq in pair_freq.items():
print(f"{pair}: {freq}")

输出

1
2
3
4
5
6
7
8
9
10
11
12
字符对频次统计结果:
('l', 'o'): 7 # 5 (low) + 2 (lower)
('o', 'w'): 7 # 5 (low) + 2 (lower)
('w', 'e'): 8 # 2 (lower) + 6 (newest)
('e', 'r'): 2
('n', 'e'): 6
('e', 'w'): 6
('e', 's'): 9 # 6 (newest) + 3 (widest)
('s', 't'): 9 # 6 (newest) + 3 (widest)
('w', 'i'): 3
('i', 'd'): 3
('d', 'e'): 3

步骤 3:找到频次最高的字符对并合并

  • 选择频次最高的字符对
  • 合并 **("e", "s")** 为新符号 **es**。
  • 记录合并操作

步骤 4:更新词汇表并重复

  • 更新单词序列
  • 更新词汇表 V
  • 重复步骤 2 到 4,直到达到预定的词汇表大小

    WordPiece

参考文献:

与 BPE 不同,WordPiece 的 Score 由字符对频次与其组成部分频次的比值决定,定义 Score:

$$
\text{Score}_{\text{WordPiece}}(x, y) = \frac{\text{freq}(xy)}{\text{freq}(x) \times \text{freq}(y)}
$$

其中, $freq(x)$, $freq(y)$ 和 $freq(xy)$ 分别表示符号 x, y 和它们合并后的符号$ xy$ 的频次。

步骤

  1. 初始化词汇表 V
  2. 统计字符对的频次及 Score
  3. 找到 Score 最高的字符对并合并
  4. 更新词汇表并重复步骤 2 到 4

示例

使用与 BPE 示例相同的语料库。

步骤 1:初始化词汇表

  • 将单词拆分为字符序列
  • 词汇表 V

步骤 2:统计字符和字符对的频次,计算 Score

可以设计一个函数完成这个步骤(直接运行查看输出):

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
from collections import defaultdict

def count_char_pairs_wordpiece(word_freq):
"""
计算字符对的频次和单个字符的频次。

参数:
word_freq: List of tuples, 每个元组包含单词(列表形式)和其频次

返回:
两个字典,分别为字符对频次和单个字符频次
"""
pair_freq = defaultdict(int)
char_freq = defaultdict(int)
for word, freq in word_freq:
for i in range(len(word)):
char_freq[word[i]] += freq
if i < len(word) - 1:
pair = (word[i], word[i + 1])
pair_freq[pair] += freq
return pair_freq, char_freq

def compute_wordpiece_score(freq_xy, freq_x, freq_y):
"""
根据 WordPiece 的定义计算 Score。

参数:
freq_xy: 符号对的频次
freq_x: 符号 x 的频次
freq_y: 符号 y 的频次

返回:
计算得到的 Score
"""
if freq_x == 0 or freq_y == 0:
return 0
return freq_xy / (freq_x * freq_y)

# 示例词汇表和单词频次
word_freq = [
(['l', '##o', '##w'], 5),
(['l', '##o', '##w', '##e', '##r'], 2),
(['n', '##e', '##w', '##e', '##s', '##t'], 6),
(['w', '##i', '##d', '##e', '##s', '##t'], 3)
]

# 统计字符对频次和单个字符频次
pair_freq, char_freq = count_char_pairs_wordpiece(word_freq)

# 计算每对字符的 Score
scores = {}
for pair in pair_freq:
freq_xy = pair_freq[pair]
freq_x = char_freq[pair[0]]
freq_y = char_freq[pair[1]]
score = compute_wordpiece_score(freq_xy, freq_x, freq_y)
scores[pair] = score

# 输出结果
print("字符对频次统计结果:")
for pair, freq in pair_freq.items():
print(f"{pair}: {freq}")

print("\n单个字符频次统计结果:")
for char, freq in char_freq.items():
print(f"{char}: {freq}")

print("\n字符对 Score 计算结果:")
for pair, score in scores.items():
print(f"{pair}: {score:.4f}")

输出

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
字符对频次统计结果:
('l', '##o'): 7
('##o', '##w'): 7
('##w', '##e'): 8
('##e', '##r'): 2
('n', '##e'): 6
('##e', '##w'): 6
('##e', '##s'): 9
('##s', '##t'): 9
('w', '##i'): 3
('##i', '##d'): 3
('##d', '##e'): 3

单个字符频次统计结果:
l: 7
##o: 7
##w: 13
##e: 17
##r: 2
n: 6
##s: 9
##t: 9
w: 3
##i: 3
##d: 3

字符对 Score 计算结果:
('l', '##o'): 0.1429
('##o', '##w'): 0.0769
('##w', '##e'): 0.0362
('##e', '##r'): 0.0588
('n', '##e'): 0.0588
('##e', '##w'): 0.0271
('##e', '##s'): 0.0588
('##s', '##t'): 0.1111
('w', '##i'): 0.3333
('##i', '##d'): 0.3333
('##d', '##e'): 0.0588
  • 选择频次最高的字符对
  • 合并 **('w', '##i')** 为新符号 **wi**
  • 记录合并操作:

步骤 4:更新词汇表并重复

  • 更新词汇表 V
  • 更新单词序列
  • 重复步骤 2 到 4,直到达到预定的词汇表大小

    标记文本

每次合并时都会记录对应的 merge 规则,但并未详细说明其作用,下面将以 BPE 为例进行解释。

BPE

在之前的示例中,三轮合并后将得到以下合并规则(按合并顺序排列):

  1. 合并字符对 'e''s',得到 'es'
  2. 合并字符对 'es''t',得到 'est'
  3. 合并字符对 'l''o',得到 'lo'
    假设当前词汇表包含所有单个字符,修改官方文档最后提供的 tokenize() 示例代码进行演示:
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
41
42
43
44
45
46
47
48
49
50
51
52
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]

print("初始预分词结果:")
print(pre_tokenized_text)

# 将每个单词拆分为字符列表
splits = [[l for l in word] for word in pre_tokenized_text]
print("\n初始拆分结果:")
print(splits)

# 遍历所有合并规则(merges),逐步应用到拆分后的结果中
for pair, merge in merges.items():
print(f"\n应用合并规则: {pair} -> {merge}")

# 遍历每个已拆分的单词
for idx, split in enumerate(splits):
print(f" 合并前第 {idx+1} 个单词: {split}")
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 :]
print(f" 在位置 {i} 处合并: {split}")
else:
i += 1
# 更新拆分后的结果
splits[idx] = split

print("\n最终拆分结果:")
print(splits)

# 将所有拆分后的结果合并为一个 Token 列表并返回
return sum(splits, [])

# 示例 merges 字典
merges = {
('e', 's'): 'es',
('es', 't'): 'est',
('l', 'o'): 'lo'
}

# 示例文本
text = "estimate, local"

# 调用 tokenize 函数,并打印中间过程
tokens = tokenize(text)
print("\n最终生成的 Tokens:")
print(tokens)

输出

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
初始预分词结果:
['estimate', ',', 'local']

初始拆分结果:
[['e', 's', 't', 'i', 'm', 'a', 't', 'e'], [','], ['l', 'o', 'c', 'a', 'l']]

应用合并规则: ('e', 's') -> es
合并前第 1 个单词: ['e', 's', 't', 'i', 'm', 'a', 't', 'e']
在位置 0 处合并: ['es', 't', 'i', 'm', 'a', 't', 'e']
合并前第 2 个单词: [',']
合并前第 3 个单词: ['l', 'o', 'c', 'a', 'l']

应用合并规则: ('es', 't') -> est
合并前第 1 个单词: ['es', 't', 'i', 'm', 'a', 't', 'e']
在位置 0 处合并: ['est', 'i', 'm', 'a', 't', 'e']
合并前第 2 个单词: [',']
合并前第 3 个单词: ['l', 'o', 'c', 'a', 'l']

应用合并规则: ('l', 'o') -> lo
合并前第 1 个单词: ['est', 'i', 'm', 'a', 't', 'e']
合并前第 2 个单词: [',']
合并前第 3 个单词: ['l', 'o', 'c', 'a', 'l']
在位置 0 处合并: ['lo', 'c', 'a', 'l']

最终拆分结果:
[['est', 'i', 'm', 'a', 't', 'e'], [','], ['lo', 'c', 'a', 'l']]

最终生成的 Tokens:
['est', 'i', 'm', 'a', 't', 'e', ',', 'lo', 'c', 'a', 'l']

不过,在之前的过程中生成的最终词汇表 V 并未包含所有单个字符,而是:

1
{'e', 'r', 's', 'est', 'w', 'l', 'o', 'lo', 'es', 'i', 'n', 't', 'd'}

因此,对于输入 "estimate, local",其标记结果为:

1
['est', 'i', '[UNK]', 'a', 't', 'e', '[UNK]', 'lo', '[UNK]', '[UNK]', l]

这里的 '[UNK]'(UNKNOWN)表示该子词不在词汇表中,即属于 OOV(Out-of-Vocabulary) 的情况。

WordPiece

和 BPE 不同,WordPiece 对 OOV 采取的是「宁杀错不放过」策略,即只要有一个字符没见过,整个单词都标记为 '[UNK]'

修改官方文档最后提供的 tokenize() 示例代码进行演示:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from transformers import AutoTokenizer

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]

print("\n初始预分词结果:")
print(pre_tokenized_text)

# 对每个单词进行标记
tokenized_words = []
for word in pre_tokenized_text:
tokens = []
print(f"\n正在标记单词: {word}")

while len(word) > 0:
i = len(word)
# 尝试匹配词汇表中的最长子词
while i > 0 and word[:i] not in vocab:
i -= 1
if i == 0:
print(f" [UNK] 标记: {word}")
tokens = ["[UNK]"] # 没有匹配到则返回 [UNK]
break # 跳出循环,不再继续处理该单词

# 匹配到子词,添加到 tokens 列表中
matched_token = word[:i]
tokens.append(matched_token)
print(f" 匹配到 Token: {matched_token}")

# 更新剩余部分,并添加“##”作为前缀
word = word[i:]
if len(word) > 0:
word = f"##{word}"
print(f" 剩余部分添加前缀: {word}")

print(f" 标记结果: {tokens}")
tokenized_words.append(tokens)

print("\n最终标记结果:")
flattened_tokens = sum(tokenized_words, []) # 展平成单层列表
print(flattened_tokens)

return flattened_tokens

# 示例词汇表
vocab = {'##st', 'n', '##i', '##s', 'wid', '##d', 'wi', '##r', '##o',
'lo', 'w', '##e', '##w', '##t', 'l'}

# 示例文本
text = "estimate, local, lows"

# 使用 BERT 的分词器(WordPiece)
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')

# 调用 tokenize 函数,并打印中间过程
tokens = tokenize(text)

输出

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
初始预分词结果:
['estimate', ',', 'local', ',', 'lows']

正在标记单词: estimate
[UNK] 标记: estimate
标记结果: ['[UNK]']

正在标记单词: ,
[UNK] 标记: ,
标记结果: ['[UNK]']

正在标记单词: local
匹配到 Token: lo
剩余部分添加前缀: ##cal
[UNK] 标记: ##cal
标记结果: ['[UNK]']

正在标记单词: ,
[UNK] 标记: ,
标记结果: ['[UNK]']

正在标记单词: lows
匹配到 Token: lo
剩余部分添加前缀: ##ws
匹配到 Token: ##w
剩余部分添加前缀: ##s
匹配到 Token: ##s
标记结果: ['lo', '##w', '##s']

最终标记结果:
['[UNK]', '[UNK]', '[UNK]', '[UNK]', 'lo', '##w', '##s']

分词(Transformers)

在 Transformers 中,分词(tokenization) 实际上包含以下几个步骤:

  1. 标准化(Normalization):对文本进行必要的清理操作,例如删除多余空格或重音符号、进行 Unicode 标准化等。
  2. 预分词(Pre-tokenization):将输入拆分为单词。
  3. 通过模型处理输入(Running the input through the model):使用预分词后的单词生成一系列词元(tokens)。
  4. 后处理(Post-processing):添加分词器的特殊标记,生成注意力掩码(attention mask)和词元类型 ID(token type IDs)。
    整体流程图,[图源](https://huggingface.co/learn/llm-course/en/chapter6/8)

运行代码:

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
from transformers import AutoTokenizer

# 加载 BERT 的分词器
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")

# 原始文本
text = "Hello how are U tday"
print("原始文本:", text)

# 1. 标准化:转换为小写
normalized_text = text.lower()
print("标准化后的文本:", normalized_text)

# 2. 预分词(Pre-tokenization):将输入拆分为单词
pre_tokenized = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(normalized_text)
print("预分词结果:", pre_tokenized)

# 3. 分词:将预分词后的结果转换为子词级词元
tokens = tokenizer.tokenize(normalized_text)
print("词元(Tokens):", tokens)

# 4. 将 tokens 转换为 token IDs
token_ids = tokenizer.convert_tokens_to_ids(tokens)
print("词元 ID(Token IDs):", token_ids)

# 5. 编码(包含特殊标记和后处理)
encoded = tokenizer(normalized_text, return_tensors="pt")
print("编码结果:", encoded)

# 6. 打印注意力掩码和词元类型 ID(后处理部分)
print("注意力掩码(Attention Mask):", encoded["attention_mask"])
print("词元类型 ID(Token Type IDs):", encoded["token_type_ids"])

# 7. 解码:将 token IDs 转换回文本
decoded_text = tokenizer.decode(token_ids)
print("解码后的文本:", decoded_text)

输出

1
2
3
4
5
6
7
8
9
原始文本: Hello how are U tday
标准化后的文本: hello how are u tday
预分词结果: [('hello', (0, 5)), ('how', (6, 9)), ('are', (10, 13)), ('u', (14, 15)), ('tday', (16, 20))]
词元(Tokens): ['hello', 'how', 'are', 'u', 'td', '##ay']
词元 ID(Token IDs): [7592, 2129, 2024, 1057, 14595, 4710]
编码结果: {'input_ids': tensor([[ 101, 7592, 2129, 2024, 1057, 14595, 4710, 102]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1]])}
注意力掩码(Attention Mask): tensor([[1, 1, 1, 1, 1, 1, 1, 1]])
词元类型 ID(Token Type IDs): tensor([[0, 0, 0, 0, 0, 0, 0, 0]])
解码后的文本: hello how are u tday
本文结束 感谢您的阅读