为什么需要后训练?
在上一章中,我们学习了大模型的预训练过程。预训练完成后,我们得到了一个基础模型(Base Model)。
Base Model的问题
回顾:Base Model只会"续写",不会"遵循指令"
示例:
用户输入:"请写一首关于春天的诗"
Base Model输出(续写模式):
请写一首关于春天的诗歌
请写一首关于夏天的诗歌
请写一首关于秋天的诗歌
...
我们期望的输出:
春风拂面暖人心,
万物复苏展新颜。
桃花盛开映碧水,
燕子归来舞翩翩。
问题的本质:
- Base Model学习的是统计规律:根据前文预测下一个词
- 没有学习指令遵循:理解用户意图并执行任务
- 没有学习对话模式:问答、多轮交互
- 没有学习安全性:避免有害、偏见的输出
后训练的目标
**后训练(Post-training)**是指在预训练的基础上,进一步训练模型,使其具备特定能力:
-
指令遵循(Instruction Following):
- 理解用户的指令
- 执行特定任务(写作、翻译、问答、代码生成等)
-
对话能力(Dialogue):
- 自然的多轮对话
- 上下文理解
- 合适的语气和风格
-
安全性(Safety):
- 拒绝有害请求
- 避免偏见和歧视
- 保护隐私
-
专业能力(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)**是最直接的微调方法:在新任务的数据上,更新模型的所有参数。
- :预训练模型的参数
- :特定任务的损失函数
- 所有参数都参与更新
类比:
- 预训练:大学的通识教育(学习广泛的知识)
- 全参数微调:研究生的专业深造(在原有基础上,全方位深入学习特定领域)
训练过程
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-4 | 1e-5 ~ 5e-5(更小) |
| Batch Size | 数百万Token | 数万到数十万Token |
| 训练步数 | 数十万到数百万步 | 数千到数万步 |
| Epoch数 | 通常1 epoch | 2-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")
全参数微调的优缺点
优点:
-
✅ 效果最好:
- 所有参数都可以调整,表达能力最强
- 可以学习到任务特定的深层特征
-
✅ 灵活性高:
- 适用于各种任务
- 可以进行大幅度的适配
缺点:
-
❌ 计算成本高:
- 需要为每个任务训练一个完整的模型副本
- 例如:LLaMA-7B有7B参数,每个任务都需要7B参数的副本
-
❌ 存储成本高:
- 每个任务都需要存储完整的模型
- LLaMA-7B(FP16):~14GB/任务
- 10个任务:~140GB
-
❌ 容易过拟合:
- 数据量小时,容易遗忘预训练知识
- 需要careful的学习率调整
-
❌ 灾难性遗忘(Catastrophic Forgetting):
- 在新任务上训练会损害旧任务的性能
- 模型"忘记"预训练学到的通用知识
什么时候使用全参数微调?
适合的场景:
- 数据量充足(>100K样本)
- 计算资源充足
- 需要最佳性能
- 只有少数几个任务(<10个)
- 任务与预训练数据分布差异大(如特定领域:医疗、法律)
示例:
- 医疗问答系统(有大量医疗对话数据)
- 法律文书生成(有充足的法律文本)
- 公司内部的客服机器人(数据充足,计算资源不是问题)
LoRA:参数高效的微调方法
全参数微调的成本太高,能否只更新一小部分参数,同时保持接近全参数微调的效果?
LoRA(Low-Rank Adaptation) 就是这样一种方法!
核心思想
LoRA的核心洞察:微调时的参数更新矩阵往往是低秩的(Low-Rank)。
什么是低秩?
一个矩阵 是低秩的,意味着它可以分解为两个更小矩阵的乘积:
其中:
- ( 远小于 )
参数量对比:
其中:
- 原始矩阵需要 个参数
- 低秩分解只需要 个参数
- 参数减少比例:(例如: 时,只有 !)
深入理解:为什么ΔW可以是低秩,而W不行?
这是LoRA最关键的假设,值得深入理解。
核心区别:满秩 vs 低秩
预训练权重W:满秩(不能用小矩阵表示)
# 预训练的权重矩阵
W ∈ ℝ^(768×768)
# 如果尝试用两个小矩阵表示
W ≈ A × B # A ∈ ℝ^(768×8), B ∈ ℝ^(8×768)
为什么不行?
-
秩的限制:
rank(W) ≈ 768 # 几乎是满秩,包含768个独立方向的信息 rank(A×B) ≤ min(rank(A), rank(B)) ≤ r = 8 # 只有8个独立方向的信息 # 信息损失:768维 → 8维 = 损失99%的信息! -
预训练权重包含的信息:
W_Q (Query权重) 需要编码: - 语法信息(主语、谓语、宾语的关系) - 语义信息(词义、上下文) - 位置信息(远近、先后) - 多头注意力(不同的关注模式) - 层次结构(浅层特征、深层特征) - ... 成千上万种语言模式 这些信息无法被压缩到8维空间! -
实验证明:
# 如果用低秩矩阵替换W W_lowrank = A × B # r=8 结果: - 模型完全崩溃 - Perplexity从20.5 → 8532.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的天才之处:识别出微调本质上是低秩的,从而大幅降低计算和存储成本!
具体例子
假设 (GPT-2的维度),(LoRA的秩)
全参数更新:
需要 个参数
LoRA更新:
- : 个参数
- : 个参数
- 总计: 个参数(只有全参数的2.1%!)
LoRA的数学形式
1. 原始的Transformer层
回顾一下注意力机制中的查询矩阵:
其中:
- :输入
- :查询权重矩阵(预训练好的)
- :查询向量
2. 全参数微调
更新整个 :
是通过梯度下降学到的更新。
3. LoRA微调
冻结原始权重 (不更新),添加一个低秩更新:
其中:
- :冻结的预训练权重(不参与训练)
- :可训练的低秩矩阵A
- :可训练的低秩矩阵B
- :秩(通常 )
- :缩放因子(通常等于 )
前向传播:
拆解:
其中:
- 第一项:原始路径(冻结,不更新)
- 第二项:LoRA路径(可训练)
关键点:
- 只有 和 参与训练
- 保持不变
- 两条路径并行,最后相加
LoRA的初始化
重要: 和 的初始化方式确保训练开始时LoRA的贡献为0:
其中:
- :正态分布初始化
- :全零初始化!
效果:训练开始时,
模型从预训练的权重开始,然后逐渐学习任务特定的调整!
LoRA应用到哪些层?
Transformer中有很多权重矩阵,LoRA通常应用于:
1. 注意力层的QKV矩阵
以及输出投影:
2. MLP层(可选)
实践建议:
| 配置 | 应用LoRA的层 | 参数量 | 效果 |
|---|---|---|---|
| 最小 | 仅, | 最少 | 不错 |
| 推荐⭐ | , , , | 适中 | 好 |
| 最大 | 所有注意力层 + MLP | 较多 | 最好 |
原因:注意力层对任务适配最重要,MLP层相对次要。
LoRA的参数量计算
以GPT-2(12层,768维,12头)为例:
全参数微调
每层有4个注意力矩阵()和2个MLP矩阵():
说明:
- 注意力矩阵: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微调(,仅QKV)
每层有3个LoRA对(, , ):
说明:
- 每个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的训练过程
完整流程
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到基础模型
# 合并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%)
-
⚠️ 推理时有额外计算(如果不合并):
- 需要计算
- 约5%-10%的推理延迟
-
⚠️ 超参数敏感:
- (秩)的选择影响效果
- 需要一定的调参经验
LoRA的超参数选择
1. 秩(Rank)
| 秩 | 参数量 | 效果 | 适用场景 |
|---|---|---|---|
| 4 | 最少 | 一般 | 数据极少(<1K),简单任务 |
| 8 | 少 | 好⭐ | 大多数任务(推荐) |
| 16 | 中等 | 很好 | 复杂任务,数据充足 |
| 32-64 | 较多 | 最好 | 高要求任务 |
| 128+ | 很多 | 接近全参数 | 不推荐(失去LoRA优势) |
经验法则:从 开始,如果效果不够好再增大。
2. 缩放因子
通常设置为:
即 或
为什么α要设置为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 |
| LoRA | 1e-4 ~ 5e-4 |
原因:只更新少量参数,不容易破坏预训练知识。
QLoRA:量化+LoRA = 极致压缩
LoRA已经很高效了,但能否进一步降低成本?QLoRA的答案是:结合量化!
什么是量化?
**量化(Quantization)**是指用更低精度的数据类型存储参数:
| 数据类型 | 位数 | 范围 | 存储/参数 |
|---|---|---|---|
| FP32(单精度浮点) | 32位 | ~ to | 4 bytes |
| FP16(半精度浮点) | 16位 | ~ to | 2 bytes |
| INT8(8位整数) | 8位 | -128 to 127 | 1 byte |
| INT4(4位整数) | 4位 | -8 to 7 | 0.5 bytes |
关键:量化可以大幅减少模型的存储和内存占用,代价是精度略有损失。
QLoRA的核心思想
QLoRA = 量化的基础模型 + 正常精度的LoRA
具体做法:
-
基础模型量化到4-bit:
- 用INT4存储
- 内存占用减少到1/8(相比FP32)
- 只在推理时使用,不参与训练
-
LoRA适配器保持FP16/BF16:
- 和 用正常精度
- 参与训练和梯度更新
-
前向传播时动态反量化:
- 将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%)
为什么差距这么小?
-
NF4量化误差小:
- NF4专门为正态分布设计
- 大模型权重接近正态分布
- 量化误差在可接受范围(~5%信息损失)
-
LoRA表达能力强:
- 即使r=8,也有12K+参数
- 足以学习任务知识 + 补偿误差
-
微调任务相对简单:
- 不需要"重新学习"基础能力
- 只需适应特定领域
直观类比:
想象一本书:
原书(全精度):
文字清晰,所有细节完整
扫描版(量化):
文字略模糊,但仍可阅读
大部分信息保留(~95%)
扫描版 + 手写注释(QLoRA):
模糊的地方用注释补充(LoRA学习)
重点内容用注释强调(任务知识)
结果:
虽然底层是扫描版(量化)
但加上注释(LoRA)后
信息完整度接近原书(99%)
什么时候QLoRA会有问题?
并非所有场景都适合QLoRA:
❌ 不适合的场景:
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:均匀分布
NF4:根据正态分布优化的非均匀分布
- 神经网络的权重通常服从正态分布
- NF4在0附近分配更多的表示(更高精度)
- 在远离0的地方分配更少的表示
效果:相比INT4,NF4在相同bit数下精度损失更小。
2. 双重量化(Double Quantization)
QLoRA进一步量化量化参数本身!
背景:量化需要存储缩放因子(scale)和零点(zero point):
- :缩放因子(scale)
- :零点(zero point)
每64个参数一组,需要存储一个 和 (FP32)。
双重量化:将 和 也量化到8-bit!
节省空间:
- 原始:每64个参数需要 bytes的量化参数
- 双重量化:每64个参数需要 bytes
- 节省75%的量化参数开销
3. 分页优化器(Paged Optimizers)
训练时,优化器状态(如Adam的 和 )占用大量内存。
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的优缺点
优点:
-
✅ 内存极致压缩:
- 在消费级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条低质量数据
- 确保数据多样性
- 格式统一(提示词模板)
示例:指令微调数据格式
{
"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,
)
如果效果不够好:
- 增大秩:
- 增加目标模块:添加
k_proj,o_proj - 调整学习率:
- 增加训练轮数: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
小结
-
后训练的必要性:
- Base Model只会续写,不会遵循指令
- 需要通过微调学习指令遵循、对话、安全性
- 分为SFT(监督微调)和RLHF(强化学习)两阶段
-
全参数微调:
- 更新所有参数
- 效果最好,成本最高
- 适合数据充足、资源充足的场景
-
LoRA(Low-Rank Adaptation):
- 核心思想:参数更新是低秩的,
- 只训练0.1%-1%的参数
- 存储极小(每任务几MB)
- 效果接近全参数微调(98%+性能)
- 推荐设置:,应用于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(灵活切换)
- 追求极致性能:全参数微调
- 默认从 的LoRA开始,根据需要调整
-
关键技术细节:
- LoRA的 矩阵零初始化(训练开始时贡献为0)
- QLoRA使用NF4量化(比INT4更适合神经网络)
- 双重量化进一步压缩量化参数
- 分页优化器防止OOM
LoRA和QLoRA的出现,让大模型微调从"少数公司的特权"变成"人人可做的事情"。这是大模型民主化的重要一步!