当前位置:首页 > AI编程类

微调大模型为什么只改一小部分参数就够?LoRA低秩分解原理拆解

2周前 (09-30)AI编程类21

想在开源模型上做微调,拦在前面的门槛往往不是数据,而是显存。一个70亿参数的模型,全量微调要吃掉多少显存?先按混合精度训练的标准开销算一遍。

权重用fp16存储,70亿乘2字节约14GB;反向传播产生的梯度同样14GB;Adam优化器要给每个参数维护一阶和二阶动量,用fp32存,每参数8字节,又是56GB。三块加起来接近84GB,还没算激活值和临时张量。单张80GB的卡都放不下。

LoRA(Low-Rank Adaptation,低秩适配)给出的方案是:不动原始权重,只训练一小撮新增参数,显存占用能压到原来的零头。这篇文章从它的数学假设讲起,把公式、参数账和代码串一遍。

全量微调到底贵在哪

lora-canshu-duibi.jpg

拆开看,全量微调的成本由三部分构成:要更新的权重、对应的梯度、优化器状态。其中优化器状态这块容易被低估——Adam为每个可训练参数额外存两份fp32的量,这一项的体量是权重的四倍。

所以真正昂贵的不是"前向计算",而是"要更新的参数有多少"。这给了一个明确的优化方向:如果把可训练参数从70亿降到几百万,梯度和优化器状态就同步缩水,显存问题自然缓解。

问题是,只训练很少一部分参数,效果能追得上全量微调吗?

关键假设:微调带来的权重变化是低秩的

LoRA成立的前提,来自一篇关于内在维度的研究。其中有个结论:大模型在适配下游任务时,权重的变化量并不需要占满整个参数空间,它落在一个维度低得多的子空间里就能表达得不错。

换成矩阵的语言:假设某一层的原始权重是矩阵W0,微调后的权重是W0加上一个变化量ΔW。这个ΔW虽然是和W0同样大的矩阵,但它的"有效秩"很低——它携带的信息量,用远小于原维度的空间就能装下。

低秩的直观含义可以这么理解。一张4096×4096的矩阵有1677万个元素,但如果这1677万个数其实都由少数几个方向组合出来,那它本质上没那么"自由"。就像一个班级的考试成绩,看似几十个数,实际可能主要由"整体水平"和"偏科程度"两个因素决定。

ΔW = B·A:把大矩阵拆成两个瘦矩阵

既然ΔW是低秩的,就不必直接去学习这个d×k的大矩阵,而是把它分解成两个小矩阵相乘:

W = W0 + ΔW = W0 + B · A

其中:
  W0 ∈ R^(d×k)   原始权重,冻结,不参与更新
  B  ∈ R^(d×r)   可训练
  A  ∈ R^(r×k)   可训练
  r ≪ min(d, k)  秩,取值远小于矩阵维度

这里A负责把输入降到r维的低秩空间,B再把它升回原来的维度。两个瘦矩阵一乘,得到的就是那个大矩阵的变化量。

参数账很直观。原来要学d×k个数,现在只要学r×(d+k)个数。

代入具体数字算一遍。取d = k = 4096,r = 8:

方案可训练参数量计算式
全量微调16,777,216(约1677万)4096 × 4096
LoRA(r=8)65,536(约6.5万)8 × (4096 + 4096)

单个矩阵上,参数量降到0.39%。而实际挂到一个70亿参数的模型上时,因为只有部分层被加上适配器,整体可训练参数占比通常在千分之一量级。梯度显存和优化器状态随之同步下降,这就是显存能被压下来的原因。

缩放因子 α/r 与初始化

前向传播时,LoRA的改动落在哪里?原始计算是h = W0·x,加上适配器之后变成:

h = W0 · x + (α / r) · B · A · x

式子里多了一个缩放系数α/r。它的作用是让不同秩配置下的更新幅度保持可比——r调大了,B·A的数值尺度会变,用α/r拉回来,免得换一次秩就要重新调学习率。

