Coverage for mlos_core/mlos_core/tests/spaces/spaces_test.py: 94%
125 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-20 00:44 +0000
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-20 00:44 +0000
1#
2# Copyright (c) Microsoft Corporation.
3# Licensed under the MIT License.
4#
5"""Tests for mlos_core.spaces."""
7# pylint: disable=missing-function-docstring
9from abc import ABCMeta, abstractmethod
10from typing import Any, Callable, List, NoReturn, Union
12import ConfigSpace as CS
13import flaml.tune.sample
14import numpy as np
15import numpy.typing as npt
16import pytest
17import scipy
18from ConfigSpace.hyperparameters import Hyperparameter, NormalIntegerHyperparameter
20from mlos_core.spaces.converters.flaml import (
21 FlamlDomain,
22 FlamlSpace,
23 configspace_to_flaml_space,
24)
26OptimizerSpace = Union[FlamlSpace, CS.ConfigurationSpace]
27OptimizerParam = Union[FlamlDomain, Hyperparameter]
29NP_E: float = np.e # type: ignore[misc] # false positive (read deleted variable)
32def assert_is_uniform(arr: npt.NDArray) -> None:
33 """Implements a few tests for uniformity."""
34 _values, counts = np.unique(arr, return_counts=True)
36 kurtosis = scipy.stats.kurtosis(arr)
38 _chi_sq, p_value = scipy.stats.chisquare(counts)
40 frequencies = counts / len(arr)
41 assert np.isclose(frequencies.sum(), 1)
42 _f_chi_sq, f_p_value = scipy.stats.chisquare(frequencies)
44 assert np.isclose(kurtosis, -1.2, atol=0.1)
45 assert p_value > 0.3
46 assert f_p_value > 0.5
49def assert_is_log_uniform(arr: npt.NDArray, base: float = NP_E) -> None:
50 """Checks whether an array is log uniformly distributed."""
51 logs = np.log(arr) / np.log(base)
52 assert_is_uniform(logs)
55def test_is_uniform() -> None:
56 """Test our uniform distribution check function."""
57 np.random.seed(42)
58 uniform = np.random.uniform(1, 20, 1000)
59 assert_is_uniform(uniform)
62def test_is_log_uniform() -> None:
63 """Test our log uniform distribution check function."""
64 np.random.seed(42)
65 log_uniform = np.exp(np.random.uniform(np.log(1), np.log(20), 1000))
66 assert_is_log_uniform(log_uniform)
69def invalid_conversion_function(*args: Any) -> NoReturn:
70 """A quick dummy function for the base class to make pylint happy."""
71 raise NotImplementedError("subclass must override conversion_function")
74class BaseConversion(metaclass=ABCMeta):
75 """Base class for testing optimizer space conversions."""
77 conversion_function: Callable[..., OptimizerSpace] = invalid_conversion_function
79 @abstractmethod
80 def sample(self, config_space: OptimizerSpace, n_samples: int = 1) -> npt.NDArray:
81 """
82 Sample from the given configuration space.
84 Parameters
85 ----------
86 config_space : CS.ConfigurationSpace
87 Configuration space to sample from.
88 n_samples : int
89 Number of samples to use, by default 1.
90 """
92 @abstractmethod
93 def get_parameter_names(self, config_space: OptimizerSpace) -> List[str]:
94 """
95 Get the parameter names from the given configuration space.
97 Parameters
98 ----------
99 config_space : CS.ConfigurationSpace
100 Configuration space.
101 """
103 @abstractmethod
104 def categorical_counts(self, points: npt.NDArray) -> npt.NDArray:
105 """
106 Get the counts of each categorical value in the given points.
108 Parameters
109 ----------
110 points : np.array
111 Counts of each categorical value.
112 """
114 @abstractmethod
115 def test_dimensionality(self) -> None:
116 """Check that the dimensionality of the converted space is correct."""
118 def test_unsupported_hyperparameter(self) -> None:
119 input_space = CS.ConfigurationSpace()
120 input_space.add(NormalIntegerHyperparameter("a", mu=50, sigma=5, lower=0, upper=99))
121 with pytest.raises(ValueError, match="NormalIntegerHyperparameter"):
122 self.conversion_function(input_space)
124 def test_continuous_bounds(self) -> None:
125 input_space = CS.ConfigurationSpace()
126 input_space.add(CS.UniformFloatHyperparameter("a", lower=100, upper=200))
127 input_space.add(CS.UniformIntegerHyperparameter("b", lower=-10, upper=-5))
129 converted_space = self.conversion_function(input_space)
130 assert self.get_parameter_names(converted_space) == [ # pylint: disable=unreachable
131 "a",
132 "b",
133 ]
134 point = self.sample(converted_space)
135 assert 100 <= point[0] <= 200
136 assert -10 <= point[1] <= -5
138 def test_uniform_samples(self) -> None:
139 c = CS.UniformIntegerHyperparameter("c", lower=1, upper=20)
140 input_space = CS.ConfigurationSpace({"a": (1.0, 5.0), "c": c})
141 converted_space = self.conversion_function(input_space)
143 np.random.seed(42) # pylint: disable=unreachable
144 uniform, integer_uniform = self.sample(converted_space, n_samples=1000).T
146 # uniform float
147 assert_is_uniform(uniform)
149 # Check that we get both ends of the sampled range returned to us.
150 assert c.upper in integer_uniform
151 assert c.lower in integer_uniform
152 # integer uniform
153 assert_is_uniform(integer_uniform)
155 def test_uniform_categorical(self) -> None:
156 input_space = CS.ConfigurationSpace()
157 input_space.add(CS.CategoricalHyperparameter("c", choices=["foo", "bar"]))
158 converted_space = self.conversion_function(input_space)
159 points = self.sample(converted_space, n_samples=100) # pylint: disable=unreachable
160 counts = self.categorical_counts(points)
161 assert 35 < counts[0] < 65
162 assert 35 < counts[1] < 65
164 def test_weighted_categorical(self) -> None:
165 raise NotImplementedError("subclass must override")
167 def test_log_int_spaces(self) -> None:
168 raise NotImplementedError("subclass must override")
170 def test_log_float_spaces(self) -> None:
171 raise NotImplementedError("subclass must override")
174class TestFlamlConversion(BaseConversion):
175 """Tests for ConfigSpace to Flaml parameter conversions."""
177 conversion_function = staticmethod(configspace_to_flaml_space)
179 def sample(
180 self,
181 config_space: FlamlSpace, # type: ignore[override]
182 n_samples: int = 1,
183 ) -> npt.NDArray:
184 assert isinstance(config_space, dict)
185 assert isinstance(next(iter(config_space.values())), flaml.tune.sample.Domain)
186 ret: npt.NDArray = np.array(
187 [domain.sample(size=n_samples) for domain in config_space.values()]
188 ).T
189 return ret
191 def get_parameter_names(self, config_space: FlamlSpace) -> List[str]: # type: ignore[override]
192 assert isinstance(config_space, dict)
193 ret: List[str] = list(config_space.keys())
194 return ret
196 def categorical_counts(self, points: npt.NDArray) -> npt.NDArray:
197 _vals, counts = np.unique(points, return_counts=True)
198 assert isinstance(counts, np.ndarray)
199 return counts
201 def test_dimensionality(self) -> None:
202 input_space = CS.ConfigurationSpace()
203 input_space.add(CS.UniformIntegerHyperparameter("a", lower=1, upper=10))
204 input_space.add(CS.CategoricalHyperparameter("b", choices=["bof", "bum"]))
205 input_space.add(CS.CategoricalHyperparameter("c", choices=["foo", "bar"]))
206 output_space = configspace_to_flaml_space(input_space)
207 assert len(output_space) == 3
209 def test_weighted_categorical(self) -> None:
210 np.random.seed(42)
211 input_space = CS.ConfigurationSpace()
212 input_space.add(
213 CS.CategoricalHyperparameter("c", choices=["foo", "bar"], weights=[0.9, 0.1])
214 )
215 with pytest.raises(ValueError, match="non-uniform"):
216 configspace_to_flaml_space(input_space)
218 @pytest.mark.skip(reason="FIXME: flaml sampling is non-log-uniform")
219 def test_log_int_spaces(self) -> None:
220 np.random.seed(42)
221 # integer is supported
222 input_space = CS.ConfigurationSpace()
223 input_space.add(CS.UniformIntegerHyperparameter("d", lower=1, upper=20, log=True))
224 converted_space = configspace_to_flaml_space(input_space)
226 # test log integer sampling
227 integer_log_uniform = self.sample(converted_space, n_samples=1000)
228 integer_log_uniform = np.array(integer_log_uniform).ravel()
230 # FIXME: this fails - flaml is calling np.random.uniform() on base 10
231 # logs of the bounds as expected but for some reason the resulting
232 # samples are more skewed towards the lower end of the range
233 # See Also: https://github.com/microsoft/FLAML/issues/1104
234 assert_is_log_uniform(integer_log_uniform, base=10)
236 def test_log_float_spaces(self) -> None:
237 np.random.seed(42)
239 # continuous is supported
240 input_space = CS.ConfigurationSpace()
241 input_space.add(CS.UniformFloatHyperparameter("b", lower=1, upper=5, log=True))
242 converted_space = configspace_to_flaml_space(input_space)
244 # test log integer sampling
245 float_log_uniform = self.sample(converted_space, n_samples=1000)
246 float_log_uniform = np.array(float_log_uniform).ravel()
248 assert_is_log_uniform(float_log_uniform)
251if __name__ == "__main__":
252 # For attaching debugger debugging:
253 pytest.main(["-vv", "-k", "test_log_int_spaces", __file__])