Skip to content

10. LoRA Tutorial | LoRA 教程 ​

难度: Medium | 环境: CPU-first | 标签: 训练微调, LoRA, PEFT | 目标人群: 训练机制学习者

🚀 云端运行环境

本章节的实战代码可以点击以下链接在免费 GPU 算力平台上直接运行:

Open In ColabOpen In Studio (国内推荐:魔搭社区免费实例)


本节导读 ​

大模型微调最直接的做法是更新全部参数,但这会把显存压力迅速放大:除了模型权重,还要保存梯度和优化器状态。很多场景里,我们真正需要的不是重写整个模型,而是在已有能力上做小幅适配。

LoRA 的思路就是冻结原始权重,只在旁边加一条低秩可训练旁路。SFT 描述的是监督训练范式,LoRA 描述的是参数更新方法,二者可以组合成 LoRA SFT。LoRA 改变的是哪些参数参与梯度和优化器更新,不改变 effective batch、optimizer.step() 和 scheduler 的推进节奏。这一节在训练微调路线里承接 09:09 讲清 SFT 的样本、监督范围和 loss 对齐,10 再讲 LoRA 如何改变参数更新方式。学完这里,后面再看 13 和 60 时,你会更容易把 target modules / r / alpha / dropout 这些选择放回完整实验和项目交付里。

关键词: LoRA, PEFT, adapter, target modules


前置阅读 ​

导语: 先把模型封装、优化器和最小训练闭环补齐,再看 LoRA 如何只训练一小部分参数。


Step 1:LoRA 的适配对象与显存动机 ​

全参微调会更新模型中的全部参数。训练时,GPU 不仅要保存模型权重,还要保存梯度、优化器状态和中间激活,因此模型越大,训练显存压力越高。

LoRA 的基本做法是冻结原始模型,只在部分 Linear(线性层)旁边增加少量可训练参数。这里的重点不是立即计算低秩矩阵,而是先确定 LoRA 应该接入模型的哪条路径。

在 Transformer 的一个重复计算单元(block)中,常见挂载位置包括:

位置常见 target modules(目标模块)作用选择建议
Attentionq_proj, v_proj查询和值投影,调整注意力路径适合作为入门起点
Attentionq_proj, k_proj, v_proj, o_proj查询、键、值和输出投影,更完整地适配注意力路径任务需要更强适配时使用
MLPgate_proj, up_proj, down_proj逐 token 的前馈表示变换需要增加可学习参数时考虑

下面的结构图先标出这些投影层在 Decoder Block 中的位置;低秩分支的矩阵形状和初始化规则放到 Step 2。

LoRA 挂载位置图

LoRA 挂载位置: Attention 和 MLP 中的线性投影都可以作为适配入口,具体选择需要结合任务和预算。

Step 2:低秩旁路的结构与参数 ​

先把 LoRA 看成一个普通的 PyTorch 模块:它保留冻结的线性层作为主分支,再并排放入两个很小的可训练矩阵 A 和 B。A 通常用 Kaiming 均匀分布或高斯分布初始化,而 B 严格初始化为零,以保证训练开始时 ΔW=BA≈0,模型输出基本等于冻结基座的输出。

先从结构和参数状态确认 LoRA 是否正确接入,再理解几个控制适配规模的参数:

  • base linear weight 已冻结。
  • 只有 lora_A / lora_B 参与训练。
  • lora_A 的形状为 [r, in_features],lora_B 的形状为 [out_features, r]。
  • 可训练参数量等于 r * (in_features + out_features)。
  • r 是低秩维度,alpha / r 控制旁路更新的缩放;dropout 只作用在 LoRA 分支输入上。

LoRA 旁路结构图

LoRA 旁路结构: 主分支保留冻结权重,低秩分支负责学习增量。

Step 3:前向路径与权重合并 ​

LoRA 前向时有两条路径:输入 x 一条进入冻结的主线性层,另一条经过 LoRA 的低秩分支。代码中的行向量路径是:x → x @ A.T → @ B.T → * (alpha / r),最后把这条增量加到主分支输出。

