banner
Nagi-ovo

Nagi-ovo

Breezing
github

LLM 演进史(六):揭开 Tokenizer 的神秘面纱

Tokenizer 是 LLM 中很重要但又没那么 fancy 的组件,在本系列之前的语言模型建模中,tokenizer 的实现方式是字符级的,将所有可能出现的 65 种字符制作嵌入表,然后用 embedding layer 对训练集进行编码向量化。而实践中,现代语言模型使用了更复杂的方案,通过使用算法如字节对(Byte Pair Encoding, aka BPE)编码算法在块(chunk)级操作。

如 GPT-2 论文 Language Models are Unsupervised Multitask Learners 中,研究人员构建了一个 50,257 大小的词汇表,上下文长度为 1024 tokens。

Screenshot 2024-06-15 at 23.31.43

语言模型的 Transformer 网络中的注意力层中,每个 token 都关注序列中前面的 tokens,也就是看到先前的 1024 个 tokens。

token 可以被视为语言模型的原子单元,而 tokenize 就是将字符串文本转换为 tokens 序列的过程。

题外话:也有直接将字节输入模型而无需分词的研究(如 MegaByte),不过目前还没有得到充分的验证。

First taste#

Tokenization 是导致 LLM 产生很多怪异现象的原因:

Screenshot 2024-06-16 at 23.02.12

  • 为什么 LLM 不能正确拼写单词?分词。
  • 为什么 LLM 不能做一些非常简单的字符串处理任务,比如反转一个字符串?分词。
  • 为什么 LLM 在处理非英语语言(例如日语)时效果更差?分词。
  • 为什么 LLM 在简单算术上表现不好?分词。
  • 为什么 GPT-2 在 Python 编码中遇到了不必要的麻烦?分词。
  • 为什么我的 LLM 在看到字符串 “<|endoftext|>” 时突然停止?分词。
  • 我收到一个关于 “尾随空格” 的奇怪警告是什么原因?分词。
  • 为什么当我发送给 LLM “SolidGoldMagikarp” 时它会胡言乱语?分词。
  • 为什么我应该在使用 LLM 时更喜欢使用 YAML 而不是 JSON?分词。
  • 为什么 LLM 实际上不是端到端的语言建模?分词。
  • 痛苦的真正根源是什么?分词。

tiktokenizer.vercel.app网站对分词做了直观的可视化,这里选择 gpt2 的分词器:

Screenshot 2024-06-16 at 23.00.27

可以注意到,一个 token 中会包含前面的空格

这里的英文分词效果看着还是很正常的;而算术中,一个数字可能会作为两个 token 输入;大小写也会影响分词;非英文语言的分词则很浪费,基本都是一字符一 token;python 代码中,缩进也是由大量空格 token 构成,这些空格会消耗模型的注意力机制能处理的上下文长度,导致大量的上下文信息被占用。就像是同样的含义下,非英语语言的序列被拉长了一样。

因此 gpt2 在 python 编程中效果较差,原因不在于模型层面,而是在于上述的分词特性。

Screenshot 2024-06-16 at 23.00.45

换成 gpt-4 所用的分词器后,可以明显看到分词结果更 “合理” 了,尤其是对空格的处理,这是 OpenAI 有意为之的选择,gpt-4 的编码能力更强很大程度上也是因为 tokenizer 的改进。

