diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c90b669b..9e3f73d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - :code:`MultiobjectiveFunction` does not exist anymore [#1034](https://github.com/facebookresearch/nevergrad/pull/1034). - the new `nevergrad.errors` module gathers errors and warnings used throughout the package (WIP) [#1031](https://github.com/facebookresearch/nevergrad/pull/1031). +- `Parameter` classes are undergoing heavy changes ([#1029](https://github.com/facebookresearch/nevergrad/pull/1029) [#1036](https://github.com/facebookresearch/nevergrad/pull/1036) and more to come), please open an issue if you encounter any problem. The midterm aim is to allow for simpler constraint management. ## 0.4.3 (2021-01-28) diff --git a/nevergrad/common/typing.py b/nevergrad/common/typing.py index 3f5905fc2..d1806e95f 100644 --- a/nevergrad/common/typing.py +++ b/nevergrad/common/typing.py @@ -49,6 +49,7 @@ PathLike = Union[str, Path] FloatLoss = float Loss = Union[float, ArrayLike] +BoundValue = Optional[Union[float, int, _np.int, _np.float, _np.ndarray]] # %% Protocol definitions for executor typing diff --git a/nevergrad/functions/test_functionlib.py b/nevergrad/functions/test_functionlib.py index cdf6660d0..cfd9d8531 100644 --- a/nevergrad/functions/test_functionlib.py +++ b/nevergrad/functions/test_functionlib.py @@ -222,12 +222,12 @@ def test_far_optimum_function(independent_sigma: bool, mutable_sigma: bool) -> N 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) + assert param.sigma.value.size == (1 + independent_sigma) # type: ignore 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] + assert new_val[0] != new_val[1] # type: ignore def test_far_optimum_function_cases() -> None: diff --git a/nevergrad/optimization/callbacks.py b/nevergrad/optimization/callbacks.py index 508c69781..9a1a2ad13 100644 --- a/nevergrad/optimization/callbacks.py +++ b/nevergrad/optimization/callbacks.py @@ -110,7 +110,7 @@ def __call__(self, optimizer: base.Optimizer, candidate: p.Parameter, loss: tp.F if inspect.ismethod(val): val = repr(val.__self__) # show mutation class data[name if name else "0"] = val.tolist() if isinstance(val, np.ndarray) else val - if isinstance(param, p.Array): + if isinstance(param, p.Data): val = param.sigma.value data[(name if name else "0") + "#sigma"] = ( val.tolist() if isinstance(val, np.ndarray) else val diff --git a/nevergrad/optimization/utils.py b/nevergrad/optimization/utils.py index b886008db..250b2fb37 100644 --- a/nevergrad/optimization/utils.py +++ b/nevergrad/optimization/utils.py @@ -380,17 +380,17 @@ def _warn(self) -> None: ) @classmethod - def list_arrays(cls, parameter: p.Parameter) -> tp.List[p.Array]: - """Computes a list of data (Array) parameters in the same order as in + def list_arrays(cls, parameter: p.Parameter) -> tp.List[p.Data]: + """Computes a list of Data (Array/Scalar) parameters in the same order as in the standardized data space. """ - if isinstance(parameter, p.Array): + if isinstance(parameter, p.Data): return [parameter] elif isinstance(parameter, p.Constant): return [] if not isinstance(parameter, p.Container): raise RuntimeError(f"Unsupported parameter {parameter}") - output: tp.List[p.Array] = [] + output: tp.List[p.Data] = [] for _, subpar in sorted(parameter._content.items()): output += cls.list_arrays(subpar) return output diff --git a/nevergrad/parametrization/choice.py b/nevergrad/parametrization/choice.py index 5ff7e86bf..7cf2a8fba 100644 --- a/nevergrad/parametrization/choice.py +++ b/nevergrad/parametrization/choice.py @@ -34,7 +34,7 @@ def as_tag(cls, param: core.Parameter) -> "ChoiceTag": return cls(type(param), arity) -class BaseChoice(core.Dict): +class BaseChoice(core.Container): ChoiceTag = ChoiceTag @@ -43,8 +43,7 @@ def __init__( ) -> 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 + lchoices = list(choices) # unroll iterables (includig Tuple instances if not lchoices: raise ValueError("{self._class__.__name__} received an empty list of options.") super().__init__(choices=Tuple(*lchoices), **kwargs) @@ -61,6 +60,14 @@ def __len__(self) -> int: """Number of choices""" return len(self.choices) + def _get_parameters_str(self) -> str: + params = sorted( + (k, p.name) + for k, p in self._content.items() + if p.name != self._ignore_in_repr.get(k, "#ignoredrepr#") + ) + return ",".join(f"{k}={n}" for k, n in params) + @property def index(self) -> int: # delayed choice """Index of the chosen option""" @@ -97,11 +104,9 @@ def _find_and_set_value(self, values: tp.List[tp.Any]) -> np.ndarray: 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) # try to find where to put this for i, value in enumerate(values): - for k in nums: - choice = self.choices[k] + for k, choice in enumerate(self.choices): try: choice.value = value indices[i] = k @@ -162,7 +167,6 @@ def __init__( repetitions: tp.Optional[int] = None, deterministic: bool = False, ) -> None: - assert not isinstance(choices, Tuple) lchoices = list(choices) rep = 1 if repetitions is None else repetitions super().__init__( @@ -233,9 +237,10 @@ def mutate(self) -> None: self.choices[ind].mutate() def _internal_spawn_child(self: C) -> C: - choices = (y for x, y in sorted(self.choices.spawn_child()._content.items())) child = self.__class__( - choices=choices, deterministic=self._deterministic, repetitions=self._repetitions + choices=self.choices.spawn_child(), + deterministic=self._deterministic, + repetitions=self._repetitions, ) child._content["weights"] = self.weights.spawn_child() return child @@ -330,8 +335,7 @@ def mutate(self) -> None: 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, repetitions=self._repetitions) + child = self.__class__(choices=self.choices.spawn_child(), 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 b41d3af39..ad5f34a67 100644 --- a/nevergrad/parametrization/data.py +++ b/nevergrad/parametrization/data.py @@ -14,50 +14,10 @@ # pylint: disable=no-value-for-parameter -BoundValue = tp.Optional[tp.Union[float, int, np.int, np.float, np.ndarray]] -A = tp.TypeVar("A", bound="Array") +D = tp.TypeVar("D", bound="Data") P = tp.TypeVar("P", bound=core.Parameter) -class BoundChecker: - """Simple object for checking whether an array lies - between provided bounds. - - Parameter - --------- - lower: float or None - minimum value - upper: float or None - maximum value - - Note - ----- - Not all bounds are necessary (data can be partially bounded, or not at all actually) - """ - - def __init__(self, lower: BoundValue = None, upper: BoundValue = None) -> None: - self.bounds = (lower, upper) - - def __call__(self, value: np.ndarray) -> bool: - """Checks whether the array lies within the bounds - - Parameter - --------- - value: np.ndarray - array to check - - Returns - ------- - bool - True iff the array lies within the bounds - """ - for k, bound in enumerate(self.bounds): - if bound is not None: - if np.any((value > bound) if k else (value < bound)): - return False - return True - - class Mutation(core.Parameter): """Custom mutation or recombination This is an experimental API @@ -73,14 +33,14 @@ class Mutation(core.Parameter): # pylint: disable=unused-argument @property - def value(self) -> tp.Callable[[tp.Sequence["Array"]], None]: + def value(self) -> tp.Callable[[tp.Sequence[D]], None]: return self.apply @value.setter def value(self, value: tp.Any) -> None: raise RuntimeError("Mutation cannot be set.") - def apply(self, arrays: tp.Sequence["Array"]) -> None: + def apply(self, arrays: tp.Sequence[D]) -> None: new_value = self._apply_array([a._value for a in arrays]) arrays[0]._value = new_value @@ -99,8 +59,8 @@ def set_standardized_data( return self -# pylint: disable=too-many-arguments, too-many-instance-attributes -class Array(core.Parameter): +# pylint: disable=too-many-arguments, too-many-instance-attributes,abstract-method +class Data(core.Parameter): """Array parameter with customizable mutation and recombination. Parameters @@ -170,31 +130,7 @@ def sigma(self) -> tp.Union["Array", "Scalar"]: """Value for the standard deviation used to mutate the parameter""" return self.parameters["sigma"] # type: ignore - @property - def value(self) -> np.ndarray: - if self.integer: - return np.round(self._value) # type: ignore - return self._value - - @value.setter - def value(self, value: tp.ArrayLike) -> None: - self._check_frozen() - self._ref_data = None - if not isinstance(value, (np.ndarray, tuple, list)): - raise TypeError(f"Received a {type(value)} in place of a np.ndarray/tuple/list") - value = np.asarray(value) - assert isinstance(value, np.ndarray) - if self._value.shape != value.shape: - raise ValueError( - f"Cannot set array of shape {self._value.shape} with value of shape {value.shape}" - ) - if not BoundChecker(*self.bounds)(self.value): - raise ValueError("New value does not comply with bounds") - if self.exponent is not None and np.min(value.ravel()) <= 0: - raise ValueError("Logirithmic values cannot be negative") - self._value = value - - def sample(self: A) -> A: + def sample(self: D) -> D: if not self.full_range_sampling: return super().sample() child = self.spawn_child() @@ -210,14 +146,14 @@ def sample(self: A) -> A: # pylint: disable=unused-argument def set_bounds( - self: A, - lower: BoundValue = None, - upper: BoundValue = None, + self: D, + lower: tp.BoundValue = None, + upper: tp.BoundValue = None, method: str = "bouncing", full_range_sampling: tp.Optional[bool] = None, - a_min: BoundValue = None, - a_max: BoundValue = None, - ) -> A: + a_min: tp.BoundValue = None, + a_max: tp.BoundValue = None, + ) -> D: """Bounds all real values into [lower, upper] using a provided method Parameters @@ -265,7 +201,7 @@ def set_bounds( raise RuntimeError("A bounding method has already been set") if full_range_sampling and not both_bounds: raise ValueError("Cannot use full range sampling if both bounds are not set") - checker = BoundChecker(*bounds) + checker = utils.BoundChecker(*bounds) if not checker(self.value): raise ValueError("Current value is not within bounds, please update it first") if not (lower is None or upper is None): @@ -301,7 +237,7 @@ def set_bounds( ) return self - def set_recombination(self: A, recombination: tp.Union[None, str, core.Parameter]) -> A: + def set_recombination(self: D, recombination: tp.Union[None, str, core.Parameter]) -> D: assert self._parameters is not None self._parameters._content["recombination"] = ( recombination if isinstance(recombination, core.Parameter) else core.Constant(recombination) @@ -329,11 +265,11 @@ def mutate(self) -> None: raise TypeError("Mutation must be a string, a callable or a Mutation instance") def set_mutation( - self: A, + self: D, sigma: tp.Optional[tp.Union[float, core.Parameter]] = None, exponent: tp.Optional[float] = None, custom: tp.Optional[tp.Union[str, core.Parameter]] = None, - ) -> A: + ) -> D: """Output will be cast to integer(s) through deterministic rounding. Parameters @@ -361,7 +297,7 @@ def set_mutation( ): self.parameters._content["sigma"] = core.as_parameter(sigma) else: - self.sigma.value = sigma # type: ignore + self.sigma.value = sigma if exponent is not None: if self.bound_transform is not None and not isinstance(self.bound_transform, trans.Clipping): raise RuntimeError( @@ -379,7 +315,7 @@ def set_mutation( self.parameters._content["mutation"] = core.as_parameter(custom) return self - def set_integer_casting(self: A) -> A: + def set_integer_casting(self: D) -> D: """Output will be cast to integer(s) through deterministic rounding. Returns @@ -397,7 +333,7 @@ def set_integer_casting(self: A) -> A: # pylint: disable=unused-argument def _internal_set_standardized_data( - self: A, data: np.ndarray, reference: A, deterministic: bool = False + self: D, data: np.ndarray, reference: D, deterministic: bool = False ) -> None: assert isinstance(data, np.ndarray) sigma = reference.sigma.value @@ -407,7 +343,7 @@ def _internal_set_standardized_data( if reference.bound_transform is not None: self._value = reference.bound_transform.forward(self._value) - def _internal_spawn_child(self) -> "Array": + def _internal_spawn_child(self: D) -> D: child = self.__class__(init=self.value) child.parameters._content = { k: v.spawn_child() if isinstance(v, core.Parameter) else v @@ -417,7 +353,7 @@ def _internal_spawn_child(self) -> "Array": setattr(child, name, getattr(self, name)) return child - def _internal_get_standardized_data(self: A, reference: A) -> np.ndarray: + def _internal_get_standardized_data(self: D, reference: D) -> np.ndarray: return reference._to_reduced_space(self._value) - reference._get_ref_data() # type: ignore def _get_ref_data(self) -> np.ndarray: @@ -436,7 +372,7 @@ def _to_reduced_space(self, value: np.ndarray) -> np.ndarray: reduced = distribval / sigma return reduced.ravel() # type: ignore - def recombine(self: A, *others: A) -> None: + def recombine(self: D, *others: D) -> None: if not others: return recomb = self.parameters["recombination"].value @@ -454,7 +390,33 @@ def recombine(self: A, *others: A) -> None: raise ValueError(f'Unknown recombination "{recomb}"') -class Scalar(Array): +class Array(Data): + @property + def value(self) -> np.ndarray: + if self.integer: + return np.round(self._value) # type: ignore + return self._value + + @value.setter + def value(self, value: tp.ArrayLike) -> None: + self._check_frozen() + self._ref_data = None + if not isinstance(value, (np.ndarray, tuple, list)): + raise TypeError(f"Received a {type(value)} in place of a np.ndarray/tuple/list") + value = np.asarray(value) + assert isinstance(value, np.ndarray) + if self._value.shape != value.shape: + raise ValueError( + f"Cannot set array of shape {self._value.shape} with value of shape {value.shape}" + ) + if not utils.BoundChecker(*self.bounds)(self.value): + raise ValueError("New value does not comply with bounds") + if self.exponent is not None and np.min(value.ravel()) <= 0: + raise ValueError("Logirithmic values cannot be negative") + self._value = value + + +class Scalar(Data): """Parameter representing a scalar. Parameters @@ -498,8 +460,8 @@ def __init__( if any(a is not None for a in (lower, upper)): self.set_bounds(lower=lower, upper=upper, full_range_sampling=bounded and no_init) - @property # type: ignore - def value(self) -> float: # type: ignore + @property + def value(self) -> float: return float(self._value[0]) if not self.integer else int(np.round(self._value[0])) @value.setter diff --git a/nevergrad/parametrization/helpers.py b/nevergrad/parametrization/helpers.py index 5f1549ad9..dd184b3b7 100644 --- a/nevergrad/parametrization/helpers.py +++ b/nevergrad/parametrization/helpers.py @@ -70,7 +70,7 @@ def flatten_parameter( # pylint: disable=too-many-locals def split_as_data_parameters( parameter: core.Parameter, -) -> tp.List[tp.Tuple[str, pdata.Array]]: +) -> tp.List[tp.Tuple[str, pdata.Data]]: """List all the instances involved as parameter (not as subparameter/ endogeneous parameter) @@ -95,7 +95,7 @@ def split_as_data_parameters( copied = parameter.copy() ref = parameter.copy() flatp, flatc, flatref = ( - {x: y for x, y in flatten_parameter(pa).items() if isinstance(y, pdata.Array)} + {x: y for x, y in flatten_parameter(pa).items() if isinstance(y, pdata.Data)} for pa in (parameter, copied, ref) ) keys = list(flatp.keys()) diff --git a/nevergrad/parametrization/mutation.py b/nevergrad/parametrization/mutation.py index 4bf797ea5..4072eb5f7 100644 --- a/nevergrad/parametrization/mutation.py +++ b/nevergrad/parametrization/mutation.py @@ -10,7 +10,7 @@ from . import core from . import transforms from .data import Mutation as Mutation -from .data import Array, Scalar +from .data import Array, Scalar, Data from .choice import Choice @@ -58,7 +58,7 @@ def __init__( def axis(self) -> tp.Optional[tp.Tuple[int, ...]]: return self.parameters["axis"].value # type: ignore - def apply(self, arrays: tp.Sequence["Array"]) -> None: + def apply(self, arrays: tp.Sequence[Data]) -> None: new_value = self._apply_array([a._value for a in arrays]) bounds = arrays[0].bounds if self.parameters["fft"].value and any(x is not None for x in bounds): @@ -203,7 +203,7 @@ def __init__( def axes(self) -> tp.Optional[tp.Tuple[int, ...]]: return self.parameters["axes"].value # type: ignore - def apply(self, arrays: tp.Sequence[Array]) -> None: + def apply(self, arrays: tp.Sequence[Data]) -> None: arrays = list(arrays) assert len(arrays) == 1 data = np.zeros(arrays[0].value.shape) @@ -230,7 +230,7 @@ def __init__(self, axis: int, shape: tp.Sequence[int]): def axes(self) -> tp.Optional[tp.Tuple[int, ...]]: return self.parameters["axes"].value # type: ignore - def apply(self, arrays: tp.Sequence[Array]) -> None: + def apply(self, arrays: tp.Sequence[Data]) -> None: arrays = list(arrays) assert len(arrays) == 1 data = np.zeros(arrays[0].value.shape) diff --git a/nevergrad/parametrization/parameter.py b/nevergrad/parametrization/parameter.py index 5bb3cc282..5efe758a4 100644 --- a/nevergrad/parametrization/parameter.py +++ b/nevergrad/parametrization/parameter.py @@ -7,7 +7,7 @@ # import with "as" to explicitely allow reexport (mypy) from .utils import NotSupportedError as NotSupportedError from .core import Parameter as Parameter -from .core import Container as Container +from .core import Container as Container # abstract from .core import Dict as Dict from .core import Constant as Constant # avoid using except for checks from .core import ( @@ -15,6 +15,7 @@ ) # special case for multiobjective optimization from .container import Tuple as Tuple from .container import Instrumentation as Instrumentation +from .data import Data as Data # abstract from .data import Array as Array from .data import Scalar as Scalar from .data import Log as Log diff --git a/nevergrad/parametrization/test_parameter.py b/nevergrad/parametrization/test_parameter.py index 4f7a62508..a35b56ca9 100644 --- a/nevergrad/parametrization/test_parameter.py +++ b/nevergrad/parametrization/test_parameter.py @@ -33,10 +33,10 @@ def test_array_basics() -> None: assert "blublu:{'var1" in representation -@pytest.mark.parametrize( +@pytest.mark.parametrize( # type: ignore "param", [ - par.Dict(truc=12), # type: ignore + par.Dict(truc=12), par.Tuple(), par.Instrumentation(12), ], @@ -105,7 +105,7 @@ def check_parameter_features(param: par.Parameter) -> None: assert child_hash.name == "blublu" param.value = child.value assert param.get_value_hash() == child.get_value_hash() - if isinstance(param, par.Array): + if isinstance(param, par.Data): assert param.get_value_hash() != child_hash.get_value_hash() child_hash.value = param.value assert not np.any(param.get_standardized_data(reference=child)) @@ -128,7 +128,7 @@ def check_parameter_features(param: par.Parameter) -> None: string = pickle.dumps(child) pickle.loads(string) # array info transfer: - if isinstance(param, par.Array): + if isinstance(param, par.Data): for name in ( "integer", "exponent", diff --git a/nevergrad/parametrization/utils.py b/nevergrad/parametrization/utils.py index 0ec9ddbab..7f01e0947 100644 --- a/nevergrad/parametrization/utils.py +++ b/nevergrad/parametrization/utils.py @@ -8,12 +8,51 @@ import shutil import tempfile import subprocess -import typing as tp from pathlib import Path import numpy as np +from nevergrad.common import typing as tp from nevergrad.common import tools as ngtools +class BoundChecker: + """Simple object for checking whether an array lies + between provided bounds. + + Parameter + --------- + lower: float or None + minimum value + upper: float or None + maximum value + + Note + ----- + Not all bounds are necessary (data can be partially bounded, or not at all actually) + """ + + def __init__(self, lower: tp.BoundValue = None, upper: tp.BoundValue = None) -> None: + self.bounds = (lower, upper) + + def __call__(self, value: np.ndarray) -> bool: + """Checks whether the array lies within the bounds + + Parameter + --------- + value: np.ndarray + array to check + + Returns + ------- + bool + True iff the array lies within the bounds + """ + for k, bound in enumerate(self.bounds): + if bound is not None: + if np.any((value > bound) if k else (value < bound)): + return False + return True + + class Descriptors: """Provides access to a set of descriptors for the parametrization This can be used within optimizers.