Recommendations From Order History

In the Real World · Brief · Machine learning · Core · 2 to 3 days

Five empty slots on the basket page, 30,000 orders of history, and an answer key so you can score yourself. Build the recommender before you read the walkthrough.

The situation

Ashby Kitchen sells kitchen and coffee equipment. You have 79,045 order lines across 30,000 orders from 5,932 customers, 2025-01-06 to 2026-07-05, covering 87 products.

The basket page and the post-purchase email each show five products, and today both show the same five bestsellers to everyone. Ecommerce wants a real recommendation list and a number for what it is worth.

The data

FileWhat it holds
ashby-order-lines.csvorder_id, order_date, customer_id, sku, quantity, unit_price, line_revenue
ashby-products.csvsku, product_name, category, subcategory, unit_price, is_consumable
ashby-answer-key.csvorder_id, sku, origin, anchor_sku. Why each line is in the basket. Do not open it until you have a model
ashby-true-complements.csvanchor_sku, companion_sku, probability. The real links. Same warning

How to use the answer key

Build your recommender from the order lines and the product file alone. The other two files are the exam paper. Using the origin column as a feature gives a perfect score and teaches nothing. Using it afterwards, to ask what kind of item your model actually recommended, is the point of the exercise.

What the room believes

  1. A higher hit rate means a better recommender.
  2. Association rules surface the pairs worth recommending.
  3. Item to item collaborative filtering beats simple rules.
  4. Offline evaluation can tell you what a recommender is worth.

Definition of done

  1. A verdict on each of the four beliefs, with the evidence.
  2. At least three models compared on a holdout you can defend, including a top sellers baseline and a reorder baseline.
  3. A ranked list of recommendations per product, with the rules you applied to it.
  4. A statement of what is actually in your five slots, not only how often you were right.
  5. A pound figure for what the list is worth, and an honest account of what that figure does and does not prove.
  6. The design of the test that would settle it.

Five questions worth sitting with before you build anything

What would have happened to each purchase if the slot had been empty? Which of your recommendations is the customer already going to buy? Which are substitutes for something already in the basket? How would you split the data so the model cannot see the future? And what, exactly, is a hit worth?

If you want to go further

  • Rank the same rules on confidence and on lift, and look at what changes in the top twenty.
  • Put a support floor under the rules, then sweep it, and watch what happens to your hit rate and to the kind of item you are recommending.
  • Split the hit rate by whether the customer had bought the item before, and see how much of your score survives.
  • Work out the opportunity cost of a slot: what is the best thing you could have put there instead of the thing you did.
  • Design the live test, including how long it has to run.

When you are done, read the walkthrough. It compares six models on the same holdout, scores each one against the answer key, and finds that the model with the best hit rate is not the model worth shipping. Compare its slot composition table to yours.

In the Real World · Machine learning · Core · 2 to 3 days

Six recommenders on the same order history. The two that score best on hit rate fill three quarters of their slots with the six commonest lines in the shop. The one that scores worst is worth 11.4 times more per basket than the bestseller list the site runs today. Hit rate is not measuring what anyone is paying for.

The situation. Ashby Kitchen sells kitchen and coffee equipment. 30,000 orders, 79,045 order lines, 87 products, 78 weeks. The basket page and the post-purchase email each have five empty slots and today they show the same five bestsellers to everyone.

What the business is left with. A ranked recommendation list per product, a rule for which slots are worth filling, and the design of the test that proves whether any of it works.

Attempt it first. The brief has the same order history and an answer key you can score yourself against.

Section 1Problem Definition

No code yet. A recommendation slot is not free. It is a fixed amount of attention on a page, and whatever goes in it displaces whatever else could have gone there. That makes this a question about opportunity cost long before it is a question about algorithms.

The problem in one sentence

Every offline recommender metric rewards predicting what the customer was going to buy anyway, and the whole commercial value of a recommendation lies in the part they were not.

What fills the slotHow it scores offlineWhat it is worth
The item they buy every monthVery well. They buy it every monthNothing. It is a reminder, and the reorder page already does that
The line that is in a third of all basketsVery well. It is in a third of basketsAlmost nothing. They would have found it
A second frying pan, when a frying pan is in the basketReasonablyNegative. It is a substitute, and it delays the order
The consumable that only makes sense with the machine they just boughtBadly. It is rare in the fileThis is the entire point of the slot

Business objective

Fill five slots on the basket page with items the customer would not otherwise have bought, rank the products by how much of that they can produce, and state what has to be measured live before anyone believes a number.

Hypotheses

  1. H1. A higher hit rate means a better recommender.
  2. H2. Association rules surface the pairs worth recommending.
  3. H3. Item to item collaborative filtering beats simple rules.
  4. H4. Offline evaluation can tell you what a recommender is worth.

Why this dataset can answer a question your own data cannot

