From 4b9077da4dde3de6f2cfe6704a30eabcfd65da76 Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Thu, 19 Feb 2026 13:21:32 +0100 Subject: [PATCH 1/2] adding ordinal hp weights --- src/ConfigSpace/hyperparameters/ordinal.py | 77 +++++++++++++++++++++- test/test_hyperparameters.py | 30 ++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/ConfigSpace/hyperparameters/ordinal.py b/src/ConfigSpace/hyperparameters/ordinal.py index b0609258..5ccbcdab 100644 --- a/src/ConfigSpace/hyperparameters/ordinal.py +++ b/src/ConfigSpace/hyperparameters/ordinal.py @@ -8,7 +8,11 @@ import numpy as np -from ConfigSpace.hyperparameters.distributions import UniformIntegerDistribution +from ConfigSpace.hyperparameters.distributions import ( + Distribution, + UniformIntegerDistribution, + WeightedIntegerDiscreteDistribution, +) from ConfigSpace.hyperparameters.hp_components import ( TransformerSeq, ordinal_neighborhood, @@ -38,6 +42,9 @@ class OrdinalHyperparameter(Hyperparameter[Any, Any]): sequence: tuple[Any, ...] """Sequence of values the hyperparameter can take on.""" + weights: tuple[float, ...] | None + """The weights of the choices. If `None`, the choices are sampled uniformly.""" + name: str """Name of the hyperparameter, with which it can be accessed.""" @@ -51,6 +58,7 @@ class OrdinalHyperparameter(Hyperparameter[Any, Any]): """Size of the hyperparameter, which is the number of possible values the hyperparameter can take on within the specified sequence.""" + probabilities: Array[f64] = field(repr=False) _contains_sequence_as_value: bool def __init__( @@ -59,6 +67,7 @@ def __init__( sequence: Sequence[Any], default_value: Any | _NotSet = NotSet, meta: Mapping[Hashable, Any] | None = None, + weights: Sequence[float] | Array[np.number] | None = None, ) -> None: """Initialize an ordinal hyperparameter. @@ -71,6 +80,11 @@ def __init__( Default value of the hyperparameter meta: Field for holding meta data provided by the user + weights: + The weights of the choices. If `None`, the choices are sampled + uniformly. If given, the probabilities are normalized to sum to 1. + The length of the weights has to be the same as the length of the + choices. """ # TODO: Maybe give some way to not check this, i.e. for large sequences # of int... @@ -81,6 +95,13 @@ def __init__( f"Got {sequence} which does not fulfill this requirement.", ) + if isinstance(weights, set): + raise TypeError( + "Using a set of weights is prohibited as it can result in " + "non-deterministic behavior. Please use a list or a tuple.", + ) + + # NOTE: Most of this is a direct copy from CategoricalHyperparameter. It would be better to factor this out to avoid duplicate code / tech debt. size = len(sequence) if default_value is NotSet: default_value = sequence[0] @@ -90,6 +111,46 @@ def __init__( f"Got {default_value!r} which is not in {sequence}.", ) + if isinstance(weights, Sequence): + if len(weights) != size: + raise ValueError( + "The list of weights and the sequence are required to be" + f" of same length. Gave {len(weights)} weights and" + f" {size} sequence.", + ) + if any(weight < 0 for weight in weights): + raise ValueError( + f"Negative weights are not allowed. Got {weights}.", + ) + if all(weight == 0 for weight in weights): + raise ValueError( + "All weights are zero, at least one weight has to be strictly" + " positive.", + ) + tupled_weights = tuple(weights) + elif weights is not None: + raise TypeError( + f"The weights have to be a list, tuple or None. Got {weights!r}.", + ) + else: + tupled_weights = None + + if weights is None: + probabilities: Array[f64] = np.full(size, fill_value=1 / size, dtype=f64) + else: + _weights: Array[f64] = np.asarray(weights, dtype=f64) + probabilities = _weights / np.sum(_weights) + + # We only need to pass probabilties is they are non-uniform... + vector_dist: Distribution + if weights is not None: + vector_dist = WeightedIntegerDiscreteDistribution( + size=size, + probabilities=np.asarray(probabilities), + ) + else: + vector_dist = UniformIntegerDistribution(size=size) + try: # This can fail with a ValueError if the choices contain arbitrary objects # that are list like. @@ -108,7 +169,9 @@ def __init__( except ValueError: seq_choices = list(sequence) + self.probabilities = probabilities self.sequence = tuple(sequence) + self.weights = tupled_weights # If the Hyperparameter recieves as a Sequence during legality checks or # conversions, we need to inform it that one of the values is a Sequence itself, @@ -122,10 +185,10 @@ def __init__( name=name, size=size, default_value=default_value, + vector_dist=vector_dist, meta=meta, transformer=TransformerSeq(seq=seq_choices), neighborhood=partial(ordinal_neighborhood, size=int(size)), - vector_dist=UniformIntegerDistribution(size=size), neighborhood_size=self._ordinal_neighborhood_size, value_cast=None, ) @@ -174,6 +237,16 @@ def __str__(self) -> str: ] return ", ".join(parts) + def to_uniform(self) -> OrdinalHyperparameter: + """Converts this hyperparameter to have uniform weights.""" + return OrdinalHyperparameter( + name=self.name, + sequence=self.sequence, + default_value=self.default_value, + meta=self.meta, + weights=None, + ) + @override def to_vector(self, value: Any | Sequence[Any] | Array[Any]) -> f64 | Array[f64]: if isinstance(value, np.ndarray): diff --git a/test/test_hyperparameters.py b/test/test_hyperparameters.py index 87cd07ce..e37dbf1e 100644 --- a/test/test_hyperparameters.py +++ b/test/test_hyperparameters.py @@ -2792,9 +2792,35 @@ def test_ordinal_get_order(): assert f1.get_order("freezing") != 3 -def test_ordinal_get_seq_order(): - f1 = OrdinalHyperparameter("temp", ["freezing", "cold", "warm", "hot"]) +def test_ordinal_get_seq_order_and_weights(): + f1 = OrdinalHyperparameter( + "temp", + ["freezing", "cold", "warm", "hot"], + weights=[1, 10, 20, 30], + ) assert tuple(f1.get_seq_order()) == (0, 1, 2, 3) + assert f1.weights == (1, 10, 20, 30) + # Check that the weights are used to sample + cs = ConfigurationSpace() + cs.add(f1) + sample_counts = { + "freezing": 0, + "cold": 0, + "warm": 0, + "hot": 0, + } + samples = cs.sample_configuration(1000) + for sample in samples: + sample_counts[sample[f1.name]] += 1 + print( + sample_counts["freezing"], + sample_counts["cold"], + sample_counts["warm"], + sample_counts["hot"], + ) + assert sample_counts["freezing"] < sample_counts["cold"] + assert sample_counts["cold"] < sample_counts["warm"] + assert sample_counts["warm"] < sample_counts["hot"] def test_ordinal_get_neighbors(): From 30ed49956413a4e5bef905fc8720ff84c04f7a26 Mon Sep 17 00:00:00 2001 From: Thijs Snelleman Date: Fri, 6 Mar 2026 12:35:18 +0100 Subject: [PATCH 2/2] Copying equality method from categorical and expanding ordinal test to include one with weights --- src/ConfigSpace/api/types/categorical.py | 4 +--- src/ConfigSpace/hyperparameters/ordinal.py | 23 ++++++++++++++++++++++ test/test_api/test_hp_construction.py | 21 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/ConfigSpace/api/types/categorical.py b/src/ConfigSpace/api/types/categorical.py index f69ff82b..869da615 100644 --- a/src/ConfigSpace/api/types/categorical.py +++ b/src/ConfigSpace/api/types/categorical.py @@ -111,14 +111,12 @@ def Categorical( Any additional meta information you would like to store along with the hyperparamter. """ # noqa: E501 - if ordered and weights is not None: - raise ValueError("Can't apply `weights` to `ordered` Categorical") - if ordered: return OrdinalHyperparameter( name=name, sequence=items, default_value=default, + weights=weights, meta=meta, ) diff --git a/src/ConfigSpace/hyperparameters/ordinal.py b/src/ConfigSpace/hyperparameters/ordinal.py index 5ccbcdab..ba7896ec 100644 --- a/src/ConfigSpace/hyperparameters/ordinal.py +++ b/src/ConfigSpace/hyperparameters/ordinal.py @@ -237,6 +237,29 @@ def __str__(self) -> str: ] return ", ".join(parts) + def __eq__(self, other: Any) -> bool: + if ( + not isinstance(other, self.__class__) + or self.name != other.name + or self.default_value != other.default_value + or len(self.sequence) != len(other.sequence) + ): + return False + + # Longer check + for this_choice, this_prob in zip( + self.sequence, + self.probabilities, + ): + if this_choice not in other.sequence: + return False + + index_of_choice_in_other = other.sequence.index(this_choice) + other_prob = other.probabilities[index_of_choice_in_other] + if this_prob != other_prob: + return False + return True + def to_uniform(self) -> OrdinalHyperparameter: """Converts this hyperparameter to have uniform weights.""" return OrdinalHyperparameter( diff --git a/test/test_api/test_hp_construction.py b/test/test_api/test_hp_construction.py index 6657e5ef..d60b7da4 100644 --- a/test/test_api/test_hp_construction.py +++ b/test/test_api/test_hp_construction.py @@ -235,3 +235,24 @@ def test_ordinal() -> None: assert a == expected assert a.meta == expected.meta + + # Test with weights + expected = OrdinalHyperparameter( + "hp", + sequence=["a", "b", "c"], + weights=[1, 2, 3], + default_value="a", + meta={"hello": "world"}, + ) + + b = Categorical( + "hp", + items=["a", "b", "c"], + weights=[1, 2, 3], + default="a", + ordered=True, + meta={"hello": "world"}, + ) + + assert b == expected + assert b.meta == expected.meta