Skip to content

Capstone 5 - A Recommender with an Honest Evaluation

The decision

A catalogue of 600 items, 1,200 users, and a slot on the home page showing ten recommendations. The questions the team actually has to answer:

  1. Is a personalised recommender worth building at all, against showing the most popular items?
  2. Which users should it be applied to?
  3. How do we know the offline number is not a fantasy?

The third question is the hard one, and it is why this capstone exists. The recommender page measured that evaluating against 100 sampled negatives instead of the full catalogue inflates every hit rate by 1.90× to 2.14×. Every number below is a full-catalogue number.

Step 1 — the protocol, decided before the model

ChoiceDecisionWhy
splitleave-one-out per user with ≥5 interactionsmatches “predict the next thing”
evaluable users1,017 of 1,200183 users have too little history
candidatesall 600 items, minus the ones the user already hassampling inflates by ~2×
metrichit rate at 5, 10 and 2010 is the slot size; the others show sensitivity
second metriccatalogue coveragea recommender that shows 20 items is not a recommender
baselinemost popular itemsit must be beaten before anything ships

Writing this table first is the whole trick. Every reported improvement in recommender systems is negotiable if the protocol is chosen after the results.

Step 2 — the results

figurePopularity reaches hit@10 0.1976 by recommending 20 items to everybodymatplotlib
Left: grouped bars of hit rate at 5, 10 and 20 for popularity (0.1131, 0.1976, 0.2812), item-item (0.2409, 0.3481, 0.4779) and blend (0.2419, 0.3540, 0.4818). Right: distinct items ever recommended in a top-10 list — popularity 20 of 600, item-item 339, blend 323.Left: grouped bars of hit rate at 5, 10 and 20 for popularity (0.1131, 0.1976, 0.2812), item-item (0.2409, 0.3481, 0.4779) and blend (0.2419, 0.3540, 0.4818). Right: distinct items ever recommended in a top-10 list — popularity 20 of 600, item-item 339, blend 323.
Personalisation nearly doubles the hit rate at every k. The right panel is the part a metrics dashboard usually omits: the popularity baseline achieves its 0.1976 while ever showing only 20 distinct items — 3.3% of the catalogue — so 580 items are unreachable and the merchandising team has no idea.
Policyhit@5hit@10hit@20Catalogue coverage (top 10)
popularity0.11310.19760.281220 of 600 (3.3%)
item-item cosine0.24090.34810.4779339 of 600 (56.5%)
blend0.24190.35400.4818323 of 600 (53.8%)

Personalisation is worth building here. hit@10 goes from 0.1976 to 0.3481 — a 1.76× improvement, measured against the full catalogue, using six lines of numpy.

Catalogue coverage is the finding nobody asks for. Popularity’s 0.1976 comes from recommending the same 20 items to all 1,017 users. As an accuracy metric that is fine; as a product it means 96.7% of the catalogue is never seen, new items can never be discovered, and every user’s home page looks identical. Report coverage next to hit rate, always.

Step 3 — the blend, and where it comes from

The cold-start measurement showed that personalisation loses on users with almost no history. So the policy is two lines:

blend.py
def scores_for(user):
    """Popularity for cold users, collaborative filtering for everyone else."""
    if interactions_in_training[user] < MIN_HISTORY:      # 5
        return popularity
    return train[user] @ item_similarity
blend.py
def scores_for(user):
    """Popularity for cold users, collaborative filtering for everyone else."""
    if interactions_in_training[user] < MIN_HISTORY:      # 5
        return popularity
    return train[user] @ item_similarity
Interactions in trainingnpopularityitem-itemblend
4810.24690.17280.2469
5–72520.24210.31350.3135
8–101990.20600.32660.3266
11–152030.21180.41380.4138
16–612820.12770.39720.3972
all1,0170.19760.34810.3540

The blend gains +0.0059 overall, and all of it comes from the 81 users where item-item scored 0.1728 and popularity scored 0.2469. That is a small aggregate number produced by a large segment-level one — +0.0741 for the users it affects — which is the shape most good production changes have.

See it move

MIN_HISTORYMIN_HISTORY is the only parameter in that policy, and it is a routing parameter rather than a model one — so its effect can be computed exactly from the segment table. Drag it and watch each segment switch strategy and the aggregate follow.

sketch One routing parameter, five segments p5.js
The measured per-segment hit rates for popularity and item-item, with a draggable MIN_HISTORY cutoff. Each segment is routed to whichever strategy the cutoff selects and the aggregate hit rate is recomputed as a weighted average. The optimum is 5, which is exactly where the per-segment lines cross.

The weighted average reproduces the published numbers exactly, which is a useful check that the blend is arithmetic rather than magic: cutoff 0 gives 0.3481 (item-item everywhere), cutoff 5 gives 0.3540 (the blend), and cutoff 62 gives 0.1976 (popularity everywhere). Push the cutoff past 5 and the aggregate falls — 0.3363 at 8, 0.3127 at 11, 0.2724 at 16 — because you are now handing popularity to users who have enough history for personalisation to win. The optimum sits exactly where the two per-segment bars cross, and nowhere else.

Step 4 — what the online system needs

recommend.py
CONFIG = {"slate_size": 10, "min_history": 5, "model_version": "reco-3.0.1"}
 
 
def recommend(user_id, artefact, config):
    """A slate, plus the fields that make an offline/online comparison possible."""
    history = artefact["history"](user_id)
    cold = len(history) < config["min_history"]
    scores = (artefact["popularity"] if cold
              else artefact["similarity_scores"](history))
 
    ranked = [item for item in np.argsort(-scores) if item not in history]
    slate = ranked[:config["slate_size"]]
    return {
        "user_id": user_id,
        "slate": slate,
        "strategy": "popularity" if cold else "item_item",
        "history_size": len(history),
        "score_span": round(float(scores[slate[0]] - scores[slate[-1]]), 6),
        "model_version": config["model_version"],
        "catalogue_share_today": artefact["coverage_counter"].add(slate),
    }
recommend.py
CONFIG = {"slate_size": 10, "min_history": 5, "model_version": "reco-3.0.1"}
 
 
def recommend(user_id, artefact, config):
    """A slate, plus the fields that make an offline/online comparison possible."""
    history = artefact["history"](user_id)
    cold = len(history) < config["min_history"]
    scores = (artefact["popularity"] if cold
              else artefact["similarity_scores"](history))
 
    ranked = [item for item in np.argsort(-scores) if item not in history]
    slate = ranked[:config["slate_size"]]
    return {
        "user_id": user_id,
        "slate": slate,
        "strategy": "popularity" if cold else "item_item",
        "history_size": len(history),
        "score_span": round(float(scores[slate[0]] - scores[slate[-1]]), 6),
        "model_version": config["model_version"],
        "catalogue_share_today": artefact["coverage_counter"].add(slate),
    }

Three of those fields exist because of what this phase measured. strategystrategy makes the blend auditable — when the cold-user share moves, the aggregate hit rate moves with it and you need to know which. ** score_spanscore_span** is near zero when the model has nothing to say, which is a better abstention signal than a threshold on the raw score. catalogue_share_todaycatalogue_share_today is coverage as a live metric rather than an offline curiosity.

Step 5 — the honest limits of the offline number

ProblemWhy the offline number is optimisticWhat to do
Feedback loopsYesterday’s recommendations created today’s interactions, so the model is scored on data it shapedLog which slate produced each interaction; hold out a random-slate slice
Position biasItem 1 is clicked more than item 10 regardless of relevanceModel position explicitly, or randomise within the slate
Only one relevant itemLeave-one-out treats the other 599 as irrelevant, which is falseReport it as a lower bound; run an online test
183 excluded users15% of the base has too little history to evaluate at allTheir metric is a product question: what does a new user see?
No timestampsA random held-out item can predate the training interactionsUse a temporal split for a production estimate

The last two are the ones with teeth. A leave-one-out benchmark excludes exactly the users the blend was built for, and it can leak the future by holding out an early interaction. A temporal split fixes the second; nothing fixes the first except deciding, as a product, what a brand-new user sees.

Offline evaluation ranks candidate policies; it does not predict online performance. The purpose of the protocol above is to shortlist honestly, cheaply and reproducibly. The number that matters comes from an A/B test, and the offline number’s job is to make sure the A/B test is worth running.

The loop that number lives inside, with the leak that makes it optimistic drawn explicitly:

diagram Diagram mermaid

The dotted edge is why an offline hit rate cannot be a forecast: the data it is measured on was produced by the policy it is being compared against. The random-slate slice is the cheap structural fix — a few percent of traffic showing unranked items buys you an evaluation set that no policy has contaminated, and without it every future offline comparison inherits the same bias.

Recap

  • 1,017 evaluable users of 1,200; every number computed against the full 600-item catalogue.
  • Popularity: hit@10 0.1976 while showing only 20 distinct items (3.3% of the catalogue).
  • Item-item cosine: hit@10 0.3481 across 339 items — a 1.76× improvement, in six lines.
  • The blend (popularity below five interactions) reaches 0.3540, gaining +0.0741 for the 81 cold users and +0.0059 overall.
  • Catalogue coverage belongs next to hit rate in every report.
  • Offline numbers shortlist policies. Feedback loops, position bias and the 183 excluded users mean the decision belongs to an online test.
quizCheck yourself
  1. Your recommender reaches hit rate 0.1976 at 10. What must you check before calling it a success?

    Show answer

    B — Whether that number was measured against the full catalogue, and how many distinct items the policy ever recommends — the popularity baseline hits 0.1976 while showing only 20 of 600 items — Two protocol facts decide the interpretation: sampled negatives inflate hit rates by about 2x, and a policy can reach a respectable hit rate while making 96.7% of the catalogue unreachable.

  2. The blend lifts hit@10 from 0.3481 to 0.3540. Is it worth shipping?

    Show answer

    B — Yes: the aggregate is small because the blend only affects 81 users, and for them it is worth +0.0741 — the newest users, who are also the most likely to leave — Segmenting by activity turns an ignorable aggregate into a clear decision. This is the usual shape of a good production change: a large effect on a small, important segment.

  3. Why report catalogue coverage alongside hit rate?

    Show answer

    B — Because a policy can score well while showing everyone the same few items — 20 of 600 here — which makes new items undiscoverable and every home page identical — Coverage is a product constraint that accuracy metrics are blind to. It is also cheap: count the distinct items appearing in top-10 slates, offline and in production.

  4. Which limitation of the offline evaluation is least fixable by a better protocol?

    Show answer

    B — The feedback loop: the interactions you evaluate on were themselves produced by earlier recommendations, so the model is scored on data it shaped — Full-catalogue ranking fixes sampling, randomisation-within-slate mitigates position bias, and treating leave-one-out as a lower bound handles the single-relevant-item problem. Breaking the feedback loop requires deliberately logging random slates.

  5. What is the correct role of the offline hit rate?

    Show answer

    B — To shortlist candidate policies cheaply and reproducibly, so that the A/B test is worth running — It cannot predict online performance — feedback loops and position bias make the mapping unknown. It can rank policies honestly, which is what decides where to spend a limited number of experiment slots.

🧪 Try It Yourself

Exercise 1 – Set up the protocol

Exercise 2 – Score three policies at three cut-offs

Exercise 3 – Count the catalogue

Exercise 4 – Segment by activity, and find the blend’s real effect

Exercise 5 – Show what the sampled protocol would have claimed

Next

Phase 12 - Capstone Projects — the phase overview, and the six questions every one of these projects had to answer before a model was worth building.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did