Training-Serving Skew and Feature Stores

Training-serving skew occurs when the inputs a model receives in service differ from the inputs used to train or evaluate it because of their construction, timing, or representation. A historical aggregate that includes future outcomes, a stale user profile, and a unit conversion applied only at serving time are different causes. A change in the population can also change performance even when the feature code is identical. The examples below separate these possibilities using simulations, not a deployed service.

Run the body examples in order. They share the simulated events and history arrays. Model fitting uses the earlier 70% of events; evaluation uses the later 30%. Early stopping is disabled so the demonstration does not introduce an additional random internal validation split.

A feature computed as of when?

A historical conversion rate needs a cutoff and a definition of which outcomes were available by then. The simulation orders rows by prediction time and assumes each label becomes available immediately after its prediction. Thus later rows may use earlier test-period outcomes, but never their own or future labels. For delayed conversions, an availability-time cutoff is needed as well. The smoothed rate is \((s+20\times0.5)/(c+20)\), where s is the number of prior successes and c the number of prior requests. A new user starts at 0.5; after one success the value is 11/21. The prior 0.5 is fixed in advance, not estimated from the full dataset.

import numpy as np
from collections import defaultdict
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score

g = np.random.default_rng(0)
n_users, n_ev = 4000, 40000
uid = g.integers(0, n_users, n_ev)
user_q = g.normal(size=n_users)                      # latent per-user quality
x = g.normal(size=(n_ev, 3))
y = (g.random(n_ev) < 1 / (1 + np.exp(-(0.9 * user_q[uid] + 0.7 * x[:, 0]
                                        - 0.3 * x[:, 1])))).astype(int)

prior_m, prior_n = 0.5, 20.0
past = np.zeros(n_ev); tot_s = np.zeros(n_users); tot_c = np.zeros(n_users)
seq = defaultdict(list)
for i in range(n_ev):                                # outcome is available immediately after this row
    u = uid[i]
    past[i] = (tot_s[u] + prior_m * prior_n) / (tot_c[u] + prior_n)
    seq[u].append(i); tot_s[u] += y[i]; tot_c[u] += 1

gs = np.zeros(n_users); gc = np.zeros(n_users)       # WHOLE-HISTORY: what a GROUP BY gives
np.add.at(gs, uid, y); np.add.at(gc, uid, 1)
full = (gs[uid] + prior_m * prior_n) / (gc[uid] + prior_n)
split = int(0.7 * n_ev)

def fit_eval(train_feat, serve_feat, label):
    m = HistGradientBoostingClassifier(random_state=0, early_stopping=False).fit(
        np.c_[x[:split], train_feat[:split]], y[:split])
    off = roc_auc_score(y[split:], m.predict_proba(np.c_[x[split:], train_feat[split:]])[:, 1])
    on = roc_auc_score(y[split:], m.predict_proba(np.c_[x[split:], serve_feat[split:]])[:, 1])
    print(f"{label:34s} validation-input AUC {off:.4f}   serving-input replay AUC {on:.4f}   gap {off - on:+.4f}")

fit_eval(full, past, "trained on whole-history feature")
fit_eval(past, past, "trained on past-only feature")
# trained on whole-history feature   validation-input AUC 0.8067   serving-input replay AUC 0.7161   gap +0.0906
# trained on past-only feature       validation-input AUC 0.7195   serving-input replay AUC 0.7195   gap +0.0000

The whole-history feature uses every event, including each row’s own label and later labels. The first model scores 0.8067 with that feature and 0.7161 with the available-history input, a gap of 0.0906. Holding out model-fitting rows does not undo this feature leakage. The serving-input column recomputes scores on the same held-out labels using only the simulated available history. This replay exposes a discrepancy that the leaky validation inputs conceal; a properly time-restricted feature pipeline or an audit of feature construction can detect it before deployment.

The second model is trained and evaluated with the same past-only construction. Its two reported scores are equal because the arrays supplied to the model are identical. That equality is an implementation check in this replay, not a guarantee of future production performance. Comparing the two models on available-history inputs also measures the effect of training on the unavailable feature in this particular run; it does not establish a universal degradation size.

Logging the feature vector actually supplied to a model provides a useful replay source, but logging alone does not guarantee correctness. Missing events, delayed labels, identity mismatches, version changes, and filtering can still produce a biased training set. Shared transformations, time-correct historical reconstruction, and comparisons against served values are complementary controls. A logged feature can itself already contain leakage or the wrong units.

What a feature store must keep consistent

A feature definition needs an entity key, value type and units, aggregation window, missing-value policy, and transformation version. An offline store retains history for training and replay; an online store serves values with low latency. A registry records definitions and metadata. Batch materialization copies computed feature values into the serving store; streaming jobs can update values as events arrive. Shared definitions help consistency, but the system still needs checks on data arrival, computation, and retrieval. A feature store is not a guarantee against leakage.

A historical join must use the right entity and prediction cutoff. Event time says when the source event happened; availability time says when the pipeline could actually use it. A conversion occurring at hour 4 but arriving at hour 8 must not appear in a replay of a prediction at hour 6. Later corrections and backfills also need versioned availability information. Selecting only by event time can reconstruct a history more complete than the service actually had.

The records below are versioned feature snapshots. as_of is the snapshot’s source cutoff; available_at is when that snapshot became usable. Times are hours from a common origin. A snapshot is eligible only if both times are at or before the prediction time; ties between availability and prediction require an explicit ordering rule in a real system. Among eligible snapshots of the requested version, this toy lookup selects the latest source cutoff, then the latest arrival for that cutoff.

snapshots = [
    dict(entity="A", as_of=1, available_at=2, version=1, value=2),
    dict(entity="A", as_of=4, available_at=8, version=1, value=5),
    dict(entity="A", as_of=10, available_at=10, version=1, value=7),
    dict(entity="A", as_of=4, available_at=5, version=2, value=50),
]

def lookup(records, entity, prediction_time, version):
    candidates = [row for row in records
                  if row["entity"] == entity and row["version"] == version
                  and row["as_of"] <= prediction_time
                  and row["available_at"] <= prediction_time]
    if not candidates:
        return None
    return max(candidates, key=lambda row: (row["as_of"], row["available_at"]))["value"]

for time in (6, 9):
    print(f"A at hour {time}, version 1: {lookup(snapshots, 'A', time, 1)}")
print("A at hour 6, version 2:", lookup(snapshots, "A", 6, 2))
print("unknown entity:", lookup(snapshots, "B", 9, 1))
# A at hour 6, version 1: 2
# A at hour 9, version 1: 5
# A at hour 6, version 2: 50
# unknown entity: None

The hour-6 lookup returns 2 even though an event-time-only lookup could return 5. At hour 9, version 1 returns 5. Version 2 is a separate definition and must not be silently substituted. None means unavailable, not a measured zero. Production retrieval needs a declared age limit and fallback policy, plus monitoring for default use. A time-to-live limit (TTL) used in historical retrieval is not automatically a guarantee of online freshness; check the store’s semantics.

This scan illustrates the selection rules; it is not a feature-store implementation. A historical store might reconstruct snapshots from immutable event and arrival logs, while an online store may retain only the latest value. Replay must model the actual update, caching, expiry, and fallback behavior if the goal is to reproduce served inputs. Log request identifiers, feature and model versions, source cutoffs, retrieval times, and fallback reasons where needed to make that comparison possible.

How fresh does a feature have to be?

m = HistGradientBoostingClassifier(random_state=0, early_stopping=False).fit(
    np.c_[x[:split], past[:split]], y[:split])
print(f"{'staleness (user events)':>24} {'replay AUC':>11} {'prior-only fraction':>20}")
for k in (0, 1, 3, 10, 30, 100):
    stale = np.empty(n_ev)
    initial = np.empty(n_ev, dtype=bool)
    for u, idxs in seq.items():                       # the value from k of this user's events ago
        arr = past[idxs]
        historical_index = np.maximum(np.arange(len(idxs)) - k, 0)
        stale[idxs] = arr[historical_index]
        initial[idxs] = historical_index == 0
    auc = roc_auc_score(y[split:], m.predict_proba(np.c_[x[split:], stale[split:]])[:, 1])
    print(f"{k:24d} {auc:11.4f} {initial[split:].mean():20.4f}")
#  staleness (user events)  replay AUC  prior-only fraction
#                        0      0.7195               0.0004
#                        1      0.7159               0.0022
#                        3      0.7061               0.0348
#                       10      0.6753               0.7590
#                       30      0.6681               1.0000
#                      100      0.6681               1.0000

The table replays one fixed model using values from k earlier events for each user. It measures event-count lag, not seconds or hours. The prior-only fraction shows how much of the held-out population has been reduced to its initial value. At large enough k, all values become the same prior; the resulting score reflects how this fitted model behaves when that feature is constant.

The fresh-versus-prior gap is specific to this fitted model, dataset, and perturbation. It is not the feature’s total value, the score of a model retrained without it, or an upper bound on every future staleness cost. To choose a refresh interval, measure actual feature ages and replay realistic delays, accounting for user activity and delayed events. Compare quality and downstream costs with the latency and operating cost of the proposed pipeline.

The bug that does not raise an exception

from sklearn.linear_model import LogisticRegression

m2 = LogisticRegression(max_iter=2000).fit(np.c_[x[:split], past[:split]], y[:split])
base_scores = m2.decision_function(np.c_[x[split:], past[split:]])
base = roc_auc_score(y[split:], base_scores)
for mult in (1.0, 0.001, 1000.0, -1.0):
    xs = x[split:].copy()
    xs[:, 0] *= mult
    scores = m2.decision_function(np.c_[xs, past[split:]])
    auc = roc_auc_score(y[split:], scores)
    loss = np.mean(np.logaddexp(0, scores) - y[split:] * scores)
    print(f"x0 multiplier {mult:8g}  score AUC {auc:.4f}  AUC drop {base-auc:+.4f}  log loss {loss:.4f}")
# x0 multiplier        1  score AUC 0.7285  AUC drop +0.0000  log loss 0.6081
# x0 multiplier    0.001  score AUC 0.6589  AUC drop +0.0696  log loss 0.6548
# x0 multiplier     1000  score AUC 0.6611  AUC drop +0.0674  log loss 162.8531
# x0 multiplier       -1  score AUC 0.5120  AUC drop +0.2165  log loss 0.7885

These inputs have no physical unit; multiplying the first feature by 0.001 or 1000 mimics an inconsistent unit conversion. The code reports AUC from the linear decision score and a numerically stable logistic loss. Using the decision score for AUC avoids artificial ties from probabilities rounding to 0 or 1. At a multiplier of 1000, score AUC is 0.6611 and log loss is 162.8531. AUC measures ranking, while log loss also reflects probability quality; extreme score scaling can damage the latter substantially. Compatible array shapes and numeric types allow these examples to execute, although range or distribution checks may detect the altered values.

FailureUseful checkLimit
Whole-history aggregatePoint-in-time reconstruction and served-value replayA split applied after leaking feature construction is insufficient
Stale featureFeature age, update lag, and delay replayAcceptable age depends on feature and use case
Unit or sign mismatchUnit contract, ranges, and matched-input parityDistribution checks alone may miss symmetric sign flips
Missing column or defaultSchema, presence, null, and default-use metricsA numeric fallback can hide missingness from a null-rate check

References

Availability-aware replay additionally requires preserving the information about when data became usable; do not assume an event timestamp alone provides it.


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.