查看全课大纲(第 58 / 69 节)
  1. 1.NLP 的研究领域、应用与学习路径
  2. 2.自然语言的基本术语
  3. 3.信息论基础:信息熵与交叉熵
  4. 4.语言的进化与语义变迁
  5. 5.主观概率、客观概率与条件概率
  6. 6.贝叶斯原理与推理
  7. 7.随机变量:二项式、期望方差与联合概率
  8. 8.常见概率分布
  9. 9.辛普森悖论与贝叶斯解题实例
  10. 10.语言模型:语言概率与词袋模型
  11. 11.n-gram 语言模型(二元 / 三元 / N-Gram)
  12. 12.语言模型评价:交叉熵、困惑度与插值
  13. 13.朴素贝叶斯文本分类:垃圾邮件
  14. 14.拼写检查
  15. 15.中文分词:构词法、最大匹配、最优路径与 Jieba
  16. 16.词表示问题与语义相似度
  17. 17.TF-IDF 权重处理
  18. 18.One-Hot 表达与词表示的局限
  19. 19.Word2Vec 核心思想
  20. 20.Word2Vec 训练过程
  21. 21.Skip-gram 模型详解
  22. 22.词性标注任务与标注方法(规则 + N-Gram)
  23. 23.EM 算法与混合模型
  24. 24.高斯混合模型 GMM
  25. 25.从混合模型到隐马尔可夫模型
  26. 26.隐马尔可夫模型(HMM)推导与维特比解码
  27. 27.线性链条件随机场 CRF
  28. 28.贝叶斯网络与图模型
  29. 29.隐马尔可夫链与 HMM 推导
  30. 30.PLSA 主题模型
  31. 31.LSI 与 SVD 向量表达
  32. 32.LDA 与狄利克雷分布
  33. 33.主题模型实验复现(困惑度 / 分类 / 相似度 / 延展)
  34. 34.上下文无关文法(CFG)与句法分析
  35. 35.概率上下文无关文法 PCFG
  36. 36.统计机器翻译:词对齐与翻译模型
  37. 37.统计机器翻译中的对齐模型与参数估计
  38. 38.从统计机器翻译到神经机器翻译
  39. 39.神经元与反向传播速览(指向深度学习课)
  40. 40.CNN 原理及其在 NLP 中的应用
  41. 41.循环神经网络的基本原理与 Elman / Jordan 网络
  42. 42.LSTM:长短期记忆网络的核心思想与分步实现
  43. 43.Encoder-Decoder 框架
  44. 44.Seq2Seq 与早期注意力机制
  45. 45.注意力机制的背景与动机
  46. 46.注意力模型实现原理与匹配函数
  47. 47.记忆网络:组成与计算
  48. 48.注意力机制的应用与历史延展
  49. 49.Transformer 结构:Encoder-Decoder 栈与 Self-Attention
  50. 50.Multi-Head Attention、FFNN 与位置编码
  51. 51.层归一化、损失函数与 Universal Transformer 延展
  52. 52.预训练范式:从 ELMo 到 BERT
  53. 53.BERT 与掩码语言模型
  54. 54.BERT 下游任务微调与 GLUE 口径
  55. 55.GPT 与自回归语言建模
  56. 56.GPT-1 到 GPT-4 的演进
  57. 57.指令微调与 RLHF
  58. 58.分词器:BPE、WordPiece 与中文词表
  59. 59.上下文窗口与长文本处理
  60. 60.能力边界、成本与适用场景
  61. 61.文本分类实战:从朴素贝叶斯到逻辑回归
  62. 62.命名实体识别实战
  63. 63.文本生成与摘要实战
  64. 64.语义检索与向量库实战
  65. 65.RAG 知识库问答
  66. 66.NLP 评估指标:BLEU / ROUGE / BERTScore 与人工评估
  67. 67.幻觉与事实性问题
  68. 68.文本与知识结合(知识图谱压缩)
  69. 69.跨媒体信息检索与未来展望

分词器:BPE、WordPiece 与中文词表

小象实战讲义 · 自然语言处理

