08-大模型后训练的指令微调SFT:LoRA让大模型微调成本降低99%

4 阅读4分钟

为什么需要后训练?

在上一章中,我们学习了大模型的预训练过程。预训练完成后,我们得到了一个基础模型(Base Model)

Base Model的问题

回顾:Base Model只会"续写",不会"遵循指令"

示例

用户输入:"请写一首关于春天的诗"

Base Model输出(续写模式):

请写一首关于春天的诗歌
请写一首关于夏天的诗歌
请写一首关于秋天的诗歌
...

我们期望的输出

春风拂面暖人心,
万物复苏展新颜。
桃花盛开映碧水,
燕子归来舞翩翩。

问题的本质

  • Base Model学习的是统计规律:根据前文预测下一个词
  • 没有学习指令遵循:理解用户意图并执行任务
  • 没有学习对话模式:问答、多轮交互
  • 没有学习安全性:避免有害、偏见的输出

后训练的目标

**后训练(Post-training)**是指在预训练的基础上,进一步训练模型,使其具备特定能力:

  1. 指令遵循(Instruction Following)

    • 理解用户的指令
    • 执行特定任务(写作、翻译、问答、代码生成等)
  2. 对话能力(Dialogue)

    • 自然的多轮对话
    • 上下文理解
    • 合适的语气和风格
  3. 安全性(Safety)

    • 拒绝有害请求
    • 避免偏见和歧视
    • 保护隐私
  4. 专业能力(Domain Expertise)

    • 医疗、法律、金融等垂直领域
    • 特定任务(客服、代码助手、写作助手)

后训练的两大阶段

现代大模型的后训练通常包括两个阶段:

预训练模型(Base Model)
    ↓
【阶段1:监督微调(SFT - Supervised Fine-Tuning)】
    - 使用高质量的指令-回答对训练
    - 教会模型"如何回答问题"
    ↓
指令遵循模型(Instruction Model)
    ↓
【阶段2:强化学习(RLHF - Reinforcement Learning from Human Feedback)】
    - 使用人类反馈优化输出质量
    - 教会模型"什么是好的回答"
    ↓
对齐模型(Aligned Model)- 最终产品

本章重点:我们主要讲解**阶段1(SFT)**的不同方法:全参数微调、LoRA、QLoRA。

全参数微调(Full Fine-Tuning)

什么是全参数微调?

**全参数微调(Full Fine-Tuning)**是最直接的微调方法:在新任务的数据上,更新模型的所有参数

θfine-tuned=θpretrainedηθLtask\theta_{\text{fine-tuned}} = \theta_{\text{pretrained}} - \eta \cdot \nabla_\theta L_{\text{task}}
  • θpretrained\theta_{\text{pretrained}}:预训练模型的参数
  • LtaskL_{\text{task}}:特定任务的损失函数
  • 所有参数都参与更新

类比

  • 预训练:大学的通识教育(学习广泛的知识)
  • 全参数微调:研究生的专业深造(在原有基础上,全方位深入学习特定领域)

训练过程

1. 数据准备

监督微调的数据格式:指令-回答对

{
  "instruction": "请将下面的英文翻译成中文",
  "input": "The weather is nice today.",
  "output": "今天天气很好。"
}

或者更简单的对话格式:

{
  "prompt": "今天天气怎么样?",
  "response": "今天天气不错,阳光明媚,适合外出活动。"
}

数据规模

  • 小规模任务:1,000 - 10,000 条
  • 通用指令遵循:10,000 - 100,000 条
  • 对话模型:100,000 - 1,000,000 条

2. 训练设置

相比预训练,微调的设置通常更保守:

参数预训练全参数微调
学习率1e-4 ~ 6e-41e-5 ~ 5e-5(更小)
Batch Size数百万Token数万到数十万Token
训练步数数十万到数百万步数千到数万步
Epoch数通常1 epoch2-5 epochs
学习率调度Warmup + Cosine线性衰减或Cosine

为什么更保守?

  • 预训练模型已经学到了丰富的知识
  • 微调的目标是"适配"而不是"重新学习"
  • 学习率太大会"遗忘"预训练的知识(灾难性遗忘)

3. 训练代码

from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments

# 1. 加载预训练模型
model = AutoModelForCausalLM.from_pretrained("gpt2")  # 117M参数
tokenizer = AutoTokenizer.from_pretrained("gpt2")

# 2. 准备数据
train_dataset = load_dataset("instruction_data")

# 3. 设置训练参数
training_args = TrainingArguments(
    output_dir="./fine-tuned-gpt2",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,  # 有效batch_size = 4*8 = 32
    learning_rate=2e-5,              # 比预训练小一个数量级
    num_train_epochs=3,
    warmup_steps=100,
    logging_steps=10,
    save_steps=500,
    fp16=True,                       # 混合精度训练
)

# 4. 训练
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)

trainer.train()

# 5. 保存模型
model.save_pretrained("./fine-tuned-gpt2-final")

全参数微调的优缺点

优点

  1. 效果最好

    • 所有参数都可以调整,表达能力最强
    • 可以学习到任务特定的深层特征
  2. 灵活性高

    • 适用于各种任务
    • 可以进行大幅度的适配

缺点

  1. 计算成本高

    • 需要为每个任务训练一个完整的模型副本
    • 例如:LLaMA-7B有7B参数,每个任务都需要7B参数的副本
  2. 存储成本高

    • 每个任务都需要存储完整的模型
    • LLaMA-7B(FP16):~14GB/任务
    • 10个任务:~140GB
  3. 容易过拟合

    • 数据量小时,容易遗忘预训练知识
    • 需要careful的学习率调整
  4. 灾难性遗忘(Catastrophic Forgetting)

    • 在新任务上训练会损害旧任务的性能
    • 模型"忘记"预训练学到的通用知识

