Chapter 03

LoRA 原理:低秩矩阵分解

LoRA 让微调 7B 模型只需 2GB 显存。理解它的数学原理,才能正确设置超参数,而不是靠猜。

全参数微调的问题

微调一个 7B 参数的模型,如果更新全部参数:

LoRA(Low-Rank Adaptation,Hu et al. 2021)提供了一个优雅的解决方案:不改变原始权重,只训练两个小矩阵

低秩分解的数学原理

假设我们要更新一个权重矩阵 W ∈ ℝ^(d×k)。全参数微调会直接修改这个矩阵的每个元素。LoRA 的做法是引入一个旁路(side path):

全参数微调: W_new = W + ΔW 其中 ΔW ∈ ℝ^(d×k),需要存储和更新 d × k 个参数 LoRA 微调: W_new = W + ΔW = W + (α/r) × B × A 其中: A ∈ ℝ^(r×k) → "压缩"矩阵,将 d 维压缩到 r 维 B ∈ ℝ^(d×r) → "展开"矩阵,将 r 维展开回 d 维 r << min(d, k) → rank,通常 r = 4~64 参数量对比(d=4096, k=4096, r=16): 全参数:4096 × 4096 = 16,777,216 参数 LoRA: 16 × 4096 + 4096 × 16 = 131,072 参数 节省比例:99.2%! 前向传播: h = W₀x + (α/r) × BAx ↑ ↑ 冻结的原始权重 可训练的 LoRA 旁路
低秩假设(Low-Rank Hypothesis)
LoRA 论文的核心假设:LLM 已经在大量数据上预训练,具备丰富知识,微调时的权重更新 ΔW 在本质上是"低秩的"——它的有效维度远低于矩阵的名义维度。直觉上:微调只是在预训练模型已有知识的基础上做小幅调整,不需要改变所有维度。实验验证了这一假设在大多数 NLP 任务上成立。
矩阵秩(Rank)
矩阵 M 的秩是其线性无关列(或行)的最大数量,代表了矩阵所能表示的"信息维度"。满秩矩阵 W ∈ ℝ^(d×k) 的秩为 min(d,k)。LoRA 用秩为 r 的矩阵 BA 来近似 ΔW,r 控制了"更新的复杂度"。rank 越低,可训练参数越少,表达能力越弱;rank 越高,可训练参数越多,更接近全参数微调。
初始化策略(关键细节)
训练开始时:A 用随机高斯分布初始化(N(0, σ²)),B 初始化为全零矩阵。这确保了训练开始时 ΔW = BA = 0,模型行为与原始预训练模型完全相同。这个设计避免了"从一个随机扰动状态开始训练",保证了训练的稳定性。如果 B 不初始化为零,一开始就有噪声注入,训练会不稳定。
缩放因子 α/r
实际的权重更新是 (α/r) × BA,而不是直接 BA。α 是一个缩放超参数(通常设为 r 或 2r)。这个设计的好处是:当改变 rank 时,只需同比调整 α 就能保持相同的"更新幅度",而不需要重新调整学习率。本质上 α/r 扮演了类似"学习率缩放"的角色。

为什么低秩假设成立?

理解这一点需要了解预训练 LLM 的权重矩阵结构。研究发现,LLM 权重矩阵的奇异值(Singular Values,通过 SVD 分解得到)呈现出明显的"长尾分布":少数几个大奇异值包含了矩阵的大部分信息,绝大多数小奇异值贡献极少。

import torch
import numpy as np
from transformers import AutoModelForCausalLM

# 分析预训练模型权重矩阵的奇异值分布
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Meta-Llama-3-8B",
    torch_dtype=torch.float32
)

# 获取第一层 attention 的 q_proj 权重
W = model.model.layers[0].self_attn.q_proj.weight.data

# SVD 分解
U, S, Vh = torch.linalg.svd(W, full_matrices=False)

