Recommender Systems

What is a recommender system?

A recommender system watches what people do and puts a short, personal shortlist in front of each one, because nobody can search a catalogue of a million items.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. How it works, in one picture
  5. Where you have already used one
  6. The honest part
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

A recommender system is software that picks a small number of things it thinks you personally will like, out of a huge pile.

The analogy you have already lived

Think of the tea stall you go to every morning. By the third week, the man behind the counter starts making your tea before you say a word. Less sugar, extra ginger, in the small glass.

He never asked you to fill in a form. He watched what you ordered, remembered it, and acted on it.

That is the whole idea. A recommender system is that shopkeeper, working for ten million customers at once.

Why it exists

Search works when you already know what you want. You type "waterproof shoes", you get waterproof shoes.

The trouble starts when you do not know. You open a streaming app with 40,000 films and no plan. Typing is useless, because you cannot search for a film whose name you have never heard.

So the catalogue got bigger and browsing got worse. Before recommenders, a big catalogue meant most items were never seen by anybody. The shop was enormous and only the front shelf sold.

A recommender flips the direction of the question. Instead of you describing what you want, the system describes you back to itself, and builds a front shelf for one person.

How it works, in one picture

Every recommender, however fancy, is these three steps:

   everything you did before
   (watched, clicked, bought, skipped)
             |
             v
   [ 1. narrow the catalogue ]     40,000 items  ->  about 500 candidates
             |                     fast and rough
             v
   [ 2. score each candidate ]     500 candidates -> 500 numbers
             |                     slow and careful
             v
   [ 3. tidy the final list ]      remove repeats, mix in something new,
             |                     drop anything you already own
             v
   the ten rows you actually see

Step 1 is called retrieval — grabbing a rough shortlist quickly. Step 2 is called ranking — scoring that shortlist carefully. Step 3 is called re-ranking — fixing the list so it reads well as a list.

The split exists for a boring reason: money and time. Scoring 40,000 items carefully for every person, every time they open the app, costs far too much. So you throw most of the catalogue away cheaply first.

Where you have already used one

  • YouTube's home page. Almost nothing there was searched for.
  • Amazon's "customers who bought this also bought". One of the oldest ones still running.
  • Spotify's weekly playlist, and the autoplay after a song ends.
  • Instagram Reels and the app store's "for you" tab.
  • Zomato and Swiggy putting different restaurants first for you than for your neighbour.

Most of what you saw on your phone today was chosen by a recommender, not by you.

The honest part

A recommender does not know what is good for you. It knows what people like you tended to click on.

Those two things overlap a lot, and then they come apart badly at the edges. A system rewarded for watch time learns that outrage and cliffhangers work. A system rewarded for purchases learns to show the cheap thing you will regret.

There is a second problem, and it is structural. Popular items get shown more, so they get clicked more, so they look even more popular tomorrow. The system feeds on its own output. This is called popularity bias, and nobody has fully fixed it.

Both of these get a full lesson later. Do not skip diversity and filter bubbles — the harms are as real as the benefits.

Remember this

  • A recommender turns "what do you want?" into "here is what we think you want".
  • It always has three stages: narrow, score, tidy.
  • It optimises the number you tell it to optimise, and nothing else.

What to learn next

Developer — Code and libraries.

Setup

bash
python3 --version

No libraries at all for this one. The standard library is enough, and that is deliberate — a recommender is a data-shaping problem long before it is a modelling problem.

The data everyone actually has

Beginners picture a neat table of star ratings. Almost nobody has that. What you have is a log of events: this person finished this film. No stars, no dislikes, no explanation.

Five people, seven films, one bit of information per pair.

baseline.py
from collections import Counter

# "Finished watching" events. No star ratings anywhere - this is what real logs look like.
WATCHED = {
    "Aarav":  {"Sholay", "Lagaan", "3 Idiots"},
    "Bhavna": {"Sholay", "3 Idiots", "Dangal"},
    "Chetan": {"Sholay", "Lagaan", "Dangal"},
    "Divya":  {"Tumbbad", "Stree"},
    "Esha":   {"Tumbbad", "Andhadhun"},
}
CATALOGUE = sorted({film for films in WATCHED.values() for film in films})

POPULARITY = Counter(film for films in WATCHED.values() for film in films)


def by_popularity(person, k=2):
    """Stage 1 for everybody: the most-watched films this person has not seen."""
    unseen = [f for f in CATALOGUE if f not in WATCHED[person]]
    unseen.sort(key=lambda f: (-POPULARITY[f], f))   # name breaks ties so runs repeat
    return unseen[:k]


