文本分词算法:Byte-Pair Encoding (BPE)、WordPiece 和 SentencePiece

在将自然语言文本输入大语言模型之前,必须先将其转换为模型能够计算的数字序列。这一过程被称为分词(Tokenization),而执行该转换的模块即为分词器(Tokenizer)。分词器定义了一套规则,将原始文本切分为一个个最小的语义单元,这些单元叫作词元(Token)。

为了在词汇表大小与语义表达能力之间取得平衡,现代大语言模型普遍采用子词分词(Subword Tokenization)算法。其核心思想是:将高频词(如 "agent")保留为一个完整的词元,而将低频或未登录词(如 "Tokenization")拆分成多个有意义的子词片段(如 "Token" 和 "ization")。这样做既能控制词汇表规模,又赋予模型通过子词组合来理解与生成新词的能力。最终,所有词元会被映射为固定的整数 ID,形成模型可直接计算的数字序列。

本文围绕子词分词技术,详细讲解三种主流算法:Byte-Pair Encoding (BPE)WordPieceSentencePiece (Unigram 语言模型)

1. Byte-Pair Encoding (BPE)

原理

BPE 最早是一种数据压缩算法,后被用于机器翻译等 NLP 任务。它从字符级 开始,统计所有相邻符号对的出现频率,反复将频率最高的那一对合并成一个新符号,直到词汇表达到预定大小(或没有可合并的对)。

训练 BPE 前通常需要一个预分词步骤(如按空格分词),并为每个词加上结尾标记(如 </w>),保证合并出的子词能还原词边界。最终词汇表包含单字符、合并得到的子词以及词尾标记。

举例

假设有词频数据:low</w> 出现 5 次,lower</w> 出现 2 次,newest</w> 出现 6 次,widest</w> 出现 3 次。

  • 初始分割为字符序列,例如 l o w </w>
  • 统计所有相邻对:(l, o) 出现 5+2=7 次,(o, w) 出现 7 次,(w, </w>) 出现 5 次,(e, w) 出现 2 次...
  • 最高频对为 (e, s) 出现 6+3=9 次,于是合并成 es
  • 继续迭代,直到词汇表达到预设大小。

编码/分词

对于一个新词,按照训练时学到的合并顺序(merges),依次应用合并规则,将词拆分为子词序列。

Python 实现

python 复制代码
import re
from collections import Counter, defaultdict

def get_initial_vocab(word_freqs):
    """将词拆为字符序列,末尾加特殊标记</w>"""
    vocab = {}
    for word, freq in word_freqs.items():
        chars = ' '.join(list(word)) + ' </w>'
        vocab[chars] = freq
    return vocab

def get_pair_stats(vocab):
    """统计所有相邻符号对的频率"""
    pairs = Counter()
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[(symbols[i], symbols[i+1])] += freq
    return pairs

def merge_vocab(pair, vocab):
    """将指定符号对在词汇表中合并"""
    bigram = ' '.join(pair)
    replacement = ''.join(pair)
    new_vocab = {}
    for word, freq in vocab.items():
        new_word = word.replace(bigram, replacement)
        new_vocab[new_word] = freq
    return new_vocab

def bpe_train(word_freqs, num_merges):
    """训练 BPE,返回合并列表和最终词汇表"""
    vocab = get_initial_vocab(word_freqs)
    merges = []
    for i in range(num_merges):
        pairs = get_pair_stats(vocab)
        if not pairs:
            break
        best_pair = max(pairs, key=pairs.get)
        merges.append(best_pair)
        vocab = merge_vocab(best_pair, vocab)
    return merges, vocab

# ---- 使用演示 ----
word_freqs = {"low":5, "lower":2, "newest":6, "widest":3}
merges, final_vocab = bpe_train(word_freqs, num_merges=10)

print("合并顺序:", merges)
print("最终词汇表示例:", list(final_vocab.items())[:5])

2. WordPiece

原理