什么时候使用全参数微调?

适合的场景

  • 数据量充足(>100K样本)
  • 计算资源充足
  • 需要最佳性能
  • 只有少数几个任务(<10个)
  • 任务与预训练数据分布差异大(如特定领域:医疗、法律)

示例

  • 医疗问答系统(有大量医疗对话数据)
  • 法律文书生成(有充足的法律文本)
  • 公司内部的客服机器人(数据充足,计算资源不是问题)

LoRA:参数高效的微调方法

全参数微调的成本太高,能否只更新一小部分参数,同时保持接近全参数微调的效果?

LoRA(Low-Rank Adaptation) 就是这样一种方法!

核心思想

LoRA的核心洞察:微调时的参数更新矩阵往往是低秩的(Low-Rank)

什么是低秩?

一个矩阵 ΔWRd×d\Delta W \in \mathbb{R}^{d \times d} 是低秩的,意味着它可以分解为两个更小矩阵的乘积:

ΔW=AB\Delta W = A \cdot B

其中:

  • ARd×rA \in \mathbb{R}^{d \times r}
  • BRr×dB \in \mathbb{R}^{r \times d}
  • rdr \ll drr 远小于 dd

参数量对比

Original Matrix:d×d parametersLow-rank:d×r+r×d=2dr parametersReduction:2drd2=2rd\begin{aligned} \text{Original Matrix:} & \quad d \times d \text{ parameters} \\ \text{Low-rank:} & \quad d \times r + r \times d = 2dr \text{ parameters} \\ \text{Reduction:} & \quad \frac{2dr}{d^2} = \frac{2r}{d} \end{aligned}

其中:

  • 原始矩阵需要 d×dd \times d 个参数
  • 低秩分解只需要 2dr2dr 个参数
  • 参数减少比例:2rd\frac{2r}{d}(例如:r=8,d=768r=8, d=768 时,只有 2×8768=2.1%\frac{2 \times 8}{768} = 2.1\%!)

深入理解:为什么ΔW可以是低秩,而W不行?

这是LoRA最关键的假设,值得深入理解。

核心区别:满秩 vs 低秩

预训练权重W:满秩(不能用小矩阵表示)

# 预训练的权重矩阵
W ∈ ℝ^(768×768)

# 如果尝试用两个小矩阵表示
W ≈ A × B  # A ∈ ℝ^(768×8), B ∈ ℝ^(8×768)

为什么不行?

  1. 秩的限制

    rank(W) ≈ 768  # 几乎是满秩,包含768个独立方向的信息
    rank(A×B) ≤ min(rank(A), rank(B)) ≤ r = 8
    # 只有8个独立方向的信息
    # 信息损失:768维 → 8维 = 损失99%的信息!
    
  2. 预训练权重包含的信息

    W_Q (Query权重) 需要编码:
    - 语法信息(主语、谓语、宾语的关系)
    - 语义信息(词义、上下文)
    - 位置信息(远近、先后)
    - 多头注意力(不同的关注模式)
    - 层次结构(浅层特征、深层特征)
    - ... 成千上万种语言模式
    
    这些信息无法被压缩到8维空间!
    
  3. 实验证明

    # 如果用低秩矩阵替换W
    W_lowrank = A × B  # r=8
    
    结果:
    - 模型完全崩溃
    - Perplexity从20.58532.1
    - 输出变成乱码
    
    原因:99%的信息丢失了
    

微调变化ΔW:低秩(可以用小矩阵表示)

# 微调时的权重变化
ΔW = W_finetuned - W_pretrained

# LoRA假设:这个变化是低秩的
ΔW ≈ A × B  (r=8)

为什么ΔW可以是低秩的?

这是LoRA的核心假设,来自论文的关键洞察:

"我们假设在针对特定任务进行适应时,权重的更新具有低的'内在秩'(intrinsic rank)"

原因1:大部分知识已经学会了

预训练阶段(从零开始):
需要学习:所有语言知识 + 所有世界知识 + 所有推理能力
→ 需要高维空间(满秩,768维)

微调阶段(在预训练基础上):
只需要学习:
  ① 任务特定的微小调整
  ② 领域特定的适应
→ 只需要低维空间(低秩,8维)

原因2:任务的内在维度低

# 原始768维空间的作用

维度1-50:   语法结构
维度51-100: 词义理解
维度101-200:上下文建模
维度201-300:推理能力
维度301-400:世界知识
...
维度751-768:其他细微特征

# 微调成"医疗问答"时

需要重点调整的维度:
维度301-400(世界知识-医疗):★★★★★
维度51-100 (词义-医疗术语):★★★★
维度101-200(上下文):★★
其他维度:★ (几乎不需要变化)

→ 实际上只有少数几个"方向"需要显著调整
→ 这就是低秩的含义!

原因3:实验验证

来自LoRA论文的关键实验:

# 对全参数微调后的权重变化做奇异值分解(SVD)
ΔW = W_finetuned - W_pretrained
U, S, V = svd(ΔW)

# 观察奇异值的分布
S = [5.2, 3.8, 2.1, 0.9, 0.3, 0.1, 0.05, 0.02, ...]
      ↑    ↑    ↑    ↑    ↑     ↑     ↑      ↑
     很大  大   中   小   很小  极小  极小   极小

# 发现:前8个奇异值包含了 > 95% 的能量
# → 说明ΔW确实是低秩的!
# → 用r=8就能捕获大部分变化

直观类比

预训练权重W = 整个图书馆
- 包含10万本不同的书
- 涵盖所有学科
- 每本书都独特、不可替代
- 如果只选8个书架(低秩)→ 99%的书都没了 ✗

微调变化ΔW = 图书更新
- 大部分书保持不变
- 只更新8个主题的书(比如:医学相关)
- 其他书籍不动
- 8个书架(低秩)足够存放需要更新的书 ✓

