从0开始搭建大语言模型:文本预处理
参考仓库:LLMs-from-scratch
理解Word embedding
深度神经网络模型,包括LLM,不能直接处理原始文本,因此需要一种方法将它转换为连续值的向量,也就是embedding。如下图所示,可以使用各种embedding模型将不同的数据转换为embedding,从而供神经网络处理:
embedding的主要目的是将非数值数据转换为神经网络可以处理的格式。
常用的文本embedding方法有Word2Vec,它通过预测给定目标单词的单词上下文来生成单词嵌入来训练神经网络架构。背后的主要思想是,出现在相似语义中的单词往往具有相似的含义。
需要注意的是:LLM通常会生成自己的embedding,这些embedding是输入层的一部分,并在训练期间进行更新。将优化embedding作为LLM训练的一部分而不是使用Word2Vec的优点是,embedding是针对特定任务和手头数据进行优化的。
预处理文本数据
本部分介绍了如何将输入文本分割为单个token,这是为LLM创建embedding所需的预处理步骤。这些token可以是单个单词,也可以是特殊字符(包括标点符号)。LLM的一个通常流程为:
这里选择的文本是维基百科的一个短篇故事The_Verdict,你可以copy内容然后将它放在一个文本文件中。
预处理该文本的代码为:
首先是读取文件:
with open("./data/the_verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
print("Total number of character:", len(raw_text))
print(raw_text[:99])
# 输出为:
# Total number of character: 20479
# I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no
然后使用正则表达式匹配单词,标点符号等内容:
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))
print(preprocessed[:30])
其中正则表达式([,.?_!"()\']|--|\s)
中:
- 模式\s匹配任何空白字符(包括空格、制表符等)。
- 括号 () 表示捕获组
- [,.] 匹配逗号, 或句号;
- |表示或者
最后的输出为:
4649
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
说明有4649个token。
将token变成对应的ID
在本部分中,我们将把这些token从Python字符串转换为整数表示,以生成所谓的token id。这种转换是将token id转换为embedding向量之前的中间步骤。
该部分主要是建立一个字典,含有字符到id的映射,能够将句子转换为一串id,如下图所示:
首先是构建字典,代码为:
# 去除重复的单词
all_words = sorted(list(set(preprocessed)))
vocab_size = len(all_words)
print(vocab_size)
# 构建字典,即每个单词对应的数字
vocab = {token:integer for integer,token in enumerate(all_words)}
for i, item in enumerate(vocab.items()):
print(item)
if i > 50:
break
然后,我们构造一个Tokenizer
,它的作用是实现token的编解码,即:将句子变成token id和将token id变成句子,编解码的过程为:
同时,我们需要考虑如果遇到文本里面没有的单词该怎么办。一些特殊上下文token的使用和添加可以增强模型对文本中的上下文或其他相关信息的理解。例如,这些特殊标记可以包括未出现的单词和文档边界的标记。下图展示了使用<|unk|>代替不在字典字的单词:
其中我们添加了一个<|unk|>
标记来表示不属于训练数据的新单词和未知单词,因此也不属于现有词汇表。此外,我们添加了一个<|endoftext|>
标记,我们可以使用它来分离两个不相关的文本源。
当在多个独立文档或书籍上训练类似gpt的LLM时,通常在前一个文本源之后的每个文档或书籍之前插入token,这有助于LLM理解,尽管这些文本源是连接起来进行训练的,但实际上它们是不相关的。如下图所示:
该部分的代码为:
all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])
vocab = {token:integer for integer,token in enumerate(all_tokens)}
print(len(vocab.items()))
for i, item in enumerate(list(vocab.items())[-5:]):
print(item)
输出为:
('younger', 1156)
('your', 1157)
('yourself', 1158)
('<|endoftext|>', 1159)
然后构造Tokenizer
,为:
class SimpleTokenizerV2:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = { i:s for s,i in vocab.items()}
def encode(self, text):
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
# 对于不在str_to_int里面的标记为<|unk|>
preprocessed = [item if item in self.str_to_int else "<|unk|>" for item in preprocessed]
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids):
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) #B
return text
调用该代码处理字典厘米没有出现过的单词:
text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
text = " <|endoftext|> ".join((text1, text2))
print(text)
tokenizer = SimpleTokenizerV2(vocab)
print(tokenizer.encode(text))
print(tokenizer.decode(tokenizer.encode(text)))
输出为:
Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
[1160, 5, 362, 1155, 642, 1000, 10, 1159, 57, 1013, 981, 1009, 738, 1013, 1160, 7]
<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.
可以看到,Hello被解码成了<|unk|>。
在LLM中,还有一些其他的特殊token:
[BOS]
(sequence的开始):标记文本的开始。它标志着LLM的内容从哪里开始。[EOS]
(end of sequence):位于文本的末尾,在连接多个不相关的文本时特别有用,类似于<|endoftext|>
。例如,当组合两篇不同的维基百科文章或书籍时,[EOS] token表示一篇文章的结束和下一篇文章的开始[PAD]
(padding):当训练批量大小大于1的LLM时,批处理可能包含不同长度的文本。为了确保所有文本具有相同的长度,使用[PAD]
标记对较短的文本进行扩展或“填充”,直至批处理中最长文本的长度。
Byte pair encoding(BPE)
本部分中介绍的BPE分词器(tokenizer)用于训练LLM,如GPT-2、GPT-3和ChatGPT中使用的原始模型。因为BPE的实现比较复杂,本部分使用tiktoken
库,它含有BPE的实现,通过pip install tiktoken==0.5.1
命令安装即可。
实例化一个BPE tokenizer进行句子的编解码,如下:
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)
strings = tokenizer.decode(integers)
print(strings)
输出为:
[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]
Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.
从结果中可以看出:
- 为
<|endoftext|>
token分配一个相对较大的token ID,即50256。这是因为用于训练GPT-2、GPT-3等模型以及ChatGPT中使用的原始模型的BPE分词器,总词汇量为50257,其中<|endoftext|>
被分配了最大的token ID。 - BPE分词器可以处理任何未知单词,如someunknownPlace。BPE底层的算法将不在其预定义词汇表中的单词分解为更小的子单词单元甚至单个字符,使其能够处理未在词汇表的单词。因此,如果分词器在分词过程中遇到不熟悉的单词,BPE算法可以将其表示为子单词标记或字符序列,如下所示:
BPE的思路:它通过迭代地将频繁字符合并为子词,将频繁子词合并为单词来构建词汇。例如,BPE从将所有单个字符添加到其词汇表(“a”,“b”,…)开始。在下一阶段,它将频繁出现在一起的字符组合合并为子词。例如,“d”和“e”可以合并成子词“de”,这在许多英语单词中很常见,如“define”、“depend”、“made”和“hidden”。合并由频率截止决定