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

360 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-21 15:31 +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 try: 

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

79 except Exception: 

80 return PCSConvention.UNKNOWN 

81 for line in file_contents: 

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

83 continue 

84 if "#" in line: 

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

86 line = line.strip() 

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

88 return PCSConvention.SMAC 

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

90 return PCSConvention.ParamILS 

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

92 return PCSConvention.IRACE 

93 return PCSConvention.UNKNOWN 

94 

95 @staticmethod 

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

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

98 if not convention: 

99 convention = PCSConverter.get_convention(file) 

100 if convention == PCSConvention.ConfigSpace: 

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

102 return ConfigSpace.ConfigurationSpace.from_yaml(file) 

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

104 return ConfigSpace.ConfigurationSpace.from_json(file) 

105 if convention == PCSConvention.SMAC: 

106 return PCSConverter.parse_smac(file) 

107 if convention == PCSConvention.ParamILS: 

108 return PCSConverter.parse_paramils(file) 

109 if convention == PCSConvention.IRACE: 

110 return PCSConverter.parse_irace(file) 

111 raise Exception( 

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

113 ) 

114 

115 @staticmethod 

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

117 """Parses a SMAC2 file.""" 

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

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

120 cs = ConfigurationSpace(space_name) 

121 for line in content: 

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

123 continue 

124 comment = None 

125 line = line.strip() 

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

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

128 name = parameter.group("name") 

129 parameter_type = parameter.group("type") 

130 values = parameter.group("values") 

131 default = parameter.group("default") 

132 comment = parameter.group("comment") 

133 scale = parameter.group("scale") 

134 if parameter_type == "integer": 

135 values = ast.literal_eval(values) 

136 csparam = ConfigSpace.UniformIntegerHyperparameter( 

137 name=name, 

138 lower=int(values[0]), 

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

140 default_value=int(default), 

141 log=scale == "log", 

142 meta=comment, 

143 ) 

144 elif parameter_type == "real": 

145 values = ast.literal_eval(values) 

146 csparam = ConfigSpace.UniformFloatHyperparameter( 

147 name=name, 

148 lower=float(values[0]), 

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

150 default_value=float(default), 

151 log=scale == "log", 

152 meta=comment, 

153 ) 

154 elif parameter_type == "categorical": 

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

156 csparam = ConfigSpace.CategoricalHyperparameter( 

157 name=name, 

158 choices=values, 

159 default_value=default, 

160 meta=comment, 

161 # Does not seem to contain any weights? 

162 ) 

163 elif parameter_type == "ordinal": 

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

165 csparam = ConfigSpace.OrdinalHyperparameter( 

166 name=name, 

167 sequence=values, 

168 default_value=default, 

169 meta=comment, 

170 ) 

171 cs.add(csparam) 

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

173 # Break up the expression into the smallest possible pieces 

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

175 parameter, condition = ( 

176 match.group("parameter"), 

177 match.group("expression"), 

178 ) 

179 parameter = cs[parameter.strip()] 

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

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

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

183 condition = expression_to_configspace( 

184 condition, cs, target_parameter=parameter 

185 ) 

186 cs.add(condition) 

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

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

189 forbidden = match.group("forbidden") 

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

191 # where expressions can contain: 

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

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

194 # Supported by SMAC2 but not by ConfigSpace?: 

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

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

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

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

199 rejected_operators = ( 

200 "+", 

201 "-", 

202 "*", 

203 "^", 

204 "%", 

205 "abs", 

206 "acos", 

207 "asin", 

208 "atan", 

209 "cbrt", 

210 "ceil", 

211 "cos", 

212 "cosh", 

213 "exp", 

214 "floor", 

215 "log", 

216 "log10", 

217 "log2", 

218 "sin", 

219 "sinh", 

220 "sqrt", 

221 "tan", 

222 "tanh", 

223 ) 

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

225 print( 

226 "WARNING: Arithmetic operators are not supported by " 

227 "ConfigurationSpace. Skipping forbidden expression:\n" 

228 f"{forbidden}" 

229 ) 

230 continue 

231 forbidden = ( 

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

233 .replace(", ", " and ") 

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

235 .strip() 

236 ) # To AST notation 

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

