Rate this Page
★ ★ ★ ★ ★

Source code for torchrl.objectives.tdmpc2

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal

import torch
import torch.nn as nn
import torch.nn.functional as F
from tensordict import NestedKey, TensorDict, TensorDictBase
from tensordict.nn import dispatch, TensorDictModuleBase

from ..modules import TdMpc2QEnsemble
from .common import LossModule


def symlog_two_hot(
    value: torch.Tensor,
    vmin: float,
    vmax: float,
    num_bins: int,
) -> torch.Tensor:
    """Encode scalars with TD-MPC2's symlog-space two-hot representation.

    The categorical support is uniformly spaced in symlog space. The final
    dimension of ``value`` is interpreted as a singleton event dimension and
    the returned tensor has ``num_bins`` categories in its final dimension.

    Args:
        value: Scalar targets with shape ``[..., 1]``.
        vmin: Lower endpoint of the symlog-space support.
        vmax: Upper endpoint of the symlog-space support.
        num_bins: Number of categorical support points.

    Returns:
        A tensor containing the two-hot targets.
    """
    if num_bins <= 1:
        raise ValueError(f"num_bins must be greater than 1, got {num_bins}.")
    if vmax <= vmin:
        raise ValueError("vmax must be greater than vmin.")

    transformed = torch.sign(value) * torch.log1p(value.abs())
    if transformed.shape[-1] == 1:
        transformed = transformed.squeeze(-1)
    transformed = transformed.clamp(vmin, vmax)
    bin_size = (vmax - vmin) / (num_bins - 1)
    position = (transformed - vmin) / bin_size
    index = position.floor().long()
    offset = position - index
    encoded = torch.zeros(
        *transformed.shape,
        num_bins,
        dtype=value.dtype,
        device=value.device,
    )
    encoded.scatter_(-1, index.unsqueeze(-1), (1 - offset).unsqueeze(-1))
    encoded.scatter_(
        -1,
        ((index + 1) % num_bins).unsqueeze(-1),
        offset.unsqueeze(-1),
    )
    return encoded


def symlog_two_hot_cross_entropy(
    logits: torch.Tensor,
    target: torch.Tensor,
    vmin: float,
    vmax: float,
    num_bins: int,
) -> torch.Tensor:
    """Compute cross-entropy between logits and symlog two-hot targets.

    Args:
        logits (torch.Tensor): Unnormalized categorical predictions with
            categories in the final dimension.
        target (torch.Tensor): Scalar target values with shape ``[..., 1]``.
        vmin (float): Lower endpoint of the symlog-space support.
        vmax (float): Upper endpoint of the symlog-space support.
        num_bins (int): Number of categorical support points.

    Returns:
        Per-sample cross-entropy with a trailing singleton event dimension.

    The returned tensor retains all non-category dimensions and has a trailing
    singleton event dimension, matching TD-MPC2 reward and value losses.
    """
    encoded = symlog_two_hot(target, vmin, vmax, num_bins)
    return -(encoded * F.log_softmax(logits, dim=-1)).sum(-1, keepdim=True)


class _TdMpc2RunningScale(nn.Module):
    """Track the robust value scale used by the TD-MPC2 policy objective."""

    def __init__(self, tau: float) -> None:
        super().__init__()
        self.tau = float(tau)
        self.register_buffer("value", torch.ones(1))
        self.register_buffer("percentiles", torch.tensor([5.0, 95.0]))

    @torch.no_grad()
    def update(self, value: torch.Tensor) -> None:
        """Update the scale from a batch of scalar values."""
        value = value.detach().reshape(-1, value.shape[-1])
        if value.shape[0] == 0:
            return
        if self.value.device != value.device or self.value.dtype != value.dtype:
            self.value = self.value.to(device=value.device, dtype=value.dtype)
        sorted_value = value.sort(dim=0).values
        positions = (
            self.percentiles.to(device=value.device, dtype=value.dtype)
            * (value.shape[0] - 1)
            / 100
        )
        lower = positions.floor().long()
        upper = positions.ceil().long()
        weight_upper = positions - lower
        percentiles = (
            sorted_value[lower] * (1 - weight_upper).unsqueeze(-1)
            + sorted_value[upper] * weight_upper.unsqueeze(-1)
        ).squeeze(-1)
        new_value = (percentiles[1] - percentiles[0]).clamp_min(1.0)
        self.value.lerp_(new_value.reshape_as(self.value), self.tau)

    def forward(self, value: torch.Tensor, *, update: bool = False) -> torch.Tensor:
        """Scale values, optionally updating the running estimate first."""
        if update:
            self.update(value)
        return value / self.value


