《Python机器学习》作者科普长文:从头构建类GPT文本分类器,代码开源
学起来吧!
近日,机器学习研究员、畅销书《Python 机器学习》作者 Sebastian Raschka 又分享了一篇长文,主题为《从头开始构建一个 GPT 风格的 LLM 分类器》。
文章展示了如何将预训练的大型语言模型(LLM)转化为强大的文本分类器。机器之心对文章内容进行了不改变原意的编译、整理:
为什么要关注分类呢?首先,针对分类任务,对预训练模型进行微调是一个简单有效的 LLM 知识入门方式。其次,文本分类有许多商业应用场景,比如:垃圾邮件检测、情感分析、客户反馈分类、主题分类等等。
阅读完本文,你将找到以下 7 个问题的答案:
1. 需要训练所有层吗?
2. 为什么微调最后一个 token,而不是第一个 token?
3. BERT 与 GPT 在性能上有何比较?
4. 应该禁用因果掩码吗?
5. 扩大模型规模会有什么影响?
6. LoRA 可以带来什么改进?
7. Padding 还是不 Padding?
完整代码可以从 GitHub 找到: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 token: tensor([[-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 ( b, num_tokens, self.d_out ) context_vec = self.out_proj (context_vec) return context_vec下表 5 展示了改变代码后对垃圾邮件分类任务带来的影响。
表 5:有无使用因果注意力掩码来微调 GPT-2 分类器的结果。
可以看到,在微调阶段禁用因果掩码可以带来略微的提升。
增加模型大小会带来哪些影响?
目前为止,我们只看到了最小的 GPT-2(124M)模型的性能,那么与规模更大的 GPT-2 变体相比如何呢?比如 GPT-2 medium(355M)、GPT-2 large(774M)和 GPT-2 XL(1558M)。结果如下表 6 所示。
表 6:不同参数规模的 GPT-2 变体的分类微调结果。
可以看到,随着模型参数增加,预测准确率显著提升。不过 GPT-2 medium 是个例外,它在其他数据集上的性能同样很差。我怀疑该模型可能没有经过很好的预训练。
此外,最大的 GPT-2 XL 获得了比最小的 GPT-2 small(124M)好得多的分类准确率,但微调时间也长了 7 倍。
LoRA 预计能带来哪些改进?
回到本文第一个问题:我们需要训练所有层吗?结果发现,当仅仅微调最后一个 transformer 块而不是整个模型时, 我们可以(或几乎可以)匹配分配性能。所以仅仅微调最后一个块的优势在于训练速度更快,毕竟不是所有的权重参数都要更新。
接下来的问题是与低秩适应(LoRA)的比较结果如何,LoRA 是一种参数高效的微调技术。
表 7:覆盖所有层的完整微调 vs 利用 LoRA 的参数高效微调。
可以看到,完整微调(所有层)和 LoRA 在数据集上获得了相似的测试集性能。
在小模型上,LoRA 会稍微慢一点,添加 LoRA 层带来的额外开销可能会超过获得的收益。但当训练更大的 15 亿参数模型时,LoRA 的训练速度会快 1.53 倍。
填充(Padding)还是不填充?
如果我们想要在训练或推理阶段分批次地处理数据(包括一次处理多个输入序列),则需要插入 padding token,以确保训练样本的长度相等。
图中描述了给定批次中的输入文本如何在 padding 过程中保持长度相等。
在常规文本生成任务中,由于 padding tokens 通常要添加到右侧,因而 padding 不影响模型的响应结果。并且由于前面讨论过的因果掩码,这些 padding tokens 也不影响其他 token。
但是,我们对最后一个 token 进行了微调。同时由于 padding tokens 在最后一个 token 的左侧,因此可能影响结果。
如果我们使用的批大小为 1,实际上不需要 pad 输入。当然,这样做从计算的角度来看更加高效(一次只处理一个输入样本)。并且批大小为 1 可以用作一个变通方法,来测试使用 padding 是否影响结果。
表 8:有无 padding 时,GPT-2(124M)的训练准确率、验证准确率和测试准确率变化。
可以看到,避免 padding tokens 的确可以为模型带来效果的显著提升。这里使用了梯度累计来模拟批大小 8,以匹配默认实验的批大小,并进行公平比较。
作者介绍
个人主页:https://sebastianraschka.com/
Sebastian Raschka 是一名机器学习和人工智能研究员,曾在威斯康星大学麦迪逊分校担任统计学助理教授,专门研究深度学习和机器学习。他致力于关于 AI 和深度学习相关的内容更简单易懂。
Sebastian 还热衷于开源软件,十多年来,他一直是一个充满热情的开源贡献者。他提出的方法现已成功在 Kaggle 等机器学习竞赛中得到应用。
除了编写代码,Sebastian 还喜欢写作,他撰写了畅销书《Python Machine Learning》(《Python 机器学习》)和《Machine Learning with PyTorch and ScikitLearn》。
这篇博客的内容是他的新书《Build a Large Language Model (From Scratch)》的第六章。
更多研究细节,可参考原博客。
原博链接:https://magazine.sebastianraschka.com/p/building-a-gpt-style-llm-classifier
- 免责声明
- 本文所包含的观点仅代表作者个人看法,不代表新火种的观点。在新火种上获取的所有信息均不应被视为投资建议。新火种对本文可能提及或链接的任何项目不表示认可。 交易和投资涉及高风险,读者在采取与本文内容相关的任何行动之前,请务必进行充分的尽职调查。最终的决策应该基于您自己的独立判断。新火种不对因依赖本文观点而产生的任何金钱损失负任何责任。