Files
ray-project--ray/python/ray/tune/tests/test_sample.py
T
2026-07-13 13:17:40 +08:00

1908 lines
68 KiB
Python

"""
If you ever run into issues like
https://gist.github.com/xwjiang2010/13e6df091e5938aff5b44769bec8ffb8,
change your pytest running directory to ray/python/ray/tune/tests/
"""
import sys
import unittest
from collections import defaultdict
from unittest.mock import patch
import numpy as np
import pytest
import ray
import ray.tune.search.sample
from ray import tune
from ray.tune import Experiment
from ray.tune.search.util import logger
from ray.tune.search.variant_generator import generate_variants
def _mock_objective(config):
tune.report(config)
def assertDictAlmostEqual(a, b):
for k, v in a.items():
assert k in b, f"Key {k} not found in {b}"
w = b[k]
assert type(v) is type(w), f"Type {type(v)} is not {type(w)}"
if isinstance(v, dict):
assert assertDictAlmostEqual(v, w), f"Subdict {v} != {w}"
elif isinstance(v, (int, float)):
assert np.isclose(v, w)
elif isinstance(v, (list, tuple)):
# Does not work for nested dicts or lists
assert all(x == y for x, y in zip(v, w))
else:
assert v == w
return True
class SearchSpaceTest(unittest.TestCase):
def setUp(self):
self.config = {
"func": tune.sample_from(lambda spec: spec.config.uniform * 0.01),
"uniform": tune.uniform(-5, -1),
"quniform": tune.quniform(3.2, 5.4, 0.2),
"loguniform": tune.loguniform(1e-4, 1e-2),
"qloguniform": tune.qloguniform(1e-4, 1e-1, 5e-5),
"choice": tune.choice([2, 3, 4]),
"randint": tune.randint(-9, 15),
"lograndint": tune.lograndint(1, 10),
"qrandint": tune.qrandint(-21, 12, 3),
"qrandint_q3": tune.qrandint(1, 10, 3),
"qrandint_q1": tune.qrandint(1, 5, 1),
"qlograndint": tune.qlograndint(2, 20, 2),
"randn": tune.randn(10, 2),
"qrandn": tune.qrandn(10, 2, 0.2),
}
def tearDown(self):
ray.shutdown()
def _testTuneSampleAPI(self, configs, ignore=None, check_stats=True):
ignore = ignore or []
stats = defaultdict(list)
for out in configs:
for k, v in out.items():
if k not in ignore:
stats[k].append(v)
if "func" not in ignore:
self.assertAlmostEqual(out["func"], out["uniform"] * 0.01)
if "uniform" not in ignore:
self.assertGreaterEqual(out["uniform"], -5)
self.assertLess(out["uniform"], -1)
if "quniform" not in ignore:
self.assertGreaterEqual(out["quniform"], 3.2)
self.assertLessEqual(out["quniform"], 5.4)
self.assertAlmostEqual(
out["quniform"] / 0.2, round(out["quniform"] / 0.2)
)
if "loguniform" not in ignore:
self.assertGreaterEqual(out["loguniform"], 1e-4)
self.assertLess(out["loguniform"], 1e-2)
if "qloguniform" not in ignore:
self.assertGreaterEqual(out["qloguniform"], 1e-4)
self.assertLessEqual(out["qloguniform"], 1e-1)
self.assertAlmostEqual(
out["qloguniform"] / 5e-5, round(out["qloguniform"] / 5e-5)
)
if "choice" not in ignore:
self.assertIn(out["choice"], [2, 3, 4])
if "randint" not in ignore:
self.assertGreaterEqual(out["randint"], -9)
self.assertLess(out["randint"], 15)
self.assertTrue(isinstance(out["randint"], int))
if "lograndint" not in ignore:
self.assertGreaterEqual(out["lograndint"], 1)
self.assertLess(out["lograndint"], 10)
self.assertTrue(isinstance(out["lograndint"], int))
if "qrandint" not in ignore:
self.assertGreaterEqual(out["qrandint"], -21)
self.assertLessEqual(out["qrandint"], 12)
self.assertEqual(out["qrandint"] % 3, 0)
self.assertTrue(isinstance(out["qrandint"], int))
if "qrandint_q3" not in ignore:
self.assertGreaterEqual(out["qrandint_q3"], 1)
self.assertLessEqual(out["qrandint_q3"], 10)
self.assertEqual(out["qrandint_q3"] % 3, 0)
self.assertTrue(isinstance(out["qrandint_q3"], int))
if "qrandint_q1" not in ignore:
self.assertGreaterEqual(out["qrandint_q1"], 1)
self.assertLessEqual(out["qrandint_q1"], 5)
self.assertEqual(out["qrandint_q1"] % 1, 0)
self.assertTrue(isinstance(out["qrandint_q1"], int))
if "qlograndint" not in ignore:
self.assertGreaterEqual(out["qlograndint"], 2)
self.assertLessEqual(out["qlograndint"], 20)
self.assertEqual(out["qlograndint"] % 2, 0)
self.assertTrue(isinstance(out["qlograndint"], int))
if "randn" not in ignore:
# Very improbable
self.assertGreater(out["randn"], 0)
self.assertLess(out["randn"], 20)
if "qrandn" not in ignore:
self.assertGreater(out["qrandn"], 0)
self.assertLess(out["qrandn"], 20)
self.assertAlmostEqual(out["qrandn"] / 0.2, round(out["qrandn"] / 0.2))
if check_stats:
for k, v in stats.items():
if k == "choice":
self.assertIn(2, v, msg="choice failed for 2")
self.assertIn(3, v, msg="choice failed for 3")
self.assertIn(4, v, msg="choice failed for 4")
elif k == "randint":
for i in range(-9, 15):
self.assertIn(i, v, msg=f"randint failed for i={i}")
elif k == "qrandint":
for i in range(-21, 13, 3):
self.assertIn(i, v, msg=f"qrandint failed for i={i}")
elif k == "qrandint_q3":
for i in range(3, 11, 3):
self.assertIn(i, v, msg=f"qrandint_q3 failed for i={i}")
elif k == "qrandint_q1":
for i in range(1, 5, 1):
self.assertIn(i, v, msg=f"qrandint_q1 failed for i={i}")
elif k == "lograndint":
for i in range(1, 10):
self.assertIn(i, v, msg=f"lograndint failed for i={i}")
elif k == "qlograndint":
for i in range(2, 21, 2):
self.assertIn(i, v, msg=f"qlograndint failed for i={i}")
def testSampleBoundsRandom(self):
config = self.config.copy()
def config_generator():
for i in range(1000):
for _, generated in generate_variants({"config": config}):
yield generated["config"]
self._testTuneSampleAPI(config_generator())
def testReproducibility(self):
config = self.config.copy()
config.pop("func")
def config_generator(random_state):
if random_state is None:
np.random.seed(1000)
for _, generated in generate_variants(
{"config": config},
random_state=ray.tune.search.sample._BackwardsCompatibleNumpyRng(
random_state
),
):
yield generated["config"]
with patch("ray.tune.search.sample.LEGACY_RNG", True):
global_seed_legacy = [
next(config_generator(random_state=None)) for _ in range(100)
]
seed_legacy = [
next(config_generator(random_state=1000)) for _ in range(100)
]
generator_legacy = [
next(config_generator(random_state=np.random.RandomState(1000)))
for _ in range(100)
]
for i in range(100):
assertDictAlmostEqual(global_seed_legacy[0], global_seed_legacy[i])
assertDictAlmostEqual(global_seed_legacy[0], seed_legacy[i])
assertDictAlmostEqual(global_seed_legacy[0], generator_legacy[i])
if not ray.tune.search.sample.LEGACY_RNG:
seed_new = [next(config_generator(random_state=1000)) for _ in range(100)]
generator_new = [
next(config_generator(random_state=np.random.default_rng(1000)))
for _ in range(100)
]
for i in range(100):
assertDictAlmostEqual(seed_new[0], seed_new[i])
assertDictAlmostEqual(seed_new[0], generator_new[i])
def testReproducibilityBasicVariantGenerator(self):
config = self.config.copy()
config.pop("func")
from ray.tune.search.basic_variant import BasicVariantGenerator
ray.init(num_cpus=1)
num_samples = 5
params = dict(
run_or_experiment=_mock_objective,
config=config,
metric="uniform",
mode="max",
num_samples=num_samples,
)
with patch("ray.tune.search.sample.LEGACY_RNG", True):
np.random.seed(1000)
analysis_global_seed = tune.run(
search_alg=BasicVariantGenerator(max_concurrent=1), # global seed
**params,
)
np.random.seed(1000)
analysis_global_seed_2 = tune.run(
search_alg=BasicVariantGenerator(max_concurrent=1), # global seed
**params,
)
analysis_seed = tune.run(
search_alg=BasicVariantGenerator(max_concurrent=1, random_state=1000),
**params,
)
analysis_seed_2 = tune.run(
search_alg=BasicVariantGenerator(max_concurrent=1, random_state=1000),
**params,
)
analysis_generator = tune.run(
search_alg=BasicVariantGenerator(
max_concurrent=1, random_state=np.random.RandomState(1000)
),
**params,
)
analysis_generator_2 = tune.run(
search_alg=BasicVariantGenerator(
max_concurrent=1, random_state=np.random.RandomState(1000)
),
**params,
)
for i in range(num_samples):
assertDictAlmostEqual(
analysis_global_seed.trials[i].config,
analysis_seed.trials[i].config,
)
assertDictAlmostEqual(
analysis_global_seed.trials[i].config,
analysis_generator.trials[i].config,
)
assertDictAlmostEqual(
analysis_global_seed.trials[i].config,
analysis_global_seed_2.trials[i].config,
)
assertDictAlmostEqual(
analysis_global_seed.trials[i].config,
analysis_seed_2.trials[i].config,
)
assertDictAlmostEqual(
analysis_global_seed.trials[i].config,
analysis_generator_2.trials[i].config,
)
if not ray.tune.search.sample.LEGACY_RNG:
analysis_seed = tune.run(
search_alg=BasicVariantGenerator(max_concurrent=1, random_state=1000),
**params,
)
analysis_seed_2 = tune.run(
search_alg=BasicVariantGenerator(max_concurrent=1, random_state=1000),
**params,
)
analysis_generator = tune.run(
search_alg=BasicVariantGenerator(
max_concurrent=1, random_state=np.random.default_rng(1000)
),
**params,
)
analysis_generator_2 = tune.run(
search_alg=BasicVariantGenerator(
max_concurrent=1, random_state=np.random.default_rng(1000)
),
**params,
)
for i in range(num_samples):
assertDictAlmostEqual(
analysis_seed.trials[i].config, analysis_generator.trials[i].config
)
assertDictAlmostEqual(
analysis_seed.trials[i].config, analysis_seed_2.trials[i].config
)
assertDictAlmostEqual(
analysis_seed.trials[i].config,
analysis_generator_2.trials[i].config,
)
def testBoundedFloat(self):
bounded = ray.tune.search.sample.Float(-4.2, 8.3)
# Don't allow to specify more than one sampler
with self.assertRaises(ValueError):
bounded.normal().uniform()
# Uniform
samples = bounded.uniform().sample(size=1000)
self.assertTrue(any(-4.2 < s < 8.3 for s in samples))
self.assertFalse(np.mean(samples) < -2)
# Loguniform
with self.assertRaises(ValueError):
bounded.loguniform().sample(size=1000)
bounded_positive = ray.tune.search.sample.Float(1e-4, 1e-1)
samples = bounded_positive.loguniform().sample(size=1000)
self.assertTrue(any(1e-4 < s < 1e-1 for s in samples))
def testUnboundedFloat(self):
unbounded = ray.tune.search.sample.Float(None, None)
# Require min and max bounds for loguniform
with self.assertRaises(ValueError):
unbounded.loguniform()
# Normal
samples = ray.tune.search.sample.Float(None, None).normal().sample(size=1000)
self.assertTrue(any(-5 < s < 5 for s in samples))
self.assertTrue(-1 < np.mean(samples) < 1)
def testBoundedInt(self):
bounded = ray.tune.search.sample.Integer(-3, 12)
samples = bounded.uniform().sample(size=1000)
self.assertTrue(any(-3 <= s < 12 for s in samples))
self.assertFalse(np.mean(samples) < 2)
def testCategorical(self):
categories = [-2, -1, 0, 1, 2]
cat = ray.tune.search.sample.Categorical(categories)
samples = cat.uniform().sample(size=1000)
self.assertTrue(any(-2 <= s <= 2 for s in samples))
self.assertTrue(all(c in samples for c in categories))
def testFunction(self):
def sample(spec):
return np.random.uniform(-4, 4)
fnc = ray.tune.search.sample.Function(sample)
samples = fnc.sample(size=1000)
self.assertTrue(any(-4 < s < 4 for s in samples))
self.assertTrue(-2 < np.mean(samples) < 2)
def testFunctionSignature(self):
from functools import partial
def sample_a():
return 0
def sample_b(spec):
return 1
def sample_c(spec, b="ok"):
return 2
def sample_d_invalid(spec, b):
return 3
sample_d_valid = partial(sample_d_invalid, b="ok")
for sample_fn in [sample_a, sample_b, sample_c, sample_d_valid]:
fn = tune.sample_from(sample_fn)
sample = fn.sample(None)
self.assertIsNotNone(sample)
with self.assertRaises(ValueError):
fn = tune.sample_from(sample_d_invalid)
print(fn.sample(None))
def testQuantized(self):
bounded_positive = ray.tune.search.sample.Float(1e-4, 1e-1)
bounded = ray.tune.search.sample.Float(1e-4, 1e-1)
with self.assertRaises(ValueError):
# Granularity too high
bounded.quantized(5e-4)
with self.assertRaises(ValueError):
ray.tune.search.sample.Float(-1e-1, -1e-4).quantized(5e-4)
samples = bounded_positive.loguniform().quantized(5e-5).sample(size=1000)
for sample in samples:
factor = sample / 5e-5
assert 1e-4 <= sample <= 1e-1
self.assertAlmostEqual(factor, round(factor), places=10)
with self.assertRaises(ValueError):
ray.tune.search.sample.Float(0, 32).quantized(3)
samples = ray.tune.search.sample.Float(0, 33).quantized(3).sample(size=1000)
self.assertTrue(all(0 <= s <= 33 for s in samples))
def testCategoricalDtype(self):
dist = tune.choice([1.0, "str"])
np.random.seed(1000)
sample = dist.sample(size=100)
self.assertTrue(
all((x, type(x)) in [(1.0, float), ("str", str)] for x in sample)
)
def testCategoricalSeedInTrainingLoop(self):
def train_fn(config):
return 0
config = {
"integer": tune.randint(0, 100_000),
"choice": tune.choice(list(range(100_000))),
}
np.random.seed(1000)
out_1 = tune.run(train_fn, config=config, num_samples=8, verbose=0)
integers_1 = [t.config["integer"] for t in out_1.trials]
choices_1 = [t.config["choice"] for t in out_1.trials]
np.random.seed(1000)
out_2 = tune.run(train_fn, config=config, num_samples=8, verbose=0)
integers_2 = [t.config["integer"] for t in out_2.trials]
choices_2 = [t.config["choice"] for t in out_2.trials]
self.assertSequenceEqual(integers_1, integers_2)
self.assertSequenceEqual(choices_1, choices_2)
def testConvertAx(self):
from ax.service.ax_client import AxClient, ObjectiveProperties
from ray.tune.search.ax import AxSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
AxSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5).quantized(2),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
converted_config = AxSearch.convert_search_space(config)
ax_config = [
{"name": "a", "type": "choice", "values": [2, 3, 4]},
{"name": "b/x", "type": "range", "bounds": [0, 4], "value_type": "int"},
{"name": "b/y", "type": "fixed", "value": 4},
{
"name": "b/z",
"type": "range",
"bounds": [1e-4, 1e-2],
"value_type": "float",
"log_scale": True,
},
]
client1 = AxClient(random_seed=42)
client1.create_experiment(
parameters=converted_config,
objectives={"a": ObjectiveProperties(minimize=False)},
)
searcher1 = AxSearch(ax_client=client1)
client2 = AxClient(random_seed=42)
client2.create_experiment(
parameters=ax_config,
objectives={"a": ObjectiveProperties(minimize=False)},
)
searcher2 = AxSearch(ax_client=client2)
config1 = searcher1.suggest("0")
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
self.assertIn(config1["a"], [2, 3, 4])
self.assertIn(config1["b"]["x"], list(range(5)))
self.assertEqual(config["b"]["y"], 4)
self.assertLess(1e-4, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 1e-2)
searcher = AxSearch(metric="a", mode="max")
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
assert trial.config["a"] in [2, 3, 4]
mixed_config = {"a": tune.uniform(5, 6), "b": tune.uniform(8, 9)}
searcher = AxSearch(space=mixed_config, metric="a", mode="max")
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
def testSampleBoundsAx(self):
try:
# ax 1.0+: ax.modelbridge was removed
from ax.adapter.registry import Generators as Models
from ax.generation_strategy.generation_node import GenerationStep
from ax.generation_strategy.generation_strategy import GenerationStrategy
except ImportError:
# ax 0.x
from ax import Models
from ax.modelbridge.generation_strategy import (
GenerationStep,
GenerationStrategy,
)
from ax.service.ax_client import AxClient, ObjectiveProperties
from ray.tune.search.ax import AxSearch
ignore = [
"func",
"randn",
"qrandn",
"quniform",
"qloguniform",
"qrandint",
"qrandint_q1",
"qrandint_q3",
"qlograndint",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
# ax 1.0+ renamed 'model' to 'generator'; ax <0.2.0 used 'num_arms'
try:
generation_strategy = GenerationStrategy(
steps=[GenerationStep(generator=Models.UNIFORM, num_trials=-1)]
)
except TypeError:
try:
generation_strategy = GenerationStrategy(
steps=[GenerationStep(model=Models.UNIFORM, num_trials=-1)]
)
except TypeError:
generation_strategy = GenerationStrategy(
steps=[GenerationStep(model=Models.UNIFORM, num_arms=-1)]
)
client1 = AxClient(
enforce_sequential_optimization=False,
generation_strategy=generation_strategy,
)
client1.create_experiment(
parameters=AxSearch.convert_search_space(config),
objectives={"a": ObjectiveProperties(minimize=False)},
)
searcher1 = AxSearch(ax_client=client1)
def config_generator():
for i in range(50):
yield searcher1.suggest(f"trial_{i}")
# Unfortunately even random sampling in Ax takes a long time, so we
# only sample 50 trials and don't do an extensive bounds check.
# Full bounds check has been run locally and seems to work fine.
self._testTuneSampleAPI(config_generator(), ignore=ignore, check_stats=False)
def testConvertBayesOpt(self):
from ray.tune.search.bayesopt import BayesOptSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
BayesOptSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5).quantized(2),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
with self.assertRaises(ValueError):
converted_config = BayesOptSearch.convert_search_space(config)
config = {"b": {"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform()}}
bayesopt_config = {"b/z": (1e-4, 1e-2)}
converted_config = BayesOptSearch.convert_search_space(config)
searcher1 = BayesOptSearch(space=converted_config, metric="none", mode="max")
searcher2 = BayesOptSearch(space=bayesopt_config, metric="none", mode="max")
config1 = searcher1.suggest("0")
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
self.assertLess(1e-4, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 1e-2)
searcher = BayesOptSearch()
invalid_config = {"a/b": tune.uniform(4.0, 8.0)}
with self.assertRaises(ValueError):
searcher.set_search_properties("none", "max", invalid_config)
invalid_config = {"a": {"b/c": tune.uniform(4.0, 8.0)}}
with self.assertRaises(ValueError):
searcher.set_search_properties("none", "max", invalid_config)
searcher = BayesOptSearch(metric="b/z", mode="max")
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
self.assertLess(trial.config["b"]["z"], 1e-2)
mixed_config = {"a": tune.uniform(5, 6), "b": (8.0, 9.0)}
searcher = BayesOptSearch(space=mixed_config, metric="a", mode="max")
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
def testSampleBoundsBayesOpt(self):
from ray.tune.search.bayesopt import BayesOptSearch
ignore = [
"func",
"choice",
"randint",
"lograndint",
"randn",
"qrandn",
"quniform",
"qloguniform",
"qrandint",
"qrandint_q1",
"qrandint_q3",
"qlograndint",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
searcher = BayesOptSearch(
space=config,
metric="a",
mode="max",
skip_duplicate=False,
random_search_steps=1000,
)
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
self._testTuneSampleAPI(config_generator(), ignore=ignore)
@pytest.mark.skipif(
sys.version_info >= (3, 12),
reason="BOHB not yet supported for python 3.12+",
)
def testConvertBOHB(self):
import ConfigSpace
from ray.tune.search.bohb import TuneBOHB
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
TuneBOHB.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5).quantized(2),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
converted_config = TuneBOHB.convert_search_space(config)
bohb_config = ConfigSpace.ConfigurationSpace()
bohb_config.add_hyperparameters(
[
ConfigSpace.CategoricalHyperparameter("a", [2, 3, 4]),
ConfigSpace.UniformIntegerHyperparameter("b/x", lower=0, upper=4),
ConfigSpace.UniformFloatHyperparameter(
"b/z", lower=1e-4, upper=1e-2, log=True
),
]
)
converted_config.seed(1234)
bohb_config.seed(1234)
searcher1 = TuneBOHB(space=converted_config, metric="a", mode="max")
searcher2 = TuneBOHB(space=bohb_config, metric="a", mode="max")
config1 = searcher1.suggest("0")
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
self.assertIn(config1["a"], [2, 3, 4])
self.assertIn(config1["b"]["x"], list(range(5)))
self.assertLess(1e-4, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 1e-2)
searcher = TuneBOHB(metric="a", mode="max")
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
self.assertIn(trial.config["a"], [2, 3, 4])
self.assertEqual(trial.config["b"]["y"], 4)
mixed_config = {
"a": tune.uniform(5, 6),
"b": tune.uniform(8, 9), # Cannot mix ConfigSpace and Dict
}
searcher = TuneBOHB(space=mixed_config, metric="a", mode="max")
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="BOHB doesn't support py312"
)
def testSampleBoundsBOHB(self):
from ray.tune.search.bohb import TuneBOHB
ignore = [
"func",
"quniform", # BOHB drops quantization
"qloguniform", # BOHB drops quantization
"qrandint", # BOHB drops quantization
"qrandint_q3", # BOHB drops quantization
"qlograndint", # BOHB drops quantization
"randn", # ConfigSpace 1.2+ doesn't support unbounded normals
"qrandn", # ConfigSpace 1.2+ doesn't support unbounded normals
]
config = self.config.copy()
for k in ignore:
config.pop(k)
searcher = TuneBOHB(space=config, metric="a", mode="max")
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
self._testTuneSampleAPI(config_generator(), ignore=ignore)
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="HEBO doesn't support py312"
)
def testConvertHEBO(self):
import torch
from hebo.design_space.design_space import DesignSpace
from ray.tune.search.hebo import HEBOSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
HEBOSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
converted_config = HEBOSearch.convert_search_space(config)
hebo_space_config = [
{"name": "a", "type": "cat", "categories": [2, 3, 4]},
{"name": "b/x", "type": "int", "lb": 0, "ub": 5},
{"name": "b/z", "type": "pow", "lb": 1e-4, "ub": 1e-2},
]
hebo_space = DesignSpace().parse(hebo_space_config)
searcher1 = HEBOSearch(
space=converted_config, metric="a", mode="max", random_state_seed=123
)
searcher2 = HEBOSearch(
space=hebo_space, metric="a", mode="max", random_state_seed=123
)
np.random.seed(1234)
torch.manual_seed(1234)
config1 = searcher1.suggest("0")
np.random.seed(1234)
torch.manual_seed(1234)
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
self.assertIn(config1["a"], [2, 3, 4])
self.assertIn(config1["b"]["x"], list(range(5)))
self.assertLessEqual(1e-4, config1["b"]["z"])
self.assertLessEqual(config1["b"]["z"], 1e-2)
searcher = HEBOSearch(metric="a", mode="max", random_state_seed=123)
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
self.assertIn(trial.config["a"], [2, 3, 4])
self.assertEqual(trial.config["b"]["y"], 4)
# Mixed configs are not supported
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="HEBO doesn't support py312"
)
def testSampleBoundsHEBO(self):
from ray.tune.search.hebo import HEBOSearch
ignore = [
"func",
"randn",
"qrandn",
"quniform",
"qloguniform",
"qrandint",
"qrandint_q1",
"qrandint_q3",
"qlograndint",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
searcher = HEBOSearch(space=config, metric="a", mode="max", max_concurrent=1000)
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
self._testTuneSampleAPI(config_generator(), ignore=ignore)
def testConvertHyperOpt(self):
from hyperopt import hp
from ray.tune.search.hyperopt import HyperOptSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
HyperOptSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(-15, -10),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
converted_config = HyperOptSearch.convert_search_space(config)
hyperopt_config = {
"a": hp.choice("a", [2, 3, 4]),
"b": {
"x": hp.uniformint("x", -15, -11),
"y": 4,
"z": hp.loguniform("z", np.log(1e-4), np.log(1e-2)),
},
}
searcher1 = HyperOptSearch(
space=converted_config, random_state_seed=1234, metric="a", mode="max"
)
searcher2 = HyperOptSearch(
space=hyperopt_config, random_state_seed=1234, metric="a", mode="max"
)
config1 = searcher1.suggest("0")
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
self.assertIn(config1["a"], [2, 3, 4])
self.assertIn(config1["b"]["x"], list(range(-15, -10)))
self.assertEqual(config1["b"]["y"], 4)
self.assertLess(1e-4, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 1e-2)
searcher = HyperOptSearch(metric="a", mode="max")
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
assert trial.config["a"] in [2, 3, 4]
mixed_config = {"a": tune.uniform(5, 6), "b": hp.uniform("b", 8, 9)}
searcher = HyperOptSearch(space=mixed_config, metric="a", mode="max")
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
def testConvertHyperOptChooseFromListOfList(self):
from hyperopt import hp
from ray.tune.search.hyperopt import HyperOptSearch
config = {
"a": tune.choice([[1, 2], [3, 4]]),
}
converted_config = HyperOptSearch.convert_search_space(config)
hyperopt_config = {
"a": hp.choice("a", [[1, 2], [3, 4]]),
}
searcher1 = HyperOptSearch(
space=converted_config, random_state_seed=1234, metric="a", mode="max"
)
searcher2 = HyperOptSearch(
space=hyperopt_config, random_state_seed=1234, metric="a", mode="max"
)
config1 = searcher1.suggest("0")
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
# Hyperopt natively converts list to tuple.
# Try out the following script:
# ```
# a = HyperOptSearch.convert_search_space({"a": tune.choice([[1,2], [3,4]])})
# print(hyperopt.pyll.stochastic.sample(a))
# ```
self.assertTrue(config1.get("a") in [(1, 2), (3, 4)])
def testConvertHyperOptNested(self):
from ray.tune.search.hyperopt import HyperOptSearch
config = {
"a": 1,
"dict_nested": ray.tune.search.sample.Categorical(
[
{
"a": ray.tune.search.sample.Categorical(["M", "N"]),
"b": ray.tune.search.sample.Categorical(["O", "P"]),
}
]
).uniform(),
"list_nested": ray.tune.search.sample.Categorical(
[
[
ray.tune.search.sample.Categorical(["M", "N"]),
ray.tune.search.sample.Categorical(["O", "P"]),
],
[
ray.tune.search.sample.Categorical(["Q", "R"]),
ray.tune.search.sample.Categorical(["S", "T"]),
],
]
).uniform(),
"domain_nested": ray.tune.search.sample.Categorical(
[
ray.tune.search.sample.Categorical(["M", "N"]),
ray.tune.search.sample.Categorical(["O", "P"]),
]
).uniform(),
}
searcher = HyperOptSearch(metric="a", mode="max")
analysis = tune.run(
_mock_objective,
config=config,
search_alg=searcher,
num_samples=10,
)
for trial in analysis.trials:
config = trial.config
self.assertIn(config["dict_nested"]["a"], ["M", "N"])
self.assertIn(config["dict_nested"]["b"], ["O", "P"])
if config["list_nested"][0] in ["M", "N"]:
self.assertIn(config["list_nested"][1], ["O", "P"])
else:
self.assertIn(config["list_nested"][0], ["Q", "R"])
self.assertIn(config["list_nested"][1], ["S", "T"])
self.assertIn(config["domain_nested"], ["M", "N", "O", "P"])
def testConvertHyperOptConstant(self):
from ray.tune.search.hyperopt import HyperOptSearch
config = {"a": 4}
searcher = HyperOptSearch()
with self.assertRaisesRegex(
RuntimeError, "This issue can also come up with HyperOpt"
):
searcher.set_search_properties(metric="a", mode="max", config=config)
def testSampleBoundsHyperopt(self):
from ray.tune.search.hyperopt import HyperOptSearch
# Todo: Hyperopt actually suffers from the same problem as we did before
# https://github.com/ray-project/ray/pull/28187
ignore = [
"func",
"qrandint_q3",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
searcher = HyperOptSearch(
space=config, metric="a", mode="max", n_initial_points=1000
)
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
self._testTuneSampleAPI(config_generator(), ignore=ignore)
def testConvertNevergrad(self):
import nevergrad as ng
from ray.tune.search.nevergrad import NevergradSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
NevergradSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5).quantized(2),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
converted_config = NevergradSearch.convert_search_space(config)
nevergrad_config = ng.p.Dict(
a=ng.p.Choice([2, 3, 4]),
b=ng.p.Dict(
x=ng.p.Scalar(lower=0, upper=5).set_integer_casting(),
z=ng.p.Log(lower=1e-4, upper=1e-2),
),
)
searcher1 = NevergradSearch(
optimizer=ng.optimizers.OnePlusOne,
space=converted_config,
metric="a",
mode="max",
)
searcher2 = NevergradSearch(
optimizer=ng.optimizers.OnePlusOne,
space=nevergrad_config,
metric="a",
mode="max",
)
np.random.seed(1234)
config1 = searcher1.suggest("0")
np.random.seed(1234)
config2 = searcher2.suggest("0")
assertDictAlmostEqual(config1, config2)
self.assertIn(config1["a"], [2, 3, 4])
self.assertIn(config1["b"]["x"], list(range(5)))
self.assertLess(1e-4, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 1e-2)
searcher = NevergradSearch(
optimizer=ng.optimizers.OnePlusOne, metric="a", mode="max"
)
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
assert trial.config["a"] in [2, 3, 4]
mixed_config = {
"a": tune.uniform(5, 6),
"b": tune.uniform(8, 9), # Cannot mix Nevergrad cfg and tune
}
searcher = NevergradSearch(
space=mixed_config,
optimizer=ng.optimizers.OnePlusOne,
metric="a",
mode="max",
)
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
def testSampleBoundsNevergrad(self):
import nevergrad as ng
from ray.tune.search.nevergrad import NevergradSearch
ignore = [
"func",
"randn",
"qrandn",
"quniform",
"qloguniform",
"qrandint",
"qrandint_q1",
"qrandint_q3",
"qlograndint",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
optimizer = ng.optimizers.RandomSearchMaker(sampler="parametrization")
searcher = NevergradSearch(
space=config, metric="a", mode="max", optimizer=optimizer
)
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
self._testTuneSampleAPI(config_generator(), ignore=ignore)
def testConvertOptuna(self):
import optuna
from optuna.samplers import RandomSampler
from ray.tune.search.optuna import OptunaSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
OptunaSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5).quantized(2),
"y": 4,
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
converted_config = OptunaSearch.convert_search_space(config)
optuna_config = {
"a": optuna.distributions.CategoricalDistribution([2, 3, 4]),
"b": {
"x": optuna.distributions.IntDistribution(0, 5, step=2),
"z": optuna.distributions.FloatDistribution(1e-4, 1e-2, log=True),
},
}
def optuna_define_by_run(ot_trial):
ot_trial.suggest_categorical("a", [2, 3, 4])
ot_trial.suggest_int("b/x", 0, 5, step=2)
ot_trial.suggest_loguniform("b/z", 1e-4, 1e-2)
def optuna_define_by_run_with_constants(ot_trial):
ot_trial.suggest_categorical("a", [2, 3, 4])
ot_trial.suggest_int("b/x", 0, 5, step=2)
ot_trial.suggest_loguniform("b/z", 1e-4, 1e-2)
return {"constant": 1}
def optuna_define_by_run_invalid(ot_trial):
ot_trial.suggest_categorical("a", [2, 3, 4])
ot_trial.suggest_int("b/x", 0, 5, step=2)
ot_trial.suggest_loguniform("b/z", 1e-4, 1e-2)
return 1
sampler1 = RandomSampler(seed=1234)
searcher1 = OptunaSearch(
space=converted_config, sampler=sampler1, metric="a", mode="max"
)
sampler2 = RandomSampler(seed=1234)
searcher2 = OptunaSearch(
space=optuna_config, sampler=sampler2, metric="a", mode="max"
)
sampler3 = RandomSampler(seed=1234)
searcher3 = OptunaSearch(
space=optuna_define_by_run, sampler=sampler3, metric="a", mode="max"
)
sampler4 = RandomSampler(seed=1234)
searcher4 = OptunaSearch(
space=optuna_define_by_run_with_constants,
sampler=sampler4,
metric="a",
mode="max",
)
config_constant = searcher4.suggest("0")
self.assertIn("constant", config_constant)
config_constant.pop("constant")
sampler5 = RandomSampler(seed=1234)
searcher5 = OptunaSearch(
space=optuna_define_by_run_invalid, sampler=sampler5, metric="a", mode="max"
)
with self.assertRaises(TypeError):
searcher5.suggest("0")
config1 = searcher1.suggest("0")
config2 = searcher2.suggest("0")
config3 = searcher3.suggest("0")
self.assertEqual(config1, config2)
self.assertEqual(config1, config3)
self.assertEqual(config1, config_constant)
self.assertIn(config1["a"], [2, 3, 4])
self.assertIn(config1["b"]["x"], list(range(5)))
self.assertLess(1e-4, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 1e-2)
def optuna_define_by_run_branching_invalid(ot_trial):
# this is invalid because such a dict cannot be
# unflattened (will try to assign child dicts to value under "a",
# but that will be an int, instead of a dict)
a = ot_trial.suggest_categorical("a", [1, 2])
if a == 1:
ot_trial.suggest_int("a/b", 0, 3)
ot_trial.suggest_int("a/first", 2, 8)
else:
ot_trial.suggest_int("a/b", 4, 10)
ot_trial.suggest_uniform("a/second", -0.4, 0.4)
def optuna_define_by_run_branching(ot_trial):
a = ot_trial.suggest_categorical("a", ["1", "2"])
if a == "1":
ot_trial.suggest_int("nest/b", 0, 3)
ot_trial.suggest_int("nest/first", 2, 8)
else:
ot_trial.suggest_int("nest/b", 4, 10)
ot_trial.suggest_uniform("nest/second", -0.4, 0.4)
class MockOptunaSampler(RandomSampler):
def __init__(self, seed) -> None:
super().__init__(seed=seed)
self.counter = 0
def sample_independent(self, study, trial, param_name, param_distribution):
if param_name == "a":
if self.counter == 0:
self.counter += 1
return param_distribution.choices[0]
return param_distribution.choices[1]
return super().sample_independent(
study, trial, param_name, param_distribution
)
sampler_branching = RandomSampler(seed=1234)
searcher_branching = OptunaSearch(
space=optuna_define_by_run_branching_invalid,
sampler=sampler_branching,
metric="a",
mode="max",
)
with self.assertRaises(TypeError):
searcher_branching.suggest("0")
sampler_branching = MockOptunaSampler(seed=1234)
searcher_branching = OptunaSearch(
space=optuna_define_by_run_branching,
sampler=sampler_branching,
metric="a",
mode="max",
)
config_branching_1 = searcher_branching.suggest("0")
self.assertIn("a", config_branching_1)
self.assertEqual(config_branching_1["a"], "1")
self.assertIn("nest", config_branching_1)
self.assertIn("b", config_branching_1["nest"])
self.assertIn("first", config_branching_1["nest"])
self.assertGreater(4, config_branching_1["nest"]["b"])
self.assertLess(0.5, config_branching_1["nest"]["first"])
config_branching_2 = searcher_branching.suggest("1")
self.assertIn("a", config_branching_2)
self.assertEqual(config_branching_2["a"], "2")
self.assertIn("nest", config_branching_2)
self.assertIn("b", config_branching_2["nest"])
self.assertIn("second", config_branching_2["nest"])
self.assertLess(3, config_branching_2["nest"]["b"])
self.assertGreater(0.5, config_branching_2["nest"]["second"])
searcher = OptunaSearch(metric="a", mode="max")
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
assert trial.config["a"] in [2, 3, 4]
mixed_config = {
"a": tune.uniform(5, 6),
"b": tune.uniform(8, 9), # Cannot mix List and Dict
}
searcher = OptunaSearch(space=mixed_config, metric="a", mode="max")
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
def testSampleBoundsOptuna(self):
from ray.tune.search.optuna import OptunaSearch
# Quantization and log does not seem to work with Optuna
# Also, qrandint works differently in Optuna (it moves the boundaries)
ignore = [
"func",
"randn",
"qrandn",
"qloguniform",
"qlograndint",
"qrandint_q3",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
searcher = OptunaSearch(space=config, metric="a", mode="max")
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
self._testTuneSampleAPI(config_generator(), ignore=ignore)
def testConvertZOOpt(self):
from zoopt import ValueType
from ray.tune.search.zoopt import ZOOptSearch
# Grid search not supported, should raise ValueError
with self.assertRaises(ValueError):
ZOOptSearch.convert_search_space({"grid": tune.grid_search([0, 1])})
config = {
"a": ray.tune.search.sample.Categorical([2, 3, 4]).uniform(),
"b": {
"x": ray.tune.search.sample.Integer(0, 5).quantized(2),
"y": ray.tune.search.sample.Categorical([2, 4, 6, 8]).uniform(),
"z": ray.tune.search.sample.Float(1e-4, 1e-2).loguniform(),
},
}
# Does not support categorical variables
with self.assertRaises(ValueError):
converted_config = ZOOptSearch.convert_search_space(config)
config = {
"a": 2,
"b": {
"x": ray.tune.search.sample.Integer(0, 5).uniform(),
"y": ray.tune.search.sample.Categorical([2, 4, 6, 8]).uniform(),
"z": ray.tune.search.sample.Float(-3, 7).uniform().quantized(1e-4),
},
}
converted_config = ZOOptSearch.convert_search_space(config)
zoopt_config = {
"b/x": (ValueType.DISCRETE, [0, 5], True),
"b/y": (ValueType.GRID, [2, 4, 6, 8]),
"b/z": (ValueType.CONTINUOUS, [-3, 7], 1e-4),
}
zoopt_search_config = {"parallel_num": 4}
searcher1 = ZOOptSearch(
dim_dict=converted_config,
budget=5,
metric="a",
mode="max",
**zoopt_search_config,
)
searcher2 = ZOOptSearch(
dim_dict=zoopt_config,
budget=5,
metric="a",
mode="max",
**zoopt_search_config,
)
np.random.seed(1234)
config1 = searcher1.suggest("0")
np.random.seed(1234)
config2 = searcher2.suggest("0")
self.assertEqual(config1, config2)
self.assertIn(config1["b"]["x"], list(range(5)))
self.assertIn(config1["b"]["y"], [2, 4, 6, 8])
self.assertLess(-3, config1["b"]["z"])
self.assertLess(config1["b"]["z"], 7)
searcher = ZOOptSearch(budget=5, metric="a", mode="max", **zoopt_search_config)
analysis = tune.run(
_mock_objective, config=config, search_alg=searcher, num_samples=1
)
trial = analysis.trials[0]
self.assertIn(trial.config["b"]["y"], [2, 4, 6, 8])
mixed_config = {
"a": tune.uniform(5, 6),
"b": (ValueType.CONTINUOUS, [8, 9], 1e-4),
}
searcher = ZOOptSearch(
dim_dict=mixed_config,
budget=5,
metric="a",
mode="max",
**zoopt_search_config,
)
config = searcher.suggest("0")
self.assertTrue(5 <= config["a"] <= 6)
self.assertTrue(8 <= config["b"] <= 9)
def testSampleBoundsZOOpt(self):
self.skipTest(
"ZOOpt parallel_num setting does not seem to be working, "
"so skipping sampling test for now."
)
from ray.tune.search.zoopt import ZOOptSearch
ignore = [
"func",
"randn",
"qrandn",
"qloguniform",
"qlograndint",
"quniform",
"qrandint",
"qrandint_q1",
"qrandint_q3",
"loguniform",
"lograndint",
]
config = self.config.copy()
for k in ignore:
config.pop(k)
searcher = ZOOptSearch(budget=1000, parallel_num=1000)
searcher.set_search_properties(metric="a", mode="max", config=config)
def config_generator():
for i in range(1000):
yield searcher.suggest(f"trial_{i}")
searcher.on_trial_complete(
f"trial_{i}", result=dict(a=np.random.uniform(size=1))
)
self._testTuneSampleAPI(config_generator(), ignore=ignore)
def _testPointsToEvaluate(self, cls, config, exact=True, **kwargs):
points_to_evaluate = [
{k: v.sample() for k, v in config.items()} for _ in range(2)
]
print(f"Points to evaluate: {points_to_evaluate}")
searcher = cls(points_to_evaluate=points_to_evaluate, **kwargs)
analysis = tune.run(
_mock_objective,
config=config,
metric="metric",
mode="max",
search_alg=searcher,
num_samples=5,
)
for i in range(len(points_to_evaluate)):
trial_config = analysis.trials[i].config
trial_config_dict = {
"metric": trial_config["metric"],
"a": trial_config["a"],
"b": trial_config["b"],
"c": trial_config["c"],
}
if not exact:
for k, v in trial_config_dict.items():
self.assertAlmostEqual(v, points_to_evaluate[i][k], places=10)
else:
self.assertDictEqual(trial_config_dict, points_to_evaluate[i])
def testPointsToEvaluateAx(self):
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
from ray.tune.search.ax import AxSearch
return self._testPointsToEvaluate(AxSearch, config)
def testPointsToEvaluateBayesOpt(self):
config = {
"metric": ray.tune.search.sample.Float(10, 20).uniform(),
"a": ray.tune.search.sample.Float(-30, -20).uniform(),
"b": ray.tune.search.sample.Float(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
from ray.tune.search.bayesopt import BayesOptSearch
return self._testPointsToEvaluate(BayesOptSearch, config)
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="BOHB not yet supported for python 3.12+"
)
def testPointsToEvaluateBOHB(self):
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
from ray.tune.search.bohb import TuneBOHB
return self._testPointsToEvaluate(TuneBOHB, config)
def testPointsToEvaluateHyperOpt(self):
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
from ray.tune.search.hyperopt import HyperOptSearch
# See if we catch hyperopt errors caused by points to evaluate missing
# keys found in space
points_to_evaluate_missing_one = [
{k: v.sample() for k, v in list(config.items())[:-1]}
]
print(f"Points to evaluate: {points_to_evaluate_missing_one}")
searcher = HyperOptSearch(points_to_evaluate=points_to_evaluate_missing_one)
with self.assertRaises(ValueError):
tune.run(
_mock_objective,
config=config,
metric="metric",
mode="max",
search_alg=searcher,
num_samples=5,
)
return self._testPointsToEvaluate(HyperOptSearch, config)
def testPointsToEvaluateHyperOptNested(self):
space = {
"nested": [
ray.tune.search.sample.Integer(0, 10),
ray.tune.search.sample.Integer(0, 10),
],
"nosample": [4, 8],
}
points_to_evaluate = [{"nested": [2, 4], "nosample": [4, 8]}]
from ray.tune.search.hyperopt import HyperOptSearch
searcher = HyperOptSearch(
space=space, metric="_", mode="max", points_to_evaluate=points_to_evaluate
)
config = searcher.suggest(trial_id="0")
self.assertSequenceEqual(config["nested"], points_to_evaluate[0]["nested"])
self.assertSequenceEqual(config["nosample"], points_to_evaluate[0]["nosample"])
def testPointsToEvaluateNevergrad(self):
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
import nevergrad as ng
from ray.tune.search.nevergrad import NevergradSearch
return self._testPointsToEvaluate(
NevergradSearch, config, exact=False, optimizer=ng.optimizers.OnePlusOne
)
def testPointsToEvaluateOptuna(self):
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
from ray.tune.search.optuna import OptunaSearch
return self._testPointsToEvaluate(OptunaSearch, config)
def testPointsToEvaluateZoOpt(self):
self.skipTest(
"ZOOpt's latest release (0.4.1) does not support sampling "
"initial points. Please re-enable this test after the next "
"release."
)
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).uniform(),
}
from ray.tune.search.zoopt import ZOOptSearch
return self._testPointsToEvaluate(
ZOOptSearch, config, budget=10, parallel_num=8
)
def testPointsToEvaluateBasicVariant(self):
config = {
"metric": ray.tune.search.sample.Categorical([1, 2, 3, 4]).uniform(),
"a": ray.tune.search.sample.Categorical(["t1", "t2", "t3", "t4"]).uniform(),
"b": ray.tune.search.sample.Integer(0, 5),
"c": ray.tune.search.sample.Float(1e-4, 1e-1).loguniform(),
}
from ray.tune.search.basic_variant import BasicVariantGenerator
return self._testPointsToEvaluate(BasicVariantGenerator, config)
def testPointsToEvaluateBasicVariantAdvanced(self):
config = {
"grid_1": tune.grid_search(["a", "b", "c", "d"]),
"grid_2": tune.grid_search(["x", "y", "z"]),
"nested": {
"random": tune.uniform(2.0, 10.0),
"dependent": tune.sample_from(
lambda spec: -1.0 * spec.config.nested.random
),
},
}
points = [
{"grid_1": "b"},
{"grid_2": "z"},
{"grid_1": "a", "grid_2": "y"},
{"nested": {"random": 8.0}},
]
from ray.tune.search.basic_variant import BasicVariantGenerator
# grid_1 * grid_2 are 3 * 4 = 12 variants per complete grid search
# However if one grid var is set by preset variables, that run
# is excluded from grid search.
# Point 1 overwrites grid_1, so the first trial only grid searches
# over grid_2 (3 trials).
# The remaining 5 trials search over the whole space (5 * 12 trials)
searcher = BasicVariantGenerator(points_to_evaluate=[points[0]])
exp = Experiment(run=_mock_objective, name="test", config=config, num_samples=6)
searcher.add_configurations(exp)
self.assertEqual(searcher.total_samples, 1 * 3 + 5 * 12)
# Point 2 overwrites grid_2, so the first trial only grid searches
# over grid_1 (4 trials).
# The remaining 5 trials search over the whole space (5 * 12 trials)
searcher = BasicVariantGenerator(points_to_evaluate=[points[1]])
exp = Experiment(run=_mock_objective, name="test", config=config, num_samples=6)
searcher.add_configurations(exp)
self.assertEqual(searcher.total_samples, 1 * 4 + 5 * 12)
# Point 3 overwrites grid_1 and grid_2, so the first trial does not
# grid search.
# The remaining 5 trials search over the whole space (5 * 12 trials)
searcher = BasicVariantGenerator(points_to_evaluate=[points[2]])
exp = Experiment(run=_mock_objective, name="test", config=config, num_samples=6)
searcher.add_configurations(exp)
self.assertEqual(searcher.total_samples, 1 + 5 * 12)
# When initialized with all points, the first three trials are
# defined by the logic above. Only 3 trials are grid searched
# compeletely.
searcher = BasicVariantGenerator(points_to_evaluate=points)
exp = Experiment(run=_mock_objective, name="test", config=config, num_samples=6)
searcher.add_configurations(exp)
self.assertEqual(searcher.total_samples, 1 * 3 + 1 * 4 + 1 + 3 * 12)
# Run this and confirm results
analysis = tune.run(exp, search_alg=searcher)
configs = [trial.config for trial in analysis.trials]
self.assertEqual(len(configs), searcher.total_samples)
self.assertTrue(all(config["grid_1"] == "b" for config in configs[0:3]))
self.assertTrue(all(config["grid_2"] == "z" for config in configs[3:7]))
self.assertTrue(configs[7]["grid_1"] == "a" and configs[7]["grid_2"] == "y")
self.assertTrue(configs[8]["nested"]["random"] == 8.0)
self.assertTrue(configs[8]["nested"]["dependent"] == -8.0)
def testPointsToEvaluateBasicVariantFixedParam(self):
config = {
"a": 1,
"b": tune.randint(0, 3),
}
from ray.tune.search.basic_variant import BasicVariantGenerator
from ray.tune.search.variant_generator import logger
# Test whether the initial points of fixed parameters are correctly
# verified.
searcher = BasicVariantGenerator(
points_to_evaluate=[
{"a": 1, "b": 2},
]
)
analysis = tune.run(
_mock_objective,
name="test",
config=config,
search_alg=searcher,
num_samples=2,
)
configs = [trial.config for trial in analysis.trials]
self.assertEqual(searcher.total_samples, 2)
self.assertEqual(len(configs), searcher.total_samples)
self.assertEqual([cfg["a"] for cfg in configs], [1] * 2)
self.assertEqual(configs[0]["b"], 2)
# Test whether correctly throwing warning if the pre-set value of fixed
# parameters isn't the same as its initial points
searcher = BasicVariantGenerator(
points_to_evaluate=[
{"a": 2, "b": 2},
]
)
with patch.object(logger, "warning") as log_warning_mock:
tune.run(
_mock_objective,
name="test",
config=config,
search_alg=searcher,
num_samples=2,
)
log_warning_mock.assert_called_once()
self.assertEqual(
log_warning_mock.call_args[0],
("Pre-set value `2` is not equal to the value of parameter `a`: 1",),
)
def testGridSearchGenerator(self):
from ray.tune.search.basic_variant import BasicVariantGenerator
searcher = BasicVariantGenerator(constant_grid_search=False)
exp = Experiment(
run=_mock_objective,
name="test",
config={"parameter": tune.grid_search(range(10))},
num_samples=1,
)
searcher.add_configurations(exp)
trials = [searcher.next_trial() for i in range(10)]
assert [t.config["parameter"] for t in trials] == list(range(10))
def testConstantGridSearchBasicVariant(self):
config = {
"grid": tune.grid_search([1, 2, 3]),
"rand": tune.uniform(0, 1000),
"dependent_rand": tune.sample_from(lambda spec: spec.config.rand / 10),
"dependent_grid": tune.sample_from(lambda spec: spec.config.grid / 10),
}
num_samples = 6
from ray.tune.search.basic_variant import BasicVariantGenerator
# First, do not keep random variables constant
searcher = BasicVariantGenerator(constant_grid_search=False)
exp = Experiment(
run=_mock_objective, name="test", config=config, num_samples=num_samples
)
searcher.add_configurations(exp)
configs = []
while not searcher.is_finished():
trial = searcher.next_trial()
if not trial:
break
configs.append(trial.config)
for i in range(num_samples):
sub_configs = configs[i * 3 : i * 3 + 3]
# These should not be equal, because we sample randomly for
# each grid search value
self.assertNotEqual(sub_configs[0]["rand"], sub_configs[1]["rand"])
self.assertNotEqual(sub_configs[0]["rand"], sub_configs[2]["rand"])
# Second, keep random variables constant
searcher = BasicVariantGenerator(constant_grid_search=True)
exp = Experiment(
run=_mock_objective, name="test", config=config, num_samples=num_samples
)
searcher.add_configurations(exp)
configs = []
while not searcher.is_finished():
trial = searcher.next_trial()
if not trial:
break
configs.append(trial.config)
for i in range(num_samples):
sub_configs = configs[i * 3 : i * 3 + 3]
# These should be equal, because we sample randomly first and
# then keep the random values constant
self.assertEqual(sub_configs[0]["rand"], sub_configs[1]["rand"])
self.assertEqual(sub_configs[0]["rand"], sub_configs[2]["rand"])
# Also, for different samples the random variables should differ
self.assertEqual(configs[0]["grid"], configs[3]["grid"])
self.assertNotEqual(configs[0]["rand"], configs[3]["rand"])
@patch.object(logger, "warning")
@pytest.mark.skipif(
sys.version_info >= (3, 12),
reason="TODO(justinvyu): not working for python 3.12 yet",
)
def testSetSearchPropertiesBackwardsCompatibility(self, mocked_warning_method):
from ray.tune.search import Searcher
class MySearcher(Searcher):
def __init__(self, metric="a", mode="min", **kwargs):
super(MySearcher, self).__init__(metric=metric, mode=mode, **kwargs)
def suggest(self, trial_id):
return {}
def on_trial_complete(self, trial_id, result, **kwargs):
pass
# impl that has not been updated yet.
def set_search_properties(self, metric, mode, config):
pass
tune.run(_mock_objective, config={"a": 1}, search_alg=MySearcher())
mocked_warning_method.assert_called_once_with(
"Please update custom Searcher to take in function signature "
"as ``def set_search_properties(metric, mode, config, "
"**spec) -> bool``."
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__] + sys.argv[1:]))