分类微调大模型
本文介绍了大语言模型在文本分类任务上的微调方法,以垃圾短信分类为例。首先通过平衡数据集解决类别不平衡问题,并使用填充技术处理不同长度文本。然后修改预训练模型架构,替换输出层为二分类结构,并冻结大部分参数仅训练输出层。
我们已经构建了大语言模型,并对其进行了预训练。现在,我们将在特定目标任务(如文本分类)上微调大语言模型。我们研究的具体示例是将文本消息分类为“垃圾消息”或“非垃圾消息”。
微调语言模型最常见的方法是指令微调和分类微调。指令微调涉及使用特定的指令数据对一组任务进行训练,以提高语言模型理解和执行自然语言提示词中描述的任务的能力。

两种指令微调场景。由图的上半部分可知,模型的任务是判断给定文本是否为垃圾消息;由图的下半部分可知,模型被指示将英语句子翻译成德语。
分类微调,即模型被训练来识别一组特定的类别标签,比如在消息中过滤“垃圾消息”和“非垃圾消息”。需要注意的是,经过分类微调的模型只能预测它在训练过程中遇到的类别。
数据集
对大语言模型进行分类微调包括三阶段。第一阶段准备数据集,第二阶段是模型设置,第三阶段模型的微调和应用。

第一步,下载数据集并解压 sms+spam+collection.zip。下面是SMSSpamCollection 数据集在 pandas DataFrame 中的预览。

其中包含了类别标签(“非垃圾消息”或“垃圾消息”)和相应的文本消息。该数据集包含 5572 行(文本消息和标签)
上述数据中“非垃圾消息”(ham)的出现频率远高于“垃圾消息”(spam),我们需要创建一个平衡的数据集,使得每种类别包含的实例数一样。然后,将数据集分成 3 部分:70%用于训练,10%用于验证,20%用于测试。
到目前为止,我们已经下载了数据集,对其进行了平衡,并将其拆分为训练子集和验证子集。
数据加载器
我们将开发与之前处理文本数据时类似的 PyTorch 数据加载器。之前我们是利用滑动窗口技术来生成统一大小的文本块,然后将其分组为批次,以便更高效地训练模型。每个块可以作为一个独立的训练实例。然而,现在我们处理的是包含不同长度文本消息的垃圾消息数据集。为了像处理文本块那样对这些消息进行批处理,我们有以下两种方案可供选择:
- 将所有消息截断到数据集中最短消息的长度或批次长度;
- 将所有消息填充到数据集中最长消息的长度或批次长度。
第一种方案计算开销更少,但如果较短的消息远小于平均长度或最长消息,那么可能会导致信息丢失,从而降低模型性能。因此,我们选择第二种方案,这样可以保留所有消息的完整内容。
为了实现批处理,将所有消息填充到数据集中最长消息的长度,需要向所有较短的消息添加填充词元。基于性能与效率的考虑,将与"<|endoftext|>"对应的词元 ID 添加到编码的文本消息。

输入文本准备过程。首先,每条输入文本消息被转换为一系列词元 ID。然后,为了确保序列长度一致,较短的序列使用填充词元(在本示例中,词元 ID 为 50256)进行填充,以匹配最长序列的长度。
使用这些数据集作为输入,我们可以像处理文本数据那样来实例化数据加载器。不同的是,在这种情况下,目标是类别标签,而不是文本中的下一个词元。如果我们选择批次大小为 8,则每个批次将包含 8 个长度为 120 的训练样本以及每个样本对应的类别标签。

一个包含 8 条文本消息的训练批次,每条文本消息由 120 个词元 ID 组成。类别标签数组存储的是与文本消息对应的 8 个类别标签,这些标签可以是 0(“非垃圾消息”)或 1(“垃圾消息”)。
初始化带预训练权重的模型
为了对垃圾消息进行分类微调,我们需要准备模型。首先,初始化预训练模型。模型初始化可以参考《预训练大模型》小节。
load_weights_into_gpt(model, params)
此时模型在遵循指令方面存在困难。因为模型仅经过了预训练,缺乏微调。
分类头
我们需要修改预训练的大语言模型来为分类微调做好准备。为了实现这一点,我们将原始输出层(该输出层会将隐藏表示映射到一张包含 50257 个词汇的词汇表中)替换为一个较小的输出层,该输出层会映射到两个类别:0(“非垃圾消息”)和 1(“垃圾消息”),如下图所示。我们使用的是与之前相同的模型,但替换了输出层。

