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

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""" 

9 

10import enum 

11import logging 

12from collections.abc import Hashable 

13 

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 

27 

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) 

36 

37_LOG = logging.getLogger(__name__) 

38 

39 

40class TunableValueKind(enum.Enum): 

41 """Enum for the kind of the tunable value (special or not).""" 

42 

43 SPECIAL = "special" 

44 RANGE = "range" 

45 

46 

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] 

51 

52 

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. 

62 

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). 

71 

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 

85 

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 ) 

98 

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}") 

114 

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}") 

144 

145 monkey_patch_hp_quantization(range_hp) 

146 if not tunable.special: 

147 return ConfigurationSpace(space=[range_hp]) 

148 

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]) 

155 

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 

188 

189 

190def tunable_groups_to_configspace( 

191 tunables: TunableGroups, 

192 seed: int | None = None, 

193) -> ConfigurationSpace: 

194 """ 

195 Convert TunableGroups to hyperparameters in ConfigurationSpace. 

196 

197 Parameters 

198 ---------- 

199 tunables : TunableGroups 

200 A collection of tunable parameters. 

201 

202 seed : int | None 

203 Random seed to use. 

204 

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 

222 

223 

224def tunable_values_to_configuration(tunables: TunableGroups) -> Configuration: 

225 """ 

226 Converts a TunableGroups current values to a ConfigSpace Configuration. 

227 

228 Parameters 

229 ---------- 

230 tunables : TunableGroups 

231 The TunableGroups to take the current value from. 

232 

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) 

252 

253 

254def configspace_data_to_tunable_values(data: dict) -> dict[str, TunableValue]: 

255 """ 

256 Remove the fields that correspond to special values in ConfigSpace. 

257 

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 

272 

273 

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. 

278 

279 NOTE: ``!`` characters are currently disallowed in :py:class:`.Tunable` names in 

280 order handle this logic. 

281 

282 Parameters 

283 ---------- 

284 name : str 

285 The name of the tunable parameter. 

286 

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") 

295 

296 

297def special_param_name_is_temp(name: str) -> bool: 

298 """ 

299 Check if name corresponds to a temporary ConfigSpace parameter. 

300 

301 NOTE: ``!`` characters are currently disallowed in :py:class:`.Tunable` names in 

302 order handle this logic. 

303 

304 Parameters 

305 ---------- 

306 name : str 

307 The name of the hyperparameter. 

308 

309 Returns 

310 ------- 

311 is_special : bool 

312 True if the name corresponds to a temporary ConfigSpace hyperparameter. 

313 """ 

314 return name.endswith("!type") 

315 

316 

317def special_param_name_strip(name: str) -> str: 

318 """ 

319 Remove the temporary suffix from a special parameter name. 

320 

321 NOTE: ``!`` characters are currently disallowed in :py:class:`.Tunable` names in 

322 order handle this logic. 

323 

324 Parameters 

325 ---------- 

326 name : str 

327 The name of the hyperparameter. 

328 

329 Returns 

330 ------- 

331 stripped_name : str 

332 The name of the hyperparameter without the temporary suffix. 

333 """ 

334 return name.split("!", 1)[0]