Source code for protomotions.agents.common.autoregressive

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

"""Common autoregressive modules for categorical token sequences."""

from copy import deepcopy
from typing import Optional

import torch
import torch.nn.functional as F
from tensordict import TensorDict
from torch import nn

from protomotions.agents.base_agent.model import ProtoMotionsTensorDictModule
from protomotions.agents.common.config import (
    DiscreteAutoregressiveTransformerConfig,
    MLPWithConcatConfig,
)
from protomotions.agents.utils.training import get_activation_func
from protomotions.utils.hydra_replacement import get_class


def _validate_sampling_temperature(temperature: float) -> None:
    if temperature <= 0.0:
        raise ValueError("Sampling temperature must be positive.")


def _top_p_keep_mask(probs: torch.Tensor, p: float) -> torch.Tensor:
    sorted_probs, sorted_idx = torch.sort(probs, descending=True, dim=-1)
    cutoff = torch.cumsum(sorted_probs, dim=-1) > p
    cutoff[..., 1:] = cutoff[..., :-1].clone()
    cutoff[..., 0] = False

    keep_mask = torch.zeros_like(probs, dtype=torch.bool)
    keep_mask.scatter_(-1, sorted_idx, ~cutoff)
    return keep_mask


def _log_probs_from_filtered_probs(filtered_probs: torch.Tensor) -> torch.Tensor:
    return torch.where(
        filtered_probs > 0,
        filtered_probs.clamp(min=torch.finfo(filtered_probs.dtype).tiny).log(),
        torch.full_like(filtered_probs, -torch.inf),
    )


def _sampling_log_probs_from_keep_mask(
    logits: torch.Tensor,
    keep_mask: torch.Tensor,
    temperature: float,
    *,
    floor_on_mask: bool = False,
) -> torch.Tensor:
    probs = F.softmax(logits / temperature, dim=-1)
    filtered_probs = probs * keep_mask.float()
    filtered_probs = filtered_probs / (
        filtered_probs.sum(dim=-1, keepdim=True) + 1e-12
    )
    if floor_on_mask:
        return torch.where(
            keep_mask,
            filtered_probs.clamp(min=torch.finfo(filtered_probs.dtype).tiny).log(),
            torch.full_like(filtered_probs, -torch.inf),
        )
    return _log_probs_from_filtered_probs(filtered_probs)


