Build a Large Language Model (From Scratch) · Chapter 7 中文译文

微调以遵循指令

译自 Build a Large Language Model (From Scratch) MEAP V08。本文保留章节结构、代码、图、表与公式标记;图像从原 PDF 页段抽取并嵌入。

第 7 章 微调以遵循指令

本章涵盖以下内容

  • LLM 指令微调过程简介
  • 为监督式指令微调准备数据集
  • 在训练批次中组织指令数据
  • 加载预训练 LLM,并对其进行微调,使其遵循人类指令
  • 提取 LLM 生成的指令响应以供评估
  • 评估经过指令微调的 LLM

在前几章中,我们实现了 LLM 架构,进行了预训练,并把来自外部来源的预训练权重导入到了我们的模型中。随后,在上一章中,我们重点讨论了针对特定分类任务微调 LLM:区分垃圾短信和非垃圾短信。在本章中,我们将实现把 LLM 微调为遵循人类指令的过程,如图 7.1 所示;这是开发用于聊天机器人应用、个人助理以及其他对话任务的 LLM 背后的主要技术之一。

图 7.1
图 7.1 关于编写 LLM、在通用文本数据集上预训练 LLM、以及对其进行微调这三个主要阶段的思维模型。本章重点关注把预训练 LLM 微调为遵循人类指令。

图 7.1 展示了微调 LLM 的两种主要方式:用于分类的微调(步骤 8)以及把 LLM 微调为遵循指令(步骤 9)。我们已经在上一章实现了步骤 8。本章重点关注使用指令数据集微调 LLM;下一节将进一步解释这个过程。

7.1 指令微调简介

在第 5 章中,我们看到,预训练 LLM 涉及一种训练过程,在这个过程中模型学习一次生成一个词。由此得到的预训练 LLM 具备文本补全能力,也就是说,给定一个片段作为输入,它可以补全句子或写出文本段落。

不过,预训练 LLM 往往难以处理具体指令,例如“修正这段文本中的语法”或“把这段文本转换为被动语态”。我们将在第 7.5 节考察一个具体示例,在那里会加载预训练 LLM 作为指令微调的基础。

在本章中,我们重点提升 LLM 遵循这类指令并生成期望响应的能力,如图 7.2 所示。

图 7.2
图 7.2 该图展示了由 LLM 处理并生成期望响应的指令示例。

在本章余下部分,我们将分几个步骤实现指令微调过程,从数据集准备开始,如图 7.3 所示。

图 7.3
图 7.3 本章对 LLM 进行指令微调的三阶段流程示意图。阶段 1 涉及数据集准备。阶段 2 侧重模型设置和微调。阶段 3 涵盖模型评估。

准备数据集是指令微调中的一个关键方面,本章大部分时间都会花在这里。下一节会按照图 7.3 所示,实现下载和格式化数据集的代码,这是数据集准备过程的第一步。

7.2 为监督式指令微调准备数据集

在本节中,我们会下载并格式化本章用于对预训练 LLM 进行指令微调的指令数据集。该数据集由 \(1,100\) 个指令-响应对组成,类似于图 7.2 所示的示例。这个数据集是专门为本书创建的,不过感兴趣的读者可以在附录 B 中找到其他公开可用的指令数据集。

下面的代码实现并执行了一个函数来下载这个数据集。它是一个相对较小的 JSON 格式文件,大小只有 \(204\) KB。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:                                                                       #A
         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))
  • #A 如果文件已经下载,则跳过下载

执行前面代码的输出如下:

Number of entries: 1100

我们从 JSON 文件加载的 data 列表包含指令数据集中的 \(1,100\) 个条目。让我们打印其中一个条目,看看每个条目是如何组织的:

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 展示了两种不同的示例格式,它们通常称为提示词风格,用于训练 Alpaca 和 Phi-3 等著名 LLM。Alpaca 是较早公开详细说明其指令微调过程的 LLM 之一。这里也包含由 Microsoft 开发的 Phi-3,用来展示提示词风格的多样性。

图 7.4
图 7.4 LLM 指令微调中提示词风格的比较。Alpaca 风格(左)使用一种结构化格式,其中为指令、输入和响应定义了明确部分;而 Phi-3 风格(右)采用一种更简单的格式,使用指定的 <|user|><|assistant|> token。

本章余下部分使用 Alpaca 提示词风格,因为它是最流行的风格之一,这在很大程度上是因为它帮助定义了最初的微调方法。

让我们定义一个 format_input 函数,用于把 data 列表中的条目转换成图 7.4 所示的 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)          # 85% for training
test_portion = int(len(data) * 0.1)           # 10% for testing
val_portion = len(data) - train_portion - test_portion            # Remaining 5% for validation


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))

这次划分得到以下数据集大小:

Training set length: 935
Validation set length: 55
Test set length: 110

我们已经成功下载并划分了数据集,也清楚理解了数据集的提示词格式化方式;现在可以进入指令微调过程的核心实现了。在下一节中,我们将重点开发用于构造训练批次的方法,以便对 LLM 进行微调。

7.3 将数据组织成训练批次

随着我们进入指令微调流程的实现阶段,下一步如图 7.5 所示,重点是有效地构造训练批次。这需要定义一种方法,确保模型在微调过程中接收到已经格式化好的训练数据。

图 7.5
图 7.5 在上一节中下载数据集并实现文本格式化工具函数之后,本节重点介绍如何组装训练批次。

