Source code for saealib.optimizer

"""Optimizer: assembles and runs the surrogate-assisted EA pipeline."""

from __future__ import annotations

import copy
import dataclasses
import importlib.util
import logging
import pickle
import warnings
from collections.abc import Callable, Generator, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, cast

from typing_extensions import Self

from saealib.acquisition.base import AcquisitionFunction, CompositeAcquisition
from saealib.acquisition.mean import MeanPrediction
from saealib.acquisition.winrate import WinRateAcquisition
from saealib.callback import (
    CallbackManager,
    Event,
    GenerationStartEvent,
    logging_generation,
)
from saealib.context import OptimizationState
from saealib.core.compiler import (
    CompileContext,
    Compiler,
    ContractPath,
    Diagnostic,
    DiagnosticBag,
    ExecutablePlan,
    Severity,
    check_component_contract,
)
from saealib.core.compiler.contract_diagnostics import (
    check_pymoo_feedback_compatibility,
)
from saealib.core.compiler.graph import ComponentGraph
from saealib.core.contracts import ComponentContract
from saealib.core.state import OPTIMIZATION_STATE_INITIAL_KEYS
from saealib.exceptions import ConfigurationError, ValidationError
from saealib.execution.evaluator import Evaluator, SerialEvaluator
from saealib.execution.history import SUPPORTED_HISTORY_CHANNELS
from saealib.execution.runner import Runner
from saealib.execution.runtime import default_runtime_registry
from saealib.execution.scheduler import AsyncEvaluationScheduler
from saealib.policies.evaluation import EvaluationPlanner
from saealib.policies.feedback import FeedbackBuilder
from saealib.surrogate.manager import (
    LocalSurrogateManager,
    PairwiseSurrogateManager,
    SurrogateManager,
)
from saealib.surrogate.rbf_kernels import GaussianKernel
from saealib.termination import Termination
from saealib.termination import max_fe as max_fe_cond

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from saealib.algorithms.base import Algorithm
    from saealib.defaults.model import DefaultResolution
    from saealib.execution.initializer import Initializer
    from saealib.problem import Problem
    from saealib.strategies.base import OptimizationStrategy
    from saealib.surrogate.base import Surrogate


def _default_acquisition_spec(manager_type: str | type | None, direction) -> dict:
    from saealib.registry import get

    if isinstance(manager_type, type):
        manager_cls = manager_type
    else:
        try:
            manager_cls = get(manager_type) if manager_type is not None else None
        except ValidationError:
            manager_cls = None
    if isinstance(manager_cls, type) and issubclass(
        manager_cls, PairwiseSurrogateManager
    ):
        return {"type": "WinRateAcquisition", "params": {}}
    return {"type": "MeanPrediction", "params": {"direction": direction}}


class Dispatchable(Protocol):
    """Minimal interface for objects that can dispatch callback events."""

    def dispatch(self, event: Event) -> None:
        """Dispatch a callback event."""
        ...


class ComponentProvider(Protocol):
    """The interface for components that can be used by the Optimizer."""

    @property
    def seed(self) -> int | None:
        """Return the master random seed."""
        ...

    @property
    def algorithm(self) -> Algorithm:
        """Return the algorithm instance."""
        ...

    @property
    def strategy(self) -> OptimizationStrategy:
        """Return the optimization strategy instance."""
        ...

    @property
    def surrogate_manager(self) -> SurrogateManager:
        """Return the surrogate manager instance."""
        ...

    @property
    def acquisition(self) -> AcquisitionFunction:
        """Acquisition function owned by the provider, not the manager."""
        ...

    @property
    def evaluation_planner(self) -> EvaluationPlanner | None:
        """Return the evaluation planner."""
        ...

    @property
    def feedback_builder(self) -> FeedbackBuilder | None:
        """Return the configured feedback builder."""
        ...

    @property
    def async_evaluation_scheduler(self) -> AsyncEvaluationScheduler | None:
        """Return the optional asynchronous evaluation scheduler."""
        ...

    @property
    def history_channels(self) -> Sequence[str]:
        """Return the enabled execution history channels."""
        ...

    @property
    def feedback_builder_explicit(self) -> bool:
        """Return whether the feedback builder was explicitly configured."""
        ...

    @property
    def history_channels_explicit(self) -> bool:
        """Return whether history channels were explicitly configured."""
        ...

    @property
    def evaluator(self) -> Evaluator:
        """Return the evaluator instance."""
        ...

    @property
    def termination(self) -> Termination:
        """Return the termination condition."""
        ...

    @property
    def cbmanager(self) -> CallbackManager:
        """Return the callback manager."""
        ...

    def dispatch(self, event: Event) -> None:
        """Dispatch a callback event."""
        ...


