BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 87 — Train-Serve Skew

Theme: Model Lifecycle & MLOps · Format: 20-min drill · Difficulty: core

The story

A credit-decisioning model has been the quiet workhorse of a lender's underwriting for a year. Its single most important feature, by a wide margin, is income_to_debt — annual income divided by annualized debt service. In the training pipeline this feature is computed in Python over the data warehouse: annual income over monthly_debt × 12, with missing debt imputed to the training-set median. The model learned that borrowers with a ratio above roughly 4 are good risks.

The scoring service that runs at application time is a different codebase, in a different language, owned by a different team. It computes income_to_debt too — as annual income over monthly_debt. No × 12. And where debt is missing, it defaults to zero, which makes the ratio explode to infinity and get clipped to the feature's max. Nobody wrote this divergence on purpose; the two implementations were written eighteen months apart from the same one-line description in a design doc, and they drifted.

So the model was trained on a feature centered around 4 and, in production, is fed a feature centered around 48 — the same borrowers, a number twelve times too large, with a slug of maxed-out values where debt was null. Accuracy on the metrics anyone watches looks fine: the service returns a score, latency is flat, errors are zero. Approvals have crept up over the year, which everyone read as a healthier applicant pool.

⏸ Stop here and ask the room: the model is "working" — it returns a score for every application. How would anyone on this team ever find out the score is being computed on a feature the model never saw in training?

Run it — facilitator script

Timing for a 20-minute session: 2 min story · 10 min staged questions · 3 min reveal · 5 min transfer.

The model is up and scoring — so what is actually broken?

Why didn't any monitor catch a feature that's 12× off?

Two implementations of the same feature — how do you make that impossible?

The reveal

An analyst chasing the year's approval creep pulled the served feature values and found income_to_debt sitting around 48 instead of 4. The model hadn't drifted; it had been fed a mis-scaled feature since the day the serving path shipped. The fix was to compute the feature once — in a shared feature pipeline consumed by both training and serving — and to log every served feature so its distribution could be compared to training. The × 12 and the null-handling divergence both vanished the moment there was one implementation instead of two. Approvals snapped back; the "healthier applicant pool" had been a scoring artifact all along.

Why this happens — the mechanics

A model is a function fit to the distribution of its training features. It carries no memory of what those features meant, no units, no sanity range — it interpolates whatever vector you hand it and returns a confident number. Feed it the same feature on a different scale and it will not error; it will quietly extrapolate into a region it never learned, and the score is meaningless in a way that no HTTP status can express. This is the defining property of ML failures: the system is behaving exactly as coded and still producing garbage, because correctness lives in the data, not the control flow.

The organizational root is that training and serving are almost always two different worlds — a batch pipeline in one language over the warehouse for training, a low-latency service in another language over live data for serving. Any feature that isn't computed from a single shared definition is really two features that happen to share a name, and they drift the instant either side is touched. Training-serving skew is the most common way a model that tested beautifully offline underperforms in production, and it is invisible to every infrastructure metric because the infrastructure is healthy — it's the meaning that's broken.

Diagrams

Problem — how the skew is introduced:

sequenceDiagram
    autonumber
    participant TP as Training pipeline<br/>(Python, warehouse)
    participant M as Model
    participant SS as Scoring service<br/>(other lang, live)
    participant DEC as Underwriting
    Note over TP: income / (monthly_debt×12)<br/>null → median  (mean ≈ 4)
    TP->>M: Train on ratio ≈ 4
    Note over SS: income / monthly_debt<br/>null → 0  (mean ≈ 48)
    SS->>M: Score on ratio ≈ 48
    M-->>DEC: Confident score on a<br/>feature it never saw
    Note over SS,DEC: 200 OK, zero errors,<br/>approvals creep for a year

Solution — where the guardrails go:

flowchart TD
    A["One shared feature<br/>definition (feature store)"] --> B["Same transform in<br/>training AND serving"]
    C["Log served feature<br/>values in production"] --> G1["Skew monitor: serving vs<br/>training distribution"]
    G1 -.-> AL["Page when mean/spread/<br/>null-rate diverges"]
    B --> G2["Parity contract test<br/>in CI"]
    G2 -.-> AL
    AL --> H["Block deploy / roll back<br/>the feature path"]

What good looks like

The lesson

A model only knows the distribution it was trained on; serving it a feature computed differently is feeding it a stranger. Compute each feature once and monitor the served distribution against training — training-serving skew is silent, and only a distribution comparison, not an error rate, will surface it.

Named concepts: training-serving skew, feature parity, feature store, feature distribution monitoring.

Real-world echoes

Transfer

For your highest-stakes model, is each feature computed by one piece of code, or by a training implementation and a separate serving implementation? Pick the top feature by importance and prove the two paths produce the same number on the same input.

Make it a ticket: log served feature values for the top model, stand up a skew monitor comparing serving-vs-training distribution for the top-10 features by importance, and add a parity contract test to CI — page on any top feature diverging beyond a written threshold.

Related