10. LoRA Tutorial | LoRA 教程
难度: Medium | 环境: CPU-first | 标签: 训练微调, LoRA, PEFT | 目标人群: 训练机制学习者
🚀 云端运行环境
本章节的实战代码可以点击以下链接在免费 GPU 算力平台上直接运行:
本节导读
大模型微调最直接的做法是更新全部参数,但这会把显存压力迅速放大:除了模型权重,还要保存梯度和优化器状态。很多场景里,我们真正需要的不是重写整个模型,而是在已有能力上做小幅适配。
LoRA 的思路就是冻结原始权重,只在旁边加一条低秩可训练旁路。这一节在训练微调路线里承担的是 09 的工业分支页:09 先把全量 SFT 训练循环和 loss 对齐讲清楚,10 再回答为什么实际工程里更常选择 adapter 路线。学完这里,后面再看 13 和 60 时,你会更容易把 target modules / r / alpha / dropout 这些选择放回完整实验和项目交付里;如果这里没学明白,后面很容易只知道 LoRA 更省显存,却说不清它到底替代了哪些全参更新路径、为什么它会成为工业默认选项。
关键词: LoRA, PEFT, adapter, target modules
前置阅读
导语: 先把模型封装、优化器和最小训练闭环补齐,再看 LoRA 如何只训练一小部分参数。
- P0: 09. PyTorch nn.Module Basics | nn.Module 基础
- P0: 11. PyTorch Optimizers and Loss | 优化器与损失
- P0: 13. Simple Neural Network Training | 简单神经网络训练
相关阅读
导语: 理解 LoRA 的低秩旁路后,下一步最自然的是看它怎样进入端到端微调、4-bit 微调和项目化验证。
- 13. End-to-End Fine-Tuning Experiment | 端到端微调实验
- 26. QLoRA and 4bit Quantization | QLoRA 与 4-bit 量化
- 60. LoRA Fine-Tuning Project | LoRA 微调项目
- 63. LoRA Variants Benchmark | LoRA 变体基准对比
Step 1: 核心思想与痛点
全参微调的主要成本来自保存和更新完整参数,而 LoRA 的思路是只训练一条低秩旁路。
为什么需要 LoRA? 全参微调 (Full Fine-tuning) 一个 7B 模型需要大规模显存来保存参数、梯度和优化器状态。很多微调任务并不需要改动所有权重,只需要在已有能力上做局部适配。
LoRA 的本质: 冻结原始的预训练模型权重,并在目标 Linear 层旁边注入可训练的低秩矩阵 A 和 B。微调时只更新这少量参数;最终推理时,可以将旁路权重合并(merge)回主权重中。
工程上最先要回答的不是“LoRA 公式是什么”,而是“插到哪些层上”。常见选择是:
- Attention 投影层:
q_proj / k_proj / v_proj / o_proj,优先影响注意力模式和指令跟随。 - MLP 投影层:
gate_proj / up_proj / down_proj,优先影响表示变换和任务适配容量。 - 入门默认:先从
q_proj / v_proj或q_proj / k_proj / v_proj / o_proj开始,再根据任务和显存预算扩展到 MLP。
本节用单个 LoRALinear 讲清低秩旁路;后面的项目页再把它放回完整模型和训练报告里。
Step 2: LoRA 代码框架
在 PyTorch 实现中,除了保留原始冻结的线性层权重外,我们需要并排初始化两个很小的可训练矩阵 A 和 B。A 通常用 Kaiming 均匀分布或高斯分布初始化,而 B 严格初始化为零,以保证训练开始时
本节还会记录可训练参数量。判断 LoRA 是否真的生效,不能只看 loss 是否下降,还要确认:
- base linear weight 已冻结。
- 只有
lora_A / lora_B参与训练。 - 可训练参数量等于
r * (in_features + out_features)。 - merge 前后输出一致,说明部署时可以消除 LoRA 分支带来的额外推理计算。
Step 3: 核心公式与张量维度
LoRA 的核心公式可以拆成两部分:冻结的原始权重输出,以及由低秩矩阵 A、B 构成的增量输出。
前向传播公式: 以列向量约定书写,给定预训练权重
:降维矩阵,通常使用随机初始化。 :升维矩阵,必须初始化为全 0,以保证初始状态下 ,也就是微调前的输出和预训练模型完全一致。 (rank):低秩瓶颈大小。越大,可训练参数越多,表达能力越强,显存和过拟合风险也更高。 :缩放因子,通常通过 alpha / r控制 LoRA 更新幅度。dropout:只作用在 LoRA 分支输入上,用于小数据微调时抑制过拟合;推理和 merge 前通常关闭。
代码里输入 x 通常是批量行向量,所以实现写成 (x @ A.T) @ B.T * scaling,和上面的列向量公式是同一个低秩更新。
推理时合并权重 (Merge Weights):
这样在部署时,计算图里没有 A 和 B,完全没有额外的推理耗时(No Inference Latency)。
图解:LoRA 旁路插在哪里
LoRA 不替换原始 Linear,而是在冻结主分支旁边加一条可训练低秩分支。
frozen base branch
x ─────────────── W0 ───────────────────► base_out
│ │
│ trainable LoRA branch ▼
└──── dropout ──► A: k -> r ─► B: r -> d ─► + ─► output
│ │
└── alpha / r scaling在完整 LLaMA block 里,LoRA 通常挂在这些 Linear 上:
| 位置 | 常见 target modules | 作用 | 入门优先级 |
|---|---|---|---|
| Attention | q_proj, v_proj | 改变指令跟随和注意力读写 | P0 |
| Attention | q_proj, k_proj, v_proj, o_proj | 更完整地适配注意力路径 | P1 |
| MLP | gate_proj, up_proj, down_proj | 增加任务表达容量 | P1/P2 |
先少挂、能收敛、能解释,再扩展 target modules。项目报告里要同时记录 target modules、rank、alpha、dropout 和 trainable ratio。
Step 4: 动手实战
要求:请补全下方 LoRALinear 的初始化、前向传播和合并权重的 TODO 逻辑。
额外检查点:实现后要能统计 LoRA 的可训练参数量,并验证 merge 前后的输出一致。这会直接服务后面的 LoRA 微调项目报告。
import torch
import torch.nn as nn
import torch.nn.functional as F
import mathdef count_trainable_parameters(module: nn.Module) -> int:
return sum(p.numel() for p in module.parameters() if p.requires_grad)
class LoRALinear(nn.Module):
def __init__(self, in_features: int, out_features: int, r: int = 8, lora_alpha: int = 16, lora_dropout: float = 0.0):
super().__init__()
self.r = r
self.lora_alpha = lora_alpha
self.scaling = self.lora_alpha / self.r
self.lora_dropout = nn.Dropout(lora_dropout)
# ==========================================
# 主权重冻结,只让低秩旁路参与训练。
# TODO 1: 初始化主权重和 LoRA 矩阵
# ==========================================
# self.linear = ???
# self.linear.weight.requires_grad = ???
# self.lora_A = ???
# self.lora_B = ???
#pass
self.reset_parameters()
def reset_parameters(self):
# ==========================================
# 主权重和 LoRA 旁路分别按各自规则初始化。
# TODO 2: 初始化权重
# ==========================================
# nn.init.kaiming_uniform_(???)
# nn.init.kaiming_uniform_(???)
# nn.init.zeros_(???)
pass
def forward(self, x: torch.Tensor) -> torch.Tensor:
# ==========================================
# 先走主分支,再叠加低秩旁路的增量。
# TODO 3: 实现前向传播
# 1. 计算主权重的输出
# 2. 对 LoRA 分支输入应用 dropout
# 3. 计算 LoRA 分支的输出(先降维再升维,最后乘以缩放因子)
# 4. 将两者相加
# ==========================================
# result = ???
# dropped = ???
# lora_out = ???
return result
def merge_weights(self):
# ==========================================
# TODO 4: 合并权重(零延迟推理)
# 提示: 将 LoRA 的低秩更新合并到主权重中
# ==========================================
# self.linear.weight.data += ???
pass# 运行此单元格以测试你的实现
def test_lora():
try:
in_dim, out_dim = 128, 256
batch_size, seq_len = 32, 10
layer = LoRALinear(in_dim, out_dim, r=8, lora_alpha=16, lora_dropout=0.0)
x = torch.randn(batch_size, seq_len, in_dim)
# 1. 验证初始化导致 B 全零,所以初始输出等于冻结权重的输出
with torch.no_grad():
out_lora = layer(x)
out_base = layer.linear(x)
assert torch.allclose(out_lora, out_base), "初始化错误: lora_B 未被初始化为 0"
# 2. 验证只训练 LoRA 参数
expected_trainable = 8 * (in_dim + out_dim)
assert not layer.linear.weight.requires_grad, "主权重应该被冻结"
assert count_trainable_parameters(layer) == expected_trainable, "LoRA 可训练参数量统计错误"
# 3. 模拟训练一步,改变 B 的值
layer.lora_B.data.normal_(0, 0.02)
out_trained = layer(x)
assert not torch.allclose(out_trained, out_base), "前向传播错误: 旁路未能注入梯度值"
# 4. 验证合并权重的正确性
layer.eval()
out_trained = layer(x)
layer.merge_weights()
out_merged = layer.linear(x)
assert torch.allclose(out_trained, out_merged, atol=1e-5), "权重合并错误: 合并后的输出与分离时的输出不一致!"
print("\n✅ All Tests Passed! LoRA 核心算子、参数统计和 merge 逻辑实现正确。")
except NotImplementedError:
print("请先完成 TODO 部分的代码!")
raise
except (AttributeError, NameError, TypeError, ValueError) as e:
print("代码可能未完成,导致变量未定义" if isinstance(e, NameError) else "代码可能未完成,导致了类型错误")
raise NotImplementedError("请先完成 TODO 部分的代码!") from e
except AssertionError as e:
print(f"❌ 测试失败: {e}")
raise NotImplementedError("请先完成 TODO 部分的代码!") from e
except Exception as e:
print(f"❌ 发生异常: {e}")
raise
test_lora()🛑 STOP HERE 🛑
请先尝试自己完成代码并跑通测试。
如果你正在 Colab 中运行,并且遇到困难没有思路,可以向下滚动查看参考答案。
参考代码与解析
代码
def count_trainable_parameters(module: nn.Module) -> int:
return sum(p.numel() for p in module.parameters() if p.requires_grad)
class LoRALinear(nn.Module):
def __init__(self, in_features: int, out_features: int, r: int = 8, lora_alpha: int = 16, lora_dropout: float = 0.0):
super().__init__()
self.r = r
self.lora_alpha = lora_alpha
self.scaling = self.lora_alpha / self.r
self.lora_dropout = nn.Dropout(lora_dropout)
# TODO 1: 初始化主权重和 LoRA 矩阵
self.linear = nn.Linear(in_features, out_features, bias=False)
self.linear.weight.requires_grad = False
self.lora_A = nn.Parameter(torch.empty(r, in_features))
self.lora_B = nn.Parameter(torch.empty(out_features, r))
self.reset_parameters()
def reset_parameters(self):
# TODO 2: 初始化权重
nn.init.kaiming_uniform_(self.linear.weight, a=math.sqrt(5))
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
nn.init.zeros_(self.lora_B)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# TODO 3: 实现前向传播
result = self.linear(x)
dropped = self.lora_dropout(x)
lora_out = (dropped @ self.lora_A.T) @ self.lora_B.T * self.scaling
result += lora_out
return result
def merge_weights(self):
# TODO 4: 合并权重(零延迟推理)
self.linear.weight.data += (self.lora_B @ self.lora_A) * self.scaling答案与直觉
- 这一题要解决什么: 用低秩旁路替代全参更新,把微调参数量压到很小。
- 为什么这样做: 冻结主权重,训练 A/B 两个小矩阵,合并时又能回到原始线性层。
- 带走的直觉: LoRA 的核心不是“少写几个参数”,而是把更新预算集中到最有效的低秩方向上。
1. TODO 1 & 2: 初始化主权重和 LoRA 矩阵
- 主权重冻结:
self.linear.weight.requires_grad = False是 LoRA 的核心,确保预训练权重不参与梯度计算,只更新 A 和 B。 - LoRA 矩阵形状:
lora_A:[r, in_features]- 降维矩阵lora_B:[out_features, r]- 升维矩阵
- 初始化规则:
lora_A: 使用 Kaiming 初始化,提供随机性lora_B: 必须初始化为全 0,确保训练开始时,即微调模型的初始输出与预训练模型完全一致
- 参数量对比:原始权重
[out_features, in_features],LoRA 参数r * (in_features + out_features)。当r << min(in_features, out_features)时,参数量大幅减少。
2. TODO 3: 前向传播与缩放
- 实现方式:python
result = self.linear(x) dropped = self.lora_dropout(x) lora_out = (dropped @ self.lora_A.T) @ self.lora_B.T * self.scaling result += lora_out - 数学公式:
。代码使用批量行向量,所以写成 (x @ A.T) @ B.T。 - 缩放因子:
scaling = lora_alpha / r,通常lora_alpha = 16,r = 8,则scaling = 2。 - dropout 位置:dropout 只作用在 LoRA 分支输入上,帮助小数据微调时减少过拟合;推理和 merge 前要切到
eval()。 - 计算顺序:先
x @ A^T降维到[..., r],再@ B^T升维到[..., out_features],最后乘以scaling。
3. TODO 4: 合并权重(零延迟推理)
- 实现方式:
self.linear.weight.data += (self.lora_B @ self.lora_A) * self.scaling - 核心原理:由于
,可以直接将 加到 中。 - 零延迟推理:合并后,模型结构与标准 Linear 层完全相同,没有额外的矩阵乘法,推理速度与原始模型一致。
- 部署提醒:merge 前应切到
eval(),避免 dropout 造成 merge 前后输出不一致;merge 后通常不再继续训练这个 LoRA 分支。
工程要点
- target modules:入门常选
q_proj / v_proj,更完整的注意力适配会覆盖q_proj / k_proj / v_proj / o_proj,需要更强容量时再扩展到gate_proj / up_proj / down_proj。 - rank 选择:
r=8通常足够做入门和小任务,r=16可能带来边际提升,r=32以上收益递减且更容易过拟合。 - alpha 选择:常见设置是
alpha = r或alpha = 2r。过大可能让 LoRA 更新过强,过小则适配能力不足。 - dropout 选择:小数据或格式容易过拟合时可以加
0.05-0.1;数据足够多或追求稳定对齐时可以设为0.0。 - 参数统计:项目报告里至少记录 base 参数量、trainable 参数量和 trainable ratio,证明当前实验真的只训练 LoRA adapter。
- 多任务切换:可以为不同任务训练不同的 A/B 矩阵,推理时动态加载,实现“一个基座模型 + 多个 LoRA 适配器”。
