| """NLACriticModel: truncated transformer + vector value head. |
| |
| Architecture: |
| - First K transformer layers only (K = extraction layer, set via config override) |
| - No final layernorm — raw residual stream goes to head |
| - Linear(d_model, d_model) head, no bias (the paper says "affine"; we drop |
| the bias deliberately — predictions are L2-normalized in the loss anyway, |
| and identity-init is cleaner without an offset term) |
| - Forward returns .values at every position; training extracts at the LAST |
| real token of the critic prompt (suffix-anchored) for the MSE |
| |
| Layer truncation: set config.num_hidden_layers BEFORE from_pretrained so the |
| weight loader only reads K layers. Do NOT slice nn.ModuleList post-hoc — breaks |
| FSDP sharding assumptions. |
| """ |
|
|
| import json |
| import os |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn as nn |
| from huggingface_hub import snapshot_download |
| from safetensors import safe_open |
| from safetensors.torch import load_file, save_file |
| from transformers import AutoConfig, AutoModelForCausalLM, PreTrainedModel |
|
|
| from nla.arch_adapters import resolve_text_config, resolve_text_model |
|
|
|
|
| |
| |
| |
| |
| _EMBED_KEY_SUFFIXES = ("embed_tokens.weight", "wte.weight", "word_embeddings.weight") |
|
|
|
|
| @dataclass |
| class NLACriticOutput: |
| values: torch.Tensor |
| backbone_last_hidden: torch.Tensor |
|
|
|
|
| def _truncate_config_layers(config, num_layers: int) -> None: |
| """Set num_hidden_layers AND truncate per-layer arrays to match. |
| |
| transformers >=4.50 validates len(layer_types) == num_hidden_layers at |
| config init (configuration_utils.py:layer_type_validation). Qwen2/Llama3 |
| configs carry per-layer arrays that must be sliced consistently. |
| """ |
| config.num_hidden_layers = num_layers |
| for attr in ("layer_types", "sliding_window_pattern", "no_rope_layers"): |
| v = getattr(config, attr, None) |
| if isinstance(v, (list, tuple)) and len(v) > num_layers: |
| setattr(config, attr, type(v)(v[:num_layers])) |
|
|
|
|
| def _inner_transformer(backbone: PreTrainedModel) -> nn.Module: |
| """Get the inner transformer module (the part with .layers + .norm). |
| |
| Qwen/Llama/Mistral/Gemma (post-resolve_text_model → CausalLM wrapper): backbone.model |
| GPT-2/Falcon: backbone.transformer |
| """ |
| if hasattr(backbone, "model"): |
| return backbone.model |
| if hasattr(backbone, "transformer"): |
| return backbone.transformer |
| raise AssertionError( |
| f"{type(backbone).__name__} has neither .model nor .transformer — " |
| f"add the attribute name here if supporting a new arch" |
| ) |
|
|
|
|
| class NLACriticModel(PreTrainedModel): |
| """Wraps an HF causal LM backbone with layer truncation + vector value head. |
| |
| Delegates everything structural (save/load/fsdp-wrapping) to the backbone's |
| PreTrainedModel machinery. Only the forward path + head are NLA-specific. |
| """ |
|
|
| |
| |
| _supports_sdpa = True |
| _supports_flash_attn_2 = True |
| _supports_flex_attn = True |
|
|
| def __init__(self, config, backbone: PreTrainedModel): |
| super().__init__(config) |
| self.backbone = backbone |
| self.value_head = nn.Linear(config.hidden_size, config.hidden_size, bias=False) |
| |
| |
| |
| self._no_split_modules = backbone._no_split_modules |
|
|
| @classmethod |
| def from_pretrained(cls, pretrained_model_name_or_path, *, nla_num_layers: int | None = None, **kwargs): |
| """Load an NLACriticModel from an HF checkpoint. |
| |
| Normal case: checkpoint was produced by a previous NLA training run → |
| config.json already has the truncated num_hidden_layers. Just load. |
| |
| Bootstrapping case (fresh truncation from a full base model): pass |
| nla_num_layers = the datagen extraction layer_index. Truncation keeps |
| blocks 0..layer_index INCLUSIVE — we need the output OF block K, which |
| means we need block K to exist, so num_hidden_layers = K+1. |
| |
| Indexing convention (matches datagen/extractors.py): |
| - datagen `layer_index=K` hooks `model.model.layers[K]` → |
| captures its output (= HF's `hidden_states[K+1]`, post-residual-add, |
| the stream entering block K+1). |
| - critic `last_hidden_state` (with final-LN → Identity) is the output |
| of the LAST block. |
| - So last block must be block K → num_hidden_layers = K+1. |
| """ |
| |
| |
| kwargs.setdefault("trust_remote_code", True) |
| config = AutoConfig.from_pretrained( |
| pretrained_model_name_or_path, trust_remote_code=kwargs["trust_remote_code"] |
| ) |
| |
| |
| |
| |
| text_config = resolve_text_config(config) |
| if nla_num_layers is not None: |
| needed = nla_num_layers + 1 |
| assert needed <= text_config.num_hidden_layers, ( |
| f"nla_num_layers={nla_num_layers} needs blocks 0..{nla_num_layers} " |
| f"inclusive (num_hidden_layers={needed}), but base model has " |
| f"only {text_config.num_hidden_layers}." |
| ) |
| _truncate_config_layers(text_config, needed) |
|
|
| backbone = AutoModelForCausalLM.from_pretrained( |
| pretrained_model_name_or_path, |
| config=config, |
| **kwargs, |
| ) |
| |
| |
| backbone = resolve_text_model(backbone) |
| |
| |
| if hasattr(backbone, "lm_head"): |
| backbone.lm_head = nn.Identity() |
|
|
| |
| |
| inner = _inner_transformer(backbone) |
| for attr in ("norm", "final_layernorm", "ln_f"): |
| if hasattr(inner, attr): |
| setattr(inner, attr, nn.Identity()) |
| break |
| else: |
| raise AssertionError( |
| f"could not find final layernorm on {type(inner).__name__} — " |
| f"add the attribute name to the list above" |
| ) |
|
|
| model = cls(text_config, backbone) |
|
|
| |
| |
| |
| |
| |
| |
| head_path = Path(pretrained_model_name_or_path) / "value_head.safetensors" |
| if head_path.exists() and not model.value_head.weight.is_meta: |
| model.value_head.load_state_dict(load_file(str(head_path))) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if os.environ.get("NLA_FREEZE_VALUE_HEAD") == "1": |
| for p in model.value_head.parameters(): |
| p.requires_grad_(False) |
| print("[NLACriticModel] value_head FROZEN (identity)") |
|
|
| |
| |
| |
| |
| |
| |
| if not model.value_head.weight.is_meta: |
| last = next(inner.layers[-1].parameters()) |
| model.value_head.to(device=last.device, dtype=last.dtype) |
|
|
| return model |
|
|
| def forward(self, input_ids=None, position_ids=None, attention_mask=None, **kwargs): |
| out = _inner_transformer(self.backbone)( |
| input_ids=input_ids, |
| position_ids=position_ids, |
| attention_mask=attention_mask, |
| **kwargs, |
| ) |
| |
| |
| h = out.last_hidden_state |
| |
| |
| |
| return NLACriticOutput( |
| values=self.value_head(h.to(self.value_head.weight.dtype)), |
| backbone_last_hidden=h, |
| ) |
|
|
| def get_input_embeddings(self): |
| return self.backbone.get_input_embeddings() |
|
|
| def save_pretrained(self, save_directory, state_dict=None, **kwargs): |
| if state_dict is None: |
| state_dict = self.state_dict() |
| backbone_sd = {k.removeprefix("backbone."): v for k, v in state_dict.items() if k.startswith("backbone.")} |
| head_sd = {k.removeprefix("value_head."): v for k, v in state_dict.items() if k.startswith("value_head.")} |
|
|
| self.backbone.save_pretrained(save_directory, state_dict=backbone_sd, **kwargs) |
| save_file(head_sd, str(Path(save_directory) / "value_head.safetensors")) |
| |
|
|
| def gradient_checkpointing_enable(self, **kwargs): |
| self.backbone.gradient_checkpointing_enable(**kwargs) |
|
|
| def gradient_checkpointing_disable(self): |
| self.backbone.gradient_checkpointing_disable() |
|
|
|
|
| |
| |
| |
| |
| |
| ROLLOUT_EMBED_DUMP = "_nla_rollout_embed.pt" |
|
|
|
|
| def embed_dump_path(save_dir: str | None) -> Path | None: |
| """Resolve the embedding dump path. NLA_EMBED_DUMP_DIR overrides save_dir.""" |
| override = os.environ.get("NLA_EMBED_DUMP_DIR") |
| base = override or save_dir |
| if base is None: |
| return None |
| return Path(base) / ROLLOUT_EMBED_DUMP |
|
|
|
|
| def _find_embed_key(keys: list[str], where: str) -> str: |
| matches = [k for k in keys if k.endswith(_EMBED_KEY_SUFFIXES)] |
| assert len(matches) == 1, ( |
| f"expected exactly one input-embedding key in {where} " |
| f"(suffixes {_EMBED_KEY_SUFFIXES}), got {matches!r}" |
| ) |
| return matches[0] |
|
|
|
|
| def load_embedding_only(hf_checkpoint: str, dtype: torch.dtype = torch.float32) -> nn.Embedding: |
| """Load ONLY the input embedding layer from an HF checkpoint's safetensors. |
| |
| Returns a plain `nn.Embedding` wrapping the weight tensor. This is the RAW |
| lookup — if the model's embedding forward applies a scale (Gemma ×√d, T5), |
| the CALLER must multiply. See `arch_adapters.resolve_embed_scale()` and |
| how `nla_generate.py` uses `_EMBED_SCALE` explicitly. |
| |
| Avoids materializing the full model — just reads the one weight tensor |
| via safe_open's lazy loading. Handles HF Hub names via snapshot_download. |
| """ |
| root = Path(hf_checkpoint) |
| if not root.exists(): |
| root = Path(snapshot_download(hf_checkpoint)) |
|
|
| index_path = root / "model.safetensors.index.json" |
| if index_path.exists(): |
| weight_map = json.loads(index_path.read_text())["weight_map"] |
| key = _find_embed_key(list(weight_map), str(index_path)) |
| shard = root / weight_map[key] |
| else: |
| shard = root / "model.safetensors" |
| assert shard.exists(), ( |
| f"no model.safetensors or .index.json at {root!r}" |
| ) |
| with safe_open(str(shard), framework="pt") as f: |
| key = _find_embed_key(list(f.keys()), str(shard)) |
|
|
| with safe_open(str(shard), framework="pt") as f: |
| weight = f.get_tensor(key).to(dtype) |
|
|
| vocab, d_model = weight.shape |
| embed = nn.Embedding(vocab, d_model, _weight=weight) |
| embed.requires_grad_(False) |
| embed.eval() |
| return embed |
|
|