Source code for hydromodpy.calibration.optim.optimizer

"""Optimizer Protocol and registry.

An optimizer proposes parameter points (``ask``) and ingests evaluation
results (``tell``). Adapters for scipy, optuna, grid-search are found under
``hydromodpy/calibration/adapters/`` and registered here.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from importlib.metadata import entry_points
from typing import Literal, Protocol, runtime_checkable

FAILED_EVAL_COST: float = 1e12
"""Sentinel cost used when an evaluation fails or returns NaN.

A large finite penalty propagates safely through CMA-ES, scipy and DA-MH-GP
adapters; NaN would poison their internal updates.
"""


[docs] @dataclass(frozen=True, slots=True) class ParamSuggestion: """Candidate parameter point proposed by an optimizer. ``values`` maps calibrated parameter names to physical values, not transformed coordinates. ``trial_id`` is stable within one optimizer run and is used to join suggestions, evaluations, and persisted iterations. """ trial_id: int values: Mapping[str, float] source: str = "ask"
[docs] @dataclass(frozen=True, slots=True) class EvaluationResult: """Objective result produced after evaluating one suggestion. The result stores the scalar minimization cost, the optional simulation id, execution status, timing, component diagnostics, cache provenance, and any backend-specific metadata needed for reports. """ trial_id: int sim_id: str | None objective_value: float status: str = "completed" duration_s: float = 0.0 components: Mapping[str, float] | None = None from_cache: bool = False metadata: Mapping[str, object] = field(default_factory=dict)
[docs] @runtime_checkable class Optimizer(Protocol): """Ask/tell Protocol implemented by optimizer adapters. ``ask`` proposes one or more parameter points. ``tell`` feeds completed evaluations back to the optimizer. ``best`` and ``converged`` expose the state needed by ``CalibrationEngine``. """ name: str def ask(self, n: int = 1) -> list[ParamSuggestion]: ... def tell(self, results: list[EvaluationResult]) -> None: ... def suggest_next(self) -> ParamSuggestion: ... def best(self) -> EvaluationResult | None: ... def converged(self) -> bool: ...
_BUILTIN: dict[str, Callable[..., Optimizer]] = {}
[docs] def register_optimizer(name: str) -> Callable[[type], type]: """Register a built-in optimizer adapter under a public method name. Adapter modules call this decorator at import time. The registered name is the value accepted by ``CalibrationConfig.method`` and ``build_optimizer``. """ def deco(cls: type) -> type: _BUILTIN[name] = cls return cls return deco
@dataclass(frozen=True) class EngineTraits: """What a search engine can and cannot be handed. An engine refuses an impossible pairing in its constructor, which is right but late: a staged calibration builds phase two's optimizer only when phase two starts, after phase one has spent its whole solve budget. Declaring the same facts here lets a check read them before anything solves. The defaults are permissive on purpose: an engine that constrains nothing declares nothing, and a third-party one that says nothing is taken at its word rather than assumed to be limited. """ max_parameters: int | None = None """How many parameters the engine can move at once. ``None`` means any number.""" required_transform: str | None = None """The sampling transform the engine's stopping rule is written in.""" needs_signed_residual: bool = False """Whether the engine reads a signed residual the criterion has to publish.""" supports_parallel: bool = True """Whether several trials of one batch can be evaluated at once.""" tolerance_option: str | None = None """Name of this engine's own option that a user-level precision writes. ``None`` says the engine has no parameter-side stopping rule at all, and a declared precision is then refused rather than quietly dropped: a search that stops on its budget cannot honour a precision, and saying so is the only honest answer.""" accepts_a_start_point: bool = False """Whether the engine can be told where to begin, in transformed space. Only two do, and it is what a restart-based uncertainty needs: repeating a search that always begins at the same point returns the same answer, so the spread it would report would be zero by construction.""" restarts_explore_differently: bool = True """Whether repeating this engine can land anywhere else. Permissive by default, like every trait here: a stochastic sampler explores differently on a new seed and says nothing. An exhaustive sweep and a root search declare ``False``, because repeating them is the same computation twice and reporting its spread as an uncertainty would be a lie about a certainty.""" tolerance_reads: Literal["search_width", "relative_value"] | None = None """How that option reads its number. ``search_width`` is an absolute width in the space the search walks, so the precision is converted into that space. ``relative_value`` is already a relative width on the parameter's own value, so the number passes through.""" DEFAULT_ENGINE_TRAITS = EngineTraits() def engine_traits(name: str) -> EngineTraits: """Return what the engine registered under ``name`` declares about itself.""" _ensure_builtins_loaded() engine = _BUILTIN.get(name) if engine is None: for ep in entry_points(group="hydromodpy.optimizer"): if ep.name == name: try: engine = ep.load() except Exception: # noqa: BLE001 - an engine that will not load has no traits return DEFAULT_ENGINE_TRAITS break declared = getattr(engine, "traits", None) return declared if isinstance(declared, EngineTraits) else DEFAULT_ENGINE_TRAITS
[docs] def build_optimizer(name: str, space, **kwargs) -> Optimizer: """Construct an optimizer by name. Looks up built-ins first, then ``hydromodpy.optimizer`` entry points. """ # Lazy-load adapters so missing optionals do not break import. _ensure_builtins_loaded() if name in _BUILTIN: return _BUILTIN[name](space, **kwargs) for ep in entry_points(group="hydromodpy.optimizer"): if ep.name == name: return ep.load()(space, **kwargs) raise KeyError(f"Unknown optimizer: {name!r}. Available built-ins: {sorted(_BUILTIN)}")
[docs] def available_optimizers() -> tuple[str, ...]: """Return registered optimizer names, including installed entry points.""" _ensure_builtins_loaded() names = set(_BUILTIN) names.update(ep.name for ep in entry_points(group="hydromodpy.optimizer")) return tuple(sorted(names))
_LOADED = False def _ensure_builtins_loaded() -> None: """Auto-discover every adapter module under ``calibration/adapters/``. Each adapter registers itself via ``@register_optimizer`` at import time. Optional dependencies (optuna, GP, DA-MH-GP) surface as ``ImportError``; those adapters just stay unregistered. """ global _LOADED if _LOADED: return import importlib import pkgutil from hydromodpy.calibration import adapters from hydromodpy.core.logging import get_logger logger = get_logger(__name__) for module_info in pkgutil.iter_modules(adapters.__path__): name = module_info.name if name.startswith("_"): continue try: importlib.import_module(f"{adapters.__name__}.{name}") except ImportError as exc: logger.debug("Optional optimizer adapter %r skipped: %s", name, exc) _LOADED = True