Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Overview

In this notebook, we explore the problem of classification with fairness on the [UCI Adult dataset]. We show how to set up a classification problem with data-dependent fairness constraints using the TensorFlow Constrained Optimization library and then subsequently train to optimize fairness.


In [1]:
import math
import random
import numpy as np
import pandas as pd
import warnings
from six.moves import xrange
import tensorflow.compat.v1 as tf
import tensorflow_constrained_optimization as tfco
import matplotlib.pyplot as plt

tf.disable_eager_execution()

warnings.filterwarnings('ignore')
%matplotlib inline

Reading and processing dataset.

We load the [UCI Adult dataset] and do some pre-processing. The dataset is based on census data and the goal is to predict whether someone's income is over 50k. We construct four protected groups, two based on gender (Male and Female) and two based on race (White and Black).

We preprocess the features as done in works such as [ZafarEtAl15] and [GohEtAl16]. We transform the categorical features into binary ones and transform the continuous feature into buckets based on each feature's 5 quantiles values in training.

The fairness goal is that of equal opportunity. That is, we would like the true positive rates of our classifier on the protected groups to match that of the overall dataset.


In [2]:
CATEGORICAL_COLUMNS = [
    'workclass', 'education', 'marital_status', 'occupation', 'relationship',
    'race', 'gender', 'native_country'
]
CONTINUOUS_COLUMNS = [
    'age', 'capital_gain', 'capital_loss', 'hours_per_week', 'education_num'
]
COLUMNS = [
    'age', 'workclass', 'fnlwgt', 'education', 'education_num',
    'marital_status', 'occupation', 'relationship', 'race', 'gender',
    'capital_gain', 'capital_loss', 'hours_per_week', 'native_country',
    'income_bracket'
]
LABEL_COLUMN = 'label'

PROTECTED_COLUMNS = [
    'gender_Female', 'gender_Male', 'race_White', 'race_Black'
]

def get_data():
    train_df_raw = pd.read_csv("https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data", names=COLUMNS, skipinitialspace=True)
    test_df_raw = pd.read_csv("https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.test", names=COLUMNS, skipinitialspace=True, skiprows=1)

    train_df_raw[LABEL_COLUMN] = (train_df_raw['income_bracket'].apply(lambda x: '>50K' in x)).astype(int)
    test_df_raw[LABEL_COLUMN] = (test_df_raw['income_bracket'].apply(lambda x: '>50K' in x)).astype(int)
    # Preprocessing Features
    pd.options.mode.chained_assignment = None  # default='warn'

    # Functions for preprocessing categorical and continuous columns.
    def binarize_categorical_columns(input_train_df, input_test_df, categorical_columns=[]):

        def fix_columns(input_train_df, input_test_df):
            test_df_missing_cols = set(input_train_df.columns) - set(input_test_df.columns)
            for c in test_df_missing_cols:
                input_test_df[c] = 0
                train_df_missing_cols = set(input_test_df.columns) - set(input_train_df.columns)
            for c in train_df_missing_cols:
                input_train_df[c] = 0
                input_train_df = input_train_df[input_test_df.columns]
            return input_train_df, input_test_df

        # Binarize categorical columns.
        binarized_train_df = pd.get_dummies(input_train_df, columns=categorical_columns)
        binarized_test_df = pd.get_dummies(input_test_df, columns=categorical_columns)
        # Make sure the train and test dataframes have the same binarized columns.
        fixed_train_df, fixed_test_df = fix_columns(binarized_train_df, binarized_test_df)
        return fixed_train_df, fixed_test_df

    def bucketize_continuous_column(input_train_df,
                                  input_test_df,
                                  continuous_column_name,
                                  num_quantiles=None,
                                  bins=None):
        assert (num_quantiles is None or bins is None)
        if num_quantiles is not None:
            train_quantized, bins_quantized = pd.qcut(
              input_train_df[continuous_column_name],
              num_quantiles,
              retbins=True,
              labels=False)
            input_train_df[continuous_column_name] = pd.cut(
              input_train_df[continuous_column_name], bins_quantized, labels=False)
            input_test_df[continuous_column_name] = pd.cut(
              input_test_df[continuous_column_name], bins_quantized, labels=False)
        elif bins is not None:
            input_train_df[continuous_column_name] = pd.cut(
              input_train_df[continuous_column_name], bins, labels=False)
            input_test_df[continuous_column_name] = pd.cut(
              input_test_df[continuous_column_name], bins, labels=False)

    # Filter out all columns except the ones specified.
    train_df = train_df_raw[CATEGORICAL_COLUMNS + CONTINUOUS_COLUMNS + [LABEL_COLUMN]]
    test_df = test_df_raw[CATEGORICAL_COLUMNS + CONTINUOUS_COLUMNS + [LABEL_COLUMN]]
    
    # Bucketize continuous columns.
    bucketize_continuous_column(train_df, test_df, 'age', num_quantiles=4)
    bucketize_continuous_column(train_df, test_df, 'capital_gain', bins=[-1, 1, 4000, 10000, 100000])
    bucketize_continuous_column(train_df, test_df, 'capital_loss', bins=[-1, 1, 1800, 1950, 4500])
    bucketize_continuous_column(train_df, test_df, 'hours_per_week', bins=[0, 39, 41, 50, 100])
    bucketize_continuous_column(train_df, test_df, 'education_num', bins=[0, 8, 9, 11, 16])
    train_df, test_df = binarize_categorical_columns(train_df, test_df, categorical_columns=CATEGORICAL_COLUMNS + CONTINUOUS_COLUMNS)
    feature_names = list(train_df.keys())
    feature_names.remove(LABEL_COLUMN)
    num_features = len(feature_names)
    
    return train_df, test_df, feature_names