def _tdmpc2_next_key(key: NestedKey) -> NestedKey:
    """Return the transition ``next`` counterpart of a TensorDict key."""
    if isinstance(key, tuple):
        if key and key[0] == "next":
            return key
        return ("next", *key)
    return ("next", key)


[docs] class TdMpc2Loss(LossModule): """Compute the TD-MPC2 model-learning objective. Args: world_model: TensorDict-native encoder, dynamics, and reward model. policy_prior: TensorDict module producing sampled actions and policy statistics from a latent state. q_ensemble: Distributional Q-function ensemble. horizon: Number of transitions in each sampled sequence. discount: Scalar discount factor used for TD targets. rho: Temporal weighting factor for model and actor losses. consistency_coef: Weight of latent consistency loss. reward_coef: Weight of distributional reward loss. value_coef: Weight of distributional value loss. entropy_coef: Entropy coefficient in the actor objective. scale_tau: Exponential averaging factor for the running value scale. observation_key: Current observation key. action_key: Action key. reward_key: Reward key under ``"next"``. terminated_key: Termination key under ``"next"``. .. seealso:: :class:`~torchrl.trainers.algorithms.configs.TdMpc2LossConfig`, `TD-MPC2: Scalable, Robust World Models for Continuous Control <https://arxiv.org/abs/2310.16828>`_. Examples: >>> import torch >>> from tensordict import TensorDict >>> from torchrl.objectives import TdMpc2Loss >>> # The three TensorDict-native components are built from the >>> # corresponding TD-MPC2 configuration classes. >>> loss = TdMpc2Loss(world_model, policy_prior, q_ensemble) # doctest: +SKIP """ @dataclass class _AcceptedKeys: """TensorDict entries consumed by the TD-MPC2 objective.""" observation: NestedKey = "observation" action: NestedKey = "action" reward: NestedKey = "reward" terminated: NestedKey = "terminated" tensor_keys: _AcceptedKeys default_keys = _AcceptedKeys def __init__( self, world_model: TensorDictModuleBase, policy_prior: TensorDictModuleBase, q_ensemble: TdMpc2QEnsemble, *, horizon: int = 3, discount: float = 0.99, rho: float = 0.5, consistency_coef: float = 20.0, reward_coef: float = 0.1, value_coef: float = 0.1, entropy_coef: float = 1e-4, scale_tau: float = 0.01, observation_key: NestedKey = "observation", action_key: NestedKey = "action", reward_key: NestedKey = "reward", terminated_key: NestedKey = "terminated", ) -> None: super().__init__() if horizon <= 0: raise ValueError(f"horizon must be positive, got {horizon}.") if not 0 <= discount <= 1: raise ValueError(f"discount must be in [0, 1], got {discount}.") if not 0 <= rho <= 1: raise ValueError(f"rho must be in [0, 1], got {rho}.") for name, value in { "consistency_coef": consistency_coef, "reward_coef": reward_coef, "value_coef": value_coef, "entropy_coef": entropy_coef, }.items(): if value < 0: raise ValueError(f"{name} must be non-negative, got {value}.") if not 0 < scale_tau <= 1: raise ValueError(f"scale_tau must be in (0, 1], got {scale_tau}.") self.world_model = world_model self.policy_prior = policy_prior self.q_ensemble = q_ensemble self.horizon = int(horizon) self.consistency_coef = float(consistency_coef) self.reward_coef = float(reward_coef) self.value_coef = float(value_coef) self.entropy_coef = float(entropy_coef) self.register_buffer("discount", torch.as_tensor(float(discount))) self.rho = float(rho) self.scale = _TdMpc2RunningScale(scale_tau) self._tensor_keys = self._AcceptedKeys( observation=observation_key, action=action_key, reward=reward_key, terminated=terminated_key, ) try: self.latent_key = world_model.encoder.out_keys[0] self.next_latent_key = _tdmpc2_next_key(self.latent_key) self.reward_logits_key = world_model.reward_head.out_keys[0] self.next_reward_logits_key = _tdmpc2_next_key(self.reward_logits_key) self.world_model_observation_key = world_model.encoder.in_keys[0] self.world_model_action_key = world_model.dynamics.in_keys[1] except (AttributeError, IndexError) as err: raise TypeError( "world_model must expose encoder, dynamics, and reward_head " "TensorDict modules." ) from err self.policy_latent_key = policy_prior.in_keys[0] self.policy_action_key = policy_prior.out_keys[0] if len(policy_prior.out_keys) < 5: raise ValueError( "policy_prior must expose action, mean, log_std, entropy, and " "scaled_entropy outputs." ) self.policy_entropy_key = policy_prior.out_keys[3] self.policy_scaled_entropy_key = policy_prior.out_keys[4] self.q_logits_key = q_ensemble.out_keys_source[0] @property def in_keys(self) -> list[NestedKey]: """Return the canonical current and next transition keys.""" return [ self.tensor_keys.observation, self.tensor_keys.action, _tdmpc2_next_key(self.tensor_keys.observation), _tdmpc2_next_key(self.tensor_keys.reward), _tdmpc2_next_key(self.tensor_keys.terminated), ] @property def out_keys(self) -> list[NestedKey]: """Return the scalar loss and diagnostic keys written by ``forward``.""" return [ "loss_consistency", "loss_reward", "loss_value", ] def _forward_value_estimator_keys(self, **kwargs) -> None: """TD-MPC2 does not use TorchRL's value-estimator interface.""" @staticmethod def _make_td( batch_size: torch.Size, values: Sequence[tuple[NestedKey, torch.Tensor]], ) -> TensorDict: td = TensorDict({}, batch_size=batch_size) for key, value in values: td.set(key, value) return td def _sequence_tensors( self, sample: TensorDictBase ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: if sample.ndim < 1: raise ValueError( "TdMpc2Loss expects a time dimension in the TensorDict batch shape." ) if sample.batch_size[-1] != self.horizon: raise ValueError( f"Expected a final time dimension of {self.horizon}, got " f"{sample.batch_size[-1]}." ) keys = self.tensor_keys observation = sample.get(keys.observation) action = sample.get(keys.action) next_observation = sample.get(_tdmpc2_next_key(keys.observation)) reward = sample.get(_tdmpc2_next_key(keys.reward)) terminated = sample.get(_tdmpc2_next_key(keys.terminated)) missing = [ name for name, value in ( ("observation", observation), ("action", action), ("next/observation", next_observation), ("next/reward", reward), ("next/terminated", terminated), ) if value is None ] if missing: raise KeyError(f"Missing TD-MPC2 sequence keys: {missing}.") if reward.ndim == sample.ndim: reward = reward.unsqueeze(-1) if terminated.ndim == sample.ndim: terminated = terminated.unsqueeze(-1) terminated = terminated.to(dtype=reward.dtype) expected_batch = sample.batch_size for name, value in ( ("observation", observation), ("action", action), ("next/observation", next_observation), ("reward", reward), ("terminated", terminated), ): if torch.Size(value.shape[: sample.ndim]) != expected_batch: raise ValueError( f"{name} has batch shape {value.shape[: sample.ndim]}, " f"expected {expected_batch}." ) for name, value in (("reward", reward), ("terminated", terminated)): if value.shape[sample.ndim :] != torch.Size([1]): raise ValueError( f"{name} must have a singleton event dimension after the " f"batch dimensions, got shape {value.shape}." ) return observation, action, next_observation, reward, terminated def _loss_mask(self, sample: TensorDictBase) -> torch.Tensor | None: """Return the validity mask for the sample, if one is available.""" mask = None for mask_key in self._loss_mask_keys(): candidate = sample.get(mask_key, default=None) if candidate is None: continue if candidate.ndim == sample.ndim + 1 and candidate.shape[-1] == 1: candidate = candidate.squeeze(-1) if candidate.shape != sample.batch_size: raise ValueError( f"Loss mask {mask_key!r} has shape {candidate.shape}, expected " f"{sample.batch_size}." ) candidate = candidate.to(dtype=torch.bool) mask = candidate if mask is None else mask & candidate return mask @staticmethod def _masked_transition_mean( value: torch.Tensor, mask: torch.Tensor | None, ) -> torch.Tensor: """Reduce per-transition values while ignoring invalid positions.""" if mask is None: return value.mean() mask = mask.to(dtype=torch.bool) while mask.ndim < value.ndim: mask = mask.unsqueeze(-1) mask = mask.expand_as(value) return torch.where( mask, value, torch.zeros_like(value) ).sum() / mask.sum().clamp_min(1) def _encode(self, observation: torch.Tensor) -> torch.Tensor: batch_size = torch.Size(observation.shape[:-1]) td = self._make_td( batch_size, [(self.world_model_observation_key, observation)] ) return self.world_model.encode(td).get(self.latent_key) def _policy(self, latent: torch.Tensor) -> TensorDictBase: td = self._make_td( torch.Size(latent.shape[:-1]), [(self.policy_latent_key, latent)] ) return self.policy_prior(td) def _q_logits( self, latent: torch.Tensor, action: torch.Tensor, *, source: Literal["online", "detached", "target"], ) -> torch.Tensor: td = self._make_td( torch.Size(latent.shape[:-1]), [ (self.q_ensemble.in_keys[0], latent), (self.q_ensemble.in_keys[1], action), ], ) return self.q_ensemble(td, source=source).get(self.q_logits_key) def _q_value( self, latent: torch.Tensor, action: torch.Tensor, *, reduction: Literal["min", "avg"], source: Literal["online", "detached", "target"], ) -> torch.Tensor: td = self._make_td( torch.Size(latent.shape[:-1]), [ (self.q_ensemble.in_keys[0], latent), (self.q_ensemble.in_keys[1], action), ], ) return self.q_ensemble.reduce(td, reduction=reduction, source=source).get( self.q_ensemble.q_value_key ) @torch.no_grad() def _td_targets( self, next_latent: torch.Tensor, reward: torch.Tensor, terminated: torch.Tensor, ) -> torch.Tensor: policy_td = self._policy(next_latent) next_action = policy_td.get(self.policy_action_key) next_value = self._q_value( next_latent, next_action, reduction="min", source="target" ) return reward + self.discount * (1 - terminated.to(reward.dtype)) * next_value def _rollout( self, observation: torch.Tensor, action: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: z = self._encode(observation.select(-2, 0)) zs = [z] reward_predictions = [] for index in range(self.horizon): current_action = action.select(-2, index) step_td = self._make_td( torch.Size(z.shape[:-1]), [(self.latent_key, z), (self.world_model_action_key, current_action)], ) step_td = self.world_model.step(step_td) z = step_td.get(self.next_latent_key) zs.append(z) reward_predictions.append(step_td.get(self.next_reward_logits_key)) latent_sequence = torch.stack(zs, dim=-2) reward_predictions = torch.stack(reward_predictions, dim=-2) q_predictions = self._q_logits( latent_sequence[..., :-1, :], action, source="online" ) return latent_sequence, reward_predictions, q_predictions def _model_loss_components( self, sample: TensorDictBase ) -> tuple[torch.Tensor, TensorDictBase]: ( observation, action, next_observation, reward, terminated, ) = self._sequence_tensors(sample) loss_mask = self._loss_mask(sample) with torch.no_grad(): next_latent = self._encode(next_observation) td_targets = self._td_targets(next_latent, reward, terminated) latent_sequence, reward_predictions, q_predictions = self._rollout( observation, action ) consistency_loss = latent_sequence.new_zeros(()) reward_loss = latent_sequence.new_zeros(()) value_loss = latent_sequence.new_zeros(()) rho = latent_sequence.new_tensor(self.rho).pow( torch.arange(self.horizon, device=latent_sequence.device) ) for index in range(self.horizon): transition_mask = None if loss_mask is None else loss_mask[..., index] consistency_per_transition = F.mse_loss( latent_sequence.select(-2, index + 1), next_latent.select(-2, index), reduction="none", ).mean(-1) consistency_loss = ( consistency_loss + self._masked_transition_mean( consistency_per_transition, transition_mask, ) * rho[index] ) reward_per_transition = symlog_two_hot_cross_entropy( reward_predictions.select(-2, index), reward.select(-2, index), self.q_ensemble.vmin, self.q_ensemble.vmax, self.q_ensemble.num_bins, ).squeeze(-1) reward_loss = ( reward_loss + self._masked_transition_mean( reward_per_transition, transition_mask, ) * rho[index] ) value_target = ( td_targets.select(-2, index) .unsqueeze(-2) .expand( *td_targets.select(-2, index).shape[:-1], self.q_ensemble.num_q, td_targets.shape[-1], ) ) value_per_transition = ( symlog_two_hot_cross_entropy( q_predictions.select(-3, index), value_target, self.q_ensemble.vmin, self.q_ensemble.vmax, self.q_ensemble.num_bins, ) .squeeze(-1) .mean(-1) ) value_loss = ( value_loss + self._masked_transition_mean( value_per_transition, transition_mask, ) * self.q_ensemble.num_q * rho[index] ) consistency_loss = consistency_loss / self.horizon reward_loss = reward_loss / self.horizon value_loss = value_loss / (self.horizon * self.q_ensemble.num_q) total_loss = ( self.consistency_coef * consistency_loss + self.reward_coef * reward_loss + self.value_coef * value_loss ) metadata = TensorDict( { "loss_consistency": consistency_loss, "loss_reward": reward_loss, "loss_value": value_loss, "actor_latents": latent_sequence.detach(), }, batch_size=[], ) return total_loss, metadata
[docs] def model_loss(self, sample: TensorDictBase) -> tuple[torch.Tensor, TensorDictBase]: """Compute the model, reward, value, and consistency objectives. Args: sample: Canonical batch-major transition sequence with final batch dimension equal to ``horizon``. Returns: The weighted model loss and detached latent/model metadata for the subsequent actor phase. """ return self._model_loss_components(sample)
def _actor_loss_from_latents( self, latent_sequence: torch.Tensor ) -> tuple[torch.Tensor, TensorDictBase]: policy_td = self._policy(latent_sequence) action = policy_td.get(self.policy_action_key) q_value = self._q_value( latent_sequence, action, reduction="avg", source="detached" ) self.scale.update(q_value.select(-2, 0).reshape(-1, q_value.shape[-1])) normalized_q = self.scale(q_value) entropy = policy_td.get(self.policy_entropy_key) scaled_entropy = policy_td.get(self.policy_scaled_entropy_key) temporal_rho = latent_sequence.new_tensor(self.rho).pow( torch.arange(latent_sequence.shape[-2], device=latent_sequence.device) ) actor_loss = -(self.entropy_coef * scaled_entropy + normalized_q) temporal_rho = temporal_rho.reshape((1,) * (actor_loss.ndim - 2) + (-1, 1)) actor_loss = (actor_loss * temporal_rho).mean() metadata = TensorDict( { "pi_entropy": entropy.detach(), "pi_scaled_entropy": scaled_entropy.detach(), "pi_scale": self.scale.value.detach().clone(), }, batch_size=[], ) return actor_loss, metadata
[docs] def actor_loss_from_latents( self, latent_sequence: torch.Tensor ) -> tuple[torch.Tensor, TensorDictBase]: """Compute the actor objective from detached imagined latents.""" return self._actor_loss_from_latents(latent_sequence.detach())
[docs] @dispatch def forward(self, tensordict: TensorDictBase) -> TensorDictBase: """Return the independent weighted model-loss components.""" _, metadata = self._model_loss_components(tensordict) return TensorDict( { "loss_consistency": self.consistency_coef * metadata.get("loss_consistency"), "loss_reward": self.reward_coef * metadata.get("loss_reward"), "loss_value": self.value_coef * metadata.get("loss_value"), }, batch_size=[], )