diff --git a/pypesto/optimize/optimizer.py b/pypesto/optimize/optimizer.py index 9b148acdc..26e712e4a 100644 --- a/pypesto/optimize/optimizer.py +++ b/pypesto/optimize/optimizer.py @@ -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): """ @@ -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.""" @@ -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.""" @@ -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.""" @@ -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): """ @@ -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.""" @@ -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): """ @@ -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): """ @@ -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): """ @@ -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 diff --git a/test/optimize/test_optimizer_common_interface.py b/test/optimize/test_optimizer_common_interface.py index bf39c8067..b2d3d4820 100644 --- a/test/optimize/test_optimizer_common_interface.py +++ b/test/optimize/test_optimizer_common_interface.py @@ -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() @@ -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() @@ -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)