原文地址:Building A GPT-Style LLM Classifier From Scratch,by Sebastian Raschka, on 2024-09-21
从零构建 GPT 风格大语言模型分类器
面向垃圾邮件分类的 GPT 模型微调
在本文中,我将向大家演示如何将预训练大语言模型(LLM)改造为高性能文本分类器。
为什么要聚焦分类任务?首先,针对分类任务微调预训练模型,是入门模型微调技术的优质路径 —— 难度平缓但实用性强。其次,大量现实场景与业务问题本质上都属于文本分类:垃圾邮件检测、情感分析、客户反馈归类、主题标注等等。
将 GPT 模型改造为文本分类器
本文你将学到什么
为庆祝新书发布,我节选了书中一个章节的内容,带你一步步完成预训练大语言模型的微调,实现垃圾邮件分类器。
重要说明
书中关于分类微调的章节共有 35 页,单篇文章无法完整承载。因此本篇推送只选取约 10 页的核心内容,介绍分类微调的背景与核心原理。
除此之外,我还会补充一些书中未收录的额外实验结论,解答读者可能存在的共性问题。(注:以下节选自本人原稿,尚未经过曼宁出版社的专业文字编辑与最终图表排版。)
本节选对应的完整代码可在 GitHub 查看。
同时,我还会解答关于训练大语言模型分类器的 7 个常见问题:
-
是否需要训练全部层?
-
为什么微调基于最后一个 token,而非第一个?
-
BERT 与 GPT 的性能表现有何差异?
-
是否应该禁用因果掩码?
-
增大模型规模会带来怎样的效果提升?
-
LoRA 能带来多大的性能增益?
-
输入是否需要做填充(Padding)?
祝阅读愉快!
微调的不同分类
语言模型最常见的两种微调方式是指令微调与分类微调。
指令微调是指让模型在一组任务上训练,通过特定指令提升模型理解、执行自然语言提示所描述任务的能力,如下图 1 所示。
图 1:两种指令微调场景示意图。上方任务为判断给定文本是否为垃圾邮件;下方任务为根据指令将英文句子翻译成德文。
下一章会详细讲解图 1 所示的指令微调。而本章的核心是分类微调 —— 如果你有机器学习基础,对这个概念应该不会陌生。
分类微调的目标是训练模型识别一组固定的类别标签,比如 “垃圾邮件” 和 “非垃圾邮件”。分类任务并不局限于大语言模型与邮件过滤领域:比如从图像中识别不同植物种类、将新闻文章归类为体育、政治、科技等主题、在医学影像中区分良性与恶性肿瘤,都属于分类任务。
核心要点在于:经过分类微调的模型,只能预测训练中见过的类别 —— 比如它可以判断一段内容是 “垃圾邮件” 还是 “非垃圾邮件”(如图 2 所示),但无法对输入文本输出其他类型的回答。
图 2:大语言模型文本分类场景示意图。经过垃圾邮件分类微调的模型,输入文本时不需要附带额外指令;但与指令微调模型不同,它只能输出 “垃圾邮件” 或 “非垃圾邮件” 两类结果。
与图 2 的分类微调模型相比,指令微调模型通常能完成更广泛的任务。我们可以把分类微调模型看作高度专业化的模型 —— 一般来说,打造一个专用模型,比打造一个能在各类任务上都表现良好的通用模型更容易。
选择合适的方案
指令微调能提升模型理解用户指令、基于指令生成回复的能力,最适合需要处理复杂用户指令、覆盖多种任务的模型,能提升模型的灵活性与交互质量。
而分类微调则更适合需要将数据精准划分到预定义类别的项目,比如情感分析、垃圾邮件检测。
指令微调的通用性更强,但需要更大的数据集与更多算力,才能训练出能胜任多种任务的模型。相比之下,分类微调所需的数据与算力更少,但应用范围局限于模型训练过的特定类别。
用预训练权重初始化模型
由于本文是节选内容,我们跳过数据准备与模型初始化部分 —— 这些内容在前面的章节中已经实现并完成预训练。根据我的经验,和纸质书相比,阅读长篇数字文章时注意力更容易分散,因此本节选会紧紧围绕本章的一个核心要点展开。
先说明本节选的内容范围:我们聚焦于 “如何修改通用预训练大语言模型,使其适配分类任务” 这一步,对应下图 3 中的第 6 步。
图 3:本节选跳过第 1-5 步,直接从第 6 步开始(下一节进入正题)。
在讲解图 3 中的模型改造之前,先简单介绍我们使用的预训练大语言模型。
为简化流程,我们假设已经通过如下代码加载模型:
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
从输出可以明显看出,模型很难遵循指令完成任务。
这是预期结果 —— 该模型只经过了预训练,没有做过指令微调,相关内容我们会在下一章讲解。
下一节我们将改造模型,为分类微调做准备。
添加分类头
本节我们改造预训练大语言模型,使其适配分类微调。具体做法是:替换模型原本的输出层 —— 原输出层的作用是将隐层表示映射到包含 50257 个独立 token 的词表;我们将其替换为一个更小的输出层,只输出 2 个类别:0(“非垃圾邮件”)和 1(“垃圾邮件”),如下图 4 所示。
图 4:通过修改架构将 GPT 模型适配垃圾邮件分类任务的示意图。原模型的线性输出层将 768 个隐层单元映射到 50257 个 token 的词表;为实现垃圾邮件检测,我们将该层替换为新的输出层,同样基于 768 个隐层单元,但只输出两个类别,分别对应 “垃圾邮件” 与 “非垃圾邮件”。
如图 4 所示,我们沿用之前章节的模型,仅替换输出层。
输出层节点数
由于这是二分类任务,理论上只用 1 个输出节点也能实现。但这样需要修改损失函数(附录 B 参考资料中的一篇文章有相关讨论)。因此我们采用更通用的方案:输出节点数与类别数保持一致。比如对于三分类问题(如将新闻分为 “科技”“体育”“政治”),就用 3 个输出节点,以此类推。
在动手做图 4 的改造之前,我们先通过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 块(为简洁只展示了最后一个)、最终层归一化,以及输出层out_head组成。
接下来,我们把out_head替换为新的输出层(如图 4 所示),用于后续微调。
微调部分层 vs 微调全部层
由于我们基于预训练模型开始训练,其实不需要微调全部层。
原因在于:在基于神经网络的语言模型中,底层通常捕获的是基础语言结构与语义,这些能力适用于绝大多数任务与数据集。因此,只微调最后几层(靠近输出的层)往往就足以让模型适配新任务 —— 这些层更关注细粒度的语言模式与任务专属特征。
额外的好处是:只微调少量层,计算效率会高很多。感兴趣的读者可以在附录 B 的本章参考资料中找到更多内容,包括关于 “哪些层适合微调” 的实验。
要让模型适配分类微调,我们首先 “冻结” 整个模型,也就是让所有层都不可训练:
for param in model.parameters():
param.requires_grad = False
然后,如之前图 4 所示,替换输出层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
)
注意上面的代码中,我们使用BASE_CONFIG["emb_dim"](在 “gpt2-small (124M)” 模型中该值为 768),是为了让代码更通用 —— 这样同一份代码也能适配更大的 GPT-2 模型变体。
这个新的model.out_head输出层默认requires_grad为 True,也就是说训练过程中只有这一层会被更新。
技术上讲,只训练我们新增的输出层已经够用。但根据我的实验结论,再多微调几层,能显著提升微调后模型的预测性能。(更多细节见附录 C 参考资料。)
因此我们额外将最后一个 Transformer 块,以及连接该块与输出层的最终层归一化模块设置为可训练,如下图 5 所示。
图 5:我们之前加载的、在前几章实现的 GPT 模型包含 12 个重复的 Transformer 块。除输出层外,我们将最终层归一化与最后一个 Transformer 块设为可训练,其余 11 个 Transformer 块与嵌入层保持不可训练。
要让最终层归一化与最后一个 Transformer 块可训练(如图 5 所示),我们将它们的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)
打印结果显示,上面的代码将输入编码为包含 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)
输出张量如下:
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]的输出张量,其中 50257 是词表大小。
和之前一样,输出的行数对应输入 token 的数量(本例中是 4);但由于我们替换了模型的输出层,每个输出的嵌入维度(列数)从 50257 变成了 2。
我们微调模型的目标,是让它输出类别标签,判断输入是垃圾邮件还是非垃圾邮件。要实现这个目标,我们不需要对全部 4 行输出都做微调,只需要关注单个输出 token 即可 —— 具体来说,我们取对应最后一个输出 token 的最后一行,如下图 6 所示。
图 6:输入为 4 个 token 的 GPT 模型输入输出示意图。由于输出层被修改,输出张量只有 2 列。在微调模型完成垃圾邮件分类任务时,我们只关注对应最后一个 token 的最后一行。
要从输出张量中提取最后一个输出 token(如图 6 所示),我们使用如下代码:
print("Last output token:", outputs[:, -1, :])
打印结果:
Last output token: tensor([[-3.5983, 3.9902]])
进入下一节之前,我们先做个小结:接下来我们会把这些数值转化为类别标签预测。但在此之前,我们先解释一个问题:为什么我们要关注最后一个输出 token,而不是第一个、第二个或第三个?
第 3 章我们讲解过注意力机制:它会建立每个输入 token 与其他所有 token 的关联。随后我们介绍了因果注意力掩码的概念 —— 这是 GPT 类模型的常用设计。
因果掩码会限制每个 token 只能关注当前位置及之前的位置,确保每个 token 只能被自身与前置 token 影响,如下图 7 所示。
图 7:第 3 章讲解的因果注意力机制示意图,以矩阵形式展示输入 token 之间的注意力得分。空白单元格表示被因果掩码遮蔽的位置,即 token 无法关注未来的 token。单元格内的数值是注意力得分,其中最后一个 token “time” 是唯一能计算所有前置 token 注意力得分的 token。
基于图 7 的因果注意力掩码机制,序列中的最后一个 token 积累的信息最多 —— 因为它是唯一能获取所有前置 token 信息的 token。因此在垃圾邮件分类任务中,我们微调时以最后一个 token 为核心。
完成模型改造后,下一节会详细讲解如何将最后一个 token 的输出转化为类别标签预测,并计算模型的初始预测准确率。之后我们会正式微调模型,完成垃圾邮件分类任务。
评估模型性能
由于本节选篇幅已经不短,我就不展开讲解模型评估的细节了。但我想分享一张训练过程中的准确率曲线图,展示训练集与验证集上的分类准确率变化,证明模型确实能学到很好的效果。
图 8:训练准确率(实线)与验证准确率(虚线)在训练初期都大幅提升,随后趋于平稳,最终准确率接近 1.0(即 100%)。训练全程两条曲线非常接近,说明模型对训练数据的过拟合程度很低。
从图 8 可以看到,模型的验证准确率达到了约 97%,测试集准确率(未展示)约为 96%。
此外能看出模型存在轻微过拟合 —— 训练集准确率略高一点。但整体来看模型表现非常优秀:96% 的测试集准确率意味着 100 条消息中,有 96 条能被正确判定为垃圾邮件或非垃圾邮件。
(本节选没有介绍数据集细节:这是一个均衡数据集,垃圾邮件与非垃圾邮件各占 50%,也就是说随机分类器或训练很差的分类器准确率约为 50%。)
额外实验结论
读到这里你可能对很多设计选择有疑问,因此我分享一些额外实验的结果,希望能解答你的部分困惑。复现这些实验的代码已上传至 GitHub。
免责声明:这些实验大多只在 1 个数据集上运行,结论是否具有普适性,还需要在更多数据集上重复验证。
1)是否需要训练全部层?
在上面的章节节选中,出于效率考虑,我们只训练了输出层与最后一个 Transformer 块。如前文所述,做分类微调时,大语言模型并不需要更新全部层 —— 更新的权重越少,训练速度越快,因为反向传播时不需要计算这些权重的梯度。
但你可能会好奇:不更新全部层,会牺牲多少预测性能?
下表对比了三种方案:微调全部层、只微调最后一个 Transformer 块(加输出层)、只微调输出层。
表 1:训练全部层 vs 只训练最后一个 Transformer 块(含输出层)vs 只训练输出层
如表 1 所示,训练全部层的性能略好:准确率 96.67% 对比 95.00%。但对应的训练耗时也增加了约 2.5 倍。
完整的实验结果可在 GitHub 查看。
2)为什么微调基于最后一个 token,而非第一个?
如果你熟悉 BERT 这类编码器风格的语言模型(出自 Devlin 等人 2018 年的论文《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》),应该知道这类模型会把第一个 token 作为专用分类 token,如下图所示。
图注:源自 BERT 原论文的标注图,链接:https://arxiv.org/abs/1810.04805
和 BERT 不同,GPT 是解码器风格的模型,带有因果注意力掩码(之前图 7 有展示)。这意味着第一个 token 无法获取序列中其他任何 token 的上下文信息,只有最后一个 token 能看到所有其他 token 的信息。
因此,如果要用 GPT 这类模型做分类微调,我们应该以最后一个 token 为核心,才能捕获所有输入 token 的上下文信息。
下面是补充的实验证据:可以看到,如果用第一个 token 来微调 GPT 分类模型,性能会差很多。
表 2:GPT 模型微调时使用最后一个 token vs 第一个 token 的效果对比
不过我依然觉得意外:仅靠第一个 token 的信息,就能达到 75% 的垃圾邮件分类准确率。(要知道这是均衡数据集,随机分类器的准确率只有 50%。)
3)BERT 与 GPT 的性能表现有何差异?
说到 BERT,你可能会好奇它和 GPT 风格的模型在分类任务上表现谁优谁劣。
简而言之:在垃圾邮件分类数据集上,小版本 GPT-2 模型与 BERT 的表现相近,如下表所示。
表 3:GPT-2 vs BERT 垃圾邮件分类效果对比
注意 BERT 的表现略好一点(测试准确率高 1%),但它的参数量几乎是 GPT-2 的 3 倍。
此外,这个数据集可能规模太小、任务太简单,因此我又在 IMDB 电影评论数据集上做了情感分类实验(即预测评论者对电影的好恶)。
表 4:GPT-2 vs BERT 电影评论分类效果对比(代码已上传 GitHub)
可以看到,在这个更大的数据集上(包含 2.5 万条训练样本与 2.5 万条测试样本),GPT-2 与 BERT 的预测性能依然比较接近。
过去普遍认为,BERT 等编码器风格的模型在分类任务上优于解码器风格的模型。但上面的实验表明,编码器风格的 BERT 与解码器风格的 GPT,性能差距并不大。
如果你想了解更多基准对比,以及进一步提升解码器模型分类效果的技巧,可以参考这两篇最新论文:
-
Li 等人 2023 年发表的《Label Supervised LLaMA Finetuning》
-
BehnamGhader 等人 2024 年发表的《LLM2Vec: Large Language Models Are Secretly Powerful Text Encoders》
比如这两篇论文都提到:在分类微调阶段移除因果掩码,可以进一步提升解码器风格模型的分类性能。
4)是否应该禁用因果掩码?
GPT 类模型是基于下一词预测任务训练的,因此 GPT 架构的核心特征就是因果注意力掩码 —— 这和 BERT 模型、原始 Transformer 架构都不同。
但在分类微调阶段,我们其实可以移除因果掩码。移除后,我们就可以基于第一个 token 而非最后一个 token 做微调,因为未来的 token 不再被遮蔽,第一个 token 也能看到所有其他 token。
图注:有无因果掩码的注意力权重矩阵对比
好在 GPT 风格大语言模型中禁用因果注意力掩码,只需要修改 2 行代码:
class MultiheadAttention(nn.Module):
def __init__(self, d_in, d_out, context_length, dropout, num_heads):
super().__init__()
# ...
def forward(self, x):
b, num_tokens, d_in = x.shape
keys = self.W_key(x) # Shape: (b, num_tokens, d_out)
queries = self.W_query(x)
values = self.W_value(x)
# ...
attn_scores = queries @ keys.transpose(2, 3)
# 注释掉因果注意力掩码部分
# mask_bool = self.mask.bool()[:num_tokens, :num_tokens]
# attn_scores.masked_fill_(mask_bool, -torch.inf)
attn_weights = torch.softmax(
attn_scores / keys.shape[-1]**0.5, dim=-1
)
context_vec = (attn_weights @ values).transpose(1, 2)
context_vec = context_vec.contiguous().view(
b, num_tokens, self.d_out
)
context_vec = self.out_proj(context_vec)
return context_vec
下表展示了该修改对垃圾邮件分类任务性能的影响。
表 5:微调时启用 vs 禁用因果注意力掩码的 GPT-2 分类器效果对比
从表 5 结果可以看出,微调时禁用因果掩码,能带来小幅性能提升。
5)增大模型规模会带来怎样的效果提升?
到目前为止,我们只测试了最小版本的 GPT-2 模型 —— 参数量 1.24 亿的版本。它和参数量 3.55 亿、7.74 亿、15 亿的更大版本相比,表现有多大差异?
结果汇总在表 6 中。
表 6:不同规模 GPT-2 模型的分类微调效果
可以看到,模型越大,预测准确率提升越明显(不过 GPT-2 medium 是个例外。我在其他数据集上也发现这个模型表现不佳,推测它的预训练效果可能不够好)。
但需要注意:GPT-2 XL 模型的分类准确率虽然比最小版本显著更高,但微调耗时也达到了 7 倍。
6)LoRA 能带来多大的性能增益?
在第一个问题 “是否需要训练全部层?” 中我们发现:只微调最后一个 Transformer 块,性能几乎和微调整个模型持平。只微调最后一个块的优势是训练更快,因为不需要更新全部权重参数。
随之而来的问题是:这和低秩适配(LoRA)相比效果如何?LoRA 是一种参数高效的微调技术。(附录 E 会讲解 LoRA。)
表 7:全量微调(全部层)vs LoRA 参数高效微调效果对比
从表 7 可以看出,在这个数据集上,全量微调(全部层)与 LoRA 的测试集性能完全一致。
在小模型上,LoRA 速度反而略慢一点 —— 因为新增 LoRA 层带来的额外开销超过了其收益;但训练 15 亿参数量的大模型时,LoRA 的训练速度快了 1.53 倍。
7)输入是否需要做填充?
如果训练或推理时要批量处理数据(即一次处理多条输入序列),我们就需要插入填充 token,保证所有训练样本长度一致。
图注:同一批次内的输入文本如何填充至等长的示意图
在常规文本生成任务中,填充不会影响模型输出 —— 因为填充 token 通常加在序列右侧,再加上前面讲的因果掩码,这些填充 token 不会影响其他 token。
但别忘了我们是基于最后一个 token 做微调的。由于填充 token 在最后一个 token 的左侧,它们可能会影响最终结果。
如果批次大小设为 1,其实就不需要对输入做填充。当然,从计算角度看这样效率更低(因为一次只能处理一个样本)。但我们可以用批次大小为 1 的方案做对照,测试填充是否会影响结果。
(另一种方案是添加自定义掩码,让注意力计算忽略填充 token,但这需要修改 GPT 的底层实现,我们改天再讲。)
表 8:对输入做常规填充 vs 不做填充(输入长度相近)的训练效果对比
可以看到,避免填充 token 确实能给模型带来明显的性能提升!
(注:我使用了梯度累积来模拟批次大小为 8 的效果,与默认实验的批次大小保持一致,确保对比公平。)
希望这些额外实验对你有帮助。我还在 GitHub 上放了更多补充实验内容。
从零构建大语言模型
本文是我的新书《从零构建大语言模型》第 6 章的节选,篇幅约 10 页。
你刚刚读到的内容,只是全书 365 页完整内容的一小部分。全书以从零搭建 GPT 风格大语言模型为主线,带你真正理解大语言模型的底层运行原理。
如果本节选内容对你有所启发,相信书中其余内容也同样兼具深度与实用价值。
《从零构建大语言模型》购买渠道
你的支持对我意义重大,也是我持续创作的重要助力。感谢各位!



















