Fairness-Aware Classification with FairClassifier
Machine learning models deployed in high-stakes domains (lending, hiring, criminal justice) can inadvertently discriminate against protected groups.
Perpetual’s FairClassifier adds an in-processing fairness penalty to the log-loss gradient, directly reducing disparity during training rather than as a post-hoc correction.
Two criteria are supported:
Demographic Parity: \(P(\hat{Y}=1|S=0) \approx P(\hat{Y}=1|S=1)\)
Equalized Odds: equal true-positive and false-positive rates across groups.
In this tutorial we use the Adult Census Income dataset to build a fair income predictor that mitigates gender bias.
[ ]:
import pandas as pd
from perpetual import PerpetualBooster
from perpetual.fairness import FairClassifier
from sklearn.datasets import fetch_openml
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.model_selection import train_test_split
1. Load the Adult Census Dataset
[ ]:
print("Fetching Adult Census Income dataset...")
data = fetch_openml(data_id=1590, as_frame=True, parser="auto")
df = data.frame
print(f"Shape: {df.shape}")
df.head()
2. Prepare Features
We encode sex as the sensitive attribute and prepare the feature matrix.
[ ]:
# Target: income >50K
y = (df["class"].astype(str).str.strip().str.startswith(">50K")).astype(float).values
# Encode categoricals
cat_cols = df.select_dtypes(include=["category", "object"]).columns.tolist()
cat_cols = [c for c in cat_cols if c != "class"]
df_encoded = pd.get_dummies(
df.drop(columns=["class"]), columns=cat_cols, drop_first=True, dtype=float
)
# Identify the sensitive feature column index (sex_Male)
sex_col = [
c
for c in df_encoded.columns
if "sex" in c.lower() and ("male" in c.lower() or "Male" in c)
]
if not sex_col:
# Fall back: create it
df_encoded["sex_Male"] = df["sex"].map({"Male": 1, "Female": 0}).values
sex_col = ["sex_Male"]
sensitive_col_name = sex_col[0]
feature_names = list(df_encoded.columns)
sensitive_idx = feature_names.index(sensitive_col_name)
X = df_encoded.values.astype(float)
print(f"X shape: {X.shape}")
print(f"Sensitive feature: '{sensitive_col_name}' at index {sensitive_idx}")
print(f"Positive class rate: {y.mean():.2%}")
[ ]:
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
print(f"Train: {X_train.shape[0]}, Test: {X_test.shape[0]}")
3. Baseline: Unconstrained Model
First, train a standard PerpetualBooster without fairness constraints to measure the baseline disparity.
[ ]:
baseline = PerpetualBooster(objective="LogLoss", budget=0.5)
baseline.fit(X_train, y_train)
bl_probs = baseline.predict_proba(X_test)[:, 1]
bl_preds = (bl_probs >= 0.5).astype(int)
# Fairness metrics
s_test = X_test[:, sensitive_idx]
bl_rate_s1 = bl_preds[s_test == 1].mean()
bl_rate_s0 = bl_preds[s_test == 0].mean()
print("=== Baseline (unconstrained) ===")
print(f"Accuracy: {accuracy_score(y_test, bl_preds):.4f}")
print(f"AUC: {roc_auc_score(y_test, bl_probs):.4f}")
print(f"Positive rate (Male): {bl_rate_s1:.4f}")
print(f"Positive rate (Female): {bl_rate_s0:.4f}")
print(f"Demographic Parity gap: {abs(bl_rate_s1 - bl_rate_s0):.4f}")
4. Fair Model: Demographic Parity
Now train a FairClassifier that penalizes the correlation between predictions and the sensitive attribute.
[ ]:
fair_dp = FairClassifier(
sensitive_feature=sensitive_idx,
fairness_type="demographic_parity",
lam=5.0,
budget=0.5,
)
fair_dp.fit(X_train, y_train)
dp_probs = fair_dp.predict_proba(X_test)[:, 1]
dp_preds = fair_dp.predict(X_test)
dp_rate_s1 = dp_preds[s_test == 1].mean()
dp_rate_s0 = dp_preds[s_test == 0].mean()
print("=== FairClassifier (Demographic Parity, λ=5.0) ===")
print(f"Accuracy: {accuracy_score(y_test, dp_preds):.4f}")
print(f"AUC: {roc_auc_score(y_test, dp_probs):.4f}")
print(f"Positive rate (Male): {dp_rate_s1:.4f}")
print(f"Positive rate (Female): {dp_rate_s0:.4f}")
print(f"Demographic Parity gap: {abs(dp_rate_s1 - dp_rate_s0):.4f}")
5. Fair Model: Equalized Odds
Equalized Odds penalizes disparity within each class, targeting equal error rates across groups.
[ ]:
fair_eo = FairClassifier(
sensitive_feature=sensitive_idx,
fairness_type="equalized_odds",
lam=5.0,
budget=0.5,
)
fair_eo.fit(X_train, y_train)
eo_probs = fair_eo.predict_proba(X_test)[:, 1]
eo_preds = fair_eo.predict(X_test)
# Equalized Odds: check TPR and FPR per group
for label_name, s_val in [("Male", 1), ("Female", 0)]:
mask = s_test == s_val
tpr = eo_preds[(mask) & (y_test == 1)].mean()
fpr = eo_preds[(mask) & (y_test == 0)].mean()
print(f"{label_name:8s} TPR={tpr:.4f} FPR={fpr:.4f}")
print(f"\nAccuracy: {accuracy_score(y_test, eo_preds):.4f}")
print(f"AUC: {roc_auc_score(y_test, eo_probs):.4f}")
6. Compare All Models
Fairness comes with a trade-off: we reduce disparity at some cost to predictive accuracy.
[ ]:
print(f"{'Model':<35} {'Accuracy':>10} {'AUC':>10} {'DP Gap':>10}")
print("-" * 70)
for name, preds, probs in [
("Baseline (unconstrained)", bl_preds, bl_probs),
("FairClassifier (Dem. Parity)", dp_preds, dp_probs),
("FairClassifier (Eq. Odds)", eo_preds, eo_probs),
]:
acc = accuracy_score(y_test, preds)
auc = roc_auc_score(y_test, probs)
gap = abs(preds[s_test == 1].mean() - preds[s_test == 0].mean())
print(f"{name:<35} {acc:>10.4f} {auc:>10.4f} {gap:>10.4f}")
7. Tuning the Fairness Penalty
The lam parameter controls the strength of the fairness penalty. Higher values produce fairer but potentially less accurate models.
[ ]:
print(f"{'λ':>6} {'Accuracy':>10} {'AUC':>10} {'DP Gap':>10}")
print("-" * 40)
for lam in [0.0, 1.0, 5.0, 10.0, 25.0]:
clf = FairClassifier(
sensitive_feature=sensitive_idx,
fairness_type="demographic_parity",
lam=lam,
budget=0.5,
)
clf.fit(X_train, y_train)
probs = clf.predict_proba(X_test)[:, 1]
preds = clf.predict(X_test)
acc = accuracy_score(y_test, preds)
auc = roc_auc_score(y_test, probs)
gap = abs(preds[s_test == 1].mean() - preds[s_test == 0].mean())
print(f"{lam:>6.1f} {acc:>10.4f} {auc:>10.4f} {gap:>10.4f}")
Summary
In this tutorial we:
Trained a baseline model and measured gender disparity.
Used
FairClassifierwith Demographic Parity to reduce the positive-prediction gap between groups.Used
FairClassifierwith Equalized Odds to equalize TPR/FPR.Explored the accuracy–fairness trade-off by varying
lam.
The FairClassifier provides a simple, integrated approach to building fairer models without requiring separate pre- or post-processing steps.