Serving And Inference

Caching Predictions: Every Real Hit Was Stale, and Dropping Old Entries Did Not Help

0 of 33 complete

0%

Contents

Back|Serving And InferenceCaching Predictions: Every Real Hit Was Stale, and Dropping Old Entries Did Not Help
1/33
69 min left
Prerequisites
Batch or Online Scoring: A Month-Old Score Cost Nothing I Could Measure HererequiredWhat a Feature Is: A Better Model or a Better Feature?requiredFeature Freshness: Two Weeks Stale Cost Nothing I Could Measurerequired
Related Topics
Model Signatures: The Right Numbers in the Wrong Shape, and What a Schema Check CatchesPackaging, Registry and VersioningTime To Live (TTL)Caching StrategiesRefresh-Ahead CacheCaching StrategiesStale Pieces in a Pipeline: A Cached Input, an Old Scaler, and What Each One HidesThe ML & AI LifecycleFeast Hands-On: The Same Join, Row for Row, Until a Row Has No Snapshot in Its WindowFeatures and Feature Stores
1 of 33

A Jar of Answers on the Table

Let me start with a jar.

Imagine a friend asks you the same hard question every few minutes. The first time, you think hard and work out the answer. Then you write it on a small ball and drop it in a jar. The next time the question comes, you just take the ball out. That is much faster than thinking again.

A flat illustration of a woman at a wooden table, reaching into a big glass jar full of colourful balls, with a small kitchen timer on the table beside the jar. Below the picture: taking an answer you already worked out is fast; the timer says how long you trust it; the risk is that the world changed while the answer sat in the jar.

There is a timer next to the jar. You set it when you drop a ball in. When the timer rings, you throw that ball away and think again next time. The timer is how long you trust an old answer.

The jar has one danger. Your answer was right when you wrote it. But maybe something happened since then that changes the answer, and the ball in the jar does not know it.

A model that gives scores can have a jar too. In this lesson I fill one with real requests and measure how often it helps and how often it serves an answer that is out of date.

Where This Lesson Starts

This is lesson 8 of the chapter on serving a model. The model is the one from lesson 1, batch or online. It reads six numbers about a customer of a real UK gift shop. Then it gives a score: how likely the customer is to buy again in the next 30 days.

Lesson 1 asked a close question. It compared a score made at the moment of a visit with a score made earlier by a nightly, weekly or monthly batch. On this shop, even a score 30.7 days old could not be told apart from a fresh one. A cache is a different machine with a similar risk. A batch scores everyone on a schedule. A cache scores a customer only when they ask, keeps that answer, and gives it out again later. So I wanted to know: does lesson 1's result hold for a cache, and what extra choices does a cache add?

The course has a lesson on caching for retrieval and language models. That one is a semantic cache: it matches two questions that mean the same thing, even when the words differ. This lesson is about something simpler and more common for small models: an exact-key cache, where a hit needs the same key, letter for letter. I do not repeat that lesson here.

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 grid of ten cards, two per row. Cache: a small table that keeps answers already worked out, so the model does not have to answer again. Key: what you look up in the cache, here a customer id, or the customer's numbers. Hit: the key is in the cache and still alive, so the stored score is served. Miss: the key is not there, so the model scores the request now, and the score is stored. Hit rate: hits divided by all requests, how much model work the cache saved. TTL: time to live, how long an entry may be served after it was written. Stale hit: a hit where the customer placed an invoice after the score was stored. Invalidation: throwing an entry away on purpose, here when a new invoice arrives. Online score: the score the model gives at the moment of the request, as in lesson 1. Page view: a request that changes nothing; this data has none, so I simulated them. Below: AP, seeds, the bootstrap and the top fifth, as in lesson 1.

A cache is the jar: a small table that keeps answers. Each answer is stored under a key, the thing you look up. If the key is there and still alive, that is a hit, and the stored score is given out. If it is not there, that is a miss: the model scores the request now, and the new score goes into the cache.

The hit rate is the share of requests that were hits. It tells you how much model work the cache saved.

The , time to live, is the timer. It is how long an entry may be served after it was written. In this lab, reading an entry does not restart its timer. In , a plain read does not change a key's timeout either; a new EXPIRE (or GETEX with an expiry option) does.

A stale hit is a hit where the customer placed an invoice after their score was stored, so the stored score never saw that invoice. Invalidation means throwing an entry away on purpose before its timer ends. Here I throw it away when a new invoice arrives.

One Request Through a Cache

Here is what happens to one request when a cache sits in front of the model.

A sequence diagram with three lifelines: shop app, cache and model. Step 1, look up key, from the shop app to the cache. Step 2, hit: stored score, back to the shop app. Step 3, miss: score now, from the shop app to the model. Step 4, fresh score, back to the shop app. Step 5, store, start TTL, from the shop app to the cache. Below: step 1 always runs; on a hit, step 2 ends the request and the model does no work; on a miss, steps 3 to 5 run, the model scores the request from features worked out right now, and the score is stored with the time it was written. Caption: a hit saves work; whether it is still right depends on what happened since step 5.

The app first looks up the key. On a hit, it gets a stored score and stops there. The model does nothing.

On a miss, the app works out the customer's six numbers as they are right now and asks the model. This is the online score from lesson 1. Then the app stores that score in the cache, with the time it was written. From that moment, the timer runs.

Why bother? Every miss runs the model, and lesson 2 of this chapter measured that a single prediction was the biggest part of one request's time on a quiet machine. A cache skips that work on every hit. For a small model like this one the saving per request is small. For a large model, or a model that needs slow feature lookups, it can be most of the cost.

So every hit gives the score from some earlier miss. The questions of this lesson are about that gap. How often is there an earlier miss close enough to serve? And did anything happen in between that the stored score does not know?

What the Sources Say

This lesson leans on what three caches and one library promise. I downloaded each source at a fixed version and found every quote in it word for word. The lab's fact-check mode wrote them to results/pc-factcheck.json.

Four rows. Python's functools.lru_cache, keys must be hashable, no time limit: since a dictionary is used to cache results, the positional and keyword arguments to the function must be hashable; if maxsize is set to None, the LRU feature is disabled and the cache can grow without bound. cachetools.TTLCache, every item has a time to live: items that expire because they have exceeded their time-to-live will be no longer accessible, and will be removed eventually; expired items will be removed from a cache only at the next mutating operation. Redis EXPIRE, a timeout from when the key was set: after the timeout has expired, the key will automatically be deleted; the timeout will only be cleared by commands that delete or overwrite the contents of the key. scikit-learn 1.9.1, the model works on bins: these fast estimators first bin the input samples X into integer-valued bins, typically 256 bins. Caption: 16 quotes from 12 claims, every one found word for word in the fetched file.

Python's functools.lru_cache is the cache most people try first. It keys on the function's arguments, and those must be hashable, which a numpy row is not. And it has a size limit but no time limit at all. Put it on a function that takes a customer id, and a score stays in it until it is pushed out by size, however old it is.

cachetools.TTLCache gives every item a time to live. Expired items are no longer served, but they may "still claim memory" until the next change to the cache.

is a separate server that many teams use as a shared cache. Its EXPIRE command deletes a key after a timeout. Commands that change a value without replacing the key "will leave the timeout untouched", so a plain GET does not change it. Calling EXPIRE again sets a new time to live, and can read a key and set its expiration in one step. The cachetools documentation says the same for its own cache: the expiry time is fixed "at the time of insertion". The same page says Redis removes expired keys when someone reads them, and also by testing a few keys at random now and then.

