Skip to content
About
Main content

Leakage inflates the score (naive join versus as-of join)

Measure your machineNeeds Python with pandas, scikit-learn

Problem

A training set built by joining each event to the latest feature value uses information from after the event: the feature as it was computed later, not as it was known at prediction time. When later values carry information about the label, the model learns that information, its offline score looks excellent, and it fails in production, where only past values exist. An takes the latest value known at the event's time, and a temporal split tests on a period after training.

Generate 20,000 deliveries over 200 days for 100 couriers whose speed drifts. Make a feature table that holds, per courier, a daily 30-day average speed valid from the end of each day. Make the delivery time depend slightly on the speed measured on the day after the delivery (a deliberate leak). Train the same model (GradientBoostingRegressor) on a random split of the naive join and on a temporal split of the as-of join, and score both offline and on a later, unseen period with features as served.

The question. How far apart are each model's offline error and its error on the later period?


Predict

Does the as-of join with a temporal split give an offline error that matches the later period?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    leakage-inflates-the-score-naive-join.py56 linesDownload
    View the code
    leakage-inflates-the-score-naive-join.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy", "pandas", "scikit-learn"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Leakage inflates the score: naive join against as-of join.
    
    20,000 deliveries over 200 days for 100 couriers whose speed drifts. A
    feature table holds each courier's 30-day average speed per day, valid from
    the end of that day; the label depends slightly on the speed of the day after
    the delivery (a deliberate leak). A model trained on a random split of a
    naive join (each courier's latest feature value) against one trained on a
    temporal split of an as-of join (the value known at delivery time); both are
    scored offline and on a later, unseen period.
    """
    import argparse, json
    import numpy as np, pandas as pd
    from sklearn.ensemble import GradientBoostingRegressor
    from sklearn.metrics import mean_absolute_error
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    rng = np.random.default_rng(args.seed)
    N_COURIERS, N_DAYS, N_EVENTS = 100, 200, 20_000
    days = pd.date_range("2025-01-01", periods=N_DAYS, freq="D")
    speed = 30 + np.cumsum(rng.normal(0, 0.8, (N_COURIERS, N_DAYS)), axis=1)          # each courier's true daily speed drifts
    feats = pd.DataFrame({"courier_id": np.repeat(np.arange(N_COURIERS), N_DAYS), "valid_time": np.tile(days + pd.Timedelta(days=1), N_COURIERS),
                          "avg_speed_30d": pd.DataFrame(speed.T).rolling(30, min_periods=1).mean().T.to_numpy().ravel()})
    events = pd.DataFrame({"courier_id": rng.integers(0, N_COURIERS, N_EVENTS),
                           "ts": days[0] + pd.to_timedelta(rng.uniform(0, (N_DAYS - 1) * 24, N_EVENTS), unit="h")})
    events["ts"] = events.ts.astype("datetime64[ns]"); feats["valid_time"] = feats.valid_time.astype("datetime64[ns]")
    day = (events.ts - days[0]).dt.days.to_numpy()
    events["delivery_time"] = 600 / speed[events.courier_id, np.minimum(day + 1, N_DAYS - 1)] + rng.normal(0, 1.0, N_EVENTS)   # the deliberate leak
    events = events.sort_values("ts"); feats = feats.sort_values("valid_time")
    naive = events.merge(feats.groupby("courier_id").last().reset_index(), on="courier_id")      # today's value
    asof = pd.merge_asof(events, feats, left_on="ts", right_on="valid_time", by="courier_id", direction="backward")   # value known at ts
    X, y = ["avg_speed_30d"], "delivery_time"
    cut, later_cut = days[0] + pd.Timedelta(days=120), days[0] + pd.Timedelta(days=160)
    fit = lambda d: GradientBoostingRegressor(random_state=0).fit(d[X], d[y])
    mae = lambda m, d: float(mean_absolute_error(d[y], m.predict(d[X])))
    later = asof[asof.ts >= later_cut].dropna()                                        # truly unseen, features as served
    past = naive[naive.ts < later_cut]; train = past.sample(frac=0.8, random_state=0); m_naive = fit(train)
    hist = asof[asof.ts < later_cut].dropna(); m_asof = fit(hist[hist.ts < cut])
    rows = [{"model": "naive join, random split", "offline_mae": mae(m_naive, past.drop(train.index)), "later_mae": mae(m_naive, later)},
            {"model": "as-of join, temporal split", "offline_mae": mae(m_asof, hist[hist.ts >= cut]), "later_mae": mae(m_asof, later)}]
    for r in rows: r["gap"] = r["later_mae"] - r["offline_mae"]
    for r in rows: print(f"{r['model']:<28} offline MAE {r['offline_mae']:.2f}   later period MAE {r['later_mae']:.2f}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"models": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run leakage-inflates-the-score-naive-join.py
    uv run leakage-inflates-the-score-naive-join.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--seed0 Random seed.

Our run

Mean absolute error (minutes)

Mean absolute error (minutes)
ModelOfflineLater periodGap
naive join, random split3.7616.612.8
as-of join, temporal split3.3314.811.4
Reference runApple M2 · 8 cores · 8 GB
Output · uv run leakage-inflates-the-score-naive-join.py
naive join, random split     offline MAE 3.76   later period MAE 16.61
as-of join, temporal split   offline MAE 3.33   later period MAE 14.77
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · pandas 3.1.0 · scikit-learn 1.9.1 · run 2026-10-25

What we found

The naive-join model scored 3.76 minutes offline and 16.6 on the later period. The as-of model did not show the expected honest offline number: it scored 3.33 offline and 14.8 later. Both degraded on the later period by a similar amount, so in this synthetic setup the dominant effect was the drift between periods, not the leak; the leak, a speed one day later, is too similar to the 30-day average to stand out.


Read it

An offline score is only as honest as the join that built its features. Point-in-time correctness (as-of joins on the time a value became known) and a temporal split are the two controls; without them, the better a leaky feature is, the worse the surprise in production.