File indexing completed on 2026-08-12 08:24:56
0001 """PyMOO-based evolutionary optimizer for AID2E framework.
0002
0003 AID2E philosophy — separation of concerns
0004 ------------------------------------------
0005 An AID2E optimizer is a *candidate generator* and a *result ledger*. It has
0006 no knowledge of how evaluations are performed — that is the job of schedulers,
0007 workflow engines, and simulation back-ends. The optimizer only:
0008
0009 1. Produces design-point candidates (``suggest_candidates``).
0010 2. Records objective values once evaluations are complete (``update_with_results``).
0011
0012 This module wires the PyMOO evolutionary library into that interface via the
0013 **ask/tell protocol**:
0014
0015 - ``suggest_candidates()`` → ``algorithm.ask()`` — returns the current
0016 generation as parameter dicts.
0017 - ``update_with_results()`` → buffers one result; flushes
0018 ``algorithm.tell()`` automatically when the full generation has results.
0019
0020 The ``_flush_generation`` mechanism is entirely internal. Callers never need
0021 to think in terms of "generations" — they simply call the same two methods
0022 repeatedly regardless of the backend.
0023
0024 PyMOOProblem
0025 ------------
0026 The public ``PyMOOProblem`` class represents the search space as a proper
0027 PyMOO ``Problem``. It is structural only (variables, bounds, objectives)
0028 and intentionally does not evaluate candidates. Evaluations are always
0029 handled externally by workflow/scheduler components and reported back via
0030 ``update_with_results``.
0031
0032 Backend switching
0033 -----------------
0034 Use ``seed_from_trials(prior_optimizer.get_trials())`` to inject completed
0035 results from a different backend (e.g. a random-initialisation phase) before
0036 starting the evolutionary search.
0037
0038 Project: AID2E v0.0.0 — AI assisted Detector Design for EIC
0039 Homepage: https://aid2e.github.io/AID2E-framework
0040 Repository: https://github.com/aid2e/AID2E-framework.git
0041 """
0042
0043 from __future__ import annotations
0044
0045 import logging
0046 import warnings
0047 from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
0048
0049 import numpy as np
0050
0051 logger = logging.getLogger(__name__)
0052
0053
0054
0055
0056 try:
0057 from pymoo.core.problem import Problem
0058 from pymoo.core.termination import NoTermination
0059 from pymoo.operators.crossover.sbx import SBX
0060 from pymoo.operators.mutation.pm import PM
0061 from pymoo.operators.sampling.rnd import FloatRandomSampling
0062
0063 PYMOO_AVAILABLE = True
0064 except ImportError as _pymoo_err:
0065 PYMOO_AVAILABLE = False
0066 if TYPE_CHECKING:
0067 from pymoo.core.problem import Problem
0068 else:
0069 Problem = object
0070 logger.warning(
0071 "PyMOO not available: %s. Install with: pip install pymoo",
0072 _pymoo_err,
0073 )
0074
0075 from aid2e.optimizers.base import BaseOptimizer, SearchSpace, Trial
0076 from aid2e.utilities.configurations.base_models import (
0077 ChoiceParameter as DesignChoiceParameter,
0078 RangeParameter as DesignRangeParameter,
0079 )
0080 from aid2e.utilities.configurations.design_config import DesignConfig
0081 from .config import PyMOOOptimizerConfig
0082
0083
0084
0085
0086
0087
0088 class PyMOOProblem(Problem):
0089 """PyMOO Problem that wraps an AID2E ``SearchSpace``.
0090
0091 This problem is structural-only for ask/tell workflows. ``decode_x``
0092 translates PyMOO float vectors into
0093 human-readable AID2E parameter dicts — the same representation returned
0094 by ``suggest_candidates``.
0095
0096 Args:
0097 n_var: Number of continuous decision variables.
0098 n_obj: Number of objectives.
0099 xl: Lower-bound array of shape ``(n_var,)``.
0100 xu: Upper-bound array of shape ``(n_var,)``.
0101 param_items: Ordered list of ``(name, BaseParameter)`` pairs used for
0102 encoding/decoding.
0103 objective_names: Ordered objective names.
0104 """
0105
0106 def __init__(
0107 self,
0108 n_var: int,
0109 n_obj: int,
0110 xl: np.ndarray,
0111 xu: np.ndarray,
0112 param_items: List[Tuple[str, Any]],
0113 objective_names: List[str],
0114 ) -> None:
0115 """Initialise the PyMOO problem with search-space metadata."""
0116 super().__init__(n_var=n_var, n_obj=n_obj, xl=xl, xu=xu)
0117 self._param_items = param_items
0118 self._objective_names = objective_names
0119
0120 def decode_x(self, x_row: np.ndarray) -> Dict[str, Any]:
0121 """Translate a PyMOO float vector into an AID2E parameter dictionary.
0122
0123 Args:
0124 x_row: 1-D float array of length ``n_var``.
0125
0126 Returns:
0127 Mapping of parameter names to decoded values (``float`` for
0128 ``RangeParameter``, choice value for ``ChoiceParameter``).
0129 """
0130 params: Dict[str, Any] = {}
0131 for i, (name, param) in enumerate(self._param_items):
0132 val = float(x_row[i])
0133 if isinstance(param, DesignRangeParameter):
0134 params[name] = val
0135 elif isinstance(param, DesignChoiceParameter):
0136 idx = int(round(val))
0137 idx = max(0, min(idx, len(param.choices) - 1))
0138 params[name] = param.choices[idx]
0139 return params
0140
0141 def _evaluate(
0142 self, x: np.ndarray, out: dict, *args: Any, **kwargs: Any
0143 ) -> None:
0144 """Raise because AID2E always uses external evaluation.
0145
0146 Args:
0147 x: Population matrix (unused).
0148 out: Output dictionary (unused).
0149
0150 Raises:
0151 NotImplementedError: Always, because evaluation belongs to the
0152 workflow/scheduler layer in AID2E.
0153 """
0154 raise NotImplementedError(
0155 "PyMOOProblem is structural-only in ask/tell mode. "
0156 "Use PyMOOOptimizer.suggest_candidates() and "
0157 "PyMOOOptimizer.update_with_results() with external evaluation."
0158 )
0159
0160
0161 class AID2EProblem(PyMOOProblem):
0162 """Deprecated compatibility alias for ``PyMOOProblem``."""
0163
0164 def __init__(self, *args: Any, **kwargs: Any) -> None:
0165 """Warn and delegate to ``PyMOOProblem``."""
0166 warnings.warn(
0167 "AID2EProblem is deprecated and will be removed in the next "
0168 "iteration. Use PyMOOProblem instead.",
0169 DeprecationWarning,
0170 stacklevel=2,
0171 )
0172
0173 super().__init__(*args, **kwargs)
0174
0175
0176
0177
0178
0179
0180 class PyMOOOptimizer(BaseOptimizer):
0181 """Evolutionary optimizer backed by PyMOO algorithms.
0182
0183 Implements the ``BaseOptimizer`` interface using PyMOO's ask/tell protocol
0184 so that evaluations can be performed externally (e.g. via schedulers or
0185 simulation jobs) without blocking PyMOO's internal loop.
0186
0187 The generation lifecycle is::
0188
0189 candidates = optimizer.suggest_candidates() # calls algorithm.ask()
0190 for i, c in enumerate(candidates):
0191 metrics = my_evaluate(c)
0192 optimizer.update_with_results(i, c, metrics)
0193 # After the last call above, algorithm.tell() is invoked automatically.
0194 # The next suggest_candidates() call produces the next generation.
0195
0196 Supported algorithms:
0197 - ``"ga"`` — Genetic Algorithm for single-objective optimisation.
0198 - ``"nsga2"`` — NSGA-II, recommended for 2–3 objectives.
0199 - ``"nsga3"`` — NSGA-III, recommended for 3+ objectives.
0200 - ``"moead"`` — MOEA/D, weight-decomposition approach.
0201
0202 Attributes:
0203 config: ``PyMOOOptimizerConfig`` instance controlling algorithm behaviour.
0204 n_gen_completed: Number of generations that have been fully evaluated.
0205
0206 Examples:
0207 >>> from aid2e.optimizers.pymoo import PyMOOOptimizer, PyMOOOptimizerConfig
0208 >>> from aid2e.optimizers.base import SearchSpace
0209 >>> space = SearchSpace(
0210 ... parameters={
0211 ... "x": {"type": "range", "bounds": [0.0, 1.0]},
0212 ... "y": {"type": "range", "bounds": [0.0, 1.0]},
0213 ... }
0214 ... )
0215 >>> config = PyMOOOptimizerConfig(pop_size=20, seed=0)
0216 >>> opt = PyMOOOptimizer(space, config, objective_names=["loss"])
0217 >>> candidates = opt.suggest_candidates()
0218 >>> for trial_idx, c in enumerate(candidates):
0219 ... opt.update_with_results(trial_idx, c, {"loss": c["x"] + c["y"]})
0220 >>> best = opt.get_best_trial()
0221
0222 Notes:
0223 - All objectives are treated as *minimisation* targets. To maximise,
0224 negate values before passing them to ``update_with_results``.
0225 - Linear parameter constraints are not yet forwarded to PyMOO. A
0226 warning is emitted when the search space contains constraints.
0227 - ``n_candidates`` passed to ``suggest_candidates`` is informational
0228 only; the actual batch size is determined by the algorithm
0229 (``pop_size`` for the first generation, ``n_offsprings`` thereafter).
0230
0231 Project: AID2E v0.0.0 — AI assisted Detector Design for EIC
0232 Homepage: https://aid2e.github.io/AID2E-framework
0233 Repository: https://github.com/aid2e/AID2E-framework.git
0234 """
0235
0236 def __init__(
0237 self,
0238 search_space: Union[SearchSpace, DesignConfig],
0239 config: PyMOOOptimizerConfig,
0240 objective_names: List[str],
0241 seed: Optional[int] = None,
0242 ) -> None:
0243 """Initialise the PyMOO optimizer.
0244
0245 Args:
0246 search_space: Parameter search space or a ``DesignConfig`` instance.
0247 ``DesignConfig`` is automatically converted to ``SearchSpace``.
0248 config: ``PyMOOOptimizerConfig`` controlling algorithm selection and
0249 operator hyper-parameters.
0250 objective_names: Ordered list of objective metric names. These must
0251 match keys in the ``metrics`` dict passed to
0252 ``update_with_results``.
0253 seed: Integer seed overriding ``config.seed`` when provided.
0254
0255 Raises:
0256 ImportError: If PyMOO is not installed.
0257 ValueError: If the search space is empty or ``objective_names`` is
0258 empty.
0259
0260 Notes:
0261 The algorithm is initialised lazily; the actual PyMOO ``Problem``
0262 and ``Algorithm`` objects are created here but no evaluations occur
0263 until ``suggest_candidates`` is called.
0264 """
0265 if not PYMOO_AVAILABLE:
0266 raise ImportError(
0267 "PyMOO is required but not installed. "
0268 "Install with: pip install pymoo"
0269 )
0270
0271 effective_seed = seed if seed is not None else config.seed
0272
0273 super().__init__(
0274 search_space=search_space,
0275 objective_names=objective_names,
0276 seed=effective_seed,
0277 )
0278
0279 self.config = config
0280 self.resolved_algorithm = self.config.resolve_algorithm(self.n_objectives)
0281
0282 if self.search_space.constraints:
0283 logger.warning(
0284 "PyMOOOptimizer: %d parameter constraint(s) detected in the search "
0285 "space but are not yet forwarded to PyMOO. Constraint satisfaction "
0286 "is not guaranteed during candidate generation.",
0287 len(self.search_space.constraints),
0288 )
0289
0290
0291 self._param_items: List[Tuple[str, Any]] = list(
0292 self.search_space.parameters.items()
0293 )
0294
0295
0296 self._xl, self._xu = self._build_bounds()
0297 n_var = len(self._param_items)
0298
0299
0300 self.problem: PyMOOProblem = PyMOOProblem(
0301 n_var=n_var,
0302 n_obj=self.n_objectives,
0303 xl=self._xl,
0304 xu=self._xu,
0305 param_items=self._param_items,
0306 objective_names=self.objective_names,
0307 )
0308
0309
0310 self._algorithm = self._create_algorithm()
0311 self._algorithm.setup(
0312 self.problem,
0313 seed=self.seed,
0314 verbose=self.config.verbose,
0315 termination=NoTermination(),
0316 )
0317
0318
0319 self.n_gen_completed: int = 0
0320
0321
0322 self._generation_infills: Any = None
0323 self._gen_pos_to_trial_idx: Dict[int, int] = {}
0324 self._result_buffer: Dict[int, Dict[str, float]] = {}
0325
0326 logger.info(
0327 "PyMOOOptimizer initialised: algorithm=%s, pop_size=%d, "
0328 "n_params=%d, n_objectives=%d, seed=%s",
0329 self.resolved_algorithm,
0330 config.pop_size,
0331 n_var,
0332 self.n_objectives,
0333 self.seed,
0334 )
0335
0336
0337
0338
0339
0340 def _build_bounds(self) -> Tuple[np.ndarray, np.ndarray]:
0341 """Build lower/upper bound arrays from the search space parameters.
0342
0343 ``RangeParameter`` contributes its ``bounds``; ``ChoiceParameter``
0344 contributes ``[0, n_choices − 1]`` as a continuous range to be
0345 rounded during decoding.
0346
0347 Returns:
0348 Tuple of ``(xl, xu)`` each as a ``float64`` array of shape
0349 ``(n_var,)``.
0350 """
0351 xl, xu = [], []
0352 for _, param in self._param_items:
0353 if isinstance(param, DesignRangeParameter):
0354 xl.append(float(param.bounds[0]))
0355 xu.append(float(param.bounds[1]))
0356 elif isinstance(param, DesignChoiceParameter):
0357 xl.append(0.0)
0358 xu.append(float(len(param.choices) - 1))
0359 else:
0360 raise ValueError(
0361 f"Unsupported parameter type for PyMOO: "
0362 f"{param.__class__.__name__}"
0363 )
0364 return np.array(xl, dtype=float), np.array(xu, dtype=float)
0365
0366 def _decode_x(self, x_row: np.ndarray) -> Dict[str, Any]:
0367 """Delegate to ``self.problem.decode_x`` for internal use.
0368
0369 Args:
0370 x_row: 1-D float array of length ``n_var``.
0371
0372 Returns:
0373 Parameter dictionary for the given individual.
0374 """
0375 return self.problem.decode_x(x_row)
0376
0377 def _encode_params(self, params: Dict[str, Any]) -> np.ndarray:
0378 """Encode a parameter dictionary back to a PyMOO-compatible float vector.
0379
0380 Used by ``load_state`` when re-seeding completed trials into PyMOO's
0381 memory structures.
0382
0383 Args:
0384 params: Mapping of parameter names to values.
0385
0386 Returns:
0387 1-D float array of length ``n_var``.
0388 """
0389 x_row = np.zeros(len(self._param_items), dtype=float)
0390 for i, (name, param) in enumerate(self._param_items):
0391 val = params.get(name)
0392 if isinstance(param, DesignRangeParameter):
0393 x_row[i] = float(val)
0394 elif isinstance(param, DesignChoiceParameter):
0395 if val in param.choices:
0396 x_row[i] = float(param.choices.index(val))
0397 else:
0398 x_row[i] = 0.0
0399 return x_row
0400
0401 def _create_algorithm(self) -> Any:
0402 """Instantiate the PyMOO algorithm from the current config.
0403
0404 Returns:
0405 An uninitialised PyMOO ``Algorithm`` instance.
0406
0407 Raises:
0408 ValueError: If the resolved algorithm is not a supported identifier.
0409
0410 Notes:
0411 Algorithm objects are created *before* ``setup()`` is called so
0412 that ``__init__`` can validate the config without triggering any
0413 sampling.
0414 """
0415 alg = self.resolved_algorithm.lower()
0416 n_offsprings = self.config.n_offsprings
0417
0418 crossover = SBX(
0419 prob=self.config.crossover_prob,
0420 eta=self.config.crossover_eta,
0421 )
0422 mutation = PM(eta=self.config.mutation_eta)
0423 sampling = FloatRandomSampling()
0424
0425 if alg == "ga":
0426 from pymoo.algorithms.soo.nonconvex.ga import GA
0427
0428 return GA(
0429 pop_size=self.config.pop_size,
0430 n_offsprings=n_offsprings,
0431 crossover=crossover,
0432 mutation=mutation,
0433 sampling=sampling,
0434 )
0435
0436 if alg == "nsga2":
0437 from pymoo.algorithms.moo.nsga2 import NSGA2
0438
0439 return NSGA2(
0440 pop_size=self.config.pop_size,
0441 n_offsprings=n_offsprings,
0442 crossover=crossover,
0443 mutation=mutation,
0444 sampling=sampling,
0445 )
0446
0447 if alg == "nsga3":
0448 from pymoo.algorithms.moo.nsga3 import NSGA3
0449 from pymoo.class='include' href="/lxr/source/include/gsl/util/">util.ref_dirs import get_reference_directions
0450
0451 ref_dirs = get_reference_directions(
0452 "das-dennis",
0453 n_dim=self.n_objectives,
0454 n_partitions=self.config.n_partitions,
0455 )
0456 return NSGA3(
0457 ref_dirs=ref_dirs,
0458 pop_size=self.config.pop_size,
0459 n_offsprings=n_offsprings,
0460 crossover=crossover,
0461 mutation=mutation,
0462 sampling=sampling,
0463 )
0464
0465 if alg == "moead":
0466 from pymoo.algorithms.moo.moead import MOEAD
0467 from pymoo.class='include' href="/lxr/source/include/gsl/util/">util.ref_dirs import get_reference_directions
0468
0469 ref_dirs = get_reference_directions(
0470 "uniform",
0471 n_dim=self.n_objectives,
0472 n_points=self.config.pop_size,
0473 )
0474 return MOEAD(
0475 ref_dirs=ref_dirs,
0476 n_neighbors=15,
0477 crossover=crossover,
0478 mutation=mutation,
0479 sampling=sampling,
0480 )
0481
0482 raise ValueError(
0483 f"Unknown PyMOO algorithm '{self.resolved_algorithm}'. "
0484 "Supported: 'ga', 'nsga2', 'nsga3', 'moead'."
0485 )
0486
0487 def _flush_generation(self) -> None:
0488 """Advance the algorithm by one generation using buffered results.
0489
0490 Called automatically by ``update_with_results`` when every candidate
0491 produced by the most recent ``suggest_candidates`` call has a result.
0492 Builds the objective matrix ``F`` from the buffer and calls
0493 ``algorithm.tell()``.
0494
0495 Notes:
0496 This method clears ``_generation_infills``, ``_gen_pos_to_trial_idx``,
0497 and ``_result_buffer`` after flushing.
0498 """
0499 n_gen = len(self._gen_pos_to_trial_idx)
0500 F = np.zeros((n_gen, self.n_objectives), dtype=float)
0501
0502 for pos, trial_idx in self._gen_pos_to_trial_idx.items():
0503 metrics = self._result_buffer[trial_idx]
0504 for j, obj in enumerate(self.objective_names):
0505 F[pos, j] = metrics[obj]
0506
0507 self._generation_infills.set("F", F)
0508 self._algorithm.tell(infills=self._generation_infills)
0509 self.n_gen_completed += 1
0510
0511 logger.debug(
0512 "Generation %d completed: %d individuals evaluated.",
0513 self.n_gen_completed,
0514 n_gen,
0515 )
0516
0517
0518 self._generation_infills = None
0519 self._gen_pos_to_trial_idx = {}
0520 self._result_buffer = {}
0521
0522
0523
0524
0525
0526 def seed_from_trials(
0527 self,
0528 trials: List[Trial],
0529 *,
0530 only_completed: bool = True,
0531 ) -> int:
0532 """Inject completed trials from an external source into the history.
0533
0534 Extends the base implementation with a guard that prevents seeding
0535 while a generation is in-flight (i.e., ``suggest_candidates`` has
0536 been called but not all ``update_with_results`` calls have come back).
0537
0538 The injected trials are recorded in the optimizer's history and
0539 visible via ``get_trials()`` and ``get_pareto_front()``. They do
0540 *not* advance PyMOO's internal population — the next
0541 ``suggest_candidates`` call still produces the next generation.
0542
0543 Args:
0544 trials: Trials to inject, typically from a previous backend
0545 (e.g. random-initialisation results).
0546 only_completed: When ``True`` (default), non-completed trials are
0547 silently skipped.
0548
0549 Returns:
0550 Number of trials actually injected.
0551
0552 Raises:
0553 RuntimeError: If a generation is currently in-flight.
0554
0555 Examples:
0556 >>> n = pymoo_opt.seed_from_trials(random_opt.get_trials())
0557 >>> print(f"Seeded {n} prior evaluations")
0558 >>> candidates = pymoo_opt.suggest_candidates() # gen 1 starts
0559 """
0560 if self._generation_infills is not None:
0561 raise RuntimeError(
0562 "Cannot seed trials while a generation is in-flight. "
0563 "Call update_with_results() for all pending candidates first."
0564 )
0565 return super().seed_from_trials(trials, only_completed=only_completed)
0566
0567 def suggest_candidates(self, n_candidates: int = 1) -> List[Dict[str, Any]]:
0568 """Suggest the next batch of candidates to evaluate.
0569
0570 Calls ``algorithm.ask()`` to retrieve the current generation population
0571 from PyMOO and returns them as a list of parameter dicts. The actual
0572 number of candidates is determined by the algorithm (``pop_size`` for
0573 the initial generation, ``n_offsprings`` thereafter), not by
0574 ``n_candidates``.
0575
0576 Args:
0577 n_candidates: Advisory hint only. A ``DEBUG``-level message is
0578 emitted when the hint differs from the actual batch size.
0579
0580 Returns:
0581 List of parameter dicts, one per individual in the current
0582 generation. Trial indices for these candidates begin at the
0583 current ``_trial_counter``.
0584
0585 Raises:
0586 RuntimeError: If a previous generation has not yet been fully
0587 evaluated (i.e. some ``update_with_results`` calls are
0588 outstanding).
0589
0590 Examples:
0591 >>> candidates = optimizer.suggest_candidates()
0592 >>> len(candidates)
0593 100 # pop_size
0594 """
0595
0596 if self._generation_infills is not None:
0597 n_pending = len(self._gen_pos_to_trial_idx) - len(self._result_buffer)
0598 raise RuntimeError(
0599 f"Cannot suggest new candidates: {n_pending} evaluation(s) from "
0600 "the current generation are still outstanding. Call "
0601 "update_with_results() for all pending candidates first."
0602 )
0603
0604 infills = self._algorithm.ask()
0605 X = infills.get("X")
0606 batch_size = len(X)
0607
0608 if n_candidates != 1 and n_candidates != batch_size:
0609 logger.debug(
0610 "n_candidates=%d ignored — PyMOO algorithm produces %d candidates "
0611 "(pop_size=%d). Use optimizer.suggest_candidates() without a hint "
0612 "to suppress this message.",
0613 n_candidates,
0614 batch_size,
0615 self.config.pop_size,
0616 )
0617
0618 self._generation_infills = infills
0619 self._gen_pos_to_trial_idx = {}
0620 self._result_buffer = {}
0621
0622 candidates: List[Dict[str, Any]] = []
0623 for pos, x_row in enumerate(X):
0624 trial_idx = self._trial_counter
0625 self._trial_counter += 1
0626 self._gen_pos_to_trial_idx[pos] = trial_idx
0627
0628 params = self._decode_x(x_row)
0629
0630
0631 trial = Trial(index=trial_idx, parameters=params, status="pending")
0632 while len(self._trials) <= trial_idx:
0633 self._trials.append(None)
0634 self._trials[trial_idx] = trial
0635
0636 candidates.append(params)
0637
0638 logger.debug(
0639 "Generation %d: suggested %d candidates (trial indices %d–%d).",
0640 self.n_gen_completed + 1,
0641 batch_size,
0642 self._trial_counter - batch_size,
0643 self._trial_counter - 1,
0644 )
0645 return candidates
0646
0647 def update_with_results(
0648 self,
0649 trial_index: int,
0650 parameters: Dict[str, Any],
0651 metrics: Dict[str, float],
0652 ) -> None:
0653 """Record evaluation results for one candidate and advance the algorithm.
0654
0655 When the last outstanding candidate of the current generation is
0656 updated, the generation buffer is flushed automatically via
0657 ``algorithm.tell()``.
0658
0659 Args:
0660 trial_index: Index as returned in ``_trial_counter`` during
0661 ``suggest_candidates``. Must correspond to a pending trial.
0662 parameters: Parameter values that were evaluated (used for
0663 bookkeeping; the underlying search point is already tracked).
0664 metrics: Objective values keyed by objective name. All names
0665 listed in ``objective_names`` must be present.
0666
0667 Raises:
0668 ValueError: If any required objective is missing from ``metrics``.
0669
0670 Examples:
0671 >>> optimizer.update_with_results(
0672 ... trial_index=0,
0673 ... parameters={"x": 0.5, "y": 0.3},
0674 ... metrics={"f1": 0.1, "f2": 0.9},
0675 ... )
0676 """
0677 missing = [o for o in self.objective_names if o not in metrics]
0678 if missing:
0679 raise ValueError(
0680 f"update_with_results: missing objectives {missing}. "
0681 f"Expected {self.objective_names}, got {list(metrics.keys())}."
0682 )
0683
0684
0685 self._result_buffer[trial_index] = {k: float(v) for k, v in metrics.items()}
0686
0687
0688 trial = Trial(
0689 index=trial_index,
0690 parameters=parameters,
0691 metrics={k: float(v) for k, v in metrics.items()},
0692 status="completed",
0693 )
0694 while len(self._trials) <= trial_index:
0695 self._trials.append(None)
0696 self._trials[trial_index] = trial
0697
0698
0699 if len(self._result_buffer) == len(self._gen_pos_to_trial_idx):
0700 self._flush_generation()
0701
0702 def serialize_state(self) -> Dict[str, Any]:
0703 """Serialise optimizer state to a JSON-compatible dictionary.
0704
0705 The serialised state includes the config, search space description,
0706 objective names, and all recorded trials. The PyMOO algorithm's
0707 internal population state is serialised via ``pickle`` and encoded as
0708 a base-64 string to allow full resumption without re-running
0709 evaluations.
0710
0711 Returns:
0712 JSON-serialisable dictionary containing all state needed to
0713 rebuild this optimizer via ``load_state``.
0714
0715 Examples:
0716 >>> import json
0717 >>> state = optimizer.serialize_state()
0718 >>> with open("checkpoint.json", "w") as f:
0719 ... json.dump(state, f)
0720
0721 Notes:
0722 If the algorithm cannot be pickled (rare), the ``"algorithm_pickle"``
0723 key is omitted and a WARNING is logged. ``load_state`` will then
0724 recreate the algorithm from the config + seed instead.
0725 """
0726 import base64
0727 import pickle
0728
0729 space_payload = {
0730 name: param.model_dump()
0731 for name, param in self.search_space.parameters.items()
0732 }
0733 constraints_payload = [
0734 c.model_dump() for c in self.search_space.constraints
0735 ]
0736
0737 algorithm_pickle: Optional[str] = None
0738 try:
0739 algorithm_pickle = base64.b64encode(
0740 pickle.dumps(self._algorithm)
0741 ).decode("ascii")
0742 except Exception as exc:
0743 logger.warning(
0744 "Could not pickle PyMOO algorithm state: %s. "
0745 "load_state will restart the algorithm from scratch.",
0746 exc,
0747 )
0748
0749 return {
0750 "backend": "pymoo",
0751 "search_space": {
0752 "parameters": space_payload,
0753 "constraints": constraints_payload,
0754 "name": self.search_space.name,
0755 },
0756 "objective_names": self.objective_names,
0757 "seed": self.seed,
0758 "config": self.config.model_dump(),
0759 "resolved_algorithm": self.resolved_algorithm,
0760 "trials": [
0761 {
0762 "index": t.index,
0763 "parameters": t.parameters,
0764 "metrics": t.metrics,
0765 "status": t.status,
0766 "metadata": t.metadata,
0767 }
0768 for t in self._trials
0769 if t is not None
0770 ],
0771 "trial_counter": self._trial_counter,
0772 "n_gen_completed": self.n_gen_completed,
0773 "algorithm_pickle": algorithm_pickle,
0774 }
0775
0776 def load_state(self, state: Dict[str, Any]) -> None:
0777 """Restore optimizer state from a serialised dictionary.
0778
0779 The search space, config, objective names, completed trials, and
0780 (when available) the pickled algorithm state are all restored. If
0781 ``algorithm_pickle`` is absent or cannot be deserialised, the algorithm
0782 is rebuilt from the config and seed, which means the internal
0783 population will be reset.
0784
0785 Args:
0786 state: Dictionary as returned by ``serialize_state``.
0787
0788 Raises:
0789 ValueError: If required keys are missing from ``state``.
0790 ImportError: If PyMOO is not installed.
0791
0792 Notes:
0793 After a successful load, ``suggest_candidates`` resumes from where
0794 optimisation left off (provided the algorithm pickle was valid).
0795 Any in-flight generation (partial results) is discarded on load.
0796 """
0797 if not PYMOO_AVAILABLE:
0798 raise ImportError(
0799 "PyMOO is required but not installed. "
0800 "Install with: pip install pymoo"
0801 )
0802
0803 required = {"search_space", "objective_names", "config", "trials"}
0804 missing = required - state.keys()
0805 if missing:
0806 raise ValueError(f"load_state: missing keys in state: {missing}")
0807
0808
0809 self.config = PyMOOOptimizerConfig(**state["config"])
0810 self.objective_names = list(state["objective_names"])
0811 self.seed = state.get("seed", self.config.seed)
0812 resolved_algorithm = self.config.resolve_algorithm(self.n_objectives)
0813 stored_algorithm = state.get("resolved_algorithm")
0814 if stored_algorithm and stored_algorithm != resolved_algorithm:
0815 logger.warning(
0816 "Stored resolved_algorithm '%s' does not match current resolved "
0817 "algorithm '%s'; using current value.",
0818 stored_algorithm,
0819 resolved_algorithm,
0820 )
0821 self.resolved_algorithm = resolved_algorithm
0822
0823 saved_space = state["search_space"]
0824 self.search_space = SearchSpace(
0825 parameters=saved_space.get("parameters", {}),
0826 constraints=saved_space.get("constraints", []),
0827 name=saved_space.get("name"),
0828 )
0829
0830 self._param_items = list(self.search_space.parameters.items())
0831 self._xl, self._xu = self._build_bounds()
0832 self.problem = PyMOOProblem(
0833 n_var=len(self._param_items),
0834 n_obj=self.n_objectives,
0835 xl=self._xl,
0836 xu=self._xu,
0837 param_items=self._param_items,
0838 objective_names=self.objective_names,
0839 )
0840
0841
0842 import base64
0843 import pickle
0844
0845 algorithm_pickle = state.get("algorithm_pickle")
0846 if algorithm_pickle:
0847 try:
0848 self._algorithm = pickle.loads(
0849 base64.b64decode(algorithm_pickle.encode("ascii"))
0850 )
0851 logger.info("PyMOO algorithm state restored from pickle.")
0852 except Exception as exc:
0853 logger.warning(
0854 "Could not unpickle algorithm state (%s); "
0855 "recreating from config + seed.",
0856 exc,
0857 )
0858 self._algorithm = self._create_algorithm()
0859 self._algorithm.setup(
0860 self.problem,
0861 seed=self.seed,
0862 verbose=self.config.verbose,
0863 termination=NoTermination(),
0864 )
0865 else:
0866 self._algorithm = self._create_algorithm()
0867 self._algorithm.setup(
0868 self.problem,
0869 seed=self.seed,
0870 verbose=self.config.verbose,
0871 termination=NoTermination(),
0872 )
0873
0874
0875 self._trials = []
0876 for td in state["trials"]:
0877 trial = Trial(
0878 index=td["index"],
0879 parameters=td["parameters"],
0880 metrics=td.get("metrics"),
0881 status=td.get("status", "pending"),
0882 metadata=td.get("metadata", {}),
0883 )
0884 while len(self._trials) <= trial.index:
0885 self._trials.append(None)
0886 self._trials[trial.index] = trial
0887
0888 self._trial_counter = state.get("trial_counter", len(self._trials))
0889 self.n_gen_completed = state.get("n_gen_completed", 0)
0890
0891
0892 self._generation_infills = None
0893 self._gen_pos_to_trial_idx = {}
0894 self._result_buffer = {}
0895
0896 logger.info(
0897 "PyMOO optimizer state loaded: algorithm=%s, %d trials, %d generations completed.",
0898 self.resolved_algorithm,
0899 len(self._trials),
0900 self.n_gen_completed,
0901 )
0902
0903
0904
0905
0906
0907 def __repr__(self) -> str:
0908 """Return a concise string representation of the optimizer.
0909
0910 Returns:
0911 Human-readable description including algorithm, parameter count,
0912 objective count, and seed.
0913 """
0914 return (
0915 f"PyMOOOptimizer("
0916 f"algorithm={self.resolved_algorithm}, "
0917 f"pop_size={self.config.pop_size}, "
0918 f"n_params={len(self.search_space.parameters)}, "
0919 f"n_objectives={self.n_objectives}, "
0920 f"n_gen_completed={self.n_gen_completed}, "
0921 f"seed={self.seed})"
0922 )