Unverified Commit 2f8524ba authored by liuzhe-lz's avatar liuzhe-lz Committed by GitHub
Browse files

Deduplicate generated parameters for TPE and Random (#4679)

parent 5136a86d
...@@ -20,7 +20,7 @@ if dispatcher_env_vars.SDK_PROCESS != 'dispatcher': ...@@ -20,7 +20,7 @@ if dispatcher_env_vars.SDK_PROCESS != 'dispatcher':
from .common.nas_utils import training_update from .common.nas_utils import training_update
class NoMoreTrialError(Exception): class NoMoreTrialError(Exception):
def __init__(self, ErrorInfo): def __init__(self, ErrorInfo='Search space fully explored'):
super().__init__(self) super().__init__(self)
self.errorinfo = ErrorInfo self.errorinfo = ErrorInfo
......
...@@ -200,13 +200,18 @@ class GridSearchTuner(Tuner): ...@@ -200,13 +200,18 @@ class GridSearchTuner(Tuner):
mid = (l + r) / 2 mid = (l + r) / 2
diff_l = _less(l, mid, spec) diff_l = _less(l, mid, spec)
diff_r = _less(mid, r, spec) diff_r = _less(mid, r, spec)
if diff_l and diff_r: # we can skip these for non-q, but it will complicate the code # if l != 0 and r != 1, then they are already in the grid, else they are not
# the special case is needed because for normal distribution 0 and 1 will generate infinity
if (diff_l or l == 0.0) and (diff_r or r == 1.0):
# we can skip these for non-q, but it will complicate the code
new_vals.append(mid) new_vals.append(mid)
updated = True updated = True
if diff_l: if diff_l:
new_divs.append((l, mid)) new_divs.append((l, mid))
updated = (updated or l == 0.0)
if diff_r: if diff_r:
new_divs.append((mid, r)) new_divs.append((mid, r))
updated = (updated or r == 1.0)
self.grid[i] += new_vals self.grid[i] += new_vals
self.divisions[i] = new_divs self.divisions[i] = new_divs
......
...@@ -17,7 +17,7 @@ import numpy as np ...@@ -17,7 +17,7 @@ import numpy as np
import schema import schema
from nni import ClassArgsValidator from nni import ClassArgsValidator
from nni.common.hpo_utils import format_search_space, deformat_parameters from nni.common.hpo_utils import Deduplicator, format_search_space, deformat_parameters
from nni.tuner import Tuner from nni.tuner import Tuner
_logger = logging.getLogger('nni.tuner.random') _logger = logging.getLogger('nni.tuner.random')
...@@ -47,13 +47,16 @@ class RandomTuner(Tuner): ...@@ -47,13 +47,16 @@ class RandomTuner(Tuner):
if seed is None: # explicitly generate a seed to make the experiment reproducible if seed is None: # explicitly generate a seed to make the experiment reproducible
seed = np.random.default_rng().integers(2 ** 31) seed = np.random.default_rng().integers(2 ** 31)
self.rng = np.random.default_rng(seed) self.rng = np.random.default_rng(seed)
self.dedup = None
_logger.info(f'Using random seed {seed}') _logger.info(f'Using random seed {seed}')
def update_search_space(self, space): def update_search_space(self, space):
self.space = format_search_space(space) self.space = format_search_space(space)
self.dedup = Deduplicator(self.space)
def generate_parameters(self, *args, **kwargs): def generate_parameters(self, *args, **kwargs):
params = suggest(self.rng, self.space) params = suggest(self.rng, self.space)
params = self.dedup(params)
return deformat_parameters(params, self.space) return deformat_parameters(params, self.space)
def receive_trial_result(self, *args, **kwargs): def receive_trial_result(self, *args, **kwargs):
......
...@@ -23,7 +23,7 @@ from typing import Any, NamedTuple ...@@ -23,7 +23,7 @@ from typing import Any, NamedTuple
import numpy as np import numpy as np
from scipy.special import erf # pylint: disable=no-name-in-module from scipy.special import erf # pylint: disable=no-name-in-module
from nni.common.hpo_utils import OptimizeMode, format_search_space, deformat_parameters, format_parameters from nni.common.hpo_utils import Deduplicator, OptimizeMode, format_search_space, deformat_parameters, format_parameters
from nni.tuner import Tuner from nni.tuner import Tuner
from nni.typehint import Literal from nni.typehint import Literal
from nni.utils import extract_scalar_reward from nni.utils import extract_scalar_reward
...@@ -153,6 +153,7 @@ class TpeTuner(Tuner): ...@@ -153,6 +153,7 @@ class TpeTuner(Tuner):
# concurrent generate_parameters() calls are likely to yield similar result, because they use same history # concurrent generate_parameters() calls are likely to yield similar result, because they use same history
# the liar solves this problem by adding fake results to history # the liar solves this problem by adding fake results to history
self.liar = create_liar(self.args.constant_liar_type) self.liar = create_liar(self.args.constant_liar_type)
self.dedup = None
if seed is None: # explicitly generate a seed to make the experiment reproducible if seed is None: # explicitly generate a seed to make the experiment reproducible
seed = np.random.default_rng().integers(2 ** 31) seed = np.random.default_rng().integers(2 ** 31)
...@@ -165,6 +166,7 @@ class TpeTuner(Tuner): ...@@ -165,6 +166,7 @@ class TpeTuner(Tuner):
def update_search_space(self, space): def update_search_space(self, space):
self.space = format_search_space(space) self.space = format_search_space(space)
self.dedup = Deduplicator(self.space)
def generate_parameters(self, parameter_id, **kwargs): def generate_parameters(self, parameter_id, **kwargs):
if self.liar and self._running_params: if self.liar and self._running_params:
...@@ -178,6 +180,7 @@ class TpeTuner(Tuner): ...@@ -178,6 +180,7 @@ class TpeTuner(Tuner):
history = self._history history = self._history
params = suggest(self.args, self.rng, self.space, history) params = suggest(self.args, self.rng, self.space, history)
params = self.dedup(params)
self._params[parameter_id] = params self._params[parameter_id] = params
self._running_params[parameter_id] = params self._running_params[parameter_id] = params
......
# Copyright (c) Microsoft Corporation. # Copyright (c) Microsoft Corporation.
# Licensed under the MIT license. # Licensed under the MIT license.
from .dedup import Deduplicator
from .validation import validate_search_space from .validation import validate_search_space
from .formatting import * from .formatting import *
from .optimize_mode import OptimizeMode from .optimize_mode import OptimizeMode
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
"""
Deduplicate repeated parameters.
No guarantee for forward-compatibility.
"""
import logging
import nni
from .formatting import deformat_parameters
_logger = logging.getLogger(__name__)
# TODO:
# Move main logic of basic tuners (random and grid search) into SDK,
# so we can get rid of private methods and circular dependency.
class Deduplicator:
"""
A helper for tuners to deduplicate generated parameters.
When the tuner generates an already existing parameter,
calling this will return a new parameter generated with grid search.
Otherwise it returns the orignial parameter object.
If all parameters have been generated, raise ``NoMoreTrialError``.
All search space types, including nested choice, are supported.
Resuming and updating search space are not supported for now.
It will not raise error, but may return duplicate parameters.
See random tuner's source code for example usage.
"""
def __init__(self, formatted_search_space):
self._space = formatted_search_space
self._never_dup = any(_spec_never_dup(spec) for spec in self._space.values())
self._history = set()
self._grid_search = None
def __call__(self, formatted_parameters):
if self._never_dup or self._not_dup(formatted_parameters):
return formatted_parameters
if self._grid_search is None:
_logger.info(f'Tuning algorithm generated duplicate parameter: {formatted_parameters}')
_logger.info(f'Use grid search for deduplication.')
self._init_grid_search()
while True:
new = self._grid_search._suggest()
if new is None:
raise nni.NoMoreTrialError()
if self._not_dup(new):
return new
def _init_grid_search(self):
from nni.algorithms.hpo.gridsearch_tuner import GridSearchTuner
self._grid_search = GridSearchTuner()
self._grid_search.history = self._history
self._grid_search.space = self._space
self._grid_search._init_grid()
def _not_dup(self, formatted_parameters):
params = deformat_parameters(formatted_parameters, self._space)
params_str = nni.dump(params, sort_keys=True)
if params_str in self._history:
return False
else:
self._history.add(params_str)
return True
def _spec_never_dup(spec):
if spec.is_nested():
return False # "not chosen" duplicates with "not chosen"
if spec.categorical or spec.q is not None:
return False
if spec.normal_distributed:
return spec.sigma > 0
else:
return spec.low < spec.high
...@@ -78,9 +78,16 @@ class ParameterSpec(NamedTuple): ...@@ -78,9 +78,16 @@ class ParameterSpec(NamedTuple):
For nested search space, check whether this parameter should be skipped for current set of paremters. For nested search space, check whether this parameter should be skipped for current set of paremters.
This function must be used in a pattern similar to random tuner. Otherwise it will misbehave. This function must be used in a pattern similar to random tuner. Otherwise it will misbehave.
""" """
if len(self.key) < 2 or isinstance(self.key[-2], str): if self.is_nested():
return partial_parameters[self.key[:-2]] == self.key[-2]
else:
return True return True
return partial_parameters[self.key[:-2]] == self.key[-2]
def is_nested(self):
"""
Check whether this parameter is inside a nested choice.
"""
return len(self.key) >= 2 and isinstance(self.key[-2], int)
def format_search_space(search_space: SearchSpace) -> FormattedSearchSpace: def format_search_space(search_space: SearchSpace) -> FormattedSearchSpace:
""" """
...@@ -88,10 +95,6 @@ def format_search_space(search_space: SearchSpace) -> FormattedSearchSpace: ...@@ -88,10 +95,6 @@ def format_search_space(search_space: SearchSpace) -> FormattedSearchSpace:
The dict key is dict value's `ParameterSpec.key`. The dict key is dict value's `ParameterSpec.key`.
""" """
formatted = _format_search_space(tuple(), search_space) formatted = _format_search_space(tuple(), search_space)
# In CPython 3.6, dicts preserve order by internal implementation.
# In Python 3.7+, dicts preserve order by language spec.
# Python 3.6 is crappy enough. Don't bother to do extra work for it.
# Remove these comments when we drop 3.6 support.
return {spec.key: spec for spec in formatted} return {spec.key: spec for spec in formatted}
def deformat_parameters( def deformat_parameters(
......
...@@ -324,11 +324,13 @@ class BuiltinTunersTestCase(TestCase): ...@@ -324,11 +324,13 @@ class BuiltinTunersTestCase(TestCase):
self.import_data_test(tuner_fn, support_middle=False) self.import_data_test(tuner_fn, support_middle=False)
def test_tpe(self): def test_tpe(self):
self.exhaustive = True
tuner_fn = TpeTuner tuner_fn = TpeTuner
self.search_space_test_all(TpeTuner) self.search_space_test_all(TpeTuner)
self.import_data_test(tuner_fn) self.import_data_test(tuner_fn)
def test_random_search(self): def test_random_search(self):
self.exhaustive = True
tuner_fn = RandomTuner tuner_fn = RandomTuner
self.search_space_test_all(tuner_fn) self.search_space_test_all(tuner_fn)
self.import_data_test(tuner_fn) self.import_data_test(tuner_fn)
......
import numpy as np
import nni
from nni.algorithms.hpo.random_tuner import suggest
from nni.common.hpo_utils import Deduplicator, deformat_parameters, format_search_space
seed = np.random.default_rng().integers(2 ** 31)
print(seed)
rng = np.random.default_rng(seed)
finite_space = {
'x': {'_type': 'choice', '_value': ['a', 'b']},
'y': {'_type': 'quniform', '_value': [0, 1, 0.6]},
'z': {'_type': 'normal', '_value': [1, 0]},
}
infinite_space = {
'x': {'_type': 'choice', '_value': ['a', 'b']},
'y': {'_type': 'uniform', '_value': [0, 1]},
}
nested_space = {
'outer': {
'_type': 'choice',
'_value': [
{'_name': 'A', 'x': {'_type': 'choice', '_value': ['a', 'b']}},
{'_name': 'B', 'y': {'_type': 'uniform', '_value': [0, 1]}},
]
}
}
def test_dedup_finite():
space = format_search_space(finite_space)
dedup = Deduplicator(space)
params = []
exhausted = False
try:
for i in range(7):
p = dedup(suggest(rng, space))
params.append(deformat_parameters(p, space))
except nni.NoMoreTrialError:
exhausted = True
params = sorted(params, key=(lambda p: (p['x'], p['y'], p['z'])))
assert exhausted
assert params == [
{'x': 'a', 'y': 0.0, 'z': 1.0},
{'x': 'a', 'y': 0.6, 'z': 1.0},
{'x': 'a', 'y': 1.0, 'z': 1.0},
{'x': 'b', 'y': 0.0, 'z': 1.0},
{'x': 'b', 'y': 0.6, 'z': 1.0},
{'x': 'b', 'y': 1.0, 'z': 1.0},
]
def test_dedup_infinite():
space = format_search_space(infinite_space)
dedup = Deduplicator(space)
for i in range(10):
p = suggest(rng, space)
assert dedup(p) is p
def test_dedup_nested():
space = format_search_space(nested_space)
dedup = Deduplicator(space)
params = set()
for i in range(10):
p = dedup(suggest(rng, space))
s = nni.dump(deformat_parameters(p, space), sort_keys=True)
assert s not in params
params.add(s)
if __name__ == '__main__':
test_dedup_finite()
test_dedup_infinite()
test_dedup_nested()
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment