Recommendation system
Build a film recommender from a tiny ratings table using item-to-item collaborative filtering, then handle the new-user problem that breaks every real system.
- 18 min read
- 3 reading levels
- Updated
Read these first
On this page 8
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
What you are building
A program that guesses which film you will like, from what other people liked.
Think of a friend who has the same taste in films as you. You both loved the same three. When that friend recommends a fourth one, you go and watch it.
You are not trusting the film. You are trusting the overlap in taste.
That overlap is the whole idea, and this project turns it into code.
Why this had to be invented
A shop with twenty items needs no recommender. You can look at all twenty.
Netflix has thousands of titles. Amazon has millions of products. Nobody scrolls through a million things.
So the shelf has to rearrange itself for each person. Somebody had to work out how, without ever asking you to fill in a form about your taste.
The trick: compare films, not people
There are two ways to do this, and one of them works much better in practice.
Compare people. Find someone with taste like yours, then suggest what they liked. This sounds natural, and it breaks quickly. People change. A person who watched horror last month watches comedy now.
Compare films. Work out which films get liked by the same crowd. Films do not change their character. Once you know that two horror films attract the same people, that stays true for years.
This project uses the second one. It is called item-to-item collaborative filtering. Collaborative means it uses the crowd. Filtering means it narrows a huge list down.
How it works, in one picture
who rated what
|
v
[ compare every film with every other film ]
|
v
Tumbbad is close to Andhadhun
Tumbbad is far from Sholay
|
v
[ for one person: look at what they DID rate ]
|
v
"You gave Tumbbad a 5, and Andhadhun is close to it,
so you will probably give Andhadhun about a 4.5"
|
v
top 3 films they have not seenThe program never reads a plot. It never knows a genre. It works purely from the pattern of who rated what.
Where you have already seen this
- Netflix rows titled "Because you watched…".
- Amazon showing "Customers who bought this also bought…".
- Spotify building a weekly playlist you never asked for.
- YouTube picking the next video before the current one ends.
The honest part
Two things go wrong with this, and they go wrong for everybody.
A brand new user gets nothing. With no ratings, there is nothing to compare against. This is called the cold start problem. The system is cold because no history has warmed it up yet. The project code shows this happening, and shows the usual patch.
Popular things get more popular. A film that many people rated will keep being recommended. A good film that nobody has rated stays invisible. Left alone, a recommender narrows what everyone sees rather than widening it.
Neither problem is solved. They are managed, by every company running one.
Remember this
- A recommender guesses your taste from the overlap between you and other people.
- Comparing films to films is steadier than comparing people to people.
- New users and new items get bad recommendations, and that is a known, unfixed weakness.
What to learn next
- Unsupervised learning — finding groups in data with no answer key.
- Embeddings — the same "similar things sit close together" idea, for words.
- Sentiment analysis — another project on real text.
Developer — Code and libraries.
The problem, stated precisely
You have a table of ratings. Rows are people, columns are films, and most cells are empty because nobody watches everything.
Your job is to fill in a plausible number for the empty cells, then return the highest ones a person has not already seen.
Setup
pip install numpy pandasTwo libraries, no download, no GPU. The whole thing runs in well under a second.
The data
Real datasets exist — MovieLens 100K is the classic one, at about 5 MB. Start smaller. A table you can read with your eyes catches bugs that a 100,000-row file hides.
Six people, eight films, ratings out of five. A zero means "has not rated it", not "hated it". Confusing those two is the first bug everybody writes.
The full program
import numpy as np
import pandas as pd
# 6 people, 8 films, ratings out of 5. A 0 means "not rated yet", NOT "hated it".
RATINGS = pd.DataFrame(
[
[5, 4, 0, 5, 1, 0, 0, 2],
[4, 5, 4, 4, 0, 1, 0, 0],
[0, 4, 5, 0, 2, 0, 1, 0],
[1, 0, 2, 1, 5, 4, 4, 5],
[0, 1, 0, 2, 4, 5, 5, 4],
[2, 0, 1, 0, 5, 4, 0, 5],
],
index=["Aarav", "Bhavna", "Chetan", "Divya", "Esha", "Farhan"],
columns=["Sholay", "Lagaan", "Dangal", "3 Idiots",
"Tumbbad", "Bhoot", "Stree", "Andhadhun"],
).astype(float)
def item_similarity(ratings):
"""How alike are two films, judged only by people who rated both."""
seen = (ratings > 0).astype(float) # 1 where a rating exists
# centre each film on its own average, so a generous rater does not make
# every film look similar to every other film
means = ratings.sum(axis=0) / np.maximum(seen.sum(axis=0), 1)
centred = (ratings - means) * seen # unrated cells stay exactly 0
norms = np.sqrt((centred ** 2).sum(axis=0))
norms[norms == 0] = 1.0 # never divide by zero
similarity = (centred.T @ centred) / np.outer(norms, norms)
np.fill_diagonal(similarity.values, 0.0) # a film cannot recommend itself
return similarity
def recommend(person, ratings, similarity, k=3):
mine = ratings.loc[person]
rated = mine[mine > 0]
scores = {}
for film in mine[mine == 0].index:
weights = similarity[film][rated.index].clip(lower=0) # dislike is not evidence for
if weights.sum() == 0:
continue
scores[film] = float((weights * rated).sum() / weights.sum())
return pd.Series(scores, dtype=float).sort_values(ascending=False).head(k)
SIMILARITY = item_similarity(RATINGS)
print("films most like 'Tumbbad':")
print(SIMILARITY["Tumbbad"].sort_values(ascending=False).round(2).to_string())
for person in ["Aarav", "Esha"]:
print(f"\ntop picks for {person}:")
print(recommend(person, RATINGS, SIMILARITY).round(2).to_string())What actually comes out
films most like 'Tumbbad': Andhadhun 0.90 Stree 0.50 Bhoot 0.23 Tumbbad 0.00 Lagaan -0.31 Dangal -0.66 3 Idiots -0.75 Sholay -0.84 top picks for Aarav: Dangal 4.75 Bhoot 1.37 Stree 1.16 top picks for Esha: Sholay 1.77 Dangal 1.53
Read that first block before moving on. The program has separated the horror films from the drama films, and nobody told it what horror means. Tumbbad, Andhadhun and Stree cluster together. Sholay and 3 Idiots sit at the opposite end with strongly negative numbers.
That structure came out of six rows of ratings. This is the moment recommenders feel like magic, and it is worth pausing on.
Now read the recommendations more carefully
Aarav rated the dramas highly. He gets Dangal at 4.75 — a confident, correct pick.
But look at his next two. Bhoot scores 1.37 and Stree scores 1.16. Those are not recommendations. Those are the model saying "I have nothing else for you."
Esha is worse. She likes horror, and her only unseen films are two dramas. Both score under 1.8.
A ranked list always returns something. Ranking does not mean recommending. Real systems put a floor under it:
picks = recommend("Esha", RATINGS, SIMILARITY)
picks = picks[picks >= 3.0] # below this, show a fallback row insteadShipping a recommender without that line is how users end up seeing "Recommended for you: a film we are confident you will dislike."
Line by line, for the parts that are not obvious
(ratings - means) * seen — this is the line that makes or breaks the whole file. Subtracting the mean is called mean centring: it turns "5 out of 5" into "one point above this film's usual score". Multiplying by seen afterwards forces the unrated cells back to exactly zero, so an empty cell contributes nothing rather than contributing a large negative.
centred.T @ centred — one matrix multiply computes every film-against-every-film comparison at once. Shape (8, 6) @ (6, 8) gives (8, 8). Dividing by the outer product of the norms turns those raw totals into cosine similarity, a number from -1 to 1 measuring whether two columns point the same direction.
np.fill_diagonal(similarity.values, 0.0) — every film is a perfect match for itself. Left in, that self-match dominates every score. Note .values: np.fill_diagonal needs the underlying array, and calling it on the DataFrame raises ValueError.
.clip(lower=0) — negative similarity means "people who liked this tend to dislike that". Real information, and dangerous here. A negative weight multiplied by a high rating pulls a score in a direction the arithmetic did not intend. Dropping negatives keeps the weighted average honest.
similarity[film][rated.index] — only the films this person actually rated get a vote. This is what makes the prediction personal rather than global.
Common mistakes
Skipping the mean centring. Run the same data through plain cosine similarity, with the raw numbers instead of centred ones:
# the same similarity, but WITHOUT subtracting each film's mean
seen = (RATINGS > 0).astype(float)
raw = RATINGS * seen
norms = np.sqrt((raw ** 2).sum(axis=0))
norms[norms == 0] = 1.0
raw_similarity = (raw.T @ raw) / np.outer(norms, norms)
np.fill_diagonal(raw_similarity.values, 0.0)
print("raw cosine, no centring - similarity to Tumbbad:")
print(raw_similarity["Tumbbad"].sort_values(ascending=False).round(2).to_string())raw cosine, no centring - similarity to Tumbbad: Andhadhun 0.96 Bhoot 0.93 Stree 0.77 Dangal 0.44 Sholay 0.35 3 Idiots 0.31 Lagaan 0.25 Tumbbad 0.00
Every single film now looks positively similar to Tumbbad. Sholay went from -0.84 to +0.35. The signal is gone, because ratings are all positive numbers, so every column points into the same corner of the space. Centring is not a refinement. It is the thing that makes the numbers mean anything.
Treating 0 as a rating of zero. Pandas will happily average your empty cells into the result. Every unrated film then looks terrible, and the model recommends only the most-rated films. Use NaN and .mean(skipna=True), or use the seen mask as above.
Recommending films the person already rated. Filter with mine[mine == 0]. Users lose trust in one screen when told to watch something they reviewed last week.
Testing on the data you fitted on. Predicting a rating you already showed the model proves nothing. Hide a slice of known ratings, predict them, and compare. That is model evaluation, and it applies here exactly as it does everywhere else.
How to make it better
1. Handle the cold start
Add this to the bottom of recommend.py:
# ---- what happens to somebody brand new ------------------------------------
RATINGS.loc["Gauri"] = 0.0 # a new user, zero ratings
print("\nfilms we can recommend to Gauri:", len(recommend("Gauri", RATINGS, SIMILARITY)))
seen = (RATINGS > 0).sum(axis=0) # how many people rated it at all
mean = RATINGS[RATINGS > 0].mean(axis=0) # average of the ratings it did get
prior, weight = 3.0, 3 # pretend 3 people gave it a neutral 3
damped = ((mean * seen) + (prior * weight)) / (seen + weight)
print("fallback for a stranger, damped so one rave review cannot win:")
print(damped.sort_values(ascending=False).round(2).head(4).to_string())The new lines at the bottom of the output:
films we can recommend to Gauri: 0 fallback for a stranger, damped so one rave review cannot win: Andhadhun 3.57 Lagaan 3.29 Bhoot 3.29 Tumbbad 3.25
Zero recommendations, exactly as predicted. The fix is a damped popularity ranking: blend each film's average with a neutral prior, weighted by how many ratings it has. A film with one five-star rating no longer beats a film with fifty four-star ratings.
Every large recommender has a version of this running behind the personalised one.
2. Use behaviour, not stars
Almost nobody rates things. Everybody clicks, watches and skips.
Switch to implicit feedback — a signal taken from behaviour rather than a form. Store watch-seconds or a click flag. The maths changes: an absent value now means "not seen yet", and a low value means "seen and abandoned", which are different states. Look up alternating least squares for implicit feedback before writing this yourself.
3. Precompute and cache
Similarity between every pair of items costs time proportional to the square of the item count. With 100,000 items that matrix does not fit in memory.
Two moves solve it. Compute similarity nightly, not per request. Keep only the top 50 neighbours per item, which is all the scoring loop ever reads.
4. Move to matrix factorisation
Item-to-item breaks down when the table gets very sparse. Matrix factorisation learns a short list of hidden traits per user and per film, then multiplies them to predict a rating. scikit-surprise and implicit both do this in a few lines. The researcher block below has the objective it optimises.
5. Measure the right thing
Rating error is the wrong metric. Users see a list, not a number.
Measure recall@k: hide some films each user actually liked, then check how often they appear in the top k. Also track coverage — what fraction of your catalogue ever gets recommended to anyone. A recommender with brilliant accuracy and 3% coverage has become a bestseller list.
Try it yourself
Add a seventh person who rated Sholay a 5 and Tumbbad a 5. Predict what happens to the Tumbbad–Sholay similarity before you run it.
Then add nine more people with exactly that pattern and watch the similarity matrix bend around them. You have discovered how a small coordinated group can steer a recommender, which is a live problem for every platform that runs one.
What to learn next
- Model evaluation — measuring a recommender honestly.
- Pandas — the table operations this project leans on.
- Vector databases — finding nearest neighbours when the matrix stops fitting in memory.
Researcher — Mathematics and papers.
Item-based collaborative filtering, formally
Let $R \in \mathbb{R}^{m \times n}$ be the rating matrix over $m$ users and $n$ items, with observed index set $\mathcal{O} = {(u,i) : r_{ui} \text{ is known}}$.
Adjusted cosine similarity between items $i$ and $j$ (Sarwar et al., 2001):
$$ s_{ij} = \frac{\sum_{u \in U_{ij}} (r_{ui} - \bar{r}i)(r{uj} - \bar{r}j)} {\sqrt{\sum{u \in U_{ij}} (r_{ui} - \bar{r}i)^2}\; \sqrt{\sum{u \in U_{ij}} (r_{uj} - \bar{r}_j)^2}} $$
Where:
- $r_{ui}$ — the rating user $u$ gave item $i$.
- $U_{ij}$ — the set of users who rated both $i$ and $j$.
- $\bar{r}_i$ — the mean observed rating of item $i$.
- $s_{ij} \in [-1, 1]$ — the resulting similarity.
The prediction for an unseen pair, over the top-$k$ neighbourhood $N_k(i)$ of items the user has rated:
$$ \hat{r}{ui} = \frac{\sum{j \in N_k(i)} s_{ij}\, r_{uj}}{\sum_{j \in N_k(i)} |s_{ij}|} $$
The implementation above clips $s_{ij}$ at zero, which makes the denominator $\sum \max(s_{ij}, 0)$. That is a deliberate departure from the formula: negative-similarity terms are poorly calibrated on sparse data and frequently degrade top-$k$ ranking quality even when they improve RMSE.
Shrinkage is the standard correction for thin overlaps. With $|U_{ij}|$ small, $s_{ij}$ is high-variance, so multiply by a shrink factor:
$$ \tilde{s}{ij} = \frac{|U{ij}|}{|U_{ij}| + \lambda}\, s_{ij}, \qquad \lambda \approx 100 $$
Omitting this is the most common reason a textbook implementation underperforms a popularity baseline.
Cost
- Similarity matrix: $O(n^2 \bar{u})$ time and $O(n^2)$ memory, where $\bar{u}$ is the mean number of raters per item. Intractable beyond roughly $10^5$ items without truncation.
- Truncated to the top $k$ neighbours: $O(nk)$ memory, and scoring one user is $O(|I_u| k)$ for $|I_u|$ rated items.
- The matrix is recomputed offline. Online serving is a sparse lookup, which is why this method survived into production systems long after better-scoring alternatives appeared (Linden et al., 2003, describing Amazon).
Matrix factorisation
The dominant alternative learns latent factors $p_u, q_i \in \mathbb{R}^f$ (Koren et al., 2009):
$$ \hat{r}_{ui} = \mu + b_u + b_i + q_i^{\top} p_u $$
$\mu$ is the global mean, $b_u$ and $b_i$ are user and item biases. Fitting minimises regularised squared error over observed entries only:
$$ \min_{p, q, b} \sum_{(u,i) \in \mathcal{O}} \left(r_{ui} - \hat{r}_{ui}\right)^2
- \lambda \left(|p_u|^2 + |q_i|^2 + b_u^2 + b_i^2\right) $$
Solved by SGD or by alternating least squares, which is convex in $p$ with $q$ fixed and vice versa, and parallelises cleanly across users.
The bias terms carry more weight than newcomers expect. On the Netflix Prize data, $\mu + b_u + b_i$ alone recovered a substantial share of the achievable improvement over the global mean. Report a bias-only baseline before claiming your factor model works.
Implicit feedback
Explicit ratings are rare and non-random. For observed behaviour, Hu et al. (2008) split the signal into a binary preference $p_{ui} = \mathbb{1}[r_{ui} > 0]$ and a confidence $c_{ui} = 1 + \alpha r_{ui}$, then minimise over all cells, unobserved included:
$$ \min_{p,q} \sum_{u,i} c_{ui}\left(p_{ui} - q_i^{\top} p_u\right)^2 + \lambda\left(|p_u|^2 + |q_i|^2\right) $$
The sum over $m \times n$ cells is made tractable by an algebraic identity that reuses $Q^{\top}Q$ across users, giving $O(f^2 |\mathcal{O}| + f^3 n)$ per iteration rather than $O(f^2 mn)$.
BPR (Rendle et al., 2009) takes the ranking view instead, optimising a pairwise objective over triples $(u, i, j)$ with $i$ observed and $j$ sampled:
$$ \max_{\Theta} \sum_{(u,i,j)} \ln \sigma!\left(\hat{x}{uij}\right) - \lambda |\Theta|^2, \qquad \hat{x}{uij} = \hat{r}{ui} - \hat{r}{uj} $$
This optimises AUC directly, which matches the top-$k$ task better than squared error does.
Evaluation, and the traps in it
Offline metrics: recall@k, precision@k, MAP@k, NDCG@k. Rating-error metrics (RMSE, MAE) are weakly correlated with top-$k$ ranking quality and should not be the headline number.
Four systematic biases distort offline evaluation:
- Missing not at random. Users rate what they chose to consume. $\Pr(\text{observed})$ depends on the rating itself, so the test set is not a random sample of the truth. Marlin and Zemel (2009) established this empirically.
- Popularity bias. Cremonesi et al. (2010) showed that a plain popularity ranking beats several published personalised methods under common protocols. Report it as a baseline; if you do not beat it, say so.
- Sampled metrics. Ranking the true item against 100 sampled negatives, rather than the full catalogue, produces metrics that are not monotone in the full-catalogue metric (Krichene and Rendle, 2020). Several reported improvements dissolve under full ranking.
- Feedback loops. Training data is generated by the previous model. Chaney et al. (2018) show this drives homogenisation. Logged propensities and inverse-propensity weighting (Schnabel et al., 2016) are the standard partial correction.
Dacrema et al. (2019) reproduced eighteen neural recommendation papers from top venues and found that most were beaten by well-tuned nearest-neighbour or linear baselines. Treat that as the prior when reading new architecture claims in this area.
Current practice
Industrial systems are two-stage. Retrieval narrows millions of items to hundreds using a two-tower encoder — separate user and item towers trained with sampled softmax, so item vectors precompute into an ANN index (Covington et al., 2016; Yi et al., 2019). Ranking then scores those hundreds with a heavy model that can afford cross-features, trained on the exact objective the product cares about.
Sequential models treat the interaction history as an ordered sequence: SASRec (Kang and McAuley, 2018) applies causal self-attention, BERT4Rec (Sun et al., 2019) applies masked-item prediction. They help most where order genuinely carries signal, such as short-video and music.
Rendle et al. (2020) showed that a properly tuned dot product outperforms the learned MLP similarity of Neural Collaborative Filtering. The dot product remains the default for retrieval, and it is what makes ANN indexing possible at all.
Papers
- Sarwar et al., Item-Based Collaborative Filtering Recommendation Algorithms, WWW 2001
- Linden et al., Amazon.com Recommendations: Item-to-Item Collaborative Filtering, IEEE Internet Computing 2003
- Hu, Koren and Volinsky, Collaborative Filtering for Implicit Feedback Datasets, ICDM 2008
- Rendle et al., BPR: Bayesian Personalized Ranking from Implicit Feedback, UAI 2009 — arxiv.org/abs/1205.2618
- Koren, Bell and Volinsky, Matrix Factorization Techniques for Recommender Systems, IEEE Computer 2009
- Cremonesi, Koren and Turrin, Performance of Recommender Algorithms on Top-N Recommendation Tasks, RecSys 2010
- Covington, Adams and Sargin, Deep Neural Networks for YouTube Recommendations, RecSys 2016
- Schnabel et al., Recommendations as Treatments, ICML 2016 — arxiv.org/abs/1602.05352
- Kang and McAuley, Self-Attentive Sequential Recommendation, ICDM 2018 — arxiv.org/abs/1808.09781
- Dacrema, Cremonesi and Jannach, Are We Really Making Much Progress?, RecSys 2019 — arxiv.org/abs/1907.06902
- Krichene and Rendle, On Sampled Metrics for Item Recommendation, KDD 2020
- Rendle et al., Neural Collaborative Filtering vs. Matrix Factorization Revisited, RecSys 2020 — arxiv.org/abs/2005.09683
What to learn next
- Model evaluation — ranking metrics and honest baselines.
- Embeddings — the representation two-tower retrieval depends on.
- Vector databases — approximate nearest neighbour search at catalogue scale.