Fair Candidate Screening System#

Setup#

#!git clone https://github.com/pikalab-unibo-students/master-thesis-dizio-ay2324.git
import utils
import pandas as pd
import numpy as np
from enum import Enum

# Fairlib
import chardet
import fairlib as fl
from fairlearn.metrics import demographic_parity_ratio, equalized_odds_ratio
from fairlib.preprocessing import LFR, DisparateImpactRemover, Reweighing
from fairlib.inprocessing import AdversarialDebiasing

# Statistics
from matplotlib import pyplot as plt
import seaborn as sns

# Preprocessing
from sklearn.preprocessing import OrdinalEncoder
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

# Classification
from sklearn.model_selection import StratifiedKFold
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
import torch
import torch.nn as nn

# Post-processing
from aif360.algorithms.postprocessing import CalibratedEqOddsPostprocessing, EqOddsPostprocessing, RejectOptionClassification, DeterministicReranking
from aif360.datasets import StandardDataset
# Allowing reproducibility
random_seed = 42
np.random.seed(random_seed)

Dataset Loading#

dataset_path = 'direct_matching_20240213.csv'
with open(dataset_path, 'rb') as f:
    result = chardet.detect(f.read())
    encoding = result['encoding']
df = fl.DataFrame(pd.read_csv(dataset_path, delimiter=';', encoding=encoding))

Data Preparation#

df.head(11)
cand_id job_id distance_km match_score match_rank cand_gender cand_age_bucket cand_domicile_province cand_domicile_region cand_education job_contract_type job_professional_category job_sector job_work_province
0 5,664,912 OFF_1011_1427 32.327042 99.573387 1 Male 45-54 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
1 4,999,120 OFF_1011_1427 15.595593 99.210564 2 Male 35-44 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
2 5,413,671 OFF_1011_1427 31.348877 99.118614 3 Female 45-54 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
3 5,965,090 OFF_1011_1427 66.315598 97.409767 4 Male 15-24 TS FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
4 5,771,219 OFF_1011_1427 15.595593 97.323875 5 Female 35-44 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
5 2,216,205 OFF_1011_1427 24.946939 96.922318 6 Male 55-74 UD FRIULI VENEZIA GIULIA Diploma / Accademia : Geometra Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
6 4,594,051 OFF_1011_1427 27.959969 96.245216 7 Male 55-74 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
7 5,148,878 OFF_1011_1427 25.512180 96.235245 8 Male 25-34 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
8 5,933,345 OFF_1011_1427 28.856832 96.009712 9 Female 45-54 GO FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
9 7,204,128 OFF_1011_1427 31.348877 95.802277 10 Female 35-44 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD
10 5,025,089 OFF_1038_1739 17.786076 99.949821 1 Male 25-34 MI LOMBARDIA Diploma / Accademia : Liceo scientifico Ricerca e selezione Macellaio (m/f) GDO / Retail / Commessi / Scaffalisti MI
plt.figure(figsize=(10, 6))
sns.countplot(data=df, x='match_rank', hue='cand_gender', order=range(1, 11))
plt.title('Gender Distribution over Match Ranks')
plt.xlabel('Match Rank')
plt.ylabel('Count')
plt.legend(title='Gender')
plt.tight_layout()
plt.show()
_images/main_11_0.png
plt.figure(figsize=(8, 5))
plt.hist(df[df['match_score'] > 99.3]['match_score'], bins=120, color='skyblue', edgecolor='black')
plt.title('Distribution of Match Score')
plt.xlabel('Match Score')
plt.ylabel('Frequency')
plt.grid(True)
plt.tight_layout()
plt.show()
_images/main_12_0.png

We can assume that a candidate is hired if its match score is greater than a significant threshold, for instance, th=99.8

hired_threshold = 99.8
df1 = df.copy()
df1['hired'] = (df1['match_score'] >= hired_threshold).astype(int)
df1.shape
(8647, 15)
len(df1[df1['hired'] == 1])
5297
df1.targets = {'hired'}
plt.figure(figsize=(5, 5))
df1['hired'].value_counts().plot(kind='bar')
plt.xlabel('Hired')
plt.ylabel('Counts')
plt.title('Hired Distribution')
plt.tight_layout()
plt.show()
_images/main_20_0.png

Both the match rank and the match score are useless now that we extract the label, so we can drop them.

df1.drop(['match_score','match_rank'], axis=1, inplace=True)
df1.head()
cand_id job_id distance_km cand_gender cand_age_bucket cand_domicile_province cand_domicile_region cand_education job_contract_type job_professional_category job_sector job_work_province hired
0 5,664,912 OFF_1011_1427 32.327042 Male 45-54 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0
1 4,999,120 OFF_1011_1427 15.595593 Male 35-44 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0
2 5,413,671 OFF_1011_1427 31.348877 Female 45-54 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0
3 5,965,090 OFF_1011_1427 66.315598 Male 15-24 TS FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0
4 5,771,219 OFF_1011_1427 15.595593 Female 35-44 UD FRIULI VENEZIA GIULIA NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0

Data Preprocessing#

Before continuing with the data analysis we want to ensure that missing values are handled correctly and the data are ready to be feed in a classifier. Let’s inspect some of their statistics.

print(f'Examples in the dataset: {df1.shape[0]}')
Examples in the dataset: 8647
df1.describe(include='all')
cand_id job_id distance_km cand_gender cand_age_bucket cand_domicile_province cand_domicile_region cand_education job_contract_type job_professional_category job_sector job_work_province hired
count 8647 8647 8647.000000 8647 8646 8644 8642 2341 8647 8647 8647 8647 8647.000000
unique 6798 865 NaN 2 5 79 18 433 3 247 26 53 NaN
top 6,550,205 OFF_1011_1427 NaN Male 25-34 MI LOMBARDIA Licenza media Lavoro subordinato Operaio Generico Metalmeccanico Operai Generici MI NaN
freq 18 10 NaN 4766 2936 1341 3989 433 5658 770 2829 1689 NaN
mean NaN NaN 29.769432 NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.612582
std NaN NaN 23.493063 NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.487189
min NaN NaN 0.000000 NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.000000
25% NaN NaN 12.253924 NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.000000
50% NaN NaN 23.447361 NaN NaN NaN NaN NaN NaN NaN NaN NaN 1.000000
75% NaN NaN 41.754654 NaN NaN NaN NaN NaN NaN NaN NaN NaN 1.000000
max NaN NaN 99.966797 NaN NaN NaN NaN NaN NaN NaN NaN NaN 1.000000

Most of the variables doen’t miss any value, except for the candidate domicile province which misses just 3 values, the candidate domicile region which misses only 5 values, the candidate age which misses only 1 value and the candidate education, which instead misses way more values. We can account for the 3 missing values by simply drop the related rows, but we must find a default value for the education since the rows containing missing values are too much.

df1 = df1[~df1['cand_domicile_province'].isnull()]
df1 = df1[~df1['cand_domicile_region'].isnull()]
df1 = df1[~df1['cand_age_bucket'].isnull()]

In general, we can keep all the numerical values, but we have to account for the categorical ones in order to feed a classifier. In the next paragraphs we will focus on the features that require our attention.

IDs#

Candidate id and job id are meaningless for the task of bias detection, hence we can easily drop them.

df1.drop(['cand_id','job_id'], axis=1, inplace=True)

Distance Km#

df1['distance_km'].describe()
count    8639.000000
mean       29.754485
std        23.484031
min         0.000000
25%        12.252331
50%        23.437698
75%        41.751572
max        99.966797
Name: distance_km, dtype: float64

We can approximate the distance by rounding it.

df1['distance_km'] = df1['distance_km'].apply(lambda d : round(d)).astype(int)
df1['distance_km'].describe()
count    8639.000000
mean       29.746846
std        23.475636
min         0.000000
25%        12.000000
50%        23.000000
75%        42.000000
max       100.000000
Name: distance_km, dtype: float64

First of all, let’s inspect the distribution.

# Visualize the distribution
plt.figure(figsize=(12, 6))
df1['distance_km'].value_counts().plot(kind='bar')
plt.xlabel('Km')
plt.ylabel('Counts')
plt.title('Distance Distribution')
plt.tight_layout()
plt.show()
_images/main_40_0.png
plt.figure(figsize=(12, 6))
sns.countplot(data=df1, x='distance_km', hue='hired', order=range(1, max(df1['distance_km'])))
plt.title('Hirings Distribution over Distances')
plt.xlabel('Distance Km')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.xticks(rotation=90)
plt.tight_layout()
plt.show()
_images/main_41_0.png

From this plot we may suspect some bias related to the region of the candidate.

Candidate Gender#

# Visualize the distribution
plt.figure(figsize=(5, 5))
df1['cand_gender'].value_counts().plot(kind='bar')
plt.xlabel('Gender')
plt.ylabel('Counts')
plt.title('Gender Distribution')
plt.tight_layout()
plt.show()
_images/main_44_0.png

The dataset is imbalanced, but this tell us nothing about any possible bias or unfairness. In order to spot any kind of unfairness we should compare this distribution with the hirings.

plt.figure(figsize=(5, 5))
sns.countplot(data=df1, x='cand_gender', hue='hired')
plt.title('Hirings Distribution over Gender')
plt.xlabel('Gender')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.tight_layout()
plt.show()
_images/main_46_0.png
plt.figure(figsize=(5, 5))
sns.countplot(data=df1, y='job_sector', hue='cand_gender')
plt.title('Gender Distribution over Job Sectors')
plt.xlabel('Sector')
plt.ylabel('Count')
plt.legend(title='Gender')
plt.tight_layout()
plt.show()
_images/main_47_0.png

The difference in the distributions may be due to high demand in that sectors

sensitive_features = ['cand_gender']

Candidate Province#

df1['cand_domicile_province'].describe()
count     8639
unique      78
top         MI
freq      1340
Name: cand_domicile_province, dtype: object
# Visualize the distribution
plt.figure(figsize=(10, 4))
df1['cand_domicile_province'].value_counts().plot(kind='bar')
plt.xlabel('Province')
plt.ylabel('Counts')
plt.title('Candidate Province Distribution')
plt.tight_layout()
plt.show()
_images/main_52_0.png
plt.figure(figsize=(10, 4))
sns.countplot(data=df1, x='cand_domicile_province', hue='hired')
plt.title('Hirings Distribution over Candidate Province')
plt.xlabel('Province')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.xticks(rotation=90, ha='right')
plt.tight_layout()
plt.show()
_images/main_53_0.png

Here we can spot some bias towards the candidates coming from the north of Italy, but we need to aggregate the data in order to have a clearer overview.

In order to preprocess this feature, we have to ensure that even the job work province will be coherent with the candidate province, therefore we will use the same encoder for both of them.

province_encoder = OrdinalEncoder()
df1['cand_domicile_province'] = province_encoder.fit_transform(df1[['cand_domicile_province']])

Domicile Region#

df1['cand_domicile_region'].describe()
count          8639
unique           18
top       LOMBARDIA
freq           3988
Name: cand_domicile_region, dtype: object
# Visualize the distribution
plt.figure(figsize=(10, 5))
df1['cand_domicile_region'].value_counts().plot(kind='bar')
plt.xlabel('Domicile Region')
plt.ylabel('Counts')
plt.title('Domicile Region Distribution')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_59_0.png

Sensitive feature selection#

We identified a key intersectional variable to serve as a sensitive feature in our fairness analysis: grouped_region_gender (also called grouped_region). This variable combines candidates’ domicile regions—categorized into geographic macro-areas (North, Center, South)—with their self-reported gender.

df_copy = df1.copy()
df_copy['gender_region'] = df_copy['cand_domicile_region'].astype(str) + ' ' + df_copy['cand_gender'].astype(str)
plt.figure(figsize=(10, 6))
sns.countplot(data=df_copy, x='gender_region', hue='hired')
plt.title('Hirings Distribution over Candidate Region')
plt.xlabel('Region')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_61_0.png
df_copy['gender_region'].value_counts()
gender_region
LOMBARDIA Male                  2222
LOMBARDIA Female                1766
EMILIA ROMAGNA Male              673
VENETO Male                      670
EMILIA ROMAGNA Female            557
VENETO Female                    545
PIEMONTE Male                    445
PIEMONTE Female                  366
FRIULI VENEZIA GIULIA Male       253
LAZIO Female                     222
TOSCANA Female                   149
TOSCANA Male                     118
FRIULI VENEZIA GIULIA Female     114
TRENTINO ALTO ADIGE Male         111
LAZIO Male                       100
MARCHE Male                       75
ABRUZZO Male                      54
MARCHE Female                     51
TRENTINO ALTO ADIGE Female        28
LIGURIA Female                    26
SARDEGNA Male                     22
ABRUZZO Female                    19
VALLE D'AOSTA Female              18
SARDEGNA Female                    9
MOLISE Male                        5
UMBRIA Male                        5
LIGURIA Male                       4
UMBRIA Female                      4
MOLISE Female                      2
VALLE D'AOSTA Male                 2
PUGLIA Male                        1
CALABRIA Female                    1
CAMPANIA Female                    1
PUGLIA Female                      1
Name: count, dtype: int64
region_groups = {
    # North
    'piemonte': 'north',
    'valle d\'aosta': 'north',
    'lombardia': 'north',
    'veneto': 'north',
    'friuli venezia giulia': 'north',
    'liguria': 'north',
    'emilia romagna': 'north',
    'trentino alto adige': 'north',

    # Central
    'toscana': 'central',
    'umbria': 'central',
    'marche': 'central',
    'lazio': 'central',

    # South
    'abruzzo': 'south',
    'molise': 'south',
    'campania': 'south',
    'puglia': 'south',
    'basilicata': 'south',
    'calabria': 'south',

    # Islands
    'sicilia': 'south',
    'sardegna': 'south'
}
grouped_regions = df1['cand_domicile_region'].apply(lambda r : region_groups[str.lower(r)])
df1['grouped_regions'] = grouped_regions.astype(str) + ' ' + df1['cand_gender'].astype(str)
plt.figure(figsize=(7, 5))
sns.countplot(data=df1, x=df1['grouped_regions'], hue='hired')
plt.title('Hirings Distribution over Candidate Region')
plt.xlabel('Region')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_65_0.png
df1 = df1.drop('cand_domicile_region', axis=1)
df1 = df1.drop('cand_gender', axis=1)
df1.head()
distance_km cand_age_bucket cand_domicile_province cand_education job_contract_type job_professional_category job_sector job_work_province hired grouped_regions
0 32 45-54 71.0 NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0 north Male
1 16 35-44 71.0 NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0 north Male
2 31 45-54 71.0 NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0 north Female
3 66 15-24 69.0 NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0 north Male
4 16 35-44 71.0 NaN Lavoro subordinato Geometra e tecnico di costruzioni civili e ind... Progettisti / Design / Grafici UD 0 north Female
df1['grouped_regions'].value_counts()
grouped_regions
north Male        4380
north Female      3420
central Female     426
central Male       298
south Male          82
south Female        33
Name: count, dtype: int64

Age Buckets#

df1['cand_age_bucket'].unique()
array(['45-54', '35-44', '15-24', '55-74', '25-34'], dtype=object)
print(f"Null age buckets: {df1['cand_age_bucket'].isnull().sum()}")
Null age buckets: 0

Since it is only one we can safely drop it.

df1['cand_age_bucket'].value_counts()
cand_age_bucket
25-34    2935
35-44    2129
45-54    1777
55-74     956
15-24     842
Name: count, dtype: int64
plt.figure(figsize=(7, 4))
df1['cand_age_bucket'].value_counts().plot(kind='bar')
plt.xlabel('Age bucket')
plt.ylabel('Counts')
plt.title('Age Distribution')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_74_0.png
plt.figure(figsize=(8, 5))
sns.countplot(data=df1, x='cand_age_bucket', hue='hired', order=['15-24', '25-34', '35-44', '45-54', '55-74'])
plt.title('Hirings Distribution over Candidate Age')
plt.xlabel('Age Bucket')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.tight_layout()
plt.show()
_images/main_75_0.png
sensitive_features.append('cand_age_bucket')

We can discretize them since they are numerical but preserves the order, thus we will use progressive enumeration.

age_bucket_order = {
    '15-24': 0,
    '25-34': 1,
    '35-44': 2,
    '45-54': 3,
    '55-74': 4,
}

df1['cand_age_bucket'] = df1['cand_age_bucket'].map(age_bucket_order).astype(int)
df1['cand_age_bucket'].value_counts()
cand_age_bucket
1    2935
2    2129
3    1777
4     956
0     842
Name: count, dtype: int64

Candidate Education#

def map_education_level(x):
    if pd.isna(x):
        return 'Unknown'
    x = str(x).lower()

    # Clean common formatting inconsistencies
    x = x.replace('laurea', 'degree')
    x = x.replace('diploma', 'degree')

    if 'dottorato' in x or 'phd' in x or 'research doctorate' in x:
        return 'PhD'
    elif 'master' in x or 'lm-' in x:
        return 'Graduate'
    elif 'bachelor' in x or 'l-' in x:
        return 'Undergraduate'
    elif 'higher technical institute' in x or 'its' in x:
        return 'Higher Technical Institute'
    elif 'qualification' in x or 'certificate' in x or 'operator' in x:
        return 'Vocational Certificate'
    elif 'high school' in x or 'liceo' in x or 'technician' in x or 'technical' in x:
        return 'High School'
    elif 'middle school' in x or 'scuola media' in x:
        return 'Middle School'
    elif 'elementary' in x:
        return 'Elementary School'
    else:
        return 'Other'
df1['cand_education'] = df1['cand_education'].apply(map_education_level)
df1['cand_education'].value_counts()
cand_education
Unknown                       6298
Other                         1546
Vocational Certificate         284
High School                    172
Higher Technical Institute     158
Graduate                       120
Undergraduate                   56
PhD                              5
Name: count, dtype: int64
# Visualize the distribution
plt.figure(figsize=(7, 5))
plt.bar(df1['cand_education'].unique(), df1['cand_education'].value_counts())
plt.xlabel('Education')
plt.ylabel('Counts')
plt.title('Education Distribution')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_84_0.png
plt.figure(figsize=(8, 5))
sns.countplot(data=df1, x='cand_education', hue='hired')
plt.title('Hirings Distribution over Education')
plt.xlabel('Education')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_85_0.png

These results are probably not about any bias. However, we cannot say much looking only at this feature.

education_encoder = OrdinalEncoder()
df1['cand_education'] = education_encoder.fit_transform(df1[['cand_education']])

Job Contract Type#

df1['job_contract_type'].describe()
count                   8639
unique                     3
top       Lavoro subordinato
freq                    5653
Name: job_contract_type, dtype: object
df1['job_contract_type'].value_counts()
job_contract_type
Lavoro subordinato     5653
Ricerca e selezione    2966
Other                    20
Name: count, dtype: int64
contract_encoder = OrdinalEncoder()
df1['job_contract_type'] = contract_encoder.fit_transform(df1[['job_contract_type']])

Job Category#

df1['job_professional_category'].describe()
count                                8639
unique                                247
top       Operaio Generico Metalmeccanico
freq                                  770
Name: job_professional_category, dtype: object
category_encoder = OrdinalEncoder()
df1['job_professional_category'] = category_encoder.fit_transform(df1[['job_professional_category']])

Job Sector#

df1['job_sector'].describe()
count                8639
unique                 26
top       Operai Generici
freq                 2827
Name: job_sector, dtype: object
# Visualize the distribution
plt.figure(figsize=(10, 7))
df1['job_sector'].value_counts().plot(kind='bar')
plt.xlabel('Sector')
plt.ylabel('Counts')
plt.title('Job Sector Distribution')
plt.tight_layout()
plt.show()
_images/main_97_0.png
counts = df1['job_sector'].value_counts()
min_count = 30 #Statistically meaningful

rare = counts[counts < min_count].index

df1 = df1[~df1['job_sector'].isin(rare)]
plt.figure(figsize=(8, 5))
sns.countplot(data=df1, x='job_sector', hue='hired')
plt.title('Hirings Distribution over Job Sector')
plt.xlabel('Sector')
plt.ylabel('Count')
plt.legend(title='Hired')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/main_99_0.png
sector_encoder = OrdinalEncoder()
df1['job_sector'] = sector_encoder.fit_transform(df1[['job_sector']])

Job Work Province#

df1['job_work_province'].describe()
count     8520
unique      53
top        MI
freq      1607
Name: job_work_province, dtype: object
# Visualize the distribution
plt.figure(figsize=(10, 5))
df1['job_work_province'].value_counts().plot(kind='bar')
plt.xlabel('Work Province')
plt.ylabel('Counts')
plt.title('Work Province Distribution')
plt.tight_layout()
plt.show()
_images/main_103_0.png
df1['job_work_province'] = province_encoder.fit_transform(df1[['job_work_province']])

Considerations#

Let’s check the statistics of the dataset after the preprocessing

df1.describe(include='all')
distance_km cand_age_bucket cand_domicile_province cand_education job_contract_type job_professional_category job_sector job_work_province hired grouped_regions
count 8520.000000 8520.000000 8520.000000 8520.000000 8520.000000 8520.000000 8520.000000 8520.000000 8520.000000 8520
unique NaN NaN NaN NaN NaN NaN NaN NaN NaN 6
top NaN NaN NaN NaN NaN NaN NaN NaN NaN north Male
freq NaN NaN NaN NaN NaN NaN NaN NaN NaN 4350
mean 29.658803 1.892019 39.069131 5.233685 0.677465 136.788615 9.522183 27.065258 0.610681 NaN
std 23.349403 1.170752 23.577801 1.530368 0.945372 67.772409 4.105599 15.813288 0.487625 NaN
min 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 NaN
25% 12.000000 1.000000 11.000000 6.000000 0.000000 91.000000 7.000000 9.000000 0.000000 NaN
50% 23.000000 2.000000 38.000000 6.000000 0.000000 126.000000 12.000000 26.000000 1.000000 NaN
75% 42.000000 3.000000 59.000000 6.000000 2.000000 203.000000 12.000000 44.000000 1.000000 NaN
max 100.000000 4.000000 77.000000 7.000000 2.000000 246.000000 17.000000 52.000000 1.000000 NaN

As we can observe, there are no more missing values, and the target lable distribution is almosto uniform.

sensitive_features = ['grouped_regions']
df1.sensitive = set(sensitive_features)

Bias detection#

Before applying any mitigation strategies, an initial bias assessment was conducted on the dataset.

For each (sensitive feature, target feature) pair we computed the Statistical Parity Difference fairness metric, which measures the difference in positive outcome probability across groups. This metric allow us to assess the dataset’s inherent bias before applying any fairness interventions.

spd = df1.statistical_parity_difference()
print(spd)
{(hired=0, grouped_regions=central Female): -0.032443228428667314, (hired=0, grouped_regions=central Male): 0.1391642128509833, (hired=0, grouped_regions=north Female): -0.03122806966815528, (hired=0, grouped_regions=north Male): 0.005383279583229983, (hired=0, grouped_regions=south Female): 0.5522135458508736, (hired=0, grouped_regions=south Male): 0.08712907347134619, (hired=1, grouped_regions=central Female): 0.032443228428667314, (hired=1, grouped_regions=central Male): -0.1391642128509833, (hired=1, grouped_regions=north Female): 0.03122806966815539, (hired=1, grouped_regions=north Male): -0.005383279583230038, (hired=1, grouped_regions=south Female): -0.5522135458508735, (hired=1, grouped_regions=south Male): -0.08712907347134624}
labels = [f"{item}" for _,item in spd[{'hired': 1}].keys()]
values = list(spd[{'hired': 1}].values())
plt.figure(figsize=(8, 6))

plt.axhline(y=0, color='green', linestyle='--', label="Perfect Fairness (0)")

plt.axhline(y=0.1, color='blue', linestyle=':', label="Minimal Disparity (0.1)")
plt.axhline(y=-0.1, color='blue', linestyle=':')
plt.axhline(y=0.2, color='orange', linestyle=':', label="Moderate Disparity (0.2)")
plt.axhline(y=-0.2, color='orange', linestyle=':')
plt.axhline(y=0.3, color='red', linestyle='-', label="Significant Disparity (0.3)")
plt.axhline(y=-0.3, color='red', linestyle='-')

plt.bar(labels, values, color=['purple', 'purple'])


plt.title("Statistical Parity Difference for hired=True")
plt.ylabel("SPD Value")
plt.xticks(rotation=90)
plt.ylim([-0.5, 0.5])

plt.legend()
plt.show()
_images/main_115_0.png
grouped_regions_encoder = OrdinalEncoder()
df1['grouped_regions'] = grouped_regions_encoder.fit_transform(df1[['grouped_regions']])
spd1 = df1.statistical_parity_difference()
spd1
(hired=0, grouped_regions=0.0) -> -0.032443228428667314
(hired=0, grouped_regions=1.0) -> 0.1391642128509833
(hired=0, grouped_regions=2.0) -> -0.03122806966815528
(hired=0, grouped_regions=3.0) -> 0.005383279583229983
(hired=0, grouped_regions=4.0) -> 0.5522135458508736
(hired=0, grouped_regions=5.0) -> 0.08712907347134619
(hired=1, grouped_regions=0.0) -> 0.032443228428667314
(hired=1, grouped_regions=1.0) -> -0.1391642128509833
(hired=1, grouped_regions=2.0) -> 0.03122806966815539
(hired=1, grouped_regions=3.0) -> -0.005383279583230038
(hired=1, grouped_regions=4.0) -> -0.5522135458508735
(hired=1, grouped_regions=5.0) -> -0.08712907347134624

As we can see, the hiring rate for South region females exhibits the most unfair outcome. This means that the positive prediction rate (probability of being hired) is significantly lower for the south female subgroup compared to others. This disparity indicates that the model tends to under-select candidates from this group!

Correlation Matrix#

plt.figure(figsize=(8, 8))
sns.heatmap(df1.corr(), annot=True, fmt=".2f", cmap='coolwarm', square=True)
plt.title("Correlation Matrix")
plt.tight_layout()
plt.show()
_images/main_120_0.png

Bias mitigation techniques#

Mitigating bias means applying strategies to reduce unfair or discriminatory patterns in model predictions, ensuring equitable outcomes across sensitive groups. Our approach evaluates three main categories of bias mitigation techniques:

  • Pre-processing: Transforms the training data to reduce bias before model training. For example, reweighting examples or editing features to balance the predicted hiring rates across grouped_region-by_gender subgroups.

  • In-processing: Integrates fairness constraints directly into the learning algorithm, so the model simultaneously optimizes predictive performance and fair treatment across sensitive groups, mitigating disparities in hiring predictions during training.

  • Post-processing: Adjusts the model’s predictions after training to reduce unfair outcomes, modifying decision thresholds or outputs to ensure that under-represented groups—like south females—receive fairer predicted hiring rates without retraining the model.

By applying these techniques, we aim to reduce bias against disadvantaged subgroups while maintaining strong performance on hiring predictions.

Utils#

sensitive_feature = 'grouped_regions'
target = df1.targets.pop()

baseline_performance_metrics = []
baseline_fairness_metrics = []

preprocessing_performance_metrics = []
preprocessing_fairness_metrics = []
def compute_performance_metrics(y_true, y_pred, y_proba):
    return {
        'accuracy': accuracy_score(y_true, y_pred),
        'precision': precision_score(y_true, y_pred),
        'recall': recall_score(y_true, y_pred),
        'auc': roc_auc_score(y_true, y_proba)
    }
def compute_fairness_metrics(y_true, y_pred, sensitive_features):
    return {
        'dpr': demographic_parity_ratio(y_true, y_pred, sensitive_features=sensitive_features),
        'eor': equalized_odds_ratio(y_true, y_pred, sensitive_features=sensitive_features)
    }
def evaluate_spd(X_test, y_pred):
    X_test_copy = X_test.copy()
    X_test_copy[target] = y_pred
    dataset = fl.DataFrame(X_test_copy)
    dataset.targets = target
    dataset.sensitive = sensitive_feature

    spd = dataset.statistical_parity_difference()
    return spd
def metrics_bar_plot(dict1, dict2, label1, label2, metrics, title="Metric Comparison (mean ± std)"):
    import numpy as np
    import matplotlib.pyplot as plt

    def summarise(fold_dicts):
        means = []
        stds = []
        for m in metrics:
            values = [fold.get(m, np.nan) for fold in fold_dicts]
            # Usa nanmean e nanstd per evitare problemi con NaN
            mean_val = np.nanmean(values)
            std_val = np.nanstd(values)
            means.append(mean_val)
            stds.append(std_val)
        return np.array(means), np.array(stds)

    mean1, std1 = summarise(dict1)
    mean2, std2 = summarise(dict2)

    fig, axes = plt.subplots(1, 2, figsize=(10, 5), sharey=True)
    for ax, mean, std, label in zip(axes, [mean1, mean2], [std1, std2], [label1, label2]):
        ax.bar(metrics, mean, yerr=std, capsize=4)
        ax.set_title(label)
        ax.set_ylim(0, 1.2)  # leggermente sopra 1 per sicurezza
        ax.grid(axis="y", alpha=0.3)

    fig.suptitle(title)
    plt.tight_layout()
    plt.show()
class Simple_NN(nn.Module):
    def __init__(self):
        super(Simple_NN, self).__init__()
        input_dim = df1.shape[-1]-1
        self.layer1 = nn.Linear(input_dim, 64)
        self.layer2 = nn.Linear(64, 32)
        self.layer3 = nn.Linear(32, 1)
        self.relu = nn.ReLU()
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        x = self.relu(self.layer1(x))
        x = self.relu(self.layer2(x))
        x = self.sigmoid(self.layer3(x))
        return x

Pre-Processing#

Pre-processing bias mitigation is so named because it operates before model training, directly transforming the input data to reduce bias in learned predictions. The idea is to modify features, labels, or sample weights so that the resulting data better satisfies fairness constraints—without changing the model itself. For example, techniques like Reweighing, Disparate Impact Remover, and Learning Fair Representations (LFR) adjust the data distribution to balance outcomes across sensitive groups. We evaluate these methods using fairness metrics such as Demographic Parity Ratio (DPR), Equalized Odds Ratio (EOR), and Statistical Parity Difference (SPD), as well as performance metrics like Accuracy, Precision, Recall, and AUC. Results show that while pre-processing can improve fairness, it sometimes introduces trade-offs with predictive performance, and the variability in fairness metrics highlights the impact of data imbalance, especially for under-represented groups.

In particular, for the pre-processing bias mitigation techniques used: - Reweighing: Adjusts the weights of training examples to balance the distribution of outcomes across sensitive groups, helping reduce bias before training. - Learning Fair Representations (LFR): Learns an intermediate data representation that retains predictive information while removing group-related bias, so models trained on it produce fairer outcomes. - Disparate Impact Remover (DIR): Edits feature values to reduce their dependence on sensitive attributes, aiming to mitigate disparate impact while preserving data utility.

In particular, regarding the metrics used: - Demographic Parity Ratio (DPR): Measures the ratio of positive prediction rates between sensitive groups. A DPR close to 1 indicates that different groups receive positive outcomes at similar rates, promoting demographic parity. - Equalized Odds Ratio (EOR): Compares true positive rates and false positive rates across groups. This metric assesses whether the model makes errors (both misses and false alarms) equally across groups, aiming for fairness in both opportunity and error distribution. - Statistical Parity Difference (SPD): Calculates the difference in selection (positive prediction) rates between groups. A smaller SPD suggests that the likelihood of receiving a positive outcome does not depend strongly on group membership, supporting fairness in selection.

Utility functions#

def train_classifier(X_train, y_train):
    """
    Train a logistic regression classifier.
    """
    clf = LogisticRegression(random_state=42, max_iter=1000)
    clf.fit(X_train, y_train)
    return clf
def prepare_set(X, y, target):
    X_with_target = X.copy()
    X_with_target[target] = y.copy()
    dataset = fl.DataFrame(X_with_target)
    dataset.targets = target
    dataset.sensitive = [sensitive_feature]
    return dataset
def compute_mean_spd(spd_values):
    '''
    Compute the mean absolute Statistical Parity Difference (SPD) across all folds and groups.
    '''
    mean_spd_values = {}
    for approach in spd_values:
        all_spds = []
        for fold_dict in spd_values[approach]:
            all_spds.extend([abs(v) for v in fold_dict.values()])
        mean_spd_values[approach] = np.mean(all_spds)
    results = pd.DataFrame({
        'Approach': list(mean_spd_values.keys()),
        'Mean |SPD|': list(mean_spd_values.values())
    })
    return results

def plot_spd_results(spd_results, label="Mean"):
    plt.figure(figsize=(8, 5))
    plt.bar(spd_results['Approach'], spd_results['Mean |SPD|'], color='cornflowerblue')
    plt.title(f"{label} Absolute SPD across folds and groups")
    plt.ylabel(f"{label} |SPD|")
    plt.xticks(rotation=45)
    plt.tight_layout()
    plt.show()


def print_mean_values(values):
    ''' Print the performance or fairness values for each approach '''
    for approach in values:
        print(f"=== {approach} ===")
        for metric, value in values[approach]['mean'].items():
            print(f"  {metric}: {value:.4f}")
        print()


def compute_metrics_dict(metrics):
    '''
    Compute mean and standard deviation for each metric across all approaches.
    '''
    values = {}
    for approach in metrics:
        metrics_list = metrics[approach]
        df = pd.DataFrame(metrics_list)
        means = df.mean()
        stds = df.std()
        # Store both
        values[approach] = {
            'mean': means.to_dict(),
            'std': stds.to_dict()
        }
    return values


def plot_metrics(values, preprocess_approaches, label="Performance"):
    """
    Plot the performance or fairness metrics for each approach.
    """
    for approach in ["Baseline"] + preprocess_approaches:
        means = values[approach]["mean"]
        stds = values[approach]["std"]

        if label == "Performance":
            metrics = ["accuracy", "precision", "recall", "auc"]
        elif label == "Fairness":
            metrics = ["dpr", "eor"]
        else :
            raise ValueError("Label must be either 'Performance' or 'Fairness'.")

        plt.figure(figsize=(8,5))
        plt.bar(metrics, [means[m] for m in metrics], yerr=[stds[m] for m in metrics], capsize=5, color='cornflowerblue')
        plt.title(f"{label} Metrics - {approach} (Mean ± Std across folds)")
        plt.ylabel("Metric Value")
        plt.xticks(rotation=45)
        plt.tight_layout()
        plt.show()

Naive Train-Test split#

Firstly, we only consider one fold:

X = df1.drop(columns=target)
y = df1[target]

preprocess_approaches = ['Reweighing', 'DIR', 'LRF']

performance_metrics = {approach: [] for approach in ['Baseline'] + preprocess_approaches}
fairness_metrics = {approach: [] for approach in ['Baseline'] + preprocess_approaches}
spd_values = {approach: [] for approach in ['Baseline'] + preprocess_approaches}

# Split into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

# ==========================
# BASELINE
# ==========================
baseline = LogisticRegression(max_iter=1000, solver='liblinear')
baseline.fit(X_train, y_train)
y_pred = baseline.predict(X_test)
y_proba = baseline.predict_proba(X_test)[:, 1]

performance_metrics['Baseline'].append(
    compute_performance_metrics(y_test, y_pred, y_proba)
)
fairness_metrics['Baseline'].append(
    compute_fairness_metrics(y_test, y_pred, X_test[sensitive_feature])
)
spd_values['Baseline'].append(
    evaluate_spd(X_test, y_pred)
)

# ==========================
# PREPROCESSING METHODS
# ==========================
for approach in preprocess_approaches:

    # Wrap train/test into your special DataFrame class
    train_dataset = prepare_set(X_train, y_train, target)
    test_dataset = prepare_set(X_test, y_test, target)

    if approach == 'Reweighing':
        reweighing = Reweighing()
        reweighed_df = reweighing.fit_transform(train_dataset)
        weights = reweighed_df['weights'].values
        X_train_reweighed = X_train.copy()

        clf = LogisticRegression(random_state=42, max_iter=1000)
        clf.fit(X_train_reweighed, y_train, sample_weight=weights)
        y_pred = clf.predict(X_test)
        y_proba = clf.predict_proba(X_test)[:, 1]

        performance_metrics[approach].append(
            compute_performance_metrics(y_test, y_pred, y_proba)
        )
        fairness_metrics[approach].append(
            compute_fairness_metrics(y_test, y_pred, X_test[sensitive_feature])
        )
        spd_values[approach].append(
            evaluate_spd(X_test, y_pred)
        )

    elif approach == 'DIR':
        dir = DisparateImpactRemover(repair_level=0.9)
        X_train_repaired = dir.fit_transform(train_dataset)
        X_test_repaired = dir.fit_transform(test_dataset)

        clf = LogisticRegression(random_state=42, max_iter=1000)
        clf.fit(X_train_repaired, y_train)
        y_pred = clf.predict(X_test_repaired)
        y_proba = clf.predict_proba(X_test_repaired)[:, 1]

        performance_metrics[approach].append(
            compute_performance_metrics(y_test, y_pred, y_proba)
        )
        fairness_metrics[approach].append(
            compute_fairness_metrics(y_test, y_pred, X_test_repaired[sensitive_feature])
        )
        spd_values[approach].append(
            evaluate_spd(X_test, y_pred)
        )

    elif approach == 'LRF':
        lfr = LFR(
            input_dim=X_train.shape[1],
            latent_dim=8,
            output_dim=X_train.shape[1],
            alpha_z=1.0,
            alpha_x=1.0,
            alpha_y=1.0
        )

        lfr.fit(train_dataset, epochs=8, batch_size=32)
        X_train_transformed = lfr.transform(train_dataset)
        X_test_transformed = lfr.transform(test_dataset)

        clf = LogisticRegression(random_state=42, max_iter=1000)
        clf.fit(X_train_transformed, y_train)
        y_pred = clf.predict(X_test_transformed)
        y_proba = clf.predict_proba(X_test_transformed)[:, 1]

        performance_metrics[approach].append(
            compute_performance_metrics(y_test, y_pred, y_proba)
        )
        fairness_metrics[approach].append(
            compute_fairness_metrics(y_test, y_pred, test_dataset[sensitive_feature])
        )
        spd_values[approach].append(
            evaluate_spd(X_test, y_pred)
        )
Fairness metrics#
spd_results = compute_mean_spd(spd_values)
plot_spd_results(spd_results, label="")
_images/main_137_0.png

Plot DPR and EOR

fairness_values = compute_metrics_dict(fairness_metrics)
print_mean_values(fairness_values) # Print the fairness values for each approach
=== Baseline ===
  dpr: 0.8358
  eor: 0.7838

=== Reweighing ===
  dpr: 0.8411
  eor: 0.7871

=== DIR ===
  dpr: 0.8697
  eor: 0.8378

=== LRF ===
  dpr: 0.8182
  eor: 0.6667
plot_metrics(fairness_values, preprocess_approaches, label="Fairness")
_images/main_140_0.png _images/main_140_1.png _images/main_140_2.png _images/main_140_3.png
Performance metrics#

Our chosen metrics: Accuracy, Precision, Recall, AUC

performance_values = compute_metrics_dict(performance_metrics)
print_mean_values(performance_values) # Print the performance values for each approach
=== Baseline ===
  accuracy: 0.6244
  precision: 0.6382
  recall: 0.8934
  auc: 0.6117

=== Reweighing ===
  accuracy: 0.6232
  precision: 0.6376
  recall: 0.8921
  auc: 0.6112

=== DIR ===
  accuracy: 0.6138
  precision: 0.6276
  recall: 0.9093
  auc: 0.5957

=== LRF ===
  accuracy: 0.6162
  precision: 0.6166
  recall: 0.9879
  auc: 0.5337
plot_metrics(performance_values, preprocess_approaches, label="Performance")
_images/main_143_0.png _images/main_143_1.png _images/main_143_2.png _images/main_143_3.png

Stratified K-fold cross-validation#

We test two different splitting protocols, which, as we will see, significantly impact the results:

  1. Stratification based on the target feature only

  2. Stratification based on the target + sensitive feature combination

