GPT-2 Tokenizer 效率观察

对基于 Transformer 结构的 LLM (大语言模型)来说,模型的输入输出都是 Token(词元)。一段输入文本,首先要经过 Tokenizer(分词器)切分成 Token 再输入给模型。

不同的 Tokenizer 会把文本按不同的边界切分,那一段文本到底会被切成几个 Token 就体现了 Tokenizer 本身的效率,这本身也是信息论的讨论范畴。不过今天不做理论分析,也不介绍 Tokenizer 的训练算法,就看下 GPT-2 和 GPT-3 Tokenizer 的实际分词效率(GPT-2 和 GPT-3 使用的是同样的 Tokenizer)。

用 GPT-2 Tokenizer 编码一段中文

from transformers import GPT2Tokenizer

tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
input_str = '不同的 Tokenizer 会把文本按不同的边界切分,那一段文本到底会被切成几个 Token 就体现了 Tokenizer 本身的效率,这本身也是信息论的讨论范畴。'
tokenids = tokenizer(input_str)['input_ids']
raw_tokens = tokenizer.convert_ids_to_tokens(tokenids)
token_strs = [tokenizer.convert_tokens_to_string([token]) for token in raw_tokens]
print(len(input_str), len(tokenids), tokenids, token_strs)
82 121 [38834, 28938, 234, 21410, 29130, 7509, 220, 27670, 248, 162, 232, 232, 23877, 229, 17312, 105, 162, 234, 231, 38834, 28938, 234, 21410, 164, 122, 117, 45911, 234, 26344, 229, 26344, 228, 171, 120, 234, 165, 224, 96, 31660, 162, 106, 113, 23877, 229, 17312, 105, 26344, 108, 41753, 243, 27670, 248, 164, 95, 104, 26344, 229, 22755, 238, 49035, 254, 10310, 103, 29130, 10263, 108, 109, 19526, 241, 163, 236, 108, 12859, 228, 29130, 7509, 42164, 105, 164, 118, 104, 21410, 46763, 230, 163, 236, 229, 171, 120, 234, 32573, 247, 17312, 105, 164, 118, 104, 20046, 253, 42468, 46479, 94, 162, 223, 107, 164, 106, 118, 21410, 164, 106, 101, 164, 106, 118, 164, 234, 225, 45911, 112, 16764] ['不', '�', '�', '的', ' Token', 'izer', ' ', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '不', '�', '�', '的', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '一', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', ' Token', ' �', '�', '�', '�', '�', '�', '�', '�', '�', '�', ' Token', 'izer', ' �', '�', '�', '�', '�', '的', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '是', '�', '�', '�', '�', '�', '�', '�', '�', '的', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '。']

输入的中英文夹杂的字符串长度为 82 个字符,其中有 6 个空格,23 个英文字符(3个单词),3个标点符号,50 个汉字字符。最终编码出来的 tokenids 长度是 121 个 Token,仔细检查可以看到,大部分汉字被编码成两个 Token,英文则是按照 subword 级别进行了编码。由此可见,GPT-2 Tokenizer 编码中文的效率是很低的

GPT-2 Tokenizer 词典中包含中文的 Token 数量

import re
tokens = [tokenizer.convert_tokens_to_string([key]) for key in tokenizer.get_vocab()]
cn_tokens = [t for t in tokens if re.search(u'[\u4e00-\u9fff]', t)]
cn_tokens.sort(key=len, reverse=True)
print(len(tokens), len(cn_tokens), cn_tokens)
50257 51 [' 裏覚醒', ' 裏�', '��士', '龍喚士', ' 裏�', '龍契士', '��極', ' 裏', '龍�', '�醒', '覚醒', 'の魔', '�士', '龍�', ' 神', '龍', '神', '士', '魔', '的', '人', '天', '王', '一', '大', '装', '田', '子', '戦', '生', '者', '不', '姫', '中', '上', '闘', '是', '女', '方', '作', '黒', '之', '使', '光', '代', '版', '三', '五', '武', '将', '機']

对 GPT-2 Tokenizer 词典中的 Token 进行分析,可以看到:在数量为 50257 大小的词典中,只有 51 个 Token 包含常见的中文字符。上一节中出现的“是”、“的”、“不”也在其中,但是大家随便一想就能想到的,常见的“你”、“我”、“他”并不在内。这些数据印证了它对中文分词的效率低下。

GPT-2 Tokenizer 词典中包含英文的 Token 数量

en_tokens = [t for t in tokens if re.search(u'[A-Za-z]', t)]
en_tokens.sort(key=len, reverse=True)
print(len(en_tokens), en_tokens[:10])
46949 ['rawdownloadcloneembedreportprint', 'BuyableInstoreAndOnline', 'cloneembedreportprint', ' RandomRedditorWithNo', ' telecommunications', ' disproportionately', ' guiActiveUnfocused', 'channelAvailability', ' Telecommunications', ' externalToEVAOnly']

但是针对英文字符进行统计,可以看到在数量为 50257 大小的词典中,有 46949 个 Token 包含 26 个英文字母。由于数量太多,这里仅输出了长度排前 10 的英文 Token。

从 Tokenizer 词典中不同语言字符的分布,我们可以验证 GPT-3 论文 “Language Models are Few-Shot Learners” 中提到的这句话:

... This could be a weakness due to reusing the byte-level BPE tokenizer of GPT-2 which was developed for an almost entirely English training dataset.

GPT-2 Tokenizer 词典中最长的 Token 是什么

tokens.sort(key=len, reverse=True)
print(len(tokens), tokens[:10])
50257 [' =================================================================', ' ----------------------------------------------------------------', '----------------------------------------------------------------', '................................................................', '================================================================', '________________________________________________________________', 'ÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂ', '--------------------------------------------------------', '------------------------------------------------', ' =================================']

可以看到,最长的 Token 居然是一些连续的标记,看起来像是论坛上的分割线。在“Natural Language Processing with Transformers” 一书中说,这可能是由于 GPT-2 的词典是用以 Reddit 为核心的语料训练的。

Codex Tokenizer 提升代码处理效率的 Trick

上面讲到 Tokenizer 效率对最终生成 Token 序列大小的影响,如果只关注最终的模型效果,可能并不在意中间过程的效率问题。

但从一个小点可以看到 OpenAI 还是很重视 Tokenizer 效率的。在训练 Codex 时,它在 50257 的词典之外新增了 23 个 Token,分别编码了:[2个连续空格, 3个连续空格, ..., 23个连续空格]。

大家都知道,代码中有很多锁进和对齐,如果每个空格都编码成一个 Token,训练和推理的效率就会极低;如果像常规语言模型那样忽视更多连续的空格,又会导致生成的代码不可读,甚至对 Python 这样的语言来说代码完全不可理解。所以 OpenAI 专门修改了 Tokenizer 的词典,提升了对连续空格的编码效率。

在中文数据集上训练的 Tokenizer 表现如何?

同样使用第一节的代码,我们换一个在中文数据集上训练的 Tokenizer,效果会如何呢?我随便在 HuggingFace 上找了一个模型 HuiHuang/gpt3-base-zh,使用它的 Tokenizer 对那段话进行重新分词。

from transformers import BertTokenizerFast

tokenizer = BertTokenizerFast.from_pretrained("HuiHuang/gpt3-base-zh")
input_str = '不同的 Tokenizer 会把文本按不同的边界切分,那一段文本到底会被切成几个 Token 就体现了 Tokenizer 本身的效率,这本身也是信息论的讨论范畴。'
tokenids = tokenizer(input_str)['input_ids']
raw_tokens = tokenizer.convert_ids_to_tokens(tokenids)
token_strs = [tokenizer.convert_tokens_to_string([token]) for token in raw_tokens]
print(len(input_str), len(tokenids), tokenids, token_strs)
82 65 [2, 215, 1750, 10574, 22090, 24330, 23635, 21748, 484, 5460, 6225, 6646, 5587, 215, 1750, 10574, 17027, 10262, 1233, 1232, 21124, 17261, 202, 7807, 6225, 6646, 1274, 4447, 484, 15221, 1233, 5338, 1194, 244, 22090, 24330, 3835, 541, 9850, 336, 22090, 24330, 23635, 21748, 6646, 16757, 10574, 6162, 9809, 21124, 17059, 6646, 16757, 297, 6393, 683, 4921, 16004, 10574, 15986, 16004, 13773, 10302, 160, 3] ['[CLS]', '不', '同', '的', 'to', '##ken', '##ize', '##r', '会', '把', '文', '本', '按', '不', '同', '的', '边', '界', '切', '分', ',', '那', '一', '段', '文', '本', '到', '底', '会', '被', '切', '成', '几', '个', 'to', '##ken', '就', '体', '现', '了', 'to', '##ken', '##ize', '##r', '本', '身', '的', '效', '率', ',', '这', '本', '身', '也', '是', '信', '息', '论', '的', '讨', '论', '范', '畴', '。', '[SEP]']

可以看到,82 个字符的输入被分成了 63 个 Token(除去[CLS]和[SEP]),比 GPT-2 Tokenizer 的分词数量 121 几乎少了一半。这就是 Tokenizer 训练数据集对分词效率的影响。

Tokenizer 的效率意味着什么

在以前,Tokenizer 的效率只是算法工程师们关注的话题。但是现在,LLM 逐渐成为云服务,Token 成为 API 调用的基本收费单元。例如:OpenAI gpt-3.5-turbo 的收费标准是 $0.002/1K tokens。

在这种情况下,Tokenizer 的效率就意味着成本。这意味着使用中文调用 OpenAI 的 chatGPT API,假设单 Token 成本一样的话,每次调用的实际成本可能是调用国内类似 chatGPT 云服务的 2 倍以上。

GPT-4 的 Bits Per Word 指标是什么意思

最近人人都在聊 ChatGPT,这里聊点儿更底层的技术细节。上周 OpenAI 发布了 GPT-4,里面有一张图引起了我的兴趣。在 OpenAI 内部的 Codebase 上,他们准确预测了 next word prediction 误差随着计算规模增加的下降曲线。让我感兴趣的不是这个曲线,而是这个曲线纵轴使用的指标:Bits Per Word (BPW)

图片来自:https://openai.com/research/gpt-4

乍一看我以为这是信息论里的概念,Bits Per Word 可能是指在某种语言里表达一个词所需要的最小位数,比如用于压缩时,这意味着压缩比的理论上限。但仔细看下指标接近 1.2 BPW,虽说英文字母的 Bits Per Character (BPC) 大约也在 1.2 左右,我不太相信单词的 BPW 能降到 1.2 这个规模。

后来才了解到,Bits Per Character (BPC) / Bits Per Word (BPW) 是 NLP 任务中对语言模型的评估指标之一。这些评估指标的详细解释可以先阅读这篇文章《Evaluation Metrics for Language Modeling》,但是我感觉这篇文章的说明在理论和应用之间跳过了一些环节,下面我以我的理解补全一下这些环节。推测的内容以黑字标出,如有谬误,欢迎指正。

原始 BPW 定义

The entropy is a statistical parameter which measures, in a certain sense, how much information is produced on the average for each letter of a text in the language. If the language is translated into binary digits (0 or 1) in the most efficient way, the entropy \(H\) is the average number of binary digits required per letter of the original language.

以上,是香农在《Prediction and Entropy of Printed English》一文中对语言的熵的定义。可以看到,语言的熵 \(H\) 其实就是 BPC (Bits Per Character) 的均值。但我们一般不会报告某个字符有几个 bits,因而通常所说的 BPC,就是整个语言(数据集)的平均 BPC,所以可以理解为语言的熵 \(H\) 就是 BPC。

但是在原始论文中,香农是用字符来建模的语言的熵,当用单词来建模语言时,语言的熵 \(H\) 就是 BPW (Bits Per Word)。

用 BPW 表示交叉熵

对于生成式语言模型来说,它的目标就是从样本中学习到一个分布 \(Q\),使这个分布尽量接近语言的经验分布 \(P\)。交叉熵 \(H(P, Q)\) 经常被用来衡量这种接近程度,

\[H(P, Q) = H(P) + D_{KL}(P \Vert Q) \]

根据 《Evaluation》 一文,交叉熵的两个因子用信息论的角度来理解:

  • \(H(P)\): 就是 \(P\) 的熵,即使用为分布 \(P\) 优化的编码器编码 \(P\) 中所有可能出现的字符的平均位数;
  • \(D_{KL}(P \Vert Q)\): 使用为分布 \(Q\) 优化的编码器编码 \(P\) 中所有可能出现的字符,所需要的平均额外位数;

与原始 BPC/BPW 的定义相同,如果用 \(BPC/BPW(P, Q)\) 表示交叉熵 \(H(P, Q)\),那么它意思应该是使用为分布 \(Q\) 优化的编码器编码 \(P\) 中所有可能出现的字符/单词所需要的平均位数。

上面这点理解,与知乎上一篇文章《一文搞懂Language Modeling三大评估标准》存在 diff(飘红部分):

也就是说BPC/BPW是cross-entropy对句子长度的平均,我们可以很容易地得出它的信息论含义:

基于预测的Q序列来编码P序列所需要的额外bit数在句子长度上的平均,也就是平均每一个字母/单词需要额外的bit数。

下面这一节,可能能解释 diff 的由来。

用 BPW 表示损失指标

但是在评估一个语言模型时,我们并不是评估模型输出的整个分布,而是评估模型的输出跟实际样本的不同。拿 GPT-2 模型来说,它是拿模型输出的预测下一个 Token 的 logits 与 label (样本中真实的下一个 Token)计算交叉熵:

The output from the decoder is passed to a language modeling head, which performs a linear transformation to convert the hidden states into logits. The label is the next token in the sequence, which are created by shifting the logits to the right by one. The cross-entropy loss is calculated between the shifted logits and the labels to output the next most likely token.

  • logits 是一个 \(Q(x)\) 的向量,表示下一个 Token 是 \(x\) 的概率;
  • label 表示样本中下一个 Token 是什么,它是一个 \(P(x)\) 的向量,但只有 x = 下一个 Token 时为 1,其它位置为 0,那么:

\[H(P) = -\sum_{x}P(x)\log{P(x)} = 0\]

从信息论也好理解,在计算损失时样本 \(P\) 是一个确定性分布,确定性分布的熵应该为 0。那么在计算交叉熵损失的场景下:

\[H(P, Q) = H(P) + D_{KL}(P \Vert Q) = 0 + D_{KL}(P \Vert Q)\]

可以看到,这时候交叉熵等于相关熵,那么 \(BPC/BPW(P, Q)\) 也就是 \(D_{KL}(P \Vert Q)\),或者更精确地说,是在数据集样本上平均的 \(D_{KL}(P \Vert Q)\)。那么在评估一个语言模型的场景下,把 BPC/BPW 损失指标的信息学含义解释成:“使用为分布 \(Q\) 优化的编码器编码 \(P\) 中所有可能出现的字符/单词所需要的平均额外位数”,也是合理的了。

GPT-4 的 BPW 指标

从 GPT-4 给出的指标来看,它的 BPW 指标只有 1.26 左右。除了信息论编码的概念,这代表什么意思呢?

这里我引入《Evaluation》 一文中提到的另一个指标 Perplexity (PPL) 困惑度和一个有趣的比喻。考虑到:

\[PPL(P, Q) = 2^{H(P, Q)} = 2^{BPW(P, Q)} = 2^{1.26} \approx 2.4\]

那么 GPT-4 语言模型的效果相当于:在给定的数据集上,提供一个样本的前缀串后,GPT-4 会制作一个包含下一个 Token 的 2.4 面的新骰子,然后掷骰子来预测下一个 Token 是什么。