← 返回首页
09_lora_viz.html

手搓 LoRA:冻结底座,只训 1% 参数

上一关 第 8 章 · SFT全量微调 —— 124M 参数全更新。 这一关换手段不换任务:还是同一件 SFT,但用 LoRA —— 冻住底座,只在每层旁边挂一条低秩旁路 B·A,只训那 ~1% 的新参数,存盘只是个几 MB 的 adapter。 配套代码 phase2-sft-lora/07_lora.py

STEP 1
为何不动整座大楼
STEP 2
低秩旁路:省在哪
STEP 3
冻结 + B=0 出发
STEP 4
只训 A、B
STEP 5
前后对比 & 达标
这一关 base 124M 整段冻结 r 8 · α 16 旁路宽度/音量 可训练 0.94% 1.18M/125.7M lr 1e-3 比全量 SFT 大 50×

① 同一件事,何必把整座大楼都翻新?

第 8 章的全量 SFT 把 124M 个参数全部更新了一遍,训练要为每个参数都存梯度/优化器状态,存盘是一份完整的 ~498MB 新模型。可我们要教的只是"答题的姿态"这点小改动 —— 真有必要动到每一根钢筋吗? LoRA 的想法:底座一个数都不动,只在旁边搭一条很细的"改装管道",改动量压到 1% 级别。

全量 SFT(上一关 · 第 8 章)
124M
参数全部可训练 · 存一份 ~498MB 完整模型
LoRA(这一关 · 第 9 章)
1.18M ≈0.94%
底座冻结 · 只存 ~4.7MB adapter
省的不只是硬盘:可训练参数少,显存里要存的梯度和优化器状态也跟着少一大截 —— 这正是单卡/小显存能微调大模型的关键。 代价是:LoRA 的表达力被"低秩"限制住,极端任务上可能略逊全量微调;但做 SFT 这类"调姿态"的活,通常几乎追平
↳ 下一步:这条"细管道"为什么这么省?拆开 B·A 算笔账。

② 低秩旁路:用两块小矩阵代替一块大矩阵

原来一层是 y = W·x,W 是个大方阵(例如 768×768 ≈ 59 万个数)。 LoRA 不去改 W,而是在旁边并上 y = W·x + (B·A)·x —— A 把 768 维压到 r 维(瓶颈),B升回 768 维。 r 很小,所以 AB 加起来比 W 小得多。拨动 r 看整模型的账:

8
1.18M
可训练参数(A+B 全层合计)
0.94%
占总参数比例
4.7 MB
adapter 存盘大小(≈)
"低秩"是关键假设:微调要做的那点改变 ΔW,其实用一个很"扁"的矩阵就能近似(秩很低), 不需要一个满秩的大方阵。所以拿 B·A(秩最多 r)去逼近它就够了。r 越大表达力越强、参数也越多 —— 是个旋钮。
↳ 代码:07_lora.pyLoRALinear(lora_A: in→r,lora_B: r→out)· 实测 r=8 → 可训练 1,179,648
↳ 下一步:旁路刚挂上时会不会扰乱 base?看 B=0 这个小心机。

③ 底座冻结,B 置零 —— 从 base 平滑出发

注入 LoRA 时只动两件事:① 把原层 W 整段冻结(requires_grad=False,永不更新); ② 旁路里 A 用小随机数、B 全初始化成 0。于是训练第 0 步旁路输出恒为 0, "装了 LoRA 的模型"和原始 base 一模一样 —— 不会一上来就把 base 搅乱。点下面切换看:

x输入
W冻结·不更新
A压到 r
B升回
+
y输出
↳ 代码:07_lora.pyLoRALinear.__init__(normal_(A) + zeros_(B))与 inject_lora 前的全量 requires_grad=False
为什么是 B 置零,不是 A 置零?
只要 AB一个是 0,乘积 B·A 就是 0,初始旁路都为 0 —— 这点上两者等价。 但若两个都置零,反向传播时梯度也会卡住(谁都推不动谁),旁路永远学不起来。 所以标准做法是一个随机(A)、一个置零(B):既保证"初始 ΔW=0",又留出梯度通路让它能被训起来。
↳ 下一步:除了"优化器只拿到 A、B",训练循环和 06_sft.py 一字不差。

④ 训练循环:和 06_sft.py 逐字相同,只是优化器只拿到 A、B

zero_grad → forward(算 loss) → backward → step —— 一步没变。 loss mask、对话模板、EOS 全照搬 06。 唯一区别:交给 AdamW 的只有 requires_grad=True 的 A、B, backward 的梯度也只流到旁路,底座纹丝不动。点"跑一个 epoch"看真实 loss:

zero_grad梯度清零
forward算 loss(只回答段)
backward梯度→只到 A、B
step只更新 A、B
实测起点 2.08 → 几个 epoch 内收敛到 ~0.004(ckpt10b 上,几十秒)
回答段 loss:
注意两个旋钮和第 8 章 SFT 反着调:LoRA 可训练参数少,通常用更大的学习率(1e-3 vs 全量 2e-5)、 多过几遍数据。底座本来就冻着,不怕学崩,可以大胆点。
↳ 代码:07_lora.pymain 训练循环 · lora_params=[p for p in model.parameters() if p.requires_grad] 只把它们交给优化器
↳ 下一步:只动了 1% 参数,base 到底有没有真被调成会答题?

⑤ 训练前 vs 训练后:1% 参数,同款效果

下面是 07_lora.py 在 124M 上的真实采样。"训练前"是装了 LoRA 但 B=0(等价 base), "训练后"只更新了 A、B。重点和第 8 章一样:有没有套上格式、有没有在 EOS 处停

达标玩具级 · 看机制不刷榜
诚实交代:仍是玩具级(124M、16 条、纯英文),学到的是"答题姿态"而非可靠能力。 有意思的是:同样 16 条数据,这次 LoRA 把 "I love cats" 译成了更准的 "J'aime les chats." —— 别据此下"LoRA 比全量强"的结论,玩具规模波动很大,只说明1% 参数足够拿下这点姿态改动
↳ 代码:07_lora.pychat · 跑法:python 07_lora.py --ckpt ../phase1-124m/ckpt10b/latest.pt --epochs 60
Q&A常见疑问
训练完怎么用?adapter 要"合并"回底座吗?
两种都行:① 推理时叠加 —— 加载原始 base,再挂上 adapter,前向时算 Wx + scaling·BAx(本关代码就是这样,一份底座可挂多个 adapter 切换任务); ② 合并 —— 把 scaling·B·A 直接加进 W 得到新权重(W' = W + scaling·BA),推理零额外开销,但就回到一份完整大模型、失去"可插拔"。
该给哪些层加 LoRA?为什么本关给了 4 个 Linear?
原始论文主要加在注意力的 q/v 上。工程上常见做法是给注意力 + MLP 的线性层都加,覆盖更广、效果更稳。 本关 inject_lora 给每个 Block 的 注意力 c_attn / c_projMLP c_fc / c_proj 各挂一条(12 层 × 4 = 48 条)。 lm_headwte 权重绑定、属底座,不加。
下一关(第 10 章)会做什么?
第 8、9 关解决的都是"会答题"(SFT 任务,分别用全量 / LoRA 两种手段)。 第 10 章 · DPO 进一步到"答得合人意"—— 偏好对齐。 机制见 白皮书:RLHF vs DPO
🎉 手搓 LoRA · 通关
你已经亲手冻结底座、挂上低秩旁路,用 1% 参数做完了 SFT,并看懂了 B=0 的小心机。下一关:第 10 章 · DPO