japhba's picture
Upload folder using huggingface_hub
74d1994 verified
Raw
History Blame Contribute Delete
41.1 kB
"""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,
)
# ----------------------------------------------------------------------------
# Helpers shared with train_rl_self_contained.py (kept inline so this file
# stays self-contained — they're small, and importing creates an awkward
# coupling between SFT and RL trainers).
# ----------------------------------------------------------------------------
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
# Under device_map="auto" this layer can live on a different GPU than
# where the caller staged input_ids / the injection vector. Align both
# to the residual's device before injecting.
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)
# Robustly locate the decoder layers under PEFT / multimodal / causal-LM
# wrappers (Gemma-4: PeftModel -> ...model.language_model.layers). The old
# `while hasattr(target,"model")` walk stopped at the multimodal *Model
# (which has .language_model, not .layers). See arch_adapters.
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)
# activation_vector via flatten→numpy (zero-copy) — ~100× faster than
# to_pylist() on 4096-float lists, which builds ~1B PyFloats at 250k rows
# (GPUs sit idle for 10-20 min otherwise). Same pattern as schema.py.
acts_col = rg.column("activation_vector").combine_chunks() # ChunkedArray→Array
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)
# ----------------------------------------------------------------------------
# AR critic init: truncate base Qwen3 to K+1 layers + Linear(d, d) value_head,
# identity-init the head. (Previously a separate critic-init script; now in-process.)
# ----------------------------------------------------------------------------
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,
)
# Identity-init the value head (Linear has bias=False per models.py).
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
# ----------------------------------------------------------------------------
# LR schedule: linear warmup → cosine decay to min_lr
# ----------------------------------------------------------------------------
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
# ----------------------------------------------------------------------------
# AV forward: encode chat-template prompt + response, build response-only loss
# mask, forward through model with Karvonen hook firing on the marker token.
# ----------------------------------------------------------------------------
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:
# row["prompt"] is list[{"role","content"}] with INJECT_PLACEHOLDER inside.
# Replace with the actual injection char so the tokenizer emits the
# marker token id at the right position.
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)
# Response gets a trailing EOS so the model learns to stop.
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:
# Truncate response from the right to fit. Prompt is fixed.
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
# 1 on response positions, 0 on prompt + pad. The shift-by-one for CE
# is applied later (in the loss computation), so this mask is in
# "target token" space — positions whose CE we want to count.
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
# ----------------------------------------------------------------------------
# AR forward: tokenize the already-built critic prompt, forward, take MSE on
# normalised (pred, gold).
# ----------------------------------------------------------------------------
def _ar_prepare_chunk(rows, tokenizer, device, max_len=1024):
full_ids_list = []
kept_rows = []
n_skipped = 0
for row in rows:
# AR's prompt is the already-filled critic template string.
# add_special_tokens=False matches RL-time critic scoring and stage-3's
# build-time suffix verification (True is a no-op on Qwen but prepends
# BOS on Llama/Gemma-family tokenizers → train/reward token mismatch).
ids = tokenizer.encode(row["prompt"], add_special_tokens=False)
if len(ids) > max_len:
# Right-truncating would cut the "</text> <summary>" suffix and the
# last-token extraction would land mid-explanation — silently wrong.
# Skip the row instead (RL-side rejects over-length the same way).
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
# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
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 + nla config ----
# From --base-ckpt, NOT hardcoded — the sidecar asserts below catch a
# wrong-family tokenizer, but only if we load the one the run targets.
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}")
# ---- model ----
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, # FSDP-friendly storage (harmless single-GPU)
)
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,
)
# Target only PLAIN nn.Linear q/k/v/o projections. On multimodal
# bases (Gemma-4) the VISION tower wraps its projections in
# Gemma4ClippableLinear (unsupported by PEFT); the text decoder uses
# plain nn.Linear. Filtering by isinstance excludes vision and keeps
# Qwen/Llama behaviour identical (all text projections are nn.Linear).
_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:
# use_reentrant=True REQUIRED: transformers 5.x defaults to
# NON-reentrant checkpointing, which is incompatible with the in-place
# Karvonen injection hook (resid modified in the layer-1 forward hook)
# -> CheckpointError: different #tensors saved in forward vs recompute.
model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": True}
)
model.enable_input_require_grads()
print("[av] gradient_checkpointing ENABLED (use_reentrant=True)")
else: # ar
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)
# Check if --base-ckpt is already a critic ckpt (has value_head.safetensors)
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)
# from_pretrained ALWAYS strips the final norm, regardless of the
# CLI flag — record what actually happened, or RL would rebuild
# the critic differently than it was trained.
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:
# Inject LoRA IN-PLACE into the backbone's attn projections. Unlike
# get_peft_model this does NOT wrap the backbone in a PeftModel, so
# NLACriticModel.forward (which calls the inner transformer directly)
# is unchanged and the value_head stays a plain trainable module.
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)
# Train ONLY the LoRA adapters + the value_head; freeze the rest.
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:
# NLACriticModel wraps backbone; enable on inner module
# (use_lora+4bit path already enabled it via prepare_model_for_kbit_training)
if hasattr(model.backbone, "gradient_checkpointing_enable"):
model.backbone.gradient_checkpointing_enable()
print("[ar] gradient_checkpointing ENABLED (backbone)")
model.train()
# ---- data ----
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:
# One-time suffix-anchor sanity check (the sidecar field's stated
# purpose): the tokenized critic prompt must end with the expected
# "</text> <summary>" ids, or last-token extraction trains on the
# wrong position. Row 0 suffices — template drift hits every row.
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)")
# ---- optimizer + LR schedule ----
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")
# ---- AR-only: predict-the-mean baseline for FVE logging ----
# Paper definition: baseline = E[||v_norm - μ||²] (raw variance of the
# normalized distribution, ≈0.72), NOT MSE against normalize(μ) (≈0.94)
# which runs before 2026-06-09 used and which inflates FVE.
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})")
# ---- AR-only: held-out FVE pairs (doc-disjoint AV split) ----
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}")
# ---- wandb ----
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)
# ---- training loop ----
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 # AV only: total response tokens for normalization
accum_n = 0
for accum_idx in range(grad_accum):
# ---- pick batch ----
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
# ---- forward + loss ----
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-by-one CE on response tokens. Predict ids[:, t+1] from
# logits[:, t]. Mask is in TARGET space (positions of tokens
# to predict), so mask[:, 1:] aligned with logits[:, :-1].
shift_logits = logits[:, :-1].contiguous()
# device_map=auto can return logits on a non-zero GPU; align.
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: # ar
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)
# Scale loss for accumulation; gradients sum correctly.
(loss / grad_accum).backward()
accum_loss += loss.item()
accum_n += 1
# ---- step ----
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)
# ---- held-out FVE (AR mode, doc-disjoint) ----
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)
# ---- save ----
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:
# AR + LoRA: save just the adapter weights + value_head (NOT the
# 4-bit backbone). RL reloads via init_critic_from_base + inject.
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"],
# Whether the backbone's final RMSNorm was stripped at init.
# RL must rebuild the critic the same way or predictions
# silently shift (pre-2026-06 ckpts: norm kept = False).
"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))
# Copy the sidecar so the RL trainer can find injection_token_id etc.
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()