Build a Large Language Model (From Scratch) 第7章 通过微调遵循人类指令

本章内容

  • 大语言模型的指令微调过程
  • 为有监督指令微调准备数据集
  • 将指令数据组织成训练批次
  • 加载预训练的大语言模型并根据人类指令进行微调
  • 提取大语言模型生成的指令响应以进行评估
  • 评估指令微调后的大语言模型

前面我们实现了大语言模型架构,进行了预训练,并从外部来源将预训练好的模型权重加载到了模型中。接下来,我们将专注于将大语言模型微调到一个特定的分类任务上,即区分"垃圾消息"和"非垃圾消息"。现在,我们将实现微调大语言模型以遵循人类指令的过程,如图 7-1 所示。在开发用于聊天机器人应用程序、个人助理和其他对话任务的大语言模型时,指令微调是主要技术之一。

图 7-1 显示了微调大语言模型的两种主要方式:用于文本分类的微调(第(8)步)和微调大语言模型以遵循人类指令(第(9)步)。我们在第 6 章中实现了第(8)步。现在,我们将使用指令数据集来微调大语言模型。

7.1 指令微调介绍

现在我们知道,大语言模型的预训练是通过让模型学会逐个生成单词来实现的。预训练后的大语言模型能够进行文本补全,这意味着给定任意一个片段作为输入,模型能够生成一个句子或撰写一个段落。然而,预训练后的大语言模型在执行特定指令时往往表现不佳,比如无法完成像 "纠正这段文字的语法"或"将这段话变成被动语态"这样的指令。本章将通过一个具体的例子,展示如何加载预训练后的大语言模型以进行指令微调(也被称为有监督指令微调)。

在本章中,我们将专注于提高大语言模型遵循指令并生成合理回复的能力,如图 7-2 所示。 准备数据集是指令微调的一个关键部分。因此,接下来我们将从准备数据集开始,完成指令微调过程中 3 个阶段的所有步骤,如图 7-3 所示。

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

让我们下载并制作用于指令微调预训练的大语言模型的指令数据集。本章使用的指令数据集包含 1100 个指令-回复对,类似于图 7-2 中的示例。这个数据集是专门为本书创建的,但如果你对此感兴趣,也可以在附录 B 中找到其他公开可用的指令数据集。

代码清单 7-1 中的代码实现并执行了一个函数来下载这个数据集,该数据集保存在一个相对较小的 JSON 格式的文件中(仅 204 KB)。JSON(JavaScript 对象表示法)是一种既便于人类阅读又适合机器处理的数据交换结构,有点儿类似于 Python 字典。

python 复制代码
import json
import os
import requests


def download_and_load_file(file_path, url):
    if not os.path.exists(file_path):
        response = requests.get(url, timeout=30)
        response.raise_for_status()
        text_data = response.text
        with open(file_path, "w", encoding="utf-8") as file:
            file.write(text_data)

    with open(file_path, "r", encoding="utf-8") 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 个指令数据集样本,让我们打印其中一个来看看它长什么样:

python 复制代码
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'3 个键的 Python 字典对象。

再来看另一个例子:

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 文件中提取的各个样本)的数据集上训练模型。在获得这些样本后,有多种方法可以将样本制作成适用于大语言模型的格式。

图 7-4 展示了两种样本格式,这通常也被称为提示词风格,常用于训练知名的大语言模型, 比如 Alpaca 和 Phi-3。

Alpaca 是最早公开详细说明其指令微调过程的大语言模型之一。我们提到微软开发的Phi-3 是为了说明提示词风格的多样性。考虑到 Alpaca 提示词风格很大程度上奠定了指令微调的基础,是最流行的提示词风格之一,本章的其余部分将默认使用 Alpaca 提示词风格。


练习 7.1 改变提示词风格

在使用 Alpaca 提示词风格微调模型之后,尝试使用图 7-4 中展示的 Phi-3 提示词风格,观察其是否会影响模型回复的质量。

python 复制代码
def format_input(entry):
    instruction_text = (
        f"<|user|>\n{entry['instruction']}"
    )

    input_text = f"\n{entry['input']}" if entry["input"] else ""

    return instruction_text + input_text

I need to create a markdown quote format note that covers why Phi-3 is faster and explains the special token discussion, keeping it concise without headers.

Phi-3 模板比 Alpaca 更快,原因很简单:它生成的输入序列更短。Alpaca 模板含大量固定说明文字("Below is an instruction..."、### Instruction### Response),而 Phi-3 只用 <|user|> / <|assistant|> 精简标记。

Transformer 计算量大致与序列长度成正比(自注意力是平方关系),token 越少,每次前向/反向传播处理的数据越少,训练和推理都更快。这里省下的约 17% 时间,本质就是省下的模板样板文字对应的 token。除此之外没有魔法------评估分数(约 48.87)和 Alpaca 差不多,说明模板选择主要影响效率,而非模型质量。

关于特殊 token:一个 token 是否"特殊",取决于分词器词表里有没有为它保留独立 ID。GPT-2 认识 <|endoftext|>(编码成单个 ID 50256),但不认识 <|user|>,会把它按子词拆成 5 个 token(27, 91, 7220, 91, 29),反而抵消了 Phi-3 模板简短带来的效率优势。

想让 GPT-2 把 <|user|> 当特殊 token,需在 tiktoken 用 allowed_special 注册,但光注册不够,还要扩展模型的 embedding 层和输出层。原因是:注册只让分词器给新 token 分配一个新 ID(如 50257),而模型的 embedding 表(查找 ID→向量)和输出层(预测 ID→概率)都是按原词表大小 50257 造的,没给新 ID 留位置------输入时查表越界,输出时无对应维度、永远生成不出它。所以必须把两处尺寸各 +1,且新增参数要通过训练学习含义。

对比:<|endoftext|> 本就内置于词表(ID 50256),embedding 和输出层都有它的位置且预训练已学出含义,只是"启用"已存在的 token,无需扩展;<|user|> 是词表里全新的成员,才需要扩展 embedding 层和输出层。


下面定义一个 format_input 函数,然后我们可以使用它将 data 列表中的样本转换成 Alpaca 风格的输入格式,如代码清单 7-2 所示。

python 复制代码
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

将字典 entry 作为输入,format_input 函数会构造一个格式化的字符串。让我们把它应用于之前打印过的数据集样本 data50来看看效果:

python 复制代码
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 函数用在我们之前检查过的 data999上:

python 复制代码
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 数据集加载器之前,还需要将数据集分为训练集、验证集和测试集,所用方法与我们在第 6 章中处理垃圾消息分类数据集时相似。代码清单 7-3 展示了如何设置这些数据集的比例。

python 复制代码
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

7.3 将数据组织成训练批次

本节是指令微调过程的实现阶段,整体流程如图 7-5 所示。接下来,我们将专注于有效构建训练批次。该过程需要定义一种方法,以确保模型在微调期间正确接收到经过格式化的训练数据。

在第 6 章中,训练批次是通过 PyTorch 的 DataLoader 类自动创建的,该类使用默认的聚合(collate)函数将样本列表组合成训练批次。聚合函数的作用是将单个数据样本列表合并为一个批次,以便模型在训练时能够高效地处理。

然而,指令微调的批次处理稍微有些复杂,因为需要创建一个自定义的聚合函数,然后再将其集成到 DataLoader 中。我们将实现这个自定义聚合函数,以满足指令微调数据集的特定需求和格式。

接下来,我们将分几步来解决批次处理的问题,包括编码自定义聚合函数,如图 7-6 所示。 首先,为了完成第(2.1)步和第(2.2)步,如代码清单 7-4 所示,我们将编写一个 InstructionDataset 类,该类会应用 format_input 函数并对数据集中所有输入进行预词元化(pretokenize),类似于第 6 章中的 SpamDataset。这两个步骤将在InstructionDataset 的__init__构造方法中实现,如图 7-7 所示。

python 复制代码
import torch
from torch.utils.data import Dataset


class InstructionDataset(Dataset):
    def __init__(self, data, tokenizer):
        self.data = data

        # Pre-tokenize texts
        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|>作为填充词元。

做法 A(文本层面):full_text + "<|endoftext|>" → 再 encode → token IDs

做法 B(词元层面):full_text → encode → token IDs,再直接 append 那个填充的 token ID

作者建议用做法 B:既然文本已经被预分词成 ID 了,就没必要回到文本层面重新拼接字符串再编码,直接在 ID 列表后面追加填充词元对应的那个整数 ID 即可。这样更高效、更直接。

这就是下一个自然段的意思

一个值得注意的细节是,可以直接将<|endoftext|>对应的词元 ID 拼接到预词元化的模型输入中,而无须将<|endoftext|>拼接在输入文本的末尾。可以使用分词器的.encode 方法对 <|endoftext|>进行编码,以确定应该使用哪个词元 ID:

python 复制代码
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")

print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))
复制代码
[50256]

