NLP——LoRA和QLoRA

本文主要介绍LoRA和QLoRA


LoRA

参考链接

  • LoRA(Low-Rank Adaptation)详解——知乎,大师兄

  • 神经网络模型参数一般都是矩阵形式,比如在Self-Attention中

    • 广义上 \(W_q,W_k,W_v,W_o\) 等分别是 \(d_{model}\times d_k, d_{model}\times d_k, d_{model}\times d_v, d_v \times d_{model}\) 维度的(这里的 \(d_v\) 表示所有头的长度的和,与Transformer原论文有所区别,原始论文中 \(d_v\) 是单个头的长度)
    • 而实际在Transformer中(包括Transformer原论文和GPT等),如果不考虑多头(或者多头数 \(h=1\) ),常常有 \(d_k = d_v = d_{model}\)
    • 在面对多头Attention时,常常有 \(d_v = d_v^{MH} \times N_{head}\),(再次强调,注意这里与原始论文表示不同,原始论文中 \(d_v\) 是单个头的维度,即 \(d_{model} = d_v * h\) )
  • LoRA通常用于预训练模型的微调阶段,训练时在预训练模型上加一个旁路,替代已有的网络模型参数(一般是特别大的参数矩阵,比如Attention参数 \(W_q\) 等

    • 对应的模型LoRA代码实现:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      input_dim = 768 # 例如,预训练模型的隐藏大小
      output_dim = 768 # 例如,层的输出大小
      rank = 8 # 低秩适应的等级'r'
      W = ... # 来自预训练网络的权重,形状为 input_dim x output_dim
      W_A = nn.Parameter(torch.empty(input_dim, rank)) # LoRA权重A
      W_B = nn.Parameter(torch.empty(rank, output_dim)) # LoRA权重B
      # 初始化LoRA权重
      nn.init.kaiming_uniform_(W_A, a=math.sqrt(5))
      nn.init.zeros_(W_B)
      # 以下写法与论文中公式 h = W @ x不一样的原因是因为这里x是行向量,论文中x是当做列向量来看的
      def regular_forward_matmul(x, W):
      h = x @ W
      return h

      def lora_forward_matmul(x, W, W_A, W_B):
      h = x @ W # 常规矩阵乘法
      h += x @ (W_A @ W_B) * alpha # 使用缩放的LoRA权重
      return h
  • 假设原始模型中,某个参数矩阵为 \(W^{d \times k}\) (也可以不是方阵),LoRA网络可以用两个小矩阵来表示 \(B^{d\times r}, A^{r\times k}\)

    • 在没有LoRA时,参数 \(W_0\) 的前向过程是(下面的 \(h\) 表示参数对应的隐藏层向量, \(x, h\) 均为列向量):
      $$h = W_0 x$$
    • 加入LoRA时,该参数的前向过程为:
      $$h = W_0 x + \frac{\alpha}{r}\Delta W x = W_0 x + \frac{\alpha}{r}BAx$$
      • 原始论文中提到,一般可以使用 \(\frac{\alpha}{r}\) 来设置LoRA权重
        • 理解:权重与 \(r\) 有关的原因是 \(r\) 越大,LoRA矩阵包含的信息越多,对原始模型的影响越大,论文中提到,为了减少超参数数量, \(\alpha\) 的设置一般可以使用 \(\alpha=r\) (we simply set α to the first r we try and do not tune it),揭秘LoRA与QLoRA:百次实验告诉你如何微调LLM!则给出了更为详细的说明,并得出在LLM中使用 \(\alpha=2r\) 会更好
        • 个人观点: \(\alpha\) 理论上可以不调整,因为如果LoRA参数初始化时同时缩放 \(\frac{\alpha}{r}\) 倍,同时调整学习率为 \(lr = \frac{\alpha}{r} \times lr\),则与在 \(\Delta W\) 前使用 \(\frac{\alpha}{r}\) 实现的效果完全一致?
    • 由于 \(r << min(d,k)\),LoRA可以极大减少参数量,训练时,除了计算量外,优化器需要存储的中间变量与参数量相关,相对全量微调,使用LoRA微调的显存和计算量会极大减少
    • 模型存储时,可以将LoRA参数换算成矩阵加到原来的参数权重中,从而保证在推理时不增加额外显存和计算量
      $$W_{save} = W_0 + \frac{\alpha}{r}BA$$
      • 这里用到了矩阵运算的分配律 \((A+B)C = AC + BC\)
  • 其中 \(A\) 矩阵权重参数使用正太分布初始化, \(B\) 矩阵权重参数使用0初始化,保证了如果LoRA部分参数全为0,则无法训练LoRA

    • 为什么要这样初始化,换个方式不可以吗?如果B不为0,A为0,或者两个都为0呢?以下回答参考自LoRA与QLoRA快速介绍

      这里有一些细节需要注意,LoRA的两个矩阵A和B中,一个是零向量初始化,一个是随机初始化。个人观点是,A和B哪个是0都可以,也可以两个都是0;但至少要有一个是0,这样才能保证未经训练的LoRA作为旁路加到预训练模型中时,LoRA不会对模型的预测产生任何影响,模型能够基于当前性能进一步学习。此外,为什么可以用低秩矩阵来模拟原始矩阵?已有研究发现,大模型往往是过度参数化的,模型实际用到的维度(模型内在维度)可能并没有那么高,所以用低秩矩阵来拟合目标任务也能达到不错的效果

  • 从网络结构上理解,LoRA相对于普通的全连接层,相当于把之前的一层网络拆解成两层,但中间层没有激活函数


QLoRA

QLoRA的优化有三个核心要点

  • 4-bit NormalFloat Quantization : 首先是定义了一种4位标准浮点数(Normal Float 4-bit,NF4)量化,基于分块的分位数量化的量化策略
  • Double Quantization :其次是双重量化,包含对普通参数的一次量化和对量化常数的再一次量化,可以进一步减小缓存占用
    • 量化常数 :一次量化中,每组被量化的参数都会存储一个绝对值的最大值absmax,这个值通常一般是高精度保存,也会占用大量的显存。【问题:分组这么多吗?能不能通过减少分组来实现】
  • Paged Optimizers :最后是分页优化器(Page Optimizer),用来在显存过高时用一部分内存代替显存

LLM中的LoRA

  • 每个模型会按照自己的命名习惯为不同的参数模块分配名称
  • 一般用target_modules参数指定LoRA微调目标(目标即模型中的模块名称,每个模块代表一组对应的参数),常用的模型和可作为LoRA目标的,常用配置可参考聊聊LoRA及其target_module配置
    • 经常被用来作为LoRA微调对象的是 \(W_q, W_v\),对应target_modules=[q_proj,v_proj]* 为什么 \(W_k\) 不常被用于LoRA微调呢?参考【思考】为什么大模型lora微调经常用在attention的Q和V层而不用在K层呢
      • 直观上看, \(W_q, W_v\) 分别影响Attention的权重部分和值部分,理论上Attention的表示能力都能被影响到了
      • 实践中发现调整 \(W_q, W_v\) 基本够用了
      • 也有的模型是 \(W_q, W_k, W_v\) 同时调整的,甚至同时调整其他很多模块
  • 模块在模型中代表的含义可以打印模型结构来看
  • 对比使用LoRA前后模型可训练参数数量,参考自LLM微调(一)| 单GPU使用QLoRA微调Llama 2.0实战
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    def print_number_of_trainable_model_parameters(model):
    trainable_model_params = 0
    all_model_params = 0
    for _, param in model.named_parameters():
    all_model_params += param.numel()
    if param.requires_grad:
    trainable_model_params += param.numel()
    print(f"trainable model parameters: {trainable_model_params}. All model parameters: {all_model_params} ")
    return trainable_model_params

    ori_p = print_number_of_trainable_model_parameters(model)

    # 输出
    # trainable model parameter: 262,410,240

    ## =================

    # LoRA config
    model = prepare_model_for_kbit_training(model)
    peft_config = LoraConfig(
    r=8,
    lora_alpha=32,
    lora_dropout=0.1,
    target_modules=["q_proj", "v_proj"],
    bias="none",
    task_type="CAUSAL_LM",
    )
    model = get_peft_model(model, peft_config)

    ### compare trainable parameters #
    peft_p = print_number_of_trainable_model_parameters(model)
    print(f"# Trainable Parameter \nBefore: {ori_p} \nAfter: {peft_p} \nPercentage: {round(peft_p / ori_p * 100, 2)}")

    # 输出
    # trainable model parameter: 4,194,304