Fairness Metrics: Demographic Parity, Equalized Odds, and Trade-offs › Code Implementation

Michael BrenndoerferMarch 16, 202613 min read
Language AI HandbookAll chapters
Part 58 of 67Bias and Fairness
Chapter 444Fairness Metrics: Demographic Parity, Equalized Odds, and Trade-offs › Code Implementation
Reading section 4 of 13 · Code Implementation · 13 minSection 4 of 13 · 13 min

Book contents

Section 4 of 13 · 13 min read

Fairness Metrics: Demographic Parity, Equalized Odds, and Trade-offs › Code ImplementationOne section · Sections in this chapter4 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement these metrics in code. We will use scikit-learn for generating classifier outputs and compute fairness metrics directly, then visualize how they interact.

First, let's install the required libraries and generate a synthetic dataset.

In[3]:
Code
import subprocess

subprocess.run(
    [
        "uv",
        "pip",
        "install",
        "scikit-learn",
        "pandas",
        "numpy",
        "matplotlib",
        "scipy",
    ],
    capture_output=True,
)
In[4]:
Code
import numpy as np

np.random.seed(42)

We will simulate a hiring dataset where a sensitive attribute (group membership) correlates with both features and outcomes due to historical bias in the training data. The key design choice here is that the true ability of both groups follows the same underlying distribution, but the measurements recorded in the training data are systematically biased against group 1. This simulates what happens when historical hiring patterns or gatekeeping mechanisms leave a paper trail that models then learn from.

In[5]:
Code
from sklearn.model_selection import train_test_split


def generate_hiring_data(n=2000, seed=42):
    rng = np.random.default_rng(seed)

    # Sensitive attribute: 0 = group A, 1 = group B
    group = rng.integers(0, 2, size=n)

    # Skills score: both groups have similar true ability distribution
    # but historical data under-represents group 1's qualifications
    base_skill = rng.normal(0, 1, size=n)

    # Group 1 applicants face a higher threshold for "on-paper" qualifications
    # due to structural barriers (simulates a historical bias)
    reported_skill = base_skill - 0.5 * group + rng.normal(0, 0.3, size=n)
    experience = rng.normal(3, 1, size=n) + 0.3 * base_skill

    # True outcome: based on actual ability, not reported metrics
    hire_prob = 1 / (1 + np.exp(-(base_skill + 0.3 * experience - 0.5)))
    y_true = (rng.uniform(size=n) < hire_prob).astype(int)

    # Features available to the model (biased measurements)
    X = np.column_stack([reported_skill, experience])

    return X, y_true, group


X, y_true, group = generate_hiring_data(n=2000)
X_train, X_test, y_train, y_test, group_train, group_test = train_test_split(
    X, y_true, group, test_size=0.4, random_state=42, stratify=group
)
Out[6]:
Console
Training set: 1200 samples
Test set: 800 samples
Base rate (group 0): 0.541
Base rate (group 1): 0.581

The base rates are similar between groups, because outcomes depend on true ability. But the model only sees the biased reported skill scores. When the model learns from these biased inputs, it will systematically underestimate group 1's qualifications, even though the underlying ability distribution is the same.

Now let's build the core fairness metric computation function and train a classifier.

In[7]:
Code
def compute_group_fairness(y_true, y_pred, group, pos_label=1):
    """Compute key group fairness metrics for binary groups (0 and 1)."""
    results = {}

    for g in [0, 1]:
        mask = group == g
        yt = y_true[mask]
        yp = y_pred[mask]

        pos_pred_rate = yp.mean()

        tp = ((yt == 1) & (yp == 1)).sum()
        fp = ((yt == 0) & (yp == 1)).sum()
        fn = ((yt == 1) & (yp == 0)).sum()
        tn = ((yt == 0) & (yp == 0)).sum()

        tpr = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0
        precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
        npv = tn / (tn + fn) if (tn + fn) > 0 else 0.0

        results[f"group_{g}"] = {
            "n": len(yt),
            "positive_pred_rate": pos_pred_rate,
            "tpr": tpr,
            "fpr": fpr,
            "precision": precision,
            "npv": npv,
        }

    g0 = results["group_0"]
    g1 = results["group_1"]

    results["demographic_parity_diff"] = (
        g1["positive_pred_rate"] - g0["positive_pred_rate"]
    )
    results["demographic_parity_ratio"] = (
        g1["positive_pred_rate"] / g0["positive_pred_rate"]
        if g0["positive_pred_rate"] > 0
        else float("inf")
    )
    results["equal_opp_diff"] = g1["tpr"] - g0["tpr"]
    results["equalized_odds_diff"] = max(
        abs(g1["tpr"] - g0["tpr"]), abs(g1["fpr"] - g0["fpr"])
    )
    results["predictive_parity_diff"] = g1["precision"] - g0["precision"]
    results["npv_diff"] = g1["npv"] - g0["npv"]

    return results
