专栏名称: 数据派THU
本订阅号是“THU数据派”的姊妹账号,致力于传播大数据价值、培养数据思维。
目录
相关文章推荐
天池大数据科研平台  ·  谷歌反击,最强Gemini ... ·  昨天  
大数据文摘  ·  谢谢Deepseek,o3-mini发布即免 ... ·  5 天前  
大数据分析和人工智能  ·  35岁被优化,经济压力大,看DeepSeek ... ·  4 天前  
数据派THU  ·  GraphTeam: ... ·  4 天前  
51好读  ›  专栏  ›  数据派THU

从头构建GPT文本分类器(Python)

数据派THU  · 公众号  · 大数据  · 2025-02-07 17:00

主要观点总结

本文介绍了如何将预训练的大型语言模型(LLM)转化为强大的文本分类器,分享了从头开始构建一个GPT风格的LLM分类器的过程,并探讨了分类微调的不同种类和指令微调。同时,文章还探讨了模型架构的微调,包括是否需要对所有层进行训练,以及使用不同的微调策略如LoRA的影响。此外,文章还涉及了因果掩码的使用、模型性能评估以及补充实验的结果。

关键观点总结

关键观点1: 大型语言模型在文本分类任务中的应用

介绍了如何将预训练的LLM转化为文本分类器,并展示了使用GPT风格的LLM进行垃圾邮件分类的实例。

关键观点2: 分类微调与指令微调的区别

阐述了分类微调中模型架构的微调方法,包括是否需要对所有层进行训练,以及使用不同的微调策略如指令微调。

关键观点3: 因果掩码的使用

探讨了因果掩码在模型训练中的作用,包括其在分类微调阶段的适用性。

关键观点4: 模型性能评估

展示了模型的训练集和验证集准确率,并讨论了模型的测试集性能。

关键观点5: 补充实验结果

分享了一系列补充实验的结果,包括不同微调策略、模型规模、LoRA和padding的影响等。


正文

来源:算法进阶
本文约6500字,建议阅读10分钟
本文展示了如何将预训练的大型语言模型(LLM)转化为强大的文本分类器。


畅销书《Python 机器学习》作者 Sebastian Raschka 又分享了一篇长文,主题为《从头开始构建一个 GPT 风格的 LLM 分类器》。
文章展示了如何将预训练的大型语言模型(LLM)转化为强大的文本分类器。小编对文章内容进行了不改变原意的编译、整理。
为什么要关注分类呢?首先,针对分类任务,对预训练模型进行微调是一个简单有效的 LLM 知识入门方式。其次,文本分类有许多商业应用场景,比如:垃圾邮件检测、情感分析、客户反馈分类、主题分类等等。
阅读完本文,你将找到以下 7 个问题的答案:

1. 需要训练所有层吗?

2. 为什么微调最后一个 token,而不是第一个 token?

3. BERT 与 GPT 在性能上有何比较?

4. 应该禁用因果掩码吗?

5. 扩大模型规模会有什么影响?

6. LoRA 可以带来什么改进?

7. Padding 还是不 Padding?

完整代码:https://github.com/rasbt/LLMs-from-scratch/blob/main/ch06/01_main-chapter-code/ch06.ipynb

Different categories of finetuning
微调的不同种类
指令微调和分类微调是最常见的语言模型微调方法。指令微调是用特定任务训练模型,提高它理解和执行自然语言提示中所描述任务的能力,如下图 1 所示。

图 1:指令微调的两种场景。上方:模型的任务是判断文本是否为垃圾邮件;下方:模型的任务是将英文句子翻译成德语。
在分类微调中,模型被训练用于识别特定的类别标签,比如「垃圾邮件」和「非垃圾邮件」。分类任务还包括从图像中识别不同的植物、给新闻按体育、政治或科技等主题分类,从医学影像中区分良性和恶性肿瘤等等。
不过经过分类微调的模型只能判断类别,不能对输入的文本作出其他判断。

图 2:一个使用 LLM 进行垃圾邮件分类的示例。针对垃圾邮件分类微调的模型在输入时不需要额外的指令,然而,与指令微调模型相比,它的回答只能是「垃圾邮件」和「非垃圾邮件」。
指令微调的模型通常能够执行更广泛的任务。我们可以将分类微调的模型视为是高度专业化的模型,一般来说,开发一个专用模型比开发一个在各种任务上表现良好的通用模型更容易。
使用预训练权重初始化模型
下图中展示了将通用预训练 LLM 转变为专门用于分类任务的 LLM 需要做的修改:

图 3:在此跳过步骤 1-5,直接进入步骤 6(将在下一节开始)。
在做修改之前,让我们先简单了解一下正在使用的预训练 LLM。为简便起见,假设我们设置了如下代码来加载该模型:
model = GPTModel (BASE_CONFIG)load_weights_into_gpt (model, params)model.eval ()