The order history is simulated, and every line carries a hidden origin: the customer came for it, it was dragged in by something else in the basket, it is a repeat of something they already buy, or it was drawn from general popularity. That origin ships as a separate answer key. On a real export it does not exist, which is exactly why offline recommender evaluation is so easy to get wrong.

Section 2Data Collection

import numpy as np
import pandas as pd

lines = pd.read_csv('data/ashby-order-lines.csv', parse_dates=['order_date'])
prod = pd.read_csv('data/ashby-products.csv')
key = pd.read_csv('data/ashby-answer-key.csv')

NAME = dict(zip(prod['sku'], prod['product_name']))
SUB = dict(zip(prod['sku'], prod['subcategory']))
PRICE = dict(zip(prod['sku'], prod['unit_price']))

print('orders    :', lines['order_id'].nunique())
print('lines     :', len(lines))
print('customers :', lines['customer_id'].nunique())
print('products  :', lines['sku'].nunique(), 'of', len(prod), 'listed')
print('window    :', lines['order_date'].min().date(), 'to', lines['order_date'].max().date())
orders    : 30000
lines     : 79045
customers : 5932
products  : 87 of 87 listed
window    : 2025-01-06 to 2026-07-05
FileGrainWhat it is
ashby-order-lines.csvOne row per product per order79,045 lines across 30,000 orders. This is the export a real shop would give you
ashby-products.csvOne row per productName, category, subcategory, price. The subcategory is what tells you two items are substitutes
ashby-answer-key.csvOne row per order lineWhy the line is in the basket. Published so the exercise can be scored, and the reason this article can measure something your own data cannot
ashby-true-complements.csvOne row per true pair40 genuine complement links with the strength of each. The list any rule mining is trying to recover

Order value across the window is 2,130,026 pounds over 78 weeks. The mean basket holds 2.63 lines and 17.1% of orders hold exactly one, which matters because a single-item order cannot be used to evaluate a basket recommender at all.

Section 3Data Preprocessing

3aDuplicates and schema checks

print('duplicate order and sku pairs :',
      int(lines.duplicated(subset=['order_id', 'sku']).sum()))
print('missing values                :', int(lines.isna().sum().sum()))
print('lines with a sku not in the product file :',
      int((~lines['sku'].isin(prod['sku'])).sum()))
print('answer key rows match line rows          :', len(key) == len(lines))
duplicate order and sku pairs : 0
missing values                : 0
lines with a sku not in the product file : 0
answer key rows match line rows          : True

A duplicated order and sku pair would double count a co-occurrence and inflate every rule that touches it. There are none here. On a real export there usually are, because a customer adds the same line twice and the warehouse system splits it.

3bHandling categorical mess

Two categorical fields do real work later. The subcategory identifies substitutes: two products in Pans compete for the same purchase, so recommending one when the other is already in the basket is not a recommendation. The consumable flag identifies replenishment, which is a different job with a different page.

3cDealing with outliers

size = lines.groupby('order_id').size()
print('basket size: mean %.2f  median %d  p99 %d  max %d'
      % (size.mean(), size.median(), size.quantile(0.99), size.max()))
print('single item orders :', round(100 * (size == 1).mean(), 1), 'percent')
print('usable for evaluation :', int((size > 1).sum()), 'orders')
basket size: mean 2.63  median 2  p99 6  max 9
single item orders : 17.1 percent
usable for evaluation : 24876 orders

No basket is large enough to distort a co-occurrence count. The outlier that matters in this kind of data is the trade customer with forty lines in one order, because every pair in that basket gets counted and one buyer can invent a rule on their own. There is none here. Look for one before you trust any rule.

3dHandling missing values

Nothing is missing. The absence that matters in a recommender is not a null, it is a product with too few orders to say anything about. That is handled with a support floor in section 5, not with imputation.

3eHandling skewed data

pen = lines.groupby('sku')['order_id'].nunique() / lines['order_id'].nunique()
print('top product penetration :', round(100 * pen.max(), 1), 'percent')
print('top 10 products are     :', round(100 * lines['sku'].isin(
    pen.nlargest(10).index).mean(), 1), 'percent of all lines')
print('products in under 1 percent of orders :', int((pen < 0.01).sum()))
top product penetration : 32.6 percent
top 10 products are     : 53.7 percent of all lines
products in under 1 percent of orders : 37

This skew is the whole difficulty. A handful of lines appear in a third of baskets, so any model that predicts them will look accurate, and any metric that counts hits will reward it for doing so.

3fData types and normalisation

baskets = lines.groupby('order_id')['sku'].apply(lambda s: list(dict.fromkeys(s)))
odate = lines.groupby('order_id')['order_date'].min()
ocust = lines.groupby('order_id')['customer_id'].first()
print('baskets built :', len(baskets))
print('mean distinct items per basket :', round(baskets.apply(len).mean(), 2))
baskets built : 30000
mean distinct items per basket : 2.63