在构建自然语言处理模型时,如何将连续的文本切分成离散的、可处理的单元,是第一步也是至关重要的一步。从早期的按词切分,到如今主流的子词(Subword)方法,分词器(Tokenizer)的设计直接影响着模型的词汇覆盖、序列长度和最终的泛化能力。本节将深入剖析现代 NLP 中三种核心的子词分词算法——BPE、WordPiece 和 Unigram LM,并探讨中文场景下的特殊考量,为你构建或选择合适的分词器提供清晰的指导。

💡 核心导读

  • BPE(Byte Pair Encoding):一种自底向上、基于频次合并的子词构建算法,是众多现代分词器的基础。
  • WordPiece 与 Unigram LM:与 BPE 目标相似但训练策略不同的子词算法,分别基于似然增益和语言模型概率。
  • 字节级 BPE:将文本编码为字节序列后再应用 BPE,实现真正的“零未登录词”,是 GPT 系列等模型采用的关键工程实践。
  • 中文分词的特殊性:解析为何不能简单照搬英文的“按词”直觉,以及现代实践中如何让模型自动学习切分单位。
  • 评估与实践:了解评估分词器的关键指标,并掌握在实践中如何根据自身语料进行选择和测试。

从字符到子词:BPE 算法详解

在神经机器翻译兴起之初,一个棘手的问题是稀有词(Rare Words)或未登录词(Out-Of-Vocabulary, OOV)的处理。直接将所有稀有词映射为统一的 <UNK> 标记会丢失大量信息。2016 年,Rico Sennrich 等人在论文 Neural Machine Translation of Rare Words with Subword Units(ACL 2016,arXiv:1508.07909)中,将一种数据压缩算法——字节对编码(Byte Pair Encoding, BPE)创造性地应用于 NLP,通过子词单元有效缓解了 OOV 问题。

BPE 的核心思想是:从所有单个字符(或字节)开始,不断合并语料中出现频次最高的相邻符号对,直到达到预定的词表大小。这样,高频的字符序列(如“ing”、“ed”)会被合并成子词,而低频词则会被切分成更小的单元(如字符),从而在有限的词表内实现对任意单词的编码。

根据论文 §3.2 的描述,BPE 算法步骤如下:

  1. 初始化词表:将语料中每个单词拆分为字符序列,并在末尾添加一个特殊的词尾标记(如 </w>)以区分单词边界。所有不同的单字符构成初始词表。
  2. 统计与合并:统计所有相邻符号对在语料中的出现频次。选择频次最高的一对,将它们合并成一个新的符号,并加入到词表中。
  3. 重复合并:重复第 2 步,每次合并都将词表大小增加 1。持续进行 kk 次合并,其中 kk 是一个超参数,它决定了最终词表大小 = 初始词表大小 + kk。
  4. 编码新文本:对新文本进行分词时,按照训练时学到的合并规则(顺序至关重要),依次尝试应用这些合并操作,直到无法再进行任何合并为止。通常采用贪心的最长匹配策略。

BPE 的关键性质在于其确定性:对于同一份语料和相同的合并次数 kk,只要实现细节一致(如是否区分大小写、如何处理标点),得到的合并规则和最终词表是完全相同的。下面我们通过一个极简的例子来直观感受这个过程。

# BPE 合并过程演示(极简版)
corpus = "low lower lowest new newer newest"  # 模拟语料,已小写化并用空格分词
words = corpus.split()

# 初始化:将每个词拆分为字符,并在末尾添加词尾标记 `</w>`
vocab = {}
for w in words:
    chars = list(w) + ['</w>']
    # 用空格连接字符,作为词表项的键
    vocab[' '.join(chars)] = vocab.get(' '.join(chars), 0) + 1

print("初始状态(字符序列及频次):")
for item, freq in sorted(vocab.items()):
    print(f"  {item}: {freq}")

# 定义合并函数
def get_stats(vocab):
    """统计相邻符号对的频次"""
    pairs = {}
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pair = (symbols[i], symbols[i+1])
            pairs[pair] = pairs.get(pair, 0) + freq
    return pairs