在上一章中,训练批次由 PyTorch 的 DataLoader 类自动创建;它使用默认的整理函数将样本列表组合成批次。整理函数负责接收一组单独的数据样本,并将它们合并成一个批次,使模型能够在训练期间高效处理。

不过,本章中用于指令微调的批处理过程稍微复杂一些,需要我们创建自己的自定义整理函数,稍后再把它接入 DataLoader。我们实现这个自定义整理函数,是为了处理指令微调数据集的特定要求和格式。

在本节中,我们会分几个步骤处理批处理过程,其中包括编写自定义整理函数,如图 7.6 所示。

图 7.6
图 7.6 实现批处理过程涉及五个子步骤:应用上一节定义的提示模板,使用前几章介绍过的分词方法,添加填充 token,创建目标 token ID,并将 -100 占位 token 替换进去,以便在损失函数中遮蔽填充 token。

首先,为了实现图 7.6 中的步骤 2.1 和 2.2,我们编写一个 InstructionDataset 类,它会应用上一节中的 format_input,并对数据集中的所有输入进行预先 token 化,这与第 6 章中的 SpamDataset 类似。这两个步骤在图 7.7 中有更详细的说明。

图 7.7
图 7.7 该图展示了条目如何先使用特定提示模板进行格式化,然后被 token 化,得到模型可以处理的一串 token ID。

图 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:                                                    #A
               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)
  • #A 对文本进行预先 token 化

与第 6 章中的做法类似,我们希望通过在一个批次中收集多个训练样本来加速训练,这要求把所有输入填充到相近的长度。和上一章一样,我们使用 <|endoftext|> token 作为填充 token。

我们不需要把 <|endoftext|> token 追加到文本输入中,而是可以直接把它的 token ID 追加到已经预先 token 化的输入中。为了提醒自己应该使用哪个 token ID,我们可以对一个 <|endoftext|> token 使用 tokenizer 的 .encode 方法:

import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))

得到的 token ID 是 50256

在第 6 章中,我们把数据集中的所有样本都填充到相同长度。继续图 7.6 中的步骤 2.3,这里我们采用一种更精细的方法:开发一个可以传给数据加载器的自定义整理函数。这个自定义整理函数会把每个批次中的训练样本填充到相同长度,同时允许不同批次具有不同长度,如图 7.8 所示。这种方法只把序列扩展到当前批次中最长序列的长度,而不是扩展到整个数据集中的最长长度,从而减少不必要的填充。

图 7.8
图 7.8 该图展示了如何在批次中使用 token ID 50256 填充训练样本,以确保同一批次内部长度一致。不同批次可以具有不同长度,如图中的第一个批次和第二个批次所示。

我们可以用下面的自定义整理函数实现图 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)                        #A
     inputs_lst = []


     for item in batch:                                                           #B
          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])                                      #C
          inputs_lst.append(inputs)


     inputs_tensor = torch.stack(inputs_lst).to(device)                           #D
     return inputs_tensor
  • #A 找到批次中最长的序列
  • #B 填充并准备输入
  • #C 移除前面额外添加的填充 token
  • #D 将输入列表转换为张量,并转移到目标设备

我们实现的 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]])

从前面的输出可以看到,所有输入都被填充到了最长输入列表的长度,也就是包含 \(5\) 个 token ID 的 inputs_1

这样,我们就实现了第一个自定义整理函数,用于从输入列表创建批次。不过,正如你在第 5 章和第 6 章中学到的,我们还需要创建包含目标 token ID 的批次,它们与输入 ID 批次对应。这些目标 ID 如图 7.9 所示,非常关键,因为它们表示我们希望模型生成的内容,也是训练期间计算权重更新所需损失时要用到的内容,这与前几章类似。

图 7.9
图 7.9 实现批处理过程涉及五个子步骤。现在我们关注步骤 2.4,即目标 token ID 的创建。这个步骤很重要,因为它让模型能够学习并预测需要生成的 token。

如图 7.9 所示,我们现在要修改自定义整理函数,使其除了返回输入 token ID 之外,还返回目标 token ID。

与第 5 章中为预训练 LLM 所描述的过程类似,目标 token ID 与输入 token ID 匹配,但会向右移动一个位置。这样的设置如图 7.10 所示,使 LLM 能够学习如何预测序列中的下一个 token。

图 7.10
图 7.10 该图说明了 LLM 指令微调过程中使用的输入 token 与目标 token 的对齐方式。对于每个输入序列,都会通过把 token ID 向右移动一个位置、省略输入中的第一个 token,并追加一个文本结束 token,来创建对应的目标序列。

下面这个更新后的整理函数会根据输入 token ID 生成目标 token ID,如图 7.10 所示:

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])                                       #A
          targets = torch.tensor(padded[1:])                                  #B
          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)
  • #A 对输入截去最后一个 token
  • #B 将目标向右移动 \(+1\) 个位置

把新的 custom_collate_draft_2 函数应用到前面定义的由 \(3\) 个输入列表组成的示例批次后,它现在会返回输入批次和目标批次:

tensor([[        0,        1,          2,    3,     4],                            #A
          [      5,        6, 50256, 50256, 50256],
          [      7,        8,          9, 50256, 50256]])
tensor([[        1,        2,          3,    4, 50256],                       #B
          [      6, 50256, 50256, 50256, 50256],
          [      8,        9, 50256, 50256, 50256]])
  • #A 第一个张量表示输入
  • #B 第二个张量表示目标

下一步中,我们会给所有填充 token 分配一个 -100 占位值,如图 7.11 所示。这个特殊值使我们能够将这些填充 token 排除在训练损失计算之外,确保只有有意义的数据会影响模型学习。

