系列文章目录
【阅读记录-章节1】Build a Large Language Model (From Scratch)
【阅读记录-章节2】Build a Large Language Model (From Scratch)
【阅读记录-章节3】Build a Large Language Model (From Scratch)
【阅读记录-章节4】Build a Large Language Model (From Scratch)
【阅读记录-章节5】Build a Large Language Model (From Scratch)
【阅读记录-章节6】Build a Large Language Model (From Scratch)
【阅读记录-章节7】Build a Large Language Model (From Scratch)
文章目录
- 系列文章目录
- 7. Fine-tuning to follow instructions
-
- 7.1 Introduction to instruction fine-tuning
- 7.2 Preparing a dataset for supervised instruction fine-tuning
- 7.3 Organizing data into training batches
- 7.4 Creating data loaders for an instruction dataset
- 7.5 Loading a pretrained LLM
- 7.6 Fine-tuning the LLM on instruction data
- 7.7 Extracting and saving responses
- 7.8 Evaluating the fine-tuned LLM
- 7.9 Conclusions
7. Fine-tuning to follow instructions
之前,我们实现了LLM(大语言模型)的架构,进行了预训练,并将预训练的权重从外部资源导入到我们的模型中。接着,我们专注于对LLM进行针对特定分类任务的微调:区分垃圾短信和非垃圾短信。现在,我们将实现微调LLM以遵循人类指令的过程,如图7.1所示。

指令微调是开发用于聊天机器人应用程序、个人助理以及其他对话任务的LLM的主要技术之一。图7.1展示了微调LLM的两种主要方式:用于分类的微调(步骤8)和指令微调(步骤9)。我们在第6章中已经实现了步骤8。现在,我们将使用一个指令数据集对LLM进行微调。
7.1 Introduction to instruction fine-tuning
我们现在知道,LLM的预训练涉及一种训练过程,在这个过程中模型通过一次生成一个单词来学习。由此生成的预训练LLM具有文本补全的能力,也就是说,它可以根据输入的片段完成句子或撰写文本段落。然而,预训练的LLM通常在面对特定指令时表现欠佳,比如“修正这段文本的语法”或“将这段文本转换为被动语态”。稍后,我们将研究一个具体示例,在该示例中我们加载预训练LLM作为指令微调(也称为监督指令微调)的基础。
在这里,我们专注于提升LLM遵循这些指令并生成期望响应的能力,如图7.2所示。

准备数据集是指令微调的关键部分。接下来,我们将完成指令微调过程中三个阶段的所有步骤,从数据集准备开始,如图7.3所示。

7.2 Preparing a dataset for supervised instruction fine-tuning
让我们下载并格式化用于对预训练LLM进行指令微调的指令数据集。该数据集由1100个指令-响应对组成,类似于图7.2中的示例。这一数据集是专门为本书创建的,但有兴趣的读者可以在附录B中找到其他公开可用的指令数据集。
以下代码实现并执行了一个函数,用于下载这个数据集。数据集是一个相对较小的文件(仅204 KB),以JSON格式存储。JSON(JavaScript Object Notation)类似于Python字典的结构,提供了一种简单的、既可读又便于机器处理的数据交换格式。
下载数据集
import json
import os
import urllib
def download_and_load_file(file_path, url):
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()
with open(file_path, "r") as file:
data = json.load(file)
return data
file_path = "instruction-data.json"
url = (
"https://raw.githubusercontent.com/rasbt/LLMs-from-scratch"
"/main/ch07/01_main-chapter-code/instruction-data.json"
)
data = download_and_load_file(file_path, url)
print("Number of entries:", len(data))
执行上述代码后,输出结果为:
Number of entries: 1100
从JSON文件加载的data列表包含指令数据集的1100条记录。让我们打印一条记录以查看其结构:
print("Example entry:\n", data[50])
示例记录的内容为:
Example entry:
{'instruction': 'Identify the correct spelling of the following word.',
'input': 'Ocassion',
'output': "The correct spelling is 'Occasion.'"}
从示例中可以看到,这些记录是Python字典对象,每个对象包含三个字段:instruction(指令)、input(输入)和output(输出)。我们再看另一个示例:
print("Another example entry:\n", data[999])
输出结果表明,有时input字段可能为空:
Another example entry:
{'instruction': "What is an antonym of 'complicated'?",
'input': '',
'output': "An antonym of 'complicated' is 'simple'."}
指令微调的核心是使用明确提供的输入-输出对(如从JSON文件提取的记录)来训练模型。可以通过多种方法将这些记录格式化为适合LLM的输入格式。

