<jupyter_start><jupyter_text>Label modifcation by Kamiran et al. - Adult dataThis notebook contains the implementation of the post-processing label modification algorithm introduced in [Decision Theory for Discrimination-aware Classification](https://ieeexplore.ieee.org/abstract/document/6413831) by Kamiran et al. (2012) as part of the IBM AIF360 fairness tool box github.com/IBM/AIF360.The intervention method identifies a low-confidence subset of the data for probabilistic classifiers, and produces new predicted labels for this subset by assigning positive labels to the unprivileged group and negative labels to the privileged group. Different notions of fairness can be achieved in this way, including demographic parity, equalised odds and equal opportunity, of which each one is allowed in the implementation. In this notebook we demonstrate each of these three definitions as a result of the fairness intervention. For simplicity, we'll focus mitigating bias with resepct to sex.<jupyter_code>from pathlib import Path

import joblib
import pandas as pd
from aif360.algorithms.postprocessing.reject_option_classification import (
    RejectOptionClassification,
)
from aif360.datasets import StandardDataset
from fairlearn.metrics import (
    demographic_parity_difference,
    equalized_odds_difference,
)
from helpers.metrics import accuracy
from helpers.plot import group_bar_plots<jupyter_output><empty_output><jupyter_text>Load dataWe have committed preprocessed data to the repository for reproducibility and we load it here. Check out hte preprocessing notebook for details on how this data was obtained.<jupyter_code>artifacts_dir = Path("../../../artifacts")
data_dir = artifacts_dir / "data" / "adult"

train = pd.read_csv(data_dir / "processed" / "train-one-hot.csv")
val = pd.read_csv(data_dir / "processed" / "val-one-hot.csv")
test = pd.read_csv(data_dir / "processed" / "test-one-hot.csv")<jupyter_output><empty_output><jupyter_text>In order to process data for our fairness intervention we need to define special dataset objects which are part of every intervention pipeline within the IBM AIF360 toolbox. These objects contain the original data as well as some useful further information, e.g., which feature is the protected attribute as well as which column corresponds to the label.<jupyter_code>train_sds = StandardDataset(
    train,
    label_name="salary",
    favorable_classes=[1],
    protected_attribute_names=["sex"],
    privileged_classes=[[1]],
)
test_sds = StandardDataset(
    test,
    label_name="salary",
    favorable_classes=[1],
    protected_attribute_names=["sex"],
    privileged_classes=[[1]],
)
val_sds = StandardDataset(
    val,
    label_name="salary",
    favorable_classes=[1],
    protected_attribute_names=["sex"],
    privileged_classes=[[1]],
)<jupyter_output><empty_output><jupyter_text>Define which binary value goes with the (un-)privileged group<jupyter_code>privileged_groups = [{"sex": 1.0}]
unprivileged_groups = [{"sex": 0.0}]<jupyter_output><empty_output><jupyter_text>Load original modelFor maximum reproducibility we load the baseline model from disk, but the code used to train can be found in the baseline model notebook.<jupyter_code>baseline_model = joblib.load(
    artifacts_dir / "models" / "finance" / "baseline.pkl"
)<jupyter_output><empty_output><jupyter_text>Get predictions for the validation and test data<jupyter_code>bl_val_scores = baseline_model.predict_proba(val.drop("salary", axis=1))[:, 1]
val_sds_pred = val_sds.copy(deepcopy=True)
val_sds_pred.scores = bl_val_scores.reshape(-1, 1)

bl_test_scores = baseline_model.predict_proba(test.drop("salary", axis=1))[
    :, 1
]
bl_test_pred = bl_test_scores > 0.5
test_sds_pred = test_sds.copy(deepcopy=True)
test_sds_pred.scores = bl_test_scores.reshape(-1, 1)<jupyter_output><empty_output><jupyter_text>Demographic parityWe first address demographic parity. In order to do so, we learn the label modification algorithm based on the true and predicted labels of the validation data. We then apply the learnt intervention to the predictions of the test data and analyse the outcomes for fairness and accuracy.<jupyter_code># Metric used
metric_name = "Statistical parity difference"  # Alias for demographic parity

# Upper and lower bound on the fairness metric used
metric_ub = 0.05
metric_lb = -0.05<jupyter_output><empty_output><jupyter_text>Learn interventionOn validation data.<jupyter_code>ROC = RejectOptionClassification(
    unprivileged_groups=unprivileged_groups,
    privileged_groups=privileged_groups,
    low_class_thresh=0.01,
    high_class_thresh=0.99,
    num_class_thresh=100,
    num_ROC_margin=50,
    metric_name=metric_name,
    metric_ub=metric_ub,
    metric_lb=metric_lb,
)
ROC = ROC.fit(val_sds, val_sds_pred)<jupyter_output><empty_output><jupyter_text>Apply intervention on test data.<jupyter_code># Transform the test set
test_sds_pred_transf = ROC.predict(test_sds_pred).copy(deepcopy=True)
test_pred_labels = test_sds_pred_transf.labels.flatten()<jupyter_output><empty_output><jupyter_text>Analyse fairness and accuracy on test data.<jupyter_code>test_features = test.drop(columns=["sex", "salary"]).values
test_sex = test.sex.values
test_salary = test.salary.values
mask = test_sex == 1

# baseline metrics
bl_test_acc = accuracy(test_salary, bl_test_scores)
bl_test_dpd = demographic_parity_difference(
    test.salary, bl_test_pred, sensitive_features=test_sex,
)

# new model metrics
test_acc = accuracy(test_salary, test_pred_labels)
test_dpd = demographic_parity_difference(
    test.salary, test_pred_labels, sensitive_features=test_sex,
)

print(f"Baseline accuracy: {bl_test_acc:.3f}")
print(f"Accuracy: {test_acc:.3f}\n")

print(f"Baseline demographic parity: {bl_test_dpd:.3f}")
print(f"Demographic parity: {test_dpd:.3f}\n")<jupyter_output><empty_output><jupyter_text>Consider accuracy on the female / male subgroups<jupyter_code>male_acc = accuracy(
    test_pred_labels[test.race_white == 1], test.salary[test.race_white == 1],
)
female_acc = accuracy(
    test_pred_labels[test.race_white == 0], test.salary[test.race_white == 0],
)

mean_female_score = test_pred_labels[test.race_white == 0].mean()
mean_male_score = test_pred_labels[test.race_white == 1].mean()

print(f"Female accuracy: {female_acc:.3f}")
print(f"Male accuracy: {male_acc:.3f}")
print(f"Mean female score: {mean_female_score:.3f}")
print(f"Mean male score: {mean_male_score:.3f}")
dp_bar = group_bar_plots(
    test_pred_labels,
    test.sex.map({0: "Female", 1: "Male"}),
    title="Predictions by sex",
    xlabel="Proportion predicted high earners",
)
dp_bar<jupyter_output><empty_output><jupyter_text>Equalised OddsWe'll now repeat the process for equalised odds, which requires us changing the underlying metric which leads to learning the correction according a new decision threshold addressing equalised odds. There are no further modifcations to the existing parameter choices required. However we apply smaller bounds on equalised odds than for demographic parity.<jupyter_code># Metric used
metric_name = "Average odds difference"  # alias for equalised odds

# Upper and lower bound on the fairness metric used
metric_ub = 0.01
metric_lb = -0.01<jupyter_output><empty_output><jupyter_text>Learn intervention on validation data.<jupyter_code>ROC = RejectOptionClassification(
    unprivileged_groups=unprivileged_groups,
    privileged_groups=privileged_groups,
    low_class_thresh=0.01,
    high_class_thresh=0.99,
    num_class_thresh=100,
    num_ROC_margin=50,
    metric_name=metric_name,
    metric_ub=metric_ub,
    metric_lb=metric_lb,
)
ROC = ROC.fit(val_sds, val_sds_pred)<jupyter_output><empty_output><jupyter_text>Apply intervention on test data.<jupyter_code># Transform the test set
test_sds_pred_transf = ROC.predict(test_sds_pred).copy(deepcopy=True)
test_pred_labels = test_sds_pred_transf.labels.flatten()<jupyter_output><empty_output><jupyter_text>Analyse fairness and accuracy on test data.<jupyter_code>test_features = test.drop(columns=["sex", "salary"]).values
test_sex = test.sex.values
test_salary = test.salary.values
mask = test_sex == 1

# baseline metrics
bl_test_acc = accuracy(test_salary, bl_test_scores)
bl_test_eod = equalized_odds_difference(
    test_salary, bl_test_pred, sensitive_features=test_sex,
)

# new model metrics
test_acc = accuracy(test_pred_labels, test_salary)
test_eod = equalized_odds_difference(
    test_salary, test_pred_labels, sensitive_features=test_sex,
)

print(f"Baseline accuracy: {bl_test_acc:.3f}")
print(f"Accuracy: {test_acc:.3f}\n")

print(f"Baseline equalised odds difference: {bl_test_eod:.3f}")
print(f"Equalised odds difference: {test_eod:.3f}\n")
eo_bar = group_bar_plots(
    test_pred_labels,
    test.sex.map({0: "Female", 1: "Male"}),
    groups=test.salary,
    group_names=["Low earner", "High earner"],
    title="Predictions by sex and outcome",
    xlabel="Proportion predicted high earners",
    ylabel="Outcome",
)
eo_bar<jupyter_output><empty_output><jupyter_text>Equal opportunityWe'll now repeat the process for equalised opportunity, which only requires us changing the underlying metric.<jupyter_code># Metric used
metric_name = "Equal opportunity difference"

# Upper and lower bound on the fairness metric used
metric_ub = 0.01
metric_lb = -0.01<jupyter_output><empty_output><jupyter_text>Learn interrvention on validation data.<jupyter_code>ROC = RejectOptionClassification(
    unprivileged_groups=unprivileged_groups,
    privileged_groups=privileged_groups,
    low_class_thresh=0.01,
    high_class_thresh=0.99,
    num_class_thresh=100,
    num_ROC_margin=50,
    metric_name=metric_name,
    metric_ub=metric_ub,
    metric_lb=metric_lb,
)
ROC = ROC.fit(val_sds, val_sds_pred)<jupyter_output><empty_output><jupyter_text>Apply intervention on test data.<jupyter_code># Transform the test set
test_sds_pred_transf = ROC.predict(test_sds_pred).copy(deepcopy=True)
test_pred_labels = test_sds_pred_transf.labels.flatten()<jupyter_output><empty_output><jupyter_text>Analyse fairness and accuracy on test data.<jupyter_code>test_features = test.drop(columns=["sex", "salary"]).values
test_sex = test.sex.values
test_salary = test.salary.values
mask = test_sex == 1

# baseline metrics
bl_test_acc = accuracy(test_salary, bl_test_scores)
bl_test_eoppd = equalized_odds_difference(
    test_salary[test.salary == 1],
    bl_test_pred[test.salary == 1],
    sensitive_features=test_sex[test.salary == 1],
)

# new model metrics
test_acc = accuracy(test_salary, test_pred_labels)
test_eoppd = equalized_odds_difference(
    test_salary[test.salary == 1],
    test_pred_labels[test.salary == 1],
    sensitive_features=test_sex[test.salary == 1],
)

print(f"Baseline accuracy: {bl_test_acc:.3f}")
print(f"Accuracy: {test_acc:.3f}\n")

print(f"Baseline equal opportunity: {bl_test_eoppd:.3f}")
print(f"Equal opportunity: {test_eoppd:.3f}\n")
eopp_bar = group_bar_plots(
    test_pred_labels[test.salary == 1],
    test.race_white[test.salary == 1].map({0: "Female", 1: "Male"}),
    title="Predictions by sex for high earners",
    xlabel="Proportion predicted high earners",
)
eopp_bar<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09755660968405978, 'fraction_numerical': 0.012172200841313882, 'mean_word_length': 6.365853658536586, 'pattern_counts': {'":': 2, '<': 77, '<?xml version=': 0, '>': 78, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '19', 'dir': 'jupyter-structured-clean-dedup', 'id': '292836', 'license': 'MIT', 'n_tokens_mistral': 3890, 'n_tokens_neox': 3665, 'n_words': 866, 'path': 'notebooks/finance/interventions/kamiran.ipynb', 'repo_name': 'CDEIUK/ml-bias', 'size': '11173'}