参数高效微调 (PEFT)
本章目标:在单卡/低显存下定制化大模型
4.1 LoRA 深度实践
4.1.1 LoRA 的数学直觉
核心思想:大模型的权重矩阵通常是低秩的(尤其是在任务特定方向上)
预训练权重: W₀ ∈ R^(d×k)
微调后: W = W₀ + ΔW
LoRA 假设: ΔW = BA, 其中 B ∈ R^(d×r), A ∈ R^(r×k), r << min(d,k)
所以微调时只更新 A, B 两个小矩阵!为什么低秩假设合理?
直觉:
- 预训练已经学到了通用语言能力
- 任务适配只需要在某些方向上调整
- 这些方向通常构成低维子空间4.1.2 LoRA 公式
python
# 前向传播
class LoRALinear(nn.Module):
def __init__(self, linear, rank=4, alpha=1, dropout=0.0):
super().__init__()
self.linear = linear
self.rank = rank
self.alpha = alpha
self.scaling = alpha / rank
# LoRA 参数: A 和 B
# A: (r, k) 初始化为 N(0, 0.02)
# B: (d, r) 初始化为 0(确保 ΔW = BA 初始为 0)
self.lora_A = nn.Parameter(torch.randn(rank, linear.in_features))
self.lora_B = nn.Parameter(torch.zeros(linear.out_features, rank))
if dropout > 0:
self.lora_dropout = nn.Dropout(dropout)
else:
self.lora_dropout = nn.Identity()
# 标记原始权重不更新
self.linear.weight.requires_grad = False
if self.linear.bias is not None:
self.linear.bias.requires_grad = False
def forward(self, x):
# 原始权重输出
base_output = self.linear(x)
# LoRA 路径输出
# x @ A^T @ B^T = (x @ A^T) @ B^T
lora_output = (x @ self.lora_A.t() @ self.lora_B.t()) * self.scaling
return base_output + lora_output4.1.3 缩放因子 α 的作用
python
# 实际学习率 = α/r * base_lr
# 为什么不直接用 rank 作为缩放?
# 原因:α 是一个独立的超参数,可以调整而不改变 rank
# 经验:
# - α = 2r: 初始学习率较大
# - α = r: 1:1 缩放
# - α = r/2: 较小缩放
# 实践中常用:
lora_config = {
'r': 16,
'lora_alpha': 32, # α = 2r
'lora_dropout': 0.05,
'target_modules': ['q_proj', 'v_proj'], # 只微调这些层
}4.1.4 Q/K/V/O 各层的重要性
经验结论(来自实验):
通常 Q, V 最重要:
┌──────────────────────────────────────────────┐
│ 各层 LoRA 效果对比(经验性) │
├──────────────┬───────────────────────────────┤
│ 层 │ 效果 (Accuracy on tasks) │
├──────────────┼───────────────────────────────┤
│ Q + V │ ★★★★★ (最佳,通常) │
│ Q + K + V │ ★★★★☆ │
│ Q + K + V + O │ ★★★☆☆ │
│ W_q │ ★★★★☆ │
│ W_q + W_v │ ★★★★★ │
│ W_q + W_k │ ★★☆☆☆ │
│ FFN (q,k,v) │ ★★☆☆☆ │
│ 所有层 │ ★★★☆☆ (可能过拟合) │
└──────────────┴───────────────────────────────┘
结论:Q 和 V 是最值得加 LoRA 的!4.1.5 秩 r 的选择
| r 值 | 参数量 | 适用场景 |
|---|---|---|
| 2-4 | 极少 | 简单任务,资源极度受限 |
| 8 | 少 | 一般任务 |
| 16 | 中等 | 常用默认值 |
| 32 | 较多 | 复杂任务 |
| 64-128 | 多 | 复杂任务,效果接近全量 |
经验法则:
- 简单任务(分类):r=4~8
- 中等任务(对话):r=8~16
- 复杂任务(代码/推理):r=16~644.2 QLoRA 技术栈
4.2.1 QLoRA 核心思想
量化 + LoRA:把基础模型量化到 4-bit,LoRA 部分保持高精度
标准 LoRA: W₀ (FP16) + BA (FP16)
QLoRA: W₀ (INT4) + BA (FP16)
显存节省: ~4倍(W₀ 从 16bit → 4bit)4.2.2 4-bit NormalFloat 量化
问题:标准 INT4 量化对神经网络不理想,因为权重分布不均匀
NF4 (Normal Float 4):
python
def normal_float_quantize(tensor, num_bits=4):
"""
NF4 量化:
1. 计算 quantile(分位数)
2. 用分位数作为量化中心
3. 存储量化索引
"""
# NF4 的量化中心是预先定义的正态分布分位数
# 对于 4-bit: 16 个量化中心
nf4_centers = get_nf4_centers(num_bits) # 预计算
# 归一化到 [-1, 1]
abs_max = tensor.abs().max()
normalized = tensor / abs_max
# 量化
quantized = torch.searchsorted(nf4_centers, normalized)
# 反量化时需要存储 scale
return quantized, abs_max
def get_nf4_centers(num_bits=4):
"""
NF4 的量化中心基于分位数
对于正态分布: [-0.9407, -0.8705, -0.8136, ...]
"""
num_centers = 2 ** num_bits
# 使用 QLoRA 论文中的公式
centers = torch.tensor([quantile_gaussian(i / (num_centers - 1))
for i in range(num_centers)])
return centers4.2.3 双量化 (Double Quantization)
核心思想:不仅量化权重,还量化量化用的 scale
python
class DoubleQuantized(nn.Module):
"""
对量化 scale 再做量化
原本: 每个 token/block 一个 FP32 scale
现在: 先量化 scale 到 INT8,再存储
"""
def __init__(self, num_bits=8):
super().__init__()
self.num_bits = num_bits
def quantize(self, scales):
# scales: 每个 block 的 scale (FP32)
# 量化 scales 本身
abs_max = scales.abs().max()
scales_int8 = (scales / abs_max * 127).round().clamp(-128, 127)
return scales_int8, abs_max节省显存:
标准量化: 每个参数需要 1 个量化中心 + 1 个 FP32 scale
对于 d=4096, seq=2048: 32M params → 32M * 0.5 bytes + 32M * 4 bytes = ~140MB
双量化: 每个参数 1 个 INT4 中心 + 1 个 INT8 scale (共享)
32M * 0.5 bytes + 32M/32 * 1 byte = ~16MB + 1MB = ~17MB
(假设 32 个参数共享一个 scale)4.2.4 Paged Optimizer
问题:大模型微调时,optimizer state 可能很大,单卡放不下
解决:把 optimizer state 分页到 CPU 内存
python
class PagedOptimizer:
"""
类似 CPU 内存分页,管理 optimizer state
当 GPU 显存不够时,自动卸载到 CPU
"""
def __init__(self, model, optimizer_class, **optimizer_kwargs):
self.model = model
self.optimizer = optimizer_class(model.parameters(), **optimizer_kwargs)
self.page_manager = PageManager(cpu_pin_memory=True)
def step(self):
# 检查是否需要分页
for group in self.optimizer.param_groups:
for p in group['params']:
if p.grad is None:
continue
# 如果 GPU 显存不够,卸载到 CPU
if self.gpu_memory_low():
self.page_manager.page_out(p)
self.optimizer.step()4.3 LoRA 进阶变体
4.3.1 LoRA+
问题:LoRA 对 A 和 B 用相同学习率,不够高效
LoRA+:
python
class LoRAPlus(nn.Module):
def __init__(self, linear, rank=8, alpha=16, lora_A_lr=1e-3, lora_B_lr=1e-4):
super().__init__()
# A 用大学习率,B 用小学习率
self.lora_A = nn.Parameter(torch.randn(rank, linear.in_features))
self.lora_B = nn.Parameter(torch.zeros(linear.out_features, rank))
# 注册优化器学习率
self.lora_A_lr = lora_A_lr
self.lora_B_lr = lora_B_lr直觉:A 决定"方向",B 决定"幅度",分开学习更合理
4.3.2 DoRA (Weight-Decomposed LoRA)
核心思想:把权重分解为方向和幅度
python
class DoRALinear(nn.Module):
def __init__(self, linear, rank=8, alpha=16):
super().__init__()
self.linear = linear
self.rank = rank
self.alpha = alpha
# DoRA: 分解为 magnitude + direction
# 原始权重 W0 保持冻结
# 学习一个缩放向量 m 和方向 ΔU
# Direction (低秩)
self.lora_A = nn.Parameter(torch.randn(rank, linear.in_features) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(linear.out_features, rank))
# Magnitude (可学习缩放)
# W0 的每列 norm
self.m = nn.Parameter(torch.ones(linear.out_features))
self.linear.weight.requires_grad = False
def forward(self, x):
W0 = self.linear.weight
# Direction: W0 + BA
W_delta = self.lora_B @ self.lora_A
# Normalize direction
W_normalized = W_delta / (W_delta.norm(dim=-1, keepdim=True) + 1e-9)
# Combine with magnitude
W_final = (self.m.unsqueeze(-1) * W_normalized) + W0
# Compute output
return F.linear(x, W_final, self.linear.bias)4.3.3 IA³ (Infused Adapter by Activations)
核心思想:不是学习低秩矩阵,而是学习缩放向量
python
class IA3Linear(nn.Module):
"""
IA³: 学习逐通道的缩放向量,比 LoRA 参数量更少
"""
def __init__(self, linear, target_modules=['q', 'v']):
super().__init__()
self.linear = linear
self.linear.weight.requires_grad = False
# 只学习 3 个缩放向量
if 'q' in target_modules:
self.q_scale = nn.Parameter(torch.ones(linear.out_features))
if 'k' in target_modules:
self.k_scale = nn.Parameter(torch.ones(linear.out_features))
if 'v' in target_modules:
self.v_scale = nn.Parameter(torch.ones(linear.out_features))
def forward(self, x):
result = self.linear(x)
# 逐通道缩放
if hasattr(self, 'q_scale'):
result = result * self.q_scale.unsqueeze(0).unsqueeze(-1)
return result4.3.4 变体对比
| 方法 | 参数量 | 表达能力 | 适用场景 |
|---|---|---|---|
| LoRA | 中 (r×(d+k)) | 中 | 通用微调 |
| LoRA+ | 同 LoRA | 更好 | 需要精细控制 |
| DoRA | 同 LoRA | 更好 | 复杂任务 |
| IA³ | 极少 (3×d) | 中 | 极端资源受限 |
4.4 优化器选择
4.4.1 AdamW vs 传统 SGD
python
# AdamW = Adam + 权重衰减分开处理
# PyTorch 实现
class AdamW(torch.optim.Optimizer):
def __init__(self, params, lr=1e-3, betas=(0.9, 0.999),
eps=1e-8, weight_decay=0.01):
defaults = dict(lr=lr, betas=betas, eps=eps,
weight_decay=weight_decay)
super().__init__(params, defaults)
@torch.no_grad()
def step(self, closure=None):
for group in self.param_groups:
for p in group['params']:
if p.grad is None:
continue
grad = p.grad
# 状态
state = self.state[p]
if len(state) == 0:
state['exp_avg'] = torch.zeros_like(p)
state['exp_avg_sq'] = torch.zeros_like(p)
exp_avg, exp_avg_sq = state['exp_avg'], state['exp_avg_sq']
beta1, beta2 = group['betas']
# Adam 更新
exp_avg.mul_(beta1).add_(grad, alpha=1-beta1)
exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1-beta2)
# 权重衰减(与 Adam 分离)
p.sub_(p, alpha=group['weight_decay'])
# 偏置校正
bias_correction1 = 1 - beta1 ** state['step']
bias_correction2 = 1 - beta2 ** state['step']
step_size = group['lr'] / bias_correction1
exp_avg_sq_hat = exp_avg_sq / bias_correction2
p.addcdiv_(exp_avg, exp_avg_sq_hat.sqrt().add_(group['eps']))4.4.2 Sophia-G: 二阶优化
核心思想:用 Hessian 的对角近似做裁剪
python
class SophiaG(torch.optim.Optimizer):
"""
Sophia: 轻量二阶优化器,比 Adam 收敛更快
关键改进:用 Hessian 对角估计做梯度裁剪
减少更新幅度,防止"overshoot"
"""
def __init__(self, params, lr=1e-3, rho=0.04, betas=(0.9, 0.999)):
defaults = dict(lr=lr, rho=rho, betas=betas)
super().__init__(params, defaults)
@torch.no_grad()
def step(self, closure=None):
for group in self.param_groups:
for p in group['params']:
if p.grad is None:
continue
grad = p.grad
state = self.state[p]
if len(state) == 0:
state['exp_avg'] = torch.zeros_like(p)
state['exp_avg_sq'] = torch.zeros_like(p)
state['hessian_est'] = torch.zeros_like(p)
exp_avg, exp_avg_sq, h_est = state['exp_avg'], state['exp_avg_sq'], state['hessian_est']
beta1, beta2 = group['betas']
# 更新 exp_avg 和 exp_avg_sq
exp_avg.mul_(beta1).add_(grad, alpha=1-beta1)
exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1-beta2)
# 估计 Hessian: h = g * g (二阶矩估计)
h_est.mul_(beta2).addcmul_(grad, grad, value=1-beta2)
# Sophia 特有的裁剪
h_clipped = torch.maximum(h_est, torch.ones_like(h_est) * 1e-6)
clipped_grad = grad / h_clipped
# 裁剪: ||clipped_grad|| > rho 时缩放
grad_norm = clipped_grad.norm()
if grad_norm > group['rho']:
clipped_grad = clipped_grad * (group['rho'] / grad_norm)
p.sub_(clipped_grad, alpha=group['lr'])4.4.3 Schedule-Free 优化器
核心思想:不需要学习率衰减 scheduler
python
class ScheduleFreeAdamW(torch.optim.Optimizer):
"""
无需 learning rate schedule 的 AdamW
训练结束时自动收敛到极小值
"""
def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), weight_decay=0.01):
super().__init__()
self lr = lr
self.betas = betas
self.weight_decay = weight_decay
self.group = list(params)
# 添加 lazy 状态
for p in self.group:
self.state[p] = {'exp_avg': torch.zeros_like(p),
'exp_avg_sq': torch.zeros_like(p),
'z': torch.zeros_like(p)} # shadow parameter
@torch.no_grad()
def step(self):
for p in self.group:
grad = p.grad
if grad is None:
continue
s = self.state[p]
exp_avg, exp_avg_sq, z = s['exp_avg'], s['exp_avg_sq'], s['z']
beta1, beta2 = self.betas
# 动量更新
exp_avg.mul_(beta1).add_(grad, alpha=1-beta1)
exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1-beta2)
# 更新 shadow parameter
bias1 = 1 - beta1
bias2 = 1 - beta2
denom = (exp_avg_sq.sqrt() / math.sqrt(bias2)).add_(1e-8)
# 内插因子 β/(1-β)
beta_factor = self.betas[1] / (1 - self.betas[1])
interpolation = 1 - beta_factor
z_new = (1 + interpolation * self.lr) * z - self.lr * exp_avg / denom
# 更新参数: θ = (1-β)z + βz_new
p.mul_(self.betas[1]).add_(z_new, alpha=1-self.betas[1])
# 更新 shadow
s['exp_avg'] = exp_avg
s['exp_avg_sq'] = exp_avg_sq
s['z'] = z_new4.5 本章小结
┌─────────────────────────────────────────────────────────────┐
│ PEFT 方法对比 │
├─────────────────────────────────────────────────────────────┤
│ LoRA │ 低秩分解 A·B,省显存,效果好 │
│ QLoRA │ INT4 量化 + LoRA,单卡可训 70B │
│ LoRA+ │ A,B 分开学习率,更精细 │
│ DoRA │ 方向+幅度分解,表达更强 │
│ IA³ │ 只学缩放向量,参数量极少 │
├─────────────────────────────────────────────────────────────┤
│ 优化器选择 │
├─────────────────────────────────────────────────────────────┤
│ AdamW │ 默认首选,收敛稳定 │
│ Sophia-G │ 二阶近似,收敛更快(但更慢) │
│ Schedule-Free│ 无需 LR schedule,简化训练 │
└─────────────────────────────────────────────────────────────┘显存估算(QLoRA)
以 LLaMA-2 7B 为例:
模型参数 (INT4): 7B × 0.5 bytes = 3.5 GB
LoRA 参数 (FP16): (r=8) × 2 × 4096 × 4096 × 2 bytes ≈ 0.5 GB
KV Cache (BF16): 2 × batch × seq × 32 × 128 × 2 ≈ 小
Optimizer (QLoRA): 无(只存 LoRA 的 optimizer)
总计: ~4-5 GB(单卡可训!)
对比全量 FP16: ~14 GB
对比全量 BF16: ~14 GB面试高频问题
LoRA 为什么能省显存?
→ 只更新 A,B 两个低秩矩阵,optimizer state 从 O(d×k) → O(r×(d+k))LoRA 的 rank 怎么选?
→ 简单任务 r=4~8,复杂任务 r=16~64,通常 Q+V 就够QLoRA 的双量化是什么?
→ 不仅量化权重,还量化量化用的 scale,进一步省显存DoRA 和 LoRA 区别?
→ DoRA 把权重分解为方向和幅度两部分,更细粒度为什么 LoRA 只微调 Q,V 而不是所有层?
→ 实验表明 Q,V 包含主要的任务适配信息,FFN 主要是语言能力