Source code for pettingzoo.utils.wrappers.nan_random

"""Wrappers that replace NaN actions with a random action from the agent's space."""

from __future__ import annotations

import copy
import warnings
from typing import Any

import gymnasium.spaces
import numpy as np
from gymnasium.spaces import Discrete
from gymnasium.utils import seeding
from typing_extensions import override

from pettingzoo.utils.env import ActionType, AECEnv, AgentID, ObsType, ParallelEnv
from pettingzoo.utils.wrappers.base import BaseWrapper
from pettingzoo.utils.wrappers.base_parallel import BaseParallelWrapper


def _nan_random_np_random(seed: int | None) -> np.random.Generator:
    """Builds an RNG that does not share a stream with the wrapped environment.

    Environments usually seed themselves with ``seeding.np_random(seed)``, so
    this spawns a child sequence from the same seed, as ``StickyActionV1`` does.
    """
    _, np_seed = seeding.np_random(seed)
    child_seq = np.random.SeedSequence(np_seed).spawn(1)[0]
    return np.random.Generator(np.random.PCG64(child_seq))


def _action_mask(observation: Any, info: Any) -> Any:
    """Returns the agent's action mask, or ``None`` if it does not have one.

    A mask in a dictionary observation takes precedence over one in the info,
    the same order ``api_test`` uses when it samples masked actions.
    """
    if isinstance(observation, dict) and "action_mask" in observation:
        return observation["action_mask"]
    if isinstance(info, dict) and "action_mask" in info:
        return info["action_mask"]
    return None


def _random_action(
    space: gymnasium.spaces.Space[Any],
    mask: Any,
    agent: Any,
    np_random: np.random.Generator,
) -> Any:
    """Samples an action from ``space``, restricted to ``mask`` if there is one."""
    if mask is None:
        # Sample from a copy seeded by the wrapper, so the replacement neither
        # depends on nor advances the environment's own action space RNG.
        sample_space = copy.deepcopy(space)
        sample_space.seed(int(np_random.integers(2**32)))
        return sample_space.sample()

    if not isinstance(space, Discrete):
        raise ValueError(
            f"Agent {agent!r} has an action mask, but only Discrete action spaces "
            f"support masked NaN replacement, got {space}."
        )
    mask = np.asarray(mask)
    if mask.shape != (space.n,) or not np.isin(mask, (0, 1)).all():
        raise ValueError(
            f"The action mask for agent {agent!r} must contain only 0 and 1 and "
            f"have shape ({space.n},), got {mask!r}."
        )
    allowed = np.flatnonzero(mask)
    if allowed.size == 0:
        raise ValueError(f"The action mask for agent {agent!r} allows no actions.")
    return space.start + np_random.choice(allowed)


def _replace_nan(
    action: Any,
    space: gymnasium.spaces.Space[Any],
    mask: Any,
    agent: Any,
    np_random: np.random.Generator,
) -> Any:
    replacement = _random_action(space, mask, agent, np_random)
    warnings.warn(
        f"Step received a NaN action {action} for agent {agent!r}. "
        f"Taking the random action {replacement}.",
        stacklevel=3,
    )
    return replacement


def _has_nan(action: Any) -> bool:
    return action is not None and bool(np.isnan(action).any())


[docs] class NanRandomV1(BaseWrapper[AgentID, ObsType, Any]): """Replaces a numeric action containing a NaN with a random action. The replacement is sampled from the acting agent's action space, and a warning is emitted for each one. If the agent has an ``action_mask``, in its dictionary observation or else in its info, the replacement is drawn uniformly from the actions the mask allows. Masks are supported for ``Discrete`` action spaces; a mask on any other space, or one with the wrong shape, values other than 0 and 1, or no allowed action, raises a ``ValueError``. Numeric scalars and arrays are supported. Actions without NaNs, and ``None`` actions for dead agents, pass through unchanged. The advertised action spaces are not modified. The random actions come from the wrapper's own RNG, which :meth:`reset` reseeds from the seed it is given, so a seeded reset gives reproducible replacements. As with ``StickyActionV1``, ``reset(seed=None)`` starts a fresh unseeded stream. :param env: The AEC environment to wrap. """ def __init__(self, env: AECEnv[AgentID, ObsType, ActionType]): assert isinstance(env, AECEnv), ( "NanRandomV1 is only compatible with AEC environments, " "use NanRandomParallelV1 instead." ) super().__init__(env) self._np_random = _nan_random_np_random(None) @override def reset( self, seed: int | None = None, options: dict[str, Any] | None = None ) -> None: self._np_random = _nan_random_np_random(seed) self.env.reset(seed=seed, options=options) @override def step(self, action: Any) -> None: if _has_nan(action): agent = self.env.agent_selection mask = _action_mask(self.env.observe(agent), self.env.infos.get(agent)) action = _replace_nan( action, self.env.action_space(agent), mask, agent, self._np_random ) self.env.step(action) @override def __str__(self) -> str: return f"NanRandomV1<{self.env!s}>"
[docs] class NanRandomParallelV1(BaseParallelWrapper[AgentID, ObsType, Any]): """Replaces each numeric action containing a NaN with a random action. Each supplied action is checked independently. A replacement is sampled from that agent's action space, restricted to its ``action_mask`` when the agent's latest dictionary observation or else its latest info has one, and a warning is emitted for each replacement. Masks follow the same rules as in :class:`NanRandomV1`. Numeric scalars and arrays are supported. Actions without NaNs pass through unchanged, and the input dictionary is not modified. The advertised action spaces are not modified. Seeding works as in :class:`NanRandomV1`. :param env: The parallel environment to wrap. """ def __init__(self, env: ParallelEnv[AgentID, ObsType, ActionType]): super().__init__(env) self._np_random = _nan_random_np_random(None) self._observations: dict[AgentID, ObsType] = {} self._infos: dict[AgentID, dict[str, Any]] = {} @override def reset( self, seed: int | None = None, options: dict[str, Any] | None = None ) -> tuple[dict[AgentID, ObsType], dict[AgentID, dict[str, Any]]]: self._np_random = _nan_random_np_random(seed) self._observations, self._infos = self.env.reset(seed=seed, options=options) return self._observations, self._infos @override def step( self, actions: dict[AgentID, Any] ) -> tuple[ dict[AgentID, ObsType], dict[AgentID, float], dict[AgentID, bool], dict[AgentID, bool], dict[AgentID, dict[str, Any]], ]: replaced = {} for agent, action in actions.items(): if _has_nan(action): mask = _action_mask( self._observations.get(agent), self._infos.get(agent) ) action = _replace_nan( action, self.env.action_space(agent), mask, agent, self._np_random ) replaced[agent] = action observations, rewards, terminations, truncations, infos = self.env.step( replaced ) self._observations, self._infos = observations, infos return observations, rewards, terminations, truncations, infos @override def __str__(self) -> str: return f"NanRandomParallelV1<{self.env!s}>"