跳到主要内容

数据截至 (上游 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_rslorasrc/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 的层实现是 LoraLayersrc/peft/tuners/lora/layer.py:108)和它的线性层形态 Linearsrc/peft/tuners/lora/layer.py:873)。把公式逐项对号:

3.1 A 和 B 就是两个 nn.Linear

update_layersrc/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_parameterssrc/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.forwardsrc/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_weightsrc/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:1028B@A 的形状恰好是 (d_out, d_in),和原权重一样大——这是「可合并、推理零开销」的数学前提。transpose 处理的是 transformers 老款 Conv1D(GPT-2 系)权重转置存储的历史遗留(fan_in_fan_outsrc/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_initsrc/peft/tuners/lora/layer.py:583
"orthogonal" / "lora_ga"正交初始化 / 用梯度信息对齐初始化方向orthogonal_initlora_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_layerr <= 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.pyLoraLayer
建层与缩放同上LoraLayer.update_layer
默认初始化同上LoraLayer.reset_lora_parameters
线性层前向/合并/ΔW同上Linear.forwardLinear.mergeLinear.get_delta_weight
每层 r/α 覆盖src/peft/tuners/lora/model.pyLoraModel._create_and_replace
配置字段src/peft/tuners/lora/config.pyLoraConfigrlora_alphause_rslora