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

分类微调

译自 Build a Large Language Model (From Scratch) MEAP V08 第 6 章 “Finetuning for Classification”。本文保留原章结构、图、清单、练习和代码输出,并将图像嵌入为 data URI。

本章内容

  • 介绍不同的 LLM 微调方法。
  • 为文本分类准备数据集。
  • 修改预训练 LLM 以便进行微调。
  • 微调 LLM 来识别垃圾短信。
  • 评估微调后的 LLM 分类器的准确率。
  • 使用微调后的 LLM 对新数据进行分类。

在前几章中,我们编写了 LLM 架构,对它进行了预训练,并学习了如何把来自外部来源(例如 OpenAI)的预训练权重导入到自己的模型中。本章将收获这些工作的成果:我们会把 LLM 微调到一个特定目标任务上,例如文本分类,如图 6.1 所示。我们将考察的具体例子,是把短信分类为垃圾短信或非垃圾短信。

LLM 构建、预训练与微调三阶段示意图
图 6.1 LLM 编码、在通用文本数据集上预训练 LLM、以及对它进行微调这三个主要阶段的心智模型。本章重点是把预训练 LLM 微调为分类器。

图 6.1 展示了微调 LLM 的两种主要方式:用于分类的微调(第 8 步),以及把 LLM 微调为能够遵循指令(第 9 步)。下一节会更详细地讨论这两种微调方式。

6.1 不同类别的微调

微调语言模型最常见的方式是指令微调(instruction-finetuning)和分类微调(classification-finetuning)。指令微调是在一组任务上训练语言模型,并使用具体指令来提升模型理解和执行自然语言提示中所描述任务的能力,如图 6.2 所示。

两个指令微调场景
图 6.2 两个不同指令微调场景的示意图。上方场景中,模型的任务是判断给定文本是否为垃圾短信。下方场景中,模型收到一条把英文句子翻译成德文的指令。

下一章将讨论图 6.2 所示的指令微调。本章则聚焦于分类微调;如果你有机器学习背景,这个概念可能已经比较熟悉。

在分类微调中,模型被训练来识别一组特定的类别标签,例如“spam”和“not spam”。分类任务的例子并不局限于大型语言模型和电子邮件过滤;它们还包括从图像中识别不同植物物种、把新闻文章归入体育、政治或技术等主题,以及在医学影像中区分良性和恶性肿瘤。

关键点在于,经过分类微调的模型只能预测它在训练期间见过的类别。例如,如图 6.3 所示,它可以判断某段输入文本是“spam”还是“not spam”,但不能对输入文本说出其他内容。

LLM 文本分类场景
图 6.3 使用 LLM 进行文本分类的场景示意图。经过垃圾短信分类微调的模型,在输入旁边不需要额外指令。与指令微调模型不同,它只能回答“spam”和“not spam”。

与图 6.3 所示的分类微调模型相比,指令微调模型通常能够执行更广泛的任务。我们可以把分类微调模型看作高度专门化的模型;一般来说,开发一个专门化模型,要比开发一个能在多种任务上都表现良好的通用模型更容易。

6.2 准备数据集

在本章剩余部分,我们将修改并分类微调前几章实现和预训练的 GPT 模型。首先下载并准备数据集,如图 6.4 所示。

分类微调三阶段流程,阶段 1 为数据集准备
图 6.4 本章对 LLM 进行分类微调的三阶段流程示意图。阶段 1 包括数据集准备。阶段 2 聚焦于模型设置。阶段 3 涵盖模型微调和评估。

为了提供一个直观且有用的分类微调示例,我们将使用一个短信数据集,其中包含垃圾短信和非垃圾短信。

注意,这些是通常通过手机发送的短信,不是电子邮件。不过,同样的步骤也适用于电子邮件分类;感兴趣的读者可以在附录 B 的参考资料部分找到电子邮件垃圾分类数据集链接。

第一步是通过以下代码下载数据集:

清单 6.1 下载并解压数据集
import urllib.request
import zipfile
import os
from pathlib import Path


url = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip"
zip_path = "sms_spam_collection.zip"
extracted_path = "sms_spam_collection"
data_file_path = Path(extracted_path) / "SMSSpamCollection.tsv"


def download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path):
    if data_file_path.exists():
        print(f"{data_file_path} already exists. Skipping download and extraction.")
        return

    with urllib.request.urlopen(url) as response:                 #A
        with open(zip_path, "wb") as out_file:
            out_file.write(response.read())

    with zipfile.ZipFile(zip_path, "r") as zip_ref:               #B
        zip_ref.extractall(extracted_path)

    original_file_path = Path(extracted_path) / "SMSSpamCollection"
    os.rename(original_file_path, data_file_path)                 #C
    print(f"File downloaded and saved as {data_file_path}")


download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)

#A 下载文件
#B 解压文件
#C 添加 .tsv 文件扩展名

执行前面的代码后,数据集会作为制表符分隔的文本文件 SMSSpamCollection.tsv 保存到 sms_spam_collection 文件夹中。我们可以按如下方式把它加载到 pandas DataFrame 中:

import pandas as pd
df = pd.read_csv(data_file_path, sep="\t", header=None, names=["Label", "Text"])
df  #A

#A 在 Jupyter notebook 中渲染数据框;也可以使用 print(df)。

得到的垃圾短信数据集数据框如图 6.5 所示。

SMSSpamCollection 数据框预览
图 6.5 SMSSpamCollection 数据集在 pandas DataFrame 中的预览,展示类别标签(“ham”或“spam”)及对应短信文本。该数据集包含 5,572 行(短信和标签)。

我们来看一下类别标签的分布:

print(df["Label"].value_counts())

执行前面的代码后,我们发现数据中“ham”(也就是非垃圾短信)比“spam”多得多:

类别标签计数
Labelcount
ham4825
spam747
Name: count, dtype: int64

为简单起见,也因为出于教学目的我们更偏好较小的数据集(这样可以更快地微调大型语言模型),我们选择对数据集进行欠采样,使每个类别都包含 747 个实例。虽然还有其他处理类别不平衡的方法,但这些内容超出了本书讨论大型语言模型的范围。感兴趣的读者可以在附录 B 的参考资料部分找到关于处理不平衡数据的更多信息。

我们使用以下代码对数据集进行欠采样,并创建一个平衡数据集:

清单 6.2 创建平衡数据集
def create_balanced_dataset(df):
    num_spam = df[df["Label"] == "spam"].shape[0]                     #A
    ham_subset = df[df["Label"] == "ham"].sample(num_spam, random_state=123) #B
    balanced_df = pd.concat([ham_subset, df[df["Label"] == "spam"]])  #C
    return balanced_df


balanced_df = create_balanced_dataset(df)
print(balanced_df["Label"].value_counts())

#A 统计 "spam" 实例数量
#B 随机采样同等数量的 "ham" 实例
#C 将 ham 子集与 "spam" 合并

执行前面的代码来平衡数据集后,可以看到现在垃圾短信和非垃圾短信数量相同:

平衡后的类别标签计数
Labelcount
ham747
spam747
Name: count, dtype: int64

接下来,我们把字符串类别标签“ham”和“spam”分别转换为整数类别标签 0 和 1:

balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})

这个过程类似于把文本转换为 token ID。不过,这里不是使用包含 50,000 多个词的 GPT 词表,而是只处理两个 token ID:0 和 1。

我们创建一个 random_split 函数,将数据集分成三部分:70% 用于训练,10% 用于验证,20% 用于测试。(这些比例在机器学习中很常见,用于训练、调节和评估模型。)

清单 6.3 划分数据集
def random_split(df, train_frac, validation_frac):
    df = df.sample(frac=1, random_state=123).reset_index(drop=True)  #A

    train_end = int(len(df) * train_frac)                           #B
    validation_end = train_end + int(len(df) * validation_frac)

    train_df = df[:train_end]                                       #C
    validation_df = df[train_end:validation_end]
    test_df = df[validation_end:]

    return train_df, validation_df, test_df


train_df, validation_df, test_df = random_split(balanced_df, 0.7, 0.1) #D

#A 打乱整个 DataFrame
#B 计算划分索引
#C 划分 DataFrame
#D 测试集大小隐含为剩余的 0.2

此外,我们把数据集保存为 CSV(逗号分隔值)文件,方便稍后复用:

train_df.to_csv("train.csv", index=None)
validation_df.to_csv("validation.csv", index=None)
test_df.to_csv("test.csv", index=None)

本节中,我们下载了数据集,对它进行平衡,并把它划分为训练子集和评估子集。下一节将设置用于训练模型的 PyTorch 数据加载器。

6.3 创建数据加载器

本节将开发 PyTorch 数据加载器,它们在概念上类似于第 2 章中实现的数据加载器。

在第 2 章中,我们使用滑动窗口技术生成大小一致的文本块,然后把这些文本块组合成批次,以便更高效地训练模型。每个文本块都作为一个单独的训练实例。

然而,本章使用的垃圾短信数据集包含长度不同的短信。为了像第 2 章处理文本块那样把这些短信组成批次,我们主要有两个选择:

  1. 把所有短信截断到数据集或批次中最短短信的长度。
  2. 把所有短信填充到数据集或批次中最长短信的长度。

选项 1 的计算成本较低,但如果较短消息明显短于平均长度或最长消息,可能会造成大量信息损失,从而降低模型性能。因此,我们选择第二种方案,它保留所有消息的完整内容。

为了实现选项 2,也就是把所有短信填充到数据集中最长短信的长度,我们会向所有较短短信添加填充 token。为此,我们使用第 2 章讨论过的 <|endoftext|> 作为填充 token。

不过,我们不会直接把字符串 <|endoftext|> 附加到每条短信后面,而是可以把与 <|endoftext|> 对应的 token ID 添加到编码后的短信中,如图 6.6 所示。

短信转换为 token ID 并填充到统一长度
图 6.6 输入文本准备过程示意图。首先,每条输入短信被转换为 token ID 序列。然后,为了保证序列长度一致,较短序列会用填充 token(此处为 token ID 50256)填充到最长序列的长度。

图 6.6 假定 50,256 是填充 token <|endoftext|> 的 token ID。我们可以使用前几章中用过的 tiktoken 包里的 GPT-2 tokenizer,对 <|endoftext|> 进行编码,从而再次确认它确实是正确的 token ID:

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

执行前面的代码确实返回 [50256]

正如第 2 章所见,在实例化数据加载器之前,我们首先需要实现一个 PyTorch Dataset,指定数据如何加载和处理。

为此,我们定义 SpamDataset 类,它实现图 6.6 中展示的概念。这个 SpamDataset 类处理几个关键任务:识别训练数据集中最长的序列、对短信进行编码,并确保其他所有序列都用填充 token 填充到与最长序列相同的长度。

清单 6.4 设置 PyTorch Dataset 类
import torch
from torch.utils.data import Dataset


class SpamDataset(Dataset):
    def __init__(self, csv_file, tokenizer, max_length=None, pad_token_id=50256):
        self.data = pd.read_csv(csv_file)

        self.encoded_texts = [                         #A
            tokenizer.encode(text) for text in self.data["Text"]
        ]

        if max_length is None:
            self.max_length = self._longest_encoded_length()
        else:
            self.max_length = max_length
            self.encoded_texts = [                     #B
                encoded_text[:self.max_length]
                for encoded_text in self.encoded_texts
            ]

        self.encoded_texts = [                         #C
            encoded_text + [pad_token_id] * (self.max_length - len(encoded_text))
            for encoded_text in self.encoded_texts
        ]

    def __getitem__(self, index):
        encoded = self.encoded_texts[index]
        label = self.data.iloc[index]["Label"]
        return (
            torch.tensor(encoded, dtype=torch.long),
            torch.tensor(label, dtype=torch.long)
        )

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

    def _longest_encoded_length(self):
        max_length = 0
        for encoded_text in self.encoded_texts:
            encoded_length = len(encoded_text)
            if encoded_length > max_length:
                max_length = encoded_length
        return max_length

#A 预先对文本进行分词
#B 如果序列长于 max_length,则截断序列
#C 将序列填充到最长序列长度

SpamDataset 类从前面创建的 CSV 文件中加载数据,使用 tiktoken 的 GPT-2 tokenizer 对文本进行分词,并允许我们把序列填充或截断到统一长度;这个长度可以由最长序列决定,也可以由预定义的最大长度决定。这保证每个输入张量大小相同,而这正是创建训练数据加载器批次所必需的:

train_dataset = SpamDataset(
    csv_file="train.csv",
    max_length=None,
    tokenizer=tokenizer
)

注意,最长序列长度存储在数据集的 max_length 属性中。如果你想查看最长序列包含多少个 token,可以使用以下代码:

print(train_dataset.max_length)

这段代码输出 120,说明最长序列不超过 120 个 token,这对于短信来说是一个常见长度。值得注意的是,给定模型的上下文长度限制,它可以处理最长 1,024 个 token 的序列。如果你的数据集包含更长文本,可以在前面创建训练数据集时传入 max_length=1024,以确保数据不会超过模型支持的输入(上下文)长度。

接下来,我们把验证集和测试集填充到与最长训练序列相同的长度。需要注意的是,任何超过最长训练样本长度的验证集和测试集样本,都会在前面定义的 SpamDataset 代码中通过 encoded_text[:self.max_length] 被截断。这种截断是可选的;只要验证集和测试集中没有超过 1,024 个 token 的序列,你也可以把验证集和测试集都设置为 max_length=None

val_dataset = SpamDataset(
    csv_file="validation.csv",
    max_length=train_dataset.max_length,
    tokenizer=tokenizer
)
test_dataset = SpamDataset(
    csv_file="test.csv",
    max_length=train_dataset.max_length,
    tokenizer=tokenizer
)

使用这些数据集作为输入,我们可以像第 2 章那样实例化数据加载器。不过,在这里,目标表示的是类别标签,而不是文本中的下一个 token。例如,如果选择批大小为 8,那么每个批次会包含 8 个长度为 120 的训练样本,以及每个样本对应的类别标签,如图 6.7 所示。

批大小为 8 的短信 token ID 和标签
图 6.7 一个训练批次的示意图,其中包含 8 条表示为 token ID 的短信。每条短信由 120 个 token ID 组成。此外,类别标签数组保存与短信对应的 8 个类别标签,取值可以是 0(非垃圾短信)或 1(垃圾短信)。

以下代码创建训练集、验证集和测试集数据加载器,按照图 6.7 所示,以大小为 8 的批次加载短信和标签:

清单 6.5 创建 PyTorch 数据加载器
from torch.utils.data import DataLoader


num_workers = 0                                          #A
batch_size = 8
torch.manual_seed(123)


train_loader = DataLoader(
    dataset=train_dataset,
    batch_size=batch_size,
    shuffle=True,
    num_workers=num_workers,
    drop_last=True,
)
val_loader = DataLoader(
    dataset=val_dataset,
    batch_size=batch_size,
    num_workers=num_workers,
    drop_last=False,
)
test_loader = DataLoader(
    dataset=test_dataset,
    batch_size=batch_size,
    num_workers=num_workers,
    drop_last=False,
)

#A 这个设置可确保与大多数计算机兼容

为了确认数据加载器正常工作,并且确实返回预期大小的批次,我们遍历训练加载器,然后打印最后一个批次的张量维度:

for input_batch, target_batch in train_loader:
    pass
print("Input batch dimensions:", input_batch.shape)
print("Label batch dimensions", target_batch.shape)

输出如下:

Input batch dimensions: torch.Size([8, 120])
Label batch dimensions torch.Size([8])

可以看到,输入批次如预期那样包含 8 个训练样本,每个样本有 120 个 token。标签张量保存与这 8 个训练样本对应的类别标签。

最后,为了了解数据集大小,我们打印每个数据集中批次数量的总数:

print(f"{len(train_loader)} training batches")
print(f"{len(val_loader)} validation batches")
print(f"{len(test_loader)} test batches")

每个数据集中的批次数如下:

130 training batches
19 validation batches
38 test batches

到这里,本章的数据准备就完成了。接下来,我们将准备模型以进行微调。

6.4 用预训练权重初始化模型

本节将准备一个模型,用于进行分类微调以识别垃圾短信。我们从初始化上一章使用过的预训练模型开始,如图 6.8 所示。

分类微调三阶段流程,阶段 2 为初始化 LLM
图 6.8 本章对 LLM 进行分类微调的三阶段流程示意图。完成阶段 1,即准备数据集之后,本节聚焦于初始化将要微调用来分类垃圾短信的 LLM。

我们通过复用第 5 章中的配置来开始模型准备过程:

CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"
BASE_CONFIG = {
    "vocab_size": 50257,            # 词表大小
    "context_length": 1024,         # 上下文长度
    "drop_rate": 0.0,               # Dropout 率
    "qkv_bias": True                # 查询-键-值偏置
}
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},
}
BASE_CONFIG.update(model_configs[CHOOSE_MODEL])


assert train_dataset.max_length <= BASE_CONFIG["context_length"], (
    f"Dataset length {train_dataset.max_length} exceeds model's context "
    f"length {BASE_CONFIG['context_length']}. Reinitialize data sets with "
    f"`max_length={BASE_CONFIG['context_length']}`"
)

接下来,我们从第 5 章下载的 gpt_download.py 文件中导入 download_and_load_gpt2 函数。此外,我们还复用第 5 章中的 GPTModel 类和 load_weights_into_gpt 函数,把下载的权重加载到 GPT 模型中:

清单 6.6 加载预训练 GPT 模型
from gpt_download import download_and_load_gpt2
from chapter05 import GPTModel, load_weights_into_gpt


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

把模型权重加载到 GPTModel 之后,我们使用前几章中的文本生成工具函数,确认模型能够生成连贯文本:

from chapter04 import generate_text_simple
from chapter05 import text_to_token_ids, token_ids_to_text


text_1 = "Every effort moves you"
token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids(text_1, tokenizer),
    max_new_tokens=15,
    context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))

从以下输出可以看到,模型生成了连贯文本,这是模型权重正确加载的一个指标:

Every effort moves you forward.
The first step is to understand the importance of your work

现在,在开始把模型微调为垃圾短信分类器之前,我们先看看仅通过提示并给出指令,模型是否已经能够分类垃圾短信:

text_2 = (
    "Is the following text 'spam'? Answer with 'yes' or 'no':"
    " 'You are a winner you have been specially"
    " selected to receive $1000 cash or a $2000 award.'"
)
token_ids = generate_text_simple(
    model=model,
    idx=text_to_token_ids(text_2, tokenizer),
    max_new_tokens=23,
    context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))

模型输出如下:

Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been
specially selected to receive $1000 cash or a $2000 award.'
The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner

从输出可以明显看出,模型很难遵循指令。

这是预期之中的,因为它只经历了预训练,还没有经过指令微调;下一章会探讨指令微调。

下一节将为分类微调准备模型。

6.5 添加分类头

本节将修改预训练大型语言模型,使其准备好进行分类微调。为此,我们把原始输出层替换成一个更小的输出层:原输出层把隐藏表示映射到大小为 50,257 的词表,而新输出层把隐藏表示映射到两个类别:0(“not spam”)和 1(“spam”),如图 6.9 所示。

把 GPT 输出层替换为二分类输出层
图 6.9 这幅图展示了如何通过改变 GPT 模型架构来使其适配垃圾短信分类。最初,模型的线性输出层把 768 个隐藏单元映射到 50,257 个 token 的词表。对于垃圾短信检测,这个层被替换为新的输出层,把同样的 768 个隐藏单元映射到仅两个类别,分别表示“spam”和“not spam”。

如图 6.9 所示,除了替换输出层之外,我们使用的模型与前几章相同。

在尝试图 6.9 所示的修改之前,先通过 print(model) 打印模型架构,输出如下:

GPTModel(
    (tok_emb): Embedding(50257, 768)
    (pos_emb): Embedding(1024, 768)
    (drop_emb): Dropout(p=0.0, inplace=False)
    (trf_blocks): Sequential(
...
        (11): TransformerBlock(
            (att): MultiHeadAttention(
                (W_query): Linear(in_features=768, out_features=768, bias=True)
                (W_key): Linear(in_features=768, out_features=768, bias=True)
                (W_value): Linear(in_features=768, out_features=768, bias=True)
                (out_proj): Linear(in_features=768, out_features=768, bias=True)
                (dropout): Dropout(p=0.0, inplace=False)
            )
            (ff): FeedForward(
                (layers): Sequential(
                    (0): Linear(in_features=768, out_features=3072, bias=True)
                    (1): GELU()
                    (2): Linear(in_features=3072, out_features=768, bias=True)
                )
            )
            (norm1): LayerNorm()
            (norm2): LayerNorm()
            (drop_resid): Dropout(p=0.0, inplace=False)
        )
    )
    (final_norm): LayerNorm()
    (out_head): Linear(in_features=768, out_features=50257, bias=False)
)

上面可以清楚看到我们在第 4 章实现的架构。正如第 4 章所讨论的,GPTModel 由嵌入层开始,后接 12 个相同的 transformer block(为简洁起见,上面只展示最后一个 block),再接最后的 LayerNorm 和输出层 out_head

接下来,我们按照图 6.9 所示,把 out_head 替换成一个将被微调的新输出层。

为了让模型准备好进行分类微调,我们首先冻结模型,也就是把所有层设为不可训练:

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

然后,如图 6.9 所示,我们替换输出层(model.out_head)。原本它把层输入映射到 50,257 维(词表大小):

清单 6.7 添加分类层
torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(
    in_features=BASE_CONFIG["emb_dim"],
    out_features=num_classes
)

注意,在前面的代码中,我们使用 BASE_CONFIG["emb_dim"],在“gpt2-small (124M)”模型中它等于 768,这样可以让下面的代码更通用。这意味着我们也可以用同样的代码处理更大的 GPT-2 模型变体。

这个新的 model.out_head 输出层默认将其 requires_grad 属性设置为 True,这意味着它是训练期间唯一会被更新的层。

从技术上讲,只训练刚刚添加的输出层就已经足够。不过,我在实验中发现,微调额外的层可以显著提升微调后模型的预测性能。(更多细节请参见附录 C 中的参考资料。)

此外,我们把最后一个 transformer block 以及连接该 block 与输出层的最后 LayerNorm 模块配置为可训练,如图 6.10 所示。

GPT 模型中可训练层与冻结层的示意图
图 6.10 我们在前几章开发并在前面加载的 GPT 模型包含 12 个重复的 transformer block。除了输出层之外,我们把最后的 LayerNorm 和最后一个 transformer block 设为可训练,同时保持其余 11 个 transformer block 和嵌入层不可训练。

为了让最后的 LayerNorm 和最后一个 transformer block 可训练,如图 6.10 所示,我们把它们各自的 requires_grad 设置为 True

for param in model.trf_blocks[-1].parameters():
    param.requires_grad = True
for param in model.final_norm.parameters():
    param.requires_grad = True

即使我们添加了新的输出层,并把某些层标记为可训练或不可训练,仍然可以像前几章那样使用这个模型。例如,我们可以像之前一样向它输入一段示例文本。考虑下面这段示例文本:

inputs = tokenizer.encode("Do you have time")
inputs = torch.tensor(inputs).unsqueeze(0)
print("Inputs:", inputs)
print("Inputs dimensions:", inputs.shape) # shape: (batch_size, num_tokens)

打印输出显示,前面的代码把输入编码成了一个由 4 个输入 token 组成的张量:

Inputs: tensor([[5211,    345,     423,   640]])
Inputs dimensions: torch.Size([1, 4])

然后,我们可以照常把编码后的 token ID 传给模型:

with torch.no_grad():
    outputs = model(inputs)
print("Outputs:\n", outputs)
print("Outputs dimensions:", outputs.shape) # shape: (batch_size, num_tokens, num_classes)

输出张量如下:

Outputs:
 tensor([[[-1.5854,     0.9904],
          [-3.7235,     7.4548],
          [-2.2661,     6.6049],
          [-3.5983,     3.9902]]])
Outputs dimensions: torch.Size([1, 4, 2])

在第 4 章和第 5 章中,类似输入会产生形状为 [1, 4, 50257] 的输出张量,其中 50,257 表示词表大小。和前几章一样,输出行数对应输入 token 数量(这里是 4)。不过,由于替换了模型输出层,每个输出的嵌入维度(列数)现在从 50,257 降为 2。

请记住,我们希望微调这个模型,让它返回一个类别标签,指示模型输入是否为垃圾短信。为此,我们不需要微调全部 4 个输出行,而是可以聚焦于单个输出 token。具体来说,我们将聚焦于与最后一个输出 token 对应的最后一行,如图 6.11 所示。

4-token 输入下 GPT 分类输出张量的最后一行
图 6.11 带有 4-token 示例输入和输出的 GPT 模型示意图。由于修改了输出层,输出张量包含 2 列。在为垃圾短信分类微调模型时,我们只关注与最后一个 token 对应的最后一行。

为了从输出张量中提取图 6.11 所示的最后一个输出 token,我们使用以下代码:

print("Last output token:", outputs[:, -1, :])

它会打印:

Last output token: tensor([[-3.5983,      3.9902]])

在进入下一节之前,我们先回顾一下讨论内容。我们将重点放在把这些值转换为类别标签预测上。但首先,需要理解为什么我们特别关注最后一个输出 token,而不是第 1、第 2 或第 3 个输出 token。

第 3 章中,我们研究了注意力机制,它在每个输入 token 与其他所有输入 token 之间建立关系。随后,我们引入了因果注意力掩码的概念,它常用于类似 GPT 的模型。该掩码限制一个 token 只能关注当前位置及之前的位置,确保每个 token 只会受到自身和前面 token 的影响,如图 6.12 所示。

因果注意力掩码矩阵
图 6.12 第 3 章讨论过的因果注意力机制示意图,其中输入 token 之间的注意力分数以矩阵形式展示。空白单元格表示由于因果注意力掩码而被屏蔽的位置,防止 token 关注未来 token。单元格中的值表示注意力分数;最后一个 token “time” 是唯一会对所有前面 token 计算注意力分数的 token。

考虑图 6.12 所示的因果注意力掩码设置,序列中的最后一个 token 积累的信息最多,因为它是唯一能够访问所有前面 token 数据的 token。因此,在垃圾短信分类任务中,我们会在微调过程中关注这个最后 token。

完成模型修改后,下一节将详细说明如何把最后 token 转换为类别标签预测,并计算模型的初始预测准确率。之后,我们将在后续章节中针对垃圾短信分类任务微调模型。

6.6 计算分类损失和准确率

到目前为止,我们已经准备好数据集、加载了预训练模型,并为分类微调修改了它。在真正开始微调之前,只剩一个小部分:实现微调期间使用的模型评估函数,如图 6.13 所示。本节将完成这部分。

分类微调流程中实现评估工具函数的步骤
图 6.13 本章对 LLM 进行分类微调的三阶段流程示意图。本节实现阶段 2 的最后一步:实现用于评估模型在微调前、微调期间和微调后分类垃圾短信性能的函数。

在实现评估工具之前,我们先简要讨论如何把模型输出转换为类别标签预测。

上一章中,我们通过 softmax 函数把 50,257 个输出转换为概率,然后通过 argmax 函数返回最高概率所在位置,从而计算 LLM 生成的下一个 token 的 token ID。本章采用同样的方法,为给定输入计算模型输出的是“spam”还是“not spam”预测,如图 6.14 所示;唯一的区别是,我们处理的是 2 维输出,而不是 50,257 维输出。

最后 token logits 转换为类别概率和预测标签
图 6.14 与最后一个 token 对应的模型输出会被转换为每条输入文本的概率分数。随后,通过查找最高概率分数的索引位置获得类别标签。注意,由于模型尚未训练,它在图中错误地预测了垃圾短信标签。

为了用一个具体例子说明图 6.14,我们考虑上一节中的最后 token 输出:

print("Last output token:", outputs[:, -1, :])

与最后 token 对应的张量值如下:

Last output token: tensor([[-3.5983,             3.9902]])

可以通过以下代码获得类别标签:

probas = torch.softmax(outputs[:, -1, :], dim=-1)
label = torch.argmax(probas)
print("Class label:", label.item())

在这个例子中,代码返回 1,意味着模型预测输入文本是“spam”。这里使用 softmax 函数是可选的,因为最大输出值直接对应最高概率分数,正如第 5 章所提到的。因此,我们可以不使用 softmax,把代码简化如下:

logits = outputs[:, -1, :]
label = torch.argmax(logits)
print("Class label:", label.item())

这个概念可以用于计算所谓的分类准确率,它衡量整个数据集中正确预测所占的百分比。

为了确定分类准确率,我们把基于 argmax 的预测代码应用到数据集中的所有样本,并通过定义 calc_accuracy_loader 函数来计算正确预测比例:

清单 6.8 计算分类准确率
def calc_accuracy_loader(data_loader, model, device, num_batches=None):
    model.eval()
    correct_predictions, num_examples = 0, 0

    if num_batches is None:
        num_batches = len(data_loader)
    else:
        num_batches = min(num_batches, len(data_loader))
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i < num_batches:
            input_batch, target_batch = input_batch.to(device), target_batch.to(device)

            with torch.no_grad():
                logits = model(input_batch)[:, -1, :]       #A
            predicted_labels = torch.argmax(logits, dim=-1)

            num_examples += predicted_labels.shape[0]
            correct_predictions += (predicted_labels == target_batch).sum().item()
        else:
            break
    return correct_predictions / num_examples

#A 最后一个输出 token 的 logits

为提高效率,我们使用这个函数估计各个数据集上基于 10 个批次的分类准确率:

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


torch.manual_seed(123)
train_accuracy = calc_accuracy_loader(train_loader, model, device, num_batches=10)
val_accuracy = calc_accuracy_loader(val_loader, model, device, num_batches=10)
test_accuracy = calc_accuracy_loader(test_loader, model, device, num_batches=10)


print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

通过 device 设置,如果有支持 Nvidia CUDA 的 GPU,模型会自动在 GPU 上运行;否则会在 CPU 上运行。输出如下:

Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%

可以看到,预测准确率接近随机预测,在这个二分类场景中随机预测约为 50%。为了提升预测准确率,我们需要微调模型。

不过,在开始微调模型之前,需要定义训练过程中要优化的损失函数。我们的目标是最大化模型的垃圾短信分类准确率,也就是说,前面的代码应该输出正确的类别标签:非垃圾短信文本为 0,垃圾短信文本为 1。

然而,分类准确率不是可微函数,因此我们使用交叉熵损失作为最大化准确率的代理目标。这与第 5 章讨论过的交叉熵损失相同。

因此,calc_loss_batch 函数与第 5 章保持一致,只做一个调整:我们只聚焦于优化最后一个 token,也就是 model(input_batch)[:, -1, :],而不是所有 token,即 model(input_batch)

def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch, target_batch = input_batch.to(device), target_batch.to(device)
    logits = model(input_batch)[:, -1, :]      # 最后一个输出 token 的 logits
    loss = torch.nn.functional.cross_entropy(logits, target_batch)
    return loss

我们使用 calc_loss_batch 函数来计算从前面定义的数据加载器中取得的单个批次的损失。为了计算一个数据加载器中所有批次的损失,我们定义 calc_loss_loader 函数,它与第 5 章描述的函数相同:

清单 6.9 计算分类损失
def calc_loss_loader(data_loader, model, device, num_batches=None):
    total_loss = 0.
    if len(data_loader) == 0:
        return float("nan")
    elif num_batches is None:
        num_batches = len(data_loader)
    else:                                                     #A
        num_batches = min(num_batches, len(data_loader))
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i < num_batches:
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            total_loss += loss.item()
        else:
            break
    return total_loss / num_batches


# 与计算训练准确率类似,现在计算每个数据集的初始损失:
with torch.no_grad():                                      #B
    train_loss = calc_loss_loader(train_loader, model, device, num_batches=5)
    val_loss = calc_loss_loader(val_loader, model, device, num_batches=5)
    test_loss = calc_loss_loader(test_loader, model, device, num_batches=5)

#A 确保批次数不超过数据加载器中的批次数
#B 为提高效率关闭梯度跟踪,因为我们还没有开始训练


print(f"Training loss: {train_loss:.3f}")
print(f"Validation loss: {val_loss:.3f}")
print(f"Test loss: {test_loss:.3f}")

初始损失值如下:

Training loss: 3.095
Validation loss: 2.583
Test loss: 2.322

下一节将实现一个训练函数来微调模型,这意味着调整模型以最小化训练集损失。最小化训练集损失会帮助提高分类准确率,而这正是我们的总体目标。

6.7 在监督数据上微调模型

本节将定义并使用训练函数来微调预训练 LLM,并提升其垃圾短信分类准确率。图 6.15 所示的训练循环,与第 5 章使用的整体训练循环相同;唯一的区别是,这里计算分类准确率,而不是生成一段样本文本来评估模型。

深度神经网络训练循环
图 6.15 PyTorch 中训练深度神经网络的典型训练循环由若干步骤组成,会在多个 epoch 中遍历训练集批次。每次循环中,我们计算当前训练批次的损失以确定损失梯度,并用这些梯度更新模型权重,从而最小化训练集损失。

实现图 6.15 概念的训练函数,也非常接近第 5 章用于预训练模型的 train_model_simple 函数。

只有两处区别:现在我们跟踪的是已见过的训练样本数量(examples_seen),而不是 token 数量;并且每个 epoch 后计算准确率,而不是打印样本文本:

清单 6.10 微调模型以分类垃圾短信
def train_classifier_simple(model, train_loader, val_loader, optimizer, device,
                            num_epochs, eval_freq, eval_iter, tokenizer):
    # 初始化用于跟踪损失和已见样本数量的列表
    train_losses, val_losses, train_accs, val_accs = [], [], [], []
    examples_seen, global_step = 0, -1

    # 主训练循环
    for epoch in range(num_epochs):
        model.train()                                      #A

        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()                          #B
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            loss.backward()                                #C
            optimizer.step()                               #D
            examples_seen += input_batch.shape[0]          #E
            global_step += 1

            if global_step % eval_freq == 0:               #F
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                print(f"Ep {epoch+1} (Step {global_step:06d}): "
                      f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")

        train_accuracy = calc_accuracy_loader(             #G
            train_loader, model, device, num_batches=eval_iter
        )
        val_accuracy = calc_accuracy_loader(
            val_loader, model, device, num_batches=eval_iter
        )

        print(f"Training accuracy: {train_accuracy*100:.2f}% | ", end="")
        print(f"Validation accuracy: {val_accuracy*100:.2f}%")
        train_accs.append(train_accuracy)
        val_accs.append(val_accuracy)

    return train_losses, val_losses, train_accs, val_accs, examples_seen

#A 将模型设为训练模式
#B 重置上一次批次迭代的损失梯度
#C 计算损失梯度
#D 使用损失梯度更新模型权重
#E 新增:跟踪样本数量,而不是 token 数量
#F 可选的评估步骤
#G 每个 epoch 后计算准确率

前面 train_classifier_simple 中使用的 evaluate_model 函数,与第 5 章中使用的函数相同:

def evaluate_model(model, train_loader, val_loader, device, eval_iter):
    model.eval()
    with torch.no_grad():
        train_loss = calc_loss_loader(train_loader, model, device,
                                      num_batches=eval_iter)
        val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
    model.train()
    return train_loss, val_loss

接下来,我们初始化优化器,设置训练 epoch 数,并使用 train_classifier_simple 函数启动训练。我们会在评估结果后讨论训练 epoch 数的选择。在 M3 MacBook Air 笔记本电脑上,训练大约需要 6 分钟;在 V100 或 A100 GPU 上不到半分钟:

import time


start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.1)
num_epochs = 5


train_losses, val_losses, train_accs, val_accs, examples_seen = train_classifier_simple(
    model, train_loader, val_loader, optimizer, device,
    num_epochs=num_epochs, eval_freq=50, eval_iter=5,
    tokenizer=tokenizer
)


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

训练期间看到的输出如下:

Ep 1 (Step 000000): Train loss 2.153, Val loss 2.392
Ep 1 (Step 000050): Train loss 0.617, Val loss 0.637
Ep 1 (Step 000100): Train loss 0.523, Val loss 0.557
Training accuracy: 70.00% | Validation accuracy: 72.50%
Ep 2 (Step 000150): Train loss 0.561, Val loss 0.489
Ep 2 (Step 000200): Train loss 0.419, Val loss 0.397
Ep 2 (Step 000250): Train loss 0.409, Val loss 0.353
Training accuracy: 82.50% | Validation accuracy: 85.00%
Ep 3 (Step 000300): Train loss 0.333, Val loss 0.320
Ep 3 (Step 000350): Train loss 0.340, Val loss 0.306
Training accuracy: 90.00% | Validation accuracy: 90.00%
Ep 4 (Step 000400): Train loss 0.136, Val loss 0.200
Ep 4 (Step 000450): Train loss 0.153, Val loss 0.132
Ep 4 (Step 000500): Train loss 0.222, Val loss 0.137
Training accuracy: 100.00% | Validation accuracy: 97.50%
Ep 5 (Step 000550): Train loss 0.207, Val loss 0.143
Ep 5 (Step 000600): Train loss 0.083, Val loss 0.074
Training accuracy: 100.00% | Validation accuracy: 97.50%
Training completed in 5.65 minutes.

类似第 5 章,我们随后使用 matplotlib 绘制训练集和验证集上的损失函数:

清单 6.11 绘制分类损失
import matplotlib.pyplot as plt


def plot_values(epochs_seen, examples_seen, train_values, val_values, label="loss"):
    fig, ax1 = plt.subplots(figsize=(5, 3))

    ax1.plot(epochs_seen, train_values, label=f"Training {label}")        #A
    ax1.plot(epochs_seen, val_values, linestyle="-.", label=f"Validation {label}")
    ax1.set_xlabel("Epochs")
    ax1.set_ylabel(label.capitalize())
    ax1.legend()

    ax2 = ax1.twiny()                                                     #B
    ax2.plot(examples_seen, train_values, alpha=0)  # 用不可见曲线对齐刻度
    ax2.set_xlabel("Examples seen")

    fig.tight_layout()                                                    #C
    plt.savefig(f"{label}-plot.pdf")
    plt.show()

#A 绘制随 epoch 变化的训练损失和验证损失
#B 创建第二条 x 轴表示已见样本数
#C 调整布局以留出空间


epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_losses))


plot_values(epochs_tensor, examples_seen_tensor, train_losses, val_losses)

得到的损失曲线如图 6.16 所示。

训练损失和验证损失曲线
图 6.16 该图展示模型在五个训练 epoch 中的训练损失和验证损失。训练损失用实线表示,验证损失用虚线表示;二者都在第一个 epoch 中急剧下降,并在第五个 epoch 附近逐渐稳定。这个模式表明学习进展良好,也说明模型既从训练数据中学到了内容,又能很好地泛化到未见过的验证数据。

从图 6.16 中的陡峭下降趋势可以看出,模型很好地从训练数据中学习,而且几乎没有过拟合迹象;也就是说,训练集损失和验证集损失之间没有明显差距。

使用同一个 plot_values 函数,现在也来绘制分类准确率:

epochs_tensor = torch.linspace(0, num_epochs, len(train_accs))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_accs))


plot_values(epochs_tensor, examples_seen_tensor, train_accs, val_accs, label="accuracy")

得到的准确率图如图 6.17 所示。

训练准确率和验证准确率曲线
图 6.17 训练准确率(实线)和验证准确率(虚线)都在早期 epoch 中显著上升,随后进入平台期,几乎达到 1.0 的完美准确率。两条曲线在整个训练过程中距离很近,说明模型并没有明显过拟合训练数据。

根据图 6.17 的准确率图,模型在第 4 和第 5 个 epoch 后获得了相对较高的训练准确率和验证准确率。

不过需要注意的是,我们前面使用 train_classifier_simple 函数时设置了 eval_iter=5,这意味着出于训练效率考虑,训练和验证性能估计只基于 5 个批次。

现在,我们将对整个数据集计算训练集、验证集和测试集上的性能指标。这一次运行以下代码,不再定义 eval_iter 值:

train_accuracy = calc_accuracy_loader(train_loader, model, device)
val_accuracy = calc_accuracy_loader(val_loader, model, device)
test_accuracy = calc_accuracy_loader(test_loader, model, device)


print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")

得到的准确率数值如下:

Training accuracy: 97.21%
Validation accuracy: 97.32%
Test accuracy: 95.67%

训练集和测试集性能几乎相同。

训练集准确率和测试集准确率之间存在轻微差异,说明对训练数据的过拟合很小。通常,验证集准确率会略高于测试集准确率,因为模型开发常常涉及调节超参数,使其在验证集上表现良好,而这些设置不一定能同样有效地泛化到测试集。

这种情况很常见,但可以通过调整模型设置来潜在地缩小差距,例如提高 dropout 率(drop_rate)或优化器配置中的 weight_decay 参数。

6.8 将 LLM 用作垃圾短信分类器

在前几节完成模型微调和评估之后,现在进入本章的最后阶段,如图 6.18 所示:使用模型对垃圾短信进行分类。

分类微调流程中使用模型分类新文本的步骤
图 6.18 本章对 LLM 进行分类微调的三阶段流程示意图。本节实现阶段 3 的最后一步:使用微调后的模型分类新的垃圾短信。

最后,让我们使用经过微调的、基于 GPT 的垃圾短信分类模型。下面的 classify_review 函数遵循与本章前面实现的 SpamDataset 类似的数据预处理步骤。随后,在把文本处理为 token ID 后,该函数使用模型预测一个整数类别标签,这与第 6.6 节中的实现类似;最后返回对应的类别名称:

清单 6.12 使用模型分类新文本
def classify_review(text, model, tokenizer, device, max_length=None,
                    pad_token_id=50256):
    model.eval()

    input_ids = tokenizer.encode(text)                            #A
    supported_context_length = model.pos_emb.weight.shape[1]

    input_ids = input_ids[:min(max_length, supported_context_length)] #B

    input_ids += [pad_token_id] * (max_length - len(input_ids))   #C
    input_tensor = torch.tensor(input_ids, device=device).unsqueeze(0) #D

    with torch.no_grad():                                         #E
        logits = model(input_tensor)[:, -1, :]                    #F
    predicted_label = torch.argmax(logits, dim=-1).item()

    return "spam" if predicted_label == 1 else "not spam"         #G

#A 准备模型输入
#B 如果序列过长,则截断序列
#C 将序列填充到最长序列长度
#D 添加批次维度
#E 不跟踪梯度进行模型推理
#F 最后一个输出 token 的 logits
#G 返回分类结果

我们在一段示例文本上试试这个 classify_review 函数:

text_1 = (
    "You are a winner you have been specially"
    " selected to receive $1000 cash or a $2000 award."
)


print(classify_review(
    text_1, model, tokenizer, device, max_length=train_dataset.max_length
))

得到的模型结果正确预测为“spam”。接下来,再试一个例子:

text_2 = (
    "Hey, just wanted to check if we're still on"
    " for dinner tonight? Let me know!"
)


print(classify_review(
    text_2, model, tokenizer, device, max_length=train_dataset.max_length
))

这里,模型同样做出了正确预测,并返回“not spam”标签。

最后,如果希望以后复用模型而不必重新训练,可以使用上一章介绍过的 torch.save 方法保存模型:

torch.save(model.state_dict(), "review_classifier.pth")

保存后,可以按如下方式加载模型:

model_state_dict = torch.load("review_classifier.pth")
model.load_state_dict(model_state_dict)

6.9 小结

源 PDF 物理页范围:204-247。图像已嵌入为 base64 data URI;MathJax 脚本来自工作区本地副本并内联到本文件中。