Skip to content

Commit 3bde479

Browse files
Abstract syntax tree parser for ConfigSpace (#413)
* First version * Expanding tests * Simplifying print statement as ast.unparse is only available from Python >= 3.9 * Bugfixes * Updating documentation * Cleaning up code, expanding docs * bugfix * docs typo * Adding docs, minor fixes * Fixing example * Remove mistake file * docfix * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Updating copilot fix, adding tests * Bugfixes * docfix * reference fix * Parameter rename * Rename function * Separating the examples as two subsections * Fixing pytest message check * docstring fix * seperating test in two parts * Adding test * Clarification for possible ambiguity, adding tests * Updating method to be more strict on HP names * Docs clarification * removing commented out code --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 57b4c67 commit 3bde479

5 files changed

Lines changed: 528 additions & 2 deletions

File tree

docs/reference/util.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
## Utilities
2+
3+
### Expression to Configspace
4+
5+
In some cases we may have (highly) complex conditions or forbidden expressions that are already denoted as a regular expression. In that case, `ConfigSpace` can automatically convert them into a `ConfigSpace` expression using the [`parse_expression_from_string`][ConfigSpace.util.parse_expression_from_string]`parse_expression_from_string`. This function interprets the expression using the Python `Abstract Syntax Tree` parser and recursively converts it into the appropriate structure.
6+
7+
!!! note
8+
The converted expression is not added to ConfigSpace, only returned to the user.
9+
10+
!!! note
11+
If the expression contains illegal values, errors, or requires functionalities not available in `ConfigSpace`, appriopriate exceptions will be raised.
12+
13+
!!! note
14+
Expressions differentiate variables (Hyperparameter names) from constants (Categorical values) based on quotation marks; "a != b" implies hyperparameter a does not equal hyperparameter b, "a != 'b'" implies hyperparameter a does not equal categorical/ordinal value b.
15+
16+
#### Adding a condition
17+
18+
In this code example we show how you can add a hyperparameter condition to ConfigSpace from a string. Note that the conditional hyperparameter is specified as a seperate argument and is not part of the expression string!
19+
20+
```python exec="True" result="python" source="tabbed-left"
21+
from ConfigSpace import ConfigurationSpace
22+
from ConfigSpace.util import parse_expression_from_string
23+
24+
cs = ConfigurationSpace(
25+
{
26+
"a": (0, 10), # Integer from 0 to 10
27+
"b": ["cat", "dog"], # Categorical with choices "cat" and "dog"
28+
"c": (0.0, 1.0), # Float from 0.0 to 1.0
29+
}
30+
)
31+
print(cs)
32+
33+
# Now we add a condition and forbidden using regular expressions
34+
condition = "b != 'cat' && c > 0.001"
35+
condition = parse_expression_from_string(condition, cs, conditional_hyperparameter=cs["a"]) # We have to specify the conditional HP seperately here as the final argument
36+
37+
print(condition)
38+
39+
cs.add(condition)
40+
41+
print(cs)
42+
```
43+
44+
#### Adding a forbidden expression
45+
46+
In this example we add a forbidden expression to ConfigSpace from string. Note that the conditional hyperparameter remains unspecified; this leads to ConfigSpace interpreting the expression as a forbidden expression.
47+
48+
```python exec="True" result="python" source="tabbed-left"
49+
from ConfigSpace import ConfigurationSpace
50+
from ConfigSpace.util import parse_expression_from_string
51+
52+
cs = ConfigurationSpace(
53+
{
54+
"a": (0, 10), # Integer from 0 to 10
55+
"b": ["cat", "dog"], # Categorical with choices "cat" and "dog"
56+
"c": (0.0, 1.0), # Float from 0.0 to 1.0
57+
}
58+
)
59+
print(cs)
60+
forbidden = "a > 5 && c >= 0.94"
61+
forbidden = parse_expression_from_string(forbidden, cs)
62+
63+
print(forbidden)
64+
65+
cs.add(forbidden)
66+
67+
print(cs)
68+
```

docs/reference/utils.md

Whitespace-only changes.

mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,5 +173,5 @@ nav:
173173
- Conditions: "reference/conditions.md"
174174
- Forbidden Clauses: "reference/forbiddens.md"
175175
- Serialization: "reference/serialization.md"
176-
- Util: "reference/utils.md"
176+
- Util: "reference/util.md"
177177
- API: "api/"

src/ConfigSpace/util.py

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
2828
from __future__ import annotations
2929

30+
import ast
31+
import re
3032
import copy
3133
from collections import deque
3234
from collections.abc import Iterator, Sequence
33-
from typing import TYPE_CHECKING, Any, cast
35+
from typing import TYPE_CHECKING, Any, cast, Iterable
3436

3537
import numpy as np
3638

@@ -50,6 +52,34 @@
5052
UniformFloatHyperparameter,
5153
UniformIntegerHyperparameter,
5254
)
55+
56+
from ConfigSpace.conditions import (
57+
Condition,
58+
AndConjunction,
59+
OrConjunction,
60+
EqualsCondition,
61+
GreaterThanCondition,
62+
LessThanCondition,
63+
NotEqualsCondition,
64+
InCondition,
65+
)
66+
from ConfigSpace.forbidden import (
67+
ForbiddenClause,
68+
ForbiddenAndConjunction,
69+
ForbiddenOrConjunction,
70+
ForbiddenEqualsClause,
71+
ForbiddenGreaterThanClause,
72+
ForbiddenGreaterThanEqualsClause,
73+
ForbiddenInClause,
74+
ForbiddenLessThanClause,
75+
ForbiddenLessThanEqualsClause,
76+
ForbiddenGreaterThanRelation,
77+
ForbiddenLessThanRelation,
78+
ForbiddenEqualsRelation,
79+
ForbiddenGreaterThanEqualsRelation,
80+
ForbiddenLessThanEqualsRelation,
81+
)
82+
5383
from ConfigSpace.types import NotSet
5484

5585
if TYPE_CHECKING:
@@ -828,3 +858,228 @@ def _get_cartesian_product(
828858
unchecked_grid_pts.popleft()
829859

830860
return checked_grid_pts
861+
862+
863+
def parse_expression_from_string(
864+
expression: str,
865+
configspace: ConfigurationSpace,
866+
conditional_hyperparameter: Hyperparameter | None = None,
867+
) -> Condition | ForbiddenClause:
868+
"""Convert a logic expression to ConfigSpace expression.
869+
870+
Given a logic expression, this function will return a ConfigSpace expression
871+
that is equivalent to the logic expression. If a conditional parameter is provided,
872+
will create a condition, otherwise a forbidden expression.
873+
874+
The created expression is **NOT** automatically added to the configuration space.
875+
876+
Example Condition expression parsing:
877+
878+
```python exec="true", source="material-block" result="python"
879+
from ConfigSpace import ConfigurationSpace
880+
from ConfigSpace.util import parse_expression_from_string
881+
882+
cs = ConfigurationSpace({ "a": (0, 10), "b": (1.0, 8.0) })
883+
condition = parse_expression_from_string("a < 5", cs, conditional_hyperparameter=cs['b'])
884+
print(condition)
885+
```
886+
887+
Example Forbidden Expression Parsing:
888+
889+
```python exec="true", source="material-block" result="python"
890+
from ConfigSpace import ConfigurationSpace
891+
from ConfigSpace.util import parse_expression_from_string
892+
893+
cs = ConfigurationSpace({ "a": (0, 10), "b": (1.0, 8.0) })
894+
forbidden = parse_expression_from_string("a >= 5", cs)
895+
print(forbidden)
896+
```
897+
898+
Args:
899+
expression: The expression to convert.
900+
configspace: The ConfigSpace to use.
901+
conditional_hyperparameter: For conditions, will parse the expression as a condition
902+
underwhich the provided hyperparameter will be active.
903+
904+
Returns:
905+
A ConfigSpace Condition or ForbiddenClause.
906+
"""
907+
# Format expression to match the ast module
908+
# Format logical operators:
909+
expression = re.sub(r" & ", " and ", expression)
910+
expression = re.sub(r" && ", " and ", expression)
911+
expression = re.sub(r" \| ", " or ", expression)
912+
expression = re.sub(r" \|\| ", " or ", expression)
913+
# Format (in)equality operators:
914+
expression = re.sub(r" !== ", " != ", expression)
915+
expression = re.sub(r" (?<![<>!=])=(?<![=]) ", " == ", expression)
916+
try:
917+
# Convert to abstract syntax tree, extract body of the expression
918+
ast_expression = ast.parse(expression).body[0]
919+
except Exception as e:
920+
raise ValueError(f"Could not parse expression: '{expression}', {e}")
921+
return _recursive_conversion(
922+
ast_expression, configspace, conditional_hyperparameter=conditional_hyperparameter
923+
)
924+
925+
926+
def _recursive_conversion(
927+
item: ast.AST | list[ast.AST],
928+
configspace: ConfigurationSpace,
929+
conditional_hyperparameter: Hyperparameter | None = None,
930+
) -> Condition | ForbiddenClause:
931+
"""Recursively parse the abstract syntax tree to a ConfigSpace expression.
932+
933+
Should not be called directly, but rather through `parse_expression_from_string`.
934+
935+
Args:
936+
item: The item to parse.
937+
configspace: The ConfigSpace to use.
938+
conditional_hyperparameter: For conditions, will parse the expression as a condition
939+
underwhich the hyperparameter will be active.
940+
941+
Returns:
942+
A ConfigSpace Condition or ForbiddenClause
943+
"""
944+
if isinstance(item, list):
945+
if len(item) > 1:
946+
raise ValueError(f"Can not parse list of elements: {item}.")
947+
item = item[0]
948+
if isinstance(item, ast.Expr):
949+
return _recursive_conversion(item.value, configspace, conditional_hyperparameter)
950+
if isinstance(item, ast.Name): # Convert to hyperparameter
951+
hp = configspace.get(item.id)
952+
if hp is None:
953+
raise ValueError(f"Unknown hyperparameter: {item.id}")
954+
return hp
955+
if isinstance(item, ast.Constant): # ast.Constant are differentiated from ast.Name by integers/floats and quoted strings
956+
return item.value
957+
if (
958+
isinstance(item, ast.Tuple)
959+
or isinstance(item, ast.Set)
960+
or isinstance(item, ast.List)
961+
):
962+
values = []
963+
for v in item.elts:
964+
if isinstance(v, ast.Constant):
965+
values.append(v.value)
966+
elif isinstance(v, ast.Name): # Check if its a parameter
967+
if configspace.get(v.id) is not None:
968+
raise ValueError(
969+
f"Only constants allowed in tuples. Found: {item.elts}"
970+
)
971+
values.append(v.id) # String value was interpreted as parameter
972+
return values
973+
if isinstance(item, ast.BinOp):
974+
raise NotImplementedError("Binary operations not supported by ConfigSpace.")
975+
if isinstance(item, ast.BoolOp):
976+
values = [
977+
_recursive_conversion(v, configspace, conditional_hyperparameter) for v in item.values
978+
]
979+
if isinstance(item.op, ast.Or):
980+
if conditional_hyperparameter:
981+
return OrConjunction(*values)
982+
return ForbiddenOrConjunction(*values)
983+
elif isinstance(item.op, ast.And):
984+
if conditional_hyperparameter:
985+
return AndConjunction(*values)
986+
return ForbiddenAndConjunction(*values)
987+
else:
988+
raise ValueError(f"Unknown boolean operator: {item.op}")
989+
if isinstance(item, ast.Compare):
990+
if len(item.ops) > 1:
991+
raise ValueError(f"Only single comparisons allowed. Found: {item.ops}")
992+
left = _recursive_conversion(item.left, configspace, conditional_hyperparameter)
993+
right = _recursive_conversion(item.comparators, configspace, conditional_hyperparameter)
994+
operator = item.ops[0]
995+
996+
# CoPilot: Ensure that if there is exactly one Hyperparameter involved in the comparison, it is always on the left-hand side. This is required
997+
# because the downstream Condition/Forbidden* constructors expect the hyperparameter to be passed as the "left" argument.
998+
if isinstance(right, Hyperparameter) and not isinstance(left, Hyperparameter):
999+
# Normalize expressions like "5 < hp" into "hp > 5" by swapping sides and inverting asymmetric operators.
1000+
left, right = right, left
1001+
if isinstance(operator, ast.Lt):
1002+
operator = ast.Gt()
1003+
elif isinstance(operator, ast.LtE):
1004+
operator = ast.GtE()
1005+
elif isinstance(operator, ast.Gt):
1006+
operator = ast.Lt()
1007+
elif isinstance(operator, ast.GtE):
1008+
operator = ast.LtE()
1009+
elif isinstance(operator, ast.In):
1010+
# Having a Hyperparameter only on the right-hand side of an "in" comparison (e.g. "[1, 2] in hp") is not supported.
1011+
raise ValueError(
1012+
"Invalid comparison: 'in' operator requires a hyperparameter "
1013+
"on the left-hand side."
1014+
)
1015+
elif not isinstance(operator, (ast.Eq, ast.NotEq)): # Equality and inequality are symmetric; no operator change
1016+
# For any other unsupported operator shapes, fail.
1017+
raise ValueError(
1018+
f"Unsupported comparison between constant and hyperparameter: {ast.unparse(item)}"
1019+
)
1020+
1021+
if isinstance(left, Hyperparameter): # Convert to HP type
1022+
if isinstance(right, Iterable) and not isinstance(right, str):
1023+
right = [type(left.default_value)(v) for v in right]
1024+
if len(right) == 1 and not isinstance(operator, ast.In):
1025+
right = right[0]
1026+
elif isinstance(right, int):
1027+
right = type(left.default_value)(right)
1028+
elif not isinstance(right, Hyperparameter):
1029+
raise ValueError(
1030+
"Only hyperparameter comparisons allowed. Neither side is recognised as a hyperparameter in: "
1031+
f"{ast.unparse(item)}"
1032+
)
1033+
1034+
is_relation = isinstance(left, Hyperparameter) and isinstance(right, Hyperparameter)
1035+
if is_relation and conditional_hyperparameter:
1036+
raise ValueError("Hyperparameter relations not supported for conditions.")
1037+
1038+
if isinstance(operator, ast.Lt):
1039+
if conditional_hyperparameter:
1040+
return LessThanCondition(conditional_hyperparameter, left, right)
1041+
if is_relation:
1042+
return ForbiddenLessThanRelation(left=left, right=right)
1043+
return ForbiddenLessThanClause(hyperparameter=left, value=right)
1044+
if isinstance(operator, ast.LtE):
1045+
if conditional_hyperparameter:
1046+
raise ValueError("LessThanEquals not supported for conditions.")
1047+
if is_relation:
1048+
return ForbiddenLessThanEqualsRelation(left=left, right=right)
1049+
return ForbiddenLessThanEqualsClause(hyperparameter=left, value=right)
1050+
if isinstance(operator, ast.Gt):
1051+
if conditional_hyperparameter:
1052+
return GreaterThanCondition(conditional_hyperparameter, left, right)
1053+
if is_relation:
1054+
return ForbiddenGreaterThanRelation(left=left, right=right)
1055+
return ForbiddenGreaterThanClause(hyperparameter=left, value=right)
1056+
if isinstance(operator, ast.GtE):
1057+
if conditional_hyperparameter:
1058+
raise ValueError("GreaterThanEquals not supported for conditions.")
1059+
if is_relation:
1060+
return ForbiddenGreaterThanEqualsRelation(left=left, right=right)
1061+
return ForbiddenGreaterThanEqualsClause(hyperparameter=left, value=right)
1062+
if isinstance(operator, ast.Eq):
1063+
if conditional_hyperparameter:
1064+
return EqualsCondition(conditional_hyperparameter, left, right)
1065+
if is_relation:
1066+
return ForbiddenEqualsRelation(left=left, right=right)
1067+
return ForbiddenEqualsClause(hyperparameter=left, value=right)
1068+
if isinstance(operator, ast.In):
1069+
if is_relation:
1070+
raise ValueError("In operator not supported for hyperparameter relations.")
1071+
if conditional_hyperparameter:
1072+
return InCondition(conditional_hyperparameter, left, right)
1073+
return ForbiddenInClause(hyperparameter=left, values=right)
1074+
if isinstance(operator, ast.NotEq):
1075+
if conditional_hyperparameter:
1076+
return NotEqualsCondition(conditional_hyperparameter, left, right)
1077+
raise ValueError("NotEq operator not supported for ForbiddenClauses.")
1078+
# The following classes do not (yet?) exist in configspace
1079+
if isinstance(operator, ast.NotIn):
1080+
raise ValueError("NotIn operator not supported for ForbiddenClauses.")
1081+
if isinstance(operator, ast.Is):
1082+
raise NotImplementedError("Is operator not supported.")
1083+
if isinstance(operator, ast.IsNot):
1084+
raise NotImplementedError("IsNot operator not supported.")
1085+
raise ValueError(f"Unsupported type: {item}")

0 commit comments

Comments
 (0)