BayesHalvingSearchCV 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,15 @@
1
+ """bayes-halving-search-cv: scikit-learn hyperparameter search with bullseye
2
+ multi-fidelity data growth and scatter-search multi-start. Provides
3
+ BayesHalvingSearchCV (a from-scratch GP + Expected Improvement Bayesian
4
+ search) and PatternSearchCV (Hooke-Jeeves pattern search)."""
5
+
6
+ import logging
7
+
8
+ from ._bayes import BayesHalvingSearchCV
9
+ from ._search import PatternSearchCV
10
+ from ._space import Dimension, Space
11
+
12
+ __all__ = ["PatternSearchCV", "BayesHalvingSearchCV", "Space", "Dimension"]
13
+ __version__ = "0.1.0"
14
+
15
+ logging.getLogger("SearchCV").addHandler(logging.NullHandler())
@@ -0,0 +1,509 @@
1
+ """BayesHalvingSearchCV: sklearn-compatible Bayesian hyperparameter search
2
+ (Gaussian Process + Expected Improvement) with bullseye multi-fidelity data
3
+ growth and scatter-search multi-start.
4
+
5
+ Spec: BAYESHALVINGSearchCV_SPEC.md.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import math
12
+ import time
13
+ from copy import deepcopy
14
+ from numbers import Integral
15
+
16
+ import numpy as np
17
+ from sklearn.model_selection._search import BaseSearchCV
18
+ from sklearn.utils import check_random_state, get_tags
19
+ from sklearn.utils.validation import _num_samples
20
+
21
+ from ._climber import _better
22
+ from ._fidelity import BullseyeController
23
+ from ._gp import GPProposer
24
+ from ._sampling import ZoneSplitter, expanding_order, random_order, stratified_order
25
+ from ._space import Space
26
+ from ._starts import select_starts
27
+
28
+ logger = logging.getLogger("SearchCV")
29
+
30
+ _DEFAULT_ZONES = (0.005, 0.01, 0.1, 1.0)
31
+
32
+
33
+ class BayesHalvingSearchCV(BaseSearchCV):
34
+ """Gaussian-Process Bayesian search over a discrete hyperparameter grid,
35
+ with bullseye multi-fidelity data growth and scatter-search multi-start.
36
+
37
+ Despite the name, the fidelity mechanism is this package's self-calibrating
38
+ bullseye-ring data growth (see ``PatternSearchCV``), not classic successive
39
+ halving; the name was chosen by the project's author and kept as-is.
40
+
41
+ ``param_grid`` and multi-start (``n_starts``/``start_points``) follow the
42
+ exact same standard as :class:`PatternSearchCV` — see its docstring and
43
+ ``PatternSearchCV_SPEC.md`` for the search-space and scatter-search
44
+ conventions; they are not duplicated here. This estimator has **zero
45
+ additional dependencies** beyond ``PatternSearchCV``'s own (``numpy``,
46
+ ``scipy``, ``scikit-learn`` — the GP surrogate is
47
+ ``sklearn.gaussian_process.GaussianProcessRegressor`` plus a hand-rolled
48
+ Expected Improvement acquisition; no Optuna, no torch).
49
+
50
+ Parameters
51
+ ----------
52
+ estimator : estimator object
53
+ The estimator to tune.
54
+ param_grid : dict
55
+ Maps parameter names to either an explicit list of values or a
56
+ ``(low, high, num)`` tuple expanded to a linspace grid.
57
+ n_iter : int, default=25
58
+ Budget of genuine (non-cache-served) model evaluations **per start**,
59
+ across all data zones combined, excluding the bounded final-polish
60
+ re-scores (at most ``promote_k`` + 1 extra evaluations per start).
61
+ promote_k : int, default=3
62
+ Number of top-scored configurations re-scored (and used to seed a
63
+ fresh Gaussian Process) whenever the bullseye rings climb to a new
64
+ data zone, and again for the mandatory final polish at full data.
65
+ data_zones : int or sequence of float, default=(0.005, 0.01, 0.1, 1.0)
66
+ The data ladder. Identical semantics to ``PatternSearchCV.data_zones``.
67
+ warmup : int, default=3
68
+ Positions (starting point included) before the bullseye rings
69
+ self-calibrate. Identical semantics to ``PatternSearchCV.warmup``.
70
+ subsample : {"auto", "expanding", "stratified", "random"}, default="auto"
71
+ Identical semantics to ``PatternSearchCV.subsample``.
72
+ subsample_columns : sequence of int, optional
73
+ Column subset watched by the "stratified" transition sampler.
74
+ n_starts : int, default=1
75
+ Independent Bayesian searches. Starts are chosen by the same
76
+ scatter-search mechanism as ``PatternSearchCV`` (QMC pool + greedy
77
+ maximin); every start runs to completion (no elimination between
78
+ starts) and the best full-data optimum wins. Unlike
79
+ ``PatternSearchCV``, there is no state-match merging between starts
80
+ (no clean analog for a stochastic search) — the shared dedup cache is
81
+ the cost-saving mechanism instead.
82
+ start_points : list of dict, optional
83
+ Explicit start points (parameter dicts); they take seats before
84
+ scatter-search generation fills the rest.
85
+
86
+ Notes
87
+ -----
88
+ ``verbose >= 1`` narrates every search decision as it happens (proposals,
89
+ ring crossings, data climbs, final polish) and, at the end of ``fit``,
90
+ logs a full ``cross_validate`` pass on the winning parameters over the
91
+ complete dataset with the user's own ``cv`` splitter, exactly mirroring
92
+ ``PatternSearchCV``. This adds ``n_splits`` extra fits and is skipped
93
+ entirely at ``verbose=0`` (the default). ``verbose >= 2`` additionally
94
+ logs per-proposal debug detail.
95
+ """
96
+
97
+ _required_parameters = ["estimator", "param_grid"]
98
+
99
+ def __init__(self, estimator, param_grid, *, scoring=None, n_jobs=None,
100
+ refit=True, cv=None, verbose=0, random_state=None,
101
+ pre_dispatch="2*n_jobs", error_score=np.nan,
102
+ return_train_score=False,
103
+ n_iter=25, promote_k=3,
104
+ data_zones=_DEFAULT_ZONES, warmup=3,
105
+ subsample="auto", subsample_columns=None,
106
+ n_starts=1, start_points=None):
107
+ super().__init__(estimator=estimator, scoring=scoring, n_jobs=n_jobs,
108
+ refit=refit, cv=cv, verbose=verbose,
109
+ pre_dispatch=pre_dispatch, error_score=error_score,
110
+ return_train_score=return_train_score)
111
+ self.param_grid = param_grid
112
+ self.random_state = random_state
113
+ self.n_iter = n_iter
114
+ self.promote_k = promote_k
115
+ self.data_zones = data_zones
116
+ self.warmup = warmup
117
+ self.subsample = subsample
118
+ self.subsample_columns = subsample_columns
119
+ self.n_starts = n_starts
120
+ self.start_points = start_points
121
+
122
+ # ------------------------------------------------------------------ fit
123
+ def fit(self, X, y=None, **params):
124
+ # NOTE: with n_jobs > 1, sklearn pickles `self` for every parallel
125
+ # task, so nothing unpicklable (handlers, live GP fit state,
126
+ # generators) may ever be stored on the instance during fit.
127
+ handler = self._prepare_run(X, y)
128
+ try:
129
+ super().fit(X, y, **params)
130
+ results = (self._ctx or {}).get("results")
131
+ if results is not None:
132
+ self.local_optima_ = results["local_optima"]
133
+ self.search_history_ = results["history"]
134
+ self.n_cache_hits_ = results["cache_hits"]
135
+ if self.verbose and hasattr(self, "best_params_"):
136
+ self._log_cv_summary(X, y)
137
+ finally:
138
+ if handler is not None:
139
+ logger.removeHandler(handler)
140
+ self._ctx = None
141
+ return self
142
+
143
+ def __sklearn_tags__(self):
144
+ # BaseSearchCV delegates most sub-estimator tags but not these two;
145
+ # we support NaN inputs / multi-output y exactly iff the wrapped
146
+ # estimator does.
147
+ tags = super().__sklearn_tags__()
148
+ sub = get_tags(self.estimator)
149
+ tags.input_tags.allow_nan = sub.input_tags.allow_nan
150
+ tags.target_tags = deepcopy(sub.target_tags)
151
+ return tags
152
+
153
+ def _scoring_label(self):
154
+ """Human-readable name of the metric being optimized (for the
155
+ verbose header and the end-of-run summary)."""
156
+ if self.scoring is None:
157
+ return "estimator default (R^2 for regressors, accuracy for classifiers)"
158
+ if isinstance(self.scoring, str):
159
+ return self.scoring
160
+ if isinstance(self.scoring, dict):
161
+ names = ", ".join(self.scoring.keys())
162
+ which = self.refit if isinstance(self.refit, str) else (
163
+ "callable" if callable(self.refit) else "first")
164
+ return f"{names} (best selected by: {which})"
165
+ if callable(self.scoring):
166
+ return getattr(self.scoring, "__name__", repr(self.scoring))
167
+ return str(self.scoring)
168
+
169
+ def _prepare_run(self, X, y):
170
+ # verbose -> package logger (spec forbids print); the handler is
171
+ # returned (not stored on self) so the instance stays picklable
172
+ handler = None
173
+ if self.verbose and not any(
174
+ not isinstance(h, logging.NullHandler) for h in logger.handlers
175
+ ):
176
+ handler = logging.StreamHandler()
177
+ handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
178
+ logger.addHandler(handler)
179
+ logger.setLevel(logging.DEBUG if self.verbose >= 2 else logging.INFO)
180
+
181
+ space = Space(self.param_grid)
182
+ rng = check_random_state(self.random_state)
183
+ n_samples = _num_samples(X)
184
+ if n_samples < 1:
185
+ raise ValueError(
186
+ f"Found array with {n_samples} sample(s) "
187
+ f"(shape={getattr(X, 'shape', None)}) while a minimum of 1 "
188
+ "is required by BayesHalvingSearchCV.")
189
+ if y is None and self.__sklearn_tags__().target_tags.required:
190
+ raise ValueError(
191
+ f"{type(self).__name__} requires y to be passed, but the "
192
+ "target y is None.")
193
+
194
+ # ---- validate the ladder (same rule as PatternSearchCV) ---------
195
+ zones = self.data_zones
196
+ if isinstance(zones, Integral) and not isinstance(zones, bool):
197
+ if zones < 1:
198
+ raise ValueError(f"data_zones must be >= 1, got {zones}")
199
+ zones = [k / zones for k in range(1, zones + 1)]
200
+ else:
201
+ zones = [float(z) for z in zones]
202
+ if (not zones or any(z <= 0 or z > 1 for z in zones)
203
+ or sorted(zones) != zones or len(set(zones)) != len(zones)
204
+ or zones[-1] != 1.0):
205
+ raise ValueError(
206
+ "data_zones must be strictly ascending fractions in (0, 1] "
207
+ f"ending at 1.0, got {self.data_zones}")
208
+ if not isinstance(self.warmup, Integral) or self.warmup < 3:
209
+ raise ValueError(f"warmup must be an int >= 3, got {self.warmup}")
210
+ if not isinstance(self.n_iter, Integral) or self.n_iter < 1:
211
+ raise ValueError(f"n_iter must be an int >= 1, got {self.n_iter}")
212
+ if not isinstance(self.promote_k, Integral) or self.promote_k < 1:
213
+ raise ValueError(f"promote_k must be an int >= 1, got {self.promote_k}")
214
+ if not isinstance(self.n_starts, Integral) or self.n_starts < 1:
215
+ raise ValueError(f"n_starts must be an int >= 1, got {self.n_starts}")
216
+
217
+ # ---- header: what is being optimized, and over what grid? -------
218
+ logger.info("BayesHalvingSearchCV: optimizing metric = %s",
219
+ self._scoring_label())
220
+ logger.info("cv = %s", type(self.cv).__name__ if self.cv is not None
221
+ else "5-fold KFold (sklearn default)")
222
+ for d in space.dims:
223
+ logger.info(" %s : %s", d.name, d.values)
224
+ logger.info("n_iter=%d, promote_k=%d, n_starts=%d, warmup=%d",
225
+ self.n_iter, self.promote_k, self.n_starts, self.warmup)
226
+
227
+ # ---- resource floor: every zone must feed the CV enough rows ----
228
+ n_splits_guess = getattr(self.cv, "n_splits", None) or (
229
+ self.cv if isinstance(self.cv, Integral) else 5)
230
+ min_rows = max(2 * (int(n_splits_guess) + 1), 8)
231
+ sizes = []
232
+ for z in zones:
233
+ k = min(n_samples, max(min_rows, int(math.ceil(z * n_samples))))
234
+ if not sizes or k > sizes[-1]:
235
+ sizes.append(k)
236
+ sizes[-1] = n_samples
237
+ sizes = sorted(set(sizes))
238
+ eff_zones = [s / n_samples for s in sizes]
239
+ eff_zones[-1] = 1.0
240
+ if len(eff_zones) < len(zones):
241
+ logger.info("data ladder truncated by resource floor: %s -> %s",
242
+ zones, [round(z, 4) for z in eff_zones])
243
+
244
+ # ---- subsample priority ordering (once per fit) ------------------
245
+ mode = self.subsample
246
+ if mode == "auto":
247
+ cv_name = type(self.cv).__name__ if self.cv is not None else ""
248
+ mode = "stratified" if "TimeSeries" in cv_name else "random"
249
+ if mode == "expanding":
250
+ order = expanding_order(n_samples)
251
+ elif mode == "random":
252
+ order = random_order(n_samples, rng)
253
+ elif mode == "stratified":
254
+ order = stratified_order(X, self.subsample_columns)
255
+ else:
256
+ raise ValueError(f"subsample must be 'auto', 'expanding', "
257
+ f"'stratified' or 'random', got {self.subsample!r}")
258
+ logger.info("subsample mode=%s, zones=%s (rows %s)", mode,
259
+ [round(z, 4) for z in eff_zones], sizes)
260
+
261
+ # ---- starts (scatter search, same mechanism as PatternSearchCV) --
262
+ starts = select_starts(space, self.n_starts, self.start_points, rng)
263
+ logger.info("starts (%d): %s", len(starts),
264
+ [space.params(s) for s in starts])
265
+
266
+ self._ctx = {
267
+ "space": space, "zones": eff_zones, "sizes": sizes,
268
+ "order": order, "n_samples": n_samples, "starts": starts,
269
+ "rng": rng,
270
+ }
271
+ return handler
272
+
273
+ # ------------------------------------------------------------ search
274
+ def _run_search(self, evaluate_candidates, **kwargs):
275
+ ctx = self._ctx
276
+ space, zones, sizes = ctx["space"], ctx["zones"], ctx["sizes"]
277
+ rng = ctx["rng"]
278
+
279
+ if isinstance(self.refit, str):
280
+ metric = self.refit
281
+ else:
282
+ metric = "score"
283
+ score_key = f"mean_test_{metric}"
284
+
285
+ splitters = {}
286
+ for frac, size in zip(zones, sizes):
287
+ if frac >= 1.0:
288
+ splitters[frac] = None # original CV
289
+ else:
290
+ splitters[frac] = ZoneSplitter(
291
+ self._checked_cv_orig, ctx["order"][:size])
292
+ size_of = dict(zip(zones, sizes))
293
+
294
+ def evaluate_batch(frac, points):
295
+ params_list = [space.params(p) for p in points]
296
+ results = evaluate_candidates(
297
+ params_list,
298
+ cv=splitters[frac],
299
+ more_results={"n_resources": [size_of[frac]] * len(points)},
300
+ )
301
+ scores = results[score_key][-len(points):]
302
+ return [float(s) for s in scores]
303
+
304
+ max_asks = 10 * self.n_iter
305
+ cache = {}
306
+ per_start_results = []
307
+ for start_i, start_point in enumerate(ctx["starts"]):
308
+ result = _run_one_start(
309
+ start_i, start_point, space, zones, self.promote_k,
310
+ self.n_iter, self.warmup, evaluate_batch, cache, rng, max_asks,
311
+ )
312
+ per_start_results.append(result)
313
+ logger.info("start %d converged: %s score=%.6g (%d cache hits so far)",
314
+ start_i, space.params(result["incumbent"]),
315
+ result["score"] if result["score"] == result["score"]
316
+ else float("nan"), result["cache_hits"])
317
+
318
+ history = []
319
+ for r in per_start_results:
320
+ history.extend(r["history"])
321
+
322
+ ctx["results"] = {
323
+ "local_optima": _build_local_optima(space, per_start_results),
324
+ "history": history,
325
+ "cache_hits": sum(r["cache_hits"] for r in per_start_results),
326
+ }
327
+
328
+ # ---------------------------------------------------- best selection
329
+ @staticmethod
330
+ def _select_best_index(refit, refit_metric, results):
331
+ """best_* come ONLY from full-data evaluations, across every start."""
332
+ if callable(refit):
333
+ return refit(results)
334
+ n_res = np.asarray(results["n_resources"])
335
+ mask = n_res == n_res.max()
336
+ scores = np.asarray(results[f"mean_test_{refit_metric}"], dtype=float)
337
+ masked = np.where(mask, scores, -np.inf)
338
+ masked = np.where(np.isnan(masked), -np.inf, masked)
339
+ return int(np.argmax(masked))
340
+
341
+ # ---------------------------------------------------- CV summary log
342
+ def _log_cv_summary(self, X, y):
343
+ """Extra ``cross_validate`` pass on the winning params, over the
344
+ full data and the user's own ``cv`` splitter. Verbose-gated only:
345
+ this adds ``n_splits`` extra fits and never runs unless requested
346
+ (``verbose >= 1``). Mirrors ``PatternSearchCV``'s implementation.
347
+ """
348
+ from sklearn.base import clone
349
+ from sklearn.model_selection import cross_validate
350
+
351
+ tags = get_tags(self.estimator)
352
+ est = clone(self.estimator).set_params(**self.best_params_)
353
+
354
+ if tags.estimator_type == "regressor":
355
+ scoring = ("r2", "explained_variance",
356
+ "neg_mean_absolute_error", "neg_mean_squared_error")
357
+ else:
358
+ s = self.scoring
359
+ if isinstance(s, dict):
360
+ scoring = tuple(s.keys())
361
+ elif isinstance(s, (list, tuple)):
362
+ scoring = tuple(s)
363
+ elif isinstance(s, str):
364
+ scoring = (s,)
365
+ else:
366
+ scoring = ("accuracy",)
367
+
368
+ logger.info("Cross Validation Performance (best params, full data):")
369
+ t0 = time.time()
370
+ scores = cross_validate(est, X, y, cv=self.cv, scoring=scoring)
371
+ elapsed = time.time() - t0
372
+ logger.info("Cross Validation Time: %.6f", elapsed)
373
+
374
+ if tags.estimator_type == "regressor":
375
+ ev = scores["test_explained_variance"]
376
+ mae = -scores["test_neg_mean_absolute_error"]
377
+ mse = -scores["test_neg_mean_squared_error"]
378
+ rmse = np.sqrt(mse)
379
+ r2 = scores["test_r2"]
380
+ logger.info("EV per fold: %s", ev)
381
+ logger.info("EV: %.6f", ev.mean())
382
+ logger.info("MAE per fold: %s", mae)
383
+ logger.info("MAE: %.6f", mae.mean())
384
+ logger.info("MSE per fold: %s", mse)
385
+ logger.info("MSE: %.6f", mse.mean())
386
+ logger.info("RMSE per fold: %s", rmse)
387
+ logger.info("RMSE: %.6f", rmse.mean())
388
+ logger.info("R2 per fold: %s", r2)
389
+ logger.info("Cross Validation R2: %.6f", r2.mean())
390
+ else:
391
+ for key in scoring:
392
+ vals = scores[f"test_{key}"]
393
+ logger.info("%s per fold: %s", key, vals)
394
+ logger.info("%s: %.6f", key, vals.mean())
395
+
396
+ logger.info("fit_time per fold: %s", scores["fit_time"])
397
+ logger.info("fit_time: %.6f", scores["fit_time"].mean())
398
+ logger.info("score_time per fold: %s", scores["score_time"])
399
+ logger.info("score_time: %.6f", scores["score_time"].mean())
400
+
401
+
402
+ # ---------------------------------------------------------------------- #
403
+ # module-level helpers (no `self`: kept out of the class so nothing here #
404
+ # is ever a candidate for accidentally landing on `self` during fit) #
405
+ # ---------------------------------------------------------------------- #
406
+
407
+ def _run_one_start(start_i, start_point, space, zones, promote_k, n_iter,
408
+ warmup, evaluate_batch, cache, rng, max_asks):
409
+ """One independent Bayesian search (BAYESHALVINGSearchCV_SPEC.md §4.3):
410
+ GP-EI proposals with bullseye multi-fidelity climbing, sharing `cache`
411
+ (dedup, across starts) and drawing all randomness from `rng` (determinism,
412
+ §7)."""
413
+ controller = BullseyeController(space.min_step, n_boundaries=len(zones) - 1,
414
+ warmup=warmup)
415
+ zone_i = 0
416
+ incumbent, incumbent_score = None, None
417
+ fits_used = 0
418
+ cache_hits = 0
419
+ history = []
420
+
421
+ def score_at(idx, frac, event, trial):
422
+ nonlocal fits_used, cache_hits
423
+ key = (idx, frac)
424
+ if key in cache:
425
+ cache_hits += 1
426
+ s = cache[key]
427
+ else:
428
+ s = evaluate_batch(frac, [idx])[0]
429
+ fits_used += 1
430
+ cache[key] = s
431
+ history.append({"start": start_i, "trial": trial, "params": space.params(idx),
432
+ "fraction": frac, "score": s, "event": event})
433
+ return s
434
+
435
+ proposer = GPProposer(space, rng.randint(np.iinfo(np.int32).max))
436
+ proposer.observe(start_point, None)
437
+
438
+ proposals = 0
439
+ while fits_used < n_iter and proposals < max_asks:
440
+ idx = proposer.suggest()
441
+ proposals += 1
442
+ frac = zones[zone_i]
443
+ key = (idx, frac)
444
+ if key in cache:
445
+ cache_hits += 1
446
+ proposer.observe(idx, cache[key])
447
+ continue
448
+ score = evaluate_batch(frac, [idx])[0]
449
+ fits_used += 1
450
+ cache[key] = score
451
+ proposer.observe(idx, score)
452
+ history.append({"start": start_i, "trial": proposals, "params": space.params(idx),
453
+ "fraction": frac, "score": score, "event": "trial"})
454
+
455
+ if incumbent is None or _better(score, incumbent_score):
456
+ move = 0.0 if incumbent is None else space.distance(incumbent, idx)
457
+ incumbent, incumbent_score = idx, score
458
+ new_zone = controller.observe_improvement(move)
459
+ if new_zone > zone_i:
460
+ zone_i = new_zone
461
+ top = proposer.top_k_observed(promote_k)
462
+ proposer = GPProposer(space, rng.randint(np.iinfo(np.int32).max))
463
+ rescored = []
464
+ for cfg_idx in top:
465
+ s = score_at(cfg_idx, zones[zone_i], "climb-rescore", proposals)
466
+ proposer.observe(cfg_idx, s)
467
+ rescored.append((cfg_idx, s))
468
+ incumbent, incumbent_score = max(
469
+ rescored, key=lambda t: float("-inf") if t[1] != t[1] else t[1])
470
+
471
+ if proposals >= max_asks and fits_used < n_iter:
472
+ logger.warning("start %d hit max_asks=%d proposal cap before exhausting "
473
+ "n_iter=%d fits (%d used); proceeding to final polish",
474
+ start_i, max_asks, n_iter, fits_used)
475
+
476
+ # ---- forced final polish for this start (always) --------------------
477
+ if zones[zone_i] < 1.0:
478
+ top = proposer.top_k_observed(promote_k)
479
+ polished = []
480
+ for cfg_idx in top:
481
+ s = score_at(cfg_idx, 1.0, "final-polish", proposals)
482
+ polished.append((cfg_idx, s))
483
+ incumbent, incumbent_score = max(
484
+ polished, key=lambda t: float("-inf") if t[1] != t[1] else t[1])
485
+
486
+ return {"start_point": start_point, "incumbent": incumbent,
487
+ "score": incumbent_score, "history": history, "cache_hits": cache_hits}
488
+
489
+
490
+ def _build_local_optima(space, per_start_results):
491
+ """Distinct converged optima across starts, best first — dedup by final
492
+ incumbent index tuple (BAYESHALVINGSearchCV_SPEC.md §3.2), same shape as
493
+ PatternSearchCV's Engine.local_optima()."""
494
+ groups = {}
495
+ for r in per_start_results:
496
+ groups.setdefault(r["incumbent"], []).append(r)
497
+ out = []
498
+ for point, rs in groups.items():
499
+ score = max((r["score"] for r in rs),
500
+ key=lambda s: float("-inf") if s != s else s)
501
+ out.append({
502
+ "params": space.params(point),
503
+ "score": score,
504
+ "n_starts_converged": len(rs),
505
+ "start_points": [space.params(r["start_point"]) for r in rs],
506
+ })
507
+ out.sort(key=lambda d: float("-inf") if d["score"] != d["score"]
508
+ else d["score"], reverse=True)
509
+ return out