Skip to content

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:

the_data.py
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` column
the_data.py
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` column

Read the third line carefully. P(yq)P(y \mid q) is identical across groups. The groups differ only in the distribution of qq 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.

the_model.py
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.8042
the_model.py
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.8042

Accuracy 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 tt, let y^=1[p^t]\hat{y} = \mathbb{1}[\,\hat{p} \ge t\,], and let AA be the group.

CriterionDefinitionReads as
Demographic parityP(y^=1A=a)P(\hat{y}=1 \mid A=a) equal for all aaEqual shares selected
Equal opportunityP(y^=1y=1,A=a)P(\hat{y}=1 \mid y=1, A=a) equal (equal TPR)Qualified people have equal chances
Equalized oddsequal TPR and equal FPREqual error rates of both kinds
Predictive parityP(y=1y^=1,A=a)P(y=1 \mid \hat{y}=1, A=a) equal (equal precision)A positive means the same thing for everyone
CalibrationP(y=1p^=s,A=a)P(y=1 \mid \hat{p}=s, A=a) equal for all scores ssA 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

figureBase rates 0.7524 and 0.2147 — model accuracy 0.8000, no measurement bias anywherematplotlib
Grouped bars for two groups. Group 0 has a true base rate of 0.7524 and a selection rate of 0.8169. Group 1 has a base rate of 0.2147 and a selection rate of 0.1632.Grouped bars for two groups. Group 0 has a true base rate of 0.7524 and a selection rate of 0.8169. Group 1 has a base rate of 0.2147 and a selection rate of 0.1632.
The model tracks each group's base rate, which is what an accurate model does. Group 0 is selected at 0.8169 and group 1 at 0.1632: a gap of 0.6537, produced without the model ever seeing the group label.

At a single threshold of 0.50, applied identically to everyone:

MetricGroup 0Group 1Gap
n1,6441,556
base rate0.75240.21470.5377
selection rate0.81690.16320.6537
true positive rate0.90700.42220.4849
false positive rate0.54300.09250.4505
precision0.83540.55510.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 pp, precision (PPV) and false negative rate FNR=1TPR\mathrm{FNR} = 1 - \mathrm{TPR}:

FPR=p1p1PPVPPV(1FNR)\mathrm{FPR} = \frac{p}{1-p} \cdot \frac{1 - \mathrm{PPV}}{\mathrm{PPV}} \cdot \big(1 - \mathrm{FNR}\big)

This is Chouldechova’s identity. It is a rearrangement of the confusion matrix, so it holds exactly, always. Verify it on both groups:

chouldechova.py
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.0925
chouldechova.py
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.0925

Exact 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 p/(1p)p/(1-p) — 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:

figureGap between groups — boxed cells are the criterion each policy satisfiesmatplotlib
A three-by-three heatmap of gaps. One threshold for everyone: 0.6537, 0.4849, 0.2803. Forced demographic parity: 0.0283, 0.0660, 0.5706. Forced equal opportunity: 0.2327, 0.0002, 0.5021. The satisfied cell in each row is boxed.A three-by-three heatmap of gaps. One threshold for everyone: 0.6537, 0.4849, 0.2803. Forced demographic parity: 0.0283, 0.0660, 0.5706. Forced equal opportunity: 0.2327, 0.0002, 0.5021. The satisfied cell in each row is boxed.
Every row satisfies the criterion it optimises and violates at least one other, and the predictive-parity column gets worse in both interventions — from 0.2803 to 0.5706 and 0.5021. There is no row with three small numbers, and no policy anyone has ever proposed produces one.
PolicyGroup 1 thresholdDP gapEO gapPP gapOverall accuracy
one threshold for everyone0.50000.65370.48490.28030.7994
force demographic parity0.00500.02830.06600.57060.6103
force equal opportunity0.06500.23270.00020.50210.6959
force equal FPR0.04500.19090.01810.51820.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

figureGroup 1 under parity: more selected and more true positives, but precision collapsesmatplotlib
Group 1's four rates before and after forcing parity. Selection rate 0.163 to 0.789, true positive rate 0.422 to 0.973, false positive rate 0.092 to 0.738, precision 0.555 to 0.265.Group 1's four rates before and after forcing parity. Selection rate 0.163 to 0.789, true positive rate 0.422 to 0.973, false positive rate 0.092 to 0.738, precision 0.555 to 0.265.
Forcing demographic parity is not free and it is not purely beneficial. Group 1's true positive rate rises from 0.4222 to 0.9731 — genuinely qualified people who were being rejected now get through. Its false positive rate rises from 0.0925 to 0.7381, and precision falls from 0.5551 to 0.2649: fewer than 27% of group-1 approvals are now correct, against 84% for group 0.

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 admissiona false negative (denied opportunity)equal opportunity — equalise TPR
a fraud flag, a risk score, extra screeninga false positive (wrongly burdened)equal FPR
an allocation of a fixed budget across groupsunder-representationdemographic parity
a score handed to a human decision-makera score meaning different thingscalibration + 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 +1+1, negatives around 1-1 — 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 Φ\Phi itself:

TPR(t)=Φ(1t),FPR(t)=Φ(1t),PPV=bTPRbTPR+(1b)FPR\mathrm{TPR}(t) = \Phi(1-t), \qquad \mathrm{FPR}(t) = \Phi(-1-t), \qquad \mathrm{PPV} = \frac{b\,\mathrm{TPR}}{b\,\mathrm{TPR} + (1-b)\,\mathrm{FPR}}

Drag the two threshold handles and try to make all three gaps small at once.

sketch Two thresholds, three gaps, one impossible target p5.js
Two groups with identical conditional score distributions and base rates 0.75 and 0.25. Dragging each group's threshold shows the selection-rate, true-positive-rate and precision gaps updating in closed form; zeroing any one of them leaves at least one other large.

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.

bias_audit.py
"""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
bias_audit.py
"""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

Four rules make the difference between an audit and a ritual:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

diagram Diagram mermaid

Pitfalls

PitfallWhy it bitesWhat to do
Dropping the protected attribute and declaring the model fairgroupgroup was never a column here and the selection gap is still 0.6537Keep it out of the features if you must, but always measure with it
Reporting one fairness metricThe three criteria moved in opposite directions in every row of the policy tableReport all of them, plus base rates
Treating criterion choice as a modelling decisionThe impossibility result means the choice is about which harm is acceptableGet it decided and documented by whoever is accountable
Forcing demographic parity without looking at precisionGroup 1’s precision fell from 0.5551 to 0.2649 and its FPR rose to 0.7381Show the full before/after table for the affected group
Auditing at 0.50 when production uses another thresholdEvery rate on this page is threshold-dependentAudit the deployed threshold, and re-audit when it changes
Trusting gaps computed on tiny slicesIntersectional cells shrink fastPrint n, warn below a floor, bootstrap the gap
Equalising rates against a biased labelThe metric cannot see label biasInterrogate label provenance before modelling

Recap

  • The data had one honest feature, an identical P(yq)P(y \mid q) in both groups, and no groupgroup column — 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.
quizCheck yourself
  1. The model was never given the group column, yet the selection-rate gap is 0.6537. Why?

    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.

  2. Chouldechova's identity reproduced measured FPR to four decimals in both groups. What follows?

    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.

  3. Forcing demographic parity moved group 1's threshold to 0.0050. What is the honest summary of the result?

    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.

  4. You are building a fraud-screening model where a positive means additional scrutiny. Which criterion fits?

    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.

  5. Your audit shows an equal-opportunity gap of 0.31 for one intersectional subgroup. What is the first thing to check?

    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 coffee

Was this page helpful?

Let us know how we did