A model that scored 0.94 accuracy in your notebook can ship and report 0.71 in production, and nothing about the model changed. The notebook lied - not on purpose, but because it never paid the costs real deployment charges. It trained and scored on the same clean file, with no network, no serialization, and no users feeding it data the likes of which it has never seen. The moment a model leaves the notebook, four new forces act on it: every prediction now waits on latency, every prediction now costs money, the world drifts away from the data it was trained on, and any mismatch between how you trained and how you serve corrupts the inputs silently. This lesson is about judging those forces - when to act, and how to recover when something goes wrong.
flowchart LR N["notebook:<br/>trains and scores"] --> F["freeze preprocessing<br/>with the model"] F --> S["serve behind<br/>an API"] S --> M["monitor live inputs<br/>and predictions"] M -->|"drift or metric drop"| R["roll back to<br/>last good version"] R --> S style R fill:#b91c1c,color:#fff style M fill:#3776ab,color:#fff
Latency: the notebook never queued
In the notebook, scoring one row takes 5ms and that is the end of the story. In production that same row travels over a network, gets serialized, waits behind other requests, loads the model into memory if it was cold, and gets deserialized on the way back. The 5ms becomes 50 or 200. Averages hide the worst cases, so production latency is measured in percentiles: p50 is the median request, the experience most users get, and p99 is the slowest one in a hundred, the request that breaks autocomplete or triggers a timeout. A model whose p50 is fine but whose p99 is 800ms is not fine for an interactive feature. Budget for the tail, not the middle.
def percentile(sorted_vals, p):
# nearest-rank percentile on an already-sorted list
k = max(0, int(round(p / 100 * len(sorted_vals))) - 1)
return sorted_vals[k]
latencies = sorted([5, 8, 6, 7, 9, 100, 8, 7, 6, 9])
print("p50", percentile(latencies, 50))
print("p99", percentile(latencies, 99))
Read the tail, not the average
That p99 of 100 tells you something the median of 7 hides entirely: one request in a hundred hits a value more than ten times the typical one. In a batch job that is noise; in a user-facing API it is the difference between a snappy page and a timeout. Production dashboards watch p99, and sometimes p99.9, precisely because the average forgives the slow tail. When a latency budget slips, the fix is rarely the model itself - it is the cold-start that loads it, the serialization that round-trips it, or a queue building up behind a busy instance. Measure the tail first, then decide whether the model or the plumbing is to blame.
Drift: the world moves on
A model is frozen at training time, but the world it scores keeps changing. Drift is the gap between the data you trained on and the data arriving now, and it comes in two flavors. Covariate drift means the inputs shifted - a feature that used to average 12 now averages 18, because your users, your sensors, or your market changed. The model's internal mapping may still be correct, but it is being queried at inputs it rarely saw. Concept drift is nastier: the relationship itself flipped. The same input that used to signal 'will repay' now signals 'will default', because the economy or the fraud pattern changed. Both decay accuracy, but they demand different fixes - one is a retrain, the other is a rethink.
flowchart TD D["what drifted?"] -->|"inputs shifted"| CV["covariate drift:<br/>feature distribution moved"] D -->|"relationship flipped"| CC["concept drift:<br/>input-to-label map changed"] CV --> R1["retrain on<br/>newer data"] CC --> R2["mapping broke:<br/>redesign features"] style CV fill:#b45309,color:#fff style CC fill:#b91c1c,color:#fff
def mean(xs):
return sum(xs) / len(xs)
def drift_score(train, serve):
t = mean(train)
spread = (mean([(x - t) ** 2 for x in train])) ** 0.5
return abs(t - mean(serve)) / spread
train = [1, 2, 3, 4, 5]
serve = [4, 5, 6, 7, 8]
print(round(drift_score(train, serve), 2))
How much drift is too much?
The drift score is a distance, not a verdict. A value of 0.2 means the serve distribution's center has moved two tenths of a training standard deviation away - small enough to be normal noise. A score above 1 means the inputs have moved further than the training data typically spreads, which is a strong signal the model is now answering outside its comfort zone. Teams set a threshold and alert when the rolling drift score crosses it, then investigate before accuracy visibly drops. The threshold is a judgment call, calibrated on how much accuracy you can afford to lose before acting. The discipline is measuring drift continuously, because it never announces itself - it shows up as a slow, quiet decay in the metrics you forgot to watch.
What does this print? The training mean is 3 and its standard deviation is sqrt(2); the serve mean is 6.
def mean(xs):
return sum(xs) / len(xs)
def drift_score(train, serve):
t = mean(train)
spread = (mean([(x - t) ** 2 for x in train])) ** 0.5
return abs(t - mean(serve)) / spread
print(round(drift_score([1, 2, 3, 4, 5], [4, 5, 6, 7, 8]), 2))
Training mean is 3; serve mean is 6, so the numerator is 3.
Training standard deviation is sqrt(2), about 1.414.
3 divided by 1.414 is about 2.12.
Write should_rollback(history, drop_threshold) returning True when the model should roll back. history is a list of daily accuracy values in chronological order (oldest first). Roll back when the latest value has dropped by more than drop_threshold below the first (baseline) value.
def should_rollback(history, drop_threshold):
# roll back if the latest value dropped more than drop_threshold below history[0]
pass
The baseline is history[0] and the latest is history[-1].
A drop means baseline minus latest is large; compare it to the threshold.
def should_rollback(history, drop_threshold):
baseline = history[0]
latest = history[-1]
return (baseline - latest) > drop_threshold
A model was trained on STANDARDIZED features - each value x became (x - mean) / scale. But the serve path sends raw values to the model: standardize returns its input unchanged, so the model sees features on the wrong scale. Fix standardize to apply the training transform.
def standardize(value, mean, scale):
return value # BUG: sends the raw value; model expects (value - mean) / scale
The training transform subtracts the mean, then divides by the scale.
At value 5 with mean 5, the result should be exactly 0.
def standardize(value, mean, scale):
return (value - mean) / scale
Your credit model's accuracy has fallen for a month. The applicant pool's average debt-to-income ratio climbed from 0.30 to 0.42 over the same period - the inputs shifted, but people with a given ratio still default at the same rate as before. Which drift is this?
Covariate drift is a shift in the inputs (here the debt-ratio distribution) while the mapping from inputs to labels stays the same. Concept drift would mean the same inputs now map to different labels; skew would mean the model is fed differently-preprocessed features at serve time.
You are deploying a new ranking model that you are not yet sure beats the old one. Which strategy ships it safely while you find out?
A canary routes a small slice of traffic to the new model so a regression hurts few users and is caught fast; a shadow runs it silently alongside the old model to compare without affecting users. A big-bang replace maximizes blast radius, and offline tests never prove production behavior on real traffic.
Recap
- A model's accuracy and latency change in production because the notebook never paid for network, serialization, cold-starts, or unseen data.
- Measure latency with percentiles - watch p99, the slow tail, not the average that forgives it.
- Covariate drift moves the inputs (retrain); concept drift breaks the input-to-label mapping (redesign). A drift score turns the shift into a number you can alert on.
- Training-serving skew - feeding the model differently-preprocessed features at serve time - is the most common cause of a silent collapse; freeze preprocessing with the model.
- Ship risky models with a canary or shadow, and roll back to the last good version the moment a monitored metric drops past its threshold.
The capstone ties it together: one dataset, taken honestly from split to a leakage-free scikit-learn pipeline, with metrics that would survive production.