ennbo 0.1.0__py3-none-any.whl → 0.1.2__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.
@@ -4,7 +4,13 @@ from dataclasses import dataclass
4
4
  from typing import TYPE_CHECKING, Any, Callable
5
5
 
6
6
  from .proposal import select_uniform
7
- from .turbo_config import TurboConfig
7
+ from .turbo_config import (
8
+ LHDOnlyConfig,
9
+ TurboConfig,
10
+ TurboENNConfig,
11
+ TurboOneConfig,
12
+ TurboZeroConfig,
13
+ )
8
14
  from .turbo_utils import from_unit, latin_hypercube, to_unit
9
15
 
10
16
 
@@ -32,17 +38,51 @@ class TurboOptimizer:
32
38
  config: TurboConfig | None = None,
33
39
  ) -> None:
34
40
  import numpy as np
35
- from scipy.stats import qmc
36
41
 
37
42
  from .turbo_mode import TurboMode
38
43
 
39
44
  if config is None:
40
- config = TurboConfig()
45
+ match mode:
46
+ case TurboMode.TURBO_ONE:
47
+ config = TurboOneConfig()
48
+ case TurboMode.TURBO_ZERO:
49
+ config = TurboZeroConfig()
50
+ case TurboMode.TURBO_ENN:
51
+ config = TurboENNConfig()
52
+ case TurboMode.LHD_ONLY:
53
+ config = LHDOnlyConfig()
54
+ case _:
55
+ raise ValueError(f"Unknown mode: {mode}")
56
+ else:
57
+ match mode:
58
+ case TurboMode.TURBO_ONE:
59
+ if not isinstance(config, TurboOneConfig):
60
+ raise ValueError(
61
+ f"mode={mode} requires TurboOneConfig, got {type(config).__name__}"
62
+ )
63
+ case TurboMode.TURBO_ZERO:
64
+ if not isinstance(config, TurboZeroConfig):
65
+ raise ValueError(
66
+ f"mode={mode} requires TurboZeroConfig, got {type(config).__name__}"
67
+ )
68
+ case TurboMode.TURBO_ENN:
69
+ if not isinstance(config, TurboENNConfig):
70
+ raise ValueError(
71
+ f"mode={mode} requires TurboENNConfig, got {type(config).__name__}"
72
+ )
73
+ case TurboMode.LHD_ONLY:
74
+ if not isinstance(config, LHDOnlyConfig):
75
+ raise ValueError(
76
+ f"mode={mode} requires LHDOnlyConfig, got {type(config).__name__}"
77
+ )
78
+ case _:
79
+ raise ValueError(f"Unknown mode: {mode}")
41
80
  self._config = config
42
81
 
82
+ bounds = np.asarray(bounds, dtype=float)
43
83
  if bounds.ndim != 2 or bounds.shape[1] != 2:
44
84
  raise ValueError(bounds.shape)
45
- self._bounds = np.asarray(bounds, dtype=float)
85
+ self._bounds = bounds
46
86
  self._num_dim = self._bounds.shape[0]
47
87
  self._mode = mode
48
88
  num_candidates = config.num_candidates
@@ -53,11 +93,12 @@ class TurboOptimizer:
53
93
  if self._num_candidates <= 0:
54
94
  raise ValueError(self._num_candidates)
55
95
  self._rng = rng
56
- sobol_seed = int(self._rng.integers(1_000_000))
57
- self._sobol_engine = qmc.Sobol(d=self._num_dim, scramble=True, seed=sobol_seed)
58
- self._x_obs_list: list = []
59
- self._y_obs_list: list = []
60
- self._yvar_obs_list: list = []
96
+ self._sobol_seed_base = int(self._rng.integers(2**31 - 1))
97
+ self._x_obs_list: list[list[float]] = []
98
+ self._y_obs_list: list[float] | list[list[float]] = []
99
+ self._y_tr_list: list[float] = []
100
+ self._yvar_obs_list: list[float] | list[list[float]] = []
101
+ self._expects_yvar: bool | None = None
61
102
  match mode:
62
103
  case TurboMode.TURBO_ONE:
63
104
  from .turbo_one_impl import TurboOneImpl
@@ -100,36 +141,37 @@ class TurboOptimizer:
100
141
  if num_init_val <= 0:
101
142
  raise ValueError(f"num_init must be > 0, got {num_init_val}")
102
143
  self._num_init = num_init_val
103
- if config.local_only:
104
- center = 0.5 * (self._bounds[:, 0] + self._bounds[:, 1])
105
- self._init_lhd = center.reshape(1, -1)
106
- self._num_init = 1
107
- else:
108
- self._init_lhd = from_unit(
109
- latin_hypercube(self._num_init, self._num_dim, rng=self._rng),
110
- self._bounds,
111
- )
144
+ self._init_lhd = from_unit(
145
+ latin_hypercube(self._num_init, self._num_dim, rng=self._rng),
146
+ self._bounds,
147
+ )
112
148
  self._init_idx = 0
113
149
  self._dt_fit: float = 0.0
114
150
  self._dt_sel: float = 0.0
115
- self._local_only = config.local_only
151
+
152
+ def _sobol_seed_for_state(self, *, n_obs: int, num_arms: int) -> int:
153
+ mask64 = (1 << 64) - 1
154
+
155
+ x = int(self._sobol_seed_base) & mask64
156
+ x ^= (int(n_obs) + 1) * 0x9E3779B97F4A7C15 & mask64
157
+ x ^= (int(num_arms) + 1) * 0xBF58476D1CE4E5B9 & mask64
158
+ x = (x + 0x9E3779B97F4A7C15) & mask64
159
+ z = x
160
+ z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 & mask64
161
+ z = (z ^ (z >> 27)) * 0x94D049BB133111EB & mask64
162
+ z = z ^ (z >> 31)
163
+ return int(z & 0xFFFFFFFF)
116
164
 
117
165
  @property
118
166
  def tr_obs_count(self) -> int:
119
167
  return len(self._y_obs_list)
120
168
 
121
- @property
122
- def best_tr_value(self) -> float | None:
123
- import numpy as np
124
-
125
- if len(self._y_obs_list) == 0:
126
- return None
127
- return float(np.max(self._y_obs_list))
128
-
129
169
  @property
130
170
  def tr_length(self) -> float | None:
131
171
  if self._tr_state is None:
132
172
  return None
173
+ if not hasattr(self._tr_state, "length"):
174
+ return None
133
175
  return float(self._tr_state.length)
134
176
 
135
177
  def telemetry(self) -> Telemetry:
@@ -139,14 +181,14 @@ class TurboOptimizer:
139
181
  num_arms = int(num_arms)
140
182
  if num_arms <= 0:
141
183
  raise ValueError(num_arms)
142
- if self._tr_state is None:
184
+ # For morbo, defer TR creation until tell() when we can infer num_metrics
185
+ is_morbo = self._config.tr_type == "morbo"
186
+ if self._tr_state is None and not is_morbo:
143
187
  self._tr_state = self._mode_impl.create_trust_region(
144
- self._num_dim, num_arms
188
+ self._num_dim, num_arms, self._rng
145
189
  )
146
- if self._local_only:
147
- self._tr_state.length_max = 0.1
148
- self._tr_state.length = min(self._tr_state.length, 0.1)
149
- self._tr_state.length_init = min(self._tr_state.length_init, 0.1)
190
+ if self._tr_state is not None:
191
+ self._tr_state.validate_request(num_arms)
150
192
  early_result = self._mode_impl.try_early_ask(
151
193
  num_arms,
152
194
  self._x_obs_list,
@@ -176,6 +218,11 @@ class TurboOptimizer:
176
218
 
177
219
  def _ask_normal(self, num_arms: int, *, is_fallback: bool = False) -> np.ndarray:
178
220
  import numpy as np
221
+ from scipy.stats import qmc
222
+
223
+ # For morbo, TR is created in tell() - if still None, return LHD
224
+ if self._tr_state is None:
225
+ return self._draw_initial(num_arms)
179
226
 
180
227
  if self._tr_state.needs_restart():
181
228
  self._tr_state.restart()
@@ -187,6 +234,7 @@ class TurboOptimizer:
187
234
  self._num_init,
188
235
  )
189
236
  if should_reset_init:
237
+ self._y_tr_list = []
190
238
  self._init_idx = new_init_idx
