File indexing completed on 2026-08-12 08:24:57
0001 """Extensible test suite for optimizer implementations.
0002
0003 This module provides an AbstractOptimizerTestSuite base class that any optimizer
0004 implementation should extend and pass. This ensures consistent interface compliance
0005 and behavior across all optimizer implementations.
0006
0007 Usage:
0008 >>> class TestMyOptimizer(AbstractOptimizerTestSuite):
0009 ... @staticmethod
0010 ... def create_optimizer(**kwargs):
0011 ... return MyOptimizer(**kwargs)
0012 ...
0013 ... @staticmethod
0014 ... def get_config_class():
0015 ... return MyOptimizerConfig
0016
0017 Project: AID2E v0.0.0 - AI assisted Detector Design for EIC
0018 Homepage: https://aid2e.github.io/aid2e-framework
0019 Repository: https://github.com/aid2e/AID2E-framework.git
0020 """
0021
0022 import json
0023 from abc import ABC, abstractmethod
0024 from typing import Any, Dict
0025 import numpy as np
0026 import pytest
0027
0028 from aid2e.optimizers.base import BaseOptimizer, SearchSpace, Trial
0029
0030
0031 class AbstractOptimizerTestSuite(ABC):
0032 """Abstract base class for testing optimizer implementations.
0033
0034 Any optimizer implementation should subclass this and provide implementations
0035 for the abstract methods. This ensures that all optimizers comply with the
0036 BaseOptimizer interface and basic functionality requirements.
0037
0038 Attributes:
0039 search_space: Common search space for testing.
0040 objective_names: Common objective names for testing.
0041
0042 Examples:
0043 >>> class TestAxOptimizer(AbstractOptimizerTestSuite):
0044 ... @staticmethod
0045 ... def create_optimizer(**kwargs):
0046 ... return AxOptimizer(**kwargs)
0047 ...
0048 ... @staticmethod
0049 ... def get_config_class():
0050 ... return AxOptimizerConfig
0051
0052 Notes:
0053 Subclasses should not override test methods. Instead, they should
0054 implement the abstract methods to configure the test suite for
0055 their specific optimizer.
0056 """
0057
0058 @staticmethod
0059 @abstractmethod
0060 def create_optimizer(
0061 search_space: SearchSpace,
0062 config: Any,
0063 objective_names: list,
0064 seed: int = 42
0065 ) -> BaseOptimizer:
0066 """Create an optimizer instance for testing.
0067
0068 Args:
0069 search_space: Parameter search space definition.
0070 config: Optimizer configuration (e.g., AxOptimizerConfig).
0071 objective_names: List of objective names to optimize.
0072 seed: Random seed for reproducibility.
0073
0074 Returns:
0075 Instance of the optimizer being tested.
0076 """
0077 pass
0078
0079 @staticmethod
0080 @abstractmethod
0081 def get_config_class() -> type:
0082 """Get the configuration class for this optimizer.
0083
0084 Returns:
0085 The Pydantic config class (e.g., AxOptimizerConfig).
0086
0087 Examples:
0088 >>> return AxOptimizerConfig
0089 """
0090 pass
0091
0092 @staticmethod
0093 def get_search_space() -> SearchSpace:
0094 """Get the search space to use for testing.
0095
0096 Returns:
0097 A SearchSpace with simple 2D bounds suitable for testing.
0098
0099 Notes:
0100 Override this method if your optimizer has special requirements
0101 for the search space.
0102 """
0103 return SearchSpace(
0104 parameters={
0105 "x": {"type": "range", "value": 0.5, "bounds": [0.0, 1.0]},
0106 "y": {"type": "range", "value": 0.5, "bounds": [0.0, 1.0]},
0107 }
0108 )
0109
0110 @staticmethod
0111 def get_objective_names() -> list:
0112 """Get the objective names for testing.
0113
0114 Returns:
0115 List of objective names (default: single objective for simplicity).
0116
0117 Notes:
0118 Override this to test multi-objective optimization.
0119 """
0120 return ["loss"]
0121
0122 def create_default_config(self) -> Any:
0123 """Create a default configuration for the optimizer.
0124
0125 Returns:
0126 A valid configuration instance for the optimizer.
0127 """
0128 config_class = self.get_config_class()
0129 if config_class is None:
0130 raise NotImplementedError("get_config_class() must be implemented by subclass")
0131 return config_class()
0132
0133
0134
0135
0136 def test_optimizer_initialization(self) -> None:
0137 """Test that optimizer initializes correctly."""
0138 search_space = self.get_search_space()
0139 config = self.create_default_config()
0140 objective_names = self.get_objective_names()
0141
0142 optimizer = self.create_optimizer(
0143 search_space=search_space,
0144 config=config,
0145 objective_names=objective_names,
0146 seed=42
0147 )
0148
0149
0150 assert isinstance(optimizer, BaseOptimizer)
0151 assert optimizer.n_objectives == len(objective_names)
0152 assert optimizer.seed == 42
0153 assert len(optimizer.search_space.parameters) == len(search_space.parameters)
0154
0155 def test_optimizer_inherits_from_base(self) -> None:
0156 """Test that optimizer properly inherits from BaseOptimizer."""
0157 search_space = self.get_search_space()
0158 config = self.create_default_config()
0159 objective_names = self.get_objective_names()
0160
0161 optimizer = self.create_optimizer(
0162 search_space=search_space,
0163 config=config,
0164 objective_names=objective_names
0165 )
0166
0167 assert isinstance(optimizer, BaseOptimizer)
0168 assert hasattr(optimizer, 'suggest_candidates')
0169 assert hasattr(optimizer, 'update_with_results')
0170 assert hasattr(optimizer, 'get_pareto_front')
0171 assert hasattr(optimizer, 'get_best_trial')
0172 assert hasattr(optimizer, 'get_trials')
0173 assert hasattr(optimizer, 'serialize_state')
0174 assert hasattr(optimizer, 'load_state')
0175
0176 def test_suggest_candidates(self) -> None:
0177 """Test that the optimizer can suggest candidate parameters."""
0178 search_space = self.get_search_space()
0179 config = self.create_default_config()
0180 objective_names = self.get_objective_names()
0181
0182 optimizer = self.create_optimizer(
0183 search_space=search_space,
0184 config=config,
0185 objective_names=objective_names
0186 )
0187
0188
0189 candidates = optimizer.suggest_candidates(n_candidates=3)
0190
0191
0192 assert isinstance(candidates, list)
0193 assert len(candidates) == 3
0194
0195 for candidate in candidates:
0196 assert isinstance(candidate, dict)
0197 assert set(candidate.keys()) == set(search_space.parameters.keys())
0198
0199
0200 for param_name, param_value in candidate.items():
0201 bounds = search_space.parameters[param_name].bounds
0202 assert bounds[0] <= param_value <= bounds[1]
0203
0204 def test_update_with_results(self) -> None:
0205 """Test that the optimizer can update with trial results."""
0206 search_space = self.get_search_space()
0207 config = self.create_default_config()
0208 objective_names = self.get_objective_names()
0209
0210 optimizer = self.create_optimizer(
0211 search_space=search_space,
0212 config=config,
0213 objective_names=objective_names
0214 )
0215
0216
0217 candidates = optimizer.suggest_candidates(n_candidates=2)
0218
0219
0220 for i, candidate in enumerate(candidates):
0221 metrics = {obj: np.random.random() for obj in objective_names}
0222 optimizer.update_with_results(
0223 trial_index=i,
0224 parameters=candidate,
0225 metrics=metrics
0226 )
0227
0228
0229 trials = optimizer.get_trials()
0230 assert len(trials) == 2
0231 assert all(isinstance(t, Trial) for t in trials)
0232 assert all(t.status == "completed" for t in trials)
0233
0234 def test_get_best_trial(self) -> None:
0235 """Test retrieving the best trial."""
0236 search_space = self.get_search_space()
0237 config = self.create_default_config()
0238 objective_names = self.get_objective_names()
0239
0240 optimizer = self.create_optimizer(
0241 search_space=search_space,
0242 config=config,
0243 objective_names=objective_names
0244 )
0245
0246
0247 best = optimizer.get_best_trial()
0248 assert best is None
0249
0250
0251 candidates = optimizer.suggest_candidates(n_candidates=3)
0252 metrics_list = [
0253 {"loss": 0.5},
0254 {"loss": 0.2},
0255 {"loss": 0.8},
0256 ]
0257
0258 for i, candidate in enumerate(candidates):
0259 optimizer.update_with_results(
0260 trial_index=i,
0261 parameters=candidate,
0262 metrics=metrics_list[i]
0263 )
0264
0265
0266 best = optimizer.get_best_trial()
0267 assert best is not None
0268 assert best.metrics["loss"] == 0.2
0269
0270 def test_get_pareto_front_single_objective(self) -> None:
0271 """Test Pareto front computation for single-objective problems."""
0272 search_space = self.get_search_space()
0273 config = self.create_default_config()
0274 objective_names = self.get_objective_names()
0275
0276 optimizer = self.create_optimizer(
0277 search_space=search_space,
0278 config=config,
0279 objective_names=objective_names
0280 )
0281
0282
0283 pareto = optimizer.get_pareto_front()
0284 assert len(pareto) == 0
0285
0286
0287 candidates = optimizer.suggest_candidates(n_candidates=3)
0288 for i, candidate in enumerate(candidates):
0289 metrics = {objective_names[0]: float(i + 1)}
0290 optimizer.update_with_results(
0291 trial_index=i,
0292 parameters=candidate,
0293 metrics=metrics
0294 )
0295
0296
0297 pareto = optimizer.get_pareto_front()
0298 assert len(pareto) == 1
0299 assert pareto[0].metrics[objective_names[0]] == 1.0
0300
0301 def test_get_pareto_front_multi_objective(self) -> None:
0302 """Test Pareto front computation for multi-objective problems."""
0303 search_space = self.get_search_space()
0304 config = self.create_default_config()
0305 objective_names = ["f1", "f2"]
0306
0307 optimizer = self.create_optimizer(
0308 search_space=search_space,
0309 config=config,
0310 objective_names=objective_names
0311 )
0312
0313
0314
0315
0316 candidates = optimizer.suggest_candidates(n_candidates=4)
0317 metrics_list = [
0318 {"f1": 1.0, "f2": 1.0},
0319 {"f1": 0.5, "f2": 1.0},
0320 {"f1": 1.0, "f2": 0.5},
0321 {"f1": 0.3, "f2": 1.2},
0322 ]
0323
0324 for i, candidate in enumerate(candidates):
0325 optimizer.update_with_results(
0326 trial_index=i,
0327 parameters=candidate,
0328 metrics=metrics_list[i]
0329 )
0330
0331 pareto = optimizer.get_pareto_front()
0332 assert len(pareto) == 3
0333
0334 def test_serialize_and_load_state(self) -> None:
0335 """Test state serialization and loading."""
0336 search_space = self.get_search_space()
0337 config = self.create_default_config()
0338 objective_names = self.get_objective_names()
0339
0340 optimizer = self.create_optimizer(
0341 search_space=search_space,
0342 config=config,
0343 objective_names=objective_names,
0344 seed=42
0345 )
0346
0347
0348 candidates = optimizer.suggest_candidates(n_candidates=3)
0349 for i, candidate in enumerate(candidates):
0350 metrics = {obj: np.random.random() for obj in objective_names}
0351 optimizer.update_with_results(
0352 trial_index=i,
0353 parameters=candidate,
0354 metrics=metrics
0355 )
0356
0357
0358 state = optimizer.serialize_state()
0359
0360
0361 json_str = json.dumps(state)
0362 assert isinstance(json_str, str)
0363 assert len(json_str) > 0
0364
0365
0366 optimizer2 = self.create_optimizer(
0367 search_space=search_space,
0368 config=config,
0369 objective_names=objective_names,
0370 seed=42
0371 )
0372 optimizer2.load_state(state)
0373
0374
0375 trials1 = optimizer.get_trials()
0376 trials2 = optimizer2.get_trials()
0377 assert len(trials1) == len(trials2)
0378
0379
0380 for t1, t2 in zip(trials1, trials2):
0381 assert t1.index == t2.index
0382 assert t1.status == t2.status
0383 assert t1.parameters == t2.parameters
0384
0385 def test_config_validation(self) -> None:
0386 """Test that configuration class validates inputs properly."""
0387 config_class = self.get_config_class()
0388
0389
0390 config = config_class()
0391 assert config is not None
0392
0393
0394 if hasattr(config_class, 'model_validate'):
0395 config_dict = {}
0396 config2 = config_class.model_validate(config_dict)
0397 assert config2 is not None
0398
0399
0400 class TestAxOptimizer(AbstractOptimizerTestSuite):
0401 """Test suite for AxOptimizer implementation.
0402
0403 This test class extends AbstractOptimizerTestSuite to test the Ax-based
0404 optimizer implementation. It provides the necessary configuration methods
0405 and can add Ax-specific tests if needed.
0406 """
0407
0408 config_class = None
0409
0410 @classmethod
0411 def setup_class(cls):
0412 """Setup test class by importing AxOptimizer."""
0413 from aid2e.optimizers.ax import AxOptimizer, AxOptimizerConfig
0414 from aid2e.optimizers.ax import optimizer as ax_optimizer_module
0415 if not ax_optimizer_module.AX_NODE_STRATEGY_AVAILABLE:
0416 pytest.skip(
0417 "Installed Ax runtime lacks required node-based generation APIs.",
0418 allow_module_level=False,
0419 )
0420 cls.ax_optimizer_class = AxOptimizer
0421 cls.config_class = AxOptimizerConfig
0422
0423 @staticmethod
0424 def create_optimizer(
0425 search_space: SearchSpace,
0426 config: Any,
0427 objective_names: list,
0428 seed: int = 42
0429 ) -> BaseOptimizer:
0430 """Create an AxOptimizer instance for testing."""
0431 from aid2e.optimizers.ax import AxOptimizer
0432 return AxOptimizer(
0433 search_space=search_space,
0434 config=config,
0435 objective_names=objective_names,
0436 seed=seed
0437 )
0438
0439 @staticmethod
0440 def get_config_class() -> type:
0441 """Get the AxOptimizerConfig class."""
0442 from aid2e.optimizers.ax import AxOptimizerConfig
0443 return AxOptimizerConfig