Skip to content

Feature Stores and Training-Serving Skew

What you’ll learn

  • point-in-time correctness, measured: 0.7060 honest against 0.9564 when the window ends late
  • six ways a serving path computes “the same” feature differently, and what each one costs
  • why accuracy can fall 0.0891 while AUC falls only 0.0207 — and which one production feels
  • the parity test that catches skew before a model is involved at all
  • what a feature store actually buys you, and what it does not
  • the one-function rule, and where it has to be enforced

The setup

An event log — 4,000 customers, 51,965 events over the 120 days before a cutoff, and 9,351 events in the 30 days after it. The label is churn in that following month, and 41.60% of customers churn. Three features, all aggregates over a 30-day window ending at the cutoff:

features.py
def window_features(events, window_days, asof, customer_ids):
    """Aggregate the log over (asof - window, asof]. One function, both paths."""
    lower = asof - pd.Timedelta(days=window_days)
    window = events[(events["ts"] > lower) & (events["ts"] <= asof)]
    grouped = window.groupby("customer").agg(events=("amount", "size"),
                                             mean_amount=("amount", "mean"),
                                             last=("ts", "max"))
    grouped["days_since"] = (asof - grouped["last"]).dt.days
 
    frame = grouped.drop(columns="last").reindex(customer_ids)
    frame["events"] = frame["events"].fillna(0)          # no events in the window
    frame["mean_amount"] = frame["mean_amount"].fillna(0)
    frame["days_since"] = frame["days_since"].fillna(999)  # the "never" sentinel
    return frame
features.py
def window_features(events, window_days, asof, customer_ids):
    """Aggregate the log over (asof - window, asof]. One function, both paths."""
    lower = asof - pd.Timedelta(days=window_days)
    window = events[(events["ts"] > lower) & (events["ts"] <= asof)]
    grouped = window.groupby("customer").agg(events=("amount", "size"),
                                             mean_amount=("amount", "mean"),
                                             last=("ts", "max"))
    grouped["days_since"] = (asof - grouped["last"]).dt.days
 
    frame = grouped.drop(columns="last").reindex(customer_ids)
    frame["events"] = frame["events"].fillna(0)          # no events in the window
    frame["mean_amount"] = frame["mean_amount"].fillna(0)
    frame["days_since"] = frame["days_since"].fillna(999)  # the "never" sentinel
    return frame

568 of 4,000 customers have no events in the window, which is why the fill values are part of the feature definition rather than an implementation detail. A serving path that fills days_sincedays_since with 0 instead of 999 has changed the feature, not the plumbing.

Point-in-time correctness

The asofasof argument is the whole of point-in-time correctness. Move it 30 days forward — so the window covers the month the label is about — and the model becomes spectacular:

figure+0.2504 of pure leakagematplotlib
Left: a timeline with the 30 days before the cutoff shaded blue and the 30 days after shaded red; the correct window covers only the blue period, the late window straddles both. Right: test AUC 0.7060 for the point-in-time features against 0.9564 for the late window.Left: a timeline with the 30 days before the cutoff shaded blue and the 30 days after shaded red; the correct window covers only the blue period, the late window straddles both. Right: test AUC 0.7060 for the point-in-time features against 0.9564 for the late window.
Both feature frames come from the same function with the same window length. The only difference is the asof timestamp. The late window counts events from the month the label describes — a churned customer has none — so the model is reading the answer, and reports AUC 0.9564 for it.
FeaturesTest AUC
point-in-time, window ends at the cutoff0.7060
window ends 30 days after the cutoff0.9564
difference+0.2504

This is the most expensive bug in applied ML, and it is almost never written as asof = cutoff + 30asof = cutoff + 30. It arrives as:

  • a feature table rebuilt nightly from the current state of a customer, then joined to old labels
  • df.groupby("customer")["amount"].mean()df.groupby("customer")["amount"].mean() over the whole history, with no window at all
  • an aggregate maintained by the application, read at training time from the live database
  • a “days since last login” column that was computed when the training set was assembled, not as of the prediction moment

