Skip to content

Step 4: Add bounds support. #5

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

Open
wants to merge 4 commits into
base: 3_algorithm_classes
Choose a base branch
from
Open
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
35 changes: 34 additions & 1 deletion src/optimini/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -59,18 +79,31 @@ 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,
"adaptive": self.adaptive,
}

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,
Expand Down
4 changes: 3 additions & 1 deletion src/optimini/internal_problem.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/optimini/mark.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 22 additions & 3 deletions src/optimini/minimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,37 @@
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),
history=history,
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")
17 changes: 17 additions & 0 deletions tests/test_optimini.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import numpy as np
import pytest

from optimini.history import History, history_plot
from optimini.minimize import minimize
Expand Down Expand Up @@ -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)