""" 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)