大模型训练——peft与lora介绍
0. 简介
朋友们好,我是练习nlp两年半的算法工程师常鸿宇,今天介绍一下大规模模型的轻量级训练技术lora,以及相关模块peft。parameter-efficient fine-tuning (peft),是huggingface开发的一个python工具,项目地址:
https://github.com/huggingface/peft
其可以很方便地实现将普通的hf模型变成用于支持轻量级fine-tune的模型,使用非常便捷,目前支持4种策略,分别是:
- lora: lora: low-rank adaptation of large language models
- prefix tuning: prefix-tuning: optimizing continuous prompts for generation, p-tuning v2: prompt tuning can be comparable to fine-tuning universally across scales and tasks
- p-tuning: gpt understands, too
- prompt tuning: the power of scale for parameter-efficient prompt tuning
今天要介绍的,是其中之一,也是最近比较热门的lora (low-rank adaptation of large language models)。
1. lora原理介绍
lora的论文写的比较难读懂,但是其原理其实并不复杂。简单理解一下,就是在模型的linear层,的旁边,增加一个“旁支”,这个“旁支”的作用,就是代替原有的参数矩阵w进行训练。
结合上图,我们来直观地理解一下这个过程,输入
x
x
x,具有维度
d
d
d,举个例子,在普通的transformer模型中,这个
x
x
x可能是embedding的输出,也有可能是上一层transformer layer的输出,而
d
d
d一般就是768或者1024。按照原本的路线,它应该只走左边的部分,也就是原有的模型部分。
而在lora的策略下,增加了右侧的“旁支”,也就是先用一个linear层a,将数据从 d d d维降到 r r r,这个 r r r也就是lora的秩,是lora中最重要的一个超参数。一般会远远小于 d d d,尤其是对于现在的大模型, d d d已经不止是768或者1024,例如llama-7b,每一层transformer有32个head,这样一来 d d d就达到了4096.
接着再用第二个linear层b,将数据从 r r r变回 d d d维。最后再将左右两部分的结果相加融合,就得到了输出的hidden_state。
对于左右两个部分,右侧看起来像是左侧原有矩阵 w w w的分解,将参数量从 d ∗ d d*d d∗d变成了 d ∗ r + d ∗ r d*r+d*r d∗r+d∗r,在 r < < d r<<d r<<d的情况下,参数量就大大地降低了。熟悉各类预训练模型的同学可能会发现,这个思想其实与albert的思想有异曲同工之处,在albert中,作者通过两个策略降低了训练的参数量,其一是embedding矩阵分解,其二是跨层参数共享。
在albert中,作者考虑到词表的维度很大,所以将embedding矩阵分解成两个相对较小的矩阵,用来模拟embedding矩阵的效果,这样一来需要训练的参数量就减少了很多。
lora也是类似的思想,并且它不再局限于embedding层,而是所有出现大矩阵的地方,理论上都可以用到这样的分解。
但是与albert不同的是,albert直接用两个小矩阵替换了原来的大矩阵,而lora保留了原来的矩阵w,但是不让w参与训练,所以需要计算梯度的部分就只剩下旁支的a和b两个小矩阵。
从论文中的公式来看,在加入lora之前,模型训练的优化表示为:
max
φ
∑
(
x
,
y
)
∈
z
∑
t
=
1
∣
y
∣
log
(
p
φ
(
y
t
∣
x
,
y
<
t
)
)
\max \limits_{\phi}\sum_{\left(x, y\right) \in z} \sum_{t=1}^{\vert y \vert} \log \left(p_{\phi} \left ( y_t \vert x, y_{<t}\right)\right)
φmax(x,y)∈z∑t=1∑∣y∣log(pφ(yt∣x,y<t))
其中,模型的参数用
φ
\phi
φ表示。
而加入了lora之后,模型的优化表示为:
max
θ
∑
(
x
,
y
)
∈
z
∑
t
=
1
∣
y
∣
log
(
p
φ
0
+
δ
φ
(
θ
)
(
y
t
∣
x
,
y
<
t
)
)
\max \limits_{\theta}\sum_{\left(x, y\right) \in z} \sum_{t=1}^{\vert y \vert} \log \left(p_{\phi_0+\delta\phi \left( \theta\right)} \left ( y_t \vert x, y_{<t}\right)\right)
θmax(x,y)∈z∑t=1∑∣y∣log(pφ0+δφ(θ)(yt∣x,y<t))
其中,模型原有的参数是 φ 0 \phi_0 φ0,lora新增的参数是 δ φ ( θ ) \delta \phi\left(\theta\right) δφ(θ)。
从第二个式子可以看到,尽管参数看起来增加了(多了 δ φ ( θ ) \delta \phi\left(\theta\right) δφ(θ)),但是从前面的max的目标来看,需要优化的参数只有 θ \theta θ,而根据假设, θ < < φ \theta <<\phi θ<<φ,这就使得训练过程中,梯度计算量少了很多,所以就在低资源的情况下,我们可以只消耗 θ \theta θ这部分的资源,这样一来就可以在单卡低显存的情况下训练大模型了。
但是相应地,引入lora部分的参数,并不会在推理阶段加速,因为在前向计算的时候, φ \phi φ部分还是需要参与计算的,而 θ \theta θ部分是凭空增加了的参数,所以理论上,推理阶段应该比原来的计算量增大一点。
2. 补充资料:低显存学习方法
在介绍代码之前,在这里补充一些低显存学习方法的介绍。参考苏剑林老师的博客:ladder side-tuning:预训练模型的“过墙梯”。其中主要介绍了一篇2022年的论文:《lst: ladder side-tuning for
parameter and memory efficient transfer learning》,其中对低显存消耗的训练方法进行了综合地介绍,包括lora。
论文地址:https://arxiv.org/pdf/2206.06522.pdf
这里借用此文中的配图,来说明一下,在lora之前的常见的memory efficient transfer learning方法。
在上图中,非常形象地展示了三种transfer learning的策略。
在普通的adapter中,在各层backbone(蓝色)之间,加入了相对较小的训练参数(绿色),以此来通过调整绿色部分,减少训练参数。然而在这种策略下,缺乏梯度的直接通路(红色虚线),在反向传播中,需要经过所有蓝色的部分。并且,这种结构在并行上也会存在一些困难。
而在prompt tuning中,也存在一些固有的缺陷,它同样缺少梯度的直接通路,每次都需要经过所有的backbone部分。而且,prompt tuning的任务设置过于理想,试图只调节输入端的小部分参数,对深层部分的影响是相当有限的,这就会造成最终fine-tune的效果受到局限。
由于lst不是本文的重点,所以只借助这个示意图来对lora策略进行说明。而实际上,lst可以看做是在lora的基础上做出的进一步改进,感兴趣的同学可以阅读原文。
lst与lora类似,在原有参数矩阵的一侧增加了一个旁支通路,但是二者有些许区别:
- lora是将上一步的输入,在分支的时候,分别经过原有参数(类似于图中蓝色部分),以及旁支的通路(绿色可训练参数),二者之间是类似平等的,然后再将结果相加,作为下一层的输入;
- lst是在将输入先经过原有参数,再与输入本身相加,一起送入旁支通路。
根据lst的论文,其效果是优于lora的,但是它毕竟不是本文的主角,所以对其原理细节就不做过多的介绍了。
3. peft对lora的实现
接下来是代码部分,我们以hf的peft(当前版本0.2.0)为例,介绍一下lora是如何作用在hf模型上的。
以lora为例,peft模型的使用非常方便,只需要按照原本的方式实例化模型,然后设置一下lora的config,调用一下get_peft_model
方法,就获得了在原模型基础上的peft模型,对于lora策略来讲,就是在某些参数矩阵w的基础上增加了矩阵分解的旁支。在下面的例子中,选择了attention中的q和v的部分做lora。
# 设置超参数及配置
lora_r = 8
lora_alpha = 16
lora_dropout = 0.05
target_modules = [
"q_proj",
"v_proj",
]
config = loraconfig(
r=lora_r,
lora_alpha=lora_alpha,
target_modules=target_modules,
lora_dropout=lora_dropout,
bias="none",
task_type="causal_lm",
)
# 创建基础transformer模型
model = automodelforseq2seqlm.from_pretrained(model_name_or_path)
# 加入peft策略
model = get_peft_model(model, config)
简单介绍一下lora config相关的配置:
参数名 | 含义 |
---|---|
r | lora的秩,矩阵a和矩阵b相连接的宽度,r<<d |
lora_alpha | 归一化超参数,lora参数 δ w x \delta wx δwx会被以 α r \frac \alpha r rα归一化,以便减少改变 r r r时需要重新训练的计算量 |
lora_dropout | lora层的dropout比率 |
merge_weights | eval模式中,是否将lora矩阵的值加到原有 w 0 w_0 w0的值上 |
fan_in_fan_out | 只有应用在conv1d层时置为true,其他情况false |
bias | 是否可训练bias,none:均不可;all:均可;lora_only:只有lora部分的bias可训练 |
modules_to_save | 除了lora部分之外,还有哪些层可以被训练,并且需要保存 |
接下来,结合peft模块的源码,来看一下lora是如何实现的。
在peft模块中,peft_model.py中的peftmodel类是一个总控类,用于模型的读取保存等功能,继承了transformers中的mixin类,我们主要来看lora的实现:
代码位置:https://github.com/huggingface/peft/blob/main/src/peft/tuners/lora.py
class loramodel(torch.nn.module):
def __init__(self, config, model):
super().__init__()
self.peft_config = config
self.model = model
self._find_and_replace()
mark_only_lora_as_trainable(self.model, self.peft_config.bias)
self.forward = self.model.forward
从构造方法可以看出,这个类在创建的时候主要做了两件事:
- _find_and_replace: 找到所有需要加入lora策略的层,例如q_proj,把它们替换成lora模式;
- 保留lora部分的参数可训练,其余参数全都固定下来不动。
_find_and_replace
的逻辑很清晰,就是先找到需要的做lora的层,然后创建lora层把它替换掉。这里把关键语句列出如下:
找目标层:
# 其中的target_modules在上面的例子中就是"q_proj","v_proj"
# 这一步就是找到模型的各个组件中,名字里带"q_proj","v_proj"的
target_module_found = re.fullmatch(self.peft_config.target_modules, key)
然后对于每一个找到的目标层,创建一个新的lora层:
# 注意这里的linear是在该py中新建的类,不是torch的linear
new_module = linear(target.in_features, target.out_features, bias=bias, **kwargs)
最后调用_replace_module
方法替换掉原来的linear:
self._replace_module(parent, target_name, new_module, target)
其中这个replace的方法并不复杂,就是把原来的weight和bias赋给新创建的module,然后再分配到指定的设备上:
def _replace_module(self, parent_module, child_name, new_module, old_module):
setattr(parent_module, child_name, new_module)
new_module.weight = old_module.weight
if old_module.bias is not none:
new_module.bias = old_module.bias
if getattr(old_module, "state", none) is not none:
new_module.state = old_module.state
new_module.to(old_module.weight.device)
# dispatch to correct device
for name, module in new_module.named_modules():
if "lora_" in name:
module.to(old_module.weight.device)
接下来主要看一下lora层的实现,首先是lora的基类,可以看出这个类就是用来构造lora的各种超参数用:
class loralayer:
def __init__(
self,
r: int,
lora_alpha: int,
lora_dropout: float,
merge_weights: bool,
):
self.r = r
self.lora_alpha = lora_alpha
# optional dropout
if lora_dropout > 0.0:
self.lora_dropout = nn.dropout(p=lora_dropout)
else:
self.lora_dropout = lambda x: x
# mark the weight as unmerged
self.merged = false
self.merge_weights = merge_weights
self.disable_adapters = false
然后就要讲到上文中所提到的linear
类,也就是lora的具体实现,它同时继承了nn.linear和loralayer。
class linear(nn.linear, loralayer):
# lora implemented in a dense layer
def __init__(
self,
in_features: int,
out_features: int,
r: int = 0,
lora_alpha: int = 1,
lora_dropout: float = 0.0,
fan_in_fan_out: bool = false, # set this to true if the layer to replace stores weight like (fan_in, fan_out)
merge_weights: bool = true,
**kwargs,
):
nn.linear.__init__(self, in_features, out_features, **kwargs)
loralayer.__init__(self, r=r, lora_alpha=lora_alpha, lora_dropout=lora_dropout, merge_weights=merge_weights)
self.fan_in_fan_out = fan_in_fan_out
# actual trainable parameters
if r > 0:
self.lora_a = nn.linear(in_features, r, bias=false)
self.lora_b = nn.linear(r, out_features, bias=false)
self.scaling = self.lora_alpha / self.r
# freezing the pre-trained weight matrix
self.weight.requires_grad = false
self.reset_parameters()
if fan_in_fan_out:
self.weight.data = self.weight.data.t
在构造方法中,除了对各个超参数进行配置之外,还对所有参数进行了初始化,定义如下:
def reset_parameters(self):
nn.linear.reset_parameters(self)
if hasattr(self, "lora_a"):
# initialize a the same way as the default for nn.linear and b to zero
nn.init.kaiming_uniform_(self.lora_a.weight, a=math.sqrt(5))
nn.init.zeros_(self.lora_b.weight)
其中lora的a矩阵采用了kaiming初始化,是xavier初始化针对非线性激活函数的一种优化;b矩阵采用了零初始化,以确保在初始状态 δ w = b a \delta w =ba δw=ba为零。(值得注意的是在lora的论文中,a采用的是gaussian初始化)。
对于train和eval方法,放在一起介绍,它主要是需要对merge状态进行记录:
def train(self, mode: bool = true):
nn.linear.train(self, mode)
self.lora_a.train(mode)
self.lora_b.train(mode)
if not mode and self.merge_weights and not self.merged:
# merge the weights and mark it
if self.r > 0:
self.weight.data += (
transpose(self.lora_b.weight @ self.lora_a.weight, self.fan_in_fan_out) * self.scaling
)
self.merged = true
elif self.merge_weights and self.merged:
# make sure that the weights are not merged
if self.r > 0:
self.weight.data -= (
transpose(self.lora_b.weight @ self.lora_a.weight, self.fan_in_fan_out) * self.scaling
)
self.merged = false
def eval(self):
nn.linear.eval(self)
self.lora_a.eval()
self.lora_b.eval()
首先对于新定义的这个linear层,其本身继承了torch.nn.linear,所以需要调用nn.linear.train(self, mode)
来控制一下自身原本参数的状态,并且此外它加入了lora_a和lora_b两部分额外的参数,这两部分本质上也是nn.linear,也需要控制状态。
然后主要来理解一下merge_weights是在做什么,也就是看train中的if分支,not mode
说明是eval模式,而self.merge_weights
在上文中有介绍,是配置文件中的,意思是评估时是否需要将lora部分的weight加到linear层原本的weight中,not self.merged
是状态的记录,也就是说,如果设置了需要融合,而当前状态没有融合的话,就把lora部分的参数scale之后加上去,并且更新self.merged状态;在elif分支中,是为了在训练的过程中,确保linear本身的weights是没有经过融合过的(理论上这一步应该是在eval之后的下一轮train的第一个step触发)。
至于为什么是在train中涉及merge_weights,其实在torch的源码中,nn.linear.eval()
实际上是调用了nn.linear.train(mode=false)
,所以这里train方法中的merge_weigths,实际上是在eval中也发挥作用的。
forward中也是类似的原理,正常情况下训练过程应该是走elif的分支:
def forward(self, x: torch.tensor):
if self.disable_adapters:
if self.r > 0 and self.merged:
self.weight.data -= (
transpose(self.lora_b.weight @ self.lora_a.weight, self.fan_in_fan_out) * self.scaling
)
self.merged = false
return f.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)
elif self.r > 0 and not self.merged:
result = f.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)
if self.r > 0:
result += self.lora_b(self.lora_a(self.lora_dropout(x))) * self.scaling
return result
else:
return f.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)
在了解了这些基本原理之后,就可以类似地去实现更多更加灵活的功能了,例如对transformer的某些层增加lora,而其余的层保持不变等。
以上就是关于lora的代码实现介绍,在实际的peft模块中,还包含了更多更详细完备的设置,本文只是对基本原理和过程进行了介绍,其中包含了部分个人理解,如果错误,还请指出。如果本文对你的学习和工作有所帮助,记得留下一个免费的赞,我们下期再见。
发表评论