Parametrizing your optimization

Please note that parametrization is still a work in progress and changes are on their way (including for this documentation)! We are trying to update it to make it simpler and simpler to use (all feedbacks are welcome ;) ), with the side effect that there will be breaking changes.

The aim of parametrization is to specify what are the parameters that the optimization should be performed upon. The parametrization subpackage will help you do thanks to:

  • the parameter modules (accessed by the shortcut nevergrad.p) providing classes that should be used to specify each parameter.

  • the FolderFunction which helps transform any code into a Python function in a few lines. This can be especially helpful to optimize parameters in non-Python 3.6+ code (C++, Octave, etc…) or parameters in scripts.

Preliminary examples

The code below defines a parametrization that creates a dict with 3 elements:

  • a log-distributed scalar.

  • an array of size 2.

  • a random letter, which is either a, b or c,

The main attribute for users is the value attribute, which provides the value of the defined dict:

import nevergrad as ng

# build a parameter providing a dict value:
param = ng.p.Dict(
    # logarithmically distributed float
    log=ng.p.Log(lower=0.01, upper=1.0),
    # one-dimensional array of length 2
    array=ng.p.Array(shape=(2,)),
    # character, either "a" or "b or "c".
    char=ng.p.Choice(["a", "b", "c"]),
)

print(param.value)
# {'log': 0.01,
#  'array': array([0., 0.]),
#  'char': 'a'}

Parametrization is central in nevergrad since it is the interface between the optimization domain defined by the users, and the standardized representations used by the optimizers. The snippet below shows how to duplicate the parameter (i.e. spawn a child from the parameter), manually updating the value, and export to the standardized space. Unless implementing an algorithm, users should not have any need for this export. However, setting the value manually can be used to provide an initial prior to the optimizer.

# create a new instance
child = param.spawn_child()
# update its value
child.value = {"log": 0.2, "array": np.array([12.0, 13.0]), "char": "c"}

# export to standardized space
data = child.get_standardized_data(reference=param)
print(data)
# np.array([12., 13.,  0.,  0., 0.69, 0.90])

Similarly, optimizers can use mutate and recombine methods to update the value of parameters. You can easily check how parameters mutate, and mutation of Array variables can be adapted to your need:

param.mutate()
print(param.value)
# {'log': 0.155,
#  'array': np.array([-0.966, 0.045]),
#  'char': 'a'}

# increase the step/sigma for array
# (note that it's advised to to this during the creation
#  of the variable:
#  array=ng.p.Array(shape=(2,)).set_mutation(sigma=10))
param["array"].set_mutation(sigma=10)  # type: ignore
param.mutate()
print(param.value)
# {'log': 0.155,
#  'array': np.array([-9.47, 8.38]),  # larger mutation
#  'char': 'a'}

Note that optimizer freeze candidates to avoid modification their modification and border effects, however you can always spawn new parameters from them.

Parametrization is also responsible for the randomness in nevergrad. They have a random_state which can be set for reproducibility

param.random_state.seed(12)

Parameters

7 types of parameters are currently provided:

  • Choice(items): describes a parameter which can take values within the provided list of (usually unordered categorical) items, and for which transitions are global (from one item to any other item). The returned element will be sampled as the softmax of the values on these dimensions. Be cautious: this process is non-deterministic and makes the function evaluation noisy.

  • TransitionChoice(items): describes a parameter which can take values within the provided list of (usually ordered) items, and for which transitions are local (from one item to close items).

  • Array(shape=shape): describes a np.ndarray of any shape. The bounds of the array and the mutation of this array can be specified (see set_bounds, set_mutation). This makes it a very flexible type of parameter. Eg. Array(shape=(2, 3)).set_bounds(0, 2) encodes for an array of shape (2, 3), with values bounded between 0 and 2. It can be also set to an array of integers (see set_integer_casting)

  • Scalar(): describes a scalar. This parameter inherits from all Array methods, so it can be bounded, projected to integers and mutation rate can be customized.

  • Log(lower, upper): describes log distributed data between two bounds. Under the hood this uses an Scalar with appropriate specifications for bounds and mutations.

  • Instrumentation(*args, **kwargs): a container for other parameters. Values of parameters in the args will be returned as a tuple by param.args, and values of parameters in the kwargs will be returned as a dict by param.kwargs (in practice, param.value == (param.args, param.kwargs)). This serves to parametrize functions taking multiple arguments, since you can then call the function with func(*param.args, **param.kwargs).

Follow the link to the API reference for more details and initialization options:

nevergrad.p.Array(*[, init, shape, lower, ...])

nevergrad.p.Scalar([init, lower, upper, ...])

Parameter representing a scalar.

nevergrad.p.Log(*[, init, exponent, lower, ...])

Parameter representing a positive variable, mutated by Gaussian mutation in log-scale.

nevergrad.p.Dict(**parameters)

Dictionary-valued parameter.

nevergrad.p.Tuple(*parameters)

Tuple-valued parameter.

nevergrad.p.Instrumentation(*args, **kwargs)

Container of parameters available through args and kwargs attributes.

nevergrad.p.Choice(choices[, repetitions, ...])

Unordered categorical parameter, randomly choosing one of the provided choice options as a value.

nevergrad.p.TransitionChoice(choices[, ...])

Categorical parameter, choosing one of the provided choice options as a value, with continuous transitions.

Parametrization