同样的输入序列所消耗的 tokens 也减少了近一倍,大致的原因就是后者的分词器中的 token 数量是前者的一倍(50k to 100k

你可以理解为这是一件好事,因为这让 Transformer 模型的输入更为紧凑。现在,在预测下一个 token 时,模型可以利用的上下文长度大约是之前的两倍,这有助于提高预测的准确性和上下文相关性。

然而,随着模型能够处理更多文本的能力增强,嵌入表(embedding table) 的大小也相应增大,这会导致计算 softmax 的成本上升。因此,我们需要在文本处理的紧凑性与计算效率之间找到一个平衡点。目标是使模型既紧凑又高效,从而优化整体性能。

Unicode#

我们的目标是将字符串输入到神经网络中,为此首先需要将文本分词并转换为整数序列。然后,这些整数将用于从向量查找表中检索相应的向量,这些向量接着作为 Transformer 模型的输入。

问题来了,我们想支持其它语言和 emoji 怎么办?

Screenshot 2024-06-17 at 23.49.38

我们来看看在程序中,这些字符是什么样子的。Python 3 中,所有的字符串都是 Unicode 字符串。这意味着你可以在字符串中使用各种语言的字符、符号和表情符号,而不需要担心编码问题:

Screenshot 2024-06-17 at 23.49.58

使用 ord 函数来获取单个字符的 Unicode 码位,遍历字符串即可得到所有码位

那为什么还需要做 tokenize,而不直接用这个编码做为输入呢?

Screenshot 2024-06-17 at 23.58.33

一个原因是这样词表会很大(约 150 000 大小),另一个原因是 Unicode 编码标准是不断变化的,我们可能更想要一种稳定的方式。

UTF-8#

Unicode 联盟定义了 UTF-8、UTF-16 和 UTF-32 这三种编码方式,用于处理不同字符集的文本,这些编码是将 Unicode 文本转换为二进制数据或字节流的方式,而 UTF-8 是其中最常用的一种可变长度编码方式。

Screenshot 2024-06-18 at 00.24.49

可以看到 UTF-8 将每个码点转化为一个长度在 1-4 个字节之间的字节流,这就是所谓的变长编码,而 UTF-32 则是固定长度的。

Python 中的字符串类有 encode 方法来编码:

Screenshot 2024-06-18 at 00.29.03

得到了一个字节对象,让我们将其转为 list 来打印出原始字节:

Screenshot 2024-06-18 at 00.30.03

如果换用 UTF-16,则会看到对于简单的 ASCII 字符或英文字符有大量浪费的 '0' 编码,UTF-32 则会带来更多的 0:

image

但如果我们采用简单的 UTF-8 编码,词汇表将只包含 256 个 token。这会导致文本序列变得异常长。虽然这样嵌入表的大小较小,且顶层预测的维度较低,但长序列的处理效率却非常低。特别是在 Transformer 模型中,由于注意力机制可以处理的上下文长度有限,我们无法高效处理长时间跨度的文本信息。

因此,我们不想直接使用 UTF-8 编码的原始字节。我们希望支持更大的词汇量,并且可以通过调整超参数来实现这一点,怎么在坚持使用 UTF-8 编码的基础上实现呢?

Byte-Pair Encoding#

aka BPE

字节对编码算法允许我们将这些字节序列压缩为可变的量,这个算法并不复杂:

假设要编码的数据是:

aaabdaaaabac

现在的序列的词汇表大小为 4,共 11 个字节,其中字节对 “aa” 出现最频繁,因此将其替换为数据中未使用的字节,如 “Z”。现在有以下数据和替换表:

Zabdzabac
Z=aa

然后对字节对 “ab” 重复这个过程,用 “Y” 替换它:

ZYdZYac
Y=ab
Z=aa

唯一剩下的字节对只出现一次,编码可以在这里停止。或者,编码过程可以继续递归字节对编码,将 “ZY” 替换为 “X”:

XdXac
X=ZY
Y=ab
Z=aa

由于没有字节对再次出现超过一次,因此无法通过字节对编码进一步压缩此数据。
现在我们的序列长度为 5 字节,而词汇表大小变成了 7。

Screenshot 2024-06-19 at 13.46.22

可以看到,编码后的序列长度变长了。原因是普通的 ASCII 字符只占一个字节,而像 emoji 这样的字符在 UTF-8 编码中会占用多个字节。

在 Python 中实现这个迭代算法:

def get_stats(ids):
	counts = {}
	for pair in zip(ids, ids[1:]): # Pythonic way to iterate consecutive elements
		counts[pair] = counts.get(pair, 0) + 1
	return counts

stats = get_stats(tokens)

其中 counts[pair] = counts.get(pair, 0) + 1 相当于:

if pair in counts:
	counts[pair] += 1
else:
	counts[pair] = 1

可以看到,编码后的序列长度变长了。原因是普通的 ASCII 字符只占一个字节,而像 emoji 这样的字符在 UTF-8 编码中会占用多个字节。

在 Python 中实现这个迭代算法:

def get_stats(ids):
	counts = {}
	for pair in zip(ids, ids[1:]): # Pythonic way to iterate consecutive elements
		counts[pair] = counts.get(pair, 0) + 1
	return counts

stats = get_stats(tokens)

其中 counts[pair] = counts.get(pair, 0) + 1 相当于:

if pair in counts:
	counts[pair] += 1
else:
	counts[pair] = 1

Screenshot 2024-06-19 at 14.12.57

输出排序好的结果,可以看到每个 byte pair 的出现次数

ord 的反操作 chr 来看到这个出现频率最高的字节对是什么:

Screenshot 2024-06-19 at 14.18.21

现在来实现 Mint 操作(将字节对铸成一个新 token):

def merge(ids, pair, idx):
	# in the list of ints (ids), replace all consecutive occurences of pair with the new token idx
	newids = []
	i = 0
	while i < len(ids):
		# if we are not at the very last position AND the pair matches, replace it
		if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
			newids.append(idx)
			i += 2
		else:
			newids.append(ids[i])	
			i += 1
	return newids

Screenshot 2024-06-19 at 14.28.11

在完整的 tokens 上运行:

Screenshot 2024-06-19 at 14.29.03

序列果然减少了 616 - 596 = 20 字节

接下来我们会不断迭代这个操作。需要再次强调,这样的编码会缩短序列长度,但增加词汇表的大小。因此,迭代的次数就像一个超参数,需要调整以找到一个合适的平衡点。

这里我们设置词表大小为 276 (刚好是 256 + 20,也就是迭代 20 次算法):

Screenshot 2024-06-23 at 23.43.28

可以发现新生成的 token 也可以在后续的迭代中被合并,因此这个算法就像在建立一个二叉森林

最后查看一下压缩比:

Screenshot 2024-06-25 at 00.03.32

Decode#

Tokenizer 是一个独立于大语言模型本身的预处理阶段,因此也拥有自己的训练集,和我们平时说的训练 LLM 的数据集是有区别的,在下图所示中,tokenizer 起到的是原始本与 token 序列之间的一个翻译层的作用:

Screenshot 2024-06-25 at 00.07.35

可以将原始文本转为 token 序列,反之亦然。因此我们就可以将 LLM 的训练数据输入训练好的 tokenizer,得到所有数据的 token 序列,之后我们只需要这些序列即可。

训练 tokenizer 的时候我们关心多种语言,也包括编程语言。举个例子,如果你的分词器训练集中有大量中文数据,那合并的中文 token 也会增多,因此中文的 token 序列会变短,在上下文长度有限的情况下对中文更友好,今年推出的 GPT-4o 的 tokenizer 就很好的体现了这一点:

Screenshot 2024-06-23 at 23.50.09

《中文友好.jpg》截自 GitHub - jiangyy/gpt-tokens: What are learned in tiktoken?,这类问题史称 “solidgoldmagikarp 问题。

解码的一种实现如下:

Screenshot 2024-06-25 at 00.44.42

但这样的实现存在一种潜在的错误:

Screenshot 2024-06-25 at 00.44.17

128 的 token 解码时出现报错,

原因在于,UTF-8 是一种变长编码方式,不会自动将任意字节序列转换为有效的 UTF-8 格式,咱们的例子中,传入的原始字节不符合如下规范:

Screenshot 2024-06-25 at 00.46.55

解决的方式是修改 Python 中的默认的 error='strict

Screenshot 2024-06-25 at 00.54.13

第二行解码出来的奇怪字符就是 error='replace' 带来的

业界标准做法就是用上述方法实现,当你在输出中看到这种字符时,就说明 LLM 出现了些问题,预测输出不是有效的 token 序列。

Encode#

Screenshot 2024-06-25 at 01.21.00

其中比较难理解的是 lambda + 列表推导式的一步,让我们用最近较火的 Claude 3.5 Sonnet 来解释一下:

Screenshot 2024-06-25 at 01.22.48

Anthropic 封号力度太大,还好 Juchats 聚合了多种模型,很适合白嫖党(无广告费🥹)

这里设置长度大于 2 来确保如果只有一个字符或空字符串时,统计结果为空,避免 min 函数发生报错。

测试其它案例:

Screenshot 2024-06-25 at 01.32.24

现在我们已经了解了如何用训练数据来训练一个简单的 Tokenizer,它的核心参数实际上就是 merges 字典,其工作原理是在原始字节上构建一个二叉森林。接下来,我们将探讨先进的 LLM Tokenizer 的实现方式,看看上述简单情景是如何迅速充满复杂细节的。

GPT Series#

五年前的 GPT-2 论文 Language Models are Unsupervised Multitask Learners中,研究人员所采用的方法并不像我们之前所用的那样简单,如下图所示,频繁出现的词如 “dog” 会紧跟在各种标点符号之后。可以想象,BPE 算法会将 “dog + 标点” 合并为一个 token,最终在 merges 字典中会出现大量的 “dog + 各种标点” 的合并记录。无论是从直觉还是实验结果来看,这样的结果都不理想:

Screenshot 2024-06-26 at 00.17.31

因此,一个自然的想法就是强制规定某些类型的 token 组合不会出现。来看看 gpt-2 的代码中是怎么实现的: gpt-2/src/encoder.py/line-53

Regex#

Screenshot 2024-06-26 at 01.03.57

注意,这里的 re 并非我们习惯中使用的那个模块,而是来自 import regex as re ,是比原始 re 更强的拓展

图中这个复杂的正则表达式就是实现该规则的核心,来详细分析一下它:

  1. 's|'t|'re|'ve|'m|'ll|'d

注:正则表达式中的 | 代表的是 or\

  • 匹配英语中的常见缩写形式,例如:"is"、"it"、"are"、"have"、"am"、"will" 和 "had"。
  1. (空格)?\p {L}+:

    • \p{L} 匹配任何字母字符,+ 表示一个或多个字母字符。
    • ? 表示可选的空格。这部分匹配一个或多个字母字符,可能前面带有一个空格。
  2. (空格)?\p {N}+

    • \p{N} 匹配任何数字字符,+ 表示一个或多个数字字符。
    • ? 表示可选的空格。这部分匹配一个或多个数字字符,可能前面带有一个空格。
  3. (空格)?[^\s\p {L}\p {N}]+

    • ^\s\p{L}\p{N} 表示非空白字符、非字母字符和非数字字符,也就是标点符号,+ 表示一个或多个这样的字符。
    • ? 表示可选的空格。这部分匹配一个或多个非空白、非字母、非数字的字符,可能前面带有一个空格。
  4. \s+(?!\S)

    • \s+ 匹配一个或多个空白字符。
    • (?!\S)负向前瞻断言Negative Lookahead Assertion)是一种正则表达式的高级特性,用于确保某个模式出现在当前位置的后面,用法是(?!pattern)。这里确保这些空白字符后面不跟非空白字符。这部分匹配末尾的空白字符或只包含空白字符的字符串。
    • 这个做法相当微妙,能保留额外空格的同时仍然正确匹配出 "一个空格 + 词"