实现这个修改之后,我们会进一步讨论该过程的细节。(在第 6 章中,我们不需要担心这一点,因为我们只基于最后一个输出 token 来训练模型。)

图 7.11
图 7.11 该图说明了我们应用于数据批次的 token 替换过程中的步骤 2.5。在通过将 token ID 向右移动一个位置并追加文本结束 token 来创建目标序列后,步骤 2.5 重点是把作为填充的文本结束 token 替换为占位值 -100

在步骤 2.4 中,如图 7.11 所示,我们会把目标 token 列表中的文本结束 token 替换为 -100;这些文本结束 token 此前被我们用作填充 token,并被分配了 token ID 50256。(为什么选择 -100 作为替换值,稍后会解释。)

不过请注意,我们会在目标列表中保留一个 token ID 为 50256 的文本结束 token,如图 7.12 所示。这使 LLM 能够学习在响应指令时何时生成文本结束 token;我们把它用作生成的响应已经完成的指示。

图 7.12
图 7.12 该图说明了为训练数据准备目标批次时 token 替换过程中的步骤 2.4。它展示了如何把除了第一个文本结束 token 实例之外的所有实例(我们把它们用作填充)替换为占位值 -100,同时在每个目标序列中保留最初的文本结束 token。

在下面的代码中,我们修改自定义整理函数,把目标列表中 ID 为 50256 的 token 替换为 -100,如图 7.12 所示。此外,我们引入 allowed_max_length 参数,用来可选地限制样本长度。如果你计划使用自己的数据集,并且这些数据集超过了我们稍后在本章中微调的 GPT-2 模型所支持的 \(1024\)-token 上下文大小,这个调整会很有用。更新后的整理函数代码如下:

代码清单 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]
          # Pad sequences to max_length
          padded = new_item + [pad_token_id] * (batch_max_length - len(new_item))
          inputs = torch.tensor(padded[:-1])            # Truncate the last token for inputs
          targets = torch.tensor(padded[1:])            # Shift +1 to the right for targets


          mask = targets == pad_token_id                                             #A
          indices = torch.nonzero(mask).squeeze()                                    #A
          if indices.numel() > 1:                                                    #A
               targets[indices[1:]] = ignore_index                               #A


          if allowed_max_length is not None:
               inputs = inputs[:allowed_max_length]                              #B
               targets = targets[:allowed_max_length]                            #B


          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
  • #A 将目标中除第一个填充 token 之外的所有填充 token 替换为 ignore_index
  • #B 可选地截断到最大序列长度

我们再把整理函数应用到前面创建的示例批次上,检查它是否按预期工作:

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]])

修改后的整理函数按预期工作,它通过插入 token ID -100 改变了目标列表。这个调整背后的逻辑是什么?我们来看看这个修改的底层目的。

为便于演示,考虑下面这个简单且自包含的例子,其中每个输出 logit 都可以对应模型词表中的一个潜在 token。下面展示了当模型预测一个 token 序列时,我们可能如何在训练期间计算交叉熵损失(第 5 章中介绍过),这与我们在第 5 章预训练模型、以及在第 6 章微调模型进行分类时所做的类似:

logits_1 = torch.tensor(
     [[-1.0, 1.0],     # predictions for 1st token
      [-0.5, 1.5]]     # predictions for 2nd token
)
targets_1 = torch.tensor([0, 1]) # Correct token indices to generate
loss_1 = torch.nn.functional.cross_entropy(logits_1, targets_1)
print(loss_1)

前一段代码计算得到的损失值是 1.1269

tensor(1.1269)

如我们所预期,添加额外的 token ID 会影响损失计算。

logits_2 = torch.tensor(
     [[-1.0, 1.0],
      [-0.5, 1.5],
      [-0.5, 1.5]]                                                              #A
)
targets_2 = torch.tensor([0, 1, 1])
loss_2 = torch.nn.functional.cross_entropy(logits_2, targets_2)
print(loss_2)
  • #A 新的第 \(3\) 个 token ID 预测

添加第三个 token 后,损失值现在是 0.7936

到目前为止,我们用 PyTorch 中的交叉熵损失函数做了一些或多或少显而易见的示例计算;它正是第 5 章和第 6 章训练函数中使用的损失函数,也是本章将使用的损失函数。

现在,我们进入有趣的部分,看看如果把第三个目标 token 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)

根据这个结果可以看到,这 \(3\) 个训练样本上的最终损失,与我们前面从 \(2\) 个训练样本计算出的损失完全相同。换句话说,交叉熵损失函数忽略了 targets_3 向量中的第三个条目,也就是对应于 -100 的 token ID。(感兴趣的读者可以尝试把 -100 值替换为另一个既不是 0 也不是 1 的 token ID,会看到这会导致错误。)

那么,-100 有什么特殊之处,使它会被交叉熵损失忽略?PyTorch 中交叉熵函数的默认设置是 cross_entropy(..., ignore_index=-100)。这意味着它会忽略标记为 -100 的目标。

在本章中,我们利用这个 ignore_index 来忽略额外的文本结束(填充)token;这些 token 是我们用来把每个批次中的训练样本填充到相同长度的。

不过,如图 7.12 前面所示,我们希望在目标中保留一个 50256(文本结束)token ID,因为它可以帮助 LLM 学会生成文本结束 token,而我们可以用它来指示某个响应已经完成。

