Files
2026-07-13 13:17:40 +08:00

778 lines
29 KiB
Python

import argparse
import json
import logging
import os
import re
import time
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Optional,
Type,
Union,
)
import numpy as np
import ray
from ray import tune
from ray.air.integrations.wandb import WANDB_ENV_VAR, WandbLoggerCallback
from ray.rllib.utils.metrics import (
ENV_RUNNER_RESULTS,
EPISODE_RETURN_MEAN,
EVALUATION_RESULTS,
NUM_ENV_STEPS_SAMPLED_LIFETIME,
)
from ray.rllib.utils.serialization import convert_numpy_to_python_primitives
from ray.rllib.utils.typing import ResultDict
from ray.tune.result import TRAINING_ITERATION
if TYPE_CHECKING:
from ray.rllib.algorithms import AlgorithmConfig
logger = logging.getLogger(__name__)
def add_rllib_example_script_args(
parser: Optional[argparse.ArgumentParser] = None,
default_reward: float = 100.0,
default_iters: int = 200,
default_timesteps: int = 100000,
) -> argparse.ArgumentParser:
"""Adds RLlib-typical (and common) examples scripts command line args to a parser.
TODO (sven): This function should be used by most of our examples scripts, which
already mostly have this logic in them (but written out).
Args:
parser: The parser to add the arguments to. If None, create a new one.
default_reward: The default value for the --stop-reward option.
default_iters: The default value for the --stop-iters option.
default_timesteps: The default value for the --stop-timesteps option.
Returns:
The altered (or newly created) parser object.
"""
if parser is None:
parser = argparse.ArgumentParser()
# Algo and Algo config options.
parser.add_argument(
"--algo", type=str, default="PPO", help="The RLlib-registered algorithm to use."
)
parser.add_argument(
"--framework",
choices=["tf", "tf2", "torch"],
default="torch",
help="The DL framework specifier.",
)
parser.add_argument(
"--env",
type=str,
default=None,
help="The gym.Env identifier to run the experiment with.",
)
parser.add_argument(
"--num-env-runners",
type=int,
default=None,
help="The number of (remote) EnvRunners to use for the experiment.",
)
parser.add_argument(
"--num-envs-per-env-runner",
type=int,
default=None,
help="The number of (vectorized) environments per EnvRunner. Note that "
"this is identical to the batch size for (inference) action computations.",
)
parser.add_argument(
"--num-agents",
type=int,
default=0,
help="If 0 (default), will run as single-agent. If > 0, will run as "
"multi-agent with the environment simply cloned n times and each agent acting "
"independently at every single timestep. The overall reward for this "
"experiment is then the sum over all individual agents' rewards.",
)
# Evaluation options.
parser.add_argument(
"--evaluation-num-env-runners",
type=int,
default=0,
help="The number of evaluation (remote) EnvRunners to use for the experiment.",
)
parser.add_argument(
"--evaluation-interval",
type=int,
default=0,
help="Every how many iterations to run one round of evaluation. "
"Use 0 (default) to disable evaluation.",
)
parser.add_argument(
"--evaluation-duration",
type=lambda v: v if v == "auto" else int(v),
default=10,
help="The number of evaluation units to run each evaluation round. "
"Use `--evaluation-duration-unit` to count either in 'episodes' "
"or 'timesteps'. If 'auto', will run as many as possible during train pass ("
"`--evaluation-parallel-to-training` must be set then).",
)
parser.add_argument(
"--evaluation-duration-unit",
type=str,
default="episodes",
choices=["episodes", "timesteps"],
help="The evaluation duration unit to count by. One of 'episodes' or "
"'timesteps'. This unit will be run `--evaluation-duration` times in each "
"evaluation round. If `--evaluation-duration=auto`, this setting does not "
"matter.",
)
parser.add_argument(
"--evaluation-parallel-to-training",
action="store_true",
help="Whether to run evaluation parallel to training. This might help speed up "
"your overall iteration time. Be aware that when using this option, your "
"reported evaluation results are referring to one iteration before the current "
"one.",
)
# RLlib logging options.
parser.add_argument(
"--output",
type=str,
default=None,
help="The output directory to write trajectories to, which are collected by "
"the algo's EnvRunners.",
)
parser.add_argument(
"--log-level",
type=str,
default=None, # None -> use default
choices=["INFO", "DEBUG", "WARN", "ERROR"],
help="The log-level to be used by the RLlib logger.",
)
# tune.Tuner options.
parser.add_argument(
"--no-tune",
action="store_true",
help="Whether to NOT use tune.Tuner(), but rather a simple for-loop calling "
"`algo.train()` repeatedly until one of the stop criteria is met.",
)
parser.add_argument(
"--num-samples",
type=int,
default=1,
help="How many (tune.Tuner.fit()) experiments to execute - if possible in "
"parallel.",
)
parser.add_argument(
"--max-concurrent-trials",
type=int,
default=None,
help="How many (tune.Tuner) trials to run concurrently.",
)
parser.add_argument(
"--verbose",
type=int,
default=2,
help="The verbosity level for the `tune.Tuner()` running the experiment.",
)
parser.add_argument(
"--checkpoint-freq",
type=int,
default=0,
help=(
"The frequency (in training iterations) with which to create checkpoints. "
"Note that if --wandb-key is provided, all checkpoints will "
"automatically be uploaded to WandB."
),
)
parser.add_argument(
"--checkpoint-at-end",
action="store_true",
help=(
"Whether to create a checkpoint at the very end of the experiment. "
"Note that if --wandb-key is provided, all checkpoints will "
"automatically be uploaded to WandB."
),
)
parser.add_argument(
"--tune-max-report-freq",
type=int,
default=5, # tune default to 5
help="The frequency (in seconds) at which to log the training performance.",
)
# WandB logging options.
parser.add_argument(
"--wandb-key",
type=str,
default=None,
help="The WandB API key to use for uploading results.",
)
parser.add_argument(
"--wandb-project",
type=str,
default=None,
help="The WandB project name to use.",
)
parser.add_argument(
"--wandb-run-name",
type=str,
default=None,
help="The WandB run name to use.",
)
# Experiment stopping and testing criteria.
parser.add_argument(
"--stop-reward",
type=float,
default=default_reward,
help="Reward at which the script should stop training.",
)
parser.add_argument(
"--stop-iters",
type=int,
default=default_iters,
help="The number of iterations to train.",
)
parser.add_argument(
"--stop-timesteps",
type=int,
default=default_timesteps,
help="The number of (environment sampling) timesteps to train.",
)
parser.add_argument(
"--as-test",
action="store_true",
help="Whether this script should be run as a test. If set, --stop-reward must "
"be achieved within --stop-timesteps AND --stop-iters, otherwise this "
"script will throw an exception at the end.",
)
parser.add_argument(
"--as-release-test",
action="store_true",
help="Whether this script should be run as a release test. If set, "
"all that applies to the --as-test option is true, plus, a short JSON summary "
"will be written into a results file whose location is given by the ENV "
"variable `TEST_OUTPUT_JSON`.",
)
# Learner scaling options.
parser.add_argument(
"--num-learners",
type=int,
default=None,
help="The number of Learners to use. If `None`, use the algorithm's default "
"value.",
)
parser.add_argument(
"--num-cpus-per-learner",
type=float,
default=None,
help="The number of CPUs per Learner to use. If `None`, use the algorithm's "
"default value.",
)
parser.add_argument(
"--num-gpus-per-learner",
type=float,
default=None,
help="The number of GPUs per Learner to use. If `None` and there are enough "
"GPUs for all required Learners (--num-learners), use a value of 1, "
"otherwise 0.",
)
parser.add_argument(
"--num-aggregator-actors-per-learner",
type=int,
default=None,
help="The number of Aggregator actors to use per Learner. If `None`, use the "
"algorithm's default value.",
)
# Ray init options.
parser.add_argument("--num-cpus", type=int, default=0)
# Old API stack: config.num_gpus.
parser.add_argument(
"--num-gpus",
type=int,
default=None,
help="The number of GPUs to use (only on the old API stack).",
)
parser.add_argument(
"--old-api-stack",
action="store_true",
help="Run this script on the old API stack of RLlib.",
)
# Deprecated options that are maintained to throw an error when still used.
# Use `--old-api-stack` to disable the new API stack.
parser.add_argument(
"--enable-new-api-stack",
action="store_true",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--local-mode",
action="store_true",
help=argparse.SUPPRESS,
)
return parser
# TODO (simon): Use this function in the `run_rllib_example_experiment` when
# `no_tune` is `True`.
def should_stop(
stop: Dict[str, Any], results: ResultDict, keep_ray_up: bool = False
) -> bool:
"""Checks stopping criteria on `ResultDict`
Args:
stop: Dictionary of stopping criteria. Each criterion is a mapping of
a metric in the `ResultDict` of the algorithm to a certain criterion.
results: An RLlib `ResultDict` containing all results from a training step.
keep_ray_up: Optionally shutting down the running Ray instance.
Returns: True, if any stopping criterion is fulfilled. Otherwise, False.
"""
for key, threshold in stop.items():
val = results
for k in key.split("/"):
k = k.strip()
# If k exists in the current level, continue down;
# otherwise, set val to None and break out of this inner loop.
if isinstance(val, dict) and k in val:
val = val[k]
else:
val = None
break
# If the key was not found, simply skip to the next criterion.
if val is None:
continue
try:
# Check that val is numeric and meets the threshold.
if not np.isnan(val) and val >= threshold:
print(f"Stop criterion ({key}={threshold}) fulfilled!")
if not keep_ray_up:
ray.shutdown()
return True
except TypeError:
# If val isn't numeric, skip this criterion.
continue
# If none of the criteria are fulfilled, return False.
return False
# TODO (sven): Make this the de-facto, well documented, and unified utility for most of
# our tests:
# - CI (label: "learning_tests")
# - release tests (benchmarks)
# - example scripts
def run_rllib_example_script_experiment(
base_config: "AlgorithmConfig",
args: Optional[argparse.Namespace] = None,
*,
stop: Optional[Dict] = None,
success_metric: Optional[Dict] = None,
trainable: Optional[Type] = None,
tune_callbacks: Optional[List] = None,
keep_config: bool = False,
keep_ray_up: bool = False,
scheduler=None,
progress_reporter=None,
) -> Union[ResultDict, tune.result_grid.ResultGrid]:
"""Given an algorithm config and some command line args, runs an experiment.
There are some constraints on what properties must be defined in `args`.
It should ideally be generated via calling
`args = add_rllib_example_script_args()`, which can be found in this very module
here.
The function sets up an Algorithm object from the given config (altered by the
contents of `args`), then runs the Algorithm via Tune (or manually, if
`args.no_tune` is set to True) using the stopping criteria in `stop`.
At the end of the experiment, if `args.as_test` is True, checks, whether the
Algorithm reached the `success_metric` (if None, use `env_runners/
episode_return_mean` with a minimum value of `args.stop_reward`).
See https://github.com/ray-project/ray/tree/master/rllib/examples for an overview
of all supported command line options.
Args:
base_config: The AlgorithmConfig object to use for this experiment. This base
config will be automatically "extended" based on some of the provided
`args`. For example, `args.num_env_runners` is used to set
`config.num_env_runners`, etc.
args: A argparse.Namespace object, ideally returned by calling
`args = add_rllib_example_script_args()`. It must have the following
properties defined: `stop_iters`, `stop_reward`, `stop_timesteps`,
`no_tune`, `verbose`, `checkpoint_freq`, `as_test`. Optionally, for WandB
logging: `wandb_key`, `wandb_project`, `wandb_run_name`.
stop: An optional dict mapping ResultDict key strings (using "/" in case of
nesting, e.g. "env_runners/episode_return_mean" for referring to
`result_dict['env_runners']['episode_return_mean']` to minimum
values, reaching of which will stop the experiment). Default is:
{
"env_runners/episode_return_mean": args.stop_reward,
"training_iteration": args.stop_iters,
"num_env_steps_sampled_lifetime": args.stop_timesteps,
}
success_metric: Only relevant if `args.as_test` is True.
A dict mapping a single(!) ResultDict key string (using "/" in
case of nesting, e.g. "env_runners/episode_return_mean" for referring
to `result_dict['env_runners']['episode_return_mean']`) to a single(!)
minimum value to be reached in order for the experiment to count as
successful. If `args.as_test` is True AND this `success_metric` is not
reached with the bounds defined by `stop`, will raise an Exception.
trainable: The Trainable subclass to run in the tune.Tuner. If None (default),
use the registered RLlib Algorithm class specified by args.algo.
tune_callbacks: A list of Tune callbacks to configure with the tune.Tuner.
In case `args.wandb_key` is provided, appends a WandB logger to this
list.
keep_config: Set this to True, if you don't want this utility to change the
given `base_config` in any way and leave it as-is. This is helpful
for those example scripts which demonstrate how to set config settings
that are otherwise taken care of automatically in this function (e.g.
`num_env_runners`).
Returns:
The last ResultDict from a --no-tune run OR the tune.Tuner.fit()
results.
"""
if args is None:
parser = add_rllib_example_script_args()
args = parser.parse_args()
# Deprecated args.
if args.enable_new_api_stack:
raise ValueError(
"`--enable-new-api-stack` flag no longer supported (it's the default "
"behavior now)! To switch back to the old API stack on your scripts, use "
"the `--old-api-stack` flag."
)
if args.local_mode:
raise ValueError("`--local-mode` is no longer supported.")
# If run --as-release-test, --as-test must also be set.
if args.as_release_test:
args.as_test = True
if args.as_test:
args.verbose = 1
args.tune_max_report_freq = 30
# Initialize Ray.
ray.init(
num_cpus=args.num_cpus or None,
ignore_reinit_error=True,
)
# Define one or more stopping criteria.
if stop is None:
stop = {
f"{ENV_RUNNER_RESULTS}/{EPISODE_RETURN_MEAN}": args.stop_reward,
f"{ENV_RUNNER_RESULTS}/{NUM_ENV_STEPS_SAMPLED_LIFETIME}": (
args.stop_timesteps
),
TRAINING_ITERATION: args.stop_iters,
}
config = base_config
# Enhance the `base_config`, based on provided `args`.
if not keep_config:
# Set the framework.
config.framework(args.framework)
# Add an env specifier (only if not already set in config)?
if args.env is not None and config.env is None:
config.environment(args.env)
# Disable the new API stack?
if args.old_api_stack:
config.api_stack(
enable_rl_module_and_learner=False,
enable_env_runner_and_connector_v2=False,
)
# Define EnvRunner scaling and behavior.
if args.num_env_runners is not None:
config.env_runners(num_env_runners=args.num_env_runners)
if args.num_envs_per_env_runner is not None:
config.env_runners(num_envs_per_env_runner=args.num_envs_per_env_runner)
# Define compute resources used automatically (only using the --num-learners
# and --num-gpus-per-learner args).
# New stack.
if config.enable_rl_module_and_learner:
if args.num_gpus is not None and args.num_gpus > 0:
raise ValueError(
"--num-gpus is not supported on the new API stack! To train on "
"GPUs, use the command line options `--num-gpus-per-learner=1` and "
"`--num-learners=[your number of available GPUs]`, instead."
)
# Do we have GPUs available in the cluster?
num_gpus_available = ray.cluster_resources().get("GPU", 0)
# Number of actual Learner instances (including the local Learner if
# `num_learners=0`).
num_actual_learners = (
args.num_learners
if args.num_learners is not None
else config.num_learners
) or 1 # 1: There is always a local Learner, if num_learners=0.
# How many were hard-requested by the user
# (through explicit `--num-gpus-per-learner >= 1`).
num_gpus_requested = (args.num_gpus_per_learner or 0) * num_actual_learners
# Number of GPUs needed, if `num_gpus_per_learner=None` (auto).
num_gpus_needed_if_available = (
args.num_gpus_per_learner
if args.num_gpus_per_learner is not None
else 1
) * num_actual_learners
# Define compute resources used.
config.resources(num_gpus=0) # @OldAPIStack
if args.num_learners is not None:
config.learners(num_learners=args.num_learners)
# User wants to use aggregator actors per Learner.
if args.num_aggregator_actors_per_learner is not None:
config.learners(
num_aggregator_actors_per_learner=(
args.num_aggregator_actors_per_learner
)
)
# User wants to use GPUs if available, but doesn't hard-require them.
if args.num_gpus_per_learner is None:
if num_gpus_available >= num_gpus_needed_if_available:
config.learners(num_gpus_per_learner=1)
else:
config.learners(num_gpus_per_learner=0)
# User hard-requires n GPUs, but they are not available -> Error.
elif num_gpus_available < num_gpus_requested:
raise ValueError(
"You are running your script with --num-learners="
f"{args.num_learners} and --num-gpus-per-learner="
f"{args.num_gpus_per_learner}, but your cluster only has "
f"{num_gpus_available} GPUs!"
)
# All required GPUs are available -> Use them.
else:
config.learners(num_gpus_per_learner=args.num_gpus_per_learner)
# Set CPUs per Learner.
if args.num_cpus_per_learner is not None:
config.learners(num_cpus_per_learner=args.num_cpus_per_learner)
# Old stack (override only if arg was provided by user).
elif args.num_gpus is not None:
config.resources(num_gpus=args.num_gpus)
# Evaluation setup.
if args.evaluation_interval > 0:
config.evaluation(
evaluation_num_env_runners=args.evaluation_num_env_runners,
evaluation_interval=args.evaluation_interval,
evaluation_duration=args.evaluation_duration,
evaluation_duration_unit=args.evaluation_duration_unit,
evaluation_parallel_to_training=args.evaluation_parallel_to_training,
)
# Set the log-level (if applicable).
if args.log_level is not None:
config.debugging(log_level=args.log_level)
# Set the output dir (if applicable).
if args.output is not None:
config.offline_data(output=args.output)
# Run the experiment w/o Tune (directly operate on the RLlib Algorithm object).
if args.no_tune:
assert not args.as_test and not args.as_release_test
algo = config.build()
for i in range(stop.get(TRAINING_ITERATION, args.stop_iters)):
results = algo.train()
if ENV_RUNNER_RESULTS in results:
mean_return = results[ENV_RUNNER_RESULTS].get(
EPISODE_RETURN_MEAN, np.nan
)
print(f"iter={i} R={mean_return}", end="")
if (
EVALUATION_RESULTS in results
and ENV_RUNNER_RESULTS in results[EVALUATION_RESULTS]
):
Reval = results[EVALUATION_RESULTS][ENV_RUNNER_RESULTS][
EPISODE_RETURN_MEAN
]
print(f" R(eval)={Reval}", end="")
print()
for key, threshold in stop.items():
val = results
for k in key.split("/"):
try:
val = val[k]
except KeyError:
val = None
break
if val is not None and not np.isnan(val) and val >= threshold:
print(f"Stop criterion ({key}={threshold}) fulfilled!")
if not keep_ray_up:
ray.shutdown()
return results
if not keep_ray_up:
ray.shutdown()
return results
# Run the experiment using Ray Tune.
# Log results using WandB.
tune_callbacks = tune_callbacks or []
if hasattr(args, "wandb_key") and (
args.wandb_key is not None or WANDB_ENV_VAR in os.environ
):
wandb_key = args.wandb_key or os.environ[WANDB_ENV_VAR]
project = args.wandb_project or (
args.algo.lower() + "-" + re.sub("\\W+", "-", str(config.env).lower())
)
tune_callbacks.append(
WandbLoggerCallback(
api_key=wandb_key,
project=project,
upload_checkpoints=True,
**({"name": args.wandb_run_name} if args.wandb_run_name else {}),
)
)
# Autoconfigure a tune.CLIReporter (to log the results to the console).
# Use better ProgressReporter for multi-agent cases: List individual policy rewards.
if progress_reporter is None:
if args.num_agents == 0:
progress_reporter = tune.CLIReporter(
metric_columns={
TRAINING_ITERATION: "iter",
"time_total_s": "total time (s)",
NUM_ENV_STEPS_SAMPLED_LIFETIME: "ts",
f"{ENV_RUNNER_RESULTS}/{EPISODE_RETURN_MEAN}": "episode return mean",
},
max_report_frequency=args.tune_max_report_freq,
)
else:
progress_reporter = tune.CLIReporter(
metric_columns={
**{
TRAINING_ITERATION: "iter",
"time_total_s": "total time (s)",
NUM_ENV_STEPS_SAMPLED_LIFETIME: "ts",
f"{ENV_RUNNER_RESULTS}/{EPISODE_RETURN_MEAN}": "combined return",
},
**{
(
f"{ENV_RUNNER_RESULTS}/module_episode_returns_mean/{pid}"
): f"return {pid}"
for pid in config.policies
},
},
max_report_frequency=args.tune_max_report_freq,
)
# Force Tuner to use old progress output as the new one silently ignores our custom
# `tune.CLIReporter`.
os.environ["RAY_AIR_NEW_OUTPUT"] = "0"
# Run the actual experiment (using Tune).
start_time = time.time()
results = tune.Tuner(
trainable or config.algo_class,
param_space=config,
run_config=tune.RunConfig(
failure_config=tune.FailureConfig(max_failures=0, fail_fast="raise"),
stop=stop,
verbose=args.verbose,
callbacks=tune_callbacks,
checkpoint_config=tune.CheckpointConfig(
checkpoint_frequency=args.checkpoint_freq,
checkpoint_at_end=args.checkpoint_at_end,
),
progress_reporter=progress_reporter,
),
tune_config=tune.TuneConfig(
num_samples=args.num_samples,
max_concurrent_trials=args.max_concurrent_trials,
scheduler=scheduler,
),
).fit()
time_taken = time.time() - start_time
if not keep_ray_up:
ray.shutdown()
# If run as a test, check whether we reached the specified success criteria.
test_passed = False
if args.as_test:
# Success metric not provided, try extracting it from `stop`.
if success_metric is None:
for try_it in [
f"{EVALUATION_RESULTS}/{ENV_RUNNER_RESULTS}/{EPISODE_RETURN_MEAN}",
f"{ENV_RUNNER_RESULTS}/{EPISODE_RETURN_MEAN}",
]:
if try_it in stop:
success_metric = {try_it: stop[try_it]}
break
if success_metric is None:
success_metric = {
f"{ENV_RUNNER_RESULTS}/{EPISODE_RETURN_MEAN}": args.stop_reward,
}
# TODO (sven): Make this work for more than one metric (AND-logic?).
# Get maximum value of `metric` over all trials
# (check if at least one trial achieved some learning, not just the final one).
success_metric_key, success_metric_value = next(iter(success_metric.items()))
best_value = max(
row[success_metric_key] for _, row in results.get_dataframe().iterrows()
)
if best_value >= success_metric_value:
test_passed = True
print(f"`{success_metric_key}` of {success_metric_value} reached! ok")
if args.as_release_test:
trial = results._experiment_analysis.trials[0]
stats = trial.last_result
stats.pop("config", None)
json_summary = {
"time_taken": float(time_taken),
"trial_states": [trial.status],
"last_update": float(time.time()),
"stats": convert_numpy_to_python_primitives(stats),
"passed": [test_passed],
"not_passed": [not test_passed],
"failures": {str(trial): 1} if not test_passed else {},
}
filename = os.environ.get("TEST_OUTPUT_JSON", "/tmp/learning_test.json")
with open(filename, "wt") as f:
json.dump(json_summary, f)
if not test_passed:
if args.as_release_test:
print(
f"`{success_metric_key}` of {success_metric_value} not reached! Best value reached is {best_value}"
)
else:
raise ValueError(
f"`{success_metric_key}` of {success_metric_value} not reached! Best value reached is {best_value}"
)
return results