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.
- bayes_halving_search_cv/__init__.py +15 -0
- bayes_halving_search_cv/_bayes.py +509 -0
- bayes_halving_search_cv/_climber.py +307 -0
- bayes_halving_search_cv/_engine.py +113 -0
- bayes_halving_search_cv/_fidelity.py +81 -0
- bayes_halving_search_cv/_gp.py +185 -0
- bayes_halving_search_cv/_sampling.py +155 -0
- bayes_halving_search_cv/_search.py +465 -0
- bayes_halving_search_cv/_space.py +157 -0
- bayes_halving_search_cv/_starts.py +54 -0
- bayeshalvingsearchcv-0.1.0.dist-info/METADATA +141 -0
- bayeshalvingsearchcv-0.1.0.dist-info/RECORD +15 -0
- bayeshalvingsearchcv-0.1.0.dist-info/WHEEL +5 -0
- bayeshalvingsearchcv-0.1.0.dist-info/licenses/LICENSE +29 -0
- bayeshalvingsearchcv-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|