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.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. The two ways to do it
  5. The thing that trips everyone
  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

Collaborative filtering finds people whose taste matches yours, then recommends what they liked and you have not seen.

The analogy you have already lived

You have a cousin whose film taste keeps matching yours. Every time she says a film is good, you watch it and she was right. So now when she recommends something, you listen.

You also have an uncle who loves films you find unbearable. His recommendations are still useful — they tell you what to avoid.

Collaborative filtering is that, at scale. The system finds your cousins among ten million strangers, and it does it from behaviour alone.

Why it exists

The first idea people tried was describing items. Tag every film with a genre, a language and a decade, then match tags to your stated preferences.

That kept failing for two reasons. Writing good tags for 40,000 films is slow, expensive human work. And taste does not live in tags. Two horror films can share every tag and feel nothing alike.

Then somebody noticed something better was already sitting in the database. You do not need to know what a film is. You need to know who else watched it.

The word "collaborative" means the users do the work for each other, without meeting or knowing it. Your ratings help a stranger. Their ratings help you.

The two ways to do it

There are exactly two directions you can look.

User-based. Find people like you. Recommend what they liked.

  you  ->  people with similar history  ->  what they liked and you have not seen

Item-based. Find items like the ones you liked. Recommend those.

  a film you liked  ->  films watched by the same crowd  ->  recommend those

Both use the same table of who-did-what. The second one won in industry, and the reason is practical rather than deep. There are usually far more users than items, and items change taste slowly. You can compute film-to-film similarity overnight and reuse it all day. People-to-people similarity goes stale the moment somebody watches something.

Amazon's "customers who bought this also bought" is item-based collaborative filtering, and it has been running in some form since 2003.

The thing that trips everyone

Two people can both rate a film 4 out of 5 and mean completely different things.

Some people rate generously and give almost everything a 4 or 5. Others are harsh and reserve 5 for two films in their life. A 4 from the harsh rater is high praise. A 4 from the generous rater is a shrug.

So before comparing anybody, you subtract each person's own average from their ratings. Now the numbers say "above my usual" or "below my usual", which is comparable across people. This step is called mean centring, and skipping it makes every user look similar to every other user.

The honest part

Collaborative filtering has one crippling weakness, and it never goes away.

It cannot say anything about a person who has done nothing, or an item nobody has touched. A new film has no audience, so it has no neighbours, so it is invisible. A new user has no history, so nobody is similar to them.

This is the cold-start problem, and every real system carries a second, dumber system alongside collaborative filtering to handle it. It gets its own lesson: the cold-start problem.

There is a second weakness worth knowing early. The table is mostly empty. On a real catalogue, a typical user has touched far less than one percent of the items. Almost every pair of users shares no items at all, so "similarity" is being computed from a handful of overlaps. That is thin evidence, and it is why raw similarity numbers should never be trusted on their own.

Remember this

  • Collaborative filtering uses behaviour, not item descriptions.
  • User-based finds people like you; item-based finds items like the ones you liked.
  • Subtract each person's average before comparing, or everyone looks alike.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy

One library, no download, runs in milliseconds. A six-by-six table you can read with your eyes catches bugs that a hundred-thousand-row file hides completely.

User-based collaborative filtering, complete

user_cf.py
import numpy as np

USERS = ["Aarav", "Bhavna", "Chetan", "Divya", "Esha", "Farhan"]
FILMS = ["Sholay", "Lagaan", "3 Idiots", "Dangal", "Tumbbad", "Stree"]

# Ratings out of 5. A 0 means "not rated", NOT "hated it".
R = np.array([
    [5, 4, 5, 0, 1, 0],
    [4, 5, 4, 5, 2, 1],
    [5, 4, 0, 4, 1, 2],
    [1, 2, 1, 0, 5, 4],
    [2, 1, 0, 1, 4, 5],
    [0, 1, 2, 0, 5, 4],
], dtype=float)

SEEN = (R > 0).astype(float)
# Each person's own average, over the films they actually rated.
MEANS = R.sum(axis=1) / np.maximum(SEEN.sum(axis=1), 1)
C = (R - MEANS[:, None]) * SEEN          # unrated cells forced back to exactly 0


def user_similarity(C):
    norms = np.sqrt((C ** 2).sum(axis=1))
    norms[norms == 0] = 1.0
    S = (C @ C.T) / np.outer(norms, norms)
    np.fill_diagonal(S, 0.0)             # you are not your own neighbour
    return S


S = user_similarity(C)


def predict(u, i, k=2):
    """Predicted rating of film i for user u, from the k most similar people who rated it."""
    others = [v for v in range(len(USERS)) if SEEN[v, i] and S[u, v] > 0]
    others.sort(key=lambda v: -S[u, v])
    others = others[:k]
    if not others:
        return None
    w = np.array([S[u, v] for v in others])
    diffs = np.array([R[v, i] - MEANS[v] for v in others])
    return float(MEANS[u] + (w @ diffs) / w.sum())


