From 1ab1c36db6ec0430c672e7d833cd2e2c61e2f75a Mon Sep 17 00:00:00 2001 From: shudson Date: Thu, 14 May 2026 13:01:52 -0500 Subject: [PATCH 1/3] Add sampling gens in vocs --- libensemble/gen_classes/sampling.py | 241 ++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/libensemble/gen_classes/sampling.py b/libensemble/gen_classes/sampling.py index 042329bf3..0b0662448 100644 --- a/libensemble/gen_classes/sampling.py +++ b/libensemble/gen_classes/sampling.py @@ -7,6 +7,11 @@ __all__ = [ "UniformSample", + "LatinHypercubeSample", + "UniformSampleObjComponents", + "UniformSampleWithVariableResources", + "UniformSampleWithVarPrioritiesAndResources", + "UniformSampleCancel", ] @@ -38,3 +43,239 @@ def suggest_numpy(self, n_trials): def ingest_numpy(self, calc_in): pass # random sample so nothing to tell + + +def _lhs_unit_cube(n, k, rng): + """Generate ``k`` points in [0,1]^n using Latin hypercube sampling.""" + intervals = np.linspace(0, 1, k + 1) + rand_source = rng.uniform(0, 1, (k, n)) + rand_pts = np.zeros((k, n)) + sample = np.zeros((k, n)) + + a = intervals[:k] + b = intervals[1:] + for j in range(n): + rand_pts[:, j] = rand_source[:, j] * (b - a) + a + + for j in range(n): + sample[:, j] = rand_pts[rng.permutation(k), j] + + return sample + + +class LatinHypercubeSample(LibensembleGenerator): + """ + Latin hypercube sample over the domain specified in the VOCS. + + All ``n_trials`` points are drawn at once from a single LHS design, so + consecutive ``suggest()`` calls return new LHS designs (each independently + space-filling, but not stratified together). + """ + + def __init__(self, vocs: VOCS, random_seed: int = 1, *args, **kwargs): + super().__init__(vocs, *args, **kwargs) + self.rng = np.random.default_rng(random_seed) + + self.n = len(list(self.vocs.variables.keys())) + self.np_dtype = [(name, float) for name in self.vocs.variables.keys()] + self.lb = np.array([vocs.variables[i].domain[0] for i in vocs.variables]) + self.ub = np.array([vocs.variables[i].domain[1] for i in vocs.variables]) + + def suggest_numpy(self, n_trials): + out = np.zeros(n_trials, dtype=self.np_dtype) + + sample = _lhs_unit_cube(self.n, n_trials, self.rng) + scaled = sample * (self.ub - self.lb) + self.lb + for j, name in enumerate(self.vocs.variables.keys()): + out[name] = scaled[:, j] + + return out + + def ingest_numpy(self, calc_in): + pass + + +class UniformSampleObjComponents(LibensembleGenerator): + """ + Uniform random sample where each suggested point is replicated ``components`` + times so each objective component is evaluated separately. Each replicated row + carries the same ``x`` plus an ``obj_component`` index, a shared ``pt_id``, + and an independent random ``priority``. + + Used by component-aware solvers (e.g. POUNDERS, where each residual is its + own evaluation). The ``obj_component``, ``pt_id``, and ``priority`` fields are + libEnsemble H-array fields rather than VOCS objectives — downstream sim_f + is expected to read ``obj_component`` and return the matching residual. + """ + + def __init__(self, vocs: VOCS, components: int, random_seed: int = 1, *args, **kwargs): + super().__init__(vocs, *args, **kwargs) + self.rng = np.random.default_rng(random_seed) + self.components = components + + self.n = len(list(self.vocs.variables.keys())) + self.np_dtype = [(name, float) for name in self.vocs.variables.keys()] + [ + ("priority", float), + ("obj_component", int), + ("pt_id", int), + ] + self.lb = np.array([vocs.variables[i].domain[0] for i in vocs.variables]) + self.ub = np.array([vocs.variables[i].domain[1] for i in vocs.variables]) + self._pt_id_offset = 0 + + def suggest_numpy(self, n_trials): + m = self.components + out = np.zeros(n_trials * m, dtype=self.np_dtype) + + for i in range(n_trials): + x = self.rng.uniform(self.lb, self.ub, (1, self.n)) + slc = slice(i * m, (i + 1) * m) + for j, name in enumerate(self.vocs.variables.keys()): + out[name][slc] = x[0, j] + out["priority"][slc] = self.rng.uniform(0, 1, m) + out["obj_component"][slc] = np.arange(m) + out["pt_id"][slc] = self._pt_id_offset + i + + self._pt_id_offset += n_trials + return out + + def ingest_numpy(self, calc_in): + pass + + +class UniformSampleWithVariableResources(LibensembleGenerator): + """ + Uniform random sample that also requests a random number of resource sets per + evaluation (1 to ``max_resource_sets``). For testing/demonstrating variable + resource allocation. + + .. note:: + ``resource_sets`` is a libEnsemble manager-side H-array field, not a + VOCS variable. Whether the downstream libE manager honors it via this + new generator-class path depends on alloc_specs; the classic gen_funcs + path was tested with the default alloc. + """ + + def __init__( + self, vocs: VOCS, max_resource_sets: int, random_seed: int = 1, *args, **kwargs + ): + super().__init__(vocs, *args, **kwargs) + self.rng = np.random.default_rng(random_seed) + self.max_rsets = max_resource_sets + + self.n = len(list(self.vocs.variables.keys())) + self.np_dtype = [(name, float) for name in self.vocs.variables.keys()] + [ + ("resource_sets", int), + ] + self.lb = np.array([vocs.variables[i].domain[0] for i in vocs.variables]) + self.ub = np.array([vocs.variables[i].domain[1] for i in vocs.variables]) + + def suggest_numpy(self, n_trials): + out = np.zeros(n_trials, dtype=self.np_dtype) + + vals = self.rng.uniform(self.lb, self.ub, (n_trials, self.n)) + for j, name in enumerate(self.vocs.variables.keys()): + out[name] = vals[:, j] + out["resource_sets"] = self.rng.integers(1, self.max_rsets + 1, n_trials) + + return out + + def ingest_numpy(self, calc_in): + pass + + +class UniformSampleWithVarPrioritiesAndResources(LibensembleGenerator): + """ + Uniform random sample that emits an initial batch of ``initial_batch_size`` + points (each with one resource set and uniform priority), then on subsequent + calls emits one point at a time with a random number of resource sets (1 to + ``max_resource_sets``) and priority scaled by that count. + + .. note:: + Same caveat as ``UniformSampleWithVariableResources`` re: ``resource_sets`` + and ``priority`` being libEnsemble H-array fields rather than VOCS items. + """ + + def __init__( + self, + vocs: VOCS, + max_resource_sets: int, + initial_batch_size: int, + random_seed: int = 1, + *args, + **kwargs, + ): + super().__init__(vocs, *args, **kwargs) + self.rng = np.random.default_rng(random_seed) + self.max_rsets = max_resource_sets + self.initial_batch_size = initial_batch_size + self._initial_emitted = False + + self.n = len(list(self.vocs.variables.keys())) + self.np_dtype = [(name, float) for name in self.vocs.variables.keys()] + [ + ("resource_sets", int), + ("priority", float), + ] + self.lb = np.array([vocs.variables[i].domain[0] for i in vocs.variables]) + self.ub = np.array([vocs.variables[i].domain[1] for i in vocs.variables]) + + def suggest_numpy(self, n_trials): + if not self._initial_emitted: + b = self.initial_batch_size + out = np.zeros(b, dtype=self.np_dtype) + for i in range(b): + x = self.rng.uniform(self.lb, self.ub, (1, self.n)) + for j, name in enumerate(self.vocs.variables.keys()): + out[name][i] = x[0, j] + out["resource_sets"] = 1 + out["priority"] = 1.0 + self._initial_emitted = True + return out + + out = np.zeros(1, dtype=self.np_dtype) + x = self.rng.uniform(self.lb, self.ub) + for j, name in enumerate(self.vocs.variables.keys()): + out[name][0] = x[j] + out["resource_sets"][0] = self.rng.integers(1, self.max_rsets + 1) + out["priority"][0] = 10 * out["resource_sets"][0] + return out + + def ingest_numpy(self, calc_in): + pass + + +class UniformSampleCancel(LibensembleGenerator): + """ + Uniform random sample but every 10th point in each batch is emitted with + ``cancel_requested=True``. For testing immediate-cancellation paths. + + .. note:: + ``cancel_requested`` is a libEnsemble H-array field, not a VOCS variable. + Same caveat as the resource samplers. + """ + + def __init__(self, vocs: VOCS, random_seed: int = 1, *args, **kwargs): + super().__init__(vocs, *args, **kwargs) + self.rng = np.random.default_rng(random_seed) + + self.n = len(list(self.vocs.variables.keys())) + self.np_dtype = [(name, float) for name in self.vocs.variables.keys()] + [ + ("cancel_requested", bool), + ] + self.lb = np.array([vocs.variables[i].domain[0] for i in vocs.variables]) + self.ub = np.array([vocs.variables[i].domain[1] for i in vocs.variables]) + + def suggest_numpy(self, n_trials): + out = np.zeros(n_trials, dtype=self.np_dtype) + + vals = self.rng.uniform(self.lb, self.ub, (n_trials, self.n)) + for j, name in enumerate(self.vocs.variables.keys()): + out[name] = vals[:, j] + for i in range(n_trials): + if i % 10 == 0: + out["cancel_requested"][i] = True + + return out + + def ingest_numpy(self, calc_in): + pass From 40c1882a5a01949b00e672773eccc5dea37ef248 Mon Sep 17 00:00:00 2001 From: shudson Date: Thu, 14 May 2026 13:14:48 -0500 Subject: [PATCH 2/3] Add LHS vocs test --- .../regression_tests/test_2d_sampling_vocs.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 libensemble/tests/regression_tests/test_2d_sampling_vocs.py diff --git a/libensemble/tests/regression_tests/test_2d_sampling_vocs.py b/libensemble/tests/regression_tests/test_2d_sampling_vocs.py new file mode 100644 index 000000000..f535e1709 --- /dev/null +++ b/libensemble/tests/regression_tests/test_2d_sampling_vocs.py @@ -0,0 +1,58 @@ +""" +VOCS-based version of test_2d_sampling.py. using the + ``LatinHypercubeSample`` class. + +Execute via one of the following commands (e.g. 3 workers): + mpiexec -np 4 python test_2d_sampling_vocs.py + python test_2d_sampling_vocs.py --nworkers 3 + python test_2d_sampling_vocs.py --nworkers 3 --comms tcp +""" + +# Do not change these lines - they are parsed by run-tests.sh +# TESTSUITE_COMMS: mpi local threads tcp +# TESTSUITE_NPROCS: 2 4 + +import numpy as np +from gest_api.vocs import VOCS + +from libensemble import Ensemble +from libensemble.gen_classes.sampling import LatinHypercubeSample +from libensemble.specs import ExitCriteria, GenSpecs, LibeSpecs, SimSpecs + + +def sim_f(In, persis_info, sim_specs, _): + Out = np.zeros(1, dtype=sim_specs["out"]) + Out["f"] = np.sqrt(In["x0"] ** 2 + In["x1"] ** 2) + return Out, persis_info + + +if __name__ == "__main__": + sampling = Ensemble(parse_args=True) + sampling.libE_specs = LibeSpecs(save_every_k_sims=100) + sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x0", "x1"], outputs=[("f", float)]) + + vocs = VOCS( + variables={"x0": [-3.0, 3.0], "x1": [-2.0, 2.0]}, + objectives={"f": "MINIMIZE"}, + ) + generator = LatinHypercubeSample(vocs, random_seed=1) + + sampling.gen_specs = GenSpecs( + generator=generator, + persis_in=["x0", "x1", "f", "sim_id"], + outputs=[("x0", float), ("x1", float)], + initial_batch_size=100, + batch_size=100, + ) + + sampling.exit_criteria = ExitCriteria(sim_max=200) + + sampling.run() + if sampling.is_manager: + assert len(sampling.H) >= 200 + x0 = sampling.H["x0"] + x1 = sampling.H["x1"] + f = sampling.H["f"] + assert np.all(np.isclose(f, np.sqrt(x0 ** 2 + x1 ** 2))) + print("\nlibEnsemble has calculated the 2D vector norm of all points") + sampling.save_output(__file__) From 6824bb38fd05a900d2c18f65c2c0490b8363cffd Mon Sep 17 00:00:00 2001 From: shudson Date: Thu, 14 May 2026 13:35:59 -0500 Subject: [PATCH 3/3] Update initial_sample_method to work with sampler instance with options --- libensemble/specs.py | 18 +++- .../test_xopt_EI_initial_sample_instance.py | 92 +++++++++++++++++++ libensemble/utils/runners.py | 36 ++++++-- 3 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py diff --git a/libensemble/specs.py b/libensemble/specs.py index 2f33d447f..9ee04baa3 100644 --- a/libensemble/specs.py +++ b/libensemble/specs.py @@ -242,14 +242,22 @@ class GenSpecs(BaseModel): completed evaluations most recently told to the generator. """ - initial_sample_method: str | None = None + initial_sample_method: str | object | None = None """ Method for producing initial sample points before starting the generator. If None (default), the generator is responsible for producing its own initial - sample via ``suggest()``. Set to ``"uniform"`` to have libEnsemble generate - uniform random samples from VOCS bounds, evaluate them, and ingest the results - into the generator before optimization begins. The number of sample points is - determined by ``initial_batch_size``. + sample via ``suggest()``. May be set to either: + + - a string naming a built-in sampler — currently ``"uniform"`` or + ``"latin_hypercube"`` — which libEnsemble instantiates with the VOCS, or + - a pre-constructed sampler instance (any object with a ``suggest()`` method, + typically a ``LibensembleGenerator`` subclass from ``gen_classes.sampling``). + Use this form when you need to pass extra constructor arguments + (``random_seed``, ``max_resource_sets``, ``components``, etc.) or want to + use a custom sampler. + + libEnsemble draws ``initial_batch_size`` points from the sampler, evaluates + them, and ingests the results into the generator before optimization begins. """ threaded: bool | None = False diff --git a/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py b/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py new file mode 100644 index 000000000..c7db6d363 --- /dev/null +++ b/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py @@ -0,0 +1,92 @@ +""" +Tests libEnsemble with Xopt ExpectedImprovementGenerator using a +pre-constructed sampler instance for ``initial_sample_method``. + +Companion to ``test_xopt_EI_initial_sample.py``, which uses the string form +(``initial_sample_method="uniform"``). This test instead passes a pre-configured +``LatinHypercubeSample`` instance — exercising the path that lets the user +supply constructor kwargs (here, ``random_seed``) and choose any sampler from +``gen_classes.sampling`` (or a custom one) without going through the string +registry in ``runners.py``. + +Execute via one of the following commands (e.g. 4 workers): + mpiexec -np 5 python test_xopt_EI_initial_sample_instance.py + python test_xopt_EI_initial_sample_instance.py -n 4 +""" + +# Do not change these lines - they are parsed by run-tests.sh +# TESTSUITE_COMMS: local +# TESTSUITE_NPROCS: 4 +# TESTSUITE_EXTRA: true +# TESTSUITE_EXCLUDE: true + +import numpy as np +from gest_api.vocs import VOCS +from xopt.generators.bayesian.expected_improvement import ExpectedImprovementGenerator + +from libensemble import Ensemble +from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f +from libensemble.gen_classes.sampling import LatinHypercubeSample +from libensemble.specs import AllocSpecs, ExitCriteria, GenSpecs, LibeSpecs, SimSpecs + + +def xtest_sim(H, persis_info, sim_specs, _): + """y1 = x2, c1 = x1""" + batch = len(H) + H_o = np.zeros(batch, dtype=sim_specs["out"]) + for i in range(batch): + H_o["y1"][i] = H["x2"][i] + H_o["c1"][i] = H["x1"][i] + return H_o, persis_info + + +if __name__ == "__main__": + + batch_size = 4 + + libE_specs = LibeSpecs(gen_on_manager=True, nworkers=batch_size) + libE_specs.reuse_output_dir = True + + vocs = VOCS( + variables={"x1": [0, 1.0], "x2": [0, 10.0]}, + objectives={"y1": "MINIMIZE"}, + constraints={"c1": ["GREATER_THAN", 0.5]}, + constants={"constant1": 1.0}, + ) + + gen = ExpectedImprovementGenerator(vocs=vocs) + + # Pre-constructed sampler with a custom random_seed — not reachable via the + # string form, which always instantiates with sampler defaults. + initial_sampler = LatinHypercubeSample(vocs=vocs, random_seed=42) + + gen_specs = GenSpecs( + generator=gen, + initial_batch_size=batch_size, + initial_sample_method=initial_sampler, + batch_size=batch_size, + vocs=vocs, + ) + + sim_specs = SimSpecs( + sim_f=xtest_sim, + vocs=vocs, + ) + + alloc_specs = AllocSpecs(alloc_f=alloc_f) + exit_criteria = ExitCriteria(sim_max=20) + + workflow = Ensemble( + libE_specs=libE_specs, + sim_specs=sim_specs, + alloc_specs=alloc_specs, + gen_specs=gen_specs, + exit_criteria=exit_criteria, + ) + + H, _, _ = workflow.run() + + if workflow.is_manager: + print(f"Completed {len(H)} simulations") + assert len(H) >= 8, f"Expected at least 8 sims, got {len(H)}" + print("Test passed") diff --git a/libensemble/utils/runners.py b/libensemble/utils/runners.py index da554fad9..b6ac823e1 100644 --- a/libensemble/utils/runners.py +++ b/libensemble/utils/runners.py @@ -159,16 +159,32 @@ def _start_generator_loop(self, tag, Work, H_in): return self._loop_over_gen(tag, Work, H_in) def _create_initial_sample(self, sample_method, num_points): - """Create initial sample points using the specified sampling method.""" - from libensemble.gen_classes.sampling import UniformSample - - vocs = self.specs.get("vocs") - samplers = { - "uniform": UniformSample, - } - if sample_method not in samplers: - raise ValueError(f"Unknown initial_sample_method: {sample_method!r}. Supported: {list(samplers.keys())}") - sampler = samplers[sample_method](vocs=vocs) + """Create initial sample points using the specified sampling method. + + ``sample_method`` may be either a string naming a built-in sampler + (instantiated here with the VOCS), or a pre-constructed sampler + instance with a ``suggest()`` method (used directly). + """ + from libensemble.gen_classes.sampling import LatinHypercubeSample, UniformSample + + if isinstance(sample_method, str): + samplers = { + "uniform": UniformSample, + "latin_hypercube": LatinHypercubeSample, + } + if sample_method not in samplers: + raise ValueError( + f"Unknown initial_sample_method: {sample_method!r}. " + f"Supported: {list(samplers.keys())}" + ) + sampler = samplers[sample_method](vocs=self.specs.get("vocs")) + else: + sampler = sample_method + if not hasattr(sampler, "suggest"): + raise TypeError( + "initial_sample_method must be a string name or an object " + f"with a suggest() method; got {type(sampler).__name__}" + ) return sampler.suggest(num_points) def _persistent_result(self, calc_in, persis_info, libE_info):