[docs] class Optimizer: """ Optimizer class for evolutionary algorithms. Integrates problem definition, evolutionary algorithm, surrogate model, model manager, and termination condition, and manages the optimization process. Attributes ---------- problem : Problem The optimization problem. algorithm : Algorithm The evolutionary algorithm. surrogate : Surrogate The surrogate model. strategy : OptimizationStrategy The optimization strategy. termination : Termination The termination condition. archive : Archive The archive of evaluated solutions. popsize : int The population size. seed : int or None The master random seed. ``None`` means non-deterministic. fe : int The current number of function evaluations. gen : int The current generation number. cbmanager : CallbackManager The callback event manager. instance_name : str The name of the optimizer instance. """
[docs] def __init__(self, problem: Problem, seed: int | None = None): """ Initialize the Optimizer. Parameters ---------- problem : Problem The optimization problem. seed : int or None, optional Master random seed propagated to the initializer. ``None`` (default) means non-deterministic. """ self.problem: Problem = problem self.seed: int | None = seed self.cbmanager: CallbackManager = CallbackManager() self.cbmanager.register(GenerationStartEvent, logging_generation) self.initializer: Initializer | None = None self.evaluator: Evaluator = SerialEvaluator() self.acquisition: AcquisitionFunction = cast(AcquisitionFunction, None) self._evaluation_planner: EvaluationPlanner | None = None self.feedback_builder: FeedbackBuilder | None = None self.feedback_builder_explicit = False self.history_channels_explicit = False self.async_evaluation_scheduler: AsyncEvaluationScheduler | None = None self.history_channels: tuple[str, ...] = ("summary",) self.instance_name: str = "" self._preset: dict | None = None self._default_resolution: DefaultResolution | None = None self._last_contract_diagnostics: tuple[Diagnostic, ...] = () self._executable_plan: ExecutablePlan | None = None
# --- setters (all return self for chaining) ---
[docs] def contract_diagnostics(self) -> DiagnosticBag: """Return diagnostics for the currently configured component graph.""" diagnostics = DiagnosticBag() contracts: dict[str, ComponentContract] = {} component_names = ( "initializer", "algorithm", "surrogate_manager", "termination", "evaluator", "evaluation_planner", "feedback_builder", "acquisition", "async_evaluation_scheduler", "strategy", ) for name in component_names: component = getattr(self, name, None) if component is None: continue missing = object() try: contract_method = getattr(component, "contract", missing) except Exception as error: diagnostics.append( Diagnostic( severity=Severity.ERROR, code="contract_unavailable", message=( f"{name}.contract attribute could not be read: " f"{type(error).__name__}: {error}." ), path=ContractPath(components=(name,)), resolutions=( "Make the component's contract attribute readable and " "provide a callable contract() method.", ), ) ) continue if contract_method is missing: continue if not callable(contract_method): diagnostics.append( Diagnostic( severity=Severity.ERROR, code="contract_unavailable", message=( f"{name}.contract exists on {type(component).__name__} " "but is not callable." ), path=ContractPath(components=(name,)), resolutions=( "Provide a callable contract() method returning " "ComponentContract.", ), ) ) continue try: contract = cast(Callable[[], object], contract_method)() except Exception as error: diagnostics.append( Diagnostic( severity=Severity.ERROR, code="contract_unavailable", message=( f"{name}.contract() on {type(component).__name__} " f"raised {type(error).__name__}: {error}." ), path=ContractPath(components=(name,)), resolutions=( "Make contract() return a ComponentContract without " "raising.", ), ) ) continue if not isinstance(contract, ComponentContract): diagnostics.append( Diagnostic( severity=Severity.ERROR, code="contract_unavailable", message=( f"{name}.contract() on {type(component).__name__} " f"returned {type(contract).__name__}, not " "ComponentContract." ), path=ContractPath(components=(name,)), resolutions=( "Make contract() return a ComponentContract instance.", ), ) ) continue contracts[name] = contract diagnostics.extend( check_component_contract( contract, component=component, path=ContractPath(components=(name,)), ) ) algorithm = getattr(self, "algorithm", None) scheduler = self.async_evaluation_scheduler from saealib.algorithms.pymoo_algorithm import PymooAlgorithm if ( isinstance(algorithm, PymooAlgorithm) and not getattr(algorithm, "allow_partial_tell", False) and scheduler is not None ): algorithm_contract = contracts.get("algorithm") if algorithm_contract is not None: diagnostics.extend( check_pymoo_feedback_compatibility( algorithm_contract, consumer_path=ContractPath(components=("algorithm",)), runtime_path=ContractPath( components=("async_evaluation_scheduler",) ), ) ) return diagnostics
@property def last_contract_diagnostics(self) -> tuple[Diagnostic, ...]: """Return the diagnostics collected by the most recent ``validate``.""" return self._last_contract_diagnostics @property def executable_plan(self) -> ExecutablePlan | None: """Return the plan produced by the most recent execution preparation.""" return self._executable_plan
[docs] def describe(self) -> str: """Describe the most recently compiled plan, if one exists.""" if self._executable_plan is None: return "Optimizer(uncompiled)" return self._executable_plan.describe()
def _compile_plan(self) -> ExecutablePlan | None: """Build and compile the configured strategy graph once per run.""" strategy = getattr(self, "strategy", None) build_graph = getattr(strategy, "build_graph", None) if not callable(build_graph): self._executable_plan = None return None graph = build_graph(self) if not isinstance(graph, ComponentGraph): self._executable_plan = None return None plan = Compiler().compile( graph, CompileContext( space=self.problem.space, problem=self.problem, offered_runtime_capabilities=default_runtime_registry.offered_capabilities( self ), initial_state_keys=OPTIMIZATION_STATE_INITIAL_KEYS, ), ) self._executable_plan = plan return plan
[docs] def set_seed(self, seed: int | None) -> Self: """Set the master random seed. Returns self.""" self.seed = seed return self
[docs] def set_history(self, channels: Sequence[str]) -> Self: """Set execution history channels and return self. On resume, the checkpoint's channels take precedence unless this method was called; calling it with a different set fails validation. Assigning to ``history_channels`` directly does not count as calling it. """ requested = tuple(channels) unknown = sorted(set(requested) - SUPPORTED_HISTORY_CHANNELS) if unknown: raise ValidationError(f"Unknown history channel(s): {', '.join(unknown)}") self.history_channels = requested self.history_channels_explicit = True return self
[docs] def set_initializer(self, initializer: Initializer) -> Self: """Set the initializer. Returns self.""" self.initializer = initializer return self
[docs] def set_algorithm(self, algorithm: Algorithm) -> Self: """Set the evolutionary algorithm. Returns self.""" self.algorithm = algorithm if self.async_evaluation_scheduler is not None: self.async_evaluation_scheduler.algorithm = algorithm return self
[docs] def set_surrogate_manager(self, manager: SurrogateManager) -> Self: """Set the surrogate manager. Returns self.""" self.surrogate_manager = manager if isinstance(manager, PairwiseSurrogateManager) and self.acquisition is None: self.acquisition = WinRateAcquisition() return self
[docs] def set_acquisition(self, acquisition) -> Self: """Set the independent acquisition component.""" self.acquisition = acquisition return self
@property def evaluation_planner(self) -> EvaluationPlanner | None: """Return the configured evaluation planner.""" return self._evaluation_planner @evaluation_planner.setter def evaluation_planner(self, planner: EvaluationPlanner | None) -> None: self._evaluation_planner = planner
[docs] def set_evaluation_planner(self, planner: EvaluationPlanner) -> Self: """Set the evaluation planner.""" self.evaluation_planner = planner return self
[docs] def set_feedback_builder(self, builder: FeedbackBuilder) -> Self: """Set the feedback builder.""" self.feedback_builder = builder self.feedback_builder_explicit = True if self.async_evaluation_scheduler is not None: self.async_evaluation_scheduler.feedback_builder = builder return self
[docs] def set_surrogate(self, surrogate: Surrogate, n_neighbors: int = 50) -> Self: """ Wrap a raw Surrogate in a LocalSurrogateManager. Returns self. Equivalent to ``set_surrogate_manager(LocalSurrogateManager(surrogate, ...))``. """ from saealib.surrogate.training_set import KNNObjectiveSet self.surrogate_manager = LocalSurrogateManager( surrogate, training_set=KNNObjectiveSet(n_neighbors=n_neighbors), ) self.acquisition = MeanPrediction(direction=self.problem.direction) return self
[docs] def set_strategy(self, strategy: OptimizationStrategy) -> Self: """Set the optimization strategy. Returns self.""" self.strategy = strategy return self
[docs] def set_evaluator(self, evaluator: Evaluator) -> Self: """Set the evaluator. Returns self.""" if evaluator.has_partial_lifecycle_override(): raise ConfigurationError( "submit(), collect(), and acknowledge() must be overridden together" ) self.evaluator = evaluator return self
[docs] def set_async_evaluation_scheduler( self, scheduler: AsyncEvaluationScheduler | None ) -> Self: """Configure asynchronous evaluation for built-in strategies.""" self.async_evaluation_scheduler = scheduler if scheduler is not None: scheduler.algorithm = getattr(self, "algorithm", None) scheduler.callback_manager = self.cbmanager scheduler.feedback_builder = self.feedback_builder return self
[docs] def set_termination(self, termination: Termination) -> Self: """Set the termination condition. Returns self.""" self.termination = termination return self
[docs] def set_instance_name(self, name: str) -> Self: """Set the instance name. Returns self.""" self.instance_name = name return self
[docs] def set_preset(self, preset: str | Path | dict) -> Self: """ Set a user-defined preset. Returns self. The preset fills components that are not already configured via ``set_*()``; explicitly set components take precedence. """ from saealib.defaults import load_preset self._preset = load_preset(preset) return self
# --- preset export ---
[docs] def save_preset(self, path: str | Path) -> Path: """ Save the currently configured components as a reusable preset file. Serializes ``algorithm``, ``surrogate_manager``, ``strategy``, and ``termination`` (whichever are set via ``set_*()``) into a preset dict and writes it as YAML. Problem-owned parameters (``dim``, ``direction``) are stripped so the preset can be reused across problems of different dimensionality. Parameters ---------- path : str or Path Destination file path. The ``.yaml`` extension is added if absent. Returns ------- Path The path the preset was written to. Raises ------ ValidationError If no components are configured, or a configured component cannot be serialized (e.g. holds a raw lambda). """ from saealib.defaults import dump_preset from saealib.registry import _strip_params, to_spec preset: dict = {} for name in ( "algorithm", "surrogate_manager", "strategy", "evaluation_planner", "feedback_builder", "termination", ): component = getattr(self, name, None) if component is None: continue try: spec = to_spec(component) except ValidationError as e: raise ValidationError( f"Cannot save preset: {name} is not serializable: {e}" ) from e preset[name] = _strip_params(spec, "dim", "direction") if not preset: raise ValidationError( "Cannot save preset: no components are configured. Call " "set_algorithm()/set_strategy()/set_surrogate_manager()/" "set_termination() first." ) return dump_preset(preset, path)
# --- callbacks ---
[docs] def dispatch(self, event: Event) -> None: """Dispatch an event to the callback manager.""" self.cbmanager.dispatch(event)
# --- run ---
[docs] def validate(self, *, require_initializer: bool = True) -> list[str]: """ Check configuration consistency. Returns list of issues. Call :meth:`resolve_defaults` first when validation results should reflect the components that will be used for execution. Parameters ---------- require_initializer : bool, optional When False, skip the initializer presence check. Use this when resuming from a checkpoint where initialization is not needed. """ issues: list[str] = [] self._validate_evaluator_lifecycle(issues) algorithm = getattr(self, "algorithm", None) strategy = getattr(self, "strategy", None) termination = getattr(self, "termination", None) surrogate_manager = getattr(self, "surrogate_manager", None) self._validate_required_components( issues, algorithm, strategy, termination, require_initializer ) self._validate_component_protocols(issues) self._validate_strategy_requirements(issues, strategy, surrogate_manager) self._validate_comparator_direction(issues) if surrogate_manager is not None: self._validate_surrogate_compatibility(issues, surrogate_manager) diagnostics = tuple(self.contract_diagnostics()) if self._executable_plan is not None: diagnostics = tuple( dict.fromkeys((*diagnostics, *self._executable_plan.diagnostics)) ) self._last_contract_diagnostics = diagnostics issues.extend( str(diagnostic) for diagnostic in self._last_contract_diagnostics if diagnostic.severity is Severity.ERROR ) return issues
def _validate_evaluator_lifecycle(self, issues: list[str]) -> None: if self.evaluator.has_partial_lifecycle_override(): issues.append( "submit(), collect(), and acknowledge() must be overridden together" ) def _validate_required_components( self, issues: list[str], algorithm: object, strategy: object, termination: object, require_initializer: bool, ) -> None: if algorithm is None: issues.append("algorithm is not set; call set_algorithm()") if strategy is None: issues.append("strategy is not set; call set_strategy()") if require_initializer and self.initializer is None: issues.append("initializer is not set; call set_initializer()") if termination is None: issues.append("termination is not set; call set_termination()") def _validate_component_protocols(self, issues: list[str]) -> None: if self.evaluation_planner is not None and not isinstance( self.evaluation_planner, EvaluationPlanner ): issues.append("evaluation_planner must be an EvaluationPlanner") if self.feedback_builder is not None and not isinstance( self.feedback_builder, FeedbackBuilder ): issues.append("feedback_builder must be a FeedbackBuilder") def _validate_strategy_requirements( self, issues: list[str], strategy: object, surrogate_manager: object, ) -> None: if ( strategy is not None and getattr(strategy, "requires_surrogate", False) and surrogate_manager is None ): issues.append( f"{type(strategy).__name__} requires a surrogate_manager; " "call set_surrogate_manager() or set_surrogate()" ) if ( strategy is not None and getattr(strategy, "requires_surrogate", False) and self.acquisition is None ): issues.append( "a provider-owned acquisition is required; call set_acquisition()" ) def _validate_comparator_direction(self, issues: list[str]) -> None: _dim = getattr(self.problem.comparator, "direction", None) if ( _dim is not None and hasattr(_dim, "__len__") and len(_dim) > 0 and len(_dim) != self.problem.n_obj ): issues.append( f"comparator direction length ({len(_dim)}) does not match " f"problem.n_obj ({self.problem.n_obj})" ) def _validate_surrogate_compatibility( self, issues: list[str], surrogate_manager: object ) -> None: acq = self.acquisition surr = getattr(surrogate_manager, "surrogate", None) if ( acq is not None and surr is not None and getattr(acq, "requires_uncertainty", False) and not getattr(surr, "provides_uncertainty", False) ): issues.append( f"{type(acq).__name__} requires uncertainty estimates but " f"{type(surr).__name__} does not provide them " "(provides_uncertainty=False)" ) for acq in self._iter_acquisitions(): _adir = getattr(acq, "direction", None) if ( _adir is not None and hasattr(_adir, "__len__") and len(_adir) > 0 and len(_adir) != self.problem.n_obj ): issues.append( f"{type(acq).__name__} direction length ({len(_adir)}) does " f"not match problem.n_obj ({self.problem.n_obj})" ) def _discover_default_hint_providers(self) -> tuple[list[Any], dict[str, object]]: """Discover default providers from the configured component graph. The component contracts describe constructor-held parts, so following ``ComponentContract.parts`` keeps discovery independent of concrete component classes and supports arbitrarily nested custom components. """ from saealib.defaults.builtin import BUILTIN_DEFAULT_PROVIDER roots = ( ("initializer", getattr(self, "initializer", None)), ("algorithm", getattr(self, "algorithm", None)), ("strategy", getattr(self, "strategy", None)), ("surrogate_manager", getattr(self, "surrogate_manager", None)), ("acquisition", getattr(self, "acquisition", None)), ("comparator", self.problem.comparator), ("termination", getattr(self, "termination", None)), ("evaluator", getattr(self, "evaluator", None)), ("evaluation_planner", getattr(self, "evaluation_planner", None)), ("feedback_builder", getattr(self, "feedback_builder", None)), ( "async_evaluation_scheduler", getattr(self, "async_evaluation_scheduler", None), ), ) providers: list[Any] = [BUILTIN_DEFAULT_PROVIDER] components: dict[str, object] = {} visited: set[int] = set() provider_ids = {id(BUILTIN_DEFAULT_PROVIDER)} def visit( component: object, path: str, declared_contract: ComponentContract | None = None, ) -> None: if component is None: return # Keep every reachable path in the context even when a component # instance is shared by multiple roots or parts. Providers and # child contracts are still visited only once per instance. components[path] = component if id(component) in visited: return visited.add(id(component)) try: default_hints = getattr(component, "default_hints", None) except Exception as error: raise ConfigurationError( f"Cannot inspect {path}.default_hints: " f"{type(error).__name__}: {error}" ) from error if callable(default_hints) and id(component) not in provider_ids: providers.append(component) provider_ids.add(id(component)) contract = declared_contract if contract is None: try: contract_method = getattr(component, "contract", None) except Exception as error: raise ConfigurationError( f"Cannot inspect {path}.contract: " f"{type(error).__name__}: {error}" ) from error if not callable(contract_method): return try: contract = cast(ComponentContract, contract_method()) except Exception as error: raise ConfigurationError( f"Cannot inspect {path}.contract(): " f"{type(error).__name__}: {error}" ) from error if not isinstance(contract, ComponentContract): raise ConfigurationError( f"{path}.contract() returned " f"{type(contract).__name__}, expected ComponentContract" ) for part in contract.parts: part_path = f"{path}.{part.name}" try: child = getattr(component, part.name) except AttributeError as error: if part.optional: continue raise ConfigurationError( f"Cannot discover component part {part_path!r}: " f"attribute is missing" ) from error except Exception as error: raise ConfigurationError( f"Cannot inspect component part {part_path!r}: " f"{type(error).__name__}: {error}" ) from error if child is None: if part.optional: continue raise ConfigurationError( f"Cannot discover component part {part_path!r}: " "required part is None" ) visit(child, part_path, part.contract) for name, component in roots: if component is not None: visit(component, name) return providers, components
[docs] def resolve_defaults(self) -> None: """Fill unset components with library defaults (Registry + presets file). Components already set via ``set_*()`` are never overwritten. Gaps are filled first from a user preset (``set_preset()``), if any, then from a bundled preset selected by (1) the algorithm's registered name if ``algorithm`` is set, else (2) a Problem-shape rule, else (3) the universal fallback preset. ``initializer`` is computed from semantic defaults and is not part of any preset; ``termination`` falls back to the resolved maximum-evaluations default (``200 * problem.dim`` unless a provider recommends another value) only if neither ``set_*()`` nor a preset supplies one. """ from saealib.defaults import load_defaults from saealib.registry import _inject_params, build algorithm = getattr(self, "algorithm", None) strategy = getattr(self, "strategy", None) surrogate_manager = getattr(self, "surrogate_manager", None) bundled_preset_name = None user_preset = getattr(self, "_preset", None) if user_preset is not None: dim = self.problem.dim direction = self.problem.direction if algorithm is None and "algorithm" in user_preset: algorithm = build( _inject_params( user_preset["algorithm"], dim=dim, direction=direction ) ) self.algorithm = algorithm if surrogate_manager is None and "surrogate_manager" in user_preset: surrogate_manager, acquisition = self._build_components_from_spec( user_preset["surrogate_manager"] ) self.surrogate_manager = surrogate_manager if self.acquisition is None: self.acquisition = acquisition if strategy is None and "strategy" in user_preset: strategy = build( _inject_params( user_preset["strategy"], dim=dim, direction=direction ) ) self.strategy = strategy if self.evaluation_planner is None: spec = user_preset.get("evaluation_planner") if spec is not None: self.evaluation_planner = build(spec) if self.feedback_builder is None and "feedback_builder" in user_preset: self.feedback_builder = build(user_preset["feedback_builder"]) self.feedback_builder_explicit = True if ( getattr(self, "termination", None) is None and "termination" in user_preset ): self.termination = build( _inject_params( user_preset["termination"], dim=dim, direction=direction ) ) use_bundled_policies = strategy is None if ( algorithm is None or strategy is None or surrogate_manager is None or self.evaluation_planner is None or self.feedback_builder is None ): defaults = load_defaults() bundled_preset_name = self._select_preset_name(defaults, algorithm) preset = defaults["presets"][bundled_preset_name] if algorithm is None and "algorithm" in preset: self.algorithm = build( _inject_params( preset["algorithm"], dim=self.problem.dim, direction=self.problem.direction, ) ) if surrogate_manager is None and "surrogate_manager" in preset: manager, acquisition = self._build_components_from_spec( preset["surrogate_manager"] ) self.surrogate_manager = manager if self.acquisition is None: self.acquisition = acquisition if strategy is None and "strategy" in preset: self.strategy = build( _inject_params( preset["strategy"], dim=self.problem.dim, direction=self.problem.direction, ) ) if ( use_bundled_policies and self.evaluation_planner is None and "evaluation_planner" in preset ): self.evaluation_planner = build(preset["evaluation_planner"]) if ( use_bundled_policies and self.feedback_builder is None and "feedback_builder" in preset ): self.feedback_builder = build(preset["feedback_builder"]) from saealib.defaults.context import DefaultContext from saealib.defaults.keys import ( INITIAL_ARCHIVE_SIZE, MAX_EVALUATIONS, POPULATION_SIZE, ) from saealib.defaults.model import ( DefaultHint, DefaultResolution, DefaultStrength, ResolvedDefault, ) from saealib.defaults.resolver import DEFAULT_RESOLVER from saealib.execution.initializer import LHSInitializer # Resolve semantic defaults for the complete configured composition, # even when materialization of one of the defaults is unnecessary # because the user supplied that component explicitly. providers, components = self._discover_default_hint_providers() ctx = DefaultContext( problem=self.problem, seed=self.seed, components=components, ) resolution = DEFAULT_RESOLVER.resolve(ctx, providers) dim = self.problem.dim n_population = resolution.get(POPULATION_SIZE, 4 * dim) n_archive = resolution.get(INITIAL_ARCHIVE_SIZE, 5 * dim) # Ensure archive is at least as large as population, and retain that # invariant in the provenance trace as well. This semantic step must # run even when the initializer was supplied explicitly: otherwise a # second resolve_defaults() call would replace the normalized trace # with the raw resolver result. if n_archive < n_population: archive_resolution = resolution.resolved.get(INITIAL_ARCHIVE_SIZE) if ( archive_resolution is not None and archive_resolution.selected_hint.strength == DefaultStrength.REQUIRED ): selected = archive_resolution.selected_hint raise ConfigurationError( "REQUIRED default for " f"{INITIAL_ARCHIVE_SIZE.name!r} ({n_archive}) from " f"{selected.source!r} is smaller than the resolved " f"population size ({n_population})" ) adjusted_hint = DefaultHint( key=INITIAL_ARCHIVE_SIZE, value=n_population, strength=DefaultStrength.REQUIRED, source="optimizer", reason=( f"Raised to match the resolved population size: {n_population}" ), ) resolution = DefaultResolution( values={ **resolution.values, INITIAL_ARCHIVE_SIZE: n_population, }, resolved={ **resolution.resolved, INITIAL_ARCHIVE_SIZE: ResolvedDefault( key=INITIAL_ARCHIVE_SIZE, value=n_population, selected_hint=adjusted_hint, alternatives=( archive_resolution.alternatives if archive_resolution is not None else () ), ), }, ) n_archive = n_population if self.initializer is None: self.initializer = LHSInitializer( n_init_archive=n_archive, n_init_population=n_population, seed=self.seed ) self._default_resolution = resolution if getattr(self, "termination", None) is None: max_evaluations = resolution.get(MAX_EVALUATIONS, 200 * dim) self.termination = Termination(max_fe_cond(max_evaluations)) if self.async_evaluation_scheduler is not None: self.async_evaluation_scheduler.algorithm = self.algorithm component_types = { name: type(component).__name__ for name in ( "algorithm", "strategy", "surrogate_manager", "acquisition", "evaluation_planner", "feedback_builder", "termination", "initializer", ) if (component := getattr(self, name, None)) is not None } logger.debug( "Resolved defaults: bundled_preset=%s components=%s", bundled_preset_name, component_types, )
def _resolve_defaults(self) -> None: """Compatibility wrapper for :meth:`resolve_defaults`.""" self.resolve_defaults() def _inject_acquisition_directions(self) -> None: """Auto-inject ``problem.direction`` into unset acquisition directions. Mirrors the "inherit from problem unless explicitly set" contract used for ``NSGA3Comparator.rng`` and ``SingleObjectiveComparator.direction``: an acquisition function that already has an explicit ``direction`` (or opts out via ``direction_sensitive = False``) is left untouched. """ for acq in self._iter_acquisitions(): if ( getattr(acq, "direction_sensitive", True) and getattr(acq, "direction", None) is None ): acq.direction = self.problem.direction def _iter_acquisitions(self): """Yield provider-owned acquisitions, including composite children.""" acquisition = self.acquisition if acquisition is None: return if isinstance(acquisition, CompositeAcquisition): yield from acquisition.acquisitions.values() else: yield acquisition def _select_preset_name(self, defaults: dict, algorithm: Algorithm | None) -> str: from saealib.defaults.loader import select_preset_name algorithm_name = None if algorithm is None else type(algorithm).__name__ return select_preset_name(defaults, self.problem, algorithm_name) def _build_components_from_spec( self, spec: dict ) -> tuple[SurrogateManager, AcquisitionFunction]: return self._build_components_from_spec_static( spec, self.problem.dim, self.problem.direction ) @staticmethod def _build_components_from_spec_static( spec: dict, dim: int, direction, *, surrogate: Surrogate | None = None ) -> tuple[SurrogateManager, AcquisitionFunction]: from saealib.registry import _inject_params, build manager_spec = copy.deepcopy(spec) params = manager_spec.setdefault("params", {}) if surrogate is None: params.setdefault( "surrogate", { "type": "RBFSurrogate", "params": {"kernel": GaussianKernel()}, }, ) else: params["surrogate"] = surrogate acquisition_spec = params.pop("acquisition", None) if acquisition_spec is None: acquisition_spec = _default_acquisition_spec( manager_spec.get("type"), direction ) manager_spec = _inject_params(manager_spec, dim=dim, direction=direction) acquisition_spec = _inject_params( acquisition_spec, dim=dim, direction=direction ) return build(manager_spec), build(acquisition_spec) def _register_checkpoint( self, path: str | Path, interval: int, format: str, delete_on_success: bool, ) -> None: from saealib.checkpoint import CheckpointCallback cb = CheckpointCallback( path=path, interval=interval, format=format, delete_on_success=delete_on_success, optimizer=self if format in ("pickle", "both") else None, ) cb.register(self.cbmanager) def _validate_checkpointable(self) -> None: """Reject portable checkpoints for components with non-exportable state.""" plan = self._executable_plan if plan is None: return snapshots = dict(plan.contract_snapshots) rejected = [] for component_id, contract in snapshots.items(): if not contract.assumptions["state.checkpointable"]: component = plan.graph.node_by_id(component_id).component component_name = getattr(component, "component", component) rejected.append(f"{component_id} ({type(component_name).__name__})") if rejected: raise ConfigurationError( "Portable checkpointing requires state.checkpointable=True; " "non-checkpointable component(s): " + ", ".join(rejected) )
[docs] def iterate( self, checkpoint_path: str | Path | None = None, checkpoint_interval: int = 1, checkpoint_format: str = "npz", checkpoint_delete_on_success: bool = False, ) -> Generator[OptimizationState, None, None]: """ Iterate the optimization process. Parameters ---------- checkpoint_path : str, Path, or None, optional If provided, checkpoints are saved to this directory every *checkpoint_interval* generations. checkpoint_interval : int, optional Generations between checkpoints. Default: 1. checkpoint_format : {'npz', 'pickle', 'both'}, optional Checkpoint format. Default: ``'npz'``. checkpoint_delete_on_success : bool, optional Delete checkpoints on normal termination. Default: False. Returns ------- Generator[OptimizationState] Generator of OptimizationState. """ self.resolve_defaults() self._compile_plan() issues = self.validate() if issues: raise ConfigurationError( "Optimizer misconfigured:\n" + "\n".join(f" - {m}" for m in issues) ) self._inject_acquisition_directions() if checkpoint_path is not None: if checkpoint_format in ("npz", "both"): self._validate_checkpointable() self._register_checkpoint( checkpoint_path, checkpoint_interval, checkpoint_format, checkpoint_delete_on_success, ) return Runner(self).iterate()
[docs] def run( self, checkpoint_path: str | Path | None = None, checkpoint_interval: int = 1, checkpoint_format: str = "npz", checkpoint_delete_on_success: bool = False, ) -> OptimizationState: """ Run the optimization process. Parameters ---------- checkpoint_path : str, Path, or None, optional If provided, checkpoints are saved to this directory every *checkpoint_interval* generations. checkpoint_interval : int, optional Generations between checkpoints. Default: 1. checkpoint_format : {'npz', 'pickle', 'both'}, optional Checkpoint format. Default: ``'npz'``. checkpoint_delete_on_success : bool, optional Delete checkpoints on normal termination. Default: False. Returns ------- OptimizationState The optimization context. """ logger.info( "Optimization run started: dim=%s n_obj=%s", self.problem.dim, self.problem.n_obj, ) self.resolve_defaults() self._compile_plan() issues = self.validate() if issues: raise ConfigurationError( "Optimizer misconfigured:\n" + "\n".join(f" - {m}" for m in issues) ) self._inject_acquisition_directions() if checkpoint_path is not None: if checkpoint_format in ("npz", "both"): self._validate_checkpointable() self._register_checkpoint( checkpoint_path, checkpoint_interval, checkpoint_format, checkpoint_delete_on_success, ) state = Runner(self).run() logger.info( "Optimization run finished: fe=%s gen=%s", getattr(state, "fe", None), getattr(state, "gen", None), ) return state
[docs] def iterate_from( self, ctx: OptimizationState, checkpoint_path: str | Path | None = None, checkpoint_interval: int = 1, checkpoint_format: str = "npz", checkpoint_delete_on_success: bool = False, ) -> Generator[OptimizationState, None, None]: """ Resume iteration from an existing context (e.g. loaded from checkpoint). Does not call the initializer; all other components must be configured. Parameters ---------- ctx : OptimizationState Context to resume from. checkpoint_path : str, Path, or None, optional If provided, checkpoints are saved to this directory every *checkpoint_interval* generations. checkpoint_interval : int, optional Generations between checkpoints. Default: 1. checkpoint_format : {'npz', 'pickle', 'both'}, optional Checkpoint format. Default: ``'npz'``. checkpoint_delete_on_success : bool, optional Delete checkpoints on normal termination. Default: False. Returns ------- Generator[OptimizationState, None, None] """ self._compile_plan() issues = self.validate(require_initializer=False) if issues: raise ConfigurationError( "Optimizer misconfigured:\n" + "\n".join(f" - {m}" for m in issues) ) self._inject_acquisition_directions() if checkpoint_path is not None: if checkpoint_format in ("npz", "both"): self._validate_checkpointable() self._register_checkpoint( checkpoint_path, checkpoint_interval, checkpoint_format, checkpoint_delete_on_success, ) return Runner(self).iterate_from(ctx)
[docs] def run_from( self, ctx: OptimizationState, checkpoint_path: str | Path | None = None, checkpoint_interval: int = 1, checkpoint_format: str = "npz", checkpoint_delete_on_success: bool = False, ) -> OptimizationState: """ Resume and run to completion from an existing context. Parameters ---------- ctx : OptimizationState Context to resume from. checkpoint_path : str, Path, or None, optional If provided, checkpoints are saved to this directory every *checkpoint_interval* generations. checkpoint_interval : int, optional Generations between checkpoints. Default: 1. checkpoint_format : {'npz', 'pickle', 'both'}, optional Checkpoint format. Default: ``'npz'``. checkpoint_delete_on_success : bool, optional Delete checkpoints on normal termination. Default: False. Returns ------- OptimizationState The final optimization context. """ self._compile_plan() issues = self.validate(require_initializer=False) if issues: raise ConfigurationError( "Optimizer misconfigured:\n" + "\n".join(f" - {m}" for m in issues) ) self._inject_acquisition_directions() if checkpoint_path is not None: if checkpoint_format in ("npz", "both"): self._validate_checkpointable() self._register_checkpoint( checkpoint_path, checkpoint_interval, checkpoint_format, checkpoint_delete_on_success, ) return Runner(self).run_from(ctx)
# ------------------------------------------------------------------ # Checkpoint: pickle (limited complete reproducibility) # ------------------------------------------------------------------ _PICKLE_WARNING = ( "Pickle checkpoints are version-sensitive. " "Reproducibility is only guaranteed within the same Python " "and library versions." ) def __getstate__(self) -> dict[str, object]: """Exclude the compiled graph plan from legacy pickle checkpoints.""" state = self.__dict__.copy() state["_executable_plan"] = None return state def __setstate__(self, state: dict[str, object]) -> None: """Restore an optimizer without a stale compiled execution plan.""" self.__dict__.update(state) self.__dict__.setdefault("_executable_plan", None) self.__dict__.setdefault("_default_resolution", None)
[docs] def save_pickle(self, ctx: OptimizationState, path: str | Path) -> None: """ Save the optimizer and context together as a pickle checkpoint. This preserves fitted surrogate state and all component objects, offering complete reproducibility within the same environment. .. warning:: Pickle files are tied to specific Python and library versions. Use :meth:`OptimizationState.save` for a more portable format. Parameters ---------- ctx : OptimizationState Current optimization context. path : str or Path Destination file path. The ``.pkl`` extension is added if absent. """ warnings.warn(self._PICKLE_WARNING, UserWarning, stacklevel=2) p = Path(path) if not p.suffix: p = p.with_suffix(".pkl") with open(p, "wb") as f: pickle.dump((self, ctx), f)
[docs] @classmethod def load_pickle(cls, path: str | Path) -> tuple[Optimizer, OptimizationState]: """ Load an optimizer and context from a pickle checkpoint. The returned context has ``resumed=True``. Call :meth:`run_from` or :meth:`iterate_from` on the returned optimizer to continue the optimization. .. warning:: Pickle files are tied to specific Python and library versions. Parameters ---------- path : str or Path Path to the ``.pkl`` file. The extension is added if absent. Returns ------- tuple[Optimizer, OptimizationState] """ warnings.warn(cls._PICKLE_WARNING, UserWarning, stacklevel=2) p = Path(path) if not p.suffix: p = p.with_suffix(".pkl") with open(p, "rb") as f: optimizer, ctx = pickle.load(f) ctx = dataclasses.replace(ctx, data={**ctx.data, "resumed": True}) return optimizer, ctx
# ------------------------------------------------------------------ # Construction: from a problem definition file # ------------------------------------------------------------------
[docs] @classmethod def from_problem_file( cls, problem_path: str | Path, preset: str | Path | dict | None = None, ) -> Optimizer: """ Build an Optimizer from a Python file defining a problem. The file at *problem_path* is executed as a standalone module (it is not registered in ``sys.modules``) and must define a top-level ``problem`` variable holding a :class:`~saealib.problem.Problem` instance. If *preset* is given, it is applied first via :meth:`set_preset`; any of the top-level variables ``algorithm``, ``strategy``, ``surrogate_manager``, ``termination`` defined in the file are then applied via the corresponding ``set_*()`` method, so the file can override individual components of the preset. A top-level ``seed`` variable, if present, is passed to the ``Optimizer`` constructor. Parameters ---------- problem_path : str or Path Path to a ``.py`` file defining a top-level ``problem`` variable. preset : str, Path, dict, or None, optional Preset applied before the file's own component definitions, so that the file can override individual components. See :meth:`set_preset`. Returns ------- Optimizer A configured, not-yet-run ``Optimizer`` instance. Call ``run()`` or ``iterate()`` on it. Raises ------ ValidationError If the file does not define a top-level ``problem`` variable, or it is not a ``Problem`` instance. Also raised if a top-level ``algorithm``, ``strategy``, ``surrogate_manager``, ``termination``, or ``seed`` variable is defined but is not an instance of its expected type. FileNotFoundError If *problem_path* does not exist. Examples -------- >>> # problem.py: >>> # problem = Problem(func=..., dim=2, n_obj=1, direction=[-1]) >>> # seed = 42 >>> opt = Optimizer.from_problem_file("problem.py", preset="preset.yaml") >>> ctx = opt.run() # doctest: +SKIP """ from saealib.algorithms.base import Algorithm from saealib.problem import Problem from saealib.strategies.base import OptimizationStrategy from saealib.surrogate.manager import SurrogateManager from saealib.termination import Termination p = Path(problem_path) spec = importlib.util.spec_from_file_location(p.stem, p) if spec is None or spec.loader is None: raise ValidationError(f"Cannot load module from {p}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) problem = getattr(module, "problem", None) if not isinstance(problem, Problem): raise ValidationError( f"{p} must define a top-level `problem` variable holding a " "Problem instance" ) seed = getattr(module, "seed", None) if seed is not None and not isinstance(seed, int): raise ValidationError( f"{p}: top-level 'seed' must be an int instance, " f"got {type(seed).__name__}" ) opt = cls(problem, seed=seed) if seed is not None else cls(problem) if preset is not None: opt.set_preset(preset) for name, setter, expected in ( ("algorithm", opt.set_algorithm, Algorithm), ("strategy", opt.set_strategy, OptimizationStrategy), ("surrogate_manager", opt.set_surrogate_manager, SurrogateManager), ("evaluation_planner", opt.set_evaluation_planner, EvaluationPlanner), ("feedback_builder", opt.set_feedback_builder, FeedbackBuilder), ("termination", opt.set_termination, Termination), ): component = getattr(module, name, None) if component is not None: if not isinstance(component, expected): raise ValidationError( f"{p}: top-level '{name}' must be a {expected.__name__} " f"instance, got {type(component).__name__}" ) setter(component) return opt