# 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
import math
from collections.abc import Sequence
from copy import deepcopy
from numbers import Real
from typing import Literal
import torch
import torch.nn as nn
import torch.nn.functional as F
from tensordict import from_modules, NestedKey, TensorDictBase
from tensordict.nn import TensorDictModuleBase, TensorDictParams
from ..functional import symexp
[docs]
class SimplicialNormalization(nn.Module):
"""Apply softmax independently to fixed-size feature simplices.
Args:
dim (int): Number of features in each simplex. The last input
dimension must be divisible by ``dim``.
"""
def __init__(self, dim: int):
super().__init__()
if isinstance(dim, bool) or not isinstance(dim, int) or dim <= 0:
raise ValueError(f"dim must be a positive integer, got {dim!r}.")
self.dim = dim
[docs]
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Normalize the last dimension of ``x`` in groups of ``dim``."""
if x.shape[-1] % self.dim:
raise ValueError(
f"The last input dimension must be divisible by dim={self.dim}, "
f"got {x.shape[-1]}."
)
shape = x.shape
x = x.reshape(*shape[:-1], -1, self.dim)
x = F.softmax(x, dim=-1)
return x.reshape(shape)
def log_std(
x: torch.Tensor,
low: torch.Tensor | float,
dif: torch.Tensor | float,
) -> torch.Tensor:
"""Map unconstrained values to a bounded log-standard deviation.
Args:
x (torch.Tensor): Unconstrained values.
low (torch.Tensor or float): Lower bound of the output interval.
dif (torch.Tensor or float): Width of the output interval.
Returns:
A tensor with values in ``[low, low + dif]``.
"""
return low + 0.5 * dif * (torch.tanh(x) + 1)
def gaussian_logprob(eps: torch.Tensor, log_std: torch.Tensor) -> torch.Tensor:
"""Compute the log probability of diagonal Gaussian samples.
Args:
eps (torch.Tensor): Gaussian noise samples.
log_std (torch.Tensor): Log standard deviations for each action.
Returns:
The summed log probability with a trailing singleton event dimension.
"""
return (-0.5 * eps.pow(2) - log_std - 0.9189385175704956).sum(-1, keepdim=True)
def _squash(
mu: torch.Tensor,
pi: torch.Tensor,
log_pi: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Squash policy locations and samples while correcting log probability."""
mu = torch.tanh(mu)
pi = torch.tanh(pi)
squashed_pi = torch.log(F.relu(1 - pi.pow(2)) + 1e-6)
log_pi = log_pi - squashed_pi.sum(-1, keepdim=True)
return mu, pi, log_pi
class _TdMpc2PolicyPrior(nn.Module):
"""Sample actions from the TD-MPC2 policy prior."""
def __init__(
self,
network: nn.Module,
log_std_min: float,
log_std_max: float,
device: torch.device | str | None = None,
):
super().__init__()
self.network = network
self.register_buffer("log_std_min", torch.tensor(log_std_min, device=device))
self.register_buffer(
"log_std_dif",
torch.tensor(log_std_max, device=device) - self.log_std_min,
)
def forward(
self, latent: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
mean, raw_log_std = self.network(latent).chunk(2, dim=-1)
bounded_log_std = log_std(raw_log_std, self.log_std_min, self.log_std_dif)
eps = torch.randn_like(mean)
log_prob = gaussian_logprob(eps, bounded_log_std)
scaled_log_prob = log_prob * eps.shape[-1]
action = mean + eps * bounded_log_std.exp()
mean, action, log_prob = _squash(mean, action, log_prob)
entropy = -log_prob
scaled_entropy = -scaled_log_prob
return (
action,
mean,
bounded_log_std,
entropy,
scaled_entropy,
)
def symlog_two_hot_decode(
logits: torch.Tensor,
vmin: float,
vmax: float,
num_bins: int,
) -> torch.Tensor:
"""Decode TD-MPC2 categorical logits into scalar values.
Args:
logits (torch.Tensor): Unnormalized categorical predictions with
categories in the final dimension.
vmin (float): Lower endpoint of the symlog-space categorical support.
vmax (float): Upper endpoint of the symlog-space categorical support.
num_bins (int): Number of categorical support points.
Returns:
Decoded scalar values with a trailing singleton event dimension.
"""
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.")
support = torch.linspace(
vmin,
vmax,
num_bins,
dtype=logits.dtype,
device=logits.device,
)
value = (torch.softmax(logits, dim=-1) * support).sum(-1, keepdim=True)
return symexp(value)
def _normalize_tdmpc2_key(key: NestedKey | list[str]) -> NestedKey:
if isinstance(key, list):
return tuple(key)
return key
[docs]
class TdMpc2QEnsemble(TensorDictModuleBase):
"""Vectorized TD-MPC2 ensemble of distributional Q-functions.
Each Q-function receives the concatenation of a latent state and an action
and returns logits for a scalar categorical representation. The ensemble
dimension is exposed immediately before the category dimension.
:meth:`forward` returns the logits of every Q-function. :meth:`reduce`
follows TD-MPC2's value-estimation rule by selecting two Q-functions at
random, decoding their logits, and taking either their minimum or average.
Online, detached, and target parameter sources are available for both
operations.
Args:
q_networks: Sequence of identically shaped single-network Q-functions.
num_bins: Number of categorical bins. Must be greater than 1.
vmin: Minimum value of the symlog-space categorical support.
vmax: Maximum value of the symlog-space categorical support.
in_keys: Two TensorDict keys for the latent state and action.
Defaults to ``["latent", "action"]``.
out_keys: One TensorDict key for the ensemble logits. Defaults to
``["q_logits"]``.
q_value_key: TensorDict key written by :meth:`reduce`. Defaults to
``"q_value"``.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.modules import MLP
>>> from torchrl.modules.models.tdmpc2 import TdMpc2QEnsemble
>>> q_networks = [
... MLP(in_features=6, out_features=5, depth=2, num_cells=8)
... for _ in range(5)
... ]
>>> q_ensemble = TdMpc2QEnsemble(
... q_networks, num_bins=5, vmin=-10.0, vmax=10.0
... )
>>> data = TensorDict(
... {"latent": torch.randn(4, 4), "action": torch.randn(4, 2)},
... batch_size=[4],
... )
>>> data = q_ensemble(data)
>>> data["q_logits"].shape
torch.Size([4, 5, 5])
>>> q_ensemble.reduce(data, reduction="min")["q_value"].shape
torch.Size([4, 1])
"""
def __init__(
self,
q_networks: Sequence[nn.Module],
*,
num_bins: int,
vmin: float,
vmax: float,
in_keys: Sequence[NestedKey] | None = None,
out_keys: Sequence[NestedKey] | None = None,
q_value_key: NestedKey = "q_value",
) -> None:
super().__init__()
q_networks = tuple(q_networks)
if len(q_networks) < 2:
raise ValueError("TdMpc2QEnsemble requires at least two Q-networks.")
if num_bins <= 1:
raise ValueError("num_bins must be greater than 1.")
if vmax <= vmin:
raise ValueError("vmax must be greater than vmin.")
self.in_keys = (
["latent", "action"]
if in_keys is None
else [_normalize_tdmpc2_key(key) for key in in_keys]
)
self.out_keys = (
["q_logits"]
if out_keys is None
else [_normalize_tdmpc2_key(key) for key in out_keys]
)
if len(self.in_keys) != 2:
raise ValueError("TdMpc2QEnsemble requires exactly two input keys.")
if len(self.out_keys) != 1:
raise ValueError("TdMpc2QEnsemble requires exactly one output key.")
if len(set(self.in_keys)) != 2:
raise ValueError("TdMpc2QEnsemble input keys must be distinct.")
if self.out_keys[0] in self.in_keys:
raise ValueError("The output key must be distinct from the input keys.")
if isinstance(q_value_key, list):
q_value_key = _normalize_tdmpc2_key(q_value_key)
if len(q_value_key) == 1:
q_value_key = q_value_key[0]
if q_value_key in self.in_keys or q_value_key in self.out_keys:
raise ValueError(
"q_value_key must be distinct from the input and output keys."
)
self.num_q = len(q_networks)
self.num_bins = num_bins
self.vmin = vmin
self.vmax = vmax
self.q_value_key = q_value_key
self.q_params = from_modules(*q_networks, as_module=True)
self.target_q_params = TensorDictParams(
self.q_params.data.clone(), no_convert=True
)
with self.q_params[0].data.to("meta").to_module(q_networks[0]):
q_template = deepcopy(q_networks[0])
target_q_template = deepcopy(q_networks[0])
self.__dict__["_q_template"] = q_template
self.__dict__["_target_q_template"] = target_q_template
self._q_template.train(self.training)
self._target_q_template.train(False)
@staticmethod
def _call(
params: TensorDictBase,
module: nn.Module,
inputs: torch.Tensor,
) -> torch.Tensor:
with params.to_module(module, preserve_module_state=False):
return module(inputs)
def _source(
self, source: Literal["online", "detached", "target"]
) -> tuple[TensorDictBase, nn.Module]:
if source == "online":
return self.q_params, self._q_template
if source == "detached":
return self.q_params.detach(), self._q_template
if source == "target":
return self.target_q_params, self._target_q_template
raise ValueError(
f"source must be one of 'online', 'detached', or 'target', got {source!r}."
)
def _q_logits(
self,
latent: torch.Tensor,
action: torch.Tensor,
source: Literal["online", "detached", "target"],
) -> torch.Tensor:
params, module = self._source(source)
inputs = torch.cat((latent, action), dim=-1)
return torch.vmap(
self._call,
in_dims=(0, None, None),
randomness="different",
)(params, module, inputs)
def _inputs(self, tensordict: TensorDictBase) -> tuple[torch.Tensor, torch.Tensor]:
values = tuple(tensordict.get(key) for key in self.in_keys)
if any(value is None for value in values):
missing = [key for key, value in zip(self.in_keys, values) if value is None]
raise KeyError(f"Missing TD-MPC2 Q ensemble input keys: {missing}")
return values[0], values[1]
[docs]
def forward(
self,
tensordict: TensorDictBase,
*,
source: Literal["online", "detached", "target"] = "online",
) -> TensorDictBase:
"""Write the logits of all Q-functions to the input TensorDict.
Args:
tensordict: TensorDict containing the latent state and action.
source: Parameter source used for the Q-functions. Defaults to
``"online"``.
"""
latent, action = self._inputs(tensordict)
logits = self._q_logits(latent, action, source)
tensordict.set(self.out_keys_source[0], logits.movedim(0, -2))
return tensordict
[docs]
def reduce(
self,
tensordict: TensorDictBase,
*,
reduction: Literal["min", "avg"],
source: Literal["online", "detached", "target"] = "online",
) -> TensorDictBase:
"""Write a TD-MPC2 two-Q value reduction to the input TensorDict.
Args:
tensordict: TensorDict containing the latent state and action.
reduction: Either ``"min"`` or ``"avg"`` for the two sampled
Q-functions.
source: Parameter source used for the Q-functions. Defaults to
``"online"``.
"""
if reduction not in {"min", "avg"}:
raise ValueError(
f"reduction must be either 'min' or 'avg', got {reduction!r}."
)
latent, action = self._inputs(tensordict)
logits = self._q_logits(latent, action, source)
qidx = torch.randperm(self.num_q, device=logits.device)[:2]
values = symlog_two_hot_decode(
logits[qidx],
self.vmin,
self.vmax,
self.num_bins,
)
if reduction == "min":
q_value = values.min(dim=0).values
else:
q_value = values.sum(dim=0) / 2
tensordict.set(self.q_value_key, q_value)
return tensordict
[docs]
@torch.no_grad()
def soft_update_target(self, tau: float) -> None:
"""Update target Q-function buffers by Polyak averaging.
Args:
tau: Interpolation factor in ``[0, 1]``. A value of ``0`` keeps
the target unchanged and a value of ``1`` copies the online
parameters.
"""
if not isinstance(tau, Real) or not math.isfinite(tau) or not 0 <= tau <= 1:
raise ValueError(f"tau must be a finite value in [0, 1], got {tau!r}.")
for key, target in self.target_q_params.data.items(True, True):
source = self.q_params.data.get(key)
if torch.is_floating_point(target) or torch.is_complex(target):
target.lerp_(source, tau)
else:
target.copy_(source)
[docs]
def train(self, mode: bool = True):
"""Set training mode while keeping the target Q-functions in eval mode."""
super().train(mode)
self._q_template.train(mode)
self._target_q_template.train(False)
return self