☰
Tokenizer改写实践:从增量改造到重训词表的完整指南
2026/10/15 12:18:25 网站建设 项目流程

上个月做一个医疗文本分类项目的时候,我只换了任务数据,模型结构和训练参数都没动,结果一跑推理,输出里全是[UNK]。随便抽一条病历,“冠状动脉粥样硬化性心脏病”被切成了七八个碎片,里面还混着两个“未知字符”。我一开始怀疑是数据清洗有问题,查了一圈,清洗流程没问题、模型权重没问题,最后把矛头指向了最不起眼的环节——tokenizer。

那个下午我基本没干别的,全部时间都花在研究怎么“改写”transformers 里的 tokenizer。后来我把这次经历整理成了一套可以复用的方法,从判断要不要改、改哪一层,到增量改造、重训词表、踩坑和验证,全都走了一遍。这篇文章就是给准备对 tokenizer 下手的人写的:看完你会知道什么时候该改、改的时候碰哪些 API、改完以后怎么证明自己没白干。

1. 为什么放着现成的 tokenizer 不用,非要自己改写

1.1 你其实是在给模型“换词典”

很多人把 tokenizer 当成一个普通的文本切分工具,觉得它只是“把字符串变成数字”的搬运工。这么想会漏掉一个关键问题:预训练模型的 embedding 矩阵是按当时词表的大小定死的。tokenizer 把文本切成一串 token id,模型第一层就拿着这些 id 去查 embedding 表。如果你改了 tokenizer,等于换了一本词典,但模型手里还攥着旧词典的页码,两边对不上。

说白一点,tokenizer 是模型语义空间的入口。同一个词,切成“心肌+梗死”这个粒度,模型还能把两个子词的信息拼起来;切成“心+肌+梗+死”,语义信息基本就散了。所以改写 tokenizer 不是简单的“加几个词”,而是决定模型还能不能看懂你喂给它的文本。

1.2 什么信号说明该动手了

我总结了一套比较实用的判断信号,出现两三条以上,就值得认真考虑改写:

  • UNK 比例异常高。正常的中文通用场景,[UNK]应该是零星出现的。我那个项目里,部分病历文本的 UNK 占比接近 3%,这说明词表根本覆盖不了这个领域。
  • 平均序列长度异常长。一个词本来可以一次到位,结果被拆成六七个 token,同样的文本喂进去序列变长,训练和推理成本都上去了,模型还更容易丢信息。
  • 专业词汇被切得支离破碎。对比一下 tokenizer 切“冠状动脉”出来的片段,如果连“冠”“状”“动”都各成一家,基本可以判断词表缺乏领域词。
  • 需要新增特殊标记。现在很多任务是对话、指令、结构化抽取,要在文本里插入角色分隔符、指令起始符、字段边界符这类特殊 token,原来的词表里没有,就得扩。
  • 文本里大量出现预训练阶段没见过的符号。比如分子式C6H12O6、SMILES 序列、代码片段、表格分隔符、emoji,这些符号默认切法通常很糟糕。

1.3 什么时候千万别动

改写 tokenizer 不是越勤越好。如果你的语料就几万条,任务也很简单,只是偶尔蹦出个把 UNK,优先做文本替换和归一化预处理,不要动词表。改 tokenizer 是“牵一发而动全身”的事:词表变了,embedding 矩阵要跟着变,后续微调的成本和风险都会上升。没有足够的验证数据和时间做回归测试,我建议你忍一忍。

我自己的判断标准是:只有 UNK 或碎片化切分已经明显影响下游效果时,才值得动手。先修数据,再考虑换词典。

2. 动刀之前,先弄明白 tokenizer 的内部结构

2.1 五段式流水线:从字符到 token ID

