Event time versus availability time
Measure your machineNeeds Python with pandas, scikit-learn
Problem
A feature has two times: when its value is valid (the end of the day it describes) and when it actually becomes available (after the nightly batch lands, hours later). A training join on valid time gives each event a value that, at prediction time, did not yet exist for the events in the hours between. The share of affected events is the lag divided by the update interval, and a model trained that way is slightly too good offline.
Extend the leakage lab. Add an available_time column to the features equal to valid_time + 9 hours. First join with valid_time <= ts. Then join with available_time <= ts. Count how many events receive a different feature value under the two rules. Then train a model on each and score both on a later period with features as served (by availability).
The question. What share of events get a different feature value, and does the valid-time model look too good?
Predict
For events during the nine hours after each feature's valid_time, the two rules choose different values: about 9/24 = 37.5% of events should differ. The model trained with valid_time still looks slightly too good.
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
event-time-versus-availability-time.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 """Event time against availability time. The leakage lab's features, each now also carrying available_time = valid_time + 9 hours (a nightly batch that lands in the morning). An as-of join on valid_time against one on available_time: how many events receive a different feature value, against 9/24; then a model trained on each, scored on a later period with features joined as they would really have been served. """ 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("--lag-hours", type=float, default=9, help="Hours between valid_time and available_time.") 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) 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()}) feats["valid_time"] = feats.valid_time.astype("datetime64[ns]"); feats["available_time"] = feats.valid_time + pd.Timedelta(hours=args.lag_hours) 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]"); day = (events.ts - days[0]).dt.days.to_numpy() events["delivery_time"] = 600 / speed[events.courier_id, np.minimum(day, N_DAYS - 1)] + rng.normal(0, 1.0, N_EVENTS) events = events.sort_values("ts") by_valid = pd.merge_asof(events, feats.sort_values("valid_time"), left_on="ts", right_on="valid_time", by="courier_id", direction="backward") by_avail = pd.merge_asof(events, feats.sort_values("available_time").drop(columns="valid_time"), left_on="ts", right_on="available_time", by="courier_id", direction="backward") both = by_valid[["avg_speed_30d"]].reset_index(drop=True).join(by_avail[["avg_speed_30d"]].reset_index(drop=True), rsuffix="_avail").dropna() differ = float((both.avg_speed_30d != both.avg_speed_30d_avail).mean()) cut = days[0] + pd.Timedelta(days=150); X, y = ["avg_speed_30d"], "delivery_time" later = by_avail[by_avail.ts >= cut].dropna() # served features are the available ones rows = [] for label, d in (("joined on valid_time", by_valid), ("joined on available_time", by_avail)): tr = d[d.ts < cut].dropna(); te = d[d.ts >= cut].dropna() m = GradientBoostingRegressor(random_state=0).fit(tr[X], tr[y]) rows.append({"training": label, "offline_mae": float(mean_absolute_error(te[y], m.predict(te[X]))), "served_mae": float(mean_absolute_error(later[y], m.predict(later[X])))}) summary = {"differ_pct": differ * 100, "expected_pct": args.lag_hours / 24 * 100} print(f"events whose feature value differs between the two joins: {differ:.1%} (expected {args.lag_hours / 24:.1%})") for r in rows: print(f"{r['training']:<26} offline MAE {r['offline_mae']:.3f} MAE with features as served {r['served_mae']:.3f}") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "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 event-time-versus-availability-time.py uv run event-time-versus-availability-time.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --lag-hours9 | Hours between valid_time and available_time. |
| --seed0 | Random seed. |
Our run
Mean absolute error
| Training join | Offline | With features as served |
|---|---|---|
| joined on valid_time | 134 | 134 |
| joined on available_time | 135 | 135 |
events whose feature value differs between the two joins: 36.8% (expected 37.5%)
joined on valid_time offline MAE 134.310 MAE with features as served 134.351
joined on available_time offline MAE 134.521 MAE with features as served 134.521What we found
36.8% of events received a different feature value under the two joins, against an expected 37.5%. The models barely differed: trained on valid time, offline MAE 134 and 134 with features as served; trained on availability, 135 and 135. The valid-time model did look slightly too good, but by a margin within noise: a 30-day average changes little in nine hours.
Read it
As-of joins need the right clock: the time a value became available to the system that will use it, not the time it describes. Record availability time in feature stores, and join on it.