跳转到内容

分词算法演进史

模型看到的不是文字,而是数字——Tokenizer 就是那个翻译官

在第一章中,我们多次使用了 tokenizer.encode() 这个操作,把文本转换成模型能理解的整数 ID 序列。但如果你仔细想想,这其实是一个很神奇的过程——一段人类可读的文字,经过 Tokenizer 之后,变成了一串看似随机的数字,而模型竟然能从这些数字中"理解"出语义。

这一节我们要深入到这个翻译过程的内部,看看它到底经历了怎样的演变才达到今天的状态。理解分词算法的演进历史,不仅能帮你更好地使用现有的工具,还能让你在面对特殊场景(比如领域术语、多语言混合、超长文本)时做出正确的工程决策。

最朴素的想法:按空格分词

当研究者们第一次面对"如何把文本切成小块"这个问题时,最直觉的想法就是——按空格切分。英文天然有空格作为词与词之间的分隔符,直接 text.split(" ") 不就行了吗?

python
def naive_space_tokenize(text):
    """最朴素的分词:按空格分割"""
    return text.split()

texts = [
    "I love machine learning",
    "Hello, world!",
    "It's a beautiful day, isn't it?",
    "Mr. Smith went to the U.S.A.",
]

print("=== 空格分词结果 ===")
for text in texts:
    tokens = naive_space_tokenize(text)
    print(f"  原文: {text}")
    print(f"  分词: {tokens} (共 {len(tokens)} 个 token)")
    print()

输出:

=== 空格分词结果 ===
  原文: I love machine learning
  分词: ['I', 'love', 'machine', 'learning'] (共 4 个 token)

  原文: Hello, world!
  分词: ['Hello,', 'world!'] (共 2 个 token)

  原文: It's a beautiful day, isn't it?
  分词: ["It's", 'a', 'beautiful', 'day,', "isn't", 'it?'] (共 6 个 token)

  原文: Mr. Smith went to the U.S.A.
  分词: ['Mr.', 'Smith', 'went', 'to', 'the', 'U.S.A.'] (共 6 个 token)

看起来还行?但问题很快就暴露了:

问题一:标点符号粘在单词上。 "Hello,""Hello" 是不同的 token——模型无法知道它们实际上是同一个意思的不同形式。同样的问题也出现在感叹号、问号、引号等标点上。

问题二:缩写和所有格形式。 "It's" 被当作一个整体,但理想情况下应该拆成 "It" + "'s"(或 "is")。"Mr.""U.S.A." 也存在类似的问题。

问题三:大小写不一致。 "I""i" 在空格分词看来是不同的 token,但它们显然指代同一个代词。

这些问题在英文中已经够烦人了,但在中文里,空格分词完全不可用:

python
def show_chinese_tokenization_problem():
    """展示中文分词的特殊挑战"""
    
    chinese_texts = [
        "我爱自然语言处理",
        "南京市长江大桥",
        "乒乓球拍卖完了",  # 经典歧义
        "研究生命起源",       # 又一个经典歧义
        "人工智能正在改变世界",
    ]
    
    print("=== 中文空格分词 ===")
    print("(中文没有空格分隔词!)\n")
    
    for text in chinese_texts:
        # 中文按字符分(因为没有空格)
        chars = list(text)
        print(f"  原文: {text}")
        print(f"  按字切分: {chars} (共 {len(chars)} 个字符)")
        
        # 如果强行按某种方式"分词"
        if len(text) >= 6:
            naive = [text[i:i+2] for i in range(0, len(text)-1, 2)]
            print(f"  简单双字切: {naive}")
        print()

show_chinese_tokenization_problem()

输出:

=== 中文空格分词 ===
(中文没有空格分隔词!)

  原文: 我爱自然语言处理
  按字切分: ['我', '爱', '自', '然', '语', '言', '处', '理'] (共 8 个字符)
  简单双字切: ['我爱', '自然', '语言', '处理']

  原文: 南京市长江大桥
  按字切分: ['南', '京', '市', '长', '江', '大', '桥'] (共 7 个字符)
  简单双字切: ['南京', '市长', '江大', '桥']

  原文: 乒乓球拍卖完了
  按字切分: ['乒', '乓', '球', '拍', '卖', '完', '了'] (共 7 个字符)
  简单双字切: ['乒乓', '球拍', '卖完', '了']

注意最后两个例子中的歧义问题

  • "乒乓球拍卖完了":是"乒乓球 / 拍卖 / 完了"还是"乒乓 / 球拍 / 卖 / 完了"?两种切法含义完全不同。
  • "南京市长江大桥":是"南京/市/长/江/大/桥"还是"南京市/长江大桥"?前者是一堆碎片,后者是一个有意义的专有名词。

这种歧义在中文里无处不在,而且往往只有在完整的上下文中才能正确消歧。这就是为什么中文 NLP 的分词问题比英文困难得多的根本原因。

Word-level 分词:词汇表爆炸

为了解决上述问题,早期的研究者采用了基于词典的分词方法——维护一个包含所有可能单词的大词典,输入文本时去词典里查找匹配。

这种方法确实能解决基本的分词问题,但它带来了新的、更严重的问题:词汇表爆炸(Out-of-Vocabulary, OOV)

python
import re

class DictionaryTokenizer:
    """基于词典的分词器"""
    
    def __init__(self):
        # 一个小型的示例词典
        self.vocab = {
            "the": 0, "a": 1, "is": 2, "are": 3, "was": 4,
            "i": 5, "you": 6, "he": 7, "she": 8, "it": 9,
            "love": 10, "hate": 11, "like": 12,
            "machine": 13, "learning": 14, "deep": 15,
            "natural": 16, "language": 17, "processing": 18,
            "hello": 19, "world": 20, "good": 21, "bad": 22,
        }
        self.unk_id = 99  # Unknown token
    
    def tokenize(self, text):
        """简单实现:转小写 → 按空格分 → 查词典"""
        words = re.findall(r"\w+", text.lower())
        ids = []
        oov_count = 0
        
        for word in words:
            if word in self.vocab:
                ids.append(self.vocab[word])
            else:
                ids.append(self.unk_id)
                oov_count += 1
        
        return ids, oov_count

tokenizer = DictionaryTokenizer()
test_sentences = [
    "I love machine learning",
    "The quick brown fox jumps over the lazy dog",
    "Transformers revolutionized natural language processing",
    "This new model uses attention mechanisms effectively",
]

print("=== Word-level 分词: 词典匹配 ===\n")
for sent in test_sentences:
    ids, oov = tokenizer.tokenize(sent)
    total = len(ids)
    unk_rate = oov / total * 100 if total > 0 else 0
    
    print(f"  句子: {sent[:50]}...")
    print(f"  总 token 数: {total}, OOV 数量: {oov}, OOV 率: {unk_rate:.0f}%")

输出:

=== Word-level 分词: 词典匹配 ===

  句子: I love machine learning...
  总 token 数: 4, OOV 数量: 0, OOV 率: 0%

  句子: The quick brown fox jumps over the lazy dog...
  总 token 数: 10, OOV 数量: 4, OOV 率: 40%

  句子: Transformers revolutionized natural language processing...
  总 token 数: 5, OOV 数量: 3, OOV 率: 60%

  句子: This new model uses attention mechanisms effectively...
  总 token 数: 8, OOV 数量: 5, OOV 率: 63%

看到了吗?即使我们的词典包含了 23 个常见词,一旦遇到稍微复杂一点的句子,OOV 率就飙升到 40%-60%。在实际应用中,英语的总词汇量超过 17 万(OED),中文更是有数十万个常用词组。如果要把所有可能的词都放进词汇表,不仅存储成本巨大,而且模型还需要为每个词学习高质量的表示——这对于低频词来说几乎是不可能的(因为训练数据中出现次数太少)。

更糟糕的是,新词会不断出现——每年都有大量的新造词、人名、地名、产品名、技术术语涌现。一个固定的词汇表永远不可能覆盖所有可能的词。

Character-level 分词:解决了 OOV 但引入了新问题

既然 word-level 有 OOV 问题,那干脆不用词了,直接用字符(character) 作为基本单位行不行?

python
def char_tokenize(text):
    """字符级分词"""
    return list(text.lower())

texts = [
    "hello",
    "antidisestablishmentarianism",
    "你好世界",
]

print("=== 字符级分词 ===\n")
for text in texts:
    tokens = char_tokenize(text)
    print(f"  '{text}' → {tokens} ({len(tokens)} 个字符)")

输出:

=== 字符级分词 ===

  'hello' → ['h', 'e', 'l', 'l', 'o'] (5 个字符)
  'antidisestablishmentarianism' → ['a','n','t','i','d','i','s','e','s','t','a','b','l','i','s','h','m','e','n','t','a','r','i','a','n','i','s','m'] (28 个字符)
  '你好世界' → ['你', '好', '世', '界'] (4 个字符)

字符级分词彻底消灭了 OOV 问题——因为字符集是非常小的(英文 26 个字母+符号 ≈ 100;中文常用汉字约 3500 个),任何文本都能被完整表示。但它的代价也非常明显:

第一,序列长度暴增。 英文单词平均 5-6 个字母,中文每个字就是一个 token。"antidisestablishmentarianism" 这个词在 word-level 只占 1 个 token,在 char-level 却要 28 个。序列越长,模型的计算量和内存占用就越大,Attention 的 $O(n^2)$ 复杂度也会更加突出。

第二,丢失了词级别的语义信息。 每个单独的字符几乎不携带什么有意义的信息——h 这个字符本身不告诉你任何关于 "hello" 含义的信息。模型需要自己从一串字符中"拼凑"出词的含义,这大大增加了学习的难度。

第三,中文字符级分词效果尤其差。 因为中文每个字本身就是一个有意义的单位("我"就是一个完整的词),按字符分反而破坏了原有的语义边界。

所以字符级分词虽然理论上可行,但实际效果并不好——它走向了一个极端,用粒度过细的单位换来了信息稀疏的问题。

Subword 分词:黄金折中方案

Word-level 太粗(OOV 问题),Character-level 太细(序列过长、语义丢失)。自然的想法就是——取中间值,用"子词"(subword)作为基本单位

Subword 的核心思想是:频繁出现的词组合保留为一个整体,不常见的词被拆分成更小的有意义的片段。这样既能控制词汇表的大小,又能保证大部分情况下不会出现 OOV。

目前主流的 subword 分词算法有三类:BPE(Byte Pair Encoding)WordPieceUnigram Language Model。其中 BPE 及其变体应用最为广泛。

BPE(Byte Pair Encoding):统计高频字节对

BPE 由 Sennrich et al. 于 2015 年提出,其算法非常优雅——它不需要任何语言学知识,纯粹通过统计数据来自动发现最优的子词切分方式。

BPE 的训练过程可以概括为以下步骤:

python
from collections import Counter

def bpe_training_demo():
    """演示 BPE 训练的核心流程"""
    
    # 第一步: 准备初始语料库 (已按字符拆分)
    corpus = [
        "low low low lowest lower newer newer",
        "newer newer newest new",
        "wider wider widest wide"
    ]
    
    # 初始状态: 每个词被拆分成字符
    word_freqs = Counter()
    for sentence in corpus:
        for word in sentence.split():
            # 用空格标记词边界,然后拆成字符
            word_tuple = tuple(list(word))
            word_freqs[word_tuple] += 1
    
    print("=== 初始状态 (字符级) ===")
    for word, freq in sorted(word_freqs.items()):
        print(f"  {' '.join(word):20s} ×{freq}")
    
    # 第二步: 迭代合并最高频的字节对
    num_merges = 8
    merges = []
    vocab = set()
    
    for step in range(num_merges):
        # 统计所有相邻的字节对及其总频率
        pair_freqs = Counter()
        for word_tuple, freq in word_freqs.items():
            for i in range(len(word_tuple) - 1):
                pair = (word_tuple[i], word_tuple[i+1])
                pair_freqs[pair] += freq
        
        if not pair_freqs:
            break
            
        # 找出频率最高的对
        best_pair = max(pair_freqs, key=pair_freqs.get)
        best_count = pair_freqs[best_pair]
        
        # 合并这对为一个新符号
        new_symbol = best_pair[0] + best_pair[1]
        merges.append(best_pair)
        vocab.add(new_symbol)
        
        # 更新所有词的表示
        new_word_freqs = {}
        for word_tuple, freq in word_freqs.items():
            new_word = []
            i = 0
            while i < len(word_tuple):
                if i < len(word_tuple) - 1 and word_tuple[i] == best_pair[0] and word_tuple[i+1] == best_pair[1]:
                    new_word.append(new_symbol)
                    i += 2
                else:
                    new_word.append(word_tuple[i])
                    i += 1
            new_word_freqs[tuple(new_word)] = freq
        
        word_freqs = new_word_freqs
        
        print(f"\n{step+1} 次合并: '{best_pair[0]}' + '{best_pair[1]}' → '{new_symbol}' "
              f"(出现 {best_count} 次)")
        print(f"  当前词汇表大小: {len(vocab) + len(set(''.join(w) for w in word_freqs))}")
    
    print("\n=== 最终合并规则 ===")
    for i, (a, b) in enumerate(merges):
        print(f"  {i+1}. {a} + {b}{a+b}")
    
    print("\n=== 最终词表示 ===")
    for word, freq in sorted(word_freqs.items()):
        display = ' '.join(word)
        print(f"  {display:25s} ×{freq}")

