diff --git a/ConfigSpace/__init__.py b/ConfigSpace/__init__.py index 06a1ee91..bd4ed9f4 100644 --- a/ConfigSpace/__init__.py +++ b/ConfigSpace/__init__.py @@ -41,7 +41,8 @@ ForbiddenEqualsRelation, ForbiddenGreaterThanRelation, ForbiddenInClause, - ForbiddenLessThanRelation) + ForbiddenLessThanRelation, + ForbiddenCallableRelation) from ConfigSpace.hyperparameters import (BetaFloatHyperparameter, BetaIntegerHyperparameter, CategoricalHyperparameter, Constant, diff --git a/ConfigSpace/configuration_space.pyx b/ConfigSpace/configuration_space.pyx index 7b1295bf..a986841b 100644 --- a/ConfigSpace/configuration_space.pyx +++ b/ConfigSpace/configuration_space.pyx @@ -135,7 +135,7 @@ class ConfigurationSpace(collections.abc.Mapping): # changing this to a normal dict will break sampling because there is # no guarantee that the parent of a condition was evaluated before - self._conditionals = set() # type: Set[str] + self._conditionals = []#set() # type: Set[str] self.forbidden_clauses = [] # type: List['AbstractForbiddenComponent'] self.random = np.random.RandomState(seed) @@ -1526,12 +1526,12 @@ class ConfigurationSpace(collections.abc.Mapping): new_child = new_configspace[child_name] new_parent = new_configspace[parent_name] - if hasattr(condition, 'value'): - condition_arg = getattr(condition, 'value') - substituted_condition = condition_type(child=new_child, parent=new_parent, value=condition_arg) - elif hasattr(condition, 'values'): + if hasattr(condition, 'values'): condition_arg = getattr(condition, 'values') substituted_condition = condition_type(child=new_child, parent=new_parent, values=condition_arg) + elif hasattr(condition, 'value'): + condition_arg = getattr(condition, 'value') + substituted_condition = condition_type(child=new_child, parent=new_parent, value=condition_arg) else: raise AttributeError(f'Did not find the expected attribute in condition {type(condition)}.') @@ -1573,15 +1573,24 @@ class ConfigurationSpace(collections.abc.Mapping): hyperparameter_name = getattr(forbidden.hyperparameter, 'name') new_hyperparameter = new_configspace[hyperparameter_name] - if hasattr(forbidden, 'value'): - forbidden_arg = getattr(forbidden, 'value') - substituted_forbidden = forbidden_type(hyperparameter=new_hyperparameter, value=forbidden_arg) - elif hasattr(forbidden, 'values'): + if hasattr(forbidden, 'values'): forbidden_arg = getattr(forbidden, 'values') substituted_forbidden = forbidden_type(hyperparameter=new_hyperparameter, values=forbidden_arg) + elif hasattr(forbidden, 'value'): + forbidden_arg = getattr(forbidden, 'value') + substituted_forbidden = forbidden_type(hyperparameter=new_hyperparameter, value=forbidden_arg) else: raise AttributeError(f'Did not find the expected attribute in forbidden {type(forbidden)}.') + new_forbiddens.append(substituted_forbidden) + elif isinstance(forbidden, ForbiddenRelation): + forbidden_type = type(forbidden) + left_name = getattr(forbidden.left, 'name') + left_hyperparameter = new_configspace[left_name] + right_name = getattr(forbidden.right, 'name') + right_hyperparameter = new_configspace[right_name] + + substituted_forbidden = forbidden_type(left=left_hyperparameter, right=right_hyperparameter) new_forbiddens.append(substituted_forbidden) else: raise TypeError(f'Did not expect the supplied forbidden type {type(forbidden)}.') diff --git a/ConfigSpace/forbidden.pyx b/ConfigSpace/forbidden.pyx index 9777fea2..ef6b0a52 100644 --- a/ConfigSpace/forbidden.pyx +++ b/ConfigSpace/forbidden.pyx @@ -12,8 +12,9 @@ # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the nor the -# names of itConfigurationSpaces contributors may be used to endorse or promote products -# derived from this software without specific prior written permission. +# names of ConfigurationSpace contributors may be used to endorse or +# promote products derived from this software without specific prior +# written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED @@ -31,13 +32,14 @@ import numpy as np import io from ConfigSpace.hyperparameters import Hyperparameter from ConfigSpace.hyperparameters cimport Hyperparameter -from typing import List, Dict, Any, Union +from typing import Dict, Any, Union, Callable from ConfigSpace.forbidden cimport AbstractForbiddenComponent from libc.stdlib cimport malloc, free cimport numpy as np +ForbiddenCallable = Callable[[Hyperparameter, Hyperparameter], bool] cdef class AbstractForbiddenComponent(object): @@ -90,7 +92,8 @@ cdef class AbstractForbiddenComponent(object): def is_forbidden_vector(self, instantiated_hyperparameters, strict): return bool(self.c_is_forbidden_vector(instantiated_hyperparameters, strict)) - cdef int c_is_forbidden_vector(self, np.ndarray instantiated_hyperparameters, int strict): + cdef int c_is_forbidden_vector(self, np.ndarray instantiated_hyperparameters, + int strict): pass @@ -343,7 +346,6 @@ cdef class AbstractForbiddenConjunction(AbstractForbiddenComponent): return all([self.components[i] == other.components[i] for i in range(self.n_components)]) - cpdef set_vector_idx(self, hyperparameter_to_idx): for component in self.components: component.set_vector_idx(hyperparameter_to_idx) @@ -431,7 +433,8 @@ cdef class ForbiddenAndConjunction(AbstractForbiddenConjunction): >>> forbidden_clause_a = ForbiddenEqualsClause(cs["a"], 2) >>> forbidden_clause_b = ForbiddenInClause(cs["b"], [2]) >>> - >>> forbidden_clause = ForbiddenAndConjunction(forbidden_clause_a, forbidden_clause_b) + >>> forbidden_clause = ForbiddenAndConjunction( + ... forbidden_clause_a, forbidden_clause_b) >>> >>> cs.add_forbidden_clause(forbidden_clause) (Forbidden: a == 2 && Forbidden: b in {2}) @@ -488,7 +491,7 @@ cdef class ForbiddenRelation(AbstractForbiddenComponent): cdef public right cdef public int[2] vector_ids - def __init__(self, left: Hyperparameter, right : Hyperparameter): + def __init__(self, left: Hyperparameter, right: Hyperparameter): if not isinstance(left, Hyperparameter): raise TypeError("Argument 'left' is not of type %s." % Hyperparameter) if not isinstance(right, Hyperparameter): @@ -513,7 +516,8 @@ cdef class ForbiddenRelation(AbstractForbiddenComponent): return (self,) cpdef set_vector_idx(self, hyperparameter_to_idx): - self.vector_ids = (hyperparameter_to_idx[self.left.name], hyperparameter_to_idx[self.right.name]) + self.vector_ids = (hyperparameter_to_idx[self.left.name], + hyperparameter_to_idx[self.right.name]) cpdef is_forbidden(self, instantiated_hyperparameters, strict): left = instantiated_hyperparameters.get(self.left.name) @@ -562,13 +566,84 @@ cdef class ForbiddenRelation(AbstractForbiddenComponent): else: return False - # Relation is always evaluated against actual value and not vector representation - return self._is_forbidden(self.left._transform(left), self.right._transform(right)) + # Relation is always evaluated against actual value and + # not vector representation + return self._is_forbidden(self.left._transform(left), + self.right._transform(right)) cdef int _is_forbidden_vector(self, DTYPE_t left, DTYPE_t right) except -1: pass +cdef class ForbiddenCallableRelation(ForbiddenRelation): + """A ForbiddenCallable relation between two hyperparameters. + + The ForbiddenCallable uses two hyperparameters as input to a + specified callable, which returns True if the relationship + between the two hyperparameters is forbidden. + + A ForbiddenCallableRelation may not be serializable. + :func:`ConfigSpace.read_and_write.write` will attempt to pickle and base64 encode + the callable with pickle_callables=True. However, the unpicklability + of the callable cannot be assured. + + >>> from ConfigSpace import ConfigurationSpace, ForbiddenCallableRelation + >>> + >>> cs = ConfigurationSpace({"a": [1, 2, 3], "b": [2, 5, 6]}) + >>> + >>> forbidden_clause = ForbiddenCallableRelation(cs['a'], cs['b'], lambda a, b: a + b > 10) + >>> cs.add_forbidden_clause(forbidden_clause) + Forbidden: f(a,b) == True + + Parameters + ---------- + left : :ref:`Hyperparameters` + first argument of callable + + right : :ref:`Hyperparameters` + second argument of callable + + f : Callable + callable that relates the two hyperparameters + """ + cdef public f + + def __init__(self, left: Hyperparameter, right: Hyperparameter, + f: ForbiddenCallable): + if not isinstance(f, Callable): # Can't use ForbiddenCallable here apparently + raise TypeError("Argument 'f' is not of type %s." % Callable) + + super().__init__(left, right) + self.f = f + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, self.__class__): + return False + return super().__eq__(other) and self.f == other.f + + def __copy__(self): + return self.__class__( + a=copy.copy(self.left), + b=copy.copy(self.right), + f=copy.copy(self.f) + ) + + def __repr__(self): + if hasattr(self.f, "__qualname__"): + f_repr = self.f.__qualname__ + elif hasattr(self.f, "__class__"): + f_repr = self.__class__.__qualname__ + else: + raise ValueError("Could not find a qualname for the callable") + return f"Forbidden: {f_repr} | Arguments: {self.left.name}, {self.right.name}" + + cdef int _is_forbidden(self, left, right) except -1: + return self.f(left, right) + + cdef int _is_forbidden_vector(self, DTYPE_t left, DTYPE_t right) except -1: + return self.f(left, right) + + cdef class ForbiddenLessThanRelation(ForbiddenRelation): """A ForbiddenLessThan relation between two hyperparameters. diff --git a/ConfigSpace/hyperparameters.pyx b/ConfigSpace/hyperparameters.pyx index 15a1b75b..6fa2376b 100644 --- a/ConfigSpace/hyperparameters.pyx +++ b/ConfigSpace/hyperparameters.pyx @@ -158,7 +158,7 @@ cdef class Hyperparameter(object): def has_neighbors(self): raise NotImplementedError() - def get_neighbors(self, value, rs, number, transform = False): + def get_neighbors(self, value, rs, number, transform=False): raise NotImplementedError() def get_num_neighbors(self, value): @@ -314,7 +314,7 @@ cdef class Constant(Hyperparameter): def has_neighbors(self) -> bool: return False - def get_num_neighbors(self, value = None) -> int: + def get_num_neighbors(self, value=None) -> int: return 0 def get_neighbors(self, value: Any, rs: np.random.RandomState, number: int, @@ -385,7 +385,7 @@ cdef class NumericalHyperparameter(Hyperparameter): def has_neighbors(self) -> bool: return True - def get_num_neighbors(self, value = None) -> float: + def get_num_neighbors(self, value=None) -> float: return np.inf @@ -539,7 +539,6 @@ cdef class FloatHyperparameter(NumericalHyperparameter): raise NotImplementedError() - cdef class IntegerHyperparameter(NumericalHyperparameter): def __init__(self, name: str, default_value: int, meta: Optional[Dict] = None) -> None: super(IntegerHyperparameter, self).__init__(name, default_value, meta) @@ -555,7 +554,7 @@ cdef class IntegerHyperparameter(NumericalHyperparameter): def check_int(self, parameter: int, name: str) -> int: if abs(int(parameter) - parameter) > 0.00000001 and \ - type(parameter) is not int: + type(parameter) is not int: raise ValueError("For the Integer parameter %s, the value must be " "an Integer, too. Right now it is a %s with value" " %s." % (name, type(parameter), str(parameter))) @@ -912,7 +911,8 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): self.normalized_default_value = self._inverse_transform(self.default_value) if (lower is not None) ^ (upper is not None): - raise ValueError("Only one bound was provided when both lower and upper bounds must be provided.") + raise ValueError( + "Only one bound was provided when both lower and upper bounds must be provided.") if lower is not None and upper is not None: self.lower = float(lower) @@ -920,12 +920,12 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): if self.lower >= self.upper: raise ValueError("Upper bound %f must be larger than lower bound " - "%f for hyperparameter %s" % - (self.upper, self.lower, name)) + "%f for hyperparameter %s" % + (self.upper, self.lower, name)) elif log and self.lower <= 0: raise ValueError("Negative lower bound (%f) for log-scale " - "hyperparameter %s is forbidden." % - (self.lower, name)) + "hyperparameter %s is forbidden." % + (self.lower, name)) self.default_value = self.check_default(default_value) @@ -959,9 +959,11 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): repr_str = io.StringIO() if self.lower is None or self.upper is None: - repr_str.write("%s, Type: NormalFloat, Mu: %s Sigma: %s, Default: %s" % (self.name, repr(self.mu), repr(self.sigma), repr(self.default_value))) + repr_str.write("%s, Type: NormalFloat, Mu: %s Sigma: %s, Default: %s" % + (self.name, repr(self.mu), repr(self.sigma), repr(self.default_value))) else: - repr_str.write("%s, Type: NormalFloat, Mu: %s Sigma: %s, Range: [%s, %s], Default: %s" % (self.name, repr(self.mu), repr(self.sigma), repr(self.lower), repr(self.upper), repr(self.default_value))) + repr_str.write("%s, Type: NormalFloat, Mu: %s Sigma: %s, Range: [%s, %s], Default: %s" % ( + self.name, repr(self.mu), repr(self.sigma), repr(self.lower), repr(self.upper), repr(self.default_value))) if self.log: repr_str.write(", on log-scale") @@ -1048,8 +1050,8 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): lower = None upper = None else: - lower=np.ceil(self.lower) - upper=np.floor(self.upper) + lower = np.ceil(self.lower) + upper = np.floor(self.upper) return NormalIntegerHyperparameter(self.name, int(np.rint(self.mu)), self.sigma, lower=lower, upper=upper, @@ -1065,13 +1067,14 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): def _sample(self, rs: np.random.RandomState, size: Optional[int] = None ) -> Union[np.ndarray, float]: - if self.lower == None: + sigma = self.sigma + if sigma == 0: + return self.mu + elif self.lower == None: mu = self.mu - sigma = self.sigma return rs.normal(mu, sigma, size=size) else: mu = self.mu - sigma = self.sigma lower = self._lower upper = self._upper a = (lower - mu) / sigma @@ -1112,7 +1115,7 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): new_value = rs.normal(value, self.sigma) if self.lower is not None and self.upper is not None: - new_value = min(max(new_value, self.lower), self.upper) + new_value = min(max(new_value, self.lower), self.upper) neighbors.append(new_value) return neighbors @@ -1146,7 +1149,9 @@ cdef class NormalFloatHyperparameter(FloatHyperparameter): """ mu = self.mu sigma = self.sigma - if self.lower == None: + if sigma == 0: + return (vector == mu).astype(np.float64) + elif self.lower == None: return norm(loc=mu, scale=sigma).pdf(vector) else: mu = self.mu @@ -1240,7 +1245,8 @@ cdef class BetaFloatHyperparameter(UniformFloatHyperparameter): def __repr__(self) -> str: repr_str = io.StringIO() - repr_str.write("%s, Type: BetaFloat, Alpha: %s Beta: %s, Range: [%s, %s], Default: %s" % (self.name, repr(self.alpha), repr(self.beta), repr(self.lower), repr(self.upper), repr(self.default_value))) + repr_str.write("%s, Type: BetaFloat, Alpha: %s Beta: %s, Range: [%s, %s], Default: %s" % ( + self.name, repr(self.alpha), repr(self.beta), repr(self.lower), repr(self.upper), repr(self.default_value))) if self.log: repr_str.write(", on log-scale") @@ -1331,15 +1337,14 @@ cdef class BetaFloatHyperparameter(UniformFloatHyperparameter): upper = int(np.floor(self.upper)) default_value = int(np.rint(self.default_value)) return BetaIntegerHyperparameter(self.name, lower=lower, upper=upper, alpha=self.alpha, beta=self.beta, - default_value=int(np.rint(self.default_value)), - q=q_int, log=self.log) + default_value=int(np.rint(self.default_value)), + q=q_int, log=self.log) def is_legal(self, value: Union[float]) -> bool: if isinstance(value, (float, int)): return self.upper >= value >= self.lower return False - cpdef bint is_legal_vector(self, DTYPE_t value): return self._upper >= value >= self._lower @@ -1373,7 +1378,7 @@ cdef class BetaFloatHyperparameter(UniformFloatHyperparameter): alpha = self.alpha beta = self.beta return spbeta(alpha, beta, loc=lb, scale=ub-lb).pdf(vector) \ - * (ub-lb) / (self._upper - self._lower) + * (ub-lb) / (self._upper - self._lower) def get_max_density(self) -> float: if (self.alpha > 1) or (self.beta > 1): @@ -1726,8 +1731,7 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): cdef public nfhp cdef normalization_constant - - def __init__(self, name: str, mu: int, sigma: Union[int, float], + def __init__(self, name: str, mu: Union[int, float], sigma: Union[int, float], default_value: Union[int, None] = None, q: Union[None, int] = None, log: bool = False, lower: Optional[int] = None, @@ -1748,7 +1752,7 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): ---------- name : str Name of the hyperparameter with which it can be accessed - mu : int + mu : int, float Mean of the distribution, from which hyperparameter is sampled sigma : int, float Standard deviation of the distribution, from which @@ -1773,6 +1777,8 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): self.mu = mu self.sigma = sigma + if self.sigma == 0: + assert int(self.mu) == self.mu if default_value is not None: default_value = self.check_int(default_value, self.name) @@ -1790,19 +1796,20 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): self.log = bool(log) if (lower is not None) ^ (upper is not None): - raise ValueError("Only one bound was provided when both lower and upper bounds must be provided.") + raise ValueError( + "Only one bound was provided when both lower and upper bounds must be provided.") if lower is not None and upper is not None: self.upper = self.check_int(upper, "upper") self.lower = self.check_int(lower, "lower") if self.lower >= self.upper: raise ValueError("Upper bound %d must be larger than lower bound " - "%d for hyperparameter %s" % - (self.lower, self.upper, name)) + "%d for hyperparameter %s" % + (self.lower, self.upper, name)) elif log and self.lower <= 0: raise ValueError("Negative lower bound (%d) for log-scale " - "hyperparameter %s is forbidden." % - (self.lower, name)) + "hyperparameter %s is forbidden." % + (self.lower, name)) self.lower = lower self.upper = upper @@ -1828,9 +1835,11 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): repr_str = io.StringIO() if self.lower is None or self.upper is None: - repr_str.write("%s, Type: NormalInteger, Mu: %s Sigma: %s, Default: %s" % (self.name, repr(self.mu), repr(self.sigma), repr(self.default_value))) + repr_str.write("%s, Type: NormalInteger, Mu: %s Sigma: %s, Default: %s" % + (self.name, repr(self.mu), repr(self.sigma), repr(self.default_value))) else: - repr_str.write("%s, Type: NormalInteger, Mu: %s Sigma: %s, Range: [%s, %s], Default: %s" % (self.name, repr(self.mu), repr(self.sigma), repr(self.lower), repr(self.upper), repr(self.default_value))) + repr_str.write("%s, Type: NormalInteger, Mu: %s Sigma: %s, Range: [%s, %s], Default: %s" % ( + self.name, repr(self.mu), repr(self.sigma), repr(self.lower), repr(self.upper), repr(self.default_value))) if self.log: repr_str.write(", on log-scale") @@ -1907,7 +1916,7 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): if self.log: return self._transform_scalar(self.mu) else: - return self.mu + return int(np.round(self.mu)) elif self.is_legal(default_value): return default_value @@ -2008,7 +2017,7 @@ cdef class NormalIntegerHyperparameter(IntegerHyperparameter): def _compute_normalization(self): if self.lower is None: warnings.warn('Cannot normalize the pdf exactly for a NormalIntegerHyperparameter' - f' {self.name} without bounds. Skipping normalization for that hyperparameter.') + f' {self.name} without bounds. Skipping normalization for that hyperparameter.') return 1 else: @@ -2060,7 +2069,6 @@ cdef class BetaIntegerHyperparameter(UniformIntegerHyperparameter): cdef public bfhp cdef normalization_constant - def __init__(self, name: str, alpha: Union[int, float], beta: Union[int, float], lower: Union[int, float], upper: Union[int, float], @@ -2118,13 +2126,13 @@ cdef class BetaIntegerHyperparameter(UniformIntegerHyperparameter): else: q = self.q self.bfhp = BetaFloatHyperparameter(self.name, - self.alpha, - self.beta, - log=self.log, - q=q, - lower=self.lower, - upper=self.upper, - default_value=self.default_value) + self.alpha, + self.beta, + log=self.log, + q=q, + lower=self.lower, + upper=self.upper, + default_value=self.default_value) self.default_value = self.check_default(default_value) self.normalized_default_value = self._inverse_transform(self.default_value) @@ -2132,7 +2140,8 @@ cdef class BetaIntegerHyperparameter(UniformIntegerHyperparameter): def __repr__(self) -> str: repr_str = io.StringIO() - repr_str.write("%s, Type: BetaInteger, Alpha: %s Beta: %s, Range: [%s, %s], Default: %s" % (self.name, repr(self.alpha), repr(self.beta), repr(self.lower), repr(self.upper), repr(self.default_value))) + repr_str.write("%s, Type: BetaInteger, Alpha: %s Beta: %s, Range: [%s, %s], Default: %s" % ( + self.name, repr(self.alpha), repr(self.beta), repr(self.lower), repr(self.upper), repr(self.default_value))) if self.log: repr_str.write(", on log-scale") @@ -2190,7 +2199,6 @@ cdef class BetaIntegerHyperparameter(UniformIntegerHyperparameter): default_value=self.default_value, q=self.q, log=self.log, meta=self.meta) - def check_default(self, default_value: Union[int, float, None]) -> int: if default_value is None: # Here, we just let the BetaFloat take care of the default value @@ -2389,7 +2397,7 @@ cdef class CategoricalHyperparameter(Hyperparameter): ordered_probabilities_other is None and len(np.unique(list(ordered_probabilities_self.values()))) == 1 ) - ) + ) ) def __hash__(self): @@ -2514,7 +2522,7 @@ cdef class CategoricalHyperparameter(Hyperparameter): def has_neighbors(self) -> bool: return len(self.choices) > 1 - def get_num_neighbors(self, value = None) -> int: + def get_num_neighbors(self, value=None) -> int: return len(self.choices) - 1 def get_neighbors(self, value: int, rs: np.random.RandomState, @@ -2609,7 +2617,13 @@ cdef class CategoricalHyperparameter(Hyperparameter): Probability density values of the input vector """ probs = np.array(self.probabilities) + nan = np.isnan(vector) + if np.any(nan): + # Temporarily pick any valid index to use `vector` as an index for `probs` + vector[nan] = 0 res = np.array(probs[vector.astype(int)]) + if np.any(nan): + res[nan] = 0 if res.ndim == 0: return res.reshape(-1) return res @@ -2724,11 +2738,11 @@ cdef class OrdinalHyperparameter(Hyperparameter): def __copy__(self): return OrdinalHyperparameter( - name=self.name, - sequence=copy.deepcopy(self.sequence), - default_value=self.default_value, - meta=self.meta - ) + name=self.name, + sequence=copy.deepcopy(self.sequence), + default_value=self.default_value, + meta=self.meta + ) cpdef int compare(self, value: Union[int, float, str], value2: Union[int, float, str]): if self.value_dict[value] < self.value_dict[value2]: @@ -2948,7 +2962,8 @@ cdef class OrdinalHyperparameter(Hyperparameter): Probability density values of the input vector """ if not np.all(np.isin(vector, self.sequence)): - raise ValueError(f'Some element in the vector {vector} is not in the sequence {self.sequence}.') + raise ValueError( + f'Some element in the vector {vector} is not in the sequence {self.sequence}.') return np.ones_like(vector, dtype=np.float64) / self.num_elements def get_max_density(self) -> float: diff --git a/ConfigSpace/read_and_write/json.py b/ConfigSpace/read_and_write/json.py index b79f26a7..9c2b7ce4 100644 --- a/ConfigSpace/read_and_write/json.py +++ b/ConfigSpace/read_and_write/json.py @@ -38,6 +38,7 @@ ForbiddenLessThanRelation, ForbiddenEqualsRelation, ForbiddenGreaterThanRelation, + ForbiddenCallableRelation, ) @@ -254,7 +255,7 @@ def _build_less_than_condition(condition: LessThanCondition) -> Dict: ################################################################################ # Builder for forbidden -def _build_forbidden(clause) -> Dict: +def _build_forbidden(clause, pickle_callables=False) -> Dict: if isinstance(clause, ForbiddenEqualsClause): return _build_forbidden_equals_clause(clause) elif isinstance(clause, ForbiddenInClause): @@ -262,7 +263,7 @@ def _build_forbidden(clause) -> Dict: elif isinstance(clause, ForbiddenAndConjunction): return _build_forbidden_and_conjunction(clause) elif isinstance(clause, ForbiddenRelation): - return _build_forbidden_relation(clause) + return _build_forbidden_relation(clause, pickle_callables=pickle_callables) else: raise TypeError(clause) @@ -294,26 +295,38 @@ def _build_forbidden_and_conjunction(clause: ForbiddenAndConjunction) -> Dict: } -def _build_forbidden_relation(clause: ForbiddenRelation) -> Dict: +def _build_forbidden_relation(clause: ForbiddenRelation, pickle_callables=False) -> Dict: + d = { + 'left': clause.left.name, + 'right': clause.right.name, + 'type': 'RELATION' + } + if isinstance(clause, ForbiddenLessThanRelation): lambda_ = 'LESS' elif isinstance(clause, ForbiddenEqualsRelation): lambda_ = 'EQUALS' elif isinstance(clause, ForbiddenGreaterThanRelation): lambda_ = 'GREATER' + elif isinstance(clause, ForbiddenCallableRelation): + if pickle_callables: + lambda_ = 'CALLABLE' + from pickle import dumps + from base64 import b64encode + # pickle the callable, encode the bytes in b64, and convert it to an ASCII string + d['callable'] = b64encode(dumps(clause.f)).decode('ASCII') + else: + raise ValueError( + "Cannot serialize a ForbiddenCallableRelation if pickle_callables is False") else: raise ValueError("Unknown relation '%s'" % type(clause)) - return { - 'left': clause.left.name, - 'right': clause.right.name, - 'type': 'RELATION', - 'lambda': lambda_ - } + d['lambda'] = lambda_ + return d ################################################################################ -def write(configuration_space, indent=2): +def write(configuration_space, indent=2, pickle_callables=False): """ Create a string representation of a :class:`~ConfigSpace.configuration_space.ConfigurationSpace` in json format. @@ -335,6 +348,12 @@ def write(configuration_space, indent=2): a configuration space, which should be written to file. indent : int number of whitespaces to use as indent + pickle_callables : bool + whether the writer should attempt to pickle and encode ForbiddenCallableRelations + and similar objects in the configuration space. Defaults to False. + If False, the writer will raise an exception if it encounters a callable + based configuration space object. Unpicklability cannot be assured. Setting + this to True may compromise portability. Returns ------- @@ -386,7 +405,7 @@ def write(configuration_space, indent=2): conditions.append(_build_condition(condition)) for forbidden_clause in configuration_space.get_forbiddens(): - forbiddens.append(_build_forbidden(forbidden_clause)) + forbiddens.append(_build_forbidden(forbidden_clause, pickle_callables=pickle_callables)) rval = {} if configuration_space.name is not None: @@ -496,8 +515,8 @@ def _construct_hyperparameter(hyperparameter: Dict) -> Hyperparameter: return NormalIntegerHyperparameter( name=name, log=hyperparameter['log'], - lower=hyperparameter['lower'], - upper=hyperparameter['upper'], + mu=hyperparameter['mu'], + sigma=hyperparameter['sigma'], default_value=hyperparameter['default'], ) elif hp_type == 'categorical': @@ -625,7 +644,7 @@ def _construct_forbidden( elif forbidden_type == 'AND': return _construct_forbidden_and(clause, cs) elif forbidden_type == 'RELATION': - return _construct_forbidden_equals(clause, cs) + return _construct_forbidden_relation(clause, cs) else: return ValueError(forbidden_type) @@ -671,5 +690,10 @@ def _construct_forbidden_relation( return ForbiddenEqualsRelation(left, right) elif clause['lambda'] == "GREATER": return ForbiddenGreaterThanRelation(left, right) + elif clause['lambda'] == "CALLABLE": + from pickle import loads + from base64 import b64decode + f = loads(b64decode(clause["callable"].encode('ASCII'))) + return ForbiddenCallableRelation(left, right, f) else: raise ValueError("Unknown relation '%s'" % clause['lambda']) diff --git a/pyproject.toml b/pyproject.toml index efbe4d93..fd201e96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,2 @@ [build-system] -requires = ["setuptools", "wheel", "oldest-supported-numpy", "Cython"] +requires = ["setuptools", "wheel", "oldest-supported-numpy", "Cython ~= 0.29.32"] diff --git a/test/read_and_write/test_json.py b/test/read_and_write/test_json.py index d170880e..ef273302 100644 --- a/test/read_and_write/test_json.py +++ b/test/read_and_write/test_json.py @@ -1,7 +1,8 @@ import os import unittest +from dataclasses import dataclass -from ConfigSpace.forbidden import ForbiddenLessThanRelation +from ConfigSpace.forbidden import ForbiddenLessThanRelation, ForbiddenCallableRelation from ConfigSpace.read_and_write.json import read, write from ConfigSpace.read_and_write.pcs import read as read_pcs from ConfigSpace.read_and_write.pcs_new import read as read_pcs_new @@ -12,6 +13,14 @@ ) +@dataclass +class ProductGreaterThan: + limit: int + + def __call__(self, a, b): + return a*b > self.limit + + class TestJson(unittest.TestCase): def test_serialize_forbidden_in_clause(self): @@ -27,6 +36,10 @@ def test_serialize_forbidden_relation(self): cs.add_forbidden_clause(ForbiddenLessThanRelation(a, b)) write(cs) + cs.add_forbidden_clause(ForbiddenCallableRelation(a, b, ProductGreaterThan(3))) + self.assertRaises(ValueError, write, cs) + write(cs, pickle_callables=True) + def test_configspace_with_probabilities(self): cs = ConfigurationSpace() cs.add_hyperparameter( diff --git a/test/test_configuration_space.py b/test/test_configuration_space.py index 7c09e1d4..12aeb14f 100644 --- a/test/test_configuration_space.py +++ b/test/test_configuration_space.py @@ -46,7 +46,7 @@ BetaIntegerHyperparameter, OrdinalHyperparameter) from ConfigSpace.exceptions import ForbiddenValueError -from ConfigSpace.forbidden import ForbiddenEqualsRelation +from ConfigSpace.forbidden import ForbiddenEqualsRelation, ForbiddenLessThanRelation def byteify(input): @@ -919,6 +919,34 @@ def test_substitute_hyperparameters_in_conditions(self): self.assertEqual(new_conditions[0], test_conditions[0]) self.assertEqual(new_conditions[1], test_conditions[1]) + def test_substitute_hyperparameters_in_inconditions(self): + cs1 = ConfigurationSpace() + a = UniformIntegerHyperparameter('a', lower=0, upper=10) + b = UniformFloatHyperparameter('b', lower=1., upper=8., log=False) + cs1.add_hyperparameters([a, b]) + + cond = InCondition(b, a, [1, 2, 3, 4]) + cs1.add_conditions([cond]) + + cs2 = ConfigurationSpace() + sub_a = UniformIntegerHyperparameter('a', lower=0, upper=10) + sub_b = UniformFloatHyperparameter('b', lower=1., upper=8., log=False) + cs2.add_hyperparameters([sub_a, sub_b]) + new_conditions = cs1.substitute_hyperparameters_in_conditions(cs1.get_conditions(), cs2) + + test_cond = InCondition(b, a, [1, 2, 3, 4]) + cs2.add_conditions([test_cond]) + test_conditions = cs2.get_conditions() + + self.assertEqual(new_conditions[0], test_conditions[0]) + self.assertIsNot(new_conditions[0], test_conditions[0]) + + self.assertEqual(new_conditions[0].get_parents(), test_conditions[0].get_parents()) + self.assertIsNot(new_conditions[0].get_parents(), test_conditions[0].get_parents()) + + self.assertEqual(new_conditions[0].get_children(), test_conditions[0].get_children()) + self.assertIsNot(new_conditions[0].get_children(), test_conditions[0].get_children()) + def test_substitute_hyperparameters_in_forbiddens(self): cs1 = ConfigurationSpace() orig_hp1 = CategoricalHyperparameter("input1", [0, 1]) @@ -930,7 +958,8 @@ def test_substitute_hyperparameters_in_forbiddens(self): forb_2 = ForbiddenEqualsClause(orig_hp2, 1) forb_3 = ForbiddenEqualsClause(orig_hp3, 10) forb_4 = ForbiddenAndConjunction(forb_1, forb_2) - cs1.add_forbidden_clauses([forb_3, forb_4]) + forb_5 = ForbiddenLessThanRelation(orig_hp1, orig_hp2) + cs1.add_forbidden_clauses([forb_3, forb_4, forb_5]) cs2 = ConfigurationSpace() sub_hp1 = CategoricalHyperparameter("input1", [0, 1, 2]) @@ -944,9 +973,11 @@ def test_substitute_hyperparameters_in_forbiddens(self): test_forb_2 = ForbiddenEqualsClause(sub_hp2, 1) test_forb_3 = ForbiddenEqualsClause(sub_hp3, 10) test_forb_4 = ForbiddenAndConjunction(test_forb_1, test_forb_2) - cs2.add_forbidden_clauses([test_forb_3, test_forb_4]) + test_forb_5 = ForbiddenLessThanRelation(sub_hp1, sub_hp2) + cs2.add_forbidden_clauses([test_forb_3, test_forb_4, test_forb_5]) test_forbiddens = cs2.get_forbiddens() + self.assertEqual(new_forbiddens[2], test_forbiddens[2]) self.assertEqual(new_forbiddens[1], test_forbiddens[1]) self.assertEqual(new_forbiddens[0], test_forbiddens[0]) diff --git a/test/test_forbidden.py b/test/test_forbidden.py index 826fc646..f01c4d95 100644 --- a/test/test_forbidden.py +++ b/test/test_forbidden.py @@ -43,6 +43,7 @@ ForbiddenEqualsRelation, ForbiddenLessThanRelation, ForbiddenGreaterThanRelation, + ForbiddenCallableRelation, ) from ConfigSpace import OrdinalHyperparameter @@ -297,3 +298,9 @@ def test_relation(self): {'water_temperature': 'hot', 'water_temperature2': 'cold'}, True) ) + + forb = ForbiddenCallableRelation(hp1, hp2, lambda x, y: len(x) <= len(y)) + self.assertFalse(forb.is_forbidden( + {'water_temperature': 'boiling', 'water_temperature2': 'cold'}, + True) + ) diff --git a/test/test_hyperparameters.py b/test/test_hyperparameters.py index ca8186f9..b259a71a 100644 --- a/test/test_hyperparameters.py +++ b/test/test_hyperparameters.py @@ -1193,8 +1193,9 @@ def test_normalint(self): f1 = NormalIntegerHyperparameter("param", 0.5, 5.5) f1_ = NormalIntegerHyperparameter("param", 0.5, 5.5) self.assertEqual(f1, f1_) + default = np.int32(np.round(0.5)) self.assertEqual( - "param, Type: NormalInteger, Mu: 0.5 Sigma: 5.5, Default: 0.5", + f"param, Type: NormalInteger, Mu: 0.5 Sigma: 5.5, Default: {default}", str(f1)) # Test attributes are accessible @@ -1203,8 +1204,8 @@ def test_normalint(self): self.assertEqual(f1.sigma, 5.5) self.assertEqual(f1.q, None) self.assertEqual(f1.log, False) - self.assertAlmostEqual(f1.default_value, 0.5) - self.assertAlmostEqual(f1.normalized_default_value, 0.5) + self.assertAlmostEqual(f1.default_value, default) + self.assertAlmostEqual(f1.normalized_default_value, 0.0) with pytest.warns(UserWarning, match="Setting quantization < 1 for Integer " "Hyperparameter 'param' has no effect"): @@ -1945,6 +1946,7 @@ def test_categorical__pdf(self): point_1 = np.array([0]) point_2 = np.array([1]) array_1 = np.array([1, 0, 2]) + nan = np.array([0, np.nan]) self.assertEqual(c1._pdf(point_1)[0], 0.4) self.assertEqual(c1._pdf(point_2)[0], 0.2) self.assertAlmostEqual(c2._pdf(point_1)[0], 0.7142857142857143) @@ -1956,6 +1958,12 @@ def test_categorical__pdf(self): for res, exp_res in zip(array_results, expected_results): self.assertEqual(res, exp_res) + nan_results = c1._pdf(nan) + expected_results = np.array([0.4, 0]) + self.assertEqual(nan_results.shape, expected_results.shape) + for res, exp_res in zip(nan_results, expected_results): + self.assertEqual(res, exp_res) + # pdf must take a numpy array with self.assertRaises(TypeError): c1._pdf(0.2) @@ -1963,7 +1971,7 @@ def test_categorical__pdf(self): c1._pdf('pdf') with self.assertRaises(TypeError): c1._pdf('one') - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): c1._pdf(np.array(['zero'])) def test_categorical_get_max_density(self):