191
239
  self._init_lhd = from_unit(
192
240
  latin_hypercube(self._num_init, self._num_dim, rng=self._rng),
@@ -203,7 +251,7 @@ class TurboOptimizer:
203
251
  import time
204
252
 
205
253
  t0_fit = time.perf_counter()
206
- _gp_model, _gp_y_mean_fitted, _gp_y_std_fitted, weights = (
254
+ _gp_model, _gp_y_mean_fitted, _gp_y_std_fitted, lengthscales = (
207
255
  self._mode_impl.prepare_ask(
208
256
  self._x_obs_list,
209
257
  self._y_obs_list,
@@ -216,19 +264,27 @@ class TurboOptimizer:
216
264
  self._dt_fit = time.perf_counter() - t0_fit
217
265
 
218
266
  x_center = self._mode_impl.get_x_center(
219
- self._x_obs_list, self._y_obs_list, self._rng
267
+ self._x_obs_list,
268
+ self._y_obs_list,
269
+ self._rng,
270
+ self._tr_state,
220
271
  )
221
272
  if x_center is None:
222
273
  if len(self._y_obs_list) == 0:
223
274
  raise RuntimeError("no observations")
224
275
  x_center = np.full(self._num_dim, 0.5)
225
276
 
277
+ sobol_seed = self._sobol_seed_for_state(
278
+ n_obs=len(self._x_obs_list),
279
+ num_arms=num_arms,
280
+ )
281
+ sobol_engine = qmc.Sobol(d=self._num_dim, scramble=True, seed=sobol_seed)
226
282
  x_cand = self._tr_state.generate_candidates(
227
283
  x_center,
228
- weights,
284
+ lengthscales,
229
285
  self._num_candidates,
230
286
  self._rng,
231
- self._sobol_engine,
287
+ sobol_engine,
232
288
  )
233
289
 
234
290
  def fallback_fn(x, n):
@@ -244,12 +300,19 @@ class TurboOptimizer:
244
300
  self._rng,
245
301
  fallback_fn,
246
302
  from_unit_fn,
303
+ tr_state=self._tr_state,
247
304
  )
248
305
  self._dt_sel = time.perf_counter() - t0_sel
249
306
 
250
- self._mode_impl.update_trust_region(
251
- self._tr_state, self._y_obs_list, x_center=x_center, k=self._k
252
- )
307
+ # For morbo, TR is updated in tell() with raw multi-objective y
308
+ if self._config.tr_type != "morbo":
309
+ self._mode_impl.update_trust_region(
310
+ self._tr_state,
311
+ self._x_obs_list,
312
+ self._y_tr_list,
313
+ x_center=x_center,
314
+ k=self._k,
315
+ )
253
316
  return selected
254
317
 
255
318
  def _trim_trailing_obs(self) -> None:
@@ -259,8 +322,8 @@ class TurboOptimizer:
259
322
 
260
323
  if len(self._x_obs_list) <= self._trailing_obs:
261
324
  return
262
- y_array = np.asarray(self._y_obs_list, dtype=float)
263
- incumbent_idx = argmax_random_tie(y_array, rng=self._rng)
325
+ y_tr_array = np.asarray(self._y_tr_list, dtype=float)
326
+ incumbent_idx = argmax_random_tie(y_tr_array, rng=self._rng)
264
327
  num_total = len(self._x_obs_list)
265
328
  start_idx = max(0, num_total - self._trailing_obs)
266
329
  if incumbent_idx < start_idx:
@@ -274,21 +337,23 @@ class TurboOptimizer:
274
337
  if incumbent_idx not in indices:
275
338
  raise RuntimeError("Incumbent must be included in trimmed list")
276
339
  x_array = np.asarray(self._x_obs_list, dtype=float)
277
- incumbent_value = y_array[incumbent_idx]
340
+ incumbent_value = y_tr_array[incumbent_idx]
278
341
  self._x_obs_list = x_array[indices].tolist()
279
- self._y_obs_list = y_array[indices].tolist()
280
- if len(self._yvar_obs_list) == len(y_array):
342
+ y_obs_array = np.asarray(self._y_obs_list, dtype=float)
343
+ self._y_obs_list = y_obs_array[indices].tolist()
344
+ self._y_tr_list = y_tr_array[indices].tolist()
345
+ if len(self._yvar_obs_list) == len(y_obs_array):
281
346
  yvar_array = np.asarray(self._yvar_obs_list, dtype=float)
282
347
  self._yvar_obs_list = yvar_array[indices].tolist()
283
- y_trimmed = np.asarray(self._y_obs_list, dtype=float)
348
+ y_trimmed = np.asarray(self._y_tr_list, dtype=float)
284
349
  if not np.any(np.abs(y_trimmed - incumbent_value) < 1e-10):
285
350
  raise RuntimeError("Incumbent value must be preserved in trimmed list")
286
351
 
287
352
  def tell(
288
353
  self,
289
- x: np.ndarray | Any,
290
- y: np.ndarray | Any,
291
- y_var: np.ndarray | Any | None = None,
354
+ x: np.ndarray,
355
+ y: np.ndarray,
356
+ y_var: np.ndarray | None = None,
292
357
  ) -> np.ndarray:
293
358
  import numpy as np
294
359
 
@@ -296,8 +361,37 @@ class TurboOptimizer:
296
361
  y = np.asarray(y, dtype=float)
297
362
  if x.ndim != 2 or x.shape[1] != self._num_dim:
298
363
  raise ValueError(x.shape)
299
- if y.ndim != 1 or y.shape[0] != x.shape[0]:
300
- raise ValueError((x.shape, y.shape))
364
+
365
+ # morbo accepts 2D y with shape (n, num_metrics)
366
+ is_morbo = self._config.tr_type == "morbo"
367
+ if is_morbo:
368
+ if y.ndim == 1:
369
+ y = y.reshape(-1, 1)
370
+ if y.ndim != 2 or y.shape[0] != x.shape[0]:
371
+ raise ValueError((x.shape, y.shape))
372
+ num_metrics = y.shape[1]
373
+ # Create TR lazily for morbo, inferring num_metrics from y
374
+ if self._tr_state is None:
375
+ self._tr_state = self._mode_impl.create_trust_region(
376
+ self._num_dim, x.shape[0], self._rng, num_metrics=num_metrics
377
+ )
378
+ cfg_num_metrics = self._config.num_metrics
379
+ if cfg_num_metrics is not None and num_metrics != cfg_num_metrics:
380
+ raise ValueError(
381
+ f"y has {num_metrics} metrics but expected {cfg_num_metrics}"
382
+ )
383
+ else:
384
+ if self._tr_state is None:
385
+ raise ValueError("tell() called before ask()")
386
+ if y.ndim != 1 or y.shape[0] != x.shape[0]:
387
+ raise ValueError((x.shape, y.shape))
388
+
389
+ if self._expects_yvar is None:
390
+ self._expects_yvar = y_var is not None
391
+ if (y_var is not None) != bool(self._expects_yvar):
392
+ raise ValueError(
393
+ f"y_var must be {'provided' if self._expects_yvar else 'omitted'} on every tell() call"
394
+ )
301
395
  if y_var is not None:
302
396
  y_var = np.asarray(y_var, dtype=float)
303
397
  if y_var.shape != y.shape:
@@ -305,14 +399,108 @@ class TurboOptimizer:
305
399
  if x.shape[0] == 0:
306
400
  return np.array([], dtype=float)
307
401
  x_unit = to_unit(x, self._bounds)
308
- y_estimate = self._mode_impl.estimate_y(x_unit, y)
309
402
  self._x_obs_list.extend(x_unit.tolist())
310
- self._y_obs_list.extend(y.tolist())
311
- if y_var is not None:
312
- self._yvar_obs_list.extend(y_var.tolist())
313
- if self._trailing_obs is not None:
314
- self._trim_trailing_obs()
315
- self._mode_impl.update_trust_region(self._tr_state, self._y_obs_list)
403
+
404
+ if is_morbo:
405
+ y_estimate = y
406
+ self._y_obs_list.extend(y.tolist())
407
+ if y_var is not None:
408
+ self._yvar_obs_list.extend(y_var.tolist())
409
+ y_all = np.asarray(self._y_obs_list, dtype=float)
410
+ if y_all.ndim == 1:
411
+ y_all = y_all.reshape(-1, num_metrics)
412
+ x_all = np.asarray(self._x_obs_list, dtype=float)
413
+ self._tr_state.update_xy(x_all, y_all, k=self._k)
414
+ else:
415
+ from .turbo_mode import TurboMode
416
+
417
+ self._y_obs_list.extend(y.tolist())
418
+ if y_var is not None:
419
+ self._yvar_obs_list.extend(y_var.tolist())
420
+
421
+ if self._mode in (TurboMode.TURBO_ONE, TurboMode.TURBO_ENN):
422
+ self._mode_impl.prepare_ask(
423
+ self._x_obs_list,
424
+ self._y_obs_list,
425
+ self._yvar_obs_list,
426
+ self._num_dim,
427
+ 0,
428
+ rng=self._rng,
429
+ )
430
+ x_all = np.asarray(self._x_obs_list, dtype=float)
431
+ y_all = np.asarray(self._y_obs_list, dtype=float)
432
+ if self._mode == TurboMode.TURBO_ONE:
433
+ # We intentionally evaluate the GP posterior at the training inputs
434
+ # (the observed points) right after conditioning the model. GPyTorch
435
+ # warns about this in debug mode, but it's expected for our TR logic.
436
+ import warnings
437
+
438
+ try:
439
+ from gpytorch.utils.warnings import GPInputWarning
440
+ except Exception: # pragma: no cover
441
+ GPInputWarning = None
442
+
443
+ if GPInputWarning is None:
444
+ mu_all = np.asarray(
445
+ self._mode_impl.estimate_y(x_all, y_all), dtype=float
446
+ ).reshape(-1)
447
+ else:
448
+ with warnings.catch_warnings():
449
+ warnings.filterwarnings(
450
+ "ignore",
451
+ message=r"The input matches the stored training data\..*",
452
+ category=GPInputWarning,
453
+ )
454
+ mu_all = np.asarray(
455
+ self._mode_impl.estimate_y(x_all, y_all), dtype=float
456
+ ).reshape(-1)
457
+ else:
458
+ mu_all = np.asarray(
459
+ self._mode_impl.estimate_y(x_all, y_all), dtype=float
460
+ ).reshape(-1)
461
+ self._y_tr_list = mu_all.tolist()
462
+ if self._mode == TurboMode.TURBO_ONE:
463
+ import warnings
464
+
465
+ try:
466
+ from gpytorch.utils.warnings import GPInputWarning
467
+ except Exception: # pragma: no cover
468
+ GPInputWarning = None
469
+
470
+ if GPInputWarning is None:
471
+ y_estimate = np.asarray(
472
+ self._mode_impl.estimate_y(x_unit, y), dtype=float
473
+ )
474
+ else:
475
+ with warnings.catch_warnings():
476
+ warnings.filterwarnings(
477
+ "ignore",
478
+ message=r"The input matches the stored training data\..*",
479
+ category=GPInputWarning,
480
+ )
481
+ y_estimate = np.asarray(
482
+ self._mode_impl.estimate_y(x_unit, y), dtype=float
483
+ )
484
+ else:
485
+ y_estimate = np.asarray(
486
+ self._mode_impl.estimate_y(x_unit, y), dtype=float
487
+ )
488
+ else:
489
+ y_estimate = self._mode_impl.estimate_y(x_unit, y)
490
+ self._y_tr_list.extend(np.asarray(y_estimate, dtype=float).tolist())
491
+
492
+ if self._trailing_obs is not None:
493
+ self._trim_trailing_obs()
494
+ prev_n = int(getattr(self._tr_state, "prev_num_obs", 0))
495
+ if prev_n > 0 and prev_n <= len(self._y_tr_list):
496
+ if hasattr(self._tr_state, "best_value"):
497
+ self._tr_state.best_value = float(
498
+ np.max(np.asarray(self._y_tr_list, dtype=float)[:prev_n])
499
+ )
500
+ self._mode_impl.update_trust_region(
501
+ self._tr_state, self._x_obs_list, self._y_tr_list, k=self._k
502
+ )
503
+
316
504
  return y_estimate
317
505
 
318
506
  def _draw_initial(self, num_arms: int) -> np.ndarray:
@@ -89,14 +89,14 @@ class TurboTrustRegion:
89
89
  )
90
90
 
91
91
  def compute_bounds_1d(
92
- self, x_center: np.ndarray | Any, weights: np.ndarray | None = None
92
+ self, x_center: np.ndarray | Any, lengthscales: np.ndarray | None = None
93
93
  ) -> tuple[np.ndarray, np.ndarray]:
94
94
  import numpy as np
95
95
 
96
- if weights is None:
96
+ if lengthscales is None:
97
97
  half_length = 0.5 * self.length
98
98
  else:
99
- half_length = weights * self.length / 2.0
99
+ half_length = lengthscales * self.length / 2.0
100
100
  lb = np.clip(x_center - half_length, 0.0, 1.0)
101
101
  ub = np.clip(x_center + half_length, 0.0, 1.0)
102
102
  return lb, ub
@@ -104,20 +104,18 @@ class TurboTrustRegion:
104
104
  def generate_candidates(
105
105
  self,
106
106
  x_center: np.ndarray,
107
- weights: np.ndarray | None,
107
+ lengthscales: np.ndarray | None,
108
108
  num_candidates: int,
109
109
  rng: Generator,
110
110
  sobol_engine: QMCEngine,
111
111
  ) -> np.ndarray:
112
- from .turbo_utils import raasp
112
+ from .turbo_utils import generate_trust_region_candidates
113
113
 
114
- lb, ub = self.compute_bounds_1d(x_center, weights)
115
- return raasp(
114
+ return generate_trust_region_candidates(
116
115
  x_center,
117
- lb,
118
- ub,
116
+ lengthscales,
119
117
  num_candidates,
120
- num_pert=20,
118
+ compute_bounds_1d=self.compute_bounds_1d,
121
119
  rng=rng,
122
120
  sobol_engine=sobol_engine,
123
121
  )