跳转至

4.LoRA 原理与权重合并

推导 LoRA 的前向公式 h = W₀x + BAx,并解释为何对 A 使用高斯初始化、对 B 使用零初始化。

1.1 前向公式推导

image.png

1.2 初始化策略的动机

  • A 用高斯初始化:矩阵 A 的权重从一个均值为 0、方差为 σ2 的高斯分布中采样。这确保了初始时 AxAx 是一个随机的小向量,为训练提供了对称破缺(symmetry breaking)和丰富的探索方向。

  • B 用零初始化:矩阵 B 被初始化为全零。这样在训练开始时,ΔW=0,模型的输出完全等于预训练模型的输出 W0x。这种“零初始化”保证了微调从原始模型的性能开始,避免了随机噪声破坏预训练知识,训练可以稳定地逐步学习到有用的低秩更新。

1.3 数学直觉

若 B 也随机初始化,则微调初期 ΔW 会是一个随机矩阵,其输出会污染原本良好的预训练特征,可能导致训练初期损失剧烈震荡甚至发散。零初始化 B 则完美避开了这一问题,是 LoRA 成功的关键细节之一。


写出 LoRA 的完整前向传播代码,支持对指定的模块(如 q_proj, v_proj)注入低秩适配器。

2.1 实现思路

我们设计一个 LoRALinear 类,它包装原始的线性层。在 init 中创建低秩矩阵 A 和 B,并根据配置决定是否应用 dropout。在前向传播中,计算 W0(x) + (α/r) * dropout( B( A( dropout(x) ) ) )(dropout 可选)。然后提供一个工具函数,可以将模型中的特定层(如所有 q_projv_proj)替换为 LoRALinear 实例。

2.2 完整代码

import torch
import torch.nn as nn
import torch.nn.functional as F

class LoRALinear(nn.Module):
    def __init__(self, original_linear: nn.Linear, r: int, alpha: float, dropout: float = 0.0):
        super().__init__()
        self.original_linear = original_linear  # 冻结的原始权重
        self.r = r
        self.alpha = alpha
        self.scaling = alpha / r  # 缩放因子

        in_features = original_linear.in_features
        out_features = original_linear.out_features

        # 低秩矩阵
        self.lora_A = nn.Parameter(torch.randn(r, in_features) * 0.02)  # 高斯初始化
        self.lora_B = nn.Parameter(torch.zeros(out_features, r))        # 零初始化

        self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()

        # 冻结原始权重
        self.original_linear.weight.requires_grad = False
        if self.original_linear.bias is not None:
            self.original_linear.bias.requires_grad = False

    def forward(self, x):
        # 原始输出
        result = self.original_linear(x)
        # LoRA 增量:dropout -> A -> dropout? -> B -> scaling
        lora_x = self.dropout(x)               # (..., in_features)
        lora_x = F.linear(lora_x, self.lora_A) # (..., r)
        lora_x = F.linear(lora_x, self.lora_B) # (..., out_features)
        result = result + self.scaling * lora_x
        return result

def inject_lora(model, target_modules, r, alpha, dropout=0.0):
    """将模型中指定的线性层替换为 LoRALinear"""
    for name, module in model.named_children():
        if isinstance(module, nn.Linear) and any(t in name for t in target_modules):
            # 替换为 LoRA 版本
            lora_module = LoRALinear(module, r=r, alpha=alpha, dropout=dropout)
            setattr(model, name, lora_module)
        else:
            # 递归处理子模块
            inject_lora(module, target_modules, r, alpha, dropout)

# 使用示例
# model = AutoModelForCausalLM.from_pretrained(...)
# inject_lora(model, target_modules=['q_proj', 'v_proj'], r=8, alpha=16)

2.3 设计要点

  • lora_A 用稍小的方差(如 0.02)进行高斯初始化,这与常见的 Transformer 权重初始化类似。

  • lora_B 零初始化保证训练从原始模型开始。

  • 冻结原始权重,仅优化 A 和 B。

  • scaling = α / rforward 中显式乘入。


解释 LoRA 中缩放因子 α 的作用,推导 α/r 的缩放规则,并写出在代码中如何应用该缩放。

3.1 α 的作用

缩放因子 α 用于平衡原始输出与低秩增量的大小。由于低秩矩阵初始时 B=0,所以增量初始为零。随着训练进行,A 和 B 的值会变化,α 控制了增量对总输出的贡献强度。通常取 α = 2r 或 α = r 的固定倍数,实践中常设 α = 16 或 32,而 r=8,此时实际缩放为 α/r = 2。

3.2 缩放规则的推导

考虑学习率 η,LoRA 增量 ΔW = BA。在训练时,若没有显式的缩放,B 和 A 的更新幅度会与 η 成正比。引入 α/r 后,实际等效学习率变为 η * (α/r)。通过调整 α,可以在不改变优化器学习率的情况下,控制低秩部分的更新步长。经验上,当 r 变化时,保持 α/r 不变(例如始终为 2),可以在不同秩的实验中保持相似的收敛行为。

