, ,

Monitoring Machine Learning Models in Production: Drift, Decay and Silent Failure (Data Science Series, Part 25)

A model that breaks loudly gets fixed on Tuesday. A model that decays quietly gets fixed after a quarter of wasted budget. Here is how I monitor drift, estimate performance before labels arrive, and set thresholds nobody mutes.

Data Science Series · Part 25 of 30

Key takeaways

Models rarely crash. They decay, and a decaying model returns a valid number for every request, which is why nothing pages anyone.

Four separate things can go wrong after deployment: data quality, covariate drift, prediction drift, and concept drift. Only the last one changes what a correct answer even means, and feature drift checks cannot see it.

Feature drift is a proxy signal. Estimated performance is a better one, and realised performance against real labels is the only ground truth you get.

Alert volume decides whether monitoring survives. A monitor that fires eight times a morning gets muted in three weeks and then you have nothing.

A retention lead asked me a question in a review that I could not answer on the spot: how would we know if the churn model stopped working? We had dashboards. We had a nightly scoring job with a Slack notification when it failed. What we did not have was any way to distinguish a model producing good predictions from a model producing confident nonsense, because both look identical from outside. Both return a probability between zero and one for every customer, on time, with no errors in the log.

That is the difference between software failure and model failure. Software fails loudly: a null pointer, a timeout, a 500. Machine learning fails silently, and it fails on a timescale of weeks, which is long enough that by the time somebody notices, the business has already spent real money acting on bad scores. Monitoring is the discipline of shortening that gap from a quarter to a week.

Who this is for: you have a model serving predictions somewhere, from a nightly batch job to a live endpoint, and no monitoring beyond a job success check. No observability tooling is assumed. You should be comfortable with the serving patterns from Part 22, the tracking and registry setup from Part 24, and evaluation metrics from Part 11. Distribution comparison at the level of exploratory data analysis in the Data Analyst Series is enough statistics to follow along. Every term is defined on first use.

Cost of a model that fails quietly

Put a number on it before arguing about tooling. Our churn model scored about 7,000 active accounts a month and the retention team contacted the top decile, roughly 700 customers, with an offer costing about 40 dollars in margin each. When the model was working, that campaign converted well enough to pay for itself twice over. When the model was ranking badly, we were still spending 28,000 dollars a month, just aimed at people who were never going to leave.

Nobody in that chain had a reason to raise a flag. Marketing saw campaigns going out on schedule. Engineering saw a green pipeline. Finance saw the same line item as last quarter. Silent failure is not a technical curiosity, it is a governance hole, and it costs whatever the downstream action costs multiplied by however long it takes somebody to get suspicious.

War story

My worst one ran for nine weeks. A churn model held an area under the ROC curve, or AUC, of 0.86 for four straight months, then slid to 0.71 without one alert firing. Every monitor we owned watched input distributions, and the inputs had barely moved. What moved was the label. A pricing change in March meant customers now churned at contract renewal rather than mid term, so the same feature values pointed at a different outcome. That is concept drift, and feature drift checks are structurally blind to it. By the time a quarterly campaign review surfaced the conversion drop, we had aimed roughly 14 weeks of retention budget at the wrong 30 percent of the base. Cost of the fix was two days. Cost of the delay was six figures.

Four things that break after deployment

Lumping every post deployment problem under the word drift is how teams end up with monitoring that catches the cheap failures and misses the expensive one. Separate them, because they have different detection signals and different response times.

Data quality failure means the inputs are broken rather than shifted: a column arriving as all nulls after an upstream schema change, a currency field switching from dollars to cents, a join silently going from inner to left and filling half your rows with missing values. This is the most common failure by a wide margin and the cheapest to detect, because you are checking schema, null rates and ranges, not distributions. It is also the one that produces the most spectacular damage, since a feature that is suddenly all zeros will move every prediction at once.

Covariate drift, also called data drift, means your input distribution has moved while the underlying relationship holds. Your customer base skews younger, or a marketing push brings in a segment with shorter tenure. Notation helps here: the distribution of inputs P(X) changes but the mapping P(y given X) is stable. A model can survive this reasonably well if the new region of feature space was represented in training, and it degrades badly if it was not.

Prediction drift means the distribution of your model outputs has moved. Average predicted churn probability climbing from 0.18 to 0.29 over six weeks is worth a look. This signal is cheap because it needs no labels and only one column, and it catches a lot of upstream breakage indirectly, since broken inputs usually push outputs somewhere strange.

Concept drift means P(y given X) itself has changed. Same inputs, different correct answer. This one is expensive, slow to detect, and the only reliable signal for it is realised performance measured against actual labels. No amount of input monitoring will find it. Anyone who tells you their drift dashboard covers concept drift has not thought about it carefully.

