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 as-of join 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
Expected result: the naive-join model reports a much lower error offline (the metric is inflated) and a much higher error when you score it on a later, truly unseen period. The as-of model shows a worse offline number that matches the later period. This is the "94% versus 71%" effect of the article in miniature.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What it changes |
|---|---|
| --seed0 | Random seed. |
Our run
Mean absolute error (minutes)
| Model | Offline | Later period | Gap |
|---|---|---|---|
| naive join, random split | 3.76 | 16.6 | 12.8 |
| as-of join, temporal split | 3.33 | 14.8 | 11.4 |
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.77What 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.