
国产精品大模型API价格对比:通义千问 Max、字节跳动Doubao 1.5 pro 256k、DeepSeek V3
在本文中,我想向您展示如何将预训练的大型语言模型转换为强文本分类器。
但为什么要关注分类问题呢?首先,对预训练的分类模型进行微调是一种温和而有效的模型微调入门方法。其次,许多现实世界和商业挑战都围绕着文本分类:垃圾邮件检测、情绪分析、客户反馈分类、主题标签等等。
我很高兴地宣布我的新书《从头构建大型语言模型》即将出版,由 Manning 出版。这本书经过近两年的制作,现在终于在 Manning 网站上以电子书和印刷版的形式提供(也可以在亚马逊上预订)。
根据我的经验,深入理解一个概念的最好方法是从头开始构建它。这本书将指导您完成构建类似 GPT 的 LLM 的整个过程——从实现数据输入到使用指令数据进行微调。我的目标是,让您在读完这本书后,能够深入、详细且透彻地了解LLM的工作原理。
为了庆祝本书的出版,我特地分享了一章摘录,指导您如何将预训练的LLM微调为垃圾邮件分类器。
重要提示
关于分类微调的章节长达 35 页——对于一篇文章来说太长了。因此,在这篇文章中,我将重点介绍约 10 页的子集,介绍分类微调背后的背景和核心概念。
此外,我还将分享一些书中未涵盖的额外实验见解,并解答读者可能遇到的常见问题。(请注意,以下摘录基于 Manning 的专业文本编辑和最终图形设计之前的个人草稿。)
此外,我还将回答您可能遇到的有关训练 LLM 分类器的 7 个问题:
1)我们需要训练所有层吗?
2)为什么要微调最后一个标记,而不是第一个标记?
3)BERT 与 GPT 性能相比如何?
4)我们是否应该禁用因果掩码?
5)增加模型尺寸会产生什么影响?
6)我们可以期待 LoRA 有哪些改进?
7) 有填充还是无填充?
祝您阅读愉快!
微调语言模型最常见的方法是指令微调和分类微调。指令微调涉及使用特定指令在一组任务上训练语言模型,以提高其理解和执行自然语言提示中描述的任务的能力,如下图 1 所示。
下一章将讨论指令微调,如上图 1 所示。同时,本章将着重介绍分类微调,如果您具备机器学习背景,那么您可能对这个概念已经有所了解。
在分类微调中,模型经过训练可以识别一组特定的类别标签,例如“垃圾邮件”和“非垃圾邮件”。分类任务的例子不仅仅局限于大型语言模型和电子邮件过滤,它们还广泛存在于从图像中识别不同种类的植物、将新闻文章分类为体育、政治或科技等主题,以及判断医学影像中的肿瘤是良性还是恶性等场景中。
关键点在于,分类微调模型仅限于预测它在训练期间遇到的类别 – 例如,它可以确定某些内容是“垃圾邮件”还是“非垃圾邮件”,如下图 2 所示,但它无法对输入文本说出任何其他信息。
与图 2 所示的分类微调模型相比,指令微调模型通常能够执行更广泛的任务。我们可以将经过分类微调的模型看作是高度专业化的模型,而且通常来说,开发这样的专业化模型要比开发能够适用于各种任务的通用模型来得更容易。
选择正确的方法
指令微调可提高模型理解和根据特定用户指令生成响应的能力。指令微调最适合需要根据复杂用户指令处理各种任务的模型,可提高灵活性和交互质量。另一方面,分类微调非常适合需要将数据精确分类为预定义类别的项目,例如情绪分析或垃圾邮件检测。
虽然指令微调具有更广泛的用途,但要开发出一个能够胜任多种任务的模型,它需要更大的数据集以及更多的计算资源。相比之下,分类微调需要的数据和计算能力较少,但其用途仅限于模型训练的特定类别。
由于这是摘录的内容,我们将直接跳过数据准备和模型初始化的部分,这些内容已经在前面的章节中实现并完成了预训练。根据我的经验,与阅读实体书相比,阅读冗长的数字文章时保持注意力可能更具挑战性。因此,我将尝试让此摘录/文章紧紧围绕本章的一个关键要点。
为了提供一些关于本章摘录重点介绍的部分背景信息,本摘录主要关注将一般预训练的 LLM 转换为用于分类任务的专门的 LLM 所需的修改,如下图 3 所示。
但是,在我们跳转到图3中提到的对LLM的修改之前,让我们先简要地了解一下我们正在使用的预训练LLM。
因此,为了简单起见,假设我们设置代码来加载模型如下:
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
根据输出结果,很明显模型在遵循指令方面遇到了困难。
这是在意料之中的,因为它目前仅经过了预训练,还未经过指令微调。我们将在接下来的章节中深入探讨这个问题。
下一节为分类微调准备模型。
在本节中,我们将修改预训练的大型语言模型,以准备进行分类微调。为此,我们将原始输出层(将隐藏表示映射到 50,257 个唯一标记的词汇表)替换为较小的输出层(映射到两个类别:0(“非垃圾邮件”)和 1(“垃圾邮件”),如下图 4 所示。
如上图4所示,除了替换输出层外,我们使用与前几章相同的模型。
输出层节点
从技术上讲,我们可以使用单个输出节点,因为我们正在处理二元分类任务。但是,这需要修改损失函数,如附录 B 参考部分中的一篇文章所述。因此,我们选择了一种更通用的方法,其中输出节点的数量与类的数量相匹配。例如,在处理有三类分类问题(如将新闻文章分类为“技术”、“体育”或“政治”)时,我们会使用三个输出节点,以此类推。
在尝试进行图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 个相同的变压器块(为简洁起见,仅显示最后一个块)、最后层LayerNorm
和输出层out_head
组成。
接下来,我们用一个新的输出层替换out_head
,如图 4 所示,我们会对其进行微调。
对选定层进行微调与对所有层进行微调
由于我们从预训练模型开始,因此无需对所有模型层进行微调。这是因为在基于神经网络的语言模型中,较低层通常捕获适用于广泛任务和数据集的基本语言结构和语义。因此,仅对最后的层(靠近输出的层)进行微调通常足以使模型适应新任务,这些层更特定于细微的语言模式和特定于任务的特征。一个额外的优点是,仅对少数层进行微调在计算上更为高效。对于对此感兴趣的读者,可以在附录B的本章参考资料部分找到更多关于选择哪些层进行微调的信息(包括相关实验)。
为了让模型做好分类微调的准备,我们首先需要冻结模型,即设置所有层为不可训练状态。
for param in model.parameters():
param.requires_grad = False
然后,如前面的图 4 所示,我们替换输出层(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
为默认,这意味着它是模型中唯一在训练期间会更新的层。
从技术上讲,训练我们刚刚添加的输出层就足够了。但是,正如我在实验中发现的那样,微调其他层可以显着提高微调模型的预测性能。
此外,我们将最后一个变压器块以及连接该块到输出层的LayerNorm配置为可训练的,如图5所示。
为了使最终的LayerNorm
和最后一个 Transformer 块可训练,如上图 5 所示,我们将它们各自设置为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 个输入标记组成的张量:
Inputs: tensor([[5211, 345, 423, 640]])
Inputs dimensions: torch.Size([1, 4])
然后,我们可以像往常一样将编码的令牌 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]
,其中 50,257 代表词汇量。与前几章相同,输出的行数仍然与输入标记的数量相对应(在本例中为4)。但是,由于我们已经替换了模型的输出层,因此每个输出的嵌入维度(即列数)现在已经减少到了2,而不是之前的50,257。
请记住,我们的目标是微调这个模型,让它能够返回一个类别标签,这个标签能够指示模型的输入是垃圾邮件还是非垃圾邮件。为此,我们不需要微调所有 4 个输出行,而是可以专注于单个输出标记。具体来说,我们将重点关注与最后一个输出标记相对应的最后一行,如下图 6 所示。
为了从输出张量中提取最后一个输出标记(如上图 6 所示),我们使用以下代码:
print("Last output token:", outputs[:, -1, :])
这将打印以下内容:
Last output token: tensor([[-3.5983, 3.9902]])
在进入下一节之前,让我们回顾一下我们的讨论。我们将着重讲解如何将输出值转换为类别标签预测。但在那之前,让我们先了解一下为何我们特别关注最后一个输出标记,而不是第一个、第二个或第三个。
在第 3 章中,我们探讨了注意力机制,该机制在每个输入标记与每个其他输入标记之间建立了关系。随后,我们引入了因果注意力掩码的概念,该概念常用于类似 GPT 的模型。此掩码将标记的焦点限制在其当前位置及其之前的位置,确保每个标记只能受到其自身和前面标记的影响,如下图 7 所示。
鉴于图7中所示的因果注意掩码设置,序列中的最后一个标记累积了最多的信息,因为它能够访问到所有先前标记的数据,是唯一一个拥有这种能力的标记。因此,在我们的垃圾邮件分类任务中,我们在微调过程中将重点放在最后一个标记上。
修改完模型后,下一节将详细介绍将最后一个标记转换为类标签预测的过程,并计算模型的初始预测准确率。接下来,我们将在下一节中针对垃圾邮件分类任务微调模型。
由于这段摘录已经相当冗长,我将不再详细阐述模型评估的内容。但是至少我想与您分享一张图表,该图表展示了训练期间训练集和验证集上的分类准确率,以此来证明该模型确实学习得很好。
如上图 8 所示,该模型的验证准确率约为 97%。测试准确率(未显示)约为 96%。此外,我们可以看到模型略微过拟合,这从训练集准确率略高可以看出。总体来说,该模型的表现非常出色:在测试集上达到了96%的准确率,这意味着它能够正确识别出100条消息中的96条是垃圾邮件还是非垃圾邮件。(我们在此摘录中没有讨论数据集,但这是一个平衡的数据集,其中 50% 是垃圾邮件,50% 是非垃圾邮件,这意味着随机或训练不良的分类器将实现大约 50% 的分类准确率。)
到这个阶段,您可能对某些设计选择存在诸多疑问,因此我想分享一些其他实验的结果,这些结果或许能帮助您解答一个或多个疑问或消除某些疑虑。
免责声明:实验大多仅在 1 个数据集上运行,并且将来应该在其他数据集上重复运行,以测试这些发现是否具有普遍性。
在上面的章节摘录中,出于效率原因,我们仅训练了输出层和最后一个变压器块。如前所述,对于分类微调,没有必要更新 LLM 中的所有层。(我们更新的权重越少,训练速度就越快,因为我们不需要在反向传播期间计算这些权重的梯度。)
但是,您可能好奇如果不更新所有层,我们在预测性能上会有所损失。因此,在下表中,我对比了微调所有层、仅微调最后一个转换器块(加上最后一层)以及仅微调最后一层的实验结果。
如上表 1 所示,训练所有层的性能略好一些:96.67% 对 95.00%。(不过,这增加了运行时间约 2.5 倍。)
如果您熟悉 BERT 之类的编码器式语言模型( Devlin 等人于 2018 年发表的《BERT:用于语言理解的深度双向变压器的预训练》),您可能知道这些模型具有指定的分类标记作为它们的第一个标记,如下图所示。
与 BERT 相比,GPT 是一种带有因果注意掩码的解码器式模型(如前面的图 7 所示)。这意味着第一个token没有获取到输入中任何其他token的上下文信息,而只有最后一个token包含了关于所有其他token的信息。
因此,如果我们想使用像 GPT 这样的模型进行分类微调,我们应该关注最后一个标记,以捕获所有其他输入标记的上下文信息。
下面是额外的实验证据,我们可以看到,使用第一个标记来微调 GPT 模型进行分类会导致更差的性能。
总体而言,我仍然感到惊讶,第一个标记包含如此多的信息,能够以 75% 的准确率确定邮件是否为垃圾邮件。(这并不是一个平衡的数据集,随机分类器的准确率为 50%)。
说到 BERT,您可能想知道它与分类任务上的 GPT 风格模型相比如何。
简而言之,上一节中的小型 GPT-2 模型和 BERT 在垃圾邮件分类数据集上的表现同样出色,如下表所示。
请注意,BERT 模型的表现略好一些(测试准确率高出 1%),但规模也几乎是 3 倍。另外,考虑到之前的数据集可能太小且相对简单,我还尝试使用IMDB电影评论数据集来进行情感分类任务,也就是预测评论者是否喜欢这部电影。
可以看出,GPT-2 和 BERT 这两个模型在这个更大的数据集(由 25k 条训练集记录和 25k 条测试集记录组成)上也具有相对相似的预测性能。
普遍的观点是,在分类任务上,BERT等编码器式模型的表现要优于解码器式模型。但如上所述的实验结果显示,编码器式的BERT与解码器式的GPT模型之间的性能差异并不显著。
此外,如果您对更多基准比较和进一步改进解码器式分类模型的技巧感兴趣,您可能会喜欢这两篇最新论文:
例如,正如上述论文所讨论的,可以通过在分类微调期间去除因果掩码来进一步提高解码器式模型的分类性能。
由于我们是在下一个词预测任务上训练类似GPT的模型,因此GPT架构的一个核心特性就是采用了因果注意力掩码(这与BERT模型或原始的Transformer架构是有所不同的)。
然而,我们实际上可以在分类微调期间删除因果掩码,这将允许我们微调第一个而不是最后一个标记,因为未来的标记将不再被掩码,并且第一个标记可以看到所有其他标记。
幸运的是,在类似 GPT 的 LLM 中禁用因果注意力掩码只需要更改两行代码:
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)
# Comment out the causal attention mask part
# 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 显示了此修改如何影响垃圾邮件分类任务的性能。
根据表 5 中的结果我们可以看出,当我们在微调期间禁用因果掩码时,我们可以获得小幅改进。
到目前为止,我们仅研究了最小的 GPT-2 模型(1.24 亿个参数版本)的性能。与具有3.55亿、7.74亿和15亿个参数的较大版本相比,它的表现如何呢?相关的结果已经总结在表6中。
我们可以看到,随着模型规模的扩大,预测准确率显著提高(然而,GPT-2 中等规模在这里是个例外。我也注意到该模型在其他数据集上的表现不佳,我怀疑该模型可能没有经过很好的预训练。)
然而,尽管GPT-2 XL模型在分类准确率上明显优于最小模型,但其微调时间却相应地延长了7倍。
对于第一个问题”我们需要训练所有层吗?”,我们发现,仅通过微调最后一个Transformer块而非整个模型,我们就能(几乎)达到相同的分类性能。仅微调最后一个块的优点是训练速度更快,因为并非所有权重参数都会更新。
接下来的问题是,将其与低秩自适应(LoRA)这种参数高效的微调技术相比较,结果会如何呢?(关于LoRA的详细介绍,请参见附录E。)
如上表 7 所示,完全微调(所有层)和 LoRA 在该数据集上均获得相同的测试集性能。
在小型模型上,LoRA 稍微慢一些,因为添加 LoRA 层所带来的额外开销可能超过其带来的好处,但在训练更大的 15 亿参数模型时,LoRA 的训练速度要快 1.53 倍。
如果我们想要在训练或推理期间批量处理数据(这涉及一次处理多个输入序列),我们需要插入填充标记以确保训练示例的长度相等。
在常规文本生成任务中,填充不会影响模型响应,因为填充标记通常添加到右侧,并且由于前面讨论的因果掩码,这些填充标记不会影响其他标记。
但是,请注意,如前所述,我们的微调是基于最后一个标记的。由于填充标记位于最后一个标记的左侧,因此它们可能会对结果产生影响。
如果我们使用的批处理大小为1,那么实际上就不需要对输入进行填充。当然,从计算的角度来看,这样的处理效率会更低,因为我们一次只能处理一个输入示例。)不过,批处理大小 1 可以用作一种解决方法,以测试使用填充是否可以改善结果。(另一种解决方案是添加自定义掩码以在注意力得分计算中忽略填充标记,但由于这需要更改 GPT 实现,所以这是另一个话题。
我们可以看到,避免使用填充标记确实可以给模型带来明显的提升!(请注意,我使用梯度累积来模拟批量大小为 8,以匹配默认实验的批量大小,并进行公平的比较。)
本文展示了我的新书《从头开始构建大型语言模型》第 6 章的 10 页片段。
您所阅读的内容只是“从头开始构建类似GPT的大型语言模型(LLM)”这一整个365页旅程中的一小部分,旨在帮助您深入了解LLM的实际工作原理。
国产精品大模型API价格对比:通义千问 Max、字节跳动Doubao 1.5 pro 256k、DeepSeek V3
REST API:关键概念、最佳实践和优势
大模型API乱斗,基础参数、核心性能:Grok3、deepseek R1、ChatGPT 4o
3大AI语言大模型API价格的区别:ChatGPT 4o、百度千帆 ERNIE 4.0、阿里通义千问 Max
3大AI语言大模型API基础参数、核心性能的区别:ChatGPT 4o、百度千帆 ERNIE 4.0、阿里通义千问 Max
大模型API乱斗,价格对比:Grok3、deepseek R1、ChatGPT 4o
FastAPI 异步编程:提升 API 性能
2025最强AI大模型分析:Gemini 2.5 Pro vs Claude 3.7 Sonnet API评测
如何获取通义千问 API Key 密钥(分步指南)