Rate this Page

Source code for torchrl.objectives.value.advantages

# 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 abc
import functools
import warnings
from collections.abc import Callable
from contextlib import nullcontext
from copy import deepcopy
from dataclasses import asdict, dataclass
from functools import wraps
from typing import Any

import torch
from tensordict import is_tensor_collection, TensorDictBase
from tensordict.nn import (
    composite_lp_aggregate,
    dispatch,
    InteractionType,
    ProbabilisticTensorDictModule,
    set_composite_lp_aggregate,
    set_skip_existing,
    TensorDictModule,
    TensorDictModuleBase,
)
from tensordict.nn.probabilistic import interaction_type
from tensordict.utils import NestedKey, unravel_key
from torch import Tensor

from torchrl._utils import logger, rl_warnings
from torchrl.envs.utils import step_mdp
from torchrl.modules.distributions.utils import sample_and_log_prob
from torchrl.objectives.utils import (
    _maybe_get_or_select,
    _pseudo_vmap,
    _vmap_func,
    hold_out_net,
    register_value_estimator,
    ValueEstimators,
)
from torchrl.objectives.value.functional import (
    generalized_advantage_estimate,
    td0_return_estimate,
    td_lambda_return_estimate,
    vec_generalized_advantage_estimate,
    vec_td1_return_estimate,
    vec_td_lambda_return_estimate,
    vtrace_advantage_estimate,
)

try:
    from torch.compiler import is_dynamo_compiling
except ImportError:
    from torch._dynamo import is_compiling as is_dynamo_compiling

try:
    from torch import vmap
except ImportError as err:
    try:
        from functorch import vmap
    except ImportError:
        raise ImportError(
            "vmap couldn't be found. Make sure you have torch>2.0 installed."
        ) from err


def _standardize_by_group(
    x: Tensor,
    groups: Tensor,
    mask: Tensor | None = None,
    eps: float = 1e-4,
) -> Tensor:
    """Standardize ``x`` to zero mean and unit variance within each group.

    ``groups`` is an integer tensor whose leading dimensions match ``x`` (a
    trailing singleton dimension, as in ``(*batch, 1)``, is accepted); ``mask``
    selects the entries that count and is broadcastable to ``x``. Groups are
    the distinct values of ``groups``, so the reduction is data dependent and
    not traceable by :func:`torch.compile`.
    """
    while groups.ndim < x.ndim:
        groups = groups.unsqueeze(-1)
    if groups.ndim > x.ndim:
        raise ValueError(
            f"group ids of shape {tuple(groups.shape)} cannot index values of shape "
            f"{tuple(x.shape)}."
        )
    groups = groups.expand(x.shape).reshape(-1)
    flat = x.reshape(-1)
    if mask is None:
        weight = torch.ones_like(flat)
        flat_for_stats = flat
    else:
        flat_mask = mask.expand(x.shape).reshape(-1).bool()
        weight = flat_mask.to(flat.dtype)
        flat_for_stats = flat.masked_fill(~flat_mask, 0)
    _, index = torch.unique(groups, return_inverse=True)
    count = torch.zeros(
        int(index.max()) + 1, dtype=flat.dtype, device=flat.device
    ).index_add_(0, index, weight)
    mean = torch.zeros_like(count).index_add_(
        0, index, flat_for_stats
    ) / count.clamp_min(1)
    centered = flat - mean[index]
    if mask is not None:
        centered_for_stats = centered.masked_fill(~flat_mask, 0)
    else:
        centered_for_stats = centered
    # Unbiased variance, like ``Tensor.std`` in the global standardization.
    variance = torch.zeros_like(count).index_add_(
        0, index, centered_for_stats.square()
    ) / (count - 1).clamp_min(1)
    return (centered / variance.sqrt().clamp_min(eps)[index]).reshape(x.shape)


def _self_set_grad_enabled(fun):
    @wraps(fun)
    def new_fun(self, *args, **kwargs):
        with torch.set_grad_enabled(self.differentiable):
            return fun(self, *args, **kwargs)

    return new_fun


def _self_set_skip_existing(fun):
    @functools.wraps(fun)
    def new_func(self, *args, **kwargs):
        if self.skip_existing is not None:
            with set_skip_existing(self.skip_existing):
                return fun(self, *args, **kwargs)
        return fun(self, *args, **kwargs)

    return new_func


def _call_actor_net(
    actor_net: ProbabilisticTensorDictModule,
    data: TensorDictBase,
    params: TensorDictBase,
    log_prob_key: NestedKey,
):
    dist = actor_net.get_dist(data.select(*actor_net.in_keys, strict=False))
    current_interaction = interaction_type() or actor_net.default_interaction_type
    if current_interaction is InteractionType.RANDOM:
        sample_shape = actor_net.num_samples or torch.Size()
        with set_composite_lp_aggregate(True):
            _, log_prob = sample_and_log_prob(
                dist, sample_shape, reparameterize=dist.has_rsample
            )
        return log_prob

    s = actor_net._dist_sample(dist, interaction_type=current_interaction)
    with set_composite_lp_aggregate(True):
        return dist.log_prob(s)


