Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Polish the algo selection tool #550

Merged
merged 8 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 25 additions & 20 deletions .tools/create_algo_selection_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -119,47 +119,47 @@ 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
timmens marked this conversation as resolved.
Show resolved Hide resolved


def _is_gradient_free(algo: Type[Algorithm]) -> bool:
return not _is_gradient_based(algo)


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:
return not _is_global(algo)


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]]:
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 0 additions & 6 deletions src/estimagic/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 0 additions & 7 deletions src/optimagic/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
25 changes: 15 additions & 10 deletions src/optimagic/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 32 additions & 2 deletions src/optimagic/optimization/algorithm.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -143,8 +143,38 @@
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__

Check warning on line 153 in src/optimagic/optimization/algorithm.py

View check run for this annotation

Codecov / codecov/patch

src/optimagic/optimization/algorithm.py#L153

Added line #L153 was not covered by tests
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__

Check warning on line 161 in src/optimagic/optimization/algorithm.py

View check run for this annotation

Codecov / codecov/patch

src/optimagic/optimization/algorithm.py#L161

Added line #L161 was not covered by tests
return out

@property
def algo_info(self) -> AlgoInfo:
if not hasattr(self, "__algo_info__") or self.__algo_info__ is None:
msg = (

Check warning on line 167 in src/optimagic/optimization/algorithm.py

View check run for this annotation

Codecov / codecov/patch

src/optimagic/optimization/algorithm.py#L167

Added line #L167 was not covered by tests
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)

Check warning on line 171 in src/optimagic/optimization/algorithm.py

View check run for this annotation

Codecov / codecov/patch

src/optimagic/optimization/algorithm.py#L171

Added line #L171 was not covered by tests

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]
Expand Down
5 changes: 3 additions & 2 deletions src/optimagic/optimizers/pygmo_optimizers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

from __future__ import annotations

import contextlib
import warnings
from dataclasses import dataclass
from typing import Any, List, Literal
Expand Down Expand Up @@ -48,8 +47,10 @@

STOPPING_MAX_ITERATIONS_GENETIC = 250

with contextlib.suppress(ImportError):
try:
import pygmo as pg
except ImportError:
pass

Check warning on line 53 in src/optimagic/optimizers/pygmo_optimizers.py

View check run for this annotation

Codecov / codecov/patch

src/optimagic/optimizers/pygmo_optimizers.py#L52-L53

Added lines #L52 - L53 were not covered by tests


@mark.minimizer(
Expand Down
5 changes: 3 additions & 2 deletions src/optimagic/optimizers/tao_optimizers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""This module implements the POUNDERs algorithm."""

import contextlib
import functools
from dataclasses import dataclass

Expand All @@ -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

Check warning on line 28 in src/optimagic/optimizers/tao_optimizers.py

View check run for this annotation

Codecov / codecov/patch

src/optimagic/optimizers/tao_optimizers.py#L27-L28

Added lines #L27 - L28 were not covered by tests


@mark.minimizer(
Expand Down
61 changes: 44 additions & 17 deletions src/optimagic/visualization/history_plots.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import inspect
import itertools
from pathlib import Path
from typing import Any

import numpy as np
import plotly.graph_objects as go
from pybaum import leaf_names, tree_flatten, tree_just_flatten, tree_unflatten

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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/optimagic/optimization/test_history_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading