阶段 6:LoRA 参数高效微调
教学目标
- 理解 LoRA(Low-Rank Adaptation)的核心原理和数学推导
- 掌握 LoRA 的实现方式(A、B 矩阵分解)
- 理解 LoRA 与全量微调的区别和各自适用场景
- 能够运行 MiniMind 的原生 LoRA 训练(无需 peft 依赖)
- 掌握 LoRA 权重合并与模型导出流程
课时安排
- 理论讲解:1 课时(LoRA 原理 + 数学推导)
- 实验对比:1 课时(LoRA vs 全量微调实验)
6.1 LoRA 的动机
6.1.1 全量微调的挑战
| 挑战 | 说明 |
|---|---|
| 显存需求大 | 需要存储所有参数的梯度(64M 模型需要约 0.5GB 额外显存存梯度) |
| 训练速度慢 | 所有参数都需要更新 |
| 存储成本高 | 每个任务微调后需要保存完整的模型副本 |
| 灾难性遗忘 | 全量微调可能导致模型忘记预训练知识 |
6.1.2 LoRA 的核心假设
关键假设 :预训练模型在适配下游任务时,权重的变化量(ΔW)是低秩的。
即:虽然模型参数矩阵 W 很大,但在微调过程中的变化量 ΔW 可以用两个小矩阵的乘积近似:
plain
ΔW = W_new - W_original ≈ B × A
其中:
W ∈ ℝ^{d×d} (原始权重矩阵,d=768)
A ∈ ℝ^{d×r} (降维矩阵,r << d)
B ∈ ℝ^{r×d} (升维矩阵,r << d)
r = LoRA rank (秩,通常 4-64)
6.1.3 LoRA 参数量对比
plain
全量微调:
可训练参数 = d × d = 768 × 768 = 589,824(每层一个线性层)
LoRA 微调(rank=8):
可训练参数 = d × r + r × d = 768 × 8 + 8 × 768 = 12,288(每层)
节省比例:12,288 / 589,824 ≈ 2.1%(仅 2% 的参数需要训练!)
6.2 LoRA 数学原理
6.2.1 线性层的 LoRA 改造
原始线性层:
plain
y = W × x
W ∈ ℝ^{out×in}
加入 LoRA 后:
plain
y = W × x + (B × A) × x
= (W + B × A) × x
其中:
A ∈ ℝ^{r×in} (初始化为随机高斯分布)
B ∈ ℝ^{out×r} (初始化为零矩阵)
因此,训练开始时:
y = W × x + 0 × x = W × x (与原始模型行为完全一致)
关键设计 :B 初始化为零,确保 LoRA 训练开始时模型行为不变,实现安全启动。
6.2.2 LoRA 在 MiniMind 中的应用位置
LoRA 通常应用于 Attention 层的 Q、K、V、O 投影和 MLP 层的 W₁、W₂、W₃ 投影:
plain
原始 Attention 层:
Q = x × W_Q → Q = x × W_Q + x × (B_Q × A_Q)
K = x × W_K → K = x × W_K + x × (B_K × A_K)
V = x × W_V → V = x × W_V + x × (B_V × A_V)
O = attn × W_O → O = attn × W_O + attn × (B_O × A_O)
原始 MLP 层:
h1 = x × W₁ → h1 = x × W₁ + x × (B₁ × A₁)
h2 = x × W₂ → h2 = x × W₂ + x × (B₂ × A₂)
6.2.3 LoRA 代码实现
python
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, original_linear, rank=8, alpha=16):
super().__init__()
self.original = original_linear
self.rank = rank
self.alpha = alpha
in_dim = original_linear.in_features
out_dim = original_linear.out_features
# 冻结原始权重
self.original.weight.requires_grad = False
# LoRA 矩阵
self.lora_A = nn.Parameter(torch.randn(rank, in_dim) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(out_dim, rank))
# 缩放因子
self.scaling = alpha / rank
def forward(self, x):
# 原始输出 + LoRA 输出
original_output = self.original(x)
lora_output = (x @ self.lora_A.T @ self.lora_B.T) * self.scaling
return original_output + lora_output
6.2.4 缩放因子(Scaling Factor)
plain
LoRA 输出 = (B × A) × x × (α / r)
其中:
α (alpha) = 缩放超参数(通常设为 rank 的 1-2 倍)
r (rank) = LoRA 秩
作用:
当增大 rank 时,B×A 的"原始幅度"也会增大。
通过 α/r 的缩放,保持 LoRA 贡献的相对稳定。
这样调整 rank 时不需要重新调整学习率。
6.3 MiniMind 的 LoRA 实现
6.3.1 原生实现(无 peft 依赖)
MiniMind 从零实现 LoRA ,不依赖 HuggingFace 的 peft 库。这样做的好处:
- 代码透明,学生可以完全理解每一步
- 无额外依赖
- 与 MiniMind 的架构完美适配
6.3.2 LoRA 配置参数
| 参数 | 默认值 | 说明 |
|---|---|---|
| lora_rank | 8 | LoRA 秩 |
| lora_alpha | 16 | 缩放因子 |
| lora_dropout | 0.05 | LoRA Dropout |
| lora_target_modules | "wq", "wk", "wv", "wo", "w1", "w2", "w3" | 应用 LoRA 的模块 |
6.3.3 运行 LoRA 微调
bash
cd minimind/trainer
# 从 SFT 模型开始 LoRA 微调
python train_lora.py
# 指定 LoRA rank
python train_lora.py --lora_rank 16
# 指定 LoRA alpha
python train_lora.py --lora_alpha 32
6.3.4 LoRA 训练输出
plain
out/
├── full_sft_768.pth # 基础模型权重(冻结)
├── lora_pretrain_768.pth # LoRA 适配器权重(仅 A、B 矩阵)
└── ...
LoRA 权重文件非常小(仅包含 A、B 矩阵参数):
plain
LoRA 权重大小 ≈ num_layers × num_targets × 2 × rank × dim
≈ 8 × 7 × 2 × 8 × 768
≈ 688KB
对比全量模型:64M × 4 bytes ≈ 256MB
节省:256MB / 688KB ≈ 370 倍!
6.4 LoRA 权重合并
6.4.1 为什么需要合并?
训练好的 LoRA 适配器是独立于基础模型的,推理时需要分别加载。为了部署方便,可以将 LoRA 权重合并回基础模型。
6.4.2 合并过程
plain
合并前:
推理: y = W × x + (B × A) × x (需要分别加载 W 和 B、A)
合并后:
W_merged = W + B × A
推理: y = W_merged × x (只需加载一个权重矩阵)
6.4.3 运行权重合并
bash
cd minimind/scripts
# 合并 LoRA 权重到基础模型
python convert_model.py
# 输出: out/merged_model_768.pth(包含合并后的完整权重)
6.4.4 模型格式转换
MiniMind 支持在多种格式间转换:
bash
cd scripts
# torch 格式 → transformers 格式(可被 vLLM、ollama 使用)
python convert_model.py --format transformers --input_dir ../minimind-3
# transformers 格式 → GGUF 格式(可被 llama.cpp 使用)
# 需要安装 llama-cpp-python 或使用 llama.cpp 的 convert脚本
6.5 LoRA vs 全量微调对比实验
6.5.1 实验设计
| 对比维度 | 全量微调 | LoRA 微调 |
|---|---|---|
| 可训练参数 | 64M(全部) | 1.4M(2%) |
| 训练速度 | 基准 | 更快(反向传播计算量小) |
| 显存占用 | 基准 | 更低(不需存储全部梯度) |
| 效果 | 基准 | 通常接近(rank 足够时) |
| 存储成本 | 每任务 256MB | 每任务 ~1MB |
6.5.2 实验步骤
bash
# 实验1: 全量微调(已在阶段5完成)
python train_full_sft.py --max_epochs 5
# 实验2: LoRA rank=4
python train_lora.py --lora_rank 4
# 实验3: LoRA rank=8
python train_lora.py --lora_rank 8
# 实验4: LoRA rank=16
python train_lora.py --lora_rank 16
# 实验5: LoRA rank=32
python train_lora.py --lora_rank 32
# 对比各组在测试集上的 Loss 和回答质量
6.5.3 预期结果
plain
效果趋势(通常):
全量微调 > LoRA(r=32) ≈ LoRA(r=16) > LoRA(r=8) > LoRA(r=4)
但当 r ≥ 16 时,LoRA 效果通常非常接近全量微调。
6.6 LoRA 变体与扩展知识
6.6.1 常见 LoRA 变体
| 变体 | 核心思想 | 与标准 LoRA 的区别 |
|---|---|---|
| QLoRA | 量化 + LoRA | 将基础模型量化为 4-bit,LoRA 部分保持 bf16 |
| LoRA+ | 自适应学习率 | 为 A 和 B 设置不同的学习率 |
| DoRA | 权重分解 | 将权重分解为方向和幅度,仅对方向做 LoRA |
| VeRA | 共享随机矩阵 | 多个 LoRA 层共享相同的 A、B 随机矩阵 |
6.6.2 如何选择 LoRA rank?
| rank | 参数量 | 适用场景 |
|---|---|---|
| 1-4 | 极少 | 简单任务(如风格迁移) |
| 8-16 | 较少 | 通用微调(推荐起点) |
| 32-64 | 中等 | 复杂任务(如多轮对话、推理) |
| 128+ | 较多 | 接近全量微调的效果 |
经验法则:从 rank=8 开始,如果效果不够好,逐步增大到 16、32。
实践任务
任务 6.1:运行 LoRA 微调(必做)
- 使用 SFT 模型作为基础,运行 LoRA 微调
- 记录训练 Loss 曲线
- 合并 LoRA 权重
- 使用合并后的模型进行对话测试
bash
# 完整流程
cd trainer && python train_lora.py --lora_rank 8
cd ../scripts && python convert_model.py
cd .. && python eval_llm.py --load_from ./out/merged_model_768.pth
任务 6.2:LoRA rank 对比实验(必做)
| rank | 训练 Loss | 测试集回答质量 | 参数量 | 训练时间 |
|---|---|---|---|---|
| 4 | ||||
| 8 | ||||
| 16 | ||||
| 32 |
思考题:
- rank 增大时,Loss 是否一定更低?有没有边际效应递减的点?
- LoRA 模型在哪些类型的问题上表现与全量微调差距最大?
任务 6.3:LoRA 权重分析(选做)
python
# 分析 LoRA 矩阵的奇异值分布
import torch
# 加载 LoRA 权重
lora_weights = torch.load('out/lora_pretrain_768.pth')
# 对每层的 A、B 矩阵做 SVD
for name, param in lora_weights.items():
if 'lora_A' in name or 'lora_B' in name:
U, S, Vh = torch.linalg.svd(param)
print(f"{name}: top-3 singular values = {S[:3].tolist()}")
# 观察奇异值分布,判断 LoRA 是否"充分利用"了其秩容量
挑战任务(选做)
- 实现一个简化版的 QLoRA(4-bit 量化 + LoRA)
- 对比 LoRA 应用于不同层(仅 Attention vs Attention+MLP)的效果差异
下一阶段预告
在阶段 7 中,我们将学习**强化学习对齐(RLHF/RLAIF)**技术。理解 DPO、PPO、GRPO、CISPO 等算法的原理和区别,让模型学会区分"好回答"和"坏回答",进一步对齐人类偏好。