深入了解LoRA适配器
LoRA适配器深入了解
探索参数高效微调(PEFT):直观理解使用LoRA的微调
大型语言模型(LLMs)席卷了世界。在过去的一年里,我们目睹了它们能够做到的巨大飞跃,从相当狭窄和受限的应用到现在能够进行流利的多轮对话。
这些模型从抽取式摘要(逐字复制源代码)转变为提供抽象摘要,真是令人惊叹。它们现在完全改写摘要以符合读者的风格偏好和读者的现有知识。更令人惊讶的是,这些新模型不仅可以生成新代码,还能解释您现有的代码。令人着迷。
通常情况下,这些大型模型非常强大,甚至在零样本或少样本方式下也能产生令人印象深刻的结果。虽然这样可以进行快速实验并立即看到结果,但对于许多任务来说,通常需要对模型进行微调以达到最佳性能和效率。然而,微调它们数十亿个参数中的每一个变得不切实际且低效。此外,鉴于模型的大小,我们是否有足够的标记数据来训练这样一个庞大的模型而不过拟合?
参数高效微调(PEFT)来帮助解决这个问题:您现在可以在只微调一小部分权重的情况下实现出色的性能。不需要在多台机器上微调数十亿个参数,使整个微调过程变得更加实用和经济可行。使用PEFT和量化技术,可以在单个GPU上微调具有数十亿个参数的大型模型。
这个迷你系列适用于有经验的机器学习从业者,他们想要探索PEFT和特定的LoRA [2]:
- 在第一篇文章中,我们探索了参数高效微调(PEFT)的动机。我们回顾了为什么以及如何进行微调的工作原理,我们可以保留哪些现有实践的方面,并以更精细的方式进行推广和应用。我们将从零开始实施必要的内容,以创建直观的理解,并展示我们选择探索的方法LoRA的简洁性。
- 在第二篇文章中,我们将深入研究寻找良好超参数值的过程,即我们将回顾在应用LoRA时的相关设计决策。在此过程中,我们将建立性能比较的基线,然后回顾可以使用LoRA进行适应的组件,以及它们的影响以及如何适当地调整它们的大小。
- 基于单个任务的训练和调整模型,在第三篇文章中,我们将扩展我们的视野以调整多个任务。此外,部署方面有什么要考虑的?我们如何使用我们为单个任务训练的相对小的适配器并实现热切换机制,以便在一个单一模型端点上为多个任务进行推理。
- 在前三篇文章的过程中,我们对使用PEFT进行训练、调整和部署有了直观的理解。进入第四篇文章,我们将变得非常实际。我们将远离我们的教育模型,问自己“到目前为止我们学到了什么,我们如何将其应用于实际场景?”然后,我们将使用Hugging Face的已建立实现来实现我们的目标。这将包括使用QLoRA,将LoRA和量化技术相结合,以实现高效的GPU内存使用。
准备好了吗?今天,让我们从为什么所有这些都能起作用开始。
关于预训练和微调的有效性
在他们的工作中,Aghajanyan等人[1]展示了有关神经网络层在预训练期间如何发生变化,从而更容易进行微调的两个有趣观察。这是广泛适用的,不仅适用于特定的微调任务。
具体来说,他们表明预训练减少了表示的内在维度(ID)。以下两个图表—摘自他们的工作—说明了这种效果:


作者并没有微调所有参数,而是使用较小的、随机选择的参数子集来训练相应的模型。参数的数量被选择为与完全微调模型性能的90%相匹配。图中的两个y轴上的维度,用于实现90%性能,被表示为d90
。
第一个图显示,随着预训练持续时间的增加(x轴),d90
减少,即在后续微调中需要的参数数量减少,以实现完全微调性能的90%。这本身表明了预训练作为一种压缩知识的方式的有效性。
在第二个图中,我们还可以看到,随着容量的增加,在微调模型中实现d90
所需的参数数量也减少。非常有趣。这表明更大的模型可以学习更好的训练数据表示——模型所见的世界,并创建易于在任何下游任务中使用的分层特征。
作者指出的一个具体示例是,RoBERTa Large(354M)的d90
约为207个参数。太棒了!请在上面的图表中找到该示例,然后再检查一下较小的RoBERTa Base(123M)需要更多参数才能达到90%的性能,这里是896个。有趣。
从我对这个话题的讨论中,我了解到有一些值得明确指出的事情:
- 我们利用了微调期间的内在维度效应,但上面的图表和数字都是关于预训练的。我们只是使用微调的数字来使结果的下游影响更具体。
- 使用较大的模型不仅相对于其大小减少了内在维度,而且也绝对减少了内在维度。当我们转向PEFT时,我们将看到类似的效果。
在[1]中,您将找到上述插图作为图2、图3,引用的结果取自表1。
总之,我们可以看到,在预训练期间学到的表示压缩了模型学到的知识,并使使用这些更语义化的表示来微调下游模型变得更容易。我们将在PEFT中进一步构建。只是,我们将不再随机选择要调整的参数,并且目标是几乎匹配完全微调的性能。令人兴奋!
调整什么?
我们已经确定我们可以使用非常少量的参数。但是哪些参数?在模型中的哪个位置?在下一篇文章中,我们将详细讨论这个问题。但是为了启动我们的思考和框定我们的问题,现在让我们先思考两种一般方法:

基于任务:在使用微调时,我们希望保留预训练的知识,避免“灾难性遗忘”。我们认识到,下游任务特定的学习应该发生在微调模型的任务头部,即分类器,以及头部以下的直接层(绿色表示),而在较低层和嵌入中,我们希望保留我们对语言使用的一般知识(红色表示)。通常我们使用逐层学习率来引导模型,甚至完全冻结较低层。
这全部都基于我们对模型在下游任务中学习基本知识的理解,以及对预训练中现有知识应该保留的位置的理解。

基于架构:相反,我们还可以回顾一下架构的组件、它们的参数和可能的影响。在上面的插图中,你可以看到例如LayerNorm和Biases,它们的容量较低,但遍布在整个模型中。它们处于影响模型的中心位置,但参数相对较少。
另一方面,我们有来自嵌入的参数。它们与任务不接近,但与输入接近。我们在嵌入中有很多参数。因此,如果我们想要高效率,它们不会是我们进行任何类型的微调,包括PEFT的首选。
最后但并非最不重要的是,我们有随Transformer架构一起提供的大型线性模块,即attention vectors和feed forward layers。这些模块拥有大量的参数,我们可以决定在哪一层对它们进行调整。
我们将在下一篇文章中详细介绍如何选择正确的参数。对于本文来说,无论我们如何切分和解决问题,我们最终都会得到一些我们想要调整的参数组。在本文的其余部分,这些将是一些线性模块。
使用适配器提高效率
我们不想调整一个完整的线性模块及其所有参数,而是希望更加高效。我们采用的方法是注入适配器。这些新模块相对较小,并且将放置在我们想要调整的模块之后。适配器可以修改线性模块的输出,即它们可以以对下游任务有益的方式优化预训练输出。

但是这种方法存在一个问题。你能发现它吗?它涉及到要适应的模块和适配器的相对大小。如果你看下面的插图,你会看到GPU的内存。为了提高效率,我们调整模型的大小,使其尽可能紧密地适应可用的GPU内存。这在Transformer架构中特别容易实现,因为每个层的宽度都相同,甚至下投影的头部也会再次达到完整的宽度。因此,我们可以根据Transformer组件的统一宽度选择批处理大小。
但是如果我们现在在更大的线性层后面注入非常小的适配器,我们就会遇到问题。我们的内存使用变得低效,如下图所示。
批处理大小适合线性层的宽度,但现在我们有一个更小的适配器。因此,大部分GPU必须等待执行小适配器。这降低了GPU的利用率。而且这比插图中看起来的更糟糕,要记住图表中适配器的面积应该约为1%,而在插图中看起来更接近20%。

解决这个问题的一种方法是并行适应,只需用加法将它们连接起来,使两个路径都能对输出做出贡献。这样我们就不再有内存瓶颈,可以并行执行原始线性模块和适配器,避免了之前看到的间隙。

但是,即使并行执行也比根本没有适配器要增加负担。这对于训练来说是正确的,但对于推理来说也是正确的。这并不理想。另外,这样的适配器应该有多大?

我们将在第三篇文章中解决推理过程中的低效率问题。小窥一下:一切都会好的——我们将把模块的权重与低秩矩阵的乘积相结合。回到这篇文章,让我们解决适配器大小的问题。
低秩矩阵作为适配器
让我们放大一下。
下面,您可以看到左侧的原始线性模块是灰色的,右侧的适配器是橙色的。为了使它们兼容,输入和输出必须匹配,这样我们就可以使用相同的输入并行调用它们,然后将输出相加,类似于使用残差连接。因此,两侧的输入和输出维度必须匹配。

