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

343 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-29 10:17 +0000

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

2 

3from __future__ import annotations 

4import re 

5import ast 

6from enum import Enum 

7from pathlib import Path 

8 

9import ConfigSpace.conditions 

10import tabulate 

11import ConfigSpace 

12from ConfigSpace import ConfigurationSpace 

13from sparkle.tools.configspace import expression_to_configspace 

14 

15 

16class PCSConvention(Enum): 

17 """Internal pcs convention enum.""" 

18 

19 UNKNOWN = "UNKNOWN" 

20 SMAC = "smac" 

21 ParamILS = "paramils" 

22 IRACE = "irace" 

23 ConfigSpace = "configspace" 

24 

25 

26class PCSConverter: 

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

28 

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

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

31 

32 smac2_params_regex = re.compile( 

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

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

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

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

37 ) 

38 smac2_conditions_regex = re.compile( 

39 r"^(?P<parameter>[a-zA-Z0-9_]+)\s*\|\s*" 

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

41 ) 

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

43 

44 paramils_params_regex = re.compile( 

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

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

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

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

49 ) 

50 paramils_conditions_regex = re.compile( 

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

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

53 ) 

54 

55 irace_params_regex = re.compile( 

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

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

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

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

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

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

62 ) 

63 

64 @staticmethod 

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

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

67 try: 

68 ConfigSpace.ConfigurationSpace.from_yaml(file) 

69 return PCSConvention.ConfigSpace 

70 except Exception: 

71 pass 

72 try: 

73 ConfigSpace.ConfigurationSpace.from_json(file) 

74 return PCSConvention.ConfigSpace 

75 except Exception: 

76 pass 

77 

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

79 for line in file_contents: 

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

81 continue 

82 if "#" in line: 

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

84 line = line.strip() 

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

86 return PCSConvention.SMAC 

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

88 return PCSConvention.ParamILS 

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

90 return PCSConvention.IRACE 

91 return PCSConvention.UNKNOWN 

92 

93 @staticmethod 

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

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

96 if not convention: 

97 convention = PCSConverter.get_convention(file) 

98 if convention == PCSConvention.ConfigSpace: 

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

100 return ConfigSpace.ConfigurationSpace.from_yaml(file) 

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

102 return ConfigSpace.ConfigurationSpace.from_json(file) 

103 if convention == PCSConvention.SMAC: 

104 return PCSConverter.parse_smac(file) 

105 if convention == PCSConvention.ParamILS: 

106 return PCSConverter.parse_paramils(file) 

107 if convention == PCSConvention.IRACE: 

108 return PCSConverter.parse_irace(file) 

109 raise Exception( 

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

111 ) 

112 

113 @staticmethod 

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

115 """Parses a SMAC2 file.""" 

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

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

118 cs = ConfigurationSpace(space_name) 

119 for line in content: 

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

121 continue 

122 comment = None 

123 line = line.strip() 

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

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

126 name = parameter.group("name") 

127 parameter_type = parameter.group("type") 

128 values = parameter.group("values") 

129 default = parameter.group("default") 

130 comment = parameter.group("comment") 

131 scale = parameter.group("scale") 

132 if parameter_type == "integer": 

133 values = ast.literal_eval(values) 

134 csparam = ConfigSpace.UniformIntegerHyperparameter( 

135 name=name, 

136 lower=int(values[0]), 

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

138 default_value=int(default), 

139 log=scale == "log", 

140 meta=comment, 

141 ) 

142 elif parameter_type == "real": 

143 values = ast.literal_eval(values) 

144 csparam = ConfigSpace.UniformFloatHyperparameter( 

145 name=name, 

146 lower=float(values[0]), 

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

148 default_value=float(default), 

149 log=scale == "log", 

150 meta=comment, 

151 ) 

152 elif parameter_type == "categorical": 

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

154 csparam = ConfigSpace.CategoricalHyperparameter( 

155 name=name, 

156 choices=values, 

157 default_value=default, 

158 meta=comment, 

159 # Does not seem to contain any weights? 

160 ) 

161 elif parameter_type == "ordinal": 

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

163 csparam = ConfigSpace.OrdinalHyperparameter( 

164 name=name, 

165 sequence=values, 

166 default_value=default, 

167 meta=comment, 

168 ) 

169 cs.add(csparam) 

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

171 # Break up the expression into the smallest possible pieces 

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

173 parameter, condition = ( 

174 match.group("parameter"), 

175 match.group("expression"), 

176 ) 

177 parameter = cs[parameter.strip()] 

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

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

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

181 condition = expression_to_configspace( 

182 condition, cs, target_parameter=parameter 

183 ) 

184 cs.add(condition) 

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

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

187 forbidden = match.group("forbidden") 

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

189 # where expressions can contain: 

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

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

192 # Supported by SMAC2 but not by ConfigSpace?: 

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

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

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

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

197 rejected_operators = ( 

198 "+", 

199 "-", 

200 "*", 

201 "^", 

202 "%", 

203 "abs", 

204 "acos", 

205 "asin", 

206 "atan", 

207 "cbrt", 

208 "ceil", 

209 "cos", 

210 "cosh", 

211 "exp", 

212 "floor", 

213 "log", 

214 "log10", 

215 "log2", 

216 "sin", 

217 "sinh", 

218 "sqrt", 

219 "tan", 

220 "tanh", 

221 ) 

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

223 print( 

224 "WARNING: Arithmetic operators are not supported by " 

225 "ConfigurationSpace. Skipping forbidden expression:\n" 

226 f"{forbidden}" 

227 ) 

228 continue 

229 forbidden = ( 

230 forbidden.replace(" && ", " and ") 

231 .replace(", ", " and ") 

232 .replace(" || ", " or ") 

233 .strip() 

234 ) # To AST notation 

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

236 forbidden = expression_to_configspace(forbidden, cs) 

237 cs.add(forbidden) 

238 else: 

239 raise Exception(f"SMAC2 PCS expression not recognised on line:\n{line}") 

240 return cs 

241 

242 @staticmethod 

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

244 """Parses a paramils file.""" 

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

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

247 cs = ConfigurationSpace(name=space_name) 

248 conditions_lines = {} 

249 for line in content: 

250 line = line.strip() 

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

252 continue 

253 comment = None 

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

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

256 name = parameter.group("name") 

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

258 # ParamILS is flexible to which parameters are allowed. 

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

260 # expressions 

261 raise ValueError( 

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

263 "This is supported by ParamILS, but not by PCSConverter." 

264 ) 

265 values = parameter.group("values") 

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

267 try: 

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

269 values = sorted(values) 

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

271 parameter_type = float 

272 else: 

273 parameter_type = int 

274 except Exception: # of strings (Categorical) 

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

276 parameter_type = str 

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

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

279 parameter_type = str 

280 default = parameter.group("default") 

281 comment = parameter.group("comment") 

282 if parameter_type is int: 

283 csparam = ConfigSpace.UniformIntegerHyperparameter( 

284 name=name, 

285 lower=int(values[0]), 

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

287 default_value=int(default), 

288 meta=comment, 

289 ) 

290 elif parameter_type is float: 

291 csparam = ConfigSpace.UniformFloatHyperparameter( 

292 name=name, 

293 lower=float(values[0]), 

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

295 default_value=float(default), 

296 meta=comment, 

297 ) 

298 elif parameter_type is str: 

299 csparam = ConfigSpace.CategoricalHyperparameter( 

300 name=name, 

301 choices=values, 

302 default_value=default, 

303 meta=comment, 

304 ) 

305 cs.add(csparam) 

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

307 # Break up the expression into the smallest possible pieces 

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

309 parameter, condition = ( 

310 match.group("parameter").strip(), 

311 match.group("expression"), 

312 ) 

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

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

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

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

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

318 if parameter not in conditions_lines: 

319 conditions_lines[parameter] = condition 

320 else: 

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

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

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

324 forbidden = match.group("forbidden") 

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

326 # where expressions can contain: 

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

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

329 # Supported by SMAC2 but not by ConfigSpace?: 

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

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

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

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

334 rejected_operators = ( 

335 "+", 

336 "-", 

337 "*", 

338 "^", 

339 "%", 

340 "abs", 

341 "acos", 

342 "asin", 

343 "atan", 

344 "cbrt", 

345 "ceil", 

346 "cos", 

347 "cosh", 

348 "exp", 

349 "floor", 

350 "log", 

351 "log10", 

352 "log2", 

353 "sin", 

354 "sinh", 

355 "sqrt", 

356 "tan", 

357 "tanh", 

358 ) 

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

360 print( 

361 "WARNING: Arithmetic operators are not supported by " 

362 "ConfigurationSpace. Skipping forbidden expression:\n" 

363 f"{forbidden}" 

364 ) 

365 continue 

366 forbidden = ( 

367 forbidden.replace(" && ", " and ") 

368 .replace(", ", " and ") 

369 .replace(" || ", " or ") 

370 .strip() 

371 ) # To AST notation 

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

373 forbidden = expression_to_configspace(forbidden, cs) 

374 cs.add(forbidden) 

375 else: 

376 raise Exception( 

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

378 ) 

379 # Add the condition 

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

381 condition = expression_to_configspace(cond, cs, target_parameter=cs[pname]) 

382 cs.add(condition) 

383 return cs 

384 

385 @staticmethod 

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

387 """Parses a irace file.""" 

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

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

390 cs = ConfigurationSpace(name=space_name) 

391 standardised_conditions = [] 

392 forbidden_flag, global_flag = False, False 

393 for line in content: 

394 line = line.strip() 

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

396 continue 

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

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

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

400 forbidden_flag, global_flag = True, False 

401 continue 

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

403 global_flag, forbidden_flag = True, False 

404 continue 

405 else: 

406 raise Exception(f"IRACE PCS section not recognised on line:\n{line}") 

407 elif global_flag: # Parse global statements 

408 continue # We do not parse global group 

409 elif forbidden_flag: # Parse forbidden statements 

410 # Parse the forbidden statement to standardised format 

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

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

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

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

415 forbidden_expr = expression_to_configspace(forbidden_expr, cs) 

416 cs.add(forbidden_expr) 

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

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

419 name = parameter.group("name") 

420 parameter_type = parameter.group("type") 

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

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

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

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

425 scale = parameter.group("scale") 

426 conditions = parameter.group("conditions") 

427 comment = parameter.group("comment") 

428 # Convert categorical / ordinal values to strings 

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

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

431 else: 

432 if any( 

433 operator in values 

434 for operator in ["+", "-", "*", "/", "%", "min", "max"] 

435 ): 

436 raise ValueError( 

437 "Dependent parameter domains not supported by " 

438 "ConfigurationSpace." 

439 ) 

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

441 if parameter_type == "c": 

442 csparam = ConfigSpace.CategoricalHyperparameter( 

443 name=name, 

444 choices=values, 

445 meta=comment, 

446 ) 

447 elif parameter_type == "o": 

448 csparam = ConfigSpace.OrdinalHyperparameter( 

449 name=name, 

450 sequence=values, 

451 meta=comment, 

452 ) 

453 elif parameter_type == "r": 

454 csparam = ConfigSpace.UniformFloatHyperparameter( 

455 name=name, 

456 lower=float(lower_bound), 

457 upper=float(upper_bound), 

458 log=scale == "log", 

459 meta=comment, 

460 ) 

461 elif parameter_type == "i": 

462 csparam = ConfigSpace.UniformIntegerHyperparameter( 

463 name=name, 

464 lower=int(lower_bound), 

465 upper=int(upper_bound), 

466 log=scale == "log", 

467 meta=comment, 

468 ) 

469 cs.add(csparam) 

470 if conditions: 

471 # Convert the expression to standardised format 

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

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

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

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

476 conditions = conditions.strip() 

477 standardised_conditions.append((csparam, conditions)) 

478 else: 

479 raise Exception(f"IRACE PCS expression not recognised on line:\n{line}") 

480 

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

482 for csparam, conditions in standardised_conditions: 

483 conditions = expression_to_configspace( 

484 conditions, cs, target_parameter=csparam 

485 ) 

486 cs.add(conditions) 

487 return cs 

488 

489 @staticmethod 

490 def export( 

491 configspace: ConfigurationSpace, pcs_format: PCSConvention, file: Path 

492 ) -> str | None: 

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

494 

495 Args: 

496 configspace: ConfigurationSpace, the space to convert 

497 pcs_format: PCSConvention, the convention to conver to 

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

499 

500 Returns: 

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

502 """ 

503 # Create pcs table 

504 declaration = ( 

505 f"### {pcs_format.name} Parameter Configuration Space file " 

506 "generated by Sparkle\n" 

507 ) 

508 rows = [] 

509 extra_rows = [] 

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

511 import numpy as np 

512 

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

514 header = [ 

515 "# Parameter Name", 

516 "type", 

517 "values", 

518 "default value", 

519 "scale", 

520 "comments", 

521 ] 

522 parameter_map = { 

523 ConfigSpace.UniformFloatHyperparameter: "real", 

524 ConfigSpace.UniformIntegerHyperparameter: "integer", 

525 ConfigSpace.CategoricalHyperparameter: "categorical", 

526 ConfigSpace.OrdinalHyperparameter: "ordinal", 

527 } 

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

529 log = False 

530 if isinstance( 

531 parameter, ConfigSpace.hyperparameters.NumericalHyperparameter 

532 ): 

533 log = parameter.log 

534 if pcs_format == PCSConvention.ParamILS: # Discretise 

535 dtype = ( 

536 float 

537 if isinstance( 

538 parameter, ConfigSpace.UniformFloatHyperparameter 

539 ) 

540 else int 

541 ) 

542 if log: 

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

544 domain = list( 

545 np.unique( 

546 np.geomspace( 

547 lower, 

548 parameter.upper, 

549 granularity, 

550 dtype=dtype, 

551 ) 

552 ) 

553 ) 

554 else: 

555 domain = list( 

556 np.linspace( 

557 parameter.lower, 

558 parameter.upper, 

559 granularity, 

560 dtype=dtype, 

561 ) 

562 ) 

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

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

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

566 domain.sort() 

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

568 else: # SMAC2 takes ranges 

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

570 else: 

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

572 rows.append( 

573 [ 

574 parameter.name, 

575 parameter_map[type(parameter)] 

576 if not pcs_format == PCSConvention.ParamILS 

577 else "", 

578 domain, 

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

580 "log" 

581 if log and not pcs_format == PCSConvention.ParamILS 

582 else "", 

583 f"# {parameter.meta}", 

584 ] 

585 ) 

586 if configspace.conditions: 

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

588 for condition in configspace.conditions: 

589 condition_str = str(condition) 

590 condition_str = condition_str.replace("(", "").replace( 

591 ")", "" 

592 ) # Brackets not allowed 

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

594 condition_str = condition_str.replace( 

595 f"{condition.child.name} | ", "" 

596 ).strip() 

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

598 if pcs_format == PCSConvention.ParamILS and re.search( 

599 r"[<>!=]+|[<>]=|[!=]=", condition_str 

600 ): 

601 # TODO: Translate condition ParamILS expression (in) 

602 continue 

603 extra_rows.append(condition_str) 

604 if configspace.forbidden_clauses: 

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

606 for forbidden in configspace.forbidden_clauses: 

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

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

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

610 if pcs_format == PCSConvention.ParamILS and re.search( 

611 r"[<>!=]+|[<>]=|[!=]=", forbidden_str 

612 ): 

613 # TODO: Translate condition ParamILS expression (in) 

614 continue 

615 extra_rows.append(forbidden_str) 

616 elif pcs_format == PCSConvention.IRACE: 

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

618 parameter_map = { 

619 ConfigSpace.UniformFloatHyperparameter: "r", 

620 ConfigSpace.UniformIntegerHyperparameter: "i", 

621 ConfigSpace.CategoricalHyperparameter: "c", 

622 ConfigSpace.OrdinalHyperparameter: "o", 

623 } 

624 header = [ 

625 "# Parameter Name", 

626 "switch", 

627 "type", 

628 "values", 

629 "[conditions (using R syntax)]", 

630 "comments", 

631 ] 

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

633 parameter_conditions = [] 

634 for c in configspace.conditions: 

635 if c.child == parameter: 

636 parameter_conditions.append(c) 

637 parameter_type = parameter_map[type(parameter)] 

638 condition_type = ( 

639 parameter_type 

640 if type(parameter) is ConfigSpace.CategoricalHyperparameter 

641 else "" 

642 ) 

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

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

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

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

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

648 condition_str = condition_str.replace(" && ", " & ").replace( 

649 " || ", " | " 

650 ) 

651 if isinstance( 

652 parameter, ConfigSpace.hyperparameters.NumericalHyperparameter 

653 ): 

654 if parameter.log: 

655 parameter_type += ",log" 

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

657 if isinstance( 

658 parameter, ConfigSpace.hyperparameters.FloatHyperparameter 

659 ): 

660 # Format the floats to interpret the number of digits 

661 # (Includes scientific notation) 

662 lower, upper = ( 

663 format(parameter.lower, ".16f").strip("0"), 

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

665 ) 

666 param_digits = max( 

667 len(str(lower).split(".")[1]), 

668 len(str(upper).split(".")[1]), 

669 ) 

670 # Check if we need to update the global digits 

671 if param_digits > digits: 

672 digits = param_digits 

673 else: 

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

675 rows.append( 

676 [ 

677 parameter.name, 

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

679 parameter_type, 

680 domain, # Parameter range/domain 

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

682 f"# {parameter.meta}" if parameter.meta else "", 

683 ] 

684 ) 

685 if configspace.forbidden_clauses: 

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

687 for forbidden_expression in configspace.forbidden_clauses: 

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

689 if " in " in forbidden_str: 

690 type_char = parameter_map[ 

691 type(forbidden_expression.hyperparameter) 

692 ] 

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

694 forbidden_str = forbidden_str.replace(" && ", " & ").replace( 

695 " || ", " | " 

696 ) 

697 extra_rows.append(forbidden_str) 

698 if digits > 4: # Default digits is 4 

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

700 

701 output = ( 

702 declaration 

703 + tabulate.tabulate(rows, headers=header, tablefmt="plain", numalign="left") 

704 + "\n" 

705 ) 

706 if extra_rows: 

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

708 if file is None: 

709 return output 

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

711 

712 @staticmethod 

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

714 """Validate a pcs file.""" 

715 # TODO: Determine which format 

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

717 return