flowchart TD A[Scored batch lands] --> B[Layer 1 schema nulls ranges] B -->|fail| Z[Block the batch and page] B -->|pass| C[Layer 2 feature drift PSI] C --> D[Layer 3 prediction distribution] D --> E[Layer 4 estimated performance] E --> F{Labels arrived yet} F -->|no| G[Log estimate and wait] F -->|yes| H[Layer 5 realised AUC] H --> I{Below retrain floor} I -->|no| G I -->|yes| J[Trigger retrain and review] G --> A
Five monitoring layers, cheapest and fastest first. Only the bottom layer can catch concept drift.

Ordering matters more than most teams realise. Layer one is nearly free and catches maybe 70 percent of real incidents, so it runs first and hard blocks the batch. Layer five is the only honest signal but arrives weeks late, so it can never be your first line of defence. Everything between is a proxy of decreasing cheapness and increasing usefulness.

Drift detection on churn features with Evidently

Last part we put every training run into MLflow and promoted a registered model version with an alias. This part takes that same deployed churn model, built on the IBM Telco Customer Churn dataset, and asks whether the data arriving today still looks like the data it was trained on. Concretely: we freeze a reference window, compare each new scoring window against it, and quantify the gap.

Evidently is the fastest way to get a defensible drift report without writing statistics yourself. It picks a detection method per column automatically based on column type and sample size, and reports overall dataset drift as the share of drifting columns, flagging the dataset when at least half of them move.

# monitor_drift.py
# Tested against evidently 0.7.21, pandas 2.3.3, numpy 2.3.2, Python 3.11.9
import pandas as pd
from evidently import Report
from evidently.presets import DataDriftPreset

ref = pd.read_csv('data/churn_reference_2025Q4.csv')
cur = pd.read_csv('data/churn_scored_2026Q2.csv')

COLS = ['tenure', 'monthly_charges', 'total_charges', 'contract_type',
        'payment_method', 'internet_service', 'tech_support',
        'support_tickets_90d']

report = Report([DataDriftPreset(columns=COLS)], include_tests=True)
result = report.run(cur[COLS], ref[COLS])
result.save_html('drift_2026Q2.html')
print(result.dict()['metrics'][0])

Import paths were reorganised in the Evidently 0.7 line. If from evidently.presets import DataDriftPreset raises an ImportError on your install, check what you actually have with pip show evidently before assuming the code is wrong. [VERIFY against your installed minor version]

Running that against our second quarter window returned 2 of 8 columns drifting, which is below the default dataset level threshold, so the preset reported no dataset drift. Read that carefully, because it is a trap. Dataset level drift is a blunt summary. Two drifting columns matter enormously if they are the two features your model leans on hardest, and matter not at all if they are features the model barely uses. Permutation importance from Part 11 is what tells you which case you are in.

For a number you can put a threshold on and explain to a risk committee, I compute the population stability index, or PSI, per feature. PSI compares binned proportions between reference and current and sums the divergence. Under 0.1 is stable, 0.1 to 0.2 is worth watching, above 0.2 warrants investigation. Twenty lines of numpy gets you there and you own every step.

import numpy as np

def psi(reference, current, bins=10, eps=1e-6):
    """Population stability index for a numeric column."""
    edges = np.quantile(reference, np.linspace(0, 1, bins + 1))
    edges[0], edges[-1] = -np.inf, np.inf
    ref_pct = np.histogram(reference, bins=edges)[0] / len(reference)
    cur_pct = np.histogram(current, bins=edges)[0] / len(current)
    ref_pct = np.clip(ref_pct, eps, None)
    cur_pct = np.clip(cur_pct, eps, None)
    return float(np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct)))

for col in ['tenure', 'monthly_charges', 'support_tickets_90d']:
    print(f'{col:24s} {psi(ref[col], cur[col]):.3f}')
tenure                   0.041
monthly_charges          0.312
support_tickets_90d      0.409

Note the two clipping lines. Leave them out and the first low volume category that appears in current but not reference will give you a zero denominator inside the logarithm:

RuntimeWarning: divide by zero encountered in log
  return float(np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct)))
contract_type            inf

An infinite PSI is not a catastrophic drift event, it is an empty bin. It is a warning rather than an exception, so it will not stop your pipeline, and if your alerting compares PSI against a numeric threshold then infinity fires an alarm every single run until someone stops trusting the alarm. That small epsilon is doing quiet, important work.

Population stability index by feature, 2026 Q2 vs referenceDashed line marks the 0.2 investigation threshold. Two features cross it.0.00.10.20.30.40.040.310.220.090.060.410.070.03tenuremonthlycontractpaymentinternettickets90dtotal chgtechsupFilled bars exceed the threshold. Both are features in the model top four by permutation importance.
Dataset level drift said no. Feature level PSI, weighted by importance, said look closer.

