本章内容
- 介绍不同的大型语言模型(LLM)微调方法
- 准备用于文本分类的数据集
- 修改预训练LLM以便进行微调
- 微调LLM以识别垃圾信息
- 评估微调后的LLM分类器的准确性
- 使用微调后的LLM对新数据进行分类
到目前为止,我们已经编写了LLM的架构、对其进行了预训练,并学习了如何从外部来源(如OpenAI)导入预训练权重到我们的模型中。现在,我们将通过微调LLM用于特定的目标任务(如文本分类)来收获我们的成果。本章的具体示例是将短信分类为"垃圾信息"或"非垃圾信息"。图6.1展示了微调LLM的两种主要方式:用于分类的微调(步骤8)和用于执行指令的微调(步骤9)。
微调的不同类别
微调语言模型的最常见方式是指令微调和分类微调。指令微调通过使用特定的指令训练语言模型,以提高其理解和执行自然语言提示中描述的任务的能力,如图6.2所示。
在分类微调中,如果你有机器学习背景,你可能已经熟悉这个概念,模型被训练以识别一组特定的类别标签,例如"垃圾邮件"和"非垃圾邮件"。分类任务的示例不仅限于LLM和电子邮件过滤,还包括从图像中识别不同种类的植物;将新闻文章分类为体育、政治和技术等主题;以及在医学成像中区分良性和恶性肿瘤。
关键点是,分类微调模型只能预测它在训练期间遇到的类别。例如,它可以判断某些内容是"垃圾邮件"还是"非垃圾邮件",如图6.3所示,但它无法提供关于输入文本的其他信息。
与图6.3中展示的分类微调模型相比,指令微调模型通常可以执行更广泛的任务。我们可以将分类微调模型视为高度专业化的模型,而通常开发一个在特定任务上表现良好的专业化模型要比开发一个能够在各种任务中表现出色的通用模型更容易。
选择合适的微调方法
指令微调提升模型根据特定用户指令理解和生成响应的能力,最适合需要处理多种任务的模型,以提高其灵活性和交互质量。而分类微调则更适合需要将数据精确分类为预定义类别的项目,如情感分析或垃圾邮件检测。
虽然指令微调更具通用性,但它需要更大的数据集和更多的计算资源来开发能够熟练处理各种任务的模型。相比之下,分类微调所需的数据和计算资源较少,但其应用范围仅限于模型训练时涉及的特定类别。
准备数据集
我们将修改并对之前实现和预训练的GPT模型进行分类微调。首先,我们将下载并准备数据集,如图6.4所示。为了提供一个直观且有用的分类微调示例,我们将使用一个包含垃圾短信和非垃圾短信的文本消息数据集。
注意 文本消息通常是通过手机发送的,而非电子邮件。然而,电子邮件分类的步骤也是相同的,感兴趣的读者可以在附录B找到电子邮件垃圾分类数据集的链接。
第一步是下载数据集。
代码示例 6.1 下载并解压数据集
python
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存在,跳过下载和解压.")
return
with urllib.request.urlopen(url) as response: #1
with open(zip_path, "wb") as out_file:
out_file.write(response.read())
with zipfile.ZipFile(zip_path, "r") as zip_ref: #2
zip_ref.extractall(extracted_path)
original_file_path = Path(extracted_path) / "SMSSpamCollection"
os.rename(original_file_path, data_file_path) #3
print(f"文件已下载并保存为 {data_file_path}")
download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)
#1 下载文件
#2 解压文件
#3 添加 .tsv
文件扩展名
执行上述代码后,数据集将被保存为一个制表符分隔的文本文件,名为SMSSpamCollection.tsv
,存储在sms_spam_collection
文件夹中。我们可以将其加载到pandas DataFrame中,如下所示:
ini
import pandas as pd
df = pd.read_csv(
data_file_path, sep="\t", header=None, names=["Label", "Text"]
)
df #1
#1 在Jupyter notebook中渲染数据框架。或者,使用print(df)
来查看数据。
图6.5展示了垃圾短信数据集的数据框架。
让我们检查一下类别标签的分布:
bash
print(df["Label"].value_counts())
执行前面的代码后,我们发现数据集中"ham"(即非垃圾邮件)的数量远远多于"spam":
yaml
Label
ham 4825
spam 747
Name: count, dtype: int64
为了简单起见,同时也因为我们更喜欢较小的数据集(这样可以更快地微调LLM),我们选择对数据集进行欠采样,使每个类别包含747个实例。
注意 还有许多其他方法来处理类别不平衡,但这些超出了本书的范围。感兴趣的读者可以在附录B中找到更多关于处理不平衡数据的方法。
我们可以使用以下代码来进行欠采样并创建一个平衡的数据集。
代码示例 6.2 创建一个平衡的数据集
ini
def create_balanced_dataset(df):
num_spam = df[df["Label"] == "spam"].shape[0] #1
ham_subset = df[df["Label"] == "ham"].sample(
num_spam, random_state=123
) #2
balanced_df = pd.concat([
ham_subset, df[df["Label"] == "spam"]
]) #3
return balanced_df
balanced_df = create_balanced_dataset(df)
print(balanced_df["Label"].value_counts())
#1 计算"spam"实例的数量
#2 随机采样"ham"实例以匹配"spam"实例的数量
#3 将"ham"子集与"spam"实例组合
执行完上述代码后,我们可以看到现在数据集中垃圾短信和非垃圾短信的数量相等:
yaml
Label
ham 747
spam 747
Name: count, dtype: int64
接下来,我们将字符串形式的类别标签"ham"和"spam"分别转换为整数标签0和1:
css
balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})
此过程类似于将文本转换为标记ID。然而,这里我们不是使用包含超过50,000个词的GPT词汇表,而是只处理两个标记ID:0和1。
接下来,我们创建一个random_split
函数,将数据集分为三个部分:70%用于训练,10%用于验证,20%用于测试。(这些比例在机器学习中很常见,用于训练、调整和评估模型。)
代码示例 6.3 数据集划分
ini
def random_split(df, train_frac, validation_frac):
df = df.sample(
frac=1, random_state=123
).reset_index(drop=True) #1
train_end = int(len(df) * train_frac) #2
validation_end = train_end + int(len(df) * validation_frac) #3
train_df = df[:train_end]
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) #4
#1 随机打乱整个DataFrame
#2 计算数据集划分的索引
#3 将DataFrame划分为训练、验证和测试集
#4 测试集的大小为剩余部分,即20%
让我们将数据集保存为CSV(逗号分隔值)文件,以便以后可以重复使用:
ini
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数据加载器。
创建数据加载器
我们将开发与之前处理文本数据时概念上类似的PyTorch数据加载器。之前,我们使用滑动窗口技术生成了大小统一的文本块,然后将它们分组为批次,以提高模型训练的效率。每个块都作为单独的训练实例。然而,现在我们正在处理一个包含不同长度短信的垃圾短信数据集。为了像处理文本块一样批量处理这些短信,我们有两个主要选择:
- 将所有消息截断为数据集中或批次中最短消息的长度。
- 将所有消息填充到数据集中或批次中最长消息的长度。
第一个选项计算开销较低,但如果较短的消息比平均或最长消息小得多,可能会导致重要信息的丢失,从而降低模型性能。因此,我们选择第二个选项,保留所有消息的完整内容。
为了实现批处理,我们将所有较短的消息填充到与数据集中最长消息相同的长度。为此,我们将添加填充标记"<|endoftext|>"
。
不过,与其直接将字符串"<|endoftext|>"
附加到每条消息后面,我们可以将与"<|endoftext|>"
对应的标记ID添加到编码的消息中,如图6.6所示。"<|endoftext|>"
的标记ID是50256。我们可以通过使用之前使用的tiktoken
包中的GPT-2分词器来对"<|endoftext|>"
进行编码,来验证该标记ID是否正确:
ini
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))
确实,执行前面的代码返回 [50256]。
首先,我们需要实现一个PyTorch Dataset
类,它定义了如何加载和处理数据,之后才能实例化数据加载器。为此,我们定义了 SpamDataset
类,该类实现了图6.6中的概念。SpamDataset
类处理几个关键任务:它识别训练数据集中最长的序列,对短信进行编码,并确保所有其他序列都填充填充标记以匹配最长序列的长度。
代码示例 6.4 设置PyTorch Dataset
类
python
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) #1
self.encoded_texts = [
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 #2
self.encoded_texts = [
encoded_text[:self.max_length]
for encoded_text in self.encoded_texts
]
self.encoded_texts = [
encoded_text + [pad_token_id] *
(self.max_length - len(encoded_text))
for encoded_text in self.encoded_texts
] #3
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
#1 对文本进行预编码
#2 如果序列长度超过 max_length
,则截断
#3 将序列填充到最长的序列长度
SpamDataset
类从我们之前创建的CSV文件中加载数据,使用 tiktoken
中的 GPT-2 分词器对文本进行分词,并允许我们根据最长序列或预定义的最大长度填充或截断序列。这确保了每个输入张量的大小相同,这对于我们接下来实现的训练数据加载器中的批处理是必要的:
ini
train_dataset = SpamDataset(
csv_file="train.csv",
max_length=None,
tokenizer=tokenizer
)
最长序列的长度存储在数据集的 max_length
属性中。如果你想查看最长序列的标记数量,可以使用以下代码:
scss
print(train_dataset.max_length)
代码输出120,表示最长的序列不超过120个标记,这是短信常见的长度。模型能够处理最多1,024个标记的序列,这是其上下文长度限制。如果数据集中包含更长的文本,可以在创建训练数据集时传递 max_length=1024
,以确保数据不会超过模型支持的输入(上下文)长度。
接下来,我们对验证集和测试集进行填充,使其长度与最长的训练序列匹配。重要的是,任何超过最长训练示例长度的验证集和测试集样本都将通过 encoded_text[:self.max_length]
进行截断,这在我们之前定义的 SpamDataset
代码中进行了处理。这个截断是可选的;只要验证集和测试集中的序列不超过1,024个标记,你可以将 max_length=None
应用于这些集:
ini
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
)
练习 6.1 增加上下文长度
将输入填充到模型支持的最大标记数,观察这如何影响预测性能。
使用这些数据集作为输入,我们可以像处理文本数据时一样实例化数据加载器。然而,在这种情况下,目标是类别标签,而不是文本中的下一个标记。例如,如果我们选择批次大小为8,则每个批次将由8个长度为120的训练样本及其对应的类别标签组成,如图6.7所示。
以下代码创建了训练集、验证集和测试集的数据加载器,每次以大小为8的批次加载短信和标签。
代码示例 6.5 创建 PyTorch 数据加载器
ini
from torch.utils.data import DataLoader
num_workers = 0 #1
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,
)
#1 此设置确保与大多数计算机兼容。
为了确保数据加载器正常工作并返回预期大小的批次,我们迭代训练加载器并打印最后一个批次的张量维度:
python
for input_batch, target_batch in train_loader:
pass
print("Input batch dimensions:", input_batch.shape)
print("Label batch dimensions", target_batch.shape)
输出结果为:
css
Input batch dimensions: torch.Size([8, 120])
Label batch dimensions torch.Size([8])
如我们所料,输入批次由八个包含120个标记的训练样本组成。标签张量存储了与这八个训练样本对应的类别标签。
最后,为了了解数据集的大小,我们打印每个数据集中的批次数量:
python
print(f"{len(train_loader)} training batches")
print(f"{len(val_loader)} validation batches")
print(f"{len(test_loader)} test batches")
每个数据集中的批次数量如下:
bash
130 training batches
19 validation batches
38 test batches
现在我们已经准备好了数据,接下来需要为模型的微调做准备。
初始化具有预训练权重的模型
我们需要为分类微调做好准备,以识别垃圾短信。首先,我们初始化预训练的模型,如图6.8所示。
开始模型准备过程,我们使用了与预训练无标签数据时相同的配置:
makefile
CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"
BASE_CONFIG = {
"vocab_size": 50257, #1 # 词汇表大小
"context_length": 1024, #2 # 上下文长度
"drop_rate": 0.0, #3 # 丢弃率
"qkv_bias": True #4 # 查询-键-值偏差
}
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])
#1 词汇表大小
#2 上下文长度
#3 丢弃率
#4 查询-键-值偏差
接下来,我们从 gpt_download.py
文件中导入 download_and_load_gpt2
函数,并复用第5章中的 GPTModel
类和 load_weights_into_gpt
函数,将下载的权重加载到 GPT 模型中。
代码示例 6.6 加载预训练的 GPT 模型
ini
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
后,我们可以复用第4章和第5章中的文本生成工具函数,确保模型能够生成连贯的文本:
ini
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))
模型生成的文本输出如下,表明模型权重已正确加载:
vbnet
Every effort moves you forward.
The first step is to understand the importance of your work
在我们将模型微调为垃圾邮件分类器之前,先看看模型能否通过指令已经识别垃圾邮件:
ini
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))
模型输出如下:
dart
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
从输出可以看出,模型在遵循指令方面存在困难。这是预期结果,因为模型只经过了预训练,并未进行指令微调。因此,我们将为模型的分类微调做好准备。
添加分类头
我们需要修改预训练的LLM,以便为分类微调做准备。为此,我们将原来的输出层替换掉,原输出层将隐藏表示映射到50,257的词汇表中,而新输出层则映射到两个类别:0("非垃圾邮件")和1("垃圾邮件"),如图6.9所示。我们使用与之前相同的模型,唯一区别是替换了输出层。
输出层节点
技术上来说,我们可以使用单个输出节点来处理二分类任务。然而,这会要求我们修改损失函数,我在《Losses Learned---Optimizing Negative Log-Likelihood and Cross-Entropy in PyTorch》(mng.bz/NRZ2)中对此进行了讨论。因此,我们选择一种更通用的方法,即输出节点的数量与类别的数量相匹配。例如,对于一个三分类问题,如将新闻文章分类为"科技"、"体育"或"政治",我们将使用三个输出节点,依此类推。
在尝试图6.9所示的修改之前,我们先通过 print(model)
来打印模型的架构:
ini
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章中设计的模型架构。正如之前讨论的,GPTModel
由嵌入层、12个相同的Transformer块(为简洁起见,只展示了最后一个块),最后是 LayerNorm
和输出层 out_head
组成。
接下来,我们将 out_head
替换为一个新的输出层(见图6.9),并对其进行微调。
微调选定的层 vs. 全部层
由于我们是从一个预训练模型开始,并不需要微调所有的模型层。在基于神经网络的语言模型中,底层通常捕捉的是通用的语言结构和语义,适用于各种任务和数据集。因此,通常只需要微调靠近输出层的最后几层,这些层捕捉的是更具体的语言模式和任务相关的特征。这样不仅能够更高效地适应新任务,还能提高计算效率。感兴趣的读者可以在附录B中找到关于微调哪些层的更多信息和实验。
为了让模型准备好进行分类微调,我们首先冻结模型,即将所有层设置为不可训练:
ini
for param in model.parameters():
param.requires_grad = False
然后,我们将输出层(model.out_head
)替换掉,原输出层将输入映射为50,257维,即词汇表的大小(见图6.9)。
代码示例 6.7 添加分类层
ini
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
,这意味着它是模型中唯一会在训练期间更新的层。从技术上讲,训练我们刚刚添加的输出层已经足够了。然而,正如我在实验中发现的,微调额外的层可以显著提高模型的预测性能。(有关详细信息,请参阅附录B。)我们还将最后一个Transformer块和连接该块到输出层的 LayerNorm
模块设置为可训练,如图6.10所示。
要让最后的 LayerNorm
和最后一个 transformer block 可训练,我们需要将它们的 requires_grad
属性设置为 True
:
ini
for param in model.trf_blocks[-1].parameters():
param.requires_grad = True
for param in model.final_norm.parameters():
param.requires_grad = True
练习 6.2 微调整个模型
与其仅仅微调最后一个 transformer block,不如微调整个模型,并评估其对预测性能的影响。
即使我们添加了一个新的输出层,并将某些层标记为可训练或不可训练,我们仍然可以像以前一样使用这个模型。例如,我们可以将之前使用的相同文本输入模型:
bash
inputs = tokenizer.encode("Do you have time")
inputs = torch.tensor(inputs).unsqueeze(0)
print("Inputs:", inputs)
print("Inputs dimensions:", inputs.shape) #1
#1 shape: (batch_size, num_tokens)
打印输出显示,前面的代码将输入编码为包含四个输入 token 的 tensor:
lua
Inputs: tensor([[5211, 345, 423, 640]])
Inputs dimensions: torch.Size([1, 4])
然后,我们可以像往常一样将编码后的 token ID 传递给模型:
scss
with torch.no_grad():
outputs = model(inputs)
print("Outputs:\n", outputs)
print("Outputs dimensions:", outputs.shape)
输出的 tensor 如下所示:
ini
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])
类似的输入以前会生成一个形状为 [1, 4, 50257]
的输出 tensor,其中 50257
表示词汇表的大小。输出行的数量对应于输入 token 的数量(在本例中为四个)。然而,由于我们替换了模型的输出层,输出的嵌入维度(列数)现在是 2,而不是 50,257。
请记住,我们的目的是微调该模型,以返回一个表示输入是 "spam" 还是 "not spam" 的类别标签。我们不需要微调所有四个输出行,而是可以专注于一个输出 token。特别是,我们将重点关注对应于最后一个输出 token 的最后一行,如图6.11所示。
要从输出 tensor 中提取最后一个输出 token,可以使用以下代码:
bash
print("Last output token:", outputs[:, -1, :])
输出结果为:
lua
Last output token: tensor([[-3.5983, 3.9902]])
我们仍然需要将这些值转换为类别标签的预测结果。但是首先,让我们理解为什么特别关注最后一个输出 token。
我们之前已经探索了注意力机制,它建立了每个输入 token 与其他所有输入 token 之间的关系,并且我们讨论了在类似 GPT 模型中常用的因果注意力掩码的概念(参见第 3 章)。这种掩码限制了一个 token 的关注范围,仅限于其当前位置及之前的位置,确保每个 token 只能受到其自身和之前 token 的影响,如图 6.12 所示。
由于图 6.12 中的因果注意力掩码设置,序列中的最后一个 token 累积了最多的信息,因为它是唯一可以访问所有之前 token 数据的 token。因此,在垃圾邮件分类任务中,我们在微调过程中关注这个最后的 token。
现在我们已经准备好将最后的 token 转换为类别标签预测,并计算模型的初始预测准确率。接下来,我们将微调模型以完成垃圾邮件分类任务。
练习 6.3:微调第一个 vs. 最后一个 token
尝试微调第一个输出 token。注意与微调最后一个输出 token 相比,预测性能的变化。
计算分类损失和准确率
在我们微调模型之前,还有一个小任务:我们必须实现微调过程中使用的模型评估函数,如图 6.13 所示。
在实现评估工具之前,让我们简要讨论一下如何将模型输出转换为类别标签预测。我们之前通过 softmax 函数将 50,257 个输出转换为概率,然后通过 argmax 函数返回最高概率的位置,计算 LLM 生成的下一个 token 的 token ID。在这里,我们采用相同的方法来计算模型对给定输入的"垃圾邮件"或"非垃圾邮件"预测,如图 6.14 所示。唯一的区别是,我们现在处理的是 2 维输出,而不是 50,257 维的输出。
让我们考虑最后一个 token 输出的具体示例:
bash
print("Last output token:", outputs[:, -1, :])
该张量对应的最后一个 token 的值为:
lua
Last output token: tensor([[-3.5983, 3.9902]])
我们可以获取分类标签:
ini
probas = torch.softmax(outputs[:, -1, :], dim=-1)
label = torch.argmax(probas)
print("Class label:", label.item())
在这个例子中,代码返回 1
,意味着模型预测输入文本为"垃圾邮件(spam)"。这里使用 softmax 函数是可选的,因为最大的输出值直接对应最高的概率分数。因此,我们可以不使用 softmax 简化代码:
ini
logits = outputs[:, -1, :]
label = torch.argmax(logits)
print("Class label:", label.item())
这个概念可以用于计算分类准确率,它衡量了整个数据集中正确预测的百分比。为了确定分类准确率,我们将基于 argmax 的预测代码应用于数据集中的所有示例,并通过定义 calc_accuracy_loader
函数计算正确预测的比例。
Listing 6.8 计算分类准确率
ini
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 = input_batch.to(device)
target_batch = target_batch.to(device)
with torch.no_grad():
logits = model(input_batch)[:, -1, :] #1
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
#1 获取最后一个 token 的 logits
我们使用这个函数来确定从 10 个批次估计的各种数据集上的分类准确率,以提高效率:
ini
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 上运行,否则在 CPU 上运行。输出结果为:
yaml
Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%
我们可以看到,预测准确率接近随机预测的 50%。为了提高预测准确率,我们需要微调模型。
然而,在开始微调模型之前,我们必须定义在训练过程中优化的损失函数。我们的目标是最大化模型的垃圾邮件分类准确率,这意味着上面的代码应该输出正确的分类标签:0 表示"非垃圾邮件",1 表示"垃圾邮件"。
由于分类准确率不是可微函数,我们使用交叉熵损失作为代理来最大化准确率。因此,calc_loss_batch
函数保持不变,只需进行一个调整:我们只关注优化最后一个 token 的输出 model(input_batch)[:, -1, :]
,而不是所有 token 的输出 model(input_batch)
:
ini
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch = input_batch.to(device)
target_batch = target_batch.to(device)
logits = model(input_batch)[:, -1, :] #1
loss = torch.nn.functional.cross_entropy(logits, target_batch)
return loss
#1 获取最后一个 token 的 logits
我们使用 calc_loss_batch
函数计算从数据加载器中获取的单个批次的损失。为了计算整个数据加载器中所有批次的损失,我们像以前一样定义 calc_loss_loader
函数。
Listing 6.9 计算分类损失
python
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: #1
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
#1 确保批次数量不超过数据加载器中的批次数
类似于计算训练准确率,我们现在可以计算每个数据集的初始损失:
python
with torch.no_grad(): #1
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)
print(f"Training loss: {train_loss:.3f}")
print(f"Validation loss: {val_loss:.3f}")
print(f"Test loss: {test_loss:.3f}")
#1 禁用梯度跟踪以提高效率,因为我们还没有开始训练
初始损失值如下:
yaml
Training loss: 2.453
Validation loss: 2.583
Test loss: 2.322
接下来,我们将实现一个训练函数来微调模型,这意味着调整模型以最小化训练集损失。最小化训练集损失将有助于提高分类准确率,这是我们的总体目标。
微调模型以适应监督数据
我们必须定义并使用训练函数来微调预训练的LLM(大语言模型),以提高其垃圾邮件分类准确率。训练循环如图6.15所示,基本上与我们用于预训练的循环相同;唯一的区别是我们计算分类准确率,而不是生成示例文本来评估模型。
训练函数的实现与图6.15展示的概念紧密相关,也与用于预训练的 train_model_simple
函数非常相似。唯一的两个区别在于我们现在跟踪训练中看到的样本数量(examples_seen
),而不是看到的token数量,并且我们在每个epoch之后计算准确率,而不是打印示例文本。
清单6.10 微调模型进行垃圾邮件分类
ini
def train_classifier_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs, eval_freq, eval_iter):
train_losses, val_losses, train_accs, val_accs = [], [], [], [] #1
examples_seen, global_step = 0, -1
for epoch in range(num_epochs): #2
model.train() #3
for input_batch, target_batch in train_loader:
optimizer.zero_grad() #4
loss = calc_loss_batch(
input_batch, target_batch, model, device
)
loss.backward() #5
optimizer.step() #6
examples_seen += input_batch.shape[0] #7
global_step += 1
#8
if global_step % eval_freq == 0:
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}, "
f"Val loss {val_loss:.3f}"
)
#9
train_accuracy = calc_accuracy_loader(
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
- #1 初始化用于跟踪损失和样本数量的列表
- #2 主训练循环
- #3 将模型设置为训练模式
- #4 重置前一批次的梯度
- #5 计算梯度
- #6 使用梯度更新模型权重
- #7 新增:跟踪样本数量而不是token
- #8 可选评估步骤
- #9 在每个epoch之后计算准确率
evaluate_model
函数与我们用于预训练的函数相同:
ini
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
函数启动训练。
ini
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
)
end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")
训练输出如下:
vbnet
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%
...
Training completed in 5.65 minutes.
最后,我们使用Matplotlib绘制训练和验证集的损失曲线。
清单6.11 绘制分类损失曲线
scss
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}")
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()
ax2.plot(examples_seen, train_values, alpha=0)
ax2.set_xlabel("Examples seen")
fig.tight_layout()
plt.savefig(f"{label}-plot.pdf")
plt.show()
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的数量设置为5。epoch的数量取决于数据集和任务的难度,虽然没有通用的解决方案或推荐标准,但设置为5通常是一个不错的起点。如果模型在前几次epoch后过拟合,例如在损失曲线中看到的情况(参见图6.16),则可能需要减少epoch数量。相反,如果趋势线表明验证损失在进一步训练中可能会有所改善,则应增加epoch的数量。在这个具体案例中,5个epoch是合理的选择,因为没有出现早期过拟合的迹象,并且验证损失接近于0。
接下来,使用相同的 plot_values
函数绘制分类准确率:
ini
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展示了得到的准确率曲线。模型在第4和第5次epoch后实现了相对较高的训练和验证准确率。重要的是,我们之前在使用 train_classifier_simple
函数时设置了 eval_iter=5
,这意味着我们对训练和验证性能的估计是基于5个批次的效率评估进行的。
现在我们需要通过运行以下代码计算整个数据集上训练集、验证集和测试集的性能指标,这次不需要定义 eval_iter
值:
scss
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}%")
得到的准确率值为:
- 训练集准确率: 97.21%
- 验证集准确率: 97.32%
- 测试集准确率: 95.67%
训练集和测试集的性能几乎相同。训练集和测试集准确率之间的微小差异表明对训练数据的过拟合非常轻微。通常情况下,验证集准确率略高于测试集准确率,因为模型开发过程中常常会根据验证集的表现来调整超参数,而这些调整可能无法有效地泛化到测试集。这种情况很常见,但可以通过调整模型设置来缩小差距,例如增加 drop_rate
(丢弃率)或优化器配置中的 weight_decay
(权重衰减)参数。
使用 LLM 进行垃圾邮件分类
经过微调和评估后,我们现在可以准备分类垃圾短信了(见图 6.18)。接下来我们将使用基于 GPT 的垃圾邮件分类模型。以下 classify_review
函数的步骤与我们之前在 SpamDataset
中实现的数据预处理类似。然后,在将文本处理为 token ID 后,函数使用模型预测一个整数类别标签,类似于我们在 6.6 节中实现的过程,最终返回对应的类别名称。
代码清单 6.12 使用模型分类新文本
ini
def classify_review(
text, model, tokenizer, device, max_length=None,
pad_token_id=50256):
model.eval()
input_ids = tokenizer.encode(text) #1
supported_context_length = model.pos_emb.weight.shape[1]
input_ids = input_ids[:min( #2
max_length, supported_context_length
)]
input_ids += [pad_token_id] * (max_length - len(input_ids)) #3
input_tensor = torch.tensor(
input_ids, device=device
).unsqueeze(0) #4
with torch.no_grad(): #5
logits = model(input_tensor)[:, -1, :] #6
predicted_label = torch.argmax(logits, dim=-1).item()
return "spam" if predicted_label == 1 else "not spam" #7
#1 准备模型输入
#2 如果序列过长,则进行截断
#3 对序列进行填充
#4 添加批量维度
#5 推理时不进行梯度跟踪
#6 获取最后一个输出 token 的 logits
#7 返回分类结果
让我们尝试在示例文本上使用 classify_review
函数:
bash
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"。我们再尝试另一个示例:
ini
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
方法:
less
torch.save(model.state_dict(), "review_classifier.pth")
保存后,可以通过以下方式加载模型:
ini
model_state_dict = torch.load("review_classifier.pth", map_location=device)
model.load_state_dict(model_state_dict)
总结
- 微调大型语言模型 (LLM) 有不同的策略,包括分类微调和指令微调。
- 分类微调通过一个小型的分类层替换 LLM 的输出层。
- 在将文本消息分类为"垃圾邮件"或"非垃圾邮件"的情况下,新分类层仅包含两个输出节点。而之前在预训练中,我们使用的输出节点数等于词汇表中唯一词汇的数量(即 50,256 个)。
- 与预训练中预测文本中的下一个 token 不同,分类微调训练模型输出正确的类别标签,例如"垃圾邮件"或"非垃圾邮件"。
- 微调的模型输入是将文本转换为 token ID,与预训练时类似。
- 在微调 LLM 之前,我们加载预训练的模型作为基础模型。
- 评估分类模型时需要计算分类准确率(正确预测的比例或百分比)。
- 分类模型微调使用与 LLM 预训练时相同的交叉熵损失函数。