当前位置: 首页 > article >正文

用 pytorch 从零开始创建大语言模型(七):根据指示进行微调

用 pytorch 从零开始创建大语言模型(七):根据指示进行微调

  • 7 根据指示进行微调
    • 7.1 指令微调简介
    • 7.2 为监督式指令微调准备数据集
    • 7.3 将数据组织成训练批次
    • 7.4 为指令数据集创建数据加载器
    • 7.5 加载预训练LLM
    • 7.6 在指令数据上微调 LLM
    • 7.7 提取并保存模型响应
    • 7.8 评估微调后的LLM
    • 7.9 结论
      • 7.9.1 接下来的方向
      • 7.9.2 在快速发展的领域中保持更新
      • 7.9.3 最后的话
  • 总结

7 根据指示进行微调

本章内容包括:

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

在这里插入图片描述图7.1 编写LLM的三个主要阶段。本章聚焦于第3阶段的第9步:对一个预训练的LLM进行微调,使其能够遵循人类指令。


图7.1展示了对LLM进行微调的两种主要方式:用于分类的微调(第8步)和用于遵循指令的微调(第9步)。我们已经在第6章中实现了第8步。现在我们将使用一个指令数据集对LLM进行微调。

7.1 指令微调简介

我们现在知道,预训练一个LLM涉及一种训练过程,在该过程中它学习一次生成一个词。结果得到的预训练LLM具备文本补全(text completion)能力,也就是说,它可以在给定一个片段作为输入的情况下完成句子或撰写段落。然而,预训练的LLM通常难以处理具体的指令,例如“修正这段文本的语法”或“将这段文字改写为被动语态”。稍后,我们将研究一个具体的例子,其中我们加载预训练LLM作为指令微调(instruction fine-tuning,)的基础,这也被称为监督式指令微调(supervised instruction fine-tuning)。

在这里,我们的重点是提升LLM执行此类指令并生成期望响应的能力,如图7.2所示。数据集的准备是指令微调的关键环节。随后我们将完成指令微调过程中三个阶段中的所有步骤,从图7.3所示的数据集准备开始。


在这里插入图片描述图7.2 一些指令的示例,这些指令由LLM处理以生成期望的响应。



在这里插入图片描述图7.3 LLM指令微调的三个阶段流程。第一阶段涉及数据集准备,第二阶段聚焦于模型设置与微调,第三阶段涵盖模型的评估。我们将从第一阶段的第1步开始:下载和格式化数据集。


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

让我们下载并格式化用于指令微调预训练LLM的数据集。该数据集包含1100对指令–响应(instruction–response)样本,类似于图7.2中的示例。本书专门为此创建了该数据集,但感兴趣的读者可以在附录B中找到其他公开可用的指令数据集。

以下代码实现并执行了一个函数,用于下载该数据集,它是一个相对较小的文件(仅204 KB),采用JSON格式。JSON,即JavaScript对象表示法,其结构类似于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)

    # The book originally contained this unnecessary "else" clause:
    #else:
    #    with open(file_path, "r", encoding="utf-8") as file:
    #        text_data = file.read()

    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文件中加载的数据列表包含该指令数据集的1100条记录。我们来打印其中一条记录,以查看每条记录的结构是怎样的:

print("Example entry:\n", data[50])

示例条目的内容是:

Example entry:
 {'instruction': 'Identify the correct spelling of the following word.', 'input': 'Ocassion', 'output': "The correct spelling is 'Occasion.'"}

正如我们所看到的,示例条目是包含’instruction’(指令)、‘input’(输入)和’output’(输出)字段的Python字典对象。我们再来看一个例子:

print("Another example entry:\n", data[999])

根据该条目的内容,'input'字段有时可能是空的:

Another example entry:
{'instruction': "What is an antonym of 'complicated'?",
 'input': '',
 'output': "An antonym of 'complicated' is 'simple'."}

指令微调(instruction fine-tuning)是指在一个包含明确输入-输出对的数据集上对模型进行训练,就像我们刚刚从JSON文件中提取的这些数据一样。对于这些条目的格式化方式有多种方法可供选择。图7.4展示了两个不同的示例格式,这些格式通常被称为“提示风格”(prompt styles),并被用于训练一些著名的大语言模型,例如Alpaca和Phi-3。

Alpaca是最早公开说明其指令微调过程的大语言模型之一。Phi-3是由微软开发的,用于展示不同的提示风格。在本章的后续内容中,我们将采用Alpaca提示风格,因为它是最受欢迎的格式之一,很大程度上是因为它帮助定义了指令微调的原始方法。


在这里插入图片描述图7.4 不同提示风格在指令微调中的比较 Alpaca风格(左)采用结构化格式,明确划分了instruction、input和response等部分;而Phi-3风格(右)则采用更简洁的格式,使用<|user|>和<|assistant|>等标记进行区分。



练习 7.1 更改提示风格
在使用Alpaca提示风格对模型进行微调之后,尝试使用图7.4中展示的Phi-3提示风格,并观察它是否会影响模型的回答质量。

假设我们有如下数据项:

{
  "instruction": "Identify the correct spelling of the following word.",
  "input": "Ocassion",
  "output": "The correct spelling is 'Occasion.'"
}

在本章正文中,我们按照Alpaca风格的提示模板对其进行了格式化:

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.'

在本练习中,我们现在改为使用Phi-3提示模板进行格式化,该模板将该数据项格式化如下:

<user>  
Identify the correct spelling of the following word: 'Occasion'

<assistant>  
The correct spelling is 'Occasion'.

请注意,这种提示模板明显更简洁,这减少了微调LLM和生成文本时的运行时间与硬件需求,因为输入提示更短。为了实现这一变化,我们更新了format_input函数如下:

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

让我们把它应用于两个输入样本,一个在 'input' 字段中有内容,另一个在 'input' 字段中没有内容,以确保它能按预期运行:

sample_data = [
    {'instruction': 'Identify the correct spelling of the following word.', 'input': 'Ocassion', 'output': "The correct spelling is 'Occasion.'"}, 
    {'instruction': "What is an antonym of 'complicated'?", 'input': '', 'output': "An antonym of 'complicated' is 'simple'."}
]

print(format_input(sample_data[0]))
print()
print(format_input(sample_data[1]))
<|user|>
Identify the correct spelling of the following word.
Ocassion

<|user|>
What is an antonym of 'complicated'?

接下来,我们还需要更新 InstructionDataset 类,使其在生成响应时使用 <|assistant|> 提示模板:

import tiktoken
from torch.utils.data import Dataset

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

        # 预编码文本
        self.encoded_texts = []
        for entry in data:

            ###################################################################
            # 新增:使用 `format_input_phi` 并调整响应文本模板
            instruction_plus_input = format_input(entry)
            response_text = f"\n<|assistant|>:\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)
tokenizer = tiktoken.get_encoding("gpt2")

最后,我们还必须更新提取测试集响应的方式,以匹配 <|assistant|> 模板:

for i, entry in tqdm(enumerate(test_data), total=len(test_data)):

    input_text = format_input(entry)
    tokenizer = tokenizer

    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 改为 <|assistant|>
    response_text = generated_text[len(input_text):].replace("<|assistant|>:", "").strip()

    test_data[i]["model_response"] = response_text

