Skip to content
About
Main content

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 lands, hours later). A training join on 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

Trained on valid_time rather than available_time, how much better does the model look offline?

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.

    event-time-versus-availability-time.py56 linesDownload
    View 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)
    
  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 event-time-versus-availability-time.py
    uv run event-time-versus-availability-time.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--lag-hours9 Hours between valid_time and available_time.
--seed0 Random seed.

Our run

Mean absolute error

Mean absolute error
Training joinOfflineWith features as served
joined on valid_time134134
joined on available_time135135
Reference runApple M2 · 8 cores · 8 GB
Output · uv run event-time-versus-availability-time.py
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.521
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

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

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.