def merge_vocab(pair, vocab_in):
    """将指定的符号对合并,更新词表"""
    vocab_out = {}
    bigram = ' '.join(pair)
    replacement = ''.join(pair)
    for word in vocab_in:
        # 替换所有出现的该相邻对
        w_out = word.replace(bigram, replacement)
        vocab_out[w_out] = vocab_in[word]
    return vocab_out

# 执行3轮合并
num_merges = 3
for i in range(num_merges):
    pairs = get_stats(vocab)
    if not pairs:
        break
    best_pair = max(pairs, key=pairs.get)
    best_freq = pairs[best_pair]
    vocab = merge_vocab(best_pair, vocab)
    print(f"\n第 {i+1} 轮合并: 合并符号对 {best_pair} (频次: {best_freq})")
    print("当前词表示例:", sorted(vocab.keys())[:5])  # 只打印前5个示例
初始状态(字符序列及频次):
  l o w </w>: 1
  l o w e r </w>: 1
  l o w e s t </w>: 1
  n e w </w>: 1
  n e w e r </w>: 1
  n e w e s t </w>: 1

第 1 轮合并: 合并符号对 ('w', 'e') (频次: 4)
当前词表示例: ['l o w </w>', 'l o we r </w>', 'l o we s t </w>', 'n e w </w>', 'n e we r </w>']

第 2 轮合并: 合并符号对 ('l', 'o') (频次: 3)
当前词表示例: ['lo w </w>', 'lo we r </w>', 'lo we s t </w>', 'n e w </w>', 'n e we r </w>']

第 3 轮合并: 合并符号对 ('n', 'e') (频次: 3)
当前词表示例: ['lo w </w>', 'lo we r </w>', 'lo we s t </w>', 'ne w </w>', 'ne we r </w>']

可以看到,算法先合并了高频的相邻字符对:第 1 轮把 w 与 e 并成 we(频次 4),第 2 轮把 l 与 o 并成 lo(频次 3),第 3 轮把 n 与 e 并成 ne(频次 3)。三轮下来 low、new 的前半段已经被并成子词单元——合并顺序是按频次排出来的,不是我们指定的;er、est 这类只在两个词里出现的片段要往后排才会被合并。

WordPiece 与 Unigram LM:另辟蹊径的子词算法

BPE 基于频次进行合并,直观且有效。但研究人员很快提出了其他优化目标的分词算法,其中最具代表性的是 WordPiece 和 基于 Unigram 语言模型的分词。

WordPiece 算法最初在论文 Japanese and Korean voice search(Schuster & Nakajima, ICASSP 2012)中作为日韩语语音搜索的一种切分策略被简要提及。其训练过程与 BPE 类似,也是从字符开始自底向上合并。但关键区别在于选择合并哪一对的准则:

  • BPE:选择频次最高的相邻符号对。
  • WordPiece:选择合并后能最大程度提升语言模型似然的相邻符号对。这是一种通用表述,其具体实现通常是计算合并前后序列的似然值之差,选择能使似然增益最大的一对进行合并。

Unigram 语言模型分词(Unigram LM)则采用了完全不同的自顶向下思路:

  1. 首先,从一个非常大的种子词表开始(例如包含所有常见子词和字符)。
  2. 训练一个 Unigram 语言模型,为词表中的每个子词赋予一个概率。
  3. 迭代地移除那些对总体似然贡献最小的子词(例如通过期望最大化算法),直到词表缩小到目标大小。
  4. 推理时,使用维特比(Viterbi)算法找到使句子概率最大的切分方式。

为了更清晰地对比,我们将三者的核心差异总结如下:

特性BPEWordPieceUnigram LM
起点字符(或字节)字符(或字节)一个很大的候选子词表
训练方向自底向上(合并)自底向上(合并)自顶向下(剪枝)
每步决策依据合并频次最高的相邻对合并使语言模型似然提升最大的相邻对移除概率最低、对总体似然贡献最小的子词
停止条件达到目标词表规模(合并 k 次)达到目标词表规模达到目标词表规模
推理切分贪心最长匹配(按合并顺序应用)贪心最长匹配维特比算法(寻找最大概率切分)

需要强调的是,三者的主要差异体现在训练阶段如何构建词表。在推理(编码新文本)阶段,BPE 和 WordPiece 通常都采用相同的“最长匹配优先”的贪心策略,而 Unigram LM 则可以利用动态规划求得全局最优切分。

工程实践:字节级 BPE 与中文场景

字节级 BPE:真正的零 OOV

标准的 BPE 以字符为单位,但当遇到字符表以外的生僻字、Emoji 或多语言混合文本时,依然会出现 OOV。一个彻底的解决方案是 字节级 BPE(Byte-level BPE)。

  1. 将输入文本通过 UTF-8 编码转换为字节序列(每个字节取值 0-255)。
  2. 以这 256 个字节值作为初始词表,在此基础上运行 BPE 算法。
  3. 任何文本都可以被无损地转换为字节序列,因此理论上未登录词率降为 0。

代价是序列长度会增加(例如,一个 UTF-8 编码的中文字符通常占 3 个字节,可能被切分为 1 到 3 个子词)。GPT-2 及其后续模型广泛采用了这一策略。

中文分词的现代视角

中文分词长期以来是一个独立的研究课题,因为中文书写时没有空格分隔词。传统方法依赖词典和统计模型(如隐马尔可夫模型、条件随机场)进行切分,但总会面临切分歧义问题(如“乒乓球拍卖完了”)。

现代基于子词的方法为中文处理提供了新思路:

  • 按字切分:将每个汉字作为独立 token。优点是完全无 OOV(前提是字表完备),词表可以很小,但序列长、且丢掉了词内字的紧密关联。
  • 按词切分:使用传统分词器。优点是序列短,语义单元清晰,但严重依赖分词器质量,且不同分词器的差异会导致下游模型效果不稳定。
  • 子词(BPE)切分:从单字开始,让 BPE 算法根据语料频次自动合并出高频的词语或词片段。这避免了预先定义“词”的歧义,让模型在数据驱动下学习合适的表示单位。例如,“自然语言处理”可能被切分为 ["自然", "语言", "处理"] 或 ["自然", "语言处理"],具体取决于其在训练语料中的共现频率。

现代主流实践倾向于第三种方案:不预先套用一套中文分词词典,而是让模型通过子词分词器(尤其是字节级 BPE)从语料里学切分单位。好处是中英文走同一套流程、少一层人为歧义;代价是切分结果依赖语料,换领域就得重新看压缩率与未登录率。

下面就真跑一次中文 BPE:四句小语料,按”字”起步,跑 12 次合并,再拿一句没参与训练的话对比两种切法的 token 数。

corpus = [
    "自然语言处理是人工智能的重要分支",
    "自然语言处理需要语言模型",
    "语言模型可以处理自然语言文本",
    "人工智能与自然语言处理关系密切",
]

# 中文没有空格:把每句当成一个"词",汉字当成符号
vocab = {}
for sent in corpus:
    key = " ".join(list(sent) + ["</w>"])
    vocab[key] = vocab.get(key, 0) + 1

def get_stats(v):
    pairs = {}
    for w, f in v.items():
        s = w.split()
        for i in range(len(s) - 1):
            pairs[(s[i], s[i + 1])] = pairs.get((s[i], s[i + 1]), 0) + f
    return pairs

def merge_pair(pair, v):
    bigram, repl = " ".join(pair), "".join(pair)
    return {w.replace(bigram, repl): f for w, f in v.items()}

merges = []
for step in range(12):
    p = get_stats(vocab)
    if not p:
        break
    best = max(p, key=p.get)
    merges.append((best, p[best]))
    vocab = merge_pair(best, vocab)

print("学到的合并顺序(前 12 步):")
for i, (pair, freq) in enumerate(merges, 1):
    print(f"  {i:>2}. {''.join(pair):<6} 频次 {freq}")

