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

364 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 12:00 +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 elif any( 

275 [isinstance(v, bool) for v in values] 

276 ): # Without this check, Booleans will be considered integers by Python 

277 # Convert the booleans back to strings for ConfigSpace 

278 values = [str(v) for v in values] 

279 parameter_type = str 

280 elif any([isinstance(v, int) for v in values]): 

281 parameter_type = int 

282 except Exception: # of strings (Categorical) 

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

284 parameter_type = str 

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

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

287 parameter_type = str 

288 default = parameter.group("default") 

289 comment = parameter.group("comment") 

290 if parameter_type is int: 

291 csparam = ConfigSpace.UniformIntegerHyperparameter( 

292 name=name, 

293 lower=int(values[0]), 

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

295 default_value=int(default), 

296 meta=comment, 

297 ) 

298 elif parameter_type is float: 

299 csparam = ConfigSpace.UniformFloatHyperparameter( 

300 name=name, 

301 lower=float(values[0]), 

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

303 default_value=float(default), 

304 meta=comment, 

305 ) 

306 elif parameter_type is str: 

307 csparam = ConfigSpace.CategoricalHyperparameter( 

308 name=name, 

309 choices=values, 

310 default_value=default, 

311 meta=comment, 

312 ) 

313 cs.add(csparam) 

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

315 # Break up the expression into the smallest possible pieces 

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

317 parameter, condition = ( 

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

319 match.group("expression"), 

320 ) 

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

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

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

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

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

326 if parameter not in conditions_lines: 

327 conditions_lines[parameter] = condition 

328 else: 

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

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

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

332 forbidden = match.group("forbidden") 

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

334 # where expressions can contain: 

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

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

337 # Supported by SMAC2 but not by ConfigSpace?: 

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

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

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

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

342 rejected_operators = ( 

343 "+", 

344 "-", 

345 "*", 

346 "^", 

347 "%", 

348 "abs", 

349 "acos", 

350 "asin", 

351 "atan", 

352 "cbrt", 

353 "ceil", 

354 "cos", 

355 "cosh", 

356 "exp", 

357 "floor", 

358 "log", 

359 "log10", 

360 "log2", 

361 "sin", 

362 "sinh", 

363 "sqrt", 

364 "tan", 

365 "tanh", 

366 ) 

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

368 print( 

369 "WARNING: Arithmetic operators are not supported by " 

370 "ConfigurationSpace. Skipping forbidden expression:\n" 

371 f"{forbidden}" 

372 ) 

373 continue 

374 forbidden = ( 

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

376 .replace(", ", " and ") 

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

378 .strip() 

379 ) # To AST notation 

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

381 forbidden = expression_to_configspace(forbidden, cs) 

382 cs.add(forbidden) 

383 else: 

384 raise Exception( 

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

386 ) 

387 # Add the condition 

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

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

390 cs.add(condition) 

391 return cs 

392 

393 @staticmethod 

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

395 """Parses a irace file.""" 

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

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

398 cs = ConfigurationSpace(name=space_name) 

399 standardised_conditions = [] 

400 forbidden_flag, global_flag = False, False 

401 for line in content: 

402 line = line.strip() 

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

404 continue 

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

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

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

408 forbidden_flag, global_flag = True, False 

409 continue 

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

411 global_flag, forbidden_flag = True, False 

412 continue 

413 else: 

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

415 elif global_flag: # Parse global statements 

416 continue # We do not parse global group 

417 elif forbidden_flag: # Parse forbidden statements 

418 # Parse the forbidden statement to standardised format 

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

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

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

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

423 forbidden_expr = expression_to_configspace(forbidden_expr, cs) 

424 cs.add(forbidden_expr) 

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

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

427 name = parameter.group("name") 

428 parameter_type = parameter.group("type") 

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

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

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

432 raw_values = parameter.group("values") 

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

434 stripped_values = raw_values.strip() 

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

436 ")" 