How the Lab Was Built

I wrote the lab's design into the docstring of scripts/labs/serving/caching_predictions.py before it first ran. Before that, I had read lesson 1's code and results and the three cache documents. I had not replayed any cache or counted any repeat request.

A page in five labelled zones, titled one model, two request streams, three keys, four TTLs. The model: lesson 1's visit-trained model, 19,404 rows, 20 seeds; nothing tuned here. Real requests: lesson 1's 7,403 invoices, July to October 2011, by 2,771 customers. Simulated requests: page views, which the data does not have; 2 sessions per real invoice on average, 1 to many views in 30 minutes, 5 draws of about 44,303 views each. Three keys: customer id; customer id plus the six exact values; a hash of the model's six bins. Measured: hit rate, hits that missed an invoice, scores that changed, AP with 20 seeds and a customer bootstrap, the top fifth, and entries held at the peak. Caption: guesses written first; some held, some did not (slide 25).

The model. I used lesson 1's second model, the one trained on 19,404 rows made at the moment of real visits. Lesson 1's review showed it fits visit-time requests better than the first model. The lab trains it 20 times, with seeds 0 to 19. It stops unless every seed gives exactly the empty-row score and the online AP that lesson 1 stored.

Three keys. A cache is defined by its key, so I tried three. Key one is the customer id alone. Key two is the customer id plus the exact six numbers at the request. Key three is a short hash of the model's six bins, with no customer id. A hash is a short code made from some data: the same data always gives the same code.

Four TTLs. 1 hour, 1 day, 7 days and 30 days. For the customer key I also tried invalidation: when a new invoice from that customer arrives, their entry is dropped.

Two Request Streams, and One Thing True by Construction

A cache only helps when the same key comes back. So the most important input is the stream of requests.

The real stream is lesson 1's 7,403 invoices from July to October 2011. Each invoice is a moment the shop wanted a score.

But here is a fact I wrote down before the run.

In this data, every request is an invoice, and an invoice is exactly the event that changes a customer's six numbers. A customer-keyed entry is written at one invoice, from the numbers just before that invoice. So when the customer comes back, the entry has always missed at least one invoice: the one that wrote it. Every hit on the real stream must be stale. And dropping the entry on each invoice must turn every hit into a miss. Both are true by construction, which means the design makes them true, not the data. The lab counts them anyway, and I will not treat them as findings.

The simulated stream fixes that. A real shop also scores page views: a customer looks at products, and the page shows offers based on their score. A page view changes none of the six numbers. This data has no page views, so I made them up, on the real customers.

Every customer gets on average 2 browsing sessions per real invoice, at random moments in the four months. Each session has 1 view plus a random number more, 2 on average, all within 30 minutes. I drew this five times, with five different random seeds. Every result from this stream is labelled SIMULATED. The parameters are my assumption, not a measurement.

How sure. For AP I used lesson 1's method: 20 seeds, and a bootstrap that redraws the customers 1,000 times within each month. If the middle 95% of the differences crosses zero, the cache "cannot be told apart" from online scoring.

The Lab's Report, Running

This is a real recording of the report script, pc_report.py, on the laptop where the lab ran.

A terminal recording of pc_report.py in five sections. The model: 19,404 visit-time rows, 20 of 20 seeds give lesson 1's empty-row score. Real visits: a table of hit rate, stale hits, changed scores and 20-seed AP, for example customer_30d 43.0%, 3,186, 2,702, 0.8116 against online 0.8128, and the bootstrap from 1h -0.0014..+0.0024 to 30d -0.0046..+0.0023. Simulated draw 0: the same table, customer_1h 66.8%, binhash_1h 66.3% with 0 changed, 30d dropped -0.0027..-0.0000. Each simulated draw also prints a line of dropped minus kept, for example draw 0 at 30 days -0.00397..-0.00051. Draws 1 to 4: one line of hits each, the 30-day bootstrap, and the after-results lines. Memory: 185.7, 223.7 and 147.8 bytes per entry. Demo, playground and fact-check equal the lab. Fact-check: 16 quotes, 12 claims, all found. Last line: all 2728 checks agree with the stored lab.

The report does not trust the lab. It never imports the lab's code, or lesson 1's. It builds every feature with the features chapter's own function, build_tables, once per customer, with that customer's request times as the moments to measure at. The lab used lesson 1's faster one-pass code instead.

It draws the simulated page views again with its own code and checks that each draw has the same fingerprint as the lab's. It replays the cache in a different way. It decides each hit from the last write of each key, without the lab's queue. And it counts the live entries from each entry's lifetime with numpy. It scores AP with scikit-learn and redoes every bootstrap with an AP function written inside the report. Then it checks the post-results part, the post-review comparison of dropping with keeping, the memory sizes, the demo, the playground and the fact-check.

How Soon a Customer Comes Back

Before any cache, here is the shape of the real requests. A cache keyed by customer can only hit when the same customer comes back within the .

A bar chart of real invoices by the time since the same customer's previous request: first in the window 2,771; under 1 hour 732; 1 hour to 1 day 228; 1 to 7 days 941; 7 to 30 days 1,746; over 30 days 985. Caption: a cache keyed by customer can only hit when the same customer comes back inside its TTL.

Of the 7,403 invoices, 2,771 were a customer's first in the four months, so nothing could be in the cache for them. Then 732 came less than an hour after the same customer's previous invoice. These are customers who place two or three orders in a row, for example after forgetting an item. Another 228 came within a day, 941 within a week and 1,746 within a month.

So even before running anything, you can see that a 1-hour cache on real invoices can hit at most about one request in ten. A 30-day cache can hit more, but its stored scores will often be weeks old.

Three Keys, Three Very Different Caches

Here is the first result: the hit rate of each key on the real invoices, with a 30-day .

Three panels for the real invoices at a 30-day TTL, seed 0. Customer id: 43.0% hits; every one missed an invoice, 2,702 served a changed score. Id plus exact values: 0.0% hits; two of the six numbers are ages, and they change every second. Hash of the bins: 8.4% hits, 611 of them first visits that share the empty row; 0 scores changed. Below: the first key trusts the customer id alone; the second and third only hit when the model would give exactly the same answer. Caption: a key that can be wrong hits often; a key that is always right hits rarely here.

The customer key hit 43.0% of the real invoices at 30 days. Every one of those 3,186 hits had missed an invoice, as the design said it must. And 2,702 of them served a score different from the one the model would give at that moment.

The exact key hit 0 times. I expected that. Two of the six numbers, recency (days since the last invoice) and tenure (days since the first), are ages. They grow every second. So the same customer one minute later already has a different key.

The bins key hit 8.4%, and 611 of its 625 hits were a customer's first visit. A first visit has no history, so all six numbers are missing, and every such row gets the same key and the same score. Not one bins-key hit served a changed score.

On Real Invoices, Every Hit Was Stale

Here are all four TTLs for the customer key on real invoices.

Four rows of 100 cells, one row per TTL, cells marked as a hit that missed an invoice or as a miss. 1 hour: 727 hits, 727 missed an invoice; 606 scores changed. 1 day: 949 hits, 949 missed an invoice; 756 scores changed. 7 days: 1,745 hits, 1,745 missed an invoice; 1,386 scores changed. 30 days: 3,186 hits, 3,186 missed an invoice; 2,702 scores changed. Below: each request here is an invoice, and an invoice is exactly what changes a customer's numbers, so the stored score was always written before at least one invoice it never saw, true by construction and counted anyway; dropping the entry on each invoice turned every hit into a miss. Caption: the cache saved model work only by serving answers it could not know were out of date.

