Skip to content
Open
6 changes: 5 additions & 1 deletion docs/reference/hyperparameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The admonition title contains spaces but isn’t quoted. Elsewhere in the docs, titled admonitions use quotes (e.g., !!! tip "New in 1.1!"), and unquoted titles with spaces may not parse/render as intended depending on the Markdown extension configuration. Use the quoted title form (or omit the title entirely) for consistency and correct rendering.

Suggested change
!!! tip Updating Hyperparameter properties
!!! tip "Updating Hyperparameter properties"

Copilot uses AI. Check for mistakes.

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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/ConfigSpace/hyperparameters/beta_float.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/ConfigSpace/hyperparameters/beta_integer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 25 additions & 1 deletion src/ConfigSpace/hyperparameters/hyperparameter.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Comment on lines +141 to +147
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__setattr__ calls inspect.stack() on every attribute assignment to detect initialization. This is very expensive (it collects full stack frame info) and also brittle: any attribute assignment performed from a helper called inside __init__ (or from a dataclass __post_init__) won’t see the caller as __init__ and will incorrectly trigger the re-init path. Consider using an explicit internal flag (e.g., _is_initializing / _is_reinitializing) or a sentinel like _initialized in __dict__ instead of call-stack inspection.

Copilot uses AI. Check for mistakes.
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}")

Comment on lines +149 to +154
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

init_params extraction via self.__init__.__code__.co_varnames[:co_argcount] includes 'self' and ignores keyword-only parameters (it doesn’t consider co_kwonlyargcount). This will make the allowlist/error message awkward and can prevent updating keyword-only constructor args in subclasses. Using inspect.signature(self.__init__).parameters (and filtering out 'self') is more robust; also the annotation tuple[str] should be tuple[str, ...] if you keep it as a tuple of names.

Copilot uses AI. Check for mistakes.
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
Comment thread
thijssnelleman marked this conversation as resolved.


Comment on lines +161 to +163
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-initializing the existing instance via self.__init__(**init_params) is not transactional: many hyperparameter __init__ methods assign to attributes (e.g., lower/upper) before validation, so if validation raises, the object may be left partially mutated/inconsistent even though the setter raised. To avoid corrupting the instance, restore the previous state on failure (e.g., snapshot self.__dict__ and roll back in except) or build a new instance and swap state only after successful construction.

Suggested change
self.__init__(**init_params) # Reinitialise
previous_state = self.__dict__.copy()
try:
self.__init__(**init_params) # Reinitialise
except Exception:
self.__dict__.clear()
self.__dict__.update(previous_state)
raise

Copilot uses AI. Check for mistakes.
@property
def lower_vectorized(self) -> f64:
"""Lower bound of the hyperparameter in vector space."""
Expand Down
4 changes: 2 additions & 2 deletions src/ConfigSpace/hyperparameters/normal_float.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)

Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/ConfigSpace/hyperparameters/normal_integer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)

Expand All @@ -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)))
Expand Down
2 changes: 1 addition & 1 deletion src/ConfigSpace/hyperparameters/uniform_float.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/ConfigSpace/hyperparameters/uniform_integer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
125 changes: 120 additions & 5 deletions test/test_hyperparameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,7 +61,7 @@
}


def f() -> None:
def f():
pass


Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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
Comment thread
thijssnelleman marked this conversation as resolved.
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')",
Comment on lines +3149 to +3157
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test matches the full error string including an exact tuple of __init__ parameters. This is brittle (it will fail if the signature changes, parameter order changes, or if you later decide to omit 'self' from the message). Prefer matching a stable substring/regex (e.g., that the attribute name is not allowed) rather than the entire signature dump.

Suggested change
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')",
match=r"Can only set parameters passed to self\.__init__\..*'size' is not one of:",
):
space["b"].size = 1_000_000 # Not an init parameter
with pytest.raises(
ValueError,
match=(
r"Can only set parameters passed to self\.__init__\..*"
r"'non_existiting_attribute' is not one of:"

Copilot uses AI. Check for mistakes.
),
):
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]
Comment on lines +3172 to +3180
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new assertions rely on stochastic sampling (sample_configuration) without seeding the ConfigurationSpace RNG. This can lead to rare but real test flakiness (especially for the weighted categorical count comparisons). Seed the space (e.g., space.seed(…)) or pass a deterministic RNG where supported so the test outcome is reproducible.

Copilot uses AI. Check for mistakes.

# 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
Loading