Skip to content

Commit

Permalink
Create a transform between std data and [0,1] (#1221)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrapin authored Aug 20, 2021
1 parent cc67262 commit 3b09e00
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 125 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion nevergrad/common/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 8 additions & 5 deletions nevergrad/optimization/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
34 changes: 0 additions & 34 deletions nevergrad/optimization/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
70 changes: 0 additions & 70 deletions nevergrad/optimization/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions nevergrad/parametrization/_datalayers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
96 changes: 95 additions & 1 deletion nevergrad/parametrization/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion nevergrad/parametrization/test_parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 3b09e00

Please sign in to comment.