第 13 章 LoRA:参数高效微调
第 13 章 LoRA:参数高效微调
本章来源:本文整理自 LLMs-from-scratch 仓库的 Appendix E: Parameter-efficient Finetuning with LoRA 附录材料,原书为 Sebastian Raschka《Build a Large Language Model (From Scratch)》。
这章要解决什么问题
前面两章(第 6、7 章)我们已经学会了微调:拿一个预训练好的 GPT,用一小批针对性数据继续训练,把它改成分类器,或者改成听指令的助手。
但你可能已经隐隐觉得不对劲:每次微调,我们都在动整个模型。
第 6 章里,1.24 亿参数的 GPT-2 small,光是"全量微调"一遍,就要把所有参数都更新一遍。它勉强还能在笔记本上跑。可现实中的模型动辄几十亿、上百亿参数——这时候,"微调一次 = 重训一遍整个模型" 这件事就变得非常昂贵了。
这一章要回答的问题就是:有没有办法,既把模型调到新任务上,又只训练其中极小一部分参数?
答案是有的,而且思路极其优雅——它叫 LoRA(Low-Rank Adaptation,低秩适配)。原书里,它本来是第 6 章的最后一个小节,因为第 6 章太长,被挪到了附录 E。
一句话目标:用不到原来 1/50 的可训练参数,把模型微调到几乎一样好用。
用大白话讲原理
先想清楚:全量微调为什么贵
微调的动作,说到底就是在做一件事:调整模型里的权重,让它在你的任务上表现更好。
那模型里到底是什么在变?回忆一下第 5 章讲过的训练循环:前向算损失、反向求梯度、优化器更新权重。用公式写出来,对某一层的大权重矩阵 ,训练过程就是在反复地做一个加法:
这里的 就是这一次训练累积下来的"权重要改多少",我们叫它权重更新量(weight update)。
注意 的尺寸——它和 一模一样大。 这就麻烦在哪?假设某一层的权重是 的矩阵,那 也是 ,整整 100 万个数字要学着变。而一个 GPT 里有几百个这样的矩阵。
于是"贵"体现在三处:
- 可训练参数多:几十亿个参数,全都要存梯度、都要更新。优化器(比如 AdamW)还得给每个参数额外存一两份状态,显存直接翻倍。
- 训练慢:参数量摆在那,一轮比一轮耗时。
- 每个任务都要存一份完整模型:你为任务 A 全量微调出一个 10GB 的模型,为任务 B 又得再来一份 10GB。几个任务下来,硬盘就爆了。
类比:全量微调像为了给一本书挑错别字,把整本书重抄一遍。错别字明明只有几个,你却动了全部内容。
低秩分解:不动原书,只在旁边贴便签
LoRA 的做法,一句话:原权重一个字都不改,只在旁边挂一块很小的"补丁"。
回到上面的公式。全量微调是 。LoRA 的核心洞察(来自 Hu 等人 2021 年的论文)是:
那个巨大的更新量 ,其实没必要让它自由地长——它往往是"低秩"的,可以用两个很瘦的矩阵相乘来近似。
具体说,LoRA 不去直接学 ,而是学两个小矩阵 和 ,让它们的乘积去替代 :
于是整层的计算变成:
关键在于这俩矩阵的形状。假设原权重 是 (比如 ),我们引入一个很小的秩(rank),通常取 8、16 这种小数字,那么:
看出来省钱在哪了吗? 原来要学 个数字,现在只要学 个。当 远小于 时,这俩数字差着数量级。
举个例子:、。
- 原权重更新量 : 个参数。
- LoRA 的 和 : 个参数。
只占原来的约 4%。 而且 本身完全不动,还是那 589,824 个老参数,只不过我们不再训练它们了。
类比:现在你不动原书一个字,只是在每页旁边贴一张便利贴,写"这里的第 3 行按这个规则改一下"。书还是那本书,你的修改记录薄薄一叠,收起来就能换一本书用。
为什么可以"不碰原权重":矩阵乘法的分配律
上面还有个细节值得说透,因为它才是 LoRA 真正迷人的地方。
严格来说, 和 是要"加到" 上去的。但如果你把输入 代进去看,会发现一个漂亮的化简。全量微调时:
LoRA 时同理:
这就是矩阵乘法的分配律(distributive law)。它说明: 和 根本不需要真的加在一起,可以各算各的,最后把两个结果相加就行。
这一步的意义是革命性的:
- 预训练权重 永远保持原样,一个字节都不用改。
- LoRA 的两个小矩阵 可以**"即插即用"**——需要的时候现算,不需要的时候把它俩单独存着就行。
- 想换任务?把 换掉, 还是那个 。
所以,一个基础模型 + 一堆轻量的 LoRA 补丁,就能服务无数个任务。存储成本从"每个任务一份完整模型"变成"每个任务一小块补丁"。
两个旋钮:rank 和 alpha
LoRA 层里有两个超参数,理解它俩,就理解了 LoRA 的调节方式:
- rank():控制 、 中间那个"窄腰"有多宽。 越大,能表达的更新越丰富、可训练参数越多; 越小越省。它是表达能力与参数效率之间的一根调节杆。
- alpha():一个缩放系数,用来调节 LoRA 那部分的输出对原输出的影响力度。可以理解成"这块补丁说话有多大声"。
代码里还会用到 这个缩放因子。它本身不是必需的,但更规范、也更好用——因为它让不同 rank 的实验可以直接对比,不用重新调学习率。
原理讲完,下面动手。好消息是:数据集和模型我们第 6 章已经全部准备好了,这一章几乎是纯粹的"给模型装 LoRA"。
13.1 场景照旧:还是那个垃圾短信分类任务
附录 E 的 E.2、E.3 两节,是直接照搬第 6 章的代码——下载 SMS 垃圾短信数据集、切成训练/验证/测试三份、加载 GPT-2 small 的预训练权重、把输出层换成 2 分类头。原书作者甚至明说:这两节不用重复看,你可以直接把第 6 章的 notebook 跑一遍,然后插入后面的 LoRA 代码。
所以这里我们不重复贴一遍数据集代码,只回顾关键事实(细节见第 6 章):
- 数据是垃圾短信二分类,每批 8 条、每条 120 个 token,共 130 个训练 batch、19 个验证 batch、38 个测试 batch。
- 模型是
gpt2-small (124M),加载了 OpenAI 的预训练权重。 - 我们把
out_head换成了 2 维的分类头。
先看一眼微调之前的分类准确率——基本等于瞎猜,因为新换的分类头是随机初始化的。在训练/验证/测试集上各取几个 batch 一算,大概只有 约 46% / 45% / 49%,也就是接近二分类的"抛硬币"水平。说明模型现在完全分不清垃圾短信和正常短信,等我们装上 LoRA 再训练它。
13.2 实现 LoRA 层:LoRALayer
现在开始这一章的正文。第一个要写的东西是 LoRALayer——它负责创建矩阵 和 ,以及 alpha、rank 两个超参数,并完成 这一段计算。
import math
class LoRALayer(torch.nn.Module):
def __init__(self, in_dim, out_dim, rank, alpha):
super().__init__()
self.A = torch.nn.Parameter(torch.empty(in_dim, rank))
torch.nn.init.kaiming_uniform_(self.A, a=math.sqrt(5)) # similar to standard weight initialization
self.B = torch.nn.Parameter(torch.zeros(rank, out_dim))
self.alpha = alpha
self.rank = rank
def forward(self, x):
# Note: The original chapter didn't include the scaling by self.rank
# This scaling is not necessary, but it's more canonical and convenient
# as this lets us compare runs across different ranks without retuning learning rates
x = (self.alpha / self.rank) * (x @ self.A @ self.B)
return x逐点看几个关键设计:
- 用
kaiming_uniform_初始化,和标准权重初始化一致; 初始化为全 0(torch.zeros)。为什么 要开成 0?因为这样 一开始就是全 0 矩阵——加上它等于没加,模型初始输出和原来一模一样。这保证了"装 LoRA 不会破坏已经预训练好的能力",训练从零开始平滑地学增量。 rank就是前面讲的 ,控制 、 中间的宽度,进而控制新增参数量的多少。它是模型适应能力与参数效率之间的那根平衡杆。alpha是缩放超参数,作用在低秩适配的输出上,用来调节这块 LoRA 补丁对原层输出的影响程度。forward里x @ A @ B:先把输入 投影到 维的窄空间(乘 ),再投回原维度(乘 ),最后乘上 做缩放。这就是 在代码里的样子。
不过到这里,LoRALayer 只是能"变换输入 "而已。LoRA 真正要干的,是替换已有的 Linear 层,让更新作用在原来那套预训练权重上。所以下一步,我们要把它和原有的 Linear 层绑在一起。
13.3 把 LoRA 接进 Linear 层:LinearWithLoRA
为了让"原权重 + LoRA 补丁"合体,我们写一个 LinearWithLoRA。它同时持有原来那个 Linear 层和刚刚的 LoRALayer,前向传播时把两者输出相加:
class LinearWithLoRA(torch.nn.Module):
def __init__(self, linear, rank, alpha):
super().__init__()
self.linear = linear
self.lora = LoRALayer(
linear.in_features, linear.out_features, rank, alpha
)
def forward(self, x):
return self.linear(x) + self.lora(x)看 forward 那一行:self.linear(x) + self.lora(x)——这正是 的代码化身。
self.linear(x):走的是原封不动的预训练权重 。self.lora(x):走的是新的、要训练的小矩阵 。
这个类可以直接拿去替换神经网络里的 Linear 层——比如 LLM 里自注意力模块、前馈模块里的那些全连接层。
再强调一遍 初始为 0 的好处:装完 LoRA 之后,
self.lora(x)初始输出全是 0,所以第一遍前向的结果和装 LoRA 之前分毫不差。这意味着装上 LoRA 不会让模型瞬间变差,我们能安心地在此基础上训练。
13.4 给整个 GPT"换血":replace_linear_with_lora
一个 GPT 里散落着很多 Linear 层。我们需要一个函数,递归地把模型里所有 Linear 层都换成 LinearWithLoRA:
def replace_linear_with_lora(model, rank, alpha):
for name, module in model.named_children():
if isinstance(module, torch.nn.Linear):
# Replace the Linear layer with LinearWithLoRA
setattr(model, name, LinearWithLoRA(module, rank, alpha))
else:
# Recursively apply the same function to child modules
replace_linear_with_lora(module, rank, alpha)逻辑很直白:遍历模型的每个子模块——如果是 Linear,就地换掉;如果不是,就往里钻一层,继续找。 递归到最后,模型里所有 Linear 都被套上了 LoRA。
这才是 LoRA "即插即用"的完整形态:不改一行模型结构定义,靠一个替换函数,就能把 LoRA 注入到任意 GPT 里。
13.5 冻结原权重,只训练 A、B
装完之后,做两件事:先把所有参数锁死,再把 LoRA 换进去。 锁死只需要一句 for param in model.parameters(): param.requires_grad = False——换 LoRA 之前模型里有 1.24 亿(124,441,346)个可训练参数,这一句之后立刻变成 0,整个模型被彻底冻住。这一步就是"不改动原书"的代码体现。
不过锁死是暂时的——我们马上要把 LoRA 换进去,而 LoRA 的 、 是新建的 Parameter,默认就是可训练的。所以调用一次 replace_linear_with_lora(model, rank=16, alpha=16) 之后,再数一遍可训练参数,会得到约 267 万(2,666,528)。
可训练参数从 1.24 亿骤降到约 267 万——少了将近 50 倍。(原书原话是 "reduced the number of trainable parameters by almost 50x"。)
我们再验证一下换得对不对,同时也确认一个前面预言过的性质——因为 初始化为 0,装完 LoRA 后模型的初始表现应该纹丝不动。同样取几个 batch 重算一遍准确率,结果和装 LoRA 之前完全一致(约 46.25% / 45.00% / 48.75%)。这正好印证了那句"加上 0 等于没加"——LoRA 层的引入是"无损"的,可以放心开始训练。
13.6 训练与效果/成本对比
训练部分直接复用第 6 章的 train_classifier_simple,用 AdamW 优化器跑 5 个 epoch 即可。注意学习率用的是 8e-4,比第 6 章全量微调时的 5e-5 大不少——这也是 LoRA 的特点之一,因为可训练参数少、且都从零开始,可以用更大的学习率。
这是成本的一面:参数少、跑得动。原书给出的耗时参考如下(不同硬件差异很大):
- MacBook M1(CPU):12.10 分钟
- MacMini M4 Pro(MPS):2.16 分钟
- Jetson Nano(CUDA):3.50 分钟(读者分享)
- DGX Spark(CUDA):1.02 分钟
训练完之后,在完整数据集上算出最终准确率——训练集 99.81%、验证集 97.99%、测试集 96.67%。
一句话总结这一节:LoRA 用不到原来 1/50 的可训练参数,把垃圾短信分类任务刷到了 96.67% 的测试准确率——微调是成功的。
关键概念
- LoRA(Low-Rank Adaptation,低秩适配):只调整模型一小撮低秩参数、来把预训练模型适配到具体数据集的微调技术。因为只动一小块,所以又快又省。
- 权重更新量 :反向传播中学到的"权重要改多少"。全量微调里它和原权重一样大,是开销的主要来源。
- 低秩分解:LoRA 不求 本身,而是学两个小矩阵 、,用 来近似它。可训练参数从 降到约 。
- 分配律的作用:,所以原权重 和 LoRA 矩阵 可以各算各的——这使得 一字不用改、LoRA 补丁可插拔。
- rank():、 中间"窄腰"的宽度,控制 LoRA 新增参数量,是表达能力和参数效率的平衡杆。
- alpha():缩放超参数,调节 LoRA 输出对原层输出的影响强度;配合 使用,方便不同 rank 的实验直接对比。
- 初始化为 0:使 初始为全 0,装上 LoRA 后模型初始行为不变——引入 LoRA 是"无损"的。
LoRALayer/LinearWithLoRA/replace_linear_with_lora:分别负责"算 "、"原权重 + LoRA 相加"、"递归替换模型里所有 Linear 层"。- 冻结(freezing):
requires_grad = False把原权重锁死,只让新建的 、 参与训练。
动手练习
练习解答见原仓库附录材料。想做扎实一点,可以按下面的思路自己动手:
换个 rank 试试:把
replace_linear_with_lora(model, rank=16, alpha=16)里的rank改成 4、8、32,观察两件事——可训练参数数量怎么变(用requires_grad求和那个办法量一下),以及最终测试准确率怎么变。体会 这个"平衡杆"的作用。只给部分层装 LoRA:现在的
replace_linear_with_lora会把所有Linear都替换掉。试着改一改,只给注意力和前馈模块里的Linear装 LoRA(或者只装最后几个 transformer 块),看看能不能在更少参数下拿到差不多的效果。和全量微调对比:回到第 6 章的 notebook,跑一次全量微调的实验,把可训练参数数量、训练时间、测试准确率三项,和本章的 LoRA 结果摆在一起对比。你会直观地看到 LoRA "省了多少、损失了多少"。
如果只想先自测一下理解,试着回答这两个问题:
- "LoRA 凭什么能做到'不改原权重'?"(提示:从 说起。)
- "为什么 LoRA 层里的 要初始化成全 0?"