Back to home page

EIC code displayed by LXR

 
 

    


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     # ========== Test Methods ==========
0134     # These should NOT be overridden by subclasses
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         # Verify initialization
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         # Suggest some candidates
0189         candidates = optimizer.suggest_candidates(n_candidates=3)
0190         
0191         # Verify structure
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             # Verify bounds
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         # Suggest candidates
0217         candidates = optimizer.suggest_candidates(n_candidates=2)
0218         
0219         # Update with results
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         # Verify trials were recorded
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         # Initially no trials
0247         best = optimizer.get_best_trial()
0248         assert best is None
0249         
0250         # Add some trials
0251         candidates = optimizer.suggest_candidates(n_candidates=3)
0252         metrics_list = [
0253             {"loss": 0.5},
0254             {"loss": 0.2},  # Best
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         # Verify best trial
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()  # Single objective
0275         
0276         optimizer = self.create_optimizer(
0277             search_space=search_space,
0278             config=config,
0279             objective_names=objective_names
0280         )
0281         
0282         # Initially empty
0283         pareto = optimizer.get_pareto_front()
0284         assert len(pareto) == 0
0285         
0286         # Add trials
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         # For single objective, Pareto front = best trial
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         # Add trials with known Pareto structure
0314         # Dominated: (1.0, 1.0)
0315         # Pareto: (0.5, 1.0), (1.0, 0.5), (0.3, 1.2)
0316         candidates = optimizer.suggest_candidates(n_candidates=4)
0317         metrics_list = [
0318             {"f1": 1.0, "f2": 1.0},  # Dominated
0319             {"f1": 0.5, "f2": 1.0},  # Pareto
0320             {"f1": 1.0, "f2": 0.5},  # Pareto
0321             {"f1": 0.3, "f2": 1.2},  # Pareto
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  # One dominated solution excluded
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         # Add some trials
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         # Serialize state
0358         state = optimizer.serialize_state()
0359         
0360         # Verify state is JSON-serializable
0361         json_str = json.dumps(state)
0362         assert isinstance(json_str, str)
0363         assert len(json_str) > 0
0364         
0365         # Load state into new optimizer
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         # Verify trials were restored
0375         trials1 = optimizer.get_trials()
0376         trials2 = optimizer2.get_trials()
0377         assert len(trials1) == len(trials2)
0378         
0379         # Verify trial data matches
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         # Valid config should work
0390         config = config_class()
0391         assert config is not None
0392         
0393         # Test with custom parameters if config_class is Pydantic
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