Coverage for sparkle/tools/parameters.py: 94%

343 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-03 10:42 +0000

1"""Parameter Configuration Space tools.""" 

2from __future__ import annotations 

3import re 

4import ast 

5from enum import Enum 

6from pathlib import Path 

7 

8import ConfigSpace.conditions 

9import tabulate 

10import ConfigSpace 

11from ConfigSpace import ConfigurationSpace 

12from sparkle.tools.configspace import expression_to_configspace 

13 

14 

15class PCSConvention(Enum): 

16 """Internal pcs convention enum.""" 

17 UNKNOWN = "UNKNOWN" 

18 SMAC = "smac" 

19 ParamILS = "paramils" 

20 IRACE = "irace" 

21 ConfigSpace = "configspace" 

22 

23 

24class PCSConverter: 

25 """Parser class independent file of notation.""" 

26 section_regex = re.compile(r"\[(?P<name>[a-zA-Z]+?)\]\s*(?P<comment>#.*)?$") 

27 illegal_param_name = re.compile(r"[!:\-+@!#$%^&*()=<>?/\|~` ]") 

28 

29 smac2_params_regex = re.compile(r"^(?P<name>[a-zA-Z0-9_]+)\s+(?P<type>[a-zA-Z]+)\s+" 

30 r"(?P<values>[a-zA-Z0-9\-\[\]{}_,. ]+)\s*" 

31 r"\[(?P<default>[a-zA-Z0-9._-]+)\]?\s*" 

32 r"(?P<scale>log)?\s*(?P<comment>#.*)?$") 

33 smac2_conditions_regex = re.compile(r"^(?P<parameter>[a-zA-Z0-9_]+)\s*\|\s*" 

34 r"(?P<expression>.+)$") 

35 smac2_forbidden_regex = re.compile(r"\{(?P<forbidden>.+)\}$") 

36 

37 paramils_params_regex = re.compile( 

38 r"^(?P<name>[a-zA-Z0-9@!#:_-]+)\s*" 

39 r"(?P<values>{[a-zA-Z0-9._+\-\, ]+})\s*" 

40 r"\[(?P<default>[a-zA-Z0-9._\-+ ]+)\]?\s*" 

41 r"(?P<comment>#.*)?$") 

42 paramils_conditions_regex = re.compile(r"^(?P<parameter>[a-zA-Z0-9@!#:_-]+)\s*\|\s*" 

43 r"(?P<expression>.+)$") 

44 

45 irace_params_regex = re.compile( 

46 r"^(?P<name>[a-zA-Z0-9_]+)\s+" 

47 r"(?P<switch>[\"a-zA-Z0-9_\- ]+)\s+" 

48 r"(?P<type>[cior])(?:,)?(?P<scale>log)?\s+" 

49 r"(?P<values>[a-zA-Z0-9\-()_,. ]+)\s*" 

50 r"(?:\|)?(?P<conditions>[a-zA-Z-0-9_!=<>\%()\&\|\. ]*)?\s*" 

51 r"(?P<comment>#.*)?$") 

52 

53 @staticmethod 

54 def get_convention(file: Path) -> PCSConvention: 

55 """Determines the format of a pcs file.""" 

56 try: 

57 ConfigSpace.ConfigurationSpace.from_yaml(file) 

58 return PCSConvention.ConfigSpace 

59 except Exception: 

60 pass 

61 try: 

62 ConfigSpace.ConfigurationSpace.from_json(file) 

63 return PCSConvention.ConfigSpace 

64 except Exception: 

65 pass 

66 

67 file_contents = file.open().readlines() 

68 for line in file_contents: 

69 if line.startswith("#"): # Comment line 

70 continue 

71 if "#" in line: 

72 line, _ = line.split("#", maxsplit=1) 

73 line = line.strip() 

74 if re.match(PCSConverter.smac2_params_regex, line): 

75 return PCSConvention.SMAC 

76 elif re.match(PCSConverter.paramils_params_regex, line): 

77 return PCSConvention.ParamILS 