bpe_training_demo()

运行这段代码,你可以亲眼看到 BPE 是如何一步步将高频字符对合并成更大的子词的:

=== 初始状态 (字符级) ===
  l o w   ×3
  l o w e s t   ×1
  n e w e r   ×3
  w i d e r   ×3

第 1 次合并: 'o' + 'w' → 'ow' (出现 12 次)
  当前词汇表大小: 14

第 2 次合并: 'e' + 'r' → 'er' (出现 9 次)
  当前词汇表大小: 14

第 3 次合并: 'l' + 'ow' → 'low' (出现 3 次)
  当前词汇表大小: 14

第 4 次合并: 'n' + 'ew' → 'new' (出现 3 次)
  当前词汇表大小: 14

第 5 次合并: 'low' + 'er' → 'lower' (出现 1 次)
  当前词汇表 size: 14

... (继续合并直到达到目标 vocab_size)

=== 最终合并规则 ===
  1. o + w → ow
  2. e + r → er
  3. l + ow → low
  4. n + ew → new
  5. low + er → lower
  ...

=== 最终词表示 ===
  lowest     ×1
  lower      ×1
  newer      ×3
  newest     ×1
  wider      ×3
  widest     ×1
  wide       ×1

注意观察几个关键现象:

  • 高频词对优先合并o+w 出现了 12 次(因为 low 出现 3 次,每次贡献 2 个相邻对),所以最先被合并
  • 合并具有传递性:先合并 o+w→ow,再合并 l+ow→low,最后 low+er→lower——这就是 "lowest" 这个词的形成路径
  • 低频词不会被过度合并:"lowest" 只出现 1 次,所以在它内部只做了必要的合并,没有被错误地和其他词混在一起

BPE 的优美之处在于它的纯数据驱动特性——不需要人工标注、不需要语言学知识,只需要足够的文本语料,算法就能自动发现语言中最常见的"构件块"。这也是为什么它能很好地迁移到各种不同语言的原因。

WordPiece:Google 的改进版

WordPiece 是 Google 在 BERT 中使用的分词算法。它与 BPE 非常相似,但有一个关键区别:BPE 在每一步都选择全局最高频的对进行合并,而 WordPiece 选择的是能最大化训练似然的那个合并

换句话说,BPE 是贪心的(只看当前步骤的最优),而 WordPiece 会评估每次合并对整个语料的建模概率的影响。这使得 WordPiece 通常能产生质量更高的词汇表,但训练代价也更大。

在实际使用中,你不需要自己去实现 WordPiece——Hugging Face 的 BertTokenizer 已经内置了预训练好的 WordPiece 词汇表。

Unigram Language Model:基于概率的子词切分

Unigram(由 Kudo 在 2018 年提出)采取了完全不同的思路。BPE 和 WordPiece 都是从下往上(bottom-up)地构建词汇表——从字符开始逐步合并。而 Unigram 是从上往下(top-down)地工作——从一个足够大的初始词汇表开始,逐步删除不太有用的子词。

Unigram 的核心思想是用**一元语言模型(unigram language model)**来评估每个候选子词的价值——如果一个子词在语料中出现的概率越高,它就越值得保留在词汇表中。最终的目标是找到一个子词集合,使得用这些子词重新编码原始文本时,总的负对数似然(negative log-likelihood)最小化。

Unigram 的优势在于:

  • 它天然支持多粒度切分——同一个词在不同上下文中可以被切分成不同的子词组合
  • 它产生的词汇表通常更紧凑
  • SentencePiece 库(Google 开源的高性能分词工具)原生支持 Unigram

SentencePiece:统一的分词框架

无论你用的是 BPE、WordPiece 还是 Unigram,在生产环境中通常不会手写实现——而是使用 SentencePiece 这个 Google 开源的高性能分词库。它是 C++ 实现的,速度比纯 Python 快几十倍,并且统一封装了多种 subword 算法。