[docs] class ValueEstimatorBase(TensorDictModuleBase): """An abstract parent class for value function modules. Its :meth:`ValueFunctionBase.forward` method will compute the value (given by the value network) and the value estimate (given by the value estimator) as well as the advantage and write these values in the output tensordict. If only the value estimate is needed, the :meth:`ValueFunctionBase.value_estimate` should be used instead. Keyword Args: value_chunk_size (int, optional): if set, splits value-network calls into chunks of this many elements along ``value_chunk_dim``. Defaults to ``None``. num_chunks (int, optional): if set, splits value-network calls into this many chunks along ``value_chunk_dim``. Mutually exclusive with ``value_chunk_size``. ``num_chunk`` is accepted as an alias. Defaults to ``None``. num_chunk (int, optional): alias for ``num_chunks``. Cannot be set together with a different ``num_chunks`` value. Defaults to ``None``. value_chunk_dim (int, optional): dimension used for chunked value-network calls. Defaults to ``0`` to preserve historical behavior. shifted_budget (int, optional): number of extra value-network time slots used when ``shifted=True``. ``1`` uses a ``T+1`` budget, ``2`` can represent one internal reset plus the rollout boundary without dropping samples, and so on. Defaults to ``1``. """
[docs] @classmethod def for_loss(cls, loss_module, **hyperparams): """Construct an instance configured against ``loss_module``. Used by the value-estimator registry (:func:`~torchrl.objectives.utils.build_value_estimator`) to keep per-estimator wiring quirks out of every loss class. The default implementation picks up ``loss_module.critic_network`` if present, falling back to ``loss_module.value_network``, and forwards the remaining ``hyperparams`` to the constructor. A loss that owns a value module under a non-standard name can pass ``value_network=<the module>`` through :func:`~torchrl.objectives.utils.dispatch_value_estimator` — it wins over the auto-detected one. Estimator subclasses with additional dependencies (e.g. :class:`VTrace` needing the actor) override this method. """ if "value_network" not in hyperparams: value_network = getattr(loss_module, "critic_network", None) if value_network is None: value_network = getattr(loss_module, "value_network", None) hyperparams["value_network"] = value_network return cls(**hyperparams)
@dataclass class _AcceptedKeys: """Maintains default values for all configurable tensordict keys. This class defines which tensordict keys can be set using '.set_keys(key_name=key_value)' and their default values. Attributes: advantage (NestedKey): The input tensordict key where the advantage is written to. Will be used for the underlying value estimator. Defaults to ``"advantage"``. value_target (NestedKey): The input tensordict key where the target state value is written to. Will be used for the underlying value estimator Defaults to ``"value_target"``. value (NestedKey): The input tensordict key where the state value is expected. Will be used for the underlying value estimator. Defaults to ``"state_value"``. reward (NestedKey): The input tensordict key where the reward is written to. Defaults to ``"reward"``. done (NestedKey): The key in the input TensorDict that indicates whether a trajectory is done. Defaults to ``"done"``. terminated (NestedKey): The key in the input TensorDict that indicates whether a trajectory is terminated. Defaults to ``"terminated"``. steps_to_next_obs (NestedKey): The key in the input tensordict that indicates the number of steps to the next observation. Defaults to ``"steps_to_next_obs"``. sample_log_prob (NestedKey): The key in the input tensordict that indicates the log probability of the sampled action. Defaults to ``"sample_log_prob"`` when :func:`~tensordict.nn.composite_lp_aggregate` returns `True`, `"action_log_prob"` otherwise. """ advantage: NestedKey = "advantage" value_target: NestedKey = "value_target" value: NestedKey = "state_value" reward: NestedKey = "reward" done: NestedKey = "done" terminated: NestedKey = "terminated" steps_to_next_obs: NestedKey = "steps_to_next_obs" sample_log_prob: NestedKey | None = None def __post_init__(self): if self.sample_log_prob is None: if composite_lp_aggregate(nowarn=True): self.sample_log_prob = "sample_log_prob" else: self.sample_log_prob = "action_log_prob" default_keys = _AcceptedKeys tensor_keys: _AcceptedKeys value_network: TensorDictModule | Callable _vmap_randomness = None deactivate_vmap: bool = False @property def advantage_key(self): return self.tensor_keys.advantage @property def value_key(self): return self.tensor_keys.value @property def value_target_key(self): return self.tensor_keys.value_target @property def reward_key(self): return self.tensor_keys.reward @property def done_key(self): return self.tensor_keys.done @property def terminated_key(self): return self.tensor_keys.terminated @property def steps_to_next_obs_key(self): return self.tensor_keys.steps_to_next_obs @property def sample_log_prob_key(self): return self.tensor_keys.sample_log_prob
[docs] @abc.abstractmethod def forward( self, tensordict: TensorDictBase, *, params: TensorDictBase | None = None, target_params: TensorDictBase | None = None, ) -> TensorDictBase: """Computes the advantage estimate given the data in tensordict. If a functional module is provided, a nested TensorDict containing the parameters (and if relevant the target parameters) can be passed to the module. Args: tensordict (TensorDictBase): A TensorDict containing the data (an observation key, ``"action"``, ``("next", "reward")``, ``("next", "done")``, ``("next", "terminated")``, and ``"next"`` tensordict state as returned by the environment) necessary to compute the value estimates and the TDEstimate. The data passed to this module should be structured as :obj:`[*B, T, *F]` where :obj:`B` are the batch size, :obj:`T` the time dimension and :obj:`F` the feature dimension(s). The tensordict must have shape ``[*B, T]``. Keyword Args: params (TensorDictBase, optional): A nested TensorDict containing the params to be passed to the functional value network module. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. device (torch.device, optional): the device where the buffers will be instantiated. Defaults to ``torch.get_default_device()``. Returns: An updated TensorDict with an advantage and a value_error keys as defined in the constructor. """ ...
def __init__( self, *, value_network: TensorDictModule, shifted: bool = False, differentiable: bool = False, skip_existing: bool | None = None, advantage_key: NestedKey = None, value_target_key: NestedKey = None, value_key: NestedKey = None, device: torch.device | None = None, deactivate_vmap: bool = False, value_chunk_size: int | None = None, num_chunks: int | None = None, num_chunk: int | None = None, value_chunk_dim: int = 0, shifted_budget: int = 1, group_key: NestedKey | None = None, ): super().__init__() self.group_key = group_key if device is None: device = getattr(torch, "get_default_device", lambda: torch.device("cpu"))() # this is saved for tracking only and should not be used to cast anything else than buffers during # init. self._device = device self._tensor_keys = None self.differentiable = differentiable self.deactivate_vmap = deactivate_vmap self.value_chunk_size = self._check_positive_int( value_chunk_size, "value_chunk_size" ) self.num_chunks = self._resolve_num_chunks(num_chunks, num_chunk) self.value_chunk_dim = self._check_int(value_chunk_dim, "value_chunk_dim") if self.value_chunk_size is not None and self.num_chunks is not None: raise ValueError( "value_chunk_size and num_chunks cannot both be set. " "Use value_chunk_size to specify a chunk size or num_chunks " "to specify a number of chunks." ) if shifted_budget < 1: raise ValueError(f"shifted_budget must be >= 1, got {shifted_budget}.") self.shifted_budget = shifted_budget self.skip_existing = skip_existing self.__dict__["value_network"] = value_network self.dep_keys = {} self.shifted = self._normalize_shifted(shifted) if advantage_key is not None: raise RuntimeError( "Setting 'advantage_key' via constructor is deprecated, use .set_keys(advantage_key='some_key') instead.", ) if value_target_key is not None: raise RuntimeError( "Setting 'value_target_key' via constructor is deprecated, use .set_keys(value_target_key='some_key') instead.", ) if value_key is not None: raise RuntimeError( "Setting 'value_key' via constructor is deprecated, use .set_keys(value_key='some_key') instead.", ) @staticmethod def _check_positive_int(value: int | None, name: str) -> int | None: if value is None: return None if isinstance(value, bool) or not isinstance(value, int): raise TypeError(f"{name} must be an integer or None, got {type(value)}.") if value < 1: raise ValueError(f"{name} must be >= 1, got {value}.") return value @staticmethod def _check_int(value: int, name: str) -> int: if isinstance(value, bool) or not isinstance(value, int): raise TypeError(f"{name} must be an integer, got {type(value)}.") return value def _resolve_value_chunk_dim(self, ndim: int) -> int: if ndim < 1: raise ValueError("Cannot split value-network inputs with ndim < 1.") if self.value_chunk_dim < -ndim or self.value_chunk_dim >= ndim: raise ValueError( f"value_chunk_dim={self.value_chunk_dim} is out of bounds for ndim={ndim}." ) return self.value_chunk_dim % ndim @classmethod def _resolve_num_chunks( cls, num_chunks: int | None, num_chunk: int | None ) -> int | None: num_chunks = cls._check_positive_int(num_chunks, "num_chunks") num_chunk = cls._check_positive_int(num_chunk, "num_chunk") if num_chunk is None: return num_chunks if num_chunks is not None and num_chunks != num_chunk: raise ValueError( "num_chunks and num_chunk cannot both be set to different values." ) return num_chunk def _split_value_net_input( self, data: TensorDictBase ) -> tuple[TensorDictBase, ...]: chunk_dim = self._resolve_value_chunk_dim(data.ndim) if self.num_chunks is not None: dim_size = data.shape[chunk_dim] if dim_size == 0: return (data,) num_chunks = min(self.num_chunks, dim_size) if num_chunks == 1: return (data,) return tuple(data.tensor_split(num_chunks, dim=chunk_dim)) chunk_size = self.value_chunk_size if chunk_size is None or data.shape[chunk_dim] <= chunk_size: return (data,) return tuple(data.split(chunk_size, dim=chunk_dim)) @property def tensor_keys(self) -> _AcceptedKeys: if self._tensor_keys is None: self.set_keys() return self._tensor_keys @tensor_keys.setter def tensor_keys(self, value): if not isinstance(value, type(self._AcceptedKeys)): raise ValueError("value must be an instance of _AcceptedKeys") self._keys = value @property def in_keys(self): try: in_keys = ( self.value_network.in_keys + [ ("next", self.tensor_keys.reward), ("next", self.tensor_keys.done), ("next", self.tensor_keys.terminated), ] + [("next", in_key) for in_key in self.value_network.in_keys] ) except AttributeError: # value network does not have an `in_keys` attribute in_keys = [] if self.group_key is not None and self.group_key not in in_keys: in_keys.append(self.group_key) return in_keys @property def out_keys(self): return [ self.tensor_keys.advantage, self.tensor_keys.value_target, ]
[docs] def set_keys(self, **kwargs) -> None: """Set tensordict key names.""" for key, value in list(kwargs.items()): if isinstance(value, list): value = [unravel_key(k) for k in value] elif not isinstance(value, (str, tuple)): if value is None: raise ValueError("tensordict keys cannot be None") raise ValueError( f"key name must be of type NestedKey (Union[str, Tuple[str]]) but got {type(value)}" ) else: value = unravel_key(value) if key not in self._AcceptedKeys.__dataclass_fields__: raise KeyError( f"{key} is not an accepted tensordict key for advantages" ) if ( key == "value" and hasattr(self.value_network, "out_keys") and (value not in self.value_network.out_keys) ): raise KeyError( f"value key '{value}' not found in value network out_keys {self.value_network.out_keys}" ) kwargs[key] = value if self._tensor_keys is None: conf = asdict(self.default_keys()) conf.update(self.dep_keys) else: conf = asdict(self._tensor_keys) conf.update(kwargs) self._tensor_keys = self._AcceptedKeys(**conf)
[docs] def value_estimate( self, tensordict, target_params: TensorDictBase | None = None, next_value: torch.Tensor | None = None, **kwargs, ): """Gets a value estimate, usually used as a target value for the value network. If the state value key is present under ``tensordict.get(("next", self.tensor_keys.value))`` then this value will be used without recurring to the value network. Args: tensordict (TensorDictBase): the tensordict containing the data to read. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. next_value (torch.Tensor, optional): the value of the next state or state-action pair. Exclusive with ``target_params``. **kwargs: the keyword arguments to be passed to the value network. Returns: a tensor corresponding to the state value. """ raise NotImplementedError
@property def is_functional(self): return False @property def is_stateless(self): return False def _next_value(self, tensordict, target_params, kwargs): step_td = step_mdp(tensordict, keep_other=False) if self.value_network is not None: with hold_out_net( self.value_network ) if target_params is None else target_params.to_module( self.value_network, preserve_module_state=False ): self.value_network(step_td) next_value = step_td.get(self.tensor_keys.value) return next_value @property def vmap_randomness(self): if self._vmap_randomness is None: if is_dynamo_compiling(): self._vmap_randomness = "different" return "different" do_break = False for val in self.__dict__.values(): if isinstance(val, torch.nn.Module): import torchrl.objectives.utils for module in val.modules(): if isinstance( module, torchrl.objectives.utils.RANDOM_MODULE_LIST ): self._vmap_randomness = "different" do_break = True break if do_break: # double break break else: self._vmap_randomness = "error" return self._vmap_randomness def set_vmap_randomness(self, value): self._vmap_randomness = value def _get_time_dim(self, time_dim: int | None, data: TensorDictBase): if time_dim is not None: if time_dim < 0: time_dim = data.ndim + time_dim return time_dim time_dim_attr = getattr(self, "time_dim", None) if time_dim_attr is not None: if time_dim_attr < 0: time_dim_attr = data.ndim + time_dim_attr return time_dim_attr if data._has_names(): for i, name in enumerate(data.names): if name == "time": return i return data.ndim - 1 @staticmethod def _sanitize_next_obs_nan( data: TensorDictBase, in_keys: list[NestedKey], ) -> TensorDictBase: """Replace ``NaN`` entries in ``("next", k)`` with the corresponding root ``k``. Acts as a finite placeholder for "next observations" that are absent — as produced by :class:`~torchrl.envs.transforms.NextStateReconstructor` at trajectory ends in conjunction with :class:`~torchrl.collectors.Collector` configured with ``compact_obs=True``. Without this step, ``V(NaN) = NaN`` propagates through the TD / GAE kernels (the multiplication by ``(1 - done)`` does not zero NaN out because ``0 * NaN = NaN`` in IEEE 754). Semantics of the substitution: - At **terminated** steps the value at the next observation is masked out by ``(1 - done)`` downstream, so the substitute is discarded. - At **truncated-only** steps the substitute acts as an approximate bootstrap ``V(obs[t]) ≈ V(real_next_obs)`` — strictly an approximation, but finite and well-defined. Operates on a shallow copy so the caller's ``tensordict`` is not mutated. .. seealso:: :class:`~torchrl.collectors.Collector` (``compact_obs``) and :class:`~torchrl.envs.transforms.NextStateReconstructor` are the typical producers of ``NaN`` next-observations at trajectory ends. :class:`~torchrl.envs.transforms.NextObservationDelta` is an alternative env-side compression that *avoids* the ``NaN``-at-boundary case (at the cost of a smaller memory saving). The *Memory-efficient RL training* tutorial wires the knobs together end-to-end. """ copied = False for k in in_keys: root = data.get(k, default=None) if root is None or not root.is_floating_point(): continue next_k = ("next", *k) if isinstance(k, tuple) else ("next", k) nxt = data.get(next_k, default=None) if nxt is None or nxt.shape != root.shape: continue nan_mask = torch.isnan(nxt) if not nan_mask.any(): continue if not copied: data = data.copy() copied = True data.set(next_k, torch.where(nan_mask, root, nxt)) return data @staticmethod def _fill_missing_next_inputs( next_data: TensorDictBase, root_data: TensorDictBase, in_keys: list[NestedKey] ) -> TensorDictBase: copied = False for key in in_keys: if next_data.get(key, default=None) is not None: continue value = root_data.get(key, default=None) if value is None: continue if not copied: next_data = next_data.copy() copied = True next_data.set(key, value) return next_data def _group_ids(self, tensordict: TensorDictBase) -> Tensor | None: """Return the group ids under ``group_key``, or ``None`` when it is unset.""" if self.group_key is None: return None groups = tensordict.get(self.group_key, None) if groups is None: raise KeyError( f"group_key {self.group_key!r} is missing from the tensordict; it is " "needed to standardize within groups." ) return groups @staticmethod def _normalize_shifted( shifted: bool, ) -> bool: """Normalize the ``shifted`` argument. ``shifted=True`` uses the budgeted shifted backend. """ if shifted is False: return False if shifted is True: return True raise ValueError(f"shifted must be a boolean, got {shifted!r}.") def _call_value_net_shifted( self, data: TensorDictBase, params: TensorDictBase | None, next_params: TensorDictBase | None, value_key: NestedKey, ndim: int, value_net: TensorDictModuleBase, _call_value_net, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Compact single-call path that inserts reset next-observations. This backend keeps the value-network input length fixed to ``T + 1``. It inserts true ``("next", ...)`` entries after internal reset steps, shifts subsequent root entries to the right, and marks the displaced suffix as invalid. Retained samples have exact root and next values. """ if next_params is not None and next_params is not params: raise ValueError( "the value at t and t+1 cannot be retrieved in a single call when both params and next params are passed." ) in_keys = value_net.in_keys time_idx = ndim - 1 T = data.shape[time_idx] root_part = data.select(*in_keys, value_key, strict=False) next_part = data.get("next").select(*in_keys, value_key, strict=False) next_part = self._fill_missing_next_inputs(next_part, root_part, in_keys) if "is_init" in root_part.keys(): next_part = next_part.copy() next_part["is_init"] = next_part["is_init"] | root_part["is_init"] done = data.get(("next", self.tensor_keys.done)) terminated = data.get(("next", self.tensor_keys.terminated), default=done) if done.shape[-1] == 1: reset = done.squeeze(-1) & ~terminated.squeeze(-1) else: reset = done.any(-1) & ~terminated.any(-1) reset = reset.clone() reset[(slice(None),) * time_idx + (-1,)] = False reset_long = reset.to(torch.long) reset_cs = reset_long.cumsum(time_idx) zero_shape = list(reset.shape) zero_shape[time_idx] = 1 reset_before = torch.cat( [reset_cs.new_zeros(zero_shape), reset_cs.narrow(time_idx, 0, T - 1)], dim=time_idx, ) arange_shape = [1] * reset.ndim arange_shape[time_idx] = T arange = torch.arange(T, device=done.device).view(arange_shape) root_slot = arange + reset_before next_slot = root_slot + 1 L = T + self.shifted_budget valid = next_slot < L root_valid = root_slot < L reset_valid = reset & valid last_valid = self._shifted_last_valid(valid, time_idx) next_insert_valid = reset_valid | last_valid boundary_slot = T + reset_long.sum(time_idx, keepdim=True) boundary_valid = boundary_slot < L data_in_batch_size = list(root_part.batch_size) data_in_batch_size[time_idx] = L data_in = root_part.new_zeros(data_in_batch_size) def _expand_index(index: torch.Tensor, source: torch.Tensor) -> torch.Tensor: while index.ndim < source.ndim: index = index.unsqueeze(-1) return index.expand_as(source) def _expand_mask(mask: torch.Tensor, source: torch.Tensor) -> torch.Tensor: while mask.ndim < source.ndim: mask = mask.unsqueeze(-1) return mask.expand_as(source) def _scatter_time( destination: torch.Tensor, index: torch.Tensor, source: torch.Tensor, mask: torch.Tensor, ) -> torch.Tensor: sentinel_shape = list(destination.shape) sentinel_shape[time_idx] = 1 destination = torch.cat( [destination, destination.new_empty(sentinel_shape)], dim=time_idx ) index = torch.where(mask, index, index.new_full((), L)).clamp_max(L) index_expand = _expand_index(index, source) destination = destination.scatter(time_idx, index_expand, source) return destination.narrow(time_idx, 0, L) boundary_index = (slice(None),) * time_idx + (slice(T - 1, T),) scatter_keys = list(in_keys) if value_key not in scatter_keys: scatter_keys.append(value_key) for key in scatter_keys: root_value = root_part.get(key, default=None) if root_value is None: continue data_value = data_in.get(key, default=None) if data_value is None: continue data_value = _scatter_time(data_value, root_slot, root_value, root_valid) next_value = next_part.get(key, default=None) if next_value is not None: data_value = _scatter_time( data_value, next_slot, next_value, next_insert_valid ) data_value = _scatter_time( data_value, boundary_slot, next_value[boundary_index], boundary_valid, ) data_in.set(key, data_value) if params is not None: with params.to_module(value_net, preserve_module_state=False): values_full = _call_value_net(data_in) else: values_full = _call_value_net(data_in) def _gather_time(source: torch.Tensor, index: torch.Tensor) -> torch.Tensor: index_expand = index.clamp_max(L - 1) while index_expand.ndim < source.ndim: index_expand = index_expand.unsqueeze(-1) target_shape = list(source.shape) target_shape[time_idx] = index.shape[time_idx] index_expand = index_expand.expand(target_shape) return source.gather(time_idx, index_expand) value = _gather_time(values_full, root_slot) value_ = _gather_time(values_full, next_slot) try: value = value.view_as(done) value_ = value_.view_as(done) except RuntimeError: pass return value, value_, valid def _call_value_nets( self, data: TensorDictBase, params: TensorDictBase, next_params: TensorDictBase, single_call: bool, value_key: NestedKey, detach_next: bool, vmap_randomness: str = "error", *, value_net: TensorDictModuleBase | None = None, ): # ``single_call`` is either ``False`` or requests the budgeted shifted path. if value_net is None: value_net = self.value_network in_keys = value_net.in_keys if single_call: try: ndim = list(data.names).index("time") + 1 except ValueError: if rl_warnings(): logger.warning( "Got a tensordict without a time-marked dimension, assuming time is along the last dimension. " "This warning can be turned off by setting the environment variable RL_WARNINGS to False." ) ndim = data.ndim else: ndim = None data = self._sanitize_next_obs_nan(data, in_keys) def _call_value_net(data_in: TensorDictBase) -> torch.Tensor: chunk_dim = self._resolve_value_chunk_dim(data_in.ndim) chunks = self._split_value_net_input(data_in) if len(chunks) == 1: return value_net(data_in).get(value_key) values = [] for chunk in chunks: values.append(value_net(chunk).get(value_key)) return torch.cat(values, dim=chunk_dim) valid = None if single_call: value, value_, valid = self._call_value_net_shifted( data=data, params=params, next_params=next_params, value_key=value_key, ndim=ndim, value_net=value_net, _call_value_net=_call_value_net, ) else: data_root = data.select(*in_keys, value_key, strict=False) data_next = data.get("next").select(*in_keys, value_key, strict=False) if "is_init" in data_root.keys(): # We need to mark the first element of the "next" td as being an init step for RNNs # otherwise, consecutive elements in the sequence will be considered as part of the same # trajectory, even if they're not. data_next["is_init"] = data_next["is_init"] | data_root["is_init"] data_in = torch.stack( [data_root, data_next], 0, ) if (params is not None) ^ (next_params is not None): raise ValueError( "params and next_params must be either both provided or not." ) elif ( params is None and next_params is None and (self.value_chunk_size is not None or self.num_chunks is not None) ): value = _call_value_net(data_root) value_ = _call_value_net(data_next) elif params is not None and ( self.value_chunk_size is not None or self.num_chunks is not None ): chunk_dim = self._resolve_value_chunk_dim(data_root.ndim) params_stack = torch.stack([params, next_params], 0).contiguous() values = [] next_values = [] for root_chunk, next_chunk in zip( self._split_value_net_input(data_root), self._split_value_net_input(data_next), ): data_out = _vmap_func( value_net, (0, 0), randomness=vmap_randomness, pseudo_vmap=self.deactivate_vmap, )(torch.stack([root_chunk, next_chunk], 0), params_stack) value_est = data_out.get(value_key) values.append(value_est[0]) next_values.append(value_est[1]) value = torch.cat(values, dim=chunk_dim) value_ = torch.cat(next_values, dim=chunk_dim) elif params is not None: params_stack = torch.stack([params, next_params], 0).contiguous() data_out = _vmap_func( value_net, (0, 0), randomness=vmap_randomness, pseudo_vmap=self.deactivate_vmap, )(data_in, params_stack) elif not self.deactivate_vmap: data_out = vmap(value_net, (0,), randomness=vmap_randomness)(data_in) else: data_out = _pseudo_vmap(value_net, (0,), randomness=vmap_randomness)( data_in ) if self.value_chunk_size is None and self.num_chunks is None: value_est = data_out.get(value_key) value, value_ = value_est[0], value_est[1] data.set(value_key, value) data.set(("next", value_key), value_) if detach_next: value_ = value_.detach() return value, value_, valid def _shifted_last_valid(self, valid: torch.Tensor, time_dim: int): next_valid = torch.cat( [ valid.narrow(time_dim, 1, valid.shape[time_dim] - 1), valid.new_ones( [ *valid.shape[:time_dim], 1, *valid.shape[time_dim + 1 :], ] ), ], dim=time_dim, ) return valid & ~next_valid @staticmethod def _expand_to_match(source: torch.Tensor, target: torch.Tensor): while source.ndim < target.ndim: source = source.unsqueeze(-1) return source.expand_as(target) def _prepare_shifted_tensordict( self, tensordict: TensorDictBase, valid: torch.Tensor | None, time_dim: int ): if valid is None: return tensordict last_valid = self._shifted_last_valid(valid, time_dim) done = tensordict.get(("next", self.tensor_keys.done)) terminated = tensordict.get(("next", self.tensor_keys.terminated), default=done) done_mask = self._expand_to_match(last_valid, done) tensordict = tensordict.copy() tensordict.set( ("next", self.tensor_keys.done), done.masked_fill(done_mask, True), ) tensordict.set( ("next", self.tensor_keys.terminated), terminated.masked_fill(done_mask, False), ) return tensordict def _mask_shifted_output( self, tensordict: TensorDictBase, valid: torch.Tensor | None ): if valid is None: return tensordict.set("shifted_valid", valid) for key in (self.tensor_keys.advantage, self.tensor_keys.value_target): value = tensordict.get(key) mask = self._expand_to_match(valid, value) tensordict.set(key, value.masked_fill(~mask, 0))
[docs] @register_value_estimator( ValueEstimators.TD0, default_kwargs={"gamma": 0.99, "differentiable": True}, ) class TD0Estimator(ValueEstimatorBase): """Temporal Difference (TD(0)) estimate of advantage function. AKA bootstrapped temporal difference or 1-step return. Keyword Args: gamma (scalar): exponential mean discount. value_network (TensorDictModule): value operator used to retrieve the value estimates. shifted (bool, optional): controls how value and next-value are obtained from the value network. ``False`` (default) calls the value network twice (once on the root tensordict, once on ``"next"``), which is correct whenever ``"next"`` may differ non-trivially from ``obs[t+1]``. Truthy values request a single call: - ``True``: fixed-budget single-call path. Inserts the true ``("next", <in_key>)`` entry after every internal truncation (``done & ~terminated``), shifts subsequent samples to the right inside a sequence of length ``T + shifted_budget`` and masks the displaced suffix via ``"shifted_valid"``. Terminal steps (``done & terminated``) do not consume budget. Retained samples use exact next observations. .. note:: **Single-step rollout assumption.** ``shifted=True`` relies on the standard one-step rollout layout produced by ``env.step`` + auto-reset: at every position where ``done[t] = False``, the value-net inputs in ``("next", <in_key>)[t]`` are expected to equal ``<in_key>[t+1]``. The backend uses this invariant to evaluate ``V`` once over a fused ``[T + shifted_budget]`` sequence instead of twice over ``[T]`` streams. The canonical pipeline that breaks the invariant is **multi-step return processing** (``MultiStep`` / n-step bootstrapping), which rewrites ``("next", obs)[t]`` to ``obs[t+n]`` with ``n > 1``. ``shifted=True`` is unsupported with multi-step returns — use ``shifted=False`` instead. Single-call paths also require that the parameters at time ``t`` and ``t+1`` are identical (i.e. ``target_params`` is not used). Defaults to ``False``. average_rewards (bool, optional): if ``True``, rewards will be standardized before the TD is computed. group_key (NestedKey, optional): key of an integer entry of the input tensordict, one id per batch element such as a task id, within whose groups that standardization is done instead of over the whole batch. Defaults to ``None``. differentiable (bool, optional): if ``True``, gradients are propagated through the computation of the value function. Default is ``False``. .. note:: The proper way to make the function call non-differentiable is to decorate it in a `torch.no_grad()` context manager/decorator or pass detached parameters for functional modules. skip_existing (bool, optional): if ``True``, the value network will skip modules which outputs are already present in the tensordict. Defaults to ``None``, i.e., the value of :func:`tensordict.nn.skip_existing()` is not affected. advantage_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"advantage"``. value_target_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"value_target"``. value_key (str or tuple of str, optional): [Deprecated] the value key to read from the input tensordict. Defaults to ``"state_value"``. device (torch.device, optional): the device where the buffers will be instantiated. Defaults to ``torch.get_default_device()``. deactivate_vmap (bool, optional): whether to deactivate vmap calls and replace them with a plain for loop. Defaults to ``False``. value_chunk_size (int, optional): if set, splits value-network calls into chunks of this many elements along ``value_chunk_dim``. Defaults to ``None``. num_chunks (int, optional): if set, splits value-network calls into this many chunks along ``value_chunk_dim``. Mutually exclusive with ``value_chunk_size``. ``num_chunk`` is accepted as an alias. Defaults to ``None``. num_chunk (int, optional): alias for ``num_chunks``. Cannot be set together with a different ``num_chunks`` value. Defaults to ``None``. value_chunk_dim (int, optional): dimension used for chunked value-network calls. Defaults to ``0``. shifted_budget (int, optional): number of extra value-network time slots used when ``shifted=True``. ``1`` uses a ``T+1`` budget, ``2`` can represent one internal reset plus the rollout boundary without dropping samples, and so on. Defaults to ``1``. """ def __init__( self, *, gamma: float | torch.Tensor, value_network: TensorDictModule, shifted: bool = False, average_rewards: bool = False, differentiable: bool = False, advantage_key: NestedKey = None, value_target_key: NestedKey = None, value_key: NestedKey = None, skip_existing: bool | None = None, device: torch.device | None = None, deactivate_vmap: bool = False, value_chunk_size: int | None = None, num_chunks: int | None = None, num_chunk: int | None = None, value_chunk_dim: int = 0, shifted_budget: int = 1, group_key: NestedKey | None = None, ): super().__init__( value_network=value_network, differentiable=differentiable, shifted=shifted, advantage_key=advantage_key, value_target_key=value_target_key, value_key=value_key, skip_existing=skip_existing, device=device, deactivate_vmap=deactivate_vmap, value_chunk_size=value_chunk_size, num_chunks=num_chunks, num_chunk=num_chunk, value_chunk_dim=value_chunk_dim, shifted_budget=shifted_budget, group_key=group_key, ) self.register_buffer("gamma", torch.tensor(gamma, device=self._device)) self.average_rewards = average_rewards
[docs] @_self_set_skip_existing @_self_set_grad_enabled @dispatch def forward( self, tensordict: TensorDictBase, *, params: TensorDictBase | None = None, target_params: TensorDictBase | None = None, ) -> TensorDictBase: """Computes the TD(0) advantage given the data in tensordict. If a functional module is provided, a nested TensorDict containing the parameters (and if relevant the target parameters) can be passed to the module. Args: tensordict (TensorDictBase): A TensorDict containing the data (an observation key, ``"action"``, ``("next", "reward")``, ``("next", "done")``, ``("next", "terminated")``, and ``"next"`` tensordict state as returned by the environment) necessary to compute the value estimates and the TDEstimate. The data passed to this module should be structured as :obj:`[*B, T, *F]` where :obj:`B` are the batch size, :obj:`T` the time dimension and :obj:`F` the feature dimension(s). The tensordict must have shape ``[*B, T]``. Keyword Args: params (TensorDictBase, optional): A nested TensorDict containing the params to be passed to the functional value network module. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. Returns: An updated TensorDict with an advantage and a value_error keys as defined in the constructor. Examples: >>> from tensordict import TensorDict >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = TDEstimate( ... gamma=0.98, ... value_network=value_net, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> tensordict = TensorDict({"obs": obs, "next": {"obs": next_obs, "done": done, "terminated": terminated, "reward": reward}}, [1, 10]) >>> _ = module(tensordict) >>> assert "advantage" in tensordict.keys() The module supports non-tensordict (i.e. unpacked tensordict) inputs too: Examples: >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = TDEstimate( ... gamma=0.98, ... value_network=value_net, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> advantage, value_target = module(obs=obs, next_reward=reward, next_done=done, next_obs=next_obs, next_terminated=terminated) """ if tensordict.batch_dims < 1: raise RuntimeError( "Expected input tensordict to have at least one dimensions, got" f"tensordict.batch_size = {tensordict.batch_size}" ) if self.is_stateless and params is None: raise RuntimeError( "Expected params to be passed to advantage module but got none." ) if self.value_network is not None: if params is not None: params = params.detach() if target_params is None: target_params = params.clone(False) with hold_out_net(self.value_network) if ( params is None and target_params is None ) else nullcontext(): # we may still need to pass gradient, but we don't want to assign grads to # value net params value, next_value, valid = self._call_value_nets( data=tensordict, params=params, next_params=target_params, single_call=self.shifted, value_key=self.tensor_keys.value, detach_next=True, vmap_randomness=self.vmap_randomness, ) if valid is not None: tensordict.set("shifted_valid", valid) else: value = tensordict.get(self.tensor_keys.value) next_value = tensordict.get(("next", self.tensor_keys.value)) valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict( tensordict, valid, self._get_time_dim(None, tensordict) ) value_target = self.value_estimate(data_for_value, next_value=next_value) tensordict.set(self.tensor_keys.advantage, value_target - value) tensordict.set(self.tensor_keys.value_target, value_target) self._mask_shifted_output(tensordict, valid) return tensordict
[docs] def value_estimate( self, tensordict, target_params: TensorDictBase | None = None, next_value: torch.Tensor | None = None, **kwargs, ): reward = tensordict.get(("next", self.tensor_keys.reward)) device = reward.device if self.gamma.device != device: self.gamma = self.gamma.to(device) gamma = self.gamma steps_to_next_obs = tensordict.get(self.tensor_keys.steps_to_next_obs, None) if steps_to_next_obs is not None: gamma = gamma ** steps_to_next_obs.view_as(reward) if self.average_rewards: groups = self._group_ids(tensordict) if groups is None: reward = reward - reward.mean() reward = reward / reward.std().clamp_min(1e-5) else: reward = _standardize_by_group(reward, groups, eps=1e-5) tensordict.set( ("next", self.tensor_keys.reward), reward ) # we must update the rewards if they are used later in the code if next_value is None: next_value = self._next_value(tensordict, target_params, kwargs=kwargs) done = tensordict.get(("next", self.tensor_keys.done)) terminated = tensordict.get(("next", self.tensor_keys.terminated), default=done) value_target = td0_return_estimate( gamma=gamma, next_state_value=next_value, reward=reward, done=done, terminated=terminated, ) return value_target
[docs] @register_value_estimator( ValueEstimators.TD1, default_kwargs={"gamma": 0.99, "differentiable": True}, ) class TD1Estimator(ValueEstimatorBase): r""":math:`\infty`-Temporal Difference (TD(1)) estimate of advantage function. Keyword Args: gamma (scalar): exponential mean discount. value_network (TensorDictModule): value operator used to retrieve the value estimates. average_rewards (bool, optional): if ``True``, rewards will be standardized before the TD is computed. group_key (NestedKey, optional): key of an integer entry of the input tensordict, one id per batch element such as a task id, within whose groups that standardization is done instead of over the whole batch. Defaults to ``None``. differentiable (bool, optional): if ``True``, gradients are propagated through the computation of the value function. Default is ``False``. .. note:: The proper way to make the function call non-differentiable is to decorate it in a `torch.no_grad()` context manager/decorator or pass detached parameters for functional modules. skip_existing (bool, optional): if ``True``, the value network will skip modules which outputs are already present in the tensordict. Defaults to ``None``, i.e., the value of :func:`tensordict.nn.skip_existing()` is not affected. advantage_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"advantage"``. value_target_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"value_target"``. value_key (str or tuple of str, optional): [Deprecated] the value key to read from the input tensordict. Defaults to ``"state_value"``. shifted (bool, optional): controls how value and next-value are obtained from the value network. ``False`` (default) calls the value network twice (once on the root tensordict, once on ``"next"``), which is correct whenever ``"next"`` may differ non-trivially from ``obs[t+1]``. Truthy values request a single call: - ``True``: fixed-budget single-call path. Inserts the true ``("next", <in_key>)`` entry after every internal truncation (``done & ~terminated``), shifts subsequent samples to the right inside a sequence of length ``T + shifted_budget`` and masks the displaced suffix via ``"shifted_valid"``. Terminal steps (``done & terminated``) do not consume budget. Retained samples use exact next observations. .. note:: **Single-step rollout assumption.** ``shifted=True`` relies on the standard one-step rollout layout produced by ``env.step`` + auto-reset: at every position where ``done[t] = False``, the value-net inputs in ``("next", <in_key>)[t]`` are expected to equal ``<in_key>[t+1]``. The backend uses this invariant to evaluate ``V`` once over a fused ``[T + shifted_budget]`` sequence instead of twice over ``[T]`` streams. The canonical pipeline that breaks the invariant is **multi-step return processing** (``MultiStep`` / n-step bootstrapping), which rewrites ``("next", obs)[t]`` to ``obs[t+n]`` with ``n > 1``. ``shifted=True`` is unsupported with multi-step returns — use ``shifted=False`` instead. Single-call paths also require that the parameters at time ``t`` and ``t+1`` are identical (i.e. ``target_params`` is not used). Defaults to ``False``. device (torch.device, optional): the device where the buffers will be instantiated. Defaults to ``torch.get_default_device()``. time_dim (int, optional): the dimension corresponding to the time in the input tensordict. If not provided, defaults to the dimension marked with the ``"time"`` name if any, and to the last dimension otherwise. Can be overridden during a call to :meth:`~.value_estimate`. Negative dimensions are considered with respect to the input tensordict. deactivate_vmap (bool, optional): whether to deactivate vmap calls and replace them with a plain for loop. Defaults to ``False``. value_chunk_size (int, optional): if set, splits value-network calls into chunks of this many elements along ``value_chunk_dim``. Defaults to ``None``. num_chunks (int, optional): if set, splits value-network calls into this many chunks along ``value_chunk_dim``. Mutually exclusive with ``value_chunk_size``. ``num_chunk`` is accepted as an alias. Defaults to ``None``. num_chunk (int, optional): alias for ``num_chunks``. Cannot be set together with a different ``num_chunks`` value. Defaults to ``None``. value_chunk_dim (int, optional): dimension used for chunked value-network calls. Defaults to ``0``. shifted_budget (int, optional): number of extra value-network time slots used when ``shifted=True``. ``1`` uses a ``T+1`` budget, ``2`` can represent one internal reset plus the rollout boundary without dropping samples, and so on. Defaults to ``1``. """ def __init__( self, *, gamma: float | torch.Tensor, value_network: TensorDictModule, average_rewards: bool = False, differentiable: bool = False, skip_existing: bool | None = None, advantage_key: NestedKey = None, value_target_key: NestedKey = None, value_key: NestedKey = None, shifted: bool = False, device: torch.device | None = None, time_dim: int | None = None, deactivate_vmap: bool = False, value_chunk_size: int | None = None, num_chunks: int | None = None, num_chunk: int | None = None, value_chunk_dim: int = 0, shifted_budget: int = 1, group_key: NestedKey | None = None, ): super().__init__( value_network=value_network, differentiable=differentiable, advantage_key=advantage_key, value_target_key=value_target_key, value_key=value_key, shifted=shifted, skip_existing=skip_existing, device=device, deactivate_vmap=deactivate_vmap, value_chunk_size=value_chunk_size, num_chunks=num_chunks, num_chunk=num_chunk, value_chunk_dim=value_chunk_dim, shifted_budget=shifted_budget, group_key=group_key, ) self.register_buffer("gamma", torch.tensor(gamma, device=self._device)) self.average_rewards = average_rewards self.time_dim = time_dim
[docs] @_self_set_skip_existing @_self_set_grad_enabled @dispatch def forward( self, tensordict: TensorDictBase, *, params: TensorDictBase | None = None, target_params: TensorDictBase | None = None, ) -> TensorDictBase: """Computes the TD(1) advantage given the data in tensordict. If a functional module is provided, a nested TensorDict containing the parameters (and if relevant the target parameters) can be passed to the module. Args: tensordict (TensorDictBase): A TensorDict containing the data (an observation key, ``"action"``, ``("next", "reward")``, ``("next", "done")``, ``("next", "terminated")``, and ``"next"`` tensordict state as returned by the environment) necessary to compute the value estimates and the TDEstimate. The data passed to this module should be structured as :obj:`[*B, T, *F]` where :obj:`B` are the batch size, :obj:`T` the time dimension and :obj:`F` the feature dimension(s). The tensordict must have shape ``[*B, T]``. Keyword Args: params (TensorDictBase, optional): A nested TensorDict containing the params to be passed to the functional value network module. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. Returns: An updated TensorDict with an advantage and a value_error keys as defined in the constructor. Examples: >>> from tensordict import TensorDict >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = TDEstimate( ... gamma=0.98, ... value_network=value_net, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> tensordict = TensorDict({"obs": obs, "next": {"obs": next_obs, "done": done, "reward": reward, "terminated": terminated}}, [1, 10]) >>> _ = module(tensordict) >>> assert "advantage" in tensordict.keys() The module supports non-tensordict (i.e. unpacked tensordict) inputs too: Examples: >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = TDEstimate( ... gamma=0.98, ... value_network=value_net, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> advantage, value_target = module(obs=obs, next_reward=reward, next_done=done, next_obs=next_obs, next_terminated=terminated) """ if tensordict.batch_dims < 1: raise RuntimeError( "Expected input tensordict to have at least one dimensions, got" f"tensordict.batch_size = {tensordict.batch_size}" ) if self.is_stateless and params is None: raise RuntimeError( "Expected params to be passed to advantage module but got none." ) if self.value_network is not None: if params is not None: params = params.detach() if target_params is None: target_params = params.clone(False) with hold_out_net(self.value_network) if ( params is None and target_params is None ) else nullcontext(): # we may still need to pass gradient, but we don't want to assign grads to # value net params value, next_value, valid = self._call_value_nets( data=tensordict, params=params, next_params=target_params, single_call=self.shifted, value_key=self.tensor_keys.value, detach_next=True, vmap_randomness=self.vmap_randomness, ) if valid is not None: tensordict.set("shifted_valid", valid) else: value = tensordict.get(self.tensor_keys.value) next_value = tensordict.get(("next", self.tensor_keys.value)) valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict( tensordict, valid, self._get_time_dim(None, tensordict) ) value_target = self.value_estimate(data_for_value, next_value=next_value) tensordict.set(self.tensor_keys.advantage, value_target - value) tensordict.set(self.tensor_keys.value_target, value_target) self._mask_shifted_output(tensordict, valid) return tensordict
[docs] def value_estimate( self, tensordict, target_params: TensorDictBase | None = None, next_value: torch.Tensor | None = None, time_dim: int | None = None, **kwargs, ): reward = tensordict.get(("next", self.tensor_keys.reward)) device = reward.device if self.gamma.device != device: self.gamma = self.gamma.to(device) gamma = self.gamma steps_to_next_obs = tensordict.get(self.tensor_keys.steps_to_next_obs, None) if steps_to_next_obs is not None: gamma = gamma ** steps_to_next_obs.view_as(reward) if self.average_rewards: groups = self._group_ids(tensordict) if groups is None: reward = reward - reward.mean() reward = reward / reward.std().clamp_min(1e-5) else: reward = _standardize_by_group(reward, groups, eps=1e-5) tensordict.set( ("next", self.tensor_keys.reward), reward ) # we must update the rewards if they are used later in the code if next_value is None: next_value = self._next_value(tensordict, target_params, kwargs=kwargs) time_dim = self._get_time_dim(time_dim, tensordict) valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict(tensordict, valid, time_dim) reward = data_for_value.get(("next", self.tensor_keys.reward)) done = data_for_value.get(("next", self.tensor_keys.done)) terminated = data_for_value.get( ("next", self.tensor_keys.terminated), default=done ) value_target = vec_td1_return_estimate( gamma, next_value, reward, done=done, terminated=terminated, time_dim=time_dim, ) return value_target
[docs] @register_value_estimator( ValueEstimators.TDLambda, default_kwargs={"gamma": 0.99, "lmbda": 0.95, "differentiable": True}, ) class TDLambdaEstimator(ValueEstimatorBase): r"""TD(:math:`\lambda`) estimate of advantage function. Args: gamma (scalar): exponential mean discount. lmbda (scalar): trajectory discount. value_network (TensorDictModule): value operator used to retrieve the value estimates. average_rewards (bool, optional): if ``True``, rewards will be standardized before the TD is computed. group_key (NestedKey, optional): key of an integer entry of the input tensordict, one id per batch element such as a task id, within whose groups that standardization is done instead of over the whole batch. Defaults to ``None``. differentiable (bool, optional): if ``True``, gradients are propagated through the computation of the value function. Default is ``False``. .. note:: The proper way to make the function call non-differentiable is to decorate it in a `torch.no_grad()` context manager/decorator or pass detached parameters for functional modules. vectorized (bool, optional): whether to use the vectorized version of the lambda return. Default is `True`. skip_existing (bool, optional): if ``True``, the value network will skip modules which outputs are already present in the tensordict. Defaults to ``None``, i.e., the value of :func:`tensordict.nn.skip_existing()` is not affected. advantage_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"advantage"``. value_target_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"value_target"``. value_key (str or tuple of str, optional): [Deprecated] the value key to read from the input tensordict. Defaults to ``"state_value"``. shifted (bool, optional): controls how value and next-value are obtained from the value network. ``False`` (default) calls the value network twice (once on the root tensordict, once on ``"next"``), which is correct whenever ``"next"`` may differ non-trivially from ``obs[t+1]``. Truthy values request a single call: - ``True``: fixed-budget single-call path. Inserts the true ``("next", <in_key>)`` entry after every internal truncation (``done & ~terminated``), shifts subsequent samples to the right inside a sequence of length ``T + shifted_budget`` and masks the displaced suffix via ``"shifted_valid"``. Terminal steps (``done & terminated``) do not consume budget. Retained samples use exact next observations. .. note:: **Single-step rollout assumption.** ``shifted=True`` relies on the standard one-step rollout layout produced by ``env.step`` + auto-reset: at every position where ``done[t] = False``, the value-net inputs in ``("next", <in_key>)[t]`` are expected to equal ``<in_key>[t+1]``. The backend uses this invariant to evaluate ``V`` once over a fused ``[T + shifted_budget]`` sequence instead of twice over ``[T]`` streams. The canonical pipeline that breaks the invariant is **multi-step return processing** (``MultiStep`` / n-step bootstrapping), which rewrites ``("next", obs)[t]`` to ``obs[t+n]`` with ``n > 1``. ``shifted=True`` is unsupported with multi-step returns — use ``shifted=False`` instead. Single-call paths also require that the parameters at time ``t`` and ``t+1`` are identical (i.e. ``target_params`` is not used). Defaults to ``False``. device (torch.device, optional): the device where the buffers will be instantiated. Defaults to ``torch.get_default_device()``. time_dim (int, optional): the dimension corresponding to the time in the input tensordict. If not provided, defaults to the dimension marked with the ``"time"`` name if any, and to the last dimension otherwise. Can be overridden during a call to :meth:`~.value_estimate`. Negative dimensions are considered with respect to the input tensordict. deactivate_vmap (bool, optional): whether to deactivate vmap calls and replace them with a plain for loop. Defaults to ``False``. value_chunk_size (int, optional): if set, splits value-network calls into chunks of this many elements along ``value_chunk_dim``. Defaults to ``None``. num_chunks (int, optional): if set, splits value-network calls into this many chunks along ``value_chunk_dim``. Mutually exclusive with ``value_chunk_size``. ``num_chunk`` is accepted as an alias. Defaults to ``None``. num_chunk (int, optional): alias for ``num_chunks``. Cannot be set together with a different ``num_chunks`` value. Defaults to ``None``. value_chunk_dim (int, optional): dimension used for chunked value-network calls. Defaults to ``0``. shifted_budget (int, optional): number of extra value-network time slots used when ``shifted=True``. ``1`` uses a ``T+1`` budget, ``2`` can represent one internal reset plus the rollout boundary without dropping samples, and so on. Defaults to ``1``. """ def __init__( self, *, gamma: float | torch.Tensor, lmbda: float | torch.Tensor, value_network: TensorDictModule, average_rewards: bool = False, differentiable: bool = False, vectorized: bool = True, skip_existing: bool | None = None, advantage_key: NestedKey = None, value_target_key: NestedKey = None, value_key: NestedKey = None, shifted: bool = False, device: torch.device | None = None, time_dim: int | None = None, deactivate_vmap: bool = False, value_chunk_size: int | None = None, num_chunks: int | None = None, num_chunk: int | None = None, value_chunk_dim: int = 0, shifted_budget: int = 1, group_key: NestedKey | None = None, ): super().__init__( value_network=value_network, differentiable=differentiable, advantage_key=advantage_key, value_target_key=value_target_key, value_key=value_key, skip_existing=skip_existing, shifted=shifted, device=device, deactivate_vmap=deactivate_vmap, value_chunk_size=value_chunk_size, num_chunks=num_chunks, num_chunk=num_chunk, value_chunk_dim=value_chunk_dim, shifted_budget=shifted_budget, group_key=group_key, ) self.register_buffer("gamma", torch.tensor(gamma, device=self._device)) self.register_buffer("lmbda", torch.tensor(lmbda, device=self._device)) self.average_rewards = average_rewards self.vectorized = vectorized self.time_dim = time_dim @property def vectorized(self): if is_dynamo_compiling(): return False return self._vectorized @vectorized.setter def vectorized(self, value): self._vectorized = value
[docs] @_self_set_skip_existing @_self_set_grad_enabled @dispatch def forward( self, tensordict: TensorDictBase, *, params: list[Tensor] | None = None, target_params: list[Tensor] | None = None, ) -> TensorDictBase: r"""Computes the TD(:math:`\lambda`) advantage given the data in tensordict. If a functional module is provided, a nested TensorDict containing the parameters (and if relevant the target parameters) can be passed to the module. Args: tensordict (TensorDictBase): A TensorDict containing the data (an observation key, ``"action"``, ``("next", "reward")``, ``("next", "done")``, ``("next", "terminated")``, and ``"next"`` tensordict state as returned by the environment) necessary to compute the value estimates and the TDLambdaEstimate. The data passed to this module should be structured as :obj:`[*B, T, *F]` where :obj:`B` are the batch size, :obj:`T` the time dimension and :obj:`F` the feature dimension(s). The tensordict must have shape ``[*B, T]``. Keyword Args: params (TensorDictBase, optional): A nested TensorDict containing the params to be passed to the functional value network module. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. Returns: An updated TensorDict with an advantage and a value_error keys as defined in the constructor. Examples: >>> from tensordict import TensorDict >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = TDLambdaEstimator( ... gamma=0.98, ... lmbda=0.94, ... value_network=value_net, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> tensordict = TensorDict({"obs": obs, "next": {"obs": next_obs, "done": done, "reward": reward, "terminated": terminated}}, [1, 10]) >>> _ = module(tensordict) >>> assert "advantage" in tensordict.keys() The module supports non-tensordict (i.e. unpacked tensordict) inputs too: Examples: >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = TDLambdaEstimator( ... gamma=0.98, ... lmbda=0.94, ... value_network=value_net, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> advantage, value_target = module(obs=obs, next_reward=reward, next_done=done, next_obs=next_obs, next_terminated=terminated) """ if tensordict.batch_dims < 1: raise RuntimeError( "Expected input tensordict to have at least one dimensions, got" f"tensordict.batch_size = {tensordict.batch_size}" ) if self.is_stateless and params is None: raise RuntimeError( "Expected params to be passed to advantage module but got none." ) if self.value_network is not None: if params is not None: params = params.detach() if target_params is None: target_params = params.clone(False) with hold_out_net(self.value_network) if ( params is None and target_params is None ) else nullcontext(): # we may still need to pass gradient, but we don't want to assign grads to # value net params value, next_value, valid = self._call_value_nets( data=tensordict, params=params, next_params=target_params, single_call=self.shifted, value_key=self.tensor_keys.value, detach_next=True, vmap_randomness=self.vmap_randomness, ) if valid is not None: tensordict.set("shifted_valid", valid) else: value = tensordict.get(self.tensor_keys.value) next_value = tensordict.get(("next", self.tensor_keys.value)) valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict( tensordict, valid, self._get_time_dim(None, tensordict) ) value_target = self.value_estimate(data_for_value, next_value=next_value) tensordict.set(self.tensor_keys.advantage, value_target - value) tensordict.set(self.tensor_keys.value_target, value_target) self._mask_shifted_output(tensordict, valid) return tensordict
[docs] def value_estimate( self, tensordict, target_params: TensorDictBase | None = None, next_value: torch.Tensor | None = None, time_dim: int | None = None, **kwargs, ): reward = tensordict.get(("next", self.tensor_keys.reward)) device = reward.device if self.gamma.device != device: self.gamma = self.gamma.to(device) gamma = self.gamma steps_to_next_obs = tensordict.get(self.tensor_keys.steps_to_next_obs, None) if steps_to_next_obs is not None: gamma = gamma ** steps_to_next_obs.view_as(reward) if self.lmbda.device != device: self.lmbda = self.lmbda.to(device) lmbda = self.lmbda if self.average_rewards: groups = self._group_ids(tensordict) if groups is None: reward = reward - reward.mean() reward = reward / reward.std().clamp_min(1e-4) else: reward = _standardize_by_group(reward, groups) tensordict.set( ("next", self.tensor_keys.reward), reward ) # we must update the rewards if they are used later in the code if next_value is None: next_value = self._next_value(tensordict, target_params, kwargs=kwargs) time_dim = self._get_time_dim(time_dim, tensordict) valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict(tensordict, valid, time_dim) reward = data_for_value.get(("next", self.tensor_keys.reward)) done = data_for_value.get(("next", self.tensor_keys.done)) terminated = data_for_value.get( ("next", self.tensor_keys.terminated), default=done ) if self.vectorized: val = vec_td_lambda_return_estimate( gamma, lmbda, next_value, reward, done=done, terminated=terminated, time_dim=time_dim, ) else: val = td_lambda_return_estimate( gamma, lmbda, next_value, reward, done=done, terminated=terminated, time_dim=time_dim, ) return val
[docs] @register_value_estimator( ValueEstimators.GAE, default_kwargs={"gamma": 0.99, "lmbda": 0.95, "differentiable": True}, ) class GAE(ValueEstimatorBase): """A class wrapper around the generalized advantage estimate functional. Refer to "HIGH-DIMENSIONAL CONTINUOUS CONTROL USING GENERALIZED ADVANTAGE ESTIMATION" https://arxiv.org/pdf/1506.02438.pdf for more context. For recurrent value networks, use the :meth:`for_recurrent` alternative constructor. It selects correctness-first recurrent defaults and validates the required reset markers and recurrent state inputs. Args: gamma (scalar): exponential mean discount. lmbda (scalar): trajectory discount. value_network (TensorDictModule, optional): value operator used to retrieve the value estimates. If ``None``, this module will expect the ``"state_value"`` keys to be already filled, and will not call the value network to produce it. average_gae (bool): if ``True``, the resulting GAE values will be standardized. Default is ``False``. group_key (NestedKey, optional): key of an integer entry of the input tensordict, one id per batch element such as a task id, within whose groups that standardization is done instead of over the whole batch. Defaults to ``None``. differentiable (bool, optional): if ``True``, gradients are propagated through the computation of the value function. Default is ``False``. .. note:: The proper way to make the function call non-differentiable is to decorate it in a `torch.no_grad()` context manager/decorator or pass detached parameters for functional modules. vectorized (bool, optional): whether to use the vectorized version of the lambda return. Default is `True` if not compiling. skip_existing (bool, optional): if ``True``, the value network will skip modules which outputs are already present in the tensordict. Defaults to ``None``, i.e., the value of :func:`tensordict.nn.skip_existing()` is not affected. Defaults to "state_value". advantage_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"advantage"``. value_target_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"value_target"``. value_key (str or tuple of str, optional): [Deprecated] the value key to read from the input tensordict. Defaults to ``"state_value"``. shifted (bool, optional): controls how value and next-value are obtained from the value network. ``False`` (default) calls the value network twice (once on the root tensordict, once on ``"next"``), which is correct whenever ``"next"`` may differ non-trivially from ``obs[t+1]``. Truthy values request a single call: - ``True``: fixed-budget single-call path. Inserts the true ``("next", <in_key>)`` entry after every internal truncation (``done & ~terminated``), shifts subsequent samples to the right inside a sequence of length ``T + shifted_budget`` and masks the displaced suffix via ``"shifted_valid"``. Terminal steps (``done & terminated``) do not consume budget. Retained samples use exact next observations. .. note:: **Single-step rollout assumption.** ``shifted=True`` relies on the standard one-step rollout layout produced by ``env.step`` + auto-reset: at every position where ``done[t] = False``, the value-net inputs in ``("next", <in_key>)[t]`` are expected to equal ``<in_key>[t+1]``. The backend uses this invariant to evaluate ``V`` once over a fused ``[T + shifted_budget]`` sequence instead of twice over ``[T]`` streams. The canonical pipeline that breaks the invariant is **multi-step return processing** (``MultiStep`` / n-step bootstrapping), which rewrites ``("next", obs)[t]`` to ``obs[t+n]`` with ``n > 1``. ``shifted=True`` is unsupported with multi-step returns — use ``shifted=False`` instead. Single-call paths also require that the parameters at time ``t`` and ``t+1`` are identical (i.e. ``target_params`` is not used). Defaults to ``False``. device (torch.device, optional): the device where the buffers will be instantiated. Defaults to ``torch.get_default_device()``. time_dim (int, optional): the dimension corresponding to the time in the input tensordict. If not provided, defaults to the dimension marked with the ``"time"`` name if any, and to the last dimension otherwise. Can be overridden during a call to :meth:`~.value_estimate`. Negative dimensions are considered with respect to the input tensordict. auto_reset_env (bool, optional): if ``True``, the last ``"next"`` state of the episode isn't valid, so the GAE calculation will use the ``value`` instead of ``next_value`` to bootstrap truncated episodes. deactivate_vmap (bool, optional): if ``True``, no vmap call will be used, and vectorized maps will be replaced with simple for loops. Defaults to ``False``. value_chunk_size (int, optional): if set, splits value-network calls into chunks of this many elements along ``value_chunk_dim``. Defaults to ``None``. num_chunks (int, optional): if set, splits value-network calls into this many chunks along ``value_chunk_dim``. Mutually exclusive with ``value_chunk_size``. ``num_chunk`` is accepted as an alias. Defaults to ``None``. num_chunk (int, optional): alias for ``num_chunks``. Cannot be set together with a different ``num_chunks`` value. Defaults to ``None``. value_chunk_dim (int, optional): dimension used for chunked value-network calls. Defaults to ``0``. shifted_budget (int, optional): number of extra value-network time slots used when ``shifted=True``. ``1`` uses a ``T+1`` budget, ``2`` can represent one internal reset plus the rollout boundary without dropping samples, and so on. Defaults to ``1``. GAE will return an :obj:`"advantage"` entry containing the advantage value. It will also return a :obj:`"value_target"` entry with the return value that is to be used to train the value network. Finally, if :obj:`gradient_mode` is ``True``, an additional and differentiable :obj:`"value_error"` entry will be returned, which simply represents the difference between the return and the value network output (i.e. an additional distance loss should be applied to that signed value). .. note:: As other advantage functions do, if the ``value_key`` is already present in the input tensordict, the GAE module will ignore the calls to the value network (if any) and use the provided value instead. .. note:: Right-padding masks can be configured with ``set_keys(valid=...)``. Invalid entries are isolated from the valid prefix, excluded from advantage normalization, and written as zero in both output tensors. Other trajectory boundaries continue to use the configured ``done`` and ``terminated`` keys. .. note:: GAE can be used with value networks that rely on recurrent neural networks, provided that the init markers (`"is_init"`) and terminated / truncated markers are properly set. With ``shifted=True``, reset next-observations are inserted into a fixed-shape value-network call according to ``shifted_budget``. If ``shifted=False``, the root and ``"next"`` trajectories are stacked and the value network is called with ``vmap`` over the stack of trajectories. Because RNNs require a fair amount of control flow, they are currently not compatible with ``torch.vmap`` and, as such, the ``deactivate_vmap`` option must be turned on in these cases. Similarly, if ``shifted=False``, the ``"is_init"`` entry of the root tensordict will be copied onto the ``"is_init"`` of the ``"next"`` entry, such that trajectories are well separated both for root and ``"next"`` data. """ value_network: TensorDictModule | None @dataclass class _AcceptedKeys(ValueEstimatorBase._AcceptedKeys): """TensorDict keys accepted by :class:`GAE`. Attributes: valid (NestedKey, optional): key of a boolean mask that marks a valid trajectory prefix rather than right-padding. Invalid outputs are zero and are excluded from advantage normalization. The ``("next", value)`` entry on the last valid transition must contain the bootstrap value for the state that follows that transition. Defaults to ``None``. """ valid: NestedKey | None = None default_keys = _AcceptedKeys @property def valid_key(self): return self.tensor_keys.valid @property def in_keys(self): in_keys = super().in_keys key = self.tensor_keys.valid if key is not None and key not in in_keys: in_keys.append(key) return in_keys
[docs] @classmethod def for_recurrent( cls, *, gamma: float | torch.Tensor, lmbda: float | torch.Tensor, value_network: TensorDictModuleBase, average_gae: bool = True, shifted: bool = False, deactivate_vmap: bool = True, group_key: NestedKey | None = None, valid: NestedKey | None = None, **kwargs: Any, ) -> GAE: """Construct GAE with correctness-first recurrent-network defaults. This constructor disables ``vmap`` and uses the two-call value path by default, requires time to be the last batch dimension, and validates the recurrent reset markers and state tensors before calling the value network. The estimator is bound to the network passed at construction; construct another estimator if the value network changes. ``shifted=True`` remains available as an explicit optimization when its one-step observation invariant is known to hold. Args: gamma (scalar): exponential mean discount. lmbda (scalar): trajectory discount. value_network (TensorDictModule): recurrent value operator. Its external inputs must include ``"is_init"`` and at least one state key whose corresponding ``("next", ...)`` key is an output. average_gae (bool, optional): if ``True``, standardize advantages. Defaults to ``True``. shifted (bool, optional): if ``True``, use the explicit single-call shifted path. Defaults to ``False``. deactivate_vmap (bool, optional): if ``True``, replace ``vmap`` with sequential calls. Defaults to ``True``. group_key (NestedKey, optional): key used for group-wise advantage normalization. Defaults to ``None``. valid (NestedKey, optional): valid-transition mask key. Defaults to ``None``. **kwargs: explicit overrides accepted by :class:`GAE`, including ``time_dim`` and ``vectorized``. Returns: A recurrent-input-validating :class:`GAE` instance. Examples: >>> from torch import nn >>> from tensordict.nn import TensorDictModule, TensorDictSequential >>> from torchrl.modules import GRUModule >>> gru = GRUModule( ... input_size=3, ... hidden_size=4, ... in_keys=["obs", "hidden"], ... out_keys=["features", ("next", "hidden")], ... ) >>> critic = TensorDictSequential( ... gru, ... TensorDictModule( ... nn.Linear(4, 1), ["features"], ["state_value"] ... ), ... ) >>> gae = GAE.for_recurrent( ... gamma=0.99, lmbda=0.95, value_network=critic ... ) >>> gae.deactivate_vmap True """ if value_network is None: raise ValueError("GAE.for_recurrent requires a value_network.") in_keys = [unravel_key(key) for key in value_network.in_keys] out_keys = {unravel_key(key) for key in value_network.out_keys} if "is_init" not in in_keys: raise ValueError( "GAE.for_recurrent requires value_network.in_keys to include " "'is_init'. Add InitTracker data and expose the marker through " "the recurrent value network." ) state_keys = [] for key in in_keys: if key == "is_init": continue next_key = ("next", *key) if isinstance(key, tuple) else ("next", key) if next_key in out_keys: state_keys.append(key) if not state_keys: raise ValueError( "GAE.for_recurrent could not identify a recurrent-state input. " "The value network must expose an input key and its matching " "('next', ...) output key." ) estimator = cls( gamma=gamma, lmbda=lmbda, value_network=value_network, average_gae=average_gae, shifted=shifted, deactivate_vmap=deactivate_vmap, group_key=group_key, **kwargs, ) if valid is not None: estimator.set_keys(valid=valid) estimator._recurrent_state_keys = tuple(state_keys) return estimator
def __init__( self, *, gamma: float | torch.Tensor, lmbda: float | torch.Tensor, value_network: TensorDictModule | None, average_gae: bool = False, differentiable: bool = False, vectorized: bool | None = None, skip_existing: bool | None = None, advantage_key: NestedKey = None, value_target_key: NestedKey = None, value_key: NestedKey = None, shifted: bool = False, device: torch.device | None = None, time_dim: int | None = None, auto_reset_env: bool = False, deactivate_vmap: bool = False, value_chunk_size: int | None = None, num_chunks: int | None = None, num_chunk: int | None = None, value_chunk_dim: int = 0, shifted_budget: int = 1, group_key: NestedKey | None = None, ): super().__init__( shifted=shifted, value_network=value_network, differentiable=differentiable, advantage_key=advantage_key, value_target_key=value_target_key, value_key=value_key, skip_existing=skip_existing, device=device, deactivate_vmap=deactivate_vmap, value_chunk_size=value_chunk_size, num_chunks=num_chunks, num_chunk=num_chunk, value_chunk_dim=value_chunk_dim, shifted_budget=shifted_budget, group_key=group_key, ) self.register_buffer( "gamma", gamma.to(self._device) if isinstance(gamma, Tensor) else torch.tensor(gamma, device=self._device), ) self.register_buffer( "lmbda", lmbda.to(self._device) if isinstance(lmbda, Tensor) else torch.tensor(lmbda, device=self._device), ) self.average_gae = average_gae self.vectorized = vectorized self.time_dim = time_dim self.auto_reset_env = auto_reset_env self.deactivate_vmap = deactivate_vmap def _validate_recurrent_inputs( self, tensordict: TensorDictBase, time_dim: int | None ) -> None: state_keys = getattr(self, "_recurrent_state_keys", None) if state_keys is None: return resolved_time_dim = self._get_time_dim(time_dim, tensordict) if resolved_time_dim != tensordict.batch_dims - 1: raise RuntimeError( "GAE.for_recurrent requires time to be the last batch dimension " "because recurrent value modules consume [batch, time] layouts. " f"Resolved time_dim={resolved_time_dim} for batch names " f"{tensordict.names}. Transpose the tensordict so 'time' is last " "or pass a matching time_dim." ) required_keys = ["is_init", *state_keys] missing = [] for key in required_keys: next_key = ("next", *key) if isinstance(key, tuple) else ("next", key) if tensordict.get(key, default=None) is None: missing.append(key) if tensordict.get(next_key, default=None) is None: missing.append(next_key) if missing: raise KeyError( "GAE.for_recurrent requires root and next reset/state inputs; " f"missing keys: {missing}. Preserve recurrent state and is_init " "when writing trajectories to replay." ) for key in ("is_init", ("next", "is_init")): marker = tensordict.get(key) if not isinstance(marker, Tensor): raise TypeError( f"GAE.for_recurrent expected {key!r} to contain a Tensor, " f"got {type(marker)}." ) if marker.dtype is not torch.bool: raise TypeError( f"GAE.for_recurrent expected {key!r} to have dtype " f"torch.bool, got {marker.dtype}." ) event_shape = marker.shape[tensordict.batch_dims :] if event_shape not in (torch.Size(), torch.Size([1])): raise RuntimeError( f"GAE.for_recurrent expected {key!r} to have the batch " "layout or one trailing singleton, but got shape " f"{tuple(marker.shape)} for batch size " f"{tuple(tensordict.batch_size)}." ) for key in state_keys: next_key = ("next", *key) if isinstance(key, tuple) else ("next", key) state = tensordict.get(key) next_state = tensordict.get(next_key) if not isinstance(state, Tensor) or not isinstance(next_state, Tensor): raise TypeError( "GAE.for_recurrent expected recurrent state keys " f"{key!r} and {next_key!r} to contain tensors." ) if state.ndim <= tensordict.batch_dims: raise RuntimeError( f"GAE.for_recurrent expected state key {key!r} to have " "trailing recurrent dimensions after the batch layout; got " f"shape {tuple(state.shape)} for batch size " f"{tuple(tensordict.batch_size)}." ) if state.shape != next_state.shape: raise RuntimeError( "GAE.for_recurrent requires matching root and next state " f"layouts for {key!r}; got {tuple(state.shape)} and " f"{tuple(next_state.shape)}." ) @property def vectorized(self): if is_dynamo_compiling(): return False return self._vectorized @vectorized.setter def vectorized(self, value): self._vectorized = value
[docs] @_self_set_skip_existing @_self_set_grad_enabled @dispatch def forward( self, tensordict: TensorDictBase, *, params: list[Tensor] | None = None, target_params: list[Tensor] | None = None, time_dim: int | None = None, ) -> TensorDictBase: """Computes the GAE given the data in tensordict. If a functional module is provided, a nested TensorDict containing the parameters (and if relevant the target parameters) can be passed to the module. Args: tensordict (TensorDictBase): A TensorDict containing the data (an observation key, ``"action"``, ``("next", "reward")``, ``("next", "done")``, ``("next", "terminated")``, and ``"next"`` tensordict state as returned by the environment) necessary to compute the value estimates and the GAE. The data passed to this module should be structured as :obj:`[*B, T, *F]` where :obj:`B` are the batch size, :obj:`T` the time dimension and :obj:`F` the feature dimension(s). The tensordict must have shape ``[*B, T]``. Keyword Args: params (TensorDictBase, optional): A nested TensorDict containing the params to be passed to the functional value network module. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. time_dim (int, optional): the dimension corresponding to the time in the input tensordict. If not provided, defaults to the dimension marked with the ``"time"`` name if any, and to the last dimension otherwise. Negative dimensions are considered with respect to the input tensordict. Returns: An updated TensorDict with an advantage and a value_error keys as defined in the constructor. Examples: >>> from tensordict import TensorDict >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = GAE( ... gamma=0.98, ... lmbda=0.94, ... value_network=value_net, ... differentiable=False, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> tensordict = TensorDict({"obs": obs, "next": {"obs": next_obs}, "done": done, "reward": reward, "terminated": terminated}, [1, 10]) >>> _ = module(tensordict) >>> assert "advantage" in tensordict.keys() The module supports non-tensordict (i.e. unpacked tensordict) inputs too: Examples: >>> value_net = TensorDictModule( ... nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"] ... ) >>> module = GAE( ... gamma=0.98, ... lmbda=0.94, ... value_network=value_net, ... differentiable=False, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> advantage, value_target = module(obs=obs, next_reward=reward, next_done=done, next_obs=next_obs, next_terminated=terminated) """ if tensordict.batch_dims < 1: raise RuntimeError( "Expected input tensordict to have at least one dimension, got " f"tensordict.batch_size = {tensordict.batch_size}" ) self._validate_recurrent_inputs(tensordict, time_dim) reward = tensordict.get(("next", self.tensor_keys.reward)) device = reward.device if self.gamma.device != device: self.gamma = self.gamma.to(device) gamma = self.gamma if self.lmbda.device != device: self.lmbda = self.lmbda.to(device) lmbda = self.lmbda steps_to_next_obs = tensordict.get(self.tensor_keys.steps_to_next_obs, None) if steps_to_next_obs is not None: gamma = gamma ** steps_to_next_obs.view_as(reward) if self.value_network is not None: if params is not None: params = params.detach() if target_params is None: target_params = params.clone(False) with hold_out_net(self.value_network) if ( params is None and target_params is None ) else nullcontext(): # with torch.no_grad(): # we may still need to pass gradient, but we don't want to assign grads to # value net params value, next_value, shifted_valid = self._call_value_nets( data=tensordict, params=params, next_params=target_params, single_call=self.shifted, value_key=self.tensor_keys.value, detach_next=True, vmap_randomness=self.vmap_randomness, ) if shifted_valid is not None: tensordict.set("shifted_valid", shifted_valid) else: value = tensordict.get(self.tensor_keys.value) next_value = tensordict.get(("next", self.tensor_keys.value)) if value is None: raise ValueError( f"The tensor with key {self.tensor_keys.value} is missing, and no value network was provided." ) if next_value is None: raise ValueError( f"The tensor with key {('next', self.tensor_keys.value)} is missing, and no value network was provided." ) shifted_valid = tensordict.get("shifted_valid", default=None) time_dim = self._get_time_dim(time_dim, tensordict) data_for_value = self._prepare_shifted_tensordict( tensordict, shifted_valid, time_dim ) reward = data_for_value.get(("next", self.tensor_keys.reward)) done = data_for_value.get(("next", self.tensor_keys.done)) terminated = data_for_value.get( ("next", self.tensor_keys.terminated), default=done ) # Subclass extension hook: lets subclasses reshape / broadcast the # reward and done signals to match the value tensor before the # advantage recursion is run. Default: identity. reward, done, terminated = self._prepare_signals( reward, done, terminated, value ) valid = self._valid_mask(tensordict, shifted_valid, value) value, next_value, reward = self._zero_invalid_values( value, next_value, reward, valid ) if self.auto_reset_env: truncated = tensordict.get(("next", "truncated")) truncated = self._broadcast_optional(truncated, value) if truncated.any(): reward = reward + gamma * value * truncated recursion_terminated = done else: recursion_terminated = terminated # Keep real termination semantics separate from the synthetic boundary # inserted at the last valid transition: padding stops recursion but # must not suppress that transition's bootstrap value. done = self._apply_valid_boundaries(done, valid, time_dim) if self.vectorized: adv, value_target = vec_generalized_advantage_estimate( gamma, lmbda, value, next_value, reward, done=done, terminated=recursion_terminated, time_dim=time_dim, ) else: adv, value_target = generalized_advantage_estimate( gamma, lmbda, value, next_value, reward, done=done, terminated=recursion_terminated, time_dim=time_dim, ) if self.average_gae: adv = self._normalize_advantage( adv, valid, groups=self._group_ids(tensordict) ) tensordict.set(self.tensor_keys.advantage, adv) tensordict.set(self.tensor_keys.value_target, value_target) self._mask_shifted_output(tensordict, shifted_valid) self._mask_invalid_output(tensordict, valid) return tensordict
# -- extension hooks ----------------------------------------------------- def _prepare_signals( self, reward: Tensor, done: Tensor, terminated: Tensor, value: Tensor, ) -> tuple[Tensor, Tensor, Tensor]: """Hook to reshape reward / done / terminated before the recursion. Default implementation is identity. :class:`MultiAgentGAE` overrides this to broadcast team-shared signals across the agent dim. """ return reward, done, terminated def _broadcast_optional(self, tensor: Tensor, value: Tensor) -> Tensor: """Optional broadcast for the truncated signal used in auto_reset_env. Default: return ``tensor`` unchanged. Subclasses that broadcast rewards / done flags should typically override this with the same broadcasting policy. """ return tensor def _read_optional_mask( self, tensordict: TensorDictBase, key: NestedKey | None, target: Tensor, name: str, ) -> Tensor | None: if key is None: return None mask = tensordict.get(key) if mask.dtype is not torch.bool: raise TypeError( f"The {name} mask under key {key!r} must have dtype torch.bool, " f"got {mask.dtype}." ) try: return self._expand_to_match(mask, target) except RuntimeError as err: raise RuntimeError( f"The {name} mask under key {key!r} with shape " f"{tuple(mask.shape)} cannot be broadcast to the value shape " f"{tuple(target.shape)}." ) from err def _valid_mask( self, tensordict: TensorDictBase, shifted_valid: Tensor | None, value: Tensor, ) -> Tensor | None: valid = self._read_optional_mask( tensordict, self.tensor_keys.valid, value, "valid" ) if shifted_valid is None: return valid shifted_valid = self._expand_to_match(shifted_valid, value) if valid is None: return shifted_valid return valid & shifted_valid def _apply_valid_boundaries( self, done: Tensor, valid: Tensor | None, time_dim: int, ) -> Tensor: if valid is None: return done valid = self._expand_to_match(valid, done) next_valid = torch.cat( [ valid.narrow(time_dim, 1, valid.shape[time_dim] - 1), valid.new_ones( [ *valid.shape[:time_dim], 1, *valid.shape[time_dim + 1 :], ] ), ], dim=time_dim, ) return done | ~valid | (valid & ~next_valid) def _zero_invalid_values( self, value: Tensor, next_value: Tensor, reward: Tensor, valid: Tensor | None, ) -> tuple[Tensor, Tensor, Tensor]: if valid is None: return value, next_value, reward mask = self._expand_to_match(valid, value) return ( value.masked_fill(~mask, 0), next_value.masked_fill(~mask, 0), reward.masked_fill(~mask, 0), ) def _mask_invalid_output( self, tensordict: TensorDictBase, valid: Tensor | None ) -> None: if valid is None: return for key in (self.tensor_keys.advantage, self.tensor_keys.value_target): value = tensordict.get(key) mask = self._expand_to_match(valid, value) tensordict.set(key, value.masked_fill(~mask, 0)) def _normalize_advantage( self, adv: Tensor, valid: torch.Tensor | None = None, groups: torch.Tensor | None = None, ) -> Tensor: """Standardise the advantage tensor. Default standardises globally (single mean/std over the whole tensor), or within the groups of ``groups`` when ``group_key`` is set. :class:`MultiAgentGAE` overrides this to leave the agent dim independent. """ if groups is not None: mask = None if valid is None else self._expand_to_match(valid, adv) return _standardize_by_group(adv, groups, mask) if valid is None: loc = adv.mean() scale = adv.std().clamp_min(1e-4) return (adv - loc) / scale mask = self._expand_to_match(valid, adv) count = mask.sum().clamp_min(1) loc = adv.masked_fill(~mask, 0).sum() / count scale = ( (adv - loc) .masked_fill(~mask, 0) .pow(2) .sum() .div((count - 1).clamp_min(1)) .sqrt() .clamp_min(1e-4) ) return (adv - loc) / scale
[docs] def value_estimate( self, tensordict, params: TensorDictBase | None = None, target_params: TensorDictBase | None = None, time_dim: int | None = None, **kwargs, ): if tensordict.batch_dims < 1: raise RuntimeError( "Expected input tensordict to have at least one dimensions, got" f"tensordict.batch_size = {tensordict.batch_size}" ) self._validate_recurrent_inputs(tensordict, time_dim) reward = tensordict.get(("next", self.tensor_keys.reward)) device = reward.device if self.gamma.device != device: self.gamma = self.gamma.to(device) gamma = self.gamma if self.lmbda.device != device: self.lmbda = self.lmbda.to(device) lmbda = self.lmbda steps_to_next_obs = tensordict.get(self.tensor_keys.steps_to_next_obs, None) if steps_to_next_obs is not None: gamma = gamma ** steps_to_next_obs.view_as(reward) time_dim = self._get_time_dim(time_dim, tensordict) if self.is_stateless and params is None: raise RuntimeError( "Expected params to be passed to advantage module but got none." ) if self.value_network is not None: if params is not None: params = params.detach() if target_params is None: target_params = params.clone(False) with hold_out_net(self.value_network) if ( params is None and target_params is None ) else nullcontext(): # we may still need to pass gradient, but we don't want to assign grads to # value net params value, next_value, shifted_valid = self._call_value_nets( data=tensordict, params=params, next_params=target_params, single_call=self.shifted, value_key=self.tensor_keys.value, detach_next=True, vmap_randomness=self.vmap_randomness, ) if shifted_valid is not None: tensordict.set("shifted_valid", shifted_valid) else: value = tensordict.get(self.tensor_keys.value) next_value = tensordict.get(("next", self.tensor_keys.value)) shifted_valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict( tensordict, shifted_valid, time_dim ) reward = data_for_value.get(("next", self.tensor_keys.reward)) done = data_for_value.get(("next", self.tensor_keys.done)) terminated = data_for_value.get( ("next", self.tensor_keys.terminated), default=done ) reward, done, terminated = self._prepare_signals( reward, done, terminated, value ) valid = self._valid_mask(tensordict, shifted_valid, value) value, next_value, reward = self._zero_invalid_values( value, next_value, reward, valid ) done = self._apply_valid_boundaries(done, valid, time_dim) _, value_target = vec_generalized_advantage_estimate( gamma, lmbda, value, next_value, reward, done=done, terminated=terminated, time_dim=time_dim, ) if valid is not None: value_target = value_target.masked_fill( ~self._expand_to_match(valid, value_target), 0 ) return value_target
[docs] @register_value_estimator( ValueEstimators.MAGAE, default_kwargs={"gamma": 0.99, "lmbda": 0.95, "differentiable": True}, ) class MultiAgentGAE(GAE): """Multi-agent Generalized Advantage Estimator. Drop-in replacement for :class:`GAE` when the value network produces per-agent state values (shape ``[*B, T, n_agents, 1]``) but the reward / done / terminated signals are shared across agents at the team level (shape ``[*B, T, 1]``) — the standard cooperative-MARL layout in torchrl (see e.g. ``torchrl/envs/libs/vmas.py`` and ``torchrl/envs/libs/pettingzoo.py``). The estimator detects whether the reward/done/terminated tensors are missing the agent dimension relative to the value tensor, and broadcasts them along that dimension before running the standard vectorised GAE recursion. If the reward is already per-agent (e.g. a competitive setting), it is passed through unchanged. The output ``"advantage"`` and ``"value_target"`` entries match the shape of the value tensor (``[*B, T, n_agents, 1]``), which is what :class:`~torchrl.objectives.multiagent.MAPPOLoss` expects. Keyword Args: agent_dim (int, optional): the dimension that holds the agent index in the value tensor. Negative dimensions are taken modulo ``value.ndim``. Defaults to ``-2`` (penultimate), matching the convention used by :class:`~torchrl.modules.MultiAgentMLP`. Other args/kwargs are forwarded to :class:`GAE`. """ def __init__(self, *, agent_dim: int = -2, **kwargs): super().__init__(**kwargs) self.agent_dim = agent_dim @staticmethod def _broadcast_to_agents( tensor: torch.Tensor, target: torch.Tensor, agent_dim: int ) -> torch.Tensor: """Expand ``tensor`` along ``agent_dim`` to match ``target``'s shape. If ``tensor`` already has the same number of dims as ``target`` we assume it is per-agent and return it unchanged. Otherwise we unsqueeze at ``agent_dim`` and expand. """ if tensor.ndim == target.ndim: return tensor if tensor.ndim != target.ndim - 1: raise ValueError( f"MultiAgentGAE expected the reward/done/terminated tensor to " f"have either the same number of dims as the value tensor " f"(per-agent) or one fewer (team-shared). Got " f"tensor.shape={tuple(tensor.shape)}, " f"value.shape={tuple(target.shape)}." ) dim = agent_dim if agent_dim >= 0 else target.ndim + agent_dim n_agents = target.shape[dim] unsqueezed = tensor.unsqueeze(dim) expand_shape = list(unsqueezed.shape) expand_shape[dim] = n_agents return unsqueezed.expand(expand_shape) # -- GAE extension hooks ------------------------------------------------- def _prepare_signals( self, reward: Tensor, done: Tensor, terminated: Tensor, value: Tensor, ) -> tuple[Tensor, Tensor, Tensor]: return ( self._broadcast_to_agents(reward, value, self.agent_dim), self._broadcast_to_agents(done, value, self.agent_dim), self._broadcast_to_agents(terminated, value, self.agent_dim), ) def _broadcast_optional(self, tensor: Tensor, value: Tensor) -> Tensor: # Used by GAE for the auto_reset_env ``truncated`` tensor — same # broadcasting policy as the other team signals. return self._broadcast_to_agents(tensor, value, self.agent_dim) def _normalize_advantage( self, adv: Tensor, valid: torch.Tensor | None = None, groups: torch.Tensor | None = None, ) -> Tensor: # Per-agent standardisation: normalise over batch + time but keep the # agent dim independent so high-variance agents are not flattened. agent_dim = self.agent_dim if self.agent_dim >= 0 else adv.ndim + self.agent_dim if groups is not None: # Group ids combined with the agent index keep both independent. while groups.ndim < adv.ndim: groups = groups.unsqueeze(-1) agents = torch.arange(adv.shape[agent_dim], device=adv.device).reshape( [-1 if d == agent_dim else 1 for d in range(adv.ndim)] ) composite = groups.expand(adv.shape) * adv.shape[agent_dim] + agents mask = None if valid is None else self._expand_to_match(valid, adv) return _standardize_by_group(adv, composite, mask) reduce_dims = [d for d in range(adv.ndim) if d != agent_dim] if valid is None: loc = adv.mean(dim=reduce_dims, keepdim=True) scale = adv.std(dim=reduce_dims, keepdim=True).clamp_min(1e-4) return (adv - loc) / scale mask = self._expand_to_match(valid, adv) count = mask.sum(dim=reduce_dims, keepdim=True).clamp_min(1) loc = adv.masked_fill(~mask, 0).sum(dim=reduce_dims, keepdim=True) / count scale = ( ( (adv - loc) .masked_fill(~mask, 0) .pow(2) .sum(dim=reduce_dims, keepdim=True) / (count - 1).clamp_min(1) ) .sqrt() .clamp_min(1e-4) ) return (adv - loc) / scale
[docs] @register_value_estimator( ValueEstimators.VTrace, default_kwargs={"gamma": 0.99, "differentiable": True}, ) class VTrace(ValueEstimatorBase): """A class wrapper around V-Trace estimate functional. Refer to "IMPALA: Scalable Distributed Deep-RL with Importance Weighted Actor-Learner Architectures" :ref:`here <https://arxiv.org/abs/1802.01561>`_ for more context. Keyword Args: gamma (scalar): exponential mean discount. value_network (TensorDictModule): value operator used to retrieve the value estimates. actor_network (TensorDictModule): actor operator used to retrieve the log prob. rho_thresh (Union[float, Tensor]): rho clipping parameter for importance weights. Defaults to ``1.0``. c_thresh (Union[float, Tensor]): c clipping parameter for importance weights. Defaults to ``1.0``. average_adv (bool): if ``True``, the resulting advantage values will be standardized. Default is ``False``. group_key (NestedKey, optional): key of an integer entry of the input tensordict, one id per batch element such as a task id, within whose groups that standardization is done instead of over the whole batch. Defaults to ``None``. differentiable (bool, optional): if ``True``, gradients are propagated through the computation of the value function. Default is ``False``. .. note:: The proper way to make the function call non-differentiable is to decorate it in a `torch.no_grad()` context manager/decorator or pass detached parameters for functional modules. skip_existing (bool, optional): if ``True``, the value network will skip modules which outputs are already present in the tensordict. Defaults to ``None``, i.e., the value of :func:`tensordict.nn.skip_existing()` is not affected. Defaults to "state_value". advantage_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"advantage"``. value_target_key (str or tuple of str, optional): [Deprecated] the key of the advantage entry. Defaults to ``"value_target"``. value_key (str or tuple of str, optional): [Deprecated] the value key to read from the input tensordict. Defaults to ``"state_value"``. shifted (bool, optional): controls how value and next-value are obtained from the value network. ``False`` (default) calls the value network twice (once on the root tensordict, once on ``"next"``), which is correct whenever ``"next"`` may differ non-trivially from ``obs[t+1]``. Truthy values request a single call: - ``True``: fixed-budget single-call path. Inserts the true ``("next", <in_key>)`` entry after every internal truncation (``done & ~terminated``), shifts subsequent samples to the right inside a sequence of length ``T + shifted_budget`` and masks the displaced suffix via ``"shifted_valid"``. Terminal steps (``done & terminated``) do not consume budget. Retained samples use exact next observations. .. note:: **Single-step rollout assumption.** ``shifted=True`` relies on the standard one-step rollout layout produced by ``env.step`` + auto-reset: at every position where ``done[t] = False``, the value-net inputs in ``("next", <in_key>)[t]`` are expected to equal ``<in_key>[t+1]``. The backend uses this invariant to evaluate ``V`` once over a fused ``[T + shifted_budget]`` sequence instead of twice over ``[T]`` streams. The canonical pipeline that breaks the invariant is **multi-step return processing** (``MultiStep`` / n-step bootstrapping), which rewrites ``("next", obs)[t]`` to ``obs[t+n]`` with ``n > 1``. ``shifted=True`` is unsupported with multi-step returns — use ``shifted=False`` instead. Single-call paths also require that the parameters at time ``t`` and ``t+1`` are identical (i.e. ``target_params`` is not used). Defaults to ``False``. device (torch.device, optional): the device where the buffers will be instantiated. Defaults to ``torch.get_default_device()``. time_dim (int, optional): the dimension corresponding to the time in the input tensordict. If not provided, defaults to the dimension marked with the ``"time"`` name if any, and to the last dimension otherwise. Can be overridden during a call to :meth:`~.value_estimate`. Negative dimensions are considered with respect to the input tensordict. value_chunk_size (int, optional): if set, splits value-network calls into chunks of this many elements along ``value_chunk_dim``. Defaults to ``None``. num_chunks (int, optional): if set, splits value-network calls into this many chunks along ``value_chunk_dim``. Mutually exclusive with ``value_chunk_size``. ``num_chunk`` is accepted as an alias. Defaults to ``None``. num_chunk (int, optional): alias for ``num_chunks``. Cannot be set together with a different ``num_chunks`` value. Defaults to ``None``. value_chunk_dim (int, optional): dimension used for chunked value-network calls. Defaults to ``0``. shifted_budget (int, optional): number of extra value-network time slots used when ``shifted=True``. ``1`` uses a ``T+1`` budget, ``2`` can represent one internal reset plus the rollout boundary without dropping samples, and so on. Defaults to ``1``. VTrace will return an :obj:`"advantage"` entry containing the advantage value. It will also return a :obj:`"value_target"` entry with the V-Trace target value. .. note:: As other advantage functions do, if the ``value_key`` is already present in the input tensordict, the VTrace module will ignore the calls to the value network (if any) and use the provided value instead. """
[docs] @classmethod def for_loss(cls, loss_module, **hyperparams): """V-Trace needs both the critic *and* the actor. When the loss is functional, the actor stored on the loss module is a stateless template — we deep-copy it and bake the current params in, since V-Trace doesn't support a functional actor call. """ value_network = hyperparams.pop("value_network", None) if value_network is None: value_network = getattr(loss_module, "critic_network", None) if value_network is None: value_network = getattr(loss_module, "value_network", None) actor_network = hyperparams.pop("actor_network", None) if actor_network is None: actor_network = loss_module.actor_network if getattr(loss_module, "functional", False): actor_network = deepcopy(actor_network) loss_module.actor_network_params.to_module( actor_network, preserve_module_state=False ) return cls( value_network=value_network, actor_network=actor_network, **hyperparams, )
def __init__( self, *, gamma: float | torch.Tensor, actor_network: TensorDictModule, value_network: TensorDictModule, rho_thresh: float | torch.Tensor = 1.0, c_thresh: float | torch.Tensor = 1.0, average_adv: bool = False, differentiable: bool = False, skip_existing: bool | None = None, advantage_key: NestedKey | None = None, value_target_key: NestedKey | None = None, value_key: NestedKey | None = None, shifted: bool = False, device: torch.device | None = None, time_dim: int | None = None, value_chunk_size: int | None = None, num_chunks: int | None = None, num_chunk: int | None = None, value_chunk_dim: int = 0, shifted_budget: int = 1, group_key: NestedKey | None = None, ): super().__init__( shifted=shifted, value_network=value_network, differentiable=differentiable, advantage_key=advantage_key, value_target_key=value_target_key, value_key=value_key, skip_existing=skip_existing, device=device, value_chunk_size=value_chunk_size, num_chunks=num_chunks, num_chunk=num_chunk, value_chunk_dim=value_chunk_dim, shifted_budget=shifted_budget, group_key=group_key, ) if not isinstance(gamma, torch.Tensor): gamma = torch.tensor(gamma, device=self._device) if not isinstance(rho_thresh, torch.Tensor): rho_thresh = torch.tensor(rho_thresh, device=self._device) if not isinstance(c_thresh, torch.Tensor): c_thresh = torch.tensor(c_thresh, device=self._device) self.register_buffer("gamma", gamma) self.register_buffer("rho_thresh", rho_thresh) self.register_buffer("c_thresh", c_thresh) self.average_adv = average_adv self.actor_network = actor_network self.time_dim = time_dim if isinstance(gamma, torch.Tensor) and gamma.shape != (): raise NotImplementedError( "Per-value gamma is not supported yet. Gamma must be a scalar." ) @property def in_keys(self): parent_in_keys = super().in_keys extended_in_keys = parent_in_keys + [self.tensor_keys.sample_log_prob] return extended_in_keys
[docs] @_self_set_skip_existing @_self_set_grad_enabled @dispatch def forward( self, tensordict: TensorDictBase, *, params: list[Tensor] | None = None, target_params: list[Tensor] | None = None, time_dim: int | None = None, ) -> TensorDictBase: """Computes the V-Trace correction given the data in tensordict. If a functional module is provided, a nested TensorDict containing the parameters (and if relevant the target parameters) can be passed to the module. Args: tensordict (TensorDictBase): A TensorDict containing the data (an observation key, "action", "reward", "done" and "next" tensordict state as returned by the environment) necessary to compute the value estimates and the GAE. The data passed to this module should be structured as :obj:`[*B, T, F]` where :obj:`B` are the batch size, :obj:`T` the time dimension and :obj:`F` the feature dimension(s). Keyword Args: params (TensorDictBase, optional): A nested TensorDict containing the params to be passed to the functional value network module. target_params (TensorDictBase, optional): A nested TensorDict containing the target params to be passed to the functional value network module. time_dim (int, optional): the dimension corresponding to the time in the input tensordict. If not provided, defaults to the dimension marked with the ``"time"`` name if any, and to the last dimension otherwise. Negative dimensions are considered with respect to the input tensordict. Returns: An updated TensorDict with an advantage and a value_error keys as defined in the constructor. Examples: >>> value_net = TensorDictModule(nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"]) >>> actor_net = TensorDictModule(nn.Linear(3, 4), in_keys=["obs"], out_keys=["logits"]) >>> actor_net = ProbabilisticActor( ... module=actor_net, ... in_keys=["logits"], ... out_keys=["action"], ... distribution_class=OneHotCategorical, ... return_log_prob=True, ... ) >>> module = VTrace( ... gamma=0.98, ... value_network=value_net, ... actor_network=actor_net, ... differentiable=False, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> sample_log_prob = torch.randn(1, 10, 1) >>> tensordict = TensorDict({ ... "obs": obs, ... "done": done, ... "terminated": terminated, ... "sample_log_prob": sample_log_prob, ... "next": {"obs": next_obs, "reward": reward, "done": done, "terminated": terminated}, ... }, batch_size=[1, 10]) >>> _ = module(tensordict) >>> assert "advantage" in tensordict.keys() The module supports non-tensordict (i.e. unpacked tensordict) inputs too: Examples: >>> value_net = TensorDictModule(nn.Linear(3, 1), in_keys=["obs"], out_keys=["state_value"]) >>> actor_net = TensorDictModule(nn.Linear(3, 4), in_keys=["obs"], out_keys=["logits"]) >>> actor_net = ProbabilisticActor( ... module=actor_net, ... in_keys=["logits"], ... out_keys=["action"], ... distribution_class=OneHotCategorical, ... return_log_prob=True, ... ) >>> module = VTrace( ... gamma=0.98, ... value_network=value_net, ... actor_network=actor_net, ... differentiable=False, ... ) >>> obs, next_obs = torch.randn(2, 1, 10, 3) >>> reward = torch.randn(1, 10, 1) >>> done = torch.zeros(1, 10, 1, dtype=torch.bool) >>> terminated = torch.zeros(1, 10, 1, dtype=torch.bool) >>> sample_log_prob = torch.randn(1, 10, 1) >>> tensordict = TensorDict({ ... "obs": obs, ... "done": done, ... "terminated": terminated, ... "sample_log_prob": sample_log_prob, ... "next": {"obs": next_obs, "reward": reward, "done": done, "terminated": terminated}, ... }, batch_size=[1, 10]) >>> advantage, value_target = module( ... obs=obs, next_reward=reward, next_done=done, next_obs=next_obs, next_terminated=terminated, sample_log_prob=sample_log_prob ... ) """ if tensordict.batch_dims < 1: raise RuntimeError( "Expected input tensordict to have at least one dimensions, got " f"tensordict.batch_size = {tensordict.batch_size}" ) reward = tensordict.get(("next", self.tensor_keys.reward)) device = reward.device if self.gamma.device != device: self.gamma = self.gamma.to(device) gamma = self.gamma steps_to_next_obs = tensordict.get(self.tensor_keys.steps_to_next_obs, None) if steps_to_next_obs is not None: gamma = gamma ** steps_to_next_obs.view_as(reward) # Make sure we have the value and next value if self.value_network is not None: if params is not None: params = params.detach() if target_params is None: target_params = params.clone(False) with hold_out_net(self.value_network): # we may still need to pass gradient, but we don't want to assign grads to # value net params value, next_value, valid = self._call_value_nets( data=tensordict, params=params, next_params=target_params, single_call=self.shifted, value_key=self.tensor_keys.value, detach_next=True, vmap_randomness=self.vmap_randomness, ) if valid is not None: tensordict.set("shifted_valid", valid) else: value = tensordict.get(self.tensor_keys.value) next_value = tensordict.get(("next", self.tensor_keys.value)) lp = _maybe_get_or_select(tensordict, self.tensor_keys.sample_log_prob) if is_tensor_collection(lp): # Sum all values to match the batch size lp = lp.sum(dim="feature", reduce=True) log_mu = lp.view_as(value) # Compute log prob with current policy with hold_out_net(self.actor_network): log_pi = _call_actor_net( actor_net=self.actor_network, data=tensordict, params=None, log_prob_key=self.tensor_keys.sample_log_prob, ) log_pi = log_pi.view_as(value) time_dim = self._get_time_dim(time_dim, tensordict) valid = tensordict.get("shifted_valid", default=None) data_for_value = self._prepare_shifted_tensordict(tensordict, valid, time_dim) reward = data_for_value.get(("next", self.tensor_keys.reward)) # Compute the V-Trace correction done = data_for_value.get(("next", self.tensor_keys.done)) terminated = data_for_value.get(("next", self.tensor_keys.terminated)) adv, value_target = vtrace_advantage_estimate( gamma, log_pi, log_mu, value, next_value, reward, done, terminated, rho_thresh=self.rho_thresh, c_thresh=self.c_thresh, time_dim=time_dim, ) if self.average_adv: groups = self._group_ids(tensordict) if groups is None: loc = adv.mean() scale = adv.std().clamp_min(1e-5) adv = (adv - loc) / scale else: adv = _standardize_by_group(adv, groups, eps=1e-5) tensordict.set(self.tensor_keys.advantage, adv) tensordict.set(self.tensor_keys.value_target, value_target) self._mask_shifted_output(tensordict, valid) return tensordict
def _deprecate_class(cls, new_cls): @wraps(cls.__init__) def new_init(self, *args, **kwargs): warnings.warn(f"class {cls} is deprecated, please use {new_cls} instead.") cls.__init__(self, *args, **kwargs) cls.__init__ = new_init TD0Estimate = type("TD0Estimate", TD0Estimator.__bases__, dict(TD0Estimator.__dict__)) _deprecate_class(TD0Estimate, TD0Estimator) TD1Estimate = type("TD1Estimate", TD1Estimator.__bases__, dict(TD1Estimator.__dict__)) _deprecate_class(TD1Estimate, TD1Estimator) TDLambdaEstimate = type( "TDLambdaEstimate", TDLambdaEstimator.__bases__, dict(TDLambdaEstimator.__dict__) ) _deprecate_class(TDLambdaEstimate, TDLambdaEstimator)