chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Proximal Policy Optimization (PPO)
|
||||
|
||||
## Overview
|
||||
|
||||
[PPO](https://arxiv.org/abs/1707.06347) is a model-free on-policy RL algorithm that works
|
||||
well for both discrete and continuous action space environments. PPO utilizes an
|
||||
actor-critic framework, where there are two networks, an actor (policy network) and
|
||||
critic network (value function).
|
||||
|
||||
There are two formulations of PPO, which are both implemented in RLlib. The first
|
||||
formulation of PPO imitates the prior paper [TRPO](https://arxiv.org/abs/1502.05477)
|
||||
without the complexity of second-order optimization. In this formulation, for every
|
||||
iteration, an old version of an actor-network is saved and the agent seeks to optimize
|
||||
the RL objective while staying close to the old policy. This makes sure that the agent
|
||||
does not destabilize during training. In the second formulation, To mitigate destructive
|
||||
large policy updates, an issue discovered for vanilla policy gradient methods, PPO
|
||||
introduces the surrogate objective, which clips large action probability ratios between
|
||||
the current and old policy. Clipping has been shown in the paper to significantly
|
||||
improve training stability and speed.
|
||||
|
||||
## Distributed PPO Algorithms
|
||||
|
||||
PPO is a core algorithm in RLlib due to its ability to scale well with the number of nodes.
|
||||
|
||||
In RLlib, we provide various implementations of distributed PPO, with different underlying
|
||||
execution plans, as shown below:
|
||||
|
||||
### Distributed baseline PPO ..
|
||||
.. is a synchronous distributed RL algorithm (this algo here).
|
||||
Data collection nodes, which represent the old policy, gather data synchronously to
|
||||
create a large pool of on-policy data from which the agent performs minibatch
|
||||
gradient descent on.
|
||||
|
||||
### Asychronous PPO (APPO)
|
||||
|
||||
[See implementation here](https://github.com/ray-project/ray/blob/master/rllib/algorithms/appo/appo.py)
|
||||
|
||||
### Decentralized Distributed PPO (DDPPO)
|
||||
|
||||
[See implementation here](https://github.com/ray-project/ray/blob/master/rllib/algorithms/ddppo/ddppo.py)
|
||||
|
||||
|
||||
## Documentation & Implementation:
|
||||
|
||||
### Proximal Policy Optimization (PPO).
|
||||
|
||||
**[Detailed Documentation](https://docs.ray.io/en/master/rllib-algorithms.html#ppo)**
|
||||
|
||||
**[Implementation](https://github.com/ray-project/ray/blob/master/rllib/algorithms/ppo/ppo.py)**
|
||||
@@ -0,0 +1,12 @@
|
||||
from ray.rllib.algorithms.ppo.ppo import PPO, PPOConfig
|
||||
from ray.rllib.algorithms.ppo.ppo_tf_policy import PPOTF1Policy, PPOTF2Policy
|
||||
from ray.rllib.algorithms.ppo.ppo_torch_policy import PPOTorchPolicy
|
||||
|
||||
__all__ = [
|
||||
"PPO",
|
||||
"PPOConfig",
|
||||
# @OldAPIStack
|
||||
"PPOTF1Policy",
|
||||
"PPOTF2Policy",
|
||||
"PPOTorchPolicy",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
import abc
|
||||
from typing import List
|
||||
|
||||
from ray.rllib.core.models.configs import RecurrentEncoderConfig
|
||||
from ray.rllib.core.rl_module.apis import InferenceOnlyAPI, ValueFunctionAPI
|
||||
from ray.rllib.core.rl_module.rl_module import RLModule
|
||||
from ray.rllib.utils.annotations import (
|
||||
OverrideToImplementCustomLogic_CallToSuperRecommended,
|
||||
override,
|
||||
)
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DefaultPPORLModule(RLModule, InferenceOnlyAPI, ValueFunctionAPI, abc.ABC):
|
||||
"""Default RLModule used by PPO, if user does not specify a custom RLModule.
|
||||
|
||||
Users who want to train their RLModules with PPO may implement any RLModule
|
||||
(or TorchRLModule) subclass as long as the custom class also implements the
|
||||
`ValueFunctionAPI` (see ray.rllib.core.rl_module.apis.value_function_api.py)
|
||||
"""
|
||||
|
||||
@override(RLModule)
|
||||
def setup(self):
|
||||
# __sphinx_doc_begin__
|
||||
# If we have a stateful model, states for the critic need to be collected
|
||||
# during sampling and `inference-only` needs to be `False`. Note, at this
|
||||
# point the encoder is not built, yet and therefore `is_stateful()` does
|
||||
# not work.
|
||||
is_stateful = isinstance(
|
||||
self.catalog.actor_critic_encoder_config.base_encoder_config,
|
||||
RecurrentEncoderConfig,
|
||||
)
|
||||
if is_stateful:
|
||||
self.inference_only = False
|
||||
# If this is an `inference_only` Module, we'll have to pass this information
|
||||
# to the encoder config as well.
|
||||
if self.inference_only and self.framework == "torch":
|
||||
self.catalog.actor_critic_encoder_config.inference_only = True
|
||||
|
||||
# Build models from catalog.
|
||||
self.encoder = self.catalog.build_actor_critic_encoder(framework=self.framework)
|
||||
self.pi = self.catalog.build_pi_head(framework=self.framework)
|
||||
self.vf = self.catalog.build_vf_head(framework=self.framework)
|
||||
# __sphinx_doc_end__
|
||||
|
||||
@override(RLModule)
|
||||
def get_initial_state(self) -> dict:
|
||||
if hasattr(self.encoder, "get_initial_state"):
|
||||
return self.encoder.get_initial_state()
|
||||
else:
|
||||
return {}
|
||||
|
||||
@OverrideToImplementCustomLogic_CallToSuperRecommended
|
||||
@override(InferenceOnlyAPI)
|
||||
def get_non_inference_attributes(self) -> List[str]:
|
||||
"""Return attributes, which are NOT inference-only (only used for training)."""
|
||||
return ["vf"] + (
|
||||
[]
|
||||
if self.model_config.get("vf_share_layers")
|
||||
else ["encoder.critic_encoder"]
|
||||
)
|
||||
@@ -0,0 +1,570 @@
|
||||
"""
|
||||
Proximal Policy Optimization (PPO)
|
||||
==================================
|
||||
|
||||
This file defines the distributed Algorithm class for proximal policy
|
||||
optimization.
|
||||
See `ppo_[tf|torch]_policy.py` for the definition of the policy loss.
|
||||
|
||||
Detailed documentation: https://docs.ray.io/en/master/rllib-algorithms.html#ppo
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from ray._common.deprecation import DEPRECATED_VALUE
|
||||
from ray.rllib.algorithms.algorithm import Algorithm
|
||||
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig, NotProvided
|
||||
from ray.rllib.core.rl_module.rl_module import RLModuleSpec
|
||||
from ray.rllib.execution.rollout_ops import (
|
||||
standardize_fields,
|
||||
synchronous_parallel_sample,
|
||||
)
|
||||
from ray.rllib.execution.train_ops import (
|
||||
multi_gpu_train_one_step,
|
||||
train_one_step,
|
||||
)
|
||||
from ray.rllib.policy.policy import Policy
|
||||
from ray.rllib.utils.annotations import OldAPIStack, override
|
||||
from ray.rllib.utils.metrics import (
|
||||
ALL_MODULES,
|
||||
ENV_RUNNER_RESULTS,
|
||||
ENV_RUNNER_SAMPLING_TIMER,
|
||||
LEARNER_RESULTS,
|
||||
LEARNER_UPDATE_TIMER,
|
||||
NUM_AGENT_STEPS_SAMPLED,
|
||||
NUM_ENV_STEPS_SAMPLED,
|
||||
NUM_ENV_STEPS_SAMPLED_LIFETIME,
|
||||
NUM_MODULE_STEPS_TRAINED_LIFETIME,
|
||||
SAMPLE_TIMER,
|
||||
SYNCH_WORKER_WEIGHTS_TIMER,
|
||||
TIMERS,
|
||||
)
|
||||
from ray.rllib.utils.metrics.learner_info import LEARNER_STATS_KEY
|
||||
from ray.rllib.utils.schedules.scheduler import Scheduler
|
||||
from ray.rllib.utils.typing import ResultDict
|
||||
from ray.util.debug import log_once
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.rllib.core.learner.learner import Learner
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LEARNER_RESULTS_VF_LOSS_UNCLIPPED_KEY = "vf_loss_unclipped"
|
||||
LEARNER_RESULTS_VF_EXPLAINED_VAR_KEY = "vf_explained_var"
|
||||
LEARNER_RESULTS_KL_KEY = "mean_kl_loss"
|
||||
LEARNER_RESULTS_CURR_KL_COEFF_KEY = "curr_kl_coeff"
|
||||
LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY = "curr_entropy_coeff"
|
||||
|
||||
|
||||
class PPOConfig(AlgorithmConfig):
|
||||
"""Defines a configuration class from which a PPO Algorithm can be built.
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray.rllib.algorithms.ppo import PPOConfig
|
||||
|
||||
config = PPOConfig()
|
||||
config.environment("CartPole-v1")
|
||||
config.env_runners(num_env_runners=1)
|
||||
config.training(
|
||||
gamma=0.9, lr=0.01, kl_coeff=0.3, train_batch_size_per_learner=256
|
||||
)
|
||||
|
||||
# Build a Algorithm object from the config and run 1 training iteration.
|
||||
algo = config.build()
|
||||
algo.train()
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray.rllib.algorithms.ppo import PPOConfig
|
||||
from ray import tune
|
||||
|
||||
config = (
|
||||
PPOConfig()
|
||||
# Set the config object's env.
|
||||
.environment(env="CartPole-v1")
|
||||
# Update the config object's training parameters.
|
||||
.training(
|
||||
lr=0.001, clip_param=0.2
|
||||
)
|
||||
)
|
||||
|
||||
tune.Tuner(
|
||||
"PPO",
|
||||
run_config=tune.RunConfig(stop={"training_iteration": 1}),
|
||||
param_space=config,
|
||||
).fit()
|
||||
|
||||
.. testoutput::
|
||||
:hide:
|
||||
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, algo_class=None):
|
||||
"""Initializes a PPOConfig instance."""
|
||||
self.exploration_config = {
|
||||
# The Exploration class to use. In the simplest case, this is the name
|
||||
# (str) of any class present in the `rllib.utils.exploration` package.
|
||||
# You can also provide the python class directly or the full location
|
||||
# of your class (e.g. "ray.rllib.utils.exploration.epsilon_greedy.
|
||||
# EpsilonGreedy").
|
||||
"type": "StochasticSampling",
|
||||
# Add constructor kwargs here (if any).
|
||||
}
|
||||
|
||||
super().__init__(algo_class=algo_class or PPO)
|
||||
|
||||
# fmt: off
|
||||
# __sphinx_doc_begin__
|
||||
self.lr = 5e-5
|
||||
self.rollout_fragment_length = "auto"
|
||||
self.train_batch_size = 4000
|
||||
|
||||
# PPO specific settings:
|
||||
self.use_critic = True
|
||||
self.use_gae = True
|
||||
self.num_epochs = 30
|
||||
self.minibatch_size = 128
|
||||
self.shuffle_batch_per_epoch = True
|
||||
self.lambda_ = 1.0
|
||||
self.use_kl_loss = True
|
||||
self.kl_coeff = 0.2
|
||||
self.kl_target = 0.01
|
||||
self.vf_loss_coeff = 1.0
|
||||
self.entropy_coeff = 0.0
|
||||
self.clip_param = 0.3
|
||||
self.vf_clip_param = 10.0
|
||||
self.grad_clip = None
|
||||
|
||||
# Override some of AlgorithmConfig's default values with PPO-specific values.
|
||||
self.num_env_runners = 2
|
||||
# __sphinx_doc_end__
|
||||
# fmt: on
|
||||
|
||||
self.model["vf_share_layers"] = False # @OldAPIStack
|
||||
self.entropy_coeff_schedule = None # @OldAPIStack
|
||||
self.lr_schedule = None # @OldAPIStack
|
||||
|
||||
# Deprecated keys.
|
||||
self.sgd_minibatch_size = DEPRECATED_VALUE
|
||||
self.vf_share_layers = DEPRECATED_VALUE
|
||||
|
||||
@override(AlgorithmConfig)
|
||||
def get_default_rl_module_spec(self) -> RLModuleSpec:
|
||||
if self.framework_str == "torch":
|
||||
from ray.rllib.algorithms.ppo.torch.default_ppo_torch_rl_module import (
|
||||
DefaultPPOTorchRLModule,
|
||||
)
|
||||
|
||||
return RLModuleSpec(module_class=DefaultPPOTorchRLModule)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"The framework {self.framework_str} is not supported. "
|
||||
"Use either 'torch' or 'tf2'."
|
||||
)
|
||||
|
||||
@override(AlgorithmConfig)
|
||||
def get_default_learner_class(self) -> Union[Type["Learner"], str]:
|
||||
if self.framework_str == "torch":
|
||||
from ray.rllib.algorithms.ppo.torch.ppo_torch_learner import (
|
||||
PPOTorchLearner,
|
||||
)
|
||||
|
||||
return PPOTorchLearner
|
||||
elif self.framework_str in ["tf2", "tf"]:
|
||||
raise ValueError(
|
||||
"TensorFlow is no longer supported on the new API stack! "
|
||||
"Use `framework='torch'`."
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"The framework {self.framework_str} is not supported. "
|
||||
"Use `framework='torch'`."
|
||||
)
|
||||
|
||||
@override(AlgorithmConfig)
|
||||
def training(
|
||||
self,
|
||||
*,
|
||||
use_critic: Optional[bool] = NotProvided,
|
||||
use_gae: Optional[bool] = NotProvided,
|
||||
lambda_: Optional[float] = NotProvided,
|
||||
use_kl_loss: Optional[bool] = NotProvided,
|
||||
kl_coeff: Optional[float] = NotProvided,
|
||||
kl_target: Optional[float] = NotProvided,
|
||||
vf_loss_coeff: Optional[float] = NotProvided,
|
||||
entropy_coeff: Optional[float] = NotProvided,
|
||||
entropy_coeff_schedule: Optional[List[List[Union[int, float]]]] = NotProvided,
|
||||
clip_param: Optional[float] = NotProvided,
|
||||
vf_clip_param: Optional[float] = NotProvided,
|
||||
grad_clip: Optional[float] = NotProvided,
|
||||
# @OldAPIStack
|
||||
lr_schedule: Optional[List[List[Union[int, float]]]] = NotProvided,
|
||||
# Deprecated.
|
||||
vf_share_layers=DEPRECATED_VALUE,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
"""Sets the training related configuration.
|
||||
|
||||
Args:
|
||||
use_critic: Should use a critic as a baseline (otherwise don't use value
|
||||
baseline; required for using GAE).
|
||||
use_gae: If true, use the Generalized Advantage Estimator (GAE)
|
||||
with a value function, see https://arxiv.org/pdf/1506.02438.pdf.
|
||||
lambda_: The lambda parameter for General Advantage Estimation (GAE).
|
||||
Defines the exponential weight used between actually measured rewards
|
||||
vs value function estimates over multiple time steps. Specifically,
|
||||
`lambda_` balances short-term, low-variance estimates against long-term,
|
||||
high-variance returns. A `lambda_` of 0.0 makes the GAE rely only on
|
||||
immediate rewards (and vf predictions from there on, reducing variance,
|
||||
but increasing bias), while a `lambda_` of 1.0 only incorporates vf
|
||||
predictions at the truncation points of the given episodes or episode
|
||||
chunks (reducing bias but increasing variance).
|
||||
use_kl_loss: Whether to use the KL-term in the loss function.
|
||||
kl_coeff: Initial coefficient for KL divergence.
|
||||
kl_target: Target value for KL divergence.
|
||||
vf_loss_coeff: Coefficient of the value function loss. IMPORTANT: you must
|
||||
tune this if you set vf_share_layers=True inside your model's config.
|
||||
entropy_coeff: The entropy coefficient (float) or entropy coefficient
|
||||
schedule in the format of
|
||||
[[timestep, coeff-value], [timestep, coeff-value], ...]
|
||||
In case of a schedule, intermediary timesteps will be assigned to
|
||||
linearly interpolated coefficient values. A schedule config's first
|
||||
entry must start with timestep 0, i.e.: [[0, initial_value], [...]].
|
||||
clip_param: The PPO clip parameter.
|
||||
vf_clip_param: Clip param for the value function. Note that this is
|
||||
sensitive to the scale of the rewards. If your expected V is large,
|
||||
increase this.
|
||||
grad_clip: If specified, clip the global norm of gradients by this amount.
|
||||
|
||||
Returns:
|
||||
This updated AlgorithmConfig object.
|
||||
"""
|
||||
# Pass kwargs onto super's `training()` method.
|
||||
super().training(**kwargs)
|
||||
|
||||
if use_critic is not NotProvided:
|
||||
self.use_critic = use_critic
|
||||
# TODO (Kourosh) This is experimental.
|
||||
# Don't forget to remove .use_critic from algorithm config.
|
||||
if use_gae is not NotProvided:
|
||||
self.use_gae = use_gae
|
||||
if lambda_ is not NotProvided:
|
||||
self.lambda_ = lambda_
|
||||
if use_kl_loss is not NotProvided:
|
||||
self.use_kl_loss = use_kl_loss
|
||||
if kl_coeff is not NotProvided:
|
||||
self.kl_coeff = kl_coeff
|
||||
if kl_target is not NotProvided:
|
||||
self.kl_target = kl_target
|
||||
if vf_loss_coeff is not NotProvided:
|
||||
self.vf_loss_coeff = vf_loss_coeff
|
||||
if entropy_coeff is not NotProvided:
|
||||
self.entropy_coeff = entropy_coeff
|
||||
if clip_param is not NotProvided:
|
||||
self.clip_param = clip_param
|
||||
if vf_clip_param is not NotProvided:
|
||||
self.vf_clip_param = vf_clip_param
|
||||
if grad_clip is not NotProvided:
|
||||
self.grad_clip = grad_clip
|
||||
|
||||
# TODO (sven): Remove these once new API stack is only option for PPO.
|
||||
if lr_schedule is not NotProvided:
|
||||
self.lr_schedule = lr_schedule
|
||||
if entropy_coeff_schedule is not NotProvided:
|
||||
self.entropy_coeff_schedule = entropy_coeff_schedule
|
||||
|
||||
return self
|
||||
|
||||
@override(AlgorithmConfig)
|
||||
def validate(self) -> None:
|
||||
# Call super's validation method.
|
||||
super().validate()
|
||||
|
||||
# Synchronous sampling, on-policy/PPO algos -> Check mismatches between
|
||||
# `rollout_fragment_length` and `train_batch_size_per_learner` to avoid user
|
||||
# confusion.
|
||||
# TODO (sven): Make rollout_fragment_length a property and create a private
|
||||
# attribute to store (possibly) user provided value (or "auto") in. Deprecate
|
||||
# `self.get_rollout_fragment_length()`.
|
||||
self.validate_train_batch_size_vs_rollout_fragment_length()
|
||||
|
||||
# SGD minibatch size must be smaller than train_batch_size (b/c
|
||||
# we subsample a batch of `minibatch_size` from the train-batch for
|
||||
# each `num_epochs`).
|
||||
if (
|
||||
not self.enable_rl_module_and_learner
|
||||
and self.minibatch_size > self.train_batch_size
|
||||
):
|
||||
self._value_error(
|
||||
f"`minibatch_size` ({self.minibatch_size}) must be <= "
|
||||
f"`train_batch_size` ({self.train_batch_size}). In PPO, the train batch"
|
||||
f" will be split into {self.minibatch_size} chunks, each of which "
|
||||
f"is iterated over (used for updating the policy) {self.num_epochs} "
|
||||
"times."
|
||||
)
|
||||
elif self.enable_rl_module_and_learner:
|
||||
mbs = self.minibatch_size
|
||||
tbs = self.train_batch_size_per_learner or self.train_batch_size
|
||||
if isinstance(mbs, int) and isinstance(tbs, int) and mbs > tbs:
|
||||
self._value_error(
|
||||
f"`minibatch_size` ({mbs}) must be <= "
|
||||
f"`train_batch_size_per_learner` ({tbs}). In PPO, the train batch"
|
||||
f" will be split into {mbs} chunks, each of which is iterated over "
|
||||
f"(used for updating the policy) {self.num_epochs} times."
|
||||
)
|
||||
|
||||
# Episodes may only be truncated (and passed into PPO's
|
||||
# `postprocessing_fn`), iff generalized advantage estimation is used
|
||||
# (value function estimate at end of truncated episode to estimate
|
||||
# remaining value).
|
||||
if (
|
||||
not self.in_evaluation
|
||||
and self.batch_mode == "truncate_episodes"
|
||||
and not self.use_gae
|
||||
):
|
||||
self._value_error(
|
||||
"Episode truncation is not supported without a value "
|
||||
"function (to estimate the return at the end of the truncated"
|
||||
" trajectory). Consider setting "
|
||||
"batch_mode=complete_episodes."
|
||||
)
|
||||
|
||||
# New API stack checks.
|
||||
if self.enable_rl_module_and_learner:
|
||||
# `lr_schedule` checking.
|
||||
if self.lr_schedule is not None:
|
||||
self._value_error(
|
||||
"`lr_schedule` is deprecated and must be None! Use the "
|
||||
"`lr` setting to setup a schedule."
|
||||
)
|
||||
if self.entropy_coeff_schedule is not None:
|
||||
self._value_error(
|
||||
"`entropy_coeff_schedule` is deprecated and must be None! Use the "
|
||||
"`entropy_coeff` setting to setup a schedule."
|
||||
)
|
||||
Scheduler.validate(
|
||||
fixed_value_or_schedule=self.entropy_coeff,
|
||||
setting_name="entropy_coeff",
|
||||
description="entropy coefficient",
|
||||
)
|
||||
if isinstance(self.entropy_coeff, float) and self.entropy_coeff < 0.0:
|
||||
self._value_error("`entropy_coeff` must be >= 0.0")
|
||||
|
||||
@property
|
||||
@override(AlgorithmConfig)
|
||||
def _model_config_auto_includes(self) -> Dict[str, Any]:
|
||||
return super()._model_config_auto_includes | {"vf_share_layers": False}
|
||||
|
||||
|
||||
class PPO(Algorithm):
|
||||
@classmethod
|
||||
@override(Algorithm)
|
||||
def get_default_config(cls) -> PPOConfig:
|
||||
return PPOConfig()
|
||||
|
||||
@classmethod
|
||||
@override(Algorithm)
|
||||
def get_default_policy_class(
|
||||
cls, config: AlgorithmConfig
|
||||
) -> Optional[Type[Policy]]:
|
||||
if config["framework"] == "torch":
|
||||
|
||||
from ray.rllib.algorithms.ppo.ppo_torch_policy import PPOTorchPolicy
|
||||
|
||||
return PPOTorchPolicy
|
||||
elif config["framework"] == "tf":
|
||||
from ray.rllib.algorithms.ppo.ppo_tf_policy import PPOTF1Policy
|
||||
|
||||
return PPOTF1Policy
|
||||
else:
|
||||
from ray.rllib.algorithms.ppo.ppo_tf_policy import PPOTF2Policy
|
||||
|
||||
return PPOTF2Policy
|
||||
|
||||
@override(Algorithm)
|
||||
def training_step(self) -> None:
|
||||
# Old API stack (Policy, RolloutWorker, Connector).
|
||||
if not self.config.enable_env_runner_and_connector_v2:
|
||||
return self._training_step_old_api_stack()
|
||||
|
||||
# Collect batches from sample workers until we have a full batch.
|
||||
with self.metrics.log_time((TIMERS, ENV_RUNNER_SAMPLING_TIMER)):
|
||||
# Sample in parallel from the workers.
|
||||
if self.config.count_steps_by == "agent_steps":
|
||||
episodes, env_runner_results = synchronous_parallel_sample(
|
||||
worker_set=self.env_runner_group,
|
||||
max_agent_steps=self.config.total_train_batch_size,
|
||||
sample_timeout_s=self.config.sample_timeout_s,
|
||||
_uses_new_env_runners=(
|
||||
self.config.enable_env_runner_and_connector_v2
|
||||
),
|
||||
_return_metrics=True,
|
||||
)
|
||||
else:
|
||||
episodes, env_runner_results = synchronous_parallel_sample(
|
||||
worker_set=self.env_runner_group,
|
||||
max_env_steps=self.config.total_train_batch_size,
|
||||
sample_timeout_s=self.config.sample_timeout_s,
|
||||
_uses_new_env_runners=(
|
||||
self.config.enable_env_runner_and_connector_v2
|
||||
),
|
||||
_return_metrics=True,
|
||||
)
|
||||
# Return early if all our workers failed.
|
||||
if not episodes:
|
||||
return
|
||||
|
||||
# Reduce EnvRunner metrics over the n EnvRunners.
|
||||
self.metrics.aggregate(env_runner_results, key=ENV_RUNNER_RESULTS)
|
||||
|
||||
# Perform a learner update step on the collected episodes.
|
||||
with self.metrics.log_time((TIMERS, LEARNER_UPDATE_TIMER)):
|
||||
learner_results = self.learner_group.update(
|
||||
episodes=episodes,
|
||||
timesteps={
|
||||
NUM_ENV_STEPS_SAMPLED_LIFETIME: (
|
||||
self.metrics.peek(
|
||||
(ENV_RUNNER_RESULTS, NUM_ENV_STEPS_SAMPLED_LIFETIME)
|
||||
)
|
||||
),
|
||||
NUM_MODULE_STEPS_TRAINED_LIFETIME: (
|
||||
self.metrics.peek(
|
||||
(
|
||||
LEARNER_RESULTS,
|
||||
ALL_MODULES,
|
||||
NUM_MODULE_STEPS_TRAINED_LIFETIME,
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
),
|
||||
},
|
||||
num_epochs=self.config.num_epochs,
|
||||
minibatch_size=self.config.minibatch_size,
|
||||
shuffle_batch_per_epoch=self.config.shuffle_batch_per_epoch,
|
||||
)
|
||||
self.metrics.aggregate(learner_results, key=LEARNER_RESULTS)
|
||||
|
||||
# Update weights - after learning on the local worker - on all remote
|
||||
# workers.
|
||||
with self.metrics.log_time((TIMERS, SYNCH_WORKER_WEIGHTS_TIMER)):
|
||||
# The train results's loss keys are ModuleIDs to their loss values.
|
||||
# But we also return a total_loss key at the same level as the ModuleID
|
||||
# keys. So we need to subtract that to get the correct set of ModuleIDs to
|
||||
# update.
|
||||
# TODO (sven): We should not be using `learner_results` as a messenger
|
||||
# to infer which modules to update. `policies_to_train` might also NOT work
|
||||
# as it might be a very large set (100s of Modules) vs a smaller Modules
|
||||
# set that's present in the current train batch.
|
||||
modules_to_update = set(learner_results[0].keys()) - {ALL_MODULES}
|
||||
self.env_runner_group.sync_weights(
|
||||
# Sync weights from learner_group to all EnvRunners.
|
||||
from_worker_or_learner_group=self.learner_group,
|
||||
policies=modules_to_update,
|
||||
inference_only=True,
|
||||
)
|
||||
|
||||
@OldAPIStack
|
||||
def _training_step_old_api_stack(self) -> ResultDict:
|
||||
# Collect batches from sample workers until we have a full batch.
|
||||
with self._timers[SAMPLE_TIMER]:
|
||||
if self.config.count_steps_by == "agent_steps":
|
||||
train_batch = synchronous_parallel_sample(
|
||||
worker_set=self.env_runner_group,
|
||||
max_agent_steps=self.config.total_train_batch_size,
|
||||
sample_timeout_s=self.config.sample_timeout_s,
|
||||
)
|
||||
else:
|
||||
train_batch = synchronous_parallel_sample(
|
||||
worker_set=self.env_runner_group,
|
||||
max_env_steps=self.config.total_train_batch_size,
|
||||
sample_timeout_s=self.config.sample_timeout_s,
|
||||
)
|
||||
# Return early if all our workers failed.
|
||||
if not train_batch:
|
||||
return {}
|
||||
train_batch = train_batch.as_multi_agent()
|
||||
self._counters[NUM_AGENT_STEPS_SAMPLED] += train_batch.agent_steps()
|
||||
self._counters[NUM_ENV_STEPS_SAMPLED] += train_batch.env_steps()
|
||||
# Standardize advantages.
|
||||
train_batch = standardize_fields(train_batch, ["advantages"])
|
||||
|
||||
if self.config.simple_optimizer:
|
||||
train_results = train_one_step(self, train_batch)
|
||||
else:
|
||||
train_results = multi_gpu_train_one_step(self, train_batch)
|
||||
|
||||
policies_to_update = list(train_results.keys())
|
||||
|
||||
global_vars = {
|
||||
"timestep": self._counters[NUM_AGENT_STEPS_SAMPLED],
|
||||
# TODO (sven): num_grad_updates per each policy should be
|
||||
# accessible via `train_results` (and get rid of global_vars).
|
||||
"num_grad_updates_per_policy": {
|
||||
pid: self.env_runner.policy_map[pid].num_grad_updates
|
||||
for pid in policies_to_update
|
||||
},
|
||||
}
|
||||
|
||||
# Update weights - after learning on the local worker - on all remote
|
||||
# workers.
|
||||
with self._timers[SYNCH_WORKER_WEIGHTS_TIMER]:
|
||||
if self.env_runner_group.num_remote_workers() > 0:
|
||||
from_worker_or_learner_group = None
|
||||
self.env_runner_group.sync_weights(
|
||||
from_worker_or_learner_group=from_worker_or_learner_group,
|
||||
policies=policies_to_update,
|
||||
global_vars=global_vars,
|
||||
)
|
||||
|
||||
# For each policy: Update KL scale and warn about possible issues
|
||||
for policy_id, policy_info in train_results.items():
|
||||
# Update KL loss with dynamic scaling
|
||||
# for each (possibly multiagent) policy we are training
|
||||
kl_divergence = policy_info[LEARNER_STATS_KEY].get("kl")
|
||||
self.get_policy(policy_id).update_kl(kl_divergence)
|
||||
|
||||
# Warn about excessively high value function loss
|
||||
scaled_vf_loss = (
|
||||
self.config.vf_loss_coeff * policy_info[LEARNER_STATS_KEY]["vf_loss"]
|
||||
)
|
||||
policy_loss = policy_info[LEARNER_STATS_KEY]["policy_loss"]
|
||||
if (
|
||||
log_once("ppo_warned_lr_ratio")
|
||||
and self.config.get("model", {}).get("vf_share_layers")
|
||||
and scaled_vf_loss > 100
|
||||
):
|
||||
logger.warning(
|
||||
"The magnitude of your value function loss for policy: {} is "
|
||||
"extremely large ({}) compared to the policy loss ({}). This "
|
||||
"can prevent the policy from learning. Consider scaling down "
|
||||
"the VF loss by reducing vf_loss_coeff, or disabling "
|
||||
"vf_share_layers.".format(policy_id, scaled_vf_loss, policy_loss)
|
||||
)
|
||||
# Warn about bad clipping configs.
|
||||
train_batch.policy_batches[policy_id].set_get_interceptor(None)
|
||||
mean_reward = train_batch.policy_batches[policy_id]["rewards"].mean()
|
||||
if (
|
||||
log_once("ppo_warned_vf_clip")
|
||||
and mean_reward > self.config.vf_clip_param
|
||||
):
|
||||
self.warned_vf_clip = True
|
||||
logger.warning(
|
||||
f"The mean reward returned from the environment is {mean_reward}"
|
||||
f" but the vf_clip_param is set to {self.config['vf_clip_param']}."
|
||||
f" Consider increasing it for policy: {policy_id} to improve"
|
||||
" value function convergence."
|
||||
)
|
||||
|
||||
# Update global vars on local worker as well.
|
||||
# TODO (simon): At least in RolloutWorker obsolete I guess as called in
|
||||
# `sync_weights()` called above if remote workers. Can we call this
|
||||
# where `set_weights()` is called on the local_worker?
|
||||
self.env_runner.set_global_vars(global_vars)
|
||||
|
||||
return train_results
|
||||
@@ -0,0 +1,201 @@
|
||||
# __sphinx_doc_begin__
|
||||
import gymnasium as gym
|
||||
|
||||
from ray.rllib.core.models.base import ActorCriticEncoder, Encoder, Model
|
||||
from ray.rllib.core.models.catalog import Catalog
|
||||
from ray.rllib.core.models.configs import (
|
||||
ActorCriticEncoderConfig,
|
||||
FreeLogStdMLPHeadConfig,
|
||||
MLPHeadConfig,
|
||||
)
|
||||
from ray.rllib.utils import override
|
||||
from ray.rllib.utils.annotations import OverrideToImplementCustomLogic
|
||||
|
||||
|
||||
def _check_if_diag_gaussian(action_distribution_cls, framework, no_error=False):
|
||||
if framework == "torch":
|
||||
from ray.rllib.core.distribution.torch.torch_distribution import (
|
||||
TorchDiagGaussian,
|
||||
)
|
||||
|
||||
is_diag_gaussian = issubclass(action_distribution_cls, TorchDiagGaussian)
|
||||
if no_error:
|
||||
return is_diag_gaussian
|
||||
else:
|
||||
assert is_diag_gaussian, (
|
||||
f"free_log_std is only supported for DiagGaussian action "
|
||||
f"distributions. Found action distribution: {action_distribution_cls}."
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Framework {framework} not supported for free_log_std.")
|
||||
|
||||
|
||||
class PPOCatalog(Catalog):
|
||||
"""The Catalog class used to build models for PPO.
|
||||
|
||||
PPOCatalog provides the following models:
|
||||
- ActorCriticEncoder: The encoder used to encode the observations.
|
||||
- Pi Head: The head used to compute the policy logits.
|
||||
- Value Function Head: The head used to compute the value function.
|
||||
|
||||
The ActorCriticEncoder is a wrapper around Encoders to produce separate outputs
|
||||
for the policy and value function. See implementations of DefaultPPORLModule for
|
||||
more details.
|
||||
|
||||
Any custom ActorCriticEncoder can be built by overriding the
|
||||
build_actor_critic_encoder() method. Alternatively, the ActorCriticEncoderConfig
|
||||
at PPOCatalog.actor_critic_encoder_config can be overridden to build a custom
|
||||
ActorCriticEncoder during RLModule runtime.
|
||||
|
||||
Any custom head can be built by overriding the build_pi_head() and build_vf_head()
|
||||
methods. Alternatively, the PiHeadConfig and VfHeadConfig can be overridden to
|
||||
build custom heads during RLModule runtime.
|
||||
|
||||
Any module built for exploration or inference is built with the flag
|
||||
`ìnference_only=True` and does not contain a value network. This flag can be set
|
||||
in the `SingleAgentModuleSpec` through the `inference_only` boolean flag.
|
||||
In case that the actor-critic-encoder is not shared between the policy and value
|
||||
function, the inference-only module will contain only the actor encoder network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
observation_space: gym.Space,
|
||||
action_space: gym.Space,
|
||||
model_config_dict: dict,
|
||||
):
|
||||
"""Initializes the PPOCatalog.
|
||||
|
||||
Args:
|
||||
observation_space: The observation space of the Encoder.
|
||||
action_space: The action space for the Pi Head.
|
||||
model_config_dict: The model config to use.
|
||||
"""
|
||||
super().__init__(
|
||||
observation_space=observation_space,
|
||||
action_space=action_space,
|
||||
model_config_dict=model_config_dict,
|
||||
)
|
||||
# Replace EncoderConfig by ActorCriticEncoderConfig
|
||||
self.actor_critic_encoder_config = ActorCriticEncoderConfig(
|
||||
base_encoder_config=self._encoder_config,
|
||||
shared=self._model_config_dict["vf_share_layers"],
|
||||
)
|
||||
|
||||
self.pi_and_vf_head_hiddens = self._model_config_dict["head_fcnet_hiddens"]
|
||||
self.pi_and_vf_head_activation = self._model_config_dict[
|
||||
"head_fcnet_activation"
|
||||
]
|
||||
|
||||
# We don't have the exact (framework specific) action dist class yet and thus
|
||||
# cannot determine the exact number of output nodes (action space) required.
|
||||
# -> Build pi config only in the `self.build_pi_head` method.
|
||||
self.pi_head_config = None
|
||||
|
||||
self.vf_head_config = MLPHeadConfig(
|
||||
input_dims=self.latent_dims,
|
||||
hidden_layer_dims=self.pi_and_vf_head_hiddens,
|
||||
hidden_layer_activation=self.pi_and_vf_head_activation,
|
||||
hidden_layer_use_layernorm=self._model_config_dict.get(
|
||||
"head_fcnet_use_layernorm", False
|
||||
),
|
||||
output_layer_activation="linear",
|
||||
output_layer_dim=1,
|
||||
)
|
||||
|
||||
@OverrideToImplementCustomLogic
|
||||
def build_actor_critic_encoder(self, framework: str) -> ActorCriticEncoder:
|
||||
"""Builds the ActorCriticEncoder.
|
||||
|
||||
The default behavior is to build the encoder from the encoder_config.
|
||||
This can be overridden to build a custom ActorCriticEncoder as a means of
|
||||
configuring the behavior of a PPORLModule implementation.
|
||||
|
||||
Args:
|
||||
framework: The framework to use. Either "torch" or "tf2".
|
||||
|
||||
Returns:
|
||||
The ActorCriticEncoder.
|
||||
"""
|
||||
return self.actor_critic_encoder_config.build(framework=framework)
|
||||
|
||||
@override(Catalog)
|
||||
def build_encoder(self, framework: str) -> Encoder:
|
||||
"""Builds the encoder.
|
||||
|
||||
Since PPO uses an ActorCriticEncoder, this method should not be implemented.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Use PPOCatalog.build_actor_critic_encoder() instead for PPO."
|
||||
)
|
||||
|
||||
@OverrideToImplementCustomLogic
|
||||
def build_pi_head(self, framework: str) -> Model:
|
||||
"""Builds the policy head.
|
||||
|
||||
The default behavior is to build the head from the pi_head_config.
|
||||
This can be overridden to build a custom policy head as a means of configuring
|
||||
the behavior of a PPORLModule implementation.
|
||||
|
||||
Args:
|
||||
framework: The framework to use. Either "torch" or "tf2".
|
||||
|
||||
Returns:
|
||||
The policy head.
|
||||
"""
|
||||
# Get action_distribution_cls to find out about the output dimension for pi_head
|
||||
action_distribution_cls = self.get_action_dist_cls(framework=framework)
|
||||
if self._model_config_dict["free_log_std"]:
|
||||
_check_if_diag_gaussian(
|
||||
action_distribution_cls=action_distribution_cls, framework=framework
|
||||
)
|
||||
is_diag_gaussian = True
|
||||
else:
|
||||
is_diag_gaussian = _check_if_diag_gaussian(
|
||||
action_distribution_cls=action_distribution_cls,
|
||||
framework=framework,
|
||||
no_error=True,
|
||||
)
|
||||
required_output_dim = action_distribution_cls.required_input_dim(
|
||||
space=self.action_space, model_config=self._model_config_dict
|
||||
)
|
||||
# Now that we have the action dist class and number of outputs, we can define
|
||||
# our pi-config and build the pi head.
|
||||
pi_head_config_class = (
|
||||
FreeLogStdMLPHeadConfig
|
||||
if self._model_config_dict["free_log_std"]
|
||||
else MLPHeadConfig
|
||||
)
|
||||
self.pi_head_config = pi_head_config_class(
|
||||
input_dims=self.latent_dims,
|
||||
hidden_layer_dims=self.pi_and_vf_head_hiddens,
|
||||
hidden_layer_activation=self.pi_and_vf_head_activation,
|
||||
hidden_layer_use_layernorm=self._model_config_dict.get(
|
||||
"head_fcnet_use_layernorm", False
|
||||
),
|
||||
output_layer_dim=required_output_dim,
|
||||
output_layer_activation="linear",
|
||||
clip_log_std=is_diag_gaussian,
|
||||
log_std_clip_param=self._model_config_dict.get("log_std_clip_param", 20),
|
||||
)
|
||||
|
||||
return self.pi_head_config.build(framework=framework)
|
||||
|
||||
@OverrideToImplementCustomLogic
|
||||
def build_vf_head(self, framework: str) -> Model:
|
||||
"""Builds the value function head.
|
||||
|
||||
The default behavior is to build the head from the vf_head_config.
|
||||
This can be overridden to build a custom value function head as a means of
|
||||
configuring the behavior of a PPORLModule implementation.
|
||||
|
||||
Args:
|
||||
framework: The framework to use. Either "torch" or "tf2".
|
||||
|
||||
Returns:
|
||||
The value function head.
|
||||
"""
|
||||
return self.vf_head_config.build(framework=framework)
|
||||
|
||||
|
||||
# __sphinx_doc_end__
|
||||
@@ -0,0 +1,146 @@
|
||||
import abc
|
||||
from typing import Any, Dict
|
||||
|
||||
from ray.rllib.algorithms.ppo.ppo import (
|
||||
LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY,
|
||||
LEARNER_RESULTS_KL_KEY,
|
||||
PPOConfig,
|
||||
)
|
||||
from ray.rllib.connectors.learner import (
|
||||
AddOneTsToEpisodesAndTruncate,
|
||||
GeneralAdvantageEstimation,
|
||||
)
|
||||
from ray.rllib.core.learner.learner import Learner
|
||||
from ray.rllib.core.rl_module.apis.value_function_api import ValueFunctionAPI
|
||||
from ray.rllib.utils.annotations import (
|
||||
OverrideToImplementCustomLogic_CallToSuperRecommended,
|
||||
override,
|
||||
)
|
||||
from ray.rllib.utils.lambda_defaultdict import LambdaDefaultDict
|
||||
from ray.rllib.utils.metrics import (
|
||||
NUM_ENV_STEPS_SAMPLED_LIFETIME,
|
||||
)
|
||||
from ray.rllib.utils.numpy import convert_to_numpy
|
||||
from ray.rllib.utils.schedules.scheduler import Scheduler
|
||||
from ray.rllib.utils.typing import ModuleID, TensorType
|
||||
|
||||
|
||||
class PPOLearner(Learner):
|
||||
@override(Learner)
|
||||
def build(self) -> None:
|
||||
super().build()
|
||||
|
||||
# Dict mapping module IDs to the respective entropy Scheduler instance.
|
||||
self.entropy_coeff_schedulers_per_module: Dict[
|
||||
ModuleID, Scheduler
|
||||
] = LambdaDefaultDict(
|
||||
lambda module_id: Scheduler(
|
||||
fixed_value_or_schedule=(
|
||||
self.config.get_config_for_module(module_id).entropy_coeff
|
||||
),
|
||||
framework=self.framework,
|
||||
device=self._device,
|
||||
)
|
||||
)
|
||||
|
||||
# Set up KL coefficient variables (per module).
|
||||
# Note that the KL coeff is not controlled by a Scheduler, but seeks
|
||||
# to stay close to a given kl_target value.
|
||||
self.curr_kl_coeffs_per_module: Dict[ModuleID, TensorType] = LambdaDefaultDict(
|
||||
lambda module_id: self._get_tensor_variable(
|
||||
self.config.get_config_for_module(module_id).kl_coeff
|
||||
)
|
||||
)
|
||||
|
||||
# Extend all episodes by one artificial timestep to allow the value function net
|
||||
# to compute the bootstrap values (and add a mask to the batch to know, which
|
||||
# slots to mask out).
|
||||
if (
|
||||
self._learner_connector is not None
|
||||
and self.config.add_default_connectors_to_learner_pipeline
|
||||
):
|
||||
# Before anything, add one ts to each episode (and record this in the loss
|
||||
# mask, so that the computations at this extra ts are not used to compute
|
||||
# the loss).
|
||||
self._learner_connector.prepend(AddOneTsToEpisodesAndTruncate())
|
||||
# At the end of the pipeline (when the batch is already completed), add the
|
||||
# GAE connector, which performs a vf forward pass, then computes the GAE
|
||||
# computations, and puts the results of this (advantages, value targets)
|
||||
# directly back in the batch. This is then the batch used for
|
||||
# `forward_train` and `compute_losses`.
|
||||
self._learner_connector.append(
|
||||
GeneralAdvantageEstimation(
|
||||
gamma=self.config.gamma, lambda_=self.config.lambda_
|
||||
)
|
||||
)
|
||||
|
||||
@override(Learner)
|
||||
def remove_module(self, module_id: ModuleID, **kwargs):
|
||||
marl_spec = super().remove_module(module_id, **kwargs)
|
||||
|
||||
self.entropy_coeff_schedulers_per_module.pop(module_id, None)
|
||||
self.curr_kl_coeffs_per_module.pop(module_id, None)
|
||||
|
||||
return marl_spec
|
||||
|
||||
@OverrideToImplementCustomLogic_CallToSuperRecommended
|
||||
@override(Learner)
|
||||
def after_gradient_based_update(
|
||||
self,
|
||||
*,
|
||||
timesteps: Dict[str, Any],
|
||||
) -> None:
|
||||
super().after_gradient_based_update(timesteps=timesteps)
|
||||
|
||||
for module_id, module in self.module._rl_modules.items():
|
||||
config = self.config.get_config_for_module(module_id)
|
||||
|
||||
# Update entropy coefficient via our Scheduler.
|
||||
new_entropy_coeff = self.entropy_coeff_schedulers_per_module[
|
||||
module_id
|
||||
].update(timestep=timesteps.get(NUM_ENV_STEPS_SAMPLED_LIFETIME, 0))
|
||||
self.metrics.log_value(
|
||||
(module_id, LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY),
|
||||
new_entropy_coeff,
|
||||
window=1,
|
||||
)
|
||||
if (
|
||||
config.use_kl_loss
|
||||
and (module_id, LEARNER_RESULTS_KL_KEY) in self.metrics
|
||||
):
|
||||
kl_loss = convert_to_numpy(
|
||||
self.metrics.peek((module_id, LEARNER_RESULTS_KL_KEY))
|
||||
)
|
||||
self._update_module_kl_coeff(
|
||||
module_id=module_id,
|
||||
config=config,
|
||||
kl_loss=kl_loss,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@override(Learner)
|
||||
def rl_module_required_apis(cls) -> list[type]:
|
||||
# In order for a PPOLearner to update an RLModule, it must implement the
|
||||
# following APIs:
|
||||
return [ValueFunctionAPI]
|
||||
|
||||
@abc.abstractmethod
|
||||
def _update_module_kl_coeff(
|
||||
self,
|
||||
*,
|
||||
module_id: ModuleID,
|
||||
config: PPOConfig,
|
||||
kl_loss: float,
|
||||
) -> None:
|
||||
"""Dynamically update the KL loss coefficients of each module.
|
||||
|
||||
The update is completed using the mean KL divergence between the action
|
||||
distributions current policy and old policy of each module. That action
|
||||
distribution is computed during the most recent update/call to `compute_loss`.
|
||||
|
||||
Args:
|
||||
module_id: The module whose KL loss coefficient to update.
|
||||
config: The AlgorithmConfig specific to the given `module_id`.
|
||||
kl_loss: The mean KL loss of the module, computed inside
|
||||
`compute_loss_for_module()`.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
# Backward compat import.
|
||||
from ray.rllib.algorithms.ppo.default_ppo_rl_module import ( # noqa
|
||||
DefaultPPORLModule as PPORLModule,
|
||||
)
|
||||
from ray._common.deprecation import deprecation_warning
|
||||
|
||||
deprecation_warning(
|
||||
old="ray.rllib.algorithms.ppo.ppo_rl_module.PPORLModule",
|
||||
new="ray.rllib.algorithms.ppo.default_ppo_rl_module.DefaultPPORLModule",
|
||||
error=False,
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
TensorFlow policy class used for PPO.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Type, Union
|
||||
|
||||
from ray.rllib.evaluation.postprocessing import (
|
||||
Postprocessing,
|
||||
compute_gae_for_sample_batch,
|
||||
)
|
||||
from ray.rllib.models.modelv2 import ModelV2
|
||||
from ray.rllib.models.tf.tf_action_dist import TFActionDistribution
|
||||
from ray.rllib.policy.dynamic_tf_policy_v2 import DynamicTFPolicyV2
|
||||
from ray.rllib.policy.eager_tf_policy_v2 import EagerTFPolicyV2
|
||||
from ray.rllib.policy.sample_batch import SampleBatch
|
||||
from ray.rllib.policy.tf_mixins import (
|
||||
EntropyCoeffSchedule,
|
||||
KLCoeffMixin,
|
||||
LearningRateSchedule,
|
||||
ValueNetworkMixin,
|
||||
)
|
||||
from ray.rllib.utils.annotations import override
|
||||
from ray.rllib.utils.framework import try_import_tf
|
||||
from ray.rllib.utils.tf_utils import explained_variance, warn_if_infinite_kl_divergence
|
||||
from ray.rllib.utils.typing import AlgorithmConfigDict, TensorType, TFPolicyV2Type
|
||||
|
||||
tf1, tf, tfv = try_import_tf()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_config(config: AlgorithmConfigDict) -> None:
|
||||
"""Executed before Policy is "initialized" (at beginning of constructor).
|
||||
Args:
|
||||
config: The Policy's config.
|
||||
"""
|
||||
# If vf_share_layers is True, inform about the need to tune vf_loss_coeff.
|
||||
if config.get("model", {}).get("vf_share_layers") is True:
|
||||
logger.info(
|
||||
"`vf_share_layers=True` in your model. "
|
||||
"Therefore, remember to tune the value of `vf_loss_coeff`!"
|
||||
)
|
||||
|
||||
|
||||
# We need this builder function because we want to share the same
|
||||
# custom logics between TF1 dynamic and TF2 eager policies.
|
||||
def get_ppo_tf_policy(name: str, base: TFPolicyV2Type) -> TFPolicyV2Type:
|
||||
"""Construct a PPOTFPolicy inheriting either dynamic or eager base policies.
|
||||
|
||||
Args:
|
||||
base: Base class for this policy. DynamicTFPolicyV2 or EagerTFPolicyV2.
|
||||
|
||||
Returns:
|
||||
A TF Policy to be used with PPO.
|
||||
"""
|
||||
|
||||
class PPOTFPolicy(
|
||||
EntropyCoeffSchedule,
|
||||
LearningRateSchedule,
|
||||
KLCoeffMixin,
|
||||
ValueNetworkMixin,
|
||||
base,
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
observation_space,
|
||||
action_space,
|
||||
config,
|
||||
existing_model=None,
|
||||
existing_inputs=None,
|
||||
):
|
||||
# First thing first, enable eager execution if necessary.
|
||||
base.enable_eager_execution_if_necessary()
|
||||
|
||||
# TODO: Move into Policy API, if needed at all here. Why not move this into
|
||||
# `PPOConfig`?.
|
||||
validate_config(config)
|
||||
|
||||
# Initialize base class.
|
||||
base.__init__(
|
||||
self,
|
||||
observation_space,
|
||||
action_space,
|
||||
config,
|
||||
existing_inputs=existing_inputs,
|
||||
existing_model=existing_model,
|
||||
)
|
||||
|
||||
# Initialize MixIns.
|
||||
ValueNetworkMixin.__init__(self, config)
|
||||
EntropyCoeffSchedule.__init__(
|
||||
self, config["entropy_coeff"], config["entropy_coeff_schedule"]
|
||||
)
|
||||
LearningRateSchedule.__init__(self, config["lr"], config["lr_schedule"])
|
||||
KLCoeffMixin.__init__(self, config)
|
||||
|
||||
# Note: this is a bit ugly, but loss and optimizer initialization must
|
||||
# happen after all the MixIns are initialized.
|
||||
self.maybe_initialize_optimizer_and_loss()
|
||||
|
||||
@override(base)
|
||||
def loss(
|
||||
self,
|
||||
model: Union[ModelV2, "tf.keras.Model"],
|
||||
dist_class: Type[TFActionDistribution],
|
||||
train_batch: SampleBatch,
|
||||
) -> Union[TensorType, List[TensorType]]:
|
||||
if isinstance(model, tf.keras.Model):
|
||||
logits, state, extra_outs = model(train_batch)
|
||||
value_fn_out = extra_outs[SampleBatch.VF_PREDS]
|
||||
else:
|
||||
logits, state = model(train_batch)
|
||||
value_fn_out = model.value_function()
|
||||
|
||||
curr_action_dist = dist_class(logits, model)
|
||||
|
||||
# RNN case: Mask away 0-padded chunks at end of time axis.
|
||||
if state:
|
||||
# Derive max_seq_len from the data itself, not from the seq_lens
|
||||
# tensor. This is in case e.g. seq_lens=[2, 3], but the data is still
|
||||
# 0-padded up to T=5 (as it's the case for attention nets).
|
||||
B = tf.shape(train_batch[SampleBatch.SEQ_LENS])[0]
|
||||
max_seq_len = tf.shape(logits)[0] // B
|
||||
|
||||
mask = tf.sequence_mask(train_batch[SampleBatch.SEQ_LENS], max_seq_len)
|
||||
mask = tf.reshape(mask, [-1])
|
||||
|
||||
def reduce_mean_valid(t):
|
||||
return tf.reduce_mean(tf.boolean_mask(t, mask))
|
||||
|
||||
# non-RNN case: No masking.
|
||||
else:
|
||||
mask = None
|
||||
reduce_mean_valid = tf.reduce_mean
|
||||
|
||||
prev_action_dist = dist_class(
|
||||
train_batch[SampleBatch.ACTION_DIST_INPUTS], model
|
||||
)
|
||||
|
||||
logp_ratio = tf.exp(
|
||||
curr_action_dist.logp(train_batch[SampleBatch.ACTIONS])
|
||||
- train_batch[SampleBatch.ACTION_LOGP]
|
||||
)
|
||||
|
||||
# Only calculate kl loss if necessary (kl-coeff > 0.0).
|
||||
if self.config["kl_coeff"] > 0.0:
|
||||
action_kl = prev_action_dist.kl(curr_action_dist)
|
||||
mean_kl_loss = reduce_mean_valid(action_kl)
|
||||
warn_if_infinite_kl_divergence(self, mean_kl_loss)
|
||||
else:
|
||||
mean_kl_loss = tf.constant(0.0)
|
||||
|
||||
curr_entropy = curr_action_dist.entropy()
|
||||
mean_entropy = reduce_mean_valid(curr_entropy)
|
||||
|
||||
surrogate_loss = tf.minimum(
|
||||
train_batch[Postprocessing.ADVANTAGES] * logp_ratio,
|
||||
train_batch[Postprocessing.ADVANTAGES]
|
||||
* tf.clip_by_value(
|
||||
logp_ratio,
|
||||
1 - self.config["clip_param"],
|
||||
1 + self.config["clip_param"],
|
||||
),
|
||||
)
|
||||
|
||||
# Compute a value function loss.
|
||||
if self.config["use_critic"]:
|
||||
vf_loss = tf.math.square(
|
||||
value_fn_out - train_batch[Postprocessing.VALUE_TARGETS]
|
||||
)
|
||||
vf_loss_clipped = tf.clip_by_value(
|
||||
vf_loss,
|
||||
0,
|
||||
self.config["vf_clip_param"],
|
||||
)
|
||||
mean_vf_loss = reduce_mean_valid(vf_loss_clipped)
|
||||
# Ignore the value function.
|
||||
else:
|
||||
vf_loss_clipped = mean_vf_loss = tf.constant(0.0)
|
||||
|
||||
total_loss = reduce_mean_valid(
|
||||
-surrogate_loss
|
||||
+ self.config["vf_loss_coeff"] * vf_loss_clipped
|
||||
- self.entropy_coeff * curr_entropy
|
||||
)
|
||||
# Add mean_kl_loss (already processed through `reduce_mean_valid`),
|
||||
# if necessary.
|
||||
if self.config["kl_coeff"] > 0.0:
|
||||
total_loss += self.kl_coeff * mean_kl_loss
|
||||
|
||||
# Store stats in policy for stats_fn.
|
||||
self._total_loss = total_loss
|
||||
self._mean_policy_loss = reduce_mean_valid(-surrogate_loss)
|
||||
self._mean_vf_loss = mean_vf_loss
|
||||
self._mean_entropy = mean_entropy
|
||||
# Backward compatibility: Deprecate self._mean_kl.
|
||||
self._mean_kl_loss = self._mean_kl = mean_kl_loss
|
||||
self._value_fn_out = value_fn_out
|
||||
|
||||
return total_loss
|
||||
|
||||
@override(base)
|
||||
def stats_fn(self, train_batch: SampleBatch) -> Dict[str, TensorType]:
|
||||
return {
|
||||
"cur_kl_coeff": tf.cast(self.kl_coeff, tf.float64),
|
||||
"cur_lr": tf.cast(self.cur_lr, tf.float64),
|
||||
"total_loss": self._total_loss,
|
||||
"policy_loss": self._mean_policy_loss,
|
||||
"vf_loss": self._mean_vf_loss,
|
||||
"vf_explained_var": explained_variance(
|
||||
train_batch[Postprocessing.VALUE_TARGETS], self._value_fn_out
|
||||
),
|
||||
"kl": self._mean_kl_loss,
|
||||
"entropy": self._mean_entropy,
|
||||
"entropy_coeff": tf.cast(self.entropy_coeff, tf.float64),
|
||||
}
|
||||
|
||||
@override(base)
|
||||
def postprocess_trajectory(
|
||||
self, sample_batch, other_agent_batches=None, episode=None
|
||||
):
|
||||
sample_batch = super().postprocess_trajectory(sample_batch)
|
||||
return compute_gae_for_sample_batch(
|
||||
self, sample_batch, other_agent_batches, episode
|
||||
)
|
||||
|
||||
PPOTFPolicy.__name__ = name
|
||||
PPOTFPolicy.__qualname__ = name
|
||||
|
||||
return PPOTFPolicy
|
||||
|
||||
|
||||
PPOTF1Policy = get_ppo_tf_policy("PPOTF1Policy", DynamicTFPolicyV2)
|
||||
PPOTF2Policy = get_ppo_tf_policy("PPOTF2Policy", EagerTFPolicyV2)
|
||||
@@ -0,0 +1,217 @@
|
||||
import logging
|
||||
from typing import Dict, List, Type, Union
|
||||
|
||||
import ray
|
||||
from ray.rllib.algorithms.ppo.ppo_tf_policy import validate_config
|
||||
from ray.rllib.evaluation.postprocessing import (
|
||||
Postprocessing,
|
||||
compute_gae_for_sample_batch,
|
||||
)
|
||||
from ray.rllib.models.action_dist import ActionDistribution
|
||||
from ray.rllib.models.modelv2 import ModelV2
|
||||
from ray.rllib.policy.sample_batch import SampleBatch
|
||||
from ray.rllib.policy.torch_mixins import (
|
||||
EntropyCoeffSchedule,
|
||||
KLCoeffMixin,
|
||||
LearningRateSchedule,
|
||||
ValueNetworkMixin,
|
||||
)
|
||||
from ray.rllib.policy.torch_policy_v2 import TorchPolicyV2
|
||||
from ray.rllib.utils.annotations import override
|
||||
from ray.rllib.utils.framework import try_import_torch
|
||||
from ray.rllib.utils.numpy import convert_to_numpy
|
||||
from ray.rllib.utils.torch_utils import (
|
||||
apply_grad_clipping,
|
||||
explained_variance,
|
||||
sequence_mask,
|
||||
warn_if_infinite_kl_divergence,
|
||||
)
|
||||
from ray.rllib.utils.typing import TensorType
|
||||
|
||||
torch, nn = try_import_torch()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PPOTorchPolicy(
|
||||
ValueNetworkMixin,
|
||||
LearningRateSchedule,
|
||||
EntropyCoeffSchedule,
|
||||
KLCoeffMixin,
|
||||
TorchPolicyV2,
|
||||
):
|
||||
"""PyTorch policy class used with PPO."""
|
||||
|
||||
def __init__(self, observation_space, action_space, config):
|
||||
config = dict(ray.rllib.algorithms.ppo.ppo.PPOConfig().to_dict(), **config)
|
||||
validate_config(config)
|
||||
|
||||
TorchPolicyV2.__init__(
|
||||
self,
|
||||
observation_space,
|
||||
action_space,
|
||||
config,
|
||||
max_seq_len=config["model"]["max_seq_len"],
|
||||
)
|
||||
|
||||
ValueNetworkMixin.__init__(self, config)
|
||||
LearningRateSchedule.__init__(self, config["lr"], config["lr_schedule"])
|
||||
EntropyCoeffSchedule.__init__(
|
||||
self, config["entropy_coeff"], config["entropy_coeff_schedule"]
|
||||
)
|
||||
KLCoeffMixin.__init__(self, config)
|
||||
|
||||
self._initialize_loss_from_dummy_batch()
|
||||
|
||||
@override(TorchPolicyV2)
|
||||
def loss(
|
||||
self,
|
||||
model: ModelV2,
|
||||
dist_class: Type[ActionDistribution],
|
||||
train_batch: SampleBatch,
|
||||
) -> Union[TensorType, List[TensorType]]:
|
||||
"""Compute loss for Proximal Policy Objective.
|
||||
|
||||
Args:
|
||||
model: The Model to calculate the loss for.
|
||||
dist_class: The action distr. class.
|
||||
train_batch: The training data.
|
||||
|
||||
Returns:
|
||||
The PPO loss tensor given the input batch.
|
||||
"""
|
||||
|
||||
logits, state = model(train_batch)
|
||||
curr_action_dist = dist_class(logits, model)
|
||||
|
||||
# RNN case: Mask away 0-padded chunks at end of time axis.
|
||||
if state:
|
||||
B = len(train_batch[SampleBatch.SEQ_LENS])
|
||||
max_seq_len = logits.shape[0] // B
|
||||
mask = sequence_mask(
|
||||
train_batch[SampleBatch.SEQ_LENS],
|
||||
max_seq_len,
|
||||
time_major=model.is_time_major(),
|
||||
)
|
||||
mask = torch.reshape(mask, [-1])
|
||||
num_valid = torch.sum(mask)
|
||||
|
||||
def reduce_mean_valid(t):
|
||||
return torch.sum(t[mask]) / num_valid
|
||||
|
||||
# non-RNN case: No masking.
|
||||
else:
|
||||
mask = None
|
||||
reduce_mean_valid = torch.mean
|
||||
|
||||
prev_action_dist = dist_class(
|
||||
train_batch[SampleBatch.ACTION_DIST_INPUTS], model
|
||||
)
|
||||
|
||||
logp_ratio = torch.exp(
|
||||
curr_action_dist.logp(train_batch[SampleBatch.ACTIONS])
|
||||
- train_batch[SampleBatch.ACTION_LOGP]
|
||||
)
|
||||
|
||||
# Only calculate kl loss if necessary (kl-coeff > 0.0).
|
||||
if self.config["kl_coeff"] > 0.0:
|
||||
action_kl = prev_action_dist.kl(curr_action_dist)
|
||||
mean_kl_loss = reduce_mean_valid(action_kl)
|
||||
# TODO smorad: should we do anything besides warn? Could discard KL term
|
||||
# for this update
|
||||
warn_if_infinite_kl_divergence(self, mean_kl_loss)
|
||||
else:
|
||||
mean_kl_loss = torch.tensor(0.0, device=logp_ratio.device)
|
||||
|
||||
curr_entropy = curr_action_dist.entropy()
|
||||
mean_entropy = reduce_mean_valid(curr_entropy)
|
||||
|
||||
surrogate_loss = torch.min(
|
||||
train_batch[Postprocessing.ADVANTAGES] * logp_ratio,
|
||||
train_batch[Postprocessing.ADVANTAGES]
|
||||
* torch.clamp(
|
||||
logp_ratio, 1 - self.config["clip_param"], 1 + self.config["clip_param"]
|
||||
),
|
||||
)
|
||||
|
||||
# Compute a value function loss.
|
||||
if self.config["use_critic"]:
|
||||
value_fn_out = model.value_function()
|
||||
vf_loss = torch.pow(
|
||||
value_fn_out - train_batch[Postprocessing.VALUE_TARGETS], 2.0
|
||||
)
|
||||
vf_loss_clipped = torch.clamp(vf_loss, 0, self.config["vf_clip_param"])
|
||||
mean_vf_loss = reduce_mean_valid(vf_loss_clipped)
|
||||
# Ignore the value function.
|
||||
else:
|
||||
value_fn_out = torch.tensor(0.0).to(surrogate_loss.device)
|
||||
vf_loss_clipped = mean_vf_loss = torch.tensor(0.0).to(surrogate_loss.device)
|
||||
|
||||
total_loss = reduce_mean_valid(
|
||||
-surrogate_loss
|
||||
+ self.config["vf_loss_coeff"] * vf_loss_clipped
|
||||
- self.entropy_coeff * curr_entropy
|
||||
)
|
||||
|
||||
# Add mean_kl_loss (already processed through `reduce_mean_valid`),
|
||||
# if necessary.
|
||||
if self.config["kl_coeff"] > 0.0:
|
||||
total_loss += self.kl_coeff * mean_kl_loss
|
||||
|
||||
# Store values for stats function in model (tower), such that for
|
||||
# multi-GPU, we do not override them during the parallel loss phase.
|
||||
model.tower_stats["total_loss"] = total_loss
|
||||
model.tower_stats["mean_policy_loss"] = reduce_mean_valid(-surrogate_loss)
|
||||
model.tower_stats["mean_vf_loss"] = mean_vf_loss
|
||||
model.tower_stats["vf_explained_var"] = explained_variance(
|
||||
train_batch[Postprocessing.VALUE_TARGETS], value_fn_out
|
||||
)
|
||||
model.tower_stats["mean_entropy"] = mean_entropy
|
||||
model.tower_stats["mean_kl_loss"] = mean_kl_loss
|
||||
|
||||
return total_loss
|
||||
|
||||
# TODO: Make this an event-style subscription (e.g.:
|
||||
# "after_gradients_computed").
|
||||
@override(TorchPolicyV2)
|
||||
def extra_grad_process(self, local_optimizer, loss):
|
||||
return apply_grad_clipping(self, local_optimizer, loss)
|
||||
|
||||
@override(TorchPolicyV2)
|
||||
def stats_fn(self, train_batch: SampleBatch) -> Dict[str, TensorType]:
|
||||
return convert_to_numpy(
|
||||
{
|
||||
"cur_kl_coeff": self.kl_coeff,
|
||||
"cur_lr": self.cur_lr,
|
||||
"total_loss": torch.mean(
|
||||
torch.stack(self.get_tower_stats("total_loss"))
|
||||
),
|
||||
"policy_loss": torch.mean(
|
||||
torch.stack(self.get_tower_stats("mean_policy_loss"))
|
||||
),
|
||||
"vf_loss": torch.mean(
|
||||
torch.stack(self.get_tower_stats("mean_vf_loss"))
|
||||
),
|
||||
"vf_explained_var": torch.mean(
|
||||
torch.stack(self.get_tower_stats("vf_explained_var"))
|
||||
),
|
||||
"kl": torch.mean(torch.stack(self.get_tower_stats("mean_kl_loss"))),
|
||||
"entropy": torch.mean(
|
||||
torch.stack(self.get_tower_stats("mean_entropy"))
|
||||
),
|
||||
"entropy_coeff": self.entropy_coeff,
|
||||
}
|
||||
)
|
||||
|
||||
@override(TorchPolicyV2)
|
||||
def postprocess_trajectory(
|
||||
self, sample_batch, other_agent_batches=None, episode=None
|
||||
):
|
||||
# Do all post-processing always with no_grad().
|
||||
# Not using this here will introduce a memory leak
|
||||
# in torch (issue #6962).
|
||||
# TODO: no_grad still necessary?
|
||||
with torch.no_grad():
|
||||
return compute_gae_for_sample_batch(
|
||||
self, sample_batch, other_agent_batches, episode
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
import unittest
|
||||
|
||||
import ray
|
||||
import ray.rllib.algorithms.ppo as ppo
|
||||
from ray.rllib.algorithms.ppo.ppo_learner import LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY
|
||||
from ray.rllib.core import DEFAULT_MODULE_ID
|
||||
from ray.rllib.core.learner.learner import DEFAULT_OPTIMIZER, LR_KEY
|
||||
from ray.rllib.core.rl_module.default_model_config import DefaultModelConfig
|
||||
from ray.rllib.utils.metrics import LEARNER_RESULTS
|
||||
from ray.rllib.utils.test_utils import check, check_train_results_new_api_stack
|
||||
|
||||
|
||||
def get_model_config(lstm=False):
|
||||
return (
|
||||
dict(
|
||||
use_lstm=True,
|
||||
lstm_use_prev_action=True,
|
||||
lstm_use_prev_reward=True,
|
||||
lstm_cell_size=10,
|
||||
max_seq_len=20,
|
||||
)
|
||||
if lstm
|
||||
else {"use_lstm": False}
|
||||
)
|
||||
|
||||
|
||||
def on_train_result(algorithm, result: dict, **kwargs):
|
||||
stats = result[LEARNER_RESULTS][DEFAULT_MODULE_ID]
|
||||
# Entropy coeff goes to 0.05, then 0.0 (per iter).
|
||||
check(
|
||||
stats[LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY],
|
||||
0.05 if algorithm.iteration == 1 else 0.0,
|
||||
)
|
||||
|
||||
# Learning rate should decrease by 0.0001/4 per iteration.
|
||||
check(
|
||||
stats[DEFAULT_OPTIMIZER + "_" + LR_KEY],
|
||||
0.0000075 if algorithm.iteration == 1 else 0.000005,
|
||||
)
|
||||
# Compare reported curr lr vs the actual lr found in the optimizer object.
|
||||
optim = algorithm.learner_group._learner.get_optimizer()
|
||||
actual_optimizer_lr = (
|
||||
optim.param_groups[0]["lr"]
|
||||
if algorithm.config.framework_str == "torch"
|
||||
else optim.lr
|
||||
)
|
||||
check(stats[DEFAULT_OPTIMIZER + "_" + LR_KEY], actual_optimizer_lr)
|
||||
|
||||
|
||||
class TestPPO(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
ray.init()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
ray.shutdown()
|
||||
|
||||
def test_ppo_compilation_and_schedule_mixins(self):
|
||||
"""Test whether PPO can be built with all frameworks."""
|
||||
|
||||
# Build a PPOConfig object with the `SingleAgentEnvRunner` class.
|
||||
config = (
|
||||
ppo.PPOConfig()
|
||||
.env_runners(num_env_runners=0)
|
||||
.training(
|
||||
num_epochs=2,
|
||||
# Setup lr schedule for testing lr-scheduling correctness.
|
||||
lr=[[0, 0.00001], [512, 0.0]], # 512=4x128
|
||||
# Setup `entropy_coeff` schedule for testing whether it's scheduled
|
||||
# correctly.
|
||||
entropy_coeff=[[0, 0.1], [256, 0.0]], # 256=2x128,
|
||||
train_batch_size=128,
|
||||
)
|
||||
.callbacks(on_train_result=on_train_result)
|
||||
.evaluation(
|
||||
# Also test evaluation with remote workers.
|
||||
evaluation_num_env_runners=2,
|
||||
evaluation_duration=3,
|
||||
evaluation_duration_unit="episodes",
|
||||
evaluation_parallel_to_training=True,
|
||||
)
|
||||
)
|
||||
|
||||
num_iterations = 2
|
||||
|
||||
for env in [
|
||||
"CartPole-v1",
|
||||
"Pendulum-v1",
|
||||
]:
|
||||
print("Env={}".format(env))
|
||||
for lstm in [False]:
|
||||
print("LSTM={}".format(lstm))
|
||||
config.rl_module(model_config=get_model_config(lstm=lstm))
|
||||
|
||||
algo = config.build(env=env)
|
||||
# TODO: Maybe add an API to get the Learner(s) instances within
|
||||
# a learner group, remote or not.
|
||||
learner = algo.learner_group._learner
|
||||
optim = learner.get_optimizer()
|
||||
# Check initial LR directly set in optimizer vs the first (ts=0)
|
||||
# value from the schedule.
|
||||
lr = optim.param_groups[0]["lr"]
|
||||
check(lr, config.lr[0][1])
|
||||
|
||||
# Check current entropy coeff value using the respective Scheduler.
|
||||
entropy_coeff = learner.entropy_coeff_schedulers_per_module[
|
||||
DEFAULT_MODULE_ID
|
||||
].get_current_value()
|
||||
check(entropy_coeff, 0.1)
|
||||
|
||||
for i in range(num_iterations):
|
||||
results = algo.train()
|
||||
check_train_results_new_api_stack(results)
|
||||
print(results)
|
||||
|
||||
# algo.evaluate()
|
||||
algo.stop()
|
||||
|
||||
def test_ppo_free_log_std(self):
|
||||
"""Tests the free log std option works."""
|
||||
config = (
|
||||
ppo.PPOConfig()
|
||||
.environment("Pendulum-v1")
|
||||
.env_runners(
|
||||
num_env_runners=1,
|
||||
)
|
||||
.rl_module(
|
||||
model_config=DefaultModelConfig(
|
||||
fcnet_hiddens=[10],
|
||||
fcnet_activation="linear",
|
||||
free_log_std=True,
|
||||
vf_share_layers=True,
|
||||
),
|
||||
)
|
||||
.training(
|
||||
gamma=0.99,
|
||||
)
|
||||
)
|
||||
|
||||
algo = config.build()
|
||||
module = algo.get_module(DEFAULT_MODULE_ID)
|
||||
|
||||
# Check the free log std var is created.
|
||||
matching = [v for (n, v) in module.named_parameters() if "log_std" in n]
|
||||
assert len(matching) == 1, matching
|
||||
log_std_var = matching[0]
|
||||
|
||||
def get_value(log_std_var=log_std_var):
|
||||
return log_std_var.detach().cpu().numpy()[0]
|
||||
|
||||
# Check the variable is initially zero.
|
||||
init_std = get_value()
|
||||
assert init_std == 0.0, init_std
|
||||
algo.train()
|
||||
|
||||
# Check the variable is updated.
|
||||
post_std = get_value()
|
||||
assert post_std != 0.0, post_std
|
||||
algo.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,142 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
|
||||
import ray
|
||||
import ray.rllib.algorithms.ppo as ppo
|
||||
from ray.rllib.algorithms.ppo.ppo import LEARNER_RESULTS_CURR_KL_COEFF_KEY
|
||||
from ray.rllib.core.columns import Columns
|
||||
from ray.rllib.examples.envs.classes.multi_agent import MultiAgentCartPole
|
||||
from ray.rllib.utils.metrics import LEARNER_RESULTS
|
||||
from ray.rllib.utils.test_utils import check
|
||||
from ray.tune.registry import register_env
|
||||
|
||||
# Fake CartPole episode of n time steps.
|
||||
FAKE_BATCH = {
|
||||
Columns.OBS: np.array(
|
||||
[[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8], [0.9, 1.0, 1.1, 1.2]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
Columns.NEXT_OBS: np.array(
|
||||
[[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8], [0.9, 1.0, 1.1, 1.2]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
Columns.ACTIONS: np.array([0, 1, 1]),
|
||||
Columns.REWARDS: np.array([1.0, -1.0, 0.5], dtype=np.float32),
|
||||
Columns.TERMINATEDS: np.array([False, False, True]),
|
||||
Columns.TRUNCATEDS: np.array([False, False, False]),
|
||||
Columns.VF_PREDS: np.array([0.5, 0.6, 0.7], dtype=np.float32),
|
||||
Columns.ACTION_DIST_INPUTS: np.array(
|
||||
[[-2.0, 0.5], [-3.0, -0.3], [-0.1, 2.5]], dtype=np.float32
|
||||
),
|
||||
Columns.ACTION_LOGP: np.array([-0.5, -0.1, -0.2], dtype=np.float32),
|
||||
Columns.EPS_ID: np.array([0, 0, 0]),
|
||||
}
|
||||
|
||||
|
||||
class TestPPO(unittest.TestCase):
|
||||
ENV = gym.make("CartPole-v1")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
ray.init()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
ray.shutdown()
|
||||
|
||||
def test_save_to_path_and_restore_from_path(self):
|
||||
"""Tests saving and loading the state of the PPO Learner Group."""
|
||||
config = (
|
||||
ppo.PPOConfig()
|
||||
.environment("CartPole-v1")
|
||||
.env_runners(
|
||||
num_env_runners=0,
|
||||
)
|
||||
.training(
|
||||
gamma=0.99,
|
||||
model=dict(
|
||||
fcnet_hiddens=[10, 10],
|
||||
fcnet_activation="linear",
|
||||
vf_share_layers=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
algo_config = config.copy(copy_frozen=False)
|
||||
algo_config.validate()
|
||||
algo_config.freeze()
|
||||
learner_group1 = algo_config.build_learner_group(env=self.ENV)
|
||||
learner_group2 = algo_config.build_learner_group(env=self.ENV)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
learner_group1.save_to_path(tmpdir)
|
||||
learner_group2.restore_from_path(tmpdir)
|
||||
# Remove functions from state b/c they are not comparable via `check`.
|
||||
s1 = learner_group1.get_state()
|
||||
s2 = learner_group2.get_state()
|
||||
check(s1, s2)
|
||||
|
||||
def test_kl_coeff_changes(self):
|
||||
# Simple environment with 4 independent cartpole entities
|
||||
register_env(
|
||||
"multi_agent_cartpole", lambda _: MultiAgentCartPole({"num_agents": 2})
|
||||
)
|
||||
|
||||
initial_kl_coeff = 0.01
|
||||
config = (
|
||||
ppo.PPOConfig()
|
||||
.environment("CartPole-v1")
|
||||
.env_runners(
|
||||
num_env_runners=0,
|
||||
rollout_fragment_length=50,
|
||||
exploration_config={},
|
||||
)
|
||||
.training(
|
||||
gamma=0.99,
|
||||
model=dict(
|
||||
fcnet_hiddens=[10, 10],
|
||||
fcnet_activation="linear",
|
||||
vf_share_layers=False,
|
||||
),
|
||||
kl_coeff=initial_kl_coeff,
|
||||
)
|
||||
.environment("multi_agent_cartpole")
|
||||
.multi_agent(
|
||||
policies={"p0", "p1"},
|
||||
policy_mapping_fn=lambda agent_id, episode, **kwargs: (
|
||||
"p{}".format(agent_id % 2)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
algo = config.build()
|
||||
# Call train while results aren't returned because this is
|
||||
# a asynchronous Algorithm and results are returned asynchronously.
|
||||
curr_kl_coeff_1 = None
|
||||
curr_kl_coeff_2 = None
|
||||
while not curr_kl_coeff_1 or not curr_kl_coeff_2:
|
||||
results = algo.train()
|
||||
|
||||
# Attempt to get the current KL coefficient from the learner.
|
||||
# Iterate until we have found both coefficients at least once.
|
||||
if "p0" in results[LEARNER_RESULTS]:
|
||||
curr_kl_coeff_1 = results[LEARNER_RESULTS]["p0"][
|
||||
LEARNER_RESULTS_CURR_KL_COEFF_KEY
|
||||
]
|
||||
if "p1" in results[LEARNER_RESULTS]:
|
||||
curr_kl_coeff_2 = results[LEARNER_RESULTS]["p1"][
|
||||
LEARNER_RESULTS_CURR_KL_COEFF_KEY
|
||||
]
|
||||
|
||||
self.assertNotEqual(curr_kl_coeff_1, initial_kl_coeff)
|
||||
self.assertNotEqual(curr_kl_coeff_2, initial_kl_coeff)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,192 @@
|
||||
import itertools
|
||||
import unittest
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
import tree
|
||||
|
||||
import ray
|
||||
from ray.rllib.algorithms.ppo.ppo_catalog import PPOCatalog
|
||||
from ray.rllib.algorithms.ppo.torch.default_ppo_torch_rl_module import (
|
||||
DefaultPPOTorchRLModule,
|
||||
)
|
||||
from ray.rllib.core.columns import Columns
|
||||
from ray.rllib.core.rl_module.default_model_config import DefaultModelConfig
|
||||
from ray.rllib.models.preprocessors import get_preprocessor
|
||||
from ray.rllib.utils.framework import try_import_torch
|
||||
from ray.rllib.utils.numpy import convert_to_numpy
|
||||
from ray.rllib.utils.torch_utils import convert_to_torch_tensor
|
||||
|
||||
torch, nn = try_import_torch()
|
||||
|
||||
|
||||
def dummy_torch_ppo_loss(module, batch, fwd_out):
|
||||
adv = batch[Columns.REWARDS] - module.compute_values(batch)
|
||||
action_dist_class = module.get_train_action_dist_cls()
|
||||
action_probs = action_dist_class.from_logits(
|
||||
fwd_out[Columns.ACTION_DIST_INPUTS]
|
||||
).logp(batch[Columns.ACTIONS])
|
||||
actor_loss = -(action_probs * adv).mean()
|
||||
critic_loss = (adv**2).mean()
|
||||
loss = actor_loss + critic_loss
|
||||
|
||||
return loss
|
||||
|
||||
|
||||
def _get_input_batch_from_obs(obs, lstm):
|
||||
batch = {
|
||||
Columns.OBS: convert_to_torch_tensor(obs)[None],
|
||||
}
|
||||
if lstm:
|
||||
batch[Columns.OBS] = batch[Columns.OBS][None]
|
||||
return batch
|
||||
|
||||
|
||||
class TestPPO(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
ray.init()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
ray.shutdown()
|
||||
|
||||
def test_rollouts(self):
|
||||
# TODO: Add FrozenLake-v1 to cover LSTM case.
|
||||
env_names = ["CartPole-v1", "Pendulum-v1", "ale_py:ALE/Breakout-v5"]
|
||||
fwd_fns = ["forward_exploration", "forward_inference"]
|
||||
lstm = [True, False]
|
||||
config_combinations = [env_names, fwd_fns, lstm]
|
||||
for config in itertools.product(*config_combinations):
|
||||
env_name, fwd_fn, lstm = config
|
||||
print(f"ENV={env_name}; FWD={fwd_fn}; LSTM={lstm}")
|
||||
env = gym.make(env_name)
|
||||
|
||||
preprocessor_cls = get_preprocessor(env.observation_space)
|
||||
preprocessor = preprocessor_cls(env.observation_space)
|
||||
|
||||
module = DefaultPPOTorchRLModule(
|
||||
observation_space=preprocessor.observation_space,
|
||||
action_space=env.action_space,
|
||||
model_config=DefaultModelConfig(use_lstm=lstm),
|
||||
catalog_class=PPOCatalog,
|
||||
)
|
||||
|
||||
obs, _ = env.reset()
|
||||
obs = preprocessor.transform(obs)
|
||||
|
||||
batch = _get_input_batch_from_obs(obs, lstm)
|
||||
|
||||
if lstm:
|
||||
state_in = module.get_initial_state()
|
||||
state_in = convert_to_torch_tensor(state_in)
|
||||
state_in = tree.map_structure(lambda x: x[None], state_in)
|
||||
batch[Columns.STATE_IN] = state_in
|
||||
|
||||
if fwd_fn == "forward_exploration":
|
||||
module.forward_exploration(batch)
|
||||
else:
|
||||
module.forward_inference(batch)
|
||||
|
||||
def test_forward_train(self):
|
||||
# TODO: Add FrozenLake-v1 to cover LSTM case.
|
||||
env_names = ["CartPole-v1", "Pendulum-v1", "ale_py:ALE/Breakout-v5"]
|
||||
lstm = [False, True]
|
||||
config_combinations = [env_names, lstm]
|
||||
for config in itertools.product(*config_combinations):
|
||||
env_name, lstm = config
|
||||
print(f"ENV={env_name}; LSTM={lstm}")
|
||||
env = gym.make(env_name)
|
||||
|
||||
preprocessor_cls = get_preprocessor(env.observation_space)
|
||||
preprocessor = preprocessor_cls(env.observation_space)
|
||||
|
||||
module = DefaultPPOTorchRLModule(
|
||||
observation_space=preprocessor.observation_space,
|
||||
action_space=env.action_space,
|
||||
model_config=DefaultModelConfig(use_lstm=lstm),
|
||||
catalog_class=PPOCatalog,
|
||||
)
|
||||
|
||||
# collect a batch of data
|
||||
batches = []
|
||||
obs, _ = env.reset()
|
||||
obs = preprocessor.transform(obs)
|
||||
tstep = 0
|
||||
|
||||
if lstm:
|
||||
state_in = module.get_initial_state()
|
||||
state_in = tree.map_structure(
|
||||
lambda x: x[None], convert_to_torch_tensor(state_in)
|
||||
)
|
||||
initial_state = state_in
|
||||
|
||||
while tstep < 10:
|
||||
input_batch = _get_input_batch_from_obs(obs, lstm=lstm)
|
||||
if lstm:
|
||||
input_batch[Columns.STATE_IN] = state_in
|
||||
|
||||
fwd_out = module.forward_exploration(input_batch)
|
||||
action_dist_cls = module.get_exploration_action_dist_cls()
|
||||
action_dist = action_dist_cls.from_logits(
|
||||
fwd_out[Columns.ACTION_DIST_INPUTS]
|
||||
)
|
||||
_action = action_dist.sample()
|
||||
action = convert_to_numpy(_action[0])
|
||||
action_logp = convert_to_numpy(action_dist.logp(_action)[0])
|
||||
if lstm:
|
||||
# Since this is inference, fwd out should only contain one action
|
||||
assert len(action) == 1
|
||||
action = action[0]
|
||||
new_obs, reward, terminated, truncated, _ = env.step(action)
|
||||
new_obs = preprocessor.transform(new_obs)
|
||||
output_batch = {
|
||||
Columns.OBS: obs,
|
||||
Columns.NEXT_OBS: new_obs,
|
||||
Columns.ACTIONS: action,
|
||||
Columns.ACTION_LOGP: action_logp,
|
||||
Columns.REWARDS: np.array(reward),
|
||||
Columns.TERMINATEDS: np.array(terminated),
|
||||
Columns.TRUNCATEDS: np.array(truncated),
|
||||
Columns.STATE_IN: None,
|
||||
}
|
||||
|
||||
if lstm:
|
||||
assert Columns.STATE_OUT in fwd_out
|
||||
state_in = fwd_out[Columns.STATE_OUT]
|
||||
batches.append(output_batch)
|
||||
obs = new_obs
|
||||
tstep += 1
|
||||
|
||||
# convert the list of dicts to dict of lists
|
||||
batch = tree.map_structure(lambda *x: np.array(x), *batches)
|
||||
# convert dict of lists to dict of tensors
|
||||
fwd_in = {k: convert_to_torch_tensor(np.array(v)) for k, v in batch.items()}
|
||||
if lstm:
|
||||
fwd_in[Columns.STATE_IN] = initial_state
|
||||
# If we test lstm, the collected timesteps make up only one batch
|
||||
fwd_in = {
|
||||
k: torch.unsqueeze(v, 0) if k != Columns.STATE_IN else v
|
||||
for k, v in fwd_in.items()
|
||||
}
|
||||
|
||||
# forward train
|
||||
# before training make sure module is on the right device
|
||||
# and in training mode
|
||||
module.to("cpu")
|
||||
module.train()
|
||||
fwd_out = module.forward_train(fwd_in)
|
||||
loss = dummy_torch_ppo_loss(module, fwd_in, fwd_out)
|
||||
loss.backward()
|
||||
|
||||
# check that all neural net parameters have gradients
|
||||
for param in module.parameters():
|
||||
self.assertIsNotNone(param.grad)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Unit tests for PPO's value-bootstrapping wiring.
|
||||
|
||||
Exercises the connector pipeline (``AddOneTsToEpisodesAndTruncate`` +
|
||||
``AddColumnsFromEpisodesToTrainBatch`` + ``BatchIndividualItems``) feeding into
|
||||
``compute_value_targets``. Targets are pinned to closed-form GAE answers so a
|
||||
regression in either the connector layout or the GAE recursion is caught.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ray.rllib.algorithms.ppo import PPOConfig
|
||||
from ray.rllib.connectors.env_to_module import FlattenObservations
|
||||
from ray.rllib.connectors.learner import (
|
||||
AddColumnsFromEpisodesToTrainBatch,
|
||||
AddOneTsToEpisodesAndTruncate,
|
||||
BatchIndividualItems,
|
||||
LearnerConnectorPipeline,
|
||||
)
|
||||
from ray.rllib.core import DEFAULT_MODULE_ID
|
||||
from ray.rllib.core.columns import Columns
|
||||
from ray.rllib.core.rl_module.default_model_config import DefaultModelConfig
|
||||
from ray.rllib.env.single_agent_episode import SingleAgentEpisode
|
||||
from ray.rllib.utils.framework import try_import_torch
|
||||
from ray.rllib.utils.postprocessing.value_predictions import compute_value_targets
|
||||
from ray.rllib.utils.postprocessing.zero_padding import unpad_data_if_necessary
|
||||
from ray.rllib.utils.torch_utils import convert_to_torch_tensor
|
||||
|
||||
torch, _ = try_import_torch()
|
||||
|
||||
|
||||
def _targets(per_ep_values, per_ep_rewards, terminated, truncated, gamma, lambda_):
|
||||
"""Run the real learner pipeline, then ``compute_value_targets``."""
|
||||
episodes = [
|
||||
SingleAgentEpisode(
|
||||
observations=[0] * len(v),
|
||||
actions=[0] * len(r),
|
||||
rewards=r,
|
||||
terminated=t,
|
||||
truncated=u,
|
||||
len_lookback_buffer=0,
|
||||
)
|
||||
for v, r, t, u in zip(per_ep_values, per_ep_rewards, terminated, truncated)
|
||||
]
|
||||
pipe = LearnerConnectorPipeline(
|
||||
connectors=[
|
||||
AddOneTsToEpisodesAndTruncate(),
|
||||
AddColumnsFromEpisodesToTrainBatch(),
|
||||
BatchIndividualItems(),
|
||||
]
|
||||
)
|
||||
batch = pipe(
|
||||
episodes=episodes, batch={}, rl_module=None, explore=False, shared_data={}
|
||||
)
|
||||
lens = [len(e) for e in episodes]
|
||||
flat_values = np.array([v for vs in per_ep_values for v in vs], dtype=np.float32)
|
||||
return compute_value_targets(
|
||||
values=flat_values,
|
||||
rewards=unpad_data_if_necessary(lens, np.array(batch[Columns.REWARDS])),
|
||||
terminateds=unpad_data_if_necessary(lens, np.array(batch[Columns.TERMINATEDS])),
|
||||
truncateds=unpad_data_if_necessary(lens, np.array(batch[Columns.TRUNCATEDS])),
|
||||
gamma=gamma,
|
||||
lambda_=lambda_,
|
||||
)
|
||||
|
||||
|
||||
# Length-2 episode, values=[0, 0.95, 0.95] (last entry is the duplicated
|
||||
# bootstrap slot), rewards=[0, 1, 0], gamma=0.99.
|
||||
# terminated: target[0] = gamma*v1 + gamma*lambda*(r1 - v1) = 0.9405 + 0.0495*lambda
|
||||
# target[1] = r1 = 1.0
|
||||
# truncated: target[0] = 0.9405 + gamma*lambda*delta_1 = 0.9405 + 0.99*lambda*0.9905
|
||||
# target[1] = r1 + gamma*v_extra = 1.9405
|
||||
@pytest.mark.parametrize(
|
||||
"lambda_,is_terminated,expected",
|
||||
[
|
||||
(0.0, True, [0.9405, 1.0]),
|
||||
(0.5, True, [0.9405 + 0.99 * 0.5 * 0.05, 1.0]),
|
||||
(1.0, True, [0.99, 1.0]),
|
||||
(0.0, False, [0.9405, 1.9405]),
|
||||
(0.5, False, [0.9405 + 0.99 * 0.5 * 0.9905, 1.9405]),
|
||||
(1.0, False, [0.9405 + 0.99 * 0.9905, 1.9405]),
|
||||
],
|
||||
)
|
||||
def test_single_episode_targets(lambda_, is_terminated, expected):
|
||||
"""Single episode: terminal reward propagates; truncation keeps the bootstrap."""
|
||||
out = _targets(
|
||||
per_ep_values=[[0.0, 0.95, 0.95]],
|
||||
per_ep_rewards=[[0.0, 1.0]],
|
||||
terminated=[is_terminated],
|
||||
truncated=[not is_terminated],
|
||||
gamma=0.99,
|
||||
lambda_=lambda_,
|
||||
)
|
||||
np.testing.assert_allclose(out[:2], expected, atol=1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ep1_term,ep2_term",
|
||||
[(True, True), (True, False), (False, True), (False, False)],
|
||||
)
|
||||
def test_no_cross_episode_leak(ep1_term, ep2_term):
|
||||
"""At lambda=1, episode 1's targets must not depend on episode 2."""
|
||||
pair = _targets(
|
||||
per_ep_values=[[0.0, 0.95, 0.95], [0.0, 0.95, 0.95]],
|
||||
per_ep_rewards=[[0.0, 1.0], [0.0, 1.0]],
|
||||
terminated=[ep1_term, ep2_term],
|
||||
truncated=[not ep1_term, not ep2_term],
|
||||
gamma=0.99,
|
||||
lambda_=1.0,
|
||||
)
|
||||
solo = _targets(
|
||||
per_ep_values=[[0.0, 0.95, 0.95]],
|
||||
per_ep_rewards=[[0.0, 1.0]],
|
||||
terminated=[ep1_term],
|
||||
truncated=[not ep1_term],
|
||||
gamma=0.99,
|
||||
lambda_=1.0,
|
||||
)
|
||||
np.testing.assert_allclose(pair[:2], solo[:2], atol=1e-4)
|
||||
|
||||
|
||||
# 2x2 deterministic FrozenLake used for the end-to-end convergence check below:
|
||||
# row 0: S F states 0, 1
|
||||
# row 1: H G states 2, 3
|
||||
# Reward 1.0 at G; episodes terminate at H or G.
|
||||
# Optimal policy from S: right (to F=1), then down (to G=3, reward=1).
|
||||
# Bellman closed form with gamma=0.99 on the non-terminal states:
|
||||
# V(F) = 1 + gamma * V(G_terminal) = 1.0
|
||||
# V(S) = 0 + gamma * V(F) = 0.99
|
||||
# V on the terminal states (H, G) is never targeted during training and is
|
||||
# therefore left out of the comparison.
|
||||
_FROZEN_LAKE_2X2_CFG = {"desc": ["SF", "HG"], "is_slippery": False}
|
||||
_TRUE_V_NON_TERMINAL = np.array([0.99, 1.0], dtype=np.float32)
|
||||
|
||||
|
||||
def _train_and_get_state_values(gae_lambda: float, num_iters: int, seed: int):
|
||||
"""Train PPO on 2x2 FrozenLake and return V for all 4 states."""
|
||||
config = (
|
||||
PPOConfig()
|
||||
.environment("FrozenLake-v1", env_config=_FROZEN_LAKE_2X2_CFG)
|
||||
.env_runners(
|
||||
num_env_runners=0,
|
||||
num_envs_per_env_runner=4,
|
||||
# Discrete obs -> one-hot for the FC encoder.
|
||||
env_to_module_connector=(lambda env, spaces, device: FlattenObservations()),
|
||||
)
|
||||
.training(
|
||||
gamma=0.99,
|
||||
lambda_=gae_lambda,
|
||||
lr=3e-3,
|
||||
train_batch_size=256,
|
||||
num_epochs=10,
|
||||
minibatch_size=64,
|
||||
# Up-weight the value loss and disable entropy so V converges
|
||||
# quickly and the test stays short.
|
||||
vf_loss_coeff=1.0,
|
||||
entropy_coeff=0.0,
|
||||
)
|
||||
.rl_module(
|
||||
model_config=DefaultModelConfig(
|
||||
fcnet_hiddens=[32],
|
||||
fcnet_activation="tanh",
|
||||
),
|
||||
)
|
||||
.debugging(seed=seed)
|
||||
)
|
||||
algo = config.build_algo()
|
||||
|
||||
for _ in range(num_iters):
|
||||
algo.train()
|
||||
# `algo.get_module(...)` returns the EnvRunner's inference-only
|
||||
# module (no critic). Reach into the Learner's module to call
|
||||
# compute_values.
|
||||
learner_module = algo.learner_group._learner.module[DEFAULT_MODULE_ID]
|
||||
obs = np.eye(4, dtype=np.float32) # one-hot for each of the 4 states
|
||||
with torch.no_grad():
|
||||
return (
|
||||
learner_module.compute_values({Columns.OBS: convert_to_torch_tensor(obs)})
|
||||
.detach()
|
||||
.cpu()
|
||||
.numpy()
|
||||
)
|
||||
|
||||
|
||||
def test_value_function_converges_across_gae_lambda():
|
||||
"""
|
||||
End-to-end check that PPO trains a consistent V across `gae_lambda`.
|
||||
Different lambda values should converge to the same fixed-point V.
|
||||
"""
|
||||
v_by_lambda = {
|
||||
lam: _train_and_get_state_values(gae_lambda=lam, num_iters=40, seed=42)
|
||||
for lam in [0.0, 0.9, 1.0]
|
||||
}
|
||||
# 1) V on the visited (non-terminal) states matches the analytic V.
|
||||
for lam, v in v_by_lambda.items():
|
||||
np.testing.assert_allclose(
|
||||
v[:2],
|
||||
_TRUE_V_NON_TERMINAL,
|
||||
atol=0.05,
|
||||
err_msg=(
|
||||
f"V on non-terminal states diverged from analytic V "
|
||||
f"for lambda={lam}: got {v[:2]}, expected "
|
||||
f"{_TRUE_V_NON_TERMINAL}"
|
||||
),
|
||||
)
|
||||
# 2) V across the three lambdas must converge together.
|
||||
assert np.ptp([v[:2] for v in v_by_lambda.values()], axis=0).max() < 0.05
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ray.rllib.algorithms.ppo.default_ppo_rl_module import DefaultPPORLModule
|
||||
from ray.rllib.algorithms.ppo.ppo_catalog import PPOCatalog
|
||||
from ray.rllib.core.columns import Columns
|
||||
from ray.rllib.core.models.base import ACTOR, CRITIC, ENCODER_OUT
|
||||
from ray.rllib.core.rl_module.apis.value_function_api import ValueFunctionAPI
|
||||
from ray.rllib.core.rl_module.rl_module import RLModule
|
||||
from ray.rllib.core.rl_module.torch import TorchRLModule
|
||||
from ray.rllib.utils.annotations import override
|
||||
from ray.rllib.utils.framework import try_import_torch
|
||||
from ray.rllib.utils.typing import TensorType
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
torch, nn = try_import_torch()
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DefaultPPOTorchRLModule(TorchRLModule, DefaultPPORLModule):
|
||||
def __init__(self, *args, **kwargs):
|
||||
catalog_class = kwargs.pop("catalog_class", None)
|
||||
if catalog_class is None:
|
||||
catalog_class = PPOCatalog
|
||||
super().__init__(*args, **kwargs, catalog_class=catalog_class)
|
||||
|
||||
@override(RLModule)
|
||||
def _forward(self, batch: Dict[str, Any], **kwargs) -> Dict[str, Any]:
|
||||
"""Default forward pass (used for inference and exploration)."""
|
||||
output = {}
|
||||
# Encoder forward pass.
|
||||
encoder_outs = self.encoder(batch)
|
||||
# Stateful encoder?
|
||||
if Columns.STATE_OUT in encoder_outs:
|
||||
output[Columns.STATE_OUT] = encoder_outs[Columns.STATE_OUT]
|
||||
# Pi head.
|
||||
output[Columns.ACTION_DIST_INPUTS] = self.pi(encoder_outs[ENCODER_OUT][ACTOR])
|
||||
return output
|
||||
|
||||
@override(RLModule)
|
||||
def _forward_train(self, batch: Dict[str, Any], **kwargs) -> Dict[str, Any]:
|
||||
"""Train forward pass (keep embeddings for possible shared value func. call)."""
|
||||
output = {}
|
||||
encoder_outs = self.encoder(batch)
|
||||
output[Columns.EMBEDDINGS] = encoder_outs[ENCODER_OUT][CRITIC]
|
||||
if Columns.STATE_OUT in encoder_outs:
|
||||
output[Columns.STATE_OUT] = encoder_outs[Columns.STATE_OUT]
|
||||
output[Columns.ACTION_DIST_INPUTS] = self.pi(encoder_outs[ENCODER_OUT][ACTOR])
|
||||
return output
|
||||
|
||||
@override(ValueFunctionAPI)
|
||||
def compute_values(
|
||||
self,
|
||||
batch: Dict[str, Any],
|
||||
embeddings: Optional[Any] = None,
|
||||
) -> TensorType:
|
||||
if embeddings is None:
|
||||
# Separate vf-encoder.
|
||||
if hasattr(self.encoder, "critic_encoder"):
|
||||
batch_ = batch
|
||||
if self.is_stateful():
|
||||
# The recurrent encoders expect a `(state_in, h)` key in the
|
||||
# input dict while the key returned is `(state_in, critic, h)`.
|
||||
batch_ = batch.copy()
|
||||
batch_[Columns.STATE_IN] = batch[Columns.STATE_IN][CRITIC]
|
||||
embeddings = self.encoder.critic_encoder(batch_)[ENCODER_OUT]
|
||||
# Shared encoder.
|
||||
else:
|
||||
embeddings = self.encoder(batch)[ENCODER_OUT][CRITIC]
|
||||
|
||||
# Value head.
|
||||
vf_out = self.vf(embeddings)
|
||||
# Squeeze out last dimension (single node value head).
|
||||
return vf_out.squeeze(-1)
|
||||
@@ -0,0 +1,173 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.rllib.algorithms.ppo.ppo import (
|
||||
LEARNER_RESULTS_CURR_KL_COEFF_KEY,
|
||||
LEARNER_RESULTS_KL_KEY,
|
||||
LEARNER_RESULTS_VF_EXPLAINED_VAR_KEY,
|
||||
LEARNER_RESULTS_VF_LOSS_UNCLIPPED_KEY,
|
||||
PPOConfig,
|
||||
)
|
||||
from ray.rllib.algorithms.ppo.ppo_learner import PPOLearner
|
||||
from ray.rllib.core.columns import Columns
|
||||
from ray.rllib.core.learner.learner import ENTROPY_KEY, POLICY_LOSS_KEY, VF_LOSS_KEY
|
||||
from ray.rllib.core.learner.torch.torch_learner import TorchLearner
|
||||
from ray.rllib.evaluation.postprocessing import Postprocessing
|
||||
from ray.rllib.utils.annotations import override
|
||||
from ray.rllib.utils.framework import try_import_torch
|
||||
from ray.rllib.utils.torch_utils import explained_variance
|
||||
from ray.rllib.utils.typing import ModuleID, TensorType
|
||||
|
||||
torch, nn = try_import_torch()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PPOTorchLearner(PPOLearner, TorchLearner):
|
||||
"""Implements torch-specific PPO loss logic on top of PPOLearner.
|
||||
|
||||
This class implements the ppo loss under `self.compute_loss_for_module()`.
|
||||
"""
|
||||
|
||||
@override(TorchLearner)
|
||||
def compute_loss_for_module(
|
||||
self,
|
||||
*,
|
||||
module_id: ModuleID,
|
||||
config: PPOConfig,
|
||||
batch: Dict[str, Any],
|
||||
fwd_out: Dict[str, TensorType],
|
||||
) -> TensorType:
|
||||
module = self.module[module_id].unwrapped()
|
||||
|
||||
# Possibly apply masking to some sub loss terms and to the total loss term
|
||||
# at the end. Masking could be used for RNN-based model (zero padded `batch`)
|
||||
# and for PPO's batched value function (and bootstrap value) computations,
|
||||
# for which we add an (artificial) timestep to each episode to
|
||||
# simplify the actual computation.
|
||||
if Columns.LOSS_MASK in batch:
|
||||
mask = batch[Columns.LOSS_MASK]
|
||||
num_valid = torch.sum(mask)
|
||||
|
||||
def possibly_masked_mean(data_):
|
||||
return torch.sum(data_[mask]) / num_valid
|
||||
|
||||
else:
|
||||
possibly_masked_mean = torch.mean
|
||||
|
||||
action_dist_class_train = module.get_train_action_dist_cls()
|
||||
action_dist_class_exploration = module.get_exploration_action_dist_cls()
|
||||
|
||||
curr_action_dist = action_dist_class_train.from_logits(
|
||||
fwd_out[Columns.ACTION_DIST_INPUTS]
|
||||
)
|
||||
# TODO (sven): We should ideally do this in the LearnerConnector (separation of
|
||||
# concerns: Only do things on the EnvRunners that are required for computing
|
||||
# actions, do NOT do anything on the EnvRunners that's only required for a
|
||||
# training update).
|
||||
prev_action_dist = action_dist_class_exploration.from_logits(
|
||||
batch[Columns.ACTION_DIST_INPUTS]
|
||||
)
|
||||
|
||||
logp_ratio = torch.exp(
|
||||
curr_action_dist.logp(batch[Columns.ACTIONS]) - batch[Columns.ACTION_LOGP]
|
||||
)
|
||||
|
||||
# Only calculate kl loss if necessary (kl-coeff > 0.0).
|
||||
if config.use_kl_loss:
|
||||
action_kl = prev_action_dist.kl(curr_action_dist)
|
||||
mean_kl_loss = possibly_masked_mean(action_kl)
|
||||
else:
|
||||
mean_kl_loss = torch.tensor(0.0, device=logp_ratio.device)
|
||||
|
||||
curr_entropy = curr_action_dist.entropy()
|
||||
mean_entropy = possibly_masked_mean(curr_entropy)
|
||||
|
||||
surrogate_loss = torch.min(
|
||||
batch[Postprocessing.ADVANTAGES] * logp_ratio,
|
||||
batch[Postprocessing.ADVANTAGES]
|
||||
* torch.clamp(logp_ratio, 1 - config.clip_param, 1 + config.clip_param),
|
||||
)
|
||||
|
||||
# Compute a value function loss.
|
||||
if config.use_critic:
|
||||
value_fn_out = module.compute_values(
|
||||
batch, embeddings=fwd_out.get(Columns.EMBEDDINGS)
|
||||
)
|
||||
vf_loss = torch.pow(value_fn_out - batch[Postprocessing.VALUE_TARGETS], 2.0)
|
||||
vf_loss_clipped = torch.clamp(vf_loss, 0, config.vf_clip_param)
|
||||
mean_vf_loss = possibly_masked_mean(vf_loss_clipped)
|
||||
mean_vf_unclipped_loss = possibly_masked_mean(vf_loss)
|
||||
# Ignore the value function -> Set all to 0.0.
|
||||
else:
|
||||
z = torch.tensor(0.0, device=surrogate_loss.device)
|
||||
value_fn_out = mean_vf_unclipped_loss = vf_loss_clipped = mean_vf_loss = z
|
||||
|
||||
total_loss = possibly_masked_mean(
|
||||
-surrogate_loss
|
||||
+ config.vf_loss_coeff * vf_loss_clipped
|
||||
- (
|
||||
self.entropy_coeff_schedulers_per_module[module_id].get_current_value()
|
||||
* curr_entropy
|
||||
)
|
||||
)
|
||||
|
||||
# Add mean_kl_loss (already processed through `possibly_masked_mean`),
|
||||
# if necessary.
|
||||
if config.use_kl_loss:
|
||||
total_loss += self.curr_kl_coeffs_per_module[module_id] * mean_kl_loss
|
||||
|
||||
# Log important loss stats.
|
||||
self.metrics.log_dict(
|
||||
{
|
||||
POLICY_LOSS_KEY: -possibly_masked_mean(surrogate_loss),
|
||||
VF_LOSS_KEY: mean_vf_loss,
|
||||
LEARNER_RESULTS_VF_LOSS_UNCLIPPED_KEY: mean_vf_unclipped_loss,
|
||||
LEARNER_RESULTS_VF_EXPLAINED_VAR_KEY: explained_variance(
|
||||
batch[Postprocessing.VALUE_TARGETS], value_fn_out
|
||||
),
|
||||
ENTROPY_KEY: mean_entropy,
|
||||
LEARNER_RESULTS_KL_KEY: mean_kl_loss,
|
||||
},
|
||||
key=module_id,
|
||||
window=1, # <- single items (should not be mean/ema-reduced over time).
|
||||
)
|
||||
# Return the total loss.
|
||||
return total_loss
|
||||
|
||||
@override(PPOLearner)
|
||||
def _update_module_kl_coeff(
|
||||
self,
|
||||
*,
|
||||
module_id: ModuleID,
|
||||
config: PPOConfig,
|
||||
kl_loss: float,
|
||||
) -> None:
|
||||
if np.isnan(kl_loss):
|
||||
logger.warning(
|
||||
f"KL divergence for Module {module_id} is non-finite, this "
|
||||
"will likely destabilize your model and the training "
|
||||
"process. Action(s) in a specific state have near-zero "
|
||||
"probability. This can happen naturally in deterministic "
|
||||
"environments where the optimal policy has zero mass for a "
|
||||
"specific action. To fix this issue, consider setting "
|
||||
"`kl_coeff` to 0.0 or increasing `entropy_coeff` in your "
|
||||
"config."
|
||||
)
|
||||
|
||||
# Update the KL coefficient.
|
||||
curr_var = self.curr_kl_coeffs_per_module[module_id]
|
||||
if kl_loss > 2.0 * config.kl_target:
|
||||
# TODO (Kourosh) why not 2?
|
||||
curr_var.data *= 1.5
|
||||
elif kl_loss < 0.5 * config.kl_target:
|
||||
curr_var.data *= 0.5
|
||||
|
||||
# Log the updated KL-coeff value.
|
||||
self.metrics.log_value(
|
||||
(module_id, LEARNER_RESULTS_CURR_KL_COEFF_KEY),
|
||||
curr_var.item(),
|
||||
window=1,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Backward compat import.
|
||||
from ray.rllib.algorithms.ppo.torch.default_ppo_torch_rl_module import ( # noqa
|
||||
DefaultPPOTorchRLModule as PPOTorchRLModule,
|
||||
)
|
||||
from ray._common.deprecation import deprecation_warning
|
||||
|
||||
|
||||
deprecation_warning(
|
||||
old="ray.rllib.algorithms.ppo.torch.ppo_torch_rl_module.PPOTorchRLModule",
|
||||
new="ray.rllib.algorithms.ppo.torch.default_ppo_torch_rl_module."
|
||||
"DefaultPPOTorchRLModule",
|
||||
error=False,
|
||||
)
|
||||
Reference in New Issue
Block a user