Screenshot 2024-06-26 at 01.44.55

  1. \s+

    • 匹配尾随一个或多个空白字符。

matching result#

使用 findall 方法将输入字符串 "Hello've world123 how's are you!!!?" 分割成多个符合正则表达式模式的片段。

Screenshot 2024-06-26 at 01.08.59

  • " Hello"" world" how"" are"" you" 被匹配为一组字母,前面的空格也被匹配到;
  • "123" 被匹配为一组数字;
  • "'ve""'s" 被匹配为缩写形式;
  • "!!!?" 被匹配为一组标点符号。

这样我们的文本就被拆分成了多个文本段的列表,每一段都将独立地通过 tokenizer 从文本转为 token 序列,然后再把序列连接起来。也就是说,我们只考虑每个元素内部的 token 合并。(这样就永远不会发生把前一个词的最后一个字符和下一个词前面的空格合并的情况了)

这里一个小插曲是原始实现中没有考虑大写的情况,遇到 "HOW'S" 这样的情况就不会合并,添加匹配忽略大小写的参数后,可以看到效果变了:

Screenshot 2024-06-26 at 01.47.14

再来看一个真实情景中常见的 Python 代码案例:

这个案例旨在说明,在训练 GPT-2 的 tokenizer 时,并非所有片段都运行了 BPE 算法。需要注意的是,段落间的大量空格缩进在实际的分词结构中仍然作为单个空格,或者说是 220 token。Andrej 认为 OpenAI 在某个阶段强制执行了不合并这些空格的规则。然而,GPT-2 的 tokenizer 训练代码从未开源,因此除了这些未知的强制规则外,真实的训练过程也并非只是简单地将文本分块然后运行 BPE。