在将模型权重加载到 GPT 后,使用下列文本生成的函数库,确保模型生成连贯的文本:
from chapter04 import generate_text_simplefrom chapter05 import text_to_token_ids, token_ids_to_texttext_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 个 tokens 的词表)替换为一个较小的输出层,该层映射到两个类别:0(非垃圾邮件)和 1(垃圾邮件),如下图 4 所示。

图 4:此图展示了如何通过改变架构将 GPT 模型适配为垃圾邮件分类。最初,模型的线性输出层将 768 个隐藏单元映射到一个包含 50,257 个 tokens 的词汇表。为了进行垃圾邮件检测,这一层被替换为一个新的输出层,该层将相同的 768 个隐藏单元映射到两个类别,分别表示「垃圾邮件」和「非垃圾邮件」。

输出层节点
从技术上讲,因为这是一个二元分类任务,可以只用一个输出节点。然而,这将需要修改损失函数。因此,我们选择一种更通用的方法,匹配输出节点与分类的数量。例如,对于一个分三类的问题,如将新闻文章分类为「科技」、「体育」或「政治」,使用三个输出节点,依此类推。
在尝试进行图 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))


如上所示,GPTModel 由嵌入层和 12 个相同的 transformer 块组成,为简洁起见,仅显示最后一个块,然后是最终的 LayerNorm 和输出层 out_head。
接下来,我们将 out_head 替换为一个新的输出层,如图 4 所示,我们将对这一层进行微调。
选择微调特定层与微调所有层
我们不必对模型每一层进行微调,因为神经网络的较低层捕捉到的基本的语言结构和语义是通用的,可以在许多不同的任务和数据集中发挥作用。
因此,我们仅微调最后几层(靠近输出的层)就够了,这些层更具体于细微的语言模式和任务特征。这种方法在计算上也将更加高效。
为了准备进行分类微调,首先我们冻结模型,即将所有层设置为不可训练:
for param in model.parameters ():    param.requires_grad = False


然后,如图 4 所示,我们修改输出层 model.out_head :
torch.manual_seed (123)num_classes = 2model.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 模块设置为可训练,如图 5 所示。

图 5:用我的步骤开发的 GPT 模型包含 12 个重复的 transformer 块。除了输出层,我们将最后的 LayerNorm 和最后一个 transformer 块设置为可训练,而其余 11 个 transformer 块和嵌入层保持为不可训练。
为了做到这点,我们将它们各自的 requires_grad 设置为 True:
for param in model.trf_blocks [-1].parameters ():    param.requires_grad = Truefor 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 个输入 tokens 的张量:
Inputs: tensor ([[5211,  345,  423,  640]])Inputs dimensions: torch.Size ([1, 4])


然后,我们将编码后的 token IDs 输入模型:
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] 的输出张量,其中 50,257 代表词汇表的大小。输出行数对应于输入标记的数量(在本例中是 4)。每个输出的嵌入维度(列数)现在减少到 2,而不是 50,257,因为我们替换了模型的输出层。
由于我们的主要目标是微调出更擅长对垃圾邮件进行分类的模型。为了实现这一点,我们不需要对所有行进行微调,可以专注于一个单一的输出 token。具体来说,我们将专注于最后一行,对应的最后一个输出 token,如图 6 所示。

图 6: 本图展示了 GPT 模型处理一个包含 4 个 token 的输入示例,并生成相应输出的详细过程。模型的输出层经过调整,输出张量仅包含 2 列,为了完成分类微调,我们专注于输出的最后一行,对应的最后一个 token。
可以使用以下代码从输出张量中提取最后一个输出 token:
print ("Last output token:", outputs [:, -1, :])


Print 出来结果如下:
Last output tokentensor([[-3.5983,  3.9902]])


那么,我们为什么要选择最后一个 token,而不是其他位置上的 token 呢?
注意力机制建立了每个输入 token 与其他 token 之间的关系,为了让「注意力」集中,需要用到因果注意力掩码。它的原理是限制每个 token 只关注自己和前面的 token,如下图 7 所示:

图 7:因果注意力机制,矩阵显示了每个输入 token 之间的注意力得分。空白单元格表示被掩码屏蔽的位置,防止 token 关注后来的 token。最后一个 token「time」是唯一需要为所有之前的 token 计算注意力得分的 token。
如图所示,序列中的最后一个 token 积累了最多的信息,因此,在微调过程中,我们重点关注这个最后的 token。
如何将最后一个 token 转换为分类标签预测,并计算模型的初始预测准确率。接下来,我们将在后续部分微调模型以完成垃圾邮件分类任务。
评估模型性能
由于这部分内容已经很长,我就不详细讨论模型评估的细节了。不过,我想至少分享一张图,展示训练过程中,模型训练集和验证集的分类准确率,以展示模型确实学得很好。

