tokenizers库把分词拆成五个可独立替换的阶段——Normalizer、Pre-tokenizer、Model、Post-processor、Decoder——只有 Model 带训练参数,其余四段是纯确定性变换;理解这条管线,等于同时掌握了自定义训练、跨语言序列化和 offsets 对齐三件事。
五段管线
| 阶段 | 职责 | 常见实现 |
|---|---|---|
| Normalizer | Unicode 层的字符串改写,可改变长度 | NFC NFKC Lowercase StripAccents Replace BertNormalizer |
| Pre-tokenizer | 切出互不重叠的初始片段,定义 Model 不可跨越的边界 | ByteLevel Metaspace Whitespace Punctuation Digits Split |
| Model | 片段内部的子词切分,唯一需要训练 | BPE WordPiece Unigram WordLevel |
| Post-processor | 拼特殊 token、写 type_ids、修正 offsets | TemplateProcessing ByteLevel BertProcessing |
| Decoder | token 序列还原为字符串 | ByteLevel Metaspace WordPiece ByteFallback |
前四段全部支持 Sequence([...]) 组合。数据流:
"Hello World"
→ Normalizer "Hello World"
→ Pre-tokenizer [("Hello",(0,5)), ("ĠWorld",(5,11))] # ByteLevel 把空格编成 Ġ
→ Model ["Hello", "ĠWor", "ld"]
→ Post-processor ["<s>", "Hello", "ĠWor", "ld", "</s>"]
→ Decoder "Hello World"三条不变式决定了大部分调参直觉:
- Pre-tokenizer 的边界是硬边界。加
Digits(individual_digits=True)后词表里永远不会出现2024这种多位数 token,无论语料里它多高频。 - Decoder 必须与 Pre-tokenizer 配对。
ByteLevel对ByteLevel、Metaspace对Metaspace,错配的直接症状是解码结果里残留Ġ或▁。 - Normalizer 是有损的。
decode(encode(x)) == x只在归一化为恒等或输入已处于目标范式时成立。
从零训练一个 byte-level BPE
完整可运行示例,语料内联,不依赖网络。Model 选 BPE 算法原理 的 merge 机制,Pre-tokenizer 选 BBPE 字节级分词 的字节方案,因此不需要 unk_token。
from tokenizers import Tokenizer, models, normalizers, pre_tokenizers, decoders, processors, trainers
corpus = [
"Tokenizer 把文本切成离散单元,是大模型的唯一入口。",
"byte-level BPE 用 256 个字节符号兜底,不需要 unk token。",
"vocab_size 越大,压缩比越好,embedding 层也越重。",
"offsets 让 token 级预测能映射回原文字符区间。",
"Normalizer、Pre-tokenizer、Model、Post-processor、Decoder 五段可独立替换。",
]
def iter_corpus(repeat=200):
"""train_from_iterator 接受任意可迭代对象;真实场景换成逐行读文件的生成器即可。"""
for _ in range(repeat):
yield from corpus
tok = Tokenizer(models.BPE()) # unk_token 留空:字节级方案无 OOV
tok.normalizer = normalizers.NFC() # 只做规范化,不 lowercase、不去重音
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tok.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(
vocab_size=1000,
min_frequency=2,
special_tokens=["<|endoftext|>"],
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), # 预置全部 256 字节,保证任意输入可编码
show_progress=False,
)
tok.train_from_iterator(iter_corpus(), trainer=trainer)
# ByteLevel 后处理不加 token,只把 Ġ 造成的 offset 前移一位修回来
tok.post_processor = processors.ByteLevel(trim_offsets=True)
text = "byte-level BPE 不需要 unk token。"
enc = tok.encode(text)
print(enc.tokens)
print(enc.ids)
print(enc.offsets[:4])
assert tok.decode(enc.ids) == text # 字节级方案下严格可逆
tok.save("bbpe.json")要点:
special_tokens交给 trainer,它们会占据最小的 id(0 起),便于后续pad_id硬编码。min_frequency是 merge 的准入门槛,小语料上设 2,大语料上通常留默认 0 让vocab_size单独控制规模。vocab_size达不到时不报错,只是提前停止合并——词表比目标值小往往意味着语料不足,而非参数写错。add_prefix_space=False让"World"与" World"编成不同 token。做 NER 或任何is_split_into_words=True的任务要改成True,否则除首词外每个词都会丢掉本该存在的空格前缀,与预训练分布不一致。
换 Unigram 只需替换 Model、Pre-tokenizer、Decoder 与 trainer 四行,算法差异见 WordPiece 与 Unigram LM:
tok = Tokenizer(models.Unigram())
tok.normalizer = normalizers.Sequence([normalizers.Nmt(), normalizers.NFKC()])
tok.pre_tokenizer = pre_tokenizers.Metaspace() # 空格 → ▁,与 SentencePiece 同协议
tok.decoder = decoders.Metaspace()
trainer = trainers.UnigramTrainer(
vocab_size=1000,
unk_token="<unk>", # Unigram 必须有 unk:概率模型无字节兜底
special_tokens=["<unk>", "<s>", "</s>"],
shrinking_factor=0.75, # 每轮 EM 剪掉 25% 候选
max_piece_length=16,
)
tok.train_from_iterator(iter_corpus(), trainer=trainer)Metaspace 的空格前缀开关在不同版本间改过名(早期 add_prefix_space,后来统一为 prepend_scheme),跨版本代码不要硬写该参数;其语义与 SentencePiece 的 --add_dummy_prefix 完全一致。
需要 BERT 式的 [CLS] ... [SEP] 结构时,用 TemplateProcessing 声明式描述,而不是在业务代码里手工拼 id:
tok.post_processor = processors.TemplateProcessing(
single="<s> $A </s>",
pair="<s> $A </s> $B:1 </s>:1", # :1 指定该段的 type_id
special_tokens=[("<s>", tok.token_to_id("<s>")), ("</s>", tok.token_to_id("</s>"))],
)序列化:单个 tokenizer.json
tok.save() 输出的 tokenizer.json 是自包含的:五段组件的类型与参数、完整 vocab 与 merges、added_tokens、以及 truncation / padding 配置全在里面。加载不需要任何原始训练代码:
from tokenizers import Tokenizer
tok = Tokenizer.from_file("bbpe.json")进一步接入 transformers 生态要包一层:
from transformers import PreTrainedTokenizerFast
hf = PreTrainedTokenizerFast(
tokenizer_object=tok,
eos_token="<|endoftext|>",
pad_token="<|endoftext|>",
)
hf.save_pretrained("./my-tokenizer") # 产出 tokenizer.json + tokenizer_config.json + special_tokens_map.jsontokenizer.json 只描述编解码;AutoTokenizer.from_pretrained 还依赖 tokenizer_config.json 里的 tokenizer_class 与 chat_template,这部分机制见 特殊 Token 与 Chat Template。反向路径是 convert_slow_tokenizer,把 SentencePiece 的 .model protobuf 翻译成等价的五段配置——转换后值得在真实语料上做一次全量 id 比对,历史上归一化环节出现过偏差。
offsets 与对齐
fast tokenizer 相对 slow 实现的核心增量不是速度,而是每个 token 都携带回溯到原始字符串的字符区间。Encoding 对象暴露一组双向映射:
enc = tok.encode("Hugging Face 在巴黎")
enc.offsets # [(0,7), (7,12), ...] 每个 token 的 (start, end) 字符下标
enc.word_ids # 每个 token 属于哪个 pre-token 片段,特殊 token 为 None
enc.token_to_chars(2)
enc.char_to_token(9) # 第 9 个字符落在哪个 token 里
enc.word_to_tokens(1)典型用途是把词级标签摊到 subword 上,只对每词首个 token 计损失:
words = ["Hugging", "Face", "在", "巴黎"]
labels = [1, 2, 0, 3]
enc = hf(words, is_split_into_words=True)
prev, aligned = None, []
for wid in enc.word_ids():
if wid is None:
aligned.append(-100) # 特殊 token 不参与损失
elif wid != prev:
aligned.append(labels[wid]) # 词首 token 取真实标签
else:
aligned.append(-100) # 词内后续 subword 屏蔽
prev = wid两个易踩的坑:
trim_offsets必须开。ByteLevel 把空格并入下一个 token 的Ġ,未修正时" Face"的 offset 会从空格开始,抽取出的 span 前面多一个空格。- offsets 精度受 Normalizer 限制。库通过
NormalizedString维护归一化前后的字符对齐,但StripAccents、Replace这类删改字符的规则只能给出近似区间。要求 span 与原文严格 byte 级一致的抽取任务,Normalizer 应退化为NFC或恒等。
批量编码走 tok.encode_batch(list_of_texts),Rust 侧释放 GIL 并按 rayon 线程池并发,比 Python 循环快一个量级。在 DataLoader 里 fork 子进程时会出现 The current process just got forked 警告——正确做法是显式设 TOKENIZERS_PARALLELISM=false,把并行交给 worker 进程,而不是忽略警告。整条管线在模型输入侧的位置见 Tokenizer 概述。