better-optimize 0.3__tar.gz → 0.3.1__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.1}/PKG-INFO +49 -1
  2. {better_optimize-0.3 → better_optimize-0.3.1}/README.md +48 -0
  3. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/__init__.py +2 -1
  4. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/_version.py +3 -3
  5. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/constants.py +1 -1
  6. better_optimize-0.3/better_optimize/multistart.py → better_optimize-0.3.1/better_optimize/multi_optimize.py +129 -65
  7. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/wrapper.py +50 -29
  8. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize.egg-info/PKG-INFO +49 -1
  9. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize.egg-info/SOURCES.txt +1 -1
  10. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_multistart.py +37 -131
  11. {better_optimize-0.3 → better_optimize-0.3.1}/LICENSE +0 -0
  12. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/basinhopping.py +0 -0
  13. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/minimize.py +0 -0
  14. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/root.py +0 -0
  15. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize/utilities.py +0 -0
  16. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize.egg-info/dependency_links.txt +0 -0
  17. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize.egg-info/requires.txt +0 -0
  18. {better_optimize-0.3 → better_optimize-0.3.1}/better_optimize.egg-info/top_level.txt +0 -0
  19. {better_optimize-0.3 → better_optimize-0.3.1}/pyproject.toml +0 -0
  20. {better_optimize-0.3 → better_optimize-0.3.1}/setup.cfg +0 -0
  21. {better_optimize-0.3 → better_optimize-0.3.1}/setup.py +0 -0
  22. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_basinhopping.py +0 -0
  23. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_jax_integration.py +0 -0
  24. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_minimize.py +0 -0
  25. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_root.py +0 -0
  26. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_utilities.py +0 -0
  27. {better_optimize-0.3 → better_optimize-0.3.1}/tests/test_wrapper.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.1
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-20T00:48:11+0100",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "7c17be556adeb26794fee1e150f9b149cfa3d95c",
15
- "version": "0.3"
14
+ "full-revisionid": "0478ed4b401b74272d06dee5e904e77be2916235",
15
+ "version": "0.3.1"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -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": {
@@ -12,14 +12,15 @@ import numpy as np
12
12
  import pandas as pd
13
13
 
14
14
  from rich.console import Console
15
- from rich.progress import BarColumn, MofNCompleteColumn, TaskID, TextColumn, TimeElapsedColumn
16
- from rich.table import Column, Table
15
+ from rich.progress import TaskID
16
+ from rich.table import Table
17
17
  from scipy.optimize import OptimizeResult
18
18
  from scipy.stats import qmc
19
19
  from threadpoolctl import threadpool_limits
20
20
 
21
- from better_optimize.constants import CONSOLE_WIDTH
21
+ from better_optimize.constants import MINIMIZE_MODE_KWARGS, ROOT_MODE_KWARGS
22
22
  from better_optimize.utilities import ToggleableProgress
23
+ from better_optimize.wrapper import build_progress_bar
23
24
 
24
25
  _log = logging.getLogger(__name__)
25
26
 
@@ -268,41 +269,8 @@ class MultiStartResult:
268
269
  )
269
270
 
270
271
 
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
- """
272
+ class _MultiStart:
273
+ """Implementation behind :func:`multistart`. Not part of the public API."""
306
274
 
307
275
  def __init__(
308
276
  self,
@@ -384,7 +352,8 @@ class MultiStart:
384
352
  progress = self._build_progress()
385
353
  task_ids = self._register_tasks(progress)
386
354
 
387
- queue: mp.Queue = mp.Queue()
355
+ manager = mp.Manager()
356
+ queue = manager.Queue()
388
357
  stop = threading.Event()
389
358
  listener = threading.Thread(
390
359
  target=_drain_progress_queue,
@@ -394,20 +363,23 @@ class MultiStart:
394
363
 
395
364
  with blas_config.limiter(), progress:
396
365
  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,
366
+ try:
367
+ results = Parallel(n_jobs=self._n_jobs, backend=self._backend)(
368
+ delayed(_run_single)(
369
+ run_index=i,
370
+ x0=x0_i,
371
+ solver=self._solver,
372
+ solver_kwargs=self._solver_kwargs,
373
+ progress=ProgressProxy(queue, task_id),
374
+ task_id=task_id,
375
+ blas_cores_per_worker=blas_config.per_worker,
376
+ )
377
+ for i, (x0_i, task_id) in enumerate(zip(self._starting_points, task_ids))
406
378
  )
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)
379
+ finally:
380
+ stop.set()
381
+ listener.join(timeout=1.0)
382
+ manager.shutdown()
411
383
 
412
384
  return MultiStartResult(results=results, sort_key=self._sort_key)
413
385
 
@@ -422,31 +394,123 @@ class MultiStart:
422
394
  name = getattr(self._solver, "__name__", "")
423
395
  return self._SOLVER_LABELS.get(name, name.title() or "Optimizing")
424
396
 
397
+ @property
398
+ def _is_root(self) -> bool:
399
+ return getattr(self._solver, "__name__", "") == "root"
400
+
425
401
  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),
402
+ method = self._solver_kwargs.get("method", "")
403
+
404
+ if self._is_root:
405
+ mode_info = ROOT_MODE_KWARGS.get(method, {})
406
+ use_jac = mode_info.get("uses_jac", False) or "jac" in self._solver_kwargs
407
+ use_hess = False
408
+ else:
409
+ mode_info = MINIMIZE_MODE_KWARGS.get(method, {})
410
+ use_jac = mode_info.get("uses_grad", False) or "jac" in self._solver_kwargs
411
+ use_hess = (
412
+ mode_info.get("uses_hess", False)
413
+ or "hess" in self._solver_kwargs
414
+ or "hessp" in self._solver_kwargs
415
+ )
416
+
417
+ return build_progress_bar(
418
+ description=self._task_description,
419
+ progressbar=self._progressbar,
420
+ root=self._is_root,
421
+ use_jac=use_jac,
422
+ use_hess=use_hess,
438
423
  )
439
424
 
440
425
  def _register_tasks(self, progress: ToggleableProgress) -> list[TaskID | None]:
426
+ # Include all possible fields so columns never hit a missing key.
427
+ # Unused fields (e.g. hess_norm when no hess column) are silently ignored.
441
428
  return [
442
429
  progress.add_task(
443
430
  description=self._task_description,
444
431
  total=None,
445
432
  f_value=0.0,
446
433
  grad_norm=0.0,
434
+ hess_norm=0.0,
447
435
  )
448
436
  for _ in self._starting_points
449
437
  ]
450
438
 
451
439
 
452
- __all__ = ["MultiStart", "MultiStartResult", "generate_starts"]
440
+ def multi_optimize(
441
+ solver: Callable[..., OptimizeResult],
442
+ x0: np.ndarray | list[np.ndarray],
443
+ solver_kwargs: dict | None = None,
444
+ sort_key: Callable[[OptimizeResult], float] | None = None,
445
+ n_runs: int = 16,
446
+ init_strategy: InitStrategy = "normal",
447
+ bounds: tuple[np.ndarray, np.ndarray] | None = None,
448
+ init_scale: float = 1.0,
449
+ backend: Literal["sequential", "loky", "threading"] = "loky",
450
+ n_jobs: int = -1,
451
+ seed: int | None = None,
452
+ progressbar: bool = True,
453
+ mp_ctx: mp.context.BaseContext | None = None,
454
+ blas_cores: int | None | Literal["auto"] = "auto",
455
+ ) -> MultiStartResult:
456
+ """
457
+ Run a solver from many starting points and return the collected results.
458
+
459
+ Parameters
460
+ ----------
461
+ solver: Callable
462
+ Any solver with signature (x0, **kwargs) -> OptimizeResult, such as minimize or root
463
+ x0: np.ndarray or list of np.ndarray
464
+ A single starting point used as the center for init_strategy, or an explicit list of
465
+ starting points. When a list is provided, n_runs and init_strategy are ignored.
466
+ solver_kwargs: dict, optional
467
+ Keyword arguments forwarded verbatim to solver on every call
468
+ sort_key: Callable, optional
469
+ Function mapping an OptimizeResult to a float used for ranking. Defaults to
470
+ lambda res: res.fun, which ranks results by their optimized objective value.
471
+ n_runs: int
472
+ Number of random restarts. Ignored when x0 is a list.
473
+ init_strategy: str or Callable
474
+ How to generate starting points from x0. One of "uniform", "normal", "sobol", "lhs",
475
+ or a callable with signature (x0, n_runs, rng) → list of arrays.
476
+ bounds: tuple, optional
477
+ (low, high) arrays or scalars, required by bounded init strategies
478
+ init_scale: float
479
+ Standard deviation for the "normal" init strategy
480
+ backend: str
481
+ Execution backend. One of "sequential", "loky", or "threading".
482
+ n_jobs: int
483
+ Number of parallel workers. -1 means all cores.
484
+ seed: int, optional
485
+ Random seed for reproducible starting points
486
+ progressbar: bool
487
+ Whether to display per-run progress bars
488
+ mp_ctx: multiprocessing context, optional
489
+ Explicit multiprocessing context (spawn, fork, or forkserver)
490
+ blas_cores: int, str, or None
491
+ Total BLAS/OpenMP thread budget. "auto" splits evenly across workers, None is unlimited.
492
+
493
+ Returns
494
+ -------
495
+ result: MultiStartResult
496
+ Collected results with ranking, summary, and export utilities
497
+ """
498
+ return _MultiStart(
499
+ solver=solver,
500
+ x0=x0,
501
+ solver_kwargs=solver_kwargs,
502
+ sort_key=sort_key,
503
+ n_runs=n_runs,
504
+ init_strategy=init_strategy,
505
+ bounds=bounds,
506
+ init_scale=init_scale,
507
+ backend=backend,
508
+ n_jobs=n_jobs,
509
+ seed=seed,
510
+ progressbar=progressbar,
511
+ mp_ctx=mp_ctx,
512
+ blas_cores=blas_cores,
513
+ ).run()
514
+
515
+
516
+ __all__ = ["multi_optimize", "MultiStartResult", "generate_starts"]
@@ -24,6 +24,47 @@ from better_optimize.utilities import ToggleableProgress
24
24
  _log = logging.getLogger(__name__)
25
25
 
26
26
 
27
+ def build_progress_bar(
28
+ *,
29
+ description: str = "Minimizing",
30
+ progressbar: bool = True,
31
+ root: bool = False,
32
+ use_jac: bool = False,
33
+ use_hess: bool = False,
34
+ ) -> ToggleableProgress:
35
+ """Create a ``ToggleableProgress`` bar with columns matching the solver configuration.
36
+
37
+ Used by both :class:`ObjectiveWrapper` (standalone) and :class:`MultiStart` (shared bar).
38
+ """
39
+ bar_column = BarColumn(bar_width=None, table_column=Column(description, ratio=1))
40
+ time_column = TimeElapsedColumn(table_column=Column("Elapsed", ratio=1))
41
+ n_iters = MofNCompleteColumn(table_column=Column("Iteration"))
42
+
43
+ objective_name = "Objective" if not root else "Residual"
44
+ obj_column = TextColumn(
45
+ "{task.fields[f_value]:0.8f}", table_column=Column(objective_name, ratio=1)
46
+ )
47
+
48
+ columns = [bar_column, time_column, n_iters, obj_column]
49
+
50
+ if use_jac:
51
+ grad_name = "||grad||" if not root else "||jac||"
52
+ columns.append(
53
+ TextColumn("{task.fields[grad_norm]:0.8f}", table_column=Column(grad_name, ratio=1))
54
+ )
55
+ if use_hess:
56
+ columns.append(
57
+ TextColumn("{task.fields[hess_norm]:0.8f}", table_column=Column("||hess||", ratio=1))
58
+ )
59
+
60
+ return ToggleableProgress(
61
+ *columns,
62
+ expand=False,
63
+ disable=not progressbar,
64
+ console=Console(width=CONSOLE_WIDTH),
65
+ )
66
+
67
+
27
68
  class ObjectiveWrapper:
28
69
  def __init__(
29
70
  self,
@@ -151,6 +192,9 @@ class ObjectiveWrapper:
151
192
  if self.n_eval == 0 and self.task is None:
152
193
  verb = "Minimizing" if not self.root else "Finding Roots"
153
194
  self.task = self.progress.add_task(verb, total=self.maxeval, refresh=True, **value_dict)
195
+ elif self.n_eval == 0:
196
+ # Task was pre-registered (e.g. by MultiStart) without a known total.
197
+ self.progress.update(self.task, total=self.maxeval, **value_dict)
154
198
 
155
199
  if not completed:
156
200
  self.progress.update(self.task, advance=self.update_every, **value_dict)
@@ -160,36 +204,13 @@ class ObjectiveWrapper:
160
204
  )
161
205
 
162
206
  def initialize_progress_bar(self):
163
- # text_column = TextColumn("{task.description}", table_column=Column(ratio=1))
164
207
  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),
208
+ return build_progress_bar(
209
+ description=description,
210
+ progressbar=self.progressbar,
211
+ root=self.root,
212
+ use_jac=self.use_jac,
213
+ use_hess=self.use_hess,
193
214
  )
194
215
 
195
216
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better_optimize
3
- Version: 0.3
3
+ Version: 0.3.1
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,48 +263,6 @@ 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 = []
318
-
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)
323
-
324
- _run_single(
325
- run_index=0,
326
- x0=np.array([1.0]),
327
- solver=inspecting_solver,
328
- solver_kwargs={},
329
- progress=MagicMock(),
330
- task_id=0,
331
- blas_cores_per_worker=1,
332
- )
333
- if observed_limits:
334
- assert all(n <= 1 for n in observed_limits)
335
-
336
-
337
266
  def test_run_single_propagates_solver_exception():
338
267
  def exploding_solver(x0, **kwargs):
339
268
  raise RuntimeError("solver exploded")
@@ -420,18 +349,19 @@ class TestMultiStartResult:
420
349
  )
421
350
  def test_task_description_for_known_solvers(solver_name, expected_label):
422
351
  stub = MagicMock(__name__=solver_name)
423
- ms = MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
352
+ ms = _MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
424
353
  assert ms._task_description == expected_label
425
354
 
426
355
 
427
356
  def test_task_description_falls_back_for_unknown_solver():
428
357
  stub = MagicMock(__name__="my_custom_opt")
429
- ms = MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
358
+ ms = _MultiStart(solver=stub, x0=[np.zeros(2)], progressbar=False)
430
359
  assert ms._task_description == "My_Custom_Opt"
431
360
 
432
361
 
433
- def test_multistart_finds_rosenbrock_minimum():
434
- result = MultiStart(
362
+ @pytest.mark.parametrize("backend", ["sequential", "loky"])
363
+ def test_multistart_finds_rosenbrock_minimum(backend):
364
+ result = multi_optimize(
435
365
  solver=minimize,
436
366
  solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B"),
437
367
  x0=np.zeros(3),
@@ -439,8 +369,11 @@ def test_multistart_finds_rosenbrock_minimum():
439
369
  init_strategy="uniform",
440
370
  bounds=(-2, 2),
441
371
  seed=7913,
372
+ backend=backend,
373
+ n_jobs=2,
442
374
  progressbar=False,
443
- ).run()
375
+ blas_cores=None,
376
+ )
444
377
 
445
378
  assert len(result.results) == 8
446
379
  assert_allclose(result.x_best, np.ones(3), atol=1e-3)
@@ -449,12 +382,12 @@ def test_multistart_finds_rosenbrock_minimum():
449
382
 
450
383
  def test_multistart_with_explicit_starting_points():
451
384
  x0_list = [np.array([1.3, 0.7, 0.8]), np.array([0.5, 0.5, 0.5])]
452
- result = MultiStart(
385
+ result = multi_optimize(
453
386
  solver=minimize,
454
387
  solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B"),
455
388
  x0=x0_list,
456
389
  progressbar=False,
457
- ).run()
390
+ )
458
391
 
459
392
  assert len(result.results) == 2
460
393
  for result_i, expected_x0 in zip(result.results, x0_list):
@@ -462,7 +395,7 @@ def test_multistart_with_explicit_starting_points():
462
395
 
463
396
 
464
397
  def test_multistart_with_root_solver():
465
- result = MultiStart(
398
+ result = multi_optimize(
466
399
  solver=root,
467
400
  solver_kwargs=dict(f=simple_system, method="hybr"),
468
401
  sort_key=lambda res: float(np.linalg.norm(res.fun)),
@@ -472,38 +405,11 @@ def test_multistart_with_root_solver():
472
405
  init_scale=2.0,
473
406
  seed=9173,
474
407
  progressbar=False,
475
- ).run()
408
+ )
476
409
 
477
410
  assert result.fun_best < 1e-6
478
411
 
479
412
 
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
413
  def test_multistart_pickle_fallback(caplog):
508
414
  def unpicklable_closure():
509
415
  return None
@@ -513,14 +419,14 @@ def test_multistart_pickle_fallback(caplog):
513
419
  return OptimizeResult(x=x0, fun=float(np.sum(x0**2)), success=True)
514
420
 
515
421
  with caplog.at_level("WARNING", logger="better_optimize.multistart"):
516
- result = MultiStart(
422
+ result = multi_optimize(
517
423
  solver=solver_with_closure,
518
424
  x0=[np.zeros(2), np.ones(2)],
519
425
  backend="loky",
520
426
  n_jobs=2,
521
427
  progressbar=False,
522
428
  blas_cores=None,
523
- ).run()
429
+ )
524
430
 
525
431
  # Fallback may or may not trigger depending on environment,
526
432
  # but either way we must get valid results.
File without changes
File without changes
File without changes