3.3 在代码中的应用

如上一题代码所示,在 forward 中,我们计算 result + self.scaling * lora_x,其中 self.scaling = alpha / r。这样缩放因子就自然地融入了前向计算。

另一种变体是将缩放因子直接乘入 lora_B 的权重,在推理时可将 BA 合并进 W,缩放因子也可提前乘入 B,合并后即等效于 W0 + (α/r) BA


给定 r=8 和 α=16,计算 LoRA 适配器的实际缩放倍数,并说明不同 α 选择对训练的影响。

4.1 缩放倍数计算

缩放倍数 = α / r = 16 / 8 = 2。这意味着 LoRA 增量 BAx 在加入原始输出前会被乘以 2。换句话说,低秩部分对最终 logits 的贡献被放大了 2 倍。

4.2 不同 α 的影响

  • α 较大(如 α=32, r=8 → 缩放 4):低秩更新占据主导,模型更快偏离原始权重,可能学到更强的任务特异性,但也增加了过拟合风险。

  • α 较小(如 α=4, r=8 → 缩放 0.5):低秩更新被抑制,模型更接近原始预训练行为,微调更保守,适合数据量小的场景,但可能欠拟合。

  • 固定 α 改变 r:若保持 α/r 恒定(如始终为 2),则不同 r 的 LoRA 会有相似的动态特性,便于实验比较。

通常经验是:对于较小的微调数据集,选择较小的 α(或较小的 α/r)以保留更多预训练知识;对于大型微调数据,可适当增大 α 来鼓励模型适应新任务。


在 Transformer 的线性层上添加 LoRA 时,如何正确挂载适配器?写出 forward 中合并计算或分支计算的代码。

5.1 挂载适配器的方式

我们使用装饰器模式:用 LoRALinear 包装原始的 nn.Linear,在 forward 中同时计算原始输出和低秩增量,然后相加。这是“分支计算”方式,不需要修改原始权重。

5.2 分支计算的代码

已在第 2 题中展示。核心是:

def forward(self, x):
    result = self.original_linear(x)   # 原始分支
    lora_x = self.lora_B(self.lora_A(self.dropout(x)))  # 低秩分支
    result = result + self.scaling * lora_x
    return result

5.3 合并计算(推理优化)

在推理时,可将低秩矩阵与原始权重合并,形成单一的线性层,消除额外计算分支。

def merge_weights(self):
    # 合并后 W = W0 + (α/r) * (B @ A)
    delta = (self.alpha / self.r) * (self.lora_B.data @ self.lora_A.data)
    self.original_linear.weight.data += delta
    # 可选:清空 lora 参数
    self.lora_A = None
    self.lora_B = None

合并后模型前向变为普通的线性层,推理速度更快。


实现一个通用的 LoRALinear 类,继承自 nn.Module,包含原始权重、A、B、dropout、缩放因子等。

6.1 设计

我们可以设计一个更通用的 LoRALinear,既能从现有的 nn.Linear 构建,也能独立创建。支持可选的 bias,以及是否在合并时保留 LoRA 参数等。

6.2 完整实现

class LoRALinear(nn.Module):
    def __init__(self, in_features: int, out_features: int, r: int, alpha: float,
                 bias: bool = True, dropout: float = 0.0, base_linear: nn.Linear = None):
        super().__init__()
        # 如果提供了base_linear,则使用它的权重;否则新建
        if base_linear is not None:
            self.linear = base_linear
            in_features = base_linear.in_features
            out_features = base_linear.out_features
        else:
            self.linear = nn.Linear(in_features, out_features, bias=bias)
        self.r = r
        self.alpha = alpha
        self.scaling = alpha / r

        self.lora_A = nn.Parameter(torch.randn(r, in_features) * 0.02)
        self.lora_B = nn.Parameter(torch.zeros(out_features, r))
        self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()

        # 冻结原始线性层参数
        for param in self.linear.parameters():
            param.requires_grad = False

    def forward(self, x):
        out = self.linear(x)
        lora_out = self.lora_B(self.lora_A(self.dropout(x)))
        return out + self.scaling * lora_out

    def merge(self):
        """将 LoRA 权重合并到原始线性层"""
        delta = (self.alpha / self.r) * (self.lora_B.data @ self.lora_A.data)
        self.linear.weight.data += delta.to(self.linear.weight.dtype)
        # 清理 LoRA 参数,减少显存占用(可选)
        self.lora_A = None
        self.lora_B = None

这个类既可以用于“改造”已有模型,也可以独立构建新的 LoRA 层。


如何将多个 LoRA 适配器动态切换?写出一个支持多任务 LoRA 的前向代码,根据任务 ID 选择对应的 A、B 矩阵。

7.1 原理

