import math from enum import Enum from typing import ( TYPE_CHECKING, Collection, Dict, Iterable, List, Optional, Union, ) import gymnasium as gym import numpy import ray from ray.data.iterator import DataIterator from ray.rllib.connectors.env_to_module import EnvToModulePipeline from ray.rllib.core import ( ALL_MODULES, COMPONENT_ENV_TO_MODULE_CONNECTOR, COMPONENT_RL_MODULE, DEFAULT_AGENT_ID, DEFAULT_MODULE_ID, ) from ray.rllib.core.columns import Columns from ray.rllib.core.rl_module.multi_rl_module import MultiRLModuleSpec from ray.rllib.env.single_agent_episode import SingleAgentEpisode from ray.rllib.offline.offline_prelearner import OfflinePreLearner from ray.rllib.policy.sample_batch import MultiAgentBatch from ray.rllib.utils.annotations import override from ray.rllib.utils.checkpoints import Checkpointable from ray.rllib.utils.framework import get_device, try_import_torch from ray.rllib.utils.metrics import ( DATASET_NUM_ITERS_EVALUATED, DATASET_NUM_ITERS_EVALUATED_LIFETIME, EPISODE_LEN_MAX, EPISODE_LEN_MEAN, EPISODE_LEN_MIN, EPISODE_RETURN_MAX, EPISODE_RETURN_MEAN, EPISODE_RETURN_MIN, MODULE_SAMPLE_BATCH_SIZE_MEAN, NUM_ENV_STEPS_SAMPLED, NUM_ENV_STEPS_SAMPLED_LIFETIME, NUM_MODULE_STEPS_SAMPLED, NUM_MODULE_STEPS_SAMPLED_LIFETIME, OFFLINE_SAMPLING_TIMER, WEIGHTS_SEQ_NO, ) from ray.rllib.utils.minibatch_utils import MiniBatchRayDataIterator from ray.rllib.utils.runners.runner import Runner from ray.rllib.utils.torch_utils import convert_to_torch_tensor from ray.rllib.utils.typing import ( DeviceType, EpisodeID, StateDict, TensorType, ) if TYPE_CHECKING: from ray.rllib.algorithms.algorithm_config import AlgorithmConfig torch, _ = try_import_torch() # TODO (simon): Implement more ... class OfflinePolicyEvaluationTypes(str, Enum): """Defines the offline policy evaluation types. EVAL_LOSS: Evaluates the policy by computing the loss on a held-out validation dataset. IS: Importance Sampling. PDIS: Per-Decision Importance Sampling. In contrast to IS this method weighs each reward and not the return as a whole. As a result it usually exhibits lower variance. """ EVAL_LOSS = "eval_loss" IS = "is" PDIS = "pdis" class MiniBatchEpisodeRayDataIterator(MiniBatchRayDataIterator): """A minibatch iterator that yields episodes from Ray Datasets.""" def __init__( self, *, iterator: DataIterator, device: DeviceType, minibatch_size: int, num_iters: Optional[int], **kwargs, ): # A `ray.data.DataIterator` that can iterate in different ways over the data. self._iterator = iterator # Note, in multi-learner settings the `return_state` is in `kwargs`. self._kwargs = {k: v for k, v in kwargs.items() if k != "return_state"} self._device = device # Holds a batched_iterable over the dataset. self._batched_iterable = self._iterator.iter_batches( batch_size=minibatch_size, **self._kwargs, ) # Create an iterator that can be stopped and resumed during an epoch. self._epoch_iterator = iter(self._batched_iterable) self._num_iters = num_iters def _collate_fn( self, _batch: Dict[EpisodeID, Dict[str, numpy.ndarray]], ) -> Dict[EpisodeID, Dict[str, TensorType]]: """Converts a batch of episodes to torch tensors.""" # Avoid torch import error when framework is tensorflow. # Note (artur): This can be removed when we remove tf support. from ray.data.util.torch_utils import ( convert_ndarray_batch_to_torch_tensor_batch, ) return [ convert_ndarray_batch_to_torch_tensor_batch( episode, device=self._device, dtypes=torch.float32 ) for episode in _batch["episodes"] ] def __iter__(self) -> Iterable[List[Dict[str, numpy.ndarray]]]: """Yields minibatches of episodes.""" iteration = 0 while self._num_iters is None or iteration < self._num_iters: for batch in self._epoch_iterator: # Update the iteration counter. iteration += 1 # Convert batch to tensors. batch = self._collate_fn(batch) yield (batch) # If `num_iters` is reached break and return. if self._num_iters and iteration == self._num_iters: break else: # Reinstantiate a new epoch iterator. self._epoch_iterator = iter(self._batched_iterable) # If a full epoch on the data should be run, stop. if not self._num_iters: # Exit the loop. break class OfflinePolicyPreEvaluator(OfflinePreLearner): def __call__(self, batch: Dict[str, numpy.ndarray]) -> Dict[str, numpy.ndarray]: # If we directly read in episodes we just convert to list. if self.config.input_read_episodes: # Import `msgpack` for decoding. import msgpack import msgpack_numpy as mnp # Read the episodes and decode them. episodes: List[SingleAgentEpisode] = [ SingleAgentEpisode.from_state( msgpack.unpackb(state, object_hook=mnp.decode) ) for state in batch["item"] ] # Ensure that all episodes are done and no duplicates are in the batch. episodes = self._validate_episodes(episodes) # Add the episodes to the buffer. self.episode_buffer.add(episodes) # TODO (simon): Refactor into a single code block for both cases. episodes = self.episode_buffer.sample( num_items=self.config.train_batch_size_per_learner, batch_length_T=( self.config.model_config.get("max_seq_len", 0) if self._module.is_stateful() else None ), n_step=self.config.get("n_step", 1) or 1, # TODO (simon): This can be removed as soon as DreamerV3 has been # cleaned up, i.e. can use episode samples for training. sample_episodes=True, to_numpy=True, ) # Else, if we have old stack `SampleBatch`es. elif self.config.input_read_sample_batches: episodes: List[ SingleAgentEpisode ] = OfflinePreLearner._map_sample_batch_to_episode( self._is_multi_agent, batch, to_numpy=True, input_compress_columns=self.config.input_compress_columns, )[ "episodes" ] # Ensure that all episodes are done and no duplicates are in the batch. episodes = self._validate_episodes(episodes) # Add the episodes to the buffer. self.episode_buffer.add(episodes) # Sample steps from the buffer. episodes = self.episode_buffer.sample( num_items=self.config.train_batch_size_per_learner, batch_length_T=( self.config.model_config.get("max_seq_len", 0) if self._module.is_stateful() else None ), n_step=self.config.get("n_step", 1) or 1, # TODO (simon): This can be removed as soon as DreamerV3 has been # cleaned up, i.e. can use episode samples for training. sample_episodes=True, to_numpy=True, ) # Otherwise we map the batch to episodes. else: episodes: List[SingleAgentEpisode] = self._map_to_episodes( batch, to_numpy=False )["episodes"] episode_dicts = [] for episode in episodes: # Note, we expect users to provide terminated episodes in `SingleAgentEpisode` # or `SampleBatch` format. Otherwise computation of episode returns will be # biased. episode_dict = {} episode_dict[Columns.OBS] = episode.get_observations(slice(0, len(episode))) episode_dict[Columns.ACTIONS] = episode.get_actions() episode_dict[Columns.REWARDS] = episode.get_rewards() episode_dict[Columns.ACTION_LOGP] = episode.get_extra_model_outputs( key=Columns.ACTION_LOGP ) episode_dicts.append(episode_dict) return {"episodes": episode_dicts} class OfflinePolicyEvaluationRunner(Runner, Checkpointable): def __init__( self, config: "AlgorithmConfig", module_spec: Optional[MultiRLModuleSpec] = None, **kwargs, ): # This needs to be defined before we call the `Runner.__init__` # b/c the latter calls the `make_module` and then needs the spec. # TODO (simon): Check, if we make this a generic attribute. self.__module_spec: MultiRLModuleSpec = module_spec self.__dataset_iterator = None self.__batch_iterator = None Runner.__init__(self, config=config, **kwargs) Checkpointable.__init__(self) # This has to be defined after we have a `self.config`. self.__spaces = kwargs.get("spaces") self.__env_to_module = self.config.build_env_to_module_connector( spaces=self._spaces, device=self._device ) self.__offline_evaluation_type = OfflinePolicyEvaluationTypes( self.config["offline_evaluation_type"] ) def run( self, explore: bool = False, train: bool = True, **kwargs, ) -> None: if self.__dataset_iterator is None: raise ValueError( f"{self} doesn't have a data iterator. Can't call `run` on " "`OfflinePolicyEvaluationRunner`." ) if not self._batch_iterator: self.__batch_iterator = self._create_batch_iterator( **self.config.iter_batches_kwargs ) # Log current weight seq no. self.metrics.log_value( key=WEIGHTS_SEQ_NO, value=self._weights_seq_no, window=1, ) with self.metrics.log_time(OFFLINE_SAMPLING_TIMER): if explore is None: explore = self.config.explore # Evaluate on offline data. return self._evaluate( explore=explore, train=train, ) def _create_batch_iterator(self, **kwargs) -> Iterable: return MiniBatchEpisodeRayDataIterator( iterator=self._dataset_iterator, device=self._device, minibatch_size=self.config.offline_eval_batch_size_per_runner, num_iters=self.config.dataset_num_iters_per_eval_runner, **kwargs, ) def _evaluate( self, explore: bool, train: bool, ) -> None: num_env_steps = 0 for iteration, tensor_minibatch in enumerate(self._batch_iterator): for episode in tensor_minibatch: action_dist_cls = self.module[ DEFAULT_MODULE_ID ].get_inference_action_dist_cls() # TODO (simon): It needs here the `EnvToModule` pipeline. action_logits = self.module[DEFAULT_MODULE_ID].forward_inference( episode )[Columns.ACTION_DIST_INPUTS] # TODO (simon): It might need here the ModuleToEnv pipeline until the # `GetActions` piece. action_dist = action_dist_cls.from_logits(action_logits) actions = action_dist.sample() action_logp = action_dist.logp(actions) # If we have action log-probs use them. if Columns.ACTION_LOGP in episode: behavior_action_logp = episode[Columns.ACTION_LOGP] # Otherwise approximate them via the current action distribution. else: behavior_action_logp = action_dist.logp(episode[Columns.ACTIONS]) # Compute the weights. if self.__offline_evaluation_type == OfflinePolicyEvaluationTypes.IS: weight = torch.prod( torch.exp(action_logp) / torch.exp(behavior_action_logp) ) # Note, we use the (un)-discounted return to compare with the `EnvRunner` # returns. episode_return = episode[Columns.REWARDS].sum() offline_return = (weight * episode_return).item() elif ( self.__offline_evaluation_type == OfflinePolicyEvaluationTypes.PDIS ): weights = torch.exp(action_logp) / torch.exp(behavior_action_logp) offline_return = torch.dot(weights, episode[Columns.REWARDS]).item() episode_len = episode[Columns.REWARDS].shape[0] num_env_steps += episode_len self._log_episode_metrics(episode_len, offline_return) self._log_batch_metrics(len(tensor_minibatch), num_env_steps) # Record the number of batches pulled from the dataset. self.metrics.log_value( (ALL_MODULES, DATASET_NUM_ITERS_EVALUATED), iteration + 1, reduce="sum", ) self.metrics.log_value( (ALL_MODULES, DATASET_NUM_ITERS_EVALUATED_LIFETIME), iteration + 1, reduce="lifetime_sum", ) return self.metrics.reduce() @override(Checkpointable) def get_ctor_args_and_kwargs(self): return ( (), # *args {"config": self.config}, # **kwargs ) @override(Checkpointable) def get_state( self, components: Optional[Union[str, Collection[str]]] = None, *, not_components: Optional[Union[str, Collection[str]]] = None, **kwargs, ) -> StateDict: state = { NUM_ENV_STEPS_SAMPLED_LIFETIME: ( self.metrics.peek(NUM_ENV_STEPS_SAMPLED_LIFETIME, default=0) ), } if self._check_component(COMPONENT_RL_MODULE, components, not_components): state[COMPONENT_RL_MODULE] = self.module.get_state( components=self._get_subcomponents(COMPONENT_RL_MODULE, components), not_components=self._get_subcomponents( COMPONENT_RL_MODULE, not_components ), **kwargs, ) state[WEIGHTS_SEQ_NO] = self._weights_seq_no if self._check_component( COMPONENT_ENV_TO_MODULE_CONNECTOR, components, not_components ): state[COMPONENT_ENV_TO_MODULE_CONNECTOR] = self._env_to_module.get_state() return state def _convert_to_tensor(self, struct) -> TensorType: """Converts structs to a framework-specific tensor.""" return convert_to_torch_tensor(struct) def stop(self) -> None: """Releases all resources used by this EnvRunner. For example, when using a gym.Env in this EnvRunner, you should make sure that its `close()` method is called. """ pass def __del__(self) -> None: """If this Actor is deleted, clears all resources used by it.""" pass @override(Runner) def assert_healthy(self): """Checks that self.__init__() has been completed properly. Ensures that the instances has a `MultiRLModule` and an environment defined. Raises: AssertionError: If the EnvRunner Actor has NOT been properly initialized. """ # Make sure, we have built our RLModule properly and assigned a dataset iterator. assert self._dataset_iterator and hasattr(self, "module") @override(Runner) def get_metrics(self): return self.metrics.reduce() def _convert_batch_type( self, batch: MultiAgentBatch, to_device: bool = True, pin_memory: bool = False, use_stream: bool = False, ) -> MultiAgentBatch: batch = convert_to_torch_tensor( batch.policy_batches, device=self._device if to_device else None, pin_memory=pin_memory, use_stream=use_stream, ) # TODO (sven): This computation of `env_steps` is not accurate! length = max(len(b) for b in batch.values()) batch = MultiAgentBatch(batch, env_steps=length) return batch @override(Checkpointable) def set_state(self, state: StateDict) -> None: if COMPONENT_ENV_TO_MODULE_CONNECTOR in state: self._env_to_module.set_state(state[COMPONENT_ENV_TO_MODULE_CONNECTOR]) # Update the RLModule state. if COMPONENT_RL_MODULE in state: # A missing value for WEIGHTS_SEQ_NO or a value of 0 means: Force the # update. weights_seq_no = state.get(WEIGHTS_SEQ_NO, 0) # Only update the weigths, if this is the first synchronization or # if the weights of this `EnvRunner` lacks behind the actual ones. if weights_seq_no == 0 or self._weights_seq_no < weights_seq_no: rl_module_state = state[COMPONENT_RL_MODULE] if isinstance(rl_module_state, ray.ObjectRef): rl_module_state = ray.get(rl_module_state) self.module.set_state(rl_module_state) # Update our weights_seq_no, if the new one is > 0. if weights_seq_no > 0: self._weights_seq_no = weights_seq_no def _log_episode_metrics(self, episode_len: int, episode_return: float) -> None: """Logs episode metrics for each episode.""" # Log general episode metrics. # Use the configured window, but factor in the parallelism of the # `OfflinePolicyEvaluationRunners`. As a result, we only log the last # `window / num_env_runners` steps here, b/c everything gets # parallel-merged in the Algorithm process. win = max( 1, int( math.ceil( self.config.metrics_num_episodes_for_smoothing / (self.config.num_offline_eval_runners or 1) ) ), ) self.metrics.log_value(EPISODE_LEN_MEAN, episode_len, window=win) self.metrics.log_value(EPISODE_RETURN_MEAN, episode_return, window=win) # Per-agent returns. self.metrics.log_value( ("agent_episode_return_mean", DEFAULT_AGENT_ID), episode_return, window=win ) # Per-RLModule returns. self.metrics.log_value( ("module_episode_return_mean", DEFAULT_MODULE_ID), episode_return, window=win, ) # For some metrics, log min/max as well. self.metrics.log_value(EPISODE_LEN_MIN, episode_len, reduce="min", window=win) self.metrics.log_value( EPISODE_RETURN_MIN, episode_return, reduce="min", window=win ) self.metrics.log_value(EPISODE_LEN_MAX, episode_len, reduce="max", window=win) self.metrics.log_value( EPISODE_RETURN_MAX, episode_return, reduce="max", window=win ) def _log_batch_metrics(self, batch_size: int, num_env_steps: int): """Logs batch metrics for each mini batch.""" # Note, Offline RL does not support multi-agent RLModules yet. # Log weights seq no for this batch. self.metrics.log_value( (DEFAULT_MODULE_ID, WEIGHTS_SEQ_NO), self._weights_seq_no, window=1, ) # Log average batch size (for each module). self.metrics.log_value( key=(DEFAULT_MODULE_ID, MODULE_SAMPLE_BATCH_SIZE_MEAN), value=batch_size, ) # Log module steps (for each module). self.metrics.log_value( key=(DEFAULT_MODULE_ID, NUM_MODULE_STEPS_SAMPLED), value=num_env_steps, reduce="sum", ) self.metrics.log_value( key=(DEFAULT_MODULE_ID, NUM_MODULE_STEPS_SAMPLED_LIFETIME), value=num_env_steps, reduce="lifetime_sum", with_throughput=True, ) # Log module steps (sum of all modules). self.metrics.log_value( key=(ALL_MODULES, NUM_MODULE_STEPS_SAMPLED), value=num_env_steps, reduce="sum", ) self.metrics.log_value( key=(ALL_MODULES, NUM_MODULE_STEPS_SAMPLED_LIFETIME), value=num_env_steps, reduce="lifetime_sum", with_throughput=True, ) # Log env steps (all modules). self.metrics.log_value( key=(ALL_MODULES, NUM_ENV_STEPS_SAMPLED), value=num_env_steps, reduce="sum", ) self.metrics.log_value( key=(ALL_MODULES, NUM_ENV_STEPS_SAMPLED_LIFETIME), value=num_env_steps, reduce="lifetime_sum", with_throughput=True, ) @override(Runner) def set_device(self): try: self.__device = get_device( self.config, ( 0 if not self.worker_index else self.config.num_gpus_per_offline_eval_runner ), ) except NotImplementedError: self.__device = None @override(Runner) def make_module(self): try: from ray.rllib.env import INPUT_ENV_SPACES if not self._module_spec: self.__module_spec = self.config.get_multi_rl_module_spec( # Note, usually we have no environemnt in case of offline evaluation. env=self.config.env, spaces={ INPUT_ENV_SPACES: ( self.config.observation_space, self.config.action_space, ) }, inference_only=self.config.offline_eval_rl_module_inference_only, ) # Build the module from its spec. self.module = self._module_spec.build() # TODO (simon): Implement GPU inference. # Move the RLModule to our device. # TODO (sven): In order to make this framework-agnostic, we should maybe # make the MultiRLModule.build() method accept a device OR create an # additional `(Multi)RLModule.to()` override. self.module.foreach_module( lambda mid, mod: ( mod.to(self._device) if isinstance(mod, torch.nn.Module) else mod ) ) # If `AlgorithmConfig.get_multi_rl_module_spec()` is not implemented, this env runner # will not have an RLModule, but might still be usable with random actions. except NotImplementedError: self.module = None @property def _dataset_iterator(self) -> DataIterator: """Returns the dataset iterator.""" return self.__dataset_iterator def set_dataset_iterator(self, iterator): """Sets the dataset iterator.""" self.__dataset_iterator = iterator @property def _batch_iterator(self) -> MiniBatchRayDataIterator: return self.__batch_iterator @property def _device(self) -> Union[DeviceType, None]: return self.__device @property def _module_spec(self) -> MultiRLModuleSpec: """Returns the `MultiRLModuleSpec` of this `Runner`.""" return self.__module_spec @property def _spaces(self) -> Dict[str, gym.spaces.Space]: """Returns the spaces of thsi `Runner`.""" return self.__spaces @property def _env_to_module(self) -> EnvToModulePipeline: """Returns the env-to-module pipeline of this `Runner`.""" return self.__env_to_module @property def _offline_evaluation_type(self) -> Enum: """Returns the offline evaluation type of this `Runner`.""" return self.__offline_evaluation_type