From 4eb88f5cf6487a83aacb482fcce4768010635e2d Mon Sep 17 00:00:00 2001 From: Angel Ferran Pousa Date: Fri, 15 Sep 2023 11:30:11 +0200 Subject: [PATCH 01/11] Track number of evaluations in an Exploration --- optimas/explorations/base.py | 8 ++++++++ optimas/generators/base.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 607f36f3..770755af 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -76,6 +76,7 @@ def __init__( self.history_save_period = history_save_period self.exploration_dir_path = exploration_dir_path self.libe_comms = libe_comms + self._n_evals = 0 self._load_history(history) self._create_alloc_specs() self._create_executor() @@ -87,6 +88,9 @@ def run(self) -> None: # Set exit criteria to maximum number of evaluations. exit_criteria = {'sim_max': self.max_evals} + # Get initial number of generator trials. + n_trials_initial = self.generator.n_trials + # Create persis_info. persis_info = add_unique_random_streams({}, self.sim_workers + 2) @@ -123,6 +127,10 @@ def run(self) -> None: # Update generator with the one received from libE. self.generator._update(persis_info[1]['generator']) + # Update number of evaluation in this exploration. + n_trials_final = self.generator.n_trials + self._n_evals += n_trials_final - n_trials_initial + # Determine if current rank is master. if self.libE_specs["comms"] == "local": is_master = True diff --git a/optimas/generators/base.py b/optimas/generators/base.py index cfe91cf7..2f430798 100644 --- a/optimas/generators/base.py +++ b/optimas/generators/base.py @@ -115,6 +115,10 @@ def gpu_id(self): @property def dedicated_resources(self): return self._dedicated_resources + + @property + def n_trials(self): + return len(self._trials) def ask( self, From f069e7c8d11d4735af6502e21d5ca583c90b15ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Ferran=20Pousa?= Date: Mon, 25 Sep 2023 17:50:08 +0200 Subject: [PATCH 02/11] Enable running a subset of evaluations --- optimas/explorations/base.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 770755af..01840ce1 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -83,10 +83,29 @@ def __init__( self._initialize_evaluator() self._set_default_libe_specs() - def run(self) -> None: - """Run the exploration.""" + def run( + self, + n_evals: Optional[int] = None + ) -> None: + """Run the exploration. + + Parameters + ---------- + n_evals : int, optional + Number of evaluations to run. If not given, the exploration will + run until the number of evaluations reaches `max_evals`. + """ # Set exit criteria to maximum number of evaluations. - exit_criteria = {'sim_max': self.max_evals} + remaining_evals = self.max_evals - self._n_evals + if remaining_evals < 1: + raise ValueError( + 'The maximum number or evaluations has been reached.' + ) + if n_evals is None: + sim_max = remaining_evals + else: + sim_max = min(n_evals, remaining_evals) + exit_criteria = {'sim_max': sim_max} # Get initial number of generator trials. n_trials_initial = self.generator.n_trials @@ -101,6 +120,9 @@ def run(self) -> None: else: self.libE_specs['zero_resource_workers'] = [1] + if self._n_evals > 0: + self.libE_specs['reuse_output_dir'] = True + # Get gen_specs and sim_specs. run_params = self.evaluator.get_run_params() gen_specs = self.generator.get_gen_specs(self.sim_workers, run_params) From 50846d9a3296416c845003435e3479e6b968748b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Ferran=20Pousa?= Date: Mon, 25 Sep 2023 19:37:16 +0200 Subject: [PATCH 03/11] Inform generator function about `max_evals` --- optimas/explorations/base.py | 3 ++- optimas/gen_functions.py | 9 +++++++-- optimas/generators/ax/developer/multitask.py | 5 +++-- optimas/generators/base.py | 12 ++++++++++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 01840ce1..f9df33b4 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -125,7 +125,8 @@ def run( # Get gen_specs and sim_specs. run_params = self.evaluator.get_run_params() - gen_specs = self.generator.get_gen_specs(self.sim_workers, run_params) + gen_specs = self.generator.get_gen_specs(self.sim_workers, run_params, + sim_max) sim_specs = self.evaluator.get_sim_specs( self.generator.varying_parameters, self.generator.objectives, diff --git a/optimas/gen_functions.py b/optimas/gen_functions.py index f4c5f545..4c04fb1b 100644 --- a/optimas/gen_functions.py +++ b/optimas/gen_functions.py @@ -38,9 +38,13 @@ def persistent_generator(H, persis_info, gen_specs, libE_info): ps = PersistentSupport(libE_info, EVAL_GEN_TAG) + # Maximum number of total evaluations to generate. + max_evals = gen_specs['user']['max_evals'] + # Number of points to generate initially. - number_of_gen_points = gen_specs['user']['gen_batch_size'] + number_of_gen_points = min(gen_specs['user']['gen_batch_size'], max_evals) + n_gens = 0 n_failed_gens = 0 # Receive information from the manager (or a STOP_TAG) @@ -68,6 +72,7 @@ def persistent_generator(H, persis_info, gen_specs, libE_info): H_o['num_procs'][i] = run_params["num_procs"] H_o['num_gpus'][i] = run_params["num_gpus"] + n_gens += np.sum(H_o['num_procs'] != 0) n_failed_gens = np.sum(H_o['num_procs'] == 0) H_o = H_o[H_o['num_procs'] > 0] @@ -88,7 +93,7 @@ def persistent_generator(H, persis_info, gen_specs, libE_info): # Register trial with unknown SEM generator.tell([trial]) # Set the number of points to generate to that number: - number_of_gen_points = n + n_failed_gens + number_of_gen_points = min(n + n_failed_gens, max_evals - n_gens) n_failed_gens = 0 else: number_of_gen_points = 0 diff --git a/optimas/generators/ax/developer/multitask.py b/optimas/generators/ax/developer/multitask.py index 18a7adb7..8b2443e8 100644 --- a/optimas/generators/ax/developer/multitask.py +++ b/optimas/generators/ax/developer/multitask.py @@ -128,11 +128,12 @@ def __init__( def get_gen_specs( self, sim_workers: int, - run_params: dict + run_params: Dict, + sim_max: int ) -> Dict: """Get the libEnsemble gen_specs.""" # Get base specs. - gen_specs = super().get_gen_specs(sim_workers, run_params) + gen_specs = super().get_gen_specs(sim_workers, run_params, sim_max) # Add task to output parameters. max_length = max([len(self.lofi_task.name), len(self.hifi_task.name)]) gen_specs['out'].append(('task', str, max_length)) diff --git a/optimas/generators/base.py b/optimas/generators/base.py index 2f430798..4281123f 100644 --- a/optimas/generators/base.py +++ b/optimas/generators/base.py @@ -248,7 +248,8 @@ def save_model_to_file(self) -> None: def get_gen_specs( self, sim_workers: int, - run_params: dict + run_params: Dict, + max_evals: int ) -> Dict: """Get the libEnsemble gen_specs. @@ -256,6 +257,11 @@ def get_gen_specs( ---------- sim_workers : int Total number of parallel simulation workers. + run_params : dict + Dictionary containing the number of processes and gpus + required. + max_evals : int + Maximum number of evaluations to generate. """ self._prepare_to_send() gen_specs = { @@ -285,7 +291,9 @@ def get_gen_specs( # GPU in which to run generator. 'gpu_id': self._gpu_id, # num of procs and gpus required - 'run_params': run_params + 'run_params': run_params, + # Maximum number of evaluations to generate. + 'max_evals': max_evals } } return gen_specs From 6a1ead16992d065917ac9e285b93704fca34aba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Ferran=20Pousa?= Date: Mon, 25 Sep 2023 19:39:20 +0200 Subject: [PATCH 04/11] Enable `final_gen_send` option --- optimas/explorations/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index f9df33b4..4deefe3f 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -236,6 +236,9 @@ def _set_default_libe_specs(self) -> None: libE_specs['use_workflow_dir'] = True libE_specs['workflow_dir_path'] = self.exploration_dir_path + # Ensure evaluations of last batch are sent back to the generator. + libE_specs["final_gen_send"] = True + # get specs from generator and evaluator gen_libE_specs = self.generator.get_libe_specs() ev_libE_specs = self.evaluator.get_libe_specs() From 09a9a05c35bd35eb01aa04a5320ece37d704f337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Ferran=20Pousa?= Date: Mon, 25 Sep 2023 19:41:09 +0200 Subject: [PATCH 05/11] Remove `_reset_libensemble` workaround --- optimas/explorations/base.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 4deefe3f..51c20233 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -169,9 +169,6 @@ def run( history, persis_info, __file__, nworkers, dest_path=os.path.abspath(self.exploration_dir_path)) - # Reset state of libEnsemble. - self._reset_libensemble() - def _create_executor(self) -> None: """Create libEnsemble executor.""" self.executor = MPIExecutor() @@ -252,20 +249,4 @@ def _create_alloc_specs(self) -> None: 'user': { 'async_return': self.run_async } - } - - def _reset_libensemble(self) -> None: - """Reset the state of libEnsemble. - - After calling `libE`, some libEnsemble attributes do not come back to - their original states. This leads to issues if another `Exploration` - run is launched within the same script. This method resets the - necessary libEnsemble attributes to their original state. - """ - if Resources.resources is not None: - del Resources.resources - Resources.resources = None - if Executor.executor is not None: - del Executor.executor - Executor.executor = None - LogConfig.config.logger_set = False + } \ No newline at end of file From 99447183b566b01b2086fc1740aa6e9d9552521c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Ferran=20Pousa?= Date: Mon, 25 Sep 2023 19:43:38 +0200 Subject: [PATCH 06/11] Formatting --- optimas/explorations/base.py | 7 ++----- optimas/gen_functions.py | 2 +- optimas/generators/base.py | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 51c20233..85083ef8 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -9,9 +9,6 @@ from libensemble.tools import save_libE_output, add_unique_random_streams from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens from libensemble.executors.mpi_executor import MPIExecutor -from libensemble.resources.resources import Resources -from libensemble.executors.executor import Executor -from libensemble.logger import LogConfig from optimas.generators.base import Generator from optimas.evaluators.base import Evaluator @@ -88,7 +85,7 @@ def run( n_evals: Optional[int] = None ) -> None: """Run the exploration. - + Parameters ---------- n_evals : int, optional @@ -249,4 +246,4 @@ def _create_alloc_specs(self) -> None: 'user': { 'async_return': self.run_async } - } \ No newline at end of file + } diff --git a/optimas/gen_functions.py b/optimas/gen_functions.py index 4c04fb1b..e13065b3 100644 --- a/optimas/gen_functions.py +++ b/optimas/gen_functions.py @@ -38,7 +38,7 @@ def persistent_generator(H, persis_info, gen_specs, libE_info): ps = PersistentSupport(libE_info, EVAL_GEN_TAG) - # Maximum number of total evaluations to generate. + # Maximum number of total evaluations to generate. max_evals = gen_specs['user']['max_evals'] # Number of points to generate initially. diff --git a/optimas/generators/base.py b/optimas/generators/base.py index 4281123f..77abe380 100644 --- a/optimas/generators/base.py +++ b/optimas/generators/base.py @@ -115,7 +115,7 @@ def gpu_id(self): @property def dedicated_resources(self): return self._dedicated_resources - + @property def n_trials(self): return len(self._trials) From 1097ddbee4c9f0dec79be17f93937376c09edc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Ferran=20Pousa?= Date: Mon, 25 Sep 2023 19:49:53 +0200 Subject: [PATCH 07/11] Use latest libEnsemble --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0f7d7669..a9f6261c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ 'Programming Language :: Python :: 3.11', ] dependencies = [ - 'libensemble >= 0.10.2', + 'libensemble @ git+https://github.com/Libensemble/libensemble@develop', 'jinja2', 'ax-platform >= 0.2.9', 'mpi4py', From 801c0af869d258e08f938122e33693062f9f5aa8 Mon Sep 17 00:00:00 2001 From: Angel Ferran Pousa Date: Tue, 26 Sep 2023 08:25:16 +0200 Subject: [PATCH 08/11] Switch to libEnsemble v1.0.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a9f6261c..4882365f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ 'Programming Language :: Python :: 3.11', ] dependencies = [ - 'libensemble @ git+https://github.com/Libensemble/libensemble@develop', + 'libensemble >= 1.0.0', 'jinja2', 'ax-platform >= 0.2.9', 'mpi4py', From b0f88ee93e74f167f8b3b73da4c443b199034008 Mon Sep 17 00:00:00 2001 From: Angel Ferran Pousa Date: Tue, 26 Sep 2023 13:18:56 +0200 Subject: [PATCH 09/11] Implement resuming from previous run --- optimas/explorations/base.py | 79 ++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 85083ef8..ecbcc192 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -1,17 +1,22 @@ """Contains the definition of the base Exploration class.""" import os +import glob from typing import Optional, Union import numpy as np from libensemble.libE import libE -from libensemble.tools import save_libE_output, add_unique_random_streams +from libensemble.tools import add_unique_random_streams from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens from libensemble.executors.mpi_executor import MPIExecutor from optimas.generators.base import Generator from optimas.evaluators.base import Evaluator +from optimas.utils.logger import get_logger + + +logger = get_logger(__name__) class Exploration(): @@ -40,6 +45,14 @@ class Exploration(): history file to disk. By default equals to ``sim_workers``. exploration_dir_path : str, optional. Path to the exploration directory. By default, ``'./exploration'``. + resume : bool, optional + Whether the exploration should resume from a previous run in the same + `exploration_dir_path`. If `True`, the exploration will continue from + the last evaluation of the previous run until the total number of + evaluations (including those of the previous run) reaches `max_evals`. + There is no need to provide the `history` path (it will be ignored). + If `False` (default value), the exploration will raise an error if + the `exploration_dir_path` already exists. libe_comms : {'local', 'mpi'}, optional. The communication mode for libEnseble. Determines whether to use Python ``multiprocessing`` (local mode) or MPI for the communication @@ -60,6 +73,7 @@ def __init__( history: Optional[str] = None, history_save_period: Optional[int] = None, exploration_dir_path: Optional[str] = './exploration', + resume: Optional[bool] = False, libe_comms: Optional[str] = 'local' ) -> None: self.generator = generator @@ -74,7 +88,9 @@ def __init__( self.exploration_dir_path = exploration_dir_path self.libe_comms = libe_comms self._n_evals = 0 - self._load_history(history) + self._resume = resume + self._history_file_name = 'exploration_history_after_evaluation_{}' + self._load_history(history, resume) self._create_alloc_specs() self._create_executor() self._initialize_evaluator() @@ -154,17 +170,13 @@ def run( # Determine if current rank is master. if self.libE_specs["comms"] == "local": is_master = True - nworkers = self.sim_workers + 1 else: from mpi4py import MPI is_master = (MPI.COMM_WORLD.Get_rank() == 0) - nworkers = MPI.COMM_WORLD.Get_size() - 1 # Save history. if is_master: - save_libE_output( - history, persis_info, __file__, nworkers, - dest_path=os.path.abspath(self.exploration_dir_path)) + self._save_history() def _create_executor(self) -> None: """Create libEnsemble executor.""" @@ -176,9 +188,25 @@ def _initialize_evaluator(self) -> None: def _load_history( self, - history: Union[str, np.ndarray, None] + history: Union[str, np.ndarray, None], + resume: Optional[bool] = False, ) -> None: """Load history file.""" + # To resume an exploration, get history file from previous run. + if resume: + if history is not None: + logger.info( + 'The `history` argument is ignored when `resume=True`. ' + 'The exploration will resume using the most recent ' + 'history file.' + ) + history = self._get_most_recent_history_file_path() + if history is None: + raise ValueError( + 'Previous history file not found. ' + 'Cannot resume exploration.' + ) + # Read file. if isinstance(history, str): if os.path.exists(history): # Load array. @@ -194,8 +222,43 @@ def _load_history( # Incorporate history into generator. if history is not None: self.generator.incorporate_history(history) + # When resuming an exploration, update evaluations counter. + if resume: + self._n_evals = history.size self.history = history + def _save_history(self): + """Save history array to file.""" + filename = self._history_file_name.format(self._n_evals) + exploration_dir_path = os.path.abspath(self.exploration_dir_path) + file_path = os.path.join(exploration_dir_path, filename) + if not os.path.isfile(filename): + old_files = os.path.join( + exploration_dir_path, self._history_file_name.format("*")) + for old_file in glob.glob(old_files): + os.remove(old_file) + np.save(file_path, self.history) + + def _get_most_recent_history_file_path(self): + """Get path of most recently saved history file.""" + old_exploration_history_files = glob.glob( + os.path.join( + os.path.abspath(self.exploration_dir_path), + self._history_file_name.format("*") + ) + ) + old_libe_history_files = glob.glob( + os.path.join( + os.path.abspath(self.exploration_dir_path), + 'libE_history_'.format("*") + ) + ) + old_files = old_exploration_history_files + old_libe_history_files + if old_files: + file_evals = [int(file.split('_')[-1][:-4]) for file in old_files] + i_max_evals = np.argmax(np.array(file_evals)) + return old_files[i_max_evals] + def _set_default_libe_specs(self) -> None: """Set default exploration libe_specs.""" libE_specs = {} From 74da1ca4d4d910b70522d9ad04e0b895e63d78a2 Mon Sep 17 00:00:00 2001 From: Angel Ferran Pousa Date: Tue, 26 Sep 2023 13:20:42 +0200 Subject: [PATCH 10/11] Add test --- tests/test_exploration_resume.py | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_exploration_resume.py diff --git a/tests/test_exploration_resume.py b/tests/test_exploration_resume.py new file mode 100644 index 00000000..758f3984 --- /dev/null +++ b/tests/test_exploration_resume.py @@ -0,0 +1,110 @@ +import os + +from optimas.explorations import Exploration +from optimas.generators import RandomSamplingGenerator +from optimas.evaluators import TemplateEvaluator +from optimas.core import VaryingParameter, Objective + + +def analysis_func(sim_dir, output_params): + """Analysis function used by the template evaluator.""" + # Read back result from file + with open('result.txt') as f: + result = float(f.read()) + output_params['f'] = result + + +def test_exploration_in_steps(): + """Test that an exploration runs correctly when doing so in several steps. + """ + # Define variables and objectives. + var1 = VaryingParameter('x0', -50., 5.) + var2 = VaryingParameter('x1', -5., 15.) + obj = Objective('f', minimize=False) + + # Define variables and objectives. + gen = RandomSamplingGenerator( + varying_parameters=[var1, var2], + objectives=[obj] + ) + + # Create template evaluator. + ev = TemplateEvaluator( + sim_template=os.path.join( + os.path.abspath(os.path.dirname(__file__)), + 'resources', + 'template_simulation_script.py' + ), + analysis_func=analysis_func + ) + + # Create exploration. + exploration = Exploration( + generator=gen, + evaluator=ev, + max_evals=30, + sim_workers=2, + exploration_dir_path='./tests_output/test_template_evaluator' + ) + + # Run exploration in several steps. + exploration.run(3) + exploration.run(4) + exploration.run(10) + exploration.run(5) + exploration.run() + + # Check final state. + assert exploration._n_evals == len(exploration.history) + assert exploration._n_evals == gen.n_trials + assert exploration._n_evals == exploration.max_evals + assert exploration.history['gen_informed'][-1] + + +def test_exploration_resume(): + """Test that an exploration correctly resumes from a previous run. + """ + # Define variables and objectives. + var1 = VaryingParameter('x0', -50., 5.) + var2 = VaryingParameter('x1', -5., 15.) + obj = Objective('f', minimize=False) + + # Define variables and objectives. + gen = RandomSamplingGenerator( + varying_parameters=[var1, var2], + objectives=[obj] + ) + + # Create template evaluator. + ev = TemplateEvaluator( + sim_template=os.path.join( + os.path.abspath(os.path.dirname(__file__)), + 'resources', + 'template_simulation_script.py' + ), + analysis_func=analysis_func + ) + + # Create exploration. + exploration = Exploration( + generator=gen, + evaluator=ev, + max_evals=40, + sim_workers=2, + exploration_dir_path='./tests_output/test_template_evaluator', + resume=True + ) + + # Run exploration. + exploration.run() + + # Check final state. + assert exploration._n_evals == len(exploration.history) + assert exploration._n_evals == gen.n_trials + assert exploration._n_evals == exploration.max_evals + assert exploration.history['gen_informed'][-1] + + +if __name__ == '__main__': + test_exploration_in_steps() + test_exploration_resume() From 0bb8108076268bb2e2f5a7746732328f846c77f0 Mon Sep 17 00:00:00 2001 From: Angel Ferran Pousa Date: Tue, 26 Sep 2023 13:21:31 +0200 Subject: [PATCH 11/11] Fix bug --- optimas/explorations/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index ecbcc192..b2c64807 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -250,7 +250,7 @@ def _get_most_recent_history_file_path(self): old_libe_history_files = glob.glob( os.path.join( os.path.abspath(self.exploration_dir_path), - 'libE_history_'.format("*") + 'libE_history_{}'.format("*") ) ) old_files = old_exploration_history_files + old_libe_history_files