def by_co_watching(person, k=2):
    """Score an unseen film by how much audience it shares with this person's films."""
    scores = Counter()
    for other, films in WATCHED.items():
        if other == person:
            continue
        overlap = len(films & WATCHED[person])       # how alike are our two histories
        for film in films - WATCHED[person]:
            scores[film] += overlap
    ranked = sorted(scores.items(), key=lambda pair: (-pair[1], pair[0]))
    return [film for film, score in ranked[:k] if score > 0]


print("how often each film was watched:")
for film in sorted(CATALOGUE, key=lambda f: (-POPULARITY[f], f)):
    print(f"  {POPULARITY[film]}  {film}")

print("\nperson      popularity picks          co-watching picks")
for person in WATCHED:
    print(f"{person:<11} {str(by_popularity(person)):<25} {by_co_watching(person)}")
Output
how often each film was watched:
  3  Sholay
  2  3 Idiots
  2  Dangal
  2  Lagaan
  2  Tumbbad
  1  Andhadhun
  1  Stree

person      popularity picks          co-watching picks
Aarav       ['Dangal', 'Tumbbad']     ['Dangal']
Bhavna      ['Lagaan', 'Tumbbad']     ['Lagaan']
Chetan      ['3 Idiots', 'Tumbbad']   ['3 Idiots']
Divya       ['Sholay', '3 Idiots']    ['Andhadhun']
Esha        ['Sholay', '3 Idiots']    ['Stree']

Read the last two rows before anything else

Divya and Esha watch horror. The popularity column hands both of them Sholay and 3 Idiots, the two biggest crowd-pleasers in the table, and completely wrong for them.

The co-watching column gets both right. Divya gets Andhadhun, Esha gets Stree. Nobody told the program which films are horror. It worked that out from an overlap count on five rows.

The gap between those two columns is what personalisation buys you, shown at the smallest scale that can show it.

Now read the top three rows, because they are the uncomfortable ones

For Aarav, Bhavna and Chetan, the two columns agree on the first pick. Popularity got them right by accident, because their taste is the popular taste.

This is the most under-reported fact about recommenders. Most of your users sit near the middle, and popularity serves the middle well. A popularity baseline is not a strawman. It is genuinely hard to beat on average metrics, and teams routinely ship a complicated model that beats it by nothing.

Build the boring baseline first. Then you know what your model has to beat.

Line by line, for the parts that are not obvious

len(films & WATCHED[person]) — set intersection gives how many films two people both watched. That count becomes the weight of that person's opinion. Somebody sharing three of your films speaks louder than somebody sharing one.

films - WATCHED[person] — set difference gives the films they watched and you did not. Those are the only candidates worth scoring. Recommending something already watched is the most common visible bug in a demo.

sorted(..., key=lambda pair: (-pair[1], pair[0])) — score descending, then name ascending. The name key looks like decoration and is not. Without it, two items on the same score come back in whatever order the dictionary happens to hold, and your tests break the day you add a film.

if score > 0 — a filter that refuses to return an item with no evidence behind it. Ranking functions always return something. Returning nothing is often the correct answer, and you have to write that branch yourself.

Common mistakes

Treating "not watched" as "disliked". A zero in your table means the person never saw the item. In a catalogue of 40,000, almost everything is unseen by almost everybody. This mistake poisons every model downstream, and it has its own lesson: implicit feedback.

Not excluding what the user already has. It sounds too small to mention. It is the number one complaint in app-store reviews of shopping apps: "it keeps advertising the fridge I bought last week".

Evaluating on the data you trained on. Your model will look wonderful and then die in production. Split by time, not at random — see train, test and validation splits.

Optimising accuracy when the product needs a list. Predicting star ratings well and producing a good top-10 are different jobs. Ranking metrics explains why.

Try it yourself

Add a sixth person who has watched one film from each cluster: {"Sholay", "Tumbbad"}. Run it, and look at what by_co_watching returns for them.

Then work out, on paper, why a person with wide taste is harder to serve than a person with narrow taste. That question has no clean answer, and every large recommender team is still arguing about it.

What to learn next

Researcher — Mathematics and papers.

The problem, stated

Let $U$ be the set of users and $I$ the set of items. A recommender estimates a utility function

$$ \hat{r} : U \times I \rightarrow \mathbb{R} $$

and returns, for each user $u$, the top-$N$ set

$$ R_N(u) = \operatorname*{arg\,max}_{S \subseteq I \setminus I_u,\; |S| = N} \; \sum_{i \in S} \hat{r}(u, i) $$

Where:

  • $I_u \subseteq I$ — the items user $u$ has already consumed, excluded from candidacy.
  • $\hat{r}(u,i)$ — the estimated utility of item $i$ for user $u$.
  • $N$ — the slate size, typically 10 to 50 per row of a page.

