PEFT技术详解

前言

参数高效微调(PEFT)允许我们以极少的可训练参数微调大型预训练模型。本文介绍LoRA、Adapter等主流方法。


为什么需要PEFT

import numpy as np

print("全量微调的问题:")
print("=" * 50)
print("• 存储开销: 每个任务需要完整模型副本")
print("• 计算成本: 更新所有参数需要大量显存")
print("• 灾难遗忘: 可能破坏预训练知识")
print()

# 模型参数量示例
models = {
    'BERT-base': 110e6,
    'BERT-large': 340e6,
    'GPT-2': 1.5e9,
    'LLaMA-7B': 7e9,
    'LLaMA-65B': 65e9,
}

print("模型参数量:")
for name, params in models.items():
    memory = params * 4 / 1e9  # float32
    print(f"  {name}: {params/1e6:.0f}M 参数, 约{memory:.1f}GB 显存")

LoRA (Low-Rank Adaptation)

核心思想

LoRA在预训练权重旁边添加低秩分解矩阵:

\[W' = W + \Delta W = W + BA\]

其中 $B \in \mathbb{R}^{d \times r}$, $A \in \mathbb{R}^{r \times k}$, $r \ll \min(d, k)$

try:
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class LoRALayer(nn.Module):
        """LoRA层实现"""
        
        def __init__(self, in_features, out_features, rank=4, alpha=1.0):
            super().__init__()
            
            self.rank = rank
            self.alpha = alpha
            self.scaling = alpha / rank
            
            # 原始权重(冻结)
            self.weight = nn.Parameter(
                torch.randn(out_features, in_features) * 0.02,
                requires_grad=False
            )
            
            # LoRA分解矩阵
            self.lora_A = nn.Parameter(torch.randn(rank, in_features) * 0.02)
            self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
            
            # 只有LoRA参数需要梯度
        
        def forward(self, x):
            # 原始输出
            out = F.linear(x, self.weight)
            
            # LoRA增量
            lora_out = F.linear(F.linear(x, self.lora_A), self.lora_B)
            
            return out + lora_out * self.scaling
        
        def merge_weights(self):
            """合并权重用于推理"""
            merged = self.weight + self.lora_B @ self.lora_A * self.scaling
            return merged
        
        def num_trainable_params(self):
            """可训练参数数量"""
            return self.lora_A.numel() + self.lora_B.numel()
        
        def num_total_params(self):
            """总参数数量"""
            return self.weight.numel()
    
    # 测试
    in_dim, out_dim = 768, 768
    rank = 8
    
    lora = LoRALayer(in_dim, out_dim, rank=rank)
    
    total = lora.num_total_params()
    trainable = lora.num_trainable_params()
    
    print(f"原始参数: {total:,}")
    print(f"LoRA参数: {trainable:,}")
    print(f"参数比例: {trainable/total*100:.2f}%")
    
except ImportError:
    print("PyTorch未安装")

NumPy实现

class LoRALayerNumpy:
    """NumPy实现的LoRA层"""
    
    def __init__(self, in_features, out_features, rank=4, alpha=1.0):
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank
        
        # 原始权重
        self.weight = np.random.randn(out_features, in_features) * 0.02
        
        # LoRA矩阵
        self.lora_A = np.random.randn(rank, in_features) * 0.02
        self.lora_B = np.zeros((out_features, rank))
    
    def forward(self, x):
        # x: [batch, in_features]
        # 原始输出
        out = x @ self.weight.T
        
        # LoRA增量: x -> A -> B
        lora_out = (x @ self.lora_A.T) @ self.lora_B.T
        
        return out + lora_out * self.scaling
    
    def merge_weights(self):
        """合并权重"""
        delta_W = self.lora_B @ self.lora_A * self.scaling
        return self.weight + delta_W

# 测试
lora_np = LoRALayerNumpy(768, 768, rank=8)
x = np.random.randn(4, 768)
out = lora_np.forward(x)
print(f"输出形状: {out.shape}")

# 参数效率
total_params = 768 * 768
lora_params = 8 * 768 * 2
print(f"参数节省: {(1 - lora_params/total_params)*100:.1f}%")

Adapter

Adapter模块

try:
    class Adapter(nn.Module):
        """Adapter模块"""
        
        def __init__(self, hidden_size, bottleneck_size=64):
            super().__init__()
            
            self.down_proj = nn.Linear(hidden_size, bottleneck_size)
            self.up_proj = nn.Linear(bottleneck_size, hidden_size)
            self.act = nn.GELU()
        
        def forward(self, x):
            # 下投影 -> 激活 -> 上投影 -> 残差
            residual = x
            x = self.down_proj(x)
            x = self.act(x)
            x = self.up_proj(x)
            return x + residual
    
    
    class TransformerWithAdapter(nn.Module):
        """带Adapter的Transformer层"""
        
        def __init__(self, hidden_size, num_heads, bottleneck_size=64):
            super().__init__()
            
            # 原始组件(冻结)
            self.self_attn = nn.MultiheadAttention(hidden_size, num_heads, batch_first=True)
            self.ffn = nn.Sequential(
                nn.Linear(hidden_size, hidden_size * 4),
                nn.GELU(),
                nn.Linear(hidden_size * 4, hidden_size)
            )
            self.norm1 = nn.LayerNorm(hidden_size)
            self.norm2 = nn.LayerNorm(hidden_size)
            
            # Adapter(可训练)
            self.adapter_attn = Adapter(hidden_size, bottleneck_size)
            self.adapter_ffn = Adapter(hidden_size, bottleneck_size)
        
        def forward(self, x):
            # Self-attention + Adapter
            attn_out, _ = self.self_attn(x, x, x)
            attn_out = self.adapter_attn(attn_out)  # Adapter
            x = self.norm1(x + attn_out)
            
            # FFN + Adapter
            ffn_out = self.ffn(x)
            ffn_out = self.adapter_ffn(ffn_out)  # Adapter
            x = self.norm2(x + ffn_out)
            
            return x
    
    print("Adapter特点:")
    print("  • 插入到Transformer层中")
    print("  • 瓶颈结构压缩参数")
    print("  • 残差连接保持稳定性")
    
except NameError:
    print("需要先导入PyTorch")

Prefix Tuning

前缀调优

try:
    class PrefixTuning(nn.Module):
        """Prefix Tuning实现"""
        
        def __init__(self, num_layers, hidden_size, prefix_length=10):
            super().__init__()
            
            self.prefix_length = prefix_length
            
            # 可训练的前缀
            # [num_layers, 2, prefix_length, hidden_size]
            # 2表示key和value
            self.prefix_tokens = nn.Parameter(
                torch.randn(num_layers, 2, prefix_length, hidden_size) * 0.02
            )
        
        def get_prefix(self, batch_size, layer_idx):
            """获取特定层的前缀"""
            # [2, prefix_length, hidden_size]
            prefix = self.prefix_tokens[layer_idx]
            
            # 扩展到batch
            # [batch, 2, prefix_length, hidden_size]
            prefix = prefix.unsqueeze(0).expand(batch_size, -1, -1, -1)
            
            prefix_key = prefix[:, 0]    # [batch, prefix_length, hidden_size]
            prefix_value = prefix[:, 1]  # [batch, prefix_length, hidden_size]
            
            return prefix_key, prefix_value
    
    print("Prefix Tuning:")
    print("  • 在attention的key/value前添加可训练前缀")
    print("  • 不修改模型结构")
    print("  • 参数量与序列长度无关")
    
except NameError:
    print("需要先导入PyTorch")

Prompt Tuning

try:
    class PromptTuning(nn.Module):
        """Prompt Tuning实现"""
        
        def __init__(self, num_tokens, hidden_size, init_from_vocab=False):
            super().__init__()
            
            self.num_tokens = num_tokens
            
            # 可训练的软提示
            self.soft_prompt = nn.Parameter(
                torch.randn(num_tokens, hidden_size) * 0.02
            )
        
        def forward(self, input_embeds):
            """
            input_embeds: [batch, seq_len, hidden_size]
            """
            batch_size = input_embeds.shape[0]
            
            # 扩展软提示到batch
            prompt = self.soft_prompt.unsqueeze(0).expand(batch_size, -1, -1)
            
            # 拼接: [prompt | input]
            return torch.cat([prompt, input_embeds], dim=1)
    
    print("Prompt Tuning vs Prefix Tuning:")
    print("  • Prompt Tuning: 在输入嵌入层添加")
    print("  • Prefix Tuning: 在每层attention添加")
    
except NameError:
    print("需要先导入PyTorch")

QLoRA

量化LoRA

print("QLoRA关键技术:")
print("=" * 50)
print("• 4-bit量化: 将模型权重量化到4位")
print("• Double Quantization: 量化常数也量化")
print("• Paged Optimizers: 防止显存溢出")
print("• NormalFloat4: 对正态分布优化的数据类型")
print()

# 显存估算
def estimate_memory(num_params, bits=16, lora_rank=8, lora_ratio=0.1):
    """估算显存使用"""
    
    # 基础模型
    base_memory = num_params * bits / 8 / 1e9
    
    # LoRA参数(全精度)
    lora_params = num_params * lora_ratio * lora_rank * 2 / num_params
    lora_memory = lora_params * 16 / 8 / 1e9
    
    return base_memory, lora_memory

# 7B模型对比
params_7b = 7e9

mem_fp16, _ = estimate_memory(params_7b, bits=16)
mem_int8, _ = estimate_memory(params_7b, bits=8)
mem_int4, lora_mem = estimate_memory(params_7b, bits=4)

print("7B模型显存估算:")
print(f"  FP16全量微调: ~{mem_fp16*2:.0f}GB")  # 梯度和优化器状态
print(f"  INT8 + LoRA: ~{mem_int8 + lora_mem:.0f}GB")
print(f"  INT4 (QLoRA): ~{mem_int4 + lora_mem:.0f}GB")

PEFT方法对比

方法 参数位置 参数量 特点
LoRA 权重矩阵旁 0.1-1% 可合并,无推理开销
Adapter 层内插入 1-5% 需要修改结构
Prefix Tuning Attention前缀 <1% 不改变模型
Prompt Tuning 输入嵌入 <0.1% 最简单
QLoRA 量化+LoRA <1% 超低显存

实践指南

try:
    class LoRAConfig:
        """LoRA配置"""
        
        def __init__(self):
            # 常用配置
            self.rank = 8              # 秩,越大容量越大
            self.alpha = 16            # 缩放因子
            self.dropout = 0.1         # Dropout
            self.target_modules = [    # 应用LoRA的模块
                'q_proj', 'k_proj', 'v_proj', 'o_proj',  # Attention
                'gate_proj', 'up_proj', 'down_proj'      # FFN
            ]
    
    def apply_lora(model, config):
        """应用LoRA到模型"""
        
        for name, module in model.named_modules():
            if any(target in name for target in config.target_modules):
                if isinstance(module, nn.Linear):
                    # 替换为LoRA版本
                    lora_module = LoRALayer(
                        module.in_features,
                        module.out_features,
                        rank=config.rank,
                        alpha=config.alpha
                    )
                    # 复制原始权重
                    lora_module.weight.data = module.weight.data.clone()
                    
                    # 替换模块(伪代码)
                    # setattr(parent, child_name, lora_module)
        
        # 冻结非LoRA参数
        for name, param in model.named_parameters():
            if 'lora' not in name:
                param.requires_grad = False
        
        return model
    
    print("LoRA最佳实践:")
    print("  • rank通常4-16即可")
    print("  • alpha通常设为rank的2倍")
    print("  • 主要应用于Attention层")
    print("  • 推理时合并权重消除开销")
    
except NameError:
    print("需要先导入PyTorch")

常见问题

Q1: LoRA的rank如何选择?

  • 简单任务:rank=4-8
  • 复杂任务:rank=16-64
  • 通过验证集调优

Q2: 哪些层应该应用LoRA?

  • 必须:Q、K、V投影
  • 推荐:O投影、FFN
  • 可选:Embedding层

Q3: LoRA能达到全量微调的效果吗?

大多数任务可以达到95%以上的性能。

Q4: 多个LoRA如何合并?

支持合并多个LoRA用于多任务,但需要权重平衡。


进阶技术:QLoRA 与 Unsloth

在 LoRA 的基础上,工业界又演化出了更极致的优化方案。

1. QLoRA (Quantized LoRA)

QLoRA 通过将预训练模型量化为 4-bit,进一步降低了显存需求。

  • 4-bit NormalFloat (NF4):一种针对正态分布权重优化的新型数据类型。
  • 双量化 (Double Quantization):对量化常数再次量化,节省更多显存。
  • 分页优化器 (Paged Optimizers):利用 CPU 内存处理显存峰值,防止 OOM。

2. Unsloth:极致的训练加速

Unsloth 是目前微调 Llama 3、Mistral 等模型最快的框架。

  • 性能:比标准 Hugging Face 训练快 2-3 倍。
  • 显存:减少约 70% 的显存占用。
  • 原理:通过手动优化的 Triton 内核和 Pytorch 算子重写,消除了冗余计算。

实战:使用 PEFT 微调 Llama 3

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

model_id = "meta-llama/Meta-Llama-3-8B"

# 1. 4-bit 量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype="float16",
    bnb_4bit_use_double_quant=True,
)

