Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Stage 2: AV SFT — Activation Verbalizer | |
| Fixes vs prior version: | |
| 1. No padding='max_length' — pad to batch max length with attention_mask | |
| 2. Pass attention_mask in both train forward and generate | |
| 3. Generate with prompt-only embeds (not full padded sequence) | |
| 4. Use attention_mask in AVModel.forward | |
| """ | |
| import json, os, time, yaml, random | |
| from pathlib import Path | |
| import torch | |
| import torch.nn as nn | |
| from torch.utils.data import Dataset, DataLoader | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| from peft import LoraConfig, get_peft_model, TaskType | |
| REPO_ROOT = Path(__file__).resolve().parents[2] | |
| SIDECAR_PATH = Path(__file__).resolve().parent / "nla_meta.yaml" | |
| ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "tiny_nla" | |
| CHECKPOINT_DIR = ARTIFACTS_DIR / "checkpoints" / "av" | |
| CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True) | |
| INSTRUCT_MODEL = "Qwen/Qwen3-0.6B" | |
| def detect_device(): | |
| if torch.cuda.is_available(): | |
| return torch.device("cuda") | |
| if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): | |
| return torch.device("mps") | |
| return torch.device("cpu") | |
| def collate_fn(batch, pad_id): | |
| """Pad to batch max length (not global max).""" | |
| max_len = max(b["input_ids"].shape[0] for b in batch) | |
| input_ids = torch.full((len(batch), max_len), pad_id, dtype=torch.long) | |
| attention_mask = torch.zeros(len(batch), max_len, dtype=torch.long) | |
| labels = torch.full((len(batch), max_len), -100, dtype=torch.long) | |
| activations = torch.stack([b["activation"] for b in batch]) | |
| prompt_lens = [b["prompt_len"] for b in batch] | |
| for i, b in enumerate(batch): | |
| n = b["input_ids"].shape[0] | |
| input_ids[i, :n] = b["input_ids"] | |
| attention_mask[i, :n] = 1 | |
| labels[i, :n] = b["labels"] | |
| return { | |
| "input_ids": input_ids, | |
| "attention_mask": attention_mask, | |
| "labels": labels, | |
| "activations": activations, | |
| "prompt_lens": prompt_lens, | |
| } | |
| class AVDataset(Dataset): | |
| def __init__(self, av_data, activations, tokenizer, inj_char, inj_token_id, inj_scale, max_length=96): | |
| self.tokenizer = tokenizer | |
| self.inj_token_id = inj_token_id | |
| self.inj_scale = inj_scale | |
| self.max_length = max_length | |
| self.inj_char = inj_char | |
| self.data = [] | |
| for rec, act in zip(av_data, activations): | |
| expl = rec.get("teacher_explanation", "") or "" | |
| if not expl.strip() or expl in ("[空输出]",): | |
| continue | |
| self.data.append((expl, act)) | |
| print(f" AVDataset: {len(self.data)} samples") | |
| def __len__(self): | |
| return len(self.data) | |
| def __getitem__(self, idx): | |
| explanation, activation = self.data[idx] | |
| prompt = f"<concept>{self.inj_char}</concept>\n<explanation>" | |
| # Tokenize prompt and full sequence separately (no padding here) | |
| prompt_ids = self.tokenizer(prompt, return_tensors="pt")["input_ids"][0] | |
| expl_ids = self.tokenizer( | |
| explanation, | |
| add_special_tokens=False, | |
| return_tensors="pt", | |
| )["input_ids"][0] | |
| # Truncate explanation if needed | |
| max_expl = self.max_length - len(prompt_ids) - 1 # -1 for eos | |
| expl_ids = expl_ids[:max_expl] | |
| # Build full sequence: prompt + explanation + eos | |
| eos = torch.tensor([self.tokenizer.eos_token_id], dtype=torch.long) | |
| input_ids = torch.cat([prompt_ids, expl_ids, eos]) | |
| # Labels: -100 for prompt, actual ids for explanation+eos | |
| labels = torch.full_like(input_ids, -100) | |
| labels[len(prompt_ids):] = torch.cat([expl_ids, eos]) | |
| return { | |
| "input_ids": input_ids, | |
| "labels": labels, | |
| "activation": activation, | |
| "prompt_len": len(prompt_ids), | |
| } | |
| class AVModel(nn.Module): | |
| def __init__(self, base_model, inj_token_id): | |
| super().__init__() | |
| self.model = base_model | |
| self.inj_token_id = inj_token_id | |
| def forward(self, input_ids, attention_mask, labels, activations): | |
| embeds = self.model.get_input_embeddings()(input_ids) | |
| for b in range(input_ids.shape[0]): | |
| positions = (input_ids[b] == self.inj_token_id).nonzero(as_tuple=True)[0] | |
| if len(positions) > 0: | |
| embeds[b, positions[0].item(), :] = activations[b].to(embeds.dtype) | |
| return self.model( | |
| inputs_embeds=embeds, | |
| attention_mask=attention_mask, | |
| labels=labels, | |
| ) | |
| def scale_activations(acts, scale, device): | |
| acts = acts.to(device) | |
| norms = acts.norm(dim=-1, keepdim=True).clamp(min=1e-6) | |
| return acts / norms * scale | |
| def train(): | |
| print("=" * 60) | |
| print("AV SFT — Activation Verbalizer Training") | |
| print("=" * 60) | |
| device = detect_device() | |
| dtype = torch.float32 | |
| print(f" Device: {device}, dtype: {dtype}") | |
| with open(SIDECAR_PATH) as f: | |
| meta = yaml.safe_load(f) | |
| inj_char = meta["tokens"]["injection_char"] | |
| inj_token_id = meta["tokens"]["injection_token_id"] | |
| inj_scale = meta["extraction"]["injection_scale"] | |
| print(f" inj_char={inj_char!r} id={inj_token_id} scale={inj_scale}") | |
| with open(ARTIFACTS_DIR / "av_training_data.json", encoding="utf-8") as f: | |
| av_data = json.load(f) | |
| activations = torch.load(ARTIFACTS_DIR / "av_activations.pt", weights_only=True) | |
| print(f" Loaded {len(av_data)} records, activations {activations.shape}") | |
| tokenizer = AutoTokenizer.from_pretrained(INSTRUCT_MODEL, trust_remote_code=True) | |
| # Use a different pad token to avoid pad=eos issue | |
| tokenizer.pad_token_id = tokenizer.eos_token_id # needed for generate only | |
| random.seed(42) | |
| idx = list(range(len(av_data))) | |
| random.shuffle(idx) | |
| val_n = max(20, int(len(idx) * 0.15)) | |
| train_idx, val_idx = idx[val_n:], idx[:val_n] | |
| def mk_dataset(indices): | |
| return AVDataset( | |
| [av_data[i] for i in indices], | |
| activations[indices], | |
| tokenizer, inj_char, inj_token_id, inj_scale, | |
| ) | |
| train_ds = mk_dataset(train_idx) | |
| val_ds = mk_dataset(val_idx) | |
| pad_id = tokenizer.eos_token_id | |
| train_loader = DataLoader(train_ds, batch_size=4, shuffle=True, | |
| collate_fn=lambda b: collate_fn(b, pad_id)) | |
| val_loader = DataLoader(val_ds, batch_size=4, shuffle=False, | |
| collate_fn=lambda b: collate_fn(b, pad_id)) | |
| print(f"\n Loading {INSTRUCT_MODEL}...") | |
| base_model = AutoModelForCausalLM.from_pretrained( | |
| INSTRUCT_MODEL, trust_remote_code=True, dtype=dtype, | |
| low_cpu_mem_usage=True, attn_implementation="eager", | |
| ).to(device) | |
| lora_cfg = LoraConfig( | |
| task_type=TaskType.CAUSAL_LM, r=8, lora_alpha=16, | |
| lora_dropout=0.1, target_modules=["q_proj", "v_proj"], | |
| bias="none", | |
| ) | |
| lora_model = get_peft_model(base_model, lora_cfg) | |
| lora_model.print_trainable_parameters() | |
| av_model = AVModel(lora_model, inj_token_id).to(device) | |
| optimizer = torch.optim.AdamW(av_model.parameters(), lr=1e-4, weight_decay=0.05) | |
| scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50, eta_min=5e-6) | |
| n_epochs = 50 | |
| best_val_loss = float("inf") | |
| patience, patience_count = 10, 0 | |
| for epoch in range(n_epochs): | |
| av_model.train() | |
| train_losses = [] | |
| for batch in train_loader: | |
| input_ids = batch["input_ids"].to(device) | |
| attention_mask = batch["attention_mask"].to(device) | |
| labels = batch["labels"].to(device) | |
| scaled_acts = scale_activations(batch["activations"].float(), inj_scale, device) | |
| out = av_model(input_ids, attention_mask, labels, scaled_acts) | |
| loss = out.loss | |
| optimizer.zero_grad() | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(av_model.parameters(), 1.0) | |
| optimizer.step() | |
| train_losses.append(loss.item()) | |
| av_model.eval() | |
| val_losses = [] | |
| with torch.no_grad(): | |
| for batch in val_loader: | |
| out = av_model( | |
| batch["input_ids"].to(device), | |
| batch["attention_mask"].to(device), | |
| batch["labels"].to(device), | |
| scale_activations(batch["activations"].float(), inj_scale, device), | |
| ) | |
| val_losses.append(out.loss.item()) | |
| tl = sum(train_losses) / len(train_losses) | |
| vl = sum(val_losses) / len(val_losses) | |
| if vl < best_val_loss: | |
| best_val_loss = vl | |
| patience_count = 0 | |
| av_model.model.save_pretrained(CHECKPOINT_DIR) | |
| tokenizer.save_pretrained(CHECKPOINT_DIR) | |
| else: | |
| patience_count += 1 | |
| scheduler.step() | |
| if (epoch + 1) % 5 == 0 or epoch == 0: | |
| print(f" Epoch {epoch+1:2d}/{n_epochs} | train={tl:.4f} | val={vl:.4f} | best={best_val_loss:.4f}") | |
| if patience_count >= patience: | |
| print(f" Early stop at epoch {epoch+1}") | |
| break | |
| print(f"\n Best val loss: {best_val_loss:.4f}") | |
| # ── Generate examples (prompt-only embeds, no padding) ── | |
| print("\n Generating held-out examples...") | |
| av_model.eval() | |
| examples = [] | |
| # Reload best checkpoint | |
| from peft import PeftModel | |
| best_base = AutoModelForCausalLM.from_pretrained( | |
| INSTRUCT_MODEL, trust_remote_code=True, dtype=dtype, | |
| low_cpu_mem_usage=True, attn_implementation="eager", | |
| ).to(device) | |
| best_model = PeftModel.from_pretrained(best_base, CHECKPOINT_DIR).to(device) | |
| best_model.eval() | |
| prompt_template = f"<concept>{inj_char}</concept>\n<explanation>" | |
| with torch.no_grad(): | |
| for i in range(min(25, len(val_ds))): | |
| item = val_ds[i] | |
| act = item["activation"].unsqueeze(0).float() | |
| scaled = scale_activations(act, inj_scale, device) | |
| # Tokenize prompt only (no padding) | |
| p_ids = tokenizer(prompt_template, return_tensors="pt")["input_ids"].to(device) | |
| p_mask = torch.ones_like(p_ids) | |
| embeds = best_model.get_input_embeddings()(p_ids) | |
| inj_pos = (p_ids[0] == inj_token_id).nonzero(as_tuple=True)[0] | |
| if len(inj_pos) > 0: | |
| embeds[0, inj_pos[0].item(), :] = scaled[0].to(embeds.dtype) | |
| out_ids = best_model.generate( | |
| inputs_embeds=embeds, | |
| attention_mask=p_mask, | |
| max_new_tokens=80, | |
| do_sample=False, | |
| eos_token_id=tokenizer.eos_token_id, | |
| pad_token_id=tokenizer.eos_token_id, | |
| ) | |
| # output_ids when using inputs_embeds: only generated tokens | |
| gen_text = tokenizer.decode(out_ids[0], skip_special_tokens=True).strip() | |
| teacher = tokenizer.decode( | |
| item["labels"][item["prompt_len"]:][item["labels"][item["prompt_len"]:] != -100], | |
| skip_special_tokens=True, | |
| ) | |
| examples.append({ | |
| "index": i, | |
| "teacher": teacher, | |
| "av_generated": gen_text, | |
| }) | |
| nonempty = sum(1 for e in examples if e["av_generated"].strip()) | |
| print(f" Non-empty: {nonempty}/{len(examples)}") | |
| for e in examples[:5]: | |
| print(f" T: {e['teacher'][:60]}") | |
| print(f" G: {e['av_generated'][:80]}") | |
| print() | |
| out_path = ARTIFACTS_DIR / "av_examples.json" | |
| with open(out_path, "w", encoding="utf-8") as f: | |
| json.dump(examples, f, ensure_ascii=False, indent=2) | |
| print(f" Saved to {out_path}") | |
| print(f"\n LoRA adapter: {CHECKPOINT_DIR}") | |
| return best_val_loss | |
| if __name__ == "__main__": | |
| train() | |