437 ): 

438 stripped_values = stripped_values[2:-1] 

439 elif ( 

440 stripped_values 

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

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

443 ): 

444 stripped_values = stripped_values[1:-1] 

445 values = [] 

446 categorical_token_pattern = re.compile( 

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

448 ) 

449 for match in categorical_token_pattern.finditer(stripped_values): 

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

451 2 

452 ) # double or single quotes 

453 if token is None: 

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

455 values.append(token) 

456 else: 

457 values = ast.literal_eval(raw_values) 

458 scale = parameter.group("scale") 

459 conditions = parameter.group("conditions") 

460 comment = parameter.group("comment") 

461 # Convert categorical / ordinal values to strings 

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

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

464 else: 

465 if any( 

466 operator in values 

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

468 ): 

469 raise ValueError( 

470 "Dependent parameter domains not supported by " 

471 "ConfigurationSpace." 

472 ) 

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

474 if parameter_type == "c": 

475 csparam = ConfigSpace.CategoricalHyperparameter( 

476 name=name, 

477 choices=values, 

478 meta=comment, 

479 ) 

480 elif parameter_type == "o": 

481 csparam = ConfigSpace.OrdinalHyperparameter( 

482 name=name, 

483 sequence=values, 

484 meta=comment, 

485 ) 

486 elif parameter_type == "r": 

487 csparam = ConfigSpace.UniformFloatHyperparameter( 

488 name=name, 

489 lower=float(lower_bound), 

490 upper=float(upper_bound), 

491 log=scale == "log", 

492 meta=comment, 

493 ) 

494 elif parameter_type == "i": 

495 csparam = ConfigSpace.UniformIntegerHyperparameter( 

496 name=name, 

497 lower=int(lower_bound), 

498 upper=int(upper_bound), 

499 log=scale == "log", 

500 meta=comment, 

501 ) 

502 cs.add(csparam) 

503 if conditions: 

504 # Convert the expression to standardised format 

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

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

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

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

509 conditions = conditions.strip() 

510 standardised_conditions.append((csparam, conditions)) 

511 else: 

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

513 

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

515 for csparam, conditions in standardised_conditions: 

516 conditions = expression_to_configspace( 

517 conditions, cs, target_parameter=csparam 

518 ) 

519 cs.add(conditions) 

520 return cs 

521 

522 @staticmethod 

523 def export( 

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

525 ) -> str | None: 

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

527 

528 Args: 

529 configspace: ConfigurationSpace, the space to convert 

530 pcs_format: PCSConvention, the convention to conver to 

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

532 

533 Returns: 

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