通过调整架构使 GPT 模型适应垃圾消息分类任务。模型初始的线性输出层将768个隐藏单元映射到了拥有 50257个词元的词汇表中。为了检测垃圾消息,我们将这个层替换为一个新的输出层,该层将相同的 768 个隐藏单元映射到了两个类别,分别表示“垃圾消息”和“非垃圾消息”。
为了使模型准备好进行分类微调,我们首先冻结模型,即将所有层设为不可训练。
for param in model.parameters():
param.requires_grad = False
然后,如下面代码清单所示,替换输出层(model.out_head),该层原本是将输入映射为50257维,即词汇表的大小。
torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(
in_features=BASE_CONFIG["emb_dim"],
out_features=num_classes
)
这个新的 model.out_head 输出层的 requires_grad 属性默认设置为 True,意味着它是模型中唯一在训练过程中会被更新的层。从技术上讲,仅训练刚刚添加的输出层就足够了。然而,根据研究,微调额外的层可以显著提升模型的预测性能。我们还可以将最后一个Transformer块和连接该块到输出层的最终层归一化模块设置为可训练。
之前我们探讨过注意力机制,它建立了每个输入词元与其他输入词元之间的关系,以及因果注意力掩码的概念,这在类 GPT 模型中经常使用。这种掩码机制会限定一个词元的关注范围,即它只关注当前及之前的位置,从而确保每个词元只受自己和之前词元的影响。对于因果注意力机制可以参看《注意力机制–大模型输入的上下文》。
根据因果注意力掩码设置,序列中的最后一个词元累积了最多的信息,因为它是唯一一个可以访问之前所有数据的词元。因此,在垃圾消息分类任务中,我们在微调过程中会关注这个最后的词元。
现在我们准备将最后的词元转换为类别标签进行预测,并计算模型的初始预测准确率。随后,我们将对模型进行垃圾消息分类任务的微调。
分类损失和准确率
在微调模型之前,还有一个小任务需要完成:实现微调过程中使用的模型评估函数。
在实现评估工具之前,让我们简要讨论一下如何将模型输出转换为类别标签预测。之前我们通过将 50257个输出转换为概率(利用 softmax 函数),然后返回最高概率的位置(利用argmax函数),来计算大语言模型生成的下一个词元的词元ID。在这里,我们采取相同的方法来计算模型对于给定输入是预测为“垃圾消息”还是“非垃圾消息”,如下图所示。唯一的区别是我们处理的是2维而不是50257维的输出。

对应于最后一个词元的模型输出被转换为每个输入文本的概率分数。通过查找最高概率分数的索引位置获得类别标签。但由于模型尚未训练,因此它错误地预测了垃圾消息标签。
在开始微调模型之前,需要定义训练期间要优化的损失函数。我们的目标是最大化模型的垃圾消息分类准确率,这意味着前面的代码应该输出正确的类别标签:0 表示非垃圾消息,1 表示垃圾消息。
由于分类准确率不是一个可微分的函数,这里我们使用交叉熵损失作为替代来最大化准确率 。 因此,calc_loss_batch 函数保持不变 ,唯一的调整是专注于优化最后一个词元(model(input_batch)[:, -1, :])而不是所有词元(model(input_batch))。
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, :] # 最后一个输出词元的logits
loss = torch.nn.functional.cross_entropy(logits, target_batch)
return loss
我们使用calc_loss_batch函数来计算从之前定义的数据加载器中获得的单个批次的损失。
为了计算数据加载器中所有批次的损失,可以像之前一样定义 calc_loss_loader 函数。
接下来,我们将实现一个训练函数来微调模型,这意味着调整模型以最小化训练集损失。最小化训练集损失将有助于提高分类准确率,这也是我们的最终目标。
在有监督数据上微调模型
我们需要定义并使用训练函数来微调预训练的大语言模型,提高其垃圾消息分类准确率。微调训练循环与我们之前用于预训练的整体训练循环相同,唯一的区别是要计算分类准确率,而不是生成文本样本来评估模型。
如下代码清单所示,训练函数与用于预训练模型的train_model_simple函数非常相似。不过,我们现在跟踪的是已经看到的训练样本数量(examples_seen),而不是词元数量,并且我们在每轮后会计算准确率,而不是打印一个文本样本。
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 = [], [], [], [] #初始化列表以跟踪损失和所见词元
examples_seen, global_step = 0, -1
for epoch in range(num_epochs): # 开始主训练循环
model.train() # 设置模型为训练模式
for input_batch, target_batch in train_loader:
optimizer.zero_grad() # 重置上一个批次迭代中的损失梯度
loss = calc_loss_batch(
input_batch, target_batch, model, device
)
loss.backward() # 计算损失梯度
optimizer.step() # 使用损失梯度更新模型权重
examples_seen += input_batch.shape[0]
global_step += 1
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)
track_tokens_seen.append(tokens_seen)
print(f"Ep {epoch+1} (Step {global_step:06d}): "
f"Train loss {train_loss:.3f}, "
f"Val loss {val_loss:.3f}"
)
# 每轮之后打印一个文本样本
generate_and_print_sample(
model, tokenizer, device, start_context
)
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
接下来,初始化优化器,设置训练的轮数,并使用 train_classifier_simple 函数启动训练。
下面是损失曲线,实线是训练集损失,虚线是验证集损失。

