为什么需要后训练?
在上一章中,我们学习了大模型的预训练过程。预训练完成后,我们得到了一个基础模型(Base Model)。
Base Model的问题
回顾:Base Model只会"续写",不会"遵循指令"
示例:
用户输入:"请写一首关于春天的诗"
Base Model输出(续写模式):
erlang
请写一首关于春天的诗歌
请写一首关于夏天的诗歌
请写一首关于秋天的诗歌
...
我们期望的输出:
春风拂面暖人心,
万物复苏展新颜。
桃花盛开映碧水,
燕子归来舞翩翩。
问题的本质:
- Base Model学习的是统计规律:根据前文预测下一个词
- 没有学习指令遵循:理解用户意图并执行任务
- 没有学习对话模式:问答、多轮交互
- 没有学习安全性:避免有害、偏见的输出
后训练的目标
**后训练(Post-training)**是指在预训练的基础上,进一步训练模型,使其具备特定能力:
-
指令遵循(Instruction Following):
- 理解用户的指令
- 执行特定任务(写作、翻译、问答、代码生成等)
-
对话能力(Dialogue):
- 自然的多轮对话
- 上下文理解
- 合适的语气和风格
-
安全性(Safety):
- 拒绝有害请求
- 避免偏见和歧视
- 保护隐私
-
专业能力(Domain Expertise):
- 医疗、法律、金融等垂直领域
- 特定任务(客服、代码助手、写作助手)
后训练的两大阶段
现代大模型的后训练通常包括两个阶段:
csharp
预训练模型(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)**是最直接的微调方法:在新任务的数据上,更新模型的所有参数。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> θ fine-tuned = θ pretrained − η ⋅ ∇ θ L task \theta_{\text{fine-tuned}} = \theta_{\text{pretrained}} - \eta \cdot \nabla_\theta L_{\text{task}} </math>θfine-tuned=θpretrained−η⋅∇θLtask
- <math xmlns="http://www.w3.org/1998/Math/MathML"> θ pretrained \theta_{\text{pretrained}} </math>θpretrained:预训练模型的参数
- <math xmlns="http://www.w3.org/1998/Math/MathML"> L task L_{\text{task}} </math>Ltask:特定任务的损失函数
- 所有参数都参与更新
类比:
- 预训练:大学的通识教育(学习广泛的知识)
- 全参数微调:研究生的专业深造(在原有基础上,全方位深入学习特定领域)
训练过程
1. 数据准备
监督微调的数据格式:指令-回答对
json
{
"instruction": "请将下面的英文翻译成中文",
"input": "The weather is nice today.",
"output": "今天天气很好。"
}
或者更简单的对话格式:
json
{
"prompt": "今天天气怎么样?",
"response": "今天天气不错,阳光明媚,适合外出活动。"
}
数据规模:
- 小规模任务:1,000 - 10,000 条
- 通用指令遵循:10,000 - 100,000 条
- 对话模型:100,000 - 1,000,000 条
2. 训练设置
相比预训练,微调的设置通常更保守:
| 参数 | 预训练 | 全参数微调 |
|---|---|---|
| 学习率 | 1e-4 ~ 6e-4 | 1e-5 ~ 5e-5(更小) |
| Batch Size | 数百万Token | 数万到数十万Token |
| 训练步数 | 数十万到数百万步 | 数千到数万步 |
| Epoch数 | 通常1 epoch | 2-5 epochs |
| 学习率调度 | Warmup + Cosine | 线性衰减或Cosine |
为什么更保守?
- 预训练模型已经学到了丰富的知识
- 微调的目标是"适配"而不是"重新学习"
- 学习率太大会"遗忘"预训练的知识(灾难性遗忘)
3. 训练代码
python
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")
全参数微调的优缺点
优点:
-
✅ 效果最好:
- 所有参数都可以调整,表达能力最强
- 可以学习到任务特定的深层特征
-
✅ 灵活性高:
- 适用于各种任务
- 可以进行大幅度的适配
缺点:
-
❌ 计算成本高:
- 需要为每个任务训练一个完整的模型副本
- 例如:LLaMA-7B有7B参数,每个任务都需要7B参数的副本
-
❌ 存储成本高:
- 每个任务都需要存储完整的模型
- LLaMA-7B(FP16):~14GB/任务
- 10个任务:~140GB
-
❌ 容易过拟合:
- 数据量小时,容易遗忘预训练知识
- 需要careful的学习率调整
-
❌ 灾难性遗忘(Catastrophic Forgetting):
- 在新任务上训练会损害旧任务的性能
- 模型"忘记"预训练学到的通用知识
什么时候使用全参数微调?
适合的场景:
- 数据量充足(>100K样本)
- 计算资源充足
- 需要最佳性能
- 只有少数几个任务(<10个)
- 任务与预训练数据分布差异大(如特定领域:医疗、法律)
示例:
- 医疗问答系统(有大量医疗对话数据)
- 法律文书生成(有充足的法律文本)
- 公司内部的客服机器人(数据充足,计算资源不是问题)
LoRA:参数高效的微调方法
全参数微调的成本太高,能否只更新一小部分参数,同时保持接近全参数微调的效果?
LoRA(Low-Rank Adaptation) 就是这样一种方法!
核心思想
LoRA的核心洞察:微调时的参数更新矩阵往往是低秩的(Low-Rank)。
什么是低秩?
一个矩阵 <math xmlns="http://www.w3.org/1998/Math/MathML"> Δ W ∈ R d × d \Delta W \in \mathbb{R}^{d \times d} </math>ΔW∈Rd×d 是低秩的,意味着它可以分解为两个更小矩阵的乘积:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Δ W = A ⋅ B \Delta W = A \cdot B </math>ΔW=A⋅B
其中:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A ∈ R d × r A \in \mathbb{R}^{d \times r} </math>A∈Rd×r
- <math xmlns="http://www.w3.org/1998/Math/MathML"> B ∈ R r × d B \in \mathbb{R}^{r \times d} </math>B∈Rr×d
- <math xmlns="http://www.w3.org/1998/Math/MathML"> r ≪ d r \ll d </math>r≪d( <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r 远小于 <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d)
参数量对比:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Original Matrix: d × d parameters Low-rank: d × r + r × d = 2 d r parameters Reduction: 2 d r d 2 = 2 r d \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} </math>Original Matrix:Low-rank:Reduction:d×d parametersd×r+r×d=2dr parametersd22dr=d2r
其中:
- 原始矩阵需要 <math xmlns="http://www.w3.org/1998/Math/MathML"> d × d d \times d </math>d×d 个参数
- 低秩分解只需要 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 d r 2dr </math>2dr 个参数
- 参数减少比例: <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 r d \frac{2r}{d} </math>d2r(例如: <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 , d = 768 r=8, d=768 </math>r=8,d=768 时,只有 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 8 768 = 2.1 % \frac{2 \times 8}{768} = 2.1\% </math>7682×8=2.1%!)
深入理解:为什么ΔW可以是低秩,而W不行?
这是LoRA最关键的假设,值得深入理解。
核心区别:满秩 vs 低秩
预训练权重W:满秩(不能用小矩阵表示)
python
# 预训练的权重矩阵
W ∈ ℝ^(768×768)
# 如果尝试用两个小矩阵表示
W ≈ A × B # A ∈ ℝ^(768×8), B ∈ ℝ^(8×768)
为什么不行?
-
秩的限制:
pythonrank(W) ≈ 768 # 几乎是满秩,包含768个独立方向的信息 rank(A×B) ≤ min(rank(A), rank(B)) ≤ r = 8 # 只有8个独立方向的信息 # 信息损失:768维 → 8维 = 损失99%的信息! -
预训练权重包含的信息:
diffW_Q (Query权重) 需要编码: - 语法信息(主语、谓语、宾语的关系) - 语义信息(词义、上下文) - 位置信息(远近、先后) - 多头注意力(不同的关注模式) - 层次结构(浅层特征、深层特征) - ... 成千上万种语言模式 这些信息无法被压缩到8维空间! -
实验证明:
python# 如果用低秩矩阵替换W W_lowrank = A × B # r=8 结果: - 模型完全崩溃 - Perplexity从20.5 → 8532.1 - 输出变成乱码 原因:99%的信息丢失了
微调变化ΔW:低秩(可以用小矩阵表示)
python
# 微调时的权重变化
ΔW = W_finetuned - W_pretrained
# LoRA假设:这个变化是低秩的
ΔW ≈ A × B (r=8)
为什么ΔW可以是低秩的?
这是LoRA的核心假设,来自论文的关键洞察:
"我们假设在针对特定任务进行适应时,权重的更新具有低的'内在秩'(intrinsic rank)"
原因1:大部分知识已经学会了
预训练阶段(从零开始):
需要学习:所有语言知识 + 所有世界知识 + 所有推理能力
→ 需要高维空间(满秩,768维)
微调阶段(在预训练基础上):
只需要学习:
① 任务特定的微小调整
② 领域特定的适应
→ 只需要低维空间(低秩,8维)
原因2:任务的内在维度低
python
# 原始768维空间的作用
维度1-50: 语法结构
维度51-100: 词义理解
维度101-200:上下文建模
维度201-300:推理能力
维度301-400:世界知识
...
维度751-768:其他细微特征
# 微调成"医疗问答"时
需要重点调整的维度:
维度301-400(世界知识-医疗):★★★★★
维度51-100 (词义-医疗术语):★★★★
维度101-200(上下文):★★
其他维度:★ (几乎不需要变化)
→ 实际上只有少数几个"方向"需要显著调整
→ 这就是低秩的含义!
原因3:实验验证
来自LoRA论文的关键实验:
python
# 对全参数微调后的权重变化做奇异值分解(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就能捕获大部分变化
直观类比:
diff
预训练权重W = 整个图书馆
- 包含10万本不同的书
- 涵盖所有学科
- 每本书都独特、不可替代
- 如果只选8个书架(低秩)→ 99%的书都没了 ✗
微调变化ΔW = 图书更新
- 大部分书保持不变
- 只更新8个主题的书(比如:医学相关)
- 其他书籍不动
- 8个书架(低秩)足够存放需要更新的书 ✓
总结对比:
| 对比项 | 预训练权重 W | 微调变化 ΔW |
|---|---|---|
| 秩 | 高秩/满秩 (≈768) | 低秩 (≈8) |
| 信息内容 | 所有语言知识 | 任务特定调整 |
| 学习过程 | 从零开始 | 在现有基础上 |
| 是否可低秩分解 | ❌ 不行 | ✅ 可以 |
| 低秩分解后果 | 模型崩溃 | 性能几乎不变 |
| 原因 | 需要全部768维信息 | 只需少数几个维度 |
这就是LoRA的天才之处:识别出微调本质上是低秩的,从而大幅降低计算和存储成本!
具体例子
假设 <math xmlns="http://www.w3.org/1998/Math/MathML"> d = 768 d=768 </math>d=768(GPT-2的维度), <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 r=8 </math>r=8(LoRA的秩)
全参数更新:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W ′ = W + Δ W W' = W + \Delta W </math>W′=W+ΔW
<math xmlns="http://www.w3.org/1998/Math/MathML"> Δ W \Delta W </math>ΔW 需要 <math xmlns="http://www.w3.org/1998/Math/MathML"> 768 × 768 = 589 , 824 768 \times 768 = 589{,}824 </math>768×768=589,824 个参数
LoRA更新:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W ′ = W + A ⋅ B W' = W + A \cdot B </math>W′=W+A⋅B
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A: <math xmlns="http://www.w3.org/1998/Math/MathML"> 768 × 8 = 6 , 144 768 \times 8 = 6{,}144 </math>768×8=6,144 个参数
- <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B: <math xmlns="http://www.w3.org/1998/Math/MathML"> 8 × 768 = 6 , 144 8 \times 768 = 6{,}144 </math>8×768=6,144 个参数
- 总计: <math xmlns="http://www.w3.org/1998/Math/MathML"> 12 , 288 12{,}288 </math>12,288 个参数(只有全参数的2.1%!)
LoRA的数学形式
1. 原始的Transformer层
回顾一下注意力机制中的查询矩阵:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Q = X ⋅ W Q Q = X \cdot W_Q </math>Q=X⋅WQ
其中:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> X ∈ R n × d X \in \mathbb{R}^{n \times d} </math>X∈Rn×d:输入
- <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q ∈ R d × d W_Q \in \mathbb{R}^{d \times d} </math>WQ∈Rd×d:查询权重矩阵(预训练好的)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> Q ∈ R n × d Q \in \mathbb{R}^{n \times d} </math>Q∈Rn×d:查询向量
2. 全参数微调
更新整个 <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q W_Q </math>WQ:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W Q new = W Q + Δ W Q W_Q^{\text{new}} = W_Q + \Delta W_Q </math>WQnew=WQ+ΔWQ
<math xmlns="http://www.w3.org/1998/Math/MathML"> Δ W Q \Delta W_Q </math>ΔWQ 是通过梯度下降学到的更新。
3. LoRA微调
冻结原始权重 <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q W_Q </math>WQ(不更新),添加一个低秩更新:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W Q LoRA = W Q + α ⋅ A ⋅ B W_Q^{\text{LoRA}} = W_Q + \alpha \cdot A \cdot B </math>WQLoRA=WQ+α⋅A⋅B
其中:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q W_Q </math>WQ:冻结的预训练权重(不参与训练)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A ∈ R d × r A \in \mathbb{R}^{d \times r} </math>A∈Rd×r:可训练的低秩矩阵A
- <math xmlns="http://www.w3.org/1998/Math/MathML"> B ∈ R r × d B \in \mathbb{R}^{r \times d} </math>B∈Rr×d:可训练的低秩矩阵B
- <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r:秩(通常 <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 , 16 , 32 , 64 r=8, 16, 32, 64 </math>r=8,16,32,64)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> α \alpha </math>α:缩放因子(通常等于 <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r)
前向传播:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Q = X ⋅ W Q + X ⋅ ( A ⋅ B ) ⋅ α r Q = X \cdot W_Q + X \cdot (A \cdot B) \cdot \frac{\alpha}{r} </math>Q=X⋅WQ+X⋅(A⋅B)⋅rα
拆解:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Q = X ⋅ W Q ⏟ Original (frozen) + X ⋅ A ⋅ B ⋅ α r ⏟ LoRA (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} </math>Q=Original (frozen) X⋅WQ+LoRA (trainable) X⋅A⋅B⋅rα
其中:
- 第一项:原始路径(冻结,不更新)
- 第二项:LoRA路径(可训练)
关键点:
- 只有 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 参与训练
- <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q W_Q </math>WQ 保持不变
- 两条路径并行,最后相加
LoRA的初始化
重要 : <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 的初始化方式确保训练开始时LoRA的贡献为0:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> A ∼ N ( 0 , σ 2 ) B = 0 \begin{aligned} A &\sim \mathcal{N}(0, \sigma^2) \\ B &= 0 \end{aligned} </math>AB∼N(0,σ2)=0
其中:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A:正态分布初始化
- <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B:全零初始化!
效果 :训练开始时, <math xmlns="http://www.w3.org/1998/Math/MathML"> A ⋅ B = A ⋅ 0 = 0 A \cdot B = A \cdot 0 = 0 </math>A⋅B=A⋅0=0
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W Q LoRA = W Q + α ⋅ 0 = W Q W_Q^{\text{LoRA}} = W_Q + \alpha \cdot 0 = W_Q </math>WQLoRA=WQ+α⋅0=WQ
模型从预训练的权重开始,然后逐渐学习任务特定的调整!
LoRA应用到哪些层?
Transformer中有很多权重矩阵,LoRA通常应用于:
1. 注意力层的QKV矩阵
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Q = X ⋅ W Q + X ⋅ A Q ⋅ B Q K = X ⋅ W K + X ⋅ A K ⋅ B K V = X ⋅ W V + X ⋅ A V ⋅ B V \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} </math>QKV=X⋅WQ+X⋅AQ⋅BQ=X⋅WK+X⋅AK⋅BK=X⋅WV+X⋅AV⋅BV
以及输出投影:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> O = Attn ⋅ W O + Attn ⋅ A O ⋅ B O O = \text{Attn} \cdot W_O + \text{Attn} \cdot A_O \cdot B_O </math>O=Attn⋅WO+Attn⋅AO⋅BO
2. MLP层(可选)
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> h = GELU ( X ⋅ W 1 + X ⋅ A 1 ⋅ B 1 ) y = h ⋅ W 2 + h ⋅ A 2 ⋅ B 2 \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} </math>hy=GELU(X⋅W1+X⋅A1⋅B1)=h⋅W2+h⋅A2⋅B2
实践建议:
| 配置 | 应用LoRA的层 | 参数量 | 效果 |
|---|---|---|---|
| 最小 | 仅 <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q W_Q </math>WQ, <math xmlns="http://www.w3.org/1998/Math/MathML"> W V W_V </math>WV | 最少 | 不错 |
| 推荐⭐ | <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q W_Q </math>WQ, <math xmlns="http://www.w3.org/1998/Math/MathML"> W K W_K </math>WK, <math xmlns="http://www.w3.org/1998/Math/MathML"> W V W_V </math>WV, <math xmlns="http://www.w3.org/1998/Math/MathML"> W O W_O </math>WO | 适中 | 好 |
| 最大 | 所有注意力层 + MLP | 较多 | 最好 |
原因:注意力层对任务适配最重要,MLP层相对次要。
LoRA的参数量计算
以GPT-2(12层,768维,12头)为例:
全参数微调
每层有4个注意力矩阵( <math xmlns="http://www.w3.org/1998/Math/MathML"> W Q , W K , W V , W O W_Q, W_K, W_V, W_O </math>WQ,WK,WV,WO)和2个MLP矩阵( <math xmlns="http://www.w3.org/1998/Math/MathML"> W 1 , W 2 W_1, W_2 </math>W1,W2):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Attention: 4 × 768 × 768 = 2 , 359 , 296 MLP: 768 × 3072 + 3072 × 768 = 4 , 718 , 592 Per Layer: 7 , 077 , 888 12 Layers: 84 , 934 , 656 ≈ 85 M \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} </math>Attention:MLP:Per Layer:12 Layers:4×768×768=2,359,296768×3072+3072×768=4,718,5927,077,88884,934,656≈85M
说明:
- 注意力矩阵: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微调( <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 r=8 </math>r=8,仅QKV)
每层有3个LoRA对( <math xmlns="http://www.w3.org/1998/Math/MathML"> A Q , B Q A_Q, B_Q </math>AQ,BQ, <math xmlns="http://www.w3.org/1998/Math/MathML"> A K , B K A_K, B_K </math>AK,BK, <math xmlns="http://www.w3.org/1998/Math/MathML"> A V , B V A_V, B_V </math>AV,BV):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Per LoRA pair: 768 × 8 + 8 × 768 = 12 , 288 3 pairs per layer: 3 × 12 , 288 = 36 , 864 12 layers: 12 × 36 , 864 = 442 , 368 ≈ 0.44 M \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} </math>Per LoRA pair:3 pairs per layer:12 layers:768×8+8×768=12,2883×12,288=36,86412×36,864=442,368≈0.44M
说明:
- 每个LoRA对:768 × 8 + 8 × 768 = 12,288 参数
- 每层3对:3 × 12,288 = 36,864 参数
- 12层总计:12 × 36,864 = 442,368 ≈ 0.44M 参数
对比:
| 方法 | 可训练参数 | 占比 | 存储(FP16) |
|---|---|---|---|
| 全参数微调 | 117M | 100% | ~234 MB |
| LoRA(r=8,QKV) | 0.44M | 0.38% | ~0.9 MB |
| LoRA(r=16,QKV) | 0.88M | 0.75% | ~1.8 MB |
| LoRA(r=8,全部) | 1.3M | 1.1% | ~2.6 MB |
惊人的压缩率 :LoRA只需训练不到1%的参数!
LoRA的训练过程
完整流程
python
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库:
python
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
python
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到基础模型
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W merged = W base + α ⋅ A ⋅ B W_{\text{merged}} = W_{\text{base}} + \alpha \cdot A \cdot B </math>Wmerged=Wbase+α⋅A⋅B
python
# 合并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的优缺点
优点:
-
✅ 参数高效:
- 只需训练0.1%-1%的参数
- 极大降低内存需求
-
✅ 存储高效:
- 每个任务只需存储几MB的LoRA权重
- 一个基础模型 + N个LoRA = 支持N个任务
-
✅ 训练快速:
- 更少的参数需要更新
- 可以用更大的学习率
- 训练时间减少30%-50%
-
✅ 防止遗忘:
- 基础权重冻结,不会遗忘预训练知识
- 更鲁棒
-
✅ 动态切换:
- 可以在运行时切换不同的LoRA
- 一个模型,多种任务
缺点:
-
⚠️ 效果略逊于全参数微调(但差距很小,<2%)
-
⚠️ 推理时有额外计算(如果不合并):
- 需要计算 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ⋅ B A \cdot B </math>A⋅B
- 约5%-10%的推理延迟
-
⚠️ 超参数敏感:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r(秩)的选择影响效果
- 需要一定的调参经验
LoRA的超参数选择
1. 秩(Rank) <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r
| 秩 <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r | 参数量 | 效果 | 适用场景 |
|---|---|---|---|
| 4 | 最少 | 一般 | 数据极少(<1K),简单任务 |
| 8 | 少 | 好⭐ | 大多数任务(推荐) |
| 16 | 中等 | 很好 | 复杂任务,数据充足 |
| 32-64 | 较多 | 最好 | 高要求任务 |
| 128+ | 很多 | 接近全参数 | 不推荐(失去LoRA优势) |
经验法则 :从 <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 r=8 </math>r=8 开始,如果效果不够好再增大。
2. 缩放因子 <math xmlns="http://www.w3.org/1998/Math/MathML"> α \alpha </math>α
通常设置为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> α = r or α = 2 r \alpha = r \quad \text{or} \quad \alpha = 2r </math>α=rorα=2r
即 <math xmlns="http://www.w3.org/1998/Math/MathML"> α = r \alpha = r </math>α=r 或 <math xmlns="http://www.w3.org/1998/Math/MathML"> α = 2 r \alpha = 2r </math>α=2r
为什么α要设置为r或2r?深入解析
这个设置背后有深刻的数学和实践原因。
α的作用回顾:
python
W_new = W + α/r · A · B
↑ ↑
原始 缩放因子
原因1:归一化不同r值的更新尺度
python
# 不使用α(α=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:
python
# 使用α=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:
python
# A的初始化
A ~ N(0, σ²) # 高斯分布
# B的初始化
B = 0 # 全零
# 训练开始时
A·B = A·0 = 0
→ W_new = W + α/r · 0 = W
# 确保训练初始状态 = 预训练模型(不破坏原有知识)
训练过程中,A·B的数值大小(magnitude)与r有关:
python
# r越大,A和B越"宽"
# 矩阵乘法后,值累加更多
# magnitude(A·B) ∝ √r
# 因此需要除以r来归一化
# α/r 保证了最终更新的尺度与r无关
原因3:实验验证
来自LoRA原论文的关键实验:
erlang
实验设置:
- 模型:RoBERTa-base
- 任务:GLUE benchmark
- 测试不同α值
结果:
α = r/2 → 性能 85.2%
α = r → 性能 86.8% ⭐ 最佳
α = 2r → 性能 86.9% ⭐ 最佳
α = 4r → 性能 86.5%
α = 8r → 性能 85.8%
结论:α=r或α=2r效果最好,超出这个范围性能下降
为什么α=r是"甜点"?
markdown
α < r: LoRA更新太保守
→ 学习速度慢
→ 可能欠拟合
α = r: LoRA更新适中
→ 平衡学习速度和稳定性 ✓
→ 90%场景的最佳选择
α = 2r: LoRA更新稍强
→ 适合需要强适应的任务 ✓
→ 如领域跨度很大的微调
α > 2r: LoRA更新太激进
→ 可能破坏预训练知识
→ 训练不稳定
直观理解:
α相当于LoRA的"学习率倍数":
python
# 参数更新的总效果
对于普通参数:θ_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的学习率
实践建议:
python
# 默认设置(适合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,可能太大
...
)
调参优先级:
markdown
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 |
| LoRA | 1e-4 ~ 5e-4 |
原因:只更新少量参数,不容易破坏预训练知识。
QLoRA:量化+LoRA = 极致压缩
LoRA已经很高效了,但能否进一步降低成本?QLoRA 的答案是:结合量化!
什么是量化?
**量化(Quantization)**是指用更低精度的数据类型存储参数:
| 数据类型 | 位数 | 范围 | 存储/参数 |
|---|---|---|---|
| FP32(单精度浮点) | 32位 | ~ <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 0 − 38 10^{-38} </math>10−38 to <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 0 38 10^{38} </math>1038 | 4 bytes |
| FP16(半精度浮点) | 16位 | ~ <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 0 − 8 10^{-8} </math>10−8 to <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 0 4 10^{4} </math>104 | 2 bytes |
| INT8(8位整数) | 8位 | -128 to 127 | 1 byte |
| INT4(4位整数) | 4位 | -8 to 7 | 0.5 bytes |
关键:量化可以大幅减少模型的存储和内存占用,代价是精度略有损失。
QLoRA的核心思想
QLoRA = 量化的基础模型 + 正常精度的LoRA
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> W QLoRA = Quantize ( W base ) + A ⋅ B W^{\text{QLoRA}} = \text{Quantize}(W_{\text{base}}) + A \cdot B </math>WQLoRA=Quantize(Wbase)+A⋅B
具体做法:
-
基础模型量化到4-bit:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> W base W_{\text{base}} </math>Wbase 用INT4存储
- 内存占用减少到1/8(相比FP32)
- 只在推理时使用,不参与训练
-
LoRA适配器保持FP16/BF16:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 用正常精度
- 参与训练和梯度更新
-
前向传播时动态反量化:
- 将INT4的权重临时转换回FP16进行计算
- 计算完成后丢弃
深入理解:为什么量化基础模型不会损失太多能力?
这是QLoRA最令人惊讶的地方:把基础模型压缩到4-bit,为什么性能损失很小?
关键理解:量化确实有损失,但可以被LoRA补偿
QLoRA的架构本质:
scss
┌────────────────────────────────────────┐
│ 量化的基础模型 (INT4, 冻结) │
│ ↓ │
│ 信息有损失,但不更新 │
└────────────────────────────────────────┘
+
┌────────────────────────────────────────┐
│ LoRA适配层 (FP16/BF16, 可训练) │
│ ↓ │
│ 高精度,学习补偿量化损失 │
└────────────────────────────────────────┘
原因1:量化的确实有损失
python
# 原始权重 (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:为什么损失可以接受?
关键洞察:微调 ≠ 从头训练
scss
从头训练:
需要学习所有知识
→ 需要高精度参数存储所有细节
微调:
基础知识已存在(在量化的权重中)
只需学习:
① 新任务的特定知识
② 补偿量化误差
→ LoRA层(高精度)足以完成
具体例子:
python
# 场景:把GPT-4微调成医疗问答助手
# 基础模型(量化的)仍然包含:
✓ 语言理解能力(虽然有量化误差,但大体保留)
✓ 通用知识(基本医学概念虽然模糊,但存在)
✓ 推理能力(逻辑链条大致完整)
# 这些能力即使量化后仍然保留大部分(~95%)
# LoRA层(高精度)需要学习:
→ 医疗术语的精确用法
→ 医疗领域的推理模式
→ 补偿量化带来的小误差
# 这些只需少量参数(LoRA)就能学会
原因3:LoRA如何补偿量化损失?
python
# 前向传播的完整过程
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. 补偿量化误差的调整
训练过程中的自适应:
yaml
Epoch 1:
量化误差导致输出偏差
→ LoRA梯度更新
→ 学会部分补偿量化误差
Epoch 2:
偏差减小
→ 继续学习补偿 + 学习任务知识
...
最终:
LoRA层 ≈ 任务调整 + 量化误差补偿
原因4:实验证据
根据QLoRA论文的实验结果:
erlang
模型:LLaMA-65B
任务:多个NLP benchmark
全精度微调 (FP16): 性能 = 100%
LoRA (FP16): 性能 = 99.3%
QLoRA (4-bit + LoRA): 性能 = 99.0%
性能差距:仅0.3%!
内存占用:从180GB → 48GB(减少73%)
为什么差距这么小?
-
NF4量化误差小:
- NF4专门为正态分布设计
- 大模型权重接近正态分布
- 量化误差在可接受范围(~5%信息损失)
-
LoRA表达能力强:
- 即使r=8,也有12K+参数
- 足以学习任务知识 + 补偿误差
-
微调任务相对简单:
- 不需要"重新学习"基础能力
- 只需适应特定领域
直观类比:
erlang
想象一本书:
原书(全精度):
文字清晰,所有细节完整
扫描版(量化):
文字略模糊,但仍可阅读
大部分信息保留(~95%)
扫描版 + 手写注释(QLoRA):
模糊的地方用注释补充(LoRA学习)
重点内容用注释强调(任务知识)
结果:
虽然底层是扫描版(量化)
但加上注释(LoRA)后
信息完整度接近原书(99%)
什么时候QLoRA会有问题?
并非所有场景都适合QLoRA:
python
❌ 不适合的场景:
1. 从头预训练
→ 需要高精度累积大量知识
→ 量化损失太大
2. 需要极致性能
→ 0.3%的性能损失不可接受
→ 如竞赛、关键应用
3. 推理延迟敏感
→ 量化/反量化有额外开销
→ 实时系统可能不适合
✓ 适合的场景:
1. 资源受限的微调
→ 单GPU微调大模型
2. 快速实验和原型
→ 快速尝试不同任务
3. 多任务适配
→ 为每个任务训练一个小的LoRA
总结:
QLoRA通过以下机制保持了性能:
- 量化的是冻结参数:不参与训练,只提供基础能力
- LoRA是高精度的:可以学习补偿量化误差
- 微调任务相对简单:不需要重新学习所有知识
- 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 :均匀分布 <math xmlns="http://www.w3.org/1998/Math/MathML"> { − 8 , − 7 , . . . , 0 , . . . , 7 } \{-8, -7, ..., 0, ..., 7\} </math>{−8,−7,...,0,...,7}
NF4:根据正态分布优化的非均匀分布
- 神经网络的权重通常服从正态分布 <math xmlns="http://www.w3.org/1998/Math/MathML"> N ( 0 , σ 2 ) \mathcal{N}(0, \sigma^2) </math>N(0,σ2)
- NF4在0附近分配更多的表示(更高精度)
- 在远离0的地方分配更少的表示
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 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\} </math>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}
效果:相比INT4,NF4在相同bit数下精度损失更小。
2. 双重量化(Double Quantization)
QLoRA进一步量化量化参数本身!
背景:量化需要存储缩放因子(scale)和零点(zero point):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x quant = round ( x − z s ) x_{\text{quant}} = \text{round}\left(\frac{x - z}{s}\right) </math>xquant=round(sx−z)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> s s </math>s:缩放因子(scale)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> z z </math>z:零点(zero point)
每64个参数一组,需要存储一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> s s </math>s 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> z z </math>z(FP32)。
双重量化 :将 <math xmlns="http://www.w3.org/1998/Math/MathML"> s s </math>s 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> z z </math>z 也量化到8-bit!
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> s quant = Quantize 8 bit ( s ) s_{\text{quant}} = \text{Quantize}_{8\text{bit}}(s) </math>squant=Quantize8bit(s)
节省空间:
- 原始:每64个参数需要 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 4 = 8 2 \times 4 = 8 </math>2×4=8 bytes的量化参数
- 双重量化:每64个参数需要 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 1 = 2 2 \times 1 = 2 </math>2×1=2 bytes
- 节省75%的量化参数开销
3. 分页优化器(Paged Optimizers)
训练时,优化器状态(如Adam的 <math xmlns="http://www.w3.org/1998/Math/MathML"> m m </math>m 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v)占用大量内存。
QLoRA的解决方案:使用CPU内存作为"虚拟内存"
- 当GPU内存不足时,将优化器状态移到CPU
- 需要时再移回GPU
- 类似操作系统的分页机制
效果:防止OOM(Out of Memory),可以训练更大的模型。
QLoRA的训练代码
使用bitsandbytes库和PEFT库:
python
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的优缺点
优点:
-
✅ 内存极致压缩:
- 在消费级GPU上训练大模型(7B-13B)
- 内存需求降低到全参数微调的1/10
-
✅ 保持LoRA的所有优势:
- 参数高效
- 存储高效
- 防止遗忘
-
✅ 效果接近全参数微调:
- 论文显示在多个任务上与全参数微调性能相当
- 甚至在某些任务上更好(正则化效果)
缺点:
-
⚠️ 训练速度稍慢:
- 量化和反量化有额外开销
- 约慢10%-20%(但可以用更大batch size补偿)
-
⚠️ 需要特殊库:
- 依赖
bitsandbytes库 - 只支持CUDA(NVIDIA GPU)
- 依赖
-
⚠️ 推理时需要反量化:
- 如果不合并,推理稍慢
- 通常会合并LoRA到量化模型
三种方法的全面对比
参数和资源对比
以LLaMA-7B(7B参数)为例:
| 方法 | 可训练参数 | 训练内存 | 存储/任务 | 训练速度 | 推理速度 |
|---|---|---|---|---|---|
| 全参数微调 | 7B (100%) | ~60 GB | ~14 GB | 基准 | 基准 |
| LoRA | 4M (0.06%) | ~18 GB | ~10 MB | 1.3x | 1x |
| QLoRA | 4M (0.06%) | ~6 GB | ~10 MB | 1.2x | 1x |
效果对比
在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条低质量数据
- 确保数据多样性
- 格式统一(提示词模板)
示例:指令微调数据格式
json
{
"instruction": "任务描述",
"input": "可选的输入",
"output": "期望的输出"
}
2. 超参数调优
从默认配置开始:
python
# 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,
)
如果效果不够好:
- 增大秩: <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 → 16 → 32 r=8 \to 16 \to 32 </math>r=8→16→32
- 增加目标模块:添加
k_proj,o_proj - 调整学习率: <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 1 0 − 4 → 5 × 1 0 − 4 2 \times 10^{-4} \to 5 \times 10^{-4} </math>2×10−4→5×10−4
- 增加训练轮数:3 → 5 epochs
3. 防止过拟合
症状:训练loss下降,验证loss上升
解决方案:
- 增加LoRA dropout(0.05 → 0.1)
- 减少训练轮数
- 使用更多数据
- 降低学习率
- 使用权重衰减(weight decay)
4. 多任务LoRA
场景:需要模型在多个任务间切换
方案:一个基础模型 + 多个LoRA适配器
python
# 训练任务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合并策略
何时合并?
- 单任务部署:合并(推理更快)
- 多任务部署:不合并(灵活切换)
- 模型蒸馏:合并(作为教师模型)
如何合并?
python
# 方法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
小结
-
后训练的必要性:
- Base Model只会续写,不会遵循指令
- 需要通过微调学习指令遵循、对话、安全性
- 分为SFT(监督微调)和RLHF(强化学习)两阶段
-
全参数微调:
- 更新所有参数
- 效果最好,成本最高
- 适合数据充足、资源充足的场景
-
LoRA(Low-Rank Adaptation):
- 核心思想:参数更新是低秩的, <math xmlns="http://www.w3.org/1998/Math/MathML"> Δ W = A ⋅ B \Delta W = A \cdot B </math>ΔW=A⋅B
- 只训练0.1%-1%的参数
- 存储极小(每任务几MB)
- 效果接近全参数微调(98%+性能)
- 推荐设置: <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 r=8 </math>r=8,应用于QKV矩阵
-
QLoRA(Quantized LoRA):
- 基础模型量化到4-bit(NF4格式)
- LoRA适配器保持正常精度
- 内存降低到全参数微调的1/10
- 消费级GPU可训练7B-13B模型
- 效果与全参数微调相当
-
三种方法对比(LLaMA-7B):
方法 可训练参数 训练内存 效果 全参数 7B 60GB 100% LoRA 4M 18GB 98% QLoRA 4M 6GB 99% -
实践建议:
- 个人开发者:使用QLoRA(消费级GPU友好)
- 工业多任务:使用LoRA(灵活切换)
- 追求极致性能:全参数微调
- 默认从 <math xmlns="http://www.w3.org/1998/Math/MathML"> r = 8 r=8 </math>r=8 的LoRA开始,根据需要调整
-
关键技术细节:
- LoRA的 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 矩阵零初始化(训练开始时贡献为0)
- QLoRA使用NF4量化(比INT4更适合神经网络)
- 双重量化进一步压缩量化参数
- 分页优化器防止OOM
LoRA和QLoRA的出现,让大模型微调从"少数公司的特权"变成"人人可做的事情"。这是大模型民主化的重要一步!