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.error import InvalidBound
from gymnasium.vector.vector_env import AutoresetMode, VectorEnv, VectorWrapper
from gymnasium.wrappers.utils import RunningMeanStd

__all__ = ["NormalizeReward"]


[docs] class NormalizeReward(VectorWrapper, 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. 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, 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[np.ndarray, dict[str, Any]]: """Resets the environment and clears accumulated reward tracking state.""" self.accumulated_reward[:] = 0 self._prev_dones[:] = 0 return super().reset(seed=seed, options=options) def step( self, actions: np.ndarray ) -> tuple[ np.ndarray, 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)