天天看点

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

作者:零度AI

by April 12, 2023 by Sebastian Raschka

本文介绍了流行的大型语言模型(LLM)参数高效微调方法:前缀调优,适配器,以及LLaMA-适配器。

在快速发展的人工智能领域,有效、高效地利用大型语言模型越来越重要。

参数高效微调处于这个追求的前沿,它使研究者和实践者能够重复使用预训练模型,同时最小化它们的计算和资源占用。这也使我们能够在更广泛的硬件上训练AI模型,包括计算能力有限的设备,如笔记本电脑,智能手机和物联网设备。最后,随着对环境可持续性的关注度不断提高,参数高效微调减少了训练大规模AI模型所需的能源消耗和碳排放。

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

微调大型语言模型

自从GPT-2和GPT-3以来,我们已经看到,预训练于通用文本语料库的生成性大型语言模型(LLM)能够进行上下文学习,这意味着如果我们想让预训练的LLM执行特定的或新的任务,这些任务并非LLM明确训练过,我们并不需要进一步训练或微调预训练的LLM。相反,我们可以直接通过输入提示提供目标任务的几个示例,如下面的示例所示。

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

在上下文学习对于直接访问大型语言模型(LLM)受限的情况下是一种有价值且用户友好的方法,例如通过API或用户界面与LLM进行交互。 然而,如果我们能够访问LLM,通常使用来自目标领域的数据对其进行适应和微调会得到更好的结果。那么,我们如何将模型适应到目标任务呢?下图概述了三种常规方法。

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

上述方法都与生成式(解码器风格)模型如GPT以及以嵌入为重点的(编码器风格)模型如BERT兼容。与这三种方法相对的是,上下文学习只适用于生成模型。值得强调的是,当我们微调生成模型时,我们处理并构建它们创建的嵌入,而不是生成的输出文本。

特征基础方法

在基于特征的方法中,我们加载一个预训练的LLM并将其应用于我们的目标数据集。在这里,我们特别感兴趣的是为训练集生成输出嵌入,我们可以将其作为输入特征来训练分类模型。虽然这种方法特别适用于像BERT这样以嵌入为重点的模型,但我们也可以从生成式的GPT风格模型中提取嵌入(在我们的博客文章中可以找到一个例子)。

然后,分类模型可以是逻辑回归模型,随机森林,或者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基础模型大约有110百万个参数。然而,一个用于二分类的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%的精度。 这些结果与通常的经验法则一致,即微调更多层通常会得到更好的性能,但代价也会增加。

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

参数高效微调

在前面的章节中,我们了解到微调更多层通常会带来更好的结果。现在,上述实验是基于DistilBERT模型的,这是一个相对较小的模型。那么,如果我们想微调只能勉强适应GPU内存的更大模型,例如,最新的生成式LLM呢?我们当然可以使用上述的基于特征的或微调I的方法。但假设我们想获得类似于微调II的建模质量呢?

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

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

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

最近引起轰动的一种PEFT技术是LLaMA-Adapter,这是为Meta的流行的LLaMA模型提出的,然而,虽然LLaMA-Adapter是在LLaMA的背景下提出的,但这个想法是不针对特定模型的。

为了理解LLaMA-Adapter是如何工作的,我们必须回顾一下两种相关的技术,称为前缀调整和适配器 —— LLaMA-Adapter结合并扩展了这两种想法。

所以,在本文的剩余部分,我们将讨论各种提示修改的概念,以理解前缀调整和适配器方法,然后我们将更仔细地看一下LLaMA-Adapter。

提示调整和前缀调整

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

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

现在,上图所示的概念被称为硬提示调整,因为我们直接改变的是不可微的离散输入令牌。

与硬提示调整相反,软提示调整将输入令牌的嵌入与可通过反向传播优化以改善目标任务建模性能的可训练张量连接起来。

提示调整的一种特定形式是前缀调整。前缀调整的想法是将一个可训练的张量添加到每个变压器块,而不仅仅是输入嵌入,如在软提示调整中。下图说明了常规变压器块和带前缀的变压器块之间的区别。

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

请注意,上图中的"全连接层"是指一个小的多层感知器(两个全连接层之间有一个非线性激活函数)。这些全连接层将软提示嵌入到与变压器块输入相同维度的特征空间中,以确保连接的兼容性。 使用(Python)伪代码,我们可以说明常规变压器块和带前缀的变压器块之间的区别:

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

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

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

适配器

原始的适配器方法与前述的前缀调整有些相关,因为它们也在每个变压器块中添加了额外的参数。然而,适配器方法并没有在输入嵌入之前添加前缀,而是在两个地方添加了适配器层,如下图所示。

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

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

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

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

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

现在,问题是适配器方法与前缀调整的比较如何。基于原始前缀调整论文,当调整模型参数总数的0.1%时,适配器方法的表现稍逊于前缀调整方法。然而,当使用适配器方法调整模型参数的3%时,该方法与调整模型参数0.1%的前缀调整相持平。所以,我们可能会得出结论,前缀调整方法是这两种方法中更有效的一种。

扩展前缀调整和适配器:LLaMA-Adapter

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

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

此外,LLaMA-Adapter引入了一个零初始化的注意机制,与之配合的是门控机制。这种所谓的零初始化注意力和门控的动机是,适配器和前缀调整可能会通过引入随机初始化的张量(前缀提示或适配器层)打乱预训练LLM的语言知识,导致微调不稳定和初期训练阶段的损失值较高。

与前缀调整和原始适配器方法相比,另一个区别是LLaMA-Adapter只向最顶层的变压器层添加可学习的适应提示,而不是所有的变压器层。作者们认为,这种方法可以更有效地调整聚焦于更高级语义信息的语言表示。

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

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

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

【效率】大模型高效微调:从前缀调优到LLaMA-适配器

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

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

如果你想查看LLaMA-Adapter方法,你可以在这里找到基于GPL许可的LLaMA代码的原始实现。

另外,如果你的使用场景与GPL许可不兼容,GPL许可要求你将所有衍生作品在类似的许可下开源,可以查看Lit-LLaMA GitHub仓库。Lit-LLaMA是在Apache许可的nanoGPT代码上的LLaMA的可读实现实,这个许可证有更少的限制性条款。

具体来说,如果你对使用LLaMA-Adapter方法微调LLaMA模型感兴趣,你可以从Lit-LLaMA GitHub仓库运行以下脚本:

python finetune_adapter.py           

(仓库地址见文末)

总结

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

然而,由于LLMs是“大型”的,更新Transformer模型中的多个层次可能非常昂贵,因此研究人员开始开发参数高效的替代方案。

在这篇文章中,我们讨论了几种常规LLM微调机制的参数高效替代方案。特别是,我们讨论了通过前缀调整添加可调整的软提示和插入额外的适配器层。

最后,我们讨论了最近非常流行的LLaMA-Adapter方法,该方法通过前缀调整添加可调整的软提示,并引入额外的门控机制来稳定训练。

如果你想在实践中试试这个,可以查看:https://github.com/Lightning-AI/lit-llama