关于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 的词表。

但它的实现有几个隐含问题:

  1. 对不在词表里的字符处理不当:比如一些生僻字、emoji、特殊符号,WordPiece 往往直接丢弃或者转成 [UNK],导致信息丢失。

  2. 语种偏移:BERT-base-chinese 的词表是中文训练的,其他语种进来之后,很多词被切得太细,或者干脆被当成 [UNK]

  3. 空格和标点处理不一致:英文里的空格会被保留,但中文的空格有时候被吞掉,有时候又变成奇怪的 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 数下来了。但又有新问题:

  1. 词表膨胀:50000 的词表已经很大了,但仍然覆盖不全。有些语种的小众词还是被切成碎片。

  2. 空格和标点:BPE 对空格和标点的处理比 WordPiece 好一些,但中文的标点有时候会被拆散,比如 变成 和一个奇怪的空格 token。

  3. 迁移成本:原来用 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!