Rate this Page

Source code for torchrl.envs.libs.mujoco_playground

# 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 copy
import importlib.util
import warnings
from dataclasses import dataclass, field
from typing import Literal

import torch
from tensordict import TensorDict, TensorDictBase

from torchrl.data.tensor_specs import Bounded, Composite, Unbounded
from torchrl.envs.batched_envs import ParallelEnv
from torchrl.envs.common import _EnvPostInit, _EnvWrapper
from torchrl.envs.libs.jax_utils import (
    _ndarray_to_tensor,
    _tensor_to_ndarray,
    _tree_flatten,
    _tree_reshape,
)
from torchrl.envs.utils import _classproperty

_has_mujoco_playground = importlib.util.find_spec("mujoco_playground") is not None


def _listerize(ranges: list) -> list[int]:
    """Expand a mixed list of ints and ``(lo, hi)`` tuples into a flat list of ints.

    .. warning::
        Tuple ranges are **inclusive** on both ends: ``(0, 5)`` expands to
        ``[0, 1, 2, 3, 4, 5]``, **not** Python's usual half-open
        ``range(0, 5) == [0, 1, 2, 3, 4]``. This convention is kept for
        backwards compatibility with MABrax-style index lists.

    Examples:
        >>> _listerize([(0, 2), 5, (7, 8)])
        [0, 1, 2, 5, 7, 8]
    """
    result = []
    for r in ranges:
        if isinstance(r, tuple):
            result.extend(range(r[0], r[1] + 1))
        else:
            result.append(r)
    return result


[docs] @dataclass(frozen=True) class MujocoPlaygroundAgentSpec: """Observation/action slice definition for one agent in a cooperative task. Args: name (str): group key used in output TensorDicts (e.g. ``"agent_0"``). action_indices (list of int): indices into the global action vector that this agent controls. Must be non-overlapping across all agents and together must cover ``range(env.action_size)``. observation_indices (list of int or dict of str to list of int): for flat-obs environments, a list of ints selecting from the global observation vector. For dict-obs environments, a ``dict`` mapping each observation key to a list of ints selecting from that key's sub-vector. """ name: str action_indices: list[int] observation_indices: list[int] | dict[str, list[int]]
[docs] @dataclass(frozen=True) class MujocoPlaygroundAgentMapping: """Agent mapping for :class:`MujocoPlaygroundWrapper`. Defines how to split a single-agent MuJoCo Playground environment into a cooperative multi-agent task by partitioning the observation and action vectors among named agents. Args: agents (list of MujocoPlaygroundAgentSpec): one entry per agent, defining each agent's observation slice and the action indices it controls. homogenization_mode (str, optional): strategy for unifying heterogeneous observation/action shapes across agents so that a single shared policy can be used. - ``"none"`` (default): each agent receives exactly its own observation/action slice; shapes may differ across agents. - ``"max"``: observations are padded to ``max_obs_size + n_agents`` (a one-hot agent-ID prefix is prepended), and actions are padded to ``max_action_size``. All agents share the same input/output shape. **Policy contract (max):** each agent's observation layout is ``[one_hot_id (n_agents) | raw_obs (len(observation_indices)) | zero_pad]``, for a total length of ``max_obs_size + n_agents``. The action vector emitted by the policy has length ``max_action_size``; only the first ``len(action_indices)`` entries are used and the rest are silently dropped. - ``"concat"``: each agent receives the full global observation/action vector with zeros at positions it does not own. All agents share the same input/output shape equal to the full environment dimensions. **Policy contract (concat):** the observation has length ``env.observation_size`` with zeros at positions not owned by the agent. The policy must emit a full-length action vector; only entries at the agent's own ``action_indices`` are applied to the environment and entries at indices owned by other agents are ignored. Examples: >>> mapping = MujocoPlaygroundAgentMapping( ... agents=[ ... MujocoPlaygroundAgentSpec( ... name="agent_0", ... action_indices=[0, 1, 2], ... observation_indices=[0, 1, 2, 3], ... ), ... MujocoPlaygroundAgentSpec( ... name="agent_1", ... action_indices=[3, 4, 5], ... observation_indices=[4, 5, 6, 7], ... ), ... ], ... homogenization_mode="none", ... ) """ agents: list[MujocoPlaygroundAgentSpec] = field(default_factory=list) homogenization_mode: Literal["none", "max", "concat"] = "none"
def _validate_agent_mapping( mapping: MujocoPlaygroundAgentMapping, action_size: int, observation_size: int | dict, ) -> None: """Validate a :class:`MujocoPlaygroundAgentMapping` against environment dims. Args: mapping (MujocoPlaygroundAgentMapping): the mapping to validate. action_size (int): total action dimension of the environment. observation_size (int or dict): flat obs size or dict obs size map. Raises: ValueError: if action_indices overlap, are out of range, do not cover ``range(action_size)``, or if observation_indices are invalid. """ if not mapping.agents: raise ValueError("MujocoPlaygroundAgentMapping.agents must not be empty.") # Validate action indices seen: set[int] = set() for agent in mapping.agents: for idx in agent.action_indices: if idx < 0 or idx >= action_size: raise ValueError( f"Agent '{agent.name}' has action index {idx} out of range " f"[0, {action_size})." ) if idx in seen: raise ValueError( f"Action index {idx} appears more than once across agents " f"(duplicate found in agent '{agent.name}'). " "Action indices must be non-overlapping." ) seen.add(idx) if seen != set(range(action_size)): missing = sorted(set(range(action_size)) - seen) raise ValueError( f"Action indices do not cover range({action_size}). " f"Missing indices: {missing}." ) # Validate observation indices obs_is_dict = isinstance(observation_size, dict) for agent in mapping.agents: if obs_is_dict: if not isinstance(agent.observation_indices, dict): raise ValueError( f"Agent '{agent.name}': environment has dict observations, " "so observation_indices must be a dict mapping obs keys to " "lists of ints." ) for key, indices in agent.observation_indices.items(): if key not in observation_size: raise ValueError( f"Agent '{agent.name}': observation key '{key}' not " f"found in environment observation_size keys: " f"{list(observation_size.keys())}." ) key_size = ( int(observation_size[key][0]) if hasattr(observation_size[key], "__len__") else int(observation_size[key]) ) for idx in indices: if idx < 0 or idx >= key_size: raise ValueError( f"Agent '{agent.name}': observation index {idx} " f"for key '{key}' out of range [0, {key_size})." ) else: if not isinstance(agent.observation_indices, list): raise ValueError( f"Agent '{agent.name}': environment has flat observations, " "so observation_indices must be a list of ints." ) for idx in agent.observation_indices: if idx < 0 or idx >= observation_size: raise ValueError( f"Agent '{agent.name}' has observation index {idx} " f"out of range [0, {observation_size})." ) # Predefined multi-agent partitionings for common MuJoCo locomotion tasks. # # .. warning:: # These mappings mirror the decompositions used in JaxMARL's `MABrax`_ # suite, which targets `Brax`_'s observation/action layouts. MuJoCo # Playground is a separate code base (built on MJX) whose envs may expose # **different** observation layouts even when the action sizes match. As a # result, these mappings will produce per-agent observation slices that # are syntactically valid (indices in range) but may not be semantically # equivalent to MABrax's decomposition. Verify the partitioning matches # your env before training. # # .. _MABrax: https://github.com/FLAIROx/JaxMARL/tree/main/jaxmarl/environments/mabrax # .. _Brax: https://github.com/google/brax KNOWN_MARL_MAPPINGS: dict[str, MujocoPlaygroundAgentMapping] = { "ant_4x2": MujocoPlaygroundAgentMapping( agents=[ MujocoPlaygroundAgentSpec( "agent_0", [0, 1], _listerize([(0, 5), 6, 7, 9, 11, (13, 18), 19, 20]), ), MujocoPlaygroundAgentSpec( "agent_1", [2, 3], _listerize([(0, 5), 7, 8, 9, 11, (13, 18), 21, 22]), ), MujocoPlaygroundAgentSpec( "agent_2", [4, 5], _listerize([(0, 5), 7, 9, 10, 11, (13, 18), 23, 24]), ), MujocoPlaygroundAgentSpec( "agent_3", [6, 7], _listerize([(0, 5), 7, 9, 11, 12, (13, 18), 25, 26]), ), ] ), "halfcheetah_6x1": MujocoPlaygroundAgentMapping( agents=[ MujocoPlaygroundAgentSpec( "agent_0", [0], _listerize([(1, 2), 3, 4, 6, (9, 11), 12]) ), MujocoPlaygroundAgentSpec( "agent_1", [1], _listerize([(1, 2), 3, 4, 5, (9, 11), 13]) ), MujocoPlaygroundAgentSpec( "agent_2", [2], _listerize([(1, 2), 4, 5, (9, 11), 14]) ), MujocoPlaygroundAgentSpec( "agent_3", [3], _listerize([(1, 2), 3, 6, 7, (9, 11), 15]) ), MujocoPlaygroundAgentSpec( "agent_4", [4], _listerize([(1, 2), 6, 7, 8, (9, 11), 16]) ), MujocoPlaygroundAgentSpec( "agent_5", [5], _listerize([(1, 2), 7, 8, (9, 11)]) ), ] ), "hopper_3x1": MujocoPlaygroundAgentMapping( agents=[ MujocoPlaygroundAgentSpec( "agent_0", [0], _listerize([(0, 1), 2, 3, (5, 7), 8]) ), MujocoPlaygroundAgentSpec( "agent_1", [1], _listerize([(0, 1), 2, 3, 4, (5, 7), 9]) ), MujocoPlaygroundAgentSpec( "agent_2", [2], _listerize([(0, 1), 3, 4, (5, 7), 10]) ), ] ), "humanoid_9|8": MujocoPlaygroundAgentMapping( agents=[ MujocoPlaygroundAgentSpec( "agent_0", [0, 1, 2, 11, 12, 13, 14, 15, 16], _listerize( [ (0, 10), (12, 14), (16, 30), (39, 44), (55, 94), (115, 124), (145, 184), (191, 214), (227, 232), (245, 277), (286, 291), (298, 321), (334, 339), (352, 375), ] ), ), MujocoPlaygroundAgentSpec( "agent_1", [3, 4, 5, 6, 7, 8, 9, 10], _listerize( [ (0, 15), (22, 27), (31, 38), (85, 144), (209, 244), (269, 274), (278, 285), (316, 351), ] ), ), ] ), "walker2d_2x3": MujocoPlaygroundAgentMapping( agents=[ MujocoPlaygroundAgentSpec( "agent_0", [0, 1, 2], _listerize([0, (2, 5), (8, 9), (11, 13)]) ), MujocoPlaygroundAgentSpec( "agent_1", [3, 4, 5], _listerize([0, 2, (5, 9), (14, 16)]) ), ] ), } # Cache the (immutable) registry listing: enumerating the suites is cheap but # `available_envs` is a classproperty that can be hit repeatedly, so we build # the list once per process. _ALL_ENVS_CACHE: list[str] | None = None def _get_envs() -> list[str]: if not _has_mujoco_playground: raise ImportError( "mujoco_playground is not installed in your virtual environment." ) global _ALL_ENVS_CACHE if _ALL_ENVS_CACHE is None: from mujoco_playground import dm_control_suite, locomotion, manipulation _ALL_ENVS_CACHE = ( list(dm_control_suite.ALL_ENVS) + list(locomotion.ALL_ENVS) + list(manipulation.ALL_ENVS) ) return list(_ALL_ENVS_CACHE) class _MujocoPlaygroundMeta(_EnvPostInit): """Metaclass returning a ParallelEnv when num_workers > 1.""" def __call__(cls, *args, num_workers: int | None = None, **kwargs): # Accept num_workers either as the explicit kwarg or via the kwargs # dict (e.g. when constructed from a config that builds kwargs # dynamically), mirroring `_BraxMeta`. if num_workers is None: num_workers = kwargs.pop("num_workers", 1) else: kwargs.pop("num_workers", None) num_workers = int(num_workers) if cls.__name__ == "MujocoPlaygroundEnv" and num_workers > 1: env_name = args[0] if len(args) >= 1 else kwargs.get("env_name") env_kwargs = {k: v for k, v in kwargs.items() if k != "env_name"} def make_env(_env_name=env_name, _kwargs=env_kwargs): return cls(_env_name, num_workers=1, **_kwargs) return ParallelEnv(num_workers, make_env, metadata_from_workers=True) return super().__call__(*args, **kwargs)
[docs] class MujocoPlaygroundWrapper(_EnvWrapper): """Google DeepMind MuJoCo Playground environment wrapper. MuJoCo Playground is a collection of JAX-based MJX environments spanning locomotion, manipulation, and dm_control suite tasks. GitHub: https://github.com/google-deepmind/mujoco_playground Args: env (mujoco_playground._src.mjx_env.MjxEnv): the environment to wrap. agent_mapping (:class:`MujocoPlaygroundAgentMapping` or str, optional): if provided, the environment is decomposed into a cooperative multi-agent task. Can be either a :class:`MujocoPlaygroundAgentMapping` instance or a string key into :data:`KNOWN_MARL_MAPPINGS`. Known string values: ``"ant_4x2"``, ``"halfcheetah_6x1"``, ``"hopper_3x1"``, ``"humanoid_9|8"``, ``"walker2d_2x3"``. Defaults to ``None`` (single-agent mode). Keyword Args: from_pixels (bool, optional): Not yet supported. frame_skip (int, optional): if provided, indicates for how many steps the same action is to be repeated. The observation returned will be the last observation of the sequence, whereas the reward will be the sum of rewards across steps. device (torch.device, optional): if provided, the device on which the data is to be cast. Defaults to ``torch.device("cpu")``. batch_size (torch.Size, optional): the batch size of the environment. In ``mujoco_playground``, this controls the number of environments simulated in parallel via JAX's ``vmap`` on a single device (GPU/TPU). Defaults to ``torch.Size([])``. allow_done_after_reset (bool, optional): if ``True``, it is tolerated for envs to be ``done`` just after :meth:`reset` is called. Defaults to ``False``. Attributes: available_envs: environments available to build .. note:: Unlike :class:`~torchrl.envs.BraxWrapper`, this wrapper does **not** copy the underlying JAX env state into the output ``TensorDict``. The state is kept on the env instance (``self._current_state``) and rolled forward by ``_step``; this avoids round-tripping MJX/pytree state through ``TensorDict``, which would break MJX's metadata pytree registration. As a consequence, the output ``TensorDict`` only contains ``observation`` (or per-key obs for dict-obs envs), ``reward``, ``done`` and ``terminated`` — there is no ``state`` key. .. warning:: Because the JAX state is held on the instance rather than carried in the ``TensorDict``, **partial resets are not supported**: any call to :meth:`reset` re-initialises the *entire* vmapped batch, ignoring the ``"_reset"`` mask. For a ``batch_size`` greater than one whose sub-environments terminate at different steps (e.g. early-terminating locomotion tasks driven by a data collector), prefer scaling with ``num_workers`` (one scalar env per worker) over a single large vmapped ``batch_size``. This matches the behaviour of :class:`~torchrl.envs.BraxWrapper`. .. note:: ``terminated`` is set equal to ``done``; this wrapper does not expose a separate time-limit ``truncated`` signal. For finite-horizon tasks where bootstrapping at the episode boundary matters, append a :class:`~torchrl.envs.transforms.StepCounter` (with ``max_steps``) or otherwise track truncations yourself. Examples: >>> from mujoco_playground import dm_control_suite >>> from torchrl.envs import MujocoPlaygroundWrapper >>> import torch >>> device = "cuda" if torch.cuda.is_available() else "cpu" >>> base_env = dm_control_suite.load("CartpoleBalance") >>> env = MujocoPlaygroundWrapper(base_env, device=device) >>> env.set_seed(0) >>> td = env.reset() >>> td["action"] = env.action_spec.rand() >>> td = env.step(td) >>> print(td) TensorDict( fields={ action: Tensor(torch.Size([1]), dtype=torch.float32), done: Tensor(torch.Size([1]), dtype=torch.bool), next: TensorDict( fields={ done: Tensor(torch.Size([1]), dtype=torch.bool), observation: Tensor(torch.Size([5]), dtype=torch.float32), reward: Tensor(torch.Size([1]), dtype=torch.float32), terminated: Tensor(torch.Size([1]), dtype=torch.bool)}, batch_size=torch.Size([]), device=cpu, is_shared=False), observation: Tensor(torch.Size([5]), dtype=torch.float32), terminated: Tensor(torch.Size([1]), dtype=torch.bool)}, batch_size=torch.Size([]), device=cpu, is_shared=False) >>> print(env.available_envs) ['AcrobotSwingup', 'AcrobotSwingupSparse', 'BallInCupCatch', ...] """ git_url = "https://github.com/google-deepmind/mujoco_playground" @_classproperty def available_envs(cls): if not _has_mujoco_playground: return [] return list(_get_envs()) libname = "mujoco_playground" _lib = None _jax = None @_classproperty def lib(cls): if cls._lib is not None: return cls._lib import mujoco_playground cls._lib = mujoco_playground return mujoco_playground @_classproperty def jax(cls): if cls._jax is not None: return cls._jax import jax cls._jax = jax return jax def __init__( self, env=None, *, agent_mapping: MujocoPlaygroundAgentMapping | str | None = None, **kwargs, ): if isinstance(agent_mapping, str): if agent_mapping not in KNOWN_MARL_MAPPINGS: raise ValueError( f"Unknown agent_mapping '{agent_mapping}'. " f"Known mappings: {sorted(KNOWN_MARL_MAPPINGS)}." ) warnings.warn( f"Resolving agent_mapping='{agent_mapping}' against " "KNOWN_MARL_MAPPINGS. These mappings were authored against " "JaxMARL's MABrax (Brax) observation layouts and may not be " "semantically equivalent on the corresponding mujoco_playground " "env. Verify the partitioning matches your env before training.", stacklevel=2, ) # Deepcopy so users mutating the returned mapping cannot leak # changes back into the module-level KNOWN_MARL_MAPPINGS dict # (the dataclass is frozen but its `agents` list is not). agent_mapping = copy.deepcopy(KNOWN_MARL_MAPPINGS[agent_mapping]) if env is not None: kwargs["env"] = env # `_seed_calls_reset` is part of the `_EnvWrapper` interface; the # default `None` keeps the base class's behaviour (no extra reset on # `set_seed`). Left as an explicit attribute to make the contract # visible to subclasses. self._seed_calls_reset = None self._agent_mapping = agent_mapping super().__init__(**kwargs) if not self.device: warnings.warn( f"No device is set for env {self}. " "Setting a device in MujocoPlayground wrapped environments is strongly recommended." ) def _check_kwargs(self, kwargs: dict): if "env" not in kwargs: raise TypeError("Could not find environment key 'env' in kwargs.") env = kwargs["env"] for attr in ("reset", "step", "action_size", "observation_size"): if not hasattr(env, attr): raise TypeError( f"env is missing required attribute '{attr}'. " "Expected a mujoco_playground MjxEnv instance." ) def _build_env( self, env, _seed: int | None = None, from_pixels: bool = False, **kwargs, ): self.from_pixels = from_pixels if from_pixels: raise NotImplementedError( "from_pixels=True is not yet supported within MujocoPlaygroundWrapper" ) if kwargs: raise ValueError(f"Unsupported kwargs: {sorted(kwargs)}") return env def _obs_is_dict(self) -> bool: """Returns True if the wrapped environment uses dict observations.""" return isinstance(self._env.observation_size, dict) def _make_specs(self, env) -> None: if self._agent_mapping is not None: self._make_marl_specs(env) return obs_is_dict = self._obs_is_dict() self.action_spec = Bounded( low=-1, high=1, shape=(*self.batch_size, env.action_size), device=self.device, ) self.reward_spec = Unbounded(shape=[*self.batch_size, 1], device=self.device) if not obs_is_dict: self.observation_spec = Composite( observation=Unbounded( shape=(*self.batch_size, env.observation_size), device=self.device, ), shape=self.batch_size, ) else: obs_specs = { key: Unbounded(shape=(*self.batch_size, *shape), device=self.device) for key, shape in env.observation_size.items() } self.observation_spec = Composite(**obs_specs, shape=self.batch_size) def _make_marl_specs(self, env) -> None: """Build nested per-agent Composite specs when agent_mapping is set.""" mapping = self._agent_mapping _validate_agent_mapping(mapping, env.action_size, env.observation_size) agents = mapping.agents n_agents = len(agents) mode = mapping.homogenization_mode obs_is_dict = self._obs_is_dict() # Compute per-agent action dimension if mode == "none": action_dims = [len(a.action_indices) for a in agents] elif mode == "max": max_act = max(len(a.action_indices) for a in agents) action_dims = [max_act] * n_agents else: # concat action_dims = [env.action_size] * n_agents # Compute per-agent observation dimension if obs_is_dict: # For dict obs, per-agent obs size = sum of all selected key dims raw_obs_sizes = [] for a in agents: total = sum(len(idxs) for idxs in a.observation_indices.values()) raw_obs_sizes.append(total) else: raw_obs_sizes = [len(a.observation_indices) for a in agents] if obs_is_dict and mode != "none": raise NotImplementedError( f"homogenization_mode='{mode}' is not yet supported for " "dict-observation environments." ) if mode == "none": obs_dims = raw_obs_sizes elif mode == "max": max_obs = max(raw_obs_sizes) obs_dims = [max_obs + n_agents] * n_agents # +n_agents for one-hot else: # concat obs_dims = [env.observation_size] * n_agents # Cache the per-agent dims (and a per-agent index tensor for flat obs) # so the hot-path helpers `_split_obs_for_agents` / # `_reconstruct_global_action` don't recompute them on every step. self._marl_n_agents = n_agents self._marl_obs_dims = obs_dims self._marl_action_dims = action_dims if not obs_is_dict: self._marl_obs_index = [ torch.as_tensor( a.observation_indices, dtype=torch.long, device=self.device ) for a in agents ] else: self._marl_obs_index = None self._marl_action_index = [ torch.as_tensor(a.action_indices, dtype=torch.long, device=self.device) for a in agents ] # Build per-agent specs action_spec_dict = {} obs_spec_dict = {} reward_spec_dict = {} for i, agent in enumerate(agents): action_spec_dict[agent.name] = Composite( action=Bounded( low=-1, high=1, shape=(*self.batch_size, action_dims[i]), device=self.device, ), shape=self.batch_size, ) obs_spec_dict[agent.name] = Composite( observation=Unbounded( shape=(*self.batch_size, obs_dims[i]), device=self.device, ), shape=self.batch_size, ) reward_spec_dict[agent.name] = Composite( reward=Unbounded( shape=(*self.batch_size, 1), device=self.device, ), shape=self.batch_size, ) self.action_spec = Composite( **action_spec_dict, shape=self.batch_size, ) self.reward_spec = Composite( **reward_spec_dict, shape=self.batch_size, ) self.observation_spec = Composite( **obs_spec_dict, shape=self.batch_size, ) def _init_env(self) -> int | None: jax = self.jax self._key = None self._current_state = None # jit inside vmap (not outside) avoids retracing when batch_size changes # and lets XLA fuse the per-env kernel before stacking across the batch. self._vmap_jit_env_reset = jax.vmap(jax.jit(self._env.reset)) self._vmap_jit_env_step = jax.vmap(jax.jit(self._env.step)) def _set_seed(self, seed: int | None) -> None: # `_reset` falls back to seed=0 when no key has been initialised, so # accept the same convention here for consistency. jax = self.jax if seed is None: seed = 0 self._key = jax.random.PRNGKey(int(seed)) def _extract_obs(self, state) -> dict: """Extract observation tensors directly from raw JAX state. For flat obs: returns ``{"observation": tensor}``. For dict obs: returns ``{key: tensor, ...}`` spread directly. """ if not self._obs_is_dict(): return {"observation": _ndarray_to_tensor(state.obs).to(self.device)} else: return { key: _ndarray_to_tensor(state.obs[key]).to(self.device) for key in self._env.observation_size } def _split_obs_for_agents(self, state) -> dict: """Build per-agent observation TensorDicts from a raw JAX state. Args: state: raw JAX env state with ``.obs`` attribute. Returns: dict mapping each agent name to a :class:`~tensordict.TensorDict` with an ``"observation"`` key whose shape matches the agent's spec. """ mapping = self._agent_mapping agents = mapping.agents n_agents = self._marl_n_agents mode = mapping.homogenization_mode obs_is_dict = self._obs_is_dict() if obs_is_dict: obs_raw = { key: _ndarray_to_tensor(state.obs[key]).to(self.device) for key in self._env.observation_size } else: obs_raw = _ndarray_to_tensor(state.obs).to(self.device) result = {} for i, agent in enumerate(agents): if obs_is_dict: # Concatenate selected indices from each obs key # (only "none" mode is supported for dict obs, validated at spec build time) parts = [] for key, idxs in agent.observation_indices.items(): parts.append(obs_raw[key][..., idxs]) obs_tensor = torch.cat(parts, dim=-1) else: # Flat obs: gather the agent's own observation slice (index # tensor and target sizes are cached in `_make_marl_specs`). raw = obs_raw[..., self._marl_obs_index[i]] if mode == "none": obs_tensor = raw else: # "max" and "concat" both scatter `raw` into a zero tensor # sized to the agent's cached observation spec. obs_tensor = torch.zeros( *self.batch_size, self._marl_obs_dims[i], dtype=obs_raw.dtype, device=self.device, ) if mode == "max": # layout: [one-hot id (n_agents) | raw obs | zero pad] obs_tensor[..., i] = 1.0 obs_tensor[..., n_agents : n_agents + raw.shape[-1]] = raw else: # concat: scatter raw at the agent's own obs indices obs_tensor[..., self._marl_obs_index[i]] = raw result[agent.name] = TensorDict( {"observation": obs_tensor}, batch_size=self.batch_size, device=self.device, ) return result def _reconstruct_global_action(self, tensordict: TensorDictBase) -> torch.Tensor: """Reassemble the global action tensor from per-agent action slices. Args: tensordict (TensorDictBase): input TensorDict containing per-agent action tensors at ``(agent_name, "action")``. Returns: :class:`torch.Tensor` of shape ``(*batch_size, action_size)`` suitable for passing to the underlying JAX environment. """ mapping = self._agent_mapping mode = mapping.homogenization_mode global_action = torch.zeros( *self.batch_size, self._env.action_size, dtype=torch.float32, device=self.device, ) for i, agent in enumerate(mapping.agents): a = tensordict.get((agent.name, "action")) idx = self._marl_action_index[i] if mode == "none": global_action[..., idx] = a elif mode == "max": # Policy outputs up to max_action_size; only first n are real global_action[..., idx] = a[..., : idx.shape[0]] else: # concat # Policy outputs full global action; take only this agent's slice global_action[..., idx] = a[..., idx] return global_action def _reset(self, tensordict: TensorDictBase = None, **kwargs) -> TensorDictBase: jax = self.jax if getattr(self, "_key", None) is None: seed = getattr(self, "_seed", None) if seed is None: seed = 0 self._key = jax.random.PRNGKey(int(seed)) self._key, *keys = jax.random.split(self._key, 1 + self.numel()) state = self._vmap_jit_env_reset(jax.numpy.stack(keys)) # vmap output has leading dim = batch_size.numel() (flat). # _tree_reshape restores the original batch shape (e.g. [4, 8]). if len(self.batch_size) != 1: state = _tree_reshape(state, self.batch_size) # Store JAX state directly — avoids converting MJX/pytree state to # TensorDict and back, which breaks MJX's metadata pytree registration. self._current_state = state done_shape = (*self.batch_size, 1) done = _ndarray_to_tensor(state.done).to(self.device).bool().view(*done_shape) if self._agent_mapping is not None: source = { **self._split_obs_for_agents(state), "done": done, "terminated": done.clone(), } else: source = { **self._extract_obs(state), "done": done, "terminated": done.clone(), } return TensorDict._new_unsafe( source=source, batch_size=self.batch_size, device=self.device, ) def _step(self, tensordict: TensorDictBase) -> TensorDictBase: state = self._current_state if self._agent_mapping is not None: action_tensor = self._reconstruct_global_action(tensordict) else: action_tensor = tensordict.get("action") action = _tensor_to_ndarray(action_tensor) # vmap expects a flat leading batch dim, so collapse [d0, d1, ...] → [d0*d1*...]. if len(self.batch_size) != 1: state = _tree_flatten(state, self.batch_size) action = _tree_flatten(action, self.batch_size) next_state = self._vmap_jit_env_step(state, action) # Restore the original batch shape after vmap. if len(self.batch_size) != 1: next_state = _tree_reshape(next_state, self.batch_size) self._current_state = next_state done_shape = (*self.batch_size, 1) reward = _ndarray_to_tensor(next_state.reward).to(self.device).view(*done_shape) done = ( _ndarray_to_tensor(next_state.done).to(self.device).bool().view(*done_shape) ) if self._agent_mapping is not None: agent_tds = self._split_obs_for_agents(next_state) for agent in self._agent_mapping.agents: agent_tds[agent.name].set("reward", reward.clone()) source = { **agent_tds, "done": done, "terminated": done.clone(), } else: source = { **self._extract_obs(next_state), "reward": reward, "done": done, "terminated": done.clone(), } return TensorDict._new_unsafe( source=source, batch_size=self.batch_size, device=self.device, )
[docs] class MujocoPlaygroundEnv(MujocoPlaygroundWrapper, metaclass=_MujocoPlaygroundMeta): """Google DeepMind MuJoCo Playground environment wrapper built with the environment name. MuJoCo Playground is a collection of JAX-based MJX environments spanning locomotion, manipulation, and dm_control suite tasks. All environments from all suites are accessible by name via the unified registry. GitHub: https://github.com/google-deepmind/mujoco_playground Args: env_name (str): the environment name of the env to wrap. Must be part of :attr:`~.available_envs`. config (ml_collections.ConfigDict, optional): configuration for the environment. If ``None``, the default configuration is used. Defaults to ``None``. config_overrides (dict, optional): overrides to apply on top of ``config``. Defaults to ``None``. agent_mapping (:class:`MujocoPlaygroundAgentMapping` or str, optional): if provided, the environment is decomposed into a cooperative multi-agent task. Can be either a :class:`MujocoPlaygroundAgentMapping` instance or a string key into :data:`KNOWN_MARL_MAPPINGS`. Known string values: ``"ant_4x2"``, ``"halfcheetah_6x1"``, ``"hopper_3x1"``, ``"humanoid_9|8"``, ``"walker2d_2x3"``. The mapping and the environment name are validated against each other at construction time. Defaults to ``None`` (single-agent mode). Keyword Args: from_pixels (bool, optional): Not yet supported. frame_skip (int, optional): if provided, indicates for how many steps the same action is to be repeated. The observation returned will be the last observation of the sequence, whereas the reward will be the sum of rewards across steps. device (torch.device, optional): if provided, the device on which the data is to be cast. Defaults to ``torch.device("cpu")``. batch_size (torch.Size, optional): the batch size of the environment. In ``mujoco_playground``, this controls the number of environments simulated in parallel via JAX's ``vmap`` on a single device (GPU/TPU). Defaults to ``torch.Size([])``. allow_done_after_reset (bool, optional): if ``True``, it is tolerated for envs to be ``done`` just after :meth:`reset` is called. Defaults to ``False``. num_workers (int, optional): if greater than 1, a :class:`~torchrl.envs.ParallelEnv` will be returned instead, with each worker instantiating its own :class:`~torchrl.envs.MujocoPlaygroundEnv` instance and reporting metadata directly to the parent. Defaults to ``None``. .. note:: There are two orthogonal ways to scale environment throughput: - **batch_size**: Uses MuJoCo Playground's native JAX-based vectorization (``vmap``) to run multiple environments in parallel on a single GPU/TPU. - **num_workers**: Uses TorchRL's :class:`~torchrl.envs.ParallelEnv` to spawn multiple Python processes, each running its own ``MujocoPlaygroundEnv``. These can be combined: ``MujocoPlaygroundEnv("CartpoleBalance", batch_size=[128], num_workers=4)`` creates 4 worker processes each running 128 vectorized environments. Attributes: available_envs: environments available to build (all suites combined) Examples: >>> from torchrl.envs import MujocoPlaygroundEnv >>> import torch >>> device = "cuda" if torch.cuda.is_available() else "cpu" >>> env = MujocoPlaygroundEnv("CartpoleBalance", device=device) >>> env.set_seed(0) >>> td = env.reset() >>> td["action"] = env.action_spec.rand() >>> td = env.step(td) >>> print(td) TensorDict( fields={ action: Tensor(torch.Size([1]), dtype=torch.float32), done: Tensor(torch.Size([1]), dtype=torch.bool), next: TensorDict( fields={ done: Tensor(torch.Size([1]), dtype=torch.bool), observation: Tensor(torch.Size([5]), dtype=torch.float32), reward: Tensor(torch.Size([1]), dtype=torch.float32), terminated: Tensor(torch.Size([1]), dtype=torch.bool)}, batch_size=torch.Size([]), device=cpu, is_shared=False), observation: Tensor(torch.Size([5]), dtype=torch.float32), terminated: Tensor(torch.Size([1]), dtype=torch.bool)}, batch_size=torch.Size([]), device=cpu, is_shared=False) >>> print(env.available_envs) ['AcrobotSwingup', 'AcrobotSwingupSparse', 'BallInCupCatch', ...] To take advantage of MuJoCo Playground's JAX-based parallelism, pass a ``batch_size`` to run multiple environments in parallel on a single device: Examples: >>> from torchrl.envs import MujocoPlaygroundEnv >>> import torch >>> device = "cuda" if torch.cuda.is_available() else "cpu" >>> env = MujocoPlaygroundEnv("CartpoleBalance", batch_size=[128], device=device) >>> env.set_seed(0) >>> td = env.rollout(100) >>> print(td.shape) torch.Size([128, 100]) """ def __init__( self, env_name: str, *, config=None, config_overrides=None, **kwargs, ): kwargs["env_name"] = env_name if config is not None: kwargs["config"] = config if config_overrides is not None: kwargs["config_overrides"] = config_overrides super().__init__(**kwargs) def _build_env( self, env_name: str, config=None, config_overrides=None, **kwargs, ): if not _has_mujoco_playground: raise ImportError( f"mujoco_playground not found, unable to create {env_name}. " f"Consider downloading and installing mujoco_playground from" f" {self.git_url}" ) from mujoco_playground import registry # `from_pixels=True` is rejected by the parent wrapper; reject any # pixel/render related kwarg early to keep the error message close to # the construction site. from_pixels = kwargs.pop("from_pixels", False) if kwargs: raise ValueError(f"Unsupported kwargs: {sorted(kwargs)}") # ``frame_skip`` is not implemented in this wrapper's ``_step``; when # ``frame_skip > 1`` the ``EnvBase`` metaclass auto-appends a # :class:`~torchrl.envs.transforms.FrameSkipTransform` (see # ``EnvBase._has_frame_skip``), so there is nothing to do here. env = registry.load(env_name, config=config, config_overrides=config_overrides) return super()._build_env(env, from_pixels=from_pixels) @property def env_name(self) -> str: return self._constructor_kwargs["env_name"] def _check_kwargs(self, kwargs: dict): # We intentionally only validate `env_name` here (not the env's # attributes) because `_build_env` calls `registry.load(env_name, ...)` # to produce a valid MjxEnv before the wrapper's own `_check_kwargs` # would otherwise run. if "env_name" not in kwargs: raise TypeError("Expected 'env_name' to be part of kwargs") def __repr__(self) -> str: return ( f"{self.__class__.__name__}(env_name={self.env_name!r}, " f"batch_size={self.batch_size}, device={self.device})" )