Orthogonal projections

Quick example

from fairml import audit_model
import numpy as np
from sklearn.linear_model import LogisticRegression
import pandas as pd

# Read in the propublica data to be used for our analysis.
propublica_data = pd.read_csv("data/propublica.csv")

# Create feature and design matrix for model building.
compas_rating = propublica_data.score_factor.values
propublica_data = propublica_data.drop("score_factor", 1)

# Train simple model
clf = LogisticRegression(penalty='l2', C=0.01)
clf.fit(propublica_data.values, compas_rating)
LogisticRegression(C=0.01)
importances, _ = audit_model( clf.predict, propublica_data)

print(importances)
Feature: Two_yr_Recidivism,	 Importance: 0.23817239144523655
Feature: Number_of_Priors,	 Importance: 0.34996759559300067
Feature: Age_Above_FourtyFive,	 Importance: -0.012313674659753726
Feature: Age_Below_TwentyFive,	 Importance: 0.13399222294232016
Feature: African_American,	 Importance: 0.23136746597537264
Feature: Asian,	 Importance: -0.0003240440699935191
Feature: Hispanic,	 Importance: -0.008911211924821775
Feature: Native_American,	 Importance: 0.0004860661049902787
Feature: Other,	 Importance: -0.004374594944912508
Feature: Female,	 Importance: 0.04455605962410888
Feature: Misdemeanor,	 Importance: -0.057031756318859365
%matplotlib inline
from fairml import plot_dependencies

plot_dependencies(
    importances.median(),
    reverse_values=False,
    title="FairML feature dependence"
)
_images/fairness-orthogonal-projections_5_0.svg _images/fairness-orthogonal-projections_5_1.svg

Deep dive

Auditing the model

Model auditing requires the following information:

  • an estimator , a black-box function that has a predict method

  • an input dataframe , a dataframe with shape (n_samples, n_features)

  • a distance metric , for instance MSE or accuracy

  • a direct input pertubation strategy , some possible strategies are

    • replace with a random constant value

    • replace with median constant value

    • replace all values with a random permutation of the column

  • A stopping criteria , for instance, number of runs

We start by defining some common variables:

number_of_runs=10
distance_metric="mse"
def constant_zero(X, column_number, random_sample):
    return 0.0


def constant_median(X, column_number, random_sample):
    return np.median(X[:, column_number])


def random_sample(X, column_number, random_sample):
    return random_sample[random_sample]


perturbation_strategy_dictionary = {
    'constant-zero': constant_zero,
    'constant-median': constant_median,
    'random-sample': random_sample
}
def mse(y, y_hat):
    """ function to calculate mse between to numpy vectors """

    y = np.array(y)
    y_hat = np.array(y_hat)

    y_hat = np.reshape(y_hat, (y_hat.shape[0],))
    y = np.reshape(y, (y.shape[0],))

    diff = y - y_hat
    diff_squared = np.square(diff)
    mse = np.mean(diff_squared)

    return mse

We start by creating the output dictionaries:

from collections import defaultdict

# create output dictionaries
direct_pertubation_feature_output_dictionary = defaultdict(list)
complete_perturbation_dictionary = defaultdict(list)
list_of_column_names = propublica_data.columns
number_of_features = propublica_data.shape[1]
print(f"Number of features = {number_of_features}")
Number of features = 11

Convert data to a numpy array:

data = propublica_data.values
data
array([[0, 0, 1, ..., 1, 0, 0],
       [1, 0, 0, ..., 0, 0, 0],
       [1, 4, 0, ..., 0, 0, 0],
       ...,
       [0, 0, 1, ..., 1, 0, 0],
       [0, 3, 0, ..., 0, 1, 1],
       [1, 2, 0, ..., 0, 1, 0]])

Get the normal output:

normal_black_box_output = clf.predict(data)
normal_black_box_output
array([0, 0, 1, ..., 0, 0, 1])
def obtain_orthogonal_transformed_matrix(X, baseline_vector,
                                         column_to_skip=-1):
    """
    X is the column that has the data
    orthogonal vector is a baseline vector that we want to make the columns of
    X orthogonal to.
    skip column_to_skip if possible.
    """

    # first check to make sure that the matrix and vector have similar lengths
    # for shape
    if X.shape[0] != baseline_vector.shape[0]:
        raise ValueError('Need to be the same shape')

    for column in range(X.shape[1]):
        # you might want to skip the constant column
        # for interactions, you don't actually have them in the data
        # so you don't want to skip any column.
        if column == column_to_skip:
            continue
        orthogonal_column = get_orthogonal_vector(
            baseline_vector, X[:, column])
        X[:, column] = orthogonal_column
    return X
def get_orthogonal_vector(v1, v2):
    """
    Parameters
    ------------
    v1 - baseline vector (numpy)
    v2 - vector that you'd like to make orthogonal to v1
    Returns
    -------------
    orthogonal_v2, projection of v2 that is orthogonal to v1
    """

    # check that the two vectors are the same length
    v1 = np.array(v1)
    v2 = np.array(v2)
    if v1.shape[0] != v2.shape[0]:
        return "Error, both vectors are not of the same length"

    scaling = np.dot(v1, v2) / np.dot(v1, v1)
    orthogonal_v2 = v2 - (scaling * v1)
    return orthogonal_v2

A function to permutate columns

def replace_column_of_matrix(X, col_num, random_sample,
                             ptb_strategy):
    """
    Arguments: data matrix, n X k
    random sample: row of data matrix, 1 X k
    column number: 0 <-> k-1
    replace all elements of X[column number] X
    with random_sample[column_number]
    """

    # need to implement random permutation.
    # need to implement perturbation strategy as a function
    # need a distance metrics file.
    # this probably does not work right now, I need to go through to fix.
    if col_num >= random_sample.shape[0]:
        raise ValueError("column {} entered. Column # should be"
                         "less than {}".format(col_num,
                                               random_sample.shape[0]))

    # select the specific perturbation function chosen
    # obtain value from that function
    val_chosen = perturbation_strategy_dictionary[ptb_strategy](X,
                                                                col_num,
                                                                random_sample)
    constant_array = np.repeat(val_chosen, X.shape[0])
    X[:, col_num] = constant_array

    return X

What we’ll do first is to iterate over each column. We’ll first pick a random column to illustrate the process.

from random import randint

random_row_to_select = randint(0, propublica_data.shape[0] - 1)
random_sample_selected = data[random_row_to_select, :]

print(f"Random row = {random_row_to_select}")
print(f"Random sample = {random_sample_selected}")
Random row = 4004
Random sample = [0 4 1 0 1 0 0 0 0 0 0]
for col in range(number_of_features):
    # get reference vector
    reference_vector = data[:, col]
    data_col_ptb = replace_column_of_matrix(
                np.copy(propublica_data),
                col,
                random_sample_selected,
                ptb_strategy="constant-zero")
    output_constant_col = clf.predict(data_col_ptb)
    if distance_metric == "accuracy":
        output_difference_col = accuracy(
            output_constant_col, normal_black_box_output)
    else:
        output_difference_col = mse(
            output_constant_col, normal_black_box_output)

    # store independent output by themselves
    direct_pertubation_feature_output_dictionary[
        list_of_column_names[col]].append(output_difference_col)

    # now make all the remaining columns of the matrix
    # $data_copy_with_constant_column$
    # except $col$ orthogonal to current vector of interest.

    total_ptb_data = obtain_orthogonal_transformed_matrix(
        data_col_ptb,
        reference_vector,
        column_to_skip=col)

    total_transformed_output = clf.predict(total_ptb_data)

    if distance_metric == "accuracy":
        total_difference = accuracy(
            total_transformed_output, normal_black_box_output)
    else:
        total_difference = mse(
            total_transformed_output, normal_black_box_output)

    complete_perturbation_dictionary[
        list_of_column_names[col]].append(total_difference)

Perform the straight forward linear search at first

from random import randint