238 forbidden = expression_to_configspace(forbidden, cs) 

239 cs.add(forbidden) 

240 else: 

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

242 return cs 

243 

244 @staticmethod 

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

246 """Parses a paramils file.""" 

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

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

249 cs = ConfigurationSpace(name=space_name) 

250 conditions_lines = {} 

251 for line in content: 

252 line = line.strip() 

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

254 continue 

255 comment = None 

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

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

258 name = parameter.group("name") 

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

260 # ParamILS is flexible to which parameters are allowed. 

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

262 # expressions 

263 raise ValueError( 

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

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

266 ) 

267 values = parameter.group("values") 

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

269 try: 

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

271 values = sorted(values) 

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

273 parameter_type = float 

274 else: 

275 parameter_type = int 

276 except Exception: # of strings (Categorical) 

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

278 parameter_type = str 

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

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

281 parameter_type = str 

282 default = parameter.group("default") 

283 comment = parameter.group("comment") 

284 if parameter_type is int: 

285 csparam = ConfigSpace.UniformIntegerHyperparameter( 

286 name=name, 

287 lower=int(values[0]), 

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

289 default_value=int(default), 

290 meta=comment, 

291 ) 

292 elif parameter_type is float: 

293 csparam = ConfigSpace.UniformFloatHyperparameter( 

294 name=name, 

295 lower=float(values[0]), 

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

297 default_value=float(default), 

298 meta=comment, 

299 ) 

300 elif parameter_type is str: 

301 csparam = ConfigSpace.CategoricalHyperparameter( 

302 name=name, 

303 choices=values, 

304 default_value=default, 

305 meta=comment, 

306 ) 

307 cs.add(csparam) 

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

309 # Break up the expression into the smallest possible pieces 

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

311 parameter, condition = ( 

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

313 match.group("expression"), 

314 ) 

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

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

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

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

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

320 if parameter not in conditions_lines: 

321 conditions_lines[parameter] = condition 

322 else: 

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

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

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

326 forbidden = match.group("forbidden") 

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

328 # where expressions can contain: 

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

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

331 # Supported by SMAC2 but not by ConfigSpace?: 

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

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

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

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

336 rejected_operators = ( 

337 "+", 

338 "-", 

339 "*", 

340 "^", 

341 "%", 

342 "abs", 

343 "acos", 

344 "asin", 

345 "atan", 

346 "cbrt", 

347 "ceil", 

348 "cos", 

349 "cosh", 

350 "exp", 

351 "floor", 

352 "log", 

353 "log10", 

354 "log2", 

355 "sin", 

356 "sinh", 

357 "sqrt", 

358 "tan", 

359 "tanh", 

360 ) 

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

362 print( 

363 "WARNING: Arithmetic operators are not supported by " 

364 "ConfigurationSpace. Skipping forbidden expression:\n" 

365 f"{forbidden}" 

366 ) 

367 continue 

368 forbidden = ( 

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

370 .replace(", ", " and ") 

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

372 .strip() 

373 ) # To AST notation 

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

375 forbidden = expression_to_configspace(forbidden, cs) 

376 cs.add(forbidden) 

377 else: 

378 raise Exception( 

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

380 ) 

381 # Add the condition 

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

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

384 cs.add(condition) 

385 return cs 

386 

387 @staticmethod 

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

389 """Parses a irace file.""" 

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

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

392 cs = ConfigurationSpace(name=space_name) 

393 standardised_conditions = [] 

394 forbidden_flag, global_flag = False, False 

395 for line in content: 

396 line = line.strip() 

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

398 continue 

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

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

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

402 forbidden_flag, global_flag = True, False 

403 continue 

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

405 global_flag, forbidden_flag = True, False 

406 continue 

407 else: 

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

409 elif global_flag: # Parse global statements 

410 continue # We do not parse global group 

411 elif forbidden_flag: # Parse forbidden statements 

412 # Parse the forbidden statement to standardised format 

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

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

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

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

417 forbidden_expr = expression_to_configspace(forbidden_expr, cs) 

418 cs.add(forbidden_expr) 

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

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

421 name = parameter.group("name") 

422 parameter_type = parameter.group("type") 

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

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

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

