关于AI分词器的几点记录
项目最初用的是 BERT 的 WordPiece 分词器,数据主要是中文和英文。
为什么又要在分词器上折腾
项目最初用的是 BERT 的 WordPiece 分词器,数据主要是中文和英文。最开始觉得挺好的:中文按字切分,英文按 subword 处理,训练出来的模型也没啥问题。
后来要加入多语种支持,比如泰语、越南语、印尼语,问题就来了。这些语言有些词挺长,WordPiece 切完之后一堆碎片,token 数爆表,上下文窗口根本不够用。更麻烦的是,一些词根词缀被切散了,模型很难学到完整的语义。
这时候开始考虑 BPE(Byte Pair Encoding)和 SentencePiece。BPE 的优势是更灵活,SentencePiece 的优势是多语种友好。但文档里的解释都很抽象,真正落地的时候又发现一堆细节要处理。
# 原来的 WordPiece 分词
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
tokens = tokenizer.tokenize("我在学习自然语言处理")
# 输出: ['我', '在', '学', '习', '自', '然', '语', '言', '处', '理']
# BPE 分词
from transformers import GPT2Tokenizer
tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
tokens = tokenizer.tokenize("我在学习自然语言处理")
# 输出: ['我', '在', '学', '习', '自', '然', '语', '言', '处', '理']
看起来一样?没错,对中文来说 WordPiece 和 BPE 在字级别切分的时候很像。但问题在多语种和生僻字上。
WordPiece 的问题在哪里
WordPiece 的核心思想很简单:训练时统计词频,然后逐步合并高频的子词,直到词表达到预设大小。BERT 用的是 30522 个 token 的词表。
但它的实现有几个隐含问题:
对不在词表里的字符处理不当:比如一些生僻字、emoji、特殊符号,WordPiece 往往直接丢弃或者转成
[UNK],导致信息丢失。语种偏移:BERT-base-chinese 的词表是中文训练的,其他语种进来之后,很多词被切得太细,或者干脆被当成
[UNK]。空格和标点处理不一致:英文里的空格会被保留,但中文的空格有时候被吞掉,有时候又变成奇怪的 token。
我第一次遇到这个问题是在处理泰语数据的时候。泰语里没有显式的词边界,WordPiece 把它当成一串连续的字符,结果出来的 token 数是英文的 3 倍。
尝试 BPE
BPE 和 WordPiece 的思路差不多,但实现上更简单:直接从字符开始,不断合并高频的字对,直到词表上限。关键是它不依赖预定义的词表,而是从数据里学出来。
from tokenizers import Tokenizer, models, trainers, pre_tokenizers
# 初始化 BPE 分词器
tokenizer = Tokenizer(models.BPE())
# 配置预处理器
tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
# 训练
trainer = trainers.BpeTrainer(
vocab_size=50000,
special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"]
)
tokenizer.train(["data/thai_train.txt", "data/indonesian_train.txt"], trainer=trainer)
# 保存
tokenizer.save("tokenizer.json")
训练出来的词表里确实多了很多多语种的 subword,泰语和印尼语的 token 数下来了。但又有新问题:
词表膨胀:50000 的词表已经很大了,但仍然覆盖不全。有些语种的小众词还是被切成碎片。
空格和标点:BPE 对空格和标点的处理比 WordPiece 好一些,但中文的标点有时候会被拆散,比如
,变成,和一个奇怪的空格 token。迁移成本:原来用 WordPiece 训练的模型要重新训练,不能直接迁移词表。
BPE 解决了一些问题,但不是全部。
SentencePiece 带来的改变
SentencePiece 是 Google 推出的,它最大的特点是把空格也当成一个普通字符,而不是像传统 NLP 那样用空格分隔词。这样一来,中文、日文、韩文这种没有空格的语言也能和英文一起训练。
# 安装 SentencePiece
pip install sentencepiece
# 训练 SentencePiece 模型
spm_train --input=data/multilingual_train.txt \
--model_prefix=spiece \
--vocab_size=50000 \
--model_type=bpe \
--character_coverage=0.9995 \
--input_sentence_size=10000000 \
--shuffle_input_sentence=true
这里的关键参数:
character_coverage=0.9995:覆盖 99.95% 的字符,对中文来说足够,但对于一些生僻字比较多的语种,可能要降到 0.999 或者更低。input_sentence_size=10000000:训练用的句子数量,太大训练慢,太小覆盖不全。shuffle_input_sentence=true:打乱输入顺序,避免训练顺序偏差。
训练出来的词表里,中文、泰语、印尼语都有合理数量的 subword,而且空格和标点被统一处理了。
import sentencepiece as spm
# 加载 SentencePiece 模型
sp = spm.SentencePieceProcessor(model_file="spiece.model")
# 编码
tokens = sp.encode("我在学习自然语言处理", out_type=str)
print(tokens)
# 输出: ['我', '在', '学', '习', '自', '然', '语', '言', '处', '理']
# 解码
text = sp.decode(tokens)
print(text)
# 输出: 我在学习自然语言处理
看起来和 WordPiece 一样?关键在于多语种和生僻字:
# 生僻字
tokens = sp.encode("𠮷𠮷𠮷", out_type=str)
print(tokens)
# 输出: ['▁', '𠮷', '𠮷', '𠮷']
# 泰语
tokens = sp.encode("สวัสดีครับ", out_type=str)
print(tokens)
# 输出: ['▁สวัสดี', 'ครับ']
这里 ▁ 是 SentencePiece 里的空格 token,它把空格也编码进去了,这样多语种的空格处理就统一了。
实际迁移的坑
从 WordPiece 迁移到 SentencePiece 不是直接换工具那么简单,有几个坑要提前准备:
1. 词表映射问题
原来的模型是用 WordPiece 训练的,词表结构和 SentencePiece 不一样。直接替换会导致模型加载失败。
# 旧词表
old_vocab = {'我': 100, '在': 101, '学': 102, ...}
# 新词表
new_vocab = {'我': 50, '在': 51, '学': 52, ...}
# 建立映射
mapping = {old: new for new, old in enumerate(old_vocab)}
实际上要更复杂一些,因为 SentencePiece 的词表里多了很多新的 token,而且原来的 token 顺序也不一样。
2. 特殊 token 处理
WordPiece 的特殊 token 是 [UNK], [CLS], [SEP], [PAD], [MASK],SentencePiece 里通常是 <unk>, <s>, </s>, <pad>, <mask>。
# SentencePiece 默认特殊 token
special_tokens = {
"<unk>": 0,
"<s>": 1,
"</s>": 2,
"<pad>": 3,
"<mask>": 4,
}
如果要在 transformers 里用,得手动指定:
from transformers import PreTrainedTokenizerFast
tokenizer = PreTrainedTokenizerFast(
tokenizer_file="tokenizer.json",
bos_token="<s>",
eos_token="</s>",
unk_token="<unk>",
pad_token="<pad>",
mask_token="<mask>",
cls_token="[CLS]", # 兼容 BERT
sep_token="[SEP]", # 兼容 BERT
)
3. 上下文长度问题
换分词器之后,同样的文本 token 数会变化。原来的 512 token 限制可能不够,或者要调整批大小。
# 原来的 WordPiece
old_length = len(old_tokenizer.encode("长文本..."))
# 489
# 新的 SentencePiece
new_length = len(new_tokenizer.encode("长文本..."))
# 412
要么调大上下文窗口,要么在训练时截断更短。
4. 性能开销
SentencePiece 的实现比 WordPiece 快,但词表更大的时候,内存和计算开销也会增加。
import time
# 测试 WordPiece 性能
start = time.time()
for _ in range(1000):
old_tokenizer.encode("测试文本...")
print(f"WordPiece: {time.time() - start}s")
# 测试 SentencePiece 性能
start = time.time()
for _ in range(1000):
new_tokenizer.encode("测试文本...")
print(f"SentencePiece: {time.time() - start}s")
实际测试下来,SentencePiece 比 WordPiece 快 20-30%,但内存占用高了 15% 左右。
最终的方案
最终我们用了 SentencePiece + BPE 的组合:
spm_train --input=data/multilingual_train.txt \
--model_prefix=spiece \
--vocab_size=80000 \
--model_type=bpe \
--character_coverage=0.9995 \
--input_sentence_size=15000000 \
--shuffle_input_sentence=true \
--split_by_unicode_script=true \
--split_by_number=true \
--split_by_whitespace=true
vocab_size=80000:比原来大一些,覆盖更多语种。split_by_unicode_script=true:按 Unicode 脚本分割,比如中文和英文分开处理。split_by_number=true:数字单独分割,避免数字和字母混在一起。split_by_whitespace=true:空格也分割,和英文的空格处理保持一致。
这样训练出来的词表,中文、英文、泰语、印尼语都有合理数量的 subword,而且空格、标点、数字都被统一处理了。
# 最终效果
tokens = sp.encode("Hello 世界 สวัสดี 123", out_type=str)
print(tokens)
# 输出: ['▁Hello', '▁世界', '▁สวัสดี', '▁123']
收尾
从 WordPiece 到 SentencePiece,不是简单的工具迁移,而是对多语言文本处理的一次重新思考。WordPiece 的设计偏重中文和英文,而 SentencePiece 从一开始就把多语种纳入考量,空格、标点、数字的处理也更统一。
但迁移的成本也不小:词表映射、特殊 token、上下文长度、性能开销,每一个都要仔细处理。如果项目只是中英文,WordPiece 够用了;一旦涉及多语种,SentencePiece 确实是更合理的选择。
折腾到现在,模型上的效果提升了 2-3 个点,但更重要的是文本处理的思路清晰了:分词器不是配角,它决定了模型能"看到"什么。
下次再有人问你为什么换分词器,别只说"效果更好",把空格和标点的处理拿出来,把多语种的词表分布摆出来,道理就清楚了。
最后,如果你也在做多语言 NLP,记得提前准备好足够的训练数据。SentencePiece 的词表质量直接依赖输入文本的多样性和规模,数据不够的时候,再好的算法也白搭。
版权声明: 本文首发于 指尖魔法屋-关于AI分词器的几点记录(https://blog.thinkmoon.cn/post/246-ai-tokenizer-wordpiece-sentencepiece-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。