GPT文本分类器构建指南

利用预训练GPT模型构建文本分类器,选择性微调及优化策略详解,附代码示例。

原文标题:从头构建GPT文本分类器(Python)

原文作者:数据派THU

冷月清谈:

本文介绍了如何将预训练的GPT大型语言模型(LLM)微调为文本分类器,并通过垃圾邮件检测的例子进行了详细说明。文章探讨了微调策略,包括选择性地训练输出层和最后一个transformer块,并解释了为什么关注最后一个token的输出。文章还通过补充实验,比较了不同微调策略的性能,例如微调所有层、只微调最后一个transformer块或只微调输出层,以及使用第一个token和最后一个token进行微调的差异。此外,文章还比较了BERT和GPT在分类任务上的性能,讨论了是否禁用因果掩码、模型规模的影响以及LoRA的应用。最后,文章还分析了填充(Padding)对分类性能的影响,并建议在可能的情况下避免使用padding。

怜星夜思:

1、文章中提到了指令微调和分类微调,除了这两种微调方式外,还有哪些其他的LLM微调方法,它们各自有什么优缺点?
2、文章中选择微调最后一个token,并解释了原因。但是在实际应用中,我们应该如何选择合适的token进行微调?是否存在一些通用的指导原则?
3、文章中提到了padding对分类性能的影响,那么除了padding之外,还有哪些文本预处理技巧可以提升LLM的性能?

原文内容

来源:算法进阶
本文约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_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 个 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 = 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,这意味着这是模型中唯一会在训练期间更新的层。
从技术上讲,只训练刚刚添加的输出层就足够了。然而,我在实验中发现,微调额外的层,可以显著提高微调模型的预测性能。
此外,我们将最后一个 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 = 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 个输入 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 和深度学习相关的内容更简单易懂。
编辑:黄继彦



关于我们

数据派THU作为数据科学类公众号,背靠清华大学大数据研究中心,分享前沿数据科学与大数据技术创新研究动态、持续传播数据科学知识,努力建设数据人才聚集平台、打造中国大数据最强集团军。



新浪微博:@数据派THU

微信视频号:数据派THU

今日头条:数据派THU

我觉得文本预处理的关键在于理解数据的特点和任务的需求。例如,对于一些对语序敏感的任务,可能需要保留原始文本的顺序信息。而对于一些对语义更关注的任务,可以进行更 aggressive 的预处理,例如去除停用词、进行词干提取等。

补充一点,知识蒸馏(Knowledge Distillation)也算是一种微调方法,它可以将大型模型的知识迁移到小型模型上,降低部署成本。优点是可以提高小型模型的性能;缺点是蒸馏过程需要一定的技巧,效果可能不如直接训练大型模型。

这个问题问得好!除了指令微调和分类微调,我感觉强化学习微调也挺火的,它可以让模型从环境反馈中学习,不断优化自己的行为。优点是可以处理复杂的任务,生成更符合人类期望的文本;缺点是需要设计合适的奖励函数,训练过程比较耗时。

对于这个问题,我想到了一种方法:可以尝试对不同的token进行微调,然后比较它们的性能,选择效果最好的那个token。当然,这需要一定的实验成本,但是可以找到最优的方案。

除了padding,文本预处理技巧有很多,比如:1. 数据清洗:去除噪声、重复数据等。2. 分词:将文本切分成词语或子词单元。3. 词干提取/词形还原:将单词转换为其基本形式。4. 停用词去除:去除一些无意义的词语,例如“的”、“是”等。5. 特征工程:例如TF-IDF、词嵌入等。

除了指令微调和分类微调,还有其他几种LLM微调方法,比如:1. 提示学习(Prompt Learning):通过设计合适的提示语,引导LLM完成特定任务。优点是无需更新模型参数,适应性强;缺点是提示工程需要技巧,效果可能不稳定。2. 对抗训练(Adversarial Training):使用对抗样本来增强模型的鲁棒性。优点是可以提高模型对噪声和攻击的抵抗力;缺点是训练过程比较复杂,计算成本高。3. 多任务学习(Multi-task Learning):同时训练多个任务,让模型共享知识。优点是可以提高模型的泛化能力;缺点是任务选择和权衡需要仔细考虑。

补充一点,现在很多LLM都支持直接输入原始文本,无需进行复杂的预处理。但是对于一些特殊任务,例如需要提取特定实体或关系的任务,还是需要进行一些定制化的预处理。