diff --git a/examples/dummy_aposmm_libE_gen/run_example.py b/examples/dummy_aposmm_libE_gen/run_example.py new file mode 100644 index 00000000..c447fdac --- /dev/null +++ b/examples/dummy_aposmm_libE_gen/run_example.py @@ -0,0 +1,112 @@ +"""Basic example of parallel random sampling with simulations.""" + +from math import gamma, pi, sqrt +import numpy as np +from libensemble.generators import APOSMM +import libensemble.gen_funcs + +libensemble.gen_funcs.rc.aposmm_optimizers = "nlopt" +from optimas.core import VaryingParameter, Objective +from libensemble.tests.regression_tests.support import ( + six_hump_camel_minima as known_minima, +) + +# from optimas.generators import RandomSamplingGenerator +from optimas.generators import APOSMMWrapper +from optimas.evaluators import TemplateEvaluator +from optimas.explorations import Exploration + +from multiprocessing import set_start_method + +set_start_method("fork", force=True) + + +def analyze_simulation(simulation_directory, output_params): + """Analyze the simulation output. + + This method analyzes the output generated by the simulation to + obtain the value of the optimization objective and other analyzed + parameters, if specified. The value of these parameters has to be + given to the `output_params` dictionary. + + Parameters + ---------- + simulation_directory : str + Path to the simulation folder where the output was generated. + output_params : dict + Dictionary where the value of the objectives and analyzed parameters + will be stored. There is one entry per parameter, where the key + is the name of the parameter given by the user. + + Returns + ------- + dict + The `output_params` dictionary with the results from the analysis. + + """ + # Read back result from file + with open("result.txt") as f: + result = float(f.read()) + # Fill in output parameters. + output_params["f"] = result + return output_params + + +# Create varying parameters and objectives. +var_1 = VaryingParameter("x0", -3.0, 3.0) +var_2 = VaryingParameter("x1", -2.0, 2.0) +obj = Objective("f") + +n = 2 + +aposmm = APOSMM( + initial_sample_size=100, + localopt_method="LN_BOBYQA", + sample_points=np.round(known_minima, 1), + rk_const=0.5 * ((gamma(1 + (n / 2)) * 5) ** (1 / n)) / sqrt(pi), + xtol_abs=1e-6, + ftol_abs=1e-6, + dist_to_bound_multiple=0.5, + max_active_runs=4, # refers to APOSMM's simul local optimization runs + lb=np.array([var_1.lower_bound, var_2.lower_bound]), + ub=np.array([var_1.upper_bound, var_2.upper_bound]), +) + +gen = APOSMMWrapper( + varying_parameters=[var_1, var_2], + objectives=[obj], + libe_gen=aposmm, +) + +# Create evaluator. +ev = TemplateEvaluator( + sim_template="template_simulation_script.py", + analysis_func=analyze_simulation, +) + + +# Create exploration. +exp = Exploration( + generator=gen, evaluator=ev, max_evals=1000, sim_workers=4, run_async=True +) + + +# To safely perform exploration, run it in the block below (this is needed +# for some flavours of multiprocessing, namely spawn and forkserver) +if __name__ == "__main__": + exp.run(100) + exp.run() + exp.finalize() + + assert len(gen.libe_gen.all_local_minima) + print(f"Found {len(gen.libe_gen.all_local_minima)} minima!") + found_minima = [i["x"] for i in gen.libe_gen.all_local_minima] + found_minima_combined = np.zeros( + len(gen.libe_gen.all_local_minima), dtype=(float, 2) + ) + for i in range(len(found_minima)): + found_minima_combined[i] = found_minima[i][0] + found_minima = found_minima_combined + known_minima = np.round(known_minima, 6) + found_minima = np.round(found_minima, 6) + assert any([i in known_minima for i in found_minima]) diff --git a/examples/dummy_aposmm_libE_gen/template_simulation_script.py b/examples/dummy_aposmm_libE_gen/template_simulation_script.py new file mode 100644 index 00000000..285c4497 --- /dev/null +++ b/examples/dummy_aposmm_libE_gen/template_simulation_script.py @@ -0,0 +1,22 @@ +"""Simple template script used for demonstration. + +The script evaluates an analytical expression and stores the results in a +`result.txt` file that is later read by the analysis function. +""" + +import numpy as np + +# 2D function with multiple minima +# result = -({{x0}} + 10 * np.cos({{x0}})) * ({{x1}} + 5 * np.cos({{x1}})) + +x1 = {{x0}} +x2 = {{x1}} + +term1 = (4 - 2.1 * x1**2 + (x1**4) / 3) * x1**2 +term2 = x1 * x2 +term3 = (-4 + 4 * x2**2) * x2**2 + +result = term1 + term2 + term3 + +with open("result.txt", "w") as f: + f.write("{}".format(result)) diff --git a/examples/dummy_random_libEgen/run_example.py b/examples/dummy_random_libEgen/run_example.py new file mode 100644 index 00000000..156738a2 --- /dev/null +++ b/examples/dummy_random_libEgen/run_example.py @@ -0,0 +1,76 @@ +"""Basic example of parallel random sampling with simulations.""" + +from libensemble.gen_funcs.persistent_sampling import RandSample +from optimas.core import VaryingParameter, Objective + +# from optimas.generators import RandomSamplingGenerator +from optimas.generators import libEWrapper +from optimas.evaluators import TemplateEvaluator +from optimas.explorations import Exploration + + +def analyze_simulation(simulation_directory, output_params): + """Analyze the simulation output. + + This method analyzes the output generated by the simulation to + obtain the value of the optimization objective and other analyzed + parameters, if specified. The value of these parameters has to be + given to the `output_params` dictionary. + + Parameters + ---------- + simulation_directory : str + Path to the simulation folder where the output was generated. + output_params : dict + Dictionary where the value of the objectives and analyzed parameters + will be stored. There is one entry per parameter, where the key + is the name of the parameter given by the user. + + Returns + ------- + dict + The `output_params` dictionary with the results from the analysis. + + """ + # Read back result from file + with open("result.txt") as f: + result = float(f.read()) + # Fill in output parameters. + output_params["f"] = result + return output_params + + +# Create varying parameters and objectives. +var_1 = VaryingParameter("x0", 0.0, 15.0) +var_2 = VaryingParameter("x1", 0.0, 15.0) +obj = Objective("f") + + +# Create generator. +# gen = RandomSamplingGenerator( +# varying_parameters=[var_1, var_2], objectives=[obj], distribution="normal" +# ) + +gen = libEWrapper( + varying_parameters=[var_1, var_2], + objectives=[obj], + libe_gen=RandSample, +) + +# Create evaluator. +ev = TemplateEvaluator( + sim_template="template_simulation_script.py", + analysis_func=analyze_simulation, +) + + +# Create exploration. +exp = Exploration( + generator=gen, evaluator=ev, max_evals=10, sim_workers=4, run_async=True +) + + +# To safely perform exploration, run it in the block below (this is needed +# for some flavours of multiprocessing, namely spawn and forkserver) +if __name__ == "__main__": + exp.run() diff --git a/examples/dummy_random_libEgen/template_simulation_script.py b/examples/dummy_random_libEgen/template_simulation_script.py new file mode 100644 index 00000000..760286cc --- /dev/null +++ b/examples/dummy_random_libEgen/template_simulation_script.py @@ -0,0 +1,13 @@ +"""Simple template script used for demonstration. + +The script evaluates an analytical expression and stores the results in a +`result.txt` file that is later read by the analysis function. +""" + +import numpy as np + +# 2D function with multiple minima +result = -({{x0}} + 10 * np.cos({{x0}})) * ({{x1}} + 5 * np.cos({{x1}})) + +with open("result.txt", "w") as f: + f.write("%f" % result) diff --git a/optimas/explorations/base.py b/optimas/explorations/base.py index 2a5732b2..ef84a379 100644 --- a/optimas/explorations/base.py +++ b/optimas/explorations/base.py @@ -16,6 +16,7 @@ from libensemble.executors.mpi_executor import MPIExecutor from optimas.core.trial import TrialStatus +from optimas.generators.libE_wrapper import libEWrapper from optimas.generators.base import Generator from optimas.evaluators.base import Evaluator from optimas.evaluators.function_evaluator import FunctionEvaluator @@ -229,6 +230,22 @@ def run(self, n_evals: Optional[int] = None) -> None: # Reset `cwd` to initial value before `libE` was called. os.chdir(cwd) + def finalize(self) -> None: + """Finalize the exploration, cleanup the generator and loggers. + + When using generators known to live in memory between `Exploration.run()` + invocations (oftentimes generators from libEnsemble), call this method to + indicate to the generator and other background processes to close down + their background threads. + + There is no guarantee that subsequent `.run()` invocations will operate + after calling `.finalize()`. + """ + if isinstance(self.generator, libEWrapper): + self.generator.libe_gen.final_tell( + self._libe_history.H[["sim_id", "f"]] + ) + def attach_trials( self, trial_data: Union[Dict, List[Dict], np.ndarray, pd.DataFrame], diff --git a/optimas/gen_functions.py b/optimas/gen_functions.py index 955704ac..4358150a 100644 --- a/optimas/gen_functions.py +++ b/optimas/gen_functions.py @@ -48,6 +48,10 @@ def persistent_generator(H, persis_info, gen_specs, libE_info): # Get generator, objectives, and parameters to analyze. generator = gen_specs["user"]["generator"] + + if hasattr(generator, "libe_gen"): + generator.init_libe_gen(H, persis_info, gen_specs, libE_info) + objectives = generator.objectives analyzed_parameters = generator.analyzed_parameters @@ -109,6 +113,7 @@ def persistent_generator(H, persis_info, gen_specs, libE_info): y = calc_in[par.name][i] ev = Evaluation(parameter=par, value=y) trial.complete_evaluation(ev) + trial.libE_calc_in = calc_in[i] # Register trial with unknown SEM generator.tell([trial]) # Set the number of points to generate to that number: diff --git a/optimas/generators/__init__.py b/optimas/generators/__init__.py index 237fb921..c9176a91 100644 --- a/optimas/generators/__init__.py +++ b/optimas/generators/__init__.py @@ -22,6 +22,8 @@ from .grid_sampling import GridSamplingGenerator from .line_sampling import LineSamplingGenerator from .random_sampling import RandomSamplingGenerator +from .libE_wrapper import libEWrapper +from .aposmm import APOSMMWrapper __all__ = [ @@ -32,4 +34,6 @@ "GridSamplingGenerator", "LineSamplingGenerator", "RandomSamplingGenerator", + "libEWrapper", + "APOSMMWrapper", ] diff --git a/optimas/generators/aposmm.py b/optimas/generators/aposmm.py new file mode 100644 index 00000000..5897567a --- /dev/null +++ b/optimas/generators/aposmm.py @@ -0,0 +1,149 @@ +"""Contains definition for APOSMMWrapper class for translating APOSMM to Optimas-compatible format.""" + +import numpy as np +from typing import List + +from optimas.core import ( + Objective, + Trial, + VaryingParameter, + Parameter, + TrialParameter, +) +from .libE_wrapper import libEWrapper + + +class APOSMMWrapper(libEWrapper): + """ + Wraps a live, parameterized APOSMM generator instance. + + .. code-block:: python + + from math import gamma, pi, sqrt + import numpy as np + + from optimas.generators import APOSMMWrapper + from optimas.core import Objective, Trial, VaryingParameter + from libensemble.generators import APOSMM + import libensemble.gen_funcs + + ... + + # Create varying parameters and objectives. + var_1 = VaryingParameter("x0", -3.0, 3.0) + var_2 = VaryingParameter("x1", -2.0, 2.0) + obj = Objective("f") + + n = 2 + + aposmm = APOSMM( + initial_sample_size=100, + localopt_method="LN_BOBYQA", + rk_const=0.5 * ((gamma(1 + (n / 2)) * 5) ** (1 / n)) / sqrt(pi), + xtol_abs=1e-5, + ftol_abs=1e-5, + dist_to_bound_multiple=0.5, + max_active_runs=4, # refers to APOSMM's simul local optimization runs + lb=np.array([var_1.lower_bound, var_2.lower_bound]), + ub=np.array([var_1.upper_bound, var_2.upper_bound]), + ) + + gen = APOSMMWrapper( + varying_parameters=[var_1, var_2], + objectives=[obj], + libe_gen=aposmm, + ) + + Parameters + ---------- + varying_parameters : list of VaryingParameter + List of input parameters to vary. + objectives : list of Objective + List of optimization objectives. + libe_gen : object + A live, parameterized APOSMM generator instance. Must import and provide from libEnsemble. + """ + + def __init__( + self, + varying_parameters: List[VaryingParameter], + objectives: List[Objective], + libe_gen=None, + ) -> None: + custom_trial_parameters = [ + TrialParameter( + "x_on_cube", dtype=(float, (len(varying_parameters),)) + ), + TrialParameter("local_pt", dtype=bool), + ] + super().__init__( + varying_parameters=varying_parameters, + objectives=objectives, + custom_trial_parameters=custom_trial_parameters, + libe_gen=libe_gen, + ) + self.libe_gen = libe_gen + self.num_evals = 0 + self._told_initial_sample = False + + def _slot_in_data(self, trial): + """Slot in libE_calc_in and trial data into corresponding array fields.""" + self.new_array["f"][self.num_evals] = trial.libE_calc_in["f"] + self.new_array["x"][self.num_evals] = trial.parameter_values + self.new_array["sim_id"][self.num_evals] = trial.libE_calc_in["sim_id"] + self.new_array["x_on_cube"][self.num_evals] = trial.x_on_cube + self.new_array["local_pt"][self.num_evals] = trial.local_pt + + @property + def _array_size(self): + """Output array size must match either initial sample or N points to evaluate in parallel.""" + user = self.libe_gen.gen_specs["user"] + return ( + user["initial_sample_size"] + if not self._told_initial_sample + else user["max_active_runs"] + ) + + @property + def _enough_initial_sample(self): + """We're typically happy with at least 90% of the initial sample.""" + return self.num_evals > int( + 0.9 * self.libe_gen.gen_specs["user"]["initial_sample_size"] + ) + + @property + def _enough_subsequent_points(self): + """But we need to evaluate at least N points, for the N local-optimization processes.""" + return ( + self.num_evals >= self.libe_gen.gen_specs["user"]["max_active_runs"] + ) + + def _ask(self, trials: List[Trial]) -> List[Trial]: + """Fill in the parameter values of the requested trials.""" + n_trials = len(trials) + gen_out = self.libe_gen.ask_np(n_trials) + + for i, trial in enumerate(trials): + trial.parameter_values = gen_out[i]["x"] + trial.x_on_cube = gen_out[i]["x_on_cube"] + trial.local_pt = gen_out[i]["local_pt"] + + return trials + + def _tell(self, trials: List[Trial]) -> None: + """Pass objective values to generator, slotting/caching into APOSMM's expected results array.""" + trial = trials[0] + if self.num_evals == 0: + self.new_array = np.zeros( + self._array_size, + dtype=self.libe_gen.gen_specs["out"] + [("f", float)], + ) + self._slot_in_data(trial) + self.num_evals += 1 + if not self._told_initial_sample and self._enough_initial_sample: + self.libe_gen.tell_np(self.new_array) + self._told_initial_sample = True + self.num_evals = 0 + elif self._told_initial_sample and self._enough_subsequent_points: + self.libe_gen.tell_np(self.new_array) + self.num_evals = 0 # reset, create a new array next time around diff --git a/optimas/generators/base.py b/optimas/generators/base.py index 931a64e1..1214d477 100644 --- a/optimas/generators/base.py +++ b/optimas/generators/base.py @@ -86,6 +86,7 @@ def __init__( custom_trial_parameters: Optional[List[TrialParameter]] = None, allow_fixed_parameters: Optional[bool] = False, allow_updating_parameters: Optional[bool] = False, + _libe_gen: Optional[object] = None, ) -> None: if objectives is None: objectives = [Objective()] @@ -109,6 +110,7 @@ def __init__( ) self._allow_fixed_parameters = allow_fixed_parameters self._allow_updating_parameters = allow_updating_parameters + self._libe_gen = _libe_gen self._gen_function = persistent_generator self._given_trials = [] # Trials given for evaluation. self._queued_trials = [] # Trials queued to be given for evaluation. @@ -237,7 +239,10 @@ def ask(self, n_trials: int) -> List[Trial]: return trials def tell( - self, trials: List[Trial], allow_saving_model: Optional[bool] = True + self, + trials: List[Trial], + allow_saving_model: Optional[bool] = True, + libE_calc_in: Optional[np.typing.NDArray] = None, ) -> None: """Give trials back to generator once they have been evaluated. @@ -250,7 +255,10 @@ def tell( incorporating the evaluated trials. By default ``True``. """ - self._tell(trials) + if libE_calc_in is not None: + self._tell(trials, libE_calc_in) + else: + self._tell(trials) for trial in trials: if trial not in self._given_trials: self._add_external_evaluated_trial(trial) diff --git a/optimas/generators/libE_wrapper.py b/optimas/generators/libE_wrapper.py new file mode 100644 index 00000000..c8dd212e --- /dev/null +++ b/optimas/generators/libE_wrapper.py @@ -0,0 +1,98 @@ +"""Contains definition for libEWrapper class for translating various libEnsemble ask/tell generators to Optimas-compatible format.""" + +from copy import deepcopy +import numpy as np +from typing import List, Optional +import inspect + +from libensemble.generators import LibensembleGenThreadInterfacer + +from optimas.core import ( + Objective, + Trial, + VaryingParameter, + Parameter, + TrialParameter, +) +from .base import Generator + + +class libEWrapper(Generator): + """Generator class that wraps libEnsemble ask/tell generators.""" + + def __init__( + self, + varying_parameters: List[VaryingParameter], + objectives: List[Objective], + constraints: Optional[List[Parameter]] = None, + analyzed_parameters: Optional[List[Parameter]] = None, + use_cuda: Optional[bool] = False, + gpu_id: Optional[int] = 0, + dedicated_resources: Optional[bool] = False, + save_model: Optional[bool] = False, + model_save_period: Optional[int] = 5, + model_history_dir: Optional[str] = "model_history", + custom_trial_parameters: Optional[List[TrialParameter]] = None, + allow_fixed_parameters: Optional[bool] = False, + allow_updating_parameters: Optional[bool] = False, + libe_gen=None, + ) -> None: + super().__init__( + varying_parameters=varying_parameters, + objectives=objectives, + constraints=constraints, + analyzed_parameters=analyzed_parameters, + use_cuda=use_cuda, + gpu_id=gpu_id, + dedicated_resources=dedicated_resources, + save_model=save_model, + model_save_period=model_save_period, + model_history_dir=model_history_dir, + custom_trial_parameters=custom_trial_parameters, + allow_fixed_parameters=allow_fixed_parameters, + allow_updating_parameters=allow_updating_parameters, + _libe_gen=libe_gen, + ) + self.libe_gen = libe_gen + + def init_libe_gen(self, H, persis_info, gen_specs_in, libE_info): + """Initialize the libEnsemble generator based on gen_f local data, or starts a background thread.""" + n = len(self.varying_parameters) + gen_specs_in["user"]["generator"] = None + gen_specs = deepcopy(gen_specs_in) + gen_specs["out"] = [("x", float, (n,))] + gen_specs["user"]["lb"] = np.zeros(n) + gen_specs["user"]["ub"] = np.zeros(n) + for i, vp in enumerate(self.varying_parameters): + gen_specs["user"]["lb"][i] = vp.lower_bound + gen_specs["user"]["ub"][i] = vp.upper_bound + if self.libe_gen is not None: + if inspect.isclass(self.libe_gen): + self.libe_gen = self.libe_gen( # replace self.libe_gen with initialized instance + gen_specs, H, persis_info, libE_info + ) + else: + if ( + isinstance(self.libe_gen, LibensembleGenThreadInterfacer) + and self.libe_gen.thread is None + ): # no initialization needed except setup() + self.libe_gen.setup() # start background thread + else: + raise ValueError("libe_gen must be set") + + def _ask(self, trials: List[Trial]) -> List[Trial]: + """Fill in the parameter values of the requested trials.""" + n_trials = len(trials) + gen_out = self.libe_gen.ask(n_trials) + + for i, trial in enumerate(trials): + # Extract the 'x' field from gen_out[i] directly + x_values = gen_out[i]["x"] + trial.parameter_values = x_values + + return trials + + def _tell(self, trials: List[Trial]) -> None: + """Pass the raw objective values to generator.""" + trial = trials[0] + self.libe_gen.tell(trial.libE_calc_in) diff --git a/pyproject.toml b/pyproject.toml index 5c0632b6..e57f716d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ 'Programming Language :: Python :: 3.11', ] dependencies = [ - 'libensemble >= 1.3.0', + 'libensemble @ git+https://github.com/Libensemble/libensemble@experimental/jlnav_plus_shuds_asktell', 'jinja2', 'pandas', 'mpi4py',