Screenshot 2024-06-26 at 01.48.54

Tiktoken#

同样,这个库并非训练代码,只是 tokenize 推理的代码

Screenshot 2024-06-26 at 23.59.32

上面用过的 Tiktokenizer 网站就是使用 JavaScript 编写的 tiktoken,直接在浏览器中运行,效果是一样的。可以看到,GPT-4 的分词器改变了文本分块的正则表达式,合并了多个空格。

tiktoken 中的 GPT-2 正则实现:

Screenshot 2024-06-27 at 00.26.44

可以看到这个等价的正则表达式做了一个优化:

# 原始版本
pattern = r"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"

# 优化版本
pattern = r"'(?:[sdtm]|[rlve][re])| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"

不太懂正则,GPT-4 的解释是:优化版本通过使用非捕获组 (?: ...) 和字符类 [ ... ] 的组合减少了匹配的次数,使得匹配缩写形式时更高效。

而 GPT-4 所用的 tokenizer 的正则变化就很明显了:

Screenshot 2024-06-27 at 00.37.28

除了我们后续会讨论的特殊 token,主要的变化就是它的正则表达式,直接喂给 GPT-4o 听它解释吧:

pat_str = r"""
    '(?i:[sdmt]|ll|ve|re)          # 匹配缩写形式,如 's, 'd, 't, 'm, 'll, 've, 're
    | [^\r\n\p{L}\p{N}]?+\p{L}+    # 匹配一个可选的非换行、非字母、非数字字符,后跟一个或多个字母
    | \p{N}{1,3}                   # 匹配 1 到 3 个连续的数字字符
    | ?[^\s\p{L}\p{N}]++[\r\n]*    # 匹配一个可选的空格,后跟一个或多个非空白、非字母、非数字字符,后跟零个或多个换行符
    | \s*[\r\n]                    # 匹配零个或多个空白字符,后跟一个换行符
    | \s+(?!\S)                    # 匹配一个或多个空白字符,且后面没有非空白字符
    | \s+                          # 匹配一个或多个空白字符
"""

