<jupyter_start><jupyter_text>Ignoring bias for classification problemsThis notebook is part of a blog series where I investigate bias in AI.1. [Introducing the IgnoringEstimator](https://github.com/SjoerdCor/fairness/blob/main/blog/1.IntroducingTheIgnoringEstimator.ipynb) introduced measures of fairness and showed how naive approaches do not solve them, and on the other hand shows how easily the IgnoringEstimator is implemented and solves them well1. [Dealing with more complex biases](https://github.com/SjoerdCor/fairness/blob/main/blog/2.DealingWithMoreComplexBiases.ipynb) showed how common complex biases are: non-linear, correlated with other attributes and for continuous features, and showed how easy it is to mitigate the disparate treatment with the `IgnoringBiasEstimator`. I also showed how little attention there seems to be for this problem in existing approaches.1. [Ignoring bias for cassification poblems](https://github.com/SjoerdCor/fairness/blob/main/blog/3.IgnoringBiasForClassificationProblems.ipynb) finally shows how to use the Ignoring Estimator for the classic classification problems - since these are more prolific, we can also compare against a wide variety of existing approaches and see the `IgnoringBiasEstimator` does equally well or better both in terms of bias mitigation and accuracy. In this installment, I will use the IgnoringBiasClassifier, which eliminates Disparate Treatment for classification problems. Additionally, I will apply a wide variety of other bias mitigation strategies to make a comprehensive comparison, and show that the IgnoringBias does equally well or better both in terms of bias mitigation and accuracy.We will do so in three simple steps:1. Create a dataset with whether an employee got a promotion1. Fit classifiers with a variety of bias mitigation strategies1. Evaluate remaining bias and accuracy of the classifiers Creating the datasetFor the last time in this blog, we stick to the employee situation. Imagine the company now sets out to predict who should be promoted, based on skills. This should only depend on SocialSkills and Education, but in the real world also depends quadratically on age.<jupyter_code>def generate_employees_age(n_employees: int = 2500) -> pd.DataFrame:
    X = (pd.DataFrame({'Age': 25 + 40 * np.random.rand(n_employees),
                       'Education': 4 + 16 * np.random.rand(n_employees),
                       'SocialSkills': np.random.rand(n_employees)
                      }
                     )
        )
    return X

def eligibility_to_promotion(e: float):
    ''' Real number -> 0/1 based on binomial distribution via sigmoid'''
    prob = scipy.special.expit(e)  # Use logistic function to map real number to probability in range [0, 1]
    return np.random.binomial(1, prob) # Turn into yes/no

def add_promotion(df: pd.DataFrame) -> pd.DataFrame:
    weights = {'Age': 0,
               'Education': 0.5,
               'SocialSkills': 2.5,
              }
    error = 0.2
    bias_effect_size = -0.5
    
    base_prob = np.random.normal(-10, error, size=len(df))
    df = df.assign(PromotionEligibilitySkill = lambda df: df.mul(weights).sum('columns').add(base_prob),
                   PromotionEligibilityTrue = lambda df: (df['PromotionEligibilitySkill']
                                                          .add(utils.generate_bias(df['Age'], effect_size=bias_effect_size, power=2))
                                                         ),
                   PromotionSkill = lambda df: df['PromotionEligibilitySkill'].apply(eligibility_to_promotion),
                   PromotionTrue = lambda df: df['PromotionEligibilityTrue'].apply(eligibility_to_promotion),
                  )
    return df

df = generate_employees_age(25000).pipe(add_promotion)

PROTECTED_ATTRIBUTE = ['Age']
utils.display_df(df)
(X_train, X_test,
 y_train, y_test,
 y_skill_train, y_skill_test,
 promotion_eligibility_skill_train, promotion_eligibility_skill_test) = sklearn.model_selection.train_test_split(df.filter(['Age', 'Education', 'SocialSkills', 'Experience']),
                                                                                                                 df['PromotionTrue'],
                                                                                                                 df['PromotionSkill'],
                                                                                                                 df['PromotionEligibilitySkill'],
                                                                                                                 random_state=42
                                                                                                                 )
def plot_promotion_prob_by_age(predictions: pd.Series, age: pd.Series, age_group: float = 5, ax=None, **kwargs):
    """
    Plots mean and sem of the promotion probability per age bucket
    
    predictions: columns containing output
    age: coumn containing the age for each prediction
    age_group: bucket size for the age
    ax: pyplot Ax object to which the plot is added. If None, a new Ax is created
    **kwargs are passed to pd.plot method
    """
    if ax is None:
        fig, ax = plt.subplots()
    age_grouped = age // age_group * age_group + age_group/2
    result = predictions.groupby(age_grouped).agg(['mean', 'sem'])
    ax = (result.plot(y='mean', yerr='sem', ax=ax, label=predictions.name, **kwargs))
    return ax
    
ax = plot_promotion_prob_by_age(df['PromotionSkill'], df['Age'])
plot_promotion_prob_by_age(df['PromotionTrue'], df['Age'], ax=ax)
plt.show()<jupyter_output><empty_output><jupyter_text>Indeed, the promotions based on skills are independent of age, but in the real world middle-aged people have much higher probability of a promotion  Calculating the predictionsWith the data in hand, we fit models to predict the probability of a promotion. We apply a variety of bias mitigation strategies that work with continuos features:* No bias mitigation strategy, as a baseline* Preprocessing: InformationFilter, as implemented by `scikit-lego`* Inprocessing: AdversarialDebiasing, as implemented by `aif360`* Postprocessing: CalibratedOddsEqualizer, as implemented by `aif360`* Inprocessing: IgnoringBiasClassifier, as defined within this repository<jupyter_code>predictions = {}
def add_predictions_to_results(clf, name: str, predictions: dict = predictions,
                               X_train: pd.DataFrame = X_train, y_train: pd.Series = y_train,
                               X_test: pd.DataFrame = X_test,
                               protected_attribute: str = None) -> None:
    """"
    Helper function that fits a classifier and adds the predictions to an existing dict inplace
    
    clf: the classifier
    name: the name of the strategy, will be key in dictionary
    predictions: the dictionary to which the predictions will be added
    X_train: the features on which the  classifier is trained
    y_train: the target values on which the classifier is trained
    X_test: features of the set to be predicted
    protected_attribute: name of the column(s) of X_train and X_test which are protected if using aif360; then they must 
        be in the index, not in the feature set
    """
    
    if protected_attribute:  # only for aif360
        X_train = X_train.set_index(protected_attribute, append=True)
        X_test = X_test.set_index(protected_attribute, append=True)
        
    clf.fit(X_train, y_train)
    y_pred = utils.predict_series(clf, X=X_test, method='predict_proba')
    
    if protected_attribute:
        y_pred = y_pred.reset_index(protected_attribute, drop=True)  # aif360 adds protected attribute to outcomes as well, but we want identical indices
    predictions[name] = y_pred
rf_kwargs = {'min_samples_leaf': 20, 'random_state': 42}<jupyter_output><empty_output><jupyter_text>No bias mitigationAs a baseline, let's first calculate the naive approach of fitting a model with the protected attribute as a feature, applying no bias mitigation<jupyter_code>rf = RandomForestClassifier(**rf_kwargs)
add_predictions_to_results(rf, 'NoBiasMitigation')<jupyter_output><empty_output><jupyter_text>scikit-lego preprocessing: Information FilterSecondly, let's apply the Information Filter, we also applied in the second part of this series. It tries to transform the dataset in such a way that information about the sensitive column, i.e. Age, is lost. Subsequently, we fit our standard classifer.<jupyter_code>clf_information_filter = make_pipeline(InformationFilter(PROTECTED_ATTRIBUTE),
                                       RandomForestClassifier(**rf_kwargs),
                                      )
