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.
- {better_optimize-0.3 → better_optimize-0.3.2}/PKG-INFO +49 -1
- {better_optimize-0.3 → better_optimize-0.3.2}/README.md +48 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/__init__.py +2 -1
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/_version.py +3 -3
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/basinhopping.py +3 -1
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/constants.py +1 -1
- better_optimize-0.3/better_optimize/multistart.py → better_optimize-0.3.2/better_optimize/multi_optimize.py +147 -68
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/wrapper.py +85 -43
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/PKG-INFO +49 -1
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/SOURCES.txt +1 -1
- {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_multistart.py +83 -138
- {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_wrapper.py +5 -4
- {better_optimize-0.3 → better_optimize-0.3.2}/LICENSE +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/minimize.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/root.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize/utilities.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/dependency_links.txt +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/requires.txt +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/better_optimize.egg-info/top_level.txt +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/pyproject.toml +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/setup.cfg +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/setup.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_basinhopping.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_jax_integration.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_minimize.py +0 -0
- {better_optimize-0.3 → better_optimize-0.3.2}/tests/test_root.py +0 -0
- {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", "
|
|
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-
|
|
11
|
+
"date": "2026-03-20T19:57:42+0100",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
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
|
|
|
@@ -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
|
|
16
|
-
from rich.table import
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
272
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
296
|
-
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
)
|
|
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 =
|
|
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
|
-
)
|
|
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 =
|
|
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
|
-
)
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|