Features And Feature Stores

Point-in-Time Joins: A Latest-Value Join Promised 0.714 and Delivered 0.301

0 of 26 complete

0%

Contents

Back|Features And Feature StoresPoint-in-Time Joins: A Latest-Value Join Promised 0.714 and Delivered 0.301
1/26
62 min left
Prerequisites
What a Feature Is: A Better Model or a Better Feature?required
Related Topics
Leakage Before the Split: How Pure Noise Scored 93% AccuracyData Engineering for ML
1 of 26

A Test With Tomorrow's Notes

A friend says he can guess which of your neighbours will go to the corner shop next week. You want to test him. So you keep a notebook about each neighbour: how often they went to the shop, how much they spent, when they last went. On the first of the month you plan to hand him the notebook, as it was that morning, and ask him to guess.

But you are busy, and you test him a year later. You hand him the notebook as it is now. The pages now say things like "went to the shop on the 7th". He reads them, and he guesses almost perfectly.

A flat illustration of a meeting room. A man stands at a whiteboard showing a flowchart of boxes and arrows. Two colleagues sit at a table with a notebook and a mug, watching him. Below the scene: the score shown in the meeting is only as honest as the rows it was measured on.

Was he good at guessing? You cannot tell. The notebook you gave him already held part of the answer. Next month, when you ask him for real, he will only have the notebook as it is on that day, and his guesses will be much worse.

Machine learning teams make this mistake with real data, and the person in the picture above is often the one who shows the too-good score in a meeting. In this lesson I measure how much it inflates the score, on real data from a real shop. Then I measure how far the score falls when the model meets the future for real.

Where This Lesson Starts

The previous lesson, what a feature is, introduced this chapter's data and its one prediction task. This lesson keeps both and asks one question about them.

The feature stores lesson in the foundation course explains point-in-time correctness in words: each training row must see only the values that existed at its own moment. It also has a tiny example with four made-up numbers. I will not repeat it. Here I measure the same idea on 44,521 real training rows, with a real model, and put numbers on the damage.

The lesson on leakage before the split is the closest relative. It measured leaks from preparation steps that run before the data is split. It also named one leak it did not test: columns that hold information from after the moment of prediction. This lesson tests exactly that leak, in the form it usually takes in practice, a careless join.

The question is simple. If a team builds its training table by joining each customer's latest feature values, how much better does the model look offline than it really is?

The Words You Need First

Please read this slide slowly if any word is new. Every slide after it uses these words.

A hand-drawn glossary of seven words, each with a short meaning: feature, one number about a customer that the model reads; feature table, a table that stores each feature's value with the time it became true; cutoff, the moment we predict from; label, did the customer buy in the 30 days after the cutoff; join, attaching a row from one table to a row of another; point-in-time join, attaching the newest value that existed at the cutoff; leak, information from after the cutoff that slips into a training row.

Feature. One number about a customer that the model reads, such as how many times they have bought. The model learns from features, not from raw records.

Feature table. A table that stores each feature's value together with a timestamp: the time from which that value is true. When the value changes, the table gets a new row. The old row stays, so the table holds the whole history.

Cutoff. The moment we make a prediction from. Nothing that happened at or after the cutoff may be known to the model. In this lesson the cutoffs are the first of each month at midnight.

Label. The answer the model learns to predict. Here: did the customer buy anything in the 30 days starting at the cutoff, yes or no.

Join. Attaching a row from one table to a row of another, matched by something they share, here the customer's id.

Point-in-time join. A join that takes, for each training row, the newest feature row that already existed at that row's cutoff. It is also called an as-of join, because it asks "what was true as of this time?".

Latest-value join. A join that takes each customer's newest feature row in the whole table, whatever the cutoff. It is what you get from a query that asks for the current values.

Leak. Information from after the cutoff that slips into a training row.

How I Score a Model

Two more words, because every result slide uses them.

Average precision (AP). The model gives every customer a score: how likely they are to buy. Sort the customers from highest score to lowest. Walk down the list. Each time you reach a real buyer, note what share of the customers so far were buyers. Average those shares. A perfect model puts all the buyers first and scores 1.

A model that sorts at random scores about the share of buyers in the data, which I call the random guess. I work out AP separately for each cutoff and then average, so every month counts the same. Averaged over the test months, the share of buyers is 0.196, so 0.196 is the floor to beat.

ROC-AUC. Pick one real buyer and one customer who did not buy, at random. ROC-AUC is the chance that the model gives the buyer the higher score. A coin flip gives 0.5 and a perfect model gives 1. It does not move when the share of buyers changes, which AP does, so I report both.

Hold-out. Rows kept aside while the model learns, then used to score it. A random hold-out is a random 20 percent of the rows.

Seed. A number that fixes a random draw, so it can be repeated exactly. I draw the random hold-out 20 times, with seeds 0 to 19, and report the spread, so one lucky draw cannot fool me.

Offline score. The score a team sees before launch, on a hold-out from its own training table. Serving. Running the model on live data, after launch.

The Shop, the Customers and the Question

The data is UCI Online Retail II, a public dataset under a CC BY 4.0 licence, which lets anyone use it as long as they credit the source. It holds every sale of a UK online shop from 1 December 2009 to 9 December 2011. After this chapter's fixed cleaning, which drops lines with no customer id and keeps returns as flagged rows, it has 44,876 invoices from 5,942 customers. An invoice is one order, and a return is an invoice whose number starts with "C".

The question is the chapter's fixed one. On the first of a month, for every customer who had bought or returned anything before that day, will they buy again in the next 30 days?

Each pair of a customer and a cutoff is one row. The training rows come from 13 cutoffs, March 2010 to March 2011: 44,521 rows, and 23.4 percent of them bought. The test rows come from 5 later cutoffs, July to November 2011: 26,851 rows, and 19.7 percent bought, which averages to 0.196 when each month counts the same. Three cutoffs in between, April to June 2011, are a validation set, a set kept for checking choices. This lab makes no choices, so it only records their scores. On them, per cutoff, the point-in-time model scored an AP of 0.500 and the latest-value model 0.284.

The test cutoffs are never used to choose anything. They stand in for the months after launch.

The Feature Table, as a Nightly Job Writes It

A real feature table is usually written by a job that runs on a schedule, often once a night. I built mine the same way.

For every customer, on every day they had at least one invoice, the job writes one row. The row holds running totals over all of that customer's invoices up to the end of that day:

  • frequency: how many buy invoices so far.
  • money: how much they have spent so far, in pounds.
  • return share: returns divided by all invoices so far.
  • last buy: the time of their latest buy so far.

Each row also gets a timestamp, which I call "valid from". It is midnight after the day the row covers, the moment the night's job has seen the whole day. A day with no invoices writes no row, because nothing changed. That gives 38,502 rows for 5,942 customers.

A table of the eight rows the nightly job wrote for customer 12347, with columns valid from, frequency, money and last buy. The first row, valid from 2010-11-01, has frequency 1, money 611.53 and last buy 2010-10-31, and is marked point-in-time. The last row, valid from 2011-12-08, has frequency 8, money 4921.53 and last buy 2011-12-07, and is marked latest. Below: for the cutoff 2010-12-01, the newest row valid before it is the first one; the other seven did not exist yet.

Here is the whole table for one real customer, number 12347. This is a public dataset, so a real id is fine. The customer bought eight times in two years, and each buy added a row. Notice that no old row is ever changed. The table remembers what was true on each day, which is exactly what a point-in-time join needs.

Recency is not stored. Recency, the number of days since the last buy, changes every single day, even when the customer does nothing. Storing it would need a row for every customer on every day. So the table stores the time of the last buy, and recency is worked out at join time: the cutoff minus the last buy. This is a common pattern, and it matters later, because it is where the most obvious leak shows up.

Three Ways to Join the Same Table

Now the training rows need features. Each row is a customer and a cutoff, and the table has many rows per customer. Which one should it take? The lab tries three answers.

(a) Point-in-time. Take the newest row whose "valid from" is at or before the cutoff. In pandas, the most common Python library for tables, this is one call: pd.merge_asof, matched by customer, looking backward in time from the cutoff.

(b) Latest value. Take the customer's newest row in the whole table, whatever the cutoff. This is what a team gets when it asks a feature table for its current values and attaches them to old training rows.

(c) Off by one day. The same rows as (a), but stamped with the start of the day they cover instead of the end. I come back to this one later.

A sequence diagram with four lifelines: training row, merge_asof, feature table and model. Step 1, the training row hands merge_asof customer 12347 and the cutoff 2010-12-01. Step 2, merge_asof asks the feature table which rows are valid at or before T. Step 3, the table answers with the newest one, stamped 2010-11-01. Step 4, merge_asof hands the model frequency 1 and recency 30.4. Below: a latest-value join skips step 2 and takes the row stamped 2011-12-08.

Here is what the as-of join does for one training row. Step 2 is the whole idea: it only looks at rows that already existed. A latest-value join never asks that question.

A hand-drawn sketch of one training row. At the top: the question, asked on 2010-12-01, will this customer buy in the next 30 days. Below it, the point-in-time answer: the row of 2010-10-31, frequency 1, recency 30.4 days. Below that, the latest answer: the row of 2011-12-07, frequency 8, recency -371.7 days. Underneath: the latest row was written more than a year after the question, and the real answer was yes, the next buy came on 2010-12-07.

For customer 12347 at the cutoff 1 December 2010, the two joins give very different rows. Point-in-time says: one buy so far, the last one 30.4 days ago. Latest value says: eight buys, the last one 371.7 days in the future, which shows up as a negative recency of -371.7. The latest row also counts the buy of 7 December 2010, the very buy the label is asking about.

The Same Choice, Drawn as Blocks

An isometric drawing of eight blocks in a row, one per row of customer 12347's feature table, each as tall as the frequency it holds, from 1 to 8. Under each block, the month it became valid, from Nov 2010 to Dec 2011, and its frequency. A dashed line marks the cutoff 2010-12-01, just after the first block. The first block, left of the line, is the one point-in-time takes; the last block, at the far right, is the one latest takes.

Think of the feature table as a shelf of blocks, one per version, placed in time order. The cutoff is a line across the shelf. A point-in-time join may only reach to the left of the line, and takes the nearest block there. A latest-value join reaches to the far end of the shelf, wherever the line is.

The blocks also show why the leak is worse for some rows than others. For a cutoff late in the data, the last block is close to the line and holds little extra. For an early cutoff, like this one, the last block is a year away and holds a year of buying. The training rows in this lab come from cutoffs in 2010 and early 2011. The table runs to December 2011, so most training rows sit far from the end of the shelf.

How the Lab Was Built

I wrote the lab's design into the docstring of scripts/labs/features/point_in_time.py before it ran. A docstring is the note at the top of a Python file. The design fixed the table, the three joins, the counts, the model and every score below. Nothing in it was chosen after seeing a result, except the changes a review asked for, which the docstring and this lesson label.

A flowchart. 44,876 invoices feed a nightly table of 38,502 rows. The table is joined three ways onto 44,521 training rows. The same model learns from each, and is scored two ways: offline on a random 20 percent, and on the test months. Below: the three joins are point-in-time, latest value and off by one day; offline repeats with 20 seeds; the test months always get point-in-time features, as a live system would.

  1. A check first. Before any model, the lab recomputes all four features for every training, validation and test row. It works straight from the raw invoices before each cutoff, with no feature table at all. The point-in-time join must agree with that on every value. It does, on 344,180 values.

  2. The model. HistGradientBoostingClassifier from scikit-learn, a common library for this kind of model, with random_state=0 and its other settings left at their defaults. It grows many small decision trees, chains of yes-or-no questions about the columns, each tree fixing the mistakes of the ones before. One default matters: above 10,000 rows it turns on early stopping, which keeps 10 percent of the training rows aside to decide when to stop adding trees. Every training table here is above that size, so it is on in every fit.

  3. The offline score. On each join's own training table: hold out a random 20 percent, train on the other 80, score the 20. The score is worked out for each of the 13 training cutoffs and averaged, the same way as the test months. Twenty seeds.

  4. The serving score. Train once on the whole table, then score the five test cutoffs. The test rows always get point-in-time features, because a live system can only read values that exist when it predicts.