train_df, test_df, FEATURE_NAMES = get_data()

Model.

We use a linear model and predict positively or negatively based on threshold at 0.

In the following code, we initialize the placeholders and model. In build_train_op, we set up the constrained optimization problem. We create a rate context for the entire dataset, and compute the overall false positive rate as the positive prediction rate on the negatively labeled subset. We then construct a constraint for each of the protected groups based on the difference between the true positive rates of the protected group and that of the overall dataset. We then construct a minimization problem using RateMinimizationProblem and use the ProxyLagrangianOptimizerV1 as the solver. build_train_op initializes a training operation which will later be used to actually train the model.


In [3]:
class Model(object):
    def __init__(self,
                 tpr_max_diff=0):
        tf.random.set_random_seed(123)
        self.tpr_max_diff = tpr_max_diff
        num_features = len(FEATURE_NAMES)
        self.features_placeholder = tf.placeholder(
            tf.float32, shape=(None, num_features), name='features_placeholder')
        self.labels_placeholder = tf.placeholder(
            tf.float32, shape=(None, 1), name='labels_placeholder')
        self.protected_placeholders = [tf.placeholder(tf.float32, shape=(None, 1), name=attribute+"_placeholder") for attribute in PROTECTED_COLUMNS]
        # We use a linear model.
        self.predictions_tensor = tf.layers.dense(inputs=self.features_placeholder, units=1, activation=None)


    def build_train_op(self,
                       learning_rate,
                       unconstrained=False):
        ctx = tfco.rate_context(self.predictions_tensor, self.labels_placeholder)
        positive_slice = ctx.subset(self.labels_placeholder > 0) 
        overall_tpr = tfco.positive_prediction_rate(positive_slice)
        constraints = []
        if not unconstrained:
            for placeholder in self.protected_placeholders:
                slice_tpr = tfco.positive_prediction_rate(ctx.subset((placeholder > 0) & (self.labels_placeholder > 0)))
                constraints.append(slice_tpr >= overall_tpr - self.tpr_max_diff)
        mp = tfco.RateMinimizationProblem(tfco.error_rate(ctx), constraints)
        opt = tfco.ProxyLagrangianOptimizerV1(tf.train.AdamOptimizer(learning_rate))
        self.train_op = opt.minimize(mp)
        return self.train_op
  
    def feed_dict_helper(self, dataframe):
        feed_dict = {self.features_placeholder:
                  dataframe[FEATURE_NAMES],
              self.labels_placeholder:
                  dataframe[[LABEL_COLUMN]],}
        for i, protected_attribute in enumerate(PROTECTED_COLUMNS):
            feed_dict[self.protected_placeholders[i]] = dataframe[[protected_attribute]]
        return feed_dict

Training.

Below is the function which performs the training of our constrained optimization problem. Each call to the function does one epoch through the dataset and then yields the training and testing predictions.