Every one of these produces a number that cannot exist at prediction time. The defence is structural: compute features with a function that takes asofasof as an argument, and pass the prediction timestamp into it. A feature pipeline that cannot answer “as of when?” is not a feature pipeline.

Six serving paths, one model

Now assume the training features are correct and the serving implementation differs. Same model, same test customers, six variants of the feature computation:

figureOne trained model, seven serving paths: accuracy 0.6558 down to 0.5567matplotlib
Grouped horizontal bars of AUC and accuracy for seven serving paths. Point-in-time 0.7060 / 0.6558. Cache 3 days stale 0.7048 / 0.6600. Cache 7 days stale 0.7055 / 0.6625. 7-day window 0.6150 / 0.5567. 90-day window 0.7176 / 0.6192. days_since in hours 0.7058 / 0.6558. Events per day 0.6853 / 0.5667.Grouped horizontal bars of AUC and accuracy for seven serving paths. Point-in-time 0.7060 / 0.6558. Cache 3 days stale 0.7048 / 0.6600. Cache 7 days stale 0.7055 / 0.6625. 7-day window 0.6150 / 0.5567. 90-day window 0.7176 / 0.6192. days_since in hours 0.7058 / 0.6558. Events per day 0.6853 / 0.5667.
Staleness of a few days costs nothing here. Changing the window length costs 0.0991 accuracy. Dividing the event count by 30 — a units decision that looks harmless — costs 0.0891 accuracy while AUC falls only 0.0207. And the 90-day window has a HIGHER AUC than the model was trained with, which is the most confusing failure of the set.
Serving pathAUCAccuracyvs correct (accuracy)
point-in-time (correct)0.70600.6558
cache 3 days stale0.70480.6600+0.0042
cache 7 days stale0.70550.6625+0.0067
7-day window, not 300.61500.5567−0.0991
90-day window, not 300.71760.6192−0.0366
days_sincedays_since in hours0.70580.65580.0000
events per day, not per window0.68530.5667−0.0891

Four things worth extracting.

Staleness was harmless — here. A cache three or seven days behind changed accuracy by less than 0.007, because a 30-day count barely moves in a week. That is a property of this feature, not a general result: a “transactions in the last hour” feature served from a daily batch would be catastrophic. The test is whether the window is long relative to the staleness.

Window length is the dangerous parameter. Serving a 7-day count to a model trained on 30-day counts cost 0.0991 accuracy — a bigger loss than most modelling changes will ever gain. And nothing errors: the column is present, numeric, non-null and plausible.

A monotone rescaling can be nearly invisible in AUC and expensive in accuracy. Dividing eventsevents by 30 moved AUC by 0.0207 and accuracy by 0.0891. AUC only cares about the ranking; the threshold is calibrated to the original scale, so a rescaled input moves every prediction across it. If your monitoring watches AUC only, this class of bug is close to undetectable.

days_sincedays_since in hours cost exactly nothing. With three features and a linear model, that particular rescaling happened to preserve both ranking and the decision. Not a reason to relax: the same change on a tree ensemble, or interacting with a regularisation penalty, need not be benign. It is a reminder that the size of a skew’s damage is not predictable from the size of the code change.

See it move

The sketch is the dashboard that would have been on the wall. It cycles the seven serving paths from the table and plots each one as a point in AUC–accuracy space, keeping the correct path marked. What you are looking for is how often the two axes disagree — because a monitoring setup that watches one of them is blind to a whole class of skew.

sketch The same model on seven serving paths p5.js
Each measured serving path plotted as a point in AUC versus accuracy space, with the correct point-in-time path marked. Two paths raise AUC while lowering accuracy, and two lower accuracy by roughly 0.09 while looking entirely plausible. The panel names what code change produced each one.

The purple point is the whole argument for parity testing rather than metric monitoring. If your alert threshold is on AUC, the 90-day window looks like an improvement of 0.0116 while it quietly costs 0.0366 of accuracy; if your alert is on accuracy, you see a regression with no cause anywhere in the model, the data volumes, or the label distribution — because the cause is a number 3030 that became a 9090 in a different repository.

The parity test