为了方便,请直接下载下面文件运行就好!

  • exercise_experiments.py
  • gpt_download.py
  • previous_chapters.py

提示:考虑特殊标记

  • 请注意,Phi-3 提示模板包含一些特殊标记,例如 <|user|> 和 <|assistant|>,这些标记对于 GPT-2 的分词器来说可能不是最优的。

  • 虽然 GPT-2 分词器可以识别 <|endoftext|> 这一特殊标记(它被编码为 token ID 50256),但它在处理其他特殊标记(如上述标记)时效率较低。

  • 例如,<|user|> 会被编码成 5 个独立的 token ID(27, 91, 7220, 91, 29),这显然是非常低效的。

  • 我们可以通过 allowed_special 参数在 tiktoken 中将 <|user|> 添加为一个新的特殊标记,但请注意,GPT-2 的词汇表本身是无法处理这个新标记的,除非对其进行额外的修改。

  • 如果你对分词器和 LLM 如何扩展以处理特殊标记感兴趣,可以参考扩展内容 extend-tiktoken.ipynb(请注意,这并不是本章的要求内容,而是为感兴趣的读者准备的附加材料)。

  • 此外,我们可以假设,原生支持这些提示模板中的特殊标记的模型(即词汇表中原本包含这些标记的模型),在处理这些输入时可能效率更高、性能更好。


让我们定义一个 format_input function 来将数据列表中的条目转换为 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 指令微调 LLM 的三阶段过程。接下来,我们看看第 1 阶段的第 2 步:组装训练批次。


在上一章中,训练批次是通过 PyTorch 的 DataLoader 类自动创建的,该类使用默认的 collate 函数将样本列表合并为批次。collate 函数负责将一个个独立的数据样本合并成一个可以被模型高效处理的批次,用于训练过程。

然而,在指令微调中,批处理过程稍微复杂一些,需要我们自定义一个 collate 函数,并在后续将其传入 DataLoader 中使用。我们之所以要实现这个自定义的 collate 函数,是为了处理指令微调数据集的特定格式和需求。

我们将分几个步骤来处理批次构建的问题,包括编写这个自定义的 collate 函数,如图 7.6 所示。首先,为了实现步骤 2.1 和 2.2,我们编写一个 InstructionDataset 类,它会调用 format_input 函数并对数据集中的所有输入进行预编码(pretokenize),这与第 6 章中的 SpamDataset 类似。这个包含两个步骤的过程,如图 7.7 所示,在 InstructionDataset__init__ 构造函数中实现。


在这里插入图片描述图 7.6 实现批处理过程的五个子步骤:(2.1)应用提示模板;(2.2)使用前几章介绍的分词方法;(2.3)添加填充标记;(2.4)创建目标 token ID;(2.5)将占位符 token -100 替换,用于在损失函数中屏蔽填充 token。



在这里插入图片描述图 7.7 实现批处理过程涉及的前两个步骤。首先使用特定的提示模板对条目进行格式化(2.1),然后进行分词处理(2.2),最终得到一串模型可以处理的token ID序列。


清单 7.4 实现指令数据集类

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|>作为填充用的token。

与其在文本输入后附加<|endoftext|>字符串,不如直接在预分词后的输入末尾附加对应的token ID。我们可以对<|endoftext|>使用tokenizer的.encode方法,以确认我们应该使用哪个token ID:

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

输出的token ID是50256

接下来进入流程的步骤2.3(见图7.6),我们采用一种更为复杂的方法,开发一个自定义的collate函数,并将其传递给数据加载器。这个自定义的collate函数会将每个批次中的训练样本填充到相同的长度,同时允许不同的批次拥有不同的长度,如图7.8所示。这种方法最大限度地减少了不必要的填充,因为它仅将序列扩展至各自批次中最长的样本长度,而不是整个数据集中最长的序列长度。


在这里插入图片描述图 7.8 使用标记 ID 50256 分批填充训练示例,以确保每批示例长度一致。每个批次可能有不同的长度,如第一和第二批所示。


我们可以使用自定义整理函数来实现填充过程:

def custom_collate_draft_1(
    batch,
    pad_token_id=50256,
    device="cpu"
):
    # Find the longest sequence in the batch
    # and increase the max length by +1, which will add one extra
    # padding token below
    batch_max_length = max(len(item)+1 for item in batch)

    # Pad and prepare inputs
    inputs_lst = []

    for item in batch:
        new_item = item.copy()
        # Add an <|endoftext|> token
        new_item += [pad_token_id]
        # Pad sequences to batch_max_length
        padded = (
            new_item + [pad_token_id] *
            (batch_max_length - len(new_item))
        )
        # Via padded[:-1], we remove the extra padded token
        # that has been added via the +1 setting in batch_max_length
        # (the extra padding token will be relevant in later codes)
        inputs = torch.tensor(padded[:-1])
        inputs_lst.append(inputs)

    # Convert list of inputs to tensor and transfer to target device
    inputs_tensor = torch.stack(inputs_lst).to(device)
    return inputs_tensor

我们实现的 custom_collate_draft_1 函数旨在集成到PyTorch的 DataLoader 中使用,但它也可以作为独立工具运行。在这里,我们将独立使用它,以测试并验证它是否按预期工作。让我们试试将三个不同的输入组合成一个批次的操作,在这个过程中,每个样本都将被填充至相同的长度:

inputs_1 = [0, 1, 2, 3, 4]
inputs_2 = [5, 6]
inputs_3 = [7, 8, 9]

batch = (
    inputs_1,
    inputs_2,
    inputs_3
)

print(custom_collate_draft_1(batch))

最后得到的批次如下:

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

输出结果显示,所有输入都已填充到最长输入列表 inputs_1 的长度,其中包含五个标记 ID。

我们刚刚实现了第一个自定义的 collate 函数,用于将输入列表构建成批次。然而,正如我们之前所学的,我们还需要创建与输入 token ID 批次相对应的目标 token ID 批次。正如图 7.9 所示,这些目标 ID 至关重要,因为它们代表了我们希望模型生成的内容,也是我们在训练期间计算损失、以便更新权重时所需要的内容。也就是说,我们要修改自定义的 collate 函数,使其在返回输入 token ID 的同时,也返回目标 token ID。


在这里插入图片描述图 7.9 展示了实现批处理流程的五个子步骤。我们现在聚焦于第2.4步——创建目标 token ID。这一步至关重要,因为它使模型能够学习并预测需要生成的 token。


与我们用于预训练 LLM 的过程类似,目标 token ID 与输入 token ID 相同,但向右偏移一个位置。正如图 7.10 所示,这种设置使得 LLM 能够学习如何预测序列中的下一个 token。


在这里插入图片描述图 7.10 LLM 指令微调过程中输入和目标 token 的对齐方式。对于每个输入序列,相应的目标序列是通过将 token ID 向右移动一个位置创建的,即省略输入的第一个 token,并在末尾附加一个 end-of-text token。


以下更新的校对函数根据输入令牌 ID 生成目标令牌 ID:

def custom_collate_draft_2(
    batch,
    pad_token_id=50256,
    device="cpu"
):
    # Find the longest sequence in the batch
    batch_max_length = max(len(item)+1 for item in batch)

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

    for item in batch:
        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
        inputs_lst.append(inputs)
        targets_lst.append(targets)

    # Convert list of inputs to tensor 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
    
inputs, targets = custom_collate_draft_2(batch)
print(inputs)
print(targets)

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

下一步中,我们将所有填充 token 替换为占位值 − 100 -100 100,如图 7.11 所示。这个特殊值使我们能够在计算训练损失时排除这些填充 token,从而确保只有有意义的数据影响模型的学习。我们将在实现该修改后更详细地讨论此过程。(在进行分类微调时,我们无需担心这一点,因为我们只根据最后一个输出 token 来训练模型。)

但请注意,如图 7.12 所示,我们在目标序列中保留了一个 end-of-text token(ID 为 50256 50256 50256)。保留该 token 的原因在于,它能帮助 LLM 学习在何时生成 end-of-text token 作为对指令的响应的终止标志,这是我们判断生成响应已完成的重要依据。

在以下代码中,我们将修改自定义的 collate 函数,使其在目标列表中将 ID 为 50256 50256 50256 的填充 token 替换为 − 100 -100 100。此外,我们还引入了一个可选参数 allowed_max_length,用于限制样本的最大长度。如果你打算使用自己的数据集,而这些数据超过了 GPT-2 所支持的 1024 1024 1024 token 的上下文长度限制,这一参数将非常有用。


在这里插入图片描述图 7.11 实现批处理流程的五个子步骤。在通过向右移动一个位置并附加 end-of-text token 创建目标序列之后,在第 2.5 步中,我们将目标序列中的 end-of-text 填充 token 替换为占位值 − 100 -100 100



在这里插入图片描述图 7.12 训练数据准备中,目标 batch 的 token 替换流程第 2.4 步。我们将目标序列中除第一个 end-of-text token 以外的所有该类 token(作为填充使用)替换为占位值 − 100 -100 100,而保留每个目标序列中的首个 end-of-text token。


清单 7.5 实现自定义批处理整理函数

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 item in batch)

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

    for item in batch:
        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

        # New: 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: 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

同样,让我们在之前创建的样本批次上试用一下整理功能,以检查它是否能正常工作:

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

修改后的 collate 函数运行效果如预期,它通过插入 token ID − 100 -100 100 来调整目标列表。那么,这一调整背后的逻辑是什么?我们来探究这个修改的根本目的。

为了演示,我们考虑一个简单且自包含的例子,其中每个输出 logit 对应模型词汇表中的一个潜在 token。下面是我们在模型训练期间计算交叉熵损失(在第 5 章中介绍过)的方法,该方法类似于我们在预训练和分类微调中所使用的方法:

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)

上面代码计算出的损失值为 1.1269 1.1269 1.1269

tensor(1.1269)

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

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)

添加第三个 token 后,损失值为 0.7936 0.7936 0.7936

到目前为止,我们使用 PyTorch 的交叉熵损失函数进行了几个较为直观的示例计算,这也是我们在预训练和分类微调训练函数中所用的损失函数。接下来进入重点:我们将第三个目标 token ID 替换为 − 100 -100 100 会发生什么?

targets_3 = torch.tensor([0, 1, -100])
loss_3 = torch.nn.functional.cross_entropy(logits_2, targets_3)
print(loss_3)
print("loss_1 == loss_3:", loss_1 == loss_3)

输出为:

tensor(1.1269)
loss_1 == loss_3: tensor(True)

这三个训练样本的最终损失与我们之前仅使用两个训练样本计算得到的损失是相同的。换句话说,交叉熵损失函数忽略了 targets_3 向量中对应 − 100 -100 100 的那一项 token ID。(感兴趣的读者可以尝试将 − 100 -100 100 替换为 0 0 0 1 1 1 以外的任意 token ID,会输出 loss_1 == loss_3: tensor(False)。)

那么为什么 − 100 -100 100 会被交叉熵损失忽略?这是因为 PyTorch 中交叉熵函数的默认设置为:

cross_entropy(..., ignore_index=-100)

这意味着会忽略目标中被标记为 − 100 -100 100 的项。我们正是利用了这一 ignore_index 设置来忽略我们在训练示例中添加的 padding 用的 end-of-text token,使它们在每个 batch 中具有相同长度。

不过,我们仍然保留一个 50256 50256 50256(即 end-of-text)token ID 在目标序列中,因为这能帮助 LLM 学会在响应完成时生成 end-of-text token,从而作为一个标志。

除了屏蔽 padding token 以外,还常见的一种做法是屏蔽目标序列中对应指令部分的 token ID,如图 7.13 所示。通过屏蔽指令部分对应的 target token ID,交叉熵损失只在生成的响应部分的 target ID 上进行计算。这样,模型训练将更加专注于生成准确的响应,而不是记忆指令文本,从而帮助减少过拟合。


在这里插入图片描述图 7.13 左侧:我们在训练期间对格式化后的输入文本进行标记化,然后将其输入到 LLM。图 7.13 右侧:我们为 LLM 准备的目标文本,其中可以选择性地屏蔽指令部分,这意味着用 − 100 -100 100 作为 ignore_index 值来替换相应的 token ID。


截至本文撰写时,研究人员对在指令微调过程中是否应屏蔽指令存在分歧。例如,2024 年 Shi 等人的论文 “Instruction Tuning With Loss Over Instructions”(https://arxiv.org/abs/2405.14394)表明,不屏蔽指令有利于 LLM 的性能提升(更多细节请参阅附录 B)。在本章中,我们不会应用屏蔽处理,而是将其作为一个可选练习,供感兴趣的读者尝试。


练习 7.2 指令和输入屏蔽
在完成本章并使用 InstructionDataset 对模型进行微调后,将指令和输入 token 替换为 − 100 -100 100 掩码,以使用图 7.13 所示的指令屏蔽方法。然后评估此方法是否对模型性能产生正面影响。

为了屏蔽指令(如图所示),我们需要对InstructionDataset类和custom_collate_fn进行轻微修改。

# 这个 `format_input` 函数是从第 7 章的原始代码中复制的

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

我们可以修改InstructionDataset类,使其收集指令的长度,以便在编写collate函数时用于定位目标中的指令内容位置,如下所示:

import torch
from torch.utils.data import Dataset

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

        ##########################################################################################
        # 新增:用于存储指令长度的单独列表
        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)
            )

            ##########################################################################################
            # 新增:收集指令长度
            instruction_length = len(tokenizer.encode(instruction_plus_input))
            self.instruction_lengths.append(instruction_length)
            ##########################################################################################

    def __getitem__(self, index):
        # 新增:分别返回指令长度和文本
        return self.instruction_lengths[index], self.encoded_texts[index]

    def __len__(self):
        return len(self.data)
import tiktoken

tokenizer = tiktoken.get_encoding("gpt2")

接下来,我们更新custom_collate_fn,其中每个批次现在包含(instruction_length, item)的元组,而不仅仅是item,并且在目标 ID 列表中屏蔽对应的指令 token。

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 instruction_length, item in batch)   # 新增:批次现在是元组

    # 填充并准备输入和目标
    inputs_lst, targets_lst = [], []

    for instruction_length, 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:])  # 目标右移一位

        # 将目标中除第一个填充 token 之外的所有填充 token 替换为 `ignore_index`
        mask = targets == pad_token_id
        indices = torch.nonzero(mask).squeeze()
        if indices.numel() > 1:
            targets[indices[1:]] = ignore_index

        ##########################################################################################
        # 新增:屏蔽所有输入和指令 token 在目标中的位置
        targets[:instruction_length-1] = -100
        ##########################################################################################

        # 可选:截断到最大序列长度
        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)

    # 将输入和目标列表转换为张量并传输到目标设备
    inputs_tensor = torch.stack(inputs_lst).to(device)
    targets_tensor = torch.stack(targets_lst).to(device)

    return inputs_tensor, targets_tensor

让我们在一些示例数据上尝试它:

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)

输出:

Train loader:
torch.Size([3, 64]) torch.Size([3, 64])
print("Inputs:\n", inputs[1])
print("\n\nTargets:\n", targets[1])

输入:

tensor([...])  # 省略具体 token ID

目标:

tensor([-100, -100, ..., 50256, -100])

让我们解码输入以确保它们正确无误:

print(tokenizer.decode(list(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|>

解码未屏蔽的目标 token ID:

non_masked_targets = targets[1][targets[1] != -100]
print(tokenizer.decode(list(non_masked_targets)))

输出:

### Response:
Crocodile, Elephant, Zebra<|endoftext|>

如上所示,未屏蔽的目标 token 排除 "Instruction""Input" 部分,符合预期。现在,我们可以运行修改后的代码,以观察 LLM 在采用此屏蔽策略时的表现。

可以使用 exercise_experiments.py 代码进行对比实验:

python exercise_experiments.py --exercise_solution mask_instructions

输出示例:

...
Training completed in 1.77 minutes.
Plot saved as loss-plot-mask-instructions.pdf
...
Average score: 47.73

如我们所见,使用指令屏蔽的 LLM 在实验中表现稍逊于未屏蔽的版本,这与论文 “Instruction Tuning With Loss Over Instructions”(https://arxiv.org/abs/2405.14394)的观察一致。


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

我们已经完成了多个步骤,实现了InstructionDataset类和custom_collate_fn函数用于指令数据集。正如图7.14所示,我们现在可以直接将InstructionDataset对象和custom_collate_fn函数插入到PyTorch数据加载器中,从而自动对批次进行洗牌和组织,以便LLM的指令微调过程。


在这里插入图片描述图7.14 LLM指令微调的三阶段流程。目前,我们已经完成了数据集准备,并实现了自定义collate函数用于批量处理指令数据集。现在,我们可以创建并应用数据加载器,以便对训练集、验证集和测试集进行组织,从而用于LLM指令微调和评估。


在实现数据加载器创建步骤之前,我们需要简要讨论custom_collate_fn的设备设置。custom_collate_fn包含代码,将输入和目标张量(例如torch.stack(inputs_lst).to(device))移动到指定的设备上,该设备可以是 "cpu""cuda"(用于NVIDIA GPU),或者可选的"mps"(适用于搭载Apple Silicon芯片的Mac设备)。

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

此前,我们在主训练循环中将数据移动到目标设备(例如,当device="cuda"时,将数据传输到GPU内存)。将该过程作为collate函数的一部分的优势在于,它可以在后台执行数据传输,而不会阻塞GPU的模型训练。

以下代码用于初始化device变量:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Note:
# Uncommenting the following lines will allow the code to run on Apple Silicon chips, if applicable,
# which is much faster than on an Apple CPU (as measured on an M3 MacBook Air).
# However, the resulting loss values may be slightly different.

#if torch.cuda.is_available():
#    device = torch.device("cuda")
#elif torch.backends.mps.is_available():
#    device = torch.device("mps")
#else:
#    device = torch.device("cpu")

print("Device:", device)

这将根据你的计算机配置输出 "Device:cpu""Device:cuda"

接下来,为了在将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
)

接下来,我们可以像之前一样设置数据加载器,但这次我们将使用自定义的collate函数来进行批处理。

清单 7.6 初始化数据加载器

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, 74]) torch.Size([8, 74])
torch.Size([8, 69]) torch.Size([8, 69])

此输出表明,第一个输入和目标批次的维度为 8 × 61 8\times61 8×61,其中 8 8 8表示批量大小, 61 61 61是该批次中每个训练样本的标记数。第二个输入和目标批次的标记数不同,例如 76 76 76。得益于我们的自定义collate函数,数据加载器能够创建长度不同的批次。在下一节中,我们将加载一个预训练的LLM,并使用此数据加载器对其进行微调。

7.5 加载预训练LLM

我们花了大量时间准备数据集用于指令微调,这是监督微调过程中至关重要的环节。许多其他方面与预训练相同,使我们能够重用之前章节的大量代码。

在开始指令微调之前,我们首先需要加载一个预训练的GPT模型进行微调(见图7.15),这一过程我们之前已经进行过。然而,这次我们不再使用最小的1.24亿参数模型,而是加载3.55亿参数的中等规模模型。之所以选择这个模型,是因为1.24亿参数的模型容量过于受限,无法通过指令微调获得令人满意的结果。具体而言,较小的模型缺乏必要的容量来学习和保留复杂模式以及理解细微行为,从而难以完成高质量的指令跟随任务。


在这里插入图片描述图7.15 展示了LLM指令微调的三阶段流程。在数据集准备完成后,微调LLM以执行指令的过程首先需要加载预训练LLM,该模型将作为后续训练的基础。


加载预训练模型的代码与我们在数据预训练(5.5节)分类微调(6.4节)时所使用的代码相同,不同之处在于这次我们指定"gpt2-medium(355M)",而不是 “gpt2-small(124M)”

注意:
运行此代码将会下载3.55亿参数的GPT模型,其存储需求约为1.42GB,大约是小型模型存储需求的三倍

代码清单7.7:加载预训练模型

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

现在,让我们花一点时间评估预训练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 章中用于预训练模型的生成函数生成模型的响应:

from previous_chapters import (
    generate,
    text_to_token_ids,
    token_ids_to_text
)
# Alternatively:
# from llms_from_scratch.ch05 import (
#    generate_text_simple,
#    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):]
    .replace("### Response:", "")
    .strip()
)
print(response_text)  

这段代码从 generated_text 的开头移除输入文本,只保留模型生成的响应部分。然后,使用 strip() 函数去除前后多余的空白字符。输出如下:

The chef cooks the meal every day.

### Instruction:

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

这一输出表明预训练模型尚未能够正确地遵循给定的指令。虽然它确实创建了一个 Response 部分,但它只是重复了原始输入句子以及部分指令未能按照要求将主动句转换为被动语态

因此,接下来我们将实施微调过程,以提高模型对指令的理解能力,使其能够正确响应类似的请求

7.6 在指令数据上微调 LLM

现在是时候对 LLM 进行指令微调 了(见 图 7.16)。我们将使用 上一节加载的预训练模型,并进一步使用本章之前准备的指令数据集对其进行训练。


在这里插入图片描述图 7.16 展示了 LLM 指令微调的三阶段流程。在第 5 步,我们将对之前加载的预训练模型进行训练,使用的是本章前面准备的指令数据集


实际上,我们已经完成了大部分前期工作,包括实现指令数据集的预处理,这部分在本章开头已经完成。

至于 微调过程本身,我们可以直接复用 第 5 章 中实现的 损失计算训练函数

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.8259090423583983
Validation loss: 3.7619330406188967

处理硬件限制

使用和训练 更大的模型(如 GPT-2 medium(355M 参数))比使用 较小的 GPT-2 模型(124M 参数) 需要更高的计算资源。如果因 硬件限制 而遇到问题,可以切换到较小的模型,方法是将 CHOOSE_MODEL="gpt2-medium(355M)" 修改为 CHOOSE_MODEL="gpt2-small(124M)"(参见 7.5 节)。此外,如果想要加快模型训练速度,建议使用 GPU。本书代码仓库的补充部分列出了几种 使用云 GPU 的方法,可参考以下链接: https://mng.bz/EOEq。

下表提供了 在不同设备(包括 CPU 和 GPU)上训练 GPT-2 的参考运行时间。如果在兼容的 GPU 上运行本代码,无需修改代码,即可显著加快训练速度。在本章展示的实验结果中,我使用了 GPT-2 medium 模型,并在 RTX 4070 GPU 上完成了训练。

在这里插入图片描述

清单 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.")

以下输出显示了两个训练轮次(epochs)中的训练进度,其中损失(loss)的持续下降表明模型在理解指令生成合适的响应方面的能力正在提高。

训练输出表明模型正在有效学习,这一点可以从两个训练轮次(epochs)训练损失和验证损失值的持续下降看出。这一结果表明,模型在理解并遵循提供的指令方面的能力正在逐步提高。(由于模型在两个轮次内表现出了有效的学习,因此将训练扩展到第三个或更多轮次并非必要,甚至可能适得其反,因为这可能会导致**过拟合(overfitting)**的增加。)

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

我们稍后将重新审视并更详细地评估模型的响应质量。现在,让我们先检查训练和验证损失曲线,以进一步了解模型的学习过程。为此,我们使用与 预训练(pretraining) 时相同的 plot_losses 函数:

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 展示了两个训练轮次(epochs)中的训练损失和验证损失趋势实线代表训练损失,其在初始阶段急剧下降,随后趋于稳定;虚线代表验证损失,其变化模式与训练损失类似。


尽管图 7.17 中的损失曲线表明模型正在有效训练,但最关键的方面是模型在响应质量和正确性方面的表现。因此,接下来我们将提取模型的生成响应,并将其存储为可评估和量化响应质量的格式


练习 7.3 在原始 Alpaca 数据集上进行微调
Alpaca 数据集由斯坦福大学的研究人员创建,是最早且最受欢迎的开源指令数据集之一,共包含 52,002 条数据条目。作为本章中使用的 instruction-data.json 文件的替代方案,可以考虑在该数据集上对 LLM 进行微调。该数据集可在 此链接 获取。

该数据集包含 52,002 条数据,大约是本章所用数据的 50 倍,且大部分条目更长。因此,我强烈建议使用 GPU 进行训练,以加速微调过程。如果遇到**内存不足(out-of-memory)**错误,可以尝试以下方法来缓解:

  • 降低 batch_size(批量大小),从 8 调整为 4、2,甚至 1。
  • 减少 allowed_max_length(最大长度限制),从 1,024 调整为 512256,以减少内存占用。

要在原始 Stanford Alpaca 数据集(GitHub 链接)上微调模型,只需将文件 URL 从

url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch07/01_main-chapter-code/instruction-data.json"

修改为

url = "https://raw.githubusercontent.com/tatsu-lab/stanford_alpaca/main/alpaca_data.json"

请注意,该数据集包含 52,002 条数据(是第 7 章使用的数据集的 50 倍),且数据条目比第 7 章中的数据更长。因此,强烈建议使用 GPU 进行训练,以加快微调过程。

如果遇到**内存不足(out-of-memory)**错误,可以考虑以下方法:

  • 减少 batch_size(批量大小):从 8 降低到 4、2,甚至 1
  • 降低 allowed_max_length(最大长度限制):从 1024 降低到 512256

运行微调代码
为了方便实验,可以使用 exercise_experiments.py 代码在 52k Alpaca 数据集上以 batch_size=4 和 allowed_max_length=512 进行微调,执行以下命令:

python exercise_experiments.py --exercise_solution alpaca_52k

训练日志示例:

matplotlib version: 3.7.1
tiktoken version: 0.7.0
torch version: 2.3.0+cu121
tqdm version: 4.66.4
tensorflow version: 2.15.0
--------------------------------------------------
Training set length: 44201
Validation set length: 2601
Test set length: 5200
--------------------------------------------------
Device: cuda
--------------------------------------------------
...
Loaded model: gpt2-medium (355M)
--------------------------------------------------
Initial losses
   Training loss: 3.3681655883789063
   Validation loss: 3.4122894287109373
Ep 1 (Step 000000): Train loss 2.477, Val loss 2.750
...
Ep 2 (Step 022095): Train loss 0.761, Val loss 1.557
...
Training completed in 196.38 minutes.
Plot saved as loss-plot-alpaca52k.pdf
--------------------------------------------------
Generating responses
100% 5200/5200 [2:56:33<00:00,  2.04s/it]
Responses saved as instruction-data-with-response-alpaca52k.json
Model saved as gpt2-medium355M-sft-alpaca52k.pth

生成结果示例
训练完成后,以下是一些 Alpaca 数据集 的示例,包括模型的生成响应:

[
    {
        "instruction": "Edit the following sentence to increase readability: \"He made a huge effort and was so successful.\"",
        "input": "",
        "output": "He exerted a tremendous effort, and thus enjoyed great success.",
        "model_response": "He put in an immense effort and was rewarded with success."
    },
    {
        "instruction": "Rewrite the following sentence to make it more concise: \"I was displeased with the result of the experiment that I conducted.\"",
        "input": "",
        "output": "I was unhappy with my experiment's outcome.",
        "model_response": "I was displeased with the results of the experiment."
    },
    {
        "instruction": "How can we build a more efficient GPT model?",
        "input": "",
        "output": "We can build a more efficient GPT model by optimizing the architecture of the model, using smaller model sizes and training with fewer parameters. We can also leverage techniques such as knowledge distillation, transfer learning, dynamic sparsity and hybrid computing to further improve the efficiency of the model.",
        "model_response": "Building a more efficient GPT model requires careful planning and optimization. First, it is important to identify the target language and the context in which the model is used. Then, it is important to select the appropriate model architecture, such as backpropagation, hyperparameters, and hyperparameters. Finally, it is important to select the appropriate model weights and optimizers, such as backpropagation, hyperparameters, and hyperparameters."
    }
]

评估微调后的 LLM
使用 ollama_evaluate.py 工具对微调后的 LLM 进行评估:

ollama_evaluate.py

python ollama_evaluate.py --file_path instruction-data-with-response-alpaca52k.json

评分日志示例:

Scoring entries: 100%|████████████████████| 5200/5200 [1:07:52<00:00, 1.28it/s]
Number of scores: 5188 of 5200
Average score: 48.16

最终得分比本章所用数据集的得分略低。然而,需要注意的是,Alpaca 数据集的测试集包含更复杂和更具挑战性的指令,这可能会影响模型的最终得分。


7.7 提取并保存模型响应

指令数据集的训练集上完成LLM的微调后,我们现在可以在保留的测试集上评估其性能。首先,我们提取模型针对测试数据集中的每个输入生成的响应,并收集这些数据进行人工分析。然后,我们对LLM进行量化评估,以衡量其响应质量,如图7.18所示。


在这里插入图片描述图7.18 指令微调LLM的三阶段流程。在第3阶段的前两个步骤中,我们提取并收集模型在测试集上的响应,以便进一步分析。然后,我们评估模型的性能,以量化指令微调后的LLM的效果。


为了完成响应提取步骤,我们使用 generate 函数。接下来,我们打印模型响应,并将其与测试集中的预期答案并排对比,查看前三个测试样本的情况:

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

正如之前提到的,generate 函数返回的是合并的输入和输出文本,因此我们使用切片(slicing)和 .replace() 方法generated_text 的内容进行处理,以提取模型的响应。接下来,我们展示测试集中给定的指令,以及预期的测试集响应模型生成的响应

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)”。不过值得注意的是,积云可以发展成积雨云,而积雨云能够产生雷暴,因此这个错误是可以理解的。

更重要的是,模型的评估并不像完成微调(completion fine-tuning)那样简单。在完成微调中,我们只需计算正确的垃圾邮件/非垃圾邮件分类标签的百分比,即可获得分类准确率。然而,在实际应用中,指令微调后的LLM(如聊天机器人)通常采用多种评估方法

  • 短回答和多选题基准测试(如MMLU,Measuring Massive Multitask Language Understanding,https://arxiv.org/abs/2009.03300),用于测试模型的通识知识
  • 人类偏好比较(如LMSYS Chatbot Arena,https://arena.lmsys.org),用于比较不同LLM的表现
  • 自动化对话评测(如AlpacaEval,https://tatsu-lab.github.io/alpaca_eval/),其中另一个LLM(如GPT-4)用于评估模型的回答质量

在实际评估时,可以结合所有三种评估方法

  • 多选问答测试
  • 人工评估
  • 用于衡量对话性能的自动化指标

然而,由于本章的主要目标是评估对话能力,而不仅仅是多选问答能力,人工评估自动化评测指标可能更具参考价值

对话性能(Conversational Performance)
LLM的对话性能指的是其在人类交流中的表现,包括:

  • 理解上下文、语境和意图的能力
  • 提供相关且连贯的回答
  • 保持对话一致性
  • 适应不同主题和交流风格

这些因素共同决定了LLM在对话任务中的实际表现

人工评估虽然能够提供有价值的见解,但在处理大量响应时可能相对耗时且费力。例如,阅读并为1,100条响应分配评分将需要大量的精力

因此,考虑到任务的规模,我们将实施一种类似于自动化对话基准测试的方法,即使用另一个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


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.load_state_dict(torch.load("gpt2-medium355M-sft.pth"))

7.8 评估微调后的LLM

在此前的章节中,我们通过查看测试集的三个示例响应来判断指令微调(instruction fine-tuning)后模型的性能。虽然这种方法可以大致了解模型的表现,但当响应数量增多时,这种方法难以扩展。因此,我们实现了一种自动化评估微调LLM响应的方法,这一过程如图7.19所示。


在这里插入图片描述图7.19 微调LLM的三阶段流程在指令微调流程的最后一步,我们实现了一种方法,通过给测试集的生成响应打分量化模型的性能。


为了自动化评估测试集响应,我们使用了Meta AI开发的8B参数规模的Llama 3模型。该模型可以在本地运行,并且可以通过开源应用 Ollama 来进行推理。

💡注意
Ollama 是一个高效的LLM推理工具,可用于在笔记本电脑上运行LLMs。
它是基于开源库 llama.cpp(https://github.com/ggerganov/llama.cpp) 的封装,实现了纯C/C++实现的LLM推理,以最大化效率。
⚠️ 但请注意,Ollama 仅支持 LLM 推理(inference),不支持 LLM 训练或微调(fine-tuning)。

使用更大规模的LLM进行评估 尽管8B参数的Llama 3模型在本地可以运行,但其能力仍然不如OpenAI 提供的GPT-4等大规模专有LLMs。对于有兴趣利用GPT-4 API来评估模型生成响应的读者,可以在本书附带的补充材料中找到一个可选的代码笔记本(https://mng.bz/BgEv),学习如何利用 OpenAI API 评估模型的生成结果

要执行以下代码,请先安装 Ollama,访问 https://ollama.com,并按照网站提供的操作系统安装指南进行安装。

  • 对于 macOS 和 Windows 用户

    • 打开下载的 Ollama 应用程序
    • 如果出现安装命令行使用选项,请选择 Yes 进行安装。
  • 对于 Linux 用户

    • 请使用Ollama 官网提供的安装命令进行安装。

在实现模型评估代码之前,我们需要先下载 Llama 3 模型,并验证 Ollama 是否可以正常运行

为了在命令行终端中使用 Ollama,必须启动 Ollama 应用程序,或者在单独的终端窗口中运行 ollama serve 命令,如图7.20所示。


在这里插入图片描述图7.20 展示了运行 Ollama 的两种方式。左侧面板展示了使用 ollama serve 命令启动 Ollama 的方法。
右侧面板展示了在 macOS 上的另一种选项,即在后台运行 Ollama 应用程序,而不是使用 ollama serve 命令来启动应用程序。


另一个终端运行 Ollama 应用程序或 ollama serve 后,在命令行不要在 Python 会话中)执行以下命令,以测试 80 亿参数的 Llama 3 模型

ollama run llama3

首次执行此命令时该模型(占用 4.7GB 存储空间)将自动下载
其输出结果如下所示:

pulling manifest
pulling 6a0746a1ec1a... 100% ▕████████████████▏ 4.7 GB
pulling 4fa551d4f938... 100% ▕████████████████▏  12 KB
pulling 8ab4849b038c... 100% ▕████████████████▏  254 B
pulling 577073ffcc6c... 100% ▕████████████████▏  110 B
pulling 3f8eb4da87fa... 100% ▕████████████████▏  485 B
verifying sha256 digest
writing manifest
removing any unused layers
success

替代的 Ollama 模型
ollama run llama3 命令中,llama3 代表 经过指令微调的 80 亿参数 Llama 3 模型使用 Ollama 运行 llama3 需要约 16GB RAM。如果你的计算机内存不足,可以尝试使用 更小的模型,例如 3.8 亿参数的 phi3,其运行只需 约 8GB RAM,执行方式如下:

ollama run phi3

对于 计算能力更强的设备,你还可以使用 更大的 700 亿参数 Llama 3 模型,只需将 llama3 替换为 llama3:70b,但请注意 此模型需要更高的计算资源

ollama run llama3:70b

下载完成后,系统将进入命令行交互界面,你可以直接与模型对话。例如,尝试输入:

>>> 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 会话,但确保 ollama serve 命令或 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"))

在新的 Python 会话中运行代码
如果你已经关闭了 Python 会话,或者希望在新的 Python 会话中执行剩余代码,请运行以下代码。
该代码会重新加载 之前创建的指令和响应数据文件,并重新定义 format_input 函数tqdm 进度条将在后续步骤中使用)。

import json
from tqdm import tqdm

file_path = "instruction-data-with-response.json"

# 读取测试数据
with open(file_path, "r") as file:
    test_data = json.load(file)

# 重新定义 format_input 函数
def format_input(entry):
    instruction_text = (
        f"Below is an instruction that describes a task. "
        f"Write a response that appropriately completes the request."
        f"\n\n### Instruction:\n{entry['instruction']}"
    )

    input_text = (
        f"\n\n### Input:\n{entry['input']}" if entry["input"] else ""
    )

    return instruction_text + input_text

这样,你就可以在新的 Python 会话中继续运行剩余代码,而无需重新执行完整的训练或微调过程。


除了使用 ollama run 命令与模型交互外,还可以使用 Python 通过 REST API 与模型交互。下面的 query_modelfunction 演示了如何使用 API。

清单 7.10 查询本地 Ollama 模型

import urllib.request

def query_model(
    prompt,
    model="llama3",
    url="http://localhost:11434/api/chat"
):
    # Create the data payload as a dictionary
    data = {
        "model": model,
        "messages": [
            {"role": "user", "content": prompt}
        ],
        "options": {     # Settings below are required for deterministic responses
            "seed": 123,
            "temperature": 0,
            "num_ctx": 2048
        }
    }


    # Convert the dictionary to a JSON formatted string and encode it to bytes
    payload = json.dumps(data).encode("utf-8")

    # Create a request object, setting the method to POST and adding necessary headers
    request = urllib.request.Request(
        url,
        data=payload,
        method="POST"
    )
    request.add_header("Content-Type", "application/json")

    # Send the request and capture the response
    response_data = ""
    with urllib.request.urlopen(request) as response:
        # Read and decode the response
        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


model = "llama3"
result = query_model("What do Llamas eat?", model)
print(result)

在运行本笔记本中的后续代码单元格之前,请确保 Ollama 仍然在运行
前面的代码单元格应输出 "Ollama running: True" 以确认模型处于活动状态并可以接收请求

示例:使用 query_model 函数
以下示例演示如何使用我们刚刚实现的 query_model 函数:

model = "llama3"
result = query_model("What do Llamas eat?", model)
print(result)

示例响应输出如下

Llamas are ruminant animals, which means they have a four-chambered
stomach that allows them to digest plant-based foods. Their diet
typically consists of:
1. Grasses: Llamas love to graze on grasses, including tall grasses,
short grasses, and even weeds.
...

使用 query_model 评估微调模型的响应质量
我们将利用 query_model 函数,让 Llama 3 模型 基于测试集参考答案,对微调模型的生成响应进行评分。评分范围为 0 到 100,其中 100 代表最优响应

首先,我们将此方法应用于测试集中 前三个示例(即我们之前分析过的测试数据):

for entry in test_data[:3]:
    prompt = (
        f"Given the input `{format_input(entry)}` "
        f"and correct output `{entry['output']}`, "
        f"score the model response `{entry['model_response']}`"
        f" on a scale from 0 to 100, where 100 is the best score. "
    )
    print("\nDataset response:")
    print(">>", entry['output'])
    print("\nModel response:")
    print(">>", entry["model_response"])
    print("\nScore:")
    print(">>", query_model(prompt))
    print("\n-------------------------")

该代码的输出类似如下(由于 Ollama 不是完全确定性的,生成的文本可能会有所不同):

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

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

Score:
>> I'd rate the model response "The car is as fast as a bullet." an 85 out of 100.

Here's why:

* The response uses a simile correctly, comparing the speed of the car to something else (in this case, a bullet).
* The comparison is relevant and makes sense, as bullets are known for their high velocity.
* The phrase "as fast as" is used correctly to introduce the simile.

The only reason I wouldn't give it a perfect score is that some people might find the comparison slightly less vivid or evocative than others. For example, comparing something to lightning (as in the original response) can be more dramatic and attention-grabbing. However, "as fast as a bullet" is still a strong and effective simile that effectively conveys the idea of the car's speed.

Overall, I think the model did a great job!

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

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:
>> I'd score this model response as 40 out of 100.

Here's why:

* The model correctly identifies that thunderstorms are related to clouds (correctly identifying the type of phenomenon).
* However, it incorrectly specifies the type of cloud associated with thunderstorms. Cumulus clouds are not typically associated with thunderstorms; cumulonimbus clouds are.
* The response lacks precision and accuracy in its description.

Overall, while the model attempts to address the instruction, it provides an incorrect answer, which is a significant error.

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

Dataset response:
>> Jane Austen.

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

Score:
>> I'd rate my own response as 95 out of 100. Here's why:

* The response accurately answers the question by naming the author of 'Pride and Prejudice' as Jane Austen.
* The response is concise and clear, making it easy to understand.
* There are no grammatical errors or ambiguities that could lead to confusion.

The only reason I wouldn't give myself a perfect score is that the response is slightly redundant - it's not necessary to rephrase the question in the answer. A more concise response would be simply "Jane Austen."

Llama 3模型生成的响应表明其能够进行合理的评估,并且在模型的答案不完全正确时能够分配部分分数。例如,在对“积云”(cumulus cloud)答案的评估中,该模型认可了该回答的部分正确性。

先前的提示返回了高度详细的评估以及评分。我们可以修改提示,使其仅生成0到100范围内的整数分数,其中100代表最佳可能分数。此修改使我们能够计算模型的平均分数,从而对其性能进行更简洁和定量的评估。下面的generate_model_scores函数使用了一个修改后的提示,告诉模型“仅以整数数字回复”。

清单 7.11 评估指令微调 LLM

def generate_model_scores(json_data, json_key, model="llama3"):
    scores = []
    for entry in tqdm(json_data, desc="Scoring entries"):
        prompt = (
            f"Given the input `{format_input(entry)}` "
            f"and correct output `{entry['output']}`, "
            f"score the model response `{entry[json_key]}`"
            f" on a scale from 0 to 100, where 100 is the best score. "
            f"Respond with the integer number only."
        )
        score = query_model(prompt, model)
        try:
            scores.append(int(score))
        except ValueError:
            print(f"Could not convert score: {score}")
            continue

    return scores


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.57it/s]
Number of scores: 110 of 110
Average score: 50.32

评估结果显示,我们的微调模型的平均分数超过50,这为与其他模型进行比较或尝试不同的训练配置以提高模型性能提供了有用的基准。

值得注意的是,截至本文撰写时,Ollama在不同操作系统上的行为并非完全确定性(deterministic),这意味着你获得的分数可能会与之前的分数略有不同。为了获得更稳健的结果,你可以多次重复评估并对所得分数取平均值。

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

  • 在微调过程中调整超参数,例如学习率(learning rate)、批量大小(batch size)或训练轮数(number of epochs);
  • 增加训练数据集的规模,或使示例多样化,以涵盖更广泛的主题和风格;
  • 试验不同的提示词(prompts)或指令格式,以更有效地引导模型的响应;
  • 使用更大的预训练模型,这可能具有更强的能力来捕捉复杂模式并生成更准确的响应。

注意 作为参考,使用本文所述的方法时,Llama 3 8B基础模型(未经过任何微调)在测试集上的平均得分为58.51。而Llama 3 8B指令模型(经过通用指令跟随数据集的微调)在测试集上取得了令人印象深刻的平均得分82.6。


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

要使用LoRA对模型进行指令微调,请使用附录E中的相关类和函数:

from appendix_E import LoRALayer, LinearWithLoRA, replace_linear_with_lora

接下来,在第7.5节的模型加载代码下方添加以下代码行:

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters before: {total_params:,}")

for param in model.parameters():
    param.requires_grad = False

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters after: {total_params:,}")
replace_linear_with_lora(model, rank=16, alpha=16)

total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable LoRA parameters: {total_params:,}")
model.to(device)

为了方便起见,你可以使用exercise_experiments.py代码对模型进行微调,并使用LoRA(rank设为16,alpha设为16),如下所示:

python exercise_experiments.py --exercise_solution lora

输出结果:

matplotlib version: 3.7.1
tiktoken version: 0.7.0
torch version: 2.3.0+cu121
tqdm version: 4.66.4
tensorflow version: 2.15.0
--------------------------------------------------
Training set length: 935
Validation set length: 55
Test set length: 110
--------------------------------------------------
Device: cuda
--------------------------------------------------
File already exists and is up-to-date: gpt2/355M/checkpoint
File already exists and is up-to-date: gpt2/355M/encoder.json
File already exists and is up-to-date: gpt2/355M/hparams.json
File already exists and is up-to-date: gpt2/355M/model.ckpt.data-00000-of-00001
File already exists and is up-to-date: gpt2/355M/model.ckpt.index
File already exists and is up-to-date: gpt2/355M/model.ckpt.meta
File already exists and is up-to-date: gpt2/355M/vocab.bpe
Loaded model: gpt2-medium (355M)
--------------------------------------------------
Total trainable parameters before: 406,286,336
Total trainable parameters after: 0
Total trainable LoRA parameters: 7,898,384
Initial losses
   Training loss: 3.7684114456176756
   Validation loss: 3.7619335651397705
Ep 1 (Step 000000): Train loss 2.509, Val loss 2.519
...
Ep 2 (Step 000230): Train loss 0.308, Val loss 0.652
...
--------------------------------------------------
Generating responses
100% 110/110 [01:52<00:00,  1.03s/it]
Responses saved as instruction-data-with-response-lora.json
Model saved as gpt2-medium355M-sft-lora.pth

为了进行对比,你可以运行原始的第7章微调代码:

python exercise_experiments.py --exercise_solution baseline

请注意,在Nvidia L4 GPU上,使用LoRA的代码运行时间为1.30分钟,而基线方法的运行时间为1.80分钟。因此,LoRA的运行速度提高了约28%。

我们可以使用Ollama Llama 3方法来评估性能,该方法也已经在exercise_experiments.py脚本中实现。你可以运行以下命令进行评估:

python ollama_evaluate.py --file_path instruction-data-with-response-lora.json

输出结果:

Ollama running: True
Scoring entries: 100%|████████████████████████| 110/110 [01:13<00:00,  1.50it/s]
Number of scores: 110 of 110
Average score: 50.23

分数约为50,与原始模型的分数处于同一范围。


7.9 结论

本章标志着我们对LLM开发周期探索的结束。我们已经涵盖了所有基本步骤,包括实现LLM架构、预训练LLM以及针对特定任务进行微调,如图7.21所总结的。接下来,我们讨论一些可以进一步探索的内容。

7.9.1 接下来的方向

虽然我们已经覆盖了最基本的步骤,但在指令微调之后,还有一个可选步骤可以执行:偏好微调(preference fine-tuning)。偏好微调特别适用于定制模型,使其更符合特定用户的需求。如果你对此感兴趣,可以在本书补充的GitHub仓库(https://mng.bz/dZwD)中的04_preference-tuning-with-dpo文件夹中进行探索。

除了本书的主要内容,该GitHub仓库还包含大量额外的材料,这些内容可能对你有价值。要了解更多关于这些额外资源的信息,请访问该仓库README页面的Bonus Material部分:https://mng.bz/r12g。


在这里插入图片描述图7.21 LLM编码的三个主要阶段


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

人工智能和LLM研究领域正在以极快的速度发展(而且,取决于你问的是谁,这个速度可能令人兴奋)。保持最新进展的一种方式是定期查看arXiv上的最新研究论文:https://arxiv.org/list/cs.LG/recent。此外,许多研究人员和实践者在社交媒体平台(如X(原Twitter)和Reddit)上非常活跃,会分享和讨论最新的研究进展。

特别是r/LocalLLaMA(https://www.reddit.com/r/LocalLLaMA)这个Reddit子版块是一个很好的资源,可以帮助你与社区建立联系,并了解最新的工具和趋势。

7.9.3 最后的话

我希望你在从零开始实现LLM、编写预训练和微调函数的过程中,享受了这一学习旅程。在我看来,从零构建LLM是深入理解其工作原理的最有效方式。我希望这种实践方法为你提供了有价值的见解,并奠定了扎实的LLM开发基础。

虽然本书的主要目的是教育性学习,但你可能对在实际应用中使用不同的、更强大的LLM感兴趣。为此,我推荐你探索以下流行工具:

  • Axolotl(https://github.com/OpenAccess-AI-Collective/axolotl)
  • LitGPT(https://github.com/Lightning-AI/litgpt)

总结

  • 指令微调(instruction fine-tuning)过程将预训练的LLM调整为能够遵循人类指令并生成期望的响应。
  • 数据集准备涉及下载指令-响应数据集、格式化条目,并将其划分为训练集、验证集和测试集。
  • 训练批次是使用自定义的collate函数构造的,该函数会对序列进行填充(padding)、创建目标token ID,并掩盖填充的token。
  • 加载预训练模型:我们加载了一个355M参数量的GPT-2 Medium模型,作为指令微调的起点。
  • 模型微调:预训练模型在指令数据集上进行微调,使用的训练循环与预训练类似。
  • 评估:从测试集中提取模型的响应并对其进行评分(例如,使用另一个LLM进行评分)。
  • 自动评分:Ollama应用程序使用80亿参数的Llama模型对微调后的模型响应进行自动评分,并提供平均得分以量化性能。

http://www.kler.cn/a/613541.html

相关文章:

  • TextGrad:案例
  • 横扫SQL面试——事件流处理(峰值统计)问题
  • SDL —— 将sdl渲染画面嵌入Qt窗口显示(附:源码)
  • CSS回顾-Flex弹性盒布局
  • Vue $bus被多次触发
  • 【WPF】ListView数据绑定
  • 【AI工具开发】Notepad++插件开发实践:从基础交互到ScintillaCall集成
  • C语言之链表
  • 分布式光伏防逆流如何实现?
  • 每日免费分享之精品wordpress主题系列~DAY16
  • 云原生四重涅槃·破镜篇:混沌工程证道心,九阳真火锻金身
  • 可视化图解算法:递归基础
  • Pyside6介绍和开发第一个程序
  • GPT4o漫画制作(小白教程)
  • 后端开发中的文件上传的实现
  • Amazon CodeWhisperer 挑战十大排序算法
  • Vue下 Sortable 实现 table 列表字段可拖拽排序,显示隐藏组件开发
  • docker网桥问题导致ldap组件安装失败分析解决
  • 可直接套用的可视化模板
  • Python 3.13 正式支持 iOS:移动开发的新篇章