← Back to explainers
Explainer

Label Shift and Fairness

When the base rate moves, some metrics swing and some stay put.

Learn which fairness metrics survive a change in a group's base rate. On Benefits Denial, true and false positive rate gaps stay at +0.0181 and -0.0004 while the parity gap runs from -0.0683 to +0.0326 and the precision gap from -0.4443 to +0.0594.

If the base rate changes and nothing else does, some of your fairness metrics stay put and some swing wildly. Knowing which is half of reading a monitoring chart.

The One-Sentence Definition

Label shift is a change in how common the outcome is in a group (the base rate) while the relationship between the features and the outcome stays the same, and it moves some fairness metrics (selection rate, precision) and leaves others (true and false positive rates) unchanged.

Why It Matters

Deployed populations change in how often the outcome occurs: more patients are readmitted in winter, a lender enters a market with a higher default rate, one group's applicants change. If the model's score for a person given their features still means what it did, this is label shift, and it has a clean consequence. The error rates computed within each outcome class, the true and false positive rates, do not change, because they condition on the label. Everything that mixes the classes does: the selection rate, the precision, the calibration.

So a monitor that sees a parity gap or a precision gap move after a change in base rate does not have evidence that the model got worse. The fairness metric that looks alarming may simply be tracking the base rate. See Fairness Metric Conflicts for why metrics that mix classes cannot all hold when base rates differ.

Core Concept: Reweight the Classes Inside One Group

You can see the effect without waiting for new data. Give each positive row in a group the weight new rate / old rate and each negative row (1 - new rate) / (1 - old rate), which changes the group's base rate to the target while keeping the features-given-label distribution. Recompute the metrics on the weighted rows.

TPR, FPR           unchanged (they are within-class)
selection rate     moves with the base rate
precision (PPV)    moves with the base rate

Concrete Example: Benefits Denial

Benefits Denial: the S2 logistic regression and its test rows. Women's base rate for "income above $50K" is 0.111 in the data and men's is 0.305. We change women's base rate from 0.05 to 0.40, leaving men alone, and recompute four group gaps.

women's base rate in the test rows 0.111; men's 0.305

 women base rate  parity gap   TPR gap   FPR gap  precision gap
           0.050     -0.0683   +0.0181   -0.0004        -0.4443
           0.111     -0.0509   +0.0181   -0.0004        -0.2329  <- as measured
           0.200     -0.0251   +0.0181   -0.0004        -0.0807
           0.300     +0.0038   +0.0181   -0.0004        +0.0072
           0.400     +0.0326   +0.0181   -0.0004        +0.0594

So a model audited on a parity or predictive-parity criterion can fail it after a pure label shift, and one audited on equal opportunity or equalized odds cannot.

Detection/Implementation Code

Run from the repo root (PYTHONPATH=. python3 explainers/code/label-shift-and-fairness.py; that file is generated from the block below).

import numpy as np
from faircode.benchmark import load_audit        # same loader, encoding and split as the benchmark harness
from faircode.models import build_model

data = load_audit("Benefits Denial")             # S2 feature set: core features, no protected columns
train, test = data.idx_train, data.idx_test
model = build_model("logistic_regression", data.random_state).fit(data.X.iloc[train], data.y.iloc[train])
pred = model.predict(data.X.iloc[test])
y = data.y.to_numpy()[test]
women = data.masks["sex"].to_numpy()[test].astype(bool)
observed = y[women].mean()
print(f"women's base rate in the test rows {observed:.3f}; men's {y[~women].mean():.3f}")

def reweighted(target_rate):
    """Label shift for women only: change the share of positives among women to `target_rate`, keeping the
    features-given-label relationship fixed, by weighting positives and negatives."""
    w = np.ones(len(y))
    w[women & (y == 1)] = target_rate / observed
    w[women & (y == 0)] = (1 - target_rate) / (1 - observed)
    return w

def metrics(w):
    def rate(mask):
        return np.average(pred[mask], weights=w[mask])
    sel_w, sel_m = rate(women), rate(~women)
    tpr_w, tpr_m = rate(women & (y == 1)), rate(~women & (y == 1))
    fpr_w, fpr_m = rate(women & (y == 0)), rate(~women & (y == 0))
    ppv_w = np.average(y[women & (pred == 1)], weights=w[women & (pred == 1)])
    ppv_m = y[~women & (pred == 1)].mean()
    return sel_w - sel_m, tpr_w - tpr_m, fpr_w - fpr_m, ppv_w - ppv_m

print(f"\n{'women base rate':>16} {'parity gap':>11} {'TPR gap':>9} {'FPR gap':>9} {'precision gap':>14}")
for target in (0.05, observed, 0.20, 0.30, 0.40):
    sel, tpr, fpr, ppv = metrics(reweighted(target))
    tag = "  <- as measured" if abs(target - observed) < 1e-9 else ""
    print(f"{target:16.3f} {sel:+11.4f} {tpr:+9.4f} {fpr:+9.4f} {ppv:+14.4f}{tag}")

Limitations

  1. It assumes the features-given-label relationship is fixed. Real shifts often move both, and then the error rates move too. Label shift is a special case that is easy to reason about, not a description of all drift.
  2. The reweighting changes the sample, not the model. It tells you what the metrics would be under a different base rate, assuming the assumption holds.
  3. You cannot always see the base rate. If outcomes arrive late or not at all, the shift is invisible until they do, see Delayed Feedback.
  4. A change in one group's rate is only one kind of shift. Both groups can shift, and in different directions.
  5. Choosing the "right" metric does not remove the shift. It decides which changes in a monitor you will treat as alarms.

Further Reading


Part of The Fair Code Project - exposing and fixing algorithmic bias with real data and open code.