This recipe runs a complete fairness audit on a public dataset with seeded code: train a plain classifier, disaggregate its behavior by sex with MetricFrame, decide which gap matters, close it with ThresholdOptimizer, and produce the before-and-after table that a reviewer can actually interrogate [1]. The dataset is the adult census income set, fetched through Fairlearn's fetch_adult (an OpenML download, cached after the first call); the task is predicting income above 50k, a stand-in for any assistive yes-or-no about a person. It is the standard teaching set for a reason: the disparity is real, large, and visible at every step.
The hour breaks down as: fifteen minutes to run the two scripts and match your numbers against this page, fifteen to read the disaggregated table properly, thirty to swap in your own model and sensitive features.
What you build
audit.py trains a gradient-boosting classifier, then prints the per-group audit table (selection rate, TPR, FPR, precision, N per group) and the standard gap metrics.
mitigate.py fits a ThresholdOptimizer under an equalized-odds constraint on the same scores, then reprints the identical table for the mitigated decisions so before and after are column-for-column comparable [2].
Step 1: the baseline audit
"""Disaggregated fairness audit on the adult income dataset.
Run:
pip install fairlearn scikit-learn pandas
python audit.py
"""
import pandas as pd
from fairlearn.datasets import fetch_adult
from fairlearn.metrics import (
MetricFrame, selection_rate, count,
demographic_parity_difference, equalized_odds_difference,
)
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score
from sklearn.model_selection import train_test_split
data = fetch_adult(as_frame=True)
# Drop the survey weight and the sensitive attribute itself: the model must
# not see sex; we join it back on at evaluation time only. (Proxies remain,
# which is the point: blindness in the features is not blindness in the
# predictions.)
X = pd.get_dummies(data.data.drop(columns=["fnlwgt", "sex"]))
y = (data.target == ">50K").astype(int)
sex = data.data["sex"]
X_tr, X_te, y_tr, y_te, sex_tr, sex_te = train_test_split(
X, y, sex, test_size=0.3, random_state=7, stratify=y
)
clf = HistGradientBoostingClassifier(random_state=7).fit(X_tr, y_tr)
pred = clf.predict(X_te)
def fpr(y_true, y_pred):
neg = y_true == 0
return (y_pred[neg] == 1).mean()
mf = MetricFrame(
metrics={
"n": count,
"selection_rate": selection_rate,
"tpr": recall_score,
"fpr": fpr,
"precision": precision_score,
"accuracy": accuracy_score,
},
y_true=y_te, y_pred=pred, sensitive_features=sex_te,
)
print(mf.by_group.round(3).to_string())
print("\ndemographic parity difference:",
round(demographic_parity_difference(y_te, pred, sensitive_features=sex_te), 3))
print("equalized odds difference:",
round(equalized_odds_difference(y_te, pred, sensitive_features=sex_te), 3))
Output from the seeded run (fairlearn 0.14, scikit-learn 1.9; small drift across versions is normal, the pattern is not):
n selection_rate tpr fpr precision accuracy
sex
Female 4807.0 0.082 0.589 0.019 0.789 0.938
Male 9846.0 0.257 0.662 0.082 0.778 0.841
demographic parity difference: 0.175
equalized odds difference: 0.073
Read it like an eval report, not a compliance form. The model never saw the sex column, and the disparity is right there anyway, carried in through proxies. The selection-rate ratio (0.082 / 0.257, about 0.32) is far below the four-fifths tripwire, and the TPR row carries the harm story: of the women who genuinely earn above the threshold, the model finds 7 points fewer than it does for men. If this decision granted access to something good, that gap is the harm, and it is invisible in the 87% headline accuracy. Note also what the N column buys: both groups are in the thousands here, so the gaps are not sampling noise; on your own data, attach Wilson intervals per row before concluding anything, per the statistics discipline.
One more habit from the metrics chapter: base rates differ between the groups in this dataset, which means the impossibility results are live and you must choose which parity to pursue [3]. This recipe chooses equalized odds, the both-error-rates constraint, because both directions of error plausibly matter for an income-shaped decision [4].
Step 2: mitigation, honestly reported
"""ThresholdOptimizer under equalized odds, before/after in one table."""
from fairlearn.postprocessing import ThresholdOptimizer
# ...imports and data split identical to audit.py...
to = ThresholdOptimizer(
estimator=clf,
constraints="equalized_odds",
predict_method="predict_proba",
prefit=True,
)
to.fit(X_tr, y_tr, sensitive_features=sex_tr)
pred_mit = to.predict(X_te, sensitive_features=sex_te, random_state=7)
# rebuild the same MetricFrame with pred_mit and print both tables
In the seeded run the constraint does its job: TPR converges to 0.598 versus 0.605 and FPR to 0.058 versus 0.060, taking the equalized odds difference from 0.073 to 0.007. The costs are just as visible: the female selection rate rises to 0.117 while the male rate falls to 0.225, and precision for the female group drops from 0.79 to 0.56 as the threshold admits more borderline positives. Those lines are the ones to sit with. Post-processing closes the gap by changing decisions for real people near the boundary, and ThresholdOptimizer needs the sensitive attribute at prediction time, which is a legal question to resolve before this leaves a notebook.
The deliverable is one table with a baseline and mitigated column per metric per group, plus three written lines: which constraint you chose and why, what it cost and who it moved, and what you did not fix (the label bias question belongs to the data integrity checklist, not to a threshold).
Figure: ThresholdOptimizer under an equalized odds constraint converges both groups on the adult income dataset: true positive rates move to 0.598 versus 0.605 and false positive rates to 0.058 versus 0.060, cutting the equalized odds difference from 0.073 to 0.007.
Adapting to your product
Swap fetch_adult for your feature table, sex for whatever sensitive columns your product or regulator cares about (pass several columns to get intersections), and the constraint for the one your harm analysis picked. If retraining is on the table or the attribute cannot be used at prediction time, move from ThresholdOptimizer to the reductions approach per the Fairlearn chapter. Then schedule the audit script to run on every release next to your other release gates; a fairness audit that ran once is a screenshot, not a control.