深入理解分词(Tokenization)与BPE算法
什么是分词?
分词(Tokenization)是自然语言处理中最基础也最重要的步骤之一。它的核心任务是将一段连续的文本切分成离散的单元(tokens),这些单元可以是单词、子词或字符。
为什么分词如此重要?
在深度学习时代,模型无法直接理解原始文本。无论是 ChatGPT、BERT 还是其他大语言模型,它们处理的是数字,而不是文字。分词就是连接"人类语言"和"模型数字世界"的桥梁:
原始文本: "I love deep learning"
↓
分词结果: ["I", "love", "deep", "learning"]
↓
模型输入: [45, 892, 2341, 6789]
分词的选择直接影响:
- 词汇表大小(越大越占内存)
- 处理速度
- 对未知词(OOV)的处理能力
- 模型理解语言的能力
分词策略的演进
1. 字符级分词(Character-level)
将每个字符都作为一个 token:
"hello" → ["h", "e", "l", "l", "o"]
优点:词汇表极小(ASCII只有128个字符),不会有未知词问题。 缺点:丢失了字符组合的语义信息,序列过长,计算效率低。
2. 单词级分词(Word-level)
按空格分隔,每个单词是一个 token:
"hello world" → ["hello", "world"]
优点:保留了单词的语义含义。 缺点:词汇表爆炸式增长,无法处理未见过的新词(如 "tokenization" vs "tokenize")。
3. 子词级分词(Subword-level)—— 现代标准
这是目前大语言模型普遍采用的方案,将单词分解成有意义的子词单元。BPE 就是最流行的子词分词算法之一。
"tokenization" → ["token", "ization"]
优点:平衡了字符级和单词级的优势,能够灵活处理新词,同时保留了语义单元。
BPE(Byte 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) 合并成 ug:
'h 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 次(最高),将其合并为 un:
'h 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[i+1]] += 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, key=pairs.get) # 找到频率最高的对
vocab = merge_vocab(best, vocab) # 合并这个对
print(f"第{i+1 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 有很多变体和改进版本:
- WordPiece:Google BERT 使用的算法,优化了选择策略
- Unigram LM:基于语言模型选择删除而非合并
- SentencePiece:Google 提供的统一分词框架,支持多种算法
总结
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[i+1]] += 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, key=pairs.get)
vocab = merge_vocab(best, vocab)
print(f"第{i+1}次合并: {best} -> {''.join(best)}")
print(f"新词表(部分): {list(vocab.keys())}")
print("-" * 20)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)