多任务 LoRA 为每个任务单独训练一组 A、B 矩阵(或者仅训练 B,A 共享),在推理时根据任务 ID 动态选择对应的低秩权重。我们可以维护一个字典 self.lora_adapters = {task_id: (A, B)},在前向时根据传入的 task_id 选取对应的 A、B。

7.2 实现

class MultiTaskLoRALinear(nn.Module):
    def __init__(self, in_features, out_features, r, alpha, task_ids, dropout=0.0,
                 base_linear=None):
        super().__init__()
        if base_linear:
            self.linear = base_linear
        else:
            self.linear = nn.Linear(in_features, out_features)
        self.r = r
        self.alpha = alpha
        self.scaling = alpha / r
        self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()

        # 为每个任务创建独立的 A 和 B
        self.lora_A = nn.ParameterDict({
            task: nn.Parameter(torch.randn(r, in_features) * 0.02) for task in task_ids
        })
        self.lora_B = nn.ParameterDict({
            task: nn.Parameter(torch.zeros(out_features, r)) for task in task_ids
        })
        # 冻结原始权重
        for p in self.linear.parameters():
            p.requires_grad = False

    def forward(self, x, task_id):
        out = self.linear(x)
        A = self.lora_A[task_id]
        B = self.lora_B[task_id]
        lora_out = B(A(self.dropout(x)))
        return out + self.scaling * lora_out

使用时:

# 假设有两个任务: 'task1', 'task2'
adapter = MultiTaskLoRALinear(768, 768, r=8, alpha=16, task_ids=['task1','task2'])
out1 = adapter(x1, task_id='task1')
out2 = adapter(x2, task_id='task2')

7.3 优化

  • 如果任务数量多,可考虑仅切换 B(A 共享)以节省参数。

  • 切换开销极小,只是选取不同的参数张量。


在推理时将 LoRA 权重合并到原始权重中(W_merged = W₀ + BA),写出合并代码,并说明合并后对延迟的影响。

8.1 合并代码

def merge_lora(model):
    for module in model.modules():
        if isinstance(module, LoRALinear):
            delta = (module.alpha / module.r) * (module.lora_B.data @ module.lora_A.data)
            module.linear.weight.data += delta.to(module.linear.weight.dtype)
            # 删除 LoRA 参数,释放显存
            del module.lora_A
            del module.lora_B
            # 将模块转换为普通 nn.Linear,移除 LoRA 分支
            # 可替换整个 module 为 module.linear

在 HuggingFace 的 PEFT 库中,model.merge_and_unload() 就是执行这一操作。

8.2 对延迟的影响

  • 合并前:每次前向需要计算原始线性层 + LoRA 分支,引入额外的矩阵乘法和加法,增加了计算量和内存带宽消耗。

  • 合并后:LoRA 权重被吸收进原始的 nn.Linear,模型退化为标准的线性层,推理速度与未微调时完全一致,无额外开销。同时,LoRA 参数可以被释放,减少显存占用。因此,在生产环境部署时,推荐进行合并。


合并 LoRA 权重时,如何处理量化基础模型的场景(如 base model 为 4‑bit)?写出 QLoRA 的合并步骤。

9.1 QLoRA 背景

QLoRA 在 4‑bit 量化模型上应用 LoRA。量化后的权重存储为低比特整数,在计算时会被反量化为浮点。LoRA 适配器仍然以浮点形式训练。合并时,需要将浮点的 LoRA 增量加到反量化后的权重上,再重新量化保存,或者直接保存合并后的浮点权重。

9.2 合并步骤

  1. 获取原始模型权重的浮点表示:将 4‑bit 量化权重反量化为 FP16/FP32。

  2. 计算 LoRA 增量:delta = (α/r) * (B @ A)

  3. 浮点合并:W_merged_fp = W_dequantized + delta

  4. 将合并后的权重重新量化(如采用相同的量化参数或重新校准)。

  5. 保存量化后的模型,移除 LoRA 适配器。

伪代码:

import bitsandbytes as bnb

def merge_qlora(quantized_model, lora_weights):
    for name, module in quantized_model.named_modules():
        if hasattr(module, 'lora_A'):
            # 获取 LoRA 增量
            A = module.lora_A.data
            B = module.lora_B.data
            delta = (module.alpha / module.r) * (B @ A)
            # 获取反量化后的权重(假设模块是 bnb.nn.Linear4bit)
            # 使用 bnb 的内部方法获取浮点权重
            weight_fp = bnb.functional.dequantize_4bit(module.weight, module.weight.quant_state)
            merged_fp = weight_fp + delta.to(weight_fp.dtype)
            # 重新量化
            new_weight, new_quant_state = bnb.functional.quantize_4bit(merged_fp, ...)
            module.weight = new_weight
            module.weight.quant_state = new_quant_state
            # 移除 LoRA
            del module.lora_A, module.lora_B

