Source code for pettingzoo.utils.wrappers.scale_action

"""Wrappers that scale the bounds of a Box action space."""

from __future__ import annotations

from typing import Any, cast

import gymnasium.spaces
import numpy as np
from gymnasium.spaces import Box
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 _scaled_space(
    space: gymnasium.spaces.Space[Any], scale: float, wrapper_name: str
) -> Box:
    """Returns ``space`` with its bounds multiplied by ``scale``.

    A negative scale maps ``low`` above ``high``, so the two are swapped to keep the
    Box valid.
    """
    assert isinstance(space, Box), (
        f"{wrapper_name} only works with Box action spaces, got {type(space).__name__}."
    )
    low = space.low * scale
    high = space.high * scale
    if scale < 0:
        low, high = high, low
    dtype = cast(
        "type[np.floating[Any]] | type[np.integer[Any]]", np.dtype(space.dtype).type
    )
    return Box(low=low, high=high, shape=space.shape, dtype=dtype)


def _unscaled_action(
    action: np.ndarray, space: Box, scaled_space: Box, scale: float
) -> np.ndarray:
    """Maps an action from ``scaled_space`` back into ``space``.

    Multiplying a bound by ``scale`` and dividing it back is not exact in floating
    point, so an action sitting on a bound of the scaled space can come back a
    fraction outside ``space``. Those are clipped. An action that was already
    outside ``scaled_space`` is left alone, so wrappers further in still see it as
    out of bounds.
    """
    action = np.asarray(action)
    unscaled = (action / scale).astype(space.dtype)
    inside = (action >= scaled_space.low) & (action <= scaled_space.high)
    return np.where(inside, np.clip(unscaled, space.low, space.high), unscaled)


[docs] class ScaleActionV1(BaseWrapper[AgentID, ObsType, Any]): """Scales the bounds of each agent's Box action space by a constant factor. The action space this wrapper advertises has ``low`` and ``high`` multiplied by ``scale``. Actions are divided by ``scale`` before they reach the wrapped environment, so an action drawn from the advertised space is a valid action for the wrapped environment. Actions on a bound of the advertised space are clipped to the wrapped bound, since the round trip through ``scale`` is not exact in floating point. An action outside the advertised space is passed through as is. Scaling an integer Box narrows what can be reached: at ``scale`` 0.5, a ``Box(0, 10, dtype=int64)`` advertises ``Box(0, 5)`` and only even actions are reachable. A bound large enough that ``bound * scale`` overflows the dtype becomes infinite, and actions there stay infinite. :param env: The AEC environment to wrap. :param scale: Non-zero factor applied to the action space bounds. A negative scale flips the bounds, so they are swapped to keep the Box valid. """ def __init__(self, env: AECEnv[AgentID, ObsType, ActionType], scale: float): assert isinstance(env, AECEnv), ( "ScaleActionV1 is only compatible with AEC environments, " "use ScaleActionParallelV1 instead." ) assert scale != 0, "ScaleActionV1 needs a non-zero scale." super().__init__(env) self.scale = scale self._action_spaces: dict[AgentID, Box] = {} for agent in getattr(env, "possible_agents", []): self.action_space(agent) @override def action_space(self, agent: AgentID) -> Box: if agent not in self._action_spaces: self._action_spaces[agent] = _scaled_space( self.env.action_space(agent), self.scale, "ScaleActionV1" ) return self._action_spaces[agent] @override def step(self, action: np.ndarray | None) -> None: if action is not None: agent = self.agent_selection space = self.env.action_space(agent) assert isinstance(space, Box), ( "ScaleActionV1 only works with Box action spaces." ) action = _unscaled_action( action, space, self.action_space(agent), self.scale ) self.env.step(action) @override def __str__(self) -> str: return f"ScaleActionV1<{self.env!s}>"
[docs] class ScaleActionParallelV1(BaseParallelWrapper[AgentID, ObsType, Any]): """Scales the bounds of each agent's Box action space by a constant factor. The action space this wrapper advertises has ``low`` and ``high`` multiplied by ``scale``. Actions are divided by ``scale`` before they reach the wrapped environment, so an action drawn from the advertised space is a valid action for the wrapped environment. Actions on a bound of the advertised space are clipped to the wrapped bound, since the round trip through ``scale`` is not exact in floating point. An action outside the advertised space is passed through as is. Scaling an integer Box narrows what can be reached: at ``scale`` 0.5, a ``Box(0, 10, dtype=int64)`` advertises ``Box(0, 5)`` and only even actions are reachable. A bound large enough that ``bound * scale`` overflows the dtype becomes infinite, and actions there stay infinite. :param env: The parallel environment to wrap. :param scale: Non-zero factor applied to the action space bounds. A negative scale flips the bounds, so they are swapped to keep the Box valid. """ def __init__(self, env: ParallelEnv[AgentID, ObsType, ActionType], scale: float): assert scale != 0, "ScaleActionParallelV1 needs a non-zero scale." super().__init__(env) self.scale = scale self._action_spaces: dict[AgentID, Box] = {} for agent in getattr(env, "possible_agents", []): self.action_space(agent) @override def action_space(self, agent: AgentID) -> Box: if agent not in self._action_spaces: self._action_spaces[agent] = _scaled_space( self.env.action_space(agent), self.scale, "ScaleActionParallelV1" ) return self._action_spaces[agent] @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]], ]: unscaled = {} for agent, action in actions.items(): space = self.env.action_space(agent) assert isinstance(space, Box), ( "ScaleActionParallelV1 only works with Box action spaces." ) unscaled[agent] = _unscaled_action( action, space, self.action_space(agent), self.scale ) return self.env.step(unscaled) @override def __str__(self) -> str: return f"ScaleActionParallelV1<{self.env!s}>"