关于作者 Sebastian Raschka

现为威斯康星大学麦迪逊分校的助理教授。《Python 机器学习》作者,这可以说是近十年来最畅销的机器学习书籍之一 开放电子书:《机器学习数学》书稿PDF

 

以下为博客正文,机器翻译

原文请移步https://sebastianraschka.com/blog/2023/llm-finetuning-llama-adapter.html 

在快速发展的人工智能领域,以高效和有效的方式利用大型语言模型变得越来越重要。参数效率的微调处于这一追求的最前沿,允许研究人员和从业者重复使用预先训练的模型,同时最大限度地减少其计算和资源足迹。它还允许我们在更广泛的硬件上训练人工智能模型,包括计算能力有限的设备,如笔记本电脑、智能手机和物联网设备。最后,随着对环境可持续性的日益关注,参数高效的微调减少了与培训大规模人工智能模型相关的能源消耗和碳足迹。

总而言之,参数效率微调至少有5个原因:

1)降低计算成本(需要更少的GPU和GPU时间);2)更快的训练时间(更快地完成训练);3)更低的硬件要求(适用于更小的GPU和更少的内存);4)更好的建模性能(减少过度拟合);5)更少的存储(大多数权重可以在不同的任务之间共享)。

本文解释了微调的广泛概念,并讨论了流行的参数效率替代方案,如前缀调优和适配器。最后,我们将看看最近的LLAMA-Adapter方法,看看如何在实践中使用它。

目录

微调大型语言模型#

自GPT-2(Radford等人)和GPT-3(Brown等人)以来,我们看到,在一般文本语料库上预先训练的生成大型语言模型(LLM)能够进行语境学习,如果我们想执行LLM没有明确训练的特定或新任务,则不需要我们进一步训练或微调预训练的LLM。相反,我们可以通过输入提示直接提供一些目标任务的示例,如下例所示。

上下文学习示例

在对大型语言模型(LLM)的直接访问受到限制的情况下,例如通过API或用户界面与LLM交互时,语境学习是一种有价值的且用户友好的方法。

然而,如果我们可以访问LLM,使用目标域的数据对目标任务进行调整和微调通常会导致更好的结果。那么,我们如何使模型适应目标任务呢?下图概述了三种常规方法。

三种经典的微调方法

基于特征的方法#

在基于特征的方法中,我们加载预训练的LLM,并将其应用于我们的目标数据集。在这里,我们对生成训练集的输出嵌入特别感兴趣,我们可以将其用作训练分类模型的输入功能。虽然这种方法在BERT等以嵌入为重点时特别常见,但我们也可以从生成的GPT样式模型中提取嵌入(您可以在我的博客文章《使用梯度累积在单个GPU上微调大型语言模型》中找到一个示例)。

然后,分类模型可以是逻辑回归模型、随机森林或XGBoost——无论我们内心想要什么。(然而,根据我的经验,像逻辑回归这样的线性分类器在这里表现最好。)

从概念上讲,我们可以用以下代码来说明基于特征的方法:

model = AutoModel.from_pretrained("distilbert-base-uncased")

# ...
# tokenize dataset
# ...

# generate embeddings
@torch.inference_mode()
def get_output_embeddings(batch): 
    output = model(
        batch["input_ids"],
        attention_mask=batch["attention_mask"]
    ).last_hidden_state[:, 0]
return {"features": output}
  
dataset_features = dataset_tokenized.map(
  get_output_embeddings, batched=True, batch_size=10)

X_train = np.array(imdb_features["train"]["features"])
y_train = np.array(imdb_features["train"]["label"])

X_val = np.array(imdb_features["validation"]["features"])
y_val = np.array(imdb_features["validation"]["label"])

X_test = np.array(imdb_features["test"]["features"])
y_test = np.array(imdb_features["test"]["label"])

# train classifier
from sklearn.linear_model import LogisticRegression

clf = LogisticRegression()
clf.fit(X_train, y_train)

