File indexing completed on 2026-08-12 08:24:56
0001 """Ax-based Bayesian optimizer for AID2E framework."""
0002
0003 from copy import deepcopy
0004 from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
0005 import logging, sys
0006 import numpy as np
0007
0008 logger = logging.getLogger(__name__)
0009
0010
0011 try:
0012 import ax
0013 from ax.core.experiment import Experiment
0014 from ax.core.search_space import SearchSpace as AxSearchSpace
0015 from ax.core.parameter import ChoiceParameter as AxChoiceParameter
0016 from ax.core.parameter import ParameterType, RangeParameter as AxRangeParameter
0017 from ax.core.parameter_constraint import ParameterConstraint as AxParameterConstraint
0018 try:
0019 from ax.core.parameter_constraint import SumConstraint as AxSumConstraint
0020 except ImportError:
0021 AxSumConstraint = None
0022 from ax.core.objective import MultiObjective, Objective
0023 from ax.core.optimization_config import MultiObjectiveOptimizationConfig, OptimizationConfig
0024 from ax.core.metric import Metric
0025 from ax.core.outcome_constraint import ComparisonOp, ObjectiveThreshold
0026 from ax.generation_strategy.generation_strategy import GenerationStrategy
0027 try:
0028 from ax.generation_strategy.center_generation_node import CenterGenerationNode
0029 from ax.generation_strategy.transition_criterion import MinTrials
0030 from ax.generation_strategy.generation_node import GenerationNode
0031 from ax.generation_strategy.generator_spec import GeneratorSpec
0032
0033
0034
0035 AX_NODE_STRATEGY_AVAILABLE = True
0036 except ImportError:
0037 CenterGenerationNode = None
0038 MinTrials = None
0039 GenerationNode = None
0040 GeneratorSpec = None
0041 AX_NODE_STRATEGY_AVAILABLE = False
0042 from ax.adapter.registry import Generators
0043 AX_AVAILABLE = True
0044 except ImportError as e:
0045 AX_AVAILABLE = False
0046
0047 if TYPE_CHECKING:
0048 from ax.core.experiment import Experiment
0049 from ax.core.search_space import SearchSpace as AxSearchSpace
0050 from ax.core.optimization_config import OptimizationConfig
0051 from ax.generation_strategy.generation_strategy import GenerationStrategy
0052 from ax.core.parameter_constraint import (
0053 ParameterConstraint as AxParameterConstraint,
0054 SumConstraint as AxSumConstraint,
0055 )
0056 else:
0057 AxSearchSpace = None
0058 OptimizationConfig = None
0059 GenerationStrategy = None
0060 AxParameterConstraint = None
0061 AxSumConstraint = None
0062 CenterGenerationNode = None
0063 MinTrials = None
0064 GenerationNode = None
0065 GeneratorSpec = None
0066 AX_NODE_STRATEGY_AVAILABLE = False
0067 logger.warning(f"Ax not available: {e}. Install with: pip install ax-platform==1.0.0")
0068
0069 from aid2e.optimizers.base import (
0070 BaseOptimizer,
0071 SearchSpace,
0072 Trial,
0073 TRIAL_STATUS_SUGGESTED,
0074 )
0075 from aid2e.utilities.configurations.base_models import (
0076 ChoiceParameter as DesignChoiceParameter,
0077 RangeParameter as DesignRangeParameter,
0078 )
0079 from aid2e.utilities.configurations.design_config import DesignConfig
0080 from ._resolver import resolve_generator_kwargs
0081 from .config import AxOptimizerConfig
0082
0083
0084 class AxOptimizer(BaseOptimizer):
0085 """Ax-based Bayesian optimization for multi-objective optimization.
0086
0087 This optimizer uses the Ax platform for Bayesian optimization with
0088 support for multiple objectives and native Ax Modular BoTorch generation.
0089
0090 Attributes:
0091 config: AxOptimizerConfig instance with strategy settings.
0092 objective_names: List of objective metric names.
0093 experiment: Ax Experiment object managing trials.
0094 generation_strategy: Ax GenerationStrategy for candidate generation.
0095
0096 Examples:
0097 >>> from aid2e.optimizers.ax import AxOptimizer, AxOptimizerConfig
0098 >>> search_space = SearchSpace(
0099 ... parameters={
0100 ... "x": {"type": "range", "bounds": [0.0, 1.0]},
0101 ... "y": {"type": "range", "bounds": [0.0, 1.0]}
0102 ... }
0103 ... )
0104 >>> config = AxOptimizerConfig(
0105 ... initialization_strategy="sobol",
0106 ... generator="BOTORCH_MODULAR"
0107 ... )
0108 >>> optimizer = AxOptimizer(
0109 ... search_space=search_space,
0110 ... config=config,
0111 ... objective_names=["loss", "time"]
0112 ... )
0113
0114 Notes:
0115 This implementation defaults to a native Ax node-based generation
0116 strategy that transitions from an initializer node into
0117 ``Generators.BOTORCH_MODULAR``.
0118
0119 Project: AID2E v0.0.1 - AI assisted Detector Design for EIC
0120 Homepage: https://aid2e.github.io/AID2E-framework
0121 Repository: https://github.com/aid2e/AID2E-framework.git
0122 """
0123
0124 def __init__(
0125 self,
0126 search_space: Union[SearchSpace, DesignConfig],
0127 config: AxOptimizerConfig,
0128 objective_names: List[str],
0129 seed: Optional[int] = None,
0130 objective_directions: Optional[Dict[str, Any]] = None,
0131 ):
0132 """Initialize the Ax optimizer.
0133
0134 Args:
0135 search_space: Parameter search space definition.
0136 config: AxOptimizerConfig instance with strategy settings.
0137 objective_names: List of objective metric names to optimize.
0138 seed: Random seed for reproducibility (overrides config.seed if provided).
0139
0140 Raises:
0141 ImportError: If Ax is not installed.
0142 ValueError: If search_space is empty or config is invalid.
0143
0144 Notes:
0145 The optimizer is initialized but not yet ready to suggest candidates.
0146 Ax Experiment and GenerationStrategy are created lazily on first use.
0147 """
0148 if not AX_AVAILABLE:
0149 raise ImportError(
0150 "Ax is required but not installed. "
0151 "Install with: pip install ax-platform==1.0.0"
0152 )
0153 if not AX_NODE_STRATEGY_AVAILABLE:
0154 raise RuntimeError(
0155 "The installed Ax runtime does not support the node-based "
0156 "generation API required by AID2E. Upgrade Ax to a version "
0157 "that provides CenterGenerationNode, GenerationNode, "
0158 "GeneratorSpec, and MinTrials."
0159 )
0160
0161
0162 super().__init__(
0163 search_space=search_space,
0164 objective_names=objective_names,
0165 objective_directions=objective_directions,
0166 seed=seed if seed is not None else config.seed,
0167 )
0168
0169 self.config = config
0170
0171
0172
0173
0174
0175 self.ax_search_space = self._create_ax_search_space()
0176
0177
0178 self.optimization_config = self._create_optimization_config()
0179
0180
0181 self.experiment = Experiment(
0182 name=f"aid2e_optimization",
0183 search_space=self.ax_search_space,
0184 optimization_config=self.optimization_config
0185 )
0186
0187
0188 self.generation_strategy = self._create_generation_strategy()
0189
0190
0191
0192
0193 logger.info(
0194 f"AxOptimizer initialized: {len(self.search_space.parameters)} params, "
0195 f"{len(objective_names)} objectives, strategy={config.initialization_strategy}, "
0196 f"generator={config.generator}"
0197 )
0198
0199 def _parse_constraint_to_ax(
0200 self, constraint
0201 ) -> Optional[Any]:
0202 """Parse a ParameterConstraint rule to an Ax constraint object.
0203
0204 Args:
0205 constraint: The ParameterConstraint from design_config.
0206
0207 Returns:
0208 Ax constraint object (ParameterConstraint or SumConstraint), or None if parsing fails.
0209
0210 Notes:
0211 Ax ParameterConstraint supports linear constraints of the form:
0212 sum(w_i * param_i) <= bound
0213
0214 This method attempts to parse simple sum constraints like "x + y <= 1.5"
0215 into Ax's format. More complex expressions may not be supported.
0216 """
0217 import re
0218
0219 rule = constraint.rule
0220
0221
0222
0223
0224
0225
0226 pattern = r'^([^<>=]+)\s*([<>]=?)\s*([\d.]+)$'
0227 match = re.match(pattern, rule.strip())
0228
0229 if not match:
0230 logger.warning(
0231 f"Constraint '{constraint.name}' has unsupported format: {rule}. "
0232 "Only simple sum constraints are supported (e.g., 'x + y <= 1.5')."
0233 )
0234 return None
0235
0236 lhs, operator, bound_str = match.groups()
0237 bound = float(bound_str)
0238
0239
0240
0241
0242 param_pattern = r'([+-]?)\s*([a-zA-Z_][a-zA-Z0-9_.]*)'
0243 terms = re.findall(param_pattern, lhs)
0244
0245 if not terms:
0246 logger.warning(
0247 f"Constraint '{constraint.name}': Could not parse parameters from: {lhs}"
0248 )
0249 return None
0250
0251
0252 constraint_dict = {}
0253 for sign, param_name in terms:
0254 coeff = 1.0 if sign != '-' else -1.0
0255 constraint_dict[param_name.strip()] = coeff
0256
0257 strict_epsilon = sys.float_info.epsilon
0258
0259
0260
0261
0262 if operator in ['<=', '<']:
0263 is_upper_bound = True
0264 if operator == '<':
0265 bound -= strict_epsilon
0266 elif operator in ['>=', '>']:
0267
0268 is_upper_bound = True
0269 constraint_dict = {k: -v for k, v in constraint_dict.items()}
0270 bound = -bound
0271 if operator == '>':
0272 bound -= strict_epsilon
0273 else:
0274 logger.warning(f"Unsupported operator in constraint: {operator}")
0275 return None
0276
0277
0278 coeffs = list(constraint_dict.values())
0279 if all(c == coeffs[0] for c in coeffs) and coeffs[0] == 1.0:
0280
0281
0282
0283 pass
0284
0285 try:
0286 terms_rendered = []
0287 for param_name, coeff in constraint_dict.items():
0288 if coeff == 1.0:
0289 terms_rendered.append(param_name)
0290 elif coeff == -1.0:
0291 terms_rendered.append(f"-{param_name}")
0292 else:
0293 terms_rendered.append(f"{coeff}*{param_name}")
0294
0295 inequality = " + ".join(terms_rendered).replace("+ -", "- ")
0296 inequality = f"{inequality} <= {bound}"
0297 ax_constraint = AxParameterConstraint(inequality=inequality)
0298 logger.debug(
0299 f"Converted constraint '{constraint.name}' to Ax format: "
0300 f"{inequality}"
0301 )
0302 return ax_constraint
0303 except Exception as e:
0304 logger.warning(
0305 f"Failed to create Ax constraint for '{constraint.name}': {e}"
0306 )
0307 return None
0308
0309 def _create_ax_search_space(self) -> AxSearchSpace:
0310 """Create an Ax SearchSpace from the typed SearchSpace parameters.
0311
0312 Returns:
0313 Ax SearchSpace describing the optimization domain with constraints.
0314
0315 Raises:
0316 ValueError: If a parameter type is not supported by the Ax backend.
0317
0318 Notes:
0319 Constraints from search_space.constraints are automatically converted
0320 to Ax ParameterConstraint objects and included in the search space.
0321 """
0322
0323 ax_params = []
0324 for param_name, param in self.search_space.parameters.items():
0325 if isinstance(param, DesignRangeParameter):
0326 lower, upper = param.bounds
0327 ax_params.append(
0328 AxRangeParameter(
0329 name=param_name,
0330 parameter_type=ParameterType.FLOAT,
0331 lower=float(lower),
0332 upper=float(upper),
0333 )
0334 )
0335 elif isinstance(param, DesignChoiceParameter):
0336 ax_params.append(
0337 AxChoiceParameter(
0338 name=param_name,
0339 parameter_type=ParameterType.STRING,
0340 values=list(param.choices),
0341 is_ordered=False,
0342 )
0343 )
0344 else:
0345 raise ValueError(
0346 f"Unsupported parameter type for Ax: {param.__class__.__name__}"
0347 )
0348
0349
0350 ax_constraints = []
0351 for constraint in self.search_space.constraints:
0352 ax_constraint = self._parse_constraint_to_ax(constraint)
0353 if ax_constraint is not None:
0354 ax_constraints.append(ax_constraint)
0355 logger.debug(f"Added constraint '{constraint.name}': {constraint.rule}")
0356
0357 return AxSearchSpace(
0358 parameters=ax_params,
0359 parameter_constraints=ax_constraints if ax_constraints else None
0360 )
0361
0362 def _create_optimization_config(self):
0363 """Create Ax optimization configuration for multi-objective optimization.
0364
0365 Returns:
0366 Ax OptimizationConfig or None for single objective.
0367 """
0368 if len(self.objective_names) == 1:
0369
0370 name = self.objective_names[0]
0371 direction = getattr(
0372 self.objective_directions.get(name),
0373 "value",
0374 self.objective_directions.get(name, "minimize"),
0375 )
0376 minimize = str(direction).lower() != "maximize"
0377 return OptimizationConfig(
0378 objective=Objective(
0379 metric=Metric(name=name, lower_is_better=minimize),
0380 minimize=minimize,
0381 )
0382 )
0383 else:
0384
0385 objectives = []
0386 for name in self.objective_names:
0387 direction = getattr(
0388 self.objective_directions.get(name),
0389 "value",
0390 self.objective_directions.get(name, "minimize"),
0391 )
0392 minimize = str(direction).lower() != "maximize"
0393 objectives.append(
0394 Objective(
0395 metric=Metric(name=name, lower_is_better=minimize),
0396 minimize=minimize,
0397 )
0398 )
0399 objective_thresholds = []
0400 if self.config.objective_thresholds:
0401 for name, bound in self.config.objective_thresholds.items():
0402 direction = getattr(
0403 self.objective_directions.get(name),
0404 "value",
0405 self.objective_directions.get(name, "minimize"),
0406 )
0407 minimize = str(direction).lower() != "maximize"
0408 objective_thresholds.append(
0409 ObjectiveThreshold(
0410 metric=Metric(name=name, lower_is_better=minimize),
0411 bound=float(bound),
0412 relative=False,
0413 op=ComparisonOp.LEQ if minimize else ComparisonOp.GEQ,
0414 )
0415 )
0416 return MultiObjectiveOptimizationConfig(
0417 objective=MultiObjective(objectives=objectives),
0418 objective_thresholds=objective_thresholds,
0419 )
0420
0421 def _create_generation_strategy(self):
0422 """Create Ax GenerationStrategy based on config.
0423
0424 Returns:
0425 Ax GenerationStrategy configured with chosen initialization +
0426 model-based optimization backend.
0427
0428 Notes:
0429 Strategy uses the required node-based Ax API and transitions from
0430 initialization into the configured model-based generator.
0431 """
0432 return self._create_node_generation_strategy()
0433
0434 def _create_node_generation_strategy(self):
0435 """Create a node-based generation strategy using the latest Ax APIs.
0436
0437 Notes:
0438 This mirrors the modern Modular BoTorch tutorial pattern of chaining
0439 CenterOfSearchSpace -> initializer node -> model-based node using
0440 transition criteria such as MinTrials.
0441 """
0442 model_node_name = self._get_model_node_name()
0443 model_node = GenerationNode(
0444 name=model_node_name,
0445 generator_specs=[
0446 GeneratorSpec(
0447 generator_enum=self._get_model_based_generator_enum(),
0448 generator_kwargs=self._get_model_generator_kwargs(),
0449 generator_gen_kwargs=self._get_model_generator_gen_kwargs(),
0450 )
0451 ],
0452 )
0453
0454 nodes = []
0455 init_strategy = self.config.initialization_strategy.lower()
0456 init_trials = int(self.config.n_initial_samples)
0457
0458 if init_strategy == "center":
0459 remaining_init_trials = max(0, init_trials - 1)
0460 next_node_name = model_node.name
0461 if remaining_init_trials > 0:
0462 init_node = self._build_initialization_node(
0463 node_name="Sobol",
0464 generator_enum=Generators.SOBOL,
0465 num_trials=remaining_init_trials,
0466 transition_to=model_node.name,
0467 )
0468 next_node_name = init_node.name
0469 nodes.append(init_node)
0470
0471 nodes.insert(0, CenterGenerationNode(next_node_name=next_node_name))
0472 nodes.append(model_node)
0473 return GenerationStrategy(
0474 name=f"Center+{next_node_name}+{model_node.name}",
0475 nodes=nodes,
0476 )
0477
0478 init_node = self._build_initialization_node(
0479 node_name="Random" if init_strategy == "random" else "Sobol",
0480 generator_enum=self._get_initialization_model_enum(),
0481 num_trials=init_trials,
0482 transition_to=model_node.name,
0483 )
0484
0485 return GenerationStrategy(
0486 name=f"{init_node.name}+{model_node.name}",
0487 nodes=[init_node, model_node],
0488 )
0489
0490 def _build_initialization_node(
0491 self,
0492 *,
0493 node_name: str,
0494 generator_enum: Any,
0495 num_trials: int,
0496 transition_to: str,
0497 ):
0498 """Build one initialization node for the node-based Ax API."""
0499 return GenerationNode(
0500 name=node_name,
0501 generator_specs=[
0502 GeneratorSpec(
0503 generator_enum=generator_enum,
0504 generator_kwargs=self._get_initialization_generator_kwargs(),
0505 )
0506 ],
0507 transition_criteria=[
0508 MinTrials(
0509 threshold=num_trials,
0510 transition_to=transition_to,
0511 use_all_trials_in_exp=True,
0512 )
0513 ],
0514 )
0515
0516 def _get_initialization_model_enum(self):
0517 """Return Ax initializer generator enum for the configured strategy."""
0518 init_strategy = self.config.initialization_strategy.lower()
0519 if init_strategy == "uniform":
0520 uniform = getattr(Generators, "UNIFORM", None)
0521 if uniform is not None:
0522 return uniform
0523 logger.warning(
0524 "Generators.UNIFORM is unavailable in this Ax version; "
0525 "falling back to Sobol for initialization."
0526 )
0527 return Generators.SOBOL
0528
0529 def _get_initialization_generator_kwargs(self) -> Dict[str, Any]:
0530 """Return generator kwargs for initialization nodes."""
0531 if self.seed is None:
0532 return {}
0533 return {"seed": int(self.seed)}
0534
0535 def _get_model_based_generator_enum(self):
0536 """Return the configured Ax model-based generator enum."""
0537 generator_name = self.config.generator
0538 generator_enum = getattr(Generators, generator_name, None)
0539 if generator_enum is None:
0540 raise ValueError(
0541 f"Configured Ax generator '{generator_name}' is unavailable in "
0542 "the installed Ax version."
0543 )
0544 return generator_enum
0545
0546 def _get_model_node_name(self) -> str:
0547 """Return the display name for the active model-based generation node."""
0548 if self.config.generator == "BOTORCH_MODULAR":
0549 return "ModularBoTorch"
0550 return self.config.generator
0551
0552 def _get_model_generator_kwargs(self) -> Dict[str, Any]:
0553 """Return resolved generator kwargs for the configured backend."""
0554 return resolve_generator_kwargs(
0555 generator_name=self.config.generator,
0556 generator_kwargs=self.config.generator_kwargs,
0557 )
0558
0559 def _get_model_generator_gen_kwargs(self) -> Dict[str, Any]:
0560 """Return generation-time kwargs for model-based candidate generation."""
0561 return deepcopy(self.config.generator_gen_kwargs)
0562
0563 def _split_generator_run(self, generator_run: Any) -> List[Any]:
0564 """Split a possibly batched Ax generator run into per-arm runs.
0565
0566 Args:
0567 generator_run: A generator run returned by Ax.
0568
0569 Returns:
0570 List of single-arm generator runs.
0571 """
0572 if not hasattr(generator_run, "arms"):
0573 raise TypeError(
0574 "Expected an Ax generator run with an 'arms' attribute, got "
0575 f"{type(generator_run).__name__}"
0576 )
0577
0578 arms = list(generator_run.arms)
0579 if len(arms) <= 1:
0580 return [generator_run]
0581
0582 from ax.core.generator_run import GeneratorRun
0583
0584 weights = list(getattr(generator_run, "weights", []) or [])
0585 single_runs: List[Any] = []
0586 for index, arm in enumerate(arms):
0587 weight = weights[index] if index < len(weights) else 1.0
0588 single_run = GeneratorRun(
0589 arms=[arm],
0590 weights=[weight],
0591 fit_time=getattr(generator_run, "fit_time", None),
0592 gen_time=getattr(generator_run, "gen_time", None),
0593 generation_node_name=getattr(generator_run, "_generation_node_name", None),
0594 )
0595 for attr_name in ("_model_key", "_generation_node_name"):
0596 if hasattr(generator_run, attr_name):
0597 setattr(single_run, attr_name, getattr(generator_run, attr_name))
0598 single_runs.append(single_run)
0599
0600 return single_runs
0601
0602 def _normalize_generator_runs(self, gen_result: Any) -> List[Any]:
0603 """Normalize Ax generation output into per-arm generator runs.
0604
0605 Args:
0606 gen_result: Value returned by ``generation_strategy.gen(...)``.
0607
0608 Returns:
0609 List of single-arm generator runs.
0610
0611 Raises:
0612 TypeError: If the return shape cannot be interpreted as generator
0613 run output.
0614
0615 Notes:
0616 Newer Ax internals may return wrapper/list-like structures around
0617 generator runs. AID2E uses one Ax generation call per requested
0618 batch and then splits the result into individual trial records.
0619 """
0620 if hasattr(gen_result, "arms"):
0621 return self._split_generator_run(gen_result)
0622
0623 if isinstance(gen_result, (list, tuple)):
0624 generator_runs: List[Any] = []
0625 for item in gen_result:
0626 generator_runs.extend(self._normalize_generator_runs(item))
0627 return generator_runs
0628
0629 if hasattr(gen_result, "generator_run_structs"):
0630 structs = getattr(gen_result, "generator_run_structs")
0631 generator_runs: List[Any] = []
0632 for struct in structs:
0633 generator_runs.extend(self._normalize_generator_runs(struct))
0634 return generator_runs
0635
0636 if hasattr(gen_result, "generator_run"):
0637 return self._normalize_generator_runs(getattr(gen_result, "generator_run"))
0638
0639 raise TypeError(
0640 "Unsupported Ax generation result type: "
0641 f"{type(gen_result).__name__}"
0642 )
0643
0644 def _get_generation_strategy_metadata(self) -> Dict[str, Any]:
0645 """Return strategy progress metadata compatible across Ax APIs.
0646
0647 Returns:
0648 Dictionary containing ``ax_step_index`` when available and
0649 ``ax_node_name`` for node-based strategies.
0650 """
0651 metadata: Dict[str, Any] = {"ax_step_index": -1}
0652
0653 step_index = getattr(self.generation_strategy, "current_step_index", None)
0654 if step_index is not None:
0655 try:
0656 metadata["ax_step_index"] = int(step_index)
0657 except (TypeError, ValueError):
0658 logger.debug("Unable to coerce Ax step index '%s' to int", step_index)
0659
0660 node_name = getattr(self.generation_strategy, "current_node_name", None)
0661 if node_name is not None:
0662 metadata["ax_node_name"] = str(node_name)
0663
0664 return metadata
0665
0666 def suggest_candidates(self, n_candidates: int = 1) -> List[Dict[str, Any]]:
0667 """Suggest the next batch of parameter configurations to evaluate.
0668
0669 Args:
0670 n_candidates: Number of candidates to generate.
0671
0672 Returns:
0673 List of parameter dictionaries ready for evaluation.
0674
0675 Notes:
0676 Constraints are handled natively by Ax when present in search_space.
0677 Ax enforces constraints during candidate generation automatically.
0678
0679 IMPLEMENTATION NOTE: This method generates the requested batch in a
0680 single Ax call whenever possible, then splits the resulting batch
0681 into individual AID2E trial records so the rest of the framework can
0682 keep using single-trial ``update_with_results`` semantics.
0683
0684 The GenerationStrategy still properly tracks progress: after
0685 n_initial_samples individual trials complete (regardless of how
0686 they were generated), it automatically switches from Sobol to BO.
0687 """
0688
0689 candidates = []
0690 model_keys: List[str] = []
0691
0692 generator_run_result = self.generation_strategy.gen(
0693 experiment=self.experiment,
0694 n=n_candidates,
0695 )
0696 strategy_metadata = self._get_generation_strategy_metadata()
0697 generator_runs = self._normalize_generator_runs(generator_run_result)
0698 if len(generator_runs) < n_candidates:
0699 raise RuntimeError(
0700 "Ax returned fewer generator runs than requested: "
0701 f"requested={n_candidates}, got={len(generator_runs)}"
0702 )
0703
0704 for generator_run in generator_runs[:n_candidates]:
0705 trial = self.experiment.new_trial(generator_run=generator_run)
0706 trial.mark_running(no_runner_required=True)
0707 arm = generator_run.arms[0]
0708 candidate_params = dict(arm.parameters)
0709 model_key = getattr(generator_run, "_model_key", "unknown")
0710 self.set_trial_status(
0711 trial_index=trial.index,
0712 status=TRIAL_STATUS_SUGGESTED,
0713 parameters=candidate_params,
0714 metrics=None,
0715 metadata={
0716 "ax_model_key": model_key,
0717 **strategy_metadata,
0718 },
0719 )
0720 candidates.append(candidate_params)
0721 model_keys.append(model_key)
0722
0723 self._trial_counter = max(self._trial_counter, len(self.experiment.trials))
0724 strategy_metadata = self._get_generation_strategy_metadata()
0725 strategy_ref = strategy_metadata.get(
0726 "ax_node_name",
0727 strategy_metadata.get("ax_step_index", -1),
0728 )
0729
0730 logger.debug(
0731 "Generated %d candidates using %s (trials %d-%d, strategy=%s)",
0732 n_candidates,
0733 ",".join(model_keys) if model_keys else "unknown",
0734 len(self.experiment.trials) - n_candidates,
0735 len(self.experiment.trials) - 1,
0736 strategy_ref,
0737 )
0738 return candidates
0739
0740 def update_with_results(
0741 self,
0742 trial_index: int,
0743 parameters: Dict[str, Any],
0744 metrics: Dict[str, float]
0745 ) -> None:
0746 """Update optimizer with evaluation results from a trial.
0747
0748 Args:
0749 trial_index: Unique identifier for the trial.
0750 parameters: Parameter values that were evaluated.
0751 metrics: Objective values obtained from evaluation.
0752
0753 Examples:
0754 >>> optimizer.update_with_results(
0755 ... trial_index=0,
0756 ... parameters={'x': 0.5, 'y': 0.3},
0757 ... metrics={'loss': 0.1, 'accuracy': 0.9}
0758 ... )
0759
0760 Notes:
0761 Completes the trial in Ax experiment and attaches data.
0762 This allows the surrogate model to learn from the evaluation.
0763 """
0764
0765 for obj_name in self.objective_names:
0766 if obj_name not in metrics:
0767 raise ValueError(
0768 f"Missing objective '{obj_name}' in metrics. "
0769 f"Expected: {self.objective_names}, got: {list(metrics.keys())}"
0770 )
0771
0772
0773 if trial_index < len(self.experiment.trials):
0774 trial = self.experiment.trials[trial_index]
0775
0776
0777 trial.mark_completed()
0778
0779
0780 from ax.core.data import Data
0781 import pandas as pd
0782
0783 data_rows = []
0784 for metric_name, metric_value in metrics.items():
0785 if metric_name in self.objective_names:
0786 data_rows.append({
0787 'trial_index': trial_index,
0788 'metric_name': metric_name,
0789 'metric_signature': metric_name,
0790 'arm_name': trial.arm.name if trial.arm else f"arm_{trial_index}",
0791 'mean': float(metric_value),
0792 'sem': 0.0
0793 })
0794
0795 if data_rows:
0796 df = pd.DataFrame(data_rows)
0797 data = Data(df=df)
0798 self.experiment.attach_data(data)
0799
0800
0801 self.set_trial_status(
0802 trial_index=trial_index,
0803 status="completed",
0804 parameters=parameters,
0805 metrics={k: float(v) for k, v in metrics.items()},
0806 )
0807
0808 logger.debug(
0809 f"Updated trial {trial_index} with {len(metrics)} metrics"
0810 )
0811
0812 def serialize_state(self) -> Dict[str, Any]:
0813 """Serialize optimizer state for distributed execution.
0814
0815 Returns:
0816 Dictionary containing all necessary state to reconstruct the optimizer.
0817
0818 Examples:
0819 >>> state = optimizer.serialize_state()
0820 >>> json.dumps(state) # Should be JSON-serializable
0821 """
0822 space_payload = {
0823 name: param.model_dump()
0824 for name, param in self.search_space.parameters.items()
0825 }
0826 constraints_payload = [
0827 constraint.model_dump()
0828 for constraint in self.search_space.constraints
0829 ]
0830
0831 return {
0832 "search_space": {
0833 "parameters": space_payload,
0834 "constraints": constraints_payload,
0835 "name": self.search_space.name,
0836 },
0837 "n_objectives": self.n_objectives,
0838 "seed": self.seed,
0839 "objective_names": self.objective_names,
0840 "config": {
0841 "initialization_strategy": self.config.initialization_strategy,
0842 "generator": self.config.generator,
0843 "generator_kwargs": deepcopy(self.config.generator_kwargs),
0844 "generator_gen_kwargs": deepcopy(self.config.generator_gen_kwargs),
0845 "objective_thresholds": (
0846 deepcopy(self.config.objective_thresholds)
0847 if self.config.objective_thresholds is not None
0848 else None
0849 ),
0850 "n_initial_samples": self.config.n_initial_samples,
0851 "n_iterations": self.config.n_iterations,
0852 "batch_size": self.config.batch_size,
0853 "seed": self.config.seed,
0854 },
0855 "trials": [
0856 {
0857 "index": t.index,
0858 "parameters": t.parameters,
0859 "metrics": t.metrics,
0860 "status": t.status,
0861 "metadata": t.metadata
0862 }
0863 for t in self._trials if t is not None
0864 ],
0865 "trial_counter": self._trial_counter
0866 }
0867
0868 def load_state(self, state: Dict[str, Any]) -> None:
0869 """Load optimizer state from serialized form.
0870
0871 Args:
0872 state: Dictionary containing serialized optimizer state.
0873
0874 Raises:
0875 ValueError: If state is invalid or incompatible.
0876
0877 Notes:
0878 This recreates the Ax experiment and generation strategy,
0879 then replays all trials to restore the optimizer state.
0880 """
0881
0882 required_keys = ["search_space", "objective_names", "config", "trials"]
0883 for key in required_keys:
0884 if key not in state:
0885 raise ValueError(f"Missing required key in state: {key}")
0886
0887
0888 self.config = AxOptimizerConfig(**state["config"])
0889 self.objective_names = list(state["objective_names"])
0890
0891
0892 saved_space = state["search_space"] or {}
0893 parameters_payload = saved_space.get("parameters", saved_space)
0894 constraints_payload = saved_space.get("constraints", [])
0895
0896 self.search_space = SearchSpace(
0897 parameters=parameters_payload,
0898 constraints=constraints_payload,
0899 name=saved_space.get("name"),
0900 )
0901
0902 self.ax_search_space = self._create_ax_search_space()
0903 self.optimization_config = self._create_optimization_config()
0904 self.experiment = Experiment(
0905 name="aid2e_optimization",
0906 search_space=self.ax_search_space,
0907 optimization_config=self.optimization_config,
0908 )
0909 self.generation_strategy = self._create_generation_strategy()
0910
0911
0912 self._trials = []
0913 for trial_data in state["trials"]:
0914 trial = Trial(
0915 index=trial_data["index"],
0916 parameters=trial_data["parameters"],
0917 metrics=trial_data.get("metrics"),
0918 status=trial_data.get("status", "pending"),
0919 metadata=trial_data.get("metadata", {})
0920 )
0921
0922 while len(self._trials) <= trial.index:
0923 self._trials.append(None)
0924 self._trials[trial.index] = trial
0925
0926
0927 if trial.status == "completed" and trial.metrics:
0928
0929 ax_trial = self.experiment.new_trial()
0930 ax_trial.mark_running(no_runner_required=True)
0931
0932
0933 self.update_with_results(
0934 trial_index=trial.index,
0935 parameters=trial.parameters,
0936 metrics=trial.metrics
0937 )
0938
0939 self._trial_counter = state.get("trial_counter", len(self._trials))
0940 self._trial_counter = max(self._trial_counter, len(self.experiment.trials))
0941
0942 logger.info(f"Loaded optimizer state with {len(self._trials)} trials")
0943
0944 def __repr__(self) -> str:
0945 """Return string representation of the optimizer.
0946
0947 Returns:
0948 String describing the optimizer configuration.
0949 """
0950 return (
0951 f"AxOptimizer("
0952 f"n_params={len(self.search_space.parameters)}, "
0953 f"n_objectives={self.n_objectives}, "
0954 f"strategy={self.config.initialization_strategy}, "
0955 f"generator={self.config.generator}, "
0956 f"seed={self.seed}"
0957 f")"
0958 )