import tempfile import unittest import gymnasium as gym import numpy as np import ray from ray.rllib.core import DEFAULT_MODULE_ID from ray.rllib.core.learner.learner import Learner from ray.rllib.core.testing.testing_learner import BaseTestingAlgorithmConfig from ray.rllib.policy.sample_batch import MultiAgentBatch from ray.rllib.utils.framework import try_import_torch from ray.rllib.utils.metrics import ( ALL_MODULES, MODULE_TRAIN_BATCH_SIZE_MEAN, NUM_ENV_STEPS_TRAINED, NUM_ENV_STEPS_TRAINED_LIFETIME, NUM_MODULE_STEPS_TRAINED, NUM_MODULE_STEPS_TRAINED_LIFETIME, WEIGHTS_SEQ_NO, ) from ray.rllib.utils.numpy import convert_to_numpy from ray.rllib.utils.test_utils import check, get_cartpole_dataset_reader torch, _ = try_import_torch() class TestLearner(unittest.TestCase): ENV = gym.make("CartPole-v1") @classmethod def setUp(cls) -> None: ray.init() @classmethod def tearDown(cls) -> None: ray.shutdown() def test_end_to_end_update(self): """Tests the end-to-end update process for a single-agent scenario. We check that the loss is decreasing and that the metrics are where we expect them and that values are as expected. """ config = BaseTestingAlgorithmConfig() learner = config.build_learner(env=self.ENV) reader = get_cartpole_dataset_reader(batch_size=512) for seq_num in range(1, 1000): batch = reader.next().as_multi_agent() batch = learner._convert_batch_type(batch) results = learner.update(batch=batch) self.assertEqual( batch.count, results[DEFAULT_MODULE_ID][MODULE_TRAIN_BATCH_SIZE_MEAN] ) self.assertEqual( batch.count, results[DEFAULT_MODULE_ID][NUM_MODULE_STEPS_TRAINED] ) self.assertEqual( batch.count, results[DEFAULT_MODULE_ID][NUM_MODULE_STEPS_TRAINED_LIFETIME], ) self.assertEqual(seq_num, results[DEFAULT_MODULE_ID][WEIGHTS_SEQ_NO]) self.assertEqual( batch.count, results[DEFAULT_MODULE_ID][MODULE_TRAIN_BATCH_SIZE_MEAN] ) self.assertTrue(learner.TOTAL_LOSS_KEY in results[DEFAULT_MODULE_ID]) self.assertEqual( batch.count, results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED] ) self.assertEqual( batch.count, results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED_LIFETIME] ) self.assertEqual(batch.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED]) self.assertEqual( batch.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED_LIFETIME] ) self.assertLess(results[DEFAULT_MODULE_ID][Learner.TOTAL_LOSS_KEY], 0.58) def test_compute_gradients(self): """Tests the compute_gradients correctness. Tests that if we sum all the trainable variables the gradient of output w.r.t. the weights is all ones. """ config = BaseTestingAlgorithmConfig() learner = config.build_learner(env=self.ENV) params = learner.get_parameters(learner.module[DEFAULT_MODULE_ID]) tape = None loss_per_module = {ALL_MODULES: sum(param.sum() for param in params)} gradients = learner.compute_gradients(loss_per_module, gradient_tape=tape) # Type should be a mapping from ParamRefs to gradients. self.assertIsInstance(gradients, dict) for grad in gradients.values(): check(grad, np.ones(grad.shape)) def test_postprocess_gradients(self): """Tests the base grad clipping logic in `postprocess_gradients()`.""" # Clip by value only. config = BaseTestingAlgorithmConfig().training( lr=0.0003, grad_clip=0.75, grad_clip_by="value" ) learner = config.build_learner(env=self.ENV) # Pretend our computed gradients are our weights + 1.0. grads = { learner.get_param_ref(v): v + 1.0 for v in learner.get_parameters(learner.module[DEFAULT_MODULE_ID]) } # Call the learner's postprocessing method. processed_grads = list(learner.postprocess_gradients(grads).values()) # Check clipped gradients. # No single gradient must be larger than 0.1 or smaller than -0.1: self.assertTrue( all( np.max(grad) <= config.grad_clip and np.min(grad) >= -config.grad_clip for grad in convert_to_numpy(processed_grads) ) ) # Clip by norm. config.grad_clip = 1.0 config.grad_clip_by = "norm" learner = config.build_learner(env=self.ENV) # Pretend our computed gradients are our weights + 1.0. grads = { learner.get_param_ref(v): v + 1.0 for v in learner.get_parameters(learner.module[DEFAULT_MODULE_ID]) } # Call the learner's postprocessing method. processed_grads = list(learner.postprocess_gradients(grads).values()) # Check clipped gradients. for proc_grad, grad in zip( convert_to_numpy(processed_grads), convert_to_numpy(list(grads.values())), ): l2_norm = np.sqrt(np.sum(grad**2.0)) if l2_norm > config.grad_clip: check(proc_grad, grad * (config.grad_clip / l2_norm)) # Clip by global norm. config.grad_clip = 5.0 config.grad_clip_by = "global_norm" learner = config.build_learner(env=self.ENV) # Pretend our computed gradients are our weights + 1.0. grads = { learner.get_param_ref(v): v + 1.0 for v in learner.get_parameters(learner.module[DEFAULT_MODULE_ID]) } # Call the learner's postprocessing method. processed_grads = list(learner.postprocess_gradients(grads).values()) # Check clipped gradients. global_norm = np.sqrt( np.sum( [np.sum(grad**2.0) for grad in convert_to_numpy(list(grads.values()))] ) ) if global_norm > config.grad_clip: for proc_grad, grad in zip( convert_to_numpy(processed_grads), grads.values(), ): check(proc_grad, grad * (config.grad_clip / global_norm)) def test_apply_gradients(self): """Tests the apply_gradients correctness. Tests that if we apply gradients of all ones, the new params are equal to the standard SGD/Adam update rule. """ config = BaseTestingAlgorithmConfig().training(lr=0.0003) learner = config.build_learner(env=self.ENV) # calculated the expected new params based on gradients of all ones. params = learner.get_parameters(learner.module[DEFAULT_MODULE_ID]) n_steps = 100 expected = [ ( convert_to_numpy(param) - n_steps * learner.config.lr * np.ones(param.shape) ) for param in params ] for _ in range(n_steps): gradients = {learner.get_param_ref(p): torch.ones_like(p) for p in params} learner.apply_gradients(gradients) check(params, expected) def test_add_remove_module(self): """Tests the compute/apply_gradients with add/remove modules. Tests that if we add a module with SGD optimizer with a known lr (different from default), and remove the default module, with a loss that is the sum of all variables the updated parameters follow the SGD update rule. """ config = BaseTestingAlgorithmConfig().training(lr=0.0003) learner = config.build_learner(env=self.ENV) rl_module_spec = config.get_default_rl_module_spec() rl_module_spec.observation_space = self.ENV.observation_space rl_module_spec.action_space = self.ENV.action_space learner.add_module( module_id="test", module_spec=rl_module_spec, ) learner.remove_module(DEFAULT_MODULE_ID) # only test module should be left self.assertEqual(set(learner.module.keys()), {"test"}) # calculated the expected new params based on gradients of all ones. params = learner.get_parameters(learner.module["test"]) n_steps = 100 expected = [ convert_to_numpy(param) - n_steps * learner.config.lr * np.ones(param.shape) for param in params ] for _ in range(n_steps): tape = None loss_per_module = {ALL_MODULES: sum(param.sum() for param in params)} gradients = learner.compute_gradients(loss_per_module, gradient_tape=tape) learner.apply_gradients(gradients) check(params, expected) def test_save_to_path_and_restore_from_path(self): """Tests, whether a Learner's state is properly saved and restored.""" config = BaseTestingAlgorithmConfig() # Get a Learner instance for the framework and env. learner1 = config.build_learner(env=self.ENV) with tempfile.TemporaryDirectory() as tmpdir: learner1.save_to_path(tmpdir) learner2 = config.build_learner(env=self.ENV) learner2.restore_from_path(tmpdir) self._check_learner_states("torch", learner1, learner2) # Add a module then save/load and check states. with tempfile.TemporaryDirectory() as tmpdir: rl_module_spec = config.get_default_rl_module_spec() rl_module_spec.observation_space = self.ENV.observation_space rl_module_spec.action_space = self.ENV.action_space learner1.add_module( module_id="test", module_spec=rl_module_spec, ) learner1.save_to_path(tmpdir) learner2 = Learner.from_checkpoint(tmpdir) self._check_learner_states("torch", learner1, learner2) # Remove a module then save/load and check states. with tempfile.TemporaryDirectory() as tmpdir: learner1.remove_module(module_id=DEFAULT_MODULE_ID) learner1.save_to_path(tmpdir) learner2 = Learner.from_checkpoint(tmpdir) self._check_learner_states("torch", learner1, learner2) def _check_learner_states(self, framework, learner1, learner2): check(learner1.module.get_state(), learner2.module.get_state()) check(learner1._get_optimizer_state(), learner2._get_optimizer_state()) check(learner1._module_optimizers, learner2._module_optimizers) def test_multi_agent_learner_results(self): """Tests the learner results for a multi-agent scenario. We check that all metrics are where we expect them and that values are as expected. """ config = BaseTestingAlgorithmConfig() learner = config.build_learner(env=self.ENV) learner.remove_module(module_id=DEFAULT_MODULE_ID) learner.add_module( module_id="mod1", module_spec=config.get_rl_module_spec(env=self.ENV) ) learner.add_module( module_id="mod2", module_spec=config.get_rl_module_spec(env=self.ENV) ) reader = get_cartpole_dataset_reader(batch_size=512) results = {} for seq_num in range(1, 5): batch1 = reader.next() batch2 = reader.next() multi_agent_batch = MultiAgentBatch( {"mod1": batch1, "mod2": batch2}, batch1.count + batch2.count ) batch = learner._convert_batch_type(multi_agent_batch) results = learner.update(batch) # Lifetime steps are aggregated at the root, so the return value in the results will contain only the last step. for module_id, sa_batch_count in zip( ["mod1", "mod2"], [batch1.count, batch2.count] ): self.assertEqual( sa_batch_count, results[module_id][NUM_MODULE_STEPS_TRAINED_LIFETIME], ) self.assertEqual(seq_num, results[module_id][WEIGHTS_SEQ_NO]) self.assertEqual( sa_batch_count, results[module_id][MODULE_TRAIN_BATCH_SIZE_MEAN] ) # We don't know what the value should be, just check for existence. self.assertTrue(learner.TOTAL_LOSS_KEY in results[module_id]) self.assertEqual( batch1.count + batch2.count, results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED_LIFETIME], ) self.assertEqual( batch1.count + batch2.count, results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED], ) self.assertEqual( batch1.count + batch2.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED_LIFETIME], ) self.assertEqual( batch1.count + batch2.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED] ) if __name__ == "__main__": import sys import pytest sys.exit(pytest.main(["-v", __file__]))