The Lab, Rebuilt and Checked

This is a real recording of the report script, pit_report.py, run on the laptop where the lab ran. It does not trust the lab. It rebuilds every number from the raw invoices with its own code, and stops on the first one that does not match.

A terminal recording of pit_report.py. It prints five steps: the joins rebuilt from raw invoices, all equal; the naive join, 31,489 of 44,521 training rows reading the future, median 334.6 days ahead, 10th to 90th percentile 104.5 to 547.3; customer 12347 at 2010-12-01, both joined rows agree; 8 training tables of 21 fits each refitted, all scores agree; and, post-review, two cut tables and the leaky test set, all agree. Then a table of offline AP with its range, test AP and the gap: point-in-time 0.540 and 0.522, exact=False 0.540 and 0.521, off by one day 0.571 and 0.521, latest Dec 2011 0.714 and 0.301 with a gap of +0.413, latest 2011-04 0.828 and 0.197, latest 2011-07 0.767 and 0.201, and a random guess of 0.247 and 0.196, all AP per cutoff then averaged. A second table gives one latest column at a time: recency_days 0.647 and 0.205, frequency 0.642 and 0.497, money 0.589 and 0.506, return_share 0.570 and 0.522. Then the latest model on test months also joined latest: AP 0.822, AUC 0.951. The last line says all 1,107 checks agree with pit-result.json.

The report's trick is that every join in this lab is the same question with a different time limit. Point-in-time uses the invoices before the cutoff. Latest value uses every invoice in the data. Off by one day uses the invoices before the end of the cutoff day. So the report can rebuild each join's features straight from the invoices, with no feature table and no merge_asof, and compare.

It then refits every model. One honest detail: my first version refitted on its own rebuilt columns and failed at hold-out seed 3, in the sixth decimal place. Money added up in a different order differs around the thirteenth digit, and that was enough to move one internal boundary inside the model. So the report now proves the columns equal first, then refits on the lab's columns.

In the recording, "latest, Dec 2011" is the latest-value join. The two other "latest" rows are tables built earlier, which a later slide covers. "exact=False" is a setting I explain later. "10th to 90th percentile" means the range that holds the middle 80 percent of the rows.

Promised 0.714, Delivered 0.301

Here is the headline. The first number is what a team would see offline. The second is what the same model gave on the later test months.

A bar chart of average precision per cutoff, two groups. Point-in-time: offline about 0.54, test months about 0.52. Latest value: offline about 0.71, test months about 0.30. A dashed line marks a random guess at 0.196. Below: latest value, offline 0.714, test 0.301; point-in-time, offline 0.540, test 0.522.

training tableoffline AP, 20 seedstest months AP
point-in-time0.540 (0.521 to 0.550)0.522
latest value0.714 (0.707 to 0.729)0.301
random guess0.2470.196

Every AP here is worked out per cutoff and then averaged. The latest-value table promised an average precision of 0.714. The test months gave 0.301, a gap of 0.413. That is closer to a random guess, 0.196, than to the honest model.

The point-in-time table promised 0.540 and gave 0.522, a small drop of 0.019. A random hold-out from earlier months is not the same as later months, so some drop is expected, and it is not a leak.

So the leak did two things. It made the offline score look 0.174 better than the honest table's offline score. And it made the real model worse: on the test months, the honest model beat it by about 0.22.

The random-guess row differs between the columns because AP's floor is the share of buyers, and the training months had more buyers than the test months.

Not One Lucky Draw

A random hold-out is one draw. A different seed puts different rows in it, and the score moves. So I drew it twenty times.

A dot chart of offline AP per cutoff, one dot per hold-out seed, 0 to 19, for each join. The latest-value dots sit in a tight band near 0.71. The point-in-time dots sit in a tight band near 0.54. Two dashed lines mark the test-month scores: latest value on test at 0.301, far below its dots, and point-in-time on test at 0.522, just under its dots. Below: offline, latest value ran 0.707 to 0.729, point-in-time 0.521 to 0.550.

Across the 20 seeds, the latest-value table's offline AP ran from 0.707 to 0.729. The point-in-time table's ran from 0.521 to 0.550. The two bands do not touch: the lowest latest-value seed is above the highest point-in-time seed.

The test scores have no seed spread, because the model is trained once with random_state=0 and the test months are fixed. The serving score of the latest-value model, 0.301, sits far below every one of its own offline dots. None of the 20 hold-outs came close to the truth. The point-in-time model's test score, 0.522, sits just under its dots: 19 of its 20 seeds scored a little higher offline.

This is what makes it dangerous. Repeating the offline check more carefully does not help. Every random hold-out is cut from the same leaky table, so every one of them leaks in the same way.

The Same Story on a Second Score

AP depends on how many buyers there are, and the training months had more buyers than the test months. So I also report ROC-AUC, which does not move with that share.

A bar chart of ROC-AUC per cutoff, two groups. Point-in-time: offline about 0.74, test months about 0.79. Latest value: offline about 0.88, test months about 0.53. A dashed line marks a random guess at 0.5. The vertical axis starts at 0.4, not 0. Below: latest value, offline 0.877, test 0.532; point-in-time, offline 0.743, test 0.789.

On ROC-AUC, also per cutoff, the latest-value table promised 0.877 and gave 0.532 on the test months, just above a coin flip at 0.5. The point-in-time table promised 0.743 and gave 0.789.

