第 13 章 LLMLoRA微调

第 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 章讲过的训练循环:前向算损失、反向求梯度、优化器更新权重。用公式写出来,对某一层的大权重矩阵 WW,训练过程就是在反复地做一个加法:

Wupdated=W+ΔWW_{\text{updated}} = W + \Delta W

这里的 ΔW\Delta W 就是这一次训练累积下来的"权重要改多少",我们叫它权重更新量(weight update)。

注意 ΔW\Delta W 的尺寸——它和 WW 一模一样大。 这就麻烦在哪?假设某一层的权重是 1000×10001000 \times 1000 的矩阵,那 ΔW\Delta W 也是 1000×10001000 \times 1000,整整 100 万个数字要学着变。而一个 GPT 里有几百个这样的矩阵。

于是"贵"体现在三处:

  • 可训练参数多:几十亿个参数,全都要存梯度、都要更新。优化器(比如 AdamW)还得给每个参数额外存一两份状态,显存直接翻倍。
  • 训练慢:参数量摆在那,一轮比一轮耗时。
  • 每个任务都要存一份完整模型:你为任务 A 全量微调出一个 10GB 的模型,为任务 B 又得再来一份 10GB。几个任务下来,硬盘就爆了。

类比:全量微调像为了给一本书挑错别字,把整本书重抄一遍。错别字明明只有几个,你却动了全部内容。

低秩分解:不动原书,只在旁边贴便签

LoRA 的做法,一句话:原权重一个字都不改,只在旁边挂一块很小的"补丁"。

回到上面的公式。全量微调是 Wupdated=W+ΔWW_{\text{updated}} = W + \Delta W。LoRA 的核心洞察(来自 Hu 等人 2021 年的论文)是:

那个巨大的更新量 ΔW\Delta W,其实没必要让它自由地长——它往往是"低秩"的,可以用两个很瘦的矩阵相乘来近似。

具体说,LoRA 不去直接学 ΔW\Delta W,而是学两个小矩阵 AA 和 BB,让它们的乘积去替代 ΔW\Delta W:

ΔW≈AB\Delta W \approx AB

于是整层的计算变成:

Wupdated=W+ABW_{\text{updated}} = W + AB

关键在于这俩矩阵的形状。假设原权重 WW 是 d×dd \times d(比如 768×768768 \times 768),我们引入一个很小的秩(rank)rr,通常取 8、16 这种小数字,那么:

A∈Rd×r,B∈Rr×dA \in \mathbb{R}^{d \times r}, \qquad B \in \mathbb{R}^{r \times d}

看出来省钱在哪了吗? 原来要学 d×dd \times d 个数字,现在只要学 d×r+r×d=2drd \times r + r \times d = 2dr 个。当 rr 远小于 dd 时,这俩数字差着数量级。

举个例子:d=768d = 768、r=16r = 16。

  • 原权重更新量 ΔW\Delta W:768×768=589,824768 \times 768 = 589{,}824 个参数。
  • LoRA 的 AA 和 BB:768×16+16×768=24,576768 \times 16 + 16 \times 768 = 24{,}576 个参数。

只占原来的约 4%。 而且 WW 本身完全不动,还是那 589,824 个老参数,只不过我们不再训练它们了。

类比:现在你不动原书一个字,只是在每页旁边贴一张便利贴,写"这里的第 3 行按这个规则改一下"。书还是那本书,你的修改记录薄薄一叠,收起来就能换一本书用。

为什么可以"不碰原权重":矩阵乘法的分配律

上面还有个细节值得说透,因为它才是 LoRA 真正迷人的地方。

严格来说,AA 和 BB 是要"加到" WW 上去的。但如果你把输入 xx 代进去看,会发现一个漂亮的化简。全量微调时:

x(W+ΔW)=xW+xΔWx(W + \Delta W) = xW + x\Delta W

LoRA 时同理:

x(W+AB)=xW+xABx(W + AB) = xW + xAB

这就是矩阵乘法的分配律(distributive law)。它说明:WW 和 ABAB 根本不需要真的加在一起,可以各算各的,最后把两个结果相加就行。

这一步的意义是革命性的:

  • 预训练权重 WW 永远保持原样,一个字节都不用改。
  • LoRA 的两个小矩阵 ABAB 可以**"即插即用"**——需要的时候现算,不需要的时候把它俩单独存着就行。
  • 想换任务?把 ABAB 换掉,WW 还是那个 WW。

所以,一个基础模型 + 一堆轻量的 LoRA 补丁,就能服务无数个任务。存储成本从"每个任务一份完整模型"变成"每个任务一小块补丁"。

两个旋钮:rank 和 alpha

LoRA 层里有两个超参数,理解它俩,就理解了 LoRA 的调节方式:

  • rank(rr):控制 AA、BB 中间那个"窄腰"有多宽。rr 越大,能表达的更新越丰富、可训练参数越多;rr 越小越省。它是表达能力与参数效率之间的一根调节杆。
  • alpha(α\alpha):一个缩放系数,用来调节 LoRA 那部分的输出对原输出的影响力度。可以理解成"这块补丁说话有多大声"。