transformers 里的 tokenizer,不是“一个函数”那么简单,它是一条流水线。从文本进去到 token id 出来,中间要经过五个环节:

  1. Normalizer(归一化器):处理全角半角、大小写、Unicode 等价字符。中文文本常见的问题是全角逗号“,”和半角逗号“,”,归一化器会把它们统一。
  2. PreTokenizer(预分词器):按空格、标点、数字等规则把文本切成“预词块”。这一步决定了大粒度的边界。
  3. Model(核心模型):BPE、WordPiece、Unigram 这类算法,把预词块进一步切成子词。这是整个 tokenizer 的大脑。
  4. PostProcessor(后处理器):在首尾或者指定位置插入特殊 token,比如[CLS]、[SEP]、角色标记。
  5. Decoder(解码器):把 token id 还原成文本,跟 Model 的粒度一一对应。ByteLevel 这类解码器还要做字节还原。

这么拆开看就清楚多了:你所有的改写需求,本质都是在替换或调整这五段中的某一段。加词是在动 Model 的词表,加特殊 token 要动 PostProcessor,文本老是出现怪字符要动 Normalizer。

2.2 BPE、WordPiece、Unigram,三套咬合逻辑

很多人只知道“分词算法”这个笼统概念,其实三套核心算法的逻辑差别很大:

算法基本思想合并方式常见使用场景
BPE从一个字符集出发,反复合并出现频次最高的相邻符号对按频次生成式模型、代码、多语言
WordPiece每次合并能最大限度提升语言模型概率的符号对按概率增益早期编码器模型
Unigram从一个很大的词表出发,按删除后 loss 增加最小的原则剪枝按 loss 增量需要更精细词表的场景

选哪个没有绝对对错。我的经验是:代码和推理任务优先 BPE,因为它对未知符号的兜底能力强;需要高质量词表且不介意训练稍慢,Unigram 是不错的选择;如果是中文领域,无论哪种算法都要注意字符级兜底,否则生僻字很容易变成 UNK。

2.3 花五分钟把现有 tokenizer“解剖”一遍

动手改之前,先看看手里这个 tokenizer 到底长什么样:

from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("你的底座模型") # 看看词表多大、特殊 token 有哪些 print("词表大小:", len(tokenizer)) print("特殊token:", tokenizer.special_tokens_map) # 解剖各段组件(fast tokenizer 才有 backend_tokenizer) bt = tokenizer.backend_tokenizer print("Normalizer:", bt.normalizer) print("PreTokenizer:", bt.pre_tokenizer) print("Model:", bt.model) # 看一个领域词被切成了什么 for word in ["冠状动脉粥样硬化", "心肌梗死", "随访率"]: print(word, "->", tokenizer.tokenize(word))

这一步非常关键。我那次就是先看到“冠状动脉”被拆得稀碎,才确认问题在词表覆盖,而不是归一化或预分词环节。先诊断再下药,别上来就重训。

3. 温和方案:在预训练 tokenizer 上增量改造

如果只是词表不够用,没必要推翻重来。增量改造是成本最低、风险最小的方案。

3.1 add_tokens 和特殊 token 的正确加法

增量改造的核心 API 就两个:add_tokens和add_special_tokens。很多人混用,其实两者差别很大。

add_tokens用来加普通词,加进去的词参与正常的编码和解码;add_special_tokens用来加特殊标记,这些 token 会被记录到special_tokens_map里,解码时可以用skip_special_tokens=True跳过。

# 先过滤:如果这个词已经能被完整切出来,就不用重复加 def need_add(word, tokenizer): return len(tokenizer.tokenize(word)) != 1 candidates = ["冠状动脉粥样硬化", "肌钙蛋白", "随机对照试验", "随访率"] to_add = [w for w in candidates if need_add(w, tokenizer)] added = tokenizer.add_tokens(to_add) print("实际新增:", added) # 加特殊标记要单独走 add_special_tokens tokenizer.add_special_tokens({"additional_special_tokens": ["<OUTCOME>", "<DRUG>"]})

这里有个很容易踩的坑:先判断这个词是不是真的需要加。如果 tokenizer 已经能把“冠状动脉”切成一个整体,你再加一遍,词表多了一个重复项,完全没意义。我习惯用len(tokenizer.tokenize(word)) != 1来判断。

