diff --git a/docs/reference/hyperparameters.md b/docs/reference/hyperparameters.md index cfc59c99..772513bb 100644 --- a/docs/reference/hyperparameters.md +++ b/docs/reference/hyperparameters.md @@ -214,6 +214,10 @@ What makes a hyperparameter the hyperparameter it is then: if they share the same underlying vectorized space. +!!! tip Updating Hyperparameter properties + + Creating your own hyperparameter class, it is important to note that its properties may be updated after creation, see [`__setattr__`][ConfigSpace.hyperparameters.Hyperparameter.__setattr__]. This method blocks any changes after initialisation to any attribute directly or the creation of new attributes. Attribute values can only be changed through the class constructor parameters (the arguments of `__init__` function), and is only possible when they share **the same name**. Take this into consideration when subclassing the `Hyperparameter` class. + !!! example "CategoricalHyperparameter" Inside of the `__init__` method of a `CategoricalHyperparameter`, you will find something along the lines @@ -497,7 +501,7 @@ class BetaIntegerHyperparamter(IntegerHyperparameter): try: scaler = UnitScaler(i64(self.lower), i64(self.upper), log=log, dtype=i64) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e if default_value is None: diff --git a/src/ConfigSpace/hyperparameters/beta_float.py b/src/ConfigSpace/hyperparameters/beta_float.py index a2856183..4b6648d2 100644 --- a/src/ConfigSpace/hyperparameters/beta_float.py +++ b/src/ConfigSpace/hyperparameters/beta_float.py @@ -117,7 +117,7 @@ def __init__( try: scaler = UnitScaler(f64(self.lower), f64(self.upper), log=log, dtype=f64) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e if (self.alpha > 1) or (self.beta > 1): normalized_mode = (self.alpha - 1) / (self.alpha + self.beta - 2) diff --git a/src/ConfigSpace/hyperparameters/beta_integer.py b/src/ConfigSpace/hyperparameters/beta_integer.py index a1495aae..cb1b52ee 100644 --- a/src/ConfigSpace/hyperparameters/beta_integer.py +++ b/src/ConfigSpace/hyperparameters/beta_integer.py @@ -118,7 +118,7 @@ def __init__( try: scaler = UnitScaler(i64(self.lower), i64(self.upper), log=log, dtype=i64) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e if default_value is None: if (self.alpha > 1) or (self.beta > 1): diff --git a/src/ConfigSpace/hyperparameters/hyperparameter.py b/src/ConfigSpace/hyperparameters/hyperparameter.py index 9fb12f94..570b99a1 100644 --- a/src/ConfigSpace/hyperparameters/hyperparameter.py +++ b/src/ConfigSpace/hyperparameters/hyperparameter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import warnings from abc import ABC, abstractmethod from collections.abc import Callable, Hashable, Mapping, Sequence @@ -132,11 +133,34 @@ def __init__( if not self.legal_value(self.default_value): raise ValueError( f"Illegal default value {self.default_value} for" - f" hyperparamter '{self.name}'.", + f" hyperparameter '{self.name}'.", ) self._normalized_default_value = self.to_vector(self.default_value) + def __setattr__(self, name: str, value: Any): + """Check if attribute can be set on HP, and reinitialises the class if so.""" + # NOTE: Here we first verify if the object is being initialised by checking the call stack if the parent function is called "__init__" + # This is currently the best way to check as checking the caller class etc is too complex + # Alternatively, we could compare the __code__ objects/properties of the caller for self.__init__.__code__ but this would be more computationally expensive + if inspect.stack()[1][3] == '__init__': # Init, normal __setattr__ control flow + super().__setattr__(name, value) + else: # We are updating an existing attribute + # Extract all editable attributes + init_params: tuple[str] = self.__init__.__code__.co_varnames[:self.__init__.__code__.co_argcount] + + if name not in init_params or not hasattr(self, name): + raise ValueError(f"Can only set parameters passed to self.__init__. '{name}' is not one of: {init_params}") + + try: + init_params = {key: self.__dict__[key] for key in init_params if hasattr(self, key)} # This will break if the parameter is not saved under its passed name + except KeyError as e: + raise KeyError(f"You are seeing this message because the class {self.__class__.__name__} does not define an instance attribute with the same name as the class constructor parameter. To update Hyperparameter attributes after initialization, either modify the __init__ function of the class to define the attribute with the same name or call hp_instance.__init__(**new_init_parameters) to reset the parameters") from e + init_params[name] = value # Place the update value + + self.__init__(**init_params) # Reinitialise + + @property def lower_vectorized(self) -> f64: """Lower bound of the hyperparameter in vector space.""" diff --git a/src/ConfigSpace/hyperparameters/normal_float.py b/src/ConfigSpace/hyperparameters/normal_float.py index 5c8da568..15ffcd79 100644 --- a/src/ConfigSpace/hyperparameters/normal_float.py +++ b/src/ConfigSpace/hyperparameters/normal_float.py @@ -98,7 +98,7 @@ def __init__( """ if mu <= 0 and log: raise ValueError( - f"Hyperparameter '{name}' has illegal settings: " + f"Illegal value for Hyperparameter '{name}': " f"mu={mu} must be positive for log-scale.", ) @@ -111,7 +111,7 @@ def __init__( try: scaler = UnitScaler(f64(self.lower), f64(self.upper), log=log, dtype=f64) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e if default_value is None: _default_value = np.clip(self.mu, self.lower, self.upper) diff --git a/src/ConfigSpace/hyperparameters/normal_integer.py b/src/ConfigSpace/hyperparameters/normal_integer.py index a975068a..e36f0916 100644 --- a/src/ConfigSpace/hyperparameters/normal_integer.py +++ b/src/ConfigSpace/hyperparameters/normal_integer.py @@ -101,7 +101,7 @@ def __init__( """ if mu <= 0 and log: raise ValueError( - f"Hyperparameter '{name}' has illegal settings: " + f"Illegal value for Hyperparameter '{name}': " f"mu={mu} must be positive for log-scale.", ) @@ -119,7 +119,7 @@ def __init__( dtype=i64, ) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e if default_value is None: _default_value = int(np.rint(np.clip(self.mu, self.lower, self.upper))) diff --git a/src/ConfigSpace/hyperparameters/uniform_float.py b/src/ConfigSpace/hyperparameters/uniform_float.py index 255a4704..2a245512 100644 --- a/src/ConfigSpace/hyperparameters/uniform_float.py +++ b/src/ConfigSpace/hyperparameters/uniform_float.py @@ -97,7 +97,7 @@ def __init__( try: scaler = UnitScaler(f64(self.lower), f64(self.upper), log=log, dtype=f64) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e vect_dist = UnitUniformContinuousDistribution( pdf_max_density=1 / float(self.upper - self.lower), diff --git a/src/ConfigSpace/hyperparameters/uniform_integer.py b/src/ConfigSpace/hyperparameters/uniform_integer.py index 3e454064..9e0eb7aa 100644 --- a/src/ConfigSpace/hyperparameters/uniform_integer.py +++ b/src/ConfigSpace/hyperparameters/uniform_integer.py @@ -110,7 +110,7 @@ def __init__( try: scaler = UnitScaler(i64(self.lower), i64(self.upper), log=log, dtype=i64) except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + raise ValueError(f"Illegal value(s) for Hyperparameter '{name}'") from e size = self.upper - self.lower + 1 if not self.log: diff --git a/test/test_hyperparameters.py b/test/test_hyperparameters.py index 87cd07ce..b60d6176 100644 --- a/test/test_hyperparameters.py +++ b/test/test_hyperparameters.py @@ -28,6 +28,7 @@ from __future__ import annotations import copy +import re from collections import defaultdict from collections.abc import Mapping from dataclasses import dataclass @@ -60,7 +61,7 @@ } -def f() -> None: +def f(): pass @@ -286,7 +287,7 @@ def test_uniformfloat_to_integer(): def test_uniformfloat_illegal_bounds(): with pytest.raises( ValueError, - match="Hyperparameter 'param' has illegal settings", + match=re.escape("Illegal value(s) for Hyperparameter 'param'"), ) as e: _ = UniformFloatHyperparameter("param", 0, 10, log=True) @@ -298,7 +299,7 @@ def test_uniformfloat_illegal_bounds(): with pytest.raises( ValueError, - match="Hyperparameter 'param' has illegal settings", + match=re.escape("Illegal value(s) for Hyperparameter 'param'"), ) as e: _ = UniformFloatHyperparameter("param", 1, 0) @@ -1245,7 +1246,7 @@ def test_uniformint_legal_float_values(): def test_uniformint_illegal_bounds(): with pytest.raises( ValueError, - match="Hyperparameter 'param' has illegal settings", + match=re.escape("Illegal value(s) for Hyperparameter 'param'"), ) as e: UniformIntegerHyperparameter("param", 0, 10, log=True) @@ -1257,7 +1258,7 @@ def test_uniformint_illegal_bounds(): with pytest.raises( ValueError, - match="Hyperparameter 'param' has illegal settings", + match=re.escape("Illegal value(s) for Hyperparameter 'param'"), ) as e: _ = UniformIntegerHyperparameter("param", 1, 0) @@ -3080,3 +3081,117 @@ def test_arbitrary_object_allowed_in_categorical_ordinal( list(get_one_exchange_neighbourhood(sample, seed=1)) # no raise for n in ns: n.check_valid_configuration() # no raise + + +def test_update_hyperparameters(): + space = ConfigurationSpace() + space.add( + [ + UniformIntegerHyperparameter("a", 0, 100), + UniformFloatHyperparameter("b", -1.0, 1.0), + CategoricalHyperparameter("c", [1, 2, 3]), + OrdinalHyperparameter("d", [1, 2, 3]), + ], + ) + # Test updating numerical HP min/max values + space["a"].upper = 51 + assert space["a"].upper == 51 + space["a"].lower = 49 + assert space["a"].lower == 49 + + # Sample the space to verify it does not sample OOD + sample = space.sample_configuration(size=25) + for value in sample: + assert 49 <= value["a"] <= 51 + + # Test updating default values + space["a"].lower = 1 # Update first to avoid error + space["a"].default_value = 5 + assert space["a"].default_value == 5 + + # Test that it cannot change to an illegal value + with pytest.raises(ValueError): + space["a"].upper = 0 # lower than lower + with pytest.raises(ValueError): + space["a"].lower = 100 # higher than upper + with pytest.raises(ValueError): + space["a"].default_value = 1000 # Out of bounds + + # Test Float + space["b"].upper = 0.1 + assert space["b"].upper == 0.1 + space["b"].lower = -0.1 + assert space["b"].lower == -0.1 + + # Check sampling + sample = space.sample_configuration(size=100) + for value in sample: + assert -0.1 <= value["b"] <= 0.1 + + # Test illegal changes + with pytest.raises( + ValueError, + match=re.escape("Illegal value(s) for Hyperparameter 'b'"), + ): + space["b"].upper = -0.11 # lower than lower + with pytest.raises( + ValueError, + match=re.escape("Illegal value(s) for Hyperparameter 'b'"), + ): + space["b"].lower = 0.11 # higher than upper + with pytest.raises( + ValueError, + match=re.escape("Illegal value(s) for Hyperparameter 'b'"), + ): + space["b"].default_value = -10.0 # Out of bounds + with pytest.raises( + ValueError, + match=re.escape( + "Can only set parameters passed to self.__init__. 'size' is not one of: ('self', 'name', 'lower', 'upper', 'default_value', 'log', 'meta')", + ), + ): + space["b"].size = 1_000_000 # Not an init parameter + with pytest.raises( + ValueError, + match=re.escape( + "Can only set parameters passed to self.__init__. 'non_existiting_attribute' is not one of: ('self', 'name', 'lower', 'upper', 'default_value', 'log', 'meta')", + ), + ): + space["b"].non_existiting_attribute = "wrong" # cannot add new attributes + + # Test categorical HP + space["c"].choices = [1, 2, 3, 4] # Change range + assert space["c"].choices == (1, 2, 3, 4) + + space["c"].default_value = 4 # Change default value + assert space["c"].default_value == 4 + + space["c"].weights = [0.1, 0.4, 0.1, 0.4] # Change weights + assert space["c"].weights == (0.1, 0.4, 0.1, 0.4) + + # Test sampling + sample_count = {1: 0, 2: 0, 3: 0, 4: 0} + sample = space.sample_configuration(size=100) + for value in sample: + sample_count[value["c"]] += 1 + assert sample_count[2] > sample_count[1] + assert sample_count[2] > sample_count[3] + assert sample_count[4] > sample_count[1] + assert sample_count[4] > sample_count[3] + + # Test ordinal HP + space["d"].sequence = [1, 2, 3, 4] # Change range + assert space["d"].sequence == (1, 2, 3, 4) + + space["d"].default_value = 4 # Change default value + assert space["d"].default_value == 4 + + # Test sampling + sample = space.sample_configuration(size=100) + assert 4 in [s["d"] for s in sample] + + # TODO: Test changing HP type int -> float + # TODO: Test changing HP type float -> int + # TODO: Test changing HP type categorical -> ordinal + # TODO: Test changing HP type ordinal -> categorical + # TODO: Check that HP type cannot change between float/int and categorical/ordinal