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.
- 1Where bias comes from
- 2Auditing a model group by group
- 3Choosing what “fair” means
- 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.
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.
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:
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.