The hit rate grew with the : 9.8% at 1 hour, 12.8% at 1 day, 23.6% at 7 days and 43.0% at 30 days. Every hit missed an invoice, the by-construction fact from slide 7, and the counts match it exactly.

What is not by construction is how many served scores actually changed. At 1 hour, 606 of the 727 hits served a score different from online. The model is a set of trees, and one new invoice changes the purchase count and the recency, which often crosses a split point inside a tree.

With invalidation on, the real stream had no hits at all, at any TTL. Every entry was dropped by the very invoice that wrote it. On real invoices, a cache that is never stale is a cache that never hits.

And Yet the Cost Could Not Be Measured

Stale is not the same as costly. The question that matters is whether the served scores rank buyers worse than online scores do.

A plot with a horizontal zero line and one vertical bar per TTL for the real invoices, on an axis from -0.005 to +0.004 labelled cached AP minus online AP. 1 hour, 6 of 20 below: -0.0014 to +0.0024. 1 day, 5 of 20 below: -0.0010 to +0.0028. 7 days, 8 of 20 below: -0.0019 to +0.0030. 30 days, 16 of 20 below: -0.0046 to +0.0023. A diamond on each bar marks the 20-seed mean difference. Below: online scored 0.8128; the bar is the 95% interval from 1,000 redraws of the customers; at 30 days the cached scores were lower in 16 of 20 seeds, but the interval crosses zero. Caption: every hit was stale, and the cost still could not be measured, lesson 1's finding again.

Online scoring gave AP 0.8128, the mean of 20 seeds. For every , the 95% interval of the difference crosses zero. At 30 days it went from -0.0046 to +0.0023.

The seeds lean one way at 30 days: the cached scores were lower in 16 of 20 seeds. A lean is not a finding. The bootstrap says customer luck alone could make a gap this size. So my words are: on real invoices, no TTL up to 30 days could be told apart from online.

What does a difference of 0.002 AP mean? AP goes from 0 to 1, and online scored about 0.81. A change of a few thousandths means the ranking of buyers above non-buyers moved a little, for a few customers. Here every such gap was smaller than the change you get by redrawing which customers happened to visit, so none of them is a finding.

This is lesson 1's result in a new form. There, a monthly batch score up to 30.7 days old could not be told apart from a fresh one, and the same visit-trained model gave -0.0037 to +0.0031. A stale cached score is an old score, and on this slow 30-day question, old scores cost little.

One Real Customer's October

Numbers over thousands of requests hide what happens to one customer. I fixed a rule before the run: take the smallest customer id with at least 4 invoices in October 2011, and show what a 7-day cache served. After the run I added a 30-day view of the same visits, and I label it so.

A timeline of customer 12471's invoices from 18 September, with dots on 09/22, 10/05 (2 invoices), 10/12 and 10/27, and a note that the 7-day columns follow the rule fixed before the run and the 30-day columns were added after it. A table with columns visit, 7 d, served, 30 d, served, online and buys. 10/05 12:24: miss 0.9807, hit 0.9807, online 0.9807, 70. 10/05 12:25: hit 0.9807, hit 0.9807, online 0.9805, 71. 10/12 11:32: hit 0.9807, hit 0.9807, online 0.9807, 72. 10/27 13:45: miss 0.9739, miss 0.9739, online 0.9739, 72. Below: seed 0; buys is the purchase count online; with 7 days, 2 of the 4 October visits hit, served the score written on 10/05 12:24; with 30 days, 3 hit, served the score written on 22 September, 0.9807; the online score stayed between 0.9805 and 0.9807 on those visits; all four bought again within 30 days. Caption: for a customer who buys every week, an old score and a new one say the same thing.

Customer 12471 is a busy buyer. With the rule's 7-day cache, the first October visit, at 12:24 on 5 October, was a miss and wrote an entry. The next visit, one minute later, and the visit of 12 October were hits. Both were stale: an invoice had come in since the entry was written, and the purchase count online went from 70 to 72.

With a 30-day cache, which I looked at only after the run, the entry from 22 September was served on three October visits.

But look at the scores. Every served score was 0.9807. The online scores on the same visits were 0.9807, 0.9805 and 0.9807. The customer did buy again after every visit. For a loyal customer, the model is sure either way, and one more order changes almost nothing.

This is one customer. It shows how a stale hit can be harmless. It says nothing about how often.

Why the Exact Key Almost Never Hits

The exact key sounds like the safest choice: only reuse a score when the input is exactly the same. Here is why it barely works for this model.

A hand-drawn sketch of two boxes side by side, joined by an arrow. Left: 12:00:00, recency 3.00000 days, tenure 400.00000 days, frequency 9. Right: 12:01:00, recency 3.00069 days, tenure 400.00069 days, frequency 9. Under the left box, key A; under the right box, key B: a miss. Below: the numbers in the sketch are made up to show the idea; measured, on real invoices the exact key hit 0 times at every TTL; on the simulated views it hit 2,895 times at 30 days, draw 0, and 2,868 of those were customers with no invoice yet, whose six numbers are all missing and so never change. Caption: recency and tenure are ages, so the exact input of a known customer almost never repeats (27 hits in draw 0).

The sketch uses made-up numbers to show the idea. One minute later, the same customer has a recency and a tenure that are one minute larger. The key is different, so it is a miss.

The measured numbers agree. On real invoices, the exact key hit 0 times at every . On the simulated page views it did hit, 5.3% to 6.5% of requests (the mean of five draws, from 1 hour to 30 days). I did not expect that, so after the results I looked at which requests they were. In draw 0, 2,868 of the 2,895 hits at 30 days came from customers with no invoice yet, whose six numbers are all missing. The report checked the other 27. Each was a second simulated view in the same second as the one before it, a side effect of how I drew the times.

So for a model whose inputs include an age, an exact-input cache is close to useless, except for brand-new customers.

The Page Views I Made Up

Real invoices made every customer-keyed hit stale by design. To see how a cache behaves when requests do not change the inputs, I needed page views. Here is exactly how I made them.

A rough sketch of four boxes. Two sessions per real invoice on average, at random moments. Each session: 1 view, plus Poisson(2) more. All views inside 30 minutes from the session's start. Views change nothing; real invoices still change the numbers. Below: 5 draws, numpy seeds 0 to 4: 44,439, 44,051, 44,599, 43,973 and 44,452 views; labels are real, did the customer buy within 30 days after the view; 60.3% of draw 0's views did; sessions do not wait for an invoice, so a view right before an order is chance. Caption: these numbers are an assumption, not a measurement.

Each customer who had at least one real invoice in the four months gets browsing sessions. The number of sessions is random, around 2 for each of their real invoices. A session starts at a random second. It has one page view plus a random number more, 2 more on average, all within 30 minutes.

Poisson(2) means a random whole number that is 2 on average: often 1, 2 or 3, sometimes 0 or 5. Each draw gave about 44,000 views.

The labels are real. For each view, I ask the same question as lesson 1: did this customer buy within 30 days after it? In draw 0, 60.3% of views came from customers who did.

The real invoices still happen in the background. They change the customer's numbers, and with invalidation on, they drop the customer's cache entry.

How Often Each Cache Hit

Here are all the hit rates in one picture: real invoices on the left, simulated views on the right.

