Files
happy-llm/docs/chapter6/6.3 高效微调.md
Logan Zou 84c0a2d875 finish C6
2025-04-26 21:30:48 +08:00

14 KiB
Raw Blame History

6.4 高效微调

在前面几节,我们详细介绍了基于 transformers 框架对模型进行 Pretrain、SFT 以及 RLHF 的原理和实践细节。但是,由于 LLM 参数量大,训练数据多,通过上述方式对模型进行训练(主要指 SFT 及 RLHF需要调整模型全部参数资源压力非常大。对资源有限的企业或课题组来说如何高效、快速对模型进行领域或任务的微调以低成本地使用 LLM 完成目标任务,是非常重要的。

6.4.1 高效微调方案

针对全量微调的昂贵问题,目前主要有两种解决方案:

Adapt Tuning。即在模型中添加 Adapter 层,在微调时冻结原参数,仅更新 Adapter 层。

具体而言,其在预训练模型每层中插入用于下游任务的参数,即 Adapter 模块,在微调时冻结模型主体,仅训练特定于任务的参数。

Adapter

每个 Adapter 模块由两个前馈子层组成,第一个前馈子层将 Transformer 块的输出作为输入,将原始输入维度 d 投影到 $m$,通过控制 m 的大小来限制 Adapter 模块的参数量,通常情况下 $m << d$。在输出阶段,通过第二个前馈子层还原输入维度,将 m 重新投影到 $d$,作为 Adapter 模块的输出(如上图右侧结构)。

LoRA 事实上就是一种改进的 Adapt Tuning 方法。但 Adapt Tuning 方法存在推理延迟问题,由于增加了额外参数和额外计算量,导致微调之后的模型计算速度相较原预训练模型更慢。

Prefix Tuning。该种方法固定预训练 LM为 LM 添加可训练,任务特定的前缀,这样就可以为不同任务保存不同的前缀,微调成本也小。具体而言,在每一个输入 token 前构造一段与下游任务相关的 virtual tokens 作为 prefix在微调时只更新 prefix 部分的参数,而其他参数冻结不变。

也是目前常用的微量微调方法的 Ptuning其实就是 Prefix Tuning 的一种改进。但 Prefix Tuning 也存在固定的缺陷:模型可用序列长度减少。由于加入了 virtual tokens占用了可用序列长度因此越高的微调质量模型可用序列长度就越低。

6.4.2 LoRA 微调

如果一个大模型是将数据映射到高维空间进行处理这里假定在处理一个细分的小任务时是不需要那么复杂的大模型的可能只需要在某个子空间范围内就可以解决那么也就不需要对全量参数进行优化了我们可以定义当对某个子空间参数进行优化时能够达到全量参数优化的性能的一定水平如90%精度那么这个子空间参数矩阵的秩就可以称为对应当前待解决问题的本征秩intrinsic rank

预训练模型本身就隐式地降低了本征秩当针对特定任务进行微调后模型中权重矩阵其实具有更低的本征秩intrinsic rank。同时越简单的下游任务对应的本征秩越低。Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning)因此,权重更新的那部分参数矩阵尽管随机投影到较小的子空间,仍然可以有效的学习,可以理解为针对特定的下游任务这些权重矩阵就不要求满秩。我们可以通过优化密集层在适应过程中变化的秩分解矩阵来间接训练神经网络中的一些密集层,从而实现仅优化密集层的秩分解矩阵来达到微调效果。

例如,假设预训练参数为 $\theta^D_0$,在特定下游任务上密集层权重参数矩阵对应的本征秩为 $\theta^d$,对应特定下游任务微调参数为 $\theta^D$,那么有:

\theta^D = \theta^D_0 + \theta^d M

这个 M 即为 LoRA 优化的秩分解矩阵。

想对于其他高效微调方法LoRA 存在以下优势:

  1. 可以针对不同的下游任务构建小型 LoRA 模块,从而在共享预训练模型参数基础上有效地切换下游任务。
  2. LoRA 使用自适应优化器Adaptive Optimizer不需要计算梯度或维护大多数参数的优化器状态训练更有效、硬件门槛更低。
  3. LoRA 使用简单的线性设计,在部署时将可训练矩阵与冻结权重合并,不存在推理延迟。
  4. LoRA 与其他方法正交,可以组合。

因此LoRA 成为目前高效微调 LLM 的主流方法尤其是对于资源受限、有监督训练数据受限的情况下LoRA 微调往往会成为 LLM 微调的首选方法。

6.4.3 LoRA 微调的原理

1低秩参数化更新矩阵

LoRA 假设权重更新的过程中也有一个较低的本征秩,对于预训练的权重参数矩阵 W0 \in R^{d \times k} (d 为上一层输出维度,k 为下一层输入维度),使用低秩分解来表示其更新:

W_0 + {\Delta}W = W_0 + BA \space\space where \space B \in R^{d \times r}, A \in R^{r \times k}

在训练过程中,W_0 冻结不更新,$A$、B 包含可训练参数。

因此LoRA 的前向传递函数为:

h = W_0 x + \Delta W x = W_0 x + B A x

在开始训练时,对 A 使用随机高斯初始化,对 B 使用零初始化,然后使用 Adam 进行优化。

训练思路如图:

LoRA

###2应用于 Transformer

在 Transformer 结构中LoRA 技术主要应用在注意力模块的四个权重矩阵:$W_q$、$W_k$、$W_v$、$W_0$,而冻结 MLP 的权重矩阵。

通过消融实验发现同时调整 W_qW_v 会产生最佳结果。

在上述条件下,可训练参数个数为:

\Theta = 2 \times L_{LoRA} \times d_{model} \times r

其中,L_{LoRA} 为应用 LoRA 的权重矩阵的个数,d_{model} 为 Transformer 的输入输出维度,r 为设定的 LoRA 秩。

一般情况下r 取到 4、8、16。

6.4.4 LoRA 的代码实现

目前一般通过 peft 库来实现模型的 LoRA 微调。peft 库是 huggingface 开发的第三方库,其中封装了包括 LoRA、Adapt Tuning、P-tuning 等多种高效微调方法,可以基于此便捷地实现模型的 LoRA 微调。

本文简单解析 peft 库中的 LoRA 微调代码,简单分析 LoRA 微调的代码实现。

###1实现流程

LoRA 微调的内部实现流程主要包括以下几个步骤:

  1. 确定要使用 LoRA 的层。peft 库目前支持调用 LoRA 的层包括nn.Linear、nn.Embedding、nn.Conv2d 三种。

  2. 对每一个要使用 LoRA 的层,替换为 LoRA 层。所谓 LoRA 层,实则是在该层原结果基础上增加了一个旁路,通过低秩分解(即矩阵 A 和矩阵 $B$)来模拟参数更新。

  3. 冻结原参数,进行微调,更新 LoRA 层参数。

###2确定 LoRA 层

在进行 LoRA 微调时,首先需要确定 LoRA 微调参数,其中一个重要参数即是 target_modules。target_modules 一般是一个字符串列表,每一个字符串是需要进行 LoRA 的层名称,例如:

target_modules = ["q_proj","v_proj"]

这里的 q_proj 即为注意力机制中的 $W_q$ v_proj 即为注意力机制中的 $W_v$。我们可以根据模型架构和任务要求自定义需要进行 LoRA 操作的层。

在创建 LoRA 模型时,会获取该参数,然后在原模型中找到对应的层,该操作主要通过使用 re 对层名进行正则匹配实现:

# 找到模型的各个组件中,名字里带"q_proj""v_proj"的
target_module_found = re.fullmatch(self.peft_config.target_modules, key)
# 这里的 key是模型的组件名

###3替换 LoRA 层

对于找到的每一个目标层,会创建一个新的 LoRA 层进行替换。

LoRA 层在具体实现上,是定义了一个基于 Lora 基类的 Linear 类,该类同时继承了 nn.Linear 和 LoraLayer。LoraLayer 即是 Lora 基类,其主要构造了 LoRA 的各种超参:

