Fairness module in dalex

We live in a world that is getting more divided each day. In some parts of the world, the differences and inequalities between races, ethnicities, and sometimes sexes are aggravating. The data we use for modeling is in the major part a reflection of the world it derives from. And the world can be biased, so data and therefore model will likely reflect that. The introduction to this topic is well presented in Fairness and machine learning.

We propose a way in which ML engineers can easily check if their model is biased.

Fairness module is still work-in-progres and new features will be added over time.

In [1]:
import dalex as dx
import numpy as np
In [2]:
dx.__version__
Out[2]:
'0.3.0'

Case study - german credit data

To showcase the abilities of the module, we will be using the German Credit Data dataset) to assign risk for each credit-seeker.

This simple task may require using an interpretable decision tree classifier.

In [3]:
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.tree import DecisionTreeClassifier

# credit data
data = dx.datasets.load_german()

# risk is the target
X = data.drop(columns='risk')
y = data.risk

categorical_features = ['sex', 'job', 'housing', 'saving_accounts', "checking_account", 'purpose']
categorical_transformer = Pipeline(steps=[
    ('onehot', OneHotEncoder(handle_unknown='ignore'))
])

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

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

clf.fit(X, y)
Out[3]:
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('cat',
                                                  Pipeline(steps=[('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['sex', 'job', 'housing',
                                                   'saving_accounts',
                                                   'checking_account',
                                                   'purpose'])])),
                ('classifier',
                 DecisionTreeClassifier(max_depth=7, random_state=123))])

We create an Explainer object to proceed with dalex functionalities.

In [4]:
exp = dx.Explainer(clf, X, y)
Preparation of a new explainer is initiated

  -> data              : 1000 rows 9 cols
  -> target variable   : Argument 'y' was a pandas.Series. Converted to a numpy.ndarray.
  -> target variable   : 1000 values
  -> model_class       : sklearn.tree._classes.DecisionTreeClassifier (default)
  -> label             : not specified, model's class short name is taken instead (default)
  -> predict function  : <function yhat_proba_default at 0x000001AA29F6F670> will be used (default)
  -> predict function  : accepts only pandas.DataFrame, numpy.ndarray causes problems
  -> predicted values  : min = 0.0, mean = 0.7, max = 1.0
  -> model type        : classification will be used (default)
  -> residual function : difference between y and yhat (default)
  -> residuals         : min = -0.969, mean = -3.55e-18, max = 0.833
  -> model_info        : package sklearn

A new explainer has been created!
In [5]:
exp.model_performance().result
Out[5]:
recall precision f1 accuracy auc
DecisionTreeClassifier 0.922857 0.77551 0.842792 0.759 0.827486

Let's say that performance is satisfying. To check if the model is biased, we will use the fairness module from dalex. Checking if the model is fair should be straightforward. Apart from the dx.Explainer, we will need 2 parameters:

  • protected - array-like with subgroup values that denote a sensitive attribute (protected variable) like sex, nationality etc. The fairness metrics will be calculated for each of those subgroups and compared.
  • privileged - a string representing one of the subgroups. It should be the one suspected of the most privilege.
In [6]:
# array with values like male_old, female_young, etc.
protected = data.sex + '_' + np.where(data.age < 25, 'young', 'old')

privileged = 'male_old'

Now it is time to check fairness!

We use a unified dalex interface to create a fairness explanation object. Use the model_fairness() method:

In [7]:
fobject = exp.model_fairness(protected = protected, privileged=privileged)

The idea here is that ratios between scores of privileged and unprivileged metrics should be close to 1. The closer the more fair the model is. But to relax this criterion a little bit, it can be written more thoughtfully:

fairness_check

Where the epsilon is a value between 0 and 1, it should be a minimum acceptable value of the ratio. On default, it is 0.8, which adheres to four-fifths rule (80% rule) often looked at in hiring, for example.

In [8]:
fobject.fairness_check(epsilon = 0.8) # default epsilon
Ratios of metrics, base: male_old
female_old, metrics exceeded: 1
female_young, metrics exceeded: 1
male_young, metrics exceeded: 0

Ratio values: 

                   TPR       PPV       STP       ACC       FPR
female_old    1.006508  1.000000  0.927739  1.027559  0.765051
female_young  0.971800  0.879594  0.860140  0.937008  0.775330
male_young    1.030369  0.875792  0.986014  0.929134  0.998532

Conclusion: your model is not fair

This model is not fair! Generally, each metric should be between (epsilon, 1/epsilon). Metrics are calculated for each subgroup, and then their scores are divided by the score of the privileged subgroup. That is why we omit male_old in this method. When at least 2 subgroups have scores ratio outside of the epsilon range, the model may be declared unfair.

Useful attributes

The result attribute is metric_scores where each row is divided by row indexed with privileged (in this case male_old).

In [9]:
# to see all scaled metric values you can run
fobject.result
Out[9]:
TPR TNR PPV NPV FNR FPR FDR FOR ACC STP
female_old 1.006508 1.501567 1.000000 1.276846 0.923077 0.765051 1.000000 0.591584 1.027559 0.927739
female_young 0.971800 1.479624 0.879594 1.296980 1.333333 0.775330 1.450237 0.561881 0.937008 0.860140
male_old 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000
male_young 1.030369 1.003135 0.875792 1.342282 0.641026 0.998532 1.464455 0.495050 0.929134 0.986014
In [10]:
# or unscaled ones via
fobject.metric_scores
Out[10]:
TPR TNR PPV NPV FNR FPR FDR FOR ACC STP
female_old 0.928 0.479 0.789 0.761 0.072 0.521 0.211 0.239 0.783 0.796
female_young 0.896 0.472 0.694 0.773 0.104 0.528 0.306 0.227 0.714 0.738
male_old 0.922 0.319 0.789 0.596 0.078 0.681 0.211 0.404 0.762 0.858
male_young 0.950 0.320 0.691 0.800 0.050 0.680 0.309 0.200 0.708 0.846

Plots

The fairness explanation object includes plots that allow for bias visualization from different perspectives:

  1. fairness_check plot

  2. metric_scores plot

Fairness Check plot

This is a visualization of the fairness_check result.

In [11]:
fobject.plot()
Found NaN's or 0's for models: {'DecisionTreeClassifier'}
It is advisable to check 'metric_ratios'