In [4]:
def training_generator(model,
                       train_df,
                       test_df,
                       minibatch_size,
                       num_iterations_per_loop=1,
                       num_loops=1):
    random.seed(31337)
    num_rows = train_df.shape[0]
    minibatch_size = min(minibatch_size, num_rows)
    permutation = list(range(train_df.shape[0]))
    random.shuffle(permutation)

    session = tf.Session()
    session.run((tf.global_variables_initializer(),
               tf.local_variables_initializer()))

    minibatch_start_index = 0
    for n in xrange(num_loops):
        for _ in xrange(num_iterations_per_loop):
            minibatch_indices = []
            while len(minibatch_indices) < minibatch_size:
                minibatch_end_index = (
                minibatch_start_index + minibatch_size - len(minibatch_indices))
                if minibatch_end_index >= num_rows:
                    minibatch_indices += range(minibatch_start_index, num_rows)
                    minibatch_start_index = 0
                else:
                    minibatch_indices += range(minibatch_start_index, minibatch_end_index)
                    minibatch_start_index = minibatch_end_index
                    
            session.run(
                  model.train_op,
                  feed_dict=model.feed_dict_helper(
                      train_df.iloc[[permutation[ii] for ii in minibatch_indices]]))

        train_predictions = session.run(
            model.predictions_tensor,
            feed_dict=model.feed_dict_helper(train_df))
        test_predictions = session.run(
            model.predictions_tensor,
            feed_dict=model.feed_dict_helper(test_df))

        yield (train_predictions, test_predictions)

Computing accuracy and fairness metrics.


In [5]:
def error_rate(predictions, labels):
    signed_labels = (
      (labels > 0).astype(np.float32) - (labels <= 0).astype(np.float32))
    numerator = (np.multiply(signed_labels, predictions) <= 0).sum()
    denominator = predictions.shape[0]
    return float(numerator) / float(denominator)


def positive_prediction_rate(predictions, subset):
    numerator = np.multiply((predictions > 0).astype(np.float32),
                          (subset > 0).astype(np.float32)).sum()
    denominator = (subset > 0).sum()
    return float(numerator) / float(denominator)

def tpr(df):
    """Measure the true positive rate."""
    fp = sum((df['predictions'] >= 0.0) & (df[LABEL_COLUMN] > 0.5))
    ln = sum(df[LABEL_COLUMN] > 0.5)
    return float(fp) / float(ln)

def _get_error_rate_and_constraints(df, tpr_max_diff):
    """Computes the error and fairness violations."""
    error_rate_local = error_rate(df[['predictions']], df[[LABEL_COLUMN]])
    overall_tpr = tpr(df)
    return error_rate_local, [(overall_tpr - tpr_max_diff) - tpr(df[df[protected_attribute] > 0.5]) for protected_attribute in PROTECTED_COLUMNS]

def _get_exp_error_rate_constraints(cand_dist, error_rates_vector, constraints_matrix):
    """Computes the expected error and fairness violations on a randomized solution."""
    expected_error_rate = np.dot(cand_dist, error_rates_vector)
    expected_constraints = np.matmul(cand_dist, constraints_matrix)
    return expected_error_rate, expected_constraints

def training_helper(model,
                    train_df,
                    test_df,
                    minibatch_size,
                    num_iterations_per_loop=1,
                    num_loops=1):
    train_error_rate_vector = []
    train_constraints_matrix = []
    test_error_rate_vector = []
    test_constraints_matrix = []
    for train, test in training_generator(
      model, train_df, test_df, minibatch_size, num_iterations_per_loop,
      num_loops):
        train_df['predictions'] = train
        test_df['predictions'] = test

        train_error_rate, train_constraints = _get_error_rate_and_constraints(
          train_df, model.tpr_max_diff)
        train_error_rate_vector.append(train_error_rate)
        train_constraints_matrix.append(train_constraints)

        test_error_rate, test_constraints = _get_error_rate_and_constraints(
            test_df, model.tpr_max_diff)
        test_error_rate_vector.append(test_error_rate)
        test_constraints_matrix.append(test_constraints)

    return (train_error_rate_vector, train_constraints_matrix, test_error_rate_vector, test_constraints_matrix)

Baseline without constraints.

We now declare the model, build the training op, and then perform the training. We use a linear classifier, and train using the ADAM optimizer with learning rate 0.01, with minibatch size of 100 over 40 epochs. We first train without fairness constraints to show the baseline performance. We see that without training fair fairness, we obtain a high fairness violation.


In [6]:
model = Model(tpr_max_diff=0.05)
model.build_train_op(0.01, unconstrained=True)

# training_helper returns the list of errors and violations over each epoch.
train_errors, train_violations, test_errors, test_violations = training_helper(
      model,
      train_df,
      test_df,
      100,
      num_iterations_per_loop=326,
      num_loops=40)

In [7]:
print("Train Error", train_errors[-1])
print("Train Violation", max(train_violations[-1]))
print()
print("Test Error", test_errors[-1])
print("Test Violation", max(test_violations[-1]))


Train Error 0.14213322686649674
Train Violation 0.018718948559792892

Test Error 0.1440329218106996
Test Violation 0.03962224410764137

Training with fairness constraints.

