引言
欢迎来到《从零构建大型语言模型:Python实现20亿参数LLM的完整指南》的第六课。在上一课中,我们学习了训练数据的获取与预处理,为模型训练奠定了数据基础。本课我们将深入探讨语言模型开发中的另一个关键环节:文本标记化(tokenization)与词汇表构建。
标记化是连接人类语言与机器理解的桥梁。它将原始文本转换为模型可以处理的数字序列,这一过程直接影响模型的学习能力、表达效率和计算性能。不同的标记化方法会导致不同的模型行为,从而影响最终的生成质量和推理效率。对于我们的20亿参数模型,选择和实现适当的标记化策略尤为重要。
在本课中,我们将首先比较主流的子词标记化技术,包括BPE、WordPiece和SentencePiece,理解它们的原理、优缺点及适用场景。然后,我们将从零开始实现字节对编码(BPE)算法,这是现代大型语言模型中最常用的标记化方法之一。接着,我们将探讨如何构建和优化词汇表,平衡表达能力和计算效率。最后,我们将讨论特殊标记处理和序列长度管理,这对于控制模型输入输出行为至关重要。
通过本课的学习,你将掌握:
- 主流子词标记化技术的工作原理及比较
- BPE算法的完整实现过程
- 词汇表设计与优化的关键策略
- 特殊标记处理和长序列管理技术
无论是从头训练模型还是微调现有模型,这些知识都将帮助你理解和控制模型与文本之间的关键接口。让我们开始探索标记化的奥秘!
1. 子词标记化技术比较
标记化的演变
标记化技术经历了从简单到复杂的发展过程:
-
单词级标记化:最早的方法,简单按空格和标点分割
- 优点:直观,保留完整单词语义
- 缺点:词汇表极大,无法处理未登录词(OOV),模型参数膨胀
-
字符级标记化:将文本分解为单个字符
- 优点:词汇表小,无OOV问题
- 缺点:序列过长,丢失词级语义,计算成本高
-
子词标记化:在单词和字符之间寻找平衡点
- 优点:词汇表大小适中,能处理OOV,保留部分语义结构
- 缺点:实现复杂,需要训练,单词可能被不直观地分割
现代大型语言模型几乎都采用子词标记化,它在表达效率和计算成本之间取得了最佳平衡。以下我们将深入比较几种主流子词标记化技术。
BPE (Byte-Pair Encoding)
BPE算法最初用于数据压缩,后被改编用于NLP领域。它是GPT系列、RoBERTa等模型使用的标记化方法。
基本原理:
- 从字符级词汇表开始
- 统计文本中最频繁的字符对(相邻字符)
- 将最频繁的字符对合并为新的子词单元
- 重复过程直到达到目标词汇表大小或满足其他停止条件
示例: 假设有文本:"low lower lowest lowering" 初始分词:l o w _ l o w e r _ l o w e s t _ l o w e r i n g
- 最频繁对:
l o出现4次,合并为lo - 新表示:
lo w _ lo w e r _ lo w e s t _ lo w e r i n g - 最频繁对:
lo w出现4次,合并为low - 新表示:
low _ low e r _ low e s t _ low e r i n g - 继续合并...最终得到:
low _ lower _ lowest _ lowering
优势:
- 适应性强,根据训练语料自动发现常见模式
- 有效处理复合词和词缀
- 生成的词汇表大小可控
- 编码和解码过程高效
局限性:
- 纯频率驱动,可能忽略语义边界
- 对罕见词的处理相对粗糙
- 不直接支持多语言场景(需要特殊处理)
WordPiece
WordPiece是BERT、DistilBERT等模型采用的标记化方法,由Google开发。
基本原理:
- 从字符级词汇表开始
- 对每对可能的子词组合,计算合并后语言模型似然提升
- 选择提升最大的对合并
- 重复直到达到目标词汇表大小
与BPE不同,WordPiece使用语言模型似然而非简单频率,计算公式:
得分(x,y) = (freq(xy) / (freq(x) * freq(y))) - 1
示例标记化:
- "playing" → ["play", "##ing"]
- "unbelievable" → ["un", "##bel", "##iev", "##able"]
注意"##"前缀标记子词位于单词中间
优势:
- 考虑了子词对语言模型概率的贡献
- 倾向于保留有意义的词素(语言学单位)
- 通常对英语等拼接式语言切分更自然
局限性:
- 计算成本高于BPE
- 需要预先进行单词分割(不适合中文等无明显分词界限的语言)
- 对数字、URL等特殊序列处理不佳
SentencePiece
SentencePiece是由Google开发的更全面的标记化解决方案,用于T5、XLM-R等多语言模型。
关键特性:
- 无预分词:将所有输入视为字符序列,包括空格
- 真正的可逆操作:保证解码能恢复原始文本(包括空格)
- 支持多种算法:内置BPE和Unigram两种算法
- 多语言友好:对非拉丁语系语言表现良好
- 直接支持字节级操作:可处理任何Unicode字符
Unigram算法原理:
- 从一个大型候选子词集开始
- 迭代移除使模型似然减少最少的标记
- 使用基于概率的分词(可能有多种分词方式)
示例: 文本:"I like SentencePiece" SentencePiece标记化:["▁I", "▁like", "▁S", "ent", "ence", "P", "iece"]
注意"▁"表示前导空格,成为标记的一部分
优势:
- 真正语言无关,适用于任何语言
- 无需语言特定的预处理(如空格分词)
- 支持子采样以加速训练
- 具备统一的API和工具链
局限性:
- 实现复杂度高于单纯的BPE或WordPiece
- Unigram模型训练较慢
- 需要额外处理空格标记
技术选择与性能对比
为了选择适合我们20亿参数模型的标记化技术,以下是关键性能指标比较:
| 指标 | BPE | WordPiece | SentencePiece |
|---|---|---|---|
| 训练速度 | 快 | 中等 | 慢(BPE模式)/较慢(Unigram模式) |
| 分词一致性 | 确定性 | 确定性 | 可能概率性(Unigram模式) |
| 多语言支持 | 有限 | 有限 | 优秀 |
| 内存效率 | 高 | 中等 | 高 |
| 实现复杂度 | 中等 | 高 | 高 |
| 主流LLM使用 | GPT系列, LLaMA | BERT系列 | T5, mT5, XLM-R |
对于我们的20亿参数模型,BPE标记化是一个平衡的选择,因为:
- 具有出色的计算效率,适合大规模训练
- 实现相对简单,便于定制和优化
- 与GPT系列架构兼容性好
- 在主要英文语料上表现优异
- 可通过适当的预处理步骤扩展到多语言场景
如果多语言能力是重点,则SentencePiece是更好的选择;如果希望保留更多语言学合理性,WordPiece可能更合适。
2. 从零实现BPE标记化算法
在理解了BPE的概念后,现在我们将从零实现这一算法,包括训练过程和编码/解码过程。这不仅能加深对算法的理解,也为我们提供一个可定制的标记化工具。
BPE算法流程详解
完整的BPE实现包括两个主要阶段:
阶段1: 训练(构建合并规则)
- 将训练语料分割成字符序列,添加结束标记
- 构建初始词汇表(所有出现的字符)
- 计算所有相邻字符对的频率
- 选择最高频的字符对合并
- 更新词汇表和所有文本中的字符对
- 返回步骤3,直到达到目标词汇量或迭代次数
阶段2: 编码(应用合并规则)
- 将输入文本分割成字符
- 按训练时获得的合并优先级顺序应用合并规则
- 返回最终的标记序列
BPE词汇表训练实现
让我们开始实现BPE训练过程:
def train_bpe(text_iterator, vocab_size, special_tokens=None):
"""
从文本语料库训练BPE标记化器
参数:
text_iterator: 文本迭代器,每次返回一行文本
vocab_size: 目标词汇表大小
special_tokens: 特殊标记列表,如[PAD], [CLS]等
返回:
vocabulary: 最终词汇表
merges: 合并规则列表
"""
# 初始化特殊标记
if special_tokens is None:
special_tokens = []
# 步骤1: 构建初始字符级词汇表
char_vocab = set()
word_counts = {}
print("构建初始字符词汇表...")
for line in text_iterator:
# 简单按空格分词,实际中可能需要更复杂的预处理
words = line.strip().split()
for word in words:
# 添加结束符号
word = word + "</w>"
# 更新词频统计
if word not in word_counts:
word_counts[word] = 0
word_counts[word] += 1
# 更新字符词汇表
for char in word:
char_vocab.add(char)
# 将单词表示为字符列表,便于后续合并
splits = {word: [c for c in word] for word in word_counts.keys()}
# 当前词汇表:特殊标记 + 字符词汇表
vocabulary = special_tokens + list(char_vocab)
merges = []
# 当词汇表大小小于目标大小时,继续合并
while len(vocabulary) < vocab_size:
# 统计所有相邻标记对的频率
pair_counts = {}
for word, freq in word_counts.items():
split = splits[word]
if len(split) == 1:
continue
for i in range(len(split) - 1):
pair = (split[i], split[i+1])
if pair not in pair_counts:
pair_counts[pair] = 0
pair_counts[pair] += freq
# 如果没有更多可合并的对,提前退出
if not pair_counts:
break
# 找出最高频的对
best_pair = max(pair_counts, key=pair_counts.get)
new_token = best_pair[0] + best_pair[1]
merges.append(best_pair)
# 将该对合并添加到词汇表
vocabulary.append(new_token)
# 更新所有分割,应用新合并规则
for word in word_counts:
split = splits[word]
i = 0
while i < len(split) - 1:
if split[i] == best_pair[0] and split[i+1] == best_pair[1]:
split = split[:i] + [new_token] + split[i+2:]
i = 0 # 重新开始,因为合并可能创造新的可合并对
else:
i += 1
splits[word] = split
print(f"合并: {best_pair} -> {new_token}, 词汇表大小: {len(vocabulary)}")
# 如果达到目标词汇量,停止
if len(vocabulary) >= vocab_size:
break
return vocabulary, merges
BPE编码与解码实现
有了训练好的合并规则,我们现在可以实现编码函数,将文本转换为标记ID:
def encode(text, merges, vocab):
"""
使用BPE将文本编码为标记ID
参数:
text: 输入文本
merges: BPE合并规则
vocab: 词汇表(标记到ID的映射)
返回:
token_ids: 标记ID列表
"""
# 按空格分词
words = text.strip().split()
token_ids = []
for word in words:
# 添加结束符号
word = word + "</w>"
# 初始分割为字符
chars = [c for c in word]
# 应用合并规则
for pair, merge in enumerate(merges):
i = 0
while i < len(chars) - 1:
if chars[i] == merge[0] and chars[i+1] == merge[1]:
chars = chars[:i] + [merge[0] + merge[1]] + chars[i+2:]
else:
i += 1
# 将分词结果转换为ID
for token in chars:
if token in vocab:
token_ids.append(vocab[token])
else:
# 处理OOV:可以使用特殊的[UNK]标记或字符级回退
if "<unk>" in vocab:
token_ids.append(vocab["<unk>"])
else:
# 字符级回退处理
for char in token:
if char in vocab:
token_ids.append(vocab[char])
elif "<unk>" in vocab:
token_ids.append(vocab["<unk>"])
return token_ids
def decode(token_ids, id_to_token):
"""
将标记ID解码回文本
参数:
token_ids: 标记ID列表
id_to_token: ID到标记的映射
返回:
text: 解码后的文本
"""
tokens = [id_to_token[id] for id in token_ids]
text = ""
current_word = ""
for token in tokens:
if token.endswith("</w>"):
# 结束当前单词
current_word += token[:-4] # 移除</w>标记
text += current_word + " "
current_word = ""
else:
current_word += token
return text.strip()
提升实现的效率与健壮性
上述实现是为了教学目的而设计的,强调清晰度。实际应用中,我们需要考虑以下优化:
-
算法优化:
- 使用优先队列存储合并候选项,避免每次重新计算全部频率
- 实现增量式更新而非全量重新计算
- 利用并行处理加速频率统计和合并操作
-
内存优化:
- 使用更高效的数据结构(如字典树)存储分词状态
- 实现流式处理大文件,避免一次加载全部语料
- 采用稀疏表示存储频率信息
-
健壮性增强:
- 添加正则表达式预处理,统一处理数字、URL等特殊文本
- 实现更智能的OOV处理策略
- 增加Unicode规范化和特殊字符处理
-
缓存机制:
- 缓存常见单词的标记化结果
- 实现分层缓存策略(内存+磁盘)
- 添加预热机制,提前处理高频词
完整的生产级实现会更复杂,通常包含2000-3000行代码,并使用C++等高性能语言实现核心部分。对于我们的教学目的,上述Python实现已经捕捉了BPE的核心原理。
3. 构建与优化词汇表
词汇表(Vocabulary)是将标记映射到数字ID的字典,它直接影响模型的性能、大小和训练效率。现在我们将深入探讨如何构建和优化词汇表。
词汇表设计原则
词汇表设计需要平衡多种因素:
-
表达效率:
- 较大的词汇表能减少每个序列的标记数
- 更有效地表示常见词/短语
- 但会增加嵌入层的参数数量
-
计算效率:
- 嵌入层大小与词汇表大小成正比
- 较小的词汇表降低内存需求和计算负担
- 影响训练和推理速度
-
泛化能力:
- 适当的词汇大小有助于模型学习更好的表示
- 过大的词汇表可能导致数据稀疏性问题
- 过小的词汇表则表达能力受限
-
领域适应性:
- 通用词汇表 vs. 领域特定词汇表
- 常用词/专业术语的覆盖率
- 不同语言或方言的支持
词汇表大小选择
词汇表大小是一个关键决策,主流LLM的选择各不相同:
| 模型 | 词汇表大小 | 备注 |
|---|---|---|
| GPT-2 | 50,257 | 包括字节级回退 |
| BERT | 30,522 | 英文版本 |
| RoBERTa | 50,265 | 与GPT-2相似 |
| T5 | 32,128 | SentencePiece实现 |
| GPT-3 | 约50,000 | 基于GPT-2词汇表扩展 |
| LLaMA | 32,000 | 基于BPE,但有独特处理 |
对于我们的20亿参数模型,推荐的词汇表大小范围是32,000-50,000,这一范围能够:
- 提供足够的表达能力处理广泛文本
- 保持合理的嵌入层大小(约0.5-1.5亿参数)
- 与主流模型兼容,便于迁移学习
- 在序列长度和计算效率间取得平衡
词汇表大小最佳值取决于多种因素,如语言、领域、模型大小和计算预算。实验表明,对于英文通用模型,增加词汇表超过50,000后收益递减,而维持至少30,000通常是必要的。
词汇表内容优化
除了大小,词汇表的内容组成也至关重要:
-
基本标记分布:
- 字符级标记:保证任何文本都可表示
- 常见子词:高频率部分和词缀
- 完整单词:常用词如"the", "and", "is"等
- 常见短语:如"in the", "of the"等
-
领域特定标记:
- 根据目标应用添加专业术语
- 为高价值领域增加特定词汇表示
- 平衡通用性和专业性
-
特殊标记分配:
- 系统预留标记(如
<s>,</s>,<pad>,<mask>) - 任务特定标记(如
<img>,<code>,<math>) - 控制标记(用于引导生成)
- 系统预留标记(如
-
多样性考虑:
- 不同语言的平衡表示
- 各种编写风格(正式/非正式)的覆盖
- 技术性和非技术性内容的平衡
实现词汇表优化流程
以下是一个词汇表优化流程实现:
def optimize_vocabulary(base_vocab, training_data, target_size,
special_tokens, domain_specific_texts=None):
"""
优化模型词汇表
参数:
base_vocab: 初始词汇表
training_data: 训练数据迭代器
target_size: 目标词汇表大小
special_tokens: 必须保留的特殊标记
domain_specific_texts: 可选的领域特定文本
返回:
optimized_vocab: 优化后的词汇表
"""
# 步骤1: 分析当前词汇表使用情况
token_usage = {token: 0 for token in base_vocab}
total_tokens = 0
print("分析标记使用频率...")
for text in training_data:
# 使用当前词汇表标记化文本
tokens = tokenize(text, base_vocab)
# 更新使用计数
for token in tokens:
if token in token_usage:
token_usage[token] += 1
total_tokens += 1
# 步骤2: 识别低频和无用标记
token_usage_ratio = {token: count/total_tokens
for token, count in token_usage.items()}
# 按使用率排序标记
sorted_tokens = sorted(token_usage_ratio.items(),
key=lambda x: x[1], reverse=True)
# 提取必须保留的标记(特殊标记和高频标记)
essential_tokens = set(special_tokens)
essential_tokens.update([token for token, _ in
sorted_tokens[:int(target_size * 0.7)]])
# 步骤3: 如果有领域特定文本,处理它们
domain_tokens = set()
if domain_specific_texts:
# 使用BPE在领域文本上训练额外标记
print("处理领域特定文本...")
domain_vocab, _ = train_bpe(domain_specific_texts,
int(target_size * 0.2))
domain_tokens = set(domain_vocab)
# 步骤4: 构建最终词汇表
final_vocab = list(essential_tokens)
# 添加领域特定标记
remaining_slots = target_size - len(final_vocab)
domain_to_add = min(len(domain_tokens), int(remaining_slots * 0.6))
print(f"添加{domain_to_add}个领域特定标记...")
final_vocab.extend(list(domain_tokens)[:domain_to_add])
# 用一般频率标记填充剩余空间
remaining_slots = target_size - len(final_vocab)
if remaining_slots > 0:
# 找出尚未包含的频率标记
remaining_tokens = [token for token, _ in sorted_tokens
if token not in essential_tokens
and token not in domain_tokens]
final_vocab.extend(remaining_tokens[:remaining_slots])
# 确保词汇表大小正确
assert len(final_vocab) <= target_size, "词汇表超出目标大小"
print(f"最终词汇表大小: {len(final_vocab)}")
return final_vocab
词汇表评估指标
如何评估词汇表质量?以下是一些关键指标:
-
压缩率:平均每个原始词被压缩为多少标记
压缩率 = 原始单词数 / 标记数- 较高的压缩率表示更高效的编码
- 通常在0.6-0.9之间,取决于语言和领域
-
覆盖率:单个标记表示完整单词的比例
覆盖率 = 单标记词数 / 总词数- 反映了常用词的有效性
- 对于英语,优秀的覆盖率在15-25%之间
-
均匀度:标记使用分布的均匀程度
- 可以使用熵或基尼系数测量
- 过低的均匀度表示资源浪费
- 过高的均匀度可能表示特定领域表示不足
-
OOV处理效率:处理未见词的效率
- 平均每个OOV词被拆分为多少标记
- 回退机制的成功率
一个平衡的词汇表应该在这些指标上表现良好,但具体权重取决于应用场景。例如,对于翻译系统,压缩率可能更重要;而对于代码生成,精确表示特定符号的能力可能更关键。
4. 特殊标记处理与序列长度管理
除了常规单词和子词标记外,现代LLM还需要特殊标记和长序列管理策略。这些机制控制着模型的输入/输出行为和上下文窗口利用。
特殊标记设计
特殊标记是词汇表中预留的、具有特定功能的标记,它们服务于多种目的:
-
结构标记:
<s>/<bos>:序列开始标记</s>/<eos>:序列结束标记<pad>:填充标记,用于批处理对齐<mask>:掩码标记,用于掩码语言模型训练
-
功能标记:
<sep>:分隔不同文本段<cls>:分类标记,通常用于表示整句<unk>:未知标记,表示词汇表外单词
-
控制标记:
<human>/<assistant>:对话角色标记<system>:系统指令标记- 自定义控制标记:如
<code>,<math>,<translation>
-
语言和任务标记:
- 语言标识符:如
<en>,<zh>,<fr> - 任务指示器:如
<summarize>,<qa>,<translate>
- 语言标识符:如
为20亿参数模型设计特殊标记时,建议采用以下策略:
- 保持最小必要集:每个特殊标记都占用词汇空间,应谨慎添加
- 一致的格式:如使用尖括号
<>表示所有特殊标记 - 有意义的命名:名称应直观反映功能
- 预留扩展空间:为未来功能预留额外特殊标记
特殊标记实现与处理
特殊标记不仅需要添加到词汇表,还需要在模型架构和训练过程中特殊处理:
class SpecialTokenHandler:
"""特殊标记处理工具"""
def __init__(self, tokenizer, special_token_dict):
"""
初始化特殊标记处理器
参数:
tokenizer: 基础标记化器
special_token_dict: 特殊标记配置,如
{'bos_token': '<s>', 'eos_token': '</s>'}
"""
self.tokenizer = tokenizer
self.special_tokens = special_token_dict
# 确保特殊标记在词汇表中
self._add_special_tokens()
# 缓存特殊标记ID
self.special_token_ids = {
name: self.tokenizer.convert_tokens_to_ids(token)
for name, token in self.special_tokens.items()
}
def _add_special_tokens(self):
"""将特殊标记添加到词汇表"""
special_tokens_list = list(self.special_tokens.values())
original_vocab_size = len(self.tokenizer.vocab)
# 添加缺失的特殊标记
for token in special_tokens_list:
if token not in self.tokenizer.vocab:
self.tokenizer.vocab[token] = len(self.tokenizer.vocab)
# 更新反向映射
self.tokenizer.ids_to_tokens = {
v: k for k, v in self.tokenizer.vocab.items()
}
new_vocab_size = len(self.tokenizer.vocab)
print(f"添加了{new_vocab_size - original_vocab_size}个特殊标记")
def prepare_for_model(self, token_ids, max_length=None,
add_bos=True, add_eos=True, padding=True):
"""
准备标记序列用于模型输入
参数:
token_ids: 原始标记ID序列
max_length: 最大序列长度,None表示不截断
add_bos: 是否添加开始标记
add_eos: 是否添加结束标记
padding: 是否填充到最大长度
返回:
processed_ids: 处理后的标记ID序列
attention_mask: 注意力掩码,1表示非填充位置
"""
processed_ids = []
# 添加BOS标记
if add_bos and 'bos_token' in self.special_tokens:
processed_ids.append(self.special_token_ids['bos_token'])
# 添加主体标记ID
processed_ids.extend(token_ids)
# 添加EOS标记
if add_eos and 'eos_token' in self.special_tokens:
processed_ids.append(self.special_token_ids['eos_token'])
# 处理序列长度
if max_length is not None:
# 截断
if len(processed_ids) > max_length:
processed_ids = processed_ids[:max_length]
# 确保EOS仍然存在(如果需要)
if add_eos and 'eos_token' in self.special_tokens:
processed_ids[-1] = self.special_token_ids['eos_token']
# 填充
attention_mask = [1] * len(processed_ids)
if padding and len(processed_ids) < max_length:
pad_id = self.special_token_ids.get(
'pad_token', 0) # 默认使用0
padding_length = max_length - len(processed_ids)
processed_ids.extend([pad_id] * padding_length)
attention_mask.extend([0] * padding_length)
else:
attention_mask = [1] * len(processed_ids)
return processed_ids, attention_mask
长序列处理
大型语言模型的一个关键特性是处理长上下文的能力,这需要有效的序列长度管理策略:
-
最大长度限制:
- 模型架构决定的硬性限制(如位置编码长度)
- 内存和计算资源施加的实际限制
- 典型值:2048-8192标记(较大模型达到32k以上)
-
长序列切分策略:
- 固定长度分块:简单但可能破坏文档结构
- 文档边界感知:在文档边界处分割
- 语义边界分割:在段落或章节边界分割
- 滑动窗口:使用重叠窗口增加连续性
-
注意力掩码管理:
- 全局注意力 vs. 局部注意力
- 分块注意力策略
- 稀疏注意力模式
-
位置编码扩展:
- 插值方法扩展学习的位置编码
- 相对位置编码减轻长度限制
- ALiBi等无限长度方法
以下是处理长序列的实用实现:
def process_long_text(text, tokenizer, max_seq_length,
overlap=0.1, respect_boundaries=True):
"""
处理超出最大长度的文本
参数:
text: 输入文本
tokenizer: 标记化器
max_seq_length: 最大序列长度
overlap: 重叠比例(0-1之间)
respect_boundaries: 是否尊重文档边界
返回:
sequences: 处理后的序列列表
"""
# 将文本标记化
all_tokens = tokenizer.tokenize(text)
all_token_ids = tokenizer.convert_tokens_to_ids(all_tokens)
sequences = []
# 如果总长度小于最大长度,直接返回
if len(all_token_ids) <= max_seq_length:
token_ids, mask = prepare_sequence(
all_token_ids, max_seq_length, tokenizer)
sequences.append((token_ids, mask))
return sequences
# 计算步长(考虑重叠)
stride = int(max_seq_length * (1 - overlap))
# 不考虑边界的简单分块
if not respect_boundaries:
for i in range(0, len(all_token_ids), stride):
chunk = all_token_ids[i:i + max_seq_length]
token_ids, mask = prepare_sequence(
chunk, max_seq_length, tokenizer)
sequences.append((token_ids, mask))
# 尊重文档边界的分块
else:
# 首先按段落分割文本
paragraphs = text.split("\n\n")
paragraphs = [p for p in paragraphs if p.strip()]
# 标记化每个段落
paragraph_tokens = []
for para in paragraphs:
tokens = tokenizer.tokenize(para)
token_ids = tokenizer.convert_tokens_to_ids(tokens)
paragraph_tokens.append(token_ids)
# 将段落组合成不超过最大长度的块
current_chunk = []
current_length = 0
for para_ids in paragraph_tokens:
if current_length + len(para_ids) > max_seq_length:
# 如果当前块不为空,添加到序列中
if current_chunk:
token_ids, mask = prepare_sequence(
current_chunk, max_seq_length, tokenizer)
sequences.append((token_ids, mask))
# 重置当前块
current_chunk = para_ids
current_length = len(para_ids)
else:
# 添加段落到当前块
current_chunk.extend(para_ids)
current_length += len(para_ids)
# 添加最后一个块
if current_chunk:
token_ids, mask = prepare_sequence(
current_chunk, max_seq_length, tokenizer)
sequences.append((token_ids, mask))
return sequences
def prepare_sequence(token_ids, max_length, tokenizer):
"""准备单个序列,添加特殊标记和填充"""
# 添加特殊标记
if hasattr(tokenizer, 'bos_token_id') and tokenizer.bos_token_id:
token_ids = [tokenizer.bos_token_id] + token_ids
# 截断
if len(token_ids) > max_length:
token_ids = token_ids[:max_length]
if hasattr(tokenizer, 'eos_token_id') and tokenizer.eos_token_id:
token_ids[-1] = tokenizer.eos_token_id
# 添加EOS(如果有空间)
elif hasattr(tokenizer, 'eos_token_id') and tokenizer.eos_token_id:
if len(token_ids) < max_length:
token_ids.append(tokenizer.eos_token_id)
# 创建注意力掩码
attention_mask = [1] * len(token_ids)
# 填充
padding_length = max_length - len(token_ids)
if padding_length > 0:
if hasattr(tokenizer, 'pad_token_id') and tokenizer.pad_token_id:
token_ids = token_ids + [tokenizer.pad_token_id] * padding_length
else:
token_ids = token_ids + [0] * padding_length
attention_mask = attention_mask + [0] * padding_length
return token_ids, attention_mask
序列批处理优化
高效的批处理策略可以显著提升训练效率。主要考虑因素包括:
-
动态批大小 vs. 固定序列长度:
- 固定标记数策略:总标记数固定(如
batch_size * seq_length = constant) - 可变批大小:根据序列长度动态调整批大小
- 梯度累积:允许有效使用大批量
- 固定标记数策略:总标记数固定(如
-
批内序列分组:
- 长度分桶(bucketing):相似长度的序列分到同一批次
- 减少填充浪费:最小化每批次中的填充标记比例
- 动态填充:仅填充到批次中最长序列的长度
-
注意力掩码优化:
- 全局注意力:每个位置可以关注所有位置
- 因果注意力:每个位置只能关注自身及之前的位置
- 分块注意力:仅允许关注相关块内的位置
高效批处理实现示例:
def create_length_based_batches(sequences, batch_size,
max_length, length_bucket_width=32):
"""
基于序列长度创建批次,减少填充
参数:
sequences: 序列列表
batch_size: 目标批大小
max_length: 最大序列长度
length_bucket_width: 长度桶宽度
返回:
batches: 批次列表
"""
# 计算每个序列的长度
seq_lengths = [len(seq[0]) for seq in sequences]
# 创建长度桶
buckets = {}
for i, length in enumerate(seq_lengths):
# 向上取整到最近的桶边界
bucket_idx = (length + length_bucket_width - 1) // length_bucket_width
if bucket_idx not in buckets:
buckets[bucket_idx] = []
buckets[bucket_idx].append(i)
# 从每个桶中创建批次
batches = []
for bucket_idx in sorted(buckets.keys()):
bucket_indices = buckets[bucket_idx]
# 将桶中的序列分成批次
for i in range(0, len(bucket_indices), batch_size):
batch_indices = bucket_indices[i:i + batch_size]
# 获取当前批次的序列
batch_sequences = [sequences[idx] for idx in batch_indices]
# 找出当前批次中的最大序列长度
batch_max_len = max(len(seq[0]) for seq in batch_sequences)
# 将批次中的最大长度向上取整到桶宽度的倍数
batch_max_len = ((batch_max_len + length_bucket_width - 1)
// length_bucket_width) * length_bucket_width
# 确保不超过全局最大长度
batch_max_len = min(batch_max_len, max_length)
# 准备批次输入
batch_input_ids = []
batch_attention_masks = []
for token_ids, attention_mask in batch_sequences:
# 剪裁或填充到批次最大长度
if len(token_ids) < batch_max_len:
padding_length = batch_max_len - len(token_ids)
token_ids = token_ids + [0] * padding_length
attention_mask = attention_mask + [0] * padding_length
else:
token_ids = token_ids[:batch_max_len]
attention_mask = attention_mask[:batch_max_len]
batch_input_ids.append(token_ids)
batch_attention_masks.append(attention_mask)
batches.append({
'input_ids': batch_input_ids,
'attention_mask': batch_attention_masks,
'batch_max_len': batch_max_len
})
return batches
这种优化的批处理可以显著减少填充浪费,提高训练效率。在某些情况下,它可以将训练吞吐量提高20-40%。
总结与展望
在本课中,我们深入探讨了文本标记化与词汇表构建的理论和实践。我们比较了主流子词标记化技术(BPE、WordPiece和SentencePiece),从零实现了BPE算法,探讨了词汇表构建与优化的关键策略,并学习了特殊标记处理和序列长度管理的实用技术。
标记化是连接自然语言与模型理解的桥梁,直接影响着模型的表达能力、训练效率和生成质量。通过本课的学习,我们不仅理解了现代LLM如何处理和表示文本,还掌握了实现和优化这些组件的具体方法。
关键要点回顾:
- 子词标记化平衡了词汇表大小和表达效率,是现代LLM的标准方法
- BPE算法通过迭代合并最频繁的相邻标记对,自动发现有用的子词单元
- 词汇表设计需要平衡表达效率、计算资源和泛化能力
- 特殊标记和序列长度管理对控制模型行为和处理长文本至关重要
展望未来,标记化技术还有多个发展方向:
- 多语言标记化:更好地处理不同语言的特性,尤其是低资源语言
- 上下文感知标记化:考虑语境的动态标记化技术
- 多模态标记化:整合文本、代码、数学符号等多种表示形式
- 可控制的标记化:允许用户根据应用需求自定义标记化行为
在下一课中,我们将把注意力转向模型的基础架构,学习如何实现transformer编码器和解码器,这是构建LLM的核心组件。
延伸阅读
- "Neural Machine Translation of Rare Words with Subword Units" (Sennrich et al., 2016) - BPE在NLP中的首次应用
- "SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing" (Kudo & Richardson, 2018)
- "Revisiting Pre-Trained Models for Chinese Natural Language Processing" (Cui et al., 2020) - 中文等语言的标记化挑战
- "Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer" (Raffel et al., 2020) - T5模型的标记化策略
- "RoFormer: Enhanced Transformer with Rotary Position Embedding" (Su et al., 2021) - 处理长序列位置表示的新方法
思考问题:
- 在为不同语言设计标记化策略时,应该使用单一多语言词汇表还是每种语言独立的词汇表?讨论两种方法的优缺点及其对模型性能的潜在影响。
- 如果你需要为特定领域(如医学、法律或编程)构建LLM,你会如何调整标记化和词汇表策略以最大化模型在该领域的效果?
- BPE算法是基于频率的贪婪合并策略。你能想到如何改进这个算法,使其考虑更多语言学特性或生成更有意义的子词单位吗?
- 比较字节级BPE和字符级BPE的优缺点。在什么情况下字节级BPE可能是更好的选择,特别是考虑到多语言处理和稀有字符?