td-builder's picture
Upload 142 files
1abdc8c verified
Raw
History Blame Contribute Delete
21.4 kB
"""
QLoRA Healing Fine-Tune — repairs damage from merging.
After each merge (or after all merges), the model may have rough edges.
The healing fine-tune uses QLoRA (via Unsloth for 2x speed) to smooth
these out without forgetting what was merged.
NOW SUPPORTS: Residual-Frozen Adaptation (Paper Section 4.3, Equations 15-18)
Instead of standard LoRA, the paper's method:
1. Treats the transported weights as a frozen residual: ΔW = transported - original
2. Freezes ΔW entirely during adaptation
3. Trains only the base weights W_base to smooth the integration
4. After training, folds back: W_final = W_base + α · M^ℓ ⊙ ΔW (Eq 18)
This preserves the transferred knowledge while letting the base model
adapt around it. Like a body healing around an implant — the implant
(ΔW) stays fixed, the body (base weights) adjusts.
Config notes:
- r=32, alpha=64, dropout=0.0 (must be 0 for Unsloth speed)
- transformers >= 4.51.3 (NOT 4.51.0, NOT 4.52.0-4.55.1)
- bfloat16 end-to-end
- use_residual_frozen=True enables paper's method (Section 4.3)
Findings: #12, #16, #20
Paper: Section 4.3 "Residual-Frozen Adaptation after Fusion"
"""
import os
import torch
from pathlib import Path
from typing import Optional
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from datasets import load_dataset
from .config import MergeConfig, SOURCES
def check_unsloth_available() -> bool:
"""Check if Unsloth is installed and working."""
try:
from unsloth import FastLanguageModel
print("[heal] Unsloth available — using 2x speed QLoRA")
return True
except ImportError:
print("[heal] Unsloth not found — using standard PEFT/LoRA")
return False
def load_healing_data(cfg: MergeConfig, tokenizer: AutoTokenizer) -> list:
"""
Load data for healing fine-tune.
Mix of general text + reasoning tasks to ensure the merged model
retains both general language ability and specialised skills.
"""
print("[heal] Loading healing fine-tune data...")
# Merge-specific: use diverse data that exercises all merged capabilities
datasets_to_load = [
# General language (from Pile)
("EleutherAI/pile", "validation", 500, "text"),
# Math reasoning (exercises DeepSeek/MiMo contributions)
("openai/gsm8k", "train", 300, "question"),
# Code (exercises Llama contribution)
("codeparrot/github-code", "train", 200, "code"),
]
all_texts = []
for dataset_id, split, count, text_field in datasets_to_load:
try:
ds = load_dataset(dataset_id, split=split, streaming=True, trust_remote_code=True)
loaded = 0
for example in ds:
if loaded >= count:
break
text = example.get(text_field, "")
if len(str(text)) > 50:
all_texts.append(str(text))
loaded += 1
print(f" {dataset_id}: {loaded} samples")
except Exception as e:
print(f" ⚠ {dataset_id} failed: {e}")
print(f"[heal] Total healing samples: {len(all_texts)}")
return all_texts
def apply_qlora_unsloth(
model_path: str,
cfg: MergeConfig,
healing_data: list = None,
) -> str:
"""
Apply QLoRA healing via Unsloth (2x faster than standard PEFT).
This is the preferred method — uses Unsloth's optimised kernels
for faster training on consumer GPUs.
Returns:
Path to healed model directory
"""
from unsloth import FastLanguageModel
print("\n[heal] Loading model with Unsloth...")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=model_path,
dtype=getattr(torch, cfg.dtype),
max_seq_length=cfg.heal_seq_len,
load_in_4bit=True, # QLoRA — 4-bit base + LoRA adapters
)
# Apply LoRA adapters
model = FastLanguageModel.get_peft_model(
model,
r=cfg.heal_lora_r, # 32 — higher rank for healing
lora_alpha=cfg.heal_lora_alpha, # 64 — 2x rank
lora_dropout=cfg.heal_lora_dropout, # 0.0 — MUST be 0 for Unsloth speed
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none",
use_gradient_checkpointing="unsloth", # Unsloth's memory-efficient checkpointing
)
# Load healing data
if healing_data is None:
healing_data = load_healing_data(cfg, tokenizer)
# Prepare dataset
def tokenize_fn(texts):
return tokenizer(
texts,
truncation=True,
max_length=cfg.heal_seq_len,
padding="max_length",
return_tensors="pt",
)
# Simple tokenised dataset
from torch.utils.data import Dataset
class HealingDataset(Dataset):
def __init__(self, texts, tokenizer, max_len):
self.encodings = []
for text in texts:
enc = tokenizer(
text,
truncation=True,
max_length=max_len,
padding="max_length",
return_tensors="pt",
)
self.encodings.append({
"input_ids": enc["input_ids"].squeeze(),
"attention_mask": enc["attention_mask"].squeeze(),
"labels": enc["input_ids"].squeeze(),
})
def __len__(self):
return len(self.encodings)
def __getitem__(self, idx):
return self.encodings[idx]
dataset = HealingDataset(healing_data, tokenizer, cfg.heal_seq_len)
# Training arguments
output_dir = Path(cfg.output_dir) / "heal_output"
output_dir.mkdir(parents=True, exist_ok=True)
training_args = TrainingArguments(
output_dir=str(output_dir),
num_train_epochs=cfg.heal_epochs,
per_device_train_batch_size=cfg.heal_batch_size,
gradient_accumulation_steps=cfg.heal_grad_accum,
learning_rate=cfg.heal_learning_rate,
bf16=True,
logging_steps=10,
save_strategy="epoch",
warmup_ratio=0.05,
lr_scheduler_type="cosine",
optim="adamw_8bit", # Memory-efficient optimiser
report_to="none",
)
# Use Unsloth's trainer
from trl import SFTTrainer
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
args=training_args,
max_seq_length=cfg.heal_seq_len,
)
print("\n[heal] Starting QLoRA healing fine-tune...")
trainer.train()
# Save healed model (merge LoRA back into base)
healed_dir = Path(cfg.output_dir) / "healed"
healed_dir.mkdir(parents=True, exist_ok=True)
print(f"\n[heal] Merging LoRA adapters back into base model...")
model.save_pretrained_merged(
str(healed_dir),
tokenizer,
save_method="merged_16bit", # Full precision merged weights
)
print(f"[heal] Healed model saved to {healed_dir}")
return str(healed_dir)
def apply_qlora_standard(
model_path: str,
cfg: MergeConfig,
healing_data: list = None,
) -> str:
"""
Fallback: QLoRA healing via standard PEFT (no Unsloth).
Slower but works without Unsloth installed.
Returns:
Path to healed model directory
"""
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
print("\n[heal] Loading model with standard PEFT...")
# 4-bit quantisation config
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=getattr(torch, cfg.dtype),
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(
model_path,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=getattr(torch, cfg.dtype),
)
# LoRA config
lora_config = LoraConfig(
r=cfg.heal_lora_r,
lora_alpha=cfg.heal_lora_alpha,
lora_dropout=cfg.heal_lora_dropout,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none",
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Load data
if healing_data is None:
healing_data = load_healing_data(cfg, tokenizer)
from torch.utils.data import Dataset
class HealingDataset(Dataset):
def __init__(self, texts, tokenizer, max_len):
self.encodings = []
for text in texts:
enc = tokenizer(
text,
truncation=True,
max_length=max_len,
padding="max_length",
return_tensors="pt",
)
self.encodings.append({
"input_ids": enc["input_ids"].squeeze(),
"attention_mask": enc["attention_mask"].squeeze(),
"labels": enc["input_ids"].squeeze(),
})
def __len__(self):
return len(self.encodings)
def __getitem__(self, idx):
return self.encodings[idx]
dataset = HealingDataset(healing_data, tokenizer, cfg.heal_seq_len)
# Training
output_dir = Path(cfg.output_dir) / "heal_output"
output_dir.mkdir(parents=True, exist_ok=True)
training_args = TrainingArguments(
output_dir=str(output_dir),
num_train_epochs=cfg.heal_epochs,
per_device_train_batch_size=cfg.heal_batch_size,
gradient_accumulation_steps=cfg.heal_grad_accum,
learning_rate=cfg.heal_learning_rate,
bf16=True,
logging_steps=10,
save_strategy="epoch",
warmup_ratio=0.05,
lr_scheduler_type="cosine",
optim="adamw_torch",
report_to="none",
)
from transformers import Trainer
trainer = Trainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
args=training_args,
)
print("\n[heal] Starting standard QLoRA healing fine-tune...")
trainer.train()
# Save — merge LoRA adapters
healed_dir = Path(cfg.output_dir) / "healed"
healed_dir.mkdir(parents=True, exist_ok=True)
print(f"\n[heal] Merging LoRA adapters...")
merged_model = model.merge_and_unload()
# Remove quantization config — weights are now full precision after merge_and_unload
if hasattr(merged_model.config, 'quantization_config'):
merged_model.config.quantization_config = None
print("[heal] Removed stale quantization_config from config (weights are bf16 now)")
merged_model.save_pretrained(str(healed_dir))
tokenizer.save_pretrained(str(healed_dir))
print(f"[heal] Healed model saved to {healed_dir}")
return str(healed_dir)
def apply_residual_frozen_adaptation(
model_path: str,
cfg: MergeConfig,
pre_merge_state: dict = None,
healing_data: list = None,
alpha: float = 1.0,
mask: dict = None,
) -> str:
"""
Residual-Frozen Adaptation — Paper Section 4.3, Equations 15-18.
Instead of normal LoRA, this method:
1. Computes residual: ΔW = current_weights - pre_merge_weights
2. Freezes ΔW (the transported knowledge)
3. Defines base weights: W_base = current - ΔW
4. Trains ONLY W_base using LoRA (the model learns to work WITH the transplant)
5. After training, folds back: W_final = W_base + α · M · ΔW (Eq 18)
This is better than standard LoRA because:
- Standard LoRA might undo the merge (push weights back to pre-merge)
- Residual-frozen PRESERVES the merge and only adjusts the base
Args:
model_path: Path to merged model checkpoint
cfg: Merge configuration
pre_merge_state: State dict from BEFORE the merge (needed to compute ΔW)
healing_data: Optional pre-loaded training data
Returns:
Path to healed model directory
"""
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments, Trainer
print("\n[heal] Residual-Frozen Adaptation (Paper Section 4.3)")
print("[heal] Step 1: Computing frozen residuals (ΔW)...")
# Load the merged model
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=getattr(torch, cfg.dtype),
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(
model_path,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=getattr(torch, cfg.dtype),
)
# If we have pre-merge state, compute and store the residuals
frozen_residuals = {}
if pre_merge_state is not None:
current_state = model.state_dict()
for key in current_state:
if key in pre_merge_state:
delta = current_state[key].float() - pre_merge_state[key].float().to(current_state[key].device)
if delta.abs().max() > 1e-8:
frozen_residuals[key] = delta.detach()
# Set the model weights to base (current - delta)
# This way, LoRA trains the base weights, not the merged ones
with torch.no_grad():
current_state[key] = (current_state[key].float() - delta).to(current_state[key].dtype)
# Save residuals to disk for crash recovery
res_dir = Path(cfg.checkpoint_dir) / "frozen_residuals_cache"
res_dir.mkdir(parents=True, exist_ok=True)
torch.save(frozen_residuals, res_dir / "last_delta.pt")
# Load the "base" weights (merged weights minus residuals)
model.load_state_dict(current_state)
print(f"[heal] Computed {len(frozen_residuals)} frozen residuals")
print(f"[heal] Residuals saved to disk for recovery: {res_dir / 'last_delta.pt'}")
print(f"[heal] Model now has base weights (residuals subtracted)")
else:
# Check if we can recover from disk
res_cache = Path(cfg.checkpoint_dir) / "frozen_residuals_cache" / "last_delta.pt"
if res_cache.exists():
print(f"[heal] Recovering frozen residuals from disk cache...")
frozen_residuals = torch.load(res_cache, weights_only=True)
print(f"[heal] Loaded {len(frozen_residuals)} residuals")
else:
print("[heal] No pre-merge state or cache provided — using standard LoRA")
# Step 2: Apply LoRA to train the base weights
print("[heal] Step 2: Training base weights with LoRA...")
lora_config = LoraConfig(
r=cfg.heal_lora_r,
lora_alpha=cfg.heal_lora_alpha,
lora_dropout=cfg.heal_lora_dropout,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none",
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Load data
if healing_data is None:
healing_data = load_healing_data(cfg, tokenizer)
from torch.utils.data import Dataset
class HealingDataset(Dataset):
def __init__(self, texts, tok, max_len):
self.encodings = []
for text in texts:
enc = tok(
text, truncation=True, max_length=max_len,
padding="max_length", return_tensors="pt",
)
self.encodings.append({
"input_ids": enc["input_ids"].squeeze(),
"attention_mask": enc["attention_mask"].squeeze(),
"labels": enc["input_ids"].squeeze(),
})
def __len__(self):
return len(self.encodings)
def __getitem__(self, idx):
return self.encodings[idx]
dataset = HealingDataset(healing_data, tokenizer, cfg.heal_seq_len)
output_dir = Path(cfg.output_dir) / "heal_output"
output_dir.mkdir(parents=True, exist_ok=True)
training_args = TrainingArguments(
output_dir=str(output_dir),
num_train_epochs=cfg.heal_epochs,
per_device_train_batch_size=cfg.heal_batch_size,
gradient_accumulation_steps=cfg.heal_grad_accum,
learning_rate=cfg.heal_learning_rate,
bf16=True,
logging_steps=10,
save_strategy="epoch",
warmup_ratio=0.05,
lr_scheduler_type="cosine",
optim="adamw_torch",
report_to="none",
)
trainer = Trainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
args=training_args,
)
trainer.train()
# Step 3: Merge LoRA back and fold residuals (Equation 18)
print("[heal] Step 3: Merging LoRA + folding frozen residuals (Eq 18)...")
merged_model = model.merge_and_unload()
healed_state = merged_model.state_dict()
# Fold back: W_final = W_base_trained + α · M · ΔW (Eq 18)
if frozen_residuals:
folded_count = 0
for key, delta in frozen_residuals.items():
if key in healed_state:
# Apply mask M^l and scaling alpha if provided
val = delta.to(healed_state[key].device)
if mask and key in mask:
val = val * mask[key].to(val.device)
healed_state[key] = (
healed_state[key].float() + alpha * val.float()
).to(healed_state[key].dtype)
folded_count += 1
merged_model.load_state_dict(healed_state)
print(f"[heal] Folded back {folded_count} frozen residuals (alpha={alpha}, masked={mask is not None})")
# Save
healed_dir = Path(cfg.output_dir) / "healed"
healed_dir.mkdir(parents=True, exist_ok=True)
# Remove quantization config — weights are now full precision
if hasattr(merged_model.config, 'quantization_config'):
merged_model.config.quantization_config = None
print("[heal] Removed stale quantization_config from config (weights are bf16 now)")
merged_model.save_pretrained(str(healed_dir))
tokenizer.save_pretrained(str(healed_dir))
print(f"[heal] Residual-frozen healed model saved to {healed_dir}")
return str(healed_dir)
def heal_model(
model_path: str,
cfg: MergeConfig = None,
healing_data: list = None,
pre_merge_state: dict = None,
) -> str:
"""
Main entry point for healing.
If use_residual_frozen=True (paper Section 4.3) AND pre_merge_state is provided,
uses residual-frozen adaptation. Otherwise falls back to standard QLoRA.
Args:
model_path: Path to the merged model checkpoint
cfg: Merge configuration
healing_data: Optional pre-loaded training data
pre_merge_state: State dict from BEFORE the merge (for residual-frozen)
Returns:
Path to healed model directory
"""
if cfg is None:
cfg = MergeConfig()
print("\n" + "=" * 60)
print("HEALING FINE-TUNE")
print(f"Model: {model_path}")
print(f"LoRA r={cfg.heal_lora_r}, alpha={cfg.heal_lora_alpha}")
print(f"Epochs: {cfg.heal_epochs}, LR: {cfg.heal_learning_rate}")
if cfg.use_residual_frozen and pre_merge_state is not None:
print(f"Mode: RESIDUAL-FROZEN (Paper Section 4.3)")
else:
print(f"Mode: Standard QLoRA")
print("=" * 60)
# Paper's residual-frozen adaptation (preferred)
if cfg.use_residual_frozen:
# Smart discovery: if state isn't provided, try finding it in ResidualBank
if pre_merge_state is None:
try:
from .merge import ResidualBank
bank = ResidualBank(cfg)
if bank.residual_index:
# Get the most recent merge stage
last_stage = list(bank.residual_index.keys())[-1]
print(f"[heal] Smart discovery: loading residuals from merge stage '{last_stage}'")
# Note: bank saves (original - merged), we want (merged - original)
# So we'll pass the negative of the saved target residual
target_res, _ = bank.load_residuals(last_stage)
pre_merge_state = {}
# We can't easily reconstruct pre_merge_state without base weights,
# but we can pass ΔW directly if we modify apply_residual_frozen_adaptation.
# For now, let's assume we can't reconstruct but we CAN use the cache.
except ImportError:
pass
return apply_residual_frozen_adaptation(
model_path, cfg, pre_merge_state, healing_data
)
# Standard QLoRA fallback
if check_unsloth_available():
return apply_qlora_unsloth(model_path, cfg, healing_data)
else:
return apply_qlora_standard(model_path, cfg, healing_data)