Description
Teach the
gymnasium.WrapperAPI surface by progressively building up from the smallest possible working exampleFocus on primitives: what a wrapper is, how it intercepts environment calls, and how wrappers compose into layered transformations
References:
Imports¶
%load_ext autoreload
%autoreload 2
# System libraries.
import logging
# Third-party libraries.
import numpy as np
import pandas as pdimport helpers.hdbg as hdbg
_LOG = logging.getLogger(__name__)
hdbg.init_logger(verbosity=logging.INFO)
# hnotebook.config_notebook()
import tutorials.gymnasium.gymnasium_utils as gymutils
try:
from IPython.display import display
except ImportError:
display = print # type: ignoreLibrary Overview¶
What problem it solves: RL agents interact with an environment via
reset()andstep(). When you need to transform observations, actions, or rewards (e.g., normalize pixels, clip actions, scale rewards), modifying the environment’s source code is impractical. Wrappers solve this by wrapping anEnvand intercepting its callsKey abstraction:
gymnasium.Wrapper: a class that holds an innerEnvand delegates every method (reset,step,render,close) to it. Subclasses override specific methods to inject transformationsMental model:
Wrapper └── env (the inner Env, which may itself be a Wrapper) └── ... (further wrappers) └── base Env (leaf)- Calling
wrapper.step(action)flows through each wrapper layer from outermost to innermost, then the result flows back out wrapper.unwrappedstrips all layers and returns the bareEnv
- Calling
Key classes:
gymnasium.Wrapper: base class, delegates everything toself.envgymnasium.ObservationWrapper: overrideobservation()to transform observationsgymnasium.ActionWrapper: overrideaction()to transform actionsgymnasium.RewardWrapper: overridereward()to transform rewardsTransformObservation,TransformAction,TransformReward: lambda-based wrappers that take a function argument- Builtin utility wrappers:
TimeLimit,RecordEpisodeStatistics,Autoreset, etc.
Primitive 1: Wrapper: The Base Class¶
Mental model: a
Wrapperis an environment that contains another environment. Every method call is forwarded to the inner env by default, so a wrapper can selectively intercept calls without reimplementing the full APIThe constructor takes a single argument: the environment to wrap
import gymnasium as gym
# Create a bare CartPole environment.
base_env = gym.make("CartPole-v1", render_mode=None)
print("base_env type:", type(base_env))
# Wrap it with the base Wrapper class.
from gymnasium import Wrapper
wrapper = Wrapper(base_env)
print("wrapper type:", type(wrapper))
# The wrapper IS still an Env (isinstance check).
print("isinstance(wrapper, gym.Env):", isinstance(wrapper, gym.Env))Delegation: method calls pass through to the inner env¶
- By default,
Wrapperdoes nothing: all calls fall through toself.env
# Reset is delegated.
obs, info = wrapper.reset(seed=42)
print("obs (delegated):", obs)
# Step is delegated.
obs2, reward, terminated, truncated, _info = wrapper.step(
wrapper.action_space.sample()
)
gymutils.print_step(
obs=obs2,
reward=reward,
terminated=terminated,
truncated=truncated,
info={},
compact=True,
)# The wrapper exposes the same spaces as the base env.
print("action_space:", wrapper.action_space)
print("observation_space:", wrapper.observation_space)Inspecting the wrapper environment¶
env.env: the directly wrapped environmentenv.unwrapped: the innermost bare environment (recursively unwraps all layers)
# Show the wrapper chain.
print("wrapper.env is base_env:", wrapper.env is base_env)
print("wrapper.unwrapped:", type(wrapper.unwrapped))
print(
"wrapper.unwrapped is base_env.unwrapped:",
wrapper.unwrapped is base_env.unwrapped,
)
# Clean up.
wrapper.close()Primitive 2: ObservationWrapper: Transform Observations¶
Mental model: An
ObservationWrapperintercepts the observation thatstep()andreset()return, passes it throughself.observation(), and returns the modified observationThe
observation()method is the only method you need to override- Called on every observation: both from
reset()and fromstep()
- Called on every observation: both from
The
step()/reset()flow:inner_env.step(action) → (obs, reward, terminated, truncated, info) wrapper.observation(obs) → modified_obs return (modified_obs, reward, terminated, truncated, info)
from gymnasium import ObservationWrapper
class FlipObservation(ObservationWrapper):
"""
Negate every element of the observation vector.
"""
def observation(self, observation):
return -observation
# Build a wrapped env.
base = gym.make("CartPole-v1")
flip_env = FlipObservation(base)
# Reset and inspect: the observation is negated.
obs, _ = flip_env.reset(seed=42)
print("original obs:", base.reset(seed=42)[0])
print("flipped obs: ", obs)# The observation() method is called on every step.
base.reset(seed=42)
flip_env.reset(seed=42)
action = flip_env.action_space.sample()
orig_obs, *_ = base.step(action)
flip_obs, *_ = flip_env.step(action)
print("original step obs:", orig_obs)
print("flipped step obs: ", flip_obs)Primitive 3: ActionWrapper: Transform Actions¶
Mental model: An
ActionWrapperintercepts the action thatstep()receives and passes it throughself.action()before forwarding it to the inner environmentThe
action()method is the only method you need to override- Called on every action before
step()on the inner env
- Called on every action before
The
step()flow:wrapper.action(action) → modified_action inner_env.step(modified_action) → (obs, reward, ...) return (obs, reward, ...)
from gymnasium import ActionWrapper
class SwapAction(ActionWrapper):
"""
Swap the two discrete actions: 0 ↔ 1.
A minimal action transformation on a 2-action env (CartPole).
"""
def action(self, action):
# Swap 0 and 1: action 0 becomes 1, action 1 becomes 0.
return 1 - action# Create a wrapped env and compare action behavior.
base2 = gym.make("CartPole-v1")
action_swap_env = SwapAction(base2)
base2.reset(seed=0)
action_swap_env.reset(seed=0)
# Step with action=0 → the wrapper swaps to action=1 before passing to inner env.
obs_a, *_ = base2.step(0)
obs_b, *_ = action_swap_env.step(0)
print("action=0 on base env -> obs:", obs_a)
print("action=0 on swap wrapper (→ action=1) -> obs:", obs_b)
print(
"Different because a different action was taken:",
not np.array_equal(obs_a, obs_b),
)Primitive 4: RewardWrapper: Transform Rewards¶
Mental model: A
RewardWrapperintercepts the reward thatstep()returns and passes it throughself.reward()before returning it to the callerThe
reward()method is the only method you need to overrideThe
step()flow:inner_env.step(action) → (obs, reward, terminated, truncated, info) wrapper.reward(reward) → modified_reward return (obs, modified_reward, terminated, truncated, info)
from gymnasium import RewardWrapper
class DoubleReward(RewardWrapper):
"""
Multiply every reward by a factor.
"""
def __init__(self, env, factor=2.0):
super().__init__(env)
self._factor = factor
def reward(self, reward):
return reward * self._factor# Build the wrapped env and compare rewards.
base3 = gym.make("CartPole-v1")
reward_env = DoubleReward(base3, factor=3.0)
base3.reset(seed=0)
reward_env.reset(seed=0)
action = 1
# Compare step rewards.
_, rew_a, *_ = base3.step(action)
_, rew_b, *_ = reward_env.step(action)
print(f"base reward: {rew_a} doubled reward: {rew_b}")
print(f"ratio: {rew_b / rew_a}")Primitive 5: Lambda Wrappers (Function-Based)¶
Mental model: Instead of writing a full subclass, you can pass a function to
TransformObservation,TransformAction, orTransformReward. The function is applied to every observation/action/rewardThese are the “quick and clean” way for simple transformations
from gymnasium.wrappers import (
TransformObservation,
TransformAction,
TransformReward,
)# TransformObservation: add Gaussian noise to every observation.
base4 = gym.make("CartPole-v1")
noisy_env = TransformObservation(
base4,
func=lambda obs: obs + 0.1 * np.random.default_rng(42).random(obs.shape),
observation_space=base4.observation_space,
)
obs_clean, *_ = base4.reset(seed=0)
obs_noisy, *_ = noisy_env.reset(seed=0)
data = {
"obs_source": ["clean", "noisy"],
"observation": [str(obs_clean), str(obs_noisy)],
}
display(pd.DataFrame(data))# TransformAction: scale continuous actions for MountainCarContinuous.
base5 = gym.make("MountainCarContinuous-v0")
# Clip action magnitude to 50%.
clipped_action_env = TransformAction(
base5,
func=lambda a: np.clip(a, -0.5, 0.5),
action_space=base5.action_space,
)
base5.reset(seed=0)
clipped_action_env.reset(seed=0)
# Send a large action and compare the trajectory.
obs_base, *_ = base5.step(np.array([1.0]))
obs_clipped, *_ = clipped_action_env.step(np.array([1.0]))
data2 = {
"env_source": ["base", "clipped_action"],
"next_obs": [str(obs_base), str(obs_clipped)],
}
display(pd.DataFrame(data2))
base5.close()
clipped_action_env.close()# TransformReward: scale and shift every reward.
base6 = gym.make("CartPole-v1")
scaled_reward_env = TransformReward(base6, func=lambda r: 2.0 * r + 1.0)
base6.reset(seed=0)
scaled_reward_env.reset(seed=0)
_, rew_base, *_ = base6.step(1)
_, rew_scaled, *_ = scaled_reward_env.step(1)
print(f"base reward: {rew_base} scaled reward: {rew_scaled}")
base6.close()
scaled_reward_env.close()Composition Example 1: TimeLimit — Truncating Episodes¶
- Mental model: A
TimeLimitwrapper truncates an episode after a fixed number of steps, settingtruncated=Trueinstead ofterminated=True
from gymnasium.wrappers import TimeLimit
# Make a custom env with no natural termination.
class InfiniteCartPole(gym.Env):
"""
A CartPole that never terminates (no time limit, no failure).
"""
def __init__(self):
super().__init__()
self._env = gym.make("CartPole-v1")
self.action_space = self._env.action_space
self.observation_space = self._env.observation_space
def reset(self, *, seed=None, options=None):
return self._env.reset(seed=seed, options=options)
def step(self, action):
obs, reward, _terminated, truncated, info = self._env.step(action)
# Never terminate naturally.
return obs, reward, False, truncated, info
def close(self):
self._env.close()# Without TimeLimit, the episode could run forever.
infinite_env = InfiniteCartPole()
# With TimeLimit, the episode is truncated after 5 steps.
limited_env = TimeLimit(infinite_env, max_episode_steps=5)
limited_env.reset(seed=0)
for i in range(6):
obs, reward, terminated, truncated, info = limited_env.step(1)
print(f"Step {i}: terminated={terminated} truncated={truncated}")
if terminated or truncated:
print(f" Episode ended at step {i}")
break
limited_env.close()Composition Example 2: Multiple Wrappers Stacked¶
- Mental model: wrappers compose by nesting. Each layer transforms one aspect (actions, rewards, observations). The outermost wrapper sees the final result
# Stack three wrappers: one for each channel (observation, action, reward).
from gymnasium.wrappers import TimeLimit, RecordEpisodeStatistics, Autoreset
base7 = gym.make("CartPole-v1")
# Layer 1 (outermost): double the reward.
w1 = DoubleReward(base7, factor=2.0)
# Layer 2: flip the observation.
w2 = FlipObservation(w1)
# Layer 3 (innermost): swap the action.
w3 = SwapAction(w2)
# What is the actual action -> inner step action?
w3.reset(seed=0)
print("Outer wrapper type:", type(w3).__name__)
print("w3.env type:", type(w3.env).__name__)
print("w3.env.env type:", type(w3.env.env).__name__)
print("w3.unwrapped type:", type(w3.unwrapped).__name__)
# Flow: action=0
# → SwapAction.action(0) → 1
# → inner env step(1)
# → FlipObservation.observation(obs) → -obs
# → DoubleReward.reward(reward) → 2 * reward
obs, reward, *_ = w3.step(0)
print(f" obs (negated): {obs}")
print(f" reward (doubled): {reward}")
w3.close()Composition Example 3: Custom Observation Wrapper¶
- Problem: a discrete observation space (e.g., frozen lake grid) is hard to visualize. Wrap it to emit one-hot encoded observations
class OneHotObservation(ObservationWrapper):
"""
Convert a discrete observation into a one-hot vector.
"""
def __init__(self, env):
super().__init__(env)
n = int(env.observation_space.n)
self.observation_space = gym.spaces.Box(
low=0.0, high=1.0, shape=(n,), dtype=np.float32
)
def observation(self, observation):
# Build a one-hot vector for the discrete observation index.
one_hot = np.zeros(self.observation_space.shape, dtype=np.float32)
one_hot[int(observation)] = 1.0
return one_hot# Build a FrozenLake environment and wrap it.
frozen = gym.make("FrozenLake-v1", is_slippery=False)
onehot_env = OneHotObservation(frozen)
obs, _ = onehot_env.reset(seed=0)
print("Original discrete obs:", frozen.reset(seed=0)[0])
print("One-hot obs:", obs)
print("One-hot shape:", obs.shape)
# Step and see the one-hot representation.
obs2, *_ = onehot_env.step(2) # action 2 = move right (FrozenLake layout)
print("Obs after step right:", obs2)
onehot_env.close()Composition Example 4: Custom Action Wrapper¶
- Problem: discrete actions in {0, 1, 2, 3} need to be mapped to a smaller set of allowed actions
class DiscreteActionMap(ActionWrapper):
"""
Map discrete actions from the user to different actions for the env.
E.g., map {0, 1} -> {0, 2} (only left and right, no up/down).
"""
def __init__(self, env, mapping):
super().__init__(env)
self._mapping = mapping
# The wrapper's action space is the size of the mapping (input actions).
self.action_space = gym.spaces.Discrete(len(mapping))
def action(self, action):
return self._mapping[action]# Use FrozenLake: map {0: 0, 1: 2} so action 1 means "right".
frozen2 = gym.make("FrozenLake-v1", is_slippery=False)
mapped_env = DiscreteActionMap(frozen2, mapping={0: 0, 1: 2})
mapped_env.reset(seed=0)
obs, reward, terminated, truncated, info = mapped_env.step(1) # 1 -> right
print("action 1 mapped to right, obs:", obs)
# The wrapper's action space only allows {0, 1}.
print("Wrapper action space:", mapped_env.action_space)
mapped_env.close()Composition Example 5: Custom Reward Wrapper¶
- Problem: you want to shape the reward with a penalty term that depends on the observation
class RewardFromObservation(RewardWrapper):
"""
Penalize the agent based on the distance from the observation to the origin.
"""
def __init__(self, env, penalty_weight=0.01):
super().__init__(env)
self._penalty_weight = penalty_weight
def reward(self, reward):
return reward - self._penalty_weightbase8 = gym.make("CartPole-v1")
penalty_env = RewardFromObservation(base8, penalty_weight=0.5)
base8.reset(seed=0)
penalty_env.reset(seed=0)
_, rew_a, *_ = base8.step(1)
_, rew_b, *_ = penalty_env.step(1)
print(f"base reward: {rew_a} penalty reward: {rew_b} diff={rew_a - rew_b}")
base8.close()
penalty_env.close()Composition Example 6: RecordEpisodeStatistics¶
- Mental model: tracks cumulative reward, episode length, and wall-clock
time for completed episodes. Adds an
"episode"key to theinfodict at episode end, containing"r"(return),"l"(length),"t"(time)
base9 = gym.make("CartPole-v1")
stats_env = RecordEpisodeStatistics(base9, buffer_length=10)
# Run 3 episodes.
for _ in range(3):
obs, info = stats_env.reset(seed=42)
terminated, truncated = False, False
while not (terminated or truncated):
obs, reward, terminated, truncated, info = stats_env.step(
stats_env.action_space.sample()
)
# At episode end, info contains the statistics.
ep_info = info.get("episode")
if ep_info:
print(
f"Episode return: {ep_info['r']:.1f} "
f"length: {ep_info['l']} "
f"time: {ep_info['t']:.3f}s"
)
# Access the rolling buffers.
print(f"Return buffer: {list(stats_env.return_queue)}")
print(f"Length buffer: {list(stats_env.length_queue)}")
stats_env.close()Composition Example 7: Autoreset — Never Manual Reset¶
- Mental model:
Autoresetautomatically resets the environment on the step AFTER an episode ends. This simulates the behavior of vectorized environments in a single environment
base10 = gym.make("CartPole-v1")
# Limit steps to 3 so episodes end quickly, then auto-reset.
limited = TimeLimit(base10, max_episode_steps=3)
auto_env = Autoreset(limited)
# Step many times - the env auto-resets without explicit reset() calls.
auto_env.reset(seed=0)
for i in range(12):
obs, reward, terminated, truncated, info = auto_env.step(
auto_env.action_space.sample()
)
print(f" Step {i:2d}: terminated={terminated} truncated={truncated}")
if terminated or truncated:
# On the step that ends, terminated/truncated is True.
# On the next step, the env has already been reset (autoreset).
pass
auto_env.close()API Patterns¶
Inheritance pattern: subclass
Wrapper,ObservationWrapper,ActionWrapper, orRewardWrapperand override the hook method (observation(),action(), orreward()). Full control, most commonLambda/function pattern: use
TransformObservation(func=...),TransformAction(func=...),TransformReward(func=...)to apply a function without writing a class. Concise for simple transformsComposition/chaining pattern: wrappers stack by nesting:
env = WrapperC(WrapperB(WrapperA(base_env)))Each layer intercepts and can modify the data flow. Order matters: the outermost wrapper sees the action first and the observation/reward last
Utility wrapper pattern: builtin wrappers like
TimeLimit,RecordEpisodeStatistics,Autoreset,OrderEnforcingadd infrastructure behavior without changing the agent’s view of the envSpace modification pattern: when a wrapper changes the type, shape, or bounds of observations or actions, update
self.observation_spaceorself.action_spacein__init__()so downstream code knows the new contract
Interactive Exploration¶
- Experiment with the wrapper primitives
# Inspect the Wrapper class interface.
from gymnasium import Wrapper, ObservationWrapper, ActionWrapper, RewardWrapper
import helpers.hintrospection as hintros
for cls in [Wrapper, ObservationWrapper, ActionWrapper, RewardWrapper]:
print(f"\n=== {cls.__name__} ===")
hintros.print_obj_info(cls)# Experiment: what happens when you change the order of wrappers?
base_for_order = gym.make("CartPole-v1")
# Order A: flip obs first, then scale reward.
env_a = DoubleReward(FlipObservation(base_for_order), factor=10.0)
env_a.reset(seed=0)
obs_a, rew_a, *_ = env_a.step(1)
print(
f"Order A (flip inside, reward outside): obs={obs_a[:2]}, reward={rew_a:.1f}"
)
# Order B: scale reward first, then flip obs.
base_for_order2 = gym.make("CartPole-v1")
env_b = FlipObservation(DoubleReward(base_for_order2, factor=10.0))
env_b.reset(seed=0)
obs_b, rew_b, *_ = env_b.step(1)
print(
f"Order B (reward inside, flip outside): obs={obs_b[:2]}, reward={rew_b:.1f}"
)
# The observation is flipped in both cases, but the reward order doesn't matter
# for independent transforms. For linked transforms, order matters!
base_for_order.close()# Inspect the wrapper chain with `get_wrapper_attr`.
env_chain = DoubleReward(
FlipObservation(SwapAction(gym.make("CartPole-v1"))), factor=5.0
)
# `get_wrapper_attr` searches through the wrapper stack for an attribute.
print("get_wrapper_attr('_factor'):", env_chain.get_wrapper_attr("_factor"))# Question: does `wrapper.render()` work when the base env has render_mode=None?
# (It fails because the base env can't render.)
try:
no_render_env = gym.make("CartPole-v1", render_mode=None)
wrapper_no_render = DoubleReward(no_render_env)
wrapper_no_render.render()
except Exception as e:
print(f"render() failed: {type(e).__name__}: {e}")# Question: can you wrap a wrapped environment with the same wrapper type?
# (Yes, nesting is the fundamental composition mechanism.)
env_nested = DoubleReward(
DoubleReward(gym.make("CartPole-v1"), factor=2.0), factor=3.0
)
env_nested.reset(seed=0)
_, rew_nested, *_ = env_nested.step(1)
print(f"Reward with double wrapping (2x inner, 3x outer): {rew_nested}")# What wrapper attributes are accessible?
# `env.env`: the directly wrapped inner environment.
env_w_spec = TimeLimit(gym.make("CartPole-v1"), max_episode_steps=100)
print("env.env type:", type(env_w_spec.env).__name__)
print("env.unwrapped type:", type(env_w_spec.unwrapped).__name__)
# `env.spec` is the EnvSpec of the innermost environment.
print("env.spec:", env_w_spec.spec)
env_w_spec.close()Summary: The Mental Model¶
- A
Wrapperis an environment that delegates every method to an inner environment, allowing selective interception without modifying the original environment code - Three specialized subclasses provide clean hooks:
ObservationWrapper(overrideobservation()),ActionWrapper(overrideaction()), andRewardWrapper(overridereward()) - Lambda wrappers (
TransformObservation,TransformAction,TransformReward) let you apply a function without writing a class - Wrappers compose by nesting: the outermost wrapper sees the action first and the observation/reward last. The order of wrapping determines the pipeline
- Builtin utility wrappers (
TimeLimit,RecordEpisodeStatistics,Autoreset,OrderEnforcing) add infrastructure behavior that every RL project needs