hyperopt_search.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import copy
  2. import logging
  3. from functools import partial
  4. from typing import Any, Dict, List, Optional
  5. import numpy as np
  6. # Use cloudpickle instead of pickle to make lambda funcs in HyperOpt pickleable
  7. from ray import cloudpickle
  8. from ray.tune.error import TuneError
  9. from ray.tune.result import DEFAULT_METRIC
  10. from ray.tune.search import (
  11. UNDEFINED_METRIC_MODE,
  12. UNDEFINED_SEARCH_SPACE,
  13. UNRESOLVED_SEARCH_SPACE,
  14. Searcher,
  15. )
  16. from ray.tune.search.sample import (
  17. Categorical,
  18. Domain,
  19. Float,
  20. Integer,
  21. LogUniform,
  22. Normal,
  23. Quantized,
  24. Uniform,
  25. )
  26. from ray.tune.search.variant_generator import assign_value, parse_spec_vars
  27. from ray.tune.utils import flatten_dict
  28. try:
  29. hyperopt_logger = logging.getLogger("hyperopt")
  30. hyperopt_logger.setLevel(logging.WARNING)
  31. import hyperopt as hpo
  32. from hyperopt.pyll import Apply
  33. except ImportError:
  34. hpo = None
  35. Apply = None
  36. logger = logging.getLogger(__name__)
  37. HYPEROPT_UNDEFINED_DETAILS = (
  38. " This issue can also come up with HyperOpt if your search space only "
  39. "contains constant variables, which is not supported by HyperOpt. In that case, "
  40. "don't pass any searcher or add sample variables to the search space."
  41. )
  42. class HyperOptSearch(Searcher):
  43. """A wrapper around HyperOpt to provide trial suggestions.
  44. HyperOpt a Python library for serial and parallel optimization
  45. over awkward search spaces, which may include real-valued, discrete,
  46. and conditional dimensions. More info can be found at
  47. http://hyperopt.github.io/hyperopt.
  48. HyperOptSearch uses the Tree-structured Parzen Estimators algorithm,
  49. though it can be trivially extended to support any algorithm HyperOpt
  50. supports.
  51. To use this search algorithm, you will need to install HyperOpt:
  52. .. code-block:: bash
  53. pip install -U hyperopt
  54. Parameters:
  55. space: HyperOpt configuration. Parameters will be sampled
  56. from this configuration and will be used to override
  57. parameters generated in the variant generation process.
  58. metric: The training result objective value attribute. If None
  59. but a mode was passed, the anonymous metric `_metric` will be used
  60. per default.
  61. mode: One of {min, max}. Determines whether objective is
  62. minimizing or maximizing the metric attribute.
  63. points_to_evaluate: Initial parameter suggestions to be run
  64. first. This is for when you already have some good parameters
  65. you want to run first to help the algorithm make better suggestions
  66. for future parameters. Needs to be a list of dicts containing the
  67. configurations.
  68. n_initial_points: number of random evaluations of the
  69. objective function before starting to approximate it with
  70. tree parzen estimators. Defaults to 20.
  71. random_state_seed: seed for reproducible
  72. results. Defaults to None.
  73. gamma: parameter governing the tree parzen
  74. estimators suggestion algorithm. Defaults to 0.25.
  75. Tune automatically converts search spaces to HyperOpt's format:
  76. .. code-block:: python
  77. config = {
  78. 'width': tune.uniform(0, 20),
  79. 'height': tune.uniform(-100, 100),
  80. 'activation': tune.choice(["relu", "tanh"])
  81. }
  82. current_best_params = [{
  83. 'width': 10,
  84. 'height': 0,
  85. 'activation': "relu",
  86. }]
  87. hyperopt_search = HyperOptSearch(
  88. metric="mean_loss", mode="min",
  89. points_to_evaluate=current_best_params)
  90. tuner = tune.Tuner(
  91. trainable,
  92. tune_config=tune.TuneConfig(
  93. search_alg=hyperopt_search
  94. ),
  95. param_space=config
  96. )
  97. tuner.fit()
  98. If you would like to pass the search space manually, the code would
  99. look like this:
  100. .. code-block:: python
  101. space = {
  102. 'width': hp.uniform('width', 0, 20),
  103. 'height': hp.uniform('height', -100, 100),
  104. 'activation': hp.choice("activation", ["relu", "tanh"])
  105. }
  106. current_best_params = [{
  107. 'width': 10,
  108. 'height': 0,
  109. 'activation': "relu",
  110. }]
  111. hyperopt_search = HyperOptSearch(
  112. space, metric="mean_loss", mode="min",
  113. points_to_evaluate=current_best_params)
  114. tuner = tune.Tuner(
  115. trainable,
  116. tune_config=tune.TuneConfig(
  117. search_alg=hyperopt_search
  118. ),
  119. )
  120. tuner.fit()
  121. """
  122. def __init__(
  123. self,
  124. space: Optional[Dict] = None,
  125. metric: Optional[str] = None,
  126. mode: Optional[str] = None,
  127. points_to_evaluate: Optional[List[Dict]] = None,
  128. n_initial_points: int = 20,
  129. random_state_seed: Optional[int] = None,
  130. gamma: float = 0.25,
  131. ):
  132. assert (
  133. hpo is not None
  134. ), "HyperOpt must be installed! Run `pip install hyperopt`."
  135. if mode:
  136. assert mode in ["min", "max"], "`mode` must be 'min' or 'max'."
  137. super(HyperOptSearch, self).__init__(
  138. metric=metric,
  139. mode=mode,
  140. )
  141. # hyperopt internally minimizes, so "max" => -1
  142. if mode == "max":
  143. self.metric_op = -1.0
  144. elif mode == "min":
  145. self.metric_op = 1.0
  146. if n_initial_points is None:
  147. self.algo = hpo.tpe.suggest
  148. else:
  149. self.algo = partial(hpo.tpe.suggest, n_startup_jobs=n_initial_points)
  150. if gamma is not None:
  151. self.algo = partial(self.algo, gamma=gamma)
  152. self._points_to_evaluate = copy.deepcopy(points_to_evaluate)
  153. self._live_trial_mapping = {}
  154. self.rstate = np.random.RandomState(random_state_seed)
  155. self.domain = None
  156. if isinstance(space, dict) and space:
  157. resolved_vars, domain_vars, grid_vars = parse_spec_vars(space)
  158. if domain_vars or grid_vars:
  159. logger.warning(
  160. UNRESOLVED_SEARCH_SPACE.format(par="space", cls=type(self))
  161. )
  162. space = self.convert_search_space(space)
  163. self._space = space
  164. self._setup_hyperopt()
  165. def _setup_hyperopt(self) -> None:
  166. from hyperopt.fmin import generate_trials_to_calculate
  167. if not self._space:
  168. raise RuntimeError(
  169. UNDEFINED_SEARCH_SPACE.format(
  170. cls=self.__class__.__name__, space="space"
  171. )
  172. + HYPEROPT_UNDEFINED_DETAILS
  173. )
  174. if self._metric is None and self._mode:
  175. # If only a mode was passed, use anonymous metric
  176. self._metric = DEFAULT_METRIC
  177. if self._points_to_evaluate is None:
  178. self._hpopt_trials = hpo.Trials()
  179. self._points_to_evaluate = 0
  180. else:
  181. assert isinstance(self._points_to_evaluate, (list, tuple))
  182. for i in range(len(self._points_to_evaluate)):
  183. config = self._points_to_evaluate[i]
  184. self._convert_categories_to_indices(config)
  185. # HyperOpt treats initial points as LIFO, reverse to get FIFO
  186. self._points_to_evaluate = list(reversed(self._points_to_evaluate))
  187. self._hpopt_trials = generate_trials_to_calculate(self._points_to_evaluate)
  188. self._hpopt_trials.refresh()
  189. self._points_to_evaluate = len(self._points_to_evaluate)
  190. self.domain = hpo.Domain(lambda spc: spc, self._space)
  191. def _convert_categories_to_indices(self, config) -> None:
  192. """Convert config parameters for categories into hyperopt-compatible
  193. representations where instead the index of the category is expected."""
  194. def _lookup(config_dict, space_dict, key):
  195. if isinstance(config_dict[key], dict):
  196. for k in config_dict[key]:
  197. _lookup(config_dict[key], space_dict[key], k)
  198. else:
  199. if (
  200. key in space_dict
  201. and isinstance(space_dict[key], hpo.base.pyll.Apply)
  202. and space_dict[key].name == "switch"
  203. ):
  204. if len(space_dict[key].pos_args) > 0:
  205. categories = [
  206. a.obj
  207. for a in space_dict[key].pos_args[1:]
  208. if a.name == "literal"
  209. ]
  210. try:
  211. idx = categories.index(config_dict[key])
  212. except ValueError as exc:
  213. msg = (
  214. f"Did not find category with value "
  215. f"`{config_dict[key]}` in "
  216. f"hyperopt parameter `{key}`. "
  217. )
  218. if isinstance(config_dict[key], int):
  219. msg += (
  220. "In previous versions, a numerical "
  221. "index was expected for categorical "
  222. "values of `points_to_evaluate`, "
  223. "but in ray>=1.2.0, the categorical "
  224. "value is expected to be directly "
  225. "provided. "
  226. )
  227. msg += "Please make sure the specified category is valid."
  228. raise ValueError(msg) from exc
  229. config_dict[key] = idx
  230. for k in config:
  231. _lookup(config, self._space, k)
  232. def set_search_properties(
  233. self, metric: Optional[str], mode: Optional[str], config: Dict, **spec
  234. ) -> bool:
  235. if self.domain:
  236. return False
  237. space = self.convert_search_space(config)
  238. self._space = space
  239. if metric:
  240. self._metric = metric
  241. if mode:
  242. self._mode = mode
  243. self.metric_op = -1.0 if self._mode == "max" else 1.0
  244. self._setup_hyperopt()
  245. return True
  246. def suggest(self, trial_id: str) -> Optional[Dict]:
  247. if not self.domain:
  248. raise RuntimeError(
  249. UNDEFINED_SEARCH_SPACE.format(
  250. cls=self.__class__.__name__, space="space"
  251. )
  252. + HYPEROPT_UNDEFINED_DETAILS
  253. )
  254. if not self._metric or not self._mode:
  255. raise RuntimeError(
  256. UNDEFINED_METRIC_MODE.format(
  257. cls=self.__class__.__name__, metric=self._metric, mode=self._mode
  258. )
  259. )
  260. if self._points_to_evaluate > 0:
  261. using_point_to_evaluate = True
  262. new_trial = self._hpopt_trials.trials[self._points_to_evaluate - 1]
  263. self._points_to_evaluate -= 1
  264. else:
  265. using_point_to_evaluate = False
  266. new_ids = self._hpopt_trials.new_trial_ids(1)
  267. self._hpopt_trials.refresh()
  268. # Get new suggestion from Hyperopt
  269. new_trials = self.algo(
  270. new_ids,
  271. self.domain,
  272. self._hpopt_trials,
  273. self.rstate.randint(2**31 - 1),
  274. )
  275. self._hpopt_trials.insert_trial_docs(new_trials)
  276. self._hpopt_trials.refresh()
  277. new_trial = new_trials[0]
  278. self._live_trial_mapping[trial_id] = (new_trial["tid"], new_trial)
  279. # Taken from HyperOpt.base.evaluate
  280. config = hpo.base.spec_from_misc(new_trial["misc"])
  281. # We have to flatten nested spaces here so parameter names match
  282. config = flatten_dict(config, flatten_list=True)
  283. ctrl = hpo.base.Ctrl(self._hpopt_trials, current_trial=new_trial)
  284. memo = self.domain.memo_from_config(config)
  285. hpo.utils.use_obj_for_literal_in_memo(
  286. self.domain.expr, ctrl, hpo.base.Ctrl, memo
  287. )
  288. try:
  289. suggested_config = hpo.pyll.rec_eval(
  290. self.domain.expr,
  291. memo=memo,
  292. print_node_on_error=self.domain.rec_eval_print_node_on_error,
  293. )
  294. except (AssertionError, TypeError) as e:
  295. if using_point_to_evaluate and (
  296. isinstance(e, AssertionError) or "GarbageCollected" in str(e)
  297. ):
  298. raise ValueError(
  299. "HyperOpt encountered a GarbageCollected switch argument. "
  300. "Usually this is caused by a config in "
  301. "`points_to_evaluate` "
  302. "missing a key present in `space`. Ensure that "
  303. "`points_to_evaluate` contains "
  304. "all non-constant keys from `space`.\n"
  305. "Config from `points_to_evaluate`: "
  306. f"{config}\n"
  307. "HyperOpt search space: "
  308. f"{self._space}"
  309. ) from e
  310. raise e
  311. return copy.deepcopy(suggested_config)
  312. def on_trial_result(self, trial_id: str, result: Dict) -> None:
  313. ho_trial = self._get_hyperopt_trial(trial_id)
  314. if ho_trial is None:
  315. return
  316. now = hpo.utils.coarse_utcnow()
  317. ho_trial["book_time"] = now
  318. ho_trial["refresh_time"] = now
  319. def on_trial_complete(
  320. self, trial_id: str, result: Optional[Dict] = None, error: bool = False
  321. ) -> None:
  322. """Notification for the completion of trial.
  323. The result is internally negated when interacting with HyperOpt
  324. so that HyperOpt can "maximize" this value, as it minimizes on default.
  325. """
  326. ho_trial = self._get_hyperopt_trial(trial_id)
  327. if ho_trial is None:
  328. return
  329. ho_trial["refresh_time"] = hpo.utils.coarse_utcnow()
  330. if error:
  331. ho_trial["state"] = hpo.base.JOB_STATE_ERROR
  332. ho_trial["misc"]["error"] = (str(TuneError), "Tune Error")
  333. self._hpopt_trials.refresh()
  334. elif result:
  335. self._process_result(trial_id, result)
  336. del self._live_trial_mapping[trial_id]
  337. def _process_result(self, trial_id: str, result: Dict) -> None:
  338. ho_trial = self._get_hyperopt_trial(trial_id)
  339. if not ho_trial:
  340. return
  341. ho_trial["refresh_time"] = hpo.utils.coarse_utcnow()
  342. ho_trial["state"] = hpo.base.JOB_STATE_DONE
  343. hp_result = self._to_hyperopt_result(result)
  344. ho_trial["result"] = hp_result
  345. self._hpopt_trials.refresh()
  346. def _to_hyperopt_result(self, result: Dict) -> Dict:
  347. try:
  348. return {"loss": self.metric_op * result[self.metric], "status": "ok"}
  349. except KeyError as e:
  350. raise RuntimeError(
  351. f"Hyperopt expected to see the metric `{self.metric}` in the "
  352. f"last result, but it was not found. To fix this, make "
  353. f"sure your call to `tune.report` or your return value of "
  354. f"your trainable class `step()` contains the above metric "
  355. f"as a key."
  356. ) from e
  357. def _get_hyperopt_trial(self, trial_id: str) -> Optional[Dict]:
  358. if trial_id not in self._live_trial_mapping:
  359. return
  360. hyperopt_tid = self._live_trial_mapping[trial_id][0]
  361. return [t for t in self._hpopt_trials.trials if t["tid"] == hyperopt_tid][0]
  362. def get_state(self) -> Dict:
  363. return {
  364. "hyperopt_trials": self._hpopt_trials,
  365. "rstate": self.rstate.get_state(),
  366. }
  367. def set_state(self, state: Dict) -> None:
  368. self._hpopt_trials = state["hyperopt_trials"]
  369. self.rstate.set_state(state["rstate"])
  370. def save(self, checkpoint_path: str) -> None:
  371. save_object = self.__dict__.copy()
  372. save_object["__rstate"] = self.rstate.get_state()
  373. with open(checkpoint_path, "wb") as f:
  374. cloudpickle.dump(save_object, f)
  375. def restore(self, checkpoint_path: str) -> None:
  376. with open(checkpoint_path, "rb") as f:
  377. save_object = cloudpickle.load(f)
  378. if "__rstate" not in save_object:
  379. # Backwards compatibility
  380. self.set_state(save_object)
  381. else:
  382. self.rstate.set_state(save_object.pop("__rstate"))
  383. self.__dict__.update(save_object)
  384. @staticmethod
  385. def convert_search_space(spec: Dict, prefix: str = "") -> Dict:
  386. spec = copy.deepcopy(spec)
  387. resolved_vars, domain_vars, grid_vars = parse_spec_vars(spec)
  388. if not domain_vars and not grid_vars:
  389. return {}
  390. if grid_vars:
  391. raise ValueError(
  392. "Grid search parameters cannot be automatically converted "
  393. "to a HyperOpt search space."
  394. )
  395. def resolve_value(par: str, domain: Domain) -> Any:
  396. quantize = None
  397. sampler = domain.get_sampler()
  398. if isinstance(sampler, Quantized):
  399. quantize = sampler.q
  400. sampler = sampler.sampler
  401. if isinstance(domain, Float):
  402. if isinstance(sampler, LogUniform):
  403. if quantize:
  404. return hpo.hp.qloguniform(
  405. par, np.log(domain.lower), np.log(domain.upper), quantize
  406. )
  407. return hpo.hp.loguniform(
  408. par, np.log(domain.lower), np.log(domain.upper)
  409. )
  410. elif isinstance(sampler, Uniform):
  411. if quantize:
  412. return hpo.hp.quniform(
  413. par, domain.lower, domain.upper, quantize
  414. )
  415. return hpo.hp.uniform(par, domain.lower, domain.upper)
  416. elif isinstance(sampler, Normal):
  417. if quantize:
  418. return hpo.hp.qnormal(par, sampler.mean, sampler.sd, quantize)
  419. return hpo.hp.normal(par, sampler.mean, sampler.sd)
  420. elif isinstance(domain, Integer):
  421. if isinstance(sampler, LogUniform):
  422. if quantize:
  423. return hpo.base.pyll.scope.int(
  424. hpo.hp.qloguniform(
  425. par,
  426. np.log(domain.lower),
  427. np.log(domain.upper),
  428. quantize,
  429. )
  430. )
  431. return hpo.base.pyll.scope.int(
  432. hpo.hp.qloguniform(
  433. par, np.log(domain.lower), np.log(domain.upper - 1), 1.0
  434. )
  435. )
  436. elif isinstance(sampler, Uniform):
  437. if quantize:
  438. return hpo.base.pyll.scope.int(
  439. hpo.hp.quniform(
  440. par, domain.lower, domain.upper - 1, quantize
  441. )
  442. )
  443. return hpo.hp.uniformint(par, domain.lower, high=domain.upper - 1)
  444. elif isinstance(domain, Categorical):
  445. if isinstance(sampler, Uniform):
  446. return hpo.hp.choice(
  447. par,
  448. [
  449. (
  450. HyperOptSearch.convert_search_space(
  451. category, prefix=par
  452. )
  453. if isinstance(category, dict)
  454. else (
  455. HyperOptSearch.convert_search_space(
  456. dict(enumerate(category)), prefix=f"{par}/{i}"
  457. )
  458. if isinstance(category, list)
  459. and len(category) > 0
  460. and isinstance(category[0], Domain)
  461. else (
  462. resolve_value(f"{par}/{i}", category)
  463. if isinstance(category, Domain)
  464. else category
  465. )
  466. )
  467. )
  468. for i, category in enumerate(domain.categories)
  469. ],
  470. )
  471. raise ValueError(
  472. "HyperOpt does not support parameters of type "
  473. "`{}` with samplers of type `{}`".format(
  474. type(domain).__name__, type(domain.sampler).__name__
  475. )
  476. )
  477. for path, domain in domain_vars:
  478. par = "/".join([str(p) for p in ((prefix,) + path if prefix else path)])
  479. value = resolve_value(par, domain)
  480. assign_value(spec, path, value)
  481. return spec