太复杂了,直接做一个重点总结:

  • 最开始的 (?i:...) 是一个嵌入式标志,表示在此非捕获组内不区分大小写(大小写不敏感),对应我们之前在自己实现的算法中想解决的问题。

  • 注意这里匹配数字时,永远不会合并超过三位的数字,以防长数字序列成为 token。

  • 从名字来看,词表从 5 万扩容到了大约 10 万,其它的就没什么能猜的了...

encoder.py#

看了太多正则有点恶心了,回到我们的 encoder.py,从这个文件的最底部开始,我们可以看到它加载了两个文件 —— encoder.jsonvocab.bpe

Screenshot 2024-06-27 at 00.59.28

这两个文件共同构成了它们保存的 tokenizer,我们下载下来详细分析一下:

Screenshot 2024-06-27 at 01.03.48

这里 Andrej 的注释说明了这两个文件和我们之前工作的关系:

  1. encoder.json 对应我们的 vocab 词汇表:

Screenshot 2024-06-27 at 01.05.10

复习一下,vocab 能让我们从整数转换为对应的字节,高效解码

  1. vocab.bpe 对应我们的 merge:

也就是说保存了合并的规则,bpe_merges 则是根据 vocab.bpe 文件中的数据构建的合并规则列表。

因此我们只要拥有了这两个东西,就可以得到一个 tokenizer,训练后就能编码解码了。