class LoraLayer:
    def __init__(
        self,
        r: int, # LoRA 的秩
        lora_alpha: int, # 归一化参数
        lora_dropout: float, # LoRA 层的 dropout 比例
        merge_weights: bool, # eval 模式中,是否将 LoRA 矩阵的值加到原权重矩阵上
    ):
        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

nn.Linear 就是 Pytorch 的线性层实现。Linear 类就是具体的 LoRA 层,其主要实现如下:

class Linear(nn.Linear, LoraLayer):
    # LoRA 层
    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, 
        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:
            # 参数矩阵 A
            self.lora_A = nn.Linear(in_features, r, bias=False)
            # 参数矩阵 B
            self.lora_B = nn.Linear(r, out_features, bias=False)
            # 归一化系数
            self.scaling = self.lora_alpha / self.r
            # 冻结原参数,仅更新 A 和 B
            self.weight.requires_grad = False
        # 初始化 A 和 B
        self.reset_parameters()
        if fan_in_fan_out:
            self.weight.data = self.weight.data.T

替换时,直接将原层的 weight 和 bias 复制给新的 LoRA 层,再将新的 LoRA 层分配到指定设备即可。

###4训练

实现了 LoRA 层的替换后,进行微调训练即可。由于在 LoRA 层中已冻结原参数,在训练中只有 A 和 B 的参数会被更新,从而实现了高效微调。训练的整体过程与原 Fine-tune 类似,此处不再赘述。由于采用了 LoRA 方式forward 函数也会对应调整:

    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)

上述代码由于考虑到参数合并问题,有几个分支,此处我们仅阅读第二个分支即 elif 分支即可。基于 LoRA 的前向计算过程如前文公式所示,首先计算原参数与输入的乘积,再加上 A、B 分别与输入的乘积即可。

6.4.5 使用 peft 实现 LoRA 微调

peft 进行了很好的封装,支持我们便捷、高效地对大模型进行微调。此处以第二节的 LLM SFT 为例,简要介绍如何使用 peft 对大模型进行微调。如果是应用在 RLHF 上,整体思路是一致的。

首先加载所需使用库:

import torch.nn as nn
from transformers import AutoTokenizer, AutoModel
from peft import get_peft_model, LoraConfig, TaskType, PeftModel
from transformers import Trainer

其次加载原模型与原 tokenizer此处和第二节一致

# 加载基座模型
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
model = AutoModel.from_pretrained(
    MODEL_PATH, trust_remote_code=True
)

接着,设定 peft 参数:

peft_config = LoraConfig(
            task_type=TaskType.CAUSAL_LM,
            inference_mode=False,
            r=8,
            lora_alpha=32,
            lora_dropout=0.1,
        )

注意对不同的模型LoRA 参数可能有所区别。例如,对于 ChatGLM无需指定 target_modeulespeft 可以自行找到;对于 BaiChuan就需要手动指定。task_type 是模型的任务类型,大模型一般都是 CAUSAL_LM 即传统语言模型。

然后获取 LoRA 模型:

model = get_peft_model(model, peft_config)

此处的 get_peft_model 的底层操作,即为上文分析的具体实现。

最后使用 transformers 提供的 Trainer 进行训练即可,训练占用的显存就会有大幅度的降低:

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset= IterableWrapper(train_dataset),
    tokenizer=tokenizer
)
trainer.train()

如果是应用在 DPO、KTO 上,则也相同的加入 LoRA 参数并通过 get_peft_model 获取一个 LoRA 模型即可其他的不需要进行任何修改。但要注意的是LoRA 微调能够大幅度降低显卡占用且在下游任务适配上能够取得较好的效果但如果是需要学习对应知识的任务LoRA 由于只调整低秩矩阵,难以实现知识的注入,一般效果不佳,因此不推荐使用 LoRA 进行模型预训练或后训练。

参考资料

  1. LoRA: Low-rank Adaptation of Large Language Models
  2. Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning
  3. Parameter-Efficient Transfer Learning for NLP
  4. Prefix-Tuning: Optimizing Continuous Prompts for Generation