78 elif re.match(PCSConverter.irace_params_regex, line): 

79 return PCSConvention.IRACE 

80 return PCSConvention.UNKNOWN 

81 

82 @staticmethod 

83 def parse(file: Path, convention: PCSConvention = None) -> ConfigurationSpace: 

84 """Determines the format of a pcs file and parses into Configuration Space.""" 

85 if not convention: 

86 convention = PCSConverter.get_convention(file) 

87 if convention == PCSConvention.ConfigSpace: 

88 if file.suffix == ".yaml": 

89 return ConfigSpace.ConfigurationSpace.from_yaml(file) 

90 if file.suffix == ".json": 

91 return ConfigSpace.ConfigurationSpace.from_json(file) 

92 if convention == PCSConvention.SMAC: 

93 return PCSConverter.parse_smac(file) 

94 if convention == PCSConvention.ParamILS: 

95 return PCSConverter.parse_paramils(file) 

96 if convention == PCSConvention.IRACE: 

97 return PCSConverter.parse_irace(file) 

98 raise Exception( 

99 f"PCS convention not recognised based on any lines in file:\n{file}") 

100 

101 @staticmethod 

102 def parse_smac(content: list[str] | Path) -> ConfigurationSpace: 

103 """Parses a SMAC2 file.""" 

104 space_name = content.name if isinstance(content, Path) else None 

105 content = content.open().readlines() if isinstance(content, Path) else content 

106 cs = ConfigurationSpace(space_name) 

107 for line in content: 

108 if not line.strip() or line.startswith("#"): # Empty or comment 

109 continue 

110 comment = None 

111 line = line.strip() 

112 if re.match(PCSConverter.smac2_params_regex, line): 

113 parameter = re.fullmatch(PCSConverter.smac2_params_regex, line) 

114 name = parameter.group("name") 

115 parameter_type = parameter.group("type") 

116 values = parameter.group("values") 

117 default = parameter.group("default") 

118 comment = parameter.group("comment") 

119 scale = parameter.group("scale") 

120 if parameter_type == "integer": 

121 values = ast.literal_eval(values) 

122 csparam = ConfigSpace.UniformIntegerHyperparameter( 

123 name=name, 

124 lower=int(values[0]), 

125 upper=int(values[-1]), 

126 default_value=int(default), 

127 log=scale == "log", 

128 meta=comment, 

129 ) 

130 elif parameter_type == "real": 

131 values = ast.literal_eval(values) 

132 csparam = ConfigSpace.UniformFloatHyperparameter( 

133 name=name, 

134 lower=float(values[0]), 

135 upper=float(values[-1]), 

136 default_value=float(default), 

137 log=scale == "log", 

138 meta=comment, 

139 ) 

140 elif parameter_type == "categorical": 

141 values = re.sub(r"[{}\s]+", "", values).split(",") 

142 csparam = ConfigSpace.CategoricalHyperparameter( 

143 name=name, 

144 choices=values, 

145 default_value=default, 

146 meta=comment, 

147 # Does not seem to contain any weights? 

148 ) 

149 elif parameter_type == "ordinal": 

150 values = re.sub(r"[{}\s]+", "", values).split(",") 

151 csparam = ConfigSpace.OrdinalHyperparameter( 

152 name=name, 

153 sequence=values, 

154 default_value=default, 

155 meta=comment, 

156 ) 

157 cs.add(csparam) 

158 elif re.match(PCSConverter.smac2_conditions_regex, line): 

159 # Break up the expression into the smallest possible pieces 

160 match = re.fullmatch(PCSConverter.smac2_conditions_regex, line) 

161 parameter, condition =\ 

162 match.group("parameter"), match.group("expression") 

163 parameter = cs[parameter.strip()] 

164 condition = condition.replace(" || ", " or ").replace(" && ", " and ") 

165 condition = re.sub(r"(?<![<>!=])=(?<![=])", "==", condition) 

166 condition = re.sub(r"!==", "!=", condition) 

167 condition = expression_to_configspace(condition, cs, 

168 target_parameter=parameter) 

169 cs.add(condition) 

170 elif re.match(PCSConverter.smac2_forbidden_regex, line): 

171 match = re.fullmatch(PCSConverter.smac2_forbidden_regex, line) 

172 forbidden = match.group("forbidden") 

173 # Forbidden expressions structure <expression> <operator> <value> 

174 # where expressions can contain: 

175 # Logical Operators: >=, <=, >, <, ==, !=, 

176 # Logical clause operators: ( ), ||, &&, 

177 # Supported by SMAC2 but not by ConfigSpace?: 

178 # Arithmetic Operators: +, -, *, ^, % 

179 # Functions: abs, acos, asin, atan, cbrt, ceil, cos, cosh, exp, floor, 

180 # log, log10, log2, sin, sinh, sqrt, tan, tanh 

181 # NOTE: According to MA & JR, these were never actually supported 

182 rejected_operators = ("+", "-", "*", "^", "%", 

183 "abs", "acos", "asin", "atan", "cbrt", "ceil", 

184 "cos", "cosh", "exp", "floor", "log", "log10", 

185 "log2", "sin", "sinh", "sqrt", "tan", "tanh") 

186 if any([r in forbidden.split(" ") for r in rejected_operators]): 

187 print("WARNING: Arithmetic operators are not supported by " 

188 "ConfigurationSpace. Skipping forbidden expression:\n" 

189 f"{forbidden}") 

190 continue 

191 forbidden = forbidden.replace(" && ", " and ").replace( 

192 ", ", " and ").replace(" || ", " or ").strip() # To AST notation 

193 forbidden = re.sub(r"(?<![<>!=])=(?![=])", "==", forbidden) 

194 forbidden = expression_to_configspace(forbidden, cs) 

195 cs.add(forbidden) 

196 else: 

197 raise Exception( 

198 f"SMAC2 PCS expression not recognised on line:\n{line}") 

199 return cs 

200 

201 @staticmethod 

202 def parse_paramils(content: list[str] | Path) -> ConfigurationSpace: 

203 """Parses a paramils file.""" 

204 space_name = content.name if isinstance(content, Path) else None 

205 content = content.open().readlines() if isinstance(content, Path) else content 

206 cs = ConfigurationSpace(name=space_name) 

207 conditions_lines = {} 

208 for line in content: 

209 line = line.strip() 

210 if not line or line.startswith("#"): # Empty or comment 

211 continue 

212 comment = None 

213 if re.match(PCSConverter.paramils_params_regex, line): 

214 parameter = re.fullmatch(PCSConverter.paramils_params_regex, line) 

215 name = parameter.group("name") 

216 if re.match(PCSConverter.illegal_param_name, name): 

217 # ParamILS is flexible to which parameters are allowed. 

218 # We do not allow it as it creates many problems with parsing 

219 # expressions 

220 raise ValueError( 

221 f"ParamILS parameter name not allowed: {name}. " 

222 "This is supported by ParamILS, but not by PCSConverter.") 

223 values = parameter.group("values") 

224 values = values.replace("..", ",") # Replace automatic expansion 

225 try: 

226 values = list(ast.literal_eval(values)) # Values are sets 

227 values = sorted(values) 

228 if any([isinstance(v, float) for v in values]): 

229 parameter_type = float 

230 else: 

231 parameter_type = int 

232 except Exception: # of strings (Categorical) 

233 values = values.replace("{", "").replace("}", "").split(",") 

234 parameter_type = str 

235 if len(values) == 1: # Not allowed by ConfigSpace for int / float 

236 values = [str(values[0])] 

237 parameter_type = str 

238 default = parameter.group("default") 

239 comment = parameter.group("comment") 

240 if parameter_type == int: 

241 csparam = ConfigSpace.UniformIntegerHyperparameter( 

242 name=name, 

243 lower=int(values[0]), 

244 upper=int(values[-1]), 

245 default_value=int(default), 

246 meta=comment, 

247 ) 

248 elif parameter_type == float: 

249 csparam = ConfigSpace.UniformFloatHyperparameter( 

250 name=name, 

251 lower=float(values[0]), 

252 upper=float(values[-1]), 

253 default_value=float(default), 

254 meta=comment, 

255 ) 

256 elif parameter_type == str: 

257 csparam = ConfigSpace.CategoricalHyperparameter( 

258 name=name, 

259 choices=values, 

260 default_value=default, 

261 meta=comment, 

262 ) 

263 cs.add(csparam) 

264 elif re.match(PCSConverter.paramils_conditions_regex, line): 

265 # Break up the expression into the smallest possible pieces 

266 match = re.fullmatch(PCSConverter.paramils_conditions_regex, line) 

267 parameter, condition =\ 

268 match.group("parameter").strip(), match.group("expression") 

269 condition = condition.replace(" || ", " or ").replace(" && ", " and ") 

270 condition = re.sub(r"(?<![<>!=])=(?<![=])", "==", condition) 

271 condition = re.sub(r"!==", "!=", condition) 

272 # ParamILS supports multiple lines of conditions for a single parameter 

273 # so we collect, with the AND operator and parse + add them later 

274 if parameter not in conditions_lines: 

275 conditions_lines[parameter] = condition 

276 else: 

277 conditions_lines[parameter] += " and " + condition 

278 elif re.match(PCSConverter.smac2_forbidden_regex, line): 

279 match = re.fullmatch(PCSConverter.smac2_forbidden_regex, line) 

280 forbidden = match.group("forbidden") 

281 # Forbidden expressions structure <expression> <operator> <value> 

282 # where expressions can contain: 

283 # Logical Operators: >=, <=, >, <, ==, !=, 

284 # Logical clause operators: ( ), ||, &&, 

285 # Supported by SMAC2 but not by ConfigSpace?: 

286 # Arithmetic Operators: +, -, *, ^, % 

287 # Functions: abs, acos, asin, atan, cbrt, ceil, cos, cosh, exp, floor, 

288 # log, log10, log2, sin, sinh, sqrt, tan, tanh 

289 # NOTE: According to MA & JR, these were never actually supported 

290 rejected_operators = ("+", "-", "*", "^", "%", 

291 "abs", "acos", "asin", "atan", "cbrt", "ceil", 

292 "cos", "cosh", "exp", "floor", "log", "log10", 

293 "log2", "sin", "sinh", "sqrt", "tan", "tanh") 

294 if any([r in forbidden.split(" ") for r in rejected_operators]): 

295 print("WARNING: Arithmetic operators are not supported by " 

296 "ConfigurationSpace. Skipping forbidden expression:\n" 

297 f"{forbidden}") 

298 continue 

299 forbidden = forbidden.replace(" && ", " and ").replace( 

300 ", ", " and ").replace(" || ", " or ").strip() # To AST notation 

301 forbidden = re.sub(r"(?<![<>!=])=(?![=])", "==", forbidden) 

302 forbidden = expression_to_configspace(forbidden, cs) 

303 cs.add(forbidden) 

304 else: 

305 raise Exception( 

306 f"ParamILS PCS expression not recognised on line: {line}") 

307 # Add the condition 

308 for pname, cond in conditions_lines.items(): # Add conditions 

309 condition = expression_to_configspace(cond, cs, 

310 target_parameter=cs[pname]) 

311 cs.add(condition) 

312 return cs 

313 

314 @staticmethod 

315 def parse_irace(content: list[str] | Path) -> ConfigurationSpace: 

316 """Parses a irace file.""" 

317 space_name = content.name if isinstance(content, Path) else None 

318 content = content.open().readlines() if isinstance(content, Path) else content 

319 cs = ConfigurationSpace(name=space_name) 

320 standardised_conditions = [] 

321 forbidden_flag, global_flag = False, False 

322 for line in content: 

323 line = line.strip() 

324 if not line or line.startswith("#"): # Empty or comment 

325 continue 

326 if re.match(PCSConverter.section_regex, line): 

327 section = re.fullmatch(PCSConverter.section_regex, line) 

328 if section.group("name") == "forbidden": 

329 forbidden_flag, global_flag = True, False 

330 continue 

331 elif section.group("name") == "global": 

332 global_flag, forbidden_flag = True, False 

333 continue 

334 else: 

335 raise Exception( 

336 f"IRACE PCS section not recognised on line:\n{line}") 

337 elif global_flag: # Parse global statements 

338 continue # We do not parse global group 

339 elif forbidden_flag: # Parse forbidden statements 

340 # Parse the forbidden statement to standardised format 

341 forbidden_expr = re.sub(r" \& ", " and ", line) 

342 forbidden_expr = re.sub(r" \| ", " or ", forbidden_expr) 

343 forbidden_expr = re.sub(r" \%in\% ", " in ", forbidden_expr) 

344 forbidden_expr = re.sub(r" [co]\(", " (", forbidden_expr) 

345 forbidden_expr = expression_to_configspace(forbidden_expr, cs) 

346 cs.add(forbidden_expr) 

347 elif re.match(PCSConverter.irace_params_regex, line): 

348 parameter = re.fullmatch(PCSConverter.irace_params_regex, line) 

349 name = parameter.group("name") 

350 parameter_type = parameter.group("type") 

351 # NOTE: IRACE supports depedent parameter domains, e.g. parameters which 

352 # domain relies on another parameter: p2 "--p2" r ("p1", "p1 + 10")" 

353 # and is limited to the operators: +,-, *, /, %%, min, max 

354 values = ast.literal_eval(parameter.group("values")) 

355 scale = parameter.group("scale") 

356 conditions = parameter.group("conditions") 

357 comment = parameter.group("comment") 

358 # Convert categorical / ordinal values to strings 

359 if parameter_type == "c" or parameter_type == "o": 

360 values = [str(i) for i in values] 

361 else: 

362 if any(operator in values 

363 for operator in ["+", "-", "*", "/", "%", "min", "max"]): 

364 raise ValueError("Dependent parameter domains not supported by " 

365 "ConfigurationSpace.") 

366 lower_bound, upper_bound = values[0], values[1] 

367 if parameter_type == "c": 

368 csparam = ConfigSpace.CategoricalHyperparameter( 

369 name=name, 

370 choices=values, 

371 meta=comment, 

372 ) 

373 elif parameter_type == "o": 

374 csparam = ConfigSpace.OrdinalHyperparameter( 

375 name=name, 

376 sequence=values, 

377 meta=comment, 

378 ) 

379 elif parameter_type == "r": 

380 csparam = ConfigSpace.UniformFloatHyperparameter( 

381 name=name, 

382 lower=float(lower_bound), 

383 upper=float(upper_bound), 

384 log=scale == "log", 

385 meta=comment, 

386 ) 

387 elif parameter_type == "i": 

388 csparam = ConfigSpace.UniformIntegerHyperparameter( 

389 name=name, 

390 lower=int(lower_bound), 

391 upper=int(upper_bound), 

392 log=scale == "log", 

393 meta=comment, 

394 ) 

395 cs.add(csparam) 

396 if conditions: 

397 # Convert the expression to standardised format 

398 conditions = re.sub(r" \& ", " and ", conditions) 

399 conditions = re.sub(r" \| ", " or ", conditions) 

400 conditions = re.sub(r" \%in\% ", " in ", conditions) 

401 conditions = re.sub(r" [cior]\(", " (", conditions) 

402 conditions = conditions.strip() 

403 standardised_conditions.append((csparam, conditions)) 

404 else: 

405 raise Exception( 

406 f"IRACE PCS expression not recognised on line:\n{line}") 

407 

408 # We can only add the conditions after all parameters have been parsed: 

409 for csparam, conditions in standardised_conditions: 

410 conditions = expression_to_configspace(conditions, cs, 

411 target_parameter=csparam) 

412 cs.add(conditions) 

413 return cs 

414 

415 @staticmethod 

416 def export(configspace: ConfigurationSpace, 

417 pcs_format: PCSConvention, file: Path) -> str | None: 

418 """Exports a config space object to a specific PCS convention. 

419 

420 Args: 

421 configspace: ConfigurationSpace, the space to convert 

422 pcs_format: PCSConvention, the convention to conver to 

423 file: Path, the file to write to. If None, will return string. 

424 

425 Returns: 

426 String in case of no file path given, otherwise None. 

427 """ 

428 # Create pcs table 

429 declaration = f"### {pcs_format.name} Parameter Configuration Space file "\ 

430 "generated by Sparkle\n" 

431 rows = [] 

432 extra_rows = [] 

433 if pcs_format == PCSConvention.SMAC or pcs_format == PCSConvention.ParamILS: 

434 import numpy as np 

435 granularity = 20 # For ParamILS. TODO: Make it parametrisable 

436 header = ["# Parameter Name", "type", "values", 

437 "default value", "scale", "comments"] 

438 parameter_map = { 

439 ConfigSpace.UniformFloatHyperparameter: "real", 

440 ConfigSpace.UniformIntegerHyperparameter: "integer", 

441 ConfigSpace.CategoricalHyperparameter: "categorical", 

442 ConfigSpace.OrdinalHyperparameter: "ordinal", 

443 } 

444 for parameter in list(configspace.values()): 

445 log = False 

446 if isinstance(parameter, 

447 ConfigSpace.hyperparameters.NumericalHyperparameter): 

448 log = parameter.log 

449 if pcs_format == PCSConvention.ParamILS: # Discretise 

450 dtype = float if isinstance( 

451 parameter, ConfigSpace.UniformFloatHyperparameter) else int 

452 if log: 

453 lower = 1e-5 if parameter.lower == 0 else parameter.lower 

454 domain = list(np.unique(np.geomspace( 

455 lower, parameter.upper, granularity, 

456 dtype=dtype))) 

457 else: 

458 domain = list(np.linspace(parameter.lower, parameter.upper, 

459 granularity, dtype=dtype)) 

460 if dtype(parameter.default_value) not in domain: # Add default 

461 domain += [dtype(parameter.default_value)] 

462 domain = list(set(domain)) # Ensure unique values only 

463 domain.sort() 

464 domain = "{" + ",".join([str(i) for i in domain]) + "}" 

465 else: # SMAC2 takes ranges 

466 domain = f"[{parameter.lower}, {parameter.upper}]" 

467 else: 

468 domain = "{" + ",".join(parameter.choices) + "}" 

469 rows.append([parameter.name, 

470 parameter_map[type(parameter)] 

471 if not pcs_format == PCSConvention.ParamILS else "", 

472 domain, 

473 f"[{parameter.default_value}]", 

474 "log" 

475 if log and not pcs_format == PCSConvention.ParamILS else "", 

476 f"# {parameter.meta}"]) 

477 if configspace.conditions: 

478 extra_rows.extend(["", "# Parameter Conditions"]) 

479 for condition in configspace.conditions: 

480 condition_str = str(condition) 

481 condition_str = condition_str.replace( 

482 "(", "").replace(")", "") # Brackets not allowed 

483 condition_str = condition_str.replace("'", "") # No quotes needed 

484 condition_str = condition_str.replace( 

485 f"{condition.child.name} | ", "").strip() 

486 condition_str = f"{condition.child.name} | " + condition_str 

487 if (pcs_format == PCSConvention.ParamILS 

488 and re.search(r"[<>!=]+|[<>]=|[!=]=", condition_str)): 

489 # TODO: Translate condition ParamILS expression (in) 

490 continue 

491 extra_rows.append(condition_str) 

492 if configspace.forbidden_clauses: 

493 extra_rows.extend(["", "# Forbidden Expressions"]) 

494 for forbidden in configspace.forbidden_clauses: 

495 forbidden_str = str(forbidden).replace("Forbidden: ", "") 

496 forbidden_str = forbidden_str.replace("(", "{").replace(")", "}") 

497 forbidden_str = forbidden_str.replace("'", "") 

498 if (pcs_format == PCSConvention.ParamILS 

499 and re.search(r"[<>!=]+|[<>]=|[!=]=", forbidden_str)): 

500 # TODO: Translate condition ParamILS expression (in) 

501 continue 

502 extra_rows.append(forbidden_str) 

503 elif pcs_format == PCSConvention.IRACE: 

504 digits = 4 # Number of digits after decimal point required 

505 parameter_map = { 

506 ConfigSpace.UniformFloatHyperparameter: "r", 

507 ConfigSpace.UniformIntegerHyperparameter: "i", 

508 ConfigSpace.CategoricalHyperparameter: "c", 

509 ConfigSpace.OrdinalHyperparameter: "o", 

510 } 

511 header = ["# Parameter Name", "switch", "type", "values", 

512 "[conditions (using R syntax)]", "comments"] 

513 for parameter in list(configspace.values()): 

514 parameter_conditions = [] 

515 for c in configspace.conditions: 

516 if c.child == parameter: 

517 parameter_conditions.append(c) 

518 parameter_type = parameter_map[type(parameter)] 

519 condition_type = parameter_type if type( 

520 parameter) == ConfigSpace.CategoricalHyperparameter else "" 

521 condition_str = " || ".join([str(c) for c in parameter_conditions]) 

522 condition_str = condition_str.replace(f"{parameter.name} | ", "") 

523 condition_str = condition_str.replace(" in ", f" %in% {condition_type}") 

524 condition_str = condition_str.replace("{", "(").replace("}", ")") 

525 condition_str = condition_str.replace("'", "") # No quotes around string 

526 condition_str = condition_str.replace( 

527 " && ", " & ").replace(" || ", " | ") 

528 if isinstance(parameter, 

529 ConfigSpace.hyperparameters.NumericalHyperparameter): 

530 if parameter.log: 

531 parameter_type += ",log" 

532 domain = f"({parameter.lower}, {parameter.upper})" 

533 if isinstance(parameter, 

534 ConfigSpace.hyperparameters.FloatHyperparameter): 

535 # Format the floats to interpret the number of digits 

536 # (Includes scientific notation) 

537 lower, upper = (format(parameter.lower, ".16f").strip("0"), 

538 format(parameter.upper, ".16f").strip("0")) 

539 param_digits = max(len(str(lower).split(".")[1]), 

540 len(str(upper).split(".")[1])) 

541 # Check if we need to update the global digits 

542 if param_digits > digits: 

543 digits = param_digits 

544 else: 

545 domain = "(" + ",".join(parameter.choices) + ")" 

546 rows.append([parameter.name, 

547 f'"--{parameter.name} "', 

548 parameter_type, 

549 domain, # Parameter range/domain 

550 f"| {condition_str}" if condition_str else "", 

551 f"# {parameter.meta}" if parameter.meta else ""]) 

552 if configspace.forbidden_clauses: 

553 extra_rows.extend(["", "[forbidden]"]) 

554 for forbidden_expression in configspace.forbidden_clauses: 

555 forbidden_str = str(forbidden_expression).replace("Forbidden: ", "") 

556 if " in " in forbidden_str: 

557 type_char = parameter_map[ 

558 type(forbidden_expression.hyperparameter)] 

559 forbidden_str.replace(" in ", f" %in% {type_char}") 

560 forbidden_str = forbidden_str.replace( 

561 " && ", " & ").replace(" || ", " | ") 

562 extra_rows.append(forbidden_str) 

563 if digits > 4: # Default digits is 4 

564 extra_rows.extend(["", "[global]", f"digits={digits}"]) 

565 

566 output = declaration + tabulate.tabulate( 

567 rows, headers=header, 

568 tablefmt="plain", numalign="left") + "\n" 

569 if extra_rows: 

570 output += "\n".join(extra_rows) + "\n" 

571 if file is None: 

572 return output 

573 file.open("w+").write(output) 

574 

575 @staticmethod 

576 def validate(file_path: Path) -> bool: 

577 """Validate a pcs file.""" 

578 # TODO: Determine which format 

579 # TODO: Verify each line, and the order in which they were written 

580 return