【Bug已解决】Add BoRA to PEFT 解决方案
一、现象长什么样
社区提议把BoRA(Bottleneck Rank Adaptation,瓶颈秩适配)加进 PEFT。BoRA 是 LoRA 的一个变体:在标准 LoRA 的B·A·x之外,再引入一个共享瓶颈来进一步压缩可训练参数、提升跨层一致性。
但要把一个新方法接进 PEFT 时,使用者会遇到和之前“Add PSOFT”类似的工程问题:
- 没有统一的
BoRAConfig/BoRAModel/mapping注册,自己改写forward容易和 PEFT 的merge_and_unload/load_adapter/print_trainable_parameters冲突; - BoRA 的“瓶颈”结构(共享投影)若实现成额外 Module,保存时键名和 PEFT 约定不一致,跨模型
load错配; - 想和 LoRA 组合(同一模型一部分层用 LoRA、一部分用 BoRA)时发现 PEFT 没有对应扩展点;
- 复用
save_pretrained时,瓶颈参数没有统一的adapter_config.json描述。
所以“Add BoRA to PEFT”本质是集成方法论:如何按 PEFT 扩展规范,把 BoRA 接成一等公民,且能与现有方法组合、可被get_peft_model管理。
二、背景
BoRA 的核心思想(以 Bottleneck Rank Adaptation 的通用形式):标准 LoRA 是Δ = B·A·x,其中A ∈ ℝ^{r×in}、B ∈ ℝ^{out×r}。BoRA 在此基础上引入一个跨层共享的瓶颈投影P ∈ ℝ^{r×s}(s < r),把每层的A写成A = P·a_layer(a_layer ∈ ℝ^{s×in}是每层独有的小矩阵),于是每层只需训a_layer(远小于完整A),瓶颈P在所有层间共享:
Δ = B · (P · a_layer) · x好处:每层可训练参数从r·in + out·r降到s·in + out·r + r·s(共享P只算一次),且共享瓶颈起到跨层正则作用,缓解过拟合。
PEFT 扩展规范(回顾)仍是三件套:
BoRAConfig(PeftConfig):超参r、s、alpha、target_modules。BoRAModel(BaseTuner):持有基座 + 每层a_layer+ 共享P,重写forward。mapping.py注册:PEFT_TYPE_TO_MODEL_MAPPING[PeftType.BORA] = BoRAModel。
下面用可运行代码给出自包含的 BoRA 实现(结构对齐 PEFT 规范),以及一个把它接到 PEFT 风格的骨架。
三、根因
这里的“问题”是集成规范缺失导致的重复造轮子:
- 三件套不全:每个用户自己改
forward,无法复用 PEFT 生态。 - 瓶颈参数键名不统一:跨模型
save/load错配。 - 无法与 LoRA 组合:缺扩展点。
修复方向:按 PEFT 规范实现BoRAConfig+BoRAModel+mapping注册,让 BoRA 成为可管理的一等公民,且能用add_adapter与 LoRA 并存。
四、最小可运行复现
下面给一个自包含的 BoRA 实现(共享瓶颈P+ 每层a_layer+B),离线可验证数学正确。
import torch import torch.nn as nn class BoRAConfig: def __init__(self, r=8, s=2, alpha=16, target_modules=("fc1",)): self.r, self.s, self.alpha = r, s, alpha self.target_modules = target_modules class BoRALayer(nn.Module): """单层 BoRA:Δ = B · (P · a) · x,P 共享、a/B 本层独有。""" def __init__(self, in_f, out_f, r, s): super().__init__() self.a = nn.Parameter(torch.randn(s, in_f) * 0.01) # 每层独有 [s, in] self.B = nn.Parameter(torch.zeros(out_f, r)) # 每层独有 [out, r] # 共享瓶颈 P 作为 buffer(所有 BoRALayer 共享同一份) self.register_buffer("P", torch.randn(r, s) * 0.01) def forward(self, x, P): # a: [s, in] -> P@a: [r, in] -> (P@a)@x.T: [r, T] -> B@: [out, T] A_eff = P @ self.a # [r, in] delta = (self.B @ (A_eff @ x.T)).T # [T, out] return delta class BoRANet(nn.Module): def __init__(self, in_f, out_f, r, s, n_layers): super().__init__() self.P = nn.Parameter(torch.randn(r, s) * 0.01) # 共享瓶颈 self.blocks = nn.ModuleList() self.base = nn.ModuleList([nn.Linear(in_f, out_f) for _ in range(n_layers)]) for _ in range(n_layers): self.blocks.append(BoRALayer(in_f, out_f, r, s)) def forward(self, x): out = x for i, blk in enumerate(self.blocks): out = self.base[i](out) + blk(out, self.P) # 基座 + BoRA 增量 return out torch.manual_seed(0) net = BoRANet(16, 16, r=8, s=2, n_layers=3) x = torch.randn(2, 16) out = net(x) # 训练参数:共享 P(r*s) + 每层 (a: s*in + B: out*r) per_layer = 2 * 16 + 16 * 8 total = 8 * 2 + 3 * per_layer tr = sum(p.numel() for p in net.parameters() if p.requires_grad) print("输出形状:", tuple(out.shape)) print("实际可训练:", tr, " 理论:", total, " 一致:", tr == total)运行后:输出形状正确,可训练参数 = 共享P(8*2)+ 每层a(2*16)+B(16*8),且requires_grad=True仅限这些——验证了 BoRA 的核心结构与参数节省。
五、解决方案(第一层):最小直接修复
修复 1:共享瓶颈 P 用 buffer 或 Module 级参数
如BoRALayer.register_buffer("P", ...)或放在BoRANet.P,确保所有层引用同一份,避免重复存、重复训。
修复 2:冻结基座,只训 a / B / P
for p in net.base.parameters(): p.requires_grad_(False) # a/B/P 保持 requires_grad修复 3:键名统一,便于 save/load
# 每层 a/B 键名: boralayer.{i}.a.weight / boralayer.{i}.B.weight # 共享 P: boralayer.shared_P.weight六、解决方案(第二层):结构性改进
改进 1:按 PEFT 规范写 BoRAConfig + BoRAModel
from peft import PeftConfig, PeftType class BoRAConfig(PeftConfig): def __init__(self, r=8, s=2, alpha=16, **kwargs): super().__init__(peft_type=PeftType.BORA, **kwargs) self.r, self.s, self.alpha = r, s, alpha from peft.tuners import BaseTuner class BoRAModel(BaseTuner): def __init__(self, model, config, adapter_name="default"): super().__init__(model, config, adapter_name) # 注入 BoRALayer、持有共享 P、重写 forward def forward(self, *a, **k): return self.model(*a, **k) # 内部各层调用 BoRALayer改进 2:mapping 注册
from .bora import BoRAModel PEFT_TYPE_TO_MODEL_MAPPING[PeftType.BORA] = BoRAModel改进 3:与 LoRA 组合
model = get_peft_model(base, LoraConfig(r=8, target_modules=["q_proj"])) model.add_adapter("bo1", BoRAConfig(r=8, s=2, target_modules=["v_proj"])) model.set_adapter(["default", "bo1"])七、解决方案(第三层):断言 / CI 守护
import torch import torch.nn as nn import pytest class BoRALayer(nn.Module): def __init__(self, in_f, out_f, r, s): super().__init__() self.a = nn.Parameter(torch.randn(s, in_f) * 0.01) self.B = nn.Parameter(torch.zeros(out_f, r)) self.register_buffer("P", torch.randn(r, s) * 0.01) def forward(self, x, P): return (self.B @ ((P @ self.a) @ x.T)).T def test_bora_delta_shape(): torch.manual_seed(0) layer = BoRALayer(16, 16, 8, 2) P = torch.randn(8, 2) * 0.01 d = layer(torch.randn(2, 16), P) assert d.shape == (2, 16) def test_shared_bottleneck_used(): layer = BoRALayer(16, 16, 8, 2) assert hasattr(layer, "P") and layer.P.shape == (8, 2) def test_only_a_B_trainable(): torch.manual_seed(1) base = nn.Linear(16, 16) layer = BoRALayer(16, 16, 8, 2) for p in base.parameters(): p.requires_grad_(False) tr = sum(p.numel() for p in [base, layer] if p.requires_grad) assert tr == (2*16 + 16*8) # 仅 a + B def test_bora_adds_to_base(): torch.manual_seed(2) base = nn.Linear(16, 16) layer = BoRALayer(16, 16, 8, 2) x = torch.randn(2, 16) out = base(x) + layer(x, torch.randn(8, 2)*0.01) base_only = base(x) assert not torch.allclose(out, base_only)这四个测试守护“BoRA 增量形状、共享瓶颈存在、仅 a/B 可训练、增量叠加到基座”。
八、排查清单
把 BoRA 接进 PEFT 时按序查:
- 三件套齐全:
BoRAConfig+BoRAModel+mapping注册。 - 共享瓶颈 P 唯一:用 buffer 或模型级参数,所有层引用同一份。
- 基座冻结:只训
a/B/P。 - 键名统一:
boralayer.{i}.a/B+shared_P,便于 save/load。 - 复用生态:接好即可
print_trainable_parameters/save_pretrained/load_adapter。 - 与 LoRA 组合:用
add_adapter+set_adapter。 - 参数计数核对:
total = r*s + n_layers*(s*in + out*r)。 - 测试守护:增量形状、共享瓶颈、可训练参数、叠加基座。
九、小结
Add BoRA to PEFT的本质不是代码崩溃,而是集成规范缺失:用户各自改写forward、瓶颈参数键名不统一、无法与 LoRA 组合、不能复用 PEFT 生态。
最小修复是实现自包含 BoRA(每层a/B+ 共享瓶颈P、冻结基座、键名统一);结构性改进是按 PEFT 规范写BoRAConfig+BoRAModel+mapping注册,让 BoRA 成为一等公民并能与 LoRA 组合;最后用测试守护“增量形状正确、共享瓶颈存在、仅 a/B 可训练、增量叠加基座”。这样 BoRA 就能像其它 PEFT 方法一样被get_peft_model统一管理。