diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c629ea18..d8f694570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ - Experimental methods `Array.set_recombination` and `Array.set_mutation(custom=.)` are removed in favor of layers changing `Array` behaviors [#1086](https://github.com/facebookresearch/nevergrad/pull/1086). Caution: this is still very experimental (and undocumented). +- Important bug correction on the shape of bounds if specified as tuple or list instead of np.ndarray + [#1221](https://github.com/facebookresearch/nevergrad/pull/1221). ### Important changes diff --git a/nevergrad/common/typing.py b/nevergrad/common/typing.py index 2ce372ee5..f32c83d60 100644 --- a/nevergrad/common/typing.py +++ b/nevergrad/common/typing.py @@ -45,7 +45,7 @@ ArgsKwargs = Tuple[Tuple[Any, ...], Dict[str, Any]] -ArrayLike = Union[Tuple[float, ...], List[float], _np.ndarray] +ArrayLike = Union[Tuple[float, ...], List[float], _np.ndarray] # most common PathLike = Union[str, Path] FloatLoss = float Loss = Union[float, ArrayLike] diff --git a/nevergrad/optimization/oneshot.py b/nevergrad/optimization/oneshot.py index c399b19c7..500e0a404 100644 --- a/nevergrad/optimization/oneshot.py +++ b/nevergrad/optimization/oneshot.py @@ -5,10 +5,10 @@ import copy import numpy as np -from scipy import stats from scipy.spatial import ConvexHull # pylint: disable=no-name-in-module import nevergrad.common.typing as tp from nevergrad.parametrization import parameter as p +from nevergrad.parametrization import transforms as trans from . import sequences from . import base from .base import IntOrParameter @@ -274,7 +274,7 @@ def __init__( self.rescaled = rescaled self.recommendation_rule = recommendation_rule # rescale to the bounds if both are provided - self._scaler = utils.BoundScaler(self.parametrization) + self._normalizer = p.helpers.Normalizer(self.parametrization) @property def sampler(self) -> sequences.Sampler: @@ -319,10 +319,13 @@ def _internal_ask(self) -> tp.ArrayLike: assert self.budget is not None self.scale = np.sqrt(np.log(self.budget) / self.dimension) - def transf(x: np.ndarray) -> np.ndarray: - return self.scale * (stats.cauchy.ppf if self.cauchy else stats.norm.ppf)(x) # type: ignore + transf = trans.CumulativeDensity( + 0, 1, scale=self.scale, density="cauchy" if self.cauchy else "gaussian" + ) + # hack since scale is not defined before the first hack (TODO: refactor) + self._normalizer.unbounded_transform = transf - self._opposable_data = self._scaler.transform(sample, transf) + self._opposable_data = self._normalizer.backward(sample) assert self._opposable_data is not None return self._opposable_data diff --git a/nevergrad/optimization/test_utils.py b/nevergrad/optimization/test_utils.py index ac5087cc5..0c79903a9 100644 --- a/nevergrad/optimization/test_utils.py +++ b/nevergrad/optimization/test_utils.py @@ -8,8 +8,6 @@ import numpy as np import nevergrad as ng from nevergrad.common import testing -from nevergrad.parametrization import parameter as p -from nevergrad.parametrization.test_utils import split_as_data_parameters from .test_base import CounterFunction from . import experimentalvariants as xpvariants from . import utils @@ -146,35 +144,3 @@ def test_uid_queue() -> None: uidq.clear() with pytest.raises(RuntimeError): uidq.ask() - - -def test_bound_scaler() -> None: - ref = p.Instrumentation( - p.Array(shape=(1, 2)).set_bounds(-12, 12, method="arctan"), - p.Array(shape=(2,)).set_bounds(-12, 12, full_range_sampling=False), - lr=p.Log(lower=0.001, upper=1000), - stuff=p.Scalar(lower=-1, upper=2), - unbounded=p.Scalar(lower=-1, init=0.0), - value=p.Scalar(), - letter=p.Choice("abc"), - ) - # make sure the order is preserved using legacy split method - expected = [x[1] for x in split_as_data_parameters(ref)] - assert p.helpers.list_data(ref) == expected - # check the bounds - param = ref.spawn_child() - scaler = utils.BoundScaler(param) - output = scaler.transform([1.0] * param.dimension, lambda x: x) - param.set_standardized_data(output) - (array1, array2), values = param.value - np.testing.assert_array_almost_equal(array1, [[12, 12]]) - np.testing.assert_array_almost_equal(array2, [1, 1]) - assert values["stuff"] == 2 - assert values["unbounded"] == 1 - assert values["value"] == 1 - assert values["lr"] == pytest.approx(1000) - # again, on the middle point - output = scaler.transform([0] * param.dimension, lambda x: x) - param.set_standardized_data(output) - assert param.value[1]["lr"] == pytest.approx(1.0) - assert param.value[1]["stuff"] == pytest.approx(0.5) diff --git a/nevergrad/optimization/utils.py b/nevergrad/optimization/utils.py index f8fda31e2..74aab135d 100644 --- a/nevergrad/optimization/utils.py +++ b/nevergrad/optimization/utils.py @@ -5,11 +5,9 @@ import math import operator -import warnings import numpy as np from nevergrad.parametrization import parameter as p from nevergrad.parametrization.utils import float_penalty as _float_penalty -from nevergrad.parametrization import _datalayers import nevergrad.common.typing as tp from nevergrad.common.tools import OrderedSet @@ -345,74 +343,6 @@ def discard(self, uid: str) -> None: self.told.remove(uid) -class BoundScaler: - """Hacky way to sample in the space defined by the parametrization. - Given an vector of values between 0 and 1, - the transform method samples in the bounds if provided, - or using the provided function otherwise. - This is used for samplers. - Code of parametrization and/or this helper should definitely be - updated to make it simpler and more robust - """ - - def __init__(self, reference: p.Parameter) -> None: - self.reference = reference.spawn_child() - self.reference.freeze() - # initial check - parameter = self.reference.spawn_child() - parameter.set_standardized_data(np.linspace(-1, 1, self.reference.dimension)) - expected = parameter.get_standardized_data(reference=self.reference) - self._ref_arrays = p.helpers.list_data(self.reference) - arrays = p.helpers.list_data(parameter) - check = np.concatenate( - [x.get_standardized_data(reference=y) for x, y in zip(arrays, self._ref_arrays)], axis=0 - ) - self.working = True - if not np.allclose(check, expected): - self.working = False - self._warn() - - def _warn(self) -> None: - warnings.warn( - f"Failed to find bounds for {self.reference}, quasi-random optimizer may be inefficient.\n" - "Please open an issue on Nevergrad github" - ) - - def transform( - self, x: tp.ArrayLike, unbounded_transform: tp.Callable[[np.ndarray], np.ndarray] - ) -> np.ndarray: - """Transform from [0, 1] to the space between bounds""" - y = np.array(x, copy=True, dtype=float) - if not self.working: - return unbounded_transform(y) - try: - out = self._transform(y, unbounded_transform) - except Exception: # pylint: disable=broad-except - self._warn() - out = unbounded_transform(y) - return out - - def _transform( - self, x: np.ndarray, unbounded_transform: tp.Callable[[np.ndarray], np.ndarray] - ) -> np.ndarray: - # modifies x in place - start = 0 - for ref in self._ref_arrays: - end = start + ref.dimension - layers = _datalayers.BoundLayer.filter_from(ref) # find bound layers - layers = [x for x in layers if x.uniform_sampling] # keep only uniform sampling - if not layers: - x[start:end] = unbounded_transform(x[start:end]) - else: - layer_index = layers[-1]._layer_index - array = ref.spawn_child() - normalized = x[start:end].reshape(ref._value.shape) - array._layers[layer_index].set_normalized_value(normalized) # type: ignore - x[start:end] = array.get_standardized_data(reference=ref) - start = end - return x - - class ConstraintManager: """Try max_constraints_trials random explorations for satisfying constraints. The finally chosen point, if it does not satisfy constraints, is penalized as shown in the penalty function, diff --git a/nevergrad/parametrization/_datalayers.py b/nevergrad/parametrization/_datalayers.py index 9a93d0815..b59195f19 100644 --- a/nevergrad/parametrization/_datalayers.py +++ b/nevergrad/parametrization/_datalayers.py @@ -62,8 +62,7 @@ def __init__( """ # TODO improve description of methods super().__init__(lower, upper, uniform_sampling) self.bounds: tp.Tuple[tp.Optional[np.ndarray], tp.Optional[np.ndarray]] = tuple( # type: ignore - a if isinstance(a, np.ndarray) or a is None else np.array([a], dtype=float) - for a in (lower, upper) + None if a is None else trans.bound_to_array(a) for a in (lower, upper) ) both_bounds = all(b is not None for b in self.bounds) self.uniform_sampling: bool = uniform_sampling # type: ignore @@ -77,6 +76,11 @@ def __init__( f"Lower bounds {lower} should be strictly smaller than upper bounds {upper}" ) + def _normalizer(self) -> trans.Transform: + if any(b is None for b in self.bounds): + raise RuntimeError("Cannot use normalized value for not-fully bounded Parameter") + return trans.Affine(self.bounds[1] - self.bounds[0], self.bounds[0]).reverted() # type: ignore + def __call__(self, data: D, inplace: bool = False) -> D: """Creates a new Data instance with bounds""" new = data if inplace else data.copy() @@ -117,15 +121,20 @@ def _layered_sample(self) -> Data: child = root.spawn_child() # send new val to the layer under this one for the child new_val = self.random_state.uniform(size=shape) + del child.value # make sure there is no cache child._layers[self._layer_index].set_normalized_value(new_val) # type: ignore return child def set_normalized_value(self, value: np.ndarray) -> None: """Sets a value normalized between 0 and 1""" - bounds = tuple(b * np.ones(value.shape) for b in self.bounds) # type: ignore - new_val = bounds[0] + (bounds[1] - bounds[0]) * value + new_val = self._normalizer().backward(value) self._layers[self._layer_index]._layered_set_value(new_val) + def get_normalized_value(self) -> np.ndarray: + """Gets a value normalized between 0 and 1""" + value = self._layers[self._layer_index]._layered_get_value() + return self._normalizer().forward(value) + def _check(self, value: np.ndarray) -> None: if not utils.BoundChecker(*self.bounds)(value): raise errors.NevergradValueError("New value does not comply with bounds") diff --git a/nevergrad/parametrization/helpers.py b/nevergrad/parametrization/helpers.py index a6d403e5f..290f2c173 100644 --- a/nevergrad/parametrization/helpers.py +++ b/nevergrad/parametrization/helpers.py @@ -3,14 +3,18 @@ # 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 contextlib import itertools -import typing as tp +import numpy as np +import nevergrad.common.typing as tp from . import core from . import container from . import _layering +from . import _datalayers from . import data as pdata from . import choice as pchoice +from . import transforms as trans def flatten( @@ -136,3 +140,93 @@ def deterministic_sampling(parameter: core.Parameter) -> tp.Iterator[None]: parameter.value # pylint: disable=pointless-statement for lay, det in zip(int_layers, deterministic): lay.deterministic = det + + +class Normalizer: + """Hacky way to sample in the space defined by the parametrization. + Given an vector of values between 0 and 1, + the transform method samples in the bounds if provided, + or using the provided function otherwise. + This is used for samplers. + Code of parametrization and/or this helper should definitely be + updated to make it simpler and more robust + """ + + def __init__( + self, + reference: core.Parameter, + unbounded_transform: tp.Optional[trans.Transform] = None, + only_sampling: bool = False, + ) -> None: + self.reference = reference.spawn_child() + self.reference.freeze() + # initial check + parameter = self.reference.spawn_child() + parameter.set_standardized_data(np.linspace(-1, 1, self.reference.dimension)) + expected = parameter.get_standardized_data(reference=self.reference) + self._ref_arrays = list_data(self.reference) + arrays = list_data(parameter) + check = np.concatenate( + [x.get_standardized_data(reference=y) for x, y in zip(arrays, self._ref_arrays)], axis=0 + ) + self.working = True + if not np.allclose(check, expected): + self.working = False + self._warn() + self._only_sampling = only_sampling + self.unbounded_transform = ( + trans.ArctanBound(0, 1) if unbounded_transform is None else unbounded_transform + ) + + def _warn(self) -> None: + warnings.warn( + f"Failed to find bounds for {self.reference}, quasi-random optimizer may be inefficient.\n" + "Please open an issue on Nevergrad github" + ) + + def backward(self, x: tp.ArrayLike) -> np.ndarray: + """Transform from [0, 1] to standardized space""" + return self._apply(x, forward=False) + + def forward(self, x: tp.ArrayLike) -> np.ndarray: + """Transform from standardized space to [0, 1]""" + return self._apply(x, forward=True) + + def _apply(self, x: tp.ArrayLike, forward: bool = True) -> np.ndarray: + utrans = self.unbounded_transform.forward if forward else self.unbounded_transform.backward + y = np.array(x, copy=True, dtype=float) + if not self.working: + return utrans(y) + try: + self._apply_unsafe(y, forward=forward) # inplace + except Exception: # pylint: disable=broad-except + self._warn() + y = utrans(y) + return y + + def _apply_unsafe(self, x: np.ndarray, forward: bool = True) -> None: + # modifies x in place + start = 0 + utrans = self.unbounded_transform.forward if forward else self.unbounded_transform.backward + for ref in self._ref_arrays: + end = start + ref.dimension + layers = _datalayers.BoundLayer.filter_from(ref) # find bound layers + layers = [ + lay for lay in layers if not any(b is None for b in lay.bounds) + ] # keep only fully bounded layers + if self._only_sampling: # for samplers + layers = [lay for lay in layers if lay.uniform_sampling] + if not layers: + x[start:end] = utrans(x[start:end]) + else: + layer_index = layers[-1]._layer_index + array = ref.spawn_child() + if forward: + array.set_standardized_data(x[start:end]) + x[start:end] = array._layers[layer_index].get_normalized_value()[:] # type: ignore + + else: + normalized = x[start:end].reshape(ref._value.shape) + array._layers[layer_index].set_normalized_value(normalized) # type: ignore + x[start:end] = array.get_standardized_data(reference=ref) + start = end diff --git a/nevergrad/parametrization/test_parameter.py b/nevergrad/parametrization/test_parameter.py index 1a7319bf7..4692f9295 100644 --- a/nevergrad/parametrization/test_parameter.py +++ b/nevergrad/parametrization/test_parameter.py @@ -408,7 +408,7 @@ def test_array_bounded_initialization() -> None: def test_array_sampling(method: str, exponent: tp.Optional[float], sigma: float) -> None: mbound = 10000.0 param = par.Array(init=2 * np.ones((2, 3))).set_bounds( - [1.0, 1, 1], [mbound] * 3, method=method, full_range_sampling=True + [[1.0, 1, 1]], [[mbound] * 3], method=method, full_range_sampling=True # type: ignore ) param.set_mutation(exponent=exponent, sigma=sigma) new_param = param.sample() diff --git a/nevergrad/parametrization/test_utils.py b/nevergrad/parametrization/test_utils.py index 9414f2435..fc43d37d0 100644 --- a/nevergrad/parametrization/test_utils.py +++ b/nevergrad/parametrization/test_utils.py @@ -12,7 +12,9 @@ import typing as tp from pathlib import Path import numpy as np +import pytest from nevergrad.common import testing +from . import transforms as trans from . import parameter as p from . import utils from . import helpers @@ -212,6 +214,55 @@ def split_as_data_parameters( return ordered_arrays +def test_normalizer_backward() -> None: + ref = p.Instrumentation( + p.Array(shape=(1, 2)).set_bounds(-12, 12, method="arctan"), + p.Array(shape=(2,)).set_bounds(-12, 12, full_range_sampling=False), + lr=p.Log(lower=0.001, upper=1000), + stuff=p.Scalar(lower=-1, upper=2), + unbounded=p.Scalar(lower=-1, init=0.0), + value=p.Scalar(), + letter=p.Choice("abc"), + ) + # make sure the order is preserved using legacy split method + expected = [x[1] for x in split_as_data_parameters(ref)] + assert helpers.list_data(ref) == expected + # check the bounds + param = ref.spawn_child() + scaler = helpers.Normalizer(param, only_sampling=True, unbounded_transform=trans.Affine(1, 0)) + # setting + output = scaler.backward([1.0] * param.dimension) + param.set_standardized_data(output) + (array1, array2), values = param.value + np.testing.assert_array_almost_equal(array1, [[12, 12]]) + np.testing.assert_array_almost_equal(array2, [1, 1]) + assert values["stuff"] == 2 + assert values["unbounded"] == 1 + assert values["value"] == 1 + assert values["lr"] == pytest.approx(1000) + # again, on the middle point + output = scaler.backward([0] * param.dimension) + param.set_standardized_data(output) + assert param.value[1]["lr"] == pytest.approx(1.0) + assert param.value[1]["stuff"] == pytest.approx(0.5) + + +def test_normalizer_forward() -> None: + ref = p.Tuple( + p.Scalar(init=0), p.Scalar(init=1), p.Scalar(lower=-1, upper=3, init=0), p.Scalar(lower=-1, upper=1) + ) + scaler = helpers.Normalizer(ref) + out = scaler.forward(ref.get_standardized_data(reference=ref)) + np.testing.assert_almost_equal(out, [0.5, 0.5, 0.25, 0.5]) + param = ref.spawn_child() + param.value = (0, 100, -1, 1) + data = param.get_standardized_data(reference=ref) + out = scaler.forward(data) + np.testing.assert_almost_equal(out, [0.5, 0.9967849, 0, 1]) + back = scaler.backward(out) + np.testing.assert_almost_equal(back, data) # shouuld be bijective + + # # # END OF CHECK # # # diff --git a/nevergrad/parametrization/transforms.py b/nevergrad/parametrization/transforms.py index f9a6612b1..bc92234d3 100644 --- a/nevergrad/parametrization/transforms.py +++ b/nevergrad/parametrization/transforms.py @@ -11,6 +11,14 @@ from . import utils +def bound_to_array(x: tp.BoundValue) -> np.ndarray: + """Updates type of bounds to use arrays""" + if isinstance(x, (tuple, list, np.ndarray)): + return np.array(x, copy=False) + else: + return np.array([x], dtype=float) + + class Transform: """Base class for transforms implementing a forward and a backward (inverse) method. @@ -64,13 +72,13 @@ class Affine(Transform): b: float """ - def __init__(self, a: float, b: float) -> None: + def __init__(self, a: tp.BoundValue, b: tp.BoundValue) -> None: super().__init__() - if not a: + self.a = bound_to_array(a) + self.b = bound_to_array(b) + if not np.any(self.a): raise ValueError('"a" parameter should be non-zero to prevent information loss.') - self.a = a - self.b = b - self.name = f"Af({self.a},{self.b})" + self.name = f"Af({a},{b})" def forward(self, x: np.ndarray) -> np.ndarray: return self.a * x + self.b # type: ignore @@ -247,17 +255,46 @@ def backward(self, y: np.ndarray) -> np.ndarray: 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. + + Parameters + ---------- + lower: float + lower bound + upper: float + upper bound + eps: float + small values to avoid hitting the bounds + scale: float + scaling factor of the density + density: str + either gaussian, or cauchy distributions """ - def __init__(self, lower: float = 0.0, upper: float = 1.0, eps: float = 1e-9) -> None: + def __init__( + self, + lower: float = 0.0, + upper: float = 1.0, + eps: float = 1e-9, + scale: float = 1.0, + density: str = "gaussian", + ) -> None: super().__init__(a_min=lower, a_max=upper) self._b = lower self._a = upper - lower self._eps = eps + self._scale = scale self.name = f"Cd({_f(lower)},{_f(upper)})" + if density not in ("gaussian", "cauchy"): + raise ValueError("Unknown density") + if density == "gaussian": + self._forw = stats.norm.cdf + self._back = stats.norm.ppf + else: + self._forw = stats.cauchy.cdf + self._back = stats.cauchy.ppf def forward(self, x: np.ndarray) -> np.ndarray: - return self._a * stats.norm.cdf(x) + self._b # type: ignore + return self._a * self._forw(x / self._scale) + self._b # type: ignore def backward(self, y: np.ndarray) -> np.ndarray: if (y > self.a_max).any() or (y < self.a_min).any(): @@ -265,7 +302,7 @@ def backward(self, y: np.ndarray) -> np.ndarray: 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) + return self._scale * self._back(y) class Fourrier(Transform): diff --git a/nevergrad/parametrization/utils.py b/nevergrad/parametrization/utils.py index d719fd828..0021fa34b 100644 --- a/nevergrad/parametrization/utils.py +++ b/nevergrad/parametrization/utils.py @@ -351,5 +351,4 @@ def __init__(self, func: tp.Callable[[tp.Any], tp.Loss]) -> None: def __call__(self, *args: tp.Any, **kwargs: tp.Any) -> tp.Loss: out = self.func((args, kwargs)) - print("calling", args, kwargs, "out =", out) return out