Fairness Metrics and Bias Auditing
What you’ll learn
- the four group fairness criteria, defined precisely, computed on the same model
- why a model that never sees the protected attribute still produces a 0.6537 selection-rate gap
- Chouldechova’s identity, verified to four decimals: prevalence, precision and error rates are algebraically locked together
- why demographic parity, equal opportunity and predictive parity cannot all hold when base rates differ — and what each one costs when you force it
- how to write a bias audit that survives contact with a real dataset
- why “we removed the protected attribute” is not a defence
A deliberately clean setup
Every ingredient here is honest. There is one feature qq — read it as a genuine, unbiased
qualification score. It is equally predictive in both groups. The label is generated from qq alone,
by the same rule for everybody:
rng = np.random.default_rng(7)
n = 8000
group = rng.integers(0, 2, n)
q = rng.normal(np.where(group == 1, -1.1, 1.1), 1.0, n) # the ONLY difference
y = (rng.random(n) < 1 / (1 + np.exp(-1.6 * q))).astype(int) # same rule for both
X = pd.DataFrame({"q": q, "noise": rng.normal(0, 1, n)}) # note: no `group` columnrng = np.random.default_rng(7)
n = 8000
group = rng.integers(0, 2, n)
q = rng.normal(np.where(group == 1, -1.1, 1.1), 1.0, n) # the ONLY difference
y = (rng.random(n) < 1 / (1 + np.exp(-1.6 * q))).astype(int) # same rule for both
X = pd.DataFrame({"q": q, "noise": rng.normal(0, 1, n)}) # note: no `group` columnRead the third line carefully. is identical across groups. The groups differ only in the distribution of itself — one has, on average, a lower qualification score, for reasons outside the model’s data. That is the entire source of everything that follows.
And note the fourth line: groupgroup is not a feature. The model never sees it.
model = RandomForestClassifier(n_estimators=400, random_state=0).fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
print(f"{model.score(X_test, y_test):.4f}") # 0.8000
for g in (0, 1): # ranking quality per group
m = group_test == g
print(f"group {g} AUC {roc_auc_score(y_test[m], proba[m]):.4f}")
# group 0 AUC 0.7888
# group 1 AUC 0.8042model = RandomForestClassifier(n_estimators=400, random_state=0).fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
print(f"{model.score(X_test, y_test):.4f}") # 0.8000
for g in (0, 1): # ranking quality per group
m = group_test == g
print(f"group {g} AUC {roc_auc_score(y_test[m], proba[m]):.4f}")
# group 0 AUC 0.7888
# group 1 AUC 0.8042Accuracy 0.8000. AUC 0.7888 and 0.8042 — the model ranks candidates about equally well inside both groups, and if anything slightly better in group 1. By every conventional check this model is fine.
The four criteria
Fix a threshold , let , and let be the group.
| Criterion | Definition | Reads as |
|---|---|---|
| Demographic parity | equal for all | Equal shares selected |
| Equal opportunity | equal (equal TPR) | Qualified people have equal chances |
| Equalized odds | equal TPR and equal FPR | Equal error rates of both kinds |
| Predictive parity | equal (equal precision) | A positive means the same thing for everyone |
| Calibration | equal for all scores | A score of 0.7 means 0.7 for everyone |
These are not variations on a theme. They are different demands, and the next section shows they are mutually exclusive here.
What the base rates do
At a single threshold of 0.50, applied identically to everyone:
| Metric | Group 0 | Group 1 | Gap |
|---|---|---|---|
| n | 1,644 | 1,556 | |
| base rate | 0.7524 | 0.2147 | 0.5377 |
| selection rate | 0.8169 | 0.1632 | 0.6537 |
| true positive rate | 0.9070 | 0.4222 | 0.4849 |
| false positive rate | 0.5430 | 0.0925 | 0.4505 |
| precision | 0.8354 | 0.5551 | 0.2803 |
Three points deserve attention.
The gaps are large and the model is not broken. 0.6537 is not a rounding artefact; it is what “one threshold for everyone” produces when the underlying rates are 0.7524 and 0.2147.
Removing the protected attribute achieved nothing. groupgroup was never a column. qq carries the
group difference perfectly well on its own, and any sufficiently rich feature set will. “Fairness
through unawareness” is not a technique; it is a way of not looking.
The direction of unfairness depends on which criterion you invoke. Group 1 is selected far less often (bad by demographic parity) and has a much lower false positive rate (good, if a positive is a punishment). Nothing about the numbers alone tells you which reading is right.
The gaps are locked together algebraically
The reason you cannot fix all of these at once is not statistical, it is arithmetic. For any classifier and any group, with prevalence , precision (PPV) and false negative rate :
This is Chouldechova’s identity. It is a rearrangement of the confusion matrix, so it holds exactly, always. Verify it on both groups:
for row in group_rows:
p = y_test[group_test == row["group"]].mean()
ppv, fnr = row["precision"], 1 - row["tpr"]
implied = (p / (1 - p)) * ((1 - ppv) / ppv) * (1 - fnr)
print(f"group {row['group']} prevalence {p:.4f} "
f"implied FPR {implied:.4f} measured FPR {row['fpr']:.4f}")
# group 0 prevalence 0.7524 implied FPR 0.5430 measured FPR 0.5430
# group 1 prevalence 0.2147 implied FPR 0.0925 measured FPR 0.0925for row in group_rows:
p = y_test[group_test == row["group"]].mean()
ppv, fnr = row["precision"], 1 - row["tpr"]
implied = (p / (1 - p)) * ((1 - ppv) / ppv) * (1 - fnr)
print(f"group {row['group']} prevalence {p:.4f} "
f"implied FPR {implied:.4f} measured FPR {row['fpr']:.4f}")
# group 0 prevalence 0.7524 implied FPR 0.5430 measured FPR 0.5430
# group 1 prevalence 0.2147 implied FPR 0.0925 measured FPR 0.0925Exact to four decimals in both groups, because it is an identity and not an approximation.
Now read it as a constraint. Suppose you equalise PPV across groups (predictive parity) and also equalise FNR (part of equalized odds). Then everything on the right-hand side is equal across groups except — so FPR must differ, in the ratio of the prevalence odds. Here those odds are 3.0393 and 0.2733, a ratio of 11.1. There is no classifier, no architecture, no amount of data that escapes this. When base rates differ, predictive parity and equalized odds are incompatible except in the degenerate case of a perfect classifier.
Three policies, three different failures
Take the same model and only change the thresholds. Solve for the group-1 threshold that equalises each criterion in turn:
| Policy | Group 1 threshold | DP gap | EO gap | PP gap | Overall accuracy |
|---|---|---|---|---|---|
| one threshold for everyone | 0.5000 | 0.6537 | 0.4849 | 0.2803 | 0.7994 |
| force demographic parity | 0.0050 | 0.0283 | 0.0660 | 0.5706 | 0.6103 |
| force equal opportunity | 0.0650 | 0.2327 | 0.0002 | 0.5021 | 0.6959 |
| force equal FPR | 0.0450 | 0.1909 | 0.0181 | 0.5182 | 0.6794 |
Look at the thresholds. To reach demographic parity, group 1’s threshold falls to 0.0050 — the model must approve essentially anyone whose predicted probability is not zero. That is what closing a 0.6537 gap by threshold surgery actually requires, and the accuracy cost is 0.7994 → 0.6103.
Interestingly, the last row shows that equalized odds is nearly reachable: at a threshold of 0.0450 the FPR gap is 0.0012 and the TPR gap 0.0181 (the bisection lands group 1’s FPR at 0.5442 against group 0’s 0.5430, since probabilities come in discrete steps). Both error rates roughly matched — and the precision gap has grown to 0.5182. The identity is not negotiable.
Who pays for parity
This is the part that gets skipped. Forcing demographic parity does real good — the TPR for group 1 goes from 0.4222 to 0.9731, meaning most of the qualified people who were previously refused now succeed. It also means 73.81% of unqualified group-1 applicants are now approved, against 54.30% in group 0, and that a group-1 approval carries 0.2649 precision against group 0’s 0.8354.
Whether that trade is right depends entirely on what the decision does to people:
| If the positive decision is… | The costly error is… | Reasonable criterion |
|---|---|---|
| a loan, a job interview, an admission | a false negative (denied opportunity) | equal opportunity — equalise TPR |
| a fraud flag, a risk score, extra screening | a false positive (wrongly burdened) | equal FPR |
| an allocation of a fixed budget across groups | under-representation | demographic parity |
| a score handed to a human decision-maker | a score meaning different things | calibration + predictive parity |
There is no default. Picking one of these rows is the fairness work; computing the metric afterwards is the easy part.
See it move
Threshold changes are the only lever most teams actually have, so it is worth developing intuition for what they can and cannot do. Below, two groups have the same score distributions conditional on the label — positives around , negatives around — and differ only in base rate: 0.75 against 0.25. Everything is computed in closed form from the normal CDF, so nothing is approximated except itself:
Drag the two threshold handles and try to make all three gaps small at once.
Two gaps can be closed at once only in the limit where you select everybody or nobody. The third never cooperates, and that is the impossibility result rendered as a toy.
Writing an audit that is actually useful
A fairness audit is not a single number. It is a table, sliced by group, with counts attached so you can tell a real gap from four unlucky rows.
"""A group fairness report. No dependencies beyond numpy."""
import numpy as np
def group_report(proba, y_true, group, thresholds=None, default_threshold=0.5,
min_count=100):
"""Per-group rates at a threshold, plus the gaps and a small-sample warning.
`thresholds` maps group -> threshold, so you can audit a policy that treats
groups differently as easily as one that does not.
"""
rows = []
for g in sorted(set(group)):
mask = group == g
t = default_threshold if thresholds is None else thresholds[g]
pred = (proba[mask] >= t).astype(int)
yy = y_true[mask]
positives, selected = yy == 1, pred == 1
rows.append({
"group": g,
"n": int(mask.sum()),
"n_positive": int(positives.sum()),
"threshold": t,
"base_rate": float(yy.mean()),
"selection": float(pred.mean()),
"tpr": float(pred[positives].mean()) if positives.any() else np.nan,
"fpr": float(pred[~positives].mean()) if (~positives).any() else np.nan,
"precision": float(yy[selected].mean()) if selected.any() else np.nan,
})
header = f"{'group':>7s} {'n':>6s} {'base':>7s} {'sel':>7s} {'tpr':>7s} {'fpr':>7s} {'prec':>7s}"
print(header)
for r in rows:
warn = " (n too small to trust)" if r["n"] < min_count else ""
print(f"{r['group']!s:>7s} {r['n']:6d} {r['base_rate']:7.4f} "
f"{r['selection']:7.4f} {r['tpr']:7.4f} {r['fpr']:7.4f} "
f"{r['precision']:7.4f}{warn}")
def spread(key):
values = [r[key] for r in rows if not np.isnan(r[key])]
return max(values) - min(values)
print(f"\ndemographic parity gap {spread('selection'):.4f}")
print(f"equal opportunity gap {spread('tpr'):.4f}")
print(f"equal FPR gap {spread('fpr'):.4f}")
print(f"predictive parity gap {spread('precision'):.4f}")
return rows"""A group fairness report. No dependencies beyond numpy."""
import numpy as np
def group_report(proba, y_true, group, thresholds=None, default_threshold=0.5,
min_count=100):
"""Per-group rates at a threshold, plus the gaps and a small-sample warning.
`thresholds` maps group -> threshold, so you can audit a policy that treats
groups differently as easily as one that does not.
"""
rows = []
for g in sorted(set(group)):
mask = group == g
t = default_threshold if thresholds is None else thresholds[g]
pred = (proba[mask] >= t).astype(int)
yy = y_true[mask]
positives, selected = yy == 1, pred == 1
rows.append({
"group": g,
"n": int(mask.sum()),
"n_positive": int(positives.sum()),
"threshold": t,
"base_rate": float(yy.mean()),
"selection": float(pred.mean()),
"tpr": float(pred[positives].mean()) if positives.any() else np.nan,
"fpr": float(pred[~positives].mean()) if (~positives).any() else np.nan,
"precision": float(yy[selected].mean()) if selected.any() else np.nan,
})
header = f"{'group':>7s} {'n':>6s} {'base':>7s} {'sel':>7s} {'tpr':>7s} {'fpr':>7s} {'prec':>7s}"
print(header)
for r in rows:
warn = " (n too small to trust)" if r["n"] < min_count else ""
print(f"{r['group']!s:>7s} {r['n']:6d} {r['base_rate']:7.4f} "
f"{r['selection']:7.4f} {r['tpr']:7.4f} {r['fpr']:7.4f} "
f"{r['precision']:7.4f}{warn}")
def spread(key):
values = [r[key] for r in rows if not np.isnan(r[key])]
return max(values) - min(values)
print(f"\ndemographic parity gap {spread('selection'):.4f}")
print(f"equal opportunity gap {spread('tpr'):.4f}")
print(f"equal FPR gap {spread('fpr'):.4f}")
print(f"predictive parity gap {spread('precision'):.4f}")
return rowsFour rules make the difference between an audit and a ritual:
- Report every criterion, not the one that looks best. A report showing only the criterion your model happens to satisfy is worse than no report, because it implies the others were checked.
- Print the counts. A 0.30 gap on 40 people is noise; the same gap on 4,000 is a finding. Attach a confidence interval or at least a minimum-count warning.
- Slice intersectionally, then stop where the counts run out. Gaps often live in the intersections, and the cells get small fast. Say where you stopped and why.
- Audit the threshold you actually deploy. Metrics at 0.50 are irrelevant if operations uses 0.31.
The audit as a procedure
The order of these steps is load-bearing: the label question comes first, because a criterion chosen before the label is examined can be satisfied perfectly and still be meaningless.
flowchart TD A["Before modelling:
how was the label produced?"] --> B{"Could the label itself
encode past discrimination?"} B -->|"yes -- 'was hired',
'was arrested', 'was flagged'"| C["No group metric can detect this.
Settle it with domain owners,
or change the target."] B -->|"no, the label is
the outcome you care about"| D["Measure base rates
per group, with counts"] C --> D D --> E{"Do base rates differ
between groups?"} E -->|"yes"| F["Demographic parity and
equalised odds CANNOT both hold.
Choose which to violate,
in writing."] E -->|"no"| G["Both are achievable at once --
rare in practice."] F --> H["Pick the criterion from the
harm: FN-heavy -> equal TPR,
FP-heavy -> equal FPR,
access-heavy -> selection parity"] G --> H H --> I["Report ALL four criteria
at the deployed threshold,
with counts and intervals"] I --> J{"Any cell below the
minimum count?"} J -->|"yes"| K["Mark it untrustworthy.
Do not act on it,
do not hide it."] J -->|"no"| L{"Gap larger than
your declared tolerance?"} L -->|"yes"| M["State who pays for the fix:
group thresholds, reject option,
or a different model"] L -->|"no"| N["Publish the table, the
threshold, and the date.
Re-run it on a schedule."] M --> I
Pitfalls
| Pitfall | Why it bites | What to do |
|---|---|---|
| Dropping the protected attribute and declaring the model fair | groupgroup was never a column here and the selection gap is still 0.6537 | Keep it out of the features if you must, but always measure with it |
| Reporting one fairness metric | The three criteria moved in opposite directions in every row of the policy table | Report all of them, plus base rates |
| Treating criterion choice as a modelling decision | The impossibility result means the choice is about which harm is acceptable | Get it decided and documented by whoever is accountable |
| Forcing demographic parity without looking at precision | Group 1’s precision fell from 0.5551 to 0.2649 and its FPR rose to 0.7381 | Show the full before/after table for the affected group |
| Auditing at 0.50 when production uses another threshold | Every rate on this page is threshold-dependent | Audit the deployed threshold, and re-audit when it changes |
| Trusting gaps computed on tiny slices | Intersectional cells shrink fast | Print n, warn below a floor, bootstrap the gap |
| Equalising rates against a biased label | The metric cannot see label bias | Interrogate label provenance before modelling |
Recap
- The data had one honest feature, an identical in both groups, and no
groupgroupcolumn — and still produced a selection-rate gap of 0.6537 at a single threshold of 0.50. - Base rates 0.7524 against 0.2147 are the whole story. An accurate model reproduces them.
- Chouldechova’s identity reproduced both groups’ FPR exactly — 0.5430 and 0.0925 — from prevalence, precision and FNR. The criteria are algebraically locked together.
- Prevalence odds differ by 11.1× here, so predictive parity and equalized odds cannot both hold.
- Forcing demographic parity needed a group-1 threshold of 0.0050, cost accuracy 0.7994 → 0.6103, raised TPR 0.4222 → 0.9731 and dropped precision 0.5551 → 0.2649.
- Equal opportunity was cheaper (accuracy 0.6959) and left a demographic-parity gap of 0.2327.
- Choosing among these is a decision about harm, not a hyperparameter.
The model was never given the group column, yet the selection-rate gap is 0.6537. Why?
Fairness through unawareness fails because any sufficiently informative feature set proxies the protected attribute. Here q does it single-handedly, and the model is doing exactly its job: matching the base rates it was shown.
Show answer
B — The single feature q carries the group difference, and an accurate model reproduces base rates of 0.7524 and 0.2147 — Fairness through unawareness fails because any sufficiently informative feature set proxies the protected attribute. Here q does it single-handedly, and the model is doing exactly its job: matching the base rates it was shown.
Chouldechova's identity reproduced measured FPR to four decimals in both groups. What follows?
It is an identity, a rearrangement of the confusion matrix, so it always holds exactly. Read as a constraint it is the impossibility result: with unequal base rates you may satisfy predictive parity or equalized odds, not both.
Show answer
B — Prevalence, precision and the two error rates are algebraically linked, so equalising precision and FNR forces FPR to differ by the prevalence-odds ratio — 11.1x here — It is an identity, a rearrangement of the confusion matrix, so it always holds exactly. Read as a constraint it is the impossibility result: with unequal base rates you may satisfy predictive parity or equalized odds, not both.
Forcing demographic parity moved group 1's threshold to 0.0050. What is the honest summary of the result?
Both halves are real, which is why the trade has to be made deliberately. Overall accuracy fell from 0.7994 to 0.6103, and a group-1 approval now carries 0.2649 precision against group 0's 0.8354.
Show answer
B — Genuinely qualified group-1 members benefit — TPR 0.4222 to 0.9731 — while precision falls to 0.2649 and FPR rises to 0.7381 — Both halves are real, which is why the trade has to be made deliberately. Overall accuracy fell from 0.7994 to 0.6103, and a group-1 approval now carries 0.2649 precision against group 0's 0.8354.
You are building a fraud-screening model where a positive means additional scrutiny. Which criterion fits?
The costly error defines the criterion. For a punitive positive, being wrongly flagged is the harm, so equalise FPR. For an assistive positive — a loan, an interview — the harm is a false negative, so equalise TPR.
Show answer
B — Equal false positive rates, because the harm falls on people wrongly flagged — The costly error defines the criterion. For a punitive positive, being wrongly flagged is the harm, so equalise FPR. For an assistive positive — a loan, an interview — the harm is a false negative, so equalise TPR.
Your audit shows an equal-opportunity gap of 0.31 for one intersectional subgroup. What is the first thing to check?
TPR is computed on positives only, so a subgroup with 40 rows and 6 positives produces gaps that swing wildly. Print n and n_positive, bootstrap the gap, and set a minimum count below which you report 'insufficient data' rather than a number.
Show answer
B — The subgroup's count, and specifically how many positives it contains — a gap on 40 rows is noise — TPR is computed on positives only, so a subgroup with 40 rows and 6 positives produces gaps that swing wildly. Print n and n_positive, bootstrap the gap, and set a minimum count below which you report 'insufficient data' rather than a number.
🧪 Try It Yourself
Exercise 1 – Build a dataset with no bias in it
Exercise 2 – Compute the four rates per group
Exercise 3 – Pay for demographic parity
Exercise 4 – Equal opportunity is cheaper, and leaves a gap
Exercise 5 – Verify the impossibility arithmetically
Next
Phase 9 - Interpretability & Responsible ML — the phase overview, with the decision table for which tool answers which question and where each one misleads.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
