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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 12:00 +0000
1"""Parameter Configuration Space tools."""
3from __future__ import annotations
4import re
5import ast
6from enum import Enum
7from pathlib import Path
9import ConfigSpace.conditions
10import tabulate
11import ConfigSpace
12from ConfigSpace import ConfigurationSpace
13from sparkle.tools.configspace import expression_to_configspace
16class PCSConvention(Enum):
17 """Internal pcs convention enum."""
19 UNKNOWN = "UNKNOWN"
20 SMAC = "smac"
21 ParamILS = "paramils"
22 IRACE = "irace"
23 ConfigSpace = "configspace"
26class PCSConverter:
27 """Parser class independent file of notation."""
29 section_regex = re.compile(r"\[(?P<name>[a-zA-Z]+?)\]\s*(?P<comment>#.*)?$")
30 illegal_param_name = re.compile(r"[!:\-+@!#$%^&*()=<>?/\|~` ]")
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>.+)\}$")
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 )
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 )
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
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 )
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
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
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}")
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
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.
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.
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
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}"])
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)
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