From 6bc7f0bac6c47ee45b68aa7cef2ff32dbd0ece6b Mon Sep 17 00:00:00 2001 From: Default Date: Tue, 12 Apr 2022 11:21:19 +0800 Subject: [PATCH 01/45] PPO, SAC, DDPG passed --- examples/gym/rl/__init__.py | 16 ++++ examples/gym/rl/algorithms/__init__.py | 0 examples/gym/rl/algorithms/ddpg.py | 76 +++++++++++++++ examples/gym/rl/algorithms/ppo.py | 88 ++++++++++++++++++ examples/gym/rl/algorithms/random.py | 19 ++++ examples/gym/rl/algorithms/sac.py | 93 +++++++++++++++++++ examples/gym/rl/algorithms/utils.py | 13 +++ examples/gym/rl/callbacks.py | 23 +++++ examples/gym/rl/config.py | 1 + examples/gym/rl/env_helper.py | 23 +++++ examples/gym/rl/env_sampler.py | 43 +++++++++ examples/gym/rl/policy_trainer.py | 44 +++++++++ maro/simulator/scenarios/gym/__init__.py | 0 .../scenarios/gym/business_engine.py | 90 ++++++++++++++++++ .../scenarios/gym/test_business_engine.py | 22 +++++ 15 files changed, 551 insertions(+) create mode 100644 examples/gym/rl/__init__.py create mode 100644 examples/gym/rl/algorithms/__init__.py create mode 100644 examples/gym/rl/algorithms/ddpg.py create mode 100644 examples/gym/rl/algorithms/ppo.py create mode 100644 examples/gym/rl/algorithms/random.py create mode 100644 examples/gym/rl/algorithms/sac.py create mode 100644 examples/gym/rl/algorithms/utils.py create mode 100644 examples/gym/rl/callbacks.py create mode 100644 examples/gym/rl/config.py create mode 100644 examples/gym/rl/env_helper.py create mode 100644 examples/gym/rl/env_sampler.py create mode 100644 examples/gym/rl/policy_trainer.py create mode 100644 maro/simulator/scenarios/gym/__init__.py create mode 100644 maro/simulator/scenarios/gym/business_engine.py create mode 100644 maro/simulator/scenarios/gym/test_business_engine.py diff --git a/examples/gym/rl/__init__.py b/examples/gym/rl/__init__.py new file mode 100644 index 000000000..af4ce720f --- /dev/null +++ b/examples/gym/rl/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from .callbacks import post_collect, post_evaluate +from .env_sampler import agent2policy, env_sampler_creator +from .policy_trainer import device_mapping, policy_creator, trainer_creator + +__all__ = [ + "agent2policy", + "device_mapping", + "env_sampler_creator", + "policy_creator", + "post_collect", + "post_evaluate", + "trainer_creator", +] diff --git a/examples/gym/rl/algorithms/__init__.py b/examples/gym/rl/algorithms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/gym/rl/algorithms/ddpg.py b/examples/gym/rl/algorithms/ddpg.py new file mode 100644 index 000000000..5053660ff --- /dev/null +++ b/examples/gym/rl/algorithms/ddpg.py @@ -0,0 +1,76 @@ +from typing import Dict, Tuple + +import torch +from torch.optim import Adam + +from examples.gym.rl.algorithms.utils import mlp +from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim +from maro.rl.model import ContinuousDDPGNet, ContinuousQNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import DDPGParams, DDPGTrainer + +actor_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, +} +critic_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, +} +actor_learning_rate = 1e-3 +critic_learning_rate = 1e-3 + + +class MyActorNet(ContinuousDDPGNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + self._fc = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + output_activation=torch.nn.Tanh, + ) + self._limit = gym_env.action_space.high[0] + self._optim = Adam(self._fc.parameters(), lr=actor_learning_rate) + + def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: + actions = self._limit * self._fc(states.float()) + actions += 0.1 * torch.rand(actions.shape) + return torch.clamp(actions, -self._limit, self._limit) + + +class MyCriticNet(ContinuousQNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + self._critic = mlp( + [state_dim + action_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + return self._critic(torch.cat([states.float(), actions], dim=1)).reshape(-1) + + +def get_policy(name: str) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyActorNet(gym_state_dim, gym_action_dim), + ) + + +def get_ddpg(name: str) -> DDPGTrainer: + return DDPGTrainer( + name=name, + params=DDPGParams( + replay_memory_capacity=int(1e6), + batch_size=128, + get_q_critic_net_func=lambda: MyCriticNet(gym_state_dim, gym_action_dim), + reward_discount=0.99, + soft_update_coef=0.01, + update_target_every=1, + num_epochs=100, + n_start_train=10000, + ), + ) diff --git a/examples/gym/rl/algorithms/ppo.py b/examples/gym/rl/algorithms/ppo.py new file mode 100644 index 000000000..aff1a7720 --- /dev/null +++ b/examples/gym/rl/algorithms/ppo.py @@ -0,0 +1,88 @@ +from typing import Dict, Tuple + +import numpy as np +import torch +from torch.distributions import Normal +from torch.optim import Adam + +from examples.gym.rl.algorithms.utils import mlp +from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_state_dim +from maro.rl.model import ContinuousACBasedNet, VNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import PPOParams, PPOTrainer + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + + +class MyActorNet(ContinuousACBasedNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.sample() + logps = distribution.log_prob(actions).sum(axis=-1) + return actions, logps + + def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + distribution = self._distribution(states) + logps = distribution.log_prob(actions).sum(axis=-1) + return logps + + def _distribution(self, states: torch.Tensor) -> Normal: + mu = self._mu_net(states.float()) + std = torch.exp(self._log_std) + return Normal(mu, std) + + +class MyCriticNet(VNet): + def __init__(self, state_dim: int) -> None: + super(MyCriticNet, self).__init__(state_dim=state_dim) + self._critic = mlp( + [state_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: + return self._critic(states.float()).squeeze(-1) + + +def get_policy(name: str) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyActorNet(gym_state_dim, gym_action_dim), + ) + + +def get_ppo(name: str) -> PPOTrainer: + return PPOTrainer( + name=name, + params=PPOParams( + get_v_critic_net_func=lambda: MyCriticNet(gym_state_dim), + reward_discount=0.99, + grad_iters=80, + min_logp=None, + lam=0.97, + clip_ratio=0.2, + is_discrete_action=False, + ), + ) diff --git a/examples/gym/rl/algorithms/random.py b/examples/gym/rl/algorithms/random.py new file mode 100644 index 000000000..ed19f0867 --- /dev/null +++ b/examples/gym/rl/algorithms/random.py @@ -0,0 +1,19 @@ +import numpy as np +from gym.spaces import Space + +from maro.rl.policy import RuleBasedPolicy + + +class RandomGymPolicy(RuleBasedPolicy): + def __init__(self, name: str, action_space: Space) -> None: + super(RandomGymPolicy, self).__init__(name=name) + self._action_space = action_space + + def _rule(self, states: np.ndarray) -> object: + n_sample = states.shape[0] + action = [self._action_space.sample() for _ in range(n_sample)] + return action + + +def get_policy(action_space: Space, name: str) -> RuleBasedPolicy: + return RandomGymPolicy(name=name, action_space=action_space) diff --git a/examples/gym/rl/algorithms/sac.py b/examples/gym/rl/algorithms/sac.py new file mode 100644 index 000000000..d31f49329 --- /dev/null +++ b/examples/gym/rl/algorithms/sac.py @@ -0,0 +1,93 @@ +from typing import Dict, Tuple + +import numpy as np +import torch +import torch.nn.functional as F +from torch.distributions import Normal +from torch.optim import Adam + +from examples.gym.rl.algorithms.utils import mlp +from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim +from maro.rl.model import ContinuousQNet, ContinuousSACNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer + +actor_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, +} +critic_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, +} +actor_learning_rate = 1e-3 +critic_learning_rate = 1e-3 + + +class MyActorNet(ContinuousSACNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + self._fc = mlp( + [state_dim] + actor_net_conf["hidden_dims"], + activation=actor_net_conf["activation"], + output_activation=actor_net_conf["activation"], + ) + self._mu_layer = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) + self._log_std_layer = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) + self._limit = gym_env.action_space.high[0] + + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.rsample() + logps = distribution.log_prob(actions).sum(axis=-1) + logps -= (2 * (np.log(2) - actions - F.softplus(-2 * actions))).sum(axis=1) + return self._limit * torch.tanh(actions), logps + + def _distribution(self, states: torch.Tensor) -> Normal: + net_out = self._fc(states.float()) + mu = self._mu_layer(net_out) + log_std = self._log_std_layer(net_out) + log_std = torch.clamp(log_std, -20, 2) # TODO + std = torch.exp(log_std) + return Normal(mu, std) + + +class MyCriticNet(ContinuousQNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + self._critic = mlp( + [state_dim + action_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + return self._critic(torch.cat([states.float(), actions], dim=1)).reshape(-1) + + +def get_policy(name: str) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyActorNet(gym_state_dim, gym_action_dim), + ) + + +def get_sac(name: str) -> SoftActorCriticTrainer: + return SoftActorCriticTrainer( + name=name, + params=SoftActorCriticParams( + replay_memory_capacity=int(1e6), + batch_size=128, + get_q_critic_net_func=lambda: MyCriticNet(gym_state_dim, gym_action_dim), + reward_discount=0.99, + soft_update_coef=0.01, + update_target_every=1, + entropy_coef=0.2, + num_epochs=100, + n_start_train=10000, + ) + ) diff --git a/examples/gym/rl/algorithms/utils.py b/examples/gym/rl/algorithms/utils.py new file mode 100644 index 000000000..14e0369a9 --- /dev/null +++ b/examples/gym/rl/algorithms/utils.py @@ -0,0 +1,13 @@ +from typing import List, Type + +import torch + + +def mlp( + sizes: List[int], activation: Type[torch.nn.Module], output_activation: Type[torch.nn.Module] = torch.nn.Identity +) -> torch.nn.Sequential: + layers = [] + for j in range(len(sizes) - 1): + act = activation if j < len(sizes) - 2 else output_activation + layers += [torch.nn.Linear(sizes[j], sizes[j + 1]), act()] + return torch.nn.Sequential(*layers) diff --git a/examples/gym/rl/callbacks.py b/examples/gym/rl/callbacks.py new file mode 100644 index 000000000..f466e53b4 --- /dev/null +++ b/examples/gym/rl/callbacks.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import numpy as np + + +def _show_info(rewards: list, tag: str) -> None: + print(f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " + f"N segments = {len(rewards)}, " + f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " + f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " + f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " + f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n") + + +def post_collect(info_list: list, ep: int, segment: int) -> None: + rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] + _show_info(rewards, "Collect") + + +def post_evaluate(info_list: list, ep: int) -> None: + rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] + _show_info(rewards, "Evaluate") diff --git a/examples/gym/rl/config.py b/examples/gym/rl/config.py new file mode 100644 index 000000000..e30f48bff --- /dev/null +++ b/examples/gym/rl/config.py @@ -0,0 +1 @@ +algorithm = "sac" diff --git a/examples/gym/rl/env_helper.py b/examples/gym/rl/env_helper.py new file mode 100644 index 000000000..e309dcc88 --- /dev/null +++ b/examples/gym/rl/env_helper.py @@ -0,0 +1,23 @@ +from maro.simulator import Env +from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine + +env_conf = { + "scenario": "gym", + "start_tick": 0, + "durations": 5000, + "options": { + "random_seed": None, + }, +} + +helper_env = Env(**env_conf) +be = helper_env.business_engine +assert isinstance(be, GymBusinessEngine) + +gym_env = be.gym_env +gym_state_dim = gym_env.observation_space.shape[0] +gym_action_dim = gym_env.action_space.shape[0] +# action_lower_bound = gym_env.action_space.low.tolist() +# action_upper_bound = gym_env.action_space.high.tolist() +action_lower_bound = [float('-inf') for _ in range(gym_env.action_space.shape[0])] # TODO +action_upper_bound = [float('inf') for _ in range(gym_env.action_space.shape[0])] # TODO diff --git a/examples/gym/rl/env_sampler.py b/examples/gym/rl/env_sampler.py new file mode 100644 index 000000000..7135b3a92 --- /dev/null +++ b/examples/gym/rl/env_sampler.py @@ -0,0 +1,43 @@ +from typing import Any, Callable, Dict, Optional, Tuple + +import numpy as np + +from maro.rl.policy import AbsPolicy +from maro.rl.rollout import AbsEnvSampler, CacheElement +from maro.simulator import Env +from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine + +from .config import algorithm +from .env_helper import env_conf, helper_env + + +class GymEnvSampler(AbsEnvSampler): + def _get_global_and_agent_state( + self, event: np.ndarray, tick: int = None, + ) -> Tuple[Optional[np.ndarray], Dict[Any, np.ndarray]]: + return None, {0: event} + + def _translate_to_env_action(self, action_dict: Dict[Any, np.ndarray], event: object) -> Dict[Any, object]: + return action_dict # TODO + + def _get_reward(self, env_action_dict: Dict[Any, object], event: object, tick: int) -> Dict[Any, float]: + be = self._env.business_engine + assert isinstance(be, GymBusinessEngine) + return {0: be.get_reward_at_tick(tick)} + + def _post_step(self, cache_element: CacheElement, reward: Dict[Any, float]) -> None: + self._info["env_metric"] = self._env.metrics + + def _post_eval_step(self, cache_element: CacheElement, reward: Dict[Any, float]) -> None: + self._post_step(cache_element, reward) + + +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in helper_env.agent_idx_list} + + +def env_sampler_creator(policy_creator: Dict[str, Callable[[str], AbsPolicy]]) -> GymEnvSampler: + return GymEnvSampler( + get_env=lambda: Env(**env_conf), + policy_creator=policy_creator, + agent2policy=agent2policy, + ) diff --git a/examples/gym/rl/policy_trainer.py b/examples/gym/rl/policy_trainer.py new file mode 100644 index 000000000..b3a353dbc --- /dev/null +++ b/examples/gym/rl/policy_trainer.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from functools import partial + +from maro.rl.training.utils import extract_trainer_name +from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine +from .config import algorithm + +from .env_helper import helper_env + +be = helper_env.business_engine +assert isinstance(be, GymBusinessEngine) + +if algorithm == "random": + from .algorithms.random import get_policy + policy_creator = { + f"{algorithm}_{i}.policy": partial(get_policy, be.gym_env.action_space) for i in helper_env.agent_idx_list + } + trainer_creator = {} +elif algorithm == "ppo": + from .algorithms.ppo import get_policy, get_ppo + policy_creator = { + f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list + } + trainer_creator = {extract_trainer_name(policy_name): get_ppo for policy_name in policy_creator} +elif algorithm == "ddpg": + from .algorithms.ddpg import get_ddpg, get_policy + policy_creator = { + f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list + } + trainer_creator = {extract_trainer_name(policy_name): get_ddpg for policy_name in policy_creator} +elif algorithm == "sac": + from .algorithms.sac import get_policy, get_sac + policy_creator = { + f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list + } + trainer_creator = {extract_trainer_name(policy_name): get_sac for policy_name in policy_creator} +else: + raise ValueError(f"Unsupported algorithm: {algorithm}") + +device_mapping = { + policy_name: "cpu" for policy_name in policy_creator +} diff --git a/maro/simulator/scenarios/gym/__init__.py b/maro/simulator/scenarios/gym/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py new file mode 100644 index 000000000..811b63c43 --- /dev/null +++ b/maro/simulator/scenarios/gym/business_engine.py @@ -0,0 +1,90 @@ +from copy import deepcopy +from typing import List, Optional + +import gym +from maro.backends.frame import FrameBase, SnapshotList + +from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents +from maro.simulator.scenarios import AbsBusinessEngine + + +class GymBusinessEngine(AbsBusinessEngine): + def __init__( + self, event_buffer: EventBuffer, topology: Optional[str], start_tick: int, + max_tick: int, snapshot_resolution: int, max_snapshots: Optional[int], additional_options: dict = None, + ) -> None: + super(GymBusinessEngine, self).__init__( + scenario_name="gym", event_buffer=event_buffer, topology=topology, start_tick=start_tick, + max_tick=max_tick, snapshot_resolution=snapshot_resolution, max_snapshots=max_snapshots, + additional_options=additional_options, + ) + + self._gym_scenario_name = "Walker2d-v2" + self._gym_env = gym.make(self._gym_scenario_name) + self._seed = additional_options.get("random_seed", None) + + self._gym_env.seed(self._seed) + self._last_obs = self._gym_env.reset() + self._is_done = False + self._reward_record = {} + self._info_record = {} + + self._frame: FrameBase = FrameBase() + self._snapshots: SnapshotList = self._frame.snapshots + + self._register_events() + + @property + def gym_env(self) -> gym.Env: + return self._gym_env + + @property + def frame(self) -> FrameBase: + return self._frame + + @property + def snapshots(self) -> SnapshotList: + return self._snapshots + + def _register_events(self) -> None: + self._event_buffer.register_event_handler(MaroEvents.TAKE_ACTION, self._on_action_received) + + def _on_action_received(self, event: CascadeEvent) -> None: + actions = event.payload + assert isinstance(actions, list) + action = actions[0] + + self._last_obs, reward, self._is_done, info = self._gym_env.step(action) + self._reward_record[event.tick] = reward + self._info_record[event.tick] = info + + def step(self, tick: int) -> None: + self._event_buffer.insert_event(self._event_buffer.gen_decision_event(tick, self._last_obs)) + + @property + def configs(self) -> dict: + return {} + + def get_reward_at_tick(self, tick: int) -> float: + return self._reward_record[tick] + + def get_info_at_tick(self, tick: int) -> object: # TODO + return self._info_record[tick] + + def reset(self, keep_seed: bool = False) -> None: + self._gym_env.seed(self._seed) + self._last_obs = self._gym_env.reset() + self._is_done = False + self._reward_record = {} + self._info_record = {} + + def post_step(self, tick: int) -> bool: + return self._is_done or tick + 1 == self._max_tick + + def get_agent_idx_list(self) -> List[int]: + return [0] + + def get_metrics(self) -> dict: + return { + "reward_record": {k: v for k, v in self._reward_record.items()}, + } diff --git a/maro/simulator/scenarios/gym/test_business_engine.py b/maro/simulator/scenarios/gym/test_business_engine.py new file mode 100644 index 000000000..2c67e00ff --- /dev/null +++ b/maro/simulator/scenarios/gym/test_business_engine.py @@ -0,0 +1,22 @@ +from maro.simulator import Env +from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine + +if __name__ == "__main__": + env = Env( + scenario="gym", + start_tick=0, + durations=10, + ) + be = env.business_engine + assert isinstance(be, GymBusinessEngine) + gym_env = be.gym_env + + print(gym_env.action_space.low) + print(gym_env.action_space.high) + + metrics, decision_event, is_done = env.step(None) + while not is_done: + action = gym_env.action_space.sample() + print(f"State at tick {env.tick}: {decision_event}") + print(f"Action: {action}") + metrics, decision_event, is_done = env.step(action) From b3f5aefdfb447dd623f42a729f58cc258a61976e Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 12 Apr 2022 15:58:31 +0800 Subject: [PATCH 02/45] Explore in SAC --- examples/gym/rl/algorithms/sac.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/gym/rl/algorithms/sac.py b/examples/gym/rl/algorithms/sac.py index d31f49329..d14a33bc9 100644 --- a/examples/gym/rl/algorithms/sac.py +++ b/examples/gym/rl/algorithms/sac.py @@ -41,9 +41,13 @@ def __init__(self, state_dim: int, action_dim: int) -> None: def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: distribution = self._distribution(states) - actions = distribution.rsample() - logps = distribution.log_prob(actions).sum(axis=-1) - logps -= (2 * (np.log(2) - actions - F.softplus(-2 * actions))).sum(axis=1) + if exploring: + actions = distribution.rsample() + logps = distribution.log_prob(actions).sum(axis=-1) + logps -= (2 * (np.log(2) - actions - F.softplus(-2 * actions))).sum(axis=1) + else: + actions = distribution.loc + logps = torch.randn(states.shape[0]) # Fake return self._limit * torch.tanh(actions), logps def _distribution(self, states: torch.Tensor) -> Normal: From 5dab7111b7c0a0616bf527db2f997752a0759493 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Fri, 22 Apr 2022 19:49:23 +0800 Subject: [PATCH 03/45] Test GYM on server --- examples/gym/rl/algorithms/ddpg.py | 4 +--- examples/gym/rl/algorithms/ppo.py | 2 +- examples/gym/rl/algorithms/sac.py | 2 +- examples/gym/rl/env_sampler.py | 3 +-- examples/rl/gym.yml | 35 ++++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 examples/rl/gym.yml diff --git a/examples/gym/rl/algorithms/ddpg.py b/examples/gym/rl/algorithms/ddpg.py index 5053660ff..52cda4d0a 100644 --- a/examples/gym/rl/algorithms/ddpg.py +++ b/examples/gym/rl/algorithms/ddpg.py @@ -1,5 +1,3 @@ -from typing import Dict, Tuple - import torch from torch.optim import Adam @@ -71,6 +69,6 @@ def get_ddpg(name: str) -> DDPGTrainer: soft_update_coef=0.01, update_target_every=1, num_epochs=100, - n_start_train=10000, + min_num_to_trigger_training=10000, ), ) diff --git a/examples/gym/rl/algorithms/ppo.py b/examples/gym/rl/algorithms/ppo.py index aff1a7720..55d92d1d3 100644 --- a/examples/gym/rl/algorithms/ppo.py +++ b/examples/gym/rl/algorithms/ppo.py @@ -1,4 +1,4 @@ -from typing import Dict, Tuple +from typing import Tuple import numpy as np import torch diff --git a/examples/gym/rl/algorithms/sac.py b/examples/gym/rl/algorithms/sac.py index d14a33bc9..e6e6dae9c 100644 --- a/examples/gym/rl/algorithms/sac.py +++ b/examples/gym/rl/algorithms/sac.py @@ -1,4 +1,4 @@ -from typing import Dict, Tuple +from typing import Tuple import numpy as np import torch diff --git a/examples/gym/rl/env_sampler.py b/examples/gym/rl/env_sampler.py index 7135b3a92..c18415d79 100644 --- a/examples/gym/rl/env_sampler.py +++ b/examples/gym/rl/env_sampler.py @@ -6,13 +6,12 @@ from maro.rl.rollout import AbsEnvSampler, CacheElement from maro.simulator import Env from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine - from .config import algorithm from .env_helper import env_conf, helper_env class GymEnvSampler(AbsEnvSampler): - def _get_global_and_agent_state( + def _get_global_and_agent_state_impl( self, event: np.ndarray, tick: int = None, ) -> Tuple[Optional[np.ndarray], Dict[Any, np.ndarray]]: return None, {0: event} diff --git a/examples/rl/gym.yml b/examples/rl/gym.yml new file mode 100644 index 000000000..5fb57f8aa --- /dev/null +++ b/examples/rl/gym.yml @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + +# Run this workflow by executing one of the following commands: +# - python .\examples\rl\run_rl_example.py .\examples\rl\gym.yml +# - (Requires installing MARO from source) maro local run .\examples\rl\gym.yml + +job: gym_rl_workflow +scenario_path: "examples/gym/rl" +log_path: "log/rl_job/gym.txt" +main: + num_episodes: 200 + num_steps: null + eval_schedule: 5 + min_n_sample: 5000 + logging: + stdout: INFO + file: DEBUG +rollout: + logging: + stdout: INFO + file: DEBUG +training: + mode: simple + load_path: null + load_episode: null + checkpointing: + path: "checkpoint/rl_job/gym" + interval: 5 + logging: + stdout: INFO + file: DEBUG From 211c06feb22bca646d0a5e50224633640b6018ce Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 22:11:02 +0800 Subject: [PATCH 04/45] Sync server changes --- examples/rl/gym.yml | 2 +- maro/rl/training/replay_memory.py | 2 +- maro/simulator/scenarios/gym/business_engine.py | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/rl/gym.yml b/examples/rl/gym.yml index 5fb57f8aa..394287383 100644 --- a/examples/rl/gym.yml +++ b/examples/rl/gym.yml @@ -12,7 +12,7 @@ job: gym_rl_workflow scenario_path: "examples/gym/rl" log_path: "log/rl_job/gym.txt" main: - num_episodes: 200 + num_episodes: 600 num_steps: null eval_schedule: 5 min_n_sample: 5000 diff --git a/maro/rl/training/replay_memory.py b/maro/rl/training/replay_memory.py index e797834ad..992885703 100644 --- a/maro/rl/training/replay_memory.py +++ b/maro/rl/training/replay_memory.py @@ -361,7 +361,7 @@ def __init__( self._actions = [np.zeros((self._capacity, action_dim), dtype=np.float32) for action_dim in self._action_dims] self._rewards = [np.zeros(self._capacity, dtype=np.float32) for _ in range(self.agent_num)] self._next_states = np.zeros((self._capacity, self._state_dim), dtype=np.float32) - self._terminals = np.zeros(self._capacity, dtype=np.bool) + self._terminals = np.zeros(self._capacity, dtype=bool) assert len(agent_states_dims) == self.agent_num self._agent_states_dims = agent_states_dims diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py index 811b63c43..b391948a0 100644 --- a/maro/simulator/scenarios/gym/business_engine.py +++ b/maro/simulator/scenarios/gym/business_engine.py @@ -19,12 +19,11 @@ def __init__( additional_options=additional_options, ) - self._gym_scenario_name = "Walker2d-v2" + self._gym_scenario_name = "Walker2d-v4" self._gym_env = gym.make(self._gym_scenario_name) self._seed = additional_options.get("random_seed", None) - self._gym_env.seed(self._seed) - self._last_obs = self._gym_env.reset() + self._last_obs = self._gym_env.reset()[0] self._is_done = False self._reward_record = {} self._info_record = {} @@ -54,7 +53,7 @@ def _on_action_received(self, event: CascadeEvent) -> None: assert isinstance(actions, list) action = actions[0] - self._last_obs, reward, self._is_done, info = self._gym_env.step(action) + self._last_obs, reward, self._is_done, _, info = self._gym_env.step(action) self._reward_record[event.tick] = reward self._info_record[event.tick] = info @@ -72,8 +71,7 @@ def get_info_at_tick(self, tick: int) -> object: # TODO return self._info_record[tick] def reset(self, keep_seed: bool = False) -> None: - self._gym_env.seed(self._seed) - self._last_obs = self._gym_env.reset() + self._last_obs = self._gym_env.reset()[0] self._is_done = False self._reward_record = {} self._info_record = {} From 514250a0493e7954ee7358694f6e2063de723610 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 22:17:11 +0800 Subject: [PATCH 05/45] pre-commit --- examples/gym/rl/algorithms/ddpg.py | 5 +++-- examples/gym/rl/algorithms/ppo.py | 5 +++-- examples/gym/rl/algorithms/sac.py | 7 +++--- examples/gym/rl/algorithms/utils.py | 4 +++- examples/gym/rl/callbacks.py | 14 +++++++----- examples/gym/rl/env_helper.py | 4 ++-- examples/gym/rl/env_sampler.py | 5 ++++- examples/gym/rl/policy_trainer.py | 22 ++++++++----------- .../scenarios/gym/business_engine.py | 22 ++++++++++++++----- 9 files changed, 52 insertions(+), 36 deletions(-) diff --git a/examples/gym/rl/algorithms/ddpg.py b/examples/gym/rl/algorithms/ddpg.py index 52cda4d0a..ead02e563 100644 --- a/examples/gym/rl/algorithms/ddpg.py +++ b/examples/gym/rl/algorithms/ddpg.py @@ -1,12 +1,13 @@ import torch from torch.optim import Adam -from examples.gym.rl.algorithms.utils import mlp -from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim from maro.rl.model import ContinuousDDPGNet, ContinuousQNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import DDPGParams, DDPGTrainer +from examples.gym.rl.algorithms.utils import mlp +from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim + actor_net_conf = { "hidden_dims": [256, 256], "activation": torch.nn.ReLU, diff --git a/examples/gym/rl/algorithms/ppo.py b/examples/gym/rl/algorithms/ppo.py index 55d92d1d3..bd93cf325 100644 --- a/examples/gym/rl/algorithms/ppo.py +++ b/examples/gym/rl/algorithms/ppo.py @@ -5,12 +5,13 @@ from torch.distributions import Normal from torch.optim import Adam -from examples.gym.rl.algorithms.utils import mlp -from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_state_dim from maro.rl.model import ContinuousACBasedNet, VNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import PPOParams, PPOTrainer +from examples.gym.rl.algorithms.utils import mlp +from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_state_dim + actor_net_conf = { "hidden_dims": [64, 64], "activation": torch.nn.Tanh, diff --git a/examples/gym/rl/algorithms/sac.py b/examples/gym/rl/algorithms/sac.py index e6e6dae9c..4bd533e7a 100644 --- a/examples/gym/rl/algorithms/sac.py +++ b/examples/gym/rl/algorithms/sac.py @@ -6,12 +6,13 @@ from torch.distributions import Normal from torch.optim import Adam -from examples.gym.rl.algorithms.utils import mlp -from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim from maro.rl.model import ContinuousQNet, ContinuousSACNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer +from examples.gym.rl.algorithms.utils import mlp +from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim + actor_net_conf = { "hidden_dims": [256, 256], "activation": torch.nn.ReLU, @@ -93,5 +94,5 @@ def get_sac(name: str) -> SoftActorCriticTrainer: entropy_coef=0.2, num_epochs=100, n_start_train=10000, - ) + ), ) diff --git a/examples/gym/rl/algorithms/utils.py b/examples/gym/rl/algorithms/utils.py index 14e0369a9..75511dd33 100644 --- a/examples/gym/rl/algorithms/utils.py +++ b/examples/gym/rl/algorithms/utils.py @@ -4,7 +4,9 @@ def mlp( - sizes: List[int], activation: Type[torch.nn.Module], output_activation: Type[torch.nn.Module] = torch.nn.Identity + sizes: List[int], + activation: Type[torch.nn.Module], + output_activation: Type[torch.nn.Module] = torch.nn.Identity, ) -> torch.nn.Sequential: layers = [] for j in range(len(sizes) - 1): diff --git a/examples/gym/rl/callbacks.py b/examples/gym/rl/callbacks.py index f466e53b4..08232d5b7 100644 --- a/examples/gym/rl/callbacks.py +++ b/examples/gym/rl/callbacks.py @@ -5,12 +5,14 @@ def _show_info(rewards: list, tag: str) -> None: - print(f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " - f"N segments = {len(rewards)}, " - f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " - f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " - f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " - f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n") + print( + f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " + f"N segments = {len(rewards)}, " + f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " + f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " + f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " + f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n", + ) def post_collect(info_list: list, ep: int, segment: int) -> None: diff --git a/examples/gym/rl/env_helper.py b/examples/gym/rl/env_helper.py index e309dcc88..fc7908ed7 100644 --- a/examples/gym/rl/env_helper.py +++ b/examples/gym/rl/env_helper.py @@ -19,5 +19,5 @@ gym_action_dim = gym_env.action_space.shape[0] # action_lower_bound = gym_env.action_space.low.tolist() # action_upper_bound = gym_env.action_space.high.tolist() -action_lower_bound = [float('-inf') for _ in range(gym_env.action_space.shape[0])] # TODO -action_upper_bound = [float('inf') for _ in range(gym_env.action_space.shape[0])] # TODO +action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] # TODO +action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] # TODO diff --git a/examples/gym/rl/env_sampler.py b/examples/gym/rl/env_sampler.py index c18415d79..f5d06b9a7 100644 --- a/examples/gym/rl/env_sampler.py +++ b/examples/gym/rl/env_sampler.py @@ -6,13 +6,16 @@ from maro.rl.rollout import AbsEnvSampler, CacheElement from maro.simulator import Env from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine + from .config import algorithm from .env_helper import env_conf, helper_env class GymEnvSampler(AbsEnvSampler): def _get_global_and_agent_state_impl( - self, event: np.ndarray, tick: int = None, + self, + event: np.ndarray, + tick: int = None, ) -> Tuple[Optional[np.ndarray], Dict[Any, np.ndarray]]: return None, {0: event} diff --git a/examples/gym/rl/policy_trainer.py b/examples/gym/rl/policy_trainer.py index b3a353dbc..44e13fc8a 100644 --- a/examples/gym/rl/policy_trainer.py +++ b/examples/gym/rl/policy_trainer.py @@ -5,8 +5,8 @@ from maro.rl.training.utils import extract_trainer_name from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine -from .config import algorithm +from .config import algorithm from .env_helper import helper_env be = helper_env.business_engine @@ -14,31 +14,27 @@ if algorithm == "random": from .algorithms.random import get_policy + policy_creator = { f"{algorithm}_{i}.policy": partial(get_policy, be.gym_env.action_space) for i in helper_env.agent_idx_list } trainer_creator = {} elif algorithm == "ppo": from .algorithms.ppo import get_policy, get_ppo - policy_creator = { - f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list - } + + policy_creator = {f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list} trainer_creator = {extract_trainer_name(policy_name): get_ppo for policy_name in policy_creator} elif algorithm == "ddpg": from .algorithms.ddpg import get_ddpg, get_policy - policy_creator = { - f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list - } + + policy_creator = {f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list} trainer_creator = {extract_trainer_name(policy_name): get_ddpg for policy_name in policy_creator} elif algorithm == "sac": from .algorithms.sac import get_policy, get_sac - policy_creator = { - f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list - } + + policy_creator = {f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list} trainer_creator = {extract_trainer_name(policy_name): get_sac for policy_name in policy_creator} else: raise ValueError(f"Unsupported algorithm: {algorithm}") -device_mapping = { - policy_name: "cpu" for policy_name in policy_creator -} +device_mapping = {policy_name: "cpu" for policy_name in policy_creator} diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py index b391948a0..0af410c27 100644 --- a/maro/simulator/scenarios/gym/business_engine.py +++ b/maro/simulator/scenarios/gym/business_engine.py @@ -1,21 +1,31 @@ -from copy import deepcopy from typing import List, Optional import gym -from maro.backends.frame import FrameBase, SnapshotList +from maro.backends.frame import FrameBase, SnapshotList from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents from maro.simulator.scenarios import AbsBusinessEngine class GymBusinessEngine(AbsBusinessEngine): def __init__( - self, event_buffer: EventBuffer, topology: Optional[str], start_tick: int, - max_tick: int, snapshot_resolution: int, max_snapshots: Optional[int], additional_options: dict = None, + self, + event_buffer: EventBuffer, + topology: Optional[str], + start_tick: int, + max_tick: int, + snapshot_resolution: int, + max_snapshots: Optional[int], + additional_options: dict = None, ) -> None: super(GymBusinessEngine, self).__init__( - scenario_name="gym", event_buffer=event_buffer, topology=topology, start_tick=start_tick, - max_tick=max_tick, snapshot_resolution=snapshot_resolution, max_snapshots=max_snapshots, + scenario_name="gym", + event_buffer=event_buffer, + topology=topology, + start_tick=start_tick, + max_tick=max_tick, + snapshot_resolution=snapshot_resolution, + max_snapshots=max_snapshots, additional_options=additional_options, ) From fc0c02d27752b5d63ac1db5f3e7fffd20d4e7e91 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 23:08:47 +0800 Subject: [PATCH 06/45] Ready to try on server --- examples/gym_new/rl/__init__.py | 8 ++ examples/gym_new/rl/algorithms/__init__.py | 0 examples/gym_new/rl/algorithms/ppo.py | 95 ++++++++++++++++++++++ examples/gym_new/rl/env_sampler.py | 50 ++++++++++++ examples/gym_new/rl/rl_component_bundle.py | 54 ++++++++++++ examples/rl/gym.yml | 2 +- 6 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 examples/gym_new/rl/__init__.py create mode 100644 examples/gym_new/rl/algorithms/__init__.py create mode 100644 examples/gym_new/rl/algorithms/ppo.py create mode 100644 examples/gym_new/rl/env_sampler.py create mode 100644 examples/gym_new/rl/rl_component_bundle.py diff --git a/examples/gym_new/rl/__init__.py b/examples/gym_new/rl/__init__.py new file mode 100644 index 000000000..90be439f0 --- /dev/null +++ b/examples/gym_new/rl/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from .rl_component_bundle import rl_component_bundle + +__all__ = [ + "rl_component_bundle", +] diff --git a/examples/gym_new/rl/algorithms/__init__.py b/examples/gym_new/rl/algorithms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/gym_new/rl/algorithms/ppo.py b/examples/gym_new/rl/algorithms/ppo.py new file mode 100644 index 000000000..c3bd3e3f7 --- /dev/null +++ b/examples/gym_new/rl/algorithms/ppo.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Tuple + +import numpy as np +import torch +from torch.distributions import Normal +from torch.optim import Adam + +from maro.rl.model import ContinuousACBasedNet, VNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import PPOParams, PPOTrainer + +from examples.gym.rl.algorithms.utils import mlp + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + + +class MyActorNet(ContinuousACBasedNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.sample() + logps = distribution.log_prob(actions).sum(axis=-1) + return actions, logps + + def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + distribution = self._distribution(states) + logps = distribution.log_prob(actions).sum(axis=-1) + return logps + + def _distribution(self, states: torch.Tensor) -> Normal: + mu = self._mu_net(states.float()) + std = torch.exp(self._log_std) + return Normal(mu, std) + + +class MyCriticNet(VNet): + def __init__(self, state_dim: int) -> None: + super(MyCriticNet, self).__init__(state_dim=state_dim) + self._critic = mlp( + [state_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: + return self._critic(states.float()).squeeze(-1) + + +def get_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyActorNet(gym_state_dim, gym_action_dim), + ) + + +def get_ppo(name: str, state_dim: int) -> PPOTrainer: + return PPOTrainer( + name=name, + reward_discount=0.99, + params=PPOParams( + get_v_critic_net_func=lambda: MyCriticNet(state_dim), + grad_iters=80, + lam=0.97, + clip_ratio=0.2, + ), + ) diff --git a/examples/gym_new/rl/env_sampler.py b/examples/gym_new/rl/env_sampler.py new file mode 100644 index 000000000..b6865e338 --- /dev/null +++ b/examples/gym_new/rl/env_sampler.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +from typing import Any, Dict, Tuple, Union + +import numpy as np + +from maro.rl.rollout import AbsEnvSampler, CacheElement +from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine + + +def _show_info(rewards: list, tag: str) -> None: + print( + f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " + f"N segments = {len(rewards)}, " + f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " + f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " + f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " + f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n", + ) + + +class GymEnvSampler(AbsEnvSampler): + def _get_global_and_agent_state_impl( + self, + event: np.ndarray, + tick: int = None, + ) -> Tuple[Union[None, np.ndarray, list], Dict[Any, Union[np.ndarray, list]]]: + return None, {0: event} + + def _translate_to_env_action(self, action_dict: Dict[Any, Union[np.ndarray, list]], event: Any) -> dict: + return action_dict + + def _get_reward(self, env_action_dict: dict, event: Any, tick: int) -> Dict[Any, float]: + be = self._env.business_engine + assert isinstance(be, GymBusinessEngine) + return {0: be.get_reward_at_tick(tick)} + + def _post_step(self, cache_element: CacheElement) -> None: + self._info["env_metric"] = self._env.metrics + + def _post_eval_step(self, cache_element: CacheElement) -> None: + self._post_step(cache_element) + + def post_collect(self, info_list: list, ep: int) -> None: + rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] + _show_info(rewards, "Collect") + + def post_evaluate(self, info_list: list, ep: int) -> None: + rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] + _show_info(rewards, "Evaluate") diff --git a/examples/gym_new/rl/rl_component_bundle.py b/examples/gym_new/rl/rl_component_bundle.py new file mode 100644 index 000000000..89c618d91 --- /dev/null +++ b/examples/gym_new/rl/rl_component_bundle.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +from typing import cast + +from examples.gym_new.rl.env_sampler import GymEnvSampler +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle +from maro.simulator import Env +from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine + +env_conf = { + "scenario": "gym", + "start_tick": 0, + "durations": 5000, + "options": { + "random_seed": None, + }, +} + +learn_env = Env(**env_conf) +test_env = learn_env +num_agents = len(learn_env.agent_idx_list) + +gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env +gym_state_dim = gym_env.observation_space.shape[0] +gym_action_dim = gym_env.action_space.shape[0] +action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] +action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] + +algorithm = "ppo" + +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} + +if algorithm == "ppo": + from .algorithms.ppo import get_policy, get_ppo + + policies = [ + get_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) + ] + trainers = [get_ppo(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] +else: + raise ValueError(f"Unsupported algorithm: {algorithm}") + +rl_component_bundle = RLComponentBundle( + env_sampler=GymEnvSampler( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + ), + agent2policy=agent2policy, + policies=policies, + trainers=trainers, +) diff --git a/examples/rl/gym.yml b/examples/rl/gym.yml index 394287383..192bb732e 100644 --- a/examples/rl/gym.yml +++ b/examples/rl/gym.yml @@ -9,7 +9,7 @@ # - (Requires installing MARO from source) maro local run .\examples\rl\gym.yml job: gym_rl_workflow -scenario_path: "examples/gym/rl" +scenario_path: "examples/gym_new/rl" log_path: "log/rl_job/gym.txt" main: num_episodes: 600 From 9fcdf4257c8b07a084b355b71d1f3411e5baef7f Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 23:10:49 +0800 Subject: [PATCH 07/45] . --- maro/simulator/scenarios/gym/business_engine.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py index 0af410c27..fbda99a6d 100644 --- a/maro/simulator/scenarios/gym/business_engine.py +++ b/maro/simulator/scenarios/gym/business_engine.py @@ -96,3 +96,6 @@ def get_metrics(self) -> dict: return { "reward_record": {k: v for k, v in self._reward_record.items()}, } + + def set_seed(self, seed: int) -> None: + pass From 01b5a9497f0774f818029bfcd27dbc48445b4a11 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 23:12:36 +0800 Subject: [PATCH 08/45] . --- examples/gym_new/rl/algorithms/utils.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/gym_new/rl/algorithms/utils.py diff --git a/examples/gym_new/rl/algorithms/utils.py b/examples/gym_new/rl/algorithms/utils.py new file mode 100644 index 000000000..d05df6d01 --- /dev/null +++ b/examples/gym_new/rl/algorithms/utils.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import List, Type + +import torch + + +def mlp( + sizes: List[int], + activation: Type[torch.nn.Module], + output_activation: Type[torch.nn.Module] = torch.nn.Identity, +) -> torch.nn.Sequential: + layers = [] + for j in range(len(sizes) - 1): + act = activation if j < len(sizes) - 2 else output_activation + layers += [torch.nn.Linear(sizes[j], sizes[j + 1]), act()] + return torch.nn.Sequential(*layers) From dd27eed15e694754fd7ce727f65530d0b2c78385 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 23:13:25 +0800 Subject: [PATCH 09/45] . --- examples/gym_new/rl/algorithms/ppo.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/gym_new/rl/algorithms/ppo.py b/examples/gym_new/rl/algorithms/ppo.py index c3bd3e3f7..b39e13445 100644 --- a/examples/gym_new/rl/algorithms/ppo.py +++ b/examples/gym_new/rl/algorithms/ppo.py @@ -8,12 +8,11 @@ from torch.distributions import Normal from torch.optim import Adam +from examples.gym_new.rl.algorithms.utils import mlp from maro.rl.model import ContinuousACBasedNet, VNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import PPOParams, PPOTrainer -from examples.gym.rl.algorithms.utils import mlp - actor_net_conf = { "hidden_dims": [64, 64], "activation": torch.nn.Tanh, From 1c8f258e9bb8fb546deeba50c20c6885ab68c22e Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 23:14:03 +0800 Subject: [PATCH 10/45] . --- examples/gym_new/rl/algorithms/ppo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gym_new/rl/algorithms/ppo.py b/examples/gym_new/rl/algorithms/ppo.py index b39e13445..71e1d7d57 100644 --- a/examples/gym_new/rl/algorithms/ppo.py +++ b/examples/gym_new/rl/algorithms/ppo.py @@ -8,10 +8,10 @@ from torch.distributions import Normal from torch.optim import Adam -from examples.gym_new.rl.algorithms.utils import mlp from maro.rl.model import ContinuousACBasedNet, VNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import PPOParams, PPOTrainer +from .utils import mlp actor_net_conf = { "hidden_dims": [64, 64], From 1aa10856d63100530c1cfcc0803dd6f89bd89967 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 17 Jan 2023 23:18:02 +0800 Subject: [PATCH 11/45] . --- maro/simulator/scenarios/gym/business_engine.py | 7 ++++--- maro/simulator/scenarios/gym/common.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 maro/simulator/scenarios/gym/common.py diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py index fbda99a6d..2bffb35ac 100644 --- a/maro/simulator/scenarios/gym/business_engine.py +++ b/maro/simulator/scenarios/gym/business_engine.py @@ -1,10 +1,11 @@ -from typing import List, Optional +from typing import List, Optional, cast import gym from maro.backends.frame import FrameBase, SnapshotList from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents from maro.simulator.scenarios import AbsBusinessEngine +from maro.simulator.scenarios.gym.common import Action, DecisionEvent class GymBusinessEngine(AbsBusinessEngine): @@ -59,7 +60,7 @@ def _register_events(self) -> None: self._event_buffer.register_event_handler(MaroEvents.TAKE_ACTION, self._on_action_received) def _on_action_received(self, event: CascadeEvent) -> None: - actions = event.payload + actions = cast(Action, event.payload).action assert isinstance(actions, list) action = actions[0] @@ -68,7 +69,7 @@ def _on_action_received(self, event: CascadeEvent) -> None: self._info_record[event.tick] = info def step(self, tick: int) -> None: - self._event_buffer.insert_event(self._event_buffer.gen_decision_event(tick, self._last_obs)) + self._event_buffer.insert_event(self._event_buffer.gen_decision_event(tick, DecisionEvent(self._last_obs))) @property def configs(self) -> dict: diff --git a/maro/simulator/scenarios/gym/common.py b/maro/simulator/scenarios/gym/common.py new file mode 100644 index 000000000..8d9adedfa --- /dev/null +++ b/maro/simulator/scenarios/gym/common.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from maro.common import BaseAction, BaseDecisionEvent + + +class Action(BaseAction): + def __init__(self, action) -> None: + self.action = action + + +class DecisionEvent(BaseDecisionEvent): + def __init__(self, state) -> None: + self.state = state From 148af387f54422ebc730bea4a43bbf6fc5df38d7 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 18 Jan 2023 02:30:01 +0000 Subject: [PATCH 12/45] Performance OK --- examples/gym_new/rl/env_sampler.py | 7 ++++--- examples/rl/gym.yml | 2 +- maro/simulator/scenarios/gym/business_engine.py | 4 +--- maro/simulator/scenarios/gym/common.py | 6 ++++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/examples/gym_new/rl/env_sampler.py b/examples/gym_new/rl/env_sampler.py index b6865e338..745fa7290 100644 --- a/examples/gym_new/rl/env_sampler.py +++ b/examples/gym_new/rl/env_sampler.py @@ -6,6 +6,7 @@ from maro.rl.rollout import AbsEnvSampler, CacheElement from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine +from maro.simulator.scenarios.gym.common import Action def _show_info(rewards: list, tag: str) -> None: @@ -25,10 +26,10 @@ def _get_global_and_agent_state_impl( event: np.ndarray, tick: int = None, ) -> Tuple[Union[None, np.ndarray, list], Dict[Any, Union[np.ndarray, list]]]: - return None, {0: event} + return None, {0: event.state} - def _translate_to_env_action(self, action_dict: Dict[Any, Union[np.ndarray, list]], event: Any) -> dict: - return action_dict + def _translate_to_env_action(self, action_dict: dict, event: Any) -> dict: + return {k: Action(v) for k, v in action_dict.items()} def _get_reward(self, env_action_dict: dict, event: Any, tick: int) -> Dict[Any, float]: be = self._env.business_engine diff --git a/examples/rl/gym.yml b/examples/rl/gym.yml index 192bb732e..3af8eca7c 100644 --- a/examples/rl/gym.yml +++ b/examples/rl/gym.yml @@ -12,7 +12,7 @@ job: gym_rl_workflow scenario_path: "examples/gym_new/rl" log_path: "log/rl_job/gym.txt" main: - num_episodes: 600 + num_episodes: 1000 num_steps: null eval_schedule: 5 min_n_sample: 5000 diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py index 2bffb35ac..8c9d14045 100644 --- a/maro/simulator/scenarios/gym/business_engine.py +++ b/maro/simulator/scenarios/gym/business_engine.py @@ -60,9 +60,7 @@ def _register_events(self) -> None: self._event_buffer.register_event_handler(MaroEvents.TAKE_ACTION, self._on_action_received) def _on_action_received(self, event: CascadeEvent) -> None: - actions = cast(Action, event.payload).action - assert isinstance(actions, list) - action = actions[0] + action = cast(Action, event.payload[0]).action self._last_obs, reward, self._is_done, _, info = self._gym_env.step(action) self._reward_record[event.tick] = reward diff --git a/maro/simulator/scenarios/gym/common.py b/maro/simulator/scenarios/gym/common.py index 8d9adedfa..f2de98173 100644 --- a/maro/simulator/scenarios/gym/common.py +++ b/maro/simulator/scenarios/gym/common.py @@ -1,14 +1,16 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import numpy as np + from maro.common import BaseAction, BaseDecisionEvent class Action(BaseAction): - def __init__(self, action) -> None: + def __init__(self, action: np.ndarray) -> None: self.action = action class DecisionEvent(BaseDecisionEvent): - def __init__(self, state) -> None: + def __init__(self, state: np.ndarray) -> None: self.state = state From 99ff7b99ae2a69a1343c0b38ff2308c13dabd074 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 18 Jan 2023 10:39:05 +0800 Subject: [PATCH 13/45] Move to tests --- examples/rl/cim.yml | 2 +- examples/rl/cim_distributed.yml | 2 +- examples/rl/gym.yml | 6 +- examples/rl/vm_scheduling.yml | 2 +- tests/rl/__init__.py | 8 ++ tests/rl/algorithms/__init__.py | 0 tests/rl/algorithms/ppo.py | 94 ++++++++++++++++ tests/rl/algorithms/utils.py | 18 +++ tests/rl/config.yml | 34 ++++++ tests/rl/gym_wrapper/__init__.py | 8 ++ tests/rl/gym_wrapper/env_sampler.py | 52 +++++++++ tests/rl/gym_wrapper/rl_component_bundle.py | 55 ++++++++++ tests/rl/gym_wrapper/simulator/__init__.py | 2 + .../gym_wrapper/simulator/business_engine.py | 103 ++++++++++++++++++ tests/rl/gym_wrapper/simulator/common.py | 16 +++ tests/rl/run.py | 18 +++ 16 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 tests/rl/__init__.py create mode 100644 tests/rl/algorithms/__init__.py create mode 100644 tests/rl/algorithms/ppo.py create mode 100644 tests/rl/algorithms/utils.py create mode 100644 tests/rl/config.yml create mode 100644 tests/rl/gym_wrapper/__init__.py create mode 100644 tests/rl/gym_wrapper/env_sampler.py create mode 100644 tests/rl/gym_wrapper/rl_component_bundle.py create mode 100644 tests/rl/gym_wrapper/simulator/__init__.py create mode 100644 tests/rl/gym_wrapper/simulator/business_engine.py create mode 100644 tests/rl/gym_wrapper/simulator/common.py create mode 100644 tests/rl/run.py diff --git a/examples/rl/cim.yml b/examples/rl/cim.yml index bb99d164a..ce149972b 100644 --- a/examples/rl/cim.yml +++ b/examples/rl/cim.yml @@ -5,7 +5,7 @@ # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run_rl_example.py .\examples\rl\cim.yml +# - python .\examples\rl\run.py .\examples\rl\cim.yml # - (Requires installing MARO from source) maro local run .\examples\rl\cim.yml job: cim_rl_workflow diff --git a/examples/rl/cim_distributed.yml b/examples/rl/cim_distributed.yml index 3b11cb6e1..17027888b 100644 --- a/examples/rl/cim_distributed.yml +++ b/examples/rl/cim_distributed.yml @@ -5,7 +5,7 @@ # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run_rl_example.py .\examples\rl\cim.yml +# - python .\examples\rl\run.py .\examples\rl\cim.yml # - (Requires installing MARO from source) maro local run .\examples\rl\cim.yml job: cim_rl_workflow diff --git a/examples/rl/gym.yml b/examples/rl/gym.yml index 3af8eca7c..2c1722f2a 100644 --- a/examples/rl/gym.yml +++ b/examples/rl/gym.yml @@ -5,14 +5,14 @@ # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run_rl_example.py .\examples\rl\gym.yml -# - (Requires installing MARO from source) maro local run .\examples\rl\gym.yml +# - python .\examples\rl\run.py .\examples\rl\config.yml +# - (Requires installing MARO from source) maro local run .\examples\rl\config.yml job: gym_rl_workflow scenario_path: "examples/gym_new/rl" log_path: "log/rl_job/gym.txt" main: - num_episodes: 1000 + num_episodes: 600 num_steps: null eval_schedule: 5 min_n_sample: 5000 diff --git a/examples/rl/vm_scheduling.yml b/examples/rl/vm_scheduling.yml index 5f4f20747..926a3cf93 100644 --- a/examples/rl/vm_scheduling.yml +++ b/examples/rl/vm_scheduling.yml @@ -5,7 +5,7 @@ # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run_rl_example.py .\examples\rl\vm_scheduling.yml +# - python .\examples\rl\run.py .\examples\rl\vm_scheduling.yml # - (Requires installing MARO from source) maro local run .\examples\rl\vm_scheduling.yml job: vm_scheduling_rl_workflow diff --git a/tests/rl/__init__.py b/tests/rl/__init__.py new file mode 100644 index 000000000..d3af9b425 --- /dev/null +++ b/tests/rl/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from tests.rl.gym_wrapper.rl_component_bundle import rl_component_bundle + +__all__ = [ + "rl_component_bundle", +] diff --git a/tests/rl/algorithms/__init__.py b/tests/rl/algorithms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rl/algorithms/ppo.py b/tests/rl/algorithms/ppo.py new file mode 100644 index 000000000..71e1d7d57 --- /dev/null +++ b/tests/rl/algorithms/ppo.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Tuple + +import numpy as np +import torch +from torch.distributions import Normal +from torch.optim import Adam + +from maro.rl.model import ContinuousACBasedNet, VNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import PPOParams, PPOTrainer +from .utils import mlp + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + + +class MyActorNet(ContinuousACBasedNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.sample() + logps = distribution.log_prob(actions).sum(axis=-1) + return actions, logps + + def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + distribution = self._distribution(states) + logps = distribution.log_prob(actions).sum(axis=-1) + return logps + + def _distribution(self, states: torch.Tensor) -> Normal: + mu = self._mu_net(states.float()) + std = torch.exp(self._log_std) + return Normal(mu, std) + + +class MyCriticNet(VNet): + def __init__(self, state_dim: int) -> None: + super(MyCriticNet, self).__init__(state_dim=state_dim) + self._critic = mlp( + [state_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: + return self._critic(states.float()).squeeze(-1) + + +def get_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyActorNet(gym_state_dim, gym_action_dim), + ) + + +def get_ppo(name: str, state_dim: int) -> PPOTrainer: + return PPOTrainer( + name=name, + reward_discount=0.99, + params=PPOParams( + get_v_critic_net_func=lambda: MyCriticNet(state_dim), + grad_iters=80, + lam=0.97, + clip_ratio=0.2, + ), + ) diff --git a/tests/rl/algorithms/utils.py b/tests/rl/algorithms/utils.py new file mode 100644 index 000000000..d05df6d01 --- /dev/null +++ b/tests/rl/algorithms/utils.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import List, Type + +import torch + + +def mlp( + sizes: List[int], + activation: Type[torch.nn.Module], + output_activation: Type[torch.nn.Module] = torch.nn.Identity, +) -> torch.nn.Sequential: + layers = [] + for j in range(len(sizes) - 1): + act = activation if j < len(sizes) - 2 else output_activation + layers += [torch.nn.Linear(sizes[j], sizes[j + 1]), act()] + return torch.nn.Sequential(*layers) diff --git a/tests/rl/config.yml b/tests/rl/config.yml new file mode 100644 index 000000000..43644e91d --- /dev/null +++ b/tests/rl/config.yml @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + +# Run this workflow by executing one of the following commands: +# - python tests/rl/run.py tests/rl/config.yml + +job: gym_rl_workflow +scenario_path: "tests/rl/gym_wrapper" +log_path: "tests/rl/log/gym.txt" +main: + num_episodes: 1000 + num_steps: null + eval_schedule: 5 + min_n_sample: 5000 + logging: + stdout: INFO + file: DEBUG +rollout: + logging: + stdout: INFO + file: DEBUG +training: + mode: simple + load_path: null + load_episode: null + checkpointing: + path: "tests/rl/checkpoint/gym" + interval: 5 + logging: + stdout: INFO + file: DEBUG diff --git a/tests/rl/gym_wrapper/__init__.py b/tests/rl/gym_wrapper/__init__.py new file mode 100644 index 000000000..90be439f0 --- /dev/null +++ b/tests/rl/gym_wrapper/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from .rl_component_bundle import rl_component_bundle + +__all__ = [ + "rl_component_bundle", +] diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py new file mode 100644 index 000000000..19e929b4f --- /dev/null +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Any, Dict, Tuple, Union + +import numpy as np + +from maro.rl.rollout import AbsEnvSampler, CacheElement +from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine +from tests.rl.gym_wrapper.simulator.common import Action, DecisionEvent + + +def _show_info(rewards: list, tag: str) -> None: + print( + f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " + f"N segments = {len(rewards)}, " + f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " + f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " + f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " + f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n", + ) + + +class GymEnvSampler(AbsEnvSampler): + def _get_global_and_agent_state_impl( + self, + event: DecisionEvent, + tick: int = None, + ) -> Tuple[Union[None, np.ndarray, list], Dict[Any, Union[np.ndarray, list]]]: + return None, {0: event.state} + + def _translate_to_env_action(self, action_dict: dict, event: Any) -> dict: + return {k: Action(v) for k, v in action_dict.items()} + + def _get_reward(self, env_action_dict: dict, event: Any, tick: int) -> Dict[Any, float]: + be = self._env.business_engine + assert isinstance(be, GymBusinessEngine) + return {0: be.get_reward_at_tick(tick)} + + def _post_step(self, cache_element: CacheElement) -> None: + self._info["env_metric"] = self._env.metrics + + def _post_eval_step(self, cache_element: CacheElement) -> None: + self._post_step(cache_element) + + def post_collect(self, info_list: list, ep: int) -> None: + rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] + _show_info(rewards, "Collect") + + def post_evaluate(self, info_list: list, ep: int) -> None: + rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] + _show_info(rewards, "Evaluate") diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py new file mode 100644 index 000000000..80e522e03 --- /dev/null +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import cast + +from examples.gym_new.rl.env_sampler import GymEnvSampler +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle +from maro.simulator import Env +from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine + +env_conf = { + "scenario": "gym", + "start_tick": 0, + "durations": 5000, + "options": { + "random_seed": None, + }, +} + +learn_env = Env(**env_conf) +test_env = learn_env +num_agents = len(learn_env.agent_idx_list) + +gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env +gym_state_dim = gym_env.observation_space.shape[0] +gym_action_dim = gym_env.action_space.shape[0] +action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] +action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] + +algorithm = "ppo" + +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} + +if algorithm == "ppo": + from tests.rl.algorithms.ppo import get_policy, get_ppo + + policies = [ + get_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) + ] + trainers = [get_ppo(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] +else: + raise ValueError(f"Unsupported algorithm: {algorithm}") + +rl_component_bundle = RLComponentBundle( + env_sampler=GymEnvSampler( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + ), + agent2policy=agent2policy, + policies=policies, + trainers=trainers, +) diff --git a/tests/rl/gym_wrapper/simulator/__init__.py b/tests/rl/gym_wrapper/simulator/__init__.py new file mode 100644 index 000000000..9a0454564 --- /dev/null +++ b/tests/rl/gym_wrapper/simulator/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. diff --git a/tests/rl/gym_wrapper/simulator/business_engine.py b/tests/rl/gym_wrapper/simulator/business_engine.py new file mode 100644 index 000000000..51fb31034 --- /dev/null +++ b/tests/rl/gym_wrapper/simulator/business_engine.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import List, Optional, cast + +import gym +from maro.backends.frame import FrameBase, SnapshotList + +from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents +from maro.simulator.scenarios import AbsBusinessEngine +from .common import Action, DecisionEvent + + +class GymBusinessEngine(AbsBusinessEngine): + def __init__( + self, + event_buffer: EventBuffer, + topology: Optional[str], + start_tick: int, + max_tick: int, + snapshot_resolution: int, + max_snapshots: Optional[int], + additional_options: dict = None, + ) -> None: + super(GymBusinessEngine, self).__init__( + scenario_name="gym", + event_buffer=event_buffer, + topology=topology, + start_tick=start_tick, + max_tick=max_tick, + snapshot_resolution=snapshot_resolution, + max_snapshots=max_snapshots, + additional_options=additional_options, + ) + + self._gym_scenario_name = "Walker2d-v4" + self._gym_env = gym.make(self._gym_scenario_name) + self._seed = additional_options.get("random_seed", None) + + self._last_obs = self._gym_env.reset()[0] + self._is_done = False + self._reward_record = {} + self._info_record = {} + + self._frame: FrameBase = FrameBase() + self._snapshots: SnapshotList = self._frame.snapshots + + self._register_events() + + @property + def gym_env(self) -> gym.Env: + return self._gym_env + + @property + def frame(self) -> FrameBase: + return self._frame + + @property + def snapshots(self) -> SnapshotList: + return self._snapshots + + def _register_events(self) -> None: + self._event_buffer.register_event_handler(MaroEvents.TAKE_ACTION, self._on_action_received) + + def _on_action_received(self, event: CascadeEvent) -> None: + action = cast(Action, event.payload[0]).action + + self._last_obs, reward, self._is_done, _, info = self._gym_env.step(action) + self._reward_record[event.tick] = reward + self._info_record[event.tick] = info + + def step(self, tick: int) -> None: + self._event_buffer.insert_event(self._event_buffer.gen_decision_event(tick, DecisionEvent(self._last_obs))) + + @property + def configs(self) -> dict: + return {} + + def get_reward_at_tick(self, tick: int) -> float: + return self._reward_record[tick] + + def get_info_at_tick(self, tick: int) -> object: # TODO + return self._info_record[tick] + + def reset(self, keep_seed: bool = False) -> None: + self._last_obs = self._gym_env.reset()[0] + self._is_done = False + self._reward_record = {} + self._info_record = {} + + def post_step(self, tick: int) -> bool: + return self._is_done or tick + 1 == self._max_tick + + def get_agent_idx_list(self) -> List[int]: + return [0] + + def get_metrics(self) -> dict: + return { + "reward_record": {k: v for k, v in self._reward_record.items()}, + } + + def set_seed(self, seed: int) -> None: + pass diff --git a/tests/rl/gym_wrapper/simulator/common.py b/tests/rl/gym_wrapper/simulator/common.py new file mode 100644 index 000000000..f2de98173 --- /dev/null +++ b/tests/rl/gym_wrapper/simulator/common.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import numpy as np + +from maro.common import BaseAction, BaseDecisionEvent + + +class Action(BaseAction): + def __init__(self, action: np.ndarray) -> None: + self.action = action + + +class DecisionEvent(BaseDecisionEvent): + def __init__(self, state: np.ndarray) -> None: + self.state = state diff --git a/tests/rl/run.py b/tests/rl/run.py new file mode 100644 index 000000000..e15cd7c8e --- /dev/null +++ b/tests/rl/run.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import argparse + +from maro.cli.local.commands import run + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("conf_path", help="Path of the job deployment") + parser.add_argument("--evaluate_only", action="store_true", help="Only run evaluation part of the workflow") + return parser.parse_args() + + +if __name__ == "__main__": + args = get_args() + run(conf_path=args.conf_path, containerize=False, evaluate_only=args.evaluate_only) From 65ba1a11b415df152bd0940bda5334a9680e0830 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 18 Jan 2023 03:23:47 +0000 Subject: [PATCH 14/45] Remove old versions --- examples/gym/rl/__init__.py | 16 --- examples/gym/rl/algorithms/__init__.py | 0 examples/gym/rl/algorithms/ddpg.py | 75 ------------- examples/gym/rl/algorithms/ppo.py | 89 ---------------- examples/gym/rl/algorithms/random.py | 19 ---- examples/gym/rl/algorithms/sac.py | 98 ----------------- examples/gym/rl/algorithms/utils.py | 15 --- examples/gym/rl/callbacks.py | 25 ----- examples/gym/rl/config.py | 1 - examples/gym/rl/env_helper.py | 23 ---- examples/gym/rl/env_sampler.py | 45 -------- examples/gym/rl/policy_trainer.py | 40 ------- examples/gym_new/rl/__init__.py | 8 -- examples/gym_new/rl/algorithms/__init__.py | 0 examples/gym_new/rl/algorithms/ppo.py | 94 ---------------- examples/gym_new/rl/algorithms/utils.py | 18 ---- examples/gym_new/rl/env_sampler.py | 51 --------- examples/gym_new/rl/rl_component_bundle.py | 54 ---------- examples/rl/gym.yml | 35 ------ maro/simulator/scenarios/gym/__init__.py | 0 .../scenarios/gym/business_engine.py | 100 ------------------ maro/simulator/scenarios/gym/common.py | 16 --- .../scenarios/gym/test_business_engine.py | 22 ---- tests/rl/gym_wrapper/rl_component_bundle.py | 6 +- 24 files changed, 4 insertions(+), 846 deletions(-) delete mode 100644 examples/gym/rl/__init__.py delete mode 100644 examples/gym/rl/algorithms/__init__.py delete mode 100644 examples/gym/rl/algorithms/ddpg.py delete mode 100644 examples/gym/rl/algorithms/ppo.py delete mode 100644 examples/gym/rl/algorithms/random.py delete mode 100644 examples/gym/rl/algorithms/sac.py delete mode 100644 examples/gym/rl/algorithms/utils.py delete mode 100644 examples/gym/rl/callbacks.py delete mode 100644 examples/gym/rl/config.py delete mode 100644 examples/gym/rl/env_helper.py delete mode 100644 examples/gym/rl/env_sampler.py delete mode 100644 examples/gym/rl/policy_trainer.py delete mode 100644 examples/gym_new/rl/__init__.py delete mode 100644 examples/gym_new/rl/algorithms/__init__.py delete mode 100644 examples/gym_new/rl/algorithms/ppo.py delete mode 100644 examples/gym_new/rl/algorithms/utils.py delete mode 100644 examples/gym_new/rl/env_sampler.py delete mode 100644 examples/gym_new/rl/rl_component_bundle.py delete mode 100644 examples/rl/gym.yml delete mode 100644 maro/simulator/scenarios/gym/__init__.py delete mode 100644 maro/simulator/scenarios/gym/business_engine.py delete mode 100644 maro/simulator/scenarios/gym/common.py delete mode 100644 maro/simulator/scenarios/gym/test_business_engine.py diff --git a/examples/gym/rl/__init__.py b/examples/gym/rl/__init__.py deleted file mode 100644 index af4ce720f..000000000 --- a/examples/gym/rl/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from .callbacks import post_collect, post_evaluate -from .env_sampler import agent2policy, env_sampler_creator -from .policy_trainer import device_mapping, policy_creator, trainer_creator - -__all__ = [ - "agent2policy", - "device_mapping", - "env_sampler_creator", - "policy_creator", - "post_collect", - "post_evaluate", - "trainer_creator", -] diff --git a/examples/gym/rl/algorithms/__init__.py b/examples/gym/rl/algorithms/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/gym/rl/algorithms/ddpg.py b/examples/gym/rl/algorithms/ddpg.py deleted file mode 100644 index ead02e563..000000000 --- a/examples/gym/rl/algorithms/ddpg.py +++ /dev/null @@ -1,75 +0,0 @@ -import torch -from torch.optim import Adam - -from maro.rl.model import ContinuousDDPGNet, ContinuousQNet -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import DDPGParams, DDPGTrainer - -from examples.gym.rl.algorithms.utils import mlp -from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim - -actor_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, -} -critic_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, -} -actor_learning_rate = 1e-3 -critic_learning_rate = 1e-3 - - -class MyActorNet(ContinuousDDPGNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - self._fc = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], - activation=actor_net_conf["activation"], - output_activation=torch.nn.Tanh, - ) - self._limit = gym_env.action_space.high[0] - self._optim = Adam(self._fc.parameters(), lr=actor_learning_rate) - - def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: - actions = self._limit * self._fc(states.float()) - actions += 0.1 * torch.rand(actions.shape) - return torch.clamp(actions, -self._limit, self._limit) - - -class MyCriticNet(ContinuousQNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._critic = mlp( - [state_dim + action_dim] + critic_net_conf["hidden_dims"] + [1], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - return self._critic(torch.cat([states.float(), actions], dim=1)).reshape(-1) - - -def get_policy(name: str) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyActorNet(gym_state_dim, gym_action_dim), - ) - - -def get_ddpg(name: str) -> DDPGTrainer: - return DDPGTrainer( - name=name, - params=DDPGParams( - replay_memory_capacity=int(1e6), - batch_size=128, - get_q_critic_net_func=lambda: MyCriticNet(gym_state_dim, gym_action_dim), - reward_discount=0.99, - soft_update_coef=0.01, - update_target_every=1, - num_epochs=100, - min_num_to_trigger_training=10000, - ), - ) diff --git a/examples/gym/rl/algorithms/ppo.py b/examples/gym/rl/algorithms/ppo.py deleted file mode 100644 index bd93cf325..000000000 --- a/examples/gym/rl/algorithms/ppo.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Tuple - -import numpy as np -import torch -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousACBasedNet, VNet -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import PPOParams, PPOTrainer - -from examples.gym.rl.algorithms.utils import mlp -from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_state_dim - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - - -class MyActorNet(ContinuousACBasedNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], - activation=actor_net_conf["activation"], - ) - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - actions = distribution.sample() - logps = distribution.log_prob(actions).sum(axis=-1) - return actions, logps - - def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - distribution = self._distribution(states) - logps = distribution.log_prob(actions).sum(axis=-1) - return logps - - def _distribution(self, states: torch.Tensor) -> Normal: - mu = self._mu_net(states.float()) - std = torch.exp(self._log_std) - return Normal(mu, std) - - -class MyCriticNet(VNet): - def __init__(self, state_dim: int) -> None: - super(MyCriticNet, self).__init__(state_dim=state_dim) - self._critic = mlp( - [state_dim] + critic_net_conf["hidden_dims"] + [1], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: - return self._critic(states.float()).squeeze(-1) - - -def get_policy(name: str) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyActorNet(gym_state_dim, gym_action_dim), - ) - - -def get_ppo(name: str) -> PPOTrainer: - return PPOTrainer( - name=name, - params=PPOParams( - get_v_critic_net_func=lambda: MyCriticNet(gym_state_dim), - reward_discount=0.99, - grad_iters=80, - min_logp=None, - lam=0.97, - clip_ratio=0.2, - is_discrete_action=False, - ), - ) diff --git a/examples/gym/rl/algorithms/random.py b/examples/gym/rl/algorithms/random.py deleted file mode 100644 index ed19f0867..000000000 --- a/examples/gym/rl/algorithms/random.py +++ /dev/null @@ -1,19 +0,0 @@ -import numpy as np -from gym.spaces import Space - -from maro.rl.policy import RuleBasedPolicy - - -class RandomGymPolicy(RuleBasedPolicy): - def __init__(self, name: str, action_space: Space) -> None: - super(RandomGymPolicy, self).__init__(name=name) - self._action_space = action_space - - def _rule(self, states: np.ndarray) -> object: - n_sample = states.shape[0] - action = [self._action_space.sample() for _ in range(n_sample)] - return action - - -def get_policy(action_space: Space, name: str) -> RuleBasedPolicy: - return RandomGymPolicy(name=name, action_space=action_space) diff --git a/examples/gym/rl/algorithms/sac.py b/examples/gym/rl/algorithms/sac.py deleted file mode 100644 index 4bd533e7a..000000000 --- a/examples/gym/rl/algorithms/sac.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Tuple - -import numpy as np -import torch -import torch.nn.functional as F -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousQNet, ContinuousSACNet -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer - -from examples.gym.rl.algorithms.utils import mlp -from examples.gym.rl.env_helper import action_lower_bound, action_upper_bound, gym_action_dim, gym_env, gym_state_dim - -actor_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, -} -critic_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, -} -actor_learning_rate = 1e-3 -critic_learning_rate = 1e-3 - - -class MyActorNet(ContinuousSACNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - self._fc = mlp( - [state_dim] + actor_net_conf["hidden_dims"], - activation=actor_net_conf["activation"], - output_activation=actor_net_conf["activation"], - ) - self._mu_layer = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) - self._log_std_layer = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) - self._limit = gym_env.action_space.high[0] - - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - if exploring: - actions = distribution.rsample() - logps = distribution.log_prob(actions).sum(axis=-1) - logps -= (2 * (np.log(2) - actions - F.softplus(-2 * actions))).sum(axis=1) - else: - actions = distribution.loc - logps = torch.randn(states.shape[0]) # Fake - return self._limit * torch.tanh(actions), logps - - def _distribution(self, states: torch.Tensor) -> Normal: - net_out = self._fc(states.float()) - mu = self._mu_layer(net_out) - log_std = self._log_std_layer(net_out) - log_std = torch.clamp(log_std, -20, 2) # TODO - std = torch.exp(log_std) - return Normal(mu, std) - - -class MyCriticNet(ContinuousQNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._critic = mlp( - [state_dim + action_dim] + critic_net_conf["hidden_dims"] + [1], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - return self._critic(torch.cat([states.float(), actions], dim=1)).reshape(-1) - - -def get_policy(name: str) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyActorNet(gym_state_dim, gym_action_dim), - ) - - -def get_sac(name: str) -> SoftActorCriticTrainer: - return SoftActorCriticTrainer( - name=name, - params=SoftActorCriticParams( - replay_memory_capacity=int(1e6), - batch_size=128, - get_q_critic_net_func=lambda: MyCriticNet(gym_state_dim, gym_action_dim), - reward_discount=0.99, - soft_update_coef=0.01, - update_target_every=1, - entropy_coef=0.2, - num_epochs=100, - n_start_train=10000, - ), - ) diff --git a/examples/gym/rl/algorithms/utils.py b/examples/gym/rl/algorithms/utils.py deleted file mode 100644 index 75511dd33..000000000 --- a/examples/gym/rl/algorithms/utils.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import List, Type - -import torch - - -def mlp( - sizes: List[int], - activation: Type[torch.nn.Module], - output_activation: Type[torch.nn.Module] = torch.nn.Identity, -) -> torch.nn.Sequential: - layers = [] - for j in range(len(sizes) - 1): - act = activation if j < len(sizes) - 2 else output_activation - layers += [torch.nn.Linear(sizes[j], sizes[j + 1]), act()] - return torch.nn.Sequential(*layers) diff --git a/examples/gym/rl/callbacks.py b/examples/gym/rl/callbacks.py deleted file mode 100644 index 08232d5b7..000000000 --- a/examples/gym/rl/callbacks.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import numpy as np - - -def _show_info(rewards: list, tag: str) -> None: - print( - f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " - f"N segments = {len(rewards)}, " - f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " - f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " - f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " - f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n", - ) - - -def post_collect(info_list: list, ep: int, segment: int) -> None: - rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - _show_info(rewards, "Collect") - - -def post_evaluate(info_list: list, ep: int) -> None: - rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - _show_info(rewards, "Evaluate") diff --git a/examples/gym/rl/config.py b/examples/gym/rl/config.py deleted file mode 100644 index e30f48bff..000000000 --- a/examples/gym/rl/config.py +++ /dev/null @@ -1 +0,0 @@ -algorithm = "sac" diff --git a/examples/gym/rl/env_helper.py b/examples/gym/rl/env_helper.py deleted file mode 100644 index fc7908ed7..000000000 --- a/examples/gym/rl/env_helper.py +++ /dev/null @@ -1,23 +0,0 @@ -from maro.simulator import Env -from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine - -env_conf = { - "scenario": "gym", - "start_tick": 0, - "durations": 5000, - "options": { - "random_seed": None, - }, -} - -helper_env = Env(**env_conf) -be = helper_env.business_engine -assert isinstance(be, GymBusinessEngine) - -gym_env = be.gym_env -gym_state_dim = gym_env.observation_space.shape[0] -gym_action_dim = gym_env.action_space.shape[0] -# action_lower_bound = gym_env.action_space.low.tolist() -# action_upper_bound = gym_env.action_space.high.tolist() -action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] # TODO -action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] # TODO diff --git a/examples/gym/rl/env_sampler.py b/examples/gym/rl/env_sampler.py deleted file mode 100644 index f5d06b9a7..000000000 --- a/examples/gym/rl/env_sampler.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any, Callable, Dict, Optional, Tuple - -import numpy as np - -from maro.rl.policy import AbsPolicy -from maro.rl.rollout import AbsEnvSampler, CacheElement -from maro.simulator import Env -from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine - -from .config import algorithm -from .env_helper import env_conf, helper_env - - -class GymEnvSampler(AbsEnvSampler): - def _get_global_and_agent_state_impl( - self, - event: np.ndarray, - tick: int = None, - ) -> Tuple[Optional[np.ndarray], Dict[Any, np.ndarray]]: - return None, {0: event} - - def _translate_to_env_action(self, action_dict: Dict[Any, np.ndarray], event: object) -> Dict[Any, object]: - return action_dict # TODO - - def _get_reward(self, env_action_dict: Dict[Any, object], event: object, tick: int) -> Dict[Any, float]: - be = self._env.business_engine - assert isinstance(be, GymBusinessEngine) - return {0: be.get_reward_at_tick(tick)} - - def _post_step(self, cache_element: CacheElement, reward: Dict[Any, float]) -> None: - self._info["env_metric"] = self._env.metrics - - def _post_eval_step(self, cache_element: CacheElement, reward: Dict[Any, float]) -> None: - self._post_step(cache_element, reward) - - -agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in helper_env.agent_idx_list} - - -def env_sampler_creator(policy_creator: Dict[str, Callable[[str], AbsPolicy]]) -> GymEnvSampler: - return GymEnvSampler( - get_env=lambda: Env(**env_conf), - policy_creator=policy_creator, - agent2policy=agent2policy, - ) diff --git a/examples/gym/rl/policy_trainer.py b/examples/gym/rl/policy_trainer.py deleted file mode 100644 index 44e13fc8a..000000000 --- a/examples/gym/rl/policy_trainer.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from functools import partial - -from maro.rl.training.utils import extract_trainer_name -from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine - -from .config import algorithm -from .env_helper import helper_env - -be = helper_env.business_engine -assert isinstance(be, GymBusinessEngine) - -if algorithm == "random": - from .algorithms.random import get_policy - - policy_creator = { - f"{algorithm}_{i}.policy": partial(get_policy, be.gym_env.action_space) for i in helper_env.agent_idx_list - } - trainer_creator = {} -elif algorithm == "ppo": - from .algorithms.ppo import get_policy, get_ppo - - policy_creator = {f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list} - trainer_creator = {extract_trainer_name(policy_name): get_ppo for policy_name in policy_creator} -elif algorithm == "ddpg": - from .algorithms.ddpg import get_ddpg, get_policy - - policy_creator = {f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list} - trainer_creator = {extract_trainer_name(policy_name): get_ddpg for policy_name in policy_creator} -elif algorithm == "sac": - from .algorithms.sac import get_policy, get_sac - - policy_creator = {f"{algorithm}_{i}.policy": get_policy for i in helper_env.agent_idx_list} - trainer_creator = {extract_trainer_name(policy_name): get_sac for policy_name in policy_creator} -else: - raise ValueError(f"Unsupported algorithm: {algorithm}") - -device_mapping = {policy_name: "cpu" for policy_name in policy_creator} diff --git a/examples/gym_new/rl/__init__.py b/examples/gym_new/rl/__init__.py deleted file mode 100644 index 90be439f0..000000000 --- a/examples/gym_new/rl/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from .rl_component_bundle import rl_component_bundle - -__all__ = [ - "rl_component_bundle", -] diff --git a/examples/gym_new/rl/algorithms/__init__.py b/examples/gym_new/rl/algorithms/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/gym_new/rl/algorithms/ppo.py b/examples/gym_new/rl/algorithms/ppo.py deleted file mode 100644 index 71e1d7d57..000000000 --- a/examples/gym_new/rl/algorithms/ppo.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import Tuple - -import numpy as np -import torch -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousACBasedNet, VNet -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import PPOParams, PPOTrainer -from .utils import mlp - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - - -class MyActorNet(ContinuousACBasedNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], - activation=actor_net_conf["activation"], - ) - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - actions = distribution.sample() - logps = distribution.log_prob(actions).sum(axis=-1) - return actions, logps - - def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - distribution = self._distribution(states) - logps = distribution.log_prob(actions).sum(axis=-1) - return logps - - def _distribution(self, states: torch.Tensor) -> Normal: - mu = self._mu_net(states.float()) - std = torch.exp(self._log_std) - return Normal(mu, std) - - -class MyCriticNet(VNet): - def __init__(self, state_dim: int) -> None: - super(MyCriticNet, self).__init__(state_dim=state_dim) - self._critic = mlp( - [state_dim] + critic_net_conf["hidden_dims"] + [1], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: - return self._critic(states.float()).squeeze(-1) - - -def get_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyActorNet(gym_state_dim, gym_action_dim), - ) - - -def get_ppo(name: str, state_dim: int) -> PPOTrainer: - return PPOTrainer( - name=name, - reward_discount=0.99, - params=PPOParams( - get_v_critic_net_func=lambda: MyCriticNet(state_dim), - grad_iters=80, - lam=0.97, - clip_ratio=0.2, - ), - ) diff --git a/examples/gym_new/rl/algorithms/utils.py b/examples/gym_new/rl/algorithms/utils.py deleted file mode 100644 index d05df6d01..000000000 --- a/examples/gym_new/rl/algorithms/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import List, Type - -import torch - - -def mlp( - sizes: List[int], - activation: Type[torch.nn.Module], - output_activation: Type[torch.nn.Module] = torch.nn.Identity, -) -> torch.nn.Sequential: - layers = [] - for j in range(len(sizes) - 1): - act = activation if j < len(sizes) - 2 else output_activation - layers += [torch.nn.Linear(sizes[j], sizes[j + 1]), act()] - return torch.nn.Sequential(*layers) diff --git a/examples/gym_new/rl/env_sampler.py b/examples/gym_new/rl/env_sampler.py deleted file mode 100644 index 745fa7290..000000000 --- a/examples/gym_new/rl/env_sampler.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. -from typing import Any, Dict, Tuple, Union - -import numpy as np - -from maro.rl.rollout import AbsEnvSampler, CacheElement -from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine -from maro.simulator.scenarios.gym.common import Action - - -def _show_info(rewards: list, tag: str) -> None: - print( - f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " - f"N segments = {len(rewards)}, " - f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " - f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " - f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " - f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n", - ) - - -class GymEnvSampler(AbsEnvSampler): - def _get_global_and_agent_state_impl( - self, - event: np.ndarray, - tick: int = None, - ) -> Tuple[Union[None, np.ndarray, list], Dict[Any, Union[np.ndarray, list]]]: - return None, {0: event.state} - - def _translate_to_env_action(self, action_dict: dict, event: Any) -> dict: - return {k: Action(v) for k, v in action_dict.items()} - - def _get_reward(self, env_action_dict: dict, event: Any, tick: int) -> Dict[Any, float]: - be = self._env.business_engine - assert isinstance(be, GymBusinessEngine) - return {0: be.get_reward_at_tick(tick)} - - def _post_step(self, cache_element: CacheElement) -> None: - self._info["env_metric"] = self._env.metrics - - def _post_eval_step(self, cache_element: CacheElement) -> None: - self._post_step(cache_element) - - def post_collect(self, info_list: list, ep: int) -> None: - rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - _show_info(rewards, "Collect") - - def post_evaluate(self, info_list: list, ep: int) -> None: - rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - _show_info(rewards, "Evaluate") diff --git a/examples/gym_new/rl/rl_component_bundle.py b/examples/gym_new/rl/rl_component_bundle.py deleted file mode 100644 index 89c618d91..000000000 --- a/examples/gym_new/rl/rl_component_bundle.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. -from typing import cast - -from examples.gym_new.rl.env_sampler import GymEnvSampler -from maro.rl.rl_component.rl_component_bundle import RLComponentBundle -from maro.simulator import Env -from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine - -env_conf = { - "scenario": "gym", - "start_tick": 0, - "durations": 5000, - "options": { - "random_seed": None, - }, -} - -learn_env = Env(**env_conf) -test_env = learn_env -num_agents = len(learn_env.agent_idx_list) - -gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env -gym_state_dim = gym_env.observation_space.shape[0] -gym_action_dim = gym_env.action_space.shape[0] -action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] -action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] - -algorithm = "ppo" - -agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} - -if algorithm == "ppo": - from .algorithms.ppo import get_policy, get_ppo - - policies = [ - get_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) - ] - trainers = [get_ppo(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] -else: - raise ValueError(f"Unsupported algorithm: {algorithm}") - -rl_component_bundle = RLComponentBundle( - env_sampler=GymEnvSampler( - learn_env=learn_env, - test_env=test_env, - policies=policies, - agent2policy=agent2policy, - ), - agent2policy=agent2policy, - policies=policies, - trainers=trainers, -) diff --git a/examples/rl/gym.yml b/examples/rl/gym.yml deleted file mode 100644 index 2c1722f2a..000000000 --- a/examples/rl/gym.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -# Example RL config file for GYM scenario. -# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. - -# Run this workflow by executing one of the following commands: -# - python .\examples\rl\run.py .\examples\rl\config.yml -# - (Requires installing MARO from source) maro local run .\examples\rl\config.yml - -job: gym_rl_workflow -scenario_path: "examples/gym_new/rl" -log_path: "log/rl_job/gym.txt" -main: - num_episodes: 600 - num_steps: null - eval_schedule: 5 - min_n_sample: 5000 - logging: - stdout: INFO - file: DEBUG -rollout: - logging: - stdout: INFO - file: DEBUG -training: - mode: simple - load_path: null - load_episode: null - checkpointing: - path: "checkpoint/rl_job/gym" - interval: 5 - logging: - stdout: INFO - file: DEBUG diff --git a/maro/simulator/scenarios/gym/__init__.py b/maro/simulator/scenarios/gym/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/maro/simulator/scenarios/gym/business_engine.py b/maro/simulator/scenarios/gym/business_engine.py deleted file mode 100644 index 8c9d14045..000000000 --- a/maro/simulator/scenarios/gym/business_engine.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import List, Optional, cast - -import gym - -from maro.backends.frame import FrameBase, SnapshotList -from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents -from maro.simulator.scenarios import AbsBusinessEngine -from maro.simulator.scenarios.gym.common import Action, DecisionEvent - - -class GymBusinessEngine(AbsBusinessEngine): - def __init__( - self, - event_buffer: EventBuffer, - topology: Optional[str], - start_tick: int, - max_tick: int, - snapshot_resolution: int, - max_snapshots: Optional[int], - additional_options: dict = None, - ) -> None: - super(GymBusinessEngine, self).__init__( - scenario_name="gym", - event_buffer=event_buffer, - topology=topology, - start_tick=start_tick, - max_tick=max_tick, - snapshot_resolution=snapshot_resolution, - max_snapshots=max_snapshots, - additional_options=additional_options, - ) - - self._gym_scenario_name = "Walker2d-v4" - self._gym_env = gym.make(self._gym_scenario_name) - self._seed = additional_options.get("random_seed", None) - - self._last_obs = self._gym_env.reset()[0] - self._is_done = False - self._reward_record = {} - self._info_record = {} - - self._frame: FrameBase = FrameBase() - self._snapshots: SnapshotList = self._frame.snapshots - - self._register_events() - - @property - def gym_env(self) -> gym.Env: - return self._gym_env - - @property - def frame(self) -> FrameBase: - return self._frame - - @property - def snapshots(self) -> SnapshotList: - return self._snapshots - - def _register_events(self) -> None: - self._event_buffer.register_event_handler(MaroEvents.TAKE_ACTION, self._on_action_received) - - def _on_action_received(self, event: CascadeEvent) -> None: - action = cast(Action, event.payload[0]).action - - self._last_obs, reward, self._is_done, _, info = self._gym_env.step(action) - self._reward_record[event.tick] = reward - self._info_record[event.tick] = info - - def step(self, tick: int) -> None: - self._event_buffer.insert_event(self._event_buffer.gen_decision_event(tick, DecisionEvent(self._last_obs))) - - @property - def configs(self) -> dict: - return {} - - def get_reward_at_tick(self, tick: int) -> float: - return self._reward_record[tick] - - def get_info_at_tick(self, tick: int) -> object: # TODO - return self._info_record[tick] - - def reset(self, keep_seed: bool = False) -> None: - self._last_obs = self._gym_env.reset()[0] - self._is_done = False - self._reward_record = {} - self._info_record = {} - - def post_step(self, tick: int) -> bool: - return self._is_done or tick + 1 == self._max_tick - - def get_agent_idx_list(self) -> List[int]: - return [0] - - def get_metrics(self) -> dict: - return { - "reward_record": {k: v for k, v in self._reward_record.items()}, - } - - def set_seed(self, seed: int) -> None: - pass diff --git a/maro/simulator/scenarios/gym/common.py b/maro/simulator/scenarios/gym/common.py deleted file mode 100644 index f2de98173..000000000 --- a/maro/simulator/scenarios/gym/common.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import numpy as np - -from maro.common import BaseAction, BaseDecisionEvent - - -class Action(BaseAction): - def __init__(self, action: np.ndarray) -> None: - self.action = action - - -class DecisionEvent(BaseDecisionEvent): - def __init__(self, state: np.ndarray) -> None: - self.state = state diff --git a/maro/simulator/scenarios/gym/test_business_engine.py b/maro/simulator/scenarios/gym/test_business_engine.py deleted file mode 100644 index 2c67e00ff..000000000 --- a/maro/simulator/scenarios/gym/test_business_engine.py +++ /dev/null @@ -1,22 +0,0 @@ -from maro.simulator import Env -from maro.simulator.scenarios.gym.business_engine import GymBusinessEngine - -if __name__ == "__main__": - env = Env( - scenario="gym", - start_tick=0, - durations=10, - ) - be = env.business_engine - assert isinstance(be, GymBusinessEngine) - gym_env = be.gym_env - - print(gym_env.action_space.low) - print(gym_env.action_space.high) - - metrics, decision_event, is_done = env.step(None) - while not is_done: - action = gym_env.action_space.sample() - print(f"State at tick {env.tick}: {decision_event}") - print(f"Action: {action}") - metrics, decision_event, is_done = env.step(action) diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 80e522e03..d0008e850 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -3,13 +3,15 @@ from typing import cast -from examples.gym_new.rl.env_sampler import GymEnvSampler from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.simulator import Env from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine +from .env_sampler import GymEnvSampler + env_conf = { - "scenario": "gym", + # "scenario": "gym", + "business_engine_cls": GymBusinessEngine, "start_tick": 0, "durations": 5000, "options": { From f4a85b83350ad84c7e64f07d1cde7df90630ce23 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 18 Jan 2023 11:40:47 +0800 Subject: [PATCH 15/45] PPO done --- examples/rl/cim.yml | 4 ++-- examples/rl/cim_distributed.yml | 6 +++--- examples/rl/{run_rl_example.py => run.py} | 0 examples/rl/vm_scheduling.yml | 4 ++-- tests/rl/__init__.py | 8 -------- tests/rl/algorithms/__init__.py | 2 ++ tests/rl/gym_wrapper/config.py | 4 ++++ tests/rl/gym_wrapper/rl_component_bundle.py | 5 ++--- tests/rl/gym_wrapper/simulator/business_engine.py | 9 +++------ 9 files changed, 18 insertions(+), 24 deletions(-) rename examples/rl/{run_rl_example.py => run.py} (100%) delete mode 100644 tests/rl/__init__.py create mode 100644 tests/rl/gym_wrapper/config.py diff --git a/examples/rl/cim.yml b/examples/rl/cim.yml index ce149972b..95549a7a8 100644 --- a/examples/rl/cim.yml +++ b/examples/rl/cim.yml @@ -5,8 +5,8 @@ # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run.py .\examples\rl\cim.yml -# - (Requires installing MARO from source) maro local run .\examples\rl\cim.yml +# - python ./examples/rl/run.py ./examples/rl/cim.yml +# - (Requires installing MARO from source) maro local run ./examples/rl/cim.yml job: cim_rl_workflow scenario_path: "examples/cim/rl" diff --git a/examples/rl/cim_distributed.yml b/examples/rl/cim_distributed.yml index 17027888b..2a52a0846 100644 --- a/examples/rl/cim_distributed.yml +++ b/examples/rl/cim_distributed.yml @@ -1,12 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -# Example RL config file for CIM scenario. +# Example RL config file for CIM scenario (distributed version). # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run.py .\examples\rl\cim.yml -# - (Requires installing MARO from source) maro local run .\examples\rl\cim.yml +# - python ./examples/rl/run.py ./examples/rl/cim_distributed.yml +# - (Requires installing MARO from source) maro local run ./examples/rl/cim_distributed.yml job: cim_rl_workflow scenario_path: "examples/cim/rl" diff --git a/examples/rl/run_rl_example.py b/examples/rl/run.py similarity index 100% rename from examples/rl/run_rl_example.py rename to examples/rl/run.py diff --git a/examples/rl/vm_scheduling.yml b/examples/rl/vm_scheduling.yml index 926a3cf93..7ec2a79e0 100644 --- a/examples/rl/vm_scheduling.yml +++ b/examples/rl/vm_scheduling.yml @@ -5,8 +5,8 @@ # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. # Run this workflow by executing one of the following commands: -# - python .\examples\rl\run.py .\examples\rl\vm_scheduling.yml -# - (Requires installing MARO from source) maro local run .\examples\rl\vm_scheduling.yml +# - python ./examples/rl/run.py ./examples/rl/vm_scheduling.yml +# - (Requires installing MARO from source) maro local run ./examples/rl/vm_scheduling.yml job: vm_scheduling_rl_workflow scenario_path: "examples/vm_scheduling/rl" diff --git a/tests/rl/__init__.py b/tests/rl/__init__.py deleted file mode 100644 index d3af9b425..000000000 --- a/tests/rl/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from tests.rl.gym_wrapper.rl_component_bundle import rl_component_bundle - -__all__ = [ - "rl_component_bundle", -] diff --git a/tests/rl/algorithms/__init__.py b/tests/rl/algorithms/__init__.py index e69de29bb..9a0454564 100644 --- a/tests/rl/algorithms/__init__.py +++ b/tests/rl/algorithms/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py new file mode 100644 index 000000000..a52e6286e --- /dev/null +++ b/tests/rl/gym_wrapper/config.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +algorithm = "ppo" diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index d0008e850..1239270c4 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -6,12 +6,13 @@ from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.simulator import Env from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine +from .config import algorithm from .env_sampler import GymEnvSampler env_conf = { - # "scenario": "gym", "business_engine_cls": GymBusinessEngine, + "topology": "Walker2d-v4", "start_tick": 0, "durations": 5000, "options": { @@ -29,8 +30,6 @@ action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] -algorithm = "ppo" - agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} if algorithm == "ppo": diff --git a/tests/rl/gym_wrapper/simulator/business_engine.py b/tests/rl/gym_wrapper/simulator/business_engine.py index 51fb31034..65dee6eff 100644 --- a/tests/rl/gym_wrapper/simulator/business_engine.py +++ b/tests/rl/gym_wrapper/simulator/business_engine.py @@ -33,14 +33,11 @@ def __init__( additional_options=additional_options, ) - self._gym_scenario_name = "Walker2d-v4" + self._gym_scenario_name = topology self._gym_env = gym.make(self._gym_scenario_name) self._seed = additional_options.get("random_seed", None) - self._last_obs = self._gym_env.reset()[0] - self._is_done = False - self._reward_record = {} - self._info_record = {} + self.reset() self._frame: FrameBase = FrameBase() self._snapshots: SnapshotList = self._frame.snapshots @@ -63,7 +60,7 @@ def _register_events(self) -> None: self._event_buffer.register_event_handler(MaroEvents.TAKE_ACTION, self._on_action_received) def _on_action_received(self, event: CascadeEvent) -> None: - action = cast(Action, event.payload[0]).action + action = cast(Action, cast(list, event.payload)[0]).action self._last_obs, reward, self._is_done, _, info = self._gym_env.step(action) self._reward_record[event.tick] = reward From 234919153f289b146b522cc6aec110624e93c55b Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 18 Jan 2023 11:51:22 +0800 Subject: [PATCH 16/45] Start to test AC --- tests/rl/algorithms/ac.py | 93 +++++++++++++++++++++ tests/rl/algorithms/ppo.py | 80 +----------------- tests/rl/gym_wrapper/rl_component_bundle.py | 20 +++-- 3 files changed, 108 insertions(+), 85 deletions(-) create mode 100644 tests/rl/algorithms/ac.py diff --git a/tests/rl/algorithms/ac.py b/tests/rl/algorithms/ac.py new file mode 100644 index 000000000..6912f886d --- /dev/null +++ b/tests/rl/algorithms/ac.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Tuple + +import numpy as np +import torch +from torch.distributions import Normal +from torch.optim import Adam + +from maro.rl.model import ContinuousACBasedNet, VNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer +from .utils import mlp + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + + +class MyContinuousActorNet(ContinuousACBasedNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyContinuousActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.sample() + logps = distribution.log_prob(actions).sum(axis=-1) + return actions, logps + + def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + distribution = self._distribution(states) + logps = distribution.log_prob(actions).sum(axis=-1) + return logps + + def _distribution(self, states: torch.Tensor) -> Normal: + mu = self._mu_net(states.float()) + std = torch.exp(self._log_std) + return Normal(mu, std) + + +class MyCriticNet(VNet): + def __init__(self, state_dim: int) -> None: + super(MyCriticNet, self).__init__(state_dim=state_dim) + self._critic = mlp( + [state_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: + return self._critic(states.float()).squeeze(-1) + + +def get_ac_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyContinuousActorNet(gym_state_dim, gym_action_dim), + ) + + +def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: + return ActorCriticTrainer( + name=name, + reward_discount=0.99, + params=ActorCriticParams( + get_v_critic_net_func=lambda: MyCriticNet(state_dim), + grad_iters=80, + lam=0.97, + ), + ) diff --git a/tests/rl/algorithms/ppo.py b/tests/rl/algorithms/ppo.py index 71e1d7d57..5dd51f138 100644 --- a/tests/rl/algorithms/ppo.py +++ b/tests/rl/algorithms/ppo.py @@ -1,87 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from typing import Tuple - -import numpy as np -import torch -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousACBasedNet, VNet -from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import PPOParams, PPOTrainer -from .utils import mlp - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - - -class MyActorNet(ContinuousACBasedNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], - activation=actor_net_conf["activation"], - ) - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - actions = distribution.sample() - logps = distribution.log_prob(actions).sum(axis=-1) - return actions, logps - - def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - distribution = self._distribution(states) - logps = distribution.log_prob(actions).sum(axis=-1) - return logps +from .ac import MyCriticNet, get_ac_policy - def _distribution(self, states: torch.Tensor) -> Normal: - mu = self._mu_net(states.float()) - std = torch.exp(self._log_std) - return Normal(mu, std) - - -class MyCriticNet(VNet): - def __init__(self, state_dim: int) -> None: - super(MyCriticNet, self).__init__(state_dim=state_dim) - self._critic = mlp( - [state_dim] + critic_net_conf["hidden_dims"] + [1], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: - return self._critic(states.float()).squeeze(-1) - - -def get_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyActorNet(gym_state_dim, gym_action_dim), - ) +get_ppo_policy = get_ac_policy -def get_ppo(name: str, state_dim: int) -> PPOTrainer: +def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: return PPOTrainer( name=name, reward_discount=0.99, diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 1239270c4..9b622ad32 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -32,17 +32,21 @@ agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} -if algorithm == "ppo": - from tests.rl.algorithms.ppo import get_policy, get_ppo - - policies = [ - get_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) - ] - trainers = [get_ppo(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] +if algorithm == "ac": + from tests.rl.algorithms.ac import get_ac_policy as get_policy + from tests.rl.algorithms.ac import get_ac_trainer as get_trainer +elif algorithm == "ppo": + from tests.rl.algorithms.ppo import get_ppo_policy as get_policy + from tests.rl.algorithms.ppo import get_ppo_trainer as get_trainer else: raise ValueError(f"Unsupported algorithm: {algorithm}") +policies = [ + get_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) +] +trainers = [get_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] + rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( learn_env=learn_env, From f6f7dae289942ecb6bacc3311f1129b081522d49 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 18 Jan 2023 12:33:40 +0800 Subject: [PATCH 17/45] Start to test SAC --- tests/rl/algorithms/ac.py | 12 +-- tests/rl/algorithms/ppo.py | 4 +- tests/rl/algorithms/sac.py | 87 +++++++++++++++++++++ tests/rl/gym_wrapper/rl_component_bundle.py | 31 +++++--- 4 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 tests/rl/algorithms/sac.py diff --git a/tests/rl/algorithms/ac.py b/tests/rl/algorithms/ac.py index 6912f886d..4cc99ff53 100644 --- a/tests/rl/algorithms/ac.py +++ b/tests/rl/algorithms/ac.py @@ -25,9 +25,9 @@ critic_learning_rate = 1e-3 -class MyContinuousActorNet(ContinuousACBasedNet): +class MyContinuousACBasedNet(ContinuousACBasedNet): def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyContinuousActorNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + super(MyContinuousACBasedNet, self).__init__(state_dim=state_dim, action_dim=action_dim) log_std = -0.5 * np.ones(action_dim, dtype=np.float32) self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) @@ -54,9 +54,9 @@ def _distribution(self, states: torch.Tensor) -> Normal: return Normal(mu, std) -class MyCriticNet(VNet): +class MyVCriticNet(VNet): def __init__(self, state_dim: int) -> None: - super(MyCriticNet, self).__init__(state_dim=state_dim) + super(MyVCriticNet, self).__init__(state_dim=state_dim) self._critic = mlp( [state_dim] + critic_net_conf["hidden_dims"] + [1], activation=critic_net_conf["activation"], @@ -77,7 +77,7 @@ def get_ac_policy( return ContinuousRLPolicy( name=name, action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousActorNet(gym_state_dim, gym_action_dim), + policy_net=MyContinuousACBasedNet(gym_state_dim, gym_action_dim), ) @@ -86,7 +86,7 @@ def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: name=name, reward_discount=0.99, params=ActorCriticParams( - get_v_critic_net_func=lambda: MyCriticNet(state_dim), + get_v_critic_net_func=lambda: MyVCriticNet(state_dim), grad_iters=80, lam=0.97, ), diff --git a/tests/rl/algorithms/ppo.py b/tests/rl/algorithms/ppo.py index 5dd51f138..41a71e694 100644 --- a/tests/rl/algorithms/ppo.py +++ b/tests/rl/algorithms/ppo.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. from maro.rl.training.algorithms import PPOParams, PPOTrainer -from .ac import MyCriticNet, get_ac_policy +from .ac import MyVCriticNet, get_ac_policy get_ppo_policy = get_ac_policy @@ -12,7 +12,7 @@ def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: name=name, reward_discount=0.99, params=PPOParams( - get_v_critic_net_func=lambda: MyCriticNet(state_dim), + get_v_critic_net_func=lambda: MyVCriticNet(state_dim), grad_iters=80, lam=0.97, clip_ratio=0.2, diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py new file mode 100644 index 000000000..b7ca85770 --- /dev/null +++ b/tests/rl/algorithms/sac.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +from typing import Tuple + +import numpy as np +import torch +from torch.distributions import Normal +from torch.optim import Adam + +from maro.rl.model import ContinuousSACNet, QNet +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer +from tests.rl.algorithms.utils import mlp + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + + +class MyContinuousSACNet(ContinuousSACNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.sample() + logps = distribution.log_prob(actions).sum(axis=-1) + return actions, logps + + def _distribution(self, states: torch.Tensor) -> Normal: + mu = self._mu_net(states.float()) + std = torch.exp(self._log_std) + return Normal(mu, std) + + +class MyQCriticNet(QNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + self._critic = mlp( + [state_dim + action_dim] + critic_net_conf["hidden_dims"] + [1], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) + + +def get_sac_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim), + ) + + +def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCriticTrainer: + return SoftActorCriticTrainer( + name=name, + reward_discount=0.99, + params=SoftActorCriticParams( + get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), + num_epochs=10, + n_start_train=10000, + ), + ) diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 9b622ad32..6a580b359 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -33,19 +33,32 @@ agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} if algorithm == "ac": - from tests.rl.algorithms.ac import get_ac_policy as get_policy - from tests.rl.algorithms.ac import get_ac_trainer as get_trainer + from tests.rl.algorithms.ac import get_ac_policy, get_ac_trainer + + policies = [ + get_ac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) + ] + trainers = [get_ac_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] elif algorithm == "ppo": - from tests.rl.algorithms.ppo import get_ppo_policy as get_policy - from tests.rl.algorithms.ppo import get_ppo_trainer as get_trainer + from tests.rl.algorithms.ppo import get_ppo_policy, get_ppo_trainer + + policies = [ + get_ppo_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) + ] + trainers = [get_ppo_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] +elif algorithm == "sac": + from tests.rl.algorithms.sac import get_sac_policy, get_sac_trainer + + policies = [ + get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) + ] + trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] else: raise ValueError(f"Unsupported algorithm: {algorithm}") -policies = [ - get_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) -] -trainers = [get_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( From 110fec4b3f0d508039986019468e59549e58b9ec Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Sat, 28 Jan 2023 10:57:20 +0800 Subject: [PATCH 18/45] SAC test passed --- tests/rl/algorithms/sac.py | 40 +++++++++++++-------- tests/rl/gym_wrapper/rl_component_bundle.py | 3 +- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index b7ca85770..97d4ff931 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -4,6 +4,7 @@ import numpy as np import torch +import torch.nn.functional as F from torch.distributions import Normal from torch.optim import Adam @@ -23,29 +24,39 @@ actor_learning_rate = 3e-4 critic_learning_rate = 1e-3 +LOG_STD_MAX = 2 +LOG_STD_MIN = -20 + class MyContinuousSACNet(ContinuousSACNet): - def __init__(self, state_dim: int, action_dim: int) -> None: + def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + self._net = mlp( + [state_dim] + actor_net_conf["hidden_dims"], activation=actor_net_conf["activation"], + output_activation=actor_net_conf["activation"], ) + self._mu = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) + self._log_std = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) + self._action_limit = action_limit self._optim = Adam(self.parameters(), lr=actor_learning_rate) def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - actions = distribution.sample() - logps = distribution.log_prob(actions).sum(axis=-1) - return actions, logps + net_out = self._net(states.float()) + mu = self._mu(net_out) + log_std = torch.clamp(self._log_std(net_out), LOG_STD_MIN, LOG_STD_MAX) + std = torch.exp(log_std) + + pi_distribution = Normal(mu, std) + pi_action = pi_distribution.rsample() if exploring else mu + + logp_pi = pi_distribution.log_prob(pi_action).sum(axis=-1) + logp_pi -= (2 * (np.log(2) - pi_action - F.softplus(-2 * pi_action))).sum(axis=1) + + pi_action = torch.tanh(pi_action) * self._action_limit - def _distribution(self, states: torch.Tensor) -> Normal: - mu = self._mu_net(states.float()) - std = torch.exp(self._log_std) - return Normal(mu, std) + return pi_action, logp_pi class MyQCriticNet(QNet): @@ -67,11 +78,12 @@ def get_sac_policy( action_upper_bound: list, gym_state_dim: int, gym_action_dim: int, + action_limit: float, ) -> ContinuousRLPolicy: return ContinuousRLPolicy( name=name, action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim), + policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim, action_limit), ) diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 6a580b359..20651c4e9 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -29,6 +29,7 @@ gym_action_dim = gym_env.action_space.shape[0] action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] +action_limit = gym_env.action_space.high[0] agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} @@ -52,7 +53,7 @@ from tests.rl.algorithms.sac import get_sac_policy, get_sac_trainer policies = [ - get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) for i in range(num_agents) ] trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] From 2a1ccd56a2b002d8bfe9d48254354bbd403c8994 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Sat, 28 Jan 2023 13:27:44 +0800 Subject: [PATCH 19/45] Multiple round in evaluation --- maro/rl/rollout/batch_env_sampler.py | 9 ++- maro/rl/rollout/env_sampler.py | 89 ++++++++++++++------------- maro/rl/rollout/worker.py | 2 +- maro/rl/workflows/config/parser.py | 2 + maro/rl/workflows/config/template.yml | 1 + maro/rl/workflows/main.py | 17 ++--- maro/rl/workflows/rollout_worker.py | 7 +-- maro/rl/workflows/train_worker.py | 7 +-- maro/rl/workflows/utils.py | 9 --- 9 files changed, 73 insertions(+), 70 deletions(-) delete mode 100644 maro/rl/workflows/utils.py diff --git a/maro/rl/rollout/batch_env_sampler.py b/maro/rl/rollout/batch_env_sampler.py index a3504a156..b6b0976f0 100644 --- a/maro/rl/rollout/batch_env_sampler.py +++ b/maro/rl/rollout/batch_env_sampler.py @@ -189,8 +189,13 @@ def sample( "info": [res["info"][0] for res in results], } - def eval(self, policy_state: Dict[str, Dict[str, Any]] = None) -> dict: - req = {"type": "eval", "policy_state": policy_state, "index": self._ep} # -1 signals test + def eval(self, policy_state: Dict[str, Dict[str, Any]] = None, num_episodes: int = 1) -> dict: + req = { + "type": "eval", + "policy_state": policy_state, + "index": self._ep, + "num_eval_episodes": num_episodes, + } # -1 signals test results = self._controller.collect(req, self._eval_parallelism) return { "info": [res["info"][0] for res in results], diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 81e6ccdad..6ed8a4f6c 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -514,50 +514,55 @@ def load_policy_state(self, path: str) -> List[str]: return loaded - def eval(self, policy_state: Dict[str, Dict[str, Any]] = None) -> dict: + def eval(self, policy_state: Dict[str, Dict[str, Any]] = None, num_episodes: int = 1) -> dict: self._switch_env(self._test_env) - self._reset() - if policy_state is not None: - self.set_policy_state(policy_state) - - self._agent_wrapper.exploit() - while not self._end_of_episode: - action_dict = self._agent_wrapper.choose_actions(self._agent_state_dict) - env_action_dict = self._translate_to_env_action(action_dict, self._event) + info_list = [] - # Store experiences in the cache - cache_element = CacheElement( - tick=self.env.tick, - event=self._event, - state=self._state, - agent_state_dict=self._select_trainable_agents(self._agent_state_dict), - action_dict=self._select_trainable_agents(action_dict), - env_action_dict=self._select_trainable_agents(env_action_dict), - # The following will be generated later - reward_dict={}, - terminal_dict={}, - next_state=None, - next_agent_state_dict={}, - ) - - # Update env and get new states (global & agent) - self._step(list(env_action_dict.values())) - - if self._reward_eval_delay is None: # TODO: necessary to calculate reward in eval()? - self._calc_reward(cache_element) - self._post_eval_step(cache_element) - - self._append_cache_element(cache_element) - self._append_cache_element(None) - - tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) - while len(self._trans_cache) > 0 and self._trans_cache[0].tick <= tick_bound: - cache_element = self._trans_cache.pop(0) - if self._reward_eval_delay is not None: - self._calc_reward(cache_element) - self._post_eval_step(cache_element) - - return {"info": [self._info]} + for _ in range(num_episodes): + self._reset() + if policy_state is not None: + self.set_policy_state(policy_state) + + self._agent_wrapper.exploit() + while not self._end_of_episode: + action_dict = self._agent_wrapper.choose_actions(self._agent_state_dict) + env_action_dict = self._translate_to_env_action(action_dict, self._event) + + # Store experiences in the cache + cache_element = CacheElement( + tick=self.env.tick, + event=self._event, + state=self._state, + agent_state_dict=self._select_trainable_agents(self._agent_state_dict), + action_dict=self._select_trainable_agents(action_dict), + env_action_dict=self._select_trainable_agents(env_action_dict), + # The following will be generated later + reward_dict={}, + terminal_dict={}, + next_state=None, + next_agent_state_dict={}, + ) + + # Update env and get new states (global & agent) + self._step(list(env_action_dict.values())) + + if self._reward_eval_delay is None: # TODO: necessary to calculate reward in eval()? + self._calc_reward(cache_element) + self._post_eval_step(cache_element) + + self._append_cache_element(cache_element) + self._append_cache_element(None) + + tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) + while len(self._trans_cache) > 0 and self._trans_cache[0].tick <= tick_bound: + cache_element = self._trans_cache.pop(0) + if self._reward_eval_delay is not None: + self._calc_reward(cache_element) + self._post_eval_step(cache_element) + + info_list.append(self._info) + + return {"info": info_list} @abstractmethod def _post_step(self, cache_element: CacheElement) -> None: diff --git a/maro/rl/rollout/worker.py b/maro/rl/rollout/worker.py index b8301ee38..1532a6489 100644 --- a/maro/rl/rollout/worker.py +++ b/maro/rl/rollout/worker.py @@ -59,7 +59,7 @@ def _compute(self, msg: list) -> None: result = ( self._env_sampler.sample(policy_state=req["policy_state"], num_steps=req["num_steps"]) if req["type"] == "sample" - else self._env_sampler.eval(policy_state=req["policy_state"]) + else self._env_sampler.eval(policy_state=req["policy_state"], num_episodes=req["num_eval_episodes"]) ) self._stream.send(pyobj_to_bytes({"result": result, "index": req["index"]})) else: diff --git a/maro/rl/workflows/config/parser.py b/maro/rl/workflows/config/parser.py index db52f065a..fb19a0e90 100644 --- a/maro/rl/workflows/config/parser.py +++ b/maro/rl/workflows/config/parser.py @@ -286,6 +286,8 @@ def get_job_spec(self, containerize: bool = False) -> Dict[str, Tuple[str, Dict[ else: main_proc_env["EVAL_SCHEDULE"] = " ".join([str(val) for val in sorted(sch)]) + main_proc_env["NUM_EVAL_EPISODES"] = str(self._config["main"].get("num_eval_episodes", 1)) + load_path = self._config["training"].get("load_path", None) if load_path is not None: env["main"]["LOAD_PATH"] = path_mapping[load_path] diff --git a/maro/rl/workflows/config/template.yml b/maro/rl/workflows/config/template.yml index 3464e9edc..67b0479ac 100644 --- a/maro/rl/workflows/config/template.yml +++ b/maro/rl/workflows/config/template.yml @@ -24,6 +24,7 @@ main: # A list indicates the episodes at the end of which policies are to be evaluated. Note that episode indexes are # 1-based. eval_schedule: 10 + num_eval_episodes: 10 # Number of Episodes to run in evaluation. # Minimum number of samples to start training in one epoch. The workflow will re-run experience collection # until we have at least `min_n_sample` of experiences. min_n_sample: 1 diff --git a/maro/rl/workflows/main.py b/maro/rl/workflows/main.py index 31de7caa1..a6c954a1c 100644 --- a/maro/rl/workflows/main.py +++ b/maro/rl/workflows/main.py @@ -14,21 +14,20 @@ from maro.rl.utils import get_torch_device from maro.rl.utils.common import float_or_none, get_env, int_or_none, list_or_none from maro.rl.utils.training import get_latest_ep -from maro.rl.workflows.utils import env_str_helper from maro.utils import LoggerV2 class WorkflowEnvAttributes: def __init__(self) -> None: # Number of training episodes - self.num_episodes = int(env_str_helper(get_env("NUM_EPISODES"))) + self.num_episodes = int(get_env("NUM_EPISODES")) # Maximum number of steps in on round of sampling. self.num_steps = int_or_none(get_env("NUM_STEPS", required=False)) # Minimum number of data samples to start a round of training. If the data samples are insufficient, re-run # data sampling until we have at least `min_n_sample` data entries. - self.min_n_sample = int(env_str_helper(get_env("MIN_N_SAMPLE"))) + self.min_n_sample = int(get_env("MIN_N_SAMPLE")) # Path to store logs. self.log_path = get_env("LOG_PATH") @@ -46,6 +45,7 @@ def __init__(self) -> None: # Evaluating schedule. self.eval_schedule = list_or_none(get_env("EVAL_SCHEDULE", required=False)) + self.num_eval_episodes = int_or_none(get_env("NUM_EVAL_EPISODES", required=False)) # Restore configurations. self.load_path = get_env("LOAD_PATH", required=False) @@ -58,7 +58,7 @@ def __init__(self) -> None: # Parallel sampling configurations. self.parallel_rollout = self.env_sampling_parallelism is not None or self.env_eval_parallelism is not None if self.parallel_rollout: - self.port = int(env_str_helper(get_env("ROLLOUT_CONTROLLER_PORT"))) + self.port = int(get_env("ROLLOUT_CONTROLLER_PORT")) self.min_env_samples = int_or_none(get_env("MIN_ENV_SAMPLES", required=False)) self.grace_factor = float_or_none(get_env("GRACE_FACTOR", required=False)) @@ -67,8 +67,8 @@ def __init__(self) -> None: # Distributed training configurations. if self.train_mode != "simple": self.proxy_address = ( - env_str_helper(get_env("TRAIN_PROXY_HOST")), - int(env_str_helper(get_env("TRAIN_PROXY_FRONTEND_PORT"))), + str(get_env("TRAIN_PROXY_HOST")), + int(get_env("TRAIN_PROXY_FRONTEND_PORT")), ) self.logger = LoggerV2( @@ -188,6 +188,7 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow eval_point_index += 1 result = env_sampler.eval( policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, + num_episodes=env_attr.num_eval_episodes ) env_sampler.post_evaluate(result["info"], ep) @@ -210,7 +211,7 @@ def evaluate_only_workflow(rl_component_bundle: RLComponentBundle, env_attr: Wor loaded = env_sampler.load_policy_state(path) env_attr.logger.info(f"Loaded policies {loaded} into env sampler from {path}") - result = env_sampler.eval() + result = env_sampler.eval(num_episodes=env_attr.num_eval_episodes) env_sampler.post_evaluate(result["info"], -1) if isinstance(env_sampler, BatchEnvSampler): @@ -218,7 +219,7 @@ def evaluate_only_workflow(rl_component_bundle: RLComponentBundle, env_attr: Wor if __name__ == "__main__": - scenario_path = env_str_helper(get_env("SCENARIO_PATH")) + scenario_path = get_env("SCENARIO_PATH") scenario_path = os.path.normpath(scenario_path) sys.path.insert(0, os.path.dirname(scenario_path)) module = importlib.import_module(os.path.basename(scenario_path)) diff --git a/maro/rl/workflows/rollout_worker.py b/maro/rl/workflows/rollout_worker.py index 8343873b3..a5a7b4b22 100644 --- a/maro/rl/workflows/rollout_worker.py +++ b/maro/rl/workflows/rollout_worker.py @@ -8,18 +8,17 @@ from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.rollout import RolloutWorker from maro.rl.utils.common import get_env, int_or_none -from maro.rl.workflows.utils import env_str_helper from maro.utils import LoggerV2 if __name__ == "__main__": - scenario_path = env_str_helper(get_env("SCENARIO_PATH")) + scenario_path = get_env("SCENARIO_PATH") scenario_path = os.path.normpath(scenario_path) sys.path.insert(0, os.path.dirname(scenario_path)) module = importlib.import_module(os.path.basename(scenario_path)) rl_component_bundle: RLComponentBundle = getattr(module, "rl_component_bundle") - worker_idx = int(env_str_helper(get_env("ID"))) + worker_idx = int(get_env("ID")) logger = LoggerV2( f"ROLLOUT-WORKER.{worker_idx}", dump_path=get_env("LOG_PATH"), @@ -30,7 +29,7 @@ worker = RolloutWorker( idx=worker_idx, rl_component_bundle=rl_component_bundle, - producer_host=env_str_helper(get_env("ROLLOUT_CONTROLLER_HOST")), + producer_host=get_env("ROLLOUT_CONTROLLER_HOST"), producer_port=int_or_none(get_env("ROLLOUT_CONTROLLER_PORT")), logger=logger, ) diff --git a/maro/rl/workflows/train_worker.py b/maro/rl/workflows/train_worker.py index 4565c5b72..42f868312 100644 --- a/maro/rl/workflows/train_worker.py +++ b/maro/rl/workflows/train_worker.py @@ -8,11 +8,10 @@ from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training import TrainOpsWorker from maro.rl.utils.common import get_env, int_or_none -from maro.rl.workflows.utils import env_str_helper from maro.utils import LoggerV2 if __name__ == "__main__": - scenario_path = env_str_helper(get_env("SCENARIO_PATH")) + scenario_path = get_env("SCENARIO_PATH") scenario_path = os.path.normpath(scenario_path) sys.path.insert(0, os.path.dirname(scenario_path)) module = importlib.import_module(os.path.basename(scenario_path)) @@ -28,9 +27,9 @@ file_level=get_env("LOG_LEVEL_FILE", required=False, default="CRITICAL"), ) worker = TrainOpsWorker( - idx=int(env_str_helper(get_env("ID"))), + idx=int(get_env("ID")), rl_component_bundle=rl_component_bundle, - producer_host=env_str_helper(get_env("TRAIN_PROXY_HOST")), + producer_host=get_env("TRAIN_PROXY_HOST"), producer_port=int_or_none(get_env("TRAIN_PROXY_BACKEND_PORT")), logger=logger, ) diff --git a/maro/rl/workflows/utils.py b/maro/rl/workflows/utils.py deleted file mode 100644 index accfbe86f..000000000 --- a/maro/rl/workflows/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import Optional - - -def env_str_helper(string: Optional[str]) -> str: - assert string is not None - return string From c37122010d2205a22c5726dfdc8b6ce19384f114 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Sat, 28 Jan 2023 13:31:27 +0800 Subject: [PATCH 20/45] Modify config.yml --- tests/rl/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/rl/config.yml b/tests/rl/config.yml index 43644e91d..1d941ded7 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -14,6 +14,7 @@ main: num_episodes: 1000 num_steps: null eval_schedule: 5 + num_eval_episodes: 10 min_n_sample: 5000 logging: stdout: INFO From a65d902e4fe49824d557b43b607c754e8247dd16 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Sat, 28 Jan 2023 22:46:58 +0800 Subject: [PATCH 21/45] Add Callbacks --- .gitignore | 1 + examples/cim/rl/env_sampler.py | 22 +++- examples/rl/cim.yml | 4 +- examples/rl/cim_distributed.yml | 4 +- examples/rl/vm_scheduling.yml | 4 +- maro/rl/rollout/env_sampler.py | 2 + maro/rl/workflows/callback.py | 180 +++++++++++++++++++++++++++++ maro/rl/workflows/config/parser.py | 6 +- maro/rl/workflows/main.py | 31 +++-- tests/rl/config.yml | 4 +- 10 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 maro/rl/workflows/callback.py diff --git a/.gitignore b/.gitignore index 2d5f5ea48..ccfb070d2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ htmlcov/ .coveragerc .tmp/ .xmake/ +outputs/ diff --git a/examples/cim/rl/env_sampler.py b/examples/cim/rl/env_sampler.py index 32c910f36..6550d062c 100644 --- a/examples/cim/rl/env_sampler.py +++ b/examples/cim/rl/env_sampler.py @@ -90,11 +90,21 @@ def post_collect(self, info_list: list, ep: int) -> None: for info in info_list: print(f"env summary (episode {ep}): {info['env_metric']}") - # print the average env metric - if len(info_list) > 1: - metric_keys, num_envs = info_list[0]["env_metric"].keys(), len(info_list) - avg_metric = {key: sum(info["env_metric"][key] for info in info_list) / num_envs for key in metric_keys} - print(f"average env summary (episode {ep}): {avg_metric}") + # average env metric + metric_keys, num_envs = info_list[0]["env_metric"].keys(), len(info_list) + avg_metric = {key: sum(info["env_metric"][key] for info in info_list) / num_envs for key in metric_keys} + print(f"average env summary (episode {ep}): {avg_metric}") + + self.metrics.update(avg_metric) def post_evaluate(self, info_list: list, ep: int) -> None: - self.post_collect(info_list, ep) + # print the env metric from each rollout worker + for info in info_list: + print(f"env summary (episode {ep}): {info['env_metric']}") + + # average env metric + metric_keys, num_envs = info_list[0]["env_metric"].keys(), len(info_list) + avg_metric = {key: sum(info["env_metric"][key] for info in info_list) / num_envs for key in metric_keys} + print(f"average env summary (episode {ep}): {avg_metric}") + + self.metrics.update({"val/" + k: v for k, v in avg_metric.items()}) diff --git a/examples/rl/cim.yml b/examples/rl/cim.yml index 95549a7a8..358546f4d 100644 --- a/examples/rl/cim.yml +++ b/examples/rl/cim.yml @@ -10,7 +10,7 @@ job: cim_rl_workflow scenario_path: "examples/cim/rl" -log_path: "log/rl_job/cim.txt" +log_path: "outputs/cim_rl/" main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null @@ -27,7 +27,7 @@ training: load_path: null load_episode: null checkpointing: - path: "checkpoint/rl_job/cim" + path: "outputs/cim_rl/checkpoints" interval: 5 logging: stdout: INFO diff --git a/examples/rl/cim_distributed.yml b/examples/rl/cim_distributed.yml index 2a52a0846..65d747b6f 100644 --- a/examples/rl/cim_distributed.yml +++ b/examples/rl/cim_distributed.yml @@ -10,7 +10,7 @@ job: cim_rl_workflow scenario_path: "examples/cim/rl" -log_path: "log/rl_job/cim.txt" +log_path: "outputs/cim_rl/" main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null @@ -35,7 +35,7 @@ training: load_path: null load_episode: null checkpointing: - path: "checkpoint/rl_job/cim" + path: "outputs/cim_rl/checkpoints" interval: 5 proxy: host: "127.0.0.1" diff --git a/examples/rl/vm_scheduling.yml b/examples/rl/vm_scheduling.yml index 7ec2a79e0..d7bf57838 100644 --- a/examples/rl/vm_scheduling.yml +++ b/examples/rl/vm_scheduling.yml @@ -10,7 +10,7 @@ job: vm_scheduling_rl_workflow scenario_path: "examples/vm_scheduling/rl" -log_path: "log/rl_job/vm_scheduling.txt" +log_path: "outputs/vm_rl/" main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null @@ -27,7 +27,7 @@ training: load_path: null load_episode: null checkpointing: - path: "checkpoint/rl_job/vm_scheduling" + path: "outputs/vm_rl/checkpoints" interval: 5 logging: stdout: INFO diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 6ed8a4f6c..86a89b989 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -267,6 +267,7 @@ def __init__( self._reward_eval_delay = reward_eval_delay self._info: dict = {} + self.metrics: dict = {} assert self._reward_eval_delay is None or self._reward_eval_delay >= 0 @@ -407,6 +408,7 @@ def _append_cache_element(self, cache_element: Optional[CacheElement]) -> None: def _reset(self) -> None: self.env.reset() self._info.clear() + self.metrics.clear() self._trans_cache.clear() self._agent_last_index.clear() self._step(None) diff --git a/maro/rl/workflows/callback.py b/maro/rl/workflows/callback.py new file mode 100644 index 000000000..0b58d1f76 --- /dev/null +++ b/maro/rl/workflows/callback.py @@ -0,0 +1,180 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +import copy +import os +from typing import Dict, List, Union + +import pandas as pd + +from maro.rl.rollout import AbsEnvSampler, BatchEnvSampler +from maro.rl.training import TrainingManager +from maro.utils import LoggerV2 + +EnvSampler = Union[AbsEnvSampler, BatchEnvSampler] + + +class Callback(object): + def on_episode_start( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_episode_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_training_start( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_training_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_validation_start( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_validation_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_test_start( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + def on_test_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + pass + + +class Checkpoint(Callback): + def __init__(self, path: str, interval: int) -> None: + super(Checkpoint, self).__init__() + + self._path = path + self._interval = interval + + def on_training_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + if ep % self._interval == 0: + training_manager.save(os.path.join(self._path, str(ep))) + logger.info(f"[Episode {ep}] All trainer states saved under {self._path}") + + +class MetricsRecorder(Callback): + def __init__(self, path: str) -> None: + super(MetricsRecorder, self).__init__() + + self._metrics: Dict[int, dict] = {} + self._path = path + + def _dump_metric_history(self) -> None: + if len(self._metrics) == 0: + return + + metric_list = [self._metrics[ep] for ep in sorted(self._metrics.keys())] + df = pd.DataFrame.from_records(metric_list) + df.to_csv(os.path.join(self._path, "metrics.csv"), index=True) + + def on_training_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + if len(env_sampler.metrics) > 0: + metrics = copy.deepcopy(env_sampler.metrics) + metrics["ep"] = ep + if ep in self._metrics: + self._metrics[ep].update(metrics) + else: + self._metrics[ep] = metrics + self._dump_metric_history() + + def on_validation_end( + self, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + self.on_training_end(env_sampler, training_manager, logger, ep) + + +SUPPORTED_CALLBACK_FUNC = { + "on_episode_start", + "on_episode_end", + "on_training_start", + "on_training_end", + "on_validation_start", + "on_validation_end", + "on_test_start", + "on_test_end", +} + + +class CallbackManager(object): + def __init__(self, callbacks: List[Callback]) -> None: + super(CallbackManager, self).__init__() + + self._callbacks = callbacks + + def call( + self, + func_name: str, + env_sampler: EnvSampler, + training_manager: TrainingManager, + logger: LoggerV2, + ep: int, + ) -> None: + assert func_name in SUPPORTED_CALLBACK_FUNC + + for callback in self._callbacks: + func = getattr(callback, func_name) + func(env_sampler, training_manager, logger, ep) diff --git a/maro/rl/workflows/config/parser.py b/maro/rl/workflows/config/parser.py index fb19a0e90..5910bd0a1 100644 --- a/maro/rl/workflows/config/parser.py +++ b/maro/rl/workflows/config/parser.py @@ -231,10 +231,9 @@ def get_path_mapping(self, containerize: bool = False) -> dict: local/log/path -> "/logs" Defaults to False. """ - log_dir = os.path.dirname(self._config["log_path"]) path_map = { self._config["scenario_path"]: "/scenario" if containerize else self._config["scenario_path"], - log_dir: "/logs" if containerize else log_dir, + self._config["log_path"]: "/logs" if containerize else self._config["log_path"], } load_path = self._config["training"].get("load_path", None) @@ -387,9 +386,8 @@ def get_job_spec(self, containerize: bool = False) -> Dict[str, Tuple[str, Dict[ ) # All components write logs to the same file - log_dir, log_file = os.path.split(self._config["log_path"]) for _, vars in env.values(): - vars["LOG_PATH"] = os.path.join(path_mapping[log_dir], log_file) + vars["LOG_PATH"] = path_mapping[self._config["log_path"]] return env diff --git a/maro/rl/workflows/main.py b/maro/rl/workflows/main.py index a6c954a1c..0f98ebb6b 100644 --- a/maro/rl/workflows/main.py +++ b/maro/rl/workflows/main.py @@ -14,6 +14,7 @@ from maro.rl.utils import get_torch_device from maro.rl.utils.common import float_or_none, get_env, int_or_none, list_or_none from maro.rl.utils.training import get_latest_ep +from maro.rl.workflows.callback import CallbackManager, Checkpoint, MetricsRecorder from maro.utils import LoggerV2 @@ -73,7 +74,7 @@ def __init__(self) -> None: self.logger = LoggerV2( "MAIN", - dump_path=self.log_path, + dump_path=os.path.join(self.log_path, "log.txt"), dump_mode="a", stdout_level=self.log_level_stdout, file_level=self.log_level_file, @@ -131,6 +132,17 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow logger=env_attr.logger, ) + callbacks = [] + if env_attr.checkpoint_path is not None: + callbacks.append( + Checkpoint( + path=env_attr.checkpoint_path, + interval=1 if env_attr.checkpoint_interval is None else env_attr.checkpoint_interval, + ) + ) + callbacks.append(MetricsRecorder(path=env_attr.log_path)) + cbm = CallbackManager(callbacks) + if env_attr.load_path: assert isinstance(env_attr.load_path, str) @@ -148,6 +160,8 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow # main loop for ep in range(start_ep, env_attr.num_episodes + 1): + cbm.call("on_episode_start", env_sampler, training_manager, env_attr.logger, ep) + collect_time = training_time = 0.0 total_experiences: List[List[ExpElement]] = [] total_info_list: List[dict] = [] @@ -169,15 +183,12 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow env_sampler.post_collect(total_info_list, ep) - env_attr.logger.info(f"Roll-out completed for episode {ep}. Training started...") tu0 = time.time() + env_attr.logger.info(f"Roll-out completed for episode {ep}. Training started...") + cbm.call("on_training_start", env_sampler, training_manager, env_attr.logger, ep) training_manager.record_experiences(total_experiences) training_manager.train_step() - if env_attr.checkpoint_path and (not env_attr.checkpoint_interval or ep % env_attr.checkpoint_interval == 0): - assert isinstance(env_attr.checkpoint_path, str) - pth = os.path.join(env_attr.checkpoint_path, str(ep)) - training_manager.save(pth) - env_attr.logger.info(f"All trainer states saved under {pth}") + cbm.call("on_training_end", env_sampler, training_manager, env_attr.logger, ep) training_time += time.time() - tu0 # performance details @@ -185,6 +196,8 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow f"ep {ep} - roll-out time: {collect_time:.2f} seconds, training time: {training_time:.2f} seconds", ) if env_attr.eval_schedule and ep == env_attr.eval_schedule[eval_point_index]: + cbm.call("on_validation_start", env_sampler, training_manager, env_attr.logger, ep) + eval_point_index += 1 result = env_sampler.eval( policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, @@ -192,6 +205,10 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow ) env_sampler.post_evaluate(result["info"], ep) + cbm.call("on_validation_end", env_sampler, training_manager, env_attr.logger, ep) + + cbm.call("on_episode_end", env_sampler, training_manager, env_attr.logger, ep) + if isinstance(env_sampler, BatchEnvSampler): env_sampler.exit() training_manager.exit() diff --git a/tests/rl/config.yml b/tests/rl/config.yml index 1d941ded7..6543548f9 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -9,7 +9,7 @@ job: gym_rl_workflow scenario_path: "tests/rl/gym_wrapper" -log_path: "tests/rl/log/gym.txt" +log_path: "tests/rl_log" main: num_episodes: 1000 num_steps: null @@ -28,7 +28,7 @@ training: load_path: null load_episode: null checkpointing: - path: "tests/rl/checkpoint/gym" + path: "tests/rl_log/checkpoints" interval: 5 logging: stdout: INFO From aa484f8df2f214c65e11e3a8c4194998e347b3ee Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Mon, 30 Jan 2023 02:19:53 +0000 Subject: [PATCH 22/45] [wip] SAC performance not good --- .gitignore | 1 + maro/rl/training/algorithms/sac.py | 3 ++- tests/rl/algorithms/sac.py | 11 ++++++----- tests/rl/gym_wrapper/env_sampler.py | 24 +++++++++++++----------- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index ccfb070d2..04c31a589 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ htmlcov/ .tmp/ .xmake/ outputs/ +tests/rl_log/ diff --git a/maro/rl/training/algorithms/sac.py b/maro/rl/training/algorithms/sac.py index 338addf57..e01da160e 100644 --- a/maro/rl/training/algorithms/sac.py +++ b/maro/rl/training/algorithms/sac.py @@ -22,7 +22,7 @@ class SoftActorCriticParams(BaseTrainerParams): num_epochs: int = 1 n_start_train: int = 0 q_value_loss_cls: Optional[Callable] = None - soft_update_coef: float = 1.0 + soft_update_coef: float = 0.05 class SoftActorCriticOps(AbsTrainOps): @@ -58,6 +58,7 @@ def __init__( def _get_critic_loss(self, batch: TransitionBatch) -> Tuple[torch.Tensor, torch.Tensor]: self._q_net1.train() + self._q_net2.train() states = ndarray_to_tensor(batch.states, device=self._device) # s next_states = ndarray_to_tensor(batch.next_states, device=self._device) # s' actions = ndarray_to_tensor(batch.actions, device=self._device) # a diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index 97d4ff931..929007890 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -14,12 +14,12 @@ from tests.rl.algorithms.utils import mlp actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, } critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, } actor_learning_rate = 3e-4 critic_learning_rate = 1e-3 @@ -93,7 +93,8 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit reward_discount=0.99, params=SoftActorCriticParams( get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=10, + num_epochs=50, n_start_train=10000, + soft_update_coef=0.01, ), ) diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index 19e929b4f..4b666793f 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -10,15 +10,15 @@ from tests.rl.gym_wrapper.simulator.common import Action, DecisionEvent -def _show_info(rewards: list, tag: str) -> None: - print( - f"[{tag}] Total N-steps = {sum([len(e) for e in rewards])}, " - f"N segments = {len(rewards)}, " - f"Average reward = {np.mean([sum(e) for e in rewards]):.4f}, " - f"Max reward = {np.max([sum(e) for e in rewards]):.4f}, " - f"Min reward = {np.min([sum(e) for e in rewards]):.4f}, " - f"Average N-steps = {np.mean([len(e) for e in rewards]):.1f}\n", - ) +def _calc_metrics(rewards: list) -> dict: + return { + "n_step": sum([len(e) for e in rewards]), + "n_segment": len(rewards), + "avg_reward": np.mean([sum(e) for e in rewards]), + "max_reward": np.max([sum(e) for e in rewards]), + "min_reward": np.min([sum(e) for e in rewards]), + "avg_n_step": np.mean([len(e) for e in rewards]), + } class GymEnvSampler(AbsEnvSampler): @@ -45,8 +45,10 @@ def _post_eval_step(self, cache_element: CacheElement) -> None: def post_collect(self, info_list: list, ep: int) -> None: rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - _show_info(rewards, "Collect") + metrics = _calc_metrics(rewards) + self.metrics.update(metrics) def post_evaluate(self, info_list: list, ep: int) -> None: rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - _show_info(rewards, "Evaluate") + metrics = _calc_metrics(rewards) + self.metrics.update({"val/" + k: v for k, v in metrics.items()}) From 84ec6e65836eadabc08a3bf26df11dc4e80dae27 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Mon, 30 Jan 2023 16:15:10 +0000 Subject: [PATCH 23/45] [wip] still not good --- maro/rl/training/algorithms/sac.py | 17 +++++++---- tests/rl/algorithms/sac.py | 47 +++++++++++++++++++++++------- tests/rl/gym_wrapper/config.py | 2 +- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/maro/rl/training/algorithms/sac.py b/maro/rl/training/algorithms/sac.py index e01da160e..77d751f1d 100644 --- a/maro/rl/training/algorithms/sac.py +++ b/maro/rl/training/algorithms/sac.py @@ -68,11 +68,11 @@ def _get_critic_loss(self, batch: TransitionBatch) -> Tuple[torch.Tensor, torch. assert isinstance(self._policy, ContinuousRLPolicy) with torch.no_grad(): - next_actions, next_logps = self._policy.get_actions_with_logps(states) - q1 = self._target_q_net1.q_values(next_states, next_actions) - q2 = self._target_q_net2.q_values(next_states, next_actions) - q = torch.min(q1, q2) - y = rewards + self._reward_discount * (1.0 - terminals.float()) * (q - self._entropy_coef * next_logps) + next_actions, next_logps = self._policy.get_actions_with_logps(next_states) + target_q1 = self._target_q_net1.q_values(next_states, next_actions) + target_q2 = self._target_q_net2.q_values(next_states, next_actions) + target_q = torch.min(target_q1, target_q2) + y = rewards + self._reward_discount * (1.0 - terminals.float()) * (target_q - self._entropy_coef * next_logps) q1 = self._q_net1.q_values(states, actions) q2 = self._q_net2.q_values(states, actions) @@ -101,6 +101,9 @@ def update_critic(self, batch: TransitionBatch) -> None: self._q_net2.step(loss_q2) def _get_actor_loss(self, batch: TransitionBatch) -> torch.Tensor: + self._q_net1.freeze() + self._q_net2.freeze() + self._policy.train() states = ndarray_to_tensor(batch.states, device=self._device) # s actions, logps = self._policy.get_actions_with_logps(states) @@ -109,6 +112,10 @@ def _get_actor_loss(self, batch: TransitionBatch) -> torch.Tensor: q = torch.min(q1, q2) loss = (self._entropy_coef * logps - q).mean() + + self._q_net1.unfreeze() + self._q_net2.unfreeze() + return loss @remote diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index 929007890..ab5655f6c 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -5,7 +5,7 @@ import numpy as np import torch import torch.nn.functional as F -from torch.distributions import Normal +from torch.distributions import Independent, Normal from torch.optim import Adam from maro.rl.model import ContinuousSACNet, QNet @@ -14,12 +14,12 @@ from tests.rl.algorithms.utils import mlp actor_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, } critic_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, } actor_learning_rate = 3e-4 critic_learning_rate = 1e-3 @@ -28,9 +28,9 @@ LOG_STD_MIN = -20 -class MyContinuousSACNet(ContinuousSACNet): +class MyContinuousSACNetOld(ContinuousSACNet): def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: - super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + super(MyContinuousSACNetOld, self).__init__(state_dim=state_dim, action_dim=action_dim) self._net = mlp( [state_dim] + actor_net_conf["hidden_dims"], @@ -48,15 +48,40 @@ def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> log_std = torch.clamp(self._log_std(net_out), LOG_STD_MIN, LOG_STD_MAX) std = torch.exp(log_std) - pi_distribution = Normal(mu, std) + pi_distribution = Independent(Normal(mu, std), 1) pi_action = pi_distribution.rsample() if exploring else mu - logp_pi = pi_distribution.log_prob(pi_action).sum(axis=-1) + logp_pi = pi_distribution.log_prob(pi_action) logp_pi -= (2 * (np.log(2) - pi_action - F.softplus(-2 * pi_action))).sum(axis=1) - pi_action = torch.tanh(pi_action) * self._action_limit + return torch.tanh(pi_action) * self._action_limit, logp_pi + +class MyContinuousSACNet(ContinuousSACNet): + def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: + super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = mlp( + [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + self._action_limit = action_limit + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + mu = self._mu_net(states.float()) + std = torch.exp(torch.clamp(self._log_std, LOG_STD_MIN, LOG_STD_MAX)) # 1 + distribution = Normal(mu, std) + + actions = distribution.rsample() if exploring else mu + logps = distribution.log_prob(actions).sum(axis=-1) + + logps -= (2 * (np.log(2) - actions - F.softplus(-2 * actions))).sum(axis=1) # 2 + actions = torch.tanh(actions) * self._action_limit # 3 - return pi_action, logp_pi + return actions, logps class MyQCriticNet(QNet): diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py index a52e6286e..062abd3d2 100644 --- a/tests/rl/gym_wrapper/config.py +++ b/tests/rl/gym_wrapper/config.py @@ -1,4 +1,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -algorithm = "ppo" +algorithm = "sac" From 0ceaac415bc85f8bbbf24fe0947fc08d7a2fb0a3 Mon Sep 17 00:00:00 2001 From: Jinyu-W <53509467+Jinyu-W@users.noreply.github.com> Date: Tue, 31 Jan 2023 11:47:54 +0800 Subject: [PATCH 24/45] update for some PR comments; Add a MARKDOWN file (#576) Co-authored-by: Jinyu Wang --- tests/rl/algorithms/ac.py | 1 + tests/rl/algorithms/ppo.py | 1 + tests/rl/algorithms/sac.py | 2 ++ tests/rl/gym_wrapper/config.py | 9 ++++++ tests/rl/gym_wrapper/env_sampler.py | 1 + tests/rl/gym_wrapper/rl_component_bundle.py | 14 ++------- .../gym_wrapper/simulator/business_engine.py | 8 +++-- tests/rl/performance.md | 30 +++++++++++++++++++ 8 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 tests/rl/performance.md diff --git a/tests/rl/algorithms/ac.py b/tests/rl/algorithms/ac.py index 4cc99ff53..9bec0f8ef 100644 --- a/tests/rl/algorithms/ac.py +++ b/tests/rl/algorithms/ac.py @@ -11,6 +11,7 @@ from maro.rl.model import ContinuousACBasedNet, VNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer + from .utils import mlp actor_net_conf = { diff --git a/tests/rl/algorithms/ppo.py b/tests/rl/algorithms/ppo.py index 41a71e694..61b3c8576 100644 --- a/tests/rl/algorithms/ppo.py +++ b/tests/rl/algorithms/ppo.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. from maro.rl.training.algorithms import PPOParams, PPOTrainer + from .ac import MyVCriticNet, get_ac_policy get_ppo_policy = get_ac_policy diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index 97d4ff931..21b0482e1 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. + from typing import Tuple import numpy as np @@ -11,6 +12,7 @@ from maro.rl.model import ContinuousSACNet, QNet from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer + from tests.rl.algorithms.utils import mlp actor_net_conf = { diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py index a52e6286e..0d37afcf4 100644 --- a/tests/rl/gym_wrapper/config.py +++ b/tests/rl/gym_wrapper/config.py @@ -2,3 +2,12 @@ # Licensed under the MIT license. algorithm = "ppo" + +env_conf = { + "topology": "Walker2d-v4", + "start_tick": 0, + "durations": 5000, + "options": { + "random_seed": None, + }, +} diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index 19e929b4f..0e1e3d30a 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -6,6 +6,7 @@ import numpy as np from maro.rl.rollout import AbsEnvSampler, CacheElement + from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine from tests.rl.gym_wrapper.simulator.common import Action, DecisionEvent diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 20651c4e9..ef5ae5484 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -5,22 +5,14 @@ from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.simulator import Env + from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine -from .config import algorithm +from .config import algorithm, env_conf from .env_sampler import GymEnvSampler -env_conf = { - "business_engine_cls": GymBusinessEngine, - "topology": "Walker2d-v4", - "start_tick": 0, - "durations": 5000, - "options": { - "random_seed": None, - }, -} -learn_env = Env(**env_conf) +learn_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) test_env = learn_env num_agents = len(learn_env.agent_idx_list) diff --git a/tests/rl/gym_wrapper/simulator/business_engine.py b/tests/rl/gym_wrapper/simulator/business_engine.py index 65dee6eff..626a0f6e2 100644 --- a/tests/rl/gym_wrapper/simulator/business_engine.py +++ b/tests/rl/gym_wrapper/simulator/business_engine.py @@ -4,10 +4,11 @@ from typing import List, Optional, cast import gym -from maro.backends.frame import FrameBase, SnapshotList +from maro.backends.frame import FrameBase, SnapshotList from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents from maro.simulator.scenarios import AbsBusinessEngine + from .common import Action, DecisionEvent @@ -62,7 +63,7 @@ def _register_events(self) -> None: def _on_action_received(self, event: CascadeEvent) -> None: action = cast(Action, cast(list, event.payload)[0]).action - self._last_obs, reward, self._is_done, _, info = self._gym_env.step(action) + self._last_obs, reward, self._is_done, self._truncated, info = self._gym_env.step(action) self._reward_record[event.tick] = reward self._info_record[event.tick] = info @@ -82,11 +83,12 @@ def get_info_at_tick(self, tick: int) -> object: # TODO def reset(self, keep_seed: bool = False) -> None: self._last_obs = self._gym_env.reset()[0] self._is_done = False + self._truncated = False self._reward_record = {} self._info_record = {} def post_step(self, tick: int) -> bool: - return self._is_done or tick + 1 == self._max_tick + return self._is_done or self._truncated or tick + 1 == self._max_tick def get_agent_idx_list(self) -> List[int]: return [0] diff --git a/tests/rl/performance.md b/tests/rl/performance.md new file mode 100644 index 000000000..43849b553 --- /dev/null +++ b/tests/rl/performance.md @@ -0,0 +1,30 @@ +# Performance for Gym Task Suite + +We benchmarked the MARO RL Toolkit implementation in Gym task suite. +Some are compared to the benchmarks in [OpenAI Spinning Up](https://spinningup.openai.com/en/latest/spinningup/bench.html#). +Limited by the environment version difference, +there may be some gaps between the performance here and that in Spinning Up benchmarks. + +The hyper-parameters are set to align with those used in [Spinning Up](https://spinningup.openai.com/en/latest/spinningup/bench.html#experiment-details): + +- Network of on-policy algorithms: size (64, 32) with tanh units for both policy and value function; +- Network of off-policy algorithms: size (256, 256) with relu units; +- Batch size for on-policy algorithms: 4000 steps of interaction per batch update; +- Batch size for off-policy algorithms: size 100 for each gradient descent step; + +## Walker2d + +### Benchmark in Spinning Up - PyTorch Version + +- Environment version: Walker2d-v3 +- 3M timesteps + +![Walker2d: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_walker2d_performance.svg) + +### Performance with MARO RL Toolkit + +- Environment version: Walker2d-v4 +- Training Mode: simple +- Rollout Mode: single +- Environment duration: 5000 ticks +- Num of episodes: 600 From aad41d90c180b2e7f7af5fc905dffe22ebe92bb1 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 31 Jan 2023 05:44:02 +0000 Subject: [PATCH 25/45] Use FullyConnected to replace mlp --- examples/cim/rl/algorithms/ac.py | 2 ++ examples/cim/rl/algorithms/dqn.py | 1 + examples/cim/rl/algorithms/maddpg.py | 2 ++ examples/vm_scheduling/rl/algorithms/ac.py | 2 ++ examples/vm_scheduling/rl/algorithms/dqn.py | 1 + maro/rl/model/fc_block.py | 16 +++++++++------- tests/rl/algorithms/ac.py | 15 +++++++++------ tests/rl/algorithms/sac.py | 15 +++++++++------ tests/rl/algorithms/utils.py | 18 ------------------ 9 files changed, 35 insertions(+), 37 deletions(-) delete mode 100644 tests/rl/algorithms/utils.py diff --git a/examples/cim/rl/algorithms/ac.py b/examples/cim/rl/algorithms/ac.py index 1769493df..d0e88a97f 100644 --- a/examples/cim/rl/algorithms/ac.py +++ b/examples/cim/rl/algorithms/ac.py @@ -11,6 +11,7 @@ actor_net_conf = { "hidden_dims": [256, 128, 64], "activation": torch.nn.Tanh, + "output_activation": torch.nn.Tanh, "softmax": True, "batch_norm": False, "head": True, @@ -19,6 +20,7 @@ "hidden_dims": [256, 128, 64], "output_dim": 1, "activation": torch.nn.LeakyReLU, + "output_activation": torch.nn.LeakyReLU, "softmax": False, "batch_norm": True, "head": True, diff --git a/examples/cim/rl/algorithms/dqn.py b/examples/cim/rl/algorithms/dqn.py index d62e3443d..022275552 100644 --- a/examples/cim/rl/algorithms/dqn.py +++ b/examples/cim/rl/algorithms/dqn.py @@ -12,6 +12,7 @@ q_net_conf = { "hidden_dims": [256, 128, 64, 32], "activation": torch.nn.LeakyReLU, + "output_activation": torch.nn.LeakyReLU, "softmax": False, "batch_norm": True, "skip_connection": False, diff --git a/examples/cim/rl/algorithms/maddpg.py b/examples/cim/rl/algorithms/maddpg.py index e6fd0a65b..14e41027c 100644 --- a/examples/cim/rl/algorithms/maddpg.py +++ b/examples/cim/rl/algorithms/maddpg.py @@ -14,6 +14,7 @@ actor_net_conf = { "hidden_dims": [256, 128, 64], "activation": torch.nn.Tanh, + "output_activation": torch.nn.Tanh, "softmax": True, "batch_norm": False, "head": True, @@ -22,6 +23,7 @@ "hidden_dims": [256, 128, 64], "output_dim": 1, "activation": torch.nn.LeakyReLU, + "output_activation": torch.nn.LeakyReLU, "softmax": False, "batch_norm": True, "head": True, diff --git a/examples/vm_scheduling/rl/algorithms/ac.py b/examples/vm_scheduling/rl/algorithms/ac.py index 94d0afd63..3462f49d5 100644 --- a/examples/vm_scheduling/rl/algorithms/ac.py +++ b/examples/vm_scheduling/rl/algorithms/ac.py @@ -11,6 +11,7 @@ actor_net_conf = { "hidden_dims": [64, 32, 32], "activation": torch.nn.LeakyReLU, + "output_activation": torch.nn.LeakyReLU, "softmax": True, "batch_norm": False, "head": True, @@ -19,6 +20,7 @@ critic_net_conf = { "hidden_dims": [256, 128, 64], "activation": torch.nn.LeakyReLU, + "output_activation": torch.nn.LeakyReLU, "softmax": False, "batch_norm": False, "head": True, diff --git a/examples/vm_scheduling/rl/algorithms/dqn.py b/examples/vm_scheduling/rl/algorithms/dqn.py index 499cb85b5..643d6c6d4 100644 --- a/examples/vm_scheduling/rl/algorithms/dqn.py +++ b/examples/vm_scheduling/rl/algorithms/dqn.py @@ -14,6 +14,7 @@ q_net_conf = { "hidden_dims": [64, 128, 256], "activation": torch.nn.LeakyReLU, + "output_activation": torch.nn.LeakyReLU, "softmax": False, "batch_norm": False, "skip_connection": False, diff --git a/maro/rl/model/fc_block.py b/maro/rl/model/fc_block.py index 31108765d..c86b0e740 100644 --- a/maro/rl/model/fc_block.py +++ b/maro/rl/model/fc_block.py @@ -39,7 +39,8 @@ def __init__( input_dim: int, output_dim: int, hidden_dims: List[int], - activation: Optional[Type[torch.nn.Module]] = nn.ReLU, + activation: Optional[Type[torch.nn.Module]] = None, + output_activation: Optional[Type[torch.nn.Module]] = None, head: bool = False, softmax: bool = False, batch_norm: bool = False, @@ -54,7 +55,8 @@ def __init__( self._output_dim = output_dim # network features - self._activation = activation() if activation else None + self._activation = activation if activation else None + self._output_activation = output_activation if output_activation else None self._head = head self._softmax = nn.Softmax(dim=1) if softmax else None self._batch_norm = batch_norm @@ -70,9 +72,9 @@ def __init__( # build the net dims = [self._input_dim] + self._hidden_dims - layers = [self._build_layer(in_dim, out_dim) for in_dim, out_dim in zip(dims, dims[1:])] + layers = [self._build_layer(in_dim, out_dim, activation=self._activation) for in_dim, out_dim in zip(dims, dims[1:])] # top layer - layers.append(self._build_layer(dims[-1], self._output_dim, head=self._head)) + layers.append(self._build_layer(dims[-1], self._output_dim, head=self._head, activation=self._output_activation)) self._net = nn.Sequential(*layers) @@ -101,7 +103,7 @@ def input_dim(self) -> int: def output_dim(self) -> int: return self._output_dim - def _build_layer(self, input_dim: int, output_dim: int, head: bool = False) -> nn.Module: + def _build_layer(self, input_dim: int, output_dim: int, head: bool = False, activation: Type[torch.nn.Module] = None) -> nn.Module: """Build a basic layer. BN -> Linear -> Activation -> Dropout @@ -110,8 +112,8 @@ def _build_layer(self, input_dim: int, output_dim: int, head: bool = False) -> n if self._batch_norm: components.append(("batch_norm", nn.BatchNorm1d(input_dim))) components.append(("linear", nn.Linear(input_dim, output_dim))) - if not head and self._activation is not None: - components.append(("activation", self._activation)) + if not head and activation is not None: + components.append(("activation", activation())) if not head and self._dropout_p: components.append(("dropout", nn.Dropout(p=self._dropout_p))) return nn.Sequential(OrderedDict(components)) diff --git a/tests/rl/algorithms/ac.py b/tests/rl/algorithms/ac.py index 9bec0f8ef..eea32fbcb 100644 --- a/tests/rl/algorithms/ac.py +++ b/tests/rl/algorithms/ac.py @@ -9,11 +9,10 @@ from torch.optim import Adam from maro.rl.model import ContinuousACBasedNet, VNet +from maro.rl.model.fc_block import FullyConnected from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer -from .utils import mlp - actor_net_conf = { "hidden_dims": [64, 64], "activation": torch.nn.Tanh, @@ -32,8 +31,10 @@ def __init__(self, state_dim: int, action_dim: int) -> None: log_std = -0.5 * np.ones(action_dim, dtype=np.float32) self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], + self._mu_net = FullyConnected( + input_dim=state_dim, + hidden_dims=actor_net_conf["hidden_dims"], + output_dim=action_dim, activation=actor_net_conf["activation"], ) self._optim = Adam(self.parameters(), lr=actor_learning_rate) @@ -58,8 +59,10 @@ def _distribution(self, states: torch.Tensor) -> Normal: class MyVCriticNet(VNet): def __init__(self, state_dim: int) -> None: super(MyVCriticNet, self).__init__(state_dim=state_dim) - self._critic = mlp( - [state_dim] + critic_net_conf["hidden_dims"] + [1], + self._critic = FullyConnected( + input_dim=state_dim, + output_dim=1, + hidden_dims=critic_net_conf["hidden_dims"], activation=critic_net_conf["activation"], ) self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index 21b0482e1..828ec2470 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -10,11 +10,10 @@ from torch.optim import Adam from maro.rl.model import ContinuousSACNet, QNet +from maro.rl.model.fc_block import FullyConnected from maro.rl.policy import ContinuousRLPolicy from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer -from tests.rl.algorithms.utils import mlp - actor_net_conf = { "hidden_dims": [64, 64], "activation": torch.nn.Tanh, @@ -34,8 +33,10 @@ class MyContinuousSACNet(ContinuousSACNet): def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._net = mlp( - [state_dim] + actor_net_conf["hidden_dims"], + self._net = FullyConnected( + input_dim=state_dim, + output_dim=actor_net_conf["hidden_dims"][-1], + hidden_dims=actor_net_conf["hidden_dims"][:-1], activation=actor_net_conf["activation"], output_activation=actor_net_conf["activation"], ) @@ -64,8 +65,10 @@ def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> class MyQCriticNet(QNet): def __init__(self, state_dim: int, action_dim: int) -> None: super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._critic = mlp( - [state_dim + action_dim] + critic_net_conf["hidden_dims"] + [1], + self._critic = FullyConnected( + input_dim=state_dim + action_dim, + output_dim=1, + hidden_dims=critic_net_conf["hidden_dims"], activation=critic_net_conf["activation"], ) self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) diff --git a/tests/rl/algorithms/utils.py b/tests/rl/algorithms/utils.py deleted file mode 100644 index d05df6d01..000000000 --- a/tests/rl/algorithms/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import List, Type - -import torch - - -def mlp( - sizes: List[int], - activation: Type[torch.nn.Module], - output_activation: Type[torch.nn.Module] = torch.nn.Identity, -) -> torch.nn.Sequential: - layers = [] - for j in range(len(sizes) - 1): - act = activation if j < len(sizes) - 2 else output_activation - layers += [torch.nn.Linear(sizes[j], sizes[j + 1]), act()] - return torch.nn.Sequential(*layers) From 8884231b986cf00e8ff752aa69c03a86f232e401 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 31 Jan 2023 06:06:51 +0000 Subject: [PATCH 26/45] Update action bound --- tests/rl/gym_wrapper/rl_component_bundle.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index ef5ae5484..26217054b 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -19,8 +19,7 @@ gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env gym_state_dim = gym_env.observation_space.shape[0] gym_action_dim = gym_env.action_space.shape[0] -action_lower_bound = [float("-inf") for _ in range(gym_env.action_space.shape[0])] -action_upper_bound = [float("inf") for _ in range(gym_env.action_space.shape[0])] +action_lower_bound, action_upper_bound = gym_env.action_space.low, gym_env.action_space.high action_limit = gym_env.action_space.high[0] agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} From 0bd25caf5e79c2f107bab71c6783720f7ec8ae57 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 31 Jan 2023 13:03:45 +0000 Subject: [PATCH 27/45] ??? --- tests/rl/algorithms/sac.py | 40 ++++++++------------------------------ 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index 55e945856..30ef28929 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -29,9 +29,9 @@ LOG_STD_MIN = -20 -class MyContinuousSACNetOld(ContinuousSACNet): +class MyContinuousSACNet(ContinuousSACNet): def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: - super(MyContinuousSACNetOld, self).__init__(state_dim=state_dim, action_dim=action_dim) + super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) self._net = FullyConnected( input_dim=state_dim, @@ -51,40 +51,15 @@ def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> log_std = torch.clamp(self._log_std(net_out), LOG_STD_MIN, LOG_STD_MAX) std = torch.exp(log_std) - pi_distribution = Independent(Normal(mu, std), 1) + pi_distribution = Normal(mu, std) pi_action = pi_distribution.rsample() if exploring else mu - logp_pi = pi_distribution.log_prob(pi_action) + logp_pi = pi_distribution.log_prob(pi_action).sum(axis=-1) logp_pi -= (2 * (np.log(2) - pi_action - F.softplus(-2 * pi_action))).sum(axis=1) - return torch.tanh(pi_action) * self._action_limit, logp_pi - -class MyContinuousSACNet(ContinuousSACNet): - def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: - super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = mlp( - [state_dim] + actor_net_conf["hidden_dims"] + [action_dim], - activation=actor_net_conf["activation"], - ) - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - self._action_limit = action_limit - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - mu = self._mu_net(states.float()) - std = torch.exp(torch.clamp(self._log_std, LOG_STD_MIN, LOG_STD_MAX)) # 1 - distribution = Normal(mu, std) - - actions = distribution.rsample() if exploring else mu - logps = distribution.log_prob(actions).sum(axis=-1) - - logps -= (2 * (np.log(2) - actions - F.softplus(-2 * actions))).sum(axis=1) # 2 - actions = torch.tanh(actions) * self._action_limit # 3 + pi_action = torch.tanh(pi_action) * self._action_limit - return actions, logps + return pi_action, logp_pi class MyQCriticNet(QNet): @@ -121,9 +96,10 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit return SoftActorCriticTrainer( name=name, reward_discount=0.99, + # replay_memory_capacity=100000, params=SoftActorCriticParams( get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=50, + num_epochs=20, n_start_train=10000, soft_update_coef=0.01, ), From 8781dd675136d83218c96601117490e06cb49eb6 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 31 Jan 2023 14:39:29 +0000 Subject: [PATCH 28/45] Change gym env wrapper metrics logci --- maro/rl/rollout/env_sampler.py | 1 - tests/rl/gym_wrapper/config.py | 2 +- tests/rl/gym_wrapper/env_sampler.py | 69 +++++++++++++++------ tests/rl/gym_wrapper/rl_component_bundle.py | 2 +- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 86a89b989..573ea2c01 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -408,7 +408,6 @@ def _append_cache_element(self, cache_element: Optional[CacheElement]) -> None: def _reset(self) -> None: self.env.reset() self._info.clear() - self.metrics.clear() self._trans_cache.clear() self._agent_last_index.clear() self._step(None) diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py index 212f8e232..0d37afcf4 100644 --- a/tests/rl/gym_wrapper/config.py +++ b/tests/rl/gym_wrapper/config.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -algorithm = "sac" +algorithm = "ppo" env_conf = { "topology": "Walker2d-v4", diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index a16ad6ca6..dc4d2d6fc 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -1,28 +1,43 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from typing import Any, Dict, Tuple, Union +from typing import Any, Dict, List, Tuple, Type, Union import numpy as np +from maro.rl.policy.abs_policy import AbsPolicy from maro.rl.rollout import AbsEnvSampler, CacheElement +from maro.rl.rollout.env_sampler import AbsAgentWrapper, SimpleAgentWrapper +from maro.simulator.core import Env from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine from tests.rl.gym_wrapper.simulator.common import Action, DecisionEvent -def _calc_metrics(rewards: list) -> dict: - return { - "n_step": sum([len(e) for e in rewards]), - "n_segment": len(rewards), - "avg_reward": np.mean([sum(e) for e in rewards]), - "max_reward": np.max([sum(e) for e in rewards]), - "min_reward": np.min([sum(e) for e in rewards]), - "avg_n_step": np.mean([len(e) for e in rewards]), - } +class GymEnvSampler(AbsEnvSampler): + def __init__( + self, + learn_env: Env, + test_env: Env, + policies: List[AbsPolicy], + agent2policy: Dict[Any, str], + trainable_policies: List[str] = None, + agent_wrapper_cls: Type[AbsAgentWrapper] = SimpleAgentWrapper, + reward_eval_delay: int = None, + ) -> None: + super(GymEnvSampler, self).__init__( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + trainable_policies=trainable_policies, + agent_wrapper_cls=agent_wrapper_cls, + reward_eval_delay=reward_eval_delay, + ) + self._sample_rewards = [] + self._eval_rewards = [] -class GymEnvSampler(AbsEnvSampler): def _get_global_and_agent_state_impl( self, event: DecisionEvent, @@ -39,17 +54,33 @@ def _get_reward(self, env_action_dict: dict, event: Any, tick: int) -> Dict[Any, return {0: be.get_reward_at_tick(tick)} def _post_step(self, cache_element: CacheElement) -> None: - self._info["env_metric"] = self._env.metrics + if not self._end_of_episode: + return + rewards = list(self._env.metrics["reward_record"].values()) + self._sample_rewards.append((len(rewards), np.sum(rewards))) def _post_eval_step(self, cache_element: CacheElement) -> None: - self._post_step(cache_element) + if not self._end_of_episode: + return + rewards = list(self._env.metrics["reward_record"].values()) + self._eval_rewards.append((len(rewards), np.sum(rewards))) def post_collect(self, info_list: list, ep: int) -> None: - rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - metrics = _calc_metrics(rewards) - self.metrics.update(metrics) + cur = { + "n_steps": sum([n for n, _ in self._sample_rewards]), + "n_segment": len(self._sample_rewards), + "avg_reward": np.mean([r for _, r in self._sample_rewards]), + "avg_n_steps": np.mean([n for n, _ in self._sample_rewards]), + } + self.metrics.update(cur) + # clear validation metrics + self.metrics = {k: v for k, v in self.metrics.items() if not k.startswith("val/")} def post_evaluate(self, info_list: list, ep: int) -> None: - rewards = [list(e["env_metric"]["reward_record"].values()) for e in info_list] - metrics = _calc_metrics(rewards) - self.metrics.update({"val/" + k: v for k, v in metrics.items()}) + cur = { + "val/n_steps": sum([n for n, _ in self._eval_rewards]), + "val/n_segment": len(self._eval_rewards), + "val/avg_reward": np.mean([r for _, r in self._eval_rewards]), + "val/avg_n_steps": np.mean([n for n, _ in self._eval_rewards]), + } + self.metrics.update(cur) diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 26217054b..f7f8afefe 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -13,7 +13,7 @@ learn_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) -test_env = learn_env +test_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) num_agents = len(learn_env.agent_idx_list) gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env From 7b9b698db3c2672c6faeb1df471139c57311727c Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Tue, 31 Jan 2023 14:40:40 +0000 Subject: [PATCH 29/45] Change gym env wrapper metrics logci --- tests/rl/config.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/rl/config.yml b/tests/rl/config.yml index 6543548f9..153726f2b 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -11,11 +11,11 @@ job: gym_rl_workflow scenario_path: "tests/rl/gym_wrapper" log_path: "tests/rl_log" main: - num_episodes: 1000 - num_steps: null - eval_schedule: 5 + num_episodes: 100000 + num_steps: 50 + eval_schedule: 100 num_eval_episodes: 10 - min_n_sample: 5000 + min_n_sample: 50 logging: stdout: INFO file: DEBUG @@ -29,7 +29,7 @@ training: load_episode: null checkpointing: path: "tests/rl_log/checkpoints" - interval: 5 + interval: 100 logging: stdout: INFO file: DEBUG From 52b4d1d651b9a5c7bb694d0ce2e0aaec78948901 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 01:27:00 +0000 Subject: [PATCH 30/45] refine env_sampler.sample under step mode --- maro/rl/rollout/env_sampler.py | 112 +++++++++++++++------------- tests/rl/config.yml | 10 +-- tests/rl/gym_wrapper/env_sampler.py | 1 + 3 files changed, 66 insertions(+), 57 deletions(-) diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 573ea2c01..4c512547d 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -252,6 +252,8 @@ def __init__( agent_wrapper_cls: Type[AbsAgentWrapper] = SimpleAgentWrapper, reward_eval_delay: int = None, ) -> None: + assert learn_env is not test_env, "Please use different envs for training and testing." + self._learn_env = learn_env self._test_env = test_env @@ -431,65 +433,71 @@ def sample( Returns: A dict that contains the collected experiences and additional information. """ - # Init the env - self._switch_env(self._learn_env) + steps_to_go = num_steps + if policy_state is not None: # Update policy state if necessary + self.set_policy_state(policy_state) + self._switch_env(self._learn_env) # Init the env + self._agent_wrapper.explore() # Collect experience + if self._end_of_episode: self._reset() - # Update policy state if necessary - if policy_state is not None: - self.set_policy_state(policy_state) + total_experiences = [] + # If steps_to_go is None, run until the end of episode + # If steps_to_go is not None, run until we collect required number of steps + while (steps_to_go is None and not self._end_of_episode) or (steps_to_go is not None and steps_to_go > 0): + if self._end_of_episode: + self._reset() - # Collect experience - self._agent_wrapper.explore() - steps_to_go = float("inf") if num_steps is None else num_steps - while not self._end_of_episode and steps_to_go > 0: - # Get agent actions and translate them to env actions - action_dict = self._agent_wrapper.choose_actions(self._agent_state_dict) - env_action_dict = self._translate_to_env_action(action_dict, self._event) - - # Store experiences in the cache - cache_element = CacheElement( - tick=self.env.tick, - event=self._event, - state=self._state, - agent_state_dict=self._select_trainable_agents(self._agent_state_dict), - action_dict=self._select_trainable_agents(action_dict), - env_action_dict=self._select_trainable_agents(env_action_dict), - # The following will be generated later - reward_dict={}, - terminal_dict={}, - next_state=None, - next_agent_state_dict={}, - ) + while not self._end_of_episode and (steps_to_go is None or steps_to_go > 0): + # Get agent actions and translate them to env actions + action_dict = self._agent_wrapper.choose_actions(self._agent_state_dict) + env_action_dict = self._translate_to_env_action(action_dict, self._event) - # Update env and get new states (global & agent) - self._step(list(env_action_dict.values())) - - if self._reward_eval_delay is None: - self._calc_reward(cache_element) - self._post_step(cache_element) - self._append_cache_element(cache_element) - steps_to_go -= 1 - self._append_cache_element(None) - - tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) - experiences: List[ExpElement] = [] - while len(self._trans_cache) > 0 and self._trans_cache[0].tick <= tick_bound: - cache_element = self._trans_cache.pop(0) - # !: Here the reward calculation method requires the given tick is enough and must be used then. - if self._reward_eval_delay is not None: - self._calc_reward(cache_element) - self._post_step(cache_element) - experiences.append(cache_element.make_exp_element()) - - self._agent_last_index = { - k: v - len(experiences) for k, v in self._agent_last_index.items() if v >= len(experiences) - } + # Store experiences in the cache + cache_element = CacheElement( + tick=self.env.tick, + event=self._event, + state=self._state, + agent_state_dict=self._select_trainable_agents(self._agent_state_dict), + action_dict=self._select_trainable_agents(action_dict), + env_action_dict=self._select_trainable_agents(env_action_dict), + # The following will be generated later + reward_dict={}, + terminal_dict={}, + next_state=None, + next_agent_state_dict={}, + ) + + # Update env and get new states (global & agent) + self._step(list(env_action_dict.values())) + + if self._reward_eval_delay is None: + self._calc_reward(cache_element) + self._post_step(cache_element) + self._append_cache_element(cache_element) + if steps_to_go is not None: + steps_to_go -= 1 + self._append_cache_element(None) + + tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) + experiences: List[ExpElement] = [] + while len(self._trans_cache) > 0 and self._trans_cache[0].tick <= tick_bound: + cache_element = self._trans_cache.pop(0) + # !: Here the reward calculation method requires the given tick is enough and must be used then. + if self._reward_eval_delay is not None: + self._calc_reward(cache_element) + self._post_step(cache_element) + experiences.append(cache_element.make_exp_element()) + + self._agent_last_index = { + k: v - len(experiences) for k, v in self._agent_last_index.items() if v >= len(experiences) + } + + total_experiences += experiences return { - "end_of_episode": self._end_of_episode, - "experiences": [experiences], + "experiences": [total_experiences], "info": [deepcopy(self._info)], # TODO: may have overhead issues. Leave to future work. } diff --git a/tests/rl/config.yml b/tests/rl/config.yml index 153726f2b..6543548f9 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -11,11 +11,11 @@ job: gym_rl_workflow scenario_path: "tests/rl/gym_wrapper" log_path: "tests/rl_log" main: - num_episodes: 100000 - num_steps: 50 - eval_schedule: 100 + num_episodes: 1000 + num_steps: null + eval_schedule: 5 num_eval_episodes: 10 - min_n_sample: 50 + min_n_sample: 5000 logging: stdout: INFO file: DEBUG @@ -29,7 +29,7 @@ training: load_episode: null checkpointing: path: "tests/rl_log/checkpoints" - interval: 100 + interval: 5 logging: stdout: INFO file: DEBUG diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index dc4d2d6fc..9ffec6557 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -83,4 +83,5 @@ def post_evaluate(self, info_list: list, ep: int) -> None: "val/avg_reward": np.mean([r for _, r in self._eval_rewards]), "val/avg_n_steps": np.mean([n for n, _ in self._eval_rewards]), } + self._eval_rewards.clear() self.metrics.update(cur) From a3fea0d78af31549587811802b215f1e9cad148d Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 03:14:44 +0000 Subject: [PATCH 31/45] Add DDPG. Performance not good... --- maro/rl/training/algorithms/ddpg.py | 10 +++++----- tests/rl/config.yml | 10 +++++----- tests/rl/gym_wrapper/rl_component_bundle.py | 8 ++++++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/maro/rl/training/algorithms/ddpg.py b/maro/rl/training/algorithms/ddpg.py index 79bd5b336..04e70ce52 100644 --- a/maro/rl/training/algorithms/ddpg.py +++ b/maro/rl/training/algorithms/ddpg.py @@ -8,7 +8,7 @@ from maro.rl.model import QNet from maro.rl.policy import ContinuousRLPolicy, RLPolicy -from maro.rl.training import AbsTrainOps, BaseTrainerParams, RandomReplayMemory, RemoteOps, SingleAgentTrainer, remote +from maro.rl.training import AbsTrainOps, BaseTrainerParams, FIFOReplayMemory, RemoteOps, SingleAgentTrainer, remote from maro.rl.utils import TransitionBatch, get_torch_device, ndarray_to_tensor from maro.utils import clone @@ -93,9 +93,9 @@ def _get_critic_loss(self, batch: TransitionBatch) -> torch.Tensor: states=next_states, # s' actions=self._target_policy.get_actions_tensor(next_states), # miu_targ(s') ) # Q_targ(s', miu_targ(s')) - - # y(r, s', d) = r + gamma * (1 - d) * Q_targ(s', miu_targ(s')) - target_q_values = (rewards + self._reward_discount * (1 - terminals.long()) * next_q_values).detach() + # y(r, s', d) = r + gamma * (1 - d) * Q_targ(s', miu_targ(s')) + target_q_values = (rewards + self._reward_discount * (1.0 - terminals.float()) * next_q_values).detach() + q_values = self._q_critic_net.q_values(states=states, actions=actions) # Q(s, a) return self._q_value_loss_func(q_values, target_q_values) # MSE(Q(s, a), y(r, s', d)) @@ -234,7 +234,7 @@ def __init__( def build(self) -> None: self._ops = cast(DDPGOps, self.get_ops()) - self._replay_memory = RandomReplayMemory( + self._replay_memory = FIFOReplayMemory( capacity=self._replay_memory_capacity, state_dim=self._ops.policy_state_dim, action_dim=self._ops.policy_action_dim, diff --git a/tests/rl/config.yml b/tests/rl/config.yml index 6543548f9..f6d4cfe10 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -11,11 +11,11 @@ job: gym_rl_workflow scenario_path: "tests/rl/gym_wrapper" log_path: "tests/rl_log" main: - num_episodes: 1000 - num_steps: null - eval_schedule: 5 + num_episodes: 25000 + num_steps: 200 + eval_schedule: 20 num_eval_episodes: 10 - min_n_sample: 5000 + min_n_sample: 1 logging: stdout: INFO file: DEBUG @@ -29,7 +29,7 @@ training: load_episode: null checkpointing: path: "tests/rl_log/checkpoints" - interval: 5 + interval: 20 logging: stdout: INFO file: DEBUG diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index f7f8afefe..7d31f577a 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -48,6 +48,14 @@ for i in range(num_agents) ] trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] +elif algorithm == "ddpg": + from tests.rl.algorithms.ddpg import get_ddpg_policy, get_ddpg_trainer + + policies = [ + get_ddpg_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) + for i in range(num_agents) + ] + trainers = [get_ddpg_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] else: raise ValueError(f"Unsupported algorithm: {algorithm}") From 23f39d1f3951de786f51d6ab188d829867223654 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 03:15:02 +0000 Subject: [PATCH 32/45] Add DDPG. Performance not good... --- tests/rl/algorithms/ddpg.py | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/rl/algorithms/ddpg.py diff --git a/tests/rl/algorithms/ddpg.py b/tests/rl/algorithms/ddpg.py new file mode 100644 index 000000000..6bad660da --- /dev/null +++ b/tests/rl/algorithms/ddpg.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import torch +from torch.optim import Adam + +from maro.rl.model import QNet +from maro.rl.model.algorithm_nets.ddpg import ContinuousDDPGNet +from maro.rl.model.fc_block import FullyConnected +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import DDPGParams, DDPGTrainer + +actor_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, + "output_activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 1e-3 +critic_learning_rate = 1e-3 + + +class MyContinuousDDPGNet(ContinuousDDPGNet): + def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: + super(MyContinuousDDPGNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + self._net = FullyConnected( + input_dim=state_dim, + output_dim=action_dim, + hidden_dims=actor_net_conf["hidden_dims"], + activation=actor_net_conf["activation"], + output_activation=actor_net_conf["output_activation"], + ) + self._action_limit = action_limit + + def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: + return self._net(states) * self._action_limit + + +class MyQCriticNet(QNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + self._critic = FullyConnected( + input_dim=state_dim + action_dim, + output_dim=1, + hidden_dims=critic_net_conf["hidden_dims"], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) + + +def get_ddpg_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, + action_limit: float, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyContinuousDDPGNet(gym_state_dim, gym_action_dim, action_limit), + ) + + +def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: + return DDPGTrainer( + name=name, + reward_discount=0.99, + replay_memory_capacity=1000000, + batch_size=100, + params=DDPGParams( + get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), + num_epochs=1, + n_start_train=1000, + soft_update_coef=0.005, + ), + ) From 9da8b9024d526408bfb8c54d24d3a0e972db1407 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 04:53:25 +0000 Subject: [PATCH 33/45] wip --- maro/rl/training/algorithms/ddpg.py | 16 ++++++++-------- tests/rl/algorithms/ddpg.py | 1 + tests/rl/config.yml | 10 +++++----- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/maro/rl/training/algorithms/ddpg.py b/maro/rl/training/algorithms/ddpg.py index 04e70ce52..31ccfd821 100644 --- a/maro/rl/training/algorithms/ddpg.py +++ b/maro/rl/training/algorithms/ddpg.py @@ -8,7 +8,7 @@ from maro.rl.model import QNet from maro.rl.policy import ContinuousRLPolicy, RLPolicy -from maro.rl.training import AbsTrainOps, BaseTrainerParams, FIFOReplayMemory, RemoteOps, SingleAgentTrainer, remote +from maro.rl.training import AbsTrainOps, BaseTrainerParams, RandomReplayMemory, RemoteOps, SingleAgentTrainer, remote from maro.rl.utils import TransitionBatch, get_torch_device, ndarray_to_tensor from maro.utils import clone @@ -27,7 +27,7 @@ class DDPGParams(BaseTrainerParams): random_overwrite (bool, default=False): This specifies overwrite behavior when the replay memory capacity is reached. If True, overwrite positions will be selected randomly. Otherwise, overwrites will occur sequentially with wrap-around. - min_num_to_trigger_training (int, default=0): Minimum number required to start training. + n_start_train (int, default=0): Minimum number required to start training. """ get_q_critic_net_func: Callable[[], QNet] @@ -36,7 +36,7 @@ class DDPGParams(BaseTrainerParams): q_value_loss_cls: Optional[Callable] = None soft_update_coef: float = 1.0 random_overwrite: bool = False - min_num_to_trigger_training: int = 0 + n_start_train: int = 0 class DDPGOps(AbsTrainOps): @@ -234,7 +234,7 @@ def __init__( def build(self) -> None: self._ops = cast(DDPGOps, self.get_ops()) - self._replay_memory = FIFOReplayMemory( + self._replay_memory = RandomReplayMemory( capacity=self._replay_memory_capacity, state_dim=self._ops.policy_state_dim, action_dim=self._ops.policy_action_dim, @@ -263,10 +263,10 @@ def _get_batch(self, batch_size: int = None) -> TransitionBatch: def train_step(self) -> None: assert isinstance(self._ops, DDPGOps) - if self._replay_memory.n_sample < self._params.min_num_to_trigger_training: + if self._replay_memory.n_sample < self._params.n_start_train: print( f"Skip this training step due to lack of experiences " - f"(current = {self._replay_memory.n_sample}, minimum = {self._params.min_num_to_trigger_training})", + f"(current = {self._replay_memory.n_sample}, minimum = {self._params.n_start_train})", ) return @@ -280,10 +280,10 @@ def train_step(self) -> None: async def train_step_as_task(self) -> None: assert isinstance(self._ops, RemoteOps) - if self._replay_memory.n_sample < self._params.min_num_to_trigger_training: + if self._replay_memory.n_sample < self._params.n_start_train: print( f"Skip this training step due to lack of experiences " - f"(current = {self._replay_memory.n_sample}, minimum = {self._params.min_num_to_trigger_training})", + f"(current = {self._replay_memory.n_sample}, minimum = {self._params.n_start_train})", ) return diff --git a/tests/rl/algorithms/ddpg.py b/tests/rl/algorithms/ddpg.py index 6bad660da..9abbf07e4 100644 --- a/tests/rl/algorithms/ddpg.py +++ b/tests/rl/algorithms/ddpg.py @@ -34,6 +34,7 @@ def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None activation=actor_net_conf["activation"], output_activation=actor_net_conf["output_activation"], ) + self._optim = Adam(self._net.parameters(), lr=critic_learning_rate) self._action_limit = action_limit def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: diff --git a/tests/rl/config.yml b/tests/rl/config.yml index f6d4cfe10..6543548f9 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -11,11 +11,11 @@ job: gym_rl_workflow scenario_path: "tests/rl/gym_wrapper" log_path: "tests/rl_log" main: - num_episodes: 25000 - num_steps: 200 - eval_schedule: 20 + num_episodes: 1000 + num_steps: null + eval_schedule: 5 num_eval_episodes: 10 - min_n_sample: 1 + min_n_sample: 5000 logging: stdout: INFO file: DEBUG @@ -29,7 +29,7 @@ training: load_episode: null checkpointing: path: "tests/rl_log/checkpoints" - interval: 20 + interval: 5 logging: stdout: INFO file: DEBUG From fb11c313d74d8a5f16d09e60e8e8f225aa858ba3 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 06:56:35 +0000 Subject: [PATCH 34/45] Sounds like sac works --- maro/rl/workflows/callback.py | 26 +++++++++++++++++--------- tests/rl/algorithms/ddpg.py | 9 +++++++-- tests/rl/algorithms/sac.py | 4 ++-- tests/rl/config.yml | 10 +++++----- tests/rl/gym_wrapper/config.py | 2 +- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/maro/rl/workflows/callback.py b/maro/rl/workflows/callback.py index 0b58d1f76..409521057 100644 --- a/maro/rl/workflows/callback.py +++ b/maro/rl/workflows/callback.py @@ -128,14 +128,15 @@ def on_training_end( logger: LoggerV2, ep: int, ) -> None: - if len(env_sampler.metrics) > 0: - metrics = copy.deepcopy(env_sampler.metrics) - metrics["ep"] = ep - if ep in self._metrics: - self._metrics[ep].update(metrics) - else: - self._metrics[ep] = metrics - self._dump_metric_history() + pass + # if len(env_sampler.metrics) > 0: + # metrics = copy.deepcopy(env_sampler.metrics) + # metrics["ep"] = ep + # if ep in self._metrics: + # self._metrics[ep].update(metrics) + # else: + # self._metrics[ep] = metrics + # self._dump_metric_history() def on_validation_end( self, @@ -144,7 +145,14 @@ def on_validation_end( logger: LoggerV2, ep: int, ) -> None: - self.on_training_end(env_sampler, training_manager, logger, ep) + if len(env_sampler.metrics) > 0: + metrics = copy.deepcopy(env_sampler.metrics) + metrics["ep"] = ep + if ep in self._metrics: + self._metrics[ep].update(metrics) + else: + self._metrics[ep] = metrics + self._dump_metric_history() SUPPORTED_CALLBACK_FUNC = { diff --git a/tests/rl/algorithms/ddpg.py b/tests/rl/algorithms/ddpg.py index 9abbf07e4..57977d926 100644 --- a/tests/rl/algorithms/ddpg.py +++ b/tests/rl/algorithms/ddpg.py @@ -36,9 +36,14 @@ def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None ) self._optim = Adam(self._net.parameters(), lr=critic_learning_rate) self._action_limit = action_limit + self._noise_scale = 0.1 # TODO def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: - return self._net(states) * self._action_limit + action = self._net(states) * self._action_limit + if exploring: + action += torch.randn(self.action_dim) * self._noise_scale + action = torch.clamp(action, -self._action_limit, self._action_limit) + return action class MyQCriticNet(QNet): @@ -79,7 +84,7 @@ def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: batch_size=100, params=DDPGParams( get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=1, + num_epochs=20, n_start_train=1000, soft_update_coef=0.005, ), diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py index 30ef28929..0845edd76 100644 --- a/tests/rl/algorithms/sac.py +++ b/tests/rl/algorithms/sac.py @@ -96,10 +96,10 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit return SoftActorCriticTrainer( name=name, reward_discount=0.99, - # replay_memory_capacity=100000, + replay_memory_capacity=200000, params=SoftActorCriticParams( get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=20, + num_epochs=10, n_start_train=10000, soft_update_coef=0.01, ), diff --git a/tests/rl/config.yml b/tests/rl/config.yml index 6543548f9..6115e8f6c 100644 --- a/tests/rl/config.yml +++ b/tests/rl/config.yml @@ -11,11 +11,11 @@ job: gym_rl_workflow scenario_path: "tests/rl/gym_wrapper" log_path: "tests/rl_log" main: - num_episodes: 1000 - num_steps: null - eval_schedule: 5 + num_episodes: 25000 + num_steps: 200 + eval_schedule: 25 num_eval_episodes: 10 - min_n_sample: 5000 + min_n_sample: 1 logging: stdout: INFO file: DEBUG @@ -29,7 +29,7 @@ training: load_episode: null checkpointing: path: "tests/rl_log/checkpoints" - interval: 5 + interval: 25 logging: stdout: INFO file: DEBUG diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py index 0d37afcf4..212f8e232 100644 --- a/tests/rl/gym_wrapper/config.py +++ b/tests/rl/gym_wrapper/config.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -algorithm = "ppo" +algorithm = "sac" env_conf = { "topology": "Walker2d-v4", From d7d328241cb24c96b0317ccf82e3338fba792ae4 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 07:37:14 +0000 Subject: [PATCH 35/45] Refactor file structure --- maro/rl/workflows/callback.py | 44 ++++---- tests/rl/algorithms/__init__.py | 2 - tests/rl/algorithms/ac.py | 97 ------------------ tests/rl/algorithms/ddpg.py | 91 ----------------- tests/rl/algorithms/ppo.py | 21 ---- tests/rl/algorithms/sac.py | 106 -------------------- tests/rl/config.yml | 35 ------- tests/rl/gym_wrapper/__init__.py | 8 -- tests/rl/gym_wrapper/common.py | 29 ++++++ tests/rl/gym_wrapper/config.py | 13 --- tests/rl/gym_wrapper/rl_component_bundle.py | 73 -------------- 11 files changed, 54 insertions(+), 465 deletions(-) delete mode 100644 tests/rl/algorithms/__init__.py delete mode 100644 tests/rl/algorithms/ac.py delete mode 100644 tests/rl/algorithms/ddpg.py delete mode 100644 tests/rl/algorithms/ppo.py delete mode 100644 tests/rl/algorithms/sac.py delete mode 100644 tests/rl/config.yml delete mode 100644 tests/rl/gym_wrapper/__init__.py create mode 100644 tests/rl/gym_wrapper/common.py delete mode 100644 tests/rl/gym_wrapper/config.py delete mode 100644 tests/rl/gym_wrapper/rl_component_bundle.py diff --git a/maro/rl/workflows/callback.py b/maro/rl/workflows/callback.py index 409521057..6ba8b76a2 100644 --- a/maro/rl/workflows/callback.py +++ b/maro/rl/workflows/callback.py @@ -110,16 +110,19 @@ class MetricsRecorder(Callback): def __init__(self, path: str) -> None: super(MetricsRecorder, self).__init__() - self._metrics: Dict[int, dict] = {} + self._full_metrics: Dict[int, dict] = {} + self._valid_metrics: Dict[int, dict] = {} self._path = path def _dump_metric_history(self) -> None: - if len(self._metrics) == 0: - return - - metric_list = [self._metrics[ep] for ep in sorted(self._metrics.keys())] - df = pd.DataFrame.from_records(metric_list) - df.to_csv(os.path.join(self._path, "metrics.csv"), index=True) + if len(self._full_metrics) > 0: + metric_list = [self._full_metrics[ep] for ep in sorted(self._full_metrics.keys())] + df = pd.DataFrame.from_records(metric_list) + df.to_csv(os.path.join(self._path, "metrics_full.csv"), index=True) + if len(self._valid_metrics) > 0: + metric_list = [self._valid_metrics[ep] for ep in sorted(self._valid_metrics.keys())] + df = pd.DataFrame.from_records(metric_list) + df.to_csv(os.path.join(self._path, "metrics_valid.csv"), index=True) def on_training_end( self, @@ -128,15 +131,14 @@ def on_training_end( logger: LoggerV2, ep: int, ) -> None: - pass - # if len(env_sampler.metrics) > 0: - # metrics = copy.deepcopy(env_sampler.metrics) - # metrics["ep"] = ep - # if ep in self._metrics: - # self._metrics[ep].update(metrics) - # else: - # self._metrics[ep] = metrics - # self._dump_metric_history() + if len(env_sampler.metrics) > 0: + metrics = copy.deepcopy(env_sampler.metrics) + metrics["ep"] = ep + if ep in self._full_metrics: + self._full_metrics[ep].update(metrics) + else: + self._full_metrics[ep] = metrics + self._dump_metric_history() def on_validation_end( self, @@ -148,10 +150,14 @@ def on_validation_end( if len(env_sampler.metrics) > 0: metrics = copy.deepcopy(env_sampler.metrics) metrics["ep"] = ep - if ep in self._metrics: - self._metrics[ep].update(metrics) + if ep in self._full_metrics: + self._full_metrics[ep].update(metrics) + else: + self._full_metrics[ep] = metrics + if ep in self._valid_metrics: + self._valid_metrics[ep].update(metrics) else: - self._metrics[ep] = metrics + self._valid_metrics[ep] = metrics self._dump_metric_history() diff --git a/tests/rl/algorithms/__init__.py b/tests/rl/algorithms/__init__.py deleted file mode 100644 index 9a0454564..000000000 --- a/tests/rl/algorithms/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. diff --git a/tests/rl/algorithms/ac.py b/tests/rl/algorithms/ac.py deleted file mode 100644 index eea32fbcb..000000000 --- a/tests/rl/algorithms/ac.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import Tuple - -import numpy as np -import torch -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousACBasedNet, VNet -from maro.rl.model.fc_block import FullyConnected -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - - -class MyContinuousACBasedNet(ContinuousACBasedNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyContinuousACBasedNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = FullyConnected( - input_dim=state_dim, - hidden_dims=actor_net_conf["hidden_dims"], - output_dim=action_dim, - activation=actor_net_conf["activation"], - ) - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - actions = distribution.sample() - logps = distribution.log_prob(actions).sum(axis=-1) - return actions, logps - - def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - distribution = self._distribution(states) - logps = distribution.log_prob(actions).sum(axis=-1) - return logps - - def _distribution(self, states: torch.Tensor) -> Normal: - mu = self._mu_net(states.float()) - std = torch.exp(self._log_std) - return Normal(mu, std) - - -class MyVCriticNet(VNet): - def __init__(self, state_dim: int) -> None: - super(MyVCriticNet, self).__init__(state_dim=state_dim) - self._critic = FullyConnected( - input_dim=state_dim, - output_dim=1, - hidden_dims=critic_net_conf["hidden_dims"], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: - return self._critic(states.float()).squeeze(-1) - - -def get_ac_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousACBasedNet(gym_state_dim, gym_action_dim), - ) - - -def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: - return ActorCriticTrainer( - name=name, - reward_discount=0.99, - params=ActorCriticParams( - get_v_critic_net_func=lambda: MyVCriticNet(state_dim), - grad_iters=80, - lam=0.97, - ), - ) diff --git a/tests/rl/algorithms/ddpg.py b/tests/rl/algorithms/ddpg.py deleted file mode 100644 index 57977d926..000000000 --- a/tests/rl/algorithms/ddpg.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import torch -from torch.optim import Adam - -from maro.rl.model import QNet -from maro.rl.model.algorithm_nets.ddpg import ContinuousDDPGNet -from maro.rl.model.fc_block import FullyConnected -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import DDPGParams, DDPGTrainer - -actor_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.ReLU, - "output_activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [256, 256], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 1e-3 -critic_learning_rate = 1e-3 - - -class MyContinuousDDPGNet(ContinuousDDPGNet): - def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: - super(MyContinuousDDPGNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - self._net = FullyConnected( - input_dim=state_dim, - output_dim=action_dim, - hidden_dims=actor_net_conf["hidden_dims"], - activation=actor_net_conf["activation"], - output_activation=actor_net_conf["output_activation"], - ) - self._optim = Adam(self._net.parameters(), lr=critic_learning_rate) - self._action_limit = action_limit - self._noise_scale = 0.1 # TODO - - def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: - action = self._net(states) * self._action_limit - if exploring: - action += torch.randn(self.action_dim) * self._noise_scale - action = torch.clamp(action, -self._action_limit, self._action_limit) - return action - - -class MyQCriticNet(QNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._critic = FullyConnected( - input_dim=state_dim + action_dim, - output_dim=1, - hidden_dims=critic_net_conf["hidden_dims"], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) - - -def get_ddpg_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, - action_limit: float, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousDDPGNet(gym_state_dim, gym_action_dim, action_limit), - ) - - -def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: - return DDPGTrainer( - name=name, - reward_discount=0.99, - replay_memory_capacity=1000000, - batch_size=100, - params=DDPGParams( - get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=20, - n_start_train=1000, - soft_update_coef=0.005, - ), - ) diff --git a/tests/rl/algorithms/ppo.py b/tests/rl/algorithms/ppo.py deleted file mode 100644 index 61b3c8576..000000000 --- a/tests/rl/algorithms/ppo.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from maro.rl.training.algorithms import PPOParams, PPOTrainer - -from .ac import MyVCriticNet, get_ac_policy - -get_ppo_policy = get_ac_policy - - -def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: - return PPOTrainer( - name=name, - reward_discount=0.99, - params=PPOParams( - get_v_critic_net_func=lambda: MyVCriticNet(state_dim), - grad_iters=80, - lam=0.97, - clip_ratio=0.2, - ), - ) diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py deleted file mode 100644 index 0845edd76..000000000 --- a/tests/rl/algorithms/sac.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import Tuple - -import numpy as np -import torch -import torch.nn.functional as F -from torch.distributions import Independent, Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousSACNet, QNet -from maro.rl.model.fc_block import FullyConnected -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - -LOG_STD_MAX = 2 -LOG_STD_MIN = -20 - - -class MyContinuousSACNet(ContinuousSACNet): - def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: - super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - self._net = FullyConnected( - input_dim=state_dim, - output_dim=actor_net_conf["hidden_dims"][-1], - hidden_dims=actor_net_conf["hidden_dims"][:-1], - activation=actor_net_conf["activation"], - output_activation=actor_net_conf["activation"], - ) - self._mu = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) - self._log_std = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) - self._action_limit = action_limit - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - net_out = self._net(states.float()) - mu = self._mu(net_out) - log_std = torch.clamp(self._log_std(net_out), LOG_STD_MIN, LOG_STD_MAX) - std = torch.exp(log_std) - - pi_distribution = Normal(mu, std) - pi_action = pi_distribution.rsample() if exploring else mu - - logp_pi = pi_distribution.log_prob(pi_action).sum(axis=-1) - logp_pi -= (2 * (np.log(2) - pi_action - F.softplus(-2 * pi_action))).sum(axis=1) - - pi_action = torch.tanh(pi_action) * self._action_limit - - return pi_action, logp_pi - - -class MyQCriticNet(QNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._critic = FullyConnected( - input_dim=state_dim + action_dim, - output_dim=1, - hidden_dims=critic_net_conf["hidden_dims"], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) - - -def get_sac_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, - action_limit: float, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim, action_limit), - ) - - -def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCriticTrainer: - return SoftActorCriticTrainer( - name=name, - reward_discount=0.99, - replay_memory_capacity=200000, - params=SoftActorCriticParams( - get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=10, - n_start_train=10000, - soft_update_coef=0.01, - ), - ) diff --git a/tests/rl/config.yml b/tests/rl/config.yml deleted file mode 100644 index 6115e8f6c..000000000 --- a/tests/rl/config.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -# Example RL config file for GYM scenario. -# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. - -# Run this workflow by executing one of the following commands: -# - python tests/rl/run.py tests/rl/config.yml - -job: gym_rl_workflow -scenario_path: "tests/rl/gym_wrapper" -log_path: "tests/rl_log" -main: - num_episodes: 25000 - num_steps: 200 - eval_schedule: 25 - num_eval_episodes: 10 - min_n_sample: 1 - logging: - stdout: INFO - file: DEBUG -rollout: - logging: - stdout: INFO - file: DEBUG -training: - mode: simple - load_path: null - load_episode: null - checkpointing: - path: "tests/rl_log/checkpoints" - interval: 25 - logging: - stdout: INFO - file: DEBUG diff --git a/tests/rl/gym_wrapper/__init__.py b/tests/rl/gym_wrapper/__init__.py deleted file mode 100644 index 90be439f0..000000000 --- a/tests/rl/gym_wrapper/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from .rl_component_bundle import rl_component_bundle - -__all__ = [ - "rl_component_bundle", -] diff --git a/tests/rl/gym_wrapper/common.py b/tests/rl/gym_wrapper/common.py new file mode 100644 index 000000000..f5d6523b6 --- /dev/null +++ b/tests/rl/gym_wrapper/common.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import cast + +from maro.simulator import Env + +from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine + + +env_conf = { + "topology": "Walker2d-v4", + "start_tick": 0, + "durations": 5000, + "options": { + "random_seed": None, + }, +} + +learn_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) +test_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) +num_agents = len(learn_env.agent_idx_list) + +gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env +gym_state_dim = gym_env.observation_space.shape[0] +gym_action_dim = gym_env.action_space.shape[0] +action_lower_bound, action_upper_bound = gym_env.action_space.low, gym_env.action_space.high +action_limit = gym_env.action_space.high[0] + diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py deleted file mode 100644 index 212f8e232..000000000 --- a/tests/rl/gym_wrapper/config.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -algorithm = "sac" - -env_conf = { - "topology": "Walker2d-v4", - "start_tick": 0, - "durations": 5000, - "options": { - "random_seed": None, - }, -} diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py deleted file mode 100644 index 7d31f577a..000000000 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import cast - -from maro.rl.rl_component.rl_component_bundle import RLComponentBundle -from maro.simulator import Env - -from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine - -from .config import algorithm, env_conf -from .env_sampler import GymEnvSampler - - -learn_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) -test_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) -num_agents = len(learn_env.agent_idx_list) - -gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env -gym_state_dim = gym_env.observation_space.shape[0] -gym_action_dim = gym_env.action_space.shape[0] -action_lower_bound, action_upper_bound = gym_env.action_space.low, gym_env.action_space.high -action_limit = gym_env.action_space.high[0] - -agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} - -if algorithm == "ac": - from tests.rl.algorithms.ac import get_ac_policy, get_ac_trainer - - policies = [ - get_ac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) - ] - trainers = [get_ac_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] -elif algorithm == "ppo": - from tests.rl.algorithms.ppo import get_ppo_policy, get_ppo_trainer - - policies = [ - get_ppo_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) - ] - trainers = [get_ppo_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] -elif algorithm == "sac": - from tests.rl.algorithms.sac import get_sac_policy, get_sac_trainer - - policies = [ - get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) - for i in range(num_agents) - ] - trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] -elif algorithm == "ddpg": - from tests.rl.algorithms.ddpg import get_ddpg_policy, get_ddpg_trainer - - policies = [ - get_ddpg_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) - for i in range(num_agents) - ] - trainers = [get_ddpg_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] -else: - raise ValueError(f"Unsupported algorithm: {algorithm}") - - -rl_component_bundle = RLComponentBundle( - env_sampler=GymEnvSampler( - learn_env=learn_env, - test_env=test_env, - policies=policies, - agent2policy=agent2policy, - ), - agent2policy=agent2policy, - policies=policies, - trainers=trainers, -) From ea26275bc3e29c8dde51233023b6a323fe73ea33 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 07:37:36 +0000 Subject: [PATCH 36/45] Refactor file structure --- tests/rl/tasks/ac/__init__.py | 132 ++++++++++++++++++++++++++++++ tests/rl/tasks/ac/config.yml | 35 ++++++++ tests/rl/tasks/ddpg/__init__.py | 126 ++++++++++++++++++++++++++++ tests/rl/tasks/ddpg/config.yml | 35 ++++++++ tests/rl/tasks/ppo/__init__.py | 54 ++++++++++++ tests/rl/tasks/ppo/config.yml | 35 ++++++++ tests/rl/tasks/sac/__init__.py | 141 ++++++++++++++++++++++++++++++++ tests/rl/tasks/sac/config.yml | 35 ++++++++ 8 files changed, 593 insertions(+) create mode 100644 tests/rl/tasks/ac/__init__.py create mode 100644 tests/rl/tasks/ac/config.yml create mode 100644 tests/rl/tasks/ddpg/__init__.py create mode 100644 tests/rl/tasks/ddpg/config.yml create mode 100644 tests/rl/tasks/ppo/__init__.py create mode 100644 tests/rl/tasks/ppo/config.yml create mode 100644 tests/rl/tasks/sac/__init__.py create mode 100644 tests/rl/tasks/sac/config.yml diff --git a/tests/rl/tasks/ac/__init__.py b/tests/rl/tasks/ac/__init__.py new file mode 100644 index 000000000..6847177c8 --- /dev/null +++ b/tests/rl/tasks/ac/__init__.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Tuple + +import numpy as np +import torch +from torch.distributions import Normal +from torch.optim import Adam + +from maro.rl.model import ContinuousACBasedNet, VNet +from maro.rl.model.fc_block import FullyConnected +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle +from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer +from tests.rl.gym_wrapper.common import ( + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + learn_env, + num_agents, + test_env, +) +from tests.rl.gym_wrapper.env_sampler import GymEnvSampler + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + + +class MyContinuousACBasedNet(ContinuousACBasedNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyContinuousACBasedNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + log_std = -0.5 * np.ones(action_dim, dtype=np.float32) + self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) + self._mu_net = FullyConnected( + input_dim=state_dim, + hidden_dims=actor_net_conf["hidden_dims"], + output_dim=action_dim, + activation=actor_net_conf["activation"], + ) + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + distribution = self._distribution(states) + actions = distribution.sample() + logps = distribution.log_prob(actions).sum(axis=-1) + return actions, logps + + def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + distribution = self._distribution(states) + logps = distribution.log_prob(actions).sum(axis=-1) + return logps + + def _distribution(self, states: torch.Tensor) -> Normal: + mu = self._mu_net(states.float()) + std = torch.exp(self._log_std) + return Normal(mu, std) + + +class MyVCriticNet(VNet): + def __init__(self, state_dim: int) -> None: + super(MyVCriticNet, self).__init__(state_dim=state_dim) + self._critic = FullyConnected( + input_dim=state_dim, + output_dim=1, + hidden_dims=critic_net_conf["hidden_dims"], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: + return self._critic(states.float()).squeeze(-1) + + +def get_ac_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyContinuousACBasedNet(gym_state_dim, gym_action_dim), + ) + + +def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: + return ActorCriticTrainer( + name=name, + reward_discount=0.99, + params=ActorCriticParams( + get_v_critic_net_func=lambda: MyVCriticNet(state_dim), + grad_iters=80, + lam=0.97, + ), + ) + + +algorithm = "ac" +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} +policies = [ + get_ac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) +] +trainers = [get_ac_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] + + +rl_component_bundle = RLComponentBundle( + env_sampler=GymEnvSampler( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + ), + agent2policy=agent2policy, + policies=policies, + trainers=trainers, +) + +__all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/ac/config.yml b/tests/rl/tasks/ac/config.yml new file mode 100644 index 000000000..bc79f4dd2 --- /dev/null +++ b/tests/rl/tasks/ac/config.yml @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + +# Run this workflow by executing one of the following commands: +# - python tests/rl/run.py tests/rl/config.yml + +job: gym_rl_workflow +scenario_path: "tests/rl/gym_wrapper/tasks/ac" +log_path: "tests/rl_log/ac" +main: + num_episodes: 1000 + num_steps: null + eval_schedule: 5 + num_eval_episodes: 10 + min_n_sample: 5000 + logging: + stdout: INFO + file: DEBUG +rollout: + logging: + stdout: INFO + file: DEBUG +training: + mode: simple + load_path: null + load_episode: null + checkpointing: + path: "tests/rl_log/ac/checkpoints" + interval: 5 + logging: + stdout: INFO + file: DEBUG diff --git a/tests/rl/tasks/ddpg/__init__.py b/tests/rl/tasks/ddpg/__init__.py new file mode 100644 index 000000000..ec0151a03 --- /dev/null +++ b/tests/rl/tasks/ddpg/__init__.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import torch +from torch.optim import Adam + +from maro.rl.model import QNet +from maro.rl.model.algorithm_nets.ddpg import ContinuousDDPGNet +from maro.rl.model.fc_block import FullyConnected +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.training.algorithms import DDPGParams, DDPGTrainer + +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle +from tests.rl.gym_wrapper.common import ( + action_limit, + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + learn_env, + num_agents, + test_env, +) +from tests.rl.gym_wrapper.env_sampler import GymEnvSampler + +actor_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, + "output_activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [256, 256], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 1e-3 +critic_learning_rate = 1e-3 + + +class MyContinuousDDPGNet(ContinuousDDPGNet): + def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: + super(MyContinuousDDPGNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + self._net = FullyConnected( + input_dim=state_dim, + output_dim=action_dim, + hidden_dims=actor_net_conf["hidden_dims"], + activation=actor_net_conf["activation"], + output_activation=actor_net_conf["output_activation"], + ) + self._optim = Adam(self._net.parameters(), lr=critic_learning_rate) + self._action_limit = action_limit + self._noise_scale = 0.1 # TODO + + def _get_actions_impl(self, states: torch.Tensor, exploring: bool) -> torch.Tensor: + action = self._net(states) * self._action_limit + if exploring: + action += torch.randn(self.action_dim) * self._noise_scale + action = torch.clamp(action, -self._action_limit, self._action_limit) + return action + + +class MyQCriticNet(QNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + self._critic = FullyConnected( + input_dim=state_dim + action_dim, + output_dim=1, + hidden_dims=critic_net_conf["hidden_dims"], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) + + +def get_ddpg_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, + action_limit: float, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyContinuousDDPGNet(gym_state_dim, gym_action_dim, action_limit), + ) + + +def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: + return DDPGTrainer( + name=name, + reward_discount=0.99, + replay_memory_capacity=1000000, + batch_size=100, + params=DDPGParams( + get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), + num_epochs=20, + n_start_train=1000, + soft_update_coef=0.005, + ), + ) + +algorithm = "ddpg" +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} +policies = [ + get_ddpg_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) + for i in range(num_agents) +] +trainers = [get_ddpg_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] + +rl_component_bundle = RLComponentBundle( + env_sampler=GymEnvSampler( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + ), + agent2policy=agent2policy, + policies=policies, + trainers=trainers, +) + +__all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/ddpg/config.yml b/tests/rl/tasks/ddpg/config.yml new file mode 100644 index 000000000..2fb186a28 --- /dev/null +++ b/tests/rl/tasks/ddpg/config.yml @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + +# Run this workflow by executing one of the following commands: +# - python tests/rl/run.py tests/rl/config.yml + +job: gym_rl_workflow +scenario_path: "tests/rl/gym_wrapper/tasks/ddpg" +log_path: "tests/rl_log/ddpg" +main: + num_episodes: 25000 + num_steps: 200 + eval_schedule: 25 + num_eval_episodes: 10 + min_n_sample: 1 + logging: + stdout: INFO + file: DEBUG +rollout: + logging: + stdout: INFO + file: DEBUG +training: + mode: simple + load_path: null + load_episode: null + checkpointing: + path: "tests/rl_log/ddpg/checkpoints" + interval: 25 + logging: + stdout: INFO + file: DEBUG diff --git a/tests/rl/tasks/ppo/__init__.py b/tests/rl/tasks/ppo/__init__.py new file mode 100644 index 000000000..977306ffc --- /dev/null +++ b/tests/rl/tasks/ppo/__init__.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle +from maro.rl.training.algorithms.ppo import PPOParams, PPOTrainer +from tests.rl.gym_wrapper.common import ( + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + learn_env, + num_agents, + test_env, +) +from tests.rl.gym_wrapper.env_sampler import GymEnvSampler +from tests.rl.tasks.ac import MyVCriticNet, get_ac_policy + + +get_ppo_policy = get_ac_policy + + +def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: + return PPOTrainer( + name=name, + reward_discount=0.99, + params=PPOParams( + get_v_critic_net_func=lambda: MyVCriticNet(state_dim), + grad_iters=80, + lam=0.97, + clip_ratio=0.2, + ), + ) + +algorithm = "ppo" +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} +policies = [ + get_ppo_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) + for i in range(num_agents) +] +trainers = [get_ppo_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] + +rl_component_bundle = RLComponentBundle( + env_sampler=GymEnvSampler( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + ), + agent2policy=agent2policy, + policies=policies, + trainers=trainers, +) + +__all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/ppo/config.yml b/tests/rl/tasks/ppo/config.yml new file mode 100644 index 000000000..5197286af --- /dev/null +++ b/tests/rl/tasks/ppo/config.yml @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + +# Run this workflow by executing one of the following commands: +# - python tests/rl/run.py tests/rl/config.yml + +job: gym_rl_workflow +scenario_path: "tests/rl/gym_wrapper/tasks/ppo" +log_path: "tests/rl_log/ppo" +main: + num_episodes: 1000 + num_steps: null + eval_schedule: 5 + num_eval_episodes: 10 + min_n_sample: 5000 + logging: + stdout: INFO + file: DEBUG +rollout: + logging: + stdout: INFO + file: DEBUG +training: + mode: simple + load_path: null + load_episode: null + checkpointing: + path: "tests/rl_log/ppo/checkpoints" + interval: 5 + logging: + stdout: INFO + file: DEBUG diff --git a/tests/rl/tasks/sac/__init__.py b/tests/rl/tasks/sac/__init__.py new file mode 100644 index 000000000..d1e05a55e --- /dev/null +++ b/tests/rl/tasks/sac/__init__.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Tuple + +import numpy as np +import torch +import torch.nn.functional as F +from torch.distributions import Independent, Normal +from torch.optim import Adam + +from maro.rl.model import ContinuousSACNet, QNet +from maro.rl.model.fc_block import FullyConnected +from maro.rl.policy import ContinuousRLPolicy +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle +from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer +from tests.rl.gym_wrapper.common import ( + action_limit, + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + learn_env, + num_agents, + test_env, +) +from tests.rl.gym_wrapper.env_sampler import GymEnvSampler + +actor_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +critic_net_conf = { + "hidden_dims": [64, 64], + "activation": torch.nn.Tanh, +} +actor_learning_rate = 3e-4 +critic_learning_rate = 1e-3 + +LOG_STD_MAX = 2 +LOG_STD_MIN = -20 + + +class MyContinuousSACNet(ContinuousSACNet): + def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: + super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + self._net = FullyConnected( + input_dim=state_dim, + output_dim=actor_net_conf["hidden_dims"][-1], + hidden_dims=actor_net_conf["hidden_dims"][:-1], + activation=actor_net_conf["activation"], + output_activation=actor_net_conf["activation"], + ) + self._mu = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) + self._log_std = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) + self._action_limit = action_limit + self._optim = Adam(self.parameters(), lr=actor_learning_rate) + + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: + net_out = self._net(states.float()) + mu = self._mu(net_out) + log_std = torch.clamp(self._log_std(net_out), LOG_STD_MIN, LOG_STD_MAX) + std = torch.exp(log_std) + + pi_distribution = Normal(mu, std) + pi_action = pi_distribution.rsample() if exploring else mu + + logp_pi = pi_distribution.log_prob(pi_action).sum(axis=-1) + logp_pi -= (2 * (np.log(2) - pi_action - F.softplus(-2 * pi_action))).sum(axis=1) + + pi_action = torch.tanh(pi_action) * self._action_limit + + return pi_action, logp_pi + + +class MyQCriticNet(QNet): + def __init__(self, state_dim: int, action_dim: int) -> None: + super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + self._critic = FullyConnected( + input_dim=state_dim + action_dim, + output_dim=1, + hidden_dims=critic_net_conf["hidden_dims"], + activation=critic_net_conf["activation"], + ) + self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) + + def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: + return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) + + +def get_sac_policy( + name: str, + action_lower_bound: list, + action_upper_bound: list, + gym_state_dim: int, + gym_action_dim: int, + action_limit: float, +) -> ContinuousRLPolicy: + return ContinuousRLPolicy( + name=name, + action_range=(action_lower_bound, action_upper_bound), + policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim, action_limit), + ) + + +def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCriticTrainer: + return SoftActorCriticTrainer( + name=name, + reward_discount=0.99, + replay_memory_capacity=200000, + params=SoftActorCriticParams( + get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), + num_epochs=10, + n_start_train=10000, + soft_update_coef=0.01, + ), + ) + + +algorithm = "sac" +agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} +policies = [ + get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) + for i in range(num_agents) +] +trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] + +rl_component_bundle = RLComponentBundle( + env_sampler=GymEnvSampler( + learn_env=learn_env, + test_env=test_env, + policies=policies, + agent2policy=agent2policy, + ), + agent2policy=agent2policy, + policies=policies, + trainers=trainers, +) + +__all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/sac/config.yml b/tests/rl/tasks/sac/config.yml new file mode 100644 index 000000000..863179ffa --- /dev/null +++ b/tests/rl/tasks/sac/config.yml @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + +# Run this workflow by executing one of the following commands: +# - python tests/rl/run.py tests/rl/config.yml + +job: gym_rl_workflow +scenario_path: "tests/rl/gym_wrapper/tasks/sac" +log_path: "tests/rl_log/sac" +main: + num_episodes: 25000 + num_steps: 200 + eval_schedule: 25 + num_eval_episodes: 10 + min_n_sample: 1 + logging: + stdout: INFO + file: DEBUG +rollout: + logging: + stdout: INFO + file: DEBUG +training: + mode: simple + load_path: null + load_episode: null + checkpointing: + path: "tests/rl_log/sac/checkpoints" + interval: 25 + logging: + stdout: INFO + file: DEBUG From 8881a1c187e8c955d3e05a7205714b083ec70586 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 1 Feb 2023 07:39:03 +0000 Subject: [PATCH 37/45] Refactor file structure --- tests/rl/tasks/ac/config.yml | 5 +---- tests/rl/tasks/ddpg/config.yml | 2 +- tests/rl/tasks/ppo/config.yml | 8 +------- tests/rl/tasks/sac/config.yml | 5 +---- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/tests/rl/tasks/ac/config.yml b/tests/rl/tasks/ac/config.yml index bc79f4dd2..6b7e1ebfe 100644 --- a/tests/rl/tasks/ac/config.yml +++ b/tests/rl/tasks/ac/config.yml @@ -4,11 +4,8 @@ # Example RL config file for GYM scenario. # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. -# Run this workflow by executing one of the following commands: -# - python tests/rl/run.py tests/rl/config.yml - job: gym_rl_workflow -scenario_path: "tests/rl/gym_wrapper/tasks/ac" +scenario_path: "tests/rl/tasks/ac" log_path: "tests/rl_log/ac" main: num_episodes: 1000 diff --git a/tests/rl/tasks/ddpg/config.yml b/tests/rl/tasks/ddpg/config.yml index 2fb186a28..961a3c1f5 100644 --- a/tests/rl/tasks/ddpg/config.yml +++ b/tests/rl/tasks/ddpg/config.yml @@ -8,7 +8,7 @@ # - python tests/rl/run.py tests/rl/config.yml job: gym_rl_workflow -scenario_path: "tests/rl/gym_wrapper/tasks/ddpg" +scenario_path: "tests/rl/tasks/ddpg" log_path: "tests/rl_log/ddpg" main: num_episodes: 25000 diff --git a/tests/rl/tasks/ppo/config.yml b/tests/rl/tasks/ppo/config.yml index 5197286af..35cf4d07f 100644 --- a/tests/rl/tasks/ppo/config.yml +++ b/tests/rl/tasks/ppo/config.yml @@ -1,14 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -# Example RL config file for GYM scenario. -# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. - -# Run this workflow by executing one of the following commands: -# - python tests/rl/run.py tests/rl/config.yml - job: gym_rl_workflow -scenario_path: "tests/rl/gym_wrapper/tasks/ppo" +scenario_path: "tests/rl/tasks/ppo" log_path: "tests/rl_log/ppo" main: num_episodes: 1000 diff --git a/tests/rl/tasks/sac/config.yml b/tests/rl/tasks/sac/config.yml index 863179ffa..20b956342 100644 --- a/tests/rl/tasks/sac/config.yml +++ b/tests/rl/tasks/sac/config.yml @@ -4,11 +4,8 @@ # Example RL config file for GYM scenario. # Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. -# Run this workflow by executing one of the following commands: -# - python tests/rl/run.py tests/rl/config.yml - job: gym_rl_workflow -scenario_path: "tests/rl/gym_wrapper/tasks/sac" +scenario_path: "tests/rl/tasks/sac" log_path: "tests/rl_log/sac" main: num_episodes: 25000 From b4db84214de10fe153c062851dedf3388d2edec6 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Mon, 6 Feb 2023 11:44:28 +0800 Subject: [PATCH 38/45] Pre-commit --- maro/rl/model/fc_block.py | 16 +++++++++++++--- tests/rl/gym_wrapper/rl_component_bundle.py | 13 +++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/maro/rl/model/fc_block.py b/maro/rl/model/fc_block.py index c86b0e740..9154d6ff0 100644 --- a/maro/rl/model/fc_block.py +++ b/maro/rl/model/fc_block.py @@ -72,9 +72,13 @@ def __init__( # build the net dims = [self._input_dim] + self._hidden_dims - layers = [self._build_layer(in_dim, out_dim, activation=self._activation) for in_dim, out_dim in zip(dims, dims[1:])] + layers = [ + self._build_layer(in_dim, out_dim, activation=self._activation) for in_dim, out_dim in zip(dims, dims[1:]) + ] # top layer - layers.append(self._build_layer(dims[-1], self._output_dim, head=self._head, activation=self._output_activation)) + layers.append( + self._build_layer(dims[-1], self._output_dim, head=self._head, activation=self._output_activation), + ) self._net = nn.Sequential(*layers) @@ -103,7 +107,13 @@ def input_dim(self) -> int: def output_dim(self) -> int: return self._output_dim - def _build_layer(self, input_dim: int, output_dim: int, head: bool = False, activation: Type[torch.nn.Module] = None) -> nn.Module: + def _build_layer( + self, + input_dim: int, + output_dim: int, + head: bool = False, + activation: Type[torch.nn.Module] = None, + ) -> nn.Module: """Build a basic layer. BN -> Linear -> Activation -> Dropout diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py index 26217054b..e19ce433f 100644 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ b/tests/rl/gym_wrapper/rl_component_bundle.py @@ -6,11 +6,9 @@ from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.simulator import Env -from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine - from .config import algorithm, env_conf from .env_sampler import GymEnvSampler - +from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine learn_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) test_env = learn_env @@ -44,7 +42,14 @@ from tests.rl.algorithms.sac import get_sac_policy, get_sac_trainer policies = [ - get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) + get_sac_policy( + f"{algorithm}_{i}.policy", + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + action_limit, + ) for i in range(num_agents) ] trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] From eb7ae9b748109b6c983d6c68af7db9dec0c1635f Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Mon, 6 Feb 2023 13:55:14 +0800 Subject: [PATCH 39/45] Pre commit --- maro/rl/training/algorithms/ddpg.py | 2 +- maro/rl/training/algorithms/sac.py | 4 +++- maro/rl/workflows/main.py | 4 ++-- tests/rl/gym_wrapper/common.py | 2 -- tests/rl/gym_wrapper/env_sampler.py | 2 +- tests/rl/tasks/ac/__init__.py | 13 +++++++------ tests/rl/tasks/ddpg/__init__.py | 24 ++++++++++++++++-------- tests/rl/tasks/ppo/__init__.py | 15 ++++++++------- tests/rl/tasks/sac/__init__.py | 22 +++++++++++++++------- 9 files changed, 53 insertions(+), 35 deletions(-) diff --git a/maro/rl/training/algorithms/ddpg.py b/maro/rl/training/algorithms/ddpg.py index 31ccfd821..53b070300 100644 --- a/maro/rl/training/algorithms/ddpg.py +++ b/maro/rl/training/algorithms/ddpg.py @@ -95,7 +95,7 @@ def _get_critic_loss(self, batch: TransitionBatch) -> torch.Tensor: ) # Q_targ(s', miu_targ(s')) # y(r, s', d) = r + gamma * (1 - d) * Q_targ(s', miu_targ(s')) target_q_values = (rewards + self._reward_discount * (1.0 - terminals.float()) * next_q_values).detach() - + q_values = self._q_critic_net.q_values(states=states, actions=actions) # Q(s, a) return self._q_value_loss_func(q_values, target_q_values) # MSE(Q(s, a), y(r, s', d)) diff --git a/maro/rl/training/algorithms/sac.py b/maro/rl/training/algorithms/sac.py index 77d751f1d..d6110f685 100644 --- a/maro/rl/training/algorithms/sac.py +++ b/maro/rl/training/algorithms/sac.py @@ -72,7 +72,9 @@ def _get_critic_loss(self, batch: TransitionBatch) -> Tuple[torch.Tensor, torch. target_q1 = self._target_q_net1.q_values(next_states, next_actions) target_q2 = self._target_q_net2.q_values(next_states, next_actions) target_q = torch.min(target_q1, target_q2) - y = rewards + self._reward_discount * (1.0 - terminals.float()) * (target_q - self._entropy_coef * next_logps) + y = rewards + self._reward_discount * (1.0 - terminals.float()) * ( + target_q - self._entropy_coef * next_logps + ) q1 = self._q_net1.q_values(states, actions) q2 = self._q_net2.q_values(states, actions) diff --git a/maro/rl/workflows/main.py b/maro/rl/workflows/main.py index 0f98ebb6b..d63c46f0a 100644 --- a/maro/rl/workflows/main.py +++ b/maro/rl/workflows/main.py @@ -138,7 +138,7 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow Checkpoint( path=env_attr.checkpoint_path, interval=1 if env_attr.checkpoint_interval is None else env_attr.checkpoint_interval, - ) + ), ) callbacks.append(MetricsRecorder(path=env_attr.log_path)) cbm = CallbackManager(callbacks) @@ -201,7 +201,7 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow eval_point_index += 1 result = env_sampler.eval( policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, - num_episodes=env_attr.num_eval_episodes + num_episodes=env_attr.num_eval_episodes, ) env_sampler.post_evaluate(result["info"], ep) diff --git a/tests/rl/gym_wrapper/common.py b/tests/rl/gym_wrapper/common.py index f5d6523b6..f7cf57f0e 100644 --- a/tests/rl/gym_wrapper/common.py +++ b/tests/rl/gym_wrapper/common.py @@ -7,7 +7,6 @@ from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine - env_conf = { "topology": "Walker2d-v4", "start_tick": 0, @@ -26,4 +25,3 @@ gym_action_dim = gym_env.action_space.shape[0] action_lower_bound, action_upper_bound = gym_env.action_space.low, gym_env.action_space.high action_limit = gym_env.action_space.high[0] - diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index 9ffec6557..591a1b29a 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -4,8 +4,8 @@ from typing import Any, Dict, List, Tuple, Type, Union import numpy as np -from maro.rl.policy.abs_policy import AbsPolicy +from maro.rl.policy.abs_policy import AbsPolicy from maro.rl.rollout import AbsEnvSampler, CacheElement from maro.rl.rollout.env_sampler import AbsAgentWrapper, SimpleAgentWrapper from maro.simulator.core import Env diff --git a/tests/rl/tasks/ac/__init__.py b/tests/rl/tasks/ac/__init__.py index 6847177c8..d2e13629f 100644 --- a/tests/rl/tasks/ac/__init__.py +++ b/tests/rl/tasks/ac/__init__.py @@ -13,14 +13,15 @@ from maro.rl.policy import ContinuousRLPolicy from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer + from tests.rl.gym_wrapper.common import ( - action_lower_bound, - action_upper_bound, - gym_state_dim, - gym_action_dim, - learn_env, + action_lower_bound, + action_upper_bound, + gym_action_dim, + gym_state_dim, + learn_env, num_agents, - test_env, + test_env, ) from tests.rl.gym_wrapper.env_sampler import GymEnvSampler diff --git a/tests/rl/tasks/ddpg/__init__.py b/tests/rl/tasks/ddpg/__init__.py index ec0151a03..851b1d807 100644 --- a/tests/rl/tasks/ddpg/__init__.py +++ b/tests/rl/tasks/ddpg/__init__.py @@ -8,18 +8,18 @@ from maro.rl.model.algorithm_nets.ddpg import ContinuousDDPGNet from maro.rl.model.fc_block import FullyConnected from maro.rl.policy import ContinuousRLPolicy +from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training.algorithms import DDPGParams, DDPGTrainer -from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from tests.rl.gym_wrapper.common import ( action_limit, - action_lower_bound, - action_upper_bound, - gym_state_dim, - gym_action_dim, - learn_env, + action_lower_bound, + action_upper_bound, + gym_action_dim, + gym_state_dim, + learn_env, num_agents, - test_env, + test_env, ) from tests.rl.gym_wrapper.env_sampler import GymEnvSampler @@ -103,10 +103,18 @@ def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: ), ) + algorithm = "ddpg" agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} policies = [ - get_ddpg_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) + get_ddpg_policy( + f"{algorithm}_{i}.policy", + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + action_limit, + ) for i in range(num_agents) ] trainers = [get_ddpg_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] diff --git a/tests/rl/tasks/ppo/__init__.py b/tests/rl/tasks/ppo/__init__.py index 977306ffc..9d1b406d6 100644 --- a/tests/rl/tasks/ppo/__init__.py +++ b/tests/rl/tasks/ppo/__init__.py @@ -3,19 +3,19 @@ from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training.algorithms.ppo import PPOParams, PPOTrainer + from tests.rl.gym_wrapper.common import ( - action_lower_bound, - action_upper_bound, - gym_state_dim, - gym_action_dim, - learn_env, + action_lower_bound, + action_upper_bound, + gym_action_dim, + gym_state_dim, + learn_env, num_agents, - test_env, + test_env, ) from tests.rl.gym_wrapper.env_sampler import GymEnvSampler from tests.rl.tasks.ac import MyVCriticNet, get_ac_policy - get_ppo_policy = get_ac_policy @@ -31,6 +31,7 @@ def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: ), ) + algorithm = "ppo" agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} policies = [ diff --git a/tests/rl/tasks/sac/__init__.py b/tests/rl/tasks/sac/__init__.py index d1e05a55e..9d74d80a5 100644 --- a/tests/rl/tasks/sac/__init__.py +++ b/tests/rl/tasks/sac/__init__.py @@ -14,15 +14,16 @@ from maro.rl.policy import ContinuousRLPolicy from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer + from tests.rl.gym_wrapper.common import ( action_limit, - action_lower_bound, - action_upper_bound, - gym_state_dim, - gym_action_dim, - learn_env, + action_lower_bound, + action_upper_bound, + gym_action_dim, + gym_state_dim, + learn_env, num_agents, - test_env, + test_env, ) from tests.rl.gym_wrapper.env_sampler import GymEnvSampler @@ -121,7 +122,14 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit algorithm = "sac" agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} policies = [ - get_sac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim, action_limit) + get_sac_policy( + f"{algorithm}_{i}.policy", + action_lower_bound, + action_upper_bound, + gym_state_dim, + gym_action_dim, + action_limit, + ) for i in range(num_agents) ] trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] From 627b7d1cb58c7a309e3ffe268a09284c46758955 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Wed, 8 Feb 2023 13:53:14 +0800 Subject: [PATCH 40/45] Minor refinement of CIM RL --- examples/cim/rl/env_sampler.py | 1 + examples/cim/rl/rl_component_bundle.py | 2 +- tests/rl/gym_wrapper/rl_component_bundle.py | 70 --------------------- 3 files changed, 2 insertions(+), 71 deletions(-) delete mode 100644 tests/rl/gym_wrapper/rl_component_bundle.py diff --git a/examples/cim/rl/env_sampler.py b/examples/cim/rl/env_sampler.py index 6550d062c..e53daee98 100644 --- a/examples/cim/rl/env_sampler.py +++ b/examples/cim/rl/env_sampler.py @@ -96,6 +96,7 @@ def post_collect(self, info_list: list, ep: int) -> None: print(f"average env summary (episode {ep}): {avg_metric}") self.metrics.update(avg_metric) + self.metrics = {k: v for k, v in self.metrics.items() if not k.startswith("val/")} def post_evaluate(self, info_list: list, ep: int) -> None: # print the env metric from each rollout worker diff --git a/examples/cim/rl/rl_component_bundle.py b/examples/cim/rl/rl_component_bundle.py index d290c8f1d..62f6b4fc1 100644 --- a/examples/cim/rl/rl_component_bundle.py +++ b/examples/cim/rl/rl_component_bundle.py @@ -13,7 +13,7 @@ # Environments learn_env = Env(**env_conf) -test_env = learn_env +test_env = Env(**env_conf) # Agent, policy, and trainers num_agents = len(learn_env.agent_idx_list) diff --git a/tests/rl/gym_wrapper/rl_component_bundle.py b/tests/rl/gym_wrapper/rl_component_bundle.py deleted file mode 100644 index e19ce433f..000000000 --- a/tests/rl/gym_wrapper/rl_component_bundle.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import cast - -from maro.rl.rl_component.rl_component_bundle import RLComponentBundle -from maro.simulator import Env - -from .config import algorithm, env_conf -from .env_sampler import GymEnvSampler -from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine - -learn_env = Env(business_engine_cls=GymBusinessEngine, **env_conf) -test_env = learn_env -num_agents = len(learn_env.agent_idx_list) - -gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env -gym_state_dim = gym_env.observation_space.shape[0] -gym_action_dim = gym_env.action_space.shape[0] -action_lower_bound, action_upper_bound = gym_env.action_space.low, gym_env.action_space.high -action_limit = gym_env.action_space.high[0] - -agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} - -if algorithm == "ac": - from tests.rl.algorithms.ac import get_ac_policy, get_ac_trainer - - policies = [ - get_ac_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) - ] - trainers = [get_ac_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] -elif algorithm == "ppo": - from tests.rl.algorithms.ppo import get_ppo_policy, get_ppo_trainer - - policies = [ - get_ppo_policy(f"{algorithm}_{i}.policy", action_lower_bound, action_upper_bound, gym_state_dim, gym_action_dim) - for i in range(num_agents) - ] - trainers = [get_ppo_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] -elif algorithm == "sac": - from tests.rl.algorithms.sac import get_sac_policy, get_sac_trainer - - policies = [ - get_sac_policy( - f"{algorithm}_{i}.policy", - action_lower_bound, - action_upper_bound, - gym_state_dim, - gym_action_dim, - action_limit, - ) - for i in range(num_agents) - ] - trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] -else: - raise ValueError(f"Unsupported algorithm: {algorithm}") - - -rl_component_bundle = RLComponentBundle( - env_sampler=GymEnvSampler( - learn_env=learn_env, - test_env=test_env, - policies=policies, - agent2policy=agent2policy, - ), - agent2policy=agent2policy, - policies=policies, - trainers=trainers, -) From 83863124618389175409fb10905ea366158be737 Mon Sep 17 00:00:00 2001 From: Jinyu-W <53509467+Jinyu-W@users.noreply.github.com> Date: Wed, 8 Feb 2023 15:24:55 +0800 Subject: [PATCH 41/45] Jinyu/rl workflow refine (#578) * remove useless files; add device mapping; update pdoc * add default checkpoint path; fix distributed worker log path issue; update example log path * update performance doc * remove tests/rl/algorithms folder --- .gitignore | 2 - examples/rl/README.md | 2 +- examples/rl/cim.yml | 4 +- examples/rl/cim_distributed.yml | 4 +- examples/rl/vm_scheduling.yml | 4 +- maro/rl/rl_component/rl_component_bundle.py | 2 +- maro/rl/workflows/config/parser.py | 7 +- maro/rl/workflows/config/template.yml | 5 +- maro/rl/workflows/rollout_worker.py | 2 +- maro/rl/workflows/train_worker.py | 2 +- tests/rl/algorithms/__init__.py | 2 - tests/rl/algorithms/ac.py | 97 ------------------ tests/rl/algorithms/ppo.py | 21 ---- tests/rl/algorithms/sac.py | 104 -------------------- tests/rl/config.yml | 34 ------- tests/rl/gym_wrapper/__init__.py | 6 -- tests/rl/gym_wrapper/common.py | 2 +- tests/rl/gym_wrapper/config.py | 13 --- tests/rl/performance.md | 74 +++++++++++--- tests/rl/tasks/ac/__init__.py | 5 + tests/rl/tasks/ac/config.yml | 4 +- tests/rl/tasks/ddpg/__init__.py | 6 ++ tests/rl/tasks/ddpg/config.yml | 4 +- tests/rl/tasks/ppo/__init__.py | 7 ++ tests/rl/tasks/ppo/config.yml | 4 +- tests/rl/tasks/sac/__init__.py | 8 +- tests/rl/tasks/sac/config.yml | 4 +- 27 files changed, 111 insertions(+), 318 deletions(-) delete mode 100644 tests/rl/algorithms/__init__.py delete mode 100644 tests/rl/algorithms/ac.py delete mode 100644 tests/rl/algorithms/ppo.py delete mode 100644 tests/rl/algorithms/sac.py delete mode 100644 tests/rl/config.yml delete mode 100644 tests/rl/gym_wrapper/config.py diff --git a/.gitignore b/.gitignore index 04c31a589..2d5f5ea48 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,3 @@ htmlcov/ .coveragerc .tmp/ .xmake/ -outputs/ -tests/rl_log/ diff --git a/examples/rl/README.md b/examples/rl/README.md index ca3a3807e..2dc7d2683 100644 --- a/examples/rl/README.md +++ b/examples/rl/README.md @@ -7,7 +7,7 @@ This folder contains scenarios that employ reinforcement learning. MARO's RL too The entrance of a RL workflow is a YAML config file. For readers' convenience, we call this config file `config.yml` in the rest part of this doc. `config.yml` specifies the path of all necessary resources, definitions, and configurations to run the job. MARO provides a comprehensive template of the config file with detailed explanations (`maro/maro/rl/workflows/config/template.yml`). Meanwhile, MARO also provides several simple examples of `config.yml` under the current folder. There are two ways to start the RL job: -- If you only need to have a quick look and try to start an out-of-box workflow, just run `python .\examples\rl\run_rl_example.py PATH_TO_CONFIG_YAML`. For example, `python .\examples\rl\run_rl_example.py .\examples\rl\cim.yml` will run the complete example RL training workflow of CIM scenario. If you only want to run the evaluation workflow, you could start the job with `--evaluate_only`. +- If you only need to have a quick look and try to start an out-of-box workflow, just run `python .\examples\rl\run.py PATH_TO_CONFIG_YAML`. For example, `python .\examples\rl\run.py .\examples\rl\cim.yml` will run the complete example RL training workflow of CIM scenario. If you only want to run the evaluation workflow, you could start the job with `--evaluate_only`. - (**Require install MARO from source**) You could also start the job through MARO CLI. Use the command `maro local run [-c] path/to/your/config` to run in containerized (with `-c`) or non-containerized (without `-c`) environments. Similar, you could add `--evaluate_only` if you only need to run the evaluation workflow. ## Create Your Own Scenarios diff --git a/examples/rl/cim.yml b/examples/rl/cim.yml index 358546f4d..581a2981e 100644 --- a/examples/rl/cim.yml +++ b/examples/rl/cim.yml @@ -10,7 +10,7 @@ job: cim_rl_workflow scenario_path: "examples/cim/rl" -log_path: "outputs/cim_rl/" +log_path: "log/cim_rl/" main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null @@ -27,7 +27,7 @@ training: load_path: null load_episode: null checkpointing: - path: "outputs/cim_rl/checkpoints" + path: "log/cim_rl/checkpoints" interval: 5 logging: stdout: INFO diff --git a/examples/rl/cim_distributed.yml b/examples/rl/cim_distributed.yml index 65d747b6f..adbbbc873 100644 --- a/examples/rl/cim_distributed.yml +++ b/examples/rl/cim_distributed.yml @@ -10,7 +10,7 @@ job: cim_rl_workflow scenario_path: "examples/cim/rl" -log_path: "outputs/cim_rl/" +log_path: "log/cim_rl/" main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null @@ -35,7 +35,7 @@ training: load_path: null load_episode: null checkpointing: - path: "outputs/cim_rl/checkpoints" + path: "log/cim_rl/checkpoints" interval: 5 proxy: host: "127.0.0.1" diff --git a/examples/rl/vm_scheduling.yml b/examples/rl/vm_scheduling.yml index d7bf57838..16baa67db 100644 --- a/examples/rl/vm_scheduling.yml +++ b/examples/rl/vm_scheduling.yml @@ -10,7 +10,7 @@ job: vm_scheduling_rl_workflow scenario_path: "examples/vm_scheduling/rl" -log_path: "outputs/vm_rl/" +log_path: "log/vm_rl/" main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null @@ -27,7 +27,7 @@ training: load_path: null load_episode: null checkpointing: - path: "outputs/vm_rl/checkpoints" + path: "log/vm_rl/checkpoints" interval: 5 logging: stdout: INFO diff --git a/maro/rl/rl_component/rl_component_bundle.py b/maro/rl/rl_component/rl_component_bundle.py index f85fe286b..22efbeb8a 100644 --- a/maro/rl/rl_component/rl_component_bundle.py +++ b/maro/rl/rl_component/rl_component_bundle.py @@ -20,7 +20,7 @@ class RLComponentBundle: If None, there will be no explicit device assignment. policy_trainer_mapping (Dict[str, str], default=None): Policy-trainer mapping which identifying which trainer to train each policy. If None, then a policy's trainer's name is the first segment of the policy's name, - seperated by dot. For example, "ppo_1.policy" is trained by "ppo_1". Only policies that provided in + separated by dot. For example, "ppo_1.policy" is trained by "ppo_1". Only policies that provided in policy-trainer mapping are considered as trainable polices. Policies that not provided in policy-trainer mapping will not be trained. """ diff --git a/maro/rl/workflows/config/parser.py b/maro/rl/workflows/config/parser.py index 5910bd0a1..709a94905 100644 --- a/maro/rl/workflows/config/parser.py +++ b/maro/rl/workflows/config/parser.py @@ -196,9 +196,10 @@ def _validate_train_proxy_section(self, proxy_section: dict) -> None: raise TypeError(f"{self._validation_err_pfx}: 'training.proxy.backend' must be an int") def _validate_checkpointing_section(self, section: dict) -> None: - if "path" not in section: - raise KeyError(f"{self._validation_err_pfx}: missing field 'path' under section 'checkpointing'") - if not isinstance(section["path"], str): + ckpt_path = section.get("path", None) + if ckpt_path is None: + section["path"] = os.path.join(self._config["log_path"], "checkpoints") + elif not isinstance(section["path"], str): raise TypeError(f"{self._validation_err_pfx}: 'training.checkpointing.path' must be a string") if "interval" in section: diff --git a/maro/rl/workflows/config/template.yml b/maro/rl/workflows/config/template.yml index 67b0479ac..ea38b9316 100644 --- a/maro/rl/workflows/config/template.yml +++ b/maro/rl/workflows/config/template.yml @@ -69,8 +69,9 @@ training: checkpointing: # Directory to save trainer snapshots under. Snapshot files created at different episodes will be saved under # separate folders named using episode numbers. For example, if a snapshot is created for a trainer named "dqn" - # at the end of episode 10, the file path would be "/path/to/your/checkpoint/folder/10/dqn.ckpt". - path: "/path/to/your/checkpoint/folder" + # at the end of episode 10, the file path would be "/path/to/your/checkpoint/folder/10/dqn.ckpt". If null, the + # default checkpoint folder would be created under `log_path`. + path: "/path/to/your/checkpoint/folder" # or `null` interval: 10 # Interval at which trained policies / models are persisted to disk. proxy: # Proxy settings. Ignored if training.mode is "simple". host: "127.0.0.1" # Proxy service host's IP address. Ignored if run in containerized environments. diff --git a/maro/rl/workflows/rollout_worker.py b/maro/rl/workflows/rollout_worker.py index a5a7b4b22..59cfa7b0d 100644 --- a/maro/rl/workflows/rollout_worker.py +++ b/maro/rl/workflows/rollout_worker.py @@ -21,7 +21,7 @@ worker_idx = int(get_env("ID")) logger = LoggerV2( f"ROLLOUT-WORKER.{worker_idx}", - dump_path=get_env("LOG_PATH"), + dump_path=os.path.join(get_env("LOG_PATH"), f"ROLLOUT-WORKER.{worker_idx}.txt"), dump_mode="a", stdout_level=get_env("LOG_LEVEL_STDOUT", required=False, default="CRITICAL"), file_level=get_env("LOG_LEVEL_FILE", required=False, default="CRITICAL"), diff --git a/maro/rl/workflows/train_worker.py b/maro/rl/workflows/train_worker.py index 42f868312..8fad5d5b6 100644 --- a/maro/rl/workflows/train_worker.py +++ b/maro/rl/workflows/train_worker.py @@ -21,7 +21,7 @@ worker_idx = int_or_none(get_env("ID")) logger = LoggerV2( f"TRAIN-WORKER.{worker_idx}", - dump_path=get_env("LOG_PATH"), + dump_path=os.path.join(get_env("LOG_PATH"), f"TRAIN-WORKER.{worker_idx}.txt"), dump_mode="a", stdout_level=get_env("LOG_LEVEL_STDOUT", required=False, default="CRITICAL"), file_level=get_env("LOG_LEVEL_FILE", required=False, default="CRITICAL"), diff --git a/tests/rl/algorithms/__init__.py b/tests/rl/algorithms/__init__.py deleted file mode 100644 index 9a0454564..000000000 --- a/tests/rl/algorithms/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. diff --git a/tests/rl/algorithms/ac.py b/tests/rl/algorithms/ac.py deleted file mode 100644 index eea32fbcb..000000000 --- a/tests/rl/algorithms/ac.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import Tuple - -import numpy as np -import torch -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousACBasedNet, VNet -from maro.rl.model.fc_block import FullyConnected -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import ActorCriticParams, ActorCriticTrainer - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - - -class MyContinuousACBasedNet(ContinuousACBasedNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyContinuousACBasedNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - log_std = -0.5 * np.ones(action_dim, dtype=np.float32) - self._log_std = torch.nn.Parameter(torch.as_tensor(log_std)) - self._mu_net = FullyConnected( - input_dim=state_dim, - hidden_dims=actor_net_conf["hidden_dims"], - output_dim=action_dim, - activation=actor_net_conf["activation"], - ) - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - distribution = self._distribution(states) - actions = distribution.sample() - logps = distribution.log_prob(actions).sum(axis=-1) - return actions, logps - - def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - distribution = self._distribution(states) - logps = distribution.log_prob(actions).sum(axis=-1) - return logps - - def _distribution(self, states: torch.Tensor) -> Normal: - mu = self._mu_net(states.float()) - std = torch.exp(self._log_std) - return Normal(mu, std) - - -class MyVCriticNet(VNet): - def __init__(self, state_dim: int) -> None: - super(MyVCriticNet, self).__init__(state_dim=state_dim) - self._critic = FullyConnected( - input_dim=state_dim, - output_dim=1, - hidden_dims=critic_net_conf["hidden_dims"], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_v_values(self, states: torch.Tensor) -> torch.Tensor: - return self._critic(states.float()).squeeze(-1) - - -def get_ac_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousACBasedNet(gym_state_dim, gym_action_dim), - ) - - -def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: - return ActorCriticTrainer( - name=name, - reward_discount=0.99, - params=ActorCriticParams( - get_v_critic_net_func=lambda: MyVCriticNet(state_dim), - grad_iters=80, - lam=0.97, - ), - ) diff --git a/tests/rl/algorithms/ppo.py b/tests/rl/algorithms/ppo.py deleted file mode 100644 index 61b3c8576..000000000 --- a/tests/rl/algorithms/ppo.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from maro.rl.training.algorithms import PPOParams, PPOTrainer - -from .ac import MyVCriticNet, get_ac_policy - -get_ppo_policy = get_ac_policy - - -def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: - return PPOTrainer( - name=name, - reward_discount=0.99, - params=PPOParams( - get_v_critic_net_func=lambda: MyVCriticNet(state_dim), - grad_iters=80, - lam=0.97, - clip_ratio=0.2, - ), - ) diff --git a/tests/rl/algorithms/sac.py b/tests/rl/algorithms/sac.py deleted file mode 100644 index 828ec2470..000000000 --- a/tests/rl/algorithms/sac.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from typing import Tuple - -import numpy as np -import torch -import torch.nn.functional as F -from torch.distributions import Normal -from torch.optim import Adam - -from maro.rl.model import ContinuousSACNet, QNet -from maro.rl.model.fc_block import FullyConnected -from maro.rl.policy import ContinuousRLPolicy -from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer - -actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, -} -actor_learning_rate = 3e-4 -critic_learning_rate = 1e-3 - -LOG_STD_MAX = 2 -LOG_STD_MIN = -20 - - -class MyContinuousSACNet(ContinuousSACNet): - def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: - super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - - self._net = FullyConnected( - input_dim=state_dim, - output_dim=actor_net_conf["hidden_dims"][-1], - hidden_dims=actor_net_conf["hidden_dims"][:-1], - activation=actor_net_conf["activation"], - output_activation=actor_net_conf["activation"], - ) - self._mu = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) - self._log_std = torch.nn.Linear(actor_net_conf["hidden_dims"][-1], action_dim) - self._action_limit = action_limit - self._optim = Adam(self.parameters(), lr=actor_learning_rate) - - def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: - net_out = self._net(states.float()) - mu = self._mu(net_out) - log_std = torch.clamp(self._log_std(net_out), LOG_STD_MIN, LOG_STD_MAX) - std = torch.exp(log_std) - - pi_distribution = Normal(mu, std) - pi_action = pi_distribution.rsample() if exploring else mu - - logp_pi = pi_distribution.log_prob(pi_action).sum(axis=-1) - logp_pi -= (2 * (np.log(2) - pi_action - F.softplus(-2 * pi_action))).sum(axis=1) - - pi_action = torch.tanh(pi_action) * self._action_limit - - return pi_action, logp_pi - - -class MyQCriticNet(QNet): - def __init__(self, state_dim: int, action_dim: int) -> None: - super(MyQCriticNet, self).__init__(state_dim=state_dim, action_dim=action_dim) - self._critic = FullyConnected( - input_dim=state_dim + action_dim, - output_dim=1, - hidden_dims=critic_net_conf["hidden_dims"], - activation=critic_net_conf["activation"], - ) - self._optim = Adam(self._critic.parameters(), lr=critic_learning_rate) - - def _get_q_values(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: - return self._critic(torch.cat([states, actions], dim=1).float()).squeeze(-1) - - -def get_sac_policy( - name: str, - action_lower_bound: list, - action_upper_bound: list, - gym_state_dim: int, - gym_action_dim: int, - action_limit: float, -) -> ContinuousRLPolicy: - return ContinuousRLPolicy( - name=name, - action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim, action_limit), - ) - - -def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCriticTrainer: - return SoftActorCriticTrainer( - name=name, - reward_discount=0.99, - params=SoftActorCriticParams( - get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=10, - n_start_train=10000, - ), - ) diff --git a/tests/rl/config.yml b/tests/rl/config.yml deleted file mode 100644 index 43644e91d..000000000 --- a/tests/rl/config.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -# Example RL config file for GYM scenario. -# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. - -# Run this workflow by executing one of the following commands: -# - python tests/rl/run.py tests/rl/config.yml - -job: gym_rl_workflow -scenario_path: "tests/rl/gym_wrapper" -log_path: "tests/rl/log/gym.txt" -main: - num_episodes: 1000 - num_steps: null - eval_schedule: 5 - min_n_sample: 5000 - logging: - stdout: INFO - file: DEBUG -rollout: - logging: - stdout: INFO - file: DEBUG -training: - mode: simple - load_path: null - load_episode: null - checkpointing: - path: "tests/rl/checkpoint/gym" - interval: 5 - logging: - stdout: INFO - file: DEBUG diff --git a/tests/rl/gym_wrapper/__init__.py b/tests/rl/gym_wrapper/__init__.py index 90be439f0..9a0454564 100644 --- a/tests/rl/gym_wrapper/__init__.py +++ b/tests/rl/gym_wrapper/__init__.py @@ -1,8 +1,2 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - -from .rl_component_bundle import rl_component_bundle - -__all__ = [ - "rl_component_bundle", -] diff --git a/tests/rl/gym_wrapper/common.py b/tests/rl/gym_wrapper/common.py index f7cf57f0e..0ea43bbb1 100644 --- a/tests/rl/gym_wrapper/common.py +++ b/tests/rl/gym_wrapper/common.py @@ -8,7 +8,7 @@ from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine env_conf = { - "topology": "Walker2d-v4", + "topology": "Walker2d-v4", # HalfCheetah-v4, Hopper-v4, Walker2d-v4, Swimmer-v4, Ant-v4 "start_tick": 0, "durations": 5000, "options": { diff --git a/tests/rl/gym_wrapper/config.py b/tests/rl/gym_wrapper/config.py deleted file mode 100644 index 0d37afcf4..000000000 --- a/tests/rl/gym_wrapper/config.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -algorithm = "ppo" - -env_conf = { - "topology": "Walker2d-v4", - "start_tick": 0, - "durations": 5000, - "options": { - "random_seed": None, - }, -} diff --git a/tests/rl/performance.md b/tests/rl/performance.md index 43849b553..f5516fbca 100644 --- a/tests/rl/performance.md +++ b/tests/rl/performance.md @@ -5,26 +5,72 @@ Some are compared to the benchmarks in [OpenAI Spinning Up](https://spinningup.o Limited by the environment version difference, there may be some gaps between the performance here and that in Spinning Up benchmarks. +## Experimental Setting + The hyper-parameters are set to align with those used in [Spinning Up](https://spinningup.openai.com/en/latest/spinningup/bench.html#experiment-details): -- Network of on-policy algorithms: size (64, 32) with tanh units for both policy and value function; -- Network of off-policy algorithms: size (256, 256) with relu units; -- Batch size for on-policy algorithms: 4000 steps of interaction per batch update; -- Batch size for off-policy algorithms: size 100 for each gradient descent step; +**Batch Size**: + +- For on-policy algorithms: 4000 steps of interaction per batch update; +- For off-policy algorithms: size 100 for each gradient descent step; + +**Network**: + +- For on-policy algorithms: size (64, 32) with tanh units for both policy and value function; +- For off-policy algorithms: size (256, 256) with relu units; + +**Performance metric**: + +- For on-policy algorithms: measured as the average trajectory return across the batch collected at each epoch; +- For off-policy algorithms: measured once every 10,000 steps by running the deterministic policy (or, in the case of SAC, the mean policy) without action noise for ten trajectories, and reporting the average return over those test trajectories; + +**Total timesteps**: set to 3M for all task suites and algorithms. + +## Benchmark in Spinning Up - PyTorch Version + +Five environments from the MuJoCo Gym task suite are reported in Spinning Up, they are: HalfCheetah, Hopper, Walker2d, Swimmer, and Ant. + +**HalfCheetah-v3**: -## Walker2d +The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/half_cheetah/). -### Benchmark in Spinning Up - PyTorch Version +![HalfCheetah: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_halfcheetah_performance.svg) -- Environment version: Walker2d-v3 -- 3M timesteps +**Hopper-v3**: + +The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/hopper/). + +![Hooper: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_hopper_performance.svg) + +**Walker2d-v3**: + +The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/walker2d/). ![Walker2d: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_walker2d_performance.svg) -### Performance with MARO RL Toolkit +**Swimmer-v3**: + +The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/swimmer/). + +![Swimmer-v3: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_swimmer_performance.svg) + +**Ant-v3**: + +The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/ant/). + +![Ant-v3: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_ant_performance.svg) + +## Performance with MARO RL Toolkit -- Environment version: Walker2d-v4 -- Training Mode: simple -- Rollout Mode: single -- Environment duration: 5000 ticks -- Num of episodes: 600 +| **Env** | **Algorithm** | **1M** | **1M/baseline** | **2M** | **2M/baseline** | **3M** | **3M/baseline** | +|:---------------:|:-------------:|:------:|:---------------:|:------:|:---------------:|:------:|:---------------:| +| **HalfCheetah** | **PPO** | | | | | | | +| | **SAC** | | | | | | | +| **Hopper** | **PPO** | | | | | | | +| | **SAC** | | | | | | | +| **Walker2d** | **PPO** | | | | | | | +| | **SAC** | | | | | | | +| **Swimmer** | **PPO** | | | | | | | +| | **SAC** | | | | | | | +| **Ant** | **PPO** | | | | | | | +| | **SAC** | | | | | | | diff --git a/tests/rl/tasks/ac/__init__.py b/tests/rl/tasks/ac/__init__.py index d2e13629f..31d4f8b1c 100644 --- a/tests/rl/tasks/ac/__init__.py +++ b/tests/rl/tasks/ac/__init__.py @@ -117,6 +117,10 @@ def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: ] trainers = [get_ac_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] +device_mapping = None +if torch.cuda.is_available(): + device_mapping = {f"{algorithm}_{i}.policy": "cuda:0" for i in range(num_agents)} + rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( @@ -128,6 +132,7 @@ def get_ac_trainer(name: str, state_dim: int) -> ActorCriticTrainer: agent2policy=agent2policy, policies=policies, trainers=trainers, + device_mapping=device_mapping, ) __all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/ac/config.yml b/tests/rl/tasks/ac/config.yml index 6b7e1ebfe..16ea56645 100644 --- a/tests/rl/tasks/ac/config.yml +++ b/tests/rl/tasks/ac/config.yml @@ -6,7 +6,7 @@ job: gym_rl_workflow scenario_path: "tests/rl/tasks/ac" -log_path: "tests/rl_log/ac" +log_path: "tests/rl/log/ac" main: num_episodes: 1000 num_steps: null @@ -25,7 +25,7 @@ training: load_path: null load_episode: null checkpointing: - path: "tests/rl_log/ac/checkpoints" + path: null interval: 5 logging: stdout: INFO diff --git a/tests/rl/tasks/ddpg/__init__.py b/tests/rl/tasks/ddpg/__init__.py index 851b1d807..a36238f83 100644 --- a/tests/rl/tasks/ddpg/__init__.py +++ b/tests/rl/tasks/ddpg/__init__.py @@ -119,6 +119,11 @@ def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: ] trainers = [get_ddpg_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] +device_mapping = None +if torch.cuda.is_available(): + device_mapping = {f"{algorithm}_{i}.policy": "cuda:0" for i in range(num_agents)} + + rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( learn_env=learn_env, @@ -129,6 +134,7 @@ def get_ddpg_trainer(name: str, state_dim: int, action_dim: int) -> DDPGTrainer: agent2policy=agent2policy, policies=policies, trainers=trainers, + device_mapping=device_mapping, ) __all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/ddpg/config.yml b/tests/rl/tasks/ddpg/config.yml index 961a3c1f5..a7b645de5 100644 --- a/tests/rl/tasks/ddpg/config.yml +++ b/tests/rl/tasks/ddpg/config.yml @@ -9,7 +9,7 @@ job: gym_rl_workflow scenario_path: "tests/rl/tasks/ddpg" -log_path: "tests/rl_log/ddpg" +log_path: "tests/rl/log/ddpg" main: num_episodes: 25000 num_steps: 200 @@ -28,7 +28,7 @@ training: load_path: null load_episode: null checkpointing: - path: "tests/rl_log/ddpg/checkpoints" + path: null interval: 25 logging: stdout: INFO diff --git a/tests/rl/tasks/ppo/__init__.py b/tests/rl/tasks/ppo/__init__.py index 9d1b406d6..534623d1a 100644 --- a/tests/rl/tasks/ppo/__init__.py +++ b/tests/rl/tasks/ppo/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import torch + from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training.algorithms.ppo import PPOParams, PPOTrainer @@ -40,6 +42,10 @@ def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: ] trainers = [get_ppo_trainer(f"{algorithm}_{i}", gym_state_dim) for i in range(num_agents)] +device_mapping = None +if torch.cuda.is_available(): + device_mapping = {f"{algorithm}_{i}.policy": "cuda:0" for i in range(num_agents)} + rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( learn_env=learn_env, @@ -50,6 +56,7 @@ def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: agent2policy=agent2policy, policies=policies, trainers=trainers, + device_mapping=device_mapping, ) __all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/ppo/config.yml b/tests/rl/tasks/ppo/config.yml index 35cf4d07f..130fdf10d 100644 --- a/tests/rl/tasks/ppo/config.yml +++ b/tests/rl/tasks/ppo/config.yml @@ -3,7 +3,7 @@ job: gym_rl_workflow scenario_path: "tests/rl/tasks/ppo" -log_path: "tests/rl_log/ppo" +log_path: "tests/rl/log/ppo" main: num_episodes: 1000 num_steps: null @@ -22,7 +22,7 @@ training: load_path: null load_episode: null checkpointing: - path: "tests/rl_log/ppo/checkpoints" + path: null interval: 5 logging: stdout: INFO diff --git a/tests/rl/tasks/sac/__init__.py b/tests/rl/tasks/sac/__init__.py index 9d74d80a5..53e99827b 100644 --- a/tests/rl/tasks/sac/__init__.py +++ b/tests/rl/tasks/sac/__init__.py @@ -6,7 +6,7 @@ import numpy as np import torch import torch.nn.functional as F -from torch.distributions import Independent, Normal +from torch.distributions import Normal from torch.optim import Adam from maro.rl.model import ContinuousSACNet, QNet @@ -134,6 +134,11 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit ] trainers = [get_sac_trainer(f"{algorithm}_{i}", gym_state_dim, gym_action_dim) for i in range(num_agents)] +device_mapping = None +if torch.cuda.is_available(): + device_mapping = {f"{algorithm}_{i}.policy": "cuda:0" for i in range(num_agents)} + + rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( learn_env=learn_env, @@ -144,6 +149,7 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit agent2policy=agent2policy, policies=policies, trainers=trainers, + device_mapping=device_mapping, ) __all__ = ["rl_component_bundle"] diff --git a/tests/rl/tasks/sac/config.yml b/tests/rl/tasks/sac/config.yml index 20b956342..0b3f8e7f3 100644 --- a/tests/rl/tasks/sac/config.yml +++ b/tests/rl/tasks/sac/config.yml @@ -6,7 +6,7 @@ job: gym_rl_workflow scenario_path: "tests/rl/tasks/sac" -log_path: "tests/rl_log/sac" +log_path: "tests/rl/log/sac" main: num_episodes: 25000 num_steps: 200 @@ -25,7 +25,7 @@ training: load_path: null load_episode: null checkpointing: - path: "tests/rl_log/sac/checkpoints" + path: null interval: 25 logging: stdout: INFO From b05c84995f29eb00b2f4b7d32f6ce4f19d0afe2d Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Thu, 9 Feb 2023 15:20:17 +0800 Subject: [PATCH 42/45] Resolve PR comments --- maro/rl/rollout/env_sampler.py | 33 +++++++++++++++------------------ maro/rl/workflows/callback.py | 16 ++++++++++++++-- maro/rl/workflows/main.py | 14 +++++++------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 4c512547d..0e98ed9be 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -264,8 +264,8 @@ def __init__( self._state: Optional[np.ndarray] = None self._agent_state_dict: Dict[Any, np.ndarray] = {} - self._trans_cache: List[CacheElement] = [] - self._agent_last_index: Dict[Any, int] = {} # Index of last occurrence of agent in self._trans_cache + self._transition_cache: List[CacheElement] = [] + self._agent_last_index: Dict[Any, int] = {} # Index of last occurrence of agent in self._transition_cache self._reward_eval_delay = reward_eval_delay self._info: dict = {} @@ -386,31 +386,26 @@ def _calc_reward(self, cache_element: CacheElement) -> None: def _append_cache_element(self, cache_element: Optional[CacheElement]) -> None: """`cache_element` == None means we are processing the last element in trans_cache""" if cache_element is None: - if len(self._trans_cache) > 0: - self._trans_cache[-1].next_state = self._trans_cache[-1].state - for agent_name, i in self._agent_last_index.items(): - e = self._trans_cache[i] + e = self._transition_cache[i] e.terminal_dict[agent_name] = self._end_of_episode e.next_agent_state_dict[agent_name] = e.agent_state_dict[agent_name] else: - self._trans_cache.append(cache_element) - - if len(self._trans_cache) > 0: - self._trans_cache[-1].next_state = cache_element.state + self._transition_cache.append(cache_element) - cur_index = len(self._trans_cache) - 1 + cur_index = len(self._transition_cache) - 1 for agent_name in cache_element.agent_names: if agent_name in self._agent_last_index: i = self._agent_last_index[agent_name] - self._trans_cache[i].terminal_dict[agent_name] = False - self._trans_cache[i].next_agent_state_dict[agent_name] = cache_element.agent_state_dict[agent_name] + e = self._transition_cache[i] + e.terminal_dict[agent_name] = False + e.next_agent_state_dict[agent_name] = cache_element.agent_state_dict[agent_name] self._agent_last_index[agent_name] = cur_index def _reset(self) -> None: self.env.reset() self._info.clear() - self._trans_cache.clear() + self._transition_cache.clear() self._agent_last_index.clear() self._step(None) @@ -471,6 +466,7 @@ def sample( # Update env and get new states (global & agent) self._step(list(env_action_dict.values())) + cache_element.next_state = self._state if self._reward_eval_delay is None: self._calc_reward(cache_element) @@ -482,8 +478,8 @@ def sample( tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) experiences: List[ExpElement] = [] - while len(self._trans_cache) > 0 and self._trans_cache[0].tick <= tick_bound: - cache_element = self._trans_cache.pop(0) + while len(self._transition_cache) > 0 and self._transition_cache[0].tick <= tick_bound: + cache_element = self._transition_cache.pop(0) # !: Here the reward calculation method requires the given tick is enough and must be used then. if self._reward_eval_delay is not None: self._calc_reward(cache_element) @@ -554,6 +550,7 @@ def eval(self, policy_state: Dict[str, Dict[str, Any]] = None, num_episodes: int # Update env and get new states (global & agent) self._step(list(env_action_dict.values())) + cache_element.next_state = self._state if self._reward_eval_delay is None: # TODO: necessary to calculate reward in eval()? self._calc_reward(cache_element) @@ -563,8 +560,8 @@ def eval(self, policy_state: Dict[str, Dict[str, Any]] = None, num_episodes: int self._append_cache_element(None) tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) - while len(self._trans_cache) > 0 and self._trans_cache[0].tick <= tick_bound: - cache_element = self._trans_cache.pop(0) + while len(self._transition_cache) > 0 and self._transition_cache[0].tick <= tick_bound: + cache_element = self._transition_cache.pop(0) if self._reward_eval_delay is not None: self._calc_reward(cache_element) self._post_eval_step(cache_element) diff --git a/maro/rl/workflows/callback.py b/maro/rl/workflows/callback.py index 6ba8b76a2..6bac6bb4b 100644 --- a/maro/rl/workflows/callback.py +++ b/maro/rl/workflows/callback.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. import copy import os +from enum import Enum from typing import Dict, List, Union import pandas as pd @@ -173,6 +174,17 @@ def on_validation_end( } +class SupportedCallbackFunc(Enum): + ON_EPISODE_START = "on_episode_start" + ON_EPISODE_END = "on_episode_end" + ON_TRAINING_START = "on_training_start" + ON_TRAINING_END = "on_training_end" + ON_VALIDATION_START = "on_validation_start" + ON_VALIDATION_END = "on_validation_end" + ON_TEST_START = "on_test_start" + ON_TEST_END = "on_test_end" + + class CallbackManager(object): def __init__(self, callbacks: List[Callback]) -> None: super(CallbackManager, self).__init__() @@ -181,7 +193,7 @@ def __init__(self, callbacks: List[Callback]) -> None: def call( self, - func_name: str, + func_name: SupportedCallbackFunc, env_sampler: EnvSampler, training_manager: TrainingManager, logger: LoggerV2, @@ -190,5 +202,5 @@ def call( assert func_name in SUPPORTED_CALLBACK_FUNC for callback in self._callbacks: - func = getattr(callback, func_name) + func = getattr(callback, func_name.value) func(env_sampler, training_manager, logger, ep) diff --git a/maro/rl/workflows/main.py b/maro/rl/workflows/main.py index d63c46f0a..4fb0f0540 100644 --- a/maro/rl/workflows/main.py +++ b/maro/rl/workflows/main.py @@ -14,7 +14,7 @@ from maro.rl.utils import get_torch_device from maro.rl.utils.common import float_or_none, get_env, int_or_none, list_or_none from maro.rl.utils.training import get_latest_ep -from maro.rl.workflows.callback import CallbackManager, Checkpoint, MetricsRecorder +from maro.rl.workflows.callback import CallbackManager, Checkpoint, MetricsRecorder, SupportedCallbackFunc from maro.utils import LoggerV2 @@ -160,7 +160,7 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow # main loop for ep in range(start_ep, env_attr.num_episodes + 1): - cbm.call("on_episode_start", env_sampler, training_manager, env_attr.logger, ep) + cbm.call(SupportedCallbackFunc.ON_EPISODE_START, env_sampler, training_manager, env_attr.logger, ep) collect_time = training_time = 0.0 total_experiences: List[List[ExpElement]] = [] @@ -185,10 +185,10 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow tu0 = time.time() env_attr.logger.info(f"Roll-out completed for episode {ep}. Training started...") - cbm.call("on_training_start", env_sampler, training_manager, env_attr.logger, ep) + cbm.call(SupportedCallbackFunc.ON_TRAINING_START, env_sampler, training_manager, env_attr.logger, ep) training_manager.record_experiences(total_experiences) training_manager.train_step() - cbm.call("on_training_end", env_sampler, training_manager, env_attr.logger, ep) + cbm.call(SupportedCallbackFunc.ON_TRAINING_END, env_sampler, training_manager, env_attr.logger, ep) training_time += time.time() - tu0 # performance details @@ -196,7 +196,7 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow f"ep {ep} - roll-out time: {collect_time:.2f} seconds, training time: {training_time:.2f} seconds", ) if env_attr.eval_schedule and ep == env_attr.eval_schedule[eval_point_index]: - cbm.call("on_validation_start", env_sampler, training_manager, env_attr.logger, ep) + cbm.call(SupportedCallbackFunc.ON_VALIDATION_START, env_sampler, training_manager, env_attr.logger, ep) eval_point_index += 1 result = env_sampler.eval( @@ -205,9 +205,9 @@ def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: Workflow ) env_sampler.post_evaluate(result["info"], ep) - cbm.call("on_validation_end", env_sampler, training_manager, env_attr.logger, ep) + cbm.call(SupportedCallbackFunc.ON_VALIDATION_END, env_sampler, training_manager, env_attr.logger, ep) - cbm.call("on_episode_end", env_sampler, training_manager, env_attr.logger, ep) + cbm.call(SupportedCallbackFunc.ON_EPISODE_END, env_sampler, training_manager, env_attr.logger, ep) if isinstance(env_sampler, BatchEnvSampler): env_sampler.exit() From ab5e6753496cd55b57b42237795a4b390e7d8751 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Thu, 9 Feb 2023 19:55:52 +0800 Subject: [PATCH 43/45] Compare PPO with spinning up (#579) * [wip] compare PPO * PPO matching * Revert unnecessary changes * Minor * Minor --- .../training/algorithms/base/ac_ppo_base.py | 45 +++++++++++--- maro/rl/training/replay_memory.py | 62 ++++--------------- tests/rl/gym_wrapper/common.py | 2 +- tests/rl/gym_wrapper/env_sampler.py | 3 +- tests/rl/tasks/ac/__init__.py | 4 +- tests/rl/tasks/ppo/__init__.py | 2 + tests/rl/tasks/ppo/config.yml | 4 +- 7 files changed, 56 insertions(+), 66 deletions(-) diff --git a/maro/rl/training/algorithms/base/ac_ppo_base.py b/maro/rl/training/algorithms/base/ac_ppo_base.py index 3227437be..544ea93b1 100644 --- a/maro/rl/training/algorithms/base/ac_ppo_base.py +++ b/maro/rl/training/algorithms/base/ac_ppo_base.py @@ -202,6 +202,8 @@ def preprocess_batch(self, batch: TransitionBatch) -> TransitionBatch: # Preprocess advantages states = ndarray_to_tensor(batch.states, device=self._device) # s actions = ndarray_to_tensor(batch.actions, device=self._device) # a + terminals = ndarray_to_tensor(batch.terminals, device=self._device) + next_states = ndarray_to_tensor(batch.next_states, device=self._device) if self._is_discrete_action: actions = actions.long() @@ -209,11 +211,34 @@ def preprocess_batch(self, batch: TransitionBatch) -> TransitionBatch: self._v_critic_net.eval() self._policy.eval() values = self._v_critic_net.v_values(states).detach().cpu().numpy() - values = np.concatenate([values, np.zeros(1)]) - rewards = np.concatenate([batch.rewards, np.zeros(1)]) - deltas = rewards[:-1] + self._reward_discount * values[1:] - values[:-1] # r + gamma * v(s') - v(s) - batch.returns = discount_cumsum(rewards, self._reward_discount)[:-1] - batch.advantages = discount_cumsum(deltas, self._reward_discount * self._lam) + + batch.returns = np.zeros(batch.size, dtype=np.float32) + batch.advantages = np.zeros(batch.size, dtype=np.float32) + i = 0 + while i < batch.size: + j = i + while j < batch.size - 1 and not terminals[j]: + j += 1 + last_val = ( + 0.0 + if terminals[j] + else self._v_critic_net.v_values( + next_states[j].unsqueeze(dim=0), + ) + .detach() + .cpu() + .numpy() + .item() + ) + + cur_values = np.append(values[i : j + 1], last_val) + cur_rewards = np.append(batch.rewards[i : j + 1], last_val) + # delta = r + gamma * v(s') - v(s) + cur_deltas = cur_rewards[:-1] + self._reward_discount * cur_values[1:] - cur_values[:-1] + batch.returns[i : j + 1] = discount_cumsum(cur_rewards, self._reward_discount)[:-1] + batch.advantages[i : j + 1] = discount_cumsum(cur_deltas, self._reward_discount * self._lam) + + i = j + 1 if self._clip_ratio is not None: batch.old_logps = self._policy.get_states_actions_logps(states, actions).detach().cpu().numpy() @@ -291,21 +316,23 @@ def train_step(self) -> None: assert isinstance(self._ops, ACBasedOps) batch = self._get_batch() - for _ in range(self._params.grad_iters): - self._ops.update_critic(batch) for _ in range(self._params.grad_iters): early_stop = self._ops.update_actor(batch) if early_stop: break + for _ in range(self._params.grad_iters): + self._ops.update_critic(batch) + async def train_step_as_task(self) -> None: assert isinstance(self._ops, RemoteOps) batch = self._get_batch() - for _ in range(self._params.grad_iters): - self._ops.update_critic_with_grad(await self._ops.get_critic_grad(batch)) for _ in range(self._params.grad_iters): if self._ops.update_actor_with_grad(await self._ops.get_actor_grad(batch)): # early stop break + + for _ in range(self._params.grad_iters): + self._ops.update_critic_with_grad(await self._ops.get_critic_grad(batch)) diff --git a/maro/rl/training/replay_memory.py b/maro/rl/training/replay_memory.py index 3e4f573e0..e93847572 100644 --- a/maro/rl/training/replay_memory.py +++ b/maro/rl/training/replay_memory.py @@ -35,29 +35,18 @@ def get_put_indexes(self, batch_size: int) -> np.ndarray: raise NotImplementedError @abstractmethod - def get_sample_indexes(self, batch_size: int = None, forbid_last: bool = False) -> np.ndarray: + def get_sample_indexes(self, batch_size: int = None) -> np.ndarray: """Generate a list of indexes that can be used to retrieve items from the replay memory. Args: batch_size (int, default=None): The required batch size. If it is None, all indexes where an experience item is present are returned. - forbid_last (bool, default=False): Whether the latest element is allowed to be sampled. - If this is true, the last index will always be excluded from the result. Returns: indexes (np.ndarray): The list of indexes. """ raise NotImplementedError - @abstractmethod - def get_last_index(self) -> int: - """Get the index of the latest element in the memory. - - Returns: - index (int): The index of the latest element in the memory. - """ - raise NotImplementedError - class RandomIndexScheduler(AbsIndexScheduler): """Index scheduler that returns random indexes when sampling. @@ -93,14 +82,11 @@ def get_put_indexes(self, batch_size: int) -> np.ndarray: self._size = min(self._size + batch_size, self._capacity) return indexes - def get_sample_indexes(self, batch_size: int = None, forbid_last: bool = False) -> np.ndarray: + def get_sample_indexes(self, batch_size: int = None) -> np.ndarray: assert batch_size is not None and batch_size > 0, f"Invalid batch size: {batch_size}" assert self._size > 0, "Cannot sample from an empty memory." return np.random.choice(self._size, size=batch_size, replace=True) - def get_last_index(self) -> int: - raise NotImplementedError - class FIFOIndexScheduler(AbsIndexScheduler): """First-in-first-out index scheduler. @@ -135,19 +121,15 @@ def get_put_indexes(self, batch_size: int) -> np.ndarray: self._head = (self._head + overwrite) % self._capacity return self.get_put_indexes(batch_size) - def get_sample_indexes(self, batch_size: int = None, forbid_last: bool = False) -> np.ndarray: - tmp = self._tail if not forbid_last else (self._tail - 1) % self._capacity + def get_sample_indexes(self, batch_size: int = None) -> np.ndarray: indexes = ( - np.arange(self._head, tmp) - if tmp > self._head - else np.concatenate([np.arange(self._head, self._capacity), np.arange(tmp)]) + np.arange(self._head, self._tail) + if self._tail > self._head + else np.concatenate([np.arange(self._head, self._capacity), np.arange(self._tail)]) ) - self._head = tmp + self._head = self._tail return indexes - def get_last_index(self) -> int: - return (self._tail - 1) % self._capacity - class AbsReplayMemory(object, metaclass=ABCMeta): """Abstract replay memory class with basic interfaces. @@ -176,9 +158,9 @@ def _get_put_indexes(self, batch_size: int) -> np.ndarray: """Please refer to the doc string in AbsIndexScheduler.""" return self._idx_scheduler.get_put_indexes(batch_size) - def _get_sample_indexes(self, batch_size: int = None, forbid_last: bool = False) -> np.ndarray: + def _get_sample_indexes(self, batch_size: int = None) -> np.ndarray: """Please refer to the doc string in AbsIndexScheduler.""" - return self._idx_scheduler.get_sample_indexes(batch_size, forbid_last) + return self._idx_scheduler.get_sample_indexes(batch_size) class ReplayMemory(AbsReplayMemory, metaclass=ABCMeta): @@ -273,7 +255,7 @@ def sample(self, batch_size: int = None) -> TransitionBatch: Returns: batch (TransitionBatch): The sampled batch. """ - indexes = self._get_sample_indexes(batch_size, self._get_forbid_last()) + indexes = self._get_sample_indexes(batch_size) return self.sample_by_indexes(indexes) def sample_by_indexes(self, indexes: np.ndarray) -> TransitionBatch: @@ -298,10 +280,6 @@ def sample_by_indexes(self, indexes: np.ndarray) -> TransitionBatch: old_logps=self._old_logps[indexes], ) - @abstractmethod - def _get_forbid_last(self) -> bool: - raise NotImplementedError - class RandomReplayMemory(ReplayMemory): def __init__( @@ -318,15 +296,11 @@ def __init__( RandomIndexScheduler(capacity, random_overwrite), ) self._random_overwrite = random_overwrite - self._scheduler = RandomIndexScheduler(capacity, random_overwrite) @property def random_overwrite(self) -> bool: return self._random_overwrite - def _get_forbid_last(self) -> bool: - return False - class FIFOReplayMemory(ReplayMemory): def __init__( @@ -342,9 +316,6 @@ def __init__( FIFOIndexScheduler(capacity), ) - def _get_forbid_last(self) -> bool: - return not self._terminals[self._idx_scheduler.get_last_index()] - class MultiReplayMemory(AbsReplayMemory, metaclass=ABCMeta): """In-memory experience storage facility for a multi trainer. @@ -446,7 +417,7 @@ def sample(self, batch_size: int = None) -> MultiTransitionBatch: Returns: batch (MultiTransitionBatch): The sampled batch. """ - indexes = self._get_sample_indexes(batch_size, self._get_forbid_last()) + indexes = self._get_sample_indexes(batch_size) return self.sample_by_indexes(indexes) def sample_by_indexes(self, indexes: np.ndarray) -> MultiTransitionBatch: @@ -470,10 +441,6 @@ def sample_by_indexes(self, indexes: np.ndarray) -> MultiTransitionBatch: next_agent_states=[state[indexes] for state in self._next_agent_states], ) - @abstractmethod - def _get_forbid_last(self) -> bool: - raise NotImplementedError - class RandomMultiReplayMemory(MultiReplayMemory): def __init__( @@ -492,15 +459,11 @@ def __init__( agent_states_dims, ) self._random_overwrite = random_overwrite - self._scheduler = RandomIndexScheduler(capacity, random_overwrite) @property def random_overwrite(self) -> bool: return self._random_overwrite - def _get_forbid_last(self) -> bool: - return False - class FIFOMultiReplayMemory(MultiReplayMemory): def __init__( @@ -517,6 +480,3 @@ def __init__( FIFOIndexScheduler(capacity), agent_states_dims, ) - - def _get_forbid_last(self) -> bool: - return not self._terminals[self._idx_scheduler.get_last_index()] diff --git a/tests/rl/gym_wrapper/common.py b/tests/rl/gym_wrapper/common.py index 0ea43bbb1..6792a8ca8 100644 --- a/tests/rl/gym_wrapper/common.py +++ b/tests/rl/gym_wrapper/common.py @@ -10,7 +10,7 @@ env_conf = { "topology": "Walker2d-v4", # HalfCheetah-v4, Hopper-v4, Walker2d-v4, Swimmer-v4, Ant-v4 "start_tick": 0, - "durations": 5000, + "durations": 1000, "options": { "random_seed": None, }, diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index 591a1b29a..73ac48351 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -75,6 +75,7 @@ def post_collect(self, info_list: list, ep: int) -> None: self.metrics.update(cur) # clear validation metrics self.metrics = {k: v for k, v in self.metrics.items() if not k.startswith("val/")} + self._sample_rewards.clear() def post_evaluate(self, info_list: list, ep: int) -> None: cur = { @@ -83,5 +84,5 @@ def post_evaluate(self, info_list: list, ep: int) -> None: "val/avg_reward": np.mean([r for _, r in self._eval_rewards]), "val/avg_n_steps": np.mean([n for n, _ in self._eval_rewards]), } - self._eval_rewards.clear() self.metrics.update(cur) + self._eval_rewards.clear() diff --git a/tests/rl/tasks/ac/__init__.py b/tests/rl/tasks/ac/__init__.py index 31d4f8b1c..24cc961fc 100644 --- a/tests/rl/tasks/ac/__init__.py +++ b/tests/rl/tasks/ac/__init__.py @@ -26,11 +26,11 @@ from tests.rl.gym_wrapper.env_sampler import GymEnvSampler actor_net_conf = { - "hidden_dims": [64, 64], + "hidden_dims": [64, 32], "activation": torch.nn.Tanh, } critic_net_conf = { - "hidden_dims": [64, 64], + "hidden_dims": [64, 32], "activation": torch.nn.Tanh, } actor_learning_rate = 3e-4 diff --git a/tests/rl/tasks/ppo/__init__.py b/tests/rl/tasks/ppo/__init__.py index 534623d1a..01207562b 100644 --- a/tests/rl/tasks/ppo/__init__.py +++ b/tests/rl/tasks/ppo/__init__.py @@ -25,6 +25,8 @@ def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: return PPOTrainer( name=name, reward_discount=0.99, + replay_memory_capacity=4000, + batch_size=4000, params=PPOParams( get_v_critic_net_func=lambda: MyVCriticNet(state_dim), grad_iters=80, diff --git a/tests/rl/tasks/ppo/config.yml b/tests/rl/tasks/ppo/config.yml index 130fdf10d..312d1274c 100644 --- a/tests/rl/tasks/ppo/config.yml +++ b/tests/rl/tasks/ppo/config.yml @@ -6,10 +6,10 @@ scenario_path: "tests/rl/tasks/ppo" log_path: "tests/rl/log/ppo" main: num_episodes: 1000 - num_steps: null + num_steps: 4000 eval_schedule: 5 num_eval_episodes: 10 - min_n_sample: 5000 + min_n_sample: 1 logging: stdout: INFO file: DEBUG From e180f1054f64a08941436d5fc6cdfb1c3da196f0 Mon Sep 17 00:00:00 2001 From: Jinyu-W <53509467+Jinyu-W@users.noreply.github.com> Date: Mon, 13 Feb 2023 14:14:08 +0800 Subject: [PATCH 44/45] SAC Test parameters update (#580) * fix sac to_device issue; update sac gym test parameters * add rl test performance plot func * update sac eval interval config * update sac checkpoint interval config * fix callback issue * update plot func * update plot func * update plot func * update performance doc; upload performance images * Minor fix in callbacks; refine plot.py format. * Add n_interactions. Use n_interactions to plot curves. * pre-commit --------- Co-authored-by: Huoran Li Co-authored-by: Huoran Li --- maro/rl/rollout/env_sampler.py | 3 + maro/rl/training/algorithms/sac.py | 3 + maro/rl/workflows/callback.py | 15 +---- tests/rl/gym_wrapper/env_sampler.py | 1 + tests/rl/log/Ant_1.png | Bin 0 -> 56994 bytes tests/rl/log/Ant_11.png | Bin 0 -> 47793 bytes tests/rl/log/HalfCheetah_1.png | Bin 0 -> 46006 bytes tests/rl/log/HalfCheetah_11.png | Bin 0 -> 38692 bytes tests/rl/log/Hopper_1.png | Bin 0 -> 63072 bytes tests/rl/log/Hopper_11.png | Bin 0 -> 65666 bytes tests/rl/log/Swimmer_1.png | Bin 0 -> 48110 bytes tests/rl/log/Swimmer_11.png | Bin 0 -> 37373 bytes tests/rl/log/Walker2d_1.png | Bin 0 -> 59770 bytes tests/rl/log/Walker2d_11.png | Bin 0 -> 50952 bytes tests/rl/performance.md | 55 ++++------------- tests/rl/plot.py | 90 ++++++++++++++++++++++++++++ tests/rl/tasks/ppo/config.yml | 5 +- tests/rl/tasks/sac/__init__.py | 26 +++++--- tests/rl/tasks/sac/config.yml | 10 ++-- 19 files changed, 134 insertions(+), 74 deletions(-) create mode 100644 tests/rl/log/Ant_1.png create mode 100644 tests/rl/log/Ant_11.png create mode 100644 tests/rl/log/HalfCheetah_1.png create mode 100644 tests/rl/log/HalfCheetah_11.png create mode 100644 tests/rl/log/Hopper_1.png create mode 100644 tests/rl/log/Hopper_11.png create mode 100644 tests/rl/log/Swimmer_1.png create mode 100644 tests/rl/log/Swimmer_11.png create mode 100644 tests/rl/log/Walker2d_1.png create mode 100644 tests/rl/log/Walker2d_11.png create mode 100644 tests/rl/plot.py diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 0e98ed9be..6c119432c 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -294,6 +294,8 @@ def __init__( [policy_name in self._rl_policy_dict for policy_name in self._trainable_policies], ), "All trainable policies must be RL policies!" + self._total_number_interactions = 0 + @property def env(self) -> Env: assert self._env is not None @@ -466,6 +468,7 @@ def sample( # Update env and get new states (global & agent) self._step(list(env_action_dict.values())) + self._total_number_interactions += 1 cache_element.next_state = self._state if self._reward_eval_delay is None: diff --git a/maro/rl/training/algorithms/sac.py b/maro/rl/training/algorithms/sac.py index d6110f685..d7332da7e 100644 --- a/maro/rl/training/algorithms/sac.py +++ b/maro/rl/training/algorithms/sac.py @@ -152,6 +152,9 @@ def soft_update_target(self) -> None: def to_device(self, device: str = None) -> None: self._device = get_torch_device(device=device) + + self._policy.to_device(self._device) + self._q_net1.to(self._device) self._q_net2.to(self._device) self._target_q_net1.to(self._device) diff --git a/maro/rl/workflows/callback.py b/maro/rl/workflows/callback.py index 6bac6bb4b..f96484a6d 100644 --- a/maro/rl/workflows/callback.py +++ b/maro/rl/workflows/callback.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. + import copy import os from enum import Enum @@ -162,18 +163,6 @@ def on_validation_end( self._dump_metric_history() -SUPPORTED_CALLBACK_FUNC = { - "on_episode_start", - "on_episode_end", - "on_training_start", - "on_training_end", - "on_validation_start", - "on_validation_end", - "on_test_start", - "on_test_end", -} - - class SupportedCallbackFunc(Enum): ON_EPISODE_START = "on_episode_start" ON_EPISODE_END = "on_episode_end" @@ -199,8 +188,6 @@ def call( logger: LoggerV2, ep: int, ) -> None: - assert func_name in SUPPORTED_CALLBACK_FUNC - for callback in self._callbacks: func = getattr(callback, func_name.value) func(env_sampler, training_manager, logger, ep) diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index 73ac48351..b0bc7e865 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -71,6 +71,7 @@ def post_collect(self, info_list: list, ep: int) -> None: "n_segment": len(self._sample_rewards), "avg_reward": np.mean([r for _, r in self._sample_rewards]), "avg_n_steps": np.mean([n for n, _ in self._sample_rewards]), + "n_interactions": self._total_number_interactions, } self.metrics.update(cur) # clear validation metrics diff --git a/tests/rl/log/Ant_1.png b/tests/rl/log/Ant_1.png new file mode 100644 index 0000000000000000000000000000000000000000..f4f451d71602d72e0ce2e55b7ad9633afbd782db GIT binary patch literal 56994 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 GocIsx*OPYu literal 0 HcmV?d00001 diff --git a/tests/rl/log/Ant_11.png b/tests/rl/log/Ant_11.png new file mode 100644 index 0000000000000000000000000000000000000000..930a258f93cd6a111449d18b265e9846ce9f06ae GIT binary patch literal 47793 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

_^RpKJ@1dOJBMby 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 literal 0 HcmV?d00001 diff --git a/tests/rl/log/HalfCheetah_1.png b/tests/rl/log/HalfCheetah_1.png new file mode 100644 index 0000000000000000000000000000000000000000..be1582bfea824a301d3864db4c92b09d3fbfefb9 GIT binary patch literal 46006 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

Fa&cd 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 E01(no;Q#;t literal 0 HcmV?d00001 diff --git a/tests/rl/log/HalfCheetah_11.png b/tests/rl/log/HalfCheetah_11.png new file mode 100644 index 0000000000000000000000000000000000000000..2a94d104b9fd1526c2e40b940ef9d1753b055097 GIT binary patch literal 38692 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

l43<)K_XaNdxcClc2HtCbGlj=I2i9 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 literal 0 HcmV?d00001 diff --git a/tests/rl/log/Hopper_1.png b/tests/rl/log/Hopper_1.png new file mode 100644 index 0000000000000000000000000000000000000000..eeb54696ffb9d8b91479cdf29437de4b67d565c8 GIT binary patch literal 63072 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 literal 0 HcmV?d00001 diff --git a/tests/rl/log/Hopper_11.png b/tests/rl/log/Hopper_11.png new file mode 100644 index 0000000000000000000000000000000000000000..a3d576122c5ab84a94a7b565adc7a367ee67b4e4 GIT binary patch literal 65666 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 T5WBpC!asUD_UdG5SzY=s7w9Av literal 0 HcmV?d00001 diff --git a/tests/rl/log/Swimmer_1.png b/tests/rl/log/Swimmer_1.png new file mode 100644 index 0000000000000000000000000000000000000000..d69962dcdfce54e1f2c3678782e8759288efa8b2 GIT binary patch literal 48110 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 Nt8Ji_r(t#He*vNu{-FQ> literal 0 HcmV?d00001 diff --git a/tests/rl/log/Swimmer_11.png b/tests/rl/log/Swimmer_11.png new file mode 100644 index 0000000000000000000000000000000000000000..c65db7d21ae980508987540bb01c664ea732efae GIT binary patch literal 37373 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 literal 0 HcmV?d00001 diff --git a/tests/rl/log/Walker2d_1.png b/tests/rl/log/Walker2d_1.png new file mode 100644 index 0000000000000000000000000000000000000000..c2aae720e466b4217a77db8192ee9eeb0d3a2f7d GIT binary patch literal 59770 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 ~QYmF>FK7Hd9}KVOlASb8 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�roH8@^V!jE9nhcCv;tPpzDzC|92z268ZnCq zaoYEU8ele5jD4OVmKMh%rvycrFf%lD;fRwSNJ@fny5RS>5+hme>H$N=QgByBRn!JT|s>yecmb zkA@XxCFm!idIE(k-u*j&yCKj&INIxbcf;UI;>C7#bxqFB8bT<82rLl+QWU{RhcQyM zXi&jfDIjqqOV&n+0k-uvC=MfKI}O6M=QNk_&q&)7-Qj;y)engim=$SI{i8=xh%q(u zM&7((gB{1DKoPj2!otFc^mMU`ii%0t6v>Snd2tj{RlRH~gY$s2p?4?8`~aP9e>0LD z-hM0Wl!|BTT5t)pXFJ`Foy}BxMbItqO%b4yYAX?+K)SyXdz(o1)uGag=$=53Mr*>X z-j?G+U~~igm_b7sH`8f*Nw3B z{^Q+(;E#4zg=rxS3;BYPj3vIGV0uW*P6Ue)TqBJ=>2J?qN+L=*^sc|UdeaunWuopi ztJObzD5<6<1S!{gctSF_tFEPmkK8OEU{t=rN`-0tZ(RiEId+BEt(H5!)6-#KP>eJ$ zE?L?}FA0PW@-|@8(ReO+f)sYDX9~7w8LFGah@lh|!>b4QAPyiI39s?Q^}s_ExOeF) zu}4zO>5o%W*~i7O*`;8ZGvCv`eLhD?;62+7d1UzTK4zL{ZW=`bwcSThkI3%49v+sp zQb;cS)c+1;;ChU-i)Jtwpfn2s+z>utSIqsAS1%5R`xG_@r-+D%if@kwpO{!)(Y|P2 zS4hB0Nl54-Y+4RyKZT6bR-}1xVfNW@D`xhYeuc-8Y<3<0UA7A{&Vg6WH+-au{ QKZSqWx9v8_*K>~g569)0F8}}l literal 0 HcmV?d00001 diff --git a/tests/rl/log/Walker2d_11.png b/tests/rl/log/Walker2d_11.png new file mode 100644 index 0000000000000000000000000000000000000000..552460a8ba017265961f9f84f5d5ec1f09f318d9 GIT binary patch literal 50952 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 literal 0 HcmV?d00001 diff --git a/tests/rl/performance.md b/tests/rl/performance.md index f5516fbca..9b7afdd8c 100644 --- a/tests/rl/performance.md +++ b/tests/rl/performance.md @@ -26,51 +26,16 @@ The hyper-parameters are set to align with those used in [Spinning Up](https://s **Total timesteps**: set to 3M for all task suites and algorithms. -## Benchmark in Spinning Up - PyTorch Version +Other parameters are set to the values in *tests/rl/tasks/*. -Five environments from the MuJoCo Gym task suite are reported in Spinning Up, they are: HalfCheetah, Hopper, Walker2d, Swimmer, and Ant. - -**HalfCheetah-v3**: - -The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/half_cheetah/). - -![HalfCheetah: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_halfcheetah_performance.svg) - -**Hopper-v3**: - -The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/hopper/). - -![Hooper: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_hopper_performance.svg) - -**Walker2d-v3**: - -The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/walker2d/). +## Performance Comparison -![Walker2d: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_walker2d_performance.svg) - -**Swimmer-v3**: - -The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/swimmer/). - -![Swimmer-v3: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_swimmer_performance.svg) - -**Ant-v3**: - -The introduction of this task can be found [here](https://gymnasium.farama.org/environments/mujoco/ant/). - -![Ant-v3: PyTorch Version](https://spinningup.openai.com/en/latest/_images/pytorch_ant_performance.svg) - -## Performance with MARO RL Toolkit +Five environments from the MuJoCo Gym task suite are reported in Spinning Up, they are: HalfCheetah, Hopper, Walker2d, Swimmer, and Ant. -| **Env** | **Algorithm** | **1M** | **1M/baseline** | **2M** | **2M/baseline** | **3M** | **3M/baseline** | -|:---------------:|:-------------:|:------:|:---------------:|:------:|:---------------:|:------:|:---------------:| -| **HalfCheetah** | **PPO** | | | | | | | -| | **SAC** | | | | | | | -| **Hopper** | **PPO** | | | | | | | -| | **SAC** | | | | | | | -| **Walker2d** | **PPO** | | | | | | | -| | **SAC** | | | | | | | -| **Swimmer** | **PPO** | | | | | | | -| | **SAC** | | | | | | | -| **Ant** | **PPO** | | | | | | | -| | **SAC** | | | | | | | +| **Env** | **Spinning Up** | **MARO RL w/o Smooth** | **MARO RL w/ Smooth** | +|:---------------:|:---------------:|:----------------------:|:---------------------:| +| [**HalfCheetah**](https://gymnasium.farama.org/environments/mujoco/half_cheetah/) | ![Hab](https://spinningup.openai.com/en/latest/_images/pytorch_halfcheetah_performance.svg) | ![Ha1](./log/HalfCheetah_1.png) | ![Ha11](./log/HalfCheetah_11.png) | +| [**Hopper**](https://gymnasium.farama.org/environments/mujoco/hopper/) | ![Hob](https://spinningup.openai.com/en/latest/_images/pytorch_hopper_performance.svg) | ![Ho1](./log/Hopper_1.png) | ![Ho11](./log/Hopper_11.png) | +| [**Walker2d**](https://gymnasium.farama.org/environments/mujoco/walker2d/) | ![Wab](https://spinningup.openai.com/en/latest/_images/pytorch_walker2d_performance.svg) | ![Wa1](./log/Walker2d_1.png) | ![Wa11](./log/Walker2d_11.png) | +| [**Swimmer**](https://gymnasium.farama.org/environments/mujoco/swimmer/) | ![Swb](https://spinningup.openai.com/en/latest/_images/pytorch_swimmer_performance.svg) | ![Sw1](./log/Swimmer_1.png) | ![Sw11](./log/Swimmer_11.png) | +| [**Ant**](https://gymnasium.farama.org/environments/mujoco/ant/) | ![Anb](https://spinningup.openai.com/en/latest/_images/pytorch_ant_performance.svg) | ![An1](./log/Ant_1.png) | ![An11](./log/Ant_11.png) | diff --git a/tests/rl/plot.py b/tests/rl/plot.py new file mode 100644 index 000000000..664126d20 --- /dev/null +++ b/tests/rl/plot.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import argparse +import os +from typing import List, Tuple + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +LOG_DIR = "tests/rl/log" + +color_map = { + "ppo": "green", + "sac": "goldenrod", +} + + +def smooth(data: np.ndarray, window_size: int) -> np.ndarray: + if window_size > 1: + """ + smooth data with moving window average. + that is, + smoothed_y[t] = average(y[t-k], y[t-k+1], ..., y[t+k-1], y[t+k]) + where the "smooth" param is width of that window (2k+1) + """ + y = np.ones(window_size) + x = np.asarray(data) + z = np.ones_like(x) + smoothed_x = np.convolve(x, y, "same") / np.convolve(z, y, "same") + return smoothed_x + else: + return data + + +def get_off_policy_data(log_dir: str) -> Tuple[np.ndarray, np.ndarray]: + file_path = os.path.join(log_dir, "metrics_full.csv") + df = pd.read_csv(file_path) + x, y = df["n_interactions"], df["val/avg_reward"] + mask = ~np.isnan(y) + x, y = x[mask], y[mask] + return x, y + + +def get_on_policy_data(log_dir: str) -> Tuple[np.ndarray, np.ndarray]: + file_path = os.path.join(log_dir, "metrics_full.csv") + df = pd.read_csv(file_path) + x, y = df["n_interactions"], df["avg_reward"] + return x, y + + +def plot_performance_curves(title: str, dir_names: List[str], smooth_window_size: int) -> None: + for name in dir_names: + log_dir = os.path.join(LOG_DIR, name) + if not os.path.exists(log_dir): + continue + + if "ppo" in name: + algorithm = "ppo" + func = get_on_policy_data + elif "sac" in name: + algorithm = "sac" + func = get_off_policy_data + else: + raise "unknown algorithm name" + + x, y = func(log_dir) + y = smooth(y, smooth_window_size) + plt.plot(x, y, label=algorithm, color=color_map[algorithm]) + + plt.legend() + plt.title(title) + plt.xlabel("Total Env Interactions") + plt.ylabel(f"Average Trajectory Return (moving average with window size = {smooth_window_size})") + plt.savefig(os.path.join(LOG_DIR, f"{title}_{smooth_window_size}.png")) + plt.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--smooth", "-s", type=int, default=11, help="smooth window size") + args = parser.parse_args() + + for env_name in ["HalfCheetah", "Hopper", "Walker2d", "Swimmer", "Ant"]: + plot_performance_curves( + title=env_name, + dir_names=[f"{algorithm}_{env_name.lower()}" for algorithm in ["ppo", "sac"]], + smooth_window_size=args.smooth, + ) diff --git a/tests/rl/tasks/ppo/config.yml b/tests/rl/tasks/ppo/config.yml index 312d1274c..de2412ee9 100644 --- a/tests/rl/tasks/ppo/config.yml +++ b/tests/rl/tasks/ppo/config.yml @@ -1,9 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +# Example RL config file for GYM scenario. +# Please refer to `maro/rl/workflows/config/template.yml` for the complete template and detailed explanations. + job: gym_rl_workflow scenario_path: "tests/rl/tasks/ppo" -log_path: "tests/rl/log/ppo" +log_path: "tests/rl/log/ppo_walker2d" main: num_episodes: 1000 num_steps: 4000 diff --git a/tests/rl/tasks/sac/__init__.py b/tests/rl/tasks/sac/__init__.py index 53e99827b..d20b01ece 100644 --- a/tests/rl/tasks/sac/__init__.py +++ b/tests/rl/tasks/sac/__init__.py @@ -28,14 +28,14 @@ from tests.rl.gym_wrapper.env_sampler import GymEnvSampler actor_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, } critic_net_conf = { - "hidden_dims": [64, 64], - "activation": torch.nn.Tanh, + "hidden_dims": [256, 256], + "activation": torch.nn.ReLU, } -actor_learning_rate = 3e-4 +actor_learning_rate = 1e-3 critic_learning_rate = 1e-3 LOG_STD_MAX = 2 @@ -109,16 +109,24 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit return SoftActorCriticTrainer( name=name, reward_discount=0.99, - replay_memory_capacity=200000, + replay_memory_capacity=1000000, + batch_size=100, params=SoftActorCriticParams( get_q_critic_net_func=lambda: MyQCriticNet(state_dim, action_dim), - num_epochs=10, - n_start_train=10000, - soft_update_coef=0.01, + update_target_every=1, + entropy_coef=0.2, + num_epochs=50, + n_start_train=1000, + soft_update_coef=0.005, ), ) +# TODO: +# 1. random seed +# 2. exploration with random sampled action # start_steps=10000, Number of steps for uniform-random action selection, before running real policy. Helps exploration. +# 3. confirm the effect of (max_ep_len=1000)? + algorithm = "sac" agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} policies = [ diff --git a/tests/rl/tasks/sac/config.yml b/tests/rl/tasks/sac/config.yml index 0b3f8e7f3..8e8fb7d7d 100644 --- a/tests/rl/tasks/sac/config.yml +++ b/tests/rl/tasks/sac/config.yml @@ -6,11 +6,11 @@ job: gym_rl_workflow scenario_path: "tests/rl/tasks/sac" -log_path: "tests/rl/log/sac" +log_path: "tests/rl/log/sac_walker2d" main: - num_episodes: 25000 - num_steps: 200 - eval_schedule: 25 + num_episodes: 80000 + num_steps: 50 + eval_schedule: 200 num_eval_episodes: 10 min_n_sample: 1 logging: @@ -26,7 +26,7 @@ training: load_episode: null checkpointing: path: null - interval: 25 + interval: 200 logging: stdout: INFO file: DEBUG From 93719499238e78f4c4c952e3b8cdb950b333d995 Mon Sep 17 00:00:00 2001 From: Huoran Li Date: Fri, 17 Feb 2023 14:15:38 +0800 Subject: [PATCH 45/45] Episode truncation & early stopping (#581) * Add truncated logic * (To be tested) early stop * Early stop test passed * Test passed * Random action. To be tested. * Warmup OK * Pre-commit * random seed * Revert pre-commit config --- examples/cim/rl/env_sampler.py | 3 + examples/rl/cim.yml | 1 + maro/rl/model/abs_net.py | 8 +- maro/rl/model/algorithm_nets/ac_based.py | 4 + maro/rl/model/algorithm_nets/ddpg.py | 4 + maro/rl/model/policy_net.py | 14 ++ maro/rl/policy/abs_policy.py | 26 ++- maro/rl/policy/continuous_rl_policy.py | 21 +- maro/rl/policy/discrete_rl_policy.py | 57 +++-- maro/rl/rollout/env_sampler.py | 54 ++++- .../training/algorithms/base/ac_ppo_base.py | 3 +- maro/rl/training/algorithms/maddpg.py | 1 + maro/rl/training/replay_memory.py | 8 + maro/rl/training/trainer.py | 4 +- maro/rl/utils/transition_batch.py | 9 +- maro/rl/workflows/callback.py | 201 +++++++++--------- maro/rl/workflows/config/parser.py | 11 +- maro/rl/workflows/config/template.yml | 1 + maro/rl/workflows/main.py | 185 ++++++++-------- tests/rl/gym_wrapper/common.py | 12 +- tests/rl/gym_wrapper/env_sampler.py | 8 +- .../gym_wrapper/simulator/business_engine.py | 4 +- tests/rl/tasks/ppo/__init__.py | 1 + tests/rl/tasks/sac/__init__.py | 21 +- 24 files changed, 411 insertions(+), 250 deletions(-) diff --git a/examples/cim/rl/env_sampler.py b/examples/cim/rl/env_sampler.py index e53daee98..c7cd241e4 100644 --- a/examples/cim/rl/env_sampler.py +++ b/examples/cim/rl/env_sampler.py @@ -109,3 +109,6 @@ def post_evaluate(self, info_list: list, ep: int) -> None: print(f"average env summary (episode {ep}): {avg_metric}") self.metrics.update({"val/" + k: v for k, v in avg_metric.items()}) + + def monitor_metrics(self) -> float: + return -self.metrics["val/container_shortage"] diff --git a/examples/rl/cim.yml b/examples/rl/cim.yml index 581a2981e..c383fa9ac 100644 --- a/examples/rl/cim.yml +++ b/examples/rl/cim.yml @@ -15,6 +15,7 @@ main: num_episodes: 30 # Number of episodes to run. Each episode is one cycle of roll-out and training. num_steps: null eval_schedule: 5 + early_stop_patience: 5 logging: stdout: INFO file: DEBUG diff --git a/maro/rl/model/abs_net.py b/maro/rl/model/abs_net.py index a559d1124..0f1430d9c 100644 --- a/maro/rl/model/abs_net.py +++ b/maro/rl/model/abs_net.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABCMeta -from typing import Any, Dict +from typing import Any, Dict, Optional import torch.nn from torch.optim import Optimizer @@ -18,6 +18,8 @@ class AbsNet(torch.nn.Module, metaclass=ABCMeta): def __init__(self) -> None: super(AbsNet, self).__init__() + self._device: Optional[torch.device] = None + @property def optim(self) -> Optimizer: optim = getattr(self, "_optim", None) @@ -119,3 +121,7 @@ def unfreeze_all_parameters(self) -> None: """Unfreeze all parameters.""" for p in self.parameters(): p.requires_grad = True + + def to_device(self, device: torch.device) -> None: + self._device = device + self.to(device) diff --git a/maro/rl/model/algorithm_nets/ac_based.py b/maro/rl/model/algorithm_nets/ac_based.py index 4462cc21d..a4a47a42f 100644 --- a/maro/rl/model/algorithm_nets/ac_based.py +++ b/maro/rl/model/algorithm_nets/ac_based.py @@ -54,3 +54,7 @@ def _get_actions_with_probs_impl(self, states: torch.Tensor, exploring: bool) -> def _get_states_actions_probs_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: # Not used in Actor-Critic or PPO pass + + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + # Not used in Actor-Critic or PPO + pass diff --git a/maro/rl/model/algorithm_nets/ddpg.py b/maro/rl/model/algorithm_nets/ddpg.py index a4ceb7424..c5e10b009 100644 --- a/maro/rl/model/algorithm_nets/ddpg.py +++ b/maro/rl/model/algorithm_nets/ddpg.py @@ -40,3 +40,7 @@ def _get_states_actions_probs_impl(self, states: torch.Tensor, actions: torch.Te def _get_states_actions_logps_impl(self, states: torch.Tensor, actions: torch.Tensor) -> torch.Tensor: # Not used in DDPG pass + + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + # Not used in DDPG + pass diff --git a/maro/rl/model/policy_net.py b/maro/rl/model/policy_net.py index 9a3b8fd5d..cf1a2df42 100644 --- a/maro/rl/model/policy_net.py +++ b/maro/rl/model/policy_net.py @@ -221,3 +221,17 @@ class ContinuousPolicyNet(PolicyNet, metaclass=ABCMeta): def __init__(self, state_dim: int, action_dim: int) -> None: super(ContinuousPolicyNet, self).__init__(state_dim=state_dim, action_dim=action_dim) + + def get_random_actions(self, states: torch.Tensor) -> torch.Tensor: + actions = self._get_random_actions_impl(states) + + assert self._shape_check( + states=states, + actions=actions, + ), f"Actions shape check failed. Expecting: {(states.shape[0], self.action_dim)}, actual: {actions.shape}." + + return actions + + @abstractmethod + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + raise NotImplementedError diff --git a/maro/rl/policy/abs_policy.py b/maro/rl/policy/abs_policy.py index 14b0bb3a9..d9ea7700c 100644 --- a/maro/rl/policy/abs_policy.py +++ b/maro/rl/policy/abs_policy.py @@ -129,6 +129,8 @@ class RLPolicy(AbsPolicy, metaclass=ABCMeta): state_dim (int): Dimension of states. action_dim (int): Dimension of actions. trainable (bool, default=True): Whether this policy is trainable. + warmup (int, default=0): Number of steps for uniform-random action selection, before running real policy. + Helps exploration. """ def __init__( @@ -138,6 +140,7 @@ def __init__( action_dim: int, is_discrete_action: bool, trainable: bool = True, + warmup: int = 0, ) -> None: super(RLPolicy, self).__init__(name=name, trainable=trainable) self._state_dim = state_dim @@ -145,6 +148,8 @@ def __init__( self._is_exploring = False self._device: Optional[torch.device] = None + self._warmup = warmup + self._call_count = 0 self.is_discrete_action = is_discrete_action @@ -200,7 +205,12 @@ def apply_gradients(self, grad: dict) -> None: raise NotImplementedError def get_actions(self, states: np.ndarray) -> np.ndarray: - actions = self.get_actions_tensor(ndarray_to_tensor(states, device=self._device)) + self._call_count += 1 + + if self._call_count <= self._warmup: + actions = self.get_random_actions_tensor(ndarray_to_tensor(states, device=self._device)) + else: + actions = self.get_actions_tensor(ndarray_to_tensor(states, device=self._device)) return actions.detach().cpu().numpy() def get_actions_tensor(self, states: torch.Tensor) -> torch.Tensor: @@ -217,6 +227,16 @@ def get_actions_tensor(self, states: torch.Tensor) -> torch.Tensor: return actions + def get_random_actions_tensor(self, states: torch.Tensor) -> torch.Tensor: + actions = self._get_random_actions_impl(states) + + assert self._shape_check( + states=states, + actions=actions, + ), f"Actions shape check failed. Expecting: {(states.shape[0], self.action_dim)}, actual: {actions.shape}." + + return actions + def get_actions_with_probs(self, states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: assert self._shape_check( states=states, @@ -273,6 +293,10 @@ def get_states_actions_logps(self, states: torch.Tensor, actions: torch.Tensor) def _get_actions_impl(self, states: torch.Tensor) -> torch.Tensor: raise NotImplementedError + @abstractmethod + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + raise NotImplementedError + @abstractmethod def _get_actions_with_probs_impl(self, states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: raise NotImplementedError diff --git a/maro/rl/policy/continuous_rl_policy.py b/maro/rl/policy/continuous_rl_policy.py index 33ed3e55d..259ba82b1 100644 --- a/maro/rl/policy/continuous_rl_policy.py +++ b/maro/rl/policy/continuous_rl_policy.py @@ -42,6 +42,8 @@ class ContinuousRLPolicy(RLPolicy): the bound for every dimension. If it is a float, it will be broadcast to all dimensions. policy_net (ContinuousPolicyNet): The core net of this policy. trainable (bool, default=True): Whether this policy is trainable. + warmup (int, default=0): Number of steps for uniform-random action selection, before running real policy. + Helps exploration. """ def __init__( @@ -50,6 +52,7 @@ def __init__( action_range: Tuple[Union[float, List[float]], Union[float, List[float]]], policy_net: ContinuousPolicyNet, trainable: bool = True, + warmup: int = 0, ) -> None: assert isinstance(policy_net, ContinuousPolicyNet) @@ -59,6 +62,7 @@ def __init__( action_dim=policy_net.action_dim, trainable=trainable, is_discrete_action=False, + warmup=warmup, ) self._lbounds, self._ubounds = _parse_action_range(self.action_dim, action_range) @@ -83,6 +87,9 @@ def _post_check(self, states: torch.Tensor, actions: torch.Tensor) -> bool: def _get_actions_impl(self, states: torch.Tensor) -> torch.Tensor: return self._policy_net.get_actions(states, self._is_exploring) + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + return self._policy_net.get_random_actions(states) + def _get_actions_with_probs_impl(self, states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: return self._policy_net.get_actions_with_probs(states, self._is_exploring) @@ -117,14 +124,22 @@ def train(self) -> None: self._policy_net.train() def get_state(self) -> dict: - return self._policy_net.get_state() + return { + "net": self._policy_net.get_state(), + "policy": { + "warmup": self._warmup, + "call_count": self._call_count, + }, + } def set_state(self, policy_state: dict) -> None: - self._policy_net.set_state(policy_state) + self._policy_net.set_state(policy_state["net"]) + self._warmup = policy_state["policy"]["warmup"] + self._call_count = policy_state["policy"]["call_count"] def soft_update(self, other_policy: RLPolicy, tau: float) -> None: assert isinstance(other_policy, ContinuousRLPolicy) self._policy_net.soft_update(other_policy.policy_net, tau) def _to_device_impl(self, device: torch.device) -> None: - self._policy_net.to(device) + self._policy_net.to_device(device) diff --git a/maro/rl/policy/discrete_rl_policy.py b/maro/rl/policy/discrete_rl_policy.py index 567e9d054..b2e9d945c 100644 --- a/maro/rl/policy/discrete_rl_policy.py +++ b/maro/rl/policy/discrete_rl_policy.py @@ -23,6 +23,8 @@ class DiscreteRLPolicy(RLPolicy, metaclass=ABCMeta): state_dim (int): Dimension of states. action_num (int): Number of actions. trainable (bool, default=True): Whether this policy is trainable. + warmup (int, default=0): Number of steps for uniform-random action selection, before running real policy. + Helps exploration. """ def __init__( @@ -31,6 +33,7 @@ def __init__( state_dim: int, action_num: int, trainable: bool = True, + warmup: int = 0, ) -> None: assert action_num >= 1 @@ -40,6 +43,7 @@ def __init__( action_dim=1, trainable=trainable, is_discrete_action=True, + warmup=warmup, ) self._action_num = action_num @@ -51,6 +55,12 @@ def action_num(self) -> int: def _post_check(self, states: torch.Tensor, actions: torch.Tensor) -> bool: return all([0 <= action < self.action_num for action in actions.cpu().numpy().flatten()]) + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + return ndarray_to_tensor( + np.random.randint(self.action_num, size=(states.shape[0], 1)), + device=self._device, + ) + class ValueBasedPolicy(DiscreteRLPolicy): """Valued-based policy. @@ -61,7 +71,8 @@ class ValueBasedPolicy(DiscreteRLPolicy): trainable (bool, default=True): Whether this policy is trainable. exploration_strategy (Tuple[Callable, dict], default=(epsilon_greedy, {"epsilon": 0.1})): Exploration strategy. exploration_scheduling_options (List[tuple], default=None): List of exploration scheduler options. - warmup (int, default=50000): Minimum number of experiences to warm up this policy. + warmup (int, default=50000): Number of steps for uniform-random action selection, before running real policy. + Helps exploration. """ def __init__( @@ -80,6 +91,7 @@ def __init__( state_dim=q_net.state_dim, action_num=q_net.action_num, trainable=trainable, + warmup=warmup, ) self._q_net = q_net @@ -91,9 +103,6 @@ def __init__( else [] ) - self._call_cnt = 0 - self._warmup = warmup - self._softmax = torch.nn.Softmax(dim=1) @property @@ -163,19 +172,9 @@ def explore(self) -> None: pass # Overwrite the base method and turn off explore mode. def _get_actions_impl(self, states: torch.Tensor) -> torch.Tensor: - actions, _ = self._get_actions_with_probs_impl(states) - return actions + return self._get_actions_with_probs_impl(states)[0] def _get_actions_with_probs_impl(self, states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - self._call_cnt += 1 - if self._call_cnt <= self._warmup: - actions = ndarray_to_tensor( - np.random.randint(self.action_num, size=(states.shape[0], 1)), - device=self._device, - ) - probs = torch.ones(states.shape[0]).float() * (1.0 / self.action_num) - return actions, probs - q_matrix = self.q_values_for_all_actions_tensor(states) # [B, action_num] q_matrix_softmax = self._softmax(q_matrix) _, actions = q_matrix.max(dim=1) # [B], [B] @@ -222,17 +221,25 @@ def train(self) -> None: self._q_net.train() def get_state(self) -> dict: - return self._q_net.get_state() + return { + "net": self._q_net.get_state(), + "policy": { + "warmup": self._warmup, + "call_count": self._call_count, + }, + } def set_state(self, policy_state: dict) -> None: self._q_net.set_state(policy_state) + self._warmup = policy_state["policy"]["warmup"] + self._call_count = policy_state["policy"]["call_count"] def soft_update(self, other_policy: RLPolicy, tau: float) -> None: assert isinstance(other_policy, ValueBasedPolicy) self._q_net.soft_update(other_policy.q_net, tau) def _to_device_impl(self, device: torch.device) -> None: - self._q_net.to(device) + self._q_net.to_device(device) class DiscretePolicyGradient(DiscreteRLPolicy): @@ -242,6 +249,8 @@ class DiscretePolicyGradient(DiscreteRLPolicy): name (str): Name of the policy. policy_net (DiscretePolicyNet): The core net of this policy. trainable (bool, default=True): Whether this policy is trainable. + warmup (int, default=50000): Number of steps for uniform-random action selection, before running real policy. + Helps exploration. """ def __init__( @@ -249,6 +258,7 @@ def __init__( name: str, policy_net: DiscretePolicyNet, trainable: bool = True, + warmup: int = 0, ) -> None: assert isinstance(policy_net, DiscretePolicyNet) @@ -257,6 +267,7 @@ def __init__( state_dim=policy_net.state_dim, action_num=policy_net.action_num, trainable=trainable, + warmup=warmup, ) self._policy_net = policy_net @@ -302,10 +313,18 @@ def train(self) -> None: self._policy_net.train() def get_state(self) -> dict: - return self._policy_net.get_state() + return { + "net": self._policy_net.get_state(), + "policy": { + "warmup": self._warmup, + "call_count": self._call_count, + }, + } def set_state(self, policy_state: dict) -> None: self._policy_net.set_state(policy_state) + self._warmup = policy_state["policy"]["warmup"] + self._call_count = policy_state["policy"]["call_count"] def soft_update(self, other_policy: RLPolicy, tau: float) -> None: assert isinstance(other_policy, DiscretePolicyGradient) @@ -350,4 +369,4 @@ def _get_state_action_logps_impl(self, states: torch.Tensor, actions: torch.Tens return action_logps.gather(1, actions).squeeze(-1) # [B] def _to_device_impl(self, device: torch.device) -> None: - self._policy_net.to(device) + self._policy_net.to_device(device) diff --git a/maro/rl/rollout/env_sampler.py b/maro/rl/rollout/env_sampler.py index 6c119432c..1e394ac78 100644 --- a/maro/rl/rollout/env_sampler.py +++ b/maro/rl/rollout/env_sampler.py @@ -146,6 +146,7 @@ class ExpElement: terminal_dict: Dict[Any, bool] next_state: Optional[np.ndarray] next_agent_state_dict: Dict[Any, np.ndarray] + truncated: bool @property def agent_names(self) -> list: @@ -171,6 +172,7 @@ def split_contents_by_agent(self) -> Dict[Any, ExpElement]: } if self.next_agent_state_dict is not None and agent_name in self.next_agent_state_dict else {}, + truncated=self.truncated, ) return ret @@ -194,6 +196,7 @@ def split_contents_by_trainer(self, agent2trainer: Dict[Any, str]) -> Dict[str, terminal_dict={}, next_state=self.next_state, next_agent_state_dict=None if self.next_agent_state_dict is None else {}, + truncated=self.truncated, ), ) for agent_name, trainer_name in agent2trainer.items(): @@ -225,6 +228,7 @@ def make_exp_element(self) -> ExpElement: terminal_dict=self.terminal_dict, next_state=self.next_state, next_agent_state_dict=self.next_agent_state_dict, + truncated=self.truncated, ) @@ -240,6 +244,8 @@ class AbsEnvSampler(object, metaclass=ABCMeta): agent_wrapper_cls (Type[AbsAgentWrapper], default=SimpleAgentWrapper): Specific AgentWrapper type. reward_eval_delay (int, default=None): Number of ticks required after a decision event to evaluate the reward for the action taken for that event. If it is None, calculate reward immediately after `step()`. + max_episode_length (int, default=None): Maximum number of steps in one episode during sampling. + When reach this limit, the environment will be truncated and reset. """ def __init__( @@ -251,6 +257,7 @@ def __init__( trainable_policies: List[str] = None, agent_wrapper_cls: Type[AbsAgentWrapper] = SimpleAgentWrapper, reward_eval_delay: int = None, + max_episode_length: int = None, ) -> None: assert learn_env is not test_env, "Please use different envs for training and testing." @@ -267,6 +274,8 @@ def __init__( self._transition_cache: List[CacheElement] = [] self._agent_last_index: Dict[Any, int] = {} # Index of last occurrence of agent in self._transition_cache self._reward_eval_delay = reward_eval_delay + self._max_episode_length = max_episode_length + self._current_episode_length = 0 self._info: dict = {} self.metrics: dict = {} @@ -301,6 +310,10 @@ def env(self) -> Env: assert self._env is not None return self._env + def monitor_metrics(self) -> float: + """Metrics watched by early stopping.""" + return float(self._total_number_interactions) + def _switch_env(self, env: Env) -> None: self._env = env @@ -406,6 +419,7 @@ def _append_cache_element(self, cache_element: Optional[CacheElement]) -> None: def _reset(self) -> None: self.env.reset() + self._current_episode_length = 0 self._info.clear() self._transition_cache.clear() self._agent_last_index.clear() @@ -414,6 +428,10 @@ def _reset(self) -> None: def _select_trainable_agents(self, original_dict: dict) -> dict: return {k: v for k, v in original_dict.items() if k in self._trainable_agents} + @property + def truncated(self) -> bool: + return self._max_episode_length == self._current_episode_length + def sample( self, policy_state: Optional[Dict[str, Dict[str, Any]]] = None, @@ -430,7 +448,7 @@ def sample( Returns: A dict that contains the collected experiences and additional information. """ - steps_to_go = num_steps + steps_to_go = num_steps if num_steps is not None else float("inf") if policy_state is not None: # Update policy state if necessary self.set_policy_state(policy_state) self._switch_env(self._learn_env) # Init the env @@ -439,18 +457,34 @@ def sample( if self._end_of_episode: self._reset() + # If num_steps is None, run until the end of episode or the episode is truncated + # If num_steps is not None, run until we collect required number of steps total_experiences = [] - # If steps_to_go is None, run until the end of episode - # If steps_to_go is not None, run until we collect required number of steps - while (steps_to_go is None and not self._end_of_episode) or (steps_to_go is not None and steps_to_go > 0): - if self._end_of_episode: + + while not any( + [ + num_steps is None and (self._end_of_episode or self.truncated), + num_steps is not None and steps_to_go == 0, + ], + ): + if self._end_of_episode or self.truncated: self._reset() - while not self._end_of_episode and (steps_to_go is None or steps_to_go > 0): + while not any( + [ + self._end_of_episode, + self.truncated, + steps_to_go == 0, + ], + ): # Get agent actions and translate them to env actions action_dict = self._agent_wrapper.choose_actions(self._agent_state_dict) env_action_dict = self._translate_to_env_action(action_dict, self._event) + self._total_number_interactions += 1 + self._current_episode_length += 1 + steps_to_go -= 1 + # Store experiences in the cache cache_element = CacheElement( tick=self.env.tick, @@ -459,24 +493,23 @@ def sample( agent_state_dict=self._select_trainable_agents(self._agent_state_dict), action_dict=self._select_trainable_agents(action_dict), env_action_dict=self._select_trainable_agents(env_action_dict), - # The following will be generated later + # The following will be generated/updated later reward_dict={}, terminal_dict={}, next_state=None, next_agent_state_dict={}, + truncated=self.truncated, ) # Update env and get new states (global & agent) self._step(list(env_action_dict.values())) - self._total_number_interactions += 1 cache_element.next_state = self._state if self._reward_eval_delay is None: self._calc_reward(cache_element) self._post_step(cache_element) self._append_cache_element(cache_element) - if steps_to_go is not None: - steps_to_go -= 1 + self._append_cache_element(None) tick_bound = self.env.tick - (0 if self._reward_eval_delay is None else self._reward_eval_delay) @@ -549,6 +582,7 @@ def eval(self, policy_state: Dict[str, Dict[str, Any]] = None, num_episodes: int terminal_dict={}, next_state=None, next_agent_state_dict={}, + truncated=False, # No truncation in evaluation ) # Update env and get new states (global & agent) diff --git a/maro/rl/training/algorithms/base/ac_ppo_base.py b/maro/rl/training/algorithms/base/ac_ppo_base.py index 544ea93b1..aeead3574 100644 --- a/maro/rl/training/algorithms/base/ac_ppo_base.py +++ b/maro/rl/training/algorithms/base/ac_ppo_base.py @@ -203,6 +203,7 @@ def preprocess_batch(self, batch: TransitionBatch) -> TransitionBatch: states = ndarray_to_tensor(batch.states, device=self._device) # s actions = ndarray_to_tensor(batch.actions, device=self._device) # a terminals = ndarray_to_tensor(batch.terminals, device=self._device) + truncated = ndarray_to_tensor(batch.truncated, device=self._device) next_states = ndarray_to_tensor(batch.next_states, device=self._device) if self._is_discrete_action: actions = actions.long() @@ -217,7 +218,7 @@ def preprocess_batch(self, batch: TransitionBatch) -> TransitionBatch: i = 0 while i < batch.size: j = i - while j < batch.size - 1 and not terminals[j]: + while j < batch.size - 1 and not (terminals[j] or truncated[j]): j += 1 last_val = ( 0.0 diff --git a/maro/rl/training/algorithms/maddpg.py b/maro/rl/training/algorithms/maddpg.py index edc63f39a..1e5d1d766 100644 --- a/maro/rl/training/algorithms/maddpg.py +++ b/maro/rl/training/algorithms/maddpg.py @@ -378,6 +378,7 @@ def record_multiple(self, env_idx: int, exp_elements: List[ExpElement]) -> None: agent_states=agent_states, next_agent_states=next_agent_states, terminals=np.array(terminal_flags), + truncated=np.array([exp_element.truncated for exp_element in exp_elements]), ) self._replay_memory.put(transition_batch) diff --git a/maro/rl/training/replay_memory.py b/maro/rl/training/replay_memory.py index e93847572..da1e7d692 100644 --- a/maro/rl/training/replay_memory.py +++ b/maro/rl/training/replay_memory.py @@ -187,6 +187,7 @@ def __init__( self._actions = np.zeros((self._capacity, self._action_dim), dtype=np.float32) self._rewards = np.zeros(self._capacity, dtype=np.float32) self._terminals = np.zeros(self._capacity, dtype=bool) + self._truncated = np.zeros(self._capacity, dtype=bool) self._next_states = np.zeros((self._capacity, self._state_dim), dtype=np.float32) self._returns = np.zeros(self._capacity, dtype=np.float32) self._advantages = np.zeros(self._capacity, dtype=np.float32) @@ -215,6 +216,7 @@ def put(self, transition_batch: TransitionBatch) -> None: assert match_shape(transition_batch.actions, (batch_size, self._action_dim)) assert match_shape(transition_batch.rewards, (batch_size,)) assert match_shape(transition_batch.terminals, (batch_size,)) + assert match_shape(transition_batch.truncated, (batch_size,)) assert match_shape(transition_batch.next_states, (batch_size, self._state_dim)) if transition_batch.returns is not None: match_shape(transition_batch.returns, (batch_size,)) @@ -237,6 +239,7 @@ def _put_by_indexes(self, indexes: np.ndarray, transition_batch: TransitionBatch self._actions[indexes] = transition_batch.actions self._rewards[indexes] = transition_batch.rewards self._terminals[indexes] = transition_batch.terminals + self._truncated[indexes] = transition_batch.truncated self._next_states[indexes] = transition_batch.next_states if transition_batch.returns is not None: self._returns[indexes] = transition_batch.returns @@ -274,6 +277,7 @@ def sample_by_indexes(self, indexes: np.ndarray) -> TransitionBatch: actions=self._actions[indexes], rewards=self._rewards[indexes], terminals=self._terminals[indexes], + truncated=self._truncated[indexes], next_states=self._next_states[indexes], returns=self._returns[indexes], advantages=self._advantages[indexes], @@ -345,6 +349,7 @@ def __init__( self._rewards = [np.zeros(self._capacity, dtype=np.float32) for _ in range(self.agent_num)] self._next_states = np.zeros((self._capacity, self._state_dim), dtype=np.float32) self._terminals = np.zeros(self._capacity, dtype=bool) + self._truncated = np.zeros(self._capacity, dtype=bool) assert len(agent_states_dims) == self.agent_num self._agent_states_dims = agent_states_dims @@ -379,6 +384,7 @@ def put(self, transition_batch: MultiTransitionBatch) -> None: assert match_shape(transition_batch.rewards[i], (batch_size,)) assert match_shape(transition_batch.terminals, (batch_size,)) + assert match_shape(transition_batch.truncated, (batch_size,)) assert match_shape(transition_batch.next_states, (batch_size, self._state_dim)) assert len(transition_batch.agent_states) == self.agent_num @@ -401,6 +407,7 @@ def _put_by_indexes(self, indexes: np.ndarray, transition_batch: MultiTransition self._actions[i][indexes] = transition_batch.actions[i] self._rewards[i][indexes] = transition_batch.rewards[i] self._terminals[indexes] = transition_batch.terminals + self._truncated[indexes] = transition_batch.truncated self._next_states[indexes] = transition_batch.next_states for i in range(self.agent_num): @@ -436,6 +443,7 @@ def sample_by_indexes(self, indexes: np.ndarray) -> MultiTransitionBatch: actions=[action[indexes] for action in self._actions], rewards=[reward[indexes] for reward in self._rewards], terminals=self._terminals[indexes], + truncated=self._truncated[indexes], next_states=self._next_states[indexes], agent_states=[state[indexes] for state in self._agent_states], next_agent_states=[state[indexes] for state in self._next_agent_states], diff --git a/maro/rl/training/trainer.py b/maro/rl/training/trainer.py index 8bced5674..774954f6c 100644 --- a/maro/rl/training/trainer.py +++ b/maro/rl/training/trainer.py @@ -254,6 +254,7 @@ def record_multiple(self, env_idx: int, exp_elements: List[ExpElement]) -> None: exp_element.action_dict[agent_name], exp_element.reward_dict[agent_name], exp_element.terminal_dict[agent_name], + exp_element.truncated, exp_element.next_agent_state_dict.get(agent_name, exp_element.agent_state_dict[agent_name]), ), ) @@ -264,7 +265,8 @@ def record_multiple(self, env_idx: int, exp_elements: List[ExpElement]) -> None: actions=np.vstack([exp[1] for exp in exps]), rewards=np.array([exp[2] for exp in exps]), terminals=np.array([exp[3] for exp in exps]), - next_states=np.vstack([exp[4] for exp in exps]), + truncated=np.array([exp[4] for exp in exps]), + next_states=np.vstack([exp[5] for exp in exps]), ) transition_batch = self._preprocess_batch(transition_batch) self.replay_memory.put(transition_batch) diff --git a/maro/rl/utils/transition_batch.py b/maro/rl/utils/transition_batch.py index f9ada5473..53bbd6233 100644 --- a/maro/rl/utils/transition_batch.py +++ b/maro/rl/utils/transition_batch.py @@ -19,6 +19,7 @@ class TransitionBatch: rewards: np.ndarray # 1D next_states: np.ndarray # 2D terminals: np.ndarray # 1D + truncated: np.ndarray # 1D returns: np.ndarray = None # 1D advantages: np.ndarray = None # 1D old_logps: np.ndarray = None # 1D @@ -34,6 +35,7 @@ def __post_init__(self) -> None: assert len(self.rewards.shape) == 1 and self.rewards.shape[0] == self.states.shape[0] assert self.next_states.shape == self.states.shape assert len(self.terminals.shape) == 1 and self.terminals.shape[0] == self.states.shape[0] + assert len(self.truncated.shape) == 1 and self.truncated.shape[0] == self.states.shape[0] def make_kth_sub_batch(self, i: int, k: int) -> TransitionBatch: return TransitionBatch( @@ -42,6 +44,7 @@ def make_kth_sub_batch(self, i: int, k: int) -> TransitionBatch: rewards=self.rewards[i::k], next_states=self.next_states[i::k], terminals=self.terminals[i::k], + truncated=self.truncated[i::k], returns=self.returns[i::k] if self.returns is not None else None, advantages=self.advantages[i::k] if self.advantages is not None else None, old_logps=self.old_logps[i::k] if self.old_logps is not None else None, @@ -60,7 +63,7 @@ class MultiTransitionBatch: agent_states: List[np.ndarray] # List of 2D next_agent_states: List[np.ndarray] # List of 2D terminals: np.ndarray # 1D - + truncated: np.ndarray # 1D returns: Optional[List[np.ndarray]] = None # List of 1D advantages: Optional[List[np.ndarray]] = None # List of 1D @@ -81,6 +84,7 @@ def __post_init__(self) -> None: assert self.agent_states[i].shape[0] == self.states.shape[0] assert len(self.terminals.shape) == 1 and self.terminals.shape[0] == self.states.shape[0] + assert len(self.truncated.shape) == 1 and self.truncated.shape[0] == self.states.shape[0] assert self.next_states.shape == self.states.shape assert len(self.next_agent_states) == len(self.agent_states) @@ -98,6 +102,7 @@ def make_kth_sub_batch(self, i: int, k: int) -> MultiTransitionBatch: agent_states = [state[i::k] for state in self.agent_states] next_agent_states = [state[i::k] for state in self.next_agent_states] terminals = self.terminals[i::k] + truncated = self.truncated[i::k] returns = None if self.returns is None else [r[i::k] for r in self.returns] advantages = None if self.advantages is None else [advantage[i::k] for advantage in self.advantages] return MultiTransitionBatch( @@ -108,6 +113,7 @@ def make_kth_sub_batch(self, i: int, k: int) -> MultiTransitionBatch: agent_states, next_agent_states, terminals, + truncated, returns, advantages, ) @@ -123,6 +129,7 @@ def merge_transition_batches(batch_list: List[TransitionBatch]) -> TransitionBat rewards=np.concatenate([batch.rewards for batch in batch_list], axis=0), next_states=np.concatenate([batch.next_states for batch in batch_list], axis=0), terminals=np.concatenate([batch.terminals for batch in batch_list]), + truncated=np.concatenate([batch.truncated for batch in batch_list]), returns=np.concatenate([batch.returns for batch in batch_list]), advantages=np.concatenate([batch.advantages for batch in batch_list]), old_logps=None diff --git a/maro/rl/workflows/callback.py b/maro/rl/workflows/callback.py index f96484a6d..1c5a2c2f7 100644 --- a/maro/rl/workflows/callback.py +++ b/maro/rl/workflows/callback.py @@ -1,10 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +from __future__ import annotations + import copy import os -from enum import Enum -from typing import Dict, List, Union +import typing +from typing import Dict, List, Optional, Union import pandas as pd @@ -12,83 +14,67 @@ from maro.rl.training import TrainingManager from maro.utils import LoggerV2 +if typing.TYPE_CHECKING: + from maro.rl.workflows.main import TrainingWorkflow + EnvSampler = Union[AbsEnvSampler, BatchEnvSampler] class Callback(object): - def on_episode_start( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def __init__(self) -> None: + self.workflow: Optional[TrainingWorkflow] = None + self.env_sampler: Optional[EnvSampler] = None + self.training_manager: Optional[TrainingManager] = None + self.logger: Optional[LoggerV2] = None + + def on_episode_start(self, ep: int) -> None: pass - def on_episode_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_episode_end(self, ep: int) -> None: pass - def on_training_start( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_training_start(self, ep: int) -> None: pass - def on_training_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_training_end(self, ep: int) -> None: pass - def on_validation_start( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_validation_start(self, ep: int) -> None: pass - def on_validation_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_validation_end(self, ep: int) -> None: pass - def on_test_start( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_test_start(self, ep: int) -> None: pass - def on_test_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_test_end(self, ep: int) -> None: pass +class EarlyStopping(Callback): + def __init__(self, patience: int) -> None: + super(EarlyStopping, self).__init__() + + self._patience = patience + self._best_ep: int = -1 + self._best: float = float("-inf") + + def on_validation_end(self, ep: int) -> None: + cur = self.env_sampler.monitor_metrics() + if cur > self._best: + self._best_ep = ep + self._best = cur + self.logger.info(f"Current metric: {cur} @ ep {ep}. Best metric: {self._best} @ ep {self._best_ep}") + + if ep - self._best_ep > self._patience: + self.workflow.early_stop = True + self.logger.info( + f"Validation metric has not been updated for {ep - self._best_ep} " + f"epochs (patience = {self._patience} epochs). Early stop.", + ) + + class Checkpoint(Callback): def __init__(self, path: str, interval: int) -> None: super(Checkpoint, self).__init__() @@ -96,16 +82,10 @@ def __init__(self, path: str, interval: int) -> None: self._path = path self._interval = interval - def on_training_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: + def on_training_end(self, ep: int) -> None: if ep % self._interval == 0: - training_manager.save(os.path.join(self._path, str(ep))) - logger.info(f"[Episode {ep}] All trainer states saved under {self._path}") + self.training_manager.save(os.path.join(self._path, str(ep))) + self.logger.info(f"[Episode {ep}] All trainer states saved under {self._path}") class MetricsRecorder(Callback): @@ -126,15 +106,9 @@ def _dump_metric_history(self) -> None: df = pd.DataFrame.from_records(metric_list) df.to_csv(os.path.join(self._path, "metrics_valid.csv"), index=True) - def on_training_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: - if len(env_sampler.metrics) > 0: - metrics = copy.deepcopy(env_sampler.metrics) + def on_training_end(self, ep: int) -> None: + if len(self.env_sampler.metrics) > 0: + metrics = copy.deepcopy(self.env_sampler.metrics) metrics["ep"] = ep if ep in self._full_metrics: self._full_metrics[ep].update(metrics) @@ -142,15 +116,9 @@ def on_training_end( self._full_metrics[ep] = metrics self._dump_metric_history() - def on_validation_end( - self, - env_sampler: EnvSampler, - training_manager: TrainingManager, - logger: LoggerV2, - ep: int, - ) -> None: - if len(env_sampler.metrics) > 0: - metrics = copy.deepcopy(env_sampler.metrics) + def on_validation_end(self, ep: int) -> None: + if len(self.env_sampler.metrics) > 0: + metrics = copy.deepcopy(self.env_sampler.metrics) metrics["ep"] = ep if ep in self._full_metrics: self._full_metrics[ep].update(metrics) @@ -163,31 +131,52 @@ def on_validation_end( self._dump_metric_history() -class SupportedCallbackFunc(Enum): - ON_EPISODE_START = "on_episode_start" - ON_EPISODE_END = "on_episode_end" - ON_TRAINING_START = "on_training_start" - ON_TRAINING_END = "on_training_end" - ON_VALIDATION_START = "on_validation_start" - ON_VALIDATION_END = "on_validation_end" - ON_TEST_START = "on_test_start" - ON_TEST_END = "on_test_end" - - class CallbackManager(object): - def __init__(self, callbacks: List[Callback]) -> None: - super(CallbackManager, self).__init__() - - self._callbacks = callbacks - - def call( + def __init__( self, - func_name: SupportedCallbackFunc, + workflow: TrainingWorkflow, + callbacks: List[Callback], env_sampler: EnvSampler, training_manager: TrainingManager, logger: LoggerV2, - ep: int, ) -> None: + super(CallbackManager, self).__init__() + + self._callbacks = callbacks + for callback in self._callbacks: + callback.workflow = workflow + callback.env_sampler = env_sampler + callback.training_manager = training_manager + callback.logger = logger + + def on_episode_start(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_episode_start(ep) + + def on_episode_end(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_episode_end(ep) + + def on_training_start(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_training_start(ep) + + def on_training_end(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_training_end(ep) + + def on_validation_start(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_validation_start(ep) + + def on_validation_end(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_validation_end(ep) + + def on_test_start(self, ep: int) -> None: + for callback in self._callbacks: + callback.on_test_start(ep) + + def on_test_end(self, ep: int) -> None: for callback in self._callbacks: - func = getattr(callback, func_name.value) - func(env_sampler, training_manager, logger, ep) + callback.on_test_end(ep) diff --git a/maro/rl/workflows/config/parser.py b/maro/rl/workflows/config/parser.py index 709a94905..a94ec371f 100644 --- a/maro/rl/workflows/config/parser.py +++ b/maro/rl/workflows/config/parser.py @@ -76,6 +76,11 @@ def _validate_main_section(self) -> None: f"positive ints", ) + early_stop_patience = self._config["main"].get("early_stop_patience", None) + if early_stop_patience is not None: + if not isinstance(early_stop_patience, int) or early_stop_patience <= 0: + raise ValueError(f"Invalid early stop patience: {early_stop_patience}. Should be a positive integer.") + if "logging" in self._config["main"]: self._validate_logging_section("main", self._config["main"]["logging"]) @@ -287,13 +292,15 @@ def get_job_spec(self, containerize: bool = False) -> Dict[str, Tuple[str, Dict[ main_proc_env["EVAL_SCHEDULE"] = " ".join([str(val) for val in sorted(sch)]) main_proc_env["NUM_EVAL_EPISODES"] = str(self._config["main"].get("num_eval_episodes", 1)) + if "early_stop_patience" in self._config["main"]: + main_proc_env["EARLY_STOP_PATIENCE"] = str(self._config["main"]["early_stop_patience"]) load_path = self._config["training"].get("load_path", None) if load_path is not None: - env["main"]["LOAD_PATH"] = path_mapping[load_path] + main_proc_env["LOAD_PATH"] = path_mapping[load_path] load_episode = self._config["training"].get("load_episode", None) if load_episode is not None: - env["main"]["LOAD_EPISODE"] = str(load_episode) + main_proc_env["LOAD_EPISODE"] = str(load_episode) if "checkpointing" in self._config["training"]: conf = self._config["training"]["checkpointing"] diff --git a/maro/rl/workflows/config/template.yml b/maro/rl/workflows/config/template.yml index ea38b9316..ac514f78b 100644 --- a/maro/rl/workflows/config/template.yml +++ b/maro/rl/workflows/config/template.yml @@ -24,6 +24,7 @@ main: # A list indicates the episodes at the end of which policies are to be evaluated. Note that episode indexes are # 1-based. eval_schedule: 10 + early_stop_patience: 10 # Number of epochs waiting for a better validation metrics. Could be `null`. num_eval_episodes: 10 # Number of Episodes to run in evaluation. # Minimum number of samples to start training in one epoch. The workflow will re-run experience collection # until we have at least `min_n_sample` of experiences. diff --git a/maro/rl/workflows/main.py b/maro/rl/workflows/main.py index 4fb0f0540..e28a46035 100644 --- a/maro/rl/workflows/main.py +++ b/maro/rl/workflows/main.py @@ -14,7 +14,7 @@ from maro.rl.utils import get_torch_device from maro.rl.utils.common import float_or_none, get_env, int_or_none, list_or_none from maro.rl.utils.training import get_latest_ep -from maro.rl.workflows.callback import CallbackManager, Checkpoint, MetricsRecorder, SupportedCallbackFunc +from maro.rl.workflows.callback import CallbackManager, Checkpoint, EarlyStopping, MetricsRecorder from maro.utils import LoggerV2 @@ -46,6 +46,7 @@ def __init__(self) -> None: # Evaluating schedule. self.eval_schedule = list_or_none(get_env("EVAL_SCHEDULE", required=False)) + self.early_stop_patience = int_or_none(get_env("EARLY_STOP_PATIENCE", required=False)) self.num_eval_episodes = int_or_none(get_env("NUM_EVAL_EPISODES", required=False)) # Restore configurations. @@ -113,105 +114,111 @@ def main(rl_component_bundle: RLComponentBundle, env_attr: WorkflowEnvAttributes if args.evaluate_only: evaluate_only_workflow(rl_component_bundle, env_attr) else: - training_workflow(rl_component_bundle, env_attr) + TrainingWorkflow().run(rl_component_bundle, env_attr) -def training_workflow(rl_component_bundle: RLComponentBundle, env_attr: WorkflowEnvAttributes) -> None: - env_attr.logger.info("Start training workflow.") +class TrainingWorkflow(object): + def run(self, rl_component_bundle: RLComponentBundle, env_attr: WorkflowEnvAttributes) -> None: + env_attr.logger.info("Start training workflow.") - env_sampler = _get_env_sampler(rl_component_bundle, env_attr) - - # evaluation schedule - env_attr.logger.info(f"Policy will be evaluated at the end of episodes {env_attr.eval_schedule}") - eval_point_index = 0 - - training_manager = TrainingManager( - rl_component_bundle=rl_component_bundle, - explicit_assign_device=(env_attr.train_mode == "simple"), - proxy_address=None if env_attr.train_mode == "simple" else env_attr.proxy_address, - logger=env_attr.logger, - ) - - callbacks = [] - if env_attr.checkpoint_path is not None: - callbacks.append( - Checkpoint( - path=env_attr.checkpoint_path, - interval=1 if env_attr.checkpoint_interval is None else env_attr.checkpoint_interval, - ), - ) - callbacks.append(MetricsRecorder(path=env_attr.log_path)) - cbm = CallbackManager(callbacks) - - if env_attr.load_path: - assert isinstance(env_attr.load_path, str) + env_sampler = _get_env_sampler(rl_component_bundle, env_attr) - ep = env_attr.load_episode if env_attr.load_episode is not None else get_latest_ep(env_attr.load_path) - path = os.path.join(env_attr.load_path, str(ep)) - - loaded = env_sampler.load_policy_state(path) - env_attr.logger.info(f"Loaded policies {loaded} into env sampler from {path}") + # evaluation schedule + env_attr.logger.info(f"Policy will be evaluated at the end of episodes {env_attr.eval_schedule}") + eval_point_index = 0 - loaded = training_manager.load(path) - env_attr.logger.info(f"Loaded trainers {loaded} from {path}") - start_ep = ep + 1 - else: - start_ep = 1 - - # main loop - for ep in range(start_ep, env_attr.num_episodes + 1): - cbm.call(SupportedCallbackFunc.ON_EPISODE_START, env_sampler, training_manager, env_attr.logger, ep) - - collect_time = training_time = 0.0 - total_experiences: List[List[ExpElement]] = [] - total_info_list: List[dict] = [] - n_sample = 0 - while n_sample < env_attr.min_n_sample: - tc0 = time.time() - result = env_sampler.sample( - policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, - num_steps=env_attr.num_steps, - ) - experiences: List[List[ExpElement]] = result["experiences"] - info_list: List[dict] = result["info"] - - n_sample += len(experiences[0]) - total_experiences.extend(experiences) - total_info_list.extend(info_list) - - collect_time += time.time() - tc0 - - env_sampler.post_collect(total_info_list, ep) - - tu0 = time.time() - env_attr.logger.info(f"Roll-out completed for episode {ep}. Training started...") - cbm.call(SupportedCallbackFunc.ON_TRAINING_START, env_sampler, training_manager, env_attr.logger, ep) - training_manager.record_experiences(total_experiences) - training_manager.train_step() - cbm.call(SupportedCallbackFunc.ON_TRAINING_END, env_sampler, training_manager, env_attr.logger, ep) - training_time += time.time() - tu0 - - # performance details - env_attr.logger.info( - f"ep {ep} - roll-out time: {collect_time:.2f} seconds, training time: {training_time:.2f} seconds", + training_manager = TrainingManager( + rl_component_bundle=rl_component_bundle, + explicit_assign_device=(env_attr.train_mode == "simple"), + proxy_address=None if env_attr.train_mode == "simple" else env_attr.proxy_address, + logger=env_attr.logger, ) - if env_attr.eval_schedule and ep == env_attr.eval_schedule[eval_point_index]: - cbm.call(SupportedCallbackFunc.ON_VALIDATION_START, env_sampler, training_manager, env_attr.logger, ep) - eval_point_index += 1 - result = env_sampler.eval( - policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, - num_episodes=env_attr.num_eval_episodes, + callbacks = [MetricsRecorder(path=env_attr.log_path)] + if env_attr.checkpoint_path is not None: + callbacks.append( + Checkpoint( + path=env_attr.checkpoint_path, + interval=1 if env_attr.checkpoint_interval is None else env_attr.checkpoint_interval, + ), + ) + if env_attr.early_stop_patience is not None: + callbacks.append(EarlyStopping(patience=env_attr.early_stop_patience)) + cbm = CallbackManager(self, callbacks, env_sampler, training_manager, env_attr.logger) + + if env_attr.load_path: + assert isinstance(env_attr.load_path, str) + + ep = env_attr.load_episode if env_attr.load_episode is not None else get_latest_ep(env_attr.load_path) + path = os.path.join(env_attr.load_path, str(ep)) + + loaded = env_sampler.load_policy_state(path) + env_attr.logger.info(f"Loaded policies {loaded} into env sampler from {path}") + + loaded = training_manager.load(path) + env_attr.logger.info(f"Loaded trainers {loaded} from {path}") + start_ep = ep + 1 + else: + start_ep = 1 + + # main loop + self.early_stop = False + for ep in range(start_ep, env_attr.num_episodes + 1): + if self.early_stop: # Might be set in `cbm.on_validation_end()` + break + + cbm.on_episode_start(ep) + + collect_time = training_time = 0.0 + total_experiences: List[List[ExpElement]] = [] + total_info_list: List[dict] = [] + n_sample = 0 + while n_sample < env_attr.min_n_sample: + tc0 = time.time() + result = env_sampler.sample( + policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, + num_steps=env_attr.num_steps, + ) + experiences: List[List[ExpElement]] = result["experiences"] + info_list: List[dict] = result["info"] + + n_sample += len(experiences[0]) + total_experiences.extend(experiences) + total_info_list.extend(info_list) + + collect_time += time.time() - tc0 + + env_sampler.post_collect(total_info_list, ep) + + tu0 = time.time() + env_attr.logger.info(f"Roll-out completed for episode {ep}. Training started...") + cbm.on_training_start(ep) + training_manager.record_experiences(total_experiences) + training_manager.train_step() + cbm.on_training_end(ep) + training_time += time.time() - tu0 + + # performance details + env_attr.logger.info( + f"ep {ep} - roll-out time: {collect_time:.2f} seconds, training time: {training_time:.2f} seconds", ) - env_sampler.post_evaluate(result["info"], ep) + if env_attr.eval_schedule and ep == env_attr.eval_schedule[eval_point_index]: + cbm.on_validation_start(ep) - cbm.call(SupportedCallbackFunc.ON_VALIDATION_END, env_sampler, training_manager, env_attr.logger, ep) + eval_point_index += 1 + result = env_sampler.eval( + policy_state=training_manager.get_policy_state() if not env_attr.is_single_thread else None, + num_episodes=env_attr.num_eval_episodes, + ) + env_sampler.post_evaluate(result["info"], ep) - cbm.call(SupportedCallbackFunc.ON_EPISODE_END, env_sampler, training_manager, env_attr.logger, ep) + cbm.on_validation_end(ep) - if isinstance(env_sampler, BatchEnvSampler): - env_sampler.exit() - training_manager.exit() + cbm.on_episode_end(ep) + + if isinstance(env_sampler, BatchEnvSampler): + env_sampler.exit() + training_manager.exit() def evaluate_only_workflow(rl_component_bundle: RLComponentBundle, env_attr: WorkflowEnvAttributes) -> None: diff --git a/tests/rl/gym_wrapper/common.py b/tests/rl/gym_wrapper/common.py index 6792a8ca8..41287cd8f 100644 --- a/tests/rl/gym_wrapper/common.py +++ b/tests/rl/gym_wrapper/common.py @@ -4,13 +4,16 @@ from typing import cast from maro.simulator import Env +from maro.utils import set_seeds from tests.rl.gym_wrapper.simulator.business_engine import GymBusinessEngine +set_seeds(123) + env_conf = { "topology": "Walker2d-v4", # HalfCheetah-v4, Hopper-v4, Walker2d-v4, Swimmer-v4, Ant-v4 "start_tick": 0, - "durations": 1000, + "durations": 100000, # Set a very large number "options": { "random_seed": None, }, @@ -21,7 +24,8 @@ num_agents = len(learn_env.agent_idx_list) gym_env = cast(GymBusinessEngine, learn_env.business_engine).gym_env +gym_action_space = gym_env.action_space gym_state_dim = gym_env.observation_space.shape[0] -gym_action_dim = gym_env.action_space.shape[0] -action_lower_bound, action_upper_bound = gym_env.action_space.low, gym_env.action_space.high -action_limit = gym_env.action_space.high[0] +gym_action_dim = gym_action_space.shape[0] +action_lower_bound, action_upper_bound = gym_action_space.low, gym_action_space.high +action_limit = gym_action_space.high[0] diff --git a/tests/rl/gym_wrapper/env_sampler.py b/tests/rl/gym_wrapper/env_sampler.py index b0bc7e865..20d387b72 100644 --- a/tests/rl/gym_wrapper/env_sampler.py +++ b/tests/rl/gym_wrapper/env_sampler.py @@ -24,6 +24,7 @@ def __init__( trainable_policies: List[str] = None, agent_wrapper_cls: Type[AbsAgentWrapper] = SimpleAgentWrapper, reward_eval_delay: int = None, + max_episode_length: int = None, ) -> None: super(GymEnvSampler, self).__init__( learn_env=learn_env, @@ -33,6 +34,7 @@ def __init__( trainable_policies=trainable_policies, agent_wrapper_cls=agent_wrapper_cls, reward_eval_delay=reward_eval_delay, + max_episode_length=max_episode_length, ) self._sample_rewards = [] @@ -54,13 +56,13 @@ def _get_reward(self, env_action_dict: dict, event: Any, tick: int) -> Dict[Any, return {0: be.get_reward_at_tick(tick)} def _post_step(self, cache_element: CacheElement) -> None: - if not self._end_of_episode: + if not (self._end_of_episode or self.truncated): return rewards = list(self._env.metrics["reward_record"].values()) self._sample_rewards.append((len(rewards), np.sum(rewards))) def _post_eval_step(self, cache_element: CacheElement) -> None: - if not self._end_of_episode: + if not (self._end_of_episode or self.truncated): return rewards = list(self._env.metrics["reward_record"].values()) self._eval_rewards.append((len(rewards), np.sum(rewards))) @@ -71,6 +73,7 @@ def post_collect(self, info_list: list, ep: int) -> None: "n_segment": len(self._sample_rewards), "avg_reward": np.mean([r for _, r in self._sample_rewards]), "avg_n_steps": np.mean([n for n, _ in self._sample_rewards]), + "max_n_steps": np.max([n for n, _ in self._sample_rewards]), "n_interactions": self._total_number_interactions, } self.metrics.update(cur) @@ -84,6 +87,7 @@ def post_evaluate(self, info_list: list, ep: int) -> None: "val/n_segment": len(self._eval_rewards), "val/avg_reward": np.mean([r for _, r in self._eval_rewards]), "val/avg_n_steps": np.mean([n for n, _ in self._eval_rewards]), + "val/max_n_steps": np.max([n for n, _ in self._eval_rewards]), } self.metrics.update(cur) self._eval_rewards.clear() diff --git a/tests/rl/gym_wrapper/simulator/business_engine.py b/tests/rl/gym_wrapper/simulator/business_engine.py index 626a0f6e2..d5b15153d 100644 --- a/tests/rl/gym_wrapper/simulator/business_engine.py +++ b/tests/rl/gym_wrapper/simulator/business_engine.py @@ -4,6 +4,7 @@ from typing import List, Optional, cast import gym +import numpy as np from maro.backends.frame import FrameBase, SnapshotList from maro.event_buffer import CascadeEvent, EventBuffer, MaroEvents @@ -36,7 +37,6 @@ def __init__( self._gym_scenario_name = topology self._gym_env = gym.make(self._gym_scenario_name) - self._seed = additional_options.get("random_seed", None) self.reset() @@ -81,7 +81,7 @@ def get_info_at_tick(self, tick: int) -> object: # TODO return self._info_record[tick] def reset(self, keep_seed: bool = False) -> None: - self._last_obs = self._gym_env.reset()[0] + self._last_obs = self._gym_env.reset(seed=np.random.randint(low=0, high=4096))[0] self._is_done = False self._truncated = False self._reward_record = {} diff --git a/tests/rl/tasks/ppo/__init__.py b/tests/rl/tasks/ppo/__init__.py index 01207562b..15fc71069 100644 --- a/tests/rl/tasks/ppo/__init__.py +++ b/tests/rl/tasks/ppo/__init__.py @@ -54,6 +54,7 @@ def get_ppo_trainer(name: str, state_dim: int) -> PPOTrainer: test_env=test_env, policies=policies, agent2policy=agent2policy, + max_episode_length=1000, ), agent2policy=agent2policy, policies=policies, diff --git a/tests/rl/tasks/sac/__init__.py b/tests/rl/tasks/sac/__init__.py index d20b01ece..1e033f12b 100644 --- a/tests/rl/tasks/sac/__init__.py +++ b/tests/rl/tasks/sac/__init__.py @@ -6,6 +6,7 @@ import numpy as np import torch import torch.nn.functional as F +from gym import spaces from torch.distributions import Normal from torch.optim import Adam @@ -14,12 +15,14 @@ from maro.rl.policy import ContinuousRLPolicy from maro.rl.rl_component.rl_component_bundle import RLComponentBundle from maro.rl.training.algorithms import SoftActorCriticParams, SoftActorCriticTrainer +from maro.rl.utils import ndarray_to_tensor from tests.rl.gym_wrapper.common import ( action_limit, action_lower_bound, action_upper_bound, gym_action_dim, + gym_action_space, gym_state_dim, learn_env, num_agents, @@ -43,7 +46,7 @@ class MyContinuousSACNet(ContinuousSACNet): - def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None: + def __init__(self, state_dim: int, action_dim: int, action_limit: float, action_space: spaces.Space) -> None: super(MyContinuousSACNet, self).__init__(state_dim=state_dim, action_dim=action_dim) self._net = FullyConnected( @@ -58,6 +61,8 @@ def __init__(self, state_dim: int, action_dim: int, action_limit: float) -> None self._action_limit = action_limit self._optim = Adam(self.parameters(), lr=actor_learning_rate) + self._action_space = action_space + def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> Tuple[torch.Tensor, torch.Tensor]: net_out = self._net(states.float()) mu = self._mu(net_out) @@ -74,6 +79,11 @@ def _get_actions_with_logps_impl(self, states: torch.Tensor, exploring: bool) -> return pi_action, logp_pi + def _get_random_actions_impl(self, states: torch.Tensor) -> torch.Tensor: + return torch.stack( + [ndarray_to_tensor(self._action_space.sample(), device=self._device) for _ in range(states.shape[0])], + ) + class MyQCriticNet(QNet): def __init__(self, state_dim: int, action_dim: int) -> None: @@ -101,7 +111,8 @@ def get_sac_policy( return ContinuousRLPolicy( name=name, action_range=(action_lower_bound, action_upper_bound), - policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim, action_limit), + policy_net=MyContinuousSACNet(gym_state_dim, gym_action_dim, action_limit, action_space=gym_action_space), + warmup=10000, ) @@ -122,11 +133,6 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit ) -# TODO: -# 1. random seed -# 2. exploration with random sampled action # start_steps=10000, Number of steps for uniform-random action selection, before running real policy. Helps exploration. -# 3. confirm the effect of (max_ep_len=1000)? - algorithm = "sac" agent2policy = {agent: f"{algorithm}_{agent}.policy" for agent in learn_env.agent_idx_list} policies = [ @@ -146,7 +152,6 @@ def get_sac_trainer(name: str, state_dim: int, action_dim: int) -> SoftActorCrit if torch.cuda.is_available(): device_mapping = {f"{algorithm}_{i}.policy": "cuda:0" for i in range(num_agents)} - rl_component_bundle = RLComponentBundle( env_sampler=GymEnvSampler( learn_env=learn_env,