本章内容包括:
- 大型语言模型(LLM)的指令微调过程
- 准备用于监督指令微调的数据集
- 在训练批次中组织指令数据
- 加载预训练的LLM并将其微调以遵循人类指令
- 提取LLM生成的指令响应以进行评估
- 评估经过指令微调的LLM
在此之前,我们已经实现了LLM的架构,进行了预训练,并将预训练的权重从外部来源导入到我们的模型中。然后,我们专注于将LLM微调用于特定的分类任务:区分垃圾短信和非垃圾短信。现在,我们将实施将LLM微调为遵循人类指令的过程,如图7.1所示。指令微调是开发聊天机器人、个人助手以及其他对话任务的主要技术之一。
图7.1展示了微调大型语言模型(LLM)的两种主要方式:用于分类任务的微调(步骤8)和将LLM微调为遵循指令(步骤9)。我们在第6章实现了步骤8。现在我们将使用指令数据集对LLM进行微调。
指令微调简介
我们已经了解到,预训练大型语言模型(LLM)涉及一个逐词生成的训练过程。经过预训练的LLM具备文本补全的能力,意味着它能够在给定一个文本片段作为输入时,完成句子或撰写段落。然而,预训练的LLM往往在处理具体指令时表现较差,比如“修正此文本中的语法”或“将此文本转换为被动语态”。接下来,我们将通过一个具体的例子,加载预训练的LLM作为指令微调的基础,指令微调也称为监督指令微调。
在这里,我们的重点是提高LLM遵循此类指令并生成期望响应的能力,如图7.2所示。准备数据集是指令微调的关键环节。接下来我们将完成指令微调过程中的所有步骤,从数据集准备开始,具体步骤如图7.3所示。
准备用于监督指令微调的数据集
现在我们来下载并格式化用于对预训练大型语言模型(LLM)进行指令微调的数据集。该数据集包含了1100对类似于图7.2中的指令-响应对。这个数据集是专为本书创建的,但感兴趣的读者可以在附录B中找到其他公开可用的指令数据集。
以下代码实现并执行了一个函数,用于下载该数据集。这个文件相对较小(仅204KB),是JSON格式。JSON(JavaScript Object Notation)类似于Python字典的结构,提供了一个简洁的数据交换结构,既便于人类阅读,也方便机器处理。
代码清单7.1 下载数据集
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: #1
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文件加载的数据列表包含了指令数据集的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.'"}
正如我们所见,示例记录是包含'instruction'(指令)、'input'(输入)和'output'(输出)的Python字典对象。我们再来看另一个示例:
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展示了两个不同的示例格式,通常称为提示风格(prompt styles),这些格式常用于像Alpaca和Phi-3这样的著名LLM的训练中。
Alpaca 是最早公开详细说明其指令微调过程的大型语言模型之一。Microsoft 开发的 Phi-3 被引入来展示提示风格的多样性。本章的其余部分将使用 Alpaca 的提示风格,因为它是最流行的风格之一,主要是因为它帮助定义了微调的初始方法。
练习 7.1 改变提示风格
在使用 Alpaca 的提示风格对模型进行微调之后,尝试使用图 7.4 中展示的 Phi-3 提示风格,观察它是否会影响模型的响应质量。
我们来定义一个 format_input 函数,用于将数据列表中的条目转换为 Alpaca 风格的输入格式。
代码清单 7.2 实现提示格式化函数
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 函数将字典条目作为输入,并构建一个格式化的字符串。让我们用数据集条目 data[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.'
请注意,如果 'input' 字段为空,format_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 数据加载器之前,让我们将数据集划分为训练集、验证集和测试集,类似于我们在上一章处理垃圾短信分类数据集时所做的。以下代码展示了如何计算各部分的比例。
代码清单 7.3 划分数据集
train_portion = int(len(data) * 0.85) #1
test_portion = int(len(data) * 0.1) #2
val_portion = len(data) - train_portion - test_portion #3
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))
#1 使用85%的数据进行训练
#2 使用10%的数据进行测试
#3 使用剩余的5%进行验证
这种划分方式得到的数据集大小如下:
Training set length: 935
Validation set length: 55
Test set length: 110
在成功下载并划分数据集,并清楚理解数据集提示格式之后,我们现在已经准备好实施指令微调过程的核心部分。接下来,我们将专注于开发用于构建训练批次的方法,以微调LLM。
将数据组织成训练批次
随着我们进入指令微调过程的实现阶段,下一步是构建有效的训练批次,如图7.5所示。这一步涉及定义一种方法,以确保在微调过程中模型能够接收到格式化的训练数据。
在前一章中,训练批次是由 PyTorch 的 DataLoader 类自动创建的,该类使用默认的合并(collate)函数将样本列表组合成批次。合并函数的作用是将单个数据样本列表合并为一个批次,以便模型在训练过程中高效处理。
然而,指令微调的批次处理过程要稍微复杂一些,需要我们创建自己的自定义合并函数,之后将其集成到 DataLoader 中。我们将实现这个自定义的合并函数,以处理指令微调数据集的特定需求和格式。
接下来我们分几步处理批次创建过程,包括编写自定义合并函数,如图7.6所示。首先,为了实现步骤2.1和2.2,我们编写一个 InstructionDataset 类,该类会应用 format_input 函数并对数据集中的所有输入进行预标记(pretokenization),类似于第6章中的 SpamDataset。这个两步过程的细节如图7.7所示,在 InstructionDataset 的 __init__ 构造方法中实现。
代码清单 7.4 实现指令数据集类
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: #1
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)
#1 预标记文本
与分类微调中使用的方法类似,我们希望通过将多个训练示例收集到一个批次中来加速训练,这需要将所有输入填充到相似的长度。与分类微调相同,我们使用 <|endoftext|> 标记作为填充标记。
与其在文本输入后附加 <|endoftext|> 标记,不如直接将对应于 <|endoftext|> 的标记ID附加到预标记的输入中。我们可以使用 tokenizer 的 .encode 方法对 <|endoftext|> 标记进行编码,来确认我们应该使用哪个标记ID:
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))
结果的标记ID是 50256。
接下来进行步骤 2.3(参见图 7.6),我们采用更复杂的方法,开发一个可以传递给数据加载器的自定义合并函数。这个自定义合并函数会将每个批次中的训练示例填充到相同长度,同时允许不同批次的长度不同,如图 7.8 所示。此方法通过仅将序列扩展到每个批次中最长的序列长度,而不是整个数据集的最大长度,从而最小化不必要的填充。
我们可以通过自定义的合并函数实现填充过程:
def custom_collate_draft_1(
batch,
pad_token_id=50256,
device="cpu"
):
batch_max_length = max(len(item)+1 for item in batch) #1
inputs_lst = []
for item in batch: #2
new_item = item.copy()
new_item += [pad_token_id]
padded = (
new_item + [pad_token_id] *
(batch_max_length - len(new_item))
)
inputs = torch.tensor(padded[:-1]) #3
inputs_lst.append(inputs)
inputs_tensor = torch.stack(inputs_lst).to(device) #4
return inputs_tensor
#1 找到批次中最长的序列
#2 填充并准备输入
#3 移除之前添加的额外填充值
#4 将输入列表转换为张量并传输到目标设备
我们实现的 custom_collate_draft_1 设计用于集成到 PyTorch 的 DataLoader 中,但它也可以作为一个独立工具使用。在这里,我们独立使用它来测试并验证其操作是否如预期那样工作。让我们尝试将三组不同的输入组成一个批次,每个示例都被填充到相同的长度:
inputs_1 = [0, 1, 2, 3, 4]
inputs_2 = [5, 6]
inputs_3 = [7, 8, 9]
batch = (
inputs_1,
inputs_2,
inputs_3
)
print(custom_collate_draft_1(batch))
生成的批次如下所示:
tensor([[ 0, 1, 2, 3, 4],
[ 5, 6, 50256, 50256, 50256],
[ 7, 8, 9, 50256, 50256]])
该输出显示,所有输入都被填充到与最长输入列表 inputs_1 相同的长度,其中包含五个标记ID。
我们刚刚实现了第一个自定义合并函数,用于从输入列表中创建批次。然而,正如我们之前学习到的,我们还需要为目标标记ID创建批次,这些标记ID与输入ID批次对应。这些目标ID,如图7.9所示,非常重要,因为它们表示我们希望模型生成的内容,并且在训练过程中需要它们来计算损失以更新权重。也就是说,我们需要修改自定义合并函数,使其在返回输入标记ID的同时,也返回目标标记ID。
类似于我们预训练大型语言模型(LLM)的过程,目标标记ID与输入标记ID相匹配,但向右移动一个位置。正如图7.10所示,这种设置使得LLM能够学习如何预测序列中的下一个标记。
以下是更新后的合并函数,用于从输入标记ID生成目标标记ID:
def custom_collate_draft_2(
batch,
pad_token_id=50256,
device="cpu"
):
batch_max_length = max(len(item)+1 for item in batch)
inputs_lst, targets_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(padded[:-1]) #1
targets = torch.tensor(padded[1:]) #2
inputs_lst.append(inputs)
targets_lst.append(targets)
inputs_tensor = torch.stack(inputs_lst).to(device)
targets_tensor = torch.stack(targets_lst).to(device)
return inputs_tensor, targets_tensor
示例批次的输入与目标:
inputs, targets = custom_collate_draft_2(batch)
print(inputs)
print(targets)
输出结果为:
tensor([[ 0, 1, 2, 3, 4], #1
[ 5, 6, 50256, 50256, 50256],
[ 7, 8, 9, 50256, 50256]])
tensor([[ 1, 2, 3, 4, 50256], #2
[ 6, 50256, 50256, 50256, 50256],
[ 8, 9, 50256, 50256, 50256]])
#1 第一个张量代表输入。
#2 第二个张量代表目标。
下一步中,我们将为所有填充标记分配一个 -100 占位符值,如图7.11所示。此特殊值使得这些填充标记不会对训练损失计算产生影响,确保只有有意义的数据影响模型的学习。我们将在实现此修改后更详细地讨论这一过程。(在分类微调时,我们不需要担心这个问题,因为当时我们仅根据最后一个输出标记训练模型。)
然而,请注意,我们在目标列表中保留了一个结束文本标记,ID 为 50256,如图 7.12 所示。保留该标记允许 LLM 学习在响应指令时何时生成结束文本标记,我们将其作为生成响应完成的指示符。
在以下代码中,我们修改了自定义的合并函数,以便在目标列表中将ID为50256的标记替换为-100。此外,我们引入了一个 allowed_max_length 参数,用于可选地限制样本的长度。这一调整在处理超过GPT-2模型支持的1024个标记上下文长度的数据集时会很有用。
代码清单 7.5 实现自定义批次合并函数
def custom_collate_fn(
batch,
pad_token_id=50256,
ignore_index=-100,
allowed_max_length=None,
device="cpu"
):
batch_max_length = max(len(item)+1 for item in batch)
inputs_lst, targets_lst = [], []
for item in batch:
new_item = item.copy()
new_item += [pad_token_id]
padded = ( #1
new_item + [pad_token_id] * #1
(batch_max_length - len(new_item)) #1
)
inputs = torch.tensor(padded[:-1]) #2
targets = torch.tensor(padded[1:]) #3
mask = targets == pad_token_id #4
indices = torch.nonzero(mask).squeeze() #4
if indices.numel() > 1: #4
targets[indices[1:]] = ignore_index #4
if allowed_max_length is not None:
inputs = inputs[:allowed_max_length] #5
targets = targets[:allowed_max_length] #5
inputs_lst.append(inputs)
targets_lst.append(targets)
inputs_tensor = torch.stack(inputs_lst).to(device)
targets_tensor = torch.stack(targets_lst).to(device)
return inputs_tensor, targets_tensor
#1 填充序列到最大长度
#2 截断输入的最后一个标记
#3 将目标向右偏移1位
#4 将除第一个填充标记外的其他填充标记替换为 ignore_index
#5 可选地截断到最大序列长度
我们再次尝试将合并函数应用到之前创建的示例批次,以确保它按预期工作:
inputs, targets = custom_collate_fn(batch)
print(inputs)
print(targets)
结果如下,其中第一个张量表示输入,第二个张量表示目标:
tensor([[ 0, 1, 2, 3, 4],
[ 5, 6, 50256, 50256, 50256],
[ 7, 8, 9, 50256, 50256]])
tensor([[ 1, 2, 3, 4, 50256],
[ 6, 50256, -100, -100, -100],
[ 8, 9, 50256, -100, -100]])
修改后的合并函数按预期工作,通过在目标列表中插入 -100 标记ID来调整目标列表。那么,这种调整背后的逻辑是什么呢?让我们探讨这一修改的核心目的。
出于演示目的,考虑以下简单的示例,其中每个输出logit对应于模型词汇表中的一个潜在标记。我们可以在模型预测序列标记时计算交叉熵损失(在第5章介绍过),类似于我们在预训练模型和分类微调时所做的过程:
logits_1 = torch.tensor(
[[-1.0, 1.0], #1
[-0.5, 1.5]] #2
)
targets_1 = torch.tensor([0, 1]) # 要生成的正确标记索引
loss_1 = torch.nn.functional.cross_entropy(logits_1, targets_1)
print(loss_1)
#1 第一个标记的预测
#2 第二个标记的预测
计算得出的损失值是 1.1269:
tensor(1.1269)
我们发现,添加额外的标记ID会影响损失计算:
logits_2 = torch.tensor(
[[-1.0, 1.0],
[-0.5, 1.5],
[-0.5, 1.5]] #1
)
targets_2 = torch.tensor([0, 1, 1])
loss_2 = torch.nn.functional.cross_entropy(logits_2, targets_2)
print(loss_2)
#1 新增的第三个标记预测
添加第三个标记后,损失值为 0.7936。
那么如果将第三个目标标记ID替换为 -100 会发生什么呢?
targets_3 = torch.tensor([0, 1, -100])
loss_3 = torch.nn.functional.cross_entropy(logits_2, targets_3)
print(loss_3)
print("loss_1 == loss_3:", loss_1 == loss_3)
结果输出为:
tensor(1.1269)
loss_1 == loss_3: tensor(True)
这表明,忽略了 targets_3 向量中对应于 -100 的第三个条目,最终损失值与先前计算的两个示例的损失值相同。原因是 PyTorch 的交叉熵损失函数默认设置为 cross_entropy(..., ignore_index=-100),这意味着它会忽略被标记为 -100 的目标。我们利用这个 ignore_index 来忽略填充标记,这样就不会让这些填充标记对训练损失的计算产生影响。
此外,常见的做法是屏蔽掉对应于指令的目标标记ID,如图7.13所示。通过屏蔽LLM中与指令对应的目标标记ID,交叉熵损失只针对生成的响应目标ID计算。这使得模型专注于生成准确的响应,而不是记住指令内容,从而有助于减少过拟合。
截至本文撰写时,研究人员对在指令微调过程中屏蔽指令是否普遍有益存在分歧。例如,Shi 等人在 2024 年的论文《Instruction Tuning With Loss Over Instructions》(arxiv.org/abs/2405.14…)中表明,不屏蔽指令有助于提高LLM 的性能(更多详情见附录B)。在本书中,我们不会应用屏蔽操作,读者可以将其作为一个可选的练习。
练习 7.2:指令和输入屏蔽
在完成本章并使用
InstructionDataset微调模型之后,将指令和输入标记替换为-100掩码,使用图7.13中展示的指令屏蔽方法。然后评估此操作是否对模型性能产生积极影响。
为指令数据集创建数据加载器
我们已经完成了多个阶段,实现了 InstructionDataset 类和针对指令数据集的 custom_collate_fn 函数。如图 7.14 所示,现在我们可以简单地将 InstructionDataset 对象和 custom_collate_fn 函数集成到 PyTorch 的数据加载器中,从而收获我们的劳动成果。这些加载器将自动对批次进行随机打乱和组织,支持 LLM 的指令微调过程。
在我们实现数据加载器创建步骤之前,需要简要讨论 custom_collate_fn 的设备设置。custom_collate_fn 包含代码,将输入和目标张量(例如 torch.stack(inputs_lst).to(device))移动到指定设备上,该设备可以是 "cpu"、"cuda"(用于 NVIDIA GPU),或者选择 "mps"(用于搭载 Apple Silicon 芯片的 Mac)。
注意: 由于 PyTorch 对 Apple Silicon 的支持仍处于试验阶段,使用 "mps" 设备可能会导致与本章内容的数值差异。
之前,我们是在主训练循环中将数据移动到目标设备上(例如,当 device="cuda" 时,将数据移动到 GPU 内存)。现在将设备传输作为合并函数的一部分,可以将该过程作为后台任务执行,避免在模型训练时阻塞 GPU。
以下代码初始化了设备变量:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# if torch.backends.mps.is_available(): #1
# device = torch.device("mps")
print("Device:", device)
#1 如果使用 Apple Silicon GPU,可以取消注释这两行代码。
这段代码将根据你的机器输出 "Device: cpu" 或 "Device: cuda"。
接下来,为了在将 custom_collate_fn 插入 PyTorch 的 DataLoader 类时重用选择的设备设置,我们使用 Python functools 标准库中的 partial 函数来创建一个预填充了设备参数的新函数版本。此外,我们将 allowed_max_length 设置为 1024,以将数据截断到 GPT-2 模型支持的最大上下文长度:
from functools import partial
customized_collate_fn = partial(
custom_collate_fn,
device=device,
allowed_max_length=1024
)
接下来,我们像以前一样设置数据加载器,但这次我们将使用自定义合并函数来处理批次生成过程。
代码清单 7.6 初始化数据加载器
from torch.utils.data import DataLoader
num_workers = 0 #1
batch_size = 8
torch.manual_seed(123)
train_dataset = InstructionDataset(train_data, tokenizer)
train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
collate_fn=customized_collate_fn,
shuffle=True,
drop_last=True,
num_workers=num_workers
)
val_dataset = InstructionDataset(val_data, tokenizer)
val_loader = DataLoader(
val_dataset,
batch_size=batch_size,
collate_fn=customized_collate_fn,
shuffle=False,
drop_last=False,
num_workers=num_workers
)
test_dataset = InstructionDataset(test_data, tokenizer)
test_loader = DataLoader(
test_dataset,
batch_size=batch_size,
collate_fn=customized_collate_fn,
shuffle=False,
drop_last=False,
num_workers=num_workers
)
#1 如果你的操作系统支持并行 Python 进程,可以尝试增加这个数值。
让我们检查由训练加载器生成的输入和目标批次的维度:
print("Train loader:")
for inputs, targets in train_loader:
print(inputs.shape, targets.shape)
输出结果如下(为节省空间部分截取):
Train loader:
torch.Size([8, 61]) torch.Size([8, 61])
torch.Size([8, 76]) torch.Size([8, 76])
torch.Size([8, 73]) torch.Size([8, 73])
...
torch.Size([8, 74]) torch.Size([8, 74])
torch.Size([8, 69]) torch.Size([8, 69])
该输出显示,第一个输入和目标批次的维度为 8 × 61,其中 8 代表批次大小,61 是该批次中每个训练示例的标记数量。第二个批次有不同的标记数量,例如 76。得益于自定义合并函数,数据加载器能够创建长度不同的批次。在下一节中,我们将加载一个预训练的大型语言模型(LLM),并使用这个数据加载器对其进行微调。
加载预训练的大型语言模型(LLM)
我们已经花了很多时间准备指令微调的数据集,这是监督微调过程中的一个关键步骤。其他许多方面与预训练相同,使我们能够重用前几章的代码。
在开始指令微调之前,首先需要加载一个预训练的 GPT 模型,这是我们之前进行过的过程,如图 7.15 所示。然而,这次我们将加载一个具有 3.55 亿参数的中型模型,而不是之前使用的 1.24 亿参数的最小模型。选择这个中型模型的原因在于,1.24 亿参数的模型容量太有限,无法通过指令微调获得令人满意的结果。特别是,小型模型缺乏学习和保留复杂模式及细微行为的必要能力,而这些能力对于高质量的指令执行任务至关重要。
我们花了很多时间准备指令微调的数据集,这是监督微调过程中的关键步骤。其他许多方面与预训练相同,使我们可以重用之前章节中的大部分代码。
在开始指令微调之前,我们首先需要加载一个要微调的预训练 GPT 模型(见图 7.15)。这一过程我们之前已经做过了,不过这次我们将加载具有 3.55 亿参数的中型模型,而不是之前使用的 1.24 亿参数的小型模型。选择中型模型的原因是,1.24 亿参数的模型容量太小,无法通过指令微调获得令人满意的结果。小型模型缺乏学习和保留复杂模式和细微行为的必要能力,而这些对于高质量的指令执行任务至关重要。
加载预训练模型的代码与我们之前在第5.5节预训练数据和第6.4节微调分类任务时使用的代码相同,唯一不同的是,我们现在指定的是 "gpt2-medium (355M)" 而不是 "gpt2-small (124M)"。
注意: 执行此代码将启动中型 GPT 模型的下载,所需存储空间约为 1.42 GB,这大约是小型模型所需空间的三倍。
代码清单 7.7 加载预训练模型
from gpt_download import download_and_load_gpt2
from chapter04 import GPTModel
from chapter05 import load_weights_into_gpt
BASE_CONFIG = {
"vocab_size": 50257, # 词汇表大小
"context_length": 1024, # 上下文长度
"drop_rate": 0.0, # dropout 率
"qkv_bias": True # Query-key-value 偏置
}
model_configs = {
"gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
"gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
"gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
"gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}
CHOOSE_MODEL = "gpt2-medium (355M)"
BASE_CONFIG.update(model_configs[CHOOSE_MODEL])
model_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")
settings, params = download_and_load_gpt2(
model_size=model_size,
models_dir="gpt2"
)
model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval();
执行代码后,将下载以下文件:
checkpoint: 100%|██████████| 77.0/77.0 [00:00<00:00, 156kiB/s]
encoder.json: 100%|██████████| 1.04M/1.04M [00:02<00:00, 467kiB/s]
hparams.json: 100%|██████████| 91.0/91.0 [00:00<00:00, 198kiB/s]
model.ckpt.data-00000-of-00001: 100%|██████████| 1.42G/1.42G
[05:50<00:00, 4.05MiB/s]
model.ckpt.index: 100%|██████████| 10.4k/10.4k [00:00<00:00, 18.1MiB/s]
model.ckpt.meta: 100%|██████████| 927k/927k [00:02<00:00, 454kiB/s]
vocab.bpe: 100%|██████████| 456k/456k [00:01<00:00, 283kiB/s]
接下来,让我们评估预训练模型在某个验证任务上的表现,通过比较其输出和期望的响应。这将为我们提供模型在微调前执行指令任务的基准表现,帮助我们更好地理解微调带来的效果。我们将使用验证集中的第一个示例进行评估:
torch.manual_seed(123)
input_text = format_input(val_data[0])
print(input_text)
指令内容如下:
Below is an instruction that describes a task. Write a response that
appropriately completes the request.
### Instruction:
Convert the active sentence to passive: 'The chef cooks the meal every day.'
接下来,我们使用与第5章中预训练模型相同的 generate 函数生成模型的响应:
from chapter05 import generate, text_to_token_ids, token_ids_to_text
token_ids = generate(
model=model,
idx=text_to_token_ids(input_text, tokenizer),
max_new_tokens=35,
context_size=BASE_CONFIG["context_length"],
eos_id=50256,
)
generated_text = token_ids_to_text(token_ids, tokenizer)
generate 函数返回输入和输出文本的组合。这在预训练模型设计为文本补全模型时非常方便,因为输入和输出会连接在一起,形成连贯的文本。然而,在评估模型的任务表现时,我们通常只关注模型生成的响应部分。
为了提取模型的响应文本,我们需要从 generated_text 中去掉输入指令的长度:
response_text = generated_text[len(input_text):].strip()
print(response_text)
该代码会从生成的文本开头去除输入文本,保留模型生成的响应。然后使用 strip() 函数移除前后的空白字符。输出如下:
### Response:
The chef cooks the meal every day.
### Instruction:
Convert the active sentence to passive: 'The chef cooks the
此输出显示,预训练模型尚未能够正确执行给定的指令。虽然它生成了一个响应部分,但它只是重复了原始输入句子和部分指令,并没有将主动句转换为被动语态。因此,我们接下来将实现微调过程,以提高模型对这些请求的理解和适当响应能力。
在指令数据上微调大型语言模型(LLM)
现在是时候对 LLM 进行指令微调了(见图 7.16)。我们将使用上一节加载的预训练模型,并使用本章前面准备好的指令数据集对其进一步训练。我们在本章开头已经完成了指令数据集处理的所有繁重工作。对于微调过程本身,我们可以重用第 5 章中实现的损失计算和训练函数:
from chapter05 import (
calc_loss_loader,
train_model_simple
)
在开始训练之前,让我们先计算训练集和验证集的初始损失:
model.to(device)
torch.manual_seed(123)
with torch.no_grad():
train_loss = calc_loss_loader(
train_loader, model, device, num_batches=5
)
val_loss = calc_loss_loader(
val_loader, model, device, num_batches=5
)
print("Training loss:", train_loss)
print("Validation loss:", val_loss)
初始损失值如下;正如之前一样,我们的目标是最小化损失:
Training loss: 3.825908660888672
Validation loss: 3.7619335651397705
处理硬件限制
使用和训练像 GPT-2 中型模型(3.55 亿参数)这样的大型模型比使用较小的 GPT-2 模型(1.24 亿参数)需要更多的计算资源。如果由于硬件限制遇到问题,可以通过将
CHOOSE_MODEL = "gpt2-medium (355M)"更改为CHOOSE_MODEL = "gpt2-small (124M)"来切换到较小的模型(参见第 7.5 节)。或者,为了加快模型训练,建议使用 GPU。本书代码库中的以下附加部分列出了使用云 GPU 的几种选项:mng.bz/EOEq。下表提供了在各种设备(包括 CPU 和 GPU)上训练每个模型的参考运行时间,适用于 GPT-2。使用兼容的 GPU 运行代码无需修改代码,并且可以显著加快训练速度。在本章中,我使用了 GPT-2 中型模型并在 A100 GPU 上进行训练。
模型名称 设备 两个 epoch 的运行时间 gpt2-medium (355M) CPU(M3 MacBook Air) 15.78 分钟 gpt2-medium (355M) GPU(NVIDIA L4) 1.83 分钟 gpt2-medium (355M) GPU(NVIDIA A100) 0.86 分钟 gpt2-small (124M) CPU(M3 MacBook Air) 5.74 分钟 gpt2-small (124M) GPU(NVIDIA L4) 0.69 分钟 gpt2-small (124M) GPU(NVIDIA A100) 0.39 分钟
现在我们已经准备好了模型和数据加载器,可以开始训练模型了。代码清单 7.8 设置了训练过程,包括初始化优化器、设置 epoch 数量以及定义评估频率。我们还将根据第 7.5 节中验证集的第一个指令(val_data[0])定义一个起始上下文,用于在训练期间评估生成的 LLM 响应。
代码清单 7.8 微调预训练的 LLM
import time
start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(
model.parameters(), lr=0.00005, weight_decay=0.1
)
num_epochs = 2
train_losses, val_losses, tokens_seen = train_model_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs=num_epochs, eval_freq=5, eval_iter=5,
start_context=format_input(val_data[0]), tokenizer=tokenizer
)
end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")
训练的输出显示了两轮(epoch)的训练进展,损失值的持续下降表明模型在逐步提高执行指令和生成适当响应的能力:
Ep 1 (Step 000000): Train loss 2.637, Val loss 2.626
Ep 1 (Step 000005): Train loss 1.174, Val loss 1.103
Ep 1 (Step 000010): Train loss 0.872, Val loss 0.944
...
Ep 1 (Step 000115): Train loss 0.520, Val loss 0.665
Below is an instruction that describes a task. Write a response that
appropriately completes the request. ### Instruction: Convert the
active sentence to passive: 'The chef cooks the meal every day.'
### Response: The meal is prepared every day by the chef.<|endoftext|>
...
Ep 2 (Step 000230): Train loss 0.300, Val loss 0.657
Below is an instruction that describes a task. Write a response
that appropriately completes the request. ### Instruction:
Convert the active sentence to passive: 'The chef cooks the meal
every day.' ### Response: The meal is cooked every day by the
chef.<|endoftext|>
训练结果显示,模型在两轮训练中有效地学习了如何执行指令,训练和验证集的损失值持续下降。这个结果表明模型逐渐提高了理解和执行所提供指令的能力。(由于模型在两轮训练中表现良好,延长到第三轮或更多可能并不必要,甚至可能导致过拟合。)
此外,在每轮结束时生成的响应让我们可以检查模型在验证集中任务执行的进展。例如,模型成功地将主动句 "The chef cooks the meal every day." 转换为被动句:"The meal is cooked every day by the chef."。
接下来,我们将使用 plot_losses 函数绘制训练和验证损失曲线,以深入了解模型的学习过程:
from chapter05 import plot_losses
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)
从图 7.17 中的损失曲线可以看到,模型在训练集和验证集上的表现显著改善。在初期阶段损失值的快速下降表明,模型快速学习到了有意义的模式和表征。随着训练进入第二轮,损失值继续下降,但速度变慢,这表明模型正在微调已学习的表征,并逐渐收敛到稳定的解决方案。
虽然图 7.17 中的损失曲线表明模型的训练效果良好,但最关键的方面是它在响应质量和正确性方面的表现。因此,接下来我们将提取模型的响应,并将其存储为便于我们评估和量化响应质量的格式。
练习 7.3 在原始 Alpaca 数据集上进行微调
Alpaca 数据集由斯坦福大学的研究人员创建,是最早且最受欢迎的公开共享指令数据集之一,包含 52,002 条记录。作为我们在此使用的 instruction-data.json 文件的替代方案,您可以尝试在该数据集上对 LLM 进行微调。数据集可在 此处获取。
该数据集包含 52,002 条记录,约为我们在此使用的数据集的 50 倍,而且大多数记录更长。因此,我强烈建议使用 GPU 来进行训练,这将加快微调过程。如果遇到内存不足的错误,您可以考虑将
batch_size从 8 减少到 4、2,甚至 1。将allowed_max_length从 1,024 降低到 512 或 256 也可以帮助解决内存问题。
提取并保存响应
在对指令数据集的训练部分进行微调后,现在我们可以评估模型在保留的测试集上的表现了。首先,我们提取模型为测试数据集中的每个输入生成的响应,并将它们收集起来进行人工分析。然后,我们对 LLM 进行评估,以量化响应的质量,如图 7.18 所示。
为了完成响应指令的步骤,我们使用 generate 函数。然后,我们将模型生成的响应与测试集中的预期答案并排打印,进行比较。以下是前三个测试集条目的结果展示:
torch.manual_seed(123)
for entry in test_data[:3]: #1
input_text = format_input(entry)
token_ids = generate( #2
model=model,
idx=text_to_token_ids(input_text, tokenizer).to(device),
max_new_tokens=256,
context_size=BASE_CONFIG["context_length"],
eos_id=50256
)
generated_text = token_ids_to_text(token_ids, tokenizer)
response_text = (
generated_text[len(input_text):]
.replace("### Response:", "")
.strip()
)
print(input_text)
print(f"\nCorrect response:\n>> {entry['output']}")
print(f"\nModel response:\n>> {response_text.strip()}")
print("-------------------------------------")
#1 迭代测试集中的前三个样本
#2 使用在第 7.5 节中导入的 generate 函数
正如之前提到的,generate 函数返回输入和输出文本的组合,所以我们通过对生成的文本进行切片并使用 .replace() 方法来提取模型的响应。接下来将显示指令、测试集给定的正确响应以及模型生成的响应。
正如我们从测试集的指令、预期响应和模型生成的响应中可以看到的那样,模型表现相对不错。对于第一条和最后一条指令,模型的答案明显是正确的,而第二条答案接近正确但不完全准确。模型回答了“积云”(cumulus cloud),而正确答案是“积雨云”(cumulonimbus)。不过值得注意的是,积云可以发展成积雨云,而积雨云能够产生雷暴。
最重要的是,模型的评估不像补全微调那样简单。对于补全任务,我们只需计算正确的垃圾邮件/非垃圾邮件标签的百分比来得到分类的准确性。然而,指令微调后的 LLM(如聊天机器人)在实践中通过多种方法进行评估:
- 短答案和选择题基准测试,如《测量大规模多任务语言理解》(MMLU,arxiv.org/abs/2009.03…),测试模型的通用知识。
- 人类偏好对比其他 LLM,如 LMSYS 聊天机器人竞技场(arena.lmsys.org)。
- 自动化对话基准测试,使用另一个 LLM(如 GPT-4)来评估模型响应的质量,如 AlpacaEval(tatsu-lab.github.io/alpaca_eval…)。
在实践中,考虑所有三种评估方法可能是有益的:选择题问答、人类评估和自动化衡量对话性能的指标。然而,既然我们主要关注的是对话表现而非选择题回答能力,那么人类评估和自动化指标可能更为相关。
对话性能
LLMs 的对话性能是指它们通过理解上下文、细微差别和意图来进行类人沟通的能力。它涵盖了提供相关且连贯的回应、保持一致性以及适应不同话题和交流风格等技能。
人类评估虽然能提供有价值的见解,但在处理大量响应时可能非常耗时和繁琐。例如,阅读并为所有 1,100 条响应分配评分将需要大量精力。因此,考虑到任务的规模,我们将实施类似于自动化对话基准的方法,使用另一个 LLM 自动评估生成的响应。此方法可以高效地评估响应质量,减少大量的人力投入,同时仍能获得有意义的性能指标。
我们将采用类似于 AlpacaEval 的方法,使用另一个 LLM 来评估微调模型的响应。然而,与依赖公开可用的基准数据集不同,我们使用自己的自定义测试集。这种自定义使得我们能够在特定的用例场景中对模型的性能进行更有针对性的评估,而这些用例已在我们的指令数据集中体现。
为了准备评估过程中的响应,我们将生成的模型响应附加到 test_set 字典中,并将更新后的数据保存为 instruction-data-with-response.json 文件,以便记录。此外,保存该文件后,我们可以在以后需要时轻松加载并分析响应。
以下代码清单使用了与之前相同的 generate 方法,但现在我们遍历整个测试集。同时,我们不再打印模型响应,而是将其添加到 test_set 字典中。
代码清单 7.9 生成测试集响应
from tqdm import tqdm
for i, entry in tqdm(enumerate(test_data), total=len(test_data)):
input_text = format_input(entry)
token_ids = generate(
model=model,
idx=text_to_token_ids(input_text, tokenizer).to(device),
max_new_tokens=256,
context_size=BASE_CONFIG["context_length"],
eos_id=50256
)
generated_text = token_ids_to_text(token_ids, tokenizer)
response_text = (
generated_text[len(input_text):]
.replace("### Response:", "")
.strip()
)
test_data[i]["model_response"] = response_text
with open("instruction-data-with-response.json", "w") as file:
json.dump(test_data, file, indent=4) #1
#1 使用缩进格式化输出
处理整个数据集在 A100 GPU 上大约需要 1 分钟,在 M3 MacBook Air 上则需要 6 分钟:
100%|██████████| 110/110 [01:05<00:00, 1.68it/s]
让我们验证响应是否正确添加到 test_set 字典中,通过检查其中一个条目:
print(test_data[0])
输出显示 model_response 已正确添加:
{
'instruction': 'Rewrite the sentence using a simile.',
'input': 'The car is very fast.',
'output': 'The car is as fast as lightning.',
'model_response': 'The car is as fast as a bullet.'
}
最后,我们将模型保存为 gpt2-medium355M-sft.pth 文件,以便在未来项目中重用它:
import re
file_name = f"{re.sub(r'[ ()]', '', CHOOSE_MODEL) }-sft.pth" #1
torch.save(model.state_dict(), file_name)
print(f"Model saved as {file_name}")
#1 移除文件名中的空格和括号
保存后的模型可以通过以下代码加载:
model.load_state_dict(torch.load("gpt2-medium355M-sft.pth"))
评估微调后的大型语言模型(LLM)
之前,我们通过查看模型对测试集三个示例的响应来判断指令微调模型的性能。虽然这种方法可以粗略了解模型的表现,但在处理大量响应时,这种方法并不适用。因此,我们将实现一种方法,使用另一个更大的 LLM 自动化评估微调后模型的响应,如图 7.19 所示。
为了自动化评估测试集的响应,我们利用了 Meta AI 开发的已经经过指令微调的 80 亿参数 Llama 3 模型。这个模型可以通过开源的 Ollama 应用程序(ollama.com)在本地运行。
注意: Ollama 是一个在笔记本电脑上运行 LLM 的高效应用程序,它作为开源库 llama.cpp(github.com/ggerganov/l…)的封装器。
使用纯 C/C++ 实现 LLM,以最大化效率。然而,Ollama 仅用于使用 LLM 进行文本生成(推理),不支持 LLM 的训练或微调。
使用通过 Web API 的更大规模 LLM
80 亿参数的 Llama 3 模型是一个功能强大的 LLM,可以在本地运行。然而,它的能力不及 OpenAI 提供的专有大型 LLM,如 GPT-4。如果读者有兴趣探索如何通过 OpenAI API 使用 GPT-4 来评估生成的模型响应,可以在本书附带的补充材料中找到一个可选的代码笔记本,网址为 mng.bz/BgEv。
要执行以下代码,请访问 ollama.com 安装 Ollama,并根据您的操作系统按照提供的说明进行安装:
- 对于 macOS 和 Windows 用户——打开下载的 Ollama 应用程序。如果提示安装命令行使用,请选择“是”。
- 对于 Linux 用户——使用 Ollama 网站上提供的安装命令。
在实现模型评估代码之前,首先下载 Llama 3 模型,并通过命令行终端验证 Ollama 是否正常工作。要从命令行使用 Ollama,您需要启动 Ollama 应用程序或在单独的终端中运行 ollama serve,如图 7.20 所示。
当 Ollama 应用程序或 ollama serve 在不同的终端中运行时,在命令行(不是 Python 会话中)执行以下命令,试用 80 亿参数的 Llama 3 模型:
ollama run llama3
第一次执行此命令时,该模型将自动下载,模型占用 4.7 GB 的存储空间。输出结果如下所示:
pulling manifest
pulling 6a0746a1ec1a... 100% |████████████████| 4.7 GB
pulling 4fa551d4f938... 100% |████████████████| 12 KB
pulling 8ab4849b038c... 100% |████████████████| 254 B
pulling 577073ffcc6c... 100% |████████████████| 110 B
pulling 3f8eb4da87fa... 100% |████████████████| 485 B
verifying sha256 digest
writing manifest
removing any unused layers
success
可选的 Ollama 模型
ollama run llama3命令中的llama3指的是经过指令微调的 80 亿参数 Llama 3 模型。使用 Ollama 运行该模型大约需要 16 GB 的内存。如果您的机器内存不足,可以尝试使用较小的模型,例如 38 亿参数的phi3模型,运行命令ollama run phi3,该模型仅需大约 8 GB 的内存。对于性能更强的计算机,您还可以通过将
llama3替换为llama3:70b来使用更大的 700 亿参数的 Llama 3 模型。不过,这个模型需要显著更多的计算资源。
一旦模型下载完成,会显示一个命令行界面,允许我们与模型进行交互。例如,尝试向模型提问:
>>> What do llamas eat?
Llamas are ruminant animals, which means they have a four-chambered
stomach and eat plants that are high in fiber. In the wild,
llamas typically feed on:
1. Grasses: They love to graze on various types of grasses, including tall
grasses, wheat, oats, and barley.
请注意,由于 Ollama 在撰写本文时不是确定性的,您看到的响应可能会有所不同。
您可以使用 /bye 输入结束这次 ollama run llama3 会话。不过,请确保在本章其余部分中继续运行 ollama serve 命令或 Ollama 应用程序。
以下代码用于验证 Ollama 会话是否正常运行,然后我们将使用 Ollama 来评估测试集响应:
import psutil
def check_if_running(process_name):
running = False
for proc in psutil.process_iter(["name"]):
if process_name in proc.info["name"]:
running = True
break
return running
ollama_running = check_if_running("ollama")
if not ollama_running:
raise RuntimeError(
"Ollama not running. Launch ollama before proceeding."
)
print("Ollama running:", check_if_running("ollama"))
确保执行上述代码后输出 Ollama running: True。如果显示为 False,请检查 ollama serve 命令或 Ollama 应用程序是否正在运行。
在新的 Python 会话中运行代码
如果您已经关闭了 Python 会话,或者更喜欢在不同的 Python 会话中执行剩余代码,可以使用以下代码,它会加载之前创建的包含指令和响应的数据文件,并重新定义之前使用的
format_input函数(tqdm进度条工具将在稍后使用):import json from tqdm import tqdm file_path = "instruction-data-with-response.json" with open(file_path, "r") as file: test_data = json.load(file) 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此代码将加载之前生成的
instruction-data-with-response.json文件,并重新定义用于格式化输入的format_input函数。
与模型交互的另一种方式是通过 REST API 使用 Python,而不是使用 ollama run 命令。以下代码展示了如何使用 API 查询模型。
代码清单 7.10 查询本地 Ollama 模型
import urllib.request
def query_model(
prompt,
model="llama3",
url="http://localhost:11434/api/chat"
):
data = { #1
"model": model,
"messages": [
{"role": "user", "content": prompt}
],
"options": { #2
"seed": 123,
"temperature": 0,
"num_ctx": 2048
}
}
payload = json.dumps(data).encode("utf-8") #3
request = urllib.request.Request( #4
url, #4
data=payload, #4
method="POST" #4
)
request.add_header("Content-Type", "application/json") #4
response_data = ""
with urllib.request.urlopen(request) as response: #5
while True:
line = response.readline().decode("utf-8")
if not line:
break
response_json = json.loads(line)
response_data += response_json["message"]["content"]
return response_data
- #1 创建字典格式的数据负载
- #2 设置确定性的响应
- #3 将字典转换为 JSON 格式字符串并编码为字节
- #4 创建请求对象,设置方法为 POST,并添加必要的头信息
- #5 发送请求并捕获响应
在运行后续代码单元之前,确保 Ollama 仍在运行。之前的代码应该打印 "Ollama running: True" 来确认模型已激活并准备好接收请求。
以下是如何使用刚刚实现的 query_model 函数的示例:
model = "llama3"
result = query_model("What do Llamas eat?", model)
print(result)
得到的响应如下:
Llamas are ruminant animals, which means they have a four-chambered
stomach that allows them to digest plant-based foods. Their diet
typically consists of:
1. Grasses: Llamas love to graze on grasses, including tall grasses,
short grasses, and even weeds.
...
使用之前定义的 query_model 函数,我们可以评估经过微调的模型生成的响应,并提示 Llama 3 模型基于给定的测试集响应为微调模型的响应打分(从 0 到 100)。
首先,我们将这种方法应用于之前测试集中的前三个示例:
for entry in test_data[:3]:
prompt = (
f"Given the input `{format_input(entry)}` "
f"and correct output `{entry['output']}`, "
f"score the model response `{entry['model_response']}`"
f" on a scale from 0 to 100, where 100 is the best score. "
)
print("\nDataset response:")
print(">>", entry['output'])
print("\nModel response:")
print(">>", entry["model_response"])
print("\nScore:")
print(">>", query_model(prompt))
print("\n-------------------------")
此代码将输出类似以下内容的结果(由于 Ollama 目前不是完全确定性的,生成的文本可能有所不同):
生成的响应表明,Llama 3 模型能够提供合理的评估,并在模型的答案不完全正确时分配部分分数。例如,在评估“积云”(cumulus cloud)答案时,模型认可了部分正确性。
先前的提示不仅返回了分数,还给出了非常详细的评估。我们可以修改提示,仅生成 0 到 100 之间的整数分数,其中 100 代表最佳分数。此修改允许我们计算模型的平均分数,作为其性能的简洁且定量的评估。以下 generate_model_scores 函数使用了修改后的提示,要求模型“仅以整数形式回应”。
代码清单 7.11 评估指令微调 LLM
def generate_model_scores(json_data, json_key, model="llama3"):
scores = []
for entry in tqdm(json_data, desc="Scoring entries"):
prompt = (
f"Given the input `{format_input(entry)}` "
f"and correct output `{entry['output']}`, "
f"score the model response `{entry[json_key]}`"
f" on a scale from 0 to 100, where 100 is the best score. "
f"Respond with the integer number only." #1
)
score = query_model(prompt, model)
try:
scores.append(int(score))
except ValueError:
print(f"Could not convert score: {score}")
continue
return scores
#1 修改后的提示语,只返回分数
现在我们将 generate_model_scores 函数应用于整个 test_data 集,这在 M3 Macbook Air 上大约需要 1 分钟:
scores = generate_model_scores(test_data, "model_response")
print(f"Number of scores: {len(scores)} of {len(test_data)}")
print(f"Average score: {sum(scores)/len(scores):.2f}\n")
结果如下:
Scoring entries: 100%|████████████████████████| 110/110
[01:10<00:00, 1.56it/s]
Number of scores: 110 of 110
Average score: 50.32
评估输出显示,微调后的模型平均得分超过 50,这为与其他模型的比较或实验不同训练配置以提高模型性能提供了有用的基准。
值得注意的是,截至撰写本文时,Ollama 在不同操作系统上的行为并不完全确定性,这意味着您获得的分数可能会与之前的分数略有不同。为获得更稳健的结果,您可以多次重复评估并取平均分数。
为了进一步提高模型的性能,我们可以探索各种策略,例如:
- 调整微调期间的超参数,例如学习率、批次大小或训练的轮数
- 增加训练数据集的大小或多样化示例,涵盖更广泛的话题和风格
- 试验不同的提示语或指令格式,以更有效地引导模型的响应
- 使用更大的预训练模型,其可能具有更大的容量来捕捉复杂模式并生成更准确的响应
注意: 作为参考,使用上述方法,未经微调的 Llama 3 8B 基础模型在测试集上的平均得分为 58.51。而 Llama 3 8B 指令模型(已在通用指令数据集上进行微调)表现更加出色,平均得分为 82.6。
练习 7.4 使用 LoRA 进行参数高效微调
为了更高效地对 LLM 进行指令微调,修改本章中的代码,使用附录 E 中介绍的低秩适应方法(LoRA)。比较修改前后训练的运行时间和模型的性能。
结论
本章标志着我们大型语言模型(LLM)开发周期的结束。我们已经涵盖了所有关键步骤,包括实现 LLM 架构、预训练 LLM 以及针对特定任务进行微调,图 7.21 总结了这些步骤。接下来,让我们讨论一些未来可以进一步研究的想法。
接下来呢?
虽然我们已经涵盖了最关键的步骤,但在指令微调之后还有一个可选步骤:偏好微调。偏好微调对于根据特定用户偏好定制模型特别有用。如果您有兴趣进一步探索,可以查看本书附带的 GitHub 仓库中的 04_preference-tuning-with-dpo 文件夹,网址为:mng.bz/dZwD。
除了本书的主要内容之外,GitHub 仓库还包含大量额外的材料,您可能会觉得有价值。要了解更多关于这些额外资源的信息,请访问仓库的 README 页面中的 "Bonus Material" 部分:mng.bz/r12g。
在快速发展的领域中保持前沿
人工智能和大型语言模型(LLM)研究领域正在以快速(且对许多人来说是令人兴奋的)速度发展。保持与最新进展同步的一种方式是探索 arXiv 上的最新研究论文,网址为:arxiv.org/list/cs.LG/…。此外,许多研究人员和从业者在社交媒体平台上非常活跃,例如 X(前称 Twitter)和 Reddit,并积极分享和讨论最新的进展。特别是 subreddit r/LocalLLaMA 是一个与社区联系并了解最新工具和趋势的好资源。我也定期在我的博客上分享见解,并撰写有关 LLM 研究的最新内容,博客网址为:magazine.sebastianraschka.com 和 sebastianraschka.com/blog/。
最后的话
我希望你享受了从零开始实现大型语言模型(LLM),并从头编写预训练和微调函数的这段旅程。在我看来,从头构建一个 LLM 是深入理解 LLM 工作原理的最有效方法。我希望这种实践方法为你提供了宝贵的见解,并在 LLM 开发方面打下了坚实的基础。
虽然本书的主要目的是教育性质,但你可能会对使用不同的、更强大的 LLM 进行实际应用感兴趣。为此,我建议探索一些流行的工具,例如 Axolotl(github.com/OpenAccess-…)或LitGPT(github.com/Lightning-A…),我也在积极参与这些项目的开发。
感谢你与我一起踏上这段学习之旅,祝你在 LLM 和 AI 这一激动人心的领域中取得成功,前程似锦!