Estimating performance while labels are still in flight

Here is the constraint that makes model monitoring hard. Our churn label is defined as departure within the next 60 days, so a prediction made on 1 April cannot be scored until 31 May. Add a data warehouse lag and a reconciliation cycle and realised performance for April lands in mid June. Ten weeks of blindness on the only signal that catches concept drift.

NannyML narrows that window with confidence based performance estimation, CBPE. Idea is simple once you see it: if your model outputs well calibrated probabilities, meaning that among all customers scored at 0.30 roughly 30 percent actually churn, then those probabilities alone let you compute expected confusion matrix cells, and from those cells any metric you like. No labels required.

# estimate_performance.py
# Tested against nannyml 0.13.1, pandas 2.3.3, Python 3.11.9
import nannyml as nml
import pandas as pd

reference = pd.read_csv('data/churn_reference_scored.csv')  # has labels
analysis = pd.read_csv('data/churn_analysis_scored.csv')    # no labels yet

estimator = nml.CBPE(
    problem_type='classification_binary',
    y_pred='prediction',
    y_pred_proba='churn_proba',
    y_true='churn',
    timestamp_column_name='scored_at',
    metrics=['roc_auc'],
    chunk_period='M',
)
estimator.fit(reference)
results = estimator.estimate(analysis)
print(results.to_df()[['roc_auc']].round(3).head())

Two constraints matter more than the code. First, the reference set must not be the training set of the monitored model, because NannyML fits a probability calibrator on reference data and fitting it on training data introduces bias. Hold out a clean slice. Second, and this is the part people skip: CBPE handles covariate shift well and does not handle concept drift at all. If P(y given X) changes, the model keeps emitting confident probabilities, CBPE believes them, and your estimate stays flat while reality falls away underneath it.

Realised AUC vs estimated AUC, twelve months of churn scoringA widening gap between the two lines is the fingerprint of concept drift.0.900.850.800.750.70pricing change shipsM1M4M7M10M12realisedCBPE estimate
Estimated performance is a useful early signal and a dangerous single source of truth.

My take

Monitor the gap, not either line. A flat estimate with a falling realised metric is the single most valuable alert in the whole stack, because it says the inputs are fine and the world changed. Once we started tracking the difference between estimated and realised AUC as its own series with a 0.05 alert band, our detection time for the failure mode that had cost us nine weeks dropped to about eleven days.

Alert thresholds people will not mute

My first monitoring setup failed for a reason that had nothing to do with statistics. We ran a Kolmogorov Smirnov test per feature at p below 0.05 across 41 columns, on daily windows of about 7,000 rows. With 41 independent tests at a 5 percent false positive rate you expect two false alarms a day from pure chance, and large samples make the test sensitive to differences too small to matter. Reality delivered six to eight alerts every morning. Inside three weeks the channel was muted, and a muted channel is worse than no channel because it looks like coverage on an audit checklist.

Rebuilding it, I changed three things. Effect size replaced significance, so PSI above 0.2 rather than a p value, because PSI does not get more trigger happy as your sample grows. Scope narrowed from all 41 features to the 8 carrying the most permutation importance, since drift in a feature the model ignores is trivia. Windows widened from daily to weekly, which smoothed out day of week seasonality that was generating most of the noise anyway. Alert volume went from roughly 45 a week to roughly one a fortnight, and people started reading them.

Gotcha: a monitoring window with no positive cases will take your metric calculation down, not just report a poor score. Compute AUC on a quiet week where nobody churned and scikit-learn raises ValueError: Only one class present in y_true. ROC AUC score is not defined in that case. If that call sits inside your nightly monitoring job with no handler, the job dies, the job failure alert fires, somebody restarts it, it dies again, and within a week the monitoring job itself is the thing everyone ignores. Guard every window with a minimum positive count, log a skip, and carry on.

Set thresholds against the cost of the action, not against a number you read in a blog post. Retraining a churn model costs about half a day and carries deployment risk, so our retrain floor sits at an AUC of 0.78, chosen because campaign economics stop working below roughly 0.76. A fraud model where a miss costs thousands would sit far tighter. Write the reasoning down next to the threshold, because in nine months somebody will ask why it is 0.78 and we picked it in a meeting is not an answer that survives a model risk review.

Detection lag against signal confidenceCheap and fast on the left, trustworthy and slow on the right. Run all four.Schema and nullslag: minutesproxy onlyFeature driftlag: about 1 weekproxy onlyEstimated metriclag: about 2 weeksblind to concept driftRealised metriclag: about 10 weeksground truthincreasing confidence, increasing lag, increasing cost to computeOnly the rightmost box can tell you the relationship between features and outcome has changed.
Every signal to the left of realised performance is an early warning, not a verdict.

Twelve months of churn monitoring numbers

Numbers from the churn project make the argument better than any diagram. Below is a monthly monitoring record: highest feature PSI, mean predicted probability, CBPE estimate, and realised AUC once labels landed. Reference window is the fourth quarter of the prior year.

MonthMax PSIMean p(churn)CBPE AUCRealised AUCGap
M10.040.1810.860.860.00
M30.070.1860.860.850.01
M50.110.1950.850.840.01
M60.140.2070.840.810.03
M70.190.2290.840.780.06
M90.280.2640.830.730.10
M100.410.2880.820.710.11
M12 (after retrain)0.050.2410.850.840.01
Gap column crosses 0.05 at month 7. Max PSI does not cross 0.2 until month 9. Two months of warning, free.

Read the columns against each other. Mean predicted probability climbed steadily from month 5, which was our earliest visible signal and needed no labels and no library at all. Max PSI lagged behind it. Gap between estimate and reality crossed a meaningful band at month 7, two full months before feature drift became loud enough to trip a 0.2 threshold. Cheapest signal in that whole table is the third column, and it is the one most teams never plot.

Second table is the one I actually put in front of stakeholders, because it converts signals into owned actions. A monitor with no named response is decoration.

SignalThresholdCadenceActionAlerts per quarter
Null rate on any inputabove 2x baselineper batchblock batch, page on call2 to 3
PSI, top 8 featuresabove 0.2weeklyticket, investigate in 5 days1 to 2
Mean predicted probabilityshift above 0.05 absoluteweeklyticket, check upstream firstabout 1
Estimated minus realised AUCabove 0.05monthlyconcept drift reviewunder 1
Realised AUCbelow 0.78monthlyretrain, notify campaign ownerunder 1
Roughly five to seven alerts a quarter across the whole stack. That is a volume a human will still read.
In practice: before installing any monitoring library, write a single query that logs mean predicted probability and prediction count per day into a small table, and plot it. Aggregation of exactly the kind covered in SQL GROUP BY and window functions is all it takes. That one chart has caught more real incidents for me than every drift test I have configured since, because broken pipelines and broken models both show up in it, usually within a day.

Tooling choices and what I would avoid

Evidently is what I reach for first on drift and data quality. It covers a wide metric surface, produces a report a non specialist can read, and runs offline against two dataframes, so you can bolt it onto an existing batch job in an afternoon. Cost is that its report objects are not a time series store, so you still need somewhere to persist the numbers.

NannyML earns its place only when your label lag is genuinely painful, which for churn it is. If your labels arrive within a day, skip performance estimation entirely and measure the real thing. Adding a calibration layer and a second library to shorten a one day gap is complexity with no payoff.

What I would avoid is buying a full observability platform as your first move. Every team I have watched do that ended up with a beautiful dashboard nobody opened, because the hard part was never rendering the chart, it was deciding what a threshold breach obliges somebody to do. Get the five signals in the table above landing in a database and a weekly message, prove somebody acts on them, and only then pay for a platform. Also avoid monitoring every feature you have. Sensitivity you gain is real, and it is dwarfed by the alert fatigue you buy.

One judgement call worth naming: we chose not to auto retrain on threshold breach, and I still think that was right. Automatic retraining on a drift alert is how a data quality bug becomes a permanently poisoned model, because the retrain happily learns the broken feature. Retraining triggers a review, a human looks at whether the drift is real, and only then does a pipeline run. Two days slower, and it has saved us at least twice.

Monitoring layers to put in place this week

If you take one thing from this part, make it the ordering. Start with schema and null checks that hard block a bad batch, because they are an afternoon of work and catch most real incidents. Add a daily log of mean predicted probability and row count next, since it needs no library and gives you the earliest cheap warning. Layer weekly PSI on your top handful of features third, with an effect size threshold rather than a p value. Add performance estimation fourth, and only if your label lag runs to weeks. Track realised performance last, and track the gap between estimated and realised as its own series, because that gap is your only view of concept drift.

Next part takes this further into automation: continuous integration and continuous delivery for machine learning pipelines, where the tests that gate a deploy include model quality checks and not just unit tests. Monitoring tells you a model went bad. CI/CD is how you get the replacement out safely without repeating the incident that produced it.

Your move this week: open whatever model you have in production, and write down how you would find out it had stopped working. If the honest answer involves somebody eventually complaining, you have a monitoring gap and now you have a five step plan for closing it.

Data Science Series · Part 25 of 30
« Previous: Part 24  |  Guide  |  Next: Part 26 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure – By Dr Pranay Jha

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure - By Dr Pranay Jha

Subscribe now to keep reading and get access to the full archive.

Continue reading