# 分析奇异值:前多少个值包含了 90% 的信息?
cumulative_energy = torch.cumsum(S**2, dim=0) / (S**2).sum()
rank_90 = (cumulative_energy < 0.9).sum().item()
rank_99 = (cumulative_energy < 0.99).sum().item()

print(f"矩阵形状: {W.shape}")             # (4096, 4096)
print(f"90% 能量 rank: {rank_90}")        # 通常远小于 4096
print(f"99% 能量 rank: {rank_99}")        # 依然远小于 4096

# 微调更新量 ΔW 通常比原始权重更低秩
# 这是 LoRA 方法有效性的核心依据

推理时的权重合并

训练完成后,LoRA 权重可以合并进原始权重,推理时没有额外开销。这是 LoRA 相比于 Adapter(早期的参数高效微调方法)的关键优势:

from peft import AutoPeftModelForCausalLM

# 训练时的计算:h = W₀x + (α/r) × BAx (两次矩阵乘法)
# 推理前合并:W_merged = W₀ + (α/r) × BA  (只在合并时做一次)
# 合并后推理:h = W_merged × x             (只有一次矩阵乘法)

# 合并代码(PEFT 库提供)
model = AutoPeftModelForCausalLM.from_pretrained(
    "./lora-checkpoint",
    torch_dtype=torch.bfloat16
)
merged = model.merge_and_unload()   # 合并 LoRA 矩阵进基座权重
merged.save_pretrained("./merged-model")  # 保存为标准格式

# 注意:合并后无法"卸载" LoRA,也无法同时使用多个 LoRA 适配器
# 如需保持灵活性(如多任务切换),推理时不合并,直接带 LoRA 推理

关键超参数详解

rank(r)— 最重要的超参

rank 决定了 LoRA 矩阵的"容量",也是对微调效果影响最大的超参数:

rank 值可训练参数(7B 模型)适用场景说明
r = 4~3M(0.04%)简单格式/风格调整最节省显存,任务简单时足够
r = 8~6M(0.08%)一般指令微调多数任务的默认推荐起点
r = 16~13M(0.16%)专业领域知识注入需要记忆大量特定格式/术语
r = 64~52M(0.64%)复杂推理任务接近全参数效果,显存增加明显
r = 128+~104M+极少需要通常会过拟合,不推荐
rank 选择的经验法则

alpha(α)— 缩放因子

alpha 控制 LoRA 更新对最终权重的影响程度,通过 α/r 缩放因子起作用。主要规律:

alpha = 2 × rank(常见策略)
很多教程推荐 alpha = 2 × rank,这会让 α/r = 2,即 LoRA 权重以 2× 的幅度影响最终结果。这是一种"略激进"的设置,适合数据量充足的情况。
alpha = rank(保守策略)
alpha = rank 时 α/r = 1,LoRA 权重以 1× 幅度影响结果。更保守,适合数据较少或担心过拟合的情况。Unsloth 框架的默认配置即使用此策略。
固定 alpha = 16
不管 rank 如何变化,alpha 固定为 16。简化调参,便于控制变量实验(只改变 rank 的影响,不受 alpha 变化干扰)。大多数论文采用此策略。

target_modules — 应用到哪些层

from peft import LoraConfig

# 方案一:只训练 attention 的 q/v 投影(最保守)
# 原始 LoRA 论文的做法,参数最少,显存最省
config_minimal = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["q_proj", "v_proj"],   # 只有 attention QV
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

# 方案二:全量线性层(推荐,效果通常好 10-20%)
# 覆盖 attention 的 q/k/v/o 和 FFN 的 gate/up/down
config_full = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",  # Attention 层
        "gate_proj", "up_proj", "down_proj"      # FFN 层
    ],
    lora_dropout=0.0,   # 通常 FFN 已有 dropout,LoRA 不需要额外 dropout
    bias="none",
    task_type="CAUSAL_LM"
)

# 自动找到所有线性层(模型架构未知时的便捷方法)
config_auto = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules="all-linear",  # PEFT 会自动找到所有 Linear 层
    task_type="CAUSAL_LM"
)

