diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 66e9049..2e19f9e 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -448,3 +448,57 @@ jobs: - name: Step 5 - Test GroupedCEExplainer run: python ./tests/gce/test_gce.py + + build-glance-on-py310: + # The type of runner that the job will run on + runs-on: "${{ matrix.os }}" + strategy: + matrix: + #os: [ubuntu-18.04, ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-20.04, macos-latest, windows-latest] + python-version: ["3.10"] + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Step 1 - checkout aix360 repository + uses: actions/checkout@v3 + + - name: Step 2 - set up python version + uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + + - name: Step 3 - upgrade setuptools + run: pip3 install pytest nbmake wheel --upgrade setuptools + + - name: Step 4 - Install aix360 with dipvae algorithm related dependencies + run: pip3 install .[glance] + + - name: Step 5 - Test Base + run: pytest ./tests/glance/test_base.py + + - name: Step 6 - Test Counterfactual Costs + run: pytest ./tests/glance/test_counterfactual_costs.py + + - name: Step 7 - Test Counterfactual Tree + run: pytest ./tests/glance/test_counterfactual_tree.py + + - name: Step 8 - Test Iterative Merges + run: pytest ./tests/glance/test_iterative_merges.py + + - name: Step 9 - Test KMeans + run: pytest ./tests/glance/test_KMeans.py + + - name: Step 10 - Test Local Cfs + run: pytest ./tests/glance/test_local_cfs.py + + - name: Step 11 - Test Node + run: pytest ./tests/glance/test_node.py + + - name: Step 12 - Test Phase2 + run: pytest ./tests/glance/test_phase2.py + + - name: Step 13 - Test Utils + run: pytest ./tests/glance/test_utils.py + + \ No newline at end of file diff --git a/aix360/algorithms/glance/__init__.py b/aix360/algorithms/glance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aix360/algorithms/glance/base.py b/aix360/algorithms/glance/base.py new file mode 100644 index 0000000..39332e1 --- /dev/null +++ b/aix360/algorithms/glance/base.py @@ -0,0 +1,115 @@ +from abc import ABC, abstractmethod +import pandas as pd +import numpy as np + + +class ClusteringMethod(ABC): + """ + Abstract base class for clustering methods. + """ + + def __init__(self): + """ + Initialize the ClusteringMethod. + """ + pass + + @abstractmethod + def fit(self, data: pd.DataFrame): + """ + Fit the clustering model on the given data. + + Parameters: + - data (pd.DataFrame): DataFrame of input data to fit the model. + """ + pass + + @abstractmethod + def predict(self, instances: pd.DataFrame) -> np.ndarray: + """ + Predict the cluster labels for the given instances. + + Parameters: + - instances (pd.DataFrame): DataFrame of input instances. + + Returns: + - cluster_labels (np.ndarray): Array of cluster labels for each instance. + """ + pass + + +class LocalCounterfactualMethod(ABC): + """ + Abstract base class for local counterfactual methods. + """ + + def __init__(self): + """ + Initialize the LocalCounterfactualMethod. + """ + pass + + @abstractmethod + def fit(self, **kwargs): + """ + Fit the counterfactual method. + + Parameters: + - **kwargs: Additional keyword arguments for fitting. + """ + pass + + @abstractmethod + def explain_instances( + self, instances: pd.DataFrame, num_counterfactuals: int + ) -> pd.DataFrame: + """ + Find the local counterfactuals for the given instances. + + Parameters: + - instances (pd.DataFrame): DataFrame of input instances for which counterfactuals are desired. + - num_counterfactuals (int): Number of counterfactuals to generate for each instance. + + Returns: + - counterfactuals (pd.DataFrame): DataFrame of counterfactual instances. + """ + pass + + +class GlobalCounterfactualMethod(ABC): + """ + Abstract base class for global counterfactual methods. + """ + + def __init__(self, **kwargs): + """ + Initialize the LocalCounterfactualMethod. + + Parameters: + - **kwargs: Additional keyword arguments for init. + """ + pass + + @abstractmethod + def fit(self, X, y, **kwargs): + """ + Fit the counterfactual method. + + Parameters: + - **kwargs: Additional keyword arguments for fitting. + """ + pass + + @abstractmethod + def explain_group(self, instances: pd.DataFrame) -> pd.DataFrame: + """ + Find the global counterfactuals for the given group of instances. + + Parameters: + - instances (pd.DataFrame, optional): DataFrame of input instances for which global counterfactuals are desired. + If None, explain the whole group of affected instances. + + Returns: + - counterfactuals (pd.DataFrame): DataFrame of counterfactual instances. + """ + pass diff --git a/aix360/algorithms/glance/clustering/__init__.py b/aix360/algorithms/glance/clustering/__init__.py new file mode 100644 index 0000000..173b0eb --- /dev/null +++ b/aix360/algorithms/glance/clustering/__init__.py @@ -0,0 +1 @@ +from .kmeans import KMeansMethod diff --git a/aix360/algorithms/glance/clustering/kmeans.py b/aix360/algorithms/glance/clustering/kmeans.py new file mode 100644 index 0000000..9ac1db2 --- /dev/null +++ b/aix360/algorithms/glance/clustering/kmeans.py @@ -0,0 +1,60 @@ +from ..base import ClusteringMethod +from sklearn.cluster import KMeans + + +class KMeansMethod(ClusteringMethod): + """ + Implementation of a clustering method using KMeans. + + This class provides an interface to apply KMeans clustering to a dataset. + """ + + def __init__(self, num_clusters, random_seed): + """ + Initializes the KMeansMethod class. + + Parameters: + ---------- + num_clusters : int + The number of clusters to form as well as the number of centroids to generate. + random_seed : int + A seed for the random number generator to ensure reproducibility. + """ + + self.num_clusters = num_clusters + self.random_seed = random_seed + self.model = KMeans() + + def fit(self, data): + """ + Fits the KMeans model on the provided dataset. + + Parameters: + ---------- + data : array-like or sparse matrix, shape (n_samples, n_features) + Training instances to cluster. + + Returns: + ------- + None + """ + self.model = KMeans( + n_clusters=self.num_clusters, n_init=10, random_state=self.random_seed + ) + self.model.fit(data) + + def predict(self, instances): + """ + Predicts the nearest cluster each sample in the provided data belongs to. + + Parameters: + ---------- + instances : array-like or sparse matrix, shape (n_samples, n_features) + New data to predict. + + Returns: + ------- + labels : array, shape (n_samples,) + Index of the cluster each sample belongs to. + """ + return self.model.predict(instances) diff --git a/aix360/algorithms/glance/counterfactual_costs.py b/aix360/algorithms/glance/counterfactual_costs.py new file mode 100644 index 0000000..1df8056 --- /dev/null +++ b/aix360/algorithms/glance/counterfactual_costs.py @@ -0,0 +1,58 @@ +from typing import Callable, List, Dict +import numpy as np +import pandas as pd + + +def build_dist_func_dataframe( + X: pd.DataFrame, + numerical_columns: List[str], + categorical_columns: List[str], + n_bins: int = 10, +) -> Callable[[pd.DataFrame, pd.DataFrame], pd.Series]: + """ + Builds and returns a custom distance function for computing distances between rows of two DataFrames based on specified numerical and categorical columns. + + For numerical columns, the values are first binned into intervals based on the provided number of bins (`n_bins`). + The distance between numerical features is computed as the sum of the absolute differences between binned values. For categorical columns, the distance is calculated as the number of mismatched categorical values. + + Parameters: + ---------- + X : pd.DataFrame + The reference DataFrame used to determine the bin intervals for numerical columns. + numerical_columns : List[str] + List of column names in `X` that contain numerical features. + categorical_columns : List[str] + List of column names in `X` that contain categorical features. + n_bins : int, optional + The number of bins to use when normalizing numerical columns, by default 10. + + Returns: + ------- + Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A distance function that takes two DataFrames as input (`X1` and `X2`) and returns a Series of distances between corresponding rows in `X1` and `X2`. + + The distance function works as follows: + - For numerical columns: the absolute differences between binned values are summed. + - For categorical columns: the number of mismatches between values is counted. + """ + feat_intervals = { + col: ((max(X[col]) - min(X[col])) / n_bins) for col in numerical_columns + } + + def bin_numericals(instances: pd.DataFrame): + ret = instances.copy() + for col in numerical_columns: + ret[col] /= feat_intervals[col] + return ret + + def dist_f(X1: pd.DataFrame, X2: pd.DataFrame) -> pd.Series: + X1 = bin_numericals(X1) + X2 = bin_numericals(X2) + + ret = (X1[numerical_columns] - X2[numerical_columns]).abs().sum(axis="columns") + ret += (X1[categorical_columns] != X2[categorical_columns]).astype(int).sum(axis="columns") + + return ret + + return dist_f + diff --git a/aix360/algorithms/glance/counterfactual_tree/__init__.py b/aix360/algorithms/glance/counterfactual_tree/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aix360/algorithms/glance/counterfactual_tree/counterfactual_tree.py b/aix360/algorithms/glance/counterfactual_tree/counterfactual_tree.py new file mode 100644 index 0000000..09162c9 --- /dev/null +++ b/aix360/algorithms/glance/counterfactual_tree/counterfactual_tree.py @@ -0,0 +1,467 @@ +from typing import Union, Any, List, Optional, Dict, Tuple, Callable +from ..base import GlobalCounterfactualMethod, LocalCounterfactualMethod +from ..iterative_merges.iterative_merges import C_GLANCE, _select_action_max_eff +import pandas as pd +from ..utils.metadata_requests import _decide_local_cf_method +from ..utils.centroid import centroid_pandas +from ..utils.action import extract_actions_pandas, apply_action_pandas +from sklearn.inspection import permutation_importance +from ..iterative_merges.iterative_merges import cumulative +from ..counterfactual_costs import build_dist_func_dataframe +from .node import Node +import numpy as np +from tqdm import tqdm + + +class T_GLANCE: + """ + A class to generate counterfactual explanations using a decision tree-like structure. + + This class allows users to create a tree structure for counterfactual generation, + optimizing effectiveness and cost based on specified features. It supports both local + and global methods for generating counterfactuals. + + Attributes: + ---------- + model : Any + The predictive model used for generating counterfactuals. + split_features : Union[List, int] + Features to split the tree. Can be a list of feature names or an integer specifying + the number of top features to use based on permutation importance. + partition_counterfactuals : int + The number of partitions to create for counterfactuals. + child_count : int + The number of children each node can have. + global_method : Union[GlobalCounterfactualMethod, str] + The global counterfactual generation method to use. + local_method : Union[LocalCounterfactualMethod, str] + The local counterfactual generation method to use. + num_local_counterfactuals : int + The number of local counterfactuals to generate. + node : Node + The root node of the counterfactual tree. + node_instances : pd.DataFrame + The instances that were used to build the counterfactual tree. + dist_func_dataframe : Callable + A distance function for calculating distances between instances. + + Methods: + ------- + fit(X, y, train_dataset=None, feat_to_vary="all", random_seed=13, numeric_features_names=None, categorical_features_names=None): + Fits the counterfactual tree to the provided data. + + _local_group_eff_cost(instances): + Calculates the effectiveness and cost of local counterfactuals for a group of instances. + + _group_eff_cost(instances): + Calculates the effectiveness and cost of counterfactuals for a group of instances, + utilizing local or global methods. + + partition_group(instances): + Partitions the group of instances into a tree structure based on the specified features. + + cumulative_leaf_actions(): + Computes the total effectiveness and cost of actions taken from leaf nodes of the tree. + + """ + + def __init__( + self, + model: Any, + split_features: Union[List, int] = None, + partition_counterfactuals: int = None, + child_count: int = 2, + global_method: Union[GlobalCounterfactualMethod, str] = None, + local_method: Union[LocalCounterfactualMethod, str] = None, + num_local_counterfactuals: int = 100, + ): + """ + Initializes the CounterfactualTree instance. + + Parameters: + ---------- + model : Any + The predictive model to use for generating counterfactuals. + split_features : Union[List, int], optional + Features to split the tree. If None, uses permutation importance to select. + If an integer, selects the top N features. + partition_counterfactuals : int, optional + Number of partitions for counterfactual generation. + child_count : int, optional + Number of children for each node in the tree. Default is 2. + global_method : Union[GlobalCounterfactualMethod, str], optional + The global counterfactual generation method to use. + local_method : Union[LocalCounterfactualMethod, str], optional + The local counterfactual generation method to use. + num_local_counterfactuals : int, optional + Number of local counterfactuals to generate. Default is 100. + """ + self.model = model + self.split_features = split_features + self.partition_counterfactuals = partition_counterfactuals + self.child_count = child_count + self.global_method = global_method + self.local_method = local_method + self.num_local_counterfactuals = num_local_counterfactuals + + def fit( + self, + X: pd.DataFrame, + y: pd.Series, + train_dataset: Optional[pd.DataFrame] = None, + feat_to_vary: Optional[Union[List[str], str]] = "all", + random_seed: int = 13, + numeric_features_names: Optional[List[str]] = None, + categorical_features_names: Optional[List[str]] = None, + ): + """ + Fits the counterfactual tree to the provided data. + + Parameters: + ---------- + X : pd.DataFrame + Features of the dataset. + y : pd.Series + Target variable. + train_dataset : Optional[pd.DataFrame], optional + The training dataset to use for local counterfactual generation methods. + feat_to_vary : Optional[Union[List[str], str]], optional + Features to vary in counterfactual generation. Default is "all". + random_seed : int, optional + Random seed for reproducibility. Default is 13. + numeric_features_names : Optional[List[str]], optional + List of numeric feature names. If None, they will be inferred from X. + categorical_features_names : Optional[List[str]], optional + List of categorical feature names. If None, they will be inferred from X. + """ + if self.split_features == None: + perm_importance = permutation_importance( + self.model, X, y, n_repeats=30, random_state=42 + ) + + feature_names = X.columns + + mean_importance = perm_importance.importances_mean + top_indices = mean_importance.argsort()[-2:][::-1] + top_features = feature_names[top_indices] + + self.split_features = list(top_features) + elif isinstance(self.split_features, int): + perm_importance = permutation_importance( + self.model, X, y, n_repeats=30, random_state=42 + ) + + feature_names = X.columns + + mean_importance = perm_importance.importances_mean + top_indices = mean_importance.argsort()[-self.split_features:][::-1] + top_features = feature_names[top_indices] + + self.split_features = list(top_features) + + self.split_values = _get_split_values(X, self.split_features, self.child_count) + + if numeric_features_names is None: + if categorical_features_names is None: + numeric_features_names = X.select_dtypes( + include=["number"] + ).columns.tolist() + else: + numeric_features_names = X.columns.difference( + categorical_features_names + ).tolist() + if categorical_features_names is None: + categorical_features_names = X.columns.difference( + numeric_features_names + ).tolist() + + self.numerical_features_names = numeric_features_names + self.categorical_features_names = categorical_features_names + self.X = X + self.y = y + self.train_dataset = train_dataset + self.random_seed = random_seed + self.feat_to_vary = feat_to_vary + self.dist_func_dataframe = build_dist_func_dataframe( + self.X, self.numerical_features_names, self.categorical_features_names + ) + + if self.local_method == None: + backup = "Dice" + else: + backup = self.local_method + self.cf_generator_backup = _decide_local_cf_method( + method=backup, + model=self.model, + train_dataset=self.train_dataset, + numeric_features_names=self.numerical_features_names, + categorical_features_names=self.categorical_features_names, + feat_to_vary=self.feat_to_vary, + random_seed=random_seed, + ) + + if self.global_method == None and self.local_method == None: + self.generation_method = "Global-IM" + if self.partition_counterfactuals == None: + self.partition_counterfactuals = 3 + self.cf_generator = C_GLANCE( + self.model, final_clusters=self.partition_counterfactuals, verbose=False + ) + if self.train_dataset is None: + raise ValueError( + "You need to pass train_dataset for Dice if you want default Iterative merges." + ) + self.cf_generator.fit(X, y, self.train_dataset) + elif self.global_method != None: + self.generation_method = "Global" + if self.partition_counterfactuals == None: + self.partition_counterfactuals = 3 + self.cf_generator = self.global_method + else: + self.generation_method = "Local" + if self.partition_counterfactuals == None: + self.partition_counterfactuals = 1 + + def _local_group_eff_cost(self, instances): + """ + Calculates the effectiveness and cost of local counterfactuals for a group of instances. + + Parameters: + ---------- + instances : pd.DataFrame + The group of instances to analyze. + + Returns: + ------- + Tuple[float, float, List[Any]] + A tuple containing the effectiveness, cost, and list of actions. + """ + centroid = centroid_pandas( + instances, + self.numerical_features_names, + self.categorical_features_names, + ) + cfs = self.cf_generator_backup.explain_instances( + centroid, + self.num_local_counterfactuals, + ) + if cfs.shape[0] == 0: + return 0, 0, [] + + actions = extract_actions_pandas( + X=pd.concat([centroid] * cfs.shape[0]).set_index( + cfs.index + ), + cfs=cfs, + categorical_features=self.categorical_features_names, + numerical_features=self.numerical_features_names, + categorical_no_action_token="-", + ) + # actions = [action for _, action in actions.iterrows()] + actions_info = _select_action_max_eff( + self.model, + instances, + actions, + self.dist_func_dataframe, + self.numerical_features_names, + self.categorical_features_names, + self.partition_counterfactuals, + ) + if type(actions_info) is not list: + actions_info = [actions_info] + actions = [action for _, _, action in actions_info] + eff, cost = cumulative( + self.model, + instances, + actions, + self.dist_func_dataframe, + self.numerical_features_names, + self.categorical_features_names, + "-", + ) + return eff, cost, actions + + def _group_eff_cost( + self, + instances, + ): + """ + Calculates the effectiveness and cost of counterfactuals for a group of instances, + utilizing local or global methods. + + Parameters: + ---------- + instances : pd.DataFrame + The group of instances to analyze. + + Returns: + ------- + Tuple[float, float, List[Any]] + A tuple containing the effectiveness, cost, and list of actions. + """ + + if self.generation_method == "Local": + return self._local_group_eff_cost(instances) + elif self.generation_method == "Global-IM": + clusters = min(100, len(instances)) + if clusters < self.partition_counterfactuals: + return self._local_group_eff_cost(instances) + else: + self.cf_generator.initial_clusters = clusters + eff, cost = self.cf_generator.explain_group(instances) + actions = self.cf_generator.global_actions() + elif self.generation_method == 'Global': + eff, cost = self.cf_generator.explain_group(instances) + actions = self.cf_generator.global_actions() + else: + raise ValueError("Generation method does not exist") + + + return eff, cost, actions + + def partition_group(self, instances: pd.DataFrame): + """ + Partitions the group of instances into a tree structure based on the specified features. + + Parameters: + ---------- + instances : pd.DataFrame + The group of instances to partition. + + Returns: + ------- + Node + The root node of the partitioned tree. + """ + + def _partition_group( + group, split_features, eff_prec=None, cost_prec=None, actions_prec=None + ): + + if eff_prec == None: + eff_node, cost_node, actions = self._group_eff_cost(group) + else: + eff_node, cost_node, actions = eff_prec, cost_prec, actions_prec + + node = Node( + effectiveness=eff_node, cost=cost_node, actions=actions, size=len(group) + ) + possible_splits = [] + + for feature in split_features: + eff_children, cost_children = 0, 0 + children_info = [] + + for feature_split_values in self.split_values[feature]: + split_df = group[group[feature].isin(feature_split_values)] + + if not split_df.empty: + eff_child, cost_child, actions = self._group_eff_cost(split_df) + eff_children += eff_child + cost_children += cost_child + children_info.append( + ( + feature_split_values, + split_df, + eff_child, + cost_child, + actions, + ) + ) + + possible_splits.append( + (feature, eff_children, cost_children, children_info) + ) + + if len(possible_splits) == 0: + return node + + possible_splits = sorted(possible_splits, key=lambda x: -x[1]) + node.split_feature = possible_splits[0][0] + split_features.remove(node.split_feature) + child_info = possible_splits[0][3] + + for child in child_info: + child_node = _partition_group( + child[1], split_features, child[2], child[3], child[4] + ) + + node.add_child(child[0], child_node) + + return node + + self.node = _partition_group(instances, self.split_features) + self.node_instances = instances + return self.node + + def cumulative_leaf_actions(self): + """ + Computes the total effectiveness and cost of actions taken from leaf nodes of the tree. + + Returns: + ------- + Tuple[float, float, int] + A tuple containing the total effectiveness, total cost, and the number of actions taken. + """ + eff, cost = cumulative( + self.model, + self.node_instances, + self.node.return_leafs_actions(), + self.dist_func_dataframe, + self.numerical_features_names, + self.categorical_features_names, + categorical_no_action_token="-", + ) + + print(f"\nTOTAL EFFECTIVENESS: {eff / self.node_instances.shape[0]:.2%}") + print(f"\nTOTAL COST: {(cost / eff if eff > 0 else 0):.2f}") + + return eff, cost, len(self.node.return_leafs_actions()) + + +def _split_list(lst, n): + """ + Splits a list into n approximately equal parts. + + Parameters: + ---------- + lst : list + The list to split. + n : int + The number of parts to split the list into. + + Returns: + ------- + Generator + A generator yielding the split parts of the list. + """ + k, m = divmod(len(lst), n) + return (lst[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)) + + +def _get_split_values(X, split_features, child_count): + """ + Generates split values for the specified features. + + Parameters: + ---------- + X : pd.DataFrame + The dataset containing features. + split_features : List[str] + The list of features to create split values for. + child_count : int + The number of child splits to create for each feature. + + Returns: + ------- + Dict[str, List[List[Any]]] + A dictionary mapping each feature to its corresponding split values. + """ + split_values = {} + for feature in split_features: + lst = sorted(list(X[feature].unique())) + split_count = child_count + if child_count == -1: + split_count = len(lst) + split_values[feature] = list(_split_list(lst, split_count)) + return split_values diff --git a/aix360/algorithms/glance/counterfactual_tree/node.py b/aix360/algorithms/glance/counterfactual_tree/node.py new file mode 100644 index 0000000..a029c81 --- /dev/null +++ b/aix360/algorithms/glance/counterfactual_tree/node.py @@ -0,0 +1,245 @@ +class Node: + """ + A class representing a node in a decision tree structure. + + Each node can have child nodes, actions associated with it, and metrics + such as effectiveness and cost. This class provides methods to add child + nodes, retrieve actions from leaf nodes, and visualize the tree structure. + + Attributes: + ---------- + split_feature : str or None + The feature used to split the data at this node. Default is None. + + actions : list or None + A list of actions associated with this node. Default is None. + + effectiveness : float + The effectiveness of the actions taken at this node. Default is 0. + + cost : float + The total cost associated with the actions at this node. Default is 0. + + size : int + The number of instances or data points at this node. Default is 0. + + children : dict + A dictionary mapping from subgroup values to child nodes. + + Methods: + ------- + add_child(subgroup, child_node): + Adds a child node to this node. + + return_leafs_actions(): + Returns all actions from the leaf nodes in the subtree rooted at this node. + + to_igraph(numeric_features=[]): + Converts the tree structure to an igraph object for visualization. + + display_igraph_jupyter(numeric_features=[]): + Displays the tree structure in a Jupyter notebook using matplotlib and igraph. + """ + + def __init__( + self, + split_feature=None, + actions=None, + effectiveness=0, + cost=0, + size=0, + ): + """ + Initializes a new Node instance. + + Parameters: + ---------- + split_feature : str or None + The feature used to split the data at this node. Default is None. + + actions : list or None + A list of actions associated with this node. Default is None. + + effectiveness : float + The effectiveness of the actions taken at this node. Default is 0. + + cost : float + The total cost associated with the actions at this node. Default is 0. + + size : int + The number of instances or data points at this node. Default is 0. + """ + self.split_feature = split_feature + self.effectiveness = effectiveness + self.cost = cost + self.size = size + self.children = {} + self.actions = actions + + def add_child(self, subgroup, child_node): + """ + Adds a child node to this node. + + Parameters: + ---------- + subgroup : any + The value associated with the child node. + + child_node : Node + The child node to be added. + """ + print() + self.children[tuple(subgroup)] = child_node + + def return_leafs_actions(self): + """ + Returns all actions from the leaf nodes in the subtree rooted at this node. + + Returns: + ------- + list + A flattened list of actions from the leaf nodes. + """ + cfs_list = [] + + def find_leafs_actions(node): + if node.children == {}: + cfs_list.append(node.actions) + else: + for child_node in node.children.values(): + find_leafs_actions(child_node) + + find_leafs_actions(self) + return [action for sublist in cfs_list for action in sublist] + + + def to_igraph(self, numeric_features=[]): + """ + Converts the tree structure to an igraph object for visualization. + + Parameters: + ---------- + numeric_features : list + A list of numeric feature names used for processing node labels. + + Returns: + ------- + ig.Graph + An igraph object representing the tree structure. + """ + import igraph as ig + + def pre_order(node, timer, reg): + node.id = timer + reg[timer] = node + timer += 1 + for _value, child_node in node.children.items(): + timer = pre_order(child_node, timer=timer, reg=reg) + return timer + + node_registry = dict() + n_nodes = pre_order(self, timer=0, reg=node_registry) + + def pre_order_2(node): + max_value = 0 + for value, child_node in node.children.items(): + if node.split_feature in numeric_features: + if max(value) > max_value: + max_value = max(value) + max_list = child_node + for value, child_node in node.children.items(): + if node.split_feature in numeric_features: + if child_node != max_list: + val = max(value) + for value, child_node in node.children.items(): + child_node.data_feat = node.split_feature + if node.split_feature in numeric_features: + if child_node == max_list: + child_node.data_val = f"> {val}" + else: + child_node.data_val = f"<= {val}" + else: + child_node.data_val = value if len(value) > 1 else value[0] + pre_order_2(child_node) + + pre_order_2(self) + self.data_feat = "all" + self.data_val = "-" + + graph = ig.Graph(directed=True) + + def add_nodes(node): + size = node.size + num_flipped = node.effectiveness + cost_sum = node.cost + eff = num_flipped / size + actions = [action[action != "-"].to_dict() for action in node.actions] + actions_ = [] + + for action in actions: + action_copy = action + for k, v in action_copy.items(): + if k in numeric_features: + action_copy[k] = round(v, 3) + actions_.append(action_copy) + + if num_flipped == 0: + cost = 0 + else: + cost = cost_sum / num_flipped + + label = f"{eff=:.2%}\n{cost=:.2f}\n{size=}\n" + for action in actions_: + label += f"{action}\n" + + graph.add_vertex( + node.id, + label=label, + ) + for _child_name, child in node.children.items(): + add_nodes(child) + + add_nodes(self) + + def add_edges(node): + for _child_name, child in node.children.items(): + graph.add_edge( + node.id, child.id, label=f"{node.split_feature} {child.data_val}" + ) + add_edges(child) + + add_edges(self) + + return graph + + def display_igraph_jupyter(self, numeric_features=[]): + """ + Displays the tree structure in a Jupyter notebook using matplotlib and igraph. + + Parameters: + ---------- + numeric_features : list + A list of numeric feature names used for processing node labels. + """ + import igraph as ig + import matplotlib.pyplot as plt + + g = self.to_igraph(numeric_features=numeric_features) + fig, ax = plt.subplots() + fig.set_figheight(10) + fig.set_figwidth(40) + vertex_labels = g.vs["label"] + edge_labels = g.es["label"] + ig.plot( + g, + target=ax, + layout="reingold_tilford", + vertex_size=55, + # vertex_frame_width=10.0, + # vertex_frame_color="white", + vertex_label=vertex_labels, + edge_label=edge_labels, + vertex_label_size=8.0, + ) + ax.invert_yaxis() + \ No newline at end of file diff --git a/aix360/algorithms/glance/iterative_merges/__init__.py b/aix360/algorithms/glance/iterative_merges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aix360/algorithms/glance/iterative_merges/iterative_merges.py b/aix360/algorithms/glance/iterative_merges/iterative_merges.py new file mode 100644 index 0000000..b81482e --- /dev/null +++ b/aix360/algorithms/glance/iterative_merges/iterative_merges.py @@ -0,0 +1,1281 @@ +from typing import Union, Any, List, Optional, Dict, Tuple, Callable, Literal +import math +import numbers +import itertools +from tqdm import tqdm +import warnings +from colorama import Fore, Style + +import numpy as np +import pandas as pd + +from scipy.cluster.hierarchy import DisjointSet +from sklearn.preprocessing import OneHotEncoder +from sklearn.compose import ColumnTransformer + +from IPython.display import display +from ..base import GlobalCounterfactualMethod +from ..base import LocalCounterfactualMethod +from ..base import ClusteringMethod +from ..utils.centroid import centroid_pandas +from ..utils.action import ( + apply_action_pandas, + actions_mean_pandas, +) +from ..counterfactual_costs import build_dist_func_dataframe +from ..utils.metadata_requests import _decide_cluster_method, _decide_local_cf_method +from .phase2 import generate_cluster_centroid_explanations + + +class C_GLANCE(GlobalCounterfactualMethod): + """ + A class for generating global counterfactual explanations using an iterative merging approach. + + It allows the user to control the number of clusters and the methods used + for clustering and generating counterfactuals. + + Attributes: + ---------- + model : Any + The predictive model used for generating counterfactuals. + initial_clusters : int + The initial number of clusters to form. + final_clusters : int + The target number of clusters after merging. + num_local_counterfactuals : int + The number of local counterfactuals to generate for each cluster. + heuristic_weights : Tuple[float, float] + Weights used in the heuristic for merging clusters. + alternative_merges : bool + If True, allows alternative merging strategies. + random_seed : int + Seed for random number generation. + verbose : bool + If True, enables verbose output during processing. + final_clustering : Optional[Dict[int, pd.DataFrame]] + The final clustering of instances after merging. + cluster_results : Optional[Dict[int, Dict[str, Any]]] + Results of the clustering including effectiveness and cost metrics. + + Methods: + ------- + _set_features_names(X, numerical_names, categorical_names): + Sets the feature names for numerical and categorical features. + + fit(X, y, train_dataset, feat_to_vary, numeric_features_names, categorical_features_names, + clustering_method, cf_generator, cluster_action_choice_algo, ...) + Fits the clustering and counterfactual generation model to the provided dataset. + + explain_group(instances): + Explains the group of instances by generating counterfactuals based on clustering. + + global_actions(): + Retrieves the global actions derived from the clustered results. + """ + + def __init__( + self, + model: Any, + initial_clusters: int = 100, + final_clusters: int = 10, + num_local_counterfactuals: int = 5, + heuristic_weights: Tuple[float, float] = (0.5, 0.5), + alternative_merges: bool = True, + random_seed: int = 13, + verbose=True, + ) -> None: + """ + Initializes the IterativeMerges instance. + + Parameters: + ---------- + model : Any + The predictive model used for generating counterfactuals. + initial_clusters : int, optional + The initial number of clusters to form. Default is 100. + final_clusters : int, optional + The target number of clusters after merging. Default is 10. + num_local_counterfactuals : int, optional + The number of local counterfactuals to generate for each cluster. Default is 5. + heuristic_weights : Tuple[float, float], optional + Weights used in the heuristic for merging clusters. Default is (0.5, 0.5). + alternative_merges : bool, optional + If True, allows alternative merging strategies. Default is True. + random_seed : int, optional + Seed for random number generation. Default is 13. + verbose : bool, optional + If True, enables verbose output during processing. Default is True. + """ + super().__init__() + self.model = model + self.initial_clusters = initial_clusters + self.final_clusters = final_clusters + self.num_local_counterfactuals = num_local_counterfactuals + self.heuristic_weights = heuristic_weights + self.alternative_merges = alternative_merges + self.random_seed = random_seed + self.verbose = verbose + self.final_clustering = None + self.clusters_results = None + + def _set_features_names( + self, + X: pd.DataFrame, + numerical_names: Optional[List[str]], + categorical_names: Optional[List[str]] + ) -> Tuple[List[str], List[str]]: + """ + Sets the feature names for numerical and categorical features. + + Parameters: + ---------- + X : pd.DataFrame + The dataset to analyze. + numerical_names : Optional[List[str]] + List of numerical feature names. If None, they will be inferred from X. + categorical_names : Optional[List[str]] + List of categorical feature names. If None, they will be inferred from X. + + Returns: + ------- + Tuple[List[str], List[str]] + A tuple containing lists of numerical and categorical feature names. + """ + if numerical_names is None and categorical_names is None: + numerical_names = X.select_dtypes( + include=["number"] + ).columns.tolist() + categorical_names = X.columns.difference( + numerical_names + ).tolist() + elif numerical_names is None and categorical_names is not None: + numerical_names = X.columns.difference(categorical_names).tolist() + elif numerical_names is not None and categorical_names is None: + categorical_names = X.columns.difference(numerical_names).tolist() + + assert numerical_names is not None and categorical_names is not None + return numerical_names, categorical_names + + def fit( + self, + X: pd.DataFrame, + y: pd.Series, + train_dataset: pd.DataFrame, + feat_to_vary: Optional[Union[List[str], str]] = "all", + numeric_features_names: Optional[List[str]] = None, + categorical_features_names: Optional[List[str]] = None, + clustering_method: Union[ClusteringMethod, Literal["KMeans"]] = "KMeans", + cf_generator: Union[ + LocalCounterfactualMethod, + Literal["Dice", "NearestNeighbors", "RandomSampling"] + ] = "Dice", + cluster_action_choice_algo: Literal["max-eff", "mean-act", "low-cost"] = "max-eff", + nns__n_scalars: Optional[int] = None, + rs__n_most_important: Optional[int] = None, + rs__n_categorical_most_frequent: Optional[int] = None, + lowcost__action_threshold: Optional[int] = None, + lowcost__num_low_cost: Optional[int] = None, + min_cost_eff_thres__effectiveness_threshold: Optional[float] = None, + min_cost_eff_thres_combinations__num_min_cost: Optional[int] = None, + eff_thres_hybrid__max_n_actions_full_combinations: Optional[int] = None, + ) -> "C_GLANCE": + """ + Fits the clustering and counterfactual generation model to the provided dataset. + + Parameters: + ---------- + X : pd.DataFrame + Features of the dataset. + y : pd.Series + Target variable. + train_dataset : pd.DataFrame + The training dataset used for local counterfactual generation methods. + feat_to_vary : Optional[Union[List[str], str]], optional + Features to vary in counterfactual generation. Default is "all". + numeric_features_names : Optional[List[str]], optional + List of numeric feature names. If None, they will be inferred from X. + categorical_features_names : Optional[List[str]], optional + List of categorical feature names. If None, they will be inferred from X. + clustering_method : Union[ClusteringMethod, Literal["KMeans"]], optional + The clustering method to use. Default is "KMeans". + cf_generator : Union[LocalCounterfactualMethod, Literal["Dice", "NearestNeighbors", "RandomSampling"]], optional + The local counterfactual generation method to use. Default is "Dice". + cluster_action_choice_algo : Literal["max-eff", "mean-act", "low-cost""], optional + The algorithm for selecting actions from clusters. Default is "max-eff". + nns__n_scalars : Optional[int], optional + Number of scalar features to use for nearest neighbors. Default is None. + rs__n_most_important : Optional[int], optional + Number of most important features for random sampling. Default is None. + rs__n_categorical_most_frequent : Optional[int], optional + Number of most frequent categorical features for random sampling. Default is None. + lowcost__action_threshold : Optional[int], optional + Action threshold for low-cost methods. Default is None. + lowcost__num_low_cost : Optional[int], optional + Number of low-cost actions to consider. Default is None. + min_cost_eff_thres__effectiveness_threshold : Optional[float], optional + Effectiveness threshold for minimum cost methods. Default is None. + min_cost_eff_thres_combinations__num_min_cost : Optional[int], optional + Number of minimum cost combinations to evaluate. Default is None. + eff_thres_hybrid__max_n_actions_full_combinations : Optional[int], optional + Maximum number of actions for full combinations in hybrid thresholding. Default is None. + + Returns: + ------- + IterativeMerges + Returns the fitted instance of IterativeMerges. + """ + self.numerical_features_names, self.categorical_features_names = self._set_features_names( + X=X, + numerical_names=numeric_features_names, + categorical_names=categorical_features_names, + ) + + self.X = X + self.y = y + self.train_dataset = train_dataset + self.clustering_method_ = clustering_method + self.action_threshold = lowcost__action_threshold if lowcost__action_threshold is not None else 1.5 + self.num_low_cost = lowcost__num_low_cost if lowcost__num_low_cost is not None else 20 + self.effectiveness_threshold = min_cost_eff_thres__effectiveness_threshold if min_cost_eff_thres__effectiveness_threshold is not None else 0.1 + self.min_cost_eff_thres_combinations__num_min_cost = min_cost_eff_thres_combinations__num_min_cost + self.cluster_action_choice_algo: Literal["max-eff", "mean-act", "low-cost", "min-cost-eff-thres", "min-cost-eff-thres-combinations", "hybrid"] = cluster_action_choice_algo + self.eff_thres_hybrid__max_n_actions_full_combinations = eff_thres_hybrid__max_n_actions_full_combinations if eff_thres_hybrid__max_n_actions_full_combinations is None else 50 + + if nns__n_scalars is not None: + self.n_scalars = nns__n_scalars + else: + self.n_scalars = 1000 + if rs__n_most_important is not None: + self.n_most_important = rs__n_most_important + else: + self.n_most_important = len(X.columns) + if rs__n_categorical_most_frequent is not None: + self.n_categorical_most_frequent = rs__n_categorical_most_frequent + else: + self.n_categorical_most_frequent = 20 + + self.cf_generator = _decide_local_cf_method( + method=cf_generator, + model=self.model, + train_dataset=self.train_dataset, + numeric_features_names=self.numerical_features_names, + categorical_features_names=self.categorical_features_names, + feat_to_vary=feat_to_vary, + random_seed=self.random_seed, + n_scalars=self.n_scalars, + n_most_important=self.n_most_important, + n_categorical_most_frequent=self.n_categorical_most_frequent, + ) + + self.dist_func_dataframe = build_dist_func_dataframe( + X=X, + numerical_columns=self.numerical_features_names, + categorical_columns=self.categorical_features_names, + ) + return self + + def explain_group( + self, instances: pd.DataFrame + ) -> Tuple[int, float]: + """ + Explains the group of instances by generating counterfactuals based on clustering. + + Parameters: + ---------- + instances : pd.DataFrame + The group of instances to explain. + + Returns: + ------- + Tuple[int, float] + A tuple containing the total effectiveness and total cost of the generated counterfactuals. + """ + if self.initial_clusters > instances.shape[0]: + warnings.warn( + "Requested number of initial clusters is larger than the number of instances to explain. Setting to number of instances." + ) + self.initial_clusters = instances.shape[0] + + self.clustering_method = _decide_cluster_method( + self.clustering_method_, self.initial_clusters, self.random_seed + ) + + clusters = _generate_clusters( + instances=instances, + num_clusters=self.initial_clusters, + categorical_features_names=self.categorical_features_names, + clustering_method=self.clustering_method, + ) + + cluster_centroids = { + i: centroid_pandas( + X=instances, + numerical_columns=self.numerical_features_names, + categorical_columns=self.categorical_features_names, + ) + for i, instances in clusters.items() + } + + cluster_explanations, cluster_expl_actions, explanations_centroid = ( + generate_cluster_centroid_explanations( + cluster_centroids=cluster_centroids, + cf_generator=self.cf_generator, + num_local_counterfactuals=self.num_local_counterfactuals, + numerical_features_names=self.numerical_features_names, + categorical_features_names=self.categorical_features_names, + ) + ) + # delete clusters with no explanations + clusters = {i: cluster for i, cluster in clusters.items() if i in cluster_explanations.keys()} + cluster_centroids = {i: cluster for i, cluster in cluster_centroids.items() if i in cluster_explanations.keys()} + + while len(clusters) > self.final_clusters: + cluster1, cluster2 = _find_candidate_clusters( + clusters=clusters, + cluster_centroids=cluster_centroids, + explanations_centroid=explanations_centroid, + heuristic_weights=self.heuristic_weights, + dist_func_dataframe=self.dist_func_dataframe, + ) + + _merge_clusters( + cluster1=cluster1, + cluster2=cluster2, + clusters=clusters, + cluster_explanations=cluster_explanations, + cluster_centroids=cluster_centroids, + cluster_expl_actions=cluster_expl_actions, + explanations_centroid=explanations_centroid, + numerical_features_names=self.numerical_features_names, + categorical_features_names=self.categorical_features_names, + ) + + clusters_res, total_eff, total_cost = cluster_results( + model=self.model, + instances=instances, + clusters=clusters, + cluster_expl_actions=cluster_expl_actions, + dist_func_dataframe=self.dist_func_dataframe, + numerical_features_names=self.numerical_features_names, + categorical_features_names=self.categorical_features_names, + cluster_action_choice_algo=self.cluster_action_choice_algo, + action_threshold=self.action_threshold, + num_low_cost=self.num_low_cost, + effectiveness_threshold=self.effectiveness_threshold, + num_min_cost=self.min_cost_eff_thres_combinations__num_min_cost, + max_n_actions_full_combinations=self.eff_thres_hybrid__max_n_actions_full_combinations, + ) + + for i, stats in clusters_res.items(): + stats["size"] = clusters[i].shape[0] + + if self.verbose == True: + format_glance_output( + cluster_stats=clusters_res, + categorical_columns = self.categorical_features_names) +# print_results( +# clusters_stats=clusters_res, +# total_effectiveness=total_eff, +# total_cost=total_cost, +# ) + + eff, cost = cumulative( + self.model, + instances, + [stats["action"] for i, stats in clusters_res.items()], + self.dist_func_dataframe, + self.numerical_features_names, + self.categorical_features_names, + "-", + ) + if self.verbose == True: + print(f"{Style.BRIGHT}TOTAL EFFECTIVENESS:{Style.RESET_ALL} {Fore.GREEN}{eff / instances.shape[0]:.2%}{Fore.RESET}") + print(f"{Style.BRIGHT}TOTAL COST:{Style.RESET_ALL} {Fore.MAGENTA}{(cost / eff):.2f}{Fore.RESET}") + + self.final_clustering = clusters + self.cluster_results = clusters_res + + return eff, cost + + def global_actions(self): + return [stats["action"] for i, stats in self.cluster_results.items()] + + +def cumulative( + model, + instances, + actions, + dist_func_dataframe, + numeric_features_names, + categorical_features_names, + categorical_no_action_token, +): + """ + Computes the cumulative effectiveness and cost of applying a set of actions + to a given set of instances using a predictive model. + + Parameters: + ---------- + model : Any + A predictive model with a predict method. This model will be used to predict + outcomes after applying actions to the input instances. + instances : pd.DataFrame + A DataFrame containing the instances for which actions are to be applied. + actions : List[dict] + A list of actions, where each action is represented as a dictionary that + specifies how to modify the instances. + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A distance function that takes two DataFrames and returns a Series of distances + between corresponding rows. + numeric_features_names : List[str] + A list of names for the numeric features in the instances DataFrame. + categorical_features_names : List[str] + A list of names for the categorical features in the instances DataFrame. + categorical_no_action_token : Any + A token used to represent a no-action state for categorical features. + + Returns: + ------- + Tuple[int, float] + A tuple containing: + - effectiveness: An integer count of how many actions were effective (i.e., + resulted in a finite cost). + - cost: A float representing the total cost incurred by the effective actions. + """ + costs = [] + all_predictions = [] + + for action in actions: + applied_df = apply_action_pandas( + instances, + action, + numeric_features_names, + categorical_features_names, + categorical_no_action_token, + ) + + predictions = model.predict(applied_df) + all_predictions.append(predictions) + cur_costs = dist_func_dataframe(instances.reset_index(drop=True), applied_df.reset_index(drop=True)) + cur_costs[predictions == 0] = np.inf + costs.append(cur_costs) + + if costs == []: + return 0, 0. + final_costs = np.column_stack(costs).min(axis=1) + effectiveness = (final_costs != np.inf).sum() + cost = final_costs[final_costs != np.inf].sum() + + return effectiveness, cost + + +def action_fake_cost( + action: pd.Series, + numerical_features_names: List[str], + categorical_features_names: List[str], +): + return ( + action[numerical_features_names].sum() + + (action[categorical_features_names] != "-").sum() + ) + + +def _select_action_low_cost( + model: Any, + instances: pd.DataFrame, + cluster_instances: pd.DataFrame, + candidate_actions: pd.DataFrame, + dist_func_dataframe: Callable[[pd.DataFrame, pd.DataFrame], pd.Series], + numerical_features_names: List[str], + categorical_features_names: List[str], + action_threshold: int, + num_low_cost: int, + inv_total_clusters: int, +): + """ + Selects the action with the lowest cost that flips a sufficient number of instances + in the given dataset, based on a predictive model. + + This function evaluates candidate actions, applies them to the provided instances, + and calculates the number of predictions that were flipped as a result. It returns + the action that results in the lowest recourse cost while also meeting a specified + threshold of flipped predictions. + + Parameters: + ---------- + model : Any + A machine learning model used for making predictions. + + instances : pd.DataFrame + A DataFrame containing the instances for which counterfactuals are being generated. + + cluster_instances : pd.DataFrame + A DataFrame containing instances from a specific cluster used for evaluating actions. + + candidate_actions : pd.DataFrame + A DataFrame containing potential actions to apply to the instances. + + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A function that computes the distance or cost between two DataFrames. + + numerical_features_names : List[str] + A list of names for the numerical features in the instances. + + categorical_features_names : List[str] + A list of names for the categorical features in the instances. + + action_threshold : int + The minimum ratio of flipped predictions to total instances required to consider + an action effective. + + num_low_cost : int + The maximum number of low-cost actions to evaluate. + + inv_total_clusters : int + The inverse of the total number of clusters used for normalization. + + Returns: + ------- + Tuple[int, float, pd.Series] + A tuple containing: + - The number of predictions flipped. + - The minimum recourse cost associated with the best action. + - The best action selected from the candidate actions. + + Raises: + ------ + ValueError + If no actions are found that meet the effectiveness threshold. + """ + actions_list = [action for _, action in candidate_actions.iterrows()] + actions_list.sort( + key=lambda action: action_fake_cost( + action, numerical_features_names, categorical_features_names + ) + ) + cf_list = [] + for action in actions_list[: min(num_low_cost, len(actions_list))]: + cfs = apply_action_pandas( + X=instances, + action=action, + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + categorical_no_action_token="-", + ) + predictions: np.ndarray = model.predict(cfs) + n_flipped = predictions.sum() + + if n_flipped > (action_threshold * inv_total_clusters) * len(instances): + cfs = apply_action_pandas( + X=cluster_instances, + action=action, + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + categorical_no_action_token="-", + ) + predictions: np.ndarray = model.predict(cfs) + n_flipped = predictions.sum() + factuals_flipped = cluster_instances[predictions == 1] + cfs_flipped = cfs[predictions == 1] + recourse_cost_sum = dist_func_dataframe(factuals_flipped, cfs_flipped).sum() + cf_list.append((n_flipped, recourse_cost_sum, action)) + + if len(cf_list) == 0: + raise ValueError( + "Change action_threshold. No action found in cluster with effectiveness in all instances above the threshold" + ) + else: + n_flipped, min_recourse_cost_sum, best_action = min( + cf_list, key=lambda x: (x[1], -x[0]) + ) + + return n_flipped, min_recourse_cost_sum, best_action + +def actions_cumulative_eff_cost( + model: Any, + X: pd.DataFrame, + actions_with_costs: List[Tuple[pd.Series, float]], + dist_func_dataframe: Callable[[pd.DataFrame, pd.DataFrame], pd.Series], + numerical_columns: List[str], + categorical_columns: List[str], + categorical_no_action_token: Any, +) -> Tuple[float, float]: + """ + Evaluates the cumulative effectiveness and cost of applying a sequence of actions + to a dataset using a predictive model. + + This function applies each action from the sorted list of actions with their costs, + predicts the outcomes, and calculates the total number of predictions that were flipped + as well as the total recourse cost incurred from the actions. + + Parameters: + ---------- + model : Any + A machine learning model used for making predictions on the modified instances. + + X : pd.DataFrame + The original DataFrame of instances to which actions will be applied. + + actions_with_costs : List[Tuple[pd.Series, float]] + A list of tuples where each tuple contains: + - A pandas Series representing the action to apply. + - A float representing the cost associated with the action. + + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A function that computes the distance or cost between two DataFrames. + + numerical_columns : List[str] + A list of names for the numerical columns in the DataFrame. + + categorical_columns : List[str] + A list of names for the categorical columns in the DataFrame. + + categorical_no_action_token : Any + A token used to represent the absence of an action for categorical features. + + Returns: + ------- + Tuple[float, float] + A tuple containing: + - The total number of predictions flipped across all actions applied. + - The total recourse cost incurred from applying the actions. + """ + X = X.copy() + actions_with_costs = sorted(actions_with_costs, key=lambda t: t[1]) + n_flipped_total = 0 + recourse_cost_sum = 0 + for action, _old_cost in actions_with_costs: + cfs = apply_action_pandas( + X=X, + action=action, + numerical_columns=numerical_columns, + categorical_columns=categorical_columns, + categorical_no_action_token=categorical_no_action_token, + ) + predictions: np.ndarray = model.predict(cfs) + n_flipped_total += predictions.sum() + factuals_flipped = X[predictions == 1] + cfs_flipped = cfs[predictions == 1] + recourse_cost_sum += dist_func_dataframe(factuals_flipped, cfs_flipped).sum() + X = X[predictions == 0] + + return n_flipped_total, recourse_cost_sum + +def _select_action_max_eff( + model: Any, + instances: pd.DataFrame, + candidate_actions: pd.DataFrame, + dist_func_dataframe: Callable[[pd.DataFrame, pd.DataFrame], pd.Series], + numerical_features_names: List[str], + categorical_features_names: List[str], + num_actions: int = 1, +) -> Tuple[int, int, pd.Series]: + """ + Selects actions based on maximizing the effectiveness. + + This function evaluates a set of candidate actions by applying each action to the given + instances, predicting the outcomes, and calculating the number of predictions that are + flipped (changed from 0 to 1). It also computes the recourse cost associated with each action. + Depending on the number of actions specified, it returns either the best action or a list + of the top actions based on effectiveness. + + Parameters: + ---------- + model : Any + A machine learning model used for making predictions on the modified instances. + + instances : pd.DataFrame + The DataFrame of original instances to which actions will be applied. + + candidate_actions : pd.DataFrame + A DataFrame containing the candidate actions to evaluate. + + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A function that computes the distance or cost between two DataFrames. + + numerical_features_names : List[str] + A list of names for the numerical columns in the DataFrame. + + categorical_features_names : List[str] + A list of names for the categorical columns in the DataFrame. + + num_actions : int, optional + The number of top actions to select based on effectiveness. Defaults to 1. + + Returns: + ------- + Tuple[int, int, pd.Series] + If `num_actions` is 1, returns: + - The maximum number of predictions flipped. + - The total recourse cost associated with the best action. + - The best action (pd.Series). + + If `num_actions` > 1, returns a list of the top actions based on their effectiveness. + """ + max_n_flipped = 0 + cf_list = [] + + for _, action in candidate_actions.iterrows(): + cfs = apply_action_pandas( + X=instances, + action=action, + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + categorical_no_action_token="-", + ) + predictions: np.ndarray = model.predict(cfs) + n_flipped = predictions.sum() + + if n_flipped < max_n_flipped and num_actions == 1: + continue + max_n_flipped = n_flipped + + factuals_flipped = instances[predictions == 1] + cfs_flipped = cfs[predictions == 1] + recourse_cost_sum = dist_func_dataframe(factuals_flipped, cfs_flipped).sum() + cf_list.append((n_flipped, recourse_cost_sum, action)) + + if num_actions == 1: + max_n_flipped, recourse_cost_sum, best_action = max( + cf_list, key=lambda x: (x[0], -x[1]) + ) + + return max_n_flipped, recourse_cost_sum, best_action + else: + cf_list.sort(key=lambda x: (-x[0], x[1])) + return cf_list[:num_actions] + + +def _select_action_mean( + model: Any, + instances: pd.DataFrame, + candidate_actions: pd.DataFrame, + dist_func_dataframe: Callable[[pd.DataFrame, pd.DataFrame], pd.Series], + numerical_features_names: List[str], + categorical_features_names: List[str], +) -> Tuple[int, int, pd.Series]: + """ + Selects the mean action from a set of candidate actions and evaluates its effectiveness. + + This function computes the mean action from the candidate actions and applies it to the + given instances. It then predicts the outcomes and calculates the number of predictions that + are flipped (changed from 0 to 1) as well as the associated recourse cost. + + Parameters: + ---------- + model : Any + A machine learning model used for making predictions on the modified instances. + + instances : pd.DataFrame + The DataFrame of original instances to which the mean action will be applied. + + candidate_actions : pd.DataFrame + A DataFrame containing the candidate actions from which the mean action will be derived. + + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A function that computes the distance or cost between two DataFrames. + + numerical_features_names : List[str] + A list of names for the numerical columns in the DataFrame. + + categorical_features_names : List[str] + A list of names for the categorical columns in the DataFrame. + + Returns: + ------- + Tuple[int, int, pd.Series] + A tuple containing: + - The number of predictions flipped by applying the mean action. + - The total recourse cost associated with the mean action. + - The mean action (pd.Series). + """ + mean_action = actions_mean_pandas( + actions=candidate_actions, + numerical_features=numerical_features_names, + categorical_features=categorical_features_names, + categorical_no_action_token="-", + ) + cfs = apply_action_pandas( + X=instances, + action=mean_action, + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + categorical_no_action_token="-", + ) + predictions: np.ndarray = model.predict(cfs) + n_flipped = predictions.sum() + factuals_flipped = instances[predictions == 1] + cfs_flipped = cfs[predictions == 1] + recourse_cost_sum = dist_func_dataframe(factuals_flipped, cfs_flipped).sum() + + return n_flipped, recourse_cost_sum, mean_action + + +def cluster_results( + model: Any, + instances: pd.DataFrame, + clusters: Dict[int, pd.DataFrame], + cluster_expl_actions: Dict[int, pd.DataFrame], + dist_func_dataframe: Callable[[pd.DataFrame, pd.DataFrame], pd.Series], + numerical_features_names: List[str], + categorical_features_names: List[str], + cluster_action_choice_algo: Literal["max-eff", "mean-act", "low-cost", "min-cost-eff-thres", "eff-thres-hybrid"] = "max-eff", + action_threshold: int = 2, + num_low_cost: int = 20, + effectiveness_threshold: float = 0.1, + num_min_cost: Optional[int] = None, + max_n_actions_full_combinations: int = 50, +) -> Tuple[Dict[int, Dict[str, Any]], float, float]: + """ + Evaluates and selects actions for each cluster based on a specified action choice algorithm. + + This function iterates through each cluster of instances, applying the specified algorithm to + select the best action for achieving recourse while minimizing costs. It calculates the total + effectiveness and mean recourse costs across all clusters. + + Parameters: + ---------- + model : Any + A machine learning model used for making predictions on modified instances. + + instances : pd.DataFrame + The DataFrame of original instances to which actions will be applied. + + clusters : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to DataFrames of instances belonging to each cluster. + + cluster_expl_actions : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to DataFrames of candidate actions for each cluster. + + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A function that computes the distance or cost between two DataFrames. + + numerical_features_names : List[str] + A list of names for the numerical columns in the DataFrames. + + categorical_features_names : List[str] + A list of names for the categorical columns in the DataFrames. + + cluster_action_choice_algo : Literal["max-eff", "mean-act", "low-cost", "min-cost-eff-thres", "eff-thres-hybrid"] + The algorithm to use for selecting actions from candidate actions. Options include: + - "max-eff": Select the action with maximum effectiveness. + - "mean-act": Select the mean action from candidate actions. + - "low-cost": Select actions based on low cost. + + action_threshold : int + Minimum threshold for the number of flipped predictions required to consider an action effective. + + num_low_cost : int + The number of low-cost actions to consider (used when the low-cost algorithm is selected). + + effectiveness_threshold : float + Minimum effectiveness required for actions (used when the min-cost-eff-thres algorithm is selected). + + num_min_cost : Optional[int] + Number of minimum cost actions to consider (used when the min-cost-eff-thres algorithm is selected). + + max_n_actions_full_combinations : int + Maximum number of actions to evaluate in full combinations (not currently used in the function). + + Returns: + ------- + Tuple[Dict[int, Dict[str, Any]], float, float] + A tuple containing: + - A dictionary where each key is a cluster ID and each value is another dictionary with the selected action, its effectiveness, and cost. + - Total effectiveness percentage across all clusters. + - Total mean recourse cost across all clusters. + """ + n_flipped_total = 0 + total_recourse_cost_sum = 0 + ret_clusters = {} + for i, cluster in clusters.items(): + if cluster_action_choice_algo == "max-eff": + n_flipped, recourse_cost_sum, selected_action = _select_action_max_eff( + model=model, + instances=cluster, + candidate_actions=cluster_expl_actions[i], + dist_func_dataframe=dist_func_dataframe, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + ) + elif cluster_action_choice_algo == "mean-act": + n_flipped, recourse_cost_sum, selected_action = _select_action_mean( + model=model, + instances=cluster, + candidate_actions=cluster_expl_actions[i], + dist_func_dataframe=dist_func_dataframe, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + ) + elif cluster_action_choice_algo == "low-cost": + n_flipped, recourse_cost_sum, selected_action = _select_action_low_cost( + model=model, + instances=instances, + cluster_instances=cluster, + candidate_actions=cluster_expl_actions[i], + dist_func_dataframe=dist_func_dataframe, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + action_threshold=action_threshold, + num_low_cost=num_low_cost, + inv_total_clusters=(1 / len(clusters)), + ) + elif cluster_action_choice_algo == "min-cost-eff-thres-combinations": + break + elif cluster_action_choice_algo == "eff-thres-hybrid": + break + else: + raise ValueError( + "Unsupported algorithm for choice of final action for each cluster" + ) + + ret_clusters[i] = { + "action": selected_action, + "effectiveness": n_flipped / cluster.shape[0], + "cost": recourse_cost_sum / n_flipped, + } + n_flipped_total += n_flipped + total_recourse_cost_sum += recourse_cost_sum + + + if cluster_action_choice_algo == "min-cost-eff-thres-combinations": + n_flipped_total, total_recourse_cost_sum, action_set = _select_action_min_cost_eff_thres_combinations( + model=model, + instances=instances, + clusters=clusters, + candidate_actions=cluster_expl_actions, + dist_func_dataframe=dist_func_dataframe, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + effectiveness_threshold=effectiveness_threshold, + num_min_cost=num_min_cost, + ) + + assert len(action_set) == len(clusters) + actions_iter = iter(action_set) + ret_clusters = {i: { + "action": next(actions_iter), + "effectiveness": np.nan, + "cost": np.nan, + } for i in clusters.keys()} + + n_individuals_total = instances.shape[0] + total_effectiveness_percentage = n_flipped_total / n_individuals_total + total_mean_recourse_cost = total_recourse_cost_sum / n_flipped_total + + return ret_clusters, total_effectiveness_percentage, total_mean_recourse_cost + + assert len(action_set) == len(clusters) + actions_iter = iter(action_set) + ret_clusters = {i: { + "action": next(actions_iter), + "effectiveness": np.nan, + "cost": np.nan, + } for i in clusters.keys()} + + n_individuals_total = instances.shape[0] + total_effectiveness_percentage = n_flipped_total / n_individuals_total + total_mean_recourse_cost = total_recourse_cost_sum / n_flipped_total + + return ret_clusters, total_effectiveness_percentage, total_mean_recourse_cost + else: + n_individuals_total = sum(cluster.shape[0] for cluster in clusters.values()) + + total_effectiveness_percentage = n_flipped_total / n_individuals_total + total_mean_recourse_cost = total_recourse_cost_sum / n_flipped_total + return ret_clusters, total_effectiveness_percentage, total_mean_recourse_cost + + +def print_results( + clusters_stats: Dict[int, Dict[str, numbers.Number]], + total_effectiveness: float, + total_cost: float, +): + """ + Prints the statistics for each cluster, including effectiveness and cost. + + This function takes the results of cluster analysis and formats them for easy + viewing. It displays the size of each cluster, the actions taken, and the + effectiveness and cost of those actions. + + Parameters: + ---------- + clusters_stats : Dict[int, Dict[str, numbers.Number]] + A dictionary where keys are cluster IDs (integers) and values are + dictionaries containing statistics for each cluster. Each value dictionary + must contain the following keys: + - "size": The size of the cluster. + - "action": The actions taken for the cluster. + - "effectiveness": The effectiveness of the actions in the cluster. + - "cost": The cost associated with the actions. + + total_effectiveness : float + The total effectiveness percentage across all clusters, represented as a decimal + (e.g., 0.75 for 75%). + + total_cost : float + The total cost associated with the actions taken across all clusters. + """ + for i, stats in enumerate(clusters_stats.values()): + print(f"CLUSTER {i + 1} with size {stats['size']}:") + display(pd.DataFrame(stats["action"]).T) + print(f"Effectiveness: {stats['effectiveness']:.2%}, Cost: {stats['cost']:.2f}") + +def format_glance_output( + cluster_stats: Dict[int, Dict[str, numbers.Number]], + categorical_columns: List[str], +): + cluster_res = pd.DataFrame(cluster_stats) + for index,row in cluster_res.T.reset_index(drop=True).iterrows(): + # print(f"{Style.BRIGHT}CLUSTER {index+1}{Style.RESET_ALL} with size {row['size']}") + output_string = f"{Style.BRIGHT}Action {index+1} \n{Style.RESET_ALL}" + for column_name, value in row['action'].to_frame().T.reset_index(drop=True).iteritems(): + if column_name in categorical_columns: + if value[0] != '-': + output_string += f"{Style.BRIGHT}{column_name}{Style.RESET_ALL} = {Fore.RED}{value[0]}{Fore.RESET} \n" + else: + if value[0] != '-': + if value[0] > 0 : + output_string += f"{Style.BRIGHT}{column_name}{Style.RESET_ALL} +{Fore.RED}{value[0]}{Fore.RESET} \n" + elif value[0] < 0 : + output_string += f"{Style.BRIGHT}{column_name}{Style.RESET_ALL} {Fore.RED}{value[0]}{Fore.RESET} \n" + print(output_string) + print(f"{Style.BRIGHT}Effectiveness:{Style.RESET_ALL} {Fore.GREEN}{row['effectiveness']:.2%}{Fore.RESET}\t{Style.BRIGHT}Cost:{Style.RESET_ALL} {Fore.MAGENTA}{row['cost']:.2f}{Fore.RESET}") + print("\n") + +def _merge_clusters( + cluster1: int, + cluster2: int, + clusters: Dict[int, pd.DataFrame], + cluster_explanations: Dict[int, pd.DataFrame], + cluster_centroids: Dict[int, pd.DataFrame], + cluster_expl_actions: Dict[int, pd.DataFrame], + explanations_centroid: Dict[int, pd.DataFrame], + numerical_features_names: List[str], + categorical_features_names: List[str], +): + """ + Merges two clusters into one and updates all associated data structures. + + This function takes two cluster identifiers and combines their respective data. + It updates the clusters, explanations, centroids, and action dataframes accordingly. + + Parameters: + ---------- + cluster1 : int + The identifier for the first cluster to merge. + + cluster2 : int + The identifier for the second cluster to merge into. + + clusters : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their respective dataframes. + + cluster_explanations : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their explanations dataframes. + + cluster_centroids : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their centroid dataframes. + + cluster_expl_actions : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their explanation actions dataframes. + + explanations_centroid : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their centroid explanations dataframes. + + numerical_features_names : List[str] + A list of names for the numerical features in the dataset. + + categorical_features_names : List[str] + A list of names for the categorical features in the dataset. + """ + clusters[cluster2] = pd.concat( + [clusters[cluster2], clusters[cluster1]], ignore_index=True + ) + del clusters[cluster1] + + cluster_explanations[cluster2] = pd.concat( + [cluster_explanations[cluster2], cluster_explanations[cluster1]], + ignore_index=True, + ) + del cluster_explanations[cluster1] + + explanations_centroid[cluster2] = centroid_pandas( + cluster_explanations[cluster2], + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + ) + del explanations_centroid[cluster1] + + cluster_expl_actions[cluster2] = pd.concat( + [cluster_expl_actions[cluster2], cluster_expl_actions[cluster1]], + ignore_index=True, + ) + del cluster_expl_actions[cluster1] + + cluster_centroids[cluster2] = centroid_pandas( + clusters[cluster2], + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + ) + del cluster_centroids[cluster1] + + +def _find_candidate_clusters( + clusters: Dict[int, pd.DataFrame], + cluster_centroids: Dict[int, pd.DataFrame], + explanations_centroid: Dict[int, pd.DataFrame], + heuristic_weights: Tuple[float, float], + dist_func_dataframe: Callable[[pd.DataFrame, pd.DataFrame], pd.Series], +) -> Tuple[int, int]: + """ + Identifies the best candidate clusters for merging based on distances of centroids + and explanation centroids, weighted by given heuristic values. + + The function selects the smallest cluster and calculates distances to all other clusters' centroids. + It uses these distances to determine a heuristic value for potential merges, returning the two + clusters with the best merge heuristic. + + Parameters: + ---------- + clusters : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their respective dataframes. + + cluster_centroids : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their centroid dataframes. + + explanations_centroid : Dict[int, pd.DataFrame] + A dictionary mapping cluster IDs to their explanation centroids. + + heuristic_weights : Tuple[float, float] + A tuple containing two weights used to combine centroid distances and explanation centroid distances. + + dist_func_dataframe : Callable[[pd.DataFrame, pd.DataFrame], pd.Series] + A function that computes the distance between two dataframes, returning a series of distances. + + Returns: + ------- + Tuple[int, int] + A tuple containing the IDs of the two candidate clusters identified for merging. + """ + clusters_idx = clusters.keys() + + smallest_cluster = min(clusters_idx, key=lambda i: (clusters[i].shape[0], i)) + smallest_expl_centroid_repeat = pd.concat( + [explanations_centroid[smallest_cluster]] * (len(clusters) - 1), + ignore_index=True, + ) + expl_centroids_rest = pd.concat( + [explanations_centroid[i] for i in clusters_idx if i != smallest_cluster], + ignore_index=True, + ) + explanations_centroid_distances = dist_func_dataframe( + smallest_expl_centroid_repeat, + expl_centroids_rest, + ) + smallest_centroid_repeat = pd.concat( + [cluster_centroids[smallest_cluster]] * (len(clusters) - 1), ignore_index=True + ) + centroids_rest = pd.concat( + [cluster_centroids[i] for i in clusters_idx if i != smallest_cluster], + ignore_index=True, + ) + cluster_centroids_distances = dist_func_dataframe( + smallest_centroid_repeat, + centroids_rest, + ) + merge_heuristic_values = ( + heuristic_weights[0] * cluster_centroids_distances + + heuristic_weights[1] * explanations_centroid_distances + ) + candidates = [ + (smallest_cluster, cluster1) + for cluster1 in clusters_idx + if cluster1 != smallest_cluster + ] + candidates = [ + (c1, c2, merge_heuristic_values.iloc[i]) + for i, (c1, c2) in enumerate(candidates) + ] + + candidates.sort(key=lambda x: (x[2], x[1])) + + return candidates[0][0], candidates[0][1] + + +def _generate_clusters( + instances: pd.DataFrame, + num_clusters: int, + categorical_features_names: List[str], + clustering_method: ClusteringMethod, +) -> Dict[int, pd.DataFrame]: + """ + Generates clusters from the given instances using the specified clustering method. + + The function applies one-hot encoding to the categorical features in the input data, + fits the provided clustering method, and assigns instances to clusters. It returns + a dictionary mapping cluster IDs to their respective dataframes. + + Parameters: + ---------- + instances : pd.DataFrame + The input data containing instances to be clustered. + + num_clusters : int + The desired number of clusters to generate. Note that the actual number of + clusters may vary depending on the clustering method used. + + categorical_features_names : List[str] + A list of names of categorical features in the input data that need to be + one-hot encoded for clustering. + + clustering_method : ClusteringMethod + An instance of a clustering method (e.g., KMeans, DBSCAN) that implements + the fit and predict methods. + + Returns: + ------- + Dict[int, pd.DataFrame] + A dictionary where the keys are cluster IDs and the values are dataframes + containing the instances assigned to each cluster. + """ + ohe_instances = _one_hot_encode(instances, categorical_features_names) + clustering_method.fit(ohe_instances) + assigned_clusters = clustering_method.predict(ohe_instances) + + cluster_ids = np.unique(assigned_clusters) + cluster_ids.sort() + clusters = {i: instances.iloc[assigned_clusters == i] for i in cluster_ids} + + return clusters + + +def _one_hot_encode(X: pd.DataFrame, categorical_columns: List[str]) -> pd.DataFrame: + """ + Applies one-hot encoding to the specified categorical columns of a DataFrame. + + This function transforms categorical columns in the input DataFrame into + a one-hot encoded format, allowing them to be used in machine learning models. + The non-categorical columns are retained in their original form. + + Parameters: + ---------- + X : pd.DataFrame + The input DataFrame containing the data with both categorical and numerical features. + + categorical_columns : List[str] + A list of names of the categorical columns in the DataFrame that should be one-hot encoded. + + Returns: + ------- + pd.DataFrame + A new DataFrame where the specified categorical columns have been one-hot encoded, + and all other columns are retained as is. + """ + transformer = ColumnTransformer( + [("ohe", OneHotEncoder(sparse_output=False), categorical_columns)], + remainder="passthrough", + ) + ret = transformer.fit_transform(X) + assert isinstance(ret, np.ndarray) + return pd.DataFrame(ret, columns=transformer.get_feature_names_out()) diff --git a/aix360/algorithms/glance/iterative_merges/phase2.py b/aix360/algorithms/glance/iterative_merges/phase2.py new file mode 100644 index 0000000..a000563 --- /dev/null +++ b/aix360/algorithms/glance/iterative_merges/phase2.py @@ -0,0 +1,102 @@ +from typing import Dict, List, Tuple + +import pandas as pd + +from ..base import LocalCounterfactualMethod +from ..utils.action import extract_actions_pandas +from ..utils.centroid import centroid_pandas + +def generate_cluster_centroid_explanations( + cluster_centroids: Dict[int, pd.DataFrame], + cf_generator: LocalCounterfactualMethod, + num_local_counterfactuals: int, + numerical_features_names: List[str], + categorical_features_names: List[str], +) -> Tuple[Dict[int, pd.DataFrame], Dict[int, pd.DataFrame], Dict[int, pd.DataFrame]]: + """ + Generates explanations for cluster centroids by creating counterfactual instances + for each centroid and extracting corresponding actions and explanations. + + Parameters: + ---------- + cluster_centroids : Dict[int, pd.DataFrame] + A dictionary where keys are cluster identifiers and values are DataFrames + representing the centroids of each cluster. + cf_generator : LocalCounterfactualMethod + An instance of a LocalCounterfactualMethod used to generate counterfactuals. + num_local_counterfactuals : int + The number of counterfactuals to generate for each cluster centroid. + numerical_features_names : List[str] + A list of names for numerical features in the dataset. + categorical_features_names : List[str] + A list of names for categorical features in the dataset. + + Returns: + ------- + Tuple[Dict[int, pd.DataFrame], Dict[int, pd.DataFrame], Dict[int, pd.DataFrame]] + A tuple containing three dictionaries: + - cluster_explanations: A dictionary of counterfactuals for each cluster centroid. + - cluster_expl_actions: A dictionary of extracted actions for the generated counterfactuals. + - explanations_centroid: A dictionary of centroid explanations based on the generated counterfactuals. + + Raises: + ------- + ValueError + If no counterfactuals are found for any of the centroids. + """ + cluster_explanations = { + i: cf_generator.explain_instances( + cluster_centroids[i], num_local_counterfactuals + ) + for i, _ in cluster_centroids.items() + } + returned_requested = True + empty_cfs_idxs = [] + for i, cfs in cluster_explanations.items(): + if cfs.empty: + empty_cfs_idxs.append(i) + if cfs.shape[0] != num_local_counterfactuals: + returned_requested = False + for i in empty_cfs_idxs: + del cluster_explanations[i] + + if not cluster_explanations: + raise ValueError("No counterfactuals found for any of the centroids.") + + if returned_requested: + cluster_expl_actions = { + i: extract_actions_pandas( + X=pd.concat([cluster_centroids[i]] * num_local_counterfactuals).set_index( + cluster_explanations[i].index + ), + cfs=cluster_explanations[i], + categorical_features=categorical_features_names, + numerical_features=numerical_features_names, + categorical_no_action_token="-", + ) + for i, _cfs in cluster_explanations.items() + } + else: + cluster_expl_actions = { + i: extract_actions_pandas( + X=pd.concat([cluster_centroids[i]] * cluster_explanations[i].shape[0]).set_index( + cluster_explanations[i].index + ), + cfs=cluster_explanations[i], + categorical_features=categorical_features_names, + numerical_features=numerical_features_names, + categorical_no_action_token="-", + ) + for i, _cfs in cluster_explanations.items() + } + + explanations_centroid = { + i: centroid_pandas( + X=cluster_explanations[i], + numerical_columns=numerical_features_names, + categorical_columns=categorical_features_names, + ) + for i, _cfs in cluster_explanations.items() + } + + return cluster_explanations, cluster_expl_actions, explanations_centroid \ No newline at end of file diff --git a/aix360/algorithms/glance/local_cfs/__init__.py b/aix360/algorithms/glance/local_cfs/__init__.py new file mode 100644 index 0000000..b2b0df6 --- /dev/null +++ b/aix360/algorithms/glance/local_cfs/__init__.py @@ -0,0 +1,3 @@ +from .dice_method import DiceMethod +from .nearest_neighbor import NearestNeighborMethod +from .random_sampling import RandomSampling diff --git a/aix360/algorithms/glance/local_cfs/dice_method.py b/aix360/algorithms/glance/local_cfs/dice_method.py new file mode 100644 index 0000000..8fe12da --- /dev/null +++ b/aix360/algorithms/glance/local_cfs/dice_method.py @@ -0,0 +1,115 @@ +from ..base import LocalCounterfactualMethod +import dice_ml +import pandas as pd + + +class DiceMethod(LocalCounterfactualMethod): + """ + Implementation of the Dice method for generating counterfactual instances.(https://interpret.ml/DiCE/) + + The Dice method uses a specified machine learning model and data to generate counterfactual examples, + providing insights into how changes in feature values can influence model predictions. + + Methods: + -------- + __init__(): + Initializes the DiceMethod instance. + + fit(model, data, outcome_name, continuous_features, feat_to_vary, random_seed=13): + Fits the DiceMethod to the provided dataset, preparing the counterfactual generator. + + explain_instances(instances, num_counterfactuals): + Generates counterfactual instances for the specified input instances. + """ + + def __init__(self): + """ + Initializes a new instance of the DiceMethod class. + + Attributes: + ---------- + cf_generator : None or dice_ml.Dice + Counterfactual generator instance, initially set to None. + """ + super().__init__() + self.cf_generator = None + + def fit( + self, + model, + data, + outcome_name, + continuous_features, + feat_to_vary, + random_seed=13, + ): + """ + Fits the DiceMethod to the provided dataset by creating a counterfactual generator. + + Parameters: + ---------- + model : object + A machine learning model used for predictions. + data : pd.DataFrame + The dataset containing features and the outcome variable. + outcome_name : str + The name of the outcome variable in the dataset. + continuous_features : List[str] + A list of names for continuous (numerical) features. + feat_to_vary : List[str] + A list of feature names that can be varied to generate counterfactuals. + random_seed : int, optional + Seed for random number generation to ensure reproducibility, by default 13. + """ + dice_dataset = dice_ml.Data( + dataframe=data, + continuous_features=continuous_features, + outcome_name=outcome_name, + ) + self.random_seed = random_seed + self.feat_to_vary = feat_to_vary + dice_model = dice_ml.Model(model=model, backend="sklearn", func=None) + self.cf_generator = dice_ml.Dice(dice_dataset, dice_model, method="random") + + def explain_instances( + self, instances: pd.DataFrame, num_counterfactuals: int + ) -> pd.DataFrame: + """ + Generates counterfactual instances for the specified input instances. + + Parameters: + ---------- + instances : pd.DataFrame + DataFrame containing the instances for which counterfactuals are generated. + num_counterfactuals : int + The number of counterfactuals to generate for each instance. + + Returns: + ------- + pd.DataFrame + A DataFrame containing the generated counterfactuals. + + Raises: + ------- + ValueError + If the counterfactual generator has not been initialized (fit method not called). + """ + if self.cf_generator is None: + raise ValueError("Fit the Local Counterfactual method first.") + + counterfactuals = self.cf_generator.generate_counterfactuals( + instances, + total_CFs=num_counterfactuals, + desired_class=1, + random_seed=self.random_seed, + features_to_vary=self.feat_to_vary, + posthoc_sparsity_param=None, + ) + + return pd.concat( + [ + counterfactuals.cf_examples_list[i].final_cfs_df.iloc[:, :-1] + for i in range(len(instances)) + ], + ignore_index=False, + ) diff --git a/aix360/algorithms/glance/local_cfs/nearest_neighbor.py b/aix360/algorithms/glance/local_cfs/nearest_neighbor.py new file mode 100644 index 0000000..b2087fd --- /dev/null +++ b/aix360/algorithms/glance/local_cfs/nearest_neighbor.py @@ -0,0 +1,114 @@ +from typing import List +import warnings + +import pandas as pd +import numpy as np +from sklearn.neighbors import NearestNeighbors +from sklearn.preprocessing import OneHotEncoder +from sklearn.compose import ColumnTransformer + +from ..base import LocalCounterfactualMethod +from ..utils.action import extract_actions_pandas, apply_actions_pandas_rows + +class NearestNeighborMethod(LocalCounterfactualMethod): + """ + NearestNeighborMethod is a local counterfactual method that finds the nearest unaffected neighbors in the training dataset to explain instances by generating counterfactuals. + + This method identifies instances in the training set where the model prediction remains unaffected, + and uses the nearest neighbors (based on feature similarity) to generate counterfactual explanations for new instances. + + Methods: + -------- + __init__(): + Initializes the NearestNeighborMethod instance. + + fit(model, data, outcome_name, continuous_features, feat_to_vary, random_seed=13): + Fits the method to the training data by identifying unaffected instances based on model predictions and preparing the feature encoding for nearest neighbor searches. + + explain_instances(instances, num_counterfactuals): + Finds and returns the nearest unaffected neighbors for each instance, generating the specified number of counterfactual explanations. + """ + def __init__(self): + """ + Initializes a new instance of the NearestNeighborMethod class. + """ + super().__init__() + + def fit( + self, + model, + data: pd.DataFrame, + outcome_name: str, + continuous_features: List[str], + feat_to_vary: List[str], + random_seed=13, + ): + """ + Fits the NearestNeighborMethod by identifying unaffected instances in the training dataset and preparing feature encodings for counterfactual search. + + Parameters: + ---------- + model : object + A machine learning model with a `predict` method that outputs binary predictions (0 or 1). + data : pd.DataFrame + A dataset containing the features and outcome variable used for fitting the method. + outcome_name : str + The name of the outcome column in the dataset. + continuous_features : List[str] + A list of continuous (numerical) feature column names. + feat_to_vary : List[str] + A list of features allowed to vary when generating counterfactuals. + random_seed : int, optional + Seed for random number generation to ensure reproducibility, by default 13. + """ + X, y = data.drop(columns=[outcome_name]), data[outcome_name] + self.numerical_features = continuous_features + self.categorical_features = X.columns.difference(continuous_features).tolist() + + self.encoder = ColumnTransformer( + [("ohe", OneHotEncoder(sparse_output=False), self.categorical_features)], + remainder="passthrough", + ).fit(X) + + train_preds = model.predict(X) + self.train_unaffected = X[train_preds == 1] + self.train_unaffected_one_hot = self.encoder.transform(self.train_unaffected) + + self.random_seed = random_seed + self.feat_to_vary = feat_to_vary + + def explain_instances( + self, instances: pd.DataFrame, num_counterfactuals: int + ) -> pd.DataFrame: + """ + Generates counterfactual explanations for the provided instances by finding the nearest unaffected neighbors in the training data. + + Parameters: + ---------- + instances : pd.DataFrame + DataFrame containing the instances for which counterfactual explanations are needed. + num_counterfactuals : int + The number of counterfactuals to generate for each instance. + + Returns: + ------- + pd.DataFrame + A DataFrame containing the nearest unaffected neighbors (counterfactuals) for each instance. + + Notes: + ------ + - If the requested number of counterfactuals exceeds the number of available unaffected instances, a warning is raised, and all unaffected instances are used. + - Nearest neighbors are determined using a one-hot encoded feature representation. + """ + instances_one_not = self.encoder.transform(instances) + if num_counterfactuals > self.train_unaffected.shape[0]: + warnings.warn(f"{num_counterfactuals} were requested, but only {self.train_unaffected.shape[0]} unaffected instances given. Taking all.") + num_counterfactuals = self.train_unaffected.shape[0] + nn = NearestNeighbors(n_neighbors=num_counterfactuals).fit(self.train_unaffected_one_hot) + distances, indices = nn.kneighbors(instances_one_not) + + cfs = [self.train_unaffected.iloc[row] for row in indices] + + return pd.concat(cfs, ignore_index=False) + + diff --git a/aix360/algorithms/glance/local_cfs/random_sampling.py b/aix360/algorithms/glance/local_cfs/random_sampling.py new file mode 100644 index 0000000..93cc13b --- /dev/null +++ b/aix360/algorithms/glance/local_cfs/random_sampling.py @@ -0,0 +1,233 @@ +import pandas as pd +from ..base import LocalCounterfactualMethod +import numpy as np +from sklearn.inspection import permutation_importance + +class RandomSampling(LocalCounterfactualMethod): + """ + RandomSampling is a local counterfactual method that generates counterfactual instances + through random sampling based on the distribution of features in the unaffected training data. + + This method identifies the most important features and the most frequent categories within the + unaffected training data to generate counterfactuals by sampling from these distributions. + + Methods: + -------- + __init__(model, n_most_important, n_categorical_most_frequent, numerical_features, categorical_features, random_state=None): + Initializes the RandomSampling instance with the specified parameters. + + fit(X, y): + Fits the RandomSampling method to the provided training data by calculating feature importances and identifying unaffected instances. + + _sample_instances(n_samples, fixed_feature_values, random_state=None): + Samples instances based on the specified feature distributions, fixing certain feature values while sampling others. + + explain(instance, num_counterfactuals, n_samples=1000, random_state=None): + Generates counterfactual explanations for a given instance by sampling and modifying feature values. + + explain_instances(instances, num_counterfactuals, n_samples=1000, random_state=None): + Generates counterfactuals for multiple instances by calling the explain method for each instance. + """ + def __init__(self, model, n_most_important, n_categorical_most_frequent, numerical_features, categorical_features, random_state=None): + """ + Initializes a new instance of the RandomSampling class. + + Parameters: + ---------- + model : object + A machine learning model used for predictions and feature importance evaluation. + n_most_important : int + The number of most important features to consider when generating counterfactuals. + n_categorical_most_frequent : int + The number of most frequent categories to consider for categorical features. + numerical_features : List[str] + A list of continuous (numerical) feature names. + categorical_features : List[str] + A list of categorical feature names. + random_state : int, optional + Seed for random number generation to ensure reproducibility, by default None. + """ + self.model = model + self.n_most_important = n_most_important + self.n_categorical_most_frequent = n_categorical_most_frequent + self.numerical_features = numerical_features + self.categorical_features = categorical_features + self.random_state = random_state + + def fit(self, X: pd.DataFrame, y: pd.Series): + """ + Fits the RandomSampling method to the provided training data by calculating feature importances and identifying unaffected instances. + + Parameters: + ---------- + X : pd.DataFrame + The training dataset containing feature columns. + y : pd.Series + The target variable corresponding to the training dataset. + + Returns: + ------- + self : RandomSampling + Returns the fitted instance of RandomSampling. + """ + self.X_ = X + self.feature_names_ = X.columns.tolist() + # Permutation feature importance + result = permutation_importance(self.model, X, y, random_state=self.random_state) + self.feature_importances_ = result.importances_mean + top_k_indices = np.argsort(self.feature_importances_)[::-1][:self.n_most_important] + self.top_k_features_ = X.columns[top_k_indices] + + train_preds = self.model.predict(X) + unaffected = X[train_preds == 1] + + # Store min and max values for numerical features + self.numeric_min_ = unaffected[self.numerical_features].min() + self.numeric_max_ = unaffected[self.numerical_features].max() + for f in self.numerical_features: + if np.isnan(self.numeric_min_[f]): + self.numeric_min_[f] = X[f].min() + if np.isnan(self.numeric_max_[f]): + self.numeric_max_[f] = X[f].max() + + # Get the top m most frequent categories for categorical features + self.categorical_top_m_ = {} + for col in self.categorical_features: + top_categories = unaffected[col].value_counts().index[:self.n_categorical_most_frequent] + if top_categories.empty: + top_categories = X[col].value_counts().index[:self.n_categorical_most_frequent] + self.categorical_top_m_[col] = top_categories + + return self + + def _sample_instances(self, n_samples: int, fixed_feature_values, random_state=None): + """ + Samples instances based on the specified feature distributions, fixing certain feature values while sampling others. + + Parameters: + ---------- + n_samples : int + The number of instances to sample. + fixed_feature_values : dict + A dictionary of feature names and their fixed values during sampling. + random_state : int, optional + Seed for random number generation, by default None. + + Returns: + ------- + pd.DataFrame + A DataFrame containing the sampled instances with the same feature structure as the original data. + """ + if random_state is not None: + np.random.seed(random_state) + samples_columns = [] + for col in self.X_.columns: + if col in fixed_feature_values: + column = [fixed_feature_values[col]] * n_samples + elif col in self.numerical_features: + column = np.random.uniform(self.numeric_min_[col], self.numeric_max_[col], n_samples) + else: + column = np.random.choice(self.categorical_top_m_[col], n_samples) + samples_columns.append(column) + return pd.DataFrame({col_name: column for col_name, column in zip(self.X_.columns, samples_columns)}) + + def explain(self, instance, num_counterfactuals, n_samples=1000, random_state=None): + """ + Generates counterfactual explanations for a given instance by sampling and modifying feature values. + + Parameters: + ---------- + instance : pd.DataFrame + A single row DataFrame representing the instance for which counterfactuals are generated. + num_counterfactuals : int + The number of counterfactuals to generate. + n_samples : int, optional + The number of samples to draw for generating counterfactuals, by default 1000. + random_state : int, optional + Seed for random number generation, by default None. + + Returns: + ------- + pd.DataFrame + A DataFrame containing the generated counterfactuals for the provided instance. + + Raises: + ------- + ValueError + If the input instance is not a single-row DataFrame or if its columns do not match the training dataset's columns. + """ + # Check if instance is a single row DataFrame + if not isinstance(instance, pd.DataFrame) or instance.shape[0] != 1: + raise ValueError("Input must be a single row DataFrame.") + + # Check if the DataFrame columns match the features provided during initialization + if set(instance.columns) != set(self.X_.columns): + raise ValueError("Columns of the input instance do not match the columns used during fitting.") + + fixed_feature_values = {} + for col in self.feature_names_: + if col not in self.top_k_features_: + fixed_feature_values[col] = instance[col].item() + random_instances = self._sample_instances(n_samples, fixed_feature_values, random_state) + + # Generate copies of the query instance that will be changed one feature + # at a time to encourage sparsity. + cfs_df = None + candidate_cfs = instance.apply(lambda col: col.repeat(n_samples)).reset_index(drop=True) + # Loop to change one feature at a time, then two features, and so on. + for num_features_to_vary in range(1, len(self.top_k_features_)+1): + selected_features = np.random.choice(self.top_k_features_, (n_samples, 1), replace=True) + for k in range(n_samples): + candidate_cfs.at[k, selected_features[k][0]] = random_instances.at[k, selected_features[k][0]] + preds = self.model.predict(candidate_cfs) + if sum(preds) > 0: + rows_to_add = candidate_cfs[preds == 1] + + if cfs_df is None: + cfs_df = rows_to_add.copy() + else: + cfs_df = pd.concat([cfs_df, rows_to_add]) + cfs_df.drop_duplicates(inplace=True) + # Always change at least 2 features before stopping + if num_features_to_vary >= 2 and len(cfs_df) >= num_counterfactuals: + break + + if cfs_df is None: + return None + + assert isinstance(cfs_df, pd.DataFrame) + if len(cfs_df) > num_counterfactuals: + cfs_df = cfs_df.sample(num_counterfactuals) + cfs_df.reset_index(inplace=True, drop=True) + return cfs_df + + def explain_instances( + self, instances: pd.DataFrame, num_counterfactuals: int, n_samples=1000, random_state=None + ) -> pd.DataFrame: + """ + Generates counterfactuals for multiple instances by calling the explain method for each instance. + + Parameters: + ---------- + instances : pd.DataFrame + DataFrame containing instances for which counterfactual explanations are needed. + num_counterfactuals : int + The number of counterfactuals to generate for each instance. + n_samples : int, optional + The number of samples to draw for generating counterfactuals, by default 1000. + random_state : int, optional + Seed for random number generation, by default None. + + Returns: + ------- + pd.DataFrame + A DataFrame containing the generated counterfactuals for all provided instances. + """ + cfs = [] + for i in range(instances.shape[0]): + cfs_instance = self.explain(instances.iloc[i:i+1], num_counterfactuals=num_counterfactuals, n_samples=n_samples, random_state=random_state) + if cfs_instance is not None: + cfs.append(cfs_instance) + + ret = pd.concat(cfs, ignore_index=False) if cfs != [] else pd.DataFrame(columns=instances.columns).astype(instances.dtypes) + return ret diff --git a/aix360/algorithms/glance/utils/__init__.py b/aix360/algorithms/glance/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aix360/algorithms/glance/utils/action.py b/aix360/algorithms/glance/utils/action.py new file mode 100644 index 0000000..174d8ab --- /dev/null +++ b/aix360/algorithms/glance/utils/action.py @@ -0,0 +1,225 @@ +from typing import List, Any, Optional + +import numpy as np +import numpy.typing as npt +import pandas as pd + + +def apply_action_pandas( + X: pd.DataFrame, + action: pd.Series, + numerical_columns: List[str], + categorical_columns: List[str], + categorical_no_action_token: Any, + numerical_no_action_token: Optional[Any] = None, +) -> pd.DataFrame: + """Apply `action` to all rows of `X`. For numerical columns, add the + respective component from `action`. For categorical columns, set the + component of all rows to the value of `action`, unless it is equal to + the `categorical_no_action_token`, in which case do nothing for this + column. + + Args: + X (pd.DataFrame): matrix of observations + action (pd.Series): for each column / feature, the action to be applied + numerical_columns (List[str]): numerical column names + categorical_columns (List[str]): categorical column names + categorical_no_action_token (Any): special value signifying no-action (i.e. equivalent to 0 for numerical columns) + + Returns: + pd.DataFrame: new observations resulting from the action application. + """ + assert (X.columns == action.index).all() + if numerical_no_action_token is None: + numerical_no_action_token = categorical_no_action_token + + ret = X.copy(deep=True) + for col in numerical_columns: + if action[col] != numerical_no_action_token: + ret[col] = X[col] + action[col] + for col in categorical_columns: + if action[col] != categorical_no_action_token: + ret[col] = action[col] + ret = ret.astype(X.dtypes) + + return ret + + +def apply_action_numpy( + X: npt.NDArray[np.number], + action: npt.NDArray[np.number], + numerical_columns: List[int], + categorical_columns: List[int], + categorical_no_action_token: np.number, +) -> npt.NDArray[np.number]: + """Apply `action` to all rows of `X`. For numerical columns, add the + respective component from `action`. For categorical columns, set the + component of all rows to the value of `action`, unless it is equal to + the `categorical_no_action_token`, in which case do nothing for this + column. + + Note: input array should have a numeric dtype. Thus, categorical columns + should be encoded by numbers (e.g. Ordinal Encoding). + + Args: + X (npt.NDArray[np.number]): matrix of observations + action (npt.NDArray[np.number]): for each column / feature, the action to be applied + numerical_columns (List[int]): numerical column indices + categorical_columns (List[int]): categorical column indices + categorical_no_action_token (np.number): special value signifying no-action (i.e. equivalent to 0 for numerical columns) + + Returns: + npt.NDArray[np.number]: new observations resulting from the action application. + """ + assert len(X.shape) == 2 + assert len(action.shape) == 1 + assert ( + X.shape[1] == action.shape[0] + ), "action should have length equal to the number of columns" + + ret = X.copy() + ret[:, numerical_columns] += action[numerical_columns] + categorical_columns_masked = np.intersect1d( + np.where(action != categorical_no_action_token)[0], categorical_columns + ) + ret[:, categorical_columns_masked] = action[categorical_columns_masked] + + return ret + + +def extract_actions_pandas( + X: pd.DataFrame, + cfs: pd.DataFrame, + categorical_features: List[str], + numerical_features: List[str], + categorical_no_action_token: Any, +): + """ + Extracts the actions needed to convert the original dataset `X` into the counterfactual dataset `cfs`. + + For categorical features, the function identifies changes between `X` and `cfs`. + If no change is observed in a categorical feature, a specified `categorical_no_action_token` is used to denote that no action is needed. + For numerical features, the function computes the difference between the counterfactual and the original values. + + Parameters: + ---------- + X : pd.DataFrame + The original dataset, where each row represents an instance, and each column is a feature. + cfs : pd.DataFrame + The counterfactual dataset, which has the same structure as `X`. It represents the desired state after some action is applied. + categorical_features : List[str] + List of columns in `X` and `cfs` that are categorical. + numerical_features : List[str] + List of columns in `X` and `cfs` that are numerical. + categorical_no_action_token : Any + A token or value to insert into categorical features where no change is needed (i.e., the feature value in `X` is the same as in `cfs`). + + Returns: + ------- + pd.DataFrame + A DataFrame of the same shape as `X` and `cfs` where each value indicates the action required to transform `X` into `cfs`: + - For categorical features: the value in `cfs` if it differs from `X`, otherwise `categorical_no_action_token`. + - For numerical features: the difference between `cfs` and `X`. + """ + actions = X.copy(deep=True) + + for col in categorical_features: + are_equal_indicator = X[col] == cfs[col] + actions.loc[are_equal_indicator, col] = categorical_no_action_token + actions.loc[~are_equal_indicator, col] = cfs.loc[~are_equal_indicator, col] + for col in numerical_features: + actions[col] = cfs[col] - X[col] + return actions + +def apply_actions_pandas_rows( + X: pd.DataFrame, + actions: pd.DataFrame, + numerical_columns: List[str], + categorical_columns: List[str], + categorical_no_action_token: object, +) -> pd.DataFrame: + """ + Applies a set of actions to transform the original dataset `X` based on the actions specified in the `actions` DataFrame. + + For numerical columns, the function adds the values from the `actions` DataFrame to the corresponding columns in `X`. + For categorical columns, if the action for a column is not equal to the `categorical_no_action_token`, the value from the `actions` DataFrame is used to update `X`. + Otherwise, the original value from `X` is retained. + + Parameters: + ---------- + X : pd.DataFrame + The original dataset, where each row represents an instance, and each column is a feature. + actions : pd.DataFrame + A DataFrame of the same shape as `X`, containing the actions to apply to each feature. + - For numerical columns: contains the values to add to the corresponding features in `X`. + - For categorical columns: contains either the new value to apply or the `categorical_no_action_token`. + numerical_columns : List[str] + List of columns in `X` and `actions` that are numerical. + categorical_columns : List[str] + List of columns in `X` and `actions` that are categorical. + categorical_no_action_token : object + A token or value indicating that no action should be taken for a categorical feature. + + Returns: + ------- + pd.DataFrame + A DataFrame of the same shape as `X` where the actions have been applied: + - For numerical columns: each value is updated by adding the corresponding action from `actions`. + - For categorical columns: updated values from `actions` are used where applicable; otherwise, the original values from `X` are retained. + """ + ret = X.copy(deep=True) + for col in numerical_columns: + ret[col] = X[col] + actions[col] + for col in categorical_columns: + no_action_indicator = actions[col] == categorical_no_action_token + ret.loc[~ no_action_indicator, col] = actions.loc[~ no_action_indicator, col].values + ret.loc[no_action_indicator, col] = X.loc[no_action_indicator, col].values + + return ret + +def actions_mean_pandas( + actions: pd.DataFrame, + numerical_features: List[str], + categorical_features: List[str], + categorical_no_action_token: Any, +) -> pd.Series: + """ + Computes the mean action for numerical features and the most frequent action for categorical features from a given actions DataFrame. + + For numerical features, the function calculates the mean of the actions across all instances. + For categorical features, it determines the most frequent value in the `actions` DataFrame, unless all values are equal to the `categorical_no_action_token`, + in which case the token is returned. + + Parameters: + ---------- + actions : pd.DataFrame + A DataFrame where each row represents an instance, and each column represents an action for a feature (either numerical or categorical). + numerical_features : List[str] + List of columns in `actions` that are numerical features. + categorical_features : List[str] + List of columns in `actions` that are categorical features. + categorical_no_action_token : Any + A token or value that indicates no action is needed for categorical features. + + Returns: + ------- + pd.Series + A Series where: + - For numerical features, the values are the mean of the actions for each numerical column. + - For categorical features, the values are the most frequent action in each categorical column, or the `categorical_no_action_token` if no action was needed. + """ + ret = pd.Series(index=actions.columns, dtype="object") + ret[numerical_features] = actions[numerical_features].mean() + for col in categorical_features: + if (actions[col] == categorical_no_action_token).all(): + ret[col] = categorical_no_action_token + else: + value_cnts = actions[col].value_counts() + most_freq = ( + value_cnts.index[0] + if value_cnts.index[0] != categorical_features + else value_cnts.index[1] + ) + ret[col] = most_freq + + return ret diff --git a/aix360/algorithms/glance/utils/centroid.py b/aix360/algorithms/glance/utils/centroid.py new file mode 100644 index 0000000..27bc664 --- /dev/null +++ b/aix360/algorithms/glance/utils/centroid.py @@ -0,0 +1,71 @@ +from typing import List +import pandas as pd +import numpy as np +import numpy.typing as npt +from statistics import multimode +from IPython.display import display + + +def centroid_pandas( + X: pd.DataFrame, + numerical_columns: List[str], + categorical_columns: List[str], +) -> pd.DataFrame: + """Calculates the centroid of the rows of a pandas DataFrame. Specifically, + for the `numerical_columns` columns, the centroid has value the mean of all + rows, while for the `categorical_columns` columns, the centroid has value + the mode of all rows. + + Args: + X (pd.DataFrame): matrix of observations + numerical_columns (List[str]): numerical column names + categorical_columns (List[str]): categorical column names + + Returns: + pd.DataFrame: DataFrame whose single row is the centroid + """ + centroid = pd.DataFrame(columns=X.columns).astype(X.dtypes) + + centroid.loc[0, numerical_columns] = X[numerical_columns].mean(axis="index") + if categorical_columns != []: + centroid.loc[0, categorical_columns] = X[categorical_columns].apply( + lambda col: multimode(col)[0] + ) + # centroid.loc[0, categorical_columns] = X[categorical_columns].mode().iloc[0] + + return centroid + + +def centroid_numpy( + X: npt.NDArray[np.number], + numerical_columns: List[int], + categorical_columns: List[int], +) -> npt.NDArray[np.number]: + """Calculates the centroid of the rows of a 2d numy array. Specifically, + for the `numerical_columns` columns, the centroid has value the mean of all + rows, while for the `categorical_columns` columns, the centroid has value + the mode of all rows. + + Args: + X (npt.NDArray[np.number]): matrix of observations + numerical_columns (List[int]): numerical column indices + categorical_columns (List[int]): categorical column indices + + Returns: + npt.NDArray[np.number]: 2d numpy array whose single row is the centroid + """ + assert len(X.shape) == 2 + centroid = np.zeros((1, X.shape[1])) + + centroid[:, numerical_columns] = X[:, numerical_columns].mean(axis=0) + + def most_frequent(x): + unique_values, counts = np.unique(x, return_counts=True) + most_common = unique_values[np.argmax(counts)] + return most_common + + centroid[:, categorical_columns] = [ + most_frequent(X[:, i]) for i in categorical_columns + ] + + return centroid diff --git a/aix360/algorithms/glance/utils/metadata_requests.py b/aix360/algorithms/glance/utils/metadata_requests.py new file mode 100644 index 0000000..b3cceed --- /dev/null +++ b/aix360/algorithms/glance/utils/metadata_requests.py @@ -0,0 +1,124 @@ +from ..base import ClusteringMethod, LocalCounterfactualMethod +from ..clustering import KMeansMethod +from ..local_cfs import DiceMethod, NearestNeighborMethod, RandomSampling + + +def _decide_cluster_method(method, n_clusters, random_seed) -> ClusteringMethod: + """ + Determines and returns the appropriate clustering method based on the input `method` argument. + + If `method` is a string specifying a known clustering algorithm, the function initializes the corresponding clustering method (e.g., KMeans). + If `method` is already an instance of a clustering method, it is returned unchanged. + + Parameters: + ---------- + method : str or ClusteringMethod + The desired clustering method. This can either be a string specifying a supported clustering method (e.g., "KMeans") or an instance of a clustering method. + n_clusters : int + The number of clusters to use in the clustering algorithm. + random_seed : int + A seed for the random number generator to ensure reproducibility. + + Returns: + ------- + ClusteringMethod + An instance of the appropriate clustering method based on the input. For example, if `method` is "KMeans", an instance of `KMeansMethod` is returned. + + Raises: + ------- + ValueError + If an unsupported string is passed as the `method` argument. + """ + if isinstance(method, str): + if method == "KMeans": + method = KMeansMethod(num_clusters=n_clusters, random_seed=random_seed) + else: + raise ValueError(f"Unsupported clustering method: {method}") + else: + method = method + return method + + +def _decide_local_cf_method( + method, model, train_dataset, numeric_features_names, + categorical_features_names, feat_to_vary, + random_seed, n_most_important: int = 15, + n_categorical_most_frequent: int = 15, + n_scalars: int = 1000, +) -> LocalCounterfactualMethod: + """ + Determines and returns the appropriate local counterfactual method based on the input `method` argument. + + This function initializes the specified local counterfactual method (e.g., "Dice", "NearestNeighbors", or "RandomSampling") and fits it to the provided training dataset. + If the `method` is already an instance of a local counterfactual method, it is returned unchanged. + + Parameters: + ---------- + method : str or LocalCounterfactualMethod + The desired local counterfactual method. This can either be a string specifying a supported method (e.g., "Dice", "NearestNeighbors", or "RandomSampling") or an instance of a local counterfactual method. + model : object + The machine learning model to be used for generating counterfactuals. + train_dataset : pd.DataFrame + The training dataset on which the counterfactual method will be fit. The dataset must contain a target column named "target". + numeric_features_names : List[str] + A list of feature names that are numeric. + categorical_features_names : List[str] + A list of feature names that are categorical. + feat_to_vary : List[str] + A list of features that are allowed to vary when generating counterfactuals. + random_seed : int + A seed for the random number generator to ensure reproducibility. + n_most_important : int, optional + The number of most important features to consider when generating counterfactuals (used by methods like RandomSampling), by default 15. + n_categorical_most_frequent : int, optional + The number of most frequent categorical values to consider when generating counterfactuals (used by methods like RandomSampling), by default 15. + n_scalars : int, optional + The number of scalar samples used during random sampling (used by RandomSampling), by default 1000. + + Returns: + ------- + LocalCounterfactualMethod + An instance of the appropriate local counterfactual method based on the input. For example, if `method` is "Dice", an instance of `DiceMethod` is returned. + + Raises: + ------- + ValueError + If an unsupported string is passed as the `method` argument. + """ + if isinstance(method, str): + if method == "Dice": + dice = DiceMethod() + dice.fit( + model, + train_dataset, + "target", + numeric_features_names, + feat_to_vary, + random_seed, + ) + method = dice + elif method == "NearestNeighbors": + method = NearestNeighborMethod() + method.fit( + model, + train_dataset, + "target", + numeric_features_names, + feat_to_vary, + random_seed, + ) + elif method == "RandomSampling": + method = RandomSampling( + model=model, + n_most_important=n_most_important, + n_categorical_most_frequent=n_categorical_most_frequent, + numerical_features=numeric_features_names, + categorical_features=categorical_features_names, + random_state=random_seed, + ) + method.fit(train_dataset.drop(columns="target"), train_dataset["target"]) + else: + raise ValueError(f"Unsupported local counterfactual method: {method}") + else: + method = method + return method diff --git a/aix360/data/adult_data/__init__.py b/aix360/data/adult_data/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/aix360/data/adult_data/__init__.py @@ -0,0 +1 @@ + diff --git a/aix360/data/compas_data/__init__.py b/aix360/data/compas_data/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/aix360/data/compas_data/__init__.py @@ -0,0 +1 @@ + diff --git a/aix360/datasets/adult_dataset.py b/aix360/datasets/adult_dataset.py new file mode 100644 index 0000000..fcc6aa7 --- /dev/null +++ b/aix360/datasets/adult_dataset.py @@ -0,0 +1,87 @@ +import os +import datetime +import pandas as pd +import numpy as np +from sklearn.preprocessing import LabelEncoder, OneHotEncoder + +def default_preprocessing(data): + all_columns = ["Age", "Workclass", "Education", "Marital-Status", + "Occupation", "Relationship", "Race", "Sex", "Capital-Gain", + "Capital-Loss", "Hours-Per-Week", "Native-Country", "Status"] + cate_columns = ['Workclass', 'Education', 'Marital-Status', 'Occupation', + 'Relationship', 'Race', 'Sex', 'Native-Country'] + numerical_columns = [c for c in all_columns if c not in cate_columns + ["Status"]] + + # remove redundant education num column (education processed in one_hot) + data = data.drop(2, axis=1) + data = data.drop(4, axis=1) + # remove rows with missing values: '?,' + data = data.replace('?,', np.nan); data = data.dropna() + data.columns = all_columns + for col in data.columns[:-1]: + #print(col) + if col not in cate_columns: + data[col] = data[col].apply(lambda x: float(x[:-1])) + else: + data[col] = data[col].apply(lambda x: x[:-1]) + # Prepocess Targets to <=50K = 0, >50K = 1 + data[data.columns[-1]] = data[data.columns[-1]].replace(['<=50K', '>50K'], + [0, 1]) + + data = data.reset_index(drop=True) + + for col in numerical_columns: + data[col] = data[col].astype(int) + + for col in data.columns: + if col not in numerical_columns and col != data.columns[-1]: + data[col] = data[col].astype(str) + data = data[data['Native-Country'] != 'Holand-Netherlands'] + return data + + +class AdultDataset(): + """Adult Dataset. + + The Adult dataset, also known as the "Census Income" dataset, is a widely used collection of demographic information derived from the 1994 U.S. Census database + and is available at https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data. + The target variable is whether an individual earns more than $50,000 per year, + making it a popular dataset for classification tasks in machine learning. + """ + + def __init__(self, custom_preprocessing=default_preprocessing, dirpath=None): + self._dirpath = dirpath + if not self._dirpath: + self._dirpath = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'data','adult_data') + + self._filepath = os.path.join(self._dirpath, 'adult.csv') + print("Using Adult dataset: ", self._filepath) + + try: + #require access to dataframe + #df = pd.read_csv(filepath) + self._df = pd.read_csv(self._filepath, header = None, delim_whitespace = True) + except IOError as err: + print("IOError: {}".format(err)) + print("To use this class, please place the adult.csv:") + print("file, as-is, in the folder:") + print("\n\t{}\n".format(os.path.abspath(os.path.join( + os.path.abspath(__file__), '..', 'data','adult_data')))) + import sys + sys.exit(1) + + if custom_preprocessing: + #require access to dataframe + #self._data = custom_preprocessing(df) + self._data = custom_preprocessing(self._df.copy()) + + # return a copy of the dataframe with Riskperformance as last column + def dataframe(self): + # First pop and then add 'Riskperformance' column + dfcopy = self._data.copy() + return(dfcopy) + + def data(self): + return self._data + diff --git a/aix360/datasets/compas_dataset.py b/aix360/datasets/compas_dataset.py new file mode 100644 index 0000000..a35714d --- /dev/null +++ b/aix360/datasets/compas_dataset.py @@ -0,0 +1,116 @@ +import os +import datetime +import pandas as pd +import numpy as np +from sklearn.preprocessing import LabelEncoder, OneHotEncoder + +def default_preprocessing(data): + data = data.dropna(subset=["days_b_screening_arrest"]) + data = data.rename(columns={data.columns[-1]: "status"}) + data = data.to_dict("list") + for k in data.keys(): + data[k] = np.array(data[k]) + + dates_in = data["c_jail_in"] + dates_out = data["c_jail_out"] + # this measures time in Jail + time_served = [] + for i in range(len(dates_in)): + di = datetime.datetime.strptime(dates_in[i], "%Y-%m-%d %H:%M:%S") + do = datetime.datetime.strptime(dates_out[i], "%Y-%m-%d %H:%M:%S") + time_served.append((do - di).days) + time_served = np.array(time_served) + time_served[time_served < 0] = 0 + data["time_served"] = time_served + + """ Filtering the data """ + # These filters are as taken by propublica + # (refer to https://github.com/propublica/compas-analysis) + # If the charge date of a defendants Compas scored crime was not within 30 days + # from when the person was arrested, we assume that because of data quality + # reasons, that we do not have the right offense. + idx = np.logical_and( + data["days_b_screening_arrest"] <= 30, data["days_b_screening_arrest"] >= -30 + ) + + # We coded the recidivist flag -- is_recid -- to be -1 + # if we could not find a compas case at all. + idx = np.logical_and(idx, data["is_recid"] != -1) + + # In a similar vein, ordinary traffic offenses -- those with a c_charge_degree of + # 'O' -- will not result in Jail time are removed (only two of them). + idx = np.logical_and(idx, data["c_charge_degree"] != "O") + # F: felony, M: misconduct + + # We filtered the underlying data from Broward county to include only those rows + # representing people who had either recidivated in two years, or had at least two + # years outside of a correctional facility. + idx = np.logical_and(idx, data["score_text"] != "NA") + + # select the examples that satisfy this criteria + for k in data.keys(): + data[k] = data[k][idx] + data = pd.DataFrame(data) + cols = [ + "Sex", + "Age_Cat", + "Race", + "C_Charge_Degree", + "Priors_Count", + "Time_Served", + "Status", + ] + data = data[[col.lower() for col in cols]] + data.columns = cols + return data + + +class COMPASDataset(): + """COMPAS Dataset. + + The COMPAS dataset (Correctional Offender Management Profiling for Alternative Sanctions) Angwin et al. (2016) + is available at https://github.com/propublica/compas-analysis/blob/master/compas-scores-two-years.csv. + Detailed description and information on the dataset can be found at https://www.propublica.org/ + article/how-we-analyzed-the-compas-recidivism-algorithm. It categorizes recidivism risk + based on several factors, including race. + + + + """ + + def __init__(self, custom_preprocessing=default_preprocessing, dirpath=None): + self._dirpath = dirpath + if not self._dirpath: + self._dirpath = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'data','compas_data') + + self._filepath = os.path.join(self._dirpath, 'compas.csv') + print("Using Compas dataset: ", self._filepath) + + try: + #require access to dataframe + #df = pd.read_csv(filepath) + self._df = pd.read_csv(self._filepath) + except IOError as err: + print("IOError: {}".format(err)) + print("To use this class, please place the compas.csv:") + print("file, as-is, in the folder:") + print("\n\t{}\n".format(os.path.abspath(os.path.join( + os.path.abspath(__file__), '..', 'data','compas_data')))) + import sys + sys.exit(1) + + if custom_preprocessing: + #require access to dataframe + #self._data = custom_preprocessing(df) + self._data = custom_preprocessing(self._df.copy()) + + # return a copy of the dataframe with Riskperformance as last column + def dataframe(self): + # First pop and then add 'Riskperformance' column + dfcopy = self._data.copy() + return(dfcopy) + + def data(self): + return self._data + diff --git a/examples/glance/Adult.ipynb b/examples/glance/Adult.ipynb new file mode 100644 index 0000000..048afde --- /dev/null +++ b/examples/glance/Adult.ipynb @@ -0,0 +1,751 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## GLANCE: Global Actions In A Nutshell for Counterfactual Explainability\n", + "\n", + "**GLANCE** is a versatile and adaptive framework, introduced in our [respective paper](https://arxiv.org/abs/2405.18921), for generating *global counterfactual explanations*.
\n", + "These explanations are expressed as actions that offer recourse to large population subgroups.
The framework aims to provide explanations and insights, ensuring that the actions benefit as many individuals as possible.\n", + "\n", + "GLANCE consists of two algorithms: \n", + "\n", + "- **C-GLANCE** that employs a clustering-based approach, ideal for explaining and debugging the model\n", + "- **T-GLANCE** that employs a tree-based approach, ideal for policy-making and auditing.\n", + "\n", + "In this notebook, we will explore how to use these algorithms effectively.\n", + "\n", + "We will use the Adult Dataset from the UCI Machine Learning Repository ([reference](https://archive.ics.uci.edu/dataset/2/adult)).
\n", + "Adult Dataset is a widely used collection of demographic information derived from the 1994 U.S. Census database. It includes features such as age, work class,
education level, marital status, occupation, relationship, race, sex, hours worked per week and the target variable is whether an individual earns more than $50,000 per year,
making it a popular dataset for classification tasks in machine learning.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Preliminaries\n", + "\n", + "### Import Dependencies \n", + "As usual in python, the first step is to import all necessary packages.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from xgboost import XGBClassifier\n", + "import pandas as pd\n", + "from aix360.algorithms.glance.iterative_merges.iterative_merges import C_GLANCE\n", + "from aix360.algorithms.glance.counterfactual_tree.counterfactual_tree import T_GLANCE\n", + "from aix360.datasets.adult_dataset import AdultDataset\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.preprocessing import OneHotEncoder\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.compose import ColumnTransformer" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load Dataset\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using Adult dataset: c:\\users\\nikolastheol\\documents\\github\\aix360\\aix360\\datasets\\..\\data\\adult_data\\adult.csv\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
AgeWorkclassEducationMarital-StatusOccupationRelationshipRaceSexCapital-GainCapital-LossHours-Per-WeekNative-CountryStatus
039State-govBachelorsNever-marriedAdm-clericalNot-in-familyWhiteMale2174040United-States0
150Self-emp-not-incBachelorsMarried-civ-spouseExec-managerialHusbandWhiteMale0013United-States0
238PrivateHS-gradDivorcedHandlers-cleanersNot-in-familyWhiteMale0040United-States0
353Private11thMarried-civ-spouseHandlers-cleanersHusbandBlackMale0040United-States0
428PrivateBachelorsMarried-civ-spouseProf-specialtyWifeBlackFemale0040Cuba0
\n", + "
" + ], + "text/plain": [ + " Age Workclass Education Marital-Status Occupation \\\n", + "0 39 State-gov Bachelors Never-married Adm-clerical \n", + "1 50 Self-emp-not-inc Bachelors Married-civ-spouse Exec-managerial \n", + "2 38 Private HS-grad Divorced Handlers-cleaners \n", + "3 53 Private 11th Married-civ-spouse Handlers-cleaners \n", + "4 28 Private Bachelors Married-civ-spouse Prof-specialty \n", + "\n", + " Relationship Race Sex Capital-Gain Capital-Loss Hours-Per-Week \\\n", + "0 Not-in-family White Male 2174 0 40 \n", + "1 Husband White Male 0 0 13 \n", + "2 Not-in-family White Male 0 0 40 \n", + "3 Husband Black Male 0 0 40 \n", + "4 Wife Black Female 0 0 40 \n", + "\n", + " Native-Country Status \n", + "0 United-States 0 \n", + "1 United-States 0 \n", + "2 United-States 0 \n", + "3 United-States 0 \n", + "4 Cuba 0 " + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data = AdultDataset()\n", + "df = data.dataframe()\n", + "df.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example Model to be used for explanations\n", + "We use the train set to train a simple XGBoostClassifier. 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.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "df = df.sample(frac=0.01, random_state=42)\n", + "X = df.drop(columns='Status')\n", + "y = df['Status']\n", + "X_train, X_test, y_train, y_test = train_test_split(X,y,test_size=0.2,random_state=13)\n", + "num_features = X_train._get_numeric_data().columns.to_list()\n", + "cate_features = X_train.columns.difference(num_features)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "model = XGBClassifier()\n", + "preprocessor = ColumnTransformer(\n", + " transformers=[\n", + " (\n", + " \"cat\",\n", + " OneHotEncoder(sparse_output=False, handle_unknown=\"ignore\"),\n", + " cate_features,\n", + " )\n", + " ],\n", + " remainder=\"passthrough\",\n", + ")\n", + "_model = Pipeline(\n", + " [\n", + " (\"preprocessor\", preprocessor),\n", + " (\"classifier\", model),\n", + " ]\n", + ")\n", + "_model.fit(X_train,y_train)\n", + "predictions = _model.predict(X_test)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "GLANCE focuses on the population that has obtained the unfavorable outcome (affected population) (affected population) by a ML model.
It aims to find the *s* global actions that, if applied, the specific population will achieve the favorable outcome.
\n", + "
\n", + "For this reason, our first step is to identify the affected population, as it serves as input to the algorithm." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "affected = X_test[predictions == 0].reset_index(drop=True)\n", + "train_dataset = df.copy()\n", + "\n", + "\n", + "for col in num_features:\n", + " train_dataset[col] = train_dataset[col].astype(float)\n", + " \n", + "train_dataset[\"target\"] = df['Status']\n", + "train_dataset.drop(columns='Status',inplace=True)\n", + "\n", + "#feat_to_vary is a variable used by DiCE counterfactual explanation\n", + "feat_to_vary = list(affected.columns)\n", + "target_name = \"Status\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## A Practical Example of GLANCE" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## C-GLANCE \n", + "C-GLANCE is a clustering-based algorithm designed to generate global counterfactual explanations.
It starts by forming initial clusters and gradually merges them until the number of clusters matches the user-defined final_clusters parameter.
From each of these final clusters, the best action is selected, and together, these actions form the global explanation.\n", + "\n", + "C-GLANCE framework is loaded with:\n", + " - the model to be explained\n", + " - number of initial clusters, \n", + " - number of final clusters, from each of which the best action is extracted\n", + " - number of local counterfactuals, that the Local Counterfactual Method generates for each centroid of the initial clusters\n", + "\n", + " C-GLANCE algorithm allows the users to specify the number of global actions generated and serves as a tool to explain and debug ML models." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 1/1 [00:00<00:00, 2.76it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.92it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.61it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.89it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.79it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.03it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.02it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.06it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.99it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.01it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.97it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.00it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.88it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.04it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.99it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.01it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.97it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.99it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.96it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.92it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.01it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.16it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.40it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.17it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.96it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.02it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.00it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.08it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.14it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.98it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.04it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.00it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.29it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.92it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.83it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.94it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.00it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.02it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.17it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 3.02it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[1mAction 1 \n", + "\u001b[0m\u001b[1mEducation\u001b[0m = \u001b[31mBachelors\u001b[39m \n", + "\u001b[1mCapital-Gain\u001b[0m +\u001b[31m48937.7\u001b[39m \n", + "\n", + "\u001b[1mEffectiveness:\u001b[0m \u001b[32m100.00%\u001b[39m\t\u001b[1mCost:\u001b[0m \u001b[35m5.79\u001b[39m\n", + "\n", + "\n", + "\u001b[1mAction 2 \n", + "\u001b[0m\u001b[1mAge\u001b[0m +\u001b[31m10.0\u001b[39m \n", + "\u001b[1mCapital-Gain\u001b[0m +\u001b[31m32792.9\u001b[39m \n", + "\n", + "\u001b[1mEffectiveness:\u001b[0m \u001b[32m100.00%\u001b[39m\t\u001b[1mCost:\u001b[0m \u001b[35m4.65\u001b[39m\n", + "\n", + "\n", + "\u001b[1mAction 3 \n", + "\u001b[0m\u001b[1mMarital-Status\u001b[0m = \u001b[31mSeparated\u001b[39m \n", + "\u001b[1mCapital-Gain\u001b[0m +\u001b[31m71470.0\u001b[39m \n", + "\u001b[1mHours-Per-Week\u001b[0m +\u001b[31m32.3\u001b[39m \n", + "\n", + "\u001b[1mEffectiveness:\u001b[0m \u001b[32m100.00%\u001b[39m\t\u001b[1mCost:\u001b[0m \u001b[35m11.47\u001b[39m\n", + "\n", + "\n", + "\u001b[1mTOTAL EFFECTIVENESS:\u001b[0m \u001b[32m100.00%\u001b[39m\n", + "\u001b[1mTOTAL COST:\u001b[0m \u001b[35m5.81\u001b[39m\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\users\\nikolastheol\\documents\\github\\aix360\\aix360\\algorithms\\glance\\iterative_merges\\iterative_merges.py:1032: FutureWarning: iteritems is deprecated and will be removed in a future version. Use .items instead.\n", + " for column_name, value in row['action'].to_frame().T.reset_index(drop=True).iteritems():\n" + ] + } + ], + "source": [ + "global_method = C_GLANCE(\n", + " _model, # model to be explained\n", + " initial_clusters=40, # starting number of clusters\n", + " final_clusters=3, # number of final clusters after merging, from each of which the best action is extracted\n", + " num_local_counterfactuals=10, # number of counterfactuals the Local Counterfactual Method generates for each centroid of the initial clusters\n", + ")\n", + "global_method.fit(\n", + " df.drop(columns=[\"Status\"]),\n", + " df[\"Status\"],\n", + " train_dataset,\n", + " feat_to_vary, # DiCE parameter that chooses which features to change in order to generate counterfactuals\n", + " cf_generator=\"Dice\"\n", + ")\n", + "\n", + "clusters, clusters_res = global_method.explain_group(affected)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## C-GLANCE Output\n", + "C-GLANCE generates a set of final actions, with a focus on their overall impact when applied to the entire affected population. While each action is initially associated with a specific cluster, the key metrics we prioritize are the *Total Effectiveness* and *Total Cost* across the whole population.\n", + "\n", + "- *Total Effectiveness* is the percentage of individuals that achieve the favorable outcome, if each one of the final actions is applied to the whole affected population.
\n", + "- *Total Cost* is calculated as the mean recourse cost of the whole set of final actions over the entire population.\n", + "\n", + "Additionally, for each generated action the suggested changes are also reported, as well as the *effectiveness* and *cost* they achieve on the population of the cluster they were extracted from. More specifically:\n", + "\n", + "- *Effectiveness*, for each cluster-action pair ($C$, $a$), represents the percentage of individuals in $C$ who get the favorable outcome when the action $a$ is applied.\n", + "
\n", + "- *Cost*, for each cluster-action pair ($C$, $a$), is the mean recourse cost computed when the action $a$ is applied to the individuals of cluster $C$.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the above output of C-GLANCE algorithm we can see the 3 actions computed.
\n", + "For example:\n", + "\n", + "\n", + "**Action 1**
\n", + "**Education =** Bachelors
\n", + "**Capital-Gain =** +48937.7
\n", + "**Effectiveness**: 100.00%\t **Cost**: 5.79
\n", + "Action 1 corresponds to the affected individuals of cluster 1 from the 3 final clusters, and suggests that if the affected individuals change their education to Bachelors Degree and increase their capital-gain for 48937,7 they will **all** get the favorable outcome as the respective Effectiveness for Action 1 is **100.0%**.
\n", + "In the same manner we can explain the other 2 Actions and their respective metrics.\n", + "\n", + "**Action 2**
\n", + "**Age =** +10.0
\n", + "**Capital-Gain =** +32792.9
\n", + "**Effectiveness**: 100.00%\t **Cost**: 4.65
\n", + "Action 2 corresponds to the affected individuals of cluster 2 from the 3 final clusters, and suggests that if the affected individuals were 10 years older and increased their capital-gain for +32792.9 they will **all** get the favorable outcome as the respective Effectiveness for Action 2 is **100.0%**.
\n", + "\n", + "**Action 3**
\n", + "**Marital-Status =** Seperated
\n", + "**Capital-Gain =** +71470.0
\n", + "**Hours-Per-Week =** +32.3
\n", + "**Effectiveness**: 100.00%\t **Cost**: 11.47
\n", + "Action 3 corresponds to the affected individuals of cluster 3 from the 3 final clusters, and suggests that if the affected individuals had Seperated as marital status, increased their capital-gain for +71470.0 and increased their working hours per week for 32.3 hours more they will **all** get the favorable outcome as the respective Effectiveness for Action 3 is **100.0%**.
\n", + "\n", + "In the end of the ouput the *Total Effectiveness* and *Total Cost* is showcased.

\n", + "**TOTAL EFFECTIVENESS:** 100.00%
\n", + "**TOTAL COST** 5.81
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## C-GLANCE Modularity\n", + "Our framework is highly **modular**, allowing users to customize various aspects of it.
\n", + "\n", + "Specifically:\n", + "- **Choice of Local Counterfactual Methods**: Users can select from a variety of local counterfactual methods to generate candidate counterfactual explanations, such as:\n", + " - **NearestNeighbors**: When queried to provide *k* counterfactuals for an affected individual, it retrieves the k nearest neighbors from the set of unaffected instances based on their proximity to the affected individual.\n", + " - **Random Sampling**: To find counterfactuals for an affected instance, this method iteratively modifies its features one at a time. The process begins by randomly altering one feature at a time, generating multiple new candidate instances\n", + "\n", + "- **Strategy for Selecting Actions**: Additionally, users can choose different strategies for selecting the best actions from the generated counterfactuals. This enables fine-tuning of the process, allowing for the optimal balance between effectiveness and recourse cost, based on user-defined preferences.\n", + " - **max-eff** : Selects actions based on maximizing the effectiveness.\n", + " - **low-cost** : Selects the action with the lowest cost that flips a sufficient number of instances.\n", + " - **mean-act** : Selects the mean action from a set of candidate actions.\n", + "\n", + "In order to use them, the user should provide the **fit** method with the **cf_generator** and **cluster_action_choice_algo** variables and choose the methods of his/hers liking." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "global_method = C_GLANCE(\n", + " _model, initial_clusters=100, final_clusters=3, num_local_counterfactuals=10\n", + ")\n", + "global_method.fit(\n", + " df.drop(columns=[\"Status\"]),\n", + " df[\"Status\"],\n", + " train_dataset,\n", + " feat_to_vary,\n", + " cluster_action_choice_algo = 'max-eff',\n", + " cf_generator=\"RandomSampling\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\users\\nikolastheol\\documents\\github\\aix360\\aix360\\algorithms\\glance\\iterative_merges\\iterative_merges.py:294: UserWarning: Requested number of initial clusters is larger than the number of instances to explain. Setting to number of instances.\n", + " warnings.warn(\n", + "c:\\users\\nikolastheol\\documents\\github\\aix360\\aix360\\algorithms\\glance\\iterative_merges\\iterative_merges.py:1032: FutureWarning: iteritems is deprecated and will be removed in a future version. Use .items instead.\n", + " for column_name, value in row['action'].to_frame().T.reset_index(drop=True).iteritems():\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[1mAction 1 \n", + "\u001b[0m\u001b[1mEducation\u001b[0m = \u001b[31mBachelors\u001b[39m \n", + "\u001b[1mCapital-Gain\u001b[0m +\u001b[31m13360.410752281923\u001b[39m \n", + "\n", + "\u001b[1mEffectiveness:\u001b[0m \u001b[32m100.00%\u001b[39m\t\u001b[1mCost:\u001b[0m \u001b[35m2.17\u001b[39m\n", + "\n", + "\n", + "\u001b[1mAction 2 \n", + "\u001b[0m\u001b[1mOccupation\u001b[0m = \u001b[31mTransport-moving\u001b[39m \n", + "\u001b[1mCapital-Gain\u001b[0m +\u001b[31m16762.525423024712\u001b[39m \n", + "\n", + "\u001b[1mEffectiveness:\u001b[0m \u001b[32m100.00%\u001b[39m\t\u001b[1mCost:\u001b[0m \u001b[35m2.68\u001b[39m\n", + "\n", + "\n", + "\u001b[1mAction 3 \n", + "\u001b[0m\u001b[1mOccupation\u001b[0m = \u001b[31mProf-specialty\u001b[39m \n", + "\u001b[1mCapital-Gain\u001b[0m +\u001b[31m17762.44715992576\u001b[39m \n", + "\n", + "\u001b[1mEffectiveness:\u001b[0m \u001b[32m100.00%\u001b[39m\t\u001b[1mCost:\u001b[0m \u001b[35m2.59\u001b[39m\n", + "\n", + "\n", + "\u001b[1mTOTAL EFFECTIVENESS:\u001b[0m \u001b[32m100.00%\u001b[39m\n", + "\u001b[1mTOTAL COST:\u001b[0m \u001b[35m2.25\u001b[39m\n" + ] + } + ], + "source": [ + "clusters, clusters_res = global_method.explain_group(affected)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## T-GLANCE\n", + "T-GLANCE is the second algorithm of the GLANCE framework that generates global counterfactual explanations using a decision tree-like structure.\n", + "\n", + "In contrast to C-GLANCE where users specify the number of the final actions to be generated, T-GLANCE allows the users to specify the split features when building the decision tree.
\n", + "\n", + "This characteristic of T-GLANCE makes it ideal for finding actions that can serve as subgroup policies, for the subgroups corresponding to the nodes of the tree.\n", + "\n", + "T-GLANCE framework is loaded with:\n", + " - the model to be explained,\n", + " - split_features, the specified features to be used in the splits, if **None** we provide suggestions based feature permutation score of the features.\n", + "\n", + "\n", + "T-GLANCE is also modular, as the user can specify in which way the actions of each node(subgroup) are computed.
\n", + "This can be either a Local Counterfactual Explanation Method (**local_method** parameter) or a Global Counterfactual Explanation Method (**global_method** parameter)." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 1/1 [00:00<00:00, 1.94it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.28it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.99it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 2.42it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.54it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.55it/s]\n", + "100%|██████████| 1/1 [00:00<00:00, 1.28it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\n", + "\n", + "\n" + ] + } + ], + "source": [ + "cf_tree = T_GLANCE(_model, \n", + " split_features=None,\n", + " local_method='Dice')\n", + "\n", + "cf_tree.fit(X, y, train_dataset)\n", + "node = cf_tree.partition_group(affected)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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kVlbgaFd/n9QIUm2SXJykj2nPv5nko6ZMlSxapHj/Pyafn6NAAAAAMweAwwAAAAAAAAAzDGLLrpokuS76Wx7K8nZmbKiwqQkf0my/i+2X5RkTKYMK/zs2iRbJjk/yV5T95uQZNdMGWCYEw5KsnySP/3iuSeT1P1VbvTUX39+jQAAAADMnrJCoVCY2yUAAAAAAAAAWDAUCoXUW3rpHP/dd+k6t8vMYd2T/H3xxTPqu+9SVlY2t+sAAAAAzHeqzO0CAAAAAAAAACw4ysrK0nyjjfLKAniB/ytTX5vhBQAAAIDfxwADAAAAAAAAAHNU8402yqtVq87tGnPcq1WrpvlGG83tGgAAAADzLQMMAAAAAAAAMAsuueSSrLHGGjnkkEMqnttmm20ybNiwaXJbb7111lhjjRQKhTly3gYNGsxW/qmnnkrVqlVzySWXVDzXoUOHNGnSJM2aNUuzZs1y++23z3D/m266KU2bNk3jxo2z3Xbb5dNPP63Y9te//jUNGjRIWVlZBg0aNN39+/fvn7Kystx3332z1PfX38M77rgjLVq0yNprr53mzZtn9913z1tvvTVLx5qeL774IltuuWXF465du2b8+PGz3K3Y6/jqq69y+OGHp2HDhmnatGnWX3/9HHvssfn6669neuwjjzwyTz/99Exz119/fbp27TpLfc8777w0atQozZo1S8uWLfPSSy8lSd56662Kn32zZs3SoEGDLL300r/Zf3o/u0022aRiv8aNG6esrCxvvvlmkqR9+/ZZZ5110rRp07Ro0SKPP/54xX7NmzfPl5Mm5Yupj3dIsn6SZkm2TPL6L877U5ITkqyZpEmSg6c+//XU/M9fayWpluSbWfhebJNk2C8e35GkRZK1kzRPsnuS2X1XfZnki0mT0rx580p7X3Xt2jUnn3zybDabdQMHDkzbtm1nKfvf//43TZs2zVprrZVtt902//vf/2aY7dmzZ1ZfffWsvvrq6dChwywd/5fv7YEDB6ZZs2bTbB82bFiWXHLJWTpWZWjbtm1WWmmlNGvWLOuss06OOeaYTJw48Xcf77DDDkvPnj0rHt98880pKyub5u+/nXbaKf369fvd5ygrK8t33333m+dn5++Rhx56KOuss07WXHPN7L333vn++++nm5s8eXJOPPHErL766lljjTVy2WWXVWx76623sv7666dZs2b55JNPfs9LAQAAYAFmgAEAAAAAAABmQd++fdOvX7/ceOONM8x88MEH+eCDD1KjRo38+9//LmG7KUaPHp2zzjoru+yyyzTPn3HGGXnrrbcyaNCgPPzwwzn66KMzatSo3+z/3nvv5Ywzzshjjz2WwYMH57DDDku7du0qtrdp0ybPPfdc6tevP93zDxs2LNdcc01atmz5u/r3798/nTp1yo033pghQ4bk1VdfTdeuXfPFF1/MfOcZWHHFFfPss89WPO7WrdssX2hezNixY7PVVlulQYMG+eCDD/LGG2/klVdeSdOmTYte6P2za6+9Nq1atfrDPX42aNCgXHHFFXnppZcyaNCgnHDCCTnhhBOSJE2aNMmgQYMqvnbbbbccdNBB0+w/o5/df//734r9unbtmsaNG2f99ddPkmy55ZZ5/fXX88Ybb6Rfv37Zd999M2bMmCRJixYtkiQvTj3OP5O8mWRQklOTtP3FOc5KUpbk/UwZKrhg6vN1p+Z//jo6yc5Jfjt6UVz/JJ2S3JhkSJJXk3RNMrvvqv9O/bV58+aV9r6aV0yePDkHHXRQLrnkkrz//vvZZZddZjhY8cwzz+S2227Lm2++mXfeeSePP/54Hn744dIWnkWTJk2arfwZZ5wxzZ+dq666apb3nTx5ciZPnlzxuFWrVhk4cGDF46effjqbbLJJxXOTJk3Kc889l2233Xa2Os5JP/74Y4444ojcd999+eCDD7LiiiumR48e083efPPNeeedd/L+++/npZdeSp8+ffL2228nmfJ3zptvvpkGDRpMM9gEAAAAiQEGAAAAAAAAmCXjxo3LEkssMc1zSy+9dKpWrVrx+LrrrsvBBx+cI4888jd30e7bt2/WWmutbLjhhunRo0fKysoqtr388svZdttt06JFi2ywwQa58847K7Ytu+yys9zxhBNOSMeOHVO3bt1pnv/lXcx//PHHFAqFaS6s/dngwYOz/vrrZ4UVVkiS7LLLLnn00UcrVhTYaqutsvLKK0/33JMnT86RRx6Zf/zjH6lRo8Ysd/7l97BLly655JJL0qhRo4rtzZs3z4477pgkOeigg9KiRYusv/762XXXXfPll18m+f87tZ9++ulZf/31s9566+WJJ56YZluSHHvssUmmXHjfrFmzjBgxIrfeems22WSTbLDBBmnatGkefPDBWep96623Zqmllkrnzp0r+levXj3t2rWruMC/2LF/eRf+tm3b5phjjsl2222XtdZaK3vvvXcmTJiQJKlVq1YWW2yxmfYpKyvLxIkTKwYIvvvuu+n+rMaPH59bbrklRxxxRMVzs/qz69ev3zT77bzzzqlVq1aSKRcsFwqFfPrppzn//PNz+OGHZ4lFF03/qdklf3Gc0ZkysJAkY5L0S9LrF88tP6PzJzliBtt+bekkP//J7JLkkiSNfrG9eZIdp/7+oExZnWH9JLtmykoLyZQVHJZMcvrUbX9JsmiNGjnqqKNy5plnVsr7akZGjBiRvffeO02aNEnjxo3Tt2/fJFN+dieccELWXXfdNG3aNM2bN8/48eMzcuTI7LDDDmnSpEnWX3/9HHbYYUmmvEd//ffY9Lz66qupVq1axZDNMccckwcffHC6Qxp33HFH/vKXv2TRRRdNjRo1cvjhh+e2226b6Tlm9b39s8cffzwbbrhh1l9//Wy99dZ55513kvx29YbBgwdXrFzz85//M888MxtuuGEuu+yyPPjggxWrAzRu3Dj333//TM9ds2bNbL311hkyZEiS5IILLsjGG2+cDTfcMDvttFPFKgNdu3bNPvvskx133DGNGzfO8OHDK47RqlWr/Oc//6n4s/3cc8/l7LPPrhhgePnll7PMMstktdVWy5dffpl99903G2+8cZo0aZKOHTtWHOeDDz7Irrvumo022ijrr7/+NCsf/KxQKOTMM8/MHnvskbFjx87y9/rRRx/NBhtskHXWWSdJctxxx83wZ3nHHXfkqKOOStWqVbP00ktnv/32+012ySWXzNixY2d6XgAAABYu1eZ2AQAAAAAAAJjXjRw5MiNHjvzNYMA999xT8fvy8vLccMMNeeqpp7LMMsukW7duGT16dJZYYokMHjw4Xbt2zeuvv57ll18+Xbp0qdjvu+++y9FHH51HHnkkK6ywQkaNGpUNN9wwm222WVZaaaW8/PLLFdkjjzwye+yxR/bYY4/fdLzrrrtSpUqV7LHHHtP0+tmll16ayy+/PJ9//nmuvfba1KtX7zeZpk2b5rXXXsv777+ftdZaKzfffHMKhUI++eST37z2X7vooouy+eabp3nz5kVzv/Zz1xEjRuSzzz7LpptuOsPsJZdcUjHQce6556Zr164Vd0QfPXp01l133VxwwQV58cUXs8cee+Sjjz6aZv+rrroqffv2zbPPPltx8fmOO+6YAw44IGVlZRk2bFhatmyZTz75ZKZDGK+99lo22WSTopnZOfagQYPy9NNPp0aNGtlqq61y991354ADDsh+++1Xkfniiy+yyy67ZNCgQb/Zv2nTpjnllFOy2mqrZemll06NGjXyzDPP/CZ3zz33pGHDhtNc8D0rP7vPPvss//73v3PTTTf9ZtvkyZNz/PHHp1Ao5MQTT0xZWVnKysqy4qqr5pF3380nSeonOSTJ01P3eWTqrx9lyrBB7yRPJKmVKasjbPerc7yQ5Nsku82w4a9e59RfRyT5LMmM31VThht+HhM6d+r5f77P/ugk6yY5aeprqDJpUsrLy/P8889nzJgxad26dY4++ug59r6akRNPPDFrr7127rnnnowYMSLNmzdP06ZNU6NGjTz55JN5++23U6VKlYwePTrVq1fPzTffnNVWWy3/+te/kiTffPNNkmSzzTbLZpttVnHcZs2a5ZFHHsmKK644zfk+/fTTaVZaqVOnThZffPF88cUXadiw4W+yW2yxRcXjBg0a5Pbbb5/pa/rleztJhgwZMs378ucL/ZMpfz8ceOCBGThwYJo0aZJbbrklbdq0qbjjfzGjR4/Oeuutl/POOy/JlD8rffv2zaabbprJkyfn+++/n+kxvv322zz22GM55ZRTcuutt2bIkCH5z3/+k6pVq+amm27KcccdV7HqxH/+85+8/vrrWW655aY5xiqrrJIVVlgh//3vf1O/fv0svfTS2WGHHXLGGWckmbIiw8+rLxx66KFp3759tt5660yaNCm77bZb7rzzzuy999454IADcvPNN2edddbJ2LFj07Jly2yyySbZaKONkiQ//fRTDjjggNStWzf33ntvqlatOst/j/z6596gQYMMHz48kyZNSrVq1WaaffHFF6fJ1K1bt2LQBAAAAH5mBQYAAAAAAAAo4vzzz0/9+vVz2GGHZZVVVplh7pFHHkmDBg2yzjrrZJlllsn222+fW2+9NUny1FNPZaeddsryy0+5t/xRRx1Vsd8LL7yQjz/+ODvvvHOaNWuW7bffPkkq7vT9S9dee+10hxe+/PLL9OzZM3//+99n2O+vf/1rhgwZkhdeeCG9e/euWFXhl9Zcc81cddVVOeSQQ9KiRYt8/fXXWXLJJX9z4eqvDR48OHffffc0dwmvDLfeemtatGiRxo0b59prr53mAtxq1aqlbdu2SZKWLVtmxRVXzOuvvz7TYw4dOjQ777xzGjdunD333DPffPNNhg4dOtvd7rjjjjRr1iyrrbZarrnmmtk+9l577ZXatWunatWq2XjjjX8zfJEkK6644nQvOv75XPfcc08+/PDDfP755znllFN+c4F48ttVFGb1Z3f99ddnt912yzLLLFPx3Kuvvpodd9wxG220Ufr3758mTZpMs7LI8ssvn2pVq+bqqY9vzJRhgp5Jzpz63KQkn2TK6givJLk0yX5Jvvp170wZgKiMO/TdmikrMDROcm2SQb/YVi1J2yRXJ1mkSpXUrFkzP/zwwzT7X331lFe455575pVXXkky595XP3viiSdyzDHHJEnq1auXvffeO0888UQaNmyYSZMm5fDDD88NN9yQiRMnpkqVKmnZsmUeffTRnHbaabn//vuz6KKLTve4gwYN+s3wwtyy9tprZ9CgQRVfjzzySMW2//73v2nSpEmaNGmSZMpqLF988UX+97//zfS4iyyySA4++OCKx9ttt11OOumknH/++XnzzTenWaHm1/r06ZNmzZplu+22S5s2bdK2bdvcd999eeKJJ9K8efM0a9Ys559/fj799NOKfXbZZZffDC/8rFWrVhk4cGAGDhyYrbfeOrVq1Uq9evUydOjQDBw4MK1atcqYMWPy5JNP5qSTTkqzZs3SokWLfPjhhxkyZEiGDBmSt99+O/vvv3+aNWuWzTbbLD/88MM0QwK77rpr1ltvvVx++eXTrBD0s2J/j8xpf/3rX/PQQw9llVVWyRdffFGScwIAADDvM8AAAAAAAAAARfztb3/Lu+++m+uuu266F/3/rF+/fnn//ffToEGDNGjQIM8++2z69es33ewvL/IuFApZb731prlw99NPP624E/esePXVVzN8+PA0a9YsDRo0yF133ZXu3bunQ4cOv8k2bdo0K620UgYOHDjdY7Vp0yYvvvhiXnnllbRr1y7jxo3LGmusUfT8zz77bIYNG5Y111yz4i7cRx99dK688spZfg316tXLyiuvnP/85z/T3f7cc8/l0ksvzSOPPJLBgwfnoosuyvjx44se85ff5xnZf//9c+SRR2bw4MEZNGhQFltssd8c97vvvkuzZs3SrFmz7LXXXkmSDTbYIC+99FJFZr/99sugQYOy9dZbZ8yYMbN87J/VrFmz4vdVq1bNpEmTZtr9l+6+++40adKk4mL0ww47LM8///w0d7EfOnRoXnzxxRx44IEVz83Kz65QKKR///454ogj8sUXX+TYY49N69atc9ZZZ+Xrr7/O22+/nWbNmk1zkfw+++yTxx57LMcef3yurVYtP/2i66GZshLD10lWzZR/tD5o6rYNkqyW5K1f5H9M8s8kh8/Wd2SKeklWTjL9d1XyXKYMTTySZHCSi5L8+ic0Ick11arl6OOOy+qrr56tt956huc7++yz07p162y33XbZe++9Z/t9Nat+fm//vMLLgQcemPfeey/rr79+Pvzww2y66aYZNGhQNtlkk9xzzz3ZaKONUl5ePsvHX3XVVfPJJ59UPP7hhx8yevTo6Q47/Do7bNiwrLrqqrP1ev6IatWqTfPafv19rl27dqpU+f9LIy666KL0798/tWvXzqGHHprzzz8/77zzTsXP4vjjj6/InnHGGRk0aFBee+21dO7cOWVlZSkUCjn77LMr/r5+66238tZb//+OXWyxxZJM/+fbqlWrPP3003n66aezzTbbJEm23nrr/Otf/8rzzz+fbbfdNoVCIUny4osvVpzjww8/TMeOHVMoFLL00ktP878XQ4cOzaGHHlpx/m233TYDBgyYpZUlfm16P8sVVlhhukNss/Jzv+6667LNNtvk008/nWcGZQAAAJj7DDAAAAAAAADATNSvXz/LLLPMDO/2/dVXX+XJJ5/Mhx9+mGHDhmXYsGEZPnx4vvjii7zxxhtp1apVHn/88YwYMSJJphls2GyzzTJ06NA88cQTFc8NGjRomgvPZ2bXXXfNV199VXHuNm3apHPnzunVq1eSTHN37o8++iivv/56GjVqNN1jDR8+PElSXl6eM888M8cff3xq165d9Pzt2rXL8OHDK87fsmXLXH311WnXrl2SKRd1X3bZZTN9HV27ds2pp56a9957r+K5119/Pf/617/y7bffpk6dOqlbt24mTJiQvn37TrPvpEmTctNNNyVJXnrppXzxxRdp1qzZb85Rp06djB49uuLxt99+m9VWWy1JcvPNN+fbb7/9zT5LLrlkxcXC9957b5LkwAMPzKhRo9KrV69pLp4eO3bsbB17TmnYsGGef/75/Pjjj0mShx56KGuttVaqV69ekbnuuuuy1157TXPH+Zn97JLk0Ucfzbfffps+ffrk0EMPrVgd4ttvv83gwYPTtGnT1KlTJ82aNcvtt9+eAQMG5Nhjj0316tXTrl27jJg0Kf/4Rdf7ktRNsnSSZZJsl+TxqduGTv1a9xf5O5I0TbLOr17z2Ulm/q5KuiY5Ncl7v3ju9ST/SvJtkjpT+0xI0vdX+05KcnySryZNyuabb57hw4ena9euufnmm1O7du1suOGGSX47dDJu3Lj069cvrVu3zv777z/L76sZ2X777StW9hg5cmTuueeetG7dOiNHjsyYMWOyww47pHfv3mnQoEHeeeedDB06NIsttlj23Xff/OMf/8j7779f8d6YFc2bN8/EiRPz9NNPJ0n69u2b3XfffZpBm5/9+c9/zk033ZQxY8bkp59+ynXXXZf9998/yZQ/i9ttt90sn3dGWrZsmbfeeiuDBw9Oktx+++1ZaaWVstJKK6Vhw4b55JNPMnLkyCSp+HtgRt57772st956OeGEE9KuXbu8+OKLadSoUcXP4vLLLy+6/5577pmrrroq33zzTZJk4sSJ013tZXo/31atWuXFF1/Mv//972yxxRZJpgwwXHjhhRWvZ7HFFkurVq1y7rnnVhzriy++yOeff5611147iy++ePr371+x7cMPP6zokiTt27fP3nvvne23377o0N307LTTTnnttdcq/g6+4oorKn6Wv/bnP/8511xzTcrLy/PNN9/kjjvu+M2qL5999llatGgxS8NkAAAALDwqY4VNAAAAAAAAWOBUrVo1kydPnu62G264ITvssMM0F4ZXqVIl+++/f/r165dLL700HTt2zOabb546depkp512yhJLLJEkWWqppfLwww/n9NNPz2mnnZaJEydm1VVXzX333feb8xx55JHZY489sscee8xW97/97W8ZOnRoFllkkVSrVi2XXXZZ1l13yiXiV111Vb744ot07949SXL44Yfnk08+yU8//ZRdd901vXv3rjjOMccck4cffjhffvlldtxxx9SpUycffvjhTM//xhtvpHnz5jPNHXHEEalVq1YOOuig/Pjjj6lWrVpWX331nHPOOVlrrbVy8803Z+21107dunWz/fbbTzNQ8vOd6Js2bZpJkybl1ltvTZ06dX5zAe9pp52W1q1bp3bt2vnXv/6Vv//972nTpk2WXHLJbLvttrN85/hFF100zzzzTM4+++ysscYaWXLJJVOrVq00a9Yse++9d5L87mPPyBdffJFddtklgwYN+s22vfbaKy+//HJatGiRGjVqZNFFF82tt95asX3y5Mm5/vrrc+ONN87SuQqFQu65555cddVVeeutt7LUUkv95iLknwdjRo8enZ9++ikDBgzIIYcckrp16+aBBx7IAw88kGuvvTZ77blnzrrvvvRLUj3JskkeSvLz0a5KckSSMzPlDnx9k6z0i/P0S3LUdDq+kWTm76opx66VKas8/Jgp/0i+epJzkqyV5OYka2fKEMP2SX45prR4klvLyrLkEkukR48e07yvysrKct555yVJTjjhhNxwww1Jkg033DBrrbVW3nzzzVSrVi1LL710atasmWOPPTZnnHHGTPv269cvd911V8XjU089NZdeemnatWuXJk2apFAopEOHDtlkk03y2muv5aijjsrEiRNTXl6ezTffPDvvvHNuvvnmXHTRRRWDFX369Kn4O+eXmjVrlkceeeQ3d8evUqVKbr755hxzzDEZP358VlxxxWkGA3bZZZd07949LVq0yDbbbJP99tsvTZo0STJlNZLddtstyZS78teqVWumr3lmll122dxyyy055JBDMmnSpCy11FK58847U1ZWlhVXXDF/+9vfsvHGG2e55ZbLzjvvXPRY7du3z5AhQ1K9evXUrl17tlaKSZKDDjooX3/9dVq1apVkyvDU4Ycfng022GCm+66wwgpZddVVs+SSS1as1PDzENsRRxxRkbvlllty6qmnpnHjxikrK8uiiy6avn37ZuWVV85DDz2Uk08+ORdffHHKy8uzzDLLTPNnPUlOPvnkLLrootl2223z+OOPZ/nll6/YVuzvkTp16uTaa6/NnnvumUmTJqVx48YV7+tk2vfLX/7yl7z88stZc801U1ZWllNPPbXiPfCzyZMnp2rVqjP/pgIAALBQKSv8vP4gAAAAAAAAMEMbb7xx2rdvnz333PN37f/DDz+kTp06SaZc2P7YY4/l0UcfnYMN503l5eVp2bJl/vvf/6ZKlcpZIH7YsGFp1qxZvvvuu0o5/sLktddeS/v27adZVWJ6TjvttOy4446zdGf1b775JuutvXaaf/NNHpw8OXPiXuzlSVom+W+mDD1UhqGZMuBQt27dvD1kSOrWrTvTfQqFQgYMGJA+ffoUzVWpUiW9e/eepcGe+dnxxx+fAw44oGK1ARYehUIhrVq1ytFHH50DDzxwbtcBAABgHmKAAQAAAAAAAGbBQw89lG7dumXddded5bvY/9Lxxx+f559/PhMnTsyKK66Yvn37pmHDhpXQdOFjgOGPGT58eHr06JEPPvigaG7vvffOEUcckerVq8/2OR544IH86U9/yg1JDvmdPUvt4iSnJrn33nt/1+DShAkT0q9fv9xzzz1Fc2uuuWY6deqUFVZY4Xf1hHnNW2+9lYMPPjgNGzbMTTfdVLHaBAAAACQGGAAAAAAAAAAWOuPGjcsVV1yRxx57rGiuadOmOeuss7LMMsv84XP+5eCD89Dtt+et8vKs/IePVrm+SNK4atXsvO++ueXWW//w8b7++uuce+65GTRoUNHcDjvskBNOOCG1atX6w+cEAAAAmBcZYAAAAAAAAABYCBQKhdx777258sori+bq1KmTnj17plGjRnP0/N98802arrdelho5Mv8uL89Sc/Toc863SVpVrZpRyyyTN95+O3Xr1p2jx3/nnXfSsWPH/PDDD0Vz7dq1y1577ZWysrI5en4AAACAuckAAwAAAAAAAMAC7PXXX0/79u0zadKkorlTTz01O+20U6VeMP/uu+9my802y5o//JB/lZenTqWd6ff5MckOVatmyGKL5Znnn896661XaecqFAp5/PHHc+GFFxbNVa1aNb17986GG25YaV0AAAAASsUAAwAAAAAAAMACZvjw4enZs2fef//9ork999wzRx11VKpXr16iZsmrr76abbfeOmuPH59Hy8szZ9c3+P2+SbJz1ap5t0aNPDlwYDbaaKOSnXvChAm59tprc++99xbNrbXWWunYsWNWWGGFEjUDAAAAmLMMMAAAAAAAAAAsAMaNG5crr7wyjz76aNFckyZN0r59+yyzzDIlavZbr732Wnbafvss+/33ube8PGvNtSZTfJhkr6pV8+Xii+exAQPSvHnzudZl1KhROffcc/PGG28Uze2000457rjjUqtWrRI1AwAAAPjjDDAAAAAAAAAAzKcKhULuv//+XH755UVziy22WHr27Jn11luvRM1mbsiQIdltp53y+aefpvfkyflrkqol7jA5yT+SnF2lSlZYeeU89NhjWXfddUvcYsbefvvtdOzYMT/++GPR3PHHH58//elPKSsrK1EzAAAAgN/HAAMAAAAAAADAfOaNN97I2WefnYkTJxbNnXLKKdl5553n2Qvbx4wZk/bt2+fSSy/N5lWrpn95edYs0bk/THJY1ap5rrw8J554Ys4555wsuuiiJTr77CkUCnnsscdy0UUXFc0tssgi6d27d5o1a1aaYgAAAACzyQADAAAAAAAAwHzgq6++Ss+ePfPee+8Vzf3pT3/KUUcdlRo1apSo2R/3zDPP5LC//CXDP/88Z0+enGOS1Kukc41IcnWS3lNXXbjuxhuz9dZbV9LZ5rwJEybkmmuuyX333Vc0t84666RDhw5ZfvnlS1MMAAAAYBYYYAAAAAAAAACYR40fPz5XXXVVHn744aK5xo0b5+yzz069epV12X/lGzNmTDp16pQrL788kydNyp8nT85xSTZN8kfXjygk+U+SK5LcWaVKqlSrlmPatUuvXr3m2VUXZsXIkSNzzjnn5K233iqa23nnndOuXbvUqlWrRM0AAAAAps8AAwAAAAAAAMA8pFAo5IEHHshll11WNFe7du306tUrjRs3LlGz0vjmm29y/fXX58p//CMfDhuWptWqZf9Jk9IiyYZJlp7F43yb5NUkryS5o1q1DJo0KavXr592J56Ytm3bpm7dupX1EuaKt99+Ox06dMiYMWOK5k444YTsscceKSv7o2MhAAAAALPPAAMAAAAAAADAPODNN99Mhw4dMn78+KK5k046KbvuuusCfwH65MmT88QTT+SKyy7LU08+mR/Gjk2SrLbIImk+cWLWS7JokppT8+OTjEnyTpJXF1kkH0+cmCRZrFatbLvddjnuhBPSunXrVKlSpfQvpoQKhUIeffTRXHzxxUVz1atXT+/evdO0adMSNQMAAAAwwAAAAAAAAAAw14wYMSI9e/bMu+++WzS3++6755hjjkmNGjVK1GzeMnny5HzwwQd59dVXp3y99FLef++9jBs/PuMnTEihUEitGjVSs0aNrLXOOmmxySZp3rx5mjdvnjXXXHOBH1qYkZ9++il9+/bNgw8+WDS3zjrrpGPHjlluueVK1AwAAABYWBlgAAAAAAAAACihn376KVdddVUeeuihorlGjRqlQ4cOqVevXomasSAbMWJEzjnnnAwePLhobtddd82xxx6bmjVrFs0BAAAA/B4GGAAAAAAAAAAqWaFQyEMPPZRLL720aK5WrVrp1atXmjRpUqJmLIzeeuutdOjQIePGjSua++tf/5rddtstZWVlJWoGAAAALOgMMAAAAAAAAABUEheKMy8rFAp5+OGH8/e//71ormbNmunVq1fWX3/9EjUDAAAAFlQGGAAAAAAAAADmoBEjRqRXr1555513iuZ22223HHPMMalZs2aJmsGMjR8/Pn379s1DDz1UNNeoUaN06NAh9erVK1EzAAAAYEFigAEAAAAAAADgD/rpp5/St2/fPPjgg0Vz6667bjp06JDllluuRM1g9s3OEM6xxx6bGjVqlKgZAAAAML8zwAAAAAAAAADwOxQKhTz88MP5+9//XjRXo0aN9OrVK02bNi1RM5hz3njjjXTs2DHjx48vmjvppJOy6667pqysrETNAAAAgPmRAQYAAAAAAACA2TB48OB06NAhY8eOLZo74YQTsscee7igmwVCoVDIQw89lEsvvbRorlatWundu3caN25comYAAADA/MQAAwAAAAAAAMBMjBw5Mr17987gwYOL5nbdddcce+yxqVmzZomaQemNHz8+V111VR5++OGiucaNG6d9+/ZZdtllS9QMAAAAmNcZYAAAAAAAAACYjgkTJuTqq6/O/fffXzS3zjrrpEOHDll++eVL1AzmHV999VV69eqVd999t2hu9913zzHHHJMaNWqUqBkAAAAwLzLAAAAAAAAAADBVoVDIo48+mosvvrhornr16unVq1eaNWtWmmIwH3jjjTfSvn37TJgwoWjulFNOyc4775yysrISNQMAAADmFQYYAAAAAAAAgIXe22+/nQ4dOmTMmDFFc8cff3z+9Kc/ufAaiigUCnnggQdy2WWXFc3Vrl07vXr1SuPGjUvUDAAAAJjbDDAAAAAAAAAAC6VRo0ald+/eeeutt4rmdt5557Rr1y61atUqUTNYcIwfPz5XXnllHnnkkaK5Jk2apH379llmmWVK1AwAAACYGwwwAAAAAAAAAAuNCRMm5Jprrsl9991XNLfWWmulY8eOWWGFFUpTDBYCX375ZXr16pX33nuvaO5Pf/pTjj766FSvXr1EzQAAAIBSMcAAAAAAAAAALNAKhUIee+yxXHTRRUVziyyySHr16pUNNtigRM1g4TVo0KC0b98+EydOLJo79dRTs9NOO6WsrKxEzQAAAIDKZIABAAAAAAAAWCC988476dixY3744YeiuXbt2mWvvfZygTTMBYVCIffff38uv/zyornFFlssvXr1SqNGjUrUDAAAAKgMBhgAAAAAAACABcaoUaNy7rnn5o033iia22mnnXLcccelVq1aJWoGzMy4ceNyxRVX5LHHHiuaW3/99XP22WdnmWWWKVEzAAAAYE4xwAAAAAAAAADM1yZMmJB+/frlnnvuKZpbc80106lTp6ywwgolagb8XsOHD0/Pnj3z/vvvF83tueeeOeqoo1K9evUSNQMAAAD+CAMMAAAAAAAAwHynUCjk8ccfz4UXXlg0V7Vq1fTu3TsbbrhhiZoBc9prr72W9u3bp7y8vGjutNNOy4477piysrISNQMAAABmlwEGAAAAAAAAYL7x7rvvplOnThk9enTR3LHHHpu99torVapUKVEzoLIVCoXce++9ufLKK4vm6tSpk549e6ZRo0YlagYAAADMKgMMAAAAAAAAwDzt66+/zrnnnptBgwYVze2www454YQTUqtWrdIUA+aacePG5Yorrshjjz1WNNesWbOcddZZqVu3bomaAQAAAMUYYAAAAAAAAADmORMmTMh1112Xu+++u2hu9dVXT+fOnbPiiiuWqBkwrxk+fHh69OiRDz74oGhu7733zhFHHJHq1auXqBkAAADwawYYAAAAAAAAgHlCoVDIgAED0qdPn6K5srKy9O7dOy1atChRM2B+8eqrr6Z9+/aZPHly0dwZZ5yR1q1bp6ysrETNAAAAgMQAAwAAAAAAADCXDRkyJB07dsx3331XNHf00Udnn332SZUqVUpTDJhvTZ48Offee2+uuuqqorklllgiPXv2zDrrrFOiZgAAALBwM8AAAAAAAAAAlNw333yT8847L6+99lrRXOvWrXPCCSekdu3aJWoGLGjGjRuXf/zjHxkwYEDR3IYbbpgzzzwzSy+9dImaAQAAwMLHAAMAAAAAAABQEhMnTkz//v1z5513Fs01bNgwnTp1ysorr1yiZsDC4n//+1969OiRjz76qGhun332yRFHHJFFFlmkRM0AAABg4WCAAQAAAAAAAKhUAwYMyPnnnz/TXO/evbPRRhuVoBFA8vLLL6d9+/Yzzf3tb39L69atS9AIAAAAFnwGGAAAAAAAAIA57v3330/Hjh3z7bffFs0dddRRadOmTapUqVKiZgDTmjx5cu6+++5cffXVRXNLLrlkevbsmbXXXrtEzQAAAGDBY4ABAAAAAAAAmCO+/fbbnHfeeXn11VeL5rbbbruceOKJWXTRRUvUDGDWjB07Nv/4xz/yxBNPFM01b948Z555ZpZaaqkSNQMAAIAFgwEGAAAAAAAA4HebOHFirr/++vzzn/8smmvQoEG6dOmSlVdeuUTNAP6Yzz//PD169MjHH39cNPfnP/85hx12WBZZZJESNQMAAID5lwEGAAAAAAAAYLY9+eSTOffcc2ea69WrVzbeeOMSNAKoPC+//HLat28/09xZZ52V7bbbrgSNAAAAYP5kgAEAAAAAAACYJR988EE6deqUr7/+umjuiCOOyL777psqVaqUqBlAaUyePDl33XVXrrnmmqK5pZdeOj169Mhaa61VomYAAAAwfzDAAAAAAAAAAMzQt99+mz59+uTll18umtt2223z17/+NYsuumiJmgHMXWPGjMmll16ap556qmiuRYsW+dvf/palllqqRM0AAABg3mWAAQAAAAAAAJjGpEmTcsMNN+T2228vmqtfv366dOmSVVZZpUTNAOZNn3/+ebp165Zhw4YVze2777457LDDUq1atdIUAwAAgHmMAQYAAAAAAAAgSfLUU0/lnHPOmWmuZ8+e2WSTTUrQCGD+89///jcdO3acae7ss8/OtttuW4JGAAAAMO8wwAAAAAAAAAALsQ8//DCdO3fOyJEji+YOP/zw7LvvvqlatWqJmgHM3yZPnpx//vOf6devX9Fc3bp106NHj6y55polagYAAABzjwEGAAAAAAAAWMh899136dOnT1566aWiuW222SYnn3xyFl100RI1A1gwjRkzJn//+9/z9NNPF81tvPHGOeOMM7LkkkuWphgAAACUmAEGAAAAAAAAWAhMmjQpN954Y2677baiuVVWWSVdunRJ/fr1S9QMYOHy2WefpVu3bvnkk0+K5vbff/8ceuihqVatWomaAQAAQOUzwAAAAAAAAAALsIEDB6ZXr14zzXXv3j2bbrppCRoB8LMXX3wxnTp1mmmuQ4cO2WabbSq/EAAAAFQyAwwAAAAAAACwgPnoo4/SqVOnjBw5smjusMMOy3777ZeqVauWqBkA01NeXp5//vOfue6664rmll122XTv3j1rrLFGiZoBAADAnGWAAQAAAAAAABYAo0ePzgUXXJAXX3yxaG7rrbfOySefnMUWW6xEzQCYHT/++GP+/ve/Z+DAgUVzLVu2zOmnn54llliiNMUAAABgDjDAAAAAAAAAAPOp8vLy3HTTTbnllluK5lZeeeV07do19evXL1EzAOaETz75JN26dctnn31WNHfAAQfkkEMOSbVq1UrUDAAAAH4fAwwAAAAAAAAwn/n3v/+dnj17zjTXrVu3bLbZZiVoBEBle+GFF9KlS5eZ5jp27Jitt966BI0AAABg9hlgAAAAAAAAgPnAxx9/nM6dO+err74qmjv00ENzwAEHpGrVqiVqBkAplZeX5/bbb8/1119fNFevXr107949q6++emmKAQAAwCwwwAAAAAAAAADzqO+//z4XXHBB/vOf/xTNbbnlljnllFNSp06dEjUDYF7w448/5uKLL84zzzxTNNeyZcucccYZWXzxxUvUDAAAAKbPAAMAAAAAAADMQ8rLy3PzzTfn5ptvLppbccUV061btzRo0KA0xQCYp33yySfp2rVrPv/886K5gw46KH/5y1+s1AMAAMBcYYABAAAAAAAA5gHPPvtsunfvPtNc165ds/nmm5egEQDzqxdeeCFdunSZaa5Tp07ZaqutStAIAAAApjDAAAAAAAAAAHPJ0KFD06VLlwwfPrxo7pBDDsmBBx7obtkAzJby8vLcdtttueGGG4rmll9++XTv3j2rrbZaiZoBAACwsDLAAAAAAAAAACX0/fff56KLLsrzzz9fNLfFFlvklFNOyeKLL16iZgAsyL7//vtcfPHFee6554rmNttss5x22mn+9wcAAIBKYYABAAAAAAAAKll5eXluvfXW3HjjjUVzK6ywQrp16+YO2ABUqmHDhqVLly754osviuYOPvjgHHzwwVYAAgAAYI4xwAAAAAAAAACV5Lnnnku3bt1mmuvcuXO23HLLEjQCgGk9++yz6d69+0xz/rcKAACAOcEAAwAAAAAAAMxB7moNwPzIakEAAACUggEGAAAAAAAA+IN++OGHXHTRRXnuueeK5jbffPOceuqpWXzxxUvUDABm3/fff5+LLroozz//fNHcFltskVNOOcX/rgEAADDLDDAAAAAAAADA71BeXp7bbrstN9xwQ9Hc8ssvn27duqVhw4YlagYAc87QoUPTpUuXDB8+vGjukEMOyYEHHmhlIQAAAIoywAAAAAAAAACz4YUXXkiXLl1mmuvUqVO22mqrEjQCgNJ45pln0qNHj5nmunbtms0337wEjQAAAJjfGGAAAAAAAACAmfjkk0/SpUuX/O9//yuaO+igg/KXv/zF3acBWKCVl5fn5ptvzs0331w0t+KKK6Zbt25p0KBBaYoBAAAwzzPAAAAAAAAAANPx448/5qKLLsqzzz5bNLfpppvmtNNOyxJLLFGiZgAw7/j+++9z4YUX5oUXXiia23LLLXPKKaekTp06JWoGAADAvMgAAwAAAAAAAExVXl6e22+/Pddff33RXL169dK9e/esvvrqpSkGAPOBjz/+OF26dMmXX35ZNHfooYfmgAMOsGIRAADAQsgAAwAAAAAAAAu9F154IV26dJlprmPHjtl6661L0AgA5m/PPPNMevToMdNct27dstlmm5WgEQAAAPMCAwwAAAAAAAAslD799NN07do1n332WdHcAQcckEMOOSTVqlUrUTMAWHCUl5fnpptuyi233FI0t/LKK6dr166pX79+iZoBAAAwNxhgAAAAAAAAYKHx448/5pJLLsm///3vormWLVvmtNNOy5JLLlmaYgCwEBg9enQuuOCCvPjii0VzW221VU455ZQstthiJWoGAABAqRhgAAAAAAAAYIFWXl6ef/7zn7nuuuuK5pZddtl07949a6yxRomaAcDC68MPP0yXLl0yYsSIorm2bdtm//33T9WqVUvUDAAAgMpkgAEAAAAAAIAF0osvvphOnTrNNNe+ffu0atWqBI0AgOn597//nZ49e84016NHj7Rs2bIEjQAAAKgsBhgAAAAAAABYYHz22Wfp1q1bPvnkk6K5/fffP4ceemiqVatWomYAwMxMmjQpN954Y2677baiuVVWWSVdu3bNqquuWqJmAAAAzCkGGAAAAAAAAJivjRkzJpdcckkGDhxYNLfxxhvn9NNPz1JLLVWaYgDA7/bdd9+lT58+eemll4rmttlmm5x88slZdNFFS9QMAACAP8IAAwAAAAAAAPOdyZMn55///Gf69etXNFe3bt306NEja665ZomaAQBz2ocffpjOnTtn5MiRRXOHH3549ttvv1SpUqVEzQAAAJhdBhgAAAAAAACYb/z3v/9Nx44dZ5o7++yzs+2225agEQBQSk899VTOOeecmeZ69uyZTTbZpASNAAAAmB0GGAAAAAAAAJinff755+nWrVuGDRtWNLfvvvumbdu2WWSRRUpTDACYayZNmpQbbrght99+e9Fc/fr106VLl6yyyiolagYAAEAxBhgAAAAAAACY54wZMyaXXnppnnrqqaK5jTbaKGeccUaWWmqpEjUDAOY13377bfr06ZOXX365aK5Vq1Y56aSTsuiii5aoGQAAAL9mgAEAAAAAAIB5wuTJk3PXXXflmmuuKZpbeuml071796y99tolagYAzC8++OCDdOrUKV9//XXR3BFHHJF99903VapUKVEzAAAAEgMMAAAAAAAAzGUvv/xy2rdvP9PcmWeeme23374EjQCABcGTTz6Zc889d6a5Xr16ZeONNy5BIwAAAAwwAAAAAAAAUHKff/55unfvnqFDhxbN7bvvvmnbtm0WWWSREjUDABY0EydOzPXXX59//vOfRXMNGjRIly5dsvLKK5eoGQAAwMLHAAMAAAAAAAAlMXbs2Fx66aV58skni+aaN2+eM888M0sttVSJmgEAC4tvv/02559/fl555ZWiue222y4nnnhiFl100RI1AwAAWDgYYAAAAAAAAKDSTJ48OXfffXeuvvrqorkll1wyPXv2zNprr12iZgDAwm7IkCHp3Llzvvnmm6K5o446Km3atEmVKlVK1AwAAGDBZYABAAAAAACAOe6VV17J2WefPdPc3/72t7Ru3boEjQAAZuyJJ57IeeedN9Nc7969s9FGG5WgEQAAwILJAAMAAAAAAABzxBdffJFu3brl448/LprbZ599cvjhh6d69eolagYAMGsmTpyY/v3758477yyaa9iwYTp37pyVVlqpRM0AAAAWDAYYAAAAAAAA+N3Gjh2byy67LAMGDCia23DDDXPmmWdm6aWXLlEzAIA/5ptvvsl5552X1157rWhu++23z4knnpjatWuXqBkAAMD8ywADAAAAAAAAs2Xy5Mm59957c9VVVxXNLbHEEunRo0fWXXfdEjUDAKgcQ4YMSceOHfPdd98VzR199NHZZ599UqVKldIUAwAAmM8YYAAAAAAAAGCWvPrqq2nfvn0mT55cNHfGGWekdevWKSsrK1EzAIDSGTBgQM4///yimbKyspxzzjlp3rx5iVoBAADMHwwwAAAAAAAAMEPDhw9Pjx498sEHHxTN7bPPPjn88MNTvXr1EjUDAJi7JkyYkOuuuy5333130dzqq6+ezp07Z8UVVyxRMwAAgHmXAQYAAAAAAACmMW7cuFx22WX517/+VTTXrFmznHnmmVlmmWVK1AwAYN709ddf59xzz82gQYOK5nbYYYeccMIJqVWrVmmKAQAAzGMMMAAAAAAAAJBCoZB77703V155ZdFcnTp10rNnzzRq1KhEzQAA5i/vvvtuOnXqlNGjRxfNHXvssdl7771TVlZWomYAAABznwEGAAAAAACAhdhrr72W9u3bp7y8vGjutNNOy4477ugCOwCAWVQoFPKvf/0rF1xwQdFclSpVcs4552TDDTcsUTMAAIC5xwADAAAAAADAQmb48OHp2bNn3n///aK5PffcM0cddVSqV69eomYAAAumCRMmpF+/frnnnnuK5tZcc8106tQpK6ywQomaAQAAlJYBBgAAAAAAgIXAuHHjcsUVV+Sxxx4rmmvatGnOOuusLLPMMiVqBgCwcBk1alTOO++8DBo0qGhuxx13zPHHH59atWqVphgAAEAJGGAAAAAAAABYQBUKhdx///25/PLLi+YWW2yx9OzZM+utt16JmgEAkCTvvPNOOnbsmB9++KForl27dtlrr71SVlZWomYAAACVwwADAAAAAADAAmbQoEFp3759Jk6cWDR36qmnZqeddnIhHADAXFYoFPL444/nwgsvLJqrVq1aevfunQ022KBEzQAAAOYsAwwAAAAAAAALgC+//DI9e/bMkCFDiub23HPPHHXUUalevXqJmgEAMDsmTJiQa665Jvfdd1/R3FprrZWOHTtmhRVWKE0xAACAOcAAAwAAAAAAwHxq3LhxufLKK/Poo48WzTVp0iRnn312ll122RI1AwBgThg1alTOOeecvPnmm0VzO+20U4477rjUqlWrRM0AAAB+HwMMAAAAAAAA85FCoZAHHnggl112WdFc7dq106tXrzRu3LhEzQAAqExvv/12OnbsmB9//LFo7vjjj8+f/vSnlJWVlagZAADArDPAAAAAAAAAMB9444030r59+0yYMKFo7pRTTsnOO+/sgjUAgAVUoVDIY489losuuqhobpFFFknv3r3TrFmz0hQDAACYBQYYAAAAAAAA5lFfffVVevbsmffee69obvfdd88xxxyTGjVqlKgZAADzggkTJuTqq6/O/fffXzS3zjrrpEOHDll++eVL1AwAAGD6DDAAAAAAAADMQ8aPH5+rrroqDz/8cNFc48aNc/bZZ6devXolagYAwLxs5MiROeecc/LWW28Vze2yyy5p165datasWaJmAAAA/88AAwAAAAAAwFxWKBTy0EMP5dJLLy2aq1WrVnr16pUmTZqUqBkAAPOjwYMHp0OHDhk7dmzR3AknnJA99tgjZWVlJWoGAAAs7AwwAAAAAAAAzCVvvvlmOnTokPHjxxfNnXTSSdl1111dWAYAwGwpFAp59NFHc/HFFxfN1ahRI7169UrTpk1L1AwAAFhYGWAAAAAAAAAooREjRqRXr1555513iuZ23333HHPMMalRo0aJmgEAsCD76aef0rdv3zz44INFc+uuu246duyYevXqlagZAACwMDHAAAAAAAAAUMl++umnXHXVVXnooYeK5ho1apQOHTq4WAwAgEo1YsSInHPOORk8eHDR3K677ppjjz02NWvWLFEzAABgQWeAAQAAAAAAoBIUCoU8/PDD+fvf/140V7NmzfTs2TNNmzYtUTMAAPh/b731Vjp06JBx48YVzf31r3/NbrvtlrKyshI1AwAAFkQGGAAAAAAAAOagwYMHp3379i4AAwBgvjI7A7i9e/dOkyZNStQMAABYkBhgAAAAAAAA+INGjhyZ3r17Z/DgwUVzu+66a4499tjUrFmzRM0AAGD2jR8/Pn379s1DDz1UNNeoUaN06NAh9erVK1EzAABgfmeAAQAAAAAA4Hf46aef0rdv3zz44INFc+uuu246dOiQ5ZZbrkTNAABgzhkxYkR69eqVd955p2hu9913zzHHHJMaNWqUqBkAADA/MsAAAAAAAAAwiwqFQh599NFcfPHFRXPVq1dP796907Rp0xI1AwCAyvfGG2+kY8eOGT9+fNHcSSedlF133TVlZWUlagYAAMwvDDAAAAAAAADMxODBg9OhQ4eMHTu2aO6EE07IHnvs4UItAAAWaIVCIQ8++GD+8Y9/FM3Vrl07vXr1SuPGjUvUDAAAmNcZYAAAAAAAAJiOUaNGpXfv3nnrrbeK5nbZZZe0a9cuNWvWLFEzAACYd4wfPz5XXXVVHn744aK5xo0bp3379ll22WVL1AwAAJgXGWAAAAAAAACYasKECbn66qtz//33F82ts8466dChQ5ZffvkSNQMAgHnfV199lZ49e+a9994rmttjjz1yzDHHpHr16iVqBgAAzCsMMAAAAAAAAAu1QqGQxx57LBdddFHR3CKLLJLevXunWbNmpSkGAADzsUGDBqVDhw6ZMGFC0dwpp5ySnXfeOWVlZSVqBgAAzE0GGAAAAAAAgIXSO++8kw4dOuTHH38smjv++OPzpz/9yQVVAADwOxQKhTzwwAO57LLLiuZq166d3r17Z7311itRMwAAYG4wwAAAAAAAACw0Ro0alXPOOSdvvvlm0dxOO+2U4447LrVq1SpRMwAAWPCNGzcuV155ZR599NGiuSZNmqR9+/ZZZpllStQMAAAoFQMMAAAAAADAAm3ChAm55pprct999xXNrbXWWunYsWNWWGGF0hQDAICF2JdffpmePXtmyJAhRXN77rlnjjrqqFSvXr1EzQAAgMpkgAEAAAAAAFjgFAqFPP7447nwwguL5qpWrZrevXtnww03LFEzAADg115//fV06NAhEydOLJo79dRTs9NOO6WsrKxEzQAAgDnNAAMAAAAAALDAeOedd9KxY8f88MMPRXPt2rXLXnvt5cInAACYhxQKhdx///25/PLLi+YWW2yx9OrVK40aNSpRMwAAYE4xwAAAAAAAAMzXvv7665x77rkZNGhQ0dyOO+6Y448/PrVq1SpNMQAA4HcbN25crrjiijz22GNFc02bNs1ZZ52VZZZZpkTNAACAP8IAAwAAAAAAMN+ZMGFC+vXrl3vuuadobs0110ynTp2ywgorlKgZAAAwpw0fPjw9e/bM+++/XzS311575cgjj0z16tVL1AwAAJhdBhgAAAAAAID5QqFQyIABA9KnT5+iuSpVqqR3795p3rx5iZoBAACl8tprr6V9+/YpLy8vmjvttNOy4447pqysrETNAACAWWGAAQAAAAAAmKe999576dixY0aPHl00d+yxx2avvfZKlSpVStQMAACYWwqFQu69995ceeWVRXOLL754evbsmXXXXbdEzQAAgGIMMAAAAAAAAPOcb775Juedd15ee+21ornWrVvnxBNPTK1atUrUDAAAmNeMGzcul112Wf71r38VzTVr1ixnnXVW6tatW6JmAADArxlgAAAAAAAA5gkTJ05Mv379cvfddxfNrb766unUqVNWWmmlEjUDAADmF1988UV69OiRDz/8sGhun332yeGHH57q1auXqBkAAJAYYAAAAAAAAOayAQMG5Pzzz59p7pxzzkmLFi1K0AgAAFgQvPLKK+nQoUMmT55cNHfGGWekdevWKSsrK1EzAABYeFWZ2wUAAAAAAID53xVXXJGysrJssskms5QfMmRI/vznP6d169ZFhxeOPvroPP744xkwYMB8O7zw3XffpV69eikrK8tdd901zba33347f/7zn9OwYcPUrl07yyyzTLbaaqs8+OCDc6ktAAAsOFq0aJHHH388jz/+eI499tgZ5vr06ZMddtghbdq0yXvvvTfHe8zu56X50cSJE9OoUaOUlZXlggsumGbbF198kYMPPjhrr7126tSpkyWXXDIbb7xxbrjhhrj3LgDAwqfa3C4AAAAAAADM/2655ZY0aNAgL730Uj788MOsscYav8l8++23Offcc/Paa68VPdb222+fE088MbVr166suiXVuXPnjB07drrbPvnkk/zwww859NBDs+KKK2bs2LG5++67s8cee6Rv3745+uijS9wWAAAWPFWqVMk+++yTffbZJ2PHjs1ll12WAQMG/CY3evTonHjiiUmSDTfcMGeeeWaWXnrpP3z+Wfm8NL/7xz/+kU8//XS620aNGpXPP/88bdq0yaqrrpqJEydmwIABadu2bYYMGZLevXuXuC0AAHNTWcEYKwAAAAAA8AcMHTo0DRs2zD333JNjjjkmxx9/fLp06ZJkyl04+/fvnzvvvLPoMRo2bJhOnTpl5ZVXLkXljB8/Pp9++mnWWmutSj3P4MGDs8EGG6Rz587p3Llz7rzzzrRp06boPuXl5WnevHnGjx9fKXd/BQAApvjf//6X7t275+OPPy6aa9OmTQ4//PAsssgis32OYp+X5oa33norTZo0maPHHDFiRNZaa62cdtpp6dy5c/r06ZPTTz99pvvtvvvuefrppzN69OhUrVp1jnYCAGDeVWVuFwAAAAAAAOZvt9xyS5ZaaqnsuuuuadOmTW655ZY8+eSTad26dXbZZZeK4YUJEyZk8ODBefrppzNw4MC8/fbbadu2bZ544olsuumm0wwvvPfee2nTpk2WXnrp1KxZMy1atMgDDzzwh7u+8cYbOfHEE7Piiivmiiuu+MPHm5mTTjope+21V7bccstZ3qdq1apZZZVV8t1331VeMQAAICuttFL69u2bAQMGFF0F4K677souu+yS1q1bT3flhmKm93lper7++uv85S9/yeKLL54ll1wyhx56aN54442UlZXl+uuvnyb7Rz4v7b777mnUqFEuvPDCjBgxYrZey4ycddZZWXvttXPwwQfP1n4NGjTI2LFjM2HChDnSAwCA+YMBBgAAAAAA4A+55ZZbsvfee2fYsGF577338sEHH+Tss8+eJlMoFPLGG2/kq6++yvbbb5+LL744jRs3Tp8+fX5zvLfffjstW7bMu+++m7POOisXXnhhFl100ey555659957Z7vf6NGjc+WVV6ZFixZp1qxZbrjhhuy999458sgjp8l9++23GTVq1Ey/xo4dO0vnvfPOO/PCCy/k/PPPn2l2zJgxGTVqVD766KNcfPHFefTRR7PddtvN9msFAAB+n4022igDBgzI448/nqOPPnqGufPPPz+tW7fOn//85wwZMmSmx/3581L16tVzwAEH5IMPPsjLL788TWby5MnZfffdc9ttt+XQQw9Nr169Mnz48Bx66KG/Od4f/bx04YUXZqWVVsrf/va3rLzyytlnn33y6KOPpry8fKb7Ts9LL72UG264IZdccknKysqKZseNG5dRo0Zl2LBhueGGG9K/f/9suummqVWr1u86NwAA86eyQqFQmNslAAAAAACA+dPTTz+dbbfdNhtssEHq1q2bQqGQ5557LvXq1cvaa69dkVtuueVyyy235JJLLslJJ52UZMpFOq1bt85TTz2V/v37p23btkmS7bffPiNGjMjLL7+cGjVqJJkyALHFFltk5MiRef/992faq1Ao5Jlnnkm/fv1y1113Zfz48dlqq61y2GGH5c9//nNq1679m30aNGiQTz75ZKbH7tKlS7p27Vo0M27cuKyzzjo56KCD0rt37wwcODCtWrXKnXfemTZt2vwmf+yxx6Zv375JkipVqmTvvffO1VdfnaWWWmqmfQAAgMoxZsyY/OMf/8iTTz5ZNNe8efOceeaZv/n/76+++mpatGiRAQMGZPvtt0+hUMiqq66affbZJ5dccklF7p577ql4rhSfl5Lk008/zfXXX5/rr78+Q4cOzcorr5y2bdvm8MMPz2qrrTZLxygUCmnZsmVWX3313HrrrRk2bFhWW2219OnTJ6effvpv8ueee+40w+7bbbdd+vfvn1VWWWWWzgcAwILBCgwAAAAAAMBsmTRpUq655pq0bt06bdu2TfXq1bP00ksnScrKyrLccsvlq6++yqqrrpr+/ftnwIABqV27dhZZZJEcddRRFcepUqVKjj/++GmO/c033+Spp57Kvvvumx9++KFi1YOvv/46O+64Yz744IP873//K9rv73//e9Zaa61ss802GThwYE477bR88MEHGThwYA499NDpDi8kU+6MOmDAgJl+HXLIITP9Hp177rmZOHFi2rdvP9Nskpx88skZMGBAbrjhhuy8884pLy/PhAkTZmlfAACgciy66KI566yzMmDAgPTv33+GF/a/+uqr2XfffdO6detcffXVmThxYpIpnzGWW265tGrVKsmUz0v77bdfbr/99mlWPHjsscdK9nnpZ6uuumo6d+6cjz76KE8++WS23nrrXHjhhVl99dWz/fbb55lnnpnpMa6//vq89dZbOe+882bpnAcccEAGDBiQW2+9NQceeGCSKcPfAAAsXKrN7QIAAAAAAMD84amnnso555xT8bhQKOTLL7/MUkstNc1FJ8ccc0w6dOiQ/fbbLyuvvHKS5JNPPskKK6zwm+GBNdZYY5rHH374YQqFQjp16pROnTpNt8eIESOy0korzbDnxRdfnE8++SStW7dO//79i2Z/afPNN5+l3MwMGzYsffr0yeWXX57FFltslvZZZ511ss466yRJDjnkkOywww7Zfffd89///jdlZWVzpBcAAPD7rbzyyrn66quTJC+99FI6dOgw3dydd96ZO++8M4VCIYMGDUrr1q0zdOjQiu2bbLJJLrzwwjz55JPZYYcdkpT289KvlZWVZdttt822226bJ598MoccckiefPLJNG7cOFtttdUM9/v+++9z9tln54wzzpjlFRTq16+f+vXrJ5kyzHD00Udn++23z5AhQ1KrVq1Z7gwAwPzNAAMAAAAAADBDH3zwQTp16pSvv/76N9u++eabTJgwIV999VW++uqriudfeOGFJFPuNvrzBTmzavLkyUmS008/PTvuuON0M7++iOfXrrvuuvTt2zf33Xdf6tevn5133jmHHXZYdtttt1SvXn2G+40cOXKau6DOyGKLLVZ0MKFz585ZaaWVss0222TYsGFJki+//LLiHMOGDcuqq66aKlVmvFB2mzZtcswxx+T999/P2muvPdNOAABA6Wy88cYZMGBAJk+enLvuuivXXHPNbzLffPNNvv7669x+++25/fbbf7N9bn1e+rURI0bk5ptvTv/+/TN48OAst9xyOeOMM9KuXbui+11wwQWZMGFC9ttvv4rPPZ9//nmS5Ntvv82wYcOy4oorFv0M1qZNm1xzzTV55plnZvh6AABY8BhgAAAAAAAApvHdd9+lT58+eemll2aaXXbZZXPFFVf85vl77rkn9957b6666qrUqlUr9evXz9NPP52xY8dOc1fRDz/8cJr9GjZsmCRZZJFFsv322/+u/j/fPfTrr7/OjTfemH79+mWfffbJMsssk4MOOiiHHXZYmjZt+pv9Ntpoo3zyySczPX6XLl3StWvXGW7/9NNP8+GHH1a8ll867rjjkky5oGfJJZec4TF+XtFi9OjRM+0DAADMHVWqVMm+++6bfffdN2PGjMmll16ap556KsmUIebq1av/ZiB5jTXWSJUqVeba56UkmTRpUh555JH0798/Dz/8cCZPnpwdd9wx3bt3z2677ZZFFllkpsf49NNP8+2332a99db7zbbevXund+/eef3119OsWbMZHsPnHgCAhVNZoVAozO0SAAAAAADA3DVp0qTccMMN070z6C/Vr18/Xbp0yTLLLJPlllsuf/7zn9OvX7/f5F544YVsvvnmuf3227Pffvvl7rvvTps2bXLJJZfkpJNOSjLl7qGtW7fOU089lf79+6dt27ZJklatWuXNN9/M4MGDs8IKK0xz3JEjR2bZZZed7df34osv5tprr80dd9yRH3/8MRtssEF69eqVnXfeuSLz/PPPV1xAU0zDhg2nO5zws+eeey6jRo2a5rnBgwenU6dO+dvf/pZNN900u+66axZZZJGMGDEi9erVmyY7ceLEtGzZMu+++25GjBhRdLUHAABg3vPBBx9kvfXWy7LLLptGjRr9Zvt3332XV155JUcffXQuv/zy3H///SX7vNS1a9dcddVV+eqrr7Laaqvl8MMPz2GHHZaVVlpptl7ja6+9lk8//XSa50aMGJFjjjkmbdu2zZ/+9Ke0atUqSyyxxAx77bHHHnnooYfy/vvvz/bKEQAAzL+swAAAAAAAAAuxgQMHplevXjPN9ejRIy1btqx4fMcdd+SHH37IHnvsMd18y5Yts+yyy+aWW27Jfvvtlz333DMbb7xxTjvttHz44YdZZ5118sADD+Sbb75JkpSVlVXse/nll2eLLbZIkyZNctRRR6Vhw4b56quv8p///Ceff/553njjjdl+nS1btkzLli1zySWX5I477si1116bf/3rX9MMMGy++eazfdzp2WKLLX7z3M+rLWy00UbZc889K54/5phj8v3332errbbKSiutlC+//DK33HJL3nvvvVx44YWGFwAAYD702muvZeLEibniiiuy/PLLp2PHjtNsX2KJJbLIIovknnvuyccff5xCoZB11lmnJJ+Xbr/99rRq1SpHHHFEtttuu2mOPTs23HDDbLjhhtM8N2zYsCTJeuutN83nnl69euX555/PTjvtlFVXXTXffPNN7r777rz88ss58cQTDS8AACxkDDAAAAAAAMBC5sMPP0znzp0zcuTIornDDz88++67b6pWrfqbbbfccktq1qyZ1q1bT3ffKlWqZNddd80tt9ySr7/+OnXr1s3DDz+ck046KTfccEOqVKmSvfbaK126dMnmm2+emjVrVuzbqFGjvPLKK+nWrVuuv/76fP3116lXr1422GCDdO7c+Q+99sUWWyxHHHFEjjjiiIwZM+YPHWtO2G+//dKvX79ceeWV+frrr1OnTp00b94855133gyHQwAAgHnbLz8v1a5dOwMGDMjkyZPzz3/+M/369UtZWVmWWWaZfPnll5kwYUKqV6+eevXq5bvvvstVV12VWrVqZZ999qmUz0uvvvpqFl100cp8+b+x66675qOPPsp1112XkSNHpmbNmll//fXTv3//HHrooSXtAgDA3FdWKBQKc7sEAAAAAABQuUaPHp0LLrggL774YtHcNttsk5NOOqlkd/6/7777stdee+W5556bYysgAAAAzMvGjBmTSy65JAMHDiyaW2KJJXL33Xf7vAQAwALFAAMAAAAAACygJk2alBtvvDG33XZb0dwqq6ySLl26pH79+pXaZ9y4calVq1bF4/Ly8uywww555ZVX8uWXX06zDQAAYGHw6aefplu3bhk6dOg0q98VCoW89tpr+f7777PVVlvloIMOyqGHHppq1arNxbYAAPDHGWAAAAAAAIAFzL///e/07Nlzprlu3bpls802K0GjKY488siMGzcum266aX766afcc889eeGFF9K7d++cffbZJesBAAAwrznyyCPz2Wef5ZNPPsnkyZMzYsSIjB49OquvvnpWW221abIdOnTINttsM3eKAgDAH2SAAQAAAAAAFgAfffRROnfunBEjRhTNtW3bNvvvv/80d/YslVtvvTUXXnhhPvzww4wfPz5rrLFG2rVrlxNOOKHkXQAAAOYlv/68tOyyy2axxRbLKqusMsN9ll122XTv3j1rrLFGCZsCAMAfY4ABAAAAAADmU99//3369OmTF198sWhuq622yimnnJLFFlusRM0AAACYE3788cdccskl+fe//10017Jly5x++ulZYoklStQMAAB+HwMMAAAAAAAwHykvL89NN92UW265pWhu5ZVXTteuXVO/fv0SNQMAAKAyffLJJ+nWrVs+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5s2b43zfHjp0qNzc3PTnn3/qwIEDunjxoqZOnSpJqlWrlvV1unfvXq1atco6+GZrW0JduHBBlStXVt26dXXy5Ent2rVLv/766xO/D0R/j3uUBobkolevXmrYsKH12v/222/W186ePXs0YMAA/frrrzpw4IC2bNmibNmySZLc3Nw0ZcoU9erVK95z9O3bVw4ODjpy5Ij279+vUaNGPdP79Lge9bmSLl067d27V/v27dOAAQPUtGnTRyo8Rz9f/vz5lT17dm3fvl2SdObMGaVLly7G823t2rV2/7x90KhRo/TSSy/p2LFj+vXXX/XRRx/p6tWH5xSLjIxUixYtNHbsWB05ckSvvfZajELON998owULFui7776zY/YAAAAAAADPDjWv/1SvXj3GpFVr165VuXLlrLeFh4dr48aNqlGjRoKPmVTrPXh0SbVGFJdJkyZp0KBBmj59uo4ePaqdO3dq0qRJOnEiZpXI1dVV+XPnjlFfehY1Ih9J8yW9F8u2XrJMdLX3/p/fJD14FTpJeuOB2yZKuiNL7emALA0OI2WZQCv7yy9rz5492rt3r/bv369Lly7p22+/jTW3KlWqaM+ePdq3b58mT56spk2bKjg42Lo9ej33cSak++uvv1S3bl116dJFJ06c0M6dO/Xjjz/q5s2bj3ys6KLXSx+lgSG5sOf7v7086mdC9erVtXfvXv31118qXLiwevTo8djnq169urZs2WKdkOrBz7io2x7lM+5Z6NSpkzp27KgjR46oT58+MZrIojt58qQGDhyoDRs26NixY7p48aL+97//Wbdv27YtweekgQEAAABAshESEqIMGTLEuC1z5sxycnKy/j5lyhS9//77at++/UMzsUycOFGFCxdWqVKlNGTIEDk4OFi37dixQzVq1FDp0qXl7e2tH3/80bota9asCcpv/vz56ty5sySpTJkyypUrV6wDyr/88ou8vb1VtGhRSdJHH32kH374Id7jOzk5KXPmzJKk33//XadPn9bo0aPl7OwsSfLy8tKAAQNiDAyMGDFC7u7u8vT0VPny5XXnzh1J0tSpU62zQJcuXVqnTp16aJaV27dvx7hGDg4O8vX1lbe3twoXLhxjoKhFixYqXbq0PDw89Prrr+vChQuSpM6dOysoKEheXl7W5UKjzwBw6dIlvfPOO9bZuidOnGg9ZoECBeTn56cKFSqo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7dq1S2FhYTFu37JlS5xfMIyPs7OzIiIirL9HLSdpi4ODgzZu3Khx48ZpxYoVOnDggEaPHp2gfeM6XnTRv1jr5OSk8PDwxzquLQ8uVdusWTO1b99eBw4c0N69e5U2bdoY9yeunBYuXKjhw4crLCxMr732mubOnRvnOeNaKjX67alTp451QCnK8OHDrYMXv/76qyTppZdeipFbQq9f9PNWqFBBkZGR2r59u6ZNm2ZzUPlxjv84fHx8tHXr1ji3d+7cWZUrV9b+/fu1d+9eFS5c2OZz0FY+f/31lxo2bChPT0+tXLlSpUuXVoUKFZQ/f35FRv43fJwlSxb99ttvqlKlitq0aaM9e/fqYHi4/pSUW9L2aMdcK+lnSVFfv54jaY2k9ZL2S+opKa5sX5JlKV5JcpIUEUdcQnnez2/dA7dPllRe0tHwcHXp2lWlSpXSypUr9dtvvyldunTKlStXjOt248YNHT16VA0bNlS9evX0559/qmDBgtbtQUFBqlevnt5++219+umncebz7bffqkWLFnJyclK6dOnUuHFj63Kr0WXNmlWvvfZajKXOE7LNFh8fnxjLAT8ovveCByXkeT569GhdunRJ27Zt059//qn33nsv1mN269bN+vrev39/wu7QAzmMHj1aU6dOVerUqdW6dWt98cUXcnJy0tatW9W9e3ddunRJ5cuXf+TGj2+++ca6vHK2bNn02muvWZdyz5cvnxo1aqRUqVIpTZo0euedd2y+dmOTL18+OTo6qkWLFpIkb29vFSxY8JGuw+OIfu3i+zyM/rlRqFAh/f3336pXr542bdqkkiVL6vr167E+hunSpbP+O2P58uXy9PSUMUaZM2eO8W+QkydPWq9x9PM9+L6fO3du5cmTR/PmzdPLL7+stGnTqmLFitq0aZPWrl1r/XeMMUatW7eOcY7z588rVapUMsaoX79+1tv3798f41q7urrK0dHxkR/HKLH9mych/zYKCgrSzZs3rUUKyVL4iWpKedJltwEAAAAAABILNa/YVa9eXWvXrtXatWtVrVo1SVLVqlX122+/adOmTXHm/+C47IM1n1y5cil79uxas2bNI+UjxT1GHNc4a1xjhc9afGPTCa3jzZgxwzr+GDXR1ePmETVZ1rp165QlSxaVLFnykY8XxZ41oihz5szRmjVrtH79eu3fv189e/aM9ZgPjtlmy5ZN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4uPjdfbsWXl4eKhMmTIpPg8PImfOnDp27Ji2bNlyX3939uxZSVKuXLkeSg8PDw9J0uXLl1O83maz6YUXXtBXX32lK1eu2C9fsGCBChcubD9S4bfffqu4uDgNGjTI/hxKUu/eveXp6akvv/wyye26uLioR48eSS5bsmSJ8ubNq4EDB6bYQ5IWLVokLy8v+fr66syZM/b/VatWTR4eHvr++++T9Bk4cKD9byVp0KBBaX1qHgo/Pz/FxsZq1apVunz5slatWmV6xNiH9T7s3bu3/cwY9xITE6MLFy6oS5cuSZ7LbNmyqWbNmvbn8vjx49q+fbu6desmLy8v+9/7+vqm+XMgLa9tSl588UWdOnVKP/zwg/2yxYsXKyEhQS+++KLpfd7Pe+zuoxgn9vzqq6+S/N+33347SS7xyJ13L99pkTNnTv3xxx/av39/itefO3dOa9eulZ+fny5fvmx/fc6ePavmzZtr//79+ueff+77fu8Xy/BtLE9JX9ubN2/q7NmzKlmypHLmzJni69unT58kZ0fo27evHB0d7Y8/8Qixq1at0s2bN1O8z7R+5icqX768/Ujf0u2jmJcpU0aHDh0yfWypSfzOPXPmzAPdDoCHy9HRUT179lRMTIwWLVqkZ555JsXc33//rddee02+vr764osv7Jc/ru+4tGJ7ydrtpcSzMOzYsUPLly9PMbN9+3bt379fL730ks6ePWv/Xrp69aqaNm2qH3/8UQkJCZLSth77X76f07KOdKdevXrZ//+cOXOqTJkycnd3l5+fn/3yMmXKKGfOnEm+L+/3te7Wrds9j67ft2/fVHtmhO08X19fbd68WW3atNGOHTs0evRoNW/eXIULF07y2bJ06VIlJCTIz88vyWMpUKCASpUqlWz9xdHRUa+//rr9387Oznr99dd16tQp/frrr5JuL5+JZ7VISEjQuXPndOvWLVWvXj3F16Njx472s7kkut/buNv9LK9fffWVatWqleRzOV++fOratWuq9/Og6tWrp9jYWPtzt3HjRvsZYOrWratTp07Z15k3btyoYsWKJTujyJ2++uorFSxYUJ06dbJflj17dvXp0ydJbuvWrTp16pT69esnV1dX++WtWrVS2bJlk6zj169fX+vXr5d0+7/F7NixQ3369FHevHntl69fv145c+a0n+0jrctVYo833njD/npLUvfu3ZNsC6UmV65crPsCAAAAAAD8H8fHXQAAAABID+Lj4zV//nzNnj3bNFekSBHVr19fuXPnVoUKFRQfH5/kB9e7rVq1SpGRkdq+fbtu3LhhvzylnYuLFSv2n/sn2rhxo0JCQrR582Zdu3YtyXUXL1685w+rJ06cSPJvLy8v+44SpUuX1ty5cxUfH6/du3dr1apVGj16tPr06aNixYqpWbNmpp0SEhL04YcfatKkSTp8+LDi4+Pt1+XJk+e/PMx7GjZsmL799ls988wzKlmypJ599lm99NJLqlu3bpr+3jCMh9IjcSghR44c98y8+OKLGj9+vL744gu99NJLunLlir766iu9/vrr9uXj6NGjkm7v/HInZ2dnFS9e3H59osKFCyf5MV2SDh48qDJlysjR8d6bf/v379fFixfl7e2d4vWnTp1K0qdUqVJJrs+XL99DG/5Ii3z58qlZs2b67LPPdO3aNcveh2l5jybutNGkSZMUr/f09JR07+dSUrIdmE6fPp3kfePh4SEPD480vbYpadGihby8vLRgwQI1bdpU0u3hmSpVqqh06dKm93k/77G7H1uJEiXk4OCgI0eO2J8DBwcHlSxZMkmuQIECypkzZ7LlOy3Cw8PVtm1blS5dWhUrVlSLFi30yiuv6KmnnpIkHThwQIZhKCgoSEFBQSnexqlTp1S4cOH7vu/7wTKctZanAgUK6PTp00kuz507t5ydnRUbG6uRI0dq1qxZ+ueff5J8D6W0I+3dz4OHh4cKFixofx4aNmyojh07KiwsTOPGjVOjRo3Url07vfTSS3JxcZGU9s/8RE8++WSyTK5cuZIMeP4XiY/VbOAKwOOVM2dORUVFSbr9mRccHJzs80yS9u3bJ+n2sNmgQYPUtGlTy7/j0ortJeu3l7p27aqIiAiFh4erXbt2ya5PXPfo1q3bPW/j4sWLypUrV5rWY//L+t6dy1ZcXJzOnTuXJJ8vXz77gIOrq2uyndi9vLxUpEiRZMutl5dXku/L+32t77XMOzo6qkiRIiled6eMsp1Xo0YNLV26VHFxcdqxY4eWLVumcePGqVOnTtq+fbvKly+v/fv3yzCMFNc9JSUZ8JSkQoUKyd3dPcllicvIkSNHVKtWLUnSnDlz9MEHH2jv3r1Jhj9Teu7v9Xrcz23c7X6W16NHj6pmzZrJrr/7vxc8CokHWdi4caNq1qypTZs2KTIyUpJUsWJFeXp6auPGjXriiSf066+/pjoYf/ToUZUsWTLZe+bux3Kv/yYiSWXLltWGDRvs/65fv76mTJmiAwcO6ODBg7LZbKpdu7Z9sKF3795av3696tatax8kSutyda/3iJOTk4oXL276WO9kGAbrvgAAAAAAAP+HAQYAAABkaZs2bVJISEiash9//LGqV6+uuXPnqnfv3jpx4oRatmxpP9rx3davX682bdqoQYMGmjRpkgoWLCgnJyfNmjVLn332WbL8vY6smFYHDx5U06ZNVbZsWY0dO1ZPPPGEnJ2d9dVXX2ncuHH2o1ampGDBgkn+PWvWrGRnlciWLZsqVaqkSpUqqXbt2mrcuLHmzZuX6g450dHRCgoKUs+ePRUREaHcuXPLwcFBgwYNMu10p3v9wBsfH5/kSJnlypXTvn37tGrVKq1Zs0ZLlizRpEmTFBwcrLCwsHvefuLOIg+6M2aiXbt2ydvb276jb0pq1aqlokWLauHChXrppZe0cuVKxcbGpvpDv5n/ugwlJCTI29tb8+bNS/H6u3fQSQ9eeukly9+HaXl+E5fpuXPnqkCBAsmuv99hA+n2Dj137nwdEhKi0NDQ+76dRC4uLmrXrp2WLVumSZMm6eTJk9q4caOio6NTvc//+h6T7v0+fpAdOO7c6UuSGjRooIMHD2rFihX65ptv9L///U/jxo3TlClT1KtXL/vr8+6776p58+Yp3ubdO8A/KizDWWd5+vvvv5PtwPb999+rUaNGGjhwoGbNmqVBgwapdu3a8vLyks1mU+fOndP8HXknm82mxYsX66efftLKlSv19ddfq2fPnvrggw/0008/ycPD474/8+91ROoHHfpL/M7NmzfvA90OAGuULFnS/r2zdu1ajRw5MsXczJkzdfz4ce3Zs0cHDhxge4ntJftZGLp3764VK1Ykuz7xMb7//vuqUqVKireReIa7tKzH/pf1vTuXrU2bNqlx48ZJrj98+LCKFi1qfzz3epwpufP78n5f63st83eeycFMRtvOc3Z2Vo0aNVSjRg2VLl1aPXr00KJFixQSEqKEhATZbDatXr06xec6cRm5H59++qm6d++udu3aaciQIfL29la2bNk0cuRIHTx4MFk+pdfjfm/jbulp+8RM5cqVlSNHDm3YsEHPPfeczp07Zz8Dg4ODg2rWrKkNGzaoRIkSiouLsw88WCnxPn/88UcdOnRIVatWlbu7u+rXr68JEyboypUr+u233+yDeZIeyXJl5vz586z7AgAAAAAA/B8GGAAAAJDl/PXXXwoNDdXff/9tmuvSpYteffVVffrpp+rRo4f98vbt2+v111/XTz/9pAULFtzz75csWSJXV1d9/fXX9qMeS7d3dnkQ99o5ZeXKlbpx44a++OKLJEdLTjzlvZmYmJgk/65QoYJpvnr16pKk48ePp9pr8eLFaty4sWbMmJHk8gsXLiT54dZsp9NcuXLpwoULyS4/evRosqPdubu768UXX9SLL76ouLg4dejQQVFRURoxYoRcXV1TvP2yZctKur1jyoPavHmzDh48qJdffjnVrJ+fnz788ENdunRJCxYsUNGiRe1HgpQkHx8fSbePanvn44yLi9Phw4dT3RlKun2U8p9//lk3b95MdlTKOzPffvut6tata7pjWGKf/fv3J+lz+vTphzb8kVaP+314LyVKlJAkeXt7m74+dz6Xd0s8inGiefPmKTY21v7vxOc+La/tvbz44ouaM2eOvvvuO+3Zs0eGYSQZnrnXfUppf4/t378/yU7bBw4cUEJCgn3nLx8fHyUkJGj//v0qV66cPXfy5ElduHDB/hxJKX8GxMXFJfkMSpQ7d2716NFDPXr00JUrV9SgQQOFhoaqV69e9sfh5OSUpvfPo8QyfFtWWJ6cnJySfc9WrlxZ0u3vyG7duumDDz6wX3f9+vUUv/MSn4c7d6q8cuWKjh8/rueeey5JrlatWqpVq5aioqL02WefqWvXrpo/f7569eqV5s/8+/FfBkcOHz4sBwcH+9GQAWQcTZo0UZMmTXTr1i3NmTNHY8eOTXJ9vnz5tGfPHu3atUs2m029evVKcWic7aXbssL20ssvv6zIyEiFhYWpTZs2Sa5LXPfw9PRM0/pZauuxD7q+V7ly5WSvd0pDnf9FWl/rhyUjbucluvs9VKJECRmGoWLFiqVp3eHff//V1atXk5yF4c8//5Qk+/rj4sWLVbx4cS1dujTJ+yutB7m4n9u41/v3fpZXHx+fNK17PwrZsmVTrVq1tHHjRm3YsEGenp6qVKmS/fo6depowYIF9mGL1AYYfHx8tGvXrmRnJLj7sdz530TuPkPcvn37kqzjP/nkk3ryySe1fv16HTp0SPXr15d0eyj57bff1qJFixQfH68GDRrY/yaty9Wd75E7e9y8eVOHDx+2r9un5n6yAAAAAAAAmV3qh2gBAAAAMoErV64oMjJSvr6+eu211+45vFCrVi0tWrRIMTEx6tmzZ4pHmvbw8NDkyZMVGhqq559//p73mS1bNtlstiRHdD5y5IiWL1/+QI8l8Qf4u3dQSTxa3J1HeLx48WKadgBq1qxZkv8lHmF0/fr1unnzZrL8V199JUkqU6ZMkl4p7TSTLVu2ZEdpXrRokf755580PS7p9o/KP/30k+Li4uyXrVq1KtnrePbs2ST/dnZ2Vvny5WUYRoqPI1HhwoX1xBNPaOvWrffMpMXRo0fVvXt3OTs7a8iQIanmX3zxRd24cUNz5szRmjVr5Ofnl+T6Zs2aydnZWRMmTEjyHM6YMUMXL15Uq1atUr2Pjh076syZM5o4cWKy6xJv08/PT/Hx8YqIiEiWuXXrlv01adasmZycnPTRRx8l6TN+/PhUezxsj/t9eC/NmzeXp6enoqOjU1zmTp8+Len2UXyrVKmiOXPm6OLFi/brY2JitHv37iR/U7du3STvz8SdXNLy2t5Ls2bNlDt3bi1YsEALFizQM888k2Tn8Hvd5/28xz7++OMk//7oo48kSS1btpQk+w7Xdy8/iTtB3rl8lyhRQj/++GOS3LRp05IdMf/ufh4eHipZsqRu3Lgh6fZO+Y0aNdLUqVNT3Fk98fWxAstw1lmeXF1dk33P5sqVS1LK35EfffRRsi539rzzuZk8ebJu3bplfx7Onz+f7PYSj2ad2Dutn/n3w+w7/F5+/fVXVahQQV5eXvd9fwDSB0dHR7322mv29d7y5cvbLy9btqyKFy+uvHnz6ujRo+rZs6dOnjypAwcO6OrVq5LYXrqzV2bfXko8C8P27dv1xRdfJLmuWrVqKlGihMaMGaMrV64k+9u7189SW4990PW9XLlyJXu97zXYcb/S+lo/LBlhO+/7779Pcdvl7vdQhw4dlC1bNoWFhSXLG4aRbNm+deuWpk6dav93XFycpk6dqnz58qlatWqSUv5M+Pnnn7V58+Y090/rbWTPnl1S8vfv/Syvzz33nH766Sf98ssvSa6/1xk2HrZ69erp9OnTmjVrlmrWrJnkLCB16tTRvn37tGLFCuXJkyfJQHFKnnvuOf37779avHix/bJr165p2rRpSXLVq1eXt7e3pkyZYl+XlaTVq1drz549yf6bSP369bV27Vr98ssv9gGGKlWqKEeOHHrvvffk5uZmf/2ltC9X1atXV758+TRlypQkn7OzZ89O8/rvxYsXdfDgQfuZKwAAAAAAALI6zsAAAACATCs+Pl4LFixIdYeUfPnyKTw83H6kuLTo1q1bqplWrVpp7NixatGihV566SWdOnVKH3/8sUqWLKnff/89zfd1t8QfWwMCAtS5c2c5OTnp+eef17PPPitnZ2c9//zzev3113XlyhVNnz5d3t7eKf4QnhajRo3Sr7/+qg4dOuipp56SJG3btk2ffPKJcufOrUGDBiXpNXnyZEVGRqpkyZLy9vZWkyZN1Lp1a4WHh6tHjx6qU6eOdu7cqXnz5iU7EmiJEiWUM2dOTZkyRTly5JC7u7tq1qypYsWKqVevXlq8eLFatGghPz8/HTx4UJ9++qn9aJ2Jnn32WRUoUEB169ZV/vz5tWfPHk2cOFGtWrVSjhw5TB9r27ZttWzZsmRHALyXbdu26dNPP1VCQoIuXLigLVu2aMmSJbLZbJo7d679+TJTtWpVlSxZUgEBAbpx40aSI4dKt5fNESNGKCwsTC1atFCbNm20b98+TZo0STVq1EjTWR5effVVffLJJ3r77bftP+JfvXpV3377rfr166e2bduqYcOGev311zVy5Eht375dzz77rJycnLR//34tWrRIH374oTp16qR8+fLp3Xff1ciRI9W6dWs999xz+u2337R69eo0HTH0hx9+UOPGjRUSEqLQ0NBU86l5nO/De/H09NTkyZP1yiuvqGrVqurcubPy5cunv/76S19++aXq1q1rHzgYOXKkWrVqpXr16qlnz546d+6cPvroI1WoUCHFnbfulpbX9l6cnJzUoUMHzZ8/X1evXtWYMWPS9Pju5z12+PBhtWnTRi1atNDmzZv16aef6qWXXrIfcbJy5crq1q2bpk2bpgsXLqhhw4b65ZdfNGfOHLVr1y7JUeZ79eqlN954Qx07dpSvr6927Nihr7/+OtlyV758eTVq1EjVqlVT7ty5tXXrVi1evFgDBgywZz7++GPVq1dPlSpVUu/evVW8eHGdPHlSmzdv1rFjx7Rjx457Pn6W4Ye7DGf15UmSWrdurblz58rLy0vly5fX5s2b9e233ypPnjwp5uPi4tS0aVP5+fnZvw/q1atnP5r1nDlzNGnSJLVv314lSpTQ5cuXNX36dHl6etqHPNL6mX8/qlSpomzZsmnUqFG6ePGiXFxc1KRJE3l7e6eYv3nzptatW6d+/frd1/0ASN/69++v6tWr688//1RQUJDOnTuXLHP27Fm1a9dOklSzZk1du3aN7aVMur10t65duyoiIkLbt29PcrmDg4P+97//qWXLlqpQoYJ69OihwoUL659//tH3338vT09PrVy50p5Py3rsg34/Pyppfa0floywnTdw4EBdu3ZN7du3V9myZRUXF6dNmzbZz1CYeDbMEiVKKDIyUiNGjNCRI0fUrl075ciRQ4cPH9ayZcvUp08fvfvuu/bbLVSokEaNGqUjR46odOnSWrBggbZv365p06bZz17XunVrLV26VO3bt1erVq10+PBhTZkyReXLl0/Tuuz93Iabm5vKly+vBQsWqHTp0sqdO7cqVqyoihUrpnl5HTp0qObOnasWLVrorbfekru7u6ZNmyYfH580fWY2atRI69atS3XY/V4Sz6qwefPmZK9zrVq1ZLPZ9NNPP+n5559P9TOid+/emjhxol599VX9+uuvKliwoObOnWsf9Ejk5OSkUaNGqUePHmrYsKG6dOmikydP6sMPP1TRokU1ePDgJPn69etr3rx5stls9r7ZsmVTnTp19PXXX6tRo0Zydna259O6XDk5OSkyMlKvv/66mjRpohdffFGHDx/WrFmz0vz+/fbbb2UYhul2OgAAAAAAQJZiAAAAAJnM5s2bjWbNmqX6v7Vr16bp9mbNmmVIMrZs2WKa8/HxMVq1apXkshkzZhilSpUyXFxcjLJlyxqzZs0yQkJCjLtXxSUZ/fv3T/F2JRkhISFJLouIiDAKFy5sODg4GJKMw4cPG4ZhGF988YXx1FNPGa6urkbRokWNUaNGGTNnzkySMQzDaNiwodGwYcNUH/vGjRuN/v37GxUrVjS8vLwMJycn48knnzS6d+9uHDx4MEn2xIkTRqtWrYwcOXIYkuy3f/36deOdd94xChYsaLi5uRl169Y1Nm/enGKHFStWGOXLlzccHR0NScasWbPs133wwQdG4cKFDRcXF6Nu3brG1q1bk93G1KlTjQYNGhh58uQxXFxcjBIlShhDhgwxLl68mOpj3bZtmyHJWL9+vWnu8OHDhiT7/xwdHY3cuXMbNWvWNEaMGGEcPXo02d98//33hiTj+++/T3ZdQECAIckoWbLkPe9z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AAAAAAAAAAAAAAAAkO5IYAAAAAAAAAAAAAAAAAAAAAAAAOmOBAYAAAAAAAAAAAAAAAAAAAAAAJDuSGAAAAAAAAAAAAAAAAAAAAAAAADpjgQGAAAAAAAAAAAAAAAAAAAAAACQ7khgAAAAAAAAAAAAAAAAAAAAAAAA6Y4EBgAAAAAAAAAAAAAAAAAAAAAAkO5IYAAAAAAAAAAAAAAAAAAAAAAAAOmOBAYAAAAAAAAAAAAAAAAAAAAAAJDuSGAAAAAAAAAAAAAAAAAAAAAAAADpjgQGAAAAAAAAAAAAAAAAAAAAAACQ7khgAAAAAAAAAAAAAAAAAAAAAAAA6Y4EBgAAAAAAAAAAAAAAAAAAAAAAkO5IYAAAAAAAAAAAAAAAAAAAAAAAAOmOBAYAAAAAAAAAAAAAAAAAAAAAAJDuSGAAAAAAAAAAAAAAAAAAAAAAAADpjgQGAAAAAAAAAAAAAAAAAAAAAACQ7khgAAAAAAAAAAAAAAAAAAAAAAAA6Y4EBgAAAAAAAAAAAAAAAAAAAAAAkO5IYAAAAAAAAAAAAAAAAAAAAAAAAOmOBAYAAAAAAAAAAAAAAAAAAAAAAJDuSGAAAAAAAAAAAAAAAAAAAAAAAADpjgQGAAAAAAAAAAAAAAAAAAAAAACQ7khgAAAAAAAAAAAAAAAAAAAAAAAA6Y4EBgAAAAAAAAAAAAAAAAAAAAAAkO5IYAAAAAAAAAAAAAAAAAAAAAAAAOmOBAYAAAAAAAAAAAAAAAAAAAAAAJDuSGAAAAAAAAAAAAAAAAAAAAAAAADpjgQGAAAAAAAAAAAAAAAAAAAAAACQ7v4Pe0b4oZ4AoksAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "node.display_igraph_jupyter(\n", + " numeric_features=affected.select_dtypes(\n", + " include=[\"number\"]).columns.tolist()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "TOTAL EFFECTIVENESS: 100.00%\n", + "\n", + "TOTAL COST: 4.27\n" + ] + } + ], + "source": [ + "eff, cost, length = cf_tree.cumulative_leaf_actions()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## T-GLANCE Output\n", + "The output of the T-GLANCE algorithm consists of a visual representation of the decision tree in which the user can inspect the subgroups created by the chosen split features and the respective actions along with their *Effectiveness* and *Cost*.\n", + "\n", + "*Effectiveness* and *Cost* are computed for the population of each node of the tree, similar to the way they were computed for each final cluster in C-GLANCE.\n", + "\n", + "*Total Effectiveness* and *Total Cost* are computed in the same manner as in C-GLANCE, with the only difference being that the set of actions being evaluated are the ones that correspond to the leaf nodes of the tree." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "More specifically, in the above output of T-GLANCE the end user can see that 3 subgroups were created using as split features the **Age** and **Marital-Status**.
\n", + "For example, the user can inspect that the individuals corresponding to the bottom right node belong to the subgroup with **Age <= 43** and **Marital-Status{Never-Married,Seperated,Widowed}**.
\n", + "\n", + "By applying the action **{Age:25.789, Relationship: Not-In-Family, Capital-Gain: 50416,753, Capital-Loss: 0.0, Hours-Per-Week: 16,174}** that is showcased in the bottom of the node, all individuals can get the favorable outcome as the *effectiveness* on this node is **100.00%** with a cost of 10.85.
Value 0.0 for feature Capital-Loss suggests that this action does not alter the specific feature.\n", + "\n", + "As a practical example of the auditing capabilities of T-GLANCE, one can observe the node described previously in contrast to the right child of the root node. More specifically, this node corresponds to individuals with **Age > 43**, with the optimal suggested action being **{Age:0.0, Workclass: Local-Gov, Capital-Gain: 18483,25, Capital-Loss: 0.0, Hours-Per-Week: 0.0}** with *effectiveness* of **100.00%** and cost 2.76.\n", + "\n", + "By comparing the suggested actions for the two subgroups, the user can gain the insight that individuals having **Age > 43** need only to change their workclass and raise their capital-gain while the individuals belonging to **Age <= 43** and **Marital-Status{Never-Married,Seperated,Widowed}** subgroup have to increase their capital-gain much more and also increase their hours-per-week and their age and also change their relationship-status." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "aix360", + "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.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/setup.py b/setup.py index eb1101a..9703530 100644 --- a/setup.py +++ b/setup.py @@ -124,6 +124,16 @@ "lime", "shap==0.42.1", ], + "glance":[ + "numpy==1.23.5", + "pandas==1.5.3", + "scikit-learn==1.5.2", + "dice-ml==0.11", + "tqdm==4.66.1", + "igraph==0.11.4", + "colorama==0.4.6", + "xgboost==2.0.3" + ], } # minimal dependencies in install_requires diff --git a/tests/glance/test_KMeans.py b/tests/glance/test_KMeans.py new file mode 100644 index 0000000..0913d83 --- /dev/null +++ b/tests/glance/test_KMeans.py @@ -0,0 +1,64 @@ +import pytest +import numpy as np +from sklearn.datasets import make_blobs +from sklearn.cluster import KMeans +from aix360.algorithms.glance.clustering import KMeansMethod # Replace 'your_module' with the actual module name +from sklearn.exceptions import NotFittedError + +def test_kmeans_initialization(): + """ + Test that KMeansMethod initializes correctly with the given number of clusters and random seed. + """ + num_clusters = 3 + random_seed = 42 + kmeans_method = KMeansMethod(num_clusters=num_clusters, random_seed=random_seed) + + assert kmeans_method.num_clusters == num_clusters + assert kmeans_method.random_seed == random_seed + assert isinstance(kmeans_method.model, KMeans) # Ensure model is an instance of KMeans + + +def test_kmeans_fit(): + """ + Test that the KMeansMethod can fit data properly. + """ + # Create synthetic data + num_clusters = 3 + data, _ = make_blobs(n_samples=100, centers=num_clusters, random_state=42) + + kmeans_method = KMeansMethod(num_clusters=num_clusters, random_seed=42) + kmeans_method.fit(data) + + assert kmeans_method.model.n_clusters == num_clusters + assert hasattr(kmeans_method.model, 'cluster_centers_') # Ensure model has been fit + + +def test_kmeans_predict(): + """ + Test that KMeansMethod can predict cluster assignments for new instances. + """ + # Create synthetic data + num_clusters = 3 + data, labels = make_blobs(n_samples=100, centers=num_clusters, random_state=42) + + kmeans_method = KMeansMethod(num_clusters=num_clusters, random_seed=42) + kmeans_method.fit(data) # Fit the model first + + # Test prediction on new instances + new_instances = np.array([[0, 0], [5, 5], [-5, -5]]) + predictions = kmeans_method.predict(new_instances) + print(predictions.dtype) + + assert len(predictions) == len(new_instances) # Ensure prediction length matches input length + assert all(isinstance(pred, np.integer) for pred in predictions) # Ensure predictions are valid cluster labels + + +def test_kmeans_predict_without_fit(): + """ + Test that predict raises an exception if the model hasn't been fit. + """ + kmeans_method = KMeansMethod(num_clusters=3, random_seed=42) + + with pytest.raises(NotFittedError): # Raises error when model is not fitted + kmeans_method.predict([[0, 0]]) + diff --git a/tests/glance/test_base.py b/tests/glance/test_base.py new file mode 100644 index 0000000..d9c5ed9 --- /dev/null +++ b/tests/glance/test_base.py @@ -0,0 +1,259 @@ +import pytest +import pandas as pd +import numpy as np +from aix360.algorithms.glance.base import ClusteringMethod, LocalCounterfactualMethod, GlobalCounterfactualMethod + + +# Concrete implementation for ClusteringMethod +class SimpleKMeans(ClusteringMethod): + def __init__(self, **kwargs): + super().__init__() # Call parent __init__ (optional but explicit) + self.kwargs = kwargs # Store any passed kwargs + + def fit(self, data: pd.DataFrame): + self.data = data + self.labels = np.random.randint(0, 2, size=len(data)) # Randomly assign clusters (2 clusters) + + def predict(self, instances: pd.DataFrame) -> np.ndarray: + return self.labels # Return the same random labels + + +# Concrete implementation for LocalCounterfactualMethod +class SimpleCounterfactual(LocalCounterfactualMethod): + def __init__(self, **kwargs): + super().__init__() + self.kwargs = kwargs # Store any passed kwargs + + def fit(self, **kwargs): + # Store additional fit kwargs + self.fit_kwargs = kwargs + + def explain_instances(self, instances: pd.DataFrame, num_counterfactuals: int) -> pd.DataFrame: + return instances.copy() # Dummy return for testing + + +# Concrete implementation for GlobalCounterfactualMethod +class SimpleGlobalCounterfactual(GlobalCounterfactualMethod): + def __init__(self, **kwargs): + super().__init__(**kwargs) # Call parent __init__ and pass kwargs + self.kwargs = kwargs # Store any passed kwargs + + def fit(self, X, y, **kwargs): + self.X = X + self.y = y + self.fit_kwargs = kwargs # Store any additional kwargs + + def explain_group(self, instances: pd.DataFrame = None) -> pd.DataFrame: + if instances is None: + return self.X # Return training data if no instances provided + return instances.copy() + + +# Sample data for testing +sample_data = pd.DataFrame({ + 'feature1': [1.0, 2.0, 3.0], + 'feature2': [4.0, 5.0, 6.0], +}) + +sample_target = pd.Series([0, 1, 0]) # Example target variable + + +# Test for SimpleKMeans +def test_simple_kmeans(): + kmeans = SimpleKMeans(param1="test") + kmeans.fit(sample_data) + + # Check if labels are assigned correctly + assert isinstance(kmeans, ClusteringMethod) + assert len(kmeans.labels) == len(sample_data) + assert set(kmeans.labels).issubset({0, 1}) # Expect labels to be either 0 or 1 + + predictions = kmeans.predict(sample_data) + assert len(predictions) == len(sample_data) # Predictions should match the number of samples + + # Test that kwargs are passed correctly + assert kmeans.kwargs['param1'] == "test" + + +# Test for SimpleCounterfactual +def test_simple_counterfactual(): + counterfactual = SimpleCounterfactual(param1="test") + counterfactual.fit(param2="fit_param") + + cf = counterfactual.explain_instances(sample_data, num_counterfactuals=2) + + # Check if the counterfactuals are returned correctly + assert isinstance(counterfactual, LocalCounterfactualMethod) + assert cf.equals(sample_data) # For this stub implementation, they should be the same + + # Test that kwargs are passed correctly + assert counterfactual.kwargs['param1'] == "test" + assert counterfactual.fit_kwargs['param2'] == "fit_param" + + +# Test for SimpleGlobalCounterfactual +def test_simple_global_counterfactual(): + global_cf = SimpleGlobalCounterfactual(param1="test") + global_cf.fit(sample_data, sample_target, param2="fit_param") + + # Test case when instances are passed + cf_group = global_cf.explain_group(sample_data) + assert cf_group.equals(sample_data) # For this stub implementation, they should be the same + assert isinstance(global_cf, GlobalCounterfactualMethod) + # Test case when no instances are passed + cf_group_default = global_cf.explain_group() + assert cf_group_default.equals(sample_data) # Should return the training data + + # Test that kwargs are passed correctly + assert global_cf.kwargs['param1'] == "test" + assert global_cf.fit_kwargs['param2'] == "fit_param" + + +# Test abstract class instantiation errors +def test_abstract_classes_instantiation(): + with pytest.raises(TypeError): + ClusteringMethod() # Should raise TypeError because it is abstract + + with pytest.raises(TypeError): + LocalCounterfactualMethod() # Should raise TypeError because it is abstract + + with pytest.raises(TypeError): + GlobalCounterfactualMethod() # Should raise TypeError because it is abstract + + +# # Test edge case for empty data +# def test_empty_data(): +# empty_data = pd.DataFrame() + +# kmeans = SimpleKMeans() +# kmeans.fit(empty_data) +# assert len(kmeans.predict(empty_data)) == 0 # Ensure it handles empty data correctly + +# counterfactual = SimpleCounterfactual() +# cf = counterfactual.explain_instances(empty_data, num_counterfactuals=2) +# assert cf.empty # Check that the result is an empty DataFrame + +# global_cf = SimpleGlobalCounterfactual() +# global_cf.fit(empty_data, pd.Series()) +# cf_group = global_cf.explain_group(empty_data) +# assert cf_group.empty # Check that the result is an empty DataFrame + +# def test_data_with_missing_values(): +# data_with_nan = pd.DataFrame({ +# 'feature1': [1.0, np.nan, 3.0], +# 'feature2': [4.0, 5.0, np.nan], +# }) + +# # Test for ClusteringMethod +# kmeans = SimpleKMeans() +# kmeans.fit(data_with_nan) +# predictions = kmeans.predict(data_with_nan) +# assert len(predictions) == len(data_with_nan) + +# # Test for LocalCounterfactualMethod +# counterfactual = SimpleCounterfactual() +# cf = counterfactual.explain_instances(data_with_nan, num_counterfactuals=2) +# assert cf.equals(data_with_nan) + +# # Test for GlobalCounterfactualMethod +# global_cf = SimpleGlobalCounterfactual() +# global_cf.fit(data_with_nan, pd.Series([0, 1, 0])) +# cf_group = global_cf.explain_group(data_with_nan) +# assert cf_group.equals(data_with_nan) + +# def test_data_with_mixed_types(): +# mixed_data = pd.DataFrame({ +# 'feature1': [1, 2, 3], # integers +# 'feature2': [1.5, 2.5, 3.5], # floats +# 'feature3': ['a', 'b', 'c'] # strings +# }) + +# # Test for ClusteringMethod +# kmeans = SimpleKMeans() +# kmeans.fit(mixed_data) +# predictions = kmeans.predict(mixed_data) +# assert len(predictions) == len(mixed_data) + +# # Test for LocalCounterfactualMethod +# counterfactual = SimpleCounterfactual() +# cf = counterfactual.explain_instances(mixed_data, num_counterfactuals=2) +# assert cf.equals(mixed_data) + +# # Test for GlobalCounterfactualMethod +# global_cf = SimpleGlobalCounterfactual() +# global_cf.fit(mixed_data, pd.Series([0, 1, 0])) +# cf_group = global_cf.explain_group(mixed_data) +# assert cf_group.equals(mixed_data) + +# def test_counterfactual_num_values(): +# # Test with num_counterfactuals=0 +# counterfactual = SimpleCounterfactual() +# cf_zero = counterfactual.explain_instances(sample_data, num_counterfactuals=0) +# assert cf_zero.equals(sample_data) # Expected to return the same data + +# # Test with negative num_counterfactuals +# cf_negative = counterfactual.explain_instances(sample_data, num_counterfactuals=-1) +# assert cf_negative.equals(sample_data) # Should handle gracefully (same data) + +# def test_single_instance(): +# single_instance = pd.DataFrame({'feature1': [1.0], 'feature2': [4.0]}) + +# # Test for ClusteringMethod +# kmeans = SimpleKMeans() +# kmeans.fit(single_instance) +# predictions = kmeans.predict(single_instance) +# assert len(predictions) == 1 + +# # Test for LocalCounterfactualMethod +# counterfactual = SimpleCounterfactual() +# cf = counterfactual.explain_instances(single_instance, num_counterfactuals=1) +# assert cf.equals(single_instance) + +# # Test for GlobalCounterfactualMethod +# global_cf = SimpleGlobalCounterfactual() +# global_cf.fit(single_instance, pd.Series([0])) +# cf_group = global_cf.explain_group(single_instance) +# assert cf_group.equals(single_instance) + +# def test_fit_with_kwargs(): +# # ClusteringMethod with extra kwargs +# kmeans = SimpleKMeans(param1="test") +# kmeans.fit(sample_data) +# assert kmeans.kwargs['param1'] == "test" + +# # LocalCounterfactualMethod with extra kwargs +# counterfactual = SimpleCounterfactual(param1="test") +# counterfactual.fit(param2="fit_param", extra_param="extra") +# assert counterfactual.fit_kwargs['extra_param'] == "extra" + +# # GlobalCounterfactualMethod with extra kwargs +# global_cf = SimpleGlobalCounterfactual(param1="test") +# global_cf.fit(sample_data, sample_target, param2="fit_param", another_param="another") +# assert global_cf.fit_kwargs['another_param'] == "another" + +# def test_global_counterfactual_with_none(): +# global_cf = SimpleGlobalCounterfactual() +# global_cf.fit(sample_data, sample_target) + +# # Test case with instances as None +# cf_group_default = global_cf.explain_group(None) +# assert cf_group_default.equals(sample_data) # Should return training data + +# def test_predict_with_empty_data(): +# empty_data = pd.DataFrame() + +# kmeans = SimpleKMeans() +# kmeans.fit(empty_data) +# predictions = kmeans.predict(empty_data) +# assert len(predictions) == 0 # Ensure predict returns no labels for empty data +# def test_clustering_method_abstract_instantiation(): +# with pytest.raises(TypeError): +# ClusteringMethod() + +# def test_local_counterfactual_method_abstract_instantiation(): +# with pytest.raises(TypeError): +# LocalCounterfactualMethod() + +# def test_global_counterfactual_method_abstract_instantiation(): +# with pytest.raises(TypeError): +# GlobalCounterfactualMethod() diff --git a/tests/glance/test_counterfactual_costs.py b/tests/glance/test_counterfactual_costs.py new file mode 100644 index 0000000..9471b1e --- /dev/null +++ b/tests/glance/test_counterfactual_costs.py @@ -0,0 +1,52 @@ +import pandas as pd +import numpy as np +import pytest +from aix360.algorithms.glance.counterfactual_costs import build_dist_func_dataframe +# Assume build_dist_func_dataframe is imported from the relevant module + +def test_build_dist_func_dataframe(): + # Setup input DataFrame with numerical and categorical columns + X = pd.DataFrame({ + 'age': [25, 30, 22, 45], + 'salary': [50000, 60000, 55000, 53000], + 'gender': ['Male', 'Female', 'Female', 'Male'] + }) + + # Specify numerical and categorical columns + numerical_columns = ['age', 'salary'] + categorical_columns = ['gender'] + + # Build the distance function + dist_func = build_dist_func_dataframe(X, numerical_columns, categorical_columns, n_bins=5) + + # Generate test DataFrames + X1 = pd.DataFrame({ + 'age': [26, 31, 23, 46], + 'salary': [51000, 61000, 54000, 52000], + 'gender': ['Male', 'Female', 'Male', 'Female'] + }) + + X2 = pd.DataFrame({ + 'age': [25, 30, 22, 45], + 'salary': [50000, 60000, 55000, 53000], + 'gender': ['Female', 'Female', 'Female', 'Male'] + }) + + # Calculate expected distances manually + feat_intervals = { + col: ((max(X[col]) - min(X[col])) / 5) for col in numerical_columns + } + + + expected_distances = np.array([ + abs(26 - 25) / feat_intervals['age'] + abs(51000 - 50000) / feat_intervals['salary'] + (X1['gender'][0] != X2['gender'][0]), # First row: age diff + gender diff + abs(31 - 30) / feat_intervals['age'] + abs(61000 - 60000) / feat_intervals['salary'] + (X1['gender'][1] != X2['gender'][1]), # Second row: age diff + gender same + abs(23 - 22) / feat_intervals['age'] + abs(54000 - 55000) / feat_intervals['salary'] + (X1['gender'][2] != X2['gender'][2]), # Third row: age diff + gender diff + abs(46 - 45) / feat_intervals['age'] + abs(52000 - 53000) / feat_intervals['salary'] + (X1['gender'][3] != X2['gender'][3]) # Fourth row: age diff + gender same + ]) + + # Invoke the distance function + distances = dist_func(X1, X2) + + # Assert the distances are as expected + pd.testing.assert_series_equal(distances, pd.Series(expected_distances), check_dtype=False) diff --git a/tests/glance/test_counterfactual_tree.py b/tests/glance/test_counterfactual_tree.py new file mode 100644 index 0000000..a429f81 --- /dev/null +++ b/tests/glance/test_counterfactual_tree.py @@ -0,0 +1,68 @@ +import pandas as pd +import numpy as np +import pytest +from sklearn.linear_model import LogisticRegression +from sklearn.datasets import make_classification +from aix360.algorithms.glance.counterfactual_tree.counterfactual_tree import T_GLANCE # Adjust the import as needed + +# Sample dataset for testing +def create_sample_data(): + # Example with 100 samples and 5 features + np.random.seed(0) # For reproducibility + data = pd.DataFrame(np.random.rand(100, 5), columns=[f'feature_{i}' for i in range(5)]) + data['target'] = np.random.randint(0, 2, size=100) # Binary outcome + return data + +@pytest.fixture +def sample_data(): + return create_sample_data() + +@pytest.fixture +def fitted_model(sample_data): + model = LogisticRegression() + model.fit(sample_data.drop(columns='target'), sample_data['target']) + return model + +def test_counterfactual_tree_initialization(fitted_model): + tree = T_GLANCE(model=fitted_model) + assert tree.model == fitted_model + assert tree.split_features is None + assert tree.partition_counterfactuals is None + +def test_counterfactual_tree_fit(fitted_model): + data = create_sample_data() + tree = T_GLANCE(model=fitted_model) + + # Test default split_features (None) + tree.fit(data.drop(columns='target'), data['target'], data) + assert len(tree.split_features) == 2 # Assuming model has informative features + + # Test with specific number of split features + tree = T_GLANCE(model=fitted_model, split_features=3) + tree.fit(data.drop(columns='target'), data['target'], data) + assert len(tree.split_features) == 3 + + # Test with a numeric feature list + numeric_features = [f'feature_{i}' for i in range(5)] + tree.fit(data.drop(columns='target'), data['target'], data ,numeric_features_names=numeric_features) + assert tree.numerical_features_names == numeric_features + + # Test with categorical features + categorical_features = [f'feature_0'] + tree.fit(data.drop(columns='target'), data['target'], data, categorical_features_names=categorical_features) + assert tree.categorical_features_names == categorical_features + +def test_partition_group(fitted_model): + data = create_sample_data() + tree = T_GLANCE(model=fitted_model) + tree.fit(data.drop(columns='target'), data['target'],data) + + # Simulate instances for partitioning + instances = data.sample(20).drop(columns='target') + node = tree.partition_group(instances) + + assert node is not None + assert hasattr(node, 'split_feature') + assert hasattr(node, 'children') + + diff --git a/tests/glance/test_iterative_merges.py b/tests/glance/test_iterative_merges.py new file mode 100644 index 0000000..3f83e83 --- /dev/null +++ b/tests/glance/test_iterative_merges.py @@ -0,0 +1,487 @@ +import pytest +import pandas as pd +import numpy as np +from sklearn.cluster import KMeans +from sklearn.preprocessing import OneHotEncoder +from sklearn.compose import ColumnTransformer +from typing import List, Dict +from aix360.algorithms.glance.iterative_merges.iterative_merges import C_GLANCE,cumulative,action_fake_cost,_select_action_low_cost,_select_action_max_eff,_select_action_mean,print_results,format_glance_output,_generate_clusters, _one_hot_encode,_find_candidate_clusters,_merge_clusters +import unittest + +# Sample data for testing +@pytest.fixture +def sample_data(): + data = { + 'feature1': ['A', 'B', 'A', 'B', 'C'], + 'feature2': [1, 2, 1, 2, 3], + 'feature3': [5.0, 6.0, 5.5, 6.5, 7.0] + } + return pd.DataFrame(data) + +def test_one_hot_encode(sample_data): + categorical_columns = ['feature1'] + encoded_df = _one_hot_encode(sample_data, categorical_columns) + + # Check if the output is a DataFrame + assert isinstance(encoded_df, pd.DataFrame) + + # Check if the correct columns are present after encoding + expected_columns = ['ohe__feature1_A', 'ohe__feature1_B', 'ohe__feature1_C', 'remainder__feature2', 'remainder__feature3'] + assert all(col in encoded_df.columns for col in expected_columns) + + # Check the shape of the output + assert encoded_df.shape == (5, 5) # 5 rows and 5 columns + + # Check the first row of the encoded DataFrame + expected_first_row = [1.0, 0.0, 0.0, 1, 5.0] # One-hot encoded values for first entry + assert all(encoded_df.iloc[0].values == expected_first_row) + +def test_generate_clusters(sample_data): + categorical_features_names = ['feature1'] + num_clusters = 2 + + # Create a KMeans clustering method + clustering_method = KMeans(n_clusters=num_clusters, random_state=42) + + clusters = _generate_clusters(sample_data, num_clusters, categorical_features_names, clustering_method) + + # Check if the clusters dictionary is returned + assert isinstance(clusters, dict) + + # Check if the number of clusters is correct + assert len(clusters) == num_clusters + + # Check if all instances are assigned to a cluster + all_assigned = sum(len(cluster) for cluster in clusters.values()) + assert all_assigned == sample_data.shape[0] + + # Check if the clusters are correctly assigned + for cluster_id, cluster_df in clusters.items(): + assert isinstance(cluster_df, pd.DataFrame) + assert all(cluster_df.index.isin(sample_data.index)) + +@pytest.fixture +def clusters_data(): + return { + 0: pd.DataFrame({'feature1': [1, 2], 'feature2': [3, 4]}), + 1: pd.DataFrame({'feature1': [5, 6], 'feature2': [7, 8]}), + 2: pd.DataFrame({'feature1': [9], 'feature2': [10]}), # Smallest cluster + } + +@pytest.fixture +def centroids_data(): + return { + 0: pd.DataFrame({'feature1': [1.5], 'feature2': [3.5]}), + 1: pd.DataFrame({'feature1': [5.5], 'feature2': [7.5]}), + 2: pd.DataFrame({'feature1': [9], 'feature2': [10]}), + } + +@pytest.fixture +def explanations_data(): + return { + 0: pd.DataFrame({'explanation': [0.1, 0.2]}), + 1: pd.DataFrame({'explanation': [0.3, 0.4]}), + 2: pd.DataFrame({'explanation': [0.5]}), + } + +@pytest.fixture +def distance_function(): + # A simple mock distance function + def mock_distance(df1: pd.DataFrame, df2: pd.DataFrame) -> pd.Series: + return pd.Series(np.random.rand(df1.shape[0])) # Random distances + return mock_distance + +def test_find_candidate_clusters(clusters_data, centroids_data, explanations_data, distance_function): + heuristic_weights = (0.5, 0.5) + + # Call the function with mock data + candidate_cluster = _find_candidate_clusters( + clusters=clusters_data, + cluster_centroids=centroids_data, + explanations_centroid=explanations_data, + heuristic_weights=heuristic_weights, + dist_func_dataframe=distance_function + ) + + # Validate the output + assert isinstance(candidate_cluster, tuple) + assert len(candidate_cluster) == 2 + + # The smallest cluster is expected to be cluster 2 (with only one instance) + assert candidate_cluster[0] == 2 # Expected smallest cluster + assert candidate_cluster[1] in [0, 1] # The candidate cluster should be either cluster 0 or cluster 1 + + # Additional checks can be added based on your specific expectations + # For instance, we could check that the candidate cluster is one of the remaining clusters + assert candidate_cluster[1] != candidate_cluster[0] + + +@pytest.fixture +def categorical_columns(): + return ["Action1", "Action2"] + +def test_print_results(capsys, clusters_stats): + total_effectiveness = 0.8 + total_cost = 250.0 + + # Call the print_results function + print_results(clusters_stats, total_effectiveness, total_cost) + + # Capture the output + captured = capsys.readouterr() + + # Validate the output + assert "CLUSTER 1 with size 10:" in captured.out + assert "Effectiveness: 85.00%, Cost: 100.00" in captured.out + assert "CLUSTER 2 with size 15:" in captured.out + assert "Effectiveness: 75.00%, Cost: 150.00" in captured.out + +@pytest.fixture +def clusters_stats(): + return { + 0: { + "size": 10, + "action": pd.DataFrame({"Action1": [1], "Action2": [2]}), + "effectiveness": 0.85, + "cost": 100.0, + }, + 1: { + "size": 15, + "action": pd.DataFrame({"Action1": [-1], "Action2": [0]}), + "effectiveness": 0.75, + "cost": 150.0, + }, + } + +@pytest.fixture +def categorical_columns(): + return ["Action1", "Action2"] + +def strip_ansi_codes(text: str) -> str: + import re + + """Remove ANSI escape sequences from a string.""" + ansi_escape = re.compile(r'\x1B\[[0-?9;]*[mK]') + return ansi_escape.sub('', text) + +def test_format_glance_output(capsys, clusters_stats, categorical_columns): + # Convert the action DataFrames into a Series for the test + for cluster_id in clusters_stats.keys(): + clusters_stats[cluster_id]['action'] = clusters_stats[cluster_id]['action'].iloc[0] + + # Call the format_glance_output function + format_glance_output(clusters_stats, categorical_columns) + + # Capture the output + captured = capsys.readouterr() + + # Strip ANSI codes from captured output for clean comparison + output = strip_ansi_codes(captured.out) + + # Validate the output for cluster 1 + assert "Action 1" in output + assert "Effectiveness: 85.00%" in output + assert "Cost: 100.00" in output + assert "Action 2" in output + assert "Effectiveness: 75.00%" in output + assert "Cost: 150.00" in output + + +class MockModel: + def predict(self, X): + # Simple mock predict function that flips the prediction based on a threshold + return (X.sum(axis=1) > 1).astype(int) + +def mock_distance_function(df1: pd.DataFrame, df2: pd.DataFrame) -> pd.Series: + # Simple distance function that computes the sum of absolute differences + return (df1 - df2).abs().sum(axis=1) + +def test_select_action_mean(): + # Sample instances DataFrame + instances = pd.DataFrame({ + 'feature1': [0, 2, 1], + 'feature2': [1, 0, 1] + }) + + # Sample candidate actions DataFrame + candidate_actions = pd.DataFrame({ + 'feature1': [1, 0, 2], + 'feature2': [0, 2, 3] + }) + + # Define numerical and categorical features + numerical_features_names = ['feature1', 'feature2'] + categorical_features_names = [] # Assuming no categorical features for this test + + # Create a mock model instance + model = MockModel() + + # Call the _select_action_mean function + n_flipped, recourse_cost_sum, mean_action = _select_action_mean( + model=model, + instances=instances, + candidate_actions=candidate_actions, + dist_func_dataframe=mock_distance_function, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + ) + # Expected values + expected_n_flipped = 3 # From the mock model predictions (0 + 1 + 1 = 2) + + # The calculation for recourse cost sum + + expected_recourse_cost_sum = 6 + + # Mean of candidate actions + expected_mean_action = pd.Series({ + 'feature1': 1.0, + 'feature2': 1.666667 + }) + + mean_action = mean_action.astype('float64') + expected_mean_action = expected_mean_action.astype('float64') + # Assertions + assert n_flipped == expected_n_flipped + assert recourse_cost_sum == expected_recourse_cost_sum + pd.testing.assert_series_equal(mean_action, expected_mean_action) + +def test_select_action_max_eff(): + # Sample instances DataFrame + instances = pd.DataFrame({ + 'feature1': [0, 1, 2], + 'feature2': [1, 0, 1] + }) + + # Sample candidate actions DataFrame + candidate_actions = pd.DataFrame({ + 'feature1': [1, 0], + 'feature2': [0, 2] + }) + + # Define numerical and categorical features + numerical_features_names = ['feature1', 'feature2'] + categorical_features_names = [] # Assuming no categorical features for this test + + # Create a mock model instance + model = MockModel() + + # Call the _select_action_max_eff function + max_n_flipped, recourse_cost_sum, best_action = _select_action_max_eff( + model=model, + instances=instances, + candidate_actions=candidate_actions, + dist_func_dataframe=mock_distance_function, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + num_actions=1, + ) + + # Expected values + expected_max_n_flipped = 3 # Based on the mock model predictions + expected_recourse_cost_sum = 3 + + expected_best_action = pd.Series([1, 0], index=['feature1', 'feature2'], name=0) + expected_best_action = expected_best_action.astype(np.int64) + best_action = best_action.astype(np.int64) + + assert max_n_flipped == expected_max_n_flipped + assert recourse_cost_sum == expected_recourse_cost_sum + pd.testing.assert_series_equal(best_action, expected_best_action) + +def test_select_action_low_cost(): + + model = MockModel() + instances = pd.DataFrame({ + 'feature1': [1, 2, 3], + 'feature2': [0, 5, 6] + }) + + # Example cluster instances DataFrame + cluster_instances = pd.DataFrame({ + 'feature1': [1, 2], + 'feature2': [4, 5] + }) + + candidate_actions = pd.DataFrame({ + 'feature1': [1, 2], + 'feature2': [3, 4],}) + + numerical_features_names = ['feature1', 'feature2'] + categorical_features_names = [] + action_threshold = 0.5 + num_low_cost = 1 + inv_total_clusters = 1 + + # Mock the dist_func_dataframe + dist_func_dataframe = mock_distance_function + + + # Call the function under test + n_flipped, min_recourse_cost_sum, best_action = _select_action_low_cost( + model=model, + instances=instances, + cluster_instances=cluster_instances, + candidate_actions=candidate_actions, + dist_func_dataframe=dist_func_dataframe, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names, + action_threshold=action_threshold, + num_low_cost=num_low_cost, + inv_total_clusters=inv_total_clusters, + ) + + assert n_flipped == 2 + assert min_recourse_cost_sum == 8 + pd.testing.assert_series_equal(best_action, pd.Series([1, 3], index=['feature1', 'feature2'], name=0)) + +class TestActionFakeCost(unittest.TestCase): + + def test_basic_functionality(self): + # Sample action with numerical and categorical features + action = pd.Series({ + 'feature1': 10, + 'feature2': 20, + 'cat_feature1': "-", + 'cat_feature2': "value" + }) + numerical_features_names = ['feature1', 'feature2'] + categorical_features_names = ['cat_feature1', 'cat_feature2'] + + result = action_fake_cost(action, numerical_features_names, categorical_features_names) + expected = 10 + 20 + 1 # sum of numerical + count of non "-" in categorical + self.assertEqual(result, expected) + + def test_no_categorical_features(self): + # Action with only numerical features + action = pd.Series({ + 'feature1': 15, + 'feature2': 25 + }) + numerical_features_names = ['feature1', 'feature2'] + categorical_features_names = [] # No categorical features + + result = action_fake_cost(action, numerical_features_names, categorical_features_names) + expected = 15 + 25 # Just the sum of numerical features + self.assertEqual(result, expected) + + def test_all_categorical_features(self): + # Action with categorical features only + action = pd.Series({ + 'cat_feature1': "-", + 'cat_feature2': "-" + }) + numerical_features_names = [] # No numerical features + categorical_features_names = ['cat_feature1', 'cat_feature2'] + + result = action_fake_cost(action, numerical_features_names, categorical_features_names) + expected = 0 # No numerical features and both categorical are "-" + self.assertEqual(result, expected) + + def test_mixed_features(self): + # Action with mixed categorical features + action = pd.Series({ + 'feature1': 5, + 'feature2': 10, + 'cat_feature1': "value", + 'cat_feature2': "-" + }) + numerical_features_names = ['feature1', 'feature2'] + categorical_features_names = ['cat_feature1', 'cat_feature2'] + + result = action_fake_cost(action, numerical_features_names, categorical_features_names) + expected = 5 + 10 + 1 # sum of numerical features + count of non "-" + self.assertEqual(result, expected) + +def mock_apply_action_pandas(instances, action, numeric_features_names, categorical_features_names, categorical_no_action_token): + # Example: Apply an action by adding a fixed value to numeric features + modified_instances = instances.copy() + for feature in numeric_features_names: + if feature in action: + modified_instances[feature] += action[feature] + return modified_instances + +# Replace the real function with the mock one +apply_action_pandas = mock_apply_action_pandas + +def test_cumulative(): + instances = pd.DataFrame({ + 'feature1': [1, 2, 3], + 'feature2': [0, 5, 6] + }) + + # Example cluster instances DataFrame + + candidate_actions = [pd.Series({'feature1': 3,'feature2': 2})] + model = MockModel() + categorical_features_names = [] + numeric_features_names = ['feature1', 'feature2'] + categorical_no_action_token = "-" + + effectiveness, cost = cumulative( + model, + instances, + candidate_actions, + mock_distance_function, + numeric_features_names, + categorical_features_names, + categorical_no_action_token + ) + + assert effectiveness == 3 + assert cost == 15 + +def test_iterative_merges_init(): + model = MockModel() + im = C_GLANCE(model=model) + + assert im.model == model + assert im.initial_clusters == 100 + assert im.final_clusters == 10 + assert im.num_local_counterfactuals == 5 + assert im.heuristic_weights == (0.5, 0.5) + assert im.alternative_merges is True + assert im.random_seed == 13 + assert im.verbose is True + +def test_set_features_names(): +# Sample data for testing + sample_X = pd.DataFrame({ + 'feature1': [1.0, 2.0, 3.0], + 'feature2': [4.0, 5.0, 6.0] + }) + + sample_y = pd.Series([0, 1, 0]) # Target variable + model = MockModel() + im = C_GLANCE(model=model) + + numerical_names, categorical_names = im._set_features_names(sample_X, None, None) + assert numerical_names == ['feature1', 'feature2'] + assert categorical_names == [] + + numerical_names, categorical_names = im._set_features_names(sample_X, ['feature1'], None) + assert numerical_names == ['feature1'] + assert categorical_names == ['feature2'] + + numerical_names, categorical_names = im._set_features_names(sample_X, None, ['feature2']) + assert numerical_names == ['feature1'] + assert categorical_names == ['feature2'] + +def test_fit(): + sample_X = pd.DataFrame({ + 'feature1': [1.0, 2.0, 3.0], + 'feature2': [4.0, 5.0, 6.0], + 'target':[0,1,1] + }) + + sample_y = pd.Series([0, 1, 0]) # Target variable + model = MockModel() + im = C_GLANCE(model=model) + + result = im.fit(sample_X.drop(columns='target'), sample_y, sample_X) + + assert isinstance(result, C_GLANCE) + assert im.numerical_features_names == ['feature1', 'feature2'] + assert im.categorical_features_names == [] + assert im.X.equals(sample_X.drop(columns='target')) + assert im.y.equals(sample_y) + assert im.train_dataset.equals(sample_X) \ No newline at end of file diff --git a/tests/glance/test_local_cfs.py b/tests/glance/test_local_cfs.py new file mode 100644 index 0000000..996ecb7 --- /dev/null +++ b/tests/glance/test_local_cfs.py @@ -0,0 +1,150 @@ +import pytest +import pandas as pd +import numpy as np +from sklearn.linear_model import LogisticRegression +from sklearn.datasets import make_classification +from aix360.algorithms.glance.local_cfs.nearest_neighbor import NearestNeighborMethod +from aix360.algorithms.glance.local_cfs.random_sampling import RandomSampling +from aix360.algorithms.glance.local_cfs.dice_method import DiceMethod + + +# Create a sample dataset for testing +def create_sample_data(): + X, y = make_classification(n_samples=100, n_features=5, n_informative=3, n_redundant=1, random_state=42) + df = pd.DataFrame(X, columns=[f'feature_{i}' for i in range(5)]) + df['outcome'] = y + return df + +# Test for NearestNeighborMethod +def test_nearest_neighbor_method(): + # Create sample data + data = create_sample_data() + + # Initialize model and methods + model = LogisticRegression() + model.fit(data.drop(columns='outcome'), data['outcome']) # Fit model to data + nn_method = NearestNeighborMethod() + + # Fit the NearestNeighborMethod + nn_method.fit(model, data, outcome_name='outcome', continuous_features=[f'feature_{i}' for i in range(5)], feat_to_vary=['feature_0']) + + # Test explaining instances + instances = data.sample(5).drop(columns='outcome') # Randomly select 5 instances + counterfactuals = nn_method.explain_instances(instances, num_counterfactuals=3) + + # Validate output + assert counterfactuals.shape[0] == 15 # 5 instances * 3 counterfactuals + assert set(counterfactuals.columns) == set(data.columns[:-1]) # Check if columns match original features + + + +def create_sample_data_random(num_samples=100): + np.random.seed(42) # For reproducibility + data = pd.DataFrame({ + 'feature_0': np.random.rand(num_samples), + 'feature_1': np.random.rand(num_samples), + 'feature_2': np.random.rand(num_samples), + 'feature_3': np.random.rand(num_samples), + 'feature_4': np.random.rand(num_samples), + 'outcome': np.random.choice([0, 1], num_samples) + }) + return data + +@pytest.fixture +def setup_data(): + """Fixture to create sample data and fit a model.""" + data = create_sample_data_random() + model = LogisticRegression() + model.fit(data.drop(columns='outcome'), data['outcome']) + + return model, data + +def test_random_sampling_method(setup_data): + model, data = setup_data + + # Initialize RandomSampling method + nn_method = RandomSampling(model, n_most_important=3, n_categorical_most_frequent=2, + numerical_features=[f'feature_{i}' for i in range(5)], + categorical_features=[]) + + # Fit the method + nn_method.fit(data.drop(columns='outcome'), data['outcome']) + + # Test explaining instances + instances = data.sample(5).drop(columns='outcome') # Randomly select 5 instances + counterfactuals = nn_method.explain_instances(instances, num_counterfactuals=3) + + # Validate output + assert counterfactuals.shape[0] == 15 # 5 instances * 3 counterfactuals + assert set(counterfactuals.columns) == set(data.columns[:-1]) # Check if columns match original features + +def test_invalid_explain_input_shape(setup_data): + model, data = setup_data + + nn_method = RandomSampling(model, n_most_important=3, n_categorical_most_frequent=2, + numerical_features=[f'feature_{i}' for i in range(5)], + categorical_features=[]) + nn_method.fit(data.drop(columns='outcome'), data['outcome']) + + # Check that ValueError is raised for empty DataFrame + with pytest.raises(ValueError, match="Input must be a single row DataFrame."): + nn_method.explain(pd.DataFrame(), num_counterfactuals=3) + +def test_explain_instances_with_all_one_class(setup_data): + model, data = setup_data + + # Create a subset with only one class + all_zeros = data[data['outcome'] == 0].sample(5).drop(columns='outcome') + nn_method = RandomSampling(model, n_most_important=3, n_categorical_most_frequent=2, + numerical_features=[f'feature_{i}' for i in range(5)], + categorical_features=[]) + nn_method.fit(data.drop(columns='outcome'), data['outcome']) + + # Explain instances where all instances belong to one class + counterfactuals = nn_method.explain_instances(all_zeros, num_counterfactuals=3) + + # Assert that the output is handled properly, could be empty or valid based on implementation + assert isinstance(counterfactuals, pd.DataFrame) + +def test_explain_instances_with_insufficient_valid_counterfactuals(setup_data): + model, data = setup_data + + # Testing behavior when the method should return fewer counterfactuals than requested + nn_method = RandomSampling(model, n_most_important=3, n_categorical_most_frequent=2, + numerical_features=[f'feature_{i}' for i in range(5)], + categorical_features=[]) + nn_method.fit(data.drop(columns='outcome'), data['outcome']) + + few_instances = data.sample(1).drop(columns='outcome') # Only one instance + counterfactuals = nn_method.explain_instances(few_instances, num_counterfactuals=10) + + # The output should not exceed the number requested + assert counterfactuals.shape[0] <= 10 +# def test_random_sampling_method(): +# data = create_sample_data() + +# # Initialize model and methods +# model = LogisticRegression() +# model.fit(data.drop(columns='outcome'), data['outcome']) # Fit model to data +# nn_method = RandomSampling(model, 15, 20, numerical_features=[f'feature_{i}' for i in range(5)], categorical_features=[]) +# nn_method.fit(data.drop(columns='outcome'), data['outcome']) + +# instances = data.sample(5).drop(columns='outcome') # Randomly select 5 instances +# counterfactuals = nn_method.explain_instances(instances, num_counterfactuals=3) +# # Validate output +# assert counterfactuals.shape[0] == 15 # 5 instances * 3 counterfactuals +# assert set(counterfactuals.columns) == set(data.columns[:-1]) # Check if columns match original features + +def test_dice_method(): + data = create_sample_data() + + # Initialize model and methods + model = LogisticRegression() + model.fit(data.drop(columns='outcome'), data['outcome']) # Fit model to data + dice_method = DiceMethod() + dice_method.fit(model,data,'outcome',[f'feature_{i}' for i in range(5)],[f'feature_{i}' for i in range(2)]) + instances = data.sample(5).drop(columns='outcome') + counterfactuals = dice_method.explain_instances(instances, num_counterfactuals=3) + # Validate output + assert counterfactuals.shape[0] == 15 # 5 instances * 3 counterfactuals + assert set(counterfactuals.columns) == set(data.columns[:-1]) # Check if columns match original features diff --git a/tests/glance/test_node.py b/tests/glance/test_node.py new file mode 100644 index 0000000..b868144 --- /dev/null +++ b/tests/glance/test_node.py @@ -0,0 +1,136 @@ +from aix360.algorithms.glance.counterfactual_tree.node import Node +import pandas as pd +import unittest + +class TestNodeClass(unittest.TestCase): + + def setUp(self): + """Set up some common test cases.""" + # Create a root node and two children for testing. + self.root = Node(split_feature="feature1", actions=[{"action": "A"}], effectiveness=5, cost=10, size=100) + self.child1 = Node(split_feature="feature2", actions=[{"action": "B"}], effectiveness=3, cost=5, size=50) + self.child2 = Node(split_feature="feature3", actions=[{"action": "C"}], effectiveness=2, cost=8, size=25) + + def test_node_initialization(self): + """Test if the node initializes correctly with given parameters.""" + node = Node(split_feature="feature1", actions=[{"action": "A"}], effectiveness=5, cost=10, size=100) + self.assertEqual(node.split_feature, "feature1") + self.assertEqual(node.actions, [{"action": "A"}]) + self.assertEqual(node.effectiveness, 5) + self.assertEqual(node.cost, 10) + self.assertEqual(node.size, 100) + self.assertEqual(node.children, {}) + + def test_add_child(self): + """Test if the add_child method works as expected.""" + subgroup1 = [1, 2] + subgroup2 = [3, 4] + + # Add children to the root node. + self.root.add_child(subgroup1, self.child1) + self.root.add_child(subgroup2, self.child2) + + # Check if the children were added correctly. + self.assertIn(tuple(subgroup1), self.root.children) + self.assertIn(tuple(subgroup2), self.root.children) + self.assertEqual(self.root.children[tuple(subgroup1)], self.child1) + self.assertEqual(self.root.children[tuple(subgroup2)], self.child2) + + def test_return_leafs_actions(self): + """Test if the return_leafs_actions method returns all actions from leaf nodes.""" + # Add children to root node. + self.root.add_child([1, 2], self.child1) + self.root.add_child([3, 4], self.child2) + + # Since both child1 and child2 are leaf nodes, their actions should be returned. + leaf_actions = self.root.return_leafs_actions() + self.assertEqual(len(leaf_actions), 2) + self.assertIn({"action": "B"}, leaf_actions) + self.assertIn({"action": "C"}, leaf_actions) + + def test_return_leafs_actions_with_nested_tree(self): + """Test if return_leafs_actions works with a nested tree structure.""" + child3 = Node(split_feature="feature4", actions=[{"action": "D"}], effectiveness=1, cost=3, size=15) + # Add child1 as a child of root, and child3 as a child of child1. + self.child1.add_child([5, 6], child3) + self.root.add_child([1, 2], self.child1) + + # Now, child3 is the only leaf node. + leaf_actions = self.root.return_leafs_actions() + self.assertEqual(leaf_actions, [{"action": "D"}]) + +class TestNodeGraphClass(unittest.TestCase): + + def setUp(self): + """Set up some common test cases.""" + # Create a root node and two children for testing. + self.root = Node( + split_feature="feature1", + actions=[pd.Series({"action": "A"})], # Use Pandas Series + effectiveness=5, + cost=10, + size=100 + ) + self.child1 = Node( + split_feature="feature2", + actions=[pd.Series({"action": "B"})], # Use Pandas Series + effectiveness=3, + cost=5, + size=50 + ) + self.child2 = Node( + split_feature="feature3", + actions=[pd.Series({"action": "C"})], # Use Pandas Series + effectiveness=2, + cost=8, + size=25 + ) + + def test_to_igraph_structure(self): + """Test if the iGraph object is correctly created from the node structure.""" + # Add children to the root node. + self.root.add_child([1, 2], self.child1) + self.root.add_child([3, 4], self.child2) + + # Convert the node tree to iGraph. + g = self.root.to_igraph() + + # Check if the correct number of vertices is created. + self.assertEqual(len(g.vs), 3) # 1 root node + 2 child nodes. + + # Check if the correct number of edges is created. + self.assertEqual(len(g.es), 2) # 2 edges from root to its children. + + # Check the vertex labels. + vertex_labels = g.vs["label"] + self.assertTrue(all("action" in label for label in vertex_labels)) + + # Check the edge labels. + edge_labels = g.es["label"] + self.assertIn("feature1", edge_labels[0]) # Root should have split on 'feature1'. + + # Check the labels of the edges. + edge_labels = g.es["label"] + self.assertEqual(edge_labels[0], "feature1 (1, 2)") + self.assertEqual(edge_labels[1], "feature1 (3, 4)") + + def test_pre_order_traversal_node_ids(self): + """Test if pre-order traversal correctly assigns node IDs.""" + # Add children to the root node. + self.root.add_child([1, 2], self.child1) + self.root.add_child([3, 4], self.child2) + + # Traverse the tree and assign IDs. + self.root.to_igraph() + + # Check if the nodes were assigned unique IDs in pre-order fashion. + self.assertEqual(self.root.id, 0) + self.assertEqual(self.child1.id, 1) + self.assertEqual(self.child2.id, 2) + + + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/glance/test_phase2.py b/tests/glance/test_phase2.py new file mode 100644 index 0000000..5566019 --- /dev/null +++ b/tests/glance/test_phase2.py @@ -0,0 +1,116 @@ +import pytest +import pandas as pd +import numpy as np +from aix360.algorithms.glance.iterative_merges.phase2 import generate_cluster_centroid_explanations +from aix360.algorithms.glance.utils.action import extract_actions_pandas +from aix360.algorithms.glance.utils.centroid import centroid_pandas +from aix360.algorithms.glance.base import LocalCounterfactualMethod +from unittest.mock import MagicMock + +@pytest.fixture +def setup_cluster_data(): + # Create synthetic cluster centroids + cluster_centroids = { + 0: pd.DataFrame(np.random.rand(1, 5), columns=[f'feature_{i}' for i in range(5)]), + 1: pd.DataFrame(np.random.rand(1, 5), columns=[f'feature_{i}' for i in range(5)]) + } + numerical_features_names = [f'feature_{i}' for i in range(3)] + categorical_features_names = [f'feature_{i}' for i in range(3, 5)] + return cluster_centroids, numerical_features_names, categorical_features_names + + +@pytest.fixture +def setup_mock_cf_generator(): + # Mock the LocalCounterfactualMethod and its explain_instances method + cf_generator = MagicMock(spec=LocalCounterfactualMethod) + + # Simulate counterfactual generation returning valid data + def mock_explain_instances(instances, num_counterfactuals): + return pd.DataFrame( + np.random.rand(num_counterfactuals, instances.shape[1]), + columns=instances.columns + ) + + cf_generator.explain_instances.side_effect = mock_explain_instances + return cf_generator + + +def test_generate_cluster_centroid_explanations_basic(setup_cluster_data, setup_mock_cf_generator): + cluster_centroids, numerical_features_names, categorical_features_names = setup_cluster_data + cf_generator = setup_mock_cf_generator + + num_local_counterfactuals = 3 + + cluster_explanations, cluster_expl_actions, explanations_centroid = generate_cluster_centroid_explanations( + cluster_centroids=cluster_centroids, + cf_generator=cf_generator, + num_local_counterfactuals=num_local_counterfactuals, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names + ) + + # Test the shape and type of returned cluster_explanations + assert isinstance(cluster_explanations, dict) + assert len(cluster_explanations) == len(cluster_centroids) + for cluster_id, cf in cluster_explanations.items(): + assert isinstance(cf, pd.DataFrame) + assert cf.shape[0] == num_local_counterfactuals + + # Test the cluster_expl_actions are returned and not empty + assert isinstance(cluster_expl_actions, dict) + assert len(cluster_expl_actions) == len(cluster_centroids) + + # Test the centroid calculations + assert isinstance(explanations_centroid, dict) + assert len(explanations_centroid) == len(cluster_centroids) + for cluster_id, centroid in explanations_centroid.items(): + # Fix: Expecting DataFrame instead of Series + assert isinstance(centroid, pd.DataFrame) # Update to DataFrame + assert centroid.shape[1] == len(numerical_features_names) + len(categorical_features_names) + + +def test_generate_cluster_centroid_explanations_empty_counterfactuals(setup_cluster_data): + cluster_centroids, numerical_features_names, categorical_features_names = setup_cluster_data + + # Mock the LocalCounterfactualMethod to return empty DataFrames for counterfactuals + cf_generator = MagicMock(spec=LocalCounterfactualMethod) + cf_generator.explain_instances.return_value = pd.DataFrame() + + num_local_counterfactuals = 3 + + with pytest.raises(ValueError, match="No counterfactuals found for any of the centroids."): + generate_cluster_centroid_explanations( + cluster_centroids=cluster_centroids, + cf_generator=cf_generator, + num_local_counterfactuals=num_local_counterfactuals, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names + ) + + +def test_generate_cluster_centroid_explanations_incorrect_num_counterfactuals(setup_cluster_data, setup_mock_cf_generator): + cluster_centroids, numerical_features_names, categorical_features_names = setup_cluster_data + cf_generator = setup_mock_cf_generator + + # Mock the explain_instances method to return an incorrect number of counterfactuals + def mock_explain_instances(instances, num_counterfactuals): + return pd.DataFrame(np.random.rand(2, instances.shape[1]), columns=instances.columns) # Only return 2 cfs + + cf_generator.explain_instances.side_effect = mock_explain_instances + + num_local_counterfactuals = 3 + + cluster_explanations, cluster_expl_actions, explanations_centroid = generate_cluster_centroid_explanations( + cluster_centroids=cluster_centroids, + cf_generator=cf_generator, + num_local_counterfactuals=num_local_counterfactuals, + numerical_features_names=numerical_features_names, + categorical_features_names=categorical_features_names + ) + + # Test the shape and type of returned cluster_explanations + assert isinstance(cluster_explanations, dict) + assert len(cluster_explanations) == len(cluster_centroids) + for cluster_id, cf in cluster_explanations.items(): + assert isinstance(cf, pd.DataFrame) + assert cf.shape[0] != num_local_counterfactuals \ No newline at end of file diff --git a/tests/glance/test_utils.py b/tests/glance/test_utils.py new file mode 100644 index 0000000..efac547 --- /dev/null +++ b/tests/glance/test_utils.py @@ -0,0 +1,420 @@ +import pytest +import numpy as np +import pandas as pd +from statistics import multimode +from aix360.algorithms.glance.clustering import KMeansMethod # Adjust the import based on your module structure +from sklearn.preprocessing import OneHotEncoder, StandardScaler +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from aix360.algorithms.glance.utils.centroid import centroid_pandas,centroid_numpy # Replace 'your_module' with the actual module name +from aix360.algorithms.glance.utils.action import apply_action_numpy,apply_action_pandas,actions_mean_pandas,apply_actions_pandas_rows,extract_actions_pandas +from aix360.algorithms.glance.utils.metadata_requests import _decide_cluster_method,_decide_local_cf_method +from aix360.algorithms.glance.local_cfs import DiceMethod, NearestNeighborMethod, RandomSampling +from xgboost import XGBClassifier + +def test_centroid_pandas(): + """ + Test the centroid_pandas function for numerical and categorical columns. + """ + data = { + "age": [25, 30, 22, 35, 40], + "salary": [50000, 60000, 55000, 45000, 70000], + "gender": ["Male", "Female", "Female", "Male", "Female"], + } + df = pd.DataFrame(data) + + numerical_columns = ["age", "salary"] + categorical_columns = ["gender"] + + centroid = centroid_pandas(df, numerical_columns, categorical_columns) + + # Expected centroid values + expected_centroid = pd.DataFrame({ + "age": [30.4], # Mean of [25, 30, 22, 35, 40] + "salary": [56000.0], # Mean of [50000, 60000, 55000, 45000, 70000] + "gender": ["Female"], # Mode of ['Male', 'Female', 'Female', 'Male', 'Female'] + }) + + pd.testing.assert_frame_equal(centroid, expected_centroid) + + +def test_centroid_pandas_no_categorical_columns(): + """ + Test centroid_pandas when there are no categorical columns. + """ + data = { + "age": [25, 30, 22, 35, 40], + "salary": [50000, 60000, 55000, 45000, 70000], + } + df = pd.DataFrame(data) + + numerical_columns = ["age", "salary"] + categorical_columns = [] + + centroid = centroid_pandas(df, numerical_columns, categorical_columns) + + # Expected centroid values (just the mean of the numerical columns) + expected_centroid = pd.DataFrame({ + "age": [30.4], # Mean of [25, 30, 22, 35, 40] + "salary": [56000.0], # Mean of [50000, 60000, 55000, 45000, 70000] + }) + + pd.testing.assert_frame_equal(centroid, expected_centroid) + +def test_centroid_numpy(): + """ + Test the centroid_numpy function for numerical and categorical columns. + """ + data = np.array([ + [25, 50000, 0], # 0 = Male + [30, 60000, 1], # 1 = Female + [22, 55000, 1], + [35, 45000, 0], + [40, 70000, 1] + ]) + + numerical_columns = [0, 1] # age and salary + categorical_columns = [2] # gender + + centroid = centroid_numpy(data, numerical_columns, categorical_columns) + + # Expected centroid values + expected_centroid = np.array([[30.4, 56000.0, 1]]) # Mode of gender is '1' (Female) + + np.testing.assert_array_equal(centroid, expected_centroid) + + +def test_centroid_numpy_no_categorical_columns(): + """ + Test centroid_numpy when there are no categorical columns. + """ + data = np.array([ + [25, 50000], + [30, 60000], + [22, 55000], + [35, 45000], + [40, 70000] + ]) + + numerical_columns = [0, 1] # age and salary + categorical_columns = [] # No categorical columns + + centroid = centroid_numpy(data, numerical_columns, categorical_columns) + + # Expected centroid values (just the mean of the numerical columns) + expected_centroid = np.array([[30.4, 56000.0]]) + + np.testing.assert_array_equal(centroid, expected_centroid) + + +def test_apply_action_pandas(): + """ + Test the apply_action_pandas function for applying actions to numerical and categorical columns. + """ + data = pd.DataFrame({ + "age": [25, 30, 22, 35], + "salary": [50000, 60000, 55000, 45000], + "gender": ["Male", "Female", "Female", "Male"] + }) + + action = pd.Series({"age": 5, "salary": 1000, "gender": "Female"}) + + numerical_columns = ["age", "salary"] + categorical_columns = ["gender"] + categorical_no_action_token = "NoChange" + + result = apply_action_pandas(data, action, numerical_columns, categorical_columns, categorical_no_action_token) + + expected_result = pd.DataFrame({ + "age": [30, 35, 27, 40], # Age incremented by 5 + "salary": [51000, 61000, 56000, 46000], # Salary incremented by 1000 + "gender": ["Female", "Female", "Female", "Female"] # Gender set to 'Female' + }) + + pd.testing.assert_frame_equal(result, expected_result) + +def test_apply_action_pandas_no_change_token(): + """ + Test apply_action_pandas with a categorical no-action token. + """ + data = pd.DataFrame({ + "age": [25, 30, 22, 35], + "salary": [50000, 60000, 55000, 45000], + "gender": ["Male", "Female", "Female", "Male"] + }) + + action = pd.Series({"age": 5, "salary": 1000, "gender": "NoChange"}) + + numerical_columns = ["age", "salary"] + categorical_columns = ["gender"] + categorical_no_action_token = "NoChange" + + result = apply_action_pandas(data, action, numerical_columns, categorical_columns, categorical_no_action_token) + + expected_result = pd.DataFrame({ + "age": [30, 35, 27, 40], # Age incremented by 5 + "salary": [51000, 61000, 56000, 46000], # Salary incremented by 1000 + "gender": ["Male", "Female", "Female", "Male"] # No change for gender + }) + + pd.testing.assert_frame_equal(result, expected_result) + +def test_apply_action_numpy(): + """ + Test the apply_action_numpy function for applying actions to numerical and categorical columns. + """ + data = np.array([ + [25, 50000, 0], # 0 = Male + [30, 60000, 1], # 1 = Female + [22, 55000, 1], + [35, 45000, 0] + ]) + + action = np.array([5, 1000, 1]) # Increase age by 5, salary by 1000, gender to 'Female' (1) + + numerical_columns = [0, 1] + categorical_columns = [2] + categorical_no_action_token = 0 # '0' means no change for gender + + result = apply_action_numpy(data, action, numerical_columns, categorical_columns, categorical_no_action_token) + + expected_result = np.array([ + [30, 51000, 1], + [35, 61000, 1], + [27, 56000, 1], + [40, 46000, 1] + ]) + + np.testing.assert_array_equal(result, expected_result) + +def test_extract_actions_pandas(): + """ + Test the extract_actions_pandas function for extracting actions from differences between two dataframes. + """ + X = pd.DataFrame({ + "age": [25, 30, 22], + "salary": [50000, 60000, 55000], + "gender": ["Male", "Female", "Female"] + }) + + cfs = pd.DataFrame({ + "age": [30, 30, 25], + "salary": [51000, 61000, 55000], + "gender": ["Female", "Female", "Male"] + }) + + numerical_features = ["age", "salary"] + categorical_features = ["gender"] + categorical_no_action_token = "NoChange" + + result = extract_actions_pandas(X, cfs, categorical_features, numerical_features, categorical_no_action_token) + + expected_result = pd.DataFrame({ + "age": [5, 0, 3], # Difference in ages + "salary": [1000, 1000, 0], # Difference in salary + "gender": ["Female", "NoChange", "Male"] # Gender action, 'NoChange' for unchanged + }) + + pd.testing.assert_frame_equal(result, expected_result) + +def test_apply_actions_pandas_rows(): + """ + Test the apply_actions_pandas_rows function for applying row-wise actions to numerical and categorical columns. + """ + X = pd.DataFrame({ + "age": [25, 30, 22], + "salary": [50000, 60000, 55000], + "gender": ["Male", "Female", "Female"] + }) + + actions = pd.DataFrame({ + "age": [5, 0, 3], + "salary": [1000, 1000, 0], + "gender": ["Female", "NoChange", "Male"] + }) + + numerical_columns = ["age", "salary"] + categorical_columns = ["gender"] + categorical_no_action_token = "NoChange" + + result = apply_actions_pandas_rows(X, actions, numerical_columns, categorical_columns, categorical_no_action_token) + + expected_result = pd.DataFrame({ + "age": [30, 30, 25], # Age updated by actions + "salary": [51000, 61000, 55000], # Salary updated by actions + "gender": ["Female", "Female", "Male"] # Gender updated where applicable + }) + + pd.testing.assert_frame_equal(result, expected_result) + +def test_actions_mean_pandas(): + """ + Test the actions_mean_pandas function for calculating the mean action for numerical and categorical columns. + """ + actions = pd.DataFrame({ + "age": [5, 0, 3], + "salary": [1000, 1000, 0], + "gender": ["Female", "NoChange", "Male"] + }) + + numerical_features = ["age", "salary"] + categorical_features = ["gender"] + categorical_no_action_token = "NoChange" + + result = actions_mean_pandas(actions, numerical_features, categorical_features, categorical_no_action_token) + + expected_result = pd.Series({ + "age": 8 / 3, # Mean of [5, 0, 3] + "salary": 2000 / 3, # Mean of [1000, 1000, 0] + "gender": "Female" # Most frequent value + }) + + pd.testing.assert_series_equal(result, expected_result) + +def test_decide_cluster_method_kmeans(): + """Test that 'KMeans' method returns KMeansMethod instance.""" + n_clusters = 3 + random_seed = 42 + result = _decide_cluster_method("KMeans", n_clusters, random_seed) + assert isinstance(result, KMeansMethod) + assert result.num_clusters == n_clusters + assert result.random_seed == random_seed + +def test_decide_cluster_method_invalid(): + """Test that invalid method raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported clustering method: unsupported_method"): + _decide_cluster_method("unsupported_method", n_clusters=3, random_seed=42) + +def test_decide_cluster_method_instance(): + """Test that passing an instance returns the same instance.""" + kmeans_instance = KMeansMethod(num_clusters=3, random_seed=42) + result = _decide_cluster_method(kmeans_instance, n_clusters=None, random_seed=None) + assert result is kmeans_instance + +def test_decide_local_cf_method_dice(): + """Test that 'Dice' method returns an instance of DiceMethod.""" + model = XGBClassifier() # Create or mock a model as required + train_dataset = X = pd.DataFrame({ + "age": [25, 30, 22], + "salary": [50000, 60000, 55000], + "gender": ["Male", "Female", "Female"], + 'target': [1,0,1] + }) # Create or mock a DataFrame as required + numeric_features_names = ['age', 'salary'] + categorical_features_names = ['gender'] + feat_to_vary = ['age', 'salary'] + random_seed = 42 + + result = _decide_local_cf_method("Dice", model, train_dataset, numeric_features_names, categorical_features_names, feat_to_vary, random_seed) + assert isinstance(result, DiceMethod) + +def test_decide_local_cf_method_nearest_neighbors(): + """Test that 'NearestNeighbors' method returns an instance of NearestNeighborMethod.""" + model = XGBClassifier() # Create or mock a model as required + train_dataset = X = pd.DataFrame({ + "age": [25, 30, 22,45,60,20], + "salary": [50000, 60000, 55000, 53000,75000,30000], + "gender": ["Male", "Female", "Female","Male", "Female",'Female'], + 'target': [1,0,1,0,1,1] + }) + numeric_features_names = ['age', 'salary'] + categorical_features_names = ['gender'] + preprocessor = ColumnTransformer( + transformers=[ + ( + "cat", + OneHotEncoder(sparse_output=False, handle_unknown="ignore"), + categorical_features_names, + ) + ], + remainder="passthrough", + ) + model_ = Pipeline( + [ + ("preprocessor", preprocessor), + ("normalize", StandardScaler()), + ("classifier", model), + ] + ) + model_.fit( + train_dataset.drop(columns='target'), + train_dataset['target'], + ) + feat_to_vary = ['age', 'salary'] + random_seed = 42 + + result = _decide_local_cf_method("NearestNeighbors", model_, train_dataset, numeric_features_names, categorical_features_names, feat_to_vary, random_seed) + assert isinstance(result, NearestNeighborMethod) + +def test_decide_local_cf_method_random_sampling(): + """Test that 'RandomSampling' method returns an instance of RandomSampling.""" + model = XGBClassifier() # Create or mock a model as required + train_dataset = X = pd.DataFrame({ + "age": [25, 30, 22,45,60,20], + "salary": [50000, 60000, 55000, 53000,75000,30000], + "gender": ["Male", "Female", "Female","Male", "Female",'Female'], + 'target': [1,0,1,0,1,1] + }) + numeric_features_names = ['age', 'salary'] + categorical_features_names = ['gender'] + preprocessor = ColumnTransformer( + transformers=[ + ( + "cat", + OneHotEncoder(sparse_output=False, handle_unknown="ignore"), + categorical_features_names, + ) + ], + remainder="passthrough", + ) + model_ = Pipeline( + [ + ("preprocessor", preprocessor), + ("normalize", StandardScaler()), + ("classifier", model), + ] + ) + model_.fit( + train_dataset.drop(columns='target'), + train_dataset['target'], + ) + feat_to_vary = ['age', 'salary'] + random_seed = 42 + + result = _decide_local_cf_method("RandomSampling", model_, train_dataset, numeric_features_names, categorical_features_names, feat_to_vary, random_seed) + assert isinstance(result, RandomSampling) + +def test_decide_local_cf_method_invalid(): + """Test that invalid method raises ValueError.""" + model = XGBClassifier() # Create or mock a model as required + train_dataset = X = pd.DataFrame({ + "age": [25, 30, 22], + "salary": [50000, 60000, 55000], + "gender": ["Male", "Female", "Female"], + 'target': [1,0,1] + }) # Create or mock a DataFrame as required + numeric_features_names = ['age', 'salary'] + categorical_features_names = ['gender'] + feat_to_vary = ['age', 'salary'] + random_seed = 42 + + with pytest.raises(ValueError, match="Unsupported local counterfactual method: unsupported_method"): + _decide_local_cf_method("unsupported_method", model, train_dataset, numeric_features_names, categorical_features_names, feat_to_vary, random_seed) + +def test_decide_local_cf_method_instance(): + """Test that passing an instance returns the same instance.""" + dice_instance = DiceMethod() + model = XGBClassifier() # Create or mock a model as required + train_dataset = X = pd.DataFrame({ + "age": [25, 30, 22], + "salary": [50000, 60000, 55000], + "gender": ["Male", "Female", "Female"], + 'target': [1,0,1] + }) # Create or mock a DataFrame as required + numeric_features_names = ['age', 'salary'] + categorical_features_names = ['gender'] + feat_to_vary = ['age', 'salary'] + random_seed = 42 + + result = _decide_local_cf_method(dice_instance, model, train_dataset, numeric_features_names, categorical_features_names, feat_to_vary, random_seed) + assert result is dice_instance \ No newline at end of file