LoRA 从原理到手写实现:为什么只训练不到 1% 的参数,也能微调大模型?
本文面向正在学习大模型微调的同学,从全参数微调的显存问题出发,推导 LoRA 的低秩更新公式,并使用 PyTorch 手写一个可以运行和验证的
LoRALinear。
1. 为什么需要 LoRA?
大语言模型中的线性层可以写成:
y=W0x y=W_0x y=W0x
其中,W0W_0W0 是预训练得到的权重。全参数微调会更新模型中的全部参数,并为这些参数保存梯度和优化器状态。对于数十亿参数的模型,这会带来很高的显存和存储成本。
以一个 4096×40964096\times40964096×4096 的线性层为例,其权重参数量为:
4096×4096=16,777,216 4096\times4096=16,777,216 4096×4096=16,777,216
如果模型中有几十层类似结构,全参数微调的成本会快速增长。
LoRA(Low-Rank Adaptation)的核心做法是:
- 冻结预训练权重 W0W_0W0;
- 不直接训练完整的权重增量 ΔW\Delta WΔW;
- 用两个较小的矩阵表示这个增量;
- 训练时只更新这两个小矩阵。
2. LoRA 的核心公式
微调后的权重可以写成:
W′=W0+ΔW W'=W_0+\Delta W W′=W0+ΔW
LoRA 假设具体任务所需的权重变化具有较低的内在秩,因此把增量分解为:
ΔW=BA \Delta W=BA ΔW=BA
假设原线性层权重为:
W0∈Rdout×din W_0\in\mathbb{R}^{d_{out}\times d_{in}} W0∈Rdout×din
两个 LoRA 矩阵的形状分别是:
A∈Rr×din A\in\mathbb{R}^{r\times d_{in}} A∈Rr×din
B∈Rdout×r B\in\mathbb{R}^{d_{out}\times r} B∈Rdout×r
其中 rrr 是人为设置的低秩维度,并且通常满足:
r≪din,dout r\ll d_{in},d_{out} r≪din,dout
实际前向传播常写为:
y=W0x+αrBAx y=W_0x+\frac{\alpha}{r}BAx y=W0x+rαBAx
其中 α\alphaα 是 LoRA 的缩放超参数。
需要注意的是:rrr 不是“矩阵中有几个秩”,而是低秩分支的中间维度,同时也是 BABABA 的最大秩上限:
rank(BA)≤r \operatorname{rank}(BA)\le r rank(BA)≤r
3. LoRA 为什么能节省参数?
原矩阵的参数量为:
doutdin d_{out}d_{in} doutdin
LoRA 新增的参数量为:
rdin+doutr=r(din+dout) rd_{in}+d_{out}r=r(d_{in}+d_{out}) rdin+doutr=r(din+dout)
仍以 4096×40964096\times40964096×4096 的线性层为例,设置 r=16r=16r=16:
16×4096+4096×16=131,072 16\times4096+4096\times16=131,072 16×4096+4096×16=131,072
新增参数占原矩阵的比例为:
131,07216,777,216≈0.78125% \frac{131,072}{16,777,216}\approx0.78125\% 16,777,216131,072≈0.78125%
这意味着该线性层只需训练不到 1% 的参数。
不过,这只是单个方阵的比例。整个模型的可训练参数比例还取决于 LoRA 覆盖了哪些层、Rank 的大小以及模型结构。
4. 数据在 LoRA 分支中如何流动?
假设输入形状为:
x∈RB×din x\in\mathbb{R}^{B\times d_{in}} x∈RB×din
LoRA 分支进行两次映射:
[B,din]→A[B,r]→B[B,dout] [B,d_{in}] \xrightarrow{A} [B,r] \xrightarrow{B} [B,d_{out}] [B,din]A[B,r]B[B,dout]
也就是:
输入维度 d_in
↓ A
低秩维度 r
↓ B
输出维度 d_out
完整线性层存在两条路径:
输入 x
├── 冻结的基础层 W₀ ─────────┐
└── Dropout → A → B → 缩放 ──┤
↓
相加
5. 使用 PyTorch 手写 LoRALinear
下面实现一个包装现有 nn.Linear 的 LoRA 层。
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class LoRALinear(nn.Module):
def __init__(
self,
base_layer: nn.Linear,
rank: int = 8,
alpha: float = 16.0,
dropout: float = 0.0,
):
super().__init__()
if rank <= 0:
raise ValueError("rank 必须大于 0")
self.base_layer = base_layer
self.rank = rank
self.alpha = alpha
self.scaling = alpha / rank
in_features = base_layer.in_features
out_features = base_layer.out_features
# A: [rank, in_features]
self.lora_A = nn.Parameter(
torch.empty(rank, in_features)
)
# B: [out_features, rank]
self.lora_B = nn.Parameter(
torch.zeros(out_features, rank)
)
self.lora_dropout = nn.Dropout(dropout)
# 冻结预训练线性层
for parameter in self.base_layer.parameters():
parameter.requires_grad = False
# A 随机初始化,B 保持为 0
nn.init.kaiming_uniform_(
self.lora_A,
a=math.sqrt(5),
)
def forward(self, x):
base_output = self.base_layer(x)
lora_hidden = F.linear(
self.lora_dropout(x),
self.lora_A,
)
lora_output = F.linear(
lora_hidden,
self.lora_B,
)
return base_output + self.scaling * lora_output
F.linear(x, weight) 实际执行的是:
x⋅weightT x\cdot weight^T x⋅weightT
所以代码中的形状变化为:
x: [batch, d_in]
lora_A: [r, d_in]
lora_hidden: [batch, r]
lora_B: [d_out, r]
lora_output: [batch, d_out]
6. 为什么 A 随机初始化、B 零初始化?
初始化时令:
B=0 B=0 B=0
因此:
BA=0 BA=0 BA=0
LoRA 层刚插入模型时:
W′=W0 W'=W_0 W′=W0
也就是说,加入 LoRA 不会在训练开始前突然改变基础模型的输出。
可以使用下面的代码进行验证:
torch.manual_seed(42)
base = nn.Linear(4, 3)
lora = LoRALinear(
base_layer=base,
rank=2,
alpha=4,
)
x = torch.randn(5, 4)
base_output = base(x)
lora_output = lora(x)
print(torch.allclose(base_output, lora_output, atol=1e-6))
预期输出:
True
如果 A、B 都初始化为零,两者会出现不利的梯度耦合。常见做法是让一侧随机初始化、另一侧初始化为零。
由于初始时 B=0B=0B=0,第一次反向传播时,A 的梯度可能为零;B 更新后,后续训练步骤中 A 就能获得有效梯度。
7. 验证只有 LoRA 参数参与训练
for name, parameter in lora.named_parameters():
print(
name,
tuple(parameter.shape),
parameter.requires_grad,
)
预期结果类似:
lora_A (2, 4) True
lora_B (3, 2) True
base_layer.weight (3, 4) False
base_layer.bias (3,) False
统计可训练参数:
trainable = sum(
p.numel()
for p in lora.parameters()
if p.requires_grad
)
total = sum(
p.numel()
for p in lora.parameters()
)
print("可训练参数量:", trainable)
print("总参数量:", total)
print("可训练比例:", trainable / total)
8. Rank 和 Alpha 应该怎么选?
Rank rrr
Rank 决定低秩分支的表达能力和参数量。
- rrr 小:参数少、速度快,但可能欠拟合;
- rrr 大:表达能力强,但显存和训练成本增加;
- Rank 增大不代表效果一定等比例提升。
常见实验起点:
r = 8、16、32
项目中最好不要只使用一个 Rank,而应进行消融实验。
Alpha α\alphaα
LoRA 的实际缩放系数为:
αr \frac{\alpha}{r} rα
例如:
r = 16, alpha = 16 → scaling = 1
r = 16, alpha = 32 → scaling = 2
常见起点是 alpha=r 或 alpha=2r,但最终应该以验证集结果为准。
9. LoRA 应该加到哪些层?
Transformer 注意力模块常见的投影层包括:
q_proj
k_proj
v_proj
o_proj
MLP 中常见:
gate_proj
up_proj
down_proj
低成本配置可以从 q_proj 和 v_proj 开始;更充分的适配通常会覆盖 Attention 和 MLP 中的多个线性层。
适合项目的消融设计:
方案 A:q_proj + v_proj
方案 B:q_proj + k_proj + v_proj + o_proj
方案 C:Attention 四个投影 + MLP 三个投影
比较三种方案的:
- 可训练参数量;
- 验证集 Loss;
- 下游任务指标;
- 训练时间和显存。
10. 如何合并 LoRA 权重?
训练完成后,可以把增量合并进基础权重:
Wmerged=W0+αrBA W_{merged}=W_0+\frac{\alpha}{r}BA Wmerged=W0+rαBA
代码如下:
@torch.no_grad()
def merge_lora_weights(lora_layer: LoRALinear):
delta_weight = (
lora_layer.lora_B
@ lora_layer.lora_A
)
lora_layer.base_layer.weight.add_(
lora_layer.scaling * delta_weight
)
其中:
$$
[d_{out},r]\times[r,d_{in}]
[d_{out},d_{in}]
$$
合并后,不应再额外执行 LoRA 分支,否则相同增量会被计算两次。
部署时有两种方式:
- 保留“基础模型 + Adapter”,便于快速切换不同任务;
- 合并权重,减少额外分支并得到独立模型。
11. LoRA 的局限
LoRA 减少的是可训练参数、梯度和优化器状态,但基础模型权重仍然需要加载到显存中。
例如 7B 模型使用 FP16 权重时,仅权重理论上就需要:
7×109×2 bytes≈14 GB 7\times10^9\times2\text{ bytes}\approx14\text{ GB} 7×109×2 bytes≈14 GB
再加上激活、临时张量和运行开销,普通消费级显卡仍可能比较紧张。这也是 QLoRA 出现的原因:将冻结的基础模型量化为 4-bit,同时继续用较高精度训练 LoRA 参数。
更多推荐



所有评论(0)