# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# + tags=["hide-cell"]
# HIDDEN
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

import warnings
warnings.filterwarnings('ignore')

from sklearn import metrics
sns.set(rc={'figure.figsize':(12,6)})

url = 'https://raw.githubusercontent.com/propublica/compas-analysis/master/compas-scores-two-years.csv'
recidivism = (
    pd.read_csv(url)
    .drop(['name', 'first', 'last'], axis=1)
    .assign(pred_recid=lambda x:x['score_text'].isin(['Medium', 'High']).astype(int))
)

# order for bar charts
order = ['African-American', 'Caucasian', 'Hispanic', 'Asian', 'Native American', 'Other']
# -

# # COMPAS Analysis II
#
# In this section, we revisit the case of COMPAS, paying attention to observations made by parity criteria and understanding to what extent that existing COMPAS scores can give rise to fairer decisions.  
#
# ## Parity Measures
#
# First, we examine the parity of recommended decisions by the COMPAS score, interpreting the results in turn. We will define the recommended decision to one that, according to ProPublica, broadly falls in line with usage in Broward County:
# * An individual is predicted to re-offend if their COMPAS score is greater than or equal to 5. This corresponds to COMPAS labeling an individual medium or high risk.
# * An individual is predicted to *not* re-offend if their COMPAS score is less than 5. This corresponds to COMPAS labeling an individual low risk.
#
# Each parity measure is calculated with 95% confidence intervals.

# **Demographic Parity**. The rates of predicted re-offense varies drastically:
# 1. Approximately 60% of Black and Native American defendants are predicted to re-offend
# 2. Approximately 30% of White, Hispanic, and Asian defendants are predicted to re-offend
#
# Arguing for Demographic Parity to hold in this context is uncommon, as each group has different *true* rates of re-offense. In a fair society, one would assume that the crime-rates across different groups is identical. However, this is not currently the case. While imposing demographic parity as a long-term policy initiative is common for decisions related to *opportunity*, it makes less sense as a criteria for decisions that remove fundamental freedoms.

a = recidivism
sns.barplot(data=a, x='race', y='pred_recid', order=order)
plt.suptitle('Demographic Parity Criteria');

# **Accuracy Parity**. The rates of accurate predictions are the same among groups, within the standard of error. Northpointe used this criteria when developing the COMPAS model. The model arrives at the *right* prediction 60% of the time.

# +
a = (
    recidivism
    .assign(
        accuracy=(~(recidivism['is_recid'] + recidivism['pred_recid']) % 2)
    )
)

sns.barplot(data=a, x='race', y='accuracy', order=order)
plt.suptitle('Accuracy Parity Criteria');
# -

# **Predictive Value Parity**. COMPAS largely maintains consistent rates of recidivism across groups, among populations they deem high and low risk. That is:
# * Among those labeled high risk by COMPAS, approximately 60% re-offended
# * Among those labeled low risk by COMPAS, approximately 60% did not re-offend.
#

# +
fig, (ax1, ax2) = plt.subplots(1,2, figsize=(18,6))

a1 = recidivism[recidivism['pred_recid'] == 1].assign(PPV=lambda x:x['is_recid'])
sns.barplot(data=a1, x='race', y='PPV', ax=ax1, order=order)
ax1.set_title('PPV')

a2 = recidivism[recidivism['pred_recid'] == 0].assign(NPV=lambda x:1 - x['is_recid'])
sns.barplot(data=a2, x='race', y='NPV', ax=ax2, order=order)
ax2.set_title('NPV');

plt.suptitle('Predictive Value Parity Criteria');
# -

# **Equalized Odds**. The False Positive and False Negative rates vary significantly across groups and Black defendants experience consistently unfair treatment as compared to other defendants.
# * White defendants are improperly released 66% more often than Black defendants.
# * Black defendants are improperly denied release twice as often as White defendants.

# +
fig, (ax1, ax2) = plt.subplots(1,2, figsize=(18,6))

a1 = recidivism[recidivism['is_recid'] == 1].assign(FN=lambda x:1 - x['pred_recid'])
sns.barplot(data=a1, x='race', y='FN', ax=ax1, order=order)
ax1.set_title('False Negative')

a2 = recidivism[recidivism['is_recid'] == 0].assign(FP=lambda x:x['pred_recid'])
sns.barplot(data=a2, x='race', y='FP', ax=ax2, order=order)
ax2.set_title('False Positive');

plt.suptitle('Equalized Odds Criteria');
# -

# ## Calibration
#
# The COMPAS scores mean to capture the likelihood of re-offense by defendants. On the one hand, the distribution of risk across the population differs by group:
# * COMPAS scores deem white defendants primarily low risk (60%), with very few predicted as high risk (10%).
# * Black defendants risk scores are roughly uniformly distributed (40% low risk; 30% high risk).
#
# Given all individuals considered here have been arrested, this difference likely points to differences in the behavior of the population (e.g. arrested for different crimes), as well as differences in what the COMPAS score attempts to measure. This difference in scores leads to drastically different racial make-up per decile.

cols = ['race', 'decile_score', 'is_recid']
df = recidivism[cols].loc[lambda x:x['race'].isin(['African-American', 'Caucasian'])]

# +
fig, (ax1, ax2) = plt.subplots(1,2)

a1 = (
    df
    .groupby(['race'])['decile_score']
    .value_counts(normalize=True)
    .rename('proportion')
    .reset_index()
    .sort_values(by='decile_score')
)

sns.barplot(data=a1, x='decile_score' ,y='proportion',hue='race', ax=ax1)
ax1.set_title('Distribution of Scores by Race')

a2 = (
    df
    .groupby(['decile_score'])['race']
    .value_counts(normalize=True)
    .rename('proportion')
    .reset_index()
    .sort_values(by='race')
)

sns.barplot(data=a2, x='decile_score' ,y='proportion',hue='race', ax=ax2);
ax2.set_title('Distribution of Race by Score');
# -

# The condition for calibration given in ___ requires the proportion of recidivism in each decile grow with the decile. That is,
#
# $$
# P(Y = 1|S = p) = p
# $$
#
# In each COMPAS score, the recidivism rate increases approximately 10% with each decile, though the condition does not strictly hold.

sns.barplot(data=df, x='decile_score', y='is_recid', color='orange')
plt.suptitle('Recidivism rate of each decile (calibration)');

# COMPAS likely used a related notion of calibration. Among all re-offending individuals, $p$% of individuals have a score at least as high as $p$. Thus, if a threshold for a classifier is set at score $p$, that classifier would correctly predict $p$% of all re-offenses.
#

# +
tots_by_dec = df.groupby('decile_score').is_recid.sum()

title = '% of recidivism above each decile'
(tots_by_dec.iloc[::-1].cumsum() / tots_by_dec.sum()).plot(kind='bar', title=title);
# -

# Within each group, the recidivism rates for each decile are similar:

sns.barplot(data=df, x='decile_score', y='is_recid', hue='race')
plt.suptitle('group-wise recidividsm rates by decile');

# On the other hand, the proportion of re-offenders below each potential threshold varies by race. Almost 40% of all recidivism in the Black population lies in the top three deciles, whereas the same 40% in the White population requires thresholding at the top five deciles.

(
    df
    .groupby(['race', 'decile_score'])
    .is_recid
    .sum()    
    .rename('tot')
    .reset_index()
    .pivot(columns='race', index='decile_score', values='tot')
    .apply(lambda x:x / x.sum())
    .iloc[::-1,:]
    .cumsum()
).plot(kind='bar', title='% of recidivism above each decile by race');

# ## Balance of positive/negative class
#
# Much like the equalized odds parity condition, balance of positive/negative class requires scores to be similar, conditional on the true outcome. COMPAS assigns much higher risk to Black defendants, regardless of whether they re-offended or not.

# +
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18,6))

sns.barplot(data=df, x='is_recid', y='decile_score', ax = ax1)
ax1.set_title('balance of pos/neg class (population)')

sns.barplot(data=df, x='is_recid', y='decile_score', hue='race', ax=ax2)
ax2.set_title('balance of pos/neg class by race');
# -

# ## ROC Curve
#
# Judges use COMPAS deciles to make decisions, as well the text descriptors ("Low, Medium, High Risk") offered by Northpointe.  These text descriptors correspond to score thresholds of 4 and 7.

recidivism.groupby(['decile_score', 'score_text']).size().rename('counts').to_frame()

# What if we could provide judges with an interpretation of these scores that are more sensitive to race? To this aim, we will analyze the scores of the COMPAS model more broadly. The ROC curve for the COMPAS score parameterizes prediction performance (TPR, FPR) by score thresholds:

# +
fpr, tpr, thresh = metrics.roc_curve(df['is_recid'], df['decile_score'])

ax = (
    pd.DataFrame(tpr, index=fpr)
    .plot(legend=False, title=('ROC Curve: COMPAS\n AUC: %f' % metrics.auc(fpr, tpr)))
)
ax.plot([(0,0), (1,1)], 'r--')
ax.set_xlabel('FPR')
ax.set_ylabel('TPR');


# -

# Calculating separate ROC curves by group, we see similar overall performance. However, we do see two important differences in the curves:
# 1. The curve corresponding to the White population has higher TPR at *low* values of FPR.
# 2. The curve corresponding to the Black population has higher TPR at *high* values of FPR.
#
# Any realistic choice of threshold (operating point) would fix a relatively low FPR, resulting in a model that performs better on the White population.
#
# However, one sees a large difference in model characteristics when keeping track of where the score thresholds lie. The granularity of the thresholds are concentrated at opposite ends of the respective curves; the scores mean different things for the different groups.

# +
def calc_roc(x, **kwargs):
    
    fpr, tpr, thresh = metrics.roc_curve(x['is_recid'], x['decile_score'], **kwargs)
    return pd.DataFrame({'fpr': fpr, 'tpr': tpr, 'thresh': thresh})

a = df.groupby('race').apply(calc_roc).reset_index(level=0).reset_index(drop=True)
a_blk = a[a.race == 'African-American'].drop('race', axis=1).reset_index(drop=True)
a_wht = a[a.race == 'Caucasian'].drop('race', axis=1).reset_index(drop=True)

# +
fig, (ax1, ax2) = plt.subplots(1,2, figsize=(16,8))

a_blk.plot(x='fpr', y='tpr', ax=ax1)
a_wht.plot(x='fpr', y='tpr', ax=ax1)
ax1.plot([(0,0), (1,1)], 'r--')

ax1.set_title('ROC Curves by Race')

auc_blk = metrics.auc(a_blk['fpr'], a_blk['tpr'])
auc_wht = metrics.auc(a_wht['fpr'], a_wht['tpr'])

ax1.legend(['African-American (AUC: %f)' % auc_blk, 'Caucasian (AUC: %f)' % auc_wht]);

a_blk.plot(x='fpr', y='tpr', ax=ax2)
a_blk.plot(kind='scatter', x='fpr', y='tpr', ax=ax2, c='blue')

a_wht.plot(x='fpr', y='tpr', ax=ax2)
a_wht.plot(kind='scatter', x='fpr', y='tpr', ax=ax2, c='orange')

ax2.plot([(0,0), (1,1)], 'r--')

ax2.set_title('ROC Curves by Race (with score thresholds)')

ax2.legend(['African-American', 'Caucasian']);
# -

# The differences in performance for given thresholds are easily read off the rows of the table:

pd.concat([a_wht.set_index('thresh'), a_blk.set_index('thresh')], axis=1, keys=['White', 'Black']).iloc[1:]


# ## Choosing a model (To be completed)
#
# Is there a model from these scores that may be 'more fair' than the existing COMPAS recommendations?
#
# * What is the 'best' model?
# * Need to calculate the utility

# +
def calc_utility(df, thresh, wt):

    return metrics.accuracy_score(
        df['is_recid'], 
        (df.decile_score >= thresh).astype(int), 
        sample_weight=df['is_recid'].replace({1: wt, 0:1})
    )

def find_best_thresh(df, wt=0.5):
    
    thresh, util = 0, 0
    for k in range(1, 11):
        m = calc_utility(df, k, wt)
        if m >= util:
            util = m
            thresh = k
    
    return thresh


# -

a = pd.DataFrame(
    {str(round(i,2)):[calc_utility(df, k, i) for k in range(1,11)] for i in np.arange(0,1.01,0.1)}
).T

a

ax = a.T.plot(figsize=(12,6))
ax.legend(bbox_to_anchor=(1., 1.), bbox_transform=ax.transAxes);

# ### Single Threshold Model
#

find_best_thresh(df, wt=0.7)


# ### ROC Curve with best single threshold

# +
fpr, tpr, thresh = metrics.roc_curve(df['is_recid'], df['decile_score'])


ax = pd.DataFrame(tpr, index=fpr).plot(legend=False, title='ROC Curve: COMPAS')
ax.plot([fpr[5]], [tpr[5]], 'ro')
ax.plot([(0,0), (1,1)], 'r--');
# -