So the picture does not depend on which score you pick. The leaky model looks clearly better offline and is close to useless on later months. The honest model looks modest offline and holds up.

The honest model moves in opposite directions on the two scores: AP drops a little, from 0.540 to 0.522, while ROC-AUC rises, from 0.743 to 0.789. The share of buyers, AP's floor, is 0.196 in the test months against 0.247 in training. That lower share can explain AP falling, but not ROC-AUC rising. Two other differences I did not test: the test-month model learned from all the training rows, not 80 percent of them, and the test months are different months. Neither is a leak: both scores use point-in-time features.

How Much of the Future Got In

Three panels about the latest-value join on 44,521 training rows. Rows from the future: 31,489, which is 70.7 percent of all training rows. How far ahead: 334.6 days, the median, with the middle 80 percent from 104.5 to 547.3. Negative recency: 31,230 rows whose last buy is after the cutoff. Below: a row is from the future when the joined row includes an invoice on or after the cutoff.

The lab counted the leak directly. A training row reads the future when the row joined to it includes any invoice on or after the cutoff.

With the latest-value join, 31,489 of the 44,521 training rows read the future: 70.7 percent. The median row looked 334.6 days ahead, nearly a year. The middle 80 percent of them looked between 104.5 and 547.3 days ahead.

31,230 rows had a negative recency, a last buy after the cutoff. A careful person might spot negative numbers and stop. But the other columns leak too, with nothing strange in them. A frequency of 8 instead of 1 is a perfectly normal-looking number.

Only 13,032 rows read nothing from the future. Those are customers who never made another invoice after the cutoff, so their latest row is also their point-in-time row.

Why the Latest Row Is Such a Strong Hint

Why does this leak help so much offline? The counts answer it.

A hand-sketched column of three boxes joined by arrows, titled the leak, counted by hand. Rows whose latest row reaches past the cutoff: 31,489, and 33.1 percent of them bought. Rows with nothing after the cutoff: 13,032, and 0.0 percent of them bought. A buy after the cutoff always writes a new row, so no new row means no buy. Below: that last line is true by construction; the table leaks the label's shadow.

Of the rows whose latest row reaches past the cutoff, 33.1 percent bought in the 30-day window. Of the rows with nothing after the cutoff, 0.0 percent bought.

That zero is not a finding. It is true by construction. If a customer buys in the window, that buy writes a new row after the cutoff, so their latest row must reach past it. A row with nothing after the cutoff therefore cannot be a buyer. The latest-value table splits the rows into "certainly did not buy" and "might have bought" before the model learns anything.

On top of that, the latest row carries the buy itself: its frequency is higher, its money is higher and its recency is negative. The model does not need to learn anything about customers. It only needs to notice which rows carry traces of the window it is meant to predict.

Is the End-of-Data Table an Unfair Example?

A fair objection: no team trains in 2011 on a table that runs to the end of 2011. The latest row in my table is often a year after the cutoff, because the data ends in December 2011. A real team builds its training set on some day, from the table as it is that day. So, after a review, I measured two more realistic teams. This slide was added after the first results.

The first team builds right after the training months, from the table as it stood on 1 April 2011. The second builds on 1 July 2011, just before the test months. Each takes every customer's newest row in the table on that day and joins it to every training row.

A bar chart of average precision per cutoff for latest-value tables built on three dates: April 2011, July 2011 and December 2011. Each has an offline bar and a test-months bar. The offline bars are tallest for April, about 0.83, then July, about 0.77, then December, about 0.71. The test bars are about 0.20, 0.20 and 0.30. A dashed line marks a random guess at 0.196. Below: built in April 2011, offline 0.828, test 0.197; 23,733 rows read the future, a median 133.7 days ahead.

table built onrows from the futuremedian days aheadoffline APtest AP
1 April 201123,733133.70.8280.197
1 July 201127,604187.60.7670.201

Which Column Carries the Leak?

The design asked one more question before the run: which of the four columns carries the leak? For each column, the lab built a training table with that one column from the latest row and the other three point-in-time.

A bar chart of average precision per cutoff for six training tables: none of the columns from the latest row, then recency, frequency, money and returns alone, then all four. Each has an offline bar and a test-months bar. A dashed line marks a random guess at 0.196. Recency alone has a high offline bar and a test bar near the random line. Below: recency alone, offline 0.647, test 0.205; frequency alone, offline 0.642, test 0.497.

column from the latest rowoffline APtest AP
none (point-in-time)0.5400.522
recency0.6470.205
frequency0.6420.497
money0.5890.506
return share0.5700.522
all four0.714

Off by One Day

The latest-value join is a big, obvious mistake once you know to look for it. The next one is small and easy to miss.

A page in four labelled zones about what the timestamp on a row means. A row for day D: running totals over every invoice up to the end of day D. This lab: valid from D plus 1 at 00:00, so a row stamped exactly at the cutoff holds only days before it, and the join may take it. The slip: stamped D at 00:00, so the row for the cutoff day itself gets taken, with that day's buys inside. Recency: not stored, worked out at join time as the cutoff minus the last buy. Below: the same rows, the same join, only the stamp moved by one day.

Many tables are split by date, one part per day. It is natural to stamp a day's row with that day's date, which means midnight at the start of the day. But the row holds totals up to the end of the day. If you then join "at or before the cutoff", the row for the cutoff day itself gets matched, and it holds that day's buys. Those buys are inside the label window.

A hand-drawn sketch of two boxes. 576 training rows took the cutoff day's own row, 1.3 percent of 44,521. 93.1 percent of them bought in the window, against 22.5 percent of the other rows. Below: offline AP rose from 0.540 to 0.571; on the test months it was 0.521, against 0.522; a small leak, but it flatters only the offline number.

The lab measured it rather than assuming it. Stamping at the start of the day let 576 training rows, 1.3 percent, take the cutoff day's own row. Of those rows, 93.1 percent bought in the window, against 22.5 percent of the rest. Of course they did: many of them were buying that very day.