for current_iteration in range(number_of_runs):
        random_row_to_select = randint(0, data.shape[0] - 1)
        random_sample_selected = data[random_row_to_select, :]

        # go over every column
        for col in range(number_of_features):
            # get reference vector
            reference_vector = data[:, col]
            data_col_ptb = replace_column_of_matrix(
                np.copy(data),
                col,
                random_sample_selected,
                ptb_strategy="constant-zero")
            output_constant_col = clf.predict(data_col_ptb)
            if distance_metric == "accuracy":
                output_difference_col = accuracy(
                    output_constant_col, normal_black_box_output)
            else:
                output_difference_col = mse(
                    output_constant_col, normal_black_box_output)

            # store independent output by themselves
            direct_pertubation_feature_output_dictionary[
                list_of_column_names[col]].append(output_difference_col)

            # now make all the remaining columns of the matrix
            # $data_copy_with_constant_column$
            # except $col$ orthogonal to current vector of interest.

            total_ptb_data = obtain_orthogonal_transformed_matrix(
                data_col_ptb,
                reference_vector,
                column_to_skip=col)

            total_transformed_output = clf.predict(total_ptb_data)

            if distance_metric == "accuracy":
                total_difference = accuracy(
                    total_transformed_output, normal_black_box_output)
            else:
                total_difference = mse(
                    total_transformed_output, normal_black_box_output)

            complete_perturbation_dictionary[
                list_of_column_names[col]].append(total_difference)
def detect_feature_sign(predict_function, X, col_num):

    normal_output = predict_function(X)
    column_range = X[:, col_num].max() - X[:, col_num].min()

    X[:, col_num] = X[:, col_num] + np.repeat(column_range, X.shape[0])
    new_output = predict_function(X)

    diff = new_output - normal_output
    total_diff = np.mean(diff)

    if total_diff >= 0:
        return 1
    else:
        return -1
for cols in range(data.shape[1]):
        sign = detect_feature_sign(clf.predict, np.copy(data), cols)

        dictionary_key = list_of_column_names[cols]

        # TO DO - change this
        # this is wasteful, need to apply the sign once to
        # summary statistic for each feature.
        # this works for now
        for i in range(len(complete_perturbation_dictionary[dictionary_key])):
            complete_perturbation_dictionary[dictionary_key][i] = (
                sign * complete_perturbation_dictionary[dictionary_key][i])
class AuditResult(dict):

    def median(self):
        new_compressed_dict = {}
        for key, value in self.items():
            new_compressed_dict[key] = np.median(np.array(value))
        return new_compressed_dict

    def __repr__(self):
        output = []
        for key, value in self.items():
            importance = np.median(np.array(value))
            output.append("Feature: {},\t Importance: {}"
                          .format(key, importance))
        return "\n".join(output)
AuditResult(complete_perturbation_dictionary)
Feature: Two_yr_Recidivism,	 Importance: 0.23817239144523655
Feature: Number_of_Priors,	 Importance: 0.34996759559300067
Feature: Age_Above_FourtyFive,	 Importance: -0.012313674659753726
Feature: Age_Below_TwentyFive,	 Importance: 0.13399222294232016
Feature: African_American,	 Importance: 0.23136746597537264
Feature: Asian,	 Importance: -0.0003240440699935191
Feature: Hispanic,	 Importance: -0.008911211924821775
Feature: Native_American,	 Importance: 0.0004860661049902787
Feature: Other,	 Importance: -0.004374594944912508
Feature: Female,	 Importance: 0.04455605962410888
Feature: Misdemeanor,	 Importance: -0.057031756318859365
AuditResult(direct_pertubation_feature_output_dictionary)
Feature: Two_yr_Recidivism,	 Importance: 0.09219053791315619
Feature: Number_of_Priors,	 Importance: 0.2801360985093973
Feature: Age_Above_FourtyFive,	 Importance: 0.021872974724562542
Feature: Age_Below_TwentyFive,	 Importance: 0.10207388204795852
Feature: African_American,	 Importance: 0.06869734283862605
Feature: Asian,	 Importance: 0.0
Feature: Hispanic,	 Importance: 0.004860661049902786
Feature: Native_American,	 Importance: 0.0
Feature: Other,	 Importance: 0.0038885288399222295
Feature: Female,	 Importance: 0.006156837329876863
Feature: Misdemeanor,	 Importance: 0.023493195074530137