|
27 | 27 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
28 | 28 | from __future__ import annotations |
29 | 29 |
|
| 30 | +import ast |
| 31 | +import re |
30 | 32 | import copy |
31 | 33 | from collections import deque |
32 | 34 | from collections.abc import Iterator, Sequence |
33 | | -from typing import TYPE_CHECKING, Any, cast |
| 35 | +from typing import TYPE_CHECKING, Any, cast, Iterable |
34 | 36 |
|
35 | 37 | import numpy as np |
36 | 38 |
|
|
50 | 52 | UniformFloatHyperparameter, |
51 | 53 | UniformIntegerHyperparameter, |
52 | 54 | ) |
| 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 | + |
53 | 83 | from ConfigSpace.types import NotSet |
54 | 84 |
|
55 | 85 | if TYPE_CHECKING: |
@@ -828,3 +858,228 @@ def _get_cartesian_product( |
828 | 858 | unchecked_grid_pts.popleft() |
829 | 859 |
|
830 | 860 | 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