Training-serving skew
What it is
Any systematic difference between the inputs a model was trained on and the inputs it receives when serving: preprocessing re-implemented in the serving language, a feature computed over a different window, a default value filled differently, a column that is fresh in the warehouse and stale in the request path, or a category the encoder never saw.
Why it matters
It is the dominant cause of a model that scored well and performs badly, and it produces no errors. The service returns 200, the model returns a probability, and the probability is drawn from a distribution the model was never fitted to.
In the real world: Features calculated differently in the nightly training job and the live app make a good model perform badly.
Visual example
Skew auditFour small, individually reasonable differences: the model receives different numbers.
What each difference costs
spend_30d: -1,275 on a top-three feature: the row looks far poorer than it is
country: case mismatch -> an all-zero one-hot block, i.e. 'no country'
age: 0 is not missing; it is an extreme value the model takes literally
freshness: a 24h-stale ticket count misses exactly the spike that predicts churn
Offline AUC 0.79, live AUC 0.63 — and every service returned 200
Why this matters
None of these four is a bug in the ordinary sense. Each is a small independent decision made by someone reimplementing preprocessing without the training code in front of them.
Common misconception
Wrong idea: if the model file is the same, the model is the same.
Correct idea: a model is a function of its inputs. Change how the inputs are computed and you have deployed a different model, however identical the weights.
# A skew test worth running in CI: score a frozen sample through BOTH paths.
import numpy as np, pandas as pd
golden = pd.read_parquet('golden_sample.parquet') # 1,000 rows captured from prod
offline = pipe.predict_proba(golden)[:, 1] # the training-time code path
online = np.array([serving_client.score(row) for row in golden.to_dict('records')])
delta = np.abs(offline - online)
print(f'max {delta.max():.6f} mean {delta.mean():.6f} over 1e-4: {(delta > 1e-4).sum()}')
assert delta.max() < 1e-4, 'training and serving paths disagree'The most common single cause is a feature whose definition drifted: spend_30d computed in the warehouse as a calendar-month sum and in the serving path as a trailing-30-day sum. Both are defensible; having both is a silent, permanent input shift. Define each feature once, in one place, and test the two paths against each other on a golden sample.
Practise it: Would You Notice the Input Drifting?