diff --git a/ConfigSpace/api/types/categorical.py b/ConfigSpace/api/types/categorical.py index c0c84a27..a3deef31 100644 --- a/ConfigSpace/api/types/categorical.py +++ b/ConfigSpace/api/types/categorical.py @@ -119,7 +119,7 @@ def Categorical( :py:class:`CategoricalHyperparameter`. meta: dict | None = None - Any additional meta information you would like to store along with the hyperparamter. + Any additional meta information you would like to store along with the hyperparameter. """ if ordered and weights is not None: raise ValueError("Can't apply `weights` to `ordered` Categorical") diff --git a/ConfigSpace/configuration_space.py b/ConfigSpace/configuration_space.py index 29f624f6..a66dfe7a 100644 --- a/ConfigSpace/configuration_space.py +++ b/ConfigSpace/configuration_space.py @@ -31,9 +31,10 @@ import copy import io import warnings +from abc import ABC, abstractmethod from collections import OrderedDict, defaultdict, deque from itertools import chain -from typing import Any, Iterable, Iterator, KeysView, Mapping, cast, overload +from typing import Any, Callable, Generic, Iterable, Iterator, KeysView, Mapping, TypeVar, cast, overload from typing_extensions import Final import numpy as np @@ -144,7 +145,7 @@ def __init__( seed: int | None = None, meta: dict | None = None, *, - space: None + space: None | ConfigurationSpace | ( dict[ str, @@ -164,7 +165,7 @@ def __init__( meta : dict, optional Field for holding meta data provided by the user. Not used by the configuration space. - space: + space: dict | ConfigurationSpace, optional A simple configuration space to use: .. code:: python @@ -182,13 +183,17 @@ def __init__( """ # If first arg is a dict, we assume this to be `space` if isinstance(name, dict): + if space is not None: + raise ValueError( + f"If name (or the first arg) is a dict, space must be None, got: {space}", + ) space = name name = None self.name = name self.meta = meta - # NOTE: The idx of a hyperparamter is tied to its order in _hyperparamters + # NOTE: The idx of a hyperparameter is tied to its order in _hyperparameters # Having three variables to keep track of this seems excessive self._hyperparameters: OrderedDict[str, Hyperparameter] = OrderedDict() self._hyperparameter_idx: dict[str, int] = {} @@ -795,7 +800,7 @@ def get_active_hyperparameters( def sample_configuration(self, size: None = None) -> Configuration: ... - # Technically this is wrong given the current behaviour but it's + # 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. @@ -1582,3 +1587,134 @@ def get_hyperparameter_names(self) -> list[str]: return list(self._hyperparameters.keys()) # --------------------------------------------------- + + +TOutput = TypeVar("TOutput") + + +class TypedConfigurationSpace(ConfigurationSpace, Generic[TOutput], ABC): + """ + An extension of ConfigurationSpace that allows to sample instances of a given type. + In this version, the construction of the chosen type has to be implemented explicitly + by implementing the abstract method `_instantiate_type_from_config`, and each + implementation of this class is bound to the type it returns via generics. + + A common case is when the user already has implemented a function for + instantiating the desired type from kwargs (e.g., the type's standard constructor) + and does not wish to create a separate class for handling the sampling. + In that case, one can use :class:`TypedConfigurationSpaceFromConstructor` + instead. + + Examples + -------- + + A typical example would be + >>> @dataclass + ... class MyOutputClass: + ... a: int + + For spaces that contain an integer under the key "a", one can then implement + >>> class MyOutputConfigurationSpace(TypedConfigurationSpace[MyOutputClass]): + ... def _instantiate_type_from_config(self, config: Configuration): + ... return MyOutputClass(**dict(config)) + ... + + Then, one can create a config space of instances of `MyOutputClass` from the configuration space as + >>> space = {"a": (1, 5)} + >>> my_config_space = MyOutputConfigurationSpace(space=space) + """ + + @abstractmethod + def _instantiate_type_from_config(self, config: Configuration) -> TOutput: + pass + + @overload + def sample_type(self, size: None = None) -> TOutput: + ... + + @overload + def sample_type(self, size: int) -> list[TOutput]: + ... + + def sample_type(self, size: int | None = None) -> TOutput | list[TOutput]: + """Sample a configuration from the configuration space object. + + Parameters + ---------- + size : int, optional + Number of configurations to sample. + + Returns + ------- + TOut | list[TOut] + Sampled instances of the output type. If `size=None`, a single instance is + returned. Otherwise, a list of instances is returned. + """ + configs = super().sample_configuration(size) + if size in (None, 1): + configs = [configs] + + configs = cast(list[Configuration], configs) + usr_type_instances = [self._instantiate_type_from_config(conf) for conf in configs] + if size is None: + return usr_type_instances[0] + return usr_type_instances + + +class TypedConfigurationSpaceFromConstructor(TypedConfigurationSpace[TOutput]): + """ + An specialization of TypedConfigurationSpaceFromConstructor for the common special + case is when the type can be instantiated directly from kwargs and the user + does not desire to have a separate class for handling the sampling of the type. + + Examples + -------- + + A typical example would be + >>> @dataclass + ... class MyOutputClass: + ... a: int + + Contrary to the base `TypedConfigurationSpace`, one does not need to implement + a new class to reach the desired functionality and instead only needs to pass + the type constructor, which in this case is just the type itself + >>> space = {"a": (1, 5)} + >>> my_config_space = TypedConfigurationSpaceFromConstructor(MyOutputClass, space=space) + + + """ + def __init__( + self, + output_constructor: Callable[[...], TOutput], + 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 + ---------- + output_constructor : Callable[[...], TOutput] + Takes the unpacked samples of the config space as kwargs + and returns an instance of the desired type. A typical example + is the default constructor of a dataclass. + + """ + self.output_constructor = output_constructor + super().__init__( + name=name, + seed=seed, + meta=meta, + space=space, + ) + + def _instantiate_type_from_config(self, config: Configuration) -> TOutput: + return self.output_constructor(**dict(config)) diff --git a/ConfigSpace/util.py b/ConfigSpace/util.py index 120bfce3..35ba5c63 100644 --- a/ConfigSpace/util.py +++ b/ConfigSpace/util.py @@ -54,7 +54,7 @@ def impute_inactive_values( """Impute inactive parameters. Iterate through the hyperparameters of a ``Configuration`` and set the - values of the inactive hyperparamters to their default values if the choosen + values of the inactive hyperparameters to their default values if the choosen ``strategy`` is 'default'. Otherwise ``strategy`` contains a float number. Set the hyperparameters' value to this number. @@ -341,7 +341,7 @@ def deactivate_inactive_hyperparameters( ---------- configuration : dict a configuration as a dictionary. Key: name of the hyperparameter. - Value: value of this hyperparamter + Value: value of this hyperparameter configuration from which inactive hyperparameters will be removed configuration_space : :class:`~ConfigSpace.configuration_space.ConfigurationSpace` The defined configuration space. It is necessary to find the inactive @@ -427,7 +427,7 @@ def fix_types( ---------- configuration : dict a configuration as a dictionary. Key: name of the hyperparameter. - Value: value of this hyperparamter + Value: value of this hyperparameter configuration_space : :class:`~ConfigSpace.configuration_space.ConfigurationSpace` Configuration space which knows the types for all parameter values diff --git a/README.md b/README.md index 41c38438..ae5e5dea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ConfigSpace -A simple Python/Cython module implementing a domain specific language to manage +A simple Python/Cython module implementing a domain-specific language to manage configuration spaces for algorithm configuration and hyperparameter optimization tasks. Distributed under BSD 3-clause, see LICENSE except all files in the directory ConfigSpace.nx, which are copied from the networkx package and licensed diff --git a/changelog.md b/changelog.md index 864145c1..817f399a 100644 --- a/changelog.md +++ b/changelog.md @@ -18,7 +18,7 @@ # Version 0.6.0 * ADD #255: An easy interface of `Float`, `Integer`, `Categorical` for creating search spaces. -* ADD #243: Add forbidden relations between two hyperparamters +* ADD #243: Add forbidden relations between two hyperparameters * MAINT #243: Change branch `master` to `main` * FIX #259: Numpy runtime error when rounding * FIX #247: No longer errors when serliazing spaces with an `InCondition` @@ -32,7 +32,7 @@ * FIX #221: Normal Hyperparameters should now properly sample from correct distribution in log space * FIX #221: Fixed boundary problems with integer hyperparameters due to numerical rounding after sampling. * MAINT #221: Categorical Hyperparameters now always have associated probabilities, remaining uniform if non are provided. (Same behaviour) -* ADD #222: BetaFloat and BetaInteger hyperparamters, hyperparameters distributed according to a beta distribution. +* ADD #222: BetaFloat and BetaInteger hyperparameters, hyperparameters distributed according to a beta distribution. * ADD #241: Implements support for [PiBo](https://openreview.net/forum?id=MMAeCXIa89), you can now embed some prior distribution knowledge into ConfigSpace hyperparameters. * See the example [here](https://automl.github.io/ConfigSpace/main/User-Guide.html#th-example-placing-priors-on-the-hyperparameters). * Hyperparameters now have a `pdf(vector: np.ndarray) -> np.ndarray` to get the probability density values for the input diff --git a/docs/api/hyperparameters.rst b/docs/api/hyperparameters.rst index 47120123..fa5604a3 100644 --- a/docs/api/hyperparameters.rst +++ b/docs/api/hyperparameters.rst @@ -5,7 +5,7 @@ Hyperparameters ConfigSpace contains :func:`~ConfigSpace.api.types.float.Float`, :func:`~ConfigSpace.api.types.integer.Integer` -and :func:`~ConfigSpace.api.types.categorical.Categorical` hyperparamters, each with their own customizability. +and :func:`~ConfigSpace.api.types.categorical.Categorical` hyperparameters, each with their own customizability. For :func:`~ConfigSpace.api.types.float.Float` and :func:`~ConfigSpace.api.types.integer.Integer`, you will find their interface much the same, being able to take the same :ref:`distributions ` and parameters. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index bad7efa8..2cdbf56d 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -76,7 +76,7 @@ And that's it! Advanced Usage -------------- Lets create a more complex example where we have two models, model ``A`` and model ``B``. -Model ``B`` is some kernel based algorithm and ``A`` just needs a simple float hyperparamter. +Model ``B`` is some kernel based algorithm and ``A`` just needs a simple float hyperparameter. We're going to create a config space that will let us correctly build a randomly selected model. diff --git a/test/test_configuration_space.py b/test/test_configuration_space.py index d850360d..3e9bd26c 100644 --- a/test/test_configuration_space.py +++ b/test/test_configuration_space.py @@ -30,6 +30,7 @@ import json import unittest from collections import OrderedDict +from dataclasses import dataclass from itertools import product import numpy as np @@ -68,6 +69,7 @@ OrdinalHyperparameter, UniformFloatHyperparameter, ) +from configuration_space import TOutput, TypedConfigurationSpace, TypedConfigurationSpaceFromConstructor def byteify(input): @@ -1017,6 +1019,32 @@ def test_estimate_size(self): assert np.isinf(cs.estimate_size()) +class TestTypedConfigurationSpace(unittest.TestCase): + def test_custom_type_returned(self): + @dataclass + class CustomOutput: + foo: int + bar: str + + class CustomConfigSpace(TypedConfigurationSpace[CustomOutput]): + def _instantiate_type_from_config(self, config: Configuration) -> TOutput: + config_dict = dict(config) + config_dict["bar"] = config_dict["baz"] + return CustomOutput(**config_dict) + + space = {"foo": (1, 5), "baz": ["a", "b"]} + typed_cs_from_custom_class = CustomConfigSpace(space=space) + typed_cs_from_passing_constructor = TypedConfigurationSpaceFromConstructor( + CustomOutput, space=space + ) + for typed_cs in (typed_cs_from_passing_constructor, typed_cs_from_custom_class): + assert isinstance(typed_cs.sample_type(), CustomOutput) + assert isinstance(typed_cs.sample_type(size=1), list) + assert isinstance(typed_cs.sample_type(size=1)[0], CustomOutput) + assert isinstance(typed_cs.sample_type(size=2), list) + + + class ConfigurationTest(unittest.TestCase): def setUp(self): cs = ConfigurationSpace()