Parametrization helps you define the parameters you want to optimize upon. Currently most algorithms make use of it to help convert the parameters into the “standardized data” space (a vector space spanning all the real values), where it is easier to define operations.

Let’s define the parametrization for a function taking 3 positional arguments and one keyword argument value.

  • arg1 = ng.p.Choice([["Helium", "Nitrogen", "Oxygen"]]) is the first positional argument, which can take 3 possible values, without any order, the selection is made stochasticly through the sampling of a softmax. It is encoded by 3 values (the softmax weights) in the “standardized space”.

  • arg2 = ng.p.TransitionChoice(["Solid", "Liquid", "Gas"]) is the second one, it encodes the choice (i.e. 1 dimension) through a single index which can mutate in a continuous way.

  • third argument will be kept constant to blublu

  • values = ng.p.Tuple(ng.p.Scalar().set_integer_casting(), ng.p.Scalar()) which represents a tuple of two scalars with different numeric types in the parameter space, and in the “standardized space”

We then define a parameter holding all these parameters, with a standardized space of dimension 6 (as the sum of the dimensions above):

arg1 = ng.p.Choice(["Helium", "Nitrogen", "Oxygen"])
arg2 = ng.p.TransitionChoice(["Solid", "Liquid", "Gas"])
values = ng.p.Tuple(ng.p.Scalar().set_integer_casting(), ng.p.Scalar())

instru = ng.p.Instrumentation(arg1, arg2, "blublu", amount=values)
print(instru.dimension)
# >>> 6

You can then directly perform optimization on a function given its parametrization:


def myfunction(arg1, arg2, arg3, amount=(2, 2)):
    print(arg1, arg2, arg3)
    return amount[0] ** 2 + amount[1] ** 2

optimizer = ng.optimizers.NGOpt(parametrization=instru, budget=100)
recommendation = optimizer.minimize(myfunction)
print(recommendation.value)
# >>> (('Helium', 'Gas', 'blublu'), {'value': (0, 0.0006602471804655007)})

Here is a glimpse of what happens on the optimization space:

instru2 = instru.spawn_child().set_standardized_data([-80, 80, -80, 0, 3, 5.0])
assert instru2.args == ("Nitrogen", "Liquid", "blublu")
assert instru2.kwargs == {"amount": (3, 5.0)}

With this code:

  • Nitrogen is selected because proba(e) = exp(80) / (exp(80) + exp(-80) + exp(-80)) = 1

  • Liquid is selected because the index for Liquid is around 0 in the standardized space.

  • amount=(3, 5.0) because the last two values of the standardized space (i.e. 3.0, 5.0) corresponds to the value of the last kwargs.

Parametrizing external code

Sometimes it is completely impractical or impossible to have a simple Python3.6+ function to optimize. This may happen when the code you want to optimize is a script. Even more so if the code you want to optimize is not Python3.6+.

We provide tooling for this situation but this is hacky, so if you can avoid it, do avoid it. Otherwise, go through these steps to instrument your code:

  • identify the variables (parameters, constants…) you want to optimize.

  • add placeholders to your code. Placeholders are just tokens of the form NG_ARG{name|comment} where you can modify the name and comment. The name you set will be the one you will need to use as your function argument. In order to avoid breaking your code, the line containing the placeholders can be commented. To notify that the line should be uncommented for parametrization, you’ll need to add “@nevergrad@” at the start of the comment. Here is an example in C which will notify that we want to obtain a function with a step argument which will inject values into the step_size variable of the code:

int step_size = 0.1
// @nevergrad@ step_size = NG_ARG{step|any comment}
  • prepare the command to execute that will run your code. Make sure that the last printed line is just a float, which is the value to base the optimization upon. We will be doing minimization here, so this value must decrease for better results.

  • instantiate your code into a function using the FolderFunction class:

import sys
from pathlib import Path
import nevergrad as ng
from nevergrad.parametrization import FolderFunction

# nevergrad/parametrization/examples contains a script
example_folder = Path(ng.__file__).parent / "parametrization" / "examples"
python = sys.executable
command = [python, "examples/script.py"]  # command to run from right outside the provided folder
# create a function from the folder
func = FolderFunction(example_folder, command, clean_copy=True)

# print the number of variables of the function:
print(func.placeholders)
# prints: [Placeholder('value1', 'this is a comment'), Placeholder('value2', None), Placeholder('string', None)]
# and run it (the script prints 12 at the end)
assert func(value1=2, value2=3, string="blublu") == 12.0
  • parametrize the function (see Parametrization section just above).

Tips and caveats

  • using FolderFunction argument clean_copy=True will copy your folder so that tempering with it during optimization will run different versions of your code.

  • under the hood, with or without clean_copy=True, when calling the function, FolderFunction will create symlink copy of the initial folder, remove the files that have tokens, and create new ones with appropriate values. Symlinks are used in order to avoid duplicating large projects, but they have some drawbacks, see next point ;)

  • one can add a compilation step to FolderFunction (the compilation just has to be included in the script). However, be extra careful that if the initial folder contains some build files, they could be modified by the compilation step, because of the symlinks. Make sure that during compilation, you remove the build symlinks first! This feature has not been fool proofed yet!!!

  • the following external file types are registered by default: [".c", ".h", ".cpp", ".hpp", ".py", ".m"]. Custom file types can be registered using FolderFunction.register_file_type by providing the relevant file suffix as well as the characters that indicate a comment. However, for now, parameters which provide a vector or values (Array) will inject code with a Python format (list) by default, which may not be suitable.