Skip to main content

Module 5 — Point-in-time correct joins

If there is one mechanism to master before touching a feature store, it is the point-in-time join. It is not exotic; it is what the store does every time it builds a training dataset. Getting it wrong is how a model reaches a 0.98 AUC in the notebook and a random 0.55 in production, without any bug in the code.

The problem: features aligned by key, not by time

Suppose we want to train a fraud model on labelled events. The labels come from a table events with one row per authorized transaction we later learned was fraudulent or not:

card_idevent_tsis_fraud
c-422026-05-14 13:47:030
c-422026-05-14 15:12:411
c-192026-05-14 08:33:120

We want to attach, for each row, the feature n_tx_last_1h from a card_activity_1h view stored as a time series:

card_idfeature_tsn_tx_last_1h
c-422026-05-14 13:00:002
c-422026-05-14 14:00:005
c-422026-05-14 15:00:0012
c-422026-05-14 16:00:0015

The naive way, which everyone writes at least once, is a plain equality join on card_id:

merged = events.merge(features, on="card_id", how="left")

This is wrong at every row. It attaches to event c-42, 13:47 every feature row for c-42, including the 15:00 and 16:00 values that had not been computed yet at 13:47. The training set now contains, for each label, values from the future — including the 16:00 row that already reflects the fraud we are trying to predict.

The model learns that "when n_tx_last_1h is 15, fraud is likely at 13:47". The pattern is real in the training set; it is inaccessible at serving time because the future has not happened yet. The metric looks brilliant offline and collapses live.

The fix: as-of join with a lookback

The correct join answers, for each event, "what was the latest feature value known at or before this event's timestamp, within the feature's TTL?" In SQL this is often written as an ASOF JOIN or with a correlated subquery; pandas has merge_asof; every feature store implements it internally as get_historical_features.

Written out in pandas so the semantics are visible:

import pandas as pd

events = events.sort_values("event_ts")
features = features.sort_values("feature_ts")

merged = pd.merge_asof(
events,
features,
left_on="event_ts",
right_on="feature_ts",
by="card_id",
direction="backward", # only rows at or before event_ts
tolerance=pd.Timedelta("2h"), # matches the feature view's TTL
)

Three parameters carry the whole meaning.

direction="backward" is what enforces the "no future" rule: only feature rows with a timestamp less than or equal to the event's timestamp are candidates.

by="card_id" aligns rows by entity, so a card only ever sees its own history.

tolerance=pd.Timedelta("2h") matches the feature view's TTL: if the latest feature row is more than two hours older than the event, the value is null — because that is what production would see too, since a value that old has left the online store or has been flagged stale.

Running this join on the example above, event c-42, 13:47 picks the 13:00 row with n_tx_last_1h = 2, not the 15:00 row with 12. That is the value the model would have been given at 13:47:03 in production. Training on it is honest.

The three subtle rules a feature store enforces for you

Doing this by hand is error-prone; a feature store does three additional things you would otherwise forget.

Alignment on the feature source's created_ts, not only its event_ts. If a card swipe at 13:42 was only ingested at 13:47:30, the value derived from it must not be available for a decision made at 13:47:00. Feast filters created_ts <= event_ts before applying merge_asof. The naive join has no idea this constraint exists.

Per-feature-view TTL. Different feature views have different validity windows. card_activity_1h uses a short TTL; country_of_residence is a slow-changing dimension that is valid for years. The store applies the TTL of each view during its own join and unions the results, rather than picking one TTL for the whole dataset.

Deduplication on entity. In a naive merge, if two events fall in the same TTL window of the same feature row, both get that row — which is correct. But if two feature rows arrive between two events (a re-ingested value, a corrected feed), the store picks the most recent one for each event. The rules for "most recent" (largest event_ts, then largest created_ts as tie-breaker) are what stop training from being non-deterministic.

The effect on metrics

We reran the fraud scoring project of module 1 with a naive join and with a point-in-time join, on the same features and the same model.

JoinTrain AUCTest AUCProduction AUC
Naive equality0.980.970.55
Point-in-time correct0.840.820.81

Two numbers are worth staring at. The naive join produces a train and test AUC that are both flattering, because the leak is present in both — splitting the data does not save you from a leak that happens at feature-attachment time. And the production AUC collapses to almost random because the model relied on future information that no longer exists at decision time.

The point-in-time join loses 15 points of AUC on paper. That is not a regression; it is a correction. The 15 points were fictitious. The 0.81 in production is what the model is worth, and 0.81 is what a business can plan on.

Splitting the data does not save you from feature leakage

Train/test split protects you from row leakage (the same row in both). It does not protect you from feature leakage, where features attached to a row contain values from that row's future. A feature that is "the same day's total spend" leaks into every row of that day, in both train and test. Point-in-time joins are the mechanism; train/test split is not.

Summary

  • A naive equality join on the entity attaches future feature values to past events; the training metrics stay good, the production metrics collapse.
  • A point-in-time correct join picks, for each event, the latest feature row whose event and created timestamps are at or before the event's timestamp, within the feature's TTL.
  • A feature store enforces this join plus three subtle rules — created_ts filter, per-view TTL, deterministic deduplication — you would otherwise implement by hand and forget one of.
  • Train/test split does not catch feature leakage; the point-in-time join does, and the honest metric it produces is what the business can plan on.

Next module: materialization and freshness — how the offline history becomes online values, and how you measure whether they are current enough.