Files
ray-project--ray/rllib/examples/envs/classes/multi_agent/footsies/utils.py
T
2026-07-13 13:17:40 +08:00

384 lines
14 KiB
Python

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()}")