diff --git a/.tools/create_algo_selection_code.py b/.tools/create_algo_selection_code.py index 6d28fde6d..17e79989f 100644 --- a/.tools/create_algo_selection_code.py +++ b/.tools/create_algo_selection_code.py @@ -109,7 +109,7 @@ def _get_algorithms_in_module(module: ModuleType) -> dict[str, Type[Algorithm]]: } algos = {} for candidate in candidate_dict.values(): - name = candidate.__algo_info__.name + name = candidate.algo_info.name if issubclass(candidate, Algorithm) and candidate is not Algorithm: algos[name] = candidate return algos @@ -119,7 +119,7 @@ def _get_algorithms_in_module(module: ModuleType) -> dict[str, Type[Algorithm]]: # Functions to filter algorithms by selectors # ====================================================================================== def _is_gradient_based(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.needs_jac # type: ignore + return algo.algo_info.needs_jac # type: ignore def _is_gradient_free(algo: Type[Algorithm]) -> bool: @@ -127,7 +127,7 @@ def _is_gradient_free(algo: Type[Algorithm]) -> bool: def _is_global(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.is_global # type: ignore + return algo.algo_info.is_global # type: ignore def _is_local(algo: Type[Algorithm]) -> bool: @@ -135,31 +135,31 @@ def _is_local(algo: Type[Algorithm]) -> bool: def _is_bounded(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.supports_bounds # type: ignore + return algo.algo_info.supports_bounds # type: ignore def _is_linear_constrained(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.supports_linear_constraints # type: ignore + return algo.algo_info.supports_linear_constraints # type: ignore def _is_nonlinear_constrained(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.supports_nonlinear_constraints # type: ignore + return algo.algo_info.supports_nonlinear_constraints # type: ignore def _is_scalar(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.solver_type == AggregationLevel.SCALAR # type: ignore + return algo.algo_info.solver_type == AggregationLevel.SCALAR # type: ignore def _is_least_squares(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.solver_type == AggregationLevel.LEAST_SQUARES # type: ignore + return algo.algo_info.solver_type == AggregationLevel.LEAST_SQUARES # type: ignore def _is_likelihood(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.solver_type == AggregationLevel.LIKELIHOOD # type: ignore + return algo.algo_info.solver_type == AggregationLevel.LIKELIHOOD # type: ignore def _is_parallel(algo: Type[Algorithm]) -> bool: - return algo.__algo_info__.supports_parallelism # type: ignore + return algo.algo_info.supports_parallelism # type: ignore def _get_filters() -> dict[str, Callable[[Type[Algorithm]], bool]]: @@ -385,27 +385,32 @@ def _all(self) -> list[Type[Algorithm]]: def _available(self) -> list[Type[Algorithm]]: _all = self._all() return [ - a for a in _all if a.__algo_info__.is_available # type: ignore + a for a in _all if a.algo_info.is_available # type: ignore ] @property - def All(self) -> list[str]: - return [a.__algo_info__.name for a in self._all()] # type: ignore + def All(self) -> list[Type[Algorithm]]: + return self._all() @property - def Available(self) -> list[str]: - return [a.__algo_info__.name for a in self._available()] # type: ignore + def Available(self) -> list[Type[Algorithm]]: + return self._available() + + @property + def AllNames(self) -> list[str]: + return [str(a.name) for a in self._all()] + + @property + def AvailableNames(self) -> list[str]: + return [str(a.name) for a in self._available()] @property def _all_algorithms_dict(self) -> dict[str, Type[Algorithm]]: - return {a.__algo_info__.name: a for a in self._all()} # type: ignore + return {str(a.name): a for a in self._all()} @property def _available_algorithms_dict(self) -> dict[str, Type[Algorithm]]: - return { - a.__algo_info__.name: a # type: ignore - for a in self._available() - } + return {str(a.name): a for a in self._available()} """) return out diff --git a/CHANGES.md b/CHANGES.md index 5d6f8fc09..0aa867272 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,9 +4,27 @@ This is a record of all past optimagic releases and what went into them in rever chronological order. We follow [semantic versioning](https://semver.org/) and all releases are available on [Anaconda.org](https://anaconda.org/optimagic-dev/optimagic). -Following the [scientific python guidelines](https://scientific-python.org/specs/spec-0000/) -we drop the official support for Python 3.9. +## 0.5.1 + +This is a minor release that introduces the new algorithm selection tool and several +small improvements. + +To learn more about the algorithm selection feature check out the following resources: + +- [How to specify and configure algorithms](https://optimagic.readthedocs.io/en/latest/how_to/how_to_specify_algorithm_and_algo_options.html) +- [How to select local optimizers](https://optimagic.readthedocs.io/en/latest/how_to/how_to_algorithm_selection.html) + +- {gh}`549` Add support for Python 3.13 ({ghuser}`timmens`) +- {gh}`550` and {gh}`534` implement the new algorithm selection tool ({ghuser}`janosg`) +- {gh}`548` and {gh}`531` improve the documentation ({ghuser}`ChristianZimpelmann`) +- {gh}`544` Adjusts the results processing of the nag optimizers to be compatible + with the latest releases ({ghuser}`timmens`) +- {gh}`543` Adds support for numpy 2.x ({ghuser}`timmens`) +- {gh}`536` Adds a how-to guide for choosing local optimizers ({ghuser}`mpetrosian`) +- {gh}`535` Allows algorithm classes and instances in estimation functions + ({ghuser}`timmens`) +- {gh}`532` Makes several small improvements to the documentation. ## 0.5.0 diff --git a/src/estimagic/__init__.py b/src/estimagic/__init__.py index 056f823e4..44a640486 100644 --- a/src/estimagic/__init__.py +++ b/src/estimagic/__init__.py @@ -1,12 +1,6 @@ -import contextlib import warnings from dataclasses import dataclass -try: - import pdbp # noqa: F401 -except ImportError: - contextlib.suppress(Exception) - from estimagic import utilities from estimagic.bootstrap import BootstrapResult, bootstrap from estimagic.estimate_ml import LikelihoodResult, estimate_ml diff --git a/src/optimagic/__init__.py b/src/optimagic/__init__.py index e1cbd6a3c..28e912234 100644 --- a/src/optimagic/__init__.py +++ b/src/optimagic/__init__.py @@ -1,12 +1,5 @@ from __future__ import annotations -import contextlib - -try: - import pdbp # noqa: F401 -except ImportError: - contextlib.suppress(Exception) - from optimagic import constraints, mark, utilities from optimagic.algorithms import algos from optimagic.benchmarking.benchmark_reports import ( diff --git a/src/optimagic/algorithms.py b/src/optimagic/algorithms.py index 748189786..a892f5a51 100644 --- a/src/optimagic/algorithms.py +++ b/src/optimagic/algorithms.py @@ -90,27 +90,32 @@ def _available(self) -> list[Type[Algorithm]]: return [ a for a in _all - if a.__algo_info__.is_available # type: ignore + if a.algo_info.is_available # type: ignore ] @property - def All(self) -> list[str]: - return [a.__algo_info__.name for a in self._all()] # type: ignore + def All(self) -> list[Type[Algorithm]]: + return self._all() @property - def Available(self) -> list[str]: - return [a.__algo_info__.name for a in self._available()] # type: ignore + def Available(self) -> list[Type[Algorithm]]: + return self._available() + + @property + def AllNames(self) -> list[str]: + return [str(a.name) for a in self._all()] + + @property + def AvailableNames(self) -> list[str]: + return [str(a.name) for a in self._available()] @property def _all_algorithms_dict(self) -> dict[str, Type[Algorithm]]: - return {a.__algo_info__.name: a for a in self._all()} # type: ignore + return {str(a.name): a for a in self._all()} @property def _available_algorithms_dict(self) -> dict[str, Type[Algorithm]]: - return { - a.__algo_info__.name: a # type: ignore - for a in self._available() - } + return {str(a.name): a for a in self._available()} @dataclass(frozen=True) diff --git a/src/optimagic/optimization/algorithm.py b/src/optimagic/optimization/algorithm.py index 3bef1d09f..7f776cf90 100644 --- a/src/optimagic/optimization/algorithm.py +++ b/src/optimagic/optimization/algorithm.py @@ -1,6 +1,6 @@ import typing import warnings -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod from dataclasses import dataclass, replace from typing import Any @@ -143,8 +143,38 @@ def __post_init__(self) -> None: raise TypeError(msg) +class AlgorithmMeta(ABCMeta): + """Metaclass to get repr, algo_info and name for classes, not just instances.""" + + def __repr__(self) -> str: + if hasattr(self, "__algo_info__") and self.__algo_info__ is not None: + out = f"om.algos.{self.__algo_info__.name}" + else: + out = self.__class__.__name__ + return out + + @property + def name(self) -> str: + if hasattr(self, "__algo_info__") and self.__algo_info__ is not None: + out = self.__algo_info__.name + else: + out = self.__class__.__name__ + return out + + @property + def algo_info(self) -> AlgoInfo: + if not hasattr(self, "__algo_info__") or self.__algo_info__ is None: + msg = ( + f"The algorithm {self.name} does not have have the __algo_info__ " + "attribute. Use the `mark.minimizer` decorator to add this attribute." + ) + raise AttributeError(msg) + + return self.__algo_info__ + + @dataclass(frozen=True) -class Algorithm(ABC): +class Algorithm(ABC, metaclass=AlgorithmMeta): @abstractmethod def _solve_internal_problem( self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] diff --git a/src/optimagic/optimizers/pygmo_optimizers.py b/src/optimagic/optimizers/pygmo_optimizers.py index e8be80ff6..44e6c7339 100644 --- a/src/optimagic/optimizers/pygmo_optimizers.py +++ b/src/optimagic/optimizers/pygmo_optimizers.py @@ -19,7 +19,6 @@ from __future__ import annotations -import contextlib import warnings from dataclasses import dataclass from typing import Any, List, Literal @@ -48,8 +47,10 @@ STOPPING_MAX_ITERATIONS_GENETIC = 250 -with contextlib.suppress(ImportError): +try: import pygmo as pg +except ImportError: + pass @mark.minimizer( diff --git a/src/optimagic/optimizers/tao_optimizers.py b/src/optimagic/optimizers/tao_optimizers.py index 508c7f444..8ea2401b5 100644 --- a/src/optimagic/optimizers/tao_optimizers.py +++ b/src/optimagic/optimizers/tao_optimizers.py @@ -1,6 +1,5 @@ """This module implements the POUNDERs algorithm.""" -import contextlib import functools from dataclasses import dataclass @@ -23,8 +22,10 @@ from optimagic.typing import AggregationLevel, NonNegativeFloat, PositiveInt from optimagic.utilities import calculate_trustregion_initial_radius -with contextlib.suppress(ImportError): +try: from petsc4py import PETSc +except ImportError: + pass @mark.minimizer( diff --git a/src/optimagic/visualization/history_plots.py b/src/optimagic/visualization/history_plots.py index 68a538638..4c4797b53 100644 --- a/src/optimagic/visualization/history_plots.py +++ b/src/optimagic/visualization/history_plots.py @@ -1,5 +1,7 @@ +import inspect import itertools from pathlib import Path +from typing import Any import numpy as np import plotly.graph_objects as go @@ -7,6 +9,7 @@ from optimagic.config import PLOTLY_PALETTE, PLOTLY_TEMPLATE from optimagic.logging.logger import LogReader, SQLiteLogOptions +from optimagic.optimization.algorithm import Algorithm from optimagic.optimization.history_tools import get_history_arrays from optimagic.optimization.optimize_result import OptimizeResult from optimagic.parameters.tree_registry import get_registry @@ -50,23 +53,7 @@ def criterion_plot( # Process inputs # ================================================================================== - if not isinstance(names, list) and names is not None: - names = [names] - - if not isinstance(results, dict): - if isinstance(results, list): - names = range(len(results)) if names is None else names - if len(names) != len(results): - raise ValueError("len(results) needs to be equal to len(names).") - results = dict(zip(names, results, strict=False)) - else: - name = 0 if names is None else names - if isinstance(name, list): - if len(name) > 1: - raise ValueError("len(results) needs to be equal to len(names).") - else: - name = name[0] - results = {name: results} + results = _harmonize_inputs_to_dict(results, names) if not isinstance(palette, list): palette = [palette] @@ -180,6 +167,46 @@ def criterion_plot( return fig +def _harmonize_inputs_to_dict(results, names): + """Convert all valid inputs for results and names to dict[str, OptimizeResult].""" + # convert scalar case to list case + if not isinstance(names, list) and names is not None: + names = [names] + + if isinstance(results, OptimizeResult): + results = [results] + + if names is not None and len(names) != len(results): + raise ValueError("len(results) needs to be equal to len(names).") + + # handle dict case + if isinstance(results, dict): + if names is not None: + results_dict = dict(zip(names, list(results.values()), strict=False)) + else: + results_dict = results + + # unlabeled iterable of results + else: + names = range(len(results)) if names is None else names + results_dict = dict(zip(names, results, strict=False)) + + # convert keys to strings + results_dict = {_convert_key_to_str(k): v for k, v in results_dict.items()} + + return results_dict + + +def _convert_key_to_str(key: Any) -> str: + if inspect.isclass(key) and issubclass(key, Algorithm): + out = str(key.name) + elif isinstance(key, Algorithm): + out = str(key.name) + else: + out = str(key) + return out + + def params_plot( result, selector=None, diff --git a/tests/optimagic/optimization/test_history_collection.py b/tests/optimagic/optimization/test_history_collection.py index b94d097db..743b8cf43 100644 --- a/tests/optimagic/optimization/test_history_collection.py +++ b/tests/optimagic/optimization/test_history_collection.py @@ -17,7 +17,7 @@ OPTIMIZERS = [] BOUNDED = [] for name, algo in AVAILABLE_ALGORITHMS.items(): - info = algo.__algo_info__ + info = algo.algo_info if not info.disable_history: if info.supports_parallelism: OPTIMIZERS.append(name) diff --git a/tests/optimagic/optimization/test_many_algorithms.py b/tests/optimagic/optimization/test_many_algorithms.py index 1882aeb08..47fbe7553 100644 --- a/tests/optimagic/optimization/test_many_algorithms.py +++ b/tests/optimagic/optimization/test_many_algorithms.py @@ -28,7 +28,7 @@ BOUNDED_ALGORITHMS = [] for name, algo in LOCAL_ALGORITHMS.items(): - if algo.__algo_info__.supports_bounds: + if algo.algo_info.supports_bounds: BOUNDED_ALGORITHMS.append(name) diff --git a/tests/optimagic/optimization/test_with_nonlinear_constraints.py b/tests/optimagic/optimization/test_with_nonlinear_constraints.py index 1df8bef3a..baaef2f8f 100644 --- a/tests/optimagic/optimization/test_with_nonlinear_constraints.py +++ b/tests/optimagic/optimization/test_with_nonlinear_constraints.py @@ -121,7 +121,7 @@ def test_nonlinear_optimization(nlc_2d_example, algorithm, constr_type): warnings.simplefilter("ignore") result = maximize(algorithm=algorithm, **kwargs[constr_type]) - if NLC_ALGORITHMS[algorithm].__algo_info__.is_global: + if NLC_ALGORITHMS[algorithm].algo_info.is_global: decimal = 0 else: decimal = 4 diff --git a/tests/optimagic/test_algo_selection.py b/tests/optimagic/test_algo_selection.py index c42cdae87..31906c7c5 100644 --- a/tests/optimagic/test_algo_selection.py +++ b/tests/optimagic/test_algo_selection.py @@ -24,3 +24,9 @@ def test_scipy_cobyla_is_present(): assert hasattr(algos.NonlinearConstrained.GradientFree.Local, "scipy_cobyla") assert hasattr(algos.NonlinearConstrained.Local.GradientFree, "scipy_cobyla") assert hasattr(algos.Local.NonlinearConstrained.GradientFree, "scipy_cobyla") + + +def test_algorithm_lists(): + assert len(algos.All) >= len(algos.Available) + assert len(algos.AllNames) == len(algos.All) + assert len(algos.AvailableNames) == len(algos.Available) diff --git a/tests/optimagic/visualization/test_history_plots.py b/tests/optimagic/visualization/test_history_plots.py index 70078b137..62e0b0ef5 100644 --- a/tests/optimagic/visualization/test_history_plots.py +++ b/tests/optimagic/visualization/test_history_plots.py @@ -7,7 +7,11 @@ from optimagic.logging import SQLiteLogOptions from optimagic.optimization.optimize import minimize from optimagic.parameters.bounds import Bounds -from optimagic.visualization.history_plots import criterion_plot, params_plot +from optimagic.visualization.history_plots import ( + _harmonize_inputs_to_dict, + criterion_plot, + params_plot, +) @pytest.fixture() @@ -130,3 +134,45 @@ def test_criterion_plot_wrong_inputs(): with pytest.raises(ValueError): criterion_plot(["bla", "bla"], names="blub") + + +def test_harmonize_inputs_to_dict_single_result(): + res = minimize(fun=lambda x: x @ x, params=np.arange(5), algorithm="scipy_lbfgsb") + assert _harmonize_inputs_to_dict(results=res, names=None) == {"0": res} + + +def test_harmonize_inputs_to_dict_single_result_with_name(): + res = minimize(fun=lambda x: x @ x, params=np.arange(5), algorithm="scipy_lbfgsb") + assert _harmonize_inputs_to_dict(results=res, names="bla") == {"bla": res} + + +def test_harmonize_inputs_to_dict_list_results(): + res = minimize(fun=lambda x: x @ x, params=np.arange(5), algorithm="scipy_lbfgsb") + results = [res, res] + assert _harmonize_inputs_to_dict(results=results, names=None) == { + "0": res, + "1": res, + } + + +def test_harmonize_inputs_to_dict_dict_input(): + res = minimize(fun=lambda x: x @ x, params=np.arange(5), algorithm="scipy_lbfgsb") + results = {"bla": res, om.algos.scipy_lbfgsb(): res, om.algos.scipy_neldermead: res} + got = _harmonize_inputs_to_dict(results=results, names=None) + expected = {"bla": res, "scipy_lbfgsb": res, "scipy_neldermead": res} + assert got == expected + + +def test_harmonize_inputs_to_dict_dict_input_with_names(): + res = minimize(fun=lambda x: x @ x, params=np.arange(5), algorithm="scipy_lbfgsb") + results = {"bla": res, "blub": res} + got = _harmonize_inputs_to_dict(results=results, names=["a", "b"]) + expected = {"a": res, "b": res} + assert got == expected + + +def test_harmonize_inputs_to_dict_invalid_names(): + results = [None] + names = ["a", "b"] + with pytest.raises(ValueError): + _harmonize_inputs_to_dict(results=results, names=names)