better-optimize 0.3__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {better_optimize-0.3 → better_optimize-0.3.2}/PKG-INFO +49 -1
  2. {better_optimize-0.3 → better_optimize-0.3.2}/README.md +48 -0
  3. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/__init__.py +2 -1
  4. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/_version.py +3 -3
  5. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/basinhopping.py +3 -1
  6. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/constants.py +1 -1
  7. better_optimize-0.3/better_optimize/multistart.py → better_optimize-0.3.2/better_optimize/multi_optimize.py +147 -68
  8. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/wrapper.py +85 -43
  9. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/PKG-INFO +49 -1
  10. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/SOURCES.txt +1 -1
  11. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_multistart.py +83 -138
  12. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_wrapper.py +5 -4
  13. {better_optimize-0.3 → better_optimize-0.3.2}/LICENSE +0 -0
  14. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/minimize.py +0 -0
  15. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/root.py +0 -0
  16. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/utilities.py +0 -0
  17. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/dependency_links.txt +0 -0
  18. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/requires.txt +0 -0
  19. {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/top_level.txt +0 -0
  20. {better_optimize-0.3 → better_optimize-0.3.2}/pyproject.toml +0 -0
  21. {better_optimize-0.3 → better_optimize-0.3.2}/setup.cfg +0 -0
  22. {better_optimize-0.3 → better_optimize-0.3.2}/setup.py +0 -0
  23. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_basinhopping.py +0 -0
  24. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_jax_integration.py +0 -0
  25. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_minimize.py +0 -0
  26. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_root.py +0 -0
  27. {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_utilities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better_optimize
3
- Version: 0.3
3
+ Version: 0.3.2
4
4
  Summary: A drop-in replacement for scipy optimize functions with quality of life improvements
5
5
  License: MIT License
6
6
  Project-URL: Homepage, https://github.com/jessegrabowski/better_optimize
@@ -149,6 +149,54 @@ Many sub-computations are repeated between the objective, gradient, and hessian
149
149
  fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
150
150
  This avoids redundant computation and speeds up the optimization process.
151
151
 
152
+ ### Parallel Optimization from Multiple Starting Points
153
+
154
+ Real-world objectives often have multiple local minima. A common workaround is to throw many random starting points at
155
+ the optimizer and keep the best result. `better_optimize` makes this painless with `multi_optimize`:
156
+
157
+ ```python
158
+ from better_optimize import minimize, multi_optimize
159
+
160
+ def rosenbrock(x):
161
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
162
+
163
+ result = multi_optimize(
164
+ solver=minimize,
165
+ solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B", tol=1e-10),
166
+ x0=np.zeros(5),
167
+ n_runs=16,
168
+ init_strategy="uniform",
169
+ bounds=(-5, 5),
170
+ backend="loky",
171
+ n_jobs=-1,
172
+ seed=42,
173
+ progressbar=True,
174
+ )
175
+
176
+ print(result.best) # Best OptimizeResult
177
+ print(result.x_best) # Best parameter vector
178
+ print(result.fun_best) # Best objective value
179
+ result.summary() # Rich table of all runs, ranked
180
+ ```
181
+
182
+ `multi_optimize` works with **any** solver that follows the `(x0, **kwargs) → OptimizeResult` signature — that includes
183
+ `minimize`, `root`, `basinhopping`, or your own custom wrapper. It just calls `solver(x0=x0_i, **solver_kwargs)` for
184
+ each starting point; it never inspects the solver internals.
185
+
186
+ A few highlights:
187
+
188
+ - **Initialization strategies** — `"uniform"`, `"normal"`, `"sobol"`, `"lhs"`, or pass your own callable. Bounded
189
+ strategies (`uniform`, `sobol`, `lhs`) require a `bounds` argument; `"normal"` perturbs around `x0` with a
190
+ configurable `init_scale`. Or just pass an explicit `list[np.ndarray]` as `x0` and skip the generation entirely.
191
+ - **Parallel backends** — `"sequential"` (for debugging), `"loky"` (CPU-bound work, default), or `"threading"`
192
+ (GIL-releasing code). Under the hood this is `joblib`, so the usual `n_jobs=-1` convention works.
193
+ - **BLAS thread control** — When many workers each spawn a full BLAS/OpenMP thread pool, you get noisy-neighbor
194
+ over-subscription. The `blas_cores` argument (default `"auto"`) caps the total thread budget so workers don't
195
+ fight over the same cores.
196
+
197
+ The returned `MultiStartResult` gives you `best`, `top_k(k)`, `ranked()`, `success_rate`, a `summary()` table, and
198
+ `to_dataframe()` for further analysis.
199
+
152
200
 
153
201
  ## Contributing
154
202
 
@@ -117,6 +117,54 @@ Many sub-computations are repeated between the objective, gradient, and hessian
117
117
  fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
118
118
  This avoids redundant computation and speeds up the optimization process.
119
119
 
120
+ ### Parallel Optimization from Multiple Starting Points
121
+
122
+ Real-world objectives often have multiple local minima. A common workaround is to throw many random starting points at
123
+ the optimizer and keep the best result. `better_optimize` makes this painless with `multi_optimize`:
124
+
125
+ ```python
126
+ from better_optimize import minimize, multi_optimize
127
+
128
+ def rosenbrock(x):
129
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
130
+
131
+ result = multi_optimize(
132
+ solver=minimize,
133
+ solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B", tol=1e-10),
134
+ x0=np.zeros(5),
135
+ n_runs=16,
136
+ init_strategy="uniform",
137
+ bounds=(-5, 5),
138
+ backend="loky",
139
+ n_jobs=-1,
140
+ seed=42,
141
+ progressbar=True,
142
+ )
143
+
144
+ print(result.best) # Best OptimizeResult
145
+ print(result.x_best) # Best parameter vector
146
+ print(result.fun_best) # Best objective value
147
+ result.summary() # Rich table of all runs, ranked
148
+ ```
149
+
150
+ `multi_optimize` works with **any** solver that follows the `(x0, **kwargs) → OptimizeResult` signature — that includes
151
+ `minimize`, `root`, `basinhopping`, or your own custom wrapper. It just calls `solver(x0=x0_i, **solver_kwargs)` for
152
+ each starting point; it never inspects the solver internals.
153
+
154
+ A few highlights:
155
+
156
+ - **Initialization strategies** — `"uniform"`, `"normal"`, `"sobol"`, `"lhs"`, or pass your own callable. Bounded
157
+ strategies (`uniform`, `sobol`, `lhs`) require a `bounds` argument; `"normal"` perturbs around `x0` with a
158
+ configurable `init_scale`. Or just pass an explicit `list[np.ndarray]` as `x0` and skip the generation entirely.
159
+ - **Parallel backends** — `"sequential"` (for debugging), `"loky"` (CPU-bound work, default), or `"threading"`
160
+ (GIL-releasing code). Under the hood this is `joblib`, so the usual `n_jobs=-1` convention works.
161
+ - **BLAS thread control** — When many workers each spawn a full BLAS/OpenMP thread pool, you get noisy-neighbor
162
+ over-subscription. The `blas_cores` argument (default `"auto"`) caps the total thread budget so workers don't
163
+ fight over the same cores.
164
+
165
+ The returned `MultiStartResult` gives you `best`, `top_k(k)`, `ranked()`, `success_rate`, a `summary()` table, and
166
+ `to_dataframe()` for further analysis.
167
+
120
168
 
121
169
  ## Contributing
122
170
 
@@ -3,6 +3,7 @@ import logging
3
3
  from better_optimize._version import get_versions
4
4
  from better_optimize.basinhopping import basinhopping
5
5
  from better_optimize.minimize import minimize
6
+ from better_optimize.multi_optimize import multi_optimize
6
7
  from better_optimize.root import root
7
8
 
8
9
  _log = logging.getLogger(__name__)
@@ -15,4 +16,4 @@ if not logging.root.handlers:
15
16
 
16
17
 
17
18
  __version__ = get_versions()["version"]
18
- __all__ = ["minimize", "root", "basinhopping", "MultiStart", "MultiStartResult"]
19
+ __all__ = ["minimize", "root", "basinhopping", "multi_optimize"]
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2026-03-19T20:00:42+0100",
11
+ "date": "2026-03-20T19:57:42+0100",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "7c17be556adeb26794fee1e150f9b149cfa3d95c",
15
- "version": "0.3"
14
+ "full-revisionid": "2c8f4f7afbc2b54fa1e8aa8827b4be15e6ea87a9",
15
+ "version": "0.3.2"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -1,3 +1,5 @@
1
+ import sys
2
+
1
3
  from collections.abc import Callable, Sequence
2
4
 
3
5
  import numpy as np
@@ -64,7 +66,7 @@ def initialize_progress_bar(progressbar, use_jac=True, use_hess=False):
64
66
  *columns,
65
67
  expand=False,
66
68
  disable=not progressbar,
67
- console=Console(width=CONSOLE_WIDTH),
69
+ console=Console(file=sys.stderr, width=CONSOLE_WIDTH),
68
70
  transient=True,
69
71
  )
70
72
 
@@ -31,7 +31,7 @@ root_method = Literal[
31
31
  ]
32
32
 
33
33
  TOLERANCES = ["xtol", "ftol", "gtol", "fatol", "xatol"]
34
- CONSOLE_WIDTH = 120
34
+ CONSOLE_WIDTH = 100
35
35
 
36
36
  MINIMIZE_MODE_KWARGS = {
37
37
  "nelder-mead": {
@@ -2,6 +2,7 @@ import contextlib
2
2
  import logging
3
3
  import multiprocessing as mp
4
4
  import pickle
5
+ import sys
5
6
  import threading
6
7
 
7
8
  from collections.abc import Callable
@@ -12,14 +13,15 @@ import numpy as np
12
13
  import pandas as pd
13
14
 
14
15
  from rich.console import Console
15
- from rich.progress import BarColumn, MofNCompleteColumn, TaskID, TextColumn, TimeElapsedColumn
16
- from rich.table import Column, Table
16
+ from rich.progress import TaskID
17
+ from rich.table import Table
17
18
  from scipy.optimize import OptimizeResult
18
19
  from scipy.stats import qmc
19
20
  from threadpoolctl import threadpool_limits
20
21
 
21
- from better_optimize.constants import CONSOLE_WIDTH
22
+ from better_optimize.constants import MINIMIZE_MODE_KWARGS, ROOT_MODE_KWARGS
22
23
  from better_optimize.utilities import ToggleableProgress
24
+ from better_optimize.wrapper import build_progress_bar
23
25
 
24
26
  _log = logging.getLogger(__name__)
25
27
 
@@ -175,8 +177,22 @@ def _run_single(
175
177
  if blas_cores_per_worker is not None
176
178
  else contextlib.nullcontext()
177
179
  )
178
- with limiter:
179
- result = solver(x0=x0, progressbar=progress, progress_task=task_id, **solver_kwargs)
180
+ try:
181
+ with limiter:
182
+ result = solver(x0=x0, progressbar=progress, progress_task=task_id, **solver_kwargs)
183
+ except Exception as exc:
184
+ _log.warning(
185
+ "Run %d failed with %s: %s",
186
+ run_index,
187
+ type(exc).__name__,
188
+ exc,
189
+ )
190
+ result = OptimizeResult(
191
+ x=x0,
192
+ fun=np.inf,
193
+ success=False,
194
+ message=f"Solver raised {type(exc).__name__}: {exc}",
195
+ )
180
196
 
181
197
  result.run_index = run_index
182
198
  result.x0 = x0
@@ -248,7 +264,7 @@ class MultiStartResult:
248
264
 
249
265
  for rank, res in enumerate(display, 1):
250
266
  table.add_row(*self._format_result_row(rank, res))
251
- Console().print(table)
267
+ Console(file=sys.stderr).print(table)
252
268
 
253
269
  def _result_to_dict(self, rank: int, res: OptimizeResult) -> dict:
254
270
  """Convert a single ``OptimizeResult`` into a flat dict for DataFrame export."""
@@ -268,41 +284,8 @@ class MultiStartResult:
268
284
  )
269
285
 
270
286
 
271
- class MultiStart:
272
- """Run a solver from many starting points and collect the results.
273
-
274
- Parameters
275
- ----------
276
- solver
277
- Any callable with signature ``(x0, **kwargs) → OptimizeResult``.
278
- x0
279
- A single starting point (used as center for ``init_strategy``) or an explicit
280
- list of starting points (in which case ``n_runs`` and ``init_strategy`` are ignored).
281
- solver_kwargs
282
- Forwarded verbatim to ``solver`` on every call.
283
- sort_key
284
- Function mapping an ``OptimizeResult`` to a float used for ranking.
285
- n_runs
286
- Number of random restarts. Ignored when ``x0`` is a list.
287
- init_strategy
288
- How to generate starting points from ``x0``.
289
- bounds
290
- ``(low, high)`` arrays or scalars, used by bounded init strategies.
291
- init_scale
292
- Standard deviation for ``"normal"`` init strategy.
293
- backend
294
- ``"sequential"``, ``"loky"``, or ``"threading"``.
295
- n_jobs
296
- Number of parallel workers (``-1`` = all cores).
297
- seed
298
- Random seed for reproducible starting points.
299
- progressbar
300
- Whether to display per-run progress bars.
301
- mp_ctx
302
- Explicit ``multiprocessing`` context (``spawn`` / ``fork`` / ``forkserver``).
303
- blas_cores
304
- Total BLAS/OpenMP thread budget. ``"auto"`` = ``n_jobs``, ``None`` = unlimited.
305
- """
287
+ class _MultiStart:
288
+ """Implementation behind :func:`multistart`. Not part of the public API."""
306
289
 
307
290
  def __init__(
308
291
  self,
@@ -384,7 +367,8 @@ class MultiStart:
384
367
  progress = self._build_progress()
385
368
  task_ids = self._register_tasks(progress)
386
369
 
387
- queue: mp.Queue = mp.Queue()
370
+ manager = mp.Manager()
371
+ queue = manager.Queue()
388
372
  stop = threading.Event()
389
373
  listener = threading.Thread(
390
374
  target=_drain_progress_queue,
@@ -394,20 +378,23 @@ class MultiStart:
394
378
 
395
379
  with blas_config.limiter(), progress:
396
380
  listener.start()
397
- results = Parallel(n_jobs=self._n_jobs, backend=self._backend)(
398
- delayed(_run_single)(
399
- run_index=i,
400
- x0=x0_i,
401
- solver=self._solver,
402
- solver_kwargs=self._solver_kwargs,
403
- progress=ProgressProxy(queue, task_id),
404
- task_id=task_id,
405
- blas_cores_per_worker=blas_config.per_worker,
381
+ try:
382
+ results = Parallel(n_jobs=self._n_jobs, backend=self._backend)(
383
+ delayed(_run_single)(
384
+ run_index=i,
385
+ x0=x0_i,
386
+ solver=self._solver,
387
+ solver_kwargs=self._solver_kwargs,
388
+ progress=ProgressProxy(queue, task_id),
389
+ task_id=task_id,
390
+ blas_cores_per_worker=blas_config.per_worker,
391
+ )
392
+ for i, (x0_i, task_id) in enumerate(zip(self._starting_points, task_ids))
406
393
  )
407
- for i, (x0_i, task_id) in enumerate(zip(self._starting_points, task_ids))
408
- )
409
- stop.set()
410
- listener.join(timeout=1.0)
394
+ finally:
395
+ stop.set()
396
+ listener.join(timeout=1.0)
397
+ manager.shutdown()
411
398
 
412
399
  return MultiStartResult(results=results, sort_key=self._sort_key)
413
400
 
@@ -422,31 +409,123 @@ class MultiStart:
422
409
  name = getattr(self._solver, "__name__", "")
423
410
  return self._SOLVER_LABELS.get(name, name.title() or "Optimizing")
424
411
 
412
+ @property
413
+ def _is_root(self) -> bool:
414
+ return getattr(self._solver, "__name__", "") == "root"
415
+
425
416
  def _build_progress(self) -> ToggleableProgress:
426
- columns = [
427
- BarColumn(bar_width=None, table_column=Column(self._task_description, ratio=2)),
428
- TimeElapsedColumn(table_column=Column("Elapsed", ratio=1)),
429
- MofNCompleteColumn(table_column=Column("Iteration")),
430
- TextColumn("{task.fields[f_value]:0.8f}", table_column=Column("Objective", ratio=1)),
431
- TextColumn("{task.fields[grad_norm]:0.8f}", table_column=Column("||grad||", ratio=1)),
432
- ]
433
- return ToggleableProgress(
434
- *columns,
435
- expand=False,
436
- disable=not self._progressbar,
437
- console=Console(width=CONSOLE_WIDTH),
417
+ method = self._solver_kwargs.get("method", "")
418
+
419
+ if self._is_root:
420
+ mode_info = ROOT_MODE_KWARGS.get(method, {})
421
+ use_jac = mode_info.get("uses_jac", False) or "jac" in self._solver_kwargs
422
+ use_hess = False
423
+ else:
424
+ mode_info = MINIMIZE_MODE_KWARGS.get(method, {})
425
+ use_jac = mode_info.get("uses_grad", False) or "jac" in self._solver_kwargs
426
+ use_hess = (
427
+ mode_info.get("uses_hess", False)
428
+ or "hess" in self._solver_kwargs
429
+ or "hessp" in self._solver_kwargs
430
+ )
431
+
432
+ return build_progress_bar(
433
+ description=self._task_description,
434
+ progressbar=self._progressbar,
435
+ root=self._is_root,
436
+ use_jac=use_jac,
437
+ use_hess=use_hess,
438
438
  )
439
439
 
440
440
  def _register_tasks(self, progress: ToggleableProgress) -> list[TaskID | None]:
441
+ # Include all possible fields so columns never hit a missing key.
442
+ # Unused fields (e.g. hess_norm when no hess column) are silently ignored.
441
443
  return [
442
444
  progress.add_task(
443
445
  description=self._task_description,
444
446
  total=None,
445
447
  f_value=0.0,
446
448
  grad_norm=0.0,
449
+ hess_norm=0.0,
447
450
  )
448
451
  for _ in self._starting_points
449
452
  ]
450
453
 
451
454
 
452
- __all__ = ["MultiStart", "MultiStartResult", "generate_starts"]
455
+ def multi_optimize(
456
+ solver: Callable[..., OptimizeResult],
457
+ x0: np.ndarray | list[np.ndarray],
458
+ solver_kwargs: dict | None = None,
459
+ sort_key: Callable[[OptimizeResult], float] | None = None,
460
+ n_runs: int = 16,
461
+ init_strategy: InitStrategy = "normal",
462
+ bounds: tuple[np.ndarray, np.ndarray] | None = None,
463
+ init_scale: float = 1.0,
464
+ backend: Literal["sequential", "loky", "threading"] = "loky",
465
+ n_jobs: int = -1,
466
+ seed: int | None = None,
467
+ progressbar: bool = True,
468
+ mp_ctx: mp.context.BaseContext | None = None,
469
+ blas_cores: int | None | Literal["auto"] = "auto",
470
+ ) -> MultiStartResult:
471
+ """
472
+ Run a solver from many starting points and return the collected results.
473
+
474
+ Parameters
475
+ ----------
476
+ solver: Callable
477
+ Any solver with signature (x0, **kwargs) -> OptimizeResult, such as minimize or root
478
+ x0: np.ndarray or list of np.ndarray
479
+ A single starting point used as the center for init_strategy, or an explicit list of
480
+ starting points. When a list is provided, n_runs and init_strategy are ignored.
481
+ solver_kwargs: dict, optional
482
+ Keyword arguments forwarded verbatim to solver on every call
483
+ sort_key: Callable, optional
484
+ Function mapping an OptimizeResult to a float used for ranking. Defaults to
485
+ lambda res: res.fun, which ranks results by their optimized objective value.
486
+ n_runs: int
487
+ Number of random restarts. Ignored when x0 is a list.
488
+ init_strategy: str or Callable
489
+ How to generate starting points from x0. One of "uniform", "normal", "sobol", "lhs",
490
+ or a callable with signature (x0, n_runs, rng) → list of arrays.
491
+ bounds: tuple, optional
492
+ (low, high) arrays or scalars, required by bounded init strategies
493
+ init_scale: float
494
+ Standard deviation for the "normal" init strategy
495
+ backend: str
496
+ Execution backend. One of "sequential", "loky", or "threading".
497
+ n_jobs: int
498
+ Number of parallel workers. -1 means all cores.
499
+ seed: int, optional
500
+ Random seed for reproducible starting points
501
+ progressbar: bool
502
+ Whether to display per-run progress bars
503
+ mp_ctx: multiprocessing context, optional
504
+ Explicit multiprocessing context (spawn, fork, or forkserver)
505
+ blas_cores: int, str, or None
506
+ Total BLAS/OpenMP thread budget. "auto" splits evenly across workers, None is unlimited.
507
+
508
+ Returns
509
+ -------
510
+ result: MultiStartResult
511
+ Collected results with ranking, summary, and export utilities
512
+ """
513
+ return _MultiStart(
514
+ solver=solver,
515
+ x0=x0,
516
+ solver_kwargs=solver_kwargs,
517
+ sort_key=sort_key,
518
+ n_runs=n_runs,
519
+ init_strategy=init_strategy,
520
+ bounds=bounds,
521
+ init_scale=init_scale,
522
+ backend=backend,
523
+ n_jobs=n_jobs,
524
+ seed=seed,
525
+ progressbar=progressbar,
526
+ mp_ctx=mp_ctx,
527
+ blas_cores=blas_cores,
528
+ ).run()
529
+
530
+
531
+ __all__ = ["multi_optimize", "MultiStartResult", "generate_starts"]
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import sys
2
3
 
3
4
  from collections.abc import Callable
4
5
  from functools import partial
@@ -24,6 +25,47 @@ from better_optimize.utilities import ToggleableProgress
24
25
  _log = logging.getLogger(__name__)
25
26
 
26
27
 
28
+ def build_progress_bar(
29
+ *,
30
+ description: str = "Minimizing",
31
+ progressbar: bool = True,
32
+ root: bool = False,
33
+ use_jac: bool = False,
34
+ use_hess: bool = False,
35
+ ) -> ToggleableProgress:
36
+ """Create a ``ToggleableProgress`` bar with columns matching the solver configuration.
37
+
38
+ Used by both :class:`ObjectiveWrapper` (standalone) and :class:`MultiStart` (shared bar).
39
+ """
40
+ bar_column = BarColumn(bar_width=None, table_column=Column(description, ratio=1))
41
+ time_column = TimeElapsedColumn(table_column=Column("Elapsed", ratio=1))
42
+ n_iters = MofNCompleteColumn(table_column=Column("Iteration"))
43
+
44
+ objective_name = "Objective" if not root else "Residual"
45
+ obj_column = TextColumn(
46
+ "{task.fields[f_value]:0.8f}", table_column=Column(objective_name, ratio=1)
47
+ )
48
+
49
+ columns = [bar_column, time_column, n_iters, obj_column]
50
+
51
+ if use_jac:
52
+ grad_name = "||grad||" if not root else "||jac||"
53
+ columns.append(
54
+ TextColumn("{task.fields[grad_norm]:0.8f}", table_column=Column(grad_name, ratio=1))
55
+ )
56
+ if use_hess:
57
+ columns.append(
58
+ TextColumn("{task.fields[hess_norm]:0.8f}", table_column=Column("||hess||", ratio=1))
59
+ )
60
+
61
+ return ToggleableProgress(
62
+ *columns,
63
+ expand=False,
64
+ disable=not progressbar,
65
+ console=Console(file=sys.stderr, width=CONSOLE_WIDTH),
66
+ )
67
+
68
+
27
69
  class ObjectiveWrapper:
28
70
  def __init__(
29
71
  self,
@@ -151,6 +193,9 @@ class ObjectiveWrapper:
151
193
  if self.n_eval == 0 and self.task is None:
152
194
  verb = "Minimizing" if not self.root else "Finding Roots"
153
195
  self.task = self.progress.add_task(verb, total=self.maxeval, refresh=True, **value_dict)
196
+ elif self.n_eval == 0:
197
+ # Task was pre-registered (e.g. by MultiStart) without a known total.
198
+ self.progress.update(self.task, total=self.maxeval, **value_dict)
154
199
 
155
200
  if not completed:
156
201
  self.progress.update(self.task, advance=self.update_every, **value_dict)
@@ -160,36 +205,13 @@ class ObjectiveWrapper:
160
205
  )
161
206
 
162
207
  def initialize_progress_bar(self):
163
- # text_column = TextColumn("{task.description}", table_column=Column(ratio=1))
164
208
  description = "Minimizing" if not self.root else "Finding Roots"
165
- bar_column = BarColumn(bar_width=None, table_column=Column(description, ratio=1))
166
- time_column = TimeElapsedColumn(table_column=Column("Elapsed", ratio=1))
167
- n_iters = MofNCompleteColumn(table_column=Column("Iteration"))
168
-
169
- objective_name = "Objective" if not self.root else "Residual"
170
- obj_column = TextColumn(
171
- "{task.fields[f_value]:0.8f}", table_column=Column(objective_name, ratio=1)
172
- )
173
-
174
- columns = [bar_column, time_column, n_iters, obj_column]
175
-
176
- if self.use_jac:
177
- grad_name = "||grad||" if not self.root else "||jac||"
178
- columns += [
179
- TextColumn("{task.fields[grad_norm]:0.8f}", table_column=Column(grad_name, ratio=1))
180
- ]
181
- if self.use_hess:
182
- columns += [
183
- TextColumn(
184
- "{task.fields[hess_norm]:0.8f}", table_column=Column("||hess||", ratio=1)
185
- )
186
- ]
187
-
188
- return ToggleableProgress(
189
- *columns,
190
- expand=False,
191
- disable=not self.progressbar,
192
- console=Console(width=CONSOLE_WIDTH),
209
+ return build_progress_bar(
210
+ description=description,
211
+ progressbar=self.progressbar,
212
+ root=self.root,
213
+ use_jac=self.use_jac,
214
+ use_hess=self.use_hess,
193
215
  )
194
216
 
195
217
 
@@ -213,20 +235,40 @@ def optimizer_early_stopping_wrapper(f_optim: partial):
213
235
  "prematurely.",
214
236
  )
215
237
  except Exception as e:
216
- raise e
217
-
218
- outputs = objective.step(final_value)
219
-
220
- if not objective.use_jac and not objective.use_hess:
221
- value = outputs
222
- grad = None
223
- hess = None
224
- elif objective.use_jac and not objective.use_hess:
225
- value, grad = outputs
226
- hess = None
227
- else:
228
- value, grad, hess = outputs
238
+ # Catch any runtime errors from the optimizer (e.g. nan/inf) and return a failed
239
+ # result instead of crashing.
240
+ _log.warning("Optimizer raised %s: %s", type(e).__name__, e)
241
+ final_value = objective.previous_x
242
+ x0 = f_optim.keywords.get("x0")
243
+ if final_value is None:
244
+ final_value = x0
245
+ res = OptimizeResult(
246
+ x=final_value,
247
+ fun=np.inf,
248
+ success=False,
249
+ message=f"Optimizer raised {type(e).__name__}: {e}",
250
+ )
229
251
 
230
- objective.update_progressbar(value, grad, hess, completed=True)
252
+ # Guard against final_value being None (can happen if no finite
253
+ # iterate was ever recorded and no x0 was available).
254
+ if final_value is not None:
255
+ try:
256
+ outputs = objective.step(final_value)
257
+
258
+ if not objective.use_jac and not objective.use_hess:
259
+ value = outputs
260
+ grad = None
261
+ hess = None
262
+ elif objective.use_jac and not objective.use_hess:
263
+ value, grad = outputs
264
+ hess = None
265
+ else:
266
+ value, grad, hess = outputs
267
+
268
+ objective.update_progressbar(value, grad, hess, completed=True)
269
+ except Exception:
270
+ # If the final evaluation itself fails (e.g. NaN inputs),
271
+ # just mark the progress bar as complete.
272
+ _log.debug("Final evaluation failed; skipping progress-bar update.", exc_info=True)
231
273
 
232
274
  return res
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better_optimize
3
- Version: 0.3
3
+ Version: 0.3.2
4
4
  Summary: A drop-in replacement for scipy optimize functions with quality of life improvements
5
5
  License: MIT License
6
6
  Project-URL: Homepage, https://github.com/jessegrabowski/better_optimize
@@ -149,6 +149,54 @@ Many sub-computations are repeated between the objective, gradient, and hessian
149
149
  fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
150
150
  This avoids redundant computation and speeds up the optimization process.
151
151
 
152
+ ### Parallel Optimization from Multiple Starting Points
153
+
154
+ Real-world objectives often have multiple local minima. A common workaround is to throw many random starting points at
155
+ the optimizer and keep the best result. `better_optimize` makes this painless with `multi_optimize`:
156
+
157
+ ```python
158
+ from better_optimize import minimize, multi_optimize
159
+
160
+ def rosenbrock(x):
161
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
162
+
163
+ result = multi_optimize(
164
+ solver=minimize,
165
+ solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B", tol=1e-10),
166
+ x0=np.zeros(5),
167
+ n_runs=16,
168
+ init_strategy="uniform",
169
+ bounds=(-5, 5),
170
+ backend="loky",
171
+ n_jobs=-1,
172
+ seed=42,
173
+ progressbar=True,
174
+ )
175
+
176
+ print(result.best) # Best OptimizeResult
177
+ print(result.x_best) # Best parameter vector
178
+ print(result.fun_best) # Best objective value
179
+ result.summary() # Rich table of all runs, ranked
180
+ ```
181
+
182
+ `multi_optimize` works with **any** solver that follows the `(x0, **kwargs) → OptimizeResult` signature — that includes
183
+ `minimize`, `root`, `basinhopping`, or your own custom wrapper. It just calls `solver(x0=x0_i, **solver_kwargs)` for
184
+ each starting point; it never inspects the solver internals.
185
+
186
+ A few highlights:
187
+
188
+ - **Initialization strategies** — `"uniform"`, `"normal"`, `"sobol"`, `"lhs"`, or pass your own callable. Bounded
189
+ strategies (`uniform`, `sobol`, `lhs`) require a `bounds` argument; `"normal"` perturbs around `x0` with a
190
+ configurable `init_scale`. Or just pass an explicit `list[np.ndarray]` as `x0` and skip the generation entirely.
191
+ - **Parallel backends** — `"sequential"` (for debugging), `"loky"` (CPU-bound work, default), or `"threading"`
192
+ (GIL-releasing code). Under the hood this is `joblib`, so the usual `n_jobs=-1` convention works.
193
+ - **BLAS thread control** — When many workers each spawn a full BLAS/OpenMP thread pool, you get noisy-neighbor
194
+ over-subscription. The `blas_cores` argument (default `"auto"`) caps the total thread budget so workers don't
195
+ fight over the same cores.
196
+
197
+ The returned `MultiStartResult` gives you `best`, `top_k(k)`, `ranked()`, `success_rate`, a `summary()` table, and
198
+ `to_dataframe()` for further analysis.
199
+
152
200
 
153
201
  ## Contributing
154
202
 
@@ -7,7 +7,7 @@ better_optimize/_version.py
7
7
  better_optimize/basinhopping.py
8
8
  better_optimize/constants.py
9
9
  better_optimize/minimize.py
10
- better_optimize/multistart.py
10
+ better_optimize/multi_optimize.py
11
11
  better_optimize/root.py
12
12
  better_optimize/utilities.py
13
13
  better_optimize/wrapper.py
@@ -13,14 +13,15 @@ from numpy.testing import assert_allclose
13
13
  from scipy.optimize import OptimizeResult
14
14
 
15
15
  from better_optimize.minimize import minimize
16
- from better_optimize.multistart import (
17
- MultiStart,
16
+ from better_optimize.multi_optimize import (
18
17
  MultiStartResult,
19
18
  ProgressProxy,
20
19
  _drain_progress_queue,
21
20
  _is_pickle_error,
21
+ _MultiStart,
22
22
  _run_single,
23
23
  generate_starts,
24
+ multi_optimize,
24
25
  setup_blas_cores,
25
26
  )
26
27
  from better_optimize.root import root
@@ -30,17 +31,6 @@ def rosenbrock(x):
30
31
  return float(np.sum(100 * (x[1:] - x[:-1] ** 2) ** 2 + (1 - x[:-1]) ** 2))
31
32
 
32
33
 
33
- def rosenbrock_fused(x):
34
- f = float(np.sum(100 * (x[1:] - x[:-1] ** 2) ** 2 + (1 - x[:-1]) ** 2))
35
- g = np.zeros_like(x)
36
- g[0] = -400 * x[0] * (x[1] - x[0] ** 2) - 2 * (1 - x[0])
37
- g[-1] = 200 * (x[-1] - x[-2] ** 2)
38
- g[1:-1] = (
39
- 200 * (x[1:-1] - x[:-2] ** 2) - 400 * x[1:-1] * (x[2:] - x[1:-1] ** 2) - 2 * (1 - x[1:-1])
40
- )
41
- return f, g
42
-
43
-
44
34
  def simple_system(x):
45
35
  return np.array([x[0] ** 2 + x[1] - 1, x[0] - x[1] ** 2 + 1])
46
36
 
@@ -73,9 +63,14 @@ def test_normal_starts_centered_on_x0(rng):
73
63
 
74
64
 
75
65
  @pytest.mark.parametrize("strategy", ["uniform", "sobol", "lhs"])
76
- def test_bounded_strategies_respect_bounds(strategy, rng):
66
+ @pytest.mark.parametrize(
67
+ "bounds",
68
+ [(-5.0, 5.0), (np.array([-10.0, 0.0, -1.0]), np.array([0.0, 5.0, 1.0]))],
69
+ ids=["scalar", "array"],
70
+ )
71
+ def test_bounded_strategies_respect_bounds(strategy, bounds, rng):
77
72
  n_params, n_runs = 3, 16
78
- low, high = -5.0, 5.0
73
+ low, high = bounds
79
74
  starts = generate_starts(
80
75
  np.zeros(n_params),
81
76
  n_runs,
@@ -110,23 +105,6 @@ def test_lhs_stratification(rng):
110
105
  assert len(set(bins)) == n_runs
111
106
 
112
107
 
113
- def test_generate_starts_with_array_bounds(rng):
114
- low = np.array([-10.0, 0.0, -1.0])
115
- high = np.array([0.0, 5.0, 1.0])
116
- starts = generate_starts(
117
- np.zeros(3),
118
- 64,
119
- "uniform",
120
- bounds=(low, high),
121
- init_scale=1.0,
122
- rng=rng,
123
- )
124
-
125
- stacked = np.stack(starts)
126
- assert np.all(stacked >= low)
127
- assert np.all(stacked <= high)
128
-
129
-
130
108
  def test_callable_strategy_dispatches_to_user_function(rng):
131
109
  x0 = np.array([1.0, 2.0])
132
110
 
@@ -179,12 +157,6 @@ def test_progress_proxy_serializes_action_to_queue(action):
179
157
  assert received_kwargs == payload
180
158
 
181
159
 
182
- def test_progress_proxy_add_task_returns_stored_id():
183
- queue = multiprocessing.Queue()
184
- proxy = ProgressProxy(queue, task_id=99)
185
- assert proxy.add_task("ignored", total=100) == 99
186
-
187
-
188
160
  def test_drain_thread_exits_when_stop_is_set():
189
161
  queue = multiprocessing.Queue()
190
162
  stop = threading.Event()
@@ -223,14 +195,18 @@ def test_drain_thread_dispatches_queued_messages():
223
195
  progress.reset.assert_called_once_with(1, visible=False)
224
196
 
225
197
 
226
- def test_setup_blas_cores_auto_divides_evenly():
227
- config = setup_blas_cores("auto", n_jobs=4, mp_ctx=None)
228
- assert config.per_worker == 1
229
-
230
-
231
- def test_setup_blas_cores_explicit_budget():
232
- config = setup_blas_cores(8, n_jobs=4, mp_ctx=None)
233
- assert config.per_worker == 2
198
+ @pytest.mark.parametrize(
199
+ "blas_cores, n_jobs, expected_per_worker",
200
+ [
201
+ ("auto", 4, 1),
202
+ (8, 4, 2),
203
+ (1, 8, 1), # floors to 1
204
+ ],
205
+ ids=["auto", "explicit", "floors-to-one"],
206
+ )
207
+ def test_setup_blas_cores_per_worker(blas_cores, n_jobs, expected_per_worker):
208
+ config = setup_blas_cores(blas_cores, n_jobs=n_jobs, mp_ctx=None)
209
+ assert config.per_worker == expected_per_worker
234
210
 
235
211
 
236
212
  def test_setup_blas_cores_none_disables_limiting():
@@ -253,11 +229,6 @@ def test_setup_blas_cores_invalid_string_raises():
253
229
  setup_blas_cores("bogus", n_jobs=4, mp_ctx=None)
254
230
 
255
231
 
256
- def test_setup_blas_cores_per_worker_floors_to_one():
257
- config = setup_blas_cores(1, n_jobs=8, mp_ctx=None)
258
- assert config.per_worker == 1
259
-
260
-
261
232
  def test_setup_blas_cores_negative_n_jobs():
262
233
  config = setup_blas_cores("auto", n_jobs=-1, mp_ctx=None)
263
234
  assert config.per_worker == 1
@@ -292,61 +263,27 @@ def test_run_single_attaches_metadata():
292
263
  assert_allclose(result.x0, x0)
293
264
 
294
265
 
295
- def test_run_single_forwards_solver_kwargs():
296
- received = {}
297
-
298
- def capturing_solver(x0, **kwargs):
299
- received.update(kwargs)
300
- return OptimizeResult(x=x0, fun=0.0, success=True)
301
-
302
- _run_single(
303
- run_index=0,
304
- x0=np.array([1.0]),
305
- solver=capturing_solver,
306
- solver_kwargs={"method": "L-BFGS-B", "tol": 1e-8},
307
- progress=MagicMock(),
308
- task_id=0,
309
- )
310
- assert received["method"] == "L-BFGS-B"
311
- assert received["tol"] == 1e-8
312
-
313
-
314
- def test_run_single_applies_blas_thread_limit():
315
- from threadpoolctl import threadpool_info
316
-
317
- observed_limits = []
266
+ def test_run_single_catches_solver_exception():
267
+ """Solver exceptions are caught and returned as failed OptimizeResults."""
318
268
 
319
- def inspecting_solver(x0, **kwargs):
320
- for lib in threadpool_info():
321
- observed_limits.append(lib["num_threads"])
322
- return OptimizeResult(x=x0, fun=0.0, success=True)
269
+ def exploding_solver(x0, **kwargs):
270
+ raise RuntimeError("NaN encountered")
323
271
 
324
- _run_single(
325
- run_index=0,
326
- x0=np.array([1.0]),
327
- solver=inspecting_solver,
272
+ x0 = np.array([1.0, 2.0])
273
+ result = _run_single(
274
+ run_index=3,
275
+ x0=x0,
276
+ solver=exploding_solver,
328
277
  solver_kwargs={},
329
278
  progress=MagicMock(),
330
279
  task_id=0,
331
- blas_cores_per_worker=1,
332
280
  )
333
- if observed_limits:
334
- assert all(n <= 1 for n in observed_limits)
335
-
336
-
337
- def test_run_single_propagates_solver_exception():
338
- def exploding_solver(x0, **kwargs):
339
- raise RuntimeError("solver exploded")
340
-
341
- with pytest.raises(RuntimeError, match="solver exploded"):
342
- _run_single(
343
- run_index=0,
344
- x0=np.zeros(2),
345
- solver=exploding_solver,
346
- solver_kwargs={},
347
- progress=MagicMock(),
348
- task_id=0,
349
- )
281
+ assert not result.success
282
+ assert result.fun == np.inf
283
+ assert "RuntimeError" in result.message
284
+ assert result.run_index == 3
285
+ assert_allclose(result.x0, x0)
286
+ assert_allclose(result.x, x0)
350
287
 
351
288
 
352
289
  class TestMultiStartResult:
@@ -420,18 +357,19 @@ class TestMultiStartResult:
420
357
  )
421
358
  def test_task_description_for_known_solvers(solver_name, expected_label):
422
359
  stub = MagicMock(__name__=solver_name)
423
- ms = MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
360
+ ms = _MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
424
361
  assert ms._task_description == expected_label
425
362
 
426
363
 
427
364
  def test_task_description_falls_back_for_unknown_solver():
428
365
  stub = MagicMock(__name__="my_custom_opt")
429
- ms = MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
366
+ ms = _MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
430
367
  assert ms._task_description == "My_Custom_Opt"
431
368
 
432
369
 
433
- def test_multistart_finds_rosenbrock_minimum():
434
- result = MultiStart(
370
+ @pytest.mark.parametrize("backend", ["sequential", "loky"])
371
+ def test_multistart_finds_rosenbrock_minimum(backend):
372
+ result = multi_optimize(
435
373
  solver=minimize,
436
374
  solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B"),
437
375
  x0=np.zeros(3),
@@ -439,8 +377,11 @@ def test_multistart_finds_rosenbrock_minimum():
439
377
  init_strategy="uniform",
440
378
  bounds=(-2, 2),
441
379
  seed=7913,
380
+ backend=backend,
381
+ n_jobs=2,
442
382
  progressbar=False,
443
- ).run()
383
+ blas_cores=None,
384
+ )
444
385
 
445
386
  assert len(result.results) == 8
446
387
  assert_allclose(result.x_best, np.ones(3), atol=1e-3)
@@ -449,12 +390,12 @@ def test_multistart_finds_rosenbrock_minimum():
449
390
 
450
391
  def test_multistart_with_explicit_starting_points():
451
392
  x0_list = [np.array([1.3, 0.7, 0.8]), np.array([0.5, 0.5, 0.5])]
452
- result = MultiStart(
393
+ result = multi_optimize(
453
394
  solver=minimize,
454
395
  solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B"),
455
396
  x0=x0_list,
456
397
  progressbar=False,
457
- ).run()
398
+ )
458
399
 
459
400
  assert len(result.results) == 2
460
401
  for result_i, expected_x0 in zip(result.results, x0_list):
@@ -462,7 +403,7 @@ def test_multistart_with_explicit_starting_points():
462
403
 
463
404
 
464
405
  def test_multistart_with_root_solver():
465
- result = MultiStart(
406
+ result = multi_optimize(
466
407
  solver=root,
467
408
  solver_kwargs=dict(f=simple_system, method="hybr"),
468
409
  sort_key=lambda res: float(np.linalg.norm(res.fun)),
@@ -472,38 +413,11 @@ def test_multistart_with_root_solver():
472
413
  init_scale=2.0,
473
414
  seed=9173,
474
415
  progressbar=False,
475
- ).run()
416
+ )
476
417
 
477
418
  assert result.fun_best < 1e-6
478
419
 
479
420
 
480
- def test_multistart_x0_list_ignores_n_runs():
481
- result = MultiStart(
482
- solver=minimize,
483
- solver_kwargs=dict(f=rosenbrock, method="nelder-mead"),
484
- x0=[np.zeros(2), np.ones(2)],
485
- n_runs=999,
486
- progressbar=False,
487
- ).run()
488
-
489
- assert len(result.results) == 2
490
-
491
-
492
- def test_multistart_with_fused_objective():
493
- result = MultiStart(
494
- solver=minimize,
495
- solver_kwargs=dict(f=rosenbrock_fused, method="L-BFGS-B"),
496
- x0=np.zeros(3),
497
- n_runs=4,
498
- init_strategy="uniform",
499
- bounds=(-2, 2),
500
- seed=3917,
501
- progressbar=False,
502
- ).run()
503
-
504
- assert_allclose(result.x_best, np.ones(3), atol=1e-3)
505
-
506
-
507
421
  def test_multistart_pickle_fallback(caplog):
508
422
  def unpicklable_closure():
509
423
  return None
@@ -513,15 +427,46 @@ def test_multistart_pickle_fallback(caplog):
513
427
  return OptimizeResult(x=x0, fun=float(np.sum(x0**2)), success=True)
514
428
 
515
429
  with caplog.at_level("WARNING", logger="better_optimize.multistart"):
516
- result = MultiStart(
430
+ result = multi_optimize(
517
431
  solver=solver_with_closure,
518
432
  x0=[np.zeros(2), np.ones(2)],
519
433
  backend="loky",
520
434
  n_jobs=2,
521
435
  progressbar=False,
522
436
  blas_cores=None,
523
- ).run()
437
+ )
524
438
 
525
439
  # Fallback may or may not trigger depending on environment,
526
440
  # but either way we must get valid results.
527
441
  assert len(result.results) == 2
442
+
443
+
444
+ def test_multistart_survives_crashing_solver():
445
+ """A solver that raises for some starting points should not kill the campaign."""
446
+ call_count = 0
447
+
448
+ def sometimes_exploding_solver(x0, **kwargs):
449
+ nonlocal call_count
450
+ call_count += 1
451
+ if call_count % 2 == 0:
452
+ raise RuntimeError("NaN in trust region subproblem")
453
+ return OptimizeResult(x=x0, fun=float(np.sum(x0**2)), success=True)
454
+
455
+ x0_list = [np.ones(2) * i for i in range(4)]
456
+ result = multi_optimize(
457
+ solver=sometimes_exploding_solver,
458
+ x0=x0_list,
459
+ backend="sequential",
460
+ progressbar=False,
461
+ blas_cores=None,
462
+ )
463
+
464
+ assert len(result.results) == 4
465
+ n_success = sum(r.success for r in result.results)
466
+ n_failed = sum(not r.success for r in result.results)
467
+ assert n_success == 2
468
+ assert n_failed == 2
469
+ for r in result.results:
470
+ if not r.success:
471
+ assert r.fun == np.inf
472
+ assert "RuntimeError" in r.message
@@ -43,7 +43,8 @@ def test_early_return_from_keyboard_interrupt(root, method):
43
43
 
44
44
 
45
45
  @pytest.mark.parametrize("root", [True, False], ids=["root", "minimize"])
46
- def test_execption_breaks_optimization(root, monkeypatch):
46
+ def test_exception_returns_failed_result(root, monkeypatch):
47
+ """Exceptions raised inside the optimizer should be caught and returned as a failed result."""
47
48
  N_EXEC = 0
48
49
 
49
50
  def f(x: np.ndarray):
@@ -64,9 +65,9 @@ def test_execption_breaks_optimization(root, monkeypatch):
64
65
  callback=objective.callback,
65
66
  )
66
67
 
67
- # Non-KeyboardInterrupt exceptions should break execution.
68
- with pytest.raises(Exception, match="Simulated error"):
69
- optimizer_early_stopping_wrapper(f_optim)
68
+ result = optimizer_early_stopping_wrapper(f_optim)
69
+ assert not result.success
70
+ assert "Simulated error" in result.message
70
71
 
71
72
 
72
73
  def func2(x, a, b):
File without changes
File without changes
File without changes