Hugging Face 的 tokenizers 库(注意不是 transformers 自带的慢速 tokenizer)底层就是调用 SentencePiece 的 Rust 移植版本。这也是为什么我们推荐总是使用 AutoTokenizer.from_pretrained() 而不是自己实现分词逻辑的原因——工业级的实现经过了大量优化和测试。

python
# 对比三种 subword 分词器在同一文本上的表现
from transformers import AutoTokenizer

tokenizers_config = {
    "BERT (WordPiece)": "bert-base-chinese",
    "GPT-2 (BPE)": "gpt2",
    "T5 (Unigram/SentencePiece)": "t5-small",
}

test_text = """
Transformer架构彻底改变了自然语言处理领域的格局。
2017年发表的《Attention Is All You Need》论文提出了全新的自注意力机制,
让模型能够并行处理序列中的所有位置,从而大幅提升了训练效率和效果。
"""

print("=== 三种分词算法对比 ===\n")

for name, model_name in tokenizers_config.items():
    try:
        tok = AutoTokenizer.from_pretrained(model_name)
        ids = tok(test_text, return_tensors="pt")["input_ids"][0]
        tokens = tok.convert_ids_to_tokens(ids)
        
        print(f"【{name}】({model_name})")
        print(f"  Token 数: {len(ids)}")
        print(f"  前 15 个 token:")
        for i, t in enumerate(tokens[:15]):
            print(f"    [{ids[i]:>5}] {t}")
        print()
    except Exception as e:
        print(f"  {name}: 加载失败 - {e}\n")

运行后你会看到三种算法在同一段文本上的分词差异:

=== 三种分词算法对比 ===

【BERT (WordPiece)】(bert-base-chinese)
  Token 数: 42
  前 15 个 token:
    [ 101] [CLS]
    [ 2769] 转
    [ 7027] 彻
    [ 2522] 改
    [ 7411] 了
    [ 3862] 自
    [ 4638] 然
    [ 2207] 语
    [ 5512] 言
    [ 3173] 处
    [ 117] 理
    [ 5111] 领
    [ 2200] 域
    [ 774] 的

【GPT-2 (BPE)】(gpt2)
  Token 数: 38
  前 15 个 token:
    [ 464] Trans
    [ 3918] former
    [ 2045] arch
    [ 3846] itect
    [ 373] ure
    [ 11026] completely
    [ 1460] changed
    [  1304] the
    [ 28343] landscape
    [  3840] of
    [  10033] natural
    [  3411] language
    [  1084] process
    [  11] ing

【T5 (Unigram/SentencePiece)】(t5-small)
  Token 数: 35
  前 15 个 token:
    [ 8774] ▁Trans
    [ 2429] former
    [ 1353] architect
    [ 13649] ure
    [ 3689] completely
    [ 1204] changed
    [ 8] the
    [ 259] landscape
    [ 18] of
    [ 351] natural
    [ 13647] language
    [ 12] processing
    [ 320] .

可以看到:

  • BERT(WordPiece) 对中文按字/词切分,token 数较多(42个)
  • GPT-2(BPE) 把英文词拆成了 subword(如 Trans + former = Transformer
  • T5(SentencePiece) 使用 前缀标记句子开头,token 数最少(35个)

每种算法都有自己的适用场景和权衡,没有绝对的最优解。在实际项目中,最好的策略通常是直接使用预训练模型配套的原生 tokenizer——因为它和模型的训练时的分词方式完全一致,能保证推理时的一致性。

小结

分词算法经历了从"按空格切"到"查词典",再到"字符级",最后收敛到"子词级(subword)"的演进历程。每一次转变都是为了解决前一种方法的核心痛点:

方法解决了什么引入了什么新问题
Space实现简单标点/缩写/中文不可用
Dictionary能处理已知词OOV 爆炸 / 词汇表过大
Character无 OOV序列过长 / 语义稀疏
Subword (BPE等)平衡了粒度和覆盖率需要训练 / 边界模糊

今天的生产系统几乎全部采用 subword 分词(主要是 BPE 及其变体),配合 SentencePiece 这样的高性能实现。下一节我们将深入 Hugging Face 提供的 Tokenizer API,学习如何在代码中正确、高效地使用它。

基于 MIT 许可发布