Source code for ConfigSpace.configuration_space

# Copyright (c) 2014-2016, ConfigSpace developers
# Matthias Feurer
# Katharina Eggensperger
# and others (see commit history).
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#     * Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above copyright
#       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 <organization> nor the
#       names of its 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
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from __future__ import annotations

import contextlib
import copy
import io
import warnings
from collections import OrderedDict, defaultdict, deque
from itertools import chain
from typing import Any, Iterable, Iterator, KeysView, Mapping, cast, overload
from typing_extensions import Final

import numpy as np

import ConfigSpace.c_util
from ConfigSpace import nx
from ConfigSpace.conditions import (
    AbstractCondition,
    AbstractConjunction,
    ConditionComponent,
    EqualsCondition,
)
from ConfigSpace.configuration import Configuration
from ConfigSpace.exceptions import (
    ActiveHyperparameterNotSetError,
    AmbiguousConditionError,
    ChildNotFoundError,
    CyclicDependancyError,
    ForbiddenValueError,
    HyperparameterAlreadyExistsError,
    HyperparameterIndexError,
    HyperparameterNotFoundError,
    IllegalValueError,
    InactiveHyperparameterSetError,
    ParentNotFoundError,
)
from ConfigSpace.forbidden import (
    AbstractForbiddenClause,
    AbstractForbiddenComponent,
    AbstractForbiddenConjunction,
    ForbiddenRelation,
)
from ConfigSpace.hyperparameters import (
    CategoricalHyperparameter,
    Constant,
    Hyperparameter,
    UniformFloatHyperparameter,
    UniformIntegerHyperparameter,
)

_ROOT: Final = "__HPOlib_configuration_space_root__"


def _parse_hyperparameters_from_dict(items: dict[str, Any]) -> Iterator[Hyperparameter]:
    for name, hp in items.items():
        # Anything that is a Hyperparameter already is good
        # Note that we discard the key name in this case in favour
        # of the name given in the dictionary
        if isinstance(hp, Hyperparameter):
            yield hp

        # Tuples are bounds, check if float or int
        elif isinstance(hp, tuple):
            if len(hp) != 2:
                raise ValueError(f"'{name}' must be (lower, upper) bound, got {hp}")

            lower, upper = hp
            if isinstance(lower, float):
                yield UniformFloatHyperparameter(name, lower, upper)
            else:
                yield UniformIntegerHyperparameter(name, lower, upper)

        # Lists are categoricals
        elif isinstance(hp, list):
            if len(hp) == 0:
                raise ValueError(f"Can't have empty list for categorical {name}")

            yield CategoricalHyperparameter(name, hp)

        # If it's an allowed type, it's a constant
        elif isinstance(hp, (int, str, float)):
            yield Constant(name, hp)

        else:
            raise ValueError(f"Unknown value '{hp}' for '{name}'")


def _assert_type(item: Any, expected: type, method: str | None = None) -> None:
    if not isinstance(item, expected):
        msg = f"Expected {expected}, got {type(item)}"
        if method:
            msg += " in method " + method
        raise TypeError(msg)


def _assert_legal(hyperparameter: Hyperparameter, value: tuple | list | Any) -> None:
    if isinstance(value, (tuple, list)):
        for v in value:
            if not hyperparameter.is_legal(v):
                raise IllegalValueError(hyperparameter, v)
    elif not hyperparameter.is_legal(value):
        raise IllegalValueError(hyperparameter, value)


