From 3a8dce35652c8a1e541efde1999d3260d8448eb6 Mon Sep 17 00:00:00 2001 From: Doresic <85789271+Doresic@users.noreply.github.com> Date: Thu, 7 May 2026 15:48:11 +0200 Subject: [PATCH 1/6] Improve profiling step-size defaults and step-size robustness --- pypesto/profile/options.py | 50 +++++- pypesto/profile/profile_next_guess.py | 52 ++++--- pypesto/profile/task.py | 9 ++ pypesto/profile/util.py | 212 ++++++++++++++++++++++++++ pypesto/profile/walk_along_profile.py | 17 ++- test/profile/test_profile.py | 183 ++++++++++++++++++++++ 6 files changed, 495 insertions(+), 28 deletions(-) diff --git a/pypesto/profile/options.py b/pypesto/profile/options.py index bcc3b805e..746e2458c 100644 --- a/pypesto/profile/options.py +++ b/pypesto/profile/options.py @@ -11,10 +11,22 @@ class ProfileOptions(dict): Default step size of the profiling routine along the profile path (adaptive step lengths algorithms will only use this as a first guess and then refine the update). + default_step_size_relative: + Relative default step size for wide `lin`-scale parameters, expressed + as a fraction of the full parameter span `ub - lb`. The effective + default step size is the maximum of the absolute and relative values. min_step_size: Lower bound for the step size in adaptive methods. + min_step_size_relative: + Relative minimum step size for wide `lin`-scale parameters, expressed + as a fraction of the full parameter span `ub - lb`. The effective + minimum step size is the maximum of the absolute and relative values. max_step_size: Upper bound for the step size in adaptive methods. + max_step_size_relative: + Relative maximum step size for wide `lin`-scale parameters, expressed + as a fraction of the full parameter span `ub - lb`. The effective + maximum step size is the maximum of the absolute and relative values. step_size_factor: Adaptive methods recompute the likelihood at the predicted point and try to find a good step length by a sort of line search algorithm. @@ -38,13 +50,21 @@ class ProfileOptions(dict): whole_path: Whether to profile the whole bounds or only till we get below the ratio. + step_size_precheck_mode: + Behavior of the profile step-size precheck. + Use ``"off"`` to disable the precheck, ``"warn"`` to emit a warning + for suspiciously small steps, and ``"raise"`` to raise an error for + extreme cases. Default: ``"warn"``. """ def __init__( self, - default_step_size: float = 0.01, - min_step_size: float = 0.001, - max_step_size: float = 0.1, + default_step_size: float = 0.02, + default_step_size_relative: float = 0.01, + min_step_size: float = 0.01, + min_step_size_relative: float = 0.005, + max_step_size: float = 0.2, + max_step_size_relative: float = 0.04, step_size_factor: float = 1.25, delta_ratio_max: float = 0.1, ratio_min: float = 0.145, @@ -52,12 +72,16 @@ def __init__( reg_order: int = 4, adaptive_target_scaling_factor: float = 1.5, whole_path: bool = False, + step_size_precheck_mode: str = "warn", ): super().__init__() self.default_step_size = default_step_size + self.default_step_size_relative = default_step_size_relative self.min_step_size = min_step_size + self.min_step_size_relative = min_step_size_relative self.max_step_size = max_step_size + self.max_step_size_relative = max_step_size_relative self.ratio_min = ratio_min self.step_size_factor = step_size_factor self.delta_ratio_max = delta_ratio_max @@ -65,6 +89,7 @@ def __init__( self.reg_order = reg_order self.adaptive_target_scaling_factor = adaptive_target_scaling_factor self.whole_path = whole_path + self.step_size_precheck_mode = step_size_precheck_mode self.validate() @@ -111,6 +136,25 @@ def validate(self): raise ValueError("default_step_size must be <= max_step_size.") if self.default_step_size < self.min_step_size: raise ValueError("default_step_size must be >= min_step_size.") + if self.default_step_size_relative <= 0: + raise ValueError("default_step_size_relative must be > 0.") + if self.min_step_size_relative <= 0: + raise ValueError("min_step_size_relative must be > 0.") + if self.max_step_size_relative <= 0: + raise ValueError("max_step_size_relative must be > 0.") + if self.min_step_size_relative > self.default_step_size_relative: + raise ValueError( + "min_step_size_relative must be <= default_step_size_relative." + ) + if self.default_step_size_relative > self.max_step_size_relative: + raise ValueError( + "default_step_size_relative must be <= max_step_size_relative." + ) if self.adaptive_target_scaling_factor < 1: raise ValueError("adaptive_target_scaling_factor must be > 1.") + if self.step_size_precheck_mode not in {"off", "warn", "raise"}: + raise ValueError( + "step_size_precheck_mode must be one of " + "{'off', 'warn', 'raise'}." + ) diff --git a/pypesto/profile/profile_next_guess.py b/pypesto/profile/profile_next_guess.py index cbeff3b3a..e0c0f9e6c 100644 --- a/pypesto/profile/profile_next_guess.py +++ b/pypesto/profile/profile_next_guess.py @@ -7,6 +7,7 @@ from ..problem import Problem from ..result import ProfilerResult from .options import ProfileOptions +from .util import ResolvedProfileStepSizes, resolve_profile_step_sizes logger = logging.getLogger(__name__) @@ -136,8 +137,9 @@ def fixed_step( ------- The updated parameter vector, of size `dim_full`. """ + resolved_steps = resolve_profile_step_sizes(problem, par_index, options) delta_x = np.zeros(len(x)) - delta_x[par_index] = par_direction * options.default_step_size + delta_x[par_index] = par_direction * resolved_steps.default_step_size # check whether the next point is maybe outside the bounds # and correct it @@ -201,11 +203,14 @@ def adaptive_step( ------- The updated parameter vector, of size `dim_full`. """ + resolved_steps = resolve_profile_step_sizes(problem, par_index, options) + trust_region_max_step = np.full(len(x), options.max_step_size) + trust_region_max_step[par_index] = resolved_steps.max_step_size # restrict step proposal to minimum and maximum step size def clip_to_minmax(step_size_proposal): - min_step_size = options.min_step_size * min_step_increase_factor - max_step_size = options.max_step_size * max_step_reduce_factor + min_step_size = resolved_steps.min_step_size * min_step_increase_factor + max_step_size = resolved_steps.max_step_size * max_step_reduce_factor return np.clip(step_size_proposal, min_step_size, max_step_size) # restrict step proposal to bounds @@ -230,13 +235,16 @@ def clip_to_bounds(step_proposal): current_profile, problem, options, + resolved_steps, ) # check whether we must make a minimum step anyway, since we're close to # the next bound min_delta_x = ( x[par_index] - + par_direction * options.min_step_size * min_step_increase_factor + + par_direction + * resolved_steps.min_step_size + * min_step_increase_factor ) if par_direction == -1 and (min_delta_x < problem.lb_full[par_index]): @@ -275,7 +283,9 @@ def par_extrapol(step_length): # Define a trust region for the step size in all directions # to avoid overshooting x_step = np.clip( - x_step, x - options.max_step_size, x + options.max_step_size + x_step, + x - trust_region_max_step, + x + trust_region_max_step, ) return clip_to_bounds(x_step) @@ -287,8 +297,8 @@ def par_extrapol(step_length): # to avoid overshooting step_in_x = np.clip( step_length * delta_x_dir, - -options.max_step_size, - options.max_step_size, + -trust_region_max_step, + trust_region_max_step, ) x_stepped = x + step_in_x return clip_to_bounds(x_stepped) @@ -339,6 +349,8 @@ def par_extrapol(step_length): par_index, problem, options, + resolved_steps.min_step_size, + resolved_steps.max_step_size, min_step_increase_factor, max_step_reduce_factor, ) @@ -353,7 +365,8 @@ def handle_profile_history( current_profile: ProfilerResult, problem: Problem, options: ProfileOptions, -) -> tuple[float, np.array, list[float], float]: + resolved_steps: ResolvedProfileStepSizes, +) -> tuple[float, np.ndarray, list[float], float, float]: """Compute the very first step direction update guesses. Check whether enough steps have been taken for applying regression, @@ -386,7 +399,7 @@ def handle_profile_history( current_profile.x_path[par_index, -2], ): # try to use the default step size - step_size_guess = options.default_step_size + step_size_guess = resolved_steps.default_step_size delta_obj_value = 0.0 last_delta_fval = 0.0 @@ -398,10 +411,11 @@ def handle_profile_history( ) # Bound the step size by default values step_size_guess = min( - last_delta_x_par_index, options.default_step_size + last_delta_x_par_index, + resolved_steps.default_step_size, ) # Step size cannot be smaller than the minimum step size - step_size_guess = max(step_size_guess, options.min_step_size) + step_size_guess = max(step_size_guess, resolved_steps.min_step_size) delta_obj_value = current_profile.fval_path[-1] - global_opt last_delta_fval = ( @@ -491,6 +505,8 @@ def do_line_search( par_index: int, problem: Problem, options: ProfileOptions, + effective_min_step_size: float, + effective_max_step_size: float, min_step_increase_factor: float, max_step_reduce_factor: float, ) -> np.ndarray: @@ -557,16 +573,14 @@ def do_line_search( next_x = clip_to_bounds(par_extrapol(step_size_guess)) # Check if we hit the bounds - if ( - direction == "decrease" - and step_size_guess - == options.min_step_size * min_step_increase_factor + if direction == "decrease" and np.isclose( + step_size_guess, + effective_min_step_size * min_step_increase_factor, ): return next_x - if ( - direction == "increase" - and step_size_guess - == options.max_step_size * max_step_reduce_factor + if direction == "increase" and np.isclose( + step_size_guess, + effective_max_step_size * max_step_reduce_factor, ): return next_x diff --git a/pypesto/profile/task.py b/pypesto/profile/task.py index 523db01d4..e53a44da7 100644 --- a/pypesto/profile/task.py +++ b/pypesto/profile/task.py @@ -8,6 +8,7 @@ from ..problem import Problem from ..result import ProfilerResult from .options import ProfileOptions +from .util import precheck_profile_step_size from .walk_along_profile import walk_along_profile logger = logging.getLogger(__name__) @@ -70,6 +71,14 @@ def execute(self) -> dict[str, Any]: # flip profile self.current_profile.flip_profile() + precheck_profile_step_size( + current_profile=self.current_profile, + problem=self.problem, + i_par=self.i_par, + par_direction=self.par_direction, + options=self.options, + ) + # compute the current profile self.current_profile = walk_along_profile( current_profile=self.current_profile, diff --git a/pypesto/profile/util.py b/pypesto/profile/util.py index 3ea7a0d00..198da6fbd 100644 --- a/pypesto/profile/util.py +++ b/pypesto/profile/util.py @@ -1,6 +1,8 @@ """Utility function for profile module.""" +import warnings from collections.abc import Iterable +from dataclasses import dataclass from typing import Any import numpy as np @@ -9,6 +11,49 @@ from ..C import GRAD from ..problem import Problem from ..result import ProfileResult, ProfilerResult, Result +from .options import ProfileOptions + +PROFILE_STEP_PRECHECK_NOMINAL_WARN_THRESHOLD = 200 +PROFILE_STEP_PRECHECK_DENSE_WARN_THRESHOLD = 1000 + + +@dataclass(frozen=True) +class ResolvedProfileStepSizes: + """ + Effective step sizes for one profiled parameter. + + Attributes + ---------- + default_step_size: + Effective default step size after combining absolute and relative + settings. + min_step_size: + Effective minimum step size after combining absolute and relative + settings. + max_step_size: + Effective maximum step size after combining absolute and relative + settings. + span: + Full parameter span `ub - lb` if a finite positive span was available + for a `lin`-scale parameter, else `None`. + uses_relative_min: + Whether the effective minimum step size is larger than the configured + absolute minimum due to the relative setting. + uses_relative_default: + Whether the effective default step size is larger than the configured + absolute default due to the relative setting. + uses_relative_max: + Whether the effective maximum step size is larger than the configured + absolute maximum due to the relative setting. + """ + + default_step_size: float + min_step_size: float + max_step_size: float + span: float | None + uses_relative_min: bool + uses_relative_default: bool + uses_relative_max: bool def chi2_quantile_to_ratio(alpha: float = 0.95, df: int = 1): @@ -80,6 +125,173 @@ def calculate_approximate_ci( return lb, ub +def resolve_profile_step_sizes( + problem: Problem, + i_par: int, + options: ProfileOptions, +) -> ResolvedProfileStepSizes: + """ + Resolve effective profile step sizes for one parameter. + + The profiling options expose absolute step-size settings for all + parameters and relative step-size settings for wide `lin`-scale + parameters. This helper combines both into one set of effective values + for the profiled parameter. + + For `lin`-scale parameters with finite positive span `ub - lb`, the + effective step sizes are computed as the maxima of the corresponding + absolute and relative settings. For `log` and `log10` parameters, or if + the span is not finite and positive, the absolute settings are used + unchanged. + + Parameters + ---------- + problem: + The parameter estimation problem containing bounds and scales. + i_par: + Index of the profiled parameter in full dimension. + options: + Profile options containing absolute and relative step-size settings. + + Returns + ------- + resolved_steps: + A :class:`ResolvedProfileStepSizes` dataclass containing the effective + minimum, default, and maximum step sizes for the profiled parameter, + together with metadata describing whether relative settings were + active. + """ + default_step_size = options.default_step_size + min_step_size = options.min_step_size + max_step_size = options.max_step_size + span = None + uses_relative_min = False + uses_relative_default = False + uses_relative_max = False + + scale = str(problem.x_scales[i_par]).lower() + if scale == "lin": + candidate_span = float(problem.ub_full[i_par] - problem.lb_full[i_par]) + if np.isfinite(candidate_span) and candidate_span > 0: + # Compute relative step sizes from the parameter span. + span = candidate_span + relative_min = options.min_step_size_relative * span + relative_default = options.default_step_size_relative * span + relative_max = options.max_step_size_relative * span + + # Use the larger of the absolute and relative step-size settings. + min_step_size = max(min_step_size, relative_min) + default_step_size = max(default_step_size, relative_default) + max_step_size = max( + max_step_size, + relative_max, + default_step_size, + ) + + # Record whether the relative settings changed the effective ones. + uses_relative_min = min_step_size > options.min_step_size + uses_relative_default = ( + default_step_size > options.default_step_size + ) + uses_relative_max = max_step_size > options.max_step_size + + return ResolvedProfileStepSizes( + default_step_size=default_step_size, + min_step_size=min_step_size, + max_step_size=max_step_size, + span=span, + uses_relative_min=uses_relative_min, + uses_relative_default=uses_relative_default, + uses_relative_max=uses_relative_max, + ) + + +def precheck_profile_step_size( + current_profile: ProfilerResult, + problem: Problem, + i_par: int, + par_direction: int, + options: ProfileOptions, +) -> None: + """ + Precheck whether the current step-size settings are suspiciously small. + + The check compares the remaining span in the current profiling direction + against the resolved effective default and minimum step sizes and warns, or + raises, if the resulting number of expected profile points exceeds + configured heuristic thresholds. For `log` and `log10` parameters, the + span and step sizes are interpreted on the transformed optimization scale. + + Parameters + ---------- + current_profile: + The current profile path, used to determine the current parameter + value. + problem: + The parameter estimation problem containing bounds and scales. + i_par: + Index of the profiled parameter in full dimension. + par_direction: + Profiling direction, either `-1` for descending or `1` for ascending. + options: + Profile options controlling the precheck behavior and step-size + settings. + """ + if options.step_size_precheck_mode == "off": + return + + scale = str(problem.x_scales[i_par]).lower() + resolved_steps = resolve_profile_step_sizes(problem, i_par, options) + + x0 = float(current_profile.x_path[i_par, -1]) + if par_direction == -1: + direction_label = "descending" + available_span = x0 - float(problem.lb_full[i_par]) + elif par_direction == 1: + direction_label = "ascending" + available_span = float(problem.ub_full[i_par]) - x0 + else: + raise ValueError("par_direction must be either -1 or 1.") + + if not np.isfinite(available_span) or available_span <= 0: + return + + nominal_count = available_span / resolved_steps.default_step_size + dense_count = available_span / resolved_steps.min_step_size + + # Check whether the expected number of steps exceeds + # the configured thresholds and emit a warning if so. + nominal_warn = nominal_count > PROFILE_STEP_PRECHECK_NOMINAL_WARN_THRESHOLD + dense_warn = dense_count > PROFILE_STEP_PRECHECK_DENSE_WARN_THRESHOLD + if not nominal_warn and not dense_warn: + return + + parameter_name = problem.x_names[i_par] + message = ( + "Profiling precheck: parameter " + f"'{parameter_name}' ({scale}, {direction_label}) may require many " + "profile steps. " + f"available_span={available_span:.6g}, " + f"effective_default_step_size={resolved_steps.default_step_size:.6g}, " + f"effective_min_step_size={resolved_steps.min_step_size:.6g}, " + f"estimated nominal steps={nominal_count:.1f}, " + f"estimated worst-case steps={dense_count:.1f}. " + "Consider increasing the step sizes." + ) + if not options.whole_path: + message += ( + " whole_path=False, so this is a bound-based upper estimate and " + f"the run may stop earlier at ratio_min={options.ratio_min:.6g}." + ) + if dense_warn: + message += " Worst-case step count is especially high." + + if dense_warn and options.step_size_precheck_mode == "raise": + raise ValueError(message) + + warnings.warn(message, UserWarning, stacklevel=2) + + def initialize_profile( problem: Problem, result: Result, diff --git a/pypesto/profile/walk_along_profile.py b/pypesto/profile/walk_along_profile.py index 5cb1d25b3..345188169 100644 --- a/pypesto/profile/walk_along_profile.py +++ b/pypesto/profile/walk_along_profile.py @@ -9,6 +9,7 @@ from ..problem import Problem from ..result import OptimizerResult, ProfilerResult from .options import ProfileOptions +from .util import resolve_profile_step_sizes logger = logging.getLogger(__name__) @@ -60,6 +61,8 @@ def walk_along_profile( if par_direction not in (-1, 1): raise AssertionError("par_direction must be -1 or 1") + resolved_steps = resolve_profile_step_sizes(problem, i_par, options) + # while loop for profiling (will be exited by break command) while True: # get current position on the profile path @@ -86,8 +89,8 @@ def walk_along_profile( while not optimization_successful: # Check max_step_size is not reduced below min_step_size if ( - options.max_step_size * max_step_reduce_factor - < options.min_step_size + resolved_steps.max_step_size * max_step_reduce_factor + < resolved_steps.min_step_size ): logger.warning( "Max step size reduced below min step size. " @@ -134,7 +137,8 @@ def walk_along_profile( max_step_reduce_factor *= 0.5 logger.warning( f"Optimization at {problem.x_names[i_par]}={x_next[i_par]} failed. " - f"Reducing max_step_size to {options.max_step_size * max_step_reduce_factor}." + "Reducing max_step_size to " + f"{resolved_steps.max_step_size * max_step_reduce_factor}." ) else: # if too many parameters are fixed, there is nothing to do ... @@ -167,8 +171,8 @@ def walk_along_profile( while not optimization_successful: # Check min_step_size is not increased above max_step_size if ( - options.min_step_size * min_step_increase_factor - > options.max_step_size + resolved_steps.min_step_size * min_step_increase_factor + > resolved_steps.max_step_size ): logger.warning( "Min step size increased above max step size. " @@ -208,7 +212,8 @@ def walk_along_profile( min_step_increase_factor *= 1.25 logger.warning( f"Optimization at {problem.x_names[i_par]}={x_next[i_par]} failed. " - f"Increasing min_step_size to {options.min_step_size * min_step_increase_factor}." + "Increasing min_step_size to " + f"{resolved_steps.min_step_size * min_step_increase_factor}." ) if not optimization_successful: diff --git a/test/profile/test_profile.py b/test/profile/test_profile.py index aea7556f0..99c840eb0 100644 --- a/test/profile/test_profile.py +++ b/test/profile/test_profile.py @@ -15,6 +15,11 @@ import pypesto.profile as profile import pypesto.visualize as visualize from pypesto import ObjectiveBase +from pypesto.profile.profile_next_guess import adaptive_step, fixed_step +from pypesto.profile.util import ( + precheck_profile_step_size, + resolve_profile_step_sizes, +) from ..util import rosen_for_sensi from ..visualize import close_fig @@ -426,6 +431,12 @@ def test_options_valid(): """Test ProfileOptions validity checks.""" # default settings are valid profile.ProfileOptions() + # A representative hybrid configuration should also validate as a group. + profile.ProfileOptions( + min_step_size_relative=0.0025, + default_step_size_relative=0.005, + max_step_size_relative=0.02, + ) # try to set invalid values with pytest.raises(ValueError): @@ -442,6 +453,178 @@ def test_options_valid(): min_step_size=2, max_step_size=1, ) + for kwargs in ( + {"default_step_size_relative": 0}, + {"min_step_size_relative": 0}, + {"max_step_size_relative": 0}, + { + "min_step_size_relative": 0.006, + "default_step_size_relative": 0.005, + }, + { + "default_step_size_relative": 0.03, + "max_step_size_relative": 0.02, + }, + {"step_size_precheck_mode": "invalid"}, + ): + with pytest.raises(ValueError): + profile.ProfileOptions(**kwargs) + + +@pytest.mark.parametrize( + ( + "scale", + "lb", + "ub", + "expected_min", + "expected_default", + "expected_max", + "uses_relative", + ), + [ + ("lin", 0.0, 100.0, 0.5, 1.0, 4.0, True), + ("lin", 0.0, 1.0, 0.01, 0.02, 0.2, False), + ("log10", -6.0, 6.0, 0.01, 0.02, 0.2, False), + ], +) +def test_resolve_profile_step_sizes( + scale, + lb, + ub, + expected_min, + expected_default, + expected_max, + uses_relative, +): + """Resolved step sizes should only expand for wide linear-scale spans.""" + problem = pypesto.Problem( + objective=pypesto.Objective(fun=lambda x: np.sum(x**2)), + lb=np.array([lb]), + ub=np.array([ub]), + x_scales=[scale], + x_names=["x0"], + ) + resolved_steps = resolve_profile_step_sizes( + problem, + 0, + profile.ProfileOptions(), + ) + + # Wide linear spans should activate the relative settings; narrow linear + # spans and non-linear scales should fall back to the absolute defaults. + assert np.isclose(resolved_steps.min_step_size, expected_min) + assert np.isclose(resolved_steps.default_step_size, expected_default) + assert np.isclose(resolved_steps.max_step_size, expected_max) + assert resolved_steps.uses_relative_min is uses_relative + assert resolved_steps.uses_relative_default is uses_relative + assert resolved_steps.uses_relative_max is uses_relative + if scale == "lin": + assert np.isclose(resolved_steps.span, ub - lb) + else: + assert resolved_steps.span is None + if scale == "lin" and uses_relative: + proposal_problem = pypesto.Problem( + objective=pypesto.Objective(fun=lambda x: 0.01 * x[0]), + lb=np.array([lb]), + ub=np.array([ub]), + x_scales=[scale], + x_names=["x0"], + ) + options = profile.ProfileOptions() + x = np.array([0.0]) + + # Fixed-step profiling should immediately use the resolved default + # step, not the smaller absolute default. + next_fixed = fixed_step(x, 0, 1, options, proposal_problem) + assert np.isclose(next_fixed[0], expected_default) + + current_profile = pypesto.ProfilerResult( + x_path=x[:, np.newaxis], + fval_path=np.array([0.0]), + ratio_path=np.array([1.0]), + ) + # The linear objective makes the adaptive line search keep increasing + # the proposal until it hits the effective max step size. If this + # fails, the adaptive path is still clipping against the old absolute + # max. + next_adaptive = adaptive_step( + x=x, + par_index=0, + par_direction=1, + options=options, + current_profile=current_profile, + problem=proposal_problem, + global_opt=0.0, + order=0, + ) + + assert next_adaptive[0] > options.max_step_size + assert np.isclose(next_adaptive[0], expected_max) + + +@pytest.mark.parametrize( + ("mode", "expect_warning", "expect_raise"), + [ + ("off", False, False), + ("warn", True, False), + ("raise", False, True), + ], +) +def test_profile_step_size_precheck_modes(mode, expect_warning, expect_raise): + """Precheck modes should suppress, warn, or raise on large spans.""" + problem = pypesto.Problem( + objective=pypesto.Objective(fun=lambda x: np.sum(x**2)), + lb=np.array([-5.0]), + ub=np.array([15.0]), + x_scales=["log10"], + x_names=["x0"], + ) + current_profile = pypesto.ProfilerResult( + x_path=np.array([[0.0]]), + fval_path=np.array([0.0]), + ratio_path=np.array([1.0]), + ) + profile_options = profile.ProfileOptions( + step_size_precheck_mode=mode, + whole_path=True, + ) + + if expect_raise: + with pytest.raises(ValueError, match="Profiling precheck"): + precheck_profile_step_size( + current_profile=current_profile, + problem=problem, + i_par=0, + par_direction=1, + options=profile_options, + ) + return + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + precheck_profile_step_size( + current_profile=current_profile, + problem=problem, + i_par=0, + par_direction=1, + options=profile_options, + ) + + precheck_warnings = [ + warning + for warning in caught + if "Profiling precheck" in str(warning.message) + ] + if expect_warning: + assert precheck_warnings + message = str(precheck_warnings[0].message) + assert "log10" in message + assert "available_span" in message + assert "effective_default_step_size" in message + assert "effective_min_step_size" in message + assert "estimated worst-case steps" in message + else: + assert not precheck_warnings @pytest.mark.parametrize( From 3ef1b13eaf79e46c4e80596b7e17c2c5af8249da Mon Sep 17 00:00:00 2001 From: Doresic <85789271+Doresic@users.noreply.github.com> Date: Thu, 7 May 2026 18:36:45 +0200 Subject: [PATCH 2/6] Profile: add multistart optimization for profiling --- pypesto/profile/options.py | 18 +++- pypesto/profile/profile.py | 28 +++++++ pypesto/profile/walk_along_profile.py | 115 +++++++++++++++++++++++--- test/profile/test_profile.py | 67 +++++++++++++++ 4 files changed, 217 insertions(+), 11 deletions(-) diff --git a/pypesto/profile/options.py b/pypesto/profile/options.py index 746e2458c..3003dfbe7 100644 --- a/pypesto/profile/options.py +++ b/pypesto/profile/options.py @@ -50,11 +50,19 @@ class ProfileOptions(dict): whole_path: Whether to profile the whole bounds or only till we get below the ratio. + profile_n_starts: + Number of optimization starts attempted at each profile point. + profile_sampling_sigma: + Standard deviation used for each free coordinate when sampling + additional optimization startpoints from a Gaussian centered at the + point suggested by `profile_next_guess`, in the reduced parameter + space. The profiled parameter itself is already fixed and is therefore + not perturbed. step_size_precheck_mode: Behavior of the profile step-size precheck. Use ``"off"`` to disable the precheck, ``"warn"`` to emit a warning for suspiciously small steps, and ``"raise"`` to raise an error for - extreme cases. Default: ``"warn"``. + extreme cases. """ def __init__( @@ -72,6 +80,8 @@ def __init__( reg_order: int = 4, adaptive_target_scaling_factor: float = 1.5, whole_path: bool = False, + profile_n_starts: int = 6, + profile_sampling_sigma: float = 0.01, step_size_precheck_mode: str = "warn", ): super().__init__() @@ -89,6 +99,8 @@ def __init__( self.reg_order = reg_order self.adaptive_target_scaling_factor = adaptive_target_scaling_factor self.whole_path = whole_path + self.profile_n_starts = profile_n_starts + self.profile_sampling_sigma = profile_sampling_sigma self.step_size_precheck_mode = step_size_precheck_mode self.validate() @@ -153,6 +165,10 @@ def validate(self): if self.adaptive_target_scaling_factor < 1: raise ValueError("adaptive_target_scaling_factor must be > 1.") + if self.profile_n_starts < 1: + raise ValueError("profile_n_starts must be >= 1.") + if self.profile_sampling_sigma <= 0: + raise ValueError("profile_sampling_sigma must be > 0.") if self.step_size_precheck_mode not in {"off", "warn", "raise"}: raise ValueError( "step_size_precheck_mode must be one of " diff --git a/pypesto/profile/profile.py b/pypesto/profile/profile.py index 58082cb31..600fc7b8c 100644 --- a/pypesto/profile/profile.py +++ b/pypesto/profile/profile.py @@ -189,6 +189,34 @@ def create_next_guess( *paired_profiles[p_index] ) + # Report if profiling found a lower function value than the supplied + # optimum so the user notices the profiling is no longer anchored at the + # true best known point. + better_optima = [] + for i_par, profiler_result in enumerate(result.profile_result.list[-1]): + if profiler_result is None: + continue + best_fval = float(np.min(profiler_result.fval_path)) + if best_fval > global_opt or np.isclose( + best_fval, global_opt, rtol=1e-10, atol=1e-8 + ): + continue + better_optima.append((problem.x_names[i_par], best_fval)) + if better_optima: + par_lines = "\n".join( + f" {name}: best fval = {fval:.6g}" for name, fval in better_optima + ) + logger.warning( + "Profiling found lower function values than the supplied " + f"optimization result (fval = {global_opt:.6g}) for:\n" + f"{par_lines}\n" + "This means profiling was started from a suboptimal point, so the " + "profile ratios and confidence thresholds are not anchored to the " + "true global optimum. Re-optimization of the model is suggested. " + "The better profile points can be included as startpoints " + "through `x_guesses` of the optimization problem." + ) + autosave( filename=filename, result=result, diff --git a/pypesto/profile/walk_along_profile.py b/pypesto/profile/walk_along_profile.py index 345188169..567f9538c 100644 --- a/pypesto/profile/walk_along_profile.py +++ b/pypesto/profile/walk_along_profile.py @@ -14,6 +14,83 @@ logger = logging.getLogger(__name__) +def profile_multistart_optimize( + optimizer: Optimizer, + problem: Problem, + startpoint: np.ndarray, + options: ProfileOptions, +) -> OptimizerResult: + """ + Perform optimization at one profile point using multiple starts. + + Additional starts are sampled in the reduced parameter space from a + Gaussian centered at the point suggested by `profile_next_guess`, using + `options.profile_sampling_sigma` as the standard deviation for each free + coordinate. The original suggested startpoint is always included unchanged + as the final start so the helper falls back to the previous single-start + behavior if all sampled alternatives fail, raise, or are worse. + + Parameters + ---------- + optimizer: + The optimizer to use. + problem: + The reduced problem with the profiling parameter already fixed. + startpoint: + Optimization startpoint suggested by `profile_next_guess`, in reduced + space. + options: + Profile options controlling the number of starts and the Gaussian + sampling spread around `startpoint`. + + Returns + ------- + The best finite optimization result across all attempted starts, or the + result from the original startpoint if all sampled starts fail. + """ + if options.profile_n_starts == 1: + return optimizer.minimize( + problem=problem, + x0=startpoint, + id=str(0), + optimize_options=OptimizeOptions(allow_failed_starts=True), + ) + + sampled_startpoints = np.random.normal( + loc=startpoint, + scale=options.profile_sampling_sigma * np.ones_like(startpoint), + size=(options.profile_n_starts - 1, len(startpoint)), + ) + sampled_startpoints = np.clip(sampled_startpoints, problem.lb, problem.ub) + startpoints = np.vstack((sampled_startpoints, startpoint[np.newaxis, :])) + + best_optimizer_result = None + best_fval = np.inf + original_start_result = None + + for i_start, candidate_startpoint in enumerate(startpoints): + optimizer_result = optimizer.minimize( + problem=problem, + x0=candidate_startpoint, + id=str(i_start), + optimize_options=OptimizeOptions(allow_failed_starts=True), + ) + + if i_start == len(startpoints) - 1: + original_start_result = optimizer_result + + if ( + np.isfinite(optimizer_result.fval) + and optimizer_result.fval < best_fval + ): + best_fval = optimizer_result.fval + best_optimizer_result = optimizer_result + + if best_optimizer_result is not None: + return best_optimizer_result + return original_start_result + + def walk_along_profile( current_profile: ProfilerResult, problem: Problem, @@ -61,6 +138,8 @@ def walk_along_profile( if par_direction not in (-1, 1): raise AssertionError("par_direction must be -1 or 1") + # Warn at most once per non-profiled parameter in each profile half. + warned_at_bound: set[int] = set() resolved_steps = resolve_profile_step_sizes(problem, i_par, options) # while loop for profiling (will be exited by break command) @@ -116,13 +195,11 @@ def walk_along_profile( startpoint = x_next[problem.x_free_indices] if startpoint.size > 0: - optimizer_result = optimizer.minimize( + optimizer_result = profile_multistart_optimize( + optimizer=optimizer, problem=problem, - x0=startpoint, - id=str(0), - optimize_options=OptimizeOptions( - allow_failed_starts=False - ), + startpoint=startpoint, + options=options, ) if np.isfinite(optimizer_result.fval): @@ -197,11 +274,11 @@ def walk_along_profile( problem.fix_parameters(i_par, x_next[i_par]) startpoint = x_next[problem.x_free_indices] - optimizer_result = optimizer.minimize( + optimizer_result = profile_multistart_optimize( + optimizer=optimizer, problem=problem, - x0=startpoint, - id=str(0), - optimize_options=OptimizeOptions(allow_failed_starts=False), + startpoint=startpoint, + options=options, ) if np.isfinite(optimizer_result.fval): @@ -268,6 +345,24 @@ def walk_along_profile( f"Optimization successful for {problem.x_names[i_par]}={x_next[i_par]:.4f}. " f"Start fval {problem.objective(x_next[problem.x_free_indices]):.6f}, end fval {optimizer_result.fval:.6f}." ) + + for k, j_par in enumerate(problem.x_free_indices): + x_j = optimizer_result.x[j_par] + lb_j = problem.lb[k] + ub_j = problem.ub[k] + at_lb = abs(x_j - lb_j) <= 1e-8 + at_ub = abs(x_j - ub_j) <= 1e-8 + if (at_lb or at_ub) and j_par not in warned_at_bound: + warned_at_bound.add(j_par) + bound_val = lb_j if at_lb else ub_j + logger.warning( + f"Parameter '{problem.x_names[j_par]}' hit its " + f"{'lower' if at_lb else 'upper'} bound " + f"({bound_val:.4g}) while profiling " + f"'{problem.x_names[i_par]}'. " + "The profile may be constrained near this region." + ) + if optimizer_result[GRAD] is not None: gradnorm = np.linalg.norm( optimizer_result[GRAD][problem.x_free_indices] diff --git a/test/profile/test_profile.py b/test/profile/test_profile.py index 99c840eb0..58164d741 100644 --- a/test/profile/test_profile.py +++ b/test/profile/test_profile.py @@ -20,6 +20,7 @@ precheck_profile_step_size, resolve_profile_step_sizes, ) +from pypesto.profile.walk_along_profile import profile_multistart_optimize from ..util import rosen_for_sensi from ..visualize import close_fig @@ -465,6 +466,8 @@ def test_options_valid(): "default_step_size_relative": 0.03, "max_step_size_relative": 0.02, }, + {"profile_n_starts": 0}, + {"profile_sampling_sigma": 0}, {"step_size_precheck_mode": "invalid"}, ): with pytest.raises(ValueError): @@ -627,6 +630,70 @@ def test_profile_step_size_precheck_modes(mode, expect_warning, expect_raise): assert not precheck_warnings +def test_profile_multistart_optimize_uses_best_start(monkeypatch): + """Multi-start profiling should tolerate failed starts and keep the best finite result.""" + + class DummyOptimizer: + def __init__(self): + self.calls = [] + + def minimize( + self, + problem, + x0=None, + id=None, + history_options=None, + optimize_options=None, + ): + del problem, history_options + self.calls.append(np.array(x0, copy=True)) + if np.isclose(x0[0], 0.5): + if optimize_options.allow_failed_starts: + # Real pyPESTO optimizers back-fill failed tolerated + # starts from history, which leaves them non-finite if no + # useful point was recorded before the exception. + return pypesto.OptimizerResult( + id=id, + x0=np.array(x0, copy=True), + fval=np.inf, + exitflag=-1, + message="sampled start failed", + ) + raise RuntimeError("sampled start failed") + return pypesto.OptimizerResult( + id=id, + x=np.array(x0, copy=True), + fval=float(np.sum(x0**2)), + ) + + problem = pypesto.Problem( + objective=pypesto.Objective(fun=lambda x: x[0] ** 2), + lb=np.array([-1.0]), + ub=np.array([1.0]), + ) + startpoint = np.array([0.8]) + options = profile.ProfileOptions(profile_n_starts=3) + + monkeypatch.setattr( + np.random, + "normal", + lambda loc, scale, size: np.array([[0.5], [0.1]]), + ) + + optimizer = DummyOptimizer() + result = profile_multistart_optimize( + optimizer=optimizer, + problem=problem, + startpoint=startpoint, + options=options, + ) + + assert len(optimizer.calls) == options.profile_n_starts + assert np.allclose(optimizer.calls[-1], startpoint) + assert np.allclose(result.x, np.array([0.1])) + assert np.isclose(result.fval, 0.01) + + @pytest.mark.parametrize( "lb,ub", [(6 * np.ones(5), 10 * np.ones(5)), (-4 * np.ones(5), 1 * np.ones(5))], From d943288279c7532a6e54b4f51000cf2b5a283114 Mon Sep 17 00:00:00 2001 From: Doresic <85789271+Doresic@users.noreply.github.com> Date: Fri, 8 May 2026 14:24:16 +0200 Subject: [PATCH 3/6] Fix extrapolation trust-region not using resolved --- pypesto/profile/profile_next_guess.py | 12 +++++++++-- test/profile/test_profile.py | 29 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/pypesto/profile/profile_next_guess.py b/pypesto/profile/profile_next_guess.py index e0c0f9e6c..7db328289 100644 --- a/pypesto/profile/profile_next_guess.py +++ b/pypesto/profile/profile_next_guess.py @@ -204,8 +204,16 @@ def adaptive_step( The updated parameter vector, of size `dim_full`. """ resolved_steps = resolve_profile_step_sizes(problem, par_index, options) - trust_region_max_step = np.full(len(x), options.max_step_size) - trust_region_max_step[par_index] = resolved_steps.max_step_size + # Fixed parameters should not move during extrapolation, so their trust + # region entries stay at zero. Free parameters get parameter-specific caps. + trust_region_max_step = np.zeros(len(x)) + for i_par in problem.x_free_indices: + if i_par == par_index: + trust_region_max_step[i_par] = resolved_steps.max_step_size + else: + trust_region_max_step[i_par] = resolve_profile_step_sizes( + problem, i_par, options + ).max_step_size # restrict step proposal to minimum and maximum step size def clip_to_minmax(step_size_proposal): diff --git a/test/profile/test_profile.py b/test/profile/test_profile.py index 99c840eb0..eb63a9807 100644 --- a/test/profile/test_profile.py +++ b/test/profile/test_profile.py @@ -561,6 +561,35 @@ def test_resolve_profile_step_sizes( assert next_adaptive[0] > options.max_step_size assert np.isclose(next_adaptive[0], expected_max) + # Extrapolated non-profiled parameters should use their own resolved + # trust-region max step sizes as well. + trust_region_problem = pypesto.Problem( + objective=pypesto.Objective(fun=lambda x: 0.0), + lb=np.array([lb, lb]), + ub=np.array([ub, ub]), + x_scales=[scale, scale], + x_names=["x0", "x1"], + ) + trust_region_profile = pypesto.ProfilerResult( + x_path=np.array([[0.0, 1.0], [0.0, 10.0]]), + fval_path=np.array([0.0, 0.0]), + ratio_path=np.array([1.0, 1.0]), + ) + next_adaptive_with_extrapolation = adaptive_step( + x=np.array([1.0, 10.0]), + par_index=0, + par_direction=1, + options=options, + current_profile=trust_region_profile, + problem=trust_region_problem, + global_opt=0.0, + order=1, + ) + + assert np.isclose( + next_adaptive_with_extrapolation[1], 10.0 + expected_max + ) + @pytest.mark.parametrize( ("mode", "expect_warning", "expect_raise"), From 12e20ebe5504a2a4bbf1acec0163f8c0015791b6 Mon Sep 17 00:00:00 2001 From: Doresic <85789271+Doresic@users.noreply.github.com> Date: Fri, 8 May 2026 17:27:51 +0200 Subject: [PATCH 4/6] Fix small bug We were changing the step size of profiling parameter to 0 by accident. --- pypesto/profile/profile_next_guess.py | 14 ++++++-------- test/profile/test_profile.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/pypesto/profile/profile_next_guess.py b/pypesto/profile/profile_next_guess.py index 7db328289..ff0993b53 100644 --- a/pypesto/profile/profile_next_guess.py +++ b/pypesto/profile/profile_next_guess.py @@ -204,16 +204,14 @@ def adaptive_step( The updated parameter vector, of size `dim_full`. """ resolved_steps = resolve_profile_step_sizes(problem, par_index, options) - # Fixed parameters should not move during extrapolation, so their trust - # region entries stay at zero. Free parameters get parameter-specific caps. + # par_index is set explicitly because it may be currently fixed (it is, + # on every walk_along_profile iteration past the first). trust_region_max_step = np.zeros(len(x)) + trust_region_max_step[par_index] = resolved_steps.max_step_size for i_par in problem.x_free_indices: - if i_par == par_index: - trust_region_max_step[i_par] = resolved_steps.max_step_size - else: - trust_region_max_step[i_par] = resolve_profile_step_sizes( - problem, i_par, options - ).max_step_size + trust_region_max_step[i_par] = resolve_profile_step_sizes( + problem, i_par, options + ).max_step_size # restrict step proposal to minimum and maximum step size def clip_to_minmax(step_size_proposal): diff --git a/test/profile/test_profile.py b/test/profile/test_profile.py index eb63a9807..1fc4e1e17 100644 --- a/test/profile/test_profile.py +++ b/test/profile/test_profile.py @@ -590,6 +590,24 @@ def test_resolve_profile_step_sizes( next_adaptive_with_extrapolation[1], 10.0 + expected_max ) + # When the profiled parameter is already fixed (as it is on every + # walk_along_profile iteration past the first), it must still get its + # own resolved trust-region cap. Otherwise adaptive_step proposes a + # zero step in the profile direction and profiling deadlocks. + trust_region_problem.fix_parameters(0, 1.0) + next_adaptive_with_fixed_profiled = adaptive_step( + x=np.array([1.0, 10.0]), + par_index=0, + par_direction=1, + options=options, + current_profile=trust_region_profile, + problem=trust_region_problem, + global_opt=0.0, + order=1, + ) + assert next_adaptive_with_fixed_profiled[0] > 1.0 + trust_region_problem.unfix_parameters(0) + @pytest.mark.parametrize( ("mode", "expect_warning", "expect_raise"), From f089901ea653b039521d302fe5d9d540ac293520 Mon Sep 17 00:00:00 2001 From: Doresic <85789271+Doresic@users.noreply.github.com> Date: Mon, 18 May 2026 14:50:54 +0200 Subject: [PATCH 5/6] Improve profile step-size resolution - explicit absolute/relative profile step-size options - deprecated old absolute step-size option names - per-parameter step-size family resolution from `ub - lb` - finite-bound validation before profiling - single upfront step-size resolution before profile walking - resolved step sizes passed through profiling tasks/proposals - lightweight many-steps precheck warning/error mode - focused profile tests for options, resolution, precheck --- pypesto/profile/options.py | 159 +++++++++++++------ pypesto/profile/profile.py | 23 ++- pypesto/profile/profile_next_guess.py | 50 +++--- pypesto/profile/task.py | 8 +- pypesto/profile/util.py | 219 ++++++++++++++------------ pypesto/profile/walk_along_profile.py | 7 +- test/profile/test_profile.py | 218 ++++++++++--------------- 7 files changed, 370 insertions(+), 314 deletions(-) diff --git a/pypesto/profile/options.py b/pypesto/profile/options.py index 746e2458c..b328c09b4 100644 --- a/pypesto/profile/options.py +++ b/pypesto/profile/options.py @@ -1,32 +1,42 @@ +import warnings from typing import Union +#: Deprecated ``ProfileOptions`` step-size names mapped to their replacements. +#: Kept for backward compatibility, both as constructor arguments and as +#: attributes. +_DEPRECATED_STEP_SIZE_NAMES = { + "default_step_size": "default_step_size_absolute", + "min_step_size": "min_step_size_absolute", + "max_step_size": "max_step_size_absolute", +} + class ProfileOptions(dict): """ Options for optimization based profiling. + Step sizes can be configured as absolute values or relative fractions of + the parameter span. A family is disabled by setting its default step size + to `0`. For each profiled parameter, pyPESTO uses either the full absolute + family or the full relative family, whichever has the larger default step + size, i.e. whichever of `default_step_size_absolute` and + `default_step_size_relative * (ub - lb)` is larger. + Attributes ---------- - default_step_size: - Default step size of the profiling routine along the profile path - (adaptive step lengths algorithms will only use this as a first guess - and then refine the update). + default_step_size_absolute: + Default absolute profile step size. Set to `0` to disable. default_step_size_relative: - Relative default step size for wide `lin`-scale parameters, expressed - as a fraction of the full parameter span `ub - lb`. The effective - default step size is the maximum of the absolute and relative values. - min_step_size: - Lower bound for the step size in adaptive methods. + Default relative profile step size, as fraction of `ub - lb`. Set to + `0` to disable. + min_step_size_absolute: + Minimum absolute step size in adaptive methods. min_step_size_relative: - Relative minimum step size for wide `lin`-scale parameters, expressed - as a fraction of the full parameter span `ub - lb`. The effective - minimum step size is the maximum of the absolute and relative values. - max_step_size: - Upper bound for the step size in adaptive methods. + Minimum relative step size, as fraction of `ub - lb`. + max_step_size_absolute: + Maximum absolute step size in adaptive methods. max_step_size_relative: - Relative maximum step size for wide `lin`-scale parameters, expressed - as a fraction of the full parameter span `ub - lb`. The effective - maximum step size is the maximum of the absolute and relative values. + Maximum relative step size, as fraction of `ub - lb`. step_size_factor: Adaptive methods recompute the likelihood at the predicted point and try to find a good step length by a sort of line search algorithm. @@ -51,19 +61,20 @@ class ProfileOptions(dict): Whether to profile the whole bounds or only till we get below the ratio. step_size_precheck_mode: - Behavior of the profile step-size precheck. - Use ``"off"`` to disable the precheck, ``"warn"`` to emit a warning - for suspiciously small steps, and ``"raise"`` to raise an error for - extreme cases. Default: ``"warn"``. + Controls the step-size precheck, which estimates how many profile + steps the resolved step sizes imply and reports suspiciously small + steps. One of ``"off"`` (disable the precheck), ``"warn"`` (only ever + emit a warning), or ``"raise"`` (raise an error only for extreme, + worst-case estimates, and warn otherwise). """ def __init__( self, - default_step_size: float = 0.02, + default_step_size_absolute: float = 0.02, default_step_size_relative: float = 0.01, - min_step_size: float = 0.01, + min_step_size_absolute: float = 0.01, min_step_size_relative: float = 0.005, - max_step_size: float = 0.2, + max_step_size_absolute: float = 0.2, max_step_size_relative: float = 0.04, step_size_factor: float = 1.25, delta_ratio_max: float = 0.1, @@ -73,14 +84,44 @@ def __init__( adaptive_target_scaling_factor: float = 1.5, whole_path: bool = False, step_size_precheck_mode: str = "warn", + default_step_size: float | None = None, + min_step_size: float | None = None, + max_step_size: float | None = None, ): super().__init__() - self.default_step_size = default_step_size + # Backward compatibility: the absolute step-size arguments were + # renamed. If an old name is passed, it overrides the new one. + if default_step_size is not None: + warnings.warn( + "`default_step_size` is deprecated. Use " + "`default_step_size_absolute` instead.", + DeprecationWarning, + stacklevel=2, + ) + default_step_size_absolute = default_step_size + if min_step_size is not None: + warnings.warn( + "`min_step_size` is deprecated. Use " + "`min_step_size_absolute` instead.", + DeprecationWarning, + stacklevel=2, + ) + min_step_size_absolute = min_step_size + if max_step_size is not None: + warnings.warn( + "`max_step_size` is deprecated. Use " + "`max_step_size_absolute` instead.", + DeprecationWarning, + stacklevel=2, + ) + max_step_size_absolute = max_step_size + + self.default_step_size_absolute = default_step_size_absolute self.default_step_size_relative = default_step_size_relative - self.min_step_size = min_step_size + self.min_step_size_absolute = min_step_size_absolute self.min_step_size_relative = min_step_size_relative - self.max_step_size = max_step_size + self.max_step_size_absolute = max_step_size_absolute self.max_step_size_relative = max_step_size_relative self.ratio_min = ratio_min self.step_size_factor = step_size_factor @@ -95,6 +136,14 @@ def __init__( def __getattr__(self, key): """Allow usage of keys like attributes.""" + if key in _DEPRECATED_STEP_SIZE_NAMES: + new_key = _DEPRECATED_STEP_SIZE_NAMES[key] + warnings.warn( + f"`{key}` is deprecated. Use `{new_key}` instead.", + DeprecationWarning, + stacklevel=2, + ) + return self[new_key] try: return self[key] except KeyError: @@ -124,31 +173,39 @@ def validate(self): Raises ``ValueError`` if current settings aren't valid. """ - if self.min_step_size <= 0: - raise ValueError("min_step_size must be > 0.") - if self.max_step_size <= 0: - raise ValueError("max_step_size must be > 0.") - if self.min_step_size > self.max_step_size: - raise ValueError("min_step_size must be <= max_step_size.") - if self.default_step_size <= 0: - raise ValueError("default_step_size must be > 0.") - if self.default_step_size > self.max_step_size: - raise ValueError("default_step_size must be <= max_step_size.") - if self.default_step_size < self.min_step_size: - raise ValueError("default_step_size must be >= min_step_size.") - if self.default_step_size_relative <= 0: - raise ValueError("default_step_size_relative must be > 0.") - if self.min_step_size_relative <= 0: - raise ValueError("min_step_size_relative must be > 0.") - if self.max_step_size_relative <= 0: - raise ValueError("max_step_size_relative must be > 0.") - if self.min_step_size_relative > self.default_step_size_relative: - raise ValueError( - "min_step_size_relative must be <= default_step_size_relative." - ) - if self.default_step_size_relative > self.max_step_size_relative: + + def validate_step_size_family(family: str) -> bool: + default_step_size = self[f"default_step_size_{family}"] + min_step_size = self[f"min_step_size_{family}"] + max_step_size = self[f"max_step_size_{family}"] + + if default_step_size < 0: + raise ValueError(f"default_step_size_{family} must be >= 0.") + if default_step_size == 0: + return False + if min_step_size <= 0: + raise ValueError(f"min_step_size_{family} must be > 0.") + if max_step_size <= 0: + raise ValueError(f"max_step_size_{family} must be > 0.") + if min_step_size > default_step_size: + raise ValueError( + f"min_step_size_{family} must be <= " + f"default_step_size_{family}." + ) + if default_step_size > max_step_size: + raise ValueError( + f"default_step_size_{family} must be <= " + f"max_step_size_{family}." + ) + return True + + absolute_enabled = validate_step_size_family("absolute") + relative_enabled = validate_step_size_family("relative") + if not absolute_enabled and not relative_enabled: raise ValueError( - "default_step_size_relative must be <= max_step_size_relative." + "At least one step-size family must be enabled by setting " + "default_step_size_absolute > 0 or " + "default_step_size_relative > 0." ) if self.adaptive_target_scaling_factor < 1: diff --git a/pypesto/profile/profile.py b/pypesto/profile/profile.py index 58082cb31..9b7934aca 100644 --- a/pypesto/profile/profile.py +++ b/pypesto/profile/profile.py @@ -12,7 +12,11 @@ from .options import ProfileOptions from .profile_next_guess import next_guess from .task import ProfilerTask -from .util import initialize_profile +from .util import ( + _format_profile_step_size_resolution_summary, + initialize_profile, + resolve_profile_step_sizes_for_parameters, +) logger = logging.getLogger(__name__) @@ -94,6 +98,13 @@ def parameter_profile( profile_options = ProfileOptions.create_instance(profile_options) profile_options.validate() + # Resolve the step sizes once, up front + resolved_steps_by_par = resolve_profile_step_sizes_for_parameters( + problem=problem, + parameter_indices=problem.x_free_indices, + options=profile_options, + ) + # Create a function handle that will be called later to get the next point. # This function will be used to generate the initial points of optimization # steps in profiling in `walk_along_profile.py` @@ -119,6 +130,7 @@ def create_next_guess( current_profile_, problem_, global_opt_, + resolved_steps_by_par, min_step_increase_factor_, max_step_reduce_factor_, ) @@ -156,6 +168,14 @@ def create_next_guess( i_par=i_par, profile_list=profile_list, ) + resolved_steps = resolved_steps_by_par[i_par] + logger.debug( + _format_profile_step_size_resolution_summary( + problem=problem, + i_par=i_par, + resolved_steps=resolved_steps, + ) + ) # create two tasks for each parameter: in descending and ascending direction for par_direction in [-1, 1]: @@ -165,6 +185,7 @@ def create_next_guess( optimizer=optimizer, options=profile_options, create_next_guess=create_next_guess, + resolved_steps_by_par=resolved_steps_by_par, global_opt=global_opt, i_par=i_par, par_direction=par_direction, diff --git a/pypesto/profile/profile_next_guess.py b/pypesto/profile/profile_next_guess.py index ff0993b53..d09458abf 100644 --- a/pypesto/profile/profile_next_guess.py +++ b/pypesto/profile/profile_next_guess.py @@ -7,7 +7,7 @@ from ..problem import Problem from ..result import ProfilerResult from .options import ProfileOptions -from .util import ResolvedProfileStepSizes, resolve_profile_step_sizes +from .util import ResolvedProfileStepSizeMap, ResolvedProfileStepSizes logger = logging.getLogger(__name__) @@ -28,6 +28,7 @@ def next_guess( current_profile: ProfilerResult, problem: Problem, global_opt: float, + resolved_steps_by_par: ResolvedProfileStepSizeMap, min_step_increase_factor: float = 1.0, max_step_reduce_factor: float = 1.0, ) -> np.ndarray: @@ -60,6 +61,8 @@ def next_guess( The problem to be solved. global_opt: Log-posterior value of the global optimum. + resolved_steps_by_par: + Pre-resolved profile step sizes. min_step_increase_factor: Factor to increase the minimal step size bound. Used only in :func:`adaptive_step`. @@ -73,7 +76,12 @@ def next_guess( """ if update_type == "fixed_step": next_initial_guess = fixed_step( - x, par_index, par_direction, profile_options, problem + x, + par_index, + par_direction, + profile_options, + problem, + resolved_steps_by_par, ) elif update_type == "adaptive_step_order_0": order = 0 @@ -94,6 +102,7 @@ def next_guess( current_profile, problem, global_opt, + resolved_steps_by_par, order, min_step_increase_factor, max_step_reduce_factor, @@ -114,11 +123,12 @@ def fixed_step( par_direction: Literal[1, -1], options: ProfileOptions, problem: Problem, + resolved_steps_by_par: ResolvedProfileStepSizeMap, ) -> np.ndarray: """Most simple method to create the next guess. - Computes the next point based on the fixed step size given by - :attr:`pypesto.profile.ProfileOptions.default_step_size`. + Computes the next point based on the resolved default step size for the + profiled parameter. Parameters ---------- @@ -132,12 +142,14 @@ def fixed_step( Various options applied to the profile optimization. problem: The problem to be solved. + resolved_steps_by_par: + Pre-resolved profile step sizes. Returns ------- The updated parameter vector, of size `dim_full`. """ - resolved_steps = resolve_profile_step_sizes(problem, par_index, options) + resolved_steps = resolved_steps_by_par[par_index] delta_x = np.zeros(len(x)) delta_x[par_index] = par_direction * resolved_steps.default_step_size @@ -160,6 +172,7 @@ def adaptive_step( current_profile: ProfilerResult, problem: Problem, global_opt: float, + resolved_steps_by_par: ResolvedProfileStepSizeMap, order: int = 1, min_step_increase_factor: float = 1.0, max_step_reduce_factor: float = 1.0, @@ -185,6 +198,8 @@ def adaptive_step( The problem to be solved. global_opt: Log-posterior value of the global optimum. + resolved_steps_by_par: + Pre-resolved profile step sizes. order: Specifies the precise algorithm for extrapolation. Available options are: @@ -203,15 +218,10 @@ def adaptive_step( ------- The updated parameter vector, of size `dim_full`. """ - resolved_steps = resolve_profile_step_sizes(problem, par_index, options) - # par_index is set explicitly because it may be currently fixed (it is, - # on every walk_along_profile iteration past the first). + resolved_steps = resolved_steps_by_par[par_index] trust_region_max_step = np.zeros(len(x)) - trust_region_max_step[par_index] = resolved_steps.max_step_size - for i_par in problem.x_free_indices: - trust_region_max_step[i_par] = resolve_profile_step_sizes( - problem, i_par, options - ).max_step_size + for i_step_par, i_resolved_steps in resolved_steps_by_par.items(): + trust_region_max_step[i_step_par] = i_resolved_steps.max_step_size # restrict step proposal to minimum and maximum step size def clip_to_minmax(step_size_proposal): @@ -578,16 +588,12 @@ def do_line_search( step_size_guess = clip_to_minmax(step_size_guess * adapt_factor) next_x = clip_to_bounds(par_extrapol(step_size_guess)) - # Check if we hit the bounds - if direction == "decrease" and np.isclose( - step_size_guess, - effective_min_step_size * min_step_increase_factor, - ): + # Check if the step-size clipping hit the adaptive bounds. + min_step_bound = effective_min_step_size * min_step_increase_factor + max_step_bound = effective_max_step_size * max_step_reduce_factor + if direction == "decrease" and step_size_guess <= min_step_bound: return next_x - if direction == "increase" and np.isclose( - step_size_guess, - effective_max_step_size * max_step_reduce_factor, - ): + if direction == "increase" and step_size_guess >= max_step_bound: return next_x # compute new objective value diff --git a/pypesto/profile/task.py b/pypesto/profile/task.py index e53a44da7..021e8eb7b 100644 --- a/pypesto/profile/task.py +++ b/pypesto/profile/task.py @@ -8,7 +8,7 @@ from ..problem import Problem from ..result import ProfilerResult from .options import ProfileOptions -from .util import precheck_profile_step_size +from .util import ResolvedProfileStepSizeMap, precheck_profile_step_size from .walk_along_profile import walk_along_profile logger = logging.getLogger(__name__) @@ -26,6 +26,7 @@ def __init__( global_opt: float, optimizer: "pypesto.optimize.Optimizer", create_next_guess: Callable, + resolved_steps_by_par: ResolvedProfileStepSizeMap, par_direction: Literal[-1, 1], ): """ @@ -45,6 +46,8 @@ def __init__( Various options applied to the profile optimization. create_next_guess: Handle of the method which creates the next profile point proposal + resolved_steps_by_par: + Pre-resolved profile step sizes. i_par: index for the current parameter par_direction: @@ -58,6 +61,7 @@ def __init__( self.current_profile = current_profile self.global_opt = global_opt self.create_next_guess = create_next_guess + self.resolved_steps_by_par = resolved_steps_by_par self.i_par = i_par self.options = options self.par_direction = par_direction @@ -77,6 +81,7 @@ def execute(self) -> dict[str, Any]: i_par=self.i_par, par_direction=self.par_direction, options=self.options, + resolved_steps=self.resolved_steps_by_par[self.i_par], ) # compute the current profile @@ -87,6 +92,7 @@ def execute(self) -> dict[str, Any]: optimizer=self.optimizer, options=self.options, create_next_guess=self.create_next_guess, + resolved_steps_by_par=self.resolved_steps_by_par, global_opt=self.global_opt, i_par=self.i_par, ) diff --git a/pypesto/profile/util.py b/pypesto/profile/util.py index 198da6fbd..d872f8b2c 100644 --- a/pypesto/profile/util.py +++ b/pypesto/profile/util.py @@ -3,7 +3,7 @@ import warnings from collections.abc import Iterable from dataclasses import dataclass -from typing import Any +from typing import Any, Literal import numpy as np import scipy.stats @@ -22,38 +22,31 @@ class ResolvedProfileStepSizes: """ Effective step sizes for one profiled parameter. + The minimum, default, and maximum values always come from the same + step-size family. + Attributes ---------- + mode: + Selected step-size family, either `"absolute"` or `"relative"`. default_step_size: - Effective default step size after combining absolute and relative - settings. + Resolved default step size. min_step_size: - Effective minimum step size after combining absolute and relative - settings. + Resolved minimum step size. max_step_size: - Effective maximum step size after combining absolute and relative - settings. + Resolved maximum step size. span: - Full parameter span `ub - lb` if a finite positive span was available - for a `lin`-scale parameter, else `None`. - uses_relative_min: - Whether the effective minimum step size is larger than the configured - absolute minimum due to the relative setting. - uses_relative_default: - Whether the effective default step size is larger than the configured - absolute default due to the relative setting. - uses_relative_max: - Whether the effective maximum step size is larger than the configured - absolute maximum due to the relative setting. + Parameter span `ub - lb` on the optimization scale. """ + mode: Literal["absolute", "relative"] default_step_size: float min_step_size: float max_step_size: float - span: float | None - uses_relative_min: bool - uses_relative_default: bool - uses_relative_max: bool + span: float + + +ResolvedProfileStepSizeMap = dict[int, ResolvedProfileStepSizes] def chi2_quantile_to_ratio(alpha: float = 0.95, df: int = 1): @@ -125,6 +118,24 @@ def calculate_approximate_ci( return lb, ub +def validate_profile_parameter_bounds(problem: Problem, i_par: int) -> float: + """Validate finite profile bounds for one parameter and return its span.""" + lb = float(problem.lb_full[i_par]) + ub = float(problem.ub_full[i_par]) + if not np.isfinite(lb) or not np.isfinite(ub): + raise ValueError( + "Profiling requires finite lower and upper bounds for parameter " + f"'{problem.x_names[i_par]}' (index={i_par})." + ) + span = ub - lb + if span <= 0: + raise ValueError( + "Profiling requires an upper bound greater than the lower bound " + f"for parameter '{problem.x_names[i_par]}' (index={i_par})." + ) + return span + + def resolve_profile_step_sizes( problem: Problem, i_par: int, @@ -133,16 +144,10 @@ def resolve_profile_step_sizes( """ Resolve effective profile step sizes for one parameter. - The profiling options expose absolute step-size settings for all - parameters and relative step-size settings for wide `lin`-scale - parameters. This helper combines both into one set of effective values - for the profiled parameter. - - For `lin`-scale parameters with finite positive span `ub - lb`, the - effective step sizes are computed as the maxima of the corresponding - absolute and relative settings. For `log` and `log10` parameters, or if - the span is not finite and positive, the absolute settings are used - unchanged. + Relative step sizes are scaled by the parameter span `ub - lb`. If the + resolved relative default is at least as large as the absolute default, + the full relative family is used. Otherwise the full absolute family is + used. Parameters ---------- @@ -156,53 +161,68 @@ def resolve_profile_step_sizes( Returns ------- resolved_steps: - A :class:`ResolvedProfileStepSizes` dataclass containing the effective - minimum, default, and maximum step sizes for the profiled parameter, - together with metadata describing whether relative settings were - active. + Resolved step sizes and selection metadata. """ - default_step_size = options.default_step_size - min_step_size = options.min_step_size - max_step_size = options.max_step_size - span = None - uses_relative_min = False - uses_relative_default = False - uses_relative_max = False - - scale = str(problem.x_scales[i_par]).lower() - if scale == "lin": - candidate_span = float(problem.ub_full[i_par] - problem.lb_full[i_par]) - if np.isfinite(candidate_span) and candidate_span > 0: - # Compute relative step sizes from the parameter span. - span = candidate_span - relative_min = options.min_step_size_relative * span - relative_default = options.default_step_size_relative * span - relative_max = options.max_step_size_relative * span - - # Use the larger of the absolute and relative step-size settings. - min_step_size = max(min_step_size, relative_min) - default_step_size = max(default_step_size, relative_default) - max_step_size = max( - max_step_size, - relative_max, - default_step_size, + # Bounds are required here because relative steps are defined from the + # finite parameter span on the optimization scale. + span = validate_profile_parameter_bounds(problem, i_par) + + if options.default_step_size_relative > 0: + relative_default_step_size = options.default_step_size_relative * span + relative_min_step_size = options.min_step_size_relative * span + relative_max_step_size = options.max_step_size_relative * span + + # Select one complete step-size family based on the default step size. + if ( + options.default_step_size_absolute == 0 + or relative_default_step_size >= options.default_step_size_absolute + ): + return ResolvedProfileStepSizes( + mode="relative", + default_step_size=relative_default_step_size, + min_step_size=relative_min_step_size, + max_step_size=relative_max_step_size, + span=span, ) - # Record whether the relative settings changed the effective ones. - uses_relative_min = min_step_size > options.min_step_size - uses_relative_default = ( - default_step_size > options.default_step_size - ) - uses_relative_max = max_step_size > options.max_step_size - return ResolvedProfileStepSizes( - default_step_size=default_step_size, - min_step_size=min_step_size, - max_step_size=max_step_size, + mode="absolute", + default_step_size=options.default_step_size_absolute, + min_step_size=options.min_step_size_absolute, + max_step_size=options.max_step_size_absolute, span=span, - uses_relative_min=uses_relative_min, - uses_relative_default=uses_relative_default, - uses_relative_max=uses_relative_max, + ) + + +def resolve_profile_step_sizes_for_parameters( + problem: Problem, + parameter_indices: Iterable[int], + options: ProfileOptions, +) -> ResolvedProfileStepSizeMap: + """Resolve effective profile step sizes for multiple parameters.""" + return { + i_par: resolve_profile_step_sizes(problem, i_par, options) + for i_par in parameter_indices + } + + +def _format_profile_step_size_resolution_summary( + problem: Problem, + i_par: int, + resolved_steps: ResolvedProfileStepSizes, +) -> str: + """Create a one-line summary of the resolved step-size family.""" + scale = str(problem.x_scales[i_par]).lower() + parameter_name = problem.x_names[i_par] + + return ( + "Resolved profile step sizes for " + f"{parameter_name} (index={i_par}): " + f"family={resolved_steps.mode}, " + f"scale={scale}, span={resolved_steps.span}, " + f"min={resolved_steps.min_step_size}, " + f"default={resolved_steps.default_step_size}, " + f"max={resolved_steps.max_step_size}." ) @@ -212,43 +232,40 @@ def precheck_profile_step_size( i_par: int, par_direction: int, options: ProfileOptions, + resolved_steps: ResolvedProfileStepSizes, ) -> None: """ - Precheck whether the current step-size settings are suspiciously small. + Warn or raise if the resolved step sizes imply many profile steps. - The check compares the remaining span in the current profiling direction - against the resolved effective default and minimum step sizes and warns, or - raises, if the resulting number of expected profile points exceeds - configured heuristic thresholds. For `log` and `log10` parameters, the - span and step sizes are interpreted on the transformed optimization scale. + Two estimates are formed: a nominal one from the default step size and a + worst-case one from the minimum step size. In ``"raise"`` mode, an error + is raised only when the worst-case estimate is excessive; a merely large + nominal estimate only triggers a warning, so valid runs are not broken. Parameters ---------- current_profile: - The current profile path, used to determine the current parameter - value. + Current profile path. problem: - The parameter estimation problem containing bounds and scales. + The parameter estimation problem. i_par: Index of the profiled parameter in full dimension. par_direction: Profiling direction, either `-1` for descending or `1` for ascending. options: - Profile options controlling the precheck behavior and step-size - settings. + Profile options. + resolved_steps: + Pre-resolved step sizes for the profiled parameter. """ if options.step_size_precheck_mode == "off": return - scale = str(problem.x_scales[i_par]).lower() - resolved_steps = resolve_profile_step_sizes(problem, i_par, options) - + # Estimate how much of the bounded parameter range is left in this + # profiling direction. x0 = float(current_profile.x_path[i_par, -1]) if par_direction == -1: - direction_label = "descending" available_span = x0 - float(problem.lb_full[i_par]) elif par_direction == 1: - direction_label = "ascending" available_span = float(problem.ub_full[i_par]) - x0 else: raise ValueError("par_direction must be either -1 or 1.") @@ -256,11 +273,10 @@ def precheck_profile_step_size( if not np.isfinite(available_span) or available_span <= 0: return + # Use the resolved default and minimum steps as nominal and dense estimates. nominal_count = available_span / resolved_steps.default_step_size dense_count = available_span / resolved_steps.min_step_size - # Check whether the expected number of steps exceeds - # the configured thresholds and emit a warning if so. nominal_warn = nominal_count > PROFILE_STEP_PRECHECK_NOMINAL_WARN_THRESHOLD dense_warn = dense_count > PROFILE_STEP_PRECHECK_DENSE_WARN_THRESHOLD if not nominal_warn and not dense_warn: @@ -268,23 +284,16 @@ def precheck_profile_step_size( parameter_name = problem.x_names[i_par] message = ( - "Profiling precheck: parameter " - f"'{parameter_name}' ({scale}, {direction_label}) may require many " - "profile steps. " - f"available_span={available_span:.6g}, " - f"effective_default_step_size={resolved_steps.default_step_size:.6g}, " - f"effective_min_step_size={resolved_steps.min_step_size:.6g}, " - f"estimated nominal steps={nominal_count:.1f}, " - f"estimated worst-case steps={dense_count:.1f}. " - "Consider increasing the step sizes." + f"Profiling parameter '{parameter_name}' may require many steps " + f"({nominal_count:.1f} with the default step size, " + f"up to {dense_count:.1f} with the minimum step size). " + "Consider increasing the profile step sizes." ) if not options.whole_path: message += ( - " whole_path=False, so this is a bound-based upper estimate and " - f"the run may stop earlier at ratio_min={options.ratio_min:.6g}." + " This is a bound-based upper estimate; profiling may stop " + "earlier at the likelihood-ratio threshold." ) - if dense_warn: - message += " Worst-case step count is especially high." if dense_warn and options.step_size_precheck_mode == "raise": raise ValueError(message) diff --git a/pypesto/profile/walk_along_profile.py b/pypesto/profile/walk_along_profile.py index 345188169..be85b0523 100644 --- a/pypesto/profile/walk_along_profile.py +++ b/pypesto/profile/walk_along_profile.py @@ -9,7 +9,7 @@ from ..problem import Problem from ..result import OptimizerResult, ProfilerResult from .options import ProfileOptions -from .util import resolve_profile_step_sizes +from .util import ResolvedProfileStepSizeMap logger = logging.getLogger(__name__) @@ -21,6 +21,7 @@ def walk_along_profile( optimizer: Optimizer, options: ProfileOptions, create_next_guess: Callable, + resolved_steps_by_par: ResolvedProfileStepSizeMap, global_opt: float, i_par: int, max_tries: int = 10, @@ -48,6 +49,8 @@ def walk_along_profile( Various options applied to the profile optimization. create_next_guess: Handle of the method which creates the next profile point proposal + resolved_steps_by_par: + Pre-resolved profile step sizes. i_par: index for the current parameter max_tries: @@ -61,7 +64,7 @@ def walk_along_profile( if par_direction not in (-1, 1): raise AssertionError("par_direction must be -1 or 1") - resolved_steps = resolve_profile_step_sizes(problem, i_par, options) + resolved_steps = resolved_steps_by_par[i_par] # while loop for profiling (will be exited by break command) while True: diff --git a/test/profile/test_profile.py b/test/profile/test_profile.py index 1fc4e1e17..faf062509 100644 --- a/test/profile/test_profile.py +++ b/test/profile/test_profile.py @@ -15,10 +15,10 @@ import pypesto.profile as profile import pypesto.visualize as visualize from pypesto import ObjectiveBase -from pypesto.profile.profile_next_guess import adaptive_step, fixed_step from pypesto.profile.util import ( precheck_profile_step_size, resolve_profile_step_sizes, + resolve_profile_step_sizes_for_parameters, ) from ..util import rosen_for_sensi @@ -150,9 +150,9 @@ def test_engine_profiling(self): def test_selected_profiling(self): # create options in order to ensure a short computation time options = profile.ProfileOptions( - default_step_size=0.02, - min_step_size=0.005, - max_step_size=1.0, + default_step_size_absolute=0.02, + min_step_size_absolute=0.005, + max_step_size_absolute=1.0, step_size_factor=1.5, delta_ratio_max=0.2, ratio_min=0.3, @@ -285,9 +285,9 @@ def test_profile_with_history(): ) profile_options = profile.ProfileOptions( - min_step_size=0.0005, + min_step_size_absolute=0.0005, delta_ratio_max=0.05, - default_step_size=0.005, + default_step_size_absolute=0.005, ratio_min=0.03, ) @@ -357,6 +357,10 @@ def test_profile_with_fixed_parameters(): # test profiling with all parameters fixed but one problem.fix_parameters([2, 3, 4], result.optimize_result.list[0]["x"][2:5]) + resolved_steps_by_par = resolve_profile_step_sizes_for_parameters( + problem, problem.x_free_indices, profile.ProfileOptions() + ) + assert set(resolved_steps_by_par) == set(problem.x_free_indices) profile.parameter_profile( problem=problem, result=result, @@ -431,7 +435,6 @@ def test_options_valid(): """Test ProfileOptions validity checks.""" # default settings are valid profile.ProfileOptions() - # A representative hybrid configuration should also validate as a group. profile.ProfileOptions( min_step_size_relative=0.0025, default_step_size_relative=0.005, @@ -440,23 +443,32 @@ def test_options_valid(): # try to set invalid values with pytest.raises(ValueError): - profile.ProfileOptions(default_step_size=-1) - with pytest.raises(ValueError): - profile.ProfileOptions(default_step_size=1, min_step_size=2) - with pytest.raises(ValueError): - profile.ProfileOptions( - default_step_size=2, - min_step_size=1, - ) + profile.ProfileOptions(default_step_size_absolute=-1) with pytest.raises(ValueError): - profile.ProfileOptions( - min_step_size=2, - max_step_size=1, + profile.ProfileOptions(default_step_size_relative=-0.01) + with pytest.warns(DeprecationWarning, match="`default_step_size`"): + options = profile.ProfileOptions(default_step_size=0.05) + assert options.default_step_size_absolute == 0.05 + # the deprecated argument overrides the new one + with pytest.warns(DeprecationWarning, match="`default_step_size`"): + options = profile.ProfileOptions( + default_step_size=0.01, + default_step_size_absolute=0.03, ) + assert options.default_step_size_absolute == 0.01 + # the deprecated attribute is still readable + with pytest.warns(DeprecationWarning, match="`default_step_size`"): + assert options.default_step_size == 0.01 for kwargs in ( - {"default_step_size_relative": 0}, - {"min_step_size_relative": 0}, - {"max_step_size_relative": 0}, + { + "default_step_size_absolute": 1, + "min_step_size_absolute": 2, + }, + { + "default_step_size_absolute": 2, + "min_step_size_absolute": 1, + "max_step_size_absolute": 1, + }, { "min_step_size_relative": 0.006, "default_step_size_relative": 0.005, @@ -465,6 +477,10 @@ def test_options_valid(): "default_step_size_relative": 0.03, "max_step_size_relative": 0.02, }, + { + "default_step_size_absolute": 0.0, + "default_step_size_relative": 0.0, + }, {"step_size_precheck_mode": "invalid"}, ): with pytest.raises(ValueError): @@ -476,27 +492,46 @@ def test_options_valid(): "scale", "lb", "ub", + "profile_options", "expected_min", "expected_default", "expected_max", - "uses_relative", + "expected_mode", ), [ - ("lin", 0.0, 100.0, 0.5, 1.0, 4.0, True), - ("lin", 0.0, 1.0, 0.01, 0.02, 0.2, False), - ("log10", -6.0, 6.0, 0.01, 0.02, 0.2, False), + ("lin", 0.0, 100.0, None, 0.5, 1.0, 4.0, "relative"), + ("lin", 0.0, 1.0, None, 0.01, 0.02, 0.2, "absolute"), + ("log10", -6.0, 6.0, None, 0.06, 0.12, 0.48, "relative"), + ( + "lin", + 0.0, + 100.0, + profile.ProfileOptions( + min_step_size_absolute=0.1, + default_step_size_absolute=0.5, + max_step_size_absolute=10.0, + min_step_size_relative=0.002, + default_step_size_relative=0.005, + max_step_size_relative=0.006, + ), + 0.2, + 0.5, + 0.6, + "relative", + ), ], ) def test_resolve_profile_step_sizes( scale, lb, ub, + profile_options, expected_min, expected_default, expected_max, - uses_relative, + expected_mode, ): - """Resolved step sizes should only expand for wide linear-scale spans.""" + """Resolved step sizes should pick one family on the optimization scale.""" problem = pypesto.Problem( objective=pypesto.Objective(fun=lambda x: np.sum(x**2)), lb=np.array([lb]), @@ -507,106 +542,22 @@ def test_resolve_profile_step_sizes( resolved_steps = resolve_profile_step_sizes( problem, 0, - profile.ProfileOptions(), + profile_options or profile.ProfileOptions(), ) - # Wide linear spans should activate the relative settings; narrow linear - # spans and non-linear scales should fall back to the absolute defaults. assert np.isclose(resolved_steps.min_step_size, expected_min) assert np.isclose(resolved_steps.default_step_size, expected_default) assert np.isclose(resolved_steps.max_step_size, expected_max) - assert resolved_steps.uses_relative_min is uses_relative - assert resolved_steps.uses_relative_default is uses_relative - assert resolved_steps.uses_relative_max is uses_relative - if scale == "lin": - assert np.isclose(resolved_steps.span, ub - lb) - else: - assert resolved_steps.span is None - if scale == "lin" and uses_relative: - proposal_problem = pypesto.Problem( - objective=pypesto.Objective(fun=lambda x: 0.01 * x[0]), - lb=np.array([lb]), - ub=np.array([ub]), - x_scales=[scale], - x_names=["x0"], - ) - options = profile.ProfileOptions() - x = np.array([0.0]) - - # Fixed-step profiling should immediately use the resolved default - # step, not the smaller absolute default. - next_fixed = fixed_step(x, 0, 1, options, proposal_problem) - assert np.isclose(next_fixed[0], expected_default) - - current_profile = pypesto.ProfilerResult( - x_path=x[:, np.newaxis], - fval_path=np.array([0.0]), - ratio_path=np.array([1.0]), - ) - # The linear objective makes the adaptive line search keep increasing - # the proposal until it hits the effective max step size. If this - # fails, the adaptive path is still clipping against the old absolute - # max. - next_adaptive = adaptive_step( - x=x, - par_index=0, - par_direction=1, - options=options, - current_profile=current_profile, - problem=proposal_problem, - global_opt=0.0, - order=0, - ) - - assert next_adaptive[0] > options.max_step_size - assert np.isclose(next_adaptive[0], expected_max) - - # Extrapolated non-profiled parameters should use their own resolved - # trust-region max step sizes as well. - trust_region_problem = pypesto.Problem( - objective=pypesto.Objective(fun=lambda x: 0.0), - lb=np.array([lb, lb]), - ub=np.array([ub, ub]), - x_scales=[scale, scale], - x_names=["x0", "x1"], - ) - trust_region_profile = pypesto.ProfilerResult( - x_path=np.array([[0.0, 1.0], [0.0, 10.0]]), - fval_path=np.array([0.0, 0.0]), - ratio_path=np.array([1.0, 1.0]), - ) - next_adaptive_with_extrapolation = adaptive_step( - x=np.array([1.0, 10.0]), - par_index=0, - par_direction=1, - options=options, - current_profile=trust_region_profile, - problem=trust_region_problem, - global_opt=0.0, - order=1, - ) - - assert np.isclose( - next_adaptive_with_extrapolation[1], 10.0 + expected_max - ) - - # When the profiled parameter is already fixed (as it is on every - # walk_along_profile iteration past the first), it must still get its - # own resolved trust-region cap. Otherwise adaptive_step proposes a - # zero step in the profile direction and profiling deadlocks. - trust_region_problem.fix_parameters(0, 1.0) - next_adaptive_with_fixed_profiled = adaptive_step( - x=np.array([1.0, 10.0]), - par_index=0, - par_direction=1, - options=options, - current_profile=trust_region_profile, - problem=trust_region_problem, - global_opt=0.0, - order=1, - ) - assert next_adaptive_with_fixed_profiled[0] > 1.0 - trust_region_problem.unfix_parameters(0) + assert resolved_steps.mode == expected_mode + assert np.isclose(resolved_steps.span, ub - lb) + assert ( + resolve_profile_step_sizes_for_parameters( + problem, + [0], + profile_options or profile.ProfileOptions(), + )[0] + == resolved_steps + ) @pytest.mark.parametrize( @@ -632,18 +583,23 @@ def test_profile_step_size_precheck_modes(mode, expect_warning, expect_raise): ratio_path=np.array([1.0]), ) profile_options = profile.ProfileOptions( + min_step_size_relative=0.0005, + default_step_size_relative=0.001, + max_step_size_relative=0.01, step_size_precheck_mode=mode, whole_path=True, ) + resolved_steps = resolve_profile_step_sizes(problem, 0, profile_options) if expect_raise: - with pytest.raises(ValueError, match="Profiling precheck"): + with pytest.raises(ValueError, match="may require many steps"): precheck_profile_step_size( current_profile=current_profile, problem=problem, i_par=0, par_direction=1, options=profile_options, + resolved_steps=resolved_steps, ) return @@ -655,21 +611,19 @@ def test_profile_step_size_precheck_modes(mode, expect_warning, expect_raise): i_par=0, par_direction=1, options=profile_options, + resolved_steps=resolved_steps, ) precheck_warnings = [ warning for warning in caught - if "Profiling precheck" in str(warning.message) + if "may require many steps" in str(warning.message) ] if expect_warning: assert precheck_warnings message = str(precheck_warnings[0].message) - assert "log10" in message - assert "available_span" in message - assert "effective_default_step_size" in message - assert "effective_min_step_size" in message - assert "estimated worst-case steps" in message + assert "default step size" in message + assert "minimum step size" in message else: assert not precheck_warnings @@ -708,10 +662,10 @@ def test_gh1165(lb, ub): profile_index=[par_idx], progress_bar=False, profile_options=profile.ProfileOptions( - min_step_size=0.1, - max_step_size=1.0, + min_step_size_absolute=0.1, + max_step_size_absolute=1.0, delta_ratio_max=0.05, - default_step_size=0.5, + default_step_size_absolute=0.5, ratio_min=0.01, whole_path=True, ), From 0440810a879cf96159e2ddc8cad5e8fd34c55ac2 Mon Sep 17 00:00:00 2001 From: Doresic <85789271+Doresic@users.noreply.github.com> Date: Fri, 22 May 2026 16:27:44 +0200 Subject: [PATCH 6/6] Change relative defaults --- pypesto/profile/options.py | 6 +++--- pypesto/profile/util.py | 2 +- test/profile/test_profile.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pypesto/profile/options.py b/pypesto/profile/options.py index b328c09b4..11bcfb87a 100644 --- a/pypesto/profile/options.py +++ b/pypesto/profile/options.py @@ -71,11 +71,11 @@ class ProfileOptions(dict): def __init__( self, default_step_size_absolute: float = 0.02, - default_step_size_relative: float = 0.01, + default_step_size_relative: float = 0.0025, min_step_size_absolute: float = 0.01, - min_step_size_relative: float = 0.005, + min_step_size_relative: float = 0.00125, max_step_size_absolute: float = 0.2, - max_step_size_relative: float = 0.04, + max_step_size_relative: float = 0.025, step_size_factor: float = 1.25, delta_ratio_max: float = 0.1, ratio_min: float = 0.145, diff --git a/pypesto/profile/util.py b/pypesto/profile/util.py index d872f8b2c..4a4303f84 100644 --- a/pypesto/profile/util.py +++ b/pypesto/profile/util.py @@ -13,7 +13,7 @@ from ..result import ProfileResult, ProfilerResult, Result from .options import ProfileOptions -PROFILE_STEP_PRECHECK_NOMINAL_WARN_THRESHOLD = 200 +PROFILE_STEP_PRECHECK_NOMINAL_WARN_THRESHOLD = 500 PROFILE_STEP_PRECHECK_DENSE_WARN_THRESHOLD = 1000 diff --git a/test/profile/test_profile.py b/test/profile/test_profile.py index faf062509..64f5c6926 100644 --- a/test/profile/test_profile.py +++ b/test/profile/test_profile.py @@ -499,9 +499,9 @@ def test_options_valid(): "expected_mode", ), [ - ("lin", 0.0, 100.0, None, 0.5, 1.0, 4.0, "relative"), + ("lin", 0.0, 100.0, None, 0.125, 0.25, 2.5, "relative"), ("lin", 0.0, 1.0, None, 0.01, 0.02, 0.2, "absolute"), - ("log10", -6.0, 6.0, None, 0.06, 0.12, 0.48, "relative"), + ("log10", -6.0, 6.0, None, 0.015, 0.03, 0.3, "relative"), ( "lin", 0.0,