Skip to content
Open
165 changes: 165 additions & 0 deletions pypesto/optimize/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,36 @@ def set_maxeval(self, evaluations: int) -> None:
f"Check supports_maxeval() before calling set_maxeval()."
)

def supports_f_abs_tol(self) -> bool:
"""
Check whether optimizer supports absolute function value tolerance.

Returns
-------
True if optimizer supports setting an absolute tolerance on the
objective function value, False otherwise.
"""
return True

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance on function value for optimization.

Parameters
----------
tol
Absolute tolerance on objective function value for termination.

Raises
------
NotImplementedError
If the optimizer does not support absolute function tolerance.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support absolute function tolerance. "
f"Check supports_f_abs_tol() before calling set_f_abs_tol()."
)


class ScipyOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -722,6 +752,24 @@ def set_maxtime(self, seconds: float) -> None:

self._maxtime_seconds = seconds

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance on function value for optimization.

Parameters
----------
tol
Absolute tolerance on objective function value for termination.

Raises
------
ValueError
If tolerance is negative.
"""
if tol < 0:
raise ValueError(f"Tolerance must be non-negative, got {tol}")
self.tol = tol


class IpoptOptimizer(Optimizer):
"""Use Ipopt (https://pypi.org/project/cyipopt/) for optimization."""
Expand Down Expand Up @@ -843,6 +891,10 @@ def set_maxiter(self, iterations: int) -> None:
self.options = {}
self.options["max_iter"] = iterations

def supports_f_abs_tol(self) -> bool:
"""Check whether optimizer supports absolute function tolerance."""
return False


class DlibOptimizer(Optimizer):
"""Use the Dlib toolbox for optimization."""
Expand Down Expand Up @@ -966,6 +1018,10 @@ def set_maxiter(self, iterations: int) -> None:
self.options = {}
self.options["maxiter"] = iterations

def supports_f_abs_tol(self) -> bool:
"""Check whether optimizer supports absolute tolerance."""
return False


class PyswarmOptimizer(Optimizer):
"""Global optimization using pyswarm."""
Expand Down Expand Up @@ -1041,6 +1097,26 @@ def set_maxiter(self, iterations: int) -> None:
self.options = {}
self.options["maxiter"] = iterations

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance for optimization.

Parameters
----------
tol
Absolute tolerance for termination.

Raises
------
ValueError
If tolerance is not positive.
"""
if tol < 0:
raise ValueError(f"Tolerance must be positive, got {tol}")
if self.options is None:
self.options = {}
self.options["minfunc"] = tol


class CmaOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1162,6 +1238,26 @@ def set_maxeval(self, evaluations: int) -> None:
self.options = {}
self.options["maxfevals"] = evaluations

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance for optimization.

Parameters
----------
tol
Absolute tolerance for termination.

Raises
------
ValueError
If tolerance is not positive.
"""
if tol < 0:
raise ValueError(f"Tolerance must be positive, got {tol}")
if self.options is None:
self.options = {}
self.options["tolfun"] = tol


class CmaesOptimizer(CmaOptimizer):
"""Deprecated, use CmaOptimizer instead."""
Expand Down Expand Up @@ -1263,6 +1359,26 @@ def set_maxiter(self, iterations: int) -> None:
self.options = {}
self.options["maxiter"] = iterations

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance for optimization.

Parameters
----------
tol
Absolute tolerance for termination.

Raises
------
ValueError
If tolerance is not positive.
"""
if tol < 0:
raise ValueError(f"Tolerance must be positive, got {tol}")
if self.options is None:
self.options = {}
self.options["atol"] = tol


class PyswarmsOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1404,6 +1520,10 @@ def set_maxiter(self, iterations: int) -> None:
self.options = {}
self.options["maxiter"] = iterations

def supports_f_abs_tol(self) -> bool:
"""Check whether optimizer supports absolute tolerance."""
return False


class NLoptOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1676,6 +1796,26 @@ def set_maxeval(self, evaluations: int) -> None:
"""
self.options["maxeval"] = evaluations

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance for optimization.

Parameters
----------
tol
Absolute tolerance for termination.

Raises
------
ValueError
If tolerance is not positive.
"""
if tol < 0:
raise ValueError(f"Tolerance must be positive, got {tol}")
if self.options is None:
self.options = {}
self.options["ftol_abs"] = tol


class FidesOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1900,3 +2040,28 @@ def set_maxiter(self, iterations: int) -> None:
self.options[FidesOptions.MAXITER] = iterations
except ImportError:
raise OptimizerImportError("fides") from None

def set_f_abs_tol(self, tol: float) -> None:
"""
Set the absolute tolerance for optimization.

Parameters
----------
tol
Absolute tolerance for termination.

Raises
------
ValueError
If tolerance is not positive.
"""
if tol < 0:
raise ValueError(f"Tolerance must be positive, got {tol}")
try:
from fides.constants import Options as FidesOptions

if self.options is None:
self.options = {}
self.options[FidesOptions.FATOL] = tol
except ImportError:
raise OptimizerImportError("fides") from None
110 changes: 105 additions & 5 deletions test/optimize/test_optimizer_common_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ def test_ess_optimizer_support(self):
optimizer.set_maxtime(10.0)
assert optimizer.max_walltime_s == 10.0

def test_cma_optimizer_support(self):
"""Test CmaOptimizer iteration limit support."""
optimizer = optimize.CmaOptimizer()

assert optimizer.supports_maxtime() is True
optimizer.set_maxtime(1000)

assert optimizer.options["timeout"] == 1000

def test_scipy_optimizer_support(self):
"""Test ScipyOptimizer time limit support."""
optimizer = optimize.ScipyOptimizer()
Expand Down Expand Up @@ -136,11 +145,6 @@ def test_cma_optimizer_support(self):
optimizer.set_maxiter(5000)
assert optimizer.options["maxiter"] == 5000

assert optimizer.supports_maxtime() is True
optimizer.set_maxtime(1000)

assert optimizer.options["timeout"] == 1000

def test_scipy_de_optimizer_support(self):
"""Test ScipyDifferentialEvolutionOptimizer iteration limit support."""
optimizer = optimize.ScipyDifferentialEvolutionOptimizer()
Expand Down Expand Up @@ -226,3 +230,99 @@ def test_ipopt_optimizer_no_support(self):

with pytest.raises(NotImplementedError):
optimizer.set_maxeval(100)


class TestOptimizerTolInterface:
"""Test the unified tolerance interface for optimizers."""

def test_scipy_optimizer_support(self):
"""Test ScipyOptimizer tolerance support."""
optimizer = optimize.ScipyOptimizer()
assert optimizer.supports_f_abs_tol() is True

optimizer.set_f_abs_tol(1e-6)
assert optimizer.tol == 1e-6

# Test updating existing value
optimizer.set_f_abs_tol(1e-8)
assert optimizer.tol == 1e-8

def test_nlopt_optimizer_support(self):
"""Test NLoptOptimizer tolerance support."""
optimizer = optimize.NLoptOptimizer()
assert optimizer.supports_f_abs_tol() is True

optimizer.set_f_abs_tol(1e-5)
assert optimizer.options["ftol_abs"] == 1e-5

def test_fides_optimizer_support(self):
"""Test FidesOptimizer tolerance support."""
optimizer = optimize.FidesOptimizer()
assert optimizer.supports_f_abs_tol() is True

optimizer.set_f_abs_tol(1e-6)

from fides.constants import Options as FidesOptions

assert FidesOptions.FATOL in optimizer.options
assert optimizer.options[FidesOptions.FATOL] == 1e-6

# Test updating existing value
optimizer.set_f_abs_tol(1e-9)
assert optimizer.options[FidesOptions.FATOL] == 1e-9

def test_cma_optimizer_support(self):
"""Test CmaOptimizer tolerance support."""
optimizer = optimize.CmaOptimizer()
assert optimizer.supports_f_abs_tol() is True

optimizer.set_f_abs_tol(1e-4)
assert optimizer.options["tolfun"] == 1e-4

def test_scipy_de_optimizer_support(self):
"""Test ScipyDifferentialEvolutionOptimizer tolerance support."""
optimizer = optimize.ScipyDifferentialEvolutionOptimizer()
assert optimizer.supports_f_abs_tol() is True

optimizer.set_f_abs_tol(1e-5)
assert optimizer.options["atol"] == 1e-5

def test_pyswarm_optimizer_support(self):
"""Test PyswarmOptimizer tolerance support."""
optimizer = optimize.PyswarmOptimizer()
assert optimizer.supports_f_abs_tol() is True

optimizer.set_f_abs_tol(1e-7)
assert optimizer.options["minfunc"] == 1e-7

def test_dlib_optimizer_no_support(self):
"""Test that DlibOptimizer does not support tolerance."""
optimizer = optimize.DlibOptimizer()
assert optimizer.supports_f_abs_tol() is False

with pytest.raises(NotImplementedError):
optimizer.set_f_abs_tol(1e-6)

def test_pyswarms_optimizer_no_support(self):
"""Test that PyswarmsOptimizer does not support tolerance."""
optimizer = optimize.PyswarmsOptimizer()
assert optimizer.supports_f_abs_tol() is False

with pytest.raises(NotImplementedError):
optimizer.set_f_abs_tol(1e-6)

def test_tolerance_validation(self):
"""Test that invalid tolerance values are rejected."""
optimizer = optimize.ScipyOptimizer()

# Test that positive values work
optimizer.set_f_abs_tol(1e-6)
assert optimizer.tol == 1e-6

# Test that zero is allowed (optimize as accurately as possible)
optimizer.set_f_abs_tol(0.0)
assert optimizer.tol == 0.0

# Test that negative values are rejected
with pytest.raises(ValueError, match="must be non-negative"):
optimizer.set_f_abs_tol(-1e-6)
Loading