BPE算法深度解析:从零到一构建语言模型的词元化引擎

发布于:2025-09-13 ⋅ 阅读:(25) ⋅ 点赞:(0)

在构建现代大语言模型(LLM)时,如何将人类语言转换为模型能够理解的数字序列,是一个至关重要的基础问题。词元化(Tokenization)就是解决这个问题的核心技术。

传统的词元化方法要么以“词”为单位,导致无法处理未登录词(OOV),要么以“字符”为单位,使得序列过长,模型难以处理。为了找到两者之间的完美平衡,字节对编码(Byte-Pair Encoding, BPE)应运而生。

本文将结合提供的 Python 代码,深入剖析 BPE 算法的实现细节,揭示其如何高效地构建词汇表,并为语言模型提供强大的词元化能力。


一、BPE算法的核心思想

BPE 是一种数据压缩思想在词元化领域的应用。其核心理念是:通过贪婪地合并训练语料库中最频繁出现的相邻字符或子词对,逐步构建一个由字符和常用子词组成的词汇表。

这个过程就像让模型自己学习语言的“组词”规则,从最基本的字母开始,逐步学习“th”、“ing”、“tion”等常见的子词单元,最终形成一个既能表示常见词,又能高效分解生僻词的词汇表。


二、理论与代码的结合:BPE算法实现详解

BPE算法代码如下:

from transformers import AutoTokenizer
from collections import defaultdict

corpus = [
    "This is the HuggingFace Course.",
    "This chapter is about tokenization.",
    "This section shows how to tokenize in practice.",
    "Let's see how to tokenize in practice.",
    "First, we need to download a tokenizer.",
    "Hopefully, you will be able to understand how they are trained and generate tokens.",
]

# 使用GPT-2词元分析器将输入分解为词
tokenizer = AutoTokenizer.from_pretrained("gpt2")

word_freqs = defaultdict(int) # 统计词频

for text in corpus:
    words_with_offsets = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text) # 将输入分解为词
    new_words = [word for word, offset in words_with_offsets] # 提取词
    for word in new_words:
        word_freqs[word.lower()] += 1

print(word_freqs)

# 计算字母表
alphabet = []
for word in word_freqs.keys():
    for letter in word:
        if letter not in alphabet:
            alphabet.append(letter)
alphabet.sort() # 排序
vocab = alphabet.copy()
# 在字典开头增加特殊词元"<|endoftext|>",用来表示文本结束
vocab.append("<|endoftext|>")

# 将词切分为字符
splits = {word: [c for c in word] for word in word_freqs.keys()}

#计算字典中所有词元对频率
def compute_pair_freqs(splits):
    pair_freqs = defaultdict(int)
    for word, freq in word_freqs.items():
        split = splits[word]
        if len(split) == 1:
            continue
        for i in range(len(split) - 1):
            pair = (split[i], split[i + 1])
            pair_freqs[pair] += freq
    return pair_freqs

# 词对合并函数
def merge_pair(a, b, splits):
    for word in word_freqs:
        split = splits[word]
        if len(split) == 1:
            continue
        i = 0
        while i < len(split) - 1:
            if split[i] == a and split[i+1] == b:
                split = split[:i] + [a + b] + split[i+2:]
            else:
                i += 1
        splits[word] = split
    return splits

# 存储合并规则
merges = {}

# 迭代训练,每次选取得分最高的词元对进行合并,直到字典大小达到设置的目标为止
vocab_size = 50

while len(vocab) < vocab_size:
    pair_freqs = compute_pair_freqs(splits)
    best_pair = ""
    max_freq = None
    for pair, freq in pair_freqs.items():
        if max_freq is None or max_freq < freq:
            max_freq = freq
            best_pair = pair
    splits = merge_pair(best_pair[0], best_pair[1], splits)
    merges[best_pair] = best_pair[0] + best_pair[1]
    vocab.append(best_pair[0] + best_pair[1])

print(f"Vocabulary size: {len(vocab)}")
print(f"First 10 vocab items: {vocab[:10]}")

# 训练完成后,tokenize函数用于对给定文本进行词元切分
def tokenize(text):
    pre_tokenize_result = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text)
    pre_tokenized_text = [word for word, offset in pre_tokenize_result]
    splits = [[l for l in word] for word in pre_tokenized_text]
    for pair, merge in merges.items():
        for idx, split in enumerate(splits):
            i = 0
            while i < len(split) - 1:
                if split[i] == pair[0] and split[i+1] == pair[1]:
                    split = split[:i] + [merge] + split[i+2:]
                else:
                    i += 1
            splits[idx] = split
    return sum(splits, [])

# 测试tokenize函数
result = tokenize("This is not a token.")
print(f"Tokenization result: {result}")

提供的代码实现了一个经典的 BPE 算法流程,可分为三个主要阶段:准备工作、核心训练循环和最终的词元化。

1. 准备工作:统计词频与初始化

在算法开始前,我们需要对原始语料库进行基础处理。

  • corpus:这是算法的训练语料库,从中学习语言模式。

  • tokenizer.backend.pre_tokenizer.pre_tokenize_str(text):这一步将原始文本初步分解为单词,例如将 "This is a token." 分解为 ["This", "is", "a", "token", "."]。这是 BPE 的一个预处理步骤,以确保我们总是在词语边界上进行操作。

  • word_freqs:通过遍历语料库,代码统计了每个单词的出现频率,这是后续合并的基础。

  • alphabet:代码通过遍历所有单词,收集了语料库中所有唯一的字符,这构成了我们初始的、最小的词汇表。

这一阶段的代码完成了数据收集和词汇表初始化,为接下来的核心算法做好了准备。

2. 核心循环:学习合并规则

这是 BPE 算法的“训练”阶段。代码通过一个 while 循环,不断地迭代合并词元,直到词汇表大小达到预设的 vocab_size

  • splits = {word: [c for c in word] ...}:在循环开始前,所有单词都被切分成了单个字符列表。

  • compute_pair_freqs:这个函数是算法的核心之一。在每次循环中,它遍历 splits 中的所有单词,并统计所有相邻词元对的出现频率。例如,对于 ["l", "o", "w"],它会统计 ("l", "o")("o", "w")

  • best_pair:代码找到 compute_pair_freqs 返回的频率最高的词元对,这就是本轮迭代要合并的对象。

  • merge_pair:这个函数执行了合并操作。它遍历所有的单词,找到 best_pair 并将它们合并成一个新词元,然后更新 splits。例如,如果 ("t", "o") 是最频繁的,它会将所有 ["t", "o"] 替换为 ["to"]

  • vocab:每次合并后,新的词元会被添加到 vocab 词汇表中。

这个循环通过反复**“统计-合并”**的贪婪策略,逐步构建了一个由高频子词组成的词汇表。

3. 训练完成:使用BPE词元化新文本

训练结束后,我们得到了一系列合并规则tokenize 函数利用这些规则来对任何新文本进行词元化。

  • tokenize(text):这个函数首先将新文本分解为单词,然后将每个单词切分成字符。

  • 应用合并规则:接下来,它会按照训练时学到的合并顺序,对每个单词的字符序列应用合并规则。例如,如果训练时先合并了 ("t", "o"),再合并 ("o", "k")tokenize 函数会严格按照这个顺序进行合并,直到所有可能的合并都完成。

通过这种方式,即使 tokenize("This is not a token.") 遇到了训练时未见过的词,比如 "token.",它也能将其分解为已知的子词 "token"".",从而避免了未登录词的问题。

总结

BPE 算法通过一种简单而强大的贪婪策略,完美地解决了大模型中的词元化难题。它在保证词汇表规模可控的同时,赋予了模型处理无限文本的能力,是现代大语言模型取得成功不可或缺的基石。


网站公告

今日签到

点亮在社区的每一天
去签到