分享

大型模型语言入门:构建大模型之数据准备(下)

 江海博览 2024-03-15 发布于浙江
AI边缘编程者
AI边缘编程者
2024-03-07 20:21

在我们上一篇文章《大型模型语言入门:构建大模型之数据准备(下)》中,我们揭开了大型模型构建过程中数据准备的重要性,讨论了如何转换令牌为唯一标识符,添加特殊上下文 Token 和 Byte Pair Encoding。

今天,我们迎来了系列文章的最后一篇——《构建大模型之数据准备(下)》,通过本篇文章,你将学习到:

  • 使用滑动窗口进行数据采样:如何更高效地处理大量长文本数据,并确保模型能够从中学习到更多有价值的信息。
  • 构建深层次的令牌嵌入表示:进一步理解如何通过令牌嵌入捕捉语言的细微差别和深层语义。
  • 精准的位置编码技巧:掌握如何通过对单词位置的精确编码,提高模型对文本结构的理解力。

使用滑动窗口进行数据采样

在上一篇中,我们详尽地探讨了令牌化的各个步骤,包括如何将文本中的字符串令牌转换成一系列整数ID。这一过程为我们接下来的任务——大型语言模型(LLMs)创建数据表示打下了基础。接下来,要进行的工作是创建用于训练大语言模型的输入与目标配对。

那么输入与目标配对究竟是什么呢?还记得我们在之前的篇章中提到的,大语言模型的预训练主要是通过预测文本中下一个词来进行的。简单来说,我们通过给模型展示一段文本,让它猜测接下来会出现什么词。这种方法帮助模型学会理解和生成语言。

大型模型语言入门:构建大模型之数据准备(下)

这里要介绍的是如何创建一个特殊的程序——数据加载器。它的职责是根据我们上图的方法,利用所谓的滑动窗口技术,从训练资料中抽取一对一对的输入和目标数据。

我们的起点是对《The Verdict》这部短篇故事进行处理,这是之前已经使用过的文本。我们将采用前文提到的 BPE(字节对编码)来进行分词。BPE 通过合并频繁出现的字节对来减少文本的复杂性。

通过这种方式,我们能够把故事转化成模型能够理解的形式,即一系列经过精加工的令牌。这个步骤非常重要,因为它为我们后面使用滑动窗口技术从故事中提取训练所需的输入和目标对打下了基础。滑动窗口技术允许我们在整个文本中按照一定的步长滑动,每次滑动捕捉一小段文本作为输入,以及紧接着的文本作为目标,从而为大型语言模型的训练提供合适的数据。这种方法既高效又实用,让模型能够更好地学习和预测文本中的语言模式。

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。这个数字代表使用 BPE 处理训练数据后,得到的总令牌(即“词”)数量。

下一步从数据集的开头去掉前50个令牌。

enc_sample = enc_text[50:]

为下一个词预测任务创建输入-目标对最简单和最直观的方法之一是创建两个变量,x和y,其中x包含输入令牌,y包含目标,这些目标是输入向右移动1位的结果:

context_size = 4 x = 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

箭头(---->) 左边的所有内容都是 LLMs 将要接收的输入,而箭头右边的 Token ID 代表大型语言模型应该预测的目标 Token ID。

为了演示目的,可以重复前面的代码,将 Token ID 转换为文本:

for i in range(1, context_size+1): context = enc_sample[:i] desired = enc_sample[i] print(tokenizer.decode(context), '---->', tokenizer.decode([desired]))

执行结果如下:

 and ---->  established
 and established ---->  himself
 and established himself ---->  in
 and established himself in ---->  a

到目前为止,已经成功构建了一系列输入和目标的配对数据,后续的章节里可以利用这些数据进行大型语言模型(LLM)的训练。

在将这些令牌转换成模型可以直接处理的 Embedding 格式之前,我们还需要完成一个关键步骤:开发一个能够高效遍历整个输入数据集的数据加载器,并且能够将这些数据转化为模型训练过程中所需的 PyTorch(开源 Python 机器学习库) 张量格式。

具体来说,我们的目标是准备两种张量:一种是输入张量,包含了大型语言模型将会“看到”的文本数据;另一种是目标张量,包括了模型需要预测的目标数据。这个过程可以参考下图的展示。可以确保数据以一种模型能够有效处理的格式被呈现。

