minbpe:BPE算法的极简实现

github 地址:karpathy/minbpe


1.BPE 算法

BPE(Byte Pair Encoding) 是大模型的 tokenizer 常用的算法。它对输入文本的字节进行编码。

该算法因 GPT-2 的论文代码而被广泛使用于 LLM。Sennrich et al. 2015被认为是 BPE 在 NLP 应用中的原始参考。

简单来说,bpe 把文本看作 utf-8 编码的字节,然后将出现次数最多的相邻字节合并,生成一个新的编码。如此反复操作。


2.minbpe 简介

2.1quickstart

from minbpe import BasicTokenizer
tokenizer = BasicTokenizer()
text = "aaabdaaabac"

# 训练
tokenizer.train(text, 256 + 3)  # 256tokens, 3merges

# 编码
print(tokenizer.encode(text))

# 解码
print(tokenizer.decode([258, 100, 258, 97, 99]))

# 保存
tokenizer.save("toy")
# writes two files: toy.model (for loading) and toy.vocab (for viewing)

英语字母一个字母对应一个字节。对于"aaabdaaabac",先计算相邻两字节的出现次数,然后选择次数最多的进行合并(“aa”,4次)。

“a”“a”合并为“aa”,编码为256。

然后再计算相邻两字节的出现次数,再合并。

在 toy.vocab 中可以看到所有字符及对应的编码。

toy.vocab

......
[a][a] -> [aa] 256
[aa][a] -> [aaa] 257
[aaa][b] -> [aaab] 258

2.2minbpe和GPT-4分词器功能相同

# 1.证明RegexTokenizer与GPT-4的分词器性能一致
text = "hello123!!!? (안녕하세요!) 😉"

# pip install tiktoken
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
print(enc.encode(text))
# [15339, 4513, 12340, 30, 320, 31495, 230, 75265, 243, 92245, 16715, 57037]

from minbpe import GPT4Tokenizer
tokenizer = GPT4Tokenizer()
print(tokenizer.encode(text))
# [15339, 4513, 12340, 30, 320, 31495, 230, 75265, 243, 92245, 16715, 57037]

# 2.标记特殊token
text = "<|endoftext|>hello world"

print(enc.encode(text, allowed_special="all"))
# [100257, 15339, 1917]

# ours
print(tokenizer.encode(text, allowed_special="all"))
# [100257, 15339, 1917]

调用 encode 时必须显示声明处理特殊标记。allowed_special 参数可以设置为"all"、“none"或一个特殊token列表。

防止恶意注入
风险特殊令牌(如<|endoftext|>、<|user|>等)通常在模型中有特殊含义(如终止序列、角色区分)。如果分词器默认解析用户输入中的特殊令牌,攻击者可能会在用户输入中恶意插入这些令牌,诱导模型执行非预期行为(例如提前终止生成、切换角色)。显式声明allowed_special参数(如限制为 "none" 或指定安全列表),可以避免将用户可控数据中的特殊字符误解析为功能性令牌,本质上是一种输入验证机制。

避免意外的语义干扰
即使没有恶意攻击,用户输入中也可能包含与特殊令牌相同的字符串(例如自然文本中恰好出现<|end|>)。如果分词器默认解析这些字符串为特殊令牌,会破坏原始文本的语义,导致模型处理出错(如错误截断、角色混淆)。通过显式控制,开发者可以确保只有预期的特殊令牌被解析,其他类似字符串仅作为普通文本处理。

3.训练

minbpe 可以在语料上训练自己的分词器。

# 仿照openai,使用正则方法来拆分文本
from minbpe import RegexTokenizer
tokenizer = RegexTokenizer()
tokenizer.train(very_long_training_string, vocab_size=32768)
tokenizer.encode("hello world") # string -> tokens
tokenizer.decode([1000, 2000, 3000]) # tokens -> string
tokenizer.save("tok32k") # writes tok32k.model and tok32k.vocab
tokenizer.load("tok32k.model") # loads the model back from disk

如果要添加special tokens

# 前256为原始字节token,接下来32768-256为合并的token,后面的32768是特殊token
from minbpe import RegexTokenizer
tokenizer = RegexTokenizer()
tokenizer.train(very_long_training_string, vocab_size=32768)
tokenizer.register_special_tokens({"<|endoftext|>": 32768})
tokenizer.encode("<|endoftext|>hello world", allowed_special="all")

3.项目解析

3.1项目结构

项目中有两个分词器,它们都可以执行分词器的三个主要功能:1)在给定文本上训练分词器词汇并执行合并,2)将文本编码为tokens,3)将tokens解码为文本。仓库的文件如下:

  1. minbpe/base.py: 实现了 Tokenizer 类,它是基础类。它包含 trainencodedecodesave/load功能,还有一些常见的工具函数。这个类不是直接使用的,而是用来继承的。
  2. minbpe/basic.py: 实现了 BasicTokenizer ,这是 BPE 算法最简单的实现,直接在文本上运行。
  3. minbpe/regex.py: 实现了 RegexTokenizer ,通过正则表达式模式进一步分割输入文本,这是一个预处理阶段,在分词之前按类别分割输入文本。这确保了不会在类别边界处进行合并。这个方法在 GPT-2 论文中被引入,并且截至 GPT-4 仍在使用。这个类还处理特殊标记(如果有)。
  4. minbpe/gpt4.py: 实现了 GPT4Tokenizer 。这个类是 RegexTokenizer 的一个轻量级包装器,精确地重现了 tiktoken 库中 GPT-4 的分词。

最后,脚本 train.py 在输入文本 tests/taylorswift.txt 上训练了两个主要的分词器,并将词汇表保存到磁盘以供可视化。


3.2代码分析

class 代码分析(https://zerolovesea.github.io/2024/03/09/MiniBPE%EF%BC%9A%E6%8E%A2%E7%A9%B6Github%E4%B8%8A%E6%9C%80%E7%AE%80%E5%8D%95%E7%9A%84BPE%E5%AE%9E%E7%8E%B0%E4%BB%A3%E7%A0%81/#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95):
	def __init__(self):
        super().__init__()

BasicTokenizerencode 时,先将文本转换为utf-8编码,然后计算stats,即文本中相邻字节的频率。

pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))

在 merges 中 寻找 stats ,找不到则设为inf。然后取最小值,因为值越小,说明该token越处于 merge 的早期,这样能实现逐渐聚合。


RegexTokenizer 在训练阶段进行改进:

  1. 使用正则表达式将输入文本拆分为 text_chunks(例如,将 "Hello, world!" 拆分为 ["Hello", ",", " world", "!"])。
  2. 将每个文本块编码为 UTF-8 字节序列。例如,"Hello" 编码为字节 b'Hello',再转为 [72, 101, 108, 108, 111]
  3. 在每个文本块内部计算stats,这一点和basic不同。这样计算相邻字符的频率时不会跨单词。

special token 的处理:

  1. register_special_tokens 将指定的 special token 加入 vocab 中。
  2. encode 时,如果 allowed_special="all",先使用正则表达式将文本中的 special token 分离出来,得到 special_chunks
    1. 对于不含 special token 的 part,使用 encode_ordinary 分割、转换为编码。
    2. special token 直接转换为编码,实现优先处理。
  3. 如果 allowed_special="none",则整个文本使用 encode_ordinary 处理。