Source code for protomotions.agents.peft.prior_with_peft

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""PEFT adapter attached to a pretrained autoregressive GPC prior.

This module owns PEFT-specific state: adapter injection, task conditioning,
and the frozen KL/sampling reference. The generic autoregressive token loop stays
on the common prior transformer.
"""

from __future__ import annotations

import copy
import logging

import torch
from torch import nn
from tensordict import TensorDict

from protomotions.agents.common.common import NormObsBase
from protomotions.agents.common.config import NormObsBaseConfig
from protomotions.agents.peft.adapters import (
    freeze_base_and_enable_peft,
    inject_transformer_peft,
    set_peft_layers_train_mode,
)

log = logging.getLogger(__name__)


[docs] class DiscretePriorWithPEFT(nn.Module): """Attach conditioned LoRA/DoRA layers to a frozen prior transformer. The wrapper consumes the prior's context keys plus one configured PEFT condition key. It builds ``task_c`` for the adapter layers and delegates token teacher-forcing/generation to ``DiscreteAutoregressiveTransformer``. Higher-level actors own observation preprocessing, decoding, and target encoding. """
[docs] def __init__( self, prior, conditioning_dim: int | None = None, rank: int = 4, alpha: float = 0.5, peft_type: str = "dora", temperature: float = 1.0, top_p: float = 0.8, sampling_mode: str = "nucleus", prior_top_p: float = 0.99, condition_key: str = "task_cond", film_input_norm: bool = False, film_input_norm_clamp: float = 5.0, ): """Initialize PEFT wrapper. Args: prior: Base TransformerPrior model to wrap. conditioning_dim: Optional dimension of the full PEFT conditioning vector, i.e. condition_key features plus frozen-prior context observations. If omitted, it is inferred from warmup_obs in init_peft(). rank: LoRA/DoRA rank. alpha: LoRA/DoRA scaling factor. peft_type: "lora" or "dora". temperature: Sampling temperature for the PEFT actor. top_p: Nucleus sampling threshold for the PEFT actor. sampling_mode: Sampling strategy - "nucleus" or "prior_constraint". prior_top_p: Nucleus threshold for the frozen prior (only used when sampling_mode="prior_constraint"). Controls how conservatively the frozen prior caps the PEFT actor's distribution. condition_key: TensorDict key carrying PEFT conditioning features. film_input_norm: Normalize PEFT conditioning before FiLM layers. film_input_norm_clamp: Clamp value for normalized conditioning. """ super().__init__() self.rank = rank self.alpha = alpha self.peft_type = peft_type self.temperature = temperature self.top_p = top_p self.prior_top_p = prior_top_p self.condition_key = condition_key self.conditioning_dim = conditioning_dim self.sampling_mode = sampling_mode self.film_input_norm = ( NormObsBase( NormObsBaseConfig( normalize_obs=True, norm_clamp_value=film_input_norm_clamp, ) ) if film_input_norm else None ) self.base_prior = prior self.reference_prior = None self.reference_film_input_norm = None self._reference_ready = False
@property def task_c_dim(self): if self.conditioning_dim is None: raise RuntimeError( "DiscretePriorWithPEFT conditioning_dim is unknown. Provide " "conditioning_dim or call init_peft with warmup_obs containing " f"{self.condition_key!r} and the frozen-prior context keys." ) return self.conditioning_dim
[docs] def init_peft(self, warmup_obs: dict | None = None): """Materialize prior context shape, then install PEFT adapters. ``warmup_obs`` should contain the raw observation keys expected by the frozen prior. It is only used when the pretrained prior's context dimension has not already been materialized by a previous forward pass. """ self._prime_context_dim(warmup_obs) if self.conditioning_dim is None: if warmup_obs is None or self.condition_key not in warmup_obs: raise RuntimeError( "DiscretePriorWithPEFT.init_peft requires warmup_obs with " f"{self.condition_key!r} and frozen-prior context keys when " "conditioning_dim is not provided." ) self.conditioning_dim = self._build_task_c(warmup_obs, None).shape[-1] self.base_prior._transformer = inject_transformer_peft( transformer=self.base_prior._transformer, conditioning_dim=self.task_c_dim, rank=self.rank, alpha=self.alpha, peft_type=self.peft_type, ) self._freeze_base() self._materialize_film_input_norm(warmup_obs)
@torch.no_grad() def _prime_context_dim(self, warmup_obs: dict | None) -> None: """Run one frozen-prior teacher-forced pass if context_dim is lazy.""" bp = self.base_prior if bp.context_dim is not None: return context_in_keys = list(bp.context_in_keys or []) if not context_in_keys: return if warmup_obs is None: raise RuntimeError( "DiscretePriorWithPEFT.init_peft requires warmup_obs when the base " "prior context_dim has not been materialized." ) missing = [key for key in context_in_keys if key not in warmup_obs] if missing: raise RuntimeError( f"Prior expects context keys {context_in_keys} but warmup_obs " f"is missing {missing}. Available keys: {list(warmup_obs.keys())}" ) device = bp._pos_emb.device td_data = {key: warmup_obs[key].to(device) for key in context_in_keys} batch_size = td_data[context_in_keys[0]].shape[0] if bp.token_key in warmup_obs: td_data[bp.token_key] = warmup_obs[bp.token_key].to(device) else: # Token values are irrelevant here; we only need a valid token tensor # shape so teacher_force can materialize the lazy context encoder dims. token_one_hot = torch.zeros( batch_size, bp.num_tokens, bp.vocab_size, device=device, dtype=torch.float32, ) token_one_hot[:, :, 0] = 1.0 td_data[bp.token_key] = token_one_hot bp.teacher_force(TensorDict(td_data, batch_size=batch_size, device=device)) def _freeze_base(self): """Freeze the base prior and leave only adapter residuals trainable.""" self.base_prior.eval() for p in self.base_prior.parameters(): p.requires_grad = False self._freeze_base_normalizers() freeze_base_and_enable_peft(self.base_prior._transformer) if log.isEnabledFor(logging.DEBUG): trainable = sum(p.numel() for p in self.parameters() if p.requires_grad) total = sum(p.numel() for p in self.parameters()) log.debug( "PEFT adapter ready: trainable %s / %s (%.2f%%), task_c_dim=%s", f"{trainable:,}", f"{total:,}", 100 * trainable / total, self.task_c_dim, )
[docs] @staticmethod def reference_state_prefixes(prefix: str = "") -> tuple[str, ...]: return ( f"{prefix}reference_prior.", f"{prefix}reference_film_input_norm.", )
[docs] def optional_full_checkpoint_state_prefixes(self) -> tuple[str, ...]: """State that is optional when loading full PEFT training checkpoints.""" return ("_anchor_", "film_input_norm.", *self.reference_state_prefixes())
@property def reference_ready(self) -> bool: return self._reference_ready def _freeze_base_normalizers(self) -> None: for module in self.base_prior.modules(): if isinstance(module, NormObsBase): module._freeze_running = True def _freeze_normalizers(self) -> None: self._freeze_base_normalizers() if self.film_input_norm is not None: self.film_input_norm._freeze_running = True @torch.no_grad() def _materialize_film_input_norm(self, warmup_obs: dict | None) -> None: if self.film_input_norm is None or warmup_obs is None: return self._build_task_c(warmup_obs, self.film_input_norm)
[docs] def ensure_reference_modules(self) -> None: if self.reference_prior is None: self.reference_prior = copy.deepcopy(self.base_prior) if self.film_input_norm is None: self.reference_film_input_norm = None elif self.reference_film_input_norm is None: self.reference_film_input_norm = copy.deepcopy(self.film_input_norm) self._freeze_reference_modules()
def _freeze_reference_modules(self) -> None: if self.reference_prior is not None: self.reference_prior.eval() for parameter in self.reference_prior.parameters(): parameter.requires_grad = False for module in self.reference_prior.modules(): if isinstance(module, NormObsBase): module._freeze_running = True if self.reference_film_input_norm is not None: self.reference_film_input_norm.eval() self.reference_film_input_norm._freeze_running = True for parameter in self.reference_film_input_norm.parameters(): parameter.requires_grad = False
[docs] def mark_reference_loaded(self) -> None: self.ensure_reference_modules() self._reference_ready = True log.info("Loaded PEFT prior reference from checkpoint state.")
[docs] def require_reference(self) -> None: if self.reference_prior is None or not self._reference_ready: raise RuntimeError( "PEFT RLFT requires a frozen reference policy for true resume. " "Warm-start with load_training_state=False or use a checkpoint " "saved after the reference-module migration." )
[docs] def capture_reference(self): """Pin the complete KL/sampling reference to the current PEFT policy.""" self._freeze_normalizers() if self.reference_ready: log.debug("PEFT prior reference already pinned; leaving it unchanged.") return False self.ensure_reference_modules() self.reference_prior.load_state_dict(self.base_prior.state_dict()) if self.film_input_norm is not None: self.reference_film_input_norm.load_state_dict( self.film_input_norm.state_dict() ) self._freeze_reference_modules() self._reference_ready = True log.info("Pinned PEFT prior reference from current adapter state.") return True
@torch.no_grad() def clear_peft(self): """Zero adapter residuals on the active student; leave the reference intact.""" for layer in self.base_prior._transformer.layers: if hasattr(layer, "m"): layer.m.data.zero_() elif hasattr(layer, "lora"): layer.lora.B.data.zero_() def _build_task_c( self, input_dict: dict, film_input_norm: NormObsBase | None, ) -> torch.Tensor: """Return task features concatenated with raw frozen-prior context.""" # Adapter layers see both the task command and the same raw context that # conditions the frozen prior. This lets PEFT modulate the prior relative # to the state the prior itself is using, without hard-coding terrain or # task-specific keys in the adapter wrapper. if self.condition_key not in input_dict: raise RuntimeError( f"DiscretePriorWithPEFT requires input_dict[{self.condition_key!r}]." ) task_c = torch.cat( [input_dict[self.condition_key], self._context(input_dict)], dim=-1, ) if film_input_norm is not None: task_c = film_input_norm(task_c) return task_c def _task_transformer_kwargs(self, input_dict: dict) -> dict: """Transformer kwargs required by PEFT-wrapped layers.""" return {"task_c": self._build_task_c(input_dict, self.film_input_norm)} def _reference_task_transformer_kwargs(self, input_dict: dict) -> dict: """Transformer kwargs for the frozen reference policy.""" return { "task_c": self._build_task_c( input_dict, self.reference_film_input_norm, ) } def _context_items(self, input_dict: dict) -> list[tuple[str, torch.Tensor]]: context_in_keys = list(self.base_prior.context_in_keys) if not context_in_keys: raise RuntimeError( "DiscretePriorWithPEFT: base prior reports no context_in_keys; " "cannot route context into encode_context." ) if all(key in input_dict for key in context_in_keys): return [(key, input_dict[key]) for key in context_in_keys] missing = [key for key in context_in_keys if key not in input_dict] raise RuntimeError( f"DiscretePriorWithPEFT expected frozen-prior context keys {context_in_keys}; " f"missing {missing}. Available keys: {list(input_dict.keys())}" ) def _context(self, input_dict: dict) -> torch.Tensor: tensors = [tensor for _, tensor in self._context_items(input_dict)] if len(tensors) == 1: return tensors[0] return torch.cat(tensors, dim=-1) def _tokens(self, input_dict: dict) -> torch.Tensor: if self.base_prior.token_key in input_dict: return input_dict[self.base_prior.token_key] return input_dict["tokens"] def _context_embedding(self, input_dict: dict, prior=None) -> torch.Tensor: context_items = self._context_items(input_dict) first_context = context_items[0][1] td = TensorDict( {key: tensor for key, tensor in context_items}, batch_size=first_context.shape[0], device=first_context.device, ) prior = self.base_prior if prior is None else prior return prior.encode_context(td)
[docs] def train(self, mode: bool = True): super().train(mode) if mode: self.base_prior.eval() if self.reference_prior is not None: self.reference_prior.eval() if self.reference_film_input_norm is not None: self.reference_film_input_norm.eval() set_peft_layers_train_mode(self.base_prior._transformer, mode) for layer in self.base_prior._transformer.layers: if hasattr(layer, "transformer_layer"): layer.transformer_layer.eval() return self
# ---- forward (teacher-forced) ----
[docs] def forward(self, input_dict: dict) -> torch.Tensor: """Teacher-force the PEFT-wrapped prior and return token logits.""" bp = self.base_prior return bp.forward_from_tokens( self._context_embedding(input_dict), self._tokens(input_dict), transformer_kwargs=self._task_transformer_kwargs(input_dict), )
@torch.no_grad() def forward_prior(self, input_dict: dict) -> torch.Tensor: """Teacher-forced forward using frozen prior (for KL loss computation). Uses no_grad since prior logits are only used as targets - gradients flow through the PEFT model's logits only. """ self.require_reference() reference = self.reference_prior return reference.forward_from_tokens( self._context_embedding(input_dict, prior=reference), self._tokens(input_dict), transformer_kwargs=self._reference_task_transformer_kwargs(input_dict), ) # ---- generate (autoregressive) ---- @torch.no_grad() def generate( self, input_dict: dict, return_logits: bool = True, return_logprob: bool = False, ): """Sample tokens from the PEFT prior, optionally constrained by reference.""" was_training = self.training self.eval() try: bp = self.base_prior context = self._context_embedding(input_dict) transformer_kwargs = self._task_transformer_kwargs(input_dict) prior_constraint = None top_p = self.top_p if self.sampling_mode == "prior_constraint": # Nucleus+prior-constraint sampling first limits the candidate set by # the frozen reference policy, then samples with the active adapter # logits. The reference is fixed at RLFT start, so sampling remains # stable while the student adapter changes. if not self.reference_ready: self.capture_reference() reference = self.reference_prior reference_context = self._context_embedding(input_dict, prior=reference) reference_kwargs = self._reference_task_transformer_kwargs(input_dict) top_p = self.prior_top_p def prior_constraint(token_indices, step): return reference.next_logits_from_context( reference_context, token_indices=token_indices, transformer_kwargs=reference_kwargs, ) indices, logits, logprob = bp.generate_from_context( context, num_tokens=bp.num_tokens, temperature=self.temperature, top_p=top_p, prior_constraint=prior_constraint, transformer_kwargs=transformer_kwargs, ) finally: self.train(was_training) outputs = [indices] if return_logits: outputs.append(logits) if return_logprob: outputs.append(logprob) if len(outputs) > 1: return tuple(outputs) return indices