A deployed model does not fail loudly. Its code is unchanged, the service returns 200s within the latency budget, and yet its predictions slowly get worse because the world it was trained on has moved: new customers, new fraud tactics, a changed upstream data pipeline, seasonal behaviour, a new product. Model monitoring is the practice of detecting that degradation, ideally before users or the business notice, and drift detection is the subset that compares distributions over time. Interviewers probe it because it separates "I trained a model" from "I run a model": you need to know which signals are available immediately (input and prediction distributions), which arrive late (true labels), and how to avoid drowning the on-call engineer in false alarms. For a Backend or AI engineer, the recurring question is what do you watch when you cannot yet know if the model is right?
Model monitoring is keeping a continuous eye on a model after it has been deployed, the same way a company watches server uptime or error rates, except here the thing being watched is the quality of the model's predictions. Because a model can be technically "working" — fast, no crashes — while quietly getting things wrong more often, monitoring tracks things like how confident the model is, how often its answers match what actually happened, and whether the data it is seeing now looks different from the data it was trained on.
Drift detection is specifically about spotting when the world feeds a model has changed shape compared to when it was trained. If a model learned to recognize fraud from last year's transaction patterns, and this year's spending habits, devices, or scam tactics look different, that mismatch is "drift." Drift detection compares the statistical shape of new incoming data against the original training data so a team can catch this gap before it quietly erodes accuracy.
Picture a weather forecaster who trained their instincts on twenty years of local weather patterns. If the climate starts shifting — hotter summers, unusual storms — their old instincts quietly stop matching reality, even though nothing about how they make forecasts has changed. Model monitoring is checking the forecaster's accuracy over time, and drift detection is specifically noticing that today's weather patterns look statistically different from the years they originally learned from.
Notation: X = input features, y = true label, P = probability distribution
Data drift P(X) changes, P(y|X) same new user segment, new device, sensor recalibrated
(covariate shift) The model may still be right, just on unfamiliar inputs.
Concept drift P(y|X) changes "spam" or "fraud" behaviour evolves; the same features
now mean something different. The model is WRONG.
Label drift P(y) changes fraud rate 0.1% -> 0.4% (prior shift). Can come from a
(prior shift) real change or from a labelling policy change.
Training-serving same feature, computed training: SQL over the warehouse (UTC dates, nulls = 0)
skew differently offline vs online serving : Java service (local time, nulls = missing)
Not drift over time: it is a BUG present from day one.
what you can measure now what you can measure later
data drift input feature histograms (no labels needed)
concept drift needs labels or a good proxy accuracy, AUC, calibration once labels arrive
label drift needs labels (or labelled sample)
The distinction matters because the fix differs. Data drift can leave accuracy untouched if the model generalises, so drift alone is a reason to investigate, not automatically to retrain. Concept drift always hurts, and retraining on old data will not help: you need fresh labelled data that reflects the new relationship. Label drift changes the base rate, which shifts calibration and the optimal decision threshold even when the model's ranking ability is intact. Training-serving skew is the most common cause of "great offline metrics, poor online behaviour" and is best prevented structurally, by computing features from one shared definition, which is the job of a feature store, and by logging the exact features used at serving time so they can be compared with training data.
Real incidents are usually mixtures, and root-causing starts with the cheap questions: did an upstream schema, unit, or default value change (a pipeline bug looks like drift); is the drift confined to one segment; does it coincide with a deploy, a marketing campaign, or a holiday? See Data Pipelines & ETL for ML for where silent upstream changes come from.
PSI (Population Stability Index), computed per feature on binned data:
PSI = sum over bins of (actual% - expected%) * ln(actual% / expected%)
Worked example, 3 bins (ILLUSTRATIVE):
bin expected% actual% (a-e) ln(a/e) term
low 0.50 0.30 -0.20 -0.5108 0.1022
mid 0.30 0.30 0.00 0.0000 0.0000
high 0.20 0.40 +0.20 +0.6931 0.1386
PSI = 0.2408
Common rule of thumb (from credit-risk practice, not a law): < 0.1 stable,
0.1-0.25 investigate, > 0.25 significant shift.
KL(P||Q) = sum p * ln(p/q) asymmetric, infinite if q = 0 where p > 0
JS(P,Q) = (KL(P||M) + KL(Q||M)) / 2, M = (P+Q)/2 symmetric, finite, max ln 2 = 0.693 (natural log)
KS statistic D = max over x of | CDF_1(x) - CDF_2(x) | (two-sample, continuous features; no binning)
PSI is popular because it is simple, symmetric (it is a sum of both directions of KL), needs no distributional assumption, and yields one number per feature that is easy to threshold and plot. Its weaknesses: it depends on the binning (deciles from the reference data are the usual choice), it needs a small epsilon for empty bins because of the logarithm, and its 0.1/0.25 thresholds are conventions rather than statistical tests. KL divergence is asymmetric and blows up when the current data has values in regions where the reference had none, which is why JS divergence (bounded, symmetric) is often preferred for monitoring. The KS test is a proper hypothesis test for continuous numeric features with no binning, but it is insensitive to categorical data and only tells you the maximum CDF gap. For categorical features, PSI or JS on category frequencies, or a chi-squared test, are typical; for text and embeddings, teams often monitor summary statistics, cluster assignments, or the distance between mean embeddings, with the caveat that these are heuristics.
A subtle trap: statistical significance is not practical significance. With enough data, KS flags shifts too small to matter, so you must combine a test or distance with a minimum effect size and, ideally, with the model's sensitivity to that feature (importance-weighted alerts). The demo below shows this effect with real numbers. Multivariate drift (correlations between features change while every marginal stays the same) is invisible to per-feature checks; approaches such as training a classifier to distinguish reference from current data exist but cost more, so per-feature checks plus prediction-distribution checks remain the practical baseline.
Event timeline for a loan-default model (ILLUSTRATIVE delays):
t=0 t=seconds t=days t=90+ days
request ──► prediction logged ──► user reaction ──► true label (defaulted / not)
│ (click, edit, │
│ complaint) ▼
▼ │ accuracy, AUC, calibration
what you can watch NOW: ▼ = the metric you actually care about,
- input feature drift weak/biased proxies but months late
- prediction distribution
- confidence / entropy
- business KPIs (approval rate)
Join key: request_id logged at prediction time, so the label can be joined back later.For many models the truth arrives late (loan default, churn, fraud chargebacks, whether a recommendation led to a purchase) or never (nobody labels most search results). So monitoring has to stand on proxies until labels land. The proxies form a ladder: input drift is fastest but least specific; prediction drift (the score distribution or the positive rate moving) is a stronger signal because it is what the model actually produced; business outcomes such as approval rate, click-through, or complaint rate are closer to the real goal but noisy and confounded by product changes; and finally true labels give accuracy, precision and recall, AUC, and calibration. Two practical rules: always log a request id and the model version with every prediction so labels can be joined later, and label a small random sample early (human annotation, or a delayed but deterministic outcome) to get a partial ground truth quickly instead of waiting for all labels. Beware feedback loops and selection bias: if the model's decisions determine which outcomes you observe (you only see repayment for loans you approved), the labels you get are not a random sample.
import math, random
random.seed(11)
def ks_stat(a, b):
a, b = sorted(a), sorted(b)
i = j = 0
d = 0.0
while i < len(a) and j < len(b):
if a[i] <= b[j]: i += 1
else: j += 1
d = max(d, abs(i / len(a) - j / len(b)))
return d
n = 500
crit = 1.358 * math.sqrt(2 / n) # approx. KS critical D, alpha = 0.05
features, checks = 100, 10 # 100 features, 10 daily checks, NO real drift anywhere
ref = [[random.gauss(0, 1) for _ in range(n)] for _ in range(features)]
total = 0
for day in range(1, checks + 1):
alerts = sum(ks_stat(ref[f], [random.gauss(0, 1) for _ in range(n)]) > crit for f in range(features))
total += alerts
print(f"day {day:2d}: {alerts} of {features} features alerted (all false alarms)")
print(f"average false alerts per day: {total / checks:.1f} (expected about {0.05 * features:.0f} at alpha=0.05)")
# Output:
# day 1: 7 of 100 features alerted (all false alarms)
# day 2: 4 of 100 features alerted (all false alarms)
# day 3: 5 of 100 features alerted (all false alarms)
# day 4: 7 of 100 features alerted (all false alarms)
# day 5: 4 of 100 features alerted (all false alarms)
# day 6: 6 of 100 features alerted (all false alarms)
# day 7: 4 of 100 features alerted (all false alarms)
# day 8: 3 of 100 features alerted (all false alarms)
# day 9: 8 of 100 features alerted (all false alarms)
# day 10: 4 of 100 features alerted (all false alarms)
# average false alerts per day: 5.2 (expected about 5 at alpha=0.05)
This simulation contains no drift at all, yet a per-feature KS test at the usual 5% level fires about 5 alerts a day across 100 features, because that is what "5% false-positive rate per test" means when you run many tests. Across 100 independent features the chance of at least one false alert on a given day is 1 - 0.95100, about 99%. With hundreds or thousands of features, naive per-feature alerting guarantees alert fatigue, and an on-call engineer who sees noise every morning stops looking, which is worse than having no alerts. Ways to reduce it: alert on a small set of high-importance features plus aggregate signals (share of features drifted, prediction-distribution drift); require a minimum effect size, not just a p-value; use a stricter significance level or a multiple-testing correction; require persistence (drift on three consecutive windows) or use a rolling baseline; use compare-to-same-hour-last-week baselines to handle seasonality; and split severities so that only actionable conditions (data pipeline broke, null rate jumped, prediction distribution collapsed) page a human while the rest go to a dashboard or a weekly report.
Retraining trigger options
schedule-based retrain every N days simple, predictable; may retrain needlessly or too late
data-volume after N new labelled examples matches label availability
drift-based PSI/KS or prediction drift fast, but drift does not always mean worse performance
performance-based accuracy/AUC below threshold the real signal, but labels are delayed
Typical safe loop: trigger -> retrain -> evaluate vs current model on fresh holdout
-> shadow / canary -> promote or roll back (never auto-deploy blindly)
LLM application signals (no classic label; measure behaviour):
quality LLM-as-judge or human-rated sample; task success; thumbs up/down; edit rate
groundedness (RAG) answer supported by retrieved context? retrieval recall on a fixed test set
refusal rate share of "I can't help with that" -> rises after a prompt/model update or filter change
token usage input/output tokens per request -> cost and latency driver; watch p50 and p99
latency time to first token, tokens/second, end-to-end
safety guardrail block rate, flagged outputs per 1,000 requests
input drift topic/intent mix, language mix, average prompt length, embedding cluster shares
Retraining triggers are a cost-versus-risk decision. A fixed schedule is easy to operate and audit but ignores what is happening; drift-based triggers respond faster but can retrain in reaction to harmless drift; performance-based triggers are the most meaningful but bounded by label delay. Most mature setups combine a schedule as a floor, drift and proxy alerts as early warning that triggers investigation, and a gated pipeline (evaluate against the incumbent on fresh data, then shadow or canary rollout with automatic rollback) so that a retrain cannot silently make things worse. Automation is described in ML Model Lifecycle & CI/CD, and reproducibility relies on data and model versioning.
LLM applications change the picture. You usually do not retrain the model at all; "drift" comes from the provider silently updating a model behind an alias, from your own prompt or retrieval changes, from a changing corpus, or from users asking new kinds of questions. There is often no ground-truth label, so quality is measured with a fixed evaluation set run on every change, sampled human review, LLM-as-judge scoring (itself imperfect and biased, so calibrate it against human ratings), and user feedback. Behavioural metrics are cheap early warnings: a jump in refusal rate can mean an over-eager safety filter, a drop in average output tokens can mean truncated or lazy answers, and a rise in tokens per request is a direct cost increase (Cost Optimization). Pin explicit model versions where the provider allows it, and treat a version change like any deployment. The tracing needed to compute these metrics is covered in Observability for AI Systems.
Standard-library Python: PSI, JS divergence, and a KS statistic computed on seeded synthetic data (Gaussian samples), covering no drift, a mean shift, a variance change, and a large shift, plus a tiny shift on a very large sample to show statistical versus practical significance. Bin edges are deciles of the training data. This is a teaching implementation, not a replacement for a vetted library.
import math, random
random.seed(7)
N = 5000
train = [random.gauss(50, 10) for _ in range(N)] # feature at training time
same = [random.gauss(50, 10) for _ in range(N)] # fresh sample, no drift
shifted = [random.gauss(55, 10) for _ in range(N)] # mean shift (+0.5 sd)
widened = [random.gauss(50, 16) for _ in range(N)] # variance change
shifted2 = [random.gauss(65, 10) for _ in range(N)] # large shift (+1.5 sd)
def quantile_edges(data, bins=10):
s = sorted(data)
return [s[int(len(s) * i / bins)] for i in range(1, bins)] # decile cut points from TRAIN
def bin_fracs(data, edges, eps=1e-4):
counts = [0] * (len(edges) + 1)
for x in data:
i = 0
while i < len(edges) and x > edges[i]:
i += 1
counts[i] += 1
return [max(c / len(data), eps) for c in counts] # eps avoids log(0)
def psi(expected, actual, bins=10):
edges = quantile_edges(expected, bins)
e, a = bin_fracs(expected, edges), bin_fracs(actual, edges)
return sum((ai - ei) * math.log(ai / ei) for ei, ai in zip(e, a))
def kl(p, q):
return sum(pi * math.log(pi / qi) for pi, qi in zip(p, q))
def js(expected, actual, bins=10):
edges = quantile_edges(expected, bins)
p, q = bin_fracs(expected, edges), bin_fracs(actual, edges)
m = [(pi + qi) / 2 for pi, qi in zip(p, q)]
return 0.5 * kl(p, m) + 0.5 * kl(q, m) # natural log; max is ln 2 = 0.693
def ks_stat(a, b):
a, b = sorted(a), sorted(b)
i = j = 0
d = 0.0
while i < len(a) and j < len(b):
if a[i] <= b[j]: i += 1
else: j += 1
d = max(d, abs(i / len(a) - j / len(b))) # max gap between the two empirical CDFs
return d
def ks_critical(n, m, c=1.358): # approx. critical D at alpha=0.05
return c * math.sqrt((n + m) / (n * m))
print(f"KS critical D (alpha=0.05, n=m={N}): {ks_critical(N, N):.4f}")
print(f"{'scenario':<22}{'PSI':>8}{'JS':>8}{'KS D':>8} verdict")
for name, cur in [("no drift", same), ("mean +0.5 sd", shifted), ("variance x1.6", widened), ("mean +1.5 sd", shifted2)]:
p = psi(train, cur)
verdict = "stable" if p < 0.1 else ("investigate" if p < 0.25 else "significant")
print(f"{name:<22}{p:8.4f}{js(train, cur):8.4f}{ks_stat(train, cur):8.4f} {verdict}")
# Sample-size effect: same tiny shift, more data => KS 'significant' but PSI stays small
tiny_a = [random.gauss(50.0, 10) for _ in range(200000)]
tiny_b = [random.gauss(50.4, 10) for _ in range(200000)]
print(f"\ntiny shift (+0.04 sd), n=200000 each: PSI={psi(tiny_a, tiny_b):.4f} "
f"KS D={ks_stat(tiny_a, tiny_b):.4f} critical={ks_critical(200000, 200000):.4f}")
# Output:
# KS critical D (alpha=0.05, n=m=5000): 0.0272
# scenario PSI JS KS D verdict
# no drift 0.0097 0.0012 0.0230 stable
# mean +0.5 sd 0.2448 0.0299 0.2000 investigate
# variance x1.6 0.2331 0.0287 0.1140 investigate
# mean +1.5 sd 1.9856 0.2066 0.5490 significant
#
# tiny shift (+0.04 sd), n=200000 each: PSI=0.0015 KS D=0.0166 critical=0.0043
Observe: the "no drift" row sits below the 0.1 PSI line and its KS distance 0.023 is below the critical 0.0272. A half-standard-deviation mean shift lands in the "investigate" band, and a variance-only change is also flagged although the mean is identical, which a simple mean-and-count monitor would miss. The last line is the trap: a 0.04 standard deviation shift is far above KS's critical value at n = 200,000 (0.0166 versus 0.0043) yet PSI is only 0.0015, so a pure p-value alert would page you for a change that probably does not matter to the model.
Q: What is the difference between data drift and concept drift, and why does it matter?
A: Data drift means the input distribution P(X) changed; concept drift means the relationship P(y|X) changed. Data drift may not hurt accuracy if the model still generalises to the new inputs, so it triggers investigation, while concept drift always degrades the model and needs fresh labelled data, because retraining on the old relationship does not fix it. The practical consequence is that input monitoring alone is insufficient: you also need outcome or proxy metrics.
Q: What is training-serving skew and how do you prevent it?
A: It is a mismatch between how features are computed for training and how they are computed at serving, such as different null handling, time zones, or aggregation windows, and it usually causes a gap between offline and online performance from the very first day. Prevent it by using a single feature definition for both paths (a feature store or a shared library), by logging the exact served features, and by comparing training and serving feature distributions. It is a bug to fix, not drift to monitor.
Q: How do you monitor a model when the true labels arrive weeks later?
A: Watch what you can see immediately: input feature drift and data quality, the prediction and score distribution, confidence, and downstream business KPIs. Log a request id and model version with every prediction so labels can be joined when they arrive, and label a small random sample early to get quick partial ground truth. The trade-off is that proxies are less specific, so a proxy alert triggers investigation and confirmation rather than automatic action.
Q: Explain PSI and how you would interpret a value of 0.3.
A: PSI bins a feature (usually into deciles of the reference data) and sums (actual% - expected%) times the log of their ratio across bins, which measures how far the current distribution moved from the reference. By the common credit-risk convention, below 0.1 is stable, 0.1 to 0.25 is worth investigating, and above 0.25 indicates a significant shift, so 0.3 is a strong signal, though I would check which bins moved, whether the feature matters to the model, and whether the cause is a pipeline change before retraining. The thresholds are a rule of thumb, and PSI depends on binning and on the epsilon used for empty bins.
Q: When would you use KS versus PSI versus JS divergence?
A: KS suits continuous numeric features and gives a p-value without binning, but it says nothing on categoricals and becomes over-sensitive at large sample sizes. PSI is a simple, interpretable per-feature score with familiar thresholds, popular in finance, at the cost of binning sensitivity. JS divergence is a symmetric, bounded version of KL that handles distributions with mismatched support better, so it is a good default for comparing category frequencies or embedding-cluster mixes.
Q: What do you monitor for an LLM-based feature, where you never retrain and have no labels?
A: Quality on a fixed evaluation set run on each change, a sampled human or judge-scored review of live traffic, user feedback, refusal and guardrail-block rates, token usage and latency (time to first token, p99), groundedness for RAG, and input mix shifts such as topics or languages. I also track which model and prompt version served each request, because provider updates and prompt edits are the real sources of change. LLM-as-judge scores are useful but noisy, so I calibrate them against human labels.
Q: How do you decide when to retrain?
A: Combine a scheduled floor with drift and proxy alerts as early warnings and performance metrics as confirmation once labels arrive. Retraining costs compute, review time, and rollout risk, so drift alone should trigger investigation, and any retrained model must beat the incumbent on a fresh holdout and pass a shadow or canary stage before promotion. The decision also depends on how fast the domain moves: fraud may need weekly retraining, while a stable domain may need quarterly.
Follow-up: You have 500 features and daily drift alerts fire on 20-30 of them, mostly nothing. How do you fix the alerting?
A: Run the arithmetic: at a 5% significance level, 500 independent tests produce about 25 false alerts a day, so this is expected noise. Restrict paging to the most important features and to aggregates (fraction of features drifted, prediction-distribution shift), add an effect-size threshold and a persistence requirement, use a stricter or corrected significance level, and move the rest to a dashboard. Then measure precision of alerts over a few weeks (how many led to an action) and tune from that.
Follow-up: Accuracy dropped but every input feature's PSI is below 0.1. What could explain it?
A: Concept drift, where P(y|X) changed while P(X) did not, is the first suspect, as is label drift or a labelling policy change. Other candidates are multivariate drift (correlations changed while marginals held), drift concentrated in a small but important segment that averages out, a bug in the label join, or a data-quality issue in features not being monitored. I would slice performance by segment and time, compare the prediction distribution and the base rate, and check the labelling process.
Follow-up: How do you avoid a retraining pipeline making the model worse when it triggers automatically?
A: Gate promotion: evaluate the candidate against the current model on a fresh holdout, including per-segment and calibration checks, then roll out through shadow or canary with automatic rollback criteria, and keep the previous model version immediately available. Beware training on data polluted by the model's own decisions (feedback loops) or by a temporary anomaly, and cap how often automatic retraining can promote without human sign-off for high-stakes models.
In an ML platform design, the monitoring path is a side pipeline: the serving layer emits a prediction log (request id, model version, features or a hash, score, timestamp) to a stream or queue (Message Queues & Event Streaming), a batch or streaming job aggregates windowed statistics, a metrics store and dashboard hold the results, and a label-join job later attaches outcomes. Do the capacity estimate with stated assumptions: at 1M predictions per day with 50 features logged at roughly 500 bytes per record (ILLUSTRATIVE), raw logs are about 500 MB per day, or roughly 15 GB per month before compression, which is small, but at 100M predictions per day it is 50 GB per day, so you sample (for example 1-10%) or store only aggregates and histograms rather than raw rows. Statistics are cheap to compute on histograms (fixed bin counts per feature per window merge additively), which is why monitoring systems keep binned sketches rather than raw data. The pipeline should be non-blocking for serving (asynchronous logging so a broken monitor never adds request latency), observe privacy constraints on logged features, and version reference profiles along with models. For LLM systems, the same design records prompt and completion metadata, token counts, and guardrail outcomes, with sampled full text stored under stricter access controls.
Monitoring more features, more often, at finer granularity catches problems sooner but costs storage and compute and, above all, produces false alarms and alert fatigue; monitoring less saves money and attention but lets silent degradation persist for weeks. Drift-based triggers are fast but only correlate with performance loss, performance-based triggers are the truth but delayed, and schedule-based retraining is simple but blind. Sensitive thresholds catch small shifts and page too often; loose thresholds miss slow drift that accumulates below the threshold each day, so compare against a fixed reference as well as a rolling baseline. Statistical tests are wrong on their own when sample sizes are large, and PSI or JS thresholds are conventions that you should calibrate against past incidents in your own system. It is the wrong choice to build elaborate drift dashboards without an owner and a runbook for what to do when an alert fires, or to retrain automatically on data the model itself influenced; a small set of well-chosen, actionable signals with clear ownership generally beats a large wall of charts.