深入解析LLM分词技术:从BPE到GPT-4分词器构建
18个月前,Andrej Karpathy提出了一个挑战:“能否将我的2小时13分钟分词器视频转化为书籍章节的形式”。我们已经完成了这个任务,以下就是这一章节的内容,包含了关键代码片段和视频中的图像(超链接到视频时间戳)。这是一个学习大型语言模型如何工作的关键部分,这个新的文本版本也非常有价值。
引言:为什么分词重要
本教程涵盖大型语言模型中的分词过程。分词是使用大型语言模型中不太令人愉快的方面之一,但有必要详细了解。这个过程很复杂,有许多隐藏的陷阱需要注意。大型语言模型中的许多奇怪行为都可以追溯到分词。
字符级分词基础
在之前的指南中,我们使用了简单版本的分词。随附的笔记本演示了将莎士比亚数据集加载为训练集。这个数据集只是一个包含文本的大型Python字符串。核心问题变成了:如何将文本输入大型语言模型?
在那个简单示例中,我们创建了字符串中出现的65个可能字符的词汇表。这些字符构成了我们的词汇表,使用查找表将每个字符(小字符串片段)转换为令牌(整数)。对字符串“hi there”进行分词会产生一系列令牌。数据集的前1000个字符被编码为令牌。由于这是字符级分词,它恰好产生了1000个连续令牌。
早期指南中的字符级方法工作如下:
import torch
text = 'This is some text dataset hello, and hi some words!'
# 获取文本中出现的唯一字符
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(''.join(chars))
print(vocab_size)
然后我们创建从字符到整数的映射:
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
encode = lambda s: [stoi[c] for c in s] # 编码器:接受字符串,输出整数列表
decode = lambda l: ''.join([itos[i] for i in l]) # 解码器:接受整数列表,输出字符串
print(encode("hii there"))
print(decode(encode("hii there")))
最后我们将整个文本数据集编码并存储到torch.Tensor中:
data = torch.tensor(encode(text), dtype=torch.long)
print(data.shape, data.dtype)
print(data[:1000]) # 前1000个字符看起来像这样
分词是许多大型语言模型奇怪行为的核心。在深入技术细节之前,值得理解为什么这个看似平凡的话题值得仔细关注。
分词问题概览
- 为什么LLM不能正确拼写单词?分词
- 为什么LLM不能执行字符串处理任务(如反转字符串)?分词
- 为什么LLM在非英语语言上表现不佳?分词
- 为什么LLM在简单算术上表现不佳?分词
- 为什么GPT-2在Python编码上遇到不必要的麻烦?分词
- 为什么我的LLM看到字符串“<|endoftext|>”时突然停止?分词
- 为什么我应该在使用LLM时偏好YAML而不是JSON?分词
我们将在最后回到这些问题,但首先考虑tiktokenizer网络应用程序。该工具使用JavaScript在浏览器中实时运行分词,允许您在输入时看到分词过程。
从字符到令牌:基础知识
简单的字符级分词
将这些令牌集成到语言模型中的过程依赖于嵌入表。有了65个可能的令牌,嵌入表包含65行。每个令牌的整数值作为查找键进入此表,检索相应的行。该行由可通过反向传播优化的可训练参数组成。得到的向量输入到transformer中,这就是transformer感知每个令牌的方式。
前面描述的字符级方法代表了朴素的分词过程。最先进的语言模型采用更复杂的方案来构建令牌词汇表。这些模型不是操作字符级别,而是使用字节对编码等算法构建的字符块,本教程将详细探讨这一点。
Unicode和UTF-8基础
目标是将字符串输入语言模型。这需要将字符串分词为来自固定词汇表的整数。这些整数然后用作向量表的查找,这些向量作为输入馈送到transformer。
挑战不仅限于支持简单的英语字母。语言模型需要处理不同的语言,例如韩语中的“annyeonghaseyo”(안녕하세요),意思是“你好”。它们还需要支持互联网上的特殊字符,包括表情符号。transformer如何处理这个文本?
text = "안녕하세요 👋 hello world 🤗"
print(text)
Python文档将字符串定义为Unicode代码点的不可变序列。Unicode代码点由Unicode联盟作为Unicode标准的一部分定义。该标准定义了大约150,000个字符,跨越161种文字,指定了这些字符的外观以及表示它们的整数。该标准继续发展——版本15.1于2023年9月发布。
Python的ord函数检索单个字符的Unicode代码点。例如,字符‘h’的Unicode代码点为104。这扩展到任意复杂的字符:拥抱表情符号的代码点为128,000,而韩语字符“안”的代码点为50,000。ord函数仅接受单个Unicode代码点字符,而不是字符串。
# 获取英语字符的Unicode代码点
print(f"ord('h') = {ord('h')}")
# 获取表情符号的Unicode代码点
print(f"ord('🤗') = {ord('🤗')}")
# 获取韩语字符的Unicode代码点
print(f"ord('안') = {ord('안')}")
我们可以检索字符串中所有字符的代码点:
# 获取字符串中每个字符的Unicode代码点
text = "안녕하세요 👋 hello world 🤗"
print([ord(x) for x in text])
考虑到原始代码点已经提供了整数,为什么不直接使用这些作为令牌而不进行任何额外的分词呢?
一个原因是词汇表大小。Unicode词汇表包含大约150,000个不同的代码点。更令人担忧的是,Unicode标准继续发展和变化,使其作为语言模型的直接表示可能不稳定。这些因素需要更好的方法。
解决方案在于编码。Unicode联盟定义了三种编码类型:UTF-8、UTF-16和UTF-32。这些编码将Unicode文本转换为二进制数据或字节字符串。UTF-8是最常见的。UTF-8将每个代码点转换为可变长度的字节字符串——介于一到四个字节之间。每个代码点根据编码模式产生一到四个字节。
在三种编码中,UTF-32提供了固定长度编码而不是可变长度的优势,但有着显著的缺点。这些编码之间的完整权衡超出了我们这里的范围。
UTF-8是明确的选择。我们可以检查将字符串编码为UTF-8时会发生什么。Python的字符串类提供了接受编码参数的.encode()方法。将字节对象转换为列表使原始字节更易读,显示根据UTF-8编码表示字符串的字节序列。
text = "안녕하세요 👋 hello world 🤗"
# UTF-8编码
utf8_bytes = list(text.encode('utf-8'))
print(f"UTF-8: {utf8_bytes}")
# UTF-16编码
utf16_bytes = list(text.encode('utf-16'))
print(f"UTF-16: {utf16_bytes}")
# UTF-32编码
utf32_bytes = list(text.encode('utf-32'))
print(f"UTF-32: {utf32_bytes}")
比较UTF-16揭示了它的一个缺点。重复零的模式(零、零加某物、零加某物)展示了浪费的编码。对于简单的ASCII或英语字符,结构始终显示为零加某物的模式。UTF-32在扩展时显示更多的浪费,有长的零序列后跟单个值。这种低效使得两种替代方案对我们的目的都不理想。
虽然UTF-8是首选,但天真地使用它会带来挑战。字节流意味着只有256个可能令牌的词汇表。这个词汇表大小极小,导致文本被拉伸到很长的字节序列。
一种朴素的方法会创建一个微小的嵌入表和一个小的预测层,但代价是极长的序列。Transformers仅支持有限上下文长度,因为注意力机制的计算原因。长序列会低效地消耗这个有限的上下文窗口,阻止模型关注足够长的文本以进行有效的下一个令牌预测。
解决方案需要支持更大的、可调的词汇表大小,同时保持UTF-8编码兼容性。我们如何实现这种平衡?
字节对编码(BPE)算法
BPE基础理解
字节对编码算法提供了答案,允许我们将字节序列压缩到可变数量。在详细探讨BPE之前,值得注意的是,将原始字节序列直接输入语言模型将是理想的。2023年夏季的一篇论文探讨了这种可能性。
挑战在于transformer架构需要修改以处理原始字节。正如前面提到的,对于如此长的序列,注意力变得极其昂贵。该论文提出了一种transformer的分层结构,可以接受原始字节作为输入。作者得出结论:“总之,这些结果建立了在规模上进行无分词自回归序列建模的可行性。”无分词建模将是一个重大进步,允许字节流直接输入模型。然而,这种方法尚未经过多个组在足够规模上的验证。在这种方法成熟之前,我们必须使用字节对编码算法压缩字节序列。
字节对编码算法相对简单,维基百科页面提供了基本概念的清晰解释。该算法在输入序列上运行——例如,仅包含四个词汇元素的序列:a、b、c和d。
当序列变得太长并需要压缩时,算法迭代地识别最常出现的令牌对。一旦识别,该对被替换为附加到词汇表中的单个新令牌。例如,如果字节对‘aa’最常出现,我们创建一个新令牌(称为大写Z),并将‘aa’的每次出现替换为Z,在序列中产生两个Z。
步骤1:初始序列
aaabdaaabac
最常见对:aa(出现2次)
将aa替换为Z:
Zabdaabac → ZabdZabac
这个转换将11个字符的序列(词汇表大小4)转换为9个令牌的序列(词汇表大小5)。第五个词汇元素Z代表了‘aa’的连接。过程重复:检查序列以识别最频繁的令牌对。如果‘ab’现在最频繁,我们创建一个新令牌Y来表示‘ab’,替换每次出现。
步骤2:继续压缩
ZabdZabac
最常见对:ab(出现2次)
将ab替换为Y:
ZYdZYac
序列现在包含七个字符,词汇表有六个元素。在最后一轮中,对‘ZY’出现最多,提示创建令牌X来表示‘ZY’。替换所有出现产生最终序列。
步骤3:最终合并
ZYdZYac
最常见对:ZY(出现2次)
将ZY替换为X:
XdXac
最终结果:XdXac
最终词汇表:{a, b, c, d, Z=aa, Y=ab, X=ZY}
原始长度:11个令牌 → 压缩长度:5个令牌
完成这个过程后,序列已从11个令牌(词汇长度4)转换为5个令牌(词汇长度7)。算法迭代地压缩序列同时铸造新令牌。相同的方法适用于字节序列:从256个词汇表大小开始,我们识别最常见的字节对并迭代地铸造新令牌,将它们附加到词汇表并执行替换。这产生压缩的训练数据集以及使用此词汇表编码任意序列并将其解码回字符串的算法。
要实现这个算法,以下示例使用此博客文章的第一段,复制为单行长文本。
要获取令牌,我们将文本编码为UTF-8。此时的令牌是单个流中的原始字节。为了在Python中更容易操作,我们将字节对象转换为整数列表,以便更好地可视化和处理。输出显示原始段落及其533个代码点的长度。UTF-8编码的字节长度为608字节(或608个令牌)。这种扩展发生是因为简单ASCII字符变为单个字节,而更复杂的Unicode字符变为多个字节,最多四个。
# 步骤1:从Nathan Reed的博客文章中获取示例文本
text = """Unicode! 🅤🅝🅘🅒🅞🅓🅔‽ 🇺🇳🇮🇨🇴🇩🇪! 😄 The very name strikes fear and awe into the hearts of programmers worldwide. We all know we ought to "support Unicode" in our software (whatever that means—like using wchar_t for all the strings, right?). But Unicode can be abstruse, and diving into the thousand-page Unicode Standard plus its dozens of supplementary annexes, reports, and notes can be more than a little intimidating. I don't blame programmers for still finding the whole thing mysterious, even 30 years after Unicode's inception."""
print(f"文本: {text}")
print(f"字符长度: {len(text)}")
# 步骤2:将文本编码为UTF-8字节并转换为整数列表
tokens = list(text.encode("utf-8"))
print(f"UTF-8编码字节: {tokens[:50]}…") # 显示前50个字节
print(f"字节长度: {len(tokens)}")
算法的第一步需要迭代字节以找到最常出现的对,然后我们将合并它。以下实现使用名为get_stats的函数来找到最常见的对。存在多种方法,但这个使用字典来跟踪计数。连续元素的迭代使用带有zip(ids, ids[1:])的Python模式。该函数增加遇到的每个对的计数。
def get_stats(ids, counts=None):
"""
给定整数列表,返回连续对计数的字典
示例:[1, 2, 3, 1, 2] -> {(1, 2): 2, (2, 3): 1, (3, 1): 1}
可选地允许更新现有的计数字典
"""
counts = {} if counts is None else counts
for pair in zip(ids, ids[1:]): # 迭代连续元素
counts[pair] = counts.get(pair, 0) + 1
return counts
zip(ids, ids[1:])模式用于连续对的工作方式如下:
# 步骤3a:了解zip(ids, ids[1:])如何用于连续对
sample_list = [1, 2, 3, 4, 5]
consecutive_pairs = list(zip(sample_list, sample_list[1:]))
print(f"示例列表: {sample_list}")
print(f"连续对: {consecutive_pairs}")
print("这是Andrej提到的迭代连续元素的‘Pythonic方式’")
在令牌上调用get_stats产生一个字典,其中键是连续元素的元组,值是对它们的计数:
# 步骤3:使用get_stats找到最常见的连续对
stats = get_stats(tokens)
print(f"唯一对的总数: {len(stats)}")
# 显示前10个最频繁的对
top_pairs = sorted([(count, pair) for pair, count in stats.items()], reverse=True)[:10]
print("\\n前10个最频繁的对:")
for count, pair in top_pairs:
print(f" {pair}: {count} 次")
为了更清晰地显示结果,我们可以迭代字典项(返回键值对)并创建一个值-键列表。这允许我们在其上调用sort(),因为Python默认按元组中的第一个元素排序。使用reverse=True产生降序。
结果显示对(101, 32)出现最频繁,出现20次。在令牌列表中搜索101, 32的所有出现证实了这20个实例。
# 步骤4:使用max()函数获取最频繁的对
most_frequent_pair = max(stats, key=stats.get)
print(f"最频繁的对: {most_frequent_pair}")
print(f"出现 {stats[most_frequent_pair]} 次")
# 将字节转换回字符以查看此对代表什么
char1 = chr(most_frequent_pair[0])
char2 = chr(most_frequent_pair[1])
print(f"这代表: '{char1}' + '{char2}'")
要检查这对代表什么,我们使用chr,这是Python中ord的逆。给定Unicode代码点101和32,我们发现这代表‘e’后跟一个空格。文本中许多单词以‘e’结尾,解释了这个对的频率。
我们可以通过在文本中查找其出现来验证最频繁的对:
# 步骤4a:通过在文本中查找其出现来验证最频繁的对
pair_to_find = most_frequent_pair # (101, 32) 即 'e' + ' '
# 找到此对出现的所有位置
occurrences = []
for i in range(len(tokens) – 1):
if tokens[i] == pair_to_find[0] and tokens[i + 1] == pair_to_find[1]:
occurrences.append(i)
print(f"找到 {len(occurrences)} 次出现对 {pair_to_find} ('e' + ' ') 在位置:")
print(f"位置: {occurrences}")
识别出最常见对后,下一步是迭代序列并铸造ID为256的新令牌。当前令牌范围从0到255,使256成为下一个可用ID。该算法将迭代整个列表,将(101, 32)的每次出现替换为256。
# 步骤5:准备合并 – 创建新令牌ID
# 当前令牌是0-255(256个可能值),所以新令牌将是256
new_token_id = 256
print(f"将用新令牌ID替换对 {most_frequent_pair}: {new_token_id}")
print(f"准备实现合并函数…")
Python提供了一种优雅的方式来使用max()在stats字典上获取最高排名的对。这返回最大键。使用key=stats.get指定排名函数,该函数返回每个键的值。这按值排名并返回具有最大值的键:(101, 32)。
识别出最常见对后,下一步是迭代序列并铸造ID为256的新令牌。当前令牌范围从0到255,使256成为下一个可用ID。算法迭代整个列表,将(101, 32)的每次出现替换为256。
# 步骤6:实现合并函数
def merge(ids, pair, idx):
"""
在整数列表(ids)中,将所有连续出现
的对替换为新的整数令牌idx
示例:ids=[1, 2, 3, 1, 2], pair=(1, 2), idx=4 -> [4, 3, 4]
"""
newids = []
i = 0
while i < len(ids):
# 如果不在最后一个位置且对匹配,则替换它
if ids[i] == pair[0] and i < len(ids) – 1 and ids[i+1] == pair[1]:
newids.append(idx)
i += 2 # 跳过对
else:
newids.append(ids[i])
i += 1
return newids
首先用简单示例测试以演示合并函数的行为:
# 用简单示例测试
test_ids = [5, 6, 6, 7, 9, 1]
result = merge(test_ids, (6, 7), 99)
print(f"原始: {test_ids}")
print(f"合并 (6, 7) -> 99 后: {result}")
将合并应用于实际令牌:
# 步骤7:将合并应用于我们的实际令牌
# 将最频繁对 (101, 32) 与令牌ID 256合并
tokens2 = merge(tokens, most_frequent_pair, new_token_id)
print(f"原始长度: {len(tokens)}")
print(f"合并后长度: {len(tokens2)}")
print(f"减少: {len(tokens) – len(tokens2)} 个令牌")
# 验证合并工作
print(f"\\n新令牌 {new_token_id} 的出现次数: {tokens2.count(new_token_id)}")
print(f"原始中旧对的出现次数: {sum(1 for i in range(len(tokens)–1) if (tokens[i], tokens[i+1]) == most_frequent_pair)}")
# 验证旧对消失
old_pair_count = sum(1 for i in range(len(tokens2)–1) if (tokens2[i], tokens2[i+1]) == most_frequent_pair)
print(f"新令牌中旧对的出现次数: {old_pair_count}")
BPE算法迭代进行:找到最常见的对,合并它,然后重复。
# 步骤8:迭代BPE算法
# 现在我们重复:找到最常见的对,合并它,重复…
# 让我们再做几次迭代
current_tokens = tokens2
vocab_size = 257 # 从256开始,现在有257
print("BPE训练进度:")
print(f"步骤0: {len(tokens)} 个令牌, 词汇表大小: 256")
print(f"步骤1: {len(current_tokens)} 个令牌, 词汇表大小: {vocab_size}")
# 再做几次迭代
for step in range(2, 6): # 步骤2-5
# 找到最常见的对
stats = get_stats(current_tokens)
if not stats: # 没有更多对可合并
break
most_frequent_pair = max(stats, key=stats.get)
# 合并它
current_tokens = merge(current_tokens, most_frequent_pair, vocab_size)
print(f"步骤 {step}: {len(current_tokens)} 个令牌, 词汇表大小: {vocab_size + 1}")
print(f" 合并的对: {most_frequent_pair} -> {vocab_size}")
vocab_size += 1
print(f"\\n最终: {len(current_tokens)} 个令牌, 词汇表大小: {vocab_size}")
跟踪合并揭示了分词器学到了什么:
# 跟踪我们进行的合并
merges = {
256: (101, 32), # 'e' + ' '
257: (100, 32), # 'd' + ' '
258: (116, 101), # 't' + 'e'
259: (115, 32), # 's' + ' '
260: (105, 110) # 'i' + 'n'
}
for token_id, (byte1, byte2) in merges.items():
char1, char2 = chr(byte1), chr(byte2)
print(f"令牌 {token_id}: ({byte1}, {byte2}) -> '{char1}' + '{char2}' = '{char1}{char2}'")
这完成了BPE的基础知识。算法迭代地找到最频繁的字节对并将它们合并为新令牌,逐渐构建一个有效表示文本的词汇表。
构建核心函数
在概念上理解了BPE算法之后,我们现在可以使用训练、编码和解码函数构建完整的分词器。为了获得更具代表性的字节对统计数据并产生合理的结果,我们将使用整个博客文章作为训练文本,而不是仅使用第一段。原始文本使用UTF-8编码编码为字节,然后在Python中转换为整数列表以便操作。
# text = 从colab笔记本复制的完整博客文章文本
tokens = list(text.encode("utf-8"))
print(f"UTF-8编码字节: {tokens[:50]}…") # 显示前50个字节
print(f"字节长度: {len(tokens)}")
合并循环使用相同的两个函数(get_stats和merge),在此为参考重新定义。新代码首先设置最终词汇表大小——一个根据最佳性能调整的超参数。使用276作为目标词汇表大小意味着执行恰好20次合并,因为我们从256个原始字节令牌开始。
# BPE训练
vocab_size = 276 # 超参数:期望的最终词汇表大小
num_merges = vocab_size – 256
tokens = list(text.encode("utf-8"))
for i in range(num_merges):
# 计算所有对
stats = get_stats(tokens)
# 找到计数最高的对
pair = max(stats, key=stats.get)
# 铸造新令牌:分配下一个可用id
idx = 256 + i
# 用idx替换令牌中所有出现的对
tokens = merge(tokens, pair, idx)
# 打印进度
print(f"合并 {i+1}/{num_merges}: {pair} -> {idx} ({stats[pair]} 次出现)")
用list()包装tokens列表在Python中创建列表的副本。merges字典维护从子对到新令牌的映射,构建一个合并的二进制森林。这种结构与树不同,因为我们从底部的叶子开始(单个字节作为起始256个令牌)并一次合并两个,创建多个根而不是单个根。
对于20次合并中的每一次,算法找到最常出现的对,铸造一个新令牌整数(当i为零时从256开始),并用新铸造的令牌替换该对的所有出现。合并记录在字典中。运行此操作产生显示所有20次合并的输出。
第一次合并匹配我们之前的示例:令牌(101, 32)合并为新令牌256。注意,单个令牌101和32在合并后仍然可以在序列中出现——只有连续出现变为256。新铸造的令牌在后续迭代中也有资格合并。第20次合并将令牌256和259合并为275,演示了替换如何在下一轮中使令牌有资格合并。这构建了一个小的二进制森林而不是单个树。
实现的压缩比可以从令牌计数计算。原始文本包含24,000字节,经过20次合并后减少到19,000令牌。大约1.27的压缩比来自将这两个值相除。更多的词汇元素将进一步增加压缩比。
这个过程代表了分词器的训练。分词器是与大型语言模型本身完全分离的对象——整个讨论仅涉及分词器训练,而不是LLM。分词器经历自己的预处理阶段,通常与LLM分开。
分词器有自己的训练文档集,可能与LLM的训练集不同。使用字节对编码算法训练分词器以创建词汇表。一旦使用其词汇表和合并训练完成,分词器可以执行编码和解码——在原始文本(Unicode代码点序列)和令牌序列之间双向转换。
有了具有合并的训练分词器,我们现在可以实现编码和解码步骤。给定文本,分词器产生令牌;给定令牌,它产生文本。这个翻译层位于两个领域之间。
语言模型作为单独的第二步进行训练。在最先进的应用中,语言模型的所有训练数据通常首先通过分词器运行,将所有内容翻译成庞大的令牌序列。然后可以丢弃原始文本,只留下令牌存储在磁盘上供大型语言模型在训练期间读取。这代表了使用单个大规模预处理阶段的一种方法。
关键是分词器训练是一个完全独立的阶段,有自己的训练集。分词器和大型语言模型的训练集可能有意不同。例如,分词器训练应考虑到许多不同语言的性能,而不仅仅是英语,以及代码与自然语言。分词器训练集中不同语言和不同代码量的混合决定了每种类型内容发生多少次合并,这影响了令牌空间中的表示密度。
直观地,在分词器训练集中包含大量日语数据会导致更多的日语令牌合并,为日语文本产生更短的令牌序列。这有益于大型语言模型,后者在令牌空间中具有有限的上下文长度。
分词器训练完成且合并确定后,我们现在可以转向实现编码和解码。
解码:从令牌返回文本
解码函数将令牌序列翻译回Python字符串对象(原始文本)。目标是实现一个接受整数列表并返回Python字符串的函数。这是一个在查看解决方案之前自己尝试的好练习。
这是一种实现方法。首先,创建一个名为vocab的预处理变量——一个将令牌ID映射到其相应字节对象的字典。从令牌0到255的原始字节开始,然后按顺序迭代所有合并来填充vocab字典。每个合并令牌的字节表示是其两个子令牌字节的连接。
# 跟踪我们进行的合并
merges = {
(101, 32) : 256, # 'e' + ' '
(100, 32) : 257, # 'd' + ' '
(116, 101) : 258, # 't' + 'e'
(115, 32) : 259, # 's' + ' '
(105, 110): 260 # 'i' + 'n'
}
# 给定ids(整数列表),返回Python字符串
vocab = {idx: bytes([idx]) for idx in range(256)}
for (p0, p1), idx in merges.items():
vocab[idx] = vocab[p0] + vocab[p1]
def decode(ids):
# 给定ids,获取令牌
tokens = b"".join(vocab[idx] for idx in ids)
# 从字节转换为字符串
text = tokens.decode("utf-8")
return text
一个重要的细节:使用.items()迭代字典要求迭代顺序与插入到merges字典中的项目顺序匹配。从Python 3.7开始,这是有保证的,但早期版本可能以不同顺序迭代,可能导致问题。
解码函数首先通过查找vocab字典中的每个ID并将所有字节连接在一起来将ID转换为令牌。这些令牌是原始字节,必须使用UTF-8解码以转换回Python字符串。这反转了之前的.encode()操作:不是在字符串对象上调用encode以获取字节,而是在字节对象上调用decode以获取字符串。
测试函数:
print(decode([97])) # 应该正常工作
然而,这个实现有一个潜在问题,可能在某个不幸的ID序列上抛出错误。解码令牌97工作正常,返回字母‘a’。但尝试将令牌128解码为单个元素会产生错误:
try:
print(decode([128])) # 这将导致UnicodeDecodeError
except Exception as e:
print(str(e))
错误消息显示:“UnicodeDecodeError: ‘utf-8’ codec can’t decode byte 0x80 in position 0: invalid start byte.”
这个错误与UTF-8编码模式有关。UTF-8字节遵循特定格式,特别是对于多字节字符。128的二进制表示是一个后跟所有零(10000000)。这不符合UTF-8规则——以‘1’开头的字节必须后跟另一个‘1’,然后‘0’,然后是Unicode内容。单个‘1’后跟零是无效的起始字节。
并非每个字节序列都代表有效的UTF-8。如果大型语言模型预测无效序列中的令牌,解码将失败。解决方案是在bytes.decode函数中使用errors参数。默认情况下,errors设置为‘strict’,这会对无效的UTF-8字节编码抛出错误。Python提供了许多错误处理选项。更改为errors="replace"会用替换字符(�)替换无效序列:
def decode(ids):
# 给定ids(整数列表),返回Python字符串
tokens = b"".join(vocab[idx] for idx in ids)
text = tokens.decode("utf-8", errors="replace")
return text
try:
print(decode([128])) # 这现在应该打印替换字符而不出错
except Exception as e:
print(str(e))
标准做法是使用errors=“replace”,这在OpenAI代码发布中也可见到。每当在输出中看到替换字符(�),表明LLM产生了无效的令牌序列。
编码:从文本到令牌
编码函数执行反向操作:将字符串转换为令牌。函数签名接受文本输入并返回表示令牌的整数列表。这是另一个在查看解决方案之前自己尝试的好练习。
这是一种实现方法。首先,使用UTF-8将文本编码以获取原始字节,然后将字节对象转换为整数列表。这些起始令牌代表序列的原始字节。
def encode(text):
# 给定字符串,返回整数列表(令牌)
tokens = list(text.encode("utf-8"))
while True:
stats = get_stats(tokens)
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break # 没有其他可合并的
idx = merges[pair]
tokens = merge(tokens, pair, idx)
return tokens
根据merges字典,一些字节可能会合并。回想一下,merges是从上到下按插入项目的顺序构建的。我们必须按此顺序应用合并——从上到下——因为后面的合并依赖于前面的。例如,后面的合并可能依赖于令牌256,这是由较早合并创建的。
由于预期有多个合并,函数使用while循环。目标是找到根据merges字典允许合并的连续字节对。为了重用现有功能,get_stats函数计算每对在令牌序列中出现的次数,并将其作为映射字节对到出现次数的字典返回。对于此实现,仅此字典的键重要——我们只需要可能的合并候选集,而不是它们的频率。
测试编码:
print(encode("hello world!"))
以及两者一起:
print(decode(encode("hello world!")))
下一步是识别在每次循环迭代中合并哪一对。我们想要merges字典中索引最低的对,确保早期合并发生在后期之前。实现使用Python的min函数在迭代器上。在字典上调用min时,Python迭代键——在这种情况下是所有连续对。key参数指定为比较返回值的函数。这里,我们使用merges.get(p, float(“inf”))来检索每对在merges字典中的索引。
处理边缘情况
当前实现需要改进以处理特殊情况。尝试编码单个字符会产生错误,因为当令牌列表仅包含一个元素或为空时,stats字典为空,导致min失败。解决方案是检查令牌列表是否至少有两个元素,然后再继续合并。如果少于两个令牌,没有可合并的,因此函数立即返回。
try:
print(encode('h'))
except Exception as e:
print(e)
def encode(text):
# 给定字符串,返回整数列表(令牌)
tokens = list(text.encode("utf-8"))
while True:
stats = get_stats(tokens)
if len(tokens) < 2:
break # 没有可合并的
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break # 没有其他可合并的
idx = merges[pair]
tokens = merge(tokens, pair, idx)
return tokens
encode('h')
测试编码-解码循环揭示了一个重要属性。编码字符串然后解码它应该返回相同的字符串:
# 测试编码/解码对训练文本是恒等变换
text2 = decode(encode(text))
print(text == text2)
# 测试新验证文本
valtext = "Many common characters, including numerals, punctuation, and other symbols, are unified within the standard"
print(decode(encode(valtext)) == valtext)
这在一般情况下成立,但反向不保证。并非所有令牌序列都代表有效的UTF-8字节流,使得一些序列不可解码。恒等属性仅在一个方向有效。使用训练文本测试确认编码和解码返回原始文本。使用验证数据测试——从该网页抓取的文本,分词器尚未见过——也正确工作,给实现带来信心。
这些是字节对编码算法的基础。该过程获取训练集并训练分词器,其中参数仅仅是merges字典。这在原始字节之上创建了一个二进制森林。有了这个合并表,我们可以在原始文本和令牌序列之间进行编码和解码。
这代表了最简单的分词器设置。下一步是检查最先进的大型语言模型及其分词器。图片变得明显更复杂。以下部分一次一个地探索这些复杂性。
GPT-2和GPT-4分词器
基于正则表达式的预分词
2019年的GPT-2论文提供了对GPT系列中使用的分词方法的见解。“输入表示”部分阐述了在UTF-8编码的字节级别表示上使用字节对编码算法的动机,讨论了词汇表大小和实现细节。
论文涵盖的概念与前面讨论的基础一致,但引入了一个重要的分歧。作者不直接应用朴素的BPE算法。考虑一个动机示例:常见单词如“dog”在文本中经常与各种标点符号一起出现——“dog.”、“dog!”、“dog?”等。朴素的BPE算法可能会将这些合并为单个令牌,导致许多令牌将同一单词与不同标点符号组合。这将不应该聚类的元素聚类在一起,将语义与标点符号混合。
论文承认这种次优性:
“我们观察到BPE包括许多常见单词(如‘dog’)的多个版本,因为它们出现在许多上下文中(例如‘dog.’、‘dog!’、‘dog?’等)。这导致有限词汇表槽位和模型容量的次优分配。为了避免这种情况,我们防止BPE跨字符类别合并任何字节序列。”
为了解决这个问题,作者手动强制执行规则,防止某些字符类型合并在一起,在字节对编码算法之上施加约束。
GitHub上的GPT-2仓库包含encoder.py中的实现。(文件名有些误导,因为这是分词器,处理编码和解码。)执行合并规则的核心机制是一个复杂的正则表达式模式:
# GPT-2编码器与正则表达式模式
class Encoder:
def __init__(self, encoder, bpe_merges
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)或者 我的个人博客 https://blog.qife122.com/
对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)





