Chapter 12

写一个新的 PEFT 方法

📌 commit a106ff4c7061 从 0 实现一个 PoC tuner:ScaleLoRA

PEFT 的代码骨架做得相对清晰,写一个全新的 tuner 只需要四个文件 ≈ 200 行。 本章带你实现一个示例方法 ScaleLoRA(在 LoRA 的 BA 外加上可学的逐位置 scaling,类似 DoRA 的简化版), 顺道把"PEFT 扩展点"全摸一遍。

12.1需要写哪些文件

文件角色
tuners/scalelora/config.py ScaleLoraConfig dataclass
tuners/scalelora/layer.py ScaleLoraLinear(替换 nn.Linear)
tuners/scalelora/model.py ScaleLoraModel(继承 BaseTuner)
tuners/scalelora/__init__.py 导出公共名字
注册到 mapping 修改 peft/utils/peft_types.py + peft/mapping_func.py

12.2定义算法

ScaleLoRA 的公式:

$$y = W_0 x + s \odot \frac{\alpha}{r}\, B A x,\quad s \in \mathbb{R}^{d_\text{out}}$$

跟 LoRA 比多了 $s$(每个 output 维度一个标量),训练时一起学。$s$ 初始为 1。

12.3config.py

# src/peft/tuners/scalelora/config.py
from dataclasses import dataclass, field
from typing import List, Optional, Union
from peft.config import PeftConfig
from peft.utils import PeftType

@dataclass
class ScaleLoraConfig(PeftConfig):
    r: int = field(default=8)
    target_modules: Optional[Union[List[str], str]] = field(default=None)
    lora_alpha: int = field(default=16)
    lora_dropout: float = field(default=0.0)
    bias: str = field(default="none")
    modules_to_save: Optional[List[str]] = field(default=None)

    def __post_init__(self):
        self.peft_type = PeftType.SCALELORA      # ★ 新加的类型

记得在 peft/utils/peft_types.py 里加一行:

class PeftType(str, enum.Enum):
    ...
    SCALELORA = "SCALELORA"        # ★ 新增

12.4layer.py

# src/peft/tuners/scalelora/layer.py
import math
import torch
from torch import nn
from peft.tuners.tuners_utils import BaseTunerLayer

class ScaleLoraLinear(nn.Module, BaseTunerLayer):
    adapter_layer_names = ("lora_A", "lora_B", "lora_s", "lora_dropout")

    def __init__(self, base_layer: nn.Linear, adapter_name: str,
                 r: int, lora_alpha: int, lora_dropout: float = 0.0):
        nn.Module.__init__(self)
        BaseTunerLayer.__init__(self)
        self.base_layer = base_layer
        self.in_features = base_layer.in_features
        self.out_features = base_layer.out_features
        # 用 ModuleDict 支持多 adapter
        self.lora_A = nn.ModuleDict({})
        self.lora_B = nn.ModuleDict({})
        self.lora_s = nn.ParameterDict({})
        self.lora_dropout = nn.ModuleDict({})
        self.scaling = {}
        self._active_adapter = adapter_name
        self.update_layer(adapter_name, r, lora_alpha, lora_dropout)

    def update_layer(self, adapter_name, r, lora_alpha, lora_dropout):
        self.lora_A[adapter_name] = nn.Linear(self.in_features, r, bias=False)
        self.lora_B[adapter_name] = nn.Linear(r, self.out_features, bias=False)
        self.lora_s[adapter_name] = nn.Parameter(torch.ones(self.out_features))
        self.lora_dropout[adapter_name] = (
            nn.Dropout(p=lora_dropout) if lora_dropout > 0 else nn.Identity()
        )
        self.scaling[adapter_name] = lora_alpha / r
        nn.init.kaiming_uniform_(self.lora_A[adapter_name].weight, a=math.sqrt(5))
        nn.init.zeros_(self.lora_B[adapter_name].weight)

    def forward(self, x: torch.Tensor):
        result = self.base_layer(x)
        if self.disable_adapters:
            return result
        for adapter in self.active_adapters:
            if adapter not in self.lora_A:
                continue
            A = self.lora_A[adapter]
            B = self.lora_B[adapter]
            s = self.lora_s[adapter]
            scaling = self.scaling[adapter]
            dropout = self.lora_dropout[adapter]
            update = B(A(dropout(x))) * scaling          # 普通 LoRA 部分
            update = update * s                          # ★ 我们加的逐位 scaling
            result = result + update
        return result

    def merge(self, safe_merge: bool = False, adapter_names=None):
        for adapter in (adapter_names or self.active_adapters):
            if adapter in self.merged_adapters: continue
            A = self.lora_A[adapter].weight
            B = self.lora_B[adapter].weight
            s = self.lora_s[adapter]
            delta_W = (s.unsqueeze(1) * (B @ A)) * self.scaling[adapter]
            self.base_layer.weight.data += delta_W
            self.merged_adapters.append(adapter)