9.3 注意事项

  • 合并后重新量化可能引入额外精度损失,理想情况下可直接在推理时使用合并后的浮点权重(若显存允许),或在微调结束后一次性合并并量化保存。

  • QLoRA 的训练过程中量化参数是固定的,合并不影响训练流程,仅用于部署优化。


实现 LoRA 的反向传播,手动推导 ∂L/∂A 和 ∂L/∂B,并写出对应的 PyTorch 自动求导验证代码。

10.1 手动推导

记前向过程:

  1. zA=Ax(忽略 dropout)

  2. zB=BzA=BAx

  3. h=W0x+szB,其中 s=α/r

损失函数 L 关于 A 和 B 的梯度: 由于 zB=BzA,根据链式法则:

image.png

10.2 验证代码

import torch

# 模拟数据
batch_size, in_dim, out_dim, r = 2, 64, 128, 4
x = torch.randn(batch_size, in_dim, requires_grad=True)
W0 = torch.randn(out_dim, in_dim, requires_grad=False)
A = torch.randn(r, in_dim, requires_grad=True)
B = torch.randn(out_dim, r, requires_grad=True)
alpha = 16.0
scaling = alpha / r

# 前向
h = x @ W0.T + scaling * (x @ A.T @ B.T)
# 假设计算损失
loss = h.sum()
loss.backward()

# 手动计算梯度
with torch.no_grad():
    # dL/dh = 1 (全1梯度)
    dL_dh = torch.ones_like(h)
    # dL/dz_B = scaling * dL_dh
    dL_dzB = scaling * dL_dh  # (batch, out_dim)
    # dL/dB = dL_dzB^T @ z_A  -> (out_dim, r)
    z_A = x @ A.T  # (batch, r)
    dL_dB_manual = dL_dzB.T @ z_A  # (out_dim, batch) @ (batch, r) = (out_dim, r)

    # dL/dA = s * B^T @ dL_dh^T @ x
    dL_dA_manual = scaling * (B.T @ dL_dh.T) @ x  # (r, out)@(out,batch)@(batch,in) = (r, in)

# 验证
print(torch.allclose(B.grad, dL_dB_manual, atol=1e-6))  # True
print(torch.allclose(A.grad, dL_dA_manual, atol=1e-6))  # True

10.3 关键点

  • LoRA 的反向传播完全遵循标准的矩阵微积分,由于 A 和 B 是独立的参数,梯度可以分别计算。

  • 在实际训练中,PyTorch 的自动求导会自动处理这些梯度,上述推导用于理解 LoRA 的训练动态,并可用于手动实现或调试。


为什么 LoRA 通常不作用在 embedding 层和 lm_head 上?如果要作用,需要注意哪些维度匹配问题?

11.1 原因分析

LoRA 通常仅应用于 Transformer 的线性投影层(如注意力中的 Q、K、V、O 矩阵以及 FFN 中的两个全连接层),而不建议或很少作用于词嵌入层(embedding)和输出投影层(lm_head)。主要理由如下:

对于 Embedding 层:

  • 参数量与收益比:词嵌入矩阵的参数量为 vocab_size × d_model,对于大型词汇表(如 50k~200k),即使使用低秩分解,r × (vocab + d_model) 也可能非常庞大,远超过原始嵌入矩阵,得不偿失。

  • 语义空间破坏:嵌入层在预训练阶段已经学习到一个紧致的语义空间,直接在其上施加低秩更新可能扭曲词向量,导致模型基础语言能力的退化。通常我们希望保持通用表征不变,仅调整高层交互。

  • 梯度更新效率:嵌入层的输入是离散的 token ID,参与梯度更新的只有极少数被选中的行(每次 batch 中仅涉及少量词汇),稀疏的梯度与密集的低秩矩阵相乘效率低下。

对于 lm_head:

  • 权重共享:许多模型(如 GPT-2、LLaMA 等)的 lm_head 权重与嵌入层是共享的(或至少共享相同维度)。如果在两者上都加 LoRA,会打破这种对称性,且可能导致不一致。

  • 输出分布剧烈变化:lm_head 直接影响生成概率,微调它容易导致模型输出分布剧烈偏移,产生更多幻觉或退化文本。冻结它而只调整中间层,可以在保留语言模型大部分能力的同时适应新任务。

  • 维度匹配:如果一定要对 lm_head 使用 LoRA,需注意其权重形状通常是 (vocab_size, d_model)(d_model, vocab_size),而 LoRA 的标准处理要求 in_featuresout_features 匹配。只需要将 lm_head 视为一个线性层 nn.Linear(d_model, vocab_size),在其上添加低秩适配器即可,但如前所述,通常不建议。

11.2 如果必须应用,维度匹配要点