大型模型语言入门:构建大模型之数据准备(下)

上图为了演示目的以字符串格式显示了 Token,但代码实现将直接操作 Token ID,因为BPE 的编码方法将令牌化和转换为 Token ID 作为单一步骤执行。

对于高效的数据加载器实现,我们将使用 PyTorch(开源 Python 机器学习库) 内置的 Dataset 和 DataLoader 类。有关安装PyTorch的更多信息和指导,请自行搜索学习实践。

数据集类的代码如下所示:

import torch from torch.utils.data import Dataset, DataLoader class GPTDatasetV1(Dataset): def __init__(self, txt, tokenizer, 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_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]

代码中的 GPTDatasetV1 类基于 PyTorch 的 Dataset 类,并定义了如何从数据集中获取单个行,每行由一定数量的 Token ID 组成(基于一个max_length),分配给一个 input_chunk 张量。target_chunk 张量包含相应的目标。当我们将数据集与PyTorch的DataLoader结合时,这个数据集返回的数据看起来如何——这将带来额外的直观感受和清晰度。如果你对PyTorch Dataset类的结构不熟悉,如代码清单2.5所示,请搜索 PyTorch 官方文档 Dataset 和 DataLoader 类的一般结构和使用方法。以下代码将使用GPTDatasetV1通过PyTorch DataLoader批量加载输入:

def create_dataloader(txt, batch_size=4, 
        max_length=256, stride=128, shuffle=True):
    tokenizer = tiktoken.get_encoding('gpt2')
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
    dataloader = DataLoader(
        dataset, batch_size=batch_size, shuffle=shuffle)
    return dataloader

可以使用批量大小为1,上下文大小为4的大型语言模型(LLM),来测试数据加载器,以便开发对前面代码中的 GPTDatasetV1 类和上述代码中的 create_dataloader 函数如何协同工作的直观理解:

with open('the-verdict.txt', 'r', encoding='utf-8') as f: raw_text = f.read() dataloader = create_dataloader( 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]])]

在 first_batch 变量中有两个张量:一个用于存放输入的 Token ID,另一个用于存放目标 Token ID。鉴于将最大长度(max_length)设置为了4,这意味着每个张量都会包含 4 个 Token ID。需要注意的是,这里选择的输入长度为 4 更多是为了方便演示;在实际应用中,我们训练大型语言模型时通常会使用至少 256 个令牌的输入长度。

为了更好地解释 stride=1 这一设置的含义,我们接下来会尝试从数据集中抓取另一批数据进行展示。通过将stride参数设置为1,数据加载器是如何每次仅向前移动一个 Token 位置来生成每一个新的数据批次的。这个过程对于理解模型如何从连续的文本流中学习上下文信息非常关键。

second_batch = next(data_iter) print(second_batch)

执行结果如下:

[tensor([[367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]

如果我们将第一批与第二批进行比较,我们可以看到,与第一批相比,第二批的 Token ID 向右移动了一个位置(例如,第一批输入的第二个ID是 367,这是第二批输入的第一个 ID)。stride 设置决定了输入在批次之间移动的位置数,模拟了滑动窗口方法,正如下图所示。

大型模型语言入门:构建大模型之数据准备(下)

到目前为止,数据加载器采样的批次大小为1,主要是为了演示之用。如果你有深度学习方面的经验,可能已经知道,较小的批次大小在训练过程中会占用较少的内存资源,但同时,它们会使模型的更新过程显得更加波动不稳。正如在常规深度学习过程中一样,选择合适的批次大小既是一种权衡,也是一个在训练大语言模型时需要进行实验的超参数。

在深入了解本系列最后两节内容之前,这两节内容将专注于如何从Token标识生成 Embedding 嵌入向量,先简单了解一下如何使用数据加载器来采样批次大小大于1的数据:

dataloader = create_dataloader(raw_text, batch_size=8, max_length=4, stride=5) data_iter = iter(dataloader) inputs, targets = next(data_iter) print('Inputs:\n', inputs) print('\nTargets:\n', targets)

执行结果如下:

Inputs:
 tensor([[   40,   367,  2885,  1464],
         [ 3619,   402,   271, 10899],
         [  257,  7026, 15632,   438],
         [  257,   922,  5891,  1576],
         [  568,   340,   373,   645],
         [ 5975,   284,   502,   284],
         [  326,    11,   287,   262],
         [  286,   465, 13476,    11]])
 
Targets:
 tensor([[  367,  2885,  1464,  1807],
         [  402,   271, 10899,  2138],
         [ 7026, 15632,   438,  2016],
         [  922,  5891,  1576,   438],
         [  340,   373,   645,  1049],
         [  284,   502,   284,  3285],
         [   11,   287,   262,  6001],
         [  465, 13476,    11,   339]])
 

请注意,将步长增加到5,这是最大长度+1。这样做是为了充分利用数据集(不跳过任何一个单词),但也避免批次之间的任何重叠,因为更多的重叠可能会导致过拟合增加。例如,如果将步长设置为最大长度,那么每一行中最后一个输入 Token ID 将成为下一行的第一个输入 Token ID。

在本篇的最后部分,将实现 Embedding 嵌入层,将 Token ID 转换成连续的向量表示,这些向量表示作为大语言模型的输入数据格式。

创建 Token Embeddings

为了准备输入文本进行大语言模型的训练,最关键的一步是把 Token ID 转换为 Embedding 嵌入向量,如下图。

大型模型语言入门:构建大模型之数据准备(下)

连续的向量表示形式,即嵌入,对于类似GPT的大语言模型至关重要,因为这些模型是通过反向传播算法在深度神经网络中训练出来的。如果你对神经网络如何利用反向传播进行训练还不太了解,建议阅读这部分知识(后续的篇章中也会介绍)。现在,让我们通过一个实践示例来展示 Token ID 如何被转换成嵌入向量。假设我们手头有四个输入Token,它们的ID分别是5、1、3和2:

input_ids = torch.tensor([5, 1, 3, 2])

为了方便理解和演示,设想一个极为简化的场景:词汇表中只有6个词(实际上 BPE [见之前的篇章] 的词汇量达到了50,257个词),我们计划生成的 Embeddings 嵌入向量维度为3(相较之下,在 GPT-3 中,嵌入向量的维度高达12,288)。

vocab_size = 6
output_dim = 3

使用 vocab_size 和 output_dim,我们可以在 PyTorch (开源深度学习框架) 中实例化一个Embedding 嵌入层,并设置随机种子为123以便复现:

torch.manual_seed(123) embedding_layer = torch.nn.Embedding(vocab_size, output_dim) print(embedding_layer.weight)

上述代码示例中的print语句打印了嵌入层底层的权重矩阵:

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)

可以看到嵌入层的权重矩阵包含小的随机值。这些值会在大语言模型训练过程中作为大语言模型优化的一部分进行优化。此外,还可以看到权重矩阵有六行三列。词汇表中每个可能的Token 都对应一行。每个嵌入维度对应一列。

在实例化嵌入层之后,可以将其应用于一个 Token ID 以获得嵌入向量:

print(embedding_layer(torch.tensor([3])))

执行结果如下:

tensor([[-0.4015,  0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)

如果将 Token ID 3 的 Embedding 嵌入向量与之前的嵌入矩阵进行比较,我们会看到它与第4行相同(Python从零开始索引,所以它对应于索引3的行)。换句话说,嵌入层本质上是一个查找操作,通过Token ID从嵌入层的权重矩阵中检索行。

嵌入层相较于矩阵乘法

对于熟悉 one-hot encoding 技术的读者来说,上文介绍的嵌入层方法实质上是一种更为高效的实现策略。这种策略在执行 one-hot encoding 后,会在一个全连接层中进行矩阵乘法运算,具体的实现示例可以在GitHub的补充代码中找到(可私信)。由于嵌入层实质上与 one-hot encoding 紧随其后的矩阵乘法操作等效,且执行更为高效,因此它可以被视为一个可以通过反向传播算法(backpropagation)进行优化的神经网络层。

之前 Embedding 篇章中,我们展示了如何把一个 Token ID 转化为一个三维的嵌入向量。接下来要把这种转化过程应用于早先定义的四个输入ID(即torch.tensor([5, 1, 3, 2])):

print(embedding_layer(input_ids))

执行后生成了一个4行3列的矩阵:

tensor([[-2.8400, -0.7849, -1.4096],
        [ 0.9178,  1.5810,  1.3010],
        [-0.4015,  0.9666, -1.1481],
        [ 1.2753, -0.2010, -0.1606]], grad_fn=<EmbeddingBackward0>)

输出矩阵中的每一行都是通过从嵌入权重矩阵中的查找操作获得的,如下图所示。

大型模型语言入门:构建大模型之数据准备(下)

这一节介绍了如何从Token ID创建 Embedding 嵌入向量。本篇的下一节也是最后一节将对这些嵌入向量进行小的修改,以编码文本中 Token 的位置信息。

编码单词位置(Encoding word positions)

在上一节中,我们将Token ID转换为连续向量表示,即所谓的token嵌入。原则上,这适合作为LLM的输入。然而,LLM的一个小缺点是它们的自注意力机制,这将在后续篇章中详细介绍,它没有对序列中 Token 的位置或顺序的概念。

先前介绍的嵌入层的工作方式:无论token ID在输入序列中的位置如何,相同的token ID总是映射到相同的向量表示,如下图。

大型模型语言入门:构建大模型之数据准备(下)

原则上,token ID的确定性、与位置无关的嵌入对于可重复性目的是有益的。然而,由于LLM的自注意力机制本身也是位置不可知的,因此向LLM中注入额外的位置信息是有帮助的。

为了实现这一点,有两大类位置感知嵌入:相对位置嵌入和绝对位置嵌入。

绝对位置嵌入直接与序列中的特定位置相关联。对于输入序列中的每个位置,都会添加一个独特的嵌入到 Token 的嵌入中,以传达其确切位置。例如,第一个 Token 将有一个特定的位置嵌入,第二个 Token 有另一个不同的嵌入,依此类推,如图。

大型模型语言入门:构建大模型之数据准备(下)

与关注token的绝对位置不同,相对位置嵌入强调的是token之间的相对位置或距离。这意味着模型学习的是“相隔多远”而不是“在哪个确切位置”的关系。这里的优势是模型可以更好地泛化到不同长度的序列,即使在训练期间没有见过这样的长度。

两种类型的位置嵌入旨在增强LLM理解token顺序和关系的能力,确保更准确和具有上下文感知的预测。它们之间的选择通常取决于特定的应用和正在处理的数据的性质。

OpenAI的GPT模型使用的是在训练过程中优化的绝对位置嵌入,而不是像原始Transformer模型中的位置编码那样固定或预定义的。这个优化过程是模型训练本身的一部分,将在后续实现。

之前为了说明原理使用了非常小的嵌入大小。现在考虑更现实和有用的嵌入大小,并将输入token编码为256维的向量表示。这比原始GPT-3模型使用的要小(在GPT-3中,嵌入大小是12,288维),对于实验来说较为合理。此外,假设token ID是由我们之前实现的BPE创建的,该分词器有一个词汇量大小为50,257:

output_dim = 256 vocab_size = 50257 token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

使用上面的 token_embedding_layer 从数据加载器中抽取数据,我们将每个批次中的每个Token嵌入到一个256维的向量中。如果我们有一个批次大小为8,每个批次有四个Token,结果将是一个8 x 4 x 256的张量。让我们首先实例化之前小节的数据加载器,带滑动窗口的数据采样:

max_length = 4
dataloader = create_dataloader(
    raw_text, batch_size=8, max_length=max_length, stride=5, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print('Token IDs:\n', inputs)
print('\nInputs shape:\n', inputs.shape)

执行结果如下:

Token IDs: tensor([[ 40, 367, 2885, 1464], [ 3619, 402, 271, 10899], [ 257, 7026, 15632, 438], [ 257, 922, 5891, 1576], [ 568, 340, 373, 645], [ 5975, 284, 502, 284], [ 326, 11, 287, 262], [ 286, 465, 13476, 11]]) Inputs shape: torch.Size([8, 4])

正如我们所看到的,Token ID 张量是8x4维的,意味着数据批次由8个文本样本组成,每个样本有4个Token。现在使用嵌入层将这些Token ID嵌入到256维向量中。

token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)

执行结果如下:

torch.Size([8, 4, 256])

根据8x4x256维张量输出,可以知道,现在每个Token ID都被嵌入为一个256维的向量。

在处理GPT模型时,采用的是一种叫做“绝对嵌入”的策略。这种方法的核心在于创建一个新的嵌入层,这个新层的尺寸要和之前的token_embedding_layer保持一致。简单来说,就是再添一个层,用于处理或转换信息,使其和我们用于标记转换的那一层具有相同的数据处理能力和维度:

block_size = max_length
pos_embedding_layer = torch.nn.Embedding(block_size, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(block_size))
print(pos_embeddings.shape)

在上述代码例子中,向位置嵌入层(pos_embeddings)提供的输入通常是一个预先设定的向量,通过torch.arange(block_size)创建,这个向量包含了一个从1开始的连续数字序列,一直到设定的最大输入长度。这里的“块大小”(block_size)是一个代表大语言模型(LLM)能处理的最大输入数据量的变量。通常根据输入文本的最大长度来设定这个值。在实际操作中,如果输入的文本长度超过了模型可以处理的“块大小”,就需要对文本进行切割。相反,如果文本短于这个大小,则通过添加一些占位符来补足长度,确保每次输入的大小都一致,这个过程会在下个篇章详细讨论。

执行结果如下:

torch.Size([4, 256])

从上面观察到,位置嵌入是由四个各自具有256个维度的向量构成的。现在可以把这些位置信息的向量加到原来的标记向量上去。在这个过程中,PyTorch会自动处理,它会把每个批次里的4个标记向量(每个都是4x256维的)和位置向量(也是4x256维的)进行相加。这样做的目的是为了让每个批次中的标记向量不仅包含了标记本身的信息,还融入了其在文本中位置的信息,这对于让模型理解文本的结构和意义是非常重要的。

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)

执行结果如下:

torch.Size([8, 4, 256])

当前所构建的 input_embeddings,正如下图概述的那样,是已经被转换成适合处理的格式的输入示例。这些经过转换的输入现在可以被主要的大语言模型(LLM)模块所处理,这部分内容将在后续篇章详细介绍。简单来说,通过这些步骤可以把原始的文本数据转换成了模型能够理解和分析的形式,为进一步的学习和模型训练打下了基础。这种转换是非常关键的,因为它让复杂的文本数据能够以一种对模型友好的方式被处理,从而充分发挥大语言模型在处理和理解自然语言方面的强大能力。

大型模型语言入门:构建大模型之数据准备(下)

最后回顾和总结数据准备系列文章:

  • 大型语言模型(LLM)需要将文本数据转换成数值向量,我们称之为嵌入,因为模型无法直接处理文字形式的数据。通过嵌入,可以把单词或图像等离散的信息转换为连续的向量空间,这一转换让信息能够适配于神经网络的操作。
  • 在处理文本数据的第一步中,我们会将文本拆分成较小的单元,这些单元可以是单词或字符,这一过程称为标记化。接着,这些小单元或标记会被转换成整数形式,我们称之为Token ID。
  • 在模型中还会引入一些特殊的标记,比如 <unk> 代表未知的单词,而 <sep> 用来标记不相关文本之间的界限。这些特殊标记有助于模型更好地理解文本和处理不同的上下文情景。
  • 针对LLM,如GPT-2和GPT-3,我们使用一种名为字节对编码(BPE)的技术来有效地处理未知单词,通过将它们拆解为更小的子单元或单个字符来实现。
  • 为了训练LLM,可以采用滑动窗口的方式在标记化的数据上生成输入和目标对。在PyTorch中,嵌入层的作用类似于一个查找表,它根据Token ID检索出对应的向量。这些向量为每个标记提供了连续的数值表示,这对深度学习模型的训练至关重要。
  • 虽然标记嵌入能够为每个标记提供一致的向量表示,但它们无法表达标记在序列中的位置信息。为了解决这一问题,我们使用了两种主要的位置嵌入方法:绝对位置嵌入和相对位置嵌入。OpenAI的GPT模型采用的是绝对位置嵌入,这种嵌入会被加到标记向量上,并在模型训练过程中进行优化,以帮助模型更好地理解文本的结构。

希望你能在接下来的文章中继续与我们同行,深入探索大型模型的构建过程。如果你对这些内容感兴趣,记得点赞、关注并收藏,这样你就不会错过我们的最新分享。感谢你的阅读,期待在后续章节中再次与你相遇!

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多