def skf_cross_validation(df1, target, sensitive_feature, split_type='target'):
    """ Perform Stratified K-Fold Cross-Validation on the dataset.
    Args:
        df1 (pd.DataFrame): The input DataFrame containing the dataset.
        target (str): The target variable for classification.
        sensitive_feature (str): The sensitive feature to be considered for fairness metrics.
    Returns:
        performance_metrics (dict): A dictionary containing performance metrics for each approach.
        fairness_metrics (dict): A dictionary containing fairness metrics for each approach.
        spd_values (dict): A dictionary containing SPD values for each approach.
    """
    X = df1.drop(columns=target)
    y = df1[target]

    preprocess_approaches = ['Reweighing', 'DIR', 'LRF']

    performance_metrics = {approach: [] for approach in ['Baseline'] + preprocess_approaches}
    fairness_metrics = {approach: [] for approach in ['Baseline'] + preprocess_approaches}
    spd_values = {approach: [] for approach in ['Baseline'] + preprocess_approaches}

    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

    x_split = df1
    if split_type == 'target':
        y_split = df1[target].astype(str)
    elif split_type == 'target+sensitive':
        y_split = df1[target].astype(str) + df1[sensitive_feature].astype(str)

    for _, (train_idx, test_idx) in enumerate(kf.split(x_split, y_split)):
        X_train, X_test = X.iloc[train_idx].copy(), X.iloc[test_idx].copy()
        y_train, y_test = y.iloc[train_idx].copy(), y.iloc[test_idx].copy()

        # ==========================
        # BASELINE
        # ==========================
        baseline = LogisticRegression(max_iter=1000, solver='liblinear')
        baseline.fit(X_train, y_train)
        y_pred = baseline.predict(X_test)
        y_proba = baseline.predict_proba(X_test)[:, 1]

        performance_metrics['Baseline'].append(
            compute_performance_metrics(y_test, y_pred, y_proba)
        )
        fairness_metrics['Baseline'].append(
            compute_fairness_metrics(y_test, y_pred, X_test[sensitive_feature])
        )
        spd_values['Baseline'].append(
            evaluate_spd(X_test, y_pred)
        )

        # ==========================
        # PREPROCESSING METHODS
        # ==========================
        for approach in preprocess_approaches:

            # Wrap train/test into your special DataFrame class
            train_dataset = prepare_set(X_train, y_train, target)
            test_dataset = prepare_set(X_test, y_test, target)

            if approach == 'Reweighing':
                reweighing = Reweighing()
                reweighed_df = reweighing.fit_transform(train_dataset)
                weights = reweighed_df['weights'].values
                X_train_reweighed = X_train.copy()

                clf = LogisticRegression(random_state=42, max_iter=1000)
                clf.fit(X_train_reweighed, y_train, sample_weight=weights)
                y_pred = clf.predict(X_test)
                y_proba = clf.predict_proba(X_test)[:, 1]

                performance_metrics[approach].append(
                    compute_performance_metrics(y_test, y_pred, y_proba)
                )
                fairness_metrics[approach].append(
                    compute_fairness_metrics(y_test, y_pred, X_test[sensitive_feature])
                )
                spd_values[approach].append(
                    evaluate_spd(X_test, y_pred)
                )

            elif approach == 'DIR':
                dir = DisparateImpactRemover(repair_level=0.9)
                X_train_repaired = dir.fit_transform(train_dataset)
                X_test_repaired = dir.fit_transform(test_dataset)

                clf = LogisticRegression(random_state=42, max_iter=1000)
                clf.fit(X_train_repaired, y_train)
                y_pred = clf.predict(X_test_repaired)
                y_proba = clf.predict_proba(X_test_repaired)[:, 1]

                performance_metrics[approach].append(
                    compute_performance_metrics(y_test, y_pred, y_proba)
                )
                fairness_metrics[approach].append(
                    compute_fairness_metrics(y_test, y_pred, X_test_repaired[sensitive_feature])
                )
                spd_values[approach].append(
                    evaluate_spd(X_test, y_pred)
                )

            elif approach == 'LRF':
                lfr = LFR(
                    input_dim=X_train.shape[1],
                    latent_dim=8,
                    output_dim=X_train.shape[1],
                    alpha_z=1.0,
                    alpha_x=1.0,
                    alpha_y=1.0
                )

                lfr.fit(train_dataset, epochs=8, batch_size=32)
                X_train_transformed = lfr.transform(train_dataset)
                X_test_transformed = lfr.transform(test_dataset)

                clf = LogisticRegression(random_state=42, max_iter=1000)
                clf.fit(X_train_transformed, y_train)
                y_pred = clf.predict(X_test_transformed)
                y_proba = clf.predict_proba(X_test_transformed)[:, 1]

                performance_metrics[approach].append(
                    compute_performance_metrics(y_test, y_pred, y_proba)
                )
                fairness_metrics[approach].append(
                    compute_fairness_metrics(y_test, y_pred, test_dataset[sensitive_feature])
                )
                spd_values[approach].append(
                    evaluate_spd(X_test, y_pred)
                )

    return performance_metrics, fairness_metrics, spd_values
1) Splitting on “target” feature#
performance_metrics, fairness_metrics, spd_values = skf_cross_validation(df1, target, sensitive_feature, split_type='target')

1.1) Fairness metrics

spd_results = compute_mean_spd(spd_values)
plot_spd_results(spd_results, label="Mean")
_images/main_149_0.png
fairness_values = compute_metrics_dict(fairness_metrics)
print_mean_values(fairness_values) # Print the fairness values for each approach
=== Baseline ===
  dpr: 0.7746
  eor: 0.1518

=== Reweighing ===
  dpr: 0.7620
  eor: 0.1429

=== DIR ===
  dpr: 0.7794
  eor: 0.1429

=== LRF ===
  dpr: 0.8673
  eor: 0.3285
plot_metrics(fairness_values, preprocess_approaches, label="Fairness")
_images/main_151_0.png _images/main_151_1.png _images/main_151_2.png _images/main_151_3.png

We spot a really high variance. Why is that? Let’s inspect the metrics for each fold.

for approach in fairness_metrics:
    print(f"=== {approach} ===")
    for i, metrics in enumerate(fairness_metrics[approach]):
        print(f"  Fold {i+1}: {metrics}")
    print()
 === Baseline ===
   Fold 1: {'dpr': np.float64(0.7767857142857143), 'eor': 0.7589285714285715}
   Fold 2: {'dpr': np.float64(0.8586326767091541), 'eor': 0.0}
   Fold 3: {'dpr': np.float64(0.8579083837510804), 'eor': 0.0}
   Fold 4: {'dpr': np.float64(0.5344827586206897), 'eor': 0.0}
   Fold 5: {'dpr': np.float64(0.8451025056947609), 'eor': 0.0}

 === Reweighing ===
   Fold 1: {'dpr': np.float64(0.75), 'eor': 0.7142857142857143}
   Fold 2: {'dpr': np.float64(0.8586326767091541), 'eor': 0.0}
   Fold 3: {'dpr': np.float64(0.8067415730337079), 'eor': 0.0}
   Fold 4: {'dpr': np.float64(0.5462962962962963), 'eor': 0.0}
   Fold 5: {'dpr': np.float64(0.8485193621867881), 'eor': 0.0}

 === DIR ===
   Fold 1: {'dpr': np.float64(0.75), 'eor': 0.7142857142857143}
   Fold 2: {'dpr': np.float64(0.8922363847045192), 'eor': 0.0}
   Fold 3: {'dpr': np.float64(0.8680555555555556), 'eor': 0.0}
   Fold 4: {'dpr': np.float64(0.5086206896551724), 'eor': 0.0}
   Fold 5: {'dpr': np.float64(0.878132118451025), 'eor': 0.0}

 === LRF ===
   Fold 1: {'dpr': np.float64(0.75), 'eor': 0.7142857142857143}
   Fold 2: {'dpr': np.float64(0.8846153846153846), 'eor': 0.0}
   Fold 3: {'dpr': np.float64(0.9066666666666666), 'eor': 0.0}
   Fold 4: {'dpr': np.float64(0.946236559139785), 'eor': 0.9279661016949152}
   Fold 5: {'dpr': np.float64(0.8491811938721606), 'eor': 0.0}



From the code above, we see that most folds output an EOR of 0. This
occurs because the “Hired South Females” group is heavily
under-represented, resulting in too few examples for this class in
each fold. As a result, EOR metric cannot be computed reliably across
all folds. This also explains the extremely high variance. > A more
exhaustive explanation will be given in the next sections.

1.2) Performance metrics

performance_values = compute_metrics_dict(performance_metrics)
print_mean_values(performance_values) # Print the fairness values for each approach
=== Baseline ===
  accuracy: 0.6185
  precision: 0.6336
  recall: 0.8904
  auc: 0.6123

=== Reweighing ===
  accuracy: 0.6192
  precision: 0.6340
  recall: 0.8910
  auc: 0.6124

=== DIR ===
  accuracy: 0.6170
  precision: 0.6257
  recall: 0.9293
  auc: 0.5990

=== LRF ===
  accuracy: 0.6195
  precision: 0.6229
  recall: 0.9554
  auc: 0.5958
plot_metrics(performance_values, preprocess_approaches, label="Performance")
_images/main_157_0.png _images/main_157_1.png _images/main_157_2.png _images/main_157_3.png
2) Splitting on “target + sensitive” feature#
performance_metrics, fairness_metrics, spd_values = skf_cross_validation(df1, target, sensitive_feature, split_type='target+sensitive')
c:UsersfolloAppDataLocalProgramsPythonPython311Libsite-packagessklearnmodel_selection_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(

2.1) Fairness metrics

spd_results = compute_mean_spd(spd_values)
plot_spd_results(spd_results, label="Mean")
_images/main_161_0.png
fairness_values = compute_metrics_dict(fairness_metrics)
print_mean_values(fairness_values) # Print the fairness values for each approach
=== Baseline ===
  dpr: 0.8156
  eor: 0.2787

=== Reweighing ===
  dpr: 0.8266
  eor: 0.3099

=== DIR ===
  dpr: 0.8547
  eor: 0.3382

=== LRF ===
  dpr: 0.6389
  eor: 0.2591
plot_metrics(fairness_values, preprocess_approaches, label="Fairness")
_images/main_163_0.png _images/main_163_1.png _images/main_163_2.png _images/main_163_3.png

We can clearly see that the second protocol leads to better fairness metrics. Specifically, SPD is lower, while EOR and DPR are higher. This is because stratifying on the target + sensitive combination ensures a slightly more balanced representation of all groups and labels in each fold, making the evaluation of fairness metrics more reliable and stable.

2.2) Performance metrics

performance_values = compute_metrics_dict(performance_metrics)
print_mean_values(performance_values) # Print the fairness values for each approach
=== Baseline ===
  accuracy: 0.6185
  precision: 0.6334
  recall: 0.8908
  auc: 0.6117

=== Reweighing ===
  accuracy: 0.6190
  precision: 0.6337
  recall: 0.8912
  auc: 0.6116

=== DIR ===
  accuracy: 0.6127
  precision: 0.6246
  recall: 0.9164
  auc: 0.5997

=== LRF ===
  accuracy: 0.6182
  precision: 0.6217
  recall: 0.9575
  auc: 0.5889
plot_metrics(performance_values, preprocess_approaches, label="Performance")
_images/main_167_0.png _images/main_167_1.png _images/main_167_2.png _images/main_167_3.png

Data rebalancing: checking the robustness of our pipeline#

The dataset is imbalanced, which prevents us from obtaining meaningful results even when following a correct pipeline. To ensure that each fold contains at least one example from underrepresented (unfair) groups, we artificially augment the data. This allows us to test whether our approach remains valid and robust under these conditions.

print(df1.groupby([target, sensitive_feature]).size())
hired  grouped_regions
0      0.0                 152
       1.0                 155
       2.0                1235
       3.0                1705
       4.0                  31
       5.0                  39
1      0.0                 272
       1.0                 141
       2.0                2100
       3.0                2645
       4.0                   2
       5.0                  43
dtype: int64
# Sampling under-represented classes
pos = df1[(df1[sensitive_feature] == 4) & df1[target] == 1].copy()

for i in range(6):
    df1.loc[-1] = pos.sample().iloc[0]
    df1.index = df1.index + 1
    df1 = df1.sort_index()
1.03.0    2645
1.02.0    2100
0.03.0    1705
0.02.0    1235
1.00.0     272
0.01.0     155
0.00.0     152
1.01.0     141
1.05.0      43
0.05.0      39
0.04.0      31
1.04.0       8
Name: count, dtype: int64
print(df1.groupby([target, sensitive_feature]).size())
hired  grouped_regions
0      0.0                 152
       1.0                 155
       2.0                1235
       3.0                1705
       4.0                  31
       5.0                  39
1      0.0                 272
       1.0                 141
       2.0                2100
       3.0                2645
       4.0                   2
       5.0                  43
dtype: int64

As we can see, the number of examples for “hired south females” has increased to 8. This makes the k-fold split feasible, and we can now expect more reliable results. We will perform the split using the “target + sensitive” feature combination.

performance_metrics, fairness_metrics, spd_values = skf_cross_validation(df1, target, sensitive_feature, split_type='target')
Fairness metrics#
spd_results = compute_mean_spd(spd_values)
plot_spd_results(spd_results, label="Mean")
_images/main_175_0.png
fairness_values = compute_metrics_dict(fairness_metrics)
print_mean_values(fairness_values) # Print the fairness values for each approach
=== Baseline ===
  dpr: 0.7957
  eor: 0.5959

=== Reweighing ===
  dpr: 0.7932
  eor: 0.5983

=== DIR ===
  dpr: 0.8381
  eor: 0.6207

=== LRF ===
  dpr: 0.8931
  eor: 0.6129
plot_metrics(fairness_values, preprocess_approaches, label="Fairness")
_images/main_177_0.png _images/main_177_1.png _images/main_177_2.png _images/main_177_3.png
Performance metrics#
performance_values = compute_metrics_dict(performance_metrics)
print_mean_values(performance_values) # Print the fairness values for each approach
=== Baseline ===
  accuracy: 0.6188
  precision: 0.6335
  recall: 0.8921
  auc: 0.6130

=== Reweighing ===
  accuracy: 0.6194
  precision: 0.6337
  recall: 0.8935
  auc: 0.6131

=== DIR ===
  accuracy: 0.6158
  precision: 0.6250
  recall: 0.9292
  auc: 0.5987

=== LRF ===
  accuracy: 0.6141
  precision: 0.6159
  recall: 0.9798
  auc: 0.5981
plot_metrics(performance_values, preprocess_approaches, label="Performance")
_images/main_180_0.png _images/main_180_1.png _images/main_180_2.png _images/main_180_3.png
Considerations#

As expected, we obtain slightly better results for the fairness metrics. The improvement is especially noticeable for DPR and EOR, which now show reasonable variance and effectively increase compared to the baseline.

baseline_performance_metrics = []
baseline_fairness_metrics = []

inprocessing_performance_metrics = []
inprocessing_fairness_metrics = []
X = df1.drop(columns=target)
y = df1[target]

# Split into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

scaler = StandardScaler()
numerical_cols = ['distance_km', 'cand_domicile_province', 'cand_education', 'job_professional_category', 'job_sector', 'job_work_province']
X_train[numerical_cols] = scaler.fit_transform(X_train[numerical_cols])
X_test[numerical_cols] = scaler.transform(X_test[numerical_cols])

print(f"Training set shape: {X_train.shape}")
print(f"Testing set shape: {X_test.shape}")
Training set shape: (5964, 9)
Testing set shape: (2556, 9)
X_train_dataframe = fl.DataFrame(X_train.copy())
X_train_dataframe.sensitive = sensitive_feature

baseline_model = AdversarialDebiasing(
    input_dim=X_train.shape[1],
    hidden_dim=8,
    output_dim=1,
    sensitive_dim=1,
    lambda_adv=0,
)

baseline_model.fit(X_train, y_train)

X_test_dataframe = fl.DataFrame(X_test.copy())
X_test_dataframe.sensitive = sensitive_feature

y_pred_tensor = baseline_model.predict(X_test)
y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
y_pred_labels = (y_pred > 0.5).astype(int)

y_proba = y_pred
perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
baseline_performance_metrics.append(perf_metrics)

# Fairness metrics
fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, X_test[sensitive_feature])
baseline_fairness_metrics.append(fair_metrics)

# SPD aggiuntivo (usando metodo fairlib)
spd = evaluate_spd(X_test.copy(), y_pred_labels)
baseline_spd_val = sum(abs(v) for v in spd.values()) / len(spd)
print(f"Baseline SPD (AdversarialDebiasing λ=0): {spd}")
Baseline SPD (AdversarialDebiasing λ=0): {(hired=0, grouped_regions=0.0): -0.0646026772453311, (hired=0, grouped_regions=1.0): -0.051445249976461715, (hired=0, grouped_regions=2.0): -0.06738347917830323, (hired=0, grouped_regions=3.0): 0.07855362819355599, (hired=0, grouped_regions=4.0): -0.03485960881017766, (hired=0, grouped_regions=5.0): 0.141780871062639, (hired=1, grouped_regions=0.0): 0.06460267724533109, (hired=1, grouped_regions=1.0): 0.051445249976461715, (hired=1, grouped_regions=2.0): 0.06738347917830323, (hired=1, grouped_regions=3.0): -0.07855362819355605, (hired=1, grouped_regions=4.0): 0.03485960881017758, (hired=1, grouped_regions=5.0): -0.14178087106263904}
adv_weights = [0.1, 0.5, 1.0]
adv_spds = []
for lam in adv_weights:
    print(f"\n== Adversarial Debiasing con lambda_adv={lam} ==")

    # Prepara fairlib dataframe
    X_tr = fl.DataFrame(X_train.copy())
    X_tr.sensitive = sensitive_feature

    X_te = fl.DataFrame(X_test.copy())
    X_te.sensitive = sensitive_feature

    # Modello Adversarial Debiasing
    model = AdversarialDebiasing(
        input_dim=X_train.shape[1],
        hidden_dim=8,
        output_dim=1,
        sensitive_dim=1,
        lambda_adv=lam,
    )

    # Addestramento
    model.fit(X_train, y_train)

    # Predizione
    y_pred_tensor = model.predict(X_test)
    y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
    y_pred_labels = (y_pred > 0.5).astype(int)
    y_proba = y_pred

    # Performance
    perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
    inprocessing_performance_metrics.append(perf_metrics)

    # Fairness
    fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, X_test[sensitive_feature])
    inprocessing_fairness_metrics.append(fair_metrics)

    # SPD extra
    spd = evaluate_spd(X_test.copy(), y_pred_labels)
    spd_val = sum(abs(v) for v in spd.values()) / len(spd)
    adv_spds.append(spd_val)
    print(f"SPD (λ={lam}): {spd}")
== Adversarial Debiasing con lambda_adv=0.1 ==
SPD (λ=0.1): {(hired=0, grouped_regions=0.0): -0.00041203131437989287, (hired=0, grouped_regions=1.0): -0.0004048582995951417, (hired=0, grouped_regions=2.0): -0.0006361323155216285, (hired=0, grouped_regions=3.0): 0.0007530120481927711, (hired=0, grouped_regions=4.0): -0.00039231071008238524, (hired=0, grouped_regions=5.0): -0.0003946329913180742, (hired=1, grouped_regions=0.0): 0.0004120313143799459, (hired=1, grouped_regions=1.0): 0.0004048582995951344, (hired=1, grouped_regions=2.0): 0.0006361323155216203, (hired=1, grouped_regions=3.0): -0.0007530120481927804, (hired=1, grouped_regions=4.0): 0.0003923107100823886, (hired=1, grouped_regions=5.0): 0.00039463299131803353}

== Adversarial Debiasing con lambda_adv=0.5 ==
SPD (λ=0.5): {(hired=0, grouped_regions=0.0): 0.01295503109399105, (hired=0, grouped_regions=1.0): -0.011336032388663968, (hired=0, grouped_regions=2.0): 0.00697418233724322, (hired=0, grouped_regions=3.0): -0.007127859974098347, (hired=0, grouped_regions=4.0): -0.010984699882306787, (hired=0, grouped_regions=5.0): -0.011049723756906077, (hired=1, grouped_regions=0.0): -0.012955031093991098, (hired=1, grouped_regions=1.0): 0.011336032388663986, (hired=1, grouped_regions=2.0): -0.006974182337243229, (hired=1, grouped_regions=3.0): 0.007127859974098372, (hired=1, grouped_regions=4.0): 0.01098469988230677, (hired=1, grouped_regions=5.0): 0.011049723756906049}

== Adversarial Debiasing con lambda_adv=1.0 ==
SPD (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

In-Processing#

sensitive_feature = 'grouped_regions'
target = df1.targets.pop()
def compute_performance_metrics(y_true, y_pred, y_proba):
    return {
        'accuracy': accuracy_score(y_true, y_pred),
        'precision': precision_score(y_true, y_pred, zero_division=0),
        'recall': recall_score(y_true, y_pred, zero_division=0),
        'auc': roc_auc_score(y_true, y_proba)
    }
def compute_fairness_metrics(y_true, y_pred, sensitive_features):
    return {
        'dpr': demographic_parity_ratio(y_true, y_pred, sensitive_features=sensitive_features),
        'eor': equalized_odds_ratio(y_true, y_pred, sensitive_features=sensitive_features)
    }
def evaluate_spd(X_test, y_pred):
    X_test_copy = X_test.copy()
    X_test_copy[target] = y_pred
    dataset = fl.DataFrame(X_test_copy)
    dataset.targets = target
    dataset.sensitive = sensitive_feature

    spd = dataset.statistical_parity_difference()
    return spd
def metrics_bar_plot(dict1, dict2, label1, label2, metrics, title="Metric Comparison (mean ± std)"):
    def summarise(metric_dict):
        means = []
        stds = []
        for m in metrics:
            values = metric_dict.get(m, [])
            mean_val = np.nanmean(values)
            std_val = np.nanstd(values)
            means.append(mean_val)
            stds.append(std_val)
        return np.array(means), np.array(stds)

    mean1, std1 = summarise(dict1)
    mean2, std2 = summarise(dict2)

    fig, axes = plt.subplots(1, 2, figsize=(10, 5), sharey=True)
    for ax, mean, std, label in zip(axes, [mean1, mean2], [std1, std2], [label1, label2]):
        ax.bar(metrics, mean, yerr=std, capsize=4)
        ax.set_title(label)
        ax.set_ylim(0, 1.2)
        ax.grid(axis="y", alpha=0.3)

    fig.suptitle(title)
    plt.tight_layout()
    plt.show()
class Simple_NN(nn.Module):
    def __init__(self, input_dim: int, return_representation: bool = False):
        super(Simple_NN, self).__init__()
        self.return_representation = return_representation

        self.bn1 = nn.BatchNorm1d(input_dim)
        self.fc1 = nn.Linear(input_dim, 64)
        self.bn2 = nn.BatchNorm1d(64)
        self.fc2 = nn.Linear(64, 32)
        self.bn3 = nn.BatchNorm1d(32)
        self.fc3 = nn.Linear(32, 1)

        self.dropout = nn.Dropout(0.3)
        self.relu = nn.ReLU()
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        x = self.bn1(x)
        x = self.relu(self.fc1(x))
        x = self.bn2(x)
        x = self.dropout(x)
        rep = self.relu(self.fc2(x))
        rep = self.bn3(rep)
        rep = self.dropout(rep)
        out = self.fc3(rep)

        if self.return_representation:
            return self.sigmoid(out), rep
        else:
            return self.sigmoid(out)
def aggregate_metrics(metric_list):
    metrics_dict = {}
    for fold_metrics in metric_list:
        for key, value in fold_metrics.items():
            if key not in metrics_dict:
                metrics_dict[key] = []
            metrics_dict[key].append(value)
    return metrics_dict

1) Adversarial Debiasing#

baseline_performance_metrics = []
baseline_fairness_metrics = []

inprocessing_performance_metrics = []
inprocessing_fairness_metrics = []

X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
Baseline#
from fairlib.inprocessing import AdversarialDebiasing

k = 5
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for fold, (train_index, test_index) in enumerate(skf.split(X, df1[sensitive_feature].astype(str))):
    print(f"\nFold {fold + 1}/{k}")

    # Suddivisione in training e test set
    X_train, X_test = X.iloc[train_index], X.iloc[test_index]
    y_train, y_test = y.iloc[train_index], y.iloc[test_index]
    sensitive_train = X.iloc[train_index][sensitive_feature]
    sensitive_test = X.iloc[test_index][sensitive_feature]

    # Aggiungi la colonna 'sensitive' per fairlib
    X_train_copy = X_train.copy()
    X_train_copy["sensitive"] = sensitive_train.values
    X_train_dataframe = fl.DataFrame(X_train_copy)
    X_train_dataframe.sensitive = "sensitive"

    X_test_copy = X_test.copy()
    X_test_copy["sensitive"] = sensitive_test.values
    X_test_dataframe = fl.DataFrame(X_test_copy)
    X_test_dataframe.sensitive = "sensitive"

    # Modello AdversarialDebiasing
    baseline_model = AdversarialDebiasing(
        input_dim=X_train.shape[1],
        hidden_dim=8,
        output_dim=1,
        sensitive_dim=1,
        lambda_adv=0,
    )

    # Training
    baseline_model.fit(X_train, y_train)

    # Prediction
    y_pred_tensor = baseline_model.predict(X_test)
    y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
    y_pred_labels = (y_pred > 0.5).astype(int)
    y_proba = y_pred

    # Performance metrics
    perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
    baseline_performance_metrics.append(perf_metrics)

    # Fairness metrics
    fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, sensitive_test)
    baseline_fairness_metrics.append(fair_metrics)

    # SPD evaluation
    spd = evaluate_spd(X_test.copy(), y_pred_labels)
    baseline_spd_val = sum(abs(v) for v in spd.values()) / len(spd)

    print(f"SPD Fold {fold + 1}: {spd}")
Fold 1/5
SPD Fold 1: {(hired=0, grouped_regions=0.0): 0.04162708833882772, (hired=0, grouped_regions=1.0): 0.11578748900155028, (hired=0, grouped_regions=2.0): -0.09149740728688097, (hired=0, grouped_regions=3.0): 0.06439560923999296, (hired=0, grouped_regions=4.0): 0.14268867924528303, (hired=0, grouped_regions=5.0): -0.033175914994096806, (hired=1, grouped_regions=0.0): -0.04162708833882767, (hired=1, grouped_regions=1.0): -0.11578748900155023, (hired=1, grouped_regions=2.0): 0.09149740728688105, (hired=1, grouped_regions=3.0): -0.06439560923999299, (hired=1, grouped_regions=4.0): -0.14268867924528306, (hired=1, grouped_regions=5.0): 0.03317591499409689}

Fold 2/5
SPD Fold 2: {(hired=0, grouped_regions=0.0): -0.1717296962182269, (hired=0, grouped_regions=1.0): -0.16686746987951806, (hired=0, grouped_regions=2.0): -0.060912891835098903, (hired=0, grouped_regions=3.0): 0.08619242941760828, (hired=0, grouped_regions=4.0): 0.4768834237233528, (hired=0, grouped_regions=5.0): 0.18643990098102137, (hired=1, grouped_regions=0.0): 0.17172969621822687, (hired=1, grouped_regions=1.0): 0.16686746987951806, (hired=1, grouped_regions=2.0): 0.06091289183509885, (hired=1, grouped_regions=3.0): -0.0861924294176083, (hired=1, grouped_regions=4.0): -0.4768834237233528, (hired=1, grouped_regions=5.0): -0.18643990098102137}

Fold 3/5
SPD Fold 3: {(hired=0, grouped_regions=0.0): 0.054364640883977855, (hired=0, grouped_regions=1.0): 0.0034969156418215297, (hired=0, grouped_regions=2.0): -0.07514402969126838, (hired=0, grouped_regions=3.0): 0.06661513403086436, (hired=0, grouped_regions=4.0): 0.07891637220259129, (hired=0, grouped_regions=5.0): -0.18478444632290789, (hired=1, grouped_regions=0.0): -0.054364640883977855, (hired=1, grouped_regions=1.0): -0.0034969156418215297, (hired=1, grouped_regions=2.0): 0.07514402969126832, (hired=1, grouped_regions=3.0): -0.06661513403086439, (hired=1, grouped_regions=4.0): -0.07891637220259129, (hired=1, grouped_regions=5.0): 0.1847844463229079}

Fold 4/5
SPD Fold 4: {(hired=0, grouped_regions=0.0): -0.07785186520093709, (hired=0, grouped_regions=1.0): 0.010221008706403578, (hired=0, grouped_regions=2.0): -0.08846349446451159, (hired=0, grouped_regions=3.0): 0.09856342077588784, (hired=0, grouped_regions=4.0): 0.3407755581668625, (hired=0, grouped_regions=5.0): -0.034952606635071076, (hired=1, grouped_regions=0.0): 0.07785186520093712, (hired=1, grouped_regions=1.0): -0.010221008706403523, (hired=1, grouped_regions=2.0): 0.08846349446451163, (hired=1, grouped_regions=3.0): -0.09856342077588787, (hired=1, grouped_regions=4.0): -0.34077555816686256, (hired=1, grouped_regions=5.0): 0.03495260663507105}

Fold 5/5
SPD Fold 5: {(hired=0, grouped_regions=0.0): -0.1203870387038704, (hired=0, grouped_regions=1.0): 0.04790741036258114, (hired=0, grouped_regions=2.0): -0.04905140987624282, (hired=0, grouped_regions=3.0): 0.05133833773654631, (hired=0, grouped_regions=4.0): 0.007067137809187274, (hired=0, grouped_regions=5.0): 0.2805094786729858, (hired=1, grouped_regions=0.0): 0.12038703870387046, (hired=1, grouped_regions=1.0): -0.04790741036258117, (hired=1, grouped_regions=2.0): 0.04905140987624279, (hired=1, grouped_regions=3.0): -0.05133833773654639, (hired=1, grouped_regions=4.0): -0.007067137809187218, (hired=1, grouped_regions=5.0): -0.2805094786729858}
With different adversary weights#
# In-processing con Adversarial Debiasing su diversi lambda_adv
adv_weights = [0.1, 0.5, 1.0]
adv_spds = []

for i, lam in enumerate(adv_weights):
    print(f"\n== Adversarial Debiasing con lambda_adv={lam} ==")

    # Crea liste per ciascun lambda
    perf_list = []
    fair_list = []
    spd_list = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[sensitive_feature].astype(str))):
        print(f"\nFold {fold + 1}/{k} - lambda_adv={lam}")

        # Split dei dati
        X_train, X_test = X.iloc[train_index], X.iloc[test_index]
        y_train, y_test = y.iloc[train_index], y.iloc[test_index]
        sensitive_train = X.iloc[train_index][sensitive_feature]
        sensitive_test = X.iloc[test_index][sensitive_feature]

        # Prepara fairlib dataframe
        X_train_copy = X_train.copy()
        X_train_copy["sensitive"] = sensitive_train.values
        X_tr = fl.DataFrame(X_train_copy)
        X_tr.sensitive = "sensitive"

        X_test_copy = X_test.copy()
        X_test_copy["sensitive"] = sensitive_test.values
        X_te = fl.DataFrame(X_test_copy)
        X_te.sensitive = "sensitive"

        # Modello Adversarial Debiasing
        model = AdversarialDebiasing(
            input_dim=X_train.shape[1],
            hidden_dim=8,
            output_dim=1,
            sensitive_dim=1,
            lambda_adv=lam,
        )

        # Addestramento
        model.fit(X_train, y_train)

        # Predizione
        y_pred_tensor = model.predict(X_test)
        y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
        y_pred_labels = (y_pred > 0.5).astype(int)
        y_proba = y_pred

        # Performance
        perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
        perf_list.append(perf_metrics)

        # Fairness
        fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, sensitive_test)
        fair_list.append(fair_metrics)

        # SPD
        spd = evaluate_spd(X_test.copy(), y_pred_labels)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)
        spd_list.append(spd_val)

        print(f"SPD Fold {fold + 1} (λ={lam}): {spd}")

    # Salva i risultati complessivi del lambda
    inprocessing_performance_metrics.append(perf_list)
    inprocessing_fairness_metrics.append(fair_list)
    adv_spds.append(spd_list)
== Adversarial Debiasing con lambda_adv=0.1 ==

Fold 1/5 - lambda_adv=0.1
SPD Fold 1 (λ=0.1): {(hired=0, grouped_regions=0.0): 0.06618826778630098, (hired=0, grouped_regions=1.0): -0.03796036368207148, (hired=0, grouped_regions=2.0): 0.011476432529064107, (hired=0, grouped_regions=3.0): -0.013092928936695461, (hired=0, grouped_regions=4.0): -0.10613207547169812, (hired=0, grouped_regions=5.0): -0.10625737898465171, (hired=1, grouped_regions=0.0): -0.06618826778630094, (hired=1, grouped_regions=1.0): 0.03796036368207145, (hired=1, grouped_regions=2.0): -0.011476432529064051, (hired=1, grouped_regions=3.0): 0.013092928936695447, (hired=1, grouped_regions=4.0): 0.10613207547169812, (hired=1, grouped_regions=5.0): 0.10625737898465171}

Fold 2/5 - lambda_adv=0.1
SPD Fold 2 (λ=0.1): {(hired=0, grouped_regions=0.0): -0.06002738736774694, (hired=0, grouped_regions=1.0): -0.06883899233296822, (hired=0, grouped_regions=2.0): -0.007551829466432708, (hired=0, grouped_regions=3.0): 0.03172435838260197, (hired=0, grouped_regions=4.0): -0.09037212049616067, (hired=0, grouped_regions=5.0): -0.05212249014394425, (hired=1, grouped_regions=0.0): 0.06002738736774693, (hired=1, grouped_regions=1.0): 0.06883899233296831, (hired=1, grouped_regions=2.0): 0.007551829466432625, (hired=1, grouped_regions=3.0): -0.031724358382602014, (hired=1, grouped_regions=4.0): 0.09037212049616061, (hired=1, grouped_regions=5.0): 0.05212249014394432}

Fold 3/5 - lambda_adv=0.1
SPD Fold 3 (λ=0.1): {(hired=0, grouped_regions=0.0): 0.06448127685696747, (hired=0, grouped_regions=1.0): 0.03406545911751994, (hired=0, grouped_regions=2.0): 0.04503408302913739, (hired=0, grouped_regions=3.0): -0.056930679402589515, (hired=0, grouped_regions=4.0): -0.03180212014134275, (hired=0, grouped_regions=5.0): -0.03195266272189349, (hired=1, grouped_regions=0.0): -0.06448127685696747, (hired=1, grouped_regions=1.0): -0.034065459117520014, (hired=1, grouped_regions=2.0): -0.04503408302913747, (hired=1, grouped_regions=3.0): 0.05693067940258956, (hired=1, grouped_regions=4.0): 0.03180212014134276, (hired=1, grouped_regions=5.0): 0.031952662721893454}

Fold 4/5 - lambda_adv=0.1
SPD Fold 4 (λ=0.1): {(hired=0, grouped_regions=0.0): 0.21397248753529166, (hired=0, grouped_regions=1.0): 0.10194219772294061, (hired=0, grouped_regions=2.0): 0.05342061453266743, (hired=0, grouped_regions=3.0): -0.1049172826421039, (hired=0, grouped_regions=4.0): -0.0881316098707403, (hired=0, grouped_regions=5.0): -0.08886255924170616, (hired=1, grouped_regions=0.0): -0.21397248753529163, (hired=1, grouped_regions=1.0): -0.10194219772294055, (hired=1, grouped_regions=2.0): -0.05342061453266744, (hired=1, grouped_regions=3.0): 0.10491728264210398, (hired=1, grouped_regions=4.0): 0.08813160987074031, (hired=1, grouped_regions=5.0): 0.08886255924170616}

Fold 5/5 - lambda_adv=0.1
SPD Fold 5 (λ=0.1): {(hired=0, grouped_regions=0.0): 0.006300630063006296, (hired=0, grouped_regions=1.0): 0.0065679514206764475, (hired=0, grouped_regions=2.0): 0.012491516703482593, (hired=0, grouped_regions=3.0): -0.010849001969035946, (hired=0, grouped_regions=4.0): -0.0624263839811543, (hired=0, grouped_regions=5.0): -0.06279620853080568, (hired=1, grouped_regions=0.0): -0.006300630063006296, (hired=1, grouped_regions=1.0): -0.006567951420676454, (hired=1, grouped_regions=2.0): -0.012491516703482586, (hired=1, grouped_regions=3.0): 0.010849001969035932, (hired=1, grouped_regions=4.0): 0.0624263839811543, (hired=1, grouped_regions=5.0): 0.0627962085308057}

== Adversarial Debiasing con lambda_adv=0.5 ==

Fold 1/5 - lambda_adv=0.5
SPD Fold 1 (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 2/5 - lambda_adv=0.5
SPD Fold 2 (λ=0.5): {(hired=0, grouped_regions=0.0): -0.02169869807811531, (hired=0, grouped_regions=1.0): -0.02108433734939759, (hired=0, grouped_regions=2.0): -0.02643356946339984, (hired=0, grouped_regions=3.0): 0.014730298174523239, (hired=0, grouped_regions=4.0): 0.16232615582881382, (hired=0, grouped_regions=5.0): 0.21348675162739528, (hired=1, grouped_regions=0.0): 0.021698698078115308, (hired=1, grouped_regions=1.0): 0.02108433734939763, (hired=1, grouped_regions=2.0): 0.02643356946339981, (hired=1, grouped_regions=3.0): -0.014730298174523204, (hired=1, grouped_regions=4.0): -0.16232615582881382, (hired=1, grouped_regions=5.0): -0.21348675162739517}

Fold 3/5 - lambda_adv=0.5
SPD Fold 3 (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 4/5 - lambda_adv=0.5
SPD Fold 4 (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 5/5 - lambda_adv=0.5
SPD Fold 5 (λ=0.5): {(hired=0, grouped_regions=0.0): -0.0006188118811881188, (hired=0, grouped_regions=1.0): -0.0006131207847946045, (hired=0, grouped_regions=2.0): 0.0015552099533437014, (hired=0, grouped_regions=3.0): -0.0012106537530266344, (hired=0, grouped_regions=4.0): -0.0005889281507656066, (hired=0, grouped_regions=5.0): -0.0005924170616113745, (hired=1, grouped_regions=0.0): 0.0006188118811880639, (hired=1, grouped_regions=1.0): 0.0006131207847945852, (hired=1, grouped_regions=2.0): -0.0015552099533436836, (hired=1, grouped_regions=3.0): 0.001210653753026647, (hired=1, grouped_regions=4.0): 0.0005889281507656108, (hired=1, grouped_regions=5.0): 0.0005924170616113944}

== Adversarial Debiasing con lambda_adv=1.0 ==

Fold 1/5 - lambda_adv=1.0
SPD Fold 1 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 2/5 - lambda_adv=1.0
SPD Fold 2 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 3/5 - lambda_adv=1.0
SPD Fold 3 (λ=1.0): {(hired=0, grouped_regions=0.0): -0.003683241252302026, (hired=0, grouped_regions=1.0): -0.0036540803897685747, (hired=0, grouped_regions=2.0): 0.0017008805327681175, (hired=0, grouped_regions=3.0): -0.00031471716864975297, (hired=0, grouped_regions=4.0): -0.0035335689045936395, (hired=0, grouped_regions=5.0): -0.0035502958579881655, (hired=1, grouped_regions=0.0): 0.0036832412523020164, (hired=1, grouped_regions=1.0): 0.0036540803897685548, (hired=1, grouped_regions=2.0): -0.0017008805327681786, (hired=1, grouped_regions=3.0): 0.0003147171686497252, (hired=1, grouped_regions=4.0): 0.0035335689045936647, (hired=1, grouped_regions=5.0): 0.0035502958579881616}

Fold 4/5 - lambda_adv=1.0
SPD Fold 4 (λ=1.0): {(hired=0, grouped_regions=0.0): -0.015518311607697082, (hired=0, grouped_regions=1.0): -0.015197568389057751, (hired=0, grouped_regions=2.0): -0.014139728159053469, (hired=0, grouped_regions=3.0): 0.016959711553967166, (hired=0, grouped_regions=4.0): -0.014688601645123384, (hired=0, grouped_regions=5.0): 0.04828199052132701, (hired=1, grouped_regions=0.0): 0.015518311607697122, (hired=1, grouped_regions=1.0): 0.015197568389057725, (hired=1, grouped_regions=2.0): 0.01413972815905351, (hired=1, grouped_regions=3.0): -0.01695971155396714, (hired=1, grouped_regions=4.0): 0.014688601645123422, (hired=1, grouped_regions=5.0): -0.04828199052132698}

Fold 5/5 - lambda_adv=1.0
SPD Fold 5 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}
model_names = ['Baseline'] + [f'AdvDebias λ={lam}' for lam in adv_weights]

# Calcola la media degli SPD per ogni lambda
mean_adv_spds = [np.mean(spd_list) for spd_list in adv_spds]
spds = [baseline_spd_val] + mean_adv_spds

results = pd.DataFrame({
    'Model': model_names,
    'SPD': spds
})

# Plot a barre
plt.figure(figsize=(8, 5))
plt.bar(results['Model'], results['SPD'], color='cornflowerblue')
plt.title('Statistical Parity Difference (SPD)')
plt.ylabel('|SPD|')
plt.xticks(rotation=30)
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()
_images/main_200_0.png
for i, lam in enumerate(adv_weights):
    print(f"== Bar plot per λ = {lam} ==")

    # Performance
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (Adversarial λ={lam})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (Adversarial λ={lam})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per λ = 0.1 ==
_images/main_201_1.png _images/main_201_2.png
== Bar plot per λ = 0.5 ==
_images/main_201_4.png _images/main_201_5.png
== Bar plot per λ = 1.0 ==
_images/main_201_7.png _images/main_201_8.png

Adversarial Debiasing demonstrated a notably positive impact on fairness metrics, with substantial improvements in both Equalized Odds Ratio (EOR) and—most significantly—Demographic Parity Ratio (DPR).

lambda_vals = [0.1, 0.5, 1.0]

accuracies = []
precisions = []
recalls = []
aucs = []

for metrics_list in inprocessing_performance_metrics:
    aggregated = aggregate_metrics(metrics_list)
    accuracies.append(np.mean(aggregated['accuracy']))
    precisions.append(np.mean(aggregated['precision']))
    recalls.append(np.mean(aggregated['recall']))
    aucs.append(np.mean(aggregated['auc']))

plt.figure(figsize=(10, 6))
plt.plot(lambda_vals, accuracies, marker='o', label='Accuracy')
plt.plot(lambda_vals, precisions, marker='o', label='Precision')
plt.plot(lambda_vals, recalls, marker='o', label='Recall')
plt.plot(lambda_vals, aucs, marker='o', label='AUC')
plt.xlabel('lambda_adv')
plt.ylabel('Metric value')
plt.title('Performance Metrics vs lambda_adv')
plt.legend()
plt.grid(True)
plt.show()
_images/main_203_0.png
dprs = []
eors = []

for metrics_list in inprocessing_fairness_metrics:
    aggregated = aggregate_metrics(metrics_list)
    dprs.append(np.mean(aggregated['dpr']))
    eors.append(np.mean(aggregated['eor']))


plt.figure(figsize=(8, 5))
plt.plot(lambda_vals, dprs, marker='o', label='DPR')
plt.plot(lambda_vals, eors, marker='o', label='EOR')
plt.xlabel('lambda_adv')
plt.ylabel('Fairness metric')
plt.title('Fairness Metrics vs lambda_adv')
plt.legend()
plt.grid(True)
plt.show()
_images/main_204_0.png

Here we can see that the bigger weight lambda give the better result in terms of fairness

2) FaUCI#

k = 5
reg_weights = [0.0, 0.1, 0.2, 0.3, 0.4]
epochs = 100
batch_size = 128
fauci_spd_vals = []

baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in reg_weights if _ != 0.0]
inprocessing_fairness_metrics = [[] for _ in reg_weights if _ != 0.0]


X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for i, weight in enumerate(reg_weights):
    print(f"\n== FaUCI training with regularization weight: {weight} ==")
    fold_spds = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[sensitive_feature].astype(str))):
        print(f"  Fold {fold + 1}/{k}")

        X_train_fold = X.iloc[train_index]
        X_test_fold = X.iloc[test_index]
        y_train_fold = y.iloc[train_index]
        y_test_fold = y.iloc[test_index]

        input_dim = X.shape[1]
        X_test_tensor = torch.tensor(X_test_fold.values, dtype=torch.float32)

        model = Simple_NN(input_dim)
        optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

        fauci_model = fl.Fauci(
            torchModel=model,
            optimizer=optimizer,
            loss=nn.BCELoss(),
            fairness_regularization="spd",
            regularization_weight=weight
        )

        # Prepara dati fairlib
        train_data = fl.DataFrame(X_train_fold)
        train_data[target] = y_train_fold.values
        train_data.targets = target
        train_data.sensitive = sensitive_feature

        # Fit
        fauci_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

        # Predict
        model.eval()
        with torch.no_grad():
            y_pred_tensor = fauci_model.predict(X_test_tensor)
            y_pred = y_pred_tensor.cpu().numpy().flatten()
            y_pred_labels = (y_pred > 0.5).astype(int)

        # Metrics
        perf_metrics = compute_performance_metrics(y_test_fold, y_pred_labels, y_pred)
        fair_metrics = compute_fairness_metrics(y_test_fold, y_pred_labels, X_test_fold[sensitive_feature])
        spd = evaluate_spd(X_test_fold.copy(), y_pred_labels)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)

        # Salva
        if weight == 0.0:
            baseline_performance_metrics.append(perf_metrics)
            baseline_fairness_metrics.append(fair_metrics)
            if fold == 0:
                baseline_spd_val = spd_val
        else:
            inprocessing_performance_metrics[i - 1].append(perf_metrics)
            inprocessing_fairness_metrics[i - 1].append(fair_metrics)
            fold_spds.append(spd_val)

    if weight != 0.0:
        mean_spd = np.mean(fold_spds)
        fauci_spd_vals.append(mean_spd)
        print(f"[λ={weight}] Mean SPD: {mean_spd:.4f}")
== FaUCI training with regularization weight: 0.0 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5

== FaUCI training with regularization weight: 0.1 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.1] Mean SPD: 0.0836

== FaUCI training with regularization weight: 0.2 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.2] Mean SPD: 0.0796

== FaUCI training with regularization weight: 0.3 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.3] Mean SPD: 0.0851

== FaUCI training with regularization weight: 0.4 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.4] Mean SPD: 0.0804
model_names = ['Baseline'] + [f'FaUCI λ={w}' for w in reg_weights if w != 0.0]
spds = [baseline_spd_val] + fauci_spd_vals

results = pd.DataFrame({
    'Model': model_names,
    'SPD': spds
})

plt.figure(figsize=(8, 5))
plt.bar(results['Model'], results['SPD'], color='mediumseagreen')
plt.title('Statistical Parity Difference (SPD) - FaUCI')
plt.ylabel('|SPD|')
plt.xticks(rotation=30)
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()
_images/main_209_0.png
for i, lam in enumerate(reg_weights[1:]):  # Salta λ=0.0 perché è il baseline
    print(f"== Bar plot per λ = {lam} ==")

    # Performance metrics
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (FaUCI λ={lam})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness metrics
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (FaUCI λ={lam})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per λ = 0.1 ==
_images/main_210_1.png _images/main_210_2.png
== Bar plot per λ = 0.2 ==
_images/main_210_4.png _images/main_210_5.png
== Bar plot per λ = 0.3 ==
_images/main_210_7.png _images/main_210_8.png
== Bar plot per λ = 0.4 ==
_images/main_210_10.png _images/main_210_11.png

In contrast to Adversarial Debiasing, the FaUCI (Fair Uncertainty-aware Classification Index) method did not yield substantial improvements in fairness metrics.

In particular, when increasing the fairness regularization parameter λ, we observed a destabilization of the Equalized Odds Ratio (EOR)—often resulting in explosive or erratic values. This suggests that the model, under strong regularization pressure, struggles to maintain balanced error rates across sensitive groups.

At the same time, while Demographic Parity Ratio (DPR) tends to decrease slightly under high λ values (indicating some fairness enforcement), this comes at the cost of a significant drop in recall. The model becomes more conservative and tends to under-predict the positive class, especially for minority groups.

fauci_lambdas = [w for w in reg_weights if w != 0.0]

accuracies = []
precisions = []
recalls = []
aucs = []

for metrics_list in inprocessing_performance_metrics:
    aggregated = aggregate_metrics(metrics_list)
    accuracies.append(np.mean(aggregated['accuracy']))
    precisions.append(np.mean(aggregated['precision']))
    recalls.append(np.mean(aggregated['recall']))
    aucs.append(np.mean(aggregated['auc']))

# Plot
plt.figure(figsize=(10, 6))
plt.plot(fauci_lambdas, accuracies, marker='o', label='Accuracy')
plt.plot(fauci_lambdas, precisions, marker='o', label='Precision')
plt.plot(fauci_lambdas, recalls, marker='o', label='Recall')
plt.plot(fauci_lambdas, aucs, marker='o', label='AUC')
plt.xlabel('λ (FaUCI)')
plt.ylabel('Metric Value')
plt.title('Performance Metrics vs Regularization Weight (FaUCI)')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
_images/main_212_0.png
fauci_lambdas = [w for w in reg_weights if w != 0.0]

dprs = []
eors = []

for metrics_list in inprocessing_fairness_metrics:
    aggregated = aggregate_metrics(metrics_list)
    dprs.append(np.mean(aggregated['dpr']))
    eors.append(np.mean(aggregated['eor']))

# Plot
plt.figure(figsize=(8, 5))
plt.plot(fauci_lambdas, dprs, marker='o', label='DPR')
plt.plot(fauci_lambdas, eors, marker='o', label='EOR')
plt.xlabel('λ (FaUCI)')
plt.ylabel('Fairness Metric')
plt.title('Fairness Metrics vs Regularization Weight (FaUCI)')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
_images/main_213_0.png

3) Prejudice Remover#

k = 5
etas = [0.0, 0.1, 0.2, 0.3, 0.4]
epochs = 100
batch_size = 128
pr_spd_vals = []

baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in etas if _ != 0.0]
inprocessing_fairness_metrics = [[] for _ in etas if _ != 0.0]


X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for i, eta in enumerate(etas):
    print(f"\n== PrejudiceRemover training with eta: {eta} ==")
    fold_spds = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[sensitive_feature].astype(str))):
        print(f"  Fold {fold + 1}/{k}")

        X_train_fold = X.iloc[train_index]
        X_test_fold = X.iloc[test_index]
        y_train_fold = y.iloc[train_index]
        y_test_fold = y.iloc[test_index]

        input_dim = X.shape[1]

        model = Simple_NN(input_dim)
        pr_model = fl.PrejudiceRemover(model, loss=nn.BCELoss(), eta=eta)

        # Prepara dati fairlib
        train_data = fl.DataFrame(X_train_fold)
        train_data[target] = y_train_fold.values
        train_data.targets = target
        train_data.sensitive = sensitive_feature

        test_data = fl.DataFrame(X_test_fold)
        test_data[target] = y_test_fold.values
        test_data.targets = target
        test_data.sensitive = sensitive_feature

        # Fit
        pr_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

        # Predict
        target_col = list(test_data.targets)[0]
        sensitive_col = list(test_data.sensitive)[0]
        X_test_df = test_data.drop(columns=target_col)
        y_true = test_data[target_col].to_numpy()

        y_pred_tensor = pr_model.predict(X_test_df)
        y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
        y_pred_bin = (y_pred > 0.5).astype(int)

        # Metrics
        perf_metrics = compute_performance_metrics(y_true, y_pred_bin, y_pred)
        fair_metrics = compute_fairness_metrics(y_true, y_pred_bin, X_test_df[sensitive_col])
        spd = evaluate_spd(X_test_df.copy(), y_pred_bin)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)

        # Salva
        if eta == 0.0:
            baseline_performance_metrics.append(perf_metrics)
            baseline_fairness_metrics.append(fair_metrics)
            if fold == 0:
                baseline_spd_val = spd_val
            print(f"[Baseline] Accuracy: {perf_metrics['accuracy']:.4f}, SPD: {spd_val:.4f}")
        else:
            inprocessing_performance_metrics[i - 1].append(perf_metrics)
            inprocessing_fairness_metrics[i - 1].append(fair_metrics)
            fold_spds.append(spd_val)

    if eta != 0.0:
        mean_spd = np.mean(fold_spds)
        pr_spd_vals.append(mean_spd)
        print(f"[η={eta}] Mean SPD: {mean_spd:.4f}")
== PrejudiceRemover training with eta: 0.0 ==
  Fold 1/5
[Baseline] Accuracy: 0.6770, SPD: 0.1216
  Fold 2/5
[Baseline] Accuracy: 0.6729, SPD: 0.0896
  Fold 3/5
[Baseline] Accuracy: 0.6829, SPD: 0.0912
  Fold 4/5
[Baseline] Accuracy: 0.6981, SPD: 0.0845
  Fold 5/5
[Baseline] Accuracy: 0.7020, SPD: 0.0834

== PrejudiceRemover training with eta: 0.1 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.1] Mean SPD: 0.0975

== PrejudiceRemover training with eta: 0.2 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.2] Mean SPD: 0.0995

== PrejudiceRemover training with eta: 0.3 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.3] Mean SPD: 0.0881

== PrejudiceRemover training with eta: 0.4 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.4] Mean SPD: 0.0994
model_names = ['Baseline'] + [f'PrejudiceRemover η={eta}' for eta in etas if eta != 0.0]
spds = [baseline_spd_val] + pr_spd_vals

results_df = pd.DataFrame({
    'Model': model_names,
    'SPD': spds
})

# Plot SPD
plt.figure(figsize=(8, 5))
plt.bar(results_df['Model'], results_df['SPD'], color='mediumseagreen')
plt.title('Statistical Parity Difference (SPD) - PrejudiceRemover')
plt.ylabel('|SPD|')
plt.xticks(rotation=30)
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()
_images/main_217_0.png
for i, eta in enumerate(etas[1:]):
    print(f"== Bar plot per η = {eta} ==")

    # Performance metrics
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (PrejudiceRemover η={eta})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness metrics
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (PrejudiceRemover η={eta})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per η = 0.1 ==
_images/main_218_1.png _images/main_218_2.png
== Bar plot per η = 0.2 ==
_images/main_218_4.png _images/main_218_5.png
== Bar plot per η = 0.3 ==
_images/main_218_7.png _images/main_218_8.png
== Bar plot per η = 0.4 ==
_images/main_218_10.png _images/main_218_11.png

While the Demographic Parity Ratio (DPR) slightly decreases compared to the baseline, the improvement is marginal and accompanied by increased variance, indicating inconsistent fairness behavior across folds.

Notably, the Equalized Odds Ratio (EOR) remains very low in both models and exhibits high variability with the Prejudice Remover, suggesting instability in controlling error-rate parity.

Additional experiments with higher η values showed diminishing returns: rather than further improving fairness, higher regularization introduced performance degradation without significant gains in DPR or EOR.

These results suggest that Prejudice Remover is sensitive to η tuning, and beyond a certain threshold, increasing regularization may overconstrain the model—leading to worse generalization and unstable fairness outcomes. This behavior is especially problematic when the dataset lacks balanced representation across sensitive groups.

etas_inprocessing = [eta for eta in etas if eta != 0.0]

accuracies = []
precisions = []
recalls = []
aucs = []

for metrics_list in inprocessing_performance_metrics:
    aggregated = aggregate_metrics(metrics_list)
    accuracies.append(np.mean(aggregated['accuracy']))
    precisions.append(np.mean(aggregated['precision']))
    recalls.append(np.mean(aggregated['recall']))
    aucs.append(np.mean(aggregated['auc']))

# Plot
plt.figure(figsize=(10, 6))
plt.plot(etas_inprocessing, accuracies, marker='o', label='Accuracy')
plt.plot(etas_inprocessing, precisions, marker='o', label='Precision')
plt.plot(etas_inprocessing, recalls, marker='o', label='Recall')
plt.plot(etas_inprocessing, aucs, marker='o', label='AUC')
plt.xlabel('Regularization Strength η (PrejudiceRemover)')
plt.ylabel('Metric Value')
plt.title('Performance Metrics vs η (PrejudiceRemover)')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
_images/main_220_0.png
etas_inprocessing = [eta for eta in etas if eta != 0.0]

dprs = []
eors = []

for metrics_list in inprocessing_fairness_metrics:
    aggregated = aggregate_metrics(metrics_list)
    dprs.append(np.mean(aggregated['dpr']))
    eors.append(np.mean(aggregated['eor']))

# Plot
plt.figure(figsize=(8, 5))
plt.plot(etas_inprocessing, dprs, marker='o', label='DPR')
plt.plot(etas_inprocessing, eors, marker='o', label='EOR')
plt.xlabel('Regularization Strength η (PrejudiceRemover)')
plt.ylabel('Fairness Metric')
plt.title('Fairness Metrics vs η (PrejudiceRemover)')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
_images/main_221_0.png

Results using different stratification#

Adversarial Debiasing#
baseline_performance_metrics = []
baseline_fairness_metrics = []

inprocessing_performance_metrics = []
inprocessing_fairness_metrics = []

X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
from fairlib.inprocessing import AdversarialDebiasing

k = 5
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for fold, (train_index, test_index) in enumerate(skf.split(X, df1[target].astype(str) + df1[sensitive_feature].astype(str))):
    print(f"\nFold {fold + 1}/{k}")

    # Suddivisione in training e test set
    X_train, X_test = X.iloc[train_index], X.iloc[test_index]
    y_train, y_test = y.iloc[train_index], y.iloc[test_index]
    sensitive_train = X.iloc[train_index][sensitive_feature]
    sensitive_test = X.iloc[test_index][sensitive_feature]

    # Aggiungi la colonna 'sensitive' per fairlib
    X_train_copy = X_train.copy()
    X_train_copy["sensitive"] = sensitive_train.values
    X_train_dataframe = fl.DataFrame(X_train_copy)
    X_train_dataframe.sensitive = "sensitive"

    X_test_copy = X_test.copy()
    X_test_copy["sensitive"] = sensitive_test.values
    X_test_dataframe = fl.DataFrame(X_test_copy)
    X_test_dataframe.sensitive = "sensitive"

    # Modello AdversarialDebiasing
    baseline_model = AdversarialDebiasing(
        input_dim=X_train.shape[1],
        hidden_dim=8,
        output_dim=1,
        sensitive_dim=1,
        lambda_adv=0,
    )

    # Training
    baseline_model.fit(X_train, y_train)

    # Prediction
    y_pred_tensor = baseline_model.predict(X_test)
    y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
    y_pred_labels = (y_pred > 0.5).astype(int)
    y_proba = y_pred

    # Performance metrics
    perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
    baseline_performance_metrics.append(perf_metrics)

    # Fairness metrics
    fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, sensitive_test)
    baseline_fairness_metrics.append(fair_metrics)

    # SPD evaluation
    spd = evaluate_spd(X_test.copy(), y_pred_labels)
    baseline_spd_val = sum(abs(v) for v in spd.values()) / len(spd)

    print(f"SPD Fold {fold + 1}: {spd}")
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
Fold 1/5
SPD Fold 1: {(hired=0, grouped_regions=0.0): -0.16671147767321876, (hired=0, grouped_regions=1.0): -0.13577868219051054, (hired=0, grouped_regions=2.0): -0.08214504126914363, (hired=0, grouped_regions=3.0): 0.12147523360621848, (hired=0, grouped_regions=4.0): 0.31919905771495877, (hired=0, grouped_regions=5.0): 0.05390703999442101, (hired=1, grouped_regions=0.0): 0.1667114776732187, (hired=1, grouped_regions=1.0): 0.1357786821905106, (hired=1, grouped_regions=2.0): 0.08214504126914368, (hired=1, grouped_regions=3.0): -0.12147523360621848, (hired=1, grouped_regions=4.0): -0.3191990577149588, (hired=1, grouped_regions=5.0): -0.053907039994421035}

Fold 2/5
SPD Fold 2: {(hired=0, grouped_regions=0.0): -0.09359444827962068, (hired=0, grouped_regions=1.0): -0.03841121013858123, (hired=0, grouped_regions=2.0): -0.04134866028894907, (hired=0, grouped_regions=3.0): 0.06407012321177541, (hired=0, grouped_regions=4.0): -0.040047114252061256, (hired=0, grouped_regions=5.0): -0.03040552320513265, (hired=1, grouped_regions=0.0): 0.0935944482796206, (hired=1, grouped_regions=1.0): 0.038411210138581175, (hired=1, grouped_regions=2.0): 0.04134866028894901, (hired=1, grouped_regions=3.0): -0.06407012321177541, (hired=1, grouped_regions=4.0): 0.04004711425206131, (hired=1, grouped_regions=5.0): 0.03040552320513268}

Fold 3/5
SPD Fold 3: {(hired=0, grouped_regions=0.0): -0.20874177960251428, (hired=0, grouped_regions=1.0): -0.11228684766369584, (hired=0, grouped_regions=2.0): -0.1284324086751224, (hired=0, grouped_regions=3.0): 0.17429918134457953, (hired=0, grouped_regions=4.0): -0.06751410051351125, (hired=0, grouped_regions=5.0): 0.10337677725118483, (hired=1, grouped_regions=0.0): 0.20874177960251428, (hired=1, grouped_regions=1.0): 0.1122868476636959, (hired=1, grouped_regions=2.0): 0.1284324086751224, (hired=1, grouped_regions=3.0): -0.17429918134457945, (hired=1, grouped_regions=4.0): 0.06751410051351114, (hired=1, grouped_regions=5.0): -0.10337677725118488}

Fold 4/5
SPD Fold 4: {(hired=0, grouped_regions=0.0): -0.1142857142857143, (hired=0, grouped_regions=1.0): 0.04038929440389294, (hired=0, grouped_regions=2.0): -0.058819192139706405, (hired=0, grouped_regions=3.0): 0.053783180352269894, (hired=0, grouped_regions=4.0): 0.34514689788702746, (hired=0, grouped_regions=5.0): 0.33797393364928907, (hired=1, grouped_regions=0.0): 0.11428571428571421, (hired=1, grouped_regions=1.0): -0.04038929440389305, (hired=1, grouped_regions=2.0): 0.05881919213970643, (hired=1, grouped_regions=3.0): -0.05378318035226992, (hired=1, grouped_regions=4.0): -0.3451468978870275, (hired=1, grouped_regions=5.0): -0.33797393364928907}

Fold 5/5
SPD Fold 5: {(hired=0, grouped_regions=0.0): -0.167975874722959, (hired=0, grouped_regions=1.0): -0.12483643295038896, (hired=0, grouped_regions=2.0): -0.05000585531727869, (hired=0, grouped_regions=3.0): 0.09374018026957744, (hired=0, grouped_regions=4.0): -0.02862193787355838, (hired=0, grouped_regions=5.0): 0.07938388625592416, (hired=1, grouped_regions=0.0): 0.16797587472295905, (hired=1, grouped_regions=1.0): 0.12483643295038893, (hired=1, grouped_regions=2.0): 0.050005855317278636, (hired=1, grouped_regions=3.0): -0.09374018026957742, (hired=1, grouped_regions=4.0): 0.02862193787355838, (hired=1, grouped_regions=5.0): -0.07938388625592419}
# In-processing con Adversarial Debiasing su diversi lambda_adv
adv_weights = [0.1, 0.5, 1.0]
adv_spds = []

for i, lam in enumerate(adv_weights):
    print(f"\n== Adversarial Debiasing con lambda_adv={lam} ==")

    # Crea liste per ciascun lambda
    perf_list = []
    fair_list = []
    spd_list = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[target].astype(str) + df1[sensitive_feature].astype(str))):
        print(f"\nFold {fold + 1}/{k} - lambda_adv={lam}")

        # Split dei dati
        X_train, X_test = X.iloc[train_index], X.iloc[test_index]
        y_train, y_test = y.iloc[train_index], y.iloc[test_index]
        sensitive_train = X.iloc[train_index][sensitive_feature]
        sensitive_test = X.iloc[test_index][sensitive_feature]

        # Prepara fairlib dataframe
        X_train_copy = X_train.copy()
        X_train_copy["sensitive"] = sensitive_train.values
        X_tr = fl.DataFrame(X_train_copy)
        X_tr.sensitive = "sensitive"

        X_test_copy = X_test.copy()
        X_test_copy["sensitive"] = sensitive_test.values
        X_te = fl.DataFrame(X_test_copy)
        X_te.sensitive = "sensitive"

        # Modello Adversarial Debiasing
        model = AdversarialDebiasing(
            input_dim=X_train.shape[1],
            hidden_dim=8,
            output_dim=1,
            sensitive_dim=1,
            lambda_adv=lam,
        )

        # Addestramento
        model.fit(X_train, y_train)

        # Predizione
        y_pred_tensor = model.predict(X_test)
        y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
        y_pred_labels = (y_pred > 0.5).astype(int)
        y_proba = y_pred

        # Performance
        perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
        perf_list.append(perf_metrics)

        # Fairness
        fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, sensitive_test)
        fair_list.append(fair_metrics)

        # SPD
        spd = evaluate_spd(X_test.copy(), y_pred_labels)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)
        spd_list.append(spd_val)

        print(f"SPD Fold {fold + 1} (λ={lam}): {spd}")

    # Salva i risultati complessivi del lambda
    inprocessing_performance_metrics.append(perf_list)
    inprocessing_fairness_metrics.append(fair_list)
    adv_spds.append(spd_list)
== Adversarial Debiasing con lambda_adv=0.1 ==

Fold 1/5 - lambda_adv=0.1
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
SPD Fold 1 (λ=0.1): {(hired=0, grouped_regions=0.0): -0.0059441194637212535, (hired=0, grouped_regions=1.0): -0.015558188655916752, (hired=0, grouped_regions=2.0): -0.016120194483279088, (hired=0, grouped_regions=3.0): 0.01926734474489375, (hired=0, grouped_regions=4.0): 0.23439340400471143, (hired=0, grouped_regions=5.0): -0.1007705986959099, (hired=1, grouped_regions=0.0): 0.005944119463721198, (hired=1, grouped_regions=1.0): 0.015558188655916738, (hired=1, grouped_regions=2.0): 0.01612019448327906, (hired=1, grouped_regions=3.0): -0.019267344744893777, (hired=1, grouped_regions=4.0): -0.23439340400471143, (hired=1, grouped_regions=5.0): 0.10077059869590987}

Fold 2/5 - lambda_adv=0.1
SPD Fold 2 (λ=0.1): {(hired=0, grouped_regions=0.0): 0.0901427896668241, (hired=0, grouped_regions=1.0): -0.0672299211787131, (hired=0, grouped_regions=2.0): -0.03649814437043772, (hired=0, grouped_regions=3.0): 0.02537831803522697, (hired=0, grouped_regions=4.0): 0.35159010600706714, (hired=0, grouped_regions=5.0): -0.09173960040447714, (hired=1, grouped_regions=0.0): -0.09014278966682421, (hired=1, grouped_regions=1.0): 0.06722992117871307, (hired=1, grouped_regions=2.0): 0.036498144370437746, (hired=1, grouped_regions=3.0): -0.025378318035226943, (hired=1, grouped_regions=4.0): -0.3515901060070671, (hired=1, grouped_regions=5.0): 0.09173960040447715}

Fold 3/5 - lambda_adv=0.1
SPD Fold 3 (λ=0.1): {(hired=0, grouped_regions=0.0): -0.07215056498201504, (hired=0, grouped_regions=1.0): -0.04952861779403431, (hired=0, grouped_regions=2.0): -0.07418325552749179, (hired=0, grouped_regions=3.0): 0.09492268254362027, (hired=0, grouped_regions=4.0): -0.11608721272834413, (hired=0, grouped_regions=5.0): -0.05361374407582939, (hired=1, grouped_regions=0.0): 0.072150564982015, (hired=1, grouped_regions=1.0): 0.04952861779403428, (hired=1, grouped_regions=2.0): 0.07418325552749183, (hired=1, grouped_regions=3.0): -0.09492268254362024, (hired=1, grouped_regions=4.0): 0.1160872127283441, (hired=1, grouped_regions=5.0): 0.05361374407582942}

Fold 4/5 - lambda_adv=0.1
SPD Fold 4 (λ=0.1): {(hired=0, grouped_regions=0.0): 0.01737213403880071, (hired=0, grouped_regions=1.0): 0.002311435523114358, (hired=0, grouped_regions=2.0): -0.008201781462210067, (hired=0, grouped_regions=3.0): 0.003382121888695952, (hired=0, grouped_regions=4.0): 0.17198417375199931, (hired=0, grouped_regions=5.0): -0.05242890995260663, (hired=1, grouped_regions=0.0): -0.01737213403880067, (hired=1, grouped_regions=1.0): -0.0023114355231144135, (hired=1, grouped_regions=2.0): 0.008201781462210067, (hired=1, grouped_regions=3.0): -0.0033821218886960214, (hired=1, grouped_regions=4.0): -0.17198417375199926, (hired=1, grouped_regions=5.0): 0.05242890995260663}

Fold 5/5 - lambda_adv=0.1
SPD Fold 5 (λ=0.1): {(hired=0, grouped_regions=0.0): -0.09820877084620136, (hired=0, grouped_regions=1.0): -0.09665653495440729, (hired=0, grouped_regions=2.0): -0.030148378077113805, (hired=0, grouped_regions=3.0): 0.0629868518977921, (hired=0, grouped_regions=4.0): 0.04975166259786176, (hired=0, grouped_regions=5.0): -0.09419431279620853, (hired=1, grouped_regions=0.0): 0.09820877084620139, (hired=1, grouped_regions=1.0): 0.09665653495440729, (hired=1, grouped_regions=2.0): 0.03014837807711379, (hired=1, grouped_regions=3.0): -0.06298685189779218, (hired=1, grouped_regions=4.0): -0.04975166259786179, (hired=1, grouped_regions=5.0): 0.09419431279620849}

== Adversarial Debiasing con lambda_adv=0.5 ==

Fold 1/5 - lambda_adv=0.5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
SPD Fold 1 (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 2/5 - lambda_adv=0.5
SPD Fold 2 (λ=0.5): {(hired=0, grouped_regions=0.0): -0.020971551066380843, (hired=0, grouped_regions=1.0): -0.03282674772036474, (hired=0, grouped_regions=2.0): -0.032364724098895584, (hired=0, grouped_regions=3.0): 0.04093277102455966, (hired=0, grouped_regions=4.0): -0.03180212014134275, (hired=0, grouped_regions=5.0): -0.032009484291641965, (hired=1, grouped_regions=0.0): 0.020971551066380933, (hired=1, grouped_regions=1.0): 0.03282674772036476, (hired=1, grouped_regions=2.0): 0.03236472409889557, (hired=1, grouped_regions=3.0): -0.04093277102455961, (hired=1, grouped_regions=4.0): 0.03180212014134276, (hired=1, grouped_regions=5.0): 0.032009484291641965}

Fold 3/5 - lambda_adv=0.5
SPD Fold 3 (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 4/5 - lambda_adv=0.5
SPD Fold 4 (λ=0.5): {(hired=0, grouped_regions=0.0): 0.010846560846560846, (hired=0, grouped_regions=1.0): -0.013990267639902677, (hired=0, grouped_regions=2.0): 0.017237764916962925, (hired=0, grouped_regions=3.0): -0.015835607376168032, (hired=0, grouped_regions=4.0): -0.013553329404832056, (hired=0, grouped_regions=5.0): -0.013625592417061612, (hired=1, grouped_regions=0.0): -0.010846560846560882, (hired=1, grouped_regions=1.0): 0.013990267639902632, (hired=1, grouped_regions=2.0): -0.01723776491696294, (hired=1, grouped_regions=3.0): 0.01583560737616796, (hired=1, grouped_regions=4.0): 0.013553329404832004, (hired=1, grouped_regions=5.0): 0.013625592417061627}

Fold 5/5 - lambda_adv=0.5
SPD Fold 5 (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

== Adversarial Debiasing con lambda_adv=1.0 ==

Fold 1/5 - lambda_adv=1.0
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
SPD Fold 1 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 2/5 - lambda_adv=1.0
SPD Fold 2 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 3/5 - lambda_adv=1.0
SPD Fold 3 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

Fold 4/5 - lambda_adv=1.0
SPD Fold 4 (λ=1.0): {(hired=0, grouped_regions=0.0): -0.0006172839506172839, (hired=0, grouped_regions=1.0): -0.0006082725060827251, (hired=0, grouped_regions=2.0): 0.0014992503748125937, (hired=0, grouped_regions=3.0): -0.001199040767386091, (hired=0, grouped_regions=4.0): -0.0005892751915144372, (hired=0, grouped_regions=5.0): -0.0005924170616113745, (hired=1, grouped_regions=0.0): 0.0006172839506173311, (hired=1, grouped_regions=1.0): 0.0006082725060827521, (hired=1, grouped_regions=2.0): -0.0014992503748125774, (hired=1, grouped_regions=3.0): 0.0011990407673860837, (hired=1, grouped_regions=4.0): 0.0005892751915144157, (hired=1, grouped_regions=5.0): 0.0005924170616113944}

Fold 5/5 - lambda_adv=1.0
SPD Fold 5 (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}
for i, lam in enumerate(adv_weights):
    print(f"== Bar plot per λ = {lam} ==")

    # Performance
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (Adversarial λ={lam})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (Adversarial λ={lam})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per λ = 0.1 ==
_images/main_227_1.png _images/main_227_2.png
== Bar plot per λ = 0.5 ==
_images/main_227_4.png _images/main_227_5.png
== Bar plot per λ = 1.0 ==
_images/main_227_7.png _images/main_227_8.png
FaUCI#
k = 5
reg_weights = [0.0, 0.1, 0.2, 0.3, 0.4]
epochs = 100
batch_size = 128
fauci_spd_vals = []

baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in reg_weights if _ != 0.0]
inprocessing_fairness_metrics = [[] for _ in reg_weights if _ != 0.0]


X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for i, weight in enumerate(reg_weights):
    print(f"\n== FaUCI training with regularization weight: {weight} ==")
    fold_spds = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[target].astype(str) + df1[sensitive_feature].astype(str))):
        print(f"  Fold {fold + 1}/{k}")

        X_train_fold = X.iloc[train_index]
        X_test_fold = X.iloc[test_index]
        y_train_fold = y.iloc[train_index]
        y_test_fold = y.iloc[test_index]

        input_dim = X.shape[1]
        X_test_tensor = torch.tensor(X_test_fold.values, dtype=torch.float32)

        model = Simple_NN(input_dim)
        optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

        fauci_model = fl.Fauci(
            torchModel=model,
            optimizer=optimizer,
            loss=nn.BCELoss(),
            fairness_regularization="spd",
            regularization_weight=weight
        )

        # Prepara dati fairlib
        train_data = fl.DataFrame(X_train_fold)
        train_data[target] = y_train_fold.values
        train_data.targets = target
        train_data.sensitive = sensitive_feature

        # Fit
        fauci_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

        # Predict
        model.eval()
        with torch.no_grad():
            y_pred_tensor = fauci_model.predict(X_test_tensor)
            y_pred = y_pred_tensor.cpu().numpy().flatten()
            y_pred_labels = (y_pred > 0.5).astype(int)

        # Metrics
        perf_metrics = compute_performance_metrics(y_test_fold, y_pred_labels, y_pred)
        fair_metrics = compute_fairness_metrics(y_test_fold, y_pred_labels, X_test_fold[sensitive_feature])
        spd = evaluate_spd(X_test_fold.copy(), y_pred_labels)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)

        # Salva
        if weight == 0.0:
            baseline_performance_metrics.append(perf_metrics)
            baseline_fairness_metrics.append(fair_metrics)
            if fold == 0:
                baseline_spd_val = spd_val
        else:
            inprocessing_performance_metrics[i - 1].append(perf_metrics)
            inprocessing_fairness_metrics[i - 1].append(fair_metrics)
            fold_spds.append(spd_val)

    if weight != 0.0:
        mean_spd = np.mean(fold_spds)
        fauci_spd_vals.append(mean_spd)
        print(f"[λ={weight}] Mean SPD: {mean_spd:.4f}")
== FaUCI training with regularization weight: 0.0 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5

== FaUCI training with regularization weight: 0.1 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.1] Mean SPD: 0.1674

== FaUCI training with regularization weight: 0.2 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.2] Mean SPD: 0.1362

== FaUCI training with regularization weight: 0.3 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.3] Mean SPD: 0.1405

== FaUCI training with regularization weight: 0.4 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.4] Mean SPD: 0.0827
for i, lam in enumerate(reg_weights[1:]):  # Salta λ=0.0 perché è il baseline
    print(f"== Bar plot per λ = {lam} ==")

    # Performance metrics
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (FaUCI λ={lam})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness metrics
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (FaUCI λ={lam})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per λ = 0.1 ==
_images/main_231_1.png _images/main_231_2.png
== Bar plot per λ = 0.2 ==
_images/main_231_4.png _images/main_231_5.png
== Bar plot per λ = 0.3 ==
_images/main_231_7.png _images/main_231_8.png
== Bar plot per λ = 0.4 ==
_images/main_231_10.png _images/main_231_11.png
Prejudice Remover#
k = 5
etas = [0.0, 0.1, 0.2, 0.3, 0.4]
epochs = 100
batch_size = 128
pr_spd_vals = []

baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in etas if _ != 0.0]
inprocessing_fairness_metrics = [[] for _ in etas if _ != 0.0]


X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for i, eta in enumerate(etas):
    print(f"\n== PrejudiceRemover training with eta: {eta} ==")
    fold_spds = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[target].astype(str) + df1[sensitive_feature].astype(str))):
        print(f"  Fold {fold + 1}/{k}")

        X_train_fold = X.iloc[train_index]
        X_test_fold = X.iloc[test_index]
        y_train_fold = y.iloc[train_index]
        y_test_fold = y.iloc[test_index]

        input_dim = X.shape[1]

        model = Simple_NN(input_dim)
        pr_model = fl.PrejudiceRemover(model, loss=nn.BCELoss(), eta=eta)

        # Prepara dati fairlib
        train_data = fl.DataFrame(X_train_fold)
        train_data[target] = y_train_fold.values
        train_data.targets = target
        train_data.sensitive = sensitive_feature

        test_data = fl.DataFrame(X_test_fold)
        test_data[target] = y_test_fold.values
        test_data.targets = target
        test_data.sensitive = sensitive_feature

        # Fit
        pr_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

        # Predict
        target_col = list(test_data.targets)[0]
        sensitive_col = list(test_data.sensitive)[0]
        X_test_df = test_data.drop(columns=target_col)
        y_true = test_data[target_col].to_numpy()

        y_pred_tensor = pr_model.predict(X_test_df)
        y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
        y_pred_bin = (y_pred > 0.5).astype(int)

        # Metrics
        perf_metrics = compute_performance_metrics(y_true, y_pred_bin, y_pred)
        fair_metrics = compute_fairness_metrics(y_true, y_pred_bin, X_test_df[sensitive_col])
        spd = evaluate_spd(X_test_df.copy(), y_pred_bin)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)

        # Salva
        if eta == 0.0:
            baseline_performance_metrics.append(perf_metrics)
            baseline_fairness_metrics.append(fair_metrics)
            if fold == 0:
                baseline_spd_val = spd_val
            print(f"[Baseline] Accuracy: {perf_metrics['accuracy']:.4f}, SPD: {spd_val:.4f}")
        else:
            inprocessing_performance_metrics[i - 1].append(perf_metrics)
            inprocessing_fairness_metrics[i - 1].append(fair_metrics)
            fold_spds.append(spd_val)

    if eta != 0.0:
        mean_spd = np.mean(fold_spds)
        pr_spd_vals.append(mean_spd)
        print(f"[η={eta}] Mean SPD: {mean_spd:.4f}")
== PrejudiceRemover training with eta: 0.0 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
[Baseline] Accuracy: 0.6819, SPD: 0.1488
  Fold 2/5
[Baseline] Accuracy: 0.6960, SPD: 0.1410
  Fold 3/5
[Baseline] Accuracy: 0.6907, SPD: 0.1611
  Fold 4/5
[Baseline] Accuracy: 0.6954, SPD: 0.1986
  Fold 5/5
[Baseline] Accuracy: 0.7042, SPD: 0.1477

== PrejudiceRemover training with eta: 0.1 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.1] Mean SPD: 0.1480

== PrejudiceRemover training with eta: 0.2 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.2] Mean SPD: 0.1611

== PrejudiceRemover training with eta: 0.3 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.3] Mean SPD: 0.1572

== PrejudiceRemover training with eta: 0.4 ==
  Fold 1/5
/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/model_selection/_split.py:811: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.4] Mean SPD: 0.1680
for i, eta in enumerate(etas[1:]):
    print(f"== Bar plot per η = {eta} ==")

    # Performance metrics
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (PrejudiceRemover η={eta})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness metrics
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (PrejudiceRemover η={eta})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per η = 0.1 ==
_images/main_235_1.png _images/main_235_2.png
== Bar plot per η = 0.2 ==
_images/main_235_4.png _images/main_235_5.png
== Bar plot per η = 0.3 ==
_images/main_235_7.png _images/main_235_8.png
== Bar plot per η = 0.4 ==
_images/main_235_10.png _images/main_235_11.png

The stratification was applied on the joint distribution of the target and sensitive feature, rather than explicitly balancing sensitive groups alone. This resulted in folds where minority sensitive groups were underrepresented or missing entirely, severely compromising both the training signal for fairness-aware algorithms and the reliability of fairness evaluation.

In contrast, when we stratified directly on the sensitive feature, each fold retained a more balanced representation of all groups, which significantly improved the performance of fairness-aware techniques for all methods

Results without using Cross-validation#

def metrics_bar_plot(dict1, dict2, label1, label2, metrics, title="Metric Comparison (mean ± std)"):

    def summarise(fold_dicts):
        means = []
        stds = []
        for m in metrics:
            values = [fold.get(m, np.nan) for fold in fold_dicts]
            mean_val = np.nanmean(values)
            std_val = np.nanstd(values)
            means.append(mean_val)
            stds.append(std_val)
        return np.array(means), np.array(stds)

    mean1, std1 = summarise(dict1)
    mean2, std2 = summarise(dict2)

    fig, axes = plt.subplots(1, 2, figsize=(10, 5), sharey=True)
    for ax, mean, std, label in zip(axes, [mean1, mean2], [std1, std2], [label1, label2]):
        ax.bar(metrics, mean, yerr=std, capsize=4)
        ax.set_title(label)
        ax.set_ylim(0, 1.2)
        ax.grid(axis="y", alpha=0.3)

    fig.suptitle(title)
    plt.tight_layout()
    plt.show()
Adversarial Debiasing#
baseline_performance_metrics = []
baseline_fairness_metrics = []

inprocessing_performance_metrics = []
inprocessing_fairness_metrics = []

X = df1.drop(columns=target)
y = df1[target]

# Split into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

scaler = StandardScaler()
numerical_cols = ['distance_km']
X_train[numerical_cols] = scaler.fit_transform(X_train[numerical_cols])
X_test[numerical_cols] = scaler.transform(X_test[numerical_cols])

print(f"Training set shape: {X_train.shape}")
print(f"Testing set shape: {X_test.shape}")
Training set shape: (5964, 9)
Testing set shape: (2556, 9)
X_train_dataframe = fl.DataFrame(X_train.copy())
X_train_dataframe.sensitive = sensitive_feature

baseline_model = AdversarialDebiasing(
    input_dim=X_train.shape[1],
    hidden_dim=8,
    output_dim=1,
    sensitive_dim=1,
    lambda_adv=0,
)

