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.
@@ -7,33 +7,85 @@ if TYPE_CHECKING:
7
7
  from numpy.random import Generator
8
8
 
9
9
  from .base_turbo_impl import BaseTurboImpl
10
- from .turbo_config import TurboConfig
10
+ from .turbo_config import TurboOneConfig
11
11
  from .turbo_utils import gp_thompson_sample
12
12
 
13
13
 
14
14
  class TurboOneImpl(BaseTurboImpl):
15
- def __init__(self, config: TurboConfig) -> None:
15
+ def __init__(self, config: TurboOneConfig) -> None:
16
16
  super().__init__(config)
17
17
  self._gp_model: Any | None = None
18
- self._gp_y_mean: float = 0.0
19
- self._gp_y_std: float = 1.0
18
+ self._gp_y_mean: float | Any = 0.0
19
+ self._gp_y_std: float | Any = 1.0
20
20
  self._fitted_n_obs: int = 0
21
21
 
22
+ def _as_2d(self, a: np.ndarray) -> np.ndarray:
23
+ import numpy as np
24
+
25
+ a = np.asarray(a, dtype=float)
26
+ if a.ndim == 1:
27
+ return a.reshape(-1, 1)
28
+ if a.ndim == 2:
29
+ return a.T
30
+ raise ValueError(a.shape)
31
+
32
+ def _broadcast_gp_mean_std(self, num_metrics: int) -> tuple[np.ndarray, np.ndarray]:
33
+ import numpy as np
34
+
35
+ num_metrics = int(num_metrics)
36
+ if num_metrics <= 0:
37
+ raise ValueError(num_metrics)
38
+ gp_y_mean = np.asarray(self._gp_y_mean, dtype=float).reshape(-1)
39
+ gp_y_std = np.asarray(self._gp_y_std, dtype=float).reshape(-1)
40
+ if gp_y_mean.size == 1 and num_metrics != 1:
41
+ gp_y_mean = np.full(num_metrics, float(gp_y_mean[0]), dtype=float)
42
+ if gp_y_std.size == 1 and num_metrics != 1:
43
+ gp_y_std = np.full(num_metrics, float(gp_y_std[0]), dtype=float)
44
+ if gp_y_mean.shape != (num_metrics,) or gp_y_std.shape != (num_metrics,):
45
+ raise ValueError((gp_y_mean.shape, gp_y_std.shape, num_metrics))
46
+ return gp_y_mean, gp_y_std
47
+
48
+ def _unstandardize(self, y_std_2d: np.ndarray) -> np.ndarray:
49
+ import numpy as np
50
+
51
+ y_std_2d = np.asarray(y_std_2d, dtype=float)
52
+ if y_std_2d.ndim != 2:
53
+ raise ValueError(y_std_2d.shape)
54
+ num_metrics = int(y_std_2d.shape[1])
55
+ gp_y_mean, gp_y_std = self._broadcast_gp_mean_std(num_metrics)
56
+ return gp_y_mean.reshape(1, -1) + gp_y_std.reshape(1, -1) * y_std_2d
57
+
22
58
  def get_x_center(
23
59
  self,
24
60
  x_obs_list: list,
25
61
  y_obs_list: list,
26
62
  rng: Generator,
63
+ tr_state: Any = None,
27
64
  ) -> np.ndarray | None:
28
65
  import numpy as np
29
66
  import torch
67
+ import warnings
30
68
 
31
69
  from .turbo_utils import argmax_random_tie
32
70
 
33
71
  if len(y_obs_list) == 0:
34
72
  return None
35
73
  if self._gp_model is None:
36
- return super().get_x_center(x_obs_list, y_obs_list, rng)
74
+ if len(y_obs_list) <= 1:
75
+ x_array = np.asarray(x_obs_list, dtype=float)
76
+ y_array = np.asarray(y_obs_list, dtype=float)
77
+ if y_array.ndim == 2:
78
+ if self._config.tr_type == "morbo" and tr_state is not None:
79
+ scores = tr_state.scalarize(y_array, clip=True)
80
+ else:
81
+ scores = y_array[:, 0]
82
+ best_idx = argmax_random_tie(scores, rng=rng)
83
+ return x_array[best_idx]
84
+ return super().get_x_center(x_obs_list, y_obs_list, rng, tr_state)
85
+ raise RuntimeError(
86
+ "TurboOneImpl.get_x_center requires a fitted GP model for 2+ observations; "
87
+ "call prepare_ask() first."
88
+ )
37
89
  if self._fitted_n_obs != len(x_obs_list):
38
90
  raise RuntimeError(
39
91
  f"GP fitted on {self._fitted_n_obs} obs but get_x_center called with {len(x_obs_list)}"
@@ -41,11 +93,38 @@ class TurboOneImpl(BaseTurboImpl):
41
93
 
42
94
  x_array = np.asarray(x_obs_list, dtype=float)
43
95
  x_torch = torch.as_tensor(x_array, dtype=torch.float64)
96
+ try:
97
+ from gpytorch.utils.warnings import GPInputWarning
98
+ except Exception: # pragma: no cover
99
+ GPInputWarning = None
100
+
44
101
  with torch.no_grad():
45
- posterior = self._gp_model.posterior(x_torch)
46
- mu = posterior.mean.cpu().numpy().ravel()
102
+ if GPInputWarning is None:
103
+ posterior = self._gp_model.posterior(x_torch)
104
+ else:
105
+ # We intentionally evaluate the GP posterior at the training inputs
106
+ # (observed points) when choosing the center. GPyTorch warns about this
107
+ # in debug mode, but it's expected for our usage.
108
+ with warnings.catch_warnings():
109
+ warnings.filterwarnings(
110
+ "ignore",
111
+ message=r"The input matches the stored training data\..*",
112
+ category=GPInputWarning,
113
+ )
114
+ posterior = self._gp_model.posterior(x_torch)
115
+ mu_std = posterior.mean.cpu().numpy()
116
+
117
+ mu = self._unstandardize(self._as_2d(mu_std))
118
+
119
+ # For morbo: scalarize mu values
120
+ if self._config.tr_type == "morbo" and tr_state is not None:
121
+ scalarized = tr_state.scalarize(mu, clip=False)
122
+ best_idx = argmax_random_tie(scalarized, rng=rng)
123
+ else:
124
+ if mu.shape[1] != 1:
125
+ raise ValueError(mu.shape)
126
+ best_idx = argmax_random_tie(mu[:, 0], rng=rng)
47
127
 
48
- best_idx = argmax_random_tie(mu, rng=rng)
49
128
  return x_array[best_idx]
50
129
 
51
130
  def needs_tr_list(self) -> bool:
@@ -56,7 +135,7 @@ class TurboOneImpl(BaseTurboImpl):
56
135
  num_arms: int,
57
136
  x_obs_list: list,
58
137
  draw_initial_fn: Callable[[int], np.ndarray],
59
- get_init_lhd_points_fn: Callable[[int], np.ndarray | None],
138
+ get_init_lhd_points_fn: Callable[[int], np.ndarray],
60
139
  ) -> np.ndarray | None:
61
140
  if len(x_obs_list) == 0:
62
141
  return get_init_lhd_points_fn(num_arms)
@@ -102,18 +181,22 @@ class TurboOneImpl(BaseTurboImpl):
102
181
  self._gp_y_mean = gp_y_mean_fitted
103
182
  if gp_y_std_fitted is not None:
104
183
  self._gp_y_std = gp_y_std_fitted
105
- weights = None
184
+ lengthscales = None
106
185
  if self._gp_model is not None:
107
- weights = (
186
+ lengthscale = (
108
187
  self._gp_model.covar_module.base_kernel.lengthscale.cpu()
109
188
  .detach()
110
189
  .numpy()
111
- .ravel()
112
190
  )
191
+ if lengthscale.ndim == 3:
192
+ lengthscale = lengthscale.mean(axis=0)
193
+ lengthscales = lengthscale.ravel()
113
194
  # First line helps stabilize second line.
114
- weights = weights / weights.mean()
115
- weights = weights / np.prod(np.power(weights, 1.0 / len(weights)))
116
- return self._gp_model, gp_y_mean_fitted, gp_y_std_fitted, weights
195
+ lengthscales = lengthscales / lengthscales.mean()
196
+ lengthscales = lengthscales / np.prod(
197
+ np.power(lengthscales, 1.0 / len(lengthscales))
198
+ )
199
+ return self._gp_model, gp_y_mean_fitted, gp_y_std_fitted, lengthscales
117
200
 
118
201
  def select_candidates(
119
202
  self,
@@ -123,17 +206,63 @@ class TurboOneImpl(BaseTurboImpl):
123
206
  rng: Generator,
124
207
  fallback_fn: Callable[[np.ndarray, int], np.ndarray],
125
208
  from_unit_fn: Callable[[np.ndarray], np.ndarray],
209
+ tr_state: Any = None,
126
210
  ) -> np.ndarray:
211
+ import numpy as np
212
+
127
213
  if self._gp_model is None:
214
+ if self._fitted_n_obs >= 2:
215
+ raise RuntimeError(
216
+ "TurboOneImpl.select_candidates requires a fitted GP model for 2+ observations; "
217
+ "call prepare_ask() first."
218
+ )
128
219
  return fallback_fn(x_cand, num_arms)
129
220
 
221
+ if self._config.tr_type == "morbo" and tr_state is not None:
222
+ import gpytorch
223
+ import torch
224
+
225
+ from .turbo_utils import torch_seed_context
226
+
227
+ x_torch = torch.as_tensor(x_cand, dtype=torch.float64)
228
+ seed = int(rng.integers(2**31 - 1))
229
+ with (
230
+ torch.no_grad(),
231
+ gpytorch.settings.fast_pred_var(),
232
+ torch_seed_context(seed, device=x_torch.device),
233
+ ):
234
+ posterior = self._gp_model.posterior(x_torch)
235
+ samples = posterior.sample(sample_shape=torch.Size([1]))
236
+
237
+ if samples.ndim == 2:
238
+ samples_std = samples[0].detach().cpu().numpy().reshape(-1, 1)
239
+ elif samples.ndim == 3:
240
+ samples_std = samples[0].detach().cpu().numpy().T
241
+ else:
242
+ raise ValueError(samples.shape)
243
+
244
+ y_samples = self._unstandardize(samples_std)
245
+ scores = tr_state.scalarize(y_samples, clip=False)
246
+ shuffled_indices = rng.permutation(len(scores))
247
+ shuffled_scores = scores[shuffled_indices]
248
+ top_k_in_shuffled = np.argpartition(-shuffled_scores, num_arms - 1)[
249
+ :num_arms
250
+ ]
251
+ idx = shuffled_indices[top_k_in_shuffled]
252
+ return from_unit_fn(x_cand[idx])
253
+
254
+ if (
255
+ np.asarray(self._gp_y_mean).ndim != 0
256
+ or np.asarray(self._gp_y_std).ndim != 0
257
+ ):
258
+ raise ValueError("multi-output GP requires tr_type='morbo'")
130
259
  idx = gp_thompson_sample(
131
260
  self._gp_model,
132
261
  x_cand,
133
262
  num_arms,
134
263
  rng,
135
- self._gp_y_mean,
136
- self._gp_y_std,
264
+ float(self._gp_y_mean),
265
+ float(self._gp_y_std),
137
266
  )
138
267
  return from_unit_fn(x_cand[idx])
139
268
 
@@ -141,12 +270,18 @@ class TurboOneImpl(BaseTurboImpl):
141
270
  import torch
142
271
 
143
272
  if self._gp_model is None:
144
- return y_observed
273
+ raise RuntimeError(
274
+ "TurboOneImpl.estimate_y requires a fitted GP model; call prepare_ask() first."
275
+ )
145
276
  x_torch = torch.as_tensor(x_unit, dtype=torch.float64)
146
277
  with torch.no_grad():
147
278
  posterior = self._gp_model.posterior(x_torch)
148
- mu = posterior.mean.cpu().numpy().ravel()
149
- return self._gp_y_mean + self._gp_y_std * mu
279
+ mu_std = posterior.mean.cpu().numpy()
280
+
281
+ mu = self._unstandardize(self._as_2d(mu_std))
282
+ if mu.shape[1] == 1:
283
+ return mu[:, 0]
284
+ return mu
150
285
 
151
286
  def get_mu_sigma(self, x_unit: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
152
287
  import torch
@@ -156,8 +291,12 @@ class TurboOneImpl(BaseTurboImpl):
156
291
  x_torch = torch.as_tensor(x_unit, dtype=torch.float64)
157
292
  with torch.no_grad():
158
293
  posterior = self._gp_model.posterior(x_torch)
159
- mu_std = posterior.mean.cpu().numpy().ravel()
160
- sigma_std = posterior.variance.cpu().numpy().ravel() ** 0.5
161
- mu = self._gp_y_mean + self._gp_y_std * mu_std
162
- sigma = self._gp_y_std * sigma_std
294
+ mu_std = posterior.mean.cpu().numpy()
295
+ sigma_std = posterior.variance.cpu().numpy() ** 0.5
296
+
297
+ mu_std_2d = self._as_2d(mu_std)
298
+ sigma_std_2d = self._as_2d(sigma_std)
299
+ mu = self._unstandardize(mu_std_2d)
300
+ _gp_y_mean, gp_y_std = self._broadcast_gp_mean_std(int(mu_std_2d.shape[1]))
301
+ sigma = gp_y_std.reshape(1, -1) * sigma_std_2d
163
302
  return mu, sigma