# ### Threshold for each group

# +
fig, ax = plt.subplots(1,1, figsize=(6,6))

a_blk.plot(x='fpr', y='tpr', ax=ax)
a_wht.plot(x='fpr', y='tpr', ax=ax)
ax.plot([a_blk.loc[5, 'fpr']], [a_blk.loc[5, 'tpr']], 'bo')
ax.plot([a_wht.loc[5, 'fpr']], [a_wht.loc[5, 'tpr']], 'o', color='orange')

ax.plot([(0,0), (1,1)], 'r--')

ax.set_title('ROC Curve')

plt.legend(['African-American', 'Caucasian']);
# -

# ### Group-aware thresholds
#
# * Create a model with possibly different thresholds per group
# * Race was *not* a variable for COMPAS.

find_best_thresh(df[df.race == 'Caucasian'], wt=0.7)

find_best_thresh(df[df.race == 'African-American'], wt=0.7)

# +
fig, ax = plt.subplots(1,1, figsize=(6,6))

a_blk.plot(x='fpr', y='tpr', ax=ax)
a_wht.plot(x='fpr', y='tpr', ax=ax)
ax.plot([a_blk.loc[5, 'fpr']], [a_blk.loc[5, 'tpr']], 'bo')
ax.plot([a_wht.loc[4, 'fpr']], [a_wht.loc[4, 'tpr']], 'o', color='orange')

ax.plot([(0,0), (1,1)], 'r--')

ax.set_title('ROC Curve')

plt.legend(['African-American', 'Caucasian']);
# -

# ### TPR-Parity / FPR-Parity / Equalized Odds
#
# * Can we choose a 'best model' subject to Equalized Odds?
# * Plot the ROC curves (or table) and figure it out by hand

pd.concat([a_wht.set_index('thresh'), a_blk.set_index('thresh')], axis=1, keys=['White', 'Black'])

# * TPR-Parity at:
#     - (Black, White) = (10, 9), (9,7), (8,6), (7,5), (6,4), (5,3)
#     - Maximize utility at (8,6)
# * FPR-Parity at:
#     - (Black, White) = (10, 9), (8, 6), (7,5), (6,4)
#     - Maximize utility at (8,6)
# * Note: approximately equalized odds as well.
# * What is PPV at (8,6)?

# ### Demographic Parity
#
# * Demographic parity is not something asked for by any groups involved
# * What would this look like?
# * "same proportion of Black/White defendants predicted 'reoffend'

# ### Proportion White for each decile:

df.assign(is_white=(df['race'] == 'Caucasian').astype(int)).groupby('decile_score').is_white.mean().plot(kind='bar');

# ### What thresholds yield the best model, subject to Demographic Parity
#
# * Iterate through all pairs of thresholds and calculate the decision by model
# * Calculate proportion 'predict reoffend' to find demographic parity
# * Find the pairs of thresholds with highest utility

sc = df.copy()
for tw in range(1,11):
    for tb in range(1,11):
        sc.loc[sc.race == 'Caucasian', 'decision_%d_%d' %(tw, tb)] = (sc.loc[sc.race == 'Caucasian', 'decile_score'] >= tw).astype(int)
        sc.loc[sc.race == 'African-American', 'decision_%d_%d' %(tw, tb)] = (sc.loc[sc.race == 'African-American', 'decile_score'] >= tb).astype(int)


demos = sc[['race'] + [x for x in sc.columns if 'decision_' in x]].groupby('race').mean().T
demos

(demos['African-American'] - demos['Caucasian']).sort_values().loc[lambda x:(-0.02 <= x) & (x <= 0.02)]



def weighted_accuracy(df, col, wt=0.7):
    
    return metrics.accuracy_score(
        df['is_recid'], 
        col, 
        sample_weight=df['is_recid'].replace({1: wt, 0:1})
    )


(
    sc
    .loc[:,[x for x in sc.columns if 'decision_' in x]]
    .apply(lambda x:weighted_accuracy(sc, x))
    .sort_values(ascending=False)
)

# ### Demographic Parity
#
# * Choose a model with the following thresholds:
#     - $t_w = 7$ for White Defendants
#     - $t_b = 9$ for Black Defendants
# * The thresholds, as we are valuing FP:FN at a 2:1 ratio
#     - Also: a lot of mass on Black population at higher end

StarcoderdataJupyterScriptsDedupFiltered