baseline_model.fit(X_train, y_train)

X_test_dataframe = fl.DataFrame(X_test.copy())
X_test_dataframe.sensitive = sensitive_feature

y_pred_tensor = baseline_model.predict(X_test)
y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
y_pred_labels = (y_pred > 0.5).astype(int)

y_proba = y_pred
perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
baseline_performance_metrics.append(perf_metrics)

# Fairness metrics
fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, X_test[sensitive_feature])
baseline_fairness_metrics.append(fair_metrics)

# SPD aggiuntivo (usando metodo fairlib)
spd = evaluate_spd(X_test.copy(), y_pred_labels)
baseline_spd_val = sum(abs(v) for v in spd.values()) / len(spd)
print(f"Baseline SPD (AdversarialDebiasing λ=0): {spd}")
Baseline SPD (AdversarialDebiasing λ=0): {(hired=0, grouped_regions=0.0): -0.06509136554843284, (hired=0, grouped_regions=1.0): -0.15620939647867432, (hired=0, grouped_regions=2.0): -0.04108483832929932, (hired=0, grouped_regions=3.0): 0.06928937247360778, (hired=0, grouped_regions=4.0): 0.10015131984531747, (hired=0, grouped_regions=5.0): 0.04179522135323241, (hired=1, grouped_regions=0.0): 0.06509136554843287, (hired=1, grouped_regions=1.0): 0.15620939647867438, (hired=1, grouped_regions=2.0): 0.04108483832929932, (hired=1, grouped_regions=3.0): -0.06928937247360778, (hired=1, grouped_regions=4.0): -0.10015131984531744, (hired=1, grouped_regions=5.0): -0.04179522135323244}
adv_weights = [0.1, 0.5, 1.0]
adv_spds = []
for lam in adv_weights:
    print(f"\n== Adversarial Debiasing con lambda_adv={lam} ==")

    X_tr = fl.DataFrame(X_train.copy())
    X_tr.sensitive = sensitive_feature

    X_te = fl.DataFrame(X_test.copy())
    X_te.sensitive = sensitive_feature

    model = AdversarialDebiasing(
        input_dim=X_train.shape[1],
        hidden_dim=8,
        output_dim=1,
        sensitive_dim=1,
        lambda_adv=lam,
    )

    model.fit(X_train, y_train)

    y_pred_tensor = model.predict(X_test)
    y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
    y_pred_labels = (y_pred > 0.5).astype(int)
    y_proba = y_pred

    perf_metrics = compute_performance_metrics(y_test, y_pred_labels, y_proba)
    inprocessing_performance_metrics.append(perf_metrics)

    fair_metrics = compute_fairness_metrics(y_test, y_pred_labels, X_test[sensitive_feature])
    inprocessing_fairness_metrics.append(fair_metrics)

    spd = evaluate_spd(X_test.copy(), y_pred_labels)
    spd_val = sum(abs(v) for v in spd.values()) / len(spd)
    adv_spds.append(spd_val)
    print(f"SPD (λ={lam}): {spd}")
== Adversarial Debiasing con lambda_adv=0.1 ==
SPD (λ=0.1): {(hired=0, grouped_regions=0.0): 0.07181801630877435, (hired=0, grouped_regions=1.0): 0.1668298653610771, (hired=0, grouped_regions=2.0): -0.00909203748526033, (hired=0, grouped_regions=3.0): -0.020444154468034992, (hired=0, grouped_regions=4.0): -0.21145547273440565, (hired=0, grouped_regions=5.0): -0.12100882542871494, (hired=1, grouped_regions=0.0): -0.07181801630877438, (hired=1, grouped_regions=1.0): -0.16682986536107713, (hired=1, grouped_regions=2.0): 0.00909203748526033, (hired=1, grouped_regions=3.0): 0.020444154468035047, (hired=1, grouped_regions=4.0): 0.21145547273440568, (hired=1, grouped_regions=5.0): 0.12100882542871494}

== Adversarial Debiasing con lambda_adv=0.5 ==
SPD (λ=0.5): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}

== Adversarial Debiasing con lambda_adv=1.0 ==
SPD (λ=1.0): {(hired=1, grouped_regions=0.0): 0.0, (hired=1, grouped_regions=1.0): 0.0, (hired=1, grouped_regions=2.0): 0.0, (hired=1, grouped_regions=3.0): 0.0, (hired=1, grouped_regions=4.0): 0.0, (hired=1, grouped_regions=5.0): 0.0}
metrics_bar_plot(
    inprocessing_performance_metrics,
    baseline_performance_metrics,
    "In-processing (Adversarial Debiasing)",
    "Baseline",
    ["accuracy", "precision", "recall", "auc"]
)

metrics_bar_plot(
    inprocessing_fairness_metrics,
    baseline_fairness_metrics,
    "In-processing (Adversarial Debiasing)",
    "Baseline",
    ["dpr", "eor"]
)
_images/main_243_0.png _images/main_243_1.png
FaUCI#
X = df1.drop(columns=target)
y = df1[target]

# Split into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

scaler = StandardScaler()
numerical_cols = ['distance_km']
X_train[numerical_cols] = scaler.fit_transform(X_train[numerical_cols])
X_test[numerical_cols] = scaler.transform(X_test[numerical_cols])

print(f"Training set shape: {X_train.shape}")
print(f"Testing set shape: {X_test.shape}")
Training set shape: (5964, 9)
Testing set shape: (2556, 9)
lambdas = [0.0, 0.1, 0.2, 0.3, 0.4]
baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in lambdas[1:]]
inprocessing_fairness_metrics = [[] for _ in lambdas[1:]]
fauci_spd_vals = []

epochs = 100
batch_size = 128
input_dim = X_train.shape[1]

for lam in lambdas:
    print(f"\n== FaUCI training with λ = {lam} ==")

    model = Simple_NN(input_dim=input_dim)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

    fauci_model = fl.Fauci(
        torchModel=model,
        optimizer=optimizer,
        loss=nn.BCELoss(),
        fairness_regularization="spd",
        regularization_weight=lam
    )

    # Prepara fairlib DataFrame
    train_data = fl.DataFrame(X_train.copy())
    train_data[target] = y_train.values
    train_data.targets = target
    train_data.sensitive = sensitive_feature

    test_data = fl.DataFrame(X_test.copy())
    test_data[target] = y_test.values
    test_data.targets = target
    test_data.sensitive = sensitive_feature

    # Training
    fauci_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

    # Predizione
    model.eval()
    with torch.no_grad():
        y_pred_tensor = fauci_model.predict(torch.tensor(X_test.values, dtype=torch.float32))
        y_pred = y_pred_tensor.cpu().numpy().flatten()
        y_pred_bin = (y_pred > 0.5).astype(int)

    # Valutazione
    perf_metrics = compute_performance_metrics(y_test, y_pred_bin, y_pred)
    fair_metrics = compute_fairness_metrics(y_test, y_pred_bin, X_test[sensitive_feature])
    spd = evaluate_spd(X_test.copy(), y_pred_bin)
    spd_val = sum(abs(v) for v in spd.values()) / len(spd)

    if lam == 0.0:
        baseline_performance_metrics.append(perf_metrics)
        baseline_fairness_metrics.append(fair_metrics)
        baseline_spd_val = spd_val
        print(f"[Baseline] Accuracy: {perf_metrics['accuracy']:.4f}, SPD: {spd_val:.4f}")
    else:
        idx = lambdas.index(lam) - 1
        inprocessing_performance_metrics[idx].append(perf_metrics)
        inprocessing_fairness_metrics[idx].append(fair_metrics)
        fauci_spd_vals.append(spd_val)
        print(f"[λ={lam}] Accuracy: {perf_metrics['accuracy']:.4f}, SPD: {spd_val:.4f}")
== FaUCI training with λ = 0.0 ==
[Baseline] Accuracy: 0.6827, SPD: 0.1008

== FaUCI training with λ = 0.1 ==
[λ=0.1] Accuracy: 0.6933, SPD: 0.0973

== FaUCI training with λ = 0.2 ==
[λ=0.2] Accuracy: 0.6608, SPD: 0.0793

== FaUCI training with λ = 0.3 ==
[λ=0.3] Accuracy: 0.6131, SPD: 0.0591

== FaUCI training with λ = 0.4 ==
[λ=0.4] Accuracy: 0.5540, SPD: 0.0458
for i, lam in enumerate(lambdas[1:]):
    print(f"\n== Bar plot per λ = {lam} (FaUCI) ==")

    metrics_bar_plot(
        inprocessing_performance_metrics[i],
        baseline_performance_metrics,
        f"In-processing (FaUCI) λ={lam}",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    metrics_bar_plot(
        inprocessing_fairness_metrics[i],
        baseline_fairness_metrics,
        f"In-processing (FaUCI) λ={lam}",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per λ = 0.1 (FaUCI) ==
_images/main_247_1.png _images/main_247_2.png
== Bar plot per λ = 0.2 (FaUCI) ==
_images/main_247_4.png _images/main_247_5.png
== Bar plot per λ = 0.3 (FaUCI) ==
_images/main_247_7.png _images/main_247_8.png
== Bar plot per λ = 0.4 (FaUCI) ==
_images/main_247_10.png _images/main_247_11.png
Prejudice Removal#
X = df1.drop(columns=target)
y = df1[target]

# Split into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

scaler = StandardScaler()
numerical_cols = ['distance_km']
X_train[numerical_cols] = scaler.fit_transform(X_train[numerical_cols])
X_test[numerical_cols] = scaler.transform(X_test[numerical_cols])

print(f"Training set shape: {X_train.shape}")
print(f"Testing set shape: {X_test.shape}")
Training set shape: (5964, 9)
Testing set shape: (2556, 9)
etas = [0.0, 0.1, 0.2, 0.3, 0.4]
baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in etas[1:]]
inprocessing_fairness_metrics = [[] for _ in etas[1:]]
pr_spd_vals = []
epochs = 100
batch_size = 128
input_dim = X_train.shape[1]

for eta in etas:
    print(f"\n== PrejudiceRemover training with eta: {eta} ==")

    model = Simple_NN(input_dim)
    pr_model = fl.PrejudiceRemover(model, loss=nn.BCELoss(), eta=eta)

    # Prepara fairlib DataFrame
    train_data = fl.DataFrame(X_train.copy())
    train_data[target] = y_train.values
    train_data.targets = target
    train_data.sensitive = sensitive_feature

    test_data = fl.DataFrame(X_test.copy())
    test_data[target] = y_test.values
    test_data.targets = target
    test_data.sensitive = sensitive_feature

    # Training
    pr_model.fit(train_data, epochs=epochs, batch_size=batch_size)

    # Valutazione
    target_col = list(test_data.targets)[0]
    sensitive_col = list(test_data.sensitive)[0]
    X_test_df = test_data.drop(columns=target_col)
    y_true = test_data[target_col].to_numpy()

    y_pred_tensor = pr_model.predict(X_test_df)
    y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
    y_pred_bin = (y_pred > 0.5).astype(int)

    # Performance
    perf_metrics = compute_performance_metrics(y_true, y_pred_bin, y_pred)

    # Fairness
    fair_metrics = compute_fairness_metrics(y_true, y_pred_bin, X_test_df[sensitive_col])

    # SPD
    spd = evaluate_spd(X_test_df.copy(), y_pred_bin)
    spd_val = sum(abs(v) for v in spd.values()) / len(spd)

    if eta == 0.0:
        baseline_performance_metrics.append(perf_metrics)
        baseline_fairness_metrics.append(fair_metrics)
        baseline_spd_val = spd_val
    else:
        idx = etas.index(eta) - 1  # Shift because baseline (eta=0) is excluded
        inprocessing_performance_metrics[idx].append(perf_metrics)
        inprocessing_fairness_metrics[idx].append(fair_metrics)
        pr_spd_vals.append(spd_val)
== PrejudiceRemover training with eta: 0.0 ==

== PrejudiceRemover training with eta: 0.1 ==

== PrejudiceRemover training with eta: 0.2 ==

== PrejudiceRemover training with eta: 0.3 ==

== PrejudiceRemover training with eta: 0.4 ==
for i, eta in enumerate(etas[1:]):
    print(f"== Bar plot per η = {eta} ==")
    metrics_bar_plot(
        inprocessing_performance_metrics[i],
        baseline_performance_metrics,
        f"PrejudiceRemover η={eta}",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    metrics_bar_plot(
        inprocessing_fairness_metrics[i],
        baseline_fairness_metrics,
        f"PrejudiceRemover η={eta}",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per η = 0.1 ==
_images/main_251_1.png _images/main_251_2.png
== Bar plot per η = 0.2 ==
_images/main_251_4.png _images/main_251_5.png
== Bar plot per η = 0.3 ==
_images/main_251_7.png _images/main_251_8.png
== Bar plot per η = 0.4 ==
_images/main_251_10.png _images/main_251_11.png

Results duplicating positive samples#

import random as rnd
pos = df1[(df1[sensitive_feature] == 4) & df1[target] == 1].copy()
for i in range(6):
    df1.loc[-1] = pos.sample().iloc[0]
    df1.index = df1.index + 1
    df1 = df1.sort_index()
FaUCI#
k = 5
reg_weights = [0.0, 0.1, 0.2, 0.3, 0.4]
epochs = 100
batch_size = 128
fauci_spd_vals = []

baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in reg_weights if _ != 0.0]
inprocessing_fairness_metrics = [[] for _ in reg_weights if _ != 0.0]


X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for i, weight in enumerate(reg_weights):
    print(f"\n== FaUCI training with regularization weight: {weight} ==")
    fold_spds = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[sensitive_feature].astype(str))):
        print(f"  Fold {fold + 1}/{k}")

        X_train_fold = X.iloc[train_index]
        X_test_fold = X.iloc[test_index]
        y_train_fold = y.iloc[train_index]
        y_test_fold = y.iloc[test_index]

        input_dim = X.shape[1]
        X_test_tensor = torch.tensor(X_test_fold.values, dtype=torch.float32)

        model = Simple_NN(input_dim)
        optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

        fauci_model = fl.Fauci(
            torchModel=model,
            optimizer=optimizer,
            loss=nn.BCELoss(),
            fairness_regularization="spd",
            regularization_weight=weight
        )

        # Prepara dati fairlib
        train_data = fl.DataFrame(X_train_fold)
        train_data[target] = y_train_fold.values
        train_data.targets = target
        train_data.sensitive = sensitive_feature

        # Fit
        fauci_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

        # Predict
        model.eval()
        with torch.no_grad():
            y_pred_tensor = fauci_model.predict(X_test_tensor)
            y_pred = y_pred_tensor.cpu().numpy().flatten()
            y_pred_labels = (y_pred > 0.5).astype(int)

        # Metrics
        perf_metrics = compute_performance_metrics(y_test_fold, y_pred_labels, y_pred)
        fair_metrics = compute_fairness_metrics(y_test_fold, y_pred_labels, X_test_fold[sensitive_feature])
        spd = evaluate_spd(X_test_fold.copy(), y_pred_labels)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)

        # Salva
        if weight == 0.0:
            baseline_performance_metrics.append(perf_metrics)
            baseline_fairness_metrics.append(fair_metrics)
            if fold == 0:
                baseline_spd_val = spd_val
        else:
            inprocessing_performance_metrics[i - 1].append(perf_metrics)
            inprocessing_fairness_metrics[i - 1].append(fair_metrics)
            fold_spds.append(spd_val)

    if weight != 0.0:
        mean_spd = np.mean(fold_spds)
        fauci_spd_vals.append(mean_spd)
        print(f"[λ={weight}] Mean SPD: {mean_spd:.4f}")
== FaUCI training with regularization weight: 0.0 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5

== FaUCI training with regularization weight: 0.1 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.1] Mean SPD: 0.0797

== FaUCI training with regularization weight: 0.2 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.2] Mean SPD: 0.0690

== FaUCI training with regularization weight: 0.3 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.3] Mean SPD: 0.0883

== FaUCI training with regularization weight: 0.4 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[λ=0.4] Mean SPD: 0.0929
for i, lam in enumerate(reg_weights[1:]):  # Salta λ=0.0 perché è il baseline
    print(f"== Bar plot per λ = {lam} ==")

    # Performance metrics
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (FaUCI λ={lam})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness metrics
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (FaUCI λ={lam})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per λ = 0.1 ==
_images/main_257_1.png _images/main_257_2.png
== Bar plot per λ = 0.2 ==
_images/main_257_4.png _images/main_257_5.png
== Bar plot per λ = 0.3 ==
_images/main_257_7.png _images/main_257_8.png
== Bar plot per λ = 0.4 ==
_images/main_257_10.png _images/main_257_11.png
Prejudice Removal#
k = 5
etas = [0.0, 0.1, 0.2, 0.3, 0.4]
epochs = 100
batch_size = 128
pr_spd_vals = []

baseline_performance_metrics = []
baseline_fairness_metrics = []
inprocessing_performance_metrics = [[] for _ in etas if _ != 0.0]
inprocessing_fairness_metrics = [[] for _ in etas if _ != 0.0]


X = df1.drop(columns=target)
y = df1[target]

scaler = StandardScaler()
numerical_cols = ['distance_km']
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])
skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

for i, eta in enumerate(etas):
    print(f"\n== PrejudiceRemover training with eta: {eta} ==")
    fold_spds = []

    for fold, (train_index, test_index) in enumerate(skf.split(X, df1[sensitive_feature].astype(str))):
        print(f"  Fold {fold + 1}/{k}")

        X_train_fold = X.iloc[train_index]
        X_test_fold = X.iloc[test_index]
        y_train_fold = y.iloc[train_index]
        y_test_fold = y.iloc[test_index]

        input_dim = X.shape[1]

        model = Simple_NN(input_dim)
        pr_model = fl.PrejudiceRemover(model, loss=nn.BCELoss(), eta=eta)

        # Prepara dati fairlib
        train_data = fl.DataFrame(X_train_fold)
        train_data[target] = y_train_fold.values
        train_data.targets = target
        train_data.sensitive = sensitive_feature

        test_data = fl.DataFrame(X_test_fold)
        test_data[target] = y_test_fold.values
        test_data.targets = target
        test_data.sensitive = sensitive_feature

        # Fit
        pr_model.fit(train_data, epochs=epochs, batch_size=batch_size, verbose=False)

        # Predict
        target_col = list(test_data.targets)[0]
        sensitive_col = list(test_data.sensitive)[0]
        X_test_df = test_data.drop(columns=target_col)
        y_true = test_data[target_col].to_numpy()

        y_pred_tensor = pr_model.predict(X_test_df)
        y_pred = y_pred_tensor.detach().cpu().numpy().flatten()
        y_pred_bin = (y_pred > 0.5).astype(int)

        # Metrics
        perf_metrics = compute_performance_metrics(y_true, y_pred_bin, y_pred)
        fair_metrics = compute_fairness_metrics(y_true, y_pred_bin, X_test_df[sensitive_col])
        spd = evaluate_spd(X_test_df.copy(), y_pred_bin)
        spd_val = sum(abs(v) for v in spd.values()) / len(spd)

        # Salva
        if eta == 0.0:
            baseline_performance_metrics.append(perf_metrics)
            baseline_fairness_metrics.append(fair_metrics)
            if fold == 0:
                baseline_spd_val = spd_val
            print(f"[Baseline] Accuracy: {perf_metrics['accuracy']:.4f}, SPD: {spd_val:.4f}")
        else:
            inprocessing_performance_metrics[i - 1].append(perf_metrics)
            inprocessing_fairness_metrics[i - 1].append(fair_metrics)
            fold_spds.append(spd_val)

    if eta != 0.0:
        mean_spd = np.mean(fold_spds)
        pr_spd_vals.append(mean_spd)
        print(f"[η={eta}] Mean SPD: {mean_spd:.4f}")
== PrejudiceRemover training with eta: 0.0 ==
  Fold 1/5
[Baseline] Accuracy: 0.6784, SPD: 0.0782
  Fold 2/5
[Baseline] Accuracy: 0.6784, SPD: 0.1147
  Fold 3/5
[Baseline] Accuracy: 0.6789, SPD: 0.0686
  Fold 4/5
[Baseline] Accuracy: 0.7088, SPD: 0.0670
  Fold 5/5
[Baseline] Accuracy: 0.7058, SPD: 0.1072

== PrejudiceRemover training with eta: 0.1 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.1] Mean SPD: 0.0901

== PrejudiceRemover training with eta: 0.2 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.2] Mean SPD: 0.0986

== PrejudiceRemover training with eta: 0.3 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.3] Mean SPD: 0.0957

== PrejudiceRemover training with eta: 0.4 ==
  Fold 1/5
  Fold 2/5
  Fold 3/5
  Fold 4/5
  Fold 5/5