The fix is not cleverness, it is a test. Compute the features both ways and compare them row by row, before any model is involved:

figureA parity test compares the two paths row by row, before any model is involvedmatplotlib
Bars of the fraction of customers whose features differ. Same function with the same arguments: 0%. Cache 3 days stale: 87.8%. 7-day window: 79.8%. Events per day: 85.8%.Bars of the fraction of customers whose features differ. Same function with the same arguments: 0%. Cache 3 days stale: 87.8%. 7-day window: 79.8%. Events per day: 85.8%.
The correct implementation matches itself exactly — 0% of rows differ, max absolute difference 0.0000. Every skew variant differs on 80-88% of customers, and would have failed this test in milliseconds. No labels, no metrics and no production traffic are required to catch any of these.
test_parity.py
def test_offline_online_parity():
    """The two feature paths must agree exactly on a sample of real entities."""
    ids = sample_customer_ids(500)
    asof = pd.Timestamp("2024-04-01")
 
    offline = window_features(events_warehouse, 30, asof, ids)   # training path
    online = fetch_features_from_service(ids, asof)              # serving path
 
    assert list(offline.columns) == list(online.columns)
    mismatch = ~np.isclose(offline.to_numpy(float), online.to_numpy(float),
                           rtol=1e-9, atol=1e-9)
    offenders = offline.index[mismatch.any(axis=1)]
    assert len(offenders) == 0, (
        f"{len(offenders)} of {len(ids)} entities differ; "
        f"first: {offenders[:5].tolist()}")
test_parity.py
def test_offline_online_parity():
    """The two feature paths must agree exactly on a sample of real entities."""
    ids = sample_customer_ids(500)
    asof = pd.Timestamp("2024-04-01")
 
    offline = window_features(events_warehouse, 30, asof, ids)   # training path
    online = fetch_features_from_service(ids, asof)              # serving path
 
    assert list(offline.columns) == list(online.columns)
    mismatch = ~np.isclose(offline.to_numpy(float), online.to_numpy(float),
                           rtol=1e-9, atol=1e-9)
    offenders = offline.index[mismatch.any(axis=1)]
    assert len(offenders) == 0, (
        f"{len(offenders)} of {len(ids)} entities differ; "
        f"first: {offenders[:5].tolist()}")
Serving variantRows that differMax abs difference
same function, same arguments0.00%0.0000
cache 3 days stale87.80%999.0000
7-day window79.77%992.0000
events per day85.80%35.7667

Two design notes that make this test practical:

Run it on real entity ids, not synthetic ones. The interesting disagreements are in the awkward cases — the 568 customers with no events, the ones with a single event on the boundary, the ones created yesterday.

Assert exact equality, not “close enough”. A tolerance of 1e-9 catches a changed fill value; a tolerance of 0.01 hides it. If the two paths cannot agree to floating-point noise, they are computing different features and you want to know why.

What a feature store is, minus the marketing

A feature store is three things, and only the first is conceptually interesting:

ComponentWhat it doesCould you do it yourself?
One definitionThe feature is declared once and materialised for both pathsYes — a shared function is exactly this
Offline storeHistorical values with point-in-time joins for trainingYes, with effort: a warehouse table plus asofasof joins
Online storeLow-latency lookup of current values for servingYes, with more effort: Redis, DynamoDB, a cache
Registry / discoveryWhich features exist, who owns them, what they meanMostly a wiki problem

The single-definition property is the one that removes the failures on this page. A feature store gives it to you by construction, but so does a function in a shared library that both paths import — which is the right answer for most teams, and stays the right answer until you have enough features and enough consumers that discovery becomes the bottleneck.

What a feature store does not do:

  • It does not stop you writing a leaky definition. asofasof correctness is still yours to get right; the store just makes the point-in-time join available.
  • It does not remove the need for a parity test. Materialisation paths can still diverge — and the point-in-time join is itself code that can be wrong.
  • It does not fix staleness. A TTL is a decision about how wrong you are willing to be, and it has to be set per feature: 30-day counts tolerated a week here, a one-hour count would not.
diagram Diagram mermaid

The last box matters as much as the test: log the feature values you actually served. Given those, skew is a query rather than an investigation — you can compare served values against the training distribution, and re-score old requests with the offline path to find the divergence. Without them, a metric regression caused by skew is nearly impossible to attribute.

Pitfalls

PitfallWhy it bitesWhat to do
Features computed from “current state”Window ending 30 days late gave AUC 0.9564 against 0.7060Every feature function takes asofasof
Two implementations of one feature80–88% of rows differed in every variantOne shared function, two materialisations
No parity testThe 7-day/30-day mix cost 0.0991 accuracy silentlyAssert exact equality on real ids in CI
Monitoring AUC onlyEvents-per-day cost 0.0891 accuracy with AUC −0.0207Watch the metric your decision uses
Assuming staleness is harmlessTrue here (0.007), catastrophic for short windowsCompare the TTL to the window length
Fill values treated as plumbing568 customers depend on the 999 sentinelFill values are part of the definition; test them
Not logging served feature valuesSkew becomes unattributable after the factLog features next to predictions
Believing a feature store removes the problemIt gives one definition, not a correct oneKeep the asofasof discipline and the parity test

Recap

  • 4,000 customers, 51,965 events before the cutoff and 9,351 after; churn rate 0.4160; 568 customers with no events in the 30-day window.
  • Moving the window 30 days later inflated AUC from 0.7060 to 0.9564+0.2504 of pure leakage.
  • Serving a 7-day window to a 30-day model cost 0.0991 accuracy; nothing raised.
  • Dividing eventsevents by 30 cost 0.0891 accuracy while AUC moved only 0.0207.
  • A 90-day window raised AUC to 0.7176 and lowered accuracy to 0.6192 — metrics disagreeing is a skew signature.
  • A parity test found every variant: 0.00% of rows differ for the correct path, 79.77–87.80% for the others.
quizCheck yourself
  1. Your churn model scores AUC 0.9564 in offline evaluation and 0.71 in production. What is the first thing to check?

    Show answer

    B — Whether the training features were computed as of the prediction time — a window ending 30 days late gave exactly this gap, +0.2504 — That size of gap is almost always point-in-time leakage rather than overfitting. Look for aggregates built from current state, or a groupby over the whole history, and make every feature function take an asof argument.

  2. Serving computes a 7-day event count; the model was trained on 30-day counts. What happens?

    Show answer

    B — Accuracy falls 0.0991 and nothing errors: the column is present, numeric, non-null and plausible — Window length is part of the feature's definition, and no type or schema check can see it. Only a parity test comparing the two implementations row by row catches it — 79.77% of customers differed.

  3. A serving bug divides one feature by 30. AUC falls by 0.0207, accuracy by 0.0891. Why the difference?

    Show answer

    B — AUC only depends on the ranking, which a monotone rescaling barely changes, while the decision threshold was calibrated to the original scale — This is why the metric you monitor should be the one your decision uses. A dashboard watching AUC would have shown a 0.02 wobble for a bug that cost nearly nine points of accuracy.

  4. What does a parity test assert, and on what data?

    Show answer

    B — That the offline and online feature computations produce identical values for the same real entities at the same asof — to floating-point tolerance, before any model runs — It needs no labels, no metrics and no traffic: 0.00% of rows differed for the correct path against 79.77-87.80% for each skewed variant. Use real ids, including the awkward ones like the 568 customers with no events.

  5. Your team is considering a feature store. What will it definitely NOT fix?

    Show answer

    B — A leaky feature definition — asof correctness is still yours, and the point-in-time join is itself code that can be wrong — The store's real gift is one definition materialised two ways, which a shared function also gives you. It cannot know that your window should end at the prediction moment, and it does not remove the need for the parity test.

🧪 Try It Yourself

Exercise 1 – Build the event log and the features

Exercise 2 – Train on point-in-time features

Exercise 3 – Leak the future, on purpose

Exercise 4 – Serve five different features

Exercise 5 – Write the parity test

Next

Testing ML Code — the parity test above is one of six kinds of test that catch ML bugs. The next page writes the other five, and measures which bugs each one catches.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did