总结对比

对比项预训练权重 W微调变化 ΔW
高秩/满秩 (≈768)低秩 (≈8)
信息内容所有语言知识任务特定调整
学习过程从零开始在现有基础上
是否可低秩分解❌ 不行✅ 可以
低秩分解后果模型崩溃性能几乎不变
原因需要全部768维信息只需少数几个维度

这就是LoRA的天才之处:识别出微调本质上是低秩的,从而大幅降低计算和存储成本!

具体例子

假设 d=768d=768(GPT-2的维度),r=8r=8(LoRA的秩)

全参数更新

W=W+ΔWW' = W + \Delta W

ΔW\Delta W 需要 768×768=589,824768 \times 768 = 589{,}824 个参数

LoRA更新

W=W+ABW' = W + A \cdot B
  • AA768×8=6,144768 \times 8 = 6{,}144 个参数
  • BB8×768=6,1448 \times 768 = 6{,}144 个参数
  • 总计:12,28812{,}288 个参数(只有全参数的2.1%!

LoRA的数学形式

1. 原始的Transformer层

回顾一下注意力机制中的查询矩阵:

Q=XWQQ = X \cdot W_Q

其中:

  • XRn×dX \in \mathbb{R}^{n \times d}:输入
  • WQRd×dW_Q \in \mathbb{R}^{d \times d}:查询权重矩阵(预训练好的)
  • QRn×dQ \in \mathbb{R}^{n \times d}:查询向量

2. 全参数微调

更新整个 WQW_Q

WQnew=WQ+ΔWQW_Q^{\text{new}} = W_Q + \Delta W_Q

ΔWQ\Delta W_Q 是通过梯度下降学到的更新。

3. LoRA微调

冻结原始权重 WQW_Q(不更新),添加一个低秩更新:

WQLoRA=WQ+αABW_Q^{\text{LoRA}} = W_Q + \alpha \cdot A \cdot B

其中:

  • WQW_Q冻结的预训练权重(不参与训练)
  • ARd×rA \in \mathbb{R}^{d \times r}可训练的低秩矩阵A
  • BRr×dB \in \mathbb{R}^{r \times d}可训练的低秩矩阵B
  • rr:秩(通常 r=8,16,32,64r=8, 16, 32, 64
  • α\alpha:缩放因子(通常等于 rr

前向传播

Q=XWQ+X(AB)αrQ = X \cdot W_Q + X \cdot (A \cdot B) \cdot \frac{\alpha}{r}

拆解:

Q=XWQOriginal (frozen)+XABαrLoRA (trainable)\begin{aligned} Q &= \underbrace{X \cdot W_Q}_{\text{Original (frozen)}} + \underbrace{X \cdot A \cdot B \cdot \frac{\alpha}{r}}_{\text{LoRA (trainable)}} \end{aligned}

其中:

  • 第一项:原始路径(冻结,不更新)
  • 第二项:LoRA路径(可训练)

关键点

  • 只有 AABB 参与训练
  • WQW_Q 保持不变
  • 两条路径并行,最后相加

LoRA的初始化

重要AABB 的初始化方式确保训练开始时LoRA的贡献为0:

AN(0,σ2)B=0\begin{aligned} A &\sim \mathcal{N}(0, \sigma^2) \\ B &= 0 \end{aligned}

其中:

  • AA:正态分布初始化
  • BB:全零初始化!

效果:训练开始时,AB=A0=0A \cdot B = A \cdot 0 = 0

WQLoRA=WQ+α0=WQW_Q^{\text{LoRA}} = W_Q + \alpha \cdot 0 = W_Q

模型从预训练的权重开始,然后逐渐学习任务特定的调整!

LoRA应用到哪些层?

Transformer中有很多权重矩阵,LoRA通常应用于:

1. 注意力层的QKV矩阵

Q=XWQ+XAQBQK=XWK+XAKBKV=XWV+XAVBV\begin{aligned} Q &= X \cdot W_Q + X \cdot A_Q \cdot B_Q \\ K &= X \cdot W_K + X \cdot A_K \cdot B_K \\ V &= X \cdot W_V + X \cdot A_V \cdot B_V \end{aligned}

以及输出投影:

O=AttnWO+AttnAOBOO = \text{Attn} \cdot W_O + \text{Attn} \cdot A_O \cdot B_O

2. MLP层(可选)

h=GELU(XW1+XA1B1)y=hW2+hA2B2\begin{aligned} h &= \text{GELU}(X \cdot W_1 + X \cdot A_1 \cdot B_1) \\ y &= h \cdot W_2 + h \cdot A_2 \cdot B_2 \end{aligned}

实践建议

配置应用LoRA的层参数量效果
最小WQW_Q, WVW_V最少不错
推荐⭐WQW_Q, WKW_K, WVW_V, WOW_O适中
最大所有注意力层 + MLP较多最好

原因:注意力层对任务适配最重要,MLP层相对次要。

LoRA的参数量计算

以GPT-2(12层,768维,12头)为例:

全参数微调

每层有4个注意力矩阵(WQ,WK,WV,WOW_Q, W_K, W_V, W_O)和2个MLP矩阵(W1,W2W_1, W_2):

Attention:4×768×768=2,359,296MLP:768×3072+3072×768=4,718,592Per Layer:7,077,88812 Layers:84,934,65685M\begin{aligned} \text{Attention:} & \quad 4 \times 768 \times 768 = 2{,}359{,}296 \\ \text{MLP:} & \quad 768 \times 3072 + 3072 \times 768 = 4{,}718{,}592 \\ \text{Per Layer:} & \quad 7{,}077{,}888 \\ \text{12 Layers:} & \quad 84{,}934{,}656 \approx 85M \end{aligned}

说明:

  • 注意力矩阵:4 × 768 × 768 = 2,359,296 参数
  • MLP矩阵:768 × 3072 + 3072 × 768 = 4,718,592 参数
  • 每层总计:7,077,888 参数
  • 12层总计:84,934,656 ≈ 85M 参数

加上Embedding和LayerNorm:总参数约117M

LoRA微调(r=8r=8,仅QKV)

每层有3个LoRA对(AQ,BQA_Q, B_Q, AK,BKA_K, B_K, AV,BVA_V, B_V):

Per LoRA pair:768×8+8×768=12,2883 pairs per layer:3×12,288=36,86412 layers:12×36,864=442,3680.44M\begin{aligned} \text{Per LoRA pair:} & \quad 768 \times 8 + 8 \times 768 = 12{,}288 \\ \text{3 pairs per layer:} & \quad 3 \times 12{,}288 = 36{,}864 \\ \text{12 layers:} & \quad 12 \times 36{,}864 = 442{,}368 \approx 0.44M \end{aligned}

说明:

  • 每个LoRA对:768 × 8 + 8 × 768 = 12,288 参数
  • 每层3对:3 × 12,288 = 36,864 参数
  • 12层总计:12 × 36,864 = 442,368 ≈ 0.44M 参数

对比

方法可训练参数占比存储(FP16)
全参数微调117M100%~234 MB
LoRA(r=8,QKV)0.44M0.38%~0.9 MB
LoRA(r=16,QKV)0.88M0.75%~1.8 MB
LoRA(r=8,全部)1.3M1.1%~2.6 MB

惊人的压缩率:LoRA只需训练不到1%的参数

LoRA的训练过程

完整流程

import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM

# 1. 加载预训练模型
model = AutoModelForCausalLM.from_pretrained("gpt2")

# 2. 冻结所有原始参数
for param in model.parameters():
    param.requires_grad = False

# 3. 添加LoRA层
class LoRALayer(nn.Module):
    def __init__(self, in_dim, out_dim, rank=8, alpha=16):
        super().__init__()
        self.rank = rank
        self.alpha = alpha

        # LoRA的两个矩阵
        self.lora_A = nn.Parameter(torch.randn(in_dim, rank) * 0.01)
        self.lora_B = nn.Parameter(torch.zeros(rank, out_dim))  # 零初始化!

    def forward(self, x, original_weight):
        # 原始路径(冻结)
        original_output = x @ original_weight

        # LoRA路径(可训练)
        lora_output = (x @ self.lora_A @ self.lora_B) * (self.alpha / self.rank)

        return original_output + lora_output

# 4. 为每个注意力层添加LoRA
for layer in model.transformer.h:
    # 为W_Q, W_K, W_V添加LoRA
    layer.attn.q_lora = LoRALayer(768, 768, rank=8)
    layer.attn.k_lora = LoRALayer(768, 768, rank=8)
    layer.attn.v_lora = LoRALayer(768, 768, rank=8)

# 5. 修改前向传播(简化示意)
def attention_forward_with_lora(self, x):
    # 原始权重(冻结)
    W_Q, W_K, W_V = self.c_attn.weight.split(768, dim=1)

    # 应用LoRA
    Q = self.q_lora(x, W_Q)
    K = self.k_lora(x, W_K)
    V = self.v_lora(x, W_V)

    # 后续的注意力计算保持不变
    attn = torch.softmax(Q @ K.T / np.sqrt(768), dim=-1)
    output = attn @ V
    return output

# 6. 训练(只训练LoRA参数)
optimizer = torch.optim.AdamW([
    p for n, p in model.named_parameters() if 'lora' in n
], lr=1e-4)

for batch in dataloader:
    loss = model(**batch).loss
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()

实际使用PEFT库

实践中,我们使用Hugging Face的PEFT库:

from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM

# 1. 加载基础模型
model = AutoModelForCausalLM.from_pretrained("gpt2")

# 2. 配置LoRA
lora_config = LoraConfig(
    r=8,                      # 秩
    lora_alpha=16,            # 缩放因子
    target_modules=["c_attn"],  # 应用LoRA的模块
    lora_dropout=0.1,         # Dropout(可选)
    bias="none",              # 不训练bias
    task_type="CAUSAL_LM"
)

# 3. 获取LoRA模型
model = get_peft_model(model, lora_config)

# 4. 查看可训练参数
model.print_trainable_parameters()
# 输出:trainable params: 294,912 || all params: 124,439,808 || trainable%: 0.24%

# 5. 训练(和普通模型一样)
from transformers import Trainer, TrainingArguments

training_args = TrainingArguments(
    output_dir="./lora-gpt2",
    per_device_train_batch_size=8,
    learning_rate=1e-4,        # LoRA可以用更大的学习率
    num_train_epochs=3,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)

trainer.train()

# 6. 保存LoRA权重(只保存A和B矩阵)
model.save_pretrained("./lora-gpt2-final")  # 只有几MB!

LoRA的合并和推理

训练完成后,有两种使用方式:

1. 动态加载LoRA

from peft import PeftModel
from transformers import AutoModelForCausalLM

# 加载基础模型
base_model = AutoModelForCausalLM.from_pretrained("gpt2")

# 加载LoRA权重
model = PeftModel.from_pretrained(base_model, "./lora-gpt2-final")

# 推理
output = model.generate(inputs)

优点

  • 可以动态切换不同的LoRA(多任务)
  • 一个基础模型 + 多个LoRA适配器

2. 合并LoRA到基础模型

Wmerged=Wbase+αABW_{\text{merged}} = W_{\text{base}} + \alpha \cdot A \cdot B
# 合并LoRA到基础权重
model = model.merge_and_unload()

# 保存合并后的模型(完整模型)
model.save_pretrained("./merged-model")

# 推理时不需要LoRA库
model = AutoModelForCausalLM.from_pretrained("./merged-model")
output = model.generate(inputs)

优点

  • 推理速度和原始模型一样(没有额外计算)
  • 不依赖LoRA库

缺点

  • 无法动态切换LoRA
  • 需要为每个任务存储完整模型

LoRA的优缺点

优点

  1. 参数高效

    • 只需训练0.1%-1%的参数
    • 极大降低内存需求
  2. 存储高效

    • 每个任务只需存储几MB的LoRA权重
    • 一个基础模型 + N个LoRA = 支持N个任务
  3. 训练快速

    • 更少的参数需要更新
    • 可以用更大的学习率
    • 训练时间减少30%-50%
  4. 防止遗忘

    • 基础权重冻结,不会遗忘预训练知识
    • 更鲁棒
  5. 动态切换

    • 可以在运行时切换不同的LoRA
    • 一个模型,多种任务

缺点

  1. ⚠️ 效果略逊于全参数微调(但差距很小,<2%)

  2. ⚠️ 推理时有额外计算(如果不合并):

    • 需要计算 ABA \cdot B
    • 约5%-10%的推理延迟
  3. ⚠️ 超参数敏感

    • rr(秩)的选择影响效果
    • 需要一定的调参经验

LoRA的超参数选择

1. 秩(Rank)rr

rr参数量效果适用场景
4最少一般数据极少(<1K),简单任务
8好⭐大多数任务(推荐)
16中等很好复杂任务,数据充足
32-64较多最好高要求任务
128+很多接近全参数不推荐(失去LoRA优势)

经验法则:从 r=8r=8 开始,如果效果不够好再增大。

2. 缩放因子 α\alpha

通常设置为:

α=rorα=2r\alpha = r \quad \text{or} \quad \alpha = 2r

α=r\alpha = rα=2r\alpha = 2r

为什么α要设置为r或2r?深入解析

这个设置背后有深刻的数学和实践原因。

α的作用回顾

W_new = W + α/r · A · B
         ↑   ↑
      原始  缩放因子

原因1:归一化不同r值的更新尺度

# 不使用α(α=1)
W_new = W + 1/r · A · B

# r=4时:
ΔW = 1/4 · A · B = 0.25 · A·B

# r=64时:
ΔW = 1/64 · A · B = 0.015625 · A·B

# 问题:r不同,更新幅度差异巨大(16倍)!
# → 难以统一调参,需要针对每个r调整学习率

引入α=r

# 使用α=r
W_new = W + r/r · A · B = W + A · B

# r=4时:
ΔW = 4/4 · A · B = 1.0 · A·B

# r=64时:
ΔW = 64/64 · A · B = 1.0 · A·B

# 好处:无论r取什么值,更新尺度一致!
# → 容易调参,可以在不同r之间快速切换

原因2:与初始化的关系

LoRA的初始化确保训练开始时更新为0:

# A的初始化
A ~ N(0, σ²)  # 高斯分布

# B的初始化
B = 0  # 全零

# 训练开始时
A·B = A·0 = 0
→ W_new = W + α/r · 0 = W

# 确保训练初始状态 = 预训练模型(不破坏原有知识)

训练过程中,A·B的数值大小(magnitude)与r有关:

# r越大,A和B越"宽"
# 矩阵乘法后,值累加更多
# magnitude(A·B) ∝ √r

# 因此需要除以r来归一化
# α/r 保证了最终更新的尺度与r无关

原因3:实验验证

来自LoRA原论文的关键实验:

实验设置:
- 模型:RoBERTa-base
- 任务:GLUE benchmark
- 测试不同α值

结果:
α = r/2    →  性能 85.2%
α = r      →  性能 86.8%  ⭐ 最佳
α = 2r     →  性能 86.9%  ⭐ 最佳
α = 4r     →  性能 86.5%
α = 8r     →  性能 85.8%

结论:α=r或α=2r效果最好,超出这个范围性能下降

为什么α=r是"甜点"?

α < r:  LoRA更新太保守
       → 学习速度慢
       → 可能欠拟合

α = r:  LoRA更新适中
       → 平衡学习速度和稳定性 ✓
       → 90%场景的最佳选择

α = 2r: LoRA更新稍强
       → 适合需要强适应的任务 ✓
       → 如领域跨度很大的微调

α > 2r: LoRA更新太激进
       → 可能破坏预训练知识
       → 训练不稳定

直观理解

α相当于LoRA的"学习率倍数":

# 参数更新的总效果
对于普通参数:θ_new = θ_old - lr · ∇L

# 对于LoRA
A_new = A_old - lr_A · ∇L_A
B_new = B_old - lr_B · ∇L_B

# 最终对W的影响
ΔW = α/r · A · B

# α越大 → LoRA对W的影响越大
# 类似于"放大"了LoRA的学习率

实践建议

# 默认设置(适合90%的场景)
config = LoraConfig(
    r=8,
    lora_alpha=8,  # α = r
    ...
)

# 需要更强适应(如领域跨度大)
config = LoraConfig(
    r=8,
    lora_alpha=16,  # α = 2r
    ...
)

# 一般不推荐
config = LoraConfig(
    r=8,
    lora_alpha=32,  # α = 4r,可能太大
    ...
)

调参优先级

1. 先固定 α=r,调整 r (4, 8, 16, 32)
   → 找到合适的模型容量

2. 如果r=8效果不够,尝试:
   - r=16, α=16  (增加容量)
   或
   - r=8, α=16   (增加更新强度)

3. 观察:
   - 验证集loss是否下降
   - 是否过拟合(train loss << val loss)

4. 微调:
   - 过拟合 → 减小r或α
   - 欠拟合 → 增大r或α

总结:α=r或2r的设置既有数学上的合理性(归一化更新尺度),又有实验上的验证(最佳性能),是LoRA设计中的一个巧妙选择。

3. 学习率

LoRA可以使用比全参数微调更大的学习率:

方法学习率范围
全参数微调1e-5 ~ 5e-5
LoRA1e-4 ~ 5e-4

原因:只更新少量参数,不容易破坏预训练知识。

QLoRA:量化+LoRA = 极致压缩

LoRA已经很高效了,但能否进一步降低成本?QLoRA的答案是:结合量化

什么是量化?

**量化(Quantization)**是指用更低精度的数据类型存储参数:

数据类型位数范围存储/参数
FP32(单精度浮点)32位~103810^{-38} to 103810^{38}4 bytes
FP16(半精度浮点)16位~10810^{-8} to 10410^{4}2 bytes
INT8(8位整数)8位-128 to 1271 byte
INT4(4位整数)4位-8 to 70.5 bytes

关键:量化可以大幅减少模型的存储和内存占用,代价是精度略有损失。

QLoRA的核心思想

QLoRA = 量化的基础模型 + 正常精度的LoRA

WQLoRA=Quantize(Wbase)+ABW^{\text{QLoRA}} = \text{Quantize}(W_{\text{base}}) + A \cdot B

具体做法

  1. 基础模型量化到4-bit

    • WbaseW_{\text{base}} 用INT4存储
    • 内存占用减少到1/8(相比FP32)
    • 只在推理时使用,不参与训练
  2. LoRA适配器保持FP16/BF16

    • AABB 用正常精度
    • 参与训练和梯度更新
  3. 前向传播时动态反量化

    • 将INT4的权重临时转换回FP16进行计算
    • 计算完成后丢弃

深入理解:为什么量化基础模型不会损失太多能力?

这是QLoRA最令人惊讶的地方:把基础模型压缩到4-bit,为什么性能损失很小?

关键理解:量化确实有损失,但可以被LoRA补偿

QLoRA的架构本质

┌────────────────────────────────────────┐
│  量化的基础模型 (INT4, 冻结)            │
│  ↓                                      │
│  信息有损失,但不更新                   │
└────────────────────────────────────────┘
           +
┌────────────────────────────────────────┐
│  LoRA适配层 (FP16/BF16, 可训练)        │
│  ↓                                      │
│  高精度,学习补偿量化损失               │
└────────────────────────────────────────┘

原因1:量化的确实有损失

# 原始权重 (FP16: 16 bits)
W_original = [0.1234567, -0.9876543, 0.5555555, ...]

# 量化后 (INT4: 4 bits)
W_quantized = [0.125, -1.0, 0.5625, ...]

# 信息损失
loss = W_original - W_quantized
# = [0.0015433, 0.0123457, -0.0069445, ...]

量化的影响:
- FP16 → INT4:从65536个可能值 → 16个可能值
- 精度大幅下降
- 小的权重值可能被"抹平"

原因2:为什么损失可以接受?

关键洞察:微调 ≠ 从头训练

从头训练:
需要学习所有知识
→ 需要高精度参数存储所有细节

微调:
基础知识已存在(在量化的权重中)
只需学习:
  ① 新任务的特定知识
  ② 补偿量化误差
→ LoRA层(高精度)足以完成

具体例子

# 场景:把GPT-4微调成医疗问答助手

# 基础模型(量化的)仍然包含:
✓ 语言理解能力(虽然有量化误差,但大体保留)
✓ 通用知识(基本医学概念虽然模糊,但存在)
✓ 推理能力(逻辑链条大致完整)

# 这些能力即使量化后仍然保留大部分(~95%)

# LoRA层(高精度)需要学习:
→ 医疗术语的精确用法
→ 医疗领域的推理模式
→ 补偿量化带来的小误差

# 这些只需少量参数(LoRA)就能学会

原因3:LoRA如何补偿量化损失?

# 前向传播的完整过程

x = input_embedding

# 1. 量化基础模型的计算(有误差)
W_quant = dequantize(W_int4)  # 临时转回FP16
output_base = x @ W_quant      # 有量化误差

# 2. LoRA的计算(高精度,无误差)
output_lora = x @ A @ B  # FP16精度

# 3. 最终输出
output = output_base + α/r * output_lora
#        ↑               ↑
#    有量化误差      可以学习补偿误差

# LoRA在训练中会学到两部分:
# 1. 任务特定的调整
# 2. 补偿量化误差的调整

训练过程中的自适应

Epoch 1:
  量化误差导致输出偏差
   LoRA梯度更新
   学会部分补偿量化误差

Epoch 2:
  偏差减小
   继续学习补偿 + 学习任务知识

...

最终:
  LoRA层  任务调整 + 量化误差补偿

原因4:实验证据

根据QLoRA论文的实验结果:

模型:LLaMA-65B
任务:多个NLP benchmark

全精度微调 (FP16):           性能 = 100%
LoRA (FP16):                 性能 = 99.3%
QLoRA (4-bit + LoRA):        性能 = 99.0%

性能差距:仅0.3%!
内存占用:从180GB → 48GB(减少73%)

为什么差距这么小?

  1. NF4量化误差小

    • NF4专门为正态分布设计
    • 大模型权重接近正态分布
    • 量化误差在可接受范围(~5%信息损失)
  2. LoRA表达能力强

    • 即使r=8,也有12K+参数
    • 足以学习任务知识 + 补偿误差
  3. 微调任务相对简单

    • 不需要"重新学习"基础能力
    • 只需适应特定领域

直观类比

想象一本书:

原书(全精度):
  文字清晰,所有细节完整

扫描版(量化):
  文字略模糊,但仍可阅读
  大部分信息保留(~95%)

扫描版 + 手写注释(QLoRA):
  模糊的地方用注释补充(LoRA学习)
  重点内容用注释强调(任务知识)

结果:
  虽然底层是扫描版(量化)
  但加上注释(LoRA)后
  信息完整度接近原书(99%)

什么时候QLoRA会有问题?

并非所有场景都适合QLoRA:

❌ 不适合的场景:
1. 从头预训练
   → 需要高精度累积大量知识
   → 量化损失太大

2. 需要极致性能
   → 0.3%的性能损失不可接受
   → 如竞赛、关键应用

3. 推理延迟敏感
   → 量化/反量化有额外开销
   → 实时系统可能不适合

✓ 适合的场景:
1. 资源受限的微调
   → 单GPU微调大模型

2. 快速实验和原型
   → 快速尝试不同任务

3. 多任务适配
   → 为每个任务训练一个小的LoRA

总结

QLoRA通过以下机制保持了性能:

  1. 量化的是冻结参数:不参与训练,只提供基础能力
  2. LoRA是高精度的:可以学习补偿量化误差
  3. 微调任务相对简单:不需要重新学习所有知识
  4. NF4量化优化:专门为神经网络权重分布设计,误差小

这使得QLoRA在仅用4-bit存储基础模型的情况下,性能损失<1%!

QLoRA的内存占用

以LLaMA-7B为例:

方法基础模型LoRA参数总内存
全参数微调(FP32)28 GB-~60 GB(含梯度和优化器状态)
全参数微调(FP16)14 GB-~30 GB
LoRA(FP16)14 GB~50 MB~18 GB
QLoRA(4-bit + LoRA)3.5 GB~50 MB~6 GB

效果:QLoRA让单个消费级GPU(如RTX 3090/4090,24GB显存)可以微调7B甚至13B的模型!

QLoRA的技术细节

1. NF4量化(4-bit NormalFloat)

QLoRA使用特殊的4-bit格式:NF4(4-bit NormalFloat)

传统INT4:均匀分布 {8,7,...,0,...,7}\{-8, -7, ..., 0, ..., 7\}

NF4:根据正态分布优化的非均匀分布

  • 神经网络的权重通常服从正态分布 N(0,σ2)\mathcal{N}(0, \sigma^2)
  • NF4在0附近分配更多的表示(更高精度)
  • 在远离0的地方分配更少的表示
NF4={1.0,0.6962,0.5251,0.3949,0.2844,0.1848,0.0911,0.0,0.0796,0.1609,0.2461,0.3379,0.4407,0.5626,0.7230,1.0}\text{NF4} = \{-1.0, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0911, 0.0, \\ \quad\quad\quad\quad 0.0796, 0.1609, 0.2461, 0.3379, 0.4407, 0.5626, 0.7230, 1.0\}

效果:相比INT4,NF4在相同bit数下精度损失更小。

2. 双重量化(Double Quantization)

QLoRA进一步量化量化参数本身

背景:量化需要存储缩放因子(scale)和零点(zero point):

xquant=round(xzs)x_{\text{quant}} = \text{round}\left(\frac{x - z}{s}\right)
  • ss:缩放因子(scale)
  • zz:零点(zero point)

每64个参数一组,需要存储一个 sszz(FP32)。

双重量化:将 sszz 也量化到8-bit!

squant=Quantize8bit(s)s_{\text{quant}} = \text{Quantize}_{8\text{bit}}(s)

节省空间

  • 原始:每64个参数需要 2×4=82 \times 4 = 8 bytes的量化参数
  • 双重量化:每64个参数需要 2×1=22 \times 1 = 2 bytes
  • 节省75%的量化参数开销

3. 分页优化器(Paged Optimizers)

训练时,优化器状态(如Adam的 mmvv)占用大量内存。

QLoRA的解决方案:使用CPU内存作为"虚拟内存"

  • 当GPU内存不足时,将优化器状态移到CPU
  • 需要时再移回GPU
  • 类似操作系统的分页机制

效果:防止OOM(Out of Memory),可以训练更大的模型。

QLoRA的训练代码

使用bitsandbytes库和PEFT库:

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 1. 配置4-bit量化
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # 使用4-bit量化
    bnb_4bit_quant_type="nf4",           # 使用NF4量化
    bnb_4bit_compute_dtype=torch.bfloat16,  # 计算时用BF16
    bnb_4bit_use_double_quant=True,      # 双重量化
)

# 2. 加载量化的模型
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto",                    # 自动分配到GPU
)

# 3. 准备模型进行k-bit训练
model = prepare_model_for_kbit_training(model)

# 4. 配置LoRA
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

# 5. 添加LoRA适配器
model = get_peft_model(model, lora_config)

# 6. 查看参数
model.print_trainable_parameters()
# 输出:trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.06%

# 7. 训练
from transformers import Trainer, TrainingArguments

training_args = TrainingArguments(
    output_dir="./qlora-llama2-7b",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    num_train_epochs=3,
    fp16=False,
    bf16=True,                           # 使用BF16训练LoRA
    logging_steps=10,
    optim="paged_adamw_32bit",           # 分页优化器
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)

trainer.train()

# 8. 保存LoRA权重
model.save_pretrained("./qlora-llama2-7b-final")

QLoRA的优缺点

优点

  1. 内存极致压缩

    • 在消费级GPU上训练大模型(7B-13B)
    • 内存需求降低到全参数微调的1/10
  2. 保持LoRA的所有优势

    • 参数高效
    • 存储高效
    • 防止遗忘
  3. 效果接近全参数微调

    • 论文显示在多个任务上与全参数微调性能相当
    • 甚至在某些任务上更好(正则化效果)

缺点

  1. ⚠️ 训练速度稍慢

    • 量化和反量化有额外开销
    • 约慢10%-20%(但可以用更大batch size补偿)
  2. ⚠️ 需要特殊库

    • 依赖bitsandbytes
    • 只支持CUDA(NVIDIA GPU)
  3. ⚠️ 推理时需要反量化

    • 如果不合并,推理稍慢
    • 通常会合并LoRA到量化模型

三种方法的全面对比

参数和资源对比

以LLaMA-7B(7B参数)为例:

方法可训练参数训练内存存储/任务训练速度推理速度
全参数微调7B (100%)~60 GB~14 GB基准基准
LoRA4M (0.06%)~18 GB~10 MB1.3x1x
QLoRA4M (0.06%)~6 GB~10 MB1.2x1x

效果对比

在MMLU(大规模多任务语言理解)基准上(LLaMA-7B):

方法MMLU准确率相对全参数
基础模型(无微调)35.1%-
全参数微调48.7%100%
LoRA (r=16)47.8%98.2%
QLoRA (r=64)48.3%99.2%

观察:LoRA和QLoRA的效果非常接近全参数微调!

使用场景建议

场景推荐方法原因
学术研究(GPU资源充足)全参数微调追求最佳性能
工业应用(少数任务)全参数微调可接受的成本,最佳效果
工业应用(多个任务)LoRA一个基础模型+多个LoRA
个人开发者QLoRA⭐消费级GPU可训练大模型
快速原型LoRA/QLoRA快速迭代
模型蒸馏的教师模型全参数微调需要最优性能
边缘设备部署QLoRA内存受限

实践建议和技巧

1. 数据准备

高质量 > 大数量

  • 1,000条高质量数据 > 10,000条低质量数据
  • 确保数据多样性
  • 格式统一(提示词模板)

示例:指令微调数据格式

{
  "instruction": "任务描述",
  "input": "可选的输入",
  "output": "期望的输出"
}

2. 超参数调优

从默认配置开始

# LoRA默认配置
lora_config = LoraConfig(
    r=8,                    # 大多数任务足够
    lora_alpha=16,          # alpha = 2*r
    target_modules=["q_proj", "v_proj"],  # 最重要的两个
    lora_dropout=0.05,
    bias="none",
)

# 训练参数
training_args = TrainingArguments(
    learning_rate=2e-4,     # LoRA用较大学习率
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
)

如果效果不够好

  1. 增大秩:r=81632r=8 \to 16 \to 32
  2. 增加目标模块:添加 k_proj, o_proj
  3. 调整学习率:2×1045×1042 \times 10^{-4} \to 5 \times 10^{-4}
  4. 增加训练轮数:3 → 5 epochs

3. 防止过拟合

症状:训练loss下降,验证loss上升

解决方案

  • 增加LoRA dropout(0.05 → 0.1)
  • 减少训练轮数
  • 使用更多数据
  • 降低学习率
  • 使用权重衰减(weight decay)

4. 多任务LoRA

场景:需要模型在多个任务间切换

方案:一个基础模型 + 多个LoRA适配器

# 训练任务A的LoRA
model_A = get_peft_model(base_model, lora_config_A)
trainer_A.train()
model_A.save_pretrained("./lora-task-A")

# 训练任务B的LoRA
model_B = get_peft_model(base_model, lora_config_B)
trainer_B.train()
model_B.save_pretrained("./lora-task-B")

# 推理时动态切换
base_model = AutoModelForCausalLM.from_pretrained("llama-2-7b")

# 使用任务A
model = PeftModel.from_pretrained(base_model, "./lora-task-A")
output_A = model.generate(input_A)

# 切换到任务B
model.unload()  # 卸载任务A的LoRA
model = PeftModel.from_pretrained(base_model, "./lora-task-B")
output_B = model.generate(input_B)

5. LoRA合并策略

何时合并?

  • 单任务部署:合并(推理更快)
  • 多任务部署:不合并(灵活切换)
  • 模型蒸馏:合并(作为教师模型)

如何合并?

# 方法1:直接合并
model = PeftModel.from_pretrained(base_model, "./lora-weights")
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./merged-model")

# 方法2:手动合并(更多控制)
for name, param in base_model.named_parameters():
    if name in lora_params:
        W_base = param.data
        A, B = lora_params[name]
        W_merged = W_base + (A @ B) * (alpha / r)
        param.data = W_merged

小结

  1. 后训练的必要性

    • Base Model只会续写,不会遵循指令
    • 需要通过微调学习指令遵循、对话、安全性
    • 分为SFT(监督微调)和RLHF(强化学习)两阶段
  2. 全参数微调

    • 更新所有参数
    • 效果最好,成本最高
    • 适合数据充足、资源充足的场景
  3. LoRA(Low-Rank Adaptation)

    • 核心思想:参数更新是低秩的,ΔW=AB\Delta W = A \cdot B
    • 只训练0.1%-1%的参数
    • 存储极小(每任务几MB)
    • 效果接近全参数微调(98%+性能)
    • 推荐设置:r=8r=8,应用于QKV矩阵
  4. QLoRA(Quantized LoRA)

    • 基础模型量化到4-bit(NF4格式)
    • LoRA适配器保持正常精度
    • 内存降低到全参数微调的1/10
    • 消费级GPU可训练7B-13B模型
    • 效果与全参数微调相当
  5. 三种方法对比(LLaMA-7B):

    方法可训练参数训练内存效果
    全参数7B60GB100%
    LoRA4M18GB98%
    QLoRA4M6GB99%
  6. 实践建议

    • 个人开发者:使用QLoRA(消费级GPU友好)
    • 工业多任务:使用LoRA(灵活切换)
    • 追求极致性能:全参数微调
    • 默认从 r=8r=8 的LoRA开始,根据需要调整
  7. 关键技术细节

    • LoRA的 BB 矩阵零初始化(训练开始时贡献为0)
    • QLoRA使用NF4量化(比INT4更适合神经网络)
    • 双重量化进一步压缩量化参数
    • 分页优化器防止OOM

LoRA和QLoRA的出现,让大模型微调从"少数公司的特权"变成"人人可做的事情"。这是大模型民主化的重要一步!