Quantity is deliberately dropped. A basket is a set of products for this purpose: buying three packs of filters is one co-occurrence with the machine, not three.

Section 4Exploratory Data Analysis

4aTarget variable analysis

There is no target column. The target is constructed: hide one item from a basket, show the model the rest, and ask whether the hidden item appears in the five it recommends. That is hit rate at five, and by the end of section 7 the interesting question will be what it does not count rather than what it does.

4bNumerical variables

top = (lines.groupby('sku')['order_id'].nunique() / lines['order_id'].nunique()).nlargest(6)
for sku, p in top.items():
    print('%-8s %-32s %5.1f percent of orders' % (sku, NAME[sku], 100 * p))
AK-803   Surface spray, 750ml              32.6 percent of orders
AK-804   Microfibre cloths, 10 pack        30.4 percent of orders
AK-802   Dishwasher tablets, 60            17.6 percent of orders
AK-124   Single origin beans, 500g         10.3 percent of orders
AK-908   Silicone spatula set               9.9 percent of orders
AK-312   Muffin tray, 12 hole               8.6 percent of orders
Order penetration of the six most common lines07.6915.3923.0830.7738.4732.6%Surface spray30.4%Microfibre cloths17.6%Dishwasher tablets10.3%Single origin beans9.9%Silicone spatula set8.6%Muffin traythe top two are in nearly a third of all baskets

Three products sit far above everything else. Surface spray, 750ml is in 32.6% of orders and Microfibre cloths, 10 pack in 30.4%. They are the kitchen equivalent of a carrier bag: everyone takes one, and no recommendation caused it.

4cCategorical variables

This is where the answer key earns its place. Every line in the file arrived in the basket for one of four reasons, and only one of them is a recommendation opportunity.

print(key['origin'].value_counts(normalize=True).mul(100).round(1))
origin
base          45.4
seed          32.3
repeat        16.8
complement     5.5
Name: proportion, dtype: float64
Why each order line is in the basket010.7121.4332.1442.8653.5732.3%seed45.4%popularity16.8%repeat5.5%complementonly the last one is a slot a recommender can win

Seed is what the customer came for. Popularity is the everyday lines going in on the way past. Repeat is a replenishment of something they already buy. Complement is the item that would not be in the basket without something else in it, and it is 5.5% of all lines. That thin slice is the whole prize.

4dRelationships between variables

Association rules come with three numbers and most write-ups rank on the wrong one. Support is how often the pair appears. Confidence is how often the companion appears given the anchor. Lift is confidence divided by the companion’s own base rate.

def rules_from(bk, min_pairs=1):
    n_bk, cnt, item = len(bk), {}, {}
    for b in bk:
        u = sorted(set(b))
        for s in u:
            item[s] = item.get(s, 0) + 1
        for i, a in enumerate(u):
            for c in u[i + 1:]:
                cnt[(a, c)] = cnt.get((a, c), 0) + 1
    rows = []
    for (a, c), k in cnt.items():
        if k < min_pairs:
            continue
        for x, y in ((a, c), (c, a)):
            rows.append({'anchor': x, 'companion': y, 'pairs': k, 'support': k / n_bk,
                         'confidence': k / item[x],
                         'lift': (k / n_bk) / ((item[x] / n_bk) * (item[y] / n_bk))})
    return pd.DataFrame(rows), item, n_bk

rl, item_cnt, n_bk = rules_from(baskets)
print('candidate pairs :', len(rl) // 2)
print(rl.nlargest(8, 'confidence')[['anchor', 'companion', 'pairs',
                                    'confidence', 'lift']].round(3).to_string(index=False))
candidate pairs : 3111
anchor companion  pairs  confidence   lift
AK-906    AK-301    121       0.688 16.343
AK-323    AK-301    233       0.465 11.056
AK-321    AK-312    675       0.448  5.225
AK-514    AK-503     91       0.412 10.268
AK-412    AK-402     25       0.391 65.836
AK-413    AK-401     86       0.374 14.022
AK-128    AK-803     20       0.370  1.137
AK-502    AK-803     35       0.347  1.063
RankAnchorCompanionBasketsConfidenceLiftReal pair
1Mixing bowl set, 3 pieceStand mixer, 5L12168.8%16.34yes
2Strong white flour, 5kgStand mixer, 5L23346.5%11.06yes
3Muffin cases, 200 packMuffin tray, 12 hole67544.8%5.23yes
4Chainmail scrubberCast iron skillet, 26cm9141.2%10.27yes
5Whetstone, 1000/3000Chef knife, 20cm, forged2539.1%65.84yes
6Honing steelChef knife, 20cm8637.4%14.02yes
7Knock boxSurface spray, 750ml2037.0%1.14no
8Stainless frying pan, 28cmSurface spray, 750ml3534.7%1.06no

The first six are genuine. From rank 7 down, all 14 remaining rules in the top twenty point at just 2 products, and the highest lift among them is 1.14. Confidence has found that surface spray, 750ml is popular. It rediscovers that fact once for every anchor in the catalogue.

truth = pd.read_csv('data/ashby-true-complements.csv')
TRUE_PAIRS = set(zip(truth['anchor_sku'], truth['companion_sku']))
rl['true_pair'] = [(a, c) in TRUE_PAIRS or (c, a) in TRUE_PAIRS
                   for a, c in zip(rl['anchor'], rl['companion'])]
FLOOR = 60
strong = rl[rl['pairs'] >= FLOOR]

for label, df in [('confidence', rl.nlargest(20, 'confidence')),
                  ('lift, no support floor', rl.nlargest(20, 'lift')),
                  ('lift, 60 basket floor', strong.nlargest(20, 'lift'))]:
    print('%-24s real pairs in top 20: %4.1f percent   median baskets behind a rule: %4d'
          % (label, 100 * df['true_pair'].mean(), df['pairs'].median()))
confidence               real pairs in top 20: 30.0 percent   median baskets behind a rule:   63
lift, no support floor   real pairs in top 20: 80.0 percent   median baskets behind a rule:   69
lift, 60 basket floor    real pairs in top 20: 100.0 percent   median baskets behind a rule:  124
Share of the top 20 rules that are genuine complements023.647.270.894.411830%confidence80%lift, no floor100%lift, 60 floorranking on the right number, with a floor under it

Ranking on confidence puts 70.0% of the top twenty on an everyday line. Ranking on lift fixes that and introduces a different problem, which is rules built on a handful of baskets. A floor of 60 baskets under the pair removes both failures and recovers 50.0% of the 40 real links in twenty rules.

The one line that decides whether rule mining works

Rank on lift, then require a floor. Lift without a floor is a machine for finding coincidences: the top rule here rests on 25 baskets out of 30,000. Confidence without lift is a machine for rediscovering your bestsellers. Neither failure is visible in the metric itself, which is why both ship so often.

4eTesting our hypotheses

HypothesisVerdictEvidence
H1. A higher hit rate means a better recommenderWrongSection 7d: top sellers scores 43.3% and reaches 49.63 pounds per thousand baskets against 563.77 pounds for the model that scores 9.9%
H2. Association rules surface the pairs worth recommendingOnly if ranked properlyConfidence gets 30.0% of the top twenty right, lift with a floor gets 100.0%
H3. Item to item collaborative filtering beats simple rulesNot hereCosine scores 45.8% on hit rate and 139.12 pounds per thousand, behind the rules on money
H4. Offline evaluation can tell you what a recommender is worthNoIt can rank models on what is in the basket. It cannot say what would have been there anyway, which is the only part with a value

4fSubgroups

lines = lines.sort_values(['customer_id', 'order_date', 'order_id'])
seen, flag = set(), []
for c, s in zip(lines['customer_id'], lines['sku']):
    flag.append((c, s) in seen)
    seen.add((c, s))
lines['seen_before'] = flag
print('lines the customer had bought before :',
      round(100 * lines['seen_before'].mean(), 1), 'percent')
print('orders containing at least one       :',
      round(100 * lines.groupby('order_id')['seen_before'].any().mean(), 1), 'percent')
lines the customer had bought before : 33.8 percent
orders containing at least one       : 62.5 percent

62.5% of orders contain something the customer has bought before. A model that does nothing but list a customer’s own history will therefore look competent, and section 7a uses exactly that as a baseline because it is the honest floor for this problem.

Section 5Feature Engineering

5aThe leakage trap

There are three ways to leak in a recommender and the first two are easy to do by accident.

The leakHow it happensWhat it does
Mining rules on the whole fileCounting co-occurrences across all orders, then evaluating on some of themThe rule already knows the basket it is being tested on. Hit rates rise and nothing improves
A random train and test splitSplitting rows at random rather than by dateThe model is trained on next month to predict last month. Section 7c measures how much that flatters it here
Scoring with the answer keyUsing the origin column as a feature rather than as a scorerPerfect results, no model. The key is the exam paper, not the textbook

5bNew features

FeatureBuilt fromWhy
Pair counts and item countsTraining baskets onlyThe raw material for support, confidence and lift
Customer purchase historyTraining orders onlyNeeded for the repeat baseline and to exclude what they already own
Customer by item matrixTraining orders, binaryThe input to item to item cosine similarity
Subcategory match flagProduct fileMarks a candidate as a substitute for something already in the basket
Already owned flagCustomer historyMarks a candidate as replenishment rather than discovery

5cEncoding

The customer by item matrix is binary, not counts. Someone who orders filters twelve times a year is not twelve times more similar to another filter buyer, and counts let the heavy repeat buyers dominate every similarity in the matrix.

5dFeature selection

The only real tuning decision in this project is the support floor, and section 7c shows it is not a modelling parameter at all. It is a dial that trades hit rate against commercial value, in that direction, every time.

Section 6Model Selection

Six candidates, each of which is a real production option that some shop is running right now.

ModelWhat it doesWhy it is here
Top sellersThe five highest penetration products, minus what is in the basketThe thing the site does today, and the baseline every recommender must beat
What they bought beforeThe customer’s own history, most frequent firstThe honest floor. If a model cannot beat a reorder list it is a reorder list
Association rules, confidenceHighest confidence companion for anything in the basketThe default in most tutorials and most shipped implementations
Association rules, liftHighest lift companion for anything in the basketThe same machinery ranked on the number that controls for popularity
Item to item, cosineCosine similarity over the customer by item matrixThe classic collaborative filter, and what most people mean by a recommender
Complement rules, filteredLift with a support floor, no substitutes, nothing ownedThe rules model with the three commercial constraints actually applied

What is deliberately not here

No matrix factorisation and no neural recommender. Both are reasonable at a much larger catalogue. At 87 products and 30,000 orders they fit the same co-occurrence structure the rules already capture, and they would move the argument in this article by nothing at all. The problem here is the evaluation, not the model class.

Section 7Model Training

7aBaselines

cutoff = odate.max() - pd.Timedelta(weeks=12)
train_ids = list(odate.index[odate <= cutoff])
after = list(odate.index[odate > cutoff])
test_ids = [o for o in after if len(baskets[o]) > 1]
print('cutoff          :', cutoff.date())
print('train orders    :', len(train_ids))
print('orders after it :', len(after))
print('usable for test :', len(test_ids), 'with two or more lines')
cutoff          : 2026-04-12
train orders    : 24,980
orders after it : 5,020
usable for test : 4,231 with two or more lines

Twelve weeks held out at the end, never touched during rule mining. Every model below sees the same 4,231 baskets and the same hidden item in each one, drawn once, so the comparison is like for like.

7bComparing candidates

# Everything a model needs, built from training orders only. Called again in 7c
# with a different floor and with a different split, so it is a function.
def fit(train_ids, floor=FLOOR):
    global pop, conf, lift, liftf, cos, hist, rr
    rr, ic, nb = rules_from(baskets.loc[train_ids])
    rr['same_subcat'] = [SUB[a] == SUB[c] for a, c in zip(rr['anchor'], rr['companion'])]
    pop = (pd.Series(ic) / nb).sort_values(ascending=False)

    conf, lift, liftf = {}, {}, {}
    for r in rr.itertuples():
        conf.setdefault(r.anchor, []).append((r.companion, r.confidence))
        lift.setdefault(r.anchor, []).append((r.companion, r.lift))
        if r.pairs >= floor and not r.same_subcat:
            liftf.setdefault(r.anchor, []).append((r.companion, r.lift))

    tl = lines[lines['order_id'].isin(set(train_ids))]
    cols = sorted(tl['sku'].unique())
    mat = pd.crosstab(tl['customer_id'], tl['sku']).clip(upper=1).astype(float).values
    nrm = np.linalg.norm(mat, axis=0)
    nrm[nrm == 0] = 1
    S = (mat.T @ mat) / np.outer(nrm, nrm)
    np.fill_diagonal(S, 0.0)
    cos = pd.DataFrame(S, index=cols, columns=cols)
    hist = tl.groupby('customer_id')['sku'].apply(list).to_dict()

fit(train_ids)
def recommend(model, visible, cust, k=5):
    own, block = set(hist.get(cust, [])), set(visible)
    if model == 'popular':
        return [s for s in pop.index if s not in block][:k]
    if model == 'repeat':
        prev = [s for s in pd.Series(hist.get(cust, [])).value_counts().index
                if s not in block]
        pad = [s for s in pop.index if s not in block and s not in prev]
        return (prev + pad)[:k]
    if model == 'cosine':
        here = [s for s in visible if s in cos.index]
        if not here:
            return [s for s in pop.index if s not in block][:k]
        sc = cos.loc[here].sum().drop(labels=[s for s in block if s in cos.index],
                                      errors='ignore')
        return list(sc.nlargest(k).index)
    src = {'confidence': conf, 'lift': lift, 'complement': liftf}[model]
    sc = {}
    for s in visible:
        for c, val in src.get(s, []):
            if c in block or (model == 'complement' and c in own):
                continue
            sc[c] = max(sc.get(c, 0.0), val)
    out = [c for c, _ in sorted(sc.items(), key=lambda x: -x[1])][:k]
    return out + [s for s in pop.index
                  if s not in block and s not in out][:k - len(out)]
# One hidden item per test basket, drawn once so every model answers the same question.
hr = np.random.default_rng(11)
HELD = {o: baskets[o][int(hr.integers(0, len(baskets[o])))]
        for o in baskets.index if len(baskets[o]) > 1}

MODELS = [('popular', 'Top sellers'), ('repeat', 'What they bought before'),
          ('confidence', 'Association rules, confidence'),
          ('lift', 'Association rules, lift'), ('cosine', 'Item to item, cosine'),
          ('complement', 'Complement rules, filtered')]
BEST6 = list(pen.nlargest(6).index)

def evaluate(ids):
    res = {m: dict(n=0, hit=0, new=0, comp=0, value=0.0, slots=0, owned=0, best=0, disc=0)
           for m, _ in MODELS}
    for oid in ids:
        b = baskets[oid]
        held = HELD[oid]
        vis = [s for s in b if s != held]
        cust, own = ocust[oid], set(hist.get(ocust[oid], []))
        for m, _ in MODELS:
            recs = recommend(m, vis, cust)
            r = res[m]
            r['n'] += 1
            r['slots'] += len(recs)
            r['owned'] += sum(1 for c in recs if c in own)
            r['best'] += sum(1 for c in recs if c in BEST6)
            r['disc'] += sum(1 for c in recs if c not in own and c not in BEST6)
            if held in recs:
                r['hit'] += 1
                r['new'] += held not in own
                if ORIGIN.get((oid, held)) == 'complement':
                    r['comp'] += 1
                    r['value'] += PRICE[held]
    return pd.DataFrame([
        {'model': label, 'hit': 100 * r['hit'] / r['n'], 'new_hit': 100 * r['new'] / r['n'],
         'complement_hit': 100 * r['comp'] / r['n'],
         'value_per_1000': 1000 * r['value'] / r['n'],
         'owned_slots': 100 * r['owned'] / r['slots'],
         'top6_slots': 100 * r['best'] / r['slots'],
         'discovery_slots': 100 * r['disc'] / r['slots']}
        for (m, label), r in [((m, l), res[m]) for m, l in MODELS]])

ORIGIN = {(r.order_id, r.sku): r.origin for r in key.itertuples()}
out = evaluate(test_ids)
print(out[['model', 'hit', 'new_hit']].round(1).to_string(index=False))
                        model  hit  new_hit
                  Top sellers 43.3     14.4
      What they bought before 37.4      5.1
Association rules, confidence 49.0     18.8
      Association rules, lift  9.9      7.3
         Item to item, cosine 45.8     16.7
   Complement rules, filtered 17.9     13.0
Hit rate at five, twelve week holdoutthe leaderboard anyone would ship fromTop sellers43.3%What they bought before37.4%Association rules, confidence49.0%Association rules, lift9.9%Item to item, cosine45.8%Complement rules, filtered17.9%

On the metric every recommender paper reports, the ranking is clear. Association rules, confidence wins at 49.0%, cosine is close behind, and the filtered complement model looks like a failure at 17.9%. Ranking on lift looks like a catastrophe at 9.9%.

The second column is the first crack. New-item hit rate counts only the hits on items the customer had never bought before. What they bought before scores 37.4% on hit rate and 5.1% on new items, because almost everything it gets right is something the customer already had.

7cHyperparameter tuning

There is one dial. The support floor decides how many baskets must sit behind a pair before it is allowed to become a rule.

for floor in (5, 20, 60, 150, 400):
    fit(train_ids, floor)
    row = evaluate(test_ids).set_index('model').loc['Complement rules, filtered']
    print('floor %3d   hit %5.1f   value per 1000 %7.2f   discovery slots %5.1f'
          % (floor, row['hit'], row['value_per_1000'], row['discovery_slots']))

fit(train_ids)          # back to the chosen floor before anything else runs
floor   5   hit   8.6   value per 1000  472.51   discovery slots  98.5
floor  20   hit  11.4   value per 1000  482.44   discovery slots  92.7
floor  60   hit  17.9   value per 1000  453.13   discovery slots  79.9
floor 150   hit  28.6   value per 1000  337.25   discovery slots  58.0
floor 400   hit  39.4   value per 1000   85.46   discovery slots  21.6
Complement revenue reached per 1,000 baskets, by support floor0108216324432540best at 60.005.00103.75202.50301.25400.00minimum baskets behind a rulehit rate rises across this range from 8.6% to 39.4%

Raise the floor and the hit rate climbs from 8.6% to 39.4%, because a higher floor admits only common products and common products are easy to predict. Over the same range the commercial value falls from 472.51 pounds to 85.46 pounds per thousand baskets. The dial does not trade accuracy against complexity. It trades a metric against money.

The second thing worth checking is the split. A random split of rows rather than a split by date is the standard shortcut, and the standard warning is that it leaks the future.

# The same evaluation with a random share of orders held out instead of the
# final twelve weeks. Rules, history and similarities are rebuilt on the rest.
share = len(after) / len(odate)
mask = pd.Series(np.random.default_rng(7).random(len(odate)) < share, index=odate.index)
fit(list(odate.index[~mask]))
rand = evaluate([o for o in odate.index[mask] if len(baskets[o]) > 1])
fit(train_ids)

gap = out[['model', 'hit']].merge(rand[['model', 'hit']], on='model',
                                  suffixes=('_time', '_random'))
gap['gap'] = gap['hit_random'] - gap['hit_time']
for r in gap.itertuples():
    print('%-32s time %5.1f   random %5.1f   gap %+5.1f'
          % (r.model, r.hit_time, r.hit_random, r.gap))
Top sellers                      time  43.3   random  41.9   gap  -1.4
What they bought before          time  37.4   random  40.5   gap  +3.1
Association rules, confidence    time  49.0   random  48.6   gap  -0.4
Association rules, lift          time   9.9   random  12.0   gap  +2.1
Item to item, cosine             time  45.8   random  45.4   gap  -0.4
Complement rules, filtered       time  17.9   random  18.0   gap  +0.1

The split is not the problem here, and it is worth saying so

The largest gap is 3.1 points and most are inside a point. Rules mined on twelve extra weeks of a stable catalogue are the same rules. Use the time split anyway, because on a catalogue that changes it will matter, but do not go looking for the explanation of these results in the split. The explanation is in what a hit is worth.

7dFinal evaluation

Three columns decide this, and none of them is the hit rate. What is in the slots, how many of the hits were on items the customer would have found anyway, and what the rest are worth.

print(out[['model', 'hit', 'owned_slots', 'top6_slots', 'discovery_slots',
           'complement_hit', 'value_per_1000']].round(2).to_string(index=False))
                        model   hit  owned_slots  top6_slots  discovery_slots  complement_hit  value_per_1000
                  Top sellers 43.35        48.94       96.91             1.85            0.35           49.63
      What they bought before 37.39        86.41       46.45             0.20            0.43           61.31
Association rules, confidence 49.00        45.54       73.93            19.07            3.78          540.20
      Association rules, lift  9.86         7.15        1.20            92.19            4.09          563.77
         Item to item, cosine 45.83        48.74       76.40            15.22            1.06          139.12
   Complement rules, filtered 17.87         7.02       20.10            79.90            3.31          453.13
ModelHit at 5Slots already ownedSlots in the top six linesDiscovery slotsComplement hitsReached per 1,000
Top sellers43.3%48.9%96.9%1.9%0.3%49.63 pounds
What they bought before37.4%86.4%46.4%0.2%0.4%61.31 pounds
Association rules, confidence49.0%45.5%73.9%19.1%3.8%540.20 pounds
Association rules, lift9.9%7.1%1.2%92.2%4.1%563.77 pounds
Item to item, cosine45.8%48.7%76.4%15.2%1.1%139.12 pounds
Complement rules, filtered17.9%7.0%20.1%79.9%3.3%453.13 pounds
What each model actually puts in the five slots-14.535+10.659+35.853+61.047+86.241+111.435Top sellersWhat they bought beforeAssociation rulesAssociation rulesItem to itemComplement rulesin the top six linesnew to this customerthe two best models on hit rate fill three quarters of their slots with the six most common lines

Top sellers puts 96.9% of its slots on the six most common lines and cosine puts 76.4%. What they bought before puts 86.4% of its slots on things the customer already owns. These are the models with the best hit rates, and this is why.

Complement revenue reached per 1,000 baskets, in poundsTop sellers50What they bought before61Association rules, confidence540Association rules, lift564Item to item, cosine139Complement rules, filtered453

Same models, same holdout, same hidden items. The ordering is close to reversed. Association rules, lift reaches 563.77 pounds while scoring 9.9% on hit rate. Top sellers reaches 49.63 pounds while scoring 43.3%.

What this number is, and what it is not

Complement revenue reached is the value of the items a model surfaced that the answer key marks as complement driven: in the basket because of something else in the basket. It is the only part of a hit that a recommendation could plausibly have caused, and it is an upper bound rather than a measurement. Even a complement hit might have happened without the slot. Nothing offline can settle that, which is why section 8 hands over a test rather than a forecast.

Across the six models the rank correlation between hit rate and value is -0.257. Not zero, which would at least be honest noise. Negative, which means that on this data choosing the model with the better hit rate is slightly worse than choosing at random.

The two leaderboards, side by side-8.457+6.201+20.859+35.518+50.176+64.834Top sellersWhat they bought beforeAssociation rulesAssociation rulesItem to itemComplement ruleshit rate, percentvalue per 1,000, tens of poundsvalue divided by ten so both fit one axis

The tallest value bar belongs to the shortest hit rate bar. Cosine is second on hit rate and fourth on value. Top sellers is third on hit rate and last on value. Only association rules on confidence does well on both, and section 7d explains why that is an accident of this catalogue rather than a reason to rank on hit rate. Value is plotted in tens of pounds so the two series share an axis, which changes neither ordering.

At 400,000 baskets a year, the spread between the best and worst model on this measure is 225,508 pounds against 19,852 pounds of complement revenue reached. Choosing on hit rate picks association rules on confidence, worth 216,080 pounds, which is close to the best available and is luck rather than method. One place down the same leaderboard sits cosine, 3.2 points behind on the metric and 160,432 pounds behind on the money. Two places down sits the bestseller list the site already runs, 19,852 pounds. Nothing in the hit rate column tells you which of those three you are choosing.

Section 8Documentation and Handoff

Ship the filtered complement rules, and measure them properly

Rank on lift, put a floor of 60 baskets under every rule, drop candidates in the same subcategory as something already in the basket, and drop anything the customer already owns. That model scores 17.9% on hit rate, puts 79.9% of its slots on something new to the customer, and reaches 453.13 pounds per thousand baskets against 49.63 pounds for what the site shows today.

Do not ship it on the strength of that number. Every figure in section 7d is an offline estimate of an upper bound. Run the slots as a randomised test, holdout against live, and read incremental revenue per basket. That is the only number that settles it, and it takes weeks rather than a modelling sprint.

What to do, and who owns it

ActionDetailOwner
Rank on lift with a support floorConfidence alone recommends your bestsellers to everyone. Lift alone recommends coincidences. Both together, with a floor, is the whole recipeAnalytics
Exclude same subcategory candidatesA second frying pan is a substitute. It is not a recommendation and it can delay the orderAnalytics
Exclude what the customer already ownsReplenishment belongs on the reorder page, where it costs no discovery slotProduct
Report slot composition beside every metricHit rate with the share of slots on top sellers and on already owned items next to it. One number without the other two is not readableAnalytics
Run the holdout test before anyone claims a numberRandomise at customer level, hold slots empty for the control, read incremental revenue per basket over at least four weeksProduct and Analytics
Refresh rules monthly, not nightlyRules built on 24,980 orders barely move in twelve weeks. Nightly refresh adds churn and no signalEngineering

What not to do

  • Do not choose a model on hit rate. Across these six the rank correlation between hit rate and value is -0.257.
  • Do not let the reorder list masquerade as a recommender. What they bought before scores 37.4% with 86.4% of its slots on items the customer already owns.
  • Do not rank association rules on confidence. 14 of the top twenty here point at 2 products with a lift of about one.
  • Do not mine rules on the full file. Mine on training orders only, or every rule already knows the basket it is scored on.
  • Do not raise the support floor to make the metric look better. It works, and it costs 387.05 pounds per thousand baskets to do it.
  • Do not reach for a bigger model first. At 87 products the evaluation is the problem, not the architecture.

Reproducibility

ItemValue
Filesashby-order-lines.csv, ashby-products.csv, ashby-answer-key.csv, ashby-true-complements.csv
Window2025-01-06 to 2026-07-05, 78 weeks
SplitTime based. Train to 2026-04-12, test on the final twelve weeks, 4,231 baskets with two or more lines
ProtocolLeave one out. One hidden item per basket, drawn once with seed 11, five recommendations, item excluded if already visible in the basket
Chosen modelLift, support floor 60 baskets, no same subcategory candidates, nothing already owned
Ground truthKnown because the history is simulated. Origin per line in the answer key, complement links in the true complements file
Librariespandas, numpy

What to take from this

  • A recommendation slot is only worth the purchases it creates. Every offline metric measures purchases that happened, which is a different set.
  • Hit rate rewards popularity and replenishment. Both are things the customer would have reached without you, and together they are most of what a good hit rate is made of.
  • Rank rules on lift and put a floor under them. Confidence finds your bestsellers, bare lift finds coincidences, and the fix is one line.
  • Report what is in the slots, not just what was hit. Share of slots on top sellers and on already owned items make a hit rate readable.
  • Exclude substitutes and owned items before you ship. Both look like hits offline and neither is a recommendation.
  • Only a holdout can price a recommender. Everything before that is a shortlist of things worth testing.

The basket page will show five things whatever happens. The question was never which model predicts best, it was what those five slots are for, and the answer is the small share of purchases that would not exist without them. On this file that share is 5.5% of all order lines, and every metric in general use is dominated by the other 94.5%.

Size the holdout test this project hands over

Sample Size Calculator

The recommendation here is a randomised slot test rather than an offline number. Put your basket conversion and the effect you need to detect in, and it tells you how many baskets the test needs. On a two per cent effect the answer is larger than most teams expect.

Open the calculator

Free, no signup. Pairs with the Power and MDE Calculator.

Track what the slots do to basket value

Ecommerce Dashboard: A Free Excel Template

A recommender is judged on items per basket and basket value, not on hit rate. This template builds both from an order export, which is the same file this project starts from.

Download the dashboard

Free Excel template, LAD branded. No signup.

Companion projects. Uplift Modelling is the same argument on a discount email: the customers most likely to buy are not the customers worth targeting. Cohorts, Retention and LTV builds the repeat purchase view that decides which of these products belong on a reorder page instead.

View Comments (1)

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading