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