线性模块和适配器可以转化为两个矩阵。由于它们的匹配维度,从机械上讲,我们现在具有了兼容性。但是由于适配器与我们正在适配的模块一样大,我们并没有变得更高效。我们需要一个既小又兼容的适配器。
两个低秩矩阵的乘积符合我们的要求:

大矩阵被分解为两个低秩矩阵。但是矩阵本身要小得多,d_in x r
和r x d_out
,尤其是r
远小于d_in
和d_out
。我们通常考虑诸如1、2、4、16之类的数字作为r
,而d_in
和d_out
则像768、1024、3072、4096这样。
让我们把这些都放在一起:

我们可以看到我们有一个单一的x
作为输入。然后,x
与原始权重W0
相乘。 W0
是预训练的权重。并且x
与A
和B
相乘,最终两个结果相加并形成调整后的输出,这里称为x'
。
有不同的适配器实现方法,但在LoRA中,我们将其作为优化问题,并为特定的下游任务学习了两个低秩矩阵A
和B
。学习这些较少的参数比学习W0
中的所有参数更高效。
初始化
让我们稍微偏离一下。您将如何初始化A
和B
?如果您随机初始化它们,请考虑在训练开始时会发生什么?
在每次前向传递中,我们会在适配模块的输出上添加随机噪声,我们必须等待优化器逐步纠正错误的初始化,这会导致微调开始时的不稳定性。
为了缓解这个问题,我们通常使用较低的学习率、较小的初始化值或者热身阶段来限制这些错误参数的影响,以避免过度不稳定化权重。在LLAMA适配器[3]的论文中,作者引入了零门控:他们从0开始,逐渐增加适配器门的值(与实际权重相乘),并在训练过程中逐步增加。
另一种方法是将A
和B
初始化为0。但这样一来,你将无法打破对称性,在学习过程中所有参数可能会被视为一个参数。
而LoRA实际上是非常优雅的。一个矩阵A
是随机初始化的,而另一个矩阵B
则初始化为0。因此,两个矩阵的乘积为0,但在反向传播过程中仍然可以单独区分每个参数。从0开始意味着归纳偏差是什么都不做,除非改变权重会导致损失减少。因此,在训练开始时不会出现不稳定性。很棒!

