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.

The reference is a frozen copy of the policy a training run starts from. This
module holds its prior and FiLM input normalizer; the actor holds its copy of
the conditioning network, and the RLFT agent decides when to pin it.
"""

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] def frozen_copy(module: nn.Module) -> nn.Module: """Return a frozen copy of ``module`` for the reference policy. The copy is in eval mode, takes no gradients and never updates its normalizers. Its running statistics share the live Lightning Fabric instead of copying it: a Fabric can hold process handles that cannot be copied, and frozen statistics never synchronize anyway. """ fabrics = { id(submodule.fabric): submodule.fabric for submodule in module.modules() if getattr(submodule, "fabric", None) is not None } copied = copy.deepcopy(module, fabrics) copied.eval() for parameter in copied.parameters(): parameter.requires_grad = False for submodule in copied.modules(): if isinstance(submodule, NormObsBase): submodule._freeze_running = True return copied
[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 reference policy (only used when sampling_mode="prior_constraint"). Controls how conservatively the reference 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 # The reference policy reads the condition computed by its own frozen # copy of the conditioning network from this key. self.reference_condition_key = f"reference_{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] def optional_full_checkpoint_state_prefixes(self) -> tuple[str, ...]: """State that is optional when loading full PEFT training checkpoints. The reference prior and FiLM input normalizer held here are declared by the actor, which lists every reference network in one place. """ return ("_anchor_", "film_input_norm.")
@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 @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: """Build the reference prior and FiLM input normalizer if missing. Inside an actor, call ``DiscretePriorPEFTActor.ensure_reference_modules``, which also builds the reference's copy of the conditioning network. """ if self.reference_prior is None: self.reference_prior = frozen_copy(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 = frozen_copy(self.film_input_norm)
[docs] def mark_reference_loaded(self) -> None: """Mark the reference state just loaded as ready to use. Inside an actor, call ``DiscretePriorPEFTActor.mark_reference_loaded``. """ self.ensure_reference_modules() self._reference_ready = True log.info("Loaded PEFT prior reference from checkpoint state.")
[docs] def reset_reference(self) -> None: """Drop the reference prior and FiLM input normalizer. Inside an actor, call ``DiscretePriorPEFTActor.reset_reference``, which also drops the reference's copy of the conditioning network. Called here alone, the copy stays behind, and a ``last.ckpt`` saved afterwards fails to load. """ self.reference_prior = None self.reference_film_input_norm = None self._reference_ready = False
[docs] def require_reference(self) -> None: if self.reference_prior is None or not self._reference_ready: raise RuntimeError( "This operation requires a frozen reference policy. Load a checkpoint " "containing its reference, or initialize a new training reference " "explicitly after loading the policy weights." )
[docs] def capture_reference(self) -> None: """Pin the KL/sampling reference to the current adapters and FiLM normalizer. This replaces any reference pinned or loaded earlier. Inside an actor, call ``DiscretePriorPEFTActor.capture_reference``, which also pins the reference's copy of the conditioning network. """ 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._reference_ready = True log.info("Pinned PEFT prior reference from current adapter state.")
@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, condition_key: str | None = None, ) -> torch.Tensor: """Return task features concatenated with raw frozen-prior context. ``condition_key`` defaults to the student's condition. """ # 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. condition_key = condition_key or self.condition_key if condition_key not in input_dict: raise RuntimeError( f"DiscretePriorWithPEFT requires input_dict[{condition_key!r}]." ) task_c = torch.cat( [input_dict[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, self.reference_condition_key, ) } 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, including its conditioning, is frozen once # pinned, so the constraint stays fixed while the student changes. self.require_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