| """Self-contained NLA SFT (AV + AR), no Miles dependency. |
| |
| Single entry point with `--mode {av,ar}`: |
| - AV: AutoModelForCausalLM + Karvonen layer-1 injection hook |
| loss = cross-entropy on response tokens only |
| target: actor learns to verbalise injected activations |
| - AR: NLACriticModel (truncated K+1-layer backbone + Linear(d,d) value_head) |
| loss = MSE on L2-normalised (pred, gold) at last-token position |
| target: critic learns to reconstruct activation from explanation text |
| |
| Replaces the old Miles-era pipeline (FSDP actor subclass, loss plug-ins, |
| rollout adapters, shell wrappers, and a separate critic-init script — all |
| removed in the repo consolidation). AR backbone truncation now happens |
| in-script. |
| |
| Loads bf16 model + bitsandbytes AdamW8bit (~4 GB optim states on 8B model |
| instead of 64 GB for fp32 AdamW). Single GPU; activation memory bounded by |
| gradient_checkpointing on the AV path. |
| |
| Saves HF format checkpoints directly — no DCP→HF conversion step. |
| """ |
|
|
| import argparse |
| import json |
| import math |
| import os |
| import re |
| import time |
| import unicodedata |
| from pathlib import Path |
|
|
| import numpy as np |
| import pyarrow.parquet as pq |
| import torch |
| import torch.nn.functional as F |
| import wandb |
| from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig |
| from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training |
|
|
| from nla.arch_adapters import resolve_decoder_layers |
| from nla.config import load_nla_config |
| from nla.injection import karvonen_inject_in_residual |
| from nla.models import NLACriticModel |
| from nla.schema import ( |
| INJECT_PLACEHOLDER, |
| extract_explanation, |
| normalize_activation, |
| resolve_target_scale, |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
|
|
|
|
| def cjk_fraction(text: str) -> float: |
| if not text: |
| return 0.0 |
| return sum(1 for c in text if "CJK" in unicodedata.name(c, "")) / len(text) |
|
|
|
|
| def _register_karvonen_hook(model, vectors_ref, inj_id, left_id, right_id, layer_idx=1): |
| """Register an embed-token-id capture + layer-1 residual-modification hook. |
| |
| On every forward: the embedding hook stashes input_ids; the layer-1 hook |
| reads them, finds marker positions, and adds the norm-matched activation |
| vector (from vectors_ref[0]) onto the residual at those positions. |
| |
| No-op when seq_len < 2 (autoregressive cache steps after rollout's prefill). |
| """ |
| state = {"input_ids": None} |
|
|
| def embed_hook(module, args, kwargs, output): |
| ids = kwargs.get("input") if kwargs else None |
| if ids is None and args: |
| ids = args[0] |
| state["input_ids"] = ids |
| return output |
|
|
| def layer_hook(module, args, output): |
| if isinstance(output, tuple): |
| resid, *rest = output |
| else: |
| resid, rest = output, None |
| input_ids = state["input_ids"] |
| if input_ids is None or resid.shape[1] < 2: |
| return output |
| v = vectors_ref[0] |
| if v is None or v.shape[0] == 0: |
| return output |
| |
| |
| |
| ids = input_ids.to(resid.device) |
| if (ids == inj_id).sum().item() == 0: |
| return output |
| injected = karvonen_inject_in_residual( |
| ids, resid, v.to(resid.device), inj_id, left_id, right_id, |
| ) |
| if rest is None: |
| return injected |
| return (injected, *rest) |
|
|
| model.get_input_embeddings().register_forward_hook(embed_hook, with_kwargs=True) |
| |
| |
| |
| |
| resolve_decoder_layers(model)[layer_idx].register_forward_hook(layer_hook) |
|
|
|
|
| def critic_predict(critic, input_ids, attention_mask, mse_scale_f): |
| """pred = value_head(normalize(backbone_last_hidden, mse_scale)). |
| |
| Same trick as train_rl_self_contained.py — bounds value_head's input norm |
| so its weight updates can't blow up the output norm by 100× (which is what |
| NaN'd AR SFT 8+ times before NLA_FREEZE_VALUE_HEAD=1 in the Miles path). |
| At identity init, equivalent to the paper's direct value_head(backbone_last). |
| """ |
| cout = critic(input_ids=input_ids, attention_mask=attention_mask) |
| backbone_last = cout.backbone_last_hidden |
| if attention_mask is not None: |
| last_idx = attention_mask.sum(dim=1) - 1 |
| else: |
| last_idx = torch.full( |
| (input_ids.shape[0],), input_ids.shape[1] - 1, device=input_ids.device, |
| ) |
| bs = input_ids.shape[0] |
| last_h = backbone_last[ |
| torch.arange(bs, device=input_ids.device), last_idx |
| ].float() |
| last_h_norm = normalize_activation(last_h, mse_scale_f) |
| pred = critic.value_head( |
| last_h_norm.to(critic.value_head.weight.dtype) |
| ).float() |
| return pred |
|
|
|
|
| def load_sft_dataset(parquet_path, n_max=None, *, mode): |
| """Stream-load AV (prompt: list[dict], response: str, activation_vector) |
| or AR (prompt: str, activation_vector). Slice rowgroups so n_max=N takes |
| only N rows, not the full first rowgroup.""" |
| cols = ( |
| ["prompt", "response", "activation_vector"] if mode == "av" |
| else ["prompt", "activation_vector"] |
| ) |
| pf = pq.ParquetFile(parquet_path) |
| rows = [] |
| for rg_idx in range(pf.num_row_groups): |
| if n_max is not None and len(rows) >= n_max: |
| break |
| rg = pf.read_row_group(rg_idx, columns=cols) |
| n_in_rg = rg.num_rows |
| take = n_in_rg if n_max is None else min(n_max - len(rows), n_in_rg) |
| rg = rg.slice(0, take) |
| |
| |
| |
| acts_col = rg.column("activation_vector").combine_chunks() |
| acts_np = (acts_col.flatten().to_numpy(zero_copy_only=False) |
| .astype(np.float32).reshape(len(acts_col), -1)) |
| prompts = rg.column("prompt").to_pylist() |
| responses = rg.column("response").to_pylist() if mode == "av" else None |
| for i in range(take): |
| row = {"prompt": prompts[i], "activation_vector": acts_np[i]} |
| if mode == "av": |
| row["response"] = responses[i] |
| rows.append(row) |
| return rows |
|
|
|
|
| def load_heldout_explanation_pairs(parquet_path, n_rows): |
| """(explanation, activation) pairs from an AV-split parquet (has `response`). |
| |
| The AV split is DOC-DISJOINT from the AR training data by stage-1 |
| construction, so FVE on these pairs is a genuine held-out number — |
| training-batch FVE overstates quality once the data is multi-epoch. |
| """ |
| pf = pq.ParquetFile(parquet_path) |
| pairs = [] |
| for rg_idx in range(pf.num_row_groups): |
| if len(pairs) >= n_rows: |
| break |
| rg = pf.read_row_group(rg_idx, columns=["response", "activation_vector"]) |
| responses = rg.column("response").to_pylist() |
| acts_col = rg.column("activation_vector").combine_chunks() |
| acts = (acts_col.flatten().to_numpy(zero_copy_only=False) |
| .astype(np.float32).reshape(len(acts_col), -1)) |
| for resp, act in zip(responses, acts): |
| expl = extract_explanation(resp) |
| if expl is None: |
| continue |
| pairs.append((expl, act)) |
| if len(pairs) >= n_rows: |
| break |
| return pairs |
|
|
|
|
| @torch.no_grad() |
| def heldout_fve_mse(critic, tokenizer, pairs, template, mse_scale_f, device, |
| micro_batch=16, max_len=1024): |
| """Mean per-sample MSE on normalized (pred, gold) over held-out pairs. |
| |
| Returns (mean_mse, n_scored). Caller divides by a predict-the-mean |
| baseline for FVE. Skips pairs whose critic prompt exceeds max_len |
| (would truncate the suffix anchor). |
| """ |
| mses = [] |
| for cs in range(0, len(pairs), micro_batch): |
| chunk = pairs[cs:cs + micro_batch] |
| ids_list, golds = [], [] |
| for expl, act in chunk: |
| ids = tokenizer.encode(template.format(explanation=expl), |
| add_special_tokens=False) |
| if not 0 < len(ids) <= max_len: |
| continue |
| ids_list.append(torch.tensor(ids, dtype=torch.long)) |
| golds.append(act) |
| if not ids_list: |
| continue |
| bs = len(ids_list) |
| T = max(t.numel() for t in ids_list) |
| batch_ids = torch.full((bs, T), tokenizer.eos_token_id, |
| dtype=torch.long, device=device) |
| attn = torch.zeros((bs, T), dtype=torch.long, device=device) |
| for i, t in enumerate(ids_list): |
| batch_ids[i, : t.numel()] = t.to(device) |
| attn[i, : t.numel()] = 1 |
| pred = critic_predict(critic, batch_ids, attn, mse_scale_f) |
| gold = torch.tensor(np.stack(golds), dtype=torch.float32, device=device) |
| pred_n = normalize_activation(pred, mse_scale_f) |
| gold_n = normalize_activation(gold, mse_scale_f) |
| mses.extend(((pred_n - gold_n) ** 2).mean(dim=-1).tolist()) |
| return float(np.mean(mses)) if mses else float("nan"), len(mses) |
|
|
|
|
| |
| |
| |
| |
|
|
| def _resolve_device_map(device_map_mode, max_gpu_mem, quant_config): |
| """Return (device_map, max_memory) for from_pretrained. |
| |
| 'single' → whole 4-bit model on GPU0 (bf16: None, caller does .to(device)). |
| 'auto' → accelerate splits weights across visible GPUs (naive MP). A |
| positive max_gpu_mem (GiB/GPU) forces a split — used to validate |
| the 397B sharding path on a small model that would otherwise fit |
| on one GPU. |
| """ |
| if quant_config is None: |
| return None, None |
| if device_map_mode == "auto": |
| max_memory = None |
| if max_gpu_mem and max_gpu_mem > 0: |
| max_memory = { |
| i: f"{max_gpu_mem}GiB" for i in range(torch.cuda.device_count()) |
| } |
| return "auto", max_memory |
| return {"": 0}, None |
|
|
|
|
| def init_critic_from_base(base_ckpt: str, num_layers: int, dtype, quant_config=None, |
| device_map=None, max_memory=None, strip_final_norm=True): |
| """Build a fresh NLACriticModel: K+1-layer truncated backbone + identity-init |
| Linear(d, d) value_head. |
| |
| Delegates to NLACriticModel.from_pretrained, which truncates via |
| config.num_hidden_layers BEFORE loading (so only the kept layers are read) |
| and is multimodal-aware (resolve_text_config / resolve_text_model). The old |
| in-place path here loaded the FULL model and sliced the ModuleList post-hoc — |
| that walk stopped at Gemma-4's multimodal *Model (no .layers) and contradicted |
| models.py's "don't slice post-hoc" invariant. `num_layers` = K+1 (blocks |
| 0..K inclusive); from_pretrained takes nla_num_layers=K and re-adds the +1. |
| |
| identity-init is critical: at step 0, pred = value_head(last_h) = last_h, so |
| the initial reconstruction loss starts at the backbone's representational |
| ceiling instead of kaiming's 1/√3 scaling. from_pretrained ALWAYS strips the |
| layer-K final norm (raw residual → head); quant_config loads the backbone in |
| 4-bit (QLoRA) with the value_head full-precision. |
| """ |
| assert strip_final_norm, ( |
| "init_critic_from_base only supports strip_final_norm=True — " |
| "NLACriticModel.from_pretrained always strips the layer-K final norm." |
| ) |
| critic = NLACriticModel.from_pretrained( |
| base_ckpt, nla_num_layers=num_layers - 1, dtype=dtype, |
| attn_implementation="sdpa", quantization_config=quant_config, |
| device_map=device_map, max_memory=max_memory, |
| ) |
| |
| vh = critic.value_head |
| with torch.no_grad(): |
| vh.weight.copy_(torch.eye(vh.weight.shape[0], dtype=vh.weight.dtype, device=vh.weight.device)) |
| print(f"[critic] from_pretrained nla_num_layers={num_layers - 1} " |
| f"(keep blocks 0..{num_layers - 1}); value_head identity-init " |
| f"(weight norm = {vh.weight.float().norm().item():.3f})") |
| return critic |
|
|
|
|
| |
| |
| |
|
|
| def build_lr_lambda(warmup_steps, total_steps, min_lr_ratio): |
| def fn(step): |
| if step < warmup_steps: |
| return step / max(1, warmup_steps) |
| prog = (step - warmup_steps) / max(1, total_steps - warmup_steps) |
| prog = min(1.0, prog) |
| cos = 0.5 * (1 + math.cos(math.pi * prog)) |
| return min_lr_ratio + (1 - min_lr_ratio) * cos |
| return fn |
|
|
|
|
| |
| |
| |
| |
|
|
| def _av_prepare_chunk(rows, tokenizer, inject_char, device, max_len=1024): |
| """Return (input_ids, attn, loss_mask, v_batch) — all [B, T] (or [B, d]).""" |
| full_ids_list = [] |
| prompt_lens = [] |
| for row in rows: |
| |
| |
| |
| msgs = [ |
| {**m, "content": m["content"].replace(INJECT_PLACEHOLDER, inject_char)} |
| if isinstance(m.get("content"), str) else m |
| for m in row["prompt"] |
| ] |
| prompt_str = tokenizer.apply_chat_template( |
| msgs, tokenize=False, add_generation_prompt=True, |
| ) |
| prompt_ids = tokenizer.encode(prompt_str, add_special_tokens=False) |
| |
| resp = row["response"] + (tokenizer.eos_token or "") |
| resp_ids = tokenizer.encode(resp, add_special_tokens=False) |
| full = prompt_ids + resp_ids |
| if len(full) > max_len: |
| |
| full = full[:max_len] |
| full_ids_list.append(torch.tensor(full, dtype=torch.long)) |
| prompt_lens.append(len(prompt_ids)) |
|
|
| bs = len(full_ids_list) |
| T = max(t.numel() for t in full_ids_list) |
| pad_id = tokenizer.eos_token_id |
| batch_ids = torch.full((bs, T), pad_id, dtype=torch.long, device=device) |
| attn = torch.zeros((bs, T), dtype=torch.long, device=device) |
| loss_mask = torch.zeros((bs, T), dtype=torch.float32, device=device) |
| for i, t in enumerate(full_ids_list): |
| L = t.numel() |
| batch_ids[i, :L] = t.to(device) |
| attn[i, :L] = 1 |
| |
| |
| |
| loss_mask[i, prompt_lens[i]:L] = 1 |
| v_batch = torch.tensor( |
| np.stack([r["activation_vector"] for r in rows]), |
| dtype=torch.float32, device=device, |
| ) |
| return batch_ids, attn, loss_mask, v_batch |
|
|
|
|
| |
| |
| |
| |
|
|
| def _ar_prepare_chunk(rows, tokenizer, device, max_len=1024): |
| full_ids_list = [] |
| kept_rows = [] |
| n_skipped = 0 |
| for row in rows: |
| |
| |
| |
| |
| ids = tokenizer.encode(row["prompt"], add_special_tokens=False) |
| if len(ids) > max_len: |
| |
| |
| |
| n_skipped += 1 |
| continue |
| full_ids_list.append(torch.tensor(ids, dtype=torch.long)) |
| kept_rows.append(row) |
| if n_skipped: |
| print(f"[ar] skipped {n_skipped}/{len(rows)} rows with critic prompt " |
| f"> {max_len} tokens (suffix anchor would be truncated)") |
| assert full_ids_list, f"all {len(rows)} rows exceeded max_len={max_len}" |
| bs = len(full_ids_list) |
| T = max(t.numel() for t in full_ids_list) |
| pad_id = tokenizer.eos_token_id |
| batch_ids = torch.full((bs, T), pad_id, dtype=torch.long, device=device) |
| attn = torch.zeros((bs, T), dtype=torch.long, device=device) |
| for i, t in enumerate(full_ids_list): |
| L = t.numel() |
| batch_ids[i, :L] = t.to(device) |
| attn[i, :L] = 1 |
| gold = torch.tensor( |
| np.stack([r["activation_vector"] for r in kept_rows]), |
| dtype=torch.float32, device=device, |
| ) |
| return batch_ids, attn, gold |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| p = argparse.ArgumentParser() |
| p.add_argument("--mode", required=True, choices=["av", "ar"]) |
| p.add_argument("--base-ckpt", required=True, |
| help="HF dir for AV (base model) or AR (base model to truncate, " |
| "OR an already-prepared NLACriticModel checkpoint).") |
| p.add_argument("--parquet", required=True, help="SFT data parquet") |
| p.add_argument("--sidecar", default=None, |
| help="Sidecar source (defaults to --parquet for the dataset sidecar)") |
| p.add_argument("--save-dir", required=True) |
| p.add_argument("--num-steps", type=int, default=1000) |
| p.add_argument("--batch-size", type=int, default=64, |
| help="Per-forward batch (= 'micro batch'). Effective batch = " |
| "batch_size × gradient_accumulation_steps.") |
| p.add_argument("--gradient-accumulation-steps", type=int, default=1) |
| p.add_argument("--ar-num-layers", type=int, default=25, |
| help="K+1 for AR mode — truncate base to this many transformer blocks") |
| p.add_argument("--heldout-parquet", default=None, |
| help="AR mode: AV-split parquet for held-out FVE (doc-disjoint " |
| "from AR training data by stage-1 construction). " |
| "Evaluated every --heldout-every steps.") |
| p.add_argument("--heldout-rows", type=int, default=1000) |
| p.add_argument("--heldout-every", type=int, default=100) |
| p.add_argument("--strip-final-norm", action=argparse.BooleanOptionalAction, |
| default=True, |
| help="AR mode: replace the backbone's final RMSNorm with " |
| "Identity so the value head sees the raw layer-K " |
| "residual (matches NLACriticModel." |
| "from_pretrained). --no-strip-final-norm reproduces " |
| "pre-2026-06 checkpoints. Recorded in ar_meta.json.") |
| p.add_argument("--max-len", type=int, default=1024) |
| p.add_argument("--lr", type=float, default=2e-5) |
| p.add_argument("--min-lr", type=float, default=2e-6) |
| p.add_argument("--lr-warmup-steps", type=int, default=50) |
| p.add_argument("--max-grad-norm", type=float, default=1.0) |
| p.add_argument("--gradient-checkpointing", action=argparse.BooleanOptionalAction, |
| default=None, |
| help="Default: ON for AV (fits 8B + batch=64 + FA2 on 141 GB H200), " |
| "OFF for AR (smaller model + shorter seq fits comfortably).") |
| p.add_argument("--attn-implementation", default="sdpa", |
| choices=["sdpa", "flash_attention_2", "eager"]) |
| p.add_argument("--quant", choices=["none", "4bit"], default="none", |
| help="4bit = bitsandbytes nf4 (QLoRA). Required for models too " |
| "big for bf16; validates the GLM-5 path on Qwen3-8B.") |
| p.add_argument("--use-lora", action="store_true", default=False, |
| help="Train a LoRA adapter on a frozen base instead of full-FT. " |
| "Mandatory for 4bit. (AR value_head stays fully trainable.)") |
| p.add_argument("--lora-r", type=int, default=128) |
| p.add_argument("--lora-alpha", type=int, default=16) |
| p.add_argument("--device-map", choices=["single", "auto"], default="single", |
| help="single = whole 4-bit model on GPU0 (fits up to ~70B on " |
| "a B200). auto = accelerate splits weights across all " |
| "visible GPUs (naive MP) — required for 397B-class bases.") |
| p.add_argument("--max-gpu-mem", type=int, default=0, |
| help="GiB/GPU cap for device_map=auto weight placement. >0 " |
| "forces a multi-GPU split (used to validate sharding on a " |
| "small model). 0 = use full GPU memory.") |
| p.add_argument("--max-rows", type=int, default=None, |
| help="Cap training rows (smoke runs)") |
| p.add_argument("--save-every", type=int, default=500) |
| p.add_argument("--seed", type=int, default=0) |
| p.add_argument("--wandb-project", default="nla-qwen3-8b") |
| p.add_argument("--wandb-name", default=None) |
| p.add_argument("--no-wandb", action="store_true") |
| args = p.parse_args() |
|
|
| torch.manual_seed(args.seed) |
| np.random.seed(args.seed) |
| device = "cuda" |
| dtype = torch.bfloat16 |
| if args.gradient_checkpointing is None: |
| args.gradient_checkpointing = (args.mode == "av") |
| if args.sidecar is None: |
| args.sidecar = args.parquet |
|
|
| |
| |
| |
| tokenizer = AutoTokenizer.from_pretrained(args.base_ckpt) |
| cfg = load_nla_config(args.sidecar, tokenizer) |
| mse_scale_f = resolve_target_scale(cfg.mse_scale, cfg.d_model) |
| print(f"[cfg] mode={args.mode} d_model={cfg.d_model} mse_scale={mse_scale_f}") |
|
|
| |
| if args.mode == "av": |
| print(f"[av] loading {args.base_ckpt} (quant={args.quant}, lora={args.use_lora})") |
| quant_config = None |
| if args.quant == "4bit": |
| quant_config = BitsAndBytesConfig( |
| load_in_4bit=True, bnb_4bit_quant_type="nf4", |
| bnb_4bit_compute_dtype=dtype, bnb_4bit_use_double_quant=True, |
| bnb_4bit_quant_storage=dtype, |
| ) |
| dmap, max_mem = _resolve_device_map(args.device_map, args.max_gpu_mem, quant_config) |
| model = AutoModelForCausalLM.from_pretrained( |
| args.base_ckpt, torch_dtype=dtype, |
| attn_implementation=args.attn_implementation, |
| quantization_config=quant_config, |
| device_map=dmap, max_memory=max_mem, |
| ) |
| if dmap is None: |
| model = model.to(device) |
| elif args.device_map == "auto" and hasattr(model, "hf_device_map"): |
| print(f"[av] device_map=auto → GPUs used: " |
| f"{sorted({d for d in model.hf_device_map.values() if isinstance(d, int)})}") |
| if args.use_lora: |
| if quant_config is not None: |
| model = prepare_model_for_kbit_training( |
| model, use_gradient_checkpointing=args.gradient_checkpointing, |
| ) |
| |
| |
| |
| |
| |
| _proj_suffixes = ("q_proj", "k_proj", "v_proj", "o_proj") |
| av_targets = sorted({ |
| name for name, mod in model.named_modules() |
| if name.endswith(_proj_suffixes) and isinstance(mod, torch.nn.Linear) |
| }) |
| assert av_targets, "no nn.Linear q/k/v/o_proj modules found for LoRA" |
| model = get_peft_model(model, LoraConfig( |
| r=args.lora_r, lora_alpha=args.lora_alpha, lora_dropout=0.0, |
| bias="none", task_type="CAUSAL_LM", use_rslora=True, |
| target_modules=av_targets, |
| )) |
| model.print_trainable_parameters() |
| vectors_ref = [None] |
| _register_karvonen_hook( |
| model, vectors_ref, |
| cfg.injection_token_id, |
| cfg.injection_left_neighbor_id, |
| cfg.injection_right_neighbor_id, |
| ) |
| if args.gradient_checkpointing: |
| |
| |
| |
| |
| model.gradient_checkpointing_enable( |
| gradient_checkpointing_kwargs={"use_reentrant": True} |
| ) |
| model.enable_input_require_grads() |
| print("[av] gradient_checkpointing ENABLED (use_reentrant=True)") |
| else: |
| quant_config = None |
| if args.quant == "4bit": |
| quant_config = BitsAndBytesConfig( |
| load_in_4bit=True, bnb_4bit_quant_type="nf4", |
| bnb_4bit_compute_dtype=dtype, bnb_4bit_use_double_quant=True, |
| bnb_4bit_quant_storage=dtype, |
| ) |
| dmap, max_mem = _resolve_device_map(args.device_map, args.max_gpu_mem, quant_config) |
| |
| is_prepared_critic = (Path(args.base_ckpt) / "value_head.safetensors").exists() |
| if is_prepared_critic: |
| print(f"[ar] loading pre-prepared critic from {args.base_ckpt}") |
| model = NLACriticModel.from_pretrained( |
| args.base_ckpt, torch_dtype=dtype, |
| attn_implementation=args.attn_implementation, |
| quantization_config=quant_config, |
| device_map=dmap, max_memory=max_mem, |
| ) |
| if dmap is None: |
| model = model.to(device) |
| |
| |
| |
| if not args.strip_final_norm: |
| print("[ar] NOTE: --no-strip-final-norm ignored on the " |
| "prepared-critic path (from_pretrained always strips); " |
| "recording final_norm_stripped=true") |
| args.strip_final_norm = True |
| else: |
| print(f"[ar] truncating base {args.base_ckpt} to {args.ar_num_layers} " |
| f"layers (quant={args.quant})") |
| model = init_critic_from_base( |
| args.base_ckpt, args.ar_num_layers, dtype, quant_config, |
| device_map=dmap, max_memory=max_mem, |
| strip_final_norm=args.strip_final_norm, |
| ) |
| if dmap is None: |
| model = model.to(device) |
| if args.use_lora: |
| |
| |
| |
| |
| from peft import inject_adapter_in_model |
| if quant_config is not None: |
| model.backbone = prepare_model_for_kbit_training( |
| model.backbone, use_gradient_checkpointing=args.gradient_checkpointing, |
| ) |
| inject_adapter_in_model(LoraConfig( |
| r=args.lora_r, lora_alpha=args.lora_alpha, lora_dropout=0.0, |
| bias="none", task_type="CAUSAL_LM", use_rslora=True, |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], |
| ), model.backbone) |
| |
| for n_, p_ in model.named_parameters(): |
| p_.requires_grad_(("lora_" in n_) or n_.startswith("value_head")) |
| n_tr = sum(p.numel() for p in model.parameters() if p.requires_grad) |
| print(f"[ar] LoRA-injected; trainable={n_tr/1e6:.1f}M (lora + value_head)") |
| vectors_ref = None |
| if args.gradient_checkpointing and not args.use_lora: |
| |
| |
| if hasattr(model.backbone, "gradient_checkpointing_enable"): |
| model.backbone.gradient_checkpointing_enable() |
| print("[ar] gradient_checkpointing ENABLED (backbone)") |
| model.train() |
|
|
| |
| print(f"[data] loading {args.parquet} (max_rows={args.max_rows})", flush=True) |
| rows = load_sft_dataset(args.parquet, n_max=args.max_rows, mode=args.mode) |
| print(f"[data] {len(rows)} rows", flush=True) |
| if args.mode == "ar" and cfg.critic_suffix_ids: |
| |
| |
| |
| |
| from nla.config import verify_critic_suffix |
| _row0_ids = tokenizer.encode(rows[0]["prompt"], add_special_tokens=False) |
| verify_critic_suffix(_row0_ids, cfg.critic_suffix_ids, context="ar row 0") |
| print(f"[ar] critic suffix anchor verified (row 0)") |
|
|
| |
| try: |
| import bitsandbytes as bnb |
| optim_cls = bnb.optim.AdamW8bit |
| print(f"[optim] using bitsandbytes AdamW8bit (bnb {bnb.__version__})") |
| except ImportError: |
| optim_cls = torch.optim.AdamW |
| print("[optim] bitsandbytes unavailable, falling back to torch AdamW (fp32 m,v)") |
| trainable = [p for p in model.parameters() if p.requires_grad] |
| optim = optim_cls(trainable, lr=args.lr, betas=(0.9, 0.95), weight_decay=0.0) |
| sched = torch.optim.lr_scheduler.LambdaLR( |
| optim, |
| build_lr_lambda(args.lr_warmup_steps, args.num_steps, |
| args.min_lr / max(args.lr, 1e-12)), |
| ) |
| n_trainable = sum(p.numel() for p in trainable) |
| print(f"[optim] trainable params: {n_trainable / 1e9:.2f} B") |
|
|
| |
| |
| |
| |
| fve_baseline = None |
| if args.mode == "ar": |
| from nla.schema import compute_predict_mean_baselines |
| _act = torch.tensor( |
| np.stack([r["activation_vector"] for r in rows[: min(len(rows), 4000)]]), |
| dtype=torch.float32, |
| ) |
| _bl_meannorm, fve_baseline = compute_predict_mean_baselines(_act, mse_scale_f) |
| print(f"[ar] predict-the-mean MSE baseline = {fve_baseline:.4f} " |
| f"(paper def; meannorm baseline = {_bl_meannorm:.4f})") |
|
|
| |
| heldout_pairs = None |
| heldout_baseline = None |
| if args.mode == "ar" and args.heldout_parquet: |
| assert cfg.critic_prompt_template is not None, ( |
| "--heldout-parquet needs critic_prompt_template in the sidecar" |
| ) |
| heldout_pairs = load_heldout_explanation_pairs( |
| args.heldout_parquet, args.heldout_rows, |
| ) |
| _h_acts = torch.tensor( |
| np.stack([a for _, a in heldout_pairs]), dtype=torch.float32, |
| ) |
| _, heldout_baseline = compute_predict_mean_baselines(_h_acts, mse_scale_f) |
| del _h_acts |
| print(f"[ar] {len(heldout_pairs)} held-out pairs from " |
| f"{args.heldout_parquet}; baseline (paper def) = {heldout_baseline:.4f}") |
|
|
| |
| if not args.no_wandb: |
| wandb.init(project=args.wandb_project, name=args.wandb_name, config=vars(args)) |
|
|
| save_dir = Path(args.save_dir) |
| save_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| rng = np.random.default_rng(args.seed) |
| perm = list(range(len(rows))) |
| rng.shuffle(perm) |
| cursor = 0 |
|
|
| grad_accum = args.gradient_accumulation_steps |
| eff_batch = args.batch_size * grad_accum |
| print(f"[loop] {args.num_steps} steps, batch={args.batch_size} × " |
| f"grad_accum={grad_accum} = eff_batch={eff_batch}") |
|
|
| for step in range(args.num_steps): |
| t0 = time.time() |
| optim.zero_grad() |
| accum_loss = 0.0 |
| accum_resp_tokens = 0 |
| accum_n = 0 |
|
|
| for accum_idx in range(grad_accum): |
| |
| if cursor + args.batch_size > len(perm): |
| rng.shuffle(perm) |
| cursor = 0 |
| chunk_rows = [rows[i] for i in perm[cursor:cursor + args.batch_size]] |
| cursor += args.batch_size |
|
|
| |
| if args.mode == "av": |
| ids, attn, loss_mask, v_batch = _av_prepare_chunk( |
| chunk_rows, tokenizer, cfg.injection_char, device, |
| max_len=args.max_len, |
| ) |
| vectors_ref[0] = v_batch |
| try: |
| logits = model(input_ids=ids, attention_mask=attn).logits.float() |
| finally: |
| vectors_ref[0] = None |
| |
| |
| |
| shift_logits = logits[:, :-1].contiguous() |
| |
| shift_targets = ids[:, 1:].to(shift_logits.device).contiguous() |
| shift_mask = loss_mask[:, 1:].to(shift_logits.device).contiguous() |
| V = shift_logits.size(-1) |
| per_tok = F.cross_entropy( |
| shift_logits.view(-1, V), |
| shift_targets.view(-1), |
| reduction="none", |
| ).view(shift_targets.shape) |
| n_resp = shift_mask.sum().clamp(min=1) |
| loss = (per_tok * shift_mask).sum() / n_resp |
| accum_resp_tokens += int(n_resp.item()) |
| else: |
| ids, attn, gold = _ar_prepare_chunk( |
| chunk_rows, tokenizer, device, max_len=args.max_len, |
| ) |
| pred = critic_predict(model, ids, attn, mse_scale_f) |
| pred_n = normalize_activation(pred, mse_scale_f) |
| gold_n = normalize_activation(gold, mse_scale_f) |
| loss = F.mse_loss(pred_n, gold_n) |
|
|
| |
| (loss / grad_accum).backward() |
| accum_loss += loss.item() |
| accum_n += 1 |
|
|
| |
| grad_norm = torch.nn.utils.clip_grad_norm_(trainable, args.max_grad_norm) |
| optim.step() |
| sched.step() |
|
|
| mean_loss = accum_loss / max(accum_n, 1) |
| cur_lr = sched.get_last_lr()[0] |
|
|
| log = { |
| "step": step, |
| "examples_seen": (step + 1) * args.batch_size * args.gradient_accumulation_steps, |
| "loss": mean_loss, |
| "lr": cur_lr, |
| "grad_norm": grad_norm.item() if hasattr(grad_norm, "item") else float(grad_norm), |
| "wall_s": time.time() - t0, |
| } |
| line = (f"step {step:04d} | loss {mean_loss:.4f} | lr {cur_lr:.2e} " |
| f"| grad {log['grad_norm']:.3f} | t {log['wall_s']:.1f}s") |
| if args.mode == "ar" and fve_baseline is not None: |
| fve = (1.0 - mean_loss / fve_baseline) * 100.0 |
| log["fve_pct"] = fve |
| line += f" | FVE {fve:.1f}%" |
| if args.mode == "av": |
| log["resp_tokens"] = accum_resp_tokens |
| line += f" | resp_toks {accum_resp_tokens}" |
| print(line, flush=True) |
|
|
| |
| if heldout_pairs is not None and ( |
| (step + 1) % args.heldout_every == 0 or (step + 1) == args.num_steps |
| ): |
| model.eval() |
| h_mse, h_n = heldout_fve_mse( |
| model, tokenizer, heldout_pairs, cfg.critic_prompt_template, |
| mse_scale_f, device, max_len=args.max_len, |
| ) |
| model.train() |
| h_fve = (1.0 - h_mse / heldout_baseline) * 100.0 |
| log["heldout_fve_pct"] = h_fve |
| log["heldout_mse"] = h_mse |
| print(f" [heldout@{step}] mse {h_mse:.4f} | FVE {h_fve:.1f}% " |
| f"(n={h_n})", flush=True) |
| if not args.no_wandb: |
| wandb.log(log, step=step) |
|
|
| |
| if (step + 1) % args.save_every == 0 or (step + 1) == args.num_steps: |
| out_dir = save_dir / f"iter_{step + 1:07d}" |
| out_dir.mkdir(parents=True, exist_ok=True) |
| print(f"[save] → {out_dir}", flush=True) |
| if args.mode == "av": |
| model.save_pretrained(str(out_dir)) |
| tokenizer.save_pretrained(str(out_dir)) |
| elif args.use_lora: |
| |
| |
| from safetensors.torch import save_file |
| sd = {n: p.detach().cpu().contiguous() |
| for n, p in model.named_parameters() |
| if ("lora_" in n) or n.startswith("value_head")} |
| save_file(sd, str(out_dir / "ar_lora_value_head.safetensors")) |
| (out_dir / "ar_meta.json").write_text(json.dumps({ |
| "ar_num_layers": args.ar_num_layers, |
| "lora_r": args.lora_r, "lora_alpha": args.lora_alpha, |
| "quant": args.quant, |
| "target_modules": ["q_proj", "k_proj", "v_proj", "o_proj"], |
| |
| |
| |
| "final_norm_stripped": args.strip_final_norm, |
| }, indent=2)) |
| tokenizer.save_pretrained(str(out_dir)) |
| else: |
| model.save_pretrained(str(out_dir)) |
| tokenizer.save_pretrained(str(out_dir)) |
| |
| import shutil |
| sidecar_src = Path(args.sidecar) |
| if sidecar_src.is_file() and sidecar_src.suffix == ".parquet": |
| sidecar_yaml = sidecar_src.with_suffix(".parquet.nla_meta.yaml") |
| if sidecar_yaml.exists(): |
| shutil.copy2(sidecar_yaml, out_dir / "nla_meta.yaml") |
|
|
| print("done.", flush=True) |
| if not args.no_wandb: |
| wandb.finish() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|