426 raw_values = parameter.group("values") 

427 if parameter_type in {"c", "o"}: 

428 stripped_values = raw_values.strip() 

429 if stripped_values.startswith("c(") and stripped_values.endswith( 

430 ")" 

431 ): 

432 stripped_values = stripped_values[2:-1] 

433 elif ( 

434 stripped_values 

435 and stripped_values[0] in "({[" 

436 and stripped_values[-1] in ")}]" 

437 ): 

438 stripped_values = stripped_values[1:-1] 

439 values = [] 

440 categorical_token_pattern = re.compile( 

441 r'\s*(?:"([^"]*)"|\'([^\']*)\'|([^,]+?))\s*(?:,|$)' 

442 ) 

443 for match in categorical_token_pattern.finditer(stripped_values): 

444 token = match.group(1) or match.group( 

445 2 

446 ) # double or single quotes 

447 if token is None: 

448 token = match.group(3).strip() # unquoted 

449 values.append(token) 

450 else: 

451 values = ast.literal_eval(raw_values) 

452 scale = parameter.group("scale") 

453 conditions = parameter.group("conditions") 

454 comment = parameter.group("comment") 

455 # Convert categorical / ordinal values to strings 

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

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

458 else: 

459 if any( 

460 operator in values 

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

462 ): 

463 raise ValueError( 

464 "Dependent parameter domains not supported by " 

465 "ConfigurationSpace." 

466 ) 

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

468 if parameter_type == "c": 

469 csparam = ConfigSpace.CategoricalHyperparameter( 

470 name=name, 

471 choices=values, 

472 meta=comment, 

473 ) 

474 elif parameter_type == "o": 

475 csparam = ConfigSpace.OrdinalHyperparameter( 

476 name=name, 

477 sequence=values, 

478 meta=comment, 

479 ) 

480 elif parameter_type == "r": 

481 csparam = ConfigSpace.UniformFloatHyperparameter( 

482 name=name, 

483 lower=float(lower_bound), 

484 upper=float(upper_bound), 

485 log=scale == "log", 

486 meta=comment, 

487 ) 

488 elif parameter_type == "i": 

489 csparam = ConfigSpace.UniformIntegerHyperparameter( 

490 name=name, 

491 lower=int(lower_bound), 

492 upper=int(upper_bound), 

493 log=scale == "log", 

494 meta=comment, 

495 ) 

496 cs.add(csparam) 

497 if conditions: 

498 # Convert the expression to standardised format 

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

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

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

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

503 conditions = conditions.strip() 

504 standardised_conditions.append((csparam, conditions)) 

505 else: 

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

507 

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

509 for csparam, conditions in standardised_conditions: 

510 conditions = expression_to_configspace( 

511 conditions, cs, target_parameter=csparam 

512 ) 

513 cs.add(conditions) 

514 return cs 

515 

516 @staticmethod 

517 def export( 

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

519 ) -> str | None: 

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

521 

522 Args: 

523 configspace: ConfigurationSpace, the space to convert 

524 pcs_format: PCSConvention, the convention to conver to 

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

526 

527 Returns: 

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

529 """ 

530 # Create pcs table 

531 declaration = ( 

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

533 "generated by Sparkle\n" 

534 ) 

535 rows = [] 

536 extra_rows = [] 

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

538 import numpy as np 

539 

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

541 header = [ 

542 "# Parameter Name", 

543 "type", 

544 "values", 

545 "default value", 

546 "scale", 

547 "comments", 

548 ] 

549 parameter_map = { 

550 ConfigSpace.UniformFloatHyperparameter: "real", 

551 ConfigSpace.UniformIntegerHyperparameter: "integer", 

552 ConfigSpace.CategoricalHyperparameter: "categorical", 

553 ConfigSpace.OrdinalHyperparameter: "ordinal", 

554 } 

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

556 log = False 

557 if isinstance( 

558 parameter, ConfigSpace.hyperparameters.NumericalHyperparameter 

559 ): 

560 log = parameter.log 

561 if pcs_format == PCSConvention.ParamILS: # Discretise 

562 dtype = ( 

563 float 

564 if isinstance( 

565 parameter, ConfigSpace.UniformFloatHyperparameter 

566 ) 

567 else int 

568 ) 

569 if log: 

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

571 domain = list( 

572 np.unique( 

573 np.geomspace( 

574 lower, 

575 parameter.upper, 

576 granularity, 

577 dtype=dtype, 

578 ) 

579 ) 

580 ) 

581 else: 

582 domain = list( 

583 np.linspace( 

584 parameter.lower, 

585 parameter.upper, 

586 granularity, 

587 dtype=dtype, 

588 ) 

589 ) 

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

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

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

593 domain.sort() 

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

595 else: # SMAC2 takes ranges 

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

597 else: 

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

599 rows.append( 

600 [ 

601 parameter.name, 

602 parameter_map[type(parameter)] 

603 if not pcs_format == PCSConvention.ParamILS 

604 else "", 

605 domain, 

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

607 "log" 

608 if log and not pcs_format == PCSConvention.ParamILS 

609 else "", 

610 f"# {parameter.meta}", 

611 ] 

612 ) 

613 if configspace.conditions: 

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

615 for condition in configspace.conditions: 

616 condition_str = str(condition) 

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

618 ")", "" 

619 ) # Brackets not allowed 

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

621 condition_str = condition_str.replace( 

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

623 ).strip() 

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

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

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

627 ): 

628 # TODO: Translate condition ParamILS expression (in) 

629 continue 

630 extra_rows.append(condition_str) 

631 if configspace.forbidden_clauses: 

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

633 for forbidden in configspace.forbidden_clauses: 

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

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

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

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

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

639 ): 

640 # TODO: Translate condition ParamILS expression (in) 

641 continue 

642 extra_rows.append(forbidden_str) 

643 elif pcs_format == PCSConvention.IRACE: 

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

645 parameter_map = { 

646 ConfigSpace.UniformFloatHyperparameter: "r", 

647 ConfigSpace.UniformIntegerHyperparameter: "i", 

648 ConfigSpace.CategoricalHyperparameter: "c", 

649 ConfigSpace.OrdinalHyperparameter: "o", 

650 } 

651 header = [ 

652 "# Parameter Name", 

653 "switch", 

654 "type", 

655 "values", 

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

657 "comments", 

658 ] 

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

660 parameter_conditions = [] 

661 for c in configspace.conditions: 

662 if c.child == parameter: 

663 parameter_conditions.append(c) 

664 parameter_type = parameter_map[type(parameter)] 

665 condition_type = ( 

666 parameter_type 

667 if type(parameter) is ConfigSpace.CategoricalHyperparameter 

668 else "" 

669 ) 

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

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

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

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

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

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

676 " || ", " | " 

677 ) 

678 if isinstance( 

679 parameter, ConfigSpace.hyperparameters.NumericalHyperparameter 

680 ): 

681 if parameter.log: 

682 parameter_type += ",log" 

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

684 if isinstance( 

685 parameter, ConfigSpace.hyperparameters.FloatHyperparameter 

686 ): 

687 # Format the floats to interpret the number of digits 

688 # (Includes scientific notation) 

689 lower, upper = ( 

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

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

692 ) 

693 param_digits = max( 

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

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

696 ) 

697 # Check if we need to update the global digits 

698 if param_digits > digits: 

699 digits = param_digits 

700 else: 

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

702 rows.append( 

703 [ 

704 parameter.name, 

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

706 parameter_type, 

707 domain, # Parameter range/domain 

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

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

710 ] 

711 ) 

712 if configspace.forbidden_clauses: 

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

714 for forbidden_expression in configspace.forbidden_clauses: 

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

716 if " in " in forbidden_str: 

717 type_char = parameter_map[ 

718 type(forbidden_expression.hyperparameter) 

719 ] 

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

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

722 " || ", " | " 

723 ) 

724 extra_rows.append(forbidden_str) 

725 if digits > 4: # Default digits is 4 

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

727 

728 output = ( 

729 declaration 

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

731 + "\n" 

732 ) 

733 if extra_rows: 

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

735 if file is None: 

736 return output 

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

738 

739 @staticmethod 

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

741 """Validate a pcs file.""" 

742 # TODO: Determine which format 

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

744 return