The offline AP, per cutoff, rose from 0.540 to 0.571, and it was higher in all 20 seeds. On the test months the score was 0.521, against 0.522 for the correct table. So this slip did not hurt the real model much here. It only made the offline number lie by about 0.031. Take a different task, such as predicting a buy in the next day instead of the next 30. Then the cutoff day would be most of the window, and the same slip could matter far more. I did not test that.

allow_exact_matches: True or False?

pd.merge_asof has a setting called allow_exact_matches. With True, its default, it may take a row stamped exactly at the cutoff. With False, only rows stamped strictly before it. Which one is right depends on what your stamp means, so I chose it on purpose.

Four numbers about merge_asof's allow_exact_matches. Rows matched exactly at the cutoff: 768. Rows that change with False: 767. Test AP with True: 0.522. Test AP with False: 0.521. Below: with rows stamped at the end of the day, True is correct, and False only makes those rows a day older.

In this lab a row is stamped at midnight after the day it covers. A row stamped exactly at the cutoff therefore covers the day before the cutoff, and holds nothing from the cutoff onward. It is legal, and it is the freshest legal row. So True is correct here.

I also ran False, as a check on that reasoning. 768 training rows had a row stamped exactly at their cutoff: customers with an invoice on the last day of the month. With False, 767 of them took an older row instead. The test AP went from 0.522 to 0.521. Nothing leaks either way; False only throws away one day of freshness.

The one row that did not change belongs to a customer who, so far, had only returned things, so the older row held the same values. I found that after the results, in the report script.

The general rule: if your stamp means "valid from", use True. If your stamp is the time of the event itself, and a feature row stamped exactly at the cutoff could hold an event at the cutoff, use False. If you do not know which your stamp means, find out before you train. If you cannot find out, use False. With daily rows like these, it costs at most a day of freshness, while True can leak. With weekly or hourly rows, the cost is one row's period, a week or an hour.

The Joins in the Lab's Code

The lab is one Python file, scripts/labs/features/point_in_time.py. Four functions carry the whole lesson.

feature_table is the nightly job. It groups the invoices by customer and day, adds up each day, then keeps running totals with cumsum, which adds each day to all the days before it. It stamps each row with the next midnight. One line carries the time of the last buy forward with ffill, which fills a gap with the value above it. My first run was missing that line.

join_pit is the point-in-time join, one call to pd.merge_asof. It sorts the training rows by cutoff and the table by stamp, because merge_asof needs both sorted by the time it matches on. It matches by customer, with direction="backward", which means "the newest row at or before", and allow_exact_matches=True. Then it works out recency as the cutoff minus the last buy, in days.

join_latest is the mistake, written on purpose. It keeps each customer's newest row with groupby("customer_id").tail(1) and merges it onto every training row. It is shorter than the correct join, and it runs without any error.

brute_force is the check. For each cutoff, it takes only the invoices before it and counts everything again from scratch. It is slow and simple, which is the point: it is too simple to share a bug with the clever version.

Try It Yourself

The full lab trains 211 models and took about seven and a half minutes on my laptop. I wrote a small demo that does the heart of it: the table, the two main joins, the leak count, customer 12347, and one hold-out per join.

A page in four labelled zones, headed pit_demo.py, designed before it ran. The data: UCI Online Retail II, downloaded once and cached. The table: the nightly job, 38,502 rows. The joins: point-in-time and latest value, the lab's own code made small. The scores: one hold-out, seed 0, and the five test months. Below: it had to equal the lab, 31,489 future rows, test AP 0.301 and 0.522; it did, on 20 numbers.

I wrote the demo's design into its docstring on 1 October 2026, after the lab had run and before the demo ran. The design names the numbers it must reproduce. To get the same hold-out rows as the lab, it keeps the training rows in the lab's order, because a seed picks row positions, not customers.

A real screenshot of VS Code with pit_demo.py open at the top of the file, showing its docstring: what it asks, the libraries it needs, how to run it, and the design written before the first run, followed by the first imports.

Before you run this lab. You need Python 3 and four libraries: pip install pandas pyarrow openpyxl scikit-learn. The first run downloads the dataset, about 45 MB, and reads its Excel sheets. On my Mac that first run took about a minute. Later runs read a cached file and take a few seconds. It needs no GPU, the graphics chip many machine learning jobs use. I ran it with scikit-learn 1.9.1 and pandas 3.0.6 on a Mac. These libraries run on Windows and Linux too, but I have not checked the numbers there. Give it a file name, python pit_demo.py out.json, and it also saves every number. That is how results/pit-demo.json was made.

"""Point-in-time joins: how much does a "latest value" join flatter
the offline score?

Lesson 2 of 'Features and Feature Stores', made small. It needs
Python 3 with pandas, pyarrow, openpyxl and scikit-learn:
    pip install pandas pyarrow openpyxl scikit-learn
    python pit_demo.py            # print the results
    python pit_demo.py out.json   # and save every number
The first run downloads UCI Online Retail II (CC BY 4.0, about
45 MB) and reads its two Excel sheets, which can take a minute or
more, once. After that it reads a cached file and runs in seconds.
It prints no timings.

Design, written 2026-10-01 before the first run. The lab behind the
lesson (point_in_time.py) ran the same day; I had its stored results.
  Data: invoice lines with a customer id, returns kept and flagged.
  Task: at the 1st of each month T, will a customer seen before T
  buy in the 30 days from T? Train on 2010-03..2011-03, test on
  2011-07..2011-11, as in the lab.
  Table: one row per customer per day with an invoice, holding
  running totals to the end of that day, stamped the next midnight.
  Recency is worked out at join time, from the last buy to T.
  Joins: point-in-time (merge_asof, rows stamped at or before T) and
  naive (each customer's newest row, whatever T is).
  Model: HistGradientBoostingClassifier(random_state=0).
  It prints: rows whose naive value is from the future and how far;
  customer 12347 at 2010-12-01 both ways; and for each join, average
  precision (AP) on a random 20% hold-out (seed 0 only; the lab uses
  20 seeds), then AP on the test months with point-in-time features.
  It must agree with the lab: 31,489 future rows, and seed 0 and the
  test scores to the last digit. pit_report.py demo checks this.
Changed after a review, 2026-10-01: the hold-out is now scored like
the test months, AP for each cutoff and then the average. The first
version pooled the hold-out's 13 cutoffs into one AP.

Author: Roni Das
Created: 2026-10-01
"""
import io
import json
import sys
import urllib.request
import warnings
import zipfile
from pathlib import Path

