Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/ConfigSpace/api/types/categorical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

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

Expand All @@ -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__(
Expand All @@ -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.

Expand All @@ -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...
Expand All @@ -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]
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -174,6 +237,39 @@ 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(
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):
Expand Down
21 changes: 21 additions & 0 deletions test/test_api/test_hp_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 28 additions & 2 deletions test/test_hyperparameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down