# 2. 加载模型
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto"
)

# 3. 准备量化训练
model = prepare_model_for_kbit_training(model)

# 4. LoRA 配置
config = LoraConfig(
    r=16, 
    lora_alpha=32, 
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], 
    lora_dropout=0.05, 
    bias="none", 
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, config)
model.print_trainable_parameters()

关键超参数建议

参数 建议值 说明
Rank (r) 8, 16, 32, 64 秩越高,表达能力越强,但显存占用也越高。通常 16 或 32 足够。
Alpha ($\alpha$) 2 * r 缩放系数。通常设为 r 的两倍。
Target Modules All Linear 建议微调所有线性层(Q, K, V, O, MLP),效果优于只调 Q, V。
Learning Rate 1e-4 ~ 2e-4 LoRA 的学习率通常比全量微调略大。

总结

概念 描述
低秩分解 W + BA,r « d
参数效率 0.1%-1%可训练参数
权重合并 推理时无额外开销
QLoRA 量化+LoRA,超低显存

参考资料

  • Hu, E. et al. (2021). “LoRA: Low-Rank Adaptation of Large Language Models”
  • Houlsby, N. et al. (2019). “Parameter-Efficient Transfer Learning for NLP”
  • Li, X. & Liang, P. (2021). “Prefix-Tuning”
  • Dettmers, T. et al. (2023). “QLoRA: Efficient Finetuning of Quantized LLMs”

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:《 机器学习基础系列——LoRA与参数高效微调 》

本文链接:http://localhost:3015/ai/LoRA%E4%B8%8E%E5%8F%82%E6%95%B0%E9%AB%98%E6%95%88%E5%BE%AE%E8%B0%83.html

本文最后一次更新为 天前,文章中的某些内容可能已过时!