Fairness Metrics: Demographic Parity, Equalized Odds, and Trade-offs › Code Implementation
Language AI HandbookAll chapters 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.
import subprocess
subprocess.run(
[
"uv",
"pip",
"install",
"scikit-learn",
"pandas",
"numpy",
"matplotlib",
"scipy",
],
capture_output=True,
)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.
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
)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.
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 resultsfrom 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)=== 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.


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.


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.
# 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"])
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.

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.