假设 lm_head 是 nn.Linear(d_model, vocab_size),其权重形状为 (vocab_size, d_model)。定义 LoRA 时:

  • in_features = d_model, out_features = vocab_size

  • 低秩矩阵 A: (r, d_model),B: (vocab_size, r)

  • 此时参数量为 r × (d_model + vocab_size)。例如 LLaMA-7B 中 d_model=4096, vocab_size=32000, r=8 时,额外参数约为 8×(4096+32000)≈289k,而原始 lm_head 参数量约 131M,新增参数占比很小,但考虑到 lm_head 的敏感性和梯度的密集性,微调仍应谨慎。

11.3 实践建议

  • 通常完全冻结 embedding 和 lm_head,仅对 Transformer 中间层应用 LoRA。

  • 如果任务与词汇强相关(如领域适应需要引入大量新术语),可以考虑只微调嵌入层而不使用 LoRA,或使用稀疏更新策略。


解释 LoRA 与全参数微调相比,为什么能显著减少优化器状态显存?给出参数量和显存的定量计算。

12.1 优化器状态显存的来源

在深度学习训练中,显存占用主要由模型参数、梯度、优化器状态(如 Adam 的一阶动量 m、二阶动量 v)以及中间激活组成。对于 Adam 优化器,每个可训练参数需要存储其梯度、m 和 v,因此优化器状态显存通常是参数显存的 2 倍(m 和 v 各一份,且梯度也占用一份显存,总计 3 倍参数显存)。

全参数微调时,所有模型参数都需要计算梯度并维护优化器状态。假设模型参数量为 P(以字节计),FP16 下参数本身占 2P 字节,梯度也占 2P 字节,Adam 状态(FP32 存储)占 4P × 2 = 8P 字节。总计约 12P 字节,但通常估算为参数显存的 3~4 倍。

12.2 LoRA 的显存节约

LoRA 仅训练低秩矩阵 A 和 B,而冻结原始权重 W₀。因此需要梯度计算和优化器状态的参数量仅为 A 和 B 的总参数量:

  • 设秩为 r,原始权重尺寸为 d_in × d_out

  • LoRA 可训练参数量 = r × (d_in + d_out)

  • 若对所有 Q、K、V、O 及 FFN 层均应用 LoRA,总可训参数可能仅占原模型的千分之几。

例如,LLaMA-7B 模型参数约 7B。若只对 Attention 的 Q、V 投影添加 r=8 的 LoRA,可训参数约数十万,相比全参数微调,优化器状态显存从数十 GB 降至几十 MB。

定量计算示例:假设线性层 W ∈ R^{4096×4096},参数量约 16.8M。应用 r=8 的 LoRA:A (8×4096), B (4096×8),共 65,536 参数,仅为原参数的 0.39%。优化器状态显存从 16.8M × 12 ≈ 202M 字节降至 65K × 12 ≈ 0.78M 字节。

12.3 其他显存组成

  • 激活显存:LoRA 前向额外计算了 A @ xB @ (A @ x),会增加少量中间激活,但相比全参数微调(需存储所有层的梯度计算图),激活显存也大幅减少,因为冻结层的激活不必保留其梯度图。

  • 使用梯度检查点(gradient checkpointing)可进一步降低激活显存,而 LoRA 的少量额外计算几乎不影响吞吐。

因此,LoRA 通过极小化可训练参数,使微调在消费级 GPU 上成为可能。


设计一个实验:比较 r=1, r=4, r=16 时的微调性能和可训练参数量,写出配置 LoRA 的代码。

13.1 实验设计

目标:评估不同秩对下游任务性能(如困惑度或分类准确率)的影响,以及参数量与训练效率。

步骤:

  1. 选择基座模型(如 GPT-2 或 LLaMA-7B)。

  2. 固定其他超参数:学习率、batch size、训练步数、LoRA 的 α(例如 α=16)、dropout 等。

  3. 对每个 r 值(1, 4, 16)分别创建一个 LoRA 配置,注入模型。

  4. 在相同的微调数据集上训练相同步数,记录训练损失、验证困惑度、下游任务指标。

  5. 统计可训练参数量、峰值显存、训练吞吐。

13.2 配置 LoRA 的代码(基于 HuggingFace PEFT)

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import get_peft_model, LoraConfig, TaskType

def run_lora_experiment(model_name, r, alpha=16):
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")

    lora_config = LoraConfig(
        task_type=TaskType.CAUSAL_LM,
        r=r,
        lora_alpha=alpha,
        lora_dropout=0.05,
        target_modules=["q_proj", "v_proj"],  # 仅示例,可按需扩展
    )
    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()  # 输出可训练参数量
    return model, tokenizer

# 实验循环
for r in [1, 4, 16]:
    model, tokenizer = run_lora_experiment("gpt2", r=r)
    # 进行训练,记录指标...

13.3 预期结果与分析

  • r=1:可训参数最少,训练最快,但性能可能受限,因为低秩约束过强,只能捕捉到非常粗糙的任务适应。

  • r=4:通常是一个较好的折中,能在多数任务上接近全参数微调的性能,同时保持低资源消耗。

  • r=16:可训参数较多,性能可能进一步提升,但边际收益递减,且过拟合风险增加。

