Skip to content

参数高效微调 (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_output

4.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~64

4.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 centers

4.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 result

4.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_new

4.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

面试高频问题

  1. LoRA 为什么能省显存?
    → 只更新 A,B 两个低秩矩阵,optimizer state 从 O(d×k) → O(r×(d+k))

  2. LoRA 的 rank 怎么选?
    → 简单任务 r=4~8,复杂任务 r=16~64,通常 Q+V 就够

  3. QLoRA 的双量化是什么?
    → 不仅量化权重,还量化量化用的 scale,进一步省显存

  4. DoRA 和 LoRA 区别?
    → DoRA 把权重分解为方向和幅度两部分,更细粒度

  5. 为什么 LoRA 只微调 Q,V 而不是所有层?
    → 实验表明 Q,V 包含主要的任务适配信息,FFN 主要是语言能力

基于 MIT 许可发布