高辣浓情御书屋(
1.3MB · 2026-03-24
在上一章中,我们学习了大模型的预训练过程。预训练完成后,我们得到了一个基础模型(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)**是最直接的微调方法:在新任务的数据上,更新模型的所有参数。
类比:
监督微调的数据格式:指令-回答对
{
"instruction": "请将下面的英文翻译成中文",
"input": "The weather is nice today.",
"output": "今天天气很好。"
}
或者更简单的对话格式:
{
"prompt": "今天天气怎么样?",
"response": "今天天气不错,阳光明媚,适合外出活动。"
}
数据规模:
相比预训练,微调的设置通常更保守:
| 参数 | 预训练 | 全参数微调 |
|---|---|---|
| 学习率 | 1e-4 ~ 6e-4 | 1e-5 ~ 5e-5(更小) |
| Batch Size | 数百万Token | 数万到数十万Token |
| 训练步数 | 数十万到数百万步 | 数千到数万步 |
| Epoch数 | 通常1 epoch | 2-5 epochs |
| 学习率调度 | Warmup + Cosine | 线性衰减或Cosine |
为什么更保守?
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")
优点:
效果最好:
灵活性高:
缺点:
计算成本高:
存储成本高:
容易过拟合:
灾难性遗忘(Catastrophic Forgetting):
适合的场景:
示例:
全参数微调的成本太高,能否只更新一小部分参数,同时保持接近全参数微调的效果?
LoRA(Low-Rank Adaptation) 就是这样一种方法!
LoRA的核心洞察:微调时的参数更新矩阵往往是低秩的(Low-Rank)。
一个矩阵 是低秩的,意味着它可以分解为两个更小矩阵的乘积:
其中:
参数量对比:
其中:
这是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的核心假设,来自论文的关键洞察:
原因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更新:
回顾一下注意力机制中的查询矩阵:
其中:
更新整个 :
是通过梯度下降学到的更新。
冻结原始权重 (不更新),添加一个低秩更新:
其中:
前向传播:
拆解:
其中:
关键点:
重要: 和 的初始化方式确保训练开始时LoRA的贡献为0:
其中:
效果:训练开始时,
模型从预训练的权重开始,然后逐渐学习任务特定的调整!
Transformer中有很多权重矩阵,LoRA通常应用于:
以及输出投影:
实践建议:
| 配置 | 应用LoRA的层 | 参数量 | 效果 |
|---|---|---|---|
| 最小 | 仅, | 最少 | 不错 |
| 推荐⭐ | , , , | 适中 | 好 |
| 最大 | 所有注意力层 + MLP | 较多 | 最好 |
原因:注意力层对任务适配最重要,MLP层相对次要。
以GPT-2(12层,768维,12头)为例:
每层有4个注意力矩阵()和2个MLP矩阵():
说明:
加上Embedding和LayerNorm:总参数约117M
每层有3个LoRA对(, , ):
说明:
对比:
| 方法 | 可训练参数 | 占比 | 存储(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%的参数!
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()
实践中,我们使用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!
训练完成后,有两种使用方式:
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到基础权重
model = model.merge_and_unload()
# 保存合并后的模型(完整模型)
model.save_pretrained("./merged-model")
# 推理时不需要LoRA库
model = AutoModelForCausalLM.from_pretrained("./merged-model")
output = model.generate(inputs)
优点:
缺点:
优点:
参数高效:
存储高效:
训练快速:
防止遗忘:
动态切换:
缺点:
️ 效果略逊于全参数微调(但差距很小,<2%)
️ 推理时有额外计算(如果不合并):
️ 超参数敏感:
| 秩 | 参数量 | 效果 | 适用场景 |
|---|---|---|---|
| 4 | 最少 | 一般 | 数据极少(<1K),简单任务 |
| 8 | 少 | 好⭐ | 大多数任务(推荐) |
| 16 | 中等 | 很好 | 复杂任务,数据充足 |
| 32-64 | 较多 | 最好 | 高要求任务 |
| 128+ | 很多 | 接近全参数 | 不推荐(失去LoRA优势) |
经验法则:从 开始,如果效果不够好再增大。
通常设置为:
即 或
为什么α要设置为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设计中的一个巧妙选择。
LoRA可以使用比全参数微调更大的学习率:
| 方法 | 学习率范围 |
|---|---|
| 全参数微调 | 1e-5 ~ 5e-5 |
| LoRA | 1e-4 ~ 5e-4 |
原因:只更新少量参数,不容易破坏预训练知识。
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 = 量化的基础模型 + 正常精度的LoRA
具体做法:
基础模型量化到4-bit:
LoRA适配器保持FP16/BF16:
前向传播时动态反量化:
这是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量化误差小:
LoRA表达能力强:
微调任务相对简单:
直观类比:
想象一本书:
原书(全精度):
文字清晰,所有细节完整
扫描版(量化):
文字略模糊,但仍可阅读
大部分信息保留(~95%)
扫描版 + 手写注释(QLoRA):
模糊的地方用注释补充(LoRA学习)
重点内容用注释强调(任务知识)
结果:
虽然底层是扫描版(量化)
但加上注释(LoRA)后
信息完整度接近原书(99%)
什么时候QLoRA会有问题?
并非所有场景都适合QLoRA:
不适合的场景:
1. 从头预训练
→ 需要高精度累积大量知识
→ 量化损失太大
2. 需要极致性能
→ 0.3%的性能损失不可接受
→ 如竞赛、关键应用
3. 推理延迟敏感
→ 量化/反量化有额外开销
→ 实时系统可能不适合
适合的场景:
1. 资源受限的微调
→ 单GPU微调大模型
2. 快速实验和原型
→ 快速尝试不同任务
3. 多任务适配
→ 为每个任务训练一个小的LoRA
总结:
QLoRA通过以下机制保持了性能:
这使得QLoRA在仅用4-bit存储基础模型的情况下,性能损失<1%!
以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使用特殊的4-bit格式:NF4(4-bit NormalFloat)
传统INT4:均匀分布
NF4:根据正态分布优化的非均匀分布
效果:相比INT4,NF4在相同bit数下精度损失更小。
QLoRA进一步量化量化参数本身!
背景:量化需要存储缩放因子(scale)和零点(zero point):
每64个参数一组,需要存储一个 和 (FP32)。
双重量化:将 和 也量化到8-bit!
节省空间:
训练时,优化器状态(如Adam的 和 )占用大量内存。
QLoRA的解决方案:使用CPU内存作为"虚拟内存"
效果:防止OOM(Out of Memory),可以训练更大的模型。
使用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")
优点:
内存极致压缩:
保持LoRA的所有优势:
效果接近全参数微调:
缺点:
️ 训练速度稍慢:
️ 需要特殊库:
bitsandbytes库️ 推理时需要反量化:
以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 | 内存受限 |
高质量 > 大数量
示例:指令微调数据格式
{
"instruction": "任务描述",
"input": "可选的输入",
"output": "期望的输出"
}
从默认配置开始:
# 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症状:训练loss下降,验证loss上升
解决方案:
场景:需要模型在多个任务间切换
方案:一个基础模型 + 多个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)
何时合并?
如何合并?
# 方法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
后训练的必要性:
全参数微调:
LoRA(Low-Rank Adaptation):
QLoRA(Quantized LoRA):
三种方法对比(LLaMA-7B):
| 方法 | 可训练参数 | 训练内存 | 效果 |
|---|---|---|---|
| 全参数 | 7B | 60GB | 100% |
| LoRA | 4M | 18GB | 98% |
| QLoRA | 4M | 6GB | 99% |
实践建议:
关键技术细节:
LoRA和QLoRA的出现,让大模型微调从"少数公司的特权"变成"人人可做的事情"。这是大模型民主化的重要一步!