print("who is most like Aarav?")
for v in sorted(range(len(USERS)), key=lambda v: -S[0, v]):
    if v != 0:
        print(f"  {S[0, v]:+.2f}  {USERS[v]}")

print("\nfilms Aarav has not rated:")
for i, film in enumerate(FILMS):
    if not SEEN[0, i]:
        p = predict(0, i)
        print(f"  {film:<9} predicted {p:.2f}" if p else f"  {film:<9} no evidence")

print("\nsame film, two very different people:")
for u in (0, 3):
    print(f"  Dangal for {USERS[u]:<7} {predict(u, 3):.2f}")
Output
who is most like Aarav?
  +0.79  Chetan
  +0.48  Bhavna
  -0.42  Esha
  -0.70  Farhan
  -0.90  Divya

films Aarav has not rated:
  Dangal    predicted 4.81
  Stree     predicted 2.06

same film, two very different people:
  Dangal for Aarav   4.81
  Dangal for Divya   1.00

What that output is telling you

The similarity column has split six people into two camps, and nobody supplied a genre. Chetan and Bhavna sit at +0.79 and +0.48. Divya sits at -0.90, which is close to a perfect opposite.

Negative similarity is real information, not noise. It says Divya's ratings move in the opposite direction to Aarav's.

Then look at the last two lines. The same film, Dangal, scores 4.81 for Aarav and 1.00 for Divya. One model, one table, two opposite answers. That is personalisation working.

Line by line, for the parts that are not obvious

R.sum(axis=1) / np.maximum(SEEN.sum(axis=1), 1) — the average over rated films only, because zeros are absences. The np.maximum(..., 1) guards a user who has rated nothing, so you get a mean of 0.0 rather than a RuntimeWarning: invalid value encountered in divide and a table full of nan.

(R - MEANS[:, None]) * SEEN — the line the whole file depends on. Subtracting the mean makes ratings comparable across people. Multiplying by SEEN afterwards puts the unrated cells back to exactly zero. Without that second step, an unrated cell becomes a large negative number and reads as "hated it", which is exactly backwards.

(C @ C.T) / np.outer(norms, norms) — one matrix multiply computes every user against every other user. On mean-centred rows, this is the cosine similarity: a number from -1 to +1 saying whether two rating patterns point the same way. On mean-centred data over co-rated items it coincides with the Pearson correlation, which is why you see both names used for the same code.

MEANS[u] + (w @ diffs) / w.sum() — the prediction is not a weighted average of raw ratings. It is your baseline, plus a weighted average of how far your neighbours deviated from their baselines. Predicting raw ratings directly makes a harsh rater's list drift upwards, which is a common and hard-to-spot bug.

S[u, v] > 0 — only positive neighbours are allowed to vote. Somebody who is your opposite tells you what to avoid, and folding that into a weighted average with a negative weight makes the arithmetic unstable when weights nearly cancel.

Item-based, in three sentences

Transpose the table and run the identical code. Rows become films, columns become people, and the output becomes film-to-film similarity.

Then score an unseen film by how similar it is to the films this person already rated highly. The full item-based version, with recommendations rather than predicted ratings, is written out in the recommendation system project.

Common mistakes

Using zero as a rating. Every part of this file treats 0 as absent. Load a dataset where 0 is a legitimate rating and the code is silently wrong everywhere. Convert to NaN on load, or keep a separate mask, and never let the two meanings share a value.

Cosine on raw, uncentred ratings. Everybody's ratings are positive, so every pair of users looks similar and the similarity matrix collapses towards +1. You will get recommendations that are pure popularity wearing a costume.

Trusting similarity computed from two shared items. Two users who both rated exactly two films the same way get a similarity of 1.0, which is meaningless. Production systems apply significance weighting: multiply the similarity by min(n_common, 50) / 50, so thin overlaps are shrunk towards zero.

Forgetting to clip predictions. Nothing above constrains the output to the rating scale. A confident neighbourhood can predict 5.4 out of five. Wrap the return in min(5.0, max(1.0, value)) before it reaches a user interface.

Recomputing the whole similarity matrix on every request. It costs $O(|U|^2 |I|)$ and belongs in a nightly batch job, not a request handler. This is the practical reason item-based won.

Try it yourself

Change k=2 to k=5 in predict and re-run. Aarav's Stree prediction will move, because weaker and more distant neighbours are now voting.

Then add a seventh user who has rated exactly one film. Watch what the similarity row for that person looks like, and decide for yourself whether you would show them anything at all.

What to learn next

Researcher — Mathematics and papers.

Notation

  • $U$ — users, $I$ — items, $r_{ui}$ — the rating of user $u$ on item $i$.
  • $I_u$ — items rated by $u$; $U_i$ — users who rated $i$.
  • $\bar{r}_u$ — the mean rating of user $u$ over $I_u$.
  • $N_k(u; i)$ — the $k$ nearest neighbours of $u$ among $U_i$.

User-based prediction

