-
Notifications
You must be signed in to change notification settings - Fork 2
[7] feat: add step size estimator #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,191 @@ | ||||||
| from collections.abc import Callable | ||||||
| from dataclasses import dataclass, field | ||||||
|
|
||||||
| import numpy as np | ||||||
| from scipy.optimize import minimize | ||||||
|
|
||||||
| from lonpy.sampling import BasinHoppingSampler, BasinHoppingSamplerConfig | ||||||
|
|
||||||
|
|
||||||
| @dataclass | ||||||
| class StepSizeEstimatorConfig: | ||||||
| """ | ||||||
| Configuration for step size estimation. | ||||||
|
|
||||||
| Attributes: | ||||||
| n_samples: Number of random initial points to evaluate. | ||||||
| n_perturbations: Perturbations per sample point. | ||||||
| target_escape_rate: Target escape rate to find (0.5 = 50% of perturbations escape). | ||||||
| search_precision: Decimal digits of precision for step size search. | ||||||
| coordinate_precision: Precision for identifying distinct optima. | ||||||
| Use None for full double precision. | ||||||
| minimizer_method: Scipy minimizer method (default: "L-BFGS-B"). | ||||||
| minimizer_options: Options passed to scipy.optimize.minimize. | ||||||
| bounded: Whether to enforce domain bounds during perturbation. | ||||||
| seed: Random seed for reproducibility. | ||||||
| """ | ||||||
|
|
||||||
| n_samples: int = 100 | ||||||
| n_perturbations: int = 30 | ||||||
| target_escape_rate: float = 0.5 | ||||||
| search_precision: int = 4 | ||||||
|
||||||
| coordinate_precision: int | None = 4 | ||||||
| minimizer_method: str = "L-BFGS-B" | ||||||
| minimizer_options: dict = field( | ||||||
| default_factory=lambda: {"ftol": 1e-07, "gtol": 0, "maxiter": 15000} | ||||||
| ) | ||||||
| bounded: bool = True | ||||||
| seed: int | None = None | ||||||
|
|
||||||
|
|
||||||
| @dataclass(frozen=True) | ||||||
| class StepSizeResult: | ||||||
| """ | ||||||
| Result of step size estimation. | ||||||
|
|
||||||
| Attributes: | ||||||
| step_size: Estimated optimal step size (percentage of domain range). | ||||||
| escape_rate: Achieved escape rate at this step size. | ||||||
| error: Absolute difference between achieved and target escape rate. | ||||||
| """ | ||||||
|
|
||||||
| step_size: float | ||||||
| escape_rate: float | ||||||
| error: float | ||||||
|
|
||||||
|
|
||||||
| class StepSizeEstimator: | ||||||
| """ | ||||||
| Estimates the optimal fixed step size for basin-hopping sampling. | ||||||
|
|
||||||
| The optimal step size is defined as the one that produces an escape rate | ||||||
| closest to a target (default 0.5), meaning ~50% of perturbations lead to | ||||||
| a different local optimum. | ||||||
|
|
||||||
| The search uses a decimal refinement approach, progressively narrowing | ||||||
| the step size to the configured precision. | ||||||
|
|
||||||
| Example: | ||||||
| >>> import numpy as np | ||||||
| >>> estimator = StepSizeEstimator() | ||||||
| >>> result = estimator.estimate(problem, [(-5, 5)] * 2) | ||||||
| >>> print(f"Step size: {result.step_size}, escape rate: {result.escape_rate:.3f}") | ||||||
| """ | ||||||
|
|
||||||
| def __init__(self, config: StepSizeEstimatorConfig | None = None): | ||||||
| self.config = config or StepSizeEstimatorConfig() | ||||||
|
|
||||||
| def _make_sampler(self) -> BasinHoppingSampler: | ||||||
| sampler_config = BasinHoppingSamplerConfig( | ||||||
| coordinate_precision=self.config.coordinate_precision, | ||||||
| bounded=self.config.bounded, | ||||||
| minimizer_method=self.config.minimizer_method, | ||||||
| minimizer_options=self.config.minimizer_options, | ||||||
| step_mode="percentage", | ||||||
| ) | ||||||
| return BasinHoppingSampler(sampler_config) | ||||||
|
|
||||||
| def _compute_escape_rate( | ||||||
| self, | ||||||
| func: Callable[[np.ndarray], float], | ||||||
| domain_array: np.ndarray, | ||||||
| step_size: float, | ||||||
| sampler: BasinHoppingSampler, | ||||||
| ) -> float: | ||||||
| """Compute the average escape rate for a given step size.""" | ||||||
| bounds_array = domain_array if self.config.bounded else None | ||||||
| p = step_size * np.abs(domain_array[:, 1] - domain_array[:, 0]) | ||||||
|
|
||||||
| escape_rates = [] | ||||||
|
|
||||||
| for _ in range(self.config.n_samples): | ||||||
| x0 = np.random.uniform(domain_array[:, 0], domain_array[:, 1]) | ||||||
| res = minimize( | ||||||
| func, | ||||||
| x0, | ||||||
| method=self.config.minimizer_method, | ||||||
| options=self.config.minimizer_options, | ||||||
| bounds=bounds_array, | ||||||
| ) | ||||||
| optimum = res.x | ||||||
| optimum_hash = sampler._hash_solution(optimum) | ||||||
|
|
||||||
| escapes = 0 | ||||||
| for _ in range(self.config.n_perturbations): | ||||||
| x_perturbed = sampler._perturbation(optimum, p, bounds_array) | ||||||
| res_perturbed = minimize( | ||||||
| func, | ||||||
| x_perturbed, | ||||||
| method=self.config.minimizer_method, | ||||||
| options=self.config.minimizer_options, | ||||||
| bounds=bounds_array, | ||||||
| ) | ||||||
| new_hash = sampler._hash_solution(res_perturbed.x) | ||||||
| if new_hash != optimum_hash: | ||||||
| escapes += 1 | ||||||
|
|
||||||
|
Comment on lines
+110
to
+126
|
||||||
| escape_rates.append(escapes / self.config.n_perturbations) | ||||||
|
|
||||||
| return float(np.mean(escape_rates)) | ||||||
|
|
||||||
| def estimate( | ||||||
| self, | ||||||
|
Comment on lines
+88
to
+132
|
||||||
| func: Callable[[np.ndarray], float], | ||||||
| domain: list[tuple[float, float]], | ||||||
| ) -> StepSizeResult: | ||||||
| """ | ||||||
| Estimate the optimal step size for basin-hopping sampling. | ||||||
|
|
||||||
| Args: | ||||||
| func: Objective function to minimize (f: R^n -> R). | ||||||
| domain: List of (lower, upper) bounds per dimension. | ||||||
|
|
||||||
| Returns: | ||||||
| StepSizeResult with the estimated step size, achieved escape rate, and error. | ||||||
| """ | ||||||
| if self.config.seed is not None: | ||||||
| np.random.seed(self.config.seed) | ||||||
|
|
||||||
| domain_array = np.array(domain) | ||||||
| sampler = self._make_sampler() | ||||||
|
|
||||||
| step = 0.1 | ||||||
|
Comment on lines
+149
to
+152
|
||||||
| increment = 0.1 | ||||||
| target = self.config.target_escape_rate | ||||||
|
|
||||||
| best_lower: tuple[float, float] | None = None # (step, rate) | ||||||
| best_upper: tuple[float, float] | None = None # (step, rate) | ||||||
| last_tested: tuple[float, float] | None = None | ||||||
|
|
||||||
| for _ in range(self.config.search_precision): | ||||||
| while step <= 1.0 + 1e-12: | ||||||
|
||||||
| while step <= 1.0 + 1e-12: | |
| while step <= 1.0: |
Copilot
AI
Feb 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the search algorithm, when rate >= target (line 172-181), the code breaks out of the inner while loop and continues with the next iteration of the outer for loop. However, the step variable may not be reset appropriately if best_lower is None. In this case, step is set to increment (line 180), but increment was just divided by 10 (line 176). On subsequent outer loop iterations, if rate < target on the first test, step will be incremented by the small increment value and could remain below the previously tested values, causing redundant testing or inefficient search. Consider whether the initialization of step should account for previously explored ranges.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The target_escape_rate configuration parameter has no validation. Values outside [0, 1] would not make sense for an escape rate (which is a probability/ratio), but there's no check to prevent invalid values. Consider adding validation in init or post_init to ensure 0 <= target_escape_rate <= 1.