""" Focal-Head LoRA Finetune ========================================== • Selectively fine-tunes "important attention heads" (via LoRA) to enhance LLM alignment with system instructions. • Key components: 1) detect_heads : compares normal vs. conflict attention → selects top-k heads 2) Q-LoRA (4-bit): injects LoRA only into q/k projection layers with 4-bit quantization 3) make_sys_mask : builds token-level masks for system segments across chat templates 4) focus_loss : encourages final-token attention to return to system region (FP32 for numerical stability) """ import os, json, argparse, math, glob, random, re, pickle from collections import defaultdict from typing import List, Tuple, Dict import random import torch, numpy as np from torch.utils.data import Dataset, DataLoader from tqdm import tqdm from transformers import ( AutoConfig, AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, get_linear_schedule_with_warmup, ) from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, PeftModel import evallib as evallib # ---------------- Set random seed ---------------- SEED = 42 random.seed(SEED) np.random.seed(SEED) torch.manual_seed(SEED) # Default evaluation dataset (fixed 8-task dev set) EVAL_DATA_PATH = os.path.join("../data/focal_lora_dataset_dev/dev_eval.json") # ====================================================== # 1️⃣ Locate LoRA target layers (q_proj/k_proj) # ====================================================== def get_lora_targets(model, layers: List[int]) -> List[str]: mtype = (getattr(model.config, "model_type", "") or "").lower() archs = [x.lower() for x in getattr(model.config, "architectures", [])] if mtype.startswith("qwen2") or any("qwen2" in a for a in archs): return [f"model.layers.{i}.self_attn.{p}" for i in layers for p in ("q_proj", "k_proj")] if "phi" in mtype or any("phi" in a for a in archs): return [f"model.layers.{i}.self_attn.{p}" for i in layers for p in ("q_proj", "k_proj", "qkv_proj")] if mtype in {"llama", "mistral"} or "llama" in mtype: return [f"model.layers.{i}.self_attn.{p}" for i in layers for p in ("q_proj", "k_proj")] # fallback for unknown models cand = [] for name, _ in model.named_modules(): if any(f".{i}." in name for i in layers) and name.split(".")[-1] in { "q_proj", "k_proj", "qkv_proj", "c_attn", "query_key_value"}: cand.append(name) return cand # ====================================================== # 2️⃣ Load model with 4-bit quantization # ====================================================== def load_model(model_path: str): cfg = AutoConfig.from_pretrained(model_path, trust_remote_code=True) try: tok = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True, use_fast=True) tok_inf = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True, use_fast=True) except Exception: tok = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True, use_fast=False) tok_inf = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True, use_fast=False) if tok.pad_token_id is None: tok.pad_token = tok.eos_token tok.pad_token_id = tok.eos_token_id tok.padding_side = "right" if tok_inf.pad_token_id is None: tok_inf.pad_token = tok.eos_token tok_inf.pad_token_id = tok.eos_token_id tok_inf.padding_side = "left" bnb_cfg = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", ) model = AutoModelForCausalLM.from_pretrained( model_path, config=cfg, quantization_config=bnb_cfg, device_map="auto", trust_remote_code=True, attn_implementation="eager", ) return model, tok, tok_inf # ====================================================== # 3️⃣ Construct system token mask # ====================================================== def make_sys_mask(input_ids: torch.Tensor, sub_ids: torch.Tensor, tokenizer) -> torch.Tensor: """ input_ids: Tensor (B, N) sub_ids: Tensor (B, M) padded with tokenizer.pad_token_id tokenizer: tokenizer object with pad_token_id and decode() Returns: mask: Bool tensor of shape (B, N) with True only for the FIRST match """ pad_id = tokenizer.pad_token_id device = input_ids.device B, N = input_ids.shape _, M = sub_ids.shape # Compute true (unpadded) lengths sub_lens = (sub_ids != pad_id).sum(dim=1) # (B,) mask = torch.zeros_like(input_ids, dtype=torch.bool) for b in range(B): L = sub_lens[b].item() if L == 0 or L > N: print(f"\n⚠️ Invalid sub length at batch {b}") print("input_ids:", tokenizer.decode(input_ids[b], skip_special_tokens=False)) print("sub_ids: ", tokenizer.decode(sub_ids[b], skip_special_tokens=False)) continue # Sliding windows windows = input_ids[b].unfold(dimension=0, size=L, step=1) # (N-L+1, L) # Target without padding target = sub_ids[b, :L] # (L,) full_match = (windows == target).all(dim=1) idx = torch.where(full_match)[0] if len(idx) > 0: # ✅ FIRST match only start = idx[0].item() mask[b, start:start + L] = True else: # ❌ NOT FOUND → DEBUG OUTPUT print(f"\n❌ Subsequence NOT found at batch index {b}") print("input_ids:", tokenizer.decode(input_ids[b], skip_special_tokens=False)) print("sub_ids: ", tokenizer.decode(sub_ids[b, :L], skip_special_tokens=False)) # Attention Sink B, L = input_ids.shape non_pad = (input_ids != pad_id) # [B, L], bool first_nonpad = non_pad.int().argmax(dim=1) # [B] positions = torch.arange(L, device=input_ids.device).unsqueeze(0) # [1, L] window_mask = (positions >= first_nonpad.unsqueeze(1)) & \ (positions < (first_nonpad + 4).unsqueeze(1)) & \ non_pad mask |= window_mask # or: mask = window_mask.clone() if you want only this return mask def make_orig_sys_mask(input_ids: torch.Tensor, tok) -> torch.Tensor: pad_id = tok.pad_token_id B, L = input_ids.shape mask = torch.zeros_like(input_ids, dtype=torch.bool) tid = tok.convert_tokens_to_ids start_header = tid("<|start_header_id|>") end_header = tid("<|end_header_id|>") eot = tok.eos_token_id sys_tok = tid("<|system|>") end_tok = tid("<|end|>") im_start = tid("<|im_start|>") im_end = tid("<|im_end|>") inst_start = tid("[INST]") inst_end = tid("[/INST]") for b in range(B): row = input_ids[b].tolist() # Format a: header template if start_header in row: try: s = row.index(end_header) + 1 e = row.index(eot) mask[b, s:e] = True continue except ValueError: pass # Format b: ChatML <|system|> if sys_tok in row: try: s = row.index(sys_tok) + 1 e = row.index(end_tok, s) mask[b, s:e] = True continue except ValueError: pass # Format c: OpenChat <|im_start|> system <|im_end|> if im_start in row and im_end in row: for pos in [i for i, t in enumerate(row) if t == im_start]: if pos + 1 < L and tok.decode([row[pos + 1]]).strip() == "system": s = pos + 2 e = row.index(im_end, s) mask[b, s:e] = True break if mask[b].any(): continue # Format d: [INST]...[/INST] if inst_start in row and inst_end in row: ist = row.index(inst_start) + 1 iend = row.index(inst_end) split = None for i in range(ist, iend - 1): if input_ids[b, i].item() == eot and input_ids[b, i + 1].item() == eot: split = i break if split is None: for i in range(ist, iend): if tok.decode([row[i]]).isspace(): split = i break if split and ist < split: mask[b, ist:split] = True else: mask[b, ist:iend] = True # Attention Sink B, L = input_ids.shape non_pad = (input_ids != pad_id) # [B, L], bool first_nonpad = non_pad.int().argmax(dim=1) # [B] positions = torch.arange(L, device=input_ids.device).unsqueeze(0) # [1, L] window_mask = (positions >= first_nonpad.unsqueeze(1)) & \ (positions < (first_nonpad + 4).unsqueeze(1)) & \ non_pad mask |= window_mask # or: mask = window_mask.clone() if you want only this return mask # ====================================================== # 4️⃣ Identify important attention heads # ====================================================== def trim_and_stack(rows): m = min(len(r) for r in rows) return np.stack([r[:m] for r in rows]) def trim_same(a, b): m = min(a.shape[1], b.shape[1]) return a[:, :m], b[:, :m] def score_heads(norm, conf): scores = {} for k in norm: if k not in conf: continue try: n = trim_and_stack(norm[k]) c = trim_and_stack(conf[k]) n, c = trim_same(n, c) except Exception: continue p, q = [np.exp(x - np.max(x, -1, keepdims=True)) for x in (n, c)] p /= p.sum(-1, keepdims=True) q /= q.sum(-1, keepdims=True) kl = (p * (np.log(p + 1e-6) - np.log(q + 1e-6))).sum() / p.shape[0] shift = np.mean(np.abs(n.mean(1) - c.mean(1))) frob = np.linalg.norm(n - c, ord="fro") scores[k] = 0.4 * frob + 0.3 * shift + 0.3 * kl return scores def extract_attn(model, tok, sys_msg, usr_msg): text = tok.apply_chat_template( [{"role": "system", "content": sys_msg}, {"role": "user", "content": usr_msg}], tokenize=False, add_generation_prompt=True) inp = tok(text, return_tensors="pt").to(model.device) with torch.no_grad(): out = model(**inp, output_attentions=True) return out.attentions def detect_heads(json_file, model, tok): data = json.load(open(json_file, encoding="utf-8")) grp = defaultdict(lambda: {"normal": None, "conflict": None}) for s in data: bid = s["id"].replace("_normal", "").replace("_conflict", "") grp[bid][s["label"]] = s nA, cA = defaultdict(list), defaultdict(list) for pair in tqdm(grp.values(), desc="Extract"): for lab in ("normal", "conflict"): if pair[lab] is None: continue s = pair[lab] usr = f"{s['task']} {s['user_message']}".strip() or s["task"] attn = extract_attn(model, tok, s["system_message"], usr) last = attn[0][0].shape[2] - 1 for l in range(len(attn)): for h in range(attn[l][0].shape[0]): row = attn[l][0][h, last, :].float().cpu().numpy() (nA if lab == "normal" else cA)[f"L{l}_H{h}"].append(row) scored = sorted(score_heads(nA, cA).items(), key=lambda x: x[1], reverse=True) return [(k, float(v)) for k, v in scored] def save_heads_config(heads, output_dir, model_path, json_path, topk): """Cache detected heads so we can resume training without recomputing.""" os.makedirs(output_dir, exist_ok=True) heads_path = os.path.join(output_dir, "heads.json") payload = { "model_path": model_path, "json_path": json_path, "topk": str(topk), "heads": heads, } with open(heads_path, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2) print(f"💾 Saved heads cache → {heads_path}") def select_top_heads(all_heads: List[Tuple[str, float]], topk_spec) -> List[Tuple[str, float]]: """Select top heads based on numeric count or percentage (e.g., '10p').""" if not all_heads: return [] if topk_spec is None: return all_heads if isinstance(topk_spec, str): spec = topk_spec.strip().lower() else: spec = str(topk_spec) if not spec: return all_heads if spec.endswith("p"): try: percent = float(spec[:-1]) except ValueError: raise ValueError(f"Invalid percentage for --topk: {topk_spec}") count = max(1, math.ceil(percent / 100.0 * len(all_heads))) else: try: count = int(float(spec)) except ValueError: raise ValueError(f"Invalid numeric value for --topk: {topk_spec}") count = max(1, count) return all_heads[:min(count, len(all_heads))] def load_heads_config(heads_file: str): if not os.path.exists(heads_file): raise FileNotFoundError(f"Heads file not found: {heads_file}") with open(heads_file, "r", encoding="utf-8") as f: payload = json.load(f) raw_heads = payload.get("heads") if raw_heads is None: raise ValueError(f"'heads' not defined in {heads_file}") heads = [(str(tag), float(score)) for tag, score in raw_heads] meta = { "model_path": payload.get("model_path"), "json_path": payload.get("json_path"), "topk": payload.get("topk"), } return heads, meta def _extract_suffix_index(name: str) -> int: m = re.search(r"(\d+)$", name) return int(m.group(1)) if m else -1 def discover_existing_adapter(out_dir: str): if not os.path.isdir(out_dir): return None, 0 candidates = [] root_config = os.path.join(out_dir, "adapter_config.json") if os.path.exists(root_config): candidates.append((0, out_dir)) for entry in os.listdir(out_dir): path = os.path.join(out_dir, entry) if not os.path.isdir(path): continue if os.path.exists(os.path.join(path, "adapter_config.json")): candidates.append((_extract_suffix_index(entry), path)) if not candidates: return None, 0 candidates.sort(key=lambda x: x[0]) resume_path = candidates[-1][1] next_idx = candidates[-1][0] + 1 if candidates[-1][0] >= 0 else 0 return resume_path, next_idx # ====================================================== # 5️⃣ Focus Loss: encourages attention to system region # ====================================================== def focus_loss(attns, sys_mask, heads): B = sys_mask.size(0) total_loss = torch.zeros([], dtype=torch.float32, device=sys_mask.device) valid_heads = 0 for tag, _ in heads: l = int(tag.split("_")[0][1:]) h = int(tag.split("_H")[1]) A = attns[l][:, h].float() last = A.size(1) - 1 head_loss = torch.zeros([], dtype=torch.float32, device=sys_mask.device) for b in range(B): m = sys_mask[b] if not m.any(): continue v = A[b, last] head_loss += v[m].sum() / v.sum().clamp_min(1e-6) / B total_loss += head_loss valid_heads += 1 return 1 - total_loss / max(valid_heads, 1) # ====================================================== # Dataset and Collate Function for Fine-tuning # ====================================================== class ConflictDS(Dataset): """Dataset for loading conflict samples from multiple JSON files.""" def __init__(self, json_files: List[str], tokenizer): self.samples = [] self.tokenizer = tokenizer for json_file in json_files: if not os.path.exists(json_file): continue with open(json_file, 'r', encoding='utf-8') as f: data = json.load(f) # Filter for conflict samples only conflicts = [s for s in data if s.get('label') == 'conflict'] self.samples.extend(conflicts) random.shuffle(self.samples) print(f"📊 Loaded {len(self.samples)} conflict samples from {len(json_files)} files") def __len__(self): return len(self.samples) def __getitem__(self, idx): return self.samples[idx] def collate(batch: List[Dict], tokenizer): """ Collate function to batch samples and tokenize them. Combines task + user_message as described in the paper. """ conversations = [] texts_sys = [] for sample in batch: # Combine task and user_message (if present) task = sample.get('task', '') user_msg = sample.get('user_message', '') # Combine as per line 209 logic: task + user_message user_content = f"{task} {user_msg}".strip() if user_msg else task # Build chat format messages = [ {"role": "system", "content": sample['system_message']}, {"role": "user", "content": user_content} ] conversations.append(messages) texts_sys.append(sample['system_message']) # Apply chat template and tokenize texts = [ tokenizer.apply_chat_template(conv, tokenize=False, add_generation_prompt=True) for conv in conversations ] # Tokenize with padding encoded = tokenizer( texts, padding=True, truncation=True, max_length=2048, return_tensors='pt' ) encoded_sys = tokenizer( texts_sys, padding=True, truncation=True, max_length=2048, add_special_tokens=False, return_tensors='pt' ) return { 'input_ids': encoded['input_ids'], 'system_ids': encoded_sys['input_ids'], 'attention_mask': encoded['attention_mask'] } # ====================================================== # 6️⃣ Training with LoRA on selected heads # ====================================================== def save_model( model, tok, out_dir, epoch, batch_idx, current_ratio, heads=None, eval_data_path: str = EVAL_DATA_PATH, ): """Save model/tokenizer and run lightweight eval with detailed logging.""" print("Running eval and saving model") save_dir = os.path.join(out_dir, f"batch_{epoch}_{batch_idx}") os.makedirs(save_dir, exist_ok=True) model.save_pretrained(save_dir) tok.save_pretrained(save_dir) # Run quick evaluations eval_asr = evallib.quick_eval_asr( model, tokenizer=tok, data_path=eval_data_path, heads=heads, ) eval_mmlu = evallib.quick_eval_mmlu( model, tokenizer=tok ) head_pairs = [] if heads: for tag, _score in heads: try: l = int(tag.split("_")[0][1:]) h = int(tag.split("_")[1][1:]) head_pairs.append((l, h)) except Exception: continue # quick_eval_asr handles attention capture internally now; just forward the payload detail_payload = {"eval_asr": eval_asr, "eval_mmlu": eval_mmlu} with open(os.path.join(save_dir, "detail_log.pkl"), "wb") as f: pickle.dump(detail_payload, f) # Append training log info_file = os.path.join(out_dir, "training_log.csv") if not os.path.exists(info_file): with open(info_file, "w") as info: info.write("epoch,batch_idx,current_ratio,normal_success,conflict_success,both_success,mmlu_acc\n") normal_success = eval_asr.get("normal_success") if isinstance(eval_asr, dict) else None conflict_success = eval_asr.get("conflict_success") if isinstance(eval_asr, dict) else None both_success = eval_asr.get("both_success") if isinstance(eval_asr, dict) else None mmlu_acc = eval_mmlu.get("accuracy") if isinstance(eval_mmlu, dict) else None with open(info_file, "a") as info: info.write( f"{epoch},{batch_idx},{current_ratio:.4f}," f"{normal_success if normal_success is not None else ''}," f"{conflict_success if conflict_success is not None else ''}," f"{both_success if both_success is not None else ''}," f"{mmlu_acc if mmlu_acc is not None else ''}\n" ) return save_dir, {"asr": eval_asr, "mmlu": eval_mmlu} def tune(model, tok,tok_inf, heads, data_dir, out_dir, epochs, bs, lr, lam_foc, resume_adapter=None, start_batch_idx=0): layers = sorted({int(t.split("_")[0][1:]) for t, _ in heads}) targets = get_lora_targets(model, layers) if not targets: raise ValueError("No q/k projection layers found!") lora_cfg = LoraConfig(r=8, lora_alpha=16, bias="none", target_modules=targets, task_type="CAUSAL_LM") model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=False) if resume_adapter: if not os.path.exists(resume_adapter): raise FileNotFoundError(f"LoRA adapter not found: {resume_adapter}") model = PeftModel.from_pretrained(model, resume_adapter, is_trainable=True) print(f"♻️ Loaded existing LoRA adapter → {resume_adapter}") else: model = get_peft_model(model, lora_cfg) os.makedirs(out_dir, exist_ok=True) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) total = sum(p.numel() for p in model.parameters()) ratio = trainable / total * 100 print(f"🔧 Trainable parameters: {trainable:,} / {total:,} ({ratio:.4f}% of total)") files = glob.glob(os.path.join(data_dir, "*.json")) dl = DataLoader(ConflictDS(files, tok), batch_size=bs, shuffle=True, collate_fn=lambda b: collate(b, tok)) opt = torch.optim.AdamW(model.parameters(), lr=lr,) total = epochs * math.ceil(len(dl)) sch = get_linear_schedule_with_warmup(opt, int(0.05 * total), total) model.train() current_idx = start_batch_idx current_ratio_reached = False for ep in range(epochs): if current_ratio_reached: break pbar = tqdm(enumerate(dl), desc=f"Epoch {ep+1}/{epochs}") for idx,batch in pbar: batch = {k: v.to(model.device) for k, v in batch.items()} #breakpoint() out = model(**batch, output_attentions=True) # sys_mask = make_sys_mask(batch["input_ids"], batch["system_ids"],tok) sys_mask = make_orig_sys_mask(batch["input_ids"], tok) loss = lam_foc * focus_loss(out.attentions, sys_mask, heads) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() sch.step() opt.zero_grad() pbar.set_postfix(loss=f"{loss.item():.4f}") current_ratio = float(loss.detach().cpu().item()) / lam_foc current_ratio = 1 - current_ratio if idx % 100 == 0: save_path, eval_summary = save_model( model, tok_inf, out_dir, current_idx, idx, current_ratio, heads=heads ) eval_metrics = eval_summary.get("asr", {}) if isinstance(eval_summary, dict) else {} conflict_success = eval_metrics.get("conflict_success") print(f"✅ LoRA adapter checkpoint saved → {save_path} (conflict_success={conflict_success if conflict_success is not None else 'n/a'})") save_path, eval_summary = save_model( model, tok_inf, out_dir, current_idx, idx, current_ratio, heads=heads ) eval_metrics = eval_summary.get("asr", {}) if isinstance(eval_summary, dict) else {} conflict_success = eval_metrics.get("conflict_success") print(f"✅ LoRA adapter saved → {save_path} (conflict_success={conflict_success if conflict_success is not None else 'n/a'})") current_idx += 1 def main(): ap = argparse.ArgumentParser("Important-Head LoRA Finetune") ap.add_argument("--json_path", required=False, help="Probing file with normal and conflict samples") ap.add_argument("--model_path", required=False, help="Base model path") ap.add_argument("--tune_path", required=True, help="Folder with conflict samples for fine-tuning") ap.add_argument("--output_dir", default="outputs_lora", help="Path to save LoRA adapter") ap.add_argument("--lora_path", default=None, help="Optional existing LoRA adapter to load before training") ap.add_argument("--topk", type=str, default="10", help="Top-K important heads to select (e.g., 10 or 10p for 10%)") ap.add_argument("--epochs", type=int, default=3) ap.add_argument("--batch_size", type=int, default=4) ap.add_argument("--lr", type=float, default=1e-4) ap.add_argument("--lambda_focus", type=float, default=0.5) ap.add_argument("--head_path", type=str, default="", help="Optional path to a precomputed heads.json file.") args = ap.parse_args() preferred_heads = args.head_path.strip() heads_file = preferred_heads or os.path.join(args.output_dir, "heads.json") heads_meta = {} all_heads = None if heads_file and os.path.exists(heads_file): all_heads, heads_meta = load_heads_config(heads_file) print(f"📂 Loaded cached heads from {heads_file}") else: if preferred_heads: ap.error(f"--head_path specified but not found: {heads_file}") if not args.json_path: ap.error("--json_path is required when no cached heads are found.") if not args.model_path: ap.error("--model_path is required when computing new heads.") model_path = args.model_path or heads_meta.get("model_path") if not model_path: ap.error("Base model path missing. Provide --model_path or ensure model_path exists in output_dir/heads.json") if args.lora_path: if not os.path.exists(args.lora_path): ap.error(f"--lora_path not found: {args.lora_path}") resume_adapter, start_idx = args.lora_path, 0 print(f"♻️ Loaded LoRA adapter from --lora_path: {resume_adapter}") else: resume_adapter, start_idx = discover_existing_adapter(args.output_dir) if resume_adapter: print(f"♻️ Resuming from existing adapter in output_dir: {resume_adapter}") model, tok ,tok_inf= load_model(model_path) if all_heads is not None: print("📌 Important heads:", all_heads) else: all_heads = detect_heads(args.json_path, model, tok) print("📌 Important heads:", all_heads) save_heads_config(all_heads, args.output_dir, model_path, args.json_path, args.topk) heads = select_top_heads(all_heads, args.topk) print(f"🎯 Using {len(heads)} heads based on topk={args.topk}: {heads}") tune(model, tok,tok_inf, heads, args.tune_path, args.output_dir, args.epochs, args.batch_size, args.lr, args.lambda_focus, resume_adapter=resume_adapter, start_batch_idx=start_idx) if __name__ == "__main__": main()