[docs] def sampling_log_probs( logits: torch.Tensor, p: float = 0.9, temperature: float = 1.0, ) -> torch.Tensor: """Log-probabilities of the actual temperature/top-p sampling distribution.""" _validate_sampling_temperature(temperature) probs = F.softmax(logits / temperature, dim=-1) keep_mask = _top_p_keep_mask(probs, p) return _sampling_log_probs_from_keep_mask(logits, keep_mask, temperature)
[docs] def prior_constrained_sampling_log_probs( logits: torch.Tensor, prior_logits: torch.Tensor, p: float = 0.99, temperature: float = 1.0, overlap_threshold: float = 1e-3, ) -> torch.Tensor: """Log-probs after constraining model support to the prior top-p nucleus.""" _validate_sampling_temperature(temperature) prior_probs = F.softmax(prior_logits, dim=-1) model_probs = F.softmax(logits / temperature, dim=-1) keep_mask = _top_p_keep_mask(prior_probs, p) filtered_probs = model_probs * keep_mask.float() prob_sums = filtered_probs.sum(dim=-1, keepdim=True) low_overlap = (prob_sums < overlap_threshold).squeeze(-1) if low_overlap.any(): prior_filtered = prior_probs * keep_mask.float() prior_filtered = prior_filtered / ( prior_filtered.sum(dim=-1, keepdim=True) + 1e-12 ) filtered_probs[low_overlap] = prior_filtered[low_overlap] prob_sums = filtered_probs.sum(dim=-1, keepdim=True) filtered_probs = filtered_probs / (prob_sums + 1e-12) return _log_probs_from_filtered_probs(filtered_probs)
[docs] def nucleus_sampling(logits: torch.Tensor, p: float = 0.9, temperature: float = 1.0): """Sample categorical indices from the top-p nucleus.""" probs = sampling_log_probs(logits, p=p, temperature=temperature).exp() return torch.multinomial(probs, 1).squeeze(-1)
[docs] def nucleus_sampling_prior_constraint( logits: torch.Tensor, prior_logits: torch.Tensor, p: float = 0.99, temperature: float = 1.0, overlap_threshold: float = 1e-3, ): """Sample from logits while restricting support to the prior top-p nucleus. If the model assigns effectively no probability mass to the prior nucleus, sample from the prior nucleus instead of falling back to unconstrained model probabilities. That keeps prior-constraint mode active after policy drift. """ probs = prior_constrained_sampling_log_probs( logits, prior_logits, p=p, temperature=temperature, overlap_threshold=overlap_threshold, ).exp() return torch.multinomial(probs, 1).squeeze(-1)
[docs] def kl_divergence_categorical( logits: torch.Tensor, prior_logits: torch.Tensor, reduction: str = "mean", ): """KL divergence between categorical distributions parameterized by logits.""" log_p = F.log_softmax(logits, dim=-1) log_q = F.log_softmax(prior_logits, dim=-1) p = F.softmax(logits, dim=-1) kl = (p * (log_p - log_q)).sum(dim=-1) if reduction == "mean": return kl.mean() if reduction == "sum": return kl.sum() return kl
[docs] def kl_divergence_from_log_probs( log_p: torch.Tensor, log_q: torch.Tensor, reduction: str = "mean", ): """KL divergence for already transformed categorical log-probabilities.""" p = log_p.exp() kl_terms = torch.where( p > 0, p * (log_p - log_q), torch.zeros_like(p), ) kl = kl_terms.sum(dim=-1) if reduction == "mean": return kl.mean() if reduction == "sum": return kl.sum() return kl
[docs] def kl_divergence_sampling_distribution( logits: torch.Tensor, prior_logits: torch.Tensor, *, p: float = 0.9, temperature: float = 1.0, prior_constraint: bool = False, reduction: str = "mean", ): """KL between the actual transformed token sampling distributions.""" if prior_constraint: log_p = prior_constrained_sampling_log_probs( logits, prior_logits, p=p, temperature=temperature, ) log_q = prior_constrained_sampling_log_probs( prior_logits, prior_logits, p=p, temperature=temperature, ) else: _validate_sampling_temperature(temperature) student_probs = F.softmax(logits / temperature, dim=-1) student_keep_mask = _top_p_keep_mask(student_probs, p) prior_probs = F.softmax(prior_logits / temperature, dim=-1) prior_keep_mask = _top_p_keep_mask(prior_probs, p) reference_keep_mask = student_keep_mask | prior_keep_mask log_p = _sampling_log_probs_from_keep_mask( logits, student_keep_mask, temperature, ) log_q = _sampling_log_probs_from_keep_mask( prior_logits, reference_keep_mask, temperature, floor_on_mask=True, ) return kl_divergence_from_log_probs(log_p, log_q, reduction=reduction)
[docs] def generate_causal_mask( num_target: int, num_context: int = 0, device: Optional[torch.device] = None, ) -> torch.Tensor: """Build a float causal attention mask with an optional context prefix.""" total = num_context + num_target mask = torch.zeros(total, total, device=device) if num_context > 0: mask[:num_context, num_context:] = float("-inf") if num_target > 0: causal = torch.triu( torch.ones(num_target, num_target, device=device), diagonal=1, ) mask[num_context:, num_context:] = causal.masked_fill( causal == 1, float("-inf"), ) return mask
[docs] def resolve_discrete_autoregressive_config( config: DiscreteAutoregressiveTransformerConfig, *, num_tokens: int, vocab_size: int, ) -> DiscreteAutoregressiveTransformerConfig: """Return a copy of ``config`` with token count and vocabulary resolved.""" config = deepcopy(config) config.num_tokens = num_tokens config.vocab_size = vocab_size for model_config in config.output_head.models: if ( isinstance(model_config, MLPWithConcatConfig) and model_config.out_keys == [config.logits_key] ): model_config.num_out = vocab_size return config
[docs] class DiscreteAutoregressiveTransformer(ProtoMotionsTensorDictModule): """Categorical autoregressive transformer with configurable projections.""" config: DiscreteAutoregressiveTransformerConfig
[docs] def __init__(self, config: DiscreteAutoregressiveTransformerConfig): ProtoMotionsTensorDictModule.__init__(self) self.config = config self.in_keys = list(config.in_keys) self.out_keys = list(config.out_keys) self.context_key = config.context_key self.token_key = config.token_key self.logits_key = config.logits_key self.generated_tokens_key = ( config.generated_tokens_key or f"{config.logits_key}_tokens" ) self.logprob_key = config.logprob_key self.context_embedding_key = config.context_embedding_key self.token_embedding_key = config.token_embedding_key self.hidden_key = config.hidden_key self.d_model = config.d_model self.num_tokens = config.num_tokens self.vocab_size = config.vocab_size self.context_in_keys = list(config.context_encoder.in_keys) self.context_dim = None if self.num_tokens <= 0 or self.vocab_size <= 1: raise ValueError( "DiscreteAutoregressiveTransformer requires resolved positive num_tokens " "and vocab_size > 1." ) context_encoder_cls = get_class(config.context_encoder._target_) token_encoder_cls = get_class(config.token_encoder._target_) output_head_cls = get_class(config.output_head._target_) self._context_encoder = context_encoder_cls(config=config.context_encoder) self._token_encoder = token_encoder_cls(config=config.token_encoder) self._output_head = output_head_cls(config=config.output_head) layer = nn.TransformerEncoderLayer( d_model=config.d_model, nhead=config.num_heads, dim_feedforward=config.ff_size, dropout=config.dropout, activation=get_activation_func(config.activation, return_type="functional"), batch_first=True, ) self._transformer = nn.TransformerEncoder(layer, num_layers=config.num_layers) max_seq_len = config.max_seq_len or config.num_tokens + 1 self._pos_emb = nn.Parameter(torch.randn(1, max_seq_len, config.d_model)) self._causal_masks = {}
[docs] def muon_adam_fallback_modules(self): """Categorical input/output projections should use the auxiliary Adam path.""" return (self._token_encoder, self._output_head)
[docs] def compute_model_loss( self, tensordict: TensorDict, current_epoch: int, zero_loss: torch.Tensor, log_prefix: str = "model", ): loss = zero_loss * 0.0 log_dict = {} for name, model in ( ("context_encoder", self._context_encoder), ("token_encoder", self._token_encoder), ("output_head", self._output_head), ): if not isinstance(model, ProtoMotionsTensorDictModule): continue model_loss, model_log_dict = model.compute_model_loss( tensordict, current_epoch=current_epoch, zero_loss=zero_loss, log_prefix=f"{log_prefix}/{name}", ) loss = loss + model_loss log_dict.update(model_log_dict) return loss, log_dict
def _mask(self, num_target: int, device: torch.device): key = (num_target, device) if key not in self._causal_masks: self._causal_masks[key] = generate_causal_mask( num_target=num_target, num_context=1, device=device, ) return self._causal_masks[key] def _one_hot_tokens(self, tokens: torch.Tensor) -> torch.Tensor: if tokens.dim() == 2: return F.one_hot(tokens.long(), self.vocab_size).float() if tokens.shape[-1] == self.vocab_size: return tokens.float() raise ValueError( f"Expected token indices (B, T) or one-hot tokens with vocab " f"{self.vocab_size}; got shape {tuple(tokens.shape)}" )
[docs] def encode_context(self, tensordict: TensorDict) -> torch.Tensor: self.context_dim = sum( tensordict[key].shape[-1] for key in self.context_in_keys ) tensordict = self._context_encoder(tensordict) return tensordict[self.context_embedding_key]
[docs] def encode_tokens(self, tokens: torch.Tensor) -> torch.Tensor: batch_size = tokens.shape[0] token_td = TensorDict( {self.token_key: self._one_hot_tokens(tokens)}, batch_size=batch_size, device=tokens.device, ) token_td = self._token_encoder(token_td) return token_td[self.token_embedding_key]
[docs] def decode_logits(self, hidden: torch.Tensor) -> torch.Tensor: batch_size = hidden.shape[0] head_td = TensorDict( {self.hidden_key: hidden}, batch_size=batch_size, device=hidden.device, ) head_td = self._output_head(head_td) return head_td[self.logits_key]
[docs] def add_positions( self, sequence: torch.Tensor, pos_emb: Optional[torch.Tensor] = None, ) -> torch.Tensor: positions = self._pos_emb if pos_emb is None else pos_emb if sequence.shape[1] > positions.shape[1]: raise ValueError( f"Autoregressive sequence length {sequence.shape[1]} exceeds " f"max positional length {positions.shape[1]}" ) return sequence + positions[:, : sequence.shape[1], :]
[docs] def run_transformer( self, sequence: torch.Tensor, num_target: int, transformer: Optional[nn.Module] = None, transformer_kwargs: Optional[dict] = None, ) -> torch.Tensor: model = self._transformer if transformer is None else transformer kwargs = transformer_kwargs or {} return model(sequence, mask=self._mask(num_target, sequence.device), **kwargs)
[docs] def forward_from_tokens( self, context: torch.Tensor, tokens: torch.Tensor, *, transformer: Optional[nn.Module] = None, pos_emb: Optional[torch.Tensor] = None, transformer_kwargs: Optional[dict] = None, ) -> torch.Tensor: token_emb = self.encode_tokens(tokens) context_emb = context.unsqueeze(1) sequence = torch.cat([context_emb, token_emb], dim=1) sequence = self.add_positions(sequence, pos_emb=pos_emb) hidden = self.run_transformer( sequence, num_target=token_emb.shape[1], transformer=transformer, transformer_kwargs=transformer_kwargs, ) return self.decode_logits(hidden[:, : token_emb.shape[1]])
[docs] def teacher_force(self, tensordict: TensorDict) -> TensorDict: context = self.encode_context(tensordict) logits = self.forward_from_tokens(context, tensordict[self.token_key]) tensordict[self.logits_key] = logits return tensordict
[docs] def forward(self, tensordict: TensorDict) -> TensorDict: """Run teacher forcing when tokens are supplied, otherwise generate tokens.""" if self.token_key in tensordict.keys(): return self.teacher_force(tensordict) return self.generate(tensordict)
@torch.no_grad() def generate( self, tensordict: TensorDict, num_tokens: Optional[int] = None, *, temperature: float = 1.0, top_p: float = 0.9, prior_constraint=None, ) -> TensorDict: was_training = self.training self.eval() try: context = self.encode_context(tensordict) generated, logits, logps = self.generate_from_context( context, num_tokens=num_tokens or self.num_tokens, temperature=temperature, top_p=top_p, prior_constraint=prior_constraint, ) finally: self.train(was_training) tensordict[self.generated_tokens_key] = generated tensordict[self.logits_key] = logits if self.logprob_key is not None: tensordict[self.logprob_key] = logps return tensordict
[docs] def next_logits_from_context( self, context: torch.Tensor, token_indices: Optional[torch.Tensor] = None, *, transformer: Optional[nn.Module] = None, pos_emb: Optional[torch.Tensor] = None, transformer_kwargs: Optional[dict] = None, ) -> torch.Tensor: """Return logits for the next token after an optional prefix.""" context_emb = context.unsqueeze(1) if token_indices is None or token_indices.shape[1] == 0: sequence = context_emb else: token_emb = self.encode_tokens(token_indices) sequence = torch.cat([context_emb, token_emb], dim=1) sequence = self.add_positions(sequence, pos_emb=pos_emb) hidden = self.run_transformer( sequence, num_target=max(sequence.shape[1] - 1, 0), transformer=transformer, transformer_kwargs=transformer_kwargs, ) return self.decode_logits(hidden[:, -1])
[docs] def generate_from_context( self, context: torch.Tensor, num_tokens: int, *, temperature: float = 1.0, top_p: float = 0.9, prior_constraint=None, transformer: Optional[nn.Module] = None, pos_emb: Optional[torch.Tensor] = None, transformer_kwargs: Optional[dict] = None, ): generated = [] all_logits = [] all_logps = [] for _ in range(num_tokens): token_indices = torch.stack(generated, dim=1) if generated else None step_logits = self.next_logits_from_context( context, token_indices=token_indices, transformer=transformer, pos_emb=pos_emb, transformer_kwargs=transformer_kwargs, ) if prior_constraint is None: logp = sampling_log_probs( step_logits, p=top_p, temperature=temperature, ) else: prior_logits = prior_constraint( token_indices=token_indices, step=len(generated), ) logp = prior_constrained_sampling_log_probs( step_logits, prior_logits, p=top_p, temperature=temperature, ) next_idx = torch.multinomial(logp.exp(), 1).squeeze(-1) all_logps.append(logp.gather(-1, next_idx.unsqueeze(-1)).squeeze(-1)) generated.append(next_idx) all_logits.append(step_logits) return ( torch.stack(generated, dim=1), torch.stack(all_logits, dim=1), torch.stack(all_logps, dim=1), )