一个词汇表可能会有多种方式生成:
-
BPE(字节对编码):BPE 是从最基础的字符(字节)开始,通过不断合并高频字节对来构建更大的子词单元。在这个过程中,最终形成的子词可能包含各种不同语言、不同类型的字符组合。例如,在处理多语言文本时,可能会出现中文、英文、阿拉伯文等多种字符混合的子词。这些子词的编码范围很广,远远超出了
Latin - 1
所能表示的 256 个字符范围。 -
WordPiece:WordPiece 基于最大似然估计选择子词,它倾向于保留具有语义信息的子词。这样生成的词汇表中可能包含很多常见的词缀、词根等,并且可能会根据语言的特点进行特殊处理。比如英文中的前后缀、中文的偏旁部首等,这些子词的编码也可能无法用
Latin - 1
表示。 -
Unigram Language Model(ULM):ULM 是基于概率统计来选择子词集合的,生成的子词更加灵活多样。它会根据语料库中字符和子词的出现概率进行优化,可能会产生一些特殊的子词组合,同样可能包含超出
Latin - 1
编码范围的字符。
以Qwen-72B为例,其词汇表可能包含:
-
约20% 的中文字符直接表示
-
30% 的跨语言BPE合并单元
-
15% 的ASCII组合
-
剩余为其他语言及符号的混合表示
一、字节对编码(Byte Pair Encoding)
1、对全部词按照字节编码拆分
-
ASCII 编码:ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是一种基于拉丁字母的字符编码标准,主要用于显示现代英语和其他西欧语言。在 ASCII 编码中,每个字符用 1 个字节(8 位)表示,范围是 0x00 - 0x7F,刚好可以表示 128 个不同的字符,包括英文字母(大写和小写)、数字、标点符号等。例如,字母 'A' 在 ASCII 编码中对应的十进制值是 65,十六进制表示就是 0x41;字母 'B' 对应的十进制值是 66,十六进制表示是 0x42。所以,在 ASCII 编码下,一个英文单词中的每个字母都对应一个单字节。
-
UTF - 8 编码:UTF - 8(8 - bit Unicode Transformation Format)是一种针对 Unicode 的可变长度字符编码,它可以使用 1 到 4 个字节来表示一个字符。对于英文字母和 ASCII 字符,UTF - 8 编码和 ASCII 编码是兼容的,即英文字母仍然用 1 个字节表示。例如,英文单词 "AB" 在 UTF - 8 编码下对应的字节序列(十六进制表示)就是 "41 42"。
-
UTF - 8 是一种变长编码,中文汉字通常用 3 个字节来表示。这是因为中文的字符集非常庞大,需要更多的位来表示。例如,汉字 “你” 在 UTF - 8 编码下对应的字节序列(十六进制表示)是 "E4 BD A0"。所以,一个中文词语或者句子在 UTF - 8 编码下会由多个 3 字节的组合构成。
2、统计词频合并频繁字节对
简单的英文语料库:["low", "lower", "newest", "widest"]
①统计它们的出现频率,得到初始词表:
l | o | w | e | r | n | s | t | i |
2 | 1 | 3 | 3 | 1 | 1 | 2 | 2 | 1 |
②统计字节对频率
lo | ow | we | er | ne | ew | ws | st | wi |
1 | 2 | 1 | 1 | 1 | 1 | 1 | 2 | 1 |
id | de |
1 | 1 |
③第一次合并
找出出现频率最高的字节对,这里是 ow
和 st
(频率都为 2,我们选择 ow
进行合并)。将 ow
合并成一个新的 “单词”,更新语料库和词表。更新后的语料库变为 ["low", "lower", "newest", "widest"]
(这里虽然语料库文本不变,但内部表示发生变化),词表更新为:
l | ow | e | r | n | s | t | i |
2 | 2 | 3 | 1 | 1 | 2 | 2 | 1 |
④不断重复直到词汇数量达到要求
在使用字节对编码(BPE)处理像 “我爱北京” 这样的中文文本时,若采用 UTF - 8 编码,一个汉字通常由 3 个字节表示,“我爱北京” 就会被编码为 12 个字节,后续的词频统计和字节对合并操作是针对这 12 个字节来进行的
在 UTF - 8 编码中,大多数中文字符用 3 个字节来表示。例如,对 “我爱北京” 进行 UTF - 8 编码后,会得到一个 12 字节的序列。假设编码后的十六进制字节序列为(实际值会根据具体字符编码确定):e6 88 91 e7 88 b1 e5 8c 97 e4 ba ac
。在 BPE 编码的初始阶段,会把这 12 个字节中的每个字节当作独立的 “单词”,同时统计每个字节在语料库中的出现频率。其余过程和上面一样
二、BPE词汇保存
a. vocab.json
-
作用:映射每个Token到唯一的索引。
-
格式:JSON字典,键为Token的字符串表示,值为对应索引。
-
Token表示:
-
单字节Token:使用Latin-1编码转换为Unicode字符。例如,字节
0xDE
表示为字符Þ
(Unicode U+00DE)。 -
多字节Token:按合并顺序的字节序列用Latin-1编码为字符串。例如,字节对
0xDE 0xAD
转换为字符串"Þ"
。 -
特殊Token:直接以字符串形式保存,如
"<|endoftext|>"
。
-
b.merges.txt
-
作用:记录BPE训练过程中学到的合并规则。
-
格式:每行按优先级排列一个合并操作,格式为
字节对1 字节对2
。例如:
a b ab c
表示先将a和b合并为ab,再将ab与c合并为abc。
实现细节
-
编码处理:所有Token均以Latin-1编码保存,确保字节到字符的无损转换。例如,
0x80
对应字符€
,在JSON中可能转义为\x80
。 -
特殊字符处理:不可见字符(如控制字符)在JSON中使用Unicode转义(如
\u00DE
)。 -
加载逻辑:分词器加载时,将
vocab.json
中的字符串按Latin-1编码还原为字节序列,并结合merges.txt
重建合并规则。
优势
-
兼容性:Latin-1编码确保所有字节都能表示为合法字符,避免编码冲突。
-
压缩性:通过合并高频字节对,显著减少词汇表大小,提升处理效率。
-
灵活性:支持多语言文本,无需担心未登录字符,因任何字符均可分解为字节处理。
三、 Latin-1(256个奇怪的字符集,内含如何恢复每个token)
Latin-1(ISO/IEC 8859-1)是一种单字节编码,每个字符仅占用一个字节(8位),因此其字符集总共有 256个字符(从0x00
到0xFF
)。它并非直接对多字节进行编码,而是通过以下机制实现对多字节序列的间接处理:
-
Latin-1的编码本质
-
单字节特性:每个字符严格对应一个字节(范围
0x00
到0xFF
),无法直接表示多字节字符(如中文)。 -
Unicode兼容性:Latin-1的前256个字符(U+0000到U+00FF)与Unicode一一对应。例如:
-
0x41
→ ASCII字符A
(U+0041) -
0xDE
→ 字符Þ
(U+00DE) -
0xFF
→ 字符ÿ
(U+00FF)
-
-
为何能“处理”多字节序列?
在Qwen等字节级BPE分词器中,多字节序列(如UTF-8)被拆解为单字节,再通过Latin-1编码转换为字符字符串。具体步骤:
-
拆解多字节:例如,中文“你”的UTF-8编码为
0xE4 0xBD 0xA0
,拆分为3个单字节。 -
Latin-1映射:每个字节独立转换为Latin-1字符:
-
0xE4
→ä
(U+00E4) -
0xBD
→½
(U+00BD) -
0xA0
→
-
-
组合字符串:合并为字符串
"ä½ "
,存入词汇表(如vocab.json
)。 -
为何总共有256个字符?
-
字节的数学上限:一个字节为8位二进制数,取值范围为
0x00
(00000000)到0xFF
(11111111),共2⁸=256种可能。 -
Latin-1的设计:它充分利用了单字节的所有256个值,无任何保留或未定义码位,每个值对应唯一字符。
-
多字节编码的间接表示
虽然Latin-1本身不支持多字节字符,但通过以下方式间接处理:
-
分块映射:将多字节序列(如UTF-8)拆分为单字节,每个字节独立映射到Latin-1字符。
-
无损可逆性:Latin-1编码可逆,
str.encode("latin-1")
和bytes.decode("latin-1")
能完全还原原始字节。
-
示例:中文“你”的处理
-
UTF-8编码 →
0xE4 0xBD 0xA0
-
Latin-1转换 → 字符序列
"ä½ "
-
为何选择Latin-1而非其他编码?
-
全覆盖性:Latin-1覆盖所有256个字节值,无遗漏。
-
无歧义:每个字节对应唯一字符,避免编码冲突。
-
兼容性:与Unicode无缝对接,支持跨语言处理。
-
特殊字符的显示问题
部分Latin-1字符为不可见或控制字符(如0x00
到0x1F
),在可视化时需特殊处理:
-
转义表示:如
0x00
显示为\x00
-
Unicode名称:如
0x03
显示为[END OF TEXT]
四、merges.txt
有什么用
核心作用:确保训练和推理时对任意文本(包括未见过的文本)按照完全相同的规则进行分词。
1、训练与推理的一致性原理
-
训练阶段:通过统计语料中的高频字节对,生成合并规则(
merges.txt
),并基于这些规则逐步合并字节,构建最终词汇表(vocab.json
)。 -
推理阶段:面对新文本时,严格按照
merges.txt
中记录的合并顺序和规则,将原始字节流拆分为与训练时一致的Token序列。
2、具体流程对比
(1) 训练阶段
-
输入文本 → UTF-8编码为字节流(如“你好” →
0xE4BD A0E5A5BD
)。 -
统计频率:统计所有相邻字节对的出现频率。
-
生成合并规则:
-
找到最高频的字节对(如
0xE4
和0xBD
),合并为新Token0xE4BD
,记录到merges.txt
。 -
重复合并,直到达到预设词汇表大小。
-
-
生成词汇表:将所有合并后的Token和基础字节存入
vocab.json
。
(2) 推理阶段
-
输入新文本 → UTF-8编码为字节流(如“你好吗” →
0xE4BD A0E5A5BD E59097
)。 -
应用合并规则:
-
按
merges.txt
中的顺序,从最后一行(最新合并规则)向第一行(最早合并规则)逆序尝试合并。 -
优先合并高频的字节对,生成与训练时一致的Token。
-
-
输出Token序列:即使新文本包含未在训练中出现的部分(如“吗”的字节
0xE59097
),仍按相同规则拆分。
3、关键示例
场景1:训练时学到的合并规则
-
训练数据:“你好”(UTF-8字节:
0xE4 0xBD 0xA0 0xE5 0xA5 0xBD
) -
生成的
merges.txt
:
E4 BD # 合并0xE4和0xBD → 0xE4BD E4BD A0 # 合并0xE4BD和0xA0 → 0xE4BDA0(对应“你”) E5 A5 # 合并0xE5和0xA5 → 0xE5A5 E5A5 BD # 合并0xE5A5和0xBD → 0xE5A5BD(对应“好”)
场景2:推理时处理新文本“你好吗”
-
新文本字节流:
0xE4 0xBD 0xA0 0xE5 0xA5 0xBD 0xE5 0x90 0x97
(“你好吗”) -
分词过程:
-
优先应用最新的合并规则:
-
检查是否有
E5A5 BD
→ 合并为0xE5A5BD
(“好”)。 -
检查是否有
E4BD A0
→ 合并为0xE4BDA0
(“你”)。
-
-
剩余字节处理:
-
0xE5 0x90 0x97
(“吗”)未在训练中出现,按基础规则拆分:-
检查是否有
E5 90
→ 若不在merges.txt
中,保留为单字节。 -
最终拆分为
0xE5
,0x90
,0x97
(对应3个Token)。
-
-
-
最终Token序列:
-
["你", "好", "吗"] → 实际存储为: ["ä½ ", "好", "é\x90\x97"](假设“吗”未合并)
4、为什么必须严格遵循 merges.txt
?
(1) 保证Token粒度的对齐
-
若训练时“你好”被合并为单个Token,而推理时未应用相同规则,拆分为多个单字节Token,会导致:
-
语义偏差:模型无法识别合并后的语义单元。
-
性能下降:输入表示与训练时不一致,模型预测失效。
-
(2) 处理未知文本的鲁棒性
-
动态拆分能力:即使面对未见过的新字节组合(如“吗”),仍按
merges.txt
的优先级尝试合并,若无法合并则保留为底层字节。 -
示例:
-
若新文本中出现高频字节对
0xE5 0x90
,但merges.txt
中未记录,则保留为两个单字节Token。 -
若未来扩展
merges.txt
加入此规则,则合并为新Token。
-
-
特殊情况的处理
(1) 跨语言混合文本
-
输入:中英文混合句子“Hello 世界”。
-
处理流程:
-
所有字符统一转为UTF-8字节流。
-
英文部分(如“Hello”)可能被拆分为单字母或子词(如
H e l l o
)。 -
中文部分“世界”按
merges.txt
合并规则处理。
-
(2) 控制字符或乱码
-
输入:包含无效UTF-8字节的乱码(如
0xE4 0x12
)。 -
处理:
-
按
merges.txt
规则尝试合并,若无匹配则保留为单字节Token。 -
解码时可能显示为乱码,但字节级处理保证无损。
-
五、恢复语义
每个模型在构建词汇表时,会依据特定的分词方法对大量语料进行处理,生成一个包含所有可能 token 的集合,同时为每个 token 分配唯一的编号。这个词汇表是模型进行文本处理的基础,不同模型的词汇表在 token 集合、编码方式等方面可能存在显著差异。
模型自带的分词器(tokenizer)是专门为该模型的分词方法和词汇表量身定制的工具。它精确掌握分词过程中所遵循的规则,包括子词的划分方式、合并顺序以及 token 与编号的映射关系。在将 token 解码还原为原始文本时,分词器能够依据这些规则,准确地将 token 重新组合成原始的文本形式。
若不使用模型自带的分词器,而采用如 Latin - 1 编码这样通用的编码方式进行还原,会面临诸多问题。首先,不同的分词方法和词汇表构建方式导致 token 的编码具有特异性,通用编码方式无法理解这些特定规则,从而难以正确还原 token。其次,在将文本转换为 token 的过程中,不仅要完成字符的拆分,还需要保留文本的语义信息,以确保解码后的文本与原始文本在含义上一致。模型自带的分词器在设计时充分考虑了语义信息的保留,能够结合上下文对多义词、语义关系等进行准确处理。而通用编码方式仅关注字符的编码转换,无法处理这些复杂的语义信息。
import json
from transformers import AutoTokenizer
def read_vocab_file(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
vocab = json.load(f)
return vocab
def decode_all_tokens(vocab, tokenizer):
decoded_vocab = {}
for token, token_id in vocab.items():
try:
ids = [token_id]
decoded_token = tokenizer.decode(ids)
decoded_vocab[token] = decoded_token
except Exception as e:
print(f"解码 token '{token}' 时出错: {e}")
return decoded_vocab
def save_decoded_vocab_to_json(decoded_vocab, output_file_path):
with open(output_file_path, 'w', encoding='utf-8') as f:
json.dump(decoded_vocab, f, ensure_ascii=False, indent=4)
def main():
model_dir = "../qwen0.5"
vocab_file_path = f"{model_dir}/vocab.json"
tokenizer = AutoTokenizer.from_pretrained(model_dir)
vocab = read_vocab_file(vocab_file_path)
decoded_vocab = decode_all_tokens(vocab, tokenizer)
output_file_path = "decoded_vocab.json"
save_decoded_vocab_to_json(decoded_vocab, output_file_path)
print(f"解码结果已保存到 {output_file_path}")
if __name__ == "__main__":
main()