print("Training accuracy", clf.score(X_train, y_train))
print("Validation accuracy", clf.score(X_val, y_val))
print("test accuracy", clf.score(X_test, y_test))

(感兴趣的读者可以在这里找到完整的代码示例。)

微调I-更新输出层#

与上述基于特征的方法相关的一种流行方法是微调输出层(我们将这种方法称为微调I)。与基于特征的方法类似,我们冻结了预训练的LLM的参数。我们只训练新添加的输出层,类似于在嵌入式特征上训练逻辑回归分类器或小型多层感知器。

在代码中,这看起来如下:

model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
     num_labels=2  # suppose target task is a binary classification task
) 

# freeze all layers
for param in model.parameters():
    param.requires_grad = False
    
# then unfreeze the two last layers (output layers)
for param in model.pre_classifier.parameters():
    param.requires_grad = True

for param in model.classifier.parameters():
    param.requires_grad = True
    
# finetune model
lightning_model = CustomLightningModule(model)

trainer = L.Trainer(
    max_epochs=3,
    ...
)

trainer.fit(
  model=lightning_model,
  train_dataloaders=train_loader,
  val_dataloaders=val_loader)

# evaluate model
trainer.test(lightning_model, dataloaders=test_loader)

(感兴趣的读者可以在这里找到完整的代码示例。)

理论上,这种方法在建模性能和速度方面应该与基于特征的方法一样出色,因为我们使用相同的冻结骨干模型。然而,由于基于特征的方法使训练数据集的嵌入式特征的预计算和存储略容易,因此基于特征的方法对于特定的实际场景可能更方便。

微调II-更新所有图层#

虽然最初的BERT论文(Devlin等人)报告说,只有输出层的微调才能使建模性能与所有层的微调相当,这要昂贵得多,因为涉及的参数更多。例如,BERT基础模型大约有1.1亿个参数。然而,用于二进制分类的BERT基础模型的最后一层仅由1500个参数组成。此外,BERT基础模型的最后两层占60,000个参数——仅占总模型大小的0.6%左右。

我们的里程将根据我们的目标任务和目标域与模型预训练的数据集的相似程度而有所不同。但在实践中,微调所有层几乎总是能带来卓越的建模性能。

因此,在优化建模性能时,使用预训练LLM的黄金标准是更新所有层(此处称为微调II)。概念微调II与微调I非常相似。唯一的区别是,我们不会冻结预训练的LLM的参数,而是对它们进行微调:

model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
     num_labels=2  # suppose target task is a binary classification task
) 

# don't freeze layers
# for param in model.parameters():
#    param.requires_grad = False
    

# finetune model
lightning_model = LightningModel(model)

trainer = L.Trainer(
    max_epochs=3,
    ...
)

trainer.fit(
  model=lightning_model,
  train_dataloaders=train_loader,
  val_dataloaders=val_loader)

# evaluate model
trainer.test(lightning_model, dataloaders=test_loader)

(感兴趣的读者可以在这里找到完整的代码示例。)

如果您对一些现实世界的结果感到好奇,上面的代码片段用于使用预训练的DistilBERT基础模型训练电影评论分类器(您可以在此处访问代码笔记本):

  • 基于特征的逻辑回归方法:83%的测试准确率
  • 微调I,更新最后2层:87%的准确度
  • 微调II,更新所有图层:92%的准确度。

这些结果符合一般经验法则,即微调更多图层通常会导致更好的性能,但它会增加成本。

微调性能权衡

参数高效微调#

在前面的章节中,我们了解到,对更多图层进行微调通常会导致更好的结果。现在,上述实验基于DistilBERT模型,该模型相对较小。如果我们想微调仅适合GPU内存的大型模型,例如最新的生成LLM呢?当然,我们可以使用上述基于功能或微调的方法。但假设我们想获得与微调II类似的建模质量?

多年来,研究人员开发了几种技术(Lialin等人),以微调具有高建模性能的LLM,同时只需要训练少量参数。这些方法通常被称为参数高效微调技术(PEFT)。

下图总结了一些使用最广泛的PEFT技术。

