动手理解 BPE 分词器

在 2010 年之前,大多数系统是基于规则的,统计方法(N-Gram)被广泛使用。 这种方法在早期 NLP 系统中用于拼写检查、语言检测和非常基础的文本生成,在字节对编码成为主导之前。

但在 2018 年之后,自从"Attention is All You Need"问世并成为 AI 革命,大多数模型转向了子词分词器(BPE、WordPiece),这些字符级分词器从大型语言模型中被淘汰。

基本时间线

1、为什么字符级分词器实际上是聪明的:

  1. 没有词外问题(OOV 问题解决了)。由于字符级分词器使用每个字符,如果它们在多样化文本上训练过,就不会有词外问题,因为所有字符都已经在训练集中了。
  2. 语言无关。在字符级编码中,即使我们更改训练词汇的语言也不需要代码修改,因为它会以相同的方式处理该语言的文本。 尽管不需要代码修改,但语言检测变得复杂,比如印地语中有复杂的字符组合需要不同类型的处理。
  3. 能自然处理拼写错误。"helo" → 仍然可以理解,而在词级编码中,"helo" 会产生 OOV,需要通过拉普拉斯平滑来处理。

2、让我们从零开始构建

让我们取一个简短简单的训练语料库

text = "in the midst of chaos, there is also opportunity. the journey of learning is never easy, but it is always rewarding. every mistake you make is a step closer to understanding. intelligence is not just about knowing facts, but about connecting ideas and seeing patterns where others see noise. persistence and curiosity are the two most powerful tools you can carry. over time, even the most complex problems begin to feel manageable, and what once seemed impossible becomes second nature."

在这段文本中,你可以看到大部分字母、空格和一些标点符号。但机器不理解任何这些。 所以我们通过给它们唯一索引将其转换为数字

char = sorted(set(text))

char_to_idx = {}
for i, ch in enumerate(char):
    char_to_idx[ch] = i

idx_to_char = {}
for ch, i in char_to_idx.items():
    idx_to_char[i] = ch

在这段代码中我们主要做了两件事:

  1. 给所有字符分配了唯一的数字
  2. 创建了索引到字符和字符到索引的查找字典。
tokenizer = {
    "char_to_idx": char_to_idx,
    "idx_to_char": idx_to_char
}
bigram_counts = np.zeros((len(char), len(char)))
tokens = [char_to_idx[ch] for ch in text]

for i in range(len(tokens)-1):
    bigram_counts[tokens[i], tokens[i+1]] += 1

在这段代码中,我们主要构建了一个矩阵,用于查找训练集中两个相邻字符对出现的频率,并将它们存储在一个与唯一字符数量相同大小的矩阵中。

def softmax(x):
    exp_x= np.exp(x - np.max(x))
    return exp_x / exp_x.sum()

bigram_probs = np.zeros_like(bigram_counts)
for i in range(len(char)):
    rows = bigram_counts[i]
    if rows.sum() > 0:
        bigram_probs[i] = softmax(rows)

这是主要的数学部分,我们通过应用 SoftMax 函数对矩阵进行归一化,使所有值都在 [0,1] 范围内。

关于我的实现有一件事我想诚实说明。我使用 SoftMax 来归一化 bigram 计数,这并不完全是正确的做法。

SoftMax 是为 logits 设计的,不是为频率计数设计的。当你将它应用于计数时,它会微妙地压缩频繁和罕见对之间的差异。出现 50 次和 2 次的对之间的差距感觉比实际更近。

正确的方法是简单的归一化:

bigram_probs[i] = bigram_counts[i] / bigram_counts[i].sum()

代码中的小区别,理解上的大区别。

这是应用 SoftMax 函数后的矩阵。

def generation(start_char, max_length):
    start_idx = char_to_idx[start_char]
    current_idx = start_idx
    result = [start_char]

    for _ in range(max_length - 1):
        next_probs = bigram_probs[current_idx]
        next_idx = np.random.choice(len(char), p=next_probs)
        result.append(idx_to_char[next_idx])
        current_idx = next_idx
    
    return ''.join(result)

