diff --git a/src/estimagic/estimate_ml.py b/src/estimagic/estimate_ml.py index 81fde607f..f028f811a 100644 --- a/src/estimagic/estimate_ml.py +++ b/src/estimagic/estimate_ml.py @@ -224,7 +224,6 @@ def estimate_ml( func_eval=loglike_eval, primary_key="contributions", scaling=False, - scaling_options=None, derivative_eval=jacobian_eval, ) diff --git a/src/estimagic/estimate_msm.py b/src/estimagic/estimate_msm.py index 562dc6c47..2a7945acc 100644 --- a/src/estimagic/estimate_msm.py +++ b/src/estimagic/estimate_msm.py @@ -274,7 +274,6 @@ def helper(params): func_eval=func_eval, primary_key="contributions", scaling=False, - scaling_options=None, derivative_eval=jacobian_eval, ) diff --git a/src/optimagic/deprecations.py b/src/optimagic/deprecations.py index 1d5defe8e..ca7768044 100644 --- a/src/optimagic/deprecations.py +++ b/src/optimagic/deprecations.py @@ -60,6 +60,16 @@ def throw_criterion_and_derivative_kwargs_future_warning(): warnings.warn(msg, FutureWarning) +def throw_scaling_options_future_warning(): + msg = ( + "The `scaling_options` argument will be deprecated in favor of `scaling` in " + "optimagic version 0.6.0 and later. You can simply pass the scaling options to " + "`scaling` instead of `scaling_options`. Using `scaling_options` will become " + "an error in optimagic version 0.6.0 and later." + ) + warnings.warn(msg, FutureWarning) + + def replace_and_warn_about_deprecated_algo_options(algo_options): if not isinstance(algo_options, dict): return algo_options diff --git a/src/optimagic/optimization/check_arguments.py b/src/optimagic/optimization/check_arguments.py index b61636834..5cf7ad0d1 100644 --- a/src/optimagic/optimization/check_arguments.py +++ b/src/optimagic/optimization/check_arguments.py @@ -2,6 +2,7 @@ from pathlib import Path from optimagic.shared.check_option_dicts import check_numdiff_options +from optimagic.options import ScalingOptions def check_optimize_kwargs(**kwargs): @@ -22,8 +23,7 @@ def check_optimize_kwargs(**kwargs): "error_handling": str, "error_penalty": dict, "cache_size": (int, float), - "scaling": bool, - "scaling_options": dict, + "scaling": (bool, ScalingOptions), "multistart": bool, "multistart_options": dict, } diff --git a/src/optimagic/optimization/optimize.py b/src/optimagic/optimization/optimize.py index 9140984f3..2960c8ffc 100644 --- a/src/optimagic/optimization/optimize.py +++ b/src/optimagic/optimization/optimize.py @@ -44,6 +44,7 @@ ) from optimagic import deprecations from optimagic.deprecations import replace_and_warn_about_deprecated_algo_options +from optimagic.options import ScalingOptions def maximize( @@ -350,6 +351,9 @@ def _optimize( else fun_and_jac_kwargs ) + if scaling_options is not None: + deprecations.throw_scaling_options_future_warning() + algo_options = replace_and_warn_about_deprecated_algo_options(algo_options) # ================================================================================== @@ -452,7 +456,7 @@ def _optimize( raise NotImplementedError(msg) # ================================================================================== - # Set default values and check options + # Set default values, consolidate deprecated options, and check options # ================================================================================== fun_kwargs = _setdefault(fun_kwargs, {}) constraints = _setdefault(constraints, []) @@ -461,12 +465,13 @@ def _optimize( fun_and_jac_kwargs = _setdefault(fun_and_jac_kwargs, {}) numdiff_options = _setdefault(numdiff_options, {}) log_options = _setdefault(log_options, {}) - scaling_options = _setdefault(scaling_options, {}) error_penalty = _setdefault(error_penalty, {}) multistart_options = _setdefault(multistart_options, {}) if logging: logging = Path(logging) + scaling = _consolidate_scaling_options(scaling, scaling_options) + if not skip_checks: check_optimize_kwargs( direction=direction, @@ -486,7 +491,6 @@ def _optimize( error_handling=error_handling, error_penalty=error_penalty, scaling=scaling, - scaling_options=scaling_options, multistart=multistart, multistart_options=multistart_options, ) @@ -620,7 +624,6 @@ def _optimize( func_eval=first_crit_eval, primary_key=algo_info.primary_criterion_entry, scaling=scaling, - scaling_options=scaling_options, derivative_eval=used_deriv, soft_lower_bounds=soft_lower_bounds, soft_upper_bounds=soft_upper_bounds, @@ -878,6 +881,34 @@ def _setdefault(candidate, default): return out +def _consolidate_scaling_options(scaling, scaling_options): + """Consolidate scaling options.""" + if isinstance(scaling, ScalingOptions) and scaling_options is not None: + msg = ( + "You can not provide options through scaling and scaling_options. The " + "scaling_options argument is deprecated in favor of the scaling argument." + "You can pass options to the scaling argument directly using the " + "ScalingOptions class." + ) + raise ValueError(msg) + + if isinstance(scaling, bool): + if scaling and scaling_options is None: + scaling = ScalingOptions() + elif scaling: + try: + scaling = ScalingOptions(**scaling_options) + except TypeError as e: + msg = ( + "The scaling_options argument contains invalid keys, and is " + "deprecated in favor of the scaling argument. You can pass options " + "to the scaling argument directly using the ScalingOptions class." + ) + raise ValueError(msg) from e + + return scaling + + def _fill_multistart_options_with_defaults(options, params, x, params_to_internal): """Fill options for multistart optimization with defaults.""" defaults = { diff --git a/src/optimagic/options.py b/src/optimagic/options.py new file mode 100644 index 000000000..c96866044 --- /dev/null +++ b/src/optimagic/options.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Literal + + +@dataclass +class ScalingOptions: + method: Literal["start_values", "bound"] = "start_values" + clipping_value: float = 0.1 + magnitude: float = 1.0 diff --git a/src/optimagic/parameters/constraint_tools.py b/src/optimagic/parameters/constraint_tools.py index 5c790a210..b85e51d05 100644 --- a/src/optimagic/parameters/constraint_tools.py +++ b/src/optimagic/parameters/constraint_tools.py @@ -23,7 +23,6 @@ def count_free_params(params, constraints=None, lower_bounds=None, upper_bounds= func_eval=3, primary_key="value", scaling=False, - scaling_options={}, ) return int(internal_params.free_mask.sum()) @@ -52,5 +51,4 @@ def check_constraints(params, constraints, lower_bounds=None, upper_bounds=None) func_eval=3, primary_key="value", scaling=False, - scaling_options={}, ) diff --git a/src/optimagic/parameters/conversion.py b/src/optimagic/parameters/conversion.py index 9076b2bff..8ad670a99 100644 --- a/src/optimagic/parameters/conversion.py +++ b/src/optimagic/parameters/conversion.py @@ -18,7 +18,6 @@ def get_converter( func_eval, primary_key, scaling, - scaling_options, derivative_eval=None, soft_lower_bounds=None, soft_upper_bounds=None, @@ -46,8 +45,8 @@ def get_converter( primary_key (str): One of "value", "contributions" and "root_contributions". Used to determine how the function and derivative output has to be transformed for the optimzer. - scaling (bool): Whether scaling should be performed. - scaling_options (dict): User provided scaling options. + scaling (bool | ScalingOptions): Scaling options. If False, no scaling is + performed. derivative_eval (dict, pytree or None): Evaluation of the derivative of func at params. Used for consistency checks. soft_lower_bounds (pytree): As lower_bounds @@ -105,7 +104,6 @@ def get_converter( scale_converter, scaled_params = get_scale_converter( internal_params=internal_params, scaling=scaling, - scaling_options=scaling_options, ) def _params_to_internal(params): diff --git a/src/optimagic/parameters/scale_conversion.py b/src/optimagic/parameters/scale_conversion.py index 6a095e32e..291341b53 100644 --- a/src/optimagic/parameters/scale_conversion.py +++ b/src/optimagic/parameters/scale_conversion.py @@ -1,25 +1,30 @@ from functools import partial -from typing import NamedTuple, Callable +from typing import NamedTuple, Callable, Literal import numpy as np from optimagic.parameters.space_conversion import InternalParams +from optimagic.options import ScalingOptions + + +class ScaleConverter(NamedTuple): + params_to_internal: Callable + params_from_internal: Callable + derivative_to_internal: Callable + derivative_from_internal: Callable def get_scale_converter( - internal_params, - scaling, - scaling_options, -): + internal_params: InternalParams, + scaling: Literal[False] | ScalingOptions, +) -> tuple[ScaleConverter, InternalParams]: """Get a converter between scaled and unscaled parameters. Args: internal_params (InternalParams): NamedTuple of internal and possibly reparametrized but not yet scaled parameter values and bounds. - func (callable): The criterion function. Possibly used to calculate a scaling - factor. - scaling (bool): Whether scaling should be done. - scaling_options (dict): User provided scaling options. + scaling (Literal[False] | ScalingOptions): Scaling options. If False, no scaling + is performed. Returns: ScaleConverter: NamedTuple with methods to convert between scaled and unscaled @@ -39,12 +44,11 @@ def get_scale_converter( if not scaling: return _fast_path_scale_converter(), internal_params - scaling_options = {} if scaling_options is None else scaling_options - valid_keys = {"method", "clipping_value", "magnitude"} - scaling_options = {k: v for k, v in scaling_options.items() if k in valid_keys} - factor, offset = calculate_scaling_factor_and_offset( - internal_params=internal_params, **scaling_options + internal_params=internal_params, + method=scaling.method, + clipping_value=scaling.clipping_value, + magnitude=scaling.magnitude, ) _params_to_internal = partial( @@ -94,13 +98,6 @@ def _derivative_from_internal(derivative): return converter, params -class ScaleConverter(NamedTuple): - params_to_internal: Callable - params_from_internal: Callable - derivative_to_internal: Callable - derivative_from_internal: Callable - - def _fast_path_scale_converter(): converter = ScaleConverter( params_to_internal=lambda x: x, @@ -113,9 +110,9 @@ def _fast_path_scale_converter(): def calculate_scaling_factor_and_offset( internal_params, - method="start_values", - clipping_value=0.1, - magnitude=1, + method, + clipping_value, + magnitude, ): x = internal_params.values lower_bounds = internal_params.lower_bounds diff --git a/src/optimagic/visualization/slice_plot.py b/src/optimagic/visualization/slice_plot.py index de8c4dbe1..c4402819d 100644 --- a/src/optimagic/visualization/slice_plot.py +++ b/src/optimagic/visualization/slice_plot.py @@ -104,7 +104,6 @@ def slice_plot( func_eval=func_eval, primary_key="value", scaling=False, - scaling_options=None, ) n_params = len(internal_params.values) diff --git a/tests/optimagic/optimization/test_internal_criterion_and_derivative_template.py b/tests/optimagic/optimization/test_internal_criterion_and_derivative_template.py index c529ce150..0fe178be8 100644 --- a/tests/optimagic/optimization/test_internal_criterion_and_derivative_template.py +++ b/tests/optimagic/optimization/test_internal_criterion_and_derivative_template.py @@ -74,7 +74,6 @@ def test_criterion_and_derivative_template( func_eval=crit(base_inputs["params"]), primary_key="value", scaling=False, - scaling_options=None, derivative_eval=None, ) inputs = {k: v for k, v in base_inputs.items() if k != "params"} @@ -123,7 +122,6 @@ def test_internal_criterion_with_penalty(base_inputs, direction): func_eval=sos_scalar_criterion(base_inputs["params"]), primary_key="value", scaling=False, - scaling_options=None, derivative_eval=None, ) inputs = {k: v for k, v in base_inputs.items() if k != "params"} diff --git a/tests/optimagic/optimization/test_optimizations_with_scaling.py b/tests/optimagic/optimization/test_optimizations_with_scaling.py index 5d6db79f7..ecbd08460 100644 --- a/tests/optimagic/optimization/test_optimizations_with_scaling.py +++ b/tests/optimagic/optimization/test_optimizations_with_scaling.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd +from optimagic.options import ScalingOptions import pytest from optimagic.config import IS_PYBOBYQA_INSTALLED from optimagic.optimization.optimize import minimize @@ -32,7 +33,7 @@ def sos_gradient(params): @pytest.mark.parametrize("algorithm, scaling_options", PARAMETRIZATION) -def test_optimizations_with_scaling(algorithm, scaling_options): +def test_optimizations_with_scaling_via_dict_options(algorithm, scaling_options): params = pd.DataFrame() params["value"] = np.arange(5) params["lower_bound"] = [-1, 0, 0, 0, 0] @@ -52,3 +53,27 @@ def test_optimizations_with_scaling(algorithm, scaling_options): expected_solution = np.array([0, 0, 0, 3, 4]) aaae(res.params["value"].to_numpy(), expected_solution) + + +@pytest.mark.parametrize("algorithm, scaling_options", PARAMETRIZATION) +def test_optimizations_with_scaling(algorithm, scaling_options): + params = pd.DataFrame() + params["value"] = np.arange(5) + params["lower_bound"] = [-1, 0, 0, 0, 0] + params["upper_bound"] = np.full(5, 10) + + constraints = [{"loc": [3, 4], "type": "fixed"}] + + scaling = ScalingOptions(**scaling_options) + + res = minimize( + fun=sos_scalar_criterion, + params=params, + constraints=constraints, + algorithm=algorithm, + scaling=scaling, + jac=sos_gradient, + ) + + expected_solution = np.array([0, 0, 0, 3, 4]) + aaae(res.params["value"].to_numpy(), expected_solution) diff --git a/tests/optimagic/parameters/test_conversion.py b/tests/optimagic/parameters/test_conversion.py index b24dfff23..d29d7d32a 100644 --- a/tests/optimagic/parameters/test_conversion.py +++ b/tests/optimagic/parameters/test_conversion.py @@ -1,4 +1,5 @@ import numpy as np +from optimagic.options import ScalingOptions import pytest from optimagic.parameters.conversion import ( _is_fast_deriv_eval, @@ -19,7 +20,6 @@ def test_get_converter_fast_case(): derivative_eval=2 * np.arange(3), primary_key="value", scaling=False, - scaling_options=None, ) aaae(internal.values, np.arange(3)) @@ -45,7 +45,6 @@ def test_get_converter_with_constraints_and_bounds(): derivative_eval=2 * np.arange(3), primary_key="value", scaling=False, - scaling_options=None, ) aaae(internal.values, np.arange(2)) @@ -70,8 +69,7 @@ def test_get_converter_with_scaling(): func_eval=3, derivative_eval=2 * np.arange(3), primary_key="value", - scaling=True, - scaling_options={"method": "start_values", "clipping_value": 0.5}, + scaling=ScalingOptions(method="start_values", clipping_value=0.5), ) aaae(internal.values, np.array([0, 1, 1])) @@ -98,7 +96,6 @@ def test_get_converter_with_trees(): derivative_eval={"a": 0, "b": 2, "c": 4}, primary_key="value", scaling=False, - scaling_options=None, ) aaae(internal.values, np.arange(3)) diff --git a/tests/optimagic/parameters/test_scale_conversion.py b/tests/optimagic/parameters/test_scale_conversion.py index 4415797bf..fcb181028 100644 --- a/tests/optimagic/parameters/test_scale_conversion.py +++ b/tests/optimagic/parameters/test_scale_conversion.py @@ -3,6 +3,7 @@ from optimagic import first_derivative from optimagic.parameters.conversion import InternalParams from optimagic.parameters.scale_conversion import get_scale_converter +from optimagic.options import ScalingOptions from numpy.testing import assert_array_almost_equal as aaae from numpy.testing import assert_array_equal as aae @@ -34,15 +35,14 @@ def test_get_scale_converter_active(method, expected): names=list("abcdef"), ) - scaling_options = { - "method": method, - "clipping_value": 0.5, - } + scaling = ScalingOptions( + method=method, + clipping_value=0.5, + ) converter, scaled = get_scale_converter( internal_params=params, - scaling=True, - scaling_options=scaling_options, + scaling=scaling, ) aaae(scaled.values, expected.values) @@ -72,7 +72,6 @@ def test_scale_conversion_fast_path(): converter, scaled = get_scale_converter( internal_params=params, scaling=False, - scaling_options=None, ) aae(params.values, scaled.values)