除了遮蔽填充 token 之外,常见做法还包括遮蔽与指令对应的目标 token ID,如图 7.13 所示。

图 7.13
图 7.13 左侧显示的是我们会 token 化并在训练期间馈送给 LLM 的格式化输入文本。右侧显示的是我们为 LLM 准备的目标文本,其中可以可选地遮蔽指令部分,也就是把对应的 token ID 替换为 -100 这个 ignore_index 值。

通过遮蔽与指令对应的目标 token ID,如图 7.13 所示,LLM 的交叉熵损失只会针对生成响应的目标 ID 进行计算。遮蔽指令 token 后,模型会被训练为专注于生成准确响应,而不是还要额外记住指令,这有助于减少过拟合。

目前,对于图 7.13 所示的指令遮蔽是否在指令微调中普遍有益,研究人员仍有分歧。例如,一篇题为《Instruction Tuning With Loss Over Instructions》的近期论文表明,不遮蔽指令有利于 LLM 性能(更多细节见附录 B 中的参考文献)。在本章中,我们不应用遮蔽,而是把它作为一个可选练习留给读者。

7.4 为指令数据集创建数据加载器

在上一节中,我们经过几个阶段,为指令数据集实现了一个 InstructionDataset 类和一个 custom_collate_fn 函数。在本节中,如图 7.14 所示,我们可以收获前面工作的成果:只需把 InstructionDataset 对象和 custom_collate_fn 函数接入 PyTorch 数据加载器即可。这些加载器会自动为 LLM 指令微调过程打乱数据并组织批次。

图 7.14
图 7.14 在前几节中,我们准备了数据集,并实现了一个用于对指令数据集进行批处理的自定义整理函数。在本节中,我们创建并应用数据加载器,用于 LLM 指令微调和评估所需的训练集、验证集和测试集。

在实现图 7.14 所示的数据加载器创建步骤之前,我们需要先简要讨论上一节实现的 custom_collate_fn 的设备设置。

custom_collate_fn 包含将输入张量和目标张量移动到指定设备的代码(例如 torch.stack(inputs_lst).to(device)),该设备可以是 "cpu""cuda"(用于 GPU),也可以可选地是用于搭载 Apple Silicon 芯片的 Mac 的 "mps"。(注意,与本章内容相比,使用 "mps" 设备可能会导致数值差异,因为 PyTorch 对 Apple Silicon 的支持仍处于实验阶段。)

在前几章中,我们是在主训练循环中把数据移动到目标设备上的(例如当 device="cuda" 时移动到 GPU 内存)。把这一步作为整理函数的一部分有一个好处:可以在训练循环之外,以后台过程执行这种设备转移,避免它在模型训练期间阻塞 GPU。

下面的代码会初始化 device 变量:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# if torch.backends.mps.is_available():                                              #A
#      device = torch.device("mps")"                                                 #A
print("Device:", device)
  • #A 如果要在 Apple Silicon 芯片上使用 GPU,请取消注释这两行

接下来,为了在本节稍后把 custom_collate_fn 接入 PyTorch 的 DataLoader 类时复用所选的设备设置,我们使用 Python functools 标准库中的 partial 函数,创建一个预先填充了 device 参数的新函数版本。此外,我们将 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                                                                   #A
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
)
  • #A 如果你的操作系统支持并行 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 \times 61\),其中 \(8\) 表示批次大小,\(61\) 是该批次中每个训练样本的 token 数。第二个输入批次和目标批次具有不同的 token 数,例如 \(76\)。

正如我们在前面的代码输出中看到的,借助自定义整理函数,数据加载器能够创建不同长度的批次。在下一节中,我们将加载一个预训练 LLM,然后用这个数据加载器对它进行微调。

7.5 加载预训练 LLM

在前面几节中,我们花了很多时间为指令微调准备数据集,这是监督式微调过程中的一个关键方面。许多其他方面与预训练相同,因此我们可以复用前面章节中的大量代码。在开始指令微调之前,我们首先加载一个希望进行微调的预训练 GPT 模型,如图 7.15 所示。

图 7.15
图 7.15 数据集准备完成后,用于指令跟随的 LLM 微调流程从加载一个预训练 LLM 开始,它将作为后续训练的基础。这个预训练模型已经从海量文本数据中学习到通用语言模式和知识,随后会在下一节通过微调过程适配到指令跟随任务。

如图 7.15 的章节概览图所示,本节聚焦于第 4 步:加载一个预训练 LLM,作为指令微调的起点,这与前几章中的流程类似。不过,这次我们不再使用之前最小的 \(124\) million 参数模型,而是加载拥有 \(355\) million 参数的中等规模模型。选择它的原因是,\(124\) million 参数模型的容量过于有限,难以通过指令微调得到质量上令人满意的结果。

这使用的代码与第 5 章 5.5 节和第 6 章 6.4 节相同,只是现在我们指定的是 "gpt2-medium (355M)",而不是 "gpt2-small (124M)"。请注意,执行下面的代码会开始下载中等规模 GPT 模型,其存储需求约为 \(1.42\) GB。这大约是小模型所需存储空间的 \(3\) 倍:

清单 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,           # Vocabulary size
    "context_length": 1024,        # Context length
    "drop_rate": 0.0,              # Dropout rate
    "qkv_bias": True               # Query-key-value bias
}


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]

