什么是分词?

分词(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 的训练和应用分为两个阶段:

训练阶段(离线)

  1. 收集大量文本语料库
  2. 统计所有字符对频率
  3. 逐步合并最高频对,生成合并规则
  4. 保存合并规则和最终词汇表

推理阶段(在线)

  1. 将待处理的文本拆分成字符
  2. 按照训练得到的合并规则依次应用
  3. 得到最终的 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 的优势与局限

优势

  1. 数据驱动:自动学习语料库中的常见子词模式
  2. 灵活性:能够处理训练时未见过的词
  3. 平衡性:在词汇表大小和表达能力之间取得平衡
  4. 语言无关:适用于任何语言,包括中文、日文等

局限

  1. 贪婪策略:每次只选择最高频对,可能不是最优解
  2. 频率依赖:常见词会拆分成更小的单元
  3. 初始切分敏感:字符级初始化可能影响最终结果

现代改进

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)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