EduFlow

Section 5 · Beyond the Basics

Responsible ML: Bias, Fairness and Drift

20 min4 min readPython exercise

Lesson at a glance

Audit a model’s errors group by group, pick a fairness criterion that matches the harm, and monitor inputs, predictions and outcomes so you notice when the world drifts away from the training data.

  1. 1Where bias comes from
  2. 2Auditing a model group by group
  3. 3Choosing what “fair” means
  4. 4After launch: drift

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Your champion model reached a test ROC AUC of 0.81. That’s one number for 100 customers from eight countries and three segments. It’s entirely possible for a model to be good on average and poor for one group, and for that group to be the one with the least power to complain. It’s also possible for a model that was good in October to be quietly wrong by March. This lesson covers both: fairness before launch, drift after it.

Where bias comes from

Bias in a model is rarely a line of code. It arrives with the data and with how predictions are used:

  • Historical bias: labels record past decisions, not ground truth. A model trained on who got approved learns who was approved, including any prejudice in those approvals.
  • Representation: small groups give the model less to learn from. Cartwheel has 17 Jordanian customers in the whole table; any model will be less reliable for them.
  • Proxies: removing a sensitive column doesn’t remove its information. Shipping fees, delivery times and currency can stand in for country.
  • Use: the same score means different things depending on the action. A churn flag that triggers a discount helps the flagged customer; a flag that triggers a credit freeze hurts them.

Auditing a model group by group

To audit fairly you need predictions for many customers that the model didn’t train on. cross_val_predict gives every row an out-of-fold prediction: each customer is scored by a model trained on the other folds.

Python
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import StratifiedKFold, cross_val_predict
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
model = make_pipeline(StandardScaler(), LogisticRegression())
prob = cross_val_predict(model, churn[features], churn["churned"], cv=cv, method="predict_proba")[:, 1]
audit = churn.assign(flagged=(prob >= 0.5).astype(int))

def group_report(g):
    return pd.Series({
        "customers": len(g),
        "churn_rate": g["churned"].mean(),
        "flag_rate": g["flagged"].mean(),
        "recall": recall_score(g["churned"], g["flagged"]),
        "precision": precision_score(g["churned"], g["flagged"]),
    })

print(audit.groupby("segment")[["churned", "flagged"]].apply(group_report).round(2))
print(audit.groupby("country")[["churned", "flagged"]].apply(group_report).round(2))

Read across the columns:

  • Flag rate (selection rate): how often each group gets the offer.
  • Recall: of each group’s churners, how many the model catches. Here that’s the share of at-risk customers who get help.
  • Precision: of each group’s flagged customers, how many were really leaving. Low precision means discounts going to people who’d have stayed.

Recall ranges from 0.71 (Canada, UAE, UK) to 0.82 (Egypt, Jordan). Precision for small businesses is 0.66 against 0.78 for consumers. Some gaps are noise: Jordan’s numbers rest on 17 customers, so a single customer moves them by several points. Others deserve a closer look as data grows. The point of the audit isn’t a pass/fail stamp; it’s knowing where the model is weaker, so you can decide whether that’s acceptable.

Choosing what “fair” means

There are several reasonable definitions, and when groups have different base rates (Egypt churns at 67%, Germany at 45%) they can’t all hold at once:

  • Equal selection rates (demographic parity): every group flagged at the same rate.
  • Equal recall (equal opportunity): every group’s true churners caught at the same rate.
  • Equal precision: a flag means the same risk in every group.

Pick by asking who is harmed by which error. For win-back offers the harm is a churner who never gets the offer, so equal recall is the natural target. For a model that freezes accounts, the harm is a false positive, and you’d look at false positive rates instead. Write the choice down; it’s a business and ethical decision, not a technical default.

After launch: drift

The model learned from customers as they were on 30 September 2025. The world moves. Data drift is a change in the inputs: a new mobile app shifts mobile_share, a courier strike stretches days_since_last_order. Concept drift is a change in the relationship itself: a competitor’s loyalty scheme makes even recent customers leave. And churn has label delay: you only learn whether a prediction was right 90 days later.

A loop with four stages. Daily: check input distributions against training data. Daily: track the share of customers flagged. After 90 days: compare predictions with real outcomes. When checks fail: investigate, then retrain and re-audit, which feeds back to the deployed model. deployed model input drift checks daily, no labels needed prediction mix flag rate per group real performance after 90 days investigate, retrain, re-audit
Monitoring a deployed churn model

Input drift can be checked every day, without labels, by comparing each feature’s recent values with the training data. The two-sample Kolmogorov–Smirnov test from SciPy is a simple start:

Python
import numpy as np
import pandas as pd
from scipy.stats import ks_2samp

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
training = churn[features]

# Simulate next quarter: a courier problem delays orders and a new app shifts shopping to mobile
rng = np.random.default_rng(7)
recent = training.sample(150, random_state=1).copy()
recent["days_since_last_order"] += rng.integers(20, 60, len(recent))
recent["mobile_share"] = (recent["mobile_share"] + 0.3).clip(0, 1)

for col in features:
    stat, p = ks_2samp(training[col], recent[col])
    flag = "DRIFT" if p < 0.01 else ""
    print(f"{col:22} KS {stat:.2f}  p={p:.3g} {flag}")

The two columns that changed light up; the rest don’t. In production you’d run this on a schedule, alert on sustained drift rather than a single bad day, and pair it with the flag rate per group and, once outcomes arrive, recall and precision per group. When real performance drops, retrain on recent data and rerun this lesson’s audit before redeploying.

Monitoring assumes the model is running somewhere. The last lesson packages the champion pipeline, puts it behind an API and closes the loop.

Key takeaways

  • Bias enters through the data (historical labels, under-represented groups, proxy features) and through how predictions are used; a good overall score can hide a bad score for one group.
  • Audit with per-group metrics: selection rate, recall and precision, computed on out-of-fold or test predictions.
  • Fairness definitions such as equal selection rates, equal recall and equal precision usually can’t all hold at once; choose the one that matches who is harmed by which error.
  • Dropping a sensitive column doesn’t remove bias, because other features act as proxies; keep it available for auditing.
  • Monitor three things after launch: input distributions (data drift), the distribution of predictions, and real performance once outcomes arrive.

Further reading

Check your understanding

3 questions

Question 1 of 3

The churn model’s recall is 0.82 for Egyptian customers and 0.71 for Canadian customers. Win-back offers go to flagged customers. Who is disadvantaged?

Question 2 of 3

A teammate removes country from the features ‘to make the model fair’. Why is that not enough?

Question 3 of 3

Two months after launch, the share of customers flagged as churners rises from 47% to 60%, but no churn outcomes are known yet. What’s the best first step?

Practice

Python exercise

Audit by segment, then check for drift

  1. flagged: an out-of-fold 0/1 churn flag for every customer, from cross_val_predict(model, ..., cv=cv, method="predict_proba") and a 0.5 threshold.
  2. recall_by_segment: recall of those flags within each segment (a Series indexed by segment). lowest_recall_segment: the segment whose churners are caught least often.
  3. drifted: the list of feature names where recent differs from churn[features] according to a two-sample KS test with p < 0.01.

Data files: churn.csv

import numpy as np
import pandas as pd
from scipy.stats import ks_2samp
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import StratifiedKFold, cross_val_predict
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
model = make_pipeline(StandardScaler(), LogisticRegression())

# Next quarter's customers (simulated): a premium range lifted basket sizes and a returns policy change caused more tickets
rng = np.random.default_rng(3)
recent = churn[features].sample(150, random_state=2).copy()
recent["avg_order_value"] *= 1.6
recent["support_tickets"] += rng.integers(1, 3, len(recent))

# 1. Out-of-fold churn flags (threshold 0.5) for every customer, using cross_val_predict
flagged = None

# 2. Recall per segment (a Series indexed by segment), and the segment with the lowest recall
recall_by_segment = None
lowest_recall_segment = None

# 3. Columns of `recent` whose distribution differs from churn[features] (KS test p < 0.01), as a list
drifted = []
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • flagged holds out-of-fold 0/1 flags for all 398 customers
  • recall_by_segment has the recall of each segment
  • lowest_recall_segment names the weakest segment
  • drifted lists exactly the shifted columns