A map that used to be right
Open a maps app to get from your house to a friend's place across town. Two years ago it sent you down a particular road. Today, it sends you a different way — because a new flyover opened, an old road became one-way, and a stretch near a metro construction site keeps flooding in the monsoon. The app's code did not change. Its instructions for "how to compute a route" are exactly what they were. What changed is the world the app is making decisions about. If the app still trusted its two-year-old map of the city, every route it gave you today would be a little bit wrong, and some would be badly wrong.
A trained machine learning model has exactly this problem, and it is far more common than most people expect. A model is not a living, adapting thing — once training finishes, it is a fixed function. You give it an input, it applies the same fixed rule, and it gives you an output, forever, unless someone intervenes. But the world the model was trained to describe keeps moving. Prices change, slang changes, fraud tactics change, festival shopping patterns change, even handwriting styles submitted through a scanner app change. A model frozen in the past, applied to a present that has moved on, is exactly like that two-year-old map: confidently wrong. This slow mismatch between "what the model learned" and "what is actually happening now" is called model drift, and detecting it early — before it quietly wrecks real decisions — is one of the most important jobs in running any deployed ML system. This chapter builds, from first principles and with real code, the tools to catch it.
From maps to models: what "drift" actually means
Formally, a trained model is a function learned from a training dataset that was collected during some window of time — say, all of January. That dataset is a snapshot: it captures what inputs typically looked like in January, and what the correct output was for each of them. The model's entire "knowledge" is baked out of that one snapshot. Drift is what happens when reality, after deployment, no longer matches that snapshot. There are two genuinely different ways this can happen, and confusing them is a common source of bugs in real ML systems, so it is worth separating them carefully.
- Data drift (also called covariate shift): the inputs the model sees start looking different from the inputs it was trained on, even though the underlying rule connecting input to correct output hasn't changed. Example: a model trained on typical-sized UPI transactions suddenly starts seeing a wave of much larger transactions during a festival sale. The rule "large + odd-time + new payee = suspicious" might still be perfectly valid — but the model has never seen inputs shaped like this, so its confidence and accuracy on this new shape of data become unreliable.
- Concept drift: the relationship between input and correct output itself changes. The inputs might look statistically identical to before, but what used to be the right answer is no longer the right answer. Example: last year, a certain pattern of SMS-based OTP requests reliably meant fraud. This year, scammers switched to fake customer-care calls with a completely different input signature, while genuine users started behaving in new ways too (more UPI Lite micro-transactions, more QR scans at small vendors). The old input-to-fraud mapping the model learned is now stale, independent of whether the raw numbers "look normal."
Data drift is something you can often notice immediately, just by watching the inputs. Concept drift is sneakier — you can only see it clearly once you know whether the model's predictions were actually right, which usually takes time to find out. Continuous monitoring has to deal with both.
Worked example: watching a fraud-detection model week by week
Suppose, for a school project, you build a classifier that labels each UPI transaction as fraud or not fraud. Trained on January data, it scores 92% accuracy on a held-out test set before you deploy it. That 92% is your baseline — the number you'll keep comparing against. Every week after deployment, once enough transactions get their true fraud/not-fraud status confirmed (say, from customer disputes and bank reports), you compute that week's real-world accuracy. Here are eight weeks of numbers:
baseline_accuracy = 0.92
weekly_accuracy = [0.91, 0.90, 0.88, 0.85, 0.80, 0.77, 0.74, 0.69]
threshold = 0.10 # we allow at most a 10 percentage-point drop
for week, acc in enumerate(weekly_accuracy, start=1):
drop = baseline_accuracy - acc
status = "ALERT: drift detected" if drop > threshold else "OK"
print(f"Week {week}: accuracy = {acc*100:.0f}%, drop = {drop*100:.0f} pts -> {status}")
Trace this by hand, the way you would trace any loop: enumerate(..., start=1) pairs each accuracy with a week number starting at 1, so the loop runs eight times. For each week, drop is baseline minus that week's accuracy — how many percentage points accuracy has fallen. Weeks 1–4 give drops of 1, 2, 4, and 7 points: all comfortably under the 10-point threshold, so status stays "OK". Week 5 gives 0.92 − 0.80 = 0.12, a 12-point drop — that crosses the threshold, so the printed line becomes "Week 5: accuracy = 80%, drop = 12 pts -> ALERT: drift detected". Weeks 6, 7, and 8 (drops of 15, 18, and 23 points) keep alerting, each one worse than the last. The important design decision here is the if / else inside a Python conditional expression: it turns a raw number into a binary decision a dashboard or an on-call engineer can act on immediately, rather than making a human stare at a shrinking percentage and guess whether it's "bad enough yet."
Here is that same trace as a picture — accuracy on the vertical axis, weeks along the bottom, with the baseline and the alert threshold both drawn in:
Notice that the line crosses the red dashed threshold between week 4 and week 5 — exactly where the code's alert fired. This is the simplest possible drift detector: pick a baseline, pick a tolerance, and raise a flag the moment reality falls outside it. It is crude, but it is also the foundation everything more advanced is built on.
Data drift: when the inputs themselves change
Accuracy monitoring has one big limitation: you can only compute it once you know the true labels, and for fraud, that can take days or weeks (a customer has to notice and report the fraudulent transaction). So a second, faster signal is useful: watch the inputs themselves, without needing to know if any prediction was right or wrong. If the shape of incoming data has clearly shifted away from what the model was trained on, that's a warning sign worth raising immediately, even before accuracy numbers are available.
Suppose your training data had these five sample transaction amounts (in rupees): 500, 700, 900, 750, 650. Now suppose it's Diwali sale week, and the five most recent transactions are: 1200, 2500, 1800, 3000, 2000. Let's compute the mean of each by hand, the way you'd do it for any average:
Training mean = (500 + 700 + 900 + 750 + 650) ÷ 5 = 3500 ÷ 5 = ₹700
Live mean = (1200 + 2500 + 1800 + 3000 + 2000) ÷ 5 = 10500 ÷ 5 = ₹2100
To express this as a "how big is the shift" number, we compute percentage change:
training_amounts = [500, 700, 900, 750, 650]
live_amounts = [1200, 2500, 1800, 3000, 2000]
def mean(values):
return sum(values) / len(values)
baseline_mean = mean(training_amounts)
current_mean = mean(live_amounts)
percent_change = (current_mean - baseline_mean) / baseline_mean * 100
print(f"Baseline mean: Rs {baseline_mean:.0f}")
print(f"Current mean: Rs {current_mean:.0f}")
print(f"Change: {percent_change:.1f}%")
Tracing it: sum(training_amounts) is 3500, divided by 5 gives baseline_mean = 700.0. sum(live_amounts) is 10500, divided by 5 gives current_mean = 2100.0. Then percent_change = (2100 − 700) / 700 × 100 = 1400 / 700 × 100 = 2.0 × 100 = 200.0. The program prints Baseline mean: Rs 700, Current mean: Rs 2100, and Change: 200.0%. A 200% increase means the average transaction size has exactly tripled — this is a large, unmistakable data drift signal, and it would be visible the moment the week's transactions come in, days before you'd have enough confirmed fraud labels to compute a real accuracy number.
Here's the same kind of shift shown as a distribution, comparing what fraction of transactions fall into each amount range in training data versus this week's live data:
The blue bars (training) are tallest on the left and shrink to almost nothing on the right; the orange bars (live) do the opposite. That crossing pattern — mass moving from one end of the distribution to the other — is the visual signature of data drift. Note carefully what this diagram does not tell you: it doesn't say the model is wrong. It only says the model is now operating on inputs it has never really seen before, which makes its past accuracy numbers a less trustworthy guide to how it's doing right now.
Why one bad week isn't proof — and why waiting too long isn't safe either
Real-world accuracy numbers are noisy from week to week even with no drift at all — a slightly unlucky batch of hard cases can knock a couple of points off just by chance. If your alert rule reacts to every single week's number, you'll get false alarms that train everyone to ignore the alerts (this is a real failure mode in monitoring systems, sometimes called "alert fatigue"). The standard fix is to monitor a rolling average over the last few weeks instead of any single week in isolation:
from collections import deque
class RollingAccuracyMonitor:
def __init__(self, window_size=4, threshold=0.10, baseline=0.92):
self.window = deque(maxlen=window_size)
self.threshold = threshold
self.baseline = baseline
def log_week(self, accuracy):
self.window.append(accuracy)
avg = sum(self.window) / len(self.window)
if self.baseline - avg > self.threshold:
print("ALERT")
else:
print("OK")
deque(maxlen=4) is a list-like queue that automatically forgets its oldest entry once it holds more than 4 items — a clean way to keep "only the last 4 weeks" without manually slicing a list every time. Let's trace it, week by week, using the same eight accuracy values as before (0.91, 0.90, 0.88, 0.85, 0.80, 0.77, 0.74, 0.69), computing each rolling average by hand:
- Week 1: window = [0.91], average ≈ 0.910, gap from baseline ≈ 0.010 → OK
- Week 2: window = [0.91, 0.90], average ≈ 0.905, gap ≈ 0.015 → OK
- Week 3: window = [0.91, 0.90, 0.88], average ≈ 0.897, gap ≈ 0.023 → OK
- Week 4: window = [0.91, 0.90, 0.88, 0.85], average = 0.885, gap = 0.035 → OK
- Week 5: oldest (0.91) drops off; window = [0.90, 0.88, 0.85, 0.80], average = 0.8575, gap = 0.0625 → still OK
- Week 6: window = [0.88, 0.85, 0.80, 0.77], average = 0.825, gap = 0.095 → still OK, but close
- Week 7: window = [0.85, 0.80, 0.77, 0.74], average = 0.79, gap = 0.13 → ALERT
- Week 8: window = [0.80, 0.77, 0.74, 0.69], average = 0.75, gap = 0.17 → ALERT
Compare this to the single-week rule from earlier, which alerted at week 5. The rolling-average version doesn't alert until week 7 — a full two weeks later. This is a genuine trade-off, not a flaw to be "fixed" away: a wider averaging window filters out noise and gives you fewer false alarms, but it also reacts more slowly to a real, sustained drop, because a handful of good weeks mixed into the average can mask a bad trend for a while. A narrower window (say, window_size=1, which is just the single-week rule) is maximally sensitive but maximally jumpy. Choosing the window size is choosing how much you're willing to trade early warning for stability — there's no universally correct number, only a decision that depends on how costly a missed drift is versus how costly a false alarm is.
Common misconception
A trap many beginners fall into: "If my model's code has no bugs and I haven't touched it, it will keep performing exactly as well as it did on launch day." This confuses two completely different things — the correctness of your program and the correctness of your program's assumptions about the world. Your code can be bug-free, well-tested, and completely unchanged, and the model can still degrade badly, because the mismatch isn't in the code — it's between the frozen training snapshot inside the model and a world that keeps moving. A model is not like a calculator, which will correctly compute 7 × 8 forever regardless of what year it is. A model is closer to a weather forecast from three months ago: it was accurate for the conditions it was built on, and it has no way of knowing the conditions have changed unless something outside the model tells it so. That "something" is exactly what monitoring provides.
Putting it together: a two-signal monitor
A production monitoring system typically combines both signals — the slow-but-certain accuracy signal, and the fast-but-indirect data drift signal — and treats them with different urgency, since a confirmed accuracy drop is much stronger evidence of a real problem than an input shift that might turn out to be harmless:
class DriftMonitor:
def __init__(self, baseline_accuracy, baseline_feature_mean,
accuracy_threshold=0.10, drift_threshold=0.30):
self.baseline_accuracy = baseline_accuracy
self.baseline_feature_mean = baseline_feature_mean
self.accuracy_threshold = accuracy_threshold
self.drift_threshold = drift_threshold
def check(self, current_accuracy, current_feature_mean):
acc_drop = self.baseline_accuracy - current_accuracy
feature_change = abs(current_feature_mean - self.baseline_feature_mean) / self.baseline_feature_mean
if acc_drop > self.accuracy_threshold:
return "CRITICAL: model accuracy has dropped -- retrain now"
elif feature_change > self.drift_threshold:
return "WARNING: input data has shifted -- watch closely"
else:
return "OK"
monitor = DriftMonitor(baseline_accuracy=0.92, baseline_feature_mean=700)
print(monitor.check(current_accuracy=0.90, current_feature_mean=2100))
print(monitor.check(current_accuracy=0.80, current_feature_mean=750))
Trace the two calls separately. First call: current_accuracy=0.90, so acc_drop = 0.92 − 0.90 = 0.02, which is not greater than 0.10 — the first branch fails. Then feature_change = abs(2100 − 700) / 700 = 1400 / 700 = 2.0, which is greater than 0.30, so the function returns "WARNING: input data has shifted -- watch closely". That's the first printed line. Second call: current_accuracy=0.80, so acc_drop = 0.92 − 0.80 = 0.12, which is greater than 0.10 — the if branch fires immediately and the function returns "CRITICAL: model accuracy has dropped -- retrain now", without even evaluating the feature check (that's what the elif guarantees — Python never checks it, because the accuracy branch already returned). Notice the deliberate ordering: accuracy drop is checked first and labeled CRITICAL, because it's backed by confirmed ground truth; feature drift is checked second and labeled only WARNING, because a shift in inputs is a hint, not proof, that something's wrong.
The ground-truth delay problem
There's one more practical wrinkle worth naming explicitly, because it's what makes continuous monitoring an engineering problem and not just a one-time statistics calculation. For many real systems, you don't find out whether a prediction was correct until well after you made it. A loan-default model won't know if a borrower actually defaults until months later. A fraud model often waits on a customer complaint or a bank's investigation. This means the "accuracy" signal is always monitoring the past — by the time week 5's true accuracy is fully known, it might already be week 7, and the model has been running unmonitored (in the accuracy sense) the whole time. This is exactly why the faster, label-free signal — watching the input data itself for drift — matters so much in practice: it's the only warning you get in real time, while you wait for the slower, more trustworthy accuracy signal to catch up and confirm (or clear) the alarm. A well-built monitoring pipeline logs every prediction with a timestamp the moment it's made, logs the true outcome separately whenever it eventually arrives, and reconciles the two later — treating "we don't have the true label yet" as a normal, expected state rather than a gap in the system.
Practice: check your understanding
- A weather model trained on ten years of Chennai monsoon data is deployed elsewhere in Tamil Nadu without retraining. Rainfall patterns there are structurally different (different terrain, different monsoon timing). Is this primarily data drift, concept drift, or neither — and why?
- A model's baseline accuracy is 88%. This week's confirmed accuracy is 81%. Using a 10-percentage-point alert threshold like the one in this chapter's first example, does this alert? Show the subtraction.
- Two students each build a rolling-average monitor: Student A uses
window_size=2, Student B useswindow_size=8. Whose monitor will alert sooner after a sudden, sustained accuracy drop — and whose will be more resistant to a single noisy week? Explain in one sentence each. - Explain, in your own words, why a data drift alert (input distribution changed) is weaker evidence of a real problem than an accuracy drift alert (confirmed predictions got worse) — even though the data drift alert usually arrives first.
Answers to check yourself: (1) This is data drift, not concept drift — the physical rule connecting weather features to rainfall outcomes hasn't changed, but the model is now seeing an input distribution (terrain, timing) unlike anything in its Chennai-only training data. (2) Drop = 88 − 81 = 7 percentage points, which is under the 10-point threshold, so this would print OK — even though 81% might still feel concerning, which is exactly why threshold choice matters. (3) Student A's 2-week window reacts faster to a sustained drop (fewer old good weeks diluting the average) but is jumpier on noisy single weeks; Student B's 8-week window is much steadier but takes longer to notice a real, sustained problem. (4) Because a data drift alert only tells you the inputs look unfamiliar — the model could still be making perfectly correct predictions on that unfamiliar data; an accuracy drift alert is checked against actual confirmed outcomes, so it directly proves the model is now getting things wrong, not just that the world looks different.
Summary
A trained model is a frozen function built from a snapshot of the world; drift is what happens when the present stops matching that snapshot. Data drift means the inputs themselves have shifted shape (measurable immediately, by comparing statistics like the mean of a feature before and after deployment); concept drift means the correct input-to-output relationship has changed (only confirmable once true outcomes are known, by comparing accuracy against a baseline). Threshold-based detectors turn either signal into an actionable alert by comparing a current number against a baseline and a tolerance; rolling-window averages trade detection speed for stability against single noisy weeks — there is no free choice here, only a deliberate one. Because ground truth for accuracy often arrives late, real monitoring pipelines lean on fast, label-free data drift checks as an early warning while waiting for the slower, more conclusive accuracy signal to confirm whether retraining is actually needed. None of this requires the model's code to have a bug — drift is a property of the changing world, not of broken software, which is precisely why it can't be caught by testing the code once and walking away; it has to be watched continuously.
Think About It
Think about this: How would you explain model drift detection and continuous monitoring to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.