训练 AI 大模型系列之 LoRA:不动模型本体,靠一条低秩旁路改专长(附完整手算算例)

训练 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₀"

先分清两件容易混的事:

  1. 训练时 W₀ 本身偏偏一步没动 :它被冻结(requires_grad=False),全程不改数字。
  2. 训练完"加"这一个动作偏偏确实加了 :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₀、只加小旁路/小网络",省显存省算力。

相关推荐
a努力。1 小时前
API、CLI、MCP:谁在调用决定接口形态
人工智能
我先测了1 小时前
Midscene.js 全栈学习指南:从入门到精通的 AI 视觉自动化框架
javascript·人工智能·自动化·agent·midscene
TomEval1 小时前
【测AI】第10篇:AI 模型功能测试实战 —— 测试用例怎么设计?
人工智能·python·功能测试·测试用例
Reload.1 小时前
深度学习-极验叠字点选-yolo-模型调优-高阶数学-opencv
人工智能·深度学习·opencv·yolo·计算机视觉·视觉检测
救救孩子把1 小时前
AI面试八股文-01_1-LLM基础概念(Token-上下文窗口-幻觉)
人工智能·面试·职场和发展·llm·token
ADAI_Bowen1 小时前
渲境AI实测:当AI尝试扛起建筑效果图的交付压力
大数据·人工智能
yi0111 小时前
DAY27: LeetCode 110. 平衡二叉树 | 在递归中判断是否平衡
人工智能·笔记·python·算法·leetcode·二叉树·递归
Sammyyyyy1 小时前
jev-ultrafast 实战:浏览器操作代理加本地 AI 网关,把云控制台配置做成自动化流水线
运维·人工智能·自动化·ai编程·servbay
狂师1 小时前
爆火!新一代e2e AI测试框架,单日狂揽1500 star
前端·人工智能·测试