From de6fdc453f263eb1b6a029c652de0b38eda9903a Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Wed, 19 Nov 2025 15:18:42 +0200 Subject: [PATCH 01/11] added back the naive NSAM code to enable experiment running on it. --- .../baseline_algorithms_version/__init__.py | 0 .../naive_convex_hull_learner.py | 174 +++++++++ ...nvex_hull_learner_no_dependency_removal.py | 166 ++++++++ .../naive_linear_regression_learner.py | 364 ++++++++++++++++++ .../naive_numeric_fluent_learner_algorithm.py | 95 +++++ ...learner_algorithm_no_dependency_removal.py | 98 +++++ ...e_polynomial_fluents_learning_algorithm.py | 118 ++++++ .../learners/baseline_learners/__init__.py | 0 .../baseline_learners/naive_numeric_sam.py | 197 ++++++++++ ...naive_numeric_sam_no_dependency_removal.py | 202 ++++++++++ 10 files changed, 1414 insertions(+) create mode 100644 sam_learning/core/baseline_algorithms_version/__init__.py create mode 100644 sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner.py create mode 100644 sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner_no_dependency_removal.py create mode 100644 sam_learning/core/baseline_algorithms_version/naive_linear_regression_learner.py create mode 100644 sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm.py create mode 100644 sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm_no_dependency_removal.py create mode 100644 sam_learning/core/baseline_algorithms_version/naive_polynomial_fluents_learning_algorithm.py create mode 100644 sam_learning/learners/baseline_learners/__init__.py create mode 100644 sam_learning/learners/baseline_learners/naive_numeric_sam.py create mode 100644 sam_learning/learners/baseline_learners/naive_numeric_sam_no_dependency_removal.py diff --git a/sam_learning/core/baseline_algorithms_version/__init__.py b/sam_learning/core/baseline_algorithms_version/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner.py b/sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner.py new file mode 100644 index 00000000..cce1ef62 --- /dev/null +++ b/sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner.py @@ -0,0 +1,174 @@ +"""This module contains the ConvexHullLearner class.""" +import logging +import os +from pathlib import Path +from typing import Dict, List, Tuple, Optional + +import matplotlib.pyplot as plt +import numpy as np +from pandas import DataFrame, Series +from pddl_plus_parser.models import Precondition, PDDLFunction +from scipy.spatial import ConvexHull, convex_hull_plot_2d, QhullError + +from sam_learning.core import NotSafeActionError +from sam_learning.core.learning_types import ConditionType, EquationSolutionType +from sam_learning.core.numeric_learning.numeric_utils import ( + construct_multiplication_strings, + construct_linear_equation_string, + prettify_coefficients, + construct_numeric_conditions, + filter_constant_features, + detect_linear_dependent_features, +) + +EPSILON = 1e-10 + + +class NaiveConvexHullLearner: + """Class that learns the convex hull of the preconditions of an action.""" + + logger: logging.Logger + action_name: str + domain_functions: Dict[str, PDDLFunction] + convex_hull_error_file_path: Path + + def __init__(self, action_name: str, domain_functions: Dict[str, PDDLFunction]): + self.logger = logging.getLogger(__name__) + self.convex_hull_error_file_path = Path(os.environ["CONVEX_HULL_ERROR_PATH"]) + self.action_name = action_name + self.domain_functions = domain_functions + + def _execute_convex_hull(self, points: np.ndarray, display_mode: bool = True) -> Tuple[List[List[float]], List[float]]: + """Runs the convex hull algorithm on the given input points. + + :param points: the points to run the convex hull algorithm on. + :param display_mode: whether to display the convex hull. + :return: the coefficients of the planes that represent the convex hull and the border point. + """ + hull = ConvexHull(points) + self._display_convex_hull(display_mode, hull, points.shape[1]) + + A = hull.equations[:, : points.shape[1]] + b = -hull.equations[:, points.shape[1]] + coefficients = [prettify_coefficients(row) for row in A] + border_point = prettify_coefficients(b) + return coefficients, border_point + + def _create_convex_hull_linear_inequalities( + self, points_df: DataFrame, display_mode: bool = True + ) -> Tuple[List[List[float]], List[float], List[str], Optional[List[str]]]: + """Create the convex hull and returns the matrix representing the inequalities. + + :param points_df: the dataframe containing the points that represent the values of the function in the states + of the observations prior to the action's execution. + :return: the matrix representing the inequalities of the planes created by the convex hull as well as the + names of the features that are part of the convex hull. + + Note: the returned values represents the linear inequalities of the convex hull, i.e., Ax <= b. + """ + self.logger.debug(f"Creating convex hull for action {self.action_name}.") + extra_conditions = [] + columns_to_use = points_df.columns.tolist() + if points_df.shape[1] == 1: + points = points_df.to_numpy() + self.logger.debug("The convex hull is single dimensional, creating min-max conditions on the new basis.") + coefficients = [[-1], [1]] + border_point = prettify_coefficients([-points.min(), points.max()]) + + else: + no_constant_columns_matrix, equality_strs, removed_fluents = filter_constant_features(points_df) + filtered_previous_state_matrix, column_equality_strs, removed_fluents = detect_linear_dependent_features(no_constant_columns_matrix) + coefficients, border_point = self._execute_convex_hull(filtered_previous_state_matrix.to_numpy(), display_mode) + extra_conditions = [*equality_strs, *column_equality_strs] + columns_to_use = filtered_previous_state_matrix.columns.tolist() + + return coefficients, border_point, columns_to_use, extra_conditions + + @staticmethod + def _construct_pddl_inequality_scheme( + coefficient_matrix: np.ndarray, border_points: np.ndarray, headers: List[str], sign_to_use: str = "<=" + ) -> List[str]: + """Construct the inequality strings in the appropriate PDDL format. + + :param coefficient_matrix: the matrix containing the coefficient vectors for each inequality. + :param border_points: the convex hull point which ensures that Ax <= b. + :param headers: the headers of the columns in the coefficient matrix. + :param sign_to_use: the sign to use in the inequalities (for cases when we want to use equalities). + :return: the inequalities PDDL formatted strings. + """ + inequalities = set() + for inequality_coefficients, border_point in zip(coefficient_matrix, border_points): + multiplication_functions = construct_multiplication_strings(inequality_coefficients, headers) + constructed_left_side = construct_linear_equation_string(multiplication_functions) + inequalities.add(f"({sign_to_use} {constructed_left_side} {border_point})") + + return list(inequalities) + + def _display_convex_hull(self, display_mode: bool, hull: ConvexHull, num_dimensions: int) -> None: + """Displays the convex hull in as a plot. + + :param display_mode: whether to display the plot. + :param hull: the convex hull to display. + :param num_dimensions: the number of dimensions of the original data. + """ + if not display_mode: + return + + if num_dimensions == 2: + _ = convex_hull_plot_2d(hull) + plt.title(f"{self.action_name} - convex hull") + plt.show() + + elif num_dimensions == 3: + fig = plt.figure() + ax = fig.add_subplot(111, projection="3d") + ax.plot_trisurf(hull.points[:, 0], hull.points[:, 1], hull.points[:, 2], triangles=hull.simplices) + plt.title(f"{self.action_name} - convex hull") + plt.show() + + def _construct_single_dimension_inequalities(self, single_column_df: Series, equality_strs: List[str] = []) -> Precondition: + """Construct a single dimension precondition representation. + + :param single_column_df: the fluent only fluent that is relevant to the preconditions' creation. + :param equality_strs: the equality conditions that are already present in the preconditions. + :return: the preconditions string and the condition type. + """ + min_value = single_column_df.min() + max_value = single_column_df.max() + relevant_fluent = single_column_df.name + if min_value == max_value: + conditions = [f"(= {relevant_fluent} {min_value})"] + else: + conditions = [f"(>= {relevant_fluent} {min_value})", f"(<= {relevant_fluent} {max_value})"] + + conditions.extend(equality_strs) + return construct_numeric_conditions(conditions, condition_type=ConditionType.conjunctive, domain_functions=self.domain_functions) + + def construct_safe_linear_inequalities(self, state_storge: Dict[str, List[float]], relevant_fluents: Optional[List[str]] = []) -> Precondition: + """Constructs the linear inequalities strings that will be used in the learned model later. + + :return: the inequality strings and the type of equations that were constructed (injunctive / disjunctive) + """ + irrelevant_fluents = [fluent for fluent in state_storge.keys() if fluent not in relevant_fluents] if relevant_fluents is not None else [] + state_data = DataFrame(state_storge).drop_duplicates().drop(columns=irrelevant_fluents) + if relevant_fluents is not None and len(relevant_fluents) == 1: + self.logger.debug("Only one dimension is needed in the preconditions!") + return self._construct_single_dimension_inequalities(state_data.loc[:, relevant_fluents[0]]) + + try: + A, b, column_names, additional_projection_conditions = self._create_convex_hull_linear_inequalities(state_data, display_mode=False) + inequalities_strs = self._construct_pddl_inequality_scheme(A, b, column_names) + if additional_projection_conditions is not None: + inequalities_strs.extend(additional_projection_conditions) + + return construct_numeric_conditions(inequalities_strs, condition_type=ConditionType.conjunctive, domain_functions=self.domain_functions) + + except (QhullError, ValueError): + self.logger.warning( + "Convex hull failed to create a convex hull, using disjunctive preconditions " + "(probably since the rank of the matrix is 2 and it cannot create a degraded " + "convex hull)." + ) + raise NotSafeActionError( + name=self.action_name, reason="Convex hull failed to create a convex hull", solution_type=EquationSolutionType.convex_hull_not_found + ) diff --git a/sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner_no_dependency_removal.py b/sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner_no_dependency_removal.py new file mode 100644 index 00000000..f5c3b1c3 --- /dev/null +++ b/sam_learning/core/baseline_algorithms_version/naive_convex_hull_learner_no_dependency_removal.py @@ -0,0 +1,166 @@ +"""This module contains the ConvexHullLearner class.""" +import logging +import os +from pathlib import Path +from typing import Dict, List, Tuple, Optional + +import matplotlib.pyplot as plt +import numpy as np +from pandas import DataFrame, Series +from pddl_plus_parser.models import Precondition, PDDLFunction +from scipy.spatial import ConvexHull, convex_hull_plot_2d, QhullError + +from sam_learning.core import NotSafeActionError +from sam_learning.core.learning_types import ConditionType, EquationSolutionType +from sam_learning.core.numeric_learning.numeric_utils import ( + construct_multiplication_strings, + construct_linear_equation_string, + prettify_coefficients, + construct_numeric_conditions, +) + + +class NaiveConvexHullLearnerNoDependencyRemoval: + """Class that learns the convex hull of the preconditions of an action.""" + + logger: logging.Logger + action_name: str + domain_functions: Dict[str, PDDLFunction] + convex_hull_error_file_path: Path + + def __init__(self, action_name: str, domain_functions: Dict[str, PDDLFunction]): + self.logger = logging.getLogger(__name__) + self.convex_hull_error_file_path = Path(os.environ["CONVEX_HULL_ERROR_PATH"]) + self.action_name = action_name + self.domain_functions = domain_functions + + def _execute_convex_hull(self, points: np.ndarray, display_mode: bool = True) -> Tuple[List[List[float]], List[float]]: + """Runs the convex hull algorithm on the given input points. + + :param points: the points to run the convex hull algorithm on. + :param display_mode: whether to display the convex hull. + :return: the coefficients of the planes that represent the convex hull and the border point. + """ + hull = ConvexHull(points) + self._display_convex_hull(display_mode, hull, points.shape[1]) + + A = hull.equations[:, : points.shape[1]] + b = -hull.equations[:, points.shape[1]] + coefficients = [prettify_coefficients(row) for row in A] + border_point = prettify_coefficients(b) + return coefficients, border_point + + def _create_convex_hull_linear_inequalities( + self, points_df: DataFrame, display_mode: bool = True + ) -> Tuple[List[List[float]], List[float], List[str], Optional[List[str]]]: + """Create the convex hull and returns the matrix representing the inequalities. + + :param points_df: the dataframe containing the points that represent the values of the function in the states + of the observations prior to the action's execution. + :return: the matrix representing the inequalities of the planes created by the convex hull as well as the + names of the features that are part of the convex hull. + + Note: the returned values represents the linear inequalities of the convex hull, i.e., Ax <= b. + """ + self.logger.debug(f"Creating convex hull for action {self.action_name}.") + columns_to_use = points_df.columns.tolist() + if points_df.shape[1] == 1: + points = points_df.to_numpy() + self.logger.debug("The convex hull is single dimensional, creating min-max conditions on the new basis.") + coefficients = [[-1], [1]] + border_point = prettify_coefficients([-points.min(), points.max()]) + + else: + coefficients, border_point = self._execute_convex_hull(points_df.to_numpy(), display_mode) + columns_to_use = points_df.columns.tolist() + + return coefficients, border_point, columns_to_use, [] + + @staticmethod + def _construct_pddl_inequality_scheme( + coefficient_matrix: np.ndarray, border_points: np.ndarray, headers: List[str], sign_to_use: str = "<=" + ) -> List[str]: + """Construct the inequality strings in the appropriate PDDL format. + + :param coefficient_matrix: the matrix containing the coefficient vectors for each inequality. + :param border_points: the convex hull point which ensures that Ax <= b. + :param headers: the headers of the columns in the coefficient matrix. + :param sign_to_use: the sign to use in the inequalities (for cases when we want to use equalities). + :return: the inequalities PDDL formatted strings. + """ + inequalities = set() + for inequality_coefficients, border_point in zip(coefficient_matrix, border_points): + multiplication_functions = construct_multiplication_strings(inequality_coefficients, headers) + constructed_left_side = construct_linear_equation_string(multiplication_functions) + inequalities.add(f"({sign_to_use} {constructed_left_side} {border_point})") + + return list(inequalities) + + def _display_convex_hull(self, display_mode: bool, hull: ConvexHull, num_dimensions: int) -> None: + """Displays the convex hull in as a plot. + + :param display_mode: whether to display the plot. + :param hull: the convex hull to display. + :param num_dimensions: the number of dimensions of the original data. + """ + if not display_mode: + return + + if num_dimensions == 2: + _ = convex_hull_plot_2d(hull) + plt.title(f"{self.action_name} - convex hull") + plt.show() + + elif num_dimensions == 3: + fig = plt.figure() + ax = fig.add_subplot(111, projection="3d") + ax.plot_trisurf(hull.points[:, 0], hull.points[:, 1], hull.points[:, 2], triangles=hull.simplices) + plt.title(f"{self.action_name} - convex hull") + plt.show() + + def _construct_single_dimension_inequalities(self, single_column_df: Series, equality_strs: List[str] = []) -> Precondition: + """Construct a single dimension precondition representation. + + :param single_column_df: the fluent only fluent that is relevant to the preconditions' creation. + :param equality_strs: the equality conditions that are already present in the preconditions. + :return: the preconditions string and the condition type. + """ + min_value = single_column_df.min() + max_value = single_column_df.max() + relevant_fluent = single_column_df.name + if min_value == max_value: + conditions = [f"(= {relevant_fluent} {min_value})"] + else: + conditions = [f"(>= {relevant_fluent} {min_value})", f"(<= {relevant_fluent} {max_value})"] + + conditions.extend(equality_strs) + return construct_numeric_conditions(conditions, condition_type=ConditionType.conjunctive, domain_functions=self.domain_functions) + + def construct_safe_linear_inequalities(self, state_storge: Dict[str, List[float]], relevant_fluents: Optional[List[str]] = []) -> Precondition: + """Constructs the linear inequalities strings that will be used in the learned model later. + + :return: the inequality strings and the type of equations that were constructed (injunctive / disjunctive) + """ + irrelevant_fluents = [fluent for fluent in state_storge.keys() if fluent not in relevant_fluents] if relevant_fluents is not None else [] + state_data = DataFrame(state_storge).drop_duplicates().drop(columns=irrelevant_fluents) + if relevant_fluents is not None and len(relevant_fluents) == 1: + self.logger.debug("Only one dimension is needed in the preconditions!") + return self._construct_single_dimension_inequalities(state_data.loc[:, relevant_fluents[0]]) + + try: + A, b, column_names, additional_projection_conditions = self._create_convex_hull_linear_inequalities(state_data, display_mode=False) + inequalities_strs = self._construct_pddl_inequality_scheme(A, b, column_names) + if additional_projection_conditions is not None: + inequalities_strs.extend(additional_projection_conditions) + + return construct_numeric_conditions(inequalities_strs, condition_type=ConditionType.conjunctive, domain_functions=self.domain_functions) + + except (QhullError, ValueError): + self.logger.warning( + "Convex hull failed to create a convex hull, using disjunctive preconditions " + "(probably since the rank of the matrix is 2 and it cannot create a degraded " + "convex hull)." + ) + raise NotSafeActionError( + name=self.action_name, reason="Convex hull failed to create a convex hull", solution_type=EquationSolutionType.convex_hull_not_found + ) diff --git a/sam_learning/core/baseline_algorithms_version/naive_linear_regression_learner.py b/sam_learning/core/baseline_algorithms_version/naive_linear_regression_learner.py new file mode 100644 index 00000000..e78d3a4f --- /dev/null +++ b/sam_learning/core/baseline_algorithms_version/naive_linear_regression_learner.py @@ -0,0 +1,364 @@ +"""This module contains the LinearRegressionLearner class.""" +import logging +from typing import Optional, List, Dict, Tuple, Set + +import numpy as np +from pandas import DataFrame +from pddl_plus_parser.models import Precondition, NumericalExpressionTree, PDDLFunction +from sklearn.linear_model import LinearRegression + +from sam_learning.core.exceptions import NotSafeActionError +from sam_learning.core.learning_types import EquationSolutionType, ConditionType +from sam_learning.core.numeric_learning.numeric_utils import ( + detect_linear_dependent_features, + construct_linear_equation_string, + construct_non_circular_assignment, + construct_multiplication_strings, + prettify_coefficients, + construct_numeric_conditions, + construct_numeric_effects, + filter_constant_features, + get_num_independent_equations, +) + +LABEL_COLUMN = "label" +NEXT_STATE_PREFIX = "next_state_" # prefix for the next state variables. +SUBTRACTION_COLUMN = "subtraction" +POST_NEXT_STATE_PREFIX_INDEX = len(NEXT_STATE_PREFIX) + +LEGAL_LEARNING_SCORE = 1.00 + + +class NaiveLinearRegressionLearner: + def __init__(self, action_name: str, domain_functions: Dict[str, PDDLFunction]): + self.logger = logging.getLogger(__name__) + self.action_name = action_name + self.domain_functions = domain_functions + + @staticmethod + def _combine_states_data( + prev_state: Dict[str, List[float]], next_state: Dict[str, List[float]], relevant_fluents: Optional[List[str]] = None + ) -> DataFrame: + """Combines the previous and next states data into a single dataframe. + + :param prev_state: the previous state data. + :param next_state: the next state data. + :param relevant_fluents: the relevant fluents to use. + :return: the combined dataframe. + """ + pre_state_fluents_to_use = ( + [key for key in prev_state.keys() if key in relevant_fluents] if relevant_fluents is not None else list(prev_state.keys()) + ) + next_state_fluents_to_use = ( + [fluent for fluent in next_state.keys() if fluent in relevant_fluents] if relevant_fluents is not None else list(next_state.keys()) + ) + combined_data = {fluent_name: fluent_values for fluent_name, fluent_values in prev_state.items() if fluent_name in pre_state_fluents_to_use} + combined_data.update( + { + f"{NEXT_STATE_PREFIX}{fluent_name}": fluent_values + for fluent_name, fluent_values in next_state.items() + if fluent_name in next_state_fluents_to_use + } + ) + dataframe = DataFrame(combined_data).fillna(0) + dataframe.drop_duplicates(inplace=True) + return dataframe + + def _validate_legal_equations(self, values_df: DataFrame) -> bool: + """Validates that there are enough independent equations which enable for a single solution for the equation. + + :param values_df: the matrix constructed based on the observations. + """ + if len(values_df) == 1: + return False + + num_dimensions = len(values_df.columns.tolist()) + 1 # +1 for the bias. + num_independent_rows = get_num_independent_equations(values_df) + if num_independent_rows >= num_dimensions: + return True + + failure_reason = ( + f"There are too few independent rows of data! " f"cannot create a linear equation from the current data for action - {self.action_name}!" + ) + self.logger.warning(failure_reason) + + return False + + def _solve_regression_problem( + self, values_matrix: np.ndarray, function_post_values: np.ndarray, allow_unsafe_learning: bool = True + ) -> Tuple[List[float], float]: + """Solves the polynomial equations using a matrix form. + + Note: the equation Ax=b is solved as: x = inverse(A)*b. + + :param values_matrix: the A matrix that contains the previous values of the function variables. + :param function_post_values: the resulting values after the linear change. + :param allow_unsafe_learning: whether to allow unsafe learning. + :return: the vector representing the coefficients for the function variables and the learning score (R^2). + """ + regressor = LinearRegression() + regressor.fit(values_matrix, function_post_values) + learning_score = regressor.score(values_matrix, function_post_values) + if learning_score < LEGAL_LEARNING_SCORE: + reason = ( + f"The learned effects are not safe since the R^2 is not high enough. " + f"Got R^2 of {learning_score} and expected {LEGAL_LEARNING_SCORE}! " + f"Action {self.action_name} is not safe!" + ) + self.logger.warning(reason) + if not allow_unsafe_learning: + raise NotSafeActionError(self.action_name, reason, EquationSolutionType.no_solution_found) + + coefficients = list(regressor.coef_) + [regressor.intercept_] + coefficients = prettify_coefficients(coefficients) + self.logger.debug(f"Learned the coefficients for the numeric equations with r^2 score of {learning_score}") + return coefficients, learning_score + + def _calculate_scale_factor(self, coefficient_vector: List[float], regression_df: DataFrame, lifted_function: str) -> Optional[float]: + """ + + :param coefficient_vector: + :param regression_df: + :param lifted_function: + :return: + """ + if ( + lifted_function in regression_df.columns + and coefficient_vector[list(regression_df.columns).index(lifted_function)] != 0 + and all( + [ + coefficient_vector[list(regression_df.columns).index(function)] == 0 + for function in regression_df.columns + if function != lifted_function + ] + ) + ): + self.logger.debug(f"The value of the {lifted_function} is being scaled by a constant factor!") + return round(coefficient_vector[list(regression_df.columns).index(lifted_function)], 10) + + return None + + def _extract_non_zero_change(self, lifted_function: str, regression_df: DataFrame) -> Tuple[float, float]: + """ + + :param lifted_function: + :param regression_df: + :return: + """ + previous_value = next_value = 0.0 + for i in range(len(regression_df)): + previous_value = regression_df.iloc[i][lifted_function] + next_value = regression_df.iloc[i][LABEL_COLUMN] + if previous_value != next_value: + break + return next_value, previous_value + + def _solve_linear_equations(self, lifted_function: str, regression_df: DataFrame, allow_unsafe_learning: bool = False) -> Optional[str]: + """Computes the change in the function value based on the previous values of the function. + + Note: We assume in this stage that the change results from a polynomial function of the previous values. + + :param lifted_function: the function to compute the change for. + :param regression_df: the matrix containing the previous values of the function and the resulting values. + :param allow_unsafe_learning: whether to allow unsafe learning. + :return: the string representing the polynomial function change in PDDL+ format. + """ + regression_array = np.array(regression_df.loc[:, regression_df.columns != LABEL_COLUMN]) + function_post_values = np.array(regression_df[LABEL_COLUMN]) + coefficient_vector, learning_score = self._solve_regression_problem(regression_array, function_post_values, allow_unsafe_learning) + + if all([coef == 0 for coef in coefficient_vector]) and len(regression_df[LABEL_COLUMN].unique()) == 1: + self.logger.debug( + "The algorithm designated a vector of zeros to the equation " + "which means that there are no coefficients and the label is also constant value." + ) + return f"(assign {lifted_function} {regression_df[LABEL_COLUMN].unique()[0]})" + + functions_and_dummy = list(regression_df.columns[:-1]) + ["(dummy)"] + scale_factor = self._calculate_scale_factor(coefficient_vector, regression_df, lifted_function) + if scale_factor is not None: + if scale_factor == 1: + self.logger.debug("The scale factor is 1, there is no change in the function value.") + return None + + scale = "scale-up" if scale_factor > 1 else "scale-down" + self.logger.debug("Currently supporting only scaling of the function by a constant value.") + return f"({scale} {lifted_function} {scale_factor})" + + if lifted_function in regression_df.columns and coefficient_vector[list(regression_df.columns).index(lifted_function)] != 0: + self.logger.debug("the function is a part of the equation, cannot use circular format!") + coefficients_map = {lifted_func: coef for lifted_func, coef in zip(functions_and_dummy, coefficient_vector)} + next_value, previous_value = self._extract_non_zero_change(lifted_function, regression_df) + return construct_non_circular_assignment(lifted_function, coefficients_map, previous_value, next_value) + + multiplication_functions = construct_multiplication_strings(coefficient_vector, functions_and_dummy) + if len(multiplication_functions) == 0: + self.logger.debug( + "The algorithm designated a vector of zeros to the equation " "which means that there are not coefficients. Continuing." + ) + return None + + constructed_right_side = construct_linear_equation_string(multiplication_functions) + return f"(assign {lifted_function} {constructed_right_side})" + + @staticmethod + def _extract_const_conditions(precondition_statements: List[List[str]], additional_conditions: List[str] = None) -> List[str]: + """Extract conditions that are constant for all the samples. + + Note: + These preconditions that appear in all samples will be considered axioms and will be added the + general preconditions to create a more compact version of the action's preconditions. + + :param precondition_statements: the precondition statements. + :return: the extracted constant conditions. + """ + const_preconditions = set(additional_conditions) if additional_conditions else set() + for conjunction_statement in precondition_statements: + for condition in conjunction_statement: + if all([condition in other_conjunction for other_conjunction in precondition_statements]): + const_preconditions.add(condition) + continue + + for conjunction_statement in precondition_statements: + for const_condition in const_preconditions: + if const_condition in conjunction_statement: + conjunction_statement.remove(const_condition) + + return list(const_preconditions) + + def _construct_restrictive_numeric_preconditions(self, combined_data: DataFrame, additional_conditions: List[str] = None) -> Precondition: + """Constructs the restrictive numeric preconditions for the action. + + Note: + if there is only one sample we create an AND condition, otherwise we create an OR condition. + + :param combined_data: the combined data of the previous and next states. + :return: the restrictive numeric preconditions. + """ + self.logger.debug(f"Constructing the restrictive numeric preconditions for the action {self.action_name}.") + precondition_statements = [] + for index, row in combined_data.iterrows(): + current_data_conditions = [f"(= {fluent} {row[fluent]})" for fluent in combined_data.columns if not fluent.startswith(NEXT_STATE_PREFIX)] + precondition_statements.append(current_data_conditions) + + if len(precondition_statements) == 1: + return construct_numeric_conditions(precondition_statements[0], ConditionType.conjunctive, self.domain_functions) + + const_preconditions = self._extract_const_conditions(precondition_statements, additional_conditions) + combined_preconditions = None + if len(const_preconditions) > 0: + combined_preconditions = construct_numeric_conditions(const_preconditions, ConditionType.conjunctive, self.domain_functions) + + disjunctive_preconditions = Precondition("or") + for precondition_statement in precondition_statements: + disjunctive_preconditions.add_condition( + construct_numeric_conditions(precondition_statement, ConditionType.conjunctive, self.domain_functions) + ) + + if combined_preconditions is not None: + combined_preconditions.add_condition(disjunctive_preconditions) + return combined_preconditions + + return disjunctive_preconditions + + def _construct_effect_from_single_sample( + self, prev_state_data: Dict[str, List[float]], next_state_data: Dict[str, List[float]] + ) -> Set[NumericalExpressionTree]: + """constructs the effect from a single sample. + + :return: the constructed numeric effect. + """ + assignment_statements = [] + for fluent in next_state_data: + if fluent not in prev_state_data: + raise ValueError(f"The fluent {fluent} is not in the previous state data.") + + if prev_state_data[fluent][0] != next_state_data[fluent][0]: + assignment_statements.append(f"(assign {fluent} {next_state_data[fluent][0]})") + + return construct_numeric_effects(assignment_statements, self.domain_functions) + + def _filter_out_constant_effects(self, constant_features: List[str], combined_data_df: DataFrame, filtered_df: DataFrame) -> List[str]: + """ + + :param constant_features: + :param combined_data_df: + :param filtered_df: + :return: + """ + removed_next_state_variables = [] + for constant_feature in constant_features: + if combined_data_df[constant_feature].equals(combined_data_df[f"{NEXT_STATE_PREFIX}{constant_feature}"]): + self.logger.info( + f"The value of the {constant_feature} is the same before and " + f"after the application of the action. The value is constant so it is not needed!" + ) + filtered_df.drop(f"{NEXT_STATE_PREFIX}{constant_feature}", axis=1, inplace=True) + removed_next_state_variables.append(constant_feature) + + return removed_next_state_variables + + def construct_assignment_equations( + self, + previous_state_data: Dict[str, List[float]], + next_state_data: Dict[str, List[float]], + allow_unsafe_learning: bool = False, + relevant_fluents: Optional[List[str]] = None, + ) -> Tuple[Set[NumericalExpressionTree], Optional[Precondition], bool]: + """Constructs the assignment statements for the action according to the changed value functions. + + :param previous_state_data: the data of the previous state. + :param next_state_data: the data of the next state. + :param allow_unsafe_learning: whether to allow unsafe learning. + :param relevant_fluents: the fluents that are part of the action's preconditions and effects. + :return: the constructed effects with the possibly additional preconditions. + """ + if relevant_fluents is not None and len(relevant_fluents) == 0: + self.logger.debug(f"No numeric effects for the action {self.action_name}.") + return set(), None, False + + assignment_statements = [] + relevant_next_state_fluents = [fluent_name for fluent_name in next_state_data.keys() if fluent_name in relevant_fluents] if relevant_fluents else list(next_state_data.keys()) + self.logger.info(f"Constructing the fluent assignment equations for action {self.action_name}.") + combined_data = self._combine_states_data(previous_state_data, next_state_data, relevant_fluents) + if combined_data.shape[0] == 1: + raise NotSafeActionError( + name=self.action_name, reason="Not enough data to learn the numeric effects!", solution_type=EquationSolutionType.not_enough_data + ) + + # The dataset contains more than one observation. + self.logger.debug("Removing fluents that are constant zero...") + tagged_next_state_fluents = [f"{NEXT_STATE_PREFIX}{fluent_name}" for fluent_name in relevant_next_state_fluents] + filtered_df, constant_features_conditions, constant_features = filter_constant_features( + combined_data, columns_to_ignore=tagged_next_state_fluents + ) + features_df = filtered_df.copy()[[k for k in previous_state_data.keys() if k in filtered_df.columns]] + regression_df, linear_dep_conditions, dependent_columns = detect_linear_dependent_features(features_df) + combined_conditions = linear_dep_conditions + constant_features_conditions + tested_fluents_names = [fluent_name for fluent_name in relevant_next_state_fluents] + is_safe_to_learn = self._validate_legal_equations(regression_df) + for feature_fluent, tagged_fluent in zip(tested_fluents_names, tagged_next_state_fluents): + regression_df[LABEL_COLUMN] = combined_data[tagged_fluent] + if combined_data[feature_fluent].equals(combined_data[tagged_fluent]) and is_safe_to_learn: + self.logger.debug( + f"The value of the {feature_fluent} is the same before and " + f"after the application of the action. The action does not change the value!" + ) + continue + + polynomial_equation = self._solve_linear_equations(feature_fluent, regression_df, allow_unsafe_learning=allow_unsafe_learning) + + if polynomial_equation is not None: + assignment_statements.append(polynomial_equation) + + if not is_safe_to_learn: + raise NotSafeActionError( + name=self.action_name, reason="Not enough data to learn the numeric effects!", solution_type=EquationSolutionType.not_enough_data + ) + + self.logger.info(f"Finished constructing the assignment statements for the action {self.action_name}.") + return ( + construct_numeric_effects(assignment_statements, self.domain_functions), + construct_numeric_conditions(combined_conditions, ConditionType.conjunctive, self.domain_functions), + True, + ) diff --git a/sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm.py b/sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm.py new file mode 100644 index 00000000..a06ff9b4 --- /dev/null +++ b/sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm.py @@ -0,0 +1,95 @@ +"""Module that stores amd learns an action's numeric state fluents.""" +import logging +from collections import defaultdict +from typing import Dict, List, Tuple, Optional, Set + +import numpy as np +from pddl_plus_parser.models import PDDLFunction, Precondition, NumericalExpressionTree + +from sam_learning.core import NotSafeActionError, EquationSolutionType +from sam_learning.core.baseline_algorithms_version.naive_convex_hull_learner import NaiveConvexHullLearner +from sam_learning.core.baseline_algorithms_version.naive_linear_regression_learner import NaiveLinearRegressionLearner + +np.seterr(divide='ignore', invalid='ignore') + + +class NaiveNumericFluentStateStorage: + """Stores and learned the numeric state fluents of a single action.""" + + logger: logging.Logger + previous_state_storage: Dict[str, List[float]] # lifted function str -> numeric values. + negative_samples_storage: Dict[str, List[float]] # lifted function str -> numeric values. + next_state_storage: Dict[str, List[float]] # lifted function str -> numeric values. + + def __init__(self, action_name: str, domain_functions: Dict[str, PDDLFunction]): + self.logger = logging.getLogger(__name__) + self.previous_state_storage = defaultdict(list) + self.next_state_storage = defaultdict(list) + self.action_name = action_name + self.convex_hull_learner = NaiveConvexHullLearner(action_name, domain_functions) + self.linear_regression_learner = NaiveLinearRegressionLearner(action_name, domain_functions) + + def add_to_previous_state_storage(self, state_fluents: Dict[str, PDDLFunction]) -> None: + """Adds the matched lifted state fluents to the previous state storage. + + :param state_fluents: the lifted state fluents that were matched for the action. + """ + for state_fluent_lifted_str, state_fluent_data in state_fluents.items(): + self.previous_state_storage[state_fluent_lifted_str].append(state_fluent_data.value) + + def add_to_next_state_storage(self, state_fluents: Dict[str, PDDLFunction]) -> None: + """Adds the matched lifted state fluents to the next state storage. + + :param state_fluents: the lifted state fluents that were matched for the action. + """ + for state_fluent_lifted_str, state_fluent_data in state_fluents.items(): + self.next_state_storage[state_fluent_lifted_str].append(state_fluent_data.value) + if len(self.previous_state_storage.get(state_fluent_lifted_str, [])) != \ + len(self.next_state_storage[state_fluent_lifted_str]): + self.logger.debug("This is a case where effects create new fluents - should adjust the previous state.") + self.previous_state_storage[state_fluent_lifted_str].append(0) + + def filter_out_inconsistent_state_variables(self) -> None: + """Filters out fluents that appear only in part of the states since they are not safe. + + :return: only the safe state variables that appear in *all* states. + """ + max_function_len = max([len(values) for values in self.previous_state_storage.values()]) + self.previous_state_storage = {lifted_function: state_values for lifted_function, state_values in + self.previous_state_storage.items() if len(state_values) == max_function_len} + self.next_state_storage = {lifted_function: state_values for lifted_function, state_values in + self.next_state_storage.items() if len(state_values) == max_function_len} + + def construct_safe_linear_inequalities( + self, relevant_fluents: Optional[List[str]] = None) -> Precondition: + """Constructs the linear inequalities strings that will be used in the learned model later. + + :return: The precondition that contains the linear inequalities. + """ + return self.convex_hull_learner.construct_safe_linear_inequalities( + self.previous_state_storage, relevant_fluents) + + def _validate_safe_solving(self) -> None: + """Validates that it is safe to apply linear regression to solve the input equations.""" + for lifted_function in self.next_state_storage: + num_variables = len(self.previous_state_storage) + num_equations = len(self.next_state_storage[lifted_function]) + # validate that it is possible to solve linear equations at all. + if num_equations < num_variables or num_equations == num_variables == 1: + failure_reason = f"Cannot solve linear equations when too little input equations given." + self.logger.warning(failure_reason) + raise NotSafeActionError( + self.action_name, failure_reason, EquationSolutionType.not_enough_data) + + def construct_assignment_equations( + self, allow_unsafe_learning: bool = False, relevant_fluents: Optional[List[str]] = None) -> Tuple[ + Set[NumericalExpressionTree], Optional[Precondition], bool]: + """Constructs the assignment statements for the action according to the changed value functions. + + :param allow_unsafe_learning: whether to allow learning from unsafe data. + :param relevant_fluents: the fluents that are part of the action's preconditions and effects. + :return: the constructed assignment statements. + """ + self._validate_safe_solving() + return self.linear_regression_learner.construct_assignment_equations( + self.previous_state_storage, self.next_state_storage, allow_unsafe_learning=allow_unsafe_learning, relevant_fluents=relevant_fluents) diff --git a/sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm_no_dependency_removal.py b/sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm_no_dependency_removal.py new file mode 100644 index 00000000..bf7e2091 --- /dev/null +++ b/sam_learning/core/baseline_algorithms_version/naive_numeric_fluent_learner_algorithm_no_dependency_removal.py @@ -0,0 +1,98 @@ +"""Module that stores amd learns an action's numeric state fluents.""" +import logging +from collections import defaultdict +from typing import Dict, List, Tuple, Optional, Set + +import numpy as np +from pddl_plus_parser.models import PDDLFunction, Precondition, NumericalExpressionTree + +from sam_learning.core import NotSafeActionError, EquationSolutionType +from sam_learning.core.baseline_algorithms_version.naive_convex_hull_learner_no_dependency_removal import NaiveConvexHullLearnerNoDependencyRemoval +from sam_learning.core.baseline_algorithms_version.naive_linear_regression_learner import NaiveLinearRegressionLearner + +np.seterr(divide="ignore", invalid="ignore") + + +class NaiveNumericFluentStateStorageNoDependencyRemoval: + """Stores and learned the numeric state fluents of a single action.""" + + logger: logging.Logger + previous_state_storage: Dict[str, List[float]] # lifted function str -> numeric values. + negative_samples_storage: Dict[str, List[float]] # lifted function str -> numeric values. + next_state_storage: Dict[str, List[float]] # lifted function str -> numeric values. + + def __init__(self, action_name: str, domain_functions: Dict[str, PDDLFunction]): + self.logger = logging.getLogger(__name__) + self.previous_state_storage = defaultdict(list) + self.next_state_storage = defaultdict(list) + self.action_name = action_name + self.convex_hull_learner = NaiveConvexHullLearnerNoDependencyRemoval(action_name, domain_functions) + self.linear_regression_learner = NaiveLinearRegressionLearner(action_name, domain_functions) + + def add_to_previous_state_storage(self, state_fluents: Dict[str, PDDLFunction]) -> None: + """Adds the matched lifted state fluents to the previous state storage. + + :param state_fluents: the lifted state fluents that were matched for the action. + """ + for state_fluent_lifted_str, state_fluent_data in state_fluents.items(): + self.previous_state_storage[state_fluent_lifted_str].append(state_fluent_data.value) + + def add_to_next_state_storage(self, state_fluents: Dict[str, PDDLFunction]) -> None: + """Adds the matched lifted state fluents to the next state storage. + + :param state_fluents: the lifted state fluents that were matched for the action. + """ + for state_fluent_lifted_str, state_fluent_data in state_fluents.items(): + self.next_state_storage[state_fluent_lifted_str].append(state_fluent_data.value) + if len(self.previous_state_storage.get(state_fluent_lifted_str, [])) != len(self.next_state_storage[state_fluent_lifted_str]): + self.logger.debug("This is a case where effects create new fluents - should adjust the previous state.") + self.previous_state_storage[state_fluent_lifted_str].append(0) + + def filter_out_inconsistent_state_variables(self) -> None: + """Filters out fluents that appear only in part of the states since they are not safe. + + :return: only the safe state variables that appear in *all* states. + """ + max_function_len = max([len(values) for values in self.previous_state_storage.values()]) + self.previous_state_storage = { + lifted_function: state_values + for lifted_function, state_values in self.previous_state_storage.items() + if len(state_values) == max_function_len + } + self.next_state_storage = { + lifted_function: state_values + for lifted_function, state_values in self.next_state_storage.items() + if len(state_values) == max_function_len + } + + def construct_safe_linear_inequalities(self, relevant_fluents: Optional[List[str]] = None) -> Precondition: + """Constructs the linear inequalities strings that will be used in the learned model later. + + :return: The precondition that contains the linear inequalities. + """ + return self.convex_hull_learner.construct_safe_linear_inequalities(self.previous_state_storage, relevant_fluents) + + def _validate_safe_solving(self) -> None: + """Validates that it is safe to apply linear regression to solve the input equations.""" + for lifted_function in self.next_state_storage: + num_variables = len(self.previous_state_storage) + num_equations = len(self.next_state_storage[lifted_function]) + # validate that it is possible to solve linear equations at all. + if num_equations < num_variables or num_equations == num_variables == 1: + failure_reason = f"Cannot solve linear equations when too little input equations given." + self.logger.warning(failure_reason) + raise NotSafeActionError(self.action_name, failure_reason, EquationSolutionType.not_enough_data) + + def construct_assignment_equations( + self, allow_unsafe_learning: bool = False, relevant_fluents: Optional[List[str]] = None + ) -> Tuple[Set[NumericalExpressionTree], Optional[Precondition], bool]: + """Constructs the assignment statements for the action according to the changed value functions. + + :param allow_unsafe_learning: whether to allow learning from unsafe data. + :param relevant_fluents: the fluents that are part of the action's preconditions and effects. + :return: the constructed assignment statements. + """ + self._validate_safe_solving() + return self.linear_regression_learner.construct_assignment_equations( + self.previous_state_storage, self.next_state_storage, allow_unsafe_learning=allow_unsafe_learning, relevant_fluents=relevant_fluents + ) diff --git a/sam_learning/core/baseline_algorithms_version/naive_polynomial_fluents_learning_algorithm.py b/sam_learning/core/baseline_algorithms_version/naive_polynomial_fluents_learning_algorithm.py new file mode 100644 index 00000000..d670420d --- /dev/null +++ b/sam_learning/core/baseline_algorithms_version/naive_polynomial_fluents_learning_algorithm.py @@ -0,0 +1,118 @@ +"""Module that learns polynomial preconditions and effects from a domain.""" +import itertools +from typing import Dict, List, Optional + +import numpy +from pddl_plus_parser.models import PDDLFunction, Precondition + +from sam_learning.core.baseline_algorithms_version.naive_numeric_fluent_learner_algorithm import NaiveNumericFluentStateStorage + + +class NaivePolynomialFluentsLearningAlgorithm(NaiveNumericFluentStateStorage): + """Class that learns polynomial preconditions and effects from a domain. + + Note: + If the polynom degree is 0 the algorithm reverts to its linear version. + degree of 1 is the multiplication of each couple of state fluents. + degree 2 and above is the maximal degree of the polynomial. + """ + polynom_degree: int + is_verbose: bool + + def __init__(self, action_name: str, polynom_degree: int, + domain_functions: Dict[str, PDDLFunction], is_verbose: bool = False): + super().__init__(action_name, domain_functions) + self.polynom_degree = polynom_degree + self.is_verbose = is_verbose + + def _create_polynomial_string_recursive(self, fluents: List[str]) -> str: + """Creates the polynomial string representing the equation recursively. + + :param fluents: the numeric fluents to create the polynomial string from. + :return: the polynomial string representing the equation. + """ + if len(fluents) == 1: + return fluents[0] + + return f"(* {fluents[0]} {self._create_polynomial_string_recursive(fluents[1:])})" + + def create_polynomial_string(self, fluents: List[str]) -> str: + """The auxiliary function that creates the polynomial string representing the equation. + + :param fluents: the numeric fluents to create the polynomial string from. + :return: the polynomial string representing the equation. + """ + return self._create_polynomial_string_recursive(fluents) + + def _add_polynom_to_storage(self, state_fluents: Dict[str, PDDLFunction], + storage: Dict[str, List[float]]) -> None: + """Adds the polynomial representation of the state fluents to the storage. + + :param state_fluents: the numeric fluents present in the input state. + :param storage: the storage to update. + """ + if self.polynom_degree == 1: + for first_fluent, second_fluent in itertools.combinations(list(state_fluents.keys()), r=2): + multiplied_fluent = self.create_polynomial_string(sorted([first_fluent, second_fluent])) + storage[multiplied_fluent].append( + state_fluents[first_fluent].value * state_fluents[second_fluent].value) + return + + for degree in range(2, self.polynom_degree + 1): + for fluent_combination in itertools.combinations_with_replacement( + list(state_fluents.keys()), r=degree): + polynomial_fluent = self.create_polynomial_string(sorted(list(fluent_combination))) + values = [state_fluents[fluent].value for fluent in fluent_combination] + self.previous_state_storage[polynomial_fluent].append(numpy.prod(values)) + + def add_to_previous_state_storage(self, state_fluents: Dict[str, PDDLFunction]) -> None: + """Adds the matched lifted state fluents to the previous state storage. + + :param state_fluents: the lifted state fluents that were matched for the action. + """ + super().add_to_previous_state_storage(state_fluents) + if self.polynom_degree == 0: + return + + self._add_polynom_to_storage(state_fluents, self.previous_state_storage) + + def add_to_next_state_storage(self, state_fluents: Dict[str, PDDLFunction]) -> None: + """Adds the matched lifted state fluents to the next state storage. + + :param state_fluents: the lifted state fluents that were matched for the action. + """ + super().add_to_next_state_storage(state_fluents) + if self.polynom_degree == 0: + return + + for fluent in self.next_state_storage: + if len(self.previous_state_storage.get(fluent, [])) != len(self.next_state_storage[fluent]): + self.logger.debug("This is a case where effects create new fluents - should adjust the previous state.") + self.previous_state_storage[fluent].append(0) + + def construct_safe_linear_inequalities( + self, relevant_fluents: Optional[List[str]] = None) -> Precondition: + """Constructs the linear inequalities strings that will be used in the learned model later. + + :return: the inequality strings and the type of equations that were constructed (injunctive / disjunctive) + """ + algorithm_relevant_fluents = relevant_fluents or list(self.previous_state_storage.keys()) + polynomial_relevant_fluents = [*algorithm_relevant_fluents] + if self.polynom_degree == 0: + return super().construct_safe_linear_inequalities(relevant_fluents) + + if self.is_verbose: + self.logger.debug(f"Fluents are given as verbose - so only learning the fluents given as input!") + return super().construct_safe_linear_inequalities(algorithm_relevant_fluents) + + if self.polynom_degree == 1: + for first_fluent, second_fluent in itertools.combinations(relevant_fluents, r=2): + polynomial_relevant_fluents.append(self.create_polynomial_string([first_fluent, second_fluent])) + + return super().construct_safe_linear_inequalities(polynomial_relevant_fluents) + + for degree in range(2, self.polynom_degree + 1): + for fluent_combination in itertools.combinations_with_replacement(relevant_fluents, r=degree): + polynomial_relevant_fluents.append(self.create_polynomial_string(list(fluent_combination))) + + return super().construct_safe_linear_inequalities(polynomial_relevant_fluents) diff --git a/sam_learning/learners/baseline_learners/__init__.py b/sam_learning/learners/baseline_learners/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sam_learning/learners/baseline_learners/naive_numeric_sam.py b/sam_learning/learners/baseline_learners/naive_numeric_sam.py new file mode 100644 index 00000000..24842252 --- /dev/null +++ b/sam_learning/learners/baseline_learners/naive_numeric_sam.py @@ -0,0 +1,197 @@ +"""Extension to SAM Learning that can learn numeric state variables.""" + +from typing import List, Dict, Tuple, Optional + +from pddl_plus_parser.models import Observation, ActionCall, State, Domain + +from sam_learning.core import LearnerDomain, NumericFunctionMatcher, NotSafeActionError, LearnerAction +from sam_learning.core.learner_domain import DISJUNCTIVE_PRECONDITIONS_REQ +from sam_learning.core.baseline_algorithms_version.naive_numeric_fluent_learner_algorithm import NaiveNumericFluentStateStorage +from sam_learning.core.baseline_algorithms_version.naive_polynomial_fluents_learning_algorithm import ( + NaivePolynomialFluentsLearningAlgorithm, +) +from sam_learning.learners.sam_learning import SAMLearner + + +class NaiveNumericSAMLearner(SAMLearner): + """The Extension of SAM that is able to learn numeric state variables.""" + + storage: Dict[str, NaiveNumericFluentStateStorage] + function_matcher: NumericFunctionMatcher + relevant_fluents: Dict[str, List[str]] + + def __init__( + self, + partial_domain: Domain, + relevant_fluents: Optional[Dict[str, List[str]]] = None, + effects_fluent_map: Optional[Dict[str, List[str]]] = None, + **kwargs, + ): + super().__init__(partial_domain) + self.storage = {} + self.function_matcher = NumericFunctionMatcher(partial_domain) + self.relevant_fluents = relevant_fluents + self.effects_fluent_map = effects_fluent_map + + def _construct_safe_numeric_preconditions(self, action: LearnerAction) -> None: + """Constructs the safe preconditions for the input action. + + :param action: the action that the preconditions are constructed for. + """ + action_name = action.name + if self.relevant_fluents is None: + learned_numeric_preconditions = self.storage[action_name].construct_safe_linear_inequalities() + + elif len(self.relevant_fluents[action_name]) == 0: + self.logger.debug(f"The action {action_name} has no numeric preconditions.") + return + + else: + learned_numeric_preconditions = self.storage[action_name].construct_safe_linear_inequalities(self.relevant_fluents[action_name]) + + if learned_numeric_preconditions.binary_operator == "and": + self.logger.debug("The learned preconditions are a conjunction. Adding the internal numeric conditions.") + for condition in learned_numeric_preconditions.operands: + action.preconditions.add_condition(condition) + + return + + self.logger.debug("The learned preconditions are not a conjunction. Adding them as a separate condition.") + action.preconditions.add_condition(learned_numeric_preconditions) + self.partial_domain.requirements.append(DISJUNCTIVE_PRECONDITIONS_REQ) + + def _construct_safe_numeric_effects(self, action: LearnerAction) -> None: + """Constructs the safe numeric effects for the input action. + + :param action: the action that its effects are constructed for. + """ + effects, numeric_preconditions, learned_perfectly = self.storage[action.name].construct_assignment_equations( + relevant_fluents=self.relevant_fluents[action.name] if self.relevant_fluents is not None else None + ) + if effects is not None and len(effects) > 0: + action.numeric_effects = effects + + if self.relevant_fluents is None: + self.logger.debug(f"No feature selection applied, using the numeric preconditions as is.") + return + + self.logger.info(f"The effect of action - {action.name} were learned perfectly.") + if numeric_preconditions is not None: + for cond in numeric_preconditions.operands: + if cond in action.preconditions.root: + continue + + action.preconditions.add_condition(cond) + + return + + def add_new_action(self, grounded_action: ActionCall, previous_state: State, next_state: State) -> None: + """Adds a new action to the learned domain. + + :param grounded_action: the grounded action that was executed according to the observation. + :param previous_state: the state that the action was executed on. + :param next_state: the state that was created after executing the action on the previous + """ + super().add_new_action(grounded_action, previous_state, next_state) + self.logger.debug(f"Creating the new storage for the action - {grounded_action.name}.") + previous_state_lifted_matches = self.function_matcher.match_state_functions( + grounded_action, self.triplet_snapshot.previous_state_functions + ) + next_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, self.triplet_snapshot.next_state_functions) + self.storage[grounded_action.name] = NaiveNumericFluentStateStorage(grounded_action.name, self.partial_domain.functions) + self.storage[grounded_action.name].add_to_previous_state_storage(previous_state_lifted_matches) + self.storage[grounded_action.name].add_to_next_state_storage(next_state_lifted_matches) + self.logger.debug(f"Done creating the numeric state variable storage for the action - {grounded_action.name}") + + def update_action(self, grounded_action: ActionCall, previous_state: State, next_state: State) -> None: + """Updates the action's data according to the new input observed triplet. + + :param grounded_action: the grounded action that was observed. + :param previous_state: the state that the action was executed on. + :param next_state: the state that was created after executing the action on the previous + state. + """ + action_name = grounded_action.name + super().update_action(grounded_action, previous_state, next_state) + self.logger.debug(f"Adding the numeric state variables to the numeric storage of action - {action_name}.") + previous_state_lifted_matches = self.function_matcher.match_state_functions( + grounded_action, self.triplet_snapshot.previous_state_functions + ) + next_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, self.triplet_snapshot.next_state_functions) + self.storage[action_name].add_to_previous_state_storage(previous_state_lifted_matches) + self.storage[action_name].add_to_next_state_storage(next_state_lifted_matches) + self.logger.debug(f"Done updating the numeric state variable storage for the action - {grounded_action.name}") + + def learn_action_model(self, observations: List[Observation]) -> Tuple[LearnerDomain, Dict[str, str]]: + """Learn the SAFE action model from the input observations. + + :param observations: the list of trajectories that are used to learn the safe action model. + :return: a domain containing the actions that were learned and the metadata about the learning. + """ + self.logger.info("Starting to learn the action model!") + super().start_measure_learning_time() + allowed_actions = {} + learning_metadata = {} + super().deduce_initial_inequality_preconditions() + for observation in observations: + self.current_trajectory_objects = observation.grounded_objects + for component in observation.components: + if not component.is_successful: + continue + + self.handle_single_trajectory_component(component) + + for action_name, action in self.partial_domain.actions.items(): + if action_name not in self.storage: + self.logger.debug(f"The action - {action_name} has not been observed in the trajectories!") + continue + + self.storage[action_name].filter_out_inconsistent_state_variables() + try: + self._construct_safe_numeric_preconditions(action) + self._construct_safe_numeric_effects(action) + allowed_actions[action_name] = action + learning_metadata[action_name] = "OK" + self.logger.info(f"Done learning the action - {action_name}!") + + except NotSafeActionError as e: + self.logger.debug(f"The action - {e.action_name} is not safe for execution, reason - {e.reason}") + learning_metadata[action_name] = e.solution_type.name + + self.partial_domain.actions = allowed_actions + + super().end_measure_learning_time() + learning_metadata["learning_time"] = str(self.learning_end_time - self.learning_start_time) + return self.partial_domain, learning_metadata + + +class NaivePolynomialSAMLearning(NaiveNumericSAMLearner): + """The Extension of SAM that is able to learn polynomial state variables.""" + + storage: Dict[str, NaivePolynomialFluentsLearningAlgorithm] + polynom_degree: int + + def __init__( + self, partial_domain: Domain, relevant_fluents: Optional[Dict[str, List[str]]] = None, polynomial_degree: int = 1, **kwargs + ): + super().__init__(partial_domain, relevant_fluents, **kwargs) + self.polynom_degree = polynomial_degree + + def add_new_action(self, grounded_action: ActionCall, previous_state: State, next_state: State) -> None: + """Adds a new action to the learned domain. + + :param grounded_action: the grounded action that was executed according to the observation. + :param previous_state: the state that the action was executed on. + :param next_state: the state that was created after executing the action on the previous + state. + """ + super().add_new_action(grounded_action, previous_state, next_state) + self.logger.debug(f"Creating the new storage for the action - {grounded_action.name}.") + previous_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, previous_state.state_fluents) + next_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, next_state.state_fluents) + self.storage[grounded_action.name] = NaivePolynomialFluentsLearningAlgorithm( + grounded_action.name, self.polynom_degree, self.partial_domain.functions, is_verbose=True + ) + self.storage[grounded_action.name].add_to_previous_state_storage(previous_state_lifted_matches) + self.storage[grounded_action.name].add_to_next_state_storage(next_state_lifted_matches) + self.logger.debug(f"Done creating the numeric state variable storage for the action - {grounded_action.name}") diff --git a/sam_learning/learners/baseline_learners/naive_numeric_sam_no_dependency_removal.py b/sam_learning/learners/baseline_learners/naive_numeric_sam_no_dependency_removal.py new file mode 100644 index 00000000..b3b1f5f7 --- /dev/null +++ b/sam_learning/learners/baseline_learners/naive_numeric_sam_no_dependency_removal.py @@ -0,0 +1,202 @@ +"""Extension to SAM Learning that can learn numeric state variables.""" + +from typing import List, Dict, Tuple, Optional + +from pddl_plus_parser.models import Observation, ActionCall, State, Domain + +from sam_learning.core import LearnerDomain, NumericFunctionMatcher, NotSafeActionError, LearnerAction +from sam_learning.core.baseline_algorithms_version.naive_numeric_fluent_learner_algorithm_no_dependency_removal import ( + NaiveNumericFluentStateStorageNoDependencyRemoval, +) +from sam_learning.core.learner_domain import DISJUNCTIVE_PRECONDITIONS_REQ +from sam_learning.core.baseline_algorithms_version.naive_numeric_fluent_learner_algorithm import NaiveNumericFluentStateStorage +from sam_learning.core.baseline_algorithms_version.naive_polynomial_fluents_learning_algorithm import ( + NaivePolynomialFluentsLearningAlgorithm, +) +from sam_learning.learners.sam_learning import SAMLearner + + +class NaiveNumericSAMLearnerNoDependencyRemoval(SAMLearner): + """The Extension of SAM that is able to learn numeric state variables.""" + + storage: Dict[str, NaiveNumericFluentStateStorageNoDependencyRemoval] + function_matcher: NumericFunctionMatcher + relevant_fluents: Dict[str, List[str]] + + def __init__( + self, + partial_domain: Domain, + relevant_fluents: Optional[Dict[str, List[str]]] = None, + effects_fluent_map: Optional[Dict[str, List[str]]] = None, + **kwargs, + ): + super().__init__(partial_domain) + self.storage = {} + self.function_matcher = NumericFunctionMatcher(partial_domain) + self.relevant_fluents = relevant_fluents + self.effects_fluent_map = effects_fluent_map + + def _construct_safe_numeric_preconditions(self, action: LearnerAction) -> None: + """Constructs the safe preconditions for the input action. + + :param action: the action that the preconditions are constructed for. + """ + action_name = action.name + if self.relevant_fluents is None: + learned_numeric_preconditions = self.storage[action_name].construct_safe_linear_inequalities() + + elif len(self.relevant_fluents[action_name]) == 0: + self.logger.debug(f"The action {action_name} has no numeric preconditions.") + return + + else: + learned_numeric_preconditions = self.storage[action_name].construct_safe_linear_inequalities(self.relevant_fluents[action_name]) + + if learned_numeric_preconditions.binary_operator == "and": + self.logger.debug("The learned preconditions are a conjunction. Adding the internal numeric conditions.") + for condition in learned_numeric_preconditions.operands: + action.preconditions.add_condition(condition) + + return + + self.logger.debug("The learned preconditions are not a conjunction. Adding them as a separate condition.") + action.preconditions.add_condition(learned_numeric_preconditions) + self.partial_domain.requirements.add(DISJUNCTIVE_PRECONDITIONS_REQ) + + def _construct_safe_numeric_effects(self, action: LearnerAction) -> None: + """Constructs the safe numeric effects for the input action. + + :param action: the action that its effects are constructed for. + """ + effects, numeric_preconditions, learned_perfectly = self.storage[action.name].construct_assignment_equations( + relevant_fluents=self.relevant_fluents[action.name] if self.relevant_fluents is not None else None + ) + if effects is not None and len(effects) > 0: + action.numeric_effects = effects + + if self.relevant_fluents is None: + self.logger.debug(f"No feature selection applied, using the numeric preconditions as is.") + return + + self.logger.info(f"The effect of action - {action.name} were learned perfectly.") + if numeric_preconditions is not None: + for cond in numeric_preconditions.operands: + if cond in action.preconditions.root: + continue + + action.preconditions.add_condition(cond) + + return + + def add_new_action(self, grounded_action: ActionCall, previous_state: State, next_state: State) -> None: + """Adds a new action to the learned domain. + + :param grounded_action: the grounded action that was executed according to the observation. + :param previous_state: the state that the action was executed on. + :param next_state: the state that was created after executing the action on the previous + """ + super().add_new_action(grounded_action, previous_state, next_state) + self.logger.debug(f"Creating the new storage for the action - {grounded_action.name}.") + previous_state_lifted_matches = self.function_matcher.match_state_functions( + grounded_action, self.triplet_snapshot.previous_state_functions + ) + next_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, self.triplet_snapshot.next_state_functions) + self.storage[grounded_action.name] = NaiveNumericFluentStateStorageNoDependencyRemoval( + grounded_action.name, self.partial_domain.functions + ) + self.storage[grounded_action.name].add_to_previous_state_storage(previous_state_lifted_matches) + self.storage[grounded_action.name].add_to_next_state_storage(next_state_lifted_matches) + self.logger.debug(f"Done creating the numeric state variable storage for the action - {grounded_action.name}") + + def update_action(self, grounded_action: ActionCall, previous_state: State, next_state: State) -> None: + """Updates the action's data according to the new input observed triplet. + + :param grounded_action: the grounded action that was observed. + :param previous_state: the state that the action was executed on. + :param next_state: the state that was created after executing the action on the previous + state. + """ + action_name = grounded_action.name + super().update_action(grounded_action, previous_state, next_state) + self.logger.debug(f"Adding the numeric state variables to the numeric storage of action - {action_name}.") + previous_state_lifted_matches = self.function_matcher.match_state_functions( + grounded_action, self.triplet_snapshot.previous_state_functions + ) + next_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, self.triplet_snapshot.next_state_functions) + self.storage[action_name].add_to_previous_state_storage(previous_state_lifted_matches) + self.storage[action_name].add_to_next_state_storage(next_state_lifted_matches) + self.logger.debug(f"Done updating the numeric state variable storage for the action - {grounded_action.name}") + + def learn_action_model(self, observations: List[Observation]) -> Tuple[LearnerDomain, Dict[str, str]]: + """Learn the SAFE action model from the input observations. + + :param observations: the list of trajectories that are used to learn the safe action model. + :return: a domain containing the actions that were learned and the metadata about the learning. + """ + self.logger.info("Starting to learn the action model!") + super().start_measure_learning_time() + allowed_actions = {} + learning_metadata = {} + super().deduce_initial_inequality_preconditions() + for observation in observations: + self.current_trajectory_objects = observation.grounded_objects + for component in observation.components: + if not super().are_states_different(component.previous_state, component.next_state): + continue + + self.handle_single_trajectory_component(component) + + for action_name, action in self.partial_domain.actions.items(): + if action_name not in self.storage: + self.logger.debug(f"The action - {action_name} has not been observed in the trajectories!") + continue + + self.storage[action_name].filter_out_inconsistent_state_variables() + try: + self._construct_safe_numeric_preconditions(action) + self._construct_safe_numeric_effects(action) + allowed_actions[action_name] = action + learning_metadata[action_name] = "OK" + self.logger.info(f"Done learning the action - {action_name}!") + + except NotSafeActionError as e: + self.logger.debug(f"The action - {e.action_name} is not safe for execution, reason - {e.reason}") + learning_metadata[action_name] = e.solution_type.name + + self.partial_domain.actions = allowed_actions + + super().end_measure_learning_time() + learning_metadata["learning_time"] = str(self.learning_end_time - self.learning_start_time) + return self.partial_domain, learning_metadata + + +class NaivePolynomialSAMLearningNoDependencyRemoval(NaiveNumericSAMLearnerNoDependencyRemoval): + """The Extension of SAM that is able to learn polynomial state variables.""" + + storage: Dict[str, NaivePolynomialFluentsLearningAlgorithm] + polynom_degree: int + + def __init__( + self, partial_domain: Domain, relevant_fluents: Optional[Dict[str, List[str]]] = None, polynomial_degree: int = 1, **kwargs + ): + super().__init__(partial_domain, relevant_fluents, **kwargs) + self.polynom_degree = polynomial_degree + + def add_new_action(self, grounded_action: ActionCall, previous_state: State, next_state: State) -> None: + """Adds a new action to the learned domain. + + :param grounded_action: the grounded action that was executed according to the observation. + :param previous_state: the state that the action was executed on. + :param next_state: the state that was created after executing the action on the previous + state. + """ + super().add_new_action(grounded_action, previous_state, next_state) + self.logger.debug(f"Creating the new storage for the action - {grounded_action.name}.") + previous_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, previous_state.state_fluents) + next_state_lifted_matches = self.function_matcher.match_state_functions(grounded_action, next_state.state_fluents) + self.storage[grounded_action.name] = NaivePolynomialFluentsLearningAlgorithm( + grounded_action.name, self.polynom_degree, self.partial_domain.functions, is_verbose=True + ) + self.storage[grounded_action.name].add_to_previous_state_storage(previous_state_lifted_matches) + self.storage[grounded_action.name].add_to_next_state_storage(next_state_lifted_matches) + self.logger.debug(f"Done creating the numeric state variable storage for the action - {grounded_action.name}") From f0280103cb04c7cd34182415a33a04cc93386536 Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Wed, 19 Nov 2025 15:53:45 +0200 Subject: [PATCH 02/11] Fixed migration issues with the old algorithms and removed the problem object from the experiments. --- .../parallel_basic_experiment_runner.py | 2 +- experiments/experiments_consts.py | 2 ++ .../baseline_learners/naive_numeric_sam.py | 15 ++++++--------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/experiments/concurrent_execution/parallel_basic_experiment_runner.py b/experiments/concurrent_execution/parallel_basic_experiment_runner.py index 0e51290e..f2773919 100644 --- a/experiments/concurrent_execution/parallel_basic_experiment_runner.py +++ b/experiments/concurrent_execution/parallel_basic_experiment_runner.py @@ -211,7 +211,7 @@ def collect_observations( # assuming that the folders were created so that each folder contains only the correct number of trajectories, i.e., iteration_number problem_path = train_set_dir_path / f"{trajectory_file_path.stem}.pddl" problem = ProblemParser(problem_path, partial_domain).parse_problem() - complete_observation = TrajectoryParser(partial_domain, problem).parse_trajectory( + complete_observation = TrajectoryParser(partial_domain).parse_trajectory( trajectory_file_path, executing_agents=self.executing_agents ) allowed_observations.append(complete_observation) diff --git a/experiments/experiments_consts.py b/experiments/experiments_consts.py index 3e198c01..15d7d219 100644 --- a/experiments/experiments_consts.py +++ b/experiments/experiments_consts.py @@ -1,4 +1,5 @@ from sam_learning.learners import NumericSAMLearner, IncrementalNumericSAMLearner +from sam_learning.learners.baseline_learners.naive_numeric_sam import NaivePolynomialSAMLearning from utilities import LearningAlgorithmType DEFAULT_SPLIT = 5 @@ -19,4 +20,5 @@ NUMERIC_SAM_ALGORITHM_VERSIONS = { LearningAlgorithmType.numeric_sam: NumericSAMLearner, LearningAlgorithmType.incremental_nsam: IncrementalNumericSAMLearner, + LearningAlgorithmType.naive_nsam: NaivePolynomialSAMLearning, } diff --git a/sam_learning/learners/baseline_learners/naive_numeric_sam.py b/sam_learning/learners/baseline_learners/naive_numeric_sam.py index 24842252..e1986db4 100644 --- a/sam_learning/learners/baseline_learners/naive_numeric_sam.py +++ b/sam_learning/learners/baseline_learners/naive_numeric_sam.py @@ -2,10 +2,9 @@ from typing import List, Dict, Tuple, Optional -from pddl_plus_parser.models import Observation, ActionCall, State, Domain +from pddl_plus_parser.models import Observation, ActionCall, State, Domain, Action -from sam_learning.core import LearnerDomain, NumericFunctionMatcher, NotSafeActionError, LearnerAction -from sam_learning.core.learner_domain import DISJUNCTIVE_PRECONDITIONS_REQ +from sam_learning.core import NumericFunctionMatcher, NotSafeActionError from sam_learning.core.baseline_algorithms_version.naive_numeric_fluent_learner_algorithm import NaiveNumericFluentStateStorage from sam_learning.core.baseline_algorithms_version.naive_polynomial_fluents_learning_algorithm import ( NaivePolynomialFluentsLearningAlgorithm, @@ -33,7 +32,7 @@ def __init__( self.relevant_fluents = relevant_fluents self.effects_fluent_map = effects_fluent_map - def _construct_safe_numeric_preconditions(self, action: LearnerAction) -> None: + def _construct_safe_numeric_preconditions(self, action: Action) -> None: """Constructs the safe preconditions for the input action. :param action: the action that the preconditions are constructed for. @@ -56,11 +55,9 @@ def _construct_safe_numeric_preconditions(self, action: LearnerAction) -> None: return - self.logger.debug("The learned preconditions are not a conjunction. Adding them as a separate condition.") - action.preconditions.add_condition(learned_numeric_preconditions) - self.partial_domain.requirements.append(DISJUNCTIVE_PRECONDITIONS_REQ) + raise ValueError(f"The learned numeric preconditions for the action {action.name} are not a conjunction.") - def _construct_safe_numeric_effects(self, action: LearnerAction) -> None: + def _construct_safe_numeric_effects(self, action: Action) -> None: """Constructs the safe numeric effects for the input action. :param action: the action that its effects are constructed for. @@ -122,7 +119,7 @@ def update_action(self, grounded_action: ActionCall, previous_state: State, next self.storage[action_name].add_to_next_state_storage(next_state_lifted_matches) self.logger.debug(f"Done updating the numeric state variable storage for the action - {grounded_action.name}") - def learn_action_model(self, observations: List[Observation]) -> Tuple[LearnerDomain, Dict[str, str]]: + def learn_action_model(self, observations: List[Observation]) -> Tuple[Domain, Dict[str, str]]: """Learn the SAFE action model from the input observations. :param observations: the list of trajectories that are used to learn the safe action model. From 59d05f1d7dd75b7ee4f67ce874971f79ea9e777c Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Mon, 24 Nov 2025 16:15:29 +0200 Subject: [PATCH 03/11] fixed bug in the predicate matcher code. --- sam_learning/core/predicates_matcher.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sam_learning/core/predicates_matcher.py b/sam_learning/core/predicates_matcher.py index 862320cb..7834b920 100644 --- a/sam_learning/core/predicates_matcher.py +++ b/sam_learning/core/predicates_matcher.py @@ -3,7 +3,7 @@ import logging from typing import List, Tuple, Optional -from pddl_plus_parser.models import Domain, Predicate, GroundedPredicate, ActionCall +from pddl_plus_parser.models import Domain, Predicate, GroundedPredicate, ActionCall, PDDLObject from sam_learning.core.vocabulary_creator import VocabularyCreator from sam_learning.core.matching_utils import contains_duplicates, create_signature_permutations @@ -168,9 +168,6 @@ def get_possible_literal_matches( """ self.logger.debug(f"Finding the possible matches for the grounded action - {str(grounded_action_call)}") possible_matches = [] - lifted_signature = self.matcher_domain.actions[grounded_action_call.name].signature - grounded_predicates_vocabulary = self.vocabulary_creator.create_grounded_predicate_vocabulary( - domain=self.matcher_domain, observed_objects={grounded_obj: PDDLObject(grounded_obj, type=lifted_signature.keys()[]) for grounded_obj in grounded_action_call.parameters}) for state_predicate in state_literals: if extra_grounded_object is None or extra_grounded_object not in state_predicate.grounded_objects: possible_matches.extend(self.match_predicate_to_action_literals(state_predicate, grounded_action_call)) From d25c41f8ca77ebcf1dd3fe9328ca94dbe724ecdc Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Mon, 24 Nov 2025 16:49:59 +0200 Subject: [PATCH 04/11] updated plan miner trajectory creator as it had a bug in it.. --- trajectory_creators/plan_miner_trajectories_creator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/trajectory_creators/plan_miner_trajectories_creator.py b/trajectory_creators/plan_miner_trajectories_creator.py index 584ca314..024b21bb 100644 --- a/trajectory_creators/plan_miner_trajectories_creator.py +++ b/trajectory_creators/plan_miner_trajectories_creator.py @@ -97,8 +97,12 @@ def create_plan_miner_trajectories(self) -> None: action = action_triplet.grounded_action_call prev_state = action_triplet.previous_state next_state = action_triplet.next_state - previous_state_predicates = triplet_snapshot.create_discrete_state_snapshot(prev_state, observation.grounded_objects) - next_state_predicates = triplet_snapshot.create_discrete_state_snapshot(next_state, observation.grounded_objects) + previous_state_predicates = triplet_snapshot.create_discrete_state_snapshot( + prev_state, list(observation.grounded_objects.values()) + ) + next_state_predicates = triplet_snapshot.create_discrete_state_snapshot( + next_state, list(observation.grounded_objects.values()) + ) op = Operator( action=domain.actions[action.name], domain=domain, From 6fd86f4ee850d5a4075d432bd74e1d532b1d6b27 Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Tue, 25 Nov 2025 12:26:48 +0200 Subject: [PATCH 05/11] added additional trajectory statistics based on the reviewr's requests/ --- .../parallel_basic_experiment_runner.py | 14 ++++- statistics/trajectories_statistics.py | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 statistics/trajectories_statistics.py diff --git a/experiments/concurrent_execution/parallel_basic_experiment_runner.py b/experiments/concurrent_execution/parallel_basic_experiment_runner.py index f2773919..037e6416 100644 --- a/experiments/concurrent_execution/parallel_basic_experiment_runner.py +++ b/experiments/concurrent_execution/parallel_basic_experiment_runner.py @@ -13,6 +13,7 @@ from experiments.experiments_consts import MAX_SIZE_MB, DEFAULT_NUMERIC_TOLERANCE, NUMERIC_ALGORITHMS from statistics.learning_statistics_manager import LearningStatisticsManager +from statistics.trajectories_statistics import compute_trajectory_statistics, export_trajectory_statistics from statistics.utils import init_semantic_performance_calculator from utilities import LearningAlgorithmType, SolverType, NegativePreconditionPolicy from validators import DomainValidator @@ -197,25 +198,34 @@ def read_domain_file(self, train_set_dir_path: Path) -> Domain: return DomainParser(domain_path=partial_domain_path, partial_parsing=True).parse_domain() def collect_observations( - self, train_set_dir_path: Path, partial_domain: Domain + self, train_set_dir_path: Path, partial_domain: Domain, fold: int ) -> Union[List[Observation], List[MultiAgentObservation]]: """Collects all the observations from the trajectories in the train set directory. :param train_set_dir_path: the path to the directory containing the trajectories. :param partial_domain: the partial domain without the actions' preconditions and effects. + :param fold: the index of the current fold. :return: the allowed observations. """ allowed_observations = [] + used_problems = [] sorted_trajectory_paths = sorted(train_set_dir_path.glob("*.trajectory")) # for consistency for index, trajectory_file_path in enumerate(sorted_trajectory_paths): # assuming that the folders were created so that each folder contains only the correct number of trajectories, i.e., iteration_number problem_path = train_set_dir_path / f"{trajectory_file_path.stem}.pddl" problem = ProblemParser(problem_path, partial_domain).parse_problem() + used_problems.append(problem) complete_observation = TrajectoryParser(partial_domain).parse_trajectory( trajectory_file_path, executing_agents=self.executing_agents ) allowed_observations.append(complete_observation) + self.logger.info("Exporting trajectories statistics.") + trajectories_stats = compute_trajectory_statistics(allowed_observations, used_problems, domain=partial_domain) + export_trajectory_statistics( + trajectories_stats, + self.working_directory_path / "results_directory" / f"trajectories_statistics_{fold}_{len(allowed_observations)}.csv", + ) return allowed_observations def learn_model_offline( @@ -236,7 +246,7 @@ def learn_model_offline( self.logger.info(f"Starting the learning phase for the fold - {fold_num}!") partial_domain_path = train_set_dir_path / self.domain_file_name partial_domain = DomainParser(domain_path=partial_domain_path, partial_parsing=True).parse_domain() - allowed_observations = self.collect_observations(train_set_dir_path, partial_domain) + allowed_observations = self.collect_observations(train_set_dir_path, partial_domain, fold_num) self.logger.info(f"Learning the action model using {len(allowed_observations)} trajectories!") learned_model, learning_report = self._apply_learning_algorithm(partial_domain, allowed_observations, test_set_dir_path) self.learning_statistics_manager.add_to_action_stats( diff --git a/statistics/trajectories_statistics.py b/statistics/trajectories_statistics.py new file mode 100644 index 00000000..5950384e --- /dev/null +++ b/statistics/trajectories_statistics.py @@ -0,0 +1,55 @@ +"""Module for computing statistics on the experiment trajectories.""" + +import csv +from pathlib import Path +from typing import List, Dict + +from pddl_plus_parser.models import Observation, Problem, Domain + + +def compute_trajectory_statistics(trajectories: List[Observation], problems: List[Problem], domain: Domain) -> Dict[str, float]: + """Compute statistics for the given trajectories.""" + action_distribution: Dict[str, int] = {action_name: 0 for action_name in domain.actions.keys()} + number_of_problem_objects: List[int] = [] + number_of_functions_in_problem: List[int] = [] + number_of_predicates_in_problem: List[int] = [] + trajectory_lengths: List[int] = [] + + for trajectory, problem in zip(trajectories, problems): + trajectory_lengths.append(len(trajectory)) + number_of_problem_objects.append(len(problem.objects)) + number_of_functions_in_problem.append(len(problem.initial_state_fluents)) + number_of_predicates_in_problem.append(sum(len(preds) for preds in problem.initial_state_predicates.values())) + for component in trajectory.components: + action_distribution[component.grounded_action_call.name] += 1 + + statistics = { + "min_trajectory_length": min(trajectory_lengths), + "max_trajectory_length": max(trajectory_lengths), + "average_trajectory_length": sum(trajectory_lengths) / len(trajectory_lengths) if trajectory_lengths else 0, + "average_number_of_problem_objects": ( + sum(number_of_problem_objects) / len(number_of_problem_objects) if number_of_problem_objects else 0 + ), + "min_number_of_problem_objects": min(number_of_problem_objects), + "max_number_of_problem_objects": max(number_of_problem_objects), + "average_number_of_functions_in_problem": ( + sum(number_of_functions_in_problem) / len(number_of_functions_in_problem) if number_of_functions_in_problem else 0 + ), + "min_number_of_functions_in_problem": min(number_of_functions_in_problem), + "max_number_of_functions_in_problem": max(number_of_functions_in_problem), + "average_number_of_predicates_in_problem": ( + sum(number_of_predicates_in_problem) / len(number_of_predicates_in_problem) if number_of_predicates_in_problem else 0 + ), + "min_number_of_predicates_in_problem": min(number_of_predicates_in_problem), + "max_number_of_predicates_in_problem": max(number_of_predicates_in_problem), + "action_distribution": sum(action_distribution.values()) / len(action_distribution), + } + return statistics + + +def export_trajectory_statistics(statistics: Dict[str, float], filepath: Path) -> None: + """Export the computed statistics to a file.""" + with open(filepath, "w") as csv_file: + stats_writer = csv.DictWriter(csv_file, fieldnames=statistics.keys()) + stats_writer.writeheader() + stats_writer.writerow(statistics) From 1e90fac044aff05e460d71dcc0cfa4b26965739f Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Thu, 27 Nov 2025 17:28:09 +0200 Subject: [PATCH 06/11] Add trajectory statistics collection and logging improvements Introduced trajectory statistics aggregation in NumericResultsCollector and defined TRAJECTORY_STATS_COLUMNS. Improved Plan-Miner process output logging using threading. Updated parallel experiment runner to select a specific trajectory and commented out semantic performance calculator initialization. Bumped pddl-plus-parser dependency to 3.16.4. Added utility script for copying matching trajectory files. --- .../numeric_distributed_results_collector.py | 23 +++++++++++++- .../parallel_basic_experiment_runner.py | 6 ++-- .../parallel_numeric_experiment_runner.py | 22 +++++++++++++- requirements.txt | 2 +- statistics/trajectories_statistics.py | 16 ++++++++++ trajectory_creators/to_delete.py | 30 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 trajectory_creators/to_delete.py diff --git a/experiments/concurrent_execution/numeric_distributed_results_collector.py b/experiments/concurrent_execution/numeric_distributed_results_collector.py index 60f5fb0d..04ea6dab 100644 --- a/experiments/concurrent_execution/numeric_distributed_results_collector.py +++ b/experiments/concurrent_execution/numeric_distributed_results_collector.py @@ -1,13 +1,15 @@ import argparse +import csv import logging from pathlib import Path from typing import List from pddl_plus_parser.lisp_parsers import DomainParser -from experiments.concurrent_execution.distributed_results_collector import DistributedResultsCollector +from experiments.concurrent_execution.distributed_results_collector import DistributedResultsCollector, FOLD_FIELD from experiments.plotting.plot_nsam_results import plot_results from experiments.plotting.plot_nsam_solo_results import plot_solo_results +from statistics.trajectories_statistics import TRAJECTORY_STATS_COLUMNS def parse_arguments() -> argparse.Namespace: @@ -34,6 +36,24 @@ def __init__( ): super().__init__(working_directory_path, domain_file_name, learning_algorithms, num_folds, iterations) + def collect_trajectories_statistics(self) -> None: + """Collects the trajectories statistics from the results directory.""" + results_directory = self.working_directory_path / "results_directory" + max_iteration = max(self.iterations) if self.iterations else 0 + combined_statistics_data = [] + for fold_index in range(self.num_folds): + trajectory_stats_path = results_directory / f"trajectories_statistics_{fold_index}_{max_iteration}.csv" + with open(trajectory_stats_path, "r") as trajectory_stats_file: + self.logger.info(f"Trajectory statistics for fold {fold_index} at iteration {max_iteration}") + reader = csv.DictReader(trajectory_stats_file) + combined_statistics_data.extend([{FOLD_FIELD: fold_index, **row} for row in reader]) + + combined_statistics_path = results_directory / f"combined_trajectories_statistics.csv" + with open(combined_statistics_path, "wt") as combined_statistics_file: + writer = csv.DictWriter(combined_statistics_file, fieldnames=[FOLD_FIELD, *TRAJECTORY_STATS_COLUMNS]) + writer.writeheader() + writer.writerows(combined_statistics_data) + def collect_numeric_statistics(self) -> None: """Collects the statistics from the results directory.""" self._collect_solving_statistics(collecting_triplets=False) @@ -43,6 +63,7 @@ def collect_numeric_statistics(self) -> None: domain = DomainParser(self.working_directory_path / self.domain_file_name).parse_domain() self._collect_syntactic_performance_statistics(domain) self._collect_numeric_semantic_performance_statistics(collecting_triplets=False) + self.collect_trajectories_statistics() def collect_numeric_statistics_with_triplets_statistics(self) -> None: self._collect_solving_statistics(collecting_triplets=True) diff --git a/experiments/concurrent_execution/parallel_basic_experiment_runner.py b/experiments/concurrent_execution/parallel_basic_experiment_runner.py index 037e6416..5f32f330 100644 --- a/experiments/concurrent_execution/parallel_basic_experiment_runner.py +++ b/experiments/concurrent_execution/parallel_basic_experiment_runner.py @@ -209,8 +209,8 @@ def collect_observations( """ allowed_observations = [] used_problems = [] - sorted_trajectory_paths = sorted(train_set_dir_path.glob("*.trajectory")) # for consistency - for index, trajectory_file_path in enumerate(sorted_trajectory_paths): + sorted_trajectory_paths = sorted(train_set_dir_path.glob("*.trajectory"))[48] # for consistency + for index, trajectory_file_path in enumerate([sorted_trajectory_paths]): # assuming that the folders were created so that each folder contains only the correct number of trajectories, i.e., iteration_number problem_path = train_set_dir_path / f"{trajectory_file_path.stem}.pddl" problem = ProblemParser(problem_path, partial_domain).parse_problem() @@ -268,7 +268,7 @@ def run_fold_iteration(self, fold_num: int, train_set_dir_path: Path, test_set_d :param iteration_number: the current iteration number. """ self.logger.info(f"Running fold {fold_num} iteration {iteration_number}") - self._init_semantic_performance_calculator(fold_num) + # self._init_semantic_performance_calculator(fold_num) self.learn_model_offline(fold_num, train_set_dir_path, test_set_dir_path) self.domain_validator.write_statistics(fold_num, iteration_number) self.semantic_performance_calc.export_semantic_performance(fold_num, iteration_number) diff --git a/experiments/concurrent_execution/parallel_numeric_experiment_runner.py b/experiments/concurrent_execution/parallel_numeric_experiment_runner.py index 44705c50..bbff5fbd 100644 --- a/experiments/concurrent_execution/parallel_numeric_experiment_runner.py +++ b/experiments/concurrent_execution/parallel_numeric_experiment_runner.py @@ -6,6 +6,7 @@ import shutil import signal import subprocess +import threading import time import uuid from pathlib import Path @@ -127,6 +128,15 @@ def _create_learned_domain_for_evaluation(self, plan_miner_output_domain_path: P domain_file.write(plan_miner_domain.to_pddl()) return plan_miner_domain + def _log_plan_miner_output(self, process: subprocess.Popen) -> None: + """Logs the output of the Plan-Miner process. + + :param process: the Plan-Miner process. + """ + assert process.stdout is not None + for line in process.stdout: + self.logger.info(f"Plan-Miner: {line.strip()}") + def _run_plan_miner_process(self, plan_miner_trajectory_file_path: Path) -> Optional[Path]: """Runs the Plan-Miner process to learn the action model. @@ -136,7 +146,17 @@ def _run_plan_miner_process(self, plan_miner_trajectory_file_path: Path) -> Opti plan_miner_domain_name = f"{self.domain_file_name.split('.')[0]}_{uuid.uuid4()}" os.chdir(PLAN_MINER_DIR_PATH) # cmd = ./PlanMiner {path to pts file} {domain name without extension} - process = subprocess.Popen(f"./bin/PlanMiner {plan_miner_trajectory_file_path} {plan_miner_domain_name}", shell=True) + process = subprocess.Popen( + f"./bin/PlanMiner {plan_miner_trajectory_file_path} {plan_miner_domain_name}", + shell=True, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) + logging_lambda = lambda: self._log_plan_miner_output(process) + logging_thread = threading.Thread(target=logging_lambda, daemon=True) + logging_thread.start() + try: process.wait(timeout=LEARNING_TIMEOUT) diff --git a/requirements.txt b/requirements.txt index 5e2e72fa..45f3f9d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ scikit-learn==1.5.0 networkx==3.1 jdk4py==17.0.7.0 anytree~=2.8.0 -pddl-plus-parser==3.16.3 +pddl-plus-parser==3.16.4 pandas==2.1.1 scikit-obliquetree==0.1.4 diff --git a/statistics/trajectories_statistics.py b/statistics/trajectories_statistics.py index 5950384e..502abe43 100644 --- a/statistics/trajectories_statistics.py +++ b/statistics/trajectories_statistics.py @@ -6,6 +6,22 @@ from pddl_plus_parser.models import Observation, Problem, Domain +TRAJECTORY_STATS_COLUMNS = [ + "min_trajectory_length", + "max_trajectory_length", + "average_trajectory_length", + "average_number_of_problem_objects", + "min_number_of_problem_objects", + "max_number_of_problem_objects", + "average_number_of_functions_in_problem", + "min_number_of_functions_in_problem", + "max_number_of_functions_in_problem", + "average_number_of_predicates_in_problem", + "min_number_of_predicates_in_problem", + "max_number_of_predicates_in_problem", + "action_distribution", +] + def compute_trajectory_statistics(trajectories: List[Observation], problems: List[Problem], domain: Domain) -> Dict[str, float]: """Compute statistics for the given trajectories.""" diff --git a/trajectory_creators/to_delete.py b/trajectory_creators/to_delete.py new file mode 100644 index 00000000..6b638d5e --- /dev/null +++ b/trajectory_creators/to_delete.py @@ -0,0 +1,30 @@ +import os +import shutil + + +def copy_matching_trajectories(dataset_dir, pddl_dir): + # Collect base names of all PDDL files + pddl_basenames = {os.path.splitext(f)[0] for f in os.listdir(pddl_dir) if f.endswith(".pddl")} + + # Ensure target directory exists + os.makedirs(pddl_dir, exist_ok=True) + + # Iterate over trajectory files in dataset + for filename in os.listdir(dataset_dir): + if not filename.endswith(".trajectory"): + continue + + base = os.path.splitext(filename)[0] + + # Copy only if the base matches a PDDL base name + if base in pddl_basenames: + src = os.path.join(dataset_dir, filename) + dst = os.path.join(pddl_dir, filename) + shutil.copy2(src, dst) + print(f"Copied: {filename}") + + +if __name__ == "__main__": + dataset_dir = "/home/mordocha/numeric_planning/domains/domains_for_new_nsam/satellite/" + pddl_dir = "/home/mordocha/numeric_planning/domains/domains_for_new_nsam/satellite/train/fold_0_3_80/" + copy_matching_trajectories(dataset_dir, pddl_dir) From d2386ef39560337596963447e28e2df396094dc7 Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Tue, 2 Dec 2025 13:19:23 +0200 Subject: [PATCH 07/11] Improve trajectory handling and update dependencies Refactored trajectory file iteration in ParallelExperimentRunner to process all files instead of a single index. Enabled semantic performance calculator initialization. Enhanced error logging in ExperimentTrajectoriesCreator for trajectory creation failures. Updated pddl-plus-parser to version 3.16.5. Added 'solving_time_per_problem' to SOLVING_STATISTICS and ensured sorted processing of test set problems in DomainValidator. --- .../parallel_basic_experiment_runner.py | 6 +++--- requirements.txt | 2 +- trajectory_creators/experiments_trajectories_creator.py | 4 +++- validators/safe_domain_validator.py | 4 +++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/experiments/concurrent_execution/parallel_basic_experiment_runner.py b/experiments/concurrent_execution/parallel_basic_experiment_runner.py index 5f32f330..037e6416 100644 --- a/experiments/concurrent_execution/parallel_basic_experiment_runner.py +++ b/experiments/concurrent_execution/parallel_basic_experiment_runner.py @@ -209,8 +209,8 @@ def collect_observations( """ allowed_observations = [] used_problems = [] - sorted_trajectory_paths = sorted(train_set_dir_path.glob("*.trajectory"))[48] # for consistency - for index, trajectory_file_path in enumerate([sorted_trajectory_paths]): + sorted_trajectory_paths = sorted(train_set_dir_path.glob("*.trajectory")) # for consistency + for index, trajectory_file_path in enumerate(sorted_trajectory_paths): # assuming that the folders were created so that each folder contains only the correct number of trajectories, i.e., iteration_number problem_path = train_set_dir_path / f"{trajectory_file_path.stem}.pddl" problem = ProblemParser(problem_path, partial_domain).parse_problem() @@ -268,7 +268,7 @@ def run_fold_iteration(self, fold_num: int, train_set_dir_path: Path, test_set_d :param iteration_number: the current iteration number. """ self.logger.info(f"Running fold {fold_num} iteration {iteration_number}") - # self._init_semantic_performance_calculator(fold_num) + self._init_semantic_performance_calculator(fold_num) self.learn_model_offline(fold_num, train_set_dir_path, test_set_dir_path) self.domain_validator.write_statistics(fold_num, iteration_number) self.semantic_performance_calc.export_semantic_performance(fold_num, iteration_number) diff --git a/requirements.txt b/requirements.txt index 45f3f9d3..840f32e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ scikit-learn==1.5.0 networkx==3.1 jdk4py==17.0.7.0 anytree~=2.8.0 -pddl-plus-parser==3.16.4 +pddl-plus-parser==3.16.5 pandas==2.1.1 scikit-obliquetree==0.1.4 diff --git a/trajectory_creators/experiments_trajectories_creator.py b/trajectory_creators/experiments_trajectories_creator.py index e92f7df0..a2d1c548 100644 --- a/trajectory_creators/experiments_trajectories_creator.py +++ b/trajectory_creators/experiments_trajectories_creator.py @@ -1,4 +1,5 @@ """Creates the trajectories that will be used in the trajectory""" + import logging import sys from pathlib import Path @@ -54,7 +55,8 @@ def create_domain_trajectories(self) -> None: triplets = trajectory_exporter.parse_plan(problem, solution_file_path) trajectory_exporter.export_to_file(triplets, trajectory_file_path) - except (ValueError, IndexError): + except (ValueError, IndexError) as e: + self.logger.warning(f"Could not create trajectory for problem - {problem_file_path.stem}. Error: {e}") continue diff --git a/validators/safe_domain_validator.py b/validators/safe_domain_validator.py index d7f08caf..e2df7ec1 100644 --- a/validators/safe_domain_validator.py +++ b/validators/safe_domain_validator.py @@ -27,6 +27,7 @@ "policy", "learning_time", "solving_time", + "solving_time_per_problem", "solver", "ok", "no_solution", @@ -284,7 +285,7 @@ def validate_domain( self.logger.info("Solving the test set problems using the learned domain!") problem_solving_times = [] - for problem_path in test_set_directory_path.glob(f"{self.problem_prefix}*.pddl"): + for problem_path in sorted(test_set_directory_path.glob(f"{self.problem_prefix}*.pddl")): problem_solving_report = {} problem_solved = False problem_file_name = problem_path.stem @@ -338,6 +339,7 @@ def validate_domain( "num_trajectories": num_trajectories, "policy": preconditions_removal_policy.name, "num_trajectory_triplets": num_triplets, + "solving_time_per_problem": problem_solving_times, "learning_time": learning_time, "solver": [solver.name for solver in solvers_portfolio], **solving_stats, From 8f34f859efb193f123a087c574f6b576557a31b0 Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Tue, 2 Dec 2025 13:20:18 +0200 Subject: [PATCH 08/11] Delete to_delete.py --- trajectory_creators/to_delete.py | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 trajectory_creators/to_delete.py diff --git a/trajectory_creators/to_delete.py b/trajectory_creators/to_delete.py deleted file mode 100644 index 6b638d5e..00000000 --- a/trajectory_creators/to_delete.py +++ /dev/null @@ -1,30 +0,0 @@ -import os -import shutil - - -def copy_matching_trajectories(dataset_dir, pddl_dir): - # Collect base names of all PDDL files - pddl_basenames = {os.path.splitext(f)[0] for f in os.listdir(pddl_dir) if f.endswith(".pddl")} - - # Ensure target directory exists - os.makedirs(pddl_dir, exist_ok=True) - - # Iterate over trajectory files in dataset - for filename in os.listdir(dataset_dir): - if not filename.endswith(".trajectory"): - continue - - base = os.path.splitext(filename)[0] - - # Copy only if the base matches a PDDL base name - if base in pddl_basenames: - src = os.path.join(dataset_dir, filename) - dst = os.path.join(pddl_dir, filename) - shutil.copy2(src, dst) - print(f"Copied: {filename}") - - -if __name__ == "__main__": - dataset_dir = "/home/mordocha/numeric_planning/domains/domains_for_new_nsam/satellite/" - pddl_dir = "/home/mordocha/numeric_planning/domains/domains_for_new_nsam/satellite/train/fold_0_3_80/" - copy_matching_trajectories(dataset_dir, pddl_dir) From cb4220b385516c7f8dcff8626bbef881b7062452 Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Thu, 11 Dec 2025 16:39:20 +0200 Subject: [PATCH 09/11] added retries to the Metric-FF execution to avoid cases where the planner should have solved the problem if given another try. --- solvers/metric_ff_solver.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/solvers/metric_ff_solver.py b/solvers/metric_ff_solver.py index ea2236ed..db5ac71d 100644 --- a/solvers/metric_ff_solver.py +++ b/solvers/metric_ff_solver.py @@ -104,6 +104,13 @@ def solve_problem( self.logger.debug(f"Starting to work on solving problem - {problem_file_path.stem}") solution_path = problems_directory_path / f"{problem_file_path.stem}.solution" run_command = f"./ff -o {domain_file_path} -f {problem_file_path} -s 0 -t {tolerance} > {solution_path}" + num_retries = 0 + solution_output = self._run_metric_ff_process(run_command, solution_path, problem_file_path, solving_timeout) + while solution_output == SolutionOutputTypes.solver_error and num_retries < 3: + self.logger.debug(f"Retrying to solve problem - {problem_file_path.stem}. Retry number {num_retries + 1}") + solution_output = self._run_metric_ff_process(run_command, solution_path, problem_file_path, solving_timeout) + num_retries += 1 + return self._run_metric_ff_process(run_command, solution_path, problem_file_path, solving_timeout) def execute_solver( From ff14b72a0690b799ba7e25361e013b30d5400d2c Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Thu, 8 Jan 2026 10:29:59 +0200 Subject: [PATCH 10/11] Added handling of the numeric precision results for the NSAM journal paper. --- .../combine_numeric_precision_results.py | 41 +++++++++++++++++ .../plotting/plot_numeric_precision.py | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 experiments/concurrent_execution/combine_numeric_precision_results.py create mode 100644 experiments/plotting/plot_numeric_precision.py diff --git a/experiments/concurrent_execution/combine_numeric_precision_results.py b/experiments/concurrent_execution/combine_numeric_precision_results.py new file mode 100644 index 00000000..1a2a7031 --- /dev/null +++ b/experiments/concurrent_execution/combine_numeric_precision_results.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path + +import pandas as pd +import glob, re + + +def combine_numeric_precision_results(input_directory: str, output_directory: Path) -> None: + INPUT_GLOB = f"{input_directory}/solving_combined_statistics_*_digit*.csv" + OUTPUT_CSV = output_directory / "numeric_precision_out.csv" + KEEP_ALGO = "numeric_sam" + + paths = sorted(glob.glob(INPUT_GLOB)) + dfs = [] + + for p in paths: + df = pd.read_csv(p) + m = re.search(r"_(\d+)_digits\.csv", p) + if "num_digits" not in df.columns and m: + df["num_digits"] = int(m.group(1)) + if "learning_algorithm" in df.columns: + df = df[df["learning_algorithm"].astype(str) == KEEP_ALGO].copy() + dfs.append(df) + + all_df = pd.concat(dfs, ignore_index=True) + + out = ( + all_df.groupby(["num_digits", "num_trajectories"], as_index=False)["percent_ok"] + .mean() + .sort_values(["num_digits", "num_trajectories"]) + ) + + out = out.rename(columns={"percent_ok": "percent_solved"}) + out = out[["num_trajectories", "percent_solved", "num_digits"]] + out.to_csv(OUTPUT_CSV, index=False) + + +if __name__ == "__main__": + input_dir = sys.argv[1] + output_dir = Path(sys.argv[2]) + combine_numeric_precision_results(input_dir, output_dir) diff --git a/experiments/plotting/plot_numeric_precision.py b/experiments/plotting/plot_numeric_precision.py new file mode 100644 index 00000000..4597dee8 --- /dev/null +++ b/experiments/plotting/plot_numeric_precision.py @@ -0,0 +1,46 @@ +import sys +from pathlib import Path + +import pandas as pd +import matplotlib.pyplot as plt + + +# Load data +def plot_numeric_precision(input_path: Path, output_folder_path: Path) -> None: + df = pd.read_csv(input_path) + + plt.figure(figsize=(12, 8)) + + digits = sorted(df["num_digits"].unique()) + linestyles = ["solid", "dashed", "dotted", "dashdot"] + colors = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#F0E442", "#56B4E9"] + + for i, d in enumerate(digits): + subset = df[df["num_digits"] == d].sort_values("num_trajectories") + plt.plot( + subset["num_trajectories"], + subset["percent_solved"], + label=f"{int(d)} digits", + linewidth=8, + linestyle=linestyles[i % len(linestyles)], + color=colors[i % len(colors)], + ) + + plt.xlabel("# Trajectories", fontsize=44) + plt.ylabel("AVG % of solved", fontsize=44) + plt.ylim(0, 100) + plt.xticks(fontsize=44) + plt.yticks(fontsize=44) + plt.legend(fontsize=40) + plt.grid(True) + plt.tight_layout() + + output_path = output_folder_path / "numeric_precision.pdf" + plt.savefig(output_path, bbox_inches="tight", dpi=300) + plt.close() + + +if __name__ == "__main__": + input_path = Path(sys.argv[1]) + output_folder_path = Path(sys.argv[2]) + plot_numeric_precision(input_path, output_folder_path) From 462c0d80adb12df6728eca623ea034f36ee43956 Mon Sep 17 00:00:00 2001 From: Argaman Mordoch Date: Thu, 8 Jan 2026 10:41:24 +0200 Subject: [PATCH 11/11] Refine action distribution statistics in trajectory analysis Replaces single 'action_distribution' metric with 'average', 'min', and 'max' action distribution statistics for more detailed analysis. Also imports numpy's mean function for accurate averaging. --- statistics/trajectories_statistics.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/statistics/trajectories_statistics.py b/statistics/trajectories_statistics.py index 502abe43..e069427e 100644 --- a/statistics/trajectories_statistics.py +++ b/statistics/trajectories_statistics.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import List, Dict +from numpy.ma.core import mean from pddl_plus_parser.models import Observation, Problem, Domain TRAJECTORY_STATS_COLUMNS = [ @@ -19,7 +20,9 @@ "average_number_of_predicates_in_problem", "min_number_of_predicates_in_problem", "max_number_of_predicates_in_problem", - "action_distribution", + "average_action_distribution", + "min_action_distribution", + "max_action_distribution", ] @@ -58,7 +61,9 @@ def compute_trajectory_statistics(trajectories: List[Observation], problems: Lis ), "min_number_of_predicates_in_problem": min(number_of_predicates_in_problem), "max_number_of_predicates_in_problem": max(number_of_predicates_in_problem), - "action_distribution": sum(action_distribution.values()) / len(action_distribution), + "average_action_distribution": mean(list(action_distribution.values())) if action_distribution else 0, + "min_action_distribution": min(action_distribution.values()) if action_distribution else 0, + "max_action_distribution": max(action_distribution.values()) if action_distribution else 0, } return statistics