diff --git a/aif360/detectors/__init__.py b/aif360/detectors/__init__.py new file mode 100644 index 00000000..3c823bc2 --- /dev/null +++ b/aif360/detectors/__init__.py @@ -0,0 +1,2 @@ +from aif360.detectors.mdss.MDSS import MDSS +from aif360.detectors.mdss_detector import bias_scan \ No newline at end of file diff --git a/aif360/metrics/mdss/MDSS.py b/aif360/detectors/mdss/MDSS.py similarity index 70% rename from aif360/metrics/mdss/MDSS.py rename to aif360/detectors/mdss/MDSS.py index 1c4dd64a..9d73f18c 100644 --- a/aif360/metrics/mdss/MDSS.py +++ b/aif360/detectors/mdss/MDSS.py @@ -1,5 +1,14 @@ -from aif360.metrics.mdss.ScoringFunctions.ScoringFunction import ScoringFunction -from aif360.metrics.mdss.generator import get_entire_subset, get_random_subset +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.generator import get_entire_subset, get_random_subset + + +from aif360.detectors.mdss.ScoringFunctions import ( + Bernoulli, + BerkJones, + Gaussian, + ScoringFunction, + Poisson, +) import pandas as pd import numpy as np @@ -10,33 +19,33 @@ class MDSS(object): def __init__(self, scoring_function: ScoringFunction): self.scoring_function = scoring_function - def get_aggregates(self, coordinates: pd.DataFrame, outcomes: pd.Series, probs: pd.Series, + def get_aggregates(self, coordinates: pd.DataFrame, outcomes: pd.Series, expectations: pd.Series, current_subset: dict, column_name: str, penalty: float): """ Conditioned on the current subsets of values for all other attributes, - compute the summed outcome (observed_sum = \sum_i y_i) and all probabilities p_i + compute the summed outcome (observed_sum = \sum_i y_i) and all expectations p_i for each value of the current attribute. Also use additive linear-time subset scanning to compute the set of distinct thresholds for which different subsets of attribute values have positive scores. Note that the number of such thresholds will be linear rather than exponential in the arity of the attribute. :param coordinates: data frame containing having as columns the covariates/features - :param probs: data series containing the probabilities/expected outcomes + :param expectations: data series containing the expectations/expected outcomes :param outcomes: data series containing the outcomes/observed outcomes :param current_subset: current subset to compute aggregates :param column_name: attribute name to scan over :param penalty: penalty coefficient :return: dictionary of aggregates, sorted thresholds (roots), observed sum of the subset, array of observed - probabilities + expectations """ # compute the subset of records matching the current subgroup along all other dimensions - # temp_df includes the covariates x_i, outcome y_i, and predicted probability p_i for each matching record + # temp_df includes the covariates x_i, outcome y_i, and predicted expectation p_i for each matching record if current_subset: to_choose = coordinates[current_subset.keys()].isin(current_subset).all(axis=1) - temp_df = pd.concat([coordinates.loc[to_choose], outcomes[to_choose], probs[to_choose]], axis=1) + temp_df = pd.concat([coordinates.loc[to_choose], outcomes[to_choose], expectations[to_choose]], axis=1) else: - temp_df = pd.concat([coordinates, outcomes, probs], axis=1) + temp_df = pd.concat([coordinates, outcomes, expectations], axis=1) # these wil be used to keep track of the aggregate values and the distinct thresholds to be considered aggregates = {} @@ -49,11 +58,11 @@ def get_aggregates(self, coordinates: pd.DataFrame, outcomes: pd.Series, probs: # compute the sum of outcomes \sum_i y_i observed_sum = group.iloc[:, -2].sum() - # all probabilities p_i - probs = group.iloc[:, -1].values + # all expectations p_i + expectations = group.iloc[:, -1].values # compute q_min and q_max for the attribute value - exist, q_mle, q_min, q_max = scoring_function.compute_qs(observed_sum, probs, penalty) + exist, q_mle, q_min, q_max = scoring_function.compute_qs(observed_sum, expectations, penalty) # Add to aggregates, and add q_min and q_max to thresholds. # Note that thresholds is a set so duplicates will be removed automatically. @@ -63,21 +72,21 @@ def get_aggregates(self, coordinates: pd.DataFrame, outcomes: pd.Series, probs: 'q_min': q_min, 'q_max': q_max, 'observed_sum': observed_sum, - 'probs': probs + 'expectations': expectations } thresholds.update([q_min, q_max]) - # We also keep track of the summed outcomes \sum_i y_i and the probabilities p_i for the case where _ + # We also keep track of the summed outcomes \sum_i y_i and the expectations p_i for the case where _ # all_ values of that attribute are considered (regardless of whether they contribute positively to score). # This is necessary because of the way we compute the penalty term: including all attribute values, equivalent # to ignoring the attribute, has the lowest penalty (of 0) and thus we need to score that subset as well. all_observed_sum = temp_df.iloc[:, -2].sum() - all_probs = temp_df.iloc[:, -1].values + all_expectations = temp_df.iloc[:, -1].values - return [aggregates, sorted(thresholds), all_observed_sum, all_probs] + return [aggregates, sorted(thresholds), all_observed_sum, all_expectations] def choose_aggregates(self, aggregates: dict, thresholds: list, penalty: float, all_observed_sum: float, - all_probs: list): + all_expectations: list): """ Having previously computed the aggregates and the distinct q thresholds to consider in the get_aggregates function,we are now ready to choose the best @@ -87,11 +96,11 @@ def choose_aggregates(self, aggregates: dict, thresholds: list, penalty: float, We then pick the best q and score over all of the ranges considered. :param aggregates: dictionary of aggregates. For each feature value, it has q_mle, q_min, q_max, observed_sum, - and the probabilities + and the expectations :param thresholds: sorted thresholds (roots) :param penalty: penalty coefficient :param all_observed_sum: sum of observed binary outcomes for all i - :param all_probs: data series containing all the probabilities/expected outcomes + :param all_expectations: data series containing all the expectations/expected outcomes :return: """ # initialize @@ -104,28 +113,28 @@ def choose_aggregates(self, aggregates: dict, thresholds: list, penalty: float, for i in range(len(thresholds) - 1): threshold = (thresholds[i] + thresholds[i + 1]) / 2 observed_sum = 0.0 - probs = [] + expectations = [] names = [] # keep only the aggregates which have a positive contribution to the score in that q range - # we must keep track of the sum of outcome values as well as all predicted probabilities + # we must keep track of the sum of outcome values as well as all predicted expectations for key, value in aggregates.items(): if (value['q_min'] < threshold) & (value['q_max'] > threshold): names.append(key) observed_sum += value['observed_sum'] - probs = probs + value['probs'].tolist() + expectations = expectations + value['expectations'].tolist() - if len(probs) == 0: + if len(expectations) == 0: continue # compute the MLE value of q, making sure to only consider the desired direction (positive or negative) - probs = np.asarray(probs) - current_q_mle = scoring_function.qmle(observed_sum, probs) + expectations = np.asarray(expectations) + current_q_mle = scoring_function.qmle(observed_sum, expectations) # Compute the score for the given subset at the MLE value of q. # Notice that each included value gets a penalty, so the total penalty # is multiplied by the number of included values. - current_interval_score = scoring_function.score(observed_sum, probs, penalty * len(names), current_q_mle) + current_interval_score = scoring_function.score(observed_sum, expectations, penalty * len(names), current_q_mle) # keep track of the best score, best q, and best subset of attribute values found so far if current_interval_score > best_score: @@ -138,12 +147,12 @@ def choose_aggregates(self, aggregates: dict, thresholds: list, penalty: float, # from all other attributes, just considering the current attribute.) # compute the MLE value of q, making sure to only consider the desired direction (positive or negative) - current_q_mle = scoring_function.qmle(all_observed_sum, all_probs) + current_q_mle = scoring_function.qmle(all_observed_sum, all_expectations) # Compute the score for the given subset at the MLE value of q. # Again, the penalty (for that attribute) is 0 when all attribute values are included. - current_score = scoring_function.score(all_observed_sum, all_probs, 0, current_q_mle) + current_score = scoring_function.score(all_observed_sum, all_expectations, 0, current_q_mle) # Keep track of the best score, best q, and best subset of attribute values found. # Note that if the best subset contains all values of the given attribute, @@ -154,14 +163,14 @@ def choose_aggregates(self, aggregates: dict, thresholds: list, penalty: float, return [best_names, best_score] - def score_current_subset(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, + def score_current_subset(self, coordinates: pd.DataFrame, expectations: pd.Series, outcomes: pd.Series, current_subset: dict, penalty: float): """ Just scores the subset without performing ALTSS. We still need to determine the MLE value of q. :param coordinates: data frame containing having as columns the covariates/features - :param probs: data series containing the probabilities/expected outcomes + :param expectations: data series containing the expectations/expected outcomes :param outcomes: data series containing the outcomes/observed outcomes :param current_subset: current subset to be scored :param penalty: penalty coefficient @@ -169,21 +178,21 @@ def score_current_subset(self, coordinates: pd.DataFrame, probs: pd.Series, outc """ # compute the subset of records matching the current subgroup along all dimensions - # temp_df includes the covariates x_i, outcome y_i, and predicted probability p_i for each matching record + # temp_df includes the covariates x_i, outcome y_i, and predicted expectation p_i for each matching record if current_subset: to_choose = coordinates[current_subset.keys()].isin(current_subset).all(axis=1) - temp_df = pd.concat([coordinates.loc[to_choose], outcomes[to_choose], probs[to_choose]], axis=1) + temp_df = pd.concat([coordinates.loc[to_choose], outcomes[to_choose], expectations[to_choose]], axis=1) else: - temp_df = pd.concat([coordinates, outcomes, probs], axis=1) + temp_df = pd.concat([coordinates, outcomes, expectations], axis=1) scoring_function = self.scoring_function - # we must keep track of the sum of outcome values as well as all predicted probabilities + # we must keep track of the sum of outcome values as well as all predicted expectations observed_sum = temp_df.iloc[:, -2].sum() - probs = temp_df.iloc[:, -1].values + expectations = temp_df.iloc[:, -1].values # compute the MLE value of q, making sure to only consider the desired direction (positive or negative) - current_q_mle = scoring_function.qmle(observed_sum, probs) + current_q_mle = scoring_function.qmle(observed_sum, expectations) # total_penalty = penalty * sum of list lengths in current_subset total_penalty = 0 @@ -193,23 +202,69 @@ def score_current_subset(self, coordinates: pd.DataFrame, probs: pd.Series, outc total_penalty *= penalty # Compute and return the penalized score - penalized_score = scoring_function.score(observed_sum, probs, total_penalty, current_q_mle) - return penalized_score + penalized_score = scoring_function.score(observed_sum, expectations, total_penalty, current_q_mle) + return np.round(penalized_score, 4) - def scan(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, penalty: float, - num_iters: int, verbose: bool = False, seed: int = 0): + def scan(self, coordinates: pd.DataFrame, expectations: pd.Series, outcomes: pd.Series, penalty: float, + num_iters: int, verbose: bool = False, seed: int = 0, mode: str = 'binary'): """ :param coordinates: data frame containing having as columns the covariates/features - :param probs: data series containing the probabilities/expected outcomes + :param expectations: data series containing the expectations/expected outcomes :param outcomes: data series containing the outcomes/observed outcomes :param penalty: penalty coefficient :param num_iters: number of iteration :param verbose: logging flag :param seed: numpy seed. Default equals 0 + :param mode: one of ['binary', 'continuous', 'nominal', 'ordinal']. Defaults to binary. :return: [best subset, best score] """ np.random.seed(seed) + # Check that the appropriate scoring function is used + + if isinstance(self.scoring_function, BerkJones): + modes = ["binary", "continuous", "nominal", "ordinal"] + assert mode in modes, f"Expected one of {modes} for BerkJones, got {mode}." + + # Ensure that BerkJones only work in Autostrat mode + unique_expectations = expectations.unique() + if isinstance(self.scoring_function, BerkJones) and len(unique_expectations) != 1: + raise Exception( + "BerkJones scorer supports scanning in autostrat mode only." + ) + + # Bin the continuous outcomes column for Berk Jones in continuous mode + alpha = self.scoring_function.alpha + direction = self.scoring_function.direction + + if mode == "continuous": + quantile = outcomes.quantile(alpha) + outcomes = (outcomes > quantile).apply(int) + + # Flip outcomes to scan in the negative direction for BerkJones + # This is equivalent to switching the p-values + if direction == "negative": + outcomes = 1 - outcomes + + if isinstance(self.scoring_function, Bernoulli): + modes = ["binary", "nominal"] + assert mode in modes, f"Expected one of {modes} for Bernoulli, got {mode}." + + if isinstance(self.scoring_function, Gaussian): + assert mode == 'continuous', f"Expected continuous, got {mode}." + + # Set variance for Gaussian + self.scoring_function.var = expectations.var() + + # Move entire distribution to the positive axis + shift = np.abs(expectations.min()) + np.abs(outcomes.min()) + outcomes = outcomes + shift + expectations = expectations + shift + + if isinstance(self.scoring_function, Poisson): + modes = ["binary", "ordinal"] + assert mode in modes, f"Expected one of {modes} for Poisson, got {mode}." + # initialize best_subset = {} best_score = -1e10 @@ -229,7 +284,7 @@ def scan(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, # score the entire population current_score = self.score_current_subset( coordinates=coordinates, - probs=probs, + expectations=expectations, outcomes=outcomes, penalty=penalty, current_subset=current_subset @@ -248,10 +303,10 @@ def scan(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, del current_subset[attribute_to_scan] # call get_aggregates and choose_aggregates to find best subset of attribute values - aggregates, thresholds, all_observed_sum, all_probs = self.get_aggregates( + aggregates, thresholds, all_observed_sum, all_expectations = self.get_aggregates( coordinates=coordinates, outcomes=outcomes, - probs=probs, + expectations=expectations, current_subset=current_subset, column_name=attribute_to_scan, penalty=penalty @@ -262,7 +317,7 @@ def scan(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, thresholds=thresholds, penalty=penalty, all_observed_sum=all_observed_sum, - all_probs=all_probs + all_expectations=all_expectations ) temp_subset = current_subset.copy() @@ -276,7 +331,7 @@ def scan(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, # above includes only the penalty for the current attribute. temp_score = self.score_current_subset( coordinates=coordinates, - probs=probs, + expectations=expectations, outcomes=outcomes, penalty=penalty, current_subset=temp_subset @@ -286,10 +341,16 @@ def scan(self, coordinates: pd.DataFrame, probs: pd.Series, outcomes: pd.Series, if temp_score > current_score + 1E-6: flags.fill(0) - # TODO: confirm with Skyler: sanity check to make sure score has not decreased - assert temp_score >= current_score - 1E-6, \ - "WARNING SCORE HAS DECREASED from %.3f to %.3f" % (current_score, temp_score) - + # sanity check to make sure score has not decreased + # sanity check may not apply to Gaussian in penalized mode (TODO: to check Maths again) + if not isinstance(self.scoring_function, Gaussian) and penalty > 0: + assert ( + temp_score >= current_score - 1e-6 + ), "WARNING SCORE HAS DECREASED from %.6f to %.6f" % ( + current_score, + temp_score, + ) + flags[attribute_number_to_scan] = 1 current_subset = temp_subset current_score = temp_score diff --git a/aif360/detectors/mdss/ScoringFunctions/BerkJones.py b/aif360/detectors/mdss/ScoringFunctions/BerkJones.py new file mode 100644 index 00000000..e39cb6e9 --- /dev/null +++ b/aif360/detectors/mdss/ScoringFunctions/BerkJones.py @@ -0,0 +1,138 @@ +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.ScoringFunctions import optim + +import numpy as np + + +class BerkJones(ScoringFunction): + def __init__(self, **kwargs): + """ + Berk-Jones score function is a non parametric expectatation based + scan statistic that also satisfies the ALTSS property; Non-parametric scoring functions + do not make parametric assumptions about the model or outcome [1]. + + kwargs must contain + 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') + 'alpha (float)' - the alpha threshold that will be used to compute the score. + In practice, it may be useful to search over a grid of alpha thresholds and select the one with the maximum score. + + + [1] Neill, D. B., & Lingwall, J. (2007). A nonparametric scan statistic for multivariate disease surveillance. Advances in + Disease Surveillance, 4(106), 570 + """ + + super(BerkJones, self).__init__(**kwargs) + self.alpha = self.kwargs.get('alpha') + assert self.alpha is not None, "Warning: calling Berk Jones without alpha" + + if self.direction == 'negative': + self.alpha = 1 - self.alpha + + + def score(self, observed_sum: float, expectations: np.array, penalty: float, q: float): + """ + Computes berk jones score for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty term. Should be positive + :param q: current value of q + :return: berk jones score for the current value of q + """ + alpha = self.alpha + + key = tuple([observed_sum, len(expectations), penalty, q, alpha]) + ans = self.score_cache.get(key) + if ans is not None: + self.cache_counter['score'] += 1 + return ans + + if q < alpha: + q = alpha + + assert q > 0, ( + "Warning: calling compute_score_given_q with " + "observed_sum=%.2f, expectations of length=%d, penalty=%.2f, q=%.2f, alpha=%.3f" + % (observed_sum, len(expectations), penalty, q, alpha) + ) + if q == 1: + ans = observed_sum * np.log(q / alpha) - penalty + self.score_cache[key] = ans + return ans + + a = observed_sum * np.log(q / alpha) + b = (len(expectations) - observed_sum) * np.log((1 - q) / (1 - alpha)) + ans = ( + a + + b + - penalty + ) + + self.score_cache[key] = ans + return ans + + def qmle(self, observed_sum: float, expectations: np.array): + """ + Computes the q which maximizes score (q_mle). + for berk jones this is given to be N_a/N + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param direction: direction not considered + :return: q MLE + """ + alpha = self.alpha + + key = tuple([observed_sum, len(expectations), alpha]) + ans = self.qmle_cache.get(key) + if ans is not None: + self.cache_counter['qmle'] += 1 + return ans + + if len(expectations) == 0: + self.qmle_cache[key] = 0 + return 0 + else: + q = observed_sum / len(expectations) + + if (q < alpha): + self.qmle_cache[key] = alpha + return alpha + + self.qmle_cache[key] = q + return q + + def compute_qs(self, observed_sum: float, expectations: np.array, penalty: float): + """ + Computes roots (qmin and qmax) of the score function for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty coefficient + """ + alpha = self.alpha + + key = tuple([observed_sum, len(expectations), penalty, alpha]) + ans = self.compute_qs_cache.get(key) + if ans is not None: + self.cache_counter['qs'] += 1 + return ans + + q_mle = self.qmle(observed_sum, expectations) + + if self.score(observed_sum, expectations, penalty, q_mle) > 0: + exist = 1 + q_min = optim.bisection_q_min( + self, observed_sum, expectations, penalty, q_mle, temp_min=alpha + ) + q_max = optim.bisection_q_max( + self, observed_sum, expectations, penalty, q_mle, temp_max=1 + ) + else: + # there are no roots + exist = 0 + q_min = 0 + q_max = 0 + + ans = [exist, q_mle, q_min, q_max] + self.compute_qs_cache[key] = ans + return ans diff --git a/aif360/detectors/mdss/ScoringFunctions/Bernoulli.py b/aif360/detectors/mdss/ScoringFunctions/Bernoulli.py new file mode 100644 index 00000000..be3358eb --- /dev/null +++ b/aif360/detectors/mdss/ScoringFunctions/Bernoulli.py @@ -0,0 +1,121 @@ +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.ScoringFunctions import optim + +import numpy as np + + +class Bernoulli(ScoringFunction): + def __init__(self, **kwargs): + """ + Bernoulli score function. May be appropriate to use when the outcome of + interest is assumed to be Bernoulli distributed or Binary. + + kwargs must contain + 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') + """ + + super(Bernoulli, self).__init__(**kwargs) + + def score(self, observed_sum: float, expectations: np.array, penalty: float, q: float): + """ + Computes bernoulli bias score for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty term. Should be positive + :param q: current value of q + :return: bias score for the current value of q + """ + + assert q > 0, ( + "Warning: calling compute_score_given_q with " + "observed_sum=%.2f, expectations of length=%d, penalty=%.2f, q=%.2f" + % (observed_sum, len(expectations), penalty, q) + ) + + key = tuple([observed_sum, expectations.tostring(), penalty, q]) + ans = self.score_cache.get(key) + if ans is not None: + self.cache_counter['score'] += 1 + return ans + + ans = observed_sum * np.log(q) - np.log(1 - expectations + q * expectations).sum() - penalty + self.score_cache[key] = ans + return ans + + def qmle(self, observed_sum: float, expectations: np.array): + """ + Computes the q which maximizes score (q_mle). + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + """ + direction = self.direction + + key = tuple([observed_sum, expectations.tostring()]) + ans = self.qmle_cache.get(key) + if ans is not None: + self.cache_counter['qmle'] += 1 + return ans + + ans = optim.bisection_q_mle(self, observed_sum, expectations, direction=direction) + self.qmle_cache[key] = ans + return ans + + def compute_qs(self, observed_sum: float, expectations: np.array, penalty: float): + """ + Computes roots (qmin and qmax) of the score function for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty coefficient + """ + direction = self.direction + + key = tuple([observed_sum, expectations.tostring(), penalty]) + ans = self.compute_qs_cache.get(key) + if ans is not None: + self.cache_counter['qs'] += 1 + return ans + + q_mle = self.qmle(observed_sum, expectations) + + if self.score(observed_sum, expectations, penalty, q_mle) > 0: + exist = 1 + q_min = optim.bisection_q_min(self, observed_sum, expectations, penalty, q_mle) + q_max = optim.bisection_q_max(self, observed_sum, expectations, penalty, q_mle) + else: + # there are no roots + exist = 0 + q_min = 0 + q_max = 0 + + # only consider the desired direction, positive or negative + if exist: + exist, q_min, q_max = optim.direction_assertions(direction, q_min, q_max) + + ans = [exist, q_mle, q_min, q_max] + self.compute_qs_cache[key] = ans + return ans + + def q_dscore(self, observed_sum:float, expectations:np.array, q:float): + """ + This actually computes q times the slope, which has the same sign as the slope since q is positive. + score = Y log q - \sum_i log(1-p_i+qp_i) + dscore/dq = Y/q - \sum_i (p_i/(1-p_i+qp_i)) + q dscore/dq = Y - \sum_i (qp_i/(1-p_i+qp_i)) + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param q: current value of q + :return: q dscore/dq + """ + key = tuple([observed_sum, expectations.tostring(), q]) + ans = self.qdscore_cache.get(key) + if ans is not None: + self.cache_counter['qdscore'] += 1 + return ans + + ans = observed_sum - (q * expectations / (1 - expectations + q * expectations)).sum() + self.qdscore_cache[key] = ans + return ans diff --git a/aif360/detectors/mdss/ScoringFunctions/Gaussian.py b/aif360/detectors/mdss/ScoringFunctions/Gaussian.py new file mode 100644 index 00000000..a385ba7d --- /dev/null +++ b/aif360/detectors/mdss/ScoringFunctions/Gaussian.py @@ -0,0 +1,122 @@ +from turtle import pen +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.ScoringFunctions import optim + +import numpy as np + + +class Gaussian(ScoringFunction): + def __init__(self, **kwargs): + """ + Gaussian score function. May be appropriate to use when the outcome of + interest is assumed to be normally distributed. + + kwargs must contain + 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') + """ + + super(Gaussian, self).__init__(**kwargs) + + def score( + self, observed_sum: float, expectations: np.array, penalty: float, q: float + ): + """ + Computes gaussian bias score for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty term. Should be positive + :param q: current value of q + :return: bias score for the current value of q + """ + + key = tuple([observed_sum, expectations.sum(), penalty, q]) + ans = self.score_cache.get(key) + if ans is not None: + self.cache_counter["score"] += 1 + return ans + + assumed_var = self.var + expected_sum = expectations.sum() + penalty /= self.var + + C = ( + observed_sum * expected_sum / assumed_var * (q - 1) + ) + + B = ( + expected_sum**2 * (1 - q**2) / (2 * assumed_var) + ) + + if C > B and self.direction == 'positive': + ans = C + B + elif B > C and self.direction == 'negative': + ans = C + B + else: + ans = 0 + + ans -= penalty + self.score_cache[key] = ans + + return ans + + def qmle(self, observed_sum: float, expectations: np.array): + """ + Computes the q which maximizes score (q_mle). + """ + key = tuple([observed_sum, expectations.sum()]) + ans = self.qmle_cache.get(key) + if ans is not None: + self.cache_counter["qmle"] += 1 + return ans + + expected_sum = expectations.sum() + + # Deals with case where observed_sum = expected_sum = 0 + if observed_sum == expected_sum: + ans = 1 + else: + ans = observed_sum / expected_sum + + assert np.isnan(ans) == False, f'{expected_sum}, {observed_sum}, {ans}' + self.qmle_cache[key] = ans + return ans + + def compute_qs(self, observed_sum: float, expectations: np.array, penalty: float): + """ + Computes roots (qmin and qmax) of the score function for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty coefficient + """ + + direction = self.direction + + q_mle = self.qmle(observed_sum, expectations) + + key = tuple([observed_sum, expectations.sum(), penalty]) + ans = self.compute_qs_cache.get(key) + if ans is not None: + self.cache_counter["qs"] += 1 + return ans + + q_mle_score = self.score(observed_sum, expectations, penalty, q_mle) + + if q_mle_score > 0: + exist = 1 + q_min = optim.bisection_q_min(self, observed_sum, expectations, penalty, q_mle, temp_min=-1e6) + q_max = optim.bisection_q_max(self, observed_sum, expectations, penalty, q_mle, temp_max=1e6) + else: + # there are no roots + exist = 0 + q_min = 0 + q_max = 0 + + # only consider the desired direction, positive or negative + if exist: + exist, q_min, q_max = optim.direction_assertions(direction, q_min, q_max) + + ans = [exist, q_mle, q_min, q_max] + self.compute_qs_cache[key] = ans + return ans diff --git a/aif360/detectors/mdss/ScoringFunctions/Poisson.py b/aif360/detectors/mdss/ScoringFunctions/Poisson.py new file mode 100644 index 00000000..ff10e81a --- /dev/null +++ b/aif360/detectors/mdss/ScoringFunctions/Poisson.py @@ -0,0 +1,118 @@ +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.ScoringFunctions import optim + +import numpy as np + + +class Poisson(ScoringFunction): + def __init__(self, **kwargs): + """ + Poisson score function. May be appropriate to use when the outcome of + interest is assumed to be Poisson distributed or Binary. + + kwargs must contain + 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') + """ + + super(Poisson, self).__init__(**kwargs) + + def score(self, observed_sum: float, expectations: np.array, penalty: float, q: float): + """ + Computes poisson bias score for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty term. Should be positive + :param q: current value of q + :return: bias score for the current value of q + """ + + assert q > 0, ( + "Warning: calling compute_score_given_q with " + "observed_sum=%.2f, expectations of length=%d, penalty=%.2f, q=%.2f" + % (observed_sum, len(expectations), penalty, q) + ) + key = tuple([observed_sum, expectations.sum(), penalty, q]) + ans = self.score_cache.get(key) + if ans is not None: + self.cache_counter['score'] += 1 + return ans + + ans = observed_sum * np.log(q) + (expectations - q * expectations).sum() - penalty + self.score_cache[key] = ans + return ans + + def qmle(self, observed_sum: float, expectations: np.array): + """ + Computes the q which maximizes score (q_mle). + """ + direction = self.direction + + key = tuple([observed_sum, expectations.sum()]) + ans = self.qmle_cache.get(key) + if ans is not None: + self.cache_counter['qmle'] += 1 + return ans + + ans = optim.bisection_q_mle(self, observed_sum, expectations, direction=direction) + self.qmle_cache[key] = ans + return ans + + def compute_qs(self, observed_sum: float, expectations: np.array, penalty: float): + """ + Computes roots (qmin and qmax) of the score function for given q + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param penalty: penalty coefficient + """ + + direction = self.direction + + q_mle = self.qmle(observed_sum, expectations) + + key = tuple([observed_sum, expectations.tostring(), penalty]) + ans = self.compute_qs_cache.get(key) + if ans is not None: + self.cache_counter['qs'] += 1 + return ans + + if self.score(observed_sum, expectations, penalty, q_mle) > 0: + exist = 1 + q_min = optim.bisection_q_min(self, observed_sum, expectations, penalty, q_mle) + q_max = optim.bisection_q_max(self, observed_sum, expectations, penalty, q_mle) + else: + # there are no roots + exist = 0 + q_min = 0 + q_max = 0 + + # only consider the desired direction, positive or negative + if exist: + exist, q_min, q_max = optim.direction_assertions(direction, q_min, q_max) + + ans = [exist, q_mle, q_min, q_max] + self.compute_qs_cache[key] = ans + return ans + + def q_dscore(self, observed_sum, expectations, q): + """ + This actually computes q times the slope, which has the same sign as the slope since q is positive. + score = Y log q + \sum_i (p_i - qp_i) + dscore/dq = Y / q - \sum_i(p_i) + q dscore/dq = q_dscore = Y - (q * \sum_i(p_i)) + + :param observed_sum: sum of observed binary outcomes for all i + :param expectations: predicted outcomes for each data element i + :param q: current value of q + :return: q dscore/dq + """ + key = tuple([observed_sum, expectations.sum(), q]) + ans = self.qdscore_cache.get(key) + if ans is not None: + self.cache_counter['qdscore'] += 1 + return ans + + ans = observed_sum - (q * expectations).sum() + self.qdscore_cache[key] = ans + return ans diff --git a/aif360/metrics/mdss/ScoringFunctions/ScoringFunction.py b/aif360/detectors/mdss/ScoringFunctions/ScoringFunction.py similarity index 55% rename from aif360/metrics/mdss/ScoringFunctions/ScoringFunction.py rename to aif360/detectors/mdss/ScoringFunctions/ScoringFunction.py index 2be52558..ec7c0672 100644 --- a/aif360/metrics/mdss/ScoringFunctions/ScoringFunction.py +++ b/aif360/detectors/mdss/ScoringFunctions/ScoringFunction.py @@ -1,54 +1,67 @@ import numpy as np -class ScoringFunction: +class ScoringFunction: def __init__(self, **kwargs): """ This is an abstract class for Scoring Functions (or expectation-based scan statistics). - - [1] introduces a property of many commonly used log-likelihood ratio scan statistics called + + [1] introduces a property of many commonly used log-likelihood ratio scan statistics called Additive linear-time subset scanning (ALTSS) that allows for exact of efficient maximization of these - statistics over all subsets of the data, without requiring an exhaustive search over all subsets and + statistics over all subsets of the data, without requiring an exhaustive search over all subsets and allows penalty terms to be included. - - [1] Speakman, S., Somanchi, S., McFowland III, E., & Neill, D. B. (2016). Penalized fast subset scanning. + + [1] Speakman, S., Somanchi, S., McFowland III, E., & Neill, D. B. (2016). Penalized fast subset scanning. Journal of Computational and Graphical Statistics, 25(2), 382-404. """ self.kwargs = kwargs + self._reset() + self.direction = kwargs.get('direction') + + directions = ['positive', 'negative'] + assert self.direction in directions, f"Expected one of {directions}, got {self.direction}" - def score(self, observed_sum: float, probs: np.array, penalty: float, q: float): + def _reset(self): + self.score_cache = {} + self.dscore_cache = {} + self.qdscore_cache = {} + self.qmle_cache = {} + self.compute_qs_cache = {} + self.cache_counter = {"score": 0, "dscore": 0, "qdscore": 0, "qmle": 0, "qs": 0} + + def score( + self, observed_sum: float, expectations: np.array, penalty: float, q: float + ): """ Computes the score for the given q. (for the given records). - - The alternative hypothesis of MDSS assumes that there exists some constant multiplicative factor q > 1 - for the subset of records being scored by the scoring function. - q is sometimes refered to as relative risk or severity. - + + The alternative hypothesis of MDSS assumes that there exists some constant multiplicative factor q > 1 + for the subset of records being scored by the scoring function. + q is sometimes refered to as relative risk or severity. + """ raise NotImplementedError - def dscore(self, observed_sum: float, probs: np.array, q: float): + def dscore(self, observed_sum: float, expectations: np.array, q: float): """ Computes the first derivative of the score function """ raise NotImplementedError - def q_dscore(self, observed_sum: float, probs: np.array, q: float): + def q_dscore(self, observed_sum: float, expectations: np.array, q: float): """ Computes the first derivative of the score function multiplied by the given q """ raise NotImplementedError - def qmle(self, observed_sum: float, probs: np.array): + def qmle(self, observed_sum: float, expectations: np.array): """ Computes the q which maximizes score (q_mle). """ raise NotImplementedError - def compute_qs(self, observed_sum: float, probs: np.array, penalty: float): + def compute_qs(self, observed_sum: float, expectations: np.array, penalty: float): """ Computes roots (qmin and qmax) of the score function (for the given records) """ raise NotImplementedError - - diff --git a/aif360/detectors/mdss/ScoringFunctions/__init__.py b/aif360/detectors/mdss/ScoringFunctions/__init__.py new file mode 100644 index 00000000..7a7f9b01 --- /dev/null +++ b/aif360/detectors/mdss/ScoringFunctions/__init__.py @@ -0,0 +1,5 @@ +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.ScoringFunctions.Bernoulli import Bernoulli +from aif360.detectors.mdss.ScoringFunctions.Poisson import Poisson +from aif360.detectors.mdss.ScoringFunctions.BerkJones import BerkJones +from aif360.detectors.mdss.ScoringFunctions.Gaussian import Gaussian diff --git a/aif360/metrics/mdss/ScoringFunctions/optim.py b/aif360/detectors/mdss/ScoringFunctions/optim.py similarity index 92% rename from aif360/metrics/mdss/ScoringFunctions/optim.py rename to aif360/detectors/mdss/ScoringFunctions/optim.py index 06e03e9f..5201daf7 100644 --- a/aif360/metrics/mdss/ScoringFunctions/optim.py +++ b/aif360/detectors/mdss/ScoringFunctions/optim.py @@ -1,5 +1,5 @@ import numpy as np -from aif360.metrics.mdss.ScoringFunctions.ScoringFunction import ScoringFunction +from aif360.detectors.mdss.ScoringFunctions.ScoringFunction import ScoringFunction def bisection_q_mle(score_function: ScoringFunction, observed_sum: float, probs: np.array, **kwargs): @@ -14,10 +14,10 @@ def bisection_q_mle(score_function: ScoringFunction, observed_sum: float, probs: :param probs: predicted probabilities p_i for each data element i :return: q MLE """ - q_temp_min = 1e-6 - q_temp_max = 1e6 + q_temp_min = 1e-3 + q_temp_max = 1e3 - while np.abs(q_temp_max - q_temp_min) > 1e-6: + while np.abs(q_temp_max - q_temp_min) > 1e-3: q_temp_mid = (q_temp_min + q_temp_max) / 2 if np.sign(score_function.q_dscore(observed_sum, probs, q_temp_mid)) > 0: @@ -36,7 +36,7 @@ def bisection_q_mle(score_function: ScoringFunction, observed_sum: float, probs: return q -def bisection_q_min(score_function: ScoringFunction, observed_sum: float, probs: np.array, penalty: float, q_mle: float, temp_min = 1e-6, **kwargs): +def bisection_q_min(score_function: ScoringFunction, observed_sum: float, probs: np.array, penalty: float, q_mle: float, temp_min = 1e-3, **kwargs): """ Compute q for which score = 0, using the fact that score is monotonically increasing for q > q_mle. q_max is computed via binary search. @@ -51,7 +51,7 @@ def bisection_q_min(score_function: ScoringFunction, observed_sum: float, probs: q_temp_min = temp_min q_temp_max = q_mle - while np.abs(q_temp_max - q_temp_min) > 1e-6: + while np.abs(q_temp_max - q_temp_min) > 1e-3: q_temp_mid = (q_temp_min + q_temp_max) / 2 if np.sign(score_function.score(observed_sum, probs, penalty, q_temp_mid)) > 0: @@ -61,7 +61,7 @@ def bisection_q_min(score_function: ScoringFunction, observed_sum: float, probs: return (q_temp_min + q_temp_max) / 2 -def bisection_q_max(score_function: ScoringFunction, observed_sum: float, probs: np.array, penalty: float, q_mle: float, temp_max = 1e6, **kwargs): +def bisection_q_max(score_function: ScoringFunction, observed_sum: float, probs: np.array, penalty: float, q_mle: float, temp_max = 1e3, **kwargs): """ Compute q for which score = 0, using the fact that score is monotonically decreasing for q > q_mle. q_max is computed via binary search. @@ -76,7 +76,7 @@ def bisection_q_max(score_function: ScoringFunction, observed_sum: float, probs: q_temp_min = q_mle q_temp_max = temp_max - while np.abs(q_temp_max - q_temp_min) > 1e-6: + while np.abs(q_temp_max - q_temp_min) > 1e-3: q_temp_mid = (q_temp_min + q_temp_max) / 2 if np.sign(score_function.score(observed_sum, probs, penalty, q_temp_mid)) > 0: diff --git a/aif360/detectors/mdss/__init__.py b/aif360/detectors/mdss/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aif360/metrics/mdss/generator.py b/aif360/detectors/mdss/generator.py similarity index 100% rename from aif360/metrics/mdss/generator.py rename to aif360/detectors/mdss/generator.py diff --git a/aif360/detectors/mdss_detector.py b/aif360/detectors/mdss_detector.py new file mode 100644 index 00000000..65c2fa27 --- /dev/null +++ b/aif360/detectors/mdss_detector.py @@ -0,0 +1,157 @@ +from typing import Union + +from aif360.detectors.mdss.ScoringFunctions import ( + Bernoulli, + BerkJones, + Gaussian, + ScoringFunction, + Poisson, +) +from aif360.detectors.mdss.MDSS import MDSS + +import pandas as pd + + +def bias_scan( + data: pd.DataFrame, + observations: pd.Series, + expectations: Union[pd.Series, pd.DataFrame] = None, + favorable_value: Union[str, float] = None, + overpredicted: bool = True, + scoring: Union[str, ScoringFunction] = "Bernoulli", + num_iters: int = 10, + penalty: float = 1e-17, + mode: str = "binary", + **kwargs, +): + """ + scan to find the highest scoring subset of records + + :param data (dataframe): the dataset (containing the features) the model was trained on + :param observations (series): ground truth (correct) target values + :param expectations (series, dataframe, optional): pandas series estimated targets + as returned by a model for binary, continuous and ordinal modes. + If mode is nominal, this is a dataframe with columns containing expectations for each nominal class. + If None, model is assumed to be a dumb model that predicts the mean of the targets + or 1/(num of categories) for nominal mode. + :param favorable_value(str, float, optional): Should be high or low or float if the mode in [binary, ordinal, or continuous]. + If float, value has to be minimum or maximum in the observations column. Defaults to high if None for these modes. + Support for float left in to keep the intuition clear in binary classification tasks. + If mode is nominal, favorable values should be one of the unique categories in the observations. + Defaults to a one-vs-all scan if None for nominal mode. + :param overpredicted (bool, optional): flag for group to scan for. + True means we scan for a group whose expectations/predictions are systematically higher than observed. + In other words, True means we scan for a group whose observeed is systematically lower than the expectations. + False means we scan for a group whose expectations/predictions are systematically lower than observed. + In other words, False means we scan for a group whose observed is systematically higher than the expectations. + :param scoring (str or class): One of 'Bernoulli', 'Gaussian', 'Poisson', or 'BerkJones' or subclass of + :class:`aif360.metrics.mdss.ScoringFunctions.ScoringFunction`. + :param num_iters (int, optional): number of iterations (random restarts). Should be positive. + :param penalty (float,optional): penalty term. Should be positive. The penalty term as with any regularization parameter may need to be + tuned for ones use case. The higher the penalty, the less complex (number of features and feature values) the + highest scoring subset that gets returned is. + :param mode: one of ['binary', 'continuous', 'nominal', 'ordinal']. Defaults to binary. + In nominal mode, up to 10 categories are supported by default. + To increase this, pass in keyword argument max_nominal = integer value. + + :returns: the highest scoring subset and the score or dict of the highest scoring subset and the score for each category in nominal mode + """ + # Ensure correct mode is passed in. + modes = ["binary", "continuous", "nominal", "ordinal"] + assert mode in modes, f"Expected one of {modes}, got {mode}." + + # Set correct favorable value (this tells us if higher or lower is better) + min_val, max_val = observations.min(), observations.max() + uniques = list(observations.unique()) + + if favorable_value == 'high': + favorable_value = max_val + elif favorable_value == 'low': + favorable_value = min_val + elif favorable_value is None: + if mode in ["binary", "ordinal", "continuous"]: + favorable_value = max_val # Default to higher is better + elif mode == "nominal": + favorable_value = "flag-all" # Default to scan through all categories + assert favorable_value in [ + "flag-all", + *uniques, + ], f"Expected one of {uniques}, got {favorable_value}." + + assert favorable_value in [ + min_val, + max_val, + "flag-all", + *uniques, + ], f"Favorable_value should be high, low, or one of categories {uniques}, got {favorable_value}." + + # Set appropriate direction for scanner depending on mode and overppredicted flag + if mode in ["ordinal", "continuous"]: + if favorable_value == max_val: + kwargs["direction"] = "negative" if overpredicted else "positive" + else: + kwargs["direction"] = "positive" if overpredicted else "negative" + else: + kwargs["direction"] = "negative" if overpredicted else "positive" + + # Set expectations to mean targets for non-nominal modes + if expectations is None and mode != "nominal": + expectations = pd.Series(observations.mean(), index=observations.index) + + # Set appropriate scoring function + if scoring == "Bernoulli": + scoring = Bernoulli(**kwargs) + elif scoring == "BerkJones": + scoring = BerkJones(**kwargs) + elif scoring == "Gaussian": + scoring = Gaussian(**kwargs) + elif scoring == "Poisson": + scoring = Poisson(**kwargs) + else: + scoring = scoring(**kwargs) + + if mode == "binary": # Flip observations if favorable_value is 0 in binary mode. + observations = pd.Series(observations == favorable_value, dtype=int) + elif mode == "nominal": + unique_outs = set(sorted(observations.unique())) + size_unique_outs = len(unique_outs) + if expectations is not None: # Set expectations to 1/(num of categories) for nominal mode + expectations_cols = set(sorted(expectations.columns)) + assert ( + unique_outs == expectations_cols + ), f"Expected {unique_outs} in expectation columns, got {expectations_cols}" + else: + expectations = pd.Series( + 1 / observations.nunique(), index=observations.index + ) + max_nominal = kwargs.get("max_nominal", 10) + + assert ( + size_unique_outs <= max_nominal + ), f"Nominal mode only support up to {max_nominal} labels, got {size_unique_outs}. Use keyword argument max_nominal to increase the limit." + + if favorable_value != "flag-all": # If favorable flag is set, use one-vs-others strategy to scan, else use one-vs-all strategy + observations = observations.map({favorable_value: 1}) + observations = observations.fillna(0) + if isinstance(expectations, pd.DataFrame): + expectations = expectations[favorable_value] + else: + results = {} + orig_observations = observations.copy() + orig_expectations = expectations.copy() + for unique in uniques: + observations = orig_observations.map({unique: 1}) + observations = observations.fillna(0) + + if isinstance(expectations, pd.DataFrame): + expectations = orig_expectations[unique] + + scanner = MDSS(scoring) + result = scanner.scan( + data, expectations, observations, penalty, num_iters, mode=mode + ) + results[unique] = result + return results + + scanner = MDSS(scoring) + return scanner.scan(data, expectations, observations, penalty, num_iters, mode=mode) diff --git a/aif360/metrics/mdss/ScoringFunctions/BerkJones.py b/aif360/metrics/mdss/ScoringFunctions/BerkJones.py deleted file mode 100644 index cdb21d35..00000000 --- a/aif360/metrics/mdss/ScoringFunctions/BerkJones.py +++ /dev/null @@ -1,103 +0,0 @@ -from aif360.metrics.mdss.ScoringFunctions.ScoringFunction import ScoringFunction -from aif360.metrics.mdss.ScoringFunctions import optim - -import numpy as np - -class BerkJones(ScoringFunction): - - def __init__(self, **kwargs): - """ - Berk-Jones score function is a non parametric expectatation based - scan statistic that also satisfies the ALTSS property; Non-parametric scoring functions - do not make parametric assumptions about the model or outcome [1]. - - kwargs must contain - 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') - 'alpha (float)' - the alpha threshold that will be used to compute the score. - In practice, it may be useful to search over a grid of alpha thresholds and select the one with the maximum score. - - - [1] Neill, D. B., & Lingwall, J. (2007). A nonparametric scan statistic for multivariate disease surveillance. Advances in - Disease Surveillance, 4(106), 570 - """ - - super(BerkJones, self).__init__() - assert 'direction' in kwargs.keys() - assert 'alpha' in kwargs.keys() - self.kwargs = kwargs - - def score(self, observed_sum: float, probs: np.array, penalty: float, q: float): - """ - Computes berk jones score for given q - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param penalty: penalty term. Should be positive - :param q: current value of q - :return: berk jones score for the current value of q - """ - assert 'alpha' in self.kwargs.keys(), "Warning: calling bj score without alpha" - alpha = self.kwargs['alpha'] - - if q < alpha: - q = alpha - - assert q > 0, "Warning: calling compute_score_given_q with " \ - "observed_sum=%.2f, probs of length=%d, penalty=%.2f, q=%.2f, alpha=%.3f" \ - % (observed_sum, len(probs), penalty, q, alpha) - if q == 1: - return observed_sum * np.log(q/alpha) - penalty - - return observed_sum * np.log(q/alpha) + (len(probs) - observed_sum) * np.log((1 - q)/(1 - alpha)) - penalty - - - def qmle(self, observed_sum: float, probs: np.array): - """ - Computes the q which maximizes score (q_mle). - for berk jones this is given to be N_a/N - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param direction: direction not considered - :return: q MLE - """ - - assert 'alpha' in self.kwargs.keys(), "Warning: calling bj qmle without alpha" - alpha = self.kwargs['alpha'] - - direction = None - if 'direction' in self.kwargs: - direction = self.kwargs['direction'] - - if len(probs) == 0: - return 0 - else: - q = observed_sum/len(probs) - - if ((direction == 'positive') & (q < alpha)) | ((direction == 'negative') & (q > alpha)): - return alpha - return q - - def compute_qs(self, observed_sum: float, probs: np.array, penalty: float): - """ - Computes roots (qmin and qmax) of the score function - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param penalty: penalty coefficient - """ - assert 'alpha' in self.kwargs.keys(), "Warning: calling compute_qs bj without alpha" - alpha = self.kwargs['alpha'] - - q_mle = self.qmle(observed_sum, probs) - - if self.score(observed_sum, probs, penalty, q_mle) > 0: - exist = 1 - q_min = optim.bisection_q_min(self, observed_sum, probs, penalty, q_mle, temp_min=alpha) - q_max = optim.bisection_q_max(self, observed_sum, probs, penalty, q_mle, temp_max=1) - else: - # there are no roots - exist = 0 - q_min = 0 - q_max = 0 - - return exist, q_mle, q_min, q_max diff --git a/aif360/metrics/mdss/ScoringFunctions/Bernoulli.py b/aif360/metrics/mdss/ScoringFunctions/Bernoulli.py deleted file mode 100644 index 883e99b5..00000000 --- a/aif360/metrics/mdss/ScoringFunctions/Bernoulli.py +++ /dev/null @@ -1,91 +0,0 @@ -from aif360.metrics.mdss.ScoringFunctions.ScoringFunction import ScoringFunction -from aif360.metrics.mdss.ScoringFunctions import optim - -import numpy as np - -class Bernoulli(ScoringFunction): - - def __init__(self, **kwargs): - """ - Bernoulli score function. May be appropriate to use when the outcome of - interest is assumed to be Bernoulli distributed or Binary. - - kwargs must contain - 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') - """ - - super(Bernoulli, self).__init__() - assert 'direction' in kwargs.keys() - self.kwargs = kwargs - - def score(self, observed_sum: float, probs: np.array, penalty: float, q: float): - """ - Computes bernoulli bias score for given q - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param penalty: penalty term. Should be positive - :param q: current value of q - :return: bias score for the current value of q - """ - - assert q > 0, "Warning: calling compute_score_given_q with " \ - "observed_sum=%.2f, probs of length=%d, penalty=%.2f, q=%.2f" \ - % (observed_sum, len(probs), penalty, q) - - return observed_sum * np.log(q) - np.log(1 - probs + q * probs).sum() - penalty - - def qmle(self, observed_sum: float, probs: np.array): - """ - Computes the q which maximizes score (q_mle). - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - """ - assert 'direction' in self.kwargs.keys() - direction = self.kwargs['direction'] - return optim.bisection_q_mle(self, observed_sum, probs, direction=direction) - - def compute_qs(self, observed_sum: float, probs: np.array, penalty: float): - """ - Computes roots (qmin and qmax) of the score function - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param penalty: penalty coefficient - """ - direction = None - if 'direction' in self.kwargs: - direction = self.kwargs['direction'] - - q_mle = self.qmle(observed_sum, probs) - - if self.score(observed_sum, probs, penalty, q_mle) > 0: - exist = 1 - q_min = optim.bisection_q_min(self, observed_sum, probs, penalty, q_mle) - q_max = optim.bisection_q_max(self, observed_sum, probs, penalty, q_mle) - else: - # there are no roots - exist = 0 - q_min = 0 - q_max = 0 - - # only consider the desired direction, positive or negative - if exist: - exist, q_min, q_max = optim.direction_assertions(direction, q_min, q_max) - - return exist, q_mle, q_min, q_max - - def q_dscore(self, observed_sum, probs, q): - """ - This actually computes q times the slope, which has the same sign as the slope since q is positive. - score = Y log q - \sum_i log(1-p_i+qp_i) - dscore/dq = Y/q - \sum_i (p_i/(1-p_i+qp_i)) - q dscore/dq = Y - \sum_i (qp_i/(1-p_i+qp_i)) - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param q: current value of q - :return: q dscore/dq - """ - return observed_sum - (q * probs / (1 - probs + q * probs)).sum() \ No newline at end of file diff --git a/aif360/metrics/mdss/ScoringFunctions/Poisson.py b/aif360/metrics/mdss/ScoringFunctions/Poisson.py deleted file mode 100644 index bbc4cbe1..00000000 --- a/aif360/metrics/mdss/ScoringFunctions/Poisson.py +++ /dev/null @@ -1,89 +0,0 @@ -from aif360.metrics.mdss.ScoringFunctions.ScoringFunction import ScoringFunction -from aif360.metrics.mdss.ScoringFunctions import optim - -import numpy as np - -class Poisson(ScoringFunction): - - def __init__(self, **kwargs): - """ - Bernoulli score function. May be appropriate to use when the outcome of - interest is assumed to be Poisson distributed or Binary. - - kwargs must contain - 'direction (str)' - direction of the severity; could be higher than expected outcomes ('positive') or lower than expected ('negative') - """ - super(Poisson, self).__init__() - assert 'direction' in kwargs.keys() - self.kwargs = kwargs - - def score(self, observed_sum: float, probs: np.array, penalty: float, q: float): - """ - Computes poisson bias score for given q - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param penalty: penalty term. Should be positive - :param q: current value of q - :return: bias score for the current value of q - """ - - assert q > 0, "Warning: calling compute_score_given_q with " \ - "observed_sum=%.2f, probs of length=%d, penalty=%.2f, q=%.2f" \ - % (observed_sum, len(probs), penalty, q) - - return observed_sum * np.log(q) + (probs - q *probs).sum() - penalty - - def qmle(self, observed_sum: float, probs: np.array): - """ - Computes the q which maximizes score (q_mle). - """ - assert 'direction' in self.kwargs.keys() - direction = self.kwargs['direction'] - return optim.bisection_q_mle(self, observed_sum, probs, direction=direction) - - def compute_qs(self, observed_sum: float, probs: np.array, penalty: float): - """ - Computes roots (qmin and qmax) of the score function - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param penalty: penalty coefficient - """ - - direction = None - if 'direction' in self.kwargs: - direction = self.kwargs['direction'] - - q_mle = self.qmle(observed_sum, probs) - - if self.score(observed_sum, probs, penalty, q_mle) > 0: - exist = 1 - q_min = optim.bisection_q_min(self, observed_sum, probs, penalty, q_mle) - q_max = optim.bisection_q_max(self, observed_sum, probs, penalty, q_mle) - else: - # there are no roots - exist = 0 - q_min = 0 - q_max = 0 - - # only consider the desired direction, positive or negative - if exist: - exist, q_min, q_max = optim.direction_assertions(direction, q_min, q_max) - - return exist, q_mle, q_min, q_max - - - def q_dscore(self, observed_sum, probs, q): - """ - This actually computes q times the slope, which has the same sign as the slope since q is positive. - score = Y log q + \sum_i (p_i - qp_i) - dscore/dq = Y / q - \sum_i(p_i) - q dscore/dq = q_dscore = Y - (q * \sum_i(p_i)) - - :param observed_sum: sum of observed binary outcomes for all i - :param probs: predicted probabilities p_i for each data element i - :param q: current value of q - :return: q dscore/dq - """ - return observed_sum - (q * probs).sum() \ No newline at end of file diff --git a/aif360/metrics/mdss/ScoringFunctions/__init__.py b/aif360/metrics/mdss/ScoringFunctions/__init__.py deleted file mode 100644 index f0b1b03b..00000000 --- a/aif360/metrics/mdss/ScoringFunctions/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from aif360.metrics.mdss.ScoringFunctions.ScoringFunction import ScoringFunction -from aif360.metrics.mdss.ScoringFunctions.Bernoulli import Bernoulli -from aif360.metrics.mdss.ScoringFunctions.Poisson import Poisson -from aif360.metrics.mdss.ScoringFunctions.BerkJones import BerkJones diff --git a/aif360/metrics/mdss/__init__.py b/aif360/metrics/mdss/__init__.py deleted file mode 100644 index d5df06c0..00000000 --- a/aif360/metrics/mdss/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from aif360.metrics.mdss.MDSS import MDSS \ No newline at end of file diff --git a/aif360/metrics/mdss_classification_metric.py b/aif360/metrics/mdss_classification_metric.py index e44701cb..2897d0ac 100644 --- a/aif360/metrics/mdss_classification_metric.py +++ b/aif360/metrics/mdss_classification_metric.py @@ -1,45 +1,79 @@ -from collections import defaultdict +from typing import Union + from aif360.datasets import BinaryLabelDataset from aif360.metrics import ClassificationMetric -from aif360.metrics.mdss.ScoringFunctions import Bernoulli, ScoringFunction -from aif360.metrics.mdss.MDSS import MDSS +from aif360.detectors.mdss.ScoringFunctions import Bernoulli, BerkJones, ScoringFunction +from aif360.detectors.mdss.MDSS import MDSS import pandas as pd +from sklearn.exceptions import deprecated + class MDSSClassificationMetric(ClassificationMetric): - """ - Bias subset scanning is proposed as a technique to identify bias in predictive models using subset scanning [1]. - This class is a wrapper for the bias scan scoring and scanning methods that uses the ClassificationMetric abstraction. + """Bias subset scanning is proposed as a technique to identify bias in + predictive models using subset scanning [#zhang16]_. + + This class is a wrapper for the bias scan scoring and scanning methods that + uses the ClassificationMetric abstraction. + References: - .. [1] Zhang, Z., & Neill, D. B. (2016). Identifying significant predictive bias in classifiers. arXiv preprint arXiv:1611.08292. + .. [#zhang16] `Zhang, Z. and Neill, D. B., "Identifying significant + predictive bias in classifiers," arXiv preprint, 2016. + `_ """ - def __init__(self, dataset: BinaryLabelDataset, classified_dataset: BinaryLabelDataset, - scoring_function: ScoringFunction = Bernoulli(direction='positive'), unprivileged_groups: dict = None, privileged_groups:dict = None): - - super(MDSSClassificationMetric, self).__init__(dataset, classified_dataset, - unprivileged_groups=unprivileged_groups, - privileged_groups=privileged_groups) - - self.scanner = MDSS(scoring_function) - - def score_groups(self, privileged=True, penalty = 1e-17): + + def __init__( + self, + dataset: BinaryLabelDataset, + classified_dataset: BinaryLabelDataset, + scoring: Union[str, ScoringFunction] = 'Bernoulli', + unprivileged_groups: dict = None, + privileged_groups: dict = None, + **kwargs + ): """ - compute the bias score for a prespecified group of records. - - :param privileged: flag for group to score - privileged group (True) or unprivileged group (False). - This abstract the need to explicitly specify the direction of bias to scan for which depends on what the favourable label is. - :param penalty: penalty term. Should be positive. The penalty term as with any regularization parameter may need to be - tuned for ones use case. The higher the penalty, the less complex (number of features and feature values) the highest scoring - subset that gets returned is. - - :returns: the score for the group + Args: + dataset (BinaryLabelDataset): Dataset containing ground-truth + labels. + classified_dataset (BinaryLabelDataset): Dataset containing + predictions. + scoring (str or ScoringFunction): One of 'Bernoulli' (parametric), or 'BerkJones' (non-parametric) + or subclass of :class:`aif360.metrics.mdss.ScoringFunctions.ScoringFunction`. + Defaults to Bernoulli. + privileged_groups (list(dict)): Privileged groups. Format is a list + of `dicts` where the keys are `protected_attribute_names` and + the values are values in `protected_attributes`. Each `dict` + element describes a single group. See examples for more details. + unprivileged_groups (list(dict)): Unprivileged groups in the same + format as `privileged_groups`. + """ + + super(MDSSClassificationMetric, self).__init__( + dataset, + classified_dataset, + unprivileged_groups=unprivileged_groups, + privileged_groups=privileged_groups, + ) + + self.scoring = scoring + self.kwargs = kwargs + + def score_groups(self, privileged=True, penalty=1e-17): + """Compute the bias score for a prespecified group of records. + + Args: + privileged (bool): Flag for which direction to scan: privileged + (``True``) implies negative (observed worse than predicted + outcomes) while unprivileged (``False``) implies positive + (observed better than predicted outcomes). + + Returns: + float: Bias score for the given group. + The higher the score, the evidence for bias. """ groups = self.privileged_groups if privileged else self.unprivileged_groups subset = dict() - - xor_op = privileged ^ bool(self.classified_dataset.favorable_label) - direction = 'positive' if xor_op else 'negative' for g in groups: for k, v in g.items(): @@ -47,35 +81,87 @@ def score_groups(self, privileged=True, penalty = 1e-17): subset[k].append(v) else: subset[k] = [v] - - coordinates = pd.DataFrame(self.dataset.features, columns=self.dataset.feature_names) + + coordinates = pd.DataFrame( + self.dataset.features, columns=self.dataset.feature_names + ) expected = pd.Series(self.classified_dataset.scores.flatten()) - outcomes = pd.Series(self.dataset.labels.flatten()) - - self.scanner.scoring_function.kwargs['direction'] = direction - return self.scanner.score_current_subset(coordinates, expected, outcomes, dict(subset), penalty) - - def bias_scan(self, privileged=True, num_iters = 10, penalty = 1e-17): + outcomes = pd.Series(self.dataset.labels.flatten() == self.dataset.favorable_label, dtype=int) + + # In MDSS, we look for subset whose observations systematically deviates from expectations. + # Positive direction means observations are systematically higher than expectations + # (or expectations are systematically higher than observations) while + # Negative direction means observatons are systematically lower than expectations + # (or expectations are systematically higher than observations) + + # For a privileged group, we are looking for a subset whose expectations + # (where expectations is obtained from a model) is systematically higher than the observations. + # This means we scan in the negative direction. + + # For an uprivileged group, we are looking for a subset whose expectations + # (where expectations is obtained from a model) is systematically lower the observations. + # This means we scan in the position direction. + + self.kwargs['direction'] = "negative" if privileged else "positive" + + if self.scoring == "Bernoulli": + scoring_function = Bernoulli(**self.kwargs) + elif self.scoring == "BerkJones": + scoring_function = BerkJones(**self.kwargs) + else: + scoring_function = self.scoring(**self.kwargs) + + scanner = MDSS(scoring_function) + + return scanner.score_current_subset( + coordinates, expected, outcomes, dict(subset), penalty + ) + + @deprecated('Change to new interface - aif360.detectors.mdss_detector.bias_scan by version 0.5.0.') + def bias_scan(self, privileged=True, num_iters=10, penalty=1e-17): """ scan to find the highest scoring subset of records - - :param privileged: flag for group to scan for - privileged group (True) or unprivileged group (False). + + :param privileged: flag for group to scan for - privileged group (True) or unprivileged group (False). This abstract the need to explicitly specify the direction of bias to scan for which depends on what the favourable label is. :param num_iters: number of iterations (random restarts) - :param penalty: penalty term. Should be positive. The penalty term as with any regularization parameter may need to be + :param penalty: penalty term. Should be positive. The penalty term as with any regularization parameter may need to be tuned for ones use case. The higher the penalty, the less complex (number of features and feature values) the highest scoring subset that gets returned is. - + :returns: the highest scoring subset and the score """ - xor_op = privileged ^ bool(self.classified_dataset.favorable_label) - direction = 'positive' if xor_op else 'negative' - self.scanner.scoring_function.kwargs['direction'] = direction + coordinates = pd.DataFrame( + self.classified_dataset.features, + columns=self.classified_dataset.feature_names, + ) - coordinates = pd.DataFrame(self.classified_dataset.features, columns=self.classified_dataset.feature_names) - expected = pd.Series(self.classified_dataset.scores.flatten()) - outcomes = pd.Series(self.dataset.labels.flatten()) - - return self.scanner.scan(coordinates, expected, outcomes, penalty, num_iters) \ No newline at end of file + outcomes = pd.Series(self.dataset.labels.flatten() == self.dataset.favorable_label, dtype=int) + + # In MDSS, we look for subset whose observations systematically deviates from expectations. + # Positive direction means observations are systematically higher than expectations + # (or expectations are systematically lower than observations) while + # Negative direction means observatons are systematically lower than expectations + # (or expectations are systematically higher than observations) + + # For a privileged group, we are looking for a subset whose expectations + # (where expectations is obtained from a model) is systematically higher than the observations. + # This means we scan in the negative direction. + + # For an uprivileged group, we are looking for a subset whose expectations + # (where expectations is obtained from a model) is systematically lower the observations. + # This means we scan in the position direction. + + self.kwargs['direction'] = "negative" if privileged else "positive" + + if self.scoring == "Bernoulli": + scoring_function = Bernoulli(**self.kwargs) + elif self.scoring == "BerkJones": + scoring_function = BerkJones(**self.kwargs) + else: + scoring_function = self.scoring(**self.kwargs) + + scanner = MDSS(scoring_function) + return scanner.scan(coordinates, expected, outcomes, penalty, num_iters) diff --git a/aif360/sklearn/detectors/__init__.py b/aif360/sklearn/detectors/__init__.py new file mode 100644 index 00000000..2f23033c --- /dev/null +++ b/aif360/sklearn/detectors/__init__.py @@ -0,0 +1 @@ +from aif360.sklearn.detectors.detectors import bias_scan \ No newline at end of file diff --git a/aif360/sklearn/detectors/detectors.py b/aif360/sklearn/detectors/detectors.py new file mode 100644 index 00000000..34b72616 --- /dev/null +++ b/aif360/sklearn/detectors/detectors.py @@ -0,0 +1,64 @@ +from typing import Union + +from aif360.detectors import bias_scan +from aif360.detectors.mdss import ScoringFunction + +import pandas as pd + + +def bias_scan( + X: pd.DataFrame, + y_true: pd.Series, + y_pred: Union[pd.Series, pd.DataFrame] = None, + pos_label: Union[str, float] = None, + overpredicted: bool = True, + scoring: Union[str, ScoringFunction] = "Bernoulli", + num_iters: int = 10, + penalty: float = 1e-17, + mode: str = "binary", + **kwargs, +): + """ + scan to find the highest scoring subset of records (see demo_mdss_detector.ipynb for example usage) + + :param X (dataframe): the dataset (containing the features) the model was trained on + :param y_true (series): ground truth (correct) target values + :param y_pred (series, dataframe, optional): pandas series estimated targets + as returned by a model for binary, continuous and ordinal modes. + If mode is nominal, this is a dataframe with columns containing expectations/predictions for each nominal class. + If None, model is assumed to be a dumb model that predicts the mean of the targets + or 1/(num of categories) for nominal mode. + :param pos_label (str, float, optional): Should be high or low or float if the mode in [binary, ordinal, or continuous]. + If float, value has to be minimum or maximum in the y_true column. Defaults to high if None for these modes. + Support for float left in to keep the intuition clear in binary classification tasks. + If mode is nominal, favorable values should be one of the unique categories in the y_true column. + Defaults to a one-vs-all scan if None for nominal mode. + :param overpredicted (bool, optional): flag for group to scan for. + True means we scan for a group whose expectations/predictions are systematically higher than observed. + In other words, True means we scan for a group whose observeed is systematically lower than the expectations. + False means we scan for a group whose expectations/predictions are systematically lower than observed. + In other words, False means we scan for a group whose observed is systematically higher than the expectations. + :param scoring (str or class): One of 'Bernoulli', 'Gaussian', 'Poisson', or 'BerkJones' or subclass of + :class:`aif360.metrics.mdss.ScoringFunctions.ScoringFunction`. + :param num_iters (int, optional): number of iterations (random restarts). Should be positive. + :param penalty (float,optional): penalty term. Should be positive. The penalty term as with any regularization parameter may need to be + tuned for ones use case. The higher the penalty, the less complex (number of features and feature values) the + highest scoring subset that gets returned is. + :param mode: one of ['binary', 'continuous', 'nominal', 'ordinal']. Defaults to binary. + In nominal mode, up to 10 categories are supported by default. + To increase this, pass in keyword argument max_nominal = integer value. + + :returns: the highest scoring subset and the score or dict of the highest scoring subset and the score for each category in nominal mode + """ + return bias_scan( + data=X, + observations=y_true, + expectations=y_pred, + favorable_value=pos_label, + overpredicted=overpredicted, + scoring=scoring, + num_iters=num_iters, + penalty=penalty, + mode=mode, + kwargs=kwargs + ) diff --git a/aif360/sklearn/metrics/metrics.py b/aif360/sklearn/metrics/metrics.py index a34003ad..27bebdb5 100644 --- a/aif360/sklearn/metrics/metrics.py +++ b/aif360/sklearn/metrics/metrics.py @@ -6,11 +6,11 @@ from sklearn.metrics import multilabel_confusion_matrix from sklearn.neighbors import NearestNeighbors from sklearn.utils import check_X_y -from sklearn.exceptions import UndefinedMetricWarning +from sklearn.exceptions import UndefinedMetricWarning, deprecated from aif360.sklearn.utils import check_groups -from aif360.metrics.mdss.ScoringFunctions import Bernoulli -from aif360.metrics.mdss.MDSS import MDSS +from aif360.detectors.mdss.ScoringFunctions import BerkJones, Bernoulli +from aif360.detectors.mdss.MDSS import MDSS __all__ = [ # meta-metrics @@ -420,60 +420,123 @@ def average_odds_error(y_true, y_pred, prot_attr=None, pos_label=1, return (abs(tpr_diff) + abs(fpr_diff)) / 2 -def mdss_bias_score(y_true, y_pred, pos_label=1, privileged=True, num_iters = 10): - """ - compute the bias score for a prespecified group of records with each observation's likelihood - is assumed to Bernoulli distributed and independent. - :param y_true (array-like): ground truth (correct) target values - :param y_pred (array-like): estimated targets as returned by a classifier - :param pos_label (scalar, optional): label of the positive class - :param privileged (bool, optional): flag for group to score - privileged group (True) or unprivileged group (False) - :param num_iters (scalar, optional): number of iterations - """ - xor_op = privileged ^ bool(pos_label) - direction = 'positive' if xor_op else 'negative' +def mdss_bias_score(y_true, probas_pred, X=None, subset=None, *, pos_label=1, + scoring='Bernoulli', privileged=True, penalty=1e-17, + **kwargs): + """Compute the bias score for a prespecified group of records using a + given scoring function. - dummy_subset = dict({'index': range(len(y_true))}) - expected = pd.Series(y_pred) - outcomes = pd.Series(y_true) - coordinates = pd.DataFrame(dummy_subset, index=expected.index) + Args: + y_true (array-like): Ground truth (correct) target values. + probas_pred (array-like): Probability estimates of the positive class. + X (DataFrame, optional): The dataset (containing the features) that was + used to predict `probas_pred`. If not specified, the subset is + returned as indices. + subset (dict, optional): Mapping of column names to list of values. + Samples are included in the subset if they match any value in each + of the columns provided. If `X` is not specified, `subset` may + be of the form `{'index': [0, 1, ...]}` or `None`. If `None`, score + over the full set (note: `penalty` is irrelevant in this case). + pos_label (scalar, optional): Label of the positive class. + scoring (str or class): One of 'Bernoulli' or 'BerkJones' or + subclass of + :class:`aif360.metrics.mdss.ScoringFunctions.ScoringFunction`. + privileged (bool): Flag for which direction to scan: privileged + (``True``) implies negative (observed worse than predicted outcomes) + while unprivileged (``False``) implies positive (observed better + than predicted outcomes). + penalty (scalar): Penalty coefficient. Should be positive. The higher + the penalty, the less complex (number of features and feature + values) the highest scoring subset that gets returned is. + **kwargs: Additional kwargs to be passed to `scoring` (not including + `direction`). + Returns: + float: Bias score for the given group. + See also: + :func:`mdss_bias_scan` + """ - scoring_function = Bernoulli(direction=direction) - scanner = MDSS(scoring_function) + if X is None: + X = pd.DataFrame({'index': range(len(y_true))}) + else: + X = X.reset_index(drop=True) # match all indices - return scanner.score_current_subset(coordinates, expected, outcomes, dummy_subset, 1e-17) + expected = pd.Series(probas_pred).reset_index(drop=True) + outcomes = pd.Series(y_true == pos_label, dtype=int).reset_index(drop=True) + direction = 'negative' if privileged else 'positive' + kwargs['direction'] = direction -def mdss_bias_scan(y_true, y_pred, dataset=None, pos_label=1, privileged=True, num_iters = 10, penalty = 0.0): - """ - scan to find the highest scoring subset of records. each observation's likelihood is - assumed to Bernoulli distributed and independent. - :param y_true (array-like): ground truth (correct) target values - :param y_pred (array-like): estimated targets as returned by a classifier - :param dataset (dataframe optional): the dataset (containing the features) the classifier was trained on. If not specified, the subset is returned as indices. - :param pos_label (scalar, optional): label of the positive class - :param privileged (bool, optional): flag for group to scan for - privileged group (True) or unprivileged group (False) - :param num_iters (scalar, optional): number of iterations - :param penalty: penalty coefficient. Should be positive - """ + if scoring == 'Bernoulli': + scoring_function = Bernoulli(**kwargs) + elif scoring == 'BerkJones': + scoring_function = BerkJones(**kwargs) + else: + scoring_function = scoring(**kwargs) + scanner = MDSS(scoring_function) - xor_op = privileged ^ bool(pos_label) - direction = 'positive' if xor_op else 'negative' + return scanner.score_current_subset(X, expected, outcomes, subset or {}, penalty) - expected = pd.Series(y_pred) - outcomes = pd.Series(y_true) +@deprecated('Change to new interface - aif360.sklearn.detectors.mdss_detector.bias_scan by version 0.5.0.') +def mdss_bias_scan(y_true, probas_pred, X=None, *, pos_label=1, + scoring='Bernoulli', privileged=True, n_iter=10, + penalty=1e-17, **kwargs): + """Scan to find the highest scoring subset of records. - if dataset is not None: - coordinates = dataset + Bias scan is a technique to identify bias in predictive models using subset + scanning [#zhang16]_. + Args: + y_true (array-like): Ground truth (correct) target values. + probas_pred (array-like): Probability estimates of the positive class. + X (dataframe, optional): The dataset (containing the features) that was + used to predict `probas_pred`. If not specified, the subset is + returned as indices. + pos_label (scalar): Label of the positive class. + scoring (str or class): One of 'Bernoulli' or 'BerkJones' or + subclass of + :class:`aif360.metrics.mdss.ScoringFunctions.ScoringFunction`. + privileged (bool): Flag for which direction to scan: privileged + (``True``) implies negative (observed worse than predicted outcomes) + while unprivileged (``False``) implies positive (observed better + than predicted outcomes). + n_iter (scalar): Number of iterations (random restarts). + penalty (scalar): Penalty coefficient. Should be positive. The higher + the penalty, the less complex (number of features and feature + values) the highest scoring subset that gets returned is. + **kwargs: Additional kwargs to be passed to `scoring` (not including + `direction`). + Returns: + tuple: + Highest scoring subset and its bias score + * **subset** (dict) -- Mapping of features to values defining the + highest scoring subset. + * **score** (float) -- Bias score for that group. + See also: + :func:`mdss_bias_score` + References: + .. [#zhang16] `Zhang, Z. and Neill, D. B., "Identifying significant + predictive bias in classifiers," arXiv preprint, 2016. + `_ + """ + if X is None: + X = pd.DataFrame({'index': range(len(y_true))}) else: - dummy_subset = dict({'index': range(len(y_true))}) - coordinates = pd.DataFrame(dummy_subset, index=expected.index) + X = X.reset_index(drop=True) # match all indices + expected = pd.Series(probas_pred).reset_index(drop=True) + outcomes = pd.Series(y_true == pos_label, dtype=int).reset_index(drop=True) - scoring_function = Bernoulli(direction=direction) + direction = 'negative' if privileged else 'positive' + kwargs['direction'] = direction + if scoring == 'Bernoulli': + scoring_function = Bernoulli(**kwargs) + elif scoring == 'BerkJones': + scoring_function = BerkJones(**kwargs) + else: + scoring_function = scoring(**kwargs) scanner = MDSS(scoring_function) - return scanner.scan(coordinates, expected, outcomes, penalty, num_iters) + return scanner.scan(X, expected, outcomes, penalty, n_iter) # ========================== INDIVIDUAL FAIRNESS =============================== diff --git a/examples/demo_mdss_classifier_metric.ipynb b/examples/demo_mdss_classifier_metric.ipynb index c338bfc5..1731026c 100644 --- a/examples/demo_mdss_classifier_metric.ipynb +++ b/examples/demo_mdss_classifier_metric.ipynb @@ -40,24 +40,11 @@ "metadata": {}, "outputs": [], "source": [ - "import sys\n", "import itertools\n", - "sys.path.append(\"../\")" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ + "\n", "from aif360.metrics import BinaryLabelDatasetMetric \n", "from aif360.metrics.mdss_classification_metric import MDSSClassificationMetric\n", - "from aif360.metrics.mdss.ScoringFunctions.Bernoulli import Bernoulli\n", - "\n", - "\n", "from aif360.algorithms.preprocessing.optim_preproc_helpers.data_preproc_functions import load_preproc_data_compas\n", - "from aif360.algorithms.inprocessing.meta_fair_classifier import MetaFairClassifier\n", "\n", "from IPython.display import Markdown, display\n", "import numpy as np\n", @@ -66,7 +53,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -84,7 +71,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -106,7 +93,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -125,7 +112,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -134,7 +121,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "metadata": { "scrolled": true }, @@ -227,7 +214,7 @@ "4 0.0 1.0 1.0 0.0 0.0 0.0" ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -246,7 +233,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -259,7 +246,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -268,7 +255,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "metadata": {}, "outputs": [ { @@ -383,14 +370,7 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -430,7 +410,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -439,7 +419,7 @@ "LogisticRegression()" ] }, - "execution_count": 12, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -450,18 +430,25 @@ "clf.fit(dataset_orig_train.features, dataset_orig_train.labels.flatten())" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the probability scores we use are the probabilities of the favorable label, which is 0 in this case." + ] + }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ - "dataset_bias_test_prob = clf.predict_proba(dataset_orig_test.features)[:,1]" + "dataset_bias_test_prob = clf.predict_proba(dataset_orig_test.features)[:,0]" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -479,7 +466,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -492,15 +479,302 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### bias scoring\n", + "### bias scoring" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, we try to observe the difference between the model prediction and the actual observations of the favorable label, which in this case is 0. We create a new test_df for this computation. \n", "\n", - "We'll create an instance of the MDSS Classification Metric and assess the apriori defined privileged and unprivileged groups; females and males respectively." + "If the model's average prediction of the favorable label is higher than the actual observations average, then the group is said to be privileged. In the converse case, the group is said to be unprivileged.\n", + "\n", + "We would check for whether the male and female groups are privileged or not using mdss score" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "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", + " \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", + "
sexraceage_catpriors_countc_charge_degreetwo_year_recidmodel_not_recidobserved_not_recid
24791.01.02.02.00.01.00.5529450.0
35741.00.01.00.00.00.00.7409601.0
5130.01.00.01.00.00.00.3747341.0
17250.00.02.02.00.01.00.4444860.0
960.01.01.01.01.01.00.5849040.0
...........................
49310.01.00.01.00.00.00.3747341.0
32640.00.00.00.00.01.00.5357620.0
16530.00.01.01.00.00.00.4900411.0
26071.01.01.00.00.01.00.7691410.0
27320.01.00.02.01.01.00.2517240.0
\n", + "

1584 rows × 8 columns

\n", + "
" + ], + "text/plain": [ + " sex race age_cat priors_count c_charge_degree two_year_recid \\\n", + "2479 1.0 1.0 2.0 2.0 0.0 1.0 \n", + "3574 1.0 0.0 1.0 0.0 0.0 0.0 \n", + "513 0.0 1.0 0.0 1.0 0.0 0.0 \n", + "1725 0.0 0.0 2.0 2.0 0.0 1.0 \n", + "96 0.0 1.0 1.0 1.0 1.0 1.0 \n", + "... ... ... ... ... ... ... \n", + "4931 0.0 1.0 0.0 1.0 0.0 0.0 \n", + "3264 0.0 0.0 0.0 0.0 0.0 1.0 \n", + "1653 0.0 0.0 1.0 1.0 0.0 0.0 \n", + "2607 1.0 1.0 1.0 0.0 0.0 1.0 \n", + "2732 0.0 1.0 0.0 2.0 1.0 1.0 \n", + "\n", + " model_not_recid observed_not_recid \n", + "2479 0.552945 0.0 \n", + "3574 0.740960 1.0 \n", + "513 0.374734 1.0 \n", + "1725 0.444486 0.0 \n", + "96 0.584904 0.0 \n", + "... ... ... \n", + "4931 0.374734 1.0 \n", + "3264 0.535762 0.0 \n", + "1653 0.490041 1.0 \n", + "2607 0.769141 0.0 \n", + "2732 0.251724 0.0 \n", + "\n", + "[1584 rows x 8 columns]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_df = dataset_bias_test.convert_to_dataframe()[0]\n", + "test_df['model_not_recid'] = dataset_bias_test.scores.flatten()\n", + "test_df['observed_not_recid'] = 1 - test_df['two_year_recid']\n", + "test_df" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "model_not_recid 0.617559\n", + "observed_not_recid 0.657051\n", + "dtype: float64" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Females actual vs predicted rates of positive label\n", + "test_df[test_df.sex == 1][['model_not_recid','observed_not_recid']].mean()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Since model average predictions for the positive label is lower than the observed average by a substantial amount (about 4%), the female group is most likely unprivileged." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "model_not_recid 0.512445\n", + "observed_not_recid 0.497642\n", + "dtype: float64" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Males actual vs predicted rates of positive label\n", + "test_df[test_df.sex == 0][['model_not_recid','observed_not_recid']].mean()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Since model average predictions for the positive label is greater than the observed average by a small amount (about 1.5%), the male group could be privileged." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, we'll create an instance of the MDSS Classification Metric and assess the apriori defined privileged and unprivileged groups; females and males respectively. \n", + "\n", + "By apriori defining the male group as unprivileged, we are saying we expect that the model's predictions is systematically lower than the actual observation.\n", + "\n", + "By apriori defining the female group as privileged, we are saying we expect that the model's predictions is systematically higher than the actual observation.\n", + "\n", + "From our mini-analysis above, we know that these hypothesis are unlikely to be true " + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, "outputs": [], "source": [ "mdss_classified = MDSSClassificationMetric(dataset_orig_test, dataset_bias_test,\n", @@ -510,42 +784,55 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 19, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "-1e-17" + "-0.0" ] }, - "execution_count": 17, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ + "# We are asking the question:\n", + "# Is there evidence that the hypothesized privileged group is actually privileged?\n", + "\n", "female_privileged_score = mdss_classified.score_groups(privileged=True)\n", "female_privileged_score" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By having a score very close to zero, mdss bias score is informing us that there is no evidence from the data that our hypothesis of the female group being privileged is true." + ] + }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 20, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "-1e-17" + "-0.0" ] }, - "execution_count": 18, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ + "# We are asking the question:\n", + "# Is there evidence that the hypothesized unprivileged group is actually unprivileged?\n", + "\n", "male_unprivileged_score = mdss_classified.score_groups(privileged=False)\n", "male_unprivileged_score" ] @@ -554,12 +841,19 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "It appears there is no multiplicative increase in the odds for females thus no bias towards females and the bias score is negligible. Similarly there is no multiplicative decrease in the odds for males. We can alternate our assumptions of priviledge and unprivileged groups to see if there is some bias." + "By having a score very close zero, mdss bias score is informing us that there is no evidence from the data to support our hypothesis of the male group being unprivileged is true." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can flip our initial hypothesis and check if the male group is privileged or the female group is unprivileged." ] }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 21, "metadata": {}, "outputs": [], "source": [ @@ -570,16 +864,16 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "0.630108034329993" + "0.6301" ] }, - "execution_count": 20, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } @@ -589,18 +883,25 @@ "male_privileged_score" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By having a positive score, mdss bias score is informing us that there is evidence from the data that our hypothesis of the male group being privileged is true." + ] + }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 23, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "1.17706135776708" + "1.1771" ] }, - "execution_count": 21, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -614,7 +915,18 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "It appears there is some multiplicative increase in the odds of recidivism for male and a multiplicative decrease in the odds for females." + "By having a positive score, mdss bias score is informing us that there is evidence from the data to support our hypothesis of the female group being unprivileged is true." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By taking into account the size of the group and the magnitude of the deviation, mdss bias core has been able to tell us the following about the male and female groups:\n", + "- There is no evidence that the female group is privileged.\n", + "- There is no evidence that the male group is unprivileged.\n", + "- There is evidence that the male group is privileged.\n", + "- There is evidence that the female is unprivileged." ] }, { @@ -628,9 +940,18 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 24, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Function bias_scan is deprecated; Change to new interface - aif360.detectors.mdss_detector.bias_scan by version 0.5.0.\n", + "Function bias_scan is deprecated; Change to new interface - aif360.detectors.mdss_detector.bias_scan by version 0.5.0.\n" + ] + } + ], "source": [ "privileged_subset = mdss_classified.bias_scan(penalty=0.5, privileged=True)\n", "unprivileged_subset = mdss_classified.bias_scan(penalty=0.5, privileged=False)" @@ -638,15 +959,15 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 25, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "({'race': [0.0], 'age_cat': [0.0], 'sex': [0.0]}, 3.153105510860506)\n", - "({'sex': [1.0], 'race': [0.0]}, 3.303741512514005)\n" + "({'race': [0.0], 'age_cat': [0.0], 'sex': [0.0]}, 3.1531)\n", + "({'sex': [1.0], 'race': [0.0]}, 3.3037)\n" ] } ], @@ -657,7 +978,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 26, "metadata": {}, "outputs": [], "source": [ @@ -683,7 +1004,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 27, "metadata": {}, "outputs": [], "source": [ @@ -699,7 +1020,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 28, "metadata": {}, "outputs": [ { @@ -756,7 +1077,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "['race', 'sex', 'age_cat']\n" + "['sex', 'race', 'age_cat']\n" ] }, { @@ -814,7 +1135,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 29, "metadata": {}, "outputs": [], "source": [ @@ -836,14 +1157,14 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 30, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Test set: Difference in mean outcomes between unprivileged and privileged groups = 0.275836\n" + "Test set: Difference in mean outcomes between unprivileged and privileged groups = 0.345722\n" ] } ], @@ -873,7 +1194,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 31, "metadata": {}, "outputs": [], "source": [ @@ -883,37 +1204,37 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 32, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'Our detected priviledged group has a size of 192, we observe 0.6770833333333334 as the average risk of recidivism, but our model predicts 0.5730004938240804'" + "'Our detected priviledged group has a size of 192, we observe 0.6770833333333334 as the average risk of recidivism, but our model predicts 0.5730004938240802'" ] }, - "execution_count": 31, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } ], "source": [ "\"Our detected priviledged group has a size of {}, we observe {} as the average risk of recidivism, but our model predicts {}\"\\\n", - ".format(len(temp_df), temp_df['observed'].mean(), temp_df['probabilities'].mean())" + ".format(len(temp_df), temp_df['observed'].mean(), 1 - temp_df['probabilities'].mean())" ] }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 33, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'This is a multiplicative increase in the odds by 1.56251443909305'" + "'This is a multiplicative increase in the odds by 2.81370969044125'" ] }, - "execution_count": 32, + "execution_count": 33, "metadata": {}, "output_type": "execute_result" } @@ -929,7 +1250,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 34, "metadata": {}, "outputs": [], "source": [ @@ -938,7 +1259,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 35, "metadata": {}, "outputs": [], "source": [ @@ -948,7 +1269,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 36, "metadata": {}, "outputs": [ { @@ -957,28 +1278,28 @@ "'Our detected unpriviledged group has a size of 169, we observe 0.33136094674556216 as the average risk of recidivism, but our model predicts 0.43652313575727764'" ] }, - "execution_count": 35, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } ], "source": [ "\"Our detected unpriviledged group has a size of {}, we observe {} as the average risk of recidivism, but our model predicts {}\"\\\n", - ".format(len(temp_df), temp_df['observed'].mean(), temp_df['probabilities'].mean())" + ".format(len(temp_df), temp_df['observed'].mean(), 1 - temp_df['probabilities'].mean())" ] }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 37, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'This is a multiplicative decrease in the odds by 0.6397030278261826'" + "'This is a multiplicative decrease in the odds by 0.38392002104569445'" ] }, - "execution_count": 36, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -994,7 +1315,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 38, "metadata": {}, "outputs": [], "source": [ @@ -1018,10 +1339,13 @@ } ], "metadata": { + "interpreter": { + "hash": "a7b8e4082fc046e7b321ebd13577b0b02bbec122b09da65f91f262e840b142f2" + }, "kernelspec": { "display_name": "aif360", "language": "python", - "name": "aif360" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -1033,7 +1357,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.7" + "version": "3.8.12" } }, "nbformat": 4, diff --git a/examples/demo_mdss_detector.ipynb b/examples/demo_mdss_detector.ipynb new file mode 100644 index 00000000..465abbed --- /dev/null +++ b/examples/demo_mdss_detector.ipynb @@ -0,0 +1,1541 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Bias scan using Multi-Dimensional Subset Scan (MDSS)\n", + "\n", + "\"Identifying Significant Predictive Bias in Classifiers\" https://arxiv.org/abs/1611.08292\n", + "\n", + "The goal of bias scan is to identify a subgroup(s) that has significantly more predictive bias than would be expected from an unbiased classifier. There are $\\prod_{m=1}^{M}\\left(2^{|X_{m}|}-1\\right)$ unique subgroups from a dataset with $M$ features, with each feature having $|X_{m}|$ discretized values, where a subgroup is any $M$-dimension\n", + "Cartesian set product, between subsets of feature-values from each feature --- excluding the empty set. Bias scan mitigates this computational hurdle by approximately identifing the most statistically biased subgroup in linear time (rather than exponential).\n", + "\n", + "\n", + "We define the statistical measure of predictive bias function, $score_{bias}(S)$ as a likelihood ratio score and a function of a given subgroup $S$. The null hypothesis is that the given prediction's odds are correct for all subgroups in\n", + "\n", + "$\\mathcal{D}$: $H_{0}:odds(y_{i})=\\frac{\\hat{p}_{i}}{1-\\hat{p}_{i}}\\ \\forall i\\in\\mathcal{D}$.\n", + "\n", + "The alternative hypothesis assumes some constant multiplicative bias in the odds for some given subgroup $S$:\n", + "\n", + "\n", + "$H_{1}:\\ odds(y_{i})=q\\frac{\\hat{p}_{i}}{1-\\hat{p}_{i}},\\ \\text{where}\\ q>1\\ \\forall i\\in S\\ \\mbox{and}\\ q=1\\ \\forall i\\notin S.$\n", + "\n", + "In the classification setting, each observation's likelihood is Bernoulli distributed and assumed independent. This results in the following scoring function for a subgroup $S$\n", + "\n", + "\\begin{align*}\n", + "score_{bias}(S)= & \\max_{q}\\log\\prod_{i\\in S}\\frac{Bernoulli(\\frac{q\\hat{p}_{i}}{1-\\hat{p}_{i}+q\\hat{p}_{i}})}{Bernoulli(\\hat{p}_{i})}\\\\\n", + "= & \\max_{q}\\log(q)\\sum_{i\\in S}y_{i}-\\sum_{i\\in S}\\log(1-\\hat{p}_{i}+q\\hat{p}_{i}).\n", + "\\end{align*}\n", + "Our bias scan is thus represented as: $S^{*}=FSS(\\mathcal{D},\\mathcal{E},F_{score})=MDSS(\\mathcal{D},\\hat{p},score_{bias})$.\n", + "\n", + "where $S^{*}$ is the detected most anomalous subgroup, $FSS$ is one of several subset scan algorithms for different problem settings, $\\mathcal{D}$ is a dataset with outcomes $Y$ and discretized features $\\mathcal{X}$, $\\mathcal{E}$ are a set of expectations or 'normal' values for $Y$, and $F_{score}$ is an expectation-based scoring statistic that measures the amount of anomalousness between subgroup observations and their expectations.\n", + "\n", + "Predictive bias emphasizes comparable predictions for a subgroup and its observations and Bias scan provides a more general method that can detect and characterize such bias, or poor classifier fit, in the larger space of all possible subgroups, without a priori specification." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Usage\n", + "\n", + "MDScan currently supports three scoring functions. These scoring functions usage are described below:\n", + "- *BerkJones*: Non-parametric scoring function. To be used for all of the four types of outcomes supported - binary, continuous, nominal, ordinal.\n", + "- *Bernoulli*: Parametric scoring function. To used for two of the four types of outcomes supported - binary and nominal.\n", + "- *Guassian*: Parametric scoring function. To used for one of the four types of outcomes supported - continuous.\n", + "- *Poisson*: Parametric scoring function. To be used for three of the four types of outcomes supported - binary, continuous, and ordinal.\n", + "\n", + "Note, non-parametric scoring functions can only be used for datasets where the expectations are constant or none.\n", + "\n", + "The type of outcomes must be provided using the mode keyword argument. The definition for the four types of outcomes supported are provided below:\n", + "- Binary: Yes/no outcomes. Outcomes must 0 or 1.\n", + "- Continuous: Continuous outcomes. Outcomes could be any real number.\n", + "- Nominal: Multiclass outcomes with no rank or order between them. Outcomes must be a finite set of integers with dimensionality <= 10.\n", + "- Ordinal: Multiclass outcomes that are ranked in a specific order. Outcomes must be positive integers.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from aif360.detectors.mdss_detector import bias_scan\n", + "from aif360.algorithms.preprocessing.optim_preproc_helpers.data_preproc_functions import load_preproc_data_compas\n", + "\n", + "import numpy as np\n", + "import pandas as pd" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We'll demonstrate finding the most anomalous subset with bias scan using the compas dataset. We can specify subgroups to be scored or scan for the most anomalous subgroup. Bias scan allows us to decide if we aim to identify bias as `higher` than expected probabilities or `lower` than expected probabilities." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Compas Dataset\n", + "This is a binary classification use case where the favorable label is 0 and the scoring function is the default bernoulli." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "np.random.seed(0)\n", + "\n", + "dataset_orig = load_preproc_data_compas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The dataset has the categorical features one-hot encoded so we'll modify the dataset to convert them back \n", + "to the categorical featues because scanning one-hot encoded features may find subgroups that are not meaningful eg. a subgroup with 2 race values. " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_orig_df = pd.DataFrame(dataset_orig.features, columns=dataset_orig.feature_names)\n", + "\n", + "age_cat = np.argmax(dataset_orig_df[['age_cat=Less than 25', 'age_cat=25 to 45', \n", + " 'age_cat=Greater than 45']].values, axis=1).reshape(-1, 1)\n", + "priors_count = np.argmax(dataset_orig_df[['priors_count=0', 'priors_count=1 to 3', \n", + " 'priors_count=More than 3']].values, axis=1).reshape(-1, 1)\n", + "c_charge_degree = np.argmax(dataset_orig_df[['c_charge_degree=F', 'c_charge_degree=M']].values, axis=1).reshape(-1, 1)\n", + "\n", + "features = np.concatenate((dataset_orig_df[['sex', 'race']].values, age_cat, priors_count, \\\n", + " c_charge_degree, dataset_orig.labels), axis=1)\n", + "feature_names = ['sex', 'race', 'age_cat', 'priors_count', 'c_charge_degree']" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "df = pd.DataFrame(features, columns=feature_names + ['two_year_recid'])" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "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", + "
sexraceage_catpriors_countc_charge_degreetwo_year_recid
00.00.01.00.00.01.0
10.00.00.02.00.01.0
20.01.01.02.00.01.0
31.01.01.00.01.00.0
40.01.01.00.00.00.0
\n", + "
" + ], + "text/plain": [ + " sex race age_cat priors_count c_charge_degree two_year_recid\n", + "0 0.0 0.0 1.0 0.0 0.0 1.0\n", + "1 0.0 0.0 0.0 2.0 0.0 1.0\n", + "2 0.0 1.0 1.0 2.0 0.0 1.0\n", + "3 1.0 1.0 1.0 0.0 1.0 0.0\n", + "4 0.0 1.0 1.0 0.0 0.0 0.0" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### training\n", + "We'll train a simple classifier to predict the probability of the outcome" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "LogisticRegression()" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from sklearn.linear_model import LogisticRegression\n", + "X = df.drop('two_year_recid', axis = 1)\n", + "y = df['two_year_recid']\n", + "clf = LogisticRegression(solver='lbfgs', C=1.0, penalty='l2')\n", + "clf.fit(X, y)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the probability scores we use are the probabilities of the favorable label, which is 0 in this case." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "probs = pd.Series(clf.predict_proba(X)[:,0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### bias scan\n", + "We can scan for a privileged and unprivileged subset using bias scan" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=X,observations=y,expectations=probs,favorable_value=0, overpredicted=True)\n", + "unprivileged_subset = bias_scan(data=X,observations=y,expectations=probs,favorable_value=0,overpredicted=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'age_cat': [1.0], 'priors_count': [0.0, 1.0, 2.0], 'sex': [1.0], 'race': [1.0], 'c_charge_degree': [0.0]}, 7.9086)\n", + "({'race': [0.0], 'age_cat': [1.0, 2.0], 'priors_count': [1.0], 'c_charge_degree': [0.0, 1.0]}, 7.0227)\n" + ] + } + ], + "source": [ + "print(privileged_subset)\n", + "print(unprivileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "dff = X.copy()\n", + "dff['observed'] = y \n", + "dff['probabilities'] = 1 - probs" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "to_choose = dff[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = dff.loc[to_choose]" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected priviledged group has a size of 147, we observe 0.5374149659863946 as the average risk of recidivism, but our model predicts 0.3827815971689547'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\"Our detected priviledged group has a size of {}, we observe {} as the average risk of recidivism, but our model predicts {}\"\\\n", + ".format(len(temp_df), temp_df['observed'].mean(), temp_df['probabilities'].mean())" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "to_choose = dff[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = dff.loc[to_choose]" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected priviledged group has a size of 732, we observe 0.3770491803278688 as the average risk of recidivism, but our model predicts 0.44470388217799317'" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\"Our detected priviledged group has a size of {}, we observe {} as the average risk of recidivism, but our model predicts {}\"\\\n", + ".format(len(temp_df), temp_df['observed'].mean(), temp_df['probabilities'].mean())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Adult Dataset\n", + "This is a binary classification use case where the favorable label is 1 and the scoring function is the berk jones." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
workclasseducationmarital_statusoccupationrelationshipracesexnative_countryage_bineducation_num_binhours_per_week_bincapital_gain_bincapital_loss_binobservedexpectation
0Private11thNever-marriedMachine-op-inspctOwn-childBlackMaleUnited-States17-271-840-440000.236226
1PrivateHS-gradMarried-civ-spouseFarming-fishingHusbandWhiteMaleUnited-States37-47945-990000.236226
2Local-govAssoc-acdmMarried-civ-spouseProtective-servHusbandWhiteMaleUnited-States28-3612-1640-440010.236226
3PrivateSome-collegeMarried-civ-spouseMachine-op-inspctHusbandBlackMaleUnited-States37-4710-1140-447298-7978010.236226
4?Some-collegeNever-married?Own-childWhiteFemaleUnited-States17-2710-111-390000.236226
\n", + "
" + ], + "text/plain": [ + " workclass education marital_status occupation \\\n", + "0 Private 11th Never-married Machine-op-inspct \n", + "1 Private HS-grad Married-civ-spouse Farming-fishing \n", + "2 Local-gov Assoc-acdm Married-civ-spouse Protective-serv \n", + "3 Private Some-college Married-civ-spouse Machine-op-inspct \n", + "4 ? Some-college Never-married ? \n", + "\n", + " relationship race sex native_country age_bin education_num_bin \\\n", + "0 Own-child Black Male United-States 17-27 1-8 \n", + "1 Husband White Male United-States 37-47 9 \n", + "2 Husband White Male United-States 28-36 12-16 \n", + "3 Husband Black Male United-States 37-47 10-11 \n", + "4 Own-child White Female United-States 17-27 10-11 \n", + "\n", + " hours_per_week_bin capital_gain_bin capital_loss_bin observed expectation \n", + "0 40-44 0 0 0 0.236226 \n", + "1 45-99 0 0 0 0.236226 \n", + "2 40-44 0 0 1 0.236226 \n", + "3 40-44 7298-7978 0 1 0.236226 \n", + "4 1-39 0 0 0 0.236226 " + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data = pd.read_csv('https://gist.githubusercontent.com/Viktour19/b690679802c431646d36f7e2dd117b9e/raw/d8f17bf25664bd2d9fa010750b9e451c4155dd61/adult_autostrat.csv')\n", + "data.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that for the adult dataset, the positive label is 1 and thus the expectations provided is the probability of the earning >50k i.e label 1 and the favorable label is 1 which is the default for binary classification tasks. Since we would be using scoring function BerkJones, we also need to pass in an alpha value. Alpha can be interpreted as what proportion of the data you expect to have the favorable value" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "X = data.drop(['observed','expectation'], axis = 1)\n", + "probs = data['expectation']\n", + "y = data['observed']" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=X, observations=y, scoring='BerkJones', expectations=probs, overpredicted=True,penalty=50, alpha = .24)\n", + "unprivileged_subset = bias_scan(data=X,observations=y, scoring='BerkJones', expectations=probs, overpredicted=False,penalty=50, alpha = .24)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'relationship': [' Not-in-family', ' Other-relative', ' Own-child', ' Unmarried'], 'capital_gain_bin': ['0']}, 932.4812)\n", + "({'education_num_bin': ['12-16'], 'marital_status': [' Married-civ-spouse']}, 1041.1901)\n" + ] + } + ], + "source": [ + "print(privileged_subset)\n", + "print(unprivileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "dff = X.copy()\n", + "dff['observed'] = y \n", + "dff['probabilities'] = probs" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 8532, we observe 0.0472 as the average probability of earning >50k, but our model predicts 0.2362'" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = dff[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = dff.loc[to_choose]\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the average probability of earning >50k, but our model predicts {}\"\\\n", + ".format(len(temp_df), np.round(temp_df['observed'].mean(),4), np.round(temp_df['probabilities'].mean(),4))" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected unprivileged group has a size of 2430, we observe 0.6996 as the average probability of earning >50k, but our model predicts 0.2362'" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = dff[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = dff.loc[to_choose]\n", + "\n", + "\"Our detected unprivileged group has a size of {}, we observe {} as the average probability of earning >50k, but our model predicts {}\"\\\n", + ".format(len(temp_df), np.round(temp_df['observed'].mean(),4), np.round(temp_df['probabilities'].mean(),4))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Insurance Costs\n", + "This is a regression use case where the favorable value is 0 and the scoring function is Gaussian." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(1338, 7)" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data = pd.read_csv('https://raw.githubusercontent.com/Adebayo-Oshingbesan/data/main/insurance.csv')\n", + "data.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "for col in ['bmi','age']:\n", + " data[col] = pd.qcut(data[col], 10, duplicates='drop')\n", + " data[col] = data[col].apply(lambda x: str(round(x.left, 2)) + ' - ' + str(round(x.right,2)))" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "features = data.drop('charges', axis = 1)\n", + "X = features.copy()\n", + "\n", + "for feature in X.columns:\n", + " X[feature] = X[feature].astype('category').cat.codes\n", + "\n", + "y = data['charges']" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.linear_model import LinearRegression\n", + "reg = LinearRegression()\n", + "reg.fit(X, y)\n", + "y_pred = pd.Series(reg.predict(X))" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=features, observations=y, expectations=y_pred, scoring = 'Gaussian', \n", + " overpredicted=True, penalty=1e10, mode ='continuous', favorable_value='low')\n", + "\n", + "unprivileged_subset = bias_scan(data=features, observations=y, expectations=y_pred, scoring = 'Gaussian', \n", + " overpredicted=False, penalty=1e10, mode ='continuous', favorable_value='low')" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'bmi': ['15.96 - 22.99', '22.99 - 25.33', '25.33 - 27.36'], 'smoker': ['no']}, 2384.5786)\n", + "({'bmi': ['15.96 - 22.99', '22.99 - 25.33', '25.33 - 27.36', '27.36 - 28.8'], 'smoker': ['yes']}, 3927.8765)\n" + ] + } + ], + "source": [ + "print(privileged_subset)\n", + "print(unprivileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 321, we observe 7844.840295856697 as the mean insurance costs, but our model predicts 5420.49326277455'" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = data[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = data.loc[to_choose].copy()\n", + "temp_y = y_pred.loc[to_choose].copy()\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the mean insurance costs, but our model predicts {}\"\\\n", + ".format(len(temp_df), temp_df['charges'].mean(), temp_y.mean())" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 115, we observe 21148.37389617392 as the mean insurance costs, but our model predicts 29694.035319112852'" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = data[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = data.loc[to_choose].copy()\n", + "temp_y = y_pred.loc[to_choose].copy()\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the mean insurance costs, but our model predicts {}\"\\\n", + ".format(len(temp_df), temp_df['charges'].mean(), temp_y.mean())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hospitalization Time\n", + "This is an ordinal, multiclass classification use case where the favorable value is 1 and the scoring function is Poisson." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(29980, 22)" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data = pd.read_csv('https://raw.githubusercontent.com/Adebayo-Oshingbesan/data/main/hospital.csv')\n", + "data = data[data['Length of Stay'] != '120 +'].fillna('Unknown')\n", + "data.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [], + "source": [ + "X = data.drop(['Length of Stay'], axis = 1)\n", + "y = pd.to_numeric(data['Length of Stay'])" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=X, observations=y, scoring = 'Poisson', favorable_value = 'low', overpredicted=True, penalty=50, mode ='ordinal')\n", + "unprivileged_subset = bias_scan(data=X, observations=y, scoring = 'Poisson', favorable_value = 'low', overpredicted=False, penalty=50, mode ='ordinal')" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'APR Severity of Illness Description': ['Extreme']}, 11180.5386)\n", + "({'Patient Disposition': ['Home or Self Care', 'Left Against Medical Advice', 'Short-term Hospital'], 'APR Severity of Illness Description': ['Minor', 'Moderate'], 'APR MDC Code': [1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 21]}, 9950.881)\n" + ] + } + ], + "source": [ + "print(privileged_subset)\n", + "print(unprivileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [], + "source": [ + "dff = X.copy()\n", + "dff['observed'] = y \n", + "dff['predicted'] = y.mean()" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 1900, we observe 15.2216 as the average number of days spent in the hospital, but our model predicts 5.4231'" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = dff[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = dff.loc[to_choose]\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the average number of days spent in the hospital, but our model predicts {}\"\\\n", + ".format(len(temp_df), np.round(temp_df['observed'].mean(),4), np.round(temp_df['predicted'].mean(),4))" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected unprivileged group has a size of 14620, we observe 2.8301 as the average number of days spent in the hospital, but our model predicts 5.4231'" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = dff[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = dff.loc[to_choose]\n", + "\n", + "\"Our detected unprivileged group has a size of {}, we observe {} as the average number of days spent in the hospital, but our model predicts {}\"\\\n", + ".format(len(temp_df), np.round(temp_df['observed'].mean(),4), np.round(temp_df['predicted'].mean(),4))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Temperature Dataset\n", + "This is a regression use case where the favorable value is the higher temperatures and the scoring function is Berk Jones." + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "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", + "
SummaryPrecipTypeHumidityWindSpeedVisibilityPressureDailySummaryTemperature
0Partly Cloudyrain0.8914.119715.82631015.13Partly cloudy throughout the day.9.472222
1Partly Cloudyrain0.8614.264615.82631015.63Partly cloudy throughout the day.9.355556
2Mostly Cloudyrain0.893.928414.95691015.94Partly cloudy throughout the day.9.377778
3Partly Cloudyrain0.8314.103615.82631016.41Partly cloudy throughout the day.8.288889
4Mostly Cloudyrain0.8311.044615.82631016.51Partly cloudy throughout the day.8.755556
\n", + "
" + ], + "text/plain": [ + " Summary PrecipType Humidity WindSpeed Visibility Pressure \\\n", + "0 Partly Cloudy rain 0.89 14.1197 15.8263 1015.13 \n", + "1 Partly Cloudy rain 0.86 14.2646 15.8263 1015.63 \n", + "2 Mostly Cloudy rain 0.89 3.9284 14.9569 1015.94 \n", + "3 Partly Cloudy rain 0.83 14.1036 15.8263 1016.41 \n", + "4 Mostly Cloudy rain 0.83 11.0446 15.8263 1016.51 \n", + "\n", + " DailySummary Temperature \n", + "0 Partly cloudy throughout the day. 9.472222 \n", + "1 Partly cloudy throughout the day. 9.355556 \n", + "2 Partly cloudy throughout the day. 9.377778 \n", + "3 Partly cloudy throughout the day. 8.288889 \n", + "4 Partly cloudy throughout the day. 8.755556 " + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data = pd.read_csv('https://raw.githubusercontent.com/Adebayo-Oshingbesan/data/main/weatherHistory.csv')\n", + "data.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Binning the continuous features since bias scan support only categorical features." + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [], + "source": [ + "for col in ['Humidity','WindSpeed','Visibility','Pressure']:\n", + " data[col] = pd.qcut(data[col], 10, duplicates='drop')\n", + " data[col] = data[col].apply(lambda x: str(round(x.left, 2)) + ' - ' + str(round(x.right,2)))" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [], + "source": [ + "features = data.drop('Temperature', axis = 1)\n", + "y = data['Temperature']" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=features, observations=y, favorable_value = 'high',\n", + " scoring = 'BerkJones', overpredicted=True, penalty=50, mode ='continuous', alpha = .4)\n", + "\n", + "unprivileged_subset = bias_scan(data=features, observations=y, favorable_value = 'high',\n", + " scoring = 'BerkJones', overpredicted=False, penalty=50, mode ='continuous', alpha = .4)" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'Pressure': ['-0.0 - 1007.07', '1018.17 - 1020.0', '1020.0 - 1022.42', '1022.42 - 1026.61', '1026.61 - 1046.38'], 'Humidity': ['0.72 - 0.78', '0.78 - 0.83', '0.83 - 0.87', '0.87 - 0.92', '0.92 - 0.95', '0.95 - 1.0']}, 6907.8227)\n", + "({'Visibility': ['9.9 - 9.98', '9.98 - 10.05', '10.05 - 11.04', '11.04 - 11.45', '11.45 - 15.15', '15.15 - 15.83', '15.83 - 16.1'], 'PrecipType': ['rain'], 'Pressure': ['-0.0 - 1007.07', '1007.07 - 1010.68', '1010.68 - 1012.95', '1012.95 - 1014.8', '1014.8 - 1016.45', '1016.45 - 1018.17', '1018.17 - 1020.0', '1020.0 - 1022.42']}, 19962.4291)\n" + ] + } + ], + "source": [ + "print(privileged_subset)\n", + "print(unprivileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 31607, we observe 5.155584909121915 as the mean temperature, but our model predicts 11.932678437519867'" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = data[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = data.loc[to_choose].copy()\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the mean temperature, but our model predicts {}\"\\\n", + ".format(len(temp_df), temp_df['Temperature'].mean(), y.mean())" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected unprivileged group has a size of 55642, we observe 16.773802762911167 as the mean temperature, but our model predicts 11.932678437519867'" + ] + }, + "execution_count": 43, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = data[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = data.loc[to_choose].copy()\n", + "\n", + "\"Our detected unprivileged group has a size of {}, we observe {} as the mean temperature, but our model predicts {}\"\\\n", + ".format(len(temp_df), temp_df['Temperature'].mean(), y.mean())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Iris Dataset\n", + "This is an nominal, multiclass classification use case where the favorable value is a flower specie and the scoring function is Bernoulli." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": {}, + "outputs": [ + { + "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", + "
SepalLengthCmSepalWidthCmPetalLengthCmPetalWidthCmSpecies
05.13.51.40.2Iris-setosa
14.93.01.40.2Iris-setosa
24.73.21.30.2Iris-setosa
34.63.11.50.2Iris-setosa
45.03.61.40.2Iris-setosa
\n", + "
" + ], + "text/plain": [ + " SepalLengthCm SepalWidthCm PetalLengthCm PetalWidthCm Species\n", + "0 5.1 3.5 1.4 0.2 Iris-setosa\n", + "1 4.9 3.0 1.4 0.2 Iris-setosa\n", + "2 4.7 3.2 1.3 0.2 Iris-setosa\n", + "3 4.6 3.1 1.5 0.2 Iris-setosa\n", + "4 5.0 3.6 1.4 0.2 Iris-setosa" + ] + }, + "execution_count": 44, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "iris_data = pd.read_csv('https://raw.githubusercontent.com/Adebayo-Oshingbesan/data/main/Iris.csv').drop('Id', axis = 1)\n", + "iris_data.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "metadata": {}, + "outputs": [], + "source": [ + "for col in iris_data.columns:\n", + " if col != 'Species':\n", + " iris_data[col] = pd.qcut(iris_data[col], 10, duplicates='drop')\n", + " iris_data[col] = iris_data[col].apply(lambda x: str(round(x.left, 2)) + ' - ' + str(round(x.right,2)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " Training simple model on data" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "metadata": {}, + "outputs": [], + "source": [ + "X = iris_data.drop('Species', axis = 1)\n", + "for col in X.columns:\n", + " X[col] = X[col].cat.codes\n", + "\n", + "y = iris_data['Species']" + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.linear_model import LogisticRegression\n", + "clf_2 = LogisticRegression(C=1e-3)\n", + "clf_2.fit(X, y)\n", + "iris_data['Prediction'] = clf_2.predict(X)" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "metadata": {}, + "outputs": [], + "source": [ + "features = iris_data.drop(['Species','Prediction'], axis = 1)\n", + "expectations = pd.DataFrame(clf_2.predict_proba(X), columns=clf_2.classes_)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Bias scan" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=features, observations=y, expectations=expectations, scoring = 'Bernoulli', \n", + " favorable_value = 'Iris-virginica', overpredicted=True, penalty=.05, mode ='nominal')\n", + "unprivileged_subset = bias_scan(data=features, observations=y, expectations=expectations, scoring = 'Bernoulli', \n", + " favorable_value = 'Iris-virginica', overpredicted=False, penalty=.005, mode ='nominal')" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'PetalLengthCm': ['1.0 - 1.4', '1.4 - 1.5', '1.5 - 1.7', '1.7 - 3.9', '3.9 - 4.35', '4.35 - 4.64'], 'PetalWidthCm': ['0.1 - 0.2', '0.2 - 0.4', '0.4 - 1.16', '1.16 - 1.3', '1.3 - 1.5']}, 20.0508)\n", + "({'SepalLengthCm': ['4.8 - 5.0', '5.6 - 5.8', '6.1 - 6.3', '6.3 - 6.52', '6.52 - 6.9', '6.9 - 7.9'], 'PetalWidthCm': ['1.5 - 1.8', '1.8 - 1.9', '1.9 - 2.2', '2.2 - 2.5'], 'PetalLengthCm': ['4.35 - 4.64', '5.0 - 5.32', '5.32 - 5.8', '5.8 - 6.9']}, 22.101)\n" + ] + } + ], + "source": [ + "print(privileged_subset)\n", + "print(unprivileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 88, we observe 0 as the count of Iris-virginica, but our model predicts 50'" + ] + }, + "execution_count": 51, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = iris_data[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = iris_data.loc[to_choose].copy()\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the count of Iris-virginica, but our model predicts {}\"\\\n", + ".format(len(temp_df), (temp_df['Species'] == 'Iris-virginica').sum(), (temp_df['Prediction'] == 'Iris-setosa').sum())" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected unprivileged group has a size of 39, we observe 39 as the count of Iris-virginica, but our model predicts 38'" + ] + }, + "execution_count": 52, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = iris_data[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = iris_data.loc[to_choose].copy()\n", + "\n", + "\"Our detected unprivileged group has a size of {}, we observe {} as the count of Iris-virginica, but our model predicts {}\"\\\n", + ".format(len(temp_df), (temp_df['Species'] == 'Iris-virginica').sum(), (temp_df['Prediction'] == 'Iris-virginica').sum())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Assuming we want to scan for the second most privileged group, we can remove the records that belongs to the most privileged_subset and then rescan." + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "metadata": {}, + "outputs": [], + "source": [ + "to_choose = iris_data[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "X_filtered = iris_data[~to_choose]\n", + "y_filtered = y[~to_choose]" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "metadata": {}, + "outputs": [], + "source": [ + "privileged_subset = bias_scan(data=X_filtered.drop(['Species','Prediction'], axis = 1), observations=y_filtered, \n", + " favorable_value = 'Iris-virginica', scoring = 'Bernoulli', overpredicted=True, penalty=1e-6, mode = 'nominal')" + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "({'PetalLengthCm': ['1.0 - 1.4', '1.4 - 1.5', '1.5 - 1.7', '1.7 - 3.9', '3.9 - 4.35', '4.35 - 4.64']}, 36.0207)\n" + ] + } + ], + "source": [ + "print(privileged_subset)" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Our detected privileged group has a size of 89, we observe 0 as the count of Iris-virginica, but our model predicts 4'" + ] + }, + "execution_count": 56, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "to_choose = X_filtered[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = X_filtered.loc[to_choose]\n", + "\n", + "\"Our detected privileged group has a size of {}, we observe {} as the count of Iris-virginica, but our model predicts {}\"\\\n", + ".format(len(temp_df), (temp_df['Species'] == 'Iris-virginica').sum(), (temp_df['Prediction'] == 'Iris-virginica').sum())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In summary, this notebook explains how to use the new mdss bias scan interface in aif360.detectors to scan for bias, even for tasks beyond binary classification, using the concepts of over-predictions and under-predictions." + ] + } + ], + "metadata": { + "interpreter": { + "hash": "a7b8e4082fc046e7b321ebd13577b0b02bbec122b09da65f91f262e840b142f2" + }, + "kernelspec": { + "display_name": "aif360", + "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.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/sklearn/demo_mdss_bias_scan.ipynb b/examples/sklearn/demo_mdss_bias_scan.ipynb new file mode 100644 index 00000000..2a32661c --- /dev/null +++ b/examples/sklearn/demo_mdss_bias_scan.ipynb @@ -0,0 +1,823 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Identifying Significant Predictive Bias in Classifiers\n", + "\n", + "In this notebook, we attempt to recreate the analysis by Zhe Zhang and Daniel Neill in [Identifying Significant Predictive Bias in Classifiers](https://arxiv.org/pdf/1611.08292.pdf).\n", + "\n", + "The analysis is broken down into three steps, starting with a model trained on COMPAS decile scores only. After running bias scan, we add the distinguishing feature, priors count, to the model. We scan again and train a third model with the new subgroups accounted for. Finally, we reproduce Figure 2 from the paper." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "import seaborn as sns\n", + "sns.set(context='talk', style='whitegrid')\n", + "\n", + "from sklearn.metrics import RocCurveDisplay\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "from aif360.sklearn.datasets import fetch_compas\n", + "from aif360.sklearn.metrics import mdss_bias_scan, mdss_bias_score" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Data loading" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "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", + " \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", + "
sexraceage_catpriors_countc_charge_degreedecile_score
idsexrace
1MaleOtherMaleOtherGreater than 450F1
3MaleAfrican-AmericanMaleAfrican-American25 - 450F3
4MaleAfrican-AmericanMaleAfrican-AmericanLess than 251 to 5F4
7MaleOtherMaleOther25 - 450M1
8MaleCaucasianMaleCaucasian25 - 45More than 5F6
...........................
10996MaleAfrican-AmericanMaleAfrican-AmericanLess than 250F7
10997MaleAfrican-AmericanMaleAfrican-AmericanLess than 250F3
10999MaleOtherMaleOtherGreater than 450F1
11000FemaleAfrican-AmericanFemaleAfrican-American25 - 451 to 5M2
11001FemaleHispanicFemaleHispanicLess than 251 to 5F4
\n", + "

6172 rows × 6 columns

\n", + "
" + ], + "text/plain": [ + " sex race age_cat \\\n", + "id sex race \n", + "1 Male Other Male Other Greater than 45 \n", + "3 Male African-American Male African-American 25 - 45 \n", + "4 Male African-American Male African-American Less than 25 \n", + "7 Male Other Male Other 25 - 45 \n", + "8 Male Caucasian Male Caucasian 25 - 45 \n", + "... ... ... ... \n", + "10996 Male African-American Male African-American Less than 25 \n", + "10997 Male African-American Male African-American Less than 25 \n", + "10999 Male Other Male Other Greater than 45 \n", + "11000 Female African-American Female African-American 25 - 45 \n", + "11001 Female Hispanic Female Hispanic Less than 25 \n", + "\n", + " priors_count c_charge_degree decile_score \n", + "id sex race \n", + "1 Male Other 0 F 1 \n", + "3 Male African-American 0 F 3 \n", + "4 Male African-American 1 to 5 F 4 \n", + "7 Male Other 0 M 1 \n", + "8 Male Caucasian More than 5 F 6 \n", + "... ... ... ... \n", + "10996 Male African-American 0 F 7 \n", + "10997 Male African-American 0 F 3 \n", + "10999 Male Other 0 F 1 \n", + "11000 Female African-American 1 to 5 M 2 \n", + "11001 Female Hispanic 1 to 5 F 4 \n", + "\n", + "[6172 rows x 6 columns]" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cols = ['sex', 'race', 'age_cat', 'priors_count', 'c_charge_degree', 'decile_score']\n", + "X, y = fetch_compas(usecols=cols)\n", + "# Quantize priors count between 0, 1-5, and >5\n", + "X['priors_count'] = pd.cut(X['priors_count'], [-1, 0, 5, 100],\n", + " labels=['0', '1 to 5', 'More than 5'])\n", + "X" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1. Decile score only" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "type object 'RocCurveDisplay' has no attribute 'from_estimator'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/var/folders/ls/ry_nv3ds5n94ykp7g9f6g68w0000gn/T/ipykernel_7745/1962219583.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0mf\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0max\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mplt\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msubplots\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mfigsize\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m6\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m6\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 6\u001b[0;31m \u001b[0mRocCurveDisplay\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfrom_estimator\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnorthpointe\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdec\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0my\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0max\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0max\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m;\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m: type object 'RocCurveDisplay' has no attribute 'from_estimator'" + ] + }, + { + "data": { + "image/png": "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qVOnyuVyadasWUpLS9O6detUWFioH/zgB3rmmWcUExOj5cuXh/UOAQBCL8I509XoAV599VVVVlaqublZ8fHxWr16tW655RZJ0s6dO1VcXKxt27Zp9uzZkqTW1lY99thjqq2tVU9Pj1JTU1VUVKTLL788bHcGABAew44FAOD/L951FgBgIhYAABOxAACYiAUAwEQsAAAmYgEAMJ01seBjW/uNdC2++eYbPfjgg1q4cKFmzpypnJwceTyesZlsmI10LQZqbm5WamqqnnvuufBNcAyNdC16enr0/PPP68Ybb1RKSopuvvlm7d69e2wmG2YjXYuWlhYVFxdr3rx5SktL05o1a86bx4ten3zyiaZPn66vvvrqjPsF/djpnAXeeecdJzk52fn973/vHDhwwPnd737nJCUlOR6P53uPWbVqlXP99dc7O3fudN59913n5ptvdjIyMpxvv/12DGceeiNdi5MnTzrLli1zFi5c6OzcudP54IMPnN/+9rdOUlKS89e//nWMZx9awfxc9Orp6XFWrlzpJCUlORUVFWMw2/AKZi02bNjgXH311U5lZaVz8OBB5ze/+Y2TnJzs7Nu3bwxnHnojXYuenh4nLy/PmTt3rvPmm286f/vb35xbb73VycjIcI4fPz7Gsw+Po0ePOhkZGU5SUpLT3Nx8xn2Dfew8K2KxaNEi57777gsYu/fee52srKwh9//nP//pJCUlOfv37+8b+89//uNce+21zh/+8IewzjXcRroW7733npOUlOTU19cHjOfn5zvLli0L2zzHwkjXYqDt27c78+fPP29iMdK1aGpqcqZNm+a8/vrrAeO//OUvnQ0bNoRtnmNhpGvh9XqdpKQk58033+wb8/l8TlJSkrNz585wTjXsOjs7ne3btzszZ8500tLSzFiM5rFz3J+G4mNb+wWzFtHR0crNzdWMGTMCxi+//PJB7xR8LglmLQYeu2nTJm3YsCHc0xwTwaxFbW2toqKi+t6Sp9f27dv14IMPhnO6YRXMWpw8eVJS/we2SVJsbKwk6fjx4+Gb7Bioq6vTpk2bdMcdd6iwsNDcfzSPneMeCz62tV8wazFnzhw98sgjAR9O1dnZqf379+uKK64I42zDK5i1kE4/T19UVKTs7GzNnz8/vJMcI8GsRUNDgxITE3Xw4EEtW7ZMV111lTIzM/XOO++Ef8JhFMxaTJs2TbNnz1ZFRYWOHj2qlpYWbdy4URdddJEWLVoU/kmH0ZQpU1RbW6u77757WB8ON5rHzmG962w4nQ0f23q2CGYthrJp0yY1NjaqoqIitBMcQ8GuxUsvvSS/368tW7aEd4JjKJi1aGlpUXNzs0pKSnTvvfcqLi5OVVVVWrt2rSZOnKjrr78+/BMPg2B/Lh566CHdeeedWrJkiSTpwgsvVEVFheLj48M42/C7+OKLR7T/aB47xz0Wzln0sa3jLZi1+O5+TzzxhLZu3ar8/Pxz+remYNbC6/Xqqaee0tNPPz3kJzieq4JZi87OTrW0tGjLli1auHChpNNnoV6vV88+++w5G4tg1uLo0aPKy8vTZZddppKSEkVFRen111/XPffcoz/96U+67rrrwj/xs8RoHjvH/ZE12I9t7d3+3WOC+djWs0Uwa9Hr1KlTuv/++/Xiiy8qPz9fDzzwQPgmOgZGuhbd3d0qKipSVlaW0tPT1dXVpa6uLkmnn5rq/fe5KJifi+joaEVGRgY8Nx0REaG5c+eqoaEhjLMNr2DWYuvWrZKkyspKLVq0SPPmzVN5ebmuvPJKPfroo+Gd8FlmNI+d4x4LPra1XzBrIZ3+H+fXv/61PB6PSkpKzvlQSCNfi+bmZtXX16u6ulrTp0/v+5KkZ555pu/f56Jgfi7cbveQkezs7Bz0W/m5JJi1+PLLLzVlypS+i9rS6XCmpqbqs88+C+Nszz6jeewc91jwsa39glmL7u5u3XXXXaqvr1dZWZluv/32sZpuWI10LSZNmqQ33nhj0JckLV++vO/f56Jgfi4yMjLkOE7AizO7urr0/vvvKzU1NexzDpdg1iIxMVGffvqpWltbA8br6+t16aWXhnW+Z5vRPHaO+zULiY9tHWika/Hqq6/q0KFDys3N1SWXXKIjR4703VZERISuueaacbonozfStfjunw/3mjRp0vduO1eMdC3mzJmjG264QRs3btSJEyeUkJCgV155RV988YWefPLJcb43ozPStVi5cqXefvtt5efna/Xq1YqKitJbb72lQ4cO9R1zvgrpY2fQrwYJsR07djg33XSTc/XVVzvZ2dkBL6D5y1/+4iQlJTl///vf+8aOHz/uFBUVOdddd53z4x//2Fm1apVz9OjRcZh56I1kLX71q185SUlJQ35deeWV43QPQmekPxffdb68KM9xRr4W//3vf53HHnvMmTdvnjNjxgwnNzfX+cc//jEOMw+9ka7FZ5995qxZs8aZOXOmk5qa6ixfvtz58MMPx2Hm4dN7vwe+KC+Uj518rCoAwDTu1ywAAGc/YgEAMBELAICJWAAATMQCAGAiFgAAE7EAAJiIBQDARCwAAKb/AfxcJSbgd+HMAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "dec = X[['decile_score']]\n", + "northpointe = LogisticRegression(penalty='none').fit(dec, y)\n", + "y_prob = northpointe.predict_proba(dec)[:, 1]\n", + "\n", + "f, ax = plt.subplots(figsize=(6, 6))\n", + "RocCurveDisplay.from_estimator(northpointe, dec, y, ax=ax);" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "df = pd.concat([X, pd.Series(1-y_prob, name='recid_prob', index=X.index)], axis=1)\n", + "orig_clf = df.groupby('decile_score').mean().recid_prob" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Privileged group\n", + "\n", + "\"Privileged\" in this case means the model underestimates the probability of recidivism (overestimates favorable outcomes) for this subgroup. This leads to advantage for those individuals." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Function mdss_bias_scan is deprecated; Change to new interface - aif360.sklearn.detectors.mdss_detector.bias_scan by version 0.5.0.\n" + ] + }, + { + "data": { + "text/plain": [ + "({'priors_count': ['More than 5']}, 36.3302)" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "priv_sub, priv_score = mdss_bias_scan(y, y_prob, X=X, pos_label='Survived',\n", + " penalty=0.5, privileged=True)\n", + "priv = df[priv_sub.keys()].isin(priv_sub).all(axis=1)\n", + "priv_sub, priv_score" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note: we show probabilities of recidivism but bias scanning is done with respect to the positive label, 'Survived'." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Observed: 71.42%\n", + "Expected: 60.27%\n", + "n = 1221\n" + ] + } + ], + "source": [ + "print(f'Observed: {y[priv].cat.codes.mean():.2%}')\n", + "print(f'Expected: {df[priv].recid_prob.mean():.2%}')\n", + "print(f'n = {sum(priv)}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Unprivileged group\n", + "\n", + "\"Unprivileged\" means the model overestimates the probability of recidivism (underestimates favorable outcomes) for this subgroup. This disadvantages those individuals." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "({'priors_count': ['0']}, 45.1434)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "unpriv_sub, unpriv_score = mdss_bias_scan(y, y_prob, X=X, pos_label='Survived',\n", + " penalty=0.5, privileged=False)\n", + "unpriv = df[unpriv_sub.keys()].isin(unpriv_sub).all(axis=1)\n", + "unpriv_sub, unpriv_score" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Observed: 28.63%\n", + "Expected: 38.06%\n", + "n = 2085\n" + ] + } + ], + "source": [ + "print(f'Observed: {y[unpriv].cat.codes.mean():.2%}')\n", + "print(f'Expected: {df[unpriv].recid_prob.mean():.2%}')\n", + "print(f'n = {sum(unpriv)}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Decile score + priors count" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "dec = dec.assign(priors_count=X['priors_count'].cat.codes)\n", + "northpointe = LogisticRegression(penalty='none').fit(dec, y)\n", + "y_prob_pc = northpointe.predict_proba(dec)[:, 1]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "df = pd.concat([X, pd.Series(1-y_prob_pc, name='recid_prob', index=X.index)], axis=1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Privileged group" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Function mdss_bias_scan is deprecated; Change to new interface - aif360.sklearn.detectors.mdss_detector.bias_scan by version 0.5.0.\n" + ] + }, + { + "data": { + "text/plain": [ + "({'sex': ['Male'], 'age_cat': ['Less than 25']}, 24.49)" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "priv_sub, priv_score = mdss_bias_scan(y, y_prob_pc, X=X, pos_label='Survived',\n", + " penalty=1, privileged=True)\n", + "priv = df[priv_sub.keys()].isin(priv_sub).all(axis=1)\n", + "priv_sub, priv_score" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Observed: 60.04%\n", + "Expected: 49.76%\n", + "n = 1101\n", + "unpenalized score: 26.49\n" + ] + } + ], + "source": [ + "print(f'Observed: {y[priv].cat.codes.mean():.2%}')\n", + "print(f'Expected: {df[priv].recid_prob.mean():.2%}')\n", + "print(f'n = {sum(priv)}')\n", + "\n", + "priv_unpen = mdss_bias_score(y, y_prob_pc, X=X, subset=priv_sub,\n", + " pos_label='Survived', privileged=True, penalty=0)\n", + "print(f'unpenalized score: {priv_unpen:.2f}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Unprivileged group" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "({'decile_score': [2, 3, 6, 9, 10],\n", + " 'sex': ['Female'],\n", + " 'c_charge_degree': ['M']},\n", + " 12.1591)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "unpriv_sub, unpriv_score = mdss_bias_scan(y, y_prob_pc, X=X, pos_label='Survived',\n", + " penalty=0.25, privileged=False, n_iter=25)\n", + "unpriv = df[unpriv_sub.keys()].isin(unpriv_sub).all(axis=1)\n", + "unpriv_sub, unpriv_score" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Observed: 20.79%\n", + "Expected: 36.96%\n", + "n = 202\n", + "unpenalized score: 13.91\n" + ] + } + ], + "source": [ + "print(f'Observed: {y[unpriv].cat.codes.mean():.2%}')\n", + "print(f'Expected: {df[unpriv].recid_prob.mean():.2%}')\n", + "print(f'n = {sum(unpriv)}')\n", + "\n", + "unpriv_unpen = mdss_bias_score(y, y_prob_pc, X=X, subset=unpriv_sub,\n", + " pos_label='Survived', privileged=False, penalty=0)\n", + "print(f'unpenalized score: {unpriv_unpen:.2f}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Decile score + priors count + top groups" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "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", + "
sexraceage_catpriors_countc_charge_degreedecile_scorerecid_probgroup
idsexrace
1MaleOtherMaleOtherGreater than 450F10.186358neither
3MaleAfrican-AmericanMaleAfrican-American25 - 450F30.265247neither
4MaleAfrican-AmericanMaleAfrican-AmericanLess than 251 to 5F40.448002under-estimated
7MaleOtherMaleOther25 - 450M10.186358neither
8MaleCaucasianMaleCaucasian25 - 45More than 5F60.696115neither
\n", + "
" + ], + "text/plain": [ + " sex race age_cat \\\n", + "id sex race \n", + "1 Male Other Male Other Greater than 45 \n", + "3 Male African-American Male African-American 25 - 45 \n", + "4 Male African-American Male African-American Less than 25 \n", + "7 Male Other Male Other 25 - 45 \n", + "8 Male Caucasian Male Caucasian 25 - 45 \n", + "\n", + " priors_count c_charge_degree decile_score \\\n", + "id sex race \n", + "1 Male Other 0 F 1 \n", + "3 Male African-American 0 F 3 \n", + "4 Male African-American 1 to 5 F 4 \n", + "7 Male Other 0 M 1 \n", + "8 Male Caucasian More than 5 F 6 \n", + "\n", + " recid_prob group \n", + "id sex race \n", + "1 Male Other 0.186358 neither \n", + "3 Male African-American 0.265247 neither \n", + "4 Male African-American 0.448002 under-estimated \n", + "7 Male Other 0.186358 neither \n", + "8 Male Caucasian 0.696115 neither " + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df['group'] = 'neither'\n", + "df.loc[priv, 'group'] = 'under-estimated'\n", + "df.loc[unpriv, 'group'] = 'over-estimated'\n", + "df['group'] = df.group.astype('category')\n", + "df.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "dec = dec.join(pd.get_dummies(df.group))\n", + "northpointe = LogisticRegression(penalty='none').fit(dec, y)\n", + "y_prob_pcg = northpointe.predict_proba(dec)[:, 1]\n", + "df['recid_prob'] = 1 - y_prob_pcg" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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iIiIiCoDJr2IiKjS0GRmFbutOjVVL+t6WXrUhtd/RgCC8Nr6JvZ28P1sZon7JSIiIiKqjHjbmIiIKg25mWnx21pYQChEoup1ZAoFnDp2gKmdHe6v/RGZsQ/zqSSDvV9TeI/+DxQ21pzlRURERERUDLyKJiKiSkNhZYUqbq7IfBBbpHaCXA6bBg30Ng5BLodd0yZo9u0yJF+9hqdHj0GZmASZQo4q7u5w6R4MU3t7SJIEGRewJyIiIiIqFia9iIio0hBVKjgHv4V7q78vUjuHN1pAYWWp17HITEwAADYN6sO6Xl0IMlnOY4yiqH2t5PPKiIiIiIgqL67pRURElYbMxAROnTtCYW1V+EaCANde7xhsTIJMBplCgX9u3cKt27e1CS8iIiIiIioZJr2IiKhSEeRyNJjxX8jMzApV32PYEFh5eUIowo6LRERERERkfLyCJyKiSkVmYgJLL080nj8HVdzdX1nPxNYWdcaPhUu3tyBwXS0iIiIionKHa3oREVGlI1MoUMXNFc2WL0bKjZt4tHcfMmMfQtJoYFa1Khw7dkDVlm9AEiUmvIiIiIiIyikmvYiIqFLKXTvLuq4PrOv6aB9flCQJkkYDQS6HwHwXEREREVG5xaQXERFVarnJrlu3bkEQBHh7e0NQ8OORiIiIiKi841U9ERERAFEUjT0EIiIiIiLSIy5kT0REREREREREFQ6TXkREREREREREVOEw6UVERERERERERBUOk15ERERERERERFThMOlFREREREREREQVDpNeRERERERERERU4TDpRUREREREREREFQ6TXkREREREREREVOEw6UVERERERERERBUOk15ERERERERERFThMOlFREREREREREQVjtGTXrt370b37t3RuHFjBAcHY/v27QXWT09Px2effYbWrVvDz88Pw4cPx/3793XqqNVqLF68GO3atUOTJk0wYMAAXLlyxXAnQUREREREREREZYpRk1779u3DxIkT0bp1a6xYsQItWrTAlClTsH///le2+eijj7B//35MnDgR8+fPx5MnTzBo0CCkpqZq63z55ZdYt24dhg8fjkWLFkEul2Pw4MGIiYkpjdMiIiIiIiIiIiIjUxiz82+++QbBwcGYPn06AKBt27ZITk7GkiVL8NZbb+Wpf/78eZw4cQKrV69GYGAgAMDf3x+dOnXCxo0bER4ejgcPHmDTpk2YMWMG+vfvDwBo06YNgoKCsGbNGnz22Weld4JERERERERERGQURpvpFRMTg+joaHTt2lWnPCgoCHfv3s13Vtbp06dhaWmJ1q1ba8scHBwQEBCAkydPAgD++OMPaDQaBAUFaeuYmpqiffv22jpERERERERERFSxCZIkScbo+MSJEwgPD8eOHTtQr149bfn169fRq1cvndlcucaNG4fo6Ghs27ZNp3z27NnYt28fTp8+jQULFuC3337Dn3/+qVNn3bp1mDt3Li5fvgxzc/MijTUiIqKIZ0dEVP6YmpqiUaNGJT4OYyYRVQaMmUREhaevmElUVEZ7vDF3DS4rKyudcktLSwBAWlpanjZpaWl56ue2ya1fUB0gZyH8oia9csnl8mK1IyKqjBgziYgKjzGTiIhI/4yW9MqdYCYIQr7lMlneJy8LmpSWW/9VdV7VX2E0b968yG2IiCorxkwiosJjzCQiIjIco63pZW1tDSDvjK709HSd119kZWWlff3lNrmzuwqqk/s6ERERERERERFVbEZLenl4eAAAoqOjdcqjoqJ0Xn+5TUxMTJ7ZXFFRUdr6np6eSEpKQnJycp46bm5uMDU11ds5EBERERERERFR2WS0pFetWrXg5uaG/fv365QfPHgQtWvXRo0aNfK0adOmDVJSUnDmzBltWUJCAs6fP49WrVoBgPa/Bw4c0NZRKpU4ceKE9jUiIiIiIiIiIqrYjLamFwCMGjUK06ZNg62tLdq3b4+jR49i3759WLRoEYCchFZ0dDS8vb1hZWWFgIAAtGjRAhMmTMDEiRNhZ2eHZcuWwdraGv379wcAuLq6olevXpg9ezYyMjJQq1YtrF27FsnJyQgLCzPm6RIRERERERERUSkRpIJWhy8Fv/zyC3744Qc8evQI7u7uCA8Px7vvvgsA2Lp1K6ZNm4b169fjjTfeAAAkJydj3rx5OHz4MERRRPPmzTF16lR4enpqj6lUKrFw4ULs3r0bGRkZ8PX1xeTJk9GkSRNjnCIREREREREREZUyoye9iIiIiIiIiIiI9M1oa3oREREREREREREZCpNeRERERERERERU4TDpRUREREREREREFQ6TXkREREREREREVOEw6UVERERERERERBUOk14GduPGDfj6+uLx48el1qcoiti4cSN69uwJPz8/dO7cGXPnzkVaWlqp9C9JEtat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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "p = sns.relplot(data=df.groupby(['decile_score', 'priors_count', 'group']).mean(),\n", + " x='decile_score', y='recid_prob', hue='priors_count',\n", + " style='priors_count', palette=['r', 'g', 'b'],\n", + " markers=['o', 's', '^'], col='group', s=250)\n", + "for ax in p.axes.flatten():\n", + " ax.plot(range(1, 11), orig_clf, '--k')\n", + "plt.ylim([0, 1]);\n", + "plt.yticks(np.linspace(0, 1., 5));\n", + "plt.xticks(range(1, 11));" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "interpreter": { + "hash": "fcff873a1570883acbec4abcfe5c307ffcf4bd6893dbc807699742fbff819568" + }, + "kernelspec": { + "display_name": "Python 3.8.12 64-bit ('aif360-metrics': conda)", + "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.9.7" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/sklearn/demo_mdss_classifier_metric_sklearn.ipynb b/examples/sklearn/demo_mdss_classifier_metric_sklearn.ipynb index 2e3e7595..52ac8825 100644 --- a/examples/sklearn/demo_mdss_classifier_metric_sklearn.ipynb +++ b/examples/sklearn/demo_mdss_classifier_metric_sklearn.ipynb @@ -12,16 +12,15 @@ "Cartesian set product, between subsets of feature-values from each feature --- excluding the empty set. Bias scan mitigates this computational hurdle by approximately identifing the most statistically biased subgroup in linear time (rather than exponential).\n", "\n", "\n", - "We define the statistical measure of predictive bias function, $score_{bias}(S)$ as a likelihood ratio score and a function of a given subgroup $S$. The null hypothesis is that the given prediction's odds are correct for all subgroups in\n", + "We define the statistical measure of predictive bias function, $score_{bias}(S)$ as a likelihood ratio score and a function of a given subgroup $S$. The null hypothesis is that the given prediction's odds are correct for all subgroups in $\\mathcal{D}$:\n", "\n", - "$\\mathcal{D}$: $H_{0}:odds(y_{i})=\\frac{\\hat{p}_{i}}{1-\\hat{p}_{i}}\\ \\forall i\\in\\mathcal{D}$.\n", + "$$H_{0}:odds(y_{i})=\\frac{\\hat{p}_{i}}{1-\\hat{p}_{i}}\\ \\forall i\\in\\mathcal{D}.$$\n", "\n", "The alternative hypothesis assumes some constant multiplicative bias in the odds for some given subgroup $S$:\n", "\n", + "$$H_{1}:\\ odds(y_{i})=q\\frac{\\hat{p}_{i}}{1-\\hat{p}_{i}},\\ \\text{where}\\ q>1\\ \\forall i\\in S\\ \\mathrm{and}\\ q=1\\ \\forall i\\notin S.$$\n", "\n", - "$H_{1}:\\ odds(y_{i})=q\\frac{\\hat{p}_{i}}{1-\\hat{p}_{i}},\\ \\text{where}\\ q>1\\ \\forall i\\in S\\ \\mbox{and}\\ q=1\\ \\forall i\\notin S.$\n", - "\n", - "In the classification setting, each observation's likelihood is Bernoulli distributed and assumed independent. This results in the following scoring function for a subgroup $S$\n", + "In the classification setting, each observation's likelihood is Bernoulli distributed and assumed independent. This results in the following scoring function for a subgroup $S$:\n", "\n", "\\begin{align*}\n", "score_{bias}(S)= & \\max_{q}\\log\\prod_{i\\in S}\\frac{Bernoulli(\\frac{q\\hat{p}_{i}}{1-\\hat{p}_{i}+q\\hat{p}_{i}})}{Bernoulli(\\hat{p}_{i})}\\\\\n", @@ -40,20 +39,12 @@ "metadata": {}, "outputs": [], "source": [ - "import sys\n", - "import itertools\n", - "sys.path.append(\"../\")\n", - "\n", - "from aif360.sklearn.datasets import fetch_compas\n", - "from aif360.sklearn.metrics import mdss_bias_scan, mdss_bias_score\n", - "\n", + "import pandas as pd\n", "from sklearn.model_selection import train_test_split\n", "from sklearn.linear_model import LogisticRegression\n", - "from sklearn.preprocessing import OrdinalEncoder\n", "\n", - "from IPython.display import Markdown, display\n", - "import numpy as np\n", - "import pandas as pd" + "from aif360.sklearn.datasets import fetch_compas\n", + "from aif360.sklearn.metrics import mdss_bias_scan, mdss_bias_score" ] }, { @@ -62,7 +53,9 @@ "source": [ "We'll demonstrate scoring a subset and finding the most anomalous subset with bias scan using the compas dataset.\n", "\n", - "We can specify subgroups to be scored or scan for the most anomalous subgroup. Bias scan allows us to decide if we aim to identify bias as `higher` than expected probabilities or `lower` than expected probabilities. Depending on the favourable label, the corresponding subgroup may be categorized as priviledged or unprivileged." + "We can specify subgroups to be scored or scan for the most anomalous subgroup. Bias scan allows us to decide if we aim to identify bias as observing **lower** than predicted probabilities of recidivism, i.e. overestimation, (unprivileged) or observing **higher** than predicted probabilities, i.e. underestimation, (privileged).\n", + "\n", + "Note: categorical features must not be one-hot encoded since scanning those features may find subgroups that are not meaningful e.g., a subgroup with 2 race values." ] }, { @@ -71,36 +64,19 @@ "metadata": {}, "outputs": [], "source": [ - "np.random.seed(0)\n", - "\n", - "#load the data, reindex and change target class to 0/1\n", - "X, y = fetch_compas(usecols=['sex', 'race', 'age_cat', 'priors_count', 'c_charge_degree'])\n", - "\n", - "X.index = pd.MultiIndex.from_arrays(X.index.codes, names=X.index.names)\n", - "y.index = pd.MultiIndex.from_arrays(y.index.codes, names=y.index.names)\n", - "\n", - "y = pd.Series(y.factorize(sort=True)[0], index=y.index)\n", + "cols = ['sex', 'race', 'age_cat', 'priors_count', 'c_charge_degree']\n", + "X, y = fetch_compas(usecols=cols, binary_race=True)\n", "\n", "# Quantize priors count between 0, 1-3, and >3\n", - "def quantize_priors_count(x):\n", - " if x <= 0:\n", - " return '0'\n", - " elif 1 <= x <= 3:\n", - " return '1 to 3'\n", - " else:\n", - " return 'More than 3'\n", - " \n", - "X['priors_count'] = pd.Categorical(X['priors_count'].apply(lambda x: quantize_priors_count(x)), ordered=True, categories=['0', '1 to 3', 'More than 3'])\n", - "enc = OrdinalEncoder()\n", - "\n", - "X_vals = enc.fit_transform(X)" + "X['priors_count'] = pd.cut(X['priors_count'], [-1, 0, 3, 100],\n", + " labels=['0', '1 to 3', 'More than 3'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### training\n", + "### Training\n", "We'll split the dataset and then train a simple classifier to predict the probability of the outcome; (0: Survived, 1: Recidivated)" ] }, @@ -108,12 +84,130 @@ "cell_type": "code", "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "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", + "
sexraceage_catpriors_countc_charge_degree
idsexrace
3MaleAfrican-AmericanMaleAfrican-American25 - 450F
4MaleAfrican-AmericanMaleAfrican-AmericanLess than 25More than 3F
8MaleCaucasianMaleCaucasian25 - 45More than 3F
10FemaleCaucasianFemaleCaucasian25 - 450M
14MaleCaucasianMaleCaucasian25 - 450F
\n", + "
" + ], + "text/plain": [ + " sex race age_cat \\\n", + "id sex race \n", + "3 Male African-American Male African-American 25 - 45 \n", + "4 Male African-American Male African-American Less than 25 \n", + "8 Male Caucasian Male Caucasian 25 - 45 \n", + "10 Female Caucasian Female Caucasian 25 - 45 \n", + "14 Male Caucasian Male Caucasian 25 - 45 \n", + "\n", + " priors_count c_charge_degree \n", + "id sex race \n", + "3 Male African-American 0 F \n", + "4 Male African-American More than 3 F \n", + "8 Male Caucasian More than 3 F \n", + "10 Female Caucasian 0 M \n", + "14 Male Caucasian 0 F " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "np.random.seed(0)\n", - "\n", "(X_train, X_test,\n", - " y_train, y_test) = train_test_split(X_vals, y, train_size=0.7, random_state=1234567)" + " y_train, y_test) = train_test_split(X, y, train_size=0.7, shuffle=False, random_state=42)\n", + "\n", + "X_train.head()" ] }, { @@ -124,7 +218,7 @@ { "data": { "text/plain": [ - "LogisticRegression()" + "array(['Recidivated', 'Survived'], dtype=object)" ] }, "execution_count": 4, @@ -133,33 +227,30 @@ } ], "source": [ - "clf = LogisticRegression(solver='lbfgs', C=1.0, penalty='l2')\n", - "clf.fit(X_train, y_train)" + "clf = LogisticRegression(solver='lbfgs', C=1.0, penalty='l2', random_state=0)\n", + "clf.fit(X_train.apply(lambda s: s.cat.codes), y_train)\n", + "clf.classes_" ] }, { - "cell_type": "code", - "execution_count": 5, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "test_prob = clf.predict_proba(X_test)[:,1]" + "predictions should reflect the probability of a favorable outcome (i.e. no recidivism)." ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ - "dff = pd.DataFrame(X_test, columns=X.columns)\n", - "dff['observed'] = pd.Series(y_test.values)\n", - "dff['probabilities'] = pd.Series(test_prob)" + "test_prob = clf.predict_proba(X_test.apply(lambda s: s.cat.codes))[:, 1]" ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -195,74 +286,84 @@ " \n", " \n", " 0\n", - " 1.0\n", - " 0.0\n", - " 0.0\n", - " 1.0\n", - " 0.0\n", - " 1\n", - " 0.487700\n", + " Male\n", + " African-American\n", + " 25 - 45\n", + " 0\n", + " F\n", + " Survived\n", + " 0.691005\n", " \n", " \n", " 1\n", - " 1.0\n", - " 2.0\n", - " 1.0\n", - " 0.0\n", - " 0.0\n", - " 0\n", - " 0.322064\n", + " Male\n", + " African-American\n", + " 25 - 45\n", + " More than 3\n", + " F\n", + " Recidivated\n", + " 0.325263\n", " \n", " \n", " 2\n", - " 1.0\n", - " 2.0\n", - " 1.0\n", - " 0.0\n", - " 0.0\n", - " 0\n", - " 0.322064\n", + " Male\n", + " Caucasian\n", + " Greater than 45\n", + " More than 3\n", + " F\n", + " Recidivated\n", + " 0.333202\n", " \n", " \n", " 3\n", - " 1.0\n", - " 2.0\n", - " 0.0\n", - " 2.0\n", - " 0.0\n", - " 0\n", - " 0.643483\n", + " Male\n", + " Caucasian\n", + " Greater than 45\n", + " 1 to 3\n", + " M\n", + " Survived\n", + " 0.556058\n", " \n", " \n", " 4\n", - " 1.0\n", - " 5.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 0\n", - " 0.223666\n", + " Male\n", + " African-American\n", + " Less than 25\n", + " 1 to 3\n", + " F\n", + " Recidivated\n", + " 0.385969\n", " \n", " \n", "\n", "" ], "text/plain": [ - " sex race age_cat priors_count c_charge_degree observed probabilities\n", - "0 1.0 0.0 0.0 1.0 0.0 1 0.487700\n", - "1 1.0 2.0 1.0 0.0 0.0 0 0.322064\n", - "2 1.0 2.0 1.0 0.0 0.0 0 0.322064\n", - "3 1.0 2.0 0.0 2.0 0.0 0 0.643483\n", - "4 1.0 5.0 0.0 0.0 0.0 0 0.223666" + " sex race age_cat priors_count c_charge_degree \\\n", + "0 Male African-American 25 - 45 0 F \n", + "1 Male African-American 25 - 45 More than 3 F \n", + "2 Male Caucasian Greater than 45 More than 3 F \n", + "3 Male Caucasian Greater than 45 1 to 3 M \n", + "4 Male African-American Less than 25 1 to 3 F \n", + "\n", + " observed probabilities \n", + "0 Survived 0.691005 \n", + "1 Recidivated 0.325263 \n", + "2 Recidivated 0.333202 \n", + "3 Survived 0.556058 \n", + "4 Recidivated 0.385969 " ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "dff.head()" + "df = X_test.copy()\n", + "df['observed'] = y_test\n", + "df['probabilities'] = test_prob\n", + "df.reset_index(drop=True).head()" ] }, { @@ -276,140 +377,118 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### bias scoring\n", + "### Bias scoring\n", "\n", - "We'll call the MDSS Classification Metric and score the test set. The privileged argument indicates the direction for which to scan for bias depending on the positive label. In our case since the positive label is 0, `True` corresponds to checking for lower than expected probabilities and `False` corresponds to checking for higher than expected probabilities." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "females = dff[dff['sex'] == 1]\n", - "males = dff[dff['sex'] == 0]" + "We'll call the bias scoring function and score the test set. The `privileged` argument indicates the direction for which to scan for bias depending on the positive label. In our case since the positive label is 1 ('Survived'), `True` corresponds to checking for underestimated risk of recidivism and `False` corresponds to checking for overestimated risk of recidivism." ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "-1.512e-14\n", - "2.363262497629335\n" + "4.8846\n", + "0.952\n" ] } ], "source": [ - "# get the bias score of females assuming they are privileged\n", - "print(mdss_bias_score(females['observed'], females['probabilities'], pos_label=0, privileged=True))\n", - "\n", - "# get the bias score of females assuming they are unprivileged\n", - "print(mdss_bias_score(females['observed'], females['probabilities'], pos_label=0, privileged=False))" + "print(mdss_bias_score(df['observed'], df['probabilities'], pos_label='Survived',\n", + " X=df.iloc[:, :-2], subset={'sex': ['Female']},\n", + " privileged=True))\n", + "print(mdss_bias_score(df['observed'], df['probabilities'], pos_label='Survived',\n", + " X=df.iloc[:, :-2], subset={'sex': ['Male']},\n", + " privileged=False))" ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "0.003755523381276868\n", - "-3.4000000000000004e-15\n" + "-0.0\n", + "-0.0\n" ] } ], "source": [ - "# get the bias score of males assuming they are privileged\n", - "print(mdss_bias_score(males['observed'], males['probabilities'], pos_label=0, privileged=True))\n", - "\n", - "# get the bias score of males assuming they are unprivileged\n", - "print(mdss_bias_score(males['observed'], males['probabilities'], pos_label=0, privileged=False))" + "print(mdss_bias_score(df['observed'], df['probabilities'], pos_label='Survived',\n", + " X=df.iloc[:, :-2], subset={'sex': ['Male']},\n", + " privileged=True))\n", + "print(mdss_bias_score(df['observed'], df['probabilities'], pos_label='Survived',\n", + " X=df.iloc[:, :-2], subset={'sex': ['Female']},\n", + " privileged=False))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "If we assume correctly, then our bias score is going to be higher; thus whichever of the assumptions results in a higher bias score has the most evidence of being true. This means females are likley unprivileged whereas males are likely priviledged by our classifier. Note that the default penalty term added is what results in a negative bias score." + "If we assume correctly, then our bias score is going to be higher; thus whichever of the assumptions results in a higher bias score has the most evidence of being true. This means females are likely privileged whereas males are likely unpriviledged by our classifier." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### bias scan\n", + "### Bias scan\n", "We get the bias score for the apriori defined subgroup but assuming we had no prior knowledge \n", "about the predictive bias and wanted to find the subgroups with the most bias, we can apply bias scan to identify the priviledged and unpriviledged groups. The privileged argument is not a reference to a group but the direction for which to scan for bias." ] }, { "cell_type": "code", - "execution_count": 29, - "metadata": {}, - "outputs": [], - "source": [ - "privileged_subset = mdss_bias_scan(dff['observed'], dff['probabilities'], dataset = dff[dff.columns[:-2]], \\\n", - " pos_label=0, penalty=0.5, privileged=True)\n", - "unprivileged_subset = mdss_bias_scan(dff['observed'], dff['probabilities'], dataset = dff[dff.columns[:-2]], \\\n", - " pos_label=0, penalty=0.5, privileged=False)" - ] - }, - { - "cell_type": "code", - "execution_count": 30, + "execution_count": 9, "metadata": {}, "outputs": [ { - "name": "stdout", + "name": "stderr", "output_type": "stream", "text": [ - "({'sex': [1.0], 'age_cat': [2.0]}, 4.710934850314047)\n", - "({'age_cat': [1.0]}, 30.149019994560646)\n" + "Function mdss_bias_scan is deprecated; Change to new interface - aif360.sklearn.detectors.mdss_detector.bias_scan by version 0.5.0.\n", + "Function mdss_bias_scan is deprecated; Change to new interface - aif360.sklearn.detectors.mdss_detector.bias_scan by version 0.5.0.\n" ] } ], "source": [ - "print(privileged_subset)\n", - "print(unprivileged_subset)" + "privileged_subset = mdss_bias_scan(df['observed'], df['probabilities'],\n", + " X=df[df.columns[:-2]], pos_label='Survived',\n", + " penalty=0.5, privileged=True)\n", + "unprivileged_subset = mdss_bias_scan(df['observed'], df['probabilities'],\n", + " X=df[df.columns[:-2]], pos_label='Survived',\n", + " penalty=0.5, privileged=False)" ] }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 10, "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "[array(['Female', 'Male'], dtype=object),\n", - " array(['African-American', 'Asian', 'Caucasian', 'Hispanic',\n", - " 'Native American', 'Other'], dtype=object),\n", - " array(['25 - 45', 'Greater than 45', 'Less than 25'], dtype=object),\n", - " array(['0', '1 to 3', 'More than 3'], dtype=object),\n", - " array(['F', 'M'], dtype=object)]" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "({'sex': ['Female'], 'age_cat': ['25 - 45', 'Less than 25']}, 9.3413)\n", + "({'age_cat': ['Greater than 45']}, 15.1498)\n" + ] } ], "source": [ - "enc.categories_" + "print(privileged_subset)\n", + "print(unprivileged_subset)" ] }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -423,7 +502,7 @@ "source": [ "We can observe that the bias score is higher than the score of the prior groups. These subgroups are guaranteed to be the highest scoring subgroup among the exponentially many subgroups.\n", "\n", - "For the purposes of this example, the logistic regression model systematically under estimates the recidivism risk of individuals belonging to the `Female` and `Less than 25` group. Whereas individuals belonging to the `Greater than 45` age group are assigned a higher risk than is actually observed. We refer to these subgroups as the `detected privileged group` and `detected unprivileged group` respectively." + "For the purposes of this example, the logistic regression model systematically under estimates the recidivism risk of individuals belonging to the `Female aged less than 25` group. Whereas individuals belonging to the `Greater than 45` age group are assigned a higher risk than is actually observed. We refer to these subgroups as the `detected privileged group` and `detected unprivileged group` respectively." ] }, { @@ -436,17 +515,17 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ - "to_choose = dff[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", - "temp_df = dff.loc[to_choose]" + "to_choose = df[privileged_subset[0].keys()].isin(privileged_subset[0]).all(axis=1)\n", + "temp_df = df.loc[to_choose]" ] }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 13, "metadata": { "scrolled": true }, @@ -454,47 +533,46 @@ { "data": { "text/plain": [ - "'Our detected priviledged group has a size of 340, our model predicts 0.5271796434466836 probability of recidivism but we observe 0.6147058823529412 as the mean outcome'" + "'Our detected priviledged group has a size of 256, our model predicts 34.51% probability of recidivism but we observe 48.05% as the mean outcome'" ] }, - "execution_count": 34, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "\"Our detected priviledged group has a size of {}, our model predicts {} probability of recidivism but we observe {} as the mean outcome\"\\\n", - ".format(len(temp_df), temp_df['probabilities'].mean(), temp_df['observed'].mean())" + "group_obs = temp_df['observed'].cat.codes.mean()\n", + "group_prob = 1-temp_df['probabilities'].mean()\n", + "\n", + "\"Our detected priviledged group has a size of {}, our model predicts {:.2%} probability of recidivism but we observe {:.2%} as the mean outcome\"\\\n", + ".format(len(temp_df), group_prob, group_obs)" ] }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 14, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'This is a multiplicative increase in the odds by 1.430910678064278'" + "'This is a multiplicative increase in the odds by 1.755'" ] }, - "execution_count": 35, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "group_obs = temp_df['observed'].mean()\n", - "group_prob = temp_df['probabilities'].mean()\n", - "\n", "odds_mul = (group_obs / (1 - group_obs)) / (group_prob /(1 - group_prob))\n", - "\"This is a multiplicative increase in the odds by {}\"\\\n", - ".format(odds_mul)" + "\"This is a multiplicative increase in the odds by {:.3f}\".format(odds_mul)" ] }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 15, "metadata": {}, "outputs": [], "source": [ @@ -503,63 +581,62 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 16, "metadata": {}, "outputs": [], "source": [ - "to_choose = dff[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", - "temp_df = dff.loc[to_choose]" + "to_choose = df[unprivileged_subset[0].keys()].isin(unprivileged_subset[0]).all(axis=1)\n", + "temp_df = df.loc[to_choose]" ] }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 17, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'Our detected unpriviledged group has a size of 403, our model predicts 0.4661469627631023 probability of recidivism but we observe 0.2878411910669975 as the mean outcome'" + "'Our detected unpriviledged group has a size of 309, our model predicts 47.03% probability of recidivism but we observe 32.36% as the mean outcome'" ] }, - "execution_count": 38, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "\"Our detected unpriviledged group has a size of {}, our model predicts {} probability of recidivism but we observe {} as the mean outcome\"\\\n", - ".format(len(temp_df), temp_df['probabilities'].mean(), temp_df['observed'].mean())" + "group_obs = temp_df['observed'].cat.codes.mean()\n", + "group_prob = 1-temp_df['probabilities'].mean()\n", + "\n", + "\"Our detected unpriviledged group has a size of {}, our model predicts {:.2%} probability of recidivism but we observe {:.2%} as the mean outcome\"\\\n", + ".format(len(temp_df), group_prob, group_obs)" ] }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 18, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'This is a multiplicative decrease in the odds by 0.4628869654122457'" + "'This is a multiplicative decrease in the odds by 0.539'" ] }, - "execution_count": 39, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "group_obs = temp_df['observed'].mean()\n", - "group_prob = temp_df['probabilities'].mean()\n", - "\n", "odds_mul = (group_obs / (1 - group_obs)) / (group_prob /(1 - group_prob))\n", - "\"This is a multiplicative decrease in the odds by {}\"\\\n", - ".format(odds_mul)" + "\"This is a multiplicative decrease in the odds by {:.3f}\".format(odds_mul)" ] }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 19, "metadata": {}, "outputs": [], "source": [ @@ -570,23 +647,19 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "In summary this notebook demonstrates the use of bias scan to identify subgroups with significant predictive bias, as quantified by a likelihood ratio score, using subset scannig. This allows consideration of not just subgroups of a priori interest or small dimensions, but the space of all possible subgroups of features.\n", + "In summary this notebook demonstrates the use of bias scan to identify subgroups with significant predictive bias, as quantified by a likelihood ratio score, using subset scanning. This allows consideration of not just subgroups of a priori interest or small dimensions, but the space of all possible subgroups of features.\n", "It also presents opportunity for a kind of bias mitigation technique that uses the multiplicative odds in the over-or-under estimated subgroups to adjust for predictive fairness." ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { + "interpreter": { + "hash": "fcff873a1570883acbec4abcfe5c307ffcf4bd6893dbc807699742fbff819568" + }, "kernelspec": { "display_name": "aif360", "language": "python", - "name": "aif360" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -598,7 +671,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.7" + "version": "3.8.12" } }, "nbformat": 4,