接下来,在第(2.3)步(参见图 7-6)中,我们将采取更复杂的方法,开发一个自定义聚合函数来传递给数据加载器。该函数可以将每个批次中的训练示例填充到相同长度,同时允许不同批次具有不同长度,如图 7-8 所示。这种方法通过仅扩展序列以匹配每个批次中最长的序列,从而减少了不必要的填充。

可以用一个自定义的聚合函数来实现填充过程:

python 复制代码
def custom_collate_draft_1(
    batch,
    pad_token_id=50256,
    device="cpu"
):
    # 找出批次中最长的序列,
    # 并将最大长度 +1,这样下面会额外添加一个
    # 填充(padding)token--结束标记 <|endoftext|> token
    # 原始:       [7, 1]
	# ① 加结尾:   [7, 1, 50256]           ← 这个是"额外添加的那一个"
	# ② 补对齐:   [7, 1, 50256, 50256, 50256]   ← 后面两个是纯填充
    batch_max_length = max(len(item) + 1 for item in batch)

    # 填充并准备输入
    inputs_lst = []

    for item in batch:
        new_item = item.copy()
        # 添加一个 <|endoftext|> token
        new_item += [pad_token_id]
        # 将序列填充到 batch_max_length 的长度
        padded = (
            new_item + [pad_token_id] *
            (batch_max_length - len(new_item))
        )
        # 通过 padded[:-1],我们移除因为 batch_max_length 中的 +1 设置
        # 而额外添加的那个填充 token
        # (这个额外的填充 token 会在后续的代码中用到)
        inputs = torch.tensor(padded[:-1])
        inputs_lst.append(inputs)

    # 将输入列表转换为张量,并转移到目标设备上
    inputs_tensor = torch.stack(inputs_lst).to(device)
    return inputs_tensor

给每条序列末尾加一个 <|endoftext|> token这是有实际意义的。它告诉模型这条文本到这里结束了。模型需要学会预测文本何时结束,所以这个结束标记必须真实存在于训练数据里。这个 token 是要被模型学习和预测的。

这个 +1 是为 <|endoftext|>预留出位置。而因为gpt2没有padding token,所以采用了同样的<|endoftext|>。

同一个 50256 身兼结束标记和填充符,所以后续代码通常要用 mask 来区分这两种角色。

这里的 custom_collate_draft_1 旨在与 PyTorch DataLoader 集成,但它也可以独立使用。 现在,我们将独立运行和测试它,以确保其功能正常。让我们试试将 3 个不同长度的输入聚合成一个批次,并使得每个示例的长度相同:

python 复制代码
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 个词元 ID)都被填充到最长的输入列表 inputs_1 的长度。

我们刚刚实现了第一个自定义的聚合函数,用于从输入列表中创建批次 。然而,正如之前所提到的,我们还需要生成与输入词元 ID 批次对应的目标词元 ID 。这些目标词元 ID(参见图 7-9)非常重要,因为它们代表我们期望模型生成的内容,并且在训练中用来计算损失,以便进行权重更新 。因此,我们需要对自定义聚合函数进行修改,以便除了输入词元 ID 之外,还能返回目标词元 ID

与我们预训练大语言模型时的做法相似,目标词元 ID 与输入词元 ID 相对应,但向左移动了一个位置。这样的设计(参见图 7-10)使得大语言模型能够学习如何预测序列中的下一个词元。

下面这段代码通过更新聚合函数实现了为输入词元 ID 生成目标词元 ID 的功能

python 复制代码
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()
        # 添加一个 <|endoftext|> 结束标记
        new_item += [pad_token_id]
        # 将序列填充到最大长度
        padded = (
            new_item + [pad_token_id] *
            (batch_max_length - len(new_item))
        )
        inputs = torch.tensor(padded[:-1])   # 截去最后一个 token 作为输入
        targets = torch.tensor(padded[1:])   # 向右移动一位作为目标
        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

将这段代码应用到我们之前定义的包含 3 个输入列表的 batch 变量上,新的custom_collate_ draft_2 函数现在可以同时返回输入和目标批次。

复制代码
tensor([[    0,     1,     2,     3,     4],
        [    5,     6, 50256, 50256, 50256],
        [    7,     8,     9, 50256, 50256]])
tensor([[    1,     2,     3,     4, 50256],
        [    6, 50256, 50256, 50256, 50256],
        [    8,     9, 50256, 50256, 50256]])

在下一步中,我们会为所有填充词元都分配一个-100 占位符值(参见图 7-11 突出显示的部分)。这个特殊值使我们能够在计算训练损失时排除填充词元的影响,从而确保只有有效的数据会影响模型的学习。我们将在实现此修改后更详细地讨论这一过程。(值得说明的是,分类微调时无须担心这个问题,因为我们只根据最后的输出词元对模型进行训练。)

不过,值得注意的是,我们在目标列表中保留了一个结束符词元,ID 为 50256,如图 7-12 所示。保留此词元有助于大语言模型学会何时根据指令生成结束符词元,一般我们将其作为生成的回复已经完成的指示符。

在代码清单 7-5 中,我们修改了自定义聚合函数,以将目标列表中 ID 为 50256 的词元替换为-100。此外,我们还引入了一个 allowed_max_length 参数,以选择性地限制样本的长度。这一调整在处理超过 GPT-2 模型支持的 1024 个词元上下文大小的数据集时将非常有用。

python 复制代码
def custom_collate_fn(
    batch,
    pad_token_id=50256,
    ignore_index=-100,
    allowed_max_length=None,
    device="cpu"
):
    # 找出该批次中最长的序列长度(+1 为末尾要添加的 <|endoftext|> 词元预留位置)
    batch_max_length = max(len(item) + 1 for item in batch)

    # 用于存放处理后的输入序列和目标序列
    inputs_lst, targets_lst = [], []

    for item in batch:
        # 复制当前样本,避免修改原始数据
        new_item = item.copy()
        # 在序列末尾添加一个 <|endoftext|> 词元
        new_item += [pad_token_id]
        # 用填充词元把序列补齐到该批次的最大长度
        padded = (
            new_item + [pad_token_id] *
            (batch_max_length - len(new_item))
        )
        inputs = torch.tensor(padded[:-1])   # 去掉最后一个词元,作为输入
        targets = torch.tensor(padded[1:])   # 整体右移一位,作为目标(预测下一个词元)

        # 保留第一个填充词元,其余填充替换为 -100,计算损失时会被忽略
        # 例:targets = [40, 318, 257, 50256, 50256, 50256] → [40, 318, 257, 50256, -100, -100]
		mask = targets == pad_token_id              # [F, F, F, T, T, T]
		indices = torch.nonzero(mask).squeeze()     # [3, 4, 5]
		if indices.numel() > 1:
		    targets[indices[1:]] = ignore_index     # 保留第1个填充,其余改-100
		# 结果:targets = [40, 318, 257, 50256, -100, -100]

        # 可选:截断到允许的最大长度,适配 GPT-2 等模型的上下文限制(如 1024)
        if allowed_max_length is not None: # 假设 =4,截断到前4个
            inputs = inputs[:allowed_max_length]
            targets = targets[:allowed_max_length] # [40, 318, 257, 50256]

        inputs_lst.append(inputs)
        targets_lst.append(targets)

    # 将输入和目标列表堆叠成张量,并转移到目标设备(CPU 或 GPU)
    inputs_tensor = torch.stack(inputs_lst).to(device)
    targets_tensor = torch.stack(targets_lst).to(device)

    return inputs_tensor, targets_tensor

让我们在之前创建的样本批次上再尝试一下新的聚合函数,来看看它是否按预期工作:

python 复制代码
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]])

看起来修改后的聚合函数在正常工作,它成功地在目标列表对应位置插入了词元 ID -100。但是,这一调整背后的逻辑是什么呢?让我们探讨一下此修改的根本目的

为方便理解,可以考虑一个简单的示例,其中输出逻辑值(logits)的每一维都对应着模型词汇表中的一个潜在词元。下面的代码展示了在训练过程中交叉熵损失(参见第 5 章)是如何计算的,这一过程与我们在预训练和分类微调模型时的操作类似:

python 复制代码
logits_1 = torch.tensor(
    [[-1.0, 1.0],  # 1st training example
     [-0.5, 1.5]]  # 2nd training example
)
targets_1 = torch.tensor([0, 1])


loss_1 = torch.nn.functional.cross_entropy(logits_1, targets_1)
print(loss_1)
复制代码
tensor(1.1269)

正如预期的那样,增加一个额外的词元会影响损失的计算:

python 复制代码
logits_2 = torch.tensor(
    [[-1.0, 1.0],
     [-0.5, 1.5],
     [-0.5, 1.5]]  # New 3rd training example
)
targets_2 = torch.tensor([0, 1, 1])

loss_2 = torch.nn.functional.cross_entropy(logits_2, targets_2)
print(loss_2)
复制代码
tensor(0.7936)

在加入第三个词元后,损失值变成了 0.7936。

到目前为止,我们已经使用 PyTorch 的交叉熵损失函数进行了若干简单示例计算,这个损失函数正是我们在预训练和分类微调时使用的损失函数。接下来,来看一个有趣的情况:如果将第三个目标词元 ID 替换为-100,会发生什么呢?

python 复制代码
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)所对应的损失。(如果你对此感兴趣,可以尝试将-100 替换为其他非 0 或 1 的词元,你将会发现错误。)

那么,-100 究竟有什么特别之处,使交叉熵损失能够忽略它呢?原来,在 PyTorch 中,交叉熵函数的默认设置为 cross_entropy(..., ignore_index=-100)。这意味着它会忽略标记为-100 的目标。我们利用这个 ignore_index 来忽略那些用于填充训练示例以使每个批次具有相同长度的额外结束符(填充)词元。然而,我们需要在目标中保留结束符词元 ID 50256,因为它有助于大语言模型学习生成结束符词元,从而在适当的时候结束回复。

除了掩码填充词元,实践中我们通常还会掩码与指令相关的目标词元,如图 7-13 所示。通过掩码与指令对应的目标词元,交叉熵损失可以仅针对生成的回复目标词元进行计算。因此,模型的训练更专注于生成准确的回复,而非记住指令,这样可以帮助减少过拟合。

指令掩码:只对回复计算损失

训练数据会拼成一整段喂给模型,模型逐位置预测"下一个词",用交叉熵衡量准不准。以这条数据为例:

复制代码
指令:法国的首都是哪里?
回复:巴黎

拼成:法国 的 首都 是 哪里? 巴黎

不掩码------每个位置都算损失:

复制代码
法国            → 的      ✗ 算损失(在学背指令)
法国 的          → 首都    ✗ 算损失(在学背指令)
.......
法国 的 首都 是 哪里? → 巴黎  ✓ 算损失(在学回答)

前四行是在逼模型"背出指令后半句",可我们要的是它会回答 ,不是会复述问题

掩码 ------把指令部分的目标词元换成 -100ignore_index),损失跳过这些位置:

复制代码
法国            → -100    忽略
法国 的          → -100    忽略
法国 的 首都      → -100    忽略
法国 的 首都 是   → -100    忽略
法国 的 首都 是 哪里? → 巴黎  ✓ 只算这一处

注意指令照样作为输入被模型读到(预测"巴黎"需要先看懂问题),只是不再因"没背对指令"受罚。

好处:指令模板(如 "Below is an instruction...")在海量数据里反复出现,全算损失会让模型死记硬背、易过拟合。只对回复算损失,训练信号更干净,模型专注于生成准确回复。

截至目前,研究人员对在指令微调过程中是否应掩码指令部分的损失仍存在分歧。例如,Shi 等人在 2024 年发表的论文"Instruction Tuning With Loss Over Instructions"中指出,不掩码指令可以提升大语言模型的性能(详细信息参见附录 B)。在本节中,我们不掩码指令部分,并将掩码指令部分的实验作为一个可选的练习。


练习 7.2 指令与输入掩码

在完成本节内容,并使用 InstructionDataset 微调模型后,尝试将指令和输入部分的词 元替换为-100 来实践图 7-13 中的指令掩码方法。然后评估该方法是否会对模型的性能有益。

python 复制代码
import torch
from torch.utils.data import Dataset


class InstructionDataset(Dataset):
    def __init__(self, data, tokenizer):
        self.data = data

        ##########################################################################################
        # New: Separate list for instruction lengths
        self.instruction_lengths = []
        ##########################################################################################
        
        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)
            )

            ##########################################################################################
            # New: collect instruction lengths
            instruction_length = len(tokenizer.encode(instruction_plus_input))
            self.instruction_lengths.append(instruction_length)
            ##########################################################################################
            
    def __getitem__(self, index):
        # New: return both instruction lengths and texts separately
        return self.instruction_lengths[index], self.encoded_texts[index]

    def __len__(self):
        return len(self.data)
python 复制代码
def custom_collate_fn(
    batch,
    pad_token_id=50256,
    ignore_index=-100,
    allowed_max_length=None,
    device="cpu"
):
    # Find the longest sequence in the batch
    batch_max_length = max(len(item)+1 for instruction_length, item in batch)   # New: batch is now a tuple

    # Pad and prepare inputs and targets
    inputs_lst, targets_lst = [], []

    for instruction_length, item in batch:  # New: batch is now a tuple
        new_item = item.copy()
        # Add an <|endoftext|> token
        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

        # Replace all but the first padding tokens in targets by ignore_index
        mask = targets == pad_token_id
        indices = torch.nonzero(mask).squeeze()
        if indices.numel() > 1:
            targets[indices[1:]] = ignore_index

        ##########################################################################################
        # New: Mask all input and instruction tokens in the targets
        targets[:instruction_length-1] = -100
        ##########################################################################################
        
        # Optionally truncate to maximum sequence length
        if allowed_max_length is not None:
            inputs = inputs[:allowed_max_length]
            targets = targets[:allowed_max_length]
        
        inputs_lst.append(inputs)
        targets_lst.append(targets)

    # Convert list of inputs and targets to tensors and transfer to target device
    inputs_tensor = torch.stack(inputs_lst).to(device)
    targets_tensor = torch.stack(targets_lst).to(device)

    return inputs_tensor, targets_tensor
python 复制代码
sample_data = [
    {'instruction': "What is an antonym of 'complicated'?", 'input': '', 'output': "An antonym of 'complicated' is 'simple'."},
    {'instruction': 'Sort the following list in alphabetical order.', 'input': 'Zebra, Elephant, Crocodile', 'output': 'Crocodile, Elephant, Zebra'},
    {'instruction': 'Arrange the given numbers in descending order.', 'input': '5, 12, 8, 3, 15', 'output': '15, 12, 8, 5, 3.'}
]

from torch.utils.data import DataLoader

train_dataset = InstructionDataset(sample_data, tokenizer)
train_loader = DataLoader(
    train_dataset,
    batch_size=len(sample_data),
    collate_fn=custom_collate_fn,
    num_workers=0
)

print("Train loader:")
for inputs, targets in train_loader:
    print(inputs.shape, targets.shape)

print("Inputs:\n", inputs[1])
print("\n\nTargets:\n", targets[1])
复制代码
Train loader:
torch.Size([3, 64]) torch.Size([3, 64])
Inputs:
 tensor([21106,   318,   281, 12064,   326,  8477,   257,  4876,    13, 19430,
          257,  2882,   326, 20431, 32543,   262,  2581,    13,   198,   198,
        21017, 46486,    25,   198, 42758,   262,  1708,  1351,   287, 24830,
          605,  1502,    13,   198,   198, 21017, 23412,    25,   198,    57,
        37052,    11, 42651,    11,  9325, 19815,   576,   198,   198, 21017,
        18261,    25,   198,    34, 12204,   375,   576,    11, 42651,    11,
         1168, 37052, 50256, 50256])


Targets:
 tensor([ -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,
         -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,
         -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,
         -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,
         -100,  -100,  -100,  -100,  -100,  -100,   198,   198, 21017, 18261,
           25,   198,    34, 12204,   375,   576,    11, 42651,    11,  1168,
        37052, 50256,  -100,  -100])
python 复制代码
print(tokenizer.decode(list[int](inputs[1])))
复制代码
Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
Sort the following list in alphabetical order.

### Input:
Zebra, Elephant, Crocodile

### Response:
Crocodile, Elephant, Zebra<|endoftext|><|endoftext|>
python 复制代码
non_masked_targets = targets[1][targets[1] != -100]

print(tokenizer.decode(list(non_masked_targets)))
复制代码
### Response:
Crocodile, Elephant, Zebra<|endoftext|>

如上所示,未被掩码的目标词元(non-masked target tokens)不包含 Instruction(指令)和 Input(输入)字段,这正是我们想要的效果。现在,我们可以运行这段修改后的代码,来观察大语言模型在使用这种掩码策略进行微调后的表现如何。

"从分数可以看出,指令掩码(instruction masking)的表现确实略差一些,这与《Instruction Tuning With Loss Over Instructions》这篇论文中的观察结果一致。"

也就是说,这就印证了我上一条回答里提到的:把指令和输入部分掩码掉(替换成 -100),并不会提升性能,反而让平均分略微下降。所以练习 7.2 的答案是------这种指令掩码方法对模型性能没有帮助,甚至有轻微负面影响。


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

我们已经完成多个步骤,成功实现了用于指令数据集的 InstructionDataset 类和 custom_ collate_fn 函数。如图 7-14 所示,现在我们可以收获劳动成果了,只需将 InstructionDataset 对象和 custom_collate_fn 函数传入 PyTorch 数据加载器即可。在大语言模型的指令微调过程中,这些加载器将自动聚合并随机打乱用于迭代训练的数据。

在创建数据加载器之前,还需要讨论一下 custom_collate_fn 的设备设置。该函数包含将输入和目标张量(如 torch.stack(inputs_lst).to(device))移动到指定设备的代码,这个设备既可以是"cpu"或"cuda"(适用于 NVIDIA GPU),也可以是"mps"(适用于配备 Apple Silicon 芯片的 Mac)。

注意:使用 "mps" 设备可能会导致数值结果与本章内容存在差异, 因为 PyTorch 中对 Apple Silicon 的支持仍然处于实验阶段。

在之前的代码中,我们是在模型训练循环时才将数据移动到目标设备(例如,当device= "cuda"时,数据被移动到 GPU 内存)。现在,将这一过程写在聚合函数中带来了一些好处,因为它可以在训练循环之外的后台执行,从而避免在模型训练期间阻塞 GPU。

python 复制代码
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

这段代码将根据你的设备打印"Device: cpu"或"Device: cuda"。

为了在将 custom_collate_fn 函数应用于 PyTorch DataLoader 类时重用所选择的设备设置,我们利用 Python 的 functools 标准库中的 partial 函数创建该函数的新版本并预先填充设备参数。此外,可以将 allowed_max_length 设置为 1024,这样数据就会被截断到 GPT-2 模型支持的最大上下文长度,稍后我们将对其进行微调。

python 复制代码
from functools import partial

customized_collate_fn = partial(
    custom_collate_fn,
    device=device,
    allowed_max_length=1024
)

接下来,可以像我们之前所做的那样设置数据加载器,但是这次要使用自定义的聚合函数来做批处理,如代码清单 7-6 所示。

DataLoaderDataset 变成可以一批一批取数据的迭代器,主要做三件事:打乱数据、把单个样本组合成 batch、多进程并行加载。

常用参数:batch_size(每批多少样本)、shuffle(是否打乱)、num_workers(并行进程数)、drop_last(最后不满一批是否丢弃)、pin_memory(加速搬到 GPU)。

collate_fn 决定"如何把一批单样本拼成一个 batch"。你不指定时,PyTorch 会用默认的 collate_fn,它用 torch.stack 堆叠------前提是每个样本形状相同。图像任务里图片都 resize 成同样大小,所以从不需要自己写:

python 复制代码
# 4 张图片,每张 [3, 224, 224]
# 默认 collate_fn 自动堆成 [4, 3, 224, 224] 【batch_size=4】

文本任务不一样:序列长度不一致,torch.stack 会报错:

python 复制代码
样本1: [12, 45, 88]        # 长度 3
样本2: [7, 33, 90, 22, 5]  # 长度 5
样本3: [61, 2]             # 长度 2
# 形状不齐,无法 stack

所以要自定义 collate_fn 做 padding 补齐、截断(allowed_max_length)、生成 target(右移一位)、放到指定 device

python 复制代码
# padding 到统一长度 5(0 为填充 token)
样本1: [12, 45, 88, 0, 0]
样本2: [7, 33, 90, 22, 5]
样本3: [61, 2,  0,  0, 0]
# 现在可以堆成 [3, 5] 的整齐矩阵

因为自定义函数需要 deviceallowed_max_length 等额外参数,而 DataLoader 调用时只传 batch,所以用 functools.partial 把这些参数预先固定好:

python 复制代码
customized_collate_fn = partial(custom_collate_fn, device=device, allowed_max_length=1024)
# 等价于:只接受 batch 一个参数的新函数
loader = DataLoader(dataset, batch_size=8, collate_fn=customized_collate_fn)
python 复制代码
from torch.utils.data import DataLoader


num_workers = 0
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
)

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, 68]) torch.Size([8, 68])
torch.Size([8, 65]) torch.Size([8, 65])
torch.Size([8, 72]) torch.Size([8, 72])
torch.Size([8, 80]) torch.Size([8, 80])
torch.Size([8, 67]) torch.Size([8, 67])
torch.Size([8, 62]) torch.Size([8, 62])
torch.Size([8, 75]) torch.Size([8, 75])
torch.Size([8, 62]) torch.Size([8, 62])
torch.Size([8, 68]) torch.Size([8, 68])
torch.Size([8, 67]) torch.Size([8, 67])
torch.Size([8, 77]) torch.Size([8, 77])
torch.Size([8, 69]) torch.Size([8, 69])
torch.Size([8, 79]) torch.Size([8, 79])
torch.Size([8, 71]) torch.Size([8, 71])
torch.Size([8, 66]) torch.Size([8, 66])
torch.Size([8, 83]) torch.Size([8, 83])
torch.Size([8, 68]) torch.Size([8, 68])
torch.Size([8, 80]) torch.Size([8, 80])
torch.Size([8, 71]) torch.Size([8, 71])
torch.Size([8, 69]) torch.Size([8, 69])
torch.Size([8, 65]) torch.Size([8, 65])
...
torch.Size([8, 83]) torch.Size([8, 83])
torch.Size([8, 66]) torch.Size([8, 66])
torch.Size([8, 74]) torch.Size([8, 74])
torch.Size([8, 69]) torch.Size([8, 69])
Output is truncated. View as a scrollable element or open in a text editor. Adjust cell output settings...

num_workers=0:不开子进程,数据在主进程里加载。也就是说,训练主进程每次要用数据时,自己停下来去读、去处理,读完再继续训练。

num_workers>0(比如 4):开 4 个子进程在后台并行预取数据。主进程训练当前 batch 时,子进程已经在准备后面的 batch 了,减少 GPU 等待数据的时间。

该输出表明,第一个输入批次和目标批次的维度为 8×61,其中 8 是批次大小,61 是该批次中每个训练样本的词元数量。第二个输入批次和目标批次中的词元数量则有 76 个,与第一个不同。 由于我们使用了自定义的聚合函数,因此数据加载器能够创建不同长度的批次。在 7.5 节中,我们将加载一个预训练的大语言模型,并利用这个数据加载器对该模型进行微调。

python 复制代码
print(inputs[0])
复制代码
tensor([21106,   318,   281, 12064,   326,  8477,   257,  4876,    13, 19430,
          257,  2882,   326, 20431, 32543,   262,  2581,    13,   198,   198,
        21017, 46486,    25,   198, 30003,  6525,   262,  6827,  1262,   257,
          985,   576,    13,   198,   198, 21017, 23412,    25,   198,   464,
         5156,   318,   845, 13779,    13,   198,   198, 21017, 18261,    25,
          198,   464,  5156,   318,   355, 13779,   355,   257,  4936,    13,
        50256, 50256, 50256, 50256, 50256, 50256, 50256, 50256, 50256],
       device='mps:0')
python 复制代码
print(targets[0])
复制代码
tensor([  318,   281, 12064,   326,  8477,   257,  4876,    13, 19430,   257,
         2882,   326, 20431, 32543,   262,  2581,    13,   198,   198, 21017,
        46486,    25,   198, 30003,  6525,   262,  6827,  1262,   257,   985,
          576,    13,   198,   198, 21017, 23412,    25,   198,   464,  5156,
          318,   845, 13779,    13,   198,   198, 21017, 18261,    25,   198,
          464,  5156,   318,   355, 13779,   355,   257,  4936,    13, 50256,
         -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100,  -100],
       device='mps:0')

7.5 加载预训练的大语言模型

我们在准备用于指令微调的数据集上投入了大量时间,这是监督微调过程中的关键环节。指令微调的许多其他方面与预训练相似,因此我们可以重用之前章节中的大部分代码。

在开始指令微调之前,需要加载一个你希望进行微调的预训练 GPT 模型(参见图 7-15),加载过程与我们在前面章节中的操作一致。然而,这次我们不再使用参数量为 1.24 亿的最小的 GPT 模型,而是加载参数量为 3.55 亿的中等规模的 GPT 模型。这是因为参数量为 1.24 亿的模型容量过于有限,无法通过指令微调获得令人满意的结果。具体来说,较小的模型在学习高质量的指令遵循任务时,缺乏执行该任务所需的复杂模式和细微行为的能力

加载预训练模型所需的代码与预训练数据(参见 5.5 节)和文本分类微调(参见 6.4 节)时使用的代码相同,唯一不同的是,这次我们指定的是" gpt2-medium (355M) "而不是"gpt2-small (124M)",如代码清单 7-7 所示。

注意:执行代码清单 7-7 将开始下载中等规模的 GPT模型,其存储需求约为 1.42 GB。这大约是最小的 GPT 模型所需存储空间的 3 倍。

python 复制代码
from gpt_download import download_and_load_gpt2
from previous_chapters import GPTModel, load_weights_into_gpt
# If the `previous_chapters.py` file is not available locally,
# you can import it from the `llms-from-scratch` PyPI package.
# For details, see: https://github.com/rasbt/LLMs-from-scratch/tree/main/pkg
# E.g.,
# from llms_from_scratch.ch04 import GPTModel
# from llms_from_scratch.ch05 import download_and_load_gpt2, 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()

执行代码后,必需的文件会被下载到本地。

现在,让我们先花一些时间,通过将模型输出与预期的回复进行比较,来评估预训练的大语言模型在验证任务上的表现。这将为我们提供一个模型的基准性能指标,该指标反映了模型在未经微调的情况下在指令遵循任务中的表现情况,并能帮助我们更好地理解微调后的效果。下面我们将使用验证集中第一个样本进行评估:

python 复制代码
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.'

接下来,使用 generate 函数生成模型的回复,我们在第 5 章中使用过这个函数:

python 复制代码
from previous_chapters import (
    generate,
    text_to_token_ids,
    token_ids_to_text
)
# Alternatively:
# from llms_from_scratch.ch05 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 函数返回的是拼接在一起的输入和输出文本。这种返回值在之前的章节中非常有用,因为未经微调的大语言模型主要用来做文本补全,而将文本补全的输入和输出连接在一起便会形成连贯易读的文本。然而,当评估模型在特定任务上的表现时,我们通常希望仅关注模型生成的回复。

idx = torch.cat((idx, idx_next), dim=1) # (batch_size, num_tokens+1) 函数从头到尾都在往传入的 idx(也就是你的输入 prompt)后面追加新生成的 token,最后 return idx 返回的是输入 + 新生成内容拼在一起的完整序列。这就是generate函数的设计。

为了抽取模型的回复,需要在生成的文本 generated_text 中减去输入指令的长度:

python 复制代码
response_text = (
    generated_text[len(input_text):]
    .replace("### Response:", "")
    .strip()
)
print(response_text)

这段代码将输入文本从生成的文本开头移除,留下的仅是模型生成的回复。接下来,使用 strip() 函数去除字符串前后的空白字符,得到的输出如下所示:

复制代码
The chef cooks the meal every day.

### Instruction:

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

上述输出显示,预训练模型还不能正确遵循给定的指令。尽管它"有模有样"地生成了回复部分 ### Response,但只是简单地重复了输入的句子和部分指令,未能按照要求将主动句转换为被动句。因此,我们需要实施微调过程,以提升模型理解和正确回复此类请求的能力。

7.6 在指令数据上微调大语言模型

是时候对大语言模型进行指令微调了(参见图 7-16)。我们将利用 7.5 节中加载的预训练模型,并进一步使用本章早期准备的指令数据集对其进行训练。在处理指令数据集时我们已经花费了大量精力,因此接下来的微调过程可以复用第 5 章中介绍的损失计算和训练迭代函数。

python 复制代码
from previous_chapters import (
    calc_loss_loader,
    train_model_simple
)
# Alternatively:
# from llms_from_scratch.ch05 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.8259105682373047
Validation loss: 3.7619349479675295

硬件设备有限制该怎么办

使用和训练相对比较大的模型(如参数量为 3.55 亿的 GPT-2 medium)比使用较小的模型(如参数量为 1.24 亿的 GPT-2 small)更耗费计算资源。如果你的设备存在硬件限制,那么可以通过将 CHOOSE_MODEL = "gpt2-medium (355M)"改为 CHOOSE_MODEL = "gpt2-small (124M)" 来切换到更小的模型(参见 7.5 节)。如果不存在硬件限制,那么为了加速模型训练,请考虑使用 GPU。本书对应的代码仓库中列出了许多可用的云 GPU。


在准备好模型和数据加载器后,现在可以开始训练模型了。代码清单 7-8 中的代码设置了训练过程,包括初始化优化器、设定训练轮数、定义评估的频率和起始上下文(start_context)。 在这里,起始上下文是指在训练过程中,评估大语言模型在 7.5 节中介绍的第一个验证集指令(val_data0)上生成的回复。

python 复制代码
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.")

训练后的模型就是原来的 model 本身,它被原地(in-place)修改了,没有产生一个新的模型对象。

整个过程都在同一个 model 对象上进行,权重被不断更新覆盖。

以下输出展示了模型在两轮内的训练进展,其中损失的持续下降表明其在遵循指令和生成恰当回复方面的能力正在不断提升:

复制代码
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 000020): Train loss 0.776, Val loss 0.881
Ep 1 (Step 000025): Train loss 0.754, Val loss 0.859
Ep 1 (Step 000030): Train loss 0.800, Val loss 0.836
Ep 1 (Step 000035): Train loss 0.714, Val loss 0.809
Ep 1 (Step 000040): Train loss 0.672, Val loss 0.806
Ep 1 (Step 000045): Train loss 0.633, Val loss 0.789
Ep 1 (Step 000050): Train loss 0.663, Val loss 0.783
Ep 1 (Step 000055): Train loss 0.760, Val loss 0.763
Ep 1 (Step 000060): Train loss 0.719, Val loss 0.743
Ep 1 (Step 000065): Train loss 0.653, Val loss 0.735
Ep 1 (Step 000070): Train loss 0.535, Val loss 0.732
Ep 1 (Step 000075): Train loss 0.567, Val loss 0.736
Ep 1 (Step 000080): Train loss 0.602, Val loss 0.731
Ep 1 (Step 000085): Train loss 0.513, Val loss 0.715
Ep 1 (Step 000090): Train loss 0.571, Val loss 0.696
Ep 1 (Step 000095): Train loss 0.504, Val loss 0.687
Ep 1 (Step 000100): Train loss 0.507, Val loss 0.682
Ep 1 (Step 000105): Train loss 0.568, Val loss 0.674
Ep 1 (Step 000110): Train loss 0.562, Val loss 0.669
Ep 1 (Step 000115): Train loss 0.519, 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 000225): Train loss 0.346, Val loss 0.663
Ep 2 (Step 000230): Train loss 0.299, 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 everyday 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 3.35 minutes.
Output is truncated. View as a scrollable element or open in a text editor. Adjust cell output settings...

训练输出日志表明模型正在快速学习,因为在两轮内训练集和验证集的损失值持续下降,这表明模型逐渐提高了理解和遵循所给指令的能力。(由于模型在两轮内的损失已经降到较低的水平,因此延长训练到第三轮或更多轮并无必要,甚至可能适得其反,导致过拟合加剧。)

此外,每一轮结束时生成的回复让我们能够检查模型在验证集示例中正确执行给定任务的进展。在这个例子中,模型成功地将主动句"The chef cooks the meal every day."转化为了被动句 "The meal is cooked every day by the chef."

稍后我们将更详细地回顾和评估模型的回复质量。现在,让我们查看训练集损失曲线和验证集损失曲线,以便深入了解模型的学习过程。为此,我们将使用与预训练阶段相同的 plot_losses 函数。

python 复制代码
from previous_chapters import plot_losses
# Alternatively:
# from llms_from_scratch.ch05 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 文件的替代品,请考虑在 Alpaca 数据集上微调一个大语言模型。

Alpaca 数据集包含 52 002 条样本,大概是我们使用的数据集的 50 倍,而且大多数样本比我们的数据集长一些。因此,强烈建议使用 GPU 来完成训练,它将极大加速微调过程。如果你在微调过程中遇到了内存不足(out-of-memory)的错误,那么可以考虑将 batch_size 从 8 降低到 4、2 甚至是 1。将 allowed_max_length 从 1024 降低到 512 或 256 也可以帮助解决内存不足问题。

这个还得下载,在训练,就不做了


7.7 抽取并保存模型回复

我们已经在指令数据集的训练集上完成了对大语言模型的微调,现在要在模型未见过的测试集上评估模型的性能。首先,提取测试集中每个输入对应的模型生成的回复,并将这些回复收集起来进行人工分析。然后,对大语言模型进行评估以量化模型回复的质量,如图 7-18 所示。

为完成回复指令的步骤,可以使用 generate 函数。接下来,将模型的回复与测试集前面 3 个条目的预期回复并排打印,以便进行比较。

python 复制代码
torch.manual_seed(123)


for entry in test_data[:3]:

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

    print(input_text)
    print(f"\nCorrect response:\n>> {entry['output']}")
    print(f"\nModel response:\n>> {response_text.strip()}")
    print("-------------------------------------")
复制代码
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.
-------------------------------------
...

Model response:
>> The author of 'Pride and Prejudice' is Jane Austen.
-------------------------------------
Output is truncated. View as a scrollable element or open in a text editor. Adjust cell output settings...

前面提到过, generate 函数会返回拼接在一起的输入文本和输出文本,因此我们会对 generated_text 内容使用切片和.replace()方法,以提取模型的回复。以下是相关的指令、 测试集上的预期回复和模型的实际回复。

根据测试集的指令、预期回复和模型的实际回复,可以看出模型的表现相对不错。第一个指令和最后一个指令的答案都是正确的,而第二个指令的答案虽然与正确答案接近,但并不完全准确。模型给出的回答是"cumulus cloud"(积云),而不是"cumulonimbus"(积雨云)。不过,积云确实可以发展成积雨云,而积雨云能够引发雷暴。

更重要的是,模型评估并不像指令微调那样简单。在文本分类时,我们只需通过计算正确的垃圾消息与非垃圾消息分类标签的比例来获取准确性。然而,在实践中,对指令微调的大语言模型(如聊天机器人)的评估需要多种方法。

  • 短答案和多项选择的基准测试,比如"Measuring Massive Multitask Language Understanding"(MMLU),主要考查模型的综合知识。
  • 与其他大语言模型进行人类偏好比较,比如 LMSYS 聊天机器人竞技场。
  • 使用其他大语言模型(如 GPT-4)来自动评估回复的对话基准,比如 AlpacaEval。

在实际操作中,同时考虑这 3 种评估方法(多项选择问答、人类评估,以及衡量对话性能的自动化指标)是有必要的。不过,由于我们的重点是评估对话性能而不仅仅是模型回答多项选择问题的能力,因此人类评估和自动化指标可能更加相关。

指令微调模型的三种评估方法

1. 多项选择基准测试(如 MMLU)

给模型做标准化考试题(数学、历史、法律等几十个学科),每题有固定答案(A/B/C/D)。用模型选择与标准答案对比算正确率。完全自动、客观,但只考知识和推理,考不出对话质量。

2. 人类偏好比较(如 LMSYS Chatbot Arena)

同一问题让两个匿名模型各生成回答,真人投票选更好的一个,事后揭晓身份。用 Elo 评分算法把胜负转换成排行榜分数。最贴近人的真实偏好,但慢、贵、需海量投票。

3. LLM 自动评估(如 AlpacaEval)

把"人类裁判"换成强模型(如 GPT-4)当评委:给它指令 + 两个回答,让它判断哪个更好,最后算出胜率(win rate)。速度快、成本低,接近人类评估效果,但裁判模型可能有偏见(如偏爱更长回答)。

对比

  • 多选基准:标准答案裁判 / 考知识 / 快而便宜
  • 人类偏好:真人裁判 / 考综合对话体验 / 慢而贵
  • LLM 自动评估:强模型裁判 / 考对话质量 / 快而较便宜

关键点:这三种方法评估的是"回答整体好不好",而非"与参考答案逐字相同"。因此 loss 判不出对错的情况(如 bullet vs lightning 两个比喻都合理),靠人或 GPT-4 判断就能识别。自己训练模型时最实用的是方法三。


对话性能

大语言模型的对话性能是指它们在理解上下文、细微差别和意图的基础上,进行类似人 类沟通的能力。这种性能涵盖了多项技能,包括提供相关且连贯的回答、保持一致性,以及能够适应不同的主题和交流风格。


人类评估虽然能够提供宝贵的见解,但在处理大量回复时可能相对费时费力。例如,阅读并为所有 1100 个回复打分将需要花费大量的精力。

因此,考虑到当前任务的规模,我们将实施一种类似于自动化对话基准的方法,利用另一个大语言模型来自动评估回复。通过这种方法,我们可以高效地评估生成的回复质量,而不需要大量人力参与,从而节省时间和资源,同时仍能获得有意义的性能指标。

我们要采用的是一种受 AlpacaEval 启发的方法,使用另一个大语言模型来评估微调后的模型的回复。然而,与依赖公开的基准数据集不同,我们将使用自定义的测试集。这种定制化使我们能够在预期的用例背景下对模型性能进行更有针对性和相关性的评估。这些用例在我们的指令数据集中有所体现。

为了给评估过程准备预期回复,我们将生成的模型响应附加到 test_set 字典中,并将更新后的数据保存为 instruction-data-with-response.json 文件以便记录。此外,通过保存该文件,我们可以在后续的 Python 会话中轻松加载和分析这些响应。

代码清单 7-9 以与之前相同的方式使用了 generate 方法。然而,这次我们将遍历整个 test_set,并且不再只是打印模型回复,而是将它们添加到 test_set 字典中。

python 复制代码
from tqdm import tqdm

# test_data 是一个 list,里面有很多条数据,每条长这样:
# {
#     'instruction': 'Rewrite the sentence using a simile.',  # 指令:用明喻改写句子
#     'input': 'The car is very fast.',                       # 输入:原句
#     'output': 'The car is as fast as lightning.'            # 标准答案(人工写好的参考回复)
# }

# 遍历整个测试集 test_data,为每条数据生成模型回复
# tqdm 用来显示进度条,total 告诉它总共有多少条(比如 1100 条)
for i, entry in tqdm(enumerate(test_data), total=len(test_data)):

    # 把一条数据格式化成模型能理解的输入提示(prompt)
    # entry 就是上面那种字典,format_input 通常会用到 instruction 和 input 字段,
    # 拼成类似:
    #   "### Instruction:\nRewrite the sentence using a simile.\n\n### Input:\nThe car is very fast.\n\n### Response:\n"
    # 注意:这里不会把 output(标准答案)放进去,因为要让模型自己回答
    input_text = format_input(entry)

    # 让模型根据输入生成 token(模型内部用数字表示文字)
    token_ids = generate(
        model=model,
        # 把文字 input_text 转成 token 数字,再放到 GPU/CPU 上
        idx=text_to_token_ids(input_text, tokenizer).to(device),
        max_new_tokens=256,                        # 最多再生成 256 个新 token
        context_size=BASE_CONFIG["context_length"],
        eos_id=50256                               # 遇到结束符 50256 就停止生成
    )

    # 把生成的 token 数字再翻译回文字
    # 注意:generated_text 里同时包含了「输入提示 + 模型回复」
    # 例如 generated_text =
    #   "### Instruction:\nRewrite...### Response:\nThe car is as fast as a rocket."
    generated_text = token_ids_to_text(token_ids, tokenizer)

    # 只保留模型真正回复的部分:
    # 1) generated_text[len(input_text):] ------ 切掉前面重复的输入提示,只留回复
    # 2) .replace("### Response:", "")     ------ 去掉可能残留的 "### Response:" 标记
    # 3) .strip()                          ------ 去掉首尾多余的空格和换行
    # 结果 response_text = "The car is as fast as a rocket."
    response_text = generated_text[len(input_text):].replace("### Response:", "").strip()

    # 给 test_data 里第 i 条字典【新增】一个键 "model_response"
    # 原有的 instruction / input / output 都还在,只是多了一个模型自己的回答:
    # test_data[i] = {
    #     '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 rocket.'  # 模型的回答(新增)
    # }
    test_data[i]["model_response"] = response_text

# 循环结束后,test_data 里每条数据都同时有了「标准答案 output」和「模型回复 model_response」
# 把整个结果保存成 JSON 文件,方便以后加载,并用另一个大模型对比 output 和 model_response 来打分
with open("instruction-data-with-response.json", "w") as file:
    json.dump(test_data, file, indent=4)  # indent=4 让文件排版好看、易读

让我们通过检查其中一个测试样本来确认回复是否被成功加入到 test_set 字典中:

python 复制代码
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 文件中,以便将来的项目可以复用这个模型:

python 复制代码
import re


file_name = f"{re.sub(r'[ ()]', '', CHOOSE_MODEL) }-sft.pth"
torch.save(model.state_dict(), file_name)
print(f"Model saved as {file_name}")

# Load model via
# model.load_state_dict(torch.load("gpt2-medium355M-sft.pth"))
复制代码
Model saved as gpt2-medium355M-sft.pth

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

7.8 评估微调后的大语言模型

之前,我们通过查看指令微调模型在测试集的 3 个样本上的回复来评估其性能。虽然这种方法能大致了解模型的表现,但在处理大量回复时并不适用。因此,我们实现了一种方法,即利用另一个更强大的模型自动评估微调后的大语言模型的回复,如图 7-19 所示。

为实现自动化的测试集响应评估,我们使用了由 Meta AI 开发的现有的经过指令微调后参数量为 80 亿的 Llama3 模型。该模型可以通过开源的 Ollama 应用程序在本地运行。


注意

