Coverage for mlos_bench/mlos_bench/optimizers/convert_configspace.py: 98%
88 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-01 00:52 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-01 00:52 +0000
1#
2# Copyright (c) Microsoft Corporation.
3# Licensed under the MIT License.
4#
5"""Functions to convert :py:class:`.TunableGroups` that :py:mod:`mlos_bench` uses to to
6:py:class:`ConfigSpace.ConfigurationSpace` for use with the
7:py:mod:`mlos_core.optimizers`.
8"""
10import enum
11import logging
12from collections.abc import Hashable
14from ConfigSpace import (
15 Beta,
16 CategoricalHyperparameter,
17 Configuration,
18 ConfigurationSpace,
19 EqualsCondition,
20 Float,
21 Integer,
22 Normal,
23 Uniform,
24)
25from ConfigSpace.hyperparameters import NumericalHyperparameter
26from ConfigSpace.types import NotSet
28from mlos_bench.tunables.tunable import Tunable
29from mlos_bench.tunables.tunable_groups import TunableGroups
30from mlos_bench.tunables.tunable_types import TunableValue
31from mlos_bench.util import try_parse_val
32from mlos_core.spaces.converters.util import (
33 QUANTIZATION_BINS_META_KEY,
34 monkey_patch_hp_quantization,
35)
37_LOG = logging.getLogger(__name__)
40class TunableValueKind(enum.Enum):
41 """Enum for the kind of the tunable value (special or not)."""
43 SPECIAL = "special"
44 RANGE = "range"
47def _normalize_weights(weights: list[float]) -> list[float]:
48 """Helper function for normalizing weights to probabilities."""
49 total = sum(weights)
50 return [w / total for w in weights]
53def _tunable_to_configspace(
54 tunable: Tunable,
55 group_name: str | None = None,
56 cost: int = 0,
57) -> ConfigurationSpace:
58 """
59 Convert a single Tunable to an equivalent set of ConfigSpace Hyperparameter objects,
60 wrapped in a ConfigurationSpace for composability.
61 Note: this may be more than one Hyperparameter in the case of special value handling.
63 Parameters
64 ----------
65 tunable : Tunable
66 An mlos_bench Tunable object.
67 group_name : str
68 Human-readable id of the CovariantTunableGroup this Tunable belongs to.
69 cost : int
70 Cost to change this parameter (comes from the corresponding CovariantTunableGroup).
72 Returns
73 -------
74 cs : ConfigSpace.ConfigurationSpace
75 A ConfigurationSpace object that corresponds to the Tunable.
76 """
77 # pylint: disable=too-complex
78 meta: dict[Hashable, TunableValue] = {"cost": cost}
79 if group_name is not None:
80 meta["group"] = group_name
81 if tunable.is_numerical and tunable.quantization_bins:
82 # Temporary workaround to dropped quantization support in ConfigSpace 1.0
83 # See Also: https://github.com/automl/ConfigSpace/issues/390
84 meta[QUANTIZATION_BINS_META_KEY] = tunable.quantization_bins
86 if tunable.type == "categorical":
87 return ConfigurationSpace(
88 {
89 tunable.name: CategoricalHyperparameter(
90 name=tunable.name,
91 choices=tunable.categories,
92 weights=_normalize_weights(tunable.weights) if tunable.weights else None,
93 default_value=tunable.default,
94 meta=meta,
95 )
96 }
97 )
99 distribution: Uniform | Normal | Beta | None = None
100 if tunable.distribution == "uniform":
101 distribution = Uniform()
102 elif tunable.distribution == "normal":
103 distribution = Normal(
104 mu=tunable.distribution_params["mu"],
105 sigma=tunable.distribution_params["sigma"],
106 )
107 elif tunable.distribution == "beta":
108 distribution = Beta(
109 alpha=tunable.distribution_params["alpha"],
110 beta=tunable.distribution_params["beta"],
111 )
112 elif tunable.distribution is not None:
113 raise TypeError(f"Invalid Distribution Type: {tunable.distribution}")
115 range_hp: NumericalHyperparameter
116 if tunable.type == "int":
117 range_hp = Integer(
118 name=tunable.name,
119 bounds=(int(tunable.range[0]), int(tunable.range[1])),
120 log=bool(tunable.is_log),
121 distribution=distribution,
122 default=(
123 int(tunable.default)
124 if tunable.in_range(tunable.default) and tunable.default is not None
125 else None
126 ),
127 meta=meta,
128 )
129 elif tunable.type == "float":
130 range_hp = Float(
131 name=tunable.name,
132 bounds=tunable.range,
133 log=bool(tunable.is_log),
134 distribution=distribution,
135 default=(
136 float(tunable.default)
137 if tunable.in_range(tunable.default) and tunable.default is not None
138 else None
139 ),
140 meta=meta,
141 )
142 else:
143 raise TypeError(f"Invalid Parameter Type: {tunable.type}")
145 monkey_patch_hp_quantization(range_hp)
146 if not tunable.special:
147 return ConfigurationSpace(space=[range_hp])
149 # Compute the probabilities of switching between regular and special values.
150 special_weights: list[float] | None = None
151 switch_weights = [0.5, 0.5] # FLAML requires uniform weights.
152 if tunable.weights and tunable.range_weight is not None:
153 special_weights = _normalize_weights(tunable.weights)
154 switch_weights = _normalize_weights([sum(tunable.weights), tunable.range_weight])
156 # Create three hyperparameters: one for regular values,
157 # one for special values, and one to choose between the two.
158 (special_name, type_name) = special_param_names(tunable.name)
159 conf_space = ConfigurationSpace(
160 space=[
161 range_hp,
162 CategoricalHyperparameter(
163 name=special_name,
164 choices=tunable.special,
165 weights=special_weights,
166 default_value=tunable.default if tunable.default in tunable.special else NotSet,
167 meta=meta,
168 ),
169 CategoricalHyperparameter(
170 name=type_name,
171 choices=[TunableValueKind.SPECIAL.value, TunableValueKind.RANGE.value],
172 weights=switch_weights,
173 default_value=TunableValueKind.SPECIAL.value,
174 ),
175 ]
176 )
177 conf_space.add(
178 [
179 EqualsCondition(
180 conf_space[special_name], conf_space[type_name], TunableValueKind.SPECIAL.value
181 ),
182 EqualsCondition(
183 conf_space[tunable.name], conf_space[type_name], TunableValueKind.RANGE.value
184 ),
185 ]
186 )
187 return conf_space
190def tunable_groups_to_configspace(
191 tunables: TunableGroups,
192 seed: int | None = None,
193) -> ConfigurationSpace:
194 """
195 Convert TunableGroups to hyperparameters in ConfigurationSpace.
197 Parameters
198 ----------
199 tunables : TunableGroups
200 A collection of tunable parameters.
202 seed : int | None
203 Random seed to use.
205 Returns
206 -------
207 configspace : ConfigSpace.ConfigurationSpace
208 A new ConfigurationSpace instance that corresponds to the input TunableGroups.
209 """
210 space = ConfigurationSpace(seed=seed)
211 for tunable, group in tunables:
212 space.add_configuration_space(
213 prefix="",
214 delimiter="",
215 configuration_space=_tunable_to_configspace(
216 tunable,
217 group.name,
218 group.get_current_cost(),
219 ),
220 )
221 return space
224def tunable_values_to_configuration(tunables: TunableGroups) -> Configuration:
225 """
226 Converts a TunableGroups current values to a ConfigSpace Configuration.
228 Parameters
229 ----------
230 tunables : TunableGroups
231 The TunableGroups to take the current value from.
233 Returns
234 -------
235 ConfigSpace.Configuration
236 A ConfigSpace Configuration.
237 """
238 values: dict[str, TunableValue] = {}
239 for tunable, _group in tunables:
240 if tunable.special:
241 (special_name, type_name) = special_param_names(tunable.name)
242 if tunable.value in tunable.special:
243 values[type_name] = TunableValueKind.SPECIAL.value
244 values[special_name] = tunable.value
245 else:
246 values[type_name] = TunableValueKind.RANGE.value
247 values[tunable.name] = tunable.value
248 else:
249 values[tunable.name] = tunable.value
250 configspace = tunable_groups_to_configspace(tunables)
251 return Configuration(configspace, values=values)
254def configspace_data_to_tunable_values(data: dict) -> dict[str, TunableValue]:
255 """
256 Remove the fields that correspond to special values in ConfigSpace.
258 In particular, remove and keys suffixes added by `special_param_names`.
259 """
260 data = data.copy()
261 specials = [special_param_name_strip(k) for k in data.keys() if special_param_name_is_temp(k)]
262 for k in specials:
263 (special_name, type_name) = special_param_names(k)
264 if data[type_name] == TunableValueKind.SPECIAL.value:
265 data[k] = data[special_name]
266 if special_name in data:
267 del data[special_name]
268 del data[type_name]
269 # May need to convert numpy values to regular types.
270 data = {k: try_parse_val(v) for k, v in data.items()}
271 return data
274def special_param_names(name: str) -> tuple[str, str]:
275 """
276 Generate the names of the auxiliary hyperparameters that correspond to a tunable
277 that can have special values.
279 NOTE: ``!`` characters are currently disallowed in :py:class:`.Tunable` names in
280 order handle this logic.
282 Parameters
283 ----------
284 name : str
285 The name of the tunable parameter.
287 Returns
288 -------
289 special_name : str
290 The name of the hyperparameter that corresponds to the special value.
291 type_name : str
292 The name of the hyperparameter that chooses between the regular and the special values.
293 """
294 return (name + "!special", name + "!type")
297def special_param_name_is_temp(name: str) -> bool:
298 """
299 Check if name corresponds to a temporary ConfigSpace parameter.
301 NOTE: ``!`` characters are currently disallowed in :py:class:`.Tunable` names in
302 order handle this logic.
304 Parameters
305 ----------
306 name : str
307 The name of the hyperparameter.
309 Returns
310 -------
311 is_special : bool
312 True if the name corresponds to a temporary ConfigSpace hyperparameter.
313 """
314 return name.endswith("!type")
317def special_param_name_strip(name: str) -> str:
318 """
319 Remove the temporary suffix from a special parameter name.
321 NOTE: ``!`` characters are currently disallowed in :py:class:`.Tunable` names in
322 order handle this logic.
324 Parameters
325 ----------
326 name : str
327 The name of the hyperparameter.
329 Returns
330 -------
331 stripped_name : str
332 The name of the hyperparameter without the temporary suffix.
333 """
334 return name.split("!", 1)[0]