3.2 别忘了把 Embedding 层跟着变大

这是增量改造里翻车率最高的地方。很多人加完词直接跑,结果报错说 embedding 矩阵和词表大小对不上。

原因很简单:原模型的 embedding 矩阵是(原词表大小, hidden_size),你加了几个 token,矩阵行数不够用了。解决办法是用一个现成的 API:

model.resize_token_embeddings(len(tokenizer))

注意:resize_token_embeddings会把新区间的向量初始化成随机值。默认随机分布的方差如果太大,新词的 embedding 会和旧词冲突,训练一开始 loss 容易飙升。我一般手动把新增部分的方差压小,或者用下面这种更稳的初始化方式:

import torch old_size = model.get_input_embeddings().weight.shape[0] new_size = len(tokenizer) if new_size > old_size: model.resize_token_embeddings(new_size) with torch.no_grad(): new_rows = model.get_input_embeddings().weight[old_size:] new_rows.normal_(mean=0.0, std=0.02)

0.02这个 std 不是拍脑袋定的,很多预训练模型初始化 embedding 时用的就是 0.02 量级,跟已有向量的尺度比较接近。

3.3 增量改造的边界:词表膨胀失控

增量改造不是无限加词的借口。每加一个 token,embedding 矩阵就多一行,模型参数跟着涨,训练和推理都会变慢。更麻烦的是,新词如果加得太多,旧词表主导的语义分布会被稀释,微调稳定性反而下降。

我给自己定过一条线:单次增量改造增加的 token 数不超过原词表的 2%。如果候选词超过这个量级,说明你的领域和预训练语料差得太远,增量改造补不过来,应该考虑换方案了。另外,加词之前最好做一轮词频统计,只加出现次数足够多、切分又确实不合理的高频词,不要一上来把所有专业术语都堆进去。

4. 彻底方案:用领域语料重新训练 tokenizer

增量改造解决不了“整个词表结构和预训练语料差异太大”的问题。我的医疗场景就是这样:光加几十个词不够,病历文本里大量缩写、药名、英文混杂,必须用领域语料重新训一套词表。

4.1 用 tokenizers 库的 Trainer 训练新词表

transformers 的 fast tokenizer 底层是 tokenizers 库,我们可以直接用它来训练新词表:

from tokenizers import Tokenizer, models, trainers, pre_tokenizers from tokenizers.normalizers import NFKC from tokenizers.decoders import ByteLevel # 以 BPE 为基础重新训练 tokenizer = Tokenizer(models.BPE()) tokenizer.normalizer = NFKC() # 代码或混合文本建议用 ByteLevel,能兜住所有字符 tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel() tokenizer.decoder = ByteLevel() trainer = trainers.BpeTrainer( vocab_size=32000, min_frequency=2, special_tokens=["[UNK]", "[PAD]", "[CLS]", "[SEP]"], show_progress=True, ) texts = [] # 这里读入你的领域语料,最好按行拆成字符串列表 for line in open("domain_corpus.txt", encoding="utf-8"): line = line.strip() if line: texts.append(line) tokenizer.train_from_iterator(texts, trainer=trainer) tokenizer.save("new_tokenizer.json")

这里最核心的一点:special_tokens的顺序不是随便排的。它在训练时决定了这些 token 的 id 顺序,[UNK]、[PAD]这种必须放在最前面,保证 id 稳定。我在第一次训练时把特殊 token 放在了末尾,结果加载后[UNK]的 id 变到一个很大的数,模型预测填空时全乱了。

4.2 训练参数的取舍:vocab_size、min_frequency、pre_tokenizer

vocab_size是最难拍的数字。我的建议是:尽量接近底座模型原来的词表大小。很多人觉得词表越大越好,其实不然。词表太大,embedding 矩阵跟着变大,参数量上去一大截;词表太小,碎片化又会变严重。我用的底座模型词表是 30k 量级,新词表也设在 32k,这样后续要把旧 embedding 迁移过来时,维度不会差太远。