import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import average_precision_score as ap_score
from sklearn.metrics import roc_auc_score as auc_score
from sklearn.model_selection import train_test_split

warnings.filterwarnings("ignore")
URL = ("https://archive.ics.uci.edu/static/public/502/"
       "online+retail+ii.zip")
CACHE = Path.home() / "lab-data" / "features" / "retail.parquet"
COLS = ["recency_days", "frequency", "money", "return_share"]
DAY = pd.Timedelta(days=1)
TRAIN = pd.date_range("2010-03-01", "2011-03-01", freq="MS")
TEST = pd.date_range("2011-07-01", "2011-11-01", freq="MS")


def load():
    """Invoice lines with a customer id, sorted by time."""
    if not CACHE.exists():
        raw = urllib.request.urlopen(URL).read()
        z = zipfile.ZipFile(io.BytesIO(raw))
        x = [n for n in z.namelist() if n.endswith(".xlsx")][0]
        a, b = pd.read_excel(z.open(x), sheet_name=None).values()
        df = pd.concat([a[a["InvoiceDate"] < b["InvoiceDate"].min()],
                        b], ignore_index=True)
        df = df.rename(columns={"Customer ID": "customer_id",
                                "InvoiceDate": "ts",
                                "Invoice": "invoice"})
        df = df.dropna(subset=["customer_id"])
        df["customer_id"] = df["customer_id"].astype(int)
        df["invoice"] = df["invoice"].astype(str)
        df["is_return"] = df["invoice"].str.startswith("C")
        df["amount"] = df["Quantity"] * df["Price"]
        df = df.sort_values(["ts", "invoice"], kind="stable")
        CACHE.parent.mkdir(parents=True, exist_ok=True)
        df[["invoice", "ts", "customer_id", "is_return",
            "amount"]].to_parquet(CACHE, index=False)
    return pd.read_parquet(CACHE)


def labels(ev, cutoffs):
    """(customer, T) rows: seen before T; buys in [T, T + 30 days)?"""
    buys = ev[~ev["is_return"]]
    out = []
    for t in cutoffs:
        seen = ev.loc[ev["ts"] < t, "customer_id"].unique()
        win = buys[(buys["ts"] >= t) & (buys["ts"] < t + 30 * DAY)]
        df = pd.DataFrame({"customer_id": seen, "cutoff": t})
        df["label"] = df["customer_id"].isin(set(win["customer_id"]))
        out.append(df.astype({"label": int}))
    return pd.concat(out, ignore_index=True)


def feature_table(ev):
    """The nightly job: running totals per customer per active day."""
    inv = ev.groupby(["customer_id", "invoice"], sort=False).agg(
        ts=("ts", "min"), ret=("is_return", "first"),
        amount=("amount", "sum")).reset_index()
    inv["day"] = inv["ts"].dt.normalize()
    inv["buy"] = (~inv["ret"]).astype(int)
    inv["spent"] = inv["amount"].where(~inv["ret"], 0.0)
    inv["buy_ts"] = inv["ts"].where(~inv["ret"])
    d = inv.groupby(["customer_id", "day"]).agg(
        buys=("buy", "sum"), rets=("ret", "sum"),
        money=("spent", "sum"), last_buy=("buy_ts", "max"),
        last_ts=("ts", "max")).reset_index()
    g = d.groupby("customer_id")
    d["frequency"] = g["buys"].cumsum()
    d["money"] = g["money"].cumsum()
    n_all = d["frequency"] + g["rets"].cumsum()
    d["return_share"] = (n_all - d["frequency"]) / n_all
    d["last_buy_ts"] = g["last_buy"].cummax()
    d["last_buy_ts"] = d.groupby("customer_id")["last_buy_ts"].ffill()
    d["feature_ts"] = d["day"] + DAY      # valid from next midnight
    return d.drop(columns=["buys", "rets", "last_buy"])


def point_in_time(rows, table):
    m = pd.merge_asof(rows.sort_values("cutoff", kind="stable"),
                      table.sort_values("feature_ts"),
                      left_on="cutoff", right_on="feature_ts",
                      by="customer_id", direction="backward",
                      allow_exact_matches=True)
    return recency(m)


def latest(rows, table):
    newest = table.sort_values("feature_ts").groupby(
        "customer_id").tail(1)
    return recency(rows.merge(newest, on="customer_id", how="left"))


def recency(df):
    df["recency_days"] = (df["cutoff"] - df["last_buy_ts"]) / DAY
    return df          # rows stay in the order labels() made them


def model():
    return HistGradientBoostingClassifier(random_state=0)


def scores(y, p):
    return {"ap": float(ap_score(y, p)), "auc": float(auc_score(y, p))}


def by_cutoff(df, m):
    """AP and ROC-AUC for each cutoff, then the average."""
    per = [scores(g["label"], m.predict_proba(g[COLS])[:, 1])
           for _, g in df.groupby("cutoff")]
    return {k: sum(s[k] for s in per) / len(per) for k in ("ap", "auc")}


ev = load()
table = feature_table(ev)
train_rows, test_rows = labels(ev, TRAIN), labels(ev, TEST)
train = {"pit": point_in_time(train_rows, table),
         "naive": latest(train_rows, table)}
test = point_in_time(test_rows, table)

