Akiyue's picture
Add files using upload-large-folder tool
f325414 verified
Raw
History Blame Contribute Delete
4.46 kB
"""Abstract optimizer interface and result/history dataclasses.
Every optimizer in :mod:`ahdcma.algorithms` (DOA, CMA-ES wrapper, PSO, GWO,
WOA, SCSO, GEGO, I-HAHO, AHD-CMA) extends :class:`Optimizer`. The contract
is intentionally narrow — a single :meth:`Optimizer.optimize` call returns
:class:`OptimizationResult`, which fully describes the run for downstream
statistics and figures.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from numpy.typing import NDArray
FitnessFn = Callable[[NDArray[np.float64]], float]
@dataclass
class SearchSpace:
"""Continuous search-space description.
Optimizers operate in the unit hyper-cube ``[lower, upper]^d``. The
``names`` list is optional metadata for logging.
"""
dim: int
lower: NDArray[np.float64]
upper: NDArray[np.float64]
names: Sequence[str] | None = None
def __post_init__(self) -> None:
if self.lower.shape != (self.dim,) or self.upper.shape != (self.dim,):
raise ValueError(
f"lower/upper must have shape ({self.dim},); got "
f"{self.lower.shape} and {self.upper.shape}"
)
if np.any(self.lower >= self.upper):
raise ValueError("each lower bound must be strictly below the upper bound")
@classmethod
def unit_cube(cls, dim: int, names: Sequence[str] | None = None) -> SearchSpace:
"""Convenience constructor: ``[0, 1]^dim``."""
return cls(
dim=dim,
lower=np.zeros(dim, dtype=np.float64),
upper=np.ones(dim, dtype=np.float64),
names=names,
)
def clip(self, x: NDArray[np.float64]) -> NDArray[np.float64]:
"""Project ``x`` (any shape ending in ``dim``) into the box."""
return np.clip(x, self.lower, self.upper)
@dataclass
class History:
"""Per-generation run trace.
Each list has length equal to the number of completed generations.
``populations`` and ``fitnesses`` are ragged in principle (CMA-ES may
grow the population), but in practice all our algorithms hold it fixed.
"""
generations: list[int] = field(default_factory=list)
populations: list[NDArray[np.float64]] = field(default_factory=list)
fitnesses: list[NDArray[np.float64]] = field(default_factory=list)
best_fitness: list[float] = field(default_factory=list)
mode_per_gen: list[str] = field(default_factory=list)
entropy_per_gen: list[float] = field(default_factory=list)
ruggedness_per_gen: list[float] = field(default_factory=list)
def append(
self,
generation: int,
population: NDArray[np.float64],
fitness: NDArray[np.float64],
*,
mode: str = "",
entropy: float = float("nan"),
ruggedness: float = float("nan"),
) -> None:
"""Record a single generation."""
self.generations.append(int(generation))
self.populations.append(np.asarray(population, dtype=np.float64).copy())
self.fitnesses.append(np.asarray(fitness, dtype=np.float64).copy())
self.best_fitness.append(float(np.min(fitness)))
self.mode_per_gen.append(mode)
self.entropy_per_gen.append(float(entropy))
self.ruggedness_per_gen.append(float(ruggedness))
def __len__(self) -> int:
return len(self.generations)
@dataclass
class OptimizationResult:
"""Final outcome of a single :meth:`Optimizer.optimize` call."""
best_x: NDArray[np.float64]
best_f: float
history: History
config_snapshot: Mapping[str, Any]
run_id: str
wall_time: float
class Optimizer(ABC):
"""Abstract base class. Subclasses implement :meth:`optimize`."""
def __init__(
self,
config: Mapping[str, Any],
fitness_fn: FitnessFn,
search_space: SearchSpace,
*,
run_id: str = "anonymous",
) -> None:
self.config = dict(config)
self.fitness_fn = fitness_fn
self.search_space = search_space
self.run_id = run_id
self._history = History()
@abstractmethod
def optimize(self) -> OptimizationResult:
"""Run the optimization loop and return the result."""
def get_history(self) -> History:
"""Return the recorded :class:`History`."""
return self._history