从0开始搭建大语言模型:Dataset构造
通过滑动窗口进行数据采样
我们要构造输入-目标对来对模型进行训练。
在LLM中,它通过预测文本中的下一个单词进行训练,如下所示:
给定一个文本样本,提取输入块作为LLM的输入子样本,LLM在训练期间的预测任务是预测输入块之后的下一个单词。在训练过程中,我们屏蔽掉所有超过目标的单词。
为下一个单词预测任务创建输入-目标对的最简单和最直观的方法之一是创建两个变量,x和y,其中x包含输入token,y包含目标,它们之间的下标偏移为1,代码实现如下所示:
import tiktoken
with open("./data/the_verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
tokenizer = tiktoken.get_encoding("gpt2")
enc_text = tokenizer.encode(raw_text)
# 移除前面50个
enc_sample = enc_text[50:]
# 构建input-target对
context_size = 4
x = enc_sample[:context_size]
# 跟x相比往后偏移一个距离
y = enc_sample[1:context_size+1]
输入和目标之间的变化用代码输出为:
# token id形式的表示
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(context, "---->", desired)
# 转换为token形式
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))
对应的输出为:
[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
and ----> established
and established ----> himself
and established himself ----> in
and established himself in ----> a
在训练的时候,我们希望数据加载器返回两个张量:一个是包含LLM看到的文本的输入张量,一个是包含LLM要预测的目标张量,如下图所示:
图中为了方便理解展示的是文本token,但实际上都是token id,即一些数字。
为了实现这一功能,使用Pytorch
的Dataset
和DataLoader
,它可以高效地实现。
代码如下:
import torch
from torch.utils.data import Dataset, DataLoader
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
# max_length表示选中的文本的最大长度,stride表示间隔多少个词再开始
self.tokenizer = tokenizer
self.input_ids = []
self.target_ids = []
token_ids = tokenizer.encode(txt)
# 构造训练集
for i in range(0, len(token_ids) - max_length, stride):
# 一个input-target对
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]
# 创建data loader
def create_dataloader_v1(txt, batch_size=4, max_length=256, stride=128, shuffle=True, drop_last=True):
tokenizer = tiktoken.get_encoding("gpt2")
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last)
return dataloader
调用这两个类,代码为:
# 创建loader,进行数据加载
## stride设置规定了输入在批次中移动的距离,模拟滑动窗口方法
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)
输出为:
[tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
在create_dataloader_v1
的参数中,stride设置规定了输入在批次中移动的距离,模拟滑动窗口方法,当stride等于窗口大小,即max_length时,可以防止每个batch之间的重叠,过多的重叠可能导致过拟合,如下所示:
创建token embeddings
准备用于LLM训练的输入文本的最后一步是将token id转换为embedding向量,如下所示:
一般来说,我们刚开始时用随机值初始化这些embedding权重。这个初始化是LLM学习过程的起点。
在Pytorch中,我们使用nn.Embedding
来生成embedding,示例代码为:
input_ids = torch.tensor([2, 3, 5, 1])
vocab_size = 6
output_dim = 3
torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)
这里,vocab_size是字典的大小,output_dim是embedding的尺寸。
输出如下,其中Embedding层的权重尺寸为vocab_size * output_dim
:
Parameter containing:
tensor([[ 0.3374, -0.1778, -0.1690],
[ 0.9178, 1.5810, 1.3010],
[ 1.2753, -0.2010, -0.1606],
[-0.4015, 0.9666, -1.1481],
[-1.1589, 0.3255, -0.6315],
[-2.8400, -0.7849, -1.4096]], requires_grad=True)
我们可以看到权重矩阵有6行3列。词汇表中有6个可能的单词,每个单词对应一行。每个embedding维度都有一列。
对一个token id进行embedding,代码为:
print(embedding_layer(torch.tensor([3])))
此时的输出为:
tensor([[-0.4015, 0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
维度是3,表示3这个token id对应的embedding尺寸。
如果我们将ID为3的标记的embedding向量与之前的embedding矩阵进行比较,我们看到它与第4行相同(Python从索引0开始,因此它是与索引3对应的行)。embedding层本质上是一个查找操作,通过token ID从embedding层的权重矩阵中检索行。如下所示:
编码word位置
LLM的一个小缺点是其中的自注意力机制没有序列中token的位置或顺序的概念。前面介绍的embedding层的工作方式是,相同的token ID总是映射到相同的向量表示,无论令牌ID在输入序列中的位置,如下图所示。
原则上,token ID的确定性、位置无关的embedding有利于再现性。然而,由于LLM本身的自注意力机制也是位置无关的,因此有助于向LLM中加入额外的位置信息,有两种方式:
- 相对位置embeddings
- 绝对位置embeddings
绝对位置embeddings与序列中的特定位置直接关联。对于输入序列中的每个位置,在token的embeddings中添加一个唯一的embeddings,以传递其确切位置。如下图所示:
相对位置嵌入的重点是标记之间的相对位置或距离。这意味着模型根据“距离多远”而不是“确切位置”来学习关系。这样做的好处是,即使在训练过程中没有看到过长度不同的序列,模型也可以更好地泛化。
这两种类型的位置embedding旨在增强LLM理解token之间的顺序和关系的能力,确保更准确和上下文感知的预测。
OpenAI的GPT模型使用在训练过程中可以优化的绝对位置embedding,而不是像原始Transformer模型中的位置编码那样是固定的或预定义的。
接下来我们来完成对上一节的the_verdict
文本内容的embedding,代码如下:
output_dim = 256
vocab_size = 50257
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
max_length = 4
dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=max_length, stride=max_length, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
其中raw_text为文本内容,最后输出的内容为:
Token IDs:
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]])
Inputs shape:
torch.Size([8, 4])
torch.Size([8, 4, 256])
然后创建位置编码,代码为:
# 创建position embedding
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)
其中pos_embeddings的输入通常是一个占位向量torch.arange(context_length),它包含一个数字序列0,1,…,最大值的长度 - 1。context_length是一个变量,表示LLM支持的输入大小。
最后将位置编码加到embedding上,代码为:
input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)
整个输入处理的流程为: