From 63a6a9a2d3ce45d44ceace9a5ad0cb3c1d2a6729 Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Tue, 18 Nov 2025 15:30:56 +0100 Subject: [PATCH 1/5] Example solution to adapting HP bounds and defaults dynamically --- .../hyperparameters/hyperparameter.py | 2 +- .../hyperparameters/uniform_integer.py | 66 +++++++++++++++++++ test/test_hyperparameters.py | 44 +++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/ConfigSpace/hyperparameters/hyperparameter.py b/src/ConfigSpace/hyperparameters/hyperparameter.py index 9fb12f94..dfd85d1c 100644 --- a/src/ConfigSpace/hyperparameters/hyperparameter.py +++ b/src/ConfigSpace/hyperparameters/hyperparameter.py @@ -132,7 +132,7 @@ 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) diff --git a/src/ConfigSpace/hyperparameters/uniform_integer.py b/src/ConfigSpace/hyperparameters/uniform_integer.py index 3e454064..5a836d1b 100644 --- a/src/ConfigSpace/hyperparameters/uniform_integer.py +++ b/src/ConfigSpace/hyperparameters/uniform_integer.py @@ -162,6 +162,72 @@ def to_float(self) -> UniformFloatHyperparameter: meta=self.meta, ) + def __setattr__(self, name: str, value: Any) -> None: + if hasattr(self, name): # Post init, as the value was already set, thus an update + if name == "upper" and value != self.upper or name == "lower" and value != self.lower: # Update sampling scales + value = int(np.rint(value)) + if name == "upper": + if value < self.default_value or value < self.lower: + raise ValueError( + f"Upper bound {value} is smaller than the lower bound or default value [{self.lower}, {self.default_value}]" + ) + size = value - self.lower + 1 + try: + scaler = UnitScaler(i64(self.lower), i64(value), log=self.log, dtype=i64) + except ValueError as e: + raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + else: + if value > self.default_value or value > self.upper: + raise ValueError( + f"Lower bound {value} is greater than the upper bound or default value [{self.lower}, {self.default_value}]" + ) + size = self.upper - value + 1 + try: + scaler = UnitScaler(i64(value), i64(self.upper), log=self.log, dtype=i64) + except ValueError as e: + raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e + + # Properties to update + if not self.log: + vector_dist = UniformIntegerNormalizedDistribution(size=int(size)) + else: + vector_dist = DiscretizedContinuousScipyDistribution( + rv=uniform(), # type: ignore + steps=int(size), + _max_density=float(1 / size), + _pdf_norm=float(size), + lower_vectorized=f64(0.0), + upper_vectorized=f64(1.0), + log_scale=self.log, + transformer=scaler, + ) + self._vector_dist = vector_dist + self._transformer = scaler + self._neighborhood = vector_dist.neighborhood + self._neighborhood_size = self._integer_neighborhood_size + self.size = size + elif name == "default_value" and (not hasattr(self, name) or value != self.default_value): + value = int(np.rint(value)) + if value is not None and not is_close_to_integer( + f64(value), + atol=ATOL, + ): + raise TypeError( + f"`{name}` for hyperparameter '{self.name}' must be an integer." + f" Got '{type(value).__name__}' for {name}={value}.", + ) + if not self.legal_value(self.default_value): + raise ValueError( + f"Illegal default value {self.default_value} for" + f" hyperparameter '{self.name}'.", + ) + if value < self.lower or value > self.upper: + raise ValueError( + f"Default value {value} is out of range [{self.lower}, {self.upper}]" + ) + self._normalized_default_value = self.to_vector(value) + return super().__setattr__(name, value) + def __str__(self) -> str: parts = [ self.name, diff --git a/test/test_hyperparameters.py b/test/test_hyperparameters.py index 87cd07ce..7c6de92e 100644 --- a/test/test_hyperparameters.py +++ b/test/test_hyperparameters.py @@ -3080,3 +3080,47 @@ 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=10) + 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 + + with pytest.raises(ValueError): # Test that it cannot change to an illegal value + space["a"].upper = 0 # lower than lower + + # Test Float + space["b"].upper = 0.9 + assert space["b"].upper == 0.9 + space["b"].lower = -0.9 + assert space["b"].lower == -0.9 + + # TODO: Test updating categorical values + # TODO: Test updating ordinal values + # 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 From 67270618d30e9635007f901abec5d9f546788b36 Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Wed, 19 Nov 2025 11:17:45 +0100 Subject: [PATCH 2/5] Alternative solution --- .../hyperparameters/hyperparameter.py | 18 +++++ .../hyperparameters/uniform_integer.py | 66 ------------------- 2 files changed, 18 insertions(+), 66 deletions(-) diff --git a/src/ConfigSpace/hyperparameters/hyperparameter.py b/src/ConfigSpace/hyperparameters/hyperparameter.py index dfd85d1c..0a861a55 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 @@ -137,6 +138,23 @@ def __init__( 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.""" + #if hasattr(self, name): # Class has been initialised, value change post init + if inspect.stack()[1][3] != '__init__': # This should be only executed on update, not init + # 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("Can't set attribute {name}, must be one passed to init.") # Something better error message than this + + 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 + init_params[name] = value # Place the update value + + self.__init__(**init_params) # Reinitialise + else: + super().__setattr__(name, value) + @property def lower_vectorized(self) -> f64: """Lower bound of the hyperparameter in vector space.""" diff --git a/src/ConfigSpace/hyperparameters/uniform_integer.py b/src/ConfigSpace/hyperparameters/uniform_integer.py index 5a836d1b..3e454064 100644 --- a/src/ConfigSpace/hyperparameters/uniform_integer.py +++ b/src/ConfigSpace/hyperparameters/uniform_integer.py @@ -162,72 +162,6 @@ def to_float(self) -> UniformFloatHyperparameter: meta=self.meta, ) - def __setattr__(self, name: str, value: Any) -> None: - if hasattr(self, name): # Post init, as the value was already set, thus an update - if name == "upper" and value != self.upper or name == "lower" and value != self.lower: # Update sampling scales - value = int(np.rint(value)) - if name == "upper": - if value < self.default_value or value < self.lower: - raise ValueError( - f"Upper bound {value} is smaller than the lower bound or default value [{self.lower}, {self.default_value}]" - ) - size = value - self.lower + 1 - try: - scaler = UnitScaler(i64(self.lower), i64(value), log=self.log, dtype=i64) - except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e - else: - if value > self.default_value or value > self.upper: - raise ValueError( - f"Lower bound {value} is greater than the upper bound or default value [{self.lower}, {self.default_value}]" - ) - size = self.upper - value + 1 - try: - scaler = UnitScaler(i64(value), i64(self.upper), log=self.log, dtype=i64) - except ValueError as e: - raise ValueError(f"Hyperparameter '{name}' has illegal settings") from e - - # Properties to update - if not self.log: - vector_dist = UniformIntegerNormalizedDistribution(size=int(size)) - else: - vector_dist = DiscretizedContinuousScipyDistribution( - rv=uniform(), # type: ignore - steps=int(size), - _max_density=float(1 / size), - _pdf_norm=float(size), - lower_vectorized=f64(0.0), - upper_vectorized=f64(1.0), - log_scale=self.log, - transformer=scaler, - ) - self._vector_dist = vector_dist - self._transformer = scaler - self._neighborhood = vector_dist.neighborhood - self._neighborhood_size = self._integer_neighborhood_size - self.size = size - elif name == "default_value" and (not hasattr(self, name) or value != self.default_value): - value = int(np.rint(value)) - if value is not None and not is_close_to_integer( - f64(value), - atol=ATOL, - ): - raise TypeError( - f"`{name}` for hyperparameter '{self.name}' must be an integer." - f" Got '{type(value).__name__}' for {name}={value}.", - ) - if not self.legal_value(self.default_value): - raise ValueError( - f"Illegal default value {self.default_value} for" - f" hyperparameter '{self.name}'.", - ) - if value < self.lower or value > self.upper: - raise ValueError( - f"Default value {value} is out of range [{self.lower}, {self.upper}]" - ) - self._normalized_default_value = self.to_vector(value) - return super().__setattr__(name, value) - def __str__(self) -> str: parts = [ self.name, From babe99e828f06b955fb85f490209b154e4bbc3e4 Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Wed, 19 Nov 2025 11:19:34 +0100 Subject: [PATCH 3/5] Update comment --- src/ConfigSpace/hyperparameters/hyperparameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConfigSpace/hyperparameters/hyperparameter.py b/src/ConfigSpace/hyperparameters/hyperparameter.py index 0a861a55..f10de318 100644 --- a/src/ConfigSpace/hyperparameters/hyperparameter.py +++ b/src/ConfigSpace/hyperparameters/hyperparameter.py @@ -140,7 +140,7 @@ def __init__( def __setattr__(self, name: str, value: Any): """Check if attribute can be set on HP, and reinitialises the class if so.""" - #if hasattr(self, name): # Class has been initialised, value change post init + # NOTE: The following check is 'ugly', but it works... if inspect.stack()[1][3] != '__init__': # This should be only executed on update, not init # Extract all editable attributes init_params: tuple[str] = self.__init__.__code__.co_varnames[:self.__init__.__code__.co_argcount] From 97f4084f02d969d9495eacb6193d52ea43a83f3d Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Wed, 19 Nov 2025 11:33:34 +0100 Subject: [PATCH 4/5] Updating test to cover updates on all four major HP types --- test/test_hyperparameters.py | 63 +++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/test/test_hyperparameters.py b/test/test_hyperparameters.py index 7c6de92e..4d0a4364 100644 --- a/test/test_hyperparameters.py +++ b/test/test_hyperparameters.py @@ -3099,7 +3099,7 @@ def test_update_hyperparameters(): assert space["a"].lower == 49 # Sample the space to verify it does not sample OOD - sample = space.sample_configuration(size=10) + sample = space.sample_configuration(size=25) for value in sample: assert 49 <= value["a"] <= 51 @@ -3108,17 +3108,64 @@ def test_update_hyperparameters(): space["a"].default_value = 5 assert space["a"].default_value == 5 - with pytest.raises(ValueError): # Test that it cannot change to an illegal value + # 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.9 - assert space["b"].upper == 0.9 - space["b"].lower = -0.9 - assert space["b"].lower == -0.9 + 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=25) + for value in sample: + assert -0.1 <= value["b"] <= 0.1 + + # Test illegal changes + with pytest.raises(ValueError): + space["b"].upper = -0.11 # lower than lower + with pytest.raises(ValueError): + space["b"].lower = 0.11 # higher than upper + with pytest.raises(ValueError): + space["b"].default_value = -10.0 # Out of bounds + + # 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=25) + 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=25) + assert 4 in [s["d"] for s in sample] - # TODO: Test updating categorical values - # TODO: Test updating ordinal values # TODO: Test changing HP type int -> float # TODO: Test changing HP type float -> int # TODO: Test changing HP type categorical -> ordinal From 729bc8980d6d14f4a619bcf01a0a976fafab6edc Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Thu, 20 Nov 2025 10:42:14 +0100 Subject: [PATCH 5/5] Update sample size --- test/test_hyperparameters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_hyperparameters.py b/test/test_hyperparameters.py index 4d0a4364..05484244 100644 --- a/test/test_hyperparameters.py +++ b/test/test_hyperparameters.py @@ -3123,7 +3123,7 @@ def test_update_hyperparameters(): assert space["b"].lower == -0.1 # Check sampling - sample = space.sample_configuration(size=25) + sample = space.sample_configuration(size=100) for value in sample: assert -0.1 <= value["b"] <= 0.1 @@ -3147,7 +3147,7 @@ def test_update_hyperparameters(): # Test sampling sample_count = {1: 0, 2: 0, 3: 0, 4: 0} - sample = space.sample_configuration(size=25) + sample = space.sample_configuration(size=100) for value in sample: sample_count[value["c"]] += 1 assert sample_count[2] > sample_count[1] @@ -3163,7 +3163,7 @@ def test_update_hyperparameters(): assert space["d"].default_value == 4 # Test sampling - sample = space.sample_configuration(size=25) + sample = space.sample_configuration(size=100) assert 4 in [s["d"] for s in sample] # TODO: Test changing HP type int -> float