Writing the objective as a sum over $S$ makes top-$N$ selection separable, so greedy selection of the $N$ highest scores is optimal. That separability is an assumption, not a fact. The moment the objective rewards diversity or penalises near-duplicates, utility becomes set-valued, greedy selection stops being optimal, and the problem turns into submodular maximisation. See diversity and filter bubbles.

Why the industrial pipeline is staged

Full scoring costs $O(|U| \cdot |I| \cdot c)$ per refresh, with $c$ the cost of one model evaluation. For $|I| \sim 10^8$ and a deep ranker, that is unaffordable by several orders of magnitude.

The standard decomposition was made canonical by Covington, Adams and Sargin (2016), Deep Neural Networks for YouTube Recommendations:

StageCorpus sizeModel classLatency budget
Retrieval$10^6$–$10^9$ → $10^2$–$10^3$Bi-encoder plus ANN index~10 ms
Ranking$10^2$–$10^3$ → scoredDeep cross-feature model~50 ms
Re-rankingslateMMR, DPP, business rules~1 ms

Retrieval must factorise into $f(u)^\top g(i)$ so item vectors can be precomputed and searched with approximate nearest neighbours. That constraint is why two-tower models dominate retrieval. The reason is architectural, not empirical.

Feedback loops make the data non-i.i.d.

The training data of a deployed recommender is generated by the previous version of that recommender. Logged interactions are therefore missing-not-at-random: the exposure probability $p(o_{ui} = 1)$ is a function of the old policy, and is confounded with relevance.

Chaney, Stewart and Engelhardt (2018), How algorithmic confounding in recommendation systems increases homogeneity, formalise the consequence. Naive offline training amplifies whatever bias the previous policy carried, and supervised evaluation on logged data measures agreement with the old policy rather than utility.

Inverse propensity scoring corrects the estimator when exposure probabilities are known or modelled:

$$ \hat{R}{\text{IPS}}(\pi) = \frac{1}{|D|} \sum{(u,i) \in D} \frac{\mathbb{1}[\pi \text{ shows } i \text{ to } u]}{p(o_{ui}=1)} \; r_{ui} $$

Where $\pi$ is the policy under evaluation, $D$ the logged dataset, $r_{ui}$ the observed reward, and $p(o_{ui}=1)$ the logging propensity. The estimator is unbiased when propensities are correct and bounded away from zero. Variance explodes when they are small, which is why clipped and self-normalised variants are used in practice. Schnabel et al. (2016), Recommendations as Treatments, is the reference treatment.

The offline–online gap

Offline metric improvements transfer to online lift unreliably. Three mechanisms account for most of it:

  1. Exposure bias, above — the test set contains only items the old system chose to show.
  2. Objective mismatch — offline metrics score a ranking, online metrics score user behaviour across sessions and weeks.
  3. Presentation effects — click probability depends on slot, thumbnail and neighbouring items, none of which appear in an offline ranking metric.

Dacrema, Cremonesi and Jannach (2019), Are we really making much progress?, reproduced 18 neural recommender papers and found 11 were beaten by properly tuned simple baselines, most often item-kNN or a linear model. The methodological point survives whatever you conclude about the individual reproductions: tuning effort spent on baselines is systematically smaller than tuning effort spent on the proposed method.

Foundational reading

  • Resnick et al. (1994), GroupLens: An Open Architecture for Collaborative Filtering of Netnews — the first user-based collaborative filtering system.
  • Linden, Smith and York (2003), Amazon.com Recommendations: Item-to-Item Collaborative Filtering — why item-item beat user-user in production.
  • Koren, Bell and Volinsky (2009), Matrix Factorization Techniques for Recommender Systems — the Netflix Prize synthesis.
  • Covington, Adams and Sargin (2016), Deep Neural Networks for YouTube Recommendations — the retrieval-then-ranking pipeline as deployed.
  • Dacrema et al. (2019), Are we really making much progress? — arxiv.org/abs/1907.06902
  • Chaney et al. (2018), How Algorithmic Confounding in Recommendation Systems Increases Homogeneity — arxiv.org/abs/1710.11214

What to learn next

What to learn next

These follow on from what you just read.

  • Recommender Systems

    Collaborative filtering

    Collaborative filtering recommends things by finding people who behaved like you and looking at what they liked next, without knowing anything about the items themselves.

  • Recommender Systems

    Content-based filtering

    Content-based filtering recommends items that look like the ones you already liked, using the item's own description instead of other people's behaviour.

  • Recommender Systems

    Matrix factorisation

    Matrix factorisation describes every user and every item by a short list of hidden traits, learned from the ratings, so a missing rating becomes a multiplication instead of a lookup.