经验上α常取r的1到2倍。r=8配α=16、r=16配α=32,都是见得到的组合。

初始化这一步有个细节很关键:A用随机高斯分布初始化,B直接初始化为零。这样在训练开始的那一刻,B·A整体等于零,模型的输出和微调前完全一致。训练是从原始能力出发平滑推进的,不会因为新增模块引入的随机扰动把已有能力带偏。

落到代码:用 peft 挂一个适配器

from peft import LoraConfig, get_peft_model

config = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["q_proj", "v_proj"],   # 只挂到部分线性层
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(base_model, config)
model.print_trainable_parameters()
# trainable params: 4,194,304
# all params: 6,738,415,616
# trainable%: 0.06

输出里的那行统计值得留意:可训练参数419万,占全体67亿的0.06%。这个数量级意味着,原本放不下的训练任务,现在消费级显卡也能跑起来。

target_modules决定适配器挂在哪些层上。只挂注意力的q_proj和v_proj是很常见的做法,参数省;如果任务对能力要求更高,也可以把k_proj、o_proj乃至FFN层的线性层一并加上,代价是参数量上升。

超参数怎么选

参数作用常见取值与取舍
r(秩)决定适配器的表达容量4/8/16/32/64;简单任务8够用,复杂任务往16到64调
lora_alpha缩放因子,实际缩放为α/r常取r的1到2倍
target_modules适配器挂载位置注意力投影层常用;追求效果可加FFN层
lora_dropout抑制过拟合0.05到0.1;数据量小可适当提高
learning_rate训练步长可比全量微调高一些,1e-4到3e-4量级

r不是越大越好。秩提高会增加参数和过拟合风险,而在很多任务上加到某个值之后收益就趋于平缓。稳妥的做法是从r=8起步,观察验证集表现再决定要不要往上调。

推理阶段:适配器还能合并回去

LoRA还有一个工程上的便利。训练完成后,可以把B·A乘出来直接加到W0上:

merged_model = model.merge_and_unload()
# 合并后:W = W0 + (α/r) · B · A

合并之后,模型结构跟原始结构完全一致,推理时没有额外计算、没有额外延迟,等于把学到的能力"焊"进了权重里。这一点对部署很友好。

当然也可以选择不合并,保留适配器独立存放。这样同一个基座模型可以挂载多套适配器,按请求切换不同能力,代价是每次前向都要多算一遍B·A。

它的边界在哪

LoRA省的是训练开销,但它不是处处适用。它假设下游任务需要的能力,可以用低秩的方式在原始权重的子空间里表达。如果目标任务跟基座的能力差得太远——比如让一个纯文本模型去理解图像——那么改动落在低秩子空间里的假设就不太成立,效果会打折。

另外,LoRA更新的是既有层上的适配器,它不改变模型的结构,也不新增能力。想让模型掌握全新的模态或全新的知识体系,往往还得考虑更重量级的方案。

至于显存,如果基座本身太大,连把权重装进显存都困难,LoRA也帮不上忙。这时候通常会叠加量化——把基座权重压到4比特存放,适配器仍用fp16训练,也就是QLoRA的思路,让更大规模的模型能在单卡上微调。

小结

把逻辑再捋一遍:全量微调之所以贵,是因为要更新的参数量太大,梯度和优化器状态跟着膨胀。LoRA的依据是"微调带来的权重变化是低秩的"这个假设,于是用ΔW = B·A把一个大矩阵拆成两个瘦矩阵,参数量从d×k降到r×(d+k),降幅可达几个数量级。初始化时让B为零保证起点不变,训练时用α/r控制更新幅度,推理时可以合并回原权重而不增加延迟。

理解了这个推导,再去看各种参数高效微调方案,会发现思路是共通的——都是在"可训练参数总量"和"表达能力"之间找平衡点,LoRA只是其中落地得比较广的一种。

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。