Two grids of hit rates, rows for keys and columns for TTLs of 1 hour, 1 day, 7 days and 30 days. Real invoices: id 10%, 13%, 24%, 43%; id, dropped 0%, 0%, 0%, 0%; exact 0%, 0%, 0%, 0%; bins 4%, 7%, 8%, 8%. Simulated views, mean of 5 draws: id 67%, 70%, 78%, 87%; id, dropped 67%, 70%, 77%, 84%; exact 5%, 5%, 6%, 6%; bins 66%, 69%, 69%, 70%. Under the real grid: the bins key hit almost only first visits, whose six numbers are all missing (611 of 625 at 30 days); invoices mostly do not repeat within minutes. Under the simulated grid: the bins key hit 66.4% at 1 hour against 66.8% for the id key, and never served a changed score; page views do.

The two halves look like two different worlds. On page views, the customer key hit 66.8% of requests at 1 hour, and 87.0% at 30 days, the mean of five draws. Most of the hits at 1 hour come from views later in the same session.

With invalidation, the hit rate barely fell: 66.8% at 1 hour, 83.5% at 30 days. Invoices are rare next to page views, so dropping an entry on each invoice costs few hits.

The surprise is the bins key. It hit 66.4% at 1 hour, almost as much as the customer key, and it never served a changed score. Slide 21 explains why.

The Longer the TTL, the More Hits Go Out of Date

On page views, a hit is no longer stale by design. So now staleness is a real measurement.

A line chart of the share of customer-key hits, simulated views, mean of 5 draws, against the TTL. Score changed, 20-seed mean: 1 hour 0.4%, 1 day 3.5%, 7 days 24.3%, 30 days 52.0%. Missed an invoice, seed 0: 1 hour 0.1%, 1 day 1.0%, 7 days 9.9%, 30 days 31.3%. Score changed with entries dropped on invoice, shown as dots, lower than the kept line at each TTL. Below: a score can change with no invoice, recency and tenure keep ageing. Caption: at 1 hour almost nothing goes stale; at 30 days about half the hits serve a changed score.

At 1 hour, only 0.4% of hits served a score different from online. At 1 day it was 3.5%, at 7 days 24.3%, and at 30 days 52.0%.

Notice that more scores changed than missed an invoice. At 30 days, 31.3% of hits had missed an invoice, but 52.0% served a changed score. The difference is the clock. Recency and tenure grow every second, so after enough days, a customer's numbers cross a split point in a tree even with no new invoice.

Invalidation removes the invoice part, but not the clock part. So with entries dropped on each invoice, 42.2% of hits at 30 days still served a changed score.

Dropping Old Entries Did Not Help

Now the cost. I expected long TTLs to cost a little AP and invalidation to win some of it back. The second half was wrong.

A plot with a horizontal zero line and four columns, each with five vertical bars, one per simulated draw, on an axis from -0.005 to +0.003 labelled cached AP minus online AP. 7 d, kept: 0 of 5 below 0. 7 d, dropped: 1 of 5 below 0. 30 d, kept: 0 of 5 below 0. 30 d, dropped: 4 of 5 below 0. A diamond on each bar marks the 20-seed mean difference. Below: a filled bar lies wholly below zero; at 1 hour and 1 day, not drawn, no interval was below zero, and a few sat just above it, 1 hour kept and dropped and 1 day dropped, all in draw 0, by at most +0.0002; keeping stale entries for 30 days could not be told apart from online in any draw; dropping them on each invoice was measurably below online in 4 of 5; compared directly, asked after the review, dropped minus kept at 30 days was wholly below zero in 2 of 5 draws, with its mean below zero in 5 of 5. Caption: not what I guessed; the next figure is one possible reason, and it is only that.

At 1 hour and 1 day, no interval in any draw was below zero. Three sat just above zero, all in draw 0: the 1-hour cache, kept and dropped, and the 1-day cache with dropping. Those gaps were tiny, at most 0.0002 AP, and the other four draws crossed zero. So I read them as the luck of one draw, not as helping. At 7 days, kept entries crossed zero in all five draws, and dropped entries were below online in one draw, from -0.0015 to -0.0003.

At 30 days, keeping stale entries could not be told apart from online in any of the five draws. Dropping entries on each invoice was below online in 4 of 5 draws. The lowest was draw 2, from -0.0046 to -0.0016, and in draw 3 the interval just crossed zero, from -0.0026 to +0.0003.

Being below online and being below keeping are two different questions, and my first draft mixed them up. An independent review pointed this out, so I added a labelled post-review part to the lab: the same bootstrap, but with dropping measured directly against keeping. At 30 days, dropped minus kept was wholly below zero in 2 of 5 draws (draw 0: -0.0040 to -0.0005; draw 4: -0.0041 to -0.0002). Its mean was below zero in all 5. At 7 days it was wholly below zero in 1 of 5 draws.

So my words are: dropping was below online in 4 of 5 draws, keeping in none; compared directly, dropping was below keeping in 2 of 5 draws, with the mean below in 5 of 5. These are a few thousandths of AP. Dropping entries on each invoice did not help on this task, and it may have hurt a little.

Why Did Invalidation Hurt? My Best Guess

This slide answers a question I asked after seeing the results. I wrote its design into the lab's docstring before that part ran, and the lab labels it as post-results work.

A bar chart titled dropped entries were more often written just after an invoice: for each of the five simulated draws, TTL 30 days, the share of served entries written within 1 day after an invoice, for entries kept and entries dropped on invoice: draw 0 6.2% and 13.6%, draw 1 6.6% and 13.2%, draw 2 4.8% and 12.0%, draw 3 6.7% and 12.6%, draw 4 6.3% and 13.9%. Below: bars are the share of served entries written within 1 day after any invoice line, returns included; median age of a served entry, kept 181 to 200 h, dropped 45 to 57 h; mean over draws of served minus online, 20-seed mean, views by non-buyers, kept -0.0046, dropped -0.0002; by buyers, kept -0.0050, dropped -0.0050; the two arms hit different requests, so these are group means, not paired rows. Caption: a guess that fits, a just-bought entry, frozen for 30 days, keeps saying just bought.

My guess, written before this part ran: when an invoice drops an entry, the next page view writes a new one, often soon after that invoice. So the entries that live under invalidation more often hold a recency close to zero: "this customer just bought". Then that entry is frozen for up to 30 days, still saying "just bought", while the online score sees the days pass.

The numbers fit the guess. Here "an invoice" means any invoice line, returns included. With entries kept, about 6% of served entries were written within a day of one. With entries dropped, about 13%.

Dropped entries were also much younger when served: a median of 45 to 57 hours, against 181 to 200 hours for kept entries. And look at customers who did not buy again. Under dropping, their cached score fell less below the online score, so they ranked a little higher than they should. For customers who did buy, the two were about the same. One caution: the two caches hit different requests, so these are averages over two different groups of hits, not the same rows compared twice.

That fits, but it does not prove the cause. Other explanations may fit too. What I can say is narrow: on this shop, with these simulated views, dropping entries on each invoice was not a free improvement.

One Night of Page Views

The SIMULATED worked example uses a rule fixed before the run. In draw 0, take the smallest customer id with a stale hit under the customer key with a 1-day .

A table for customer 12431 on 2011-08-13, after midnight, headed: the 1-day entry was written at 08/12 12:00; an invoice followed at 08/12 14:19. Rows are page views from 00:36:22 to 00:57:31, columns 1 hour, 1 hour with drop, 1 day and 1 day with drop. At 00:36:22: miss 0.529, miss 0.529, hit 0.732, miss 0.529. Every later view: hit 0.529 for 1 hour, 1 hour with drop and 1 day with drop, and hit 0.732 for 1 day. Below: each cell is hit or miss and the score served, seed 0; the online score was 0.529 all night; kept for a day, the cache served 0.732, a score from before the invoice; dropping it on the invoice served the right one. Caption: one customer shows how a stale hit happens; it says nothing about how often.