Ollama 是一款高效的应用程序,专为在笔记本电脑上运行大语言模型而设计。作为开源 llama.cpp 库的包装器,它旨在用纯 C/C++实现大语言模型,以最大限度提高效率。不过,Ollama 仅用于生成文本(推理),不支持大语言模型的训练或微调。


通过 Web API 来使用更大的模型

参数量为 80 亿的 Llama 3 模型是一款非常强大的本地可运行的大语言模型,但它的性能仍然不及 OpenAI 提供的 GPT-4 等大语言模型。如果你希望了解如何通过 OpenAI API 利用 GPT-4 来评估生成的模型回复,可以参考本书附带的补充材料,其中提供了一个可选的 Jupyter Notebook。


要执行接下来的代码,需要先下载并安装 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 存储空间的模型将会自动下载。输出如下所示。


可用的 Ollama 模型

ollama run llama3 命令中的 llama3 指的是经过指令微调的参数量为 80 亿的 Llama 3模型。使用 Ollama 搭配 Llama 3 模型大约需要 16 GB 的内存(RAM)。如果你的机器内存不足,那么可以尝试使用更小的模型,比如通过运行 ollama run phi3 来使用参数量为 38 亿的 Phi-3 模型,这个模型仅需大约 8 GB 的内存。 对于更强大的计算机,还可以通过将 llama3 替换为 llama3:70b 来使用参数量为 700 亿的更大的 Llama 3 模型。但需要注意的是,这个模型所需的计算资源会显著增加。


【省略掉ollama的代码,以及prompt,这些内容过于简单,ai/博客均可轻松解决】

模型打分时生成的解释表明,Llama 3 模型可以进行合理的评估,比如能够在模型的答案不完全正确时给予部分分数。例如,在对"cumulus cloud"(积云)答案的评估中,评估模型能够发现微调模型产生回复的部分正确性。

除了分数,之前的提示词让模型返回的是非常详细的评估。现在我们可以修改提示词,让模型仅生成从 0 到 100 的整数分数,其中 100 代表最佳分数。这样的修改使我们比较容易计算模型的平均分数,从而对其性能进行更简洁和定量的评估。下面的代码清单 7-11 中的 generate_ model_scores 函数使用了修改后的提示词"Respond with the integer number only." 要求模型仅返回整数。

评估结果表明,微调后的模型平均分数超过 50,这为与其他模型进行比较或尝试使用不同的训练配置来提升模型性能提供了有用的基准。

为了进一步提升模型的性能,也可以探索以下策略:

  • 在微调过程中调整超参数,比如学习率、批次大小或训练轮数;
  • 增加训练数据集的规模或多样化的示例,以涵盖更广泛的话题和风格;
  • 尝试不同的提示词或指令格式,以更有效地引导模型的回复;
  • 使用更大的预训练模型,以便更好地捕捉复杂模式并生成更准确的回复。

练习 7.4 使用 LoRA 进行参数高效微调

为了更高效地对大语言模型进行指令微调,请修改本章中的代码,并使用附录E中的低秩适应方法(LoRA)。然后比较修改前后的训练时间和模型性能。

这个部分单独出来写,额外的文章


7.9 结论

本章标志着我们的大语言模型开发旅程的结束。我们已经涵盖了所有重要的步骤,包括实现大语言模型架构、预训练大语言模型,以及针对特定任务对大语言模型进行微调。这些内容在图 7-21 中也进行了总结。

7.9.1 下一步

虽然本书已经介绍了最关键的步骤,但在指令微调后还有一个可选步骤:偏好微调。偏好微调非常适合定制模型,以便更好地满足特定用户的偏好。如果你想进一步了解这方面的内容,可以访问本书 GitHub 仓库中的 04_preference-tuning-with-dpo 文件夹。

除了本书所涵盖的主要内容,GitHub 仓库中还有许多你可能会觉得有用的补充材料。如果你想学习这些额外的资源,请访问仓库 README 页面的附加材料(Bonus Material)部分。

7.9.2 跟上领域的最新进展

人工智能和大语言模型研究领域正在飞速(而且根据不同的人的看法,可能是令人兴奋的)发展。跟上最新进展的一种方式是浏览 arXiv 上的最新研究论文。此外,许多研究人员和从业者在社交媒体平台如 X(前 Twitter)和 Reddit上非常活跃,经常分享和讨论最新的发展动态。特别是 r/LocalLLaMA 这个 Reddit 子版块,它是一个很好的资源,能够帮助你与社区建立联系,并随时了解最新的工具和趋势。我也会定期分享见解,并在我的博客上撰写关于大语言模型研究的最新内容。

7.9.3 写在最后

希望你在从头开始实现一个大语言模型,以及编写预训练与微调函数的过程中获得了乐趣。 在我看来,从零开始构建大语言模型是深入理解大语言模型如何工作的最佳方式。希望这种实践方法能为你提供有价值的见解,并为大语言模型的开发打下坚实的基础。

虽然本书的主要目的是教育,但你可能会对在现实世界的应用程序中使用不同且更强大的大语言模型感兴趣。为此,我推荐你了解一些流行的工具,比如 Axolotl 或 LitGPT,我也在积极参与这些工具的开发。

感谢你和我一起踏上这段学习旅程,祝你在大语言模型和人工智能这个激动人心的领域中未来一切顺利!

7.10 小结

  • 指令微调的过程是将预训练的大语言模型调整为能够遵循人类的指令并生成所需的回复。
  • 准备数据集的步骤包括下载指令-回复数据集、整理数据格式,以及将其拆分为训练集、 验证集和测试集。
  • 训练批次是通过自定义聚合函数构建的,该函数负责填充序列、创建目标词元 ID,并掩码填充词元。
  • 我们加载了一个参数量为 3.55 亿的预训练 GPT-2 medium 模型来作为指令微调的起始点。
  • 预训练模型在指令数据集上进行微调,使用的是与预训练相似的训练循环方法。
  • 评估阶段包括从测试集中提取模型的回复并对其进行评分(例如,使用另一个大语言模型进行评分)。
  • Ollama 应用程序配备了一个参数量为 80 亿的 Llama 模型,可以自动对微调模型在测试集上产生的回复进行评分,并提供一个平均分以量化性能。
相关推荐
数字融合1 小时前
透明化视场时空配准视频孪生多视域空间融合技术
人工智能·音视频·virtualenv
手写码匠1 小时前
华为云Flexus+DeepSeek征文|DeepSeek+RAG知识库实战:基于Flexus X实例与Dify构建企业级智能问答系统
人工智能·深度学习·算法·aigc
发量惊人的中年网工2 小时前
工业视觉云边 AI 推理总是卡顿?SD-WAN 打通边缘算力互联
网络·人工智能·网络安全·云计算
小白的后端世界2 小时前
LangChain 模型创建与调用实战:从 init_chat_model 到企业级多模型接入
人工智能·python·langchain
AC赳赳老秦2 小时前
软著公开信息批量采集:OpenClaw 抓取软件著作权公开数据,分析企业技术布局方向
大数据·网络·人工智能·python·php·deepseek·openclaw
天天代码码天天2 小时前
一张照片生成可调用的 3D 人脸:3DDFA-V3 C++ DLL 与 C# Demo 实战
人工智能
国际云,接待2 小时前
AWS账单防爆雷实战:Budgets、Cost Anomaly Detection与预算动作联动
人工智能·aws·twitter·finops·云成本·budgets
用户125758524362 小时前
进销存后台别急着上线,先重放一次退货请求
人工智能·后端·go