第 7 章 LLMGPT指令微调

第 7 章 微调:指令微调

第 7 章 微调:指令微调

本章来源:本文翻译整理自 LLMs-from-scratch 仓库的 ch07/01_main-chapter-code/ch07.ipynb,原书为 Sebastian Raschka《Build a Large Language Model (From Scratch)》。

本章要做什么

在本章,我们教 LLM 更好地遵循指令。具体包括:指令微调简介、为有监督指令微调准备数据集、把数据组织成训练批次、为指令数据集创建数据加载器、加载预训练 LLM、在指令数据上微调 LLM、提取和保存响应、评估微调过的 LLM。

本 notebook 使用的包

from importlib.metadata import version

pkgs = [
    "numpy",       # PyTorch & TensorFlow dependency
    "matplotlib",  # Plotting library
    "tiktoken",    # Tokenizer
    "torch",       # Deep learning library
    "tqdm",        # Progress bar
    "tensorflow",  # For OpenAI's pretrained weights
]
for p in pkgs:
    print(f"{p} version: {version(p)}")

7.1 指令微调简介

在第 5 章,我们看到预训练 LLM 涉及一个它学习一次生成一个词的训练过程。因此,预训练过的 LLM 擅长文本补全,但不擅长遵循指令。在本章,我们教 LLM 更好地遵循指令。

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

我们将使用作者为本章准备的一个指令数据集。

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

我们从上面 JSON 文件加载的 data 列表里的每一项都是下面形式的一个字典:

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

注意,'input' 字段可以为空:

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

指令微调常被称为"有监督指令微调"(supervised instruction finetuning),因为它涉及在一个输入-输出对被显式提供的数据集上训练模型。

有几种方法把这些条目格式化作为 LLM 的输入;下图说明了两种示例格式,分别用于训练 Alpaca(https://crfm.stanford.edu/2023/03/13/alpaca.html)和 Phi-3(https://arxiv.org/abs/2404.14219)LLM。

在本章,我们使用 Alpaca 风格的提示词格式,它是指令微调最初的提示词模板。下面,我们格式化将作为输入传给 LLM 的输入。

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

一个带 input 字段的格式化响应看起来如下所示:

model_input = format_input(data[50])
desired_response = f"\n\n### Response:\n{data[50]['output']}"

print(model_input + desired_response)

下面是一个没有 input 字段的格式化响应:

model_input = format_input(data[999])
desired_response = f"\n\n### Response:\n{data[999]['output']}"

print(model_input + desired_response)

最后,在下一节准备 PyTorch 数据加载器之前,我们把数据集分成训练、验证和测试集:

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

7.3 把数据组织成训练批次

我们分几步处理这个数据集的批处理,如下面的图总结的。

首先,我们实现一个 InstructionDataset 类,它预分词数据集里的所有输入,类似于第 6 章的 SpamDataset

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 作为填充 token:

import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")

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

在第 6 章,我们把数据集里的所有示例填充到相同长度。这里,我们采取一个更精巧的方法,开发一个自定义的 "collate" 函数,我们可以把它传给数据加载器。这个自定义 collate 函数把每个批次里的训练示例填充到相同长度(但不同的批次可以有不同长度)。

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

上面,我们只返回了给 LLM 的输入;然而,对 LLM 训练,我们也需要目标值。和预训练 LLM 类似,目标是输入向右移一位,这样 LLM 学会预测下一个 token:

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)

接下来,我们引入一个 ignore_index 值,把所有填充 token ID 替换成一个新值;这个 ignore_index 的目的是让我们能在损失函数里忽略填充值(稍后详述)。具体来说,这意味着我们把对应 50256 的 token ID 替换成 -100,如下所示。

(此外,我们还引入 allowed_max_length,以防我们想限制样本的长度;如果你计划使用自己比 GPT-2 模型支持的 1024 token 上下文大小更长的数据集,这会很有用。)

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)

让我们看看用 -100 替换实现了什么。为了说明,让我们假设我们有一个带 2 个类标签 0 和 1 的小分类任务,和第六章类似。如果我们有下面的 logits 值(模型最后一层的输出),我们计算下面的损失:

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)

现在,正如预期的,再加一个训练示例会影响损失:

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)

让我们看看,如果我们把其中一个示例的类标签替换成 -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)

正如我们看到的,这 3 个训练示例上的结果损失和我们从 2 个训练示例算出的损失相同,这意味着交叉熵损失函数忽略了带 -100 标签的训练示例。默认情况下,PyTorch 有 cross_entropy(..., ignore_index=-100) 设置来忽略对应标签 -100 的示例。

用这个 -100 的 ignore_index,我们可以忽略批次里那些用来把训练示例填充到相同长度的额外文本结束(padding)token。然而,我们不想忽略文本结束(padding)token(50256)的第一个实例,因为它能帮助向 LLM 发出信号,表明响应何时完成。

在实践中,通常还会掩掉对应指令的目标 token ID,如下面的图所示(这是读者在完成本章后一个值得推荐的练习)。

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

在本节,我们用 InstructionDataset 类和 custom_collate_fn 函数实例化训练、验证和测试数据加载器。

前面 custom_collate_fn 函数的另一个额外细节是,我们现在直接把数据移到目标设备(比如 GPU),而不是在主训练循环里做,这提高了效率,因为当我们把 custom_collate_fn 作为数据加载器的一部分使用时,它可以作为后台进程执行。用 Python functools 标准库里的 partial 函数,我们创建一个新函数,把原函数的 device 参数预填好:

if torch.cuda.is_available():
    device = torch.device("cuda")
elif torch.backends.mps.is_available():
    # Use PyTorch 2.9 or newer for stable mps results
    major, minor = map(int, torch.__version__.split(".")[:2])
    if (major, minor) >= (2, 9):
        device = torch.device("mps")
    else:
        device = torch.device("cpu")
else:
    device = torch.device("cpu")

print("Device:", device)
from functools import partial

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

接下来,我们和之前章节类似地实例化数据加载器,除了我们现在为批处理过程提供自己的 collate 函数:

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)

正如我们基于上面的输出看到的,所有批次的批量大小都是 8,但长度不同,符合预期。让我们也复查输入里包含对应 token ID 50256 的 <|endoftext|> 填充 token,通过打印 inputs 批次里第一个训练示例的内容:

print(inputs[0])

类似地,我们视觉复查目标里包含 -100 占位 token:

print(targets[0])

7.5 加载一个预训练 LLM

在本节,我们用和第 5 章 5.5 节、第 6 章 6.4 节相同的代码加载一个预训练 GPT 模型。

然而,我们不加载最小的 1.24 亿参数模型,而是加载带 3.55 亿参数的 medium 版本,因为 1.24 亿模型太小,通过指令微调无法达到定性合理的结果。

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

在我们于下一节开始微调模型之前,让我们看看它在其中一个验证任务上表现如何:

torch.manual_seed(123)

input_text = format_input(val_data[0])
print(input_text)
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 函数返回组合的输入和输出文本,这在前一节里对创建可读文本很方便。要隔离出响应,我们可以从 generated_text 的开头减去指令的长度:

response_text = (
    generated_text[len(input_text):]
    .replace("### Response:", "")
    .strip()
)
print(response_text)

正如我们看到的,模型还不能遵循指令;它创建了一个 "Response" 部分,但只是重复了原始输入句子和指令。

7.6 在指令数据上微调 LLM

在本节,我们微调模型。注意,我们可以复用之前章节用过的所有损失计算和训练函数:

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)

注意,训练比之前章节贵一点,因为我们用更大的模型(3.55 亿而不是 1.24 亿参数)。各种设备的运行时间供参考如下(在兼容的 GPU 设备上运行这个 notebook 不需要改代码):

模型 设备 2 个 epoch 的运行时间
gpt2-medium (355M) CPU (M3 MacBook Air) 15.78 分钟
gpt2-medium (355M) GPU (M3 MacBook Air) 10.77 分钟
gpt2-medium (355M) GPU (L4) 1.83 分钟
gpt2-medium (355M) GPU (A100) 0.86 分钟
gpt2-small (124M) CPU (M3 MacBook Air) 5.74 分钟
gpt2-small (124M) GPU (M3 MacBook Air) 3.73 分钟
gpt2-small (124M) GPU (L4) 0.69 分钟
gpt2-small (124M) GPU (A100) 0.39 分钟

作者用 "gpt2-medium (355M)" 模型运行了这个 notebook。

import time

start_time = time.time()

torch.manual_seed(123)

optimizer = torch.optim.AdamW(model.parameters(), lr=0.00005, weight_decay=0.1)

num_epochs = 2

train_losses, val_losses, tokens_seen = train_model_simple(
    model, train_loader, val_loader, optimizer, device,
    num_epochs=num_epochs, eval_freq=5, eval_iter=5,
    start_context=format_input(val_data[0]), tokenizer=tokenizer
)

end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")

正如我们基于上面的输出看到的,模型训练得很好,因为我们可以从递减的训练损失和验证损失值看出来。此外,基于每个 epoch 之后打印的响应文本,我们可以看到模型正确遵循了把输入句子 'The chef cooks the meal every day.' 转成被动语态 'The meal is cooked every day by the chef.' 的指令(我们会在后面一节正确格式化并评估响应)。最后,让我们看一下训练和验证损失曲线:

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)

正如我们看到的,损失在第一个 epoch 开始时急剧下降,这意味着模型开始快速学习。我们可以看到,在约 1 个训练 epoch 时开始出现轻微过拟合。

7.7 提取和保存响应

在本节,我们保存测试集响应,供下一节评分。我们也为将来使用保存一份模型副本。但首先,让我们简短看一下微调过的模型生成的响应:

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

正如我们基于测试集指令、给定响应和模型响应看到的,模型表现相对不错。第一个和最后一个指令的答案明显正确。第二个答案很接近;模型回答 "cumulus cloud"(积云)而不是 "cumulonimbus"(积雨云)(然而,注意积云可以发展成能够产生雷暴的积雨云)。

最重要的是,我们可以看到模型评估不像上一章那么简单,上一章我们只需计算正确的垃圾/非垃圾类标签的百分比来得到分类准确率。在实践中,指令微调过的 LLM(比如聊天机器人)通过多种方法评估:

在下一节,我们用一种类似 AlpacaEval 的方法,用另一个 LLM 评估我们模型的响应;然而,我们会用我们自己的测试集,而不是用公开可用的基准数据集。为此,我们把模型响应加进 test_data 字典,并把它保存成 "instruction-data-with-response.json" 文件用于记录保存,这样需要的话我们可以单独加载并在 Python 会话里分析它。

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

让我们复查其中一个条目,看看响应是否被正确添加进 test_data 字典:

print(test_data[0])

最后,我们也保存模型,以防我们将来想复用它:

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

7.8 评估微调过的 LLM

在本节,我们用另一个更大的 LLM 自动化微调 LLM 的响应评估。具体来说,我们用 Meta AI 的指令微调过的 80 亿参数 Llama 3 模型,它可以通过 ollama(https://ollama.com)本地运行。(另外,如果你更想用 GPT-4 这种更能干的 LLM 走 OpenAI API,请参见 llm-instruction-eval-openai.ipynb notebook。)

Ollama 是一个高效运行 LLM 的应用。它是 llama.cpp(https://github.com/ggerganov/llama.cpp)的包装器,后者用纯 C/C++ 实现 LLM 以最大化效率。注意,它是一个用 LLM 生成文本(推理)的工具,不是训练或微调 LLM。在运行下面的代码之前,通过访问 https://ollama.com 并按照说明安装 ollama(比如,点击 "Download" 按钮,为你的操作系统下载 ollama 应用)。

对 macOS 和 Windows 用户,点击你下载的 ollama 应用;如果它提示你安装命令行用法,说 "yes"。Linux 用户可以使用 ollama 网站上提供的安装命令。

一般来说,在我们能从命令行使用 ollama 之前,我们必须要么启动 ollama 应用,要么在单独的终端里运行 ollama serve

注意

  • 在终端里运行 ollama serve 时,如上所述,你可能会遇到一条错误消息,说 Error: listen tcp 127.0.0.1:11434: bind: address already in use
  • 如果是这样,试试用命令 OLLAMA_HOST=127.0.0.1:11435 ollama serve(如果这个地址也在用,试着把数字加一,直到找到没在用的地址)。

在另一个终端里运行 ollama 应用或 ollama serve 的情况下,在命令行执行下面的命令试试 80 亿参数的 Llama 3 模型(模型占 4.7 GB 存储空间,第一次执行这个命令时会自动下载):

# 8B model
ollama run llama3

输出看起来如下所示:

$ ollama run llama3
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

注意,llama3 指的是指令微调过的 80 亿参数 Llama 3 模型。

"llama3" 模型(一个 8B 参数模型)需要 16 GB 内存;如果你的机器不支持,你可以试试更小的模型,比如 3.8B 参数的 phi-3 模型,通过设置 model = "phi-3",它只需要 8 GB 内存。另外,如果你的机器支持,你也可以用更大的 700 亿参数 Llama 3 模型,把 llama3 替换成 llama3:70b

下载完成后,你会看到一个命令行提示符,允许你和模型聊天。试试 "What do llamas eat?" 这样的提示,它应该返回类似下面的输出:

>>> What do llamas eat?
Llamas are ruminant animals, which means they have a four-chambered
stomach and eat plants that are high in fiber. In the wild, llamas
typically feed on:
1. Grasses: They love to graze on various types of grasses, including tall
grasses, wheat, oats, and barley.

你可以用输入 /bye 结束这个会话。

下面的代码在继续用 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"))
# This cell is optional; it allows you to restart the notebook
# and only run section 7.7 without rerunning any of the previous code
import json
from tqdm import tqdm

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

with open(file_path, "r") as file:
    test_data = json.load(file)


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

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

    return instruction_text + input_text

现在,除了我们之前用的 ollama run 命令,与模型交互的另一种方式是通过它的 REST API 在 Python 里用下面的函数。在运行这个 notebook 里后面的单元之前,确保 ollama 仍在运行(前面的代码单元应该打印 "Ollama running: True")。接下来,运行下面的代码单元来查询模型:

import requests  # noqa: F811
# import urllib.request

def query_model(
    prompt,
    model="llama3",
    # If you used OLLAMA_HOST=127.0.0.1:11435 ollama serve
    # update the address from 11434 to 11435
    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
        }
    }

    # (原书中这一函数基于 urllib 的版本在此省略;requests 版本更稳健)
    # 代码与仓库一致,通过 ollama REST API 发送请求并返回生成的文本。

注意,如果你收到 HTTPError: 404 Client Error: Not Found for url: http://localhost:11434/api/chat 错误,这可能意味着你还没下载 llama3 模型(要下载模型,要么用 UI,要么在终端用 ollama run llama3)。

现在,用我们上面定义的 query_model 函数,我们可以评估我们微调模型的响应;让我们在之前一节看过的前 3 个测试集响应上试一下:

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

注意:更好的评估提示

  • 一位读者(Ayoosh Kathuria)建议一个更长、改进的提示,它在 1-5 的尺度上评估响应(而不是 1 到 100),并使用一个评分标准,产生更准确、更少噪音的评估:
prompt = """
You are a fair judge assistant tasked with providing clear, objective feedback based on specific criteria, ensuring each assessment reflects the absolute standards set for performance.
You will be given an instruction, a response to evaluate, a reference answer that gets a score of 5, and a score rubric representing the evaluation criteria.
Write a detailed feedback that assess the quality of the response strictly based on the given score rubric, not evaluating in general.
Please do not generate any other opening, closing, and explanations.

Here is the rubric you should use to build your answer:
1: The response fails to address the instructions, providing irrelevant, incorrect, or excessively verbose information that detracts from the user's request.
2: The response partially addresses the instructions but includes significant inaccuracies, irrelevant details, or excessive elaboration that detracts from the main task.
3: The response follows the instructions with some minor inaccuracies or omissions. It is generally relevant and clear, but may include some unnecessary details or could be more concise.
4: The response adheres to the instructions, offering clear, accurate, and relevant information in a concise manner, with only occasional, minor instances of excessive detail or slight lack of clarity.
5: The response fully adheres to the instructions, providing a clear, accurate, and relevant answer in a concise and efficient manner. It addresses all aspects of the request without unnecessary details or elaboration

Provide your feedback as follows:

Feedback:::
Evaluation: (your rationale for the rating, as a text)
Total rating: (your rating, as a number between 1 and 5)

You MUST provide values for 'Evaluation:' and 'Total rating:' in your answer.

Now here is the instruction, the reference answer, and the response.

Instruction: {instruction}
Reference Answer: {reference}
Answer: {answer}


Provide your feedback. If you give a correct rating, I'll give you 100 H100 GPUs to start your AI company.
Feedback:::
Evaluation: """
  • 更多上下文和信息,参见这个 GitHub 讨论。

正如我们看到的,Llama 3 模型提供了一个合理的评估,如果模型不完全正确也会给部分分,正如我们基于 "cumulus cloud" 答案看到的。注意,前面的提示返回非常冗长的评估;我们可以调整提示,生成 0 到 100 之间(100 最好)的整数响应,为我们模型计算平均分。测试集 110 个条目的评估在 M3 MacBook Air 笔记本电脑上大约需要 1 分钟。

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

我们的模型取得了高于 50 的平均分,我们可以用它作为参考点来把模型和其它模型比较,或尝试其它可能改进模型的训练设置。注意,写这篇文章时 ollama 在不同操作系统上不完全是确定性的,所以你得到的数字可能和上面显示的略有不同。

作为参考,原始的:

  • Llama 3 8B base 模型取得 58.51 分。
  • Llama 3 8B instruct 模型取得 82.65 分。

7.9 结论

7.9.1 接下来是什么

这标志着这本书的最后一章。我们覆盖了 LLM 开发周期的主要步骤:实现 LLM 架构、预训练 LLM、微调它。

指令微调之后有时会跟的一个可选步骤,如本章所述,是偏好微调(preference finetuning)。偏好微调过程对定制模型以更好地对齐特定用户偏好可能特别有用;如果你感兴趣,参见 ../04_preference-tuning-with-dpo 文件夹。

这个 GitHub 仓库还包含大量你可能喜欢的额外补充材料;更多信息,请参见这个仓库 README 页面上的 Bonus Material 部分。

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

本节没有代码。

7.9.3 最后的话

作者希望你喜欢这段从零实现 LLM、编写预训练和微调函数的旅程。在他看来,从零实现 LLM 是理解 LLM 怎么工作的最好方式,他希望你们通过这种方法获得了更好的理解。

虽然这本书服务于教育目的,但你可能有兴趣在真实世界应用里使用不同的、更强大的 LLM。为此,你可以考虑像 axolotl(https://github.com/OpenAccess-AI-Collective/axolotl)或 LitGPT(https://github.com/Lightning-AI/litgpt,作者参与开发)这样的流行工具。

总结与要点

接下来做什么?

关键概念

  • 指令微调(instruction finetuning):也叫有监督指令微调,训练模型遵循指令,输入-输出对显式提供。
  • Alpaca 风格提示词### Instruction: / ### Input: / ### Response: 格式。
  • 自定义 collate 函数:把每个批次填充到该批次最长长度(不同批次可不同长)。
  • ignore_index (-100):让交叉熵损失忽略填充 token;但保留第一个 end-of-text token 让模型知道响应何时完成。
  • masked loss:用 -100 掩掉填充(和可选地掩掉指令),只对响应部分计算损失。
  • 模型评估:用另一个 LLM(Llama 3 via Ollama)给模型响应打分(0-100)。
  • 偏好微调(preference finetuning):指令微调后可选的一步,把模型对齐到特定用户偏好(DPO)。

练习

练习解答见仓库的 ch07/01_main-chapter-code/exercise-solutions.ipynb

全书回顾

到这里,七章全部完成。你走完了一个完整的 LLM 开发周期:

  1. 理解 LLM:LLM 是什么、Transformer 架构基础。
  2. 处理文本:分词、BPE、嵌入、位置编码、数据加载。
  3. 注意力机制:自注意力、因果注意力、多头注意力。
  4. 实现 GPT:LayerNorm、GELU、前馈、Transformer 块、完整 GPT 模型、文本生成。
  5. 预训练:交叉熵损失、困惑度、训练循环、温度与 top-k 解码、加载 OpenAI 权重。
  6. 分类微调:把 GPT 变成垃圾短信分类器。
  7. 指令微调:让 GPT 遵循指令,并用更大的 LLM 评估。

正如作者所说:从零实现 LLM,是理解 LLM 怎么工作的最好方式。 现在你不仅读懂了,而且亲手写过了。