这篇文章会用 “原理 + 数学 + 代码 + 直观类比 + 最佳实践” 的方式,带你彻底掌握 LoRA 中的:
A
、B
矩阵
r
(秩)
α
(alpha)
α/r
缩放比
初始化策略
其他关键参数(dropout、bias、target_modules)
一、LoRA 核心思想回顾
我们有一个大模型,其某层权重为 $ W \in \mathbb{R}^{d \times k} $(例如 4096×4096),全量微调要更新这个 WWW,成本极高。
LoRA 的想法是:不直接改 WWW,而是加一个小的低秩增量:
ΔW=B⋅A其中 A∈Rd×r, B∈Rr×k, r≪d \Delta W = B \cdot A \quad \text{其中 } A \in \mathbb{R}^{d \times r},\ B \in \mathbb{R}^{r \times k},\ r \ll d ΔW=B⋅A其中 A∈Rd×r, B∈Rr×k, r≪d
最终前向传播变为:
h=x⋅(W+ΔW)=x⋅W+x⋅B⋅A⏟LoRA 增量 h = x \cdot (W + \Delta W) = x \cdot W + \underbrace{x \cdot B \cdot A}_{\text{LoRA 增量}} h=x⋅(W+ΔW)=x⋅W+LoRA 增量x⋅B⋅A
- 原始 WWW:冻结不动
- A,BA, BA,B:可训练的小矩阵
这就是“参数高效微调”的本质:只训练极少量参数,就能让大模型适应新任务。
二、A 和 B 矩阵详解
一句话总结(
在 LoRA 中:
- 矩阵 A:负责“捕捉输入中的新特征”(可理解为“激活路径”)
- 矩阵 B:负责“把这些特征映射回输出空间”(可理解为“重组输出”)
它们共同构成一个低秩增量:
ΔW=B⋅A \Delta W = B \cdot A ΔW=B⋅A
其中 A 先学“怎么提取”,B 再学“怎么组合”,两者协同完成对原始权重的微调。
一、背景回顾:LoRA 是干嘛的?
我们有一个大模型,比如 Qwen2.5-VL-7B,它的某个线性层是:
h = x @ W # W 是原始权重,比如 [4096 → 4096]
全参数微调要更新这个 WWW,但参数太多(千万级),显存爆炸。
LoRA 的想法是:我不动 WWW,而是加一个小的增量 ΔW\Delta WΔW:
h=x@(W+ΔW) h = x @ (W + \Delta W) h=x@(W+ΔW)
而这个 ΔW\Delta WΔW 不是随便学的,它是两个小矩阵的乘积:
ΔW=B⋅A其中 A∈Rd×r, B∈Rr×k, r≪d \Delta W = B \cdot A \quad \text{其中 } A \in \mathbb{R}^{d \times r},\ B \in \mathbb{R}^{r \times k},\ r \ll d ΔW=B⋅A其中 A∈Rd×r, B∈Rr×k, r≪d
这就是 LoRA 的核心公式。
二、A 和 B 到底长什么样?有什么区别?
我们以一个具体的例子来说明:
假设有一个 q_proj
层:
- 输入维度 d=4096d = 4096d=4096
- 输出维度 k=4096k = 4096k=4096
- LoRA 秩 r=64r = 64r=64
那么:
矩阵 | 形状 | 作用 |
---|---|---|
AAA | [4096 × 64] | 将高维输入压缩到低维(64维“隐空间”) |
BBB | [64 × 4096] | 将低维表示扩展回原始输出空间 |
所以:
- x⋅Ax \cdot Ax⋅A:把输入 xxx 投影到一个 64 维的“LoRA 特征空间”
- (x⋅A)⋅B(x \cdot A) \cdot B(x⋅A)⋅B:再从这个特征空间投影回最终输出空间
A 是“降维器”(encoder),B 是“升维器”(decoder)
这就像你在做 PCA 或 AutoEncoder:
- A:编码器(encoder)→ 压缩信息
- B:解码器(decoder)→ 重构输出
只是这里不是为了压缩,而是为了高效地学习一个微小的增量变化。
三、A 和 B 的初始化方式不同(关键区别!)
这是很多人忽略但极其重要的点!
#在 peft 库中,默认初始化是:
# 矩阵 A:从正态分布初始化
A = torch.randn(in_features, r) * std # 例如 N(0, 0.02)
# 矩阵 B:全零初始化
B = torch.zeros(r, out_features) # 初始为 0
为什么这么做?
因为:
- 初始时我们希望 ΔW=B⋅A=0\Delta W = B \cdot A = 0ΔW=B⋅A=0,这样模型行为完全由原始 WWW 决定(保持预训练能力)
- 如果 B=0B=0B=0,不管 AAA 是什么,ΔW=0\Delta W = 0ΔW=0
- 所以 B 初始化为 0,A 初始化为小随机数
这样做的好处是:训练开始时模型输出不变,训练稳定;随着训练进行,B 逐渐学到有效的增量
举个形象的比喻
想象你在教一个大师画画(预训练模型),他已经很厉害了。
现在你想让他学会画猫:
- 你不能直接改他的大脑(冻结 WWW)
- 你给他戴一副“智能眼镜”(LoRA)
- 这副眼镜有两个镜片:
- A 镜片(前镜):观察画面,提取“耳朵、胡须”等猫的特征
- B 镜片(后镜):把这些特征融合,告诉大脑“该怎么改笔触”
- 这副眼镜有两个镜片:
刚开始,B 镜片是透明的(B=0B=0B=0),所以不影响作画。
训练过程中,B 学会如何利用 A 提取的特征来调整输出。
A 学“看什么”,B 学“怎么用”
四、数学表达:前向传播过程
给你完整的数学流程:
原始输出:h=x⋅WLoRA 增量:Δh=x⋅(B⋅A)总输出:hnew=x⋅W+x⋅B⋅A=x⋅(W+B⋅A) \begin{align*} \text{原始输出:} &\quad h = x \cdot W \\ \text{LoRA 增量:} &\quad \Delta h = x \cdot (B \cdot A) \\ \text{总输出:} &\quad h_{\text{new}} = x \cdot W + x \cdot B \cdot A \\ &\quad = x \cdot (W + B \cdot A) \end{align*} 原始输出:LoRA 增量:总输出:h=x⋅WΔh=x⋅(B⋅A)hnew=x⋅W+x⋅B⋅A=x⋅(W+B⋅A)
注意顺序:是 x→A→Bx \to A \to Bx→A→B,所以:
- AAA 是第一个变换,必须能接收原始输入 xxx
- BBB 是最后一个变换,必须能输出到原输出空间
五、代码层面:A 和 B 是怎么创建的?
在 peft
源码中(简化版):
class LoraLayer:
def __init__(self, in_features, out_features, r=64):
self.lora_A = nn.Linear(in_features, r, bias=False)
self.lora_B = nn.Linear(r, out_features, bias=False)
# 初始化
nn.init.kaiming_uniform_(self.lora_A.weight, a=math.sqrt(5)) # 小随机数
nn.init.zeros_(self.lora_B.weight) # 全零
def forward(self, x):
return x @ self.lora_B.weight @ self.lora_A.weight # 注意顺序:先A后B,但矩阵乘法反过来
注意!虽然逻辑上是 x⋅A⋅Bx \cdot A \cdot Bx⋅A⋅B,但在 PyTorch 中写成:
delta = x @ (lora_B.weight @ lora_A.weight)
因为矩阵乘法结合律:
(x⋅A)⋅B=x⋅(A⋅B)(x \cdot A) \cdot B = x \cdot (A \cdot B)(x⋅A)⋅B=x⋅(A⋅B),但 A 和 B 的权重要先相乘
六、训练时谁更重要?A 还是 B?
两者都重要,但角色不同:
维度 | 矩阵 A | 矩阵 B |
---|---|---|
作用 | 特征提取(输入侧) | 输出调制(输出侧) |
敏感性 | 对输入变化敏感 | 对任务目标敏感 |
梯度流动 | 接收来自上一层的梯度 | 直接连接 loss,梯度更强 |
训练速度 | 通常收敛较慢 | 通常先更新(因靠近输出) |
实践建议:
- 不要冻结 A 或 B
- 保持两者都可训练
- 使用相同的优化器(如 AdamW)
七、缩放系数 αr\frac{\alpha}{r}rα 的作用
你还记得这个公式吗?
ΔW=αr⋅B⋅A \Delta W = \frac{\alpha}{r} \cdot B \cdot A ΔW=rα⋅B⋅A
它的作用是控制 LoRA 更新的强度。
比如:
- r=64,α=16r=64, \alpha=16r=64,α=16 → 缩放 = 0.25 → 增量较小,保守
- r=64,α=64r=64, \alpha=64r=64,α=64 → 缩放 = 1.0 → 增量较大,激进
相当于:你让 B 的输出乘以一个系数,控制它对最终结果的影响程度
所以即使 B 学得很快,也可以用 α/r\alpha/rα/r 把它“压一压”,防止破坏原始模型能力。
八、图解:A 和 B 的数据流动
x (输入, 4096)
│
▼
┌─────────┐
│ A │ ← LoRA_A: [4096×64],提取低维特征
│ (随机初值)│
└─────────┘
│
▼
z (64维)
│
▼
┌─────────┐
│ B │ ← LoRA_B: [64×4096],初始为0
│ (零初始化) │
└─────────┘
│
▼
Δh (输出增量, 4096)
│
├───→ 加到原始输出 x@W 上
▼
h_new = x@W + Δh
初始状态:B=0 → Δh=0 → 模型行为不变
训练中:B 逐渐非零 → Δh 生效 → 模型微调
九、常见误区澄清
误区 | 正确认知 |
---|---|
“A 和 B 是对称的” | ❌ 不对!A 是 encoder,B 是 decoder,角色不同 |
“可以交换 A 和 B” | ❌ 不行!形状不匹配,且初始化策略不同 |
“只训练 B 就够了” | ❌ 必须一起训练,A 提供特征,B 使用特征 |
“A 应该初始化为 0” | ❌ 不行!那样 ΔW\Delta WΔW 梯度也为 0,无法训练 |
总结:A 与 B 的核心区别表
特性 | 矩阵 A | 矩阵 B |
---|---|---|
形状 | [in_dim × r] | [r × out_dim] |
作用 | 特征提取(降维) | 输出调制(升维) |
初始化 | 小随机数(如 N(0,0.02)) | 全零 |
训练起点 | 有梯度但变化慢 | 初始为0,逐步激活 |
类比 | 眼睛(看输入) | 手(改输出) |
是否可省略 | ❌ 不可 | ❌ 不可 |
三、参数 r
:LoRA 秩(Rank)
含义:
- rrr 是低秩分解的“中间维度”
- 控制 LoRA 模型的容量(表达能力)
参数量计算:
每个 LoRA 层参数数:
Params=r×(in+out)
\text{Params} = r \times (\text{in} + \text{out})
Params=r×(in+out)
例如:in=4096, out=4096, r=64
→ 64×(4096+4096)=524,28864 \times (4096 + 4096) = 524,28864×(4096+4096)=524,288
如果一个模型有 40 层,每层 7 个模块(q,k,v,o,gate,up,down)→ 总 LoRA 参数 ≈ 1.47 亿
但实际上由于共享结构,通常在 100万~300万 可训练参数之间。
如何选择 r
?
r 值 | 显存 | 训练速度 | 表达能力 | 推荐场景 |
---|---|---|---|---|
8~16 | 极低 | 快 | 弱 | 快速实验、极小数据 |
32 | 低 | 较快 | 中 | 平衡选择 |
64 | 中 | 正常 | 强 | 多数推荐 |
128+ | 高 | 慢 | 接近全微调 | 数据量大、追求 SOTA |
📌 Qwen 官方推荐:r=64
或 r=128
四、参数 α
(lora_alpha):缩放系数
含义:
控制 LoRA 增量 ΔW\Delta WΔW 的强度。
最终公式是:
ΔW=αr⋅B⋅A \Delta W = \frac{\alpha}{r} \cdot B \cdot A ΔW=rα⋅B⋅A
所以 α\alphaα 不是独立作用,而是和 rrr 一起决定缩放比例。
为什么需要 α\alphaα?
因为:
- 当 rrr 很小时,B⋅AB \cdot AB⋅A 的数值范围可能太小 → 影响太弱
- 我们想让 LoRA 的更新“力度”可控
👉 举个例子:
- r=8,α=16r=8, \alpha=16r=8,α=16 → 缩放 = 16/8=2.016/8 = 2.016/8=2.0 → 增量被放大 2 倍
- r=64,α=16r=64, \alpha=16r=64,α=16 → 缩放 = 16/64=0.2516/64 = 0.2516/64=0.25 → 增量被缩小
也就是说:α\alphaα 可以补偿 rrr 太小带来的表达力不足
推荐的 α/r\alpha/rα/r 比值(经验法则)
α/r\alpha/rα/r | 特点 | 推荐场景 |
---|---|---|
0.1 ~ 0.25 | 保守,更新弱 | 小数据、防止过拟合 |
0.5 | 平衡 | 多数情况推荐 |
1.0 | 激进,更新强 | 大数据、快速收敛 |
>2.0 | 太强,易破坏原始知识 | ❌ 不推荐 |
最佳实践:保持 α/r=0.5\alpha/r = 0.5α/r=0.5 或 1.0
例如:
r=64, alpha=32
→ 比值 0.5 (推荐)r=64, alpha=64
→ 比值 1.0
建议尝试
alpha=32
或64
,提升学习效率
五、lora_dropout=0.05
:防过拟合
作用:
在 LoRA 的 A 输出上加 dropout,防止过拟合。
x → A → Dropout → B → Δh
- 训练时启用
- 推理时自动关闭
推荐值:
数据量 | dropout |
---|---|
<1k 样本 | 0.1 ~ 0.3 |
1k~10k | 0.05 ~ 0.1 |
>10k | 0.0 ~ 0.05 |
六、bias="none"
:是否微调偏置项
选项:
"none"
:不训练任何 bias( 推荐)"all"
:训练所有 bias"lora_only"
:只训练 LoRA 层内部的 bias
为什么用 "none"
?
-
bias 参数量小
-
微调 bias 效果不明显
-
增加复杂度,容易过拟合
标准做法就是
bias="none"
七、target_modules:哪些模块加 LoRA?
你在代码中写了:
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"]
这是针对 Qwen 类模型(基于 Transformer) 的最佳选择。
各模块含义:
模块 | 所属结构 | 说明 |
---|---|---|
q_proj | Attention | Query 投影 |
k_proj | Attention | Key 投影 |
v_proj | Attention | Value 投影 |
o_proj | Attention | 输出投影 |
gate_proj , up_proj , down_proj | FFN (SwiGLU) | Qwen 使用门控 FFN |
Qwen 的 FFN 是 SwiGLU 结构:
FFN(x)=down_proj(Swish(gate_proj(x))⊗up_proj(x)) \text{FFN}(x) = \text{down\_proj}\left( \text{Swish}(\text{gate\_proj}(x)) \otimes \text{up\_proj}(x) \right) FFN(x)=down_proj(Swish(gate_proj(x))⊗up_proj(x))
所以这三个都必须微调!
八、总结:LoRA 参数最佳实践表
参数 | 推荐值 | 说明 |
---|---|---|
r (秩) | 64 或 128 | 越大表达力越强,显存越高 |
lora_alpha | 2×r 或 r | 保持 α/r=0.5\alpha/r = 0.5α/r=0.5~1.0 |
lora_dropout | 0.05 ~ 0.1 | 小数据集可更高 |
bias | "none" | 不微调 bias |
target_modules | Q/K/V/O + Gate/Up/Down | 覆盖主要路径 |
init for B | zero | 确保初始 ΔW=0\Delta W = 0ΔW=0 |
init for A | 小随机数 | 如 Kaiming Uniform |
你的配置:
r=64, alpha=16, dropout=0.05, bias="none"
→ 唯一建议:把 alpha
改成 32 或 64,让学习更强一些。
九、你可以做的实验
实验 1:对比不同 alpha
实验 | r | alpha | α/r | 观察 |
---|---|---|---|---|
A | 64 | 16 | 0.25 | 收敛慢? |
B | 64 | 32 | 0.5 | 更快? |
C | 64 | 64 | 1.0 | 是否过拟合? |
实验 2:可视化 A 和 B 的权重分布
for name, param in peft_model.named_parameters():
if "lora_A" in name and "weight" in name:
print(f"{name}: mean={param.mean():.6f}, std={param.std():.6f}")
if "lora_B" in name and "weight" in name:
print(f"{name}: mean={param.mean():.6f}, std={param.std():.6f}")
你应该看到:
- A:有非零均值
- B:训练初期接近 0,后期逐渐非零