在下一节深入微调模型之前,我们先花一点时间评估预训练 LLM 在一个验证任务上的表现,将它的输出与期望响应进行比较。这样可以让我们对模型在未经微调、开箱即用时完成指令跟随任务的能力有一个基线认识,也有助于我们稍后理解微调带来的影响。在这次评估中,我们使用验证集中的第一个示例:

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 函数返回的是输入文本与输出文本拼接后的结果。这个行为在前面章节中很方便,因为预训练 LLM 主要被设计成文本补全模型,输入和输出会被串接起来形成连贯、可读的文本。不过,当评估模型在某个特定任务上的表现时,我们通常只希望关注模型生成的响应。

为了分离模型的响应文本,我们需要从 generated_text 的开头减去输入指令的长度:

response_text = generated_text[len(input_text):].strip()
print(response_text)

这段代码会从 generated_text 的开头移除输入文本,只留下模型生成的响应。随后应用 strip() 函数,移除任何前导或尾随空白字符。输出如下:

### Response:


The chef cooks the meal every day.


### Instruction:


Convert the active sentence to passive: 'The chef cooks the

从输出可以看到,预训练模型还不能正确遵循给定指令。虽然它确实创建了一个 “Response” 部分,但它只是重复了原始输入句子和部分指令,没有按要求把主动句转换为被动语态。

在接下来的小节中,我们将实现微调过程,以提升模型理解这类请求并做出恰当响应的能力。

7.6 在指令数据上微调 LLM

如图 7.16 的章节概览所示,本节聚焦于微调 LLM。我们将上一节加载的预训练模型拿来,使用本章前面准备好的指令数据集继续训练它。

图 7.16
图 7.16 在用于指令跟随的 LLM 微调第 5 步中,我们使用本章前面准备好的指令数据集,训练上一节加载的预训练模型。

如前所述,我们在本章开头实现指令数据集处理时,已经完成了所有艰难的工作。对于微调过程本身,我们可以复用第 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

表 7.1 提供了在不同设备上训练各个模型的参考运行时间,包括 CPU 和 GPU。在兼容的 GPU 上运行这段代码不需要修改代码,并且可以显著加快训练速度。对于本章展示的结果,我使用 GPT-2 medium 模型,并在 A100 GPU 上训练它。

表 7.1 GPT-2 指令微调的参考运行时间
模型名称 设备 2 个 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 分钟

模型和数据加载器准备就绪后,现在可以开始训练模型了。下面的代码会设置训练过程,包括初始化优化器、设置 epoch 数量,以及定义评估频率和起始上下文;这个起始上下文用于在训练期间基于我们前面查看过的第一个验证集指令(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 000015): Train loss 0.857, Val loss 0.906
...
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|>The following is an instruction that describes a task. Write a
response that appropriately completes the request.         ### Instruction: Convert the active
sentence to passive:
Ep 2 (Step 000120): Train loss 0.438, Val loss 0.670
Ep 2 (Step 000125): Train loss 0.453, Val loss 0.685
Ep 2 (Step 000130): Train loss 0.448, Val loss 0.681
Ep 2 (Step 000135): Train loss 0.408, Val loss 0.677
...
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 following is an instruction that describes a task. Write a response
that appropriately completes the request.       ### Instruction: What is the capital of the
United Kingdom
Training completed in 0.87 minutes.

训练输出显示模型正在有效学习,这可以从两个 epoch 中训练损失和验证损失值持续下降看出。这表明模型正在逐步提升理解并遵循所给指令的能力。(由于模型在这两个 epoch 内已经表现出有效学习,将训练扩展到第三个 epoch 或更多并非必要,甚至在这里可能适得其反,因为它可能导致过拟合增加。)

此外,每个 epoch 结束时生成的响应让我们能够检查模型在验证集示例中正确执行给定任务的进展。在这个例子中,模型成功地将主动句 “The chef cooks the meal every day.” 转换为对应的被动语态:“The meal is cooked every day by the chef.”

我们会在后面的章节中重新审视并更详细地评估模型的响应质量。但现在,为了结束本节,我们先查看训练损失和验证损失曲线,以进一步了解模型的学习过程。为此,我们使用第 5 章中的 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.17 展示两个 epoch 内训练损失和验证损失趋势的图。实线表示训练损失,显示出先急剧下降后趋于稳定;虚线表示验证损失,其变化模式类似。

从图 7.17 所示的损失图可以看到,在训练过程中,模型在训练集和验证集上的表现都有显著提升。初始阶段损失迅速下降,说明模型正在快速从数据中学习有意义的模式和表示。随后,当训练进入第二个 epoch 时,损失继续下降,但速度变慢,这表明模型正在微调已学习到的表示,并收敛到一个稳定的解。

虽然图 7.17 中的损失图表明模型训练有效,但最关键的方面是它在响应质量和正确性方面的表现。在本章剩余部分,我们将提取响应并将其存储为一种便于我们评估和量化响应质量的格式。

7.7 提取并保存响应

按照上一节所述,在指令数据集的训练部分上微调 LLM 之后,我们现在继续评估它在留出的测试集上的表现。为此,我们首先提取测试数据集中每个输入对应的模型生成响应,并将它们收集起来供人工分析,如图 7.18 的章节概览所示。

图 7.18
图 7.18 本节重点是提取并收集模型在留出测试数据集上的响应,以便进一步分析。下一节会介绍模型评估,用于量化指令微调后 LLM 的性能。

我们从第 7 步开始,也就是图 7.18 所示的响应生成步骤,使用 generate 函数。然后,我们打印前三个测试集条目的模型响应,并将它们与期望的测试集答案并排呈现以便比较:

torch.manual_seed(123)