def encode(text, merges):
    syms = list(text) + ["</w>"]
    for pair, _ in merges:
        a, b = pair
        out, i = [], 0
        while i < len(syms):
            if i < len(syms) - 1 and syms[i] == a and syms[i + 1] == b:
                out.append(a + b)
                i += 2
            else:
                out.append(syms[i])
                i += 1
        syms = out
    return syms

target = "自然语言处理是人工智能的分支"
chars = list(target) + ["</w>"]
subs = encode(target, merges)
print(f"\n目标句:{target}")
print(f"按字切分:{chars} → {len(chars)} 个 token")
print(f"BPE 切分:{subs} → {len(subs)} 个 token")
学到的合并顺序(前 12 步):
   1. 语言     频次 6
   2. 自然     频次 4
   3. 自然语言   频次 4
   4. 处理     频次 4
   5. 自然语言处理 频次 3
   6. 人工     频次 2
   7. 人工智    频次 2
   8. 人工智能   频次 2
   9. 语言模    频次 2
  10. 语言模型   频次 2
  11. 自然语言处理是 频次 1
  12. 自然语言处理是人工智能 频次 1

目标句:自然语言处理是人工智能的分支
按字切分:['自', '然', '语', '言', '处', '理', '是', '人', '工', '智', '能', '的', '分', '支', '</w>'] → 15 个 token
BPE 切分:['自然语言处理是人工智能', '的', '分', '支', '</w>'] → 5 个 token

这份输出有三处值得停下来看:

  1. 前 5 步合并出来的正好是”语言""自然""自然语言""处理""自然语言处理”——没有一个词是我们手工指定的,全是按频次从语料里长出来的。这就是”让数据决定切分单位”的实际含义。
  2. 第 11、12 步开始合并只出现 1 次的串,还越并越长(把整句”自然语言处理是人工智能”并成了一个 token)。原因是 12 次合并在四句话的语料上太多了:高频对耗尽后,算法就去吃低频对。所以 k 不是越大越好,要和语料规模匹配——这是自己跑一遍才会看到的坑。
  3. 序列从 15 个 token 压到 5 个,代价是第 17 行那个巨型 token 在别的语料上几乎不会命中。压缩率和泛化是此消彼长的,评估时要一起看。

如何选择与评估分词器?

今天,“子词分词(尤其是 BPE 及其变种)+ 字节级兜底” 已成为大语言模型分词器的主流设计方案。面对具体任务时,我们应如何选择和评估?

评估分词器好坏,通常关注以下三个核心指标,并且必须在自己任务的语料上进行测试:

  1. 压缩率:同样长度的文本,切分后产生的 token 数量。token 越少,模型需要处理的序列越短,效率通常越高。
  2. 未登录词率:测试集中有多少 token 不在词表内。理想情况下应为 0(字节级 BPE 可保证)。
  3. 下游任务效果:这是最终标准。在不同分词方案下训练或微调模型,比较其在目标任务(如分类、翻译)上的性能。

没有“放之四海而皆准”的最佳词表大小或分词算法。你需要:

  • 明确任务领域和主要语言。
  • 准备代表性的训练和测试语料。
  • 尝试不同的分词算法(BPE, WordPiece, Unigram LM)和不同的词表大小。
  • 综合比较上述三个指标,选择最适合你场景的方案。

📝 动手练一练

  1. 概念辨析:BPE 和 WordPiece 在训练阶段构建词表时,最核心的决策准则有什么不同?这种不同可能导致学到的词表有什么倾向性差异?
👉 点击查看参考答案
  • BPE 的决策准则是:合并当前语料中频次最高的相邻符号对。这使它倾向于快速合并那些共现频率极高的字符组合(如英文中的“th”、“ing”),可能更快地形成常见的词缀和字母组合。
  • WordPiece 的决策准则是:合并能使语言模型似然值提升最大的相邻符号对。这相当于不仅考虑频次,还考虑合并后对新“词”概率分布的贡献。理论上,它可能更倾向于合并那些能显著降低序列困惑度、对语言建模更有用的单元,可能对形态丰富的语言(如德语、土耳其语)中复杂的词形变化更有帮助。