下面对于合并的实现就很眼熟了,遍历序列,合并找到的字节对,迭代到没有可合并到为止:

Screenshot 2024-06-27 at 01.31.13

这里有一个小插曲,OpenAI 的实现中还有 byte_encoderbyte_decoder,可能是便于处理多语言和特殊字符,Andrej 认为这个步骤有些无趣,就不做过多展开了,了解具体作用的大佬欢迎在评论区解释 :)

Special token#

除了由原始字节和 BPE 算法合并生成的 token 外,我们还可以插入各种 token,用于分隔数据的不同部分或引入特殊结构的 token 字符串。

Screenshot 2024-06-27 at 01.43.16

这里可以注意到,映射表的大小是 50,257,而我们原先的编码方式只有 256 个原始 token,那剩下的 50k token 是哪儿来的呢?

OpenAI 实际上做了 50k 次合并,那 256 + 50,000 = 50256,最后一个 token 是哪儿来的呢?

这就是加入的一个special token

Screenshot 2024-06-27 at 01.44.24

该 token 用于在训练集中分隔文档,即表示 “文档结束,接下来的内容和先前文档无关”,模型同样是在预训练中来学习这一点。这些特殊 token 不由 BPE 算法处理,而是检测到时添加到 token 向量中,具体实现位置是在 tiktoken/src/lib.rs中。

不仅语言建模(预训练)时会用到这些特殊 token,后期微调阶段也需要它们,因为我们除了分隔文档外,还需要界定 “Assistant” 和 “User” 的对话。

Screenshot 2024-07-01 at 23.47.27

比如 GPT-3.5-Turbo 的特殊标记有如图的几种,“im” 的全称是 “Imaginary”,可以界定不同对话区域病并跟踪对话的流动

除此之外,你还可以可以像如图所示来扩充 token:

Pasted image 20240702225220

在 GPT-4 的分词器中,除了和 GPT-2 一样拥有 ENDOFTEXT,还多了四个新特殊 token,三个 FIM 和一个 Control Token:

Screenshot 2024-07-03 at 00.41.39

其中的 FIM = Fill in the Middle,详见论文 [2207.14255] Efficient Training of Language Models to Fill in the Middle

当添加一个特殊 token 时,我们需要对 Transformer 及其参数进行一些调整。因为我们实际上是在添加一个整数 token,需要确保词汇表的嵌入矩阵扩展了一行。通常情况下,这一行将使用非常小的随机值初始化,以表示这个 token 的向量。此外,还需要在 Transformer 的最后一层确保进入分类器的投影也进行了相应的扩展。

minbpe#

分别对 GPT-4 和在 Taylor Swift 的 Wiki 文本上训练的 BPE vocab 进行可视化,可以发现 merge 的顺序差异很大:

Screenshot 2024-07-03 at 00.55.51

注意到,GPT-4 中开始合并的都是空格,可以猜想这是由于训练数据中有大量 Python 代码导致的,而另一个的顺序也是取决于训练的数据。

SentencePiece#

另一个在 LLM 中常与 tokenization 一起使用的库是 google/sentencepiece: Unsupervised text tokenizer for Neural Network-based text generation.,在 Llama、Mistral 等很多模型中有使用。

与 Tiktoken 不同的是,它可以同时做到高效训练和推理,并支持多种词汇表训练算法(包含我们前面学的 BPE),我们来看一下二者的主要区别:

  • Tiktoken:首先取字符串中的每个字符在 Unicode 编码中的唯一编号(code point,就是码位),然后使用 UTF-8 编码成字节,然后在合并字节(字节级);

  • SentencePiece:直接在 code point 级别上运行合并,如果碰巧用尽了码位,那么可能会有一些罕见的码点,那么这些码位要么被映射到一个特殊的未知 token,比如 UNK,要么如果开启了字节回退选项,则会使用 UTF-8 对它们进行编码,然后该编码的各个字节再被翻译成 token。