实验中应观察到随着 r 增加,训练损失下降更快,但 r=16 相比 r=4 的提升可能不显著。最终选择取决于任务复杂度和资源预算。


实现 AdaLoRA 的动态秩分配思想简化版:根据权重重要性,在训练过程中修剪不重要的秩,写出伪代码。

14.1 AdaLoRA 原理

AdaLoRA 将 LoRA 的固定秩 r 改为可学习的,并通过重要性评分(如基于参数梯度或 Fisher 信息)动态地分配秩。它将 ΔW 分解为 SVD 形式:ΔW = P Λ Q,其中 Λ 是对角矩阵,包含奇异值。通过裁剪小的奇异值及其对应的奇异向量,实现秩的剪枝,使重要方向保留更多容量,不重要的方向被移除。

14.2 简化版伪代码

我们维护一个总预算(target_total_rank),但实际秩根据重要性动态调整。

class AdaLoRALinear(nn.Module):
    def __init__(self, in_dim, out_dim, initial_r, target_r):
        self.A = nn.Parameter(torch.randn(initial_r, in_dim))
        self.B = nn.Parameter(torch.zeros(out_dim, initial_r))
        self.importance = nn.Parameter(torch.ones(initial_r))  # 每个秩的重要性
        self.target_r = target_r

    def forward(self, x):
        # 根据 importance 选取 top-r 个秩
        _, idx = torch.topk(self.importance, self.target_r)
        A_sub = self.A[idx]  # (r', in_dim)
        B_sub = self.B[:, idx]  # (out_dim, r')
        return x @ A_sub.T @ B_sub.T * (alpha / self.target_r)

    def update_importance(self):
        # 示例:基于梯度的 L2 范数更新重要性
        grad_A = self.A.grad  # 需要累积
        importance = grad_A.norm(dim=1)  # 简单范数
        self.importance.data = 0.9 * self.importance.data + 0.1 * importance

完整的 AdaLoRA 会更复杂,涉及 SVD 重参数化和正交性约束。上述简化版仅用于说明思想。


如何将 LoRA 与梯度检查点结合使用?写出在 LoRA 层上启用 torch.utils.checkpoint 的代码片段。

15.1 背景

梯度检查点(Gradient Checkpointing)通过在反向传播时重新计算中间激活来节省显存。对于 LoRA 层,我们希望冻结的原始权重部分不保存激活,而只保存 LoRA 分支必要的激活。PyTorch 的 torch.utils.checkpoint.checkpoint 可以包裹任意函数。

15.2 代码实现

LoRALinear.forward 中使用梯度检查点,需要将计算拆分为一个独立的函数,以便重计算。

import torch.utils.checkpoint as checkpoint

class LoRALinear(nn.Module):
    # ... 初始化省略

    def _lora_forward(self, x):
        # 仅包含 LoRA 分支的计算
        return self.lora_B(self.lora_A(self.dropout(x)))

    def forward(self, x):
        base_out = self.original_linear(x)
        # 使用检查点包裹 LoRA 分支,节省其激活显存
        lora_out = checkpoint.checkpoint(self._lora_forward, x, use_reentrant=False)
        return base_out + self.scaling * lora_out

注意:如果希望也减少原始线性层内部的激活,可对整个 self.original_linear 使用检查点,但通常原始层已被冻结且不更新梯度,可以不保留其激活图。更简单的做法是仅在模型的 Transformer 层级别应用检查点,LoRA 层内的少量激活可忽略。


当基座模型更新(继续预训练)后,原先训练的 LoRA 权重是否还能有效合并?写出验证代码并分析。

16.1 理论分析