[η=0.4] Mean SPD: 0.0918
for i, eta in enumerate(etas[1:]):
    print(f"== Bar plot per η = {eta} ==")

    # Performance metrics
    inproc_perf_dict = aggregate_metrics(inprocessing_performance_metrics[i])
    base_perf_dict = aggregate_metrics(baseline_performance_metrics)

    metrics_bar_plot(
        inproc_perf_dict,
        base_perf_dict,
        f"In-processing (PrejudiceRemover η={eta})",
        "Baseline",
        ["accuracy", "precision", "recall", "auc"]
    )

    # Fairness metrics
    inproc_fair_dict = aggregate_metrics(inprocessing_fairness_metrics[i])
    base_fair_dict = aggregate_metrics(baseline_fairness_metrics)

    metrics_bar_plot(
        inproc_fair_dict,
        base_fair_dict,
        f"In-processing (PrejudiceRemover η={eta})",
        "Baseline",
        ["dpr", "eor"]
    )
== Bar plot per η = 0.1 ==
_images/main_261_1.png _images/main_261_2.png
== Bar plot per η = 0.2 ==
_images/main_261_4.png _images/main_261_5.png
== Bar plot per η = 0.3 ==
_images/main_261_7.png _images/main_261_8.png
== Bar plot per η = 0.4 ==
_images/main_261_10.png _images/main_261_11.png

The result for fairness metrics show a slight improvement

Among these, Adversarial Debiasing consistently achieved the best trade-off between fairness and accuracy. It works by introducing an adversarial component that learns to predict the sensitive attribute, while the main model is trained to minimize its predictive power. This leads to internal representations that are less biased and more equitable across groups.

A key strength of Adversarial Debiasing is its ability to generalize fairness constraints, even in the presence of limited data from minority sensitive groups. Unlike other techniques that rely heavily on subgroup-specific examples, the adversarial setup enforces global invariance to the sensitive attribute across the entire representation space. As a result, it can still learn fair representations even when some subgroups are underrepresented, which explains its robust improvement in fairness metrics despite data imbalance. Because this constraint operates at the level of feature representations, and not directly on group statistics, it’s less sensitive to data imbalance. Even if there are fewer minority samples, the model still gets a signal from the adversary about whether sensitive info is leaking — and it adjusts accordingly.

In contrast, FaUCI and Prejudice Removal showed limited improvement in fairness, even when tuning regularization strengths (lambda, eta). The main bottleneck was the lack of sufficient examples from minority sensitive groups, which prevented the algorithms from learning generalizable fairness constraints. Just doubling the positive examples partially mitigated this issue, improving fairness outcomes for underrepresented subgroups.

Furthermore, we observed that fairness metrics degraded when using standard k-fold cross-validation, due to the absence of stratification over sensitive attributes. Some folds lacked representation for certain groups, making fairness evaluation unreliable.

Post-Processing#

sensitive_feature = 'grouped_regions'
target = 'hired'
postproc_df = df1.copy()
pd.crosstab(df1[sensitive_feature], df1[target], normalize='index')
hired 0 1
grouped_regions
0.0 0.358491 0.641509
1.0 0.523649 0.476351
2.0 0.370315 0.629685
3.0 0.391954 0.608046
4.0 0.939394 0.060606
5.0 0.475610 0.524390

The distribution of the discriminated group is extremely skewed towards label 0, and by observing the data we can easily check that we have only 2 positive examples for that category, therefore there is a problem in the protocol itself when we split in training and test.

south_females_hired = len(postproc_df[(postproc_df[sensitive_feature] == 4) & (postproc_df[target] == 1)])
south_females_not_hired = len(postproc_df[(postproc_df[sensitive_feature] == 4) & (postproc_df[target] == 0)])

print(f'Number of females coming from the south of Italy that was hired: {south_females_hired}')
print(f'Number of females coming from the south of Italy that was not hired: {south_females_not_hired}')
Number of females coming from the south of Italy that was hired: 2
Number of females coming from the south of Italy that was not hired: 31
minority_encoding = [4]

privileged_groups = [{sensitive_feature: v} for v in postproc_df[sensitive_feature].unique() if v not in minority_encoding]
unprivileged_groups = [{sensitive_feature: v} for v in minority_encoding]
class PostProcessingAlgorithms(Enum):
    CALIBRATED_EQ_ODDS = 1
    EQ_ODDS = 2
    REJECTED_OPTION_CLASSIFICATION = 3
class aif360_PostProcessing_Wrapper():
    def __init__(self,
                 algorithm:PostProcessingAlgorithms,
                 unprivileged_groups:list[dict],
                 privileged_groups:list[dict],
                 **kwargs):

        self.priv_groups = privileged_groups

        if algorithm == PostProcessingAlgorithms.CALIBRATED_EQ_ODDS:
            self.algorithm = CalibratedEqOddsPostprocessing(unprivileged_groups=unprivileged_groups, privileged_groups=privileged_groups, **kwargs)
        elif algorithm == PostProcessingAlgorithms.EQ_ODDS:
            self.algorithm = EqOddsPostprocessing(unprivileged_groups=unprivileged_groups, privileged_groups=privileged_groups, **kwargs)
        elif algorithm == PostProcessingAlgorithms.REJECTED_OPTION_CLASSIFICATION:
            self.algorithm = RejectOptionClassification(unprivileged_groups=unprivileged_groups, privileged_groups=privileged_groups, **kwargs)
        else:
            raise AttributeError('The specified algorithm does not exists')

    def build_dataset(self, X, y_label=None, scores=None):
        df = X.copy()
        df[target] = y_label.reshape(-1, 1)
        if scores is not None:
            df['scores'] = scores.reshape(-1, 1)

        ds = StandardDataset(df,
                            target,
                            favorable_classes=[1],
                            protected_attribute_names=[sensitive_feature],
                            privileged_classes=[list(d.values()) for d in self.priv_groups],
                            scores_name='scores' if scores is not None else None
                        )

        return ds

    def fit(self, X, y_scores):
        y_true = X[target].values
        y_pred = (y_scores > 0.5).astype(int)

        ds_true = self.build_dataset(X, y_label=y_true)
        ds_pred = self.build_dataset(X, y_label=y_pred, scores=y_scores)

        self.algorithm.fit(dataset_true=ds_true, dataset_pred=ds_pred)
        return self

    def predict(self, X, y_scores):
        y_pred = (y_scores > 0.5).astype(int)
        ds_pred = self.build_dataset(X, y_label=y_pred, scores=y_scores)

        mitigated = self.algorithm.predict(ds_pred)
        return mitigated.labels.ravel()

    def fit_predict(self, X, y_scores):
        return self.fit(X, y_scores).predict(X, y_scores)
calib_eq_odds = aif360_PostProcessing_Wrapper(PostProcessingAlgorithms.CALIBRATED_EQ_ODDS,
                                              unprivileged_groups=unprivileged_groups,
                                              privileged_groups=privileged_groups
                                            )

eq_odds = aif360_PostProcessing_Wrapper(PostProcessingAlgorithms.EQ_ODDS,
                                              unprivileged_groups=unprivileged_groups,
                                              privileged_groups=privileged_groups
                                            )
postproc_baseline_fairness_metrics = []
eq_odds_fairness_metrics = []
calib_eq_odds_fairness_metrics = []

postproc_baseline_performance_metrics = []
eq_odds_performance_metrics = []
calib_eq_odds_performance_metrics = []

spds = []

X = fl.DataFrame(postproc_df.drop(target, axis=1).copy())
y = postproc_df[target].copy()

X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.5, random_state=random_seed, shuffle=True)

base_clf = LogisticRegression(max_iter=1000, solver='liblinear')
base_clf.fit(X_train, y_train)
y_scores_train = base_clf.predict_proba(X_train)[:,1]

y_pred_test = base_clf.predict(X_test)
y_scores_test = base_clf.predict_proba(X_test)[:,1]
spd = evaluate_spd(X_test.copy(), y_pred_test)
spds.append(sum(abs(v) for v in spd.values()) / len(spd))

df_train = X_train.copy()
df_train[target] = y_train
df_test = X_test.copy()
df_test[target] = y_test

postproc_baseline_fairness_metrics.append(compute_fairness_metrics(y_test, y_pred_test, df_test[sensitive_feature]))
postproc_baseline_performance_metrics.append(compute_performance_metrics(y_test, y_pred_test, y_scores_test))

calib_eq_odds = calib_eq_odds.fit(df_train, y_scores_train)
transformed_pred = calib_eq_odds.predict(df_test, y_scores_test)
spd = evaluate_spd(X_test.copy(), transformed_pred)
spds.append(sum(abs(v) for v in spd.values()) / len(spd))

eq_odds = eq_odds.fit(df_train, y_scores_train)
transformed_eq_pred = eq_odds.predict(df_test, y_scores_test)
spd = evaluate_spd(X_test.copy(), transformed_eq_pred)
spds.append(sum(abs(v) for v in spd.values()) / len(spd))

calib_eq_odds_fairness_metrics.append(compute_fairness_metrics(y_test, transformed_pred, df_test[sensitive_feature]))
eq_odds_fairness_metrics.append(compute_fairness_metrics(y_test, transformed_eq_pred, df_test[sensitive_feature]))

calib_eq_odds_performance_metrics.append(compute_performance_metrics(y_test, transformed_pred, np.ones(len(transformed_pred))))
eq_odds_performance_metrics.append(compute_performance_metrics(y_test, transformed_eq_pred, np.ones(len(transformed_pred))))
metrics_bar_plot(postproc_baseline_fairness_metrics,
                 calib_eq_odds_fairness_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['dpr','eor'],
                 title="Baseline vs Calibrated Equalized Odds")

metrics_bar_plot(postproc_baseline_fairness_metrics,
                 eq_odds_fairness_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['dpr','eor'],
                 title="Baseline vs Equalized Odds")
_images/main_274_0.png _images/main_274_1.png
metrics_bar_plot(postproc_baseline_performance_metrics,
                 calib_eq_odds_performance_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['accuracy','precision', 'recall', 'auc'],
                 title="Baseline vs Calibrated Equalized Odds Performances")

metrics_bar_plot(postproc_baseline_performance_metrics,
                 eq_odds_performance_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['accuracy','precision', 'recall', 'auc'],
                 title="Baseline vs Equalized Odds Performances")
_images/main_275_0.png _images/main_275_1.png
model_names = ['Baseline', 'Equalized Odds', 'Calibrated Equalized Odds']

results = pd.DataFrame({
    'Model': model_names,
    'SPD': spds
})

plt.figure(figsize=(8, 5))
plt.bar(results['Model'], results['SPD'], color='cornflowerblue')
plt.title('Statistical Parity Difference (SPD)')
plt.ylabel('|SPD|')
plt.xticks(rotation=30)
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()
_images/main_276_0.png

Because the protected slice has only two positive cases, hard-threshold Equalized Odds finds no feasible adjustment and leaves the metrics unchanged, whereas the more flexible Calibrated Equalized Odds can re-label one borderline instance.

To obtain reliable fairness metrics, we stratify the K-fold split on the joint distribution of the target label and the sensitive attribute, ensuring each fold contains a proportional share of every (target, sensitive-group) combination.

(postproc_df[target].astype(str)+postproc_df[sensitive_feature].astype(str)).value_counts()
13.0    2645
12.0    2100
03.0    1705
02.0    1235
10.0     272
01.0     155
00.0     152
11.0     141
15.0      43
05.0      39
04.0      31
14.0       2
Name: count, dtype: int64
kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
postproc_baseline_fairness_metrics = []
eq_odds_fairness_metrics = []
calib_eq_odds_fairness_metrics = []

postproc_baseline_performance_metrics = []
eq_odds_performance_metrics = []
calib_eq_odds_performance_metrics = []


for fold, (train_idx, test_idx) in enumerate(kf.split(postproc_df, postproc_df[target].astype(str)+postproc_df[sensitive_feature].astype(str))):
    train_df = postproc_df.iloc[train_idx].copy()
    test_df = postproc_df.iloc[test_idx].copy()


    clf = LogisticRegression(max_iter=1000, solver='liblinear')
    clf.fit(train_df.drop(target, axis=1), train_df[target])

    train_scores = clf.predict_proba(train_df.drop(target, axis=1))[:, 1]
    test_scores = clf.predict_proba(test_df.drop(target, axis=1))[:, 1]

    y_test_pred = (test_scores > 0.5).astype(int)
    postproc_baseline_fairness_metrics.append(
        compute_fairness_metrics(test_df[target], y_test_pred, test_df[sensitive_feature]))
    postproc_baseline_performance_metrics.append(compute_performance_metrics(test_df[target], y_test_pred, test_scores))

    calib_eq_odds = aif360_PostProcessing_Wrapper(PostProcessingAlgorithms.CALIBRATED_EQ_ODDS,
                                                unprivileged_groups=unprivileged_groups,
                                                privileged_groups=privileged_groups
                                                )

    eq_odds = aif360_PostProcessing_Wrapper(PostProcessingAlgorithms.EQ_ODDS,
                                                unprivileged_groups=unprivileged_groups,
                                                privileged_groups=privileged_groups
                                                )


    calib_eq_odds.fit(train_df, train_scores)
    eq_odds.fit(train_df, train_scores)

    calib_eq_odds_trans_pred = calib_eq_odds.predict(test_df, test_scores)
    eq_odds_trans_pred = eq_odds.predict(test_df, test_scores)

    calib_eq_odds_fairness_metrics.append( compute_fairness_metrics(test_df[target], calib_eq_odds_trans_pred, test_df[sensitive_feature]))
    eq_odds_fairness_metrics.append( compute_fairness_metrics(test_df[target], eq_odds_trans_pred, test_df[sensitive_feature]))

    calib_eq_odds_performance_metrics.append(compute_performance_metrics(test_df[target], calib_eq_odds_trans_pred, np.ones(len(calib_eq_odds_trans_pred))))
    eq_odds_performance_metrics.append(compute_performance_metrics(test_df[target], eq_odds_trans_pred, np.ones(len(eq_odds_trans_pred))))
/Users/kor/anaconda3/lib/python3.10/site-packages/sklearn/model_selection/_split.py:805: UserWarning: The least populated class in y has only 2 members, which is less than n_splits=5.
  warnings.warn(
metrics_bar_plot(postproc_baseline_fairness_metrics,
                 calib_eq_odds_fairness_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['dpr','eor'],
                 title="Baseline vs Calibrated Equalized Odds")

metrics_bar_plot(postproc_baseline_fairness_metrics,
                 eq_odds_fairness_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['dpr','eor'],
                 title="Baseline vs Equalized Odds")
_images/main_281_0.png _images/main_281_1.png
metrics_bar_plot(postproc_baseline_performance_metrics,
                 calib_eq_odds_performance_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['accuracy','precision', 'recall', 'auc'],
                 title="Baseline vs Calibrated Equalized Odds Performances")

metrics_bar_plot(postproc_baseline_performance_metrics,
                 eq_odds_performance_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['accuracy','precision', 'recall', 'auc'],
                 title="Baseline vs Equalized Odds Performances")
_images/main_282_0.png _images/main_282_1.png

For the same reason of before, the eq_dots produces the same results as the baseline since it leaves the predictions as they are, while the calibrated_eq_dots improves the solution. Then, if we look at the variance in the boxplot, we can notice that the mean+std of the baseline metrics and the calibrated_eq_odds are the same as before (without cross validation). This is probably because the techniques are highly dependent on the sensitive feature distribution, that in our case is extremely skewed and contains only two positive examples. We can test this assumption later by duplicating (i.e. oversampling) the positive labels and observing the behaviour of the post processing

In particular, because EOR compares true positive rates across groups, having only 2 positive examples in the protected group means that even one misclassification causes a large drop in $ P(:raw-latex:`hat{Y}`=1 | Y=1, S=s) $ (i.e. The probability that the model predicts a positive outcome, given that the true label is positive and the individual belongs to sensitive group s), making the ratio unstable and difficult to improve, while the skewed label distribution limits the ability of the model to predict positives, keeping both EOR and DPR low regardless of post-processing.

If we oversample the positive examples with respect to the sensitive feature:

pos = postproc_df[(postproc_df[sensitive_feature] == 4) & (postproc_df[target] == 1)].copy()
neg = postproc_df[(postproc_df[sensitive_feature] == 4) & (postproc_df[target] == 0)].copy()

pos_oversamples = 6
neg_oversamples = 0

for _ in range(pos_oversamples):
    postproc_df.loc[-1] = pos.sample().iloc[0]
    postproc_df.index = postproc_df.index + 1
    postproc_df = postproc_df.sort_index()

    for _ in range(neg_oversamples):
        postproc_df.loc[-1] = neg.sample().iloc[0]
        postproc_df.index = postproc_df.index + 1
        postproc_df = postproc_df.sort_index()
(postproc_df[target].astype(str)+postproc_df[sensitive_feature].astype(str)).value_counts()
1.03.0    2645
1.02.0    2100
0.03.0    1705
0.02.0    1235
1.00.0     272
0.01.0     155
0.00.0     152
1.01.0     141
1.05.0      43
0.05.0      39
0.04.0      31
1.04.0       8
Name: count, dtype: int64
kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
postproc_baseline_fairness_metrics = []
eq_odds_fairness_metrics = []
calib_eq_odds_fairness_metrics = []

postproc_baseline_performance_metrics = []
eq_odds_performance_metrics = []
calib_eq_odds_performance_metrics = []

for fold, (train_idx, test_idx) in enumerate(kf.split(postproc_df, postproc_df[target].astype(str)+postproc_df[sensitive_feature].astype(str))):
    train_df = postproc_df.iloc[train_idx].copy()
    test_df = postproc_df.iloc[test_idx].copy()

    clf = LogisticRegression(max_iter=1000, solver='liblinear')
    clf.fit(train_df.drop(target, axis=1), train_df[target])

    train_scores = clf.predict_proba(train_df.drop(target, axis=1))[:, 1]
    test_scores = clf.predict_proba(test_df.drop(target, axis=1))[:, 1]

    y_test_pred = (test_scores > 0.5).astype(int)
    postproc_baseline_fairness_metrics.append(
        compute_fairness_metrics(test_df[target], y_test_pred, test_df[sensitive_feature]))
    postproc_baseline_performance_metrics.append(compute_performance_metrics(test_df[target], y_test_pred, test_scores))

    calib_eq_odds = aif360_PostProcessing_Wrapper(PostProcessingAlgorithms.CALIBRATED_EQ_ODDS,
                                                unprivileged_groups=unprivileged_groups,
                                                privileged_groups=privileged_groups
                                                )

    eq_odds = aif360_PostProcessing_Wrapper(PostProcessingAlgorithms.EQ_ODDS,
                                                unprivileged_groups=unprivileged_groups,
                                                privileged_groups=privileged_groups
                                                )


    calib_eq_odds.fit(train_df, train_scores)
    eq_odds.fit(train_df, train_scores)

    calib_eq_odds_trans_pred = calib_eq_odds.predict(test_df, test_scores)
    eq_odds_trans_pred = eq_odds.predict(test_df, test_scores)

    calib_eq_odds_fairness_metrics.append( compute_fairness_metrics(test_df[target], calib_eq_odds_trans_pred, test_df[sensitive_feature]))
    eq_odds_fairness_metrics.append( compute_fairness_metrics(test_df[target], eq_odds_trans_pred, test_df[sensitive_feature]))

    calib_eq_odds_performance_metrics.append(compute_performance_metrics(test_df[target], calib_eq_odds_trans_pred, np.ones(len(calib_eq_odds_trans_pred))))
    eq_odds_performance_metrics.append(compute_performance_metrics(test_df[target], eq_odds_trans_pred, np.ones(len(eq_odds_trans_pred))))
metrics_bar_plot(postproc_baseline_fairness_metrics,
                 calib_eq_odds_fairness_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['dpr','eor'],
                 title="Baseline vs Calibrated Equalized Odds")

metrics_bar_plot(postproc_baseline_fairness_metrics,
                 eq_odds_fairness_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['dpr','eor'],
                 title="Baseline vs Equalized Odds")
_images/main_289_0.png _images/main_289_1.png
metrics_bar_plot(postproc_baseline_performance_metrics,
                 calib_eq_odds_performance_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['accuracy','precision', 'recall', 'auc'],
                 title="Baseline vs Calibrated Equalized Odds Performances")

metrics_bar_plot(postproc_baseline_performance_metrics,
                 eq_odds_performance_metrics,
                 label1='w/o post-processing',
                 label2='with post-processing',
                 metrics=['accuracy','precision', 'recall', 'auc'],
                 title="Baseline vs Equalized Odds Performances")
_images/main_290_0.png _images/main_290_1.png

We can observe that the Calibrated Equalized Odds further improves the metrics, but this can just give us an intuition since it is not a real data augmentation. A possible improvement would be to use a powerful data augmentation technique such as SMOTHE in order to produce results according to the real distribution.