代码中的实现是什么样的?
让我们来看一下我们小型示例的一些代码片段。你可以在附带的笔记本中找到完整的代码,我们在接下来的文章中使用的更完整的实现也在同一个存储库中。
让我们从如何设置适配器开始。我们传入一个要适配的模块的引用,我们现在称之为adaptee
。我们存储对其原始forward
方法的引用,并将adaptee
的forward
方法指向适配器的forward
方法的实现。
class LoRAAdapter(nn.Module): def __init__(self, adaptee, # <- 要适配的模块 r): super().__init__() self.r = r self.adaptee = adaptee # 存储对要适配的模块的原始forward实现的指针 # 然后将其forward方法指向这个适配器模块 self.orig_forward = adaptee.forward adaptee.forward = self.forward [..]
现在我们已经设置好了整合的机制,我们还要初始化我们的低秩矩阵的参数。请注意,我们用0初始化一个矩阵,另一个矩阵随机初始化:
[..] # 将权重矩阵直接添加到adaptee中, # 这样更方便报告参数, # 并在以后删除它。 adaptee.lora_A = (nn.Parameter(torch.randn(adaptee.in_features, r)/ math.sqrt(adaptee.in_features))) adaptee.lora_B = nn.Parameter(torch.zeros(r, adaptee.out_features))
最后,作为LoRAAdapter
类的一部分,我们有我们的forward
方法,它首先使用我们的输入x
调用adaptee
的forward
方法。这是在原始模块中执行的原始路径。但是,我们还将该结果与我们适配的分支的结果相加,我们在这个分支中将输入x
与A
和B
进行矩阵乘法。
def forward(self, x, *args, **kwargs): return ( self.orig_forward(x, *args, **kwargs) + x @ self.adaptee.lora_A @ self.adaptee.lora_B )
这种简洁的实现方式非常优雅。
还有更多可能感兴趣的细节,但最好是在代码的陪伴下进行解释。你可以在附带的笔记本中找到这些内容:
- 如何首先冻结整个模型
- 然后如何解冻分类器。因为它是特定于我们的下游任务,并且我们完全训练它。
- 如何添加适配器;它们都是活动的,没有被冻结。
- 回顾模块的矩阵维度与两个低秩矩阵
A
和B
之间的关系。 - 使用较小值
r
时,参数数量减少了多少?
以下是一个小节的摘录,显示了原始模块output.dense
的参数不会被训练(标记为0
),但其LoRA矩阵是可训练的(标记为1
),当然,模型的整体分类器也是可训练的(用1
标记):
[..]roberta.encoder.layer.11.attention.output.LayerNorm.bias 0 768roberta.encoder.layer.11.intermediate.dense.weight 0 2359296roberta.encoder.layer.11.intermediate.dense.bias 0 3072roberta.encoder.layer.11.output.dense.weight 0 2359296roberta.encoder.layer.11.output.dense.bias 0 768roberta.encoder.layer.11.output.dense.lora_A 1 12288roberta.encoder.layer.11.output.dense.lora_B 1 3072roberta.encoder.layer.11.output.LayerNorm.weight 0 768roberta.encoder.layer.11.output.LayerNorm.bias 0 768classifier.dense.weight 1 589824classifier.dense.bias 1 768classifier.out_proj.weight 1 1536classifier.out_proj.bias 1 2[..]总参数数目:12,497,8946,其中可训练的参数数目为:923,906(0.7392%)
请查看笔记本获取更多信息。
试一试?
此外,您将在笔记本中看到一些测试,以展示整个设置是如何机械地工作的。
然后,我们运行第一个实验并将训练作业提交给SageMaker。我们对原始模型进行了完全微调,然后进行了启用了LoRA的训练,如此处所述。
为了我们的测试,我们使用r
=2,在所有层中调整query
和output
参数,对sst-2数据集[5]上的RoBERTa Large [4]进行完全微调和LoRA微调。我们使用5e-5
和4e-4
作为完全微调和LoRA微调的学习率。
这是结果(在笔记本中查看更多):
完全微调准确率:0.944LoRA微调准确率:0.933
那么这是…很好,不太好吗?这是什么?首先,它清楚地显示整个设置在机械层面上是有效的,这很好。而且超过90%的准确率表明它表现良好。
但是表现得有多好?我们将这些数字与什么进行比较?这两个单独的训练运行有多具有代表性?我们只是幸运还是不幸运?LoRA的数字比传统方法更好?这不奇怪吗?我们对传统方法进行了多好的调整?
上述所有结果都不可靠。我们不知道在第二次运行中使用我们的超参数是否会产生类似的结果。此外,我们使用了根据半教育猜测选择的超参数。
当然,有一个更好的方法。因此,在下一篇文章中,我们将应用更严谨的方法来选择超参数,并将更系统地评估性能:
- 建立比较的基线
- 为基线和实验搜索良好的超参数
- 最重要的是:通过数据驱动的方式加深我们对LoRA方法和设计决策影响的理解,使我们的直觉更加一致
在那之前,希望您在阅读本文章时玩得开心。
特别感谢Constantin Gonzalez、Ümit Yoldas、Valerio Perrone和Elina Lesyk在撰写本文期间提供的宝贵反馈。
除非另有说明,所有图片均为作者所拍。
[1] Armen Aghajanyan, Luke Zettlemoyer, Sonal Gupta. Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning, 2020
[2] Edward J. Hu, Yelong Shen, Phillip Wallis, Zeyuan Allen-Zhu, Yuanzhi Li, Shean Wang, Lu Wang, Weizhu Chen. LoRA: Low-Rank Adaptation of Large Language Models, 2021
[3] Renrui Zhang, Jiaming Han, Chris Liu, Peng Gao, Aojun Zhou, Xiangfei Hu, Shilin Yan, Pan Lu, Hongsheng Li, Yu Qiao. LLaMA-Adapter: Efficient Fine-tuning of Language Models with Zero-init Attention, 2023
[4] 刘银涵,Myle Ott,Naman Goyal,杜静飞,Mandar Joshi,Danqi Chen,Omer Levy,Mike Lewis,Luke Zettlemoyer,Veselin Stoyanov。RoBERTa:一种经过强化优化的BERT预训练方法,2019
[5] Richard Socher,Alex Perelygin,Jean Wu,Jason Chuang,Christopher D. Manning,Andrew Ng和Christopher Potts。递归深度模型用于情感树库的语义组合性,2013