跳到主要内容 构建并微调大型语言模型实现文本分类任务 | 极客日志
Python AI 算法
构建并微调大型语言模型实现文本分类任务 利用预训练大型语言模型进行文本分类任务的完整流程。内容包括准备垃圾短信数据集并进行平衡处理,初始化 GPT 模型并替换输出层以适应二分类任务,冻结部分参数以优化微调效率,以及设置 PyTorch 数据加载器。最后阐述了模型训练的基本步骤,包括损失函数选择、优化器配置及训练循环的实现,为实际部署分类模型奠定基础。
莫名其妙 发布于 2025/2/7 更新于 2026/4/20 1 浏览本章内容
介绍不同的大型语言模型(LLM)微调方法
准备用于文本分类的数据集
修改预训练 LLM 以便进行微调
微调 LLM 以识别垃圾信息
评估微调后的 LLM 分类器的准确性
使用微调后的 LLM 对新数据进行分类
到目前为止,我们已经编写了 LLM 的架构、对其进行了预训练,并学习了如何从外部来源导入预训练权重到我们的模型中。现在,我们将通过微调 LLM 用于特定的目标任务(如文本分类)来收获我们的成果。本章的具体示例是将短信分类为'垃圾信息'或'非垃圾信息'。图 6.1 展示了微调 LLM 的两种主要方式:用于分类的微调(步骤 8)和用于执行指令的微调(步骤 9)。
微调的不同类别
微调语言模型的最常见方式是指令微调和分类微调。指令微调通过使用特定的指令训练语言模型,以提高其理解和执行自然语言提示中描述的任务的能力。
在分类微调中,如果你有机器学习背景,你可能已经熟悉这个概念,模型被训练以识别一组特定的类别标签,例如'垃圾邮件'和'非垃圾邮件'。分类任务的示例不仅限于 LLM 和电子邮件过滤,还包括从图像中识别不同种类的植物;将新闻文章分类为体育、政治和技术等主题;以及在医学成像中区分良性和恶性肿瘤。
关键点是,分类微调模型只能预测它在训练期间遇到的类别。例如,它可以判断某些内容是'垃圾邮件'还是'非垃圾邮件',但它无法提供关于输入文本的其他信息。
与分类微调模型相比,指令微调模型通常可以执行更广泛的任务。我们可以将分类微调模型视为高度专业化的模型,而通常开发一个在特定任务上表现良好的专业化模型要比开发一个能够在各种任务中表现出色的通用模型更容易。
选择合适的微调方法
指令微调提升模型根据特定用户指令理解和生成响应的能力,最适合需要处理多种任务的模型,以提高其灵活性和交互质量。而分类微调则更适合需要将数据精确分类为预定义类别的项目,如情感分析或垃圾邮件检测。
虽然指令微调更具通用性,但它需要更大的数据集和更多的计算资源来开发能够熟练处理各种任务的模型。相比之下,分类微调所需的数据和计算资源较少,但其应用范围仅限于模型训练时涉及的特定类别。
准备数据集
我们将修改并对之前实现和预训练的 GPT 模型进行分类微调。首先,我们将下载并准备数据集。为了提供一个直观且有用的分类微调示例,我们将使用一个包含垃圾短信和非垃圾短信的文本消息数据集。
注意 文本消息通常是通过手机发送的,而非电子邮件。然而,电子邮件分类的步骤也是相同的。
第一步是下载数据集。
代码示例 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} 已存在,跳过下载和解压." )
urllib.request.urlopen(url) response:
(zip_path, ) out_file:
out_file.write(response.read())
zipfile.ZipFile(zip_path, ) zip_ref:
zip_ref.extractall(extracted_path)
original_file_path = Path(extracted_path) /
os.rename(original_file_path, data_file_path)
( )
download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
RSA密钥对生成器 生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
Mermaid 预览与可视化编辑 基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
curl 转代码 解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
return
with
as
with
open
"wb"
as
with
"r"
as
"SMSSpamCollection"
print
f"文件已下载并保存为 {data_file_path} "
#1 下载文件
#2 解压文件
#3 添加 .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" ]
)
print (df)
print (df["Label" ].value_counts())
执行前面的代码后,我们发现数据集中'ham'(即非垃圾邮件)的数量远远多于'spam':
Label
ham 4825
spam 747
Name: count, dtype: int64
为了简单起见,同时也因为我们更喜欢较小的数据集(这样可以更快地微调 LLM),我们选择对数据集进行欠采样,使每个类别包含 747 个实例。
注意 还有许多其他方法来处理类别不平衡,但这些超出了本书的范围。
我们可以使用以下代码来进行欠采样并创建一个平衡的数据集。
def create_balanced_dataset (df ):
num_spam = df[df["Label" ] == "spam" ].shape[0 ]
ham_subset = df[df["Label" ] == "ham" ].sample(
num_spam, random_state=123
)
balanced_df = pd.concat([
ham_subset, df[df["Label" ] == "spam" ]
])
return balanced_df
balanced_df = create_balanced_dataset(df)
print (balanced_df["Label" ].value_counts())
#1 计算'spam'实例的数量
#2 随机采样'ham'实例以匹配'spam'实例的数量
#3 将'ham'子集与'spam'实例组合
执行完上述代码后,我们可以看到现在数据集中垃圾短信和非垃圾短信的数量相等:
Label
ham 747
spam 747
Name: count, dtype: int64
接下来,我们将字符串形式的类别标签'ham'和'spam'分别转换为整数标签 0 和 1:
balanced_df["Label" ] = balanced_df["Label" ].map ({"ham" : 0 , "spam" : 1 })
此过程类似于将文本转换为标记 ID。然而,这里我们不是使用包含超过 50,000 个词的 GPT 词汇表,而是只处理两个标记 ID:0 和 1。
接下来,我们创建一个 random_split 函数,将数据集分为三个部分:70% 用于训练,10% 用于验证,20% 用于测试。(这些比例在机器学习中很常见,用于训练、调整和评估模型。)
def random_split (df, train_frac, validation_frac ):
df = df.sample(
frac=1 , random_state=123
).reset_index(drop=True )
train_end = int (len (df) * train_frac)
validation_end = train_end + int (len (df) * validation_frac)
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 )
#1 随机打乱整个 DataFrame
#2 计算数据集划分的索引
#3 将 DataFrame 划分为训练、验证和测试集
#4 测试集的大小为剩余部分,即 20%
让我们将数据集保存为 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 数据加载器。
创建数据加载器 我们将开发与之前处理文本数据时概念上类似的 PyTorch 数据加载器。之前,我们使用滑动窗口技术生成了大小统一的文本块,然后将它们分组为批次,以提高模型训练的效率。每个块都作为单独的训练实例。然而,现在我们正在处理一个包含不同长度短信的垃圾短信数据集。为了像处理文本块一样批量处理这些短信,我们有两个主要选择:
将所有消息截断为数据集中或批次中最短消息的长度。
将所有消息填充到数据集中或批次中最长消息的长度。
第一个选项计算开销较低,但如果较短的消息比平均或最长消息小得多,可能会导致重要信息的丢失,从而降低模型性能。因此,我们选择第二个选项,保留所有消息的完整内容。
为了实现批处理,我们将所有较短的消息填充到与数据集中最长消息相同的长度。为此,我们将添加填充标记 <|endoftext|>。
不过,与其直接将字符串 <|endoftext|> 附加到每条消息后面,我们可以将与 <|endoftext|> 对应的标记 ID 添加到编码的消息中。<|endoftext|> 的标记 ID 是 50256。我们可以通过使用之前使用的 tiktoken 包中的 GPT-2 分词器来对该标记 ID 进行编码,来验证该标记 ID 是否正确:
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2" )
print (tokenizer.encode("<|endoftext|>" , allowed_special={"<|endoftext|>" }))
首先,我们需要实现一个 PyTorch Dataset 类,它定义了如何加载和处理数据,之后才能实例化数据加载器。为此,我们定义了 SpamDataset 类。SpamDataset 类处理几个关键任务:它识别训练数据集中最长的序列,对短信进行编码,并确保所有其他序列都填充填充标记以匹配最长序列的长度。
代码示例 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 = [
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 = [
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
]
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 分词器对文本进行分词,并允许我们根据最长序列或预定义的最大长度填充或截断序列。这确保了每个输入张量的大小相同,这对于我们接下来实现的训练数据加载器中的批处理是必要的:
train_dataset = SpamDataset(
csv_file="train.csv" ,
max_length=None ,
tokenizer=tokenizer
)
最长序列的长度存储在数据集的 max_length 属性中。如果你想查看最长序列的标记数量,可以使用以下代码:
print (train_dataset.max_length)
代码输出 120,表示最长的序列不超过 120 个标记,这是短信常见的长度。模型能够处理最多 1,024 个标记的序列,这是其上下文长度限制。如果数据集中包含更长的文本,可以在创建训练数据集时传递 max_length=1024,以确保数据不会超过模型支持的输入(上下文)长度。
接下来,我们对验证集和测试集进行填充,使其长度与最长的训练序列匹配。重要的是,任何超过最长训练示例长度的验证集和测试集样本都将通过 encoded_text[:self.max_length] 进行截断,这在我们之前定义的 SpamDataset 代码中进行了处理。这个截断是可选的;只要验证集和测试集中的序列不超过 1,024 个标记,你可以将 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
)
使用这些数据集作为输入,我们可以像处理文本数据时一样实例化数据加载器。然而,在这种情况下,目标是类别标签,而不是文本中的下一个标记。例如,如果我们选择批次大小为 8,则每个批次将由 8 个长度为 120 的训练样本及其对应的类别标签组成。
以下代码创建了训练集、验证集和测试集的数据加载器,每次以大小为 8 的批次加载短信和标签。
代码示例 6.5 创建 PyTorch 数据加载器
from torch.utils.data import DataLoader
num_workers = 0
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 ,
)
为了确保数据加载器正常工作并返回预期大小的批次,我们迭代训练加载器并打印最后一个批次的张量维度:
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 ])
如我们所料,输入批次由八个包含 120 个标记的训练样本组成。标签张量存储了与这八个训练样本对应的类别标签。
最后,为了了解数据集的大小,我们打印每个数据集中的批次数量:
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
现在我们已经准备好了数据,接下来需要为模型的微调做准备。
初始化具有预训练权重的模型 我们需要为分类微调做好准备,以识别垃圾短信。首先,我们初始化预训练的模型。
开始模型准备过程,我们使用了与预训练无标签数据时相同的配置:
CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"
BASE_CONFIG = {
"vocab_size" : 50257 ,
"context_length" : 1024 ,
"drop_rate" : 0.0 ,
"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])
#1 词汇表大小
#2 上下文长度
#3 丢弃率
#4 查询 - 键 - 值偏差
接下来,我们从 gpt_download.py 文件中导入 download_and_load_gpt2 函数,并复用第 5 章中的 GPTModel 类和 load_weights_into_gpt 函数,将下载的权重加载到 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 后,我们可以复用第 4 章和第 5 章中的文本生成工具函数,确保模型能够生成连贯的文本:
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
从输出可以看出,模型在遵循指令方面存在困难。这是预期结果,因为模型只经过了预训练,并未进行指令微调。因此,我们将为模型的分类微调做好准备。
添加分类头 我们需要修改预训练的 LLM,以便为分类微调做准备。为此,我们将原来的输出层替换掉,原输出层将隐藏表示映射到 50,257 的词汇表中,而新输出层则映射到两个类别:0('非垃圾邮件')和 1('垃圾邮件')。我们使用与之前相同的模型,唯一区别是替换了输出层。
技术上来说,我们可以使用单个输出节点来处理二分类任务。然而,这会要求我们修改损失函数。因此,我们选择一种更通用的方法,即输出节点的数量与类别的数量相匹配。例如,对于一个三分类问题,如将新闻文章分类为'科技'、'体育'或'政治',我们将使用三个输出节点,依此类推。
在尝试修改之前,我们先通过 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 章中设计的模型架构。正如之前讨论的,GPTModel 由嵌入层、12 个相同的 Transformer 块(为简洁起见,只展示了最后一个块),最后是 LayerNorm 和输出层 out_head 组成。
接下来,我们将 out_head 替换为一个新的输出层,并对其进行微调。
由于我们是从一个预训练模型开始,并不需要微调所有的模型层。在基于神经网络的语言模型中,底层通常捕捉的是通用的语言结构和语义,适用于各种任务和数据集。因此,通常只需要微调靠近输出层的最后几层,这些层捕捉的是更具体的语言模式和任务相关的特征。这样不仅能够更高效地适应新任务,还能提高计算效率。
为了让模型准备好进行分类微调,我们首先冻结模型,即将所有层设置为不可训练:
for param in model.parameters():
param.requires_grad = False
然后,我们将输出层(model.out_head)替换掉,原输出层将输入映射为 50,257 维,即词汇表的大小。
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,这意味着它是模型中唯一会在训练期间更新的层。从技术上讲,训练我们刚刚添加的输出层已经足够了。然而,正如我在实验中发现的,微调额外的层可以显著提高模型的预测性能。我们还将最后一个 Transformer 块和连接该块到输出层的 LayerNorm 模块设置为可训练。
要让最后的 LayerNorm 和最后一个 transformer block 可训练,我们需要将它们的 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)
打印输出显示,前面的代码将输入编码为包含四个输入 token 的 tensor:
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)
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 的最后一行。
要从输出 tensor 中提取最后一个输出 token,可以使用以下代码:
print ("Last output token:" , outputs[:, -1 , :])
Last output token: tensor([[-3.5983, 3.9902]] )
我们仍然需要将这些值转换为类别标签的预测结果。但是首先,让我们理解为什么特别关注最后一个输出 token。
我们之前已经探索了注意力机制,它建立了每个输入 token 与其他所有输入 token 之间的关系,并且我们讨论了在类似 GPT 模型中常用的因果注意力掩码的概念。这种掩码限制了一个 token 的关注范围,仅限于其当前位置及之前的位置,确保每个 token 只能受到其自身和之前 token 的影响。
由于因果注意力掩码设置,序列中的最后一个 token 累积了最多的信息,因为它是唯一可以访问所有之前 token 数据的 token。因此,在垃圾邮件分类任务中,我们在微调过程中关注这个最后的 token。
现在我们已经准备好将最后的 token 转换为类别标签预测,并计算模型的初始预测准确率。接下来,我们将微调模型以完成垃圾邮件分类任务。
模型训练与评估 为了完成分类任务,我们需要定义损失函数和优化器,并运行训练循环。对于多分类任务,交叉熵损失函数(CrossEntropyLoss)是最常用的选择。
import torch.optim as optim
device = torch.device("cuda" if torch.cuda.is_available() else "cpu" )
model.to(device)
optimizer = optim.AdamW(model.parameters(), lr=5e-5 )
criterion = torch.nn.CrossEntropyLoss()
接下来,我们定义训练循环。在每个 epoch 中,我们将遍历训练数据加载器,计算前向传播的损失,反向传播梯度,并更新模型参数。同时,我们也会在验证集上评估模型性能。
epochs = 3
for epoch in range (epochs):
model.train()
total_loss = 0
for batch_idx, (input_ids, labels) in enumerate (train_loader):
input_ids = input_ids.to(device)
labels = labels.to(device)
optimizer.zero_grad()
outputs = model(input_ids)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len (train_loader)
print (f"Epoch {epoch+1 } /{epochs} , Loss: {avg_loss:.4 f} " )
训练完成后,我们可以在测试集上评估模型的最终性能。通过比较预测标签和真实标签,我们可以计算准确率、精确率和召回率等指标。
model.eval ()
correct = 0
total = 0
with torch.no_grad():
for input_ids, labels in test_loader:
input_ids = input_ids.to(device)
labels = labels.to(device)
outputs = model(input_ids)
_, predicted = torch.max (outputs, 1 )
total += labels.size(0 )
correct += (predicted == labels).sum ().item()
accuracy = correct / total
print (f"Test Accuracy: {accuracy:.4 f} " )
通过上述步骤,我们成功地将预训练的大型语言模型微调为一个专门用于垃圾短信分类的分类器。这种方法不仅适用于垃圾邮件检测,还可以扩展到情感分析、意图识别等多种文本分类任务中。