想象你面前躺着一座由乐高积木拼成的小房子。如果孩子想照着图纸搭一座新房子,他不会把整座房子原封不动搬过去,而是先把它拆成一块块能复用的积木。BERT 读英文时也是这个思路:它不把每个单词当成铁板一块,而是把单词拆成更小的“子词”积木。拆法的关键规则只有两条:第一,拆的时候要贪心——先试着拿最大的一块积木,塞得下就用,塞不下就换小一号;第二,每块“接尾积木”的侧面要盖一个小标记“##“,表示这块积木要接在前一块后面,不能单独站住。这个办法让 BERT 对从没见过的生词也能拼个大概,比如 unhappiness(不快乐)可以被拆成 un + ##happi + ##ness。接下来我们就一层层拆开这个”乐高房子”,看看 WordPiece 到底怎么工作。
理解 WordPiece 分词是搭建 NLP 模型的第一块地基。你会在处理文本、构建词表、评估数据质量时反复用到这套思维,它直接决定模型能读懂多“生僻”的输入。
目录
可视化演示
展示WordPiece分词的贪婪最长匹配过程(tokenization->token+##iz+##at+##ion)、与BPE的核心差异(似然值vs频率合并标准)、词汇表构建与未登录词OOV拆分机制,交互切换核心维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
贪婪最长匹配分词 从左到右扫描文本,在每个位置找词汇表中最长匹配子词 ##前缀标识子词为词的续接部分,非独立词开头 tokenization -> token + ##iz + ##at + ##ion greedy: argmax_len(subword in vocab) at each position 贪婪匹配+##前缀+子词续接
为什么 BERT 需要分词
回想那座乐高小房子:如果每个单词都必须用一块独一无二的大积木拼出来,那么世界上有多少单词,积木盒里就要准备多少块积木。英文词汇量动辄五十万甚至上百万,这会让词表变得无比庞大,模型的内存和训练时间都吃不消。更重要的是,一座从没见过的房子(生词)根本没有对应的积木可用,模型只能干瞪眼——这就是经典的“未登录词”问题。
WordPiece 的解决办法是把大积木换成小块:把单词拆成更小的子词,用有限的块数拼出无限的变化。比如 unhappiness 拆成 un、happi、ness 三块,其中每一块都能在其他单词里复用:un 出现在 unable、unfair 中,happi 出现在 happiness、unhappy 中。这样一来,词表从五十万缩到三万个 token 左右,词表大小只是原来的零头。分词也就成了 BERT 接收文本前的第一道工序:原始句子必须切成子词序列,才能变成模型能读的向量输入。
这个“拆分”的数学表达很简单:一个句子 S 就是 n 个子词依次拼接的结果。
所以,积木盒里不需要准备世界上所有房子的完整造型,只要准备一小盒常用的积木块,孩子就能拼出任何见到的房子。这就是子词分词能控制词表规模、又不怕生词的根本原因。
- S完整的一句输入文本,在送入模型前先要做切分。
- w_i切分得到的第 i 个子词,是词表里真正存在的 token。
- 加号代表子词之间的拼接关系,强调切分可逆、能还原原句。
- n子词的个数,整词切分时 n 等于原词数,子词化后 n 会变大。
##前缀:子词的身份标记
乐高积木要拼得稳,每块积木的卡扣位置是有讲究的:有的积木是“底座块”,可以稳稳站在地上;有的积木是“接尾块”,必须卡在前一块的侧面上。WordPiece 用 ## 前缀来区分这两种积木。词首的子词不带任何标记,比如 un 就是“底座块”;而后面接上的子词都带 ##,比如 ##happi、##ness,它们是“接尾块”。看到 ## 你就知道:这块积木不能单独摆在句首,必须接着前面那块拼。
这个约定虽然简单,却同时解决了两件事。第一,切分结果不会产生歧义:["un", "happi", “ness“] 和 [”unh”, "appi", “ness“] 是两种完全不同的切分方式,## 明确标出了每一块的”接尾“身份。第二,还原原词很容易:BERT 把 token 序列输出后,去掉所有 ## 再直接拼接,就能找回原来的单词。所以在词表存储时,”happi“和”##happi”是两个独立的词表项,虽然共享相同的字母,但身份完全不同。
用数学语言描述这个标记规则:第 0 个子词(词首)保持原样,其余子词都加上 ## 前缀。
有了 ## 这个“卡扣标记”,积木块的拼接顺序就永远不会乱。模型看到 ## 开头的 token,就知道它的语义要依附于前一个 token,从而把“不快乐”读成完整的一个概念,而不是两个孤零零的碎片。
- t_i最终写入词表和模型输入的 token,它是带或不带 ## 标记后的完整形式。
- w_i原始切分出来的子词字符串,还没有加任何标记。
- i=0表示这是整词的第一个子词,也就是“底座块”,保持原样不加标记。
- \text{\#\#}w_i非词首的子词都要加上两个井号,表示它是接在前一块后面的“接尾块”。
贪婪最长匹配算法
现在积木盒准备好了,孩子要开始拆房子了。他从左往右看单词,手里的规则只有一条:先拿最大的积木试。要切 unhappiness 时,他先看整个单词 unhappiness 在不在积木盒里——不在;缩短一点,unhappines——不在;再缩短,unhappi——不在;一路缩到 un——在!于是他切下第一块 un。接下来从 h 开始,重复同样的操作:happiness、happines、happine……最终在 happi 处命中(注意查找时对非词首要加 ## 前缀,所以实际查的是 ##happiness、##happines……直到 ##happi)。切下第二块 ##happi,最后剩下 ness 命中 ##ness。
这个策略叫“贪婪最长匹配优先”:每一步都选择“能匹配的最长子词”,而不是最短的,也不是随机长度的,并且永远从左往右单向扫描,扫描过的位置不会回头重新切。它保证切分过程是确定性的——同一个词配同一张词表,永远得到同一种切法,这对模型训练和推理非常重要。但贪婪也有代价:它只保证每一步局部最长,不保证整句的切分方案是某种全局意义下的“最优”。
把“选最长”写严格一点就是:在词表 V 中,从剩余文本 R 的所有前缀里,选长度最大的那个作为当前子词。
孩子拆乐高的手法也许不是数学家眼中的“最优拆法”—他可能看不出某块积木拆成两块能拼出更多花样—但他拆得快、拆得稳,每一刀都有明确依据。贪婪匹配就是这样一种工程上极其高效的近似方案,BERT 用了它,秒级之内就能切完一整句。
- t\*找出来的最优子词,也就是本步要切下的那块积木。
- \arg\max在候选集合里找出能让某个指标达到最大的那个元素。
- t \in \mathcal{V}候选必须来自词表 V,积木盒里没有的积木不能用。
- t \preceq R候选必须是剩余文本的前缀,只能从当前指针位置往右连续截取。
- |t|候选子词的长度,我们想让长度最大。
WordPiece 与 BPE 的区别
同样是“把大房子拆成小积木”,市面还有一种流行的拆法叫 BPE(Byte Pair Encoding,字节对编码)。两种方法都用贪心合并的思路从语料里长出一套子词词表,但它们的核心判据不同:BPE 看“哪两块积木经常粘在一起”,WordPiece 看“把哪两块粘起来能让语义预测更确定”。
BPE 的具体做法是从字母表开始,反复统计相邻字符对的出现频率,每次把出现频次最高的一对粘成新子词。比如 “t“ 和 ”h“ 总是紧挨着出现,粘成 ”th“ 后,词表里就多了一块”大号积木“。而 WordPiece 虽然也做类似的贪心合并,但它在决定”要不要把 a 和 b 粘起来”时,会问一个更聪明的问题:合并成 ab 之后,语言模型对文本整体的似然提升有多大?合并不只是因为它常见,还因为它能让模型更好地预测下一个词。
BPE 的合并选型可以简单地写成“选频率最高的相邻对”:
两种拆积木的策略,一个像“统计工人”一个像“语义选品师”。BPE 更简单粗暴、训练更快,在很多生成模型里用得很多;WordPiece 则更贴合 BERT 这种双向语言模型的训练目标,让切出来的子词更接近有意义的语义单元,这也是 BERT 选它而不是选 BPE 的重要原因。
- pair\*被选中合并的那个字符对,比如 (t, h)。
- \mathcal{P}当前词表下所有相邻对构成的集合,只有相邻出现的字符才会被考虑。
- \operatorname{count}(a,b)这对字符在语料里相邻出现的总次数,BPE 的合并依据。
- \arg\max挑出频次最高的那一对进行合并,然后重复整个过程。
WordPiece 词表训练流程
那盒积木到底是怎么造出来的?孩子不是凭空设计积木块的,而是观察了大量房子的图纸(语料),把经常一起出现的零件选出来变成标准积木。WordPiece 的训练也在做类似的事,它的完整流程分四步走。
第一步,准备初始词表:把语料里出现的所有单字符收进来,再加一些高频的完整单词,比如 "the"、"of" 这种常用词,直接以整词身份进词表。第二步,用当前词表把语料切成子词序列,统计所有相邻子词对的出现频率。第三步,对每个候选合并对,计算一个分数:合并成新子词后,相比不合并,语言模型的似然能提升多少。第四步,只保留分数最高的那一个合并,更新词表,然后回到第二步重新统计,循环往复,直到词表达到预设的大小(BERT 大约是 3 万)。
整个训练过程用一句话概括就是:语料负责提供候选,似然负责做裁判。每次合并的分数可以用合并前后概率的比值来衡量:
这就像是孩子的积木盒在一次次的拼搭中“自我进化”:每轮只增加一块最有用的新积木,其他候选统统排队等待。训练结束后,盒子里留下的每一块积木都经过语料的千锤百炼,既有细碎的单字母小块,也有像 un、ness 这样的常用“大块头”,恰好够 BERT 拼出绝大多数单词。
- score(a,b)候选合并对的得分,分数越高越优先合并。
- P(ab)把 a、b 合并成 ab 之后,这个新子词在语料中的出现概率。
- P(a), P(b)合并前,两个子词各自独立出现的概率。
- log取对数把比值转成可加的增益,方便比较不同候选对。
未登录词与稀有词处理
再坚固的积木盒也有装不下的零件。当模型遇到一个词表里完全没有的单词时,WordPiece 不会立刻放弃,而是会逐级降级:先把剩下的部分从最长子词一路缩到单个字符,如果词表覆盖了全部单字符(训练时通常都会保证这一点),那么任何词至少能切成一个个单字符 token 拼读出来。打个比方,newday 这个词如果词表里没有 new 也没有 day,它就会被切成 n、##e、##w、##d、##a、##y,虽然碎了一些,但模型还能“逐字母读出来”,不至于完全懵掉。
只有在极少数情况下——比如遇到根本无法拆分的特殊符号或者全角乱码——才会启用到最后的兜底 token:[UNK]。这个 token 相当于给一块实在没法归类的积木贴上了“未知件”标签。值得注意的是,[UNK] 的出现会带来信息损失,因为模型无法知道这块积木原来是长什么样的;所以好的预处理流程会尽量让 [UNK] 只出现在真正无法处理的噪声里,而不出现在常见单词上。
单字符降级和 [UNK] 兜底的关系可以用一个简单的规则表达:每个无法成块的字符 c,如果词表里有就单独成 token,否则标记为 [UNK]。
孩子拼房子时最怕缺件:缺一块大积木,他用几块小的也能凑合;连小积木都没有了,他才贴上“未知件”贴上去了事。WordPiece 用同样的层级设计,让 BERT 面对再偏僻的词也不会当场崩溃,而是尽可能拼出一点语义线索。
- g(c)单个字符 c 经过兜底规则后变成的最终 token。
- c \in \mathcal{V}字符已经在词表里,可以作为独立单字符 token 使用。
- [UNK]词表里没有这个字符,只能贴“未知件”标签交给模型。
- 分段第一分支是常态,第二分支是极端情况,体现“逐级降级”的设计思想。
WordPiece 对模型的实际影响
最后站远一点看,换用积木拼装而不是整词搬运,对 BERT 的整体效果到底有什么影响?最直接的好处是词表大幅缩小,从整词方案的五十万量级降到三万左右,模型 embedding 层的参数因此少了九成以上。其次是 OOV 问题被大幅缓解:绝大部分生词都能通过子词组合获得一个可学习的表示,而不是一律变成信息量为零的 [UNK]。
代价也同样真实:单词被切碎后,一个句子对应的 token 数量变多了,而 Transformer 的注意力计算量随序列长度平方增长,所以序列变长会带来明显的训练和推理开销。此外,WordPiece 对英文这类“空格分词 + 词缀丰富”的语言非常友好,对中文这种字与字之间没有空格的语言就不那么直接了,通常需要配合额外的中文分词或直接按字切分。因此在工程上,是否用 WordPiece、词表定多大,都是数据预处理阶段需要反复权衡的决策。
从模型的角度看,无论子词怎么切,训练目标依然是预测完整句子的概率,子词序列只是它的另一种写法:
那座乐高房子最终拼出来的模样,和图纸上的原设计是同一座房子,只是多了一些拼接缝。WordPiece 让 BERT 用有限的积木块搭出无限种表达,也让我们明白:分词从来不只是“切一切”的琐碎工作,它决定了模型以何种粒度理解语言,是数据质量评估中不可绕过的一环。
- P(S)整个句子的概率,是模型训练时要去最大化的目标。
- 连乘符号把每个子词的预测概率乘起来,表示整句由所有子词共同决定。
- P(w_i \mid w_{<i})看到前面所有子词后,第 i 个子词被正确预测的概率。
AI 应用场景
文本分类任务的预处理流水线
阶段:数据预处理与特征构建
解决的问题:原始文本是一串字符流,模型不认识字符串。传统做法用整词词表,遇到没见过的词就丢信息;用 WordPiece 可以把每个句子都转成词表内 token 序列,任何生词都能被拆成已知子词,保证输入零死角。
具体案例:在情感分类任务中,评论“unhappiness with the service“先被 WordPiece 切成 [”un”, "##happi", "##ness", "with", "the", “service“],再拼上 [CLS] 和 [SEP] 标签送入 BERT。即使评论里出现 ”unhappiness“ 这种训练语料里极少见的词,模型也能从 ”un“ 和 ”happi” 的组合中抓到负向情感线索,分类准确率比整词基线明显提升。
机器翻译中的稀有词处理
阶段:模型训练与推理输入
解决的问题:翻译模型最怕稀有词——德语里大量可拼接的复合词,在训练语料中可能只出现一两次,整词模型几乎学不到它们的表示。利用 WordPiece 的共享子词,复合词被拆成共同的小块,翻译时每个小块都能复用。
具体案例:英德翻译系统中,德语词 “Lebensversicherungsgesellschaft“(人寿保险公司)不在词表里,WordPiece 把它切成几个可理解的子词,与英语侧的 ”life insurance company” 在子词层面形成对齐,翻译质量显著优于直接给 [UNK] 的老系统。
领域微调时的新词兜底策略
阶段:模型微调与持续学习
解决的问题:医疗、法律等垂直领域有大量通用语料里见不到的专业词汇,直接用现成 BERT 会出现大量 [UNK]。理解 WordPiece 的降级逻辑后,团队可以决定是扩展词表做增量训练,还是接受单字符拆分的兜底表现,避免盲目加词表导致过拟合。
具体案例:某医疗文本处理项目中,"pneumonoultramicroscopicsilicovolcanoconiosis"(一种尘肺病)被切成一长串单字符子词,虽然碎但每个字符都有 embedding,模型仍能通过字符模式与上下文判断这是疾病实体,[UNK] 率从 8% 降到接近零。
Python 演示
class WordPieceTokenizer: def __init__(self, vocab): self.vocab = set(vocab) def _longest_match(self, text, start): end = len(text) while end > start: piece = text[start:end] if start > 0: piece = "##" + piece if piece in self.vocab: return end, piece end -= 1 # 极端兜底: 单字符降级 single = text[start] if start > 0: single = "##" + single return start + 1, single def encode(self, text): tokens = [] start = 0 while start < len(text): start, piece = self._longest_match(text, start) tokens.append(piece) return tokens def main(): vocab = [ "[UNK]", "un", "##happi", "##ness", "happy", "##birthday", "a", "b", "d", "e", "h", "i", "n", "p", "s", "w", "y", "##b", "##d", "##e", "##h", "##i", "##n", "##p", "##s", "##w", "##y" ] tokenizer = WordPieceTokenizer(vocab) print(tokenizer.encode("unhappiness")) print(tokenizer.encode("happybirthday")) print(tokenizer.encode("newday")) if __name__ == "__main__": main()
self.vocab = set(vocab)词表转成集合,让每次匹配查询都是 O(1) 的哈希查找。piece = text[start:end]从当前指针位置截取一段候选子串,end 从全句末尾逐渐缩短。if start > 0: piece = "##" + piece不是词首时,尝试的候选都要带上 ## 前缀去查词表。if piece in self.vocab: return end, piece命中词表立即返回,这就是“最长匹配优先”里的最长。end -= 1没命中就把候选长度减一,继续尝试更短的子串。single = text[start]所有候选都失败时,退回到单字符兜底,保证任何词都能切出来。while start < len(text)主循环从左往右推进指针,直到整句话切完。
常见误区
误区很多初学者看到 ##happi,以为 BERT 输入里真的包含两个井号字符,甚至疑惑“英文里怎么会有 #”。
正解## 是分词器内部使用的边界标记,只存在于 token 切分和词表查询过程中,并不会作为模型输入的真实字符。还原原词时直接删掉 ## 再拼接即可。这就像乐高积木侧面的卡扣标记只是拼装提示,不是积木本身的颜色或形状。
为何常见:因为可视化工具在展示 BERT 输入时常常直接打印带 ## 的 token,让人误以为它就是原始文本的一部分。
误区有读者看到 ##happi、##ness,就推断所有子词都带 ##,甚至去查"##un"为什么不存在。
正解## 只加在非词首子词上。整词的第一个子词永远是“底座块”,不带任何标记;只有它后面的接尾块才需要 ##。一条规则即可区分:一个词切出的 token 里,最多只有一个不带 ## 的子词,且它一定排在最前面。
为何常见:因为示例里常常用三块以上的子词展示,后面几块都带 ##,让人误以为这是普遍规律而忽略了词首那块的例外。
误区以为 BPE 和 WordPiece 只是名字不同,训练时都是“看哪个字符对出现得多就合并哪个”。
正解BPE 确实按相邻对的出现频次合并,但 WordPiece 按“合并后语言模型似然增益”来挑选,一个重统计频率,一个重预测收益。两者共享贪心合并的壳,内核完全不同。
为何常见:市面上很多科普文章把 BPE 讲成子词分词的代表,读者顺藤摸瓜就把 WordPiece 也归到同一类,忽略了它在训练目标上的关键改良。
误区认为“最长匹配优先”这么严谨的名字,切出来的肯定是语义上最合理、全局最正确的子词序列。
正解贪心算法每一步只看局部最长,不回退、不枚举整体方案,因此完全可能错过一种“每个子词都短一点但整体语义更连贯”的切法。比如 shes 在词表 {sh, she, es} 下会切成 she + ##s,而不是 sh + ##es,后者可能语义更好。贪心换来的是确定性和 O(L²) 以内的快速切分,工程上够用但绝非全局最优。
为何常见:日常语言里“最长”听起来像绝对最优,让人直觉地认为算法是全枚举后取极值,实际上它只是贪心地逐段取最大。
面试题
Q1. ##前缀在WordPiece中的作用是什么?
- A.表示这个子词出现在词首位置
- B.表示这个子词不是单词开头← 正确答案
- C.表示这个子词的发音规则
- D.表示这个子词需要大写处理
答案:B。## 是“接尾块”的标记,表示该子词必须接在前一个 token 后面,不能独立出现在词首。A 正好说反了,词首子词不带 ##;C 和 D 与发音、大小写毫无关系。想象乐高积木的卡扣标记,它只说明拼接位置,不改变积木本身。
Q2. 用词表 {un, ##happi, ##ness} 贪婪切分 unhappiness, 结果是什么?
- A.['un','##happi','##ness']← 正确答案
- B.['u','##nhappi','##ness']
- C.['unh','##appi','##ness']
- D.['un','##happines','##s']
答案:A。贪婪最长匹配从左往右:先试整个 unhappiness 不在词表,逐步缩短到 un 命中;再对剩余部分从 ##happiness 试到 ##happi 命中;最后 ##ness 命中。B 和 C 的首块不在词表,D 的 ##happines 也不在,只有 A 是每一步取最长可用积木的结果。
Q3. WordPiece和BPE选择合并候选时的核心区别是什么?
- A.BPE按字符对出现频率选候选← 正确答案
- B.WordPiece按出现顺序选候选
- C.BPE按词语语义相似度选择
- D.WordPiece按拼音读音选择
答案:A。BPE 的合并依据是相邻字符对的出现频次,WordPiece 则看合并后语言模型似然增益的大小。B 说反了,顺序不是判据;C 和 D 描述的语义相似度、拼音读音都不是这两种算法的合并标准。这就像“统计工人”和“语义选品师”挑选积木块的眼光不同。
Q4. 关于##前缀标识,下列说法正确的是?
- A.##只出现在非词首的子词上← 正确答案
- B.##是分词器内部使用的标记← 正确答案
- C.##会作为真实字符送进模型
- D.##标记了子词的词性信息
答案:AB。A 正确,词首子词不带 ##,只有后续接尾块才带;B 正确,它是分词器内部标识,不是原始文本的一部分。C 错误,## 在还原和输入编码时会被去掉,不会以真实字符进入模型;D 错误,## 只表示拼接位置,与词性无关。
Q5. WordPiece遇到词表中完全没有的子词时,通常如何处理?
- A.直接丢弃整个单词不处理
- B.降级到单字符或[UNK]兜底← 正确答案
- C.强行切分并随机分配词义
- D.向词表动态添加一个新词
答案:B。WordPiece 的兜底逻辑是逐级降级:先尝试更短的子词,缩到单个字符,最后才用 [UNK] 标记。A 丢词会损失信息,C 随机分配会破坏语义,D 动态加词在推理阶段不可行。就像缺大积木就用小积木凑,实在没有才贴“未知件”标签。
Q6. 贪婪最长匹配策略可能带来的问题有哪些?
- A.匹配结果不一定是全局最优← 正确答案
- B.每个候选都需查一次词表← 正确答案
- C.词表越大切分速度一定越慢
- D.只能处理英文不能处理中文
答案:AB。A 正确,贪心只看局部最长,不保证整体最优;B 正确,每个候选前缀都要做一次词表查询,候选数量影响耗时。C 错误,词表用哈希集合存储后,单次查询是 O(1),词表规模不会直接拖慢单次查询;D 错误,WordPiece 对中文按字切分同样可用。
Q7. WordPiece词表中的子词一般是如何被选取出来的?
- A.按似然提升最大原则贪心选← 正确答案
- B.按字母表顺序依次挑选
- C.由人工手工编写生成词表
- D.按句子出现位置随机选取
答案:A。WordPiece 的训练从字符词表出发,反复统计候选合并对,每次保留“合并后语言模型似然增益”最大的那一个,直到词表达到目标大小。B 的字母表顺序、C 的人工编写、D 的随机选取都与训练过程不符,词表是从语料里“长”出来的。
Q8. WordPiece切分完成后,怎样把token序列还原成原来的单词?
- A.直接拼接并去掉##前缀标记← 正确答案
- B.对每个子词重新排序再拼接
- C.用词表索引替换所有子词
- D.根据词性规则重新组词
答案:A。还原就是切分的逆操作:把带 ## 的 token 去掉标记后,按原顺序依次拼接。B 重新排序会打乱原文,C 用索引替换毫无意义,D 按词性组词引入了切分时不存在的信息。这就像按卡扣顺序把积木块原样拼回去,房子自然复原。
Q9. 为什么BERT选用WordPiece而不是整词词表?
- A.有效控制词表规模到三万左右← 正确答案
- B.子词共享提升稀有词表示能力← 正确答案
- C.完全消除所有未登录词问题
- D.模型输入长度会显著增长
答案:AB。A 正确,子词化把词表从五十万量级压到三万;B 正确,稀有词能复用常见子词的表示。C 错误,[UNK] 兜底仍存在,未登录词只是被大幅缓解而非完全消除;D 是子词化的副作用而非选它的原因,所以不选。
Q10. 句子经过WordPiece切分后,为什么语义链条没有被切断?
- A.子词拼接可还原原文且词序不变← 正确答案
- B.每个子词都独立携带完整词义
- C.WordPiece只切分标点不看单词
- D.切分后子词顺序被模型打乱
答案:A。WordPiece 保证切分可逆、子词按原词顺序排列,模型依然按从左到右的顺序建模整句的概率。B 错误,子词大多不携带完整词义,而是组合出语义;C 错误,它切的是单词内部;D 错误,模型不会打乱子词顺序。积木只是拆开,拼回去还是同一座房子。