从头开始构建GPT标记器
对于GPT Tokenizer,论文《Language Models are Unsupervised Multitask Learners》中介绍了一种字节级编码作为LLM的标记化机制:
The vocabulary is expanded to 50,257. We also increase the context size from 512 to 1024 tokens and a larger batchsize of 512 is used.
最终结论是LLM的词汇扩展到50257,上下文可以看到1024个tokens,也就是在transformer的注意力层中,每个token都可以关注序列前最多1024个token。
标记化是将字符串或文本转换为标记序列的过程。字节对编码算法不算很复杂,下面我们可以从头开始构建它。
在进行构建之前,我们先简要了解一下tokenization的复杂性,许多看起来只是神经网络架构或大型语言模型本身的问题,实际上都是标记化的问题,可以从源头追溯到问题所在,当LLM出现以下问题时,通常是由分词引起的:
- 为什么大型语言模型不能拼写单词?
- 为什么大型语言模型在执行简单的字符串处理任务(例如反转字符串)时表现不佳?
- 为什么大型语言模型在处理非英语语言(例如日语)时表现较差?
- 为什么大型语言模型在简单算术上表现不好?
- 为什么 GPT-2 在 Python 编码时遇到不必要的麻烦?
- 为什么我的大型语言模型在遇到字符串 “<|endoftext|>” 时突然停止?
- 为什么我在使用大型语言模型时会收到关于“尾随空白”的奇怪警告?
- 为什么当我问大型语言模型关于“SolidGoldMagikarp”时它会崩溃?
- 为什么我应该在使用大型语言模型时优先选择 YAML 而不是 JSON?
- 为什么大型语言模型不是真正的端到端语言建模?
- LLM问题的根源是什么?
所以分词是很多问题的根源,我们将在文章末尾再回顾这些问题,现在我们先跳过它,进入下面这个网络应用(https://tiktokenizer.vercel.app/)
在这个网站中分词结果会用JavaScript很直观地展现出来,在左边框中随意输入一些内容(注意右上角我们选择的是GPT-2):
Tokenization is at the heart of much weirdness of LLMs. Do not brush it off.
127 + 677 = 804
1275 + 6773 = 8041
Egg.
I have an Egg.
egg.
EGG.
很高兴见到你。我是OpenAI开发的大规模语言模型ChatGPT。如果有任何疑问,请随时问我。
for i in range(1, 101):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
这些标记被不同的颜色区分开,例如第一个词Tokenization被标记成了30642,和1634
注意,每个token前面的空格也是token的一部分,GPT-2对英语句子的分词似乎没什么问题,但我们看下面的数学运算,677实际上应该划分为一个token但却分成了两个,其他数字也有这种情况。
同样,字符串Egg
在句子开头时被划分成了两个标记,但前面有空格时又可以准确划分成一个标记,大写变小写也可以划分成一个标记…以上这些情况都有可能产生不同的划分
语言模型必须从将要训练的所有互联网文本的原始数据中学习,它必须在神经网络的参数中对它们进行分组,并且仅仅根据数据模式来理解,这些都是非常相似的。
接着我们还测试了中文的分词结果,分词器将这个句子分成了很多的Token,这比同样的英文句子会多很多,这就意味着我们对完全相同的句子在处理中文时需要使用更多的Token,这样的话就增加了文本的序列长度因此,然后在transfomer的注意力中。当这些标记尝试捕捉信息时,很容易耗尽最大上下文长度而无法捕捉更多有效信息。
基本上所有的非英语文本在transformer的角度看序列都变长了,这也就是LLM在非英语问题上表现得不如英语好的原因,这与用于分词器和分词本身的训练有关。
最后的例子是一个执行FizzBuzz的Python代码片段,在这里,所有的单独的空格都有单独的标记,这无疑也增加了token序列的长度
而对于GPT-4,它的分词情况就好很多,对Python中空白字符的处理有了很大改进
所以从CPT-2到GPT-4的Python编码能力的提高不仅仅是语言模型、架构和优化细节的问题,而且也来源于分词器的设计以及它如何将字符组合成标记。
下面我们来构建分词器,记住我们的目的是什么,我们想要将字符串输入到语言模型中,所以我们需要以某种方式将字符串标记为机器能够看懂的整数,然后,,我们将使用这些整数来查找向量查找表,并将这些向量作为输入送到转换器中。这其中的难点是,我们不只是想支持的英文字母,而是适应不同种类的语言。
例如对于下面这个句子:
你好!
在Python中,这些字符串是不可变的Unicode(统一码)序列,我们可以通过在Python中使用ORD函数来访问给定单个字符的Unicode
例如传入单个字符"h"所得到的Unicode代码点是104
Input:ord("h")
Output:104
那么句子"你好!"
的统一码为:
[ord(x) for x in "你好!"]
#output: [20320, 22909, 65281]
那么为什么我们不能简单地使用这些整数而不需要任何标记化呢?主要原因有几点:
- 词汇表会非常长
- Unicode标准会不断变化,很难表示成稳定的统一形式
因此Unicode定义了三种类型的编码,UTF-8、UTF-6和UTF-32
我们将"你好!"
编码成UTF-8
list("你好!".encode("utf-8"))
#output: [228, 189, 160, 229, 165, 189, 239, 188, 129]
然而,如果我们只是简单地使用UTF-8这些字节流,这就意味着词汇长度只有256个可能的标记,那么所有的文本都会被拉伸成很长的字节序列,尽管嵌入表会很小,因此我们必须使用字节对编码算法()进行压缩。
下面是一个字节对编码算法的例子:
假设我们要编码如下数据
aaabdaaabac
字节对“aa”出现次数最多,所以我们用数据中没有出现的字节“Z”替换“aa”得到替换表
Z <- aa
数据转变为
ZabdZabac
在这个数据中,字节对“Za”出现的次数最多,我们用另外一个字节“Y”来替换它(这种情况下由于所有的“Z”都将被替换,所以也可以用“Z”来替换“Za”),得到替换表以及数据
Z <- aa Y <- Za YbdYbac
我们再次替换最常出现的字节对得到:
Z <- aa Y <- Za X <- Yb XdXac
由于不再有重复出现的字节对,所以这个数据不能再被进一步压缩。
经过压缩,原来的token长度11变成了5,词汇长度由5变成了7
通过这种方式,我们可以迭代地压缩我们的序列,同时创造新的token。
以相同的方式,对于文本UTF-8编码的字节序列,我们可以找出最常出现的字节对,用新的token替换它,通过这种方式我们将得到一个压缩的训练数据集,以及一个用于将任意序列编码的算法,并使用这个词汇表进行解码,将其解码回字符串。
如下例子所示,文本从一篇博客中截取的
# text from https://www.reedbeta.com/blog/programmers-intro-to-unicode/
text = "Unicode! 🅤🅝🅘🅒🅞🅓🅔‽ 🇺🇳🇮🇨🇴🇩🇪! 😄 The very name strikes fear and awe into the hearts of programmers worldwide. We all know we ought to “support Unicode” in our software (whatever that means—like using wchar_t for all the strings, right?). But Unicode can be abstruse, and diving into the thousand-page Unicode Standard plus its dozens of supplementary annexes, reports, and notes can be more than a little intimidating. I don’t blame programmers for still finding the whole thing mysterious, even 30 years after Unicode’s inception."
# 编码测字节流
tokens = text.encode("utf-8") # raw bytes
tokens = list(map(int, tokens)) # convert to a list of integers in range 0..255 for convenience
print('---')
print(text)
print("length:", len(text))
print('---')
print(tokens)
print("length:", len(tokens))
对于原始文本,它的长度是533个代码点,UTF-8编码后得到616个token,比原始代码点更多是因为有些比较复杂的字符会编码成多个字节。
接下来我们要迭代寻找这里出现最多的字节对,counts用于记录每个字节对出现的次数
def get_stats(ids):
counts = {}
for pair in zip(ids, ids[1:]): # 迭代连续元素
counts[pair] = counts.get(pair, 0) + 1
return counts
stats = get_stats(tokens)
#print(stats)
print(sorted(((v,k) for k,v in stats.items()), reverse=True))
仅展示部分结果:
(101,32)字节对是出现频率最高的,我们可以用max函数得到:
top_pair = max(stats, key=stats.get)
top_pair
当我们创建一个新的标记时,它的ID将是256,因为0-255已经被占用了,所以应该将(101,32)的字节对全部用256替换掉
def merge(ids, pair, idx):
# 在(ids)列表中, 用新的token idx替换所有的(101,32)字节对
newids = []
i = 0
while i < len(ids):
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
#print(merge([5, 6, 6, 7, 9, 1], (6, 7), 99))
tokens2 = merge(tokens, top_pair, 256)
print(tokens2)
print("length:", len(tokens2))
可以发现token序列长度缩减到了,596,那么我们需要进行多少次的替换呢?我们替换的轮数越多,词汇表就越大,序列就越短,就像调超参数一样,我们需要在实验中找到最佳的替换次数,例如GPT-4目前使用了大概100,000个token,这是目前最合理的一个数值。
现在我们来重复上面的步骤,但这次我们用的文本是整篇博客(注意博客太长了,这里没有完全展示出来,代码里是完整的,可到文末领取):
# making the training text longer to have more representative token statistics
# text from https://www.reedbeta.com/blog/programmers-intro-to-unicode/
text = """A Programmer’s Introduction to Unicode March 3, 2017 · Coding · 22 Comments Unicode! 🅤🅝🅘🅒🅞🅓🅔‽ 🇺\u200c🇳\u200c🇮\u200c🇨\u200c🇴\u200c🇩\u200c🇪! 😄 The very name strikes fear and awe into the hearts of programmers worldwide. We all know we ought to “support Unicode” in our software (whatever that means—like using wchar_t for all the strings, right?). But Unicode can be abstruse, and diving into the thousand-page Unicode Standard plus its dozens of supplementary annexes, reports, and notes can be more than a little intimidating. I don’t blame programmers for still finding the whole thing mysterious, even 30 years after Unicode’s inception. A few months ago, I got interested in Unicode and decided to spend some time learning more about it in detail. In this article, ..."""
tokens = text.encode("utf-8") # 原始字节
tokens = list(map(int, tokens)) # 转成0-255的列表
现在我们要做的第一件事是决定我们的分词器要具有的最终词汇量,前面我们说到这就像是一个超参数,假设我们要得到276个词汇,那么我们将进行20次合并
vocab_size = 276
num_merges = vocab_size - 256
ids = list(tokens)
merges = {} # (int, int) -> int
for i in range(num_merges):
stats = get_stats(ids)
pair = max(stats, key=stats.get)
idx = 256 + i
print(f"merging {pair} into a new token {idx}")
ids = merge(ids, pair, idx)
merges[pair] = idx
"""
merging (101, 32) into a new token 256
merging (105, 110) into a new token 257
merging (115, 32) into a new token 258
merging (116, 104) into a new token 259
merging (101, 114) into a new token 260
merging (99, 111) into a new token 261
merging (116, 32) into a new token 262
merging (226, 128) into a new token 263
merging (44, 32) into a new token 264
merging (97, 110) into a new token 265
merging (111, 114) into a new token 266
merging (100, 32) into a new token 267
merging (97, 114) into a new token 268
merging (101, 110) into a new token 269
merging (257, 103) into a new token 270
merging (261, 100) into a new token 271
merging (121, 32) into a new token 272
merging (46, 32) into a new token 273
merging (97, 108) into a new token 274
merging (259, 256) into a new token 275
"""
最后可以看一下压缩比率:
原文长度是24597个token,进行字节对替换之后变成了19438个token
print("tokens length:", len(tokens))
print("ids length:", len(ids))
print(f"compression ratio: {len(tokens) / len(ids):.2f}X")
"""
tokens length: 24597
ids length: 19438
compression ratio: 1.27X
"""
注意,Tokenizer是一个完全独立于LLM的模块。它有自己的文本训练数据集(可能与LLM不同),我们可以使用字节对编码(BPE)算法在这个数据集上训练词汇表。然后,它在原始文本和token序列之间来回转换。LLM后来只看到令牌,不直接处理任何原始文本。
例如,当你训练标记器时。正如我提到的。我们不只关心英文文本的性能。所以你可能想要研究不同种类的语言混合以及不同数量的代码等等。因为你的分词器训练集中不同语言的数量将决定有多少个合并,这决定了这种类型的数据在标记空间中的密度。比如说你的分词器训练集中有大量的中文数据。那么意味着会合并更多的中文标记。因此中文将具有较短的序列。这对于具有有限上下文长度的大型语言模型来说是有益的。
上面我们已经训练好了一个分词器,接下来我们演示如何用新的词汇表进行编码和解码。
解码
# 创建从令牌ID到该令牌的字节对象的映射,0-255使用令牌的原始字节
vocab = {idx: bytes([idx]) for idx in range(256)}
# 按照所有的合并补全这个vocab列表,实际上就是两个字节的拼接
for (p0, p1), idx in merges.items():
vocab[idx] = vocab[p0] + vocab[p1]
def decode(ids):
# 对特定的 ids (list of integers), 返回字符串
tokens = b"".join(vocab[idx] for idx in ids)
#并不是每个字节序列都有有效的UTF-8
# 当遇到无效起始字节时(utf-8对起始字节的格式是有规定的,例如128=100...是无效的,我们可以将错误输出转为一个特殊字符,errors="replace")
text = tokens.decode("utf-8", errors="replace")
return text
print(decode([128]))
如果我们的LLM预测的token不是有效的UTF-8编码,那么我们可能无法解码它们,所以一般的解决方法都是errors=replace
。
编码
def encode(text):
# 给定一个字符串,返回tokens
tokens = list(text.encode("utf-8"))
while len(tokens) >= 2:
stats = get_stats(tokens)
# 寻找stats在此次循环中需要合并的对,也就是我们要在merge字典中找到具有最低索引的键或类似键,因为我们想要在后期合并之前完成所有的早期合并
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break # 没有在编码范围内
idx = merges[pair]
tokens = merge(tokens, pair, idx)
return tokens
print(encode("Hello world!"))
#[72, 101, 108, 108, 111, 32, 119, 266, 108, 100, 33]
下面来验证一下解码和编码的过程是否正确,如果正确,字符串经过编码和解码后得到的字符串应该和原来相等,但请注意,如果进行反向过程(解码->编码),不是所有的标记序列都是有效的UTF-8字节流,所以一般是单向执行:
valtext = "Many common characters, including numerals, punctuation, and other symbols, are unified within the standard and are not treated as specific to any given writing system. Unicode encodes thousands of emoji, with the continued development thereof conducted by the Consortium as a part of the standard.[4] Moreover, the widespread adoption of Unicode was in large part responsible for the initial popularization of emoji outside of Japan. Unicode is ultimately capable of encoding more than 1.1 million characters."
valtext2 = decode(encode(valtext))
print(valtext2 == valtext)
##True
接下来我们将看一些最先进的大型语言模型以及它们使用的分词器类型,这会比上面我们介绍的复杂很多。
GPT系列
以GPT-2为例,前面的和我们上面介绍的基本一样,不同的是,举个例子,如果dog
在文本中出现非常频繁,并且它紧挨着各种标点符号出现,如dog. dog! dog?
等等,字节对编码(BPE)算法可能会将它们合并为单个标记,然后得到很多只是带有稍微不同标点的dog标记,把语义和标点符号结合在了一起,这在实验中表明不太理想。
GPT-2做的是在BPE算法上自上而下地以手动方式强制执行某些类型的字符永远不应该合并在一起(https://github.com/openai/gpt-2/blob/master/src/encoder.py)
,核心在于下面的正则表达式,表达式的含义可以参考(https://www.regular-expressions.info/unicode.html):
import regex as re
gpt2pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")
print(re.findall(gpt2pat, "Hello've world123 how's are you!!!?"))
## ['Hello', "'ve", ' world', '123', ' how', "'s", ' are', ' you', '!!!?']
这里将我们的字符串进行拆分,而不是直接对其进行编码,同时符号也可以单独拆分出来了。
接下来再来看一个对代码进行拆分的例子:
example = """
for i in range(1, 101):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
"""
print(re.findall(gpt2pat, example))
##['\n', 'for', ' i', ' in', ' range', '(', '1', ',', ' 101', '):', '\n ', ' if', ' i', ' %', ' 3', ' ==', ' 0', ' and', ' i', ' %', ' 5', ' ==', ' 0', ':', '\n ', ' print', '("', 'FizzBuzz', '")', '\n ', ' elif', ' i', ' %', ' 3', ' ==', ' 0', ':', '\n ', ' print', '("', 'Fizz', '")', '\n ', ' elif', ' i', ' %', ' 5', ' ==', ' 0', ':', '\n ', ' print', '("', 'Buzz', '")', '\n ', ' else', ':', '\n ', ' print', '(', 'i', ')', '\n']
那么这种分词器是怎么训练的呢,你可能会认为首先用上面这个正则表达式将文本拆分,然后所有块中运行一种BP算法,但其实不是,注意上面这段程序每行开头前面的缩进会被划分成单个元素,例如'\n '
, 但实际上在分词器训练时从未将这些空格合并,它们是保持独立的,单独用一个token表示,所以训练的时候可能加了一些额外的规则,因为GPT的训练代码没有开源。
下面我们介绍一下OpenAI官方的分词库TIC token
import tiktoken
# GPT-2 (不合并空格)
enc = tiktoken.get_encoding("gpt2")
print(enc.encode(" hello world!!!"))
# GPT-4 (合并空格)
enc = tiktoken.get_encoding("cl100k_base")
print(enc.encode(" hello world!!!"))
##[220, 220, 220, 23748, 995, 10185]
##[262, 24748, 1917, 12340]
GPT-4和GPT-2分词方式略微不同的是,GPT-4对字母大小写的划分没那么敏感,同时不会合并超过三位数的数字,以防止非常长的数字序列成为标记。
GPT-4的词汇量从大约5w增加到大约10w。
接下来我想简要介绍一下OpenAI发布的GPT-2的encode.py(https://github.com/openai/gpt-2/blob/master/src/encoder.py)文件。
这份代码很简短,首先从底部开始(看代码注释):
def get_encoder(model_name, models_dir):
# 加载encoder.json和vocab.bpe两个文件
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,
)
这两个文件保存的是分词器,如果你想查看上面的两个文件,可以使用这段代码来查看
# 如果没有下载,要先下载这两个文件
#!wget https://openaipublic.blob.core.windows.net/gpt-2/models/1558M/vocab.bpe
#!wget https://openaipublic.blob.core.windows.net/gpt-2/models/1558M/encoder.json
import os, json
# 这里的encoder实际上相当于我们前面的词汇表vocab
with open('encoder.json', 'r') as f:
encoder = json.load(f) # <--- ~equivalent to our "vocab"
with open('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]]
# 等同于我们前面的merges
# 所以这两个文件等同于我们之前提到的合并变量和词汇变量
while循环用于识别下一个应该合并的二元组
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)
词汇表encoder一共有50257个token,256个原始token+50000次合并+1个特殊token
len(encoder) # 256个原始token+50000次合并+1个特殊token
# 50257
这个特殊的标记是文本结束标记
encoder['<|endoftext|>']
# 50256
结束标记,用于告知模型当前文本已结束,并且接下来的内容与先前文本无关。
接下来我们将讨论另一个在Llamas,Mistral等大模型中常使用的标记化库SentencePiece,它与TickToken不同,它既可以进行训练又可以进行推断,并且都非常有效。主要区别如下:
- TickToken首先取字符串中的代码点,然后使用UTF-8编码成字节,然后将字节合并。
- SentencePiece直接在代码点的级别上运行,然后合并代码点,如果用尽了代码点,那么可能会有些罕见的代码点,它们很少出现,这种罕见程度由字符覆盖超参数决定,这些代码点要么被映射到一个特殊的未知标记,比如UNK,要么如果你开了字节回退选项,那么它将采用这些罕见的代码点,使用UTF-8对它们进行编码,然后该编码的各个字节将被翻译成标记。
这有点难理解,让我们来看个例子:
import sentencepiece as spm
# 用一些随机文本编写一个toy.txt文件
with open("toy.txt", "w", encoding="utf-8") as f:
f.write("SentencePiece is an unsupervised text tokenizer and detokenizer mainly for Neural Network-based text generation systems where the vocabulary size is predetermined prior to the neural model training. SentencePiece implements subword units (e.g., byte-pair-encoding (BPE) [Sennrich et al.]) and unigram language model [Kudo.]) with the extension of direct training from raw sentences. SentencePiece allows us to make a purely end-to-end system that does not depend on language-specific pre/postprocessing.")
sentencepiece有大量的选项和配置,这是因为在处理各种各样的事情的过程中不断完善的
# train a sentencepiece model on it
# 这里的设置与训练Llama 2相同
import os
options = dict(
# input spec
input="toy.txt",
input_format="text",
# output spec
model_prefix="tok400", # output filename prefix
# algorithm spec
# BPE alg
model_type="bpe",
vocab_size=400,
# normalization
normalization_rule_name="identity", # ew, turn off normalization
remove_extra_whitespaces=False,
input_sentence_size=200000000, # max number of training sentences
max_sentence_length=4192, # max number of bytes per sentence
seed_sentencepiece_size=1000000,
shuffle_input_sentence=True,
# rare word treatment
character_coverage=0.99995,
byte_fallback=True,
# merge rules
split_digits=True,
split_by_unicode_script=True,
split_by_whitespace=True,
split_by_number=True,
max_sentencepiece_length=16,
add_dummy_prefix=True,
allow_whitespace_only_pieces=True,
# special tokens
unk_id=0, # the UNK token MUST exist
bos_id=1, # the others are optional, set to -1 to turn off
eos_id=2,
pad_id=-1,
# systems
num_threads=os.cpu_count(), # use ~all system resources
)
spm.SentencePieceTrainer.train(**options)
sp = spm.SentencePieceProcessor()
# 加载模型文件并查看词汇表
sp.load('tok400.model')
vocab = [[sp.id_to_piece(idx), idx] for idx in range(sp.get_piece_size())]
vocab
'''[['<unk>', 0],
['<s>', 1],
['</s>', 2],
['<0x00>', 3],
['<0x01>', 4],
['<0x02>', 5],
['<0x03>', 6],
['<0x04>', 7],
['<0x05>', 8],
['<0x06>', 9],
...
['x', 388],
['z', 389],
['(', 390],
['N', 391],
['[', 392],
[']', 393],
['v', 394],
[',', 395],
['/', 396],
['B', 397],
['E', 398],
['K', 399]]
'''
词汇表的表示顺序是以特殊标记开始,然后是字节标记,之后是合并标记,最后是是一些单个代码点标记。在训练集中出现频率较低的代码点不会添加到词汇表中。
ids = sp.encode("hello 안녕하세요")
print(ids)
# [362, 378, 361, 372, 358, 362, 239, 152, 139, 238, 136, 152, 240, 152, 155, 239, 135, 187, 239, 157, 151]
如上代码所示,由于韩语字符不是训练集的一部分,所以含有在训练时没有见过的代码点,并且这些代码点没有与他们对应的标记,但是上面字节回退设置选项配置为True,byte_fallback=True
,所以句子片段会回退到字节,用UTF-8进行编码,然后使用这些标记来表示那些字节。
# 解码上面的token
print([sp.id_to_piece(idx) for idx in ids])
# ['▁', 'h', 'e', 'l', 'lo', '▁', '<0xEC>', '<0x95>', '<0x88>', '<0xEB>', '<0x85>', '<0x95>', '<0xED>', '<0x95>', '<0x98>', '<0xEC>', '<0x84>', '<0xB8>', '<0xEC>', '<0x9A>', '<0x94>']
这里我们注意到,hello前面多了一个空格,这是为了hello在句子开头与句子其他位置(因为在这种情况下hello划分后前面会带一个空格)时使用的是相同的token。
最后我们再来详细讨论一下如何设置词汇量大小。
当我们定义一个语言模型时,有一个标记嵌入表,他是一个二维数组,其中词汇量一般是行数,每个词汇元素,每个标记都有一个使用反向传播进行训练的词向量,向量的大小是嵌入的大小,等于transformer中的通道数量。
随着词汇量的增加,嵌入表的大小也会增加,从而线性层的大小也会增加,所以要消耗更多的计算资源;而且每个标记相关联的向量由于出现的频率很少(因为总的词汇量增加了)所以训练不足,因为它们几乎不参与前向后向传递。不过,随着词汇量的增长,我们可以有效的缩短序列长度,但如果词汇量太多,又容易把大段文本压缩成一个标记,这可能会造成信息的损失,这些都是在设计词汇量是考虑的因素。
这是一个经验性的超参数,如今最先进的架构的词汇量通常在100,000左右。
下面我们来讨论一下使用预训练模型如何拓展词汇量,以下是一个比较常规的做法,当你对ChatGPT进行微调时,除了基本模型外,会引入更多新的特殊标记,我们所要做的就是调整嵌入表的大小,然后随机初始化嵌入表的参数,再增加线性层的大小,一般我们会冻结原始模型,只训练新的参数。
当然拓展词汇量不仅仅是增加嵌入表和线性层的大小,这篇论文(https://arxiv.org/abs/2304.08467)介绍了一个详细的方法,这篇论文介绍了使用GIST标记来压缩提示。
大致思想是,假设我们需要对语言模型输入非常长的提示,这会减慢语言模型的回应速度,因为模型需要对其进行编码,而这篇论文中作者所做的是引入新的标记,然后通过蒸馏来训练模型,所以你要保持整个模型冻结,只训练新标记的嵌入,并且优化这些新的标记,使得语言模型的能够有效处理非常长的提示输入。
这是一种压缩技术,将那个非常长的提示压缩成那些少数的新GIST标记。
现在我们回到文章开头的问题并进行解答:
-
为什么大型语言模型不能拼写单词?
因为这些字符被分成了标记,一个标记可能包含了多个单词。
-
为什么大型语言模型在执行简单的字符串处理任务(例如反转字符串)时表现不佳?
不能直接反转,但它可以先把字符串划分成单个字符,从而得到单独的标记,再反转打印出来。
-
为什么大型语言模型在处理非英语语言(例如日语)时表现较差?
标记器再非英语数据上训练不足。
-
为什么大型语言模型在简单算术上表现不好?
数字的划分很随机。
-
为什么 GPT-2 在 Python 编码时遇到不必要的麻烦?
在一定程度上是建模的问题,涉及架构、数据集和模型能力,但也涉及到标记化,因为可能对空格的编码效率太低。
-
为什么我的大型语言模型在遇到字符串 “<|endoftext|>” 时突然停止?
没有对应的特殊的标记处理逻辑。
-
为什么当我问大型语言模型关于“SolidGoldMagikarp”时它会崩溃?
“SolidGoldMagikarp”实际上是一个Reddit用户,分词数据集与实际语言模型的训练数据集非常不同,所以在分词数据集中,可能有大量的Reddit数据,因为“SolidGoldMagikarp”是一个经常发帖的人,这个词出现的频率很高,所以被合并成一个单独的标记,但是当你训练模型的时候,这些字符串没有在Reddit的数据中出现,这个标记在优化的开始是随机初始化的,并且在模型训练的过程中从未被更新过。在测试的时候,如果你调用这个标记,相当于抽出未经训练的嵌入表中的一行,从而产生一些未定义行为。
-
为什么我应该在使用大型语言模型时优先选择 YAML 而不是 JSON?
JSON在标记时非常密集,而YAML在标记上更高效一些。