LLMs模型是如何理解和生成文字的呢? 这背后,一个至关重要的环节就是分词 (Tokenization)。
前面我们介绍了picoGPT:GPT 的“迷你版”,麻雀虽小五脏俱全,一个用纯 Python 实现的极简 GPT 模型。 它的目标是让学习者能够更轻松地理解 GPT 的内部工作原理。 在这个项目中,有一个名为 encoder.py 的文件,它直接搬运了 OpenAI GPT-2 仓库中的 BPE (Byte Pair Encoding) 分词器代码。 我们下面就以它为主线来介绍分词器。
为什么我们要关注 BPE?
想象一下,如果让模型直接处理原始的字符,那词汇量将无比庞大,而且对于模型理解词语之间的关系会非常困难。如果以单词为单位进行分词,又会遇到新词或罕见词无法识别的问题。
BPE 巧妙地解决了这个问题。它是一种子词 (Subword) 分词算法,能够将单词拆分成更小的、更常见的单元,从而在词汇量大小和处理未知词汇之间取得平衡。 就像搭积木一样,用有限的“积木块”组合出各种各样的“单词”,这些子词就是我们常说的token。
分词器使用
词汇表以及决定字符串如何分解的字节对组合(byte-pair merges),是通过训练分词器获得的,我们这里使用的是GPT-2中使用的BPE分词器。
当我们加载分词器,就会从一些文件加载已经训练好的词汇表和字节对组合,这些文件在我们运行load_encoder_hparams_and_params的时候,随着模型文件被一起下载了。
你可以查看models/124M/encoder.json(词汇表)和models/124M/vocab.bpe(字节对组合)。
我们在用LLM生成内容是,输入的是一些文本,这些文本被表示成一串整数序列,每个整数都与文本中的token对应,通过下面代码可以看到影射关系:
>>> ids = encoder.encode("Not all heroes wear capes.")
>>> ids
[3673, 477, 10281, 5806, 1451, 274, 13]
>>> encoder.decode(ids)
"Not all heroes wear capes."
分词器的词汇表(存储于encoder.decoder),我们可以看看实际的token到底长啥样:
>>> [encoder.decoder[i] for i in ids]
['Not', 'Ġall', 'Ġheroes', 'Ġwear', 'Ġcap', 'es', '.']
注意:
- 有的时候我们的token是单词(比如:Not),
- 有的时候虽然也是单词,但是可能会有一个空格在它前面(比如Ġall, Ġ代表一个空格),
- 有时候是一个单词的一部分(比如:capes被分隔为Ġcap和es),
- 还有可能它就是标点符号(比如:.)。
BPE的一个好处是它可以编码任意字符串。如果遇到了某些没有在词汇表里显示的字符串,那么BPE就会将其分割为它能够理解的子串:
>>> [encoder.decoder[i] for i in encoder.encode("zjqfl")]
['z', 'j', 'q', 'fl']
我们可以看下中文的支持
>>> ids = encoder.encode("郭红俊测试!")
>>> ids
[32849, 255, 163, 118, 95, 46479, 232, 38184, 233, 46237, 243, 171, 120, 223]
>>> encoder.decode(ids)
郭红俊测试!
>>> [encoder.decoder[i] for i in ids]
éĥ
Ń
ç
º
¢
ä¿
Ĭ
æµ
ĭ
è¯
ķ
ï
¼
ģ
我们还可以检查一下词汇表的大小:
>>> len(encoder.decoder)
50257
完整的分词器使用代码:
from utils import load_encoder_hparams_and_params
model_size: str = "124M"
models_dir: str = "models"
# load encoder, hparams, and params from the released open-ai gpt-2 files
encoder, hparams, params = load_encoder_hparams_and_params(model_size, models_dir)
ids = encoder.encode("Not all heroes wear capes.")
print(ids)
print(encoder.decode(ids))
for i in ids:
print(encoder.decoder[i])
ids = encoder.encode("郭红俊测试!")
print(ids)
print(encoder.decode(ids))
for i in ids:
print(encoder.decoder[i])
print(len(encoder.decoder))
--
走进 picoGPT 的 encoder.py
现在,让我们一起打开 picoGPT 的 encoder.py,看看 BPE 是如何实现的:
get_encoder(model_name, models_dir):加载预训练的 BPE 分词器
这个函数负责加载预训练模型的 BPE 分词器。
def get_encoder(model_name, models_dir):
with open(os.path.join(models_dir, model_name, "encoder.json"), "r") as f:
encoder = json.load(f)
with open(os.path.join(models_dir, model_name, "vocab.bpe"), "r", encoding="utf-8") as f:
bpe_data = f.read()
bpe_merges = [tuple(merge_str.split()) for merge_str in bpe_data.split("\n")[1:-1]]
return Encoder(encoder=encoder, bpe_merges=bpe_merges)
它会读取存储在 encoder.json (包含词汇表) 和 vocab.bpe (包含合并规则) 文件中的数据,并创建一个 Encoder 实例。
Encoder 类:BPE 分词的核心
这是 encoder.py 中最重要的部分,包含了 BPE 分词的主要逻辑。
主要属性:
- pat: 一个正则表达式,用于初步将文本分割成更小的单元,例如将 "Hello world!" 分割成 "Hello", " world", "!"。
- bpe_merges: 一个列表,记录了 BPE 算法学习到的合并规则,例如 ('e', 'r') 表示 'e' 和 'r' 应该被合并。
- bpe_ranks: 将 bpe_merges 列表中的合并操作赋予优先级,越早出现的合并操作优先级越高。
- encoder: 一个字典,存储了词汇表(也是),将每个 token (包括单个字符和合并后的子词) 映射到一个唯一的 ID。
主要方法:
encode(self, text):文本编码成 token ID
这个方法是整个编码流程的入口。
def encode(self, text):
bpe_tokens = []
for token in re.findall(self.pat, text):
token = "".join(self.byte_encoder[b] for b in token.encode("utf-8"))
bpe_tokens.extend(self.encoder[bpe_token] for bpe_token in self.bpe(token).split(" "))
return bpe_tokens
它首先使用 self.pat 对文本进行初步分割,然后将每个分割后的单元进行字节到 Unicode 的转换,接着使用 self.bpe() 方法进行 BPE 合并,最后通过 self.encoder 将合并后的子词映射到对应的 ID。
decode(self, tokens):token ID 解码回文本
这个方法执行与 encode 相反的操作,将 token ID 序列转换回原始的文本。
def decode(self, tokens):
text = "".join([self.decoder[token] for token in tokens])
text = bytearray([self.byte_decoder[c] for c in text]).decode("utf-8", errors=self.errors)
return text
--
bpe(self, token):执行 BPE 合并
这个方法接收一个 token,并根据 bpe_ranks 中的优先级(预先学习到的 BPE 合并规则),逐步合并相邻的字符对,直到无法合并为止。
例如,对于 "lower",如果模型学习到 "ow" 和 "er" 是常见的组合,它可能会先合并 "ow",再合并 "er",最终得到 "low" 和 "er" 两个子词。
def bpe(self, token):
if token in self.cache:
return self.cache[token]
word = tuple(token)
pairs = get_pairs(word)
if not pairs:
return token
while True:
bigram = min(pairs, key=lambda pair: self.bpe_ranks.get(pair, float("inf")))
if bigram not in self.bpe_ranks:
break
first, second = bigram
new_word = []
i = 0
while i < len(word):
try:
j = word.index(first, i)
new_word.extend(word[i:j])
i = j
except:
new_word.extend(word[i:])
break
if word[i] == first and i < len(word) - 1 and word[i + 1] == second:
new_word.append(first + second)
i += 2
else:
new_word.append(word[i])
i += 1
new_word = tuple(new_word)
word = new_word
if len(word) == 1:
break
else:
pairs = get_pairs(word)
word = " ".join(word)
self.cache[token] = word
return word
整个代码的关键点:
- bpe_ranks 决定了合并的顺序。
- 算法是迭代的,每次合并优先级最高的相邻字符对。
- 使用 get_pairs 来动态获取当前 "词" 的可合并对。
- 缓存机制可以提高性能。
BPE 如何助力 GPT 的“语言魔术”?
有了 BPE 分词器,GPT 就能将输入的文本切分成模型能够理解的单元 (tokens)。 这样做的好处是:
-
控制词汇量: BPE 算法可以有效控制模型的词汇量大小,避免过于庞大。
-
处理未知词汇: 对于不在词汇表中的新词,BPE 可以将其拆分成已知的子词单元进行处理,提高了模型的泛化能力。
-
捕捉词义信息: 通过学习常见的字符组合,BPE 可以在一定程度上捕捉到词语的语义信息,例如 "unhappy" 可以被拆分成 "un" 和 "happy",模型可以学习到 "un" 有否定的含义。
总结
picoGPT 的 encoder.py 文件虽然简洁,但却清晰地展示了 GPT 模型中 BPE 分词器的核心原理。 通过理解这段代码,我们就能更好地理解 GPT 是如何将人类的语言转换成机器可以处理的数字序列,从而实现强大的文本理解和生成能力。
如果你对 GPT 的内部机制充满好奇,不妨深入研究一下 picoGPT 这个项目,特别是它的 encoder.py 文件。 相信你会有更深入的理解和收获! 它就像一个窗口,让你窥见大型语言模型背后的精巧设计。
希望这篇文章能帮助你揭开 GPT “语言魔术” 的一角,认识到 BPE 分词器在其中的重要作用。 下次当你使用 GPT 生成一段文字时,不妨想想它背后默默工作的 BPE,它正在默默地将你的语言分解、理解,并最终创造出令人惊叹的结果。
浙公网安备 33010602011771号