| """ |
| 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...") |
|
|
| |
| datasets_to_load = [ |
| |
| ("EleutherAI/pile", "validation", 500, "text"), |
| |
| ("openai/gsm8k", "train", 300, "question"), |
| |
| ("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, |
| ) |
|
|
| |
| model = FastLanguageModel.get_peft_model( |
| model, |
| 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", |
| use_gradient_checkpointing="unsloth", |
| ) |
|
|
| |
| if healing_data is None: |
| healing_data = load_healing_data(cfg, tokenizer) |
|
|
| |
| def tokenize_fn(texts): |
| return tokenizer( |
| texts, |
| truncation=True, |
| max_length=cfg.heal_seq_len, |
| padding="max_length", |
| return_tensors="pt", |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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", |
| report_to="none", |
| ) |
|
|
| |
| 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() |
|
|
| |
| 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", |
| ) |
|
|
| 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...") |
|
|
| |
| 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 = 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() |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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() |
| |
| 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)...") |
|
|
| |
| 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), |
| ) |
|
|
| |
| 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() |
| |
| |
| with torch.no_grad(): |
| current_state[key] = (current_state[key].float() - delta).to(current_state[key].dtype) |
|
|
| |
| 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") |
| |
| |
| 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: |
| |
| 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") |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| print("[heal] Step 3: Merging LoRA + folding frozen residuals (Eq 18)...") |
|
|
| merged_model = model.merge_and_unload() |
| healed_state = merged_model.state_dict() |
|
|
| |
| if frozen_residuals: |
| folded_count = 0 |
| for key, delta in frozen_residuals.items(): |
| if key in healed_state: |
| |
| 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})") |
|
|
| |
| healed_dir = Path(cfg.output_dir) / "healed" |
| healed_dir.mkdir(parents=True, exist_ok=True) |
| |
| 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) |
|
|
| |
| if cfg.use_residual_frozen: |
| |
| if pre_merge_state is None: |
| try: |
| from .merge import ResidualBank |
| bank = ResidualBank(cfg) |
| if bank.residual_index: |
| |
| last_stage = list(bank.residual_index.keys())[-1] |
| print(f"[heal] Smart discovery: loading residuals from merge stage '{last_stage}'") |
| |
| |
| target_res, _ = bank.load_residuals(last_stage) |
| pre_merge_state = {} |
| |
| |
| |
| except ImportError: |
| pass |
|
|
| return apply_residual_frozen_adaptation( |
| model_path, cfg, pre_merge_state, healing_data |
| ) |
|
|
| |
| if check_unsloth_available(): |
| return apply_qlora_unsloth(model_path, cfg, healing_data) |
| else: |
| return apply_qlora_standard(model_path, cfg, healing_data) |
|
|