1085 lines
37 KiB
Python
1085 lines
37 KiB
Python
import json
|
|
import os
|
|
import pickle
|
|
import random
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from functools import partial
|
|
from typing import List, Optional
|
|
from unittest.mock import MagicMock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import cloudpickle, tune
|
|
from ray._private.test_utils import object_memory_usage
|
|
from ray.tune import (
|
|
Callback,
|
|
Checkpoint,
|
|
CheckpointConfig,
|
|
FailureConfig,
|
|
RunConfig,
|
|
Trainable,
|
|
)
|
|
from ray.tune.experiment import Trial
|
|
from ray.tune.schedulers import PopulationBasedTraining
|
|
from ray.tune.schedulers.pb2 import PB2
|
|
from ray.tune.schedulers.pb2_utils import UCB
|
|
from ray.tune.schedulers.pbt import _filter_mutated_params_from_config
|
|
from ray.tune.tests.execution.utils import create_execution_test_objects
|
|
from ray.tune.tune_config import TuneConfig
|
|
from ray.tune.utils.mock_trainable import MOCK_TRAINABLE_NAME, register_mock_trainable
|
|
from ray.tune.utils.util import flatten_dict
|
|
|
|
# Import psutil after ray so the packaged version is used.
|
|
import psutil
|
|
|
|
MB = 1024**2
|
|
|
|
|
|
class MockParam(object):
|
|
def __init__(self, params):
|
|
self._params = params
|
|
self._index = 0
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
val = self._params[self._index % len(self._params)]
|
|
self._index += 1
|
|
return val
|
|
|
|
|
|
class DummyTrial:
|
|
def __init__(self, trial_id, finished=False, config: Optional[dict] = None):
|
|
self.trial_id = trial_id
|
|
self._finished = finished
|
|
self.config = config if config is not None else {}
|
|
|
|
def is_finished(self):
|
|
return self._finished
|
|
|
|
|
|
class PopulationBasedTrainingMemoryTest(unittest.TestCase):
|
|
def setUp(self):
|
|
ray.init(num_cpus=1, object_store_memory=100 * MB)
|
|
|
|
def tearDown(self):
|
|
ray.shutdown()
|
|
|
|
def testMemoryCheckpointFree(self):
|
|
class MyTrainable(Trainable):
|
|
def setup(self, config):
|
|
# Make sure this is large enough so ray uses object store
|
|
# instead of in-process store.
|
|
self.large_object = random.getrandbits(int(10e6))
|
|
self.iter = 0
|
|
self.a = config["a"]
|
|
|
|
def step(self):
|
|
self.iter += 1
|
|
return {"metric": self.iter + self.a}
|
|
|
|
def save_checkpoint(self, checkpoint_dir):
|
|
file_path = os.path.join(checkpoint_dir, "model.mock")
|
|
|
|
with open(file_path, "wb") as fp:
|
|
pickle.dump((self.large_object, self.iter, self.a), fp)
|
|
|
|
def load_checkpoint(self, checkpoint_dir):
|
|
file_path = os.path.join(checkpoint_dir, "model.mock")
|
|
|
|
with open(file_path, "rb") as fp:
|
|
self.large_object, self.iter, self.a = pickle.load(fp)
|
|
|
|
class CheckObjectMemoryUsage(Callback):
|
|
def on_trial_save(
|
|
self, iteration: int, trials: List["Trial"], trial: "Trial", **info
|
|
):
|
|
assert object_memory_usage() <= (12 * 80e6)
|
|
|
|
param_a = MockParam([1, -1])
|
|
|
|
pbt = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="metric",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
hyperparam_mutations={"b": [-1]},
|
|
)
|
|
|
|
tune.run(
|
|
MyTrainable,
|
|
name="ray_demo",
|
|
scheduler=pbt,
|
|
stop={"training_iteration": 10},
|
|
num_samples=3,
|
|
checkpoint_config=CheckpointConfig(checkpoint_frequency=3),
|
|
fail_fast=True,
|
|
config={"a": tune.sample_from(lambda _: param_a())},
|
|
callbacks=[CheckObjectMemoryUsage()],
|
|
)
|
|
|
|
|
|
class PopulationBasedTrainingFileDescriptorTest(unittest.TestCase):
|
|
def setUp(self):
|
|
ray.init(num_cpus=2)
|
|
os.environ["TUNE_GLOBAL_CHECKPOINT_S"] = "1"
|
|
|
|
def tearDown(self):
|
|
ray.shutdown()
|
|
|
|
def testFileFree(self):
|
|
class MyTrainable(Trainable):
|
|
def setup(self, config):
|
|
self.iter = 0
|
|
self.a = config["a"]
|
|
|
|
def step(self):
|
|
self.iter += 1
|
|
return {"metric": self.iter + self.a}
|
|
|
|
def save_checkpoint(self, checkpoint_dir):
|
|
file_path = os.path.join(checkpoint_dir, "model.mock")
|
|
|
|
with open(file_path, "wb") as fp:
|
|
pickle.dump((self.iter, self.a), fp)
|
|
|
|
def load_checkpoint(self, checkpoint_dir):
|
|
file_path = os.path.join(checkpoint_dir, "model.mock")
|
|
|
|
with open(file_path, "rb") as fp:
|
|
self.iter, self.a = pickle.load(fp)
|
|
|
|
from ray.tune.callback import Callback
|
|
|
|
class FileCheck(Callback):
|
|
def __init__(self, verbose=False):
|
|
self.iter_ = 0
|
|
self.process = psutil.Process()
|
|
self.verbose = verbose
|
|
|
|
def on_trial_result(self, *args, **kwargs):
|
|
self.iter_ += 1
|
|
all_files = self.process.open_files()
|
|
if self.verbose:
|
|
print("Iteration", self.iter_)
|
|
print("=" * 10)
|
|
print("Object memory use: ", object_memory_usage())
|
|
print("Virtual Mem:", self.get_virt_mem() >> 30, "gb")
|
|
print("File Descriptors:", len(all_files))
|
|
assert len(all_files) < 20
|
|
|
|
@classmethod
|
|
def get_virt_mem(cls):
|
|
return psutil.virtual_memory().used
|
|
|
|
param_a = MockParam([1, -1])
|
|
|
|
pbt = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="metric",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
quantile_fraction=0.5,
|
|
hyperparam_mutations={"b": [-1]},
|
|
)
|
|
|
|
checkpoint_config = CheckpointConfig(
|
|
num_to_keep=3,
|
|
checkpoint_frequency=2,
|
|
)
|
|
|
|
tune.run(
|
|
MyTrainable,
|
|
name="ray_demo",
|
|
scheduler=pbt,
|
|
stop={"training_iteration": 10},
|
|
num_samples=4,
|
|
checkpoint_config=checkpoint_config,
|
|
verbose=False,
|
|
fail_fast=True,
|
|
config={"a": tune.sample_from(lambda _: param_a())},
|
|
callbacks=[FileCheck()],
|
|
)
|
|
|
|
|
|
class PopulationBasedTrainingSynchTest(unittest.TestCase):
|
|
def setUp(self):
|
|
ray.init(num_cpus=2)
|
|
|
|
def train_fn_sync(config):
|
|
iter = 0
|
|
|
|
checkpoint = tune.get_checkpoint()
|
|
if checkpoint:
|
|
with checkpoint.as_directory() as checkpoint_dir:
|
|
checkpoint_path = os.path.join(checkpoint_dir, "checkpoint")
|
|
with open(checkpoint_path, "rb") as fp:
|
|
a, iter = pickle.load(fp)
|
|
|
|
a = config["a"] # Use the new hyperparameter if perturbed.
|
|
|
|
while True:
|
|
iter += 1
|
|
with tempfile.TemporaryDirectory() as checkpoint_dir:
|
|
checkpoint_path = os.path.join(checkpoint_dir, "checkpoint")
|
|
with open(checkpoint_path, "wb") as fp:
|
|
pickle.dump((a, iter), fp)
|
|
# Different sleep times so that asynch test runs do not
|
|
# randomly succeed. If well performing trials finish later,
|
|
# then bad performing trials will already have continued
|
|
# to train, which is exactly what we want to test when
|
|
# comparing sync vs. async.
|
|
time.sleep(a / 20)
|
|
# Score gets better every iteration.
|
|
tune.report(
|
|
{"mean_accuracy": iter + a, "a": a},
|
|
checkpoint=Checkpoint.from_directory(checkpoint_dir),
|
|
)
|
|
|
|
self.MockTrainingFuncSync = train_fn_sync
|
|
|
|
def tearDown(self):
|
|
ray.shutdown()
|
|
|
|
def synchSetup(self, synch, param=None):
|
|
if param is None:
|
|
param = [10, 20, 40]
|
|
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="mean_accuracy",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
log_config=True,
|
|
hyperparam_mutations={"c": lambda: 1},
|
|
synch=synch,
|
|
)
|
|
|
|
param_a = MockParam(param)
|
|
|
|
random.seed(100)
|
|
np.random.seed(100)
|
|
analysis = tune.run(
|
|
self.MockTrainingFuncSync,
|
|
config={"a": tune.sample_from(lambda _: param_a()), "c": 1},
|
|
fail_fast=True,
|
|
num_samples=3,
|
|
scheduler=scheduler,
|
|
name="testPBTSync",
|
|
stop={"training_iteration": 3},
|
|
)
|
|
return analysis
|
|
|
|
def testAsynchFail(self):
|
|
analysis = self.synchSetup(False)
|
|
self.assertTrue(
|
|
any(
|
|
analysis.dataframe(metric="mean_accuracy", mode="max")["mean_accuracy"]
|
|
!= 43
|
|
)
|
|
)
|
|
|
|
def testSynchPass(self):
|
|
analysis = self.synchSetup(True)
|
|
|
|
all_results = set(
|
|
analysis.dataframe(metric="mean_accuracy", mode="max")["mean_accuracy"]
|
|
)
|
|
|
|
self.assertEqual(all_results, {43})
|
|
|
|
def testSynchPassLast(self):
|
|
analysis = self.synchSetup(True, param=[30, 20, 10])
|
|
|
|
all_results = set(
|
|
analysis.dataframe(metric="mean_accuracy", mode="max")["mean_accuracy"]
|
|
)
|
|
|
|
self.assertEqual(all_results, {33})
|
|
|
|
def testExploitWhileSavingTrial(self):
|
|
"""Tests a synch PBT failure mode where a trial misses its `SAVING_RESULT` event
|
|
book-keeping due to being stopped by the PBT algorithm (to exploit another
|
|
trial).
|
|
|
|
Trials checkpoint ever N iterations, and the perturbation interval is every N
|
|
iterations. (N = 2 in the test.)
|
|
|
|
Raises a `TimeoutError` if hanging for a specified `timeout`.
|
|
|
|
1. Trial 0 comes in with training result
|
|
2. Trial 0 begins saving checkpoint (which may take a long time, 5s here)
|
|
3. Trial 1 comes in with result
|
|
4. Trial 1 forcefully stops Trial 0 via exploit, while trial_0.is_saving
|
|
5. Trial 0 should resume training properly with Trial 1's checkpoint
|
|
"""
|
|
|
|
class MockTrainable(tune.Trainable):
|
|
def setup(self, config):
|
|
self.reset_config(config)
|
|
|
|
def step(self):
|
|
time.sleep(self.training_time)
|
|
return {"score": self.score}
|
|
|
|
def save_checkpoint(self, checkpoint_dir):
|
|
with open(os.path.join(checkpoint_dir, "checkpoint.json"), "w") as f:
|
|
json.dump({"a": self.a}, f)
|
|
time.sleep(self.saving_time)
|
|
|
|
def load_checkpoint(self, checkpoint_dir):
|
|
with open(os.path.join(checkpoint_dir, "checkpoint.json"), "r") as f:
|
|
checkpoint_dict = json.load(f)
|
|
self.a = checkpoint_dict["a"]
|
|
|
|
def reset_config(self, new_config):
|
|
self.a = new_config["a"]
|
|
self.score = new_config["score"]
|
|
self.training_time = new_config["training_time"]
|
|
self.saving_time = new_config["saving_time"]
|
|
return True
|
|
|
|
perturbation_interval = 2
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="score",
|
|
mode="max",
|
|
perturbation_interval=perturbation_interval,
|
|
hyperparam_mutations={"a": tune.uniform(0, 1)},
|
|
synch=True,
|
|
)
|
|
|
|
class TimeoutExceptionStopper(tune.stopper.TimeoutStopper):
|
|
def stop_all(self):
|
|
decision = super().stop_all()
|
|
if decision:
|
|
raise TimeoutError("Trials are hanging! Timeout reached...")
|
|
return decision
|
|
|
|
timeout = 30.0
|
|
training_times = [0.1, 0.15]
|
|
saving_times = [5.0, 0.1]
|
|
tuner = tune.Tuner(
|
|
MockTrainable,
|
|
param_space={
|
|
"a": tune.uniform(0, 1),
|
|
"score": tune.grid_search([0, 1]),
|
|
"training_time": tune.sample_from(
|
|
lambda config: training_times[config["score"]]
|
|
),
|
|
"saving_time": tune.sample_from(
|
|
lambda config: saving_times[config["score"]]
|
|
),
|
|
},
|
|
tune_config=TuneConfig(
|
|
num_samples=1,
|
|
scheduler=scheduler,
|
|
),
|
|
run_config=RunConfig(
|
|
stop=tune.stopper.CombinedStopper(
|
|
tune.stopper.MaximumIterationStopper(5),
|
|
TimeoutExceptionStopper(timeout),
|
|
),
|
|
failure_config=FailureConfig(fail_fast=True),
|
|
checkpoint_config=CheckpointConfig(
|
|
# Match `checkpoint_interval` with `perturbation_interval`
|
|
checkpoint_frequency=perturbation_interval,
|
|
),
|
|
),
|
|
)
|
|
random.seed(100)
|
|
np.random.seed(1000)
|
|
results = tuner.fit()
|
|
assert not results.errors
|
|
|
|
|
|
class PopulationBasedTrainingConfigTest(unittest.TestCase):
|
|
def setUp(self):
|
|
ray.init(num_cpus=2)
|
|
|
|
def tearDown(self):
|
|
ray.shutdown()
|
|
|
|
def testNoConfig(self):
|
|
def MockTrainingFunc(config):
|
|
a = config["a"]
|
|
b = config["b"]
|
|
c1 = config["c"]["c1"]
|
|
c2 = config["c"]["c2"]
|
|
|
|
while True:
|
|
tune.report({"mean_accuracy": a * b * (c1 + c2)})
|
|
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="mean_accuracy",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
hyperparam_mutations={
|
|
"a": tune.uniform(0, 0.3),
|
|
"b": [1, 2, 3],
|
|
"c": {
|
|
"c1": lambda: np.random.uniform(0.5),
|
|
"c2": tune.choice([2, 3, 4]),
|
|
},
|
|
},
|
|
)
|
|
|
|
tune.run(
|
|
MockTrainingFunc,
|
|
fail_fast=True,
|
|
num_samples=4,
|
|
scheduler=scheduler,
|
|
name="testNoConfig",
|
|
stop={"training_iteration": 3},
|
|
)
|
|
|
|
|
|
class PopulationBasedTrainingResumeTest(unittest.TestCase):
|
|
def setUp(self):
|
|
ray.init(num_cpus=2)
|
|
|
|
def tearDown(self):
|
|
ray.shutdown()
|
|
|
|
def testPermutationContinuation(self):
|
|
"""
|
|
Tests continuation of runs after permutation.
|
|
Sometimes, runs were continued from deleted checkpoints.
|
|
This deterministic initialisation would fail when the
|
|
fix was not applied.
|
|
See issues #9036, #9036
|
|
"""
|
|
|
|
class MockTrainable(tune.Trainable):
|
|
def setup(self, config):
|
|
self.iter = 0
|
|
self.a = config["a"]
|
|
self.b = config["b"]
|
|
self.c = config["c"]
|
|
|
|
def step(self):
|
|
self.iter += 1
|
|
return {"mean_accuracy": (self.a - self.iter) * self.b}
|
|
|
|
def save_checkpoint(self, tmp_checkpoint_dir):
|
|
checkpoint_path = os.path.join(tmp_checkpoint_dir, "model.mock")
|
|
with open(checkpoint_path, "wb") as fp:
|
|
pickle.dump((self.a, self.b, self.iter), fp)
|
|
|
|
def load_checkpoint(self, tmp_checkpoint_dir):
|
|
checkpoint_path = os.path.join(tmp_checkpoint_dir, "model.mock")
|
|
with open(checkpoint_path, "rb") as fp:
|
|
self.a, self.b, self.iter = pickle.load(fp)
|
|
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="mean_accuracy",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
log_config=True,
|
|
hyperparam_mutations={"c": lambda: 1},
|
|
)
|
|
|
|
param_a = MockParam([10, 20, 30, 40])
|
|
param_b = MockParam([1.2, 0.9, 1.1, 0.8])
|
|
|
|
random.seed(100)
|
|
np.random.seed(1000)
|
|
checkpoint_config = CheckpointConfig(
|
|
num_to_keep=2,
|
|
checkpoint_score_attribute="min-training_iteration",
|
|
checkpoint_frequency=1,
|
|
checkpoint_at_end=True,
|
|
)
|
|
tune.run(
|
|
MockTrainable,
|
|
config={
|
|
"a": tune.sample_from(lambda _: param_a()),
|
|
"b": tune.sample_from(lambda _: param_b()),
|
|
"c": 1,
|
|
},
|
|
fail_fast=True,
|
|
num_samples=4,
|
|
checkpoint_config=checkpoint_config,
|
|
scheduler=scheduler,
|
|
name="testPermutationContinuation",
|
|
stop={"training_iteration": 3},
|
|
)
|
|
|
|
def testPermutationContinuationFunc(self):
|
|
def MockTrainingFunc(config):
|
|
iter = 0
|
|
a = config["a"]
|
|
b = config["b"]
|
|
|
|
if tune.get_checkpoint():
|
|
with tune.get_checkpoint().as_directory() as checkpoint_dir:
|
|
checkpoint_path = os.path.join(checkpoint_dir, "model.mock")
|
|
with open(checkpoint_path, "rb") as fp:
|
|
a, b, iter = pickle.load(fp)
|
|
|
|
while True:
|
|
iter += 1
|
|
with tempfile.TemporaryDirectory() as checkpoint_dir:
|
|
checkpoint_path = os.path.join(checkpoint_dir, "model.mock")
|
|
with open(checkpoint_path, "wb") as fp:
|
|
pickle.dump((a, b, iter), fp)
|
|
tune.report(
|
|
{"mean_accuracy": (a - iter) * b},
|
|
checkpoint=Checkpoint.from_directory(checkpoint_dir),
|
|
)
|
|
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="mean_accuracy",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
log_config=True,
|
|
hyperparam_mutations={"c": lambda: 1},
|
|
)
|
|
param_a = MockParam([10, 20, 30, 40])
|
|
param_b = MockParam([1.2, 0.9, 1.1, 0.8])
|
|
random.seed(100)
|
|
np.random.seed(1000)
|
|
checkpoint_config = CheckpointConfig(
|
|
num_to_keep=2,
|
|
checkpoint_score_attribute="min-training_iteration",
|
|
)
|
|
tune.run(
|
|
MockTrainingFunc,
|
|
config={
|
|
"a": tune.sample_from(lambda _: param_a()),
|
|
"b": tune.sample_from(lambda _: param_b()),
|
|
"c": 1,
|
|
},
|
|
fail_fast=True,
|
|
num_samples=4,
|
|
checkpoint_config=checkpoint_config,
|
|
scheduler=scheduler,
|
|
name="testPermutationContinuationFunc",
|
|
stop={"training_iteration": 3},
|
|
)
|
|
|
|
def testBurnInPeriod(self):
|
|
runner, *_ = create_execution_test_objects()
|
|
storage_context = runner._storage
|
|
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
metric="error",
|
|
mode="min",
|
|
perturbation_interval=5,
|
|
hyperparam_mutations={"ignored": [1]},
|
|
burn_in_period=50,
|
|
log_config=True,
|
|
synch=True,
|
|
)
|
|
|
|
class MockTrial(Trial):
|
|
@property
|
|
def checkpoint(self):
|
|
return Checkpoint.from_directory("dummy")
|
|
|
|
@property
|
|
def status(self):
|
|
return Trial.PAUSED
|
|
|
|
@status.setter
|
|
def status(self, status):
|
|
pass
|
|
|
|
register_mock_trainable()
|
|
trials = [
|
|
MockTrial(MOCK_TRAINABLE_NAME, config=dict(num=i), storage=storage_context)
|
|
for i in range(1, 5)
|
|
]
|
|
trial1, trial2, trial3, trial4 = trials
|
|
|
|
for trial in trials:
|
|
trial.init_local_path()
|
|
runner.add_trial(trial)
|
|
|
|
for trial in trials:
|
|
scheduler.on_trial_add(runner, trial)
|
|
|
|
# Add initial results.
|
|
scheduler.on_trial_result(
|
|
runner, trial1, result=dict(training_iteration=1, error=50)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial2, result=dict(training_iteration=1, error=50)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial3, result=dict(training_iteration=1, error=10)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial4, result=dict(training_iteration=1, error=100)
|
|
)
|
|
|
|
# Add more results. Without burn-in, this would now exploit
|
|
scheduler.on_trial_result(
|
|
runner, trial1, result=dict(training_iteration=30, error=50)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial2, result=dict(training_iteration=30, error=50)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial3, result=dict(training_iteration=30, error=10)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial4, result=dict(training_iteration=30, error=100)
|
|
)
|
|
|
|
self.assertEqual(trial4.config["num"], 4)
|
|
|
|
# Add more results. Since this is after burn-in, it should now exploit
|
|
scheduler.on_trial_result(
|
|
runner, trial1, result=dict(training_iteration=50, error=50)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial2, result=dict(training_iteration=50, error=50)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial3, result=dict(training_iteration=50, error=10)
|
|
)
|
|
scheduler.on_trial_result(
|
|
runner, trial4, result=dict(training_iteration=50, error=100)
|
|
)
|
|
|
|
self.assertEqual(trial4.config["num"], 3)
|
|
|
|
# Assert that trials do not hang after `burn_in_period`
|
|
self.assertTrue(all(t.status == "PAUSED" for t in runner.get_trials()))
|
|
self.assertTrue(scheduler.choose_trial_to_run(runner))
|
|
|
|
# Assert that trials do not hang when a terminated trial is added
|
|
trial5 = Trial(MOCK_TRAINABLE_NAME, config=dict(num=5))
|
|
runner.add_trial(trial5)
|
|
scheduler.on_trial_add(runner, trial5)
|
|
trial5.set_status(Trial.TERMINATED)
|
|
self.assertTrue(scheduler.choose_trial_to_run(runner))
|
|
|
|
|
|
class PopulationBasedTrainingLoggingTest(unittest.TestCase):
|
|
def testFilterHyperparamConfig(self):
|
|
filtered_params = _filter_mutated_params_from_config(
|
|
{
|
|
"training_loop_config": {
|
|
"lr": 0.1,
|
|
"momentum": 0.9,
|
|
"batch_size": 32,
|
|
"test_mode": True,
|
|
"ignore_nested": {
|
|
"b": 0.1,
|
|
},
|
|
},
|
|
"other_config": {
|
|
"a": 0.5,
|
|
},
|
|
},
|
|
{
|
|
"training_loop_config": {
|
|
"lr": tune.uniform(0, 1),
|
|
"momentum": tune.uniform(0, 1),
|
|
}
|
|
},
|
|
)
|
|
assert filtered_params == {
|
|
"training_loop_config": {"lr": 0.1, "momentum": 0.9}
|
|
}, filtered_params
|
|
|
|
def testSummarizeHyperparamChanges(self):
|
|
def test_config(
|
|
hyperparam_mutations,
|
|
old_config,
|
|
resample_probability=0.25,
|
|
print_summary=False,
|
|
):
|
|
scheduler = PopulationBasedTraining(
|
|
time_attr="training_iteration",
|
|
hyperparam_mutations=hyperparam_mutations,
|
|
resample_probability=resample_probability,
|
|
)
|
|
new_config, operations = scheduler._get_new_config(
|
|
None, DummyTrial("parent_id", config=old_config)
|
|
)
|
|
|
|
old_params = _filter_mutated_params_from_config(
|
|
old_config, hyperparam_mutations
|
|
)
|
|
new_params = _filter_mutated_params_from_config(
|
|
new_config, hyperparam_mutations
|
|
)
|
|
|
|
summary = scheduler._summarize_hyperparam_changes(
|
|
old_params, new_params, operations
|
|
)
|
|
if print_summary:
|
|
print(summary)
|
|
return scheduler, new_config, operations
|
|
|
|
# 1. Empty hyperparam_mutations (no hyperparams mutated) should raise an error
|
|
with self.assertRaises(tune.TuneError):
|
|
_, new_config, operations = test_config({}, {})
|
|
|
|
# 2. No nesting
|
|
hyperparam_mutations = {
|
|
"a": tune.uniform(0, 1),
|
|
"b": list(range(5)),
|
|
}
|
|
scheduler, new_config, operations = test_config(
|
|
hyperparam_mutations, {"a": 0.5, "b": 2}
|
|
)
|
|
assert operations["a"] in [
|
|
f"* {factor}" for factor in scheduler._perturbation_factors
|
|
] + ["resample"]
|
|
assert operations["b"] in ["shift left", "shift right", "resample"]
|
|
|
|
# 3. With nesting
|
|
hyperparam_mutations = {
|
|
"a": tune.uniform(0, 1),
|
|
"b": list(range(5)),
|
|
"c": {
|
|
"d": tune.uniform(2, 3),
|
|
"e": {"f": [-1, 0, 1]},
|
|
},
|
|
}
|
|
scheduler, new_config, operations = test_config(
|
|
hyperparam_mutations,
|
|
{
|
|
"a": 0.5,
|
|
"b": 2,
|
|
"c": {
|
|
"d": 2.5,
|
|
"e": {"f": 0},
|
|
},
|
|
},
|
|
)
|
|
assert isinstance(operations["c"], dict)
|
|
assert isinstance(operations["c"]["e"], dict)
|
|
assert operations["c"]["d"] in [
|
|
f"* {factor}" for factor in scheduler._perturbation_factors
|
|
] + ["resample"]
|
|
assert operations["c"]["e"]["f"] in ["shift left", "shift right", "resample"]
|
|
|
|
# 4. Test shift that results in noop
|
|
hyperparam_mutations = {"a": [1]}
|
|
scheduler, new_config, operations = test_config(
|
|
hyperparam_mutations, {"a": 1}, resample_probability=0
|
|
)
|
|
assert operations["a"] in ["shift left (noop)", "shift right (noop)"]
|
|
|
|
# 5. Test that missing keys in inputs raises an error
|
|
with self.assertRaises(AssertionError):
|
|
scheduler._summarize_hyperparam_changes(
|
|
{"a": 1, "b": {"c": 2}},
|
|
{"a": 1, "b": {}},
|
|
{"a": "noop", "b": {"c": "noop"}},
|
|
)
|
|
# It's ok to have missing operations (just fill in the ones that are present)
|
|
scheduler._summarize_hyperparam_changes(
|
|
{"a": 1, "b": {"c": 2}}, {"a": 1, "b": {"c": 2}}, {"a": "noop"}
|
|
)
|
|
scheduler._summarize_hyperparam_changes(
|
|
{"a": 1, "b": {"c": 2}}, {"a": 1, "b": {"c": 2}}, {}
|
|
)
|
|
|
|
# Make sure that perturbation and logging work with extra keys that aren't
|
|
# included in hyperparam_mutations (both should ignore the keys)
|
|
hyperparam_mutations = {
|
|
"train_loop_config": {
|
|
"lr": tune.uniform(0, 1),
|
|
"momentum": tune.uniform(0, 1),
|
|
}
|
|
}
|
|
test_config(
|
|
hyperparam_mutations,
|
|
{
|
|
"train_loop_config": {
|
|
"lr": 0.1,
|
|
"momentum": 0.9,
|
|
"batch_size": 32,
|
|
"test_mode": True,
|
|
}
|
|
},
|
|
resample_probability=0,
|
|
)
|
|
|
|
|
|
class DummyState:
|
|
def __init__(self, last_score):
|
|
self.last_score = last_score
|
|
|
|
|
|
class PopulationBasedTrainingNanScoreTest(unittest.TestCase):
|
|
def test_pbt_with_nan_scores(self):
|
|
# Create three trials: one with nan, two with valid scores
|
|
t1 = DummyTrial("t1", config=MagicMock())
|
|
t2 = DummyTrial("t2", config=MagicMock())
|
|
t3 = DummyTrial("t3", config=MagicMock())
|
|
|
|
for scheduler_class in (PopulationBasedTraining, PB2):
|
|
# Patch _trial_state with dummy states
|
|
# Note: list.sort does not change the order if nan is present
|
|
max_states = {
|
|
t1: DummyState(last_score=20.0),
|
|
t2: DummyState(last_score=float("nan")),
|
|
t3: DummyState(last_score=10.0),
|
|
}
|
|
min_states = {
|
|
t3: DummyState(last_score=10.0),
|
|
t2: DummyState(last_score=float("nan")),
|
|
t1: DummyState(last_score=20.0),
|
|
}
|
|
with self.subTest(scheduler_class=scheduler_class.__name__):
|
|
if scheduler_class is PopulationBasedTraining:
|
|
hp_kwargs = {"hyperparam_mutations": {"lr": [1e-4, 1e-3]}}
|
|
else:
|
|
hp_kwargs = {"hyperparam_bounds": {"lr": [1e-4, 1e-3]}}
|
|
# test max mode
|
|
max_scheduler = scheduler_class(
|
|
metric="reward",
|
|
mode="max",
|
|
quantile_fraction=0.5,
|
|
**hp_kwargs,
|
|
)
|
|
max_scheduler._trial_state = max_states
|
|
for t, state in max_states.items():
|
|
max_scheduler._save_trial_state(
|
|
state, 100, {"reward": state.last_score, "time_total_s": 1}, t
|
|
)
|
|
|
|
# Should not raise, but nan disrupts sorting
|
|
max_bottom, max_top = max_scheduler._quantiles()
|
|
max_other_trials = [
|
|
t
|
|
for t in max_scheduler._trial_state
|
|
if t not in max_bottom + max_top
|
|
]
|
|
max_ordered_results = [
|
|
max_scheduler._trial_state[t].last_score
|
|
for t in [*max_bottom, *max_other_trials, *max_top]
|
|
]
|
|
|
|
self.assertIn(t1, max_top)
|
|
self.assertIn(t2, max_other_trials)
|
|
self.assertIn(t3, max_bottom)
|
|
self.assertEqual(max_ordered_results[-1], 20)
|
|
|
|
# Test min mode
|
|
min_scheduler = scheduler_class(
|
|
metric="reward",
|
|
mode="min",
|
|
quantile_fraction=0.5,
|
|
**hp_kwargs,
|
|
)
|
|
min_scheduler._trial_state = min_states
|
|
for t, state in min_states.items():
|
|
min_scheduler._save_trial_state(
|
|
state, 100, {"reward": state.last_score, "time_total_s": 1}, t
|
|
)
|
|
min_bottom, min_top = min_scheduler._quantiles()
|
|
min_other_trials = [
|
|
t
|
|
for t in min_scheduler._trial_state
|
|
if t not in min_bottom + min_top
|
|
]
|
|
min_ordered_results = [
|
|
min_scheduler._trial_state[t].last_score
|
|
for t in [*min_bottom, *min_other_trials, *min_top]
|
|
]
|
|
|
|
self.assertIn(t1, min_bottom)
|
|
self.assertIn(t2, min_other_trials)
|
|
self.assertIn(t3, min_top)
|
|
self.assertEqual(abs(min_ordered_results[-1]), 10)
|
|
|
|
|
|
def _create_pb2_scheduler(
|
|
metric="score",
|
|
mode="max",
|
|
perturbation_interval=1,
|
|
hyperparam_bounds=None,
|
|
custom_explore_fn=None,
|
|
) -> PB2:
|
|
hyperparam_bounds = hyperparam_bounds or {"a": [0.0, 1.0]}
|
|
return PB2(
|
|
metric=metric,
|
|
mode=mode,
|
|
time_attr="training_iteration",
|
|
perturbation_interval=perturbation_interval,
|
|
quantile_fraction=0.25,
|
|
hyperparam_bounds=hyperparam_bounds,
|
|
custom_explore_fn=custom_explore_fn,
|
|
)
|
|
|
|
|
|
def _save_trial_result(scheduler: PB2, trial: Trial, time: int, result: dict):
|
|
scheduler._save_trial_state(scheduler._trial_state[trial], time, result, trial)
|
|
|
|
|
|
def _result(time: int, val: float) -> dict:
|
|
"""Creates a dummy Tune result to report."""
|
|
return {"training_iteration": time, "score": val}
|
|
|
|
|
|
def test_pb2_perturbation(monkeypatch):
|
|
hyperparam_bounds = {"a": [1.0, 2.0]}
|
|
pb2 = _create_pb2_scheduler(
|
|
metric="score", mode="max", hyperparam_bounds=hyperparam_bounds
|
|
)
|
|
|
|
mock_runner = MagicMock()
|
|
|
|
# One trial at each end of the hyperparam bounds, one performing better than the
|
|
# other. We expect a perturbed value to be closer to the better performing one.
|
|
trials = [
|
|
Trial("pb2_test", stub=True, config={"a": 1.0}),
|
|
Trial("pb2_test", stub=True, config={"a": 2.0}),
|
|
]
|
|
for trial in trials:
|
|
pb2.on_trial_add(mock_runner, trial)
|
|
|
|
# Collect 10 timesteps of data
|
|
# PB2 fits a model to estimate the increase in score between timesteps
|
|
# Each timestep, trial 1's score increases by 10, trial 2's score increases by 20
|
|
for t in range(1, 11):
|
|
for i, trial in enumerate(trials):
|
|
_save_trial_result(pb2, trial, t, _result(time=t, val=t * (i + 1) * 10))
|
|
|
|
# Ignoring variance (kappa=0) and only optimizing for exploitation,
|
|
# we expect the next point suggested to be close to higher-performing trial
|
|
monkeypatch.setattr(ray.tune.schedulers.pb2_utils, "UCB", partial(UCB, kappa=0.0))
|
|
new_config, _ = pb2._get_new_config(trials[0], trials[1])
|
|
assert new_config["a"] > 1.5
|
|
assert pb2._quantiles() == ([trials[0]], [trials[1]])
|
|
|
|
|
|
def test_pb2_nested_hyperparams():
|
|
"""Test that PB2 with nested hyperparams behaves the same as without nesting."""
|
|
hyperparam_bounds = {"a": [1.0, 2.0], "b": {"c": [2.0, 4.0], "d": [4.0, 10.0]}}
|
|
pb2_nested = _create_pb2_scheduler(
|
|
metric="score",
|
|
mode="max",
|
|
hyperparam_bounds=hyperparam_bounds,
|
|
)
|
|
pb2_flat = _create_pb2_scheduler(
|
|
metric="score",
|
|
mode="max",
|
|
hyperparam_bounds=flatten_dict(hyperparam_bounds, delimiter=""),
|
|
)
|
|
|
|
mock_runner = MagicMock()
|
|
|
|
trials_nested = [Trial("pb2_test", stub=True) for _ in range(3)]
|
|
trials_flat = [Trial("pb2_test", stub=True) for _ in range(3)]
|
|
|
|
np.random.seed(2023)
|
|
|
|
for trial_nested, trial_flat in zip(trials_nested, trials_flat):
|
|
pb2_nested.on_trial_add(mock_runner, trial_nested)
|
|
# Let PB2 generate the initial config randomly, then use the same
|
|
# initial values for the flattened version
|
|
flattened_init_config = flatten_dict(trial_nested.config, delimiter="")
|
|
trial_flat.config = flattened_init_config
|
|
pb2_flat.on_trial_add(mock_runner, trial_flat)
|
|
|
|
# Make sure that config suggestions are the same for each timestep
|
|
for t in range(1, 10):
|
|
for i, (trial_nested, trial_flat) in enumerate(zip(trials_nested, trials_flat)):
|
|
res = _result(time=t, val=t * (i + 1) * 10)
|
|
_save_trial_result(pb2_nested, trial_nested, t, res)
|
|
_save_trial_result(pb2_flat, trial_flat, t, res)
|
|
|
|
# Reset seed before each _get_new_config call so both schedulers
|
|
# get the same random sequence in optimize_acq
|
|
np.random.seed(2023 + t)
|
|
new_config, _ = pb2_nested._get_new_config(trials_nested[0], trials_nested[-1])
|
|
np.random.seed(2023 + t)
|
|
new_config_flat, _ = pb2_flat._get_new_config(trials_flat[0], trials_flat[-1])
|
|
|
|
# Make sure the suggested config is still nested properly
|
|
assert list(new_config.keys()) == ["a", "b"]
|
|
assert list(new_config["b"].keys()) == ["c", "d"]
|
|
assert np.allclose(
|
|
list(flatten_dict(new_config, delimiter="").values()),
|
|
list(new_config_flat.values()),
|
|
)
|
|
|
|
|
|
def test_pb2_missing_hyperparam_init():
|
|
"""Test that PB2 fills in all missing hyperparameters (those that are not
|
|
specified in param_space)."""
|
|
hyperparam_bounds = {"a": [1.0, 2.0], "b": {"c": [2.0, 4.0], "d": [4.0, 10.0]}}
|
|
pb2 = _create_pb2_scheduler(hyperparam_bounds=hyperparam_bounds)
|
|
mock_runner = MagicMock()
|
|
|
|
def validate_config(config, bounds):
|
|
for param, bound in bounds.items():
|
|
if isinstance(bound, dict):
|
|
validate_config(config[param], bound)
|
|
else:
|
|
low, high = bound
|
|
assert config[param] >= low and config[param] < high
|
|
|
|
trial = Trial("test_pb2", stub=True)
|
|
pb2.on_trial_add(mock_runner, trial)
|
|
validate_config(trial.config, hyperparam_bounds)
|
|
|
|
trial = Trial("test_pb2", stub=True, config={"b": {"c": 3.0}})
|
|
pb2.on_trial_add(mock_runner, trial)
|
|
validate_config(trial.config, hyperparam_bounds)
|
|
assert trial.config["b"]["c"] == 3.0
|
|
|
|
|
|
def test_pb2_hyperparam_bounds_validation():
|
|
"""Check that hyperparam bounds are validated (must be tuples of [low, high])."""
|
|
# Too many values
|
|
hyperparam_bounds = {"a": [1.0, 2.0], "b": {"c": [2.0, 4.0, 6.0]}}
|
|
with pytest.raises(ValueError):
|
|
_create_pb2_scheduler(hyperparam_bounds=hyperparam_bounds)
|
|
|
|
# Ordering is wrong
|
|
hyperparam_bounds = {"a": [1.0, 2.0], "b": {"c": [4.0, 2.0]}}
|
|
with pytest.raises(ValueError):
|
|
_create_pb2_scheduler(hyperparam_bounds=hyperparam_bounds)
|
|
|
|
|
|
def test_pb2_custom_explore_fn():
|
|
"""Test custom post-processing on the config generated by PB2."""
|
|
hyperparam_bounds = {"a": [1.0, 2.0], "b": {"c": [2.0, 4.0], "d": [4.0, 10.0]}}
|
|
|
|
def explore(config):
|
|
config["b"]["c"] = int(config["b"]["c"])
|
|
return config
|
|
|
|
pb2 = _create_pb2_scheduler(
|
|
hyperparam_bounds=hyperparam_bounds,
|
|
custom_explore_fn=explore,
|
|
)
|
|
mock_runner = MagicMock()
|
|
trial = Trial("test_pb2", stub=True)
|
|
pb2.on_trial_add(mock_runner, trial)
|
|
_save_trial_result(pb2, trial, 1, _result(time=1, val=10))
|
|
new_config, _ = pb2._get_new_config(trial, trial)
|
|
assert isinstance(new_config["b"]["c"], int)
|
|
|
|
|
|
def test_pb2_custom_explore_fn_lambda():
|
|
"""Test that a PB2 scheduler with a lambda explore fn can be serialized."""
|
|
hyperparam_bounds = {"a": [1.0, 2.0], "b": {"c": [2.0, 4.0], "d": [4.0, 10.0]}}
|
|
|
|
pb2 = _create_pb2_scheduler(
|
|
hyperparam_bounds=hyperparam_bounds,
|
|
custom_explore_fn=lambda config: config,
|
|
)
|
|
cloudpickle.dumps(pb2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", __file__]))
|