nv = train["naive"]
future = nv["last_ts"] >= nv["cutoff"]
ahead = ((nv["last_ts"] - nv["cutoff"]) / DAY)[future]
print(f"table rows {len(table):,}; train rows {len(nv):,}; "
      f"test rows {len(test):,}")
print(f"naive join: {future.sum():,} train rows read the future,")
print(f"  median {ahead.median():.1f} days ahead")

T = pd.Timestamp("2010-12-01")
print(f"customer 12347 at {T.date()}:")
ex = {}
for kind, df in train.items():
    r = df[(df["customer_id"] == 12347) & (df["cutoff"] == T)].iloc[0]
    ex[kind] = {"frequency": int(r["frequency"]),
                "money": round(float(r["money"]), 2),
                "recency_days": float(r["recency_days"])}
    print(f"  {kind:5s} row of {r['day'].date()}, frequency "
          f"{r['frequency']}, recency {r['recency_days']:.1f}")

print(f"\n{'join':6s} {'offline AP':>10s} {'test AP':>8s}")
offline, served = {}, {}
for kind, df in train.items():
    a, b = train_test_split(df, test_size=0.2, random_state=0)
    m = model().fit(a[COLS], a["label"])
    pooled = scores(b["label"], m.predict_proba(b[COLS])[:, 1])
    offline[kind] = {**by_cutoff(b, m), "ap_pooled": pooled["ap"]}
    m = model().fit(df[COLS], df["label"])
    served[kind] = by_cutoff(test, m)
    print(f"{kind:6s} {offline[kind]['ap']:10.3f} "
          f"{served[kind]['ap']:8.3f}")
guess = test.groupby("cutoff")["label"].mean().mean()
print(f"random guess, test AP {guess:.3f}")

if len(sys.argv) > 1:
    out = {"rows": {"train": len(nv), "test": len(test)},
           "table_rows": len(table), "future_rows": int(future.sum()),
           "days_ahead_median": float(ahead.median()),
           "example": {"customer_id": 12347, **ex},
           "offline_seed0": offline, "test": served}
    json.dump(out, open(sys.argv[1], "w"), indent=1)

Do the As-Of Join by Hand

This box holds customer 12347's real feature table, exactly as the nightly job wrote it. It needs nothing but Python, so it runs in your browser. Press Run to see what each join gives at the cutoff 1 December 2010.

Then change CUTOFF to another first of the month, such as "2011-05-01", and run again. Watch which row each join takes. Last, set CUTOFF = "2010-11-01" and EXACT = False. The customer's first row is stamped exactly at that cutoff. With False there is no row stamped strictly before the cutoff, so the join returns empty features.

The report script writes this box from the stored lab results. It then runs the box at all 21 cutoffs, once with EXACT = True and once with False. Each answer must match the lab's own merge_asof for this customer.

Common Mistakes, and When Each Check Helps

Joining the current values onto old rows. This is the latest-value join. It often hides inside a helper that "gets the customer's features", written for the live system and reused for training. The live system should read the latest values. Training rows must not.

Storing only the latest value. If your feature table overwrites old values, you cannot build a point-in-time training set at all, however careful the join. Keep every version with the time it became true.

Not knowing what the stamp means. A stamp can mean when the event happened, when the job ran, or the start of the day the row covers. The off-by-one slip came from that alone. Write down what your stamp means, next to the table.

Storing values that change by themselves. Recency changes every day. A stored recency is stale the day after it is written. Store the time of the event and work out the age at join time.

Trusting a random hold-out to catch a leak. It cannot. All twenty hold-outs here came from the same leaky table and all twenty agreed with each other, because each was cut from that table. Only scoring later months, with features built the way serving builds them, showed the truth. Later months alone are not enough. When their features were also joined latest-value, the leaky model got an AP of 0.822 on them. I measured that after the review.

When a point-in-time join matters. Whenever a training row stands for a past moment and features change over time: almost every prediction about customers, users, machines or prices.

When it does not. When features never change after they are first known, like a customer's country of signup. Or when every training row was built and stored at the moment it happened. Even then, check a few rows against the raw events.

What This Lab Cannot Tell You

Two columns. What the lab shows: how far a latest-value join moved one model's scores, on one shop's data; which of four columns carried the leak; and what a one-day stamp slip did. What it cannot show: other data, other models and other tasks; features that arrive late or are fixed after the fact; and a real feature store's own join.

One shop, one task, one model. The sizes here belong to this data, a 30-day window and this model. A different task could leak more or less. A leaky table always looked better offline here. Whether it did worse later depended on the column: return share alone looked better offline, 0.570 against 0.540, and scored the same on the test months, 0.522. The numbers do not travel to other data.

Three build dates. I measured latest-value tables built at three dates: April 2011, July 2011 and December 2011. A team building on another date, or refreshing its training set every month, could see different numbers.

Clean arrival times. Every invoice here arrives in the table the same night. In real pipelines some data arrives late, or gets corrected later. A row stamped by when it arrived and a row stamped by when it happened can disagree. A later lesson in this chapter simulates late data.

One seed for the model. The model always used random_state=0. The 20 seeds vary the hold-out, not the model's own randomness, which here mainly sets the 10 percent it keeps aside for early stopping.

No real . I wrote the join by hand in pandas. Whether a product does the same thing is a question for its own test, which the last lesson of this chapter runs.

What to Do on Monday

A hand-drawn list of five checks. Store versions: keep every value with the time it became true, never only the latest. Join as of the cutoff: merge_asof, or your feature store's historical read. Know your stamp: does a row's time mean the start or the end of what it covers? Rebuild a sample: recompute some rows from raw events and compare. Test on later months: score the newest cutoffs, with features built the way serving builds them. Below: a score that looks too good is a reason to check the join.

