分词器:从手写BPE到SentencePiece
在开始动手之前,我先想清楚了自己的需求:
- 词表可控:我需要能根据自己的语料库来构建词表,而不是依赖别人预训练好的词表
- 混合支持:既要能处理中文,也要能处理英文和数字,最好能处理一些常见的标点符号
- 性能可用:虽然不需要极致性能,但至少不能太慢,毕竟可能要处理大量文本
- 可调试:我希望能看到分词的过程,知道为什么文本会被这样拆分
- 体积小:不要依赖那些几百MB的词典,我的应用场景对资源比较敏感
基于这些需求,我决定实现一个基于 BPE(Byte Pair Encoding)算法的分词器。
最近在做中文大模型训练相关的工作,发现一个让我很困扰的问题:现有的分词工具对中文的处理总是差那么一点点意思。
背景:为什么我需要自己实现分词
最近在做中文大模型训练相关的工作,发现一个让我很困扰的问题:现有的分词工具对中文的处理总是差那么一点点意思。要么分词粒度太粗,丢失了语义边界;要么分词太细,把本来应该在一起的词拆得七零八落。
更让人难受的是,很多开源的中文分词器要么依赖庞大的词典,要么对专有名词识别效果很差。我的场景里经常会出现一些特定领域的术语,用通用分词器处理时,这些术语总是被错误地拆分。
比如说"机器学习"这个词,有的分词器会把它拆成"机器"和"学习",但实际上在很多技术文档中,“机器学习"应该作为一个整体来处理。再比如"Transformer架构”,如果被拆成"Transformer"和"架构",在某些上下文下会丢失原本的含义。
所以我就想,为什么不自己实现一个分词器呢?这样既能针对我的场景做优化,又能把整个过程搞得明明白白。
需求:我到底想要什么样的分词器
在开始动手之前,我先想清楚了自己的需求:
- 词表可控:我需要能根据自己的语料库来构建词表,而不是依赖别人预训练好的词表
- 混合支持:既要能处理中文,也要能处理英文和数字,最好能处理一些常见的标点符号
- 性能可用:虽然不需要极致性能,但至少不能太慢,毕竟可能要处理大量文本
- 可调试:我希望能看到分词的过程,知道为什么文本会被这样拆分
- 体积小:不要依赖那些几百MB的词典,我的应用场景对资源比较敏感
基于这些需求,我决定实现一个基于 BPE(Byte Pair Encoding)算法的分词器。BPE 算法在自然语言处理中已经被广泛使用,效果也经过了验证。
实现:从零开始实现 BPE 分词器
第一步:预处理文本
BPE 算法的第一步是将文本转换成一个字符序列。对于中文来说,每个汉字就是一个字符;对于英文来说,每个字母就是一个字符;对于数字和标点符号也是如此。
我先写了一个简单的预处理函数:
def preprocess_text(text):
# 统一转换为小写,方便英文处理
text = text.lower()
# 简单的分字处理
chars = list(text)
return chars
这个函数很简单,但有个问题:它把所有的标点符号都当作独立的字符了。这在某些情况下没问题,但如果有些标点符号经常成对出现(比如引号、括号),就应该让它们有机会合并成一个单元。
第二步:构建初始词表
有了预处理后的字符序列,接下来就需要统计字符出现的频率,构建初始词表。
def build_initial_vocab(corpus):
vocab = {}
for word in corpus:
chars = list(word)
for char in chars:
if char not in vocab:
vocab[char] = 1
else:
vocab[char] += 1
return vocab
这里的逻辑也很简单:遍历所有文本,统计每个字符出现的次数。字符出现的频率越高,它在后续合并过程中就越容易被选中。
第三步:实现 BPE 合并算法
这是 BPE 算法的核心部分。它的基本思想是:不断地将出现频率最高的相邻字符对合并成一个新的字符,直到达到预设的合并次数或者词表大小。
def get_pair_counts(words):
pairs = {}
for word in words:
for i in range(len(word) - 1):
pair = (word[i], word[i+1])
if pair not in pairs:
pairs[pair] = 1
else:
pairs[pair] += 1
return pairs
def merge_pair(words, pair):
new_word = []
i = 0
while i < len(words):
if i < len(words) - 1 and words[i] == pair[0] and words[i+1] == pair[1]:
new_word.append(pair[0] + pair[1])
i += 2
else:
new_word.append(words[i])
i += 1
return new_word
def bpe_train(corpus, num_merges):
vocab = build_initial_vocab(corpus)
words = [list(word) for word in corpus]
merges = []
for i in range(num_merges):
pair_counts = get_pair_counts(words)
if not pair_counts:
break
best_pair = max(pair_counts.items(), key=lambda x: x[1])[0]
merges.append(best_pair)
new_word = []
for word in words:
new_word.append(merge_pair(word, best_pair))
words = new_word
return merges
这个实现有几个关键点:
get_pair_counts函数统计所有相邻字符对的出现频率merge_pair函数将指定的字符对合并成一个新的字符串bpe_train函数循环执行合并过程,记录每次合并的字符对
第四步:使用训练好的分词器
训练完成后,我就可以使用这些合并规则来对新的文本进行分词了:
def tokenize(text, merges):
if not text:
return []
word = list(text.lower())
while len(word) > 1:
pairs = set([(word[i], word[i+1]) for i in range(len(word) - 1)])
best_pair = None
for pair in merges:
if pair in pairs:
best_pair = pair
break
if best_pair is None:
break
word = merge_pair(word, best_pair)
return word
这个函数的逻辑是:给定一段文本,先用训练好的合并规则不断地合并字符对,直到没有可以合并的字符对为止。
踩坑:那些让我头秃的问题
虽然 BPE 算法本身不复杂,但在实际实现过程中,我遇到了不少问题。
问题一:未知字符处理
最让我头疼的是未知字符。当输入文本中出现训练词表中没有的字符时,我的分词器直接报错了。
我刚开始的处理方式很简单:遇到未知字符就跳过。但这样做的后果是,文本中经常出现一些莫名其妙的空缺,尤其是处理一些特殊符号时。
后来我想了个办法:对于未知字符,就保持原样,不参与合并过程。这样至少不会丢失信息。
def tokenize_with_unknown(text, merges):
if not text:
return []
word = list(text.lower())
# 检查所有字符是否都在词表中
all_chars = set()
for pair in merges:
all_chars.add(pair[0])
all_chars.add(pair[1])
# 过滤掉未知字符
word = [char for char in word if char in all_chars or char.isspace()]
while len(word) > 1:
pairs = set([(word[i], word[i+1]) for i in range(len(word) - 1)])
best_pair = None
for pair in merges:
if pair in pairs:
best_pair = pair
break
if best_pair is None:
break
word = merge_pair(word, best_pair)
return word
问题二:合并顺序问题
BPE 算法的另一个问题是合并顺序。如果两个字符对的出现频率相同,先合并哪个会影响最终的分词结果。
我发现这个影响在某些情况下还挺明显的。比如说,如果"ab"和"bc"的出现频率相同,那么先合并"ab"还是先合并"bc",会得到不同的词表。
这个问题我没有完美的解决方案,只能根据实际应用场景来调整。对于我的应用场景,我选择优先合并看起来更有语义的字符对。
问题三:词表大小控制
BPE 算法需要预设合并次数或者词表大小。如果词表太小,分词粒度就会太粗;如果词表太大,就会浪费存储空间,也可能出现过拟合的问题。
我一开始设定的是合并 1000 次,但发现这样生成的词表中有大量只出现一次的词对,几乎没有实际价值。
后来我改用了一个动态策略:每次合并后检查新生成的词对在语料库中的出现次数,如果次数低于某个阈值,就停止合并。
def bpe_train_dynamic(corpus, min_count=2):
vocab = build_initial_vocab(corpus)
words = [list(word) for word in corpus]
merges = []
while True:
pair_counts = get_pair_counts(words)
if not pair_counts:
break
best_pair = max(pair_counts.items(), key=lambda x: x[1])[0]
if pair_counts[best_pair] < min_count:
break
merges.append(best_pair)
new_word = []
for word in words:
new_word.append(merge_pair(word, best_pair))
words = new_word
return merges
结果:效果如何
经过一段时间的调试和优化,我的分词器终于能正常工作了。我用它处理了一些技术文档,效果还算满意。
分词效果对比
先看看对一些常见术语的处理效果:
| 原文 | 通用分词器 | 我的分词器 |
|---|---|---|
| 机器学习 | 机器/学习 | 机器学习 |
| 深度神经网络 | 深度/神经/网络 | 深度/神经网络 |
| Transformer架构 | Transformer/架构 | Transformer架构 |
| 自然语言处理 | 自然/语言/处理 | 自然语言处理 |
可以看到,我的分词器在处理这些术语时,能更好地保持语义完整性。
性能对比
我也对比了一下性能:
| 指标 | 通用分词器 | 我的分词器 |
|---|---|---|
| 词表大小 | ~10000 | ~3000 |
| 分词速度 | 1000 词/秒 | 800 词/秒 |
| 内存占用 | ~50MB | ~10MB |
虽然速度稍微慢一点,但词表大小和内存占用都有明显优势。
实际应用
我把这个分词器用在了自己的一个项目上:一个针对技术文档的搜索引擎。相比之前使用的通用分词器,搜索相关性有了明显提升,特别是对技术术语的搜索效果更好。
延伸:从手写 BPE 到工业级分词器
自己实现 BPE 让我理解了算法的本质,但在实际项目里,更多时候我们会直接用成熟的分词器。这里补充几种主流方案,以及我在多语种项目里踩过的迁移坑。
WordPiece:BERT 背后的分词器
WordPiece 和 BPE 思路相近:训练时统计词频,逐步合并高频子词,直到词表达到预设大小(BERT 用的是 30522 个 token)。但它在实际使用中有几个隐含问题:
- 不在词表里的字符处理不当:生僻字、emoji、特殊符号往往被直接丢弃或转成
[UNK],信息丢失。 - 语种偏移:BERT-base-chinese 的词表是按中文训练的,其他语种进来后,很多词被切得太细,或干脆变成
[UNK]。 - 空格和标点处理不一致:英文空格会被保留,中文空格有时被吞掉,有时又变成奇怪的 token。
WordPiece 的中文分词效果,可以用 BERT 自带的分词器直观看到:
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
tokens = tokenizer.tokenize("我在学习自然语言处理")
# 输出: ['我', '在', '学', '习', '自', '然', '语', '言', '处', '理']
我第一次真正感到 WordPiece 的局限,是在处理泰语数据时。泰语没有显式的词边界,WordPiece 把它当成一串连续字符,结果 token 数是英文的 3 倍。
用 HuggingFace tokenizers 训练 BPE
BPE 的好处是不依赖预定义词表,而是从数据里学出来。用 HuggingFace 的 tokenizers 库可以很方便地训练一个 BPE 分词器:
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 还覆盖不全)、中文标点偶尔被拆散、以及原模型要重新训练、无法直接迁移词表。
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:打乱输入顺序,避免训练顺序偏差。
在 Python 里使用也很直接:
import sentencepiece as spm
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)
# 输出: ['▁', '𠮷', '𠮷', '𠮷']
# 泰语
tokens = sp.encode("สวัสดีครับ", out_type=str)
# 输出: ['▁สวัสดี', 'ครับ']
这里的 ▁ 是 SentencePiece 的空格 token——它把空格也编码进去了,多语种的空格处理因此被统一。
分词器迁移的几个坑
从一个分词器换到另一个,不是直接换工具那么简单:
1. 词表映射:原模型用 WordPiece 训练,词表结构和 SentencePiece 不一样,直接替换会导致模型加载失败,需要建立 token 到新索引的映射。
2. 特殊 token 不一致:WordPiece 用 [UNK]/[CLS]/[SEP]/[PAD]/[MASK],SentencePiece 通常用 <unk>/<s>/</s>/<pad>/<mask>。要在 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 限制可能不够,要么调大上下文窗口,要么训练时截断更短。
4. 性能开销: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
split_by_unicode_script=true:按 Unicode 脚本分割,中文和英文分开处理。split_by_number=true:数字单独分割,避免数字和字母混在一起。split_by_whitespace=true:空格也分割,和英文的空格处理保持一致。
tokens = sp.encode("Hello 世界 สวัสดี 123", out_type=str)
print(tokens)
# 输出: ['▁Hello', '▁世界', '▁สวัสดี', '▁123']
一句话总结:如果只是中英文,WordPiece 够用;一旦涉及多语种,SentencePiece 是更合理的选择。分词器不是配角——它决定了模型能"看到"什么。
结语
这次自己实现分词器的经历让我对 BPE 算法有了更深入的理解。虽然最终的实现可能不如那些成熟的工具强大,但整个过程中学到的东西是很有价值的。
分词看似简单,实则蕴含着很多细节和技巧。不同的应用场景需要不同的分词策略,没有放之四海而皆准的解决方案。有时候,自己动手实现一个简单的版本,反而能更好地理解问题的本质,也能更灵活地针对自己的需求做调整。
如果你也在处理类似的文本处理问题,不妨也试试自己实现一个分词器。即使最终还是要用那些成熟的工具,这个过程本身也是很有意义的。
本文整合了关于分词器(Tokenization)的多篇笔记。
版权声明: 本文首发于 指尖魔法屋-分词器:从手写BPE到SentencePiece(https://blog.thinkmoon.cn/post/344-ai-tokenization-text-token-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。