两者的倾向性差异在理论上存在,但在许多实际任务和语料中,最终学到的词表效果可能相近。选择哪种往往取决于具体实现、库的支持以及历史项目惯性。

  1. 手算练习:假设我们有一个极小的语料,包含两个单词:"apple"(出现 3 次)和 "applet"(出现 2 次)。我们使用 BPE 算法,初始词表为单个字符 {'a', 'p', 'l', 'e', 't', '</w>'}。如果只进行 2 次合并,请手动推导前两次合并的符号对分别是什么?合并后的词表(子词集合)是怎样的?(提示:注意词尾标记 </w>)
👉 点击查看参考答案

首先,将单词表示为带词尾标记的字符序列并统计频次:

  • "apple": a p p l e </w> (频次 3)
  • "applet": a p p l e t </w> (频次 2)

第一步:统计初始相邻对频次

  • ('a', 'p'): 3 + 2 = 5
  • ('p', 'p'): 3 + 2 = 5
  • ('p', 'l'): 3 + 2 = 5
  • ('l', 'e'): 3 + 2 = 5
  • ('e', '</w>') (来自 apple): 3
  • ('e', 't') (来自 applet): 2
  • ('t', '</w>'): 2

频次最高的对有多组,都是 5。通常 BPE 实现会按字典序或首次出现顺序选择。假设我们选择 ('a', 'p') 进行第一次合并,新符号为 'ap'。

合并后序列变为:

  • ap p l e </w> (频次 3)
  • ap p l e t </w> (频次 2)

第二步:统计新的相邻对频次

  • ('ap', 'p'): 3 + 2 = 5
  • ('p', 'l'): 3 + 2 = 5
  • ('l', 'e'): 3 + 2 = 5
  • ('e', '</w>'): 3
  • ('e', 't'): 2
  • ('t', '</w>'): 2

频次最高的对仍有多个(5)。假设选择 ('ap', 'p') 进行第二次合并,新符号为 'app'。

合并后序列变为:

  • app l e </w> (频次 3)
  • app l e t </w> (频次 2)

经过两次合并后的词表(子词集合) 包含:

  • 初始字符:{'a', 'p', 'l', 'e', 't', '</w>'}
  • 第一次合并产生的新符号:'ap'
  • 第二次合并产生的新符号:'app'
  • 总计:8 个子词单元。

延伸阅读

  • Neural Machine Translation of Rare Words with Subword Units(Sennrich et al., ACL 2016,arXiv:1508.07909)—— BPE 应用于 NLP 的奠基论文。
  • Japanese and Korean voice search(Schuster & Nakajima, ICASSP 2012, DOI 10.1109/ICASSP.2012.6289079)—— WordPiece 算法的原始出处。
  • Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates(Kudo, 2018,arXiv:1804.10959)—— 提出基于 Unigram 语言模型的分词及子词正则化方法。

本章小结

本节我们深入探讨了现代 NLP 中核心的分词技术。我们从解决稀有词翻译问题的 BPE 算法出发,理解了其自底向上、基于频次合并的工作原理。进而,我们对比了 WordPiece(基于似然增益合并)和 Unigram LM(基于概率剪枝)这两种不同的子词构建策略。在工程实践上,字节级 BPE 通过操作字节序列实现了真正的零未登录词,成为大模型的标配。对于中文,现代最佳实践是放弃传统的有歧义的分词器,转而让模型通过子词分词器从数据中自动学习合适的表示单元。最后,我们明确了评估分词器应从压缩率、未登录词率和下游任务效果三个维度,并在自身语料上进行实测的原则。

📋 行动清单

  • 使用一个小型英文/中文混合语料,亲自编写或调用一个 BPE 训练代码,观察不同合并次数下词表的变化。
  • 在你的任务领域语料上,分别尝试按字、按传统分词、按 BPE 子词进行切分,并统计比较它们的序列平均长度。
  • 思考:如果你要为一个处理多语言(中、英、代码、数学公式)的模型设计分词器,你会优先考虑哪种方案?为什么?

—— 小象教研组