535 """ 

536 # Create pcs table 

537 declaration = ( 

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

539 "generated by Sparkle\n" 

540 ) 

541 rows = [] 

542 extra_rows = [] 

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

544 import numpy as np 

545 

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

547 header = [ 

548 "# Parameter Name", 

549 "type", 

550 "values", 

551 "default value", 

552 "scale", 

553 "comments", 

554 ] 

555 parameter_map = { 

556 ConfigSpace.UniformFloatHyperparameter: "real", 

557 ConfigSpace.UniformIntegerHyperparameter: "integer", 

558 ConfigSpace.CategoricalHyperparameter: "categorical", 

559 ConfigSpace.OrdinalHyperparameter: "ordinal", 

560 } 

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

562 log = False 

563 if isinstance( 

564 parameter, ConfigSpace.hyperparameters.NumericalHyperparameter 

565 ): 

566 log = parameter.log 

567 if pcs_format == PCSConvention.ParamILS: # Discretise 

568 dtype = ( 

569 float 

570 if isinstance( 

571 parameter, ConfigSpace.UniformFloatHyperparameter 

572 ) 

573 else int 

574 ) 

575 if log: 

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

577 domain = list( 

578 np.unique( 

579 np.geomspace( 

580 lower, 

581 parameter.upper, 

582 granularity, 

583 dtype=dtype, 

584 ) 

585 ) 

586 ) 

587 else: 

588 domain = list( 

589 np.linspace( 

590 parameter.lower, 

591 parameter.upper, 

592 granularity, 

593 dtype=dtype, 

594 ) 

595 ) 

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

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

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

599 domain.sort() 

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

601 else: # SMAC2 takes ranges 

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

603 else: 

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

605 rows.append( 

606 [ 

607 parameter.name, 

608 parameter_map[type(parameter)] 

609 if not pcs_format == PCSConvention.ParamILS 

610 else "", 

611 domain, 

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

613 "log" 

614 if log and not pcs_format == PCSConvention.ParamILS 

615 else "", 

616 f"# {parameter.meta}", 

617 ] 

618 ) 

619 if configspace.conditions: 

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

621 for condition in configspace.conditions: 

622 condition_str = str(condition) 

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

624 ")", "" 

625 ) # Brackets not allowed 

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

627 condition_str = condition_str.replace( 

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

629 ).strip() 

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

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

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

633 ): 

634 # TODO: Translate condition ParamILS expression (in) 

635 continue 

636 extra_rows.append(condition_str) 

637 if configspace.forbidden_clauses: 

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

639 for forbidden in configspace.forbidden_clauses: 

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

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

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

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

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

645 ): 

646 # TODO: Translate condition ParamILS expression (in) 

647 continue 

648 extra_rows.append(forbidden_str) 

649 elif pcs_format == PCSConvention.IRACE: 

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

651 parameter_map = { 

652 ConfigSpace.UniformFloatHyperparameter: "r", 

653 ConfigSpace.UniformIntegerHyperparameter: "i", 

654 ConfigSpace.CategoricalHyperparameter: "c", 

655 ConfigSpace.OrdinalHyperparameter: "o", 

656 } 

657 header = [ 

658 "# Parameter Name", 

659 "switch", 

660 "type", 

661 "values", 

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

663 "comments", 

664 ] 

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

666 parameter_conditions = [] 

667 for c in configspace.conditions: 

668 if c.child == parameter: 

669 parameter_conditions.append(c) 

670 parameter_type = parameter_map[type(parameter)] 

671 condition_type = ( 

672 parameter_type 

673 if type(parameter) is ConfigSpace.CategoricalHyperparameter 

674 else "" 

675 ) 

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

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

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

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

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

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

682 " || ", " | " 

683 ) 

684 if isinstance( 

685 parameter, ConfigSpace.hyperparameters.NumericalHyperparameter 

686 ): 

687 if parameter.log: 

688 parameter_type += ",log" 

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

690 if isinstance( 

691 parameter, ConfigSpace.hyperparameters.FloatHyperparameter 

692 ): 

693 # Format the floats to interpret the number of digits 

694 # (Includes scientific notation) 

695 lower, upper = ( 

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

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

698 ) 

699 param_digits = max( 

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

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

702 ) 

703 # Check if we need to update the global digits 

704 if param_digits > digits: 

705 digits = param_digits 

706 else: 

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

708 rows.append( 

709 [ 

710 parameter.name, 

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

712 parameter_type, 

713 domain, # Parameter range/domain 

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

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

716 ] 

717 ) 

718 if configspace.forbidden_clauses: 

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

720 for forbidden_expression in configspace.forbidden_clauses: 

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

722 if " in " in forbidden_str: 

723 type_char = parameter_map[ 

724 type(forbidden_expression.hyperparameter) 

725 ] 

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

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

728 " || ", " | " 

729 ) 

730 extra_rows.append(forbidden_str) 

731 if digits > 4: # Default digits is 4 

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

733 

734 output = ( 

735 declaration 

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

737 + "\n" 

738 ) 

739 if extra_rows: 

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

741 if file is None: 

742 return output 

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

744 

745 @staticmethod 

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

747 """Validate a pcs file.""" 

748 # TODO: Determine which format 

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

750 return