数据截至 (上游 commit 5779b17b9a67)
01 · LoRA 的数学原理
这一章讲什么: 不动一行 PEFT 源码,先把 LoRA 的数学讲透——低秩假设、ΔW = BA、α/r 缩放、为什么 B 要初始化成零。然后再把这些式子和
src/peft/tuners/lora/layer.py里的真实代码一行行对上。读完你会知道每个超参数在公式里的确切位置。
1. 它要解决的小问题
全量微调一个 d×d 的 权重矩阵 W,就要为这 d² 个参数存梯度和优化器状态。7B 模型大约有几百个这样的矩阵,优化器状态以百 GB 计。
LoRA(Low-Rank Adaptation,论文 arXiv:2106.09685)的赌注是:
微调带来的权重变化量 ΔW,虽然形状是 d×d,但「有效秩」很低——它包含的信息量远小于 d² 个数。
如果这个假设成立,ΔW 就没必要用 d² 个参数表示。这就是整个方法的地基。
2. 思路:用两个小矩阵冒充一个大增量
2.1 核心分解
秩为 r 的 d×d 矩阵恰好可以写成两个小矩阵的乘积:
ΔW = B · A B ∈ ℝ^{d_out×r}, A ∈ ℝ^{r×d_in}, r ≪ min(d_out, d_in)
前向从 h = Wx 变成:
h = W·x + (α/r) · B·A·x
└─基座,冻结─┘ └── 低秩支路,可训练 ──┘
三项设计各有出处,逐个说:
- ΔW = B·A —— 参数量从 d_out·d_in 降到 r·(d_out + d_in)。d=4096、r=16 时,16.8M 个参数变成 13.1 万个,约 1/128。
- 缩放因子 α/r —— α 是常数超参
lora_alpha。把缩放绑在 r 上,换 r 做实验时学习率不用跟着重调(论文 §4.1 的动机)。秩稳定变体 RSLoRA(arXiv:2312.03732)认为 α/√r 在大 r 时更稳,PEFT 里一个开关切换(use_rslora,src/peft/tuners/lora/config.py:642-652)。 - B 初始化成全零 —— 于是训练第 0 步 BA = 0,模型行为精确等于未微调的原模型。优化从「无扰动点」出发,而不是一个随机扰动过的模型。
2.2 图示:一个被 LoRA 化的线性层
x ──┬───────────────────► W·x ──┐
│ (冻结) │
└──► dropout ─► A ─► B ─► ×(α/r) ─► (+) ─► y
r 维瓶颈,只有 A、B 可训练
重点看:支路里没有激活函数,就是两次线性投影;瓶颈维度 r 是整个方法唯一的「容量旋钮」。
2.3 原理演示
下面这段用纯 PyTorch 把上面的式子演一遍(# 示意,非源码):
import torch
import torch.nn as nn
class LoRALinear(nn.Module): # 示意,非源码
def __init__(self, base: nn.Linear, r: int, alpha: int):
super().__init__()
self.base = base # 冻结
for p in self.base.parameters():
p.requires_grad = False
self.A = nn.Linear(base.in_features, r, bias=False)
self.B = nn.Linear(r, base.out_features, bias=False)
nn.init.zeros_(self.B.weight) # 起点 = 原模型
self.scaling = alpha / r
def forward(self, x):
return self.base(x) + self.B(self.A(x)) * self.scaling
# 参数量对比
d, r = 4096, 16
print(d * d, "→", r * (d + d)) # 16777216 → 131072,约 1/128
重点看:self.base(x) + ... * self.scaling 这一行就是 PEFT 真实前向的全部骨架,见下一节。
3. 真实实现:式子里的每一项住在哪
PEFT 里 LoRA 的层实现是 LoraLayer(src/peft/tuners/lora/layer.py:108)和它的线性层形态 Linear(src/peft/tuners/lora/layer.py:873)。把公式逐项对号:
3.1 A 和 B 就是两个 nn.Linear
update_layer(src/peft/tuners/lora/layer.py:219)创建可训练参数:
self.lora_A[adapter_name] = nn.Linear(self.in_features, r, bias=False)
self.lora_B[adapter_name] = nn.Linear(r, self.out_features, bias=lora_bias)
(src/peft/tuners/lora/layer.py:263-264)注意它们被放进 nn.ModuleDict、以 adapter 名为 key——这是多适配器能力的根基,第 3 章细讲。
3.2 缩放因子在建层时一次算死
if use_rslora:
self.scaling[adapter_name] = lora_alpha / math.sqrt(r)
else:
self.scaling[adapter_name] = lora_alpha / r
(src/peft/tuners/lora/layer.py:278-281)α 不 参与训练,前向里只做一次标量乘。
3.3 初始化:A 随机、B 归零
reset_lora_parameters(src/peft/tuners/lora/layer.py:332)里,默认初始化完全复刻微软原版 loralib(代码注释里直接给了 loralib 链接):
nn.init.kaiming_uniform_(self.lora_A[adapter_name].weight, a=math.sqrt(5))
nn.init.zeros_(self.lora_B[adapter_name].weight)
(src/peft/tuners/lora/layer.py:338、:343)A 用 kaiming uniform 给个随机方向,B 归零保证 BA = 0。选 "gaussian" 则 A 换成 std=1/r 的正态(src/peft/tuners/lora/layer.py:339-340)。
3.4 前向就是 §2.3 那一行
Linear.forward(src/peft/tuners/lora/layer.py:1035)的 vanilla 路径:
result = result + lora_B(lora_A(dropout(x))) * scaling
(src/peft/tuners/lora/layer.py:1063)和公式 h = Wx + (α/r)·B·A·dropout(x) 逐符号对应。dropout 只加在支路上,基座输出不受影响(dropout 层建于 src/peft/tuners/lora/layer.py:255-260)。
3.5 ΔW 的还原:B@A 乘回缩放
要把适配器并回基座时,先算 delta 权重(get_delta_weight,src/peft/tuners/lora/layer.py:1005):
output_tensor = transpose(weight_B @ weight_A, self.fan_in_fan_out) * self.scaling[adapter]
(src/peft/tuners/lora/layer.py:1028)B@A 的形状恰好是 (d_out, d_in),和原权重一样大——这是「可合并、推理零开销」的数学前提。transpose 处理的是 transformers 老款 Conv1D(GPT-2 系)权重转置存储的历史遗留(fan_in_fan_out;src/peft/utils/other.py:1311)。
4. 初始化不止一种:一个需要基座权重的「变体分发链」
默认的「A 随机 B 归零」只是起点。update_layer 里有一串 elif,按 init_lora_weights 的取值分发到不同的初始化器(src/peft/tuners/lora/layer.py:288-312):
| 取值 | 干什么 | 实现 |
|---|---|---|
True / "gaussian" | 上节的默认/高斯初始化,不碰基座 | reset_lora_parameters |
"olora" / "pissa" / "corda" / "mica" | 对基座 W 做 SVD/分解,把主成分装进 A、B,同时改基座(W 减去主成分) | olora_init 等(src/peft/tuners/lora/layer.py:384 起) |
"loftq" | 配合量化基座交替优化,让量化误差和 LoRA 初始化互相抵消 | loftq_init(src/peft/tuners/lora/layer.py:583) |
"orthogonal" / "lora_ga" | 正交初始化 / 用梯度信息对齐初始化方向 | orthogonal_init、lora_ga_init |
"eva" | 只先把 B 归零,真正的初始化在外部用激活统计量做 | src/peft/tuners/lora/layer.py:303-304 |
一个共同的工程细节:这些需要读基座权重 W 的初始化器,全部包在 gather_params_ctx(...) 里调用(src/peft/tuners/lora/layer.py:289-310)——DeepSpeed ZeRO-3 下权重是分片的,得先临时聚合成全量才能做 SVD。这是教科书公式里没有、生产代码必须有的东西。
5. 关键细节与坑
- α 不是可训练参数,是建层时烧进
scaling的常数。 想调 α 不用改权重,改配置重建/改scaling即可。 - embedding 层的零初始化方向是反的。
nn.Embedding的 LoRA 是 A 归零、B 用默认初始化(src/peft/tuners/lora/layer.py:346-350,注释同样指向 loralib)。因为这里 A 对应「输出侧」——方向反了,零初始化该落在谁头上也跟着反。 - 每层可以用不同的 r 和 α。
rank_pattern/alpha_pattern按模块名正则覆盖全局值,在_create_and_replace里逐层解析(src/peft/tuners/lora/model.py:234-237)。所以「注意力层 r=16、FFN 层 r=4」是一等公民配置。 - r 必须为正整数——但 AdaLoRA 例外。
update_layer里r <= 0直接报错(src/peft/tuners/lora/layer.py:239-240);AdaLoRA 允许r=0表示该层被剪成零秩(src/peft/tuners/adalora/layer.py:55-57,注释注明见 issue #1539)。 fan_in_fan_out只为一类老层存在。 现代nn.Linear权重是 (out, in),GPT-2 时代的Conv1D是 (in, out);设错它会让 delta 权重转置错、形状对不上。一般不用动。- 低秩是假设不是定理。 任务需要的 ΔW 若真实秩很高(如大幅改输出分布的指令微调),小 r 会成为瓶颈;这时要么加大 r,要么上全量——「参数高效」从来不是「质量等价」的承诺。
6. 代码地图(本章涉及)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| LoRA 层公共状态 | src/peft/tuners/lora/layer.py | LoraLayer |
| 建层与缩放 | 同上 | LoraLayer.update_layer |
| 默认初始化 | 同上 | LoraLayer.reset_lora_parameters |
| 线性层前向/合并/ΔW | 同上 | Linear.forward、Linear.merge、Linear.get_delta_weight |
| 每层 r/α 覆盖 | src/peft/tuners/lora/model.py | LoraModel._create_and_replace |
| 配置字段 | src/peft/tuners/lora/config.py | LoraConfig(r、lora_alpha、use_rslora) |