Customer 12431 browsed at noon on 12 August, which wrote a cache entry. Then they placed a real invoice at 14:19 that day. Just after midnight, they browsed again for about 20 minutes.

With a 1-day TTL and no invalidation, every view that night got the noon score, 0.732. The model, asked fresh, said 0.529, because the new invoice changed the customer's numbers. With invalidation, the invoice dropped the noon entry, the first night view was a miss, and the rest of the session got 0.529 from it.

With a 1-hour TTL, the noon entry had long expired, so the night session started fresh either way. This is the whole trade in one customer: a longer TTL gives more hits, and some of them carry news the cache never heard.

A Key That Is Never Wrong

The bins key surprised me most. It hit almost as often as the customer key on page views, and it never served a wrong score.

Three panels. Six numbers: 6; recency, frequency, money, return share, tenure, products, at the request. Six bin numbers: 0 to 255; each number replaced by the bin the model puts it in. An 8-byte key: 8 B; a blake2b hash of the six bins, with no customer id. Below: two requests in the same six bins get the same score, so a hit is never wrong; counted, not assumed, 0 changed scores across 5 draws, 4 TTLs and 20 seeds; it uses _bin_mapper, a private part of scikit-learn, and the cache must be emptied when the model changes. Caption: within the hour the ages barely move, so the bins stay the same and the key hits.

Here is the idea. The model never looks at the exact value of a number. It only asks which bin the value falls in, like "recency between 15.2 and 16.1 days". So two requests whose six numbers fall in the same six bins must get the same score.

So I made the key from the bins: six small whole numbers, turned into an 8-byte hash. Within a 30-minute session, recency and tenure grow by minutes, which almost never moves them into another bin. So the key repeats, and the cache hits.

I did not trust this argument. The lab compared every served score with the online score, for every draw, and seed. Not one differed.

Why did the bins key hit so often? I checked this after the results, and the lab labels it as post-results work. Many customers had no invoice yet when they browsed, so their six numbers were all missing, and all of them share one key. In draw 0, 2,693 of the bins key's hits at 1 hour were such rows. Every other hit at 1 hour came from the same customer, within the hour.

Three warnings. The bins come from _bin_mapper, a private part of scikit-learn that can change in a new version. The key belongs to one trained model, so the cache must be emptied whenever the model changes. And this trick only works for a model that bins its inputs, like this one.

How Many of the Best Leads Moved

AP summarizes the whole ranking. A shop often acts only on the top, so I also counted the top fifth.

A bar chart of top-fifth requests that differ from online's top fifth, mean of 20 seeds, for the customer key, real and simulated, at 1 hour, 1 day, 7 days and 30 days. Real: 23.1, 27.7, 47.5 and 109.3. Simulated, mean of 5 draws: 2.0, 20.1, 163.8 and 447.2. Below: of 1,479 real top-fifth places, a 30-day cache moved 109.3; of about 8,859 simulated ones, 447.2; the exact and bins keys moved none. Caption: AP barely moved, but a few hundred leads changed places.

The top fifth is the 20% of requests each month with the highest scores. I counted the requests that were in the top fifth by the served scores but not by the online scores.

On real invoices, a 30-day customer cache moved 109.3 of 1,479 top-fifth places, the mean of 20 seeds. On the simulated page views it moved 447.2 of about 8,859, the mean of five draws. At 1 hour, almost nothing moved in the simulated views: 2.0 places.

So the ranking as a whole stayed about as good, but a few hundred specific customers swapped in and out of the top. If your shop sends a coupon to the top fifth, a long changes who gets it, even when AP cannot see the difference. The exact and bins keys moved no one, because they never served a changed score.

What Each Cache Holds

A cache costs memory. I measured two things: how many entries each cache held at its busiest moment, and how many bytes one entry takes.

An isometric drawing of three blocks of different heights for simulated draw 0 at a 30-day TTL. Id: 296 KiB. Exact: 2,319 KiB. Bins: 510 KiB. Below: id, 1,631 entries x 185.7 bytes; exact, 10,613 entries x 223.7 bytes; bins, 3,536 entries x 147.8 bytes; bytes per entry measured with tracemalloc on a Python dict. Caption: small for this shop; Redis has its own overhead, which I did not measure.

The peak is the most entries alive at any moment. At 30 days on draw 0, the customer key held at most 1,631 entries, one per active customer. The exact key held 10,613, because every miss writes a new key and the old ones live on until their timer ends. The bins key held 3,536.

For bytes per entry, I used Python's tracemalloc, a tool that counts the memory Python hands out. I built a dictionary of real keys, each mapped to a score and a write time, and divided the memory by the number of entries, five times. A customer entry took 185.7 bytes, an exact entry 223.7 and a bins entry 147.8. These are Python numbers on one laptop. A server stores keys its own way, and I did not measure it.

Notice the trade between hits and memory. The exact key held more than six times as many entries as the customer key, for far fewer hits. Most of its entries were written once and never read again. The bins key sat in between: it held about twice as many entries as the customer key, and it never served a wrong score.

The bytes per entry were measured at different entry counts (5,942, 41,342 and 13,490 entries), and a Python dict grows in steps, so treat them as rough figures.

For this shop, all of it is under a few megabytes. For a site with millions of customers, the exact key would need the most memory and give the fewest hits.

A Month-Old Answer, Three Times

This lesson and lesson 1 measured the same thing from different sides: what does an old score cost on this shop?

Three cards. Lesson 1, monthly batch, visit-trained model, the same visits: scores up to 30.7 days old, -0.0037 to +0.0031 AP. This lesson, real invoices, a 30-day cache keyed by customer: 43.0% hits, all stale, -0.0046 to +0.0023 AP. This lesson, simulated views, the same cache, kept or dropped on each invoice: against online, kept crosses zero in 5 of 5 draws, dropped is below zero in 4 of 5; dropped below kept in 2 of 5. Caption: on a slow 30-day question, staleness barely shows; on a question that changes within hours, I have no numbers here.

Three measurements agree on the main point. On this shop's 30-day question, a score up to a month old could not be told apart from a fresh one. That held for a monthly batch in lesson 1. It held for a 30-day cache on real invoices here. And it held for a 30-day cache that keeps its entries on simulated views here.

The only cost I could measure came from a choice that sounded safer: dropping entries on each invoice. It was below online in 4 of 5 draws, and below keeping in 2 of 5. So a cache adds a new way to go wrong that a batch does not have: the rules for when an entry lives and dies.

For a fast question, the picture would be different. The course measured one in batch vs streaming data: counting bike rentals for the next hour, where old counts nearly doubled the error. A fraud check on a card payment, or a feed that changes with every click, could lose a lot from a score that is one hour old. I have not measured those tasks here, so I give no numbers for them.

What I can say is what would change, in words. On a fast task, the share of hits that serve a changed score would climb much faster with the than it did here. The right answer moves within minutes, not weeks. The clock part of staleness would matter more too: a feature like "minutes since the last click" crosses a split point quickly. So a long TTL that cost nothing measurable on this shop could cost a lot there.

And invalidation would likely matter in the other direction from what I found here, because on a fast task the newest event carries most of the signal. That last sentence is a guess, not a measurement. The way to know is the same replay this lab did, on your own requests.

My Guesses Before the Run, Checked

