BPE算法详解:从原理到实现,掌握NLP子词分词核心技术

发布时间:2026/7/31 5:04:45

BPE算法详解:从原理到实现,掌握NLP子词分词核心技术 1. 项目概述为什么BPE是NLP的基石如果你最近在玩大模型或者对自然语言处理NLP有点兴趣那你肯定绕不开一个词BPE全称Byte Pair Encoding中文叫字节对编码。听起来是不是有点“编译原理”或者“计算机组成原理”那味儿别怕它其实没想象中那么玄乎。简单来说BPE就是一种把文本“切词”的方法但它切的不是我们理解的“词语”而是一种更灵活、更聪明的“子词”单元。为什么我们需要它想象一下传统的分词方法比如按空格切分英文或者用复杂规则切分中文会遇到两个大麻烦一是面对新词、网络热词比如“yyds”、“栓Q”直接傻眼二是词表会无限膨胀比如英文有“run”, “running”, “runs”三个词都要单独存。BPE就是为了解决这两个问题而生的。它通过一种统计和迭代合并的方式从最基本的字符比如英文字母a, b, c开始逐步“学习”出最常出现的字符组合形成一个新的“词表”。这个新词表里的“词”可能是完整的单词如“the”也可能是单词的一部分如“ing”、“ation”甚至是跨单词的常见组合。这样一来无论遇到多生僻的词都能用这些学到的“零件”拼出来大大提高了模型的泛化能力也控制了词表大小。现在几乎所有主流大模型GPT系列、BERT等的Tokenizer底层用的都是BPE或其变种如WordPiece、SentencePiece。所以搞懂BPE是理解现代NLP模型如何“阅读”文本的第一步。2. BPE核心原理拆解从统计合并到子词词表BPE的核心思想非常直观可以用一个简单的类比来理解拼乐高。我们最开始有一大堆最基础的、不可再分的乐高颗粒对应字符如英文字母。然后我们观察哪些颗粒最经常被粘在一起玩统计频率最高的相邻字符对就把它们永久性地粘成一个稍大一点的组合件新的子词单元。我们反复进行这个过程直到组合出足够多、足够常用的“大零件”或者词表大小达到我们设定的上限。2.1 算法步骤详解下面我们一步步拆解BPE的训练学习词表过程。假设我们有一个非常小的语料库“low lower newest widest”。步骤一初始化与基础词频统计首先我们在每个单词的末尾添加一个特殊的结束符比如/w用来标记单词边界。这很重要因为“est”在单词中间和结尾可能意义不同。然后我们把所有单词拆分成最基础的字符包括/w并统计每个“基础单元”的出现频率。初始状态low -l o w /wlower -l o w e r /wnewest -n e w e s t /wwidest -w i d e s t /w词频统计以字符为单位l: 2, o: 2, w: 3, /w: 4, e: 4, r: 1, n: 1, s: 2, t: 2, i: 1, d: 1注意这里的w出现了3次low, lower, wideste出现了4次。步骤二迭代合并最高频字节对这是BPE的核心循环。我们找出当前所有相邻的字符对或子词对中出现频率最高的那一对然后把它们合并成一个新的子词单元并更新词表。第一轮合并我们统计所有相邻对。例如在l o w /w中相邻对有(l, o),(o, w),(w, /w)。遍历所有单词后我们发现频率最高的字节对是(e, s)它在newest (n e w e s t /w)和widest (w i d e s t /w)中各出现一次总共2次。其他如(w, /w)只在low中出现1次。合并将所有的e s合并为es。更新语料newest -n e w es t /wwidest -w i d es t /w新增子词es加入词表。第二轮合并重新统计相邻对。现在(es, t)出现了2次在newest和widest中。(w, /w)依然是1次。合并将所有的es t合并为est。更新语料newest -n e w est /wwidest -w i d est /w新增子词est加入词表。注意此时es可能还会作为独立单元存在于其他未来可能出现的组合中但在这个小语料里它被est替代了。第三轮合并继续统计。现在(l, o)出现2次low, lower(o, w)出现2次low, lower。通常选择先合并的规则可以是最高频如果频率相同可以按字母顺序或任意确定规则。假设我们合并(l, o)。合并将所有的l o合并为lo。更新语料low -lo w /wlower -lo w e r /w新增子词lo加入词表。我们可以继续这个过程比如下一轮合并(lo, w)得到low再合并(low, /w)得到low/w作为一个完整的单词单元。迭代何时停止通常我们会预设两个条件之一1) 达到设定的合并次数如10000次2) 词表大小达到目标值如30000。达到条件后我们就得到了一个最终的BPE词表里面包含了从字符到各种常见子词的所有单元。2.2 编码与解码过程训练好词表后我们如何使用它来处理新文本编码Encode给定一个新单词比如“lowest”它不在我们训练语料中。首先在末尾加上/w得到“lowest/w”。然后将其拆分为最细的字符[‘l’ ‘o’ ‘w’ ‘e’ ‘s’ ‘t’ ‘/w’]。接着我们遍历词表中所有可能的子词单元按长度从长到短排序优先匹配长的。尝试将字符序列与词表中的子词进行匹配。在我们的例子中词表里有low如果之前合并出来了、est、es、lo等。我们会优先匹配到low和est因为lowest/w可以切分为lowest/w注意est包含了/w吗不在我们的词表里est是es和t的合并不一定带/w。实际上est/w可能是一个独立的单元这取决于训练。假设我们的词表里恰好有est/w这个单元因为newest/w和widest/w训练得到那么lowest/w就会被编码为[‘low’ ‘est/w’]两个ID。如果词表里没有est/w但有est和/w则可能被编码为[‘low’ ‘est’ ‘/w’]。注意编码过程通常是一个贪婪最长匹配算法。即从第一个字符开始尽可能匹配词表中最长的子串。这是BPE实现中的一个关键细节不同的实现如Hugging Face的tokenizers库可能有细微差别。解码Decode将一串子词ID转换回文本。根据ID取出对应的子词字符串。将它们直接拼接起来。将单词结束符/w替换为空格或直接移除视具体实现而定。 例如将[‘low’ ‘est/w’]拼接成“lowest/w”然后去掉/w得到“lowest”。一个重要陷阱直接拼接有时会导致歧义。比如词表中有“ab”和“c”也有“a”和“bc”。编码“abc”可能得到[‘ab’ ‘c’]解码拼接为“abc”这是正确的。但如果我们简单地将所有token用空字符串连接“a”“bc”也会得到“abc”无法区分原始输入是“abc”还是“a”“bc”。这就是为什么在BPE的许多实现中除了单词结尾的/w在非结尾的子词前会加一个特殊前缀如##或_来标记它是一个词的中间部分。例如“playing”可能被编码为[‘play’ ‘##ing’]解码时去掉##再拼接得到“playing”。这是WordPieceBERT所用的典型做法而原始BPE和SentencePiece通常用/w或字节级编码来处理这个问题。3. 从零实现BPE代码与细节剖析理解了原理我们动手实现一个简化版的BPE这能帮你彻底吃透每一个环节。我们将过程分为两部分训练学习词表和编码/解码。3.1 训练阶段代码实现import re from collections import defaultdict, Counter class SimpleBPE: def __init__(self, vocab_size1000): self.vocab_size vocab_size # 目标词表大小 self.vocab {} # 词表子词 - ID self.merges {} # 记录合并规则合并后的子词 - (部分1 部分2) self.pattern r”‘s|’t|’re|’ve|’m|’ll|’d| ?\p{L}| ?\p{N}| ?[^\s\p{L}\p{N}]|\s(?!\S)|\s” # 一个简单的预分词正则此处为示意实际可用更简单的空格分词 def _get_stats(self, word_freq): 统计当前词汇中所有相邻符号对的频率 pairs defaultdict(int) for word, freq in word_freq.items(): symbols word.split() # 此时word是空格分隔的子词序列如”l o w /w” for i in range(len(symbols)-1): pair (symbols[i], symbols[i1]) pairs[pair] freq return pairs def _merge_vocab(self, pair, word_freq): 将指定的字节对在所有词汇中合并 first, second pair new_pattern re.compile(r(?!\S)’ re.escape(first ‘ ‘ second) r’(?!\S)’) # 确保精确匹配空格分隔的pair # 更简单的实现遍历并替换 new_word_freq {} bigram first ‘ ‘ second merged first second for word, freq in word_freq.items(): new_word word.replace(bigram, merged) new_word_freq[new_word] freq return new_word_freq def train(self, text_corpus): 训练BPE词表 # 1. 预分词并添加结束符初始化词表 # 为了简化我们这里用空格分词代替复杂的预分词 words text_corpus.lower().split() # 转小写并按空格分 word_freq Counter([w ‘/w’ for w in words]) # 添加结束符并统计频率 # 初始词表是所有字符加上结束符 initial_vocab set() for word in word_freq.keys(): for char in word: if char ! ‘ ‘: # 我们的word目前没有内部空格 initial_vocab.add(char) self.vocab {token: idx for idx, token in enumerate(sorted(initial_vocab))} print(f”初始词表大小{len(self.vocab)}“) print(f”初始词表{self.vocab}“) # 将单词表示为字符序列空格分隔的字符串便于处理 word_freq_seq {} for word, freq in word_freq.items(): # 用空格将字符分开例如 “low/w” - “l o w / w ” # 注意对于‘/w’我们将其视为一个整体单元这里简化处理将其拆开为‘’‘/’‘w’‘’但更好的做法是将其作为一个特殊符号。 # 我们调整将‘/w’作为一个整体token。 chars ‘ ‘.join(list(word.replace(‘/w’, ‘’))) ‘ /w’ # “low/w” - “l o w /w” word_freq_seq[chars] freq num_merges self.vocab_size - len(self.vocab) for i in range(num_merges): pairs self._get_stats(word_freq_seq) if not pairs: break # 找到频率最高的pair best_pair max(pairs, keypairs.get) best_freq pairs[best_pair] if best_freq 2: # 如果最高频次小于2可以提前停止 print(f”最高频对频率为{best_freq}停止合并。“) break # 执行合并 first, second best_pair merged_token first second self.merges[best_pair] merged_token # 更新词表 if merged_token not in self.vocab: self.vocab[merged_token] len(self.vocab) # 在所有单词中合并这个pair word_freq_seq self._merge_vocab(best_pair, word_freq_seq) # 可选打印进度 if (i1) % 50 0: print(f”合并第 {i1} 轮: {best_pair} - {merged_token} (频率: {best_freq})“) print(f”训练完成。最终词表大小{len(self.vocab)}“) print(f”合并规则数量{len(self.merges)}“) # 我们可以查看一些高频子词 common_tokens list(self.vocab.keys())[-20:] # 最后加入的通常是高频合并结果 print(f”部分高频子词示例{common_tokens}“)代码要点与避坑指南预分词的重要性原始BPE论文是在单词级别进行合并。但在处理像中文这样没有空格分隔的语言或者处理英文中的标点、数字时我们需要一个“预分词”步骤。上述代码简化成了按空格分词。工业级实现如SentencePiece会使用一种称为“统一分割”的方法或者直接在最原始的字节/Unicode字符级别操作完全不需要预分词。结束符/w的处理添加结束符是为了区分单词边界防止跨单词的合并。在实现时要确保/w被当作一个独立的符号处理而不是被拆成 ‘‘, ‘/‘, ‘w‘, ‘‘。合并的优先级与冲突当存在多个频率相同的字节对时需要定义一个确定的选择规则例如按字母顺序。这保证了结果的可复现性。效率问题上述实现为了清晰效率不高。每次合并都需要遍历所有单词并替换字符串。工业实现会使用更高效的数据结构如将单词表示为符号列表合并操作只是修改列表中的相邻元素。词表存储最终我们需要保存两部分一是vocab子词到ID的映射二是merges合并规则。解码时merges可以用来重建编码过程但通常编码时直接使用vocab进行贪婪匹配即可。3.2 编码与解码实现class SimpleBPE(SimpleBPE): # 继承上面的训练类 def encode_word(self, word): 编码单个单词为子词ID列表贪婪最长匹配 # 添加结束符 word word.lower() ‘/w‘ # 初始化为字符列表 tokens list(word) # 获取所有子词并按长度降序排序便于贪婪最长匹配 subword_list sorted(self.vocab.keys(), keylambda x: len(x), reverseTrue) # 移除单字符中的‘/w‘因为我们已将其作为整体处理。这里需要调整。 # 更健壮的做法将‘/w‘作为一个特殊token不参与子词匹配。 # 简化处理我们假设‘/w‘已经在词表中且匹配时优先匹配它。 result [] i 0 while i len(tokens): matched False # 尝试匹配最长的可能子词 for subword in subword_list: # 检查从位置i开始是否能匹配subword # 注意subword可能是一个字符串如‘est’我们需要比较字符序列。 # 由于我们的tokens是字符列表subword是字符串需要转换。 subword_chars list(subword) if tokens[i:ilen(subword_chars)] subword_chars: result.append(self.vocab[subword]) i len(subword_chars) matched True break if not matched: # 理论上不应该发生因为至少字符本身在词表中。 # 如果发生可以回退到字节编码或未知token。 print(f”警告无法编码字符 ‘{tokens[i]}’ 使用未知token“) # 这里可以添加一个UNK token i 1 return result def encode(self, text): 编码一段文本 words text.lower().split() token_ids [] for word in words: token_ids.extend(self.encode_word(word)) return token_ids def decode(self, token_ids): 将子词ID列表解码回文本 # 构建反向词表 id_to_token {id: token for token, id in self.vocab.items()} # 将ID序列转换回子词字符串 subwords [id_to_token[id] for id in token_ids] # 拼接 text ‘’.join(subwords) # 将‘/w‘替换为空格并去除首尾可能多余的空格 text text.replace(‘/w‘, ‘ ‘).strip() return text # 使用示例 if __name__ “__main__”: # 训练 corpus ”low lower newest widest low low low“ # 重复‘low’增加其频率 bpe SimpleBPE(vocab_size50) bpe.train(corpus) # 编码新词 test_word ”lowest“ token_ids bpe.encode_word(test_word) print(f”单词 ‘{test_word}’ 编码为ID: {token_ids}“) print(f”对应的子词: {[list(bpe.vocab.keys())[list(bpe.vocab.values()).index(id)] for id in token_ids]}“) # 解码 decoded_text bpe.decode(token_ids) print(f”解码回文本: ‘{decoded_text}’“) # 测试一个不在训练集中的词 test_word2 ”higher“ token_ids2 bpe.encode_word(test_word2) print(f”单词 ‘{test_word2}’ 编码为ID: {token_ids2}“) print(f”对应的子词: {[list(bpe.vocab.keys())[list(bpe.vocab.values()).index(id)] for id in token_ids2]}“)编码解码的注意事项贪婪最长匹配算法编码函数encode_word的核心是贪婪最长匹配。它从单词开头开始总是尝试匹配词表中最长的可能子串。这个算法简单有效但并不是全局最优的可能有一种不同的切分方式使得总的token数更少。不过在实践中它工作得很好。大小写处理通常在训练前会将文本统一转为小写以减小词表大小并提高泛化能力。但有些任务如命名实体识别需要保留大小写信息这时可以不对大小写进行归一化或者将大写字母当作不同的符号处理。未知词OOV处理如果遇到一个字符或字符序列完全不在词表中怎么办健壮的实现需要有一个应对策略。常见方法包括使用UNK标记将所有未知字符映射到一个统一的未知标记。回退到字节级将未知词拆分成UTF-8字节然后用字节级的词表进行编码。这是SentencePiece等先进工具采用的方法确保了“任何文本都能被编码”彻底解决了OOV问题。解码歧义如前所述简单的拼接可能导致歧义。我们的示例代码因为使用了/w作为单词边界解码时将其替换为空格在单词内部子词间没有添加分隔符所以对于某些情况可能出错。例如如果词表中有ab和c编码abc得到[ab, c]解码拼接为abc这是正确的。但如果另一个词a和bc也存在且编码了a bc解码也会得到abc无法区分。因此更常见的做法是在非首子词前添加一个特殊符号如_或##解码时再移除。我们的简化实现忽略了这一点但在理解原理阶段问题不大。4. BPE的变种、实战对比与常见问题理解了基础BPE我们来看看它的几个著名变种以及在实际应用中会遇到哪些坑。4.1 主流变种WordPiece vs SentencePieceWordPiece 由Google提出最早用于BERT模型。其核心算法与BPE非常相似但合并字节对的标准不同。BPE合并最高频的相邻对而WordPiece合并能最大程度提升语言模型概率的相邻对。具体来说它计算并比较合并前后整个语料库的似然值likelihood的提升选择提升最大的对进行合并。不过在实际实现中有一种更简单的近似方法合并具有最大互信息值即频率(pair) / (频率(first) * 频率(second))的pair。WordPiece在编码时也使用贪婪最长匹配并在非首子词前添加##以示区别。SentencePiece 这是Google推出的一个开源工具包实现了BPE以及一种称为Unigram Language Model的子词切分算法。它的一个革命性特点是完全不需要预分词。它将输入文本直接视为Unicode字符序列甚至可以将空格也当作普通字符用_替代进行处理和编码。这意味着它可以直接处理多种语言包括中文、日文等没有空格的语言的混合文本非常干净利落。SentencePiece的训练目标可以是BPE也可以是Unigram LM。后者是一种从大词表开始逐步剪枝得到小词表的方法有时能产生更优的切分结果。如何选择如果你在使用BERT系列模型那么你已经在用WordPiece了。如果你需要从头训练一个多语言模型或者处理混合语料SentencePiece通常是更强大、更方便的选择。原始的BPE是理解所有变种的基础很多自定义场景下自己实现一个简化版BPE进行快速实验也是可行的。4.2 实战中的关键参数与调优当你使用Hugging Face的tokenizers库或SentencePiece训练自己的Tokenizer时会接触到几个关键参数词表大小vocab_size 这是最重要的参数。通常设置在几千到几万之间。例如GPT-2用了50257BERT-base用了30522。更大的词表可以更精细地表示文本压缩率更高序列更短但会导致模型嵌入层参数增加可能增加过拟合风险。更小的词表泛化能力更强但序列更长计算效率低。需要根据语料规模和任务权衡。字符覆盖范围character_coverage 主要用于SentencePiece。为了保证能编码所有文本模型会保留一个基础字符集。对于像日文这样字符集很大的语言可能需要降低覆盖率如0.9995以避免词表被大量生僻字符占据。对于英文1.0即可。是否小写lowercase 训练前是否将文本转为小写。这能显著减小词表大小但会丢失所有大小写信息。对于需要区分大小写的任务如代码生成、命名实体识别需要关闭此选项。控制符号control symbols 如[PAD],[UNK],[CLS],[SEP],[MASK]等。这些需要在训练前添加到词表中并确保它们不会被合并或拆分。4.3 常见问题与排查技巧问题一编码结果不一致或出现大量UNK可能原因训练语料和推理语料的预处理方式不一致。比如训练时做了小写化推理时没有或者训练时使用了特定的标点符号规范化规则推理时没做。排查确保训练和推理的文本预处理管道tokenization前的清洗、规范化步骤完全一致。可以打印出几条原始句子经过预处理后的样子进行对比。问题二模型在特定领域如医学、法律表现不佳可能原因通用词表如BERT的词表缺乏该领域的专业术语子词。例如“Deoxyribonucleic” 可能被切分成一堆无意义的子词丢失了语义。解决方案进行领域自适应预训练。收集大量领域文本用领域语料在原有词表上继续训练BPE合并增量学习或者完全重新训练一个领域词表。Hugging Face的tokenizers库支持增量训练。问题三生成文本时出现奇怪的空格或符号可能原因解码逻辑有误特别是对非首子词前缀如##和单词结束符的处理不当。排查手动编码几个单词再解码观察中间过程。检查解码函数是否正确地去掉了##并将/w或_转换成了空格。问题四词表很大但编码后的序列长度仍然很长可能原因语料中存在大量数字、随机字符串如产品ID、或未登录语言字符。BPE对高度随机、无模式的字符序列压缩效果很差。解决方案考虑在预处理阶段将长数字替换为特定标记如NUM。使用SentencePiece的字节回退byte fallback模式它能保证任何文本都能以字节为单位编码彻底解决OOV但序列可能会变长。对于特定类型的噪声设计规则进行清洗。一个实用的检查清单训练语料代表性你的训练语料是否足够大、足够干净并且能代表你未来要处理的数据预处理一致性训练Tokenizer和后续使用Tokenizer时文本清洗去除HTML标签、规范化标点、统一空格等步骤是否完全一致特殊标记是否添加了任务所需的所有特殊标记如[CLS],[SEP],[MASK],[PAD]它们在词表中的ID是否固定词表大小选择的词表大小是否在模型容量和效率之间取得了平衡可以通过观察词频分布有多少token很少被使用来调整。编码验证随机采样一些句子人工检查编码前后的结果是否合理生僻词是否被合理地切分成了可理解的子词BPE及其变种是现代NLP的无声基石。它用一种巧妙而高效的方式解决了开放词汇表问题让模型能够处理前所未见的词语。理解其原理不仅能帮你更好地使用预训练模型当你在面对特定领域、特定语言的任务时定制自己的Tokenizer将成为一项强大的技能。从看懂原理到动手实现再到解决实际问题这条路径上的每一步都能让你对文本如何转化为模型可理解的数字这件事有更深刻的掌控。

相关新闻