From 2c7c967d130012d737b8d35f50412257cc61a58f Mon Sep 17 00:00:00 2001 From: WojtAcht Date: Tue, 13 Oct 2026 20:20:25 +0200 Subject: [PATCH 1/3] fix: handle failed local searches explicitly in continuous sampling (#44) A local search fails when minimize raises ValueError or returns a non-finite fitness. A failed initial search fails its run; a failed search after a perturbation is rejected and counts as non-improving. Results with success=False but finite fitness are still used. - record minimizer success/status/message in raw_records - add BasinHoppingResult.run_summary with per-run status - warn once per sample() when any local search failed - StepSizeEstimator: divide by evaluated perturbations, skip failed samples, raise RuntimeError when nothing can be evaluated --- CHANGELOG.md | 2 + docs/user-guide/sampling.md | 29 +++++ src/lonkit/continuous/sampling.py | 190 ++++++++++++++++++++--------- src/lonkit/continuous/step_size.py | 53 ++++---- tests/continuous/test_sampling.py | 119 +++++++++++++++++- tests/continuous/test_step_size.py | 56 +++++++++ 6 files changed, 363 insertions(+), 86 deletions(-) create mode 100644 tests/continuous/test_step_size.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 130b46c..21e0820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Bug Fixes +- Continuous sampling now handles failed local searches explicitly. A local search fails when `minimize` raises `ValueError` or returns a non-finite fitness. A failed initial local search fails its run, and a failed search after a perturbation is rejected and counts as a non-improving iteration. Previously, runs starting at a `nan` minimum silently vanished, and an `inf` minimum made `LON.from_trace_data()` fail after all sampling had finished. Results with `success=False` and a finite fitness are still used as local optima. `raw_records` now include the minimizer's `success`, `status` and `message`, and the new `BasinHoppingResult.run_summary` reports each run's status, iterations, failed local searches and evaluations. `sample()` warns when any local search failed (#44). +- `StepSizeEstimator` divides each sample's escape count by its number of evaluated perturbations instead of `n_perturbations`, so failed local searches no longer bias the escape rate down. Samples whose initial local search fails are skipped, and `RuntimeError` is raised if no sample can be evaluated, instead of returning `nan` (#44). - Runs without accepted transitions are no longer dropped from the LON and its performance metrics. `BasinHoppingSampler` and `ILSSampler` now record such a run as a single trace row from its initial local optimum to itself. `LON.from_trace_data()` drops self-loops, so the optimum becomes a vertex without edges and the run counts towards `success` and `deviation`. Basin-Hopping runs with a non-finite initial minimum are still skipped. Seeded traces and LONs change only when some run had no accepted transitions. Set `preserve_runs_without_transitions=False` in either sampler config to restore the earlier behavior, excluding these runs from both the graph and performance metrics (#39). - Raised the minimum joblib version to 1.4.0, which introduced the `generator_unordered` mode required by parallel Basin-Hopping sampling. diff --git a/docs/user-guide/sampling.md b/docs/user-guide/sampling.md index b254f96..c73e5d3 100644 --- a/docs/user-guide/sampling.md +++ b/docs/user-guide/sampling.md @@ -168,6 +168,31 @@ config = BasinHoppingSamplerConfig( ) ``` +### Failed Local Searches + +A local search fails when `minimize` raises `ValueError` or returns a non-finite +fitness (`nan` or `inf`). Such a result never enters the LON: + +- if the initial local search of a run fails, the whole run fails and is left out + of the trace and the performance metrics; +- if a local search after a perturbation fails, it is rejected and counts as a + non-improving iteration. + +Results with `success=False` but a finite fitness are used as local optima. On +non-smooth functions such as Ackley, L-BFGS-B often reports `ABNORMAL` at genuine +optima. The minimizer's `success`, `status` and `message` are kept in +`raw_records`, so such results can be filtered out afterwards. + +`sample()` warns if any local search failed. `result.run_summary` has one row per run: + +```python +result = sampler.sample(func, domain) +print(result.run_summary) +# run status n_iterations n_failed_minimizations nfev +# 0 1 completed 250 0 4123 +# 1 2 failed 0 1 12 +``` + ## Discrete Sampling with ILS Independent ILS runs can use multiple processes via `n_jobs`: @@ -328,6 +353,10 @@ for record in result.raw_records[:5]: print(f" Current: {record['current_f']:.4f}") print(f" New: {record['new_f']:.4f}") print(f" Accepted: {record['accepted']}") + print(f" Minimizer converged: {record['success']}") + +# one row per run: run, status, n_iterations, n_failed_minimizations, nfev +print(result.run_summary) ``` ## Loading External Traces diff --git a/src/lonkit/continuous/sampling.py b/src/lonkit/continuous/sampling.py index ffa53b1..9801f73 100644 --- a/src/lonkit/continuous/sampling.py +++ b/src/lonkit/continuous/sampling.py @@ -1,17 +1,18 @@ import warnings from collections.abc import Callable, Sequence -from dataclasses import dataclass, replace -from typing import Literal +from dataclasses import dataclass, field, replace +from typing import Any, Literal import joblib import numpy as np import pandas as pd -from scipy.optimize import minimize +from scipy.optimize import OptimizeResult, minimize from tqdm import tqdm from lonkit.lon import LON, LONConfig StepMode = Literal["percentage", "fixed"] +RUN_SUMMARY_COLUMNS = ["run", "status", "n_iterations", "n_failed_minimizations", "nfev"] @dataclass @@ -53,6 +54,12 @@ class BasinHoppingSamplerConfig: preserve_runs_without_transitions: Keep runs with no accepted moves as self-loop trace rows. If False, omit them from the trace, LON, and performance metrics, matching the earlier behavior. Default: True. + + A local search fails if ``minimize`` raises ``ValueError`` or returns a non-finite + ``fun``. A failed initial local search fails the whole run; a failed search after a + perturbation counts as a non-improving iteration. Results with ``success=False`` and + a finite ``fun`` are used as local optima: on non-smooth functions, minimizers often + report e.g. ``ABNORMAL`` at genuine optima. Their status is kept in ``raw_records``. """ n_runs: int = 100 @@ -90,16 +97,23 @@ class BasinHoppingResult: transitions between local optima. raw_records: List of dicts with detailed iteration data, including all perturbation attempts (both accepted and rejected). Each dict has keys - `run`, `iteration`, `current_x`, `current_f`, `new_x`, `new_f`, and `accepted`. - If all perturbations fail and `preserve_runs_without_transitions` is enabled, - a rejected self-transition with `iteration=-1` preserves the finite - result of the initial minimization. + `run`, `iteration`, `current_x`, `current_f`, `new_x`, `new_f`, `accepted`, + and the `success`, `status` and `message` reported by the minimizer for `new_x`. + Failed results with a non-finite `new_f` are recorded with `accepted=False`. If all perturbations fail and + `preserve_runs_without_transitions` is enabled, a rejected self-transition + with `iteration=-1` preserves the result of the initial minimization. nfev: Total number of objective function evaluations across all runs. + run_summary: DataFrame with one row per run and columns `run`, `status` + (`"completed"`, or `"failed"` if the initial local search failed), + `n_iterations`, `n_failed_minimizations` and `nfev`. """ trace_df: pd.DataFrame raw_records: list[dict] nfev: int + run_summary: pd.DataFrame = field( + default_factory=lambda: pd.DataFrame(columns=RUN_SUMMARY_COLUMNS) + ) class BasinHoppingSampler: @@ -182,6 +196,42 @@ def _hash_solution(self, x: np.ndarray) -> str: return hash_str + def _local_search( + self, + func: Callable[[np.ndarray], float], + x0: np.ndarray, + bounds_array: np.ndarray | None, + ) -> tuple[OptimizeResult | None, str | None]: + """ + Run ``minimize`` from ``x0``. + + Returns: + Tuple of (result, failure). ``result`` is ``None`` if ``minimize`` raised + ``ValueError``. ``failure`` is ``None`` if the result can be used as a local + optimum, otherwise a description of why it cannot. + """ + try: + res = minimize( + func, + x0, + method=self.config.minimizer_method, + options=self.config.minimizer_options, + bounds=bounds_array if self.config.bounded else None, + ) + except ValueError as e: + return None, f"ValueError: {e}" + if not np.isfinite(res.fun): + return res, f"non-finite fitness {res.fun}" + return res, None + + @staticmethod + def _minimizer_status(res: OptimizeResult) -> dict: + return { + "success": bool(res.get("success", True)), + "status": res.get("status"), + "message": str(res.get("message", "")), + } + def _single_bh_run( self, run: int, @@ -190,7 +240,7 @@ def _single_bh_run( p: np.ndarray, bounds_array: np.ndarray | None, rng: np.random.Generator, - ) -> tuple[list[dict], int]: + ) -> tuple[list[dict], int, dict]: """ Execute a single Basin-Hopping run. @@ -203,29 +253,34 @@ def _single_bh_run( rng: Random number generator for perturbations in this run. Returns: - Tuple of (records, nfev) for this run. + Tuple of (records, nfev, summary) for this run, where summary is a row of + ``BasinHoppingResult.run_summary``. """ nfev = 0 - - try: - res = minimize( - func, - initial_point, - method=self.config.minimizer_method, - options=self.config.minimizer_options, - bounds=bounds_array if self.config.bounded else None, - ) - except ValueError as e: + summary: dict[str, Any] = { + "run": run, + "status": "completed", + "n_iterations": 0, + "n_failed_minimizations": 0, + "nfev": 0, + } + + res, failure = self._local_search(func, initial_point, bounds_array) + if res is not None: + nfev += res.nfev + if failure is not None: warnings.warn( - f"Run {run}: initial minimize failed with ValueError: {e}. " + f"Run {run}: initial local search failed ({failure}). " f"Starting point: {initial_point}. Skipping run.", stacklevel=3, ) - return [], 0 + summary.update(status="failed", n_failed_minimizations=1, nfev=nfev) + return [], nfev, summary + assert res is not None current_x = res.x current_f = res.fun - nfev += res.nfev + current_status = self._minimizer_status(res) records: list[dict] = [] iters_without_improvement = 0 @@ -241,21 +296,16 @@ def _single_bh_run( break x_perturbed = self._perturbation(current_x, p, rng, bounds_array) - try: - res = minimize( - func, - x_perturbed, - method=self.config.minimizer_method, - options=self.config.minimizer_options, - bounds=bounds_array if self.config.bounded else None, - ) - except ValueError as e: + res, failure = self._local_search(func, x_perturbed, bounds_array) + if failure is not None: + summary["n_failed_minimizations"] += 1 + if res is None: # L-BFGS-B can produce internal iterates that slightly # violate bounds, causing approx_derivative to fail. # Skip this perturbation and try the next one. warnings.warn( f"Run {run}, iteration {iter_index}: minimize after perturbation " - f"failed with ValueError: {e}. " + f"failed ({failure}). " f"Perturbed point: {x_perturbed}. Skipping perturbation.", stacklevel=3, ) @@ -266,6 +316,7 @@ def _single_bh_run( new_x = res.x new_f = res.fun nfev += res.nfev + accepted = failure is None and new_f <= current_f records.append( { @@ -275,26 +326,29 @@ def _single_bh_run( "current_f": current_f, "new_x": new_x.copy(), "new_f": new_f, - "accepted": new_f <= current_f, + "accepted": accepted, + **self._minimizer_status(res), } ) + # A failed local search counts as a non-improving iteration if self.config.n_iter_no_change is not None: - if new_f < current_f: + if failure is None and new_f < current_f: iters_without_improvement = 0 else: iters_without_improvement += 1 # Acceptance criterion (minimization: accept if better or equal) - if new_f <= current_f: + if accepted: current_x = new_x.copy() current_f = new_f + current_status = self._minimizer_status(res) iter_index += 1 - # No perturbation produced a record: preserve the finite initial result + # No perturbation produced a record: preserve the initial result # for _construct_trace_data without inventing an accepted move. - if self.config.preserve_runs_without_transitions and not records and np.isfinite(current_f): + if self.config.preserve_runs_without_transitions and not records: records.append( { "run": run, @@ -304,10 +358,12 @@ def _single_bh_run( "new_x": current_x.copy(), "new_f": current_f, "accepted": False, + **current_status, } ) - return records, nfev + summary.update(n_iterations=iter_index, nfev=nfev) + return records, nfev, summary def _sequential_bh( self, @@ -318,7 +374,7 @@ def _sequential_bh( run_seeds: list[np.random.SeedSequence], progress_callback: Callable[[int, int], None] | None = None, verbose: bool = False, - ) -> tuple[list[dict], int]: + ) -> tuple[list[dict], int, list[dict]]: """ Executes set of ``_single_bh_run`` calls in sequence, without using ``joblib``. @@ -326,20 +382,22 @@ def _sequential_bh( """ raw_records: list[dict] = [] nfev_total = 0 + summaries: list[dict] = [] runs = range(1, self.config.n_runs + 1) run_iter = tqdm(runs, total=self.config.n_runs) if verbose else runs for run in run_iter: rng = np.random.default_rng(run_seeds[run - 1]) - records, nfev = self._single_bh_run( + records, nfev, summary = self._single_bh_run( run, func, initial_points[run - 1], p, bounds_array, rng ) if progress_callback: progress_callback(run, self.config.n_runs) raw_records.extend(records) nfev_total += nfev + summaries.append(summary) - return raw_records, nfev_total + return raw_records, nfev_total, summaries def _parallel_bh( self, @@ -351,7 +409,7 @@ def _parallel_bh( effective_n_jobs: int, progress_callback: Callable[[int, int], None] | None = None, verbose: bool = False, - ) -> tuple[list[dict], int]: + ) -> tuple[list[dict], int, list[dict]]: """ Executes set of ``_single_bh_run`` calls in parallel using ``joblib``. @@ -385,16 +443,18 @@ def _parallel_bh( raw_records: list[dict] = [] nfev_total = 0 + summaries: list[dict] = [] result_iter = ( tqdm(parallel_results, total=self.config.n_runs) if verbose else parallel_results ) - for completed_runs, (_, records, nfev) in enumerate(result_iter, 1): + for completed_runs, (_, records, nfev, summary) in enumerate(result_iter, 1): if progress_callback: progress_callback(completed_runs, self.config.n_runs) raw_records.extend(records) nfev_total += nfev + summaries.append(summary) - return raw_records, nfev_total + return raw_records, nfev_total, summaries def _basin_hopping_sampling( self, @@ -403,7 +463,7 @@ def _basin_hopping_sampling( initial_points: np.ndarray, progress_callback: Callable[[int, int], None] | None = None, verbose: bool = False, - ) -> tuple[list[dict], int]: + ) -> tuple[list[dict], int, list[dict]]: """ Run Basin-Hopping sampling to generate LON data. @@ -422,10 +482,11 @@ def _basin_hopping_sampling( process, regardless of ``n_jobs``. Returns: - Tuple of (raw_records, nfev_total) where raw_records is a list of + Tuple of (raw_records, nfev_total, summaries) where raw_records is a list of raw sampling records (one per perturbation step, each a dict with keys: - run, iteration, current_x, current_f, new_x, new_f, accepted) and - nfev_total is the total number of function evaluations. + run, iteration, current_x, current_f, new_x, new_f, accepted, success, + status, message), nfev_total is the total number of function evaluations + and summaries holds one ``run_summary`` row per run, in completion order. """ n_var = len(domain) domain_array = np.array(domain) @@ -512,13 +573,13 @@ def _construct_trace_data(self, raw_records: list[dict]) -> pd.DataFrame: if self.config.preserve_runs_without_transitions: # A run with no accepted moves keeps its initial minimum as a self-loop row # (dropped from the graph by LON.from_trace_data()), so it still counts - # towards success/deviation. Non-finite minima are skipped. + # towards success/deviation. Failed runs have no records. runs_in_trace = {row["run"] for row in trace_records} first_records: dict[int, dict] = {} for rec in raw_records: first_records.setdefault(rec["run"], rec) for run, rec in first_records.items(): - if run in runs_in_trace or not np.isfinite(rec["current_f"]): + if run in runs_in_trace: continue node = self._hash_solution( self._round_value(rec["current_x"], self.config.coordinate_precision) @@ -597,14 +658,31 @@ def sample( resolved_points = self._resolve_initial_points(initial_points, domain) # Collect all raw sampling data - raw_records, nfev_total = self._basin_hopping_sampling( + raw_records, nfev_total, summaries = self._basin_hopping_sampling( func, domain, resolved_points, progress_callback, verbose ) # Construct trace data from accepted transitions trace_df = self._construct_trace_data(raw_records) - return BasinHoppingResult(trace_df=trace_df, raw_records=raw_records, nfev=nfev_total) + run_summary = pd.DataFrame(summaries, columns=RUN_SUMMARY_COLUMNS) + run_summary = run_summary.sort_values(by=["run"]).reset_index(drop=True) + n_failed_runs = int((run_summary["status"] == "failed").sum()) + n_failed_minimizations = int(run_summary["n_failed_minimizations"].sum()) + if n_failed_minimizations: + warnings.warn( + f"{n_failed_minimizations} local search(es) failed (ValueError or non-finite fitness); " + f"{n_failed_runs} of {self.config.n_runs} run(s) failed. " + "See BasinHoppingResult.run_summary.", + stacklevel=2, + ) + + return BasinHoppingResult( + trace_df=trace_df, + raw_records=raw_records, + nfev=nfev_total, + run_summary=run_summary, + ) def sample_to_lon( self, @@ -650,7 +728,7 @@ def _run_single_bh_in_worker( bounds_array: np.ndarray | None, config: BasinHoppingSamplerConfig, seed: np.random.SeedSequence, -) -> tuple[int, list[dict], int]: +) -> tuple[int, list[dict], int, dict]: """ Module-level worker for a single Basin-Hopping run. @@ -669,12 +747,12 @@ def _run_single_bh_in_worker( seed: ``SeedSequence`` child spawned for this run. Returns: - Tuple of (run, records, nfev) for this run. + Tuple of (run, records, nfev, summary) for this run. """ sampler = BasinHoppingSampler(config) rng = np.random.default_rng(seed) - records, nfev = sampler._single_bh_run(run, func, initial_point, p, bounds_array, rng) - return run, records, nfev + records, nfev, summary = sampler._single_bh_run(run, func, initial_point, p, bounds_array, rng) + return run, records, nfev, summary def compute_lon( diff --git a/src/lonkit/continuous/step_size.py b/src/lonkit/continuous/step_size.py index 08794ab..9f9d4d4 100644 --- a/src/lonkit/continuous/step_size.py +++ b/src/lonkit/continuous/step_size.py @@ -3,7 +3,6 @@ from dataclasses import dataclass import numpy as np -from scipy.optimize import minimize from lonkit.continuous.sampling import BasinHoppingSampler, BasinHoppingSamplerConfig @@ -33,6 +32,10 @@ class StepSizeEstimatorConfig: Default: `None`. bounded: Whether to enforce domain bounds during perturbation. Default: `True`. seed: Random seed for reproducibility. Default: `None`. + + Failed local searches (see ``BasinHoppingSamplerConfig``) are skipped: a sample whose + initial local search fails is dropped, and failed perturbations are excluded from + that sample's escape rate. """ n_samples: int = 100 @@ -125,6 +128,13 @@ def _compute_escape_rate( Compute the average escape rate for a given step size. Cost per call: `n_samples` + `n_samples * n_perturbations` minimizations. + + Each sample's escape rate is the fraction of escapes among its evaluated + perturbations, i.e. those whose local search did not fail. + + Raises: + RuntimeError: If no sample has a valid initial local search and at least + one evaluated perturbation. """ bounds_array = domain_array if self.config.bounded else None p = step_size * np.abs(domain_array[:, 1] - domain_array[:, 0]) @@ -134,53 +144,48 @@ def _compute_escape_rate( for _ in range(self.config.n_samples): x0 = rng.uniform(domain_array[:, 0], domain_array[:, 1]) - try: - res = minimize( - func, - x0, - method=self.config.minimizer_method, - options=self.config.minimizer_options, - bounds=bounds_array if self.config.bounded else None, - ) - except ValueError as e: + res, failure = sampler._local_search(func, x0, bounds_array) + if failure is not None: warnings.warn( - f"Initial minimize failed with ValueError: {e}. " - f"Starting point: {x0}. Skipping run.", + f"Initial local search failed ({failure}). " + f"Starting point: {x0}. Skipping sample.", stacklevel=3, ) continue + assert res is not None optimum = res.x optimum_rounded = sampler._round_value(optimum, self.config.coordinate_precision) optimum_hash = sampler._hash_solution(optimum_rounded) escapes = 0 + evaluated = 0 for _ in range(self.config.n_perturbations): x_perturbed = sampler._perturbation(optimum, p, rng, bounds_array) - try: - res_perturbed = minimize( - func, - x_perturbed, - method=self.config.minimizer_method, - options=self.config.minimizer_options, - bounds=bounds_array, - ) - except ValueError as e: + res_perturbed, failure = sampler._local_search(func, x_perturbed, bounds_array) + if failure is not None: # L-BFGS-B can produce internal iterates that slightly # violate bounds, causing approx_derivative to fail. # Skip this perturbation and try the next one. warnings.warn( - f"Minimize after perturbation " - f"failed with ValueError: {e}. " + f"Minimize after perturbation failed ({failure}). " f"Perturbed point: {x_perturbed}. Skipping perturbation.", stacklevel=3, ) continue + assert res_perturbed is not None + evaluated += 1 new_hash = sampler._hash_solution(res_perturbed.x) if new_hash != optimum_hash: escapes += 1 - escape_rates.append(escapes / self.config.n_perturbations) + if evaluated: + escape_rates.append(escapes / evaluated) + if not escape_rates: + raise RuntimeError( + f"Could not estimate the escape rate for step size {step_size}: " + "every sample failed its initial local search or all of its perturbations." + ) return float(np.mean(escape_rates)) def estimate( diff --git a/tests/continuous/test_sampling.py b/tests/continuous/test_sampling.py index c8c2360..ee13997 100644 --- a/tests/continuous/test_sampling.py +++ b/tests/continuous/test_sampling.py @@ -1,6 +1,7 @@ from unittest.mock import patch import numpy as np +import pandas as pd import pytest from scipy.optimize import OptimizeResult @@ -71,27 +72,36 @@ def test_mixed_runs_keep_accepted_transitions(self, preserve: bool) -> None: } @pytest.mark.parametrize("preserve", [True, False]) - @pytest.mark.parametrize("initial_fitness", [0.0, float("nan")]) - def test_all_perturbations_fail(self, initial_fitness: float, preserve: bool) -> None: + def test_all_perturbations_fail(self, preserve: bool) -> None: sampler = BasinHoppingSampler( BasinHoppingSamplerConfig( n_runs=1, max_iter=2, seed=SEED, preserve_runs_without_transitions=preserve ) ) - initial_result = OptimizeResult(x=np.zeros(2), fun=initial_fitness, nfev=1) + initial_result = OptimizeResult(x=np.zeros(2), fun=0.0, nfev=1) with ( patch( "lonkit.continuous.sampling.minimize", side_effect=[initial_result, ValueError("failed"), ValueError("failed")], ), - pytest.warns(UserWarning, match="minimize after perturbation failed") as warnings, + pytest.warns(UserWarning) as warnings, ): result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((1, 2))) - assert len(warnings) == 2 + messages = [str(w.message) for w in warnings] + assert sum("minimize after perturbation failed" in m for m in messages) == 2 assert result.nfev == 1 + assert result.run_summary.to_dict("records") == [ + { + "run": 1, + "status": "completed", + "n_iterations": 2, + "n_failed_minimizations": 2, + "nfev": 1, + } + ] lon = sampler.sample_to_lon(result) - if not preserve or not np.isfinite(initial_fitness): + if not preserve: assert result.raw_records == [] assert result.trace_df.empty assert lon.n_vertices == 0 @@ -144,3 +154,100 @@ def nan_for_negative_x0(x: np.ndarray) -> float: nan_for_negative_x0, DOMAIN_2D, initial_points=np.array([[-2.0, 0.0], [2.0, 0.0]]) ) assert set(result.trace_df["run"]) == {2} + + +def _res(x: float, fun: float, success: bool = True) -> OptimizeResult: + return OptimizeResult( + x=np.full(2, x), + fun=fun, + nfev=1, + success=success, + status=0 if success else 2, + message="ok" if success else "ABNORMAL", + ) + + +class TestMinimizerFailures: + def test_unconverged_result_is_used_and_status_recorded(self) -> None: + sampler = BasinHoppingSampler(BasinHoppingSamplerConfig(n_runs=1, max_iter=1, seed=SEED)) + results = [_res(2.0, 8.0), _res(1.0, 2.0, success=False)] + with patch("lonkit.continuous.sampling.minimize", side_effect=results): + result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((1, 2))) + + (record,) = result.raw_records + assert record["accepted"] + assert record["success"] is False + assert record["status"] == 2 + assert record["message"] == "ABNORMAL" + assert result.run_summary.to_dict("records") == [ + { + "run": 1, + "status": "completed", + "n_iterations": 1, + "n_failed_minimizations": 0, + "nfev": 2, + } + ] + assert sampler.sample_to_lon(result).n_vertices == 2 + + @pytest.mark.parametrize("bad", [float("nan"), float("inf")]) + def test_non_finite_initial_minimum_fails_run(self, bad: float) -> None: + def f(x: np.ndarray) -> float: + return bad if x[0] > 3 else float(np.sum(x**2)) + + sampler = BasinHoppingSampler( + BasinHoppingSamplerConfig(n_runs=4, n_iter_no_change=5, seed=1) + ) + points = np.array([[4.0, 1.0], [5.0, 2.0], [-1.0, -1.0], [-2.0, 1.0]]) + with pytest.warns(UserWarning, match="initial local search failed"): + result = sampler.sample(f, [(-5, 5)] * 2, initial_points=points) + + assert result.run_summary["status"].tolist() == [ + "failed", + "failed", + "completed", + "completed", + ] + assert set(result.trace_df["run"]) == {3, 4} + assert np.isfinite(result.trace_df[["fit1", "fit2"]].to_numpy()).all() + assert all(r["run"] in (3, 4) for r in result.raw_records) + sampler.sample_to_lon(result) # trace is valid + + def test_non_finite_mid_run_is_never_accepted(self) -> None: + sampler = BasinHoppingSampler(BasinHoppingSamplerConfig(n_runs=1, max_iter=2, seed=SEED)) + results = [_res(2.0, 8.0), _res(1.0, float("-inf")), _res(1.0, 2.0)] + with ( + patch("lonkit.continuous.sampling.minimize", side_effect=results), + pytest.warns(UserWarning, match="1 local search"), + ): + result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((1, 2))) + + assert [r["accepted"] for r in result.raw_records] == [False, True] + assert result.trace_df[["fit1", "fit2"]].to_numpy().tolist() == [[8.0, 2.0]] + assert result.run_summary.loc[0, "n_failed_minimizations"] == 1 + + def test_initial_value_error_reports_failed_run(self) -> None: + sampler = BasinHoppingSampler(BasinHoppingSamplerConfig(n_runs=2, max_iter=1, seed=SEED)) + results = [ValueError("bad"), _res(2.0, 8.0), _res(1.0, 2.0)] + with ( + patch("lonkit.continuous.sampling.minimize", side_effect=results), + pytest.warns(UserWarning, match="1 of 2 run"), + ): + result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((2, 2))) + assert result.run_summary["status"].tolist() == ["failed", "completed"] + assert result.trace_df["run"].tolist() == [2] + + def test_run_summary_matches_across_n_jobs(self) -> None: + def f(x: np.ndarray) -> float: + return float("nan") if x[0] > 3 else float(np.sum(x**2)) + + summaries = [] + for n_jobs in (1, 2): + config = BasinHoppingSamplerConfig( + n_runs=6, n_iter_no_change=3, seed=SEED, n_jobs=n_jobs + ) + with pytest.warns(UserWarning): + result = BasinHoppingSampler(config).sample(f, DOMAIN_2D) + summaries.append(result.run_summary) + pd.testing.assert_frame_equal(summaries[0], summaries[1]) + assert summaries[0]["run"].tolist() == list(range(1, 7)) diff --git a/tests/continuous/test_step_size.py b/tests/continuous/test_step_size.py new file mode 100644 index 0000000..d215474 --- /dev/null +++ b/tests/continuous/test_step_size.py @@ -0,0 +1,56 @@ +from unittest.mock import patch + +import numpy as np +import pytest +from scipy.optimize import OptimizeResult + +from lonkit import StepSizeEstimator, StepSizeEstimatorConfig +from tests.conftest import DOMAIN_2D, SEED, sphere + + +def _res(x: float, fun: float = 0.0, success: bool = True) -> OptimizeResult: + return OptimizeResult(x=np.full(2, x), fun=fun, nfev=1, success=success, message="") + + +class TestEscapeRateWithFailures: + def test_failed_perturbations_are_excluded_from_denominator(self) -> None: + estimator = StepSizeEstimator( + StepSizeEstimatorConfig(n_samples=1, n_perturbations=4, seed=SEED) + ) + # Two of four perturbations fail; one of the two evaluated escapes. + results = [_res(0.0), ValueError("bad"), _res(1.0, float("nan")), _res(0.0), _res(1.0)] + with ( + patch("lonkit.continuous.sampling.minimize", side_effect=results), + pytest.warns(UserWarning, match="Skipping perturbation"), + ): + rate = estimator._compute_escape_rate( + sphere, np.array(DOMAIN_2D), 0.1, estimator._make_sampler() + ) + assert rate == 0.5 + + def test_sample_without_evaluated_perturbations_is_skipped(self) -> None: + estimator = StepSizeEstimator( + StepSizeEstimatorConfig(n_samples=2, n_perturbations=1, seed=SEED) + ) + results = [_res(0.0), ValueError("bad"), _res(0.0), _res(1.0)] + with ( + patch("lonkit.continuous.sampling.minimize", side_effect=results), + pytest.warns(UserWarning), + ): + rate = estimator._compute_escape_rate( + sphere, np.array(DOMAIN_2D), 0.1, estimator._make_sampler() + ) + assert rate == 1.0 + + def test_all_samples_failing_raises(self) -> None: + def nan_everywhere(x: np.ndarray) -> float: + return float("nan") + + estimator = StepSizeEstimator( + StepSizeEstimatorConfig(n_samples=3, n_perturbations=2, seed=SEED) + ) + with ( + pytest.warns(UserWarning, match="Initial local search failed"), + pytest.raises(RuntimeError, match="Could not estimate the escape rate"), + ): + estimator.estimate(nan_everywhere, DOMAIN_2D) From 4e32b9507e991f193b71c40aa36fea0e8e57deef Mon Sep 17 00:00:00 2001 From: WojtAcht Date: Wed, 14 Oct 2026 15:15:43 +0200 Subject: [PATCH 2/3] feat: simplify error handling --- CHANGELOG.md | 2 +- docs/user-guide/sampling.md | 18 +---- src/lonkit/continuous/sampling.py | 122 ++++++++---------------------- tests/continuous/test_sampling.py | 71 ++++++++--------- 4 files changed, 67 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e0820..67af694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Bug Fixes -- Continuous sampling now handles failed local searches explicitly. A local search fails when `minimize` raises `ValueError` or returns a non-finite fitness. A failed initial local search fails its run, and a failed search after a perturbation is rejected and counts as a non-improving iteration. Previously, runs starting at a `nan` minimum silently vanished, and an `inf` minimum made `LON.from_trace_data()` fail after all sampling had finished. Results with `success=False` and a finite fitness are still used as local optima. `raw_records` now include the minimizer's `success`, `status` and `message`, and the new `BasinHoppingResult.run_summary` reports each run's status, iterations, failed local searches and evaluations. `sample()` warns when any local search failed (#44). +- Continuous sampling now handles failed local searches explicitly. A local search fails when `minimize` raises `ValueError` or returns a non-finite fitness. A failed initial local search fails its run, and a failed search after a perturbation is rejected and counts as a non-improving iteration. Previously, runs starting at a `nan` minimum silently vanished, and an `inf` minimum made `LON.from_trace_data()` fail after all sampling had finished. Results with `success=False` and a finite fitness are still used as local optima. Each failed local search emits a warning with its context and reason (#44). - `StepSizeEstimator` divides each sample's escape count by its number of evaluated perturbations instead of `n_perturbations`, so failed local searches no longer bias the escape rate down. Samples whose initial local search fails are skipped, and `RuntimeError` is raised if no sample can be evaluated, instead of returning `nan` (#44). - Runs without accepted transitions are no longer dropped from the LON and its performance metrics. `BasinHoppingSampler` and `ILSSampler` now record such a run as a single trace row from its initial local optimum to itself. `LON.from_trace_data()` drops self-loops, so the optimum becomes a vertex without edges and the run counts towards `success` and `deviation`. Basin-Hopping runs with a non-finite initial minimum are still skipped. Seeded traces and LONs change only when some run had no accepted transitions. Set `preserve_runs_without_transitions=False` in either sampler config to restore the earlier behavior, excluding these runs from both the graph and performance metrics (#39). diff --git a/docs/user-guide/sampling.md b/docs/user-guide/sampling.md index c73e5d3..c1652b8 100644 --- a/docs/user-guide/sampling.md +++ b/docs/user-guide/sampling.md @@ -180,18 +180,10 @@ fitness (`nan` or `inf`). Such a result never enters the LON: Results with `success=False` but a finite fitness are used as local optima. On non-smooth functions such as Ackley, L-BFGS-B often reports `ABNORMAL` at genuine -optima. The minimizer's `success`, `status` and `message` are kept in -`raw_records`, so such results can be filtered out afterwards. +optima. -`sample()` warns if any local search failed. `result.run_summary` has one row per run: - -```python -result = sampler.sample(func, domain) -print(result.run_summary) -# run status n_iterations n_failed_minimizations nfev -# 0 1 completed 250 0 4123 -# 1 2 failed 0 1 12 -``` +Each failed local search emits a warning with the run and iteration (for +perturbations), the starting point, and the reason for failure. ## Discrete Sampling with ILS @@ -353,10 +345,6 @@ for record in result.raw_records[:5]: print(f" Current: {record['current_f']:.4f}") print(f" New: {record['new_f']:.4f}") print(f" Accepted: {record['accepted']}") - print(f" Minimizer converged: {record['success']}") - -# one row per run: run, status, n_iterations, n_failed_minimizations, nfev -print(result.run_summary) ``` ## Loading External Traces diff --git a/src/lonkit/continuous/sampling.py b/src/lonkit/continuous/sampling.py index 9801f73..7aa103d 100644 --- a/src/lonkit/continuous/sampling.py +++ b/src/lonkit/continuous/sampling.py @@ -1,7 +1,7 @@ import warnings from collections.abc import Callable, Sequence -from dataclasses import dataclass, field, replace -from typing import Any, Literal +from dataclasses import dataclass, replace +from typing import Literal import joblib import numpy as np @@ -12,7 +12,6 @@ from lonkit.lon import LON, LONConfig StepMode = Literal["percentage", "fixed"] -RUN_SUMMARY_COLUMNS = ["run", "status", "n_iterations", "n_failed_minimizations", "nfev"] @dataclass @@ -56,10 +55,9 @@ class BasinHoppingSamplerConfig: performance metrics, matching the earlier behavior. Default: True. A local search fails if ``minimize`` raises ``ValueError`` or returns a non-finite - ``fun``. A failed initial local search fails the whole run; a failed search after a - perturbation counts as a non-improving iteration. Results with ``success=False`` and - a finite ``fun`` are used as local optima: on non-smooth functions, minimizers often - report e.g. ``ABNORMAL`` at genuine optima. Their status is kept in ``raw_records``. + ``fun``. A failed initial search skips the run; a failed perturbation is rejected + and counts as a non-improving iteration. Failures emit warnings. Results with + ``success=False`` and finite fitness are still used as local optima. """ n_runs: int = 100 @@ -97,23 +95,16 @@ class BasinHoppingResult: transitions between local optima. raw_records: List of dicts with detailed iteration data, including all perturbation attempts (both accepted and rejected). Each dict has keys - `run`, `iteration`, `current_x`, `current_f`, `new_x`, `new_f`, `accepted`, - and the `success`, `status` and `message` reported by the minimizer for `new_x`. - Failed results with a non-finite `new_f` are recorded with `accepted=False`. If all perturbations fail and - `preserve_runs_without_transitions` is enabled, a rejected self-transition - with `iteration=-1` preserves the result of the initial minimization. + `run`, `iteration`, `current_x`, `current_f`, `new_x`, `new_f`, and `accepted`. + If all perturbations fail and `preserve_runs_without_transitions` is enabled, + a rejected self-transition with `iteration=-1` preserves the finite + result of the initial minimization. nfev: Total number of objective function evaluations across all runs. - run_summary: DataFrame with one row per run and columns `run`, `status` - (`"completed"`, or `"failed"` if the initial local search failed), - `n_iterations`, `n_failed_minimizations` and `nfev`. """ trace_df: pd.DataFrame raw_records: list[dict] nfev: int - run_summary: pd.DataFrame = field( - default_factory=lambda: pd.DataFrame(columns=RUN_SUMMARY_COLUMNS) - ) class BasinHoppingSampler: @@ -224,14 +215,6 @@ def _local_search( return res, f"non-finite fitness {res.fun}" return res, None - @staticmethod - def _minimizer_status(res: OptimizeResult) -> dict: - return { - "success": bool(res.get("success", True)), - "status": res.get("status"), - "message": str(res.get("message", "")), - } - def _single_bh_run( self, run: int, @@ -240,7 +223,7 @@ def _single_bh_run( p: np.ndarray, bounds_array: np.ndarray | None, rng: np.random.Generator, - ) -> tuple[list[dict], int, dict]: + ) -> tuple[list[dict], int]: """ Execute a single Basin-Hopping run. @@ -253,18 +236,9 @@ def _single_bh_run( rng: Random number generator for perturbations in this run. Returns: - Tuple of (records, nfev, summary) for this run, where summary is a row of - ``BasinHoppingResult.run_summary``. + Tuple of (records, nfev) for this run. """ nfev = 0 - summary: dict[str, Any] = { - "run": run, - "status": "completed", - "n_iterations": 0, - "n_failed_minimizations": 0, - "nfev": 0, - } - res, failure = self._local_search(func, initial_point, bounds_array) if res is not None: nfev += res.nfev @@ -274,13 +248,11 @@ def _single_bh_run( f"Starting point: {initial_point}. Skipping run.", stacklevel=3, ) - summary.update(status="failed", n_failed_minimizations=1, nfev=nfev) - return [], nfev, summary + return [], nfev assert res is not None current_x = res.x current_f = res.fun - current_status = self._minimizer_status(res) records: list[dict] = [] iters_without_improvement = 0 @@ -298,17 +270,13 @@ def _single_bh_run( x_perturbed = self._perturbation(current_x, p, rng, bounds_array) res, failure = self._local_search(func, x_perturbed, bounds_array) if failure is not None: - summary["n_failed_minimizations"] += 1 - if res is None: - # L-BFGS-B can produce internal iterates that slightly - # violate bounds, causing approx_derivative to fail. - # Skip this perturbation and try the next one. warnings.warn( f"Run {run}, iteration {iter_index}: minimize after perturbation " f"failed ({failure}). " f"Perturbed point: {x_perturbed}. Skipping perturbation.", stacklevel=3, ) + if res is None: iters_without_improvement += 1 iter_index += 1 continue @@ -327,7 +295,6 @@ def _single_bh_run( "new_x": new_x.copy(), "new_f": new_f, "accepted": accepted, - **self._minimizer_status(res), } ) @@ -342,7 +309,6 @@ def _single_bh_run( if accepted: current_x = new_x.copy() current_f = new_f - current_status = self._minimizer_status(res) iter_index += 1 @@ -358,12 +324,10 @@ def _single_bh_run( "new_x": current_x.copy(), "new_f": current_f, "accepted": False, - **current_status, } ) - summary.update(n_iterations=iter_index, nfev=nfev) - return records, nfev, summary + return records, nfev def _sequential_bh( self, @@ -374,7 +338,7 @@ def _sequential_bh( run_seeds: list[np.random.SeedSequence], progress_callback: Callable[[int, int], None] | None = None, verbose: bool = False, - ) -> tuple[list[dict], int, list[dict]]: + ) -> tuple[list[dict], int]: """ Executes set of ``_single_bh_run`` calls in sequence, without using ``joblib``. @@ -382,22 +346,20 @@ def _sequential_bh( """ raw_records: list[dict] = [] nfev_total = 0 - summaries: list[dict] = [] runs = range(1, self.config.n_runs + 1) run_iter = tqdm(runs, total=self.config.n_runs) if verbose else runs for run in run_iter: rng = np.random.default_rng(run_seeds[run - 1]) - records, nfev, summary = self._single_bh_run( + records, nfev = self._single_bh_run( run, func, initial_points[run - 1], p, bounds_array, rng ) if progress_callback: progress_callback(run, self.config.n_runs) raw_records.extend(records) nfev_total += nfev - summaries.append(summary) - return raw_records, nfev_total, summaries + return raw_records, nfev_total def _parallel_bh( self, @@ -409,7 +371,7 @@ def _parallel_bh( effective_n_jobs: int, progress_callback: Callable[[int, int], None] | None = None, verbose: bool = False, - ) -> tuple[list[dict], int, list[dict]]: + ) -> tuple[list[dict], int]: """ Executes set of ``_single_bh_run`` calls in parallel using ``joblib``. @@ -443,18 +405,16 @@ def _parallel_bh( raw_records: list[dict] = [] nfev_total = 0 - summaries: list[dict] = [] result_iter = ( tqdm(parallel_results, total=self.config.n_runs) if verbose else parallel_results ) - for completed_runs, (_, records, nfev, summary) in enumerate(result_iter, 1): + for completed_runs, (_, records, nfev) in enumerate(result_iter, 1): if progress_callback: progress_callback(completed_runs, self.config.n_runs) raw_records.extend(records) nfev_total += nfev - summaries.append(summary) - return raw_records, nfev_total, summaries + return raw_records, nfev_total def _basin_hopping_sampling( self, @@ -463,7 +423,7 @@ def _basin_hopping_sampling( initial_points: np.ndarray, progress_callback: Callable[[int, int], None] | None = None, verbose: bool = False, - ) -> tuple[list[dict], int, list[dict]]: + ) -> tuple[list[dict], int]: """ Run Basin-Hopping sampling to generate LON data. @@ -482,11 +442,10 @@ def _basin_hopping_sampling( process, regardless of ``n_jobs``. Returns: - Tuple of (raw_records, nfev_total, summaries) where raw_records is a list of + Tuple of (raw_records, nfev_total) where raw_records is a list of raw sampling records (one per perturbation step, each a dict with keys: - run, iteration, current_x, current_f, new_x, new_f, accepted, success, - status, message), nfev_total is the total number of function evaluations - and summaries holds one ``run_summary`` row per run, in completion order. + run, iteration, current_x, current_f, new_x, new_f, accepted) and + nfev_total is the total number of function evaluations. """ n_var = len(domain) domain_array = np.array(domain) @@ -573,13 +532,13 @@ def _construct_trace_data(self, raw_records: list[dict]) -> pd.DataFrame: if self.config.preserve_runs_without_transitions: # A run with no accepted moves keeps its initial minimum as a self-loop row # (dropped from the graph by LON.from_trace_data()), so it still counts - # towards success/deviation. Failed runs have no records. + # towards success/deviation. Non-finite minima are skipped. runs_in_trace = {row["run"] for row in trace_records} first_records: dict[int, dict] = {} for rec in raw_records: first_records.setdefault(rec["run"], rec) for run, rec in first_records.items(): - if run in runs_in_trace: + if run in runs_in_trace or not np.isfinite(rec["current_f"]): continue node = self._hash_solution( self._round_value(rec["current_x"], self.config.coordinate_precision) @@ -658,31 +617,14 @@ def sample( resolved_points = self._resolve_initial_points(initial_points, domain) # Collect all raw sampling data - raw_records, nfev_total, summaries = self._basin_hopping_sampling( + raw_records, nfev_total = self._basin_hopping_sampling( func, domain, resolved_points, progress_callback, verbose ) # Construct trace data from accepted transitions trace_df = self._construct_trace_data(raw_records) - run_summary = pd.DataFrame(summaries, columns=RUN_SUMMARY_COLUMNS) - run_summary = run_summary.sort_values(by=["run"]).reset_index(drop=True) - n_failed_runs = int((run_summary["status"] == "failed").sum()) - n_failed_minimizations = int(run_summary["n_failed_minimizations"].sum()) - if n_failed_minimizations: - warnings.warn( - f"{n_failed_minimizations} local search(es) failed (ValueError or non-finite fitness); " - f"{n_failed_runs} of {self.config.n_runs} run(s) failed. " - "See BasinHoppingResult.run_summary.", - stacklevel=2, - ) - - return BasinHoppingResult( - trace_df=trace_df, - raw_records=raw_records, - nfev=nfev_total, - run_summary=run_summary, - ) + return BasinHoppingResult(trace_df=trace_df, raw_records=raw_records, nfev=nfev_total) def sample_to_lon( self, @@ -728,7 +670,7 @@ def _run_single_bh_in_worker( bounds_array: np.ndarray | None, config: BasinHoppingSamplerConfig, seed: np.random.SeedSequence, -) -> tuple[int, list[dict], int, dict]: +) -> tuple[int, list[dict], int]: """ Module-level worker for a single Basin-Hopping run. @@ -747,12 +689,12 @@ def _run_single_bh_in_worker( seed: ``SeedSequence`` child spawned for this run. Returns: - Tuple of (run, records, nfev, summary) for this run. + Tuple of (run, records, nfev) for this run. """ sampler = BasinHoppingSampler(config) rng = np.random.default_rng(seed) - records, nfev, summary = sampler._single_bh_run(run, func, initial_point, p, bounds_array, rng) - return run, records, nfev, summary + records, nfev = sampler._single_bh_run(run, func, initial_point, p, bounds_array, rng) + return run, records, nfev def compute_lon( diff --git a/tests/continuous/test_sampling.py b/tests/continuous/test_sampling.py index ee13997..93d69bf 100644 --- a/tests/continuous/test_sampling.py +++ b/tests/continuous/test_sampling.py @@ -91,15 +91,6 @@ def test_all_perturbations_fail(self, preserve: bool) -> None: messages = [str(w.message) for w in warnings] assert sum("minimize after perturbation failed" in m for m in messages) == 2 assert result.nfev == 1 - assert result.run_summary.to_dict("records") == [ - { - "run": 1, - "status": "completed", - "n_iterations": 2, - "n_failed_minimizations": 2, - "nfev": 1, - } - ] lon = sampler.sample_to_lon(result) if not preserve: assert result.raw_records == [] @@ -168,7 +159,7 @@ def _res(x: float, fun: float, success: bool = True) -> OptimizeResult: class TestMinimizerFailures: - def test_unconverged_result_is_used_and_status_recorded(self) -> None: + def test_unconverged_finite_result_is_used(self) -> None: sampler = BasinHoppingSampler(BasinHoppingSamplerConfig(n_runs=1, max_iter=1, seed=SEED)) results = [_res(2.0, 8.0), _res(1.0, 2.0, success=False)] with patch("lonkit.continuous.sampling.minimize", side_effect=results): @@ -176,18 +167,7 @@ def test_unconverged_result_is_used_and_status_recorded(self) -> None: (record,) = result.raw_records assert record["accepted"] - assert record["success"] is False - assert record["status"] == 2 - assert record["message"] == "ABNORMAL" - assert result.run_summary.to_dict("records") == [ - { - "run": 1, - "status": "completed", - "n_iterations": 1, - "n_failed_minimizations": 0, - "nfev": 2, - } - ] + assert result.nfev == 2 assert sampler.sample_to_lon(result).n_vertices == 2 @pytest.mark.parametrize("bad", [float("nan"), float("inf")]) @@ -202,12 +182,6 @@ def f(x: np.ndarray) -> float: with pytest.warns(UserWarning, match="initial local search failed"): result = sampler.sample(f, [(-5, 5)] * 2, initial_points=points) - assert result.run_summary["status"].tolist() == [ - "failed", - "failed", - "completed", - "completed", - ] assert set(result.trace_df["run"]) == {3, 4} assert np.isfinite(result.trace_df[["fit1", "fit2"]].to_numpy()).all() assert all(r["run"] in (3, 4) for r in result.raw_records) @@ -218,36 +192,53 @@ def test_non_finite_mid_run_is_never_accepted(self) -> None: results = [_res(2.0, 8.0), _res(1.0, float("-inf")), _res(1.0, 2.0)] with ( patch("lonkit.continuous.sampling.minimize", side_effect=results), - pytest.warns(UserWarning, match="1 local search"), + pytest.warns(UserWarning, match="minimize after perturbation failed"), ): result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((1, 2))) assert [r["accepted"] for r in result.raw_records] == [False, True] assert result.trace_df[["fit1", "fit2"]].to_numpy().tolist() == [[8.0, 2.0]] - assert result.run_summary.loc[0, "n_failed_minimizations"] == 1 def test_initial_value_error_reports_failed_run(self) -> None: sampler = BasinHoppingSampler(BasinHoppingSamplerConfig(n_runs=2, max_iter=1, seed=SEED)) results = [ValueError("bad"), _res(2.0, 8.0), _res(1.0, 2.0)] with ( patch("lonkit.continuous.sampling.minimize", side_effect=results), - pytest.warns(UserWarning, match="1 of 2 run"), + pytest.warns(UserWarning, match="Run 1: initial local search failed"), ): result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((2, 2))) - assert result.run_summary["status"].tolist() == ["failed", "completed"] assert result.trace_df["run"].tolist() == [2] - def test_run_summary_matches_across_n_jobs(self) -> None: + @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) + def test_failed_perturbations_stop_after_no_change_limit(self, bad: float) -> None: + sampler = BasinHoppingSampler( + BasinHoppingSamplerConfig(n_runs=1, n_iter_no_change=2, max_iter=None, seed=SEED) + ) + results = [_res(2.0, 8.0), _res(1.0, bad), _res(1.0, bad)] + with ( + patch("lonkit.continuous.sampling.minimize", side_effect=results) as minimize, + pytest.warns(UserWarning, match="minimize after perturbation failed") as warnings, + ): + result = sampler.sample(sphere, DOMAIN_2D, initial_points=np.zeros((1, 2))) + + assert minimize.call_count == 3 + assert len(warnings) == 2 + assert not any(r["accepted"] for r in result.raw_records) + assert result.nfev == 3 + assert result.trace_df["fit1"].tolist() == [8.0] + assert sampler.sample_to_lon(result).n_vertices == 1 + + def test_failed_runs_match_across_n_jobs(self) -> None: def f(x: np.ndarray) -> float: return float("nan") if x[0] > 3 else float(np.sum(x**2)) - summaries = [] + results = [] + points = np.array([[4.0, 1.0], [-1.0, -1.0]]) for n_jobs in (1, 2): config = BasinHoppingSamplerConfig( - n_runs=6, n_iter_no_change=3, seed=SEED, n_jobs=n_jobs + n_runs=2, n_iter_no_change=3, seed=SEED, n_jobs=n_jobs ) - with pytest.warns(UserWarning): - result = BasinHoppingSampler(config).sample(f, DOMAIN_2D) - summaries.append(result.run_summary) - pd.testing.assert_frame_equal(summaries[0], summaries[1]) - assert summaries[0]["run"].tolist() == list(range(1, 7)) + results.append(BasinHoppingSampler(config).sample(f, DOMAIN_2D, initial_points=points)) + pd.testing.assert_frame_equal(results[0].trace_df, results[1].trace_df) + assert results[0].nfev == results[1].nfev + assert set(results[0].trace_df["run"]) == {2} From b56d248bdca40a2869a5624c803fd7ae9703ecc9 Mon Sep 17 00:00:00 2001 From: WojtAcht Date: Wed, 14 Oct 2026 15:57:37 +0200 Subject: [PATCH 3/3] fix: re-emit warnings from parallel Basin-Hopping workers Warnings raised in joblib worker processes never reached the parent, so failed local searches were invisible with n_jobs > 1. Workers now return caught warnings and the parent re-emits them. Also drop stale comments and a dead finiteness check. --- src/lonkit/continuous/sampling.py | 22 +++++++++++++++------- src/lonkit/continuous/step_size.py | 4 +--- tests/continuous/test_sampling.py | 9 ++++++++- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/lonkit/continuous/sampling.py b/src/lonkit/continuous/sampling.py index 7aa103d..52f6fb9 100644 --- a/src/lonkit/continuous/sampling.py +++ b/src/lonkit/continuous/sampling.py @@ -383,6 +383,9 @@ def _parallel_bh( The ``joblib.Parallel`` runner needs to use the ``_run_single_bh_in_worker`` function, because of pickling - the pickled objects cannot share the same memory to achieve true parallelism between the processes, so we need to create separate ``BasinHoppingSampler`` object for each run. + + Warnings raised in worker processes do not reach the parent process, so workers + return them and they are re-emitted here. """ parallel_runner = joblib.Parallel( n_jobs=effective_n_jobs, @@ -408,7 +411,9 @@ def _parallel_bh( result_iter = ( tqdm(parallel_results, total=self.config.n_runs) if verbose else parallel_results ) - for completed_runs, (_, records, nfev) in enumerate(result_iter, 1): + for completed_runs, (_, records, nfev, caught) in enumerate(result_iter, 1): + for message in caught: + warnings.warn(message, stacklevel=2) if progress_callback: progress_callback(completed_runs, self.config.n_runs) raw_records.extend(records) @@ -532,13 +537,13 @@ def _construct_trace_data(self, raw_records: list[dict]) -> pd.DataFrame: if self.config.preserve_runs_without_transitions: # A run with no accepted moves keeps its initial minimum as a self-loop row # (dropped from the graph by LON.from_trace_data()), so it still counts - # towards success/deviation. Non-finite minima are skipped. + # towards success/deviation. runs_in_trace = {row["run"] for row in trace_records} first_records: dict[int, dict] = {} for rec in raw_records: first_records.setdefault(rec["run"], rec) for run, rec in first_records.items(): - if run in runs_in_trace or not np.isfinite(rec["current_f"]): + if run in runs_in_trace: continue node = self._hash_solution( self._round_value(rec["current_x"], self.config.coordinate_precision) @@ -670,7 +675,7 @@ def _run_single_bh_in_worker( bounds_array: np.ndarray | None, config: BasinHoppingSamplerConfig, seed: np.random.SeedSequence, -) -> tuple[int, list[dict], int]: +) -> tuple[int, list[dict], int, list[Warning | str]]: """ Module-level worker for a single Basin-Hopping run. @@ -689,12 +694,15 @@ def _run_single_bh_in_worker( seed: ``SeedSequence`` child spawned for this run. Returns: - Tuple of (run, records, nfev) for this run. + Tuple of (run, records, nfev, warnings) for this run. Warnings are returned + instead of emitted, because they would not reach the parent process. """ sampler = BasinHoppingSampler(config) rng = np.random.default_rng(seed) - records, nfev = sampler._single_bh_run(run, func, initial_point, p, bounds_array, rng) - return run, records, nfev + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + records, nfev = sampler._single_bh_run(run, func, initial_point, p, bounds_array, rng) + return run, records, nfev, [w.message for w in caught] def compute_lon( diff --git a/src/lonkit/continuous/step_size.py b/src/lonkit/continuous/step_size.py index 9f9d4d4..24cdc61 100644 --- a/src/lonkit/continuous/step_size.py +++ b/src/lonkit/continuous/step_size.py @@ -163,9 +163,7 @@ def _compute_escape_rate( x_perturbed = sampler._perturbation(optimum, p, rng, bounds_array) res_perturbed, failure = sampler._local_search(func, x_perturbed, bounds_array) if failure is not None: - # L-BFGS-B can produce internal iterates that slightly - # violate bounds, causing approx_derivative to fail. - # Skip this perturbation and try the next one. + # Excluded from this sample's escape rate warnings.warn( f"Minimize after perturbation failed ({failure}). " f"Perturbed point: {x_perturbed}. Skipping perturbation.", diff --git a/tests/continuous/test_sampling.py b/tests/continuous/test_sampling.py index 93d69bf..528f853 100644 --- a/tests/continuous/test_sampling.py +++ b/tests/continuous/test_sampling.py @@ -233,12 +233,19 @@ def f(x: np.ndarray) -> float: return float("nan") if x[0] > 3 else float(np.sum(x**2)) results = [] + messages = [] points = np.array([[4.0, 1.0], [-1.0, -1.0]]) for n_jobs in (1, 2): config = BasinHoppingSamplerConfig( n_runs=2, n_iter_no_change=3, seed=SEED, n_jobs=n_jobs ) - results.append(BasinHoppingSampler(config).sample(f, DOMAIN_2D, initial_points=points)) + with pytest.warns(UserWarning) as caught: + results.append( + BasinHoppingSampler(config).sample(f, DOMAIN_2D, initial_points=points) + ) + messages.append(sorted(str(w.message) for w in caught)) + assert any("Run 1: initial local search failed" in m for m in messages[1]) + assert messages[0] == messages[1] pd.testing.assert_frame_equal(results[0].trace_df, results[1].trace_df) assert results[0].nfev == results[1].nfev assert set(results[0].trace_df["run"]) == {2}