Skip to content

Commit

Permalink
Add a parameter-based ES algorithm
Browse files Browse the repository at this point in the history
  • Loading branch information
jrapin committed Jan 28, 2020
1 parent 5052aa5 commit bb7ffdf
Show file tree
Hide file tree
Showing 18 changed files with 424 additions and 60 deletions.
19 changes: 19 additions & 0 deletions nevergrad/benchmark/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
1 change: 1 addition & 0 deletions nevergrad/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 60 additions & 6 deletions nevergrad/functions/functionlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
"""
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
24 changes: 23 additions & 1 deletion nevergrad/functions/test_functionlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions nevergrad/optimization/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 21 additions & 8 deletions nevergrad/optimization/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
-----
Expand All @@ -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,
Expand All @@ -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")
Expand All @@ -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]] = []
Expand Down
99 changes: 99 additions & 0 deletions nevergrad/optimization/es.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion nevergrad/optimization/families.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading

0 comments on commit bb7ffdf

Please sign in to comment.