diff --git a/src/optimini/algorithms.py b/src/optimini/algorithms.py index cc8df19..1fbbafc 100644 --- a/src/optimini/algorithms.py +++ b/src/optimini/algorithms.py @@ -2,12 +2,19 @@ import numpy as np from numpy.typing import NDArray +from scipy.optimize import Bounds as ScipyBounds from scipy.optimize import minimize as scipy_minimize +from optimini import mark from optimini.internal_problem import InternalProblem from optimini.utils import Algorithm, InternalResult +@mark.minimizer( + name="L-BFGS-B", + supports_bounds=True, + needs_bounds=False, +) @dataclass(frozen=True) class SciPyLBFGSB(Algorithm): convergence_ftol: float = 1e-8 @@ -18,6 +25,8 @@ class SciPyLBFGSB(Algorithm): def _solve_internal_problem( self, problem: InternalProblem, x0: NDArray[np.float64] ) -> InternalResult: + bounds = _get_scipy_bounds(problem.lower_bounds, problem.upper_bounds) + options = { "maxcor": self.limited_memory_length, "ftol": self.convergence_ftol, @@ -27,11 +36,17 @@ def _solve_internal_problem( fun=problem.fun, x0=x0, method="L-BFGS-B", + bounds=bounds, options=options, ) return InternalResult(x=res.x, fun=res.fun) +@mark.minimizer( + name="CG", + supports_bounds=False, + needs_bounds=False, +) @dataclass(frozen=True) class SciPyCG(Algorithm): convergence_gtol: float = 1e-8 @@ -49,6 +64,11 @@ def _solve_internal_problem( return InternalResult(x=res.x, fun=res.fun) +@mark.minimizer( + name="Nelder-Mead", + supports_bounds=True, + needs_bounds=False, +) @dataclass(frozen=True) class SciPyNelderMead(Algorithm): stopping_maxiter: int = 10_000 @@ -59,6 +79,8 @@ class SciPyNelderMead(Algorithm): def _solve_internal_problem( self, problem: InternalProblem, x0: NDArray[np.float64] ) -> InternalResult: + bounds = _get_scipy_bounds(problem.lower_bounds, problem.upper_bounds) + options = { "maxiter": self.stopping_maxiter, "fatol": self.convergence_ftol, @@ -66,11 +88,22 @@ def _solve_internal_problem( } res = scipy_minimize( - fun=problem.fun, x0=x0, method="Nelder-Mead", options=options + fun=problem.fun, x0=x0, method="Nelder-Mead", bounds=bounds, options=options ) return InternalResult(x=res.x, fun=res.fun) +def _get_scipy_bounds(lower_bounds, upper_bounds): + if lower_bounds is None and upper_bounds is None: + return None + if lower_bounds is None: + lower_bounds = -np.inf + if upper_bounds is None: + upper_bounds = np.inf + + return ScipyBounds(lower_bounds, upper_bounds) + + OPTIMIZER_REGISTRY = { "L-BFGS-B": SciPyLBFGSB, "CG": SciPyCG, diff --git a/src/optimini/internal_problem.py b/src/optimini/internal_problem.py index 8fff759..7b8f87b 100644 --- a/src/optimini/internal_problem.py +++ b/src/optimini/internal_problem.py @@ -1,10 +1,12 @@ class InternalProblem: """Wraps a user provided function to add functionality""" - def __init__(self, fun, converter, history): + def __init__(self, fun, lb, ub, converter, history): self._user_fun = fun self._converter = converter self._history = history + self.lower_bounds = converter.flatten(lb) + self.upper_bounds = converter.flatten(ub) def fun(self, x): params = self._converter.unflatten(x) diff --git a/src/optimini/mark.py b/src/optimini/mark.py new file mode 100644 index 0000000..af850b6 --- /dev/null +++ b/src/optimini/mark.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass +class AlgoInfo: + """An oversimplified collection of algorithm properties""" + + name: str + supports_bounds: bool + needs_bounds: bool + + +def minimizer(name, supports_bounds, needs_bounds): + """Decorator to mark minimizers and add algorithm information""" + + def decorator(cls): + algo_info = AlgoInfo(name, supports_bounds, needs_bounds) + cls.__algo_info__ = algo_info + return cls + + return decorator diff --git a/src/optimini/minimize.py b/src/optimini/minimize.py index 4272d93..af4d635 100644 --- a/src/optimini/minimize.py +++ b/src/optimini/minimize.py @@ -5,14 +5,23 @@ from optimini.utils import OptimizeResult -def minimize(fun, params, method, options=None): +def minimize(fun, params, method, lower_bounds=None, upper_bounds=None, options=None): """Minimize a function using a given method""" options = {} if options is None else options + algo = OPTIMIZER_REGISTRY[method](**options) + + _fail_if_incompatible_bounds(lower_bounds, upper_bounds, algo) + converter = Converter(params) history = History() - problem = InternalProblem(fun, converter, history) + problem = InternalProblem( + fun, + lower_bounds, + upper_bounds, + converter, + history, + ) x0 = converter.flatten(params) - algo = OPTIMIZER_REGISTRY[method](**options) raw_res = algo._solve_internal_problem(problem, x0) res = OptimizeResult( x=converter.unflatten(raw_res.x), @@ -20,3 +29,13 @@ def minimize(fun, params, method, options=None): fun=raw_res.fun, ) return res + + +def _fail_if_incompatible_bounds(lower_bounds, upper_bounds, algo): + supports_bounds = algo.__algo_info__.supports_bounds + needs_bounds = algo.__algo_info__.needs_bounds + if supports_bounds and needs_bounds: + if lower_bounds is None or upper_bounds is None: + raise ValueError("Bounds are required for this algorithm") + if not supports_bounds and (lower_bounds is not None or upper_bounds is not None): + raise ValueError("Bounds are not supported for this algorithm") diff --git a/tests/test_optimini.py b/tests/test_optimini.py index 9840e2c..6dfb423 100644 --- a/tests/test_optimini.py +++ b/tests/test_optimini.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from optimini.history import History, history_plot from optimini.minimize import minimize @@ -32,3 +33,19 @@ def test_history_collection(): res = minimize(dict_fun, params, method="L-BFGS-B") assert isinstance(res.history, History) history_plot({"test": res}) + + +@pytest.mark.parametrize("method", ["L-BFGS-B", "Nelder-Mead"]) +def test_with_bounds(method): + params = {"a": 1, "b": 2} + lb = {"a": -1, "b": 1} + res = minimize(dict_fun, params, method=method, lower_bounds=lb) + assert np.allclose(res.x["a"], 0, atol=1e-4) + assert np.allclose(res.x["b"], 1, atol=1e-4) + + +def test_unsupported_bounds(): + params = {"a": 1, "b": 2} + lb = {"a": -1, "b": 1} + with pytest.raises(ValueError): + minimize(dict_fun, params, method="CG", lower_bounds=lb)