5 Pretraining on Unlabeled Data
-
本章节包含:
- 计算训练集和验证集损失:用于评估训练期间 LLM 生成文本的质量。
- 实现训练函数并对 LLM 进行预训练:通过编写训练函数,完成 LLM 的预训练过程。
- 保存和加载模型权重:以便在需要时继续训练 LLM。
- 加载 OpenAI 的预训练权重:将 OpenAI 的预训练权重加载到模型中,以加速训练或直接使用。
本章节的重点是实现训练功能并对LLM进行预训练。
LLM 开发的三个主要阶段包括:
- 编码 LLM:实现模型架构和核心组件。
- 预训练 LLM:在通用文本数据集上训练模型,使其学习语言的基本规律。
- 微调 LLM:在带标签的数据集上进一步训练,使模型适应特定任务。
本章重点在于 预训练 LLM,涵盖以下内容:
- 实现训练代码
- 评估生成文本的质量(通过计算训练集和验证集损失)
- 保存和加载模型权重
- 加载 OpenAI 的预训练权重,为后续微调提供基础
关键概念
- 权重(Weights) :模型的可训练参数,存储在 PyTorch 的线性层等模块中,可通过
.weight
属性或model.parameters()
方法访问。 - 模型评估:通过基本技术衡量生成文本的质量,是优化训练过程的必要步骤。
本章为后续章节的微调和优化奠定了基础。
5.1 Evaluating generative text models
-
本节主要内容如下图 1,2,3
- 简要回顾一下使用上一章的代码初始化 GPT 模型的内容
- 讨论 LLMs 的基本评估指标
- 将评估指标应用于一个训练和验证数据集
5.1.1 Using GPT to generate text
-
我们使用之前章节的代码初始化一个 GPT 模型
pythonimport torch from previous_chapters import GPTModel GPT_CONFIG_124M = { "vocab_size": 50257, # Vocabulary size "context_length": 256, # Shortened context length (orig: 1024) "emb_dim": 768, # Embedding dimension "n_heads": 12, # Number of attention heads "n_layers": 12, # Number of layers "drop_rate": 0.1, # Dropout rate "qkv_bias": False # Query-key-value bias } torch.manual_seed(123) model = GPTModel(GPT_CONFIG_124M) model.eval(); # Disable dropout during inference
需要注意以下几点
这段内容主要介绍了大语言模型(LLMs)训练及相关设置的一些要点:
- 随机失活(dropout):上文使用了 0.1 的随机失活,但如今训练 LLMs 时不使用随机失活较为常见
- 偏置向量设置 :现代 LLMs 在
nn.Linear
层处理查询、键和值矩阵时,不像早期 GPT 模型那样使用偏置向量,通过设置 "qkv_bias": false 实现。 - 上下文长度(context_length)调整: - 为降低训练模型的计算资源需求,将上下文长度设为 256 个词元,而原始 1.24 亿参数的 GPT - 2 模型使用 1024 个词元。 - 这样做是为方便在笔记本电脑上理解和运行代码示例。
-
接下来,我们将使用上一章中的
generate_text_simple
函数来生成文本,此外,我们定义了两个便捷函数text_to_token_ids
和token_ids_to_text
,用于在词元(token)表示和文本表示之间进行转换。上图展示了使用 GPT 模型的一个三步文本生成过程
- tokenizer 将输入的文本转换为 token ids
- 模型接受 token ids 并生成对应的对数几率(logits),他们是表示词汇表中每个词元概率分布的向量。
- 将对数几率转换为 token ids,tokenizer 将其解码为人类可读的文本。
代码如下
pythonimport tiktoken from previous_chapters import generate_text_simple def text_to_token_ids(text, tokenizer): encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'}) encoded_tensor = torch.tensor(encoded).unsqueeze(0) # add batch dimension return encoded_tensor def token_ids_to_text(token_ids, tokenizer): flat = token_ids.squeeze(0) # remove batch dimension return tokenizer.decode(flat.tolist()) start_context = "Every effort moves you" tokenizer = tiktoken.get_encoding("gpt2") # text to token_id input_ids = text_to_token_ids(start_context, tokenizer) print("Input ids:\n", input_ids) token_ids = generate_text_simple( model=model, idx=input_ids, max_new_tokens=10, context_size=GPT_CONFIG_124M["context_length"] ) print("Generated token ids:\n", token_ids) # token_id to text output_text = token_ids_to_text(token_ids, tokenizer) print("Gentrated text:\n", output_text) """输出如下""" Input ids: tensor([[6109, 3626, 6100, 345]]) Generated token ids: tensor([[ 6109, 3626, 6100, 345, 34245, 5139, 2492, 25405, 17434, 17853, 5308, 3398, 13174, 43071]]) Gentrated text: Every effort moves you rentingetic wasnم refres RexMeCHicular stren
基于输出可知模型因未经过训练尚未生成连贯文本,为定义文本"连贯"或"高质量"的标准,需实施数值方法评估生成内容 。
5.1.2 Calculating the text generation loss: cross-entropy and perplexity
-
本节借助计算文本生成损失,探究对训练中生成文本质量进行量化评估的技术。为让相关概念清晰且实用,我们会以实际示例逐步展开讲解。开篇先简要回顾第2章的数据加载方式以及第4章利用
generate_text_simple
函数生成文本的方法 。 -
假设我们有一个
inputs
张量,其中包含2个训练示例(行)的词元ID(token IDs)。 与inputs
相对应,targets
包含我们希望模型生成的所需词元ID。pythoninputs = torch.tensor([[16833, 3626, 6100], # ["every effort moves", [40, 1107, 588]]) # "I really like"] targets = torch.tensor([[3626, 6100, 345 ], # [" effort moves you", [1107, 588, 11311]]) # " really like chocolate"]
请注意,如我们在第2章实现数据加载器时所解释的,
targets
是inputs
偏移1个位置后的结果。pythonwith torch.no_grad(): logits = model(inputs) probas = torch.softmax(logits, dim=-1) # Probability of each token in vocabulary print(probas.shape) # Shape: (batch_size, num_tokens, vocab_size) """输出""" torch.Size([2, 3, 50257])
将
inputs
输入模型可得两个各由3个词元组成的输入示例的对数几率向量,每个词元是与词汇表大小对应的50,257维向量,应用softmax函数能把该对数几率张量转为同维度的概率得分张量。 第一个数字 2 对应于输入中的两个示例(行),也称为批量大小。第二个数字 3 对应于每个输入(行)中的标记数量。最后,最后一个数字对应于嵌入维数,该维数由词汇量决定。上图仅仅使用一个小的 7 个标记词汇来概述文本生成。对于左侧所示的 3 个输入标记中的每一个,我们计算一个向量,其中包含与词汇表中每个标记相对应的概率分数。每个向量中最高概率得分的索引位置代表最有可能的下一个令牌ID。这些与最高概率分数相关联的令牌 ID 被选择并映射回表示模型生成的文本的文本。
pythontoken_ids = torch.argmax(probas, dim=-1, keepdim=True) print("Token IDs:\n", token_ids) """输出""" Token IDs: tensor([[[16657], [ 339], [42826]], [[49906], [29669], [41751]]])
如前一章所讨论,我们可应用
argmax
函数将概率得分转换为预测tokenID,鉴于上述softmax函数为每个token生成一个50,257维向量,argmax
函数会返回该向量中概率得分最高的位置作为给定token的预测tokenID,又因我们有2个各含3个token的输入批次,故得到一个2×3的预测tokenID矩阵 。 -
如果我们对这些token进行解码,就会发现它们与我们希望模型预测的 token(即目标 token)有很大差异:
pythonprint(f"Targets batch 1: {token_ids_to_text(targets[0], tokenizer)}") print(f"Outputs batch 1: {token_ids_to_text(token_ids[0].flatten(), tokenizer)}") """输出""" Targets batch 1: effort moves you Outputs batch 1: Armed heNetflix
这是因为模型尚未经过训练,所以为了训练模型,我们需要知道它与正确预测(目标)之间的差距有多大。
当然,上图将所有内容呈现在一张图中展示了一个仅包含7个词元的精简词汇表的softmax概率,这意味着起始随机值会在1/7(约等于0.14)左右波动,而我们用于GPT - 2模型的词汇表有50,257个词元,大部分初始概率会在0.00002左右波动。
对于两个输入文本中的每一个,我们可以通过以下代码打印与目标标记对应的初始softmax概率分数:
pythontext_idx = 0 target_probas_1 = probas[text_idx, [0, 1, 2], targets[text_idx]] print("Text 1:", target_probas_1) text_idx = 1 target_probas_2 = probas[text_idx, [0, 1, 2], targets[text_idx]] print("Text 2:", target_probas_2) """输出""" Text 1: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05]) Text 2: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])
我们计算两个示例批次(target_probas_1 和 target_probas_2)的概率分数的损失
python# Compute logarithm of all token probabilities log_probas = torch.log(torch.cat((target_probas_1, target_probas_2))) print(log_probas) """输出""" tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])
python# Calculate the average probability for each token avg_log_probas = torch.mean(log_probas) print(avg_log_probas) """输出""" tensor(-10.7940)
目标是通过优化模型权重,使这个平均对数概率尽可能大。由于对数的性质,其最大可能值 位置为0,而我们目前离0还很远。 在深度学习中,标准做法不是最大化平均对数概率,而是最小化平均对数概率的负值。就我们的情况而言,不是要将 -10.7722 最大化使其趋近于0,而是在深度学习中,我们会将 10.7722 最小化,使其趋近于0。-10.7722 的负值,即 10.7722,在深度学习中也被称为交叉熵损失。
pythonneg_avg_log_probas = avg_log_probas * -1 print(neg_avg_log_probas) """输出""" tensor(10.7940)
PyTorch已经实现了一个
cross_entropy
函数,该函数可以执行前面提到的步骤。在应用
cross_entropy
函数之前,让我们先检查一下对数几率(logits)和目标(targets)的形状。python# Logits have shape (batch_size, num_tokens, vocab_size) print("Logits shape:", logits.shape) # Targets have shape (batch_size, num_tokens) print("Targets shape:", targets.shape) """输出""" Logits shape: torch.Size([2, 3, 50257]) Targets shape: torch.Size([2, 3])
logits 张量具有三个维度:批量大小、标记数量和词汇量大小。目标张量有两个维度:批量大小和标记数量。
对于PyTorch中的
cross_entropy
函数,我们希望通过在批次维度上合并这些张量来将它们展平:pythonlogits_flat = logits.flatten(0, 1) targets_flat = targets.flatten() print("Flattened logits:", logits_flat.shape) print("Flattened targets:", targets_flat.shape) """输出""" Flattened logits: torch.Size([6, 50257]) Flattened targets: torch.Size([6])
请注意,目标值是token ID,它们同时也代表了我们希望在对数几率张量中最大化的索引位置,而PyTorch中的
cross_entropy
函数会自动在内部对对数几率中那些需要最大化的 token索引应用softmax函数并进行对数概率计算。pythonloss = torch.nn.functional.cross_entropy(logits_flat, targets_flat) print(loss) """输出""" tensor(10.7940)
与交叉熵损失相关的一个概念是大语言模型(LLM)的困惑度。困惑度就是交叉熵损失的指数值。
pythonperplexity = torch.exp(loss) print(perplexity) """输出""" tensor(48725.8203)
困惑度(Perplexity)衡量的是模型预测的概率分布与数据集中单词的实际分布的匹配程度。与损失(loss)类似,困惑度越低表明模型的预测越接近实际分布。
困惑度可以通过公式 perplexity = torch.exp (loss) 来计算,将其应用于之前计算出的损失时,返回的结果是 tensor (47678.8633)。
困惑度通常被认为比原始损失值更具可解释性,因为它表示在每一步中模型不确定的有效词汇表大小。在给定的示例中,这意味着模型不确定要从词汇表中的 47678 个单词或词元(tokens)中生成哪一个作为下一个词元。
5.1.3 Calculating the training and validation set losses
-
我们使用一个相对较小的数据集来训练大语言模型(LLM)(实际上,仅仅是一篇短篇小说)。
原因如下:
- 你无需合适的图形处理器(GPU),就能在笔记本电脑上几分钟内运行代码示例。
- 训练完成得相对较快(只需几分钟而非数周),这对于教学目的很有好处。
- 我们使用的文本来自公有领域,因此可以将其包含在这个 GitHub 代码库中,而不会侵犯任何使用权,也不会使代码库规模过大。
例如,Llama 2 7B 在 2 万亿个词元上进行训练,需要在 A100 GPU 上花费 184,320 GPU 小时。
- 在撰写本文时,亚马逊云服务(AWS)上一台配备 8 个 A100 的云服务器每小时成本约为 30 美元。
- 因此,通过粗略计算,训练这个大语言模型的成本为 184,320÷8×30 美元 = 690,000 美元。
-
接下来,我们将数据集分为训练集和验证集,并使用第 2 章中的数据加载器来准备用于 LLM 训练的批次。
pythonimport os import urllib.request file_path = "the-verdict.txt" url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt" if not os.path.exists(file_path): with urllib.request.urlopen(url) as response: text_data = response.read().decode('utf-8') with open(file_path, "w", encoding="utf-8") as file: file.write(text_data) else: with open(file_path, "r", encoding="utf-8") as file: text_data = file.read()
通过打印前100个词和后100个词来快速检查文本是否加载正常。
python# First 100 characters print(text_data[:99]) # Last 100 characters print(text_data[-99:]) """输出""" I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no it for me! The Strouds stand alone, and happen once--but there's no exterminating our kind of art."
pythontotal_characters = len(text_data) total_tokens = len(tokenizer.encode(text_data)) print("Characters:", total_characters) print("Tokens:", total_tokens) """输出""" Characters: 20479 Tokens: 5145
这段文本仅有 5145 个token
-
接下来,为训练LLM,我们将数据集划分为训练集和验证集,用第2章的数据加载器准备批次数据,出于可视化目的下图假设
max_length = 6
,但训练加载器中max_length
设为LLM支持的上下文长度,且下图仅展示输入词元,因训练LLM是预测文本下一个单词,所以目标与输入类似只是偏移一个位置 。设置 train_ratio ,使用 90% 数据训练,剩下的 10% 作为训练期间模型评估的验证数据
pythonfrom previous_chapters import create_dataloader_v1 # Train/validation ratio train_ratio = 0.90 split_idx = int(train_ratio * len(text_data)) train_data = text_data[:split_idx] val_data = text_data[split_idx:] torch.manual_seed(123) train_loader = create_dataloader_v1( train_data, batch_size=2, max_length=GPT_CONFIG_124M["context_length"], stride=GPT_CONFIG_124M["context_length"], drop_last=True, shuffle=True, num_workers=0 ) val_loader = create_dataloader_v1( val_data, batch_size=2, max_length=GPT_CONFIG_124M["context_length"], stride=GPT_CONFIG_124M["context_length"], drop_last=False, shuffle=False, num_workers=0 )
python# Sanity check if total_tokens * (train_ratio) < GPT_CONFIG_124M["context_length"]: print("Not enough tokens for the training loader. " "Try to lower the `GPT_CONFIG_124M['context_length']` or " "increase the `training_ratio`") if total_tokens * (1-train_ratio) < GPT_CONFIG_124M["context_length"]: print("Not enough tokens for the validation loader. " "Try to lower the `GPT_CONFIG_124M['context_length']` or " "decrease the `training_ratio`")
检查下数据是否加载正确(可选)
pythonprint("Train loader:") for x, y in train_loader: print(x.shape, y.shape) print("\nValidation loader:") for x, y in val_loader: print(x.shape, y.shape) """输出""" Train loader: torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) Validation loader: torch.Size([2, 256]) torch.Size([2, 256])
确认 token 大小在预期的范围内(可选)
pythontrain_tokens = 0 for input_batch, target_batch in train_loader: train_tokens += input_batch.numel() val_tokens = 0 for input_batch, target_batch in val_loader: val_tokens += input_batch.numel() print("Training tokens:", train_tokens) print("Validation tokens:", val_tokens) print("All tokens:", train_tokens + val_tokens) """输出""" Training tokens: 4608 Validation tokens: 512 All tokens: 5120
-
接下来,我们实现两个实函数
- 用于计算给定批次的交叉熵损失。
- 用于计算数据加载器中用户指定数量批次的损失。
pythondef calc_loss_batch(input_batch, target_batch, model, device): input_batch, target_batch = input_batch.to(device), target_batch.to(device) logits = model(input_batch) loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten()) return loss def calc_loss_loader(data_loader, model, device, num_batches=None): total_loss = 0. if len(data_loader) == 0: return float("nan") elif num_batches is None: num_batches = len(data_loader) else: # Reduce the number of batches to match the total number of batches in the data loader # if num_batches exceeds the number of batches in the data loader num_batches = min(num_batches, len(data_loader)) for i, (input_batch, target_batch) in enumerate(data_loader): if i < num_batches: loss = calc_loss_batch(input_batch, target_batch, model, device) total_loss += loss.item() else: break return total_loss / num_batches
默认情况下, calc_loss_batch 函数会迭代给定数据加载器中的所有批次,将损失累积到total_loss 变量中,然后计算总批次数的损失并求平均值。或者,我们可以通过 num_batches 指定较小的批次数,以加快模型训练期间的评估速度。
若机器配备支持 CUDA 的 GPU,大语言模型(LLM)无需更改代码即可在该 GPU 上训练,且通过 "device" 设置,能保证数据加载到与大语言模型(LLM)相同的设备上。
pythondevice = torch.device("cuda" if torch.cuda.is_available() else "cpu") # Note: # Uncommenting the following lines will allow the code to run on Apple Silicon chips, if applicable, # which is approximately 2x faster than on an Apple CPU (as measured on an M3 MacBook Air). # However, the resulting loss values may be slightly different. #if torch.cuda.is_available(): # device = torch.device("cuda") #elif torch.backends.mps.is_available(): # device = torch.device("mps") #else: # device = torch.device("cpu") # # print(f"Using {device} device.") model.to(device) # no assignment model = model.to(device) necessary for nn.Module classes torch.manual_seed(123) # For reproducibility due to the shuffling in the data loader with torch.no_grad(): # Disable gradient tracking for efficiency because we are not training, yet train_loss = calc_loss_loader(train_loader, model, device) val_loss = calc_loss_loader(val_loader, model, device) print("Training loss:", train_loss) print("Validation loss:", val_loss) """输出""" device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # Note: # Uncommenting the following lines will allow the code to run on Apple Silicon chips, if applicable, # which is approximately 2x faster than on an Apple CPU (as measured on an M3 MacBook Air). # However, the resulting loss values may be slightly different. #if torch.cuda.is_available(): # device = torch.device("cuda") #elif torch.backends.mps.is_available(): # device = torch.device("mps") #else: # device = torch.device("cpu") # # print(f"Using {device} device.") model.to(device) # no assignment model = model.to(device) necessary for nn.Module classes torch.manual_seed(123) # For reproducibility due to the shuffling in the data loader with torch.no_grad(): # Disable gradient tracking for efficiency because we are not training, yet train_loss = calc_loss_loader(train_loader, model, device) val_loss = calc_loss_loader(val_loader, model, device) print("Training loss:", train_loss) print("Validation loss:", val_loss)
由于模型尚未训练,损失值相对较高。为了进行比较,如果模型学习生成出现在训练和验证集中的下一个标记,则损失接近 0。
-
现在我们有了一种方法来衡量生成文本的质量,在下一节中,我们训练 LLM 来减少这种损失,使其能够更好地生成文本,如下图所示。
下一节重点关注大语言模型(LLM)的预训练。在模型训练完成后,我们将实现不同的文本生成策略,并保存和加载预训练的模型权重。