训练 AI 大模型系列之 LoRA:不动模型本体,靠一条低秩旁路改专长(附完整手算算例)
适用读者 :会用
peft/transformers调模型、但不太清楚 LoRA 内部到底在干嘛的同学。本文不讲玄学比喻,把每一步拆成能算的数,配一个最小玩具例子从头算到尾。
技术定位:LoRA(Low-Rank Adaptation,低秩适应),参数高效微调 PEFT 的龙头方法。
第一部分:8 步概念骨架
一句话本质
不动大模型本体 W₀,只在关键矩阵上练一条小旁路(A、B),训完把它焊回 W₀。
第 1 步:原始模型长什么样
一个个大模型(以 7B 为例)有 32 层 Transformer。每层里有两大块:
- 自注意力 Attention
q_proj:把输入变成"查询向量",决定当前词要关注哪些词k_proj:把输入变成"键向量",标记每个词是什么信息v_proj:把输入变成"值向量",存放每个词的实际内容o_proj:把注意力计算结果整合输出
- 前馈网络 FFN
gate_proj/up_proj/down_proj:门控、升维、降维(数据在里面绕一圈)
每个矩阵里都存着一个 W₀ ,是预训练学出来的权重。第 1 层的 q_proj 有一个 W₀,第 2 层的也有一个,大小一样但数字不同------因为每层学的东西不同。所有 W₀ 加起来就是这个模型的全部"大脑"。
第 2 步:决定给哪些矩阵加 LoRA
python
target_modules=["q_proj", "v_proj"]
意思是:每层的 q_proj 和 v_proj 加 LoRA;k_proj、o_proj、三个 FFN 完全不动。选哪些人设全由你定------这就是 LoRA 的"只在关键处下手"。
第 3 步:A 和 B 从哪来
对每个被选中的矩阵(如某层 q_proj,W₀ 形状 out × in),设一个秩 r(比如 16),系统新建两个小矩阵:
- A :形状
r × in(很小) - B :形状
out × r(很小)
A、B 不是从 W₀ 里拆的,是全新创建 :A 高斯随机数初始化(给个初始方向),B 全部填 0。
为什么 B 初始化为 0?
因为训练一开始 输出 = W₀·x + B·A·x = W₀·x + 0·A·x = W₀·x ------ 模型输出和原始模型完全一样,不会一上来就把模型搞乱。
第 4 步:冻结 W₀,只训练 A 和 B
python
for param in model.parameters():
param.requires_grad = False # 冻结所有 W₀
for param in lora_A.parameters():
param.requires_grad = True # 只让 A 可训练
for param in lora_B.parameters():
param.requires_grad = True # 只让 B 可训练
训练时 W₀ 完全不动,只有 A、B 更新。全部 A、B 加起来只有几 MB~几十 MB,相比主模型几乎可忽略。
第 5 步:前向传播
加 LoRA 的矩阵算 W₀·x + B·A·x;没加的算 W₀·x。32 层走完,得到模型预测结果。
第 6 步:算 loss
把预测和正确答案比,得到一个数字 loss。越大越差、越小越准。
第 7 步:反向传播
loss 从最后一层往回传,每经过一个加 LoRA 的矩阵,算出 A、B 各自该改多少,优化器(AdamW)更新:
A_new = A_old - 学习率 × A的梯度
B_new = B_old - 学习率 × B的梯度
W₀ 全程不动。重复成千上万次,A、B 慢慢学会"把输出偏向客服/医疗/代码风格"。
第 8 步:训练完成后融合
W_new = W₀ + B·A # B·A 形状和 W₀ 一样,可直接相加
- 融合后推理
输出 = W_new·x,更快、不需要任何 LoRA 代码。 - 也可以不融合 ,把 A、B 单独存成
adapter_model(几 MB),在同一基座上切不同任务(客服 LoRA / 医疗 LoRA / 代码 LoRA 来回换)。
8 步总表
| 步骤 | 干什么 |
|---|---|
| 1 | 原始模型 N 层,每层多个 W₀ 矩阵 |
| 2 | 选定哪些矩阵加 LoRA(如 q_proj、v_proj) |
| 3 | 为每个矩阵新建 A 和 B,B 初始化为 0 |
| 4 | 冻结所有 W₀,只让 A、B 可训练 |
| 5 | 前向:有 LoRA 算 W₀·x + B·A·x,否则算 W₀·x |
| 6 | 算 loss,看预测与正确答案差多少 |
| 7 | 反向传播:只更新 A、B,W₀ 不动 |
| 8 | 训练完把 B·A 加进 W₀,融合成 W_new |
第二部分:用最小玩具例子把每一步算出来(含 -100)
目标 :前面讲"道理",这一部分把每个字算成数,一步都不省。设定刻意做到最小,看得清每一手的数字。
训练数据(一行样本)
- 输入:
怎么重启路由器? - 期望输出:
先断电再通电。
简化设定
- 模型:1 层,1 个 q_proj
- W₀ 形状:
4 × 4 - LoRA 秩
r = 2 - 词表大小
5(方便演示)
第 1 步:分词
完整序列 = ["怎么","重启","路由器","?","先","断电","再","通电","。"]
input_ids = [1, 2, 3, 4, 5, 6, 7, 8, 9]
第 2 步:构造 labels(带 -100)
问题部分标 -100,答案部分保留真实 id:
labels = [-100, -100, -100, -100, 5, 6, 7, 8, 9]
-100 的作用 :每个 token 本来都要"算出 loss 并回传梯度";
-100就是一个"此位免考"记号------loss 碰到 -100 直接跳过,这一个位置既不算分、也不回传梯度。 位置 0~3 是问题(题干),不计分;位置 4~8 是答案(考生),才算分。代码里通常这么写:
labels[:, :prompt_len] = -100------ 把 prompt(问题)部分全部标成 -100。
第 3 步:创建 A 和 B
A = [[ 0.1, -0.2, 0.3, 0.1],
[ 0.2, 0.1, -0.1, 0.3]] # 2×4
B = [[0, 0],
[0, 0],
[0, 0],
[0, 0]] # 4×2,全 0
第 4 步:冻结 W₀
W₀.requires_grad = False
A.requires_grad = True
B.requires_grad = True
第 5 步之前(embedding):把"编号"查成"向量"
这是模型自带的一步,发生在 LoRA 之前、分词之后 。token 编号只是整数,模型认不得;embedding 层(也叫
embed_tokens)拿编号去查一张表,每个编号换回一行向量。这就是"每个字变成向量"真正发生的地方。
词表(示意)对应 embedding 表,每行是一个向量:
id=1("怎么") → [1, 0, 0, 0]
id=2("重启") → [0, 1, 0, 0]
id=3("路由器")→ [0, 0, 1, 0]
id=4("?") → [0, 0, 0, 1]
...
所以 input_ids=[1,2,3,4,5,6,7,8,9] 里:
第 1 个编号 1 → 查表 → 得到 ["怎么" 的向量] = [1,0,0,0]
第 2 个编号 2 → 查表 → 得到 ["重启" 的向量] = [0,1,0,0]
......每个编号都这样换成一个向量,再一起往下走。
真实模型里 这不是 4 维,是 1024 维 :每个编号查出一行 1024 个数,再送进 q_proj 跟 1024×1024 的 W₀ 相乘。下面为了手算,把"怎么"查表得到的向量简写成
x=[1,0,0,0]。
第 6 步:前向传播
输入 x = [1, 0, 0, 0]。
W₀ · x = [1, 0, 0, 0]
A · x = [0.1, 0.2]
B · (A·x) = [0, 0, 0, 0] # B 全 0,所以这一项为 0
输出 = W₀·x + B·A·x
= [1, 0, 0, 0]
模型对每个位置输出一个 5 维概率分布(词表大小 = 5):
位置0("怎么"): 预测 → [0.3, 0.2, 0.2, 0.15, 0.15] 标签-100
位置1("重启"): 预测 → [0.1, 0.4, 0.2, 0.15, 0.15] 标签-100
位置2("路由器"): 预测 → [0.2, 0.1, 0.3, 0.2, 0.2] 标签-100
位置3("?"): 预测 → [0.15, 0.15, 0.2, 0.3, 0.2] 标签-100
位置4("先"): 预测 → [0.1, 0.1, 0.1, 0.2, 0.5] 标签=5
位置5("断电"): 预测 → [0.2, 0.1, 0.1, 0.1, 0.5] 标签=6
位置6("再"): 预测 → [0.1, 0.2, 0.1, 0.1, 0.5] 标签=7
位置7("通电"): 预测 → [0.3, 0.1, 0.1, 0.1, 0.4] 标签=8
位置8("。"): 预测 → [0.2, 0.2, 0.2, 0.2, 0.2] 标签=9
第 7 步:算 loss(带 -100 屏蔽)
问题区(标签 -100)→ 全部跳过,不算 loss: 位置 0~3 跳过。
答案区(标签是真实 id)→ 逐个算: loss = -log(模型给出真实 token 的概率):
位置4: label=5,模型给 token 5 的概率=0.5 → loss₄ = -log(0.5) = 0.693
位置5: label=6,模型给 token 6 的概率=0.5 → loss₅ = -log(0.5) = 0.693
位置6: label=7,模型给 token 7 的概率=0.5 → loss₆ = -log(0.5) = 0.693
位置7: label=8,模型给 token 8 的概率=0.4 → loss₇ = -log(0.4) = 0.916
位置8: label=9,模型给 token 9 的概率=0.2 → loss₈ = -log(0.2) = 1.609
总 loss(只对答案区 5 个位置取平均):
loss = (0.693 + 0.693 + 0.693 + 0.916 + 1.609) / 5
= 4.604 / 5
= 0.921
位置 0~3 的 loss 完全不算 ,因为
label=-100。这就是"只问答案、不问问题"在数字上的样子。
第 8 步:反向传播
loss = 0.921 从位置 8 往回传,梯度只经过位置 4~8(答案) ,位置 0~3 没有梯度。学习率 lr = 0.1,更新 A、B(化简后的梯度):
∂loss/∂B = [[-0.15, -0.30],
[ 0.15, 0.30],
[ 0, 0 ],
[ 0, 0 ]]
∂loss/∂A = [[-0.15, 0, 0, 0],
[-0.30, 0, 0, 0]]
B_new = B - 0.1 × ∂loss/∂B
= [[ 0.015, 0.030],
[-0.015, -0.030],
[ 0, 0 ],
[ 0, 0 ]]
A_new = A - 0.1 × ∂loss/∂A
= [[0.115, -0.2, 0.3, 0.1],
[0.230, 0.1, -0.1, 0.3]]
第 9 步:再跑一次前向(验证动了)
用更新后的 A、B:
A_new · x = [0.115, 0.230]
B_new · (A_new·x) = [0.0086, -0.0086, 0, 0]
输出 = W₀·x + B·A·x
= [1.0086, -0.0086, 0, 0]
输出稍微偏了一点,往目标方向移动了------A、B 真的在起作用,不再全是 0。
第 10 步:重复 + 融合
换下一条数据,再走一遍 5→6→7→8。几万条跑完,A、B 就学会"看到问题后该怎么回答"。最后:
W_new = W₀ + B·A # 4×4 + 4×4 = 4×4
推理时直接用 W_new,不再需要 A、B。
第三部分:低秩(rank)是什么 + 为什么 B·A 加回 W₀ 不会打乱它
"低秩"到底是什么
一个矩阵的"秩" = 有几行是"真独立"的,即不能被别的行拼出来。
如果某一行能用别的行"乘倍数再加"拼出来,这行就不算数(不增加秩);只有拼不出来的那几行才算独立行。独立行的数目 = 秩。
例子 A:3×3,但只有 1 行是"真新"的(低秩)
M1 = [ 2 4 6 ] 第1行
[ 1 2 3 ] 第2行 = 第1行 × 0.5
[ 3 6 9 ] 第3行 = 第1行 × 1.5
- 第1行
[2,4,6]:独立。 - 第2行
[1,2,3]:第1行 × 0.5,拼得出来 → 不独立。 - 第3行
[3,6,9]:第1行 × 1.5,也拼得出来 → 不独立。
独立行 1 行 → 秩 = 1 = 超低秩。整张表其实就是那 1 行换着花样放大,信息量跟 1 行差不多。
例子 B:3×3,有两行真独立(秩 = 2)
M2 = [ 1 0 0 ]
[ 0 1 0 ] 这两行互相拼不出来
[ 2 3 0 ] 第3行 = 2×第1行 + 3×第2行(拼得出来)
- 第1行
[1,0,0]:独立。 - 第2行
[0,1,0]:靠第1行乘任何数拼不出(第1行第2、3列都是 0)→ 独立。 - 第3行
[2,3,0]:能拼出来 → 不独立。
独立行 2 行 → 秩 = 2。
例子 C:3×3,三行都独立(满秩 / 高秩)
M3 = [ 1 0 0 ]
[ 0 1 0 ]
[ 0 0 1 ]
三行怎么拼都拼不出对方 → 独立行 3 行 → 秩 = 3 = 满秩。
小结:
| 矩阵 | 独立行 | 秩 | 名字 |
|---|---|---|---|
| M1 | 1 行 | 1 | 低秩 |
| M2 | 2 行 | 2 | 中等 |
| M3 | 3 行 | 3 | 满秩 / 高秩 |
所以"低秩"一句话:独立行少,整张表真含的新方向不多。 LoRA 赌的就是:"把模型变成客服"这个修正本身信息量低、方向高度重复,所以用低秩就能装下,不必动 W₀ 的全部。
为什么 B·A 一定是低秩(秩不会超过 r)
看形状(你的 r 常见取值 8 / 16):
B 是 [out×r] A 是 [r×in] B·A 是 [out×in]
关键在 B:B 只有 r 列 。矩阵乘法里,B·A 的每一列都是 B 那 r 列的某种拼合------拼来拼去,方向就那 r 个。所以:
不管 B、A 里的数字怎么变 B·A 的秩 ≤ r
用数算一遍(B 4×2、A 2×4、B·A 4×4):
B = [ 1 0 ]
[ 0 1 ]
[ 0 0 ]
[ 0 0 ]
A = [ 1 0 0 0 ]
[ 0 1 0 0 ]
B·A = [ 1 0 0 0 ]
[ 0 1 0 0 ]
[ 0 0 0 0 ]
[ 0 0 0 0 ]
看 B·A 结果:只有左上 2×2 有东西,底下两行全是 0------它的影响力被锁死在极少的方向里。这就是低秩(秩 2)。
加回 W₀,到底算不算"影响 W₀"
先分清两件容易混的事:
- 训练时 W₀ 本身偏偏一步没动 :它被冻结(
requires_grad=False),全程不改数字。 - 训练完"加"这一个动作偏偏确实加了 :
W_new = W₀ + B·A,是字面意义的加。
那为什么说"没打乱 W₀"?差别在两套改法思路:
- 全量微调:把 W₀ 的每个数都重学,等于整张图重画 → 容易把原来的通识也画没。
- LoRA :
B·A是低秩(≤ r 个方向),只在很有限的几个维度叠一层薄薄的修正,像给照片加一层浅色滤镜------底下的原图通识还在,只是色调轻轻偏一点。
算一遍(W₀ 拿单位阵 I 做例子,加 B·A):
W₀ = [ 1 0 0 0 ] B·A = [ 1 0 0 0 ]
[ 0 1 0 0 ] [ 0 1 0 0 ]
[ 0 0 1 0 ] [ 0 0 0 0 ]
[ 0 0 0 1 ] [ 0 0 0 0 ]
W_new = W₀ + B·A
= [ 2 0 0 0 ]
[ 0 2 0 0 ]
[ 0 0 1 0 ]
[ 0 0 0 1 ]
只有左上 2×2 被加倍,下面两行原封不动------这就是 B·A 低秩加回去的样子:只在少数方向动,大部分没碰。
一句话收 :低秩 = B·A 的动作范围被锁死在 ≤ r(16)个方向里;加回 W₀ 确实加了,但因为加的是低秩修正,只在很少方向上轻轻叠一层,底下 W₀ 的原知识没被打乱。全量微调是"重画",LoRA 是"着色"------那层色,就是低秩的 B·A。
收尾:回到真实模型(0.5B)对照
上面用了一个"1 层、4×4、词表 5"的玩具,只为看得清数字。真实模型(比如 Qwen 0.5B)原理一模一样,只是数字换大:
| 维度 | 玩具例子 | 真实 0.5B |
|---|---|---|
| 层数 | 1 层,1 个 q_proj | 24 层(每层 q/k/v/o) |
| 权重形状 | 4 × 4 | 自注意力口 1024 × 1024 |
| r | 2 | 16(LoraConfig(r=16)) |
| 可训练参数 | 只有 A、B | 约 216 万(常见的 ~0.4%) |
真实实验里几个对得上的现象:
- 训练过程 loss 一路下降(如
2.358 → 0.401),说明 A、B 真被拧向目标。 - 初始 A 随机、B 全 0 → 一开始输出 = 裸模型。
- 融合用
W_new = W₀ + (α/r)·B·A,其中α/r是缩放。若r=16、α=32,则缩放 = 2,即把修正量放大 2 倍。
一个小坑提醒(practical tip) :个别版本下,"直接加载 adapter 再前向"时 LoRA 修正可能没真正作用上(前向 logit 差为 0)。这时候不要慌,罪魁往往不是训练白做,而是加载/前向没把 B·A 加上去。校验办法:同一输入,算一下"加载补丁后"与"裸基座"的 logit 差,非 0 才说明真接上了。
一句话收死全文: W₀ 是大脑,A/B 是焊在 q_proj 上的小旁路;训练只管拧 A/B(-100 保证只算答案区),训完 W_new = W₀ + (α/r)·B·A 焊回,模型就会当客服,且速度不掉。
LoRA 是**参数高效微调(PEFT)**整个门类里最有名的一个;旁边还站着 QLoRA / DoRA / Adapter / Prefix-Tuning / IA³ 等兄弟,共同点是"不动 W₀、只加小旁路/小网络",省显存省算力。