MiniMind 学习笔记 11:LoRA 微调,从低秩增量到权重合并

前面几篇文章已经从 tokenizer、dataset、训练循环、模型结构、Attention、RoPE、MoE 和推理生成走完了一条 LLM 工程主线。

这篇开始看一个非常常见的微调技术:LoRA。

LoRA 的全称是 Low-Rank Adaptation。它属于参数高效微调,也就是 PEFT(Parameter-Efficient Fine-Tuning)的一类方法。

它要解决的问题很实际:

我已经有一个训练好的基础模型;
我想让它适配某个领域、某种人设、某类任务;
但我不想全量更新所有参数。

LoRA 的做法是:冻结原模型,只新增并训练一小组低秩参数。

MiniMind 里没有依赖 PEFT 这类封装库,而是手写了一版简洁 LoRA,实现集中在:

model/model_lora.py
trainer/train_lora.py

这很适合用来理解 LoRA 的本质。

1. 全参微调和 LoRA 的区别

如果做全参数微调,模型里的原始权重会被直接更新:

W -> W'

也就是说,训练会修改原模型的大量参数。

LoRA 不直接改 W,而是在旁边学习一个增量:

W' = W + ΔW

关键是,ΔW 不直接训练成一个完整大矩阵,而是拆成两个小矩阵:

ΔW = B @ A

其中:

A: 把输入降到低维 rank 空间
B: 把低维表示升回输出维度

如果 rank 很小,那么需要训练的参数量就远小于完整的 W

这就是 LoRA 的核心:用低秩增量近似一次权重更新。

2. LoRA 在代码里长什么样

MiniMind 的 LoRA 类非常短:

class LoRA(nn.Module):
    def __init__(self, in_features, out_features, rank):
        super().__init__()
        self.rank = rank
        self.A = nn.Linear(in_features, rank, bias=False)
        self.B = nn.Linear(rank, out_features, bias=False)
        self.A.weight.data.normal_(mean=0.0, std=0.02)
        self.B.weight.data.zero_()

    def forward(self, x):
        return self.B(self.A(x))

这就是两个小 Linear 串联:

x -> A -> B

形状变化是:

x:       [..., in_features]
A(x):    [..., rank]
B(A(x)): [..., out_features]

如果原始 Linear 是:

768 -> 768

原始权重参数量是:

768 * 768 = 589,824

如果 LoRA rank 是 16:

A: 768 -> 16
B: 16 -> 768

LoRA 参数量是:

768 * 16 + 16 * 768 = 24,576

大约只有原始矩阵的 4.17%。

这就是参数高效的来源。

3. 为什么 B 初始化为 0

MiniMind 里:

self.A.weight.data.normal_(mean=0.0, std=0.02)
self.B.weight.data.zero_()

刚开始:

B = 0

所以:

ΔW = B @ A = 0

这意味着 LoRA 刚注入时,不会改变原模型输出。

也就是说:

训练开始前:
Linear(x) + LoRA(x) = Linear(x)

这个设计很重要。它保证 LoRA 微调从原模型行为出发,而不是一开始就扰乱模型。

4. LoRA 是怎么“挂到” Linear 上的

MiniMind 通过 apply_lora 注入 LoRA:

def apply_lora(model, rank=16):
    for name, module in model.named_modules():
        if isinstance(module, nn.Linear) and module.weight.shape[0] == module.weight.shape[1]:
            lora = LoRA(module.weight.shape[0], module.weight.shape[1], rank=rank).to(model.device)
            setattr(module, "lora", lora)
            original_forward = module.forward

            def forward_with_lora(x, layer1=original_forward, layer2=lora):
                return layer1(x) + layer2(x)

            module.forward = forward_with_lora

这段代码做了一个 monkey patch。

原来 Linear 是:

y = Linear(x)

注入 LoRA 后变成:

y = Linear(x) + LoRA(x)

图上看就是:

                  ┌──────────────┐
                  │ 原 Linear W   │
                  └──────┬───────┘
                         │
x ───────────────────────┼──────────> add -> y
                         │
                  ┌──────▼───────┐
                  │ LoRA: A -> B  │
                  └──────────────┘

所以你可以把 LoRA 理解成:原 Linear 旁边并联了一条低秩小分支。

5. MiniMind 当前会给哪些层加 LoRA

注意,MiniMind 当前实现有一个简化条件:

module.weight.shape[0] == module.weight.shape[1]

也就是只给方阵 Linear 加 LoRA。

在 Attention 里有四个主要 Linear:

self.q_proj = nn.Linear(config.hidden_size, config.num_attention_heads * self.head_dim, bias=False)
self.k_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
self.v_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
self.o_proj = nn.Linear(config.num_attention_heads * self.head_dim, config.hidden_size, bias=False)

默认配置:

hidden_size = 768
num_attention_heads = 8
num_key_value_heads = 4
head_dim = 96

所以:

Linear形状当前是否命中 LoRA
q_proj768 -> 768
k_proj768 -> 384
v_proj768 -> 384
o_proj768 -> 768

因此在 MiniMind 当前默认 Attention 里,LoRA 主要影响:

Q 怎么产生;
Attention 输出怎么写回 hidden state。

图上看:

hidden_states
    |
    |--------- q_proj ---------> Q_base
    |             |
    |             +-- LoRA_q: A -> B -> Q_delta
    |                               |
    |----------- add <--------------|
    |                               |
    |------------------------------> Q = Q_base + Q_delta
    |
    |--------- k_proj ---------> K
    |
    |--------- v_proj ---------> V

Q, K
  |
  v
Attention scores / softmax / @ V
  |
  v