I wrote five guesses into the lab's docstring before it ran. Here they are word for word, against the results.

  1. "REAL, key (a): hit rate under 1% at 1 h, a few % at 1 d, about a quarter at 7 d, about half at 30 d; 100% of hits stale (by construction). AP: no can be told apart from online, because lesson 1 found a score up to 30.7 days old cost nothing measurable on this question. Top-fifth differences grow with the TTL." Wrong at 1 hour and 1 day: 9.8% and 12.8%, because many customers place several invoices within minutes. Close at 7 and 30 days: 23.6% and 43.0%. Every hit was stale, as designed, and no TTL could be told apart from online. The top-fifth differences did grow with the TTL: 23.1, 27.7, 47.5 and 109.3 places.

  2. "Key (b): no hit, or almost none, on either stream (the clock moves recency and tenure)." Right on real invoices. Wrong on page views: 5.3% to 6.5% (mean of five draws), almost all from customers with no history yet.

  3. "Key (c): exact on every hit; on the real stream most hits are first visits sharing the empty-row key." Right on both counts: 0 changed scores, and 611 of 625 real hits at 30 days were first visits.

  4. "SIMULATED, key (a): most views after the first in a session hit at 1 h; few hits stale at 1 h, many at 30 d. Invalidation removes the invoice part and costs little hit rate at 1 h. AP cannot be told apart from online at any TTL." The first parts were right: 66.8% hit at 1 hour, 0.4% of hits changed, and invalidation cost almost no hits at 1 hour. The last part was wrong both ways. Dropping entries at 30 days was below online in 4 of 5 draws, and three short-TTL caches in draw 0 sat just above zero.

  5. "Memory: key (a) holds at most one entry per customer; (b) and (c) hold more keys for the same hits." Right: 1,631, 10,613 and 3,536 entries at the 30-day peak.

Try It Yourself

The full lab trains 20 models, draws five sets of page views and redraws the customers thousands of times. I wrote a smaller demo that does the heart of it with one model and one draw.

A page in four labelled zones, headed pc_demo.py, designed before it ran. The model: lesson 1's visit-trained model, seed 0, trained from one pass over the invoices. Two streams: the real invoices, and the lab's simulated page views, draw 0. The caches: customer id with 4 TTLs, kept or dropped on each invoice; the bins key at 1 hour and 30 days. Left out on purpose: the 20 seeds, the other draws and the bootstrap; those stay in the lab. Caption: it printed real online AP 0.8174, simulated 0.8363, seed 0, equal to the lab.

I wrote the demo's design into its docstring after the lab had run and before the demo first ran. It trains the visit-trained model with seed 0. It builds the real invoices and the lab's simulated draw 0, and works out every request's six numbers in one pass over the invoices. Then it replays each cache with a plain dictionary and a queue, and prints a table.

A real screenshot of VS Code with pc_demo.py open at the top of the file, showing its docstring: what it needs, how to run it, and the design written before it first ran.

Before you run this lab. You need Python 3 with pandas, pyarrow and scikit-learn: pip install pandas pyarrow scikit-learn openpyxl. First run python fetch_data.py from the scripts/labs/features folder. It downloads the shop data once, about 46 MB, and writes one cleaned file. Then, inside the examples folder of scripts/labs/serving, run python pc_demo.py. It needs no GPU. I ran it with scikit-learn 1.9.1 and Python 3.13 on a Mac. Another version of scikit-learn may give slightly different decimals, and the bins key reads a private part of scikit-learn that a new version may change. The pattern should hold: every real hit misses an invoice, and page views hit often.

Run a Cache on One Customer

This box holds a real cache and real data from the lab. It needs nothing but Python, so it runs in your browser. It has no model inside: the scores were made by the lab's seed-0 model.

Press Run. It replays a cache keyed by customer over every SIMULATED page view of customer 12431, the customer from slide 20. All of that customer's real invoices up to October 2011 run in the background. Each line says hit or miss, and whether a hit missed an invoice. Then it prints the lab's results for the same setting: real invoices, or simulated views from draw 0.

Change TTL_HOURS to 1, 24, 168 or 720, and DROP_ON_INVOICE to True, and run it again. Watch which hits disappear.

The report script writes this box from its own replay of the data, runs it for all eight settings, and checks that the hits and stale hits it prints for this customer equal a replay of the whole stream. One customer's numbers are an example. The lab's table at the bottom is the measurement.

Try this order. First run it as it is, with a 1-day . Then set the TTL to 1 hour: most hits stay, because views in one session are minutes apart, and the stale ones go. Then set it to 720 hours, 30 days, and watch how many hits now carry a score from before an invoice. Last, set DROP_ON_INVOICE to True and see which of those hits turn back into misses.

The Lab's Code, Piece by Piece

The lab is one file, scripts/labs/serving/caching_predictions.py. It imports lesson 1's code and never changes it.

Models rebuilds lesson 1's visit-trained model with 20 seeds, using lesson 1's own build_requests and snapshots. It stops unless every seed's empty-row score and online AP equal what lesson 1 stored.

simulate_views draws the SIMULATED page views with the parameters on slide 15. label_at gives each request its real 30-day label.

run_cache is the cache. It walks the requests in time order. Before each request, it applies the invoices that came before it, if invalidation is on, and removes entries whose has run out. Then it looks up the key. On a hit it records which earlier request wrote the entry. On a miss it writes a new entry. It also tracks the most entries alive at once.

evaluate runs every key and TTL on one stream. For each hit it checks whether an invoice came between the write and the hit, whether the six numbers differ, and whether the served score differs from online, for every seed. Then it computes AP per month, the top fifth, and seedmean_boot, the customer bootstrap of the 20-seed mean.

bytes_per_entry measures memory with . holds the post-results questions of slides 14 and 19, and downloads each source and searches for every quote.

How I Would Decide, From This Lab

Here is the order of questions I would ask before a model's scores.

A flowchart. You want to cache scores leads to a diamond: does the request itself change the inputs? Yes, like an order, leads to: a customer-keyed hit is always stale. No, like a page view, leads to a diamond: can you key on the model's input? Yes leads to: key on the bins or the input, never wrong. No leads to: key on the customer with a short TTL, then measure the cost on your data. Below: here a 1-hour TTL on page views hit 66.8% with 0.4% of hits changed; the bins key hit 66.4% with none changed. Caption: measure the staleness cost on your own requests before you trust a long TTL.

The chart asks about the request first, because on this shop that one question split the results in two: real invoices made every customer-keyed hit stale, and page views did not. Here are the same steps in words.

  1. Ask whether the request itself changes the inputs. If a request is an order, a payment or a message, the customer's numbers change with it. Then a cache keyed by customer serves a stale score on every hit, by design. On real invoices here, that was 100% of hits.

  2. Try to key on what the model actually sees. For a model that bins its inputs, the bins make a key that is never wrong. For other models, the exact input is a safe key, but if the input has an age in it, it will rarely repeat.

  3. If you must key on the customer, start with a short . On the simulated page views, 1 hour gave most of the hits and almost no changed scores.

  4. Measure the cost on your own requests. Replay a week of real request times, as this lab did. Count hits and stale hits, and score the served answers with seeds and a bootstrap.

  5. Test your invalidation rule, do not assume it. Here, the rule that sounded safest did not help at 30 days: it was below online in 4 of 5 draws, and below keeping in 2 of 5.

When a Cache Helps, and When It Does Not

Use a cache when the same key comes back soon. On page views, most requests came minutes after another from the same customer, and a 1-hour cache hit two thirds of them.