$$ \hat{r}_{ui} = \bar{r}u + \frac{\sum{v \in N_k(u;i)} s(u,v)\,(r_{vi} - \bar{r}v)}{\sum{v \in N_k(u;i)} |s(u,v)|} $$

The mean-centring is not cosmetic: it removes a per-user additive bias, which is the dominant single-parameter effect in explicit rating data. Adding a per-item bias term as well reduces error further, and that observation is what leads directly to the bias terms in matrix factorisation.

Similarity functions

Pearson correlation over co-rated items $I_{uv} = I_u \cap I_v$:

$$ s_{\text{pearson}}(u,v) = \frac{\sum_{i \in I_{uv}} (r_{ui} - \bar{r}u)(r{vi} - \bar{r}v)}{\sqrt{\sum{i \in I_{uv}} (r_{ui} - \bar{r}u)^2}\sqrt{\sum{i \in I_{uv}} (r_{vi} - \bar{r}_v)^2}} $$

Adjusted cosine for item-item, centring on the user mean rather than the item mean, since the bias being removed belongs to the rater:

$$ s_{\text{adj}}(i,j) = \frac{\sum_{u \in U_{ij}} (r_{ui} - \bar{r}u)(r{uj} - \bar{r}u)}{\sqrt{\sum{u \in U_{ij}} (r_{ui} - \bar{r}u)^2}\sqrt{\sum{u \in U_{ij}} (r_{uj} - \bar{r}_u)^2}} $$

Sarwar et al. (2001) found adjusted cosine outperformed plain cosine and Pearson for item-item on MovieLens, which is the reason it became the default.

Significance weighting (Herlocker et al., 1999) shrinks similarities estimated from few observations:

$$ s'(u,v) = \frac{\min(|I_{uv}|, \gamma)}{\gamma} \cdot s(u,v) $$

with $\gamma$ typically 25 to 50. Without it, pairs overlapping on two items produce $|s| = 1$ and dominate every neighbourhood. This single correction is worth more on sparse data than most model changes.

Complexity

OperationCostNotes
User-user similarity matrix$O(\lvert U \rvert^2 \bar{n}_u)$$\bar{n}_u$ = mean items per user
Item-item similarity matrix$O(\lvert I \rvert^2 \bar{n}_i)$precomputable offline
One prediction, precomputed $S$$O(k)$after a top-$k$ lookup
Storage for item-item, truncated$O(\lvert I \rvert k)$keep only top $k$ per item

Item-item wins in deployment because $\lvert I \rvert \ll \lvert U \rvert$ for most catalogues, and because item neighbourhoods are stable over hours while user neighbourhoods change on every interaction. Linden, Smith and York (2003) document exactly this reasoning at Amazon scale.

Sparsity, stated numerically

MovieLens 20M has density $\approx 0.53\%$. The Netflix Prize data is $\approx 1.2\%$. Large industrial catalogues run several orders of magnitude sparser. Under such sparsity, $|I_{uv}| = 0$ for the overwhelming majority of user pairs, so the similarity matrix is not only sparse — it is undefined almost everywhere. Every practical neighbourhood method therefore relies on shrinkage, on falling back to item-item, or on abandoning the neighbourhood entirely for a latent-factor model.

Neighbourhood methods are not obsolete

Two results are worth holding onto against the assumption that deep models superseded this family.

SLIM (Ning and Karypis, 2011) learns a sparse non-negative item-item coefficient matrix $W$ by solving a regularised least-squares problem with $\operatorname{diag}(W) = 0$, and beats hand-computed similarities substantially.

EASE$^R$ (Steck, 2019), Embarrassingly Shallow Autoencoders for Sparse Data, drops the non-negativity and sparsity constraints, which makes the problem admit a closed-form solution. With $X$ the binary user-item matrix and $G = X^\top X + \lambda \mathbf{I}$, $P = G^{-1}$:

$$ B = \mathbf{I} - P \cdot \operatorname{diagMat}!\left(\frac{1}{\operatorname{diag}(P)}\right), \qquad B_{jj} := 0 $$

Scores are $X B$. One matrix inversion, one hyperparameter, no gradient descent, and it remains competitive with deep models on standard top-$N$ benchmarks. When a new architecture reports a win, check whether EASE$^R$ was in the comparison table.

Papers

  • Resnick et al. (1994), GroupLens: An Open Architecture for Collaborative Filtering of Netnews, CSCW.
  • Herlocker et al. (1999), An Algorithmic Framework for Performing Collaborative Filtering, SIGIR — significance weighting and neighbourhood design.
  • Sarwar et al. (2001), Item-Based Collaborative Filtering Recommendation Algorithms, WWW.
  • Linden, Smith and York (2003), Amazon.com Recommendations: Item-to-Item Collaborative Filtering, IEEE Internet Computing.
  • Ning and Karypis (2011), SLIM: Sparse Linear Methods for Top-N Recommender Systems, ICDM.
  • Steck (2019), Embarrassingly Shallow Autoencoders for Sparse Data — arxiv.org/abs/1905.03375

What to learn next