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:

  1. Trained a baseline model and measured gender disparity.

  2. Used FairClassifier with Demographic Parity to reduce the positive-prediction gap between groups.

  3. Used FairClassifier with Equalized Odds to equalize TPR/FPR.

  4. 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.