SentencePiece 不是一种新的子词算法,而是一层把空白当成普通字符的输入输出协议:它包裹 BPE 与 Unigram,用 ▁ 编码空格,从而摆脱对语言特定预分词器的依赖,并在 raw text 层面提供可逆的编解码。

语言无关的来源:取消预分词

传统管线要求先用空白或形态分析器把句子切成词,再对词内部做子词切分。这条假设对中文、日语、泰语直接失效,也让 detokenize 变成需要语言规则的猜测游戏。

SentencePiece 的做法是把整句原始文本视为 Unicode 字符流,唯一的预处理是把空格替换成可见元字符 ▁(U+2581,LOWER ONE EIGHTH BLOCK),然后让子词算法在这个流上自由切分——piece 可以跨越空格边界。

输入:  "Hello world."
替换:  "▁Hello▁world."
切分:  ['▁Hello', '▁world', '.']
还原:  ''.join(pieces).replace('▁', ' ').lstrip()

由此得到两个性质:

  • Lossless:decode(encode(x)) == x 是纯字符串操作,不含任何语言规则,也不需要 is_prev_token_punctuation 之类的 detokenizer 启发式。
  • 对称:中文这类无空白语言与英文走完全相同的代码路径,不存在“先装一个分词器”的前置依赖。这与 BBPE 字节级分词 用 Ġ 标记空格的思路同源,区别在于 SentencePiece 在字符层面操作,字节只作为兜底。

双模式:bpe 与 unigram

--model_type 有四个取值,实际训练只用前两个,后两个用于对照实验与流水线调试。

model_type训练目标典型使用者
unigram(默认)EM 迭代剪枝,最大化句子的 unigram 语言模型似然T5、mT5、ALBERT、XLM-R
bpe频率驱动的贪心 mergeLLaMA 系、Mistral、Gemma
char按字符建表基线对照
word按空白切,不做子词需要外部预分词的场景

算法细节分别见 BPE 算法原理 与 WordPiece 与 Unigram LM。就 SentencePiece 的选型而言,两点差异有实际后果:

  • unigram 支持采样切分。词表附带每个 piece 的 log 概率,可以在训练时按分布采样次优切分(subword regularization),作为数据增强;bpe 的 merge 是确定性的,没有这个能力。
  • unigram 训练更吃内存。它需要先构建大规模 seed 词表再迭代剪枝,语料超过千万句时必须用 --input_sentence_size 配合 --shuffle_input_sentence=true 采样,否则容易 OOM。bpe 在大语料上更省资源。

byte_fallback:字符层的 OOV 兜底

默认情况下,训练时被 --character_coverage 排除在外的稀有字符会在编码时变成 <unk>,信息不可恢复。--byte_fallback=true 把这类字符拆成 UTF-8 字节,映射到词表中预留的 256 个 <0x00>…<0xFF> token:

vocab_size=32000, byte_fallback=true
"𠮷" → ['<0xF0>', '<0xA0>', '<0xAE>', '<0xB7>']   # 4 字节,无 <unk>

要点:

  • 这 256 个 token 占用 vocab_size 预算,vocab_size=32000 实际留给子词的只有约 31700 个槽位。
  • 覆盖率仍然重要。byte_fallback 保证不丢信息,但一个字符退化成 3–4 个 token,压缩比严重恶化。CJK 语料应把 --character_coverage 从英文默认的 1.0 降到 0.9995,让高频汉字进词表、生僻字走字节路径——权衡细节见 中文与多语言分词。
  • 效果上等价于字节级兜底,但入口是字符而非字节,所以常见字符仍然保留单 token 表示,不像纯字节方案那样天然把所有非 ASCII 打散。

退化(lossy):三个默认开关

“无损”只在默认归一化关闭时成立。以下三个默认值会让 decode(encode(x)) != x:

参数默认副作用
--normalization_rule_namenmt_nfkcNFKC 折叠全角/半角、合并兼容字符
--remove_extra_whitespacestrue连续空格压成一个,首尾空白被裁掉
--add_dummy_prefixtrue句首自动补 ▁,"abc" 与 " abc" 编码相同

对代码、Markdown、需要精确还原原文的任务,必须显式关掉:

spm_train --input=corpus.txt --model_prefix=code \
  --vocab_size=32000 --model_type=bpe \
  --normalization_rule_name=identity \
  --remove_extra_whitespaces=false \
  --add_dummy_prefix=false \
  --allow_whitespace_only_pieces=true \
  --byte_fallback=true

--allow_whitespace_only_pieces=true 允许词表出现纯空白 piece(如 4 个连续 ▁),缩进密集的代码由此显著省 token;配合 --split_digits=true 逐位切分数字,是 LLaMA 系的标准配置,动机见 数字与代码的 Tokenization。

用户词典与保留 token

两类符号的语义完全不同,混用是常见错误:

  • --user_defined_symbols:参与编码匹配。文本中出现该串时强制切成单个 token,且优先于所有子词规则。适合领域术语、占位符、<|im_start|> 这类对话标记。
  • --control_symbols:只占 id,不匹配文本。编码器永远不会从普通文本中产出它们,只能由调用方手动拼接 id。适合 <pad>、[CLS] 等结构标记,可防止用户输入伪造控制 token。
spm_train --input=corpus.txt --model_prefix=chat --vocab_size=32000 \
  --user_defined_symbols='<|im_start|>,<|im_end|>' \
  --control_symbols='<pad>,<mask>'

另有 --required_chars 强制某些字符必须进词表,以及 --hard_vocab_limit=false:unigram 在小语料上常报 Vocabulary size too high,放开 hard limit 可以让实际词表小于目标值而不是直接失败。

CLI 与 Python API

CLI 四件套:spm_train 训练、spm_encode 编码、spm_decode 解码、spm_export_vocab 导出可读词表。产物是 prefix.model(protobuf,自包含归一化规则与词表)与 prefix.vocab(TSV 文本,仅供查看)。

import sentencepiece as spm
 
# 训练:参数与 CLI 完全一致,逗号分隔字符串或 kwargs 均可
spm.SentencePieceTrainer.train(
    input="corpus.txt",
    model_prefix="demo",
    vocab_size=8000,
    model_type="unigram",
    character_coverage=0.9995,
    byte_fallback=True,
    input_sentence_size=2_000_000,   # 大语料采样,避免 unigram 训练 OOM
    shuffle_input_sentence=True,
)
 
sp = spm.SentencePieceProcessor(model_file="demo.model")
 
print(sp.encode("深度学习 tokenizer", out_type=str))
print(sp.encode("深度学习 tokenizer"))            # id 序列
print(sp.decode(sp.encode("深度学习 tokenizer"))) # 原样还原
 
# subword regularization:仅 unigram 可用
# nbest_size=-1 表示在全部候选切分上按 alpha 平滑后的分布采样
for _ in range(3):
    print(sp.encode("tokenization", out_type=str,
                    enable_sampling=True, nbest_size=-1, alpha=0.1))

encode 接受字符串列表批量处理,并可用 num_threads 并行;add_bos=True / add_eos=True 在 id 序列两端补 <s> / </s>,比手工拼接更不容易出错。

与 Hugging Face 的衔接

transformers 中的 LlamaTokenizer、T5Tokenizer 等 slow 实现直接读 .model 文件;对应的 fast 版本由 convert_slow_tokenizer 把 protobuf 转成 tokenizer.json。两条路径的编码结果应当但不总是一致——归一化与 add_dummy_prefix 的转换历史上出现过偏差,替换 tokenizer 后值得在真实语料上做一次全量 id 比对。整体管线位置见 Tokenizer 概述。