Skip to content
Open
159 changes: 156 additions & 3 deletions pypesto/optimize/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,58 @@ 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()."
)

def _set_option_tol(self, tol: float, option_key: str) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class Optimizer does not have an options attribute, we should not try accessing it here. I'd leave validation to the optimizer and just set the options directly in each optimizer without this extra method.

"""
Set tolerance in options dict with validation.

Parameters
----------
tol
Absolute tolerance value (must be positive).
option_key
The key to use in the options dictionary.

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[option_key] = tol


class ScipyOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -722,6 +774,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 @@ -822,9 +892,7 @@ def set_maxtime(self, seconds: float) -> None:
)
if self.options is None:
self.options = {}
# We explicitly cast to float, as the IpoptOptimizer requires
# the provision of a float for the max_wall_time option.
self.options["max_wall_time"] = float(seconds)
Comment on lines -825 to -827

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why removed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that should not have happened. I think something got mixed up when i merged develop...

self.options["max_wall_time"] = seconds

def supports_maxiter(self) -> bool:
"""Check whether optimizer supports iteration limits."""
Expand All @@ -843,6 +911,23 @@ 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

def set_tol(self, tol: float) -> None:
"""
Set the convergence tolerance.

See https://coin-or.github.io/Ipopt/OPTIONS.html for more information.

Parameters
----------
tol
Tolerance value for termination.
"""
self._set_option_tol(tol, "tol")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I remember, ipopt has quite complex termination criteria. While various tolerances are supported, I think just hitting this single value is insufficient for termination, so it might be a bit confusing. Not completely sure whether it should be added here or not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, it says so in the IPOPT documentation with a quite lengthy passage. I would in general be fine with removing it, but i am not sure how to handle the supports_rel_tol later. I think we can say it does not directly support f_abs_tol but this one is a bit harder. Removed it for now!



class DlibOptimizer(Optimizer):
"""Use the Dlib toolbox for optimization."""
Expand Down Expand Up @@ -966,6 +1051,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 +1130,17 @@ 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.
"""
self._set_option_tol(tol, "minfunc")


class CmaOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1162,6 +1262,17 @@ 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.
"""
self._set_option_tol(tol, "tolfun")


class CmaesOptimizer(CmaOptimizer):
"""Deprecated, use CmaOptimizer instead."""
Expand Down Expand Up @@ -1263,6 +1374,17 @@ 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.
"""
self._set_option_tol(tol, "atol")


class PyswarmsOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1404,6 +1526,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 +1802,17 @@ 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.
"""
self._set_option_tol(tol, "ftol_abs")


class FidesOptimizer(Optimizer):
"""
Expand Down Expand Up @@ -1900,3 +2037,19 @@ 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.
"""
try:
from fides.constants import Options as FidesOptions

self._set_option_tol(tol, FidesOptions.FATOL)
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)