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