diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4656f87..a99352abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ - `recommend` now provides an evaluated candidate when possible. For non-deterministic parametrization like `Choice`, this means we won't resample, and we will actually recommend the best past evaluated candidate [#668](https://github.com/facebookresearch/nevergrad/pull/668). Still, some optimizers (like `TBPSA`) may recommend a non-evaluated point. -- `Choice` now takes a new `repetitions` parameters for sampling several times, - it is equivalent to :code:`Tuple(*[Choice(options) for _ in range(repetitions)])` but can be be around 30x faster for large numbers of repetitions [#670](https://github.com/facebookresearch/nevergrad/pull/670). +- `Choice` and `TransitionChoice` can now take a `repetitions` parameters for sampling several times, + it is equivalent to :code:`Tuple(*[Choice(options) for _ in range(repetitions)])` but can be be up to 30x faster for large numbers of repetitions [#670](https://github.com/facebookresearch/nevergrad/pull/670) [#696](https://github.com/facebookresearch/nevergrad/pull/696). - Defaults for bounds in `Array` is now `bouncing`, which is a variant of `clipping` avoiding over-sompling on the bounds [#684](https://github.com/facebookresearch/nevergrad/pull/684) and [#691](https://github.com/facebookresearch/nevergrad/pull/691). diff --git a/nevergrad/parametrization/choice.py b/nevergrad/parametrization/choice.py index 6992f4a23..04c0c3f00 100644 --- a/nevergrad/parametrization/choice.py +++ b/nevergrad/parametrization/choice.py @@ -3,6 +3,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +import warnings import typing as tp import numpy as np from nevergrad.common.typetools import ArrayLike @@ -11,7 +12,6 @@ from . import core from .container import Tuple from .data import Array -from .data import Scalar # weird pylint issue on "Descriptors" # pylint: disable=no-value-for-parameter @@ -22,7 +22,15 @@ class BaseChoice(core.Dict): - def __init__(self, *, choices: tp.Iterable[tp.Any], **kwargs: tp.Any) -> None: + def __init__( + self, + *, + choices: tp.Iterable[tp.Any], + repetitions: tp.Optional[int] = None, + **kwargs: tp.Any + ) -> None: + assert repetitions is None or isinstance(repetitions, int) # avoid silent issues + self._repetitions = repetitions assert not isinstance(choices, Tuple) lchoices = list(choices) # for iterables if not lchoices: @@ -41,10 +49,11 @@ def __len__(self) -> int: return len(self.choices) @property - def index(self) -> int: - """Index of the chosen option, if unique + def index(self) -> int: # delayed choice + """Index of the chosen option """ - raise NotImplementedError + assert self.indices.size == 1 + return int(self.indices[0]) @property def indices(self) -> np.ndarray: @@ -67,12 +76,15 @@ def value(self, value: tp.Any) -> None: self._find_and_set_value(value) def _get_value(self) -> tp.Any: - return core.as_parameter(self.choices[self.index]).value + if self._repetitions is None: + return core.as_parameter(self.choices[self.index]).value + return tuple(core.as_parameter(self.choices[ind]).value for ind in self.indices) def _find_and_set_value(self, values: tp.List[tp.Any]) -> np.ndarray: """Must be adapted to each class This handles a list of values, not just one """ # TODO this is currenlty very messy, may need some improvement + values = [values] if self._repetitions is None else values self._check_frozen() indices: np.ndarray = -1 * np.ones(len(values), dtype=int) nums = sorted(int(k) for k in self.choices._content) @@ -141,11 +153,10 @@ def __init__( deterministic: bool = False, ) -> None: assert not isinstance(choices, Tuple) - assert repetitions is None or isinstance(repetitions, int) # avoid silent issues lchoices = list(choices) - self._repetitions: tp.Optional[int] = repetitions - rep = 1 if self._repetitions is None else self._repetitions - super().__init__(choices=lchoices, weights=Array(shape=(rep, len(lchoices)), mutable_sigma=False)) + rep = 1 if repetitions is None else repetitions + super().__init__(choices=lchoices, repetitions=repetitions, + weights=Array(shape=(rep, len(lchoices)), mutable_sigma=False)) self._deterministic = deterministic self._indices: tp.Optional[np.ndarray] = None @@ -166,13 +177,6 @@ def indices(self) -> np.ndarray: # delayed choice assert self._indices is not None return self._indices - @property - def index(self) -> int: # delayed choice - """Index of the chosen option - """ - assert self.indices.size == 1 - return int(self.indices[0]) - @property def weights(self) -> Array: """The weights used to draw the value @@ -184,15 +188,10 @@ def probabilities(self) -> np.ndarray: """The probabilities used to draw the value """ exp = np.exp(self.weights.value) - return exp / np.sum(exp) # type: ignore - - def _get_value(self) -> tp.Any: - if self._repetitions is None: - return super()._get_value() - return tuple(core.as_parameter(self.choices[ind]).value for ind in self.indices) + return exp / np.sum(exp) def _find_and_set_value(self, values: tp.Any) -> np.ndarray: - indices = super()._find_and_set_value([values] if self._repetitions is None else values) + indices = super()._find_and_set_value(values) self._indices = indices # force new probabilities arity = self.weights.value.shape[1] @@ -254,28 +253,28 @@ def __init__( self, choices: tp.Iterable[tp.Any], transitions: tp.Union[ArrayLike, Array] = (1.0, 1.0), + repetitions: tp.Optional[int] = None, ) -> None: + choices = list(choices) + positions = Array(init=len(choices) / 2.0 * np.ones((repetitions if repetitions is not None else 1,))) + positions.set_bounds(0, len(choices), method="gaussian") super().__init__(choices=choices, - position=Scalar(), + repetitions=repetitions, + positions=positions, transitions=transitions if isinstance(transitions, Array) else np.array(transitions, copy=False)) assert self.transitions.value.ndim == 1 - @property - def index(self) -> int: - return discretization.threshold_discretization(np.array([self.position.value]), arity=len(self.choices))[0] - @property def indices(self) -> np.ndarray: - return np.array([self.index], dtype=int) + return np.minimum(len(self) - 1e-9, self.positions.value).astype(int) def _find_and_set_value(self, values: tp.Any) -> np.ndarray: - indices = super()._find_and_set_value([values]) # only one value for this class - self._set_index(int(indices[0])) + indices = super()._find_and_set_value(values) # only one value for this class + self._set_index(indices) return indices - def _set_index(self, index: int) -> None: - out = discretization.inverse_threshold_discretization([index], len(self.choices)) - self.position.value = out[0] + def _set_index(self, indices: np.ndarray) -> None: + self.positions.value = indices + 0.5 @property def transitions(self) -> Array: @@ -284,28 +283,39 @@ def transitions(self) -> Array: return self["transitions"] # type: ignore @property - def position(self) -> Scalar: + def position(self) -> Array: """The continuous version of the index (used when working with standardized space) """ - return self["position"] # type: ignore + warnings.warn("position is replaced by positions in order to allow for repetitions", DeprecationWarning) + return self.positions + + @property + def positions(self) -> Array: + """The continuous version of the index (used when working with standardized space) + """ + return self["positions"] # type: ignore def mutate(self) -> None: # force random_state sync self.random_state # pylint: disable=pointless-statement transitions = core.as_parameter(self.transitions) transitions.mutate() - probas = np.exp(transitions.value) - probas /= np.sum(probas) # TODO decide if softmax is the best way to go... - move = self.random_state.choice(list(range(probas.size)), p=probas) - sign = 1 if self.random_state.randint(2) else -1 - new_index = max(0, min(len(self.choices), self.index + sign * move)) - self._set_index(new_index) + rep = 1 if self._repetitions is None else self._repetitions + # + enc = discretization.Encoder(np.ones((rep, 1)) * np.log(self.transitions.value), + self.random_state) + moves = enc.encode() + signs = self.random_state.choice([-1, 1], size=rep) + new_index = np.clip(self.indices + signs * moves, 0, len(self) - 1) + self._set_index(new_index.ravel()) # mutate corresponding parameter - self.choices[self.index].mutate() + indices = set(self.indices) + for ind in indices: + self.choices[ind].mutate() def _internal_spawn_child(self: T) -> T: choices = (y for x, y in sorted(self.choices.spawn_child()._content.items())) - child = self.__class__(choices=choices) - child._content["position"] = self.position.spawn_child() + child = self.__class__(choices=choices, repetitions=self._repetitions) + child._content["positions"] = self.positions.spawn_child() child._content["transitions"] = self.transitions.spawn_child() return child diff --git a/nevergrad/parametrization/data.py b/nevergrad/parametrization/data.py index 0d4da9912..c232cfcfe 100644 --- a/nevergrad/parametrization/data.py +++ b/nevergrad/parametrization/data.py @@ -260,7 +260,8 @@ def set_bounds( if (bounds[0] >= bounds[1]).any(): # type: ignore raise ValueError(f"Lower bounds {lower} should be strictly smaller than upper bounds {upper}") # update instance - transforms = dict(clipping=trans.Clipping, arctan=trans.ArctanBound, tanh=trans.TanhBound) + transforms = dict(clipping=trans.Clipping, arctan=trans.ArctanBound, tanh=trans.TanhBound, + gaussian=trans.CumulativeDensity) transforms["bouncing"] = functools.partial(trans.Clipping, bounce=True) # type: ignore if method in transforms: if self.exponent is not None and method not in ("clipping", "bouncing"): diff --git a/nevergrad/parametrization/test_parameter.py b/nevergrad/parametrization/test_parameter.py index 24f1adc18..1464816e6 100644 --- a/nevergrad/parametrization/test_parameter.py +++ b/nevergrad/parametrization/test_parameter.py @@ -58,6 +58,7 @@ def _true(*args: tp.Any, **kwargs: tp.Any) -> bool: # pylint: disable=unused-ar par.Choice([par.Array(shape=(2,)), "blublu"]), par.Choice([1, 2], repetitions=2), par.TransitionChoice([par.Array(shape=(2,)), par.Scalar()]), + par.TransitionChoice(["a", "b", "c"], transitions=(0, 2, 1), repetitions=4), ], ) def test_parameters_basic_features(param: par.Parameter) -> None: @@ -163,8 +164,8 @@ def check_parameter_freezable(param: par.Parameter) -> None: "Instrumentation(Tuple(Array{(2,)}),Dict(string=blublu,truc=plop))"), (par.Choice([1, 12]), "Choice(choices=Tuple(1,12),weights=Array{(1,2)})"), (par.Choice([1, 12], deterministic=True), "Choice{det}(choices=Tuple(1,12),weights=Array{(1,2)})"), - (par.TransitionChoice([1, 12]), "TransitionChoice(choices=Tuple(1,12),position=Scalar[" - "sigma=Log{exp=2.0}],transitions=[1. 1.])") + (par.TransitionChoice([1, 12]), "TransitionChoice(choices=Tuple(1,12),positions=Array{Cd(0,2)}" + ",transitions=[1. 1.])") ] ) def test_parameter_names(param: par.Parameter, name: str) -> None: @@ -314,6 +315,17 @@ def test_choice_repetitions() -> None: choice.mutate() +def test_transition_choice_repetitions() -> None: + choice = par.TransitionChoice([0, 1, 2, 3], repetitions=2) + choice.random_state.seed(12) + assert len(choice) == 4 + assert choice.value == (2, 2) + choice.value = (3, 1) + np.testing.assert_almost_equal(choice.positions.value, [3.5, 1.5], decimal=3) + choice.mutate() + assert choice.value == (3, 0) + + def test_descriptors() -> None: d1 = utils.Descriptors() d2 = utils.Descriptors(continuous=False) diff --git a/nevergrad/parametrization/test_parameters_legacy.py b/nevergrad/parametrization/test_parameters_legacy.py index 2e328f8e2..476cc587f 100644 --- a/nevergrad/parametrization/test_parameters_legacy.py +++ b/nevergrad/parametrization/test_parameters_legacy.py @@ -51,7 +51,7 @@ def test_instrumentation() -> None: # check naming instru_str = ("Instrumentation(Tuple(Scalar[sigma=Log{exp=2.0}],3)," "Dict(a=TransitionChoice(choices=Tuple(0,1,2,3)," - "position=Scalar[sigma=Log{exp=2.0}],transitions=[1. 1.])," + "positions=Array{Cd(0,4)},transitions=[1. 1.])," "b=Choice(choices=Tuple(0,1,2,3),weights=Array{(1,4)})))") testing.printed_assert_equal(instru.name, instru_str) testing.printed_assert_equal("blublu", instru.set_name("blublu").name) diff --git a/nevergrad/parametrization/test_transforms.py b/nevergrad/parametrization/test_transforms.py index d516e3071..b020801d0 100644 --- a/nevergrad/parametrization/test_transforms.py +++ b/nevergrad/parametrization/test_transforms.py @@ -16,7 +16,8 @@ exponentiate=(transforms.Exponentiate(3, 4), "Ex(3,4)"), tanh=(transforms.TanhBound(3.0, 4.5), "Th(3,4.5)"), arctan=(transforms.ArctanBound(3, 4), "At(3,4)"), - cumdensity=(transforms.CumulativeDensity(), "Cd()"), + cumdensity=(transforms.CumulativeDensity(), "Cd(0,1)"), + cumdensity2=(transforms.CumulativeDensity(1, 3), "Cd(1,3)"), clipping=(transforms.Clipping(None, 1e12), "Cl(None,1000000000000)"), bouncing=(transforms.Clipping(-12000, 12000, bounce=True), "Cl(-12000,12000,b)"), fourrier=(transforms.Fourrier(), "F(0)"), @@ -37,6 +38,7 @@ def test_back_and_forth(transform: transforms.Transform, string: str) -> None: arctan=(transforms.ArctanBound(3, 5), [-100000, 100000, 0], [3, 5, 4]), bouncing=(transforms.Clipping(0, 10, bounce=True), [-1, 22, 3], [1, 0, 3]), cumdensity=(transforms.CumulativeDensity(), [-10, 0, 10], [0, .5, 1]), + cumdensity_bounds=(transforms.CumulativeDensity(2, 4), [-10, 0, 10], [2, 3, 4]), ) def test_vals(transform: transforms.Transform, x: List[float], expected: List[float]) -> None: y = transform.forward(np.array(x)) diff --git a/nevergrad/parametrization/transforms.py b/nevergrad/parametrization/transforms.py index ac332ae6e..b967a484d 100644 --- a/nevergrad/parametrization/transforms.py +++ b/nevergrad/parametrization/transforms.py @@ -242,31 +242,40 @@ def __init__( def forward(self, x: np.ndarray) -> np.ndarray: self._check_shape(x) - return self._b + self._a * np.arctan(x) # type: ignore + return self._b + self._a * np.arctan(x) def backward(self, y: np.ndarray) -> np.ndarray: self._check_shape(y) if (y > self.a_max).any() or (y < self.a_min).any(): raise ValueError(f"Only data between {self.a_min} and {self.a_max} can be transformed back.") - return np.tan((y - self._b) / self._a) # type: ignore + return np.tan((y - self._b) / self._a) -class CumulativeDensity(Transform): +class CumulativeDensity(BoundTransform): """Bounds all real values into [0, 1] using a gaussian cumulative density function (cdf) Beware, cdf goes very fast to its limits. """ - def __init__(self) -> None: - super().__init__() - self.name = "Cd()" + def __init__( + self, + lower: float = 0.0, + upper: float = 1.0, + eps: float = 1e-9 + ) -> None: + super().__init__(a_min=lower, a_max=upper) + self._b = lower + self._a = upper - lower + self._eps = eps + self.name = f"Cd({_f(lower)},{_f(upper)})" def forward(self, x: np.ndarray) -> np.ndarray: - return stats.norm.cdf(x) # type: ignore + return self._a * stats.norm.cdf(x) + self._b def backward(self, y: np.ndarray) -> np.ndarray: - if np.max(y) > 1 or np.min(y) < 0: - raise ValueError("Only data between 0 and 1 can be transformed back (bounds lead to infinity).") - return stats.norm.ppf(y) # type: ignore + if (y > self.a_max).any() or (y < self.a_min).any(): + raise ValueError(f"Only data between {self.a_min} and {self.a_max} can be transformed back.\nGot: {y}") + y = np.clip((y - self._b) / self._a, self._eps, 1 - self._eps) + return stats.norm.ppf(y) class Fourrier(Transform):