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