add_predictions_to_results(clf_information_filter, 'InformationFilter')<jupyter_output><empty_output><jupyter_text>aif360 Inprocessing: Adversarial DebiasingAn interesting inprocessing approach is [Adversarial Debiasing](https://aif360.readthedocs.io/en/latest/modules/generated/aif360.sklearn.inprocessing.AdversarialDebiasing.htmlaif360.sklearn.inprocessing.AdversarialDebiasing), where we fit a classifier and its adversary neural network simultaneously; the classifier tries to fit data (promotion from age, social skills and education level) as well as possible, while the adversary tries to estimate the protected column (age) based solely on the probability of promotion. By reducing the ability of the adversary, the classifier can not rely on information from the protected column.<jupyter_code>%%time
clf_adv_deb = AdversarialDebiasing(prot_attr=PROTECTED_ATTRIBUTE, adversary_loss_weight=None, random_state=42, classifier_num_hidden_units=64)
add_predictions_to_results(clf_adv_deb, 'AdversarialDebiasing', protected_attribute=PROTECTED_ATTRIBUTE)<jupyter_output>Wall time: 4min 57s<jupyter_text>aif360 Postprocessing: CalibratedOddsEqualizerIn this postprocessing scheme, we fit a classifier as if we do not mitigate the bias, but then afterwards we equalize the odds, by e.g. artifically increasing the probability of a certain gender or race.A great drawback is that this strategy requires binary _groups_, not continuous features, so we must define beforehand who is advantaged and who is disadvantaged. In our case, we make two groups: those middle-aged, between 32.5 and 57.5 years old, and those who are either younger than 32.5 or older than 57.5.<jupyter_code>def add_index_to_series(series: pd.Series, index_series: pd.Series) -> pd.Series:
    '''Append index to series.
    
    series: the series to which an index is added
    index_series: the series which is appended as an index
    '''
    
    res = (pd.concat([series, index_series], axis='columns')
           .set_index(index_series.name, append=True)
           .squeeze()
          )
    return res
def dummify(series, low, high):
    """"
    Return series as dummy whether it is in groups
    
    low: minimum threshold value to dummify
    high: maximum threshold value to dummify
    """
    
    return series.between(low, high).astype(int)
# Divide age into two groups: advantaged and disadvantaged, as required to work with CalibratedOddsEqualizer
advantaged_age_group = (32.5, 57.5)

# Add the group as an index to y, as required by aif360
y_train_with_age = y_train.pipe(add_index_to_series, dummify(X_train['Age'], *advantaged_age_group))
y_test_with_age = y_test.pipe(add_index_to_series, dummify(X_test['Age'], *advantaged_age_group))
                              
X_test_grouped_age = X_test.assign(Age = lambda df: dummify(df['Age'], *advantaged_age_group))
clf_postproc = PostProcessingMeta(estimator=RandomForestClassifier(**rf_kwargs),
                                  postprocessor=CalibratedEqualizedOdds(PROTECTED_ATTRIBUTE, random_state=42),
                                  random_state=42)

add_predictions_to_results(clf_postproc, 'CalibratedOddsEqualizer', y_train=y_train_with_age, protected_attribute=PROTECTED_ATTRIBUTE, X_test=X_test_grouped_age)<jupyter_output><empty_output><jupyter_text>Ignoring Bias, from this repositoryFinally, of course, we use the IgnoringBiasClassifire from this repository. As a correction strategy we use the "Logitadditive", which is basically the same as the additive method we used in the regression problem, but now applicable for classification problems. We can not just add a constant to probabilities, as that might light lead to probabilities larger than 1, or smaller than 0. Therefore, we first transform the probability to the range (-inf, inf) by using the logit function, add the constant there, and then transform back using the sigmoid function to a probability in the range [0, 1].<jupyter_code>ib = fairestimator.IgnoringBiasClassifier(RandomForestClassifier(**rf_kwargs),
                                          ignored_cols=[0], # Ignore the zeroeth column at prediction time -> Age
                                          correction_strategy='Logitadditive')

add_predictions_to_results(ib, 'IgnoringBias')<jupyter_output><empty_output><jupyter_text>Evaluating the different options Measuring fairnessWe have applied six bias mitigation strategies, and now we can finally visualize the results. We are looking for two twhings:1. The probability of a promotion should be unbiased, i.e. it should equal for every age group (in this toy dataset skills are independent of age). Just as in [Blog 2](https://github.com/SjoerdCor/fairness/blob/main/blog/2.DealingWithMoreComplexBiases.ipynb), we will use the Mutual Information as a fairness measure.1. The probability of promotion should be well-calibrated, i.e. the average predicted promotion probability should match the average true promotion probabilityIn the plot, we see three groups:* The strongly biased strategy, matching the grey dashed line: the true world biased promotion probabilities  * Applying No Bias Mitigatoin (blue line) propagates the bias  * InformationFilter (in orange) mostly unsuccessful in removing the bias: it leaves a linear binas where younger employees are strongly advantaged over older employees.* The largely unbiased, but badly calibrated strategies  * AdversialDebiasing (green line) does not exhibit a large bias, but gives everybody a way too low probability of promotion. Perhaps we could improve this with more tweaking, but since it is already very slow and other strategies are succesful out-of-the-box, I decided not to spend more time on this* The (mostly) successful strategies, closely following the black dahsed line: the promotion probabilities based purely on skill.  * Postprocessing with the CalibratedOddsEqualizer (red) does very well, which is somewhat surprising given that we had to discretize Age. However, I fear it will not scale well to more complex, continuous biases where we do not know well who is (dis)advantaged beforehand.  * IgnoringBias (purple) does very well, both on removing bias and calibration<jupyter_code>df_predictions = pd.DataFrame(predictions)
df_bias = df_predictions.sub(scipy.special.expit(promotion_eligibility_skill_test), axis=0)
ax = plot_promotion_prob_by_age(y_skill_test, X_test['Age'], figsize=(16, 12), ls='--', c='k')
plot_promotion_prob_by_age(y_test, X_test['Age'], ax=ax, ls='--', c='grey')

for col in df_predictions:
    plot_promotion_prob_by_age(df_predictions[col], X_test['Age'], ax=ax)

ax.set_ylabel('Promotion probability')
plt.show()
%%time 
n_bootstraps = 500
# Boostrapping takes quite a bit of time

# We use a larger number of neigbors for the Mutual Information, since bootstrapping duplicates some samples, and the nearest neighbor is then the sample itself
# With this number, the mean of all the bootstraps matches the non-bootsrapped MI
df_disp_treat = (utils.bootstrap(utils.calculate_mutual_information, pd.concat([df_bias, X_test['Age']], axis='columns'), n_bootstrap= n_bootstraps, n_neighbors=100)
                 .pipe(utils.calculate_bootstrap_confidence_interval)
                )<jupyter_output>Wall time: 6min 49s<jupyter_text>Finally, we calculate the disparate treatment as the mutual information between the age and the bias , which is the difference between the predicted probability of promtion and the skills-based probability of promotion. Indeed, 3 strategies are (mostly) sucessful in removing the bias, of which the IgnoringBiasEstimator has the best performance.<jupyter_code>def plot_biases(biases: dict, **kwargs):
    """
    Plot fairness metric with uncertainty for multiple mitigation strategies.

    biases: fairness metrics, dict of dicts structured 
        {"name": {"diff_mean": fairness metric, "diff_sem": uncertainty fairness metric}}
    **kwargs are passed to the DataFrame plot method
    """
    fig, ax = plt.subplots()
    df = pd.DataFrame(biases).transpose()
    p = ax.barh(y=range(len(df)), width=df[0.5], xerr=(df['Uncertainty_lower'], df['Uncertainty_upper']))
    ax.axvline(0, c='k', ls='--')
    plt.yticks(range(len(df)), df.index)
    ax.invert_yaxis()
    ax.set_ylabel('FairnessMethod')
    
    ax.bar_label(p, label_type='edge', fmt='%.3f')
    plt.margins(0.1)  # Make room for the labels
    
    
    return ax

ax = plot_biases(df_disp_treat)
ax.set_xlabel("Disparate Treatment")
plt.show()<jupyter_output><empty_output><jupyter_text>Measuring accuracyAs a measure of the accuracy of the methods, we use [log loss](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.log_loss.html), which checks both calibration and accuracy.Four strategies perform almost equally well, of which IgnoringBiasEstimator, again, has the best accuracy. Additionally, it is clear that AdversarialDebiasing does poorly, due to its horrible calibration.<jupyter_code>dummy_logloss = sklearn.metrics.log_loss(y_skill_test, [y_skill_test.mean()] * len(y_skill_test))
accs = df_predictions.apply(lambda col: sklearn.metrics.log_loss(y_skill_test, col))
ax = accs.plot(kind='barh')
plt.bar_label(ax.containers[0], fmt='%.2f', c='white', label_type='center')
ax.axvline(dummy_logloss, c='k', ls='--')
ax.invert_yaxis()
plt.ylabel('Bias mitigation strategy')
plt.xlabel('Logloss (lower is better)')
plt.show()
predictions_train = {}
add_predictions_to_results(rf, 'NoBiasMitigation', X_test=X_train, predictions=predictions_train)
add_predictions_to_results(ib, 'IgnoringBias', X_test=X_train, predictions=predictions_train)
df_predictions_train = pd.DataFrame(predictions_train)
dummy_logloss_train = sklearn.metrics.log_loss(y_skill_test, [y_skill_test.mean()] * len(y_skill_test))

accs = df_predictions_train.apply(lambda col: sklearn.metrics.log_loss(y_skill_train, col))
ax = accs.plot(kind='barh')
plt.bar_label(ax.containers[0], fmt='%.2f', c='white', label_type='center')
ax.axvline(dummy_logloss_train, c='k', ls='--')
ax.invert_yaxis()
plt.ylabel('Bias mitigation strategy')
plt.xlabel('Logloss (lower is better)')
plt.show()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.07824751006569189, 'fraction_numerical': 0.007734689552871371, 'mean_word_length': 3.816789997448329, 'pattern_counts': {'":': 3, '<': 39, '<?xml version=': 0, '>': 45, 'https://': 6, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 37, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '10', 'dir': 'jupyter-structured-clean-dedup', 'id': '319715', 'license': 'MIT', 'n_tokens_mistral': 5446, 'n_tokens_neox': 5143, 'n_words': 1782, 'path': 'blog/3.IgnoringBiasForClassificationProblems.ipynb', 'repo_name': 'SjoerdCor/fairness', 'size': '18876'}