Repository: Trusted-AI/AIF360 (language: python)

Issue:
Add implementation for Fairness Aware Counterfactuals for Subgroups
We have implemented the "FACTS" framework from [our respective paper](https://arxiv.org/abs/2306.14978). We believe that it would make a useful addition to the fairness auditing methods already provided by aif360.

(Placeholder issue for the respective pull request)



Discussion / hints:

Fix patch:
diff --git a/aif360/sklearn/detectors/__init__.py b/aif360/sklearn/detectors/__init__.py
index 7291c36f..aad7520c 100644
--- a/aif360/sklearn/detectors/__init__.py
+++ b/aif360/sklearn/detectors/__init__.py
@@ -2,7 +2,10 @@
 Methods for detecting subsets for which a model or dataset is biased.
 """
 from aif360.sklearn.detectors.detectors import bias_scan
+from aif360.sklearn.detectors.facts import FACTS, FACTS_bias_scan
 
 __all__ = [
     'bias_scan',
+    'FACTS',
+    'FACTS_bias_scan',
 ]
diff --git a/aif360/sklearn/detectors/facts/__init__.py b/aif360/sklearn/detectors/facts/__init__.py
new file mode 100644
index 00000000..e01a7cd5
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/__init__.py
@@ -0,0 +1,583 @@
+from typing import List, Dict, Optional
+from collections import defaultdict
+
+import pandas as pd
+from pandas import DataFrame
+from sklearn.base import BaseEstimator
+
+from .parameters import ParameterProxy, feature_change_builder
+from .misc import (
+    valid_ifthens,
+    calc_costs,
+    rules2rulesbyif,
+    select_rules_subset,
+    select_rules_subset_KStest,
+    cum_corr_costs_all
+)
+from .formatting import print_recourse_report, print_recourse_report_KStest_cumulative
+from .rule_filters import delete_fair_rules
+
+__all__ = ["FACTS", "print_recourse_report", "FACTS_bias_scan"]
+
+def FACTS_bias_scan(
+    X: pd.DataFrame,
+    clf: BaseEstimator,
+    prot_attr: str,
+    metric: str,
+    categorical_features: Optional[List[str]] = None,
+    freq_itemset_min_supp: float = 0.1,
+    feature_weights: Dict[str, float] = defaultdict(lambda : 1),
+    feats_allowed_to_change: Optional[List[str]] = None,
+    feats_not_allowed_to_change: Optional[List[str]] = None,
+    viewpoint: str = "macro",
+    sort_strategy: str = "max-cost-diff-decr",
+    top_count: int = 1,
+    phi: float = 0.5,
+    c: float = 0.5,
+    verbose: bool = True,
+    print_recourse_report: bool = False,
+    show_subgroup_costs: bool = False,
+    show_action_costs: bool = False,
+    is_correctness_metric: bool = False,
+):
+    """FACTS is an efficient, model-agnostic, highly parameterizable, and
+    explainable framework for evaluating subgroup fairness through
+    counterfactual explanations [#FACTS23]_.
+
+    Note:
+        This function is a wrapper to run the FACTS framework from start to
+        finish. Its purpose is to provide an API which is both closer to the
+        `detectors` API and more succinct.
+
+        For more options and greater control (including the option to cache
+        some intermediate results and then apply more than one metric fast),
+        consider using the :class:`FACTS` class.
+
+    References:
+        .. [#FACTS23] `L. Kavouras, K. Tsopelas, G. Giannopoulos,
+           D. Sacharidis, E. Psaroudaki, N. Theologitis, D. Rontogiannis,
+           D. Fotakis, I. Emiris, "Fairness Aware Counterfactuals for
+           Subgroups", arXiv preprint, 2023.
+           <https://arxiv.org/abs/2306.14978>`_
+
+    Args:
+        X (DataFrame): Dataset given as a :class:`pandas.DataFrame`. As in
+            standard scikit-learn convention, it is expected to contain one
+            instance per row and one feature / explanatory variable per
+            column (labels not needed, we already have an ML model).
+
+        clf (sklearn.base.BaseEstimator): A trained and ready to use
+            classifier, implementing method `predict(X)`, where `X` is
+            the matrix of features; predictions returned by `predict(X)`
+            are either 0 or 1. In other words, fitted scikit-learn
+            classifiers.
+
+        prot_attr (str): the name of the column that represents the
+            protected attribute.
+
+        metric (str, optional): one of the following choices
+        
+            - "equal-effectiveness"
+            - "equal-choice-for-recourse"
+            - "equal-effectiveness-within-budget"
+            - "equal-cost-of-effectiveness"
+            - "equal-mean-recourse"
+            - "fair-tradeoff"
+            
+            Defaults to "equal-effectiveness".
+
+            For explanation of each of those metrics, refer either to the
+            paper [#FACTS23]_ or the demo_FACTS notebook.
+            
+        categorical_features (list(str), optional): the list of categorical
+            features. The default is to choose (dynamically, inside `fit`) the
+            columns of the dataset with types "object" or "category".
+        
+        freq_itemset_min_supp (float, optional): minimum support for all the runs
+            of the frequent itemset mining algorithm (specifically, `FP Growth <https://en.wikipedia.org/wiki/Association_rule_learning#FP-growth_algorithm>`_).
+            We mine frequent itemsets to generate candidate subpopulation groups and candidate actions.
+            For more information, see paper [#FACTS23]_.
+            Defaults to 10%.
+        
+        feature_weights (dict(str, float), optional): the weights for each feature. Used in the calculation
+            of the cost of a suggested change. Specifically, the term corresponding to each feature is
+            multiplied by this weight.
+            Defaults to 1, for all features.
+
+        feats_allowed_to_change (list(str), optional): if provided, only
+            allows these features to change value in the suggested recourses.
+            Default: no frozen features.
+            *Note*: providing both `feats_allowed_to_change` and
+            `feats_not_allowed_to_change` is currently treated as an error.
+
+        feats_not_allowed_to_change (list(str), optional): if provided,
+            prevents these features from changing at all in any given
+            recourse.
+            Default: no frozen features.
+            *Note*: providing both `feats_allowed_to_change` and
+            `feats_not_allowed_to_change` is currently treated as an error.
+
+        viewpoint (str, optional): "macro" or "micro". Refers to the 
+            notions of "macro viewpoint" and "micro viewpoint" defined
+            in section 2.2 of the paper [#FACTS23]_.
+            
+            As a short explanation, consider a set of actions A and a
+            subgroup (cohort / set of individuals) G. Metrics with the
+            macro viewpoint interpretation are constrained to always apply
+            one action from A to the entire G, while metrics with the micro
+            interpretation are allowed to give each individual in G the
+            min-cost action from A which changes the individual's class.
+            
+            Note that not all combinations of `metric` and `viewpoint` are
+            valid, e.g. "Equal Choice for Recourse" only has a macro
+            interpretation.
+
+            Defaults to "macro".
+
+        sort_strategy (str, optional): one of the following choices
+            
+            - `"max-cost-diff-decr"`: simply rank the groups in descending \
+                order according to the unfairness metric.
+            - `"max-cost-diff-decr-ignore-forall-subgroups-empty"`: ignore \
+                groups for which we have no available actions whatsoever.
+            - `"max-cost-diff-decr-ignore-exists-subgroup-empty"`: ignore \
+                groups for which at least one protected subgroup has \
+                no available actions.
+
+            Defaults to "max-cost-diff-decr".
+
+        top_count (int, optional): the number of subpopulation groups that
+            the algorithm will keep.
+            Defaults to 1, i.e. returns the most biased group.
+
+        phi (float, optional): effectiveness threshold. Real number in [0, 1].
+            Applicable for "equal-choice-for-recourse" and
+            "equal-cost-of-effectiveness" metrics. For these two metrics, an
+            action is considered to achieve recourse for a subpopulation group
+            if at least `phi` % of the group's individuals achieve recourse.
+            Defaults to 0.5.
+
+        c (float, optional): cost budget. Real number. Applicable for
+            "equal-effectiveness-within-budget" metric. Specifies the maximum
+            cost that can be payed for an action (by the individual, by a
+            central authority etc.)
+            Defaults to 0.5.
+
+        verbose (bool, optional): whether to print intermediate messages and
+            progress bar. Defaults to True.
+
+        print_recourse_report (bool, optional): whether to print a detailed
+            and annotated report of the most biased groups to stdout. If False,
+            the most biased groups are only computed and returned.
+            Defaults to False.
+
+        show_subgroup_costs (bool, optional): Whether to show the costs assigned
+            to each protected subgroup.
+            Defaults to False.
+        
+        show_action_costs (bool, optional): Whether to show the costs assigned
+            to each specific action.
+            Defaults to False.
+
+        is_correctness_metric (bool, optional): if True, the metric is considered
+            to quantify utility, i.e. the greater it is for a group, the
+            more beneficial it is for the individuals of the group.
+            Defaults to False.
+
+    Returns:
+        list(tuple(dict(str, str), float)): the most biased groups as a list \
+            of pairs. In each pair, the first element is the group description \
+            as a dict. The second element is the value of the chosen unfairness \
+            metric for this group.
+    """
+    detector = FACTS(
+        clf=clf,
+        prot_attr=prot_attr,
+        categorical_features=categorical_features,
+        freq_itemset_min_supp=freq_itemset_min_supp,
+        feature_weights=feature_weights, # type: ignore
+        feats_allowed_to_change=feats_allowed_to_change,
+        feats_not_allowed_to_change=feats_not_allowed_to_change,
+    )
+
+    detector = detector.fit(X=X, verbose=verbose)
+
+    detector.bias_scan(
+        metric=metric,
+        viewpoint=viewpoint,
+        sort_strategy=sort_strategy,
+        top_count=top_count,
+        phi=phi,
+        c=c,
+    )
+
+    if print_recourse_report:
+        detector.print_recourse_report(
+            show_subgroup_costs=show_subgroup_costs,
+            show_action_costs=show_action_costs,
+            correctness_metric=is_correctness_metric,
+        )
+    
+    if detector.subgroup_costs is None:
+        assert detector.unfairness is not None
+        scores = detector.unfairness
+    else:
+        scores = {sg: max(costs.values()) - min(costs.values()) for sg, costs in detector.subgroup_costs.items()}
+    
+    most_biased_subgroups = [(sg.to_dict(), score) for sg, score in scores.items() if sg in detector.top_rules.keys()]
+    return most_biased_subgroups
+
+class FACTS(BaseEstimator):
+    """FACTS is an efficient, model-agnostic, highly parameterizable, and
+    explainable framework for evaluating subgroup fairness through
+    counterfactual explanations [#FACTS23]_.
+
+    This class is a wrapper for the various methods exposed by the
+    FACTS framework.
+
+    References:
+        .. [#FACTS23] `L. Kavouras, K. Tsopelas, G. Giannopoulos,
+           D. Sacharidis, E. Psaroudaki, N. Theologitis, D. Rontogiannis,
+           D. Fotakis, I. Emiris, "Fairness Aware Counterfactuals for
+           Subgroups", arXiv preprint, 2023.
+           <https://arxiv.org/abs/2306.14978>`_
+    """
+
+    def __init__(
+        self,
+        clf,
+        prot_attr,
+        categorical_features=None,
+        freq_itemset_min_supp=0.1,
+        feature_weights=defaultdict(lambda : 1),
+        feats_allowed_to_change=None,
+        feats_not_allowed_to_change=None,
+    ):
+        """
+        Args:
+            clf (sklearn.base.BaseEstimator): A trained and ready to use
+                classifier, implementing method `predict(X)`, where `X` is
+                the matrix of features; predictions returned by `predict(X)`
+                are either 0 or 1. In other words, fitted scikit-learn
+                classifiers.
+            prot_attr (str): the name of the column that represents the
+                protected attribute.
+            categorical_features (list(str), optional): the list of categorical
+                features. The default is to choose (dynamically, inside `fit`) the
+                columns of the dataset with types "object" or "category".
+            freq_itemset_min_supp (float, optional): minimum support for all the runs
+                of the frequent itemset mining algorithm (specifically, `FP Growth <https://en.wikipedia.org/wiki/Association_rule_learning#FP-growth_algorithm>`_).
+                We mine frequent itemsets to generate candidate subpopulation groups and candidate actions.
+                For more information, see paper [#FACTS23]_.
+                Defaults to 10%.
+            feature_weights (dict(str, float), optional): the weights for each feature. Used in the calculation
+                of the cost of a suggested change. Specifically, the term corresponding to each feature is
+                multiplied by this weight.
+                Defaults to 1, for all features.
+            feats_allowed_to_change (list(str), optional): if provided, only
+                allows these features to change value in the suggested recourses.
+                Default: no frozen features.
+                *Note*: providing both `feats_allowed_to_change` and
+                `feats_not_allowed_to_change` is currently treated as an error.
+            feats_not_allowed_to_change (list(str), optional): if provided,
+                prevents these features from changing at all in any given
+                recourse.
+                Default: no frozen features.
+                *Note*: providing both `feats_allowed_to_change` and
+                `feats_not_allowed_to_change` is currently treated as an error.
+        """
+        self.clf = clf
+        self.prot_attr = prot_attr
+        self.freq_itemset_min_supp = freq_itemset_min_supp
+        self.categorical_features = categorical_features
+        self.feature_weights = feature_weights
+        self.feats_allowed_to_change = feats_allowed_to_change
+        self.feats_not_allowed_to_change = feats_not_allowed_to_change
+
+    def fit(self, X: DataFrame, verbose: bool = True):
+        """Calculates subpopulation groups, actions and respective effectiveness
+
+        Args:
+            X (DataFrame): Dataset given as a :class:`pandas.DataFrame`. As in
+                standard scikit-learn convention, it is expected to contain one
+                instance per row and one feature / explanatory variable per
+                column (labels not needed, we already have an ML model).
+            verbose (bool): whether to print intermediate messages and progress bar. Defaults to True.
+
+        Raises:
+            ValueError: `feats_allowed_to_change` and `feats_not_allowed_to_change`
+                cannot be given simultaneously.
+            Exception: when unreachable code is executed.
+
+        Returns:
+            FACTS: Returns self.
+        """
+        if self.categorical_features is None:
+            self.categorical_features = X.select_dtypes(include=["object", "category"]).columns.to_list()
+        all_feats = X.columns.tolist()
+        if self.feats_allowed_to_change is not None and self.feats_not_allowed_to_change is not None:
+            raise ValueError("Please specify only feats_allowed_to_change or feats_not_allowed_to_change, not both.")
+        elif self.feats_allowed_to_change is None and self.feats_not_allowed_to_change is None:
+            feats_not_allowed_to_change = set()
+        elif self.feats_allowed_to_change is not None:
+            feats_not_allowed_to_change = set(all_feats) - set(self.feats_allowed_to_change)
+        elif self.feats_not_allowed_to_change is not None:
+            feats_not_allowed_to_change = set(self.feats_not_allowed_to_change)
+        else:
+            raise Exception("Code should be unreachable.")
+
+        num_features = list(set(X.columns) - set(self.categorical_features))
+        comparators = feature_change_builder(
+            X=X,
+            num_cols=num_features,
+            cate_cols=self.categorical_features,
+            ord_cols=[],
+            feature_weights=self.feature_weights,
+            num_normalization=False,
+        )
+        params = ParameterProxy(featureChanges=comparators)
+        
+        ifthens_coverage_correctness = valid_ifthens(
+            X=X,
+            model=self.clf,
+            sensitive_attribute=self.prot_attr,
+            freqitem_minsupp=self.freq_itemset_min_supp,
+            feats_not_allowed_to_change=list(feats_not_allowed_to_change),
+            verbose=verbose,
+        )
+
+        rules_by_if = rules2rulesbyif(ifthens_coverage_correctness)
+
+        if verbose:
+            print("Computing percentages of individuals flipped by any action with cost up to c, for every c", flush=True)
+        self.rules_with_cumulative = cum_corr_costs_all(
+            rulesbyif=rules_by_if,
+            X=X,
+            model=self.clf,
+            sensitive_attribute=self.prot_attr,
+            params=params,
+            verbose=verbose,
+        )
+        self.rules_by_if = calc_costs(rules_by_if)
+
+        self.dataset = X.copy(deep=True)
+
+        return self
+    
+    def bias_scan(
+        self,
+        metric: str = "equal-effectiveness",
+        viewpoint: str = "macro",
+        sort_strategy: str = "max-cost-diff-decr",
+        top_count: int = 10,
+        filter_sequence: List[str] = [],
+        phi: float = 0.5,
+        c: float = 0.5
+    ):
+        """Examines generated subgroups and calculates the `top_count` most
+        unfair ones, with respect to the chosen metric.
+
+        Stores the final groups in instance variable `self.top_rules` and the
+        respective subgroup costs in `self.subgroup_costs` (or `self.unfairness`
+        for the "fair-tradeoff" metric).
+
+        Args:
+            metric (str, optional): one of the following choices
+            
+                - "equal-effectiveness"
+                - "equal-choice-for-recourse"
+                - "equal-effectiveness-within-budget"
+                - "equal-cost-of-effectiveness"
+                - "equal-mean-recourse"
+                - "fair-tradeoff"
+                
+                Defaults to "equal-effectiveness".
+
+                For explanation of each of those metrics, refer either to the
+                paper [#FACTS23]_ or the demo_FACTS notebook.
+
+            viewpoint (str, optional): "macro" or "micro". Refers to the 
+                notions of "macro viewpoint" and "micro viewpoint" defined
+                in section 2.2 of the paper [#FACTS23]_.
+                
+                As a short explanation, consider a set of actions A and a
+                subgroup (cohort / set of individuals) G. Metrics with the
+                macro viewpoint interpretation are constrained to always apply
+                one action from A to the entire G, while metrics with the micro
+                interpretation are allowed to give each individual in G the
+                min-cost action from A which changes the individual's class.
+                
+                Note that not all combinations of `metric` and `viewpoint` are
+                valid, e.g. "Equal Choice for Recourse" only has a macro
+                interpretation.
+
+                Defaults to "macro".
+
+            sort_strategy (str, optional): one of the following choices
+                
+                - `"max-cost-diff-decr"`: simply rank the groups in descending \
+                    order according to the unfairness metric.
+                - `"max-cost-diff-decr-ignore-forall-subgroups-empty"`: ignore \
+                    groups for which we have no available actions whatsoever.
+                - `"max-cost-diff-decr-ignore-exists-subgroup-empty"`: ignore \
+                    groups for which at least one protected subgroup has \
+                    no available actions.
+
+                Defaults to "max-cost-diff-decr".
+
+            top_count (int, optional): the number of subpopulation groups that
+                the algorithm will keep.
+                Defaults to 10.
+
+            filter_sequence (List[str], optional): List of various filters
+                applied on the groups and / or actions. Available filters are:
+
+                - `"remove-contained"`: does not show groups which are subsumed \
+                    by other shown groups. By "subsumed" we mean that the group \
+                    is defined by extra feature values, but those values are \
+                    not changed by any action.
+                - `"remove-below-thr-corr"`: does not show actions which are \
+                    below the given effectiveness threshold. Refer also to the \
+                    documentation of parameter `phi` below.
+                - `"remove-above-thr-cost"`: does not show action that cost more \
+                    than the given cost budget. Refer also to the documentation \
+                    of parameter `c` below.
+                - `"keep-rules-until-thr-corr-reached"`: 
+                - `"remove-fair-rules"`: do not show groups which do not exhibit \
+                    bias.
+                - `"keep-only-min-change"`: for each group shown, show only the \
+                    suggested actions that have minimum cost, ignore the others.
+                
+                Defaults to [].
+            
+            phi (float, optional): effectiveness threshold. Real number in [0, 1].
+                Applicable for "equal-choice-for-recourse" and
+                "equal-cost-of-effectiveness" metrics. For these two metrics, an
+                action is considered to achieve recourse for a subpopulation group
+                if at least `phi` % of the group's individuals achieve recourse.
+                Defaults to 0.5.
+
+            c (float, optional): cost budget. Real number. Applicable for
+                "equal-effectiveness-within-budget" metric. Specifies the maximum
+                cost that can be payed for an action (by the individual, by a
+                central authority etc.)
+                Defaults to 0.5.
+        """
+        self._metric = metric
+        if viewpoint == "macro":
+            rules = self.rules_by_if
+        elif viewpoint == "micro":
+            rules = self.rules_with_cumulative
+        else:
+            raise ValueError("viewpoint parameter can be either 'macro' or 'micro'")
+        rules = self.rules_by_if if viewpoint == "macro" else self.rules_with_cumulative
+
+        if metric == "fair-tradeoff":
+            preds_Xtest = self.clf.predict(self.dataset)
+            pop_sizes = {
+                sg: ((self.dataset[self.prot_attr] == sg) & (preds_Xtest == 0)).sum()
+                for sg in self.dataset[self.prot_attr].unique()
+            }
+            self.top_rules, self.unfairness = select_rules_subset_KStest(
+                rulesbyif=rules,
+                affected_population_sizes=pop_sizes,
+                top_count=top_count
+            )
+            self.subgroup_costs = None
+        else:
+            self.top_rules, self.subgroup_costs = select_rules_subset(
+                rulesbyif=rules,
+                metric=metric,
+                sort_strategy=sort_strategy,
+                top_count=top_count,
+                filter_sequence=filter_sequence,
+                cor_threshold=phi,
+                cost_threshold=c
+            )
+            self.unfairness = None
+    
+    def print_recourse_report(
+        self,
+        population_sizes=None,
+        missing_subgroup_val="N/A",
+        show_subgroup_costs=False,
+        show_action_costs=False,
+        show_cumulative_plots=False,
+        show_bias=None,
+        show_unbiased_subgroups=True,
+        correctness_metric=False,
+    ):
+        """Prints a nicely formatted report of the results (subpopulation groups
+        and recourses) discovered by the `bias_scan` method.
+
+        Args:
+            population_sizes (dict(str, int), optional): Number of individuals that
+                are given the negative prediction by the model, for each subgroup.
+                If given, it is included in the report together with some
+                coverage percentages.
+            missing_subgroup_val (str, optional): Optionally specify a value of the
+                protected attribute which denotes that it is missing and should not be
+                included in the printed results.
+                Defaults to "N/A".
+            show_subgroup_costs (bool, optional): Whether to show the costs assigned
+                to each protected subgroup.
+                Defaults to False.
+            show_action_costs (bool, optional): Whether to show the costs assigned
+                to each specific action.
+                Defaults to False.
+            show_cumulative_plots (bool, optional): If true, shows, for each subgroup,
+                a graph of the `effectiveness cumulative distribution`, as it is
+                called in [#FACTS23]_.
+                Defaults to False.
+            show_bias (str, optional): Specify which value of the protected
+                attribute corresponds to the subgroup against which we want to find
+                unfairness. Mainly useful for when the protected attribute is not
+                binary (e.g. race).
+                Defaults to None.
+            correctness_metric (bool, optional): if True, the metric is considered
+                to quantify utility, i.e. the greater it is for a group, the
+                more beneficial it is for the individuals of the group.
+                Defaults to False.
+            metric_name (str, optional): If given, it is added to the the printed
+                message for unfairness in a subpopulation group, i.e. the method
+                prints "Bias against females due to <metric_name>".
+
+        Raises:
+            RuntimeError: if costs for groups and subgroups are empty. Most
+                likely the `bias_scan` method was not run.
+        """
+        if self.unfairness is not None:
+            if not show_unbiased_subgroups:
+                mock_subgroup_costs = {sg: {"dummy": unfairness} for sg, unfairness in self.unfairness.items()}
+                rules_to_show = delete_fair_rules(self.top_rules, subgroup_costs=mock_subgroup_costs)
+            else:
+                rules_to_show = self.top_rules
+            print_recourse_report_KStest_cumulative(
+                rules_to_show,
+                population_sizes=population_sizes,
+                missing_subgroup_val=missing_subgroup_val,
+                unfairness=self.unfairness,
+                show_then_costs=show_action_costs,
+                show_cumulative_plots=show_cumulative_plots,
+            )
+        elif self.subgroup_costs is not None:
+            if not show_unbiased_subgroups:
+                rules_to_show = delete_fair_rules(self.top_rules, subgroup_costs=self.subgroup_costs)
+            else:
+                rules_to_show = self.top_rules
+            print_recourse_report(
+                rules_to_show,
+                population_sizes=population_sizes,
+                missing_subgroup_val=missing_subgroup_val,
+                subgroup_costs=self.subgroup_costs,
+                show_subgroup_costs=show_subgroup_costs,
+                show_then_costs=show_action_costs,
+                show_cumulative_plots=show_cumulative_plots,
+                show_bias=show_bias,
+                correctness_metric=correctness_metric,
+                metric_name=self._metric,
+            )
+        else:
+            raise RuntimeError("Something went wrong. Either subgroup_costs or unfairness should exist. Did you call `bias_scan`?")
+
+
diff --git a/aif360/sklearn/detectors/facts/clean.py b/aif360/sklearn/detectors/facts/clean.py
new file mode 100644
index 00000000..40bd529a
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/clean.py
@@ -0,0 +1,140 @@
+import pandas as pd
+from pandas import DataFrame
+import numpy as np
+
+
+def clean_adult(X: DataFrame) -> DataFrame:
+    """Cleans the `Adult` dataset. Specifically, drops the columns `fnlwgt`
+    and `education`, removes rows with missing values, discretizes numeric
+    features and binarizes labels.
+
+    Args:
+        X (DataFrame): input DataFrame. Assumed to contain the Adult dataset
+            as can be found at https://raw.githubusercontent.com/columbia/fairtest/master/data/adult/adult.csv
+            at the time of writing.
+
+    Returns:
+        DataFrame: the preprocessed DataFrame
+    """
+    X = X.reset_index(drop=True)
+    X = X.drop(columns=["fnlwgt", "education"], errors="ignore")
+    cols = list(X.columns)
+    X[cols] = X[cols].replace([" ?"], np.nan)
+    X = X.dropna()
+    def strip_str(x):
+        if isinstance(x, str):
+            return x.strip()
+        else:
+            return x
+    X = X.applymap(strip_str)
+    X["relationship"] = X["relationship"].replace(["Husband", "Wife"], "Married")
+    X["hours-per-week"] = pd.cut(
+        x=X["hours-per-week"],
+        bins=[0.9, 25, 39, 40, 55, 100],
+        labels=["PartTime", "MidTime", "FullTime", "OverTime", "BrainDrain"],
+    )
+    X.age = pd.qcut(X.age, q=5)
+    X["income"] = np.where((X["income"] == "<=50K"), 0, 1)
+
+    return X
+
+def clean_ssl(X: DataFrame) -> DataFrame:
+    """Cleans the `Subject List Dataset (SSL)` dataset. Specifically, binarizes
+    the predictions, removes irrelevant values, and discretizes numeric features.
+
+    Args:
+        X (DataFrame): input DataFrame. Assumed to contain the SSL dataset
+            as can be found at https://raw.githubusercontent.com/samuel-yeom/fliptest/master/exact-ot/chicago-ssl-clean.csv
+            (at the time of writing)
+
+    Returns:
+        DataFrame: the preprocessed DataFrame
+    """
+    X["SSL SCORE"] = np.where((X["SSL SCORE"] >= 345), 0, 1)
+    X = X.replace("WBH", "BLK")
+    X = X.replace("WWH", "WHI")
+    X = X.replace("U", np.nan)
+    X = X.replace("X", np.nan)
+    X = X.dropna()
+    X = X[(X["RACE CODE CD"] != "I") & (X["RACE CODE CD"] != "API")]
+    X["PREDICTOR RAT TREND IN CRIMINAL ACTIVITY"] = pd.qcut(
+        X["PREDICTOR RAT TREND IN CRIMINAL ACTIVITY"], q=6
+    )
+    X["PREDICTOR RAT AGE AT LATEST ARREST"] = X[
+        "PREDICTOR RAT AGE AT LATEST ARREST"
+    ].replace("less than 20", "10-20")
+
+    return X
+
+def clean_compas(X: DataFrame) -> DataFrame:
+    """Cleans the `COMPAS / ProPublica Recidivism` dataset. Specifically, drops
+    the columns `age` and `c_charge_desc`, maps labels to 0-1 and discretizes
+    continuous features
+
+    Args:
+        X (DataFrame): input DataFrame with the COMPAS dataset. Assumed to be
+            the dataframe returned by the function `aif360.sklearn.datasets.fetch_compas`.
+
+    Returns:
+        DataFrame: the preprocessed DataFrame
+    """
+    X = X.reset_index(drop=True)
+    X = X.drop(columns=["age", "c_charge_desc"])
+    X["priors_count"] = pd.cut(X["priors_count"], [-0.1, 1, 5, 10, 15, 38])
+    X.target.replace("Recidivated", 0, inplace=True)
+    X.target.replace("Survived", 1, inplace=True)
+    X["age_cat"].replace("Less than 25", "10-25", inplace=True)
+
+    return X
+
+
+def clean_dataset(X: DataFrame, dataset: str) -> DataFrame:
+    """Cleans and modifies the input DataFrame based on the specified dataset types.
+    Convenience function for calling the above functions specifying the dataset as an argument.
+
+    Args:
+        X (DataFrame): The input DataFrame to be cleaned.
+        dataset (str): A string indicating the dataset type. Valid options are 'adult', 'SSL', or 'compas'.
+
+    Returns:
+        DataFrame: The cleaned and modified DataFrame.
+
+    Raises:
+        None
+
+    Example:
+        >>> cols = ["fnlwgt", "education", "relationship", "hours-per-week", "age", "income"]
+        >>> mock_adult = pd.DataFrame(
+        ... [
+        ...     [13, "hello", " ?", 15, 30, " <=50K"],
+        ...     [13, "hello", " waifu", 15, " ?", " <=50K"],
+        ...     [13, "hello", " ?", 15, 30, " broke"],
+        ...     [" ?", "hello", " Husband", 30, 10, " <=50K"],
+        ...     [13, "hello", " Husband", 45, 20, " >50K"],
+        ...     [13, "hello", " Husband", 39.5, 30, " <=50K"],
+        ...     [13, "hello", " Husband", 15, 40, " >50K"],
+        ...     [13, "hello", " Wife", 45, 50, " >50K"],
+        ...     [13, "hello", " Wife", 39.5, 60, " <=50K"],
+        ...     [13, "hello", " Wife", 80, 70, " >50K"],
+        ... ],
+        ... columns=cols
+        ... )
+        >>> print(clean_dataset(mock_adult, "adult"))
+          relationship hours-per-week            age  income
+        3      Married        MidTime  (9.999, 22.0]       0
+        4      Married       OverTime  (9.999, 22.0]       1
+        5      Married       FullTime   (22.0, 34.0]       0
+        6      Married       PartTime   (34.0, 46.0]       1
+        7      Married       OverTime   (46.0, 58.0]       1
+        8      Married       FullTime   (58.0, 70.0]       0
+        9      Married     BrainDrain   (58.0, 70.0]       1
+    """
+
+    if dataset == "adult":
+        X = clean_adult(X)
+    elif dataset == "SSL":
+        X = clean_ssl(X)
+    elif dataset == "compas":
+        X = clean_compas(X)
+
+    return X
diff --git a/aif360/sklearn/detectors/facts/formatting.py b/aif360/sklearn/detectors/facts/formatting.py
new file mode 100644
index 00000000..b49386e5
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/formatting.py
@@ -0,0 +1,364 @@
+from typing import List, Dict, Tuple, Optional
+
+import matplotlib.pyplot as plt
+
+from colorama import Fore, Style
+
+from .predicate import Predicate
+
+ASSUME_ZERO = 10 ** (-7)
+
+def print_recourse_report(
+    rules: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    population_sizes: Optional[Dict[str, int]] = None,
+    missing_subgroup_val: str = "N/A",
+    subgroup_costs: Optional[Dict[Predicate, Dict[str, float]]] = None,
+    show_subgroup_costs: bool = False,
+    show_then_costs: bool = False,
+    show_cumulative_plots: bool = False,
+    show_bias: Optional[str] = None,
+    correctness_metric: bool = False,
+    metric_name: Optional[str] = None,
+) -> None:
+    """
+    Prints a report detailing the recourses and fairness assessment for a given set of rules.
+    In our work, we generally refer to this representation as "Comparative Subgroup Counterfactuals".
+
+    Args:
+        rules (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]):
+            The collection of rules with associated recourses, correctness, and costs.
+        population_sizes (Optional[Dict[str, int]], optional):
+            A dictionary specifying the population sizes for the protected and unprotected populations.
+            If provided, it is added to the coverage statistics included in the report.
+            Defaults to None.
+        missing_subgroup_val (str, optional):
+            In the case of records with missing values for the protected attribute (e.g. sex), this parameter
+            determines whether the "group" of individuals with unknown protected attribute will be shown
+            in the report or not. In other words, the group with this name is excluded altogether from the report.
+            Defaults to "N/A".
+        subgroup_costs (Optional[Dict[Predicate, Dict[str, float]]], optional):
+            A dictionary specifying the aggregate costs for each subgroup in each rule.
+            If provided, the costs of recourses will be included in the report.
+            Defaults to None.
+        show_subgroup_costs (bool, optional):
+            Indicates whether to display the subgroup costs in the report.
+            Only applicable when subgroup_costs is provided.
+            Defaults to False.
+        show_then_costs (bool, optional):
+            Indicates whether to display the counterfactual costs of recourses in the report.
+            Defaults to False.
+        show_cumulative_plots (bool, optional):
+            Indicates whether to display the cumulative effectiveness plots in the report.
+            Defaults to False.
+        show_bias (Optional[str], optional):
+            Specifies the biased subgroup to highlight in the report.
+            Only applicable when subgroup_costs is provided.
+            Defaults to None.
+        correctness_metric (bool, optional):
+            Indicates whether the metric to use for assessing bias is correctness or cost metric.
+            If False, it is a cost metric and the group with maximum value is the biased one.
+            If True, it is a correctness metric and the group with minimum value is the biased one.
+            Only applicable when subgroup_costs is provided.
+            Defaults to False.
+        metric_name (str, optional):
+            The name of the fairness metric used to assess bias.
+            Only applicable when subgroup_costs is provided.
+            Defaults to "Equal Effectiveness".
+
+    Returns:
+        None
+    """
+    if len(rules) == 0:
+        print(
+            f"{Style.BRIGHT}With the given parameters, no recourses showing unfairness have been found!{Style.RESET_ALL}"
+        )
+
+    for ifclause, sg_thens in rules.items():
+        if subgroup_costs is not None and show_bias is not None:
+            curr_subgroup_costs = subgroup_costs[ifclause]
+            biased_subgroup, max_cost = max(
+                curr_subgroup_costs.items(), key=lambda p: p[1]
+            )
+            if biased_subgroup != show_bias:
+                continue
+
+        print(f"If {Style.BRIGHT}{ifclause}{Style.RESET_ALL}:")
+        for subgroup, (cov, thens) in sg_thens.items():
+            if subgroup == missing_subgroup_val:
+                continue
+
+            # print coverage statistics for the subgroup
+            print(
+                f"\tProtected Subgroup '{Style.BRIGHT}{subgroup}{Style.RESET_ALL}', {Fore.BLUE}{cov:.2%}{Fore.RESET} covered",
+                end="",
+            )
+            if population_sizes is not None:
+                if subgroup in population_sizes:
+                    print(f" out of {population_sizes[subgroup]}", end="")
+                else:
+                    print(" (protected subgroup population size not given)", end="")
+            print()
+
+            # print each available recourse together with the respective correctness
+            if thens == []:
+                print(f"\t\t{Fore.RED}No recourses for this subgroup!{Fore.RESET}")
+            for then, correctness, cost in thens:
+                _, thenstr = ifthen2str(ifclause=ifclause, thenclause=then)
+
+                # abs() used to get rid of -0.0
+                assert correctness >= -ASSUME_ZERO
+                cor_str = Fore.GREEN + f"{abs(correctness):.2%}" + Fore.RESET
+                print(
+                    f"\t\tMake {Style.BRIGHT}{thenstr}{Style.RESET_ALL} with effectiveness {cor_str}",
+                    end="",
+                )
+
+                if show_then_costs:
+                    print(f" and counterfactual cost = {round(cost,2)}", end="")
+                print(".")
+
+            if subgroup_costs is not None and show_subgroup_costs:
+                cost_of_current_subgroup = subgroup_costs[ifclause][subgroup]
+                if f"{cost_of_current_subgroup:.2f}" == "-0.00":
+                    cost_of_current_subgroup = 0
+                print(
+                    f"\t\t{Style.BRIGHT}Aggregate cost{Style.RESET_ALL} of the above recourses = {Fore.MAGENTA}{cost_of_current_subgroup:.2f}{Fore.RESET}"
+                )
+
+        # TODO: show bias message in (much) larger font size.
+        if subgroup_costs is not None:
+            curr_subgroup_costs = subgroup_costs[ifclause]
+            if correctness_metric == False:
+                max_intergroup_cost_diff = max(curr_subgroup_costs.values()) - min(
+                    curr_subgroup_costs.values()
+                )
+                biased_subgroup, max_cost = max(
+                    curr_subgroup_costs.items(), key=lambda p: p[1]
+                )
+            else:
+                max_intergroup_cost_diff = max(curr_subgroup_costs.values()) - min(
+                    curr_subgroup_costs.values()
+                )
+                biased_subgroup, max_cost = min(
+                    curr_subgroup_costs.items(), key=lambda p: p[1]
+                )
+            if max_intergroup_cost_diff > 0:
+                metric_message = "" if metric_name is None else f" with respect to {metric_name}"
+                print(
+                    f"\t{Fore.MAGENTA}Bias against {biased_subgroup}{metric_message}. Unfairness score = {round(max_intergroup_cost_diff,3)}.{Fore.RESET}"
+                )
+            else:
+                print(f"\t{Fore.MAGENTA}No bias!{Fore.RESET}")
+
+        if show_cumulative_plots:
+            print(
+                f"\t{Fore.CYAN}Cumulative effectiveness plot for the above recourses:{Fore.RESET}"
+            )
+            cost_cors = {}
+            for sg, (_cov, thens) in rules[ifclause].items():
+                cost_cors[sg] = (
+                    [cost for _, _, cost in thens],
+                    [cor for _, cor, _ in thens],
+                )
+            plot_aggregate_correctness(cost_cors)
+            plt.show()
+
+def print_recourse_report_KStest_cumulative(
+    rules: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    population_sizes: Optional[Dict[str, int]] = None,
+    missing_subgroup_val: str = "N/A",
+    unfairness: Optional[Dict[Predicate, float]] = None,
+    show_then_costs: bool = False,
+    show_cumulative_plots: bool = False,
+) -> None:
+    """
+    Specialized function to produce a report (Comparative Subgroup Counterfactuals) based on the
+    "Fair Effectiveness-Cost Trade-Off" fairness metric, for a given set of rules.
+
+    Args:
+        rules (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]):
+            The collection of rules with associated recourses, correctness, and costs.
+        population_sizes (Optional[Dict[str, int]], optional):
+            A dictionary specifying the population sizes for the subgroups.
+            If provided, it is added to the coverage statistics included in the report.
+            Defaults to None.
+        missing_subgroup_val (str, optional):
+            In the case of records with missing values for the protected attribute (e.g. sex), this parameter
+            determines whether the "group" of individuals with unknown protected attribute will be shown
+            in the report or not. In other words, the group with this name is excluded altogether from the report.
+            Defaults to "N/A".
+        unfairness (Optional[Dict[Predicate, float]], optional):
+            A dictionary specifying the unfairness scores for each rule.
+            If provided, the unfairness scores will be included in the report.
+            Defaults to None.
+        show_then_costs (bool, optional):
+            Indicates whether to display the counterfactual costs of recourses in the report.
+            Defaults to False.
+        show_cumulative_plots (bool, optional):
+            Indicates whether to display the cumulative effectiveness plots in the report.
+            Defaults to False.
+        metric_name (str, optional):
+            The name of the fairness metric used to assess unfairness.
+            Only applicable when unfairness is provided.
+            Defaults to "Fair Effectiveness-Cost Trade-Off".
+
+    Returns:
+        None
+    """
+    if len(rules) == 0:
+        print(
+            f"{Style.BRIGHT}With the given parameters, no recourses showing unfairness have been found!{Style.RESET_ALL}"
+        )
+
+    for ifclause, sg_thens in rules.items():
+        print(f"If {Style.BRIGHT}{ifclause}{Style.RESET_ALL}:")
+        for subgroup, (cov, thens) in sg_thens.items():
+            if subgroup == missing_subgroup_val:
+                continue
+
+            # print coverage statistics for the subgroup
+            print(
+                f"\tProtected Subgroup '{Style.BRIGHT}{subgroup}{Style.RESET_ALL}', {Fore.BLUE}{cov:.2%}{Fore.RESET} covered",
+                end="",
+            )
+            if population_sizes is not None:
+                if subgroup in population_sizes:
+                    print(f" out of {population_sizes[subgroup]}", end="")
+                else:
+                    print(" (protected subgroup population size not given)", end="")
+            print()
+
+            # print each available recourse together with the respective correctness
+            if thens == []:
+                print(f"\t\t{Fore.RED}No recourses for this subgroup!{Fore.RESET}")
+            for then, correctness, cost in thens:
+                _, thenstr = ifthen2str(ifclause=ifclause, thenclause=then)
+
+                # abs() used to get rid of -0.0
+                assert correctness >= -ASSUME_ZERO
+                cor_str = Fore.GREEN + f"{abs(correctness):.2%}" + Fore.RESET
+                print(
+                    f"\t\tMake {Style.BRIGHT}{thenstr}{Style.RESET_ALL} with effectiveness {cor_str}",
+                    end="",
+                )
+
+                if show_then_costs:
+                    print(f" and counterfactual cost = {round(cost,2)}", end="")
+                print(".")
+
+        if unfairness is not None:
+            curr_unfairness = unfairness[ifclause]
+            print(
+                f"\t{Fore.MAGENTA} Unfairness based on \"Fair Effectiveness-Cost Trade-Off\" metric = {round(curr_unfairness,2)}.{Fore.RESET}"
+            )
+
+        if show_cumulative_plots:
+            print(
+                f"\t{Fore.CYAN}Cumulative effectiveness plot for the above recourses:{Fore.RESET}"
+            )
+            cost_cors = {}
+            for sg, (_cov, thens) in rules[ifclause].items():
+                cost_cors[sg] = (
+                    [cost for _, _, cost in thens],
+                    [cor for _, cor, _ in thens],
+                )
+            plot_aggregate_correctness(cost_cors)
+            plt.show()
+
+
+def ifthen2str(
+    ifclause: Predicate,
+    thenclause: Predicate,
+    show_same_feats: bool = False,
+    same_col: str = "default",
+    different_col: str = Fore.RED,
+) -> Tuple[str, str]:
+    """
+    Converts if-then clauses into strings for display in the recourse report.
+
+    Args:
+        ifclause (Predicate): The if-clause predicate.
+        thenclause (Predicate): The then-clause predicate.
+        show_same_feats (bool, optional):
+            Indicates whether to include features having the same values in both the
+            "if" clause and the "then" clause in the string representation.
+            Defaults to False.
+        same_col (str, optional):
+            The color code to use for displaying features with the same values.
+            Set to "default" to add no color (and hence use the default, if one is already chosen).
+            Defaults to "default".
+        different_col (str, optional):
+            The color code to use for displaying features with different values.
+            Defaults to Fore.RED.
+
+    Returns:
+        Tuple[str, str]: A tuple containing the string representation of the if-clause and then-clause, respectively.
+    """
+    ifstr = []
+    thenstr = []
+    first_rep = True
+    thendict = thenclause.to_dict()
+    for f, v in ifclause.to_dict().items():
+        if not show_same_feats and v == thendict[f]:
+            continue
+
+        if first_rep:
+            first_rep = False
+        else:
+            ifstr.append(", ")
+            thenstr.append(", ")
+
+        if v == thendict[f]:
+            if same_col != "default":
+                ifstr.append(same_col + f"{f} = {v}" + Fore.RESET)
+                thenstr.append(same_col + f"{f} = {v}" + Fore.RESET)
+            else:
+                ifstr.append(f"{f} = {v}")
+                thenstr.append(f"{f} = {v}")
+        else:
+            ifstr.append(different_col + f"{f} = {v}" + Fore.RESET)
+            thenstr.append(different_col + f"{f} = {thendict[f]}" + Fore.RESET)
+
+    return "".join(ifstr), "".join(thenstr)
+
+
+def plot_aggregate_correctness(
+    costs_cors_per_subgroup: Dict[str, Tuple[List[float], List[float]]]
+):
+    """
+    Creates a line plot showing the aggregate correctness percentage for different subgroups based on the cost of change.
+
+    Args:
+        costs_cors_per_subgroup (Dict[str, Tuple[List[float], List[float]]]):
+            A dictionary mapping subgroup names to a tuple of lists representing the costs and correctness percentages
+            for each subgroup.
+
+    Returns:
+        plt.Figure: The created matplotlib Figure object.
+    """
+    subgroup_markers = {
+        sg: (index, 0, 0)
+        for index, sg in enumerate(costs_cors_per_subgroup.keys(), start=3)
+    }
+    fig, ax = plt.subplots()
+    lines = []
+    labels = []
+    for sg, (costs, correctnesses) in costs_cors_per_subgroup.items():
+        (line,) = ax.step(
+            costs,
+            correctnesses,
+            where="post",
+            marker=subgroup_markers[sg],
+            label=sg,
+            alpha=0.7,
+        )
+        lines.append(line)
+        labels.append(sg)
+    ax.set_xlabel("Cost of change")
+    ax.set_ylabel("Correctness percentage")
+    ax.legend(lines, labels)
+    return fig
diff --git a/aif360/sklearn/detectors/facts/frequent_itemsets.py b/aif360/sklearn/detectors/facts/frequent_itemsets.py
new file mode 100644
index 00000000..487b39d0
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/frequent_itemsets.py
@@ -0,0 +1,94 @@
+import warnings
+import numpy as np
+import pandas as pd
+
+from pandas import DataFrame
+from typing import List, Tuple
+
+from mlxtend.preprocessing import TransactionEncoder
+from mlxtend.frequent_patterns import fpgrowth
+## removes nasty mlxtend-caused warning: they set a global warning filter on
+## Deprecation Warnings which then hits even when e.g. importing matplotlib.
+if warnings.filters[0] == ("always", None, DeprecationWarning, None, 0):
+    warnings.filters.pop(0)
+
+from .predicate import Predicate
+
+
+def preprocessDataset(data: DataFrame) -> DataFrame:
+    """Preprocesses the input DataFrame by converting categorical columns to
+        NumPy arrays and mapping each cell value with its column name.
+
+    Args:
+        data (DataFrame): The input DataFrame to be preprocessed.
+
+    Returns:
+        DataFrame: The preprocessed DataFrame.
+
+    Raises:
+        None
+    """
+    d = data.copy()
+    for col in d:
+        if isinstance(d[col].dtype, pd.CategoricalDtype):
+            d[col] = np.asarray(d[col])
+        d[col] = d[col].map(lambda x: (col, x))
+    return d
+
+
+def fpgrowth_out_to_predicate_list(
+    fpgrowth_out: DataFrame,
+) -> Tuple[List[Predicate], List[float]]:
+    """Converts the output of the FP Growth algorithm stored in a DataFrame to a
+        list of Predicate objects and their corresponding support values.
+
+    Args:
+        fpgrowth_out (DataFrame): The DataFrame containing the output of the
+            FP Growth algorithm.
+
+    Returns:
+        Tuple[List[Predicate], List[float]]: A tuple containing the list of
+            Predicate objects and the list of corresponding support values.
+
+    Raises:
+        None
+
+    Examples:
+        >>> freq_itemsets = run_fpgrowth(preprocessDataset(df), min_support=0.03)
+        >>> predicate_list = fpgrowth_out_to_predicate_list(freq_itemsets)
+
+    """
+    predicate_set = []
+    for itemset in fpgrowth_out["itemsets"].to_numpy():
+        pred = {feature: value for feature, value in list(itemset)}
+        pred = Predicate.from_dict(pred)
+        predicate_set.append(pred)
+
+    return predicate_set, fpgrowth_out["support"].to_numpy().tolist()
+
+
+def run_fpgrowth(data: DataFrame, min_support: float = 0.001) -> DataFrame:
+    """Runs the FP Growth algorithm on the input DataFrame to find frequent
+        itemsets.
+
+    Args:
+        data (DataFrame): The input DataFrame.
+        min_support (float, optional): The minimum support threshold for
+            itemsets. Defaults to 0.001, i.e 0.1%.
+
+    Returns:
+        DataFrame: The DataFrame containing frequent itemsets sorted by
+            support in descending order.
+
+    Raises:
+        None
+
+    Examples:
+        >>> freq_itemsets = runFPGrowth(preprocessDataset(df), min_support=0.03)
+    """
+    sets = data.to_numpy().tolist()
+    te = TransactionEncoder()
+    sets_onehot = te.fit_transform(sets)
+    df = DataFrame(sets_onehot, columns=te.columns_)  # type: ignore
+    frequent_itemsets = fpgrowth(df, min_support=min_support, use_colnames=True)
+    return frequent_itemsets.sort_values(["support"], ascending=[False])
diff --git a/aif360/sklearn/detectors/facts/metrics.py b/aif360/sklearn/detectors/facts/metrics.py
new file mode 100644
index 00000000..7ce555fd
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/metrics.py
@@ -0,0 +1,229 @@
+from typing import List, Tuple, Dict, Callable
+
+import numpy as np
+from pandas import DataFrame
+
+from .predicate import Predicate
+
+##### Metrics as guided by AReS paper.
+
+def incorrectRecoursesIfThen(
+    ifclause: Predicate, thenclause: Predicate, X_aff: DataFrame, model
+) -> int:
+    """Compute the number of incorrect recourses given an if-then clause.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thenclause: The then-clause predicate.
+        X_aff: The affected DataFrame.
+        model: The ML model under study. Expected to have a "predict" method.
+
+    Returns:
+        The number of incorrect recourses.
+
+    Raises:
+        ValueError: If there are no covered instances for the given if-clause.
+    """
+    X_aff_covered_bool = (X_aff[ifclause.features] == ifclause.values).all(axis=1)
+    X_aff_covered = X_aff[X_aff_covered_bool].copy()
+    if X_aff_covered.shape[0] == 0:
+        raise ValueError(
+            "Assuming non-negative frequent itemset threshold, total absence of covered instances should be impossible!"
+        )
+
+    X_aff_covered[thenclause.features] = thenclause.values
+
+    preds = model.predict(X_aff_covered)
+    return np.shape(preds)[0] - np.sum(preds)
+
+
+##### Subgroup cost metrics of the "macro" and "micro" viewpoints.
+
+def if_group_cost_min_change_correctness_threshold(
+    ifclause: Predicate,
+    thens_corrs_costs: List[Tuple[Predicate, float, float]],
+    cor_thres: float = 0.5,
+) -> float:
+    """Calculate the minimum feature change for a given if-clause and a list of then-clauses with a minimum correctness threshold.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thenclauses: The list of then-clause predicates with their corresponding correctness values.
+        cor_thres: The minimum correctness threshold. Only then-clauses with a correctness value greater than or equal to this threshold will be considered.
+        params: The parameter proxy.
+
+    Returns:
+        The minimum feature change value.
+
+    """
+    costs = np.array(
+        [
+            cost
+            for thenclause, cor, cost in thens_corrs_costs
+            if cor >= cor_thres
+        ]
+    )
+    if costs.size > 0:
+        ret = costs.min()
+    else:
+        ret = np.inf
+    return ret
+
+def if_group_cost_recoursescount_correctness_threshold(
+    ifclause: Predicate,
+    thens_corrs_costs: List[Tuple[Predicate, float, float]],
+    cor_thres: float = 0.5,
+) -> float:
+    """Calculate the negative count of feature changes for a given if-clause and a list of then-clauses with a minimum correctness threshold.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thenclauses: The list of then-clause predicates with their corresponding correctness values.
+        cor_thres: The minimum correctness threshold. Only then-clauses with a correctness value greater than or equal to this threshold will be considered.
+        params: The parameter proxy.
+
+    Returns:
+        The negative count of feature changes.
+
+    """
+    costs = np.array(
+        [
+            cost
+            for thenclause, cor, cost in thens_corrs_costs
+            if cor >= cor_thres
+        ]
+    )
+    return -costs.size
+
+def if_group_maximum_correctness(
+    ifclause: Predicate,
+    thens_corrs_costs: List[Tuple[Predicate, float, float]],
+) -> float:
+    """Calculate the maximum correctness value for a given if-clause and a list of then-clauses.
+
+    Args:
+        ifclause: The if-clause predicate.
+        then_corrs_costs: The list of then-clause predicates with their corresponding correctness and cost values.
+        params: The parameter proxy.
+
+    Returns:
+        The maximum correctness value.
+
+    """
+    return max(cor for _then, cor, _cost in thens_corrs_costs)
+
+def if_group_cost_max_correctness_cost_budget(
+    ifclause: Predicate,
+    then_corrs_costs: List[Tuple[Predicate, float, float]],
+    cost_thres: float = 0.5,
+) -> float:
+    """Calculate the maximum correctness value for a given if-clause and a list of then-clauses with cost below a threshold.
+
+    Args:
+        ifclause: The if-clause predicate.
+        then_corrs_costs: The list of then-clause predicates with their corresponding correctness and cost values.
+        cor_thres: The correctness threshold.
+        cost_thres: The cost threshold. Only then-clauses with cost below this threshold will be considered.
+        params: The parameter proxy.
+
+    Returns:
+        The maximum correctness value.
+
+    """
+    corrs = np.array(
+        [cor for _then, cor, cost in then_corrs_costs if cost <= cost_thres]
+    )
+    if corrs.size > 0:
+        ret = corrs.max()
+    else:
+        ret = np.inf
+    return ret
+
+
+def if_group_average_recourse_cost_conditional(
+    ifclause: Predicate,
+    thens: List[Tuple[Predicate, float, float]],
+) -> float:
+    """Calculate the average recourse cost conditional on the correctness for a given if-clause and a list of then-clauses.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thens: The list of then-clause predicates with their corresponding correctness and cost values.
+        params: The parameter proxy.
+
+    Returns:
+        The average recourse cost conditional on the correctness.
+
+    """
+    mincost_cdf = np.array([corr for then, corr, cost in thens])
+    costs = np.array([cost for then, corr, cost in thens])
+
+    mincost_pmf = np.diff(mincost_cdf, prepend=0)
+
+    total_prob = np.sum(mincost_pmf)
+    if total_prob > 0:
+        return np.dot(mincost_pmf, costs) / np.sum(mincost_pmf)
+    else:
+        return np.inf
+
+
+##### Aggregations of if-group cost for all protected subgroups and subgroups in a list
+
+if_group_cost_f_t = Callable[[Predicate, List[Tuple[Predicate, float, float]]], float]
+
+
+def calculate_if_subgroup_costs(
+    ifclause: Predicate,
+    thenclauses: Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]],
+    group_calculator: if_group_cost_f_t
+) -> Dict[str, float]:
+    """Calculate the costs for each subgroup of a given if-clause.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thenclauses: A dictionary mapping subgroup names to their corresponding coverage and then-clause predicates.
+        group_calculator: The function used to calculate the cost for each subgroup. Defaults to `if_group_cost_min_change_correctness_threshold`.
+        **kwargs: Additional keyword arguments to be passed to the group_calculator function.
+
+    Returns:
+        A dictionary mapping subgroup names to their calculated costs.
+
+    """
+    return {
+        sg: group_calculator(ifclause, thens)
+        for sg, (_cov, thens) in thenclauses.items()
+    }
+
+
+def calculate_all_if_subgroup_costs(
+    ifclauses: List[Predicate],
+    all_thenclauses: List[Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]],
+    group_calculator: if_group_cost_f_t
+) -> Dict[Predicate, Dict[str, float]]:
+    ret: Dict[Predicate, Dict[str, float]] = {}
+    for ifclause, thenclauses in zip(ifclauses, all_thenclauses):
+        ret[ifclause] = calculate_if_subgroup_costs(ifclause, thenclauses, group_calculator)
+    return ret
+
+##### Calculations of discrepancies between the costs of different subgroups (for the same if-group)
+
+def max_intergroup_cost_diff(
+    ifclause: Predicate,
+    thenclauses: Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]],
+    group_calculator: if_group_cost_f_t
+) -> float:
+    """Calculate the maximum difference in subgroup costs for an if-clause and its corresponding then-clauses.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thenclauses: A dictionary mapping subgroup names to their corresponding coverage, then-clause predicates, and costs.
+        **kwargs: Additional keyword arguments to be passed to the calculate_if_subgroup_costs function.
+
+    Returns:
+        The maximum difference in subgroup costs.
+
+    """
+    group_costs = list(
+        calculate_if_subgroup_costs(ifclause, thenclauses, group_calculator).values()
+    )
+    return max(group_costs) - min(group_costs)
diff --git a/aif360/sklearn/detectors/facts/misc.py b/aif360/sklearn/detectors/facts/misc.py
new file mode 100644
index 00000000..7e88859a
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/misc.py
@@ -0,0 +1,683 @@
+from tqdm import tqdm
+from typing import List, Tuple, Dict
+import functools
+
+import numpy as np
+from pandas import DataFrame
+
+from .parameters import *
+from .predicate import Predicate, recIsValid, featureChangePred, drop_two_above
+from .frequent_itemsets import run_fpgrowth, preprocessDataset, fpgrowth_out_to_predicate_list
+from .metrics import (
+    incorrectRecoursesIfThen,
+    if_group_cost_min_change_correctness_threshold,
+    if_group_cost_recoursescount_correctness_threshold,
+    if_group_maximum_correctness,
+    if_group_cost_max_correctness_cost_budget,
+    if_group_average_recourse_cost_conditional,
+    calculate_all_if_subgroup_costs
+)
+from .optimization import (
+    sort_triples_by_max_costdiff,
+    sort_triples_KStest,
+)
+from .rule_filters import (
+    remove_rules_below_correctness_threshold,
+    keep_rules_until_correctness_threshold_reached,
+    remove_rules_above_cost_budget,
+    filter_contained_rules_simple,
+    filter_contained_rules_keep_max_bias,
+    delete_fair_rules,
+    keep_only_minimum_change,
+)
+
+def affected_unaffected_split(
+    X: DataFrame, model
+) -> Tuple[DataFrame, DataFrame]:
+    """
+    Split the input data into affected and unaffected individuals.
+
+    Args:
+        X (pd.DataFrame): The input data.
+        model (ModelAPI): The model used for predictions.
+
+    Returns:
+        Tuple[pd.DataFrame, pd.DataFrame]:
+            A tuple containing the affected individuals and unaffected individuals.
+    """
+    # get model predictions
+    preds = model.predict(X)
+    # find affected individuals
+    X_aff_idxs = np.where(preds == 0)[0]
+    X_aff = X.iloc[X_aff_idxs, :]
+
+    # find unaffected individuals
+    X_unaff_idxs = np.where(preds == 1)[0]
+    X_unaff = X.iloc[X_unaff_idxs, :]
+
+    return X_aff, X_unaff
+
+
+def freqitemsets_with_supports(
+    X: DataFrame, min_support: float = 0.01
+) -> Tuple[List[Predicate], List[float]]:
+    """
+    Calculate frequent itemsets with their support values.
+
+    Args:
+        X (DataFrame): The input data.
+        min_support (float, optional): The minimum support threshold. Defaults to 0.01.
+
+    Returns:
+        Tuple[List[Predicate], List[float]]:
+            A tuple containing the list of frequent itemsets and their support values.
+    """
+    ret = fpgrowth_out_to_predicate_list(
+        run_fpgrowth(preprocessDataset(X), min_support=min_support)
+    )
+    return ret
+
+
+def calculate_correctnesses(
+    ifthens_withsupp: List[Tuple[Predicate, Predicate, Dict[str, float]]],
+    affected_by_subgroup: Dict[str, DataFrame],
+    sensitive_attribute: str,
+    model,
+    verbose: bool = True,
+) -> List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]:
+    """
+    Calculate the correctness of recourse actions for each subgroup in a list of if-then rules.
+
+    Args:
+        ifthens_withsupp (List[Tuple[Predicate, Predicate, Dict[str, float]]]):
+            List of if-then rules with their support values.
+        affected_by_subgroup (Dict[str, DataFrame]):
+            Dictionary where keys are subgroup names and values are DataFrames representing affected individuals in each subgroup.
+        sensitive_attribute (str):
+            Name of the sensitive attribute in the dataset.
+        model (ModelAPI): The model used for making predictions.
+        verbose (bool): whether to print progress bar. Defaults to True.
+
+    Returns:
+        List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]:
+            List of tuples containing the if-then rule, its support values, and the correctness of recourse actions for each subgroup.
+    """
+    subgroup_names = list(affected_by_subgroup.keys())
+    ifthens_with_correctness = []
+    ifthens_withsupp_iter = tqdm(ifthens_withsupp) if verbose else ifthens_withsupp
+    for h, s, ifsupps in ifthens_withsupp_iter:
+        recourse_correctness = {}
+        for sg in subgroup_names:
+            incorrect_recourses_for_sg = incorrectRecoursesIfThen(
+                h,
+                s,
+                affected_by_subgroup[sg].assign(**{sensitive_attribute: sg}),
+                model,
+            )
+            covered_sg = ifsupps[sg] * affected_by_subgroup[sg].shape[0]
+            inc_sg = incorrect_recourses_for_sg / covered_sg
+            recourse_correctness[sg] = 1 - inc_sg
+
+        ifthens_with_correctness.append((h, s, ifsupps, recourse_correctness))
+
+    return ifthens_with_correctness
+
+def aff_intersection_version_2(RLs_and_supports, subgroups, verbose=True):
+    """
+    Compute the intersection of multiple sets of predicates and their corresponding supports.
+
+    Args:
+        RLs_and_supports (Dict[str, List[Tuple[Dict[str, any], float]]]):
+            Dictionary of predicates and their supports for each subgroup.
+        subgroups (List[str]): List of subgroup names.
+        verbose (bool): whether to print progress bar. Defaults to True.
+
+    Returns:
+        List[Tuple[Predicate, Dict[str, float]]]:
+            List of tuples containing the intersected predicates and their supports.
+
+    Raises:
+        ValueError: If there are fewer than 2 subgroups.
+    """
+    RLs_supports_dict = {
+        sg: {tuple(sorted(zip(p.features, p.values))): supp for p, supp in zip(*RL_sup)}
+        for sg, RL_sup in RLs_and_supports.items()
+    }
+
+    if len(RLs_supports_dict) < 1:
+        raise ValueError("There must be at least 2 subgroups.")
+    else:
+        aff_intersection = []
+
+        _, sg1 = min((len(RLs_supports_dict[sg]), sg) for sg in subgroups)
+
+        RLs_supports_dict_sg1_iter = tqdm(RLs_supports_dict[sg1].items()) if verbose else RLs_supports_dict[sg1].items()
+        for value, supp in RLs_supports_dict_sg1_iter:
+            in_all = True
+            supp_dict = {sg1: supp}
+            for sg2 in subgroups:
+                if sg2 == sg1:
+                    continue
+                if value not in RLs_supports_dict[sg2]:
+                    in_all = False
+                    break
+                supp_dict[sg2] = RLs_supports_dict[sg2].pop(value)
+
+            if in_all == True:
+                feat_dict = {}
+                for feat in value:
+                    feat_dict[feat[0]] = feat[1]
+                aff_intersection.append((feat_dict, supp_dict))
+
+    aff_intersection = [
+        (Predicate.from_dict(d), supps) for d, supps in aff_intersection
+    ]
+
+    return aff_intersection
+
+def valid_ifthens(
+    X: DataFrame,
+    model,
+    sensitive_attribute: str,
+    freqitem_minsupp: float = 0.01,
+    missing_subgroup_val: str = "N/A",
+    drop_infeasible: bool = True,
+    drop_above: bool = True,
+    feats_not_allowed_to_change: List[str] = [],
+    verbose: bool = True,
+) -> List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]:
+    """
+    Compute valid if-then rules along with their coverage and correctness metrics.
+
+    Args:
+        X (DataFrame): Input data.
+        model (ModelAPI): The model used for predictions.
+        sensitive_attribute (str): The name of the sensitive attribute column in the dataset.
+        freqitem_minsupp (float): Minimum support threshold for frequent itemset mining.
+        missing_subgroup_val (str): Value indicating missing or unknown subgroup.
+        drop_infeasible (bool): Whether to drop infeasible if-then rules.
+        drop_above (bool): Whether to drop if-then rules with feature changes above a certain threshold.
+        feats_not_allowed_to_change (list[str]): optionally, the user can provide some features which are not allowed to change at all (e.g. sex).
+        verbose (bool): whether to print intermediate messages and progress bar. Defaults to True.
+
+    Returns:
+        List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]:
+            List of tuples containing the valid if-then rules, coverage metrics, and correctness metrics.
+
+    """
+    # throw out all individuals for whom the value of the sensitive attribute is unknown
+    X = X[X[sensitive_attribute] != missing_subgroup_val]
+
+    # split into affected-unaffected
+    X_aff, X_unaff = affected_unaffected_split(X, model)
+
+    # find descriptors of all sensitive subgroups
+    subgroups = np.unique(X[sensitive_attribute])
+    # split affected individuals into subgroups
+    affected_subgroups = {
+        sg: X_aff[X_aff[sensitive_attribute] == sg].drop([sensitive_attribute], axis=1)
+        for sg in subgroups
+    }
+
+    # calculate frequent itemsets for each subgroup and turn them into predicates
+    if verbose:
+        print(
+            "Computing candidate subgroups.",
+            flush=True,
+        )
+    affected_subgroups_iter = tqdm(affected_subgroups.items(), leave=False) if verbose else affected_subgroups.items()
+    RLs_and_supports = {
+        sg: freqitemsets_with_supports(affected_sg, min_support=freqitem_minsupp)
+        for sg, affected_sg in affected_subgroups_iter
+    }
+
+    # intersection of frequent itemsets of all sensitive subgroups
+    aff_intersection = aff_intersection_version_2(RLs_and_supports, subgroups, verbose=verbose)
+    if verbose:
+        print(f"Number of subgroups: {len(aff_intersection)}", flush=True)
+
+        print(
+            "Computing candidate recourses for all subgroups.",
+            flush=True,
+        )
+    # Frequent itemsets for the unaffacted (to be used in the then clauses)
+    freq_unaffected, _ = freqitemsets_with_supports(
+        X_unaff, min_support=freqitem_minsupp
+    )
+
+    # Filter all if-then pairs to keep only valid
+
+    # we want to create a dictionary for freq_unaffected key: features in tuple, value: list(values)
+    # for each Predicate in aff_intersection we loop through the list from dictionary
+    # create dictionary:
+
+    freq_unaffected_dict = {}
+    for predicate_ in freq_unaffected:
+        if tuple(predicate_.features) in freq_unaffected_dict:
+            freq_unaffected_dict[tuple(predicate_.features)].append(predicate_.values)
+        else:
+            freq_unaffected_dict[tuple(predicate_.features)] = [predicate_.values]
+
+    ifthens = []
+    aff_intersection_iter = tqdm(aff_intersection) if verbose else aff_intersection
+    for predicate_, supps_dict in aff_intersection_iter:
+        candidates = freq_unaffected_dict.get(tuple(predicate_.features))
+        if candidates == None:
+            continue
+        for candidate_values in candidates:
+            # resIsValid can be changed to avoid checking if features are the same
+            if recIsValid(
+                predicate_,
+                Predicate(predicate_.features, candidate_values),
+                affected_subgroups[subgroups[0]],
+                drop_infeasible,
+                feats_not_allowed_to_change=feats_not_allowed_to_change
+            ):
+                ifthens.append(
+                    (
+                        predicate_,
+                        Predicate(predicate_.features, candidate_values),
+                        supps_dict,
+                    )
+                )
+
+    # keep ifs that have change on features of max value 2
+    if drop_above == True:
+        age = [val.left for val in X.age.unique()]
+        age.sort()
+        ifthens = [
+            (ifs, then, cov)
+            for ifs, then, cov in ifthens
+            if drop_two_above(ifs, then, age)
+        ]
+
+    # Calculate correctness percentages
+    if verbose:
+        print("Computing percentages of individuals flipped by each action independently.", flush=True)
+    ifthens_with_correctness = calculate_correctnesses(
+        ifthens, affected_subgroups, sensitive_attribute, model, verbose=verbose
+    )
+
+    return ifthens_with_correctness
+
+def calc_costs(
+    rules: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]],
+    params: ParameterProxy = ParameterProxy()
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    ret: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]] = dict()
+    for ifclause, thenclauses in rules.items():
+        newthenclauses: Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]] = dict()
+        for sg, (cov, thens) in thenclauses.items():
+            # TODO: make featureChangePred return a float, if possible
+            newthens = [(then, cor, float(featureChangePred(ifclause, then, params))) for then, cor in thens]
+            newthenclauses[sg] = (cov, newthens)
+        ret[ifclause] = newthenclauses
+    return ret
+
+def rules2rulesbyif(
+    rules: List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+    """
+    Group rules based on the If clauses instead of protected subgroups.
+
+    Args:
+        rules (List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]):
+            List of tuples containing the if-then rules, coverage metrics, and correctness metrics.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+            Dictionary containing the rules grouped by the If clauses.
+
+    """
+    # group rules based on If clauses, instead of protected subgroups!
+    rules_by_if: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]
+    ] = {}
+    for h, s, covs, cors in rules:
+        if h not in rules_by_if:
+            rules_by_if[h] = {sg: (cov, []) for sg, cov in covs.items()}
+
+        for sg, (_cov, sg_thens) in rules_by_if[h].items():
+            sg_thens.append((s, cors[sg]))
+
+    return rules_by_if
+
+def rulesbyif2rules(
+    rules_by_if: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]
+) -> List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]:
+    """
+    Convert rules grouped by If clauses to rules.
+
+    Args:
+        rules_by_if (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            Dictionary containing rules grouped by the If clauses.
+
+    Returns:
+        List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]]:
+            List of tuples containing the if-then rules, coverage metrics, and correctness metrics.
+
+    """
+    rules: List[Tuple[Predicate, Predicate, Dict[str, float], Dict[str, float]]] = []
+    for ifclause, thenclauses in rules_by_if.items():
+        then_covs_cors = dict()
+        for sg, (cov, thens) in thenclauses.items():
+            for then, cor in thens:
+                if then in then_covs_cors:
+                    then_covs_cors[then][0][sg] = cov
+                    then_covs_cors[then][1][sg] = cor
+                else:
+                    then_covs_cors[then] = ({sg: cov}, {sg: cor})
+
+        for then, covs_cors in then_covs_cors.items():
+            rules.append((ifclause, then, covs_cors[0], covs_cors[1]))
+    return rules
+
+
+def select_rules_subset(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    metric: str = "equal-effectiveness",
+    sort_strategy: str = "max-cost-diff-decr",
+    top_count: int = 10,
+    filter_sequence: List[str] = [],
+    cor_threshold: float = 0.5,
+    cost_threshold: float = 0.5,
+    secondary_sorting_objectives: List[str] = [],
+) -> Tuple[
+    Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]],
+    Dict[Predicate, Dict[str, float]],
+]:
+    """Selects a subset of rules.
+
+    Args:
+        rulesbyif:
+            A dictionary mapping predicates to a dictionary of tuples containing
+            cost, correctness, and cumulative cost values for each rule.
+        metric:
+            The metric to use for sorting the rules (default: "equal-effectiveness").
+        sort_strategy:
+            The strategy to use for sorting the rules (default: "abs-diff-decr").
+        top_count:
+            The number of top rules to select (default: 10).
+        filter_sequence:
+            A list of filtering criteria to apply to the rules (default: []).
+        cor_threshold:
+            The correctness threshold for filtering rules (default: 0.5).
+        cost_threshold:
+            The cost threshold for filtering rules (default: 0.5).
+        c_inf:
+            The coefficient for infinity value in fairness-of-mean-recourse-cinf metric
+            (default: 2).
+        secondary_sorting_objectives:
+            A list of secondary objectives for sorting the rules
+            (default: []).
+        params: A parameter proxy object (default: ParameterProxy()).
+
+    Returns:
+        A tuple containing the selected subset of rules and the costs of the then-blocks
+        of the top rules.
+
+    """
+    # step 1: sort according to metric
+    metrics: Dict[
+        str, Callable[[Predicate, List[Tuple[Predicate, float, float]]], float]
+    ] = {
+        "equal-cost-of-effectiveness": functools.partial(
+            if_group_cost_min_change_correctness_threshold,
+            cor_thres=cor_threshold
+        ),
+        "equal-choice-for-recourse": functools.partial(
+            if_group_cost_recoursescount_correctness_threshold,
+            cor_thres=cor_threshold
+        ),
+        "equal-effectiveness": if_group_maximum_correctness,
+        "equal-effectiveness-within-budget": functools.partial(
+            if_group_cost_max_correctness_cost_budget, cost_thres=cost_threshold
+        ),
+        "equal-mean-recourse": if_group_average_recourse_cost_conditional
+    }
+    sorting_functions = {
+        "max-cost-diff-decr": functools.partial(
+            sort_triples_by_max_costdiff,
+            ignore_nans=False,
+            ignore_infs=False,
+            secondary_objectives=secondary_sorting_objectives,
+        ),
+        "max-cost-diff-decr-ignore-forall-subgroups-empty": functools.partial(
+            sort_triples_by_max_costdiff,
+            ignore_nans=True,
+            ignore_infs=False,
+            secondary_objectives=secondary_sorting_objectives,
+        ),
+        "max-cost-diff-decr-ignore-exists-subgroup-empty": functools.partial(
+            sort_triples_by_max_costdiff,
+            ignore_nans=True,
+            ignore_infs=True,
+            secondary_objectives=secondary_sorting_objectives,
+        ),
+    }
+    metric_fn = metrics[metric]
+    sort_fn = sorting_functions[sort_strategy]
+    rules_sorted = sort_fn(rulesbyif, group_calculator=metric_fn)
+
+    # step 2: keep only top k rules
+    top_rules = dict(rules_sorted[:top_count])
+
+    # keep also the aggregate costs of the then-blocks of the top rules
+    costs = calculate_all_if_subgroup_costs(
+        list(rulesbyif.keys()),
+        list(rulesbyif.values()),
+        group_calculator=metric_fn,
+    )
+
+    # step 3 (optional): filtering
+    filters: Dict[
+        str,
+        Callable[
+            [
+                Dict[
+                    Predicate,
+                    Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]],
+                ]
+            ],
+            Dict[
+                Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+            ],
+        ],
+    ] = {
+        "remove-contained": functools.partial(
+            filter_contained_rules_keep_max_bias, subgroup_costs=costs
+        ),
+        "remove-below-thr-corr": functools.partial(
+            remove_rules_below_correctness_threshold, threshold=cor_threshold
+        ),
+        "remove-above-thr-cost": functools.partial(
+            remove_rules_above_cost_budget, threshold=cost_threshold
+        ),
+        "keep-rules-until-thr-corr-reached": functools.partial(
+            keep_rules_until_correctness_threshold_reached,
+            threshold=cor_threshold,
+        ),
+        "remove-fair-rules": functools.partial(
+            delete_fair_rules, subgroup_costs=costs
+        ),
+        "keep-only-min-change": keep_only_minimum_change,
+    }
+    for single_filter in filter_sequence:
+        top_rules = filters[single_filter](top_rules)
+
+    return top_rules, costs
+
+
+def select_rules_subset_KStest(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    affected_population_sizes: Dict[str, int],
+    top_count: int = 10,
+    filter_contained: bool = False,
+) -> Tuple[
+    Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]],
+    Dict[Predicate, float],
+]:
+    """Selects a subset of rules based on the Kolmogorov-Smirnov (KS) test metric.
+
+    Args:
+        rulesbyif: A dictionary mapping predicates to a dictionary of tuples containing
+            cost, correctness, and cumulative cost values for each rule.
+        affected_population_sizes: A dictionary mapping subgroup names to their sizes.
+        top_count: The number of top rules to select (default: 10).
+        filter_contained: Whether to filter contained rules (default: False).
+
+    Returns:
+        A tuple containing the selected subset of rules and the unfairness values
+        calculated.
+
+    """
+    # step 1: sort according to metric
+    rules_sorted, unfairness = sort_triples_KStest(rulesbyif, affected_population_sizes)
+
+    # step 2: keep only top k rules
+    top_rules = dict(rules_sorted[:top_count])
+
+    if filter_contained:
+        top_rules = filter_contained_rules_simple(top_rules)
+
+    return top_rules, unfairness
+
+
+def cum_corr_costs(
+    ifclause: Predicate,
+    thenclauses: List[Tuple[Predicate, float]],
+    X: DataFrame,
+    model,
+    params: ParameterProxy = ParameterProxy(),
+) -> List[Tuple[Predicate, float, float]]:
+    """Calculate cumulative correctness and costs for the given if-then rules.
+
+    Args:
+        ifclause: The if-clause predicate.
+        thenclauses: A list of tuples containing then-clause predicates and their
+            corresponding correctness values.
+        X: The DataFrame containing the data.
+        model: The model API used for prediction.
+        params: Optional parameter proxy (default: ParameterProxy()).
+
+    Returns:
+        A list of tuples containing the updated then-clause predicates, cumulative
+        correctness values, and costs.
+
+    """
+    withcosts = [
+        (thenclause, cor, featureChangePred(ifclause, thenclause, params))
+        for thenclause, cor in thenclauses
+    ]
+    thens_sorted_by_cost = sorted(withcosts, key=lambda c: (c[2], c[1]))
+
+    X_covered_bool = (X[ifclause.features] == ifclause.values).all(axis=1)
+    X_covered = X[X_covered_bool]
+    covered_count = X_covered.shape[0]
+
+    cumcorrs = []
+    for thenclause, _cor, _cost in thens_sorted_by_cost:
+        if X_covered.shape[0] == 0:
+            cumcorrs.append(0)
+            continue
+        X_temp = X_covered.copy()
+        X_temp[thenclause.features] = thenclause.values
+        preds = model.predict(X_temp)
+
+        corrected_count = np.sum(preds)
+        cumcorrs.append(corrected_count)
+        X_covered = X_covered[~preds.astype(bool)]  # type: ignore
+
+    cumcorrs = np.array(cumcorrs).cumsum() / covered_count
+    updated_thens = [
+        (thenclause, cumcor, float(cost))
+        for (thenclause, _cor, cost), cumcor in zip(thens_sorted_by_cost, cumcorrs)
+    ]
+
+    return updated_thens
+
+
+def cum_corr_costs_all(
+    rulesbyif: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]],
+    X: DataFrame,
+    model,
+    sensitive_attribute: str,
+    params: ParameterProxy = ParameterProxy(),
+    verbose: bool = True,
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Calculate cumulative correctness and costs for all if-then rules.
+
+    Args:
+        rulesbyif: A dictionary containing if-clause predicates as keys and a nested
+            dictionary as values. The nested dictionary contains subgroup names as
+            keys, and tuples of coverage and a list of then-clause predicates with
+            their corresponding correctness values as values.
+        X: The DataFrame containing the data.
+        model: The model API used for prediction.
+        sensitive_attribute: The name of the sensitive attribute in the data.
+        params: Optional parameter proxy (default: ParameterProxy()).
+        verbose: whether to print intermediate messages and progress bar. Defaults to True.
+
+    Returns:
+        A dictionary with if-clause predicates as keys. Each if-clause predicate
+        maps to a nested dictionary where subgroup names are the keys, and tuples
+        of coverage and a list of updated then-clause predicates with their
+        cumulative correctness values and costs are the values.
+
+    """
+    X_affected: DataFrame = X[model.predict(X) == 0]  # type: ignore
+    ret: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ] = {}
+    rulesbyif_iter = tqdm(rulesbyif.items()) if verbose else rulesbyif.items()
+    for ifclause, all_thens in rulesbyif_iter:
+        all_thens_new: Dict[
+            str, Tuple[float, List[Tuple[Predicate, float, float]]]
+        ] = {}
+        for sg, (cov, thens) in all_thens.items():
+            subgroup_affected = X_affected[X_affected[sensitive_attribute] == sg]
+            all_thens_new[sg] = (
+                cov,
+                cum_corr_costs(
+                    ifclause, thens, subgroup_affected, model, params=params
+                ),
+            )
+        ret[ifclause] = all_thens_new
+    return ret
+
+def cum_corr_costs_all_minimal(
+    rulesbyif: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]],
+    X: DataFrame,
+    model,
+    sensitive_attribute: str,
+    params: ParameterProxy = ParameterProxy(),
+) -> Dict[Predicate, Dict[str, List[Tuple[float, float]]]]:
+    """Compute cumulative correctness and cost for all rules.
+
+    Args:
+        rulesbyif: A dictionary containing if-clause predicates as keys and a nested
+            dictionary as values. The nested dictionary contains subgroup names as
+            keys, and tuples of coverage and a list of then-clause predicates with
+            their corresponding correctness as values.
+        X: The input DataFrame.
+        model: The model API.
+        sensitive_attribute: The name of the sensitive attribute in the DataFrame.
+        params: Optional parameter proxy (default: ParameterProxy()).
+
+    Returns:
+        A dictionary containing if-clause predicates as keys and a nested dictionary as
+        values. The nested dictionary contains subgroup names as keys, and a list of
+        tuples representing the cumulative correctness and cost of the then-clauses.
+    """
+    full_rules = cum_corr_costs_all(rulesbyif, X, model, sensitive_attribute, params)
+    ret: Dict[Predicate, Dict[str, List[Tuple[float, float]]]] = {}
+    for ifclause, all_thens in full_rules.items():
+        ret[ifclause] = {}
+        for sg, (cov, thens) in all_thens.items():
+            thens_plain = [(corr, cost) for _then, corr, cost in thens]
+            ret[ifclause][sg] = thens_plain
+    return ret
diff --git a/aif360/sklearn/detectors/facts/optimization.py b/aif360/sklearn/detectors/facts/optimization.py
new file mode 100644
index 00000000..de4fb273
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/optimization.py
@@ -0,0 +1,142 @@
+import functools
+
+from typing import List, Tuple, Dict
+
+import numpy as np
+
+from .predicate import Predicate
+from .metrics import (
+    max_intergroup_cost_diff,
+    calculate_all_if_subgroup_costs,
+)
+
+##### Rankings of if-groups
+
+def sort_triples_by_max_costdiff(
+    rulesbyif: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]],
+    ignore_nans: bool = False,
+    ignore_infs: bool = False,
+    secondary_objectives: List[str] = [],
+    **kwargs,
+) -> List[Tuple[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]]:
+    """Sorts the triples by maximum cost difference with generic options to handle NaN, infinity, and secondary objectives.
+
+    Args:
+        rulesbyif (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost and predicate pairs.
+        ignore_nans (bool, optional):
+            Flag indicating whether to ignore NaN values in the cost difference. Defaults to False.
+        ignore_infs (bool, optional):
+            Flag indicating whether to ignore infinity values in the cost difference. Defaults to False.
+        secondary_objectives (List[str], optional):
+            List of secondary objectives to include in the sorting criteria. Defaults to an empty list.
+
+    Returns:
+        List[Tuple[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]]:
+            Sorted list of triples with the associated maximum cost difference.
+    """
+    subgroup_costs = calculate_all_if_subgroup_costs(
+        list(rulesbyif.keys()), list(rulesbyif.values()), **kwargs
+    )
+
+    max_intergroup_cost_diffs = {
+        ifclause: max(subgroup_costs[ifclause].values())
+        - min(subgroup_costs[ifclause].values())
+        for ifclause, _ in rulesbyif.items()
+    }
+    min_group_costs = {
+        ifclause: min(subgroup_costs[ifclause].values())
+        for ifclause, _ in rulesbyif.items()
+    }
+    max_group_correctness = {
+        ifclause: max(
+            cor for _sg, (_cov, thens) in thenclauses.items() for _then, cor, _cost in thens
+        )
+        for ifclause, thenclauses in rulesbyif.items()
+    }
+
+    def objective_fn(ifthens, ignore_nan, ignore_inf, return_indicator):
+        ifclause = ifthens[0]
+        max_costdiff = max_intergroup_cost_diffs[ifclause]
+        if ignore_nan and np.isnan(max_costdiff):
+            max_costdiff = -np.inf
+        if ignore_inf and np.isinf(max_costdiff):
+            max_costdiff = -np.inf
+
+        optional_rets = {
+            "min-group-cost": -min_group_costs[ifclause],
+            "max-group-corr": max_group_correctness[ifclause],
+        }
+        ret = (max_costdiff,)
+        for i in return_indicator:
+            ret = ret + (optional_rets[i],)
+        return ret
+
+    return sorted(
+        rulesbyif.items(),
+        key=functools.partial(
+            objective_fn,
+            ignore_nan=ignore_nans,
+            ignore_inf=ignore_infs,
+            return_indicator=secondary_objectives,
+        ),
+        reverse=True,
+    )
+
+def sort_triples_KStest(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    affected_population_sizes: Dict[str, int],
+) -> Tuple[
+    List[
+        Tuple[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]
+    ],
+    Dict[Predicate, float],
+]:
+    """Sorts the triples using the Kolmogorov-Smirnov test to measure unfairness.
+
+    Args:
+        rulesbyif (Dict[ Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]] ]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost, correctness, and predicate tuples.
+        affected_population_sizes (Dict[str, int]):
+            Dictionary mapping group IDs to their respective affected population sizes.
+
+    Returns:
+        Tuple[ List[ Tuple[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]] ], Dict[Predicate, float], ]:
+            A tuple containing a sorted list of triples and a dictionary mapping predicates to their unfairness scores.
+    """
+
+    def calculate_test(
+        thenclauses: Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ):
+        if len(thenclauses) != 2:
+            raise NotImplementedError("Definition only for two protected subgroups")
+
+        sgs = list(thenclauses.keys())
+        sg1 = sgs[0]
+        sg2 = sgs[1]
+        corrs1 = np.array([corr for then, corr, cost in thenclauses[sg1][1]])
+        corrs2 = np.array([corr for then, corr, cost in thenclauses[sg2][1]])
+        term1: float = abs(corrs1 - corrs2).max()
+
+        cov1 = thenclauses[sg1][0]
+        cov2 = thenclauses[sg2][0]
+        affected_sg1 = cov1 * affected_population_sizes[sg1]
+        affected_sg2 = cov2 * affected_population_sizes[sg2]
+        term2: float = np.sqrt(
+            (affected_sg1 * affected_sg2) / (affected_sg1 + affected_sg2)
+        )
+
+        return term1 * term2
+
+    unfairness: Dict[Predicate, float] = {}
+    for ifclause, thenclauses in rulesbyif.items():
+        unfairness[ifclause] = calculate_test(thenclauses)
+
+    return (
+        sorted(
+            rulesbyif.items(), key=lambda ifthens: unfairness[ifthens[0]], reverse=True
+        ),
+        unfairness,
+    )
diff --git a/aif360/sklearn/detectors/facts/parameters.py b/aif360/sklearn/detectors/facts/parameters.py
new file mode 100644
index 00000000..ed420f82
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/parameters.py
@@ -0,0 +1,189 @@
+from typing import Dict, Callable, Any, List, Optional
+import functools
+from collections import defaultdict
+from dataclasses import dataclass, field
+
+from pandas import DataFrame
+
+
+def default_change(v1, v2) -> float:
+    """Compares two values and returns 0 if they are equal, and 1 if they are different.
+
+    Args:
+        v1: The first value to be compared.
+        v2: The second value to be compared.
+
+    Returns:
+        int: 0 if the values are equal, 1 if the values are diff
+    """
+
+    return 0 if v1 == v2 else 1
+
+
+def make_default_featureChanges():
+    """Creates a defaultdict with a default value of the default_change function.
+
+    Returns:
+        defaultdict: A defaultdict with default_change as the default value.
+    """
+    return defaultdict(lambda: default_change)
+
+
+def naive_feature_change_builder(
+    num_cols: List[str],
+    cate_cols: List[str],
+    feature_weights: Dict[str, int],
+) -> Dict[str, Callable[[Any, Any], int]]:
+    """Builds a dictionary of feature change functions based on the provided lists of numerical
+    and categorical columns, along with the weights for each feature.
+
+    Args:
+        num_cols (List[str]): List of numerical column names.
+        cate_cols (List[str]): List of categorical column names.
+        feature_weights (Dict[str, int]): Dictionary mapping feature names to their weights.
+
+    Returns:
+        Dict[str, Callable[[Any, Any], int]]: Dictionary of feature change functions.
+    """
+
+    def feature_change_cate(v1, v2, weight):
+        """Calculates the change between two categorical values based on the provided weight.
+
+        Args:
+            v1: The first categorical value.
+            v2: The second categorical value.
+            weight: The weight assigned to the feature.
+
+        Returns:
+            int: The change between the categorical values multiplied by the weight.
+                Returns 0 if the values are equal, otherwise returns 1 multiplied by the weight.
+        """
+        return (0 if v1 == v2 else 1) * weight
+
+    def feature_change_num(v1, v2, weight):
+        """Calculates the change between two numerical values based on the provided weight.
+
+        Args:
+            v1: The first numerical value.
+            v2: The second numerical value.
+            weight: The weight assigned to the feature.
+
+        Returns:
+            int: The absolute difference between the numerical values multiplied by the weight.
+        """
+        return abs(v1 - v2) * weight
+
+    ret_cate = {
+        col: functools.partial(feature_change_cate, weight=feature_weights.get(col, 1))
+        for col in cate_cols
+    }
+    ret_num = {
+        col: functools.partial(feature_change_num, weight=feature_weights.get(col, 1))
+        for col in num_cols
+    }
+    return {**ret_cate, **ret_num}
+
+
+def feature_change_builder(
+    X: Optional[DataFrame],
+    num_cols: List[str],
+    cate_cols: List[str],
+    ord_cols: List[str],
+    feature_weights: Dict[str, int],
+    num_normalization: bool = False,
+    feats_to_normalize: Optional[List[str]] = None,
+) -> Dict[str, Callable[[Any, Any], float]]:
+    """Constructs a dictionary of feature change functions based on the input parameters.
+
+    Args:
+        X (DataFrame): The input DataFrame containing the data.
+        num_cols (List[str]): A list of column names representing the numeric features.
+        cate_cols (List[str]): A list of column names representing the categorical features.
+        ord_cols (List[str]): _description_
+        feature_weights (Dict[str, int]): A dictionary mapping feature names to their corresponding weights.
+        num_normalization (bool, optional): A flag indicating whether to normalize numeric features. Default is False.
+        feats_to_normalize (Optional[List[str]], optional):
+            A list of column names specifying the numeric features to be normalized.
+            If None, all numeric features will be normalized. Default is None.
+
+    Returns:
+        Dict[str, Callable[[Any, Any], int]]:
+            A dictionary mapping feature names to the corresponding
+            feature change functions.
+    """
+
+    def feature_change_cate(v1, v2, weight):
+        return (0 if v1 == v2 else 1) * weight
+
+    def feature_change_num(v1, v2, weight):
+        return abs(v1 - v2) * weight
+
+    def feature_change_ord(v1, v2, weight, t):
+        return abs(t[v1] - t[v2]) * weight
+
+    ### normalization of numeric features
+    if num_normalization:
+        if X is None:
+            raise ValueError("Cannot perform numeric normalization without a dataset!")
+        
+        max_vals = X.max(axis=0)
+        min_vals = X.min(axis=0)
+        weight_multipliers = {}
+        for col in num_cols:
+            weight_multipliers[col] = 1
+        for col in cate_cols:
+            weight_multipliers[col] = 1
+
+        if feats_to_normalize is not None:
+            for col in feats_to_normalize:
+                weight_multipliers[col] = 1 / (max_vals[col] - min_vals[col])
+        else:
+            for col in num_cols:
+                weight_multipliers[col] = 1 / (max_vals[col] - min_vals[col])
+    else:
+        weight_multipliers = defaultdict(lambda : 1)
+
+    ret_cate = {
+        col: functools.partial(feature_change_cate, weight=feature_weights.get(col, 1))
+        for col in cate_cols
+    }
+    ret_num = {
+        col: functools.partial(
+            feature_change_num,
+            weight=weight_multipliers[col] * feature_weights.get(col, 1),
+        )
+        for col in num_cols
+    }
+
+    if ord_cols != []:
+        if X is None:
+            raise ValueError("Cannot handle ordinal columns without a dataframe!")
+        ret_ord = {
+            col: functools.partial(
+                feature_change_ord,
+                weight=feature_weights.get(col, 1),
+                t={name: code for code, name in enumerate(X[col].cat.categories)},
+            )
+            for col in ord_cols
+        }
+        
+        return {**ret_cate, **ret_num, **ret_ord}
+    
+    return {**ret_cate, **ret_num}
+
+
+@dataclass
+class ParameterProxy:
+    """Proxy class for managing recourse parameters."""
+
+    featureChanges: Dict[str, Callable[[Any, Any], float]] = field(
+        default_factory=make_default_featureChanges
+    )
+
+    def setFeatureChange(self, fc: Dict):
+        """Set the feature changes.
+
+        Args:
+            fc (Dict): A dictionary mapping feature names to their change functions.
+        """
+        self.featureChanges.update(fc)
diff --git a/aif360/sklearn/detectors/facts/predicate.py b/aif360/sklearn/detectors/facts/predicate.py
new file mode 100644
index 00000000..abb34fa7
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/predicate.py
@@ -0,0 +1,256 @@
+from dataclasses import dataclass, field
+from typing import List, Any, Dict, Mapping
+import operator
+import functools
+
+import pandas as pd
+from pandas import DataFrame
+from .parameters import ParameterProxy
+
+
+@functools.total_ordering
+@dataclass
+class Predicate:
+    """Represents a predicate with features and values."""
+
+    features: List[str] = field(default_factory=list)
+    values: List[Any] = field(default_factory=list)
+
+    def __eq__(self, __o: object) -> bool:
+        """
+        Checks if the predicate is equal to another predicate.
+        """
+        if not isinstance(__o, Predicate):
+            return False
+
+        d1 = self.to_dict()
+        d2 = __o.to_dict()
+        return d1 == d2
+
+    def __lt__(self, __o: object) -> bool:
+        """
+        Compares the predicate with another predicate based on their representations.
+        """
+        if not isinstance(__o, Predicate):
+            raise TypeError(f"Comparison not supported between instances of '{type(self)}' and '{type(__o)}'")
+        return repr(self) < repr(__o)
+
+    def __hash__(self) -> int:
+        """
+        Returns the hash value of the predicate.
+        """
+        return hash(repr(self))
+
+    def __str__(self) -> str:
+        """
+        Returns the string representation of the predicate.
+        """
+        ret = []
+        first_iter = True
+        for f, v in zip(self.features, self.values):
+            if first_iter:
+                first_iter = False
+            else:
+                ret.append(", ")
+
+            ret.append(f"{f} = {v}")
+        return "".join(ret)
+
+    def __post_init__(self):
+        """
+        Initializes the predicate after the data class is created.
+        """
+        pairs = sorted(zip(self.features, self.values))
+        self.features = [f for f, _v in pairs]
+        self.values = [v for _f, v in pairs]
+
+    @staticmethod
+    def from_dict(d: Dict[str, str]) -> "Predicate":
+        """
+        Creates a Predicate instance from a dictionary.
+
+        Args:
+            d: A dictionary representing the predicate.
+
+        Returns:
+            A Predicate instance.
+        """
+        feats = list(d.keys())
+        vals = list(d.values())
+        return Predicate(features=feats, values=vals)
+
+    def to_dict(self) -> Dict[str, str]:
+        """
+        Converts the predicate to a dictionary representation.
+
+        Returns:
+            A dictionary representing the predicate.
+        """
+        return dict(zip(self.features, self.values))
+
+    def satisfies(self, x: Mapping[str, Any]) -> bool:
+        """
+        Checks if the predicate is satisfied by a given input.
+
+        Args:
+            x: The input to be checked against the predicate.
+
+        Returns:
+            True if the predicate is satisfied, False otherwise.
+        """
+        return all(
+            x[feat] == val
+            for feat, val in zip(self.features, self.values)
+        )
+    
+    def satisfies_v(self, X: DataFrame) -> pd.Series:
+        """Vectorized version of the `satisfies` method.
+
+        Args:
+            X (DataFrame): a dataframe of instances (rows)
+
+        Returns:
+            pd.Series: boolean Series with value `True` if an instance satisfies the predicate and `False` otherwise
+        """
+        X_covered_bool = (X[self.features] == self.values).all(axis=1)
+
+        return X_covered_bool
+
+    def width(self):
+        """
+        Returns the number of features in the predicate.
+        """
+        return len(self.features)
+
+    def contains(self, other: object) -> bool:
+        """
+        Checks if the predicate contains another predicate.
+
+        Args:
+            other: The predicate to check for containment.
+
+        Returns:
+            True if the predicate contains the other predicate, False otherwise.
+        """
+        if not isinstance(other, Predicate):
+            return False
+
+        d1 = self.to_dict()
+        d2 = other.to_dict()
+        return all(feat in d1 and d1[feat] == val for feat, val in d2.items())
+
+def featureChangePred(
+    p1: Predicate, p2: Predicate, params: ParameterProxy = ParameterProxy()
+):
+    """
+    Calculates the feature change between two predicates.
+
+    Args:
+        p1: The first Predicate.
+        p2: The second Predicate.
+        params: The ParameterProxy object containing feature change functions.
+
+    Returns:
+        The feature change between the two predicates.
+    """
+    total = 0
+    for i, f in enumerate(p1.features):
+        val1 = p1.values[i]
+        val2 = p2.values[i]
+        costChange = params.featureChanges[f](val1, val2)
+        total += costChange
+    return total
+
+def recIsValid(
+    p1: Predicate, p2: Predicate, X: DataFrame, drop_infeasible: bool, feats_not_allowed_to_change: List[str] = []
+) -> bool:
+    """
+    Checks if the given pair of predicates is valid based on the provided conditions.
+
+    Args:
+        p1: The first Predicate.
+        p2: The second Predicate.
+        X: The DataFrame containing the data.
+        drop_infeasible: Flag indicating whether to drop infeasible cases.
+
+    Returns:
+        True if the pair of predicates is valid, False otherwise.
+    """
+    n1 = len(p1.features)
+    n2 = len(p2.features)
+    if n1 != n2:
+        return False
+
+    featuresMatch = all(map(operator.eq, p1.features, p2.features))
+    existsChange = any(map(operator.ne, p1.values, p2.values))
+
+    if not (featuresMatch and existsChange):
+        return False
+
+    if n1 == len(X.columns) and all(map(operator.ne, p1.values, p2.values)):
+        return False
+    
+    p1_d = p1.to_dict()
+    p2_d = p2.to_dict()
+    if any(f in feats_not_allowed_to_change and p1_d[f] != p2_d[f] for f in p1.features):
+        return False
+
+    if drop_infeasible == True:
+        feat_change = True
+        for count, feat in enumerate(p1.features):
+            if p1.values[count] != "Unknown" and p2.values[count] == "Unknown":
+                return False
+            if feat == "parents":
+                parents_change = p1.values[count] <= p2.values[count]
+                feat_change = feat_change and parents_change
+            if feat == "age":
+                age_change = p1.values[count].left <= p2.values[count].left
+                feat_change = feat_change and age_change
+            elif feat == "ages":
+                age_change = p1.values[count] <= p2.values[count]
+                feat_change = feat_change and age_change
+            elif feat == "education-num":
+                edu_change = p1.values[count] <= p2.values[count]
+                feat_change = feat_change and edu_change
+            elif feat == "PREDICTOR RAT AGE AT LATEST ARREST":
+                age_change = p1.values[count] <= p2.values[count]
+                feat_change = feat_change and age_change
+            elif feat == "age_cat":
+                age_change = p1.values[count] <= p2.values[count]
+                feat_change = feat_change and age_change
+            elif feat == "sex":
+                race_change = p1.values[count] == p2.values[count]
+                feat_change = feat_change and race_change
+        return feat_change
+    
+    return True
+
+
+def drop_two_above(p1: Predicate, p2: Predicate, l: list) -> bool:
+    """
+    Checks if the values of the given predicates are within a difference of two based on the provided conditions.
+
+    Args:
+        p1: The first Predicate.
+        p2: The second Predicate.
+        l: The list of values for comparison.
+
+    Returns:
+        True if the values are within a difference of two, False otherwise.
+    """
+    feat_change = True
+
+    for count, feat in enumerate(p1.features):
+        if feat == "education-num":
+            edu_change = p2.values[count] - p1.values[count] <= 2
+            feat_change = feat_change and edu_change
+        elif feat == "age":
+            age_change = (
+                l.index(p2.values[count].left) - l.index(p1.values[count].left) <= 2
+            )
+            feat_change = feat_change and age_change
+        elif feat == "PREDICTOR RAT AGE AT LATEST ARREST":
+            age_change = l.index(p2.values[count]) - l.index(p1.values[count]) <= 2
+            feat_change = feat_change and age_change
+
+    return feat_change
diff --git a/aif360/sklearn/detectors/facts/rule_filters.py b/aif360/sklearn/detectors/facts/rule_filters.py
new file mode 100644
index 00000000..2dcfdf7c
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/rule_filters.py
@@ -0,0 +1,252 @@
+from typing import Dict, Tuple, List
+from pandas import Series
+import numpy as np
+
+from .predicate import Predicate, featureChangePred
+
+
+def remove_rules_below_correctness_threshold(
+    rulesbyif: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]],
+    threshold: float = 0.5,
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Filters the rules by correctness threshold.
+
+    Args:
+        rulesbyif (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost and correctness tuples.
+        threshold (float, optional):
+            The threshold value for filtering the rules based on correctness. Defaults to 0.5.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+            Filtered rules based on the correctness threshold.
+    """
+    ret = dict()
+    for ifclause, thenclauses in rulesbyif.items():
+        filtered_thenclauses = dict()
+        for sg, (cov, sg_thens) in thenclauses.items():
+            filtered_thens = [(then, cor, cost) for then, cor, cost in sg_thens if cor >= threshold]
+            filtered_thenclauses[sg] = (cov, filtered_thens)
+        ret[ifclause] = filtered_thenclauses
+    return ret
+
+def keep_rules_until_correctness_threshold_reached(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    threshold: float = 0.5,
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Filters rules based on cumulative correctness threshold.
+
+    Args:
+        rulesbyif (Dict[ Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]] ]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost, correctness, and cumulative cost tuples.
+        threshold (float, optional):
+            Threshold value for the cumulative correctness. Rules with a cumulative correctness value greater than the threshold are kept. Defaults to 0.5.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+            Dictionary containing the filtered rules based on the cumulative correctness threshold.
+    """
+    ret: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ] = dict()
+    for ifclause, thenclauses in rulesbyif.items():
+        filtered_thenclauses: Dict[
+            str, Tuple[float, List[Tuple[Predicate, float, float]]]
+        ] = dict()
+        for sg, (cov, sg_thens) in thenclauses.items():
+            filtered_thens: List[Tuple[Predicate, float, float]] = []
+            for then, cor, cost in sg_thens:
+                filtered_thens.append((then, cor, cost))
+                if cor >= threshold:
+                    break
+            filtered_thenclauses[sg] = (cov, filtered_thens)
+        ret[ifclause] = filtered_thenclauses
+    return ret
+
+def remove_rules_above_cost_budget(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    threshold: float = 0.5,
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Filters rules based on cumulative cost threshold.
+
+    Args:
+        rulesbyif (Dict[ Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]] ]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost, correctness, and cumulative cost tuples.
+        threshold (float, optional):
+            Threshold value for the cumulative cost. Rules with a cumulative cost value less than or equal to the threshold are kept.
+            Defaults to 0.5.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+            Dictionary containing the filtered rules based on the cumulative cost threshold.
+    """
+    ret: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]] = dict()
+    for ifclause, thenclauses in rulesbyif.items():
+        filtered_thenclauses: Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]] = dict()
+        for sg, (cov, sg_thens) in thenclauses.items():
+            filtered_thens = [
+                (then, cor, cost) for then, cor, cost in sg_thens if cost <= threshold
+            ]
+            filtered_thenclauses[sg] = (cov, filtered_thens)
+        ret[ifclause] = filtered_thenclauses
+    return ret
+
+def filter_contained_rules_simple(
+    rulesbyif: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Filters the rules to remove the contained rules based on simple containment criteria.
+
+    Args:
+        rulesbyif (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost and correctness tuples.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+            Filtered rules after removing the contained rules based on simple containment criteria.
+    """
+    ret: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]] = dict()
+    for ifclause, thenclauses in rulesbyif.items():
+        flag_keep = True
+        allthens = [
+            then
+            for _sg, (_cov, sg_thens) in thenclauses.items()
+            for then, _cor, _cost in sg_thens
+        ]
+        for otherif, _ in rulesbyif.items():
+            if not ifclause.contains(otherif):
+                continue
+            extra_features = list(set(ifclause.features) - set(otherif.features))
+            if len(extra_features) == 0:
+                continue
+            if_and_allthens_relevant_values = [
+                tuple(then.to_dict()[feat] for feat in extra_features)
+                for then in allthens
+            ]
+            if_and_allthens_relevant_values.append(
+                tuple(ifclause.to_dict()[feat] for feat in extra_features)
+            )
+            if Series(if_and_allthens_relevant_values).unique().size == 1:
+                flag_keep = False
+                break
+
+        if flag_keep:
+            ret[ifclause] = thenclauses
+
+    return ret
+
+
+# TODO: implementation is slightly incorrect. Should create partition of ifs where each if has a "subsumes" relationship with at least another.
+# essentially, if "subsumes" is a graph, it is transient, and we want weakly connected components
+def filter_contained_rules_keep_max_bias(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    subgroup_costs: Dict[Predicate, Dict[str, float]],
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    ret = dict()
+    cost_values = {
+        subgroup: costs.values() for subgroup, costs in subgroup_costs.items()
+    }
+    bias_measures = {
+        subgroup: max(costs) - min(costs) for subgroup, costs in cost_values.items()
+    }
+    flags_keep = {subgroup: True for subgroup, _thens in rulesbyif.items()}
+    for ifclause, thenclauses in rulesbyif.items():
+        allthens = [
+            then
+            for _sg, (_cov, sg_thens) in thenclauses.items()
+            for then, _cor, _cost in sg_thens
+        ]
+        for otherif, _ in rulesbyif.items():
+            if not ifclause.contains(otherif):
+                continue
+            extra_features = list(set(ifclause.features) - set(otherif.features))
+            if len(extra_features) == 0:
+                continue
+            if_and_allthens_relevant_values = [
+                tuple(then.to_dict()[feat] for feat in extra_features)
+                for then in allthens
+            ]
+            if_and_allthens_relevant_values.append(
+                tuple(ifclause.to_dict()[feat] for feat in extra_features)
+            )
+            if Series(if_and_allthens_relevant_values).unique().size == 1:
+                if bias_measures[ifclause] > bias_measures[otherif]:
+                    flags_keep[otherif] = False
+                else:
+                    flags_keep[ifclause] = False
+
+    for ifclause, thenclauses in rulesbyif.items():
+        if flags_keep[ifclause]:
+            ret[ifclause] = thenclauses
+    return ret
+
+
+def delete_fair_rules(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+    subgroup_costs: Dict[Predicate, Dict[str, float]],
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Deletes fair rules from the given set of rules based on subgroup costs.
+
+    Args:
+        rulesbyif (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost and correctness tuples.
+        subgroup_costs (Dict[Predicate, Dict[str, float]]):
+            Dictionary mapping predicates to a dictionary of group IDs and their corresponding subgroup costs.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+            Dictionary containing the remaining rules after deleting the fair rules.
+    """
+    ret: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ] = dict()
+    for ifclause, thenclauses in rulesbyif.items():
+        curr_subgroup_costs = list(subgroup_costs[ifclause].values())
+        max_intergroup_cost_diff = max(curr_subgroup_costs) - min(curr_subgroup_costs)
+        if max_intergroup_cost_diff == 0 or np.isnan(max_intergroup_cost_diff):
+            continue
+        ret[ifclause] = thenclauses
+    return ret
+
+
+def keep_only_minimum_change(
+    rulesbyif: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ],
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]]:
+    """Filters rules based on minimum change.
+
+    Args:
+        rulesbyif (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            Dictionary mapping predicates to a dictionary of group IDs and associated cost and correctness tuples.
+        params (ParameterProxy, optional):
+            Parameter proxy object containing parameter values for calculating the feature change. Defaults to ParameterProxy().
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+            Dictionary containing the filtered rules based on the minimum change criterion.
+    """
+    ret: Dict[
+        Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float, float]]]]
+    ] = dict()
+    for ifclause, thenclauses in rulesbyif.items():
+        ret[ifclause] = dict()
+        for sg, (cov, thens) in thenclauses.items():
+            min_change = min(
+                (cost for _then, _cor, cost in thens),
+                default=np.inf,
+            )
+            newthens = [
+                (then, cor, cost)
+                for then, cor, cost in thens
+                if cost <= min_change
+            ]
+            ret[ifclause][sg] = (cov, newthens)
+    return ret
diff --git a/aif360/sklearn/detectors/facts/utils.py b/aif360/sklearn/detectors/facts/utils.py
new file mode 100644
index 00000000..1a84e756
--- /dev/null
+++ b/aif360/sklearn/detectors/facts/utils.py
@@ -0,0 +1,195 @@
+from typing import Dict, List, Tuple, Any
+import pickle
+from pathlib import Path
+from os import PathLike
+from pandas import DataFrame
+
+from .predicate import Predicate
+
+
+def load_object(file: PathLike) -> object:
+    """Loads and returns an object from the specified file using the pickle
+        library.
+
+    Args:
+        file (PathLike): The path to the file containing the object.
+
+    Returns:
+        object: The loaded object.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("rb") as inf:
+        ret = pickle.load(inf)
+    return ret
+
+
+def save_object(file: PathLike, o: object) -> None:
+    """Saves the provided object to the specified file using the pickle library.
+
+    Args:
+        file (PathLike): The path to the file where the object will be saved.
+        o (object): The object to be saved.
+
+    Returns:
+        None
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("wb") as outf:
+        pickle.dump(o, outf)
+
+
+def load_rules_by_if(
+    file: PathLike,
+) -> Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+    """Loads and returns a dictionary of rules.
+
+    Args:
+        file (PathLike): The path to the file containing the rules.
+
+    Returns:
+        Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]:
+            The dictionary of rules organized by the antecedent Predicate.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("rb") as inf:
+        rules_by_if = pickle.load(inf)
+    return rules_by_if
+
+
+def save_rules_by_if(
+    file: PathLike,
+    rules: Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]],
+) -> None:
+    """Saves the provided rules dictionary to the specified file using the
+        pickle library.
+
+    Args:
+        file (PathLike): The path to the file where the rules will be saved.
+        rules (Dict[Predicate, Dict[str, Tuple[float, List[Tuple[Predicate, float]]]]]):
+            The dictionary of rules
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("wb") as outf:
+        pickle.dump(rules, outf)
+
+
+def load_test_data_used(file: PathLike) -> DataFrame:
+    """Loads and returns the test data used from the specified file using the
+        pickle library.
+
+    Args:
+        file (PathLike): The path to the file containing the test data.
+
+    Returns:
+        DataFrame: The loaded test data.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("rb") as inf:
+        X_test = pickle.load(inf)
+    return X_test
+
+
+def save_test_data_used(file: PathLike, X: DataFrame) -> None:
+    """Saves the provided test data to the specified file using the pickle
+        library.
+
+    Args:
+        file (PathLike): The path to the file where the test data will
+            be saved.
+        X (DataFrame): The test data to be saved.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("wb") as outf:
+        pickle.dump(X, outf)
+
+
+def load_model(file: PathLike):
+    """Loads and returns a trained model from the specified file using
+        the pickle library.
+
+    Args:
+        file (PathLike): The path to the file containing the model.
+
+    Returns:
+        ModelAPI: The loaded trained model.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("rb") as inf:
+        model = pickle.load(inf)
+    return model
+
+
+def save_model(file: PathLike, model) -> None:
+    """Saves the provided model to the specified file using the pickle
+        library.
+
+    Args:
+        file (PathLike): The path to the file where the model will be saved.
+        model (ModelAPI): The model to be saved.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("wb") as outf:
+        pickle.dump(model, outf)
+
+
+def load_state(file: PathLike) -> Tuple[Dict, DataFrame, Any]:
+    """Loads and returns the rules, Dataframe, and a model from the specified
+        file using the pickle library.
+
+    Args:
+        file (PathLike):  The path to the file containing the state.
+
+    Returns:
+        Tuple[Dict, DataFrame, ModelAPI]: A tuple containing the loaded rules,
+            DataFrame, and model.
+
+    Raises:
+        Nones
+    """
+    p = Path(file)
+    with p.open("rb") as inf:
+        (rules, X, model) = pickle.load(inf)
+    return (rules, X, model)
+
+
+def save_state(file: PathLike, rules: Dict, X: DataFrame, model) -> None:
+    """Saves the rules, dataframe, model to the specified file using the pickle
+        library.
+
+    Args:
+        file (PathLike): The path to the file where the data will be saved.
+        rules (Dict): The rules dictionary to be saved.
+        X (DataFrame): The DataFrame to be saved.
+        model (ModelAPI): The model to be saved.
+
+    Raises:
+        None
+    """
+    p = Path(file)
+    with p.open("wb") as outf:
+        pickle.dump((rules, X, model), outf)
+
diff --git a/docs/source/modules/sklearn.rst b/docs/source/modules/sklearn.rst
index f92127d7..92913472 100644
--- a/docs/source/modules/sklearn.rst
+++ b/docs/source/modules/sklearn.rst
@@ -153,6 +153,19 @@ Bias scan
 
    detectors.bias_scan
 
+Fairness Aware Subgroup Counterfactuals
+---------------------------------------
+.. currentmodule:: aif360.sklearn
+
+.. autosummary::
+   :toctree: generated/
+   :template: class.rst
+
+   detectors.FACTS
+
+   :template: base.rst
+   detectors.FACTS_bias_scan
+
 :mod:`aif360.sklearn.preprocessing`: Pre-processing algorithms
 ==============================================================
 
diff --git a/examples/demo_FACTS.ipynb b/examples/demo_FACTS.ipynb
new file mode 100644
index 00000000..a99d26ef
--- /dev/null
+++ b/examples/demo_FACTS.ipynb
@@ -0,0 +1,775 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Fairness auditing for subgroups using Fairness Aware Counterfactuals for Subgroups (FACTS).\n",
+    "\n",
+    "[FACTS](https://arxiv.org/abs/2306.14978) is an efficient, model-agnostic, highly parameterizable, and explainable framework for auditing subgroup fairness through counterfactual explanations. FACTS focuses on identifying a specific type of bias, i.e. the *difficulty in achieving recourse*. In short, it focuses on the population that has obtained the unfavorable outcome (*affected population*) by a ML model and tries to identify differences in the difficulty of changing the ML model's decision to obtain the favorable outcome, between affected subpopulations.\n",
+    "\n",
+    "In this notebook, we will see how to use this algorithm for discovering subgroups where the bias of a model (logistic regression for simplicity) between Males and Females is high.\n",
+    "\n",
+    "We will use the Adult dataset from UCI ([reference](https://archive.ics.uci.edu/ml/datasets/adult))."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Preliminaries"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Import dependencies\n",
+    "\n",
+    "As usual in python, the first step is to import all necessary packages."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "WARNING:root:No module named 'tempeh': fetch_lawschool_gpa will be unavailable. To install, run:\n",
+      "pip install 'aif360[LawSchoolGPA]'\n"
+     ]
+    }
+   ],
+   "source": [
+    "from sklearn.model_selection import train_test_split\n",
+    "from sklearn.linear_model import LogisticRegression\n",
+    "from sklearn.compose import ColumnTransformer\n",
+    "from sklearn.pipeline import Pipeline\n",
+    "from sklearn.preprocessing import OneHotEncoder\n",
+    "\n",
+    "from aif360.sklearn.datasets.openml_datasets import fetch_adult\n",
+    "from aif360.sklearn.detectors.facts.clean import clean_dataset\n",
+    "from aif360.sklearn.detectors.facts import FACTS, FACTS_bias_scan\n",
+    "\n",
+    "from IPython.display import display\n",
+    "\n",
+    "import warnings\n",
+    "warnings.filterwarnings(\"ignore\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Below, you can change the `random_seed` variable to `None` if you would like for the pseudo-random parts to actually change between runs. We have set it to a specific value for reproducibility."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "random_seed = 131313 # for reproducibility"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Load Dataset"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>age</th>\n",
+       "      <th>workclass</th>\n",
+       "      <th>education-num</th>\n",
+       "      <th>marital-status</th>\n",
+       "      <th>occupation</th>\n",
+       "      <th>relationship</th>\n",
+       "      <th>race</th>\n",
+       "      <th>sex</th>\n",
+       "      <th>capital-gain</th>\n",
+       "      <th>capital-loss</th>\n",
+       "      <th>hours-per-week</th>\n",
+       "      <th>native-country</th>\n",
+       "      <th>income</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>(16.999, 26.0]</td>\n",
+       "      <td>Private</td>\n",
+       "      <td>7.0</td>\n",
+       "      <td>Never-married</td>\n",
+       "      <td>Machine-op-inspct</td>\n",
+       "      <td>Own-child</td>\n",
+       "      <td>Black</td>\n",
+       "      <td>Male</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>FullTime</td>\n",
+       "      <td>United-States</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>(34.0, 41.0]</td>\n",
+       "      <td>Private</td>\n",
+       "      <td>9.0</td>\n",
+       "      <td>Married-civ-spouse</td>\n",
+       "      <td>Farming-fishing</td>\n",
+       "      <td>Married</td>\n",
+       "      <td>White</td>\n",
+       "      <td>Male</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>OverTime</td>\n",
+       "      <td>United-States</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>(26.0, 34.0]</td>\n",
+       "      <td>Local-gov</td>\n",
+       "      <td>12.0</td>\n",
+       "      <td>Married-civ-spouse</td>\n",
+       "      <td>Protective-serv</td>\n",
+       "      <td>Married</td>\n",
+       "      <td>White</td>\n",
+       "      <td>Male</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>FullTime</td>\n",
+       "      <td>United-States</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>(41.0, 50.0]</td>\n",
+       "      <td>Private</td>\n",
+       "      <td>10.0</td>\n",
+       "      <td>Married-civ-spouse</td>\n",
+       "      <td>Machine-op-inspct</td>\n",
+       "      <td>Married</td>\n",
+       "      <td>Black</td>\n",
+       "      <td>Male</td>\n",
+       "      <td>7688.0</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>FullTime</td>\n",
+       "      <td>United-States</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>(26.0, 34.0]</td>\n",
+       "      <td>Private</td>\n",
+       "      <td>6.0</td>\n",
+       "      <td>Never-married</td>\n",
+       "      <td>Other-service</td>\n",
+       "      <td>Not-in-family</td>\n",
+       "      <td>White</td>\n",
+       "      <td>Male</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>MidTime</td>\n",
+       "      <td>United-States</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "              age  workclass  education-num      marital-status  \\\n",
+       "0  (16.999, 26.0]    Private            7.0       Never-married   \n",
+       "1    (34.0, 41.0]    Private            9.0  Married-civ-spouse   \n",
+       "2    (26.0, 34.0]  Local-gov           12.0  Married-civ-spouse   \n",
+       "3    (41.0, 50.0]    Private           10.0  Married-civ-spouse   \n",
+       "4    (26.0, 34.0]    Private            6.0       Never-married   \n",
+       "\n",
+       "          occupation   relationship   race   sex  capital-gain  capital-loss  \\\n",
+       "0  Machine-op-inspct      Own-child  Black  Male           0.0           0.0   \n",
+       "1    Farming-fishing        Married  White  Male           0.0           0.0   \n",
+       "2    Protective-serv        Married  White  Male           0.0           0.0   \n",
+       "3  Machine-op-inspct        Married  Black  Male        7688.0           0.0   \n",
+       "4      Other-service  Not-in-family  White  Male           0.0           0.0   \n",
+       "\n",
+       "  hours-per-week native-country  income  \n",
+       "0       FullTime  United-States       0  \n",
+       "1       OverTime  United-States       0  \n",
+       "2       FullTime  United-States       1  \n",
+       "3       FullTime  United-States       1  \n",
+       "4        MidTime  United-States       0  "
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "# load the adult dataset and perform some simple preprocessing steps\n",
+    "# See output for a glimpse of the final dataset's characteristics\n",
+    "X, y, sample_weight = fetch_adult()\n",
+    "data = clean_dataset(X.assign(income=y), \"adult\")\n",
+    "display(data.head())\n",
+    "\n",
+    "# split into train-test data\n",
+    "y = data['income']\n",
+    "X = data.drop('income', axis=1)\n",
+    "X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.7, random_state=random_seed, stratify=y)"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Example Model to be used for Auditing\n",
+    "\n",
+    "We use the train set to train a simple logistic regression model. This will serve as the demonstrative model, which we will then treat as a black box and apply our algorithm.\n",
+    "\n",
+    "Of course, any model can be used in its place. Our purpose here is not to produce a good model, but to audit the fairness of an existing one."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#### here, we incrementally build the example model. It consists of one preprocessing step,\n",
+    "#### which is to turn categorical features into the respective one-hot encodings, and\n",
+    "#### a simple scikit-learn logistic regressor.\n",
+    "categorical_features = X.select_dtypes(include=[\"object\", \"category\"]).columns.to_list()\n",
+    "categorical_features_onehot_transformer = ColumnTransformer(\n",
+    "    transformers=[\n",
+    "        (\"one-hot-encoder\", OneHotEncoder(), categorical_features)\n",
+    "    ],\n",
+    "    remainder=\"passthrough\"\n",
+    ")\n",
+    "model = Pipeline([\n",
+    "    (\"one-hot-encoder\", categorical_features_onehot_transformer),\n",
+    "    (\"clf\", LogisticRegression(max_iter=1500))\n",
+    "])\n",
+    "\n",
+    "#### train the model\n",
+    "model = model.fit(X_train, y_train)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Accuracy = 85.16%\n"
+     ]
+    }
+   ],
+   "source": [
+    "# showcase model's accuracy\n",
+    "y_pred = model.predict(X_test)\n",
+    "print(f\"Accuracy = {(y_test.values == y_pred).sum() / y_test.shape[0]:.2%}\")"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# A Practical Example of FACTS\n",
+    "\n",
+    "The real essence of our work starts here. Specifically, we showcase the generation of candidate subpopulation groups and counterfactuals and the detection of those groups that exhibit the greatest unfairness, with respect to one of several metrics."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Load and Fit FACTS"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# load FACTS framework with:\n",
+    "# - the model to be audited\n",
+    "# - protected attribute \"sex\" and\n",
+    "# - assigning equal, unit weights to all features for cost computation.\n",
+    "# - no features forbidden from changing, i.e. user can specify any features that cannot change at all.\n",
+    "detector = FACTS(\n",
+    "    clf=model,\n",
+    "    prot_attr=\"sex\",\n",
+    "    feature_weights={f: 1 for f in X.columns},\n",
+    "    feats_not_allowed_to_change=[]\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Computing candidate subgroups.\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "100%|██████████████████████████████████████████████████████████████████████████| 1046/1046 [00:00<00:00, 523287.45it/s]"
+     ]
+    },
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Number of subgroups: 563\n",
+      "Computing candidate recourses for all subgroups.\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\n",
+      "100%|█████████████████████████████████████████████████████████████████████████████| 563/563 [00:00<00:00, 50669.32it/s]"
+     ]
+    },
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Computing percentages of individuals flipped by each action independently.\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\n",
+      "100%|████████████████████████████████████████████████████████████████████████████████| 590/590 [00:13<00:00, 43.37it/s]"
+     ]
+    },
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Computing percentages of individuals flipped by any action with cost up to c, for every c\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\n",
+      "100%|████████████████████████████████████████████████████████████████████████████████| 416/416 [00:12<00:00, 32.57it/s]\n"
+     ]
+    }
+   ],
+   "source": [
+    "# generates candidate subpopulation groups for bias and candidate actions\n",
+    "detector = detector.fit(X_test)"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Detect Groups with Unfairness in Protected Subgroups (using \"Equal Choice for Recourse\" metric)\n",
+    "\n",
+    "Here we demonstrate the `bias_scan` method of our detector, which ranks subpopulation groups from most to least unfair, with respect to the chosen metric and, of course, the protected attribute.\n",
+    "\n",
+    "For the purposes of the demo, we use the \"Equal Choice for Recourse\" definition / metric. This posits that the classifier acts fairly for the group in question if the protected subgroups can choose among the same number of sufficiently effective actions to achieve recourse. By sufficiently effective we mean those actions (out of all candidates) which work for at least $100\\phi \\%$ (for some $\\phi \\in [0,1]$) of the subgroup.\n",
+    "\n",
+    "Given this definition, the respective unfairness *metric* is defined to be the difference in the number of sufficiently effective actions between the two protected subgroups.\n",
+    "\n",
+    "**Suggestion**: this metric may find utility in scenarios where the aim is to guarantee that protected subgroups have a similar range of options available to them when it comes to making adjustments in order to attain a favorable outcome. For example, when evaluating job candidates, the employer may wish to ensure that applicants from different backgrounds (that currently fail to meet expectations) have an equal array of career / retraining opportunities that may land them the job, so as to ensure diversity in all sectors of the company, which employ individuals with a plethora of roles."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Detects the top `top_count` most biased groups based on the given metric\n",
+    "# available metrics are:\n",
+    "# - equal-effectiveness\n",
+    "# - equal-choice-for-recourse\n",
+    "# - equal-effectiveness-within-budget\n",
+    "# - equal-cost-of-effectiveness\n",
+    "# - equal-mean-recourse\n",
+    "# - fair-tradeoff\n",
+    "# a short description for each metric is given below\n",
+    "detector.bias_scan(\n",
+    "    metric=\"equal-choice-for-recourse\",\n",
+    "    phi=0.1,\n",
+    "    top_count=3\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "If \u001b[1mage = (26.0, 34.0], hours-per-week = FullTime\u001b[0m:\n",
+      "\tProtected Subgroup '\u001b[1mFemale\u001b[0m', \u001b[34m10.59%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m7.73%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m3.98%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m5.39%\u001b[39m.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m0.00\u001b[39m\n",
+      "\tProtected Subgroup '\u001b[1mMale\u001b[0m', \u001b[34m13.78%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m19.66%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m10.63%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m13.39%\u001b[39m.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m-3.00\u001b[39m\n",
+      "\t\u001b[35mBias against Female with respect to equal-choice-for-recourse. Unfairness score = 3.\u001b[39m\n",
+      "If \u001b[1mage = (26.0, 34.0], capital-loss = 0.0, hours-per-week = FullTime\u001b[0m:\n",
+      "\tProtected Subgroup '\u001b[1mFemale\u001b[0m', \u001b[34m10.34%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m7.67%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m4.08%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m5.28%\u001b[39m.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m0.00\u001b[39m\n",
+      "\tProtected Subgroup '\u001b[1mMale\u001b[0m', \u001b[34m13.27%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m18.43%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m9.27%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m11.92%\u001b[39m.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m-2.00\u001b[39m\n",
+      "\t\u001b[35mBias against Female with respect to equal-choice-for-recourse. Unfairness score = 2.\u001b[39m\n",
+      "If \u001b[1mhours-per-week = FullTime, native-country = United-States\u001b[0m:\n",
+      "\tProtected Subgroup '\u001b[1mFemale\u001b[0m', \u001b[34m41.66%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m2.62%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = BrainDrain\u001b[39m\u001b[0m with effectiveness \u001b[32m1.79%\u001b[39m.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m0.00\u001b[39m\n",
+      "\tProtected Subgroup '\u001b[1mMale\u001b[0m', \u001b[34m46.78%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m10.08%\u001b[39m.\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = BrainDrain\u001b[39m\u001b[0m with effectiveness \u001b[32m8.70%\u001b[39m.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m-1.00\u001b[39m\n",
+      "\t\u001b[35mBias against Female with respect to equal-choice-for-recourse. Unfairness score = 1.\u001b[39m\n"
+     ]
+    }
+   ],
+   "source": [
+    "# prints the result into a nicely formatted report\n",
+    "detector.print_recourse_report(\n",
+    "    show_action_costs=False,\n",
+    "    show_subgroup_costs=True,\n",
+    "    show_unbiased_subgroups=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Example Output Breakdown"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let us now disect the above example and the output we see, one step at a time.\n",
+    "\n",
+    "#### Prelude: $\\phi = 0.1$\n",
+    "\n",
+    "As we mentioned in the general description of this metric, this is the parameter that determines whether we consider an action sufficiently effective or not. So, here, we consider an action effective if it manages to flip the prediction for at least 10% of the individuals under study, and ineffective otherwise.\n",
+    "\n",
+    "#### **age = (26.0, 34.0], hours-per-week = FullTime**\n",
+    "\n",
+    "This is the first (hence, most biased) group. The group description is mostly self-explanatory: everything inside this block concerns all those (affected) individuals that are from 26 (not inclusive) to 34 years old and have a fulltime job. Now, since the output has the same structure for all groups, let us consider this group as an example and further disect the output we see in this block.\n",
+    "\n",
+    "#### *Protected subgroups 'Male' / 'Female'*\n",
+    "\n",
+    "We split the population of this group, according to the protected attribute. Hence, we distinguish between males that are 26-34 years old and have a fulltime job and females that are 26-34 years old and have a fulltime job.\n",
+    "\n",
+    "The \"covered\" percentage reported here in blue signifies that out of all affected females, 10.59% are 26-34 years old and have a fulltime job, while the respective percentage for males is 13.78%.\n",
+    "\n",
+    "#### *Make age = (41.0, 50.0], hours-per-week = OverTime*\n",
+    "\n",
+    "This is one of the 3 actions we have tried to apply on the individuals in the current subpopulation group. We report the action, along with its effectiveness and, optionally, the cost; here we omit the action cost because the \"Equal Choice for Recourse\" metric does not take it into account.\n",
+    "\n",
+    "At this point, let us give a more direct interpretation for the **effectiveness**. In this case, for example, the interpretation could be the following: if all females aged 26-34 with fulltime jobs change their age group to 41-50 years old and their working hours to overtime, then 7.73% of them will actually manage to receive the positive prediction from the model. The rest will still receive the negative prediction.\n",
+    "\n",
+    "#### *Protected Subgroups' Aggregate Cost*\n",
+    "\n",
+    "The \"aggregate cost of the above recourses\" message shows how we quantify the *cost of recourse* for all actions in each protected subgroup.\n",
+    "\n",
+    "This is derived directly from the definition of each metric. Here, for example, we use the \"Equal Choice for Recourse\" metric, which counts the number of effective actions available to each of the protected subgroups. In this group, females have no (sufficiently) effective actions, and as such we say that they gain 0 units. Males have 3 effective actions, so they gain 3 units.\n",
+    "\n",
+    "Finally, to keep the formalization of having costs everywhere, we rephrase this instead into males having a recourse cost of -3 and females having a recourse cost of 0.\n",
+    "\n",
+    "As we also mention in the next paragraph, the final bias score of the subgroup is nothing more than the absolute difference of these 2 costs.\n",
+    "\n",
+    "#### *Bias Deduction / Metric Application*\n",
+    "\n",
+    "Given the above, one can see that the (same) actions, if applied to females of the subpopulation group, cannot yield more than 10% effectiveness, while in males they achieve up to 19.66%! This is why we argue that, in the terms of bias of recourse, this group exhibits bias against females.\n",
+    "\n",
+    "This is, of course, with respect to the \"Equal Choice for Recourse\" metric, which posits that the 2 protected subgroups should have the same number of effective actions. Since none of the 3 actions are sufficiently effective for females, and all 3 of them are sufficiently effective for males, we score this group as having a bias measure of $|0 - 3| = 3$."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Example without Bias of Recourse\n",
+    "\n",
+    "For completeness, we also demonstrate how, for some choices of metrics and parameters, FACTS may fail to find any subpopulation groups that exhibit bias between the protected populations, and thus deduce that in this case there is no recourse related bias."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "detector.bias_scan(\n",
+    "    metric=\"equal-choice-for-recourse\",\n",
+    "    phi=0.7,\n",
+    "    top_count=3\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "\u001b[1mWith the given parameters, no recourses showing unfairness have been found!\u001b[0m\n"
+     ]
+    }
+   ],
+   "source": [
+    "# prints the result into a nicely formatted report\n",
+    "detector.print_recourse_report(\n",
+    "    show_action_costs=False,\n",
+    "    show_subgroup_costs=True,\n",
+    "    show_unbiased_subgroups=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Aternative API\n",
+    "\n",
+    "We also provide a more succinct API in the form of a wrapper function. This is closer in style to the API of existing `aif360` detectors.\n",
+    "\n",
+    "The previous example could be run equivalently with the following."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "If \u001b[1mage = (26.0, 34.0], hours-per-week = FullTime\u001b[0m:\n",
+      "\tProtected Subgroup '\u001b[1mFemale\u001b[0m', \u001b[34m10.59%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m7.73%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m3.98%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m5.39%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m0.00\u001b[39m\n",
+      "\tProtected Subgroup '\u001b[1mMale\u001b[0m', \u001b[34m13.78%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m19.66%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m10.63%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m13.39%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m-3.00\u001b[39m\n",
+      "\t\u001b[35mBias against Female with respect to equal-choice-for-recourse.. Unfairness score = 3.\u001b[39m\n",
+      "If \u001b[1mage = (26.0, 34.0], capital-loss = 0.0, hours-per-week = FullTime\u001b[0m:\n",
+      "\tProtected Subgroup '\u001b[1mFemale\u001b[0m', \u001b[34m10.34%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m7.67%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m4.08%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m5.28%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m0.00\u001b[39m\n",
+      "\tProtected Subgroup '\u001b[1mMale\u001b[0m', \u001b[34m13.27%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m18.43%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (41.0, 50.0]\u001b[39m\u001b[0m with effectiveness \u001b[32m9.27%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mage = (34.0, 41.0]\u001b[39m, \u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m11.92%\u001b[39m and counterfactual cost = 2.0.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m-2.00\u001b[39m\n",
+      "\t\u001b[35mBias against Female with respect to equal-choice-for-recourse.. Unfairness score = 2.\u001b[39m\n",
+      "If \u001b[1mhours-per-week = FullTime, native-country = United-States\u001b[0m:\n",
+      "\tProtected Subgroup '\u001b[1mFemale\u001b[0m', \u001b[34m41.66%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m2.62%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = BrainDrain\u001b[39m\u001b[0m with effectiveness \u001b[32m1.79%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m0.00\u001b[39m\n",
+      "\tProtected Subgroup '\u001b[1mMale\u001b[0m', \u001b[34m46.78%\u001b[39m covered\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = OverTime\u001b[39m\u001b[0m with effectiveness \u001b[32m10.08%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\tMake \u001b[1m\u001b[31mhours-per-week = BrainDrain\u001b[39m\u001b[0m with effectiveness \u001b[32m8.70%\u001b[39m and counterfactual cost = 1.0.\n",
+      "\t\t\u001b[1mAggregate cost\u001b[0m of the above recourses = \u001b[35m-1.00\u001b[39m\n",
+      "\t\u001b[35mBias against Female with respect to equal-choice-for-recourse.. Unfairness score = 1.\u001b[39m\n"
+     ]
+    }
+   ],
+   "source": [
+    "most_biased_subgroups = FACTS_bias_scan(\n",
+    "    X=X_test,\n",
+    "    clf=model,\n",
+    "    prot_attr=\"sex\",\n",
+    "    feature_weights={f: 1 for f in X.columns},\n",
+    "    feats_not_allowed_to_change=[],\n",
+    "    metric=\"equal-choice-for-recourse\",\n",
+    "    phi=0.1,\n",
+    "    top_count=3,\n",
+    "    verbose=False, # hides progress bars\n",
+    "    print_recourse_report=True,\n",
+    "    show_action_costs=True,\n",
+    "    show_subgroup_costs=True,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "[({'hours-per-week': 'FullTime', 'native-country': 'United-States'}, 1),\n",
+       " ({'age': Interval(26.0, 34.0, closed='right'), 'hours-per-week': 'FullTime'},\n",
+       "  3),\n",
+       " ({'age': Interval(26.0, 34.0, closed='right'),\n",
+       "   'capital-loss': 0.0,\n",
+       "   'hours-per-week': 'FullTime'},\n",
+       "  2)]"
+      ]
+     },
+     "execution_count": 13,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "most_biased_subgroups"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Short Description of all Definitions / Metrics of Subgroup Recourse Fairness\n",
+    "\n",
+    "Here we give a brief description of each of the metrics available in our framework apart from \"Equal Choice for Recourse\".\n",
+    "\n",
+    "## Equal Effectiveness\n",
+    "\n",
+    "The classifier is considered to act fairly for a population group if the same proportion of individuals in the protected subgroups can achieve recourse.\n",
+    "\n",
+    "**Suggestion**: this metric ignores costs altogether and compares only the percentage of males VS females that can cross the model's decision boundary by the same actions. We would use it in applications where the goal is equal impact, in the sense that a change (or a set thereof) affects the same proportion of individuals in the protected subgroups. For example, in a hiring scenario, a similar proportion of males and females are expected to benefit from the same change.\n",
+    "\n",
+    "## Equal Effectiveness within Budget\n",
+    "\n",
+    "The classifier is considered to act fairly for a population group if the same proportion of individuals in the protected subgroups can achieve recourse with a cost at most $c$, where $c$ is some user-provided cost budget.\n",
+    "\n",
+    "**Suggestion**: this metric is similar to the above, but puts a bound on how large the cost of an action can be. Could be used to limit changes with undesirably large cost, e.g., salary changes up to 10K.\n",
+    "\n",
+    "## Equal Cost of Effectiveness\n",
+    "\n",
+    "The classifier is considered to act fairly for a population group if the minimum cost required to be sufficiently effective in the protected subgroups is equal. Again, as in \"Equal Choice for Recourse\", by \"sufficiently effective\" we refer to those actions that successfully flip the model's decision for at least $100\\phi \\%$ (for $\\phi \\in [0,1]$) of the subgroup.\n",
+    "\n",
+    "**Suggestion**: this metric could be useful when an external factor imposes a specific threshold, e.g. in credit risk assessment, a guideline which states that the effort required to be 80% certain that you will have your loan accepted should be the same for males and females.\n",
+    "\n",
+    "## Equal (Conditional) Mean Recourse\n",
+    "\n",
+    "This definition extends the notion of *burden* from literature ([reference](https://dl.acm.org/doi/10.1145/3375627.3375812)) to the case where not all individuals may achieve recourse. Omitting some details, given any set of individuals, the **conditional mean recourse cost** is the mean recourse cost among the subset of individuals that can actually achieve recourse, i.e. by at least one of the available actions.\n",
+    "\n",
+    "Given the above, this definition considers the classifier to act fairly for a population group if the (conditional) mean recourse cost for the protected subgroups is the same.\n",
+    "\n",
+    "**Suggestion**: this metric compares the mean cost required to achieve recourse for the protected subgroups. It could be useful in a scenario like loan approval, where one needs to ensure that the cost of changes needed to receive the loan are the same for males and females on average.\n",
+    "\n",
+    "## Fair Effectiveness-Cost Trade-Off\n",
+    "\n",
+    "This is the strictest definition, which considers the classifier to act fairly for a population group only if the protected subgroups have the same effectiveness-cost distribution (checked in the implementation via a statistical test).\n",
+    "\n",
+    "Equivalently, Equal Effectiveness within Budget must hold for *every* value of the cost budget $c$.\n",
+    "\n",
+    "**Suggestion**: this metric considers all available actions and compares all their possible trade-offs between effectiveness and cost among the protected subgroups. This could be useful for cases where the protected attribute should have absolutely no impact on the available options to achieve recourse, such as in high-risk situations like estimating the risk of a convicted individual to act unlawfully in the future (as in the well known [COMPAS dataset](https://www.propublica.org/datastore/dataset/compas-recidivism-risk-score-data-and-analysis))."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.18"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/requirements.txt b/requirements.txt
index 25213c0a..266f030e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,6 +11,8 @@ rpy2==3.4.5
 skorch==0.11.0
 inFairness>=0.2.2
 pot==0.9
+mlxtend
+colorama
 
 # notebooks
 jupyter
diff --git a/setup.py b/setup.py
index ba05dfd8..289aeaac 100644
--- a/setup.py
+++ b/setup.py
@@ -25,6 +25,7 @@
     'notebooks': ['jupyter', 'tqdm', 'igraph[plotting]', 'lightgbm', 'seaborn', 'ipympl'],
     'LawSchoolGPA': ['tempeh'],
     'OptimalTransport': ['pot'],
+    'FACTS': ['mlxtend', 'colorama', 'tqdm'],
 }
 extras['tests'] = reduce(lambda l1, l2: l1+l2, extras.values(), ['pytest>=3.5', 'pytest-cov>=2.8.1'])
 extras['docs'] = ['sphinx<2', 'jinja2<3.1.0', 'sphinx_rtd_theme']


Test patch:
diff --git a/tests/sklearn/facts/__init__.py b/tests/sklearn/facts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/sklearn/facts/test_clean.py b/tests/sklearn/facts/test_clean.py
new file mode 100644
index 00000000..70480256
--- /dev/null
+++ b/tests/sklearn/facts/test_clean.py
@@ -0,0 +1,119 @@
+import pandas as pd
+
+from aif360.sklearn.detectors.facts.clean import clean_dataset
+
+
+def test_clean_adult() -> None:
+    cols = ["fnlwgt", "education", "relationship", "hours-per-week", "age", "income"]
+    mock_adult = pd.DataFrame(
+        [
+            [13, "hello", " ?", 15, 30, " <=50K"],
+            [13, "hello", " waifu", 15, " ?", " <=50K"],
+            [13, "hello", " ?", 15, 30, " broke"],
+            [" ?", "hello", " Husband", 30, 10, " <=50K"],
+            [13, "hello", " Husband", 45, 20, " >50K"],
+            [13, "hello", " Husband", 39.5, 30, " <=50K"],
+            [13, "hello", " Husband", 15, 40, " >50K"],
+            [13, "hello", " Wife", 45, 50, " >50K"],
+            [13, "hello", " Wife", 39.5, 60, " <=50K"],
+            [13, "hello", " Wife", 80, 70, " >50K"],
+        ],
+        columns=cols
+    )
+
+    cols = ["relationship", "hours-per-week", "age", "income"]
+    res_adult = pd.DataFrame(
+        [
+            ["Married", "MidTime", 10, 0],
+            ["Married", "OverTime", 20, 1],
+            ["Married", "FullTime", 30, 0],
+            ["Married", "PartTime", 40, 1],
+            ["Married", "OverTime", 50, 1],
+            ["Married", "FullTime", 60, 0],
+            ["Married", "BrainDrain", 70, 1],
+        ],
+        columns=cols
+    )
+    res_adult["age"] = pd.qcut(res_adult["age"], q=5)
+
+    df1 = clean_dataset(mock_adult, "adult").reset_index(drop=True)
+    df2 = res_adult
+    assert (df1 == df2).all().all()
+
+def test_clean_ssl() -> None:
+    cols = ["SSL SCORE", "RACE CODE CD", "PREDICTOR RAT TREND IN CRIMINAL ACTIVITY", "PREDICTOR RAT AGE AT LATEST ARREST"]
+    mock_ssl = pd.DataFrame(
+        [
+            [200, "WBH", 1, "20-30"],
+            [200, "WBH", 2, "20-30"],
+            [400, "WBH", 3, "30-40"],
+            [200, "WBH", 4, "30-40"],
+            [200, "WWH", 5, "20-30"],
+            [400, "WWH", 6, "less than 20"],
+            [200, "API", 7, "less than 20"],
+            [200, "U", 8, "45"],
+            [400, "X", 9, "39.5"],
+            [200, "I", 10, "80"],
+            [200, "API", 11, "80"],
+        ],
+        columns=cols
+    )
+
+    res_ssl = pd.DataFrame(
+        [
+            [1, "BLK", 1, "20-30"],
+            [1, "BLK", 2, "20-30"],
+            [0, "BLK", 3, "30-40"],
+            [1, "BLK", 4, "30-40"],
+            [1, "WHI", 5, "20-30"],
+            [0, "WHI", 6, "10-20"],
+        ],
+        columns=cols
+    )
+    res_ssl["PREDICTOR RAT TREND IN CRIMINAL ACTIVITY"] = pd.qcut(res_ssl["PREDICTOR RAT TREND IN CRIMINAL ACTIVITY"], q=6)
+
+    df1 = clean_dataset(mock_ssl, "SSL").reset_index(drop=True)
+    df2 = res_ssl
+    assert (df1 == df2).all().all()
+
+def test_clean_compas() -> None:
+    cols = ["age", "c_charge_desc", "priors_count", "age_cat", "target"]
+    mock_compas = pd.DataFrame(
+        [
+            [200, "WBH", 0, "20-30", "Recidivated"],
+            [200, "WBH", 1, "20-30", "Recidivated"],
+            [400, "WBH", 3, "30-40", "Recidivated"],
+            [200, "WBH", 7, "30-40", "Survived"],
+            [200, "WWH", 13, "20-30", "Recidivated"],
+            [400, "WWH", 14, "Less than 25", "Survived"],
+            [200, "API", 17, "Less than 25", "Recidivated"],
+            [200, "API", 21, "Less than 25", "Recidivated"],
+            [400, "API", 29, "Less than 25", "Survived"],
+            [200, "API", 31, "Less than 25", "Recidivated"],
+            [200, "API", 37, "Less than 25", "Survived"],
+        ],
+        columns=cols
+    )
+
+    cols = ["priors_count", "age_cat", "target"]
+    res_compas = pd.DataFrame(
+        [
+            [0, "20-30", 0],
+            [1, "20-30", 0],
+            [3, "30-40", 0],
+            [7, "30-40", 1],
+            [13, "20-30", 0],
+            [14, "10-25", 1],
+            [17, "10-25", 0],
+            [21, "10-25", 0],
+            [29, "10-25", 1],
+            [31, "10-25", 0],
+            [37, "10-25", 1],
+        ],
+        columns=cols
+    )
+    res_compas["priors_count"] = pd.cut(res_compas["priors_count"], [-0.1, 1, 5, 10, 15, 38])
+
+    df1 = clean_dataset(mock_compas, "compas").reset_index(drop=True)
+    df2 = res_compas
+    assert (df1 == df2).all().all()
\ No newline at end of file
diff --git a/tests/sklearn/facts/test_formatting.py b/tests/sklearn/facts/test_formatting.py
new file mode 100644
index 00000000..b07ce5c2
--- /dev/null
+++ b/tests/sklearn/facts/test_formatting.py
@@ -0,0 +1,71 @@
+from aif360.sklearn.detectors.facts.formatting import (
+    print_recourse_report,
+    print_recourse_report_KStest_cumulative,
+    ifthen2str,
+    plot_aggregate_correctness
+)
+from aif360.sklearn.detectors.facts.predicate import Predicate
+
+def test_print_recourse_report(capfd) -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.2, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.7, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.15, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.45, 18.),
+            (Predicate.from_dict({"a": 23}), 0.65, 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, float(6 + 25)),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, float(12 + 35)),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, float(18 + 50)),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, float(30 + 60)),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, float(6 + 25)),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, float(12 + 35)),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, float(18 + 50)),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, float(30 + 60)),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"b": 40}), 0.2, 25.),
+            (Predicate.from_dict({"b": 38}), 0.4, 35.),
+            (Predicate.from_dict({"b": 35}), 0.6, 50.),
+            (Predicate.from_dict({"b": 33}), 0.9, 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"b": 40}), 0.15, 25.),
+            (Predicate.from_dict({"b": 38}), 0.35, 35.),
+            (Predicate.from_dict({"b": 35}), 0.5, 50.),
+            (Predicate.from_dict({"b": 33}), 0.8, 60.),
+        ])},
+    }
+    pop_sizes = {"Male": 900, "Female": 1000}
+    sg_costs = {
+        Predicate.from_dict({"a": 13}): {"Male": 18., "Female": 30.},
+        Predicate.from_dict({"a": 13, "b": 45}): {"Male": 47., "Female": 47.},
+        Predicate.from_dict({"b": 45}): {"Male": 53., "Female": 88.}
+    }
+
+    print_recourse_report(ifthens, pop_sizes, subgroup_costs=sg_costs, show_subgroup_costs=True, show_then_costs=True, show_bias="Male")
+    out, err = capfd.readouterr()
+    expected = "If \x1b[1ma = 13, b = 45\x1b[0m:\n\tProtected Subgroup '\x1b[1mMale\x1b[0m', \x1b[34m20.00%\x1b[39m covered out of 900\n\t\tMake \x1b[1m\x1b[31ma = 15\x1b[39m, \x1b[31mb = 40\x1b[39m\x1b[0m with effectiveness \x1b[32m45.00%\x1b[39m and counterfactual cost = 31.0.\n\t\tMake \x1b[1m\x1b[31ma = 17\x1b[39m, \x1b[31mb = 38\x1b[39m\x1b[0m with effectiveness \x1b[32m50.00%\x1b[39m and counterfactual cost = 47.0.\n\t\tMake \x1b[1m\x1b[31ma = 19\x1b[39m, \x1b[31mb = 35\x1b[39m\x1b[0m with effectiveness \x1b[32m75.00%\x1b[39m and counterfactual cost = 68.0.\n\t\tMake \x1b[1m\x1b[31ma = 23\x1b[39m, \x1b[31mb = 33\x1b[39m\x1b[0m with effectiveness \x1b[32m99.00%\x1b[39m and counterfactual cost = 90.0.\n\t\t\x1b[1mAggregate cost\x1b[0m of the above recourses = \x1b[35m47.00\x1b[39m\n\tProtected Subgroup '\x1b[1mFemale\x1b[0m', \x1b[34m25.00%\x1b[39m covered out of 1000\n\t\tMake \x1b[1m\x1b[31ma = 15\x1b[39m, \x1b[31mb = 40\x1b[39m\x1b[0m with effectiveness \x1b[32m40.00%\x1b[39m and counterfactual cost = 31.0.\n\t\tMake \x1b[1m\x1b[31ma = 17\x1b[39m, \x1b[31mb = 38\x1b[39m\x1b[0m with effectiveness \x1b[32m50.00%\x1b[39m and counterfactual cost = 47.0.\n\t\tMake \x1b[1m\x1b[31ma = 19\x1b[39m, \x1b[31mb = 35\x1b[39m\x1b[0m with effectiveness \x1b[32m70.00%\x1b[39m and counterfactual cost = 68.0.\n\t\tMake \x1b[1m\x1b[31ma = 23\x1b[39m, \x1b[31mb = 33\x1b[39m\x1b[0m with effectiveness \x1b[32m85.00%\x1b[39m and counterfactual cost = 90.0.\n\t\t\x1b[1mAggregate cost\x1b[0m of the above recourses = \x1b[35m47.00\x1b[39m\n\t\x1b[35mNo bias!\x1b[39m\n"
+    assert out == expected
+
+# def test_print_recourse_report_KStest_cumulative() -> None:
+#     pass
+
+# def test_ifthen2str() -> None:
+#     pass
+
+# def test_plot_aggregate_correctness() -> None:
+#     pass
+
diff --git a/tests/sklearn/facts/test_frequent_itemsets.py b/tests/sklearn/facts/test_frequent_itemsets.py
new file mode 100644
index 00000000..f769cff3
--- /dev/null
+++ b/tests/sklearn/facts/test_frequent_itemsets.py
@@ -0,0 +1,27 @@
+import pandas as pd
+
+from aif360.sklearn.detectors.facts.frequent_itemsets import preprocessDataset, fpgrowth_out_to_predicate_list, run_fpgrowth
+from aif360.sklearn.detectors.facts.predicate import Predicate
+
+def test_frequent_itemset_pipeline() -> None:
+    df = pd.DataFrame(
+        [
+            [1, 2, 3, 4],
+            [1, 7, 7, 4],
+            [1, 5, 5, 5],
+            [1, 3, 3, 7],
+        ],
+        columns=["a", "b", "c", "d"]
+    )
+    df["a"] = df["a"].astype("category")
+
+    freq_itsets, supports = fpgrowth_out_to_predicate_list(run_fpgrowth(preprocessDataset(df), min_support=0.5))
+
+    assert supports == [1, 0.5, 0.5, 0.5, 0.5]
+    assert set(freq_itsets) == set([
+        Predicate.from_dict({"a": 1}),
+        Predicate.from_dict({"c": 3}),
+        Predicate.from_dict({"d": 4}),
+        Predicate.from_dict({"a": 1, "c": 3}),
+        Predicate.from_dict({"a": 1, "d": 4}),
+    ])
diff --git a/tests/sklearn/facts/test_metrics.py b/tests/sklearn/facts/test_metrics.py
new file mode 100644
index 00000000..150e00f9
--- /dev/null
+++ b/tests/sklearn/facts/test_metrics.py
@@ -0,0 +1,174 @@
+from functools import partial
+import pandas as pd
+import numpy as np
+
+import pytest
+
+from aif360.sklearn.detectors.facts.metrics import (
+    incorrectRecoursesIfThen,
+    if_group_cost_min_change_correctness_threshold,
+    if_group_cost_recoursescount_correctness_threshold,
+    if_group_maximum_correctness,
+    if_group_cost_max_correctness_cost_budget,
+    if_group_average_recourse_cost_conditional,
+    calculate_all_if_subgroup_costs
+)
+from aif360.sklearn.detectors.facts.predicate import Predicate
+from aif360.sklearn.detectors.facts.parameters import feature_change_builder, ParameterProxy
+
+class MockModel:
+    def predict(self, X: pd.DataFrame) -> np.ndarray:
+        ret = []
+        for i, r in X.iterrows():
+            if r["a"] > 0:
+                ret.append(0)
+            elif r["c"] < 15:
+                ret.append(1)
+            else:
+                ret.append(0)
+        return np.array(ret)
+
+def test_incorrectRecoursesIfThen() -> None:
+    df = pd.DataFrame(
+        [
+            [1, 2, 3, 4],
+            [1, 20, 30, 40],
+            [21, 2, 3, 4],
+            [19, 2, 3, 4],
+        ],
+        columns=["a", "b", "c", "d"]
+    )
+    model = MockModel()
+
+    ifclause = Predicate.from_dict({"a": 1})
+    thenclause = Predicate.from_dict({"a": -10})
+
+    assert incorrectRecoursesIfThen(ifclause, thenclause, df, model) == 1
+
+def test_if_group_cost_min_change_correctness_threshold() -> None:
+    ifclause = Predicate.from_dict({"a": 13})
+    thenclauses = [
+        (Predicate.from_dict({"a": 15}), 0.5, 6.),
+        (Predicate.from_dict({"a": 17}), 0.5, 12.),
+        (Predicate.from_dict({"a": 19}), 0.5, 18.),
+        (Predicate.from_dict({"a": 23}), 0.5, 30.),
+    ]
+    comparators = feature_change_builder(None, num_cols=["a"], cate_cols=[], ord_cols=[], feature_weights={"a": 3})
+    params = ParameterProxy(featureChanges=comparators)
+
+    assert if_group_cost_min_change_correctness_threshold(ifclause, thenclauses, 0.3) == 6
+    assert if_group_cost_min_change_correctness_threshold(ifclause, thenclauses, 0.7) == np.inf
+
+def test_if_group_cost_recoursescount_correctness_threshold() -> None:
+    ifclause = Predicate.from_dict({"a": 13})
+    thenclauses = [
+        (Predicate.from_dict({"a": 15}), 0.5, 6.),
+        (Predicate.from_dict({"a": 17}), 0.5, 12.),
+        (Predicate.from_dict({"a": 19}), 0.5, 18.),
+        (Predicate.from_dict({"a": 23}), 0.5, 30.),
+    ]
+    comparators = feature_change_builder(None, num_cols=["a"], cate_cols=[], ord_cols=[], feature_weights={"a": 3})
+    params = ParameterProxy(featureChanges=comparators)
+
+    assert if_group_cost_recoursescount_correctness_threshold(ifclause, thenclauses, 0.3) == -4
+    assert if_group_cost_recoursescount_correctness_threshold(ifclause, thenclauses, 0.7) == 0
+
+def test_if_group_maximum_correctness() -> None:
+    ifclause = Predicate.from_dict({"a": 13})
+    thenclauses = [
+        (Predicate.from_dict({"a": 15}), 0.2, 13.),
+        (Predicate.from_dict({"a": 17}), 0.3, 33.),
+        (Predicate.from_dict({"a": 19}), 0.5, 53.),
+        (Predicate.from_dict({"a": 23}), 0.7, 73.),
+    ]
+    comparators = feature_change_builder(None, num_cols=["a"], cate_cols=[], ord_cols=[], feature_weights={"a": 3})
+    params = ParameterProxy(featureChanges=comparators)
+
+    assert if_group_maximum_correctness(ifclause, thenclauses) == 0.7
+
+def test_if_group_cost_max_correctness_cost_budget() -> None:
+    ifclause = Predicate.from_dict({"a": 13})
+    thenclauses = [
+        (Predicate.from_dict({"a": 15}), 0.2, 13.),
+        (Predicate.from_dict({"a": 17}), 0.3, 33.),
+        (Predicate.from_dict({"a": 19}), 0.5, 53.),
+        (Predicate.from_dict({"a": 23}), 0.7, 73.),
+    ]
+    comparators = feature_change_builder(None, num_cols=["a"], cate_cols=[], ord_cols=[], feature_weights={"a": 3})
+    params = ParameterProxy(featureChanges=comparators)
+
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=5) == np.inf
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=13) == 0.2
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=20) == 0.2
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=33) == 0.3
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=40) == 0.3
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=53) == 0.5
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=60) == 0.5
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=73) == 0.7
+    assert if_group_cost_max_correctness_cost_budget(ifclause, thenclauses, cost_thres=80) == 0.7
+
+def test_if_group_average_recourse_cost_conditional() -> None:
+    ifclause = Predicate.from_dict({"a": 13})
+    thenclauses = [
+        (Predicate.from_dict({"a": 15}), 0.2, 13.),
+        (Predicate.from_dict({"a": 17}), 0.3, 33.),
+        (Predicate.from_dict({"a": 19}), 0.5, 53.),
+        (Predicate.from_dict({"a": 23}), 0.7, 73.),
+    ]
+    comparators = feature_change_builder(None, num_cols=["a"], cate_cols=[], ord_cols=[], feature_weights={"a": 3})
+    params = ParameterProxy(featureChanges=comparators)
+
+    expected_result = (0.2 * 13 + 0.1 * 33 + 0.2 * 53 + 0.2 * 73) / 0.7
+    assert if_group_average_recourse_cost_conditional(ifclause, thenclauses) == pytest.approx(expected_result)
+
+    thenclauses = [
+        (Predicate.from_dict({"a": 15}), 0., 13.),
+        (Predicate.from_dict({"a": 17}), 0., 33.),
+        (Predicate.from_dict({"a": 19}), 0., 53.),
+        (Predicate.from_dict({"a": 23}), 0., 73.),
+    ]
+    assert if_group_average_recourse_cost_conditional(ifclause, thenclauses) == np.inf
+
+def test_calculate_all_if_subgroup_costs_cumulative() -> None:
+    ifclauses = [
+        Predicate.from_dict({"a": 13}),
+        Predicate.from_dict({"a": 13, "b": 45})
+    ]
+    thenclauses = [
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.2, 6),
+            (Predicate.from_dict({"a": 17}), 0.3, 12),
+            (Predicate.from_dict({"a": 19}), 0.5, 18),
+            (Predicate.from_dict({"a": 23}), 0.7, 30),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.15, 6),
+            (Predicate.from_dict({"a": 17}), 0.3, 12),
+            (Predicate.from_dict({"a": 19}), 0.45, 18),
+            (Predicate.from_dict({"a": 23}), 0.65, 30),
+        ])},
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 6 + 25),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 12 + 35),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 18 + 50),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, 30 + 60),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, 6 + 25),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 12 + 35),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 18 + 50),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, 30 + 60),
+        ])},
+    ]
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    expected_result = {
+        ifclauses[0]: {"Male": 18, "Female": 30},
+        ifclauses[1]: {"Male": 47, "Female": 47}
+    }
+    assert calculate_all_if_subgroup_costs(
+        ifclauses,
+        thenclauses,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.5)
+    ) == expected_result
diff --git a/tests/sklearn/facts/test_misc.py b/tests/sklearn/facts/test_misc.py
new file mode 100644
index 00000000..57857df8
--- /dev/null
+++ b/tests/sklearn/facts/test_misc.py
@@ -0,0 +1,478 @@
+from pprint import pprint
+
+import numpy as np
+import pandas as pd
+
+import pytest
+
+from aif360.sklearn.detectors.facts.misc import (
+    valid_ifthens,
+    rules2rulesbyif,
+    rulesbyif2rules,
+    select_rules_subset,
+    select_rules_subset_KStest,
+    cum_corr_costs_all,
+    cum_corr_costs_all_minimal
+)
+from aif360.sklearn.detectors.facts.predicate import Predicate
+from aif360.sklearn.detectors.facts.parameters import ParameterProxy, feature_change_builder
+
+class MockModel:
+    def predict(self, X: pd.DataFrame) -> np.ndarray:
+        ret = []
+        for i, r in X.iterrows():
+            if r["a"] > 20:
+                ret.append(1)
+            elif r["c"] < 15:
+                ret.append(1)
+            else:
+                ret.append(0)
+        return np.array(ret)
+
+def test_rule_generation() -> None:
+    df = pd.DataFrame(
+        [
+            [21, 2, 3, 4, "Female", pd.Interval(60, 70)],
+            [21, 13, 3, 19, "Male", pd.Interval(60, 70)],
+            [25, 2, 7, 4, "Female", pd.Interval(60, 70)],
+            [21, 2, 3, 4, "Male", pd.Interval(60, 70)],
+            [1, 2, 3, 4, "Male", pd.Interval(20, 30)],
+            [1, 20, 30, 40, "Male", pd.Interval(40, 50)],
+            [19, 2, 30, 43, "Male", pd.Interval(30, 40)],
+            [19, 13, 30, 4, "Male", pd.Interval(10, 20)],
+            [1, 2, 30, 4, "Female", pd.Interval(20, 30)],
+            [19, 20, 30, 40, "Female", pd.Interval(40, 50)],
+            [19, 2, 30, 4, "Female", pd.Interval(30, 40)],
+        ],
+        columns=["a", "b", "c", "d", "sex", "age"]
+    )
+    model = MockModel()
+    
+    ifthens = valid_ifthens(
+        df,
+        model,
+        sensitive_attribute="sex",
+        freqitem_minsupp=0.5,
+        drop_infeasible=False,
+        drop_above=True
+    )
+    ifthens = rules2rulesbyif(ifthens)
+
+    expected_ifthens = {
+        Predicate.from_dict({"a": 19}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21}), 1.)
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21}), 1.)
+            ])
+        },
+        Predicate.from_dict({"c": 30}): {
+            "Male": (1., [
+                (Predicate.from_dict({"c": 3}), 1.)
+            ]),
+            "Female": (1., [
+                (Predicate.from_dict({"c": 3}), 1.)
+            ])
+        },
+        Predicate.from_dict({"a": 19, "c": 30}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 3}), 1.)
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 3}), 1.)
+            ])
+        },
+    }
+
+    l1 = rulesbyif2rules(ifthens)
+    l2 = [
+        (Predicate.from_dict({"a": 19}), Predicate.from_dict({"a": 21}), {"Male": 2/3, "Female": 2/3}, {"Male": 1., "Female": 1.}),
+        (Predicate.from_dict({"c": 30}), Predicate.from_dict({"c": 3}), {"Male": 1., "Female": 1.}, {"Male": 1., "Female": 1.}),
+        (Predicate.from_dict({"a": 19, "c": 30}), Predicate.from_dict({"a": 21, "c": 3}), {"Male": 2/3, "Female": 2/3}, {"Male": 1., "Female": 1.}),
+    ]
+    assert sorted(l1) == sorted(l2)
+
+    assert ifthens == expected_ifthens
+
+def test_select_rules_subset() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.35, 6.), # cost: 6.
+            (Predicate.from_dict({"a": 17}), 0.7, 12.), # cost: 12.
+            (Predicate.from_dict({"a": 19}), 0.5, 18.), # cost: 18.
+            (Predicate.from_dict({"a": 23}), 0.2, 30.), # cost: 30.
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.3, 6.), # cost: 6.
+            (Predicate.from_dict({"a": 17}), 0.5, 12.), # cost: 12.
+            (Predicate.from_dict({"a": 19}), 0.2, 18.), # cost: 18.
+            (Predicate.from_dict({"a": 23}), 0., 30.), # cost: 30.
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.5, 31.), # cost: 31.
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.99, 47.), # cost: 47.
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 68.), # cost: 68.
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.45, 90.), # cost: 90.
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 31.), # cost: 31.
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.8, 47.), # cost: 47.
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 68.), # cost: 68.
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.5, 90.), # cost: 90.
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+    
+    rules, subgroup_costs = select_rules_subset(
+        ifthens,
+        metric="equal-choice-for-recourse",
+        top_count=2,
+        filter_sequence=["remove-fair-rules"],
+        cor_threshold=0.6
+    )
+    # print(subgroup_costs)
+
+    expected_rules = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.35, 6.), # cost: 6.
+            (Predicate.from_dict({"a": 17}), 0.7, 12.), # cost: 12.
+            (Predicate.from_dict({"a": 19}), 0.5, 18.), # cost: 18.
+            (Predicate.from_dict({"a": 23}), 0.2, 30.), # cost: 30.
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.3, 6.), # cost: 6.
+            (Predicate.from_dict({"a": 17}), 0.5, 12.), # cost: 12.
+            (Predicate.from_dict({"a": 19}), 0.2, 18.), # cost: 18.
+            (Predicate.from_dict({"a": 23}), 0., 30.), # cost: 30.
+        ])}
+    }
+    expected_costs = {
+        Predicate.from_dict({"a": 13}): {"Male": -1, "Female": 0},
+        Predicate.from_dict({"a": 13, "b": 45}): {"Male": -2, "Female": -2},
+    }
+
+    assert expected_rules == rules
+    assert expected_costs == subgroup_costs
+
+def test_select_rules_subset_2() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.2, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.7, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.15, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.45, 18.),
+            (Predicate.from_dict({"a": 23}), 0.7, 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 31.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 47.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 68.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, 90.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, 31.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 47.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 68.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, 90.),
+        ])}
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    rules, sg_costs = select_rules_subset(
+        ifthens,
+        metric="equal-effectiveness",
+        top_count=2,
+        filter_sequence=["remove-fair-rules"],
+    )
+
+    expected_rules = {
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 31.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 47.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 68.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, 90.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, 31.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 47.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 68.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, 90.),
+        ])}
+    }
+    expected_costs = {
+        Predicate.from_dict({"a": 13, "b": 45}): {"Male": 0.99, "Female": 0.85},
+        Predicate.from_dict({"a": 13}): {"Male": 0.7, "Female": 0.7},
+    }
+
+    assert expected_rules == rules
+    assert expected_costs == sg_costs
+
+def test_select_rules_subset_KStest() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.2, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.7, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.15, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.45, 18.),
+            (Predicate.from_dict({"a": 23}), 0.65, 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 31.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 47.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 68.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, 90.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, 31.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, 47.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 68.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, 90.),
+        ])}
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    rules, unfairness_measures = select_rules_subset_KStest(
+        ifthens,
+        affected_population_sizes={"Male": 100, "Female": 90},
+        top_count=2,
+        filter_contained=True
+    )
+
+    expected_rules = ifthens
+    expected_costs = {
+        Predicate.from_dict({"a": 13}): pytest.approx(0.05 * np.sqrt((0.2*100*0.25*90)/(0.2*100+0.25*90))),
+        Predicate.from_dict({"a": 13, "b": 45}): pytest.approx(0.14 * np.sqrt((0.2*100*0.25*90)/(0.2*100+0.25*90))),
+    }
+
+    assert rules == expected_rules
+    assert unfairness_measures == expected_costs
+
+class MockModel2:
+    def predict(self, X: pd.DataFrame) -> np.ndarray:
+        ret = []
+        for i, r in X.iterrows():
+            if r["b"] < 10 and r["a"] > 20:
+                ret.append(1)
+            elif r["b"] < 20 and r["a"] > 23:
+                ret.append(1)
+            elif r["c"] < 15:
+                ret.append(1)
+            else:
+                ret.append(0)
+        return np.array(ret)
+
+def test_cum_corr_costs_all() -> None:
+    df = pd.DataFrame(
+        [
+            [21, 2, 3, 4, "Female", pd.Interval(60, 70)],
+            [21, 13, 3, 19, "Male", pd.Interval(60, 70)],
+            [25, 2, 7, 4, "Female", pd.Interval(60, 70)],
+            [21, 2, 3, 4, "Male", pd.Interval(60, 70)],
+            [1, 2, 3, 4, "Male", pd.Interval(20, 30)],
+            [1, 20, 30, 40, "Male", pd.Interval(40, 50)],
+            [19, 2, 30, 43, "Male", pd.Interval(30, 40)],
+            [19, 13, 30, 4, "Male", pd.Interval(10, 20)],
+            [1, 2, 30, 4, "Female", pd.Interval(20, 30)],
+            [19, 20, 30, 40, "Female", pd.Interval(40, 50)],
+            [19, 2, 30, 4, "Female", pd.Interval(30, 40)],
+        ],
+        columns=["a", "b", "c", "d", "sex", "age"]
+    )
+    model = MockModel2()
+
+    ifthens = {
+        Predicate.from_dict({"a": 19}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21}), 0.5),
+                (Predicate.from_dict({"a": 24}), 1.)
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21}), 0.5),
+                (Predicate.from_dict({"a": 24}), 0.5)
+            ])
+        },
+        Predicate.from_dict({"c": 30}): {
+            "Male": (1., [
+                (Predicate.from_dict({"c": 14}), 1.)
+            ]),
+            "Female": (1., [
+                (Predicate.from_dict({"c": 14}), 1.)
+            ])
+        },
+        Predicate.from_dict({"a": 19, "c": 30}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 30}), 0.5),
+                (Predicate.from_dict({"a": 24, "c": 30}), 1.),
+                (Predicate.from_dict({"a": 19, "c": 14}), 1.),
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 30}), 0.5),
+                (Predicate.from_dict({"a": 24, "c": 30}), 0.5),
+                (Predicate.from_dict({"a": 19, "c": 14}), 1.),
+            ])
+        },
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "c"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "c": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    rules_with_cumulative = cum_corr_costs_all(
+        ifthens,
+        df,
+        model,
+        sensitive_attribute="sex",
+        params=params
+    )
+
+    expected_rules = {
+        Predicate.from_dict({"a": 19}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21}), 0.5, 6.),
+                (Predicate.from_dict({"a": 24}), 1., 15.)
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21}), 0.5, 6.),
+                (Predicate.from_dict({"a": 24}), 0.5, 15.)
+            ])
+        },
+        Predicate.from_dict({"c": 30}): {
+            "Male": (1., [
+                (Predicate.from_dict({"c": 14}), 1., 80.)
+            ]),
+            "Female": (1., [
+                (Predicate.from_dict({"c": 14}), 1., 80.)
+            ])
+        },
+        Predicate.from_dict({"a": 19, "c": 30}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 30}), 0.5, 6.),
+                (Predicate.from_dict({"a": 24, "c": 30}), 1., 15.),
+                (Predicate.from_dict({"a": 19, "c": 14}), 1., 80.),
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 30}), 0.5, 6.),
+                (Predicate.from_dict({"a": 24, "c": 30}), 0.5, 15.),
+                (Predicate.from_dict({"a": 19, "c": 14}), 1., 80.),
+            ])
+        },
+    }
+    
+    assert rules_with_cumulative == expected_rules
+
+def test_cum_corr_costs_all_minimal() -> None:
+    df = pd.DataFrame(
+        [
+            [21, 2, 3, 4, "Female", pd.Interval(60, 70)],
+            [21, 13, 3, 19, "Male", pd.Interval(60, 70)],
+            [25, 2, 7, 4, "Female", pd.Interval(60, 70)],
+            [21, 2, 3, 4, "Male", pd.Interval(60, 70)],
+            [1, 2, 3, 4, "Male", pd.Interval(20, 30)],
+            [1, 20, 30, 40, "Male", pd.Interval(40, 50)],
+            [19, 2, 30, 43, "Male", pd.Interval(30, 40)],
+            [19, 13, 30, 4, "Male", pd.Interval(10, 20)],
+            [1, 2, 30, 4, "Female", pd.Interval(20, 30)],
+            [19, 20, 30, 40, "Female", pd.Interval(40, 50)],
+            [19, 2, 30, 4, "Female", pd.Interval(30, 40)],
+        ],
+        columns=["a", "b", "c", "d", "sex", "age"]
+    )
+    model = MockModel2()
+
+    ifthens = {
+        Predicate.from_dict({"a": 19}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21}), 0.5),
+                (Predicate.from_dict({"a": 24}), 1.)
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21}), 0.5),
+                (Predicate.from_dict({"a": 24}), 0.5)
+            ])
+        },
+        Predicate.from_dict({"c": 30}): {
+            "Male": (1., [
+                (Predicate.from_dict({"c": 14}), 1.)
+            ]),
+            "Female": (1., [
+                (Predicate.from_dict({"c": 14}), 1.)
+            ])
+        },
+        Predicate.from_dict({"a": 19, "c": 30}): {
+            "Male": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 30}), 0.5),
+                (Predicate.from_dict({"a": 24, "c": 30}), 1.),
+                (Predicate.from_dict({"a": 19, "c": 14}), 1.),
+            ]),
+            "Female": (2/3, [
+                (Predicate.from_dict({"a": 21, "c": 30}), 0.5),
+                (Predicate.from_dict({"a": 24, "c": 30}), 0.5),
+                (Predicate.from_dict({"a": 19, "c": 14}), 1.),
+            ])
+        },
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "c"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "c": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    rules_with_cumulative = cum_corr_costs_all_minimal(
+        ifthens,
+        df,
+        model,
+        sensitive_attribute="sex",
+        params=params
+    )
+
+    expected_rules = {
+        Predicate.from_dict({"a": 19}): {
+            "Male": [
+                (0.5, 6.),
+                (1., 15.)
+            ],
+            "Female": [
+                (0.5, 6.),
+                (0.5, 15.)
+            ]
+        },
+        Predicate.from_dict({"c": 30}): {
+            "Male": [
+                (1., 80.)
+            ],
+            "Female": [
+                (1., 80.)
+            ]
+        },
+        Predicate.from_dict({"a": 19, "c": 30}): {
+            "Male": [
+                (0.5, 6.),
+                (1., 15.),
+                (1., 80.),
+            ],
+            "Female": [
+                (0.5, 6.),
+                (0.5, 15.),
+                (1., 80.),
+            ]
+        },
+    }
+    
+    assert rules_with_cumulative == expected_rules
\ No newline at end of file
diff --git a/tests/sklearn/facts/test_optimization.py b/tests/sklearn/facts/test_optimization.py
new file mode 100644
index 00000000..d82b45e5
--- /dev/null
+++ b/tests/sklearn/facts/test_optimization.py
@@ -0,0 +1,425 @@
+from functools import partial
+import numpy as np
+
+import pytest
+
+from aif360.sklearn.detectors.facts.optimization import (
+    sort_triples_by_max_costdiff,
+    sort_triples_KStest
+)
+from aif360.sklearn.detectors.facts.predicate import Predicate
+from aif360.sklearn.detectors.facts.parameters import ParameterProxy, feature_change_builder
+from aif360.sklearn.detectors.facts.metrics import if_group_cost_min_change_correctness_threshold
+
+
+def test_sort_triples_by_max_costdiff_simple() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.35, 6.),
+            (Predicate.from_dict({"a": 17}), 0.7, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.2, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.3, 6.),
+            (Predicate.from_dict({"a": 17}), 0.5, 12.),
+            (Predicate.from_dict({"a": 19}), 0.2, 18.),
+            (Predicate.from_dict({"a": 23}), 0., 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.5, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.99, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.45, 30. + 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.8, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.5, 30. + 60.),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"b": 40}), 0.5, 25.),
+            (Predicate.from_dict({"b": 38}), 0.75, 35.),
+            (Predicate.from_dict({"b": 35}), 0.55, 50.),
+            (Predicate.from_dict({"b": 33}), 0.3, 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"b": 40}), 0.35, 25.),
+            (Predicate.from_dict({"b": 38}), 0.55, 35.),
+            (Predicate.from_dict({"b": 35}), 0.3, 50.),
+            (Predicate.from_dict({"b": 33}), 0.05, 60.),
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.5)
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13})
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+def test_sort_triples_by_max_costdiff_ignore_nans() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.35, 6.),
+            (Predicate.from_dict({"a": 17}), 0.58, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.2, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.3, 6.),
+            (Predicate.from_dict({"a": 17}), 0.5, 12.),
+            (Predicate.from_dict({"a": 19}), 0.2, 18.),
+            (Predicate.from_dict({"a": 23}), 0., 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.5, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.99, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.45, 30. + 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.8, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.5, 30. + 60.),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"b": 40}), 0.5, 25.),
+            (Predicate.from_dict({"b": 38}), 0.75, 35.),
+            (Predicate.from_dict({"b": 35}), 0.55, 50.),
+            (Predicate.from_dict({"b": 33}), 0.3, 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"b": 40}), 0.35, 25.),
+            (Predicate.from_dict({"b": 38}), 0.55, 35.),
+            (Predicate.from_dict({"b": 35}), 0.3, 50.),
+            (Predicate.from_dict({"b": 33}), 0.05, 60.),
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.6),
+        ignore_nans=True
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"a": 13})
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+def test_sort_triples_by_max_costdiff_ignore_nans_infs() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.35, 6.),
+            (Predicate.from_dict({"a": 17}), 0.58, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.2, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.3, 6.),
+            (Predicate.from_dict({"a": 17}), 0.5, 12.),
+            (Predicate.from_dict({"a": 19}), 0.2, 18.),
+            (Predicate.from_dict({"a": 23}), 0., 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.5, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.99, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.45, 30. + 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.8, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.5, 30. + 60.),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"b": 40}), 0.5, 25.),
+            (Predicate.from_dict({"b": 38}), 0.75, 35.),
+            (Predicate.from_dict({"b": 35}), 0.55, 50.),
+            (Predicate.from_dict({"b": 33}), 0.3, 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"b": 40}), 0.35, 25.),
+            (Predicate.from_dict({"b": 38}), 0.55, 35.),
+            (Predicate.from_dict({"b": 35}), 0.3, 50.),
+            (Predicate.from_dict({"b": 33}), 0.05, 60.),
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.6),
+        ignore_infs=True,
+        ignore_nans=True
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13}),
+    ] or [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"a": 13}),
+        Predicate.from_dict({"b": 45}),
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+def test_sort_triples_by_max_costdiff_generic() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.35, 6.),
+            (Predicate.from_dict({"a": 17}), 0.58, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.2, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.3, 6.),
+            (Predicate.from_dict({"a": 17}), 0.5, 12.),
+            (Predicate.from_dict({"a": 19}), 0.2, 18.),
+            (Predicate.from_dict({"a": 23}), 0., 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.5, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.99, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.45, 30. + 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, 6. + 25.),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.8, 12. + 35.),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, 18. + 50.),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.5, 30. + 60.),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"b": 40}), 0.5, 25.),
+            (Predicate.from_dict({"b": 38}), 0.75, 35.),
+            (Predicate.from_dict({"b": 35}), 0.55, 50.),
+            (Predicate.from_dict({"b": 33}), 0.3, 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"b": 40}), 0.35, 25.),
+            (Predicate.from_dict({"b": 38}), 0.55, 35.),
+            (Predicate.from_dict({"b": 35}), 0.3, 50.),
+            (Predicate.from_dict({"b": 33}), 0.05, 60.),
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.5),
+        ignore_infs=False,
+        ignore_nans=False
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13})
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.6),
+        ignore_infs=False,
+        ignore_nans=True,
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"a": 13})
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.6),
+        ignore_infs=True,
+        ignore_nans=True,
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13}),
+    ] or [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"a": 13}),
+        Predicate.from_dict({"b": 45}),
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+def test_sort_triples_by_max_costdiff_generic_cumulative() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.2, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.7, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.15, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.45, 18.),
+            (Predicate.from_dict({"a": 23}), 0.65, 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, float(6 + 25)),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, float(12 + 35)),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, float(18 + 50)),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, float(30 + 60)),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, float(6 + 25)),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, float(12 + 35)),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, float(18 + 50)),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, float(30 + 60)),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"b": 40}), 0.2, 25.),
+            (Predicate.from_dict({"b": 38}), 0.4, 35.),
+            (Predicate.from_dict({"b": 35}), 0.6, 50.),
+            (Predicate.from_dict({"b": 33}), 0.9, 60.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"b": 40}), 0.15, 25.),
+            (Predicate.from_dict({"b": 38}), 0.35, 35.),
+            (Predicate.from_dict({"b": 35}), 0.5, 50.),
+            (Predicate.from_dict({"b": 33}), 0.8, 60.),
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.5),
+        ignore_infs=False,
+        ignore_nans=False,
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13}),
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+    ] or [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13, "b": 45}),
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.73),
+        ignore_infs=False,
+        ignore_nans=True,
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13})
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+    sort_result = sort_triples_by_max_costdiff(
+        rulesbyif=ifthens,
+        group_calculator=partial(if_group_cost_min_change_correctness_threshold, cor_thres=0.85),
+        ignore_infs=True,
+        ignore_nans=True,
+    )
+    assert [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"b": 45}),
+        Predicate.from_dict({"a": 13}),
+    ] or [ifthen[0] for ifthen in sort_result] == [
+        Predicate.from_dict({"a": 13, "b": 45}),
+        Predicate.from_dict({"a": 13}),
+        Predicate.from_dict({"b": 45}),
+    ]
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sort_result)
+
+def test_sort_triples_KStest() -> None:
+    ifthens = {
+        Predicate.from_dict({"a": 13}):
+        {"Male": (0.2, [
+            (Predicate.from_dict({"a": 15}), 0.2, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.5, 18.),
+            (Predicate.from_dict({"a": 23}), 0.7, 30.),
+        ]),
+        "Female": (0.25, [
+            (Predicate.from_dict({"a": 15}), 0.15, 6.),
+            (Predicate.from_dict({"a": 17}), 0.3, 12.),
+            (Predicate.from_dict({"a": 19}), 0.45, 18.),
+            (Predicate.from_dict({"a": 23}), 0.65, 30.),
+        ])},
+        Predicate.from_dict({"a": 13, "b": 45}):
+        {"Male": (0.3, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.45, float(6 + 25)),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, float(12 + 35)),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.75, float(18 + 50)),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.99, float(30 + 60)),
+        ]),
+        "Female": (0.27, [
+            (Predicate.from_dict({"a": 15, "b": 40}), 0.4, float(6 + 25)),
+            (Predicate.from_dict({"a": 17, "b": 38}), 0.5, float(12 + 35)),
+            (Predicate.from_dict({"a": 19, "b": 35}), 0.7, float(18 + 50)),
+            (Predicate.from_dict({"a": 23, "b": 33}), 0.85, float(30 + 60)),
+        ])},
+        Predicate.from_dict({"b": 45}):
+        {"Male": (0.1, [
+            (Predicate.from_dict({"b": 40}), 0.2, 25.),
+            (Predicate.from_dict({"b": 38}), 0.4, 35.),
+            (Predicate.from_dict({"b": 35}), 0.6, 50.),
+            (Predicate.from_dict({"b": 33}), 0.9, 60.),
+        ]),
+        "Female": (0.13, [
+            (Predicate.from_dict({"b": 40}), 0.15, 25.),
+            (Predicate.from_dict({"b": 38}), 0.35, 35.),
+            (Predicate.from_dict({"b": 35}), 0.5, 50.),
+            (Predicate.from_dict({"b": 33}), 0.8, 60.),
+        ])},
+    }
+    comparators = feature_change_builder(None, num_cols=["a", "b"], cate_cols=[], ord_cols=[], feature_weights={"a": 3, "b": 5})
+    params = ParameterProxy(featureChanges=comparators)
+
+    sorted_rules, unfairness_scores = sort_triples_KStest(
+        rulesbyif=ifthens,
+        affected_population_sizes={"Male": 85, "Female": 100}
+    )
+    assert unfairness_scores == {
+        Predicate.from_dict({"a": 13}): pytest.approx(0.05 * np.sqrt(0.2 * 0.25 * 85 * 100 / (0.2 * 85 + 0.25 * 100))),
+        Predicate.from_dict({"a": 13, "b": 45}): pytest.approx(0.14 * np.sqrt(0.3 * 0.27 * 85 * 100 / (0.3 * 85 + 0.27 * 100))),
+        Predicate.from_dict({"b": 45}): pytest.approx(0.10 * np.sqrt(0.1 * 0.13 * 85 * 100 / (0.1 * 85 + 0.13 * 100))),
+    }
+    assert all(ifthens[ifc] == thencs for ifc, thencs in sorted_rules)
+
diff --git a/tests/sklearn/facts/test_parameters.py b/tests/sklearn/facts/test_parameters.py
new file mode 100644
index 00000000..4a5fd294
--- /dev/null
+++ b/tests/sklearn/facts/test_parameters.py
@@ -0,0 +1,137 @@
+import numpy as np
+import pandas as pd
+from pandas.api.types import CategoricalDtype
+
+import pytest
+
+from aif360.sklearn.detectors.facts.parameters import (
+    naive_feature_change_builder,
+    feature_change_builder
+)
+
+def test_naive_feature_change_builder() -> None:
+    numeric_features = ["a", "b", "c", "d"]
+    categorical_features = ["e", "f", "g", "h"]
+    feat_weights = {"a": 10, "b": 10, "g": 3, "h": 3}
+
+    fns = naive_feature_change_builder(numeric_features, categorical_features, feat_weights)
+
+    assert all(f in fns.keys() for f in categorical_features)
+    assert all(f in fns.keys() for f in numeric_features)
+
+    assert fns["a"](1, 5) == 40
+    assert fns["b"](1, 13) == 120
+    assert fns["c"](1, 5) == 4
+    assert fns["d"](212, 41341) == 41341 - 212
+    assert fns["e"](1, 5) == 1
+    assert fns["f"](434343, 434343) == 0
+    assert fns["g"](1, 5) == 3
+    assert fns["h"](5, 5) == 0
+
+def test_feature_change_builder_no_norm() -> None:
+    numeric_features = ["a", "b", "c", "d"]
+    categorical_features = ["e", "f", "g", "h"]
+    ordinal_features = ["i", "j", "k", "l"]
+    feat_weights = {"a": 10, "b": 10, "g": 3, "h": 3, "i": 11, "k": 13}
+
+    mock_dataset = pd.DataFrame(np.random.randint(0, 1000, (100, 8)), columns=numeric_features + categorical_features)
+    ord_type = CategoricalDtype(categories=list(range(50)), ordered=True)
+    for col in ordinal_features:
+        mock_dataset[col] = pd.Series(np.random.randint(50, size=100), dtype=ord_type)
+
+    fns = feature_change_builder(
+        mock_dataset,
+        numeric_features,
+        categorical_features,
+        ordinal_features,
+        feat_weights,
+        num_normalization=False,
+        feats_to_normalize=None
+    )
+
+    assert fns["a"](1, 5) == 40
+    assert fns["b"](1, 13) == 120
+    assert fns["c"](1, 5) == 4
+    assert fns["d"](212, 41341) == 41341 - 212
+    assert fns["e"](1, 5) == 1
+    assert fns["f"](434343, 434343) == 0
+    assert fns["g"](1, 5) == 3
+    assert fns["h"](5, 5) == 0
+    assert fns["i"](1, 5) == 44
+    assert fns["j"](43, 43) == 0
+    assert fns["k"](1, 5) == 52
+    assert fns["l"](5, 5) == 0
+
+def test_feature_change_builder_norm_numeric() -> None:
+    numeric_features = ["a", "b", "c", "d"]
+    categorical_features = ["e", "f", "g", "h"]
+    ordinal_features = ["i", "j", "k", "l"]
+    feat_weights = {"a": 10, "b": 10, "g": 3, "h": 3, "i": 11, "k": 13}
+
+    mock_dataset = pd.DataFrame(np.random.randint(0, 1000, (100, 8)), columns=numeric_features + categorical_features)
+    ord_type = CategoricalDtype(categories=list(range(50)), ordered=True)
+    for col in ordinal_features:
+        mock_dataset[col] = pd.Series(np.random.randint(50, size=100), dtype=ord_type)
+
+    fns = feature_change_builder(
+        mock_dataset,
+        numeric_features,
+        categorical_features,
+        ordinal_features,
+        feat_weights,
+        num_normalization=True,
+        feats_to_normalize=None
+    )
+
+    max_a, min_a = mock_dataset["a"].max(), mock_dataset["a"].min()
+    assert fns["a"](1, 5) == pytest.approx(40 / (max_a - min_a))
+    max_b, min_b = mock_dataset["b"].max(), mock_dataset["b"].min()
+    assert fns["b"](1, 13) == pytest.approx(120 / (max_b - min_b))
+    max_c, min_c = mock_dataset["c"].max(), mock_dataset["c"].min()
+    assert fns["c"](1, 5) == pytest.approx(4 / (max_c - min_c))
+    max_d, min_d = mock_dataset["d"].max(), mock_dataset["d"].min()
+    assert fns["d"](212, 41341) == pytest.approx((41341 - 212) / (max_d - min_d))
+    assert fns["e"](1, 5) == 1
+    assert fns["f"](434343, 434343) == 0
+    assert fns["g"](1, 5) == 3
+    assert fns["h"](5, 5) == 0
+    assert fns["i"](1, 5) == 44
+    assert fns["j"](43, 43) == 0
+    assert fns["k"](1, 5) == 52
+    assert fns["l"](5, 5) == 0
+
+def test_feature_change_builder_norm_custom() -> None:
+    numeric_features = ["a", "b", "c", "d"]
+    categorical_features = ["e", "f", "g", "h"]
+    ordinal_features = ["i", "j", "k", "l"]
+    feat_weights = {"a": 10, "b": 10, "g": 3, "h": 3, "i": 11, "k": 13}
+
+    mock_dataset = pd.DataFrame(np.random.randint(0, 1000, (100, 8)), columns=numeric_features + categorical_features)
+    ord_type = CategoricalDtype(categories=list(range(50)), ordered=True)
+    for col in ordinal_features:
+        mock_dataset[col] = pd.Series(np.random.randint(50, size=100), dtype=ord_type)
+
+    fns = feature_change_builder(
+        mock_dataset,
+        numeric_features,
+        categorical_features,
+        ordinal_features,
+        feat_weights,
+        num_normalization=True,
+        feats_to_normalize=["a", "b"]
+    )
+
+    max_a, min_a = mock_dataset["a"].max(), mock_dataset["a"].min()
+    assert fns["a"](1, 5) == pytest.approx(40 / (max_a - min_a))
+    max_b, min_b = mock_dataset["b"].max(), mock_dataset["b"].min()
+    assert fns["b"](1, 13) == pytest.approx(120 / (max_b - min_b))
+    assert fns["c"](1, 5) == 4
+    assert fns["d"](212, 41341) == 41341 - 212
+    assert fns["e"](1, 5) == 1
+    assert fns["f"](434343, 434343) == 0
+    assert fns["g"](1, 5) == 3
+    assert fns["h"](5, 5) == 0
+    assert fns["i"](1, 5) == 44
+    assert fns["j"](43, 43) == 0
+    assert fns["k"](1, 5) == 52
+    assert fns["l"](5, 5) == 0
\ No newline at end of file
diff --git a/tests/sklearn/facts/test_predicate.py b/tests/sklearn/facts/test_predicate.py
new file mode 100644
index 00000000..015e1232
--- /dev/null
+++ b/tests/sklearn/facts/test_predicate.py
@@ -0,0 +1,171 @@
+import numpy as np
+import pandas as pd
+import string
+
+from aif360.sklearn.detectors.facts.predicate import Predicate, featureChangePred, recIsValid, drop_two_above
+from aif360.sklearn.detectors.facts.parameters import ParameterProxy
+
+
+def test_equal_different_order() -> None:
+    n_samples = 1000
+    feats = ["".join(np.random.choice(list(string.ascii_letters), l).tolist()) for l in np.random.randint(1, 20, 100)]
+    vals = np.random.randint(1, 1000, size=100)
+
+    feat_vals = list(zip(feats, vals))
+
+    samples = [np.random.permutation(feat_vals) for _ in range(n_samples)]
+    sample_feats = [[f for f, v in sample.tolist()] for sample in samples]
+    sample_vals = [[v for f, v in sample.tolist()] for sample in samples]
+
+    ps = [Predicate(fs, vs) for fs, vs in zip(sample_feats, sample_vals)]
+
+    if ps == []:
+        return
+    assert all(p == ps[0] for p in ps)
+
+def test_featureChangePred() -> None:
+    ifclause = Predicate.from_dict({"a": 1, "b": 2, "c": 3})
+    thenclause = Predicate.from_dict({"a": 1, "b": 4, "c": 7})
+    diff_penal = lambda x, y: abs(x - y)
+    params = ParameterProxy(featureChanges={
+        "a": lambda x, y: abs(x - y),
+        "b": lambda x, y: 200 * abs(x - y),
+        "c": lambda x, y: 50 * abs(x - y)
+    })
+
+    assert featureChangePred(ifclause, thenclause, params=params) == 600
+
+def test_recIsValid_nodrop() -> None:
+    n_feats = 100
+    max_str_len = 20
+    max_val = 1000
+    feats = ["".join(np.random.choice(list(string.ascii_letters), l).tolist()) for l in np.random.randint(1, max_str_len, n_feats)]
+    
+    vals = np.random.randint(1, max_val, size=n_feats)
+    mock_dataset = pd.DataFrame([vals.tolist()] * 100, columns=feats)
+
+    n_samples = 1000
+    for _ in range(n_samples):
+        n_feats_subset = np.random.randint(2, n_feats)
+        ifclause_feats = np.random.choice(feats, n_feats_subset, replace=False)
+        ifclause_vals = np.random.randint(1, max_val, size=n_feats_subset)
+        ifclause = Predicate(features=ifclause_feats.tolist(), values=ifclause_vals.tolist())
+
+        if np.random.rand() > 0.5:
+            # create valid counterfactual
+            thenclause_feats = ifclause_feats
+            thenclause_vals = ifclause_vals.copy()
+            rand_idxs = np.random.choice(list(range(n_feats_subset)), np.random.randint(1, n_feats_subset), replace=False)
+            feat_change = rand_idxs[0]
+            feats_rand = rand_idxs[1:]
+            thenclause_vals[feat_change] = ifclause_vals[feat_change] + 1
+            thenclause_vals[feats_rand] = np.random.randint(1, max_val, size=len(feats_rand))
+
+            thenclause = Predicate(features=thenclause_feats.tolist(), values=thenclause_vals.tolist())
+
+            assert recIsValid(ifclause, thenclause, mock_dataset, drop_infeasible=False)
+        else:
+            # create invalid counterfactuals
+            thenclause_feats_1 = feats[:n_feats_subset + 1]
+            thenclause_vals_1 = np.random.randint(1, max_val, size=len(thenclause_feats_1))
+            thenclause_1 = Predicate(features=thenclause_feats_1, values=thenclause_vals_1.tolist())
+
+            thenclause_2 = Predicate(features=ifclause_feats.tolist(), values=ifclause_vals.tolist())
+
+def test_recIsValid_withdrop() -> None:
+    n_samples = 1000
+    n_feats = 7
+    max_val = 1000
+    feats = ["parents", "age", "ages", "education-num", "PREDICTOR RAT AGE AT LATEST ARREST", "age_cat", "sex"]
+    feats_nosmaller = ["parents", "age", "ages", "education-num", "PREDICTOR RAT AGE AT LATEST ARREST", "age_cat"]
+    vals = np.random.randint(1, max_val, size=n_feats)
+
+    mock_dataset = pd.DataFrame([vals.tolist()] * 100, columns=feats)
+
+    for _ in range(n_samples):
+        n_feats_subset = np.random.randint(1, n_feats)
+        ifclause_feats = np.random.choice(feats, n_feats_subset, replace=False)
+        ifclause_vals = np.random.randint(1, max_val, size=n_feats_subset)
+        dic = {f: v for f, v in zip(ifclause_feats, ifclause_vals)}
+        if "age" in ifclause_feats:
+            rand_int = np.random.randint(1, max_val)
+            dic["age"] = pd.Interval(rand_int, rand_int + np.random.randint(1, max_val))
+        ifclause = Predicate.from_dict(dic)
+
+        for f in feats_nosmaller:
+            if not f in ifclause_feats:
+                continue
+            dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+            
+            dic[f] = ifclause.to_dict()[f] + 1
+            thenclause = Predicate.from_dict(dic)
+            assert recIsValid(ifclause, thenclause, mock_dataset, drop_infeasible=True)
+            dic[f] = ifclause.to_dict()[f] - 1
+            thenclause = Predicate.from_dict(dic)
+            assert not recIsValid(ifclause, thenclause, mock_dataset, drop_infeasible=True)
+        
+        if "sex" in ifclause_feats:
+            dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+            dic["sex"] = ifclause.to_dict()["sex"] + 1
+            assert not recIsValid(ifclause, Predicate.from_dict(dic), mock_dataset, drop_infeasible=True)
+        
+        dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+        rand_feat = np.random.choice(ifclause.features)
+        dic[rand_feat] = "Unknown"
+        thenclause = Predicate.from_dict(dic)
+        assert not recIsValid(ifclause, thenclause, mock_dataset, drop_infeasible=True)
+
+def test_drop_two_above() -> None:
+    n_samples = 1000
+    max_val = 1000
+    feats = ['education-num', 'age', 'PREDICTOR RAT AGE AT LATEST ARREST']
+
+    rand_int = np.random.randint(1, max_val)
+    ages_age = [pd.Interval(rand_int * i, rand_int * (i + 1)) for i in range(100)]
+    ages_rat = np.cumsum(np.random.randint(1, max_val))
+    ages_full = [age.left for age in ages_age] + ages_rat.tolist()
+
+    for _ in range(n_samples):
+        rand_int = np.random.randint(1, max_val)
+        vals = [np.random.randint(1, max_val), np.random.choice(ages_age), np.random.choice(ages_rat)]
+
+        ifclause = Predicate(features=feats, values=vals)
+
+        for offset in range(-3, 3):
+            dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+            dic["education-num"] += offset
+            thenclause = Predicate.from_dict(dic)
+            assert drop_two_above(ifclause, thenclause, ages_full)
+        for offset in range(3, 10):
+            dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+            dic["education-num"] += offset
+            thenclause = Predicate.from_dict(dic)
+            assert not drop_two_above(ifclause, thenclause, ages_full)
+        
+        for offset in range(-3, 3):
+            dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+            new_age = ages_age.index(dic["age"]) + offset
+            if new_age >= 0 and new_age < len(ages_age):
+                dic["age"] = ages_age[ages_age.index(dic["age"]) + offset]
+            else:
+                continue
+            thenclause = Predicate.from_dict(dic)
+            assert drop_two_above(ifclause, thenclause, ages_full)
+        for offset in range(3, 10):
+            dic = {f: v for f, v in zip(ifclause.features, ifclause.values)}
+            new_age = ages_age.index(dic["age"]) + offset
+            if new_age >= 0 and new_age < len(ages_age):
+                dic["age"] = ages_age[ages_age.index(dic["age"]) + offset]
+            else:
+                continue
+            thenclause = Predicate.from_dict(dic)
+            assert not drop_two_above(ifclause, thenclause, ages_full)
+
+def test_unequal_different_type() -> None:
+    p = Predicate(["sex", "age", "number_of_noses"], ["Male", pd.Interval(20, 25), 2])
+    random_stuff = [3, "Hello", pd.Interval(10, 30), 3.14]
+
+    for r in random_stuff:
+        assert p != r
+    
+
diff --git a/tests/sklearn/facts/test_rule_filters.py b/tests/sklearn/facts/test_rule_filters.py
new file mode 100644
index 00000000..10a52da8
--- /dev/null
+++ b/tests/sklearn/facts/test_rule_filters.py
@@ -0,0 +1,277 @@
+from aif360.sklearn.detectors.facts.rule_filters import (
+    remove_rules_below_correctness_threshold,
+    keep_rules_until_correctness_threshold_reached,
+    remove_rules_above_cost_budget,
+    filter_contained_rules_simple,
+    filter_contained_rules_keep_max_bias,
+    delete_fair_rules,
+    keep_only_minimum_change,
+)
+from aif360.sklearn.detectors.facts.predicate import Predicate
+from aif360.sklearn.detectors.facts.misc import calc_costs
+
+rules = {
+    Predicate.from_dict({"a": 1, "b": 2, "c": 3}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "d": "there"}), 0.7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "e": "there"}), 0.5),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "f": "there"}), 0.13),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "g": "there"}), 0.0),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "h": "there"}), 0.9),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "i": "there"}), 0.8),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "j": "there"}), 0.23)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "k": "there"}), 0.7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "l": "there"}), 0.45),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "m": "there"}), 0.57),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "n": "there"}), 0.33),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "o": "there"}), 0.62),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "p": "there"}), 0.88),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "q": "there"}), 0.43)
+        ])
+    },
+    Predicate.from_dict({"a": 13, "b": 23, "c": 3}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.5),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.13),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.0),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.9),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.8),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.23)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.45),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.57),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.33),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.62),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.88),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.43)
+        ])
+    },
+    Predicate.from_dict({"a": 13, "b": 23}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.7),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.5),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.13),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.0),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.9),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.8),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.23)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.7),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.45),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.57),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.33),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.62),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.88),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.43)
+        ])
+    },
+    Predicate.from_dict({"c": 13, "d": 23}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"c": 15, "d": 25}), 0.7),
+            (Predicate.from_dict({"c": 19, "d": 125}), 0.5)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"c": 13, "d": 15}), 0.7),
+            (Predicate.from_dict({"c": 2, "d": 15}), 0.45)
+        ])
+    },
+}
+sg_costs = {
+    Predicate.from_dict({"a": 1, "b": 2, "c": 3}): {"Male": 2., "Female": 5.},
+    Predicate.from_dict({"a": 13, "b": 23, "c": 3}): {"Male": 3., "Female": 13.},
+    Predicate.from_dict({"a": 13, "b": 23}): {"Male": 3., "Female": 9.},
+    Predicate.from_dict({"c": 13, "d": 23}): {"Male": 7., "Female": 7.}
+}
+
+def test_remove_rules_below_correctness_threshold() -> None:
+    ifclause = Predicate.from_dict({"a": 1, "b": 2, "c": 3})
+    rules_loc = calc_costs(rules)
+
+    res1 = remove_rules_below_correctness_threshold(rules_loc, 0.3)
+    assert len(res1[ifclause]["Male"][1]) == 4
+    assert len(res1[ifclause]["Female"][1]) == 7
+
+    res2 = remove_rules_below_correctness_threshold(rules_loc, 0.5)
+    assert len(res2[ifclause]["Male"][1]) == 4
+    assert len(res2[ifclause]["Female"][1]) == 4
+
+    res3 = remove_rules_below_correctness_threshold(rules_loc, 0.7)
+    assert len(res3[ifclause]["Male"][1]) == 3
+    assert len(res3[ifclause]["Female"][1]) == 2
+
+def test_filter_contained_rules_simple() -> None:
+    rules_loc = calc_costs(rules)
+    res = filter_contained_rules_simple(rules_loc)
+    assert len(res.keys()) == 3
+    assert all(k in rules_loc and rules_loc[k] == v for k, v in res.items())
+
+def test_filter_contained_rules_keep_max_bias() -> None:
+    rules_loc = calc_costs(rules)
+    res = filter_contained_rules_keep_max_bias(rules_loc, sg_costs)
+    
+    assert len(res.keys()) == 3
+    assert all(k in rules_loc and rules_loc[k] == v for k, v in res.items())
+    assert Predicate.from_dict({"a": 1, "b": 2, "c": 3}) in res
+    assert Predicate.from_dict({"a": 13, "b": 23, "c": 3}) in res
+
+def test_delete_fair_rules() -> None:
+    rules_loc = calc_costs(rules)
+    res = delete_fair_rules(rules_loc, sg_costs)
+
+    assert len(res.keys()) == 3
+
+def test_keep_only_minimum_change() -> None:
+    rules_loc = calc_costs(rules)
+    res = keep_only_minimum_change(rules_loc)
+
+    assert len(res[Predicate.from_dict({"c": 13, "d": 23})]["Male"][1]) == 2
+    assert len(res[Predicate.from_dict({"c": 13, "d": 23})]["Female"][1]) == 1
+
+
+
+rules_cumulative = {
+    Predicate.from_dict({"a": 1, "b": 2, "c": 3}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "d": "there"}), 0.13, 2),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "e": "there"}), 0.25, 2),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "f": "there"}), 0.39, 4),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "g": "there"}), 0.42, 5),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "h": "there"}), 0.60, 6),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "i": "there"}), 0.70, 6),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "j": "there"}), 0.81, 16)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "k": "there"}), 0.25, 1),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "l": "there"}), 0.32, 1),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "m": "there"}), 0.40, 2),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "n": "there"}), 0.45, 7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "o": "there"}), 0.71, 13),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "p": "there"}), 0.86, 29),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": "hello", "q": "there"}), 0.98, 37)
+        ])
+    },
+    Predicate.from_dict({"a": 13, "b": 23, "c": 3}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.17, 4),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.17, 6),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.17, 6),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.27, 7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.39, 7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.80, 44),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.80, 44)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.07, 2),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.07, 3),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.13, 5),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.13, 7),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.45, 13),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.61, 19),
+            (Predicate.from_dict({"a": 2, "b": 15, "c": 3}), 0.62, 27)
+        ])
+    },
+    Predicate.from_dict({"a": 13, "b": 23}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.23, 4),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.23, 6),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.23, 6),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.35, 7),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.45, 7),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.80, 44),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.80, 44)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.10, 2),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.10, 3),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.17, 5),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.17, 7),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.45, 13),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.70, 19),
+            (Predicate.from_dict({"a": 2, "b": 15}), 0.70, 27)
+        ])
+    },
+    Predicate.from_dict({"c": 13, "d": 23}): {
+        "Male": (0.5, [
+            (Predicate.from_dict({"c": 15, "d": 25}), 0.5, 4),
+            (Predicate.from_dict({"c": 19, "d": 125}), 0.7, 128)
+        ]),
+        "Female": (0.4, [
+            (Predicate.from_dict({"c": 13, "d": 15}), 0.45, 10),
+            (Predicate.from_dict({"c": 2, "d": 15}), 0.7, 17)
+        ])
+    },
+}
+
+def test_remove_rules_below_correctness_threshold() -> None:
+    ifclause = Predicate.from_dict({"a": 1, "b": 2, "c": 3})
+
+    res1 = remove_rules_below_correctness_threshold(rules_cumulative, 0.3)
+    assert len(res1[ifclause]["Male"][1]) == 5
+    assert len(res1[ifclause]["Female"][1]) == 6
+
+    res2 = remove_rules_below_correctness_threshold(rules_cumulative, 0.5)
+    assert len(res2[ifclause]["Male"][1]) == 3
+    assert len(res2[ifclause]["Female"][1]) == 3
+
+    res3 = remove_rules_below_correctness_threshold(rules_cumulative, 0.7)
+    assert len(res3[ifclause]["Male"][1]) == 2
+    assert len(res3[ifclause]["Female"][1]) == 3
+
+def test_keep_rules_until_correctness_threshold_reached() -> None:
+    ifclause = Predicate.from_dict({"a": 1, "b": 2, "c": 3})
+
+    res1 = keep_rules_until_correctness_threshold_reached(rules_cumulative, 0.3)
+    assert len(res1[ifclause]["Male"][1]) == 3
+    assert len(res1[ifclause]["Female"][1]) == 2
+
+    res2 = keep_rules_until_correctness_threshold_reached(rules_cumulative, 0.5)
+    assert len(res2[ifclause]["Male"][1]) == 5
+    assert len(res2[ifclause]["Female"][1]) == 5
+
+    res3 = keep_rules_until_correctness_threshold_reached(rules_cumulative, 0.7)
+    assert len(res3[ifclause]["Male"][1]) == 6
+    assert len(res3[ifclause]["Female"][1]) == 5
+
+def test_remove_rules_above_cost_budget() -> None:
+    ifclause = Predicate.from_dict({"a": 1, "b": 2, "c": 3})
+
+    res1 = remove_rules_above_cost_budget(rules_cumulative, 3)
+    assert len(res1[ifclause]["Male"][1]) == 2
+    assert len(res1[ifclause]["Female"][1]) == 3
+
+    res2 = remove_rules_above_cost_budget(rules_cumulative, 7)
+    assert len(res2[ifclause]["Male"][1]) == 6
+    assert len(res2[ifclause]["Female"][1]) == 4
+
+    res3 = remove_rules_above_cost_budget(rules_cumulative, 17)
+    assert len(res3[ifclause]["Male"][1]) == 7
+    assert len(res3[ifclause]["Female"][1]) == 5
+
+def test_filter_contained_rules_simple() -> None:
+    res = filter_contained_rules_simple(rules_cumulative)
+    assert len(res.keys()) == 3
+    assert all(k in rules_cumulative and rules_cumulative[k] == v for k, v in res.items())
+
+def test_filter_contained_rules_keep_max_bias() -> None:
+    res = filter_contained_rules_keep_max_bias(rules_cumulative, sg_costs)
+    assert len(res.keys()) == 3
+    assert all(k in rules_cumulative and rules_cumulative[k] == v for k, v in res.items())
+    assert Predicate.from_dict({"a": 1, "b": 2, "c": 3}) in res.keys()
+    assert Predicate.from_dict({"a": 13, "b": 23, "c": 3}) in res.keys()
+
+def test_delete_fair_rules_cumulative() -> None:
+    res = delete_fair_rules(rules_cumulative, sg_costs)
+
+    assert len(res.keys()) == 3
+
+def test_keep_only_minimum_change() -> None:
+    res = keep_only_minimum_change(rules_cumulative)
+
+    assert len(res[Predicate.from_dict({"c": 13, "d": 23})]["Male"][1]) == 1
+    assert len(res[Predicate.from_dict({"c": 13, "d": 23})]["Female"][1]) == 1
\ No newline at end of file
diff --git a/tests/sklearn/facts/test_utils.py b/tests/sklearn/facts/test_utils.py
new file mode 100644
index 00000000..c20ebfc0
--- /dev/null
+++ b/tests/sklearn/facts/test_utils.py
@@ -0,0 +1,80 @@
+import os
+import pandas as pd
+import numpy as np
+from numpy.typing import ArrayLike
+
+from aif360.sklearn.detectors.facts.utils import (
+    save_state,
+    save_rules_by_if,
+    save_model,
+    save_object,
+    save_test_data_used,
+    load_state,
+    load_model,
+    load_object,
+    load_rules_by_if,
+    load_test_data_used
+)
+from aif360.sklearn.detectors.facts.predicate import Predicate
+
+class MockModel:
+    def predict(self, X: ArrayLike) -> ArrayLike:
+        return np.ones(X.shape[0])
+    
+def test_state() -> None:
+    mock_rules = {"a": 1, "b": 2}
+    mock_X = pd.DataFrame([[1, 2], [3, 4]], columns=["e", "f"])
+    mock_model = MockModel()
+
+    save_state("temp", mock_rules, mock_X, mock_model)
+    r, X, m = load_state("temp")
+    assert  r == mock_rules
+    assert (X == mock_X).all().all()
+    os.remove("temp")
+
+def test_rules_by_if() -> None:
+    mock_rules = {"a": 1, "b": 2}
+
+    save_rules_by_if("temp", mock_rules)
+    r = load_rules_by_if("temp")
+    assert  r == mock_rules
+    os.remove("temp")
+
+def test_model() -> None:
+    mock_model = MockModel()
+
+    save_model("temp", mock_model)
+    m = load_model("temp")
+    assert m.predict(pd.DataFrame([[1, 2], [3, 4]])).shape[0] == 2
+    os.remove("temp")
+
+def test_object() -> None:
+    obj = 2
+    save_object("temp", obj)
+    o = load_object("temp")
+    assert o == obj
+
+    obj = [1, 2, 3, 4, 5]
+    save_object("temp", obj)
+    o = load_object("temp")
+    assert o == obj
+
+    obj = {1, 2, 3, 4, 5}
+    save_object("temp", obj)
+    o = load_object("temp")
+    assert o == obj
+
+    obj = {"one": 1, "two": 2}
+    save_object("temp", obj)
+    o = load_object("temp")
+    assert o == obj
+
+    os.remove("temp")
+
+def test_test_data_used() -> None:
+    mock_X = pd.DataFrame([[1, 2], [3, 4]], columns=["e", "f"])
+
+    save_test_data_used("temp", mock_X)
+    X = load_test_data_used("temp")
+    assert (X == mock_X).all().all()
+    os.remove("temp")