Skip to content

Commit e6961dc

Browse files
blethammeta-codesync[bot]
authored andcommitted
add support for general box bounds to RandomGenerators. (facebook#4932)
Summary: Pull Request resolved: facebook#4932 Currently RandomGenerators require that the space be mapped to [0, 1]^d, for the convenience of random number generators operating on that space by default. In a stacked diff I would like to super() gen on a random generator but need the returned points to be in raw space, no UnitX transformation. It is a relatively small change for RandomGenerators to be capable of operating with arbitrary bounds, which change this diff makes. Reviewed By: saitcakmak, eonofrey Differential Revision: D94118103 fbshipit-source-id: 7d604fbc89eb01ba2e503f6194cf5474b2f4c21b
1 parent ece5ab8 commit e6961dc

File tree

7 files changed

+63
-62
lines changed

7 files changed

+63
-62
lines changed

ax/generators/random/base.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
DEFAULT_MAX_RS_DRAWS,
2424
rejection_sample,
2525
tunable_feature_indices,
26-
validate_bounds,
2726
)
2827
from ax.utils.common.docutils import copy_doc
2928
from ax.utils.common.logger import get_logger
@@ -85,6 +84,7 @@ def __init__(
8584
# Used for deduplication.
8685
self.fallback_to_sample_polytope = fallback_to_sample_polytope
8786
self.polytope_sampler_kwargs: dict[str, Any] = polytope_sampler_kwargs or {}
87+
self._bounds: npt.NDArray = np.empty((0, 2))
8888
self.attempted_draws: int = 0
8989
if generated_points is not None:
9090
# generated_points was deprecated in Ax 1.0.0, so it can now be reaped.
@@ -146,15 +146,8 @@ def gen(
146146
tf_indices = tunable_feature_indices(
147147
bounds=search_space_digest.bounds, fixed_features=fixed_features
148148
)
149-
if fixed_features:
150-
fixed_feature_indices = np.array(list(fixed_features.keys()))
151-
else:
152-
fixed_feature_indices = np.array([])
149+
self._bounds = np.array(search_space_digest.bounds) # (d, 2)
153150

154-
validate_bounds(
155-
bounds=search_space_digest.bounds,
156-
fixed_feature_indices=fixed_feature_indices,
157-
)
158151
max_draws = DEFAULT_MAX_RS_DRAWS
159152
discrete_indices = set(search_space_digest.discrete_choices.keys())
160153
continuous_indices = {
@@ -282,22 +275,29 @@ def _gen_unconstrained(
282275
An (n x d) array of generated points.
283276
284277
"""
285-
tunable_points = self._gen_samples(n=n, tunable_d=len(tunable_feature_indices))
278+
tunable_bounds = self._bounds[tunable_feature_indices]
279+
tunable_points = self._gen_samples(
280+
n=n,
281+
tunable_d=len(tunable_feature_indices),
282+
bounds=tunable_bounds,
283+
)
286284
return add_fixed_features(
287285
tunable_points=tunable_points,
288286
d=d,
289287
tunable_feature_indices=tunable_feature_indices,
290288
fixed_features=fixed_features,
291289
)
292290

293-
def _gen_samples(self, n: int, tunable_d: int) -> npt.NDArray:
294-
"""Generate n samples on [0, 1]^d.
291+
def _gen_samples(self, n: int, tunable_d: int, bounds: npt.NDArray) -> npt.NDArray:
292+
"""Generate n samples within the given bounds.
295293
296294
Args:
297295
n: Number of points to generate.
296+
tunable_d: Number of tunable dimensions.
297+
bounds: A (tunable_d x 2) array of (lower, upper) bounds.
298298
299299
Returns:
300-
(n x d) array of generated points.
300+
(n x tunable_d) array of generated points.
301301
302302
"""
303303
raise NotImplementedError("Base RandomGenerator can't generate samples.")
@@ -359,7 +359,6 @@ def _convert_bounds(self, bounds: list[tuple[float, float]]) -> Tensor | None:
359359
360360
Args:
361361
bounds: A list of (lower, upper) tuples for each column of X.
362-
Defined on [0, 1]^d.
363362
364363
Returns:
365364
Optional 2 x d tensor representing the bounds

ax/generators/random/sobol.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,15 @@ def gen(
126126
self.init_position = none_throws(self.engine).num_generated
127127
return points, weights
128128

129-
def _gen_samples(self, n: int, tunable_d: int) -> npt.NDArray:
129+
def _gen_samples(self, n: int, tunable_d: int, bounds: npt.NDArray) -> npt.NDArray:
130130
"""Generate n samples.
131131
132132
Args:
133133
n: Number of samples to generate.
134134
tunable_d: The dimension of the generated samples. This must
135135
match the tunable parameters used while initializing the
136136
Sobol engine.
137+
bounds: A (tunable_d x 2) array of (lower, upper) bounds.
137138
138139
Returns:
139140
A numpy array of samples of shape `(n x tunable_d)`.
@@ -144,4 +145,8 @@ def _gen_samples(self, n: int, tunable_d: int) -> npt.NDArray:
144145
raise ValueError(
145146
"Sobol Engine must be initialized before candidate generation."
146147
)
147-
return none_throws(self.engine).draw(n, dtype=torch.double).numpy()
148+
unit_samples = none_throws(self.engine).draw(n, dtype=torch.double).numpy()
149+
# Rescale from [0, 1]^d to [lower, upper]^d
150+
lower = bounds[:, 0]
151+
upper = bounds[:, 1]
152+
return unit_samples * (upper - lower) + lower

ax/generators/random/uniform.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,21 @@ def __init__(
4141
# Fast-forward the random state by generating & discarding samples.
4242
self._rs.uniform(size=(self.init_position))
4343

44-
def _gen_samples(self, n: int, tunable_d: int) -> npt.NDArray:
44+
def _gen_samples(self, n: int, tunable_d: int, bounds: npt.NDArray) -> npt.NDArray:
4545
"""Generate samples from the scipy uniform distribution.
4646
4747
Args:
4848
n: Number of samples to generate.
4949
tunable_d: Dimension of samples to generate.
50+
bounds: A (tunable_d x 2) array of (lower, upper) bounds.
5051
5152
Returns:
52-
samples: An (n x d) array of random points.
53+
samples: An (n x tunable_d) array of random points.
5354
5455
"""
5556
self.init_position += n * tunable_d
56-
return self._rs.uniform(size=(n, tunable_d))
57+
return self._rs.uniform(
58+
low=bounds[:, 0],
59+
high=bounds[:, 1],
60+
size=(n, tunable_d),
61+
)

ax/generators/tests/test_random.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ def test_state(self) -> None:
3737

3838
def test_RandomGeneratorGenSamples(self) -> None:
3939
with self.assertRaises(NotImplementedError):
40-
self.random_model._gen_samples(n=1, tunable_d=1)
40+
self.random_model._gen_samples(
41+
n=1, tunable_d=1, bounds=np.array([[0.0, 1.0]])
42+
)
4143

4244
def test_RandomGeneratorGenUnconstrained(self) -> None:
4345
with self.assertRaises(NotImplementedError):
4446
self.random_model._gen_unconstrained(
45-
n=1, d=2, tunable_feature_indices=np.array([])
47+
n=1, d=2, tunable_feature_indices=np.array([], dtype=int)
4648
)
4749

4850
def test_ConvertEqualityConstraints(self) -> None:

ax/generators/tests/test_sobol.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,21 @@ def test_SobolGeneratorOnlineRestart(self) -> None:
306306
np.allclose(generated_points_single_batch, generated_points_two_trials)
307307
)
308308

309-
def test_SobolGeneratorBadBounds(self) -> None:
310-
generator = SobolGenerator()
311-
ssd = SearchSpaceDigest(feature_names=["x"], bounds=[(-1, 1)])
312-
with self.assertRaisesRegex(ValueError, "This generator operates on"):
313-
generator.gen(n=1, search_space_digest=ssd, rounding_func=lambda x: x)
309+
def test_SobolGeneratorGeneralBounds(self) -> None:
310+
generator = SobolGenerator(seed=0)
311+
bounds = [(2.0, 10.0), (0.5, 5.5), (-3.0, 3.0)]
312+
ssd = SearchSpaceDigest(
313+
feature_names=["x0", "x1", "x2"],
314+
bounds=bounds,
315+
)
316+
generated_points, weights = generator.gen(
317+
n=5, search_space_digest=ssd, rounding_func=lambda x: x
318+
)
319+
self.assertEqual(np.shape(generated_points), (5, 3))
320+
np_bounds = np.array(bounds)
321+
self.assertTrue(np.all(generated_points >= np_bounds[:, 0]))
322+
self.assertTrue(np.all(generated_points <= np_bounds[:, 1]))
323+
self.assertTrue(np.all(weights == 1.0))
314324

315325
def test_SobolGeneratorMaxDraws(self) -> None:
316326
generator = SobolGenerator(seed=0)

ax/generators/tests/test_uniform.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,18 @@ def test_with_linear_constraints(self) -> None:
167167
self.assertTrue(np.shape(expected_points) == np.shape(generated_points))
168168
self.assertTrue(np.allclose(expected_points, generated_points))
169169

170-
def test_with_bad_bounds(self) -> None:
171-
generator = UniformGenerator()
172-
with self.assertRaises(ValueError):
173-
generated_points, weights = generator.gen(
174-
n=1,
175-
search_space_digest=SearchSpaceDigest(
176-
feature_names=["a"], bounds=[(-1, 1)]
177-
),
178-
rounding_func=lambda x: x,
179-
)
170+
def test_with_general_bounds(self) -> None:
171+
generator = UniformGenerator(seed=self.seed)
172+
bounds = [(2.0, 10.0), (0.5, 5.5), (-3.0, 3.0)]
173+
ssd = SearchSpaceDigest(
174+
feature_names=["x0", "x1", "x2"],
175+
bounds=bounds,
176+
)
177+
generated_points, weights = generator.gen(
178+
n=5, search_space_digest=ssd, rounding_func=lambda x: x
179+
)
180+
self.assertEqual(np.shape(generated_points), (5, 3))
181+
np_bounds = np.array(bounds)
182+
self.assertTrue(np.all(generated_points >= np_bounds[:, 0]))
183+
self.assertTrue(np.all(generated_points <= np_bounds[:, 1]))
184+
self.assertTrue(np.all(weights == 1.0))

ax/generators/utils.py

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ def rejection_sample(
7070
rounding_func: Callable[[npt.NDArray], npt.NDArray] | None = None,
7171
existing_points: npt.NDArray | None = None,
7272
) -> tuple[npt.NDArray, int]:
73-
"""Rejection sample in parameter space. Parameter space is typically
74-
[0, 1] for all tunable parameters.
73+
"""Rejection sample in parameter space.
7574
7675
Generators must implement a `gen_unconstrained` method in order to support
7776
rejection sampling via this utility.
@@ -273,30 +272,6 @@ def tunable_feature_indices(
273272
return np.delete(feature_indices, fixed_feature_indices)
274273

275274

276-
def validate_bounds(
277-
bounds: Sequence[tuple[float, float]],
278-
fixed_feature_indices: npt.NDArray,
279-
) -> None:
280-
"""Ensure the requested space is [0,1]^d.
281-
282-
Args:
283-
bounds: A list of d (lower, upper) tuples for each column of X.
284-
fixed_feature_indices: Indices of features which are fixed at a
285-
particular value.
286-
"""
287-
for feature_idx, bound in enumerate(bounds):
288-
# Bounds for fixed features are not unit-transformed.
289-
if feature_idx in fixed_feature_indices:
290-
continue
291-
292-
if bound[0] != 0 or bound[1] != 1:
293-
raise ValueError(
294-
"This generator operates on [0,1]^d. Please make use "
295-
"of the UnitX transform in the Adapter, and ensure "
296-
"task features are fixed."
297-
)
298-
299-
300275
def best_observed_point(
301276
model: TorchGeneratorLike,
302277
bounds: Sequence[tuple[float, float]],

0 commit comments

Comments
 (0)