In[8]:
Code
from sklearn.linear_model import LogisticRegression

# Train a standard logistic regression model
clf = LogisticRegression(random_state=42)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
y_prob = clf.predict_proba(X_test)[:, 1]

metrics = compute_group_fairness(y_test, y_pred, group_test)
Out[9]:
Console
=== Per-Group Metrics ===
Metric                          Group 0    Group 1
--------------------------------------------------
N                                   399        401
Positive pred rate                0.679      0.569
True positive rate (TPR)          0.833      0.712
False positive rate (FPR)         0.497      0.369
Precision                         0.664      0.728
Neg. predictive value             0.719      0.613

=== Fairness Metrics ===
Demographic parity difference:  -0.111
Demographic parity ratio:       0.837
Equal opportunity difference:   -0.121
Equalized odds difference:      +0.128
Predictive parity difference:   +0.064
NPV difference:                 -0.106

The results show that the model satisfies predictive parity reasonably well but violates demographic parity and equalized odds. Group 1 is selected less often, and its true positive rate is lower, so the model misses more of the qualified group 1 candidates. This is a direct consequence of training on biased reported skill scores: the model has learned to undervalue group 1's qualifications.

Before examining the confusion matrices, it is useful to look at the predicted score distributions for both groups. Scores near 1.0 lead to positive predictions; scores near 0.0 lead to negative predictions. If the distributions are shifted relative to each other, that shift directly explains where demographic parity and equalized odds violations come from.

Out[10]:
Visualization
Density plot of predicted scores for Group 0, separated by true label with threshold line.
Predicted probability distributions for Group 0, separated by true label. The blue region covers truly positive individuals (Y=1) and the orange region covers truly negative individuals (Y=0). The overlap between the two distributions near the threshold (dashed line) determines the trade-off between TPR and FPR. Group 0 shows strong separation, with positive-class scores concentrated above 0.5.
Density plot of predicted scores for Group 1 with positive class shifted lower than Group 0.
Predicted probability distributions for Group 1, separated by true label. The positive-class distribution (blue) is shifted substantially to the left compared to Group 0, meaning truly positive Group 1 individuals receive lower scores. At the same threshold, more of Group 1's positive-class members fall below the cutoff, producing a lower TPR and explaining the equal opportunity violation.

The score distributions make the disparity intuitive. Group 1's positive-class scores (blue curve) are shifted toward lower probability values compared to Group 0's. When you apply the same threshold to both groups, Group 1's true positives fall below the threshold more often, producing a lower TPR. This is the root cause of the equal opportunity violation: the model is systematically less confident about Group 1's positive-class members, not because those individuals are less qualified in reality, but because the biased reported skill scores do not accurately reflect their true ability.

Let's visualize where these fairness metrics land in the confusion matrix.

Out[11]:
Visualization
Heatmap confusion matrix for Group 0 with row-normalized values showing 80% TPR.
Row-normalized confusion matrix for Group 0. Each row sums to 1.0, so cells show rates rather than counts. The top-right cell shows the true positive rate (TPR=0.80), indicating the model correctly identifies 80% of Group 0's positive-class individuals. The bottom-left cell shows the false positive rate (FPR=0.40).
Heatmap confusion matrix for Group 1 showing visibly lower true positive rate than Group 0.
Row-normalized confusion matrix for Group 1. The top-right cell (TPR) is visibly lighter than in Group 0, showing the lower recall for Group 1. Group 1's positive-class members are correctly identified at a substantially lower rate, while the false positive rate is also lower, producing the asymmetric error pattern visible in the metrics.

