Source code for gymnasium.wrappers.vector.stateful_reward

"""A collection of wrappers for modifying the reward with an internal state.

* ``NormalizeReward`` - Normalizes the rewards to a mean and standard deviation
"""

from __future__ import annotations

from typing import Any

import numpy as np

import gymnasium as gym
from gymnasium.core import ActType, ObsType
from gymnasium.error import InvalidBound
from gymnasium.vector.vector_env import AutoresetMode, VectorEnv, VectorWrapper
from gymnasium.wrappers.utils import RunningMeanStd

__all__ = ["NormalizeReward"]


[docs] class NormalizeReward( VectorWrapper[ObsType, ActType, ObsType, ActType, np.ndarray], gym.utils.RecordConstructorArgs, ): r"""This wrapper will scale rewards s.t. their exponential moving average has an approximately fixed variance. The property `_update_running_mean` allows to freeze/continue the running mean calculation of the reward statistics. If `True` (default), the `RunningMeanStd` will get updated every time `self.normalize()` is called. If False, the calculated statistics are used but not updated anymore; this may be used during evaluation. Note: The scaling depends on past trajectories and rewards will not be scaled correctly if the wrapper was newly instantiated or the policy was changed recently. Note: Unlike :class:`gymnasium.wrappers.NormalizeReward`, which carries the discounted return into the next episode unless the episode terminates, the accumulated return of a sub-environment is restarted once it is reset, i.e., at the end of an episode for same-step autoreset and on ``reset`` for disabled autoreset. Example without the normalize reward wrapper: >>> import gymnasium as gym >>> import numpy as np >>> envs = gym.make_vec("MountainCarContinuous-v0", 3) >>> _ = envs.reset(seed=123) >>> _ = envs.action_space.seed(123) >>> episode_rewards = [] >>> for _ in range(100): ... observation, reward, *_ = envs.step(envs.action_space.sample()) ... episode_rewards.append(reward) ... >>> envs.close() >>> np.mean(episode_rewards) np.float64(-0.03359492141887935) >>> np.std(episode_rewards) np.float64(0.029028230434438706) Example with the normalize reward wrapper: >>> import gymnasium as gym >>> import numpy as np >>> envs = gym.make_vec("MountainCarContinuous-v0", 3) >>> envs = NormalizeReward(envs) >>> _ = envs.reset(seed=123) >>> _ = envs.action_space.seed(123) >>> episode_rewards = [] >>> for _ in range(100): ... observation, reward, *_ = envs.step(envs.action_space.sample()) ... episode_rewards.append(reward) ... >>> envs.close() >>> np.mean(episode_rewards) np.float64(-0.1598639409552856) >>> np.std(episode_rewards) np.float64(0.2780030923586878) """ return_rms: RunningMeanStd accumulated_reward: np.ndarray[tuple[int], np.dtype[np.float32]] gamma: float epsilon: float _update_running_mean: bool _prev_dones: np.ndarray[tuple[int], np.dtype[np.float32]] _autoreset_mode: AutoresetMode def __init__( self, env: VectorEnv[ObsType, ActType, np.ndarray], gamma: float = 0.99, epsilon: float = 1e-8, ) -> None: """This wrapper will normalize immediate rewards s.t. their exponential moving average has an approximately fixed variance. Args: env (env): The environment to apply the wrapper epsilon (float): A stability parameter gamma (float): The discount factor that is used in the exponential moving average. Raises: InvalidBound: If ``gamma`` is outside ``[0, 1]``, or if ``epsilon`` is not strictly positive. """ # The exponential moving average multiplies the accumulator by `gamma` on # every step, so a value above one makes it diverge instead of converging, # and a negative one alternates its sign. if not 0 <= gamma <= 1: raise InvalidBound( f"`gamma` should be in the interval [0, 1]. Received {gamma}" ) # `epsilon` is added under the square root to keep the division away from # zero, so a non-positive value defeats its purpose and, once it exceeds # the variance, silently turns every normalized reward into NaN. if epsilon <= 0: raise InvalidBound( f"`epsilon` should be strictly positive. Received {epsilon}" ) gym.utils.RecordConstructorArgs.__init__(self, gamma=gamma, epsilon=epsilon) VectorWrapper.__init__(self, env) self.return_rms = RunningMeanStd(shape=()) self.accumulated_reward = np.zeros((self.num_envs,), dtype=np.float32) self.gamma = gamma self.epsilon = epsilon self._update_running_mean = True self._prev_dones = np.zeros((self.num_envs,), dtype=np.float32) self._autoreset_mode = self.env.metadata.get( "autoreset_mode", AutoresetMode.NEXT_STEP ) @property def update_running_mean(self) -> bool: """Property to freeze/continue the running mean calculation of the reward statistics.""" return self._update_running_mean @update_running_mean.setter def update_running_mean(self, setting: bool) -> None: """Sets the property to freeze/continue the running mean calculation of the reward statistics.""" self._update_running_mean = setting def reset( self, *, seed: int | None = None, options: dict[str, Any] | None = None, ) -> tuple[ObsType, dict[str, Any]]: """Resets the environment and clears the accumulated reward tracking state of the reset sub-environments.""" if options is not None and "reset_mask" in options: reset_mask = options["reset_mask"] self.accumulated_reward[reset_mask] = 0 self._prev_dones[reset_mask] = 0 else: self.accumulated_reward[:] = 0 self._prev_dones[:] = 0 return super().reset(seed=seed, options=options) def step( self, actions: ActType ) -> tuple[ ObsType, np.ndarray[tuple[int], np.dtype[np.float64]], np.ndarray[tuple[int], np.dtype[np.bool_]], np.ndarray[tuple[int], np.dtype[np.bool_]], dict[str, Any], ]: """Steps through the environment, normalizing the reward returned.""" obs, reward, terminated, truncated, info = super().step(actions) # SAME_STEP resets inside the terminating step, so the following step is # already the first step of a new episode and must update statistics. # NEXT_STEP / DISABLED still skip the autoreset step after a done. if self._autoreset_mode == AutoresetMode.SAME_STEP: active = np.ones((self.num_envs,), dtype=bool) else: active = ~self._prev_dones.astype(bool) self.accumulated_reward[active] = ( self.accumulated_reward[active] * self.gamma * (1 - terminated[active]) + reward[active] ) if self._update_running_mean and np.any(active): self.return_rms.update(self.accumulated_reward[active]) self._prev_dones = np.logical_or(terminated, truncated).astype(np.float32) if self._autoreset_mode == AutoresetMode.SAME_STEP: # The done sub-environments have already been reset; start a new # return immediately rather than carrying the previous episode. self.accumulated_reward[self._prev_dones.astype(bool)] = 0 return ( obs, reward / np.sqrt(self.return_rms.var + self.epsilon), terminated, truncated, info, ) def normalize(self, reward: float | np.floating | np.integer) -> np.float64: """Normalizes the rewards with the running mean rewards and their variance.""" if self._update_running_mean: self.return_rms.update(self.accumulated_reward) return reward / np.sqrt(self.return_rms.var + self.epsilon)