min_frequency控制低频词过滤。中文领域语料我一般设 2 到 5,太低会收进一堆只出现一次的噪声,太高会丢掉真正有用的低频术语。具体要看你的语料量:语料越大,这个阈值可以越高。

pre_tokenizer的选择也直接决定词表质量。有大量代码、URL、混合英文的语料,优先 ByteLevel;如果是纯中文,可以考虑空格分词为主、字符兜底的方案。我那个场景里病历夹杂大量英文缩写和数字,ByteLevel 明显比 Whitespace 好用。

语料准备往往比参数更影响结果。去重是必须的,否则高频词被重复文本扭曲;各类文本要尽量均衡采样,不要全是某一个科室的病历。我吃过一次亏:语料里 70% 是同一类报告,训出来的词表对那类文本极好,换个科室就差很多。

4.3 从 tokenizers 模型到 transformers 的加载

训练完的 tokenizer 是一个独立的 tokenizers 对象,要包装成 transformers 的PreTrainedTokenizerFast才能配合AutoModel用:

from transformers import PreTrainedTokenizerFast new_tokenizer = PreTrainedTokenizerFast( tokenizer_file="new_tokenizer.json", bos_token="[CLS]", eos_token="[SEP]", unk_token="[UNK]", pad_token="[PAD]", ) new_tokenizer.save_pretrained("my_domain_tokenizer")

一个容易忽略的细节:PreTrainedTokenizerFast的tokenizer_file之后,还要把特殊 token 的 id 跟原模型对齐。如果你的底座模型原来[PAD]的 id 是 0,而新训练的词表把[UNK]放到了 0,加载之后模型的 pad 行为会完全错乱。加载完建议立刻手动确认几个关键 id:

for name in ["unk", "pad", "cls", "sep"]: token_id = getattr(new_tokenizer, name + "_token_id") print(name, token_id)

5. 改写之后最容易翻车的三个环节

5.1 新词的 Embedding 初始化,别一股脑全随机

增量改造时,新增 token 的 embedding 是随机初始化的,这在词表动得不多时问题不大。但如果你用新训练的词表替换了大部分原词表,再全部随机初始化,模型基本等于从头学,微调几轮根本不够。

比较稳的做法是“按重叠词迁移 + 新词从相似词继承”。具体来说:新旧词表里一样的 token,直接复制原模型的 embedding;旧词表里没有的新 token,尝试从切分接近的已有词继承初始向量。比如新词表里有“心肌梗死”,旧词表里虽然没有,但旧词表有“心肌”和“梗死”,可以用这两个词的 embedding 均值做初值。

import numpy as np def init_new_embeddings(model, old_tokenizer, new_tokenizer): old_emb = model.get_input_embeddings().weight.detach().cpu().numpy() new_emb = np.random.normal(0, 0.02, (len(new_tokenizer), old_emb.shape[1])) old_vocab = set(old_tokenizer.get_vocab()) for token, new_id in new_tokenizer.get_vocab().items(): if token in old_vocab: new_emb[new_id] = old_emb[old_tokenizer.convert_tokens_to_ids(token)] return torch.from_numpy(new_emb).float()

这个逻辑不复杂,但能显著降低微调初期的 loss 抖动。我在重训词表后用这个方式初始化,第一轮 loss 比纯随机低了差不多三分之一。

5.2 tokenizer.json 才是真相

改完词表,很多人只在内存里确认结果,没意识到保存下来才是真的。save_pretrained会生成tokenizer_config.json、special_tokens_map.json和tokenizer.json三个文件。其中tokenizer.json是 fast tokenizer 的完整序列化结果,保存了词表、归一化规则、预分词规则、后处理规则。

我踩过一个很隐蔽的坑:在一个环境里用save_pretrained保存后,换到另一个环境重新加载,发现特殊 token 的行为变了。排查到最后是 tokenizers 库的版本差异导致解析tokenizer.json时对某些字段的处理不一样。所以保存后一定要在干净环境里重新加载一遍,跑两条编码解码用例确认。