图 8:训练准确率(实线)和验证准确率(虚线)在早期的训练周期中大幅上升,然后趋于平稳,达到了几乎完美的准确率 1.0,对应 100%。两条线在整个训练过程中相距较近,表明模型对训练数据并没有过度拟合。
模型的验证准确率约为 97%。测试准确率约为 96%。此外,我们可以看到模型略微有一点点过拟合,因为训练集的准确率稍高。
从补充实验得出的洞见
到这里,你可能对某些设计选择有很多疑问,所以我进行了一些补充实验并把结果分享了出来。重新运行这些实验的代码已经放在了以下 GitHub 项目中。
GitHub 地址:https://github.com/rasbt/LLMs-from-scratch/tree/main/ch06/02_bonus_additional-experiments
需要训练所有层吗?
出于效率原因,我们仅训练输出层和最后一个 transformer 块。如前所述,对于分类微调,无需更新 LLM 中的所有层。我们更新的权重越少,训练速度就越快,因为我们不需要在反向传播期间计算权重的梯度。
但是,你可能想知道如果不更新所有层,我们会留下多少预测性能。因此,在下表中,我对所有层、仅最后一个 transformer 块(包括最后一层)、仅最后一层进行了微调。

表 1:训练所有层 vs 仅训练最后一个 Transformer 块(包括最后一层)vs 仅训练最后一层
如上表 1 所示,训练所有层的性能稍好一些:96.67% vs 95.00%。不过,这使运行时间增加了约 2.5 倍。
为什么要微调最后一个 token,而不是第一个 token?
如果你熟悉 BERT(Devlin et al. 2018)等编码器式语言模型,你可能知道这些模型有一个指定的分类 token 作为其第一个 token,如下图所示:

图来自 BERT 原始论文:https://arxiv.org/abs/1810.04805
与 BERT 相比,GPT 是一种具有因果注意力掩码的解码器式模型(如图 7 所示)。这意味着第一个 token 没有输入中任何其他 token 的上下文信息。只有最后一个 token 具有有关所有其他 token 的信息。
因此,如果我们想使用像 GPT 这样的模型进行分类微调,我们应该关注最后一个 token 标记以捕获所有其他输入 token 的上下文信息。
如下表所示,我们可以看到使用第一个 token 来微调 GPT 模型进行分类会导致性能更差。

表 2:微调 GPT 模型中的最后一个 token 与第一个 token。
BERT 与 GPT 的性能比较如何?
说到 BERT,你可能想知道它在分类任务上与类 GPT 模型的性能比较如何?简单来说,在垃圾邮件分类任务上,更小的 GPT-2(124M)与更大 BERT(340M)的性能类似,具体如下表 3 所示。

表 3:GPT-2 与 BERT 的结果比较。


可以看到,BERT 模型的表现比 GPT-2 稍微好一点(测试准确率高 1%),但 BERT 的参数规模几乎是 GPT-2 的 3 倍。此外,数据集可能太小且太简单了,因此我又在 IMDB Movie Review 数据集上尝试比较了情感分类表现(即预测观看者是否喜欢一部电影)。
表 4:GPT-2 与 BERT 在影评分类任务上的比较。

可以看到,在这个更大的数据集上(包含 25k 训练和 25k 测试集记录),GPT-2 与 BERT 两个模型的预测性能同样类似。
总的来说,在分类任务上,BERT 和其他编码器风格的模型被认为优于解码器风格的模型。但是,实验结果也表明,编码器风格的 BERT 和解码器风格的 GPT 模型之间没有太大的差异。
此外,如果你对更多基准比较以及如何进一步提升解码器风格模型的分类性能感兴趣,可以参阅以下两篇最近的论文:
  • Label Supervised LLaMA Finetuning: https://arxiv.org/abs/2310.01208

  • LLM2Vec: Large Language Models Are Secretly Powerful Text Encoders: https://arxiv.org/abs/2404.05961
其中第一篇论文讨论了:在分类微调期间移除因果掩码可以提升解码器风格模型的分类性能。
我们应该禁用因果掩码吗?
当我们在下一个词(next-word)预测任务上训练类 GPT 模型时,GPT 架构的核心特征是因果注意力掩码,这与 BERT 模型或原始 transformer 架构不同。
但实际上,我们可以在分类微调阶段移除因果掩码, 从而允许我们微调第一个而不是最后一个 token。这是因为未来的 tokens 将不再被掩码,并且第一个 token 可以看到所有其他的 tokens.
有 / 无因果掩码的注意力权重矩阵。

幸运的是,在类 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)        # 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 (






请到「今天看啥」查看全文