LoRA 权重是相对于某个特定基座模型 W0 训练的。如果基座模型被继续预训练(参数更新为 W0'),那么原先在 W0 上学到的低秩增量 ΔW 可能不再适用于 W0'。原因:

  • ΔW 是针对原始输出分布进行的修正,当基座权重漂移后,修正方向可能不再匹配。

  • 强行合并会导致输出出现非预期的偏移。

因此,通常不建议跨版本合并。但若两次训练的数据分布或目标相似,且基座更新较小,合并可能仍有一定效果,但需验证。

16.2 验证代码

我们可以通过比较合并前后模型在验证集上的困惑度或下游指标来评估。

def validate_lora_merge(original_model, updated_model, lora_weights, eval_dataset):
    # 1. 在原始基座上合并 LoRA,评估
    orig_model = copy.deepcopy(original_model)
    load_lora(orig_model, lora_weights)
    merged_orig = merge_and_unload(orig_model)
    metric_orig = evaluate(merged_orig, eval_dataset)

    # 2. 在更新后的基座上合并 LoRA,评估
    upd_model = copy.deepcopy(updated_model)
    load_lora(upd_model, lora_weights)
    merged_upd = merge_and_unload(upd_model)
    metric_upd = evaluate(merged_upd, eval_dataset)

    # 3. 比较
    print(f"Original base metric: {metric_orig}, Updated base metric: {metric_upd}")
    return metric_orig, metric_upd

通常会发现更新后的基座上性能显著下降,说明需要重新训练 LoRA。


实现一个函数,将多个 LoRA 适配器进行加权融合(如 W_merged = W0 + w1B1A1 + w2B2A2),写出权重融合的代码。

17.1 应用场景

多任务 LoRA 的集成:将针对不同任务训练的多个 LoRA 适配器加权组合,得到一个融合后的模型,无需显式切换适配器。权重 w_i 可以人工指定或通过学习得到。

17.2 实现

def merge_multiple_loras(model, lora_list, weights):
    """
    lora_list: 每个元素是保存的LoRA权重字典 {layer_name: {'A': A_tensor, 'B': B_tensor, 'alpha': val, 'r': val}}
    weights: 与 lora_list 对应的融合权重列表,总和不需要为1。
    操作:对于每个LoRA层,计算 ΔW_total = Σ w_i * (α_i / r_i) * B_i @ A_i
    然后加到原始权重 W0 上。
    """
    # 首先收集所有LoRA层的名称(假设所有lora的层名称一致)
    layer_names = lora_list[0].keys()
    for name in layer_names:
        delta_total = 0.0
        for lora_weights, w in zip(lora_list, weights):
            A = lora_weights[name]['A']
            B = lora_weights[name]['B']
            alpha = lora_weights[name]['alpha']
            r = lora_weights[name]['r']
            delta = (alpha / r) * (B @ A)
            delta_total = delta_total + w * delta
        # 找到对应的原始权重层并累加
        target_module = dict(model.named_modules())[name]
        if isinstance(target_module, LoRALinear):
            target_module.linear.weight.data += delta_total.to(target_module.linear.weight.dtype)
        elif isinstance(target_module, nn.Linear):
            target_module.weight.data += delta_total.to(target_module.weight.dtype)

17.3 注意事项

  • 融合后模型变为普通线性层,不再包含 LoRA 参数。

  • 可以指定融合哪些层,或者对所有层应用。

  • 如果需要保留原 LoRA 结构以便后续调整,可创建新的适配器存储融合结果。


如何将 LoRA 适配器独立保存和加载?写出 save_lora_weights 和 load_lora_weights 的核心逻辑。

18.1 保存

LoRA 适配器的核心是 A 和 B 矩阵,以及配置参数(r, α, dropout, 应用的目标模块等)。我们只保存这些参数的 state_dict,而不是整个模型。

def save_lora_weights(model, filepath):
    lora_state = {}
    for name, module in model.named_modules():
        if isinstance(module, LoRALinear):
            lora_state[name + ".lora_A"] = module.lora_A.data
            lora_state[name + ".lora_B"] = module.lora_B.data
            lora_state[name + ".scaling"] = module.scaling
    # 同时保存配置(如r, α等)以JSON文件
    torch.save(lora_state, filepath)

18.2 加载

加载时,需要将保存的权重赋值到已经构建好的 LoRA 模型结构上。因此通常需要先加载基座模型并应用与保存时相同的 LoRA 配置(相同的 r、α、目标层),然后再加载权重。

def load_lora_weights(model, filepath):
    lora_state = torch.load(filepath)
    for name, param in model.named_parameters():
        if name in lora_state:
            param.data.copy_(lora_state[name])

在 PEFT 库中,保存和加载使用 model.save_pretrained(out_dir)PeftModel.from_pretrained(base_model, out_dir),它们内部处理了这些细节。


在分布式训练中使用 LoRA 时,梯度同步是否需要全量通信?写出 LoRA 在 DDP 中的通信优化分析。

19.1 分析

在 PyTorch 的分布式数据并行(DDP)中,每个设备持有完整的模型副本,梯度同步通过 AllReduce 通信来平均所有 rank 的梯度。标准 DDP 会对所有 requires_grad=True 的参数的梯度进行通信。

对于 LoRA:

  • 可训练参数仅 A 和 B,总参数量极小(相比全模型)。

  • 冻结的原始权重 W0 不需要梯度,因此 DDP 不会为其分配梯度桶,也不参与通信。

  • 因此,LoRA 的梯度同步通信量极低,仅与可训参数量成正比。

19.2 通信优化

  • 通信量计算:假设应用 LoRA 的所有层可训参数总数为 M(通常远小于模型大小),则每步 AllReduce 的通信量为 2 * M * sizeof(gradient)(FP16 梯度时约 2 bytes)。例如,若 M=1M 参数,通信量仅约 2MB,与模型并行中的海量通信相比可忽略。

  • 通信频率:与标准 DDP 一致,每步一次。

  • 优化:可进一步使用 gradient_as_bucket_viewbucket_cap_mb 调整桶大小以匹配 LoRA 的梯度规模,但通常默认设置已足够高效。

19.3 代码示例

import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP

model = get_lora_model(...)
model = DDP(model, device_ids=[local_rank])  # LoRA 适配器自动被 DDP 识别

由于只有 LoRA 参数需要同步,DDP 内部会忽略那些 requires_grad=False 的参数。因此,LoRA 在分布式训练中通信开销极小,扩展性极佳。


LoRA 作用于卷积层时应如何设计?写出 LoRAConv2d 的前向实现。

20.1 设计思路

卷积层的权重张量形状为 (out_channels, in_channels, kernel_h, kernel_w)。LoRA 的思想可以自然地扩展到卷积层:我们将卷积核的增量分解为低秩形式。常见做法是将卷积核视为一个大的矩阵 (out_channels, in_channels * k_h * k_w),然后应用低秩分解;或者采用张量分解。但更简单且有效的方式是将 LoRA 应用于 1x1 卷积(等价于线性层),而对于空间卷积,通常保留其原始形式,因为空间维度通常较小,低秩收益有限。

如果一定要实现通用 LoRAConv2d,我们可以将卷积操作的增量设计为:对输入 x,先经过一个小的卷积(降维),再经过一个卷积(升维),产生与原始输出同形状的增量。这类似于深度可分离卷积的思想,但低秩体现在通道数上。

20.2 实现(通道低秩版本)

class LoRAConv2d(nn.Module):
    def __init__(self, conv2d: nn.Conv2d, r: int, alpha: float):
        super().__init__()
        self.conv = conv2d
        self.r = r
        self.alpha = alpha
        self.scaling = alpha / r

        in_ch = conv2d.in_channels
        out_ch = conv2d.out_channels
        k_size = conv2d.kernel_size
        stride = conv2d.stride
        padding = conv2d.padding

        # 降维卷积 (in_ch -> r)
        self.lora_A = nn.Conv2d(in_ch, r, kernel_size=k_size, stride=stride, padding=padding, bias=False)
        # 升维卷积 (r -> out_ch) 用1x1卷积,因为空间尺寸已经由lora_A确定
        self.lora_B = nn.Conv2d(r, out_ch, kernel_size=1, bias=False)

        # 初始化:A 随机,B 零
        nn.init.kaiming_normal_(self.lora_A.weight)
        nn.init.zeros_(self.lora_B.weight)

        # 冻结原始卷积
        for p in self.conv.parameters():
            p.requires_grad = False

    def forward(self, x):
        out = self.conv(x)
        lora_out = self.lora_B(self.lora_A(x))
        return out + self.scaling * lora_out

注意:这里 lora_A 采用了与原始卷积相同的 kernel_size, stride, padding,以保证空间分辨率一致;lora_B 使用 1x1 卷积,仅做通道映射。这种设计保持了空间一致性,并且可训练参数非常少(r×in_ch×k_h×k_w + r×out_ch×1×1)。


解释 QLoRA 中双重量化和分页优化器如何与 LoRA 配合,写出加载 QLoRA 模型并进行推理的代码。

21.1 QLoRA 原理

QLoRA (Quantized LoRA) 将 LoRA 与 4-bit 量化(通常使用 NormalFloat4 数据类型)结合,进一步大幅降低显存占用。其核心组件:

  • 4-bit 基础模型权重:模型权重以 4-bit 格式存储,在计算时反量化为 BF16/FP16。

  • 双重量化:对第一次量化的缩放常数再次进行量化(如 8-bit),减少量化常数的存储开销。

  • 分页优化器:当 GPU 显存不足时,将优化器状态自动分页到 CPU 内存,以避免 OOM。

  • LoRA 适配器:以全精度(FP16/BF16)添加在需要微调的层上。

21.2 加载 QLoRA 模型并进行推理

使用 bitsandbytespeft 库可以轻松实现。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

# 加载基座模型(4-bit 量化)
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    load_in_4bit=True,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,  # 双重量化
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")

# 加载训练好的 LoRA 权重
model = PeftModel.from_pretrained(model, "path/to/lora-adapter")

# 推理
inputs = tokenizer("Hello, how are you?", return_tensors="pt").to(model.device)
outputs = model.generate(**inputs)
print(tokenizer.decode(outputs[0]))

QLoRA 训练时,bnb_4bit_use_double_quant=True 开启双重量化;分页优化器通过 paged_adamw 或在 TrainingArguments 中设置 optim="paged_adamw_32bit" 实现。

21.3 合并 QLoRA 权重

如第 9 题所述,需要反量化基础权重,加上 LoRA 增量,再选择重新量化或直接使用浮点模型。推荐使用 model.merge_and_unload() 自动完成此过程,得到合并后的浮点模型,然后可进行推理。