We now show train with the constraints using the procedure of [CoJiSr19] and returning the last solution found. We see that the fairness violation improves.

We allow an additive fairness slack of 0.05. That is, when training and evaluating the fairness constraints, the true positive rate difference between protected group has to be at least that of the overall dataset up to a slack of at most 0.05. Thus, the fairness constraints would be of the form TPR_p >= TPR - 0.05, where TPR_p and TPR denotes the true positive rates of the protected group and the overall dataset, respectively.


In [8]:
model = Model(tpr_max_diff=0.05)
model.build_train_op(0.01, unconstrained=False)

# training_helper returns the list of errors and violations over each epoch.
train_errors, train_violations, test_errors, test_violations = training_helper(
      model,
      train_df,
      test_df,
      100,
      num_iterations_per_loop=326,
      num_loops=40)

In [9]:
print("Train Error", train_errors[-1])
print("Train Violation", max(train_violations[-1]))
print()
print("Test Error", test_errors[-1])
print("Test Violation", max(test_violations[-1]))


Train Error 0.1428088817910998
Train Violation -0.01537447927428448

Test Error 0.14372581536760642
Test Violation 0.026628986947187405

Improving using Best Iterate instead of Last Iterate.

As discussed in [CotterEtAl18b], the last iterate may not be the best choice and suggests a simple heuristic to choose the best iterate out of the ones found after each epoch. The heuristic proceeds by ranking each of the solutions based on accuracy and fairness separately with respect to the training data. Any solutions which satisfy the constraints are equally ranked top in terms fairness. Each solution thus has two ranks. Then, the chosen solution is the one with the smallest maximum of the two ranks. We see that this improves the fairness and can find a better accuracy / fairness trade-off on the training data.

This solution can be calculated using find_best_candidate_index given the list of training errors and violations associated with each of the epochs.


In [10]:
best_cand_index = tfco.find_best_candidate_index(train_errors, train_violations)

print("Train Error", train_errors[best_cand_index])
print("Train Violation", max(train_violations[best_cand_index]))
print()
print("Test Error", test_errors[best_cand_index])
print("Test Violation", max(test_violations[best_cand_index]))


Train Error 0.14133472559196586
Train Violation -0.004157023292723272

Test Error 0.14348013021313188
Test Violation 0.01559670208037367

Using stochastic solutions.

As discussed in [CoJiSr19], neither the best nor last iterate will come with theoretical guarantees. One can instead use randomized solutions, which come with theoretical guarantees. However, as discussed in [CotterEtAl18b], there may not always be a clear practical benefits. We show how to use these solutions here for sake of completeness.

T-stochastic solution.

The first and simplest randomized solution suggested is the T-stochastic, which simply takes the average of all of the iterates found at each epoch.


In [11]:
print("Train Error", np.mean(train_errors))
print("Train Violation", max(np.mean(train_violations, axis=0)))
print()
print("Test Error", np.mean(test_errors))
print("Test Violation", max(np.mean(test_violations, axis=0)))


Train Error 0.14204109210405086
Train Violation 0.00040501346694489656

Test Error 0.14335882316811008
Test Violation 0.021693837027224078

m-stochastic solution.

[CoJiSr19] presents a method which shrinks down the T-stochastic solution down to one that is supported on at most (m+1) points where m is the number of constraints and is guaranteed to be at least as good as the T-stochastic solution. Here we see that indeed there is benefit in performing the shrinking.

This solution can be computed using find_best_candidate_distribution by passing in the training errors and violations found at each epoch and returns the weight of each constituent. We see that indeed, it is sparse.


In [12]:
cand_dist = tfco.find_best_candidate_distribution(train_errors, train_violations)
print(cand_dist)


[0.         0.         0.         0.         0.         0.
 0.         0.         0.         0.         0.         0.27921444
 0.         0.         0.         0.         0.         0.
 0.         0.         0.         0.72078556 0.         0.
 0.         0.         0.         0.         0.         0.
 0.         0.         0.         0.         0.         0.
 0.         0.         0.         0.        ]

In [13]:
m_stoch_error_train, m_stoch_violations_train = _get_exp_error_rate_constraints(cand_dist, train_errors, train_violations)
m_stoch_error_test, m_stoch_violations_test = _get_exp_error_rate_constraints(cand_dist, test_errors, test_violations)

print("Train Error", m_stoch_error_train)
print("Train Violation", max(m_stoch_violations_train))
print()
print("Test Error", m_stoch_error_test)
print("Test Violation", max(m_stoch_violations_test))


Train Error 0.14129184999836308
Train Violation 1.470178145890344e-16

Test Error 0.14330863310567057
Test Violation 0.01799448570692895