for entry in test_data[:3]:                                                       #A
     input_text = format_input(entry)
     token_ids = generate(                                                        #B
          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("-------------------------------------")

#A Iterate over the first 3 test set samples
#B Use the generate function imported in section 7.5

如前所述,generate 函数返回的是输入和输出拼接后的文本,因此我们对 generated_text 的内容使用切片和 .replace() 方法来提取模型的响应。下面展示了指令,以及给定的测试集响应和模型响应:

Below is an instruction that describes a task. Write a response that appropriately
completes the request.


### Instruction:
Rewrite the sentence using a simile.


### Input:
The car is very fast.


Correct response:
>> The car is as fast as lightning.


Model response:
>> The car is as fast as a bullet.
-------------------------------------
Below is an instruction that describes a task. Write a response that appropriately
completes the request.


### Instruction:
What type of cloud is typically associated with thunderstorms?


Correct response:
>> The type of cloud typically associated with thunderstorms is cumulonimbus.


Model response:
>> The type of cloud associated with thunderstorms is a cumulus cloud.
-------------------------------------
Below is an instruction that describes a task. Write a response that appropriately
completes the request.


### Instruction:
Name the author of 'Pride and Prejudice'.


Correct response:
>> Jane Austen.


Model response:
>> The author of 'Pride and Prejudice' is Jane Austen.
-------------------------------------

从测试集指令、给定响应以及模型响应可以看出,模型表现相对不错。第一个和最后一个指令的答案明显正确,而第二个答案接近但并不完全准确。模型回答的是 “cumulus cloud” 而不是 “cumulonimbus”,不过值得注意的是,cumulus cloud 可以发展成 cumulonimbus cloud,而后者能够产生雷暴。

最重要的是,我们可以看到模型评估并不像上一章那么直接;上一章中,我们只需计算正确的垃圾邮件/非垃圾邮件类别标签所占比例,就能得到分类准确率。在实践中,像聊天机器人这样的指令微调 LLM 会通过多种方法进行评估:

  1. 短答案和多项选择基准,例如 MMLU(“Measuring Massive Multitask Language Understanding”,https://arxiv.org/abs/2009.03300),用于测试模型的一般知识。
  2. 与其他 LLM 进行人类偏好比较,例如 LMSYS chatbot arena(https://arena.lmsys.org)。
  3. 自动化对话基准,其中使用另一个类似 GPT-4 的 LLM 来评估响应,例如 AlpacaEval(https://tatsu-lab.github.io/alpaca_eval/)。

在实践中,考虑所有三类评估方法是有用的:多项选择问答、人类评估,以及衡量对话性能的自动化指标。不过,由于本章我们主要关注的是对话性能评估,而不仅仅是回答多项选择题的能力,因此方法 2(人类评估)和方法 3(自动化指标)可能更相关。

人类评估虽然能提供有价值的洞见,但可能相当费力且耗时,尤其是在需要处理大量响应时。例如,阅读并为全部 \(1,100\) 个响应打分会需要大量工作。

因此,考虑到当前任务的规模,我们将实现一种类似方法 3 的方案,即使用另一个 LLM 自动评估这些响应。这让我们无需大量人工参与,就能高效评估生成响应的质量,从而在节省时间和资源的同时,仍能获得有意义的性能指标。

在下一节中,我们采用一种受 AlpacaEval 启发的方法,利用另一个 LLM 来评估我们微调后模型的响应。不过,我们不依赖公开可用的基准数据集,而是使用自己的自定义测试集。这样可以在我们的指令数据集所代表的预期使用场景中,对模型性能进行更有针对性且更相关的评估。

为了给这个评估过程准备响应,我们将生成的模型响应追加到 test_set 字典中,并把更新后的数据保存为 "instruction-data-with-response.json" 文件,以便记录保存。此外,通过保存该文件,如果后续需要,我们也可以在单独的 Python 会话中轻松加载并分析这些响应。

下面的代码以与之前相同的方式使用 generate 方法;不过,这次我们会遍历整个 test_set。另外,我们不再打印模型响应,而是把它们添加到 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)        # "indent" for pretty-printing

在 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


# Remove white spaces and parentheses from file name
file_name = f"{re.sub(r'[ ()]', '', CHOOSE_MODEL) }-sft.pth"
torch.save(model.state_dict(), file_name)
print(f"Model saved as {file_name}")

随后可以通过 model.load_state_dict(torch.load("gpt2-medium355M-sft.pth")) 加载保存的模型。

7.8 评估微调后的 LLM

此前,我们通过查看指令微调模型在测试集 \(3\) 个示例上的响应,来判断它的性能。虽然这能让我们大致了解模型表现如何,但这种方法并不适合扩展到大量响应。因此,在本节中,如图 7.19 的章节概览所示,我们会实现一种方法,使用另一个更大的 LLM 自动评估微调后 LLM 的响应。

图 7.19
图 7.19 在指令微调流水线的最后一步中,我们会实现一种方法,对微调模型为测试集生成的响应进行评分,从而量化其性能。

为了实现图 7.19 所示的第 \(9\) 步,也就是以自动化方式评估测试集响应,我们会使用 Meta AI 开发的一个已有的指令微调 \(80\) 亿参数 Llama 3 模型。这个模型可以通过开源 Ollama 应用(https://ollama.com)在本地运行。

Ollama 是一个可以在笔记本电脑上高效运行 LLM 的应用。它封装了开源 llama.cpp 库(https://github.com/ggerganov/llama.cpp),该库用纯 C/C++ 实现 LLM,以最大化效率。不过请注意,Ollama 只是一个用于使用 LLM 生成文本(推理)的工具,不支持训练或微调 LLM。

要执行下面的代码,请访问 https://ollama.com 安装 Ollama,并按照其中针对你的操作系统提供的说明进行操作:

  • 对于 macOS 和 Windows 用户:打开下载的 Ollama 应用。如果系统提示安装命令行用法,请选择“yes”。
  • 对于 Linux 用户:使用 Ollama 网站上提供的安装命令。

在实现模型评估代码之前,我们先下载 Llama 3 模型,并通过命令行终端使用它来验证 Ollama 是否正常工作。

图 7.20
图 7.20 运行 Ollama 的两种选项。左侧面板展示了使用 ollama serve 启动 Ollama。右侧面板展示了 macOS 中的第二种选项:在后台运行 Ollama 应用,而不是使用 ollama serve 命令启动应用。

如图 7.20 所示,在另一个终端中运行 Ollama 应用或 ollama serve 之后,在命令行(不是 Python 会话)中执行以下命令,试用 \(80\) 亿参数的 Llama 3 模型:

ollama run llama3

第一次执行这个命令时,系统会自动下载占用 \(4.7\) GB 存储空间的 \(80\) 亿参数 Llama 3 模型。输出大致如下:

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

模型下载完成后,我们会看到一个命令行界面,可以用它与模型交互。例如,尝试向模型提问:“What do llamas eat?”:

>>> 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 应用正在运行。

除了使用 ollama run 命令与模型交互,也可以通过 Python 使用 Ollama 的 REST API。下面的 query_model 函数展示了如何使用该 API:

代码清单 7.10 查询本地 Ollama 模型
import urllib.request


def query_model(prompt, model="llama3", url="http://localhost:11434/api/chat"):
     data = {                                                                  #A
          "model": model,
          "seed": 123,              # for deterministic responses
          "temperature": 0,         # for deterministic responses
          "messages": [
                {"role": "user", "content": prompt}
          ]
     }


     payload = json.dumps(data).encode("utf-8")                                  #B
     request = urllib.request.Request(url, data=payload, method="POST") #C
     request.add_header("Content-Type", "application/json")                      #C


     response_data = ""
     with urllib.request.urlopen(request) as response:                           #D
          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

#A Create the data payload as a dictionary
#B Convert the dictionary to a JSON formatted string and encode it to bytes
#C Create a request object, setting the method to POST and adding necessary headers
#D Send the request and capture the response
  • #A 将数据载荷创建为字典。
  • #B 将字典转换为 JSON 格式的字符串,并编码为字节。
  • #C 创建请求对象,将方法设置为 POST,并添加必要的请求头。
  • #D 发送请求并捕获响应。

在运行这个 notebook 中后续代码单元之前,请确保 Ollama 仍在运行。前面的代码单元应打印 "Ollama running: True",以确认模型处于活动状态并已准备好接收请求。

下面是如何使用刚刚实现的 query_llama 函数的一个示例:

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\) 的尺度评分。

首先,我们把这种方法应用于前一节中检查过的测试集前 \(3\) 个示例:

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 并不是完全确定性的,因此生成文本可能有所变化):

Dataset response:
>> The car is as fast as lightning.


Model response:
>> The car is as fast as a bullet.


Score:
>> A scoring task!


To evaluate the model response "The car is as fast as a bullet.", I'll consider how well
it follows the instruction and uses a simile that's coherent, natural-sounding, and
effective in conveying the idea of speed.


Here are some factors to consider:


1. **Follows instruction**: Yes, the model uses a simile to rewrite the sentence.
2. **Coherence and naturalness**: The comparison between the car's speed and a bullet is
common and easy to understand. It's a good choice for a simile that conveys the idea of
rapid movement.
3. **Effectiveness in conveying idea of speed**: A bullet is known for its high
velocity, which makes it an excellent choice to describe a fast-moving car.


Considering these factors, I'd score the model response "The car is as fast as a
bullet." around 85 out of 100. The simile is well-chosen, coherent, and effectively
conveys the idea of speed. Well done, model!


-------------------------


Dataset response:
>> The type of cloud typically associated with thunderstorms is cumulonimbus.


Model response:
>> The type of cloud associated with thunderstorms is a cumulus cloud.


Score:
>> A scoring task!


I'll evaluate the model's response based on its accuracy and relevance to the original
instruction.


**Accuracy:** The model's response is partially correct. Cumulus clouds are indeed
associated with fair weather and not typically linked to thunderstorms. The correct
answer, cumulonimbus, is a type of cloud that is closely tied to thunderstorm formation.


**Relevance:** The model's response is somewhat relevant, as it mentions clouds in the
context of thunderstorms. However, the specific type of cloud mentioned (cumulus) is not
directly related to thunderstorms.


Considering these factors, I would score the model response a **40 out of 100**. While
the response attempts to address the instruction, it provides an incorrect answer and
lacks relevance to the original question.


-------------------------


Dataset response:
>> Jane Austen.


Model response:
>> The author of 'Pride and Prejudice' is Jane Austen.


Score:
>> A simple one!


My model response: "The author of 'Pride and Prejudice' is Jane Austen."


Score: **99**


Reasoning:


* The response directly answers the question, providing the correct name of the author.
* The sentence structure is clear and easy to understand.
* There's no room for misinterpretation or ambiguity.


Overall, a perfect score!


-------------------------

根据生成的响应可以观察到,Llama 3 模型给出了合理的评估,并且在模型答案并非完全正确时,能够给出部分分数。例如,如果考虑对“cumulus cloud”答案的评估,模型承认了该响应的部分正确性。

前面的提示词除了分数之外,还会返回非常详细的评估。我们可以修改提示词,让它只生成从 \(0\) 到 \(100\) 的整数分数,其中 \(100\) 表示可能的最佳分数。这个修改让我们能够计算模型的平均分,从而得到更简洁、更量化的性能评估。

下面的 generate_model_scores 函数使用了修改后的提示词,要求模型“Respond with the integer number only.”:

代码清单 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."                          #A
          )
          score = query_model(prompt, model)
          try:
                 scores.append(int(score))
          except ValueError:
                 print(f"Could not convert score: {score}")
                 continue


     return scores

#A Modified instruction line to only return the score
  • #A 修改后的指令行,使其只返回分数。

现在,我们把 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: 54.16

评估输出显示,我们微调后的模型平均分超过 \(50\),这为与其他模型比较,或尝试不同训练配置以提升模型性能,提供了一个有用的基准。

值得注意的是,在撰写本文时 Ollama 并非完全确定性,这意味着你获得的分数可能会与上面给出的分数略有不同。为了获得更稳健的结果,可以重复评估多次,并对得到的分数取平均。

为了进一步提高模型性能,我们可以探索多种策略,例如:

  • 调整微调期间的超参数,例如学习率、批量大小或 epoch 数量。
  • 增加训练数据集的规模,或使示例更多样化,以覆盖更广泛的主题和风格。
  • 尝试不同的提示词或指令格式,更有效地引导模型响应。
  • 考虑使用更大的预训练模型,因为它可能有更强的容量来捕获复杂模式并生成更准确的响应。

7.9 结论

本章标志着我们对 LLM 开发周期之旅的结束。我们已经覆盖了所有基本步骤,包括实现 LLM 架构、预训练 LLM,以及针对特定任务对其进行微调,如图 7.21 所总结。

图 7.21
图 7.21 本图概览了本书涵盖的实现、预训练和微调 LLM 的不同阶段。

下一小节会为你提供一些想法,说明在图 7.21 所示的基本步骤之后,可以继续探索什么。

7.9.1 接下来做什么?

虽然我们已经覆盖了最重要的步骤,如图 7.21 所示,在指令微调之后还可以执行一个可选步骤:偏好微调。偏好微调特别适合用于定制模型,使其更好地符合特定用户偏好。如果你有兴趣进一步探索,请参考本书补充 GitHub 仓库中的 04_preference-tuning-with-dpo 文件夹:https://github.com/rasbt/LLMs-from-scratch/tree/main/ch07/04_preference-tuning-with-dpo

除了本书涵盖的主要内容之外,GitHub 仓库还包含大量你可能会觉得有价值的额外材料。要进一步了解这些资源,请访问仓库 README 页面上的 Bonus Material 部分:https://github.com/rasbt/LLMs-from-scratch?tab=readme-ov-file#bonus-material

7.9.2 在快速发展的领域中保持更新

AI 和 LLM 研究领域正在以很快(而且取决于你问谁,也令人兴奋)的速度发展。要跟上最新进展,一种方式是探索 arXiv 上的近期研究论文:https://arxiv.org/list/cs.LG/recent。此外,许多研究人员和实践者也非常活跃地在 X(以前的 Twitter)和 Reddit 等社交媒体平台上分享和讨论最新进展。尤其是 r/LocalLLaMA 这个 subreddit,是连接社区并了解最新工具和趋势的一个不错资源。

我也经常在自己的博客上分享见解,并撰写有关 LLM 研究最新进展的文章,地址是 https://magazine.sebastianraschka.comhttps://sebastianraschka.com/blog/

7.9.3 结语

我希望你享受了这段从零实现 LLM,并从头编写预训练和微调函数的旅程。在我看来,从零构建 LLM 是深入理解 LLM 工作原理的最有效方式。我希望这种动手实践的方法为你提供了有价值的见解,并为 LLM 开发打下了扎实基础。

虽然本书的主要目的是教育,但你可能也有兴趣在实际应用中使用不同且更强大的 LLM。为此,我建议探索一些流行工具,例如 Axolotl(https://github.com/OpenAccess-AI-Collective/axolotl)或 LitGPT(https://github.com/Lightning-AI/litgpt),我也积极参与了这些工具的开发。

感谢你与我一起踏上这段学习之旅,祝你在 LLM 和 AI 这个令人兴奋的领域中未来一切顺利!

7.10 小结

  • 指令微调过程会调整预训练 LLM,使其遵循人类指令并生成期望的响应。
  • 准备数据集包括下载一个指令-响应数据集、格式化条目,并将其划分为训练集、验证集和测试集。
  • 训练批次通过自定义整理函数构建,该函数会填充序列、创建目标 token ID,并屏蔽填充 token。
  • 我们加载了一个拥有 \(355M\) 参数的预训练 GPT-2 medium 模型,将其作为指令微调的起点。
  • 预训练模型会在指令数据集上使用类似预训练的训练循环进行微调。
  • 评估涉及提取模型在测试集上的响应并对其评分,例如使用另一个 LLM。
  • 可以使用带有 \(8B\) 参数 Llama 模型的 Ollama 应用,自动为微调模型在测试集上的响应评分,并提供一个平均分来量化性能。

章节范围:原 PDF 物理第 248-302 页。图片从同一页段抽取并嵌入本文。