These are the five checks I would make before trusting any training table built from a feature table.

  1. Store versions. Every value, with the time it became true. Never overwrite.

  2. Join as of the cutoff. In pandas, merge_asof by entity, backward in time. An entity is the thing a feature describes, here a customer. In a , use its historical read, never its live read, the call that returns today's values for serving.

  3. Know your stamp. Decide whether a row's time is the start or the end of what it covers, and set allow_exact_matches to match. If you cannot find out what the stamp means, use False. With daily rows like these, it costs at most a day of freshness, while True can leak. With weekly or hourly rows, the cost is one row's period.

  4. Rebuild a sample. Take a few hundred training rows and recompute their features from the raw events, using only events before each cutoff. Any difference is a bug. Mine was found exactly this way.

  5. Test on later months. Keep the newest cutoffs aside, build their features the way the live system will, and score them once at the end. A random hold-out cut from a leaky table leaks too, and so do later months joined the same leaky way: there the leaky model scored 0.822.

Knowledge Check

Knowledge Check

4 questions - Score 80% to pass

Q1

A team joins each customer's current feature values onto training rows from last year. What did this lab find?

Q2

Why did twenty different random hold-outs all fail to reveal the leak?

Q3

Rows are stamped at midnight after the day they cover. Which merge_asof setting is right, and why?

Q4

In the latest-value table, 0.0 percent of the rows with nothing after the cutoff were buyers. Why?

A bug the check caught. My first run stopped at step 1. On 5,578 training rows, recency did not match the raw invoices. The cause was a day on which a customer only returned something. That day's row lost the time of the last buy instead of carrying it forward. I fixed the table and ran again. No model had been trained yet. I mention it because this is exactly the kind of quiet mistake a feature pipeline makes. I only found it because I compared against the raw events.

Found in review. My first version scored the offline hold-out differently from the test months. It put all 13 training months' rows into one list and worked out one AP. That also scores how the model ranks one month's customers against another month's, which the test score never asks. Scored that pooled way, the honest table gave 0.509 and the leaky one 0.718. The pooled 0.509 made the honest model look as if it did better on later months than offline, and I wrote a paragraph about it. A reviewer caught the mismatch. Every offline number in this lesson is now per cutoff, like the test.

9 December 2011
31,489
334.6
0.714
0.301

The earlier team reads the future less far ahead, a median 133.7 days instead of 334.6. But it is fooled worse, not better. Its table promised 0.828 offline, and on the test months its model scored 0.197, the same as a random guess at 0.196. On ROC-AUC it went from 0.923 offline to 0.501, a coin flip.

One possible reason, which I did not test: in the April table, the newest row sits much closer to each training row's cutoff. So more of what it adds is the very buys the label asks about, and the leak points more straight at the answer.

So the end-of-data table is not an unfair example, one made weak on purpose to be easy to beat. It is the gentlest of the three I measured. The rule does not change with the build date: a training row may read only what existed at its own cutoff.

0.301

Recency carried the most. On its own it lifted the offline score to 0.647 and dropped the test score to 0.205, almost exactly a random guess. A likely reason: in the latest-value table every buyer has a negative recency, by construction, because their buy in the window is after the cutoff. On the test months no row ever does, because point-in-time recency cannot be negative. So in that table, every training row whose recency is zero or more, the only kind the test months contain, belongs to a customer who did not buy. The model never saw a buyer that looks like a test row.

Frequency is the quieter leak, and I think the more dangerous one. It holds no strange values at all, yet it lifted the offline score from 0.540 to 0.642 and lowered the test score to 0.497. Money leaked less. Return share leaked offline, 0.570, but left the test score where it was, 0.522. So a leaky column always looked better offline here, but whether it did worse later depended on the column.

The scoring code is plain scikit-learn. train_test_split with test_size=0.2 and the seed makes the offline hold-out. Then average_precision_score and roc_auc_score run for each cutoff and are averaged, for the hold-out and the test months alike.

This is a real run in VS Code's terminal (python pit_demo.py).

A real screenshot of VS Code's terminal after running python pit_demo.py. It prints the table, train and test row counts; the naive join's 31,489 future rows and median 334.6 days ahead; customer 12347's two joined rows; and for each join the offline AP on seed 0 and the test AP, both per cutoff, then the random guess, 0.196.

When I ran it, every line matched the stored pit-demo.json, and the longest printed line was 54 characters. The report's demo mode then checked 20 of its numbers against the lab. They include the 31,489 future rows, the median 334.6 days, seed 0's offline scores, both test scores and customer 12347's two rows. All equal to the last digit. I also ran the download path once from an empty folder, and it printed the same lines.

The seed 0 offline AP is 0.542 for point-in-time and 0.708 for latest value. Those are one draw each; the lab's averages over 20 seeds are 0.540 and 0.714. After the review I changed the demo to score per cutoff, like the lab, and ran it again; these are the numbers from that run.

Five cards headed the tools, with their logos, titled what ran where. pandas, with its logo: the invoices, the nightly table, and merge_asof, the as-of join. scikit-learn, with its logo: the model, the random hold-outs, AP and ROC-AUC. Parquet, with its logo: the cleaned invoices, kept as one file. Python, with its logo: the demo and the report; the playground box needs only Python. Feast, not run here, with no logo: a feature store whose historical read does this join; a later lesson compares the two. Below: Feast has no logo here, because the logo set has none.

pandas did all the joining and scikit-learn all the learning and scoring. Feast is an open-source . Its historical read, get_historical_features, is the call that builds training rows from past values. Its own documentation says it joins features onto each row "in a point-in-time correct way", scanning backward from each row's timestamp. It only scans as far back as a limit called the (time to live): how old a value may be and still count. Nothing in this lesson ran Feast. The last lesson of this chapter runs it and compares its join with this one, row by row.

A closing card. In large type: 0.714 to 0.301. Below: average precision a latest-value join promised offline, and what it gave on later months, per cutoff. Then: give each row only what was known at its cutoff.

The one idea to keep: give each training row only what was known at its cutoff. Here, ignoring that turned a model that really scores 0.522 into one that promised 0.714 and delivered 0.301.