chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,42 @@
import numpy as np
from gymnasium.spaces import Box, Dict, Discrete
from ray.rllib.examples.envs.classes.random_env import RandomEnv
class ActionMaskEnv(RandomEnv):
"""A randomly acting environment that publishes an action-mask each step."""
def __init__(self, config):
super().__init__(config)
# Masking only works for Discrete actions.
assert isinstance(self.action_space, Discrete)
# Add action_mask to observations.
self.observation_space = Dict(
{
"action_mask": Box(0.0, 1.0, shape=(self.action_space.n,)),
"observations": self.observation_space,
}
)
self.valid_actions = None
def reset(self, *, seed=None, options=None):
obs, info = super().reset()
self._fix_action_mask(obs)
return obs, info
def step(self, action):
# Check whether action is valid.
if not self.valid_actions[action]:
raise ValueError(
f"Invalid action ({action}) sent to env! "
f"valid_actions={self.valid_actions}"
)
obs, rew, done, truncated, info = super().step(action)
self._fix_action_mask(obs)
return obs, rew, done, truncated, info
def _fix_action_mask(self, obs):
# Fix action-mask: Everything larger 0.5 is 1.0, everything else 0.0.
self.valid_actions = np.round(obs["action_mask"])
obs["action_mask"] = self.valid_actions
@@ -0,0 +1,183 @@
import logging
import time
import numpy as np
from gymnasium.envs.classic_control import CartPoleEnv
from ray.rllib.examples.envs.classes.multi_agent import make_multi_agent
from ray.rllib.utils.annotations import override
from ray.rllib.utils.error import EnvError
logger = logging.getLogger(__name__)
class CartPoleCrashing(CartPoleEnv):
"""A CartPole env that crashes (or stalls) from time to time.
Useful for testing faulty sub-env (within a vectorized env) handling by
EnvRunners.
After crashing, the env expects a `reset()` call next (calling `step()` will
result in yet another error), which may or may not take a very long time to
complete. This simulates the env having to reinitialize some sub-processes, e.g.
an external connection.
The env can also be configured to stall (and do nothing during a call to `step()`)
from time to time for a configurable amount of time.
"""
def __init__(self, config=None):
super().__init__()
self.config = config if config is not None else {}
# Crash probability (in each `step()`).
self.p_crash = config.get("p_crash", 0.005)
# Crash probability when `reset()` is called.
self.p_crash_reset = config.get("p_crash_reset", 0.0)
# Crash exactly after every n steps. If a 2-tuple, will uniformly sample
# crash timesteps from in between the two given values.
self.crash_after_n_steps = config.get("crash_after_n_steps")
self._crash_after_n_steps = None
assert (
self.crash_after_n_steps is None
or isinstance(self.crash_after_n_steps, int)
or (
isinstance(self.crash_after_n_steps, tuple)
and len(self.crash_after_n_steps) == 2
)
)
# Only ever crash, if on certain worker indices.
faulty_indices = config.get("crash_on_worker_indices", None)
if faulty_indices and config.worker_index not in faulty_indices:
self.p_crash = 0.0
self.p_crash_reset = 0.0
self.crash_after_n_steps = None
# Stall probability (in each `step()`).
self.p_stall = config.get("p_stall", 0.0)
# Stall probability when `reset()` is called.
self.p_stall_reset = config.get("p_stall_reset", 0.0)
# Stall exactly after every n steps.
self.stall_after_n_steps = config.get("stall_after_n_steps")
self._stall_after_n_steps = None
# Amount of time to stall. If a 2-tuple, will uniformly sample from in between
# the two given values.
self.stall_time_sec = config.get("stall_time_sec")
assert (
self.stall_time_sec is None
or isinstance(self.stall_time_sec, (int, float))
or (
isinstance(self.stall_time_sec, tuple) and len(self.stall_time_sec) == 2
)
)
# Only ever stall, if on certain worker indices.
faulty_indices = config.get("stall_on_worker_indices", None)
if faulty_indices and config.worker_index not in faulty_indices:
self.p_stall = 0.0
self.p_stall_reset = 0.0
self.stall_after_n_steps = None
# Timestep counter for the ongoing episode.
self.timesteps = 0
# Time in seconds to initialize (in this c'tor).
sample = 0.0
if "init_time_s" in config:
sample = (
config["init_time_s"]
if not isinstance(config["init_time_s"], tuple)
else np.random.uniform(
config["init_time_s"][0], config["init_time_s"][1]
)
)
print(f"Initializing crashing env (with init-delay of {sample}sec) ...")
time.sleep(sample)
# Make sure envs don't crash at the same time.
self._rng = np.random.RandomState()
@override(CartPoleEnv)
def reset(self, *, seed=None, options=None):
# Reset timestep counter for the new episode.
self.timesteps = 0
self._crash_after_n_steps = None
# Should we crash?
if self._should_crash(p=self.p_crash_reset):
raise EnvError(
f"Simulated env crash on worker={self.config.worker_index} "
f"env-idx={self.config.vector_index} during `reset()`! "
"Feel free to use any other exception type here instead."
)
# Should we stall for a while?
self._stall_if_necessary(p=self.p_stall_reset)
return super().reset()
@override(CartPoleEnv)
def step(self, action):
# Increase timestep counter for the ongoing episode.
self.timesteps += 1
# Should we crash?
if self._should_crash(p=self.p_crash):
raise EnvError(
f"Simulated env crash on worker={self.config.worker_index} "
f"env-idx={self.config.vector_index} during `step()`! "
"Feel free to use any other exception type here instead."
)
# Should we stall for a while?
self._stall_if_necessary(p=self.p_stall)
return super().step(action)
def _should_crash(self, p):
rnd = self._rng.rand()
if rnd < p:
print("Crashing due to p(crash)!")
return True
elif self.crash_after_n_steps is not None:
if self._crash_after_n_steps is None:
self._crash_after_n_steps = (
self.crash_after_n_steps
if not isinstance(self.crash_after_n_steps, tuple)
else np.random.randint(
self.crash_after_n_steps[0], self.crash_after_n_steps[1]
)
)
if self._crash_after_n_steps == self.timesteps:
print("Crashing due to n timesteps reached!")
return True
return False
def _stall_if_necessary(self, p):
stall = False
if self._rng.rand() < p:
stall = True
elif self.stall_after_n_steps is not None:
if self._stall_after_n_steps is None:
self._stall_after_n_steps = (
self.stall_after_n_steps
if not isinstance(self.stall_after_n_steps, tuple)
else np.random.randint(
self.stall_after_n_steps[0], self.stall_after_n_steps[1]
)
)
if self._stall_after_n_steps == self.timesteps:
stall = True
if stall:
sec = (
self.stall_time_sec
if not isinstance(self.stall_time_sec, tuple)
else np.random.uniform(self.stall_time_sec[0], self.stall_time_sec[1])
)
print(f" -> will stall for {sec}sec ...")
time.sleep(sec)
MultiAgentCartPoleCrashing = make_multi_agent(lambda config: CartPoleCrashing(config))
@@ -0,0 +1,51 @@
from copy import deepcopy
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Box, Dict, Discrete
class CartPoleSparseRewards(gym.Env):
"""Wrapper for gym CartPole environment where reward is accumulated to the end."""
def __init__(self, config=None):
self.env = gym.make("CartPole-v1")
self.action_space = Discrete(2)
self.observation_space = Dict(
{
"obs": self.env.observation_space,
"action_mask": Box(
low=0, high=1, shape=(self.action_space.n,), dtype=np.int8
),
}
)
self.running_reward = 0
def reset(self, *, seed=None, options=None):
self.running_reward = 0
obs, infos = self.env.reset()
return {
"obs": obs,
"action_mask": np.array([1, 1], dtype=np.int8),
}, infos
def step(self, action):
obs, rew, terminated, truncated, info = self.env.step(action)
self.running_reward += rew
score = self.running_reward if terminated else 0
return (
{"obs": obs, "action_mask": np.array([1, 1], dtype=np.int8)},
score,
terminated,
truncated,
info,
)
def set_state(self, state):
self.running_reward = state[1]
self.env = deepcopy(state[0])
obs = np.array(list(self.env.unwrapped.state))
return {"obs": obs, "action_mask": np.array([1, 1], dtype=np.int8)}
def get_state(self):
return deepcopy(self.env), self.running_reward
@@ -0,0 +1,74 @@
import gymnasium as gym
import numpy as np
from gymnasium.envs.classic_control import CartPoleEnv
class CartPoleWithDictObservationSpace(CartPoleEnv):
"""CartPole gym environment that has a dict observation space.
However, otherwise, the information content in each observation remains the same.
https://github.com/Farama-Foundation/Gymnasium/blob/main/gymnasium/envs/classic_control/cartpole.py # noqa
The new observation space looks as follows (a little quirky, but this is
for testing purposes only):
gym.spaces.Dict({
"x-pos": [x-pos],
"angular-pos": gym.spaces.Dict({"test": [angular-pos]}),
"velocs": gym.spaces.Tuple([x-veloc, angular-veloc]),
})
"""
def __init__(self, config=None):
super().__init__()
# Fix our observation-space as described above.
low = self.observation_space.low
high = self.observation_space.high
# Test as many quirks and oddities as possible: Dict, Dict inside a Dict,
# Tuple inside a Dict, and both (1,)-shapes as well as ()-shapes for Boxes.
# Also add a random discrete variable here.
self.observation_space = gym.spaces.Dict(
{
"x-pos": gym.spaces.Box(low[0], high[0], (1,), dtype=np.float32),
"angular-pos": gym.spaces.Dict(
{
"value": gym.spaces.Box(low[2], high[2], (), dtype=np.float32),
# Add some random non-essential information.
"some_random_stuff": gym.spaces.Discrete(3),
}
),
"velocs": gym.spaces.Tuple(
[
# x-veloc
gym.spaces.Box(low[1], high[1], (1,), dtype=np.float32),
# angular-veloc
gym.spaces.Box(low[3], high[3], (), dtype=np.float32),
]
),
}
)
def step(self, action):
next_obs, reward, done, truncated, info = super().step(action)
return self._compile_current_obs(next_obs), reward, done, truncated, info
def reset(self, *, seed=None, options=None):
init_obs, init_info = super().reset(seed=seed, options=options)
return self._compile_current_obs(init_obs), init_info
def _compile_current_obs(self, original_cartpole_obs):
# original_cartpole_obs is [x-pos, x-veloc, angle, angle-veloc]
return {
"x-pos": np.array([original_cartpole_obs[0]], np.float32),
"angular-pos": {
"value": np.array(original_cartpole_obs[2]),
"some_random_stuff": np.random.randint(3),
},
"velocs": (
np.array([original_cartpole_obs[1]], np.float32),
np.array(original_cartpole_obs[3], np.float32),
),
}
@@ -0,0 +1,69 @@
import gymnasium as gym
import numpy as np
from gymnasium.envs.classic_control import CartPoleEnv
class CartPoleWithLargeObservationSpace(CartPoleEnv):
"""CartPole gym environment that has a large dict observation space.
However, otherwise, the information content in each observation remains the same.
https://github.com/Farama-Foundation/Gymnasium/blob/main/gymnasium/envs/classic_control/cartpole.py # noqa
The new observation space looks as follows (a little quirky, but this is
for testing purposes only):
gym.spaces.Dict({
"1": gym.spaces.Tuple((
gym.spaces.Discrete(100),
gym.spaces.Box(0, 256, shape=(30,), dtype=float32),
)),
"2": gym.spaces.Tuple((
gym.spaces.Discrete(100),
gym.spaces.Box(0, 256, shape=(30,), dtype=float32),
)),
"3": ...
"actual-obs": gym.spaces.Box(-inf, inf, (4,), float32),
})
"""
def __init__(self, config=None):
super().__init__()
# Fix our observation-space as described above.
low = self.observation_space.low
high = self.observation_space.high
# Test as many quirks and oddities as possible: Dict, Dict inside a Dict,
# Tuple inside a Dict, and both (1,)-shapes as well as ()-shapes for Boxes.
# Also add a random discrete variable here.
spaces = {
str(i): gym.spaces.Tuple(
(
gym.spaces.Discrete(100),
gym.spaces.Box(0, 256, shape=(30,), dtype=np.float32),
)
)
for i in range(100)
}
spaces.update(
{
"actually-useful-stuff": (
gym.spaces.Box(low[0], high[0], (4,), np.float32)
)
}
)
self.observation_space = gym.spaces.Dict(spaces)
def step(self, action):
next_obs, reward, done, truncated, info = super().step(action)
return self._compile_current_obs(next_obs), reward, done, truncated, info
def reset(self, *, seed=None, options=None):
init_obs, init_info = super().reset(seed=seed, options=options)
return self._compile_current_obs(init_obs), init_info
def _compile_current_obs(self, original_cartpole_obs):
return {
str(i): self.observation_space.spaces[str(i)].sample() for i in range(100)
} | {"actually-useful-stuff": original_cartpole_obs}
@@ -0,0 +1,79 @@
import gymnasium as gym
import numpy as np
from gymnasium.envs.classic_control import CartPoleEnv
from ray.rllib.examples.envs.classes.utils.cartpole_observations_proto import (
CartPoleObservation,
)
class CartPoleWithProtobufObservationSpace(CartPoleEnv):
"""CartPole gym environment that has a protobuf observation space.
Sometimes, it is more performant for an environment to publish its observations
as a protobuf message (instead of a heavily nested Dict).
The protobuf message used here is originally defined in the
`./utils/cartpole_observations.proto` file. We converted this file into a python
importable module by compiling it with:
`protoc --python_out=. cartpole_observations.proto`
.. which yielded the `cartpole_observations_proto.py` file in the same directory
(we import this file's `CartPoleObservation` message here).
The new observation space is a (binary) Box(0, 255, ([len of protobuf],), uint8).
A ConnectorV2 pipeline or simpler gym.Wrapper will have to be used to convert this
observation format into an NN-readable (e.g. float32) 1D tensor.
"""
def __init__(self, config=None):
super().__init__()
dummy_obs = self._convert_observation_to_protobuf(
np.array([1.0, 1.0, 1.0, 1.0])
)
bin_length = len(dummy_obs)
self.observation_space = gym.spaces.Box(0, 255, (bin_length,), np.uint8)
def step(self, action):
observation, reward, terminated, truncated, info = super().step(action)
proto_observation = self._convert_observation_to_protobuf(observation)
return proto_observation, reward, terminated, truncated, info
def reset(self, **kwargs):
observation, info = super().reset(**kwargs)
proto_observation = self._convert_observation_to_protobuf(observation)
return proto_observation, info
def _convert_observation_to_protobuf(self, observation):
x_pos, x_veloc, angle_pos, angle_veloc = observation
# Create the Protobuf message
cartpole_observation = CartPoleObservation()
cartpole_observation.x_pos = x_pos
cartpole_observation.x_veloc = x_veloc
cartpole_observation.angle_pos = angle_pos
cartpole_observation.angle_veloc = angle_veloc
# Serialize to binary string.
return np.frombuffer(cartpole_observation.SerializeToString(), np.uint8)
if __name__ == "__main__":
env = CartPoleWithProtobufObservationSpace()
obs, info = env.reset()
# Test loading a protobuf object with data from the obs binary string
# (uint8 ndarray).
byte_str = obs.tobytes()
obs_protobuf = CartPoleObservation()
obs_protobuf.ParseFromString(byte_str)
print(obs_protobuf)
terminated = truncated = False
while not terminated and not truncated:
action = env.action_space.sample()
obs, reward, terminated, truncated, info = env.step(action)
print(obs)
@@ -0,0 +1,71 @@
import gymnasium as gym
from gymnasium import spaces
ACTION_UP = 0
ACTION_RIGHT = 1
ACTION_DOWN = 2
ACTION_LEFT = 3
class CliffWalkingWallEnv(gym.Env):
"""Modified version of the CliffWalking environment from Farama-Foundation's
Gymnasium with walls instead of a cliff.
### Description
The board is a 4x12 matrix, with (using NumPy matrix indexing):
- [3, 0] or obs==36 as the start at bottom-left
- [3, 11] or obs==47 as the goal at bottom-right
- [3, 1..10] or obs==37...46 as the cliff at bottom-center
An episode terminates when the agent reaches the goal.
### Actions
There are 4 discrete deterministic actions:
- 0: move up
- 1: move right
- 2: move down
- 3: move left
You can also use the constants ACTION_UP, ACTION_RIGHT, ... defined above.
### Observations
There are 3x12 + 2 possible states, not including the walls. If an action
would move an agent into one of the walls, it simply stays in the same position.
### Reward
Each time step incurs -1 reward, except reaching the goal which gives +10 reward.
"""
def __init__(self, seed=42) -> None:
self.observation_space = spaces.Discrete(48)
self.action_space = spaces.Discrete(4)
self.observation_space.seed(seed)
self.action_space.seed(seed)
def reset(self, *, seed=None, options=None):
self.position = 36
return self.position, {}
def step(self, action):
x = self.position // 12
y = self.position % 12
# UP
if action == ACTION_UP:
x = max(x - 1, 0)
# RIGHT
elif action == ACTION_RIGHT:
if self.position != 36:
y = min(y + 1, 11)
# DOWN
elif action == ACTION_DOWN:
if self.position < 25 or self.position > 34:
x = min(x + 1, 3)
# LEFT
elif action == ACTION_LEFT:
if self.position != 47:
y = max(y - 1, 0)
else:
raise ValueError(f"action {action} not in {self.action_space}")
self.position = x * 12 + y
done = self.position == 47
reward = -1 if not done else 10
return self.position, reward, done, False, {}
@@ -0,0 +1,79 @@
from typing import Any, Dict, Optional
import gymnasium as gym
import numpy as np
class CorrelatedActionsEnv(gym.Env):
"""Environment that can only be solved through an autoregressive action model.
In each step, the agent observes a random number (between -1 and 1) and has
to choose two actions, a1 (discrete, 0, 1, or 2) and a2 (cont. between -1 and 1).
The reward is constructed such that actions need to be correlated to succeed. It's
impossible for the network to learn each action head separately.
There are two reward components:
The first is the negative absolute value of the delta between 1.0 and the sum of
obs + a1. For example, if obs is -0.3 and a1 was sampled to be 1, then the value of
the first reward component is:
r1 = -abs(1.0 - [obs+a1]) = -abs(1.0 - (-0.3 + 1)) = -abs(0.3) = -0.3
The second reward component is computed as the negative absolute value
of `obs + a1 + a2`. For example, if obs is 0.5, a1 was sampled to be 0,
and a2 was sampled to be -0.7, then the value of the second reward component is:
r2 = -abs(obs + a1 + a2) = -abs(0.5 + 0 - 0.7)) = -abs(-0.2) = -0.2
Because of this specific reward function, the agent must learn to optimally sample
a1 based on the observation and to optimally sample a2, based on the observation
AND the sampled value of a1.
One way to effectively learn this is through correlated action
distributions, e.g., in examples/actions/auto_regressive_actions.py
The game ends after the first step.
"""
def __init__(self, config=None):
super().__init__()
# Observation space (single continuous value between -1. and 1.).
self.observation_space = gym.spaces.Box(-1.0, 1.0, shape=(1,), dtype=np.float32)
# Action space (discrete action a1 and continuous action a2).
self.action_space = gym.spaces.Tuple(
[gym.spaces.Discrete(3), gym.spaces.Box(-2.0, 2.0, (1,), np.float32)]
)
# Internal state for the environment (e.g., could represent a factor
# influencing the relationship)
self.obs = None
def reset(
self, seed: Optional[int] = None, options: Optional[Dict[str, Any]] = None
):
"""Reset the environment to an initial state."""
super().reset(seed=seed, options=options)
# Randomly initialize the observation between -1 and 1.
self.obs = np.random.uniform(-1, 1, size=(1,))
return self.obs, {}
def step(self, action):
"""Apply the autoregressive action and return step information."""
# Extract individual action components, a1 and a2.
a1, a2 = action
a2 = a2[0] # dissolve shape=(1,)
# r1 depends on how well a1 is aligned to obs:
r1 = -abs(1.0 - (self.obs[0] + a1))
# r2 depends on how well a2 is aligned to both, obs and a1.
r2 = -abs(self.obs[0] + a1 + a2)
reward = r1 + r2
# Optionally: add some noise or complexity to the reward function
# reward += np.random.normal(0, 0.01) # Small noise can be added
# Terminate after each step (no episode length in this simple example)
return self.obs, reward, True, False, {}
+46
View File
@@ -0,0 +1,46 @@
"""
8 Environments from D4RL Environment.
Use fully qualified class-path in your configs:
e.g. "env": "ray.rllib.examples.envs.classes.d4rl_env.halfcheetah_random".
"""
import gymnasium as gym
try:
import d4rl
_ = d4rl.__name__ # Fool LINTer.
except ImportError:
d4rl = None
def halfcheetah_random():
return gym.make("halfcheetah-random-v0")
def halfcheetah_medium():
return gym.make("halfcheetah-medium-v0")
def halfcheetah_expert():
return gym.make("halfcheetah-expert-v0")
def halfcheetah_medium_replay():
return gym.make("halfcheetah-medium-replay-v0")
def hopper_random():
return gym.make("hopper-random-v0")
def hopper_medium():
return gym.make("hopper-medium-v0")
def hopper_expert():
return gym.make("hopper-expert-v0")
def hopper_medium_replay():
return gym.make("hopper-medium-replay-v0")
@@ -0,0 +1,92 @@
import gymnasium as gym
import numpy as np
from ray.rllib.env.multi_agent_env import MultiAgentEnv
class DebugCounterEnv(gym.Env):
"""Simple Env that yields a ts counter as observation (0-based).
Actions have no effect.
The episode length is always 15.
Reward is always: current ts % 3.
"""
def __init__(self, config=None):
config = config or {}
self.action_space = gym.spaces.Discrete(2)
self.observation_space = gym.spaces.Box(0, 100, (1,), dtype=np.float32)
self.start_at_t = int(config.get("start_at_t", 0))
self.i = self.start_at_t
def reset(self, *, seed=None, options=None):
self.i = self.start_at_t
return self._get_obs(), {}
def step(self, action):
self.i += 1
terminated = False
truncated = self.i >= 15 + self.start_at_t
return self._get_obs(), float(self.i % 3), terminated, truncated, {}
def _get_obs(self):
return np.array([self.i], dtype=np.float32)
class MultiAgentDebugCounterEnv(MultiAgentEnv):
def __init__(self, config):
super().__init__()
self.num_agents = config["num_agents"]
self.base_episode_len = config.get("base_episode_len", 103)
# Observation dims:
# 0=agent ID.
# 1=episode ID (0.0 for obs after reset).
# 2=env ID (0.0 for obs after reset).
# 3=ts (of the agent).
self.observation_space = gym.spaces.Dict(
{
aid: gym.spaces.Box(float("-inf"), float("inf"), (4,))
for aid in range(self.num_agents)
}
)
# Actions are always:
# (episodeID, envID) as floats.
self.action_space = gym.spaces.Dict(
{
aid: gym.spaces.Box(-float("inf"), float("inf"), shape=(2,))
for aid in range(self.num_agents)
}
)
self.timesteps = [0] * self.num_agents
self.terminateds = set()
self.truncateds = set()
def reset(self, *, seed=None, options=None):
self.timesteps = [0] * self.num_agents
self.terminateds = set()
self.truncateds = set()
return {
i: np.array([i, 0.0, 0.0, 0.0], dtype=np.float32)
for i in range(self.num_agents)
}, {}
def step(self, action_dict):
obs, rew, terminated, truncated = {}, {}, {}, {}
for i, action in action_dict.items():
self.timesteps[i] += 1
obs[i] = np.array([i, action[0], action[1], self.timesteps[i]])
rew[i] = self.timesteps[i] % 3
terminated[i] = False
truncated[i] = (
True if self.timesteps[i] > self.base_episode_len + i else False
)
if terminated[i]:
self.terminateds.add(i)
if truncated[i]:
self.truncateds.add(i)
terminated["__all__"] = len(self.terminateds) == self.num_agents
truncated["__all__"] = len(self.truncateds) == self.num_agents
return obs, rew, terminated, truncated, {}
@@ -0,0 +1,13 @@
import gymnasium as gym
def create_cartpole_deterministic(config):
env = gym.make("CartPole-v1")
env.reset(seed=config.get("seed", 0))
return env
def create_pendulum_deterministic(config):
env = gym.make("Pendulum-v1")
env.reset(seed=config.get("seed", 0))
return env
@@ -0,0 +1,131 @@
from ray.rllib.env.wrappers.dm_control_wrapper import DMCEnv
"""
8 Environments from Deepmind Control Suite
"""
def acrobot_swingup(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"acrobot",
"swingup",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def walker_walk(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"walker",
"walk",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def hopper_hop(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"hopper",
"hop",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def hopper_stand(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"hopper",
"stand",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def cheetah_run(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"cheetah",
"run",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def walker_run(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"walker",
"run",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def pendulum_swingup(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"pendulum",
"swingup",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def cartpole_swingup(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"cartpole",
"swingup",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
def humanoid_walk(
from_pixels=True, height=64, width=64, frame_skip=2, channels_first=True
):
return DMCEnv(
"humanoid",
"walk",
from_pixels=from_pixels,
height=height,
width=width,
frame_skip=frame_skip,
channels_first=channels_first,
)
@@ -0,0 +1,63 @@
"""
Example of an environment that uses a named remote actor as parameter
server.
"""
from gymnasium.envs.classic_control.cartpole import CartPoleEnv
from gymnasium.utils import seeding
import ray
@ray.remote
class ParameterStorage:
def get_params(self, rng):
return {
"MASSCART": rng.uniform(low=0.5, high=2.0),
}
class CartPoleWithRemoteParamServer(CartPoleEnv):
"""CartPoleMassEnv varies the weights of the cart and the pole."""
def __init__(self, env_config):
self.env_config = env_config
super().__init__()
# Get our param server (remote actor) by name.
self._handler = ray.get_actor(env_config.get("param_server", "param-server"))
self.rng_seed = None
self.np_random, _ = seeding.np_random(self.rng_seed)
def reset(self, *, seed=None, options=None):
if seed is not None:
self.rng_seed = int(seed)
self.np_random, _ = seeding.np_random(seed)
print(
f"Seeding env (worker={self.env_config.worker_index}) " f"with {seed}"
)
# Pass in our RNG to guarantee no race conditions.
# If `self._handler` had its own RNG, this may clash with other
# envs trying to use the same param-server.
params = ray.get(self._handler.get_params.remote(self.np_random))
# IMPORTANT: Advance the state of our RNG (self._rng was passed
# above via ray (serialized) and thus not altered locally here!).
# Or create a new RNG from another random number:
# Seed the RNG with a deterministic seed if set, otherwise, create
# a random one.
new_seed = int(
self.np_random.integers(0, 1000000) if not self.rng_seed else self.rng_seed
)
self.np_random, _ = seeding.np_random(new_seed)
print(
f"Env worker-idx={self.env_config.worker_index} "
f"mass={params['MASSCART']}"
)
self.masscart = params["MASSCART"]
self.total_mass = self.masspole + self.masscart
self.polemass_length = self.masspole * self.length
return super().reset()
@@ -0,0 +1,43 @@
import atexit
import os
import subprocess
import gymnasium as gym
from gymnasium.spaces import Discrete
class EnvWithSubprocess(gym.Env):
"""An env that spawns a subprocess."""
# Dummy command to run as a subprocess with a unique name
UNIQUE_CMD = "sleep 20"
def __init__(self, config):
self.UNIQUE_FILE_0 = config["tmp_file1"]
self.UNIQUE_FILE_1 = config["tmp_file2"]
self.UNIQUE_FILE_2 = config["tmp_file3"]
self.UNIQUE_FILE_3 = config["tmp_file4"]
self.action_space = Discrete(2)
self.observation_space = Discrete(2)
# Subprocess that should be cleaned up.
self.subproc = subprocess.Popen(self.UNIQUE_CMD.split(" "), shell=False)
self.config = config
# Exit handler should be called.
atexit.register(lambda: self.subproc.kill())
if config.worker_index == 0:
atexit.register(lambda: os.unlink(self.UNIQUE_FILE_0))
else:
atexit.register(lambda: os.unlink(self.UNIQUE_FILE_1))
def close(self):
if self.config.worker_index == 0:
os.unlink(self.UNIQUE_FILE_2)
else:
os.unlink(self.UNIQUE_FILE_3)
def reset(self, *, seed=None, options=None):
return 0, {}
def step(self, action):
return 0, 0, True, False, {}
@@ -0,0 +1,20 @@
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Box, Discrete
class FastImageEnv(gym.Env):
def __init__(self, config):
self.zeros = np.zeros((84, 84, 4))
self.action_space = Discrete(2)
self.observation_space = Box(0.0, 1.0, shape=(84, 84, 4), dtype=np.float32)
self.i = 0
def reset(self, *, seed=None, options=None):
self.i = 0
return self.zeros, {}
def step(self, action):
self.i += 1
done = truncated = self.i > 1000
return self.zeros, 1, done, truncated, {}
@@ -0,0 +1,37 @@
import numpy as np
import ray
from ray.rllib.examples.envs.classes.simple_corridor import SimpleCorridor
from ray.rllib.utils.framework import try_import_torch
torch, _ = try_import_torch()
class GPURequiringEnv(SimpleCorridor):
"""A dummy env that requires a GPU in order to work.
The env here is a simple corridor env that additionally simulates a GPU
check in its constructor via `ray.get_gpu_ids()`. If this returns an
empty list, we raise an error.
To make this env work, use `num_gpus_per_env_runner > 0` (RolloutWorkers
requesting this many GPUs each) and - maybe - `num_gpus > 0` in case
your local worker/driver must have an env as well. However, this is
only the case if `create_local_env_runner`=True (default is False).
"""
def __init__(self, config=None):
super().__init__(config)
# Fake-require some GPUs (at least one).
# If your local worker's env (`create_local_env_runner`=True) does not
# necessarily require a GPU, you can perform the below assertion only
# if `config.worker_index != 0`.
gpus_available = ray.get_gpu_ids()
print(f"{type(self).__name__} can see GPUs={gpus_available}")
# Create a dummy tensor on the GPU.
if len(gpus_available) > 0 and torch:
self._tensor = torch.from_numpy(np.random.random_sample(size=(42, 42))).to(
f"cuda:{gpus_available[0]}"
)
@@ -0,0 +1,65 @@
import gymnasium as gym
import numpy as np
class LookAndPush(gym.Env):
"""Memory-requiring Env: Best sequence of actions depends on prev. states.
Optimal behavior:
0) a=0 -> observe next state (s'), which is the "hidden" state.
If a=1 here, the hidden state is not observed.
1) a=1 to always jump to s=2 (not matter what the prev. state was).
2) a=1 to move to s=3.
3) a=1 to move to s=4.
4) a=0 OR 1 depending on s' observed after 0): +10 reward and done.
otherwise: -10 reward and done.
"""
def __init__(self):
self.action_space = gym.spaces.Discrete(2)
self.observation_space = gym.spaces.Discrete(5)
self._state = None
self._case = None
def reset(self, *, seed=None, options=None):
self._state = 2
self._case = np.random.choice(2)
return self._state, {}
def step(self, action):
assert self.action_space.contains(action)
if self._state == 4:
if action and self._case:
return self._state, 10.0, True, {}
else:
return self._state, -10, True, {}
else:
if action:
if self._state == 0:
self._state = 2
else:
self._state += 1
elif self._state == 2:
self._state = self._case
return self._state, -1, False, False, {}
class OneHot(gym.Wrapper):
def __init__(self, env):
super(OneHot, self).__init__(env)
self.observation_space = gym.spaces.Box(0.0, 1.0, (env.observation_space.n,))
def reset(self, *, seed=None, options=None):
obs, info = self.env.reset(seed=seed, options=options)
return self._encode_obs(obs), info
def step(self, action):
obs, reward, terminated, truncated, info = self.env.step(action)
return self._encode_obs(obs), reward, terminated, truncated, info
def _encode_obs(self, obs):
new_obs = np.ones(self.env.observation_space.n)
new_obs[obs] = 1.0
return new_obs
@@ -0,0 +1,35 @@
import logging
import uuid
from ray.rllib.examples.envs.classes.random_env import RandomEnv
from ray.rllib.utils.annotations import override
logger = logging.getLogger(__name__)
class MemoryLeakingEnv(RandomEnv):
"""An env that leaks very little memory.
Useful for proving that our memory-leak tests can catch the
slightest leaks.
"""
def __init__(self, config=None):
super().__init__(config)
self._leak = {}
self._steps_after_reset = 0
@override(RandomEnv)
def reset(self, *, seed=None, options=None):
self._steps_after_reset = 0
return super().reset(seed=seed, options=options)
@override(RandomEnv)
def step(self, action):
self._steps_after_reset += 1
# Only leak once an episode.
if self._steps_after_reset == 2:
self._leak[uuid.uuid4().hex.upper()] = 1
return super().step(action)
+221
View File
@@ -0,0 +1,221 @@
from typing import Optional
import gymnasium as gym
import numpy as np
from ray.rllib.env.vector_env import VectorEnv
from ray.rllib.utils.annotations import override
class MockEnv(gym.Env):
"""Mock environment for testing purposes.
Observation=0, reward=1.0, episode-len is configurable.
Actions are ignored.
"""
def __init__(self, episode_length, config=None):
self.episode_length = episode_length
self.config = config
self.i = 0
self.observation_space = gym.spaces.Discrete(1)
self.action_space = gym.spaces.Discrete(2)
def reset(self, *, seed=None, options=None):
self.i = 0
return 0, {}
def step(self, action):
self.i += 1
terminated = truncated = self.i >= self.episode_length
return 0, 1.0, terminated, truncated, {}
class MockEnv2(gym.Env):
"""Mock environment for testing purposes.
Observation=ts (discrete space!), reward=100.0, episode-len is
configurable. Actions are ignored.
"""
metadata = {
"render.modes": ["rgb_array"],
}
render_mode: Optional[str] = "rgb_array"
def __init__(self, episode_length):
self.episode_length = episode_length
self.i = 0
self.observation_space = gym.spaces.Discrete(self.episode_length + 1)
self.action_space = gym.spaces.Discrete(2)
self.rng_seed = None
def reset(self, *, seed=None, options=None):
self.i = 0
if seed is not None:
self.rng_seed = seed
return self.i, {}
def step(self, action):
self.i += 1
terminated = truncated = self.i >= self.episode_length
return self.i, 100.0, terminated, truncated, {}
def render(self):
# Just generate a random image here for demonstration purposes.
# Also see `gym/envs/classic_control/cartpole.py` for
# an example on how to use a Viewer object.
return np.random.randint(0, 256, size=(300, 400, 3), dtype=np.uint8)
class MockEnv3(gym.Env):
"""Mock environment for testing purposes.
Observation=ts (discrete space!), reward=100.0, episode-len is
configurable. Actions are ignored.
"""
def __init__(self, episode_length):
self.episode_length = episode_length
self.i = 0
self.observation_space = gym.spaces.Discrete(100)
self.action_space = gym.spaces.Discrete(2)
def reset(self, *, seed=None, options=None):
self.i = 0
return self.i, {"timestep": 0}
def step(self, action):
self.i += 1
terminated = truncated = self.i >= self.episode_length
return self.i, self.i, terminated, truncated, {"timestep": self.i}
class VectorizedMockEnv(VectorEnv):
"""Vectorized version of the MockEnv.
Contains `num_envs` MockEnv instances, each one having its own
`episode_length` horizon.
"""
def __init__(self, episode_length, num_envs):
super().__init__(
observation_space=gym.spaces.Discrete(1),
action_space=gym.spaces.Discrete(2),
num_envs=num_envs,
)
self.envs = [MockEnv(episode_length) for _ in range(num_envs)]
@override(VectorEnv)
def vector_reset(self, *, seeds=None, options=None):
seeds = seeds or [None] * self.num_envs
options = options or [None] * self.num_envs
obs_and_infos = [
e.reset(seed=seeds[i], options=options[i]) for i, e in enumerate(self.envs)
]
return [oi[0] for oi in obs_and_infos], [oi[1] for oi in obs_and_infos]
@override(VectorEnv)
def reset_at(self, index, *, seed=None, options=None):
return self.envs[index].reset(seed=seed, options=options)
@override(VectorEnv)
def vector_step(self, actions):
obs_batch, rew_batch, terminated_batch, truncated_batch, info_batch = (
[],
[],
[],
[],
[],
)
for i in range(len(self.envs)):
obs, rew, terminated, truncated, info = self.envs[i].step(actions[i])
obs_batch.append(obs)
rew_batch.append(rew)
terminated_batch.append(terminated)
truncated_batch.append(truncated)
info_batch.append(info)
return obs_batch, rew_batch, terminated_batch, truncated_batch, info_batch
@override(VectorEnv)
def get_sub_environments(self):
return self.envs
class MockVectorEnv(VectorEnv):
"""A custom vector env that uses a single(!) CartPole sub-env.
However, this env pretends to be a vectorized one to illustrate how one
could create custom VectorEnvs w/o the need for actual vectorizations of
sub-envs under the hood.
"""
def __init__(self, episode_length, mocked_num_envs):
self.env = gym.make("CartPole-v1")
super().__init__(
observation_space=self.env.observation_space,
action_space=self.env.action_space,
num_envs=mocked_num_envs,
)
self.episode_len = episode_length
self.ts = 0
@override(VectorEnv)
def vector_reset(self, *, seeds=None, options=None):
# Since we only have one underlying sub-environment, just use the first seed
# and the first options dict (the user of this env thinks, there are
# `self.num_envs` sub-environments and sends that many seeds/options).
seeds = seeds or [None]
options = options or [None]
obs, infos = self.env.reset(seed=seeds[0], options=options[0])
# Simply repeat the single obs/infos to pretend we really have
# `self.num_envs` sub-environments.
return (
[obs for _ in range(self.num_envs)],
[infos for _ in range(self.num_envs)],
)
@override(VectorEnv)
def reset_at(self, index, *, seed=None, options=None):
self.ts = 0
return self.env.reset(seed=seed, options=options)
@override(VectorEnv)
def vector_step(self, actions):
self.ts += 1
# Apply all actions sequentially to the same env.
# Whether this would make a lot of sense is debatable.
obs_batch, rew_batch, terminated_batch, truncated_batch, info_batch = (
[],
[],
[],
[],
[],
)
for i in range(self.num_envs):
obs, rew, terminated, truncated, info = self.env.step(actions[i])
# Artificially truncate once time step limit has been reached.
# Note: Also terminate/truncate, when underlying CartPole is
# terminated/truncated.
if self.ts >= self.episode_len:
truncated = True
obs_batch.append(obs)
rew_batch.append(rew)
terminated_batch.append(terminated)
truncated_batch.append(truncated)
info_batch.append(info)
if terminated or truncated:
remaining = self.num_envs - (i + 1)
obs_batch.extend([obs for _ in range(remaining)])
rew_batch.extend([rew for _ in range(remaining)])
terminated_batch.extend([terminated for _ in range(remaining)])
truncated_batch.extend([truncated for _ in range(remaining)])
info_batch.extend([info for _ in range(remaining)])
break
return obs_batch, rew_batch, terminated_batch, truncated_batch, info_batch
@override(VectorEnv)
def get_sub_environments(self):
# You may also leave this method as-is, in which case, it would
# return an empty list.
return [self.env for _ in range(self.num_envs)]
@@ -0,0 +1,34 @@
from ray.rllib.env.multi_agent_env import make_multi_agent
from ray.rllib.examples.envs.classes.cartpole_with_dict_observation_space import (
CartPoleWithDictObservationSpace,
)
from ray.rllib.examples.envs.classes.multi_agent.guess_the_number_game import (
GuessTheNumberGame,
)
from ray.rllib.examples.envs.classes.multi_agent.two_step_game import (
TwoStepGame,
TwoStepGameWithGroupedAgents,
)
from ray.rllib.examples.envs.classes.nested_space_repeat_after_me_env import (
NestedSpaceRepeatAfterMeEnv,
)
from ray.rllib.examples.envs.classes.stateless_cartpole import StatelessCartPole
# Backward compatibility.
__all__ = [
"GuessTheNumberGame",
"TwoStepGame",
"TwoStepGameWithGroupedAgents",
]
MultiAgentCartPole = make_multi_agent("CartPole-v1")
MultiAgentMountainCar = make_multi_agent("MountainCarContinuous-v0")
MultiAgentPendulum = make_multi_agent("Pendulum-v1")
MultiAgentStatelessCartPole = make_multi_agent(lambda config: StatelessCartPole(config))
MultiAgentCartPoleWithDictObservationSpace = make_multi_agent(
lambda config: CartPoleWithDictObservationSpace(config)
)
MultiAgentNestedSpaceRepeatAfterMeEnv = make_multi_agent(
lambda config: NestedSpaceRepeatAfterMeEnv(config)
)
@@ -0,0 +1,207 @@
import copy
import random
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Box, Discrete
class SimpleContextualBandit(gym.Env):
"""Simple env w/ 2 states and 3 actions (arms): 0, 1, and 2.
Episodes last only for one timestep, possible observations are:
[-1.0, 1.0] and [1.0, -1.0], where the first element is the "current context".
The highest reward (+10.0) is received for selecting arm 0 for context=1.0
and arm 2 for context=-1.0. Action 1 always yields 0.0 reward.
"""
def __init__(self, config=None):
self.action_space = Discrete(3)
self.observation_space = Box(low=-1.0, high=1.0, shape=(2,))
self.cur_context = None
def reset(self, *, seed=None, options=None):
self.cur_context = random.choice([-1.0, 1.0])
return np.array([self.cur_context, -self.cur_context]), {}
def step(self, action):
rewards_for_context = {
-1.0: [-10, 0, 10],
1.0: [10, 0, -10],
}
reward = rewards_for_context[self.cur_context][action]
return (
np.array([-self.cur_context, self.cur_context]),
reward,
True,
False,
{"regret": 10 - reward},
)
class LinearDiscreteEnv(gym.Env):
"""Samples data from linearly parameterized arms.
The reward for context X and arm i is given by X^T * theta_i, for some
latent set of parameters {theta_i : i = 1, ..., k}.
The thetas are sampled uniformly at random, the contexts are Gaussian,
and Gaussian noise is added to the rewards.
"""
DEFAULT_CONFIG_LINEAR = {
"feature_dim": 8,
"num_actions": 4,
"reward_noise_std": 0.01,
}
def __init__(self, config=None):
self.config = copy.copy(self.DEFAULT_CONFIG_LINEAR)
if config is not None and type(config) is dict:
self.config.update(config)
self.feature_dim = self.config["feature_dim"]
self.num_actions = self.config["num_actions"]
self.sigma = self.config["reward_noise_std"]
self.action_space = Discrete(self.num_actions)
self.observation_space = Box(low=-10, high=10, shape=(self.feature_dim,))
self.thetas = np.random.uniform(-1, 1, (self.num_actions, self.feature_dim))
self.thetas /= np.linalg.norm(self.thetas, axis=1, keepdims=True)
self._elapsed_steps = 0
self._current_context = None
def _sample_context(self):
return np.random.normal(scale=1 / 3, size=(self.feature_dim,))
def reset(self, *, seed=None, options=None):
self._current_context = self._sample_context()
return self._current_context, {}
def step(self, action):
assert (
self._elapsed_steps is not None
), "Cannot call env.step() beforecalling reset()"
assert action < self.num_actions, "Invalid action."
action = int(action)
context = self._current_context
rewards = self.thetas.dot(context)
opt_action = rewards.argmax()
regret = rewards.max() - rewards[action]
# Add Gaussian noise
rewards += np.random.normal(scale=self.sigma, size=rewards.shape)
reward = rewards[action]
self._current_context = self._sample_context()
return (
self._current_context,
reward,
True,
False,
{"regret": regret, "opt_action": opt_action},
)
def render(self, mode="human"):
raise NotImplementedError
class WheelBanditEnv(gym.Env):
"""Wheel bandit environment for 2D contexts
(see https://arxiv.org/abs/1802.09127).
"""
DEFAULT_CONFIG_WHEEL = {
"delta": 0.5,
"mu_1": 1.2,
"mu_2": 1,
"mu_3": 50,
"std": 0.01,
}
feature_dim = 2
num_actions = 5
def __init__(self, config=None):
self.config = copy.copy(self.DEFAULT_CONFIG_WHEEL)
if config is not None and type(config) is dict:
self.config.update(config)
self.delta = self.config["delta"]
self.mu_1 = self.config["mu_1"]
self.mu_2 = self.config["mu_2"]
self.mu_3 = self.config["mu_3"]
self.std = self.config["std"]
self.action_space = Discrete(self.num_actions)
self.observation_space = Box(low=-1, high=1, shape=(self.feature_dim,))
self.means = [self.mu_1] + 4 * [self.mu_2]
self._elapsed_steps = 0
self._current_context = None
def _sample_context(self):
while True:
state = np.random.uniform(-1, 1, self.feature_dim)
if np.linalg.norm(state) <= 1:
return state
def reset(self, *, seed=None, options=None):
self._current_context = self._sample_context()
return self._current_context, {}
def step(self, action):
assert (
self._elapsed_steps is not None
), "Cannot call env.step() before calling reset()"
action = int(action)
self._elapsed_steps += 1
rewards = [
np.random.normal(self.means[j], self.std) for j in range(self.num_actions)
]
context = self._current_context
r_big = np.random.normal(self.mu_3, self.std)
if np.linalg.norm(context) >= self.delta:
if context[0] > 0:
if context[1] > 0:
# First quadrant
rewards[1] = r_big
opt_action = 1
else:
# Fourth quadrant
rewards[4] = r_big
opt_action = 4
else:
if context[1] > 0:
# Second quadrant
rewards[2] = r_big
opt_action = 2
else:
# Third quadrant
rewards[3] = r_big
opt_action = 3
else:
# Smaller region where action 0 is optimal
opt_action = 0
reward = rewards[action]
regret = rewards[opt_action] - reward
self._current_context = self._sample_context()
return (
self._current_context,
reward,
True,
False,
{"regret": regret, "opt_action": opt_action},
)
def render(self, mode="human"):
raise NotImplementedError
@@ -0,0 +1,228 @@
"""Examples for recommender system simulating envs ready to be used by RLlib Algorithms.
This env follows RecSim obs and action APIs.
"""
from typing import Optional
import gymnasium as gym
import numpy as np
from ray.rllib.utils.numpy import softmax
class ParametricRecSys(gym.Env):
"""A recommendation environment which generates items with visible features
randomly (parametric actions).
The environment can be configured to be multi-user, i.e. different models
will be learned independently for each user, by setting num_users_in_db
parameter.
To enable slate recommendation, the `slate_size` config parameter can be
set as > 1.
"""
def __init__(
self,
embedding_size: int = 20,
num_docs_to_select_from: int = 10,
slate_size: int = 1,
num_docs_in_db: Optional[int] = None,
num_users_in_db: Optional[int] = None,
user_time_budget: float = 60.0,
):
"""Initializes a ParametricRecSys instance.
Args:
embedding_size: Embedding size for both users and docs.
Each value in the user/doc embeddings can have values between
-1.0 and 1.0.
num_docs_to_select_from: The number of documents to present to the
agent each timestep. The agent will then have to pick a slate
out of these.
slate_size: The size of the slate to recommend to the user at each
timestep.
num_docs_in_db: The total number of documents in the DB. Set this
to None, in case you would like to resample docs from an
infinite pool.
num_users_in_db: The total number of users in the DB. Set this to
None, in case you would like to resample users from an infinite
pool.
user_time_budget: The total time budget a user has throughout an
episode. Once this time budget is used up (through engagements
with clicked/selected documents), the episode ends.
"""
self.embedding_size = embedding_size
self.num_docs_to_select_from = num_docs_to_select_from
self.slate_size = slate_size
self.num_docs_in_db = num_docs_in_db
self.docs_db = None
self.num_users_in_db = num_users_in_db
self.users_db = None
self.current_user = None
self.user_time_budget = user_time_budget
self.current_user_budget = user_time_budget
self.observation_space = gym.spaces.Dict(
{
# The D docs our agent sees at each timestep.
# It has to select a k-slate out of these.
"doc": gym.spaces.Dict(
{
str(i): gym.spaces.Box(
-1.0, 1.0, shape=(self.embedding_size,), dtype=np.float32
)
for i in range(self.num_docs_to_select_from)
}
),
# The user engaging in this timestep/episode.
"user": gym.spaces.Box(
-1.0, 1.0, shape=(self.embedding_size,), dtype=np.float32
),
# For each item in the previous slate, was it clicked?
# If yes, how long was it being engaged with (e.g. watched)?
"response": gym.spaces.Tuple(
[
gym.spaces.Dict(
{
# Clicked or not?
"click": gym.spaces.Discrete(2),
# Engagement time (how many minutes watched?).
"engagement": gym.spaces.Box(
0.0, 100.0, shape=(), dtype=np.float32
),
}
)
for _ in range(self.slate_size)
]
),
}
)
# Our action space is
self.action_space = gym.spaces.MultiDiscrete(
[self.num_docs_to_select_from for _ in range(self.slate_size)]
)
def _get_embedding(self):
return np.random.uniform(-1, 1, size=(self.embedding_size,)).astype(np.float32)
def reset(self, *, seed=None, options=None):
# Reset the current user's time budget.
self.current_user_budget = self.user_time_budget
# Sample a user for the next episode/session.
# Pick from a only-once-sampled user DB.
if self.num_users_in_db is not None:
if self.users_db is None:
self.users_db = [
self._get_embedding() for _ in range(self.num_users_in_db)
]
self.current_user = self.users_db[np.random.choice(self.num_users_in_db)]
# Pick from an infinite pool of users.
else:
self.current_user = self._get_embedding()
return self._get_obs(), {}
def step(self, action):
# Action is the suggested slate (indices of the docs in the
# suggested ones).
# We calculate scores as the dot product between document features and user
# features. The softmax ensures regret<1 further down.
scores = softmax(
[np.dot(self.current_user, doc) for doc in self.currently_suggested_docs]
)
best_reward = np.max(scores)
# User choice model: User picks a doc stochastically,
# where probs are dot products between user- and doc feature
# (categories) vectors (rewards).
# There is also a no-click doc whose weight is 0.0.
user_doc_overlaps = np.array([scores[a] for a in action] + [0.0])
# We have to softmax again so that probabilities add up to 1
probabilities = softmax(user_doc_overlaps)
which_clicked = np.random.choice(
np.arange(self.slate_size + 1), p=probabilities
)
reward = 0.0
if which_clicked < self.slate_size:
# Reward is 1.0 - regret if clicked. 0.0 if not clicked.
regret = best_reward - user_doc_overlaps[which_clicked]
# The reward also represents the user engagement that we define to be
# withing the range [0...100].
reward = (1 - regret) * 100
# If anything clicked, deduct from the current user's time budget.
self.current_user_budget -= 1.0
done = truncated = self.current_user_budget <= 0.0
# Compile response.
response = tuple(
{
"click": int(idx == which_clicked),
"engagement": reward if idx == which_clicked else 0.0,
}
for idx in range(len(user_doc_overlaps) - 1)
)
return self._get_obs(response=response), reward, done, truncated, {}
def _get_obs(self, response=None):
# Sample D docs from infinity or our pre-existing docs.
# Pick from a only-once-sampled docs DB.
if self.num_docs_in_db is not None:
if self.docs_db is None:
self.docs_db = [
self._get_embedding() for _ in range(self.num_docs_in_db)
]
self.currently_suggested_docs = [
self.docs_db[doc_idx].astype(np.float32)
for doc_idx in np.random.choice(
self.num_docs_in_db,
size=(self.num_docs_to_select_from,),
replace=False,
)
]
# Pick from an infinite pool of docs.
else:
self.currently_suggested_docs = [
self._get_embedding() for _ in range(self.num_docs_to_select_from)
]
doc = {str(i): d for i, d in enumerate(self.currently_suggested_docs)}
if not response:
response = self.observation_space["response"].sample()
return {
"user": self.current_user.astype(np.float32),
"doc": doc,
"response": response,
}
if __name__ == "__main__":
"""Test RecommSys env with random actions for baseline performance."""
env = ParametricRecSys(
num_docs_in_db=100,
num_users_in_db=1,
)
obs, info = env.reset()
num_episodes = 0
episode_rewards = []
episode_reward = 0.0
while num_episodes < 100:
action = env.action_space.sample()
obs, reward, done, truncated, _ = env.step(action)
episode_reward += reward
if done:
print(f"episode reward = {episode_reward}")
env.reset()
num_episodes += 1
episode_rewards.append(episode_reward)
episode_reward = 0.0
print(f"Avg reward={np.mean(episode_rewards)}")
@@ -0,0 +1,146 @@
import gymnasium as gym
import numpy as np
from ray.rllib.env.multi_agent_env import MultiAgentEnv
from ray.rllib.utils.annotations import override
from ray.rllib.utils.typing import AgentID
class DoubleRowCorridorEnv(MultiAgentEnv):
"""A MultiAgentEnv with a single, global observation space for all agents.
There are two agents in this grid-world-style environment, `agent_0` and `agent_1`.
The grid has two-rows and multiple columns and agents must, each
separately, reach their individual goal position to receive a final reward of +10:
+---------------+
|0 |
| 1|
+---------------+
Legend:
0 = agent_0 + goal state for agent_1
1 = agent_1 + goal state for agent_0
You can change the length of the grid through providing the "length" key in the
`config` dict passed to the env's constructor.
The action space for both agents is Discrete(4), which encodes to moving up, down,
left, or right in the grid.
If the two agents collide, meaning they end up in the exact same field after both
taking their actions at any timestep, an additional reward of +5 is given to both
agents. Thus, optimal policies aim at seeking the respective other agent first, and
only then proceeding to their agent's goal position.
Each agent in the env has an observation space of a 2-tuple containing its own
x/y-position, where x is the row index, being either 0 (1st row) or 1 (2nd row),
and y is the column index (starting from 0).
"""
def __init__(self, config=None):
super().__init__()
config = config or {}
self.length = config.get("length", 15)
self.terminateds = {}
self.collided = False
# Provide information about agents and possible agents.
self.agents = self.possible_agents = ["agent_0", "agent_1"]
self.terminateds = {}
# Observations: x/y, where the first number is the row index, the second number
# is the column index, for both agents.
# For example: [0.0, 2.0] means the agent is in row 0 and column 2.
self._obs_spaces = gym.spaces.Box(
0.0, self.length - 1, shape=(2,), dtype=np.int32
)
self._act_spaces = gym.spaces.Discrete(4)
@override(MultiAgentEnv)
def reset(self, *, seed=None, options=None):
self.agent_pos = {
"agent_0": [0, 0],
"agent_1": [1, self.length - 1],
}
self.goals = {
"agent_0": [0, self.length - 1],
"agent_1": [1, 0],
}
self.terminateds = {agent_id: False for agent_id in self.agent_pos}
self.collided = False
return self._get_obs(), {}
@override(MultiAgentEnv)
def step(self, action_dict):
rewards = {
agent_id: -0.1
for agent_id in self.agent_pos
if not self.terminateds[agent_id]
}
for agent_id, action in action_dict.items():
row, col = self.agent_pos[agent_id]
# up
if action == 0 and row > 0:
row -= 1
# down
elif action == 1 and row < 1:
row += 1
# left
elif action == 2 and col > 0:
col -= 1
# right
elif action == 3 and col < self.length - 1:
col += 1
# Update positions.
self.agent_pos[agent_id] = [row, col]
obs = self._get_obs()
# Check for collision (only if both agents are still active).
if (
not any(self.terminateds.values())
and self.agent_pos["agent_0"] == self.agent_pos["agent_1"]
):
if not self.collided:
rewards["agent_0"] += 5
rewards["agent_1"] += 5
self.collided = True
# Check goals.
for agent_id in self.agent_pos:
if (
self.agent_pos[agent_id] == self.goals[agent_id]
and not self.terminateds[agent_id]
):
rewards[agent_id] += 10
self.terminateds[agent_id] = True
terminateds = {
agent_id: self.terminateds[agent_id] for agent_id in self.agent_pos
}
terminateds["__all__"] = all(self.terminateds.values())
return obs, rewards, terminateds, {}, {}
@override(MultiAgentEnv)
def get_observation_space(self, agent_id: AgentID) -> gym.Space:
return self._obs_spaces
@override(MultiAgentEnv)
def get_action_space(self, agent_id: AgentID) -> gym.Space:
return self._act_spaces
def _get_obs(self):
obs = {}
pos = self.agent_pos
for agent_id in self.agent_pos:
if self.terminateds[agent_id]:
continue
obs[agent_id] = np.array(pos[agent_id], dtype=np.int32)
return obs
@@ -0,0 +1,10 @@
# Footsies Environment
This environment implementation is based on the [FootsiesGym project](https://github.com/chasemcd/FootsiesGym),
specifically the version as of **July 28, 2025**.
## Notes
All examples in the RLlib documentation that use the Footsies environment are self-contained.
This means that you do not need to install anything from the FootsiesGym repository or other places.
Examples handle binary automatically (downloading, extracting, starting, stopping, etc.).
@@ -0,0 +1,226 @@
import collections
import copy
from typing import Any, Optional, Union
import numpy as np
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto import (
footsies_service_pb2 as footsies_pb2,
)
class FootsiesEncoder:
"""Encoder class to generate observations from the game state"""
def __init__(self, observation_delay: int):
self._encoding_history = {
agent_id: collections.deque(maxlen=int(observation_delay))
for agent_id in ["p1", "p2"]
}
self.observation_delay = observation_delay
self._last_common_state: Optional[np.ndarray] = None
self._action_id_values = list(constants.FOOTSIES_ACTION_IDS.values())
@staticmethod
def encode_common_state(game_state: footsies_pb2.GameState) -> np.ndarray:
p1_state, p2_state = game_state.player1, game_state.player2
dist_x = np.abs(p1_state.player_position_x - p2_state.player_position_x) / 8.0
return np.array(
[
dist_x,
],
dtype=np.float32,
)
@staticmethod
def _encode_input_buffer(
input_buffer: list[int], last_n: Optional[int] = None
) -> np.ndarray:
"""Encodes the input buffer into a one-hot vector.
:param input_buffer: The input buffer to encode
:type input_buffer: list[int]
:return: The encoded one-hot vector
:rtype: np.ndarray
"""
if last_n is not None:
input_buffer = input_buffer[last_n:]
ib_encoding = []
for action_id in input_buffer:
arr = [0] * (len(constants.ACTION_TO_BITS) + 1)
arr[action_id] = 1
ib_encoding.extend(arr)
input_buffer_vector = np.asarray(ib_encoding, dtype=np.float32)
return input_buffer_vector
def encode(
self,
game_state: footsies_pb2.GameState,
) -> dict[str, Any]:
"""Encodes the game state into observations for all agents.
:param game_state: The game state to encode
:type game_state: footsies_pb2.GameState
:return: The encoded observations for all agents.
:rtype: dict[str, Any]
"""
common_state = self.encode_common_state(game_state)
p1_encoding = self.encode_player_state(game_state.player1)
p2_encoding = self.encode_player_state(game_state.player2)
observation_delay = min(
self.observation_delay, len(self._encoding_history["p1"])
)
if observation_delay > 0:
p1_delayed_encoding = self._encoding_history["p1"][-observation_delay]
p2_delayed_encoding = self._encoding_history["p2"][-observation_delay]
else:
p1_delayed_encoding = copy.deepcopy(p1_encoding)
p2_delayed_encoding = copy.deepcopy(p2_encoding)
self._encoding_history["p1"].append(p1_encoding)
self._encoding_history["p2"].append(p2_encoding)
self._last_common_state = common_state
# Create features dictionary
features = {}
current_index = 0
# Common state
features["common_state"] = {
"start": current_index,
"length": len(common_state),
}
current_index += len(common_state)
# Concatenate the observations for the undelayed encoding
p1_encoding = np.hstack(list(p1_encoding.values()), dtype=np.float32)
p2_encoding = np.hstack(list(p2_encoding.values()), dtype=np.float32)
# Concatenate the observations for the delayed encoding
p1_delayed_encoding = np.hstack(
list(p1_delayed_encoding.values()), dtype=np.float32
)
p2_delayed_encoding = np.hstack(
list(p2_delayed_encoding.values()), dtype=np.float32
)
p1_centric_observation = np.hstack(
[common_state, p1_encoding, p2_delayed_encoding]
)
p2_centric_observation = np.hstack(
[common_state, p2_encoding, p1_delayed_encoding]
)
return {"p1": p1_centric_observation, "p2": p2_centric_observation}
def encode_player_state(
self,
player_state: footsies_pb2.PlayerState,
) -> dict[str, Union[int, float, list, np.ndarray]]:
"""Encodes the player state into observations.
:param player_state: The player state to encode
:type player_state: footsies_pb2.PlayerState
:return: The encoded observations for the player
:rtype: dict[str, Any]
"""
feature_dict = {
"player_position_x": player_state.player_position_x
/ constants.FeatureDictNormalizers.PLAYER_POSITION_X,
"velocity_x": player_state.velocity_x
/ constants.FeatureDictNormalizers.VELOCITY_X,
"is_dead": int(player_state.is_dead),
"vital_health": player_state.vital_health,
"guard_health": one_hot_encoder(player_state.guard_health, [0, 1, 2, 3]),
"current_action_id": self._encode_action_id(player_state.current_action_id),
"current_action_frame": player_state.current_action_frame
/ constants.FeatureDictNormalizers.CURRENT_ACTION_FRAME,
"current_action_frame_count": player_state.current_action_frame_count
/ constants.FeatureDictNormalizers.CURRENT_ACTION_FRAME_COUNT,
"current_action_remaining_frames": (
player_state.current_action_frame_count
- player_state.current_action_frame
)
/ constants.FeatureDictNormalizers.CURRENT_ACTION_REMAINING_FRAMES,
"is_action_end": int(player_state.is_action_end),
"is_always_cancelable": int(player_state.is_always_cancelable),
"current_action_hit_count": player_state.current_action_hit_count,
"current_hit_stun_frame": player_state.current_hit_stun_frame
/ constants.FeatureDictNormalizers.CURRENT_HIT_STUN_FRAME,
"is_in_hit_stun": int(player_state.is_in_hit_stun),
"sprite_shake_position": player_state.sprite_shake_position,
"max_sprite_shake_frame": player_state.max_sprite_shake_frame
/ constants.FeatureDictNormalizers.MAX_SPRITE_SHAKE_FRAME,
"is_face_right": int(player_state.is_face_right),
"current_frame_advantage": player_state.current_frame_advantage
/ constants.FeatureDictNormalizers.CURRENT_FRAME_ADVANTAGE,
# The below features leak some information about the opponent!
"would_next_forward_input_dash": int(
player_state.would_next_forward_input_dash
),
"would_next_backward_input_dash": int(
player_state.would_next_backward_input_dash
),
"special_attack_progress": min(player_state.special_attack_progress, 1.0),
}
return feature_dict
def get_last_encoding(self) -> Optional[dict[str, np.ndarray]]:
if self._last_common_state is None:
return None
return {
"common_state": self._last_common_state.reshape(-1),
"p1": np.hstack(
list(self._encoding_history["p1"][-1].values()),
dtype=np.float32,
),
"p2": np.hstack(
list(self._encoding_history["p2"][-1].values()),
dtype=np.float32,
),
}
def reset(self):
self._encoding_history = {
agent_id: collections.deque(maxlen=int(self.observation_delay))
for agent_id in ["p1", "p2"]
}
def _encode_action_id(self, action_id: int) -> np.ndarray:
"""Encodes the action id into a one-hot vector.
:param action_id: The action id to encode
:type action_id: int
:return: The encoded one-hot vector
:rtype: np.ndarray
"""
action_vector = np.zeros(len(self._action_id_values), dtype=np.float32)
# Get the index of the action id in constants.ActionID
action_index = self._action_id_values.index(action_id)
action_vector[action_index] = 1
assert action_vector.max() == 1 and action_vector.min() == 0
return action_vector
def one_hot_encoder(
value: Union[int, float, str], collection: list[Union[int, float, str]]
) -> np.ndarray:
vector = np.zeros(len(collection), dtype=np.float32)
vector[collection.index(value)] = 1
return vector
@@ -0,0 +1,40 @@
import tree # pip install dm_tree
from ray.rllib.core.rl_module import RLModule
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
from ray.rllib.policy import sample_batch
from ray.rllib.utils.spaces.space_utils import batch as batch_func
class FixedRLModule(RLModule):
def _forward_inference(self, batch, **kwargs):
return self._fixed_forward(batch, **kwargs)
def _forward_exploration(self, batch, **kwargs):
return self._fixed_forward(batch, **kwargs)
def _forward_train(self, *args, **kwargs):
raise NotImplementedError(
f"RLlib: {self.__class__.__name__} should not be trained. "
f"It is a fixed RLModule, returning a fixed action for all observations."
)
def _fixed_forward(self, batch, **kwargs):
"""Implements a fixed that always returns the same action."""
raise NotImplementedError(
"FixedRLModule: This method should be overridden by subclasses to implement a specific action."
)
class NoopFixedRLModule(FixedRLModule):
def _fixed_forward(self, batch, **kwargs):
obs_batch_size = len(tree.flatten(batch[sample_batch.SampleBatch.OBS])[0])
actions = batch_func([constants.EnvActions.NONE for _ in range(obs_batch_size)])
return {sample_batch.SampleBatch.ACTIONS: actions}
class BackFixedRLModule(FixedRLModule):
def _fixed_forward(self, batch, **kwargs):
obs_batch_size = len(tree.flatten(batch[sample_batch.SampleBatch.OBS])[0])
actions = batch_func([constants.EnvActions.BACK for _ in range(obs_batch_size)])
return {sample_batch.SampleBatch.ACTIONS: actions}
@@ -0,0 +1,284 @@
import logging
from typing import Any, Optional
import numpy as np
from gymnasium import spaces
from pettingzoo.utils.env import (
ActionType,
AgentID,
ObsType,
)
from ray.rllib.env import EnvContext
from ray.rllib.env.multi_agent_env import MultiAgentEnv
from ray.rllib.examples.envs.classes.multi_agent.footsies.encoder import FootsiesEncoder
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.footsies_binary import (
FootsiesBinary,
)
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.footsies_game import (
FootsiesGame,
)
import psutil
logger = logging.getLogger("ray.rllib")
class FootsiesEnv(MultiAgentEnv):
metadata = {"render.modes": ["human"]}
SPECIAL_CHARGE_FRAMES = 60
GUARD_BREAK_REWARD = 0.3
observation_space = spaces.Dict(
{
agent: spaces.Box(
low=-np.inf,
high=np.inf,
shape=(constants.OBSERVATION_SPACE_SIZE,),
)
for agent in ["p1", "p2"]
}
)
action_space = spaces.Dict(
{
agent: spaces.Discrete(
len(
[
constants.EnvActions.NONE,
constants.EnvActions.BACK,
constants.EnvActions.FORWARD,
constants.EnvActions.ATTACK,
constants.EnvActions.BACK_ATTACK,
constants.EnvActions.FORWARD_ATTACK,
# This is a special input that holds down
# attack for 60 frames. It's just too long of a sequence
# to easily learn by holding ATTACK for so long.
constants.EnvActions.SPECIAL_CHARGE,
]
)
)
for agent in ["p1", "p2"]
}
)
def __init__(self, config: EnvContext, port: int):
super().__init__()
if config is None:
config = {}
self.config = config
self.port = port
self.footsies_process_pid = (
None # Store PID of the running footsies process (we assume one per env)
)
self.agents: list[AgentID] = ["p1", "p2"]
self.possible_agents: list[AgentID] = self.agents.copy()
self._agent_ids: set[AgentID] = set(self.agents)
self.t: int = 0
self.max_t: int = config.get("max_t", 1000)
self.frame_skip = config.get("frame_skip", 4)
observation_delay = config.get("observation_delay", 16)
assert (
observation_delay % self.frame_skip == 0
), "observation_delay must be divisible by frame_skip"
self.encoder = FootsiesEncoder(
observation_delay=observation_delay // self.frame_skip
)
# start the game server before initializing the communication between the
# game server and the Python harness via gRPC
self._prepare_and_start_game_server()
self.game = FootsiesGame(
host=config["host"],
port=self.port,
)
self.last_game_state = None
self.special_charge_queue = {
"p1": -1,
"p2": -1,
}
@staticmethod
def _convert_to_charge_action(action: int) -> int:
if action == constants.EnvActions.BACK:
return constants.EnvActions.BACK_ATTACK
elif action == constants.EnvActions.FORWARD:
return constants.EnvActions.FORWARD_ATTACK
else:
return constants.EnvActions.ATTACK
def close(self):
"""Terminate Footsies game server process.
Run to ensure no game servers are left running.
"""
timeout = 2
try:
logger.info(
f"RLlib {self.__class__.__name__}: Terminating Footsies "
f"game server process with PID: {self.footsies_process_pid}..."
)
p = psutil.Process(self.footsies_process_pid)
p.terminate()
p.wait(timeout=timeout)
except psutil.NoSuchProcess:
logger.info(
f"RLlib {self.__class__.__name__}: Process with PID {self.footsies_process_pid} not found, "
f"it might have been already terminated."
)
except psutil.TimeoutExpired:
logger.warning(
f"RLlib {self.__class__.__name__}: Process with PID {self.footsies_process_pid} did not terminate "
f"within {timeout} seconds. "
f"Sending SIGKILL signal instead.",
)
p.kill()
p.wait(timeout=timeout)
def get_infos(self):
return {agent: {} for agent in self.agents}
def get_obs(self, game_state):
return self.encoder.encode(game_state)
def reset(
self,
*,
seed: Optional[int] = None,
options: Optional[dict] = None,
) -> tuple[dict[AgentID, ObsType], dict[AgentID, Any]]:
"""Resets the environment to the starting state
and returns the initial observations for all agents.
:return: Tuple of observations and infos for each agent.
:rtype: tuple[dict[AgentID, ObsType], dict[AgentID, Any]]
"""
self.t = 0
self.game.reset_game()
self.game.start_game()
self.encoder.reset()
self.last_game_state = self.game.get_state()
observations = self.get_obs(self.last_game_state)
return observations, {agent: {} for agent in self.agents}
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]],
]:
"""Step the environment with the provided actions for all agents.
:param actions: Dictionary mapping agent ids to their actions for this step.
:type actions: dict[AgentID, ActionType]
:return: Tuple of observations, rewards, terminates, truncateds and infos for all agents.
:rtype: tuple[ dict[AgentID, ObsType], dict[AgentID, float], dict[AgentID, bool], dict[AgentID, bool], dict[AgentID, dict[str, Any]], ]
"""
self.t += 1
for agent_id in self.agents:
empty_queue = self.special_charge_queue[agent_id] < 0
action_is_special_charge = (
actions[agent_id] == constants.EnvActions.SPECIAL_CHARGE
)
# Refill the charge queue only if we're not already in a special charge.
if action_is_special_charge and empty_queue:
self.special_charge_queue[
agent_id
] = self._build_charged_special_queue()
if self.special_charge_queue[agent_id] >= 0:
self.special_charge_queue[agent_id] -= 1
actions[agent_id] = self._convert_to_charge_action(actions[agent_id])
p1_action = self.game.action_to_bits(actions["p1"], is_player_1=True)
p2_action = self.game.action_to_bits(actions["p2"], is_player_1=False)
game_state = self.game.step_n_frames(
p1_action=p1_action, p2_action=p2_action, n_frames=self.frame_skip
)
observations = self.get_obs(game_state)
terminated = game_state.player1.is_dead or game_state.player2.is_dead
# Zero-sum game: 1 if other player is dead, -1 if you're dead:
rewards = {
"p1": int(game_state.player2.is_dead) - int(game_state.player1.is_dead),
"p2": int(game_state.player1.is_dead) - int(game_state.player2.is_dead),
}
if self.config.get("reward_guard_break", False):
p1_prev_guard_health = self.last_game_state.player1.guard_health
p2_prev_guard_health = self.last_game_state.player2.guard_health
p1_guard_health = game_state.player1.guard_health
p2_guard_health = game_state.player2.guard_health
if p2_guard_health < p2_prev_guard_health:
rewards["p1"] += self.GUARD_BREAK_REWARD
rewards["p2"] -= self.GUARD_BREAK_REWARD
if p1_guard_health < p1_prev_guard_health:
rewards["p2"] += self.GUARD_BREAK_REWARD
rewards["p1"] -= self.GUARD_BREAK_REWARD
terminateds = {
"p1": terminated,
"p2": terminated,
"__all__": terminated,
}
truncated = self.t >= self.max_t
truncateds = {
"p1": truncated,
"p2": truncated,
"__all__": truncated,
}
self.last_game_state = game_state
return observations, rewards, terminateds, truncateds, self.get_infos()
def _build_charged_special_queue(self):
assert self.SPECIAL_CHARGE_FRAMES % self.frame_skip == 0
steps_to_apply_attack = int(self.SPECIAL_CHARGE_FRAMES // self.frame_skip)
return steps_to_apply_attack
def _prepare_and_start_game_server(self):
fb = FootsiesBinary(config=self.config, port=self.port)
self.footsies_process_pid = fb.start_game_server()
def env_creator(env_config: EnvContext) -> FootsiesEnv:
"""Creates the Footsies environment
Ensure that each game server runs on a unique port. Training and evaluation env runners have separate port ranges.
Helper function to create the FootsiesEnv with a unique port based on the worker index and vector index.
It's usually passed to the `register_env()`, like this: register_env(name="FootsiesEnv", env_creator=env_creator).
"""
if env_config.get("env-for-evaluation", False):
port = (
env_config["eval_start_port"]
- 1 # "-1" to start with eval_start_port as the first port (eval worker index starts at 1)
+ int(env_config.worker_index) * env_config.get("num_envs_per_worker", 1)
+ env_config.get("vector_index", 0)
)
else:
port = (
env_config["train_start_port"]
+ int(env_config.worker_index) * env_config.get("num_envs_per_worker", 1)
+ env_config.get("vector_index", 0)
)
return FootsiesEnv(config=env_config, port=port)
@@ -0,0 +1,151 @@
from dataclasses import dataclass
OBSERVATION_SPACE_SIZE: int = 81
@dataclass
class EnvActions:
NONE = 0
BACK = 1
FORWARD = 2
ATTACK = 3
BACK_ATTACK = 4
FORWARD_ATTACK = 5
SPECIAL_CHARGE = 6
@dataclass
class GameActions:
NONE = 0
LEFT = 1
RIGHT = 2
ATTACK = 3
LEFT_ATTACK = 4
RIGHT_ATTACK = 5
@dataclass
class ActionBits:
NONE: int = 0
LEFT: int = 1 << 0
RIGHT: int = 1 << 1
ATTACK: int = 1 << 2
LEFT_ATTACK: int = LEFT | ATTACK
RIGHT_ATTACK: int = RIGHT | ATTACK
@dataclass
class ActionID:
STAND = 0
FORWARD = 1
BACKWARD = 2
DASH_FORWARD = 10
DASH_BACKWARD = 11
N_ATTACK = 100
B_ATTACK = 105
N_SPECIAL = 110
B_SPECIAL = 115
DAMAGE = 200
GUARD_M = 301
GUARD_STAND = 305
GUARD_CROUCH = 306
GUARD_BREAK = 310
GUARD_PROXIMITY = 350
DEAD = 500
WIN = 510
@dataclass
class FeatureDictNormalizers:
PLAYER_POSITION_X = 4.0
VELOCITY_X = 5.0
CURRENT_ACTION_FRAME = 25
CURRENT_ACTION_FRAME_COUNT = 25
CURRENT_ACTION_REMAINING_FRAMES = 25
CURRENT_HIT_STUN_FRAME = 10
MAX_SPRITE_SHAKE_FRAME = 10
CURRENT_FRAME_ADVANTAGE = 10
ACTION_TO_BITS = {
GameActions.NONE: ActionBits.NONE,
GameActions.LEFT: ActionBits.LEFT,
GameActions.RIGHT: ActionBits.RIGHT,
GameActions.ATTACK: ActionBits.ATTACK,
GameActions.LEFT_ATTACK: ActionBits.LEFT_ATTACK,
GameActions.RIGHT_ATTACK: ActionBits.RIGHT_ATTACK,
}
FOOTSIES_ACTION_IDS = {
"STAND": ActionID.STAND,
"FORWARD": ActionID.FORWARD,
"BACKWARD": ActionID.BACKWARD,
"DASH_FORWARD": ActionID.DASH_FORWARD,
"DASH_BACKWARD": ActionID.DASH_BACKWARD,
"N_ATTACK": ActionID.N_ATTACK,
"B_ATTACK": ActionID.B_ATTACK,
"N_SPECIAL": ActionID.N_SPECIAL,
"B_SPECIAL": ActionID.B_SPECIAL,
"DAMAGE": ActionID.DAMAGE,
"GUARD_M": ActionID.GUARD_M,
"GUARD_STAND": ActionID.GUARD_STAND,
"GUARD_CROUCH": ActionID.GUARD_CROUCH,
"GUARD_BREAK": ActionID.GUARD_BREAK,
"GUARD_PROXIMITY": ActionID.GUARD_PROXIMITY,
"DEAD": ActionID.DEAD,
"WIN": ActionID.WIN,
}
# backup file location (uploaded July 29th, 2025):
# https://ray-example-data.s3.us-west-2.amazonaws.com/rllib/env-footsies/feature_indices.json
# Dictionary mapping feature names to their index ranges within a flat observation vector.
# Each key is a feature name, and its value is a dictionary with keys:
# "start": the starting index in the observation array.
# "length": it's length in bytes
feature_indices = {
"common_state": {"start": 0, "length": 1},
"frame_count": {"start": 1, "length": 1},
"player_position_x": {"start": 2, "length": 1},
"velocity_x": {"start": 3, "length": 1},
"is_dead": {"start": 4, "length": 1},
"vital_health": {"start": 5, "length": 1},
"guard_health": {"start": 6, "length": 4},
"current_action_id": {"start": 10, "length": 17},
"current_action_frame": {"start": 27, "length": 1},
"current_action_frame_count": {"start": 28, "length": 1},
"current_action_remaining_frames": {"start": 29, "length": 1},
"is_action_end": {"start": 30, "length": 1},
"is_always_cancelable": {"start": 31, "length": 1},
"current_action_hit_count": {"start": 32, "length": 1},
"current_hit_stun_frame": {"start": 33, "length": 1},
"is_in_hit_stun": {"start": 34, "length": 1},
"sprite_shake_position": {"start": 35, "length": 1},
"max_sprite_shake_frame": {"start": 36, "length": 1},
"is_face_right": {"start": 37, "length": 1},
"current_frame_advantage": {"start": 38, "length": 1},
"would_next_forward_input_dash": {"start": 39, "length": 1},
"would_next_backward_input_dash": {"start": 40, "length": 1},
"special_attack_progress": {"start": 41, "length": 1},
"opponent_frame_count": {"start": 42, "length": 1},
"opponent_player_position_x": {"start": 43, "length": 1},
"opponent_velocity_x": {"start": 44, "length": 1},
"opponent_is_dead": {"start": 45, "length": 1},
"opponent_vital_health": {"start": 46, "length": 1},
"opponent_guard_health": {"start": 47, "length": 4},
"opponent_current_action_id": {"start": 51, "length": 17},
"opponent_current_action_frame": {"start": 68, "length": 1},
"opponent_current_action_frame_count": {"start": 69, "length": 1},
"opponent_current_action_remaining_frames": {"start": 70, "length": 1},
"opponent_is_action_end": {"start": 71, "length": 1},
"opponent_is_always_cancelable": {"start": 72, "length": 1},
"opponent_current_action_hit_count": {"start": 73, "length": 1},
"opponent_current_hit_stun_frame": {"start": 74, "length": 1},
"opponent_is_in_hit_stun": {"start": 75, "length": 1},
"opponent_sprite_shake_position": {"start": 76, "length": 1},
"opponent_max_sprite_shake_frame": {"start": 77, "length": 1},
"opponent_is_face_right": {"start": 78, "length": 1},
"opponent_current_frame_advantage": {"start": 79, "length": 1},
"opponent_would_next_forward_input_dash": {"start": 80, "length": 1},
"opponent_would_next_backward_input_dash": {"start": 81, "length": 1},
"opponent_special_attack_progress": {"start": 82, "length": 1},
}
@@ -0,0 +1,227 @@
import logging
import os
import stat
import subprocess
import time
import zipfile
from dataclasses import dataclass
from pathlib import Path
import grpc
import requests
from filelock import FileLock
from ray.rllib.env import EnvContext
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto import (
footsies_service_pb2 as footsies_pb2,
footsies_service_pb2_grpc as footsies_pb2_grpc,
)
from ray.util import log_once
logger = logging.getLogger(__name__)
@dataclass
class BinaryUrls:
# Uploaded 07.28.2025
S3_ROOT = "https://ray-example-data.s3.us-west-2.amazonaws.com/rllib/env-footsies/binaries/"
# Zip file names
ZIP_LINUX_SERVER = "footsies_linux_server_021725.zip"
ZIP_LINUX_WINDOWED = "footsies_linux_windowed_021725.zip"
ZIP_MAC_HEADLESS = "footsies_mac_headless_5709b6d.zip"
ZIP_MAC_WINDOWED = "footsies_mac_windowed_5709b6d.zip"
# Full URLs
URL_LINUX_SERVER_BINARIES = S3_ROOT + ZIP_LINUX_SERVER
URL_LINUX_WINDOWED_BINARIES = S3_ROOT + ZIP_LINUX_WINDOWED
URL_MAC_HEADLESS_BINARIES = S3_ROOT + ZIP_MAC_HEADLESS
URL_MAC_WINDOWED_BINARIES = S3_ROOT + ZIP_MAC_WINDOWED
class FootsiesBinary:
def __init__(self, config: EnvContext, port: int):
self._urls = BinaryUrls()
self.config = config
self.port = port
self.binary_to_download = config["binary_to_download"]
if self.binary_to_download == "linux_server":
self.url = self._urls.URL_LINUX_SERVER_BINARIES
elif self.binary_to_download == "linux_windowed":
self.url = self._urls.URL_LINUX_WINDOWED_BINARIES
elif self.binary_to_download == "mac_headless":
self.url = self._urls.URL_MAC_HEADLESS_BINARIES
elif self.binary_to_download == "mac_windowed":
self.url = self._urls.URL_MAC_WINDOWED_BINARIES
else:
raise ValueError(f"Invalid target binary: {self.binary_to_download}")
self.full_download_dir = Path(config["binary_download_dir"]).resolve()
self.full_download_path = (
self.full_download_dir / str.split(self.url, sep="/")[-1]
)
self.full_extract_dir = Path(config["binary_extract_dir"]).resolve()
self.renamed_path = self.full_extract_dir / "footsies_binaries"
@staticmethod
def _add_executable_permission(binary_path: Path) -> None:
binary_path.chmod(binary_path.stat().st_mode | stat.S_IXUSR)
def start_game_server(self) -> int:
"""Downloads, unzips, and starts the Footsies game server binary.
Returns footsies process PID.
"""
self._download_game_binary()
self._unzip_game_binary()
if self.binary_to_download == "mac_windowed":
game_binary_path = (
Path(self.renamed_path) / "Contents" / "MacOS" / "FOOTSIES"
)
elif self.binary_to_download == "mac_headless":
game_binary_path = Path(self.renamed_path) / "FOOTSIES"
else:
game_binary_path = Path(self.renamed_path) / "footsies.x86_64"
if os.access(game_binary_path, os.X_OK):
logger.info(
f"Game binary has an 'executable' permission: {game_binary_path}"
)
else:
self._add_executable_permission(game_binary_path)
logger.info(f"Game binary path: {game_binary_path}")
# The underlying game can be quite spammy. So when we are not debugging it, we can suppress the output.
log_output = self.config.get("log_unity_output")
if log_output is None:
if log_once("log_unity_output_not_set"):
logger.warning(
"`log_unity_output` not set in environment config, not logging output by default"
)
log_output = False
if not log_output:
stdout_dest = stderr_dest = subprocess.DEVNULL
else:
stdout_dest = stderr_dest = None # Use parent's stdout/stderr
if (
self.binary_to_download == "linux_server"
or self.binary_to_download == "linux_windowed"
):
process = subprocess.Popen(
[game_binary_path, "--port", str(self.port)],
stdout=stdout_dest,
stderr=stderr_dest,
)
else:
process = subprocess.Popen(
[
"arch",
"-x86_64",
game_binary_path,
"--port",
str(self.port),
],
stdout=stdout_dest,
stderr=stderr_dest,
)
# check if the game server is running correctly
timeout = 2
channel = grpc.insecure_channel(f"localhost:{self.port}")
stub = footsies_pb2_grpc.FootsiesGameServiceStub(channel)
# step 1: try to start the game
while True:
try:
stub.StartGame(footsies_pb2.Empty())
logger.info("Game ready!")
break
except grpc.RpcError as e:
code = e.code()
if code in (
grpc.StatusCode.UNAVAILABLE,
grpc.StatusCode.DEADLINE_EXCEEDED,
):
logger.info(f"RLlib {self.__class__.__name__}: Game not ready...")
time.sleep(timeout)
continue
raise
# step 2: check if the game is ready
ready = False
while not ready:
try:
ready = stub.IsReady(footsies_pb2.Empty()).value
if not ready:
logger.info(f"RLlib {self.__class__.__name__}: Game not ready...")
time.sleep(timeout)
continue
else:
logger.info("Game ready!")
break
except grpc.RpcError as e:
if e.code() in (
grpc.StatusCode.UNAVAILABLE,
grpc.StatusCode.DEADLINE_EXCEEDED,
):
time.sleep(timeout)
logger.info(f"RLlib {self.__class__.__name__}: Game not ready...")
continue
raise
channel.close()
return process.pid
def _download_game_binary(self):
# As multiple actors might try to download all at the same time.
# The file lock should force only one actor to download
chunk_size = 1024 * 1024 # 1MB
lock_path = self.full_download_path.parent / ".footsies-download.lock"
with FileLock(lock_path, timeout=300):
if self.full_download_path.exists():
logger.info(
f"Game binary already exists at {self.full_download_path}, skipping download."
)
else:
try:
with requests.get(self.url, stream=True) as response:
response.raise_for_status()
self.full_download_dir.mkdir(parents=True, exist_ok=True)
with open(self.full_download_path, "wb") as f:
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
f.write(chunk)
logger.info(
f"Downloaded game binary to {self.full_download_path}\n"
f"Binary size: {self.full_download_path.stat().st_size / 1024 / 1024:.1f} MB\n"
)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download binary from {self.url}: {e}")
def _unzip_game_binary(self):
# As multiple actors might try to unzip or rename the paths at the same time.
# The file lock should force this function to be sequential
lock_path = self.full_download_path.parent / ".footsies-unzip.lock"
with FileLock(lock_path, timeout=300):
if self.renamed_path.exists():
logger.info(
f"Game binary already extracted at {self.renamed_path}, skipping extraction."
)
else:
self.full_extract_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(self.full_download_path, mode="r") as zip_ref:
zip_ref.extractall(self.full_extract_dir)
if self.binary_to_download == "mac_windowed":
self.full_download_path.with_suffix(".app").rename(
self.renamed_path
)
else:
self.full_download_path.with_suffix("").rename(self.renamed_path)
logger.info(f"Extracted game binary to {self.renamed_path}")
@@ -0,0 +1,121 @@
import logging
import time
import grpc
import numpy as np
import ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto.footsies_service_pb2 as footsies_pb2
import ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto.footsies_service_pb2_grpc as footsies_pb2_grpc
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
logger = logging.getLogger(__name__)
class FootsiesGame:
"""Handles gRPC communication with game the server.
This class establishes communication between the
game server and the Python harness via gRPC. It provides methods
to start the game, reset it, get the current state, and step the
game by a certain number of frames.
"""
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self.stub = self._initialize_stub()
@staticmethod
def action_to_bits(action: int, is_player_1: bool) -> int:
"""Converts an action to its corresponding bit representation."""
if isinstance(action, np.ndarray):
action = action.item()
if is_player_1:
if action == constants.EnvActions.BACK:
action = constants.GameActions.LEFT
elif action == constants.EnvActions.FORWARD:
action = constants.GameActions.RIGHT
elif action == constants.EnvActions.BACK_ATTACK:
action = constants.GameActions.LEFT_ATTACK
elif action == constants.EnvActions.FORWARD_ATTACK:
action = constants.GameActions.RIGHT_ATTACK
else:
if action == constants.EnvActions.BACK:
action = constants.GameActions.RIGHT
elif action == constants.EnvActions.FORWARD:
action = constants.GameActions.LEFT
elif action == constants.EnvActions.BACK_ATTACK:
action = constants.GameActions.RIGHT_ATTACK
elif action == constants.EnvActions.FORWARD_ATTACK:
action = constants.GameActions.LEFT_ATTACK
return constants.ACTION_TO_BITS[action]
def get_encoded_state(self) -> footsies_pb2.EncodedGameState:
"""Gets the current encoded game state by calling the GetEncodedState RPC."""
try:
return self.stub.GetEncodedState(footsies_pb2.Empty())
except Exception as e:
logger.error(f"Error calling GetEncodedState with exception: {e}")
raise e
def get_state(self) -> footsies_pb2.GameState:
"""Gets the current game state by calling the GetState RPC."""
try:
return self.stub.GetState(footsies_pb2.Empty())
except Exception as e:
logger.error(f"Error calling GetState with exception: {e}")
raise e
def is_ready(self) -> bool:
"""Checks if the game is ready by calling the IsReady RPC."""
try:
return self.stub.IsReady(footsies_pb2.Empty()).value
except Exception as e:
logger.error(f"Error calling IsReady with exception: {e}")
raise e
def reset_game(self) -> None:
"""Resets the game by calling the ResetGame RPC."""
try:
self.stub.ResetGame(footsies_pb2.Empty())
except Exception as e:
logger.error(f"Error calling ResetGame with exception: {e}")
raise e
def start_game(self) -> None:
"""Starts the game by calling the StartGame RPC."""
try:
self.stub.StartGame(footsies_pb2.Empty())
while not self.is_ready():
logger.info("Game not ready...")
time.sleep(0.5)
logger.info("StartGame called successfully")
except Exception as e:
logger.error(f"Error calling StartGame with exception: {e}")
raise e
def step_n_frames(
self, p1_action: int, p2_action: int, n_frames: int
) -> footsies_pb2.GameState:
"""Steps the game by n_frames with the given player actions. The provided actions will be repeated for all n_frames."""
try:
step_input = footsies_pb2.StepInput(
p1_action=p1_action, p2_action=p2_action, nFrames=n_frames
)
return self.stub.StepNFrames(step_input)
except Exception as e:
logger.error(f"Error calling StepNFrames with exception: {e}")
raise e
def _initialize_stub(self) -> footsies_pb2_grpc.FootsiesGameServiceStub:
try:
channel = grpc.insecure_channel(f"{self.host}:{self.port}")
return footsies_pb2_grpc.FootsiesGameServiceStub(channel)
except grpc.RpcError as e:
logger.error(f"Error connecting to gRPC stub with exception: {e}")
raise e
@@ -0,0 +1,63 @@
syntax = "proto3";
service FootsiesGameService {
rpc StartGame(Empty) returns (Empty) {}
rpc ResetGame(Empty) returns (Empty) {}
rpc StepNFrames(StepInput) returns (GameState) {}
rpc GetState(Empty) returns (GameState) {}
rpc GetEncodedState(Empty) returns (EncodedGameState) {}
rpc IsReady(Empty) returns (BoolValue) {}
}
message StepInput {
int64 p1_action = 1;
int64 p2_action = 2;
int64 nFrames = 3;
}
message PlayerState {
float player_position_x = 1;
bool is_dead = 2;
int64 vital_health = 3;
int64 guard_health = 4;
int64 current_action_id = 5;
int64 current_action_frame = 6;
int64 current_action_frame_count = 7;
bool is_action_end = 8;
bool is_always_cancelable = 9;
int64 current_action_hit_count = 10;
int64 current_hit_stun_frame = 11;
bool is_in_hit_stun = 12;
int64 sprite_shake_position = 13;
int64 max_sprite_shake_frame = 14;
float velocity_x = 15;
bool is_face_right = 16;
repeated int64 input_buffer = 17;
int64 current_frame_advantage = 18;
bool would_next_forward_input_dash = 19;
bool would_next_backward_input_dash = 20;
float special_attack_progress = 21;
}
message GameState {
PlayerState player1 = 1;
PlayerState player2 = 2;
int64 round_state = 3;
int64 frame_count = 4;
}
message EncodedGameState {
repeated float player1_encoding = 1;
repeated float player2_encoding = 2;
}
message BoolValue {
bool value = 1;
}
message Empty {}
@@ -0,0 +1,307 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto.footsies_service_pb2 as footsies__service__pb2
# import footsies_service_pb2 as footsies__service__pb2
class FootsiesGameServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.StartGame = channel.unary_unary(
"/FootsiesGameService/StartGame",
request_serializer=footsies__service__pb2.Empty.SerializeToString,
response_deserializer=footsies__service__pb2.Empty.FromString,
)
self.ResetGame = channel.unary_unary(
"/FootsiesGameService/ResetGame",
request_serializer=footsies__service__pb2.Empty.SerializeToString,
response_deserializer=footsies__service__pb2.Empty.FromString,
)
self.StepNFrames = channel.unary_unary(
"/FootsiesGameService/StepNFrames",
request_serializer=footsies__service__pb2.StepInput.SerializeToString,
response_deserializer=footsies__service__pb2.GameState.FromString,
)
self.GetState = channel.unary_unary(
"/FootsiesGameService/GetState",
request_serializer=footsies__service__pb2.Empty.SerializeToString,
response_deserializer=footsies__service__pb2.GameState.FromString,
)
self.GetEncodedState = channel.unary_unary(
"/FootsiesGameService/GetEncodedState",
request_serializer=footsies__service__pb2.Empty.SerializeToString,
response_deserializer=footsies__service__pb2.EncodedGameState.FromString,
)
self.IsReady = channel.unary_unary(
"/FootsiesGameService/IsReady",
request_serializer=footsies__service__pb2.Empty.SerializeToString,
response_deserializer=footsies__service__pb2.BoolValue.FromString,
)
class FootsiesGameServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def StartGame(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ResetGame(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def StepNFrames(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetState(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetEncodedState(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def IsReady(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def add_FootsiesGameServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
"StartGame": grpc.unary_unary_rpc_method_handler(
servicer.StartGame,
request_deserializer=footsies__service__pb2.Empty.FromString,
response_serializer=footsies__service__pb2.Empty.SerializeToString,
),
"ResetGame": grpc.unary_unary_rpc_method_handler(
servicer.ResetGame,
request_deserializer=footsies__service__pb2.Empty.FromString,
response_serializer=footsies__service__pb2.Empty.SerializeToString,
),
"StepNFrames": grpc.unary_unary_rpc_method_handler(
servicer.StepNFrames,
request_deserializer=footsies__service__pb2.StepInput.FromString,
response_serializer=footsies__service__pb2.GameState.SerializeToString,
),
"GetState": grpc.unary_unary_rpc_method_handler(
servicer.GetState,
request_deserializer=footsies__service__pb2.Empty.FromString,
response_serializer=footsies__service__pb2.GameState.SerializeToString,
),
"GetEncodedState": grpc.unary_unary_rpc_method_handler(
servicer.GetEncodedState,
request_deserializer=footsies__service__pb2.Empty.FromString,
response_serializer=footsies__service__pb2.EncodedGameState.SerializeToString,
),
"IsReady": grpc.unary_unary_rpc_method_handler(
servicer.IsReady,
request_deserializer=footsies__service__pb2.Empty.FromString,
response_serializer=footsies__service__pb2.BoolValue.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
"FootsiesGameService", rpc_method_handlers
)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class FootsiesGameService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def StartGame(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/FootsiesGameService/StartGame",
footsies__service__pb2.Empty.SerializeToString,
footsies__service__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def ResetGame(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/FootsiesGameService/ResetGame",
footsies__service__pb2.Empty.SerializeToString,
footsies__service__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def StepNFrames(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/FootsiesGameService/StepNFrames",
footsies__service__pb2.StepInput.SerializeToString,
footsies__service__pb2.GameState.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def GetState(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/FootsiesGameService/GetState",
footsies__service__pb2.Empty.SerializeToString,
footsies__service__pb2.GameState.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def GetEncodedState(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/FootsiesGameService/GetEncodedState",
footsies__service__pb2.Empty.SerializeToString,
footsies__service__pb2.EncodedGameState.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def IsReady(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/FootsiesGameService/IsReady",
footsies__service__pb2.Empty.SerializeToString,
footsies__service__pb2.BoolValue.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@@ -0,0 +1,135 @@
import os
import platform
import tempfile
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from ray.rllib.env import EnvContext
from ray.rllib.examples.envs.classes.multi_agent.footsies.footsies_env import (
env_creator,
)
# Detect platform and choose appropriate binary
if platform.system() == "Darwin":
binary_to_download = "mac_headless"
elif platform.system() == "Linux":
binary_to_download = "linux_server"
else:
raise RuntimeError(f"Unsupported platform: {platform.system()}")
FOOTSIES_ENV_BASE_CONFIG = {
"max_t": 1000,
"frame_skip": 4,
"observation_delay": 16,
"host": "localhost",
"binary_download_dir": "/tmp/ray/binaries/footsies",
"binary_extract_dir": "/tmp/ray/binaries/footsies",
"binary_to_download": binary_to_download,
}
_port_counter = 45001
def _create_env(config_overrides):
global _port_counter
config = {
**FOOTSIES_ENV_BASE_CONFIG,
"train_start_port": _port_counter,
"eval_start_port": _port_counter + 1,
**config_overrides,
}
_port_counter += 2
return env_creator(EnvContext(config, worker_index=0))
@contextmanager
def capture_stdout_stderr():
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp:
log_path = tmp.name
# Use file descriptors 1 and 2 directly (stdout and stderr)
# This bypasses pytest's wrapping of sys.stdout/stderr
stdout_fd = 1
stderr_fd = 2
# Save original file descriptors
saved_stdout = os.dup(stdout_fd)
saved_stderr = os.dup(stderr_fd)
log_file = None
try:
# Open log file with line buffering
log_file = open(log_path, "w", buffering=1)
os.dup2(log_file.fileno(), stdout_fd)
os.dup2(log_file.fileno(), stderr_fd)
yield log_path
finally:
# Flush the log file
if log_file:
log_file.flush()
# Restore original file descriptors
os.dup2(saved_stdout, stdout_fd)
os.dup2(saved_stderr, stderr_fd)
# Close file and saved descriptors
if log_file:
log_file.close()
os.close(saved_stdout)
os.close(saved_stderr)
class TestFootsies(unittest.TestCase):
def test_default_supress_output_mode(self):
with capture_stdout_stderr() as log_path:
env = _create_env({})
time.sleep(2) # Give Unity time to write output
env.close()
# Give a bit more time for any buffered output to be written
time.sleep(0.5)
# Read the captured output
with open(log_path, "r") as f:
captured_output = f.read()
assert (
"`log_unity_output` not set in environment config, not logging output by default"
in captured_output
)
assert "[UnityMemory]" not in captured_output
# Clean up
if Path(log_path).exists():
os.unlink(log_path)
def test_enable_output_mode(self):
with capture_stdout_stderr() as log_path:
env = _create_env({"log_unity_output": True})
time.sleep(2) # Give Unity time to write output
env.close()
# Give a bit more time for any buffered output to be written
time.sleep(0.5)
# Read the captured output
with open(log_path, "r") as f:
captured_output = f.read()
assert "[UnityMemory]" in captured_output
# Clean up
if Path(log_path).exists():
os.unlink(log_path)
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,383 @@
import collections
import logging
import platform
from dataclasses import dataclass
from typing import Dict, Optional
import gymnasium as gym
import numpy as np
from ray.rllib.algorithms.algorithm import Algorithm
from ray.rllib.algorithms.callbacks import RLlibCallback
from ray.rllib.core.rl_module import RLModuleSpec
from ray.rllib.env.env_runner import EnvRunner
from ray.rllib.env.multi_agent_episode import MultiAgentEpisode
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.constants import (
FOOTSIES_ACTION_IDS,
)
from ray.rllib.utils.metrics import ENV_RUNNER_RESULTS
from ray.rllib.utils.metrics.metrics_logger import MetricsLogger
from ray.rllib.utils.typing import EpisodeType
logger = logging.getLogger("ray.rllib")
@dataclass
class Matchup:
p1: str
p2: str
prob: float
class Matchmaker:
def __init__(self, matchups: list[Matchup]):
self.matchups = matchups
self.probs = [matchup.prob for matchup in matchups]
self.current_matchups = collections.defaultdict(dict)
def agent_to_module_mapping_fn(
self, agent_id: str, episode: EpisodeType, **kwargs
) -> str:
"""Mapping function that retrieves policy_id from the sampled matchup"""
id_ = episode.id_
if self.current_matchups.get(id_) is None:
# step 1: sample a matchup according to the specified probabilities
sampled_matchup = np.random.choice(a=self.matchups, p=self.probs)
# step 2: Randomize who is player 1 and player 2
policies = [sampled_matchup.p1, sampled_matchup.p2]
p1, p2 = np.random.choice(policies, size=2, replace=False)
# step 3: Set as the current matchup for the episode in question (id_)
self.current_matchups[id_]["p1"] = p1
self.current_matchups[id_]["p2"] = p2
policy_id = self.current_matchups[id_].pop(agent_id)
# remove (an empty dict) for the current episode with id_
if not self.current_matchups[id_]:
del self.current_matchups[id_]
return policy_id
class MetricsLoggerCallback(RLlibCallback):
def __init__(self, main_policy: str) -> None:
"""Log experiment metrics
Logs metrics after each episode step and at the end of each (train or eval) episode.
Metrics logged at the end of each episode will be later used by MixManagerCallback
to decide whether to add a new opponent to the mix.
"""
super().__init__()
self.main_policy = main_policy
self.action_id_to_str = {
action_id: action_str
for action_str, action_id in FOOTSIES_ACTION_IDS.items()
}
def on_episode_step(
self,
*,
episode: MultiAgentEpisode,
env_runner: Optional[EnvRunner] = None,
metrics_logger: Optional[MetricsLogger] = None,
env: Optional[gym.Env] = None,
env_index: int,
**kwargs,
) -> None:
"""Log action usage frequency
Log actions performed by both players at each step of the (training or evaluation) episode.
"""
stage = "eval" if env_runner.config.in_evaluation else "train"
# get the ModuleID for each agent
p1_module = episode.module_for("p1")
p2_module = episode.module_for("p2")
# get action string for each agent
p1_action_id = env.envs[
env_index
].unwrapped.last_game_state.player1.current_action_id
p2_action_id = env.envs[
env_index
].unwrapped.last_game_state.player2.current_action_id
p1_action_str = self.action_id_to_str[p1_action_id]
p2_action_str = self.action_id_to_str[p2_action_id]
metrics_logger.log_value(
key=f"footsies/{stage}/actions/{p1_module}/{p1_action_str}",
value=1,
reduce="sum",
window=100,
)
metrics_logger.log_value(
key=f"footsies/{stage}/actions/{p2_module}/{p2_action_str}",
value=1,
reduce="sum",
window=100,
)
def on_episode_end(
self,
*,
episode: MultiAgentEpisode,
env_runner: Optional[EnvRunner] = None,
metrics_logger: Optional[MetricsLogger] = None,
env: Optional[gym.Env] = None,
env_index: int,
**kwargs,
) -> None:
"""Log win rates
Log win rates of the main policy against its opponent at the end of the (training or evaluation) episode.
"""
stage = "eval" if env_runner.config.in_evaluation else "train"
# check status of "p1" and "p2"
last_game_state = env.envs[env_index].unwrapped.last_game_state
p1_dead = last_game_state.player1.is_dead
p2_dead = last_game_state.player2.is_dead
# get the ModuleID for each agent
p1_module = episode.module_for("p1")
p2_module = episode.module_for("p2")
if self.main_policy == p1_module:
opponent_id = p2_module
main_policy_win = p2_dead
elif self.main_policy == p2_module:
opponent_id = p1_module
main_policy_win = p1_dead
else:
logger.info(
f"RLlib {self.__class__.__name__}: Main policy: '{self.main_policy}' not found in this episode. "
f"Policies in this episode are: '{p1_module}' and '{p2_module}'. "
f"Check your multi_agent 'policy_mapping_fn'. "
f"Metrics logging for this episode will be skipped."
)
return
if p1_dead and p2_dead:
metrics_logger.log_value(
key=f"footsies/{stage}/both_dead/{self.main_policy}/vs_{opponent_id}",
value=1,
reduce="mean",
window=100,
)
elif not p1_dead and not p2_dead:
metrics_logger.log_value(
key=f"footsies/{stage}/both_alive/{self.main_policy}/vs_{opponent_id}",
value=1,
reduce="mean",
window=100,
)
else:
# log the win rate against the opponent with an 'opponent_id'
metrics_logger.log_value(
key=f"footsies/{stage}/win_rates/{self.main_policy}/vs_{opponent_id}",
value=int(main_policy_win),
reduce="mean",
window=100,
)
# log the win rate, without specifying the opponent
# this metric collected from the eval env runner
# will be used to decide whether to add
# a new opponent at the current level.
metrics_logger.log_value(
key=f"footsies/{stage}/win_rates/{self.main_policy}/vs_any",
value=int(main_policy_win),
reduce="mean",
window=100,
)
class MixManagerCallback(RLlibCallback):
def __init__(
self,
win_rate_threshold: float,
main_policy: str,
target_mix_size: int,
starting_modules=list[str], # default is ["lstm", "noop"]
fixed_modules_progression_sequence=tuple[str], # default is ("noop", "back")
) -> None:
"""Track win rates and manage mix of opponents"""
super().__init__()
self.win_rate_threshold = win_rate_threshold
self.main_policy = main_policy
self.target_mix_size = target_mix_size
self.fixed_modules_progression_sequence = tuple(
fixed_modules_progression_sequence
) # Order of RL modules to be added to the mix
self.modules_in_mix = list(
starting_modules
) # RLModules that are currently in the mix
self._trained_policy_idx = (
0 # We will use this to create new opponents of the main policy
)
def on_evaluate_end(
self,
*,
algorithm: Algorithm,
metrics_logger: Optional[MetricsLogger] = None,
evaluation_metrics: dict,
**kwargs,
) -> None:
"""Check win rates and add new opponent if necessary.
Check the win rate of the main policy against its current opponent.
If the win rate exceeds the specified threshold, add a new opponent to the mix, by modifying:
1. update the policy_mapping_fn for (training and evaluation) env runners
2. if the new policy is a trained one (not a fixed RL module), modify Algorithm's state (initialize the state of the newly added RLModule by using the main policy)
"""
_main_module = algorithm.get_module(self.main_policy)
new_module_id = None
new_module_spec = None
win_rate = evaluation_metrics[ENV_RUNNER_RESULTS][
f"footsies/eval/win_rates/{self.main_policy}/vs_any"
]
if win_rate > self.win_rate_threshold:
logger.info(
f"RLlib {self.__class__.__name__}: Win rate for main policy '{self.main_policy}' "
f"exceeded threshold ({win_rate} > {self.win_rate_threshold})."
f" Adding new RL Module to the mix..."
)
# check if fixed RL module should be added to the mix,
# and if so, create new_module_id and new_module_spec for it
for module_id in self.fixed_modules_progression_sequence:
if module_id not in self.modules_in_mix:
new_module_id = module_id
break
# in case that all fixed RL Modules are already in the mix (together with the main policy),
# we will add a new RL Module by taking main policy and adding an instance of it to the mix
if new_module_id is None:
new_module_id = f"{self.main_policy}_v{self._trained_policy_idx}"
new_module_spec = RLModuleSpec.from_module(_main_module)
self._trained_policy_idx += 1
# create new policy mapping function, to ensure that the main policy plays against newly added policy
new_mapping_fn = Matchmaker(
[
Matchup(
p1=self.main_policy,
p2=new_module_id,
prob=1.0,
)
]
).agent_to_module_mapping_fn
# STEP 1: Add the new module first (if it's a trained module)
if new_module_id not in self.fixed_modules_progression_sequence:
# Add module to Learners and EnvRunners (but don't update mapping yet)
algorithm.add_module(
module_id=new_module_id,
module_spec=new_module_spec,
new_agent_to_module_mapping_fn=None, # Don't update mapping yet!
)
# Initialize the new module with main policy's weights
algorithm.set_state(
{
"learner_group": {
"learner": {
"rl_module": {
new_module_id: _main_module.get_state(),
}
}
},
}
)
# STEP 2: CRITICAL - Update aggregator actors with the new module
# Aggregators run the learner connector pipeline which needs all modules.
if (
hasattr(algorithm, "_aggregator_actor_manager")
and algorithm._aggregator_actor_manager
):
logger.info(
f"RLlib {self.__class__.__name__}: Updating aggregator actors "
f"with new module '{new_module_id}'..."
)
# Add the new module to each aggregator actor's MultiRLModule
algorithm._aggregator_actor_manager.foreach_actor(
func=lambda actor, mid=new_module_id, spec=new_module_spec: (
actor._module.add_module(
module_id=mid,
module=spec.build(),
)
)
)
# Sync weights from learner to aggregator actors
weights = algorithm.learner_group.get_weights(
module_ids=[new_module_id]
)
algorithm._aggregator_actor_manager.foreach_actor(
func=lambda actor, w=weights: actor._module.set_state(w)
)
logger.info(
f"RLlib {self.__class__.__name__}: Aggregator actors updated successfully."
)
# STEP 3: NOW update the policy mapping function on all EnvRunners
# At this point, the module exists everywhere (Learners, EnvRunners, Aggregators)
algorithm.env_runner_group.foreach_env_runner(
lambda er: er.config.multi_agent(policy_mapping_fn=new_mapping_fn),
local_env_runner=True,
)
algorithm.eval_env_runner_group.foreach_env_runner(
lambda er: er.config.multi_agent(policy_mapping_fn=new_mapping_fn),
local_env_runner=True,
)
# Update algorithm's config to maintain consistency
algorithm.config._is_frozen = False
algorithm.config.multi_agent(policy_mapping_fn=new_mapping_fn)
algorithm.config.freeze()
# Update the current mix list
self.modules_in_mix.append(new_module_id)
else:
logger.info(
f"RLlib {self.__class__.__name__}: Win rate for main policy '{self.main_policy}' "
f"did not exceed threshold ({win_rate} <= {self.win_rate_threshold})."
)
def on_train_result(
self,
*,
algorithm: Algorithm,
metrics_logger: Optional[MetricsLogger] = None,
result: Dict,
**kwargs,
) -> None:
"""Report the current mix size at the end of training iteration.
That will tell Ray Tune, whether to stop training (once the 'target_mix_size' has been reached).
"""
result["mix_size"] = len(self.modules_in_mix)
def platform_for_binary_to_download(render: bool) -> str:
if platform.system() == "Darwin":
if render:
return "mac_windowed"
else:
return "mac_headless"
elif platform.system() == "Linux":
if render:
return "linux_windowed"
else:
return "linux_server"
else:
raise RuntimeError(f"Unsupported platform: {platform.system()}")
@@ -0,0 +1,89 @@
import gymnasium as gym
from ray.rllib.env.multi_agent_env import MultiAgentEnv
class GuessTheNumberGame(MultiAgentEnv):
"""
We have two players, 0 and 1. Agent 0 has to pick a number between 0, MAX-1
at reset. Agent 1 has to guess the number by asking N questions of whether
of the form of "a <number> is higher|lower|equal to the picked number. The
action space is MultiDiscrete [3, MAX]. For the first index 0 means lower,
1 means higher and 2 means equal. The environment answers with yes (1) or
no (0) on the reward function. Every time step that agent 1 wastes agent 0
gets a reward of 1. After N steps the game is terminated. If agent 1
guesses the number correctly, it gets a reward of 100 points, otherwise it
gets a reward of 0. On the other hand if agent 0 wins they win 100 points.
The optimal policy controlling agent 1 should converge to a binary search
strategy.
"""
MAX_NUMBER = 3
MAX_STEPS = 20
def __init__(self, config=None):
super().__init__()
self._agent_ids = {0, 1}
self.max_number = config.get("max_number", self.MAX_NUMBER)
self.max_steps = config.get("max_steps", self.MAX_STEPS)
self._number = None
self.observation_space = gym.spaces.Discrete(2)
self.action_space = gym.spaces.MultiDiscrete([3, self.max_number])
def reset(self, *, seed=None, options=None):
self._step = 0
self._number = None
# agent 0 has to pick a number. So the returned obs does not matter.
return {0: 0}, {}
def step(self, action_dict):
# get agent 0's action
agent_0_action = action_dict.get(0)
if agent_0_action is not None:
# ignore the first part of the action and look at the number
self._number = agent_0_action[1]
# next obs should tell agent 1 to start guessing.
# the returned reward and dones should be on agent 0 who picked a
# number.
return (
{1: 0},
{0: 0},
{0: False, "__all__": False},
{0: False, "__all__": False},
{},
)
if self._number is None:
raise ValueError(
"No number is selected by agent 0. Have you restarted "
"the environment?"
)
# get agent 1's action
direction, number = action_dict.get(1)
info = {}
# always the same, we don't need agent 0 to act ever again, agent 1 should keep
# guessing.
obs = {1: 0}
guessed_correctly = False
terminated = {1: False, "__all__": False}
truncated = {1: False, "__all__": False}
# everytime agent 1 does not guess correctly agent 0 gets a reward of 1.
if direction == 0: # lower
reward = {1: int(number > self._number), 0: 1}
elif direction == 1: # higher
reward = {1: int(number < self._number), 0: 1}
else: # equal
guessed_correctly = number == self._number
reward = {1: guessed_correctly * 100, 0: guessed_correctly * -100}
terminated = {1: guessed_correctly, "__all__": guessed_correctly}
self._step += 1
if self._step >= self.max_steps: # max number of steps episode is over
truncated["__all__"] = True
if not guessed_correctly:
reward[0] = 100 # agent 0 wins
return obs, reward, terminated, truncated, info
@@ -0,0 +1,236 @@
import copy
from typing import Any, Dict
import chess as ch
import numpy as np
from pettingzoo import AECEnv
from pettingzoo.classic.chess.chess import raw_env as chess_v5
from ray.rllib.env.multi_agent_env import MultiAgentEnv
class MultiAgentChess(MultiAgentEnv):
"""An interface to the PettingZoo MARL environment library.
See: https://github.com/Farama-Foundation/PettingZoo
Inherits from MultiAgentEnv and exposes a given AEC
(actor-environment-cycle) game from the PettingZoo project via the
MultiAgentEnv public API.
Note that the wrapper has some important limitations:
1. All agents have the same action_spaces and observation_spaces.
Note: If, within your aec game, agents do not have homogeneous action /
observation spaces, apply SuperSuit wrappers
to apply padding functionality: https://github.com/Farama-Foundation/
SuperSuit#built-in-multi-agent-only-functions
2. Environments are positive sum games (-> Agents are expected to cooperate
to maximize reward). This isn't a hard restriction, it just that
standard algorithms aren't expected to work well in highly competitive
games.
.. testcode::
:skipif: True
from pettingzoo.butterfly import prison_v3
from ray.rllib.env.wrappers.pettingzoo_env import PettingZooEnv
env = PettingZooEnv(prison_v3.env())
obs = env.reset()
print(obs)
# only returns the observation for the agent which should be stepping
.. testoutput::
{
'prisoner_0': array([[[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
...,
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]], dtype=uint8)
}
.. testcode::
:skipif: True
obs, rewards, dones, infos = env.step({
"prisoner_0": 1
})
# only returns the observation, reward, info, etc, for
# the agent who's turn is next.
print(obs)
.. testoutput::
{
'prisoner_1': array([[[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
...,
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]], dtype=uint8)
}
.. testcode::
:skipif: True
print(rewards)
.. testoutput::
{
'prisoner_1': 0
}
.. testcode::
:skipif: True
print(dones)
.. testoutput::
{
'prisoner_1': False, '__all__': False
}
.. testcode::
:skipif: True
print(infos)
.. testoutput::
{
'prisoner_1': {'map_tuple': (1, 0)}
}
"""
def __init__(
self,
config: Dict[Any, Any] = None,
env: AECEnv = None,
):
super().__init__()
if env is None:
self.env = chess_v5()
else:
self.env = env
self.env.reset()
self.config = config
if self.config is None:
self.config = {}
try:
self.config["random_start"] = self.config["random_start"]
except KeyError:
self.config["random_start"] = 4
# If these important attributes are not set, try to infer them.
if not self.agents:
self.agents = list(self._agent_ids)
if not self.possible_agents:
self.possible_agents = self.agents.copy()
# Get first observation space, assuming all agents have equal space
self.observation_space = self.env.observation_space(self.env.agents[0])
# Get first action space, assuming all agents have equal space
self.action_space = self.env.action_space(self.env.agents[0])
assert all(
self.env.observation_space(agent) == self.observation_space
for agent in self.env.agents
), (
"Observation spaces for all agents must be identical. Perhaps "
"SuperSuit's pad_observations wrapper can help (useage: "
"`supersuit.aec_wrappers.pad_observations(env)`"
)
assert all(
self.env.action_space(agent) == self.action_space
for agent in self.env.agents
), (
"Action spaces for all agents must be identical. Perhaps "
"SuperSuit's pad_action_space wrapper can help (usage: "
"`supersuit.aec_wrappers.pad_action_space(env)`"
)
self._agent_ids = set(self.env.agents)
def random_start(self, random_moves):
self.env.board = ch.Board()
for i in range(random_moves):
self.env.board.push(np.random.choice(list(self.env.board.legal_moves)))
return self.env.board
def observe(self):
return {
self.env.agent_selection: self.env.observe(self.env.agent_selection),
"state": self.get_state(),
}
def reset(self, *args, **kwargs):
self.env.reset()
if self.config["random_start"] > 0:
self.random_start(self.config["random_start"])
return (
{self.env.agent_selection: self.env.observe(self.env.agent_selection)},
{self.env.agent_selection: {}},
)
def step(self, action):
try:
self.env.step(action[self.env.agent_selection])
except (KeyError, IndexError):
self.env.step(action)
except AssertionError:
# Illegal action
print(action)
raise AssertionError("Illegal action")
obs_d = {}
rew_d = {}
done_d = {}
truncated_d = {}
info_d = {}
while self.env.agents:
obs, rew, done, trunc, info = self.env.last()
a = self.env.agent_selection
obs_d[a] = obs
rew_d[a] = rew
done_d[a] = done
truncated_d[a] = trunc
info_d[a] = info
if self.env.terminations[self.env.agent_selection]:
self.env.step(None)
done_d["__all__"] = True
truncated_d["__all__"] = True
else:
done_d["__all__"] = False
truncated_d["__all__"] = False
break
return obs_d, rew_d, done_d, truncated_d, info_d
def close(self):
self.env.close()
def seed(self, seed=None):
self.env.seed(seed)
def render(self, mode="human"):
return self.env.render(mode)
@property
def agent_selection(self):
return self.env.agent_selection
@property
def get_sub_environments(self):
return self.env.unwrapped
def get_state(self):
state = copy.deepcopy(self.env)
return state
def set_state(self, state):
self.env = copy.deepcopy(state)
return self.env.observe(self.env.agent_selection)
@@ -0,0 +1,220 @@
import copy
from typing import Any, Dict
from pettingzoo import AECEnv
from pettingzoo.classic.connect_four_v3 import raw_env as connect_four_v3
from ray.rllib.env.multi_agent_env import MultiAgentEnv
class MultiAgentConnect4(MultiAgentEnv):
"""An interface to the PettingZoo MARL environment library.
See: https://github.com/Farama-Foundation/PettingZoo
Inherits from MultiAgentEnv and exposes a given AEC
(actor-environment-cycle) game from the PettingZoo project via the
MultiAgentEnv public API.
Note that the wrapper has some important limitations:
1. All agents have the same action_spaces and observation_spaces.
Note: If, within your aec game, agents do not have homogeneous action /
observation spaces, apply SuperSuit wrappers
to apply padding functionality: https://github.com/Farama-Foundation/
SuperSuit#built-in-multi-agent-only-functions
2. Environments are positive sum games (-> Agents are expected to cooperate
to maximize reward). This isn't a hard restriction, it just that
standard algorithms aren't expected to work well in highly competitive
games.
.. testcode::
:skipif: True
from pettingzoo.butterfly import prison_v3
from ray.rllib.env.wrappers.pettingzoo_env import PettingZooEnv
env = PettingZooEnv(prison_v3.env())
obs = env.reset()
print(obs)
.. testoutput::
# only returns the observation for the agent which should be stepping
{
'prisoner_0': array([[[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
...,
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]], dtype=uint8)
}
.. testcode::
:skipif: True
obs, rewards, dones, infos = env.step({
"prisoner_0": 1
})
# only returns the observation, reward, info, etc, for
# the agent who's turn is next.
print(obs)
.. testoutput::
{
'prisoner_1': array([[[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
...,
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]], dtype=uint8)
}
.. testcode::
:skipif: True
print(rewards)
.. testoutput::
{
'prisoner_1': 0
}
.. testcode::
:skipif: True
print(dones)
.. testoutput::
{
'prisoner_1': False, '__all__': False
}
.. testcode::
:skipif: True
print(infos)
.. testoutput::
{
'prisoner_1': {'map_tuple': (1, 0)}
}
"""
def __init__(
self,
config: Dict[Any, Any] = None,
env: AECEnv = None,
):
super().__init__()
if env is None:
self.env = connect_four_v3()
else:
self.env = env
self.env.reset()
# If these important attributes are not set, try to infer them.
if not self.agents:
self.agents = list(self._agent_ids)
if not self.possible_agents:
self.possible_agents = self.agents.copy()
self.config = config
# Get first observation space, assuming all agents have equal space
self.observation_space = self.env.observation_space(self.env.agents[0])
# Get first action space, assuming all agents have equal space
self.action_space = self.env.action_space(self.env.agents[0])
assert all(
self.env.observation_space(agent) == self.observation_space
for agent in self.env.agents
), (
"Observation spaces for all agents must be identical. Perhaps "
"SuperSuit's pad_observations wrapper can help (useage: "
"`supersuit.aec_wrappers.pad_observations(env)`"
)
assert all(
self.env.action_space(agent) == self.action_space
for agent in self.env.agents
), (
"Action spaces for all agents must be identical. Perhaps "
"SuperSuit's pad_action_space wrapper can help (usage: "
"`supersuit.aec_wrappers.pad_action_space(env)`"
)
self._agent_ids = set(self.env.agents)
def observe(self):
return {
self.env.agent_selection: self.env.observe(self.env.agent_selection),
"state": self.get_state(),
}
def reset(self, *args, **kwargs):
self.env.reset()
return (
{self.env.agent_selection: self.env.observe(self.env.agent_selection)},
{self.env.agent_selection: {}},
)
def step(self, action):
try:
self.env.step(action[self.env.agent_selection])
except (KeyError, IndexError):
self.env.step(action)
except AssertionError:
# Illegal action
print(action)
raise AssertionError("Illegal action")
obs_d = {}
rew_d = {}
done_d = {}
trunc_d = {}
info_d = {}
while self.env.agents:
obs, rew, done, trunc, info = self.env.last()
a = self.env.agent_selection
obs_d[a] = obs
rew_d[a] = rew
done_d[a] = done
trunc_d[a] = trunc
info_d[a] = info
if self.env.terminations[self.env.agent_selection]:
self.env.step(None)
done_d["__all__"] = True
trunc_d["__all__"] = True
else:
done_d["__all__"] = False
trunc_d["__all__"] = False
break
return obs_d, rew_d, done_d, trunc_d, info_d
def close(self):
self.env.close()
def seed(self, seed=None):
self.env.seed(seed)
def render(self, mode="human"):
return self.env.render(mode)
@property
def agent_selection(self):
return self.env.agent_selection
@property
def get_sub_environments(self):
return self.env.unwrapped
def get_state(self):
state = copy.deepcopy(self.env)
return state
def set_state(self, state):
self.env = copy.deepcopy(state)
return self.env.observe(self.env.agent_selection)
@@ -0,0 +1,125 @@
# __sphinx_doc_1_begin__
import gymnasium as gym
from ray.rllib.env.multi_agent_env import MultiAgentEnv
class RockPaperScissors(MultiAgentEnv):
"""Two-player environment for the famous rock paper scissors game.
# __sphinx_doc_1_end__
Optionally, the "Sheldon Cooper extension" can be activated by passing
`sheldon_cooper_mode=True` into the constructor, in which case two more moves
are allowed: Spock and Lizard. Spock is poisoned by Lizard, disproven by Paper, but
crushes Rock and smashes Scissors. Lizard poisons Spock and eats Paper, but is
decapitated by Scissors and crushed by Rock.
# __sphinx_doc_2_begin__
Both players always move simultaneously over a course of 10 timesteps in total.
The winner of each timestep receives reward of +1, the losing player -1.0.
The observation of each player is the last opponent action.
"""
ROCK = 0
PAPER = 1
SCISSORS = 2
LIZARD = 3
SPOCK = 4
WIN_MATRIX = {
(ROCK, ROCK): (0, 0),
(ROCK, PAPER): (-1, 1),
(ROCK, SCISSORS): (1, -1),
(PAPER, ROCK): (1, -1),
(PAPER, PAPER): (0, 0),
(PAPER, SCISSORS): (-1, 1),
(SCISSORS, ROCK): (-1, 1),
(SCISSORS, PAPER): (1, -1),
(SCISSORS, SCISSORS): (0, 0),
}
# __sphinx_doc_2_end__
WIN_MATRIX.update(
{
# Sheldon Cooper mode:
(LIZARD, LIZARD): (0, 0),
(LIZARD, SPOCK): (1, -1), # Lizard poisons Spock
(LIZARD, ROCK): (-1, 1), # Rock crushes lizard
(LIZARD, PAPER): (1, -1), # Lizard eats paper
(LIZARD, SCISSORS): (-1, 1), # Scissors decapitate lizard
(ROCK, LIZARD): (1, -1), # Rock crushes lizard
(PAPER, LIZARD): (-1, 1), # Lizard eats paper
(SCISSORS, LIZARD): (1, -1), # Scissors decapitate lizard
(SPOCK, SPOCK): (0, 0),
(SPOCK, LIZARD): (-1, 1), # Lizard poisons Spock
(SPOCK, ROCK): (1, -1), # Spock vaporizes rock
(SPOCK, PAPER): (-1, 1), # Paper disproves Spock
(SPOCK, SCISSORS): (1, -1), # Spock smashes scissors
(ROCK, SPOCK): (-1, 1), # Spock vaporizes rock
(PAPER, SPOCK): (1, -1), # Paper disproves Spock
(SCISSORS, SPOCK): (-1, 1), # Spock smashes scissors
}
)
# __sphinx_doc_3_begin__
def __init__(self, config=None):
super().__init__()
self.agents = self.possible_agents = ["player1", "player2"]
# The observations are always the last taken actions. Hence observation- and
# action spaces are identical.
self.observation_spaces = self.action_spaces = {
"player1": gym.spaces.Discrete(3),
"player2": gym.spaces.Discrete(3),
}
self.last_move = None
self.num_moves = 0
# __sphinx_doc_3_end__
self.sheldon_cooper_mode = False
if config.get("sheldon_cooper_mode"):
self.sheldon_cooper_mode = True
self.action_spaces = self.observation_spaces = {
"player1": gym.spaces.Discrete(5),
"player2": gym.spaces.Discrete(5),
}
# __sphinx_doc_4_begin__
def reset(self, *, seed=None, options=None):
self.num_moves = 0
# The first observation should not matter (none of the agents has moved yet).
# Set them to 0.
return {
"player1": 0,
"player2": 0,
}, {} # <- empty infos dict
# __sphinx_doc_4_end__
# __sphinx_doc_5_begin__
def step(self, action_dict):
self.num_moves += 1
move1 = action_dict["player1"]
move2 = action_dict["player2"]
# Set the next observations (simply use the other player's action).
# Note that because we are publishing both players in the observations dict,
# we expect both players to act in the next `step()` (simultaneous stepping).
observations = {"player1": move2, "player2": move1}
# Compute rewards for each player based on the win-matrix.
r1, r2 = self.WIN_MATRIX[move1, move2]
rewards = {"player1": r1, "player2": r2}
# Terminate the entire episode (for all agents) once 10 moves have been made.
terminateds = {"__all__": self.num_moves >= 10}
# Leave truncateds and infos empty.
return observations, rewards, terminateds, {}, {}
# __sphinx_doc_5_end__
@@ -0,0 +1,183 @@
# __sphinx_doc_1_begin__
import random
import gymnasium as gym
import numpy as np
from ray.rllib.env.multi_agent_env import MultiAgentEnv
class TicTacToe(MultiAgentEnv):
"""A two-player game in which any player tries to complete one row in a 3x3 field.
The observation space is Box(-1.0, 1.0, (9,)), where each index represents a distinct
field on a 3x3 board. From the current player's perspective: 1.0 means we occupy the
field, -1.0 means the opponent owns the field, and 0.0 means the field is empty:
----------
| 0| 1| 2|
----------
| 3| 4| 5|
----------
| 6| 7| 8|
----------
The action space is Discrete(9). Actions landing on an already occupied field
result in a -1.0 penalty for the player taking the invalid action.
Once a player completes a row, they receive +1.0 reward, the losing player receives
-1.0 reward. A draw results in 0.0 reward for both players.
"""
# __sphinx_doc_1_end__
# Winning line indices: rows, columns, and diagonals.
WIN_LINES = [
[0, 1, 2], # rows
[3, 4, 5],
[6, 7, 8],
[0, 3, 6], # cols
[1, 4, 7],
[2, 5, 8],
[0, 4, 8], # diagonals
[2, 4, 6],
]
# __sphinx_doc_2_begin__
def __init__(self, config=None):
super().__init__()
# Define the agents in the game.
self.agents = self.possible_agents = ["player1", "player2"]
# Each agent observes a 9D tensor, representing the 3x3 fields of the board.
# From the current player's perspective: 1 means our piece, -1 means opponent's
# piece, 0 means empty. The board is flipped after each turn.
self.observation_spaces = {
"player1": gym.spaces.Box(-1.0, 1.0, (9,), np.float32),
"player2": gym.spaces.Box(-1.0, 1.0, (9,), np.float32),
}
# Each player has 9 actions, encoding the 9 fields each player can place a piece
# on during their turn.
self.action_spaces = {
"player1": gym.spaces.Discrete(9),
"player2": gym.spaces.Discrete(9),
}
self.max_timesteps = 30
self.board = None
self.current_player = None
self.timestep = 0
# __sphinx_doc_2_end__
# __sphinx_doc_3_begin__
def reset(self, *, seed=None, options=None):
self.board = [0] * 9
# Pick a random player to start the game and reset the current timesteps.
self.current_player = random.choice(self.agents)
self.timestep = 0
# Return observations dict (only with the starting player, which is the one
# we expect to act next).
return {self.current_player: np.array(self.board, np.float32)}, {}
# __sphinx_doc_3_end__
# __sphinx_doc_4_begin__
def step(self, action_dict):
action = action_dict[self.current_player]
opponent = "player2" if self.current_player == "player1" else "player1"
self.timestep += 1
# Invalid move: penalize and return without changing board.
if self.board[action] != 0:
# The time limit is reached
if self.timestep >= self.max_timesteps:
board_arr = np.array(self.board, np.float32)
return (
{self.current_player: board_arr, opponent: board_arr * -1},
{self.current_player: -0.5, opponent: 0.0},
{"__all__": False},
{"__all__": True},
{},
)
else:
reward = {self.current_player: -0.5}
self.board = [-x for x in self.board]
self.current_player = opponent
return (
{opponent: np.array(self.board, np.float32)},
reward,
{"__all__": False},
{"__all__": False},
{},
)
# Place the piece on the board.
self.board[action] = 1
# Check for win.
if any(all(self.board[i] == 1 for i in line) for line in self.WIN_LINES):
board_arr = np.array(self.board, np.float32)
return (
{self.current_player: board_arr, opponent: board_arr * -1},
{self.current_player: 1.0, opponent: -1.0},
{"__all__": True},
{"__all__": False},
{},
)
# Check for draw (board full, no winner).
if 0 not in self.board:
board_arr = np.array(self.board, np.float32)
return (
{self.current_player: board_arr, opponent: board_arr * -1},
{self.current_player: 0.0, opponent: 0.0},
{"__all__": True},
{"__all__": False},
{},
)
# Check for truncation.
if self.timestep >= self.max_timesteps:
board_arr = np.array(self.board, np.float32)
return (
{self.current_player: board_arr, opponent: board_arr * -1},
{self.current_player: 0.0, opponent: 0.0},
{"__all__": False},
{"__all__": True},
{},
)
# Continue game: flip board and switch player.
reward = {self.current_player: 0.0}
self.board = [-x for x in self.board]
self.current_player = opponent
return (
{opponent: np.array(self.board, np.float32)},
reward,
{"__all__": False},
{"__all__": False},
{},
)
# __sphinx_doc_4_end__
def render(self) -> str:
"""Render the current board state as an ASCII grid.
Returns:
A string representation of the board where:
- 'X' represents the current player's pieces
- 'O' represents opponent player's pieces
- ' ' represents empty fields
"""
symbols = {0: " ", 1: "X", -1: "O"}
rows = []
for i in range(3):
row_cells = [symbols[self.board[i * 3 + j]] for j in range(3)]
rows.append(" " + " | ".join(row_cells) + " ")
separator = "-----------"
return "\n" + f"\n{separator}\n".join(rows) + "\n"
@@ -0,0 +1,125 @@
import numpy as np
from gymnasium.spaces import Dict, Discrete, MultiDiscrete, Tuple
from ray.rllib.env.multi_agent_env import ENV_STATE, MultiAgentEnv
class TwoStepGame(MultiAgentEnv):
action_space = Discrete(2)
def __init__(self, env_config):
super().__init__()
self.action_space = Discrete(2)
self.state = None
self.agent_1 = 0
self.agent_2 = 1
# MADDPG emits action logits instead of actual discrete actions
self.actions_are_logits = env_config.get("actions_are_logits", False)
self.one_hot_state_encoding = env_config.get("one_hot_state_encoding", False)
self.with_state = env_config.get("separate_state_space", False)
self._agent_ids = {0, 1}
if not self.one_hot_state_encoding:
self.observation_space = Discrete(6)
self.with_state = False
else:
# Each agent gets the full state (one-hot encoding of which of the
# three states are active) as input with the receiving agent's
# ID (1 or 2) concatenated onto the end.
if self.with_state:
self.observation_space = Dict(
{
"obs": MultiDiscrete([2, 2, 2, 3]),
ENV_STATE: MultiDiscrete([2, 2, 2]),
}
)
else:
self.observation_space = MultiDiscrete([2, 2, 2, 3])
def reset(self, *, seed=None, options=None):
if seed is not None:
np.random.seed(seed)
self.state = np.array([1, 0, 0])
return self._obs(), {}
def step(self, action_dict):
if self.actions_are_logits:
action_dict = {
k: np.random.choice([0, 1], p=v) for k, v in action_dict.items()
}
state_index = np.flatnonzero(self.state)
if state_index == 0:
action = action_dict[self.agent_1]
assert action in [0, 1], action
if action == 0:
self.state = np.array([0, 1, 0])
else:
self.state = np.array([0, 0, 1])
global_rew = 0
terminated = False
elif state_index == 1:
global_rew = 7
terminated = True
else:
if action_dict[self.agent_1] == 0 and action_dict[self.agent_2] == 0:
global_rew = 0
elif action_dict[self.agent_1] == 1 and action_dict[self.agent_2] == 1:
global_rew = 8
else:
global_rew = 1
terminated = True
rewards = {self.agent_1: global_rew / 2.0, self.agent_2: global_rew / 2.0}
obs = self._obs()
terminateds = {"__all__": terminated}
truncateds = {"__all__": False}
infos = {
self.agent_1: {"done": terminateds["__all__"]},
self.agent_2: {"done": terminateds["__all__"]},
}
return obs, rewards, terminateds, truncateds, infos
def _obs(self):
if self.with_state:
return {
self.agent_1: {"obs": self.agent_1_obs(), ENV_STATE: self.state},
self.agent_2: {"obs": self.agent_2_obs(), ENV_STATE: self.state},
}
else:
return {self.agent_1: self.agent_1_obs(), self.agent_2: self.agent_2_obs()}
def agent_1_obs(self):
if self.one_hot_state_encoding:
return np.concatenate([self.state, [1]])
else:
return np.flatnonzero(self.state)[0]
def agent_2_obs(self):
if self.one_hot_state_encoding:
return np.concatenate([self.state, [2]])
else:
return np.flatnonzero(self.state)[0] + 3
class TwoStepGameWithGroupedAgents(MultiAgentEnv):
def __init__(self, env_config):
self._agent_ids = {"agents"}
super().__init__()
env = TwoStepGame(env_config)
tuple_obs_space = Tuple([env.observation_space, env.observation_space])
tuple_act_space = Tuple([env.action_space, env.action_space])
self.env = env.with_agent_groups(
groups={"agents": [0, 1]},
obs_space=tuple_obs_space,
act_space=tuple_act_space,
)
self.observation_space = Dict({"agents": self.env.observation_space})
self.action_space = Dict({"agents": self.env.action_space})
def reset(self, *, seed=None, options=None):
return self.env.reset(seed=seed, options=options)
def step(self, actions):
return self.env.step(actions)
@@ -0,0 +1,50 @@
import gymnasium as gym
import numpy as np
import tree # pip install dm_tree
from gymnasium.spaces import Box, Dict, Discrete, Tuple
from ray.rllib.utils.spaces.space_utils import flatten_space
class NestedSpaceRepeatAfterMeEnv(gym.Env):
"""Env for which policy has to repeat the (possibly complex) observation.
The action space and observation spaces are always the same and may be
arbitrarily nested Dict/Tuple Spaces.
Rewards are given for exactly matching Discrete sub-actions and for being
as close as possible for Box sub-actions.
"""
def __init__(self, config=None):
config = config or {}
self.observation_space = config.get(
"space", Tuple([Discrete(2), Dict({"a": Box(-1.0, 1.0, (2,))})])
)
self.action_space = self.observation_space
self.flattened_action_space = flatten_space(self.action_space)
self.episode_len = config.get("episode_len", 100)
def reset(self, *, seed=None, options=None):
self.steps = 0
return self._next_obs(), {}
def step(self, action):
self.steps += 1
action = tree.flatten(action)
reward = 0.0
for a, o, space in zip(
action, self.current_obs_flattened, self.flattened_action_space
):
# Box: -abs(diff).
if isinstance(space, gym.spaces.Box):
reward -= np.sum(np.abs(a - o))
# Discrete: +1.0 if exact match.
if isinstance(space, gym.spaces.Discrete):
reward += 1.0 if a == o else 0.0
truncated = self.steps >= self.episode_len
return self._next_obs(), reward, False, truncated, {}
def _next_obs(self):
self.current_obs = self.observation_space.sample()
self.current_obs_flattened = tree.flatten(self.current_obs)
return self.current_obs
@@ -0,0 +1,145 @@
import random
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Box, Dict, Discrete
class ParametricActionsCartPole(gym.Env):
"""Parametric action version of CartPole.
In this env there are only ever two valid actions, but we pretend there are
actually up to `max_avail_actions` actions that can be taken, and the two
valid actions are randomly hidden among this set.
At each step, we emit a dict of:
- the actual cart observation
- a mask of valid actions (e.g., [0, 0, 1, 0, 0, 1] for 6 max avail)
- the list of action embeddings (w/ zeroes for invalid actions) (e.g.,
[[0, 0],
[0, 0],
[-0.2322, -0.2569],
[0, 0],
[0, 0],
[0.7878, 1.2297]] for max_avail_actions=6)
In a real environment, the actions embeddings would be larger than two
units of course, and also there would be a variable number of valid actions
per step instead of always [LEFT, RIGHT].
"""
def __init__(self, max_avail_actions):
# Use simple random 2-unit action embeddings for [LEFT, RIGHT]
self.left_action_embed = np.random.randn(2)
self.right_action_embed = np.random.randn(2)
self.action_space = Discrete(max_avail_actions)
self.wrapped = gym.make("CartPole-v1")
self.observation_space = Dict(
{
"action_mask": Box(0, 1, shape=(max_avail_actions,), dtype=np.int8),
"avail_actions": Box(-10, 10, shape=(max_avail_actions, 2)),
"cart": self.wrapped.observation_space,
}
)
def update_avail_actions(self):
self.action_assignments = np.array(
[[0.0, 0.0]] * self.action_space.n, dtype=np.float32
)
self.action_mask = np.array([0.0] * self.action_space.n, dtype=np.int8)
self.left_idx, self.right_idx = random.sample(range(self.action_space.n), 2)
self.action_assignments[self.left_idx] = self.left_action_embed
self.action_assignments[self.right_idx] = self.right_action_embed
self.action_mask[self.left_idx] = 1
self.action_mask[self.right_idx] = 1
def reset(self, *, seed=None, options=None):
self.update_avail_actions()
obs, infos = self.wrapped.reset()
return {
"action_mask": self.action_mask,
"avail_actions": self.action_assignments,
"cart": obs,
}, infos
def step(self, action):
if action == self.left_idx:
actual_action = 0
elif action == self.right_idx:
actual_action = 1
else:
raise ValueError(
"Chosen action was not one of the non-zero action embeddings",
action,
self.action_assignments,
self.action_mask,
self.left_idx,
self.right_idx,
)
orig_obs, rew, done, truncated, info = self.wrapped.step(actual_action)
self.update_avail_actions()
self.action_mask = self.action_mask.astype(np.int8)
obs = {
"action_mask": self.action_mask,
"avail_actions": self.action_assignments,
"cart": orig_obs,
}
return obs, rew, done, truncated, info
class ParametricActionsCartPoleNoEmbeddings(gym.Env):
"""Same as the above ParametricActionsCartPole.
However, action embeddings are not published inside observations,
but will be learnt by the model.
At each step, we emit a dict of:
- the actual cart observation
- a mask of valid actions (e.g., [0, 0, 1, 0, 0, 1] for 6 max avail)
- action embeddings (w/ "dummy embedding" for invalid actions) are
outsourced in the model and will be learned.
"""
def __init__(self, max_avail_actions):
# Randomly set which two actions are valid and available.
self.left_idx, self.right_idx = random.sample(range(max_avail_actions), 2)
self.valid_avail_actions_mask = np.array(
[0.0] * max_avail_actions, dtype=np.int8
)
self.valid_avail_actions_mask[self.left_idx] = 1
self.valid_avail_actions_mask[self.right_idx] = 1
self.action_space = Discrete(max_avail_actions)
self.wrapped = gym.make("CartPole-v1")
self.observation_space = Dict(
{
"valid_avail_actions_mask": Box(0, 1, shape=(max_avail_actions,)),
"cart": self.wrapped.observation_space,
}
)
def reset(self, *, seed=None, options=None):
obs, infos = self.wrapped.reset()
return {
"valid_avail_actions_mask": self.valid_avail_actions_mask,
"cart": obs,
}, infos
def step(self, action):
if action == self.left_idx:
actual_action = 0
elif action == self.right_idx:
actual_action = 1
else:
raise ValueError(
"Chosen action was not one of the non-zero action embeddings",
action,
self.valid_avail_actions_mask,
self.left_idx,
self.right_idx,
)
orig_obs, rew, done, truncated, info = self.wrapped.step(actual_action)
obs = {
"valid_avail_actions_mask": self.valid_avail_actions_mask,
"cart": orig_obs,
}
return obs, rew, done, truncated, info
+126
View File
@@ -0,0 +1,126 @@
import copy
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Discrete, Tuple
from ray.rllib.examples.envs.classes.multi_agent import make_multi_agent
class RandomEnv(gym.Env):
"""A randomly acting environment.
Can be instantiated with arbitrary action-, observation-, and reward
spaces. Observations and rewards are generated by simply sampling from the
observation/reward spaces. The probability of a `terminated=True` after each
action can be configured, as well as the max episode length.
"""
def __init__(self, config=None):
config = config or {}
# Action space.
self.action_space = config.get("action_space", Discrete(2))
# Observation space from which to sample.
self.observation_space = config.get("observation_space", Discrete(2))
# Reward space from which to sample.
self.reward_space = config.get(
"reward_space",
gym.spaces.Box(low=-1.0, high=1.0, shape=(), dtype=np.float32),
)
self.static_samples = config.get("static_samples", False)
if self.static_samples:
self.observation_sample = self.observation_space.sample()
self.reward_sample = self.reward_space.sample()
# Chance that an episode ends at any step.
# Note that a max episode length can be specified via
# `max_episode_len`.
self.p_terminated = config.get("p_terminated")
if self.p_terminated is None:
self.p_terminated = config.get("p_done", 0.1)
# A max episode length. Even if the `p_terminated` sampling does not lead
# to a terminus, the episode will end after at most this many
# timesteps.
# Set to 0 or None for using no limit on the episode length.
self.max_episode_len = config.get("max_episode_len", None)
# Whether to check action bounds.
self.check_action_bounds = config.get("check_action_bounds", False)
# Steps taken so far (after last reset).
self.steps = 0
def reset(self, *, seed=None, options=None):
self.steps = 0
if not self.static_samples:
return self.observation_space.sample(), {}
else:
return copy.deepcopy(self.observation_sample), {}
def step(self, action):
if self.check_action_bounds and not self.action_space.contains(action):
raise ValueError(
"Illegal action for {}: {}".format(self.action_space, action)
)
if isinstance(self.action_space, Tuple) and len(action) != len(
self.action_space.spaces
):
raise ValueError(
"Illegal action for {}: {}".format(self.action_space, action)
)
self.steps += 1
terminated = False
truncated = False
# We are `truncated` as per our max-episode-len.
if self.max_episode_len and self.steps >= self.max_episode_len:
truncated = True
# Max episode length not reached yet -> Sample `terminated` via `p_terminated`.
elif self.p_terminated > 0.0:
terminated = bool(
np.random.choice(
[True, False], p=[self.p_terminated, 1.0 - self.p_terminated]
)
)
if not self.static_samples:
return (
self.observation_space.sample(),
self.reward_space.sample(),
terminated,
truncated,
{},
)
else:
return (
copy.deepcopy(self.observation_sample),
copy.deepcopy(self.reward_sample),
terminated,
truncated,
{},
)
# Multi-agent version of the RandomEnv.
RandomMultiAgentEnv = make_multi_agent(lambda c: RandomEnv(c))
# Large observation space "pre-compiled" random env (for testing).
class RandomLargeObsSpaceEnv(RandomEnv):
def __init__(self, config=None):
config = config or {}
config.update({"observation_space": gym.spaces.Box(-1.0, 1.0, (5000,))})
super().__init__(config=config)
# Large observation space + cont. actions "pre-compiled" random env
# (for testing).
class RandomLargeObsSpaceEnvContActions(RandomEnv):
def __init__(self, config=None):
config = config or {}
config.update(
{
"observation_space": gym.spaces.Box(-1.0, 1.0, (5000,)),
"action_space": gym.spaces.Box(-1.0, 1.0, (5,)),
}
)
super().__init__(config=config)
@@ -0,0 +1,108 @@
"""Examples for RecSim envs ready to be used by RLlib Algorithms.
RecSim is a configurable recommender systems simulation platform.
Source: https://github.com/google-research/recsim
"""
from recsim import choice_model
from recsim.environments import (
interest_evolution as iev,
interest_exploration as iex,
long_term_satisfaction as lts,
)
from ray.rllib.env.wrappers.recsim import make_recsim_env
from ray.tune import register_env
# Some built-in RecSim envs to test with.
# ---------------------------------------
# Long-term satisfaction env: User has to pick from items that are either
# a) unhealthy, but taste good, or b) healthy, but have bad taste.
# Best strategy is to pick a mix of both to ensure long-term
# engagement.
def lts_user_model_creator(env_ctx):
return lts.LTSUserModel(
env_ctx["slate_size"],
user_state_ctor=lts.LTSUserState,
response_model_ctor=lts.LTSResponse,
)
def lts_document_sampler_creator(env_ctx):
return lts.LTSDocumentSampler()
LongTermSatisfactionRecSimEnv = make_recsim_env(
recsim_user_model_creator=lts_user_model_creator,
recsim_document_sampler_creator=lts_document_sampler_creator,
reward_aggregator=lts.clicked_engagement_reward,
)
# Interest exploration env: Models the problem of active exploration
# of user interests. It is meant to illustrate popularity bias in
# recommender systems, where myopic maximization of engagement leads
# to bias towards documents that have wider appeal,
# whereas niche user interests remain unexplored.
def iex_user_model_creator(env_ctx):
return iex.IEUserModel(
env_ctx["slate_size"],
user_state_ctor=iex.IEUserState,
response_model_ctor=iex.IEResponse,
seed=env_ctx["seed"],
)
def iex_document_sampler_creator(env_ctx):
return iex.IETopicDocumentSampler(seed=env_ctx["seed"])
InterestExplorationRecSimEnv = make_recsim_env(
recsim_user_model_creator=iex_user_model_creator,
recsim_document_sampler_creator=iex_document_sampler_creator,
reward_aggregator=iex.total_clicks_reward,
)
# Interest evolution env: See https://github.com/google-research/recsim
# for more information.
def iev_user_model_creator(env_ctx):
return iev.IEvUserModel(
env_ctx["slate_size"],
choice_model_ctor=choice_model.MultinomialProportionalChoiceModel,
response_model_ctor=iev.IEvResponse,
user_state_ctor=iev.IEvUserState,
seed=env_ctx["seed"],
)
# Extend IEvVideo to fix a bug caused by None cluster_ids.
class SingleClusterIEvVideo(iev.IEvVideo):
def __init__(self, doc_id, features, video_length=None, quality=None):
super(SingleClusterIEvVideo, self).__init__(
doc_id=doc_id,
features=features,
cluster_id=0, # single cluster.
video_length=video_length,
quality=quality,
)
def iev_document_sampler_creator(env_ctx):
return iev.UtilityModelVideoSampler(doc_ctor=iev.IEvVideo, seed=env_ctx["seed"])
InterestEvolutionRecSimEnv = make_recsim_env(
recsim_user_model_creator=iev_user_model_creator,
recsim_document_sampler_creator=iev_document_sampler_creator,
reward_aggregator=iev.clicked_watchtime_reward,
)
# Backward compatibility.
register_env(
name="RecSim-v1", env_creator=lambda env_ctx: InterestEvolutionRecSimEnv(env_ctx)
)
@@ -0,0 +1,47 @@
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Box, Discrete
class RepeatAfterMeEnv(gym.Env):
"""Env in which the observation at timestep minus n must be repeated."""
def __init__(self, config=None):
config = config or {}
if config.get("continuous"):
self.observation_space = Box(-1.0, 1.0, (2,))
else:
self.observation_space = Discrete(2)
self.action_space = self.observation_space
# Note: Set `repeat_delay` to 0 for simply repeating the seen
# observation (no delay).
self.delay = config.get("repeat_delay", 1)
self.episode_len = config.get("episode_len", 100)
self.history = []
def reset(self, *, seed=None, options=None):
self.history = [0] * self.delay
return self._next_obs(), {}
def step(self, action):
obs = self.history[-(1 + self.delay)]
reward = 0.0
# Box: -abs(diff).
if isinstance(self.action_space, Box):
reward = -np.sum(np.abs(action - obs))
# Discrete: +1.0 if exact match, -1.0 otherwise.
if isinstance(self.action_space, Discrete):
reward = 1.0 if action == obs else -1.0
done = truncated = len(self.history) > self.episode_len
return self._next_obs(), reward, done, truncated, {}
def _next_obs(self):
if isinstance(self.observation_space, Box):
token = np.random.random(size=(2,))
else:
token = np.random.choice([0, 1])
self.history.append(token)
return token
@@ -0,0 +1,33 @@
import random
import gymnasium as gym
from gymnasium.spaces import Discrete
class RepeatInitialObsEnv(gym.Env):
"""Env in which the initial observation has to be repeated all the time.
Runs for n steps.
r=1 if action correct, -1 otherwise (max. R=100).
"""
def __init__(self, episode_len=100):
self.observation_space = Discrete(2)
self.action_space = Discrete(2)
self.token = None
self.episode_len = episode_len
self.num_steps = 0
def reset(self, *, seed=None, options=None):
self.token = random.choice([0, 1])
self.num_steps = 0
return self.token, {}
def step(self, action):
if action == self.token:
reward = 1
else:
reward = -1
self.num_steps += 1
done = truncated = self.num_steps >= self.episode_len
return 0, reward, done, truncated, {}
@@ -0,0 +1,46 @@
import logging
import gymnasium as gym
import numpy as np
from gymnasium.spaces import Box, Discrete
logger = logging.getLogger("ray.rllib")
class SimpleCorridor(gym.Env):
"""Example of a custom env in which you have to walk down a corridor.
You can configure the length of the corridor via the env config."""
def __init__(self, config=None):
config = config or {}
self.action_space = Discrete(2)
self.observation_space = Box(0.0, 999.0, shape=(1,), dtype=np.float32)
self.set_corridor_length(config.get("corridor_length", 10))
self._cur_pos = 0
def set_corridor_length(self, length):
self.end_pos = length
logger.info(f"Set corridor length to {self.end_pos}")
assert self.end_pos <= 999, "The maximum `corridor_length` allowed is 999!"
def reset(self, *, seed=None, options=None):
self._cur_pos = 0.0
return self._get_obs(), {}
def step(self, action):
assert action in [0, 1], action
if action == 0 and self._cur_pos > 0:
self._cur_pos -= 1.0
elif action == 1:
self._cur_pos += 1.0
terminated = self._cur_pos >= self.end_pos
truncated = False
reward = 1.0 if terminated else -0.01
return self._get_obs(), reward, terminated, truncated, {}
def _get_obs(self):
return np.array([self._cur_pos], np.float32)
+49
View File
@@ -0,0 +1,49 @@
import gymnasium as gym
from gymnasium.spaces import Box, Dict, Discrete
from ray.rllib.utils.spaces.repeated import Repeated
# Constraints on the Repeated space.
MAX_PLAYERS = 4
MAX_ITEMS = 7
MAX_EFFECTS = 2
class SimpleRPG(gym.Env):
"""Example of a custom env with a complex, structured observation.
The observation is a list of players, each of which is a Dict of
attributes, and may further hold a list of items (categorical space).
Note that the env doesn't train, it's just a dummy example to show how to
use spaces.Repeated in a custom model (see CustomRPGModel below).
"""
def __init__(self, config):
self.cur_pos = 0
self.action_space = Discrete(4)
# Represents an item.
self.item_space = Discrete(5)
# Represents an effect on the player.
self.effect_space = Box(9000, 9999, shape=(4,))
# Represents a player.
self.player_space = Dict(
{
"location": Box(-100, 100, shape=(2,)),
"status": Box(-1, 1, shape=(10,)),
"items": Repeated(self.item_space, max_len=MAX_ITEMS),
"effects": Repeated(self.effect_space, max_len=MAX_EFFECTS),
}
)
# Observation is a list of players.
self.observation_space = Repeated(self.player_space, max_len=MAX_PLAYERS)
def reset(self, *, seed=None, options=None):
return self.observation_space.sample(), {}
def step(self, action):
return self.observation_space.sample(), 1, True, False, {}
+314
View File
@@ -0,0 +1,314 @@
import gymnasium as gym
from ray.rllib.env.multi_agent_env import MultiAgentEnv
# Map representation: Always six rooms (as the name suggests) with doors in between.
MAPS = {
"small": [
"WWWWWWWWWWWWW",
"W W W W",
"W W W",
"W W W",
"W WWWW WWWW W",
"W W W W",
"W W W",
"W W GW",
"WWWWWWWWWWWWW",
],
"medium": [
"WWWWWWWWWWWWWWWWWWW",
"W W W W",
"W W W",
"W W W",
"W WWWWWWW WWWWWWW W",
"W W W W",
"W W W",
"W W GW",
"WWWWWWWWWWWWWWWWWWW",
],
"large": [
"WWWWWWWWWWWWWWWWWWWWWWWWW",
"W W W W",
"W W W W",
"W W W",
"W W W",
"W W W W",
"WW WWWWWWWWW WWWWWWWWWW W",
"W W W W",
"W W W",
"W W W W",
"W W W",
"W W W GW",
"WWWWWWWWWWWWWWWWWWWWWWWWW",
],
}
class SixRoomEnv(gym.Env):
"""A grid-world with six rooms (arranged as 2x3), which are connected by doors.
The agent starts in the upper left room and has to reach a designated goal state
in one of the rooms using primitive actions up, left, down, and right.
The agent receives a small penalty of -0.01 on each step and a reward of +10.0 when
reaching the goal state.
"""
def __init__(self, config=None):
super().__init__()
# User can provide a custom map or a recognized map name (small, medium, large).
self.map = config.get("custom_map", MAPS.get(config.get("map"), MAPS["small"]))
self.time_limit = config.get("time_limit", 50)
# Define observation space: Discrete, index fields.
self.observation_space = gym.spaces.Discrete(len(self.map) * len(self.map[0]))
# Primitive actions: up, down, left, right.
self.action_space = gym.spaces.Discrete(4)
# Initialize environment state.
self.reset()
def reset(self, *, seed=None, options=None):
self._agent_pos = (1, 1)
self._ts = 0
# Return high-level observation.
return self._agent_discrete_pos, {}
def step(self, action):
next_pos = _get_next_pos(action, self._agent_pos)
self._ts += 1
# Check if the move ends up in a wall. If so -> Ignore the move and stay
# where we are right now.
if self.map[next_pos[0]][next_pos[1]] != "W":
self._agent_pos = next_pos
# Check if the agent has reached the global goal state.
if self.map[self._agent_pos[0]][self._agent_pos[1]] == "G":
return self._agent_discrete_pos, 10.0, True, False, {}
# Small step penalty.
return self._agent_discrete_pos, -0.01, False, self._ts >= self.time_limit, {}
@property
def _agent_discrete_pos(self):
x = self._agent_pos[0]
y = self._agent_pos[1]
# discrete position = row idx * columns + col idx
return x * len(self.map[0]) + y
class HierarchicalSixRoomEnv(MultiAgentEnv):
def __init__(self, config=None):
super().__init__()
# User can provide a custom map or a recognized map name (small, medium, large).
self.map = config.get("custom_map", MAPS.get(config.get("map"), MAPS["small"]))
self.max_steps_low_level = config.get("max_steps_low_level", 15)
self.time_limit = config.get("time_limit", 50)
self.num_low_level_agents = config.get("num_low_level_agents", 3)
self.agents = self.possible_agents = ["high_level_agent"] + [
f"low_level_agent_{i}" for i in range(self.num_low_level_agents)
]
# Define basic observation space: Discrete, index fields.
observation_space = gym.spaces.Discrete(len(self.map) * len(self.map[0]))
# Low level agents always see where they are right now and what the target
# state should be.
low_level_observation_space = gym.spaces.Tuple(
(observation_space, observation_space)
)
# Primitive actions: up, down, left, right.
low_level_action_space = gym.spaces.Discrete(4)
self.observation_spaces = {"high_level_agent": observation_space}
self.observation_spaces.update(
{
f"low_level_agent_{i}": low_level_observation_space
for i in range(self.num_low_level_agents)
}
)
self.action_spaces = {
"high_level_agent": gym.spaces.Tuple(
(
# The new target observation.
observation_space,
# Low-level policy that should get us to the new target observation.
gym.spaces.Discrete(self.num_low_level_agents),
)
)
}
self.action_spaces.update(
{
f"low_level_agent_{i}": low_level_action_space
for i in range(self.num_low_level_agents)
}
)
# Initialize environment state.
self.reset()
def reset(self, *, seed=None, options=None):
self._agent_pos = (1, 1)
self._low_level_steps = 0
self._high_level_action = None
# Number of times the low-level agent reached the given target (by the high
# level agent).
self._num_targets_reached = 0
self._ts = 0
# Return high-level observation.
return {
"high_level_agent": self._agent_discrete_pos,
}, {}
def step(self, action_dict):
self._ts += 1
terminateds = {"__all__": self._ts >= self.time_limit}
truncateds = {"__all__": False}
# High-level agent acted: Set next goal and next low-level policy to use.
# Note that the agent does not move in this case and stays at its current
# location.
if "high_level_agent" in action_dict:
self._high_level_action = action_dict["high_level_agent"]
low_level_agent = f"low_level_agent_{self._high_level_action[1]}"
self._low_level_steps = 0
# Return next low-level observation for the now-active agent.
# We want this agent to act next.
return (
{
low_level_agent: (
self._agent_discrete_pos, # current
self._high_level_action[0], # target
)
},
# Penalty for a target state that's close to the current state.
{
"high_level_agent": (
self.eucl_dist(
self._agent_discrete_pos,
self._high_level_action[0],
self.map,
)
/ (len(self.map) ** 2 + len(self.map[0]) ** 2) ** 0.5
)
- 1.0,
},
terminateds,
truncateds,
{},
)
# Low-level agent made a move (primitive action).
else:
assert len(action_dict) == 1
# Increment low-level step counter.
self._low_level_steps += 1
target_discrete_pos, low_level_agent = self._high_level_action
low_level_agent = f"low_level_agent_{low_level_agent}"
next_pos = _get_next_pos(action_dict[low_level_agent], self._agent_pos)
# Check if the move ends up in a wall. If so -> Ignore the move and stay
# where we are right now.
if self.map[next_pos[0]][next_pos[1]] != "W":
self._agent_pos = next_pos
# Check if the agent has reached the global goal state.
if self.map[self._agent_pos[0]][self._agent_pos[1]] == "G":
rewards = {
"high_level_agent": 10.0,
# +1.0 if the goal position was also the target position for the
# low level agent.
low_level_agent: float(
self._agent_discrete_pos == target_discrete_pos
),
}
terminateds["__all__"] = True
return (
{"high_level_agent": self._agent_discrete_pos},
rewards,
terminateds,
truncateds,
{},
)
# Low-level agent has reached its target location (given by the high-level):
# - Hand back control to high-level agent.
# - Reward low level agent and high-level agent with small rewards.
elif self._agent_discrete_pos == target_discrete_pos:
self._num_targets_reached += 1
rewards = {
"high_level_agent": 1.0,
low_level_agent: 1.0,
}
return (
{"high_level_agent": self._agent_discrete_pos},
rewards,
terminateds,
truncateds,
{},
)
# Low-level agent has not reached anything.
else:
# Small step penalty for low-level agent.
rewards = {low_level_agent: -0.01}
# Reached time budget -> Hand back control to high level agent.
if self._low_level_steps >= self.max_steps_low_level:
rewards["high_level_agent"] = -0.01
return (
{"high_level_agent": self._agent_discrete_pos},
rewards,
terminateds,
truncateds,
{},
)
else:
return (
{
low_level_agent: (
self._agent_discrete_pos, # current
target_discrete_pos, # target
),
},
rewards,
terminateds,
truncateds,
{},
)
@property
def _agent_discrete_pos(self):
x = self._agent_pos[0]
y = self._agent_pos[1]
# discrete position = row idx * columns + col idx
return x * len(self.map[0]) + y
@staticmethod
def eucl_dist(pos1, pos2, map):
x1, y1 = pos1 % len(map[0]), pos1 // len(map)
x2, y2 = pos2 % len(map[0]), pos2 // len(map)
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5
def _get_next_pos(action, pos):
x, y = pos
# Up.
if action == 0:
return x - 1, y
# Down.
elif action == 1:
return x + 1, y
# Left.
elif action == 2:
return x, y - 1
# Right.
else:
return x, y + 1
@@ -0,0 +1,38 @@
import numpy as np
from gymnasium.envs.classic_control import CartPoleEnv
from gymnasium.spaces import Box
class StatelessCartPole(CartPoleEnv):
"""Partially observable variant of the CartPole gym environment.
https://github.com/openai/gym/blob/master/gym/envs/classic_control/
cartpole.py
We delete the x- and angular velocity components of the state, so that it
can only be solved by a memory enhanced model (policy).
"""
def __init__(self, config=None):
super().__init__()
# Fix our observation-space (remove 2 velocity components).
high = np.array(
[
self.x_threshold * 2,
self.theta_threshold_radians * 2,
],
dtype=np.float32,
)
self.observation_space = Box(low=-high, high=high, dtype=np.float32)
def step(self, action):
next_obs, reward, done, truncated, info = super().step(action)
# next_obs is [x-pos, x-veloc, angle, angle-veloc]
return np.array([next_obs[0], next_obs[2]]), reward, done, truncated, info
def reset(self, *, seed=None, options=None):
init_obs, init_info = super().reset(seed=seed, options=options)
# init_obs is [x-pos, x-veloc, angle, angle-veloc]
return np.array([init_obs[0], init_obs[2]]), init_info
@@ -0,0 +1,34 @@
import numpy as np
from gymnasium.envs.classic_control import PendulumEnv
from gymnasium.spaces import Box
class StatelessPendulum(PendulumEnv):
"""Partially observable variant of the Pendulum gym environment.
https://github.com/Farama-Foundation/Gymnasium/blob/main/gymnasium/envs/
classic_control/pendulum.py
We delete the angular velocity component of the state, so that it
can only be solved by a memory enhanced model (policy).
"""
def __init__(self, config=None):
config = config or {}
g = config.get("g", 10.0)
super().__init__(g=g)
# Fix our observation-space (remove angular velocity component).
high = np.array([1.0, 1.0], dtype=np.float32)
self.observation_space = Box(low=-high, high=high, dtype=np.float32)
def step(self, action):
next_obs, reward, done, truncated, info = super().step(action)
# next_obs is [cos(theta), sin(theta), theta-dot (angular velocity)]
return next_obs[:-1], reward, done, truncated, info
def reset(self, *, seed=None, options=None):
init_obs, init_info = super().reset(seed=seed, options=options)
# init_obs is [cos(theta), sin(theta), theta-dot (angular velocity)]
return init_obs[:-1], init_info
@@ -0,0 +1,49 @@
import logging
import gymnasium as gym
logger = logging.getLogger(__name__)
class TenStepErrorEnv(gym.Env):
"""An environment that lets you sample 1 episode and raises an error during the next one.
The expectation to the env runner is that it will sample one episode and recreate the env
to sample the second one.
"""
def __init__(self, config):
super().__init__()
self.step_count = 0
self.last_eps_errored = False
self.observation_space = gym.spaces.Box(low=0, high=1, shape=(1,))
self.action_space = gym.spaces.Box(low=0, high=1, shape=(1,))
def reset(self, seed=None, options=None):
self.step_count = 0
return self.observation_space.sample(), {
"last_eps_errored": self.last_eps_errored
}
def step(self, action):
self.step_count += 1
if self.step_count == 10:
if not self.last_eps_errored:
self.last_eps_errored = True
return (
self.observation_space.sample(),
0.0,
True,
False,
{"last_eps_errored": False},
)
else:
raise Exception("Test error")
return (
self.observation_space.sample(),
0.0,
False,
False,
{"last_eps_errored": self.last_eps_errored},
)
@@ -0,0 +1,62 @@
from typing import Type
import gymnasium as gym
class ActionTransform(gym.ActionWrapper):
def __init__(self, env, low, high):
super().__init__(env)
self._low = low
self._high = high
self.action_space = type(env.action_space)(
self._low, self._high, env.action_space.shape, env.action_space.dtype
)
def action(self, action):
return (action - self._low) / (self._high - self._low) * (
self.env.action_space.high - self.env.action_space.low
) + self.env.action_space.low
def transform_action_space(env_name_or_creator) -> Type[gym.Env]:
"""Wrapper for gym.Envs to have their action space transformed.
Args:
env_name_or_creator (Union[str, Callable[]]: String specifier or
env_maker function.
Returns:
New transformed_action_space_env function that returns an environment
wrapped by the ActionTransform wrapper. The constructor takes a
config dict with `_low` and `_high` keys specifying the new action
range (default -1.0 to 1.0). The reset of the config dict will be
passed on to the underlying/wrapped env's constructor.
.. testcode::
:skipif: True
# By gym string:
pendulum_300_to_500_cls = transform_action_space("Pendulum-v1")
# Create a transformed pendulum env.
pendulum_300_to_500 = pendulum_300_to_500_cls({"_low": -15.0})
pendulum_300_to_500.action_space
.. testoutput::
gym.spaces.Box(-15.0, 1.0, (1, ), "float32")
"""
def transformed_action_space_env(config):
if isinstance(env_name_or_creator, str):
inner_env = gym.make(env_name_or_creator)
else:
inner_env = env_name_or_creator(config)
_low = config.pop("low", -1.0)
_high = config.pop("high", 1.0)
env = ActionTransform(inner_env, _low, _high)
return env
return transformed_action_space_env
TransformedActionPendulum = transform_action_space("Pendulum-v1")
@@ -0,0 +1,8 @@
syntax = "proto3";
message CartPoleObservation {
double x_pos = 1;
double x_veloc = 2;
double angle_pos = 3;
double angle_veloc = 4;
}
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: cartpole_observations.proto
# Protobuf Python Version: 5.26.1
"""Generated protocol buffer code."""
from google.protobuf import (
descriptor as _descriptor,
descriptor_pool as _descriptor_pool,
symbol_database as _symbol_database,
)
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
b'\n\x1b\x63\x61rtpole_observations.proto"]\n\x13\x43\x61rtPoleObservation\x12\r\n\x05x_pos\x18\x01 \x01(\x01\x12\x0f\n\x07x_veloc\x18\x02 \x01(\x01\x12\x11\n\tangle_pos\x18\x03 \x01(\x01\x12\x13\n\x0b\x61ngle_veloc\x18\x04 \x01(\x01\x62\x06proto3' # noqa
)
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(
DESCRIPTOR, "cartpole_observations_proto", _globals
)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals["_CARTPOLEOBSERVATION"]._serialized_start = 31
_globals["_CARTPOLEOBSERVATION"]._serialized_end = 124
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,126 @@
import pickle
import socket
import time
import gymnasium as gym
import numpy as np
from ray.rllib.core import (
COMPONENT_RL_MODULE,
Columns,
)
from ray.rllib.env.external.rllink import (
RLlink,
get_rllink_message,
send_rllink_message,
)
from ray.rllib.env.single_agent_episode import SingleAgentEpisode
from ray.rllib.utils.framework import try_import_torch
from ray.rllib.utils.numpy import softmax
torch, _ = try_import_torch()
def _dummy_external_client(port: int = 5556):
"""A dummy client that runs CartPole and acts as a testing external env."""
def _set_state(msg_body, rl_module):
rl_module.set_state(msg_body[COMPONENT_RL_MODULE])
# return msg_body[WEIGHTS_SEQ_NO]
# Connect to server.
while True:
try:
print(f"Trying to connect to localhost:{port} ...")
sock_ = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock_.connect(("localhost", port))
break
except ConnectionRefusedError:
time.sleep(5)
# Send ping-pong.
send_rllink_message(sock_, {"type": RLlink.PING.name})
msg_type, msg_body = get_rllink_message(sock_)
assert msg_type == RLlink.PONG
# Request config.
send_rllink_message(sock_, {"type": RLlink.GET_CONFIG.name})
msg_type, msg_body = get_rllink_message(sock_)
assert msg_type == RLlink.SET_CONFIG
config = pickle.loads(msg_body["config"])
# Create the RLModule.
rl_module = config.get_rl_module_spec().build()
# Request state/weights.
send_rllink_message(sock_, {"type": RLlink.GET_STATE.name})
msg_type, msg_body = get_rllink_message(sock_)
assert msg_type == RLlink.SET_STATE
_set_state(msg_body["state"], rl_module)
env_steps_per_sample = config.get_rollout_fragment_length()
# Start actual env loop.
env = gym.make("CartPole-v1")
obs, _ = env.reset()
episode = SingleAgentEpisode(observations=[obs])
episodes = [episode]
while True:
# Perform action inference using the RLModule.
logits = rl_module.forward_exploration(
batch={
Columns.OBS: torch.tensor(np.array([obs], np.float32)),
}
)[Columns.ACTION_DIST_INPUTS][
0
].numpy() # [0]=batch size 1
# Stochastic sample.
action_probs = softmax(logits)
action = int(np.random.choice(list(range(env.action_space.n)), p=action_probs))
logp = float(np.log(action_probs[action]))
# Perform the env step.
obs, reward, terminated, truncated, _ = env.step(action)
# Collect step data.
episode.add_env_step(
action=action,
reward=reward,
observation=obs,
terminated=terminated,
truncated=truncated,
extra_model_outputs={
Columns.ACTION_DIST_INPUTS: logits,
Columns.ACTION_LOGP: logp,
},
)
# We collected enough samples -> Send them to server.
if sum(map(len, episodes)) == env_steps_per_sample:
# Send the data to the server.
send_rllink_message(
sock_,
{
"type": RLlink.EPISODES_AND_GET_STATE.name,
"episodes": [e.get_state() for e in episodes],
"timesteps": env_steps_per_sample,
},
)
# We are forced to sample on-policy. Have to wait for a response
# with the state (weights) in it.
msg_type, msg_body = get_rllink_message(sock_)
assert msg_type == RLlink.SET_STATE
_set_state(msg_body["state"], rl_module)
episodes = []
if not episode.is_done:
episode = episode.cut()
episodes.append(episode)
# If episode is done, reset env and create a new episode.
if episode.is_done:
obs, _ = env.reset()
episode = SingleAgentEpisode(observations=[obs])
episodes.append(episode)
@@ -0,0 +1,160 @@
import logging
import random
import gymnasium as gym
from gymnasium.spaces import Box, Discrete, Tuple
from ray.rllib.env import MultiAgentEnv
logger = logging.getLogger(__name__)
# Agent has to traverse the maze from the starting position S -> F
# Observation space [x_pos, y_pos, wind_direction]
# Action space: stay still OR move in current wind direction
MAP_DATA = """
#########
#S #
####### #
# #
# #
####### #
#F #
#########"""
class WindyMazeEnv(gym.Env):
def __init__(self, env_config):
self.map = [m for m in MAP_DATA.split("\n") if m]
self.x_dim = len(self.map)
self.y_dim = len(self.map[0])
logger.info("Loaded map {} {}".format(self.x_dim, self.y_dim))
for x in range(self.x_dim):
for y in range(self.y_dim):
if self.map[x][y] == "S":
self.start_pos = (x, y)
elif self.map[x][y] == "F":
self.end_pos = (x, y)
logger.info("Start pos {} end pos {}".format(self.start_pos, self.end_pos))
self.observation_space = Tuple(
[
Box(0, 100, shape=(2,)), # (x, y)
Discrete(4), # wind direction (N, E, S, W)
]
)
self.action_space = Discrete(2) # whether to move or not
def reset(self, *, seed=None, options=None):
self.wind_direction = random.choice([0, 1, 2, 3])
self.pos = self.start_pos
self.num_steps = 0
return [[self.pos[0], self.pos[1]], self.wind_direction], {}
def step(self, action):
if action == 1:
self.pos = self._get_new_pos(self.pos, self.wind_direction)
self.num_steps += 1
self.wind_direction = random.choice([0, 1, 2, 3])
at_goal = self.pos == self.end_pos
truncated = self.num_steps >= 200
done = at_goal or truncated
return (
[[self.pos[0], self.pos[1]], self.wind_direction],
100 * int(at_goal),
done,
truncated,
{},
)
def _get_new_pos(self, pos, direction):
if direction == 0:
new_pos = (pos[0] - 1, pos[1])
elif direction == 1:
new_pos = (pos[0], pos[1] + 1)
elif direction == 2:
new_pos = (pos[0] + 1, pos[1])
elif direction == 3:
new_pos = (pos[0], pos[1] - 1)
if (
new_pos[0] >= 0
and new_pos[0] < self.x_dim
and new_pos[1] >= 0
and new_pos[1] < self.y_dim
and self.map[new_pos[0]][new_pos[1]] != "#"
):
return new_pos
else:
return pos # did not move
class HierarchicalWindyMazeEnv(MultiAgentEnv):
def __init__(self, env_config):
super().__init__()
self.flat_env = WindyMazeEnv(env_config)
def reset(self, *, seed=None, options=None):
self.cur_obs, infos = self.flat_env.reset()
self.current_goal = None
self.steps_remaining_at_level = None
self.num_high_level_steps = 0
# current low level agent id. This must be unique for each high level
# step since agent ids cannot be reused.
self.low_level_agent_id = "low_level_{}".format(self.num_high_level_steps)
return {
"high_level_agent": self.cur_obs,
}, {"high_level_agent": infos}
def step(self, action_dict):
assert len(action_dict) == 1, action_dict
if "high_level_agent" in action_dict:
return self._high_level_step(action_dict["high_level_agent"])
else:
return self._low_level_step(list(action_dict.values())[0])
def _high_level_step(self, action):
logger.debug("High level agent sets goal")
self.current_goal = action
self.steps_remaining_at_level = 25
self.num_high_level_steps += 1
self.low_level_agent_id = "low_level_{}".format(self.num_high_level_steps)
obs = {self.low_level_agent_id: [self.cur_obs, self.current_goal]}
rew = {self.low_level_agent_id: 0}
done = truncated = {"__all__": False}
return obs, rew, done, truncated, {}
def _low_level_step(self, action):
logger.debug("Low level agent step {}".format(action))
self.steps_remaining_at_level -= 1
cur_pos = tuple(self.cur_obs[0])
goal_pos = self.flat_env._get_new_pos(cur_pos, self.current_goal)
# Step in the actual env
f_obs, f_rew, f_terminated, f_truncated, info = self.flat_env.step(action)
new_pos = tuple(f_obs[0])
self.cur_obs = f_obs
# Calculate low-level agent observation and reward
obs = {self.low_level_agent_id: [f_obs, self.current_goal]}
if new_pos != cur_pos:
if new_pos == goal_pos:
rew = {self.low_level_agent_id: 1}
else:
rew = {self.low_level_agent_id: -1}
else:
rew = {self.low_level_agent_id: 0}
# Handle env termination & transitions back to higher level.
terminated = {"__all__": False}
truncated = {"__all__": False}
if f_terminated or f_truncated:
terminated["__all__"] = f_terminated
truncated["__all__"] = f_truncated
logger.debug("high level final reward {}".format(f_rew))
rew["high_level_agent"] = f_rew
obs["high_level_agent"] = f_obs
elif self.steps_remaining_at_level == 0:
terminated[self.low_level_agent_id] = True
truncated[self.low_level_agent_id] = False
rew["high_level_agent"] = 0
obs["high_level_agent"] = f_obs
return obs, rew, terminated, truncated, {self.low_level_agent_id: info}