From 507ed1762e62bd6c4e85ea572ba166b69116b1ac Mon Sep 17 00:00:00 2001 From: Antonin RAFFIN Date: Wed, 1 Dec 2021 22:30:09 +0100 Subject: [PATCH] Multiprocessing support for off policy algorithms (#439) * Add multi-env training support for SAC * Fix for dict obs * Pytype fixes * Fix assert on number of envs * Remove for loop * Add support for Dict obs * Start cleanup * Update doc and bug fix * Add support for vectorized action noise and add multi env example for off-policy * Update version * Bug fix with VecNormalize * Update README table * Update variable names * Update changelog and version * Update doc and fix for `gradient_steps=-1` * Add test for `gradient_steps=-1` * Disable pytype pyi errors * Fix for DQN * Update comment on deepcopy * Remove episode_reward field * Fix RolloutReturn * Avoid modification by reference * Fix error message Co-authored-by: Anssi --- README.md | 18 +-- docs/guide/algos.rst | 14 +- docs/guide/examples.rst | 27 ++++ docs/misc/changelog.rst | 6 +- docs/modules/ddpg.rst | 2 +- docs/modules/dqn.rst | 2 +- docs/modules/sac.rst | 2 +- docs/modules/td3.rst | 2 +- stable_baselines3/common/buffers.py | 59 ++++++-- stable_baselines3/common/callbacks.py | 8 +- .../common/off_policy_algorithm.py | 141 ++++++++++-------- .../common/on_policy_algorithm.py | 4 +- stable_baselines3/common/type_aliases.py | 1 - stable_baselines3/common/utils.py | 4 +- stable_baselines3/dqn/dqn.py | 19 ++- stable_baselines3/sac/sac.py | 1 + stable_baselines3/td3/td3.py | 1 + stable_baselines3/version.txt | 2 +- tests/test_dict_env.py | 15 +- tests/test_run.py | 64 ++++++++ tests/test_spaces.py | 9 +- 21 files changed, 281 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 2cce33e..62379db 100644 --- a/README.md +++ b/README.md @@ -160,15 +160,15 @@ All the following examples can be executed online using Google colab notebooks: | **Name** | **Recurrent** | `Box` | `Discrete` | `MultiDiscrete` | `MultiBinary` | **Multi Processing** | | ------------------- | ------------------ | ------------------ | ------------------ | ------------------- | ------------------ | --------------------------------- | -| A2C | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| DDPG | :x: | :heavy_check_mark: | :x: | :x: | :x: | :x: | -| DQN | :x: | :x: | :heavy_check_mark: | :x: | :x: | :x: | -| HER | :x: | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: | :x: | -| PPO | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| SAC | :x: | :heavy_check_mark: | :x: | :x: | :x: | :x: | -| TD3 | :x: | :heavy_check_mark: | :x: | :x: | :x: | :x: | -| QR-DQN[1](#f1) | :x: | :x: | :heavy_check_mark: | :x: | :x: | :x: | -| TQC[1](#f1) | :x: | :heavy_check_mark: | :x: | :x: | :x: | :x: | +| A2C | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| DDPG | :x: | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | +| DQN | :x: | :x: | :heavy_check_mark: | :x: | :x: | :heavy_check_mark: | +| HER | :x: | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: | :x: | +| PPO | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| SAC | :x: | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | +| TD3 | :x: | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | +| QR-DQN[1](#f1) | :x: | :x: | :heavy_check_mark: | :x: | :x: | :heavy_check_mark: | +| TQC[1](#f1) | :x: | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | | Maskable PPO[1](#f1) | :x: | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | 1: Implemented in [SB3 Contrib](https://github.com/Stable-Baselines-Team/stable-baselines3-contrib) GitHub repository. diff --git a/docs/guide/algos.rst b/docs/guide/algos.rst index c9201e7..e362f8a 100644 --- a/docs/guide/algos.rst +++ b/docs/guide/algos.rst @@ -9,14 +9,14 @@ along with some useful characteristics: support for discrete/continuous actions, Name ``Box`` ``Discrete`` ``MultiDiscrete`` ``MultiBinary`` Multi Processing =================== =========== ============ ================= =============== ================ A2C ✔️ ✔️ ✔️ ✔️ ✔️ -DDPG ✔️ ❌ ❌ ❌ ❌ -DQN ❌ ✔️ ❌ ❌ ❌ -HER ✔️ ✔️ ❌ ❌ ❌ +DDPG ✔️ ❌ ❌ ❌ ✔️ +DQN ❌ ✔️ ❌ ❌ ✔️ +HER ✔️ ✔️ ❌ ❌ ❌ PPO ✔️ ✔️ ✔️ ✔️ ✔️ -SAC ✔️ ❌ ❌ ❌ ❌ -TD3 ✔️ ❌ ❌ ❌ ❌ -QR-DQN [#f1]_ ❌ ️ ✔️ ❌ ❌ ❌ -TQC [#f1]_ ✔️ ❌ ❌ ❌ ❌ +SAC ✔️ ❌ ❌ ❌ ✔️ +TD3 ✔️ ❌ ❌ ❌ ✔️ +QR-DQN [#f1]_ ❌ ️ ✔️ ❌ ❌ ✔️ +TQC [#f1]_ ✔️ ❌ ❌ ❌ ✔️ Maskable PPO [#f1]_ ❌ ✔️ ✔️ ✔️ ✔️ =================== =========== ============ ================= =============== ================ diff --git a/docs/guide/examples.rst b/docs/guide/examples.rst index a8f8de3..9317047 100644 --- a/docs/guide/examples.rst +++ b/docs/guide/examples.rst @@ -158,6 +158,33 @@ Multiprocessing: Unleashing the Power of Vectorized Environments env.render() +Multiprocessing with off-policy algorithms +------------------------------------------ + +.. warning:: + + When using multiple environments with off-policy algorithms, you should update the ``gradient_steps`` + parameter too. Set it to ``gradient_steps=-1`` to perform as many gradient steps as transitions collected. + There is usually a compromise between wall-clock time and sample efficiency, + see this `example in PR #439 `_ + + +.. code-block:: python + + import gym + + from stable_baselines3 import SAC + from stable_baselines3.common.env_util import make_vec_env + + env = make_vec_env("Pendulum-v0", n_envs=4, seed=0) + + # We collect 4 transitions per call to `ènv.step()` + # and performs 2 gradient steps per call to `ènv.step()` + # if gradient_steps=-1, then we would do 4 gradients steps per call to `ènv.step()` + model = SAC('MlpPolicy', env, train_freq=1, gradient_steps=2, verbose=1) + model.learn(total_timesteps=10_000) + + Dict Observations ----------------- diff --git a/docs/misc/changelog.rst b/docs/misc/changelog.rst index 7502c5b..8fd30a4 100644 --- a/docs/misc/changelog.rst +++ b/docs/misc/changelog.rst @@ -4,16 +4,20 @@ Changelog ========== -Release 1.3.1a3 (WIP) +Release 1.3.1a4 (WIP) --------------------------- Breaking Changes: ^^^^^^^^^^^^^^^^^ - Renamed ``mask`` argument of the ``predict()`` method to ``episode_start`` (used with RNN policies only) +- local variables ``action``, ``done`` and ``reward`` were renamed to their plural form for offpolicy algorithms (``actions``, ``dones``, ``rewards``), + this may affect custom callbacks. +- Removed ``episode_reward`` field from ``RolloutReturn()`` type New Features: ^^^^^^^^^^^^^ - Added ``norm_obs_keys`` param for ``VecNormalize`` wrapper to configure which observation keys to normalize (@kachayev) +- Added experimental support to train off-policy algorithms with multiple envs (note: ``HerReplayBuffer`` currently not supported) - Handle timeout termination properly for on-policy algorithms (when using ``TimeLimit``) diff --git a/docs/modules/ddpg.rst b/docs/modules/ddpg.rst index dd07c22..24d265f 100644 --- a/docs/modules/ddpg.rst +++ b/docs/modules/ddpg.rst @@ -39,7 +39,7 @@ Can I use? ---------- - Recurrent policies: ❌ -- Multi processing: ❌ +- Multi processing: ✔️ - Gym spaces: diff --git a/docs/modules/dqn.rst b/docs/modules/dqn.rst index 0c497ad..ce43855 100644 --- a/docs/modules/dqn.rst +++ b/docs/modules/dqn.rst @@ -34,7 +34,7 @@ Can I use? ---------- - Recurrent policies: ❌ -- Multi processing: ❌ +- Multi processing: ✔️ - Gym spaces: diff --git a/docs/modules/sac.rst b/docs/modules/sac.rst index 2f68047..a1156fd 100644 --- a/docs/modules/sac.rst +++ b/docs/modules/sac.rst @@ -46,7 +46,7 @@ Can I use? ---------- - Recurrent policies: ❌ -- Multi processing: ❌ +- Multi processing: ✔️ - Gym spaces: diff --git a/docs/modules/td3.rst b/docs/modules/td3.rst index 33cb38f..3bc93d7 100644 --- a/docs/modules/td3.rst +++ b/docs/modules/td3.rst @@ -39,7 +39,7 @@ Can I use? ---------- - Recurrent policies: ❌ -- Multi processing: ❌ +- Multi processing: ✔️ - Gym spaces: diff --git a/stable_baselines3/common/buffers.py b/stable_baselines3/common/buffers.py index 77d6c36..7e4f3f8 100644 --- a/stable_baselines3/common/buffers.py +++ b/stable_baselines3/common/buffers.py @@ -181,7 +181,8 @@ class ReplayBuffer(BaseBuffer): ): super(ReplayBuffer, self).__init__(buffer_size, observation_space, action_space, device, n_envs=n_envs) - assert n_envs == 1, "Replay buffer only support single environment for now" + # Adjust buffer size + self.buffer_size = max(buffer_size // n_envs, 1) # Check that the replay buffer can fit into the memory if psutil is not None: @@ -230,6 +231,17 @@ class ReplayBuffer(BaseBuffer): done: np.ndarray, infos: List[Dict[str, Any]], ) -> None: + + # Reshape needed when using multiple envs with discrete observations + # as numpy cannot broadcast (n_discrete,) to (n_discrete, 1) + if isinstance(self.observation_space, spaces.Discrete): + obs = obs.reshape((self.n_envs,) + self.obs_shape) + next_obs = next_obs.reshape((self.n_envs,) + self.obs_shape) + + # Same, for actions + if isinstance(self.action_space, spaces.Discrete): + action = action.reshape((self.n_envs, self.action_dim)) + # Copy to avoid modification by reference self.observations[self.pos] = np.array(obs).copy() @@ -273,20 +285,22 @@ class ReplayBuffer(BaseBuffer): return self._get_samples(batch_inds, env=env) def _get_samples(self, batch_inds: np.ndarray, env: Optional[VecNormalize] = None) -> ReplayBufferSamples: + # Sample randomly the env idx + env_indices = np.random.randint(0, high=self.n_envs, size=(len(batch_inds),)) if self.optimize_memory_usage: - next_obs = self._normalize_obs(self.observations[(batch_inds + 1) % self.buffer_size, 0, :], env) + next_obs = self._normalize_obs(self.observations[(batch_inds + 1) % self.buffer_size, env_indices, :], env) else: - next_obs = self._normalize_obs(self.next_observations[batch_inds, 0, :], env) + next_obs = self._normalize_obs(self.next_observations[batch_inds, env_indices, :], env) data = ( - self._normalize_obs(self.observations[batch_inds, 0, :], env), - self.actions[batch_inds, 0, :], + self._normalize_obs(self.observations[batch_inds, env_indices, :], env), + self.actions[batch_inds, env_indices, :], next_obs, # Only use dones that are not due to timeouts # deactivated by default (timeouts is initialized as an array of False) - self.dones[batch_inds] * (1 - self.timeouts[batch_inds]), - self._normalize_reward(self.rewards[batch_inds], env), + (self.dones[batch_inds, env_indices] * (1 - self.timeouts[batch_inds, env_indices])).reshape(-1, 1), + self._normalize_reward(self.rewards[batch_inds, env_indices].reshape(-1, 1), env), ) return ReplayBufferSamples(*tuple(map(self.to_torch, data))) @@ -491,7 +505,7 @@ class DictReplayBuffer(ReplayBuffer): super(ReplayBuffer, self).__init__(buffer_size, observation_space, action_space, device, n_envs=n_envs) assert isinstance(self.obs_shape, dict), "DictReplayBuffer must be used with Dict obs space only" - assert n_envs == 1, "Replay buffer only support single environment for now" + self.buffer_size = max(buffer_size // n_envs, 1) # Check that the replay buffer can fit into the memory if psutil is not None: @@ -511,8 +525,7 @@ class DictReplayBuffer(ReplayBuffer): for key, _obs_shape in self.obs_shape.items() } - # only 1 env is supported - self.actions = np.zeros((self.buffer_size, self.action_dim), dtype=action_space.dtype) + self.actions = np.zeros((self.buffer_size, self.n_envs, self.action_dim), dtype=action_space.dtype) self.rewards = np.zeros((self.buffer_size, self.n_envs), dtype=np.float32) self.dones = np.zeros((self.buffer_size, self.n_envs), dtype=np.float32) @@ -553,11 +566,21 @@ class DictReplayBuffer(ReplayBuffer): ) -> None: # Copy to avoid modification by reference for key in self.observations.keys(): - self.observations[key][self.pos] = np.array(obs[key]).copy() + # Reshape needed when using multiple envs with discrete observations + # as numpy cannot broadcast (n_discrete,) to (n_discrete, 1) + if isinstance(self.observation_space.spaces[key], spaces.Discrete): + obs[key] = obs[key].reshape((self.n_envs,) + self.obs_shape[key]) + self.observations[key][self.pos] = np.array(obs[key]) for key in self.next_observations.keys(): + if isinstance(self.observation_space.spaces[key], spaces.Discrete): + next_obs[key] = next_obs[key].reshape((self.n_envs,) + self.obs_shape[key]) self.next_observations[key][self.pos] = np.array(next_obs[key]).copy() + # Same reshape, for actions + if isinstance(self.action_space, spaces.Discrete): + action = action.reshape((self.n_envs, self.action_dim)) + self.actions[self.pos] = np.array(action).copy() self.rewards[self.pos] = np.array(reward).copy() self.dones[self.pos] = np.array(done).copy() @@ -582,10 +605,12 @@ class DictReplayBuffer(ReplayBuffer): return super(ReplayBuffer, self).sample(batch_size=batch_size, env=env) def _get_samples(self, batch_inds: np.ndarray, env: Optional[VecNormalize] = None) -> DictReplayBufferSamples: + # Sample randomly the env idx + env_indices = np.random.randint(0, high=self.n_envs, size=(len(batch_inds),)) # Normalize if needed and remove extra dimension (we are using only one env for now) - obs_ = self._normalize_obs({key: obs[batch_inds, 0, :] for key, obs in self.observations.items()}) - next_obs_ = self._normalize_obs({key: obs[batch_inds, 0, :] for key, obs in self.next_observations.items()}) + obs_ = self._normalize_obs({key: obs[batch_inds, env_indices, :] for key, obs in self.observations.items()}) + next_obs_ = self._normalize_obs({key: obs[batch_inds, env_indices, :] for key, obs in self.next_observations.items()}) # Convert to torch tensor observations = {key: self.to_torch(obs) for key, obs in obs_.items()} @@ -593,12 +618,14 @@ class DictReplayBuffer(ReplayBuffer): return DictReplayBufferSamples( observations=observations, - actions=self.to_torch(self.actions[batch_inds]), + actions=self.to_torch(self.actions[batch_inds, env_indices]), next_observations=next_observations, # Only use dones that are not due to timeouts # deactivated by default (timeouts is initialized as an array of False) - dones=self.to_torch(self.dones[batch_inds] * (1 - self.timeouts[batch_inds])), - rewards=self.to_torch(self._normalize_reward(self.rewards[batch_inds], env)), + dones=self.to_torch(self.dones[batch_inds, env_indices] * (1 - self.timeouts[batch_inds, env_indices])).reshape( + -1, 1 + ), + rewards=self.to_torch(self._normalize_reward(self.rewards[batch_inds, env_indices].reshape(-1, 1), env)), ) diff --git a/stable_baselines3/common/callbacks.py b/stable_baselines3/common/callbacks.py index 5f584da..cba6cb8 100644 --- a/stable_baselines3/common/callbacks.py +++ b/stable_baselines3/common/callbacks.py @@ -519,11 +519,9 @@ class StopTrainingOnMaxEpisodes(BaseCallback): self._total_max_episodes = self.max_episodes * self.training_env.num_envs def _on_step(self) -> bool: - # Checking for both 'done' and 'dones' keywords because: - # Some models use keyword 'done' (e.g.,: SAC, TD3, DQN, DDPG) - # While some models use keyword 'dones' (e.g.,: A2C, PPO) - done_array = np.array(self.locals.get("done") if self.locals.get("done") is not None else self.locals.get("dones")) - self.n_episodes += np.sum(done_array).item() + # Check that the `dones` local variable is defined + assert "dones" in self.locals, "`dones` variable is not defined, please check your code next to `callback.on_step()`" + self.n_episodes += np.sum(self.locals["dones"]).item() continue_training = self.n_episodes < self._total_max_episodes diff --git a/stable_baselines3/common/off_policy_algorithm.py b/stable_baselines3/common/off_policy_algorithm.py index 8b3c667..b1528f5 100644 --- a/stable_baselines3/common/off_policy_algorithm.py +++ b/stable_baselines3/common/off_policy_algorithm.py @@ -2,6 +2,7 @@ import io import pathlib import time import warnings +from copy import deepcopy from typing import Any, Dict, List, Optional, Tuple, Type, Union import gym @@ -11,7 +12,7 @@ import torch as th from stable_baselines3.common.base_class import BaseAlgorithm from stable_baselines3.common.buffers import DictReplayBuffer, ReplayBuffer from stable_baselines3.common.callbacks import BaseCallback -from stable_baselines3.common.noise import ActionNoise +from stable_baselines3.common.noise import ActionNoise, VectorizedActionNoise from stable_baselines3.common.policies import BasePolicy from stable_baselines3.common.save_util import load_from_pkl, save_to_pkl from stable_baselines3.common.type_aliases import GymEnv, MaybeCallback, RolloutReturn, Schedule, TrainFreq, TrainFrequencyUnit @@ -214,6 +215,7 @@ class OffPolicyAlgorithm(BaseAlgorithm): self.observation_space, self.action_space, self.device, + n_envs=self.n_envs, optimize_memory_usage=self.optimize_memory_usage, **self.replay_buffer_kwargs, ) @@ -382,7 +384,10 @@ class OffPolicyAlgorithm(BaseAlgorithm): raise NotImplementedError() def _sample_action( - self, learning_starts: int, action_noise: Optional[ActionNoise] = None + self, + learning_starts: int, + action_noise: Optional[ActionNoise] = None, + n_envs: int = 1, ) -> Tuple[np.ndarray, np.ndarray]: """ Sample an action according to the exploration policy. @@ -394,6 +399,7 @@ class OffPolicyAlgorithm(BaseAlgorithm): Required for deterministic policy (e.g. TD3). This can also be used in addition to the stochastic policy for SAC. :param learning_starts: Number of steps before learning for the warm-up phase. + :param n_envs: :return: action to take in the environment and scaled action that will be stored in the replay buffer. The two differs when the action space is not normalized (bounds are not [-1, 1]). @@ -401,7 +407,7 @@ class OffPolicyAlgorithm(BaseAlgorithm): # Select action randomly or according to policy if self.num_timesteps < learning_starts and not (self.use_sde and self.use_sde_at_warmup): # Warmup phase - unscaled_action = np.array([self.action_space.sample()]) + unscaled_action = np.array([self.action_space.sample() for _ in range(n_envs)]) else: # Note: when using continuous actions, # we assume that the policy uses tanh to scale the action @@ -458,9 +464,9 @@ class OffPolicyAlgorithm(BaseAlgorithm): self, replay_buffer: ReplayBuffer, buffer_action: np.ndarray, - new_obs: np.ndarray, + new_obs: Union[np.ndarray, Dict[str, np.ndarray]], reward: np.ndarray, - done: np.ndarray, + dones: np.ndarray, infos: List[Dict[str, Any]], ) -> None: """ @@ -471,9 +477,9 @@ class OffPolicyAlgorithm(BaseAlgorithm): :param replay_buffer: Replay buffer object where to store the transition. :param buffer_action: normalized action :param new_obs: next observation in the current episode - or first observation of the episode (when done is True) + or first observation of the episode (when dones is True) :param reward: reward for the current transition - :param done: Termination signal + :param dones: Termination signal :param infos: List of additional information about the transition. It may contain the terminal observations and information about timeout. """ @@ -485,22 +491,32 @@ class OffPolicyAlgorithm(BaseAlgorithm): # Avoid changing the original ones self._last_original_obs, new_obs_, reward_ = self._last_obs, new_obs, reward + # Avoid modification by reference + next_obs = deepcopy(new_obs_) # As the VecEnv resets automatically, new_obs is already the # first observation of the next episode - if done and infos[0].get("terminal_observation") is not None: - next_obs = infos[0]["terminal_observation"] - # VecNormalize normalizes the terminal observation - if self._vec_normalize_env is not None: - next_obs = self._vec_normalize_env.unnormalize_obs(next_obs) - else: - next_obs = new_obs_ + for i, done in enumerate(dones): + if done and infos[i].get("terminal_observation") is not None: + if isinstance(next_obs, dict): + next_obs_ = infos[i]["terminal_observation"] + # VecNormalize normalizes the terminal observation + if self._vec_normalize_env is not None: + next_obs_ = self._vec_normalize_env.unnormalize_obs(next_obs_) + # Replace next obs for the correct envs + for key in next_obs.keys(): + next_obs[key][i] = next_obs_[key] + else: + next_obs[i] = infos[i]["terminal_observation"] + # VecNormalize normalizes the terminal observation + if self._vec_normalize_env is not None: + next_obs[i] = self._vec_normalize_env.unnormalize_obs(next_obs[i, :]) replay_buffer.add( self._last_original_obs, next_obs, buffer_action, reward_, - done, + dones, infos, ) @@ -541,79 +557,72 @@ class OffPolicyAlgorithm(BaseAlgorithm): # Switch to eval mode (this affects batch norm / dropout) self.policy.set_training_mode(False) - episode_rewards, total_timesteps = [], [] num_collected_steps, num_collected_episodes = 0, 0 assert isinstance(env, VecEnv), "You must pass a VecEnv" - assert env.num_envs == 1, "OffPolicyAlgorithm only support single environment" assert train_freq.frequency > 0, "Should at least collect one step or episode." + if env.num_envs > 1: + assert train_freq.unit == TrainFrequencyUnit.STEP, "You must use only one env when doing episodic training." + + # Vectorize action noise if needed + if action_noise is not None and env.num_envs > 1 and not isinstance(action_noise, VectorizedActionNoise): + action_noise = VectorizedActionNoise(action_noise, env.num_envs) + if self.use_sde: - self.actor.reset_noise() + self.actor.reset_noise(env.num_envs) callback.on_rollout_start() continue_training = True while should_collect_more_steps(train_freq, num_collected_steps, num_collected_episodes): - done = False - episode_reward, episode_timesteps = 0.0, 0 + if self.use_sde and self.sde_sample_freq > 0 and num_collected_steps % self.sde_sample_freq == 0: + # Sample a new noise matrix + self.actor.reset_noise(env.num_envs) - while not done: + # Select action randomly or according to policy + actions, buffer_actions = self._sample_action(learning_starts, action_noise, env.num_envs) - if self.use_sde and self.sde_sample_freq > 0 and num_collected_steps % self.sde_sample_freq == 0: - # Sample a new noise matrix - self.actor.reset_noise() + # Rescale and perform action + new_obs, rewards, dones, infos = env.step(actions) - # Select action randomly or according to policy - action, buffer_action = self._sample_action(learning_starts, action_noise) + self.num_timesteps += env.num_envs + num_collected_steps += 1 - # Rescale and perform action - new_obs, reward, done, infos = env.step(action) + # Give access to local variables + callback.update_locals(locals()) + # Only stop training if return value is False, not when it is None. + if callback.on_step() is False: + return RolloutReturn(num_collected_steps * env.num_envs, num_collected_episodes, continue_training=False) - self.num_timesteps += 1 - episode_timesteps += 1 - num_collected_steps += 1 + # Retrieve reward and episode length if using Monitor wrapper + self._update_info_buffer(infos, dones) - # Give access to local variables - callback.update_locals(locals()) - # Only stop training if return value is False, not when it is None. - if callback.on_step() is False: - return RolloutReturn(0.0, num_collected_steps, num_collected_episodes, continue_training=False) + # Store data in replay buffer (normalized action and unnormalized observation) + self._store_transition(replay_buffer, buffer_actions, new_obs, rewards, dones, infos) - episode_reward += reward + self._update_current_progress_remaining(self.num_timesteps, self._total_timesteps) - # Retrieve reward and episode length if using Monitor wrapper - self._update_info_buffer(infos, done) + # For DQN, check if the target network should be updated + # and update the exploration schedule + # For SAC/TD3, the update is dones as the same time as the gradient update + # see https://github.com/hill-a/stable-baselines/issues/900 + self._on_step() - # Store data in replay buffer (normalized action and unnormalized observation) - self._store_transition(replay_buffer, buffer_action, new_obs, reward, done, infos) + for idx, done in enumerate(dones): + if done: + # Update stats + num_collected_episodes += 1 + self._episode_num += 1 - self._update_current_progress_remaining(self.num_timesteps, self._total_timesteps) + if action_noise is not None: + kwargs = dict(indices=[idx]) if env.num_envs > 1 else {} + action_noise.reset(**kwargs) - # For DQN, check if the target network should be updated - # and update the exploration schedule - # For SAC/TD3, the update is done as the same time as the gradient update - # see https://github.com/hill-a/stable-baselines/issues/900 - self._on_step() - - if not should_collect_more_steps(train_freq, num_collected_steps, num_collected_episodes): - break - - if done: - num_collected_episodes += 1 - self._episode_num += 1 - episode_rewards.append(episode_reward) - total_timesteps.append(episode_timesteps) - - if action_noise is not None: - action_noise.reset() - - # Log training infos - if log_interval is not None and self._episode_num % log_interval == 0: - self._dump_logs() - - mean_reward = np.mean(episode_rewards) if num_collected_episodes > 0 else 0.0 + # Log training infos + if log_interval is not None and self._episode_num % log_interval == 0: + self._dump_logs() callback.on_rollout_end() - return RolloutReturn(mean_reward, num_collected_steps, num_collected_episodes, continue_training) + return RolloutReturn(num_collected_steps * env.num_envs, num_collected_episodes, continue_training) diff --git a/stable_baselines3/common/on_policy_algorithm.py b/stable_baselines3/common/on_policy_algorithm.py index 0aff9bb..a58d331 100644 --- a/stable_baselines3/common/on_policy_algorithm.py +++ b/stable_baselines3/common/on_policy_algorithm.py @@ -193,9 +193,9 @@ class OnPolicyAlgorithm(BaseAlgorithm): # Handle timeout by bootstraping with value function # see GitHub issue #633 - for idx, done_ in enumerate(dones): + for idx, done in enumerate(dones): if ( - done_ + done and infos[idx].get("terminal_observation") is not None and infos[idx].get("TimeLimit.truncated", False) ): diff --git a/stable_baselines3/common/type_aliases.py b/stable_baselines3/common/type_aliases.py index 45db9eb..7e69d39 100644 --- a/stable_baselines3/common/type_aliases.py +++ b/stable_baselines3/common/type_aliases.py @@ -56,7 +56,6 @@ class DictReplayBufferSamples(ReplayBufferSamples): class RolloutReturn(NamedTuple): - episode_reward: float episode_timesteps: int n_episodes: int continue_training: bool diff --git a/stable_baselines3/common/utils.py b/stable_baselines3/common/utils.py index 7548cdc..e0dfcb9 100644 --- a/stable_baselines3/common/utils.py +++ b/stable_baselines3/common/utils.py @@ -332,8 +332,8 @@ def is_vectorized_dict_observation(observation: np.ndarray, observation_space: g return True else: raise ValueError( - f"Error: Unexpected observation shape {observation.shape} for " - + f"Tuple environment, please use {(obs.shape for obs in observation_space.spaces)} " + f"Error: Unexpected observation shape {observation[key].shape} for key {key}, " + + f"please use {observation_space.spaces[key]} " ) diff --git a/stable_baselines3/dqn/dqn.py b/stable_baselines3/dqn/dqn.py index 11e7ac7..668d729 100644 --- a/stable_baselines3/dqn/dqn.py +++ b/stable_baselines3/dqn/dqn.py @@ -1,3 +1,4 @@ +import warnings from typing import Any, Dict, List, Optional, Tuple, Type, Union import gym @@ -111,12 +112,15 @@ class DQN(OffPolicyAlgorithm): sde_support=False, optimize_memory_usage=optimize_memory_usage, supported_action_spaces=(gym.spaces.Discrete,), + support_multi_env=True, ) self.exploration_initial_eps = exploration_initial_eps self.exploration_final_eps = exploration_final_eps self.exploration_fraction = exploration_fraction self.target_update_interval = target_update_interval + # For updating the target network with multiple envs: + self._n_calls = 0 self.max_grad_norm = max_grad_norm # "epsilon" for the epsilon-greedy exploration self.exploration_rate = 0.0 @@ -135,6 +139,18 @@ class DQN(OffPolicyAlgorithm): self.exploration_final_eps, self.exploration_fraction, ) + # Account for multiple environments + # each call to step() corresponds to n_envs transitions + if self.n_envs > 1: + if self.n_envs > self.target_update_interval: + warnings.warn( + "The number of environments used is greater than the target network " + f"update interval ({self.n_envs} > {self.target_update_interval}), " + "therefore the target network will be updated after each call to env.step() " + f"which corresponds to {self.n_envs} steps." + ) + + self.target_update_interval = max(self.target_update_interval // self.n_envs, 1) def _create_aliases(self) -> None: self.q_net = self.policy.q_net @@ -145,7 +161,8 @@ class DQN(OffPolicyAlgorithm): Update the exploration rate and target network if needed. This method is called in ``collect_rollouts()`` after each step in the environment. """ - if self.num_timesteps % self.target_update_interval == 0: + self._n_calls += 1 + if self._n_calls % self.target_update_interval == 0: polyak_update(self.q_net.parameters(), self.q_net_target.parameters(), self.tau) self.exploration_rate = self.exploration_schedule(self._current_progress_remaining) diff --git a/stable_baselines3/sac/sac.py b/stable_baselines3/sac/sac.py index e9502fd..f7059d7 100644 --- a/stable_baselines3/sac/sac.py +++ b/stable_baselines3/sac/sac.py @@ -129,6 +129,7 @@ class SAC(OffPolicyAlgorithm): use_sde_at_warmup=use_sde_at_warmup, optimize_memory_usage=optimize_memory_usage, supported_action_spaces=(gym.spaces.Box), + support_multi_env=True, ) self.target_entropy = target_entropy diff --git a/stable_baselines3/td3/td3.py b/stable_baselines3/td3/td3.py index e059761..eb257a6 100644 --- a/stable_baselines3/td3/td3.py +++ b/stable_baselines3/td3/td3.py @@ -112,6 +112,7 @@ class TD3(OffPolicyAlgorithm): sde_support=False, optimize_memory_usage=optimize_memory_usage, supported_action_spaces=(gym.spaces.Box), + support_multi_env=True, ) self.policy_delay = policy_delay diff --git a/stable_baselines3/version.txt b/stable_baselines3/version.txt index 896c1f3..aca78c7 100644 --- a/stable_baselines3/version.txt +++ b/stable_baselines3/version.txt @@ -1 +1 @@ -1.3.1a3 +1.3.1a4 diff --git a/tests/test_dict_env.py b/tests/test_dict_env.py index f781999..93b13b4 100644 --- a/tests/test_dict_env.py +++ b/tests/test_dict_env.py @@ -184,19 +184,19 @@ def test_dict_spaces(model_class, channel_last): evaluate_policy(model, env, n_eval_episodes=5, warn=False) -@pytest.mark.parametrize("model_class", [PPO, A2C]) +@pytest.mark.parametrize("model_class", [PPO, A2C, SAC, DQN]) def test_multiprocessing(model_class): use_discrete_actions = model_class not in [SAC, TD3, DDPG] def make_env(): env = DummyDictEnv(use_discrete_actions=use_discrete_actions, channel_last=False) - env = gym.wrappers.TimeLimit(env, 100) + env = gym.wrappers.TimeLimit(env, 50) return env env = make_vec_env(make_env, n_envs=2, vec_env_cls=SubprocVecEnv) kwargs = {} - n_steps = 256 + n_steps = 128 if model_class in {A2C, PPO}: kwargs = dict( @@ -206,6 +206,15 @@ def test_multiprocessing(model_class): features_extractor_kwargs=dict(cnn_output_dim=32), ), ) + elif model_class in {SAC, TD3, DQN}: + kwargs = dict( + buffer_size=1000, + policy_kwargs=dict( + net_arch=[32], + features_extractor_kwargs=dict(cnn_output_dim=16), + ), + train_freq=5, + ) model = model_class("MultiInputPolicy", env, gamma=0.5, seed=1, **kwargs) diff --git a/tests/test_run.py b/tests/test_run.py index c588a02..67b31c4 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,7 +1,9 @@ +import gym import numpy as np import pytest from stable_baselines3 import A2C, DDPG, DQN, PPO, SAC, TD3 +from stable_baselines3.common.env_util import make_vec_env from stable_baselines3.common.noise import NormalActionNoise, OrnsteinUhlenbeckActionNoise normal_action_noise = NormalActionNoise(np.zeros(1), 0.1 * np.ones(1)) @@ -139,3 +141,65 @@ def test_train_freq_fail(train_freq): train_freq=train_freq, ) model.learn(total_timesteps=250) + + +@pytest.mark.parametrize("model_class", [SAC, TD3, DDPG, DQN]) +def test_offpolicy_multi_env(model_class): + kwargs = {} + if model_class in [SAC, TD3, DDPG]: + env_id = "Pendulum-v0" + policy_kwargs = dict(net_arch=[64], n_critics=1) + # Check auto-conversion to VectorizedActionNoise + kwargs = dict(action_noise=NormalActionNoise(np.zeros(1), 0.1 * np.ones(1))) + if model_class == SAC: + kwargs["use_sde"] = True + kwargs["sde_sample_freq"] = 4 + else: + env_id = "CartPole-v1" + policy_kwargs = dict(net_arch=[64]) + + def make_env(): + env = gym.make(env_id) + # to check that the code handling timeouts runs + env = gym.wrappers.TimeLimit(env, 50) + return env + + env = make_vec_env(make_env, n_envs=2) + model = model_class( + "MlpPolicy", + env, + policy_kwargs=policy_kwargs, + learning_starts=100, + buffer_size=10000, + verbose=0, + train_freq=5, + **kwargs, + ) + model.learn(total_timesteps=150) + + # Check that gradient_steps=-1 works as expected: + # perform as many gradient_steps as transitions collected + train_freq = 3 + model = model_class( + "MlpPolicy", + env, + policy_kwargs=policy_kwargs, + learning_starts=0, + buffer_size=10000, + verbose=0, + train_freq=train_freq, + gradient_steps=-1, + **kwargs, + ) + model.learn(total_timesteps=train_freq) + assert model.logger.name_to_value["train/n_updates"] == train_freq * env.num_envs + + +def test_warn_dqn_multi_env(): + with pytest.warns(UserWarning, match="The number of environments used is greater"): + DQN( + "MlpPolicy", + make_vec_env("CartPole-v1", n_envs=2), + buffer_size=100, + target_update_interval=1, + ) diff --git a/tests/test_spaces.py b/tests/test_spaces.py index 1c66d04..deb09c4 100644 --- a/tests/test_spaces.py +++ b/tests/test_spaces.py @@ -69,8 +69,13 @@ def test_action_spaces(model_class, env): model_class("MlpPolicy", env) -@pytest.mark.parametrize("model_class", [A2C, PPO]) +@pytest.mark.parametrize("model_class", [A2C, PPO, DQN]) @pytest.mark.parametrize("env", ["Taxi-v3"]) def test_discrete_obs_space(model_class, env): env = make_vec_env(env, n_envs=2, seed=0) - model_class("MlpPolicy", env, n_steps=256).learn(500) + kwargs = {} + if model_class == DQN: + kwargs = dict(buffer_size=1000, learning_starts=100) + else: + kwargs = dict(n_steps=256) + model_class("MlpPolicy", env, **kwargs).learn(256)