代码里还会用到 α/r\alpha / r 这个缩放因子。它本身不是必需的,但更规范、也更好用——因为它让不同 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——它负责创建矩阵 AA 和 BB,以及 alpha、rank 两个超参数,并完成 xABx A B 这一段计算。

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

逐点看几个关键设计:

  • AA 用 kaiming_uniform_ 初始化,和标准权重初始化一致;BB 初始化为全 0(torch.zeros)。为什么 BB 要开成 0?因为这样 ABAB 一开始就是全 0 矩阵——加上它等于没加,模型初始输出和原来一模一样。这保证了"装 LoRA 不会破坏已经预训练好的能力",训练从零开始平滑地学增量。
  • rank 就是前面讲的 rr,控制 AA、BB 中间的宽度,进而控制新增参数量的多少。它是模型适应能力与参数效率之间的那根平衡杆。
  • alpha 是缩放超参数,作用在低秩适配的输出上,用来调节这块 LoRA 补丁对原层输出的影响程度。
  • forward 里 x @ A @ B:先把输入 xx 投影到 rr 维的窄空间(乘 AA),再投回原维度(乘 BB),最后乘上 α/r\alpha / r 做缩放。这就是 ΔW≈AB\Delta W \approx AB 在代码里的样子。

不过到这里,LoRALayer 只是能"变换输入 xx"而已。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)——这正是 xW+xABxW + xAB 的代码化身。

  • self.linear(x):走的是原封不动的预训练权重 WW。
  • self.lora(x):走的是新的、要训练的小矩阵 ABAB。

这个类可以直接拿去替换神经网络里的 Linear 层——比如 LLM 里自注意力模块、前馈模块里的那些全连接层。

再强调一遍 BB 初始为 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 的 AA、BB 是新建的 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"。)

我们再验证一下换得对不对,同时也确认一个前面预言过的性质——因为 BB 初始化为 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,低秩适配):只调整模型一小撮低秩参数、来把预训练模型适配到具体数据集的微调技术。因为只动一小块,所以又快又省。
  • 权重更新量 ΔW\Delta W:反向传播中学到的"权重要改多少"。全量微调里它和原权重一样大,是开销的主要来源。
  • 低秩分解:LoRA 不求 ΔW\Delta W 本身,而是学两个小矩阵 A∈Rd×rA \in \mathbb{R}^{d \times r}、B∈Rr×dB \in \mathbb{R}^{r \times d},用 ΔW≈AB\Delta W \approx AB 来近似它。可训练参数从 d2d^2 降到约 2dr2dr。
  • 分配律的作用:x(W+AB)=xW+xABx(W + AB) = xW + xAB,所以原权重 WW 和 LoRA 矩阵 ABAB 可以各算各的——这使得 WW 一字不用改、LoRA 补丁可插拔。
  • rank(rr):AA、BB 中间"窄腰"的宽度,控制 LoRA 新增参数量,是表达能力和参数效率的平衡杆。
  • alpha(α\alpha):缩放超参数,调节 LoRA 输出对原层输出的影响强度;配合 α/r\alpha / r 使用,方便不同 rank 的实验直接对比。
  • BB 初始化为 0:使 ABAB 初始为全 0,装上 LoRA 后模型初始行为不变——引入 LoRA 是"无损"的。
  • LoRALayer / LinearWithLoRA / replace_linear_with_lora:分别负责"算 xABxAB"、"原权重 + LoRA 相加"、"递归替换模型里所有 Linear 层"。
  • 冻结(freezing):requires_grad = False 把原权重锁死,只让新建的 AA、BB 参与训练。

动手练习

练习解答见原仓库附录材料。想做扎实一点,可以按下面的思路自己动手:

  1. 换个 rank 试试:把 replace_linear_with_lora(model, rank=16, alpha=16) 里的 rank 改成 4、8、32,观察两件事——可训练参数数量怎么变(用 requires_grad 求和那个办法量一下),以及最终测试准确率怎么变。体会 rr 这个"平衡杆"的作用。

  2. 只给部分层装 LoRA:现在的 replace_linear_with_lora 会把所有 Linear 都替换掉。试着改一改,只给注意力和前馈模块里的 Linear 装 LoRA(或者只装最后几个 transformer 块),看看能不能在更少参数下拿到差不多的效果。

  3. 和全量微调对比:回到第 6 章的 notebook,跑一次全量微调的实验,把可训练参数数量、训练时间、测试准确率三项,和本章的 LoRA 结果摆在一起对比。你会直观地看到 LoRA "省了多少、损失了多少"。

如果只想先自测一下理解,试着回答这两个问题:

  1. "LoRA 凭什么能做到'不改原权重'?"(提示:从 x(W+AB)=xW+xABx(W + AB) = xW + xAB 说起。)
  2. "为什么 LoRA 层里的 BB 要初始化成全 0?"