attention output
  |
  |--------- o_proj ---------> O_base
                |
                +-- LoRA_o: A -> B -> O_delta
                                  |
             add <----------------|
              |
              v
final attention output = O_base + O_delta

需要强调:这只是 MiniMind 当前教学实现的选择,不代表 LoRA 只能加到 q_proj/o_proj

更通用的 LoRA 配置会显式指定 target modules,例如:

q_proj, k_proj, v_proj, o_proj
gate_proj, up_proj, down_proj

甚至可以覆盖所有 Linear。

6. LoRA 训练时更新哪些参数

trainer/train_lora.py 里,模型加载后会先注入 LoRA:

model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
apply_lora(model)

然后冻结非 LoRA 参数:

lora_params = []
for name, param in model.named_parameters():
    if 'lora' in name:
        param.requires_grad = True
        lora_params.append(param)
    else:
        param.requires_grad = False

优化器只拿到 LoRA 参数:

optimizer = optim.AdamW(lora_params, lr=args.learning_rate)

所以训练时:

基模权重 W 不更新;
LoRA 的 A/B 更新。

这就是 LoRA 和 full SFT 的核心差异。

SFT 是训练目标和数据组织方式;LoRA 是参数更新策略。

MiniMind 的 LoRA 训练仍然使用:

SFTDataset

也就是说,它还是在对话数据上做监督微调,只是更新的参数从“全模型”变成了“LoRA 分支”。

7. LoRA 权重为什么可以单独保存

save_lora 只保存带 .lora. 的参数:

if hasattr(module, 'lora'):
    lora_state = {
        f'{clean_name}.lora.{k}': v.cpu().half()
        for k, v in module.lora.state_dict().items()
    }

所以保存出来的文件:

lora_xxx_768.pth

不是完整模型,而是 LoRA adapter 权重。

推理时要配合基模:

base model + LoRA weight

这也是 LoRA 很灵活的地方。同一个基模,可以挂不同 LoRA:

基础模型 + 医疗 LoRA
基础模型 + 人设 LoRA
基础模型 + 选择题格式 LoRA

8. 推理时怎么加载 LoRA

eval_llm.py 里的顺序是:

model.load_state_dict(base_ckp, strict=True)

if args.lora_weight != 'None':
    apply_lora(model)
    load_lora(model, lora_path)

顺序不能反:

先加载基模;
再注入 LoRA 结构;
再加载 LoRA 权重。

使用命令:

python eval_llm.py --weight full_sft --lora_weight lora_medical

这里:

--weight full_sft
  指基础模型。

--lora_weight lora_medical
  指 LoRA 增量。

此时推理仍然是:

Linear(x) + LoRA(x)

9. merge_lora:把 LoRA 合并回原权重

LoRA 也可以合并回基础模型。

训练和动态加载时:

y = Linear(x) + LoRA(x)

其中:

LoRA(x) = B(A(x))

根据 PyTorch nn.Linear 的权重约定:

A.weight: [rank, in_features]
B.weight: [out_features, rank]

两层串联等价于:

ΔW = B.weight @ A.weight

形状是:

[out_features, rank] @ [rank, in_features]
= [out_features, in_features]

正好和原始 Linear 权重:

W: [out_features, in_features]

一样。

merge 前:

y = x @ W.T + x @ ΔW.T

合并:

y = x @ (W + ΔW).T

定义:

W' = W + ΔW

于是:

y = x @ W'.T

也就是说,原来的:

Linear 分支 + LoRA 分支

可以折叠成:

一个新的 Linear。

MiniMind 里的代码就是:

state_dict[f'{name}.weight'] += (
    module.lora.B.weight.data @ module.lora.A.weight.data
).cpu().half()

merge 后就不需要 LoRA 分支了,适合部署和导出。

10. Adapter 和 LoRA 有什么区别

学习 LoRA 时,经常会遇到另一个词:Adapter。

Adapter Tuning 也是 PEFT 方法。它通常在 Transformer block 里插入一个小模块:

x
|
down projection
|
activation
|
up projection
|
residual add
|
y

公式:

Adapter(x) = x + W_up f(W_down x)

LoRA 则是:

Linear(x) + B(A(x))

两者区别:

维度AdapterLoRA
插入位置Transformer block 中额外插小模块挂在已有 Linear 旁边
形式x + W_up f(W_down x)Linear(x) + B(A(x))
是否容易合并通常不如 LoRA 容易很容易,W' = W + B @ A
推理结构通常保留 Adapter 模块merge 后恢复普通 Linear
直觉插一个小插件层给权重加一个低秩补丁

一句话:

Adapter 是插层;
LoRA 是改权重增量。

MiniMind 当前实现的是 LoRA,不是 Adapter。

11. 什么时候适合用 LoRA

LoRA 很适合这些场景:

垂直领域适配;
人设或风格微调;
格式对齐;
数据量不大但想快速试验;
显存有限;
希望同一个基模挂多个任务 adapter。

但它也不是万能的。

如果你希望模型获得大量新知识,或者彻底改变能力边界,仅靠小规模 LoRA 不一定够。

它更适合:

在已有基模能力附近做轻量适配。

12. 小结

LoRA 可以从三个层次理解:

第一,数学上:

W' = W + ΔW
ΔW = B @ A

第二,结构上:

原 Linear 旁边并联一条 A -> B 低秩分支。

第三,工程上:

训练时冻结基模,只训练 LoRA;
推理时可以动态加载 base + lora;
部署时可以 merge 成普通 Linear。

MiniMind 的实现非常简洁,正适合用来建立 LoRA 的第一性理解。

等理解了这个版本,再看 PEFT / QLoRA / 多 target modules / rank alpha scaling 这些工程增强,就会顺很多。

发表评论