better-optimize 0.4.2__tar.gz → 0.4.3__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 (50) hide show
  1. {better_optimize-0.4.2 → better_optimize-0.4.3}/PKG-INFO +175 -22
  2. better_optimize-0.4.3/README.md +373 -0
  3. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/__init__.py +2 -1
  4. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/_version.py +3 -3
  5. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/basinhopping/core.py +145 -90
  6. better_optimize-0.4.3/better_optimize/configuration/__init__.py +116 -0
  7. better_optimize-0.4.3/better_optimize/configuration/base.py +368 -0
  8. better_optimize-0.4.3/better_optimize/configuration/first_order.py +281 -0
  9. better_optimize-0.4.3/better_optimize/configuration/global_optimizers.py +267 -0
  10. better_optimize-0.4.3/better_optimize/configuration/gradient_free.py +108 -0
  11. better_optimize-0.4.3/better_optimize/configuration/registry.py +341 -0
  12. better_optimize-0.4.3/better_optimize/configuration/root_direct.py +182 -0
  13. better_optimize-0.4.3/better_optimize/configuration/root_jac_options.py +224 -0
  14. better_optimize-0.4.3/better_optimize/configuration/root_quasi_newton.py +250 -0
  15. better_optimize-0.4.3/better_optimize/configuration/second_order.py +193 -0
  16. better_optimize-0.4.3/better_optimize/configuration/supports_constraints.py +249 -0
  17. better_optimize-0.4.3/better_optimize/constants.py +38 -0
  18. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/differential_evolution.py +79 -39
  19. better_optimize-0.4.3/better_optimize/exceptions.py +34 -0
  20. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/minimize.py +61 -21
  21. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/multi_optimize.py +101 -19
  22. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/root.py +27 -17
  23. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/sequential_optimize.py +259 -35
  24. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/utilities.py +23 -106
  25. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/wrapper.py +42 -11
  26. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize.egg-info/PKG-INFO +175 -22
  27. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize.egg-info/SOURCES.txt +12 -0
  28. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize.egg-info/requires.txt +2 -2
  29. {better_optimize-0.4.2 → better_optimize-0.4.3}/pyproject.toml +2 -2
  30. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_basinhopping.py +90 -2
  31. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_differential_evolution.py +52 -9
  32. better_optimize-0.4.3/tests/test_exceptions.py +135 -0
  33. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_minimize.py +160 -1
  34. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_multistart.py +184 -3
  35. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_root.py +102 -6
  36. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_sequential_optimize.py +279 -8
  37. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_utilities.py +22 -147
  38. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_wrapper.py +99 -24
  39. better_optimize-0.4.2/README.md +0 -220
  40. better_optimize-0.4.2/better_optimize/constants.py +0 -405
  41. better_optimize-0.4.2/better_optimize/exceptions.py +0 -10
  42. {better_optimize-0.4.2 → better_optimize-0.4.3}/LICENSE +0 -0
  43. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/basinhopping/__init__.py +0 -0
  44. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/basinhopping/progress.py +0 -0
  45. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize/basinhopping/runner.py +0 -0
  46. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize.egg-info/dependency_links.txt +0 -0
  47. {better_optimize-0.4.2 → better_optimize-0.4.3}/better_optimize.egg-info/top_level.txt +0 -0
  48. {better_optimize-0.4.2 → better_optimize-0.4.3}/setup.cfg +0 -0
  49. {better_optimize-0.4.2 → better_optimize-0.4.3}/setup.py +0 -0
  50. {better_optimize-0.4.2 → better_optimize-0.4.3}/tests/test_jax_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better_optimize
3
- Version: 0.4.2
3
+ Version: 0.4.3
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
@@ -19,10 +19,10 @@ Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: numpy
22
- Requires-Dist: scipy>=1.15
22
+ Requires-Dist: scipy>=1.18
23
23
  Requires-Dist: rich
24
24
  Requires-Dist: threadpoolctl
25
- Requires-Dist: joblib
25
+ Requires-Dist: joblib>=1.4
26
26
  Requires-Dist: pandas
27
27
  Provides-Extra: tests
28
28
  Requires-Dist: pytest; extra == "tests"
@@ -38,6 +38,7 @@ include:
38
38
  - Progress bar!
39
39
  - Early stopping!
40
40
  - Better propagation of common arguments (`maxiters`, `tol`)!
41
+ - A typed, documented configuration object for every method, so swapping optimizers is a one-argument change!
41
42
 
42
43
  ## Installation
43
44
 
@@ -68,39 +69,49 @@ All optimization routines in `better_optimize` can display a rich, informative p
68
69
 
69
70
  - No more nested `options` dictionaries! You can pass `tol`, `maxiter`, and other common options directly as top-level keyword arguments.
70
71
  - `better_optimize` automatically sorts and promotes these arguments to the correct place for each optimizer.
71
- - Generalizes argument handling: always provides `tol` and `maxiter` (or their equivalents) to the optimizer, even if you forget.
72
+ - Generalizes argument handling: always provides `tol` and `maxiter` (or their equivalents) to the optimizer, even if you forget. `root` methods disagree about whether that budget is called `maxiter`, `maxfev` or `maxfun`, and you can pass `maxiter` to any of them.
73
+ - Seven of the ten `root` methods take part of their configuration nested inside `jac_options`, which scipy splats into the jacobian it builds. Pass it as a mapping or as the typed object for that jacobian (`BroydenJacOptions`, `KrylovJacOptions`, and so on). An option that belongs there raises if you pass it at the top level, where scipy would warn and drop it.
72
74
 
73
- ### 3. Argument Checking and Validation
75
+ ### 3. Typed Configuration Objects
76
+
77
+ Every method scipy supports has a dataclass whose fields are exactly the options that method accepts, typed, defaulted, and documented with scipy's own prose. `help(BFGSConfig)` tells you what `c2` does without a trip to `scipy.optimize.show_options`.
78
+
79
+ - Pass one wherever a method name works: `minimize(f, x0, method=BFGSConfig(gtol=1e-8))`. The string form keeps working unchanged.
80
+ - A misspelled option raises `TypeError` naming it, rather than reaching scipy and being dropped with a warning nobody reads.
81
+ - Fields and defaults are checked against scipy's own function signatures by test, so a scipy release that moves a default fails a test instead of drifting quietly.
82
+ - One `minimize` call reaches local minimizers, basinhopping and differential evolution alike, because the configuration carries which solver runs it.
83
+
84
+ ### 4. Argument Checking and Validation
74
85
 
75
86
  - Automatic checking of provided gradient (`jac`), Hessian (`hess`), and Hessian-vector (`hessp`) functions.
76
87
  - Warns if you provide unnecessary or unused arguments for a given method.
77
88
  - Detects and handles fused objective functions (e.g., functions returning `(loss, grad)` or `(loss, grad, hess)` tuples).
78
89
  - Ensures that the correct function signatures and return types are used for each optimizer.
79
90
 
80
- ### 4. LRUCache1 for Fused Functions
91
+ ### 5. LRUCache1 for Fused Functions
81
92
 
82
93
  - Provides an `LRUCache1` utility to cache the results of expensive objective/gradient/Hessian computations.
83
94
  - Especially useful for triple-fused functions that return value, gradient, and Hessian together, avoiding redundant computation.
84
95
  - Totally invisible -- just pass a function with 3 return values. Seamlessly integrated into the optimization workflow.
85
96
 
86
- ### 5. Robust Basin-Hopping with Failure Tolerance
97
+ ### 6. Robust Basin-Hopping with Failure Tolerance
87
98
 
88
99
  - Enhanced `basinhopping` implementation allows you to continue even if the local minimizer fails.
89
100
  - Optionally accepts and stores failed minimizer results if they improve the global minimum.
90
101
  - Useful for noisy or non-smooth objective functions where local minimization may occasionally fail.
91
102
 
92
- ### 6. Uniform Callback API
103
+ ### 7. Uniform Callback API
93
104
 
94
105
  scipy passes callbacks a different argument for almost every method: `callback(xk)`, `callback(xk, state)`,
95
106
  `callback(intermediate_result)`, or `callback(x, f)` for root finding. `better_optimize` normalizes them to a single
96
- `callback(res)`, where `res` is an `OptimizeResult` with the current `res.x`, `res.fun`, and `res.nit` — plus `res.jac`
107
+ `callback(res)`, where `res` is an `OptimizeResult` with the current `res.x`, `res.fun`, and `res.nit` -- plus `res.jac`
97
108
  when a gradient is available, and solver-specific fields like `res.accept` (basinhopping) or `res.convergence`
98
109
  (differential evolution).
99
110
 
100
111
  - The return value is ignored, so a callback can report a value (an ELBO, a logged loss) without stopping the run.
101
112
  - Raise `StopOptimization` to stop early; the best result so far is returned with `success=False`.
102
113
  - The same callback works across `minimize`, `root`, `basinhopping`, and `differential_evolution`. `hybr` and `lm` are