这对应数学表达式:

h=W0x+ΔWx=W0x+αrBAx

其中 A 将输入维度压到 r,B 再将它恢复到输出维度;Step 2 已经说明了矩阵形状和缩放参数。

部署时可以把低秩增量一次性合并到主权重:

Wmerged=W0+αrBA

合并后,计算图不再执行 A、B 两个旁路矩阵乘法;实际延迟仍取决于权重合并、数值误差和框架实现。

Step 4: 动手实战 ​

要求:本节用单个 LoRALinear 讲清低秩旁路;后面的项目页再把它放回完整模型和训练报告里。请补全下方 LoRALinear 的初始化、前向传播和合并权重的 TODO 逻辑,并让 merge 操作可以安全地重复调用。训练时只有 LoRA 参数进入优化器,更新次数和 scheduler 节奏仍按完整训练循环的有效 update 计数。

额外检查点:实现后要能统计 LoRA 的可训练参数量,确认梯度只流向 A/B,验证 merge 前后的输出一致,并通过形状、参数边界和 dropout 状态检查。本 Step 验证的是 LoRA 线性层机制,完整任务训练和项目报告在 60 节展开。

python
import torch
import torch.nn as nn
import math
python
# 题目设计:冻结主权重,只让 LoRA 旁路承担可训练增量。
# TODO 依次覆盖参数契约、旁路前向、权重合并和可重复 merge 的状态不变量。
def count_trainable_parameters(module: nn.Module) -> int:
    """统计模块中 requires_grad=True 的参数数量。"""
    return sum(p.numel() for p in module.parameters() if p.requires_grad)


class LoRALinear(nn.Module):
    """在线性层旁路中加入可训练低秩增量的 LoRA 模块。

    主线性层被冻结;`lora_A` 和 `lora_B` 学习增量,`merged` 记录增量是否已经写回主权重。

    Args:
        in_features: 输入特征维度。
        out_features: 输出特征维度。
        r: LoRA 低秩维度,必须为正数。
        lora_alpha: 控制旁路增量的缩放系数。
        lora_dropout: 仅作用于未合并 LoRA 分支输入的 dropout 概率。

    Note:
        `merged=False` 时执行主分支与 LoRA 旁路;`merged=True` 时只执行已更新的主分支。
    """
    def __init__(self, in_features: int, out_features: int, r: int = 8, lora_alpha: int = 16, lora_dropout: float = 0.0):
        """冻结基础线性层,只训练低秩 LoRA 旁路。"""
        super().__init__()
        if in_features <= 0 or out_features <= 0:
            raise ValueError('in_features 和 out_features 必须为正数')
        if r <= 0:
            raise ValueError('r 必须为正数')
        if lora_dropout < 0.0 or lora_dropout > 1.0:
            raise ValueError('lora_dropout 必须位于 [0, 1]')
        self.r = r
        self.lora_alpha = lora_alpha
        self.scaling = self.lora_alpha / self.r
        self.lora_dropout = nn.Dropout(lora_dropout)
        self.merged = False  # 记录 LoRA 增量是否已合并到主权重
        
        # ==========================================
        # 主权重冻结,只让低秩旁路参与训练。
        # TODO 1:初始化主权重和 LoRA 矩阵。
        # self.linear = ???
        # self.linear.weight.requires_grad = ???
        # self.lora_A = ???  # 形状 [r, in_features]
        # self.lora_B = ???  # 形状 [out_features, r]
        #pass
        self.reset_parameters()

    def reset_parameters(self):
        """按基础层和 LoRA 旁路各自的规则初始化参数。"""
        # ==========================================
        # 主权重和 LoRA 旁路分别按各自规则初始化。
        # TODO 2:初始化权重。
        # 依次补全三条调用:主权重 Kaiming、A 矩阵 Kaiming、B 矩阵置零。
        # nn.init.kaiming_uniform_(self.linear.weight, a=???)
        # nn.init.kaiming_uniform_(self.lora_A, a=???)
        # nn.init.zeros_(self.lora_B)
        pass
        

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """计算基础线性层输出,并在未 merge 时叠加 LoRA 增量。"""
        # ==========================================
        # 先走主分支,再叠加低秩旁路的增量。
        # TODO 3:实现前向传播。
        # result = ???  # 主分支输出
        # dropped = ???  # LoRA 分支输入
        # lora_out = ???  # 降维、升维并乘 scaling
        # merged 时直接返回主分支,不能重复叠加旁路。
        return result

    def merge_weights(self):
        """将 LoRA 增量合并到基础权重,并保证重复调用安全。"""
        # ==========================================
        # TODO 4:合并权重。
        # if not self.merged:
        #     with torch.no_grad():
        #         self.linear.weight.add_(???)
        #     self.merged = True
        pass
