Advanced tutorial on bias detection in dalex

In this tutorial, we cover the more advanced aspects of the fairness module in dalex. For the introduction, see Fairness module in dalex example.

In [1]:
import pandas as pd 
import numpy as np 

import plotly
plotly.offline.init_notebook_mode()

Data

Firstly we load the data, which is based on the famous ProPublica study on the COMPAS recidivism algorithm.

In [2]:
compas = pd.read_csv("https://raw.githubusercontent.com/propublica/compas-analysis/master/compas-scores-two-years.csv")

To get a clearer picture, we will only use a few columns of the original data frame.

In [3]:
compas = compas[["sex", "age", "age_cat", "race", "juv_fel_count", "juv_misd_count",
                 "juv_other_count", "priors_count", "c_charge_degree", "is_recid",
                 "is_violent_recid", "two_year_recid"]]
compas.head() 
Out[3]:
sex age age_cat race juv_fel_count juv_misd_count juv_other_count priors_count c_charge_degree is_recid is_violent_recid two_year_recid
0 Male 69 Greater than 45 Other 0 0 0 0 F 0 0 0
1 Male 34 25 - 45 African-American 0 0 0 0 F 1 1 1
2 Male 24 Less than 25 African-American 0 0 1 4 F 1 0 1
3 Male 23 Less than 25 African-American 0 1 0 1 F 0 0 0
4 Male 43 25 - 45 Other 0 0 0 2 F 0 0 0

As we can see, we have a relatively compact pandas.DataFrame. The target variable is two_year_recid which denotes if a particular person will re-offend in the next two years. For this tutorial, we will use scikit-learn models, but these methods are model-agnostic.

In [4]:
age_cat = compas.age_cat
compas = compas.drop("age_cat", axis =1)
In [5]:
compas.dtypes
Out[5]:
sex                 object
age                  int64
race                object
juv_fel_count        int64
juv_misd_count       int64
juv_other_count      int64
priors_count         int64
c_charge_degree     object
is_recid             int64
is_violent_recid     int64
two_year_recid       int64
dtype: object

Models

Like in the previous example, we will make 3 basic predictive models without the hyperparameter tuning.

In [6]:
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.model_selection import train_test_split

# classifiers
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression

X_train, X_test, y_train, y_test = train_test_split(compas.drop("two_year_recid", axis =1),
                                                    compas.two_year_recid,
                                                    test_size=0.3,
                                                    random_state=123)

categorical_features = ['sex', 'race', 'c_charge_degree']
categorical_transformer = Pipeline(steps=[
    ('onehot', OneHotEncoder(handle_unknown='ignore'))
])

numerical_features = ["age", "priors_count"]
numerical_transformer = Pipeline(steps=[
    ('scale', StandardScaler())
])

preprocessor = ColumnTransformer(transformers=[
        ('cat', categorical_transformer, categorical_features),
        ('num', numerical_transformer, numerical_features)
])

clf_tree = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', DecisionTreeClassifier(max_depth=7, random_state=123))
])

clf_forest = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=200, max_depth=7, random_state=123))
])

clf_logreg = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression())
])


clf_logreg.fit(X_train, y_train)
clf_forest.fit(X_train, y_train)
clf_tree.fit(X_train, y_train)
Out[6]:
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('cat',
                                                  Pipeline(steps=[('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['sex', 'race',
                                                   'c_charge_degree']),
                                                 ('num',
                                                  Pipeline(steps=[('scale',
                                                                   StandardScaler())]),
                                                  ['age', 'priors_count'])])),
                ('classifier',
                 DecisionTreeClassifier(max_depth=7, random_state=123))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Explainers

Now, we need to create Explainer objects with the help of dalex.

In [7]:
import dalex as dx
dx.__version__
Out[7]:
'1.7.0'
In [8]:
exp_logreg = dx.Explainer(clf_logreg, X_test, y_test)
Preparation of a new explainer is initiated

  -> data              : 2165 rows 10 cols
  -> target variable   : Parameter 'y' was a pandas.Series. Converted to a numpy.ndarray.
  -> target variable   : 2165 values
  -> model_class       : sklearn.linear_model._logistic.LogisticRegression (default)
  -> label             : Not specified, model's class short name will be used. (default)
  -> predict function  : <function yhat_proba_default at 0x2b4ffe8e0> will be used (default)
  -> predict function  : Accepts only pandas.DataFrame, numpy.ndarray causes problems.
  -> predicted values  : min = 0.0421, mean = 0.444, max = 0.988
  -> model type        : classification will be used (default)
  -> residual function : difference between y and yhat (default)
  -> residuals         : min = -0.978, mean = 9.41e-06, max = 0.92
  -> model_info        : package sklearn

A new explainer has been created!
In [9]:
exp_tree = dx.Explainer(clf_tree, X_test, y_test, verbose=False)
exp_forest = dx.Explainer(clf_forest, X_test, y_test, verbose=False)

model performance

In [10]:
pd.concat([exp.model_performance().result for exp in [exp_logreg, exp_tree, exp_forest]])
Out[10]:
recall precision f1 accuracy auc
LogisticRegression 0.545265 0.658291 0.596471 0.672517 0.706155
DecisionTreeClassifier 0.516129 0.639175 0.571100 0.655889 0.688712
RandomForestClassifier 0.544225 0.648079 0.591629 0.666513 0.712740

permutation-based variable importance

Now, we will use one of the dalex methods to assess the Variable Importance of these models

In [11]:
exp_tree.model_parts().plot(objects=[exp_forest.model_parts(), exp_logreg.model_parts()])

In each of classifier, the most important feature is priors_count. The sex and race seem to be not relevant at all.

Could it still somehow make our classifiers biased?

Fairness

In this tutorial, we will investigate the sex variable. First, compute Fairness objects using the model_fairness method.

In [12]:
protected = X_test.sex
In [13]:
mf_tree = exp_tree.model_fairness(protected=protected, 
                                  privileged = "Female")

mf_forest = exp_forest.model_fairness(protected=protected, 
                                  privileged = "Female")

mf_logreg = exp_logreg.model_fairness(protected=protected, 
                                  privileged = "Female")

Now, we can easily check for bias.

In [14]:
mf_tree.fairness_check()
Bias detected in 3 metrics: TPR, FPR, STP

Conclusion: your model is not fair because 2 or more criteria exceeded acceptable limits set by epsilon.

Ratios of metrics, based on 'Female'. Parameter 'epsilon' was set to 0.8 and therefore metrics should be within (0.8, 1.25)
           TPR       ACC       PPV      FPR       STP
Male  2.021661  0.967359  1.066335  2.61165  2.448485
In [15]:
mf_forest.fairness_check()
Bias detected in 3 metrics: TPR, FPR, STP

Conclusion: your model is not fair because 2 or more criteria exceeded acceptable limits set by epsilon.

Ratios of metrics, based on 'Female'. Parameter 'epsilon' was set to 0.8 and therefore metrics should be within (0.8, 1.25)
           TPR       ACC       PPV     FPR       STP
Male  1.580822  0.908333  0.890278  3.4875  2.291209
In [16]:
mf_logreg.fairness_check()
Bias detected in 3 metrics: TPR, FPR, STP

Conclusion: your model is not fair because 2 or more criteria exceeded acceptable limits set by epsilon.

Ratios of metrics, based on 'Female'. Parameter 'epsilon' was set to 0.8 and therefore metrics should be within (0.8, 1.25)
           TPR       ACC       PPV       FPR       STP
Male  2.326848  0.941926  0.838918  6.595238  3.579832

All classifiers are biased! How could that be? We checked the variable importance!

Yes, but... variables could be correlated with each other. After all, using the default value of epsilon=0.8 concludes that the models are not fair.

We can visualize these results:

In [17]:
mf_tree.plot(objects=[mf_logreg, mf_forest])

It doesn't look good - the bias is enormous. But it is hard to pick the best model based on the plot above. To summarize it, we will use plots with parity loss.

In [18]:
mf_tree.plot(objects=[mf_logreg, mf_forest], type='stacked')

The DecisionTreeClassifier seems to have the least parity loss.

In the ProPublica case, they focused among others on FPR rates. We will do the same while looking at the accuracy, which is a performance metric.

In [19]:
mf_tree.plot(objects=[mf_logreg, mf_forest],
             type="performance_and_fairness",
             fairness_metric="FPR",
             performance_metric="accuracy")

Unfortunately, the bigger the accuracy the more bias in FPR metric.

Is there any method that would enable us to mitigate bias? Yes, the currently implemented one is called ceteris_paribus_cutoff, and it is a visual tool that looks for the minimum in the sum of parity loss metrics. More mitigation methods are now presented in the introduction.

In [20]:
mf_tree.plot(objects=[mf_logreg, mf_forest], type="ceteris_paribus_cutoff", subgroup="Male")

Let's do a controversial thing. Let's change the cutoff for Male to minimize the parity loss of metrics. Why is it controversial?

Because it might not be fair to judge similar people differently based on the subgroup. We however, for educational reasons, will take the DecisionTreeClassifier model and change its cutoff for Male.

In [21]:
mf_tree_changed = exp_tree.model_fairness(protected=X_test.sex, 
                                          privileged ="Female",
                                          cutoff={'Male': 0.62},
                                          label="tree_changed")
In [22]:
mf_tree_changed.plot([mf_tree])

As we can see, we calibrated cutoffs, so they have now the lowest value possible. As mentioned before, this may be controversial.

Besides, the user must be aware that ceteris_paribus_cutoff fits the test set, which is why the difference is this big. Nevertheless, when possible, using this method might be helpful.

In [23]:
mf_tree_changed.plot([mf_tree], type='performance_and_fairness', fairness_metric='FPR')

We should note however that such cutoff calibration will probably result in a worse performance.

Summary

The usage of fairness module in dalex is easy and intuitive. The module provides the user with ways to detect, visualize, and mitigate the bias.

Plots

This package uses plotly to render the plots:

Resources - https://dalex.drwhy.ai/python