接下来要创建 LLM 嵌入,目的是生成训练 LLM 所需的 input-target 对。这些 input-target 对是什么样子的?正如我们已经了解到的,LLMs 通过预测文本中的下一个单词来进行预训练,如图 2.12 所示。
图 2.12 给定一个文本样本,提取作为 LLM 输入的输入块作为子样本,而 LLM 在训练期间的预测任务是预测紧跟在输入块之后的下一个单词。在训练过程中,我们遮蔽了目标(target)之后的所有单词。请注意,此图中显示的文本在 LLM 能够处理之前必须经过分词;然而,为了清晰起见,此图省略了分词步骤。
实现一个数据加载器,使用滑动窗口方法从训练数据集中获取图 2.1 2中的 input-target 对。首先,使用 BPE 分词器对整个《The Verdict》短篇故事进行分词:
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,即训练集中的总 token 数。
接下来,为了演示的目的,我们从数据集中移除了前 50 个tokens,在随后的步骤中,会看到这么做的效果:
enc_sample = enc_text[50:]
为下一个单词预测任务创建 input-target 对的一种最简单且最直观的方法是创建两个变量 x
和 y
,其中 x
包含输入的 tokens,而 y
包含 target 值,target 是 input 序列向右移动一位的结果:
执行上述代码,输出如下:
x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]
通过处理 input 以及对应的 target(这些 target 是 input 向右移动一个位置的结果),我们可以创建下一个单词预测任务(见图 2.12),具体如下:
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
箭头左边的(---->
)指的是 LLM 将接收的 input,而箭头右边的 token ID 代表 LLM 应预测的 target 的 token ID。重复前面的代码,但将 token IDs 转换为文本:
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))
下面的输出显示了 input 和 target 在文本格式中的样子:
and ----> established
and established ----> himself
and established himself ----> in
and established himself in ----> a
现在,我们已经创建了可用于 LLM 训练的 input-target 对。在将 token 转化为嵌入之前,还有一项任务:实现一个高效的数据加载器,它遍历输入数据集,并以 PyTorch 张量类型返回 input 和 target,可以将这些张量视为多维数组。所返回的张量,一个是 input 张量,包含了 LLM 所见文本;另一个是 target 张量,包含 LLM 需要预测值。如图 2.13 所示。虽然图中为了说明清晰,仅展示了字符串格式的tokens,但代码实现时将在 token IDs 上直接操作,因为 BPE 分词器的 encode
方法将分词和转换为 token IDs 作为一个步骤完成。
图 2.13 为了实现高效的数据加载器,将 input 收集到一个张量 x
中,其中每一行代表一个输入上下文。第二个张量 y
包含对应的 target 预测值(下一个单词),这些 target 是通过将输入向右移动一个位置创建的。
注意:为了实现高效的数据加载器,我们将使用 PyTorch 内置的 Dataset
和 DataLoader
类。有关安装 PyTorch 的更多信息和指导,请参见相关 PyTorch 的资料。
数据集类的代码如下所示。
import torch
from torch.utils.data import Dataset, DataLoader
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []
token_ids = tokenizer.encode(txt) //将文本转换为 token ID
//使用滑动窗口将文本分割为最大长度为 max_length 的重叠序列。
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
类,定义了如何从数据集中获取单个行,其中每一行包含分配给 input_chunk
张量的若干 token ID(基于 max_length
)。target_chunk
张量则包含相应的 target 值。建议继续阅读,以了解当我们结合 PyTorch 的 DataLoader
使用该数据集时,返回的数据是什么样子的——这将带来更多的直观理解和清晰度。
以下代码使用 GPTDatasetV1
通过 PyTorch 的 DataLoader
以批次形式加载输入数据。
按照如下代码,设置 batch_size = 1
、max_length=4
,调用 create_dataloader_v1
函数。
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) //将 dataloader 转换为 Python 迭代器
first_batch = next(data_iter) //而后通过内置函数 next() 调用下一项
print(first_batch)
执行上述代码,输出如下:
[tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
变量 first_batch
包含两个张量:第一个张量存储 input 的 token ID,第二个张量存储 target 的 token ID。由于 max_length
设置为 4,这两个张量各自包含了四个 token ID。注意,input 大小为 4 实际上很小,这里仅为了简化说明而选用。通常训练 LLM 时会使用至少 256 的 input 大小。
为了理解 stride=1
的含义,让我们从这个数据集中获取另一批次:
second_batch = next(data_iter)
print(second_batch)
第二个批次包含以下内容:
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
如果我们比较第一和第二批次,可以看到第二批次的 token ID 向右移动了一位(例如,第一批次输入中的第二个 ID 是 367,这是第二批次输入的第一个 ID)。stride
设置决定了跨批次 input 移动的位置数量,模拟了滑动窗口的方法,如图 2.14 所示。
图 2.14 当从 input 数据集中创建多个批次时,在文本上滑动一个输入窗口。如果步长(stride)设置为 1,在创建下一个批次时将输入窗口移动一个位置。如果将步长设置为等于输入窗口的大小,则可以避免批次之间的重叠。
到目前为止,我们从数据加载器中采样的 batch 大小为 1,这有助于说明目的。如果你有深度学习的经验,可能知道,较小的 batch 值在训练过程中需要较少的内存,但会导致模型更新时更加嘈杂。就像在常规深度学习中一样,batch 的大小是一个需要权衡的,并且是训练 LLMs 时要尝试的一个超参数。
在深度学习中使用PyTorch时,batch size 设置得比较小会对模型产生多方面的影响,具体如下:
训练时间:由于每次迭代处理的数据量较少,达到相同训练效果需要更多的迭代次数,通常会增加训练时间。
内存占用:较小的batch size意味着在内存中同时处理的数据量减少,这对于内存有限的设备较为友好,可以避免因内存不足而导致的程序崩溃,还可能允许使用更大的模型或更复杂的网络结构。
模型收敛:
- 优化算法:较小的batch size使得每次更新的梯度估计可能更加不稳定。因为梯度是基于小部分数据计算得出的,可能无法准确代表整个数据集的梯度方向,导致优化过程出现波动,影响模型收敛的速度和稳定性。
- 泛化能力:在一定程度上,较小的batch size可能具有类似正则化的效果。因为它使得模型在训练过程中看到的数据分布更加多样化,每次更新都基于不同的小批次数据,这有助于模型学习到更鲁棒的特征,从而可能提高模型的泛化能力。但如果batch size过小,可能会导致模型无法充分学习到数据中的模式,出现欠拟合现象,使模型在训练集和测试集上的表现都不佳。
计算效率:现代深度学习框架和硬件(如GPU)通常针对较大的batch size进行了优化,以充分利用并行计算能力。较小的batch size可能无法充分发挥这些硬件的性能,导致计算资源利用率低下,因为GPU在处理大量数据时能够更高效地并行计算。
接下来简要看看当 batch_size
的值大于 1 时,如何使用数据加载器进行采样:
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]])
注意,将步长增加到 4,从而充分利用数据集(不会跳过任何一个单词)。这样做可以避免批次之间(任何两个 batch 之间)的任何重叠,因为更多的重叠可能会导致过拟合的风险增加。
原文:Sebastian Raschka. Build a Large Language Model(From Scratch),此处为原文的中文翻译,为了阅读方便,有适当修改。