深入理解分词(Tokenization)与BPE算法

张开发
2026/4/19 17:44:11 15 分钟阅读

分享文章

深入理解分词(Tokenization)与BPE算法
什么是分词分词Tokenization是自然语言处理中最基础也最重要的步骤之一。它的核心任务是将一段连续的文本切分成离散的单元tokens这些单元可以是单词、子词或字符。为什么分词如此重要在深度学习时代模型无法直接理解原始文本。无论是 ChatGPT、BERT 还是其他大语言模型它们处理的是数字而不是文字。分词就是连接人类语言和模型数字世界的桥梁原始文本: I love deep learning ↓ 分词结果: [I, love, deep, learning] ↓ 模型输入: [45, 892, 2341, 6789]分词的选择直接影响词汇表大小越大越占内存处理速度对未知词OOV的处理能力模型理解语言的能力分词策略的演进1. 字符级分词Character-level将每个字符都作为一个 tokenhello → [h, e, l, l, o]优点词汇表极小ASCII只有128个字符不会有未知词问题。缺点丢失了字符组合的语义信息序列过长计算效率低。2. 单词级分词Word-level按空格分隔每个单词是一个 tokenhello world → [hello, world]优点保留了单词的语义含义。缺点词汇表爆炸式增长无法处理未见过的新词如 tokenization vs tokenize。3. 子词级分词Subword-level—— 现代标准这是目前大语言模型普遍采用的方案将单词分解成有意义的子词单元。BPE 就是最流行的子词分词算法之一。tokenization → [token, ization]优点平衡了字符级和单词级的优势能够灵活处理新词同时保留了语义单元。BPEByte Pair Encoding算法详解BPE 最初是一种数据压缩算法后来被引入到 NLP 领域作为分词方法。其核心思想非常直观不断合并出现频率最高的字符对直到达到预设的合并次数或词汇表大小。算法原理让我们通过一个具体例子来理解 BPE 的工作原理。初始语料库假设我们的训练语料库很简单包含这些词hug, pug, pun, bun第一步我们将每个词拆分成单个字符并在末尾添加/w标记表示词的结束vocab { h u g /w: 1, p u g /w: 1, p u n /w: 1, b u n /w: 1 }第一步统计字符对频率遍历词汇表统计所有相邻字符对的出现频率字符对出现次数(h, u)1(u, g)2(p, u)2(g, )1(u, n)2(n, )2(b, u)1频率最高的字符对有多个并列第一(u, g)、(p, u)、(u, n)、(n, /w)各出现 2 次。程序会选择其中一个Python 的max()函数会按字典序选择假设选了(u, g)。第一步合并最高频字符对将(u, g)合并成ugh u g /w → h ug /w p u g /w → p ug /w p u n /w → p u n /w b u n /w → b u n /w第二步再次统计和合并现在的词汇表h ug /w: 1 p ug /w: 1 p u n /w: 1 b u n /w: 1统计新的字符对频率(u, n)出现 2 次最高将其合并为unh ug /w: 1 p ug /w: 1 p un /w: 1 b un /w: 1继续迭代...重复这个过程直到达到预设的合并次数。在代码中我们设置了num_merges 4。最终结果经过 4 次合并后我们会得到一个包含多个子词的词汇表可以用来对新的文本进行分词。代码实现解析让我们逐行分析代码实现1. 统计函数get_stats()def get_stats(vocab): 统计词元对频率 pairs collections.defaultdict(int) for word, freq in vocab.items(): symbols word.split() # 将空格分隔的字符列表化 for i in range(len(symbols)-1): # 遍历所有相邻对 pairs[symbols[i], symbols[i1]] freq # 累加频率 return pairs这个函数的作用是创建一个字典来存储所有字符对及其频率对词汇表中的每个词拆分成符号列表统计每个相邻符号对的出现次数考虑词频示例vocab {h u g /w: 2} # hug 出现了2次 pairs get_stats(vocab) # pairs {(h, u): 2, (u, g): 2, (g, /w): 2}2. 合并函数merge_vocab()def merge_vocab(pair, v_in): 合并词元对 v_out {} bigram re.escape( .join(pair)) # 转换为正则表达式模式 p re.compile(r(?!\S) bigram r(?!\S)) # 匹配完整单词边界 for word in v_in: w_out p.sub(.join(pair), word) # 替换字符对 v_out[w_out] v_in[word] # 保持原词频 return v_out这个函数的关键点re.escape()确保特殊字符被正确转义(?!\S)和(?!\S)这些是零宽断言确保只匹配完整的符号边界不会错误地合并跨词的部分(?!\S)表示前一个字符不能是非空白字符即前面是空白或字符串开始(?!\S)表示后一个字符不能是非空白字符即后面是空白或字符串结束正则表达式的威力# pair (u, g) # bigram u g # pattern r(?!\S)u g(?!\S) # 匹配h u g /w 中的 u g ✓ # 不匹配h ug /w 中的 ug ✓因为没有空格分隔3. 主循环vocab {h u g /w: 1, p u g /w: 1, p u n /w: 1, b u n /w: 1} num_merges 4 for i in range(num_merges): pairs get_stats(vocab) # 统计所有字符对频率 if not pairs: break # 如果没有可合并的对提前结束 best max(pairs, keypairs.get) # 找到频率最高的对 vocab merge_vocab(best, vocab) # 合并这个对 print(f第{i1 differentiated}次合并: {best} - {.join(best)})这个循环完成了 BPE 的核心逻辑统计 → 选择 → 合并 → 重复。BPE 的实际应用在真实的场景中BPE 的训练和应用分为两个阶段训练阶段离线收集大量文本语料库统计所有字符对频率逐步合并最高频对生成合并规则保存合并规则和最终词汇表推理阶段在线将待处理的文本拆分成字符按照训练得到的合并规则依次应用得到最终的 token 序列实际例子假设训练后得到了合并规则1. (e, r) → er 2. (t, er) → ter 3. (n, er) → ner处理新词 tokenize初始状态: t o k e n i z e /w 应用规则1: t o k er n i z e /w 应用规则2: t o k er n i z e /w (不匹配 ter) 应用规则3: t o k ner i z e /w 最终结果: [t, o, k, ner, i, z, e, /w]BPE 的优势与局限优势数据驱动自动学习语料库中的常见子词模式灵活性能够处理训练时未见过的词平衡性在词汇表大小和表达能力之间取得平衡语言无关适用于任何语言包括中文、日文等局限贪婪策略每次只选择最高频对可能不是最优解频率依赖常见词会拆分成更小的单元初始切分敏感字符级初始化可能影响最终结果现代改进BPE 有很多变体和改进版本WordPieceGoogle BERT 使用的算法优化了选择策略Unigram LM基于语言模型选择删除而非合并SentencePieceGoogle 提供的统一分词框架支持多种算法总结BPE 分词算法看似简单却体现了深刻的机器学习思想从数据中学习规律用统计规律指导决策通过这篇博客你应该能够理解为什么分词是 NLP 的基础BPE 算法是如何工作的代码中的每个函数、每一行的意义分词是连接人类语言和机器理解的桥梁而 BPE 是这座桥梁上最重要的基石之一。完整代码import re, collections def get_stats(vocab): 统计词元对频率 pairs collections.defaultdict(int) for word, freq in vocab.items(): symbols word.split() for i in range(len(symbols)-1): pairs[symbols[i],symbols[i1]] freq return pairs def merge_vocab(pair, v_in): 合并词元对 v_out {} bigram re.escape( .join(pair)) p re.compile(r(?!\S) bigram r(?!\S)) for word in v_in: w_out p.sub(.join(pair), word) v_out[w_out] v_in[word] return v_out # 准备语料库每个词末尾加上/w表示结束并切分好字符 vocab {h u g /w: 1, p u g /w: 1, p u n /w: 1, b u n /w: 1} num_merges 4 # 设置合并次数 for i in range(num_merges): pairs get_stats(vocab) if not pairs: break best max(pairs, keypairs.get) vocab merge_vocab(best, vocab) print(f第{i1}次合并: {best} - {.join(best)}) print(f新词表部分: {list(vocab.keys())}) print(- * 20)

更多文章