103
- the exception — scipy never calls a callback for them, so it is ignored with a warning.
114
+ the exception -- scipy never calls a callback for them, so it is ignored with a warning.
104
115
 
105
116
  ---
106
117
 
@@ -111,6 +122,7 @@ when a gradient is available, and solver-specific fields like `res.accept` (basi
111
122
  ```python
112
123
  import numpy as np
113
124
  from better_optimize import minimize
125
+ from better_optimize.configuration import LBFGSBConfig
114
126
 
115
127
  def rosenbrock(x):
116
128
  return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
@@ -118,9 +130,7 @@ def rosenbrock(x):
118
130
  result = minimize(
119
131
  rosenbrock,
120
132
  x0=np.array([-1.0, 2.0]),
121
- method="L-BFGS-B",
122
- tol=1e-6,
123
- maxiter=1000,
133
+ method=LBFGSBConfig(tol=1e-6, maxiter=1000),
124
134
  progressbar=True, # Show a rich progress bar!
125
135
  )
126
136
  ```
@@ -133,6 +143,78 @@ result = minimize(
133
143
 
134
144
  The result object is a standard `OptimizeResult` from `scipy.optimize`, so there are no surprises there!
135
145
 
146
+ ### Swapping Optimizers
147
+
148
+ A configuration object says which solver runs it, so trying a different optimizer family is a change to one argument rather than a rewrite. Nothing below changes but the loop variable.
149
+
150
+ ```python
151
+ import numpy as np
152
+ from better_optimize import minimize
153
+ from better_optimize.configuration import (
154
+ BasinHoppingConfig,
155
+ DifferentialEvolutionConfig,
156
+ LBFGSBConfig,
157
+ )
158
+
159
+ def rosenbrock(x):
160
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
161
+
162
+ x0 = np.array([-1.0, 2.0])
163
+ bounds = [(-5.0, 5.0), (-5.0, 5.0)]
164
+
165
+ for config in [
166
+ LBFGSBConfig(maxiter=500),
167
+ BasinHoppingConfig(niter=25, minimizer_config=LBFGSBConfig()),
168
+ DifferentialEvolutionConfig(popsize=20, rng=0),
169
+ ]:
170
+ result = minimize(rosenbrock, x0, method=config, bounds=bounds, progressbar=False)
171
+ print(f"{config.method_name:25s} {result.fun:.3e}")
172
+ ```
173
+
174
+ ```shell
175
+ L-BFGS-B 9.191e-12
176
+ basinhopping 3.696e-12
177
+ differential_evolution 4.980e-30
178
+ ```
179
+
180
+ `L-BFGS-B` is a local minimizer, `basinhopping` restarts one from perturbed points, and `differential_evolution` is a population method that never calls the local minimizer at all. They take different arguments, and scipy reaches them through different entry points. The configuration object is what absorbs that difference.
181
+
182
+ ### scipy Backwards Compatibility
183
+
184
+ For users who have already memorized every combination of algorithm and option, none of the above is required. Method names still work, and so do flat keywords -- including the ones scipy keeps inside its `options` dictionary, which `better_optimize` gathers up for you. If you memorized that part too, pass `options` yourself and it is merged and checked the same way.
185
+
186
+ ```python
187
+ import numpy as np
188
+ from better_optimize import minimize
189
+ from better_optimize.configuration import LBFGSBConfig
190
+
191
+ def rosenbrock(x):
192
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
193
+
194
+ x0 = np.array([-1.0, 2.0])
195
+
196
+ # A configuration object
197
+ minimize(rosenbrock, x0, method=LBFGSBConfig(ftol=1e-6, gtol=1e-6, maxiter=1000, maxfun=1000))
198
+
199
+ # Flat keywords, including ftol and gtol, which scipy keeps in `options`
200
+ minimize(rosenbrock, x0, method="L-BFGS-B", ftol=1e-6, gtol=1e-6, maxiter=1000)
201
+
202
+ # Or the options dictionary itself
203
+ minimize(rosenbrock, x0, method="L-BFGS-B",
204
+ options={"ftol": 1e-6, "gtol": 1e-6, "maxiter": 1000, "maxfun": 1000})
205
+ ```
206
+
207
+ Those three are the same call and return the same answer. The flat form additionally spreads a top-level `maxiter` across every budget the method has, which for `L-BFGS-B` means `maxfun` -- that is why the first and third forms name it and the second does not.
208
+
209
+ All three are checked the same way, which is the part that is not backwards compatible. A misspelled option raises `TypeError` before the run starts, with a suggestion:
210
+
211
+ ```python
212
+ minimize(rosenbrock, x0, method="L-BFGS-B", options={"gtoll": 1e-6})
213
+ # TypeError: LBFGSBConfig.__init__() got an unexpected keyword argument 'gtoll'. Did you mean 'gtol'?
214
+ ```
215
+
216
+ scipy would have accepted that call, run to completion, and mentioned the dropped option in an `OptimizeWarning` afterwards.
217
+
136
218
  ### Callbacks and Early Stopping
137
219
 
138
220
  Pass a `callback` and it runs after each iteration with an `OptimizeResult`. Its return value is ignored; raise
@@ -141,6 +223,7 @@ Pass a `callback` and it runs after each iteration with an `OptimizeResult`. Its
141
223
  ```python
142
224
  import numpy as np
143
225
  from better_optimize import minimize, StopOptimization
226
+ from better_optimize.configuration import LBFGSBConfig
144
227
 
145
228
  def rosenbrock(x):
146
229
  return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
@@ -155,7 +238,7 @@ def callback(res):
155
238
  result = minimize(
156
239
  rosenbrock,
157
240
  x0=np.array([-1.0, 2.0]),
158
- method="L-BFGS-B",
241
+ method=LBFGSBConfig(),
159
242
  callback=callback,
160
243
  )
161
244
  ```
@@ -163,10 +246,32 @@ result = minimize(
163
246
  The same callback works for `root` (`res.fun` is the residual vector), `basinhopping` (`res.accept`), and
164
247
  `differential_evolution` (`res.convergence`).
165
248
 
249
+ ### Interrupting a Run
250
+
251
+ Ctrl-C raises `OptimizationInterrupted`, a subclass of `KeyboardInterrupt`, so it stops a loop that calls a solver.
252
+ The partial result is on its `result` attribute, of the same type the solver returns: the last finite iterate for
253
+ `minimize` and `root`, the best basin for `basinhopping`, the best point evaluated so far for `differential_evolution`,
254
+ the finished stages for `sequential_optimize`, and the finished runs for `multi_optimize`. For the nested solvers, the
255
+ inner solver's partial result is on `__cause__.result`. The objective is not called again after the interrupt, so a
256
+ stalled objective does not stall the cleanup.
257
+
258
+ ```python
259
+ from better_optimize import minimize, OptimizationInterrupted
260
+
261
+ try:
262
+ result = minimize(rosenbrock, x0=np.array([-1.0, 2.0]), method="L-BFGS-B")
263
+ except OptimizationInterrupted as interrupt:
264
+ result = interrupt.result
265
+ ```
266
+
267
+ In IPython or Jupyter the `try` is unnecessary: after the traceback, the partial result is at
268
+ `sys.last_value.result`.
269
+
166
270
  ### Triple-Fused Function using Pytensor
167
271
 
168
272
  ```python
169
273
  from better_optimize import minimize
274
+ from better_optimize.configuration import NewtonCGConfig
170
275
  import pytensor.tensor as pt
171
276
  from pytensor import function
172
277
  import numpy as np
@@ -182,9 +287,7 @@ x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
182
287
  result = minimize(
183
288
  fused_fn, # No need to set flags separately, `better_optimize` handles it!
184
289
  x0=x0,
185
- method="Newton-CG",
186
- tol=1e-6,
187
- maxiter=1000,
290
+ method=NewtonCGConfig(tol=1e-6, maxiter=1000),
188
291
  progressbar=True, # Show a rich progress bar!
189
292
  )
190
293
  ```
@@ -193,6 +296,55 @@ Many sub-computations are repeated between the objective, gradient, and hessian
193
296
  fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
194
297
  This avoids redundant computation and speeds up the optimization process.
195
298
 
299
+ ### Global Optimization with Differential Evolution
300
+
301
+ `differential_evolution` searches a bounded region with a population instead of descending from a starting point, which suits objectives with many local minima and no useful gradient. It takes `bounds` rather than `x0`, and gets the same progress bar and callback treatment as everything else.
302
+
303
+ ```python
304
+ import numpy as np
305
+ from better_optimize import differential_evolution
306
+
307
+ def rastrigin(x):
308
+ return 10 * len(x) + sum(x**2 - 10 * np.cos(2 * np.pi * x))
309
+
310
+ result = differential_evolution(
311
+ rastrigin,
312
+ bounds=[(-5.12, 5.12)] * 5,
313
+ popsize=20,
314
+ rng=0,
315
+ )
316
+
317
+ print(result.x) # [-0. -0. -0. -0. -0.]
318
+ print(result.fun) # 0.0
319
+ ```
320
+
321
+ Rastrigin has a local minimum at roughly every integer lattice point, so a gradient method started anywhere but the middle converges to the wrong one.
322
+
323
+ ### Chaining Optimizers with `sequential_optimize`
324
+
325
+ It is often quicker to get close with a robust method and finish with a fast one. `sequential_optimize` runs a list of stages, forwarding each stage's best point into the next stage's starting point, and reports what every stage did.
326
+
327
+ ```python
328
+ import numpy as np
329
+ from better_optimize import sequential_optimize
330
+ from better_optimize.configuration import BFGSConfig, NelderMeadConfig
331
+
332
+ def rosenbrock(x):
333
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
334
+
335
+ result = sequential_optimize(
336
+ rosenbrock,
337
+ x0=np.array([-1.2, 1.0]),
338
+ stages=[NelderMeadConfig(maxiter=200), BFGSConfig(gtol=1e-10)],
339
+ )
340
+
341
+ print(result.x) # [0.99999552 0.99999104]
342
+ print(result.best_stage) # 1
343
+ result.summary() # Rich table of every stage, with the winner starred
344
+ ```
345
+
346
+ A stage can also be a dict carrying its own `solver` and keyword arguments, which is what you need when a stage wants its own `bounds`, `constraints`, or starting point. By default the chain stops at the first stage that fails. Pass `on_failure="continue"` to run the rest anyway and keep the best result from those that worked.
347
+
196
348
  ### Parallel Optimization from Multiple Starting Points
197
349
 
198
350
  Real-world objectives often have multiple local minima. A common workaround is to throw many random starting points at
@@ -201,13 +353,14 @@ the optimizer and keep the best result. `better_optimize` makes this painless wi
201
353
  ```python
202
354
  import numpy as np
203
355
  from better_optimize import minimize, multi_optimize
356
+ from better_optimize.configuration import LBFGSBConfig
204
357
 
205
358
  def rosenbrock(x):
206
359
  return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
207
360
 
208
361
  result = multi_optimize(
209
362
  solver=minimize,
210
- solver_kwargs=dict(f=rosenbrock, method="L-BFGS-B", tol=1e-10),
363
+ solver_kwargs=dict(f=rosenbrock, method=LBFGSBConfig(tol=1e-10)),
211
364
  x0=np.zeros(5),
212
365
  n_runs=16,
213
366
  init_strategy="uniform",
@@ -227,18 +380,18 @@ result.summary() # Rich table of all runs, ranked
227
380
  > The `loky` (process) backend needs an `if __name__ == "__main__":` guard when run as a script on macOS or Windows.
228
381
  > Notebooks and the `threading`/`sequential` backends don't.
229
382
 
230
- `multi_optimize` works with **any** solver that follows the `(x0, **kwargs) → OptimizeResult` signature — that includes
383
+ `multi_optimize` works with **any** solver that follows the `(x0, **kwargs) -> OptimizeResult` signature -- that includes
231
384
  `minimize`, `root`, `basinhopping`, or your own custom wrapper. It just calls `solver(x0=x0_i, **solver_kwargs)` for
232
385
  each starting point; it never inspects the solver internals.
233
386
 
234
387
  A few highlights:
235
388
 
236
- - **Initialization strategies** — `"uniform"`, `"normal"`, `"sobol"`, `"lhs"`, or pass your own callable. Bounded
389
+ - **Initialization strategies** -- `"uniform"`, `"normal"`, `"sobol"`, `"lhs"`, or pass your own callable. Bounded
237
390
  strategies (`uniform`, `sobol`, `lhs`) require a `bounds` argument; `"normal"` perturbs around `x0` with a
238
391
  configurable `init_scale`. Or just pass an explicit `list[np.ndarray]` as `x0` and skip the generation entirely.
239
- - **Parallel backends** — `"sequential"` (for debugging), `"loky"` (CPU-bound work, default), or `"threading"`
392
+ - **Parallel backends** -- `"sequential"` (for debugging), `"loky"` (CPU-bound work, default), or `"threading"`
240
393
  (GIL-releasing code). Under the hood this is `joblib`, so the usual `n_jobs=-1` convention works.
241
- - **BLAS thread control** — When many workers each spawn a full BLAS/OpenMP thread pool, you get noisy-neighbor
394
+ - **BLAS thread control** -- When many workers each spawn a full BLAS/OpenMP thread pool, you get noisy-neighbor
242
395
  over-subscription. The `blas_cores` argument (default `"auto"`) caps the total thread budget so workers don't
243
396
  fight over the same cores.
244
397