The side-by-side comparison makes the disparity visible: Group 0's top-right cell (true positive predictions) is darker than Group 1's, indicating the model is substantially better at identifying positive cases for Group 0. Meanwhile, Group 0 also has a darker bottom-left cell, showing higher false positives. The pattern is coherent with the score distribution insight: Group 0's score distributions are more spread, with the positive class pushed higher and the negative class creating more overlap near the threshold from below.

Threshold Sensitivity

One important aspect of group fairness metrics is that they depend on the decision threshold you use to convert predicted probabilities into binary predictions. Different thresholds produce different fairness profiles. A threshold that minimizes demographic parity difference may not minimize equalized odds difference, and the threshold that achieves the best overall accuracy may violate all fairness criteria.

This threshold sensitivity has practical consequences. In practice, choosing the operating threshold is itself a policy decision with fairness implications. A lower threshold admits more candidates overall but may shift the balance of fairness violations. A higher threshold may reduce false positives at the cost of excluding more true positives. Understanding how fairness metrics vary with threshold helps you make that decision explicitly and deliberately rather than accepting the default threshold of 0.5.

In[12]:
Code
# Compute fairness metrics at different thresholds
thresholds = np.linspace(0.2, 0.8, 50)

dp_diffs = []
eo_diffs = []
eodds_diffs = []

for thresh in thresholds:
    y_pred_t = (y_prob >= thresh).astype(int)
    m = compute_group_fairness(y_test, y_pred_t, group_test)
    dp_diffs.append(m["demographic_parity_diff"])
    eo_diffs.append(m["equal_opp_diff"])
    eodds_diffs.append(m["equalized_odds_diff"])
Out[13]:
Visualization
Line plot showing demographic parity, equal opportunity, and equalized odds differences across decision thresholds from 0.2 to 0.8.
Fairness metric differences as a function of decision threshold, ranging from 0.2 to 0.8. Each metric responds differently to threshold changes, revealing that the threshold is itself a fairness decision. Demographic parity difference (solid) shifts from negative to near-zero as the threshold rises. Equal opportunity difference (dashed) shows a non-monotone relationship, with a region where it nearly crosses zero. Equalized odds difference (dotted) tracks the maximum of the two component disparities. No threshold simultaneously drives all three metrics to zero.

This visualization reveals that no single threshold satisfies all fairness criteria simultaneously, and the threshold that minimizes one disparity often increases another. The decision about which threshold to use is inseparable from the decision about which fairness criterion to prioritize. In practice, some practitioners choose group-specific thresholds: applying a lower threshold for group 1 to compensate for the systematically lower scores. This is a form of post-processing fairness intervention, which we explored in the Bias Mitigation chapter, and it can achieve equalized odds at the cost of using different decision rules for different groups.

A useful way to summarize all fairness metrics at a glance is to plot them together in a bar chart, comparing absolute difference values. This shows at a glance which criteria are satisfied and which have the largest violations.

Out[14]:
Visualization
Horizontal bar chart of five fairness metric absolute differences with a threshold line at 0.05, showing large violations for demographic parity and equalized odds.
Summary of group fairness metrics for the trained logistic regression model at a 0.5 threshold. Each bar shows the absolute difference between Group 0 and Group 1 for a given fairness criterion. Bars colored red exceed the 0.05 disparity threshold (dashed line), indicating a meaningful fairness violation. Demographic parity and equalized odds show the largest disparities, both exceeding 0.20, while predictive parity falls within acceptable bounds. This pattern is consistent with the Chouldechova impossibility result: the model satisfies predictive parity precisely because it violates equalized odds.

The bar chart confirms the pattern: demographic parity and equalized odds show the largest disparities, while predictive parity is roughly satisfied. Red bars exceed the 0.05 tolerance threshold. This is the signature of a model trained on biased data where the base rates as measured by the biased features differ more than the true underlying base rates, producing exactly the pattern the impossibility theorem predicts.