流行的法学硕士微调方法

最近掀起大波的一个PEFT技术是LLaMA-Adapter,这是为Meta流行的LLaMA模型(Touvron等人)提出的——然而,虽然LLaMA-Adapter是在LLaMA的背景下提出的,但这个想法与模型无关。

要了解LLaMA-Adapter的工作原理,我们必须退后一步,回顾两种称为前缀调优适配器的相关技术——LLaMA-Adapter(Zhang等人)结合并扩展了这两个想法。

因此,在本文的其余部分中,我们将讨论提示修改的各种概念,以了解前缀调优和适配器方法,然后再仔细研究LLaMA-Adapter。(我们将把低级的改编留到将来的文章。)

提示调优和前缀调优#

提示调优的原始概念是指改变输入提示以实现更好的建模结果的技术。例如,假设我们有兴趣将一个英语句子翻译成德语。我们可以用各种不同的方式询问模型,如下所示。

硬提示的一个例子

现在,上面说明的这个概念被称为提示调优,因为我们直接更改了不可微的离散输入令牌。

提示调优不同,提示优调优将输入令牌的嵌入与可训练的张量连接起来,该张量可以通过反向传播进行优化,以提高目标任务的建模性能。

提示调音的特定风味是前缀调音(Li和Liang)。前缀调优的想法是向每个变压器块添加一个可训练的张量,而不是像提示调优那样只添加输入嵌入。下图说明了常规变压器块和用前缀修改的变压器块之间的区别。

LLM的前缀调整

请注意,在上图中,“完全连接的层”指的是一个小的多层感知器(两个完全连接的层,中间有一个非线性激活函数)。这些完全连接的层将软提示符嵌入到与变压器块输入具有相同维度的特征空间中,以确保串联的兼容性。

使用(Python)伪代码,我们可以说明常规变压器块和前缀修改变压器块之间的区别如下:

带有前缀代码的变压器博客

根据原始前缀调优论文,前缀调优实现了与微调所有层相当的建模性能,同时只需要训练0.1%的参数——实验基于GPT-2模型。此外,在许多情况下,前缀调优甚至优于所有层的微调,这可能是因为涉及的参数较少,这有助于减少对较小的目标数据集的过度拟合。

最后,为了澄清在推理过程中使用软提示:在学习软提示后,在执行我们微调模型的特定任务时,我们必须将其作为前缀。这允许模型调整对该特定任务的响应。此外,我们可以有多个软提示,每个提示对应不同的任务,并在推理过程中提供适当的前缀,以实现特定任务的最佳结果。

适配器#

原始适配器方法(Houlsby等人)与上述前缀调优有些关系,因为它们还为每个变压器块添加了额外的参数。然而,适配器方法没有在输入嵌入前缀之前添加前缀,而是在两个地方添加适配器层,如下图所示。

适配器LLM概述

对于喜欢(Python)伪代码的读者,适配器层可以写成如下:

LLM适配器代码

请注意,适配器的完全连接层通常相对较小,并具有类似于自动编码器的瓶颈结构。每个适配器块的第一个完全连接的层将输入投影到低维表示上。第二个完全连接的层将输入投影回输入维度。这个参数如何高效?例如,假设第一个完全连接的层将1024维的输入投影到24维,第二个完全连接的层将其投影回1024维。这意味着我们引入了1,024 x 24 + 24 x 1,024 = 49,152个重量参数。相比之下,将1024维输入重新投影到1,024维空间的单个完全连接层将具有1,024 x 1024 = 1,048,576个参数。

根据原始适配器论文,使用适配器方法训练的BERT模型的建模性能与完全微调的BERT模型相当,而只需要训练3.6%的参数。

现在,问题是适配器方法与前缀调优相比如何。根据原始前缀调优纸,当调整模型参数总数的0.1%时,适配器方法的表现略差于前缀调优方法。然而,当使用适配器方法调整3%的模型参数时,该方法与0.1%的模型参数的前缀调整相关联。因此,我们可以得出结论,前缀调优方法是两者中更有效的。