[docs]class ConfigurationSpace(Mapping[str, Hyperparameter]): """A collection-like object containing a set of hyperparameter definitions and conditions. A configuration space organizes all hyperparameters and its conditions as well as its forbidden clauses. Configurations can be sampled from this configuration space. As underlying data structure, the configuration space uses a tree-based approach to represent the conditions and restrictions between hyperparameters. """ def __init__( self, name: str | dict | None = None, seed: int | None = None, meta: dict | None = None, *, space: None | ( dict[ str, tuple[int, int] | tuple[float, float] | list[Any] | int | float | str, ] ) = None, ) -> None: """ Parameters ---------- name : str | dict, optional Name of the configuration space. If a dict is passed, this is considered the same as the `space` arg. seed : int, optional Random seed meta : dict, optional Field for holding meta data provided by the user. Not used by the configuration space. space: A simple configuration space to use: .. code:: python ConfigurationSpace( name="myspace", space={ "uniform_integer": (1, 10), "uniform_float": (1.0, 10.0), "categorical": ["a", "b", "c"], "constant": 1337, } ) """ # If first arg is a dict, we assume this to be `space` if isinstance(name, dict): space = name name = None self.name = name self.meta = meta # NOTE: The idx of a hyperparamter is tied to its order in _hyperparamters # Having three variables to keep track of this seems excessive self._hyperparameters: OrderedDict[str, Hyperparameter] = OrderedDict() self._hyperparameter_idx: dict[str, int] = {} self._idx_to_hyperparameter: dict[int, str] = {} # Use dictionaries to make sure that we don't accidently add # additional keys to these mappings (which happened with defaultdict()). # This once broke auto-sklearn's equal comparison of configuration # spaces when _children of one instance contained all possible # hyperparameters as keys and empty dictionaries as values while the # other instance not containing these. self._children: OrderedDict[str, OrderedDict[str, None | AbstractCondition]] self._children = OrderedDict() self._parents: OrderedDict[str, OrderedDict[str, None | AbstractCondition]] self._parents = OrderedDict() # 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[str] = set() self.forbidden_clauses: list[AbstractForbiddenComponent] = [] self.random = np.random.RandomState(seed) self._children[_ROOT] = OrderedDict() self._parent_conditions_of: dict[str, list[AbstractCondition]] = {} self._child_conditions_of: dict[str, list[AbstractCondition]] = {} self._parents_of: dict[str, list[Hyperparameter]] = {} self._children_of: dict[str, list[Hyperparameter]] = {} if space is not None: hyperparameters = list(_parse_hyperparameters_from_dict(space)) self.add_hyperparameters(hyperparameters)
[docs] def add_hyperparameter(self, hyperparameter: Hyperparameter) -> Hyperparameter: """Add a hyperparameter to the configuration space. Parameters ---------- hyperparameter : :ref:`Hyperparameters` The hyperparameter to add Returns ------- :ref:`Hyperparameters` The added hyperparameter """ _assert_type(hyperparameter, Hyperparameter, method="add_hyperparameter") self._add_hyperparameter(hyperparameter) self._update_cache() self._check_default_configuration() self._sort_hyperparameters() return hyperparameter
[docs] def add_hyperparameters( self, hyperparameters: Iterable[Hyperparameter], ) -> list[Hyperparameter]: """Add hyperparameters to the configuration space. Parameters ---------- hyperparameters : Iterable(:ref:`Hyperparameters`) Collection of hyperparameters to add Returns ------- list(:ref:`Hyperparameters`) List of added hyperparameters (same as input) """ hyperparameters = list(hyperparameters) for hp in hyperparameters: _assert_type(hp, Hyperparameter, method="add_hyperparameters") for hyperparameter in hyperparameters: self._add_hyperparameter(hyperparameter) self._update_cache() self._check_default_configuration() self._sort_hyperparameters() return hyperparameters
[docs] def add_condition(self, condition: ConditionComponent) -> ConditionComponent: """Add a condition to the configuration space. Check if adding the condition is legal: - The parent in a condition statement must exist - The condition must add no cycles The internal array keeps track of all edges which must be added to the DiGraph; if the checks don't raise any Exception, these edges are finally added at the end of the function. Parameters ---------- condition : :ref:`Conditions` Condition to add Returns ------- :ref:`Conditions` Same condition as input """ _assert_type(condition, ConditionComponent, method="add_condition") if isinstance(condition, AbstractCondition): self._check_edges([(condition.parent, condition.child)], [condition.value]) self._check_condition(condition.child, condition) self._add_edge(condition.parent, condition.child, condition=condition) # Loop over the Conjunctions to find out the conditions we must add! elif isinstance(condition, AbstractConjunction): dlcs = condition.get_descendant_literal_conditions() edges = [(dlc.parent, dlc.child) for dlc in dlcs] values = [dlc.value for dlc in dlcs] self._check_edges(edges, values) for dlc in dlcs: self._check_condition(dlc.child, condition) self._add_edge(dlc.parent, dlc.child, condition=condition) else: raise Exception("This should never happen!") self._sort_hyperparameters() self._update_cache() return condition
[docs] def add_conditions( self, conditions: list[ConditionComponent], ) -> list[ConditionComponent]: """Add a list of conditions to the configuration space. They must be legal. Take a look at :meth:`~ConfigSpace.configuration_space.ConfigurationSpace.add_condition`. Parameters ---------- conditions : list(:ref:`Conditions`) collection of conditions to add Returns ------- list(:ref:`Conditions`) Same as input conditions """ for condition in conditions: _assert_type(condition, ConditionComponent, method="add_conditions") edges = [] values = [] conditions_to_add = [] for condition in conditions: if isinstance(condition, AbstractCondition): edges.append((condition.parent, condition.child)) values.append(condition.value) conditions_to_add.append(condition) elif isinstance(condition, AbstractConjunction): dlcs = condition.get_descendant_literal_conditions() edges.extend([(dlc.parent, dlc.child) for dlc in dlcs]) values.extend([dlc.value for dlc in dlcs]) conditions_to_add.extend([condition] * len(dlcs)) for edge, condition in zip(edges, conditions_to_add): self._check_condition(edge[1], condition) self._check_edges(edges, values) for edge, condition in zip(edges, conditions_to_add): self._add_edge(edge[0], edge[1], condition) self._sort_hyperparameters() self._update_cache() return conditions
[docs] def add_forbidden_clause( self, clause: AbstractForbiddenComponent, ) -> AbstractForbiddenComponent: """ Add a forbidden clause to the configuration space. Parameters ---------- clause : :ref:`Forbidden clauses` Forbidden clause to add Returns ------- :ref:`Forbidden clauses` Same as input forbidden clause """ self._check_forbidden_component(clause=clause) clause.set_vector_idx(self._hyperparameter_idx) self.forbidden_clauses.append(clause) self._check_default_configuration() return clause
[docs] def add_forbidden_clauses( self, clauses: list[AbstractForbiddenComponent], ) -> list[AbstractForbiddenComponent]: """ Add a list of forbidden clauses to the configuration space. Parameters ---------- clauses : list(:ref:`Forbidden clauses`) Collection of forbidden clauses to add Returns ------- list(:ref:`Forbidden clauses`) Same as input clauses """ for clause in clauses: self._check_forbidden_component(clause=clause) clause.set_vector_idx(self._hyperparameter_idx) self.forbidden_clauses.append(clause) self._check_default_configuration() return clauses
[docs] def add_configuration_space( self, prefix: str, configuration_space: ConfigurationSpace, delimiter: str = ":", parent_hyperparameter: dict | None = None, ) -> ConfigurationSpace: """ Combine two configuration space by adding one the other configuration space. The contents of the configuration space, which should be added, are renamed to ``prefix`` + ``delimiter`` + old_name. Parameters ---------- prefix : str The prefix for the renamed hyperparameter | conditions | forbidden clauses configuration_space : :class:`~ConfigSpace.configuration_space.ConfigurationSpace` The configuration space which should be added delimiter : str, optional Defaults to ':' parent_hyperparameter : dict | None = None Adds for each new hyperparameter the condition, that ``parent_hyperparameter`` is active. Must be a dictionary with two keys "parent" and "value", meaning that the added configuration space is active when `parent` is equal to `value` Returns ------- :class:`~ConfigSpace.configuration_space.ConfigurationSpace` The configuration space, which was added """ _assert_type(configuration_space, ConfigurationSpace, method="add_configuration_space") prefix_delim = f"{prefix}{delimiter}" def _new_name(_item: Hyperparameter) -> str: if _item.name in ("", prefix): return prefix if not _item.name.startswith(prefix_delim): return f"{prefix_delim}{_item.name}" return cast(str, _item.name) new_parameters = [] for hp in configuration_space.values(): new_hp = copy.copy(hp) new_hp.name = _new_name(hp) new_parameters.append(new_hp) self.add_hyperparameters(new_parameters) conditions_to_add = [] for condition in configuration_space.get_conditions(): new_condition = copy.copy(condition) for dlc in new_condition.get_descendant_literal_conditions(): # Rename children dlc.child.name = _new_name(dlc.child) dlc.parent.name = _new_name(dlc.parent) conditions_to_add.append(new_condition) self.add_conditions(conditions_to_add) forbiddens_to_add = [] for forbidden_clause in configuration_space.forbidden_clauses: new_forbidden = forbidden_clause for dlc in new_forbidden.get_descendant_literal_clauses(): if isinstance(dlc, ForbiddenRelation): dlc.left.name = _new_name(dlc.left) dlc.right.name = _new_name(dlc.right) else: dlc.hyperparameter.name = _new_name(dlc.hyperparameter) forbiddens_to_add.append(new_forbidden) self.add_forbidden_clauses(forbiddens_to_add) conditions_to_add = [] if parent_hyperparameter is not None: parent = parent_hyperparameter["parent"] value = parent_hyperparameter["value"] # Only add a condition if the parameter is a top-level parameter of the new # configuration space (this will be some kind of tree structure). for new_hp in new_parameters: parents = self.get_parents_of(new_hp) if not any(parents): condition = EqualsCondition(new_hp, parent, value) conditions_to_add.append(condition) self.add_conditions(conditions_to_add) return configuration_space
[docs] def get_hyperparameter_by_idx(self, idx: int) -> str: """Name of a hyperparameter from the space given its id. Parameters ---------- idx : int Id of a hyperparameter Returns ------- str Name of the hyperparameter """ hp = self._idx_to_hyperparameter.get(idx) if hp is None: raise HyperparameterIndexError(idx, self) return hp
[docs] def get_idx_by_hyperparameter_name(self, name: str) -> int: """The id of a hyperparameter by its ``name``. Parameters ---------- name : str Name of a hyperparameter Returns ------- int Id of the hyperparameter with name ``name`` """ idx = self._hyperparameter_idx.get(name) if idx is None: raise HyperparameterNotFoundError(name, space=self) return idx
[docs] def get_conditions(self) -> list[AbstractCondition]: """All conditions from the configuration space. Returns ------- list(:ref:`Conditions`) Conditions of the configuration space """ conditions = [] added_conditions: set[str] = set() # Nodes is a list of nodes for source_node in self._hyperparameters.values(): # This is a list of keys in a dictionary # TODO sort the edges by the order of their source_node in the # hyperparameter list! for target_node in self._children[source_node.name]: if target_node not in added_conditions: condition = self._children[source_node.name][target_node] conditions.append(condition) added_conditions.add(target_node) return conditions
[docs] def get_forbiddens(self) -> list[AbstractForbiddenComponent]: """All forbidden clauses from the configuration space. Returns ------- list(:ref:`Forbidden clauses`) List with the forbidden clauses """ return self.forbidden_clauses
[docs] def get_children_of(self, name: str | Hyperparameter) -> list[Hyperparameter]: """ Return a list with all children of a given hyperparameter. Parameters ---------- name : str, :ref:`Hyperparameters` Hyperparameter or its name, for which all children are requested Returns ------- list(:ref:`Hyperparameters`) Children of the hyperparameter """ conditions = self.get_child_conditions_of(name) parents: list[Hyperparameter] = [] for condition in conditions: parents.extend(condition.get_children()) return parents
[docs] def generate_all_continuous_from_bounds( self, bounds: list[tuple[float, float]], ) -> None: """Generate :class:`~ConfigSpace.hyperparameters.UniformFloatHyperparameter` from a list containing lists with lower and upper bounds. The generated hyperparameters are added to the configuration space. Parameters ---------- bounds : list[tuple([float, float])] List containing lists with two elements: lower and upper bound """ self.add_hyperparameters( [ UniformFloatHyperparameter(name=f"x{i}", lower=lower, upper=upper) for i, (lower, upper) in enumerate(bounds) ], )
[docs] def get_child_conditions_of( self, name: str | Hyperparameter, ) -> list[AbstractCondition]: """ Return a list with conditions of all children of a given hyperparameter referenced by its ``name``. Parameters ---------- name : str, :ref:`Hyperparameters` Hyperparameter or its name, for which conditions are requested Returns ------- list(:ref:`Conditions`) List with the conditions on the children of the given hyperparameter """ name = name if isinstance(name, str) else name.name # This raises an exception if the hyperparameter does not exist self[name] return self._get_child_conditions_of(name)
[docs] def get_parents_of(self, name: str | Hyperparameter) -> list[Hyperparameter]: """The parents hyperparameters of a given hyperparameter. Parameters ---------- name : str, :ref:`Hyperparameters` Can either be the name of a hyperparameter or the hyperparameter object. Returns ------- list[:ref:`Conditions`] List with all parent hyperparameters """ conditions = self.get_parent_conditions_of(name) parents: list[Hyperparameter] = [] for condition in conditions: parents.extend(condition.get_parents()) return parents
[docs] def get_parent_conditions_of( self, name: str | Hyperparameter, ) -> list[AbstractCondition]: """The conditions of all parents of a given hyperparameter. Parameters ---------- name : str, :ref:`Hyperparameters` Can either be the name of a hyperparameter or the hyperparameter object Returns ------- list[:ref:`Conditions`] List with all conditions on parent hyperparameters """ if isinstance(name, Hyperparameter): name = name.name # type: ignore # This raises an exception if the hyperparameter does not exist self[name] return self._get_parent_conditions_of(name)
[docs] def get_all_unconditional_hyperparameters(self) -> list[str]: """Names of unconditional hyperparameters. Returns ------- list[:ref:`Hyperparameters`] List with all parent hyperparameters, which are not part of a condition """ return list(self._children[_ROOT])
[docs] def get_all_conditional_hyperparameters(self) -> set[str]: """Names of all conditional hyperparameters. Returns ------- set[:ref:`Hyperparameters`] Set with all conditional hyperparameter """ return self._conditionals
[docs] def get_default_configuration(self) -> Configuration: """Configuration containing hyperparameters with default values. Returns ------- :class:`~ConfigSpace.configuration_space.Configuration` Configuration with the set default values """ return self._check_default_configuration()
# For backward compatibility
[docs] def check_configuration(self, configuration: Configuration) -> None: """ Check if a configuration is legal. Raises an error if not. Parameters ---------- configuration : :class:`~ConfigSpace.configuration_space.Configuration` Configuration to check """ _assert_type(configuration, Configuration, method="check_configuration") ConfigSpace.c_util.check_configuration(self, configuration.get_array(), False)
[docs] def check_configuration_vector_representation(self, vector: np.ndarray) -> None: """ Raise error if configuration in vector representation is not legal. Parameters ---------- vector : np.ndarray Configuration in vector representation """ _assert_type(vector, np.ndarray, method="check_configuration_vector_representation") ConfigSpace.c_util.check_configuration(self, vector, False)
[docs] def get_active_hyperparameters( self, configuration: Configuration, ) -> set[Hyperparameter]: """Set of active hyperparameter for a given configuration. Parameters ---------- configuration : :class:`~ConfigSpace.configuration_space.Configuration` Configuration for which the active hyperparameter are returned Returns ------- set(:class:`~ConfigSpace.configuration_space.Configuration`) The set of all active hyperparameter """ vector = configuration.get_array() active_hyperparameters = set() for hp_name, hyperparameter in self._hyperparameters.items(): conditions = self._parent_conditions_of[hyperparameter.name] active = True for condition in conditions: parent_vector_idx = condition.get_parents_vector() # if one of the parents is None, the hyperparameter cannot be # active! Else we have to check this # Note from trying to optimize this - this is faster than using # dedicated numpy functions and indexing if any(vector[i] != vector[i] for i in parent_vector_idx): active = False break if not condition.evaluate_vector(vector): active = False break if active: active_hyperparameters.add(hp_name) return active_hyperparameters
@overload def sample_configuration(self, size: None = None) -> Configuration: ... # Technically this is wrong given the current behaviour but it's # sufficient for most cases. Once deprecation warning is up, # we can just have `1` always return a list of configurations # because an `int` was specified, `None` for single config. @overload def sample_configuration(self, size: int) -> list[Configuration]: ...
[docs] def sample_configuration( self, size: int | None = None, ) -> Configuration | list[Configuration]: """ Sample ``size`` configurations from the configuration space object. Parameters ---------- size : int, optional Number of configurations to sample. Default to 1 Returns ------- :class:`~ConfigSpace.configuration_space.Configuration`, list[:class:`~ConfigSpace.configuration_space.Configuration`]: A single configuration if ``size`` 1 else a list of Configurations """ if size == 1: warnings.warn( "Please leave at default or explicitly set `size=None`." " In the future, specifying a size will always retunr a list, even if 1", DeprecationWarning, stacklevel=2, ) # Maintain old behaviour by setting this if size is None: size = 1 _assert_type(size, int, method="sample_configuration") if size < 1: return [] iteration = 0 missing = size accepted_configurations: list[Configuration] = [] num_hyperparameters = len(self._hyperparameters) unconditional_hyperparameters = self.get_all_unconditional_hyperparameters() hyperparameters_with_children = [] _forbidden_clauses_unconditionals = [] _forbidden_clauses_conditionals = [] for clause in self.get_forbiddens(): based_on_conditionals = False for subclause in clause.get_descendant_literal_clauses(): if isinstance(subclause, ForbiddenRelation): if ( subclause.left.name not in unconditional_hyperparameters or subclause.right.name not in unconditional_hyperparameters ): based_on_conditionals = True break elif subclause.hyperparameter.name not in unconditional_hyperparameters: based_on_conditionals = True break if based_on_conditionals: _forbidden_clauses_conditionals.append(clause) else: _forbidden_clauses_unconditionals.append(clause) for uhp in unconditional_hyperparameters: children = self._children_of[uhp] if len(children) > 0: hyperparameters_with_children.append(uhp) while len(accepted_configurations) < size: if missing != size: missing = int(1.1 * missing) vector: np.ndarray = np.ndarray((missing, num_hyperparameters), dtype=float) for i, hp_name in enumerate(self._hyperparameters): hyperparameter = self._hyperparameters[hp_name] vector[:, i] = hyperparameter._sample(self.random, missing) for i in range(missing): try: configuration = Configuration( self, vector=ConfigSpace.c_util.correct_sampled_array( vector[i].copy(), _forbidden_clauses_unconditionals, _forbidden_clauses_conditionals, hyperparameters_with_children, num_hyperparameters, unconditional_hyperparameters, self._hyperparameter_idx, self._parent_conditions_of, self._parents_of, self._children_of, ), ) accepted_configurations.append(configuration) except ForbiddenValueError: iteration += 1 if iteration == size * 100: msg = (f"Cannot sample valid configuration for {self}",) raise ForbiddenValueError(msg) from None missing = size - len(accepted_configurations) if size <= 1: return accepted_configurations[0] return accepted_configurations
[docs] def seed(self, seed: int) -> None: """Set the random seed to a number. Parameters ---------- seed : int The random seed """ self.random = np.random.RandomState(seed)
[docs] def remove_hyperparameter_priors(self) -> ConfigurationSpace: """Produces a new ConfigurationSpace where all priors on parameters are removed. Non-uniform hyperpararmeters are replaced with uniform ones, and CategoricalHyperparameters with weights have their weights removed. Returns ------- :class:`~ConfigSpace.configuration_space.ConfigurationSpace` The resulting configuration space, without priors on the hyperparameters """ uniform_config_space = ConfigurationSpace() for parameter in self.values(): if hasattr(parameter, "to_uniform"): uniform_config_space.add_hyperparameter(parameter.to_uniform()) else: uniform_config_space.add_hyperparameter(copy.copy(parameter)) new_conditions = self.substitute_hyperparameters_in_conditions( self.get_conditions(), uniform_config_space, ) new_forbiddens = self.substitute_hyperparameters_in_forbiddens( self.get_forbiddens(), uniform_config_space, ) uniform_config_space.add_conditions(new_conditions) uniform_config_space.add_forbidden_clauses(new_forbiddens) return uniform_config_space
[docs] def estimate_size(self) -> float | int: """Estimate the size of the current configuration space (i.e. unique configurations). This is ``np.inf`` in case if there is a single hyperparameter of size ``np.inf`` (i.e. a :class:`~ConfigSpace.hyperparameters.UniformFloatHyperparameter`), otherwise it is the product of the size of all hyperparameters. The function correctly guesses the number of unique configurations if there are no condition and forbidden statements in the configuration spaces. Otherwise, this is an upper bound. Use :func:`~ConfigSpace.util.generate_grid` to generate all valid configurations if required. Returns ------- Union[float, int] """ sizes = [hp.get_size() for hp in self._hyperparameters.values()] if len(sizes) == 0: return 0.0 acc = 1 for size in sizes: acc *= size return acc
[docs] @staticmethod def substitute_hyperparameters_in_conditions( conditions: Iterable[ConditionComponent], new_configspace: ConfigurationSpace, ) -> list[ConditionComponent]: """ Takes a set of conditions and generates a new set of conditions with the same structure, where each hyperparameter is replaced with its namesake in new_configspace. As such, the set of conditions remain unchanged, but the included hyperparameters are changed to match those types that exist in new_configspace. Parameters ---------- new_configspace: ConfigurationSpace A ConfigurationSpace containing hyperparameters with the same names as those in the conditions. Returns ------- list[ConditionComponent]: The list of conditions, adjusted to fit the new ConfigurationSpace """ new_conditions = [] for condition in conditions: if isinstance(condition, AbstractConjunction): conjunction_type = type(condition) children = condition.get_descendant_literal_conditions() substituted_children = ConfigurationSpace.substitute_hyperparameters_in_conditions( children, new_configspace, ) substituted_conjunction = conjunction_type(*substituted_children) new_conditions.append(substituted_conjunction) elif isinstance(condition, AbstractCondition): condition_type = type(condition) child_name = condition.get_children()[0].name parent_name = condition.get_parents()[0].name new_child = new_configspace[child_name] new_parent = new_configspace[parent_name] if hasattr(condition, "values"): condition_arg = condition.values substituted_condition = condition_type( child=new_child, parent=new_parent, values=condition_arg, ) elif hasattr(condition, "value"): condition_arg = 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)}.", ) new_conditions.append(substituted_condition) else: raise TypeError(f"Did not expect the supplied condition type {type(condition)}.") return new_conditions
[docs] @staticmethod def substitute_hyperparameters_in_forbiddens( forbiddens: Iterable[AbstractForbiddenComponent], new_configspace: ConfigurationSpace, ) -> list[AbstractForbiddenComponent]: """ Takes a set of forbidden clauses and generates a new set of forbidden clauses with the same structure, where each hyperparameter is replaced with its namesake in new_configspace. As such, the set of forbidden clauses remain unchanged, but the included hyperparameters are changed to match those types that exist in new_configspace. Parameters ---------- forbiddens: Iterable[AbstractForbiddenComponent] An iterable of forbiddens new_configspace: ConfigurationSpace A ConfigurationSpace containing hyperparameters with the same names as those in the forbidden clauses. Returns ------- list[AbstractForbiddenComponent]: The list of forbidden clauses, adjusted to fit the new ConfigurationSpace """ new_forbiddens = [] for forbidden in forbiddens: if isinstance(forbidden, AbstractForbiddenConjunction): conjunction_type = type(forbidden) children = forbidden.get_descendant_literal_clauses() substituted_children = ConfigurationSpace.substitute_hyperparameters_in_forbiddens( children, new_configspace, ) substituted_conjunction = conjunction_type(*substituted_children) new_forbiddens.append(substituted_conjunction) elif isinstance(forbidden, AbstractForbiddenClause): forbidden_type = type(forbidden) hyperparameter_name = forbidden.hyperparameter.name new_hyperparameter = new_configspace[hyperparameter_name] if hasattr(forbidden, "values"): forbidden_arg = forbidden.values substituted_forbidden = forbidden_type( hyperparameter=new_hyperparameter, values=forbidden_arg, ) elif hasattr(forbidden, "value"): forbidden_arg = 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 = forbidden.left.name left_hyperparameter = new_configspace[left_name] right_name = 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 type {type(forbidden)}.") return new_forbiddens
[docs] def __eq__(self, other: Any) -> bool: """Override the default Equals behavior.""" if isinstance(other, self.__class__): this_dict = self.__dict__.copy() del this_dict["random"] other_dict = other.__dict__.copy() del other_dict["random"] return this_dict == other_dict return NotImplemented
[docs] def __hash__(self) -> int: """Override the default hash behavior (that returns the id or the object).""" return hash(self.__repr__())
def __getitem__(self, key: str) -> Hyperparameter: hp = self._hyperparameters.get(key) if hp is None: raise HyperparameterNotFoundError(key, space=self) return hp def __contains__(self, key: str) -> bool: return key in self._hyperparameters def __repr__(self) -> str: retval = io.StringIO() retval.write("Configuration space object:\n Hyperparameters:\n") if self.name is not None: retval.write(self.name) retval.write("\n") hyperparameters = sorted(self.values(), key=lambda t: t.name) # type: ignore if hyperparameters: retval.write(" ") retval.write("\n ".join([str(hyperparameter) for hyperparameter in hyperparameters])) retval.write("\n") conditions = sorted(self.get_conditions(), key=lambda t: str(t)) if conditions: retval.write(" Conditions:\n") retval.write(" ") retval.write("\n ".join([str(condition) for condition in conditions])) retval.write("\n") if self.get_forbiddens(): retval.write(" Forbidden Clauses:\n") retval.write(" ") retval.write("\n ".join([str(clause) for clause in self.get_forbiddens()])) retval.write("\n") retval.seek(0) return retval.getvalue()
[docs] def __iter__(self) -> Iterator[str]: """Iterate over the hyperparameter names in the right order.""" return iter(self._hyperparameters.keys())
[docs] def keys(self) -> KeysView[str]: """Return the hyperparameter names in the right order.""" return self._hyperparameters.keys()
def __len__(self) -> int: return len(self._hyperparameters) def _add_hyperparameter(self, hyperparameter: Hyperparameter) -> None: hp_name = hyperparameter.name existing = self._hyperparameters.get(hp_name) if existing is not None: raise HyperparameterAlreadyExistsError(existing, hyperparameter, space=self) self._hyperparameters[hp_name] = hyperparameter self._children[hp_name] = OrderedDict() # TODO remove (_ROOT) __HPOlib_configuration_space_root__, it is only used in # to check for cyclic configuration spaces. If it is only added when # cycles are checked, the code can become much easier (e.g. the parent # caching can be more or less removed). self._children[_ROOT][hp_name] = None self._parents[hp_name] = OrderedDict() self._parents[hp_name][_ROOT] = None # Save the index of each hyperparameter name to later on access a # vector of hyperparameter values by indices, must be done twice # because check_default_configuration depends on it self._hyperparameter_idx.update({hp: i for i, hp in enumerate(self._hyperparameters)}) def _sort_hyperparameters(self) -> None: levels: OrderedDict[str, int] = OrderedDict() to_visit: deque[str] = deque() for hp_name in self._hyperparameters: to_visit.appendleft(hp_name) while len(to_visit) > 0: current = to_visit.pop() if _ROOT in self._parents[current]: assert len(self._parents[current]) == 1 levels[current] = 1 else: all_parents_visited = True depth = -1 for parent in self._parents[current]: if parent not in levels: all_parents_visited = False break depth = max(depth, levels[parent] + 1) if all_parents_visited: levels[current] = depth else: to_visit.appendleft(current) by_level: defaultdict[int, list[str]] = defaultdict(list) for hp in levels: level = levels[hp] by_level[level].append(hp) nodes = [] # Sort and add to list for level in sorted(by_level): sorted_by_level = by_level[level] sorted_by_level.sort() nodes.extend(sorted_by_level) # Resort the OrderedDict new_order = OrderedDict() for node in nodes: new_order[node] = self._hyperparameters[node] self._hyperparameters = new_order # Update to reflect sorting for i, hp in enumerate(self._hyperparameters): self._hyperparameter_idx[hp] = i self._idx_to_hyperparameter[i] = hp # Update order of _children new_order = OrderedDict() new_order[_ROOT] = self._children[_ROOT] for hp in chain([_ROOT], self._hyperparameters): # Also resort the children dict children_sorting = [ (self._hyperparameter_idx[child_name], child_name) for child_name in self._children[hp] ] children_sorting.sort() children_order = OrderedDict() for _, child_name in children_sorting: children_order[child_name] = self._children[hp][child_name] new_order[hp] = children_order self._children = new_order # Update order of _parents new_order = OrderedDict() for hp in self._hyperparameters: # Also resort the parent's dict if _ROOT in self._parents[hp]: parent_sorting = [(-1, _ROOT)] else: parent_sorting = [ (self._hyperparameter_idx[parent_name], parent_name) for parent_name in self._parents[hp] ] parent_sorting.sort() parent_order = OrderedDict() for _, parent_name in parent_sorting: parent_order[parent_name] = self._parents[hp][parent_name] new_order[hp] = parent_order self._parents = new_order # update conditions for condition in self.get_conditions(): condition.set_vector_idx(self._hyperparameter_idx) # forbidden clauses for clause in self.get_forbiddens(): clause.set_vector_idx(self._hyperparameter_idx) def _check_condition( self, child_node: Hyperparameter, condition: ConditionComponent, ) -> None: for present_condition in self._get_parent_conditions_of(child_node.name): if present_condition != condition: raise AmbiguousConditionError(present_condition, condition) def _add_edge( self, parent_node: Hyperparameter, child_node: Hyperparameter, condition: ConditionComponent, ) -> None: with contextlib.suppress(Exception): # TODO maybe this has to be done more carefully del self._children[_ROOT][child_node.name] with contextlib.suppress(Exception): del self._parents[child_node.name][_ROOT] self._children[parent_node.name][child_node.name] = condition self._parents[child_node.name][parent_node.name] = condition self._conditionals.add(child_node.name) def _create_tmp_dag(self) -> nx.DiGraph: tmp_dag = nx.DiGraph() for hp_name in self._hyperparameters: tmp_dag.add_node(hp_name) tmp_dag.add_edge(_ROOT, hp_name) for parent_node_ in self._children: if parent_node_ == _ROOT: continue for child_node_ in self._children[parent_node_]: with contextlib.suppress(Exception): tmp_dag.remove_edge(_ROOT, child_node_) condition = self._children[parent_node_][child_node_] tmp_dag.add_edge(parent_node_, child_node_, condition=condition) return tmp_dag def _check_edges( self, edges: list[tuple[Hyperparameter, Hyperparameter]], values: list[Any], ) -> None: for (parent, child), value in zip(edges, values): # check if both nodes are already inserted into the graph if child.name not in self._hyperparameters: raise ChildNotFoundError(child, space=self) if parent.name not in self._hyperparameters: raise ParentNotFoundError(parent, space=self) if child != self._hyperparameters[child.name]: existing = self._hyperparameters[child.name] raise HyperparameterAlreadyExistsError(existing, child, space=self) if parent != self._hyperparameters[parent.name]: existing = self._hyperparameters[child.name] raise HyperparameterAlreadyExistsError(existing, child, space=self) _assert_legal(parent, value) # TODO: recursively check everything which is inside the conditions, # this means we have to recursively traverse the condition tmp_dag = self._create_tmp_dag() for parent, child in edges: tmp_dag.add_edge(parent.name, child.name) if not nx.is_directed_acyclic_graph(tmp_dag): cycles: list[list[str]] = list(nx.simple_cycles(tmp_dag)) for cycle in cycles: cycle.sort() cycles.sort() raise CyclicDependancyError(cycles) def _update_cache(self) -> None: self._parent_conditions_of = { name: self._get_parent_conditions_of(name) for name in self._hyperparameters } self._child_conditions_of = { name: self._get_child_conditions_of(name) for name in self._hyperparameters } self._parents_of = {name: self.get_parents_of(name) for name in self._hyperparameters} self._children_of = {name: self.get_children_of(name) for name in self._hyperparameters} def _check_forbidden_component(self, clause: AbstractForbiddenComponent) -> None: _assert_type(clause, AbstractForbiddenComponent, "_check_forbidden_component") to_check = [] relation_to_check = [] if isinstance(clause, AbstractForbiddenClause): to_check.append(clause) elif isinstance(clause, AbstractForbiddenConjunction): to_check.extend(clause.get_descendant_literal_clauses()) elif isinstance(clause, ForbiddenRelation): relation_to_check.extend(clause.get_descendant_literal_clauses()) else: raise NotImplementedError(type(clause)) def _check_hp(tmp_clause: AbstractForbiddenComponent, hp: Hyperparameter) -> None: if hp.name not in self._hyperparameters: raise HyperparameterNotFoundError( hp, space=self, preamble=f"Cannot add '{tmp_clause}' because it references '{hp.name}'", ) for tmp_clause in to_check: _check_hp(tmp_clause, tmp_clause.hyperparameter) for tmp_clause in relation_to_check: _check_hp(tmp_clause, tmp_clause.left) _check_hp(tmp_clause, tmp_clause.right) def _get_children_of(self, name: str) -> list[Hyperparameter]: conditions = self._get_child_conditions_of(name) parents: list[Hyperparameter] = [] for condition in conditions: parents.extend(condition.get_children()) return parents def _get_child_conditions_of(self, name: str) -> list[AbstractCondition]: children = self._children[name] return [children[child_name] for child_name in children if child_name != _ROOT] def _get_parents_of(self, name: str) -> list[Hyperparameter]: """The parents hyperparameters of a given hyperparameter. Parameters ---------- name : str Returns ------- list List with all parent hyperparameters """ conditions = self._get_parent_conditions_of(name) parents: list[Hyperparameter] = [] for condition in conditions: parents.extend(condition.get_parents()) return parents def _check_default_configuration(self) -> Configuration: # Check if adding that hyperparameter leads to an illegal default configuration instantiated_hyperparameters: dict[str, int | float | str | None] = {} for hp in self.values(): conditions = self._get_parent_conditions_of(hp.name) active = True for condition in conditions: parent_names = [ c.parent.name for c in condition.get_descendant_literal_conditions() ] parents = { parent_name: instantiated_hyperparameters[parent_name] for parent_name in parent_names } if not condition.evaluate(parents): # TODO find out why a configuration is illegal! active = False if not active: instantiated_hyperparameters[hp.name] = None elif isinstance(hp, Constant): instantiated_hyperparameters[hp.name] = hp.value else: instantiated_hyperparameters[hp.name] = hp.default_value # TODO copy paste from check configuration # TODO add an extra Exception type for the case that the default # configuration is forbidden! return Configuration(self, values=instantiated_hyperparameters) def _get_parent_conditions_of(self, name: str) -> list[AbstractCondition]: parents = self._parents[name] return [parents[parent_name] for parent_name in parents if parent_name != _ROOT] def _check_configuration_rigorous( self, configuration: Configuration, allow_inactive_with_values: bool = False, ) -> None: vector = configuration.get_array() active_hyperparameters = self.get_active_hyperparameters(configuration) for hp_name, hyperparameter in self._hyperparameters.items(): hp_value = vector[self._hyperparameter_idx[hp_name]] active = hp_name in active_hyperparameters if not np.isnan(hp_value) and not hyperparameter.is_legal_vector(hp_value): raise IllegalValueError(hyperparameter, hp_value) if active and np.isnan(hp_value): raise ActiveHyperparameterNotSetError(hyperparameter) if not allow_inactive_with_values and not active and not np.isnan(hp_value): raise InactiveHyperparameterSetError(hyperparameter, hp_value) self._check_forbidden(vector) def _check_forbidden(self, vector: np.ndarray) -> None: ConfigSpace.c_util.check_forbidden(self.forbidden_clauses, vector) # ------------ Marked Deprecated -------------------- # Probably best to only remove these once we actually # make some other breaking changes # * Search `Marked Deprecated` to find others
[docs] def get_hyperparameter(self, name: str) -> Hyperparameter: """Hyperparameter from the space with a given name. Parameters ---------- name : str Name of the searched hyperparameter Returns ------- :ref:`Hyperparameters` Hyperparameter with the name ``name`` """ warnings.warn( "Prefer `space[name]` over `get_hyperparameter`", DeprecationWarning, stacklevel=2, ) return self[name]
[docs] def get_hyperparameters(self) -> list[Hyperparameter]: """All hyperparameters in the space. Returns ------- list(:ref:`Hyperparameters`) A list with all hyperparameters stored in the configuration space object """ warnings.warn( "Prefer using `list(space.values())` over `get_hyperparameters`", DeprecationWarning, stacklevel=2, ) return list(self._hyperparameters.values())
[docs] def get_hyperparameters_dict(self) -> dict[str, Hyperparameter]: """All the ``(name, Hyperparameter)`` contained in the space. Returns ------- dict(str, :ref:`Hyperparameters`) An OrderedDict of names and hyperparameters """ warnings.warn( "Prefer using `dict(space)` over `get_hyperparameters_dict`", DeprecationWarning, stacklevel=2, ) return self._hyperparameters.copy()
[docs] def get_hyperparameter_names(self) -> list[str]: """Names of all the hyperparameter in the space. Returns ------- list(str) List of hyperparameter names """ warnings.warn( "Prefer using `list(space.keys())` over `get_hyperparameter_names`", DeprecationWarning, stacklevel=2, ) return list(self._hyperparameters.keys())
# ---------------------------------------------------