Source code for RuleTree.stumps.splitters.FairSplitter

"""FairSplitter: the best regression split under a fairness constraint, and the functions that evaluate it."""

from concurrent.futures.process import ProcessPoolExecutor
from typing import Union

import numpy as np
import psutil

from RuleTree.base.RuleTreeBaseSplitter import RuleTreeBaseSplitter
from RuleTree.exceptions import NoSplitFoundWarning
from RuleTree.stumps.splitters.RegressionSplitter import RegressionSplitter
from RuleTree.utils.data_utils import _get_info_gain
from RuleTree.utils.define import MODEL_TYPE_REG
from RuleTree.utils.fairness_metrics import balance_metric, max_fairness_cost, privacy_metric, privacy_metric_all


[docs] class FairSplitter(RuleTreeBaseSplitter): """ Splitter for regression that maximizes the gain penalized by the unfairness of the split with respect to a sensitive attribute. Each threshold of each numerical feature (the sensitive attribute excluded) is tested; its gain is ``sigmoid(gain) * (1 - penalty * penalization_weight)``, where the penalty measures how unfair the children are. The features are evaluated in parallel processes (``n_jobs``). Parameters ---------- penalty : {'balance', 'mfc', 'privacy'}, default=None Fairness criterion, which penalizes the gain of the splits: - 'balance': the children should have the same distribution of the sensitive attribute (statistical parity); - 'mfc': maximum fairness cost with respect to ``ideal_distribution``; - 'privacy': the children must satisfy k-anonymity, l-diversity and t-closeness on the sensitive attribute (with ``strict=True``, the splits that violate them are discarded). sensible_attribute : int, default=-1 Column of the data with the sensitive attribute. It is never used by the splits. penalization_weight : float, default=0.3 Weight of the penalty: the gain of a split is ``sigmoid(gain) * (1 - penalty * weight)``. ideal_distribution : dict, default=None Value of the sensitive attribute -> ideal fraction, required by penalty='mfc'. k_anonymity : int or float, default=2 Minimum number of instances in each child (penalty='privacy'). l_diversity : int or float, default=2 Minimum number of distinct values of the sensitive attribute in each child (penalty='privacy'). t_closeness : float, default=0.2 Maximum distance between the distribution of the sensitive attribute in a child and in the node (penalty='privacy'). strict : bool, default=True Whether the privacy constraints discard the splits that violate them, instead of only penalizing them. use_t : bool, default=True Whether t-closeness is part of the privacy constraints. n_jobs : int, default=number of physical CPUs Number of processes used to evaluate the features. criterion : {'squared_error', 'absolute_error', 'poisson'} or callable, default='squared_error' Impurity measure (see RegressionSplitter). Attributes ---------- feature_ : int Column of the feature matrix used by the split. threshold_ : float Threshold of the split. is_categorical_ : bool Whether the split is categorical. gain_ : float Penalized gain of the split. impurity_ : list of float or None Impurity of the node, of the left child and of the right child. See Also -------- FairTreeStumpRegressor : the stump made of a MatrixTransformer and this splitter. Examples -------- >>> import numpy as np >>> from sklearn.datasets import load_diabetes >>> from RuleTree import RuleTreeRegressor >>> from RuleTree.stumps.Stump import Stump >>> from RuleTree.stumps.splitters import FairSplitter >>> from RuleTree.stumps.transformers import MatrixTransformer >>> X, y = load_diabetes(return_X_y=True) >>> X = np.column_stack([X, X[:, 1] > 0]) # last column: the sensitive attribute >>> splitter = FairSplitter(penalty="balance", sensible_attribute=-1, n_jobs=1) >>> model = RuleTreeRegressor(max_depth=2, base_stumps=Stump(MatrixTransformer(), splitter)).fit(X, y) """ task = MODEL_TYPE_REG def __init__(self, penalty: str = None, sensible_attribute: int = -1, penalization_weight: float = 0.3, ideal_distribution: dict = None, k_anonymity: Union[int, float] = 2, l_diversity: Union[int, float] = 2, t_closeness: float = .2, strict: bool = True, use_t: bool = True, n_jobs: int = psutil.cpu_count(logical=False), criterion='squared_error'): """Initialize the FairSplitter; see the class documentation for the parameters.""" self.penalty = penalty self.sensible_attribute = sensible_attribute self.penalization_weight = penalization_weight self.ideal_distribution = ideal_distribution self.k_anonymity = k_anonymity self.l_diversity = l_diversity self.t_closeness = t_closeness self.strict = strict self.use_t = use_t self.n_jobs = n_jobs self.criterion = criterion
[docs] def fit(self, X, y, numerical, categorical, sample_weight=None, check_input=True): """ Find the split with the highest penalized gain among the thresholds of the numerical features (the sensitive attribute excluded) that satisfy the fairness constraint. Parameters ---------- X : numpy.ndarray of shape (n_samples, n_features) Feature matrix of the instances in the node. y : numpy.ndarray of shape (n_samples,) Target of the instances in the node. numerical : list of int Indices of the numerical columns of X. categorical : list of int Indices of the categorical columns of X. sample_weight : numpy.ndarray of shape (n_samples,), default=None Sample weights of the instances in the node. check_input : bool, default=True Whether to check the input data. Returns ------- self : FairSplitter The fitted splitter. Raises ------ NoSplitFoundWarning If no split is found. """ best_info_gain = -float('inf') self.feature_, self.threshold_, self.is_categorical_, self.impurity_ = None, None, False, None processes = [] sensible_attribute = self.sensible_attribute % X.shape[1] # also a negative index, e.g. -1 with ProcessPoolExecutor(max_workers=self.n_jobs) as executor: for i in range(X.shape[1]): if i == sensible_attribute: continue sorted_arr = np.hstack([X, y.reshape(-1, 1)]) sorted_arr = sorted_arr[:, [i, -1]][np.lexsort((X[:, -1], X[:, i]))] unique_sorted_array = np.unique(sorted_arr, axis=0) if i not in categorical: values = [ (np.mean(unique_sorted_array[j - 1:j + 1, 0])) for j in range(1, len(unique_sorted_array)) if unique_sorted_array[j - 1, 1] != unique_sorted_array[j, 1] and unique_sorted_array[j - 1, 0] != unique_sorted_array[j, 0] ] n_values_per_core = max(10, len(values) // self.n_jobs) for start_idx in range(0, len(values), n_values_per_core): args = (X, y, i, values[start_idx:start_idx + n_values_per_core], categorical, self.criterion, self.penalty, self.sensible_attribute, self.penalization_weight, self.ideal_distribution, self.k_anonymity, self.l_diversity, self.t_closeness, self.strict, self.use_t) if self.n_jobs == 1: processes.append(_inner_loop_best_split(*args)) else: processes.append(executor.submit(_inner_loop_best_split, *args)) if self.n_jobs != 1: processes = [x.result() for x in processes] for info_gain, threshold, col_idx in processes: if info_gain > best_info_gain: best_info_gain = info_gain self.feature_ = col_idx self.threshold_ = threshold self.is_categorical_ = col_idx in categorical if self.feature_ is None: raise NoSplitFoundWarning("No fair split found") self.gain_ = best_info_gain return self
[docs] def update_statistics(self, X, y, sample_weight=None): """ Recompute the impurity of the node and of the two children. Parameters ---------- X : numpy.ndarray of shape (n_samples, n_features) Feature matrix of the instances in the node. y : numpy.ndarray of shape (n_samples,) Target of the instances in the node. sample_weight : numpy.ndarray of shape (n_samples,), default=None Sample weights of the instances in the node. """ self.impurity_ = RegressionSplitter._split_impurity(self.criterion, y, self.apply(X) == 1)
def _check_balance(labels: np.ndarray, prot_attr: np.ndarray): """ Check if a split satisfies balance (statistical parity) fairness constraint. Parameters ---------- labels : np.ndarray Binary array indicating sample assignments (True for left node, False for right) prot_attr : np.ndarray Protected attribute values for each sample Returns ------- tuple (is_fair, fairness_score) where is_fair is always True and fairness_score is the complement of the balance metric (higher is better) """ return True, 1 - balance_metric(labels, prot_attr) def _check_max_fairness_cost(labels: np.ndarray, prot_attr: np.ndarray, ideal_dist: dict): """ Check if a split satisfies the maximum fairness cost constraint. Parameters ---------- labels : np.ndarray Binary array indicating sample assignments (True for left node, False for right) prot_attr : np.ndarray Protected attribute values for each sample ideal_dist : dict Dictionary specifying the ideal distribution for different groups Returns ------- tuple (is_fair, fairness_score) where is_fair is always True and fairness_score is the maximum fairness cost (lower is better) """ return True, max_fairness_cost(labels, prot_attr, ideal_dist) def _check_privacy(X, X_bool, sensible_attribute, k_anonymity, l_diversity, t_closeness, strict, categorical, use_t): """ Check if a split satisfies privacy constraints (k-anonymity, l-diversity, t-closeness). Parameters ---------- X : np.ndarray Input feature matrix X_bool : np.ndarray Binary array indicating sample assignments (True for left node, False for right) sensible_attribute : int Index of the sensitive attribute column in X k_anonymity : int or float Minimum group size required for k-anonymity l_diversity : int or float Minimum distinct values required for l-diversity t_closeness : float Maximum earth mover's distance allowed for t-closeness strict : bool If True, strictly enforce privacy constraints categorical : set Set of indices of categorical features use_t : bool Whether to use t-closeness in constraint calculations Returns ------- tuple (is_fair, fairness_score) where is_fair indicates if privacy constraints are satisfied and fairness_score is the normalized maximum privacy metric """ can_split, k, l, t = privacy_metric(X, X_bool, sensible_attribute, k_anonymity, l_diversity, t_closeness, strict, categorical, use_t) if not use_t: t = t_closeness if not can_split: return False, np.nan return can_split, np.max(privacy_metric_all(k, k_anonymity, l, l_diversity, t, t_closeness)) # /3 def _inner_loop_best_split(X: np.ndarray, y: np.ndarray, col_idx: int, thresholds: list, categorical: set, impurity_fun, penalty: str, sensible_attribute: int, penalization_weight: float, ideal_distribution: dict, k_anonymity, l_diversity, t_closeness, strict, use_t): """ Find the best split for a feature column considering fairness constraints. Parameters ---------- X : np.ndarray Input feature matrix y : np.ndarray Target values col_idx : int Index of the feature column to split on thresholds : list List of candidate threshold values to evaluate categorical : set Set of indices of categorical features impurity_fun : callable Function to calculate impurity (e.g., mse, mae) penalty : str Type of fairness constraint to apply ("balance", "mfc", or "privacy") sensible_attribute : int Index of the sensitive attribute column penalization_weight : float Weight for the fairness penalty term ideal_distribution : dict Ideal distribution when using "mfc" penalty k_anonymity, l_diversity, t_closeness : Various types Privacy constraint parameters when using "privacy" penalty strict : bool Whether to strictly enforce privacy constraints use_t : bool Whether to use t-closeness in privacy constraints Returns ------- tuple (best_info_gain, best_threshold, col_idx) representing the best split found """ len_x = len(X) best_info_gain = -np.inf best_threshold = -1 for value in thresholds: if col_idx in categorical: X_split = X[:, col_idx:col_idx + 1] == value else: X_split = X[:, col_idx:col_idx + 1] <= value if np.sum(X_split) * np.sum(~X_split) == 0: continue if penalty == "balance": ok_to_split, penalty_value = _check_balance(X_split[:, 0], X[:, sensible_attribute]) elif penalty == "mfc": ok_to_split, penalty_value = _check_max_fairness_cost(X_split[:, 0], X[:, sensible_attribute], ideal_distribution) else: ok_to_split, penalty_value = _check_privacy(X, X_split, sensible_attribute, k_anonymity, l_diversity, t_closeness, strict, categorical, use_t) if not ok_to_split: continue len_left = np.sum(X_split) curr_pred = np.ones((len(y),)) * np.mean(y) l_pred = np.ones((len(y[X_split[:, 0]]),)) * np.mean(y[X_split[:, 0]]) r_pred = np.ones((len(y[~X_split[:, 0]]),)) * np.mean(y[~X_split[:, 0]]) info_gain = _get_info_gain(RegressionSplitter._impurity_fun(impurity_fun, y_true=y, y_pred=curr_pred), RegressionSplitter._impurity_fun(impurity_fun, y_true=y[X_split[:, 0]], y_pred=l_pred), RegressionSplitter._impurity_fun(impurity_fun, y_true=y[~X_split[:, 0]], y_pred=r_pred), len_x, len_left, len_x - len_left) info_gain = 1 / (1 + np.exp(-info_gain)) info_gain -= info_gain * (penalty_value * penalization_weight) if info_gain > best_info_gain: best_info_gain = info_gain best_threshold = value return best_info_gain, best_threshold, col_idx