另外,如果你只调了 Python 层面的tokenizer.add_tokens,没调用save_pretrained,那下次加载时改动全部丢失。这个听起来像废话,但确实有同事因为顺手写了个脚本、忘了保存,白忙了半天。

5.3 fast 与 slow 路径的隐藏差异

transformers 里的 tokenizer 有 fast(Rust 后端)和 slow(Python 后端)两条实现。大部分默认加载的是 fast,但有些情况下会回退到 slow。两条路径行为差异不小:

  • 增量 token 的同步问题:fast tokenizer 的核心词表存在backend_tokenizer里,你在 Python 层add_tokens,如果处理不当,后端词表没有同步,编码时新 token 直接变 UNK。官方提供的add_tokens方法会同步,但如果你直接操作底层tokenizer._tokenizer.model,就要小心。
  • 解码行为差异:ByteLevel 解码在 fast 和 slow 下对空格的处理不一样,同一个文本 fast 解码完美还原,slow 可能多出空格。
  • padding/truncation 的参数位置:fast 路径支持更丰富的截断策略,比如只截断左侧或者右侧;slow 路径对某些组合支持不全,容易直接报错。

我的建议很粗暴:能统一用 fast 就全部用 fast。如果遇到requires a backend之类的提示,说明后端缺失,优先修复环境,而不是换回 slow。

6. 怎么判断这次改写是成功还是白干

改完 tokenizer,不能只看“新词能切出来了”就收工。我用四层验证来判断一次改写到底有没有价值。

6.1 往返一致性测试

最基础的一关,tokenizer 改完必须保证“编码→解码”能还原文本:

samples = [ "冠状动脉粥样硬化性心脏病患者需长期随访", "用药剂量调整为每天一次,每次50mg", "C6H12O6 是葡萄糖的分子式", ] for s in samples: out = tokenizer.decode(tokenizer.encode(s), skip_special_tokens=True) print(s == out, s, "=>", out)

注意,因为归一化规则的存在,全角标点可能被转成半角,所以完全相等不是在所有场景都成立。你要看的是语义级还原:不该丢的字符别丢,不该合并的别合并。如果出现 decode 之后乱码、丢字,说明 Normalizer 和 Decoder 不匹配,比如用了 NFKC 归一化但解码端没有做逆变换。

6.2 用压缩率与 OOV 率说话

光测几条样例不够,要在留出的验证集上做量化对比。我一般看三个指标:

  • UNK 率:含[UNK]的文本条数除以总条数,越低越好。
  • 平均每句 token 数:衡量碎片化程度,下降说明切分更合理。
  • 平均 token 字符长度:上升说明一个 token 能承载更多信息。

我那次改写的实测对比,数据供参考:

指标改造前增量改造后重训后
UNK 率3.2%0.6%0.4%
平均每句 token 数38.532.131.2
平均 token 字符长度1.72.42.6

重训词表在压缩率上略胜,但增量改造已经解决了绝大多数 UNK 问题。这个结果说明:如果 UNK 是你的主要痛点,增量改造就够了,没必要冒险重训。

6.3 用下游任务验证,别只看分词好看

最后也是最重要的一关:在真实下游任务上做 A/B。固定训练参数,分别用旧 tokenizer 和新 tokenizer 跑同一个分类或生成任务,对比验证集指标。

我个人的体会是:分词变好看不直接等于效果变好。有一次重训词表以后,OOV 率降了很多,但下游分类准确率反而掉了 0.8 个点,原因是新词表把一些本来切分合理的通用词合并成了领域词,模型在这个任务上反而少了泛化信息。后来我把增量和重训两套方案都保留,逐任务选择。

基于这些经历,我现在的默认策略是:先用增量改造解决 UNK,跑一轮下游验证;不够再上重训,重训务必做 embedding 初始化和 A/B 对比。改写 tokenizer 不是终点,证明它有效才是终点。验证到位的词表,才有资格进入你的模型仓库。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询