模型在 5 轮内的训练集损失和验证集损失。训练集损失(实线)和验证集损失(虚线)在第一轮急剧下降,并逐渐在第五轮趋于稳定。这表明模型的学习进展良好,并且能够从训练数据中学习,同时在未见过的验证数据上也表现出良好的泛化能力。
根据图的明显下降趋势,可以看出模型正在有效地从训练数据中学习,几乎没有过拟合的迹象。也就是说,训练集和验证集的损失之间没有明显的差距。
通常,验证集的准确率会比测试集的准确率稍高,因为模型开发过程中往往会调整超参数以提升在验证集上的性能,这可能导致模型在测试集上并不完全适用。
使用微调后模型作为垃圾消息分类器
在对模型进行微调和评估后,现在可以使用它来分类垃圾消息。
综上,分类微调涉及通过添加一个小型分类层来替换大语言模型的输出层。分类模型的评估包括计算分类准确率(正确预测的比例)。分类模型的微调使用与大语言模型预训练相同的交叉熵损失函数。
其他
选择正确的微调方法
指令微调提升了模型基于特定用户指令理解和生成响应的能力。指令微调最适合处理需要应对多种任务的模型,这些任务依赖于复杂的用户指令。通过指令微调,可以提升模型的灵活性和交互质量。而分类微调更适合需要将数据精确分类为预定义类别的任务,比如情感分析或垃圾消息检测。
虽然指令微调更具通用性,但它需要更大的数据集和更多的计算资源来开发精通多种任务的模型。相比之下,分类微调所需的数据和计算资源较少,但它的应用范围局限于模型所训练的特定类别。
输出层节点
从技术上讲,由于这是一个二分类任务,因此我们可以使用单个输出节点。然而,这需要修改损失函数。因此,我们选择了一种更通用的方法,即令输出节点的数量与类别数量相匹配。例如,对于一个三分类问题(比如将新闻文章分类为“科技”“体育”或“政治”),我们使用3个输出节点,以此类推。
是微调选定层还是微调所有层
由于模型已经经过了预训练,因此不需要微调所有的模型层。在基于神经网络的语言模型中,较低层通常捕捉基本的语言结构和语义,适用于广泛的任务和数据集,最后几层(靠近输出的层)更侧重于捕捉细微的语言模式和特定任务的特征。因此,只微调最后几层通常就足以将模型适应到新任务。同时,仅微调少量层在计算上也更加高效。
选择训练轮数
在初始化训练时,我们将轮数设置为 5 轮。轮数的选择取决于数据集和任务的难度,并没有通用的解决方案,不过通常情况下,5轮是一个不错的起点。如果模型在前几轮之后出现过拟合,则可能需要减少轮数。相反,如果趋势表明验证集损失可能随着进一步训练而改善,则应该增加轮数。在这种情况下,5 轮是合理的,因为没有早期过拟合的迹象,且验证集损失接近于0。
参考
《从零构建大模型》
更多推荐




所有评论(0)