Screenshot 2024-07-03 at 01.13.24

4o 对于 “字节回退机制” 的举例说明

由于历史包袱问题,Sentencepiece 的配置很复杂,以 Llama2 为例:

Screenshot 2024-07-03 at 23.59.10

merger rules 部分很类似前面在 tiktoken 中使用 regex

其中我们重点关注的是 Normalization 部分。这一步骤在机器翻译、文本分类等 NLP 领域应用十分广泛,我们会想将文本全部转为小写、移除双空格、Unicode 规范化等操作。但在语言模型中,Andrej 更倾向于不做任何处理,以尽可能地保留原始数据的原始形式。

Byte Fallback#

在一段英文文本上做 400 词汇量大小的训练结果如下:

  1. 首先是特殊 token,
  2. 由于 Llama2 开启了 Byte-Fallback,因此接下来是 256 个字节的 token:

Screenshot 2024-07-04 at 00.13.39

  1. 然后是 BPE 算法合并的 token:

Screenshot 2024-07-04 at 00.13.56

  1. 最后是剩下的单个 token(原始码位 token):

Screenshot 2024-07-04 at 00.14.16

由于配置中设置了 character_coverage=0.99995,意味着忽略在一百万个句子中只出现了一次的稀有码位,不添加到词汇表中,以减少 UNK 出现频率。

可以尝试编码 - 解码,要注意的是韩文并没有在我们的训练文本中出现,因此属于 UKN token,但是由于 Byte-Fallback 的开启,实际上发生的是用 UTF-8 编码韩文,然后用已知的 256 个字节 token 来表示它们:

Screenshot 2024-07-04 at 00.27.39

可以看到韩文对应的 token 编号都在 3 - 258

如果关闭字节回退的话,我们的 tokenizer 就不需要用 256 个字节来占用词汇表空间而得到更多的 merge token:

Screenshot 2024-07-04 at 00.33.09

推理结果:

Screenshot 2024-07-04 at 00.35.30

可以看到关闭字节回退的话,韩文就直接变成 UNK token 了,将这些 token 序列送入语言模型训练会导致一些不好的结果。

另一个问题是,注意到空格变成了 _ , 在 Sentencepiece 文档里提到了具体原因:

Screenshot 2024-07-04 at 00.47.12

但为什么句子的开头会多出来一个空格呢?原因在于 merge rule 中的 add_dummy_prefix=True,下面来聊聊这么做的动机。

在 Tiktoken 中,下图的例子所示的例子里,作为句子开头的 "world" 和句子中间的 "world" 是完全不同的,语言模型需要通过在海量数据上训练来认识到这两个 token 概念相似:

Screenshot 2024-07-04 at 00.50.12

那如果在句子开头加上一个虚拟空格,结果就不言而喻了,两个位置的 "world" 将使用同样的 token,也就是都作为 "(空格) world" 看待。

vocab_size#

词汇表大小怎么设置?

让我们回到 nano-gpt 的教程,vocab 在网络中只出现在两个位置:

Screenshot 2024-07-04 at 01.00.31

在定义语言模型的最开始,我们会设置一个二维数组,称为 token 嵌入表。这个嵌入表的行数等于词汇表的大小(vocab_size),每个 token 都对应一个向量,向量大小是嵌入的大小,也就是 Transformer 中的 channel 数(输入和输出的特征维度),这些向量将在训练过程中通过反向传播进行优化。基本上来说,随着词汇量的增加,嵌入表的体积也会变大(行数增多)

此外,在 Transformer 的末尾有一个 lm_head 层(线性层),这一层用于在最后进行逻辑回归,从而得到序列中下一个 token 的概率。也就是说,每当词汇表中引入一个新的 token,就会增加一个点积运算。

Screenshot 2024-07-04 at 01.11.25

为什么词汇表大小不能增加到无限呢?#

  • 首先,token 嵌入表和线性层都会增长,计算成本更昂贵;
  • 参数会更多,可能有一些参数会训练不足。直觉上来看,如果你有一个 100 万 token 的词汇表,那这些 token 在训练数据中出现的密度会变低,因此会担心与 token 关联的向量是否会因此训练不足;
  • 词汇表大小增长,序列长度会大大缩小,适度压缩是好事,但有可能导致很大的文本块(过多信息)被压缩到单个 token 中,导致 Transformer 的前向传递不足以适当地处理这些信息。

因此这是一个经验型超参数,当今 SOTA 架构一般选择 10,000 或 100, 000 左右

如何扩展预训练模型的词汇表?#

这是很常见的做法,例如对 base model 进行微调时,会引入用来维护 [USER] 和 [ASSISTANT] 之间对话元数据和结构的特殊 token。要实现这一点,只需要在上述模型结构中的嵌入表上增加新的行,重新做参数初始化 ,并拓展最后的线性层内部权重,即这两个操作都只是调整矩阵大小,是一个很 “温和的手术”,而且你可以冻结 base model 中的权重,然后引入这些新参数并只在新参数上训练。

Gist token#

引入新 token 并不只有拓展词汇表的功能,[2304.08467] Learning to Compress Prompts with Gist Tokens中介绍了如何在指令微调中引入 gist-token 来压缩训练的 prompt ,将冗长的提示词长度缩短,然后冻结模型只在新 token 上训练其嵌入语义,在性能基本不变的同时减少训练、推理成本,属于一种参数高效微调方法。

Multimodel#

当前大模型发展的一个重要方向是多模态处理,模型可以同时处理文本、图像甚至音频。那么,如何将这些不同形式的数据作为输入呢?很多人的观点趋于一致:不需要改变模型架构,坚持使用 Transformer,只是在输入的标记化(tokenization)上下功夫,将多模态输入假装成文本 token 处理,这样就能以完全相同的方式继续处理:

Screenshot 2024-07-04 at 22.39.36

包括二月份热度很高的 Sora,研究人员设计了一种将视频截断成文本 token,然后你就可以自回归处理离散 tokens,或者甚至用扩散模型处理 soft tokens:

Screenshot 2024-07-04 at 22.40.51

Prompt Attack#

Screenshot 2024-07-04 at 23.11.28

这种特殊 token 应该避免在用户输入中解析,以免遭到破坏(调戏)。

SolidGoldMagikarp#

全文见 SolidGoldMagikarp (plus, prompt generation) — LessWrong,文章中对 token embedding table 的 token 嵌入做了聚类,发现有一部分 token 很奇怪:

Screenshot 2024-07-04 at 23.49.37

如果在提示词中包含这些 token 会导致错误的响应,模型会表现出非常奇怪的行为,甚至是违反对齐和安全指令来侮辱用户。那这些 token 是啥意思,又是从哪儿来的呢?

这个问题显然源于分词过程。一个名为 SolidGoldMagikarp 的 Reddit 用户经常发帖或在帖子中被频繁提及,导致这个字符串在分词数据集中出现频率很高,最终被合并为一个 token。因此,在训练模型时,这个名字只会以一个 token 的形式出现。由于这个 token 在随机初始化后从未被激活,也从未经过反向传播优化,因此在输入模型时会像 C++ 中调用了未分配的内存,导致 “未定义行为”(undefined behavior)。

Andrej 的最终总结#

  1. 不要忽视 tokenization

    • 这一过程中存在许多潜在的错误和复杂问题,如安全问题和稳定性问题。
  2. 如果可以省去 LLM 中的这个必要步骤,将是一个伟大的成就

  3. 在自己的工作中:

    • 可以直接用 TikToken,因为它可以高效推理。
    • 如果你想从头训练词汇表,可以使用 SentencePiece 的 BPE,但要注意其中繁多的配置。
    • 当 MinBPE 的效率与 SentencePiece 相当时,可以切换到 MinBPE :)
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。