Do not use a customer-keyed cache when the request itself is the news. On real invoices, every hit missed the invoice that wrote it. It cost nothing measurable here, on a slow 30-day question, but on a fast question it could be a wrong answer on many hits.

Use a key built from the model's input when you can. It cannot serve a wrong score. Here the bins key gave almost the same hits as the customer key at 1 hour.

Do not reach for functools.lru_cache on a function of the customer id. It has no time limit, so it would serve scores that are months old, and it cannot take a numpy row as a key.

Do not trust a long just because AP looks fine. At 30 days, about half the hits served a changed score, and a few hundred top-fifth places moved, even where AP could not see a difference.

Do not assume invalidation makes things better. Here it did not. Measure it.

What This Lab Cannot Tell You

Two columns titled shows and cannot show. Shows: one shop, one 30-day question, one small tree model, 20 seeds; real invoices, and page views simulated on real customers; hit rates, staleness, AP and dictionary sizes. Cannot show: fast questions, where a score is wrong within the hour; real page-view traffic, which this data does not have; speed, money, and Redis memory.

One shop, one slow question, one model. Everything here is one UK gift wholesaler, a 30-day question and lesson 1's visit-trained model. I make no claim about fast questions or other models.

The page views are made up. Their number, their sessions and their timing are my assumptions. Real browsing may cluster around orders, which would change the hit rates and the invalidation result. Five draws show the luck of the draw, not the luck of my assumptions.

By construction. On real invoices, "every hit is stale" and "invalidation means no hits" follow from the design. I counted them; they are not discoveries.

The bootstrap is a little optimistic. It redraws customers within each month, so the true intervals may be slightly wider.

No timings and no money. I did not time a cache hit or a model call on this busy laptop. Lesson 2 measured where the time in one request goes on a quiet machine.

Labelled additions. The checks on slides 14, 19 and 21 were done after the main results, and the direct comparison of dropping with keeping on slide 18 after an independent review. The lab labels each one.

What to Do on Monday

A hand-drawn grid of six cards, titled five habits. 1, ask what changes the input: if the request is the event, a customer-keyed cache is stale by design. 2, key on what the model sees: the input, or for trees the bins, cannot serve a wrong score. 3, replay real requests: count hits, stale hits and changed scores before choosing a TTL. 4, score the served answers: AP with seeds and a bootstrap, not the hit rate alone. 5, test the invalidation: here dropping entries on each invoice did not help at 30 days. The reason: a 1-hour cache hit 66.8% of simulated views and changed almost nothing. Caption: a cache is a copy of the past; measure how old a copy your question can bear.

If someone on your team wants to put a cache in front of a model on Monday, do not start with the . Start with your request log. Ask which requests change the inputs and which do not. Replay a week of real requests through two or three key choices and TTLs, and count the hits, the stale hits and the changed scores. Then score the served answers against fresh ones, with several seeds and a bootstrap.

A closing card titled hits, staleness and the key. Hits: real invoices, 43.0% at 30 days; simulated page views, 66.8% at 1 hour. Staleness: every real hit missed an invoice, yet AP could not tell; dropping on invoice did not help, below online in 4 of 5 draws, below keeping in 2 of 5. The key: the bins key was never wrong and hit 66.4% of simulated views at 1 hour.

The one idea to keep: a cache is a jar of old answers, and three choices decide whether it helps. What the key is: here, a key built from the model's bins was never wrong. How long an entry lives: here, 1 hour on page views was almost free, and 30 days changed half the hits. And when entries die: here, dropping them on each invoice did not help. Measure all three on your own requests.

Knowledge Check

Knowledge Check

4 questions - Score 80% to pass

Q1

Why was every customer-keyed cache hit on the real invoices stale?

Q2

Why did the exact-value key almost never hit for customers with history?

Q3

On the simulated page views, what did the key built from the model's bins do?

Q4

What happened when entries were dropped on each invoice, with a 30-day TTL on page views?

GETEX

scikit-learn says the model first puts each input number into one of about 256 bins, like sorting values into labelled boxes. Slide 21 uses that.

"""Cache a model's answers: how often does the cache hit, and how often is the answer it serves out of date?

Lesson 8 of 'Serving and Inference Basics'. It needs Python 3 with pandas, pyarrow and scikit-learn, and the shop
data from the features chapter: run scripts/labs/features/fetch_data.py once first. Then, inside this folder:
    python pc_demo.py            # print the results
    python pc_demo.py out.json   # and save them
It times nothing and writes nothing to disk except out.json if you ask for it.

Design, written 2026-10-02 after the lab (caching_predictions.py) had run and before this file first ran:
  1. Train lesson 1's visit-trained model, seed 0: one row per invoice from March 2010 to February 2011, six
     features from everything strictly before it, label 1 if the customer buys again within 30 days.
  2. Two request streams. REAL: every invoice from July to October 2011 (lesson 1's 7,403 visits). SIMULATED: page
     views drawn exactly as the lab's draw 0 (2 sessions per real visit on average, 1 + Poisson(2) views each,
     within 30 minutes, at random moments). The data has no page views; these are made up, on real customers.
  3. One pass over the events in time order gives each request's six features "online", at that moment.
  4. Replay a cache with a time to live (TTL) keyed by customer, with and without dropping the entry when an
     invoice arrives, and a cache keyed by the model's own bins (always correct). For each: hit rate, hits that
     missed an invoice, served scores that differ from online, and AP of the served scores (seed 0 only; the lab
     uses 20 seeds and a bootstrap).

Author: Roni Das
Created: 2026-10-02
"""
import hashlib
import json
import sys
from collections import deque
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import average_precision_score

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[1] / "features"))
import task  # noqa: E402

HOUR = 3_600_000_000_000                      # one hour in nanoseconds
TTLS = {"1h": HOUR, "1d": 24 * HOUR, "7d": 168 * HOUR, "30d": 720 * HOUR}
START, END = pd.Timestamp("2011-07-01"), pd.Timestamp("2011-11-01")
ev = task.load_events()


def visits(a, b):
    w = ev[(ev["ts"] >= a) & (ev["ts"] < b)]
    return w[["customer_id", "ts"]].drop_duplicates().rename(columns={"ts": "t"}) \
        .sort_values(["t", "customer_id"], kind="mergesort").reset_index(drop=True)