图7.4展示了两种常用的格式化方法(提示样式),这些方法被用于训练知名的LLM,例如Alpaca和Phi-3。
- Alpaca 是最早公开其指令微调过程的LLM之一。
- Phi-3 是由微软开发的,用来展示提示样式的多样性。
本章其余部分采用Alpaca提示样式,因为它是最流行的样式之一,帮助定义了微调的初始方法。
实现提示格式化函数
以下是将数据列表中的记录转换为Alpaca样式输入格式的format_input函数:
def format_input(entry):
instruction_text = (
f"Below is an instruction that describes a task. "
f"Write a response that appropriately completes the request."
f"\n\n### Instruction:\n{
entry['instruction']}"
)
input_text = (
f"\n\n### Input:\n{
entry['input']}" if entry["input"] else ""
)
return instruction_text + input_text
该format_input函数接收一个字典记录作为输入,并构造一个格式化字符串。让我们测试它对数据集中第50条记录的效果:
model_input = format_input(data[50])
desired_response = f"\n\n### Response:\n{
data[50]['output']}"
print(model_input + desired_response)
格式化后的输入如下:
Below is an instruction that describes a task. Write a response that
appropriately completes the request.
### Instruction:
Identify the correct spelling of the following word.
### Input:
Ocassion
### Response:
The correct spelling is 'Occasion.'
请注意,format_input函数会跳过可选的### Input:部分,如果input字段为空。我们可以通过将format_input函数应用到之前查看的data[999]记录上来测试这一点:
model_input = format_input(data[999])
desired_response = f"\n\n### Response:\n{
data[999]['output']}"
print(model_input + desired_response)
输出结果显示,对于input字段为空的记录,格式化后的输入中不会包含### Input:部分:
Below is an instruction that describes a task. Write a response that
appropriately completes the request.
### Instruction:
What is an antonym of 'complicated'?
### Response:
An antonym of 'complicated' is 'simple'.
在进入下一节并设置PyTorch数据加载器之前,我们将数据集划分为训练集、验证集和测试集,这与上一章垃圾邮件分类数据集的划分方法类似。以下代码展示了如何计算各部分的比例:
# 使用85%的数据作为训练集
train_portion = int(len(data) * 0.85)
# 使用10%的数据作为测试集
test_portion = int(len(data) * 0.1)
# 剩余5%的数据作为验证集
val_portion = len(data) - train_portion - test_portion
train_data = data[:train_portion]
test_data = data[train_portion:train_portion + test_portion]
val_data = data[train_portion + test_portion:]
print("Training set length:", len(train_data))
print("Validation set length:", len(val_data))
print("Test set length:", len(test_data))
这段代码将数据集按以下比例划分:
- 85% 用于训练
- 10% 用于测试
- 5% 用于验证
执行代码后,划分后的数据集大小如下:
Training set length: 935
Validation set length: 55
Test set length: 110
成功下载并划分数据集后,我们已经清楚了数据集的提示格式化方法。接下来,我们将专注于开发构建训练批次的方法,为LLM的指令微调过程做好准备。
7.3 Organizing data into training batches
随着我们进入指令微调过程的实现阶段,下一步(如图7.5所示)是有效地构建训练批次。这需要定义一种方法,以确保模型在微调过程中接收经过格式化的训练数据。

在上一章中,训练批次是由PyTorch的DataLoader类自动创建的,它使用默认的collate函数将样本列表合并为批次。collate函数的作用是将一组独立的数据样本合并为一个单一的批次,以便模型在训练时可以高效处理。
然而,对于指令微调来说,批次处理过程稍显复杂,因此我们需要创建一个自定义的collate函数,稍后将其插入到DataLoader中。我们实现这个自定义的collate函数是为了满足指令微调数据集的特定需求和格式化要求。
我们将分几个步骤解决批次处理问题,包括编码自定义的collate函数,如图7.6所示。

首先,为了实现步骤2.1和2.2,我们需要编写一个InstructionDataset类,该类会应用format_input函数并对数据集中所有输入进行预分词(pretokenization),这与第6章中的SpamDataset类似。这一两步过程的细节(如图7.7所示)将在InstructionDataset类的__init__构造方法中实现。

1. 实现指令数据集类
指令数据集类 InstructionDataset 的任务是将数据集的每条指令-响应对格式化并预处理为可用于模型训练的编码格式。
import torch
from torch.utils.data import Dataset
class InstructionDataset(Dataset):
def __init__(self, data, tokenizer):
self.data = data
self.encoded_texts = []
# 预分词每条数据
for entry in data:
instruction_plus_input = format_input(entry)
response_text = f"\n\n### Response:\n{
entry['output']}"
full_text = instruction_plus_input + response_text
self.encoded_texts.append(tokenizer.encode(full_text))
def __getitem__(self, index):
return self.encoded_texts[index]
def __len__(self):
return len(self.data)
类似于分类微调中使用的方法,为了加速训练,我们通过将多个训练样本收集到一个批次中。这需要将所有输入填充到相同的长度。与分类微调一样,我们使用 <|endoftext|> 作为填充标记。
与其将 <|endoftext|> 直接附加到文本输入中,我们可以将与 <|endoftext|> 对应的 token ID 直接附加到预分词的输入中。我们可以使用分词器的 .encode 方法对 <|endoftext|> 进行编码,以确认使用哪个 token ID:
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={
"<|endoftext|>"}))
输出的 token ID 是 50256。

进入流程的步骤 2.3(参见图 7.6),我们采用了一种更复杂的方法,开发了一个可以传递给数据加载器的自定义 collate 函数。该自定义 collate 函数将每个批次中的训练样本填充到相同的长度,同时允许不同批次具有不同的长度(如图 7.8 所示)。这种方法通过仅将序列扩展到每个批次中最长的序列长度,而非整个数据集的最大长度,从而最大限度地减少了不必要的填充。

自定义填充过程的实现
为了处理指令微调中的批次数据,我们实现了一个自定义的 collate 函数,用于填充序列并生成目标 token IDs。
自定义 collate 函数的初版
以下是初版的 collate 函数,用于将输入填充到批次中最长的序列长度:
def custom_collate_draft_1(batch, pad_token_id=50256, device="cpu"):
batch_max_length = max(len(item) + 1 for item in batch) # 找到批次中最长的序列长度
inputs_lst = []
for item in batch:
new_item = item.copy()
new_item += [pad_token_id] # 添加一个填充值
padded = new_item + [pad_token_id] * (batch_max_length - len(new_item)) # 填充到最长长度
inputs = torch.tensor

最低0.47元/天 解锁文章
1501

被折叠的 条评论
为什么被折叠?