这个函数使用了前面所有的代码块,通过传入起始字符和想要生成的总字符数来帮助生成下一个字符。

它基本上基于条件概率方程,当前事件已知,借助它来找出下一个事件的出现概率

Bigram 计数:P(next_char | current_char)

3、但它在这里就崩溃了

  1. 序列太长例如,"I am understanding the concept": 字符级分词器会生成 30-40 个 token,而子词级只需要 6-8 个 token。 这是一个问题,因为在 Transformer 中更多的 token 会导致计算爆炸(注意力成本 = O(n²),因此字符级 token 会产生 1600 次操作,而子词级只有约 86 次)。
  2. 模型必须从零构建意义要理解"Computers",词级会像 c -> co -> com -> comp ……,极其低效。
  3. 长距离依赖如果文本:"cat on the mat was hungry" 要计算"was hungry"——谁?——→"the Cat" 模型必须记住 cat 才能计算 was hungry,这需要记忆,而它没有。

4、那么,实际修复了这个问题的是什么?

这些不是小问题。仅注意力成本就让字符级分词成为死路。

然后出现了字节对编码(BPE)。但 BPE 根本不是为 NLP 发明的。

Philip Gage 在 1994 年为文件压缩发明了它。想法很简单:找到最频繁的字节对,用一个未使用的字节替换它,重复。更小的文件,相同的信息。

2015 年,Sennrich 等人看到这个想法后想:如果我们用语言的字符来做会怎样?不是压缩文件,而是构建词汇。它成功了。

它实际上是如何工作的?

让我们取一个小语料库,和之前一样:

"low low low wide new"

从单个字符开始:

l o w
l o w
l o w
w i d e
n e w

现在计算每个相邻对:

(l, o) → 3
(o, w) → 4   ← 最频繁
(w, i) → 1

合并最频繁的。o + w 变成 ow

l ow
l ow
l ow
w i d e
n e ow

再次计数。现在 (l, ow) 出现 3 次。合并它:

low
low
low
w i d e
n e ow

我们持续这样做,直到达到词汇量限制。这个限制是我们自己设定的。GPT-2 将其设为 50,257。

结果是子词。不是完整的单词,也不是单个字符。像"un"、"ing"、"tion"这样出现频率足够高、值得拥有自己 token 的片段。

BPE 从不决定什么有意义。它只合并出现最频繁的内容。不知何故,这仍然产生了有意义的子词。

5、BPE 的现状

GPT-2、GPT-3、GPT-4 使用 tiktoken,基于 BPE。LLaMA 和 Mistral 使用 SentencePiece,BPE 的变体。BERT 使用 WordPiece,相同的理念,稍微不同的合并规则。

每次你在 ChatGPT 中输入时,你的文字都在被来自 1994 年文件压缩论文的逻辑静默地切割成子词。

但它并不完美

词汇量大小是一个猜测。太小则稀有词会被过度拆分。太大则内存膨胀。没有完美的数字,只是调优。

它不是语义感知的。BPE 不知道"unhappy"和"not happy"意思相同。它只看到字符模式。

而且它在非英语语言上表现挣扎。印地语、阿拉伯语、中文。一个印地语单词可能爆炸成很多 token。这仍然是今天活跃的研究问题。

6、我从构建中学到的

从零开始,没有库,没有捷径,我亲身体会到了字符级分词的每一个限制。序列太长。上下文蒸发。意义每次都从零重建。

那种挫败感让我真正理解了 BPE。

BPE 没有让模型理解语言。它解决了阻碍一切的基建问题。合适大小的块——不太小,不太大——给了 Transformer 真正的机会。

有时候最好的想法只是需要找到正确的问题。


原文链接: Building an LLM from Scratch — Here's Where It All Starts

汇智网翻译整理,转载请标明出处