从零构建大语言模型特殊 Token 与 BPE 字节对编码 — 让分词器处理任何未知词(五)

张开发
2026/4/19 7:29:40 15 分钟阅读

分享文章

从零构建大语言模型特殊 Token 与 BPE 字节对编码 — 让分词器处理任何未知词(五)
本篇导读上一篇我们实现了一个能编码 / 解码训练文本的分词器但它有个致命缺陷——遇到训练集里没出现过的词就会直接报错。这就像一个只认得 1,159 个字的人第 1,160 个字对他来说就是一片空白。现实世界中新词层出不穷人名、地名、品牌名新造词、俚语、网络热词专业术语、外语词汇拼写变体、打字错误本篇将介绍两种彻底解决未知词问题的思路方案一特殊 Token— 用占位符|unk|标记所有未知词简单但粗糙方案二BPE 字节对编码— 把未知词拆分成已知子词GPT 系列使用的方案同时还会引入另一个重要的特殊 Token|endoftext|用于标记文档边界。1、特殊 Token — 用占位符处理未知词思路给分词器一个兜底选项既然未知词是避免不了的那就干脆给它一个统一的占位符。我们在词汇表里增加两个特殊 Token特殊 Token用途unkendoftext图 1特殊 Token 方案和 BPE 子词方案的对比。前者简单但丢失信息后者能完美还原任何词。为什么需要|endoftext|训练 LLM 时我们通常会把大量独立文档拼接成一个长序列来喂给模型。但这里有个问题如果不加分隔模型会误以为第二篇文档是第一篇的延续从而学到错误的上下文关联。图 2训练时用|endoftext|拼接两个不相关的文档。这个特殊 Token 明确告诉模型边界在这里前后内容没有关联。代码实现扩展词汇表在上一篇的preprocessed列表基础上我们加入两个特殊 Tokenall_tokenssorted(list(set(preprocessed)))all_tokens.extend([|endoftext|,|unk|])vocab{token:integerforinteger,tokeninenumerate(all_tokens)}print(len(vocab.items()))# 1161原来 1159 新增 2 个查看词汇表的最后 5 个条目fori,iteminenumerate(list(vocab.items())[-5:]):print(item)输出(younger, 1156) (your, 1157) (yourself, 1158) (|endoftext|, 1159) (|unk|, 1160)两个新 Token 已经成功加入。升级版分词器SimpleTokenizerV2改造encode方法——遇到词汇表里没有的词就替换为|unk|classSimpleTokenizerV2:def__init__(self,vocab):self.str_to_intvocab self.int_to_str{i:sfors,iinvocab.items()}defencode(self,text):preprocessedre.split(r([,.?_!()\]|--|\s),text)preprocessed[item.strip()foriteminpreprocessedifitem.strip()]# 关键改动未知词替换为 |unk|preprocessed[itemifiteminself.str_to_intelse|unk|foriteminpreprocessed]ids[self.str_to_int[s]forsinpreprocessed]returnidsdefdecode(self,ids):text .join([self.int_to_str[i]foriinids])textre.sub(r\s([,.?!()\]),r\1,text)returntext实测用两段独立的文本拼接起来中间用|endoftext|分隔text1Hello, do you like tea?text2In the sunlit terraces of the palace.text |endoftext| .join((text1,text2))print(text)# Hello, do you like tea? |endoftext| In the sunlit terraces of the palace.编码tokenizerSimpleTokenizerV2(vocab)print(tokenizer.encode(text))# [1160, 5, 362, 1155, 642, 1000, 10, 1159, 57, 1013, 981, 1009, 738, 1013, 1160, 7]注意观察1160出现了两次——分别对应 “Hello” 和 “palace”训练集里没有的词1159出现一次——对应|endoftext|分隔符解码回去print(tokenizer.decode(tokenizer.encode(text)))# |unk|, do you like tea? |endoftext| In the sunlit terraces of the |unk|.问题出现了原文是Hello和palace解码后都变成了|unk|。原始信息彻底丢失了。这就是特殊 Token 方案的致命缺陷所有未知词都被压缩成同一个占位符模型无法区分 “Hello” 和 “palace”更学不到它们的语义。其他常见的特殊 Token不同的 LLM 使用不同的特殊 Token 体系Token含义[BOS]序列开始beginning of sequence[EOS]序列结束end of sequence类似 [PAD]填充 Token用于把不同长度的输入对齐到同一长度GPT 系列的做法只使用|endoftext|一个 Token同时充当序列边界和填充标记。至于未知词GPT 根本不用|unk|——它采用了下面要讲的 BPE 方案。2、BPE 字节对编码 — 从根本上消除未知词核心思想BPEByte Pair Encoding的思路非常巧妙与其给未知词打标签不如把它拆成已知的零件。就像乐高积木——你可能没见过宇宙飞船这个完整模型但只要你认得单个砖块就能拼出任何东西。对于someunknownPlace这样的陌生词BPE 会拆分成some | un | known | Place每个子词都在词汇表里所以能被正确编码也能被完美还原。BPE 的构建算法BPE 词汇表是通过一个迭代合并算法自动构建的。过程如下初始化把每个词按字符拆开词汇表就是所有单字符。迭代步骤统计所有相邻字符对的出现频率找到频率最高的那一对把它们合并成一个新的子词加入词汇表回到步骤 1直到达到预设的词汇表大小图 3BPE 合并过程的完整推演。从单字符开始每轮合并最频繁的相邻对直到构建出子词词汇表。频率统计的公式对于语料C { w 1 , w 2 , . . . , w n } C \{w_1, w_2, ..., w_n\}C{w1​,w2​,...,wn​}其中每个词w i w_iwi​有出现次数c i c_ici​则字符对( a , b ) (a, b)(a,b)的频率为freq ( a , b ) ∑ i 1 n c i ⋅ 1 [ ( a , b ) ∈ pairs ( w i ) ] \text{freq}(a, b) \sum_{i1}^{n} c_i \cdot \mathbb{1}[(a, b) \in \text{pairs}(w_i)]freq(a,b)i1∑n​ci​⋅1[(a,b)∈pairs(wi​)]其中1 [ ⋅ ] \mathbb{1}[\cdot]1[⋅]是指示函数括号里条件成立为 1否则为 0pairs ( w i ) \text{pairs}(w_i)pairs(wi​)是词w i w_iwi​中所有相邻字符对的集合。具体推演一轮合并假设语料里有四个词及其出现次数low ×5 lower ×2 newest ×6 widest ×3初始拆分每个词拆成字符 结束标记·l o w · 重复 5 次 l o w e r · 重复 2 次 n e w e s t · 重复 6 次 w i d e s t · 重复 3 次统计关键字符对的频率字符对频率计算频率(e, s)出现在newest和widest里 → 6 39(s, t)同样在newest和widest里 → 6 39(l, o)在low和lower里 → 5 27(o, w)在low和lower里 → 5 27(w, e)只在newest里 → 66(n, e)只在newest里 → 66最频繁的是 (e, s)频率 9。合并合并后l o w · l o w e r · n e w [es] t · w i d [es] t ·词汇表新增子词es。下一轮现在(es, t)的频率变成 9在newest和widest里成为最频繁——合并为est。再下一轮(l, o)会被合并为lo……经过几千到几万轮迭代后就得到了一个完整的 BPE 词汇表。 动手体验交互式 BPE 推演为了让这个过程更直观我做了一个交互式动画。你可以点击下一步合并按钮一步步观察当前的 Token 切分状态所有相邻字符对的频率最频繁的那一对高亮被合并词汇表逐步扩大 点击打开 BPE 交互式推演打开后你会看到四个区域训练语料一个迷你的示例语料四个词及其频率当前 Token 切分每个词被切成哪些 Token字符对频率实时统计频率最高的那个会被高亮词汇表已经进入词汇表的子词新加入的会被高亮每按一次下一步合并你就完整地走过一轮算法。建议至少点 10 次感受一下。用 tiktoken 库实战实际使用中我们不用自己实现 BPE——OpenAI 开源了tiktoken库底层用 Rust 写的非常高效。安装pipinstalltiktoken加载 GPT-2 使用的 BPE 分词器importtiktoken tokenizertiktoken.get_encoding(gpt2)测试一段混合了未知词和特殊 Token 的文本textHello, do you like tea? |endoftext| In the sunlit terraces of someunknownPlace.integerstokenizer.encode(text,allowed_special{|endoftext|})print(integers)# [15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]几个关键观察|endoftext|的 ID 是 50256——这是 GPT-2 词汇表的倒数第一个位置词汇表总大小是 50,257ID 从 0 到 50256someunknownPlace这个完全虚构的词被正确编码了——没有报错解码验证print(tokenizer.decode(integers))# Hello, do you like tea? |endoftext| In the sunlit terraces of someunknownPlace.完美还原包括那个虚构的词。为什么 GPT 选择 BPE对比两种方案| 维度 | 特殊 Token (|unk|) | BPE 子词拆分 ||—|—|—|| 未知词处理 | 压缩成一个统一标记 | 拆分成已知子词 || 信息保留 | ❌ 完全丢失原词 | ✓ 完美还原 || 语义学习 | ❌ 所有未知词看起来一样 | ✓ 子词携带语义前缀/后缀/词根 || 词汇表大小 | 受训练数据限制 | 可灵活控制GPT-2 是 50,257 || 跨语言支持 | 差 | 好字符级兜底 |GPT 选择 BPE 的根本原因它在词级精度和字符级覆盖之间找到了最佳平衡点。高频词如the、and保持完整低频复合词被拆成有意义的子词unknownPlace极端罕见的字符串被拆到单字符级别这样模型既不会被数不清的长尾词淹没又能处理任何输入。一个有趣的练习试试用 BPE 编码一个完全随机的字符串tokenstokenizer.encode(Akwirw ier)print(tokens)# [32, 74, 86, 343, 86, 220, 959]# 查看每个 Token 对应什么fortintokens:print(t,→,tokenizer.decode([t]))# 32 → A# 74 → k# 86 → w# 343 → ir# 86 → w# 220 → (空格)# 959 → ier# 重新解码整体print(tokenizer.decode(tokens))# Akwirw ier可以看到 BPE 把这个外星词拆成了 7 个子词有的是单字符A、k、w有的是子词ir、ier然后完美还原。本篇小结概念要点未知词问题固定词汇表总会遇到训练集外的新词需要兜底机制特殊 Token 方案用 endoftextBPE 核心思想把未知词拆分成已知子词乐高积木式BPE 构建算法迭代合并最频繁的相邻字符对GPT-2 词汇表50,257 个 Token最后一个是 BPE 的优势完美还原 子词携带语义 跨语言友好tiktokenOpenAI 开源的高效 BPE 实现3、关键公式回顾字符对频率freq ( a , b ) ∑ i 1 n c i ⋅ 1 [ ( a , b ) ∈ pairs ( w i ) ] \text{freq}(a, b) \sum_{i1}^{n} c_i \cdot \mathbb{1}[(a, b) \in \text{pairs}(w_i)]freq(a,b)i1∑n​ci​⋅1[(a,b)∈pairs(wi​)]BPE 每一轮的更新规则vocab t 1 vocab t ∪ { arg ⁡ max ⁡ ( a , b ) freq ( a , b ) } \text{vocab}_{t1} \text{vocab}_t \cup \{ \arg\max_{(a,b)} \text{freq}(a, b) \}vocabt1​vocabt​∪{arg(a,b)max​freq(a,b)}即每一轮把频率最高的那对字符合并加入词汇表。4、预习思考如果一个词被 BPE 拆成了 5 个子词那么这个词在嵌入层里会对应几个向量这对模型有什么影响BPE 的频率阈值决定了词汇表大小。如果阈值设得很低允许更多合并词汇表会变大还是变小每个词的 Token 数会变多还是变少中文和英文的分词差异很大——中文的字本身就是最小单位。BPE 在中文上会怎么表现

更多文章