python
# 测试设计:验证冻结参数、LoRA 形状、梯度路径、前向公式和 merge 不变量。
# baseline 是冻结主线性层;candidate 是 LoRA 旁路或 merge 后的等价路径。
def _build_lora_test_case():
    """构造固定尺寸的 CPU-first LoRA 测试样本。"""
    torch.manual_seed(42)
    in_dim, out_dim = 128, 256
    layer = LoRALinear(in_dim, out_dim, r=8, lora_alpha=16, lora_dropout=0.0)
    x = torch.randn(32, 10, in_dim)
    return layer, x, in_dim, out_dim

def _assert_parameter_contract(layer, in_dim, out_dim):
    """验证冻结主权重、LoRA 形状和可训练参数量。"""
    assert not layer.linear.weight.requires_grad, "主权重应该被冻结"
    assert count_trainable_parameters(layer) == 8 * (in_dim + out_dim), "LoRA 可训练参数量统计错误"
    assert layer.lora_A.shape == (8, in_dim), "lora_A 形状错误"
    assert layer.lora_B.shape == (out_dim, 8), "lora_B 形状错误"

def _assert_gradient_route(layer, x):
    """验证梯度只流向 LoRA 参数。"""
    layer(x).sum().backward()
    assert layer.lora_A.grad is not None, "lora_A 应该获得梯度"
    assert layer.lora_B.grad is not None, "lora_B 应该获得梯度"
    assert layer.linear.weight.grad is None, "冻结主权重不应该获得梯度"

def _assert_forward_and_merge(layer, x):
    """验证 LoRA 前向公式以及 merge 前后的等价性。"""
    with torch.no_grad():
        out_base = layer.linear(x)
        out_initial = layer(x)
        assert torch.allclose(out_initial, out_base), "初始化错误:lora_B 应为零"
        layer.lora_B.normal_(0, 0.02)
        out_separate = layer(x)
        manual = layer.linear(x) + (x @ layer.lora_A.T) @ layer.lora_B.T * layer.scaling
        assert torch.allclose(out_separate, manual, atol=1e-6), "LoRA 前向公式或缩放实现错误"
        layer.eval()
        layer.merge_weights()
        out_merged = layer(x)
        assert layer.merged, "merge 后应记录 merged 状态"
        assert torch.allclose(out_separate, out_merged, atol=1e-5), "merge 后输出与分离路径不一致"
        merged_weight = layer.linear.weight.detach().clone()
        layer.merge_weights()
        assert torch.allclose(layer.linear.weight, merged_weight), "重复 merge 改变了主权重"

def _assert_lora_boundaries(in_dim, out_dim, x):
    """验证非法参数和 dropout 的训练/评估行为。"""
    for kwargs in ({'r': 0}, {'lora_dropout': 1.1}):
        try:
            LoRALinear(in_dim, out_dim, **kwargs)
        except ValueError:
            continue
        raise AssertionError("非法参数没有触发 ValueError")

    dropout_layer = LoRALinear(in_dim, out_dim, r=4, lora_dropout=0.5)
    with torch.no_grad():
        dropout_layer.lora_B.normal_(0, 0.02)
    dropout_layer.eval()
    assert torch.allclose(dropout_layer(x), dropout_layer(x)), "eval 模式输出应保持稳定"
    dropout_layer.train()
    assert not torch.allclose(dropout_layer(x), dropout_layer(x)), "train 模式应启用 LoRA dropout"

def test_lora():
    """运行 CPU-first 的 LoRA 机制测试。"""
    try:
        layer, x, in_dim, out_dim = _build_lora_test_case()
        _assert_parameter_contract(layer, in_dim, out_dim)
        _assert_gradient_route(layer, x)
        # 清理上一项梯度,避免影响后续仅比较前向的机制证据。
        layer.zero_grad(set_to_none=True)
        _assert_forward_and_merge(layer, x)
        _assert_lora_boundaries(in_dim, out_dim, x)
        print("✅ LoRA 核心算子、参数统计和 merge 逻辑实现正确。")
        return

    except NotImplementedError:
        print("请先完成 TODO 部分的代码!")
        raise
    except (AttributeError, NameError, TypeError, ValueError) as e:
        print("代码可能未完成,导致变量未定义" if isinstance(e, NameError) else "代码可能未完成,导致了类型错误")
        raise NotImplementedError("请先完成 TODO 部分的代码!") from e
    except AssertionError as e:
        print(f"❌ 测试失败: {e}")
        raise NotImplementedError("请先完成 TODO 部分的代码!") from e
    except Exception as e:
        print(f"❌ 发生异常: {e}")
        raise

test_lora()

🛑 STOP HERE 🛑









请先尝试自己完成代码并跑通测试。
如果你正在 Colab 中运行,并且遇到困难没有思路,可以向下滚动查看参考答案。










参考代码与解析 ​

代码 ​

python
def count_trainable_parameters(module: nn.Module) -> int:
    """统计模块中 requires_grad=True 的参数数量。"""
    return sum(p.numel() for p in module.parameters() if p.requires_grad)


class LoRALinear(nn.Module):
    """在线性层旁路中加入可训练低秩增量的 LoRA 模块。"""
    def __init__(self, in_features: int, out_features: int, r: int = 8, lora_alpha: int = 16, lora_dropout: float = 0.0):
        super().__init__()
        if in_features <= 0 or out_features <= 0:
            raise ValueError('in_features 和 out_features 必须为正数')
        if r <= 0:
            raise ValueError('r 必须为正数')
        if lora_dropout < 0.0 or lora_dropout > 1.0:
            raise ValueError('lora_dropout 必须位于 [0, 1]')
        self.r = r
        self.lora_alpha = lora_alpha
        self.scaling = self.lora_alpha / self.r
        self.lora_dropout = nn.Dropout(lora_dropout)
        self.merged = False
        
        # TODO 1: 初始化主权重和 LoRA 矩阵
        self.linear = nn.Linear(in_features, out_features, bias=False)
        self.linear.weight.requires_grad = False
        
        self.lora_A = nn.Parameter(torch.empty(r, in_features))
        self.lora_B = nn.Parameter(torch.empty(out_features, r))
        
        self.reset_parameters()

    def reset_parameters(self):
        """按基础层和 LoRA 旁路各自的规则初始化参数。"""
        # TODO 2: 初始化权重
        nn.init.kaiming_uniform_(self.linear.weight, a=math.sqrt(5))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        nn.init.zeros_(self.lora_B)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """计算基础线性层输出,并在未 merge 时叠加 LoRA 增量。"""
        # TODO 3: 实现前向传播
        result = self.linear(x)
        if self.merged:
            return result
        dropped = self.lora_dropout(x)
        lora_out = (dropped @ self.lora_A.T) @ self.lora_B.T * self.scaling
        result += lora_out
        return result

    def merge_weights(self):
        """将 LoRA 增量合并到基础权重,并保证重复调用安全。"""
        # TODO 4: 合并权重
        if not self.merged:
            with torch.no_grad():
                self.linear.weight.add_((self.lora_B @ self.lora_A) * self.scaling)
            self.merged = True

解析 ​

本题用一个最小 LoRALinear 验证四件事:基础权重冻结、A/B 旁路可训练、低秩增量参与前向,以及 merge 后计算结果保持一致。

1. TODO 1:初始化主权重和 LoRA 矩阵

  • 主权重冻结:self.linear.weight.requires_grad = False 是 LoRA 的核心,确保预训练权重不参与梯度计算,只更新 A 和 B。
  • LoRA 矩阵形状:
    • lora_A: [r, in_features] - 降维矩阵
    • lora_B: [out_features, r] - 升维矩阵

2. TODO 2:初始化参数

  • 初始化规则:
    • lora_A: 使用 Kaiming 初始化,提供随机性
    • lora_B: 本实现将它初始化为全 0,确保训练开始时 ΔW=BA=0,即 LoRA 模块的初始输出与冻结基础层一致
  • 参数量对比:原始权重 [out_features, in_features],LoRA 参数 r * (in_features + out_features)。当 r << min(in_features, out_features) 时,参数量大幅减少。

3. TODO 3:前向传播与缩放

  • 实现方式:
    python
    result = self.linear(x)
    dropped = self.lora_dropout(x)
    lora_out = (dropped @ self.lora_A.T) @ self.lora_B.T * self.scaling
    result += lora_out
  • 数学公式:h=W0x+αrBAx。代码使用批量行向量,所以写成 (x @ A.T) @ B.T。
  • 缩放因子:scaling = lora_alpha / r,通常 lora_alpha = 16,r = 8,则 scaling = 2。
  • dropout 位置:dropout 只作用在 LoRA 分支输入上,帮助小数据微调时减少过拟合;推理和 merge 前要切到 eval()。
  • 计算顺序:先 x @ A^T 降维到 [..., r],再 @ B^T 升维到 [..., out_features],最后乘以 scaling。

4. TODO 4:合并权重与部署计算

  • 实现方式:在 torch.no_grad() 中使用 self.linear.weight.add_((self.lora_B @ self.lora_A) * self.scaling)。
  • 核心原理:由于 h=Wx+BAx=(W+BA)x,可以直接将 BA 加到 W 中。
  • 合并后的计算:合并后不再执行 LoRA 分支的额外矩阵乘法;实际延迟仍取决于权重合并、数值误差和框架实现。
  • 部署提醒:merge 前应切到 eval(),避免 dropout 造成 merge 前后输出不一致;用 merged 状态避免重复合并,合并后通常不再继续训练这个 LoRA 分支。

工程要点

  • target modules:入门常选 q_proj / v_proj,更完整的注意力适配会覆盖 q_proj / k_proj / v_proj / o_proj,需要更强容量时再扩展到 gate_proj / up_proj / down_proj。
  • rank 选择:r=8 通常足够做入门和小任务,r=16 可能带来边际提升,r=32 以上收益递减且更容易过拟合。
  • alpha 选择:常见设置是 alpha = r 或 alpha = 2r。过大可能让 LoRA 更新过强,过小则适配能力不足。
  • dropout 选择:小数据或格式容易过拟合时可以加 0.05-0.1;数据足够多或追求稳定对齐时可以设为 0.0。
  • 参数统计:项目报告里至少记录 base 参数量、trainable 参数量和 trainable ratio,证明当前实验真的只训练 LoRA adapter。
  • 测试边界:测试区还检查 A/B 形状、梯度归属、非法参数和 dropout 的 train/eval 行为;这些检查用于确认实现状态正确,不代表完整模型训练效果。
  • 多任务切换:可以为不同任务训练不同的 A/B 矩阵,推理时动态加载,实现“一个基座模型 + 多个 LoRA 适配器”。

相关阅读 ​

完成最小 LoRA 模块后,可以把它接到完整微调、QLoRA 和项目基准中;论文与官方实现用于继续核对参数高效微调的工程接口。

Released under the MIT License.