lora_dropout — 正则化

lora_dropout 在 A 矩阵的输出上应用 Dropout,起到正则化作用。经验值:

完整 LoRA 训练代码

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig
import torch

# 1. 加载基座模型(BF16 格式,比 FP16 更稳定)
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Meta-Llama-3-8B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct")

# 2. 应用 LoRA 配置
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,               # alpha = 2r,效果较激进
    target_modules=[              # 覆盖所有线性层
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ],
    lora_dropout=0.05,           # 轻量正则化
    bias="none",                 # 不训练 bias(通常不需要)
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出示例:trainable params: 41,943,040 || all params: 8,072,495,104 || trainable%: 0.52%

# 3. 训练配置(关键超参说明)
training_args = SFTConfig(
    output_dir="./lora-output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,    # 等效 batch_size = 16
    learning_rate=2e-4,              # LoRA 用较大学习率(比全参数高 10-100×)
    warmup_ratio=0.05,               # 5% steps 用于 warm-up
    lr_scheduler_type="cosine",      # Cosine 衰减,收敛更平滑
    logging_steps=10,
    evaluation_strategy="steps",
    eval_steps=100,
    save_steps=500,
    bf16=True,                       # 强制 BF16 训练
    gradient_checkpointing=True,     # 节省激活值显存(速度慢 20-30%)
    max_seq_length=2048,
    dataset_text_field="text",       # 数据集中存储文本的字段名
)

# 4. 启动训练
trainer = SFTTrainer(
    model=model,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    args=training_args,
)
trainer.train()

LoRA 的局限性与变体

LoRA 的局限性
LoRA 对权重更新施加了低秩约束,这在某些场景下会限制模型的学习能力:(1) 对于需要大幅调整模型"世界观"的任务(如从英语模型适配到中文),低秩限制可能不够用;(2) 持续预训练(在大量新语料上继续训练)通常需要全参数微调;(3) 理论上,LoRA 无法精确复现任意全参数微调的结果——它只能近似。但对于绝大多数"在现有能力上微调风格和格式"的任务,LoRA 完全足够。
DoRA(Weight-Decomposed LoRA)
2024 年提出的 LoRA 改进版本。将权重矩阵分解为"幅度"(magnitude)和"方向"(direction)两部分,分别用 LoRA 更新方向,用标量参数更新幅度。实验显示在相同 rank 下效果略优于 LoRA,是 Unsloth 的默认配置。使用方式:LoraConfig 中设置 use_dora=True。
RSLoRA(Rank-Stabilized LoRA)
原始 LoRA 使用 α/r 作为缩放因子,但研究发现使用 α/√r 更能稳定不同 rank 之间的学习动态。RSLoRA 在高 rank(r=64+)时特别有优势,可以在不改变 α 的情况下尝试更高的 rank。使用方式:LoraConfig 中设置 use_rslora=True。
LoRA 微调常见误区

诊断 LoRA 训练是否正常

# 监控 LoRA 训练健康度的关键指标

# 1. 打印可训练参数比例(应在 0.1% - 2%)
model.print_trainable_parameters()

# 2. 检查梯度流(排查梯度消失/爆炸)
for name, param in model.named_parameters():
    if param.requires_grad and param.grad is not None:
        grad_norm = param.grad.norm().item()
        if grad_norm > 10:                   # 梯度爆炸警告
            print(f"梯度爆炸: {name} = {grad_norm:.4f}")
        elif grad_norm < 1e-8:              # 梯度消失警告
            print(f"梯度消失: {name} = {grad_norm:.4f}")

# 3. 正常训练的 loss 曲线特征
# - 前 10% steps:loss 快速下降(warm-up 阶段)
# - 中间 80% steps:loss 稳定缓慢下降
# - 后 10% steps:loss 趋于平稳
# - eval loss 应始终接近 train loss(相差 > 0.5 可能是过拟合)
本章核心要点