diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 42e66ea67..aa363f426 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -115,4 +115,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: crate-ci/typos@v1.35.7 + - uses: crate-ci/typos@v1.38.1 diff --git a/.github/workflows/extra.yml b/.github/workflows/extra.yml index c9831ffbf..4b77ee4d5 100644 --- a/.github/workflows/extra.yml +++ b/.github/workflows/extra.yml @@ -145,4 +145,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: crate-ci/typos@v1.35.7 + - uses: crate-ci/typos@v1.38.1 diff --git a/install/install_ibcdfo.sh b/install/install_ibcdfo.sh index efd5f6dcb..0ed790f01 100644 --- a/install/install_ibcdfo.sh +++ b/install/install_ibcdfo.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -git clone --recurse-submodules -b develop https://github.com/POptUS/IBCDFO.git +git clone --recurse-submodules -b main https://github.com/POptUS/IBCDFO.git pushd IBCDFO/minq/py/minq5/ export PYTHONPATH="$PYTHONPATH:$(pwd)" echo "PYTHONPATH=$PYTHONPATH" >> $GITHUB_ENV diff --git a/install/misc_feature_requirements.txt b/install/misc_feature_requirements.txt index 2f6284657..fca53eac4 100644 --- a/install/misc_feature_requirements.txt +++ b/install/misc_feature_requirements.txt @@ -1 +1 @@ -globus-compute-sdk==3.12.0 +globus-compute-sdk==3.16.0 diff --git a/install/testing_requirements.txt b/install/testing_requirements.txt index 43f0b2291..0d6a40fa2 100644 --- a/install/testing_requirements.txt +++ b/install/testing_requirements.txt @@ -1,11 +1,11 @@ flake8==7.3.0 coverage>=7.5 -pytest==8.4.1 -pytest-cov==6.2.1 +pytest==8.4.2 +pytest-cov==7.0.0 pytest-timeout==2.4.0 mock==5.2.0 python-dateutil==2.9.0.post0 -anyio==4.10.0 -matplotlib==3.10.6 +anyio==4.11.0 +matplotlib==3.10.7 mpmath==1.3.0 -rich==14.1.0 +rich==14.2.0 diff --git a/libensemble/gen_classes/aposmm.py b/libensemble/gen_classes/aposmm.py index cd9a9c257..886171821 100644 --- a/libensemble/gen_classes/aposmm.py +++ b/libensemble/gen_classes/aposmm.py @@ -1,4 +1,5 @@ import copy +from math import gamma, pi, sqrt from typing import List import numpy as np @@ -11,9 +12,17 @@ class APOSMM(PersistentGenInterfacer): """ - Standalone object-oriented APOSMM generator + APOSMM coordinates multiple local optimization runs, dramatically reducing time for + discovering multiple minima on parallel systems. + + This *generator* adheres to the `Generator Standard `_. + + .. seealso:: + + `https://doi.org/10.1007/s12532-017-0131-4 `_ VOCS variables must include both regular and *_on_cube versions. E.g.,: + vars_std = { "var1": [-10.0, 10.0], "var2": [0.0, 100.0], @@ -26,23 +35,95 @@ class APOSMM(PersistentGenInterfacer): "x": ["var1", "var2", "var3"], "x_on_cube": ["var1_on_cube", "var2_on_cube", "var3_on_cube"], } - gen = APOSMM(vocs, variables_mapping=variables_mapping, ...) + gen = APOSMM(vocs, 3, 3, variables_mapping=variables_mapping, ...) + + Parameters + ---------- + vocs: VOCS + The VOCS object, adhering to the VOCS interface from the Generator Standard. + + max_active_runs: int + Bound on number of runs APOSMM is advancing. + + initial_sample_size: int + Number of uniformly sampled points to be evaluated internally before starting + the localopt runs. `.suggest()` will return samples from these points. + + History: npt.NDArray = [] + An optional history of previously evaluated points. + + sample_points: npt.NDArray = None + Points to be sampled (original domain). + If more sample points are needed by APOSMM during the course of the + optimization, points will be drawn uniformly over the domain. + + localopt_method: str = "LN_BOBYQA" + The local optimization method to use. + + rk_const: float = None + Multiplier in front of the ``r_k`` value. + If not provided, it will be set to ``0.5 * ((gamma(1 + (n / 2)) * 5) ** (1 / n)) / sqrt(pi)`` + + xtol_abs: float = 1e-6 + Localopt method's convergence tolerance. + + ftol_abs: float = 1e-6 + Localopt method's convergence tolerance. + + dist_to_bound_multiple: float = 0.5 + What fraction of the distance to the nearest boundary should the initial + step size be in localopt runs. + + random_seed: int = 1 + Seed for the random number generator. """ def __init__( self, vocs: VOCS, + max_active_runs: int, + initial_sample_size: int, History: npt.NDArray = [], - persis_info: dict = {}, - gen_specs: dict = {}, - libE_info: dict = {}, + sample_points: npt.NDArray = None, + localopt_method: str = "LN_BOBYQA", + rk_const: float = None, + xtol_abs: float = 1e-6, + ftol_abs: float = 1e-6, + dist_to_bound_multiple: float = 0.5, + random_seed: int = 1, **kwargs, ) -> None: + from libensemble.gen_funcs.persistent_aposmm import aposmm self.VOCS = vocs - gen_specs["gen_f"] = aposmm + + gen_specs = {} gen_specs["user"] = {} + persis_info = {} + libE_info = {} + gen_specs["gen_f"] = aposmm + n = len(list(vocs.variables.keys())) + + if not rk_const: + rk_const = 0.5 * ((gamma(1 + (n / 2)) * 5) ** (1 / n)) / sqrt(pi) + + FIELDS = [ + "initial_sample_size", + "sample_points", + "localopt_method", + "rk_const", + "xtol_abs", + "ftol_abs", + "dist_to_bound_multiple", + "max_active_runs", + ] + + for k in FIELDS: + val = locals().get(k) + if val is not None: + gen_specs["user"][k] = val + super().__init__(vocs, History, persis_info, gen_specs, libE_info, **kwargs) # Set bounds using the correct x mapping @@ -50,29 +131,25 @@ def __init__( self.gen_specs["user"]["lb"] = np.array([vocs.variables[var].domain[0] for var in x_mapping]) self.gen_specs["user"]["ub"] = np.array([vocs.variables[var].domain[1] for var in x_mapping]) - if not gen_specs.get("out"): - x_size = len(self.variables_mapping.get("x", [])) - x_on_cube_size = len(self.variables_mapping.get("x_on_cube", [])) - assert x_size > 0 and x_on_cube_size > 0, "Both x and x_on_cube must be specified in variables_mapping" - assert ( - x_size == x_on_cube_size - ), f"x and x_on_cube must have same length but got {x_size} and {x_on_cube_size}" - - gen_specs["out"] = [ - ("x", float, x_size), - ("x_on_cube", float, x_on_cube_size), - ("sim_id", int), - ("local_min", bool), - ("local_pt", bool), - ] - - gen_specs["persis_in"] = ["sim_id", "x", "x_on_cube", "f", "sim_ended"] - if "components" in kwargs or "components" in gen_specs.get("user", {}): - gen_specs["persis_in"].append("fvec") + x_size = len(self.variables_mapping.get("x", [])) + x_on_cube_size = len(self.variables_mapping.get("x_on_cube", [])) + assert x_size > 0 and x_on_cube_size > 0, "Both x and x_on_cube must be specified in variables_mapping" + assert x_size == x_on_cube_size, f"x and x_on_cube must have same length but got {x_size} and {x_on_cube_size}" + + gen_specs["out"] = [ + ("x", float, x_size), + ("x_on_cube", float, x_on_cube_size), + ("sim_id", int), + ("local_min", bool), + ("local_pt", bool), + ] + + gen_specs["persis_in"] = ["sim_id", "x", "x_on_cube", "f", "sim_ended"] + if "components" in kwargs or "components" in gen_specs.get("user", {}): + gen_specs["persis_in"].append("fvec") # SH - Need to know if this is gen_on_manager or not. - if not self.persis_info.get("nworkers"): - self.persis_info["nworkers"] = kwargs.get("nworkers", gen_specs["user"].get("max_active_runs", 4)) + self.persis_info["nworkers"] = gen_specs["user"].get("max_active_runs") self.all_local_minima = [] self._suggest_idx = 0 self._last_suggest = None diff --git a/libensemble/gen_funcs/aposmm_localopt_support.py b/libensemble/gen_funcs/aposmm_localopt_support.py index 901162783..21ceb5e0e 100644 --- a/libensemble/gen_funcs/aposmm_localopt_support.py +++ b/libensemble/gen_funcs/aposmm_localopt_support.py @@ -9,6 +9,7 @@ "run_local_tao", "run_local_dfols", "run_local_ibcdfo_pounders", + "run_local_ibcdfo_manifold_sampling", "run_local_scipy_opt", "run_external_localopt", ] @@ -27,7 +28,7 @@ class APOSMMException(Exception): """Raised for any exception in APOSMM""" -optimizer_list = ["petsc", "nlopt", "dfols", "scipy", "ibcdfo", "external"] +optimizer_list = ["petsc", "nlopt", "dfols", "scipy", "ibcdfo_pounders", "ibcdfo_manifold_sampling", "external"] optimizers = libensemble.gen_funcs.rc.aposmm_optimizers if optimizers is not None: @@ -43,8 +44,10 @@ class APOSMMException(Exception): import nlopt # noqa: F401 if "dfols" in optimizers: import dfols # noqa: F401 - if "ibcdfo" in optimizers: - from ibcdfo import pounders # noqa: F401 + if "ibcdfo_pounders" in optimizers: + from ibcdfo.pounders import pounders # noqa: F401 + if "ibcdfo_manifold_sampling" in optimizers: + from ibcdfo.manifold_sampling import manifold_sampling_primal # noqa: F401 if "scipy" in optimizers: from scipy import optimize as sp_opt # noqa: F401 if "external_localopt" in optimizers: @@ -80,6 +83,7 @@ class LocalOptInterfacer(object): - PETSc/TAO [``'pounders'``, ``'blmvm'``, ``'nm'``] - SciPy [``'scipy_Nelder-Mead'``, ``'scipy_COBYLA'``, ``'scipy_BFGS'``] - DFOLS [``'dfols'``] + - IBCDFO [``'pounders'``, ``'manifold_sampling_primal'``] - External local optimizer [``'external_localopt'``] (which use files to pass/receive ``x/f`` values) """ @@ -124,6 +128,8 @@ def __init__(self, user_specs, x0, f0, grad0=None): run_local_opt = run_local_dfols elif user_specs["localopt_method"] in ["ibcdfo_pounders"]: run_local_opt = run_local_ibcdfo_pounders + elif user_specs["localopt_method"] in ["ibcdfo_manifold_sampling"]: + run_local_opt = run_local_ibcdfo_manifold_sampling elif user_specs["localopt_method"] in ["external_localopt"]: run_local_opt = run_external_localopt else: @@ -418,6 +424,60 @@ def run_local_dfols(user_specs, comm_queue, x0, f0, child_can_read, parent_can_r finish_queue(x_opt, opt_flag, comm_queue, parent_can_read, user_specs) +def run_local_ibcdfo_manifold_sampling(user_specs, comm_queue, x0, f0, child_can_read, parent_can_read): + """ + Runs a IBCDFO local optimization run starting at ``x0``, governed by the + parameters in ``user_specs``. + + Although IBCDFO methods can receive previous evaluations, few other methods + support that, so APOSMM assumes the first point will be re-evaluated (but + not be sent back to the manager). + """ + n = len(x0) + # Define bound constraints (lower <= x <= upper) + lb = np.zeros(n) + ub = np.ones(n) + + # Set random seed (for reproducibility) + np.random.seed(0) + + # dist_to_bound = min(min(ub - x0), min(x0 - lb)) + # assert dist_to_bound > np.finfo(np.float64).eps, "The distance to the boundary is too small" + + run_max_eval = user_specs.get("run_max_eval", 100 * (n + 1)) + # g_tol = 1e-8 + # delta_0 = 0.5 * dist_to_bound + # m = len(f0) + subprob_switch = "linprog" + + [X, F, hF, xkin, flag] = manifold_sampling_primal( + user_specs["hfun"], + lambda x: scipy_dfols_callback_fun(x, comm_queue, child_can_read, parent_can_read, user_specs), + x0, + lb, + ub, + run_max_eval, + subprob_switch, + ) + + assert flag >= 0 or flag == -6, "IBCDFO errored" + + x_opt = X[xkin] + + if flag > 0: + opt_flag = 1 + else: + print( + "[APOSMM] The IBCDFO run started from " + str(x0) + " stopped with an exit " + "flag of " + str(flag) + ". No point from this run will be " + "ruled as a minimum! APOSMM may start a new run from some point " + "in this run." + ) + opt_flag = 0 + + finish_queue(x_opt, opt_flag, comm_queue, parent_can_read, user_specs) + + def run_local_ibcdfo_pounders(user_specs, comm_queue, x0, f0, child_can_read, parent_can_read): """ Runs a IBCDFO local optimization run starting at ``x0``, governed by the @@ -448,7 +508,7 @@ def run_local_ibcdfo_pounders(user_specs, comm_queue, x0, f0, child_can_read, pa else: Options = None - [X, F, hF, flag, xkin] = pounders.pounders( + [X, F, hF, flag, xkin] = pounders( lambda x: scipy_dfols_callback_fun(x, comm_queue, child_can_read, parent_can_read, user_specs), x0, n, diff --git a/libensemble/gen_funcs/persistent_aposmm.py b/libensemble/gen_funcs/persistent_aposmm.py index b1e3a3d9a..685fa4021 100644 --- a/libensemble/gen_funcs/persistent_aposmm.py +++ b/libensemble/gen_funcs/persistent_aposmm.py @@ -739,7 +739,7 @@ def initialize_children(user_specs): "nm", ]: fields_to_pass = ["x_on_cube", "f"] - elif user_specs["localopt_method"] in ["pounders", "ibcdfo_pounders", "dfols"]: + elif user_specs["localopt_method"] in ["pounders", "ibcdfo_pounders", "ibcdfo_manifold_sampling", "dfols"]: fields_to_pass = ["x_on_cube", "fvec"] else: raise NotImplementedError(f"Unknown local optimization method {user_specs['localopt_method']}.") diff --git a/libensemble/gen_funcs/persistent_ax_multitask.py b/libensemble/gen_funcs/persistent_ax_multitask.py index ede50e46b..0a2e07f20 100644 --- a/libensemble/gen_funcs/persistent_ax_multitask.py +++ b/libensemble/gen_funcs/persistent_ax_multitask.py @@ -376,9 +376,9 @@ def max_utility_from_GP(n, m, gr, hifi_task): f, cov = m.predict(obsf) # Compute expected utility u = -np.array(f["hifi_metric"]) - best_arm_indx = np.flip(np.argsort(u))[:n] + best_arm_index = np.flip(np.argsort(u))[:n] gr_new = GeneratorRun( - arms=[gr.arms[i] for i in best_arm_indx], + arms=[gr.arms[i] for i in best_arm_index], weights=[1.0] * n, ) return gr_new diff --git a/libensemble/gen_funcs/persistent_gpCAM.py b/libensemble/gen_funcs/persistent_gpCAM.py index 05b08bb5e..262ca2d6b 100644 --- a/libensemble/gen_funcs/persistent_gpCAM.py +++ b/libensemble/gen_funcs/persistent_gpCAM.py @@ -158,7 +158,7 @@ def persistent_gpCAM(H_in, persis_info, gen_specs, libE_info): """ This generation function constructs a global surrogate of `f` values. It is a batched method that produces a first batch uniformly random from (lb, ub). - On subequent iterations, it calls an optimization method to produce the next + On subsequent iterations, it calls an optimization method to produce the next batch of points. This optimization might be too slow (relative to the simulation evaluation time) for some use cases. diff --git a/libensemble/gen_funcs/persistent_sampling.py b/libensemble/gen_funcs/persistent_sampling.py index 401ccdaa9..375d7f438 100644 --- a/libensemble/gen_funcs/persistent_sampling.py +++ b/libensemble/gen_funcs/persistent_sampling.py @@ -30,7 +30,7 @@ def _get_user_params(user_specs): @persistent_input_fields(["sim_id"]) -@output_data([("x", float, (2,))]) # The dimesion of 2 is a default and can be overwritten +@output_data([("x", float, (2,))]) # The dimension of 2 is a default and can be overwritten def persistent_uniform(_, persis_info, gen_specs, libE_info): """ This generation function always enters into persistent mode and returns diff --git a/libensemble/tests/regression_tests/declare_hfun_and_combine_model_with_jax.py b/libensemble/tests/regression_tests/declare_hfun_and_combine_model_with_jax.py new file mode 100644 index 000000000..0e49363e2 --- /dev/null +++ b/libensemble/tests/regression_tests/declare_hfun_and_combine_model_with_jax.py @@ -0,0 +1,50 @@ +# This declares the hfun for Test_compare_pounder_pounders_with_jax.py and +# then used jax to combine the quadratic models of each component of the +# inputs to the hfun. +# +# For other general use cases of pounders on smooth hfuns, only the hfun below +# needs to be changed (and combinemodels_jax can be given to pounders) + + +import jax +import numpy + +jax.config.update("jax_enable_x64", True) + + +def hfun(z): + res = z[0] * z[1] - z[2] ** 2 + return res + + +@jax.jit +def hfun_d(z, zd): + resd = jax.jvp(hfun, (z,), (zd,)) + return resd + + +@jax.jit +def hfun_dd(z, zd, zdt, zdd): + _, resdd = jax.jvp(hfun_d, (z, zd), (zdt, zdd)) + return resdd + + +def G_combine(Cres, Gres): + n, m = Gres.shape + G = numpy.zeros(n) + for i in range(n): + _, G[i] = hfun_d(Cres, Gres[i, :]) + return G + + +def H_combine(Cres, Gres, Hres): + n, _, m = Hres.shape + H = numpy.zeros((n, n)) + for i in range(n): + for j in range(n): + _, H[i, j] = hfun_dd(Cres, Gres[i, :], Gres[j, :], Hres[i, j, :]) + return H + + +def combinemodels_jax(Cres, Gres, Hres): + return G_combine(Cres, Gres), H_combine(Cres, Gres, Hres) diff --git a/libensemble/tests/regression_tests/test_asktell_aposmm_nlopt.py b/libensemble/tests/regression_tests/test_asktell_aposmm_nlopt.py index 0f80e42ca..67716dca1 100644 --- a/libensemble/tests/regression_tests/test_asktell_aposmm_nlopt.py +++ b/libensemble/tests/regression_tests/test_asktell_aposmm_nlopt.py @@ -59,6 +59,7 @@ aposmm = APOSMM( vocs, + max_active_runs=workflow.nworkers, # should this match nworkers always? practically? variables_mapping={"x": ["core", "edge"], "x_on_cube": ["core_on_cube", "edge_on_cube"], "f": ["energy"]}, initial_sample_size=100, sample_points=minima, @@ -66,7 +67,6 @@ rk_const=0.5 * ((gamma(1 + (n / 2)) * 5) ** (1 / n)) / sqrt(pi), xtol_abs=1e-6, ftol_abs=1e-6, - max_active_runs=workflow.nworkers, # should this match nworkers always? practically? ) # SH TODO - dont want this stuff duplicated - pass with vocs instead diff --git a/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_manifold_sampling.py b/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_manifold_sampling.py new file mode 100644 index 000000000..9f987eadc --- /dev/null +++ b/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_manifold_sampling.py @@ -0,0 +1,129 @@ +""" +Runs libEnsemble with APOSMM+IBCDFO on two test problems. Only a single +optimization run is being performed for the below setup. + +The first case uses POUNDERS to solve the chwirut least-squares problem. For +this case, all chwirut 214 residual calculations for a given point are +performed as a single simulation evaluation. + +The second case uses the generalized POUNDERS to minimize normalized beamline +emittance. The "beamline simulation" is a synthetic polynomial test function +that takes in 4 variables and returning 3 outputs. These outputs represent +position , momentum , and the correlation between them . + +These values are then mapped to the normalized emittance - . + +Execute via one of the following commands: + mpiexec -np 3 python test_persistent_aposmm_ibcdfo_pounders.py + python test_persistent_aposmm_ibcdfo_pounders.py --nworkers 2 +Both will run with 1 manager, 1 worker running APOSMM+IBCDFO, and 1 worker +doing the simulation evaluations. +""" + +# Do not change these lines - they are parsed by run-tests.sh +# TESTSUITE_COMMS: local mpi +# TESTSUITE_NPROCS: 3 + +import multiprocessing +import sys + +import numpy as np + +import libensemble.gen_funcs +from libensemble.libE import libE + +libensemble.gen_funcs.rc.aposmm_optimizers = "ibcdfo_manifold_sampling" + +from libensemble.alloc_funcs.persistent_aposmm_alloc import persistent_aposmm_alloc as alloc_f +from libensemble.gen_funcs.persistent_aposmm import aposmm as gen_f +from libensemble.tools import add_unique_random_streams, parse_args, save_libE_output + +try: + from ibcdfo.manifold_sampling import manifold_sampling_primal # noqa: F401 + from ibcdfo.manifold_sampling.h_examples import pw_maximum as hfun + +except ModuleNotFoundError: + sys.exit("Please 'pip install ibcdfo'") + +try: + from minqsw import minqsw # noqa: F401 + +except ModuleNotFoundError: + sys.exit("Ensure https://github.com/POptUS/minq has been cloned and that minq/py/minq5/ is on the PYTHONPATH") + + +def synthetic_beamline_mapping(H, _, sim_specs): + x = H["x"][0] + assert len(x) == 4, "Assuming 4 inputs to this function" + y = np.zeros(3) # Synthetic beamline outputs + y[0] = x[0] ** 2 + 1.0 + y[1] = x[1] ** 2 + 2.0 + y[2] = x[2] * x[3] + 0.5 + + Out = np.zeros(1, dtype=sim_specs["out"]) + Out["fvec"] = y + Out["f"] = np.max(y) + return Out + + +# Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). +if __name__ == "__main__": + multiprocessing.set_start_method("fork", force=True) + + nworkers, is_manager, libE_specs, _ = parse_args() + + assert nworkers == 2, "This test is just for two workers" + + m = 3 + n = 4 + sim_f = synthetic_beamline_mapping + + sim_specs = { + "sim_f": sim_f, + "in": ["x"], + "out": [("f", float), ("fvec", float, m)], + } + + gen_out = [ + ("x", float, n), + ("x_on_cube", float, n), + ("sim_id", int), + ("local_min", bool), + ("local_pt", bool), + ("started_run", bool), + ] + + gen_specs = { + "gen_f": gen_f, + "persis_in": ["f", "fvec"] + [n[0] for n in gen_out], + "out": gen_out, + "user": { + "initial_sample_size": 1, + "stop_after_k_runs": 1, + "max_active_runs": 1, + "sample_points": np.atleast_2d(0.1 * (np.arange(n) + 1)), + "localopt_method": "ibcdfo_manifold_sampling", + "run_max_eval": 100 * (n + 1), + "components": m, + "lb": -1 * np.ones(n), + "ub": np.ones(n), + }, + } + + gen_specs["user"]["hfun"] = hfun + + alloc_specs = {"alloc_f": alloc_f} + + persis_info = add_unique_random_streams({}, nworkers + 1) + + exit_criteria = {"sim_max": 500} + + # Perform the run + H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, persis_info, alloc_specs, libE_specs) + + if is_manager: + assert np.min(H["f"]) == 2.0, "The best is 2" + assert persis_info[1].get("run_order"), "Run_order should have been given back" + assert flag == 0 + + save_libE_output(H, persis_info, __file__, nworkers) diff --git a/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders.py b/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders.py index 7523704a0..753337c9b 100644 --- a/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders.py +++ b/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders.py @@ -33,7 +33,7 @@ from libensemble.libE import libE from libensemble.sim_funcs.chwirut1 import chwirut_eval -libensemble.gen_funcs.rc.aposmm_optimizers = "ibcdfo" +libensemble.gen_funcs.rc.aposmm_optimizers = "ibcdfo_pounders" from libensemble.alloc_funcs.persistent_aposmm_alloc import persistent_aposmm_alloc as alloc_f from libensemble.gen_funcs.persistent_aposmm import aposmm as gen_f diff --git a/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders_jax.py b/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders_jax.py new file mode 100644 index 000000000..9d635ae60 --- /dev/null +++ b/libensemble/tests/regression_tests/test_persistent_aposmm_ibcdfo_pounders_jax.py @@ -0,0 +1,134 @@ +""" +Runs libEnsemble with APOSMM+IBCDFO on two test problems. Only a single +optimization run is being performed for the below setup. + +The first case uses POUNDERS to solve the chwirut least-squares problem. For +this case, all chwirut 214 residual calculations for a given point are +performed as a single simulation evaluation. + +The second case uses the generalized POUNDERS to minimize normalized beamline +emittance. The "beamline simulation" is a synthetic polynomial test function +that takes in 4 variables and returning 3 outputs. These outputs represent +position , momentum , and the correlation between them . + +These values are then mapped to the normalized emittance - . + +Execute via one of the following commands: + mpiexec -np 3 python test_persistent_aposmm_ibcdfo_pounders.py + python test_persistent_aposmm_ibcdfo_pounders.py --nworkers 2 +Both will run with 1 manager, 1 worker running APOSMM+IBCDFO, and 1 worker +doing the simulation evaluations. +""" + +# Do not change these lines - they are parsed by run-tests.sh +# TESTSUITE_COMMS: local mpi +# TESTSUITE_NPROCS: 3 + +import multiprocessing +import sys + +import numpy as np + +import libensemble.gen_funcs +from libensemble.libE import libE + +libensemble.gen_funcs.rc.aposmm_optimizers = "ibcdfo_pounders" + +from libensemble.alloc_funcs.persistent_aposmm_alloc import persistent_aposmm_alloc as alloc_f +from libensemble.gen_funcs.persistent_aposmm import aposmm as gen_f +from libensemble.tools import add_unique_random_streams, parse_args, save_libE_output + +try: + from ibcdfo.pounders import pounders # noqa: F401 + from declare_hfun_and_combine_model_with_jax import hfun, combinemodels_jax + +except ModuleNotFoundError: + sys.exit("Please 'pip install ibcdfo'") + +try: + from minqsw import minqsw # noqa: F401 + +except ModuleNotFoundError: + sys.exit("Ensure https://github.com/POptUS/minq has been cloned and that minq/py/minq5/ is on the PYTHONPATH") + + +def sum_squared(x): + return np.sum(np.power(x, 2)) + + +def synthetic_beamline_mapping(H, _, sim_specs): + x = H["x"][0] + assert len(x) == 4, "Assuming 4 inputs to this function" + y = np.zeros(3) # Synthetic beamline outputs + y[0] = x[0] ** 2 + 1.0 + y[1] = x[1] ** 2 + 2.0 + y[2] = x[2] * x[3] + 0.5 + + Out = np.zeros(1, dtype=sim_specs["out"]) + Out["fvec"] = y + Out["f"] = y[0] * y[1] - y[2] ** 2 + return Out + + +# Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). +if __name__ == "__main__": + multiprocessing.set_start_method("fork", force=True) + + nworkers, is_manager, libE_specs, _ = parse_args() + + assert nworkers == 2, "This test is just for two workers" + + m = 3 + n = 4 + sim_f = synthetic_beamline_mapping + + sim_specs = { + "sim_f": sim_f, + "in": ["x"], + "out": [("f", float), ("fvec", float, m)], + } + + gen_out = [ + ("x", float, n), + ("x_on_cube", float, n), + ("sim_id", int), + ("local_min", bool), + ("local_pt", bool), + ("started_run", bool), + ] + + gen_specs = { + "gen_f": gen_f, + "persis_in": ["f", "fvec"] + [n[0] for n in gen_out], + "out": gen_out, + "user": { + "initial_sample_size": 1, + "stop_after_k_runs": 1, + "max_active_runs": 1, + "sample_points": np.atleast_2d(0.1 * (np.arange(n) + 1)), + "localopt_method": "ibcdfo_pounders", + "run_max_eval": 100 * (n + 1), + "components": m, + "lb": -1 * np.ones(n), + "ub": np.ones(n), + }, + } + + gen_specs["user"]["hfun"] = hfun + gen_specs["user"]["combinemodels"] = combinemodels_jax + + alloc_specs = {"alloc_f": alloc_f} + + persis_info = add_unique_random_streams({}, nworkers + 1) + + exit_criteria = {"sim_max": 500} + + # Perform the run + H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, persis_info, alloc_specs, libE_specs) + + if is_manager: + print(H[["x", "f", "local_min"]]) + assert persis_info[1].get("run_order"), "Run_order should have been given back" + assert flag == 0 + + save_libE_output(H, persis_info, __file__, nworkers) diff --git a/libensemble/tests/unit_tests/test_persistent_aposmm.py b/libensemble/tests/unit_tests/test_persistent_aposmm.py index 8ea4eebed..05aee2137 100644 --- a/libensemble/tests/unit_tests/test_persistent_aposmm.py +++ b/libensemble/tests/unit_tests/test_persistent_aposmm.py @@ -122,6 +122,53 @@ def test_standalone_persistent_aposmm(): assert min_found >= 6, f"Found {min_found} minima" +def _evaluate_aposmm_instance(my_APOSMM): + from libensemble.message_numbers import FINISHED_PERSISTENT_GEN_TAG + from libensemble.sim_funcs.six_hump_camel import six_hump_camel_func + from libensemble.tests.regression_tests.support import six_hump_camel_minima as minima + + initial_sample = my_APOSMM.suggest(100) + + total_evals = 0 + eval_max = 2000 + + for point in initial_sample: + point["energy"] = six_hump_camel_func(np.array([point["core"], point["edge"]])) + total_evals += 1 + + my_APOSMM.ingest(initial_sample) + + potential_minima = [] + + while total_evals < eval_max: + + sample, detected_minima = my_APOSMM.suggest(6), my_APOSMM.suggest_updates() + if len(detected_minima): + for m in detected_minima: + potential_minima.append(m) + for point in sample: + point["energy"] = six_hump_camel_func(np.array([point["core"], point["edge"]])) + total_evals += 1 + my_APOSMM.ingest(sample) + my_APOSMM.finalize() + H, persis_info, exit_code = my_APOSMM.export() + + assert exit_code == FINISHED_PERSISTENT_GEN_TAG, "Standalone persistent_aposmm didn't exit correctly" + assert persis_info.get("run_order"), "Standalone persistent_aposmm didn't do any localopt runs" + + assert len(potential_minima) >= 6, f"Found {len(potential_minima)} minima" + + tol = 1e-3 + min_found = 0 + for m in minima: + # The minima are known on this test problem. + # We use their values to test APOSMM has identified all minima + print(np.min(np.sum((H[H["local_min"]]["x"] - m) ** 2, 1)), flush=True) + if np.min(np.sum((H[H["local_min"]]["x"] - m) ** 2, 1)) < tol: + min_found += 1 + assert min_found >= 6, f"Found {min_found} minima" + + @pytest.mark.extra def test_standalone_persistent_aposmm_combined_func(): from math import gamma, pi, sqrt @@ -176,14 +223,11 @@ def test_asktell_with_persistent_aposmm(): import libensemble.gen_funcs from libensemble.gen_classes import APOSMM - from libensemble.message_numbers import FINISHED_PERSISTENT_GEN_TAG - from libensemble.sim_funcs.six_hump_camel import six_hump_camel_func from libensemble.tests.regression_tests.support import six_hump_camel_minima as minima libensemble.gen_funcs.rc.aposmm_optimizers = "nlopt" n = 2 - eval_max = 2000 variables = {"core": [-3, 3], "edge": [-2, 2], "core_on_cube": [0, 1], "edge_on_cube": [0, 1]} objectives = {"energy": "MINIMIZE"} @@ -198,66 +242,24 @@ def test_asktell_with_persistent_aposmm(): my_APOSMM = APOSMM( vocs, - variables_mapping=variables_mapping, + max_active_runs=6, initial_sample_size=100, + variables_mapping=variables_mapping, sample_points=np.round(minima, 1), localopt_method="LN_BOBYQA", 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=6, ) - initial_sample = my_APOSMM.suggest(100) - - total_evals = 0 - eval_max = 2000 - - for point in initial_sample: - point["energy"] = six_hump_camel_func(np.array([point["core"], point["edge"]])) - total_evals += 1 - - my_APOSMM.ingest(initial_sample) - - potential_minima = [] - - while total_evals < eval_max: - - sample, detected_minima = my_APOSMM.suggest(6), my_APOSMM.suggest_updates() - if detected_minima: - print(f"sample {sample} detected_minima: {detected_minima}") - if len(detected_minima): - for m in detected_minima: - potential_minima.append(m) - for point in sample: - point["energy"] = six_hump_camel_func(np.array([point["core"], point["edge"]])) - total_evals += 1 - my_APOSMM.ingest(sample) - my_APOSMM.finalize() - H, persis_info, exit_code = my_APOSMM.export() - - print(f"Number of local_min points in H: {np.sum(H['local_min'])}", flush=True) - - assert exit_code == FINISHED_PERSISTENT_GEN_TAG, "Standalone persistent_aposmm didn't exit correctly" - assert persis_info.get("run_order"), "Standalone persistent_aposmm didn't do any localopt runs" - - assert len(potential_minima) >= 6, f"Found {len(potential_minima)} minima" - - tol = 1e-3 - min_found = 0 - for m in minima: - # The minima are known on this test problem. - # We use their values to test APOSMM has identified all minima - print(np.min(np.sum((H[H["local_min"]]["x"] - m) ** 2, 1)), flush=True) - if np.min(np.sum((H[H["local_min"]]["x"] - m) ** 2, 1)) < tol: - min_found += 1 - assert min_found >= 6, f"Found {min_found} minima" + _evaluate_aposmm_instance(my_APOSMM) def _run_aposmm_export_test(variables_mapping): """Helper function to run APOSMM export tests with given variables_mapping""" from gest_api.vocs import VOCS + from libensemble.gen_classes import APOSMM variables = { @@ -271,13 +273,13 @@ def _run_aposmm_export_test(variables_mapping): vocs = VOCS(variables=variables, objectives=objectives) aposmm = APOSMM( vocs, - variables_mapping=variables_mapping, + max_active_runs=6, initial_sample_size=10, + variables_mapping=variables_mapping, localopt_method="LN_BOBYQA", xtol_abs=1e-6, ftol_abs=1e-6, dist_to_bound_multiple=0.5, - max_active_runs=6, ) # Test basic export before finalize H, _, _ = aposmm.export() diff --git a/pyproject.toml b/pyproject.toml index 69be28199..3eb2770d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ HPE = "HPE" RO = "RO" lst = "lst" noy = "noy" +inpt = "inpt" [tool.typos.files] extend-exclude = ["*.bib", "*.xml", "docs/nitpicky"]