chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from ray._common.utils import get_function_args
|
||||
from ray.tune.schedulers.async_hyperband import ASHAScheduler, AsyncHyperBandScheduler
|
||||
from ray.tune.schedulers.hb_bohb import HyperBandForBOHB
|
||||
from ray.tune.schedulers.hyperband import HyperBandScheduler
|
||||
from ray.tune.schedulers.median_stopping_rule import MedianStoppingRule
|
||||
from ray.tune.schedulers.pbt import (
|
||||
PopulationBasedTraining,
|
||||
PopulationBasedTrainingReplay,
|
||||
)
|
||||
from ray.tune.schedulers.resource_changing_scheduler import ResourceChangingScheduler
|
||||
from ray.tune.schedulers.trial_scheduler import FIFOScheduler, TrialScheduler
|
||||
from ray.util import PublicAPI
|
||||
|
||||
|
||||
def _pb2_importer():
|
||||
# PB2 is imported lazily since it has additional dependencies.
|
||||
from ray.tune.schedulers.pb2 import PB2
|
||||
|
||||
return PB2
|
||||
|
||||
|
||||
# Values in this dictionary will be one two kinds:
|
||||
# class of the scheduler object to create
|
||||
# wrapper function to support a lazy import of the scheduler class
|
||||
SCHEDULER_IMPORT = {
|
||||
"fifo": FIFOScheduler,
|
||||
"async_hyperband": AsyncHyperBandScheduler,
|
||||
"asynchyperband": AsyncHyperBandScheduler,
|
||||
"median_stopping_rule": MedianStoppingRule,
|
||||
"medianstopping": MedianStoppingRule,
|
||||
"hyperband": HyperBandScheduler,
|
||||
"hb_bohb": HyperBandForBOHB,
|
||||
"pbt": PopulationBasedTraining,
|
||||
"pbt_replay": PopulationBasedTrainingReplay,
|
||||
"pb2": _pb2_importer,
|
||||
"resource_changing": ResourceChangingScheduler,
|
||||
}
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
def create_scheduler(
|
||||
scheduler: str,
|
||||
**kwargs: Any,
|
||||
) -> TrialScheduler:
|
||||
"""Instantiate a scheduler based on the given string.
|
||||
|
||||
This is useful for swapping between different schedulers.
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to use.
|
||||
**kwargs: Scheduler parameters.
|
||||
These keyword arguments will be passed to the initialization
|
||||
function of the chosen scheduler.
|
||||
Returns:
|
||||
ray.tune.schedulers.trial_scheduler.TrialScheduler: The scheduler.
|
||||
Example:
|
||||
>>> from ray import tune
|
||||
>>> pbt_kwargs = {}
|
||||
>>> scheduler = tune.create_scheduler('pbt', **pbt_kwargs) # doctest: +SKIP
|
||||
"""
|
||||
|
||||
scheduler = scheduler.lower()
|
||||
if scheduler not in SCHEDULER_IMPORT:
|
||||
raise ValueError(
|
||||
f"The `scheduler` argument must be one of "
|
||||
f"{list(SCHEDULER_IMPORT)}. "
|
||||
f"Got: {scheduler}"
|
||||
)
|
||||
|
||||
SchedulerClass = SCHEDULER_IMPORT[scheduler]
|
||||
|
||||
if inspect.isfunction(SchedulerClass):
|
||||
# invoke the wrapper function to retrieve class
|
||||
SchedulerClass = SchedulerClass()
|
||||
|
||||
scheduler_args = get_function_args(SchedulerClass)
|
||||
trimmed_kwargs = {k: v for k, v in kwargs.items() if k in scheduler_args}
|
||||
|
||||
return SchedulerClass(**trimmed_kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TrialScheduler",
|
||||
"HyperBandScheduler",
|
||||
"AsyncHyperBandScheduler",
|
||||
"ASHAScheduler",
|
||||
"MedianStoppingRule",
|
||||
"FIFOScheduler",
|
||||
"PopulationBasedTraining",
|
||||
"PopulationBasedTrainingReplay",
|
||||
"HyperBandForBOHB",
|
||||
"ResourceChangingScheduler",
|
||||
]
|
||||
@@ -0,0 +1,291 @@
|
||||
import logging
|
||||
import pickle
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.result import DEFAULT_METRIC
|
||||
from ray.tune.schedulers.trial_scheduler import FIFOScheduler, TrialScheduler
|
||||
from ray.util import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@PublicAPI
|
||||
class AsyncHyperBandScheduler(FIFOScheduler):
|
||||
"""Implements the Async Successive Halving.
|
||||
|
||||
This should provide similar theoretical performance as HyperBand but
|
||||
avoid straggler issues that HyperBand faces. One implementation detail
|
||||
is when using multiple brackets, trial allocation to bracket is done
|
||||
randomly with over a softmax probability.
|
||||
|
||||
See https://arxiv.org/abs/1810.05934
|
||||
|
||||
Args:
|
||||
time_attr: A training result attr to use for comparing time.
|
||||
Note that you can pass in something non-temporal such as
|
||||
`training_iteration` as a measure of progress, the only requirement
|
||||
is that the attribute should increase monotonically.
|
||||
Valid values are any key reported in the result dict by your
|
||||
trainable. The auto-filled keys ``"training_iteration"`` (the
|
||||
iteration count) and ``"time_total_s"`` (wall-clock seconds since
|
||||
the trial started) always work; any additional numeric, monotonic
|
||||
key your trainable reports via ``tune.report({...})`` is also valid
|
||||
(for example ``"timesteps_total"`` or a custom progress counter).
|
||||
Passing a key that is not present in the reported result causes
|
||||
the scheduler to skip its decision for that step.
|
||||
metric: The training result objective value attribute. Stopping
|
||||
procedures will use this attribute. If None but a mode was passed,
|
||||
the `ray.tune.result.DEFAULT_METRIC` will be used per default.
|
||||
mode: One of {min, max}. Determines whether objective is
|
||||
minimizing or maximizing the metric attribute.
|
||||
max_t: max time units per trial. Trials will be stopped after
|
||||
max_t time units (determined by time_attr) have passed.
|
||||
grace_period: Only stop trials at least this old in time.
|
||||
The units are the same as the attribute named by `time_attr`.
|
||||
reduction_factor: Used to set halving rate and amount. This
|
||||
is simply a unit-less scalar.
|
||||
brackets: Number of brackets. Each bracket has a different
|
||||
halving rate, specified by the reduction factor.
|
||||
stop_last_trials: Whether to terminate the trials after
|
||||
reaching max_t. Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_attr: str = "training_iteration",
|
||||
metric: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
max_t: int = 100,
|
||||
grace_period: int = 1,
|
||||
reduction_factor: float = 4,
|
||||
brackets: int = 1,
|
||||
stop_last_trials: bool = True,
|
||||
):
|
||||
assert max_t > 0, "Max (time_attr) not valid!"
|
||||
assert max_t >= grace_period, "grace_period must be <= max_t!"
|
||||
assert grace_period > 0, "grace_period must be positive!"
|
||||
assert reduction_factor > 1, "Reduction Factor not valid!"
|
||||
assert brackets > 0, "brackets must be positive!"
|
||||
if mode:
|
||||
assert mode in ["min", "max"], "`mode` must be 'min' or 'max'!"
|
||||
|
||||
super().__init__()
|
||||
self._reduction_factor = reduction_factor
|
||||
self._max_t = max_t
|
||||
|
||||
self._trial_info = {} # Stores Trial -> Bracket
|
||||
|
||||
# Tracks state for new trial add
|
||||
self._brackets = [
|
||||
_Bracket(
|
||||
grace_period,
|
||||
max_t,
|
||||
reduction_factor,
|
||||
s,
|
||||
stop_last_trials=stop_last_trials,
|
||||
)
|
||||
for s in range(brackets)
|
||||
]
|
||||
self._counter = 0 # for
|
||||
self._num_stopped = 0
|
||||
self._metric = metric
|
||||
self._mode = mode
|
||||
self._metric_op = None
|
||||
if self._mode == "max":
|
||||
self._metric_op = 1.0
|
||||
elif self._mode == "min":
|
||||
self._metric_op = -1.0
|
||||
self._time_attr = time_attr
|
||||
self._stop_last_trials = stop_last_trials
|
||||
|
||||
def set_search_properties(
|
||||
self, metric: Optional[str], mode: Optional[str], **spec
|
||||
) -> bool:
|
||||
if self._metric and metric:
|
||||
return False
|
||||
if self._mode and mode:
|
||||
return False
|
||||
|
||||
if metric:
|
||||
self._metric = metric
|
||||
if mode:
|
||||
self._mode = mode
|
||||
|
||||
if self._mode == "max":
|
||||
self._metric_op = 1.0
|
||||
elif self._mode == "min":
|
||||
self._metric_op = -1.0
|
||||
|
||||
if self._metric is None and self._mode:
|
||||
# If only a mode was passed, use anonymous metric
|
||||
self._metric = DEFAULT_METRIC
|
||||
|
||||
return True
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
if not self._metric or not self._metric_op:
|
||||
raise ValueError(
|
||||
"{} has been instantiated without a valid `metric` ({}) or "
|
||||
"`mode` ({}) parameter. Either pass these parameters when "
|
||||
"instantiating the scheduler, or pass them as parameters "
|
||||
"to `tune.TuneConfig()`".format(
|
||||
self.__class__.__name__, self._metric, self._mode
|
||||
)
|
||||
)
|
||||
|
||||
sizes = np.array([len(b._rungs) for b in self._brackets])
|
||||
probs = np.e ** (sizes - sizes.max())
|
||||
normalized = probs / probs.sum()
|
||||
idx = np.random.choice(len(self._brackets), p=normalized)
|
||||
self._trial_info[trial.trial_id] = self._brackets[idx]
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> str:
|
||||
action = TrialScheduler.CONTINUE
|
||||
if self._time_attr not in result or self._metric not in result:
|
||||
return action
|
||||
if result[self._time_attr] >= self._max_t and self._stop_last_trials:
|
||||
action = TrialScheduler.STOP
|
||||
else:
|
||||
bracket = self._trial_info[trial.trial_id]
|
||||
action = bracket.on_result(
|
||||
trial, result[self._time_attr], self._metric_op * result[self._metric]
|
||||
)
|
||||
if action == TrialScheduler.STOP:
|
||||
self._num_stopped += 1
|
||||
return action
|
||||
|
||||
def on_trial_complete(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
):
|
||||
if self._time_attr not in result or self._metric not in result:
|
||||
return
|
||||
bracket = self._trial_info[trial.trial_id]
|
||||
bracket.on_result(
|
||||
trial, result[self._time_attr], self._metric_op * result[self._metric]
|
||||
)
|
||||
del self._trial_info[trial.trial_id]
|
||||
|
||||
def on_trial_remove(self, tune_controller: "TuneController", trial: Trial):
|
||||
del self._trial_info[trial.trial_id]
|
||||
|
||||
def debug_string(self) -> str:
|
||||
out = "Using AsyncHyperBand: num_stopped={}".format(self._num_stopped)
|
||||
out += "\n" + "\n".join([b.debug_str() for b in self._brackets])
|
||||
return out
|
||||
|
||||
def save(self, checkpoint_path: str):
|
||||
save_object = self.__dict__
|
||||
with open(checkpoint_path, "wb") as outputFile:
|
||||
pickle.dump(save_object, outputFile)
|
||||
|
||||
def restore(self, checkpoint_path: str):
|
||||
with open(checkpoint_path, "rb") as inputFile:
|
||||
save_object = pickle.load(inputFile)
|
||||
self.__dict__.update(save_object)
|
||||
|
||||
|
||||
class _Bracket:
|
||||
"""Bookkeeping system to track the cutoffs.
|
||||
|
||||
Rungs are created in reversed order so that we can more easily find
|
||||
the correct rung corresponding to the current iteration of the result.
|
||||
|
||||
Example:
|
||||
>>> trial1, trial2, trial3 = ... # doctest: +SKIP
|
||||
>>> b = _Bracket(1, 10, 2, 0) # doctest: +SKIP
|
||||
>>> # CONTINUE
|
||||
>>> b.on_result(trial1, 1, 2) # doctest: +SKIP
|
||||
>>> # CONTINUE
|
||||
>>> b.on_result(trial2, 1, 4) # doctest: +SKIP
|
||||
>>> # rungs are reversed
|
||||
>>> b.cutoff(b._rungs[-1][1]) == 3.0 # doctest: +SKIP
|
||||
# STOP
|
||||
>>> b.on_result(trial3, 1, 1) # doctest: +SKIP
|
||||
>>> b.cutoff(b._rungs[3][1]) == 2.0 # doctest: +SKIP
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_t: int,
|
||||
max_t: int,
|
||||
reduction_factor: float,
|
||||
s: int,
|
||||
stop_last_trials: bool = True,
|
||||
):
|
||||
"""Initialize a bracket of the asynchronous successive halving algorithm.
|
||||
|
||||
Args:
|
||||
min_t: Minimum number of iterations before a trial can be stopped.
|
||||
max_t: Maximum number of iterations per trial.
|
||||
reduction_factor: Halving rate used to derive rung spacing.
|
||||
s: Bracket index, used to offset the first rung.
|
||||
stop_last_trials: If True, allow trials that survive the final rung
|
||||
to still be stopped by the bracket.
|
||||
"""
|
||||
self.rf = reduction_factor
|
||||
MAX_RUNGS = int(np.log(max_t / min_t) / np.log(self.rf) - s + 1)
|
||||
self._rungs = [
|
||||
(min_t * self.rf ** (k + s), {}) for k in reversed(range(MAX_RUNGS))
|
||||
]
|
||||
self._stop_last_trials = stop_last_trials
|
||||
|
||||
def cutoff(self, recorded) -> Optional[Union[int, float, complex, np.ndarray]]:
|
||||
if not recorded:
|
||||
return None
|
||||
return np.nanpercentile(list(recorded.values()), (1 - 1 / self.rf) * 100)
|
||||
|
||||
def on_result(self, trial: Trial, cur_iter: int, cur_rew: Optional[float]) -> str:
|
||||
action = TrialScheduler.CONTINUE
|
||||
for milestone, recorded in self._rungs:
|
||||
if (
|
||||
cur_iter >= milestone
|
||||
and trial.trial_id in recorded
|
||||
and not self._stop_last_trials
|
||||
):
|
||||
# If our result has been recorded for this trial already, the
|
||||
# decision to continue training has already been made. Thus we can
|
||||
# skip new cutoff calculation and just continue training.
|
||||
# We can also break as milestones are descending.
|
||||
break
|
||||
if cur_iter < milestone or trial.trial_id in recorded:
|
||||
continue
|
||||
else:
|
||||
cutoff = self.cutoff(recorded)
|
||||
if cutoff is not None and cur_rew < cutoff:
|
||||
action = TrialScheduler.STOP
|
||||
if cur_rew is None:
|
||||
logger.warning(
|
||||
"Reward attribute is None! Consider"
|
||||
" reporting using a different field."
|
||||
)
|
||||
else:
|
||||
recorded[trial.trial_id] = cur_rew
|
||||
break
|
||||
return action
|
||||
|
||||
def debug_str(self) -> str:
|
||||
# TODO: fix up the output for this
|
||||
iters = " | ".join(
|
||||
[
|
||||
"Iter {:.3f}: {}".format(milestone, self.cutoff(recorded))
|
||||
for milestone, recorded in self._rungs
|
||||
]
|
||||
)
|
||||
return "Bracket: " + iters
|
||||
|
||||
|
||||
ASHAScheduler = AsyncHyperBandScheduler
|
||||
|
||||
if __name__ == "__main__":
|
||||
sched = AsyncHyperBandScheduler(grace_period=1, max_t=10, reduction_factor=2)
|
||||
print(sched.debug_string())
|
||||
bracket = sched._brackets[0]
|
||||
print(bracket.cutoff({str(i): i for i in range(20)}))
|
||||
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.schedulers.hyperband import HyperBandScheduler
|
||||
from ray.tune.schedulers.trial_scheduler import TrialScheduler
|
||||
from ray.util import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@PublicAPI
|
||||
class HyperBandForBOHB(HyperBandScheduler):
|
||||
"""Extends HyperBand early stopping algorithm for BOHB.
|
||||
|
||||
This implementation removes the ``HyperBandScheduler`` pipelining. This
|
||||
class introduces key changes:
|
||||
|
||||
1. Trials are now placed so that the bracket with the largest size is
|
||||
filled first.
|
||||
|
||||
2. Trials will be paused even if the bracket is not filled. This allows
|
||||
BOHB to insert new trials into the training.
|
||||
|
||||
See ray.tune.schedulers.HyperBandScheduler for parameter docstring.
|
||||
"""
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Adds new trial.
|
||||
|
||||
On a new trial add, if current bracket is not filled, add to current
|
||||
bracket. Else, if current band is not filled, create new bracket, add
|
||||
to current bracket. Else, create new iteration, create new bracket,
|
||||
add to bracket.
|
||||
"""
|
||||
if not self._metric or not self._metric_op:
|
||||
raise ValueError(
|
||||
"{} has been instantiated without a valid `metric` ({}) or "
|
||||
"`mode` ({}) parameter. Either pass these parameters when "
|
||||
"instantiating the scheduler, or pass them as parameters "
|
||||
"to `tune.TuneConfig()`".format(
|
||||
self.__class__.__name__, self._metric, self._mode
|
||||
)
|
||||
)
|
||||
|
||||
cur_bracket = self._state["bracket"]
|
||||
cur_band = self._hyperbands[self._state["band_idx"]]
|
||||
if cur_bracket is None or cur_bracket.filled():
|
||||
retry = True
|
||||
while retry:
|
||||
# if current iteration is filled, create new iteration
|
||||
if self._cur_band_filled():
|
||||
cur_band = []
|
||||
self._hyperbands.append(cur_band)
|
||||
self._state["band_idx"] += 1
|
||||
|
||||
# MAIN CHANGE HERE - largest bracket first!
|
||||
# cur_band will always be less than s_max_1 or else filled
|
||||
s = self._s_max_1 - len(cur_band) - 1
|
||||
assert s >= 0, "Current band is filled!"
|
||||
if self._get_r0(s) == 0:
|
||||
logger.debug("BOHB: Bracket too small - Retrying...")
|
||||
cur_bracket = None
|
||||
else:
|
||||
retry = False
|
||||
cur_bracket = self._create_bracket(s)
|
||||
cur_band.append(cur_bracket)
|
||||
self._state["bracket"] = cur_bracket
|
||||
|
||||
self._state["bracket"].add_trial(trial)
|
||||
self._trial_info[trial] = cur_bracket, self._state["band_idx"]
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> str:
|
||||
"""If bracket is finished, all trials will be stopped.
|
||||
|
||||
If a given trial finishes and bracket iteration is not done,
|
||||
the trial will be paused and resources will be given up.
|
||||
|
||||
This scheduler will not start trials but will stop trials.
|
||||
The current running trial will not be handled,
|
||||
as the trialrunner will be given control to handle it."""
|
||||
|
||||
result["hyperband_info"] = {}
|
||||
bracket, _ = self._trial_info[trial]
|
||||
bracket.update_trial_stats(trial, result)
|
||||
|
||||
if bracket.continue_trial(trial):
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
result["hyperband_info"]["budget"] = bracket._cumul_r
|
||||
|
||||
# MAIN CHANGE HERE!
|
||||
statuses = [(t, t.status) for t in bracket._live_trials]
|
||||
if not bracket.filled() or any(
|
||||
status != Trial.PAUSED for t, status in statuses if t is not trial
|
||||
):
|
||||
# BOHB Specific. This hack existed in old Ray versions
|
||||
# and was removed, but it needs to be brought back
|
||||
# as otherwise the BOHB doesn't behave as intended.
|
||||
# The default concurrency limiter works by discarding
|
||||
# new suggestions if there are more running trials
|
||||
# than the limit. That doesn't take into account paused
|
||||
# trials. With BOHB, this leads to N trials finishing
|
||||
# completely and then another N trials starting,
|
||||
# instead of trials being paused and resumed in brackets
|
||||
# as intended.
|
||||
# There should be a better API for this.
|
||||
# TODO(team-ml): Refactor alongside HyperBandForBOHB
|
||||
tune_controller.search_alg.searcher.on_pause(trial.trial_id)
|
||||
return TrialScheduler.PAUSE
|
||||
|
||||
logger.debug(f"Processing bracket after trial {trial} result")
|
||||
action = self._process_bracket(tune_controller, bracket)
|
||||
if action == TrialScheduler.PAUSE:
|
||||
tune_controller.search_alg.searcher.on_pause(trial.trial_id)
|
||||
return action
|
||||
|
||||
def _unpause_trial(self, tune_controller: "TuneController", trial: Trial):
|
||||
# Hack. See comment in on_trial_result
|
||||
tune_controller.search_alg.searcher.on_unpause(trial.trial_id)
|
||||
|
||||
def choose_trial_to_run(
|
||||
self, tune_controller: "TuneController", allow_recurse: bool = True
|
||||
) -> Optional[Trial]:
|
||||
"""Fair scheduling within iteration by completion percentage.
|
||||
|
||||
List of trials not used since all trials are tracked as state
|
||||
of scheduler. If iteration is occupied (ie, no trials to run),
|
||||
then look into next iteration.
|
||||
"""
|
||||
|
||||
for hyperband in self._hyperbands:
|
||||
# band will have None entries if no resources
|
||||
# are to be allocated to that bracket.
|
||||
scrubbed = [b for b in hyperband if b is not None]
|
||||
for bracket in scrubbed:
|
||||
for trial in bracket.current_trials():
|
||||
if (
|
||||
trial.status == Trial.PAUSED
|
||||
and trial in bracket.trials_to_unpause
|
||||
) or trial.status == Trial.PENDING:
|
||||
return trial
|
||||
# MAIN CHANGE HERE!
|
||||
if not any(t.status == Trial.RUNNING for t in tune_controller.get_trials()):
|
||||
for hyperband in self._hyperbands:
|
||||
for bracket in hyperband:
|
||||
if bracket and any(
|
||||
trial.status == Trial.PAUSED
|
||||
for trial in bracket.current_trials()
|
||||
):
|
||||
# This will change the trial state
|
||||
logger.debug("Processing bracket since no trial is running.")
|
||||
self._process_bracket(tune_controller, bracket)
|
||||
|
||||
# If there are pending trials now, suggest one.
|
||||
# This is because there might be both PENDING and
|
||||
# PAUSED trials now, and PAUSED trials will raise
|
||||
# an error before the trial runner tries again.
|
||||
if allow_recurse and any(
|
||||
(
|
||||
trial.status == Trial.PAUSED
|
||||
and trial in bracket.trials_to_unpause
|
||||
)
|
||||
or trial.status == Trial.PENDING
|
||||
for trial in bracket.current_trials()
|
||||
):
|
||||
return self.choose_trial_to_run(
|
||||
tune_controller, allow_recurse=False
|
||||
)
|
||||
# MAIN CHANGE HERE!
|
||||
return None
|
||||
@@ -0,0 +1,614 @@
|
||||
import collections
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.tune.error import TuneError
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.result import DEFAULT_METRIC
|
||||
from ray.tune.schedulers.trial_scheduler import FIFOScheduler, TrialScheduler
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Implementation notes:
|
||||
# This implementation contains 3 logical levels.
|
||||
# Each HyperBand iteration is a "band". There can be multiple
|
||||
# bands running at once, and there can be 1 band that is incomplete.
|
||||
#
|
||||
# In each band, there are at most `s` + 1 brackets.
|
||||
# `s` is a value determined by given parameters, and assigned on
|
||||
# a cyclic basis.
|
||||
#
|
||||
# In each bracket, there are at most `n(s)` trials, indicating that
|
||||
# `n` is a function of `s`. These trials go through a series of
|
||||
# halving procedures, dropping lowest performers. Multiple
|
||||
# brackets are running at once.
|
||||
#
|
||||
# Trials added will be inserted into the most recent bracket
|
||||
# and band and will spill over to new brackets/bands accordingly.
|
||||
#
|
||||
# This maintains the bracket size and max trial count per band
|
||||
# to 5 and 117 respectively, which correspond to that of
|
||||
# `max_attr=81, eta=3` from the blog post. Trials will fill up
|
||||
# from smallest bracket to largest, with largest
|
||||
# having the most rounds of successive halving.
|
||||
@PublicAPI
|
||||
class HyperBandScheduler(FIFOScheduler):
|
||||
"""Implements the HyperBand early stopping algorithm.
|
||||
|
||||
HyperBandScheduler early stops trials using the HyperBand optimization
|
||||
algorithm. It divides trials into brackets of varying sizes, and
|
||||
periodically early stops low-performing trials within each bracket.
|
||||
|
||||
To use this implementation of HyperBand with Tune, all you need
|
||||
to do is specify the max length of time a trial can run `max_t`, the time
|
||||
units `time_attr`, the name of the reported objective value `metric`,
|
||||
and if `metric` is to be maximized or minimized (`mode`).
|
||||
We automatically determine reasonable values for the other
|
||||
HyperBand parameters based on the given values.
|
||||
|
||||
For example, to limit trials to 10 minutes and early stop based on the
|
||||
`episode_mean_reward` attr, construct:
|
||||
|
||||
``HyperBand('time_total_s', 'episode_reward_mean', max_t=600)``
|
||||
|
||||
Note that Tune's stopping criteria will be applied in conjunction with
|
||||
HyperBand's early stopping mechanisms.
|
||||
|
||||
See also: https://blog.ml.cmu.edu/2018/12/12/massively-parallel-hyperparameter-optimization/
|
||||
|
||||
Args:
|
||||
time_attr: The training result attr to use for comparing time.
|
||||
Note that you can pass in something non-temporal such as
|
||||
`training_iteration` as a measure of progress, the only requirement
|
||||
is that the attribute should increase monotonically.
|
||||
Valid values are any key reported in the result dict by your
|
||||
trainable. The auto-filled keys ``"training_iteration"`` (the
|
||||
iteration count) and ``"time_total_s"`` (wall-clock seconds since
|
||||
the trial started) always work; any additional numeric, monotonic
|
||||
key your trainable reports via ``tune.report({...})`` is also valid
|
||||
(for example ``"timesteps_total"`` or a custom progress counter).
|
||||
Passing a key that is not present in the reported result causes
|
||||
the scheduler to skip its decision for that step.
|
||||
metric: The training result objective value attribute. Stopping
|
||||
procedures will use this attribute. If None but a mode was passed,
|
||||
the `ray.tune.result.DEFAULT_METRIC` will be used per default.
|
||||
mode: One of {min, max}. Determines whether objective is
|
||||
minimizing or maximizing the metric attribute.
|
||||
max_t: max time units per trial. Trials will be stopped after
|
||||
max_t time units (determined by time_attr) have passed.
|
||||
The scheduler will terminate trials after this time has passed.
|
||||
Note that this is different from the semantics of `max_t` as
|
||||
mentioned in the original HyperBand paper.
|
||||
reduction_factor: Same as `eta`. Determines how sharp
|
||||
the difference is between bracket space-time allocation ratios.
|
||||
stop_last_trials: Whether to terminate the trials after
|
||||
reaching max_t. Defaults to True.
|
||||
""" # noqa: E501
|
||||
|
||||
_supports_buffered_results = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_attr: str = "training_iteration",
|
||||
metric: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
max_t: int = 81,
|
||||
reduction_factor: float = 3,
|
||||
stop_last_trials: bool = True,
|
||||
):
|
||||
assert max_t > 0, "Max (time_attr) not valid!"
|
||||
if mode:
|
||||
assert mode in ["min", "max"], "`mode` must be 'min' or 'max'!"
|
||||
|
||||
super().__init__()
|
||||
self._eta = reduction_factor
|
||||
self._s_max_1 = int(np.round(np.log(max_t) / np.log(reduction_factor))) + 1
|
||||
self._max_t_attr = max_t
|
||||
# bracket max trials
|
||||
self._get_n0 = lambda s: int(np.ceil(self._s_max_1 / (s + 1) * self._eta**s))
|
||||
# bracket initial iterations
|
||||
self._get_r0 = lambda s: int((max_t * self._eta ** (-s)))
|
||||
self._hyperbands = [[]] # list of hyperband iterations
|
||||
self._trial_info = {} # Stores Trial -> Bracket, Band Iteration
|
||||
|
||||
# Tracks state for new trial add
|
||||
self._state = {"bracket": None, "band_idx": 0}
|
||||
self._num_stopped = 0
|
||||
self._metric = metric
|
||||
self._mode = mode
|
||||
self._metric_op = None
|
||||
|
||||
if self._mode == "max":
|
||||
self._metric_op = 1.0
|
||||
elif self._mode == "min":
|
||||
self._metric_op = -1.0
|
||||
self._time_attr = time_attr
|
||||
self._stop_last_trials = stop_last_trials
|
||||
|
||||
def set_search_properties(
|
||||
self, metric: Optional[str], mode: Optional[str], **spec
|
||||
) -> bool:
|
||||
if self._metric and metric:
|
||||
return False
|
||||
if self._mode and mode:
|
||||
return False
|
||||
|
||||
if metric:
|
||||
self._metric = metric
|
||||
if mode:
|
||||
self._mode = mode
|
||||
|
||||
if self._mode == "max":
|
||||
self._metric_op = 1.0
|
||||
elif self._mode == "min":
|
||||
self._metric_op = -1.0
|
||||
|
||||
if self._metric is None and self._mode:
|
||||
# If only a mode was passed, use anonymous metric
|
||||
self._metric = DEFAULT_METRIC
|
||||
|
||||
return True
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Adds new trial.
|
||||
|
||||
On a new trial add, if current bracket is not filled,
|
||||
add to current bracket. Else, if current band is not filled,
|
||||
create new bracket, add to current bracket.
|
||||
Else, create new iteration, create new bracket, add to bracket."""
|
||||
if not self._metric or not self._metric_op:
|
||||
raise ValueError(
|
||||
"{} has been instantiated without a valid `metric` ({}) or "
|
||||
"`mode` ({}) parameter. Either pass these parameters when "
|
||||
"instantiating the scheduler, or pass them as parameters "
|
||||
"to `tune.TuneConfig()`".format(
|
||||
self.__class__.__name__, self._metric, self._mode
|
||||
)
|
||||
)
|
||||
|
||||
cur_bracket = self._state["bracket"]
|
||||
cur_band = self._hyperbands[self._state["band_idx"]]
|
||||
if cur_bracket is None or cur_bracket.filled():
|
||||
retry = True
|
||||
while retry:
|
||||
# if current iteration is filled, create new iteration
|
||||
if self._cur_band_filled():
|
||||
cur_band = []
|
||||
self._hyperbands.append(cur_band)
|
||||
self._state["band_idx"] += 1
|
||||
|
||||
# cur_band will always be less than s_max_1 or else filled
|
||||
s = len(cur_band)
|
||||
assert s < self._s_max_1, "Current band is filled!"
|
||||
if self._get_r0(s) == 0:
|
||||
logger.info("Bracket too small - Retrying...")
|
||||
cur_bracket = None
|
||||
else:
|
||||
retry = False
|
||||
cur_bracket = self._create_bracket(s)
|
||||
cur_band.append(cur_bracket)
|
||||
self._state["bracket"] = cur_bracket
|
||||
|
||||
self._state["bracket"].add_trial(trial)
|
||||
self._trial_info[trial] = cur_bracket, self._state["band_idx"]
|
||||
|
||||
def _create_bracket(self, s):
|
||||
return _Bracket(
|
||||
time_attr=self._time_attr,
|
||||
max_trials=self._get_n0(s),
|
||||
init_t_attr=self._get_r0(s),
|
||||
max_t_attr=self._max_t_attr,
|
||||
eta=self._eta,
|
||||
s=s,
|
||||
stop_last_trials=self._stop_last_trials,
|
||||
)
|
||||
|
||||
def _cur_band_filled(self) -> bool:
|
||||
"""Checks if the current band is filled.
|
||||
|
||||
The size of the current band should be equal to s_max_1"""
|
||||
|
||||
cur_band = self._hyperbands[self._state["band_idx"]]
|
||||
return len(cur_band) == self._s_max_1
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
):
|
||||
"""If bracket is finished, all trials will be stopped.
|
||||
|
||||
If a given trial finishes and bracket iteration is not done,
|
||||
the trial will be paused and resources will be given up.
|
||||
|
||||
This scheduler will not start trials but will stop trials.
|
||||
The current running trial will not be handled,
|
||||
as the trialrunner will be given control to handle it."""
|
||||
|
||||
bracket, _ = self._trial_info[trial]
|
||||
bracket.update_trial_stats(trial, result)
|
||||
|
||||
if bracket.continue_trial(trial):
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
logger.debug(f"Processing bracket after trial {trial} result")
|
||||
action = self._process_bracket(tune_controller, bracket)
|
||||
logger.debug(
|
||||
f"{action} for {trial} on "
|
||||
f"{self._time_attr}={result.get(self._time_attr)}"
|
||||
)
|
||||
return action
|
||||
|
||||
def _process_bracket(
|
||||
self, tune_controller: "TuneController", bracket: "_Bracket"
|
||||
) -> str:
|
||||
"""This is called whenever a trial makes progress.
|
||||
|
||||
When all live trials in the bracket have no more iterations left,
|
||||
Trials will be successively halved. If bracket is done, all
|
||||
non-running trials will be stopped and cleaned up,
|
||||
and during each halving phase, bad trials will be stopped while good
|
||||
trials will return to "PENDING".
|
||||
|
||||
Note some implicit conditions here: In ``on_trial_result`` a trial is
|
||||
either continued (e.g. if it didn't reach the time threshold for the bracket)
|
||||
or this method (``_process_bracket``) is called. If there are other trials left
|
||||
that still haven't reached the threshold, the trial is PAUSED. This means
|
||||
that when the bracket is actually processed (``bracket.cur_iter_done``), there
|
||||
is at most one RUNNING trial (which is the trial that is currently processed)
|
||||
and the rest are either PAUSED (as explained above) or TERMINATED/ERRORED
|
||||
(if they finish separately).
|
||||
"""
|
||||
|
||||
action = TrialScheduler.PAUSE
|
||||
if bracket.cur_iter_done():
|
||||
if bracket.finished():
|
||||
bracket.cleanup_full(tune_controller)
|
||||
return TrialScheduler.STOP
|
||||
|
||||
bracket.is_being_processed = True
|
||||
|
||||
good, bad = bracket.successive_halving(self._metric, self._metric_op)
|
||||
|
||||
logger.debug(
|
||||
f"Processing {len(good)} good and {len(bad)} bad trials in "
|
||||
f"bracket {bracket}.\n"
|
||||
f"Good: {good}\nBad: {bad}"
|
||||
)
|
||||
|
||||
# kill bad trials
|
||||
self._num_stopped += len(bad)
|
||||
for t in bad:
|
||||
if t.status == Trial.PAUSED or t.is_saving:
|
||||
logger.debug(f"Stopping other trial {str(t)}")
|
||||
tune_controller.stop_trial(t)
|
||||
elif t.status == Trial.RUNNING:
|
||||
# See the docstring: There can only be at most one RUNNING
|
||||
# trial, which is the current trial.
|
||||
logger.debug(f"Stopping current trial {str(t)}")
|
||||
bracket.cleanup_trial(t)
|
||||
action = TrialScheduler.STOP
|
||||
else:
|
||||
# Trials cannot be ERROR/TERMINATED, as then they would have
|
||||
# been removed from the bracket (in `bracket.cleanup_trial`).
|
||||
# Trials cannot be PENDING, as then they wouldn't have reported
|
||||
# enough results to finish the bracket, and it wouldn't be
|
||||
# processed.
|
||||
raise TuneError(
|
||||
f"Trial with unexpected bad status encountered: "
|
||||
f"{str(t)} is {t.status}"
|
||||
)
|
||||
|
||||
# ready the good trials - if trial is too far ahead, don't continue
|
||||
for t in good:
|
||||
if bracket.continue_trial(t):
|
||||
# The scheduler should have cleaned up this trial already.
|
||||
assert t.status not in (Trial.ERROR, Trial.TERMINATED), (
|
||||
f"Good trial {t.trial_id} is in an invalid state: {t.status}\n"
|
||||
"Expected trial to be either PAUSED, PENDING, or RUNNING.\n"
|
||||
"If you encounter this, please file an issue on the Ray Github."
|
||||
)
|
||||
if t.status == Trial.PAUSED or t.is_saving:
|
||||
logger.debug(f"Unpausing trial {str(t)}")
|
||||
self._unpause_trial(tune_controller, t)
|
||||
bracket.trials_to_unpause.add(t)
|
||||
elif t.status == Trial.RUNNING:
|
||||
# See the docstring: There can only be at most one RUNNING
|
||||
# trial, which is the current trial.
|
||||
logger.debug(f"Continuing current trial {str(t)}")
|
||||
action = TrialScheduler.CONTINUE
|
||||
# else: PENDING trial (from a previous unpause) should stay as is.
|
||||
elif bracket.finished() and bracket.stop_last_trials:
|
||||
# Scheduler decides to not continue trial because the bracket
|
||||
# reached max_t. In this case, stop the trials
|
||||
if t.status == Trial.PAUSED or t.is_saving:
|
||||
logger.debug(f"Bracket finished. Stopping other trial {str(t)}")
|
||||
tune_controller.stop_trial(t)
|
||||
elif t.status == Trial.RUNNING:
|
||||
# See the docstring: There can only be at most one RUNNING
|
||||
# trial, which is the current trial.
|
||||
logger.debug(
|
||||
f"Bracket finished. Stopping current trial {str(t)}"
|
||||
)
|
||||
bracket.cleanup_trial(t)
|
||||
action = TrialScheduler.STOP
|
||||
return action
|
||||
|
||||
def _unpause_trial(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""No-op by default."""
|
||||
return
|
||||
|
||||
def on_trial_remove(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Notification when trial terminates.
|
||||
|
||||
Trial info is removed from bracket. Triggers halving if bracket is
|
||||
not finished."""
|
||||
bracket, _ = self._trial_info[trial]
|
||||
bracket.cleanup_trial(trial)
|
||||
if not bracket.finished() and not bracket.is_being_processed:
|
||||
logger.debug(f"Processing bracket after trial {trial} removed")
|
||||
self._process_bracket(tune_controller, bracket)
|
||||
|
||||
def on_trial_complete(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
):
|
||||
"""Cleans up trial info from bracket if trial completed early."""
|
||||
self.on_trial_remove(tune_controller, trial)
|
||||
|
||||
def on_trial_error(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Cleans up trial info from bracket if trial errored early."""
|
||||
self.on_trial_remove(tune_controller, trial)
|
||||
|
||||
def choose_trial_to_run(self, tune_controller: "TuneController") -> Optional[Trial]:
|
||||
"""Fair scheduling within iteration by completion percentage.
|
||||
|
||||
List of trials not used since all trials are tracked as state
|
||||
of scheduler. If iteration is occupied (ie, no trials to run),
|
||||
then look into next iteration.
|
||||
"""
|
||||
|
||||
for hyperband in self._hyperbands:
|
||||
# band will have None entries if no resources
|
||||
# are to be allocated to that bracket.
|
||||
scrubbed = [b for b in hyperband if b is not None]
|
||||
for bracket in sorted(scrubbed, key=lambda b: b.completion_percentage()):
|
||||
for trial in bracket.current_trials():
|
||||
if (
|
||||
trial.status == Trial.PAUSED
|
||||
and trial in bracket.trials_to_unpause
|
||||
) or trial.status == Trial.PENDING:
|
||||
return trial
|
||||
return None
|
||||
|
||||
def debug_string(self) -> str:
|
||||
"""This provides a progress notification for the algorithm.
|
||||
|
||||
For each bracket, the algorithm will output a string as follows:
|
||||
|
||||
Bracket(Max Size (n)=5, Milestone (r)=33, completed=14.6%):
|
||||
{PENDING: 2, RUNNING: 3, TERMINATED: 2}
|
||||
|
||||
"Max Size" indicates the max number of pending/running experiments
|
||||
set according to the Hyperband algorithm.
|
||||
|
||||
"Milestone" indicates the iterations a trial will run for before
|
||||
the next halving will occur.
|
||||
|
||||
"Completed" indicates an approximate progress metric. Some brackets,
|
||||
like ones that are unfilled, will not reach 100%.
|
||||
"""
|
||||
out = "Using HyperBand: "
|
||||
out += "num_stopped={} total_brackets={}".format(
|
||||
self._num_stopped, sum(len(band) for band in self._hyperbands)
|
||||
)
|
||||
for i, band in enumerate(self._hyperbands):
|
||||
out += "\nRound #{}:".format(i)
|
||||
for bracket in band:
|
||||
if bracket:
|
||||
out += "\n {}".format(bracket)
|
||||
return out
|
||||
|
||||
def state(self) -> Dict[str, int]:
|
||||
return {
|
||||
"num_brackets": sum(len(band) for band in self._hyperbands),
|
||||
"num_stopped": self._num_stopped,
|
||||
}
|
||||
|
||||
|
||||
class _Bracket:
|
||||
"""Logical object for tracking Hyperband bracket progress. Keeps track
|
||||
of proper parameters as designated by HyperBand.
|
||||
|
||||
Also keeps track of progress to ensure good scheduling.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_attr: str,
|
||||
max_trials: int,
|
||||
init_t_attr: int,
|
||||
max_t_attr: int,
|
||||
eta: float,
|
||||
s: int,
|
||||
stop_last_trials: bool = True,
|
||||
):
|
||||
self._live_trials = {} # maps trial -> current result
|
||||
self._all_trials = []
|
||||
self._time_attr = time_attr # attribute to
|
||||
|
||||
self._n = self._n0 = max_trials
|
||||
self._r = self._r0 = init_t_attr
|
||||
self._max_t_attr = max_t_attr
|
||||
self._cumul_r = self._r0
|
||||
|
||||
self._eta = eta
|
||||
self._halves = s
|
||||
|
||||
self._total_work = self._calculate_total_work(self._n0, self._r0, s)
|
||||
self._completed_progress = 0
|
||||
self.stop_last_trials = stop_last_trials
|
||||
self.is_being_processed = False
|
||||
|
||||
self.trials_to_unpause = set()
|
||||
|
||||
def add_trial(self, trial: Trial):
|
||||
"""Add trial to bracket assuming bracket is not filled.
|
||||
|
||||
At a later iteration, a newly added trial will be given equal
|
||||
opportunity to catch up."""
|
||||
assert not self.filled(), "Cannot add trial to filled bracket!"
|
||||
self._live_trials[trial] = None
|
||||
self._all_trials.append(trial)
|
||||
|
||||
def cur_iter_done(self) -> bool:
|
||||
"""Checks if all iterations have completed.
|
||||
|
||||
TODO(rliaw): also check that `t.iterations == self._r`"""
|
||||
return all(
|
||||
self._get_result_time(result) >= self._cumul_r
|
||||
for result in self._live_trials.values()
|
||||
)
|
||||
|
||||
def finished(self) -> bool:
|
||||
if not self.stop_last_trials:
|
||||
return False
|
||||
return self._halves == 0 and self.cur_iter_done()
|
||||
|
||||
def current_trials(self) -> List[Trial]:
|
||||
return list(self._live_trials)
|
||||
|
||||
def continue_trial(self, trial: Trial) -> bool:
|
||||
result = self._live_trials[trial]
|
||||
if not self.stop_last_trials and self._halves == 0:
|
||||
return True
|
||||
elif self._get_result_time(result) < self._cumul_r:
|
||||
logger.debug(
|
||||
f"Continuing trial {trial} as it hasn't reached the time threshold "
|
||||
f"{self._cumul_r}, yet."
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def filled(self) -> bool:
|
||||
"""Checks if bracket is filled.
|
||||
|
||||
Only let new trials be added at current level minimizing the need
|
||||
to backtrack and bookkeep previous medians."""
|
||||
|
||||
return len(self._live_trials) == self._n
|
||||
|
||||
def successive_halving(
|
||||
self, metric: str, metric_op: float
|
||||
) -> Tuple[List[Trial], List[Trial]]:
|
||||
if self._halves == 0 and not self.stop_last_trials:
|
||||
return self._live_trials, []
|
||||
assert self._halves > 0
|
||||
|
||||
# "Halving" is a misnomer. We're actually reducing by factor `eta`.
|
||||
self._halves -= 1
|
||||
|
||||
# If we had 8 trials in the bracket and eta=2, we will keep 4.
|
||||
# If we had 9 trials in the bracket and eta=3, we will keep 3.
|
||||
self._n = int(np.ceil(self._n / self._eta))
|
||||
|
||||
# Likewise, we increase the number of iterations until we process the bracket
|
||||
# again.
|
||||
# Remember r0 = max_t * self._eta ** (-s)
|
||||
# Let max_t=16, eta=2, s=1. Then r0=8, and we calculate r1=16.
|
||||
# Let max_t=16, eta=2, s=2. Then r0=4, and we calculate r1=8, r2=16.
|
||||
|
||||
# Let max_t=81, eta=3, s=1. Then r0=27, and we calculate r1=81.
|
||||
# Let max_t=81, eta=3, s=2. Then r0=9, and we calculate r1=27, r2=81.
|
||||
self._r *= self._eta
|
||||
self._r = int(min(self._r, self._max_t_attr))
|
||||
self._cumul_r = self._r
|
||||
sorted_trials = sorted(
|
||||
self._live_trials, key=lambda t: metric_op * self._live_trials[t][metric]
|
||||
)
|
||||
|
||||
good, bad = sorted_trials[-self._n :], sorted_trials[: -self._n]
|
||||
return good, bad
|
||||
|
||||
def update_trial_stats(self, trial: Trial, result: Dict):
|
||||
"""Update result for trial. Called after trial has finished
|
||||
an iteration - will decrement iteration count.
|
||||
|
||||
TODO(rliaw): The other alternative is to keep the trials
|
||||
in and make sure they're not set as pending later."""
|
||||
|
||||
assert trial in self._live_trials
|
||||
assert self._get_result_time(result) >= 0
|
||||
observed_time = self._get_result_time(result)
|
||||
last_observed = self._get_result_time(self._live_trials[trial])
|
||||
|
||||
delta = observed_time - last_observed
|
||||
if delta <= 0:
|
||||
logger.info(
|
||||
"Restoring from a previous point in time. "
|
||||
"Previous={}; Now={}".format(last_observed, observed_time)
|
||||
)
|
||||
self._completed_progress += delta
|
||||
self._live_trials[trial] = result
|
||||
self.trials_to_unpause.discard(trial)
|
||||
|
||||
def cleanup_trial(self, trial: Trial):
|
||||
"""Clean up statistics tracking for terminated trials (either by force
|
||||
or otherwise).
|
||||
|
||||
This may cause bad trials to continue for a long time, in the case
|
||||
where all the good trials finish early and there are only bad trials
|
||||
left in a bracket with a large max-iteration."""
|
||||
self._live_trials.pop(trial, None)
|
||||
|
||||
def cleanup_full(self, tune_controller: "TuneController"):
|
||||
"""Cleans up bracket after bracket is completely finished.
|
||||
|
||||
Lets the last trial continue to run until termination condition
|
||||
kicks in."""
|
||||
for trial in self.current_trials():
|
||||
if trial.status == Trial.PAUSED:
|
||||
tune_controller.stop_trial(trial)
|
||||
|
||||
def completion_percentage(self) -> float:
|
||||
"""Returns a progress metric.
|
||||
|
||||
This will not be always finish with 100 since dead trials
|
||||
are dropped."""
|
||||
if self.finished():
|
||||
return 1.0
|
||||
return min(self._completed_progress / self._total_work, 1.0)
|
||||
|
||||
def _get_result_time(self, result: Dict) -> float:
|
||||
if result is None:
|
||||
return 0
|
||||
return result[self._time_attr]
|
||||
|
||||
def _calculate_total_work(self, n: int, r: float, s: int):
|
||||
work = 0
|
||||
cumulative_r = r
|
||||
for _ in range(s + 1):
|
||||
work += int(n) * int(r)
|
||||
n /= self._eta
|
||||
n = int(np.ceil(n))
|
||||
r *= self._eta
|
||||
r = int(min(r, self._max_t_attr - cumulative_r))
|
||||
return work
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = ", ".join(
|
||||
[
|
||||
"Max Size (n)={}".format(self._n),
|
||||
"Milestone (r)={}".format(self._cumul_r),
|
||||
"completed={:.1%}".format(self.completion_percentage()),
|
||||
]
|
||||
)
|
||||
counts = collections.Counter([t.status for t in self._all_trials])
|
||||
trial_statuses = ", ".join(
|
||||
sorted("{}: {}".format(k, v) for k, v in counts.items())
|
||||
)
|
||||
return "Bracket({}): {{{}}} ".format(status, trial_statuses)
|
||||
@@ -0,0 +1,225 @@
|
||||
import collections
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.result import DEFAULT_METRIC
|
||||
from ray.tune.schedulers.trial_scheduler import FIFOScheduler, TrialScheduler
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@PublicAPI
|
||||
class MedianStoppingRule(FIFOScheduler):
|
||||
"""Implements the median stopping rule as described in the Vizier paper:
|
||||
|
||||
https://research.google.com/pubs/pub46180.html
|
||||
|
||||
Args:
|
||||
time_attr: The training result attr to use for comparing time.
|
||||
Note that you can pass in something non-temporal such as
|
||||
`training_iteration` as a measure of progress, the only requirement
|
||||
is that the attribute should increase monotonically.
|
||||
Valid values are any key reported in the result dict by your
|
||||
trainable. The auto-filled keys ``"training_iteration"`` (the
|
||||
iteration count) and ``"time_total_s"`` (wall-clock seconds since
|
||||
the trial started) always work; any additional numeric, monotonic
|
||||
key your trainable reports via ``tune.report({...})`` is also valid
|
||||
(for example ``"timesteps_total"`` or a custom progress counter).
|
||||
Passing a key that is not present in the reported result causes
|
||||
the scheduler to skip its decision for that step.
|
||||
metric: The training result objective value attribute. Stopping
|
||||
procedures will use this attribute. If None but a mode was passed,
|
||||
the `ray.tune.result.DEFAULT_METRIC` will be used per default.
|
||||
mode: One of {min, max}. Determines whether objective is
|
||||
minimizing or maximizing the metric attribute.
|
||||
grace_period: Only stop trials at least this old in time.
|
||||
The mean will only be computed from this time onwards. The units
|
||||
are the same as the attribute named by `time_attr`.
|
||||
min_samples_required: Minimum number of trials to compute median
|
||||
over.
|
||||
min_time_slice: Each trial runs at least this long before
|
||||
yielding (assuming it isn't stopped). Note: trials ONLY yield if
|
||||
there are not enough samples to evaluate performance for the
|
||||
current result AND there are other trials waiting to run.
|
||||
The units are the same as the attribute named by `time_attr`.
|
||||
hard_stop: If False, pauses trials instead of stopping
|
||||
them. When all other trials are complete, paused trials will be
|
||||
resumed and allowed to run FIFO.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_attr: str = "time_total_s",
|
||||
metric: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
grace_period: float = 60.0,
|
||||
min_samples_required: int = 3,
|
||||
min_time_slice: int = 0,
|
||||
hard_stop: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self._stopped_trials = set()
|
||||
self._grace_period = grace_period
|
||||
self._min_samples_required = min_samples_required
|
||||
self._min_time_slice = min_time_slice
|
||||
self._metric = metric
|
||||
self._worst = None
|
||||
self._compare_op = None
|
||||
|
||||
self._mode = mode
|
||||
if mode:
|
||||
assert mode in ["min", "max"], "`mode` must be 'min' or 'max'."
|
||||
self._worst = float("-inf") if self._mode == "max" else float("inf")
|
||||
self._compare_op = max if self._mode == "max" else min
|
||||
|
||||
self._time_attr = time_attr
|
||||
self._hard_stop = hard_stop
|
||||
self._trial_state = {}
|
||||
self._last_pause = collections.defaultdict(lambda: float("-inf"))
|
||||
self._results = collections.defaultdict(list)
|
||||
|
||||
def set_search_properties(
|
||||
self, metric: Optional[str], mode: Optional[str], **spec
|
||||
) -> bool:
|
||||
if self._metric and metric:
|
||||
return False
|
||||
if self._mode and mode:
|
||||
return False
|
||||
|
||||
if metric:
|
||||
self._metric = metric
|
||||
if mode:
|
||||
self._mode = mode
|
||||
|
||||
self._worst = float("-inf") if self._mode == "max" else float("inf")
|
||||
self._compare_op = max if self._mode == "max" else min
|
||||
|
||||
if self._metric is None and self._mode:
|
||||
# If only a mode was passed, use anonymous metric
|
||||
self._metric = DEFAULT_METRIC
|
||||
|
||||
return True
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
if not self._metric or not self._worst or not self._compare_op:
|
||||
raise ValueError(
|
||||
"{} has been instantiated without a valid `metric` ({}) or "
|
||||
"`mode` ({}) parameter. Either pass these parameters when "
|
||||
"instantiating the scheduler, or pass them as parameters "
|
||||
"to `tune.TuneConfig()`".format(
|
||||
self.__class__.__name__, self._metric, self._mode
|
||||
)
|
||||
)
|
||||
|
||||
super(MedianStoppingRule, self).on_trial_add(tune_controller, trial)
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> str:
|
||||
"""Callback for early stopping.
|
||||
|
||||
This stopping rule stops a running trial if the trial's best objective
|
||||
value by step `t` is strictly worse than the median of the running
|
||||
averages of all completed trials' objectives reported up to step `t`.
|
||||
"""
|
||||
if self._time_attr not in result or self._metric not in result:
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
if trial in self._stopped_trials:
|
||||
assert not self._hard_stop
|
||||
# Fall back to FIFO
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
time = result[self._time_attr]
|
||||
self._results[trial].append(result)
|
||||
|
||||
if time < self._grace_period:
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
trials = self._trials_beyond_time(time)
|
||||
trials.remove(trial)
|
||||
|
||||
if len(trials) < self._min_samples_required:
|
||||
action = self._on_insufficient_samples(tune_controller, trial, time)
|
||||
if action == TrialScheduler.PAUSE:
|
||||
self._last_pause[trial] = time
|
||||
action_str = "Yielding time to other trials."
|
||||
else:
|
||||
action_str = "Continuing anyways."
|
||||
logger.debug(
|
||||
"MedianStoppingRule: insufficient samples={} to evaluate "
|
||||
"trial {} at t={}. {}".format(
|
||||
len(trials), trial.trial_id, time, action_str
|
||||
)
|
||||
)
|
||||
return action
|
||||
|
||||
median_result = self._median_result(trials, time)
|
||||
best_result = self._best_result(trial)
|
||||
logger.debug(
|
||||
"Trial {} best res={} vs median res={} at t={}".format(
|
||||
trial, best_result, median_result, time
|
||||
)
|
||||
)
|
||||
|
||||
if self._compare_op(median_result, best_result) != best_result:
|
||||
logger.debug("MedianStoppingRule: early stopping {}".format(trial))
|
||||
self._stopped_trials.add(trial)
|
||||
if self._hard_stop:
|
||||
return TrialScheduler.STOP
|
||||
else:
|
||||
return TrialScheduler.PAUSE
|
||||
else:
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
def on_trial_complete(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
):
|
||||
self._results[trial].append(result)
|
||||
|
||||
def debug_string(self) -> str:
|
||||
return "Using MedianStoppingRule: num_stopped={}.".format(
|
||||
len(self._stopped_trials)
|
||||
)
|
||||
|
||||
def _on_insufficient_samples(
|
||||
self, tune_controller: "TuneController", trial: Trial, time: float
|
||||
) -> str:
|
||||
pause = time - self._last_pause[trial] > self._min_time_slice
|
||||
pause = pause and [
|
||||
t
|
||||
for t in tune_controller.get_live_trials()
|
||||
if t.status in (Trial.PENDING, Trial.PAUSED)
|
||||
]
|
||||
return TrialScheduler.PAUSE if pause else TrialScheduler.CONTINUE
|
||||
|
||||
def _trials_beyond_time(self, time: float) -> List[Trial]:
|
||||
trials = [
|
||||
trial
|
||||
for trial in self._results
|
||||
if self._results[trial][-1][self._time_attr] >= time
|
||||
]
|
||||
return trials
|
||||
|
||||
def _median_result(self, trials: List[Trial], time: float):
|
||||
return np.median([self._running_mean(trial, time) for trial in trials])
|
||||
|
||||
def _running_mean(self, trial: Trial, time: float) -> np.ndarray:
|
||||
results = self._results[trial]
|
||||
# TODO(ekl) we could do interpolation to be more precise, but for now
|
||||
# assume len(results) is large and the time diffs are roughly equal
|
||||
scoped_results = [
|
||||
r for r in results if self._grace_period <= r[self._time_attr] <= time
|
||||
]
|
||||
return np.mean([r[self._metric] for r in scoped_results])
|
||||
|
||||
def _best_result(self, trial):
|
||||
results = self._results[trial]
|
||||
return self._compare_op([r[self._metric] for r in results])
|
||||
@@ -0,0 +1,505 @@
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ray.tune import TuneError
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.schedulers import PopulationBasedTraining
|
||||
from ray.tune.schedulers.pbt import _PBTTrialState
|
||||
from ray.tune.utils.util import flatten_dict, unflatten_dict
|
||||
from ray.util.debug import log_once
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
|
||||
def import_pb2_dependencies():
|
||||
try:
|
||||
import sklearn
|
||||
except ImportError:
|
||||
sklearn = None
|
||||
return sklearn
|
||||
|
||||
|
||||
has_sklearn = import_pb2_dependencies()
|
||||
|
||||
if has_sklearn:
|
||||
from sklearn.gaussian_process import GaussianProcessRegressor
|
||||
|
||||
from ray.tune.schedulers.pb2_utils import (
|
||||
UCB,
|
||||
TV_SquaredExp,
|
||||
normalize,
|
||||
optimize_acq,
|
||||
select_length,
|
||||
standardize,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fill_config(
|
||||
config: Dict, hyperparam_bounds: Dict[str, Union[dict, list, tuple]]
|
||||
) -> Dict:
|
||||
"""Fills missing hyperparameters in config by sampling uniformly from the
|
||||
specified `hyperparam_bounds`.
|
||||
Recursively fills the config if `hyperparam_bounds` is a nested dict.
|
||||
|
||||
This is a helper used to set initial hyperparameter values if the user doesn't
|
||||
specify them in the Tuner `param_space`.
|
||||
|
||||
Returns the dict of filled hyperparameters.
|
||||
"""
|
||||
filled_hyperparams = {}
|
||||
for param_name, bounds in hyperparam_bounds.items():
|
||||
if isinstance(bounds, dict):
|
||||
if param_name not in config:
|
||||
config[param_name] = {}
|
||||
filled_hyperparams[param_name] = _fill_config(config[param_name], bounds)
|
||||
elif isinstance(bounds, (list, tuple)) and param_name not in config:
|
||||
if log_once(param_name + "-missing"):
|
||||
logger.debug(
|
||||
f"Cannot find {param_name} in config. Initializing by "
|
||||
"sampling uniformly from the provided `hyperparam_bounds`."
|
||||
)
|
||||
assert len(bounds) == 2
|
||||
low, high = bounds
|
||||
config[param_name] = filled_hyperparams[param_name] = np.random.uniform(
|
||||
low, high
|
||||
)
|
||||
return filled_hyperparams
|
||||
|
||||
|
||||
def _select_config(
|
||||
Xraw: np.array,
|
||||
yraw: np.array,
|
||||
current: list,
|
||||
newpoint: np.array,
|
||||
bounds: dict,
|
||||
num_f: int,
|
||||
) -> np.ndarray:
|
||||
"""Selects the next hyperparameter config to try.
|
||||
|
||||
This function takes the formatted data, fits the GP model and optimizes the
|
||||
UCB acquisition function to select the next point.
|
||||
|
||||
Args:
|
||||
Xraw: The un-normalized array of hyperparams, Time and
|
||||
Reward
|
||||
yraw: The un-normalized vector of reward changes.
|
||||
current: The hyperparams of trials currently running. This is
|
||||
important so we do not select the same config twice. If there is
|
||||
data here then we fit a second GP including it
|
||||
(with fake y labels). The GP variance doesn't depend on the y
|
||||
labels so it is ok.
|
||||
newpoint: The Reward and Time for the new point.
|
||||
We cannot change these as they are based on the *new weights*.
|
||||
bounds: Bounds for the hyperparameters. Used to normalize.
|
||||
num_f: The number of fixed params. Almost always 2 (reward+time)
|
||||
|
||||
Returns:
|
||||
xt: A vector of new hyperparameters.
|
||||
"""
|
||||
length = select_length(Xraw, yraw, bounds, num_f)
|
||||
|
||||
Xraw = Xraw[-length:, :]
|
||||
yraw = yraw[-length:]
|
||||
|
||||
base_vals = np.array(list(bounds.values())).T
|
||||
oldpoints = Xraw[:, :num_f]
|
||||
old_lims = np.concatenate(
|
||||
(np.max(oldpoints, axis=0), np.min(oldpoints, axis=0))
|
||||
).reshape(2, oldpoints.shape[1])
|
||||
limits = np.concatenate((old_lims, base_vals), axis=1)
|
||||
|
||||
X = normalize(Xraw, limits)
|
||||
y = standardize(yraw).reshape(yraw.size, 1)
|
||||
|
||||
fixed = normalize(newpoint, oldpoints)
|
||||
|
||||
kernel = TV_SquaredExp(variance=1.0, lengthscale=1.0, epsilon=0.1)
|
||||
|
||||
try:
|
||||
m = GaussianProcessRegressor(
|
||||
kernel=kernel, optimizer="fmin_l_bfgs_b", alpha=1e-10
|
||||
)
|
||||
m.fit(X, y)
|
||||
except np.linalg.LinAlgError:
|
||||
# add diagonal ** we would ideally make this something more robust...
|
||||
X += np.eye(X.shape[0]) * 1e-3
|
||||
m = GaussianProcessRegressor(
|
||||
kernel=kernel, optimizer="fmin_l_bfgs_b", alpha=1e-10
|
||||
)
|
||||
m.fit(X, y)
|
||||
|
||||
if current is None:
|
||||
m1 = deepcopy(m)
|
||||
else:
|
||||
# add the current trials to the dataset
|
||||
padding = np.array([fixed for _ in range(current.shape[0])])
|
||||
current = normalize(current, base_vals)
|
||||
current = np.hstack((padding, current))
|
||||
|
||||
Xnew = np.vstack((X, current))
|
||||
ypad = np.zeros(current.shape[0])
|
||||
ypad = ypad.reshape(-1, 1)
|
||||
ynew = np.vstack((y, ypad))
|
||||
|
||||
kernel1 = TV_SquaredExp(variance=1.0, lengthscale=1.0, epsilon=0.1)
|
||||
m1 = GaussianProcessRegressor(
|
||||
kernel=kernel1, optimizer="fmin_l_bfgs_b", alpha=1e-10
|
||||
)
|
||||
m1.fit(Xnew, ynew)
|
||||
|
||||
xt = optimize_acq(UCB, m, m1, fixed, num_f)
|
||||
|
||||
# convert back...
|
||||
xt = xt * (np.max(base_vals, axis=0) - np.min(base_vals, axis=0)) + np.min(
|
||||
base_vals, axis=0
|
||||
)
|
||||
|
||||
xt = xt.astype(np.float32)
|
||||
return xt
|
||||
|
||||
|
||||
def _explore(
|
||||
data: pd.DataFrame,
|
||||
bounds: Dict[str, Tuple[float, float]],
|
||||
current: list,
|
||||
base: Trial,
|
||||
old: Trial,
|
||||
config: Dict[str, Tuple[float, float]],
|
||||
) -> Tuple[Dict, pd.DataFrame]:
|
||||
"""Returns next hyperparameter configuration to use.
|
||||
|
||||
This function primarily processes the data from completed trials
|
||||
and then requests the next config from the select_config function.
|
||||
It then adds the new trial to the dataframe, so that the reward change
|
||||
can be computed using the new weights.
|
||||
It returns the new point and the dataframe with the new entry.
|
||||
"""
|
||||
|
||||
df = data.sort_values(by="Time").reset_index(drop=True)
|
||||
|
||||
# Group by trial ID and hyperparams.
|
||||
# Compute change in timesteps and reward.
|
||||
df["y"] = df.groupby(["Trial"] + list(bounds.keys()))["Reward"].diff()
|
||||
df["t_change"] = df.groupby(["Trial"] + list(bounds.keys()))["Time"].diff()
|
||||
|
||||
# Delete entries without positive change in t.
|
||||
df = df[df["t_change"] > 0].reset_index(drop=True)
|
||||
df["R_before"] = df.Reward - df.y
|
||||
|
||||
# Normalize the reward change by the update size.
|
||||
# For example if trials took diff lengths of time.
|
||||
df["y"] = df.y / df.t_change
|
||||
df = df[~df.y.isna()].reset_index(drop=True)
|
||||
df = df.sort_values(by="Time").reset_index(drop=True)
|
||||
|
||||
# Only use the last 1k datapoints, so the GP is not too slow.
|
||||
df = df.iloc[-1000:, :].reset_index(drop=True)
|
||||
|
||||
# We need this to know the T and Reward for the weights.
|
||||
dfnewpoint = df[df["Trial"] == str(base)]
|
||||
|
||||
if not dfnewpoint.empty:
|
||||
# N ow specify the dataset for the GP.
|
||||
y = np.array(df.y.values)
|
||||
# Meta data we keep -> episodes and reward.
|
||||
# (TODO: convert to curve)
|
||||
t_r = df[["Time", "R_before"]]
|
||||
hparams = df[bounds.keys()]
|
||||
X = pd.concat([t_r, hparams], axis=1).values
|
||||
newpoint = df[df["Trial"] == str(base)].iloc[-1, :][["Time", "R_before"]].values
|
||||
new = _select_config(X, y, current, newpoint, bounds, num_f=len(t_r.columns))
|
||||
|
||||
new_config = config.copy()
|
||||
values = []
|
||||
# Cast types for new hyperparameters.
|
||||
for i, col in enumerate(hparams.columns):
|
||||
# Use the type from the old config. Like this types
|
||||
# should be passed on from the first config downwards.
|
||||
type_ = type(config[col])
|
||||
new_config[col] = type_(new[i])
|
||||
values.append(type_(new[i]))
|
||||
|
||||
new_T = df[df["Trial"] == str(base)].iloc[-1, :]["Time"]
|
||||
new_Reward = df[df["Trial"] == str(base)].iloc[-1, :].Reward
|
||||
|
||||
lst = [[str(old)] + [new_T] + values + [new_Reward]]
|
||||
cols = ["Trial", "Time"] + list(bounds) + ["Reward"]
|
||||
new_entry = pd.DataFrame(lst, columns=cols)
|
||||
|
||||
# Create an entry for the new config, with the reward from the
|
||||
# copied agent.
|
||||
data = pd.concat([data, new_entry]).reset_index(drop=True)
|
||||
|
||||
else:
|
||||
new_config = config.copy()
|
||||
|
||||
return new_config, data
|
||||
|
||||
|
||||
class PB2(PopulationBasedTraining):
|
||||
"""Implements the Population Based Bandit (PB2) algorithm.
|
||||
|
||||
PB2 trains a group of models (or agents) in parallel. Periodically, poorly
|
||||
performing models clone the state of the top performers, and the hyper-
|
||||
parameters are re-selected using GP-bandit optimization. The GP model is
|
||||
trained to predict the improvement in the next training period.
|
||||
|
||||
Like PBT, PB2 adapts hyperparameters during training time. This enables
|
||||
very fast hyperparameter discovery and also automatically discovers
|
||||
schedules.
|
||||
|
||||
This Tune PB2 implementation is built on top of Tune's PBT implementation.
|
||||
It considers all trials added as part of the PB2 population. If the number
|
||||
of trials exceeds the cluster capacity, they will be time-multiplexed as to
|
||||
balance training progress across the population. To run multiple trials,
|
||||
use `tune.TuneConfig(num_samples=<int>)`.
|
||||
|
||||
In {LOG_DIR}/{MY_EXPERIMENT_NAME}/, all mutations are logged in
|
||||
`pb2_global.txt` and individual policy perturbations are recorded
|
||||
in pb2_policy_{i}.txt. Tune logs: [target trial tag, clone trial tag,
|
||||
target trial iteration, clone trial iteration, old config, new config]
|
||||
on each perturbation step.
|
||||
|
||||
Args:
|
||||
time_attr: The training result attr to use for comparing time.
|
||||
Note that you can pass in something non-temporal such as
|
||||
`training_iteration` as a measure of progress, the only requirement
|
||||
is that the attribute should increase monotonically.
|
||||
Valid values are any key reported in the result dict by your
|
||||
trainable. The auto-filled keys ``"training_iteration"`` (the
|
||||
iteration count) and ``"time_total_s"`` (wall-clock seconds since
|
||||
the trial started) always work; any additional numeric, monotonic
|
||||
key your trainable reports via ``tune.report({...})`` is also valid
|
||||
(for example ``"timesteps_total"`` or a custom progress counter).
|
||||
Passing a key that is not present in the reported result causes
|
||||
the scheduler to skip its decision for that step.
|
||||
metric: The training result objective value attribute. Stopping
|
||||
procedures will use this attribute.
|
||||
mode: One of {min, max}. Determines whether objective is
|
||||
minimizing or maximizing the metric attribute.
|
||||
perturbation_interval: Models will be considered for
|
||||
perturbation at this interval of `time_attr`. Note that
|
||||
perturbation incurs checkpoint overhead, so you shouldn't set this
|
||||
to be too frequent.
|
||||
hyperparam_bounds: Hyperparameters to mutate. The format is
|
||||
as follows: for each key, enter a list of the form [min, max]
|
||||
representing the minimum and maximum possible hyperparam values.
|
||||
A key can also hold a dict for nested hyperparameters.
|
||||
Tune will sample uniformly between the bounds provided by
|
||||
`hyperparam_bounds` for the initial hyperparameter values if the
|
||||
corresponding hyperparameters are not present in a trial's initial `config`.
|
||||
quantile_fraction: Parameters are transferred from the top
|
||||
`quantile_fraction` fraction of trials to the bottom
|
||||
`quantile_fraction` fraction. Needs to be between 0 and 0.5.
|
||||
Setting it to 0 essentially implies doing no exploitation at all.
|
||||
log_config: Whether to log the ray config of each model to
|
||||
local_dir at each exploit. Allows config schedule to be
|
||||
reconstructed.
|
||||
require_attrs: Whether to require time_attr and metric to appear
|
||||
in result for every iteration. If True, error will be raised
|
||||
if these values are not present in trial result.
|
||||
synch: If False, will use asynchronous implementation of
|
||||
PBT. Trial perturbations occur every perturbation_interval for each
|
||||
trial independently. If True, will use synchronous implementation
|
||||
of PBT. Perturbations will occur only after all trials are
|
||||
synced at the same time_attr every perturbation_interval.
|
||||
Defaults to False. See Appendix A.1 here
|
||||
https://arxiv.org/pdf/1711.09846.pdf.
|
||||
custom_explore_fn: You can also specify a custom exploration
|
||||
function. This function is invoked as `f(config)`, where the input
|
||||
is the new config generated by Bayesian Optimization. This function
|
||||
should return the `config` updated as needed.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray import tune
|
||||
from ray.tune.schedulers.pb2 import PB2
|
||||
from ray.tune.examples.pbt_function import pbt_function
|
||||
|
||||
pb2 = PB2(
|
||||
metric="mean_accuracy",
|
||||
mode="max",
|
||||
perturbation_interval=20,
|
||||
hyperparam_bounds={"lr": [0.0001, 0.1]},
|
||||
)
|
||||
tuner = tune.Tuner(
|
||||
pbt_function,
|
||||
tune_config=tune.TuneConfig(
|
||||
scheduler=pb2,
|
||||
num_samples=8,
|
||||
),
|
||||
param_space={"lr": 0.0001},
|
||||
)
|
||||
tuner.fit()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_attr: str = "time_total_s",
|
||||
metric: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
perturbation_interval: float = 60.0,
|
||||
hyperparam_bounds: Dict[str, Union[dict, list, tuple]] = None,
|
||||
quantile_fraction: float = 0.25,
|
||||
log_config: bool = True,
|
||||
require_attrs: bool = True,
|
||||
synch: bool = False,
|
||||
custom_explore_fn: Optional[Callable[[dict], dict]] = None,
|
||||
):
|
||||
|
||||
sklearn_available = import_pb2_dependencies()
|
||||
if not sklearn_available:
|
||||
raise RuntimeError("Please install scikit-learn to use PB2.")
|
||||
|
||||
hyperparam_bounds = hyperparam_bounds or {}
|
||||
|
||||
if not hyperparam_bounds:
|
||||
raise TuneError(
|
||||
"`hyperparam_bounds` must be specified to use PB2 scheduler."
|
||||
)
|
||||
|
||||
super(PB2, self).__init__(
|
||||
time_attr=time_attr,
|
||||
metric=metric,
|
||||
mode=mode,
|
||||
perturbation_interval=perturbation_interval,
|
||||
hyperparam_mutations=hyperparam_bounds,
|
||||
quantile_fraction=quantile_fraction,
|
||||
resample_probability=0,
|
||||
custom_explore_fn=custom_explore_fn,
|
||||
log_config=log_config,
|
||||
require_attrs=require_attrs,
|
||||
synch=synch,
|
||||
)
|
||||
|
||||
self.last_exploration_time = 0 # when we last explored
|
||||
self.data = pd.DataFrame()
|
||||
|
||||
self._hyperparam_bounds = hyperparam_bounds
|
||||
self._hyperparam_bounds_flat = flatten_dict(
|
||||
hyperparam_bounds, prevent_delimiter=True
|
||||
)
|
||||
self._validate_hyperparam_bounds(self._hyperparam_bounds_flat)
|
||||
|
||||
# Current = trials running that have already re-started after reaching
|
||||
# the checkpoint. When exploring we care if these trials
|
||||
# are already in or scheduled to be in the next round.
|
||||
self.current = None
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
filled_hyperparams = _fill_config(trial.config, self._hyperparam_bounds)
|
||||
# Make sure that the params we sampled show up in the CLI output
|
||||
trial.evaluated_params.update(flatten_dict(filled_hyperparams))
|
||||
super().on_trial_add(tune_controller, trial)
|
||||
|
||||
def _validate_hyperparam_bounds(self, hyperparam_bounds: dict):
|
||||
"""Check that each hyperparam bound is of the form [low, high].
|
||||
|
||||
Args:
|
||||
hyperparam_bounds: Flattened mapping of hyperparameter name to a
|
||||
``[low, high]`` pair (or 2-tuple) describing the allowed range.
|
||||
|
||||
Raises:
|
||||
ValueError: if any of the hyperparam bounds are of an invalid format.
|
||||
"""
|
||||
for key, value in hyperparam_bounds.items():
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
||||
raise ValueError(
|
||||
"`hyperparam_bounds` values must either be "
|
||||
f"a list or tuple of size 2, but got {value} "
|
||||
f"instead for the param '{key}'"
|
||||
)
|
||||
low, high = value
|
||||
if low > high:
|
||||
raise ValueError(
|
||||
"`hyperparam_bounds` values must be of the form [low, high] "
|
||||
f"where low <= high, but got {value} instead for param '{key}'."
|
||||
)
|
||||
|
||||
def _save_trial_state(
|
||||
self, state: _PBTTrialState, time: int, result: Dict, trial: Trial
|
||||
):
|
||||
score = super(PB2, self)._save_trial_state(state, time, result, trial)
|
||||
|
||||
# Data logging for PB2.
|
||||
|
||||
# Collect hyperparams names and current values for this trial.
|
||||
names = list(self._hyperparam_bounds_flat.keys())
|
||||
flattened_config = flatten_dict(trial.config)
|
||||
values = [flattened_config[key] for key in names]
|
||||
|
||||
# Store trial state and hyperparams in dataframe.
|
||||
# this needs to be made more general.
|
||||
lst = [[trial, result[self._time_attr]] + values + [score]]
|
||||
cols = ["Trial", "Time"] + names + ["Reward"]
|
||||
entry = pd.DataFrame(lst, columns=cols)
|
||||
|
||||
self.data = pd.concat([self.data, entry]).reset_index(drop=True)
|
||||
self.data.Trial = self.data.Trial.astype("str")
|
||||
|
||||
def _get_new_config(self, trial: Trial, trial_to_clone: Trial) -> Tuple[Dict, Dict]:
|
||||
"""Gets new config for trial by exploring trial_to_clone's config using
|
||||
Bayesian Optimization (BO) to choose the hyperparameter values to explore.
|
||||
|
||||
Overrides `PopulationBasedTraining._get_new_config`.
|
||||
|
||||
Args:
|
||||
trial: The current trial that decided to exploit trial_to_clone.
|
||||
trial_to_clone: The top-performing trial with a hyperparameter config
|
||||
that the current trial will explore.
|
||||
|
||||
Returns:
|
||||
new_config: New hyperparameter configuration (after BO).
|
||||
operations: Empty dict since PB2 doesn't explore in easily labeled ways
|
||||
like PBT does.
|
||||
"""
|
||||
# If we are at a new timestep, we dont want to penalise for trials
|
||||
# still going.
|
||||
if self.data["Time"].max() > self.last_exploration_time:
|
||||
self.current = None
|
||||
|
||||
new_config_flat, data = _explore(
|
||||
self.data,
|
||||
self._hyperparam_bounds_flat,
|
||||
self.current,
|
||||
trial_to_clone,
|
||||
trial,
|
||||
flatten_dict(trial_to_clone.config),
|
||||
)
|
||||
|
||||
# Important to replace the old values, since we are copying across
|
||||
self.data = data.copy()
|
||||
|
||||
# If the current guy being selecting is at a point that is already
|
||||
# done, then append the data to the "current" which contains the
|
||||
# points in the current batch.
|
||||
new = [new_config_flat[key] for key in self._hyperparam_bounds_flat]
|
||||
|
||||
new = np.array(new)
|
||||
new = new.reshape(1, new.size)
|
||||
if self.data["Time"].max() > self.last_exploration_time:
|
||||
self.last_exploration_time = self.data["Time"].max()
|
||||
self.current = new.copy()
|
||||
else:
|
||||
self.current = np.concatenate((self.current, new), axis=0)
|
||||
logger.debug(self.current)
|
||||
|
||||
new_config = unflatten_dict(new_config_flat)
|
||||
|
||||
if self._custom_explore_fn:
|
||||
new_config = self._custom_explore_fn(new_config)
|
||||
assert (
|
||||
new_config is not None
|
||||
), "Custom explore function failed to return a new config"
|
||||
|
||||
return new_config, {}
|
||||
@@ -0,0 +1,212 @@
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize
|
||||
from sklearn.gaussian_process import GaussianProcessRegressor
|
||||
from sklearn.gaussian_process.kernels import Hyperparameter, Kernel
|
||||
from sklearn.metrics import pairwise_distances
|
||||
from sklearn.metrics.pairwise import euclidean_distances
|
||||
|
||||
|
||||
class TV_SquaredExp(Kernel):
|
||||
"""Time varying squared exponential kernel.
|
||||
For more info see the TV-GP-UCB paper:
|
||||
http://proceedings.mlr.press/v51/bogunovic16.pdf
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
variance=1.0,
|
||||
lengthscale=1.0,
|
||||
epsilon=0.1,
|
||||
variance_bounds=(1e-5, 1e5),
|
||||
lengthscale_bounds=(1e-5, 1e5),
|
||||
epsilon_bounds=(1e-5, 0.5),
|
||||
):
|
||||
self.variance = variance
|
||||
self.lengthscale = lengthscale
|
||||
self.epsilon = epsilon
|
||||
self.variance_bounds = variance_bounds
|
||||
self.lengthscale_bounds = lengthscale_bounds
|
||||
self.epsilon_bounds = epsilon_bounds
|
||||
|
||||
@property
|
||||
def hyperparameter_variance(self):
|
||||
return Hyperparameter("variance", "numeric", self.variance_bounds)
|
||||
|
||||
@property
|
||||
def hyperparameter_lengthscale(self):
|
||||
return Hyperparameter("lengthscale", "numeric", self.lengthscale_bounds)
|
||||
|
||||
@property
|
||||
def hyperparameter_epsilon(self):
|
||||
return Hyperparameter("epsilon", "numeric", self.epsilon_bounds)
|
||||
|
||||
def __call__(self, X, Y=None, eval_gradient=False):
|
||||
X = np.atleast_2d(X)
|
||||
if Y is None:
|
||||
Y = X
|
||||
|
||||
epsilon = np.clip(self.epsilon, 1e-5, 0.5)
|
||||
|
||||
# Time must be in the first column
|
||||
T1 = X[:, 0].reshape(-1, 1)
|
||||
T2 = Y[:, 0].reshape(-1, 1)
|
||||
dists = pairwise_distances(T1, T2, "cityblock")
|
||||
timekernel = (1 - epsilon) ** (0.5 * dists)
|
||||
|
||||
# RBF kernel on remaining features
|
||||
X_spatial = X[:, 1:]
|
||||
Y_spatial = Y[:, 1:]
|
||||
rbf = self.variance * np.exp(
|
||||
-np.square(euclidean_distances(X_spatial, Y_spatial)) / self.lengthscale
|
||||
)
|
||||
|
||||
K = rbf * timekernel
|
||||
|
||||
if eval_gradient:
|
||||
K_gradient_variance = K
|
||||
dist2 = np.square(euclidean_distances(X_spatial, Y_spatial))
|
||||
K_gradient_lengthscale = K * dist2 / self.lengthscale
|
||||
n = dists / 2
|
||||
K_gradient_epsilon = -K * n * epsilon / (1 - epsilon)
|
||||
return K, np.dstack(
|
||||
[K_gradient_variance, K_gradient_lengthscale, K_gradient_epsilon]
|
||||
)
|
||||
|
||||
return K
|
||||
|
||||
def diag(self, X):
|
||||
return np.full(X.shape[0], self.variance, dtype=np.float64)
|
||||
|
||||
def is_stationary(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def theta(self):
|
||||
return np.log([self.variance, self.lengthscale, self.epsilon])
|
||||
|
||||
@theta.setter
|
||||
def theta(self, theta):
|
||||
self.variance = np.exp(theta[0])
|
||||
self.lengthscale = np.exp(theta[1])
|
||||
self.epsilon = np.exp(theta[2])
|
||||
|
||||
@property
|
||||
def bounds(self):
|
||||
return np.log(
|
||||
[
|
||||
list(self.variance_bounds),
|
||||
list(self.lengthscale_bounds),
|
||||
list(self.epsilon_bounds),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def normalize(data, wrt):
|
||||
"""Normalize data to be in range (0,1), with respect to (wrt) boundaries,
|
||||
which can be specified.
|
||||
"""
|
||||
return (data - np.min(wrt, axis=0)) / (
|
||||
np.max(wrt, axis=0) - np.min(wrt, axis=0) + 1e-8
|
||||
)
|
||||
|
||||
|
||||
def standardize(data):
|
||||
"""Standardize to be Gaussian N(0,1). Clip final values."""
|
||||
data = (data - np.mean(data, axis=0)) / (np.std(data, axis=0) + 1e-8)
|
||||
return np.clip(data, -2, 2)
|
||||
|
||||
|
||||
def UCB(m, m1, x, fixed, kappa=None):
|
||||
"""UCB acquisition function. Interesting points to note:
|
||||
1) We concat with the fixed points, because we are not optimizing wrt
|
||||
these. This is the Reward and Time, which we can't change. We want
|
||||
to find the best hyperparameters *given* the reward and time.
|
||||
2) We use m to get the mean and m1 to get the variance. If we already
|
||||
have trials running, then m1 contains this information. This reduces
|
||||
the variance at points currently running, even if we don't have
|
||||
their label.
|
||||
Ref: https://jmlr.org/papers/volume15/desautels14a/desautels14a.pdf
|
||||
|
||||
"""
|
||||
c1 = 0.2
|
||||
c2 = 0.4
|
||||
beta_t = c1 + max(0, np.log(c2 * m.X_train_.shape[0]))
|
||||
kappa = np.sqrt(beta_t) if kappa is None else kappa
|
||||
|
||||
xtest = np.concatenate((fixed.reshape(-1, 1), np.array(x).reshape(-1, 1))).T
|
||||
|
||||
try:
|
||||
mean = m.predict(xtest)[0]
|
||||
except ValueError:
|
||||
mean = -9999
|
||||
|
||||
try:
|
||||
_, std = m1.predict(xtest, return_std=True)
|
||||
var = std[0] ** 2
|
||||
except ValueError:
|
||||
var = 0
|
||||
return mean + kappa * var
|
||||
|
||||
|
||||
def optimize_acq(func, m, m1, fixed, num_f):
|
||||
"""Optimize acquisition function."""
|
||||
|
||||
opts = {"maxiter": 200, "maxfun": 200, "disp": False}
|
||||
|
||||
T = 10
|
||||
best_value = -999
|
||||
best_theta = m1.X_train_[0, :]
|
||||
|
||||
bounds = [(0, 1) for _ in range(m.X_train_.shape[1] - num_f)]
|
||||
|
||||
for ii in range(T):
|
||||
x0 = np.random.uniform(0, 1, m.X_train_.shape[1] - num_f)
|
||||
|
||||
res = minimize(
|
||||
lambda x: -func(m, m1, x, fixed),
|
||||
x0,
|
||||
bounds=bounds,
|
||||
method="L-BFGS-B",
|
||||
options=opts,
|
||||
)
|
||||
|
||||
val = func(m, m1, res.x, fixed)
|
||||
if val > best_value:
|
||||
best_value = val
|
||||
best_theta = res.x
|
||||
|
||||
return np.clip(best_theta, 0, 1)
|
||||
|
||||
|
||||
def select_length(Xraw, yraw, bounds, num_f):
|
||||
"""Select the number of datapoints to keep, using cross validation"""
|
||||
min_len = 200
|
||||
|
||||
if Xraw.shape[0] < min_len:
|
||||
return Xraw.shape[0]
|
||||
else:
|
||||
length = min_len - 10
|
||||
scores = []
|
||||
while length + 10 <= Xraw.shape[0]:
|
||||
length += 10
|
||||
|
||||
base_vals = np.array(list(bounds.values())).T
|
||||
X_len = Xraw[-length:, :]
|
||||
y_len = yraw[-length:]
|
||||
oldpoints = X_len[:, :num_f]
|
||||
old_lims = np.concatenate(
|
||||
(np.max(oldpoints, axis=0), np.min(oldpoints, axis=0))
|
||||
).reshape(2, oldpoints.shape[1])
|
||||
limits = np.concatenate((old_lims, base_vals), axis=1)
|
||||
|
||||
X = normalize(X_len, limits)
|
||||
y = standardize(y_len).reshape(y_len.size, 1)
|
||||
|
||||
kernel = TV_SquaredExp(variance=1.0, lengthscale=1.0, epsilon=0.1)
|
||||
m = GaussianProcessRegressor(kernel=kernel, optimizer="fmin_l_bfgs_b")
|
||||
m.fit(X, y)
|
||||
|
||||
scores.append(m.log_marginal_likelihood_value_)
|
||||
idx = np.argmax(scores)
|
||||
length = (idx + int((min_len / 10))) * 10
|
||||
return length
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,875 @@
|
||||
import logging
|
||||
import pickle
|
||||
import warnings
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.air.execution.resources.request import _sum_bundles
|
||||
from ray.tune.execution.placement_groups import PlacementGroupFactory
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.schedulers.trial_scheduler import FIFOScheduler, TrialScheduler
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class DistributeResources:
|
||||
"""This class creates a basic uniform resource allocation function.
|
||||
|
||||
The function naively balances free resources (CPUs and GPUs) between
|
||||
trials, giving them all equal priority, ensuring that all resources
|
||||
are always being used. The free resources will be placed in new bundles.
|
||||
The function assumes that all bundles are equal (there is no "head"
|
||||
bundle).
|
||||
|
||||
If for some reason a trial ends up with
|
||||
more resources than there are free ones, it will adjust downwards.
|
||||
It will also ensure that trial as at least as many resources as
|
||||
it started with (``base_trial_resource``).
|
||||
|
||||
The function returns a new ``PlacementGroupFactory`` with updated
|
||||
resource requirements, or None. If the returned
|
||||
``PlacementGroupFactory`` is equal by value to the one the
|
||||
trial has currently, the scheduler will skip the update process
|
||||
internally (same with None).
|
||||
|
||||
If you wish to implement your own resource distribution logic,
|
||||
you can do so by extending this class, as it provides several
|
||||
generic methods. You can also implement a function instead.
|
||||
|
||||
Args:
|
||||
add_bundles: If True, create new bundles from free resources.
|
||||
Otherwise, spread them among base_trial_resource bundles.
|
||||
increase_by: A dict with key-value
|
||||
pairs representing an atomic unit of resources (name-amount)
|
||||
the trial will be increased by. If not set, the trial will
|
||||
increase by 1 CPU/GPU.
|
||||
increase_by_times: If set to >=1 and ``increase_by`` is set,
|
||||
the trial will increase by maximum of
|
||||
``increase_by_times * increase_by`` resources. If set to <1,
|
||||
no upper limit is set. Ignored if ``increase_by`` is not set.
|
||||
reserve_resources: A dict of
|
||||
resource_name-amount pairs representing the resources
|
||||
that will not be allocated to resized trials.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
add_bundles: bool = False,
|
||||
increase_by: Optional[Dict[str, float]] = None,
|
||||
increase_by_times: int = -1,
|
||||
reserve_resources: Optional[Dict[str, float]] = None,
|
||||
):
|
||||
self.add_bundles = add_bundles
|
||||
self.increase_by = increase_by or {}
|
||||
self.increase_by_times = increase_by_times
|
||||
self.reserve_resources = reserve_resources or {}
|
||||
|
||||
def _validate(
|
||||
self, base_trial_resource: PlacementGroupFactory, result: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""Return False if we should keep the current resources outright."""
|
||||
if not isinstance(base_trial_resource, PlacementGroupFactory):
|
||||
raise ValueError(
|
||||
f"{self.__class__.__name__} only supports PlacementGroupFactories."
|
||||
)
|
||||
|
||||
if not self.add_bundles and len(base_trial_resource.bundles) > 1:
|
||||
raise ValueError(
|
||||
"If `add_bundles` is False, the number of bundles in "
|
||||
"`resources_per_trial` must be 1 "
|
||||
f"(got {len(base_trial_resource.bundles)})."
|
||||
)
|
||||
|
||||
# Don't bother if this is just the first iteration
|
||||
if result["training_iteration"] < 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_total_available_resources(
|
||||
self, tune_controller: "TuneController"
|
||||
) -> Tuple[float, float]:
|
||||
"""Get the number of CPUs and GPUs avaialble in total (not just free)"""
|
||||
total_available_cpus = (
|
||||
tune_controller._resource_updater.get_num_cpus()
|
||||
- self.reserve_resources.get("CPU", 0)
|
||||
)
|
||||
total_available_gpus = (
|
||||
tune_controller._resource_updater.get_num_gpus()
|
||||
- self.reserve_resources.get("GPU", 0)
|
||||
)
|
||||
return total_available_cpus, total_available_gpus
|
||||
|
||||
def _get_used_cpus_and_gpus(self, t: Trial) -> Tuple[float, float]:
|
||||
"""Check how many CPUs and GPUs a trial is using currently"""
|
||||
return (
|
||||
t.placement_group_factory.required_resources.get("CPU", 0),
|
||||
t.placement_group_factory.required_resources.get("GPU", 0),
|
||||
)
|
||||
|
||||
def _get_resources_from_bundles(
|
||||
self, bundles: List[Dict[str, float]]
|
||||
) -> Dict[str, float]:
|
||||
"""Get total sums of resources in bundles"""
|
||||
if not bundles:
|
||||
return {"CPU": 0, "GPU": 0}
|
||||
return _sum_bundles(bundles)
|
||||
|
||||
def _is_bundle_empty(self, bundle: Dict[str, float]) -> bool:
|
||||
return not (bundle.get("CPU", 0) or bundle.get("GPU", 0))
|
||||
|
||||
def _add_two_bundles(
|
||||
self,
|
||||
bundles_a: List[Dict[str, float]],
|
||||
bundles_b: List[Dict[str, float]],
|
||||
increase_by: Dict[str, float],
|
||||
limit_to_increase_by_times: bool,
|
||||
max_increase_by_times: int = -1,
|
||||
):
|
||||
"""Add two bundles together.
|
||||
|
||||
If ``limit_to_increase_by_times`` is True, ``self.increase_by_times`` > 0
|
||||
and ``max_increase_by_times`` > 0, ensure that the resulting number of
|
||||
bundles is not above ``min(max_increase_by_times, self.increase_by_times)``.
|
||||
|
||||
If ``limit_to_increase_by_times`` is True and ``self.increase_by_times`` > 0,
|
||||
ensure that the resulting number of bundles is not above
|
||||
`self.increase_by_times``.
|
||||
"""
|
||||
if limit_to_increase_by_times:
|
||||
if max_increase_by_times > 0 and self.increase_by_times > 0:
|
||||
max_increase_by_times = min(
|
||||
max_increase_by_times, self.increase_by_times
|
||||
)
|
||||
elif self.increase_by_times > 0:
|
||||
max_increase_by_times = self.increase_by_times
|
||||
|
||||
if self.add_bundles:
|
||||
bundles = [b for b in bundles_a if not self._is_bundle_empty(b)] + [
|
||||
b for b in bundles_b if not self._is_bundle_empty(b)
|
||||
]
|
||||
if max_increase_by_times > 0:
|
||||
bundles = bundles[:max_increase_by_times]
|
||||
else:
|
||||
bundles_a = bundles_a or [{}]
|
||||
bundles_b = bundles_b or [{}]
|
||||
bundles = [
|
||||
{
|
||||
"CPU": bundles_a[0].get("CPU", 0) + bundles_b[0].get("CPU", 0),
|
||||
"GPU": bundles_a[0].get("GPU", 0) + bundles_b[0].get("GPU", 0),
|
||||
}
|
||||
]
|
||||
if max_increase_by_times > 0:
|
||||
bundles[0]["CPU"] = min(
|
||||
bundles[0]["CPU"],
|
||||
increase_by.get("CPU", 0) * max_increase_by_times,
|
||||
)
|
||||
bundles[0]["GPU"] = min(
|
||||
bundles[0]["GPU"],
|
||||
increase_by.get("GPU", 0) * max_increase_by_times,
|
||||
)
|
||||
|
||||
return bundles
|
||||
|
||||
def _get_multiplier(
|
||||
self,
|
||||
increase_by: Dict[str, float],
|
||||
cpus: float = 0,
|
||||
gpus: float = 0,
|
||||
max_multiplier: int = -1,
|
||||
) -> int:
|
||||
"""Get how many times ``increase_by`` bundles
|
||||
occur in ``cpus`` and ``gpus``."""
|
||||
if increase_by.get("CPU", 0) and increase_by.get("GPU", 0):
|
||||
multiplier = min(
|
||||
cpus // increase_by.get("CPU", 0),
|
||||
gpus // increase_by.get("GPU", 0),
|
||||
)
|
||||
elif increase_by.get("GPU", 0):
|
||||
multiplier = gpus // increase_by.get("GPU", 0)
|
||||
else:
|
||||
multiplier = cpus // increase_by.get("CPU", 0)
|
||||
|
||||
if max_multiplier > 0 and multiplier > 0:
|
||||
multiplier = min(max_multiplier, multiplier)
|
||||
return int(multiplier)
|
||||
|
||||
def _remove_bundles(
|
||||
self,
|
||||
bundles: List[Dict[str, float]],
|
||||
increase_by: Dict[str, float],
|
||||
multiplier: int,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""Remove ``multiplier`` ``increase_by`` bundles from ``bundles``."""
|
||||
multiplier = -abs(multiplier)
|
||||
if self.add_bundles:
|
||||
bundles = bundles[:multiplier]
|
||||
else:
|
||||
bundles = deepcopy(bundles)
|
||||
bundles[0]["CPU"] += increase_by.get("CPU", 0) * multiplier
|
||||
bundles[0]["GPU"] += increase_by.get("GPU", 0) * multiplier
|
||||
bundles[0]["CPU"] = max(bundles[0]["CPU"], 0)
|
||||
bundles[0]["GPU"] = max(bundles[0]["GPU"], 0)
|
||||
return bundles
|
||||
|
||||
def _create_new_bundles(
|
||||
self,
|
||||
increase_by: Dict[str, float],
|
||||
multiplier: int,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""Create a list of new bundles containing ``increase_by`` * ``multiplier``."""
|
||||
multiplier = abs(multiplier)
|
||||
|
||||
if self.add_bundles:
|
||||
bundles = [increase_by] * int(multiplier)
|
||||
else:
|
||||
bundles = [{}]
|
||||
bundles[0]["CPU"] = increase_by.get("CPU", 0) * multiplier
|
||||
bundles[0]["GPU"] = increase_by.get("GPU", 0) * multiplier
|
||||
|
||||
return bundles
|
||||
|
||||
def _modify_bundles_with_free_resources(
|
||||
self,
|
||||
bundles: List[Dict[str, float]],
|
||||
increase_by: Dict[str, float],
|
||||
free_cpus: float,
|
||||
free_gpus: float,
|
||||
*,
|
||||
max_multiplier: int = -1,
|
||||
max_increase_by_times: int = -1,
|
||||
):
|
||||
"""Given free resources, increase/decrease the number of bundles in
|
||||
``bundles``."""
|
||||
multiplier = self._get_multiplier(
|
||||
increase_by, free_cpus, free_gpus, max_multiplier
|
||||
)
|
||||
if multiplier < 0:
|
||||
bundles = self._remove_bundles(bundles, increase_by, multiplier)
|
||||
elif multiplier > 0:
|
||||
bundles_to_add = self._create_new_bundles(increase_by, multiplier)
|
||||
bundles = self._add_two_bundles(
|
||||
bundles, bundles_to_add, increase_by, True, max_increase_by_times
|
||||
)
|
||||
return bundles
|
||||
|
||||
def _get_added_bundles(
|
||||
self, bundles: List[Dict[str, float]], base_bundles: List[Dict[str, float]]
|
||||
) -> List[Dict[str, float]]:
|
||||
"""Return the difference between bundles and base_bundles"""
|
||||
if self.add_bundles:
|
||||
added_bundles = bundles[len(base_bundles) :]
|
||||
else:
|
||||
if not bundles:
|
||||
bundles = [{"CPU": 0, "GPU": 0}]
|
||||
if not base_bundles:
|
||||
base_bundles = [{"CPU": 0, "GPU": 0}]
|
||||
added_bundles = [
|
||||
{
|
||||
"CPU": bundles[0].get("CPU", 0) - base_bundles[0].get("CPU", 0),
|
||||
"GPU": bundles[0].get("GPU", 0) - base_bundles[0].get("GPU", 0),
|
||||
}
|
||||
]
|
||||
return added_bundles
|
||||
|
||||
def _are_bundles_below_limit(
|
||||
self,
|
||||
bundles: List[Dict[str, float]],
|
||||
base_bundles: Optional[List[Dict[str, float]]] = None,
|
||||
max_added_cpus: Optional[float] = None,
|
||||
max_added_gpus: Optional[float] = None,
|
||||
):
|
||||
if not max_added_cpus:
|
||||
if self.increase_by_times > 0:
|
||||
max_added_cpus = self.increase_by.get("CPU", 0) * self.increase_by_times
|
||||
else:
|
||||
max_added_cpus = np.inf
|
||||
if not max_added_gpus:
|
||||
if self.increase_by_times > 0:
|
||||
max_added_gpus = self.increase_by.get("GPU", 0) * self.increase_by_times
|
||||
else:
|
||||
max_added_gpus = np.inf
|
||||
added_resources = self._get_resources_from_bundles(
|
||||
self._get_added_bundles(bundles, base_bundles) if base_bundles else bundles
|
||||
)
|
||||
ret = (
|
||||
added_resources.get("CPU", -np.inf) < max_added_cpus
|
||||
or added_resources.get("GPU", -np.inf) < max_added_gpus
|
||||
)
|
||||
return ret
|
||||
|
||||
def _get_new_added_bundles(
|
||||
self,
|
||||
trial: Trial,
|
||||
all_trials: List[Trial],
|
||||
base_bundles: List[Dict[str, float]],
|
||||
increase_by: Dict[str, float],
|
||||
total_available_cpus: float,
|
||||
total_available_gpus: float,
|
||||
used_cpus: float,
|
||||
used_gpus: float,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""Returns updated added bundles."""
|
||||
upper_limit_all_trials_bundles = [list() for _ in range(len(all_trials))]
|
||||
|
||||
free_cpus = total_available_cpus - used_cpus
|
||||
free_gpus = total_available_gpus - used_gpus
|
||||
|
||||
base_resources = self._get_resources_from_bundles(base_bundles)
|
||||
upper_limit_cpus_to_distribute = total_available_cpus - (
|
||||
base_resources.get("CPU", 0) * len(all_trials)
|
||||
)
|
||||
upper_limit_gpus_to_distribute = total_available_gpus - (
|
||||
base_resources.get("GPU", 0) * len(all_trials)
|
||||
)
|
||||
max_increase_by_times = 0
|
||||
|
||||
# First, calculate upper limits for uniform allocation
|
||||
# This is done by simulating a clean slate scenario
|
||||
# The loop runs until all resources are allocated or
|
||||
# all trials are at their resource limits
|
||||
i = 0
|
||||
trials_at_limit = set()
|
||||
while (
|
||||
len(trials_at_limit) < len(all_trials)
|
||||
# we have previously asserted that at least one resource has to be
|
||||
# bigger than 0
|
||||
and upper_limit_cpus_to_distribute >= increase_by.get("CPU", 0)
|
||||
and upper_limit_gpus_to_distribute >= increase_by.get("GPU", 0)
|
||||
):
|
||||
idx = i % len(upper_limit_all_trials_bundles)
|
||||
old_bundles = deepcopy(upper_limit_all_trials_bundles[idx])
|
||||
upper_limit_all_trials_bundles[
|
||||
idx
|
||||
] = self._modify_bundles_with_free_resources(
|
||||
upper_limit_all_trials_bundles[idx],
|
||||
increase_by,
|
||||
upper_limit_cpus_to_distribute,
|
||||
upper_limit_gpus_to_distribute,
|
||||
max_multiplier=1,
|
||||
)
|
||||
added_resources = self._get_resources_from_bundles(
|
||||
self._get_added_bundles(
|
||||
upper_limit_all_trials_bundles[idx], old_bundles
|
||||
)
|
||||
)
|
||||
if not added_resources.get("CPU", 0) and not added_resources.get("GPU", 0):
|
||||
trials_at_limit.add(idx)
|
||||
elif idx == 0:
|
||||
max_increase_by_times += 1
|
||||
upper_limit_cpus_to_distribute -= added_resources.get("CPU", 0)
|
||||
upper_limit_gpus_to_distribute -= added_resources.get("GPU", 0)
|
||||
i += 1
|
||||
|
||||
# Add new resourcs, but only up to calculated upper limits
|
||||
# (max_increase_by_times)
|
||||
return self._modify_bundles_with_free_resources(
|
||||
self._get_added_bundles(
|
||||
trial.placement_group_factory.bundles, base_bundles
|
||||
),
|
||||
increase_by,
|
||||
free_cpus,
|
||||
free_gpus,
|
||||
max_increase_by_times=max_increase_by_times,
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
tune_controller: "TuneController",
|
||||
trial: Trial,
|
||||
result: Dict[str, Any],
|
||||
scheduler: "ResourceChangingScheduler",
|
||||
) -> Optional[PlacementGroupFactory]:
|
||||
"""Run resource allocation logic.
|
||||
|
||||
Returns a new ``PlacementGroupFactory`` with updated
|
||||
resource requirements, or None. If the returned
|
||||
``PlacementGroupFactory`` is equal by value to the one the
|
||||
trial has currently, the scheduler will skip the update process
|
||||
internally (same with None).
|
||||
|
||||
Args:
|
||||
tune_controller: Trial runner for this Tune run.
|
||||
Can be used to obtain information about other trials.
|
||||
trial: The trial to allocate new resources to.
|
||||
result: The latest results of trial.
|
||||
scheduler: The scheduler calling
|
||||
the function.
|
||||
|
||||
Returns:
|
||||
A new ``PlacementGroupFactory`` with updated resource requirements,
|
||||
or None if the trial's resources should be left unchanged.
|
||||
"""
|
||||
# Get base trial resources as defined in
|
||||
# ``tune.run(resources_per_trial)``
|
||||
base_trial_resource = scheduler.base_trial_resources
|
||||
|
||||
if not self._validate(base_trial_resource=base_trial_resource, result=result):
|
||||
return None
|
||||
|
||||
# default values if resources_per_trial is unspecified
|
||||
if base_trial_resource is None:
|
||||
base_trial_resource = PlacementGroupFactory([{"CPU": 1, "GPU": 0}])
|
||||
|
||||
if self.increase_by:
|
||||
increase_by = self.increase_by
|
||||
assert not self._is_bundle_empty(increase_by)
|
||||
assert increase_by.get("CPU", 0) >= 0 and increase_by.get("GPU", 0) >= 0
|
||||
elif self.add_bundles:
|
||||
increase_by = base_trial_resource.bundles[-1]
|
||||
elif base_trial_resource.bundles[0].get("GPU", 0):
|
||||
increase_by = {"GPU": 1}
|
||||
else:
|
||||
increase_by = {"CPU": 1}
|
||||
|
||||
base_bundles = deepcopy(base_trial_resource.bundles)
|
||||
|
||||
(
|
||||
total_available_cpus,
|
||||
total_available_gpus,
|
||||
) = self._get_total_available_resources(tune_controller=tune_controller)
|
||||
|
||||
all_trials = tune_controller.get_live_trials()
|
||||
|
||||
used_cpus_and_gpus = [self._get_used_cpus_and_gpus(t) for t in all_trials]
|
||||
used_cpus, used_gpus = zip(*used_cpus_and_gpus)
|
||||
used_cpus = sum(used_cpus)
|
||||
used_gpus = sum(used_gpus)
|
||||
|
||||
added_bundles = self._get_new_added_bundles(
|
||||
trial,
|
||||
all_trials,
|
||||
base_bundles,
|
||||
increase_by,
|
||||
total_available_cpus,
|
||||
total_available_gpus,
|
||||
used_cpus,
|
||||
used_gpus,
|
||||
)
|
||||
|
||||
new_bundles = self._add_two_bundles(
|
||||
base_bundles, added_bundles, increase_by, False
|
||||
)
|
||||
|
||||
pgf = PlacementGroupFactory(
|
||||
new_bundles,
|
||||
strategy=base_trial_resource.strategy,
|
||||
*base_trial_resource._args,
|
||||
**base_trial_resource._kwargs,
|
||||
)
|
||||
pgf._head_bundle_is_empty = base_trial_resource._head_bundle_is_empty
|
||||
return pgf
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class DistributeResourcesToTopJob(DistributeResources):
|
||||
"""This class creates a "TopJob" resource allocation function.
|
||||
|
||||
The function will assign all of the free resources to the best
|
||||
performing trial (as defined by ``metric`` and ``mode``). The
|
||||
previous best trials will not have their resources deallocated,
|
||||
unless in the case outlined below.
|
||||
|
||||
If for some reason a trial ends up with
|
||||
more resources than there are free ones, it will adjust downwards.
|
||||
It will also ensure that trial as at least as many resources as
|
||||
it started with (``base_trial_resource``).
|
||||
|
||||
The function returns a new ``PlacementGroupFactory`` with updated
|
||||
resource requirements, or None. If the returned
|
||||
``PlacementGroupFactory`` is equal by value to the one the
|
||||
trial has currently, the scheduler will skip the update process
|
||||
internally (same with None).
|
||||
|
||||
Args:
|
||||
add_bundles: If True, create new bundles from free resources.
|
||||
Otherwise, spread them among base_trial_resource bundles.
|
||||
increase_by: A dict with key-value
|
||||
pairs representing an atomic unit of resources (name-amount)
|
||||
the trial will be increased by. If not set, the trial will
|
||||
increase by 1 CPU/GPU.
|
||||
increase_by_times: If set to >=1 and ``increase_by`` is set,
|
||||
the trial will increase by maximum of
|
||||
``increase_by_times * increase_by`` resources. If set to <1,
|
||||
no upper limit is set. Ignored if ``increase_by`` is not set.
|
||||
reserve_resources: A dict of
|
||||
resource_name-amount pairs representing the resources
|
||||
that will not be allocated to resized trials.
|
||||
is that the attribute should increase monotonically.
|
||||
metric: The training result objective value attribute. Stopping
|
||||
procedures will use this attribute. If None, will use the metric
|
||||
of the scheduler.
|
||||
mode: One of {min, max}. Determines whether objective is
|
||||
minimizing or maximizing the metric attribute. If None, will use the metric
|
||||
of the scheduler.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
add_bundles: bool = False,
|
||||
increase_by: Optional[Dict[str, float]] = None,
|
||||
increase_by_times: int = -1,
|
||||
reserve_resources: Optional[Dict[str, float]] = None,
|
||||
metric: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
):
|
||||
super().__init__(add_bundles, increase_by, increase_by_times, reserve_resources)
|
||||
self.metric = metric
|
||||
self.mode = mode
|
||||
|
||||
@property
|
||||
def _metric_op(self) -> float:
|
||||
if self.mode not in ("min", "max"):
|
||||
raise ValueError("The mode parameter can only be either min or max.")
|
||||
if self.mode == "max":
|
||||
return 1.0
|
||||
return -1.0
|
||||
|
||||
def _get_new_added_bundles(
|
||||
self,
|
||||
trial: Trial,
|
||||
all_trials: List[Trial],
|
||||
base_bundles: List[Dict[str, float]],
|
||||
increase_by: Dict[str, float],
|
||||
total_available_cpus: float,
|
||||
total_available_gpus: float,
|
||||
used_cpus: float,
|
||||
used_gpus: float,
|
||||
) -> List[Dict[str, float]]:
|
||||
if self.metric is None:
|
||||
raise ValueError(
|
||||
"The metric parameter cannot be None. The parameter can be set in "
|
||||
"either `DistributeResourcesToTopJob`, the base scheduler or in "
|
||||
"`tune.TuneConfig()` (highest to lowest priority)."
|
||||
)
|
||||
|
||||
free_cpus = total_available_cpus - used_cpus
|
||||
free_gpus = total_available_gpus - used_gpus
|
||||
|
||||
sorted_trials = sorted(
|
||||
all_trials,
|
||||
key=lambda t: -self._metric_op * t.last_result.get(self.metric, np.inf),
|
||||
)
|
||||
|
||||
added_bundles = self._get_added_bundles(
|
||||
trial.placement_group_factory.bundles, base_bundles
|
||||
)
|
||||
|
||||
best_trial = next(
|
||||
(
|
||||
t
|
||||
for t in sorted_trials
|
||||
if self._are_bundles_below_limit(
|
||||
t.placement_group_factory.bundles, base_bundles
|
||||
)
|
||||
),
|
||||
sorted_trials[0],
|
||||
)
|
||||
|
||||
if (
|
||||
trial.trial_id != best_trial.trial_id
|
||||
# Only reduce resources here
|
||||
and self._get_multiplier(increase_by, free_cpus, free_gpus) >= 0
|
||||
):
|
||||
return added_bundles
|
||||
|
||||
return self._modify_bundles_with_free_resources(
|
||||
added_bundles,
|
||||
increase_by,
|
||||
free_cpus,
|
||||
free_gpus,
|
||||
)
|
||||
|
||||
|
||||
_DistributeResourcesDefault = DistributeResources(add_bundles=False)
|
||||
_DistributeResourcesDistributedDefault = DistributeResources(add_bundles=True)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class ResourceChangingScheduler(TrialScheduler):
|
||||
"""A utility scheduler to dynamically change resources of live trials.
|
||||
|
||||
.. versionadded:: 1.5.0
|
||||
|
||||
.. note::
|
||||
Experimental. API may change in future releases.
|
||||
|
||||
The ResourceChangingScheduler works by wrapping around any other
|
||||
scheduler and adjusting the resource requirements of live trials
|
||||
in response to the decisions of the wrapped scheduler
|
||||
through a user-specified ``resources_allocation_function``.
|
||||
|
||||
An example of such a function can be found in
|
||||
:doc:`/tune/examples/includes/xgboost_dynamic_resources_example`.
|
||||
|
||||
If the functional API is used, the current trial resources can be obtained
|
||||
by calling `tune.get_trial_resources()` inside the training function.
|
||||
The function should be able to
|
||||
:ref:`load and save checkpoints <tune-function-trainable-checkpointing>`
|
||||
(the latter preferably every iteration).
|
||||
|
||||
If the Trainable (class) API is used, you can obtain the current trial
|
||||
resources through the ``Trainable.trial_resources`` property.
|
||||
|
||||
Cannot be used if ``reuse_actors`` is True in ``tune.TuneConfig()``. A ValueError
|
||||
will be raised in that case.
|
||||
|
||||
Args:
|
||||
base_scheduler: The scheduler to provide decisions
|
||||
about trials. If None, a default FIFOScheduler will be used.
|
||||
resources_allocation_function: The callable used to change
|
||||
live trial resource requiements during tuning. This callable
|
||||
will be called on each trial as it finishes one step of training.
|
||||
The callable must take four arguments: ``TrialRunner``, current
|
||||
``Trial``, current result :class:`dict` and the
|
||||
``ResourceChangingScheduler`` calling it. The callable must
|
||||
return a ``PlacementGroupFactory``
|
||||
or None (signifying no need for an update). If
|
||||
``resources_allocation_function`` is None, no resource
|
||||
requirements will be changed at any time.
|
||||
By default, :class:`DistributeResources` will be used,
|
||||
distributing available CPUs and GPUs over all running trials
|
||||
in a robust way, without any prioritization.
|
||||
|
||||
Warning:
|
||||
If the ``resources_allocation_function`` sets trial resource
|
||||
requirements to values bigger than possible, the trial will
|
||||
not run. Ensure that your callable accounts for that possibility
|
||||
by setting upper limits. Consult :class:`DistributeResources`
|
||||
to see how that may be done.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
base_scheduler = ASHAScheduler(max_t=16)
|
||||
def my_resources_allocation_function(
|
||||
tune_controller: "TuneController",
|
||||
trial: Trial,
|
||||
result: Dict[str, Any],
|
||||
scheduler: "ResourceChangingScheduler"
|
||||
) -> Optional[Union[PlacementGroupFactory, Resource]]:
|
||||
# logic here
|
||||
# usage of PlacementGroupFactory is strongly preferred
|
||||
return PlacementGroupFactory(...)
|
||||
scheduler = ResourceChangingScheduler(
|
||||
base_scheduler,
|
||||
my_resources_allocation_function
|
||||
)
|
||||
|
||||
See :doc:`/tune/examples/includes/xgboost_dynamic_resources_example` for a
|
||||
more detailed example.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_scheduler: Optional[TrialScheduler] = None,
|
||||
resources_allocation_function: Optional[
|
||||
Callable[
|
||||
[
|
||||
"TuneController",
|
||||
Trial,
|
||||
Dict[str, Any],
|
||||
"ResourceChangingScheduler",
|
||||
],
|
||||
Optional[PlacementGroupFactory],
|
||||
]
|
||||
] = _DistributeResourcesDefault,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if resources_allocation_function is None:
|
||||
warnings.warn(
|
||||
"`resources_allocation_function` is None. No resource "
|
||||
"requirements will be changed at any time. Pass a "
|
||||
"correctly defined function to enable functionality."
|
||||
)
|
||||
self._resources_allocation_function = resources_allocation_function
|
||||
self._base_scheduler = base_scheduler or FIFOScheduler()
|
||||
self._base_trial_resources: Optional[PlacementGroupFactory] = None
|
||||
self._trials_to_reallocate: Dict[
|
||||
Trial, Optional[Union[dict, PlacementGroupFactory]]
|
||||
] = {}
|
||||
self._reallocated_trial_ids: Set[str] = set()
|
||||
self._metric = None
|
||||
self._mode = None
|
||||
|
||||
@property
|
||||
def metric(self):
|
||||
return self._base_scheduler._metric
|
||||
|
||||
@property
|
||||
def base_trial_resources(self) -> Optional[PlacementGroupFactory]:
|
||||
return self._base_trial_resources
|
||||
|
||||
def set_search_properties(
|
||||
self, metric: Optional[str], mode: Optional[str], **spec
|
||||
) -> bool:
|
||||
self._metric = metric
|
||||
self._mode = mode
|
||||
return self._base_scheduler.set_search_properties(metric, mode, **spec)
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial, **kwargs):
|
||||
# use the first trial resources as the base
|
||||
if self._base_trial_resources is None:
|
||||
self._base_trial_resources = trial.placement_group_factory
|
||||
# Raise error if the resources of a newly added trial don't match
|
||||
# base resources, but allow trials that have already had their
|
||||
# resources changed by ResourceChangingScheduler
|
||||
# (those can be added again during loading from a checkpoint)
|
||||
elif trial.trial_id not in self._reallocated_trial_ids:
|
||||
trial_resources = trial.placement_group_factory
|
||||
if trial_resources != self._base_trial_resources:
|
||||
raise RuntimeError(
|
||||
"ResourceChangingScheduler doesn't support trials with "
|
||||
"varying base resources. First trial had "
|
||||
f"{self._base_trial_resources}, trial {trial} has "
|
||||
f"{trial_resources}."
|
||||
)
|
||||
|
||||
return self._base_scheduler.on_trial_add(tune_controller, trial, **kwargs)
|
||||
|
||||
def on_trial_error(self, tune_controller: "TuneController", trial: Trial, **kwargs):
|
||||
return self._base_scheduler.on_trial_error(tune_controller, trial, **kwargs)
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> str:
|
||||
base_scheduler_decision = self._base_scheduler.on_trial_result(
|
||||
tune_controller, trial, result
|
||||
)
|
||||
if base_scheduler_decision == TrialScheduler.CONTINUE:
|
||||
new_resources = self.reallocate_trial_resources_if_needed(
|
||||
tune_controller, trial, result
|
||||
)
|
||||
if new_resources:
|
||||
self._trials_to_reallocate[trial] = new_resources
|
||||
return TrialScheduler.PAUSE
|
||||
return base_scheduler_decision
|
||||
|
||||
def on_trial_complete(
|
||||
self,
|
||||
tune_controller: "TuneController",
|
||||
trial: Trial,
|
||||
result: Dict,
|
||||
**kwargs,
|
||||
):
|
||||
return self._base_scheduler.on_trial_complete(
|
||||
tune_controller, trial, result, **kwargs
|
||||
)
|
||||
|
||||
def on_trial_remove(
|
||||
self, tune_controller: "TuneController", trial: Trial, **kwargs
|
||||
):
|
||||
return self._base_scheduler.on_trial_remove(tune_controller, trial, **kwargs)
|
||||
|
||||
def choose_trial_to_run(
|
||||
self, tune_controller: "TuneController", **kwargs
|
||||
) -> Optional[Trial]:
|
||||
if getattr(tune_controller, "_reuse_actors", False):
|
||||
raise ValueError(
|
||||
"ResourceChangingScheduler cannot be used with "
|
||||
"`reuse_actors=True`. FIX THIS by setting "
|
||||
"`reuse_actors=False` in `tune.TuneConfig()`."
|
||||
)
|
||||
|
||||
any_resources_changed = False
|
||||
|
||||
new_trials_to_reallocate = {}
|
||||
for trial, new_resources in self._trials_to_reallocate.items():
|
||||
if trial.status == Trial.RUNNING:
|
||||
new_trials_to_reallocate[trial] = new_resources
|
||||
logger.debug(f"{trial} is still running, skipping for now")
|
||||
continue
|
||||
any_resources_changed = any_resources_changed or self.set_trial_resources(
|
||||
trial, new_resources
|
||||
)
|
||||
self._trials_to_reallocate = new_trials_to_reallocate
|
||||
|
||||
trial = self._base_scheduler.choose_trial_to_run(tune_controller, **kwargs)
|
||||
return trial
|
||||
|
||||
def debug_string(self) -> str:
|
||||
return "(ResourceChangingScheduler) " f"{self._base_scheduler.debug_string()}"
|
||||
|
||||
def save(self, checkpoint_path: str):
|
||||
save_object = self.__dict__
|
||||
with open(checkpoint_path, "wb") as outputFile:
|
||||
pickle.dump(save_object, outputFile)
|
||||
|
||||
def restore(self, checkpoint_path: str):
|
||||
with open(checkpoint_path, "rb") as inputFile:
|
||||
save_object = pickle.load(inputFile)
|
||||
self.__dict__.update(save_object)
|
||||
|
||||
def set_trial_resources(
|
||||
self, trial: Trial, new_resources: Union[Dict, PlacementGroupFactory]
|
||||
) -> bool:
|
||||
"""Returns True if new_resources were set."""
|
||||
if new_resources:
|
||||
logger.info(
|
||||
f"Setting trial {trial} resource to {new_resources} "
|
||||
f"with {new_resources._bundles}"
|
||||
)
|
||||
trial.placement_group_factory = None
|
||||
trial.update_resources(new_resources)
|
||||
# keep track of all trials which had their resources changed
|
||||
self._reallocated_trial_ids.add(trial.trial_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _are_resources_the_same(
|
||||
self,
|
||||
trial: Trial,
|
||||
new_resources,
|
||||
) -> bool:
|
||||
"""Returns True if trial's resources are value equal to new_resources.
|
||||
|
||||
Only checks for PlacementGroupFactories at this moment.
|
||||
"""
|
||||
if (
|
||||
isinstance(new_resources, PlacementGroupFactory)
|
||||
and trial.placement_group_factory == new_resources
|
||||
):
|
||||
logger.debug(
|
||||
f"{trial} PGF "
|
||||
f"{trial.placement_group_factory.required_resources}"
|
||||
f" and {new_resources.required_resources}"
|
||||
f" are the same, skipping"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def reallocate_trial_resources_if_needed(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> Optional[Union[dict, PlacementGroupFactory]]:
|
||||
"""Calls user defined resources_allocation_function. If the returned
|
||||
resources are not none and not the same as currently present, returns
|
||||
them. Otherwise, returns None."""
|
||||
if self._resources_allocation_function is None:
|
||||
return None
|
||||
|
||||
if not getattr(self._resources_allocation_function, "metric", None):
|
||||
self._resources_allocation_function.metric = getattr(
|
||||
self._base_scheduler, "_metric", self._metric
|
||||
)
|
||||
if not getattr(self._resources_allocation_function, "mode", None):
|
||||
self._resources_allocation_function.mode = getattr(
|
||||
self._base_scheduler, "_mode", self._mode
|
||||
)
|
||||
|
||||
new_resources = self._resources_allocation_function(
|
||||
tune_controller, trial, result, self
|
||||
)
|
||||
|
||||
# if we can check if the new resources are the same,
|
||||
# we do that here and skip resource allocation
|
||||
if new_resources and not self._are_resources_the_same(trial, new_resources):
|
||||
return new_resources
|
||||
return None
|
||||
@@ -0,0 +1,173 @@
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
|
||||
from ray.air._internal.usage import tag_scheduler
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.result import DEFAULT_METRIC
|
||||
from ray.util.annotations import DeveloperAPI, PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.tune_controller import TuneController
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrialScheduler:
|
||||
"""Interface for implementing a Trial Scheduler class.
|
||||
|
||||
Note to Tune developers: If a new scheduler is added, please update
|
||||
`air/_internal/usage.py`.
|
||||
"""
|
||||
|
||||
CONTINUE = "CONTINUE" #: Status for continuing trial execution
|
||||
PAUSE = "PAUSE" #: Status for pausing trial execution
|
||||
STOP = "STOP" #: Status for stopping trial execution
|
||||
# Caution: Temporary and anti-pattern! This means Scheduler calls
|
||||
# into Executor directly without going through TrialRunner.
|
||||
# TODO(xwjiang): Deprecate this after we control the interaction
|
||||
# between schedulers and executor.
|
||||
NOOP = "NOOP"
|
||||
|
||||
_metric = None
|
||||
|
||||
_supports_buffered_results = True
|
||||
|
||||
def __init__(self):
|
||||
tag_scheduler(self)
|
||||
|
||||
@property
|
||||
def metric(self):
|
||||
return self._metric
|
||||
|
||||
@property
|
||||
def supports_buffered_results(self):
|
||||
return self._supports_buffered_results
|
||||
|
||||
def set_search_properties(
|
||||
self, metric: Optional[str], mode: Optional[str], **spec
|
||||
) -> bool:
|
||||
"""Pass search properties to scheduler.
|
||||
|
||||
This method acts as an alternative to instantiating schedulers
|
||||
that react to metrics with their own `metric` and `mode` parameters.
|
||||
|
||||
Args:
|
||||
metric: Metric to optimize
|
||||
mode: One of ["min", "max"]. Direction to optimize.
|
||||
**spec: Any kwargs for forward compatibility.
|
||||
Info like Experiment.PUBLIC_KEYS is provided through here.
|
||||
|
||||
Returns:
|
||||
True if the search properties were set successfully, False otherwise.
|
||||
"""
|
||||
if self._metric and metric:
|
||||
return False
|
||||
if metric:
|
||||
self._metric = metric
|
||||
|
||||
if self._metric is None:
|
||||
# Per default, use anonymous metric
|
||||
self._metric = DEFAULT_METRIC
|
||||
|
||||
return True
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Called when a new trial is added to the trial runner."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def on_trial_error(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Notification for the error of trial.
|
||||
|
||||
This will only be called when the trial is in the RUNNING state."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> str:
|
||||
"""Called on each intermediate result returned by a trial.
|
||||
|
||||
At this point, the trial scheduler can make a decision by returning
|
||||
one of CONTINUE, PAUSE, and STOP. This will only be called when the
|
||||
trial is in the RUNNING state."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def on_trial_complete(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
):
|
||||
"""Notification for the completion of trial.
|
||||
|
||||
This will only be called when the trial is in the RUNNING state and
|
||||
either completes naturally or by manual termination."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def on_trial_remove(self, tune_controller: "TuneController", trial: Trial):
|
||||
"""Called to remove trial.
|
||||
|
||||
This is called when the trial is in PAUSED or PENDING state. Otherwise,
|
||||
call `on_trial_complete`."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def choose_trial_to_run(self, tune_controller: "TuneController") -> Optional[Trial]:
|
||||
"""Called to choose a new trial to run.
|
||||
|
||||
This should return one of the trials in tune_controller that is in
|
||||
the PENDING or PAUSED state. This function must be idempotent.
|
||||
|
||||
If no trial is ready, return None."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def debug_string(self) -> str:
|
||||
"""Returns a human readable message for printing to the console."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def save(self, checkpoint_path: str):
|
||||
"""Save trial scheduler to a checkpoint"""
|
||||
raise NotImplementedError
|
||||
|
||||
def restore(self, checkpoint_path: str):
|
||||
"""Restore trial scheduler from checkpoint."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@PublicAPI
|
||||
class FIFOScheduler(TrialScheduler):
|
||||
"""Simple scheduler that just runs trials in submission order."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def on_trial_add(self, tune_controller: "TuneController", trial: Trial):
|
||||
pass
|
||||
|
||||
def on_trial_error(self, tune_controller: "TuneController", trial: Trial):
|
||||
pass
|
||||
|
||||
def on_trial_result(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
) -> str:
|
||||
return TrialScheduler.CONTINUE
|
||||
|
||||
def on_trial_complete(
|
||||
self, tune_controller: "TuneController", trial: Trial, result: Dict
|
||||
):
|
||||
pass
|
||||
|
||||
def on_trial_remove(self, tune_controller: "TuneController", trial: Trial):
|
||||
pass
|
||||
|
||||
def choose_trial_to_run(self, tune_controller: "TuneController") -> Optional[Trial]:
|
||||
for trial in tune_controller.get_trials():
|
||||
if trial.status == Trial.PENDING:
|
||||
return trial
|
||||
for trial in tune_controller.get_trials():
|
||||
if trial.status == Trial.PAUSED:
|
||||
return trial
|
||||
return None
|
||||
|
||||
def debug_string(self) -> str:
|
||||
return "Using FIFO scheduling algorithm."
|
||||
@@ -0,0 +1,27 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _set_search_properties_backwards_compatible(
|
||||
set_search_properties_func, metric: Optional[str], mode: Optional[str], **spec
|
||||
) -> bool:
|
||||
"""Wraps around set_search_properties() so that it is backward compatible.
|
||||
|
||||
Also outputs a warning to encourage custom schedulers to be updated.
|
||||
"""
|
||||
try:
|
||||
return set_search_properties_func(metric, mode, **spec)
|
||||
except TypeError as e:
|
||||
if str(e).startswith(
|
||||
"set_search_properties() got an unexpected keyword argument"
|
||||
):
|
||||
logger.warning(
|
||||
"Please update custom Scheduler to take in function signature "
|
||||
"as ``def set_search_properties(metric, mode, "
|
||||
"**spec) -> bool``."
|
||||
)
|
||||
return set_search_properties_func(metric, mode)
|
||||
else:
|
||||
raise e
|
||||
Reference in New Issue
Block a user