扩展前缀调优和适配器:LLaMA-适配器#

扩展了前缀调优和原始适配器方法的想法,研究人员最近提出了LLaMA-Adapter(Zhang等人),这是一种LLaMA的参数高效微调方法(LLaMA是Meta流行的GPT替代品)。

前缀调优一样,LLaMA-Adapter方法将可调谐的提示符张量前置到嵌入式输入。值得注意的是,在LLaMA-Adapter方法中,前缀是在嵌入表中学习和维护的,而不是外部提供的。模型中的每个变压器块都有自己独特的学习前缀,允许在不同模型层之间进行更量身定制的适应。

此外,LLaMA-Adapter引入了一种零初始化注意力机制,并结合门控。这种所谓的零初始关注和门控背后的动机是,适配器和前缀调优可能会通过合并随机初始化的张量(前缀提示符或适配器层)来破坏预训练LLM的语言知识,从而在初始训练阶段导致不稳定的微调和高损失值。

与前缀调优和原始适配器方法相比,另一个区别是,LLaMA-Adapter仅将可学习的适应提示添加到L最上面的变压器层,而不是所有变压器层。作者认为,这种方法可以更有效地调整专注于更高层次语义信息的语言表示。

虽然LLaMA适配器方法的基本思想与前缀调优(预置可调软提示)有关,但在实现方法方面存在一些额外的微妙差异。例如,只有自我关注输入的键和值序列通过可调谐的软提示进行修改。然后,根据门控因子(在训练开始时设置为零),要么使用前缀修改的注意力,要么不使用。这个概念在下面的可视化中得到了说明。

骆驼适配器大纲

在伪代码中,我们可以这样表达:

骆驼适配器伪代码

简而言之,LLaMA-Adapter和常规前缀调优之间的区别在于,LLaMA-Adapter只修改顶部(即前几个)变压器块,并引入一个门控机构来稳定训练。虽然研究人员专门对LLaMA进行实验,但他们提出的适配器方法是一种通用方法,也可以应用于其他类型的LLM(如GPT)。

使用LLaMA-Adapter方法,研究人员能够在由52k指令对组成的数据集上,在短短1小时内(使用八个A100 GPU)微调70亿个参数LLaMA模型。此外,与本研究中的问答任务相比,微调的LLaMA-Adapter模型优于所有其他模型,而只有1.2 M参数(适配器层)需要微调。

如果您想查看LLaMA-Adapter方法,您可以在此处找到GPL许可的LLaMA代码之上的原始实现。

或者,如果您的用例与GPL许可证不兼容,该许可证要求您在类似许可证下开源所有衍生作品,请查看Lit-LLaMA GitHub存储库。Lit-LLaMA是Apache许可的nanoGPT代码之上的LLaMA的可读实现,该代码具有较少的限制性许可条款。

具体来说,如果您有兴趣使用LLaMA-Apapter方法微调LLAMA模型,您可以运行

python finetune_adapter.py

来自Lit-LLaMA GitHub存储库的脚本。

结论#

微调预训练的大型语言模型(LLM)是定制这些模型以适应特定业务需求并使其与目标域数据保持一致的有效方法。这个过程涉及使用与所需域相关的较小数据集来调整模型参数,这使模型能够学习特定领域的知识和词汇。

然而,由于LLM是“大型的”,更新变压器模型中的多层可能非常昂贵,因此研究人员开始开发参数高效的替代品。

在本文中,我们讨论了传统LLM微调机制的几种参数效率的替代方案。特别是,我们通过前缀调优和插入其他适配器层来覆盖可调谐软提示。

最后,我们讨论了最近流行的LLaMA-Adapter方法,该方法预设了可调谐软提示,并引入了额外的门控机制来稳定训练。

如果您想在实践中尝试这一点,请查看Lit-LLaMA存储库-非常欢迎有关其他参数高效微调方法的问题和建议!

致谢

我想感谢Carlos Mocholi、Luca Antiga和Adrian Waelchli为提高本文清晰度而提供的建设性反馈。