WordPiece 与 BPE 非常相似,最大的区别在于合并准则 :WordPiece 不选频率最高的对,而是选择最大化语言模型似然的符号对。实际实现中,计算分数:

score = freq ( x , y ) freq ( x ) × freq ( y ) \text{score} = \frac{\text{freq}(x,y)}{\text{freq}(x) \times \text{freq}(y)} score=freq(x)×freq(y)freq(x,y)

其中 freq ( x , y ) \text{freq}(x,y) freq(x,y) 是相邻对共现次数, freq ( x ) \text{freq}(x) freq(x) 和 freq ( y ) \text{freq}(y) freq(y) 分别是两个符号单独出现的次数。这样倾向于合并那些经常一起出现、且单独出现相对较少的组合,得到的子词更具语言学意义。

与 BPE 的另一区别是,WordPiece 在词内部用 ## 前缀标记非起始子词(如 word##piece),以防止跨词边界的歧义合并。

举例

词频同 BPE 例子,但合并时选择 score 最高的对:

  • 统计出 (e, s) 频率高,但 es 本身也频繁出现,因此 score 可能不如 (w, ##i)(在 wide 中)等高。
  • 最终合并会优先使单个符号之间依赖更强的合并。

编码/分词

采用贪心最长匹配策略:从左到右,逐步匹配词汇表中最长的子词。

Python 实现

python 复制代码
def wordpiece_train(word_freqs, num_merges):
    """简化的 WordPiece 训练"""
    # 词内符号以 ## 标记非首字符
    vocab = {}
    for word, freq in word_freqs.items():
        chars = word[0]   # 首字符不加 ##
        for c in word[1:]:
            chars += ' ##' + c
        chars += ' </w>'
        vocab[chars] = freq

    merges = []
    for _ in range(num_merges):
        pairs = Counter()
        # 统计每个符号的独立频率
        symbol_freq = Counter()
        for word, freq in vocab.items():
            symbols = word.split()
            for s in symbols:
                symbol_freq[s] += freq
            for i in range(len(symbols)-1):
                pairs[(symbols[i], symbols[i+1])] += freq
        if not pairs:
            break
        # 计算 score,选最大
        best_pair = max(pairs, key=lambda p: pairs[p] / (symbol_freq[p[0]] * symbol_freq[p[1]]))
        merges.append(best_pair)
        bigram = ' '.join(best_pair)
        replacement = ''.join(best_pair)
        new_vocab = {}
        for word, freq in vocab.items():
            new_word = word.replace(bigram, replacement)
            new_vocab[new_word] = freq
        vocab = new_vocab
    return merges, vocab

3. SentencePiece (Unigram 语言模型)

原理

SentencePiece 是 Google 推出的分词框架,它不依赖预分词(即不需要事先按空格切分),而是把整个输入视为原始字节流 ,直接学习子词。虽然它支持 BPE,但其最具代表性的算法是Unigram 语言模型,配合子词正则化。

核心思想:从一个很大的候选词汇表(如所有可能的子串)出发,假设每个子词的出现相互独立,句子的概率为各子词概率的乘积:

P ( s e n t e n c e ) = ∏ x ∈ s e g m e n t a t i o n p ( x ) P(sentence) = \prod_{x \in segmentation} p(x) P(sentence)=∏x∈segmentationp(x)

通过期望最大化(EM)算法优化训练数据的边缘似然,同时逐步剪掉概率较低的候选子词,直到词汇表达到指定大小。训练完成后,编码可以用维特比算法寻找概率最大的分割,也可以根据概率采样多种分割以实现正则化。

SentencePiece 把空格编码为 _(或其它元符号),并将所有字符统一处理,非常适合多语言或无需分词的场景(如中文、日文)。

举例

训练数据:"low lower newest widest"

  • 初始候选词汇:所有单个字符 l, o, w, e, r, n, s, t, d, i, _(空格) 以及高频率的 bigram(如 lo, ow, er, es, ...)。
  • 给每个候选子词均匀概率,然后开始 EM 迭代:
    • E 步:计算每个句子所有可能分割下各子词的期望出现次数。
    • M 步:用期望计数重新估计子词概率。
  • 每轮剪掉概率最低的 20% 子词,重复直到词汇表大小符合要求。

Python 实现

python 复制代码
import math, re
from collections import Counter, defaultdict

def init_vocab(texts, max_len=3):
    """从文本中收集所有长度 <= max_len 的子串作为初始候选词汇"""
    vocab = Counter()
    for text in texts:
        # 将空格替换为特殊标记,使其可分割
        text = text.replace(' ', '_')
        for i in range(len(text)):
            for j in range(1, max_len+1):
                if i+j <= len(text):
                    vocab[text[i:i+j]] += 1
    # 只保留出现次数足够多的候选(例如至少2次)
    return {w: c for w, c in vocab.items() if c >= 2}

def viterbi_segment(text, vocab_probs):
    """用动态规划找概率最大的分割路径"""
    n = len(text)
    dp = [0.0] * (n+1)
    dp[0] = 1.0
    back = [0] * (n+1)
    for i in range(n):
        if dp[i] == 0:
            continue
        for j in range(i+1, n+1):
            sub = text[i:j]
            if sub in vocab_probs:
                prob = dp[i] * vocab_probs[sub]
                if prob > dp[j]:
                    dp[j] = prob
                    back[j] = i
    # 回溯
    segments = []
    idx = n
    while idx > 0:
        prev = back[idx]
        segments.append(text[prev:idx])
        idx = prev
    return segments[::-1]

def train_unigram(texts, vocab_size=100, num_iters=10):
    """简化的 Unigram 训练(Viterbi 近似 EM)"""
    # 初始词汇表
    raw_vocab = init_vocab(texts, max_len=4)
    vocab = list(raw_vocab.keys())
    print("初始候选词汇数:", len(vocab))

    # 均匀概率
    probs = {w: 1/len(vocab) for w in vocab}

    for it in range(num_iters):
        # 用维特比硬分割,并统计频率
        counts = Counter()
        total = 0
        for text in texts:
            text = text.replace(' ', '_')
            seg = viterbi_segment(text, probs)
            for sub in seg:
                counts[sub] += 1
                total += 1

        # 重新估计概率
        probs = {w: counts[w]/total for w in vocab if counts[w] > 0}

        # 剪枝:如果词汇量超过目标,去掉概率最低的
        while len(probs) > vocab_size:
            worst = min(probs, key=probs.get)
            del probs[worst]

        vocab = list(probs.keys())
        print(f"Iter {it+1}, vocab size: {len(vocab)}")

    return probs

# ---- 使用演示 ----
texts = ["low lower newest widest"]
probs = train_unigram(texts, vocab_size=20, num_iters=10)
print("最终词汇:", sorted(probs.keys()))

三者优缺点对比

方法 优点 缺点
BPE 简单、高效;完全基于频率,可解释性强;训练速度快;编码规则确定。 依赖预分词;可能合并不合理的子词;无法解决多分割歧义;对低频对容易过拟合。
WordPiece 合并准则基于概率最大化,子词更具语言学意义;结合 ## 前缀,边界清晰;在 BERT 等模型中效果好。 训练开销稍大于 BPE;需维护单符号频率;依赖预分词;贪心分词可能非最优。
SentencePiece 不依赖预分词,将输入视为字节流,天然支持所有语言;Unigram 提供概率模型和子词正则化,增强鲁棒性;空格可见标记。 训练复杂(EM),速度慢;实现难度高;Viterbi 解码开销较大;超参数调节需经验。

三者都在现代 NLP 中被广泛使用。BPE 适合快速实现与清晰规则,WordPiece 在理解型模型(如 BERT)中平衡了效果与速度,而 SentencePiece(Unigram)提供了最灵活、最一致的多语言处理方案,尤其在需要避免预分词偏见的生产系统中优势明显。