中文分词与未登录词 OOV:BPE vs WordPiece 在领域词表扩充中的踩坑
在将预训练大语言模型(如 LLaMA、Mistral)或小模型(如 BERT)落地到医疗、法律、金融等专业领域时,最先遭遇的隐形性能杀手往往是词表未登录(Out-Of-Vocabulary, OOV)与分词过度碎片化。
以开源的英文原生大模型为例,输入一段中文医疗文本“患者确诊为弥漫大B细胞淋巴瘤”,由于原生词表中严重缺乏中文字词,该短语会被强行切分成十几个单字甚至单字节(Byte)片段。这不仅导致上下文窗口被成倍浪费,还严重破坏了专业医学名词的整体语义表征。
为了解决这一问题,**领域词表扩充(Vocabulary Expansion)**成为领域模型继续预训练的第一道工序。
1. BPE 与 WordPiece 的算法差异
子词分词算法的核心目标是在固定大小的词表(Vocabulary)约束下,平衡词汇覆盖率与分词紧凑度:
- Byte-Pair Encoding (BPE):自底向上统计语料中相邻子词对的出现频次(Frequency)。每次将出现频次最高的一对子词合并为一个新 Token。直观、快速,也是 GPT 系列、LLaMA 的核心标配;
- WordPiece(BERT 采用):同样自底向上合并,但不是看纯频次,而是评估合并后能让语言模型整体似然概率(Likelihood)提升最大的子词对。其公式基于两者的互信息(Mutual Information):
$$\text{Score}(u, v) = \frac{\text{freq}(uv)}{\text{freq}(u) \times \text{freq}(v)}$$
在扩充中文词表时,BPE 更倾向于将高频成词合并,而 WordPiece 则更注重抑制低频生造词的出现。
2. 基于 SentencePiece 训练领域新词表并合并
在工程实践中,我们通常使用 Google 的SentencePiece工具包在领域语料上训练一个新的词表,并将其与原模型的 HuggingFace Tokenizer 深度融合:
import sentencepiece as spm from transformers import AutoTokenizer from typing import List def train_and_merge_domain_vocab( domain_corpus_path: str, base_model_dir: str, output_dir: str, new_vocab_size: int = 5000 ): # 1. 在领域无监督语料上训练 SentencePiece BPE 模型 spm.SentencePieceTrainer.train( input=domain_corpus_path, model_prefix="domain_spm", vocab_size=new_vocab_size, model_type="bpe", character_coverage=0.9995, pad_id=0, unk_id=1, bos_id=2, eos_id=3 ) # 加载领域词表 sp = spm.SentencePieceProcessor() sp.load("domain_spm.model") domain_pieces = [sp.id_to_piece(i) for i in range(sp.get_piece_size())] # 2. 加载基础预训练模型的 Tokenizer base_tokenizer = AutoTokenizer.from_pretrained(base_model_dir) original_vocab_len = len(base_tokenizer) # 3. 过滤并追加新 Token new_tokens_to_add = [p for p in domain_pieces if p not in base_tokenizer.get_vocab()] added_count = base_tokenizer.add_tokens(new_tokens_to_add) print(f"原词表大小: {original_vocab_len} | 成功扩充新 Token 数: {added_count}") base_tokenizer.save_pretrained(output_dir) return base_tokenizer, added_count3. Embedding 权重初始化与微调对齐
扩充词表后,模型的 Embedding 层权重形状(Shape)从 $(V_{\text{old}}, D)$ 扩大到了 $(V_{\text{new}}, D)$。
如果在初始化时将新增 Token 的 Embedding 随机填充,在微调前几步会产生极大的损失梯度,导致模型原有注意力权重被破坏。
优雅初始化策略:对于每个新增的领域 Token,先用原分词器将其切分为多个旧子词,取这些旧子词在原 Embedding 中的均值向量作为该新 Token 的初始权重:
import torch def resize_and_initialize_embeddings(model, tokenizer, old_vocab_size: int): # 扩展模型的 Embedding 矩阵大小并对齐 64 的倍数 model.resize_token_embeddings(len(tokenizer), pad_to_multiple_of=64) embeddings = model.get_input_embeddings().weight.data # 对新增加的词进行平滑均值初始化 for token, token_id in tokenizer.get_added_vocab().items(): if token_id >= old_vocab_size: # 用原分词器切分子词 sub_token_ids = tokenizer.encode(token, add_special_tokens=False) if sub_token_ids: mean_emb = embeddings[sub_token_ids].mean(dim=0) embeddings[token_id] = mean_emb4. 扩充前后压缩率与评测收益
我们在 50 万条中文电子病历(EMR)数据集上,对扩充 8,000 个专业医学词汇前后的性能表现进行了评测:
| 评估指标 | 基础 LLaMA-3 Tokenizer | 扩充医疗词表后的 Tokenizer |
|---|---|---|
| 平均 Token 压缩比 (Tokens/字符) | 1.85 (严重膨胀) | 0.62 (压缩 66.5%) |
| 等价上下文长度 | ~4,300 汉字 | ~12,900 汉字 (翻了 3 倍) |
| 预训练收敛吞吐 (汉字/秒) | 14,200 | 42,400 (加速 2.98x) |
| 医疗 NER 抽取 F1-Score | 88.2% | 92.4% (专业术语更稳定) |
5. 避坑指南
- 避免词表过度膨胀:词表过大(例如从 32k 强行扩充到 150k)会导致最后一层语言模型分类头(LM-Head)显存暴增,通常新增 5,000~10,000 个高频领域词性价比最高;
- 特殊控制符保护:在训练 SentencePiece 时,务必检查
<pad>,<s>,</s>,<unk>等 Special Tokens 的 ID 是否与原模型冲突,防止分词器行为紊乱。