12.5model.py

# src/peft/tuners/scalelora/model.py
import torch
from torch import nn
from peft.tuners.tuners_utils import BaseTuner
from .layer import ScaleLoraLinear

class ScaleLoraModel(BaseTuner):
    prefix = "lora_"               # ★ 用来标记可训参数的前缀

    def _check_new_adapter_config(self, config):
        # 通常空着或做简单合法性检查
        pass

    def _check_target_module_exists(self, peft_config, key):
        # 用 endswith 匹配
        if isinstance(peft_config.target_modules, str):
            import re
            return re.fullmatch(peft_config.target_modules, key)
        return any(key.endswith(t) for t in peft_config.target_modules)

    def _create_and_replace(self, peft_config, adapter_name, target, target_name,
                            parent, current_key):
        if isinstance(target, nn.Linear):
            new_module = ScaleLoraLinear(
                base_layer=target, adapter_name=adapter_name,
                r=peft_config.r, lora_alpha=peft_config.lora_alpha,
                lora_dropout=peft_config.lora_dropout,
            )
            self._replace_module(parent, target_name, new_module, target)

    def _replace_module(self, parent, child_name, new_module, child):
        setattr(parent, child_name, new_module)
        # 保持 weight / bias 设备一致
        new_module.to(child.weight.device)

    def _mark_only_adapters_as_trainable(self, model):
        for n, p in model.named_parameters():
            if "lora_" not in n:
                p.requires_grad = False

    @staticmethod
    def _prepare_adapter_config(peft_config, model_config):
        return peft_config

12.6注册到 PEFT 主入口

src/peft/mapping_func.py(或新版 mapping.py):

from peft.tuners.scalelora import ScaleLoraConfig, ScaleLoraModel

PEFT_TYPE_TO_CONFIG_MAPPING["SCALELORA"] = ScaleLoraConfig
PEFT_TYPE_TO_TUNER_MAPPING["SCALELORA"] = ScaleLoraModel

然后还要把它绑定到对应的 PeftModelForXXX 上(同文件附近):

MODEL_TYPE_TO_PEFT_MODEL_MAPPING = {
    "CAUSAL_LM": {
        ...,
        "SCALELORA": PeftModelForCausalLM,
    },
    ...
}

12.7试用

from peft.tuners.scalelora import ScaleLoraConfig
from peft import get_peft_model

cfg = ScaleLoraConfig(
    task_type="CAUSAL_LM",
    r=8, lora_alpha=16,
    target_modules=["q_proj","v_proj"],
)
model = get_peft_model(base_model, cfg)
model.print_trainable_parameters()
# trainable: ~0.12% (LoRA 0.10% + s 矩阵约 0.02%)

跑训练、save/load、merge、multi-adapter 全都跟 LoRA 一样能用——因为我们继承了 BaseTuner / BaseTunerLayer 的所有公共行为。

12.8写完后还要做什么

  1. 写测试:参考 tests/test_custom_models.py,把新 tuner 加进 PARAMETRIZED 测试矩阵;
  2. 写 docstring:每个公开类的 docstring 必须用 sphinx 风格;
  3. 跑 ruff + mypy:仓库根目录 make quality
  4. 更新 docsdocs/source/conceptual_guides/ 加方法说明;
  5. 提 PR:在 GitHub issue 里先沟通;PR 标题用 FEAT: Add ScaleLoRA

12.9真实新方法的常见难点

问题原因 / 处理
量化兼容 nn.Linear 的兼容版 (bnb / awq / gptq) 都要写一份 ScaleLoraLinear,结构相似但 base_layer 不同;可以参考 src/peft/tuners/lora/bnb.py
save_pretrained 不保存 s BaseTunerLayer.adapter_layer_names 必须列出所有 ParameterDict / ModuleDict 名字
多 adapter 切换不生效 active_adapters 必须从 BaseTunerLayer 继承,不要自己实现
merge 后效果跟 unmerge 不一致 forward 公式和 merge 公式必须严格一致;单元测试一定要写 "merge → 推理 = unmerge → 推理"
训练时 lora_s 没梯度 _mark_only_adapters_as_trainable 里 prefix 没包含 "lora_",或自己加错了

12.10另一条路:写 LoraVariant 而不是新 tuner

本章前面教的是"完全自定义 tuner"——加 config + layer + model 三件套。但如果你的新方法只是改变 LoRA 的 forward / merge 行为(如 DoRA、ALoRA),更优雅的做法是写一个 LoraVariant 子类(见 ch04.8)。

对比两条路:

维度完全自定义 tunerLoraVariant 子类
代码量 config + layer + model + 注册,~200 行 一个 LoraVariant 子类,~50 行
新 peft_type 必须新增 不需要(复用 LORA)
量化兼容 每个 backend 都要写一份 自动继承 LoRA 的全部 backend 兼容
多 adapter 需要自己实现 ModuleDict 自动支持
典型例子 IA³ / Prompt / OFT DoRA / ALoRA / VeLoRA

"新方法仍然把 ΔW = BA 当核心吗?"——是 → 选 LoraVariant;不是 → 完全自定义 tuner。

12.11LoraVariant 模板

来源:src/peft/tuners/lora/layer.py:53–101 的抽象基类。写一个新 variant 大致这样:

from peft.tuners.lora.layer import LoraVariant
import torch.nn as nn
import torch

class MyVariant(LoraVariant):
    """演示:给 LoRA forward 额外加一个 learnable bias vector"""

    def init(self, layer, adapter_name):
        # 给每个 adapter 加一个 bias parameter
        out_features = layer.base_layer.out_features
        if not hasattr(layer, "my_bias"):
            layer.my_bias = nn.ParameterDict()
        layer.my_bias[adapter_name] = nn.Parameter(torch.zeros(out_features))

    def merge_safe(self, layer, adapter_name):
        # 把 BA + my_bias 写回 base_layer
        delta_w = layer.lora_B[adapter_name].weight @ layer.lora_A[adapter_name].weight
        layer.base_layer.weight.data += delta_w * layer.scaling[adapter_name]
        # bias 单独 merge
        if layer.base_layer.bias is None:
            layer.base_layer.bias = nn.Parameter(layer.my_bias[adapter_name].clone())
        else:
            layer.base_layer.bias.data += layer.my_bias[adapter_name]

    def merge_unsafe(self, layer, adapter_name):
        # 不做 NaN 校验的版本
        self.merge_safe(layer, adapter_name)

    def unmerge(self, layer, adapter_name):
        # merge_safe 的逆
        delta_w = layer.lora_B[adapter_name].weight @ layer.lora_A[adapter_name].weight
        layer.base_layer.weight.data -= delta_w * layer.scaling[adapter_name]
        if layer.base_layer.bias is not None:
            layer.base_layer.bias.data -= layer.my_bias[adapter_name]

    def forward(self, layer, x, scaling):
        # base forward 已经在外面跑过;这里返回 LoRA 增量
        lora_x = layer.lora_A[layer.active_adapters[0]](x)
        lora_x = layer.lora_B[layer.active_adapters[0]](lora_x) * scaling
        lora_x = lora_x + layer.my_bias[layer.active_adapters[0]]
        return lora_x

注册:在 LoraConfiguse_my_variant=True flag 触发(需要给 config 加字段 + 在 layer 的 resolve_lora_variant() 加 case)。

12.12提 PR 前 5 项 checklist

必做
1单元测试:保 tests/test_decoder_models.py / tests/test_custom_models.py 风格,至少跑通 "init → forward → save → load → forward 结果一致"
2merge ↔ unmerge 等价测试output_with_adapter == base_output + adapter_output & output_after_merge == output_with_adapter
3多 adapter 测试:add / set / delete / disable / enable 五个动作分别测
4文档docs/source/package_reference/<your_method>.md 写 config 字段和论文链接
5exampleexamples/<your_method>_finetuning/ 给一个跑得通的最简 SFT

12.13这章你需要带走的