From bb7ffdf94639c37c4ba3d920b9832427c3d078a7 Mon Sep 17 00:00:00 2001 From: Jeremy Rapin Date: Tue, 28 Jan 2020 09:53:02 +0100 Subject: [PATCH] Add a parameter-based ES algorithm --- nevergrad/benchmark/experiments.py | 19 ++++ nevergrad/functions/__init__.py | 1 + nevergrad/functions/functionlib.py | 66 +++++++++++-- nevergrad/functions/test_functionlib.py | 24 ++++- nevergrad/optimization/base.py | 3 + nevergrad/optimization/callbacks.py | 29 ++++-- nevergrad/optimization/es.py | 99 +++++++++++++++++++ nevergrad/optimization/families.py | 3 +- nevergrad/optimization/optimizerlib.py | 6 +- .../optimization/recorded_recommendations.csv | 6 ++ nevergrad/optimization/test_callbacks.py | 12 +-- nevergrad/optimization/test_optimizerlib.py | 11 +-- nevergrad/parametrization/data.py | 15 ++- nevergrad/parametrization/helpers.py | 41 ++++++-- nevergrad/parametrization/parameter.py | 1 + nevergrad/parametrization/test_parameter.py | 14 --- nevergrad/parametrization/test_utils.py | 82 ++++++++++++++- nevergrad/parametrization/utils.py | 52 +++++++++- 18 files changed, 424 insertions(+), 60 deletions(-) create mode 100644 nevergrad/optimization/es.py diff --git a/nevergrad/benchmark/experiments.py b/nevergrad/benchmark/experiments.py index 70b49d553..df372d2bb 100644 --- a/nevergrad/benchmark/experiments.py +++ b/nevergrad/benchmark/experiments.py @@ -8,6 +8,7 @@ import nevergrad as ng from ..functions import ExperimentFunction from ..functions import ArtificialFunction +from ..functions import FarOptimumFunction from ..functions import MultiobjectiveFunction from ..functions import mlda as _mlda from ..functions.arcoating import ARCoating @@ -717,3 +718,21 @@ def manyobjective_example(seed: Optional[int] = None) -> Iterator[Experiment]: for budget in list(range(100, 5901, 400)): for nw in [1, 100]: yield Experiment(mofunc, optim, budget=budget, num_workers=nw, seed=next(seedg)) + + +@registry.register +def far_optimum_es(seed: Optional[int] = None) -> Iterator[Experiment]: + # prepare list of parameters to sweep for independent variables + seedg = create_seed_generator(seed) + popsizes = [5, 40] + es = [ng.families.EvolutionStrategy(recombinations=recomb, only_offsprings=False, popsize=pop) + for recomb in [0, 1] for pop in popsizes] + es += [ng.families.EvolutionStrategy(recombinations=recomb, only_offsprings=only, popsize=pop, + offsprings=10 if pop == 5 else 60) + for only in [True, False] for recomb in [0, 1] for pop in popsizes] + optimizers = ["CMA", "TwoPointsDE"] + es # type: ignore + for func in FarOptimumFunction.itercases(): + for optim in optimizers: + for budget in [100, 400, 1000, 4000, 10000]: + for _ in range(2): + yield Experiment(func, optim, budget=budget, seed=next(seedg)) diff --git a/nevergrad/functions/__init__.py b/nevergrad/functions/__init__.py index 21cc09bf7..61f401066 100644 --- a/nevergrad/functions/__init__.py +++ b/nevergrad/functions/__init__.py @@ -4,5 +4,6 @@ # LICENSE file in the root directory of this source tree. from .functionlib import ArtificialFunction as ArtificialFunction +from .functionlib import FarOptimumFunction as FarOptimumFunction from .multiobjective import MultiobjectiveFunction as MultiobjectiveFunction from .base import ExperimentFunction as ExperimentFunction diff --git a/nevergrad/functions/functionlib.py b/nevergrad/functions/functionlib.py index 5fdfaee45..a2c23fda7 100644 --- a/nevergrad/functions/functionlib.py +++ b/nevergrad/functions/functionlib.py @@ -4,12 +4,15 @@ # LICENSE file in the root directory of this source tree. import hashlib -from typing import List, Any, Callable +import itertools +import typing as tp import numpy as np from nevergrad.parametrization import parameter as p +from nevergrad.parametrization import utils as putils from nevergrad.common import tools from nevergrad.common.typetools import ArrayLike from .base import ExperimentFunction +from .multiobjective import MultiobjectiveFunction from . import utils from . import corefuncs @@ -22,7 +25,7 @@ class ArtificialVariable: def __init__(self, dimension: int, num_blocks: int, block_dimension: int, translation_factor: float, rotation: bool, hashing: bool, only_index_transform: bool) -> None: self._dimension = dimension - self._transforms: List[utils.Transform] = [] + self._transforms: tp.List[utils.Transform] = [] self.rotation = rotation self.translation_factor = translation_factor self.num_blocks = num_blocks @@ -163,7 +166,7 @@ def dimension(self) -> int: return self._dimension # bypass the instrumentation one (because of the "hashing" case) # TODO: remove @staticmethod - def list_sorted_function_names() -> List[str]: + def list_sorted_function_names() -> tp.List[str]: """Returns a sorted list of function names that can be used for the blocks """ return sorted(corefuncs.registry) @@ -181,7 +184,7 @@ def function_from_transform(self, x: np.ndarray) -> float: results.append(self._func(block)) return float(self._aggregator(results)) - def evaluation_function(self, *args: Any, **kwargs: Any) -> float: + def evaluation_function(self, *args: tp.Any, **kwargs: tp.Any) -> float: """Implements the call of the function. Under the hood, __call__ delegates to oracle_call + add some noise if noise_level > 0. """ @@ -193,7 +196,7 @@ def noisy_function(self, x: ArrayLike) -> float: return _noisy_call(x=np.array(x, copy=False), transf=self._transform, func=self.function_from_transform, noise_level=self._parameters["noise_level"], noise_dissymmetry=self._parameters["noise_dissymmetry"]) - def compute_pseudotime(self, input_parameter: Any, value: float) -> float: + def compute_pseudotime(self, input_parameter: tp.Any, value: float) -> float: """Delay before returning results in steady state mode benchmarks (fake execution time) """ args, kwargs = input_parameter @@ -208,7 +211,7 @@ def compute_pseudotime(self, input_parameter: Any, value: float) -> float: return 1. -def _noisy_call(x: np.ndarray, transf: Callable[[np.ndarray], np.ndarray], func: Callable[[np.ndarray], float], +def _noisy_call(x: np.ndarray, transf: tp.Callable[[np.ndarray], np.ndarray], func: tp.Callable[[np.ndarray], float], noise_level: float, noise_dissymmetry: bool) -> float: # pylint: disable=unused-argument x_transf = transf(x) fx = func(x_transf) @@ -220,3 +223,54 @@ def _noisy_call(x: np.ndarray, transf: Callable[[np.ndarray], np.ndarray], func: noise_level *= (1. + x_transf.ravel()[0] * 100.) noise = noise_level * np.random.normal(0, 1) * (func(side_point) - fx) return fx + noise + + +class FarOptimumFunction(ExperimentFunction): + """Very simple 2D norm-1 function with optimal value at (x_optimum, 100) + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + independent_sigma: bool = True, + mutable_sigma: bool = True, + multiobjective: bool = False, + recombination: str = "crossover", + optimum: tp.Tuple[int, int] = (80, 100) + ) -> None: + assert recombination in ("crossover", "average") + self._optimum = np.array(optimum, dtype=float) + parametrization = p.Array(shape=(2,), mutable_sigma=mutable_sigma) + init = np.array([1.0, 1.0] if independent_sigma else [1.0], dtype=float) + parametrization.set_mutation( + sigma=p.Array(init=init).set_mutation(exponent=1.2) if mutable_sigma else p.Constant(init) # type: ignore + ) + parametrization.set_recombination("average" if recombination == "average" else putils.Crossover()) + self._multiobjective = MultiobjectiveFunction(self._multifunc, 2 * self._optimum) + super().__init__(self._multiobjective if multiobjective else self._monofunc, parametrization.set_name("")) # type: ignore + descr = dict(independent_sigma=independent_sigma, mutable_sigma=mutable_sigma, + multiobjective=multiobjective, optimum=optimum, recombination=recombination) + self._descriptors.update(descr) + self.register_initialization(**descr) + + def _multifunc(self, x: np.ndarray) -> np.ndarray: + return np.abs(x - self._optimum) # type: ignore + + def _monofunc(self, x: np.ndarray) -> float: + return float(np.sum(self._multifunc(x))) + + def evaluation_function(self, *args: tp.Any, **kwargs: tp.Any) -> float: + return self._monofunc(args[0]) + + @classmethod + def itercases(cls) -> tp.Iterator["FarOptimumFunction"]: + options = dict(independent_sigma=[True, False], + mutable_sigma=[True, False], + multiobjective=[True, False], + recombination=["average", "crossover"], + optimum=[(.8, 1), (80, 100), (.8, 100)] + ) + keys = sorted(options) + select = itertools.product(*(options[k] for k in keys)) # type: ignore + cases = (dict(zip(keys, s)) for s in select) + return (cls(**c) for c in cases) diff --git a/nevergrad/functions/test_functionlib.py b/nevergrad/functions/test_functionlib.py index ee59a56a2..38fb6bb76 100644 --- a/nevergrad/functions/test_functionlib.py +++ b/nevergrad/functions/test_functionlib.py @@ -5,7 +5,9 @@ from typing import Any, Dict import numpy as np -from ..common import testing +import pytest +from nevergrad.common import testing +from nevergrad.parametrization import parameter as p from . import functionlib @@ -148,3 +150,23 @@ def test_noisy_call(x: int, noise: bool, noise_dissymmetry: bool, expect_noisy: np.testing.assert_raises(AssertionError, np.testing.assert_almost_equal, fx, x, decimal=8) else: np.testing.assert_almost_equal(fx, x, decimal=8) + + +@pytest.mark.parametrize("independent_sigma", [True, False]) # type: ignore +@pytest.mark.parametrize("mutable_sigma", [True, False]) # type: ignore +def test_far_optimum_function(independent_sigma: bool, mutable_sigma: bool) -> None: + func = functionlib.FarOptimumFunction(independent_sigma=independent_sigma, mutable_sigma=mutable_sigma).copy() + param = func.parametrization.spawn_child() + assert isinstance(param, p.Array) + assert isinstance(param.sigma, p.Array) == mutable_sigma + assert param.sigma.value.size == (1 + independent_sigma) + param.mutate() + new_val = param.sigma.value + assert bool(np.sum(np.abs(new_val - func.parametrization.sigma.value))) == mutable_sigma # type: ignore + if independent_sigma and mutable_sigma: + assert new_val[0] != new_val[1] + + +def test_far_optimum_function_cases() -> None: + cases = list(functionlib.FarOptimumFunction.itercases()) + assert len(cases) == 48 diff --git a/nevergrad/optimization/base.py b/nevergrad/optimization/base.py index 1b9abb1fc..0919082bb 100644 --- a/nevergrad/optimization/base.py +++ b/nevergrad/optimization/base.py @@ -588,6 +588,9 @@ def __init__(self) -> None: different = ngtools.different_from_defaults(self, check_mismatches=True) super().__init__(**different) + def config(self) -> tp.Dict[str, tp.Any]: + return {x: y for x, y in self.__dict__.items() if not x.startswith("_")} + def __call__( self, instrumentation: IntOrParameter, budget: Optional[int] = None, num_workers: int = 1 ) -> Optimizer: diff --git a/nevergrad/optimization/callbacks.py b/nevergrad/optimization/callbacks.py index ac81fd8cc..5a631ff4b 100644 --- a/nevergrad/optimization/callbacks.py +++ b/nevergrad/optimization/callbacks.py @@ -11,6 +11,7 @@ from pathlib import Path import numpy as np from nevergrad.parametrization import parameter as p +from nevergrad.parametrization import helpers from . import base @@ -50,6 +51,10 @@ class ParametersLogger: ---------- filepath: str or pathlib.Path the path to dump data to + append: bool + whether to append the file (otherwise it replaces it) + order: int + order of the internal/model parameters to extract Usage ----- @@ -64,15 +69,16 @@ class ParametersLogger: - this class will eventually contain display methods """ - def __init__(self, filepath: Union[str, Path], delete_existing_file: bool = False) -> None: - self._session = datetime.datetime.now().strftime("%y-%m-%d %H:%M") + def __init__(self, filepath: Union[str, Path], append: bool = True, order: int = 1) -> None: + self._session = datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S") self._filepath = Path(filepath) - if self._filepath.exists() and delete_existing_file: + self._order = order + if self._filepath.exists() and not append: self._filepath.unlink() # missing_ok argument added in python 3.8 def __call__(self, optimizer: base.Optimizer, candidate: p.Parameter, value: float) -> None: data = {"#instrumentation": optimizer.instrumentation.name, - "#name": optimizer.name, + "#optimizer": optimizer.name, "#session": self._session, "#num-ask": optimizer.num_ask, "#num-tell": optimizer.num_tell, @@ -81,11 +87,18 @@ def __call__(self, optimizer: base.Optimizer, candidate: p.Parameter, value: flo "#generation": candidate.generation, "#parents_uids": [], "#loss": value} + if hasattr(optimizer, "_parameters"): + configopt = optimizer._parameters # type: ignore + if isinstance(configopt, base.ParametrizedFamily): + data.update({"#optimizer#" + x: y for x, y in configopt.config().items()}) if candidate.generation > 1: data["#parents_uids"] = candidate.parents_uids - params = dict(candidate.kwargs) - params.update({f"#arg{k}": arg for k, arg in enumerate(candidate.args)}) - data.update({k: v.tolist() if isinstance(v, np.ndarray) else v for k, v in params.items()}) + for name, param in helpers.flatten_parameter(candidate, with_containers=False, order=1).items(): + val = param.value + data[name if name else "0"] = val.tolist() if isinstance(val, np.ndarray) else val + if isinstance(param, p.Array): + val = param.sigma.value + data[(name if name else "0") + "#sigma"] = val.tolist() if isinstance(val, np.ndarray) else val try: # avoid bugging as much as possible with self._filepath.open("a") as f: f.write(json.dumps(data) + "\n") @@ -109,7 +122,7 @@ def load_flattened(self, max_list_elements: int = 24) -> List[Dict[str, Any]]: ---------- max_list_elements: int Maximum number of elements displayed from the array, each element is given a - unique id of type list_name#i1_i2_... + unique id of type list_name#i0_i1_... """ data = self.load() flat_data: List[Dict[str, Any]] = [] diff --git a/nevergrad/optimization/es.py b/nevergrad/optimization/es.py new file mode 100644 index 000000000..9adb26f1b --- /dev/null +++ b/nevergrad/optimization/es.py @@ -0,0 +1,99 @@ +import warnings +import typing as tp +# import numpy as np +from nevergrad.parametrization import parameter as p +from nevergrad.common.tools import OrderedSet +from . import base + + +class _EvolutionStrategy(base.Optimizer): + + def __init__(self, instrumentation: base.IntOrParameter, budget: tp.Optional[int] = None, num_workers: int = 1) -> None: + if budget is not None and budget < 60: + warnings.warn("DE algorithms are inefficient with budget < 60", base.InefficientSettingsWarning) + super().__init__(instrumentation, budget=budget, num_workers=num_workers) + self._parameters = EvolutionStrategy() + self._population: tp.Dict[str, p.Parameter] = {} + self._told_queue = tp.Deque[str]() + self._asked_queue = OrderedSet[str]() + self._waiting: tp.List[p.Parameter] = [] + + def _internal_ask_candidate(self) -> p.Parameter: + if self.num_ask < self._parameters.popsize or not self._population: + param = self.instrumentation.sample() + return param + if self._told_queue: + uid = self._told_queue.popleft() + else: + uid = next(iter(self._asked_queue)) + param = self._population[uid].spawn_child() + param.mutate() + # if self._parameters.de_step and len(self._population) > 1: + # sdata = [self._population[s].get_standardized_data(reference=self.instrumentation) + # for s in self._rng.choice(list(self._population), 2, replace=False)] + # F1, F2 = 0.8, 0.8 + # data = param.get_standardized_data(reference=self.instrumentation) + # data += F2 * (self.current_bests["pessimistic"].x - data) + # #data += F1 * (sdata[1] - sdata[0]) + # param.set_standardized_data(data, reference=self.instrumentation) + if self._parameters.recombinations: + selected = self._rng.choice(list(self._population), self._parameters.recombinations, replace=False) + param.recombine(*(self._population[s] for s in selected)) + self._asked_queue.add(uid) + return param + + def _internal_tell_candidate(self, candidate: p.Parameter, value: float) -> None: + candidate._meta["value"] = value + if self._parameters.offsprings is None: + uid = candidate.heritage["lineage"] + parent_value = float('inf') if uid not in self._population else self._population[uid]._meta["value"] + if value < parent_value: + self._population[uid] = candidate + self._told_queue.append(uid) + else: + uid = candidate.uid + if candidate.parents_uids[0] not in self._population and len(self._population) < self._parameters.popsize: + self._population[uid] = candidate + self._told_queue.append(uid) + else: + self._waiting.append(candidate) + if len(self._waiting) >= self._parameters.offsprings: + choices = self._waiting + ([] if self._parameters.only_offsprings else list(self._population.values())) + choices.sort(key=lambda x: x._meta["value"]) + self._population = {x.uid: x for x in choices[:self._parameters.popsize]} + self._told_queue.clear() + self._asked_queue.clear() + self._waiting.clear() + self._told_queue.extend(list(self._population)) + + +class EvolutionStrategy(base.ParametrizedFamily): + + _optimizer_class = _EvolutionStrategy + + def __init__( + self, + *, + recombinations: int = 0, + popsize: int = 40, + offsprings: tp.Optional[int] = None, + only_offsprings: bool = False, + # de_step: bool = False, + ) -> None: + assert offsprings is None or not only_offsprings or offsprings > popsize + if only_offsprings: + assert offsprings is not None, "only_offsprings only work if offsprings is not None (non-DE mode)" + self.recombinations = recombinations + self.popsize = popsize + self.offsprings = offsprings + self.only_offsprings = only_offsprings + # self.de_step = de_step + super().__init__() + + +RecES = EvolutionStrategy(recombinations=1, only_offsprings=True, offsprings=60).with_name("RecES", register=True) +RecMixES = EvolutionStrategy(recombinations=1, only_offsprings=False, offsprings=20).with_name("RecMixES", register=True) +RecMutDE = EvolutionStrategy(recombinations=1, only_offsprings=False, offsprings=None).with_name("RecMutDE", register=True) +ES = EvolutionStrategy(recombinations=0, only_offsprings=True, offsprings=60).with_name("ES", register=True) +MixES = EvolutionStrategy(recombinations=0, only_offsprings=False, offsprings=20).with_name("MixES", register=True) +MutDE = EvolutionStrategy(recombinations=0, only_offsprings=False, offsprings=None).with_name("MutDE", register=True) diff --git a/nevergrad/optimization/families.py b/nevergrad/optimization/families.py index 1a5d062fc..a529ec98d 100644 --- a/nevergrad/optimization/families.py +++ b/nevergrad/optimization/families.py @@ -12,10 +12,11 @@ from .optimizerlib import ParametrizedBO from .optimizerlib import Chaining from .differentialevolution import DifferentialEvolution +from .es import EvolutionStrategy from .recastlib import ScipyOptimizer from .oneshot import RandomSearchMaker from .oneshot import SamplingSearch -__all__ = ["ParametrizedOnePlusOne", "ParametrizedBO", "DifferentialEvolution", +__all__ = ["ParametrizedOnePlusOne", "ParametrizedBO", "DifferentialEvolution", "EvolutionStrategy", "ScipyOptimizer", "RandomSearchMaker", "SamplingSearch", "Chaining"] diff --git a/nevergrad/optimization/optimizerlib.py b/nevergrad/optimization/optimizerlib.py index 518f11d26..647471718 100644 --- a/nevergrad/optimization/optimizerlib.py +++ b/nevergrad/optimization/optimizerlib.py @@ -16,7 +16,6 @@ from nevergrad.parametrization import helpers as paramhelpers from nevergrad.common.typetools import ArrayLike from nevergrad.functions import MultiobjectiveFunction -from nevergrad import instrumentation as inst from . import utils from . import base from . import mutations @@ -30,6 +29,7 @@ # families of optimizers # pylint: disable=unused-wildcard-import,wildcard-import, too-many-lines from .differentialevolution import * # noqa: F403 +from .es import * # noqa: F403 from .oneshot import * # noqa: F403 from .rescaledoneshot import * # noqa: F403 from .recastlib import * # noqa: F403 @@ -1543,8 +1543,8 @@ def __init__(self, instrumentation: IntOrParameter, budget: Optional[int] = None descr = self.instrumentation.descriptors self.has_noise = not (descr.deterministic and descr.deterministic_function) self.fully_continuous = descr.continuous - all_params = paramhelpers.list_parameter_instances(self.instrumentation) - self.has_discrete_not_softmax = any(isinstance(x, p.TransitionChoice) for x in all_params) + all_params = paramhelpers.flatten_parameter(self.instrumentation) + self.has_discrete_not_softmax = any(isinstance(x, p.TransitionChoice) for x in all_params.values()) # pylint: disable=too-many-nested-blocks if self.has_noise and self.has_discrete_not_softmax: # noise and discrete: let us merge evolution and bandits. diff --git a/nevergrad/optimization/recorded_recommendations.csv b/nevergrad/optimization/recorded_recommendations.csv index aa394f7b6..9b1c9f6a9 100644 --- a/nevergrad/optimization/recorded_recommendations.csv +++ b/nevergrad/optimization/recorded_recommendations.csv @@ -49,6 +49,7 @@ DiscreteOnePlusOne,0.7531428339,0.0,0.0,1.095956118,,,,,,,,,,,, DoubleFastGADiscreteOnePlusOne,0.0,0.0,0.0,0.0,,,,,,,,,,,, DoubleFastGAOptimisticNoisyDiscreteOnePlusOne,0.0,0.0,0.0,0.0,,,,,,,,,,,, EDA,-0.0691450987,-0.3901349698,-0.195989244,1.4401961148,0.8133730167,0.4021844027,-0.9366618858,-0.9048970955,-0.493399994,-0.0074111222,,,,,, +ES,1.1400386808,0.3380024444,0.4755144618,2.6390460807,0.6911075733,1.111235567,-0.2576843178,-1.1959512855,,,,,,,, FastGADiscreteOnePlusOne,0.7531428339,0.0,0.0,1.095956118,,,,,,,,,,,, FastGANoisyDiscreteOnePlusOne,-1.2151688011,0.0,0.0,1.095956118,,,,,,,,,,,, FastGAOptimisticNoisyDiscreteOnePlusOne,0.7531428339,0.0,0.0,1.095956118,,,,,,,,,,,, @@ -78,8 +79,10 @@ MilliCMA,0.0010125155,-0.0009138806,-0.0010295559,0.0012098418,,,,,,,,,,,, MiniDE,0.8273276988,-1.2921051963,-0.4797521288,0.2138608624,0.7088815721,0.7346249014,-2.6392592028,-1.0729615222,,,,,,,, MiniLhsDE,-0.0313128807,0.2738703026,-0.1988242191,0.9942001938,0.7167500893,-0.0350394443,-1.5341684983,-0.3039246928,,,,,,,, MiniQrDE,-0.2025746195,-0.8778768047,-1.2504657435,0.6265108481,0.4934247309,0.6448108695,-0.3573249779,-1.6986947217,,,,,,,, +MixES,1.1400386808,0.3380024444,0.4755144618,2.6390460807,0.6911075733,1.111235567,-0.2576843178,-1.1959512855,,,,,,,, MultiCMA,1.012515477,-0.9138805701,-1.029555946,1.2098418178,,,,,,,,,,,, MultiScaleCMA,0.0002149759,-0.0003843636,-0.0002539104,7.32548e-05,,,,,,,,,,,, +MutDE,1.1400386808,0.3380024444,0.4755144618,2.6390460807,0.6911075733,1.111235567,-0.2576843178,-1.1959512855,,,,,,,, NGO,0.0,-0.3451057176,-0.1327329683,1.9291307781,,,,,,,,,,,, NaiveTBPSA,0.002380178,-0.0558141,-0.3746306258,1.3332040355,,,,,,,,,,,, NelderMead,0.0,0.0,0.0,0.00025,,,,,,,,,,,, @@ -128,6 +131,9 @@ RandomScaleRandomSearch,0.0606364451,-0.0547288191,-0.0616554051,0.0724509972,,, RandomScaleRandomSearchPlusMiddlePoint,0.0606364451,-0.0547288191,-0.0616554051,0.0724509972,,,,,,,,,,,, RandomSearch,1.012515477,-0.9138691467,-1.0295302074,1.2097964496,,,,,,,,,,,, RandomSearchPlusMiddlePoint,1.012515477,-0.9138691467,-1.0295302074,1.2097964496,,,,,,,,,,,, +RecES,1.1400386808,0.3380024444,0.4755144618,2.6390460807,0.6911075733,1.111235567,-0.2576843178,-1.1959512855,,,,,,,, +RecMixES,1.1400386808,0.3380024444,0.4755144618,2.6390460807,0.6911075733,1.111235567,-0.2576843178,-1.1959512855,,,,,,,, +RecMutDE,1.1400386808,0.3380024444,0.4755144618,2.6390460807,0.6911075733,1.111235567,-0.2576843178,-1.1959512855,,,,,,,, Recentering0ScrHaltonSearch,-0.0031863936,-0.0122064035,0.0175068607,0.0056594882,,,,,,,,,,,, Recentering0ScrHammersleySearch,0.0138299413,-0.0031863936,-0.0122064035,0.0175068607,,,,,,,,,,,, Recentering12ScrHaltonSearch,-0.8093877002,-0.5168727592,0.3040165238,0.2160148438,,,,,,,,,,,, diff --git a/nevergrad/optimization/test_callbacks.py b/nevergrad/optimization/test_callbacks.py index d5d0b8037..7c67ca963 100644 --- a/nevergrad/optimization/test_callbacks.py +++ b/nevergrad/optimization/test_callbacks.py @@ -23,17 +23,17 @@ def test_log_parameters(tmp_path: Path) -> None: ng.p.Scalar(), blublu=ng.p.Choice(cases), array=ng.p.Array(shape=(3, 2))) - optimizer = optimizerlib.OnePlusOne(instrumentation=instrum, budget=32) - optimizer.register_callback("tell", callbacks.ParametersLogger(filepath, delete_existing_file=True)) + optimizer = optimizerlib.NoisyOnePlusOne(instrumentation=instrum, budget=32) + optimizer.register_callback("tell", callbacks.ParametersLogger(filepath, append=False)) optimizer.minimize(_func, verbosity=2) # pickling logger = callbacks.ParametersLogger(filepath) logs = logger.load_flattened() assert len(logs) == 32 - assert isinstance(logs[-1]["#arg1"], float) - assert len(logs[-1]) == 18 + assert isinstance(logs[-1]["1"], float) + assert len(logs[-1]) == 32 logs = logger.load_flattened(max_list_elements=2) - assert len(logs[-1]) == 14 + assert len(logs[-1]) == 24 # deletion - logger = callbacks.ParametersLogger(filepath, delete_existing_file=True) + logger = callbacks.ParametersLogger(filepath, append=False) assert not logger.load() diff --git a/nevergrad/optimization/test_optimizerlib.py b/nevergrad/optimization/test_optimizerlib.py index 19b4db0a8..a8dc4cc12 100644 --- a/nevergrad/optimization/test_optimizerlib.py +++ b/nevergrad/optimization/test_optimizerlib.py @@ -155,11 +155,8 @@ def test_optimizers_suggest(name: str) -> None: # pylint: disable=redefined-out # pylint: disable=redefined-outer-name -@pytest.mark.parametrize("with_parameter", [True, False]) # type: ignore @pytest.mark.parametrize("name", [name for name in registry]) # type: ignore -def test_optimizers_recommendation(with_parameter: bool, - name: str, - recomkeeper: RecommendationKeeper) -> None: +def test_optimizers_recommendation(name: str, recomkeeper: RecommendationKeeper) -> None: # set up environment optimizer_cls = registry[name] if name in UNSEEDABLE: @@ -170,7 +167,7 @@ def test_optimizers_recommendation(with_parameter: bool, random.seed(12) # may depend on non numpy generator # budget=6 by default, larger for special cases needing more budget = {"WidePSO": 100, "PSO": 100, "MEDA": 100, "EDA": 100, "MPCEDA": 100, "TBPSA": 100}.get(name, 6) - if isinstance(optimizer_cls, optlib.DifferentialEvolution): + if isinstance(optimizer_cls, (optlib.DifferentialEvolution, optlib.EvolutionStrategy)): budget = 80 dimension = min(16, max(4, int(np.sqrt(budget)))) # set up problem @@ -178,9 +175,7 @@ def test_optimizers_recommendation(with_parameter: bool, with warnings.catch_warnings(): # tests do not need to be efficient warnings.filterwarnings("ignore", category=base.InefficientSettingsWarning) - param: Union[int, ng.p.Instrumentation] = (dimension if not with_parameter else - ng.p.Instrumentation(ng.p.Array(shape=(dimension,)))) - optim = optimizer_cls(instrumentation=param, budget=budget, num_workers=1) + optim = optimizer_cls(instrumentation=dimension, budget=budget, num_workers=1) optim.instrumentation.random_state.seed(12) np.testing.assert_equal(optim.name, name) # the following context manager speeds up BO tests diff --git a/nevergrad/parametrization/data.py b/nevergrad/parametrization/data.py index 8f20f2d41..433ea0c6a 100644 --- a/nevergrad/parametrization/data.py +++ b/nevergrad/parametrization/data.py @@ -220,6 +220,12 @@ def set_bounds(self: A, a_min: BoundValue = None, a_max: BoundValue = None, "you should aim for at least 3 for better quality.") return self + def set_recombination(self: A, recombination: tp.Union[str, core.Parameter, utils.Crossover]) -> A: + assert self._parameters is not None + self._parameters._content["recombination"] = (recombination if isinstance(recombination, core.Parameter) + else core.Constant(recombination)) + return self + def set_mutation(self: A, sigma: tp.Optional[tp.Union[float, "Array"]] = None, exponent: tp.Optional[float] = None) -> A: """Output will be cast to integer(s) through deterministic rounding. @@ -305,9 +311,12 @@ def recombine(self: A, *others: A) -> None: if not others: return recomb = self.parameters["recombination"].value - all_p = [self] + list(others) - if recomb == "average": - self.set_standardized_data(np.mean([p.get_standardized_data(reference=self) for p in all_p], axis=0), deterministic=False) + all_arrays = [p.get_standardized_data(reference=self) for p in [self] + list(others)] + if isinstance(recomb, str) and recomb == "average": + self.set_standardized_data(np.mean(all_arrays, axis=0), deterministic=False) + elif isinstance(recomb, utils.Crossover): + crossover = recomb.apply(all_arrays, self.random_state) + self.set_standardized_data(crossover, reference=self, deterministic=False) else: raise ValueError(f'Unknown recombination "{recomb}"') diff --git a/nevergrad/parametrization/helpers.py b/nevergrad/parametrization/helpers.py index 283775981..6787b90fe 100644 --- a/nevergrad/parametrization/helpers.py +++ b/nevergrad/parametrization/helpers.py @@ -1,8 +1,14 @@ import typing as tp from . import core +from . import container +from . import choice -def list_parameter_instances(parameter: core.Parameter) -> tp.List[core.Parameter]: +def flatten_parameter( + parameter: core.Parameter, + with_containers: bool = True, + order: int = 0 +) -> tp.Dict[str, core.Parameter]: """List all the instances involved as parameter (not as subparameter/ endogeneous parameter) @@ -10,15 +16,36 @@ def list_parameter_instances(parameter: core.Parameter) -> tp.List[core.Paramete --------- parameter: Parameter the parameter to inspect + with_container: bool + returns only non-container instances (aka no Dict, Tuple, Instrumentation or Constant) + order: int + order of model/internal parameters to extract. With 0, no model/internal parameters is + extracted, with 1, only 1st order are extracted, with 2, so model/internal parameters and + their own model/internal parameters etc... Returns ------- - list - a list of all parameters implied in this parameter, i.e all choices, items of dict + dict + a dict of all parameters implied in this parameter, i.e all choices, items of dict and tuples etc, but not the subparameters/endogeneous parameters like sigma + with keys if type "." for a tuple containing dicts containing data for instance. + + Note + ---- + This function is experimental, its output will probably evolve before converging. """ - instances = [parameter] + flat = {"": parameter} if isinstance(parameter, core.Dict): - for p in parameter._content.values(): - instances += list_parameter_instances(p) - return instances + content_to_add: tp.List[core.Dict] = [parameter] + if isinstance(parameter, container.Instrumentation): # special case: skip internal Tuple and Dict + content_to_add = [parameter[0], parameter[1]] # type: ignore + for c in content_to_add: + for k, p in c._content.items(): + content = flatten_parameter(p, with_containers=with_containers, order=order) + flat.update({str(k) + ("" if not x else ("." if not x.startswith("#") else "") + x): y for x, y in content.items()}) + if order > 0 and parameter._parameters is not None: + subparams = flatten_parameter(parameter.parameters, with_containers=False, order=order - 1) + flat.update({"#" + str(x): y for x, y in subparams.items()}) + if not with_containers: + flat = {x: y for x, y in flat.items() if not isinstance(y, (core.Dict, core.Constant)) or isinstance(y, choice.BaseChoice)} + return flat diff --git a/nevergrad/parametrization/parameter.py b/nevergrad/parametrization/parameter.py index 39e539357..44dce0061 100644 --- a/nevergrad/parametrization/parameter.py +++ b/nevergrad/parametrization/parameter.py @@ -8,6 +8,7 @@ from .utils import NotSupportedError as NotSupportedError from .core import Parameter as Parameter from .core import Dict as Dict +from .core import Constant as Constant # avoid using except for checks from .container import Tuple as Tuple from .container import Instrumentation as Instrumentation from .data import Array as Array diff --git a/nevergrad/parametrization/test_parameter.py b/nevergrad/parametrization/test_parameter.py index 2ab984362..77f103b38 100644 --- a/nevergrad/parametrization/test_parameter.py +++ b/nevergrad/parametrization/test_parameter.py @@ -7,10 +7,8 @@ import typing as tp import pytest import numpy as np -from .core import Constant from . import utils from . import parameter as par -from . import helpers def test_array_basics() -> None: @@ -171,18 +169,6 @@ def test_parameter_names(param: par.Parameter, name: str) -> None: assert param.name == name -@pytest.mark.parametrize( # type: ignore - "param,classes", - [(par.Array(shape=(2, 2)), [par.Array]), - (par.Tuple(12), [par.Tuple, Constant]), - (par.Instrumentation(par.Array(shape=(2,))), [par.Instrumentation, par.Tuple, par.Array, par.Dict]), - ] -) -def test_list_parameter_instances(param: par.Parameter, classes: tp.List[tp.Type[par.Parameter]]) -> None: - outputs = [x.__class__ for x in helpers.list_parameter_instances(param)] - assert outputs == classes - - @pytest.mark.parametrize( # type: ignore "param,continuous,deterministic,ordered", [(par.Array(shape=(2, 2)), True, True, True), diff --git a/nevergrad/parametrization/test_utils.py b/nevergrad/parametrization/test_utils.py index 559198289..c284990e3 100644 --- a/nevergrad/parametrization/test_utils.py +++ b/nevergrad/parametrization/test_utils.py @@ -1,4 +1,5 @@ # Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. # # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. @@ -6,9 +7,13 @@ import sys import time import contextlib +import typing as tp from pathlib import Path -from typing import Any +import numpy as np +from nevergrad.common import testing +from . import parameter as p from . import utils +from . import helpers def test_temporary_directory_copy() -> None: @@ -35,12 +40,85 @@ def test_command_function() -> None: raise AssertionError("An error should have been raised") +@testing.parametrized( + scalar=(False, p.Scalar(), ("",)), + v_scalar=(True, p.Scalar(), ("",)), + tuple_=(False, p.Tuple(p.Scalar(), p.Array(shape=(2,))), ("", "0", "1")), + v_tuple_=(True, p.Tuple(p.Scalar(), p.Array(shape=(2,))), ("0", "1")), + instrumentation=(False, p.Instrumentation(p.Scalar(), y=p.Scalar()), ("", "0", "y")), + instrumentation_v=(True, p.Instrumentation(p.Scalar(), y=p.Scalar()), ("0", "y")), + choice=(False, p.Choice([p.Scalar(), "blublu"]), ("", "choices", "choices.0", "choices.1", "weights")), + v_choice=(True, p.Choice([p.Scalar(), "blublu"]), ("", "choices.0", "weights")), + tuple_choice_dict=(False, p.Tuple(p.Choice([p.Dict(x=p.Scalar(), y=12), p.Scalar()])), + ("", "0", "0.choices", "0.choices.0", "0.choices.0.x", "0.choices.0.y", "0.choices.1", "0.weights")), + v_tuple_choice_dict=(True, p.Tuple(p.Choice([p.Dict(x=p.Scalar(), y=12), p.Scalar()])), + ("0", "0.choices.0.x", "0.choices.1", "0.weights")), +) +def test_flatten_parameter(no_container: bool, param: p.Parameter, keys: tp.Iterable[str]) -> None: + flat = helpers.flatten_parameter(param, with_containers=not no_container) + assert set(flat) == set(keys), f"Unexpected flattened parameter: {flat}" + + +@testing.parametrized( + order_0=(0, ("", "choices.0.x", "choices.1", "weights")), + order_1=(1, ("", "choices.0.x", "choices.1", "weights", "choices.1#sigma", "choices.0.x#sigma")), + order_2=(2, ("", "choices.0.x", "choices.1", "weights", "choices.1#sigma", "choices.0.x#sigma", "choices.1#sigma#sigma")), + order_3=(3, ("", "choices.0.x", "choices.1", "weights", "choices.1#sigma", "choices.0.x#sigma", "choices.1#sigma#sigma")), +) +def test_flatten_parameter_order(order: int, keys: tp.Iterable[str]) -> None: + param = p.Choice([p.Dict(x=p.Scalar(), y=12), p.Scalar().sigma.set_mutation(sigma=p.Scalar())]) + flat = helpers.flatten_parameter(param, with_containers=False, order=order) + assert set(flat) == set(keys), f"Unexpected flattened parameter: {flat}" + + +def test_crossover() -> None: + x1 = 4 * np.ones((2, 3)) + x2 = 5 * np.ones((2, 3)) + co = utils.Crossover(0, (0,)) + out = co.apply((x1, x2), rng=np.random.RandomState(12)) + expected = np.ones((2, 1)).dot([[5, 5, 4]]) + np.testing.assert_array_equal(out, expected) + + +def test_random_crossover() -> None: + arrays = [k * np.ones((2, 2)) for k in range(31)] + co = utils.Crossover(0) + out = co.apply(arrays) + assert 0 in out + + +@testing.parametrized( + p2i2=(42, 2, 2, [0, 0, 1, 1, 1, 0]), + p5i6=(42, 5, 6, [3, 0, 1, 2, 5, 4]), + p1i2=(42, 1, 2, [0, 0, 1, 1, 1, 1]), + p2i3=(42, 2, 3, [1, 1, 2, 2, 2, 0]), +) +def test_kpoint_crossover(seed: int, points: int, indiv: int, expected: tp.List[int]) -> None: + rng = np.random.RandomState(seed) + crossover = utils.Crossover(points) + donors = [k * np.ones(len(expected)) for k in range(indiv)] + output = crossover.apply(donors, rng) + np.testing.assert_array_equal(output, expected) + + +@testing.parametrized( + small=(1, 5, [0]), + keep_first=(2, 1000, [0, 871]), + two_points=(3, 2, [0, 1, 0]), + two_points_big=(3, 1000, [518, 871, 0]), +) +def test_make_crossover_sequence(num_sections: int, num_individuals: int, expected: tp.List[int]) -> None: + rng = np.random.RandomState(12) + out = utils._make_crossover_sequence(num_sections=num_sections, num_individuals=num_individuals, rng=rng) + assert out == expected + + def test_descriptors() -> None: desc = utils.Descriptors(ordered=False) assert repr(desc) == "Descriptors(ordered=False)" -def do_nothing(*args: Any, **kwargs: Any) -> int: +def do_nothing(*args: tp.Any, **kwargs: tp.Any) -> int: print("my args", args, flush=True) print("my kwargs", kwargs, flush=True) if "sleep" in kwargs: diff --git a/nevergrad/parametrization/utils.py b/nevergrad/parametrization/utils.py index 3e25a9373..f30426451 100644 --- a/nevergrad/parametrization/utils.py +++ b/nevergrad/parametrization/utils.py @@ -7,16 +7,18 @@ import sys import shutil import tempfile +import warnings import subprocess import typing as tp from pathlib import Path +import numpy as np from ..common.tools import different_from_defaults class Descriptors: """Provides access to a set of descriptors for the parametrization This can be used within optimizers. - """ + """ # TODO add repr # pylint: disable=too-many-arguments def __init__( @@ -148,3 +150,51 @@ def __call__(self, *args: tp.Any, **kwargs: tp.Any) -> str: subprocess_error = subprocess.CalledProcessError(retcode, process.args, output=stdout, stderr=stderr) raise FailedJobError(stderr.decode()) from subprocess_error return stdout + + +def _make_crossover_sequence(num_sections: int, num_individuals: int, rng: np.random.RandomState) -> tp.List[int]: + assert num_individuals > 1 + indices = rng.permutation(num_individuals).tolist() + while len(indices) < num_sections: + new_indices = rng.permutation(num_individuals).tolist() + if new_indices[0] == indices[-1]: + new_indices[0], new_indices[-1] = new_indices[-1], new_indices[0] + indices.extend(new_indices) + indices = indices[:num_sections] + if 0 not in indices: + indices[rng.randint(num_sections)] = 0 # always involve first element + return indices # type: ignore + + +class Crossover: + + def __init__(self, num_points: int = 0, structured_dimensions: tp.Iterable[int] = ()) -> None: + self.num_points = num_points + self.structured_dimensions = sorted(structured_dimensions) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.num_points}, {self.structured_dimensions})" + + def apply(self, arrays: tp.Sequence[np.ndarray], rng: tp.Optional[np.random.RandomState] = None) -> np.ndarray: + if len(arrays) > 30: + warnings.warn("Crossover can only handle up to 30 arrays") + arrays = arrays[:30] + if rng is None: + rng = np.random.RandomState() + shape = tuple(d for k, d in enumerate(arrays[0].shape) if k not in self.structured_dimensions) + choices = np.zeros(shape, dtype=int) + if not self.num_points: + choices = rng.randint(0, len(arrays), size=choices.shape) + if 0 not in choices: + choices.ravel()[rng.randint(choices.size)] = 0 # always involve first element + elif choices.ndim == 1: + bounds = sorted(rng.choice(shape[0] - 1, size=self.num_points, replace=False).tolist()) # 0 to n - 2 + bounds = [0] + [1 + b for b in bounds] + [shape[0]] + indices = _make_crossover_sequence(len(bounds) - 1, len(arrays), rng) + for start, end, index in zip(bounds[:-1], bounds[1:], indices): + choices[start:end] = index + else: + raise NotImplementedError + for d in self.structured_dimensions: + choices = np.expand_dims(choices, d) + return np.choose(choices, arrays) # type:ignore