【深度学习】实验 — 动手实现GPT
- 数据处理
- 标记文本
- 添加特殊上下文标记
- 简易分词器代码实现
- 字节对编码
- 使用滑动窗口进行数据采样
- 数据集及数据加载器代码定义
- 创建词元嵌入
- 编码词的位置
- 注意力机制
- 模型定义
- 模型训练
- 使用huggingface模型进行实验
数据处理
模型的输入是张量,那么文本的字符串如何转换为张量呢?答案是tokenizing text 和 word embeddings(标记文本和词嵌入)。让我们一步一步地了解它们。
标记文本
在本节中,我们将文本词元化,这意味着将文本分解为更小的单元,例如单个单词和标点符号。
让我们基于一些简单的示例文本开发一个简单的tokenizer,然后我们可以将其应用于上面的文本
以下正则表达式将在空格上拆分
import retext = "Hello, Student. Welcome to -- the Xiamen University!"result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)
输出
['Hello', ',', 'Student', '.', 'Welcome', 'to', '--', 'the', 'Xiamen', 'University', '!']
- 这非常有效,我们现在准备将此 tokenization 应用于长文本。
- 加载我们打算处理的原始文本。
- 伊迪丝·华顿的判决是一部公共领域的短篇小说
import os
import urllib.requestif not os.path.exists("the-verdict.txt"):url = ("https://raw.githubusercontent.com/rasbt/""LLMs-from-scratch/main/ch02/01_main-chapter-code/""the-verdict.txt")file_path = "the-verdict.txt"urllib.request.urlretrieve(url, file_path)
with open("the-verdict.txt", "r", encoding="utf-8") as f:raw_text = f.read()print("Total number of character:", len(raw_text))
print(raw_text[:100])
print(raw_text[:200])
输出
Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no g
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 the height of his glory, he had dropped his painting, married a
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(preprocessed[:30])
print(len(preprocessed))
输出
['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']
4690
接下来,我们将文本标记转换为标记ID,我们可以稍后通过嵌入层进行处理
从这些标记,我们现在可以建立一个由所有独特的标记组成的词汇表
all_words = sorted(set(preprocessed))
vocab_size = len(all_words)print(vocab_size)
输出
1130
以下是本词汇表的前20个条目:
vocab = {token:integer for integer,token in enumerate(all_words)}for i, item in enumerate(vocab.items()):print(item)if i >= 50:break
输出
('!', 0)
('"', 1)
("'", 2)
('(', 3)
(')', 4)
(',', 5)
('--', 6)
('.', 7)
(':', 8)
(';', 9)
('?', 10)
('A', 11)
('Ah', 12)
('Among', 13)
('And', 14)
('Are', 15)
('Arrt', 16)
('As', 17)
('At', 18)
('Be', 19)
('Begin', 20)
添加特殊上下文标记
为未知单词添加一些“特殊”标记并表示文本的结尾很有用
-
一些分词器使用特殊的标记来为LLM(大型语言模型)提供额外的上下文信息。
-
这些特殊的标记包括:
[BOS]
(序列开始),用于标记文本的开头。[EOS]
(序列结束),用于标记文本的结尾(通常用于连接多个不相关的文本,例如两个不同的维基百科文章或书籍等)。[PAD]
(填充),如果我们在训练LLM时使用的批大小大于1(包含多个长度不同的文本);通过填充标记,可以将较短的文本填充至与最长文本相同的长度,从而使所有文本具有相同的长度。
-
[UNK]
用于表示词汇表中未包含的词语。 -
请注意,GPT-2 不需要上述的任何特殊标记,只使用
<|endoftext|>
标记来简化复杂性。 -
<|endoftext|>
与上面提到的[EOS]
标记类似。 -
GPT 也将
<|endoftext|>
用作填充标记(由于我们通常在训练批量输入时使用掩码,不会关注填充标记,因此这些填充标记的内容并不重要)。 -
GPT-2 不使用
<UNK>
标记来表示词汇表外的词语;相反,GPT-2 使用字节对编码(BPE)分词器,将词语分解为子词单元,我们将在后续部分中详细讨论这一点。 -
我们在两个独立的文本来源之间使用
<|endoftext|>
标记: -
我们来看一下如果对以下文本进行分词会发生什么:
-
为了解决词汇表中不包含某些单词的情况,我们可以向词汇表中添加像
"<|unk|>"
这样的特殊标记,以表示未知词汇。 -
既然我们已经扩展了词汇表,让我们再添加一个名为
"<|endoftext|>"
的标记。此标记在 GPT-2 训练中用于表示文本的结束(它也用于连接的文本之间,例如当我们的训练数据集包含多个文章、书籍等时)。
all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])vocab = {token:integer for integer,token in enumerate(all_tokens)}len(vocab.items())
输出
1132
for i, item in enumerate(list(vocab.items())[-5:]):print(item)
('younger', 1127)
('your', 1128)
('yourself', 1129)
('<|endoftext|>', 1130)
('<|unk|>', 1131)
- 我们还需要相应地调整分词器,使其知道何时以及如何使用新的
<unk>
标记。
简易分词器代码实现
class SimpleTokenizerV1:def __init__(self, vocab):self.str_to_int = vocabself.int_to_str = { i:s for s,i in vocab.items()}# The encode function turns text into token IDsdef encode(self, text):"""分词函数:参数:text: str输入文本,将被分词。返回值:token_ids: list返回分词后的 token ID 列表。实现步骤:1. 将文本拆分为 token。2. 移除所有开头或结尾的空白字符。3. 将任何未知 token 替换为 "<|unk|>"。4. 将 token 转换为相应的 ID。5. 返回 token ID 列表。"""preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)preprocessed = [item.strip() for item in preprocessed if item.strip()]preprocessed = [item if item in self.str_to_intelse "<|unk|>" for item in preprocessed]# use the dictionary to convert tokens to IDs (tips: use self.str_to_int)# complete this section (1/10)ids = [self.str_to_int[token] for token in preprocessed]return ids# The decode function turns token IDs back into textdef decode(self, ids):"""解码函数:参数:ids: listtoken ID 的列表。返回值:text: str解码后的文本。实现步骤:1. 将 token ID 替换为相应的 token。2. 移除指定标点符号前的空格。3. 返回解码后的文本。"""# complete this section (2/10) (tips: use self.int_to_str to get the 'text' from 'ids')tokens = [self.int_to_str[token_id] for token_id in ids]text = ' '.join(tokens)# Replace spaces before the specified punctuationstext = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)return text
让我们尝试使用修改后的分词器对文本进行分词:
tokenizer = SimpleTokenizerV1(vocab)text1 = "Hello Xiamen University!"
text2 = "I love you, good bye!"text = " <|endoftext|> ".join((text1, text2))print(text)
输出
Hello Xiamen University! <|endoftext|> I love you, good bye!
tokenizer.encode(text)
输出
[1131, 1131, 1131, 0, 1130, 53, 1131, 1126, 5, 500, 1131, 0]
tokenizer.decode(tokenizer.encode(text))
输出
'<|unk|> <|unk|> <|unk|>! <|endoftext|> I <|unk|> you, good <|unk|>!'
字节对编码
- GPT-2 使用字节对编码(BPE)作为其分词器。
- 这种编码方式允许模型将未在预定义词汇表中的单词拆解为更小的子词单元,甚至是单个字符,使其能够处理词汇表外的单词。
- 例如,如果 GPT-2 的词汇表中没有单词 “unfamiliarword”,它可能会将其分词为 [“unfam”, “iliar”, “word”] 或其他子词组合,具体取决于其训练的 BPE 合并规则。
- 原始的 BPE 分词器可以在此找到:https://github.com/openai/gpt-2/blob/master/src/encoder.py
- 本章中,我们使用 OpenAI 开源的 tiktoken 库中的 BPE 分词器,它的核心算法使用 Rust 实现,从而提升了计算性能。
- 我创建了一个 ./bytepair_encoder 笔记本,比较了这两种实现方式(在样本文本上,tiktoken 的速度约快 5 倍)。
import tiktokentokenizer = tiktoken.get_encoding("gpt2")text = ("Hello Ximen University!""I love you, good bye!"
)integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})print(integers)
[15496, 1395, 19027, 2059, 0, 40, 1842, 345, 11, 922, 33847, 0]
strings = tokenizer.decode(integers)print(strings)
输出
Hello Ximen University!I love you, good bye!
- BPE 分词器将未知词拆解为子词和单个字符:
使用滑动窗口进行数据采样
- 我们训练 LLMs 每次生成一个词,因此需要相应地准备训练数据,其中序列中的下一个词作为需要预测的目标:
with open("the-verdict.txt", "r", encoding="utf-8") as f:raw_text = f.read()enc_text = tokenizer.encode(raw_text)
print(len(enc_text))
输出
5145
- 对于每个文本块,我们需要输入和目标数据。
- 由于希望模型预测下一个词,因此目标是将输入向右偏移一个位置。
- 让我们使用前 50 个样本来展示数据集。
enc_sample = enc_text[50:]
context_size = 4x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]print(f"x: {x}")
print(f"y: {y}")
输出
x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]
- 逐个预测时,结果如下所示:
for i in range(1, context_size+1):context = enc_sample[:i]desired = enc_sample[i]print(context, "---->", desired)
输出
[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
for i in range(1, context_size+1):context = enc_sample[:i]desired = enc_sample[i]print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))
输出
and ----> establishedand established ----> himselfand established himself ----> inand established himself in ----> a
-
我们将在讲解完注意力机制后处理下一个词的预测。
-
目前,我们实现一个简单的数据加载器,用于遍历输入数据集,并返回向右偏移一个位置的输入和目标。
-
我们使用滑动窗口方法,将位置每次移动 +1:
-
创建数据集和数据加载器,从输入文本数据集中提取文本块。
数据集及数据加载器代码定义
from torch.utils.data import Dataset, DataLoader
import torchclass GPTDatasetV1(Dataset):def __init__(self, txt, tokenizer, max_length, stride):self.input_ids = []self.target_ids = []# Tokenize the entire texttoken_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})# Use a sliding window to chunk the book into overlapping sequences of max_length"""一个可能的实现步骤是:1. 以步长 stride 遍历 token_ids2. 创建一个包含 max_length 个 token 的数据块3. 创建一个输入数据块和一个目标数据块4. 将输入数据块附加到 self.input_ids5. 将目标数据块附加到 self.target_ids"""for i in range(0, len(token_ids) - max_length, stride):input_chunk = token_ids[i:i + max_length]target_chunk = token_ids[i + 1: i + max_length + 1]self.input_ids.append(torch.tensor(input_chunk))self.target_ids.append(torch.tensor(target_chunk))def __len__(self):return len(self.input_ids)def __getitem__(self, idx):return self.input_ids[idx], self.target_ids[idx]
def create_dataloader_v1(txt, batch_size=4, max_length=256,stride=128, shuffle=True, drop_last=True,num_workers=0):# Initialize the tokenizertokenizer = tiktoken.get_encoding("gpt2")# Create datasetdataset = GPTDatasetV1(txt, tokenizer, max_length, stride)# Create dataloaderdataloader = DataLoader(dataset,batch_size=batch_size,shuffle=shuffle,drop_last=drop_last,num_workers=num_workers)return dataloader
- 让我们使用批大小为 1、上下文大小为 4 的数据加载器来测试一个大型语言模型(LLM):
with open("the-verdict.txt", "r", encoding="utf-8") as f:raw_text = f.read()dataloader = create_dataloader_v1(raw_text, batch_size=1, max_length=4, stride=1, shuffle=False
)data_iter = iter(dataloader)
first_batch = next(data_iter)
print("first_batch: ",first_batch)
second_batch = next(data_iter)
print("second_batch: ",second_batch)
输出
first_batch: [tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
second_batch: [tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
- 如下所示,使用等于上下文长度(此处为 4)的步长的示例:
- 我们还可以创建批量输出。
- 注意,在这里我们增加了步长,以避免批次之间的重叠,因为更多的重叠可能导致过拟合的增加。
dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4, shuffle=False)data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)
Inputs:tensor([[ 40, 367, 2885, 1464],[ 1807, 3619, 402, 271],[10899, 2138, 257, 7026],[15632, 438, 2016, 257],[ 922, 5891, 1576, 438],[ 568, 340, 373, 645],[ 1049, 5975, 284, 502],[ 284, 3285, 326, 11]])Targets:tensor([[ 367, 2885, 1464, 1807],[ 3619, 402, 271, 10899],[ 2138, 257, 7026, 15632],[ 438, 2016, 257, 922],[ 5891, 1576, 438, 568],[ 340, 373, 645, 1049],[ 5975, 284, 502, 284],[ 3285, 326, 11, 287]])
创建词元嵌入
- 数据已经几乎可以用于大型语言模型(LLM)。
- 最后,让我们使用嵌入层将词元嵌入到连续的向量表示中。
- 通常,这些嵌入层是 LLM 本身的一部分,并在模型训练期间进行更新(训练)。
- 假设我们有以下四个输入示例,输入 ID 分别为 2、3、5 和 1(经过分词处理后):
input_ids = torch.tensor([2, 3, 5, 1])
- 为了简单起见,假设我们有一个仅包含 6 个词的小型词汇表,并且希望创建大小为 3 的嵌入:
vocab_size = 6
output_dim = 3embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
- 这将生成一个 6x3 的权重矩阵:
print(embedding_layer.weight)
输出
Parameter containing:
tensor([[ 0.2795, -0.2610, -0.4322],[ 1.3667, 0.7131, -0.3869],[-0.2535, -1.6675, -0.3712],[ 0.1377, 1.4148, 0.6343],[-0.0776, -1.1175, -0.6481],[ 0.6530, -0.1596, 0.3569]], requires_grad=True)
- 对于熟悉独热编码的人来说,上述的嵌入层方法本质上是一种更高效的实现方式,相当于在全连接层中执行独热编码后进行矩阵乘法。
- 由于嵌入层仅是独热编码和矩阵乘法方法的更高效实现,因此可以将其视为一个神经网络层,可通过反向传播进行优化。
- 为了将 ID 为 3 的词元转换为 3 维向量,我们执行以下操作:
print(embedding_layer(torch.tensor([3])))
输出
tensor([[0.1377, 1.4148, 0.6343]], grad_fn=<EmbeddingBackward0>)
-
注意,上述结果对应
embedding_layer
权重矩阵中的第 4 行。 -
为了嵌入上述所有四个
input_ids
值,我们执行以下操作: -
嵌入层本质上就是一个查找操作:
编码词的位置
- 嵌入层将 ID 转换为相同的向量表示,而不考虑它们在输入序列中的位置:
- 位置嵌入与词元嵌入向量相结合,形成大型语言模型的输入嵌入:
- BytePair 编码器的词汇表大小为 50,257。
- 假设我们希望将输入词元编码为 256 维向量表示:
vocab_size = 50257
output_dim = 256token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
- 如果我们从数据加载器中采样数据,则将每个批次中的词元嵌入到 256 维向量中。
- 如果批大小为 8,每个批次包含 4 个词元,这将生成一个 8 x 4 x 256 的张量:
pos_enc_example_text = "I love Xiamen University, I love deep learning."
tokenizer = tiktoken.get_encoding("gpt2")
pos_enc_example_ids = tokenizer.encode(pos_enc_example_text, allowed_special={"<|endoftext|>"})
max_length = len(pos_enc_example_ids)
pos_enc_example_ids = torch.tensor([pos_enc_example_ids],dtype=torch.long)
pos_enc_example_ids
输出
tensor([[ 40, 1842, 1395, 1789, 268, 2059, 11, 314, 1842, 2769, 4673, 13]])
print("Token IDs:\n", pos_enc_example_ids )
print("\nInputs shape:\n", pos_enc_example_ids.shape)
Token IDs:tensor([[ 40, 1842, 1395, 1789, 268, 2059, 11, 314, 1842, 2769, 4673, 13]])Inputs shape:torch.Size([1, 12])
print("Tokens:\n", tokenizer.decode(pos_enc_example_ids.view(-1).tolist()))
输出
Tokens:I love Xiamen University, I love deep learning.
“love” 在不同位置出现了两次,但它们具有相同的 ID,因此会有相同的词嵌入表示。
love_first_index = 1
love_second_inedx = 8
print(pos_enc_example_ids[0,love_first_index])
print(pos_enc_example_ids[0,love_second_inedx])
输出
tensor(1842)
tensor(1842)
token_embeddings = token_embedding_layer(pos_enc_example_ids)
print(token_embeddings.shape)
print(token_embeddings)
输出
torch.Size([1, 12, 256])
tensor([[[ 0.9211, 0.3466, 2.1033, ..., -1.4786, 0.6227, -0.0867],[ 0.1743, 0.2107, -1.7277, ..., 0.1518, 0.2420, -0.6309],[-0.1502, 0.3992, -0.4175, ..., -1.2679, 0.2546, -0.2264],...,[-0.6818, -1.4486, -0.7959, ..., 0.3791, -0.1743, 1.8602],[ 0.1818, 1.6600, 0.2134, ..., -0.8525, 0.2287, 1.9346],[ 0.3019, -0.2908, -1.3955, ..., -0.3863, -0.5667, 0.6608]]],grad_fn=<EmbeddingBackward0>)
print(token_embeddings[0,love_first_index])
print(token_embeddings[0,love_second_inedx])
print("Is token_embeddings[0,love_first_index] equal token_embeddings[0,love_second_inedx]?:",torch.equal(token_embeddings[0,love_first_index],token_embeddings[0,love_second_inedx]))
输出
Is token_embeddings[0,love_first_index] equal token_embeddings[0,love_second_inedx]?: True
- GPT-2 使用绝对位置嵌入,因此我们只需创建另一个嵌入层:
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(max_length))
print(pos_embeddings.shape)
输出
torch.Size([12, 256])
- 为了创建用于大型语言模型的输入嵌入,我们只需将词元嵌入与位置嵌入相加即可:
input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)
输出
torch.Size([1, 12, 256])
在添加位置嵌入之后,相同的 ID 在不同位置将具有不同的嵌入,包含了位置信息。
print(input_embeddings[0,love_first_index])
print(input_embeddings[0,love_second_inedx])
print("Is input_embeddings[0,love_first_index] equal input_embeddings[0,love_second_inedx] ?:",torch.equal(input_embeddings[0,love_first_index],input_embeddings[0,love_second_inedx]))