def page_views(real, draw=0):
    """SIMULATED: the lab's generator, so draw 0 here is the lab's draw 0."""
    rng = np.random.default_rng(draw)
    rows = []
    for c, n in real.groupby("customer_id").size().items():
        for _ in range(rng.poisson(2 * n)):
            start = int(rng.integers(START.value // 10**9, END.value // 10**9)) * 10**9
            for o in np.sort(rng.integers(0, 1800, 1 + rng.poisson(2))) * 10**9:
                if start + o < END.value:
                    rows.append((c, start + int(o)))
    df = pd.DataFrame(rows, columns=["customer_id", "t"])
    df["t"] = pd.to_datetime(df["t"].to_numpy(dtype="int64"), unit="ns")
    return df.sort_values(["t", "customer_id"], kind="mergesort").reset_index(drop=True)


def features(req):
    """Six features for each request, from events strictly before it: one pass in time order."""
    ts = ev["ts"].to_numpy().astype("datetime64[us]").astype(np.int64).tolist()
    rows = ev[["customer_id", "is_return", "amount", "invoice", "stock_code"]].itertuples(index=False)
    state, out, i = {}, [None] * len(req), 0
    rt = req["t"].to_numpy().astype("datetime64[us]").astype(np.int64).tolist()
    order = sorted(range(len(req)), key=lambda j: rt[j])
    row = next(rows)
    for j in order:
        while i < len(ts) and ts[i] < rt[j]:
            s = state.setdefault(row.customer_id, [ts[i], 0, 0, 0, 0.0, 0.0, set(), set()])
            s[1] = ts[i]
            s[2] += 1
            if row.is_return:
                s[3] += 1
            else:
                s[6].add(row.invoice)
                s[7].add(row.stock_code)
            y = row.amount - s[5]          # money is added the way pandas adds a group (Kahan's method)
            t = s[4] + y
            s[5] = t - s[4] - y
            s[4] = t
            i += 1
            row = next(rows, None)
        out[j] = state.get(req.at[j, "customer_id"])
        out[j] = None if out[j] is None else (out[j][0], out[j][1], out[j][2], out[j][3], out[j][4],
                                              len(out[j][6]), len(out[j][7]))
    x = np.full((len(req), 6), np.nan)
    have = [j for j in range(len(req)) if out[j] is not None]
    t = pd.Series(pd.DatetimeIndex(req["t"].to_numpy()[have]).as_unit("ns"))
    first, last, n, nret, money, freq, prods = (np.array([out[j][k] for j in have]) for k in range(7))
    rec = ((t - pd.Series(pd.to_datetime(last, unit="us"))).dt.total_seconds() / 86400).to_numpy()
    ten = ((t - pd.Series(pd.to_datetime(first, unit="us"))).dt.total_seconds() / 86400).to_numpy()
    x[have] = np.column_stack([rec, freq.astype(float), money, nret / n, ten, prods.astype(float)])
    return x


buy_ns = {c: g.to_numpy().astype("datetime64[ns]").astype(np.int64)
          for c, g in ev[~ev["is_return"]].groupby("customer_id")["ts"]}


def labels(req):
    out = []
    for c, t in zip(req["customer_id"], req["t"].to_numpy().astype("datetime64[ns]").astype(np.int64)):
        a = buy_ns.get(c, np.array([], dtype=np.int64))
        out.append(int(np.searchsorted(a, t + 720 * HOUR, "left") > np.searchsorted(a, t, "right")))
    return np.array(out)


# 1. the model
train = visits(pd.Timestamp("2010-03-01"), pd.Timestamp("2011-03-01"))
xt, yt = features(train), labels(train)
keep = ~np.isnan(xt[:, 0])
model = HistGradientBoostingClassifier(random_state=0).fit(xt[keep], yt[keep])
print(f"visit-trained model: {keep.sum():,} training rows, empty-row score "
      f"{model.predict_proba(np.full((1, 6), np.nan))[0, 1]:.4f}")

# 2 and 3. the two streams
inv = ev[["customer_id", "ts"]].drop_duplicates()
inv_t, inv_c = inv["ts"].to_numpy().astype("datetime64[ns]").astype(np.int64).tolist(), inv["customer_id"].tolist()
cust_ts = {c: g.to_numpy().astype("datetime64[ns]").astype(np.int64) for c, g in ev.groupby("customer_id")["ts"]}


def replay(keys, t, ttl, invalidate=False):
    """A TTL cache in time order. Returns, for each request, the request whose score it was served."""
    cache, queue, src, e = {}, deque(), np.arange(len(t)), 0
    for j, tj in enumerate(t.tolist()):
        while invalidate and e < len(inv_t) and inv_t[e] < tj:   # an invoice drops that customer's entry
            cache.pop(inv_c[e], None)
            e += 1
        while queue and queue[0][0] <= tj - ttl:                  # entries older than the TTL are gone
            ft, k = queue.popleft()
            if cache.get(k, (None,))[0] == ft:
                del cache[k]
        if keys[j] in cache:
            src[j] = cache[keys[j]][1]                            # hit: serve the stored score
        else:
            cache[keys[j]] = (tj, j)                              # miss: score now and store it
            queue.append((tj, keys[j]))
    return src


out = {}
real = visits(START, END)
for name, req in (("real visits", real), ("SIMULATED views", page_views(real))):
    x, y = features(req), labels(req)
    t = req["t"].to_numpy().astype("datetime64[ns]").astype(np.int64)
    c = req["customer_id"].to_numpy()
    month = req["t"].dt.month.to_numpy()
    online = model.predict_proba(x)[:, 1]
    ap = lambda p: np.mean([average_precision_score(y[month == m], p[month == m]) for m in (7, 8, 9, 10)])
    print(f"\n{name}: {len(req):,} requests by {len(set(c)):,} customers; online AP {ap(online):.4f}")
    print(f"{'key':10s}{'TTL':>4s}{'drop':>6s}{'hit rate':>10s}{'missed an':>11s}{'score':>9s}{'AP':>8s}")
    print(f"{'':20s}{'':10s}{'invoice':>11s}{'changed':>9s}")
    bins = [hashlib.blake2b(r.tobytes(), digest_size=8).digest() for r in model._bin_mapper.transform(x)]
    rows = [("customer", tn, d) for tn in TTLS for d in (False, True)] + [("bins", tn, False) for tn in ("1h", "30d")]
    out[name] = {"requests": len(req), "online_ap": ap(online), "arms": {}}
    for key, tn, drop in rows:
        src = replay(c.tolist() if key == "customer" else bins, t, TTLS[tn], drop)
        hit = src != np.arange(len(t))
        missed = sum(np.searchsorted(cust_ts[c[j]], t[j]) > np.searchsorted(cust_ts[c[j]], t[src[j]])
                     for j in np.flatnonzero(hit) if c[j] == c[src[j]])
        served = online[src]
        changed = int((served != online).sum())
        out[name]["arms"][f"{key} {tn}{' drop' if drop else ''}"] = {
            "hits": int(hit.sum()), "missed_invoice": int(missed), "changed": changed, "ap": ap(served)}
        print(f"{key:10s}{tn:>4s}{'yes' if drop else 'no':>6s}{hit.mean():10.1%}{missed:11,}{changed:9,}{ap(served):8.4f}")
if len(sys.argv) > 1:
    json.dump(out, open(sys.argv[1], "w"), indent=1)

This is a real run in VS Code's terminal, inside the examples folder.

A real screenshot of VS Code's terminal after running python pc_demo.py. It prints the visit-trained model with 19,404 training rows and an empty-row score of 0.3826. Then real visits, 7,403 requests by 2,771 customers, online AP 0.8174, and a table with columns key, TTL, drop, hit rate, missed an invoice, score changed and AP: customer 1h no 9.8%, 727, 606, 0.8171; customer 1h yes 0.0%; customer 30d no 43.0%, 3,186, 2,702, 0.8144; bins 30d no 8.4%, 13, 0, 0.8174. Then simulated views, 44,439 requests by 2,584 customers, online AP 0.8363: customer 1h no 66.8%, 28, 48, 0.8363; customer 30d no 87.1%, 12,107, 17,932, 0.8371; customer 30d yes 83.6%, 0, 13,861, 0.8360; bins 1h no 66.3%, 0, 0, 0.8363; bins 30d no 69.5%, 174, 0, 0.8363.

When I ran it, every hit count, every stale count, every changed-score count and every AP equalled the lab's seed-0 numbers to the last digit. Its exact output is in results/pc-demo-run.txt, and the report checks it against the lab. Notice that with seed 0 alone, the dropped 30-day cache on page views scored 0.8360 against 0.8371 for the kept one. One seed is one model. The bootstrap over 20 seeds is what makes the claim on slide 18.

tracemalloc
after
factcheck