MiniSEAL / src /lora /lora.py
aaronr43210's picture
Upload src
27d37fd verified
Raw History Blame Contribute Delete
11.2 kB
"""
Simple LoRA implementation for the custom Transformer.
LoRA paper:
Hu et al., 2022
Low-Rank Adaptation of Large Language Models
Core idea:
Freeze original linear layer.
Add trainable low-rank update:
W' = W + BA
This file supports:
- injecting LoRA into selected Linear modules
- saving only LoRA adapter weights
"""
import math
from typing import List
import torch
import torch.nn as nn
import torch.nn.functional as F
class LoRALinear(nn.Module):
"""
Wraps an existing nn.Linear layer with LoRA adapters.
Input:
base_layer: existing frozen Linear layer.
rank: LoRA rank.
alpha: scaling factor.
dropout: dropout applied before LoRA branch.
Output:
base_layer(x) + scaled_lora_update(x)
"""
def __init__(
self,
base_layer: nn.Linear,
rank: int = 16,
alpha: int = 32,
dropout: float = 0.0,
):
super().__init__()
if rank <= 0:
raise ValueError("LoRA rank must be positive.")
self.base_layer = base_layer
self.rank = rank
self.alpha = alpha
self.scaling = alpha / rank
self.dropout = nn.Dropout(dropout)
in_features = base_layer.in_features
out_features = base_layer.out_features
# Freeze base model weights.
for p in self.base_layer.parameters():
p.requires_grad = False
self.lora_A = nn.Parameter(torch.empty(rank, in_features))
self.lora_B = nn.Parameter(torch.empty(out_features, rank))
self.reset_parameters()
def reset_parameters(self):
"""
Initialize A with Kaiming, B with zeros.
This makes initial LoRA update equal to zero.
"""
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:
base_out = self.base_layer(x)
lora_hidden = F.linear(self.dropout(x), self.lora_A)
lora_out = F.linear(lora_hidden, self.lora_B)
return base_out + self.scaling * lora_out
def freeze_model(model: nn.Module) -> None:
"""
Freezes all model parameters.
"""
for p in model.parameters():
p.requires_grad = False
def inject_lora(
model: nn.Module,
target_keywords: List[str],
rank: int,
alpha: int,
dropout: float,
) -> nn.Module:
"""
Replaces matching Linear layers with LoRALinear.
Input:
model: custom Transformer.
target_keywords:
Example:
["q_proj", "k_proj", "v_proj", "out_proj",
"gate_proj", "up_proj", "down_proj"]
rank: LoRA rank.
alpha: LoRA alpha.
dropout: LoRA dropout.
Output:
model with LoRA modules injected.
"""
freeze_model(model)
def replace(module: nn.Module, prefix: str = ""):
for child_name, child in list(module.named_children()):
full_name = f"{prefix}.{child_name}" if prefix else child_name
if isinstance(child, nn.Linear):
if any(keyword in full_name for keyword in target_keywords):
setattr(
module,
child_name,
LoRALinear(
base_layer=child,
rank=rank,
alpha=alpha,
dropout=dropout,
),
)
else:
replace(child, full_name)
replace(model)
return model
def get_lora_state_dict(model: nn.Module) -> dict:
"""
Output:
State dict containing only LoRA parameters.
"""
return {
name: param.detach().cpu()
for name, param in model.named_parameters()
if "lora_A" in name or "lora_B" in name
}
def load_lora_state_dict(model: nn.Module, lora_state_dict: dict, strict: bool = True) -> None:
"""
Load LoRA A/B parameters into a model that ALREADY has LoRA injected.
The model must have been passed through ``inject_lora`` with the SAME
rank / alpha / target_keywords used when the adapter was trained, otherwise
the parameter names (and shapes) will not line up. Use ``load_lora_adapter``
to do that re-injection automatically from a saved adapter file.
Args:
model: a model with LoRALinear modules already in place.
lora_state_dict: mapping of ``*.lora_A`` / ``*.lora_B`` -> tensor.
strict: if True, require that the adapter keys exactly match the
model's LoRA parameter names (no missing / unexpected keys).
Raises:
KeyError: on a name mismatch when ``strict`` is True.
RuntimeError: on a shape mismatch (always).
"""
model_lora = {
name: param
for name, param in model.named_parameters()
if "lora_A" in name or "lora_B" in name
}
if not model_lora:
raise RuntimeError(
"No LoRA parameters found on the model. Call inject_lora(...) "
"(or load_lora_adapter) before load_lora_state_dict."
)
adapter_keys = set(lora_state_dict.keys())
model_keys = set(model_lora.keys())
if strict:
missing = model_keys - adapter_keys
unexpected = adapter_keys - model_keys
if missing or unexpected:
raise KeyError(
"LoRA adapter does not match injected model.\n"
f" missing from adapter: {sorted(missing)}\n"
f" unexpected in adapter: {sorted(unexpected)}\n"
"This usually means rank/alpha/target_keywords differ from "
"when the adapter was trained."
)
with torch.no_grad():
for name, param in model_lora.items():
if name not in lora_state_dict:
continue # non-strict: leave untouched (stays at zero-init)
incoming = lora_state_dict[name]
if incoming.shape != param.shape:
raise RuntimeError(
f"Shape mismatch for '{name}': adapter {tuple(incoming.shape)} "
f"vs model {tuple(param.shape)}."
)
param.copy_(incoming.to(param.device, dtype=param.dtype))
# Canonical on-disk adapter format. Both the automated SEAL gate
# (adaptation_service) and the HITL training path write this exact shape so a
# single loader can serve either. Older raw-dict adapters are still accepted by
# load_lora_adapter (see the backward-compat branch there).
ADAPTER_FORMAT_VERSION = 1
def save_lora_adapter(
model: nn.Module,
path: str,
*,
rank: int,
alpha: int,
target_keywords: List[str],
dropout: float = 0.0,
meta: dict | None = None,
) -> str:
"""
Save ONLY the LoRA deltas plus the config needed to re-inject them.
Storing rank/alpha/target_keywords alongside the weights is what makes the
adapter self-describing: the loader can reconstruct the exact LoRALinear
placement without the caller having to remember the hyperparameters.
Args:
model: a model with LoRA injected and (presumably) trained.
path: destination ``.pt`` file.
rank, alpha, target_keywords, dropout: the inject_lora config used.
meta: optional provenance (rewards, source, timestamp, base model, ...).
Returns:
The path written.
"""
import os as _os
payload = {
"format_version": ADAPTER_FORMAT_VERSION,
"lora_state_dict": get_lora_state_dict(model),
"lora_config": {
"rank": int(rank),
"alpha": int(alpha),
"target_keywords": list(target_keywords),
"dropout": float(dropout),
},
"meta": dict(meta) if meta else {},
}
parent = _os.path.dirname(_os.path.abspath(path))
_os.makedirs(parent, exist_ok=True)
torch.save(payload, path)
return path
def load_lora_adapter(model: nn.Module, path: str, device: str = "cpu") -> nn.Module:
"""
Re-inject LoRA using the adapter's stored config, then load its weights.
This closes the SEAL loop: an accepted/trained adapter saved with
``save_lora_adapter`` can be applied to a fresh base model for serving.
Backward compatible with legacy files that stored EITHER a bare
``{name: tensor}`` state dict OR ``{"lora_state_dict": ..., "args": ...}``
without a ``lora_config`` block; in that case we fall back to the historical
defaults (rank=16, alpha=32, targets=[q_proj, v_proj], dropout=0.0) that
every writer in this repo used.
Args:
model: a FRESH base model (no LoRA yet).
path: adapter file written by ``save_lora_adapter`` (or legacy).
device: device to map tensors onto.
Returns:
The same model, now with LoRA injected and the adapter loaded.
"""
ckpt = torch.load(path, map_location=device, weights_only=False)
if isinstance(ckpt, dict) and "lora_state_dict" in ckpt:
lora_sd = ckpt["lora_state_dict"]
cfg = ckpt.get("lora_config", {})
else:
# Legacy: the whole object IS the lora state dict.
lora_sd = ckpt
cfg = {}
rank = int(cfg.get("rank", 16))
alpha = int(cfg.get("alpha", 32))
target_keywords = list(cfg.get("target_keywords", ["q_proj", "v_proj"]))
dropout = float(cfg.get("dropout", 0.0))
inject_lora(
model,
target_keywords=target_keywords,
rank=rank,
alpha=alpha,
dropout=dropout,
)
model = model.to(device)
load_lora_state_dict(model, lora_sd, strict=True)
return model
def merge_lora_weights(model: nn.Module) -> nn.Module:
"""
Fold each LoRA delta into its frozen base weight and replace the LoRALinear
with a plain nn.Linear, giving a zero-overhead model for deployment.
For a LoRALinear with base weight W (out, in) and update (alpha/rank)*B@A:
W_merged = W + (alpha/rank) * (B @ A)
After merging the result is numerically equivalent to the LoRALinear's
forward (minus dropout, which is identity at eval time) but with no extra
matmul. In-place on ``model``; returns it for convenience.
"""
def _merge(module: nn.Module):
for child_name, child in list(module.named_children()):
if isinstance(child, LoRALinear):
base = child.base_layer
delta = child.scaling * (child.lora_B @ child.lora_A) # (out, in)
with torch.no_grad():
base.weight.add_(delta.to(base.weight.dtype))
for p in base.parameters():
p.requires_grad_(True)
setattr(module, child_name, base)
else:
_merge(child)
_merge(model)
return model
def get_trainable_parameters(model: nn.Module) -> tuple[int, int]:
"""
Output:
trainable parameters, total parameters.
"""
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
return trainable, total