LoRA 适配器家族 — 从原版到新秀
1. 原版 LoRA:一切的起点
先花一分钟复习原版 LoRA,后面所有变体都是在这个基础上改的。
全参数微调的问题是:一个 7B 模型,全量训练要 7B × 4 bytes × 各种优化器状态 ≈ 56GB+ 显存。普通人根本跑不动。LoRA 的洞察是:微调时的权重更新矩阵 ΔW 虽然是满秩的,但它可以被压缩成一个低秩矩阵,因为微调不需要改那么多自由度。
前向传播时:h = W₀x + BAx,W₀ 冻住不动,只训 A 和 B。r=16 时,参数量只有原来的 16/4096 ≈ 0.4%。
# 原版 LoRA 的核心逻辑(伪代码)
class LoRALinear(nn.Module):def __init__(self, in_dim, out_dim, r=16, alpha=32):self.W = frozen(original_weight) # 冻住的原始权重self.A = nn.Linear(in_dim, r, bias=False) # 降维矩阵self.B = nn.Linear(r, out_dim, bias=False) # 升维矩阵self.scale = alpha / r # 缩放因子nn.init.kaiming_uniform_(self.A.weight)nn.init.zeros_(self.B.weight) # B 初始化为零def forward(self, x):return self.W(x) + self.scale * self.B(self.A(x))
原版的三个设定(后面变体都会动它们)
① 低秩分解 r
所有层共用同一个 rank。r=8 省参但弱,r=64 强但参数多。原版 r=8~16 是甜点。
② 初始化方式
A 用 Kaiming 均匀分布随机初始化,B 初始化为零矩阵。这样一开始 ΔW=0,模型行为就是原始模型。
③ 统一学习率
A 和 B 用同一个 lr。缩放因子 alpha/r 作为等效学习率的调节器。
2. QLoRA:量化 + LoRA,省到极致
省显存 2023, Dettmers et al.
QLoRA 的思路简单粗暴:LoRA 已经让可训参数很少了,但冻结的原始参数还是占显存(7B 模型 bf16 = 14GB)。那把这些冻结参数压缩成 4-bit,不就更省了吗?
但有个问题:4-bit 精度太低,直接算前向传播误差大。QLoRA 的解决方案是双重量化(Double Quantization):不光量化权重本身,连量化的缩放因子也量化一次。
from transformers import BitsAndBytesConfig
from peft import LoraConfig, get_peft_model# QLoRA 配置(这就是你 DPO 脚本里用的)
bnb_config = BitsAndBytesConfig(load_in_4bit=True, # 4-bit 量化基座bnb_4bit_quant_type="nf4", # NormalFloat4,比普通 INT4 更好bnb_4bit_compute_dtype=torch.bfloat16, # 反量化回 bf16 计算,保持精度bnb_4bit_use_double_quant=True, # 双重量化,再省 0.4 bit/param
)model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b",quantization_config=bnb_config,device_map="auto",
)lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])
model = get_peft_model(model, lora_config)
效果和代价
| 全参数 bf16 | LoRA bf16 | QLoRA NF4 | |
|---|---|---|---|
| 7B 模型显存 | ~56 GB | ~16 GB | ~6 GB |
| 可训参数量 | 7B | ~4M (r=16) | ~4M (r=16) |
| 训练速度 | 基准 | ~1.5× 快 | ~0.8×(反量化有开销) |
| 效果 | 最佳 | 接近全参数 | 和 LoRA 几乎无差别 |
3. DoRA:把权重拆成方向和大小
提效果 2024, Liu et al. (ICLR 2024 Oral)
原版 LoRA 把 ΔW 整个压成低秩。DoRA 说:别着急压,先把权重矩阵拆成"方向"和"大小"两部分——方向部分用 LoRA 更新,大小部分直接训一个标量。
m: 大小(magnitude),逐通道的标量
W₀ + BA: 方向(direction),单位化后只改方向
这个设计的直觉来自对全参数微调的分析:论文发现全参数微调时,权重的方向变化很大,但大小变化很小。LoRA 同时改了方向和大小(耦合在一起),而 DoRA 把两者解耦,各管各的——效果更好。
# DoRA 的核心逻辑(简化版)
class DoRALinear(nn.Module):def __init__(self, in_dim, out_dim, r=16):self.W = frozen(original_weight)self.A = nn.Linear(in_dim, r, bias=False)self.B = nn.Linear(r, out_dim, bias=False)self.m = nn.Parameter(torch.ones(1, out_dim)) # ★ 新增:大小向量def forward(self, x):delta_W = self.B(self.A(x)) # LoRA 更新merged_W = self.W + delta_W # 合并后权重# 拆成方向 + 大小direction = F.normalize(merged_W, dim=1) # 单位化 = 纯方向return self.m * direction @ x # ★ 大小 × 方向
和 LoRA 的关键区别
| LoRA | DoRA | |
|---|---|---|
| 更新方式 | ΔW = BA,直接加到 W₀ | 先拆成方向+大小,方向和大小各更新各的 |
| 可训参数 | A, B | A, B, m(多了极少参数) |
| 效果 | 基准 | 普遍 +0.5~1.5 个点(NLU/NLG 任务) |
| 兼容性 | — | 可以叠 QLoRA(DoRA + 4bit) |
LoraConfig(use_dora=True) 一行搞定。4. AdaLoRA:重要的层给更多 rank
提效果 2023, Zhang et al.
原版 LoRA 一个很蠢的地方:所有层都用同一个 rank。但实际上不是每层都同等重要——有些层对最终任务贡献大,该多分参数;有些层贡献小,r=2 就够了。
AdaLoRA 的做法:把总参数量预算固定,然后自动把预算分配给更重要的层。它会动态评估每个权重矩阵的 SVD 奇异值,大的奇异值对应的方向重要,保留;小的奇异值不重要,裁剪掉。
# AdaLoRA 的核心思路(简化)
# 不是 A×B,而是 P×Λ×Q —— 可微的 SVD 形式
class AdaLoRALinear(nn.Module):def __init__(self, in_dim, out_dim, init_r=16):# P: 左奇异向量,Λ: 奇异值(对角),Q: 右奇异向量self.P = nn.Parameter(torch.randn(out_dim, init_r))self.Q = nn.Parameter(torch.randn(init_r, in_dim))self.Lambda = nn.Parameter(torch.ones(init_r)) # ★ 可学习的奇异值def forward(self, x):# 根据奇异值大小动态分配 rankdelta_W = self.P @ torch.diag(self.Lambda) @ self.Qreturn frozen_W(x) + delta_W @ x
关键机制:训练过程中,对奇异值 Λ 加 L1 正则,逼着不重要的奇异值趋向零。然后定期把接近零的奇异值删掉,释放 rank 预算给别的层。
和 LoRA 的区别
| LoRA | AdaLoRA | |
|---|---|---|
| rank 分配 | 所有层统一 r | 自动分配,重要的层 r 大 |
| 参数形式 | A × B | P × Λ × Q(可微 SVD) |
| 总参数量 | 固定 | 固定(预算一样,只是分配方式不同) |
| 额外开销 | 无 | 有(奇异值正则化 + 周期性剪枝) |
5. LoRA+:A 和 B 用不同的学习率
收敛更快 2024, Hayou et al.
这篇论文发现了一个非常微妙的东西:原版 LoRA 里 A 和 B 用同一个学习率,会导致训练初期 B 更新太慢。
原因:A 是随机初始化的,B 是零初始化的。反向传播时,B 的梯度包含了 A 的随机噪声,而 A 的梯度更直接。这就导致 B 学得比 A 慢——但 LoRA 的效果需要 A 和 B 配合才能体现。
LoRA+ 的解决方式简单到让人想翻白眼:给 B 一个比 A 大的学习率。
# 原版 LoRA:A 和 B 统一 lr
optimizer = AdamW([A.parameters(), B.parameters()], lr=1e-4)# LoRA+:B 的 lr 是 A 的 λ 倍(λ > 1)
optimizer = AdamW([{"params": A.parameters(), "lr": 1e-4},{"params": B.parameters(), "lr": 1e-4 * 16}, # ★ B 的 lr 大 16 倍
])
论文推荐的 λ = 2^4 = 16(即 B 的 lr 是 A 的 16 倍)。实验显示这个比例在各种任务上都比较稳。你也可以搜一下 λ,范围 4~32 差别不大。
LoraConfig(loraplus_lr_ratio=16)。6. PiSSA:用 SVD 初始化,少走弯路
收敛更快 2024, Meng et al.
原版 LoRA 的初始化是:A 随机,B 全零 → 一开始 ΔW=0,从原始模型开始学。听起来很合理——不要一上来就改了模型的行为。
但 PiSSA 说:你把原始权重的"主成分"提取出来当作 LoRA 的初始化,效果更好。因为微调本质就是"在原始权重的主方向上做微调"——那为什么不直接把主方向放在 LoRA 里,让原始权重变成残差呢?
# PiSSA 的初始化流程
import torchdef pissa_init(W, r):# 1. 对原始权重做 SVDU, S, Vt = torch.linalg.svd(W.float(), full_matrices=False)# 2. 取前 r 个奇异值对应的主成分作为 LoRA 的初始化A_init = torch.diag(torch.sqrt(S[:r])) @ Vt[:r, :] # 右奇异向量B_init = U[:, :r] @ torch.diag(torch.sqrt(S[:r])) # 左奇异向量# 3. 原始权重减去主成分,变成"残差"W_residual = W - B_init @ A_initreturn A_init, B_init, W_residual# 使用时
A.weight.data = A_init
B.weight.data = B_init
original_W = W_residual # ★ 被减掉主成分后冻结
PiSSA vs 原版 LoRA 的初始化差异
| 原版 LoRA | PiSSA | |
|---|---|---|
| A 初始化 | Kaiming 随机 | 从 W 的 SVD 主成分来 |
| B 初始化 | 全零 | 从 W 的 SVD 主成分来 |
| ΔW 初始值 | 0(模型行为不变) | ≈ W 的主成分(模型行为基本不变) |
| 冻结的 W | 完整 W₀ | W₀ - 主成分(残差) |
| 收敛速度 | 基准 | 快 2~5 倍 |
LoraConfig(init_lora_weights="pissa")。另外一个变体叫 MiLoRA(取 SVD 的 minor components 而不是 principal),思路相反但同样有效。7. VeRA:极致省参,共享随机矩阵
极限省参 2024, Kopiczko et al.
前面所有 LoRA 变体的可训参数都是 O(d×r + r×k),随 rank r 线性增长。VeRA 想做到O(d + k)——几乎不随 rank 增长。
怎么做到的?不训 A 和 B 这两个大矩阵。而是用一对冻结的随机矩阵 + 两个很小的可训缩放向量。
Afrozen: 冻结的随机高斯矩阵(所有层共享同一对)
Bfrozen: 冻结的随机高斯矩阵(所有层共享同一对)
Λ_d, Λ_b: 两个可训的对角缩放矩阵(仅 O(d+k) 参数)
# VeRA 的核心逻辑
class VeRALinear(nn.Module):def __init__(self, in_dim, out_dim, r=256): # r 可以设很大!self.W = frozen(original_weight)# 冻结的共享随机矩阵——所有 VeRA 层共用同一对!if not hasattr(VeRALinear, 'A_shared'):VeRALinear.A_shared = frozen(torch.randn(r, in_dim))VeRALinear.B_shared = frozen(torch.randn(out_dim, r))# 唯一可训的参数:两个极小的缩放向量self.Lambda_d = nn.Parameter(torch.ones(r)) # r 个参数self.Lambda_b = nn.Parameter(torch.ones(out_dim)) # out_dim 个参数def forward(self, x):# B_shared @ diag(Lambda_d) @ A_shared: 全是冻结的delta = VeRALinear.B_shared @ torch.diag(self.Lambda_d) @ VeRALinear.A_sharedreturn self.W(x) + torch.diag(self.Lambda_b) @ delta @ x
参数量对比
| LoRA (r=16) | VeRA (r=256) | 节约 | |
|---|---|---|---|
| 每层参数量 | d×r + r×k ≈ 128K | d + k + r ≈ 10K | ~92% |
| 70B 模型总参数量 | ~160M | ~10M | ~94% |
| 可训参数占比 | 0.2% | 0.01% | 20× |
8. 全家福对比 & 怎么选
一表看懂所有变体
| 方法 | 年份 | 核心改动 | 可训参数 | 效果 vs LoRA | 收敛速度 | PEFT 支持 |
|---|---|---|---|---|---|---|
| LoRA | 2021 | 低秩分解 ΔW=BA | 基准 | 基准 | 基准 | ✅ |
| QLoRA | 2023 | 4bit 量化冻结参数 | 同 LoRA | 几乎持平 | 基准 | ✅ |
| DoRA | 2024 | 权重分解为方向+大小 | +0.01% | +0.5~1.5 | 基准 | ✅ |
| AdaLoRA | 2023 | 动态分配 rank | 同 LoRA | +0.3~0.8 | 略慢 | ✅ |
| LoRA+ | 2024 | A和B不同 lr | 同 LoRA | 微弱提升 | ~2× | ✅ |
| PiSSA | 2024 | SVD 初始化 | 同 LoRA | 持平或微好 | 2~5× | ✅ |
| VeRA | 2024 | 共享冻结矩阵+缩放向量 | −94% | −1~3 个点 | 基准 | ❌ |
怎么选——按你的需求对号入座
🏠 显存紧张
首选 QLoRA。4bit 量化让 7B 模型跑在 6GB 显存上。还不够就 QLoRA + DoRA 叠 buff。
🎯 追求效果(发论文)
DoRA 是目前效果天花板。加 LoRA+(B 的 lr 调大)可以更快收敛。不差显存的话 DoRA + 全参数 bf16。
⚡ 赶时间(快速迭代)
PiSSA 收敛快 2~5 倍 + LoRA+ 再快 2 倍,叠加起来可能快一个数量级。初期实验验证想法极快。
🔬 固定预算压榨性能
AdaLoRA。同等总参数量但按需分配,钱花在刀刃上。适合"我就这么多参数量了,帮我榨出来最优效果"。
📱 边缘设备 / 极致省参
VeRA。94% 参数节省,1MB 微调 7B 模型。效果有折损,但在端侧场景这点折损可以接受。
🛡 最保险(生产环境)
QLoRA(NF4 + 双重量化)。最成熟、坑最少、社区支持最全。DoRA 虽好但还新,可以观望一两个版本再上生产。
可以叠加的组合(不互斥)
很多变体可以叠加使用——它们改的不是同一个东西:
# 最强组合(如果你不差显存)
from peft import LoraConfigconfig = LoraConfig(r=16,lora_alpha=32,use_dora=True, # DoRA: 方向+大小分解init_lora_weights="pissa", # PiSSA: SVD 初始化loraplus_lr_ratio=16, # LoRA+: B 的 lr 大 16 倍
)
# 再加 QLoRA 的 BitsAndBytesConfig 叠量化