Source code for protomotions.agents.common.supervision

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

"""Shared supervision losses for imitation and distillation."""

from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, Optional

import torch
import torch.nn.functional as F


[docs] class SupervisionLossType(str, Enum): """Supported supervised losses over configured prediction and target keys.""" MSE = "mse" DISCRETE_CROSS_ENTROPY = "discrete_cross_entropy" DISCRETE_KL = "discrete_kl" CONTINUOUS_GAUSSIAN_KL = "continuous_gaussian_kl"
[docs] @dataclass class SupervisionLossConfig: """Key-based supervised loss over model outputs and labels. Distillation agents use ``prediction_key`` and ``target_key`` to select tensors from a TensorDict batch, so the same loss config can supervise actions, discrete latent tokens, or distribution parameters. """ loss_type: SupervisionLossType = SupervisionLossType.MSE prediction_key: str = "privileged_action" target_key: str = "expert_actions" prediction_logvar_key: Optional[str] = None target_logvar_key: Optional[str] = None label_smoothing: float = 0.0 weight: float = 1.0 log_prefix: str = "supervision" enabled: bool = True extra: Dict[str, str] = field(default_factory=dict)
def _get(batch, key: str) -> torch.Tensor: if key in batch: return batch[key] raise KeyError(f"Missing tensor '{key}' for supervision loss") def _discrete_kl(logits: torch.Tensor, target_logits: torch.Tensor) -> torch.Tensor: log_p = F.log_softmax(logits, dim=-1) log_q = F.log_softmax(target_logits, dim=-1) p = F.softmax(logits, dim=-1) return (p * (log_p - log_q)).sum(dim=-1).mean() def _gaussian_kl( mean: torch.Tensor, logvar: torch.Tensor, target_mean: torch.Tensor, target_logvar: torch.Tensor, ) -> torch.Tensor: var = logvar.exp() target_var = target_logvar.exp() kl = 0.5 * ( target_logvar - logvar + (var + (mean - target_mean) ** 2) / target_var - 1 ) return kl.sum(dim=-1).mean()
[docs] def compute_supervision_loss(batch, config: SupervisionLossConfig): """Compute a configured supervised loss.""" if not config.enabled: prediction = _get(batch, config.prediction_key) zero = prediction.sum() * 0.0 return zero, {f"{config.log_prefix}/loss": zero.detach()} loss_type = SupervisionLossType(config.loss_type) prefix = config.log_prefix if loss_type == SupervisionLossType.MSE: raw_loss = F.mse_loss( _get(batch, config.prediction_key), _get(batch, config.target_key), ) metrics = {f"{prefix}/mse": raw_loss.detach()} elif loss_type == SupervisionLossType.DISCRETE_CROSS_ENTROPY: logits = _get(batch, config.prediction_key) target = _get(batch, config.target_key) raw_loss = F.cross_entropy( logits.reshape(-1, logits.shape[-1]), target.reshape(-1), label_smoothing=config.label_smoothing, ) with torch.no_grad(): accuracy = ( logits.argmax(dim=-1).reshape(-1) == target.reshape(-1) ).float().mean() metrics = { f"{prefix}/cross_entropy": raw_loss.detach(), f"{prefix}/accuracy": accuracy, f"{prefix}/perplexity": torch.exp(raw_loss.detach()), } elif loss_type == SupervisionLossType.DISCRETE_KL: raw_loss = _discrete_kl( _get(batch, config.prediction_key), _get(batch, config.target_key), ) metrics = {f"{prefix}/discrete_kl": raw_loss.detach()} elif loss_type == SupervisionLossType.CONTINUOUS_GAUSSIAN_KL: if config.prediction_logvar_key is None or config.target_logvar_key is None: raise ValueError( "Continuous Gaussian KL requires prediction_logvar_key and " "target_logvar_key." ) raw_loss = _gaussian_kl( _get(batch, config.prediction_key), _get(batch, config.prediction_logvar_key), _get(batch, config.target_key), _get(batch, config.target_logvar_key), ) metrics = {f"{prefix}/gaussian_kl": raw_loss.detach()} else: raise NotImplementedError(f"Unsupported supervision loss type: {loss_type}") weighted_loss = raw_loss * config.weight metrics[f"{prefix}/loss"] = weighted_loss.detach() return weighted_loss, metrics