Source code for pyhopper.parameters

# Copyright 2021 Mathias Lechner and the PyHopper team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import numpy as np
import typing
from inspect import signature


def cast_to_int(fvalue):
    if isinstance(fvalue, np.ndarray):
        return fvalue.astype(np.int64)
    return int(fvalue)


def call_with_temperature(func, value, temperature):
    sig = signature(func)
    if len(sig.parameters) == 1:
        return func(value)
    return func(value, temperature)


[docs]class Parameter: def __init__(self, init=None): self.initial_value = init def sample(self) -> typing.Any: raise NotImplementedError() def mutate(self, value: typing.Any, temperature: float) -> typing.Any: raise NotImplementedError()
class CustomParameter(Parameter): def __init__(self, init, mutation_strategy, sampling_strategy): super().__init__(init) self._mutation_strategy = mutation_strategy self._sampling_strategy = sampling_strategy def sample(self): return self._sampling_strategy() def mutate(self, value, temperature: float): return call_with_temperature(self._mutation_strategy, value, temperature) class IntParameter(Parameter): def __init__( self, shape, lb, ub, init, multiple_of, mutation_strategy, sampling_strategy, ): super().__init__(init) self._lb = lb self._ub = ub self._multiple_of = multiple_of self._mutation_strategy = mutation_strategy self._sampling_strategy = sampling_strategy self._shape = shape def _cast_if_scalar(self, v): if self._shape is None: # Cast to python integer return int(v) return v def _round_to_multiple_of(self, v): if self._multiple_of is None: return v new_value = v + self._multiple_of // 2 new_value -= new_value % self._multiple_of return new_value def sample(self): if self._sampling_strategy is not None: # Custom sampling new_value = self._sampling_strategy() else: # Integer is always bounded new_value = np.random.default_rng().integers( self._lb, self._ub, size=self._shape, endpoint=True ) new_value = self._round_to_multiple_of(new_value) if self._lb is not None or self._ub is not None: new_value = np.clip(new_value, self._lb, self._ub) return self._cast_if_scalar(new_value) def mutate(self, value, temperature: float): if self._mutation_strategy is not None: # deep copy value in case mutation_strategy operates in-place if isinstance(value, np.ndarray): value = np.copy(value) new_value = call_with_temperature( self._mutation_strategy, value, temperature ) else: # Integer is always bounded spread = self._ub - self._lb new_value = value + cast_to_int( np.round( temperature * 0.5 * np.random.default_rng().integers( -spread, spread, size=self._shape ) ) ) new_value = self._round_to_multiple_of(new_value) if self._lb is not None or self._ub is not None: new_value = np.clip(new_value, self._lb, self._ub) return self._cast_if_scalar(new_value) class PowerOfIntParameter(IntParameter): def __init__( self, shape, lb, ub, init, power_of, multiple_of, mutation_strategy, sampling_strategy, ): super().__init__( shape, lb, ub, init, multiple_of, mutation_strategy, sampling_strategy ) self._power_of = power_of self._log_param = IntParameter( shape, int(np.log2(lb)), int(np.log2(ub)), None, None, None, None ) def sample(self): if self._sampling_strategy is not None: # Custom sampling new_value = self._sampling_strategy() else: new_value = self._log_param.sample() new_value = 2 ** new_value new_value = self._round_to_multiple_of(new_value) if self._lb is not None or self._ub is not None: new_value = np.clip(new_value, self._lb, self._ub) return self._cast_if_scalar(new_value) def mutate(self, value, temperature: float): if self._mutation_strategy is not None: # deep copy value in case mutation_strategy operates in-place if isinstance(value, np.ndarray): value = np.copy(value) new_value = call_with_temperature( self._mutation_strategy, value, temperature ) else: log_value = cast_to_int(np.log2(value)) new_value = self._log_param.mutate(log_value, temperature) # breakpoint() new_value = 2 ** new_value new_value = self._round_to_multiple_of(new_value) if self._lb is not None or self._ub is not None: new_value = np.clip(new_value, self._lb, self._ub) return self._cast_if_scalar(new_value) class ChoiceParameter(Parameter): def __init__(self, options, init, is_ordinal, mutation_strategy, sampling_strategy): super().__init__(init) self._options = options self._is_ordinal = is_ordinal self._mutation_strategy = mutation_strategy self._sampling_strategy = sampling_strategy self._int_param = IntParameter( None, 0, len(options) - 1, None, None, None, None ) def sample(self): if self._sampling_strategy is not None: new_value = self._sampling_strategy() else: new_value = self._options[self._int_param.sample()] return new_value def mutate(self, value, temperature: float): if self._mutation_strategy is not None: new_value = call_with_temperature( self._mutation_strategy, value, temperature ) elif self._is_ordinal: # Values are ordered/related -> prefer adjacent items index = self._options.index(value) new_value = self._options[self._int_param.mutate(index, temperature)] else: # Values are not ordered/related -> just pick any item new_value = self._options[self._int_param.sample()] return new_value class FloatParameter(Parameter): def __init__( self, shape, lb, ub, init, precision, mutation_strategy, sampling_strategy, ): super().__init__(init) self._lb = lb self._ub = ub self._precision = precision self._mutation_strategy = mutation_strategy self._sampling_strategy = sampling_strategy self._shape = shape def _cast_if_scalar(self, v): if self._shape is None: # Cast to python float return float(v) return v def _round(self, value): if self._precision is not None: if self._shape is not None: raise NotImplementedError( "Setting the precision of an array parameter is not yet supported" ) # Use string format function to round to significant decimal digits # round to decimal digits fmt_string = "{:0." + str(self._precision) + "f}" value = float(fmt_string.format(value)) return value def _round_and_clip(self, value): value = self._round(value) if self._lb is not None or self._ub is not None: value = np.clip(value, self._lb, self._ub) return value def sample(self): if self._sampling_strategy is not None: new_value = self._sampling_strategy() else: if self._ub is None: # in unbounded mode we sample a Gaussian new_value = np.random.default_rng().normal(size=self._shape) else: new_value = np.random.default_rng().uniform( self._lb, self._ub, size=self._shape ) new_value = self._round_and_clip(new_value) return self._cast_if_scalar(new_value) def mutate(self, value, temperature: float): if self._mutation_strategy is not None: # deep copy value in case mutation_strategy operates in-place if isinstance(value, np.ndarray): value = np.copy(value) new_value = call_with_temperature( self._mutation_strategy, value, temperature ) else: if self._ub is None: # in unbounded mode we will just add a Gaussian new_value = value + np.random.default_rng().normal( scale=temperature + 1e-8, size=self._shape ) else: spread = 0.5 * (self._ub - self._lb) new_value = value + temperature * np.random.default_rng().uniform( -spread, spread, size=self._shape ) new_value = self._round_and_clip(new_value) return self._cast_if_scalar(new_value) class LogSpaceFloatParameter(FloatParameter): def __init__( self, shape, lb, ub, init, precision, mutation_strategy, sampling_strategy, ): super().__init__( shape, lb, ub, init, precision, mutation_strategy, sampling_strategy ) if lb <= 0.0: raise ValueError( f"Logarithmically scaled parameter must have a lower bound > 0 (got {str(lb)})" ) self._log_param = FloatParameter( shape, np.log(lb), np.log(ub), None, None, None, None ) def _round(self, value): if self._precision is not None: if self._shape is not None: raise NotImplementedError( "Setting the precision of an array parameter is not yet supported" ) # Use string format function to round to significant decimal digits # round the significant digits fmt_string = "{:0." + str(self._precision) + "g}" value = float(fmt_string.format(value)) return value def sample(self): if self._sampling_strategy is not None: # Custom sampling new_value = self._sampling_strategy() else: new_value = self._log_param.sample() new_value = np.exp(new_value) new_value = self._round_and_clip(new_value) return self._cast_if_scalar(new_value) def mutate(self, value, temperature: float): if self._mutation_strategy is not None: # deep copy value in case mutation_strategy operates in-place if isinstance(value, np.ndarray): value = np.copy(value) new_value = call_with_temperature( self._mutation_strategy, value, temperature ) else: log_value = np.log(value) new_value = self._log_param.mutate(log_value, temperature) new_value = np.exp(new_value) new_value = self._round_and_clip(new_value) return self._cast_if_scalar(new_value)