Source code for pettingzoo.utils.wrappers.max_observation

"""Wrappers that reduce each agent's observation to a max over recent frames."""

from __future__ import annotations

import functools
from collections import deque
from typing import Any

import gymnasium.spaces
import numpy as np
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

_ARRAY_SPACES = (
    gymnasium.spaces.Box,
    gymnasium.spaces.MultiBinary,
    gymnasium.spaces.MultiDiscrete,
)


def _check_memory(memory: int) -> int:
    if isinstance(memory, bool) or not isinstance(memory, (int, np.integer)):
        raise TypeError(f"memory must be an int, got {type(memory).__name__}")
    if memory < 1:
        raise ValueError(f"memory must be a positive int, got {memory}")
    return int(memory)


def _check_obs_space(
    name: str, agent: AgentID, space: gymnasium.spaces.Space[Any]
) -> None:
    if not isinstance(space, _ARRAY_SPACES):
        raise ValueError(
            f"{name} needs an array observation space, but agent {agent!r} has "
            f"{type(space).__name__}. Box, MultiBinary and MultiDiscrete are "
            "supported; Dict, Tuple and Discrete are not, because there is no "
            "elementwise max over them."
        )


def _check_possible_agents(wrapper: Any) -> None:
    """Fail at construction where we can. Envs that generate agents are checked later."""
    try:
        agents = wrapper.env.possible_agents
    except AttributeError:
        return
    for agent in agents:
        _check_obs_space(
            type(wrapper).__name__, agent, wrapper.env.observation_space(agent)
        )


def _reduce(history: deque[Any]) -> Any:
    # np.array copies, so a caller never gets a handle on the stored history and
    # cannot corrupt it. functools.reduce alone returns the stored frame itself
    # when the history holds only one.
    return np.array(functools.reduce(np.maximum, history))


[docs] class MaxObservationV1(BaseWrapper[AgentID, ObsType, ActionType]): """Replaces each agent's observation with the elementwise max over its last ``memory`` observations. This removes the flicker in environments that draw sprites on alternate frames. Histories are per agent and cleared on ``reset``, and the observation space is unchanged. Only array observation spaces work: Box, MultiBinary, MultiDiscrete. Ported from SuperSuit's max_observation_v0; the version suffix continues that numbering. :param env: The AEC environment to wrap. :param memory: How many observations to take the max over. Must be a positive int. """ def __init__(self, env: AECEnv[AgentID, ObsType, ActionType], memory: int): assert isinstance(env, AECEnv), ( "MaxObservationV1 is only compatible with AEC environments, " "use MaxObservationParallelV1 instead." ) super().__init__(env) self.memory = _check_memory(memory) self._history: dict[AgentID, deque[Any]] = {} _check_possible_agents(self) def _record(self, agent: AgentID) -> None: """Add an agent's current observation to its history. Called from reset() and step(), for the agent whose turn it is, which is where SuperSuit records too. Recording inside observe() instead would make an agent's window depend on who reads it and how often, and would silently undo the max for anything that observes out of turn, such as a centralized critic or an observation logger. """ obs = self.env.observe(agent) if obs is None: return _check_obs_space(type(self).__name__, agent, self.env.observation_space(agent)) history = self._history.setdefault(agent, deque(maxlen=self.memory)) history.append(np.array(obs)) @override def reset( self, seed: int | None = None, options: dict[str, Any] | None = None ) -> None: self._history = {} super().reset(seed=seed, options=options) self._record(self.agent_selection) @override def step(self, action: ActionType) -> None: super().step(action) if self.agent_selection in self.agents: self._record(self.agent_selection) @override def observe(self, agent: AgentID) -> ObsType | None: # An agent that joined mid-episode has not had a turn yet. if agent not in self._history: return self.env.observe(agent) return _reduce(self._history[agent]) @override def __str__(self) -> str: return f"MaxObservationV1<{self.env!s}>"
[docs] class MaxObservationParallelV1(BaseParallelWrapper[AgentID, ObsType, ActionType]): """Replaces each agent's observation with the elementwise max over its last ``memory`` observations. This removes the flicker in environments that draw sprites on alternate frames. Histories are per agent and cleared on ``reset``, and the observation space is unchanged. Only array observation spaces work: Box, MultiBinary, MultiDiscrete. Ported from SuperSuit's max_observation_v0; the version suffix continues that numbering. :param env: The parallel environment to wrap. :param memory: How many observations to take the max over. Must be a positive int. """ def __init__(self, env: ParallelEnv[AgentID, ObsType, ActionType], memory: int): assert isinstance(env, ParallelEnv), ( "MaxObservationParallelV1 is only compatible with parallel environments, " "use MaxObservationV1 instead." ) super().__init__(env) self.memory = _check_memory(memory) self._history: dict[AgentID, deque[Any]] = {} _check_possible_agents(self) def _max_observations( self, observations: dict[AgentID, ObsType] ) -> dict[AgentID, ObsType]: maxed = {} for agent, obs in observations.items(): _check_obs_space( type(self).__name__, agent, self.env.observation_space(agent) ) history = self._history.setdefault(agent, deque(maxlen=self.memory)) history.append(np.array(obs)) maxed[agent] = _reduce(history) return maxed @override def reset( self, seed: int | None = None, options: dict[str, Any] | None = None ) -> tuple[dict[AgentID, ObsType], dict[AgentID, dict[str, Any]]]: self._history = {} observations, infos = self.env.reset(seed=seed, options=options) return self._max_observations(observations), infos @override def step( self, actions: dict[AgentID, ActionType] ) -> tuple[ dict[AgentID, ObsType], dict[AgentID, float], dict[AgentID, bool], dict[AgentID, bool], dict[AgentID, dict[str, Any]], ]: observations, rewards, terminations, truncations, infos = self.env.step(actions) return ( self._max_observations(observations), rewards, terminations, truncations, infos, ) @override def __str__(self) -> str: return f"MaxObservationParallelV1<{self.env!s}>"