Ordered by how often each one turns out to be the answer.
A feature is computed differently in training and serving
CommonThe same named feature is produced by two pieces of code — an offline batch job and an online request path — and they do not agree. Different rounding, a different time window, refunds included in one and not the other, a null handled as zero in one and dropped in the other. The model was trained on one definition and is asked about the other on every single request.
Confirm
# Score the SAME production example through both paths and diff the feature vectors
# (no CLI for this — it is a comparison you have to arrange)
Any feature whose value differs between the offline and online computation for one identical input. One mismatched feature is enough to explain a large gap
Fix
- Compute the feature once, in one place, and have both training and serving read that. This is the entire reason feature stores exist, and the benefit comes from the single definition rather than from buying a product.
- Where you cannot unify immediately, add an automated comparison that fails the pipeline when the two implementations disagree on a sample of real inputs.
- Treat a feature definition as an interface with one owner, not as code that happens to appear twice.
The features used at serving time were never logged
CommonNot a cause of the gap so much as the reason you cannot find it. If only the model's inputs at training time are recorded, there is nothing to compare against. Google's guidance is direct about this: save the set of features used at serving time, and pipe those to a log to use at training time.
Confirm
# Check whether your serving path records the feature vector it scored,
# not merely the request and the prediction
Absence of the served feature vector. If it is missing, this is the first thing to fix — every other diagnosis below depends on it
Fix
- Log the exact feature vector that was scored, with the model version and a request identifier.
- Sample rather than logging everything if volume is a concern; a consistent sample is enough for distribution comparison.
- Then train from those logged features where you can, which removes this class of skew by construction.
The input data has changed since training
CommonThe pipeline is correct and the world moved. Traffic shifted to mobile, a customer segment grew, a upstream system started sending a field in a different format. The model is answering correctly about a world that no longer exists. Distinguished from a pipeline bug by its shape: it appears gradually, and it appears after a period of the model working.
Confirm
# Compare live input distributions against the training set, per feature
# (mean, median, null rate, and category frequencies for categoricals)
A feature whose distribution has moved materially since training — a null rate that climbed, a category that appeared or vanished, a mean that shifted
Fix
- Retrain on recent data, and decide deliberately whether that is scheduled, triggered by a drift threshold, or manual — 'when somebody notices' is the default nobody chooses on purpose.
- Monitor input distributions continuously. This needs no labels, which makes it the cheapest useful monitoring in ML and the first thing to add.
- Check whether an upstream producer changed a format or a default; drift is sometimes somebody else's deploy.
Evaluation was optimistic because of leakage
OccasionalThe production number is right and the evaluation number was wrong. Information that will not exist at prediction time leaked into training — a feature computed after the outcome, a random split that put near-duplicate rows on both sides, a target-derived aggregate. The gap was there from the first request, because there never was a better model.
Confirm
# Re-evaluate on a split by TIME rather than at random: train on earlier, test on later
Evaluation scores dropping sharply toward the production figure. If a time-based split reproduces the live number, the original evaluation was the problem
Fix
- Split by time for anything where predictions are made about the future, which is most things.
- Check every feature against the question: would this value be available, with this value, at the moment of prediction?
- Be suspicious of a feature that improves the model a great deal on its own. That is more often leakage than insight.
The serving stack runs different code or versions
OccasionalA tokeniser, an encoder or a scaler is a different version in the serving image than in the training environment. Library upgrades change defaults; a normalisation constant fitted during training is recomputed at serving instead of being loaded. The model artifact is identical and the thing feeding it is not.
Confirm
pip freeze > serving.txt # in the serving image, then diff against the training environment
Version differences in anything that transforms data before the model sees it, and any preprocessing parameter recomputed at serving rather than loaded from the artifact
Fix
- Ship preprocessing with the model as one versioned artifact rather than as code that happens to be deployed alongside it.
- Pin the serving environment and rebuild training and serving images from the same base.
- Persist fitted parameters — means, vocabularies, scaling constants — with the model and load them, never recompute.
The model is shaping the data it later learns from
RareThe third cause Google names, and the least obvious. A recommender only ever collects feedback on what it chose to show, so the next training set is a record of its own preferences rather than of what users would have wanted. Performance can look stable on the data being collected while genuine quality narrows.
Confirm
# Compare outcomes on served items against a small randomised holdout that bypasses the model
The model looking strong on its own traffic and weak against randomly-served items — that gap is the loop
Fix
- Keep a small randomised exploration slice so the training data is not entirely self-selected.
- Record what was shown alongside what was chosen, so 'not clicked' can be told apart from 'never presented'.
- Accept that this is a design problem rather than a bug to fix once.