Skip to content

Recommender Systems from Scratch

What you’ll learn

  • why the empty cells of a user-item matrix are the target, not missing data
  • the popularity baseline, which is not a straw man: hit rate 0.1976 at 10
  • item-item collaborative filtering in six lines of numpy: 0.3481
  • BPR matrix factorisation by SGD, and why it lost here: 0.3196
  • the evaluation trap that doubles every published number: 100 sampled negatives
  • cold start, measured: with 4 interactions, popularity beats personalisation 0.25 to 0.17

Implicit feedback

The data is 1,200 users, 600 items, and a 1 wherever a user interacted with an item. Generated from latent factors plus an item popularity term, so the reason any cell is filled is known — which lets us separate “the model learned taste” from “the model learned what is popular”.

figure2.02% of the cells are filled, and the empty ones are the prediction targetmatplotlib
Left: a 150 by 150 corner of the user-item matrix, sparsely dotted with blue cells. Right: a table — 1,200 by 600, 14,514 interactions, 2.02% density, median 10 per user, 183 users with fewer than 5, 4 items with none, top 10% of items hold 40.3% of clicks, 1,017 evaluable users.Left: a 150 by 150 corner of the user-item matrix, sparsely dotted with blue cells. Right: a table — 1,200 by 600, 14,514 interactions, 2.02% density, median 10 per user, 183 users with fewer than 5, 4 items with none, top 10% of items hold 40.3% of clicks, 1,017 evaluable users.
Text was 99.17% zeros; this is 97.98% zeros with a crucial difference. In text a zero means 'this document does not contain that word' — a fact. Here a zero means 'this user has not interacted with that item yet', which is a mixture of dislike, ignorance and not-yet, and telling those apart is the entire problem.
QuantityValue
users × items1,200 × 600
interactions14,514
density2.02%
median interactions per user10
users with fewer than 5183
items with none at all4
top 10% of items hold40.3% of all interactions

The long tail, and why popularity is hard to beat

figureRecommending the most popular items is not a straw man: it is a real 0.1976 hit ratematplotlib
Left: interactions per item against rank on a log axis, falling from 325 for the most popular item to zero. Right: cumulative share of interactions against fraction of items, rising steeply above the diagonal, with the top 10% of items marked at 40.3%.Left: interactions per item against rank on a log axis, falling from 325 for the most popular item to zero. Right: cumulative share of interactions against fraction of items, rising steeply above the diagonal, with the top 10% of items marked at 40.3%.
The most popular item collected 325 interactions and four items collected none. The top 10% of the catalogue accounts for 40.3% of all activity, so a non-personalised list of the ten most popular items is right surprisingly often — and any personalised model has to beat that before it has earned anything.

Evaluation, before any model

Leave-one-out: for every user with at least 5 interactions, hide one at random. That gives 1,017 evaluable users. Then, for each of them, rank all 600 items — excluding the ones they already interacted with in training — and ask where the hidden item landed.

HitRate@k=1UuU1[rankuk],NDCG@k=1UuU1[rankuk]log2(ranku+1)\mathrm{HitRate@}k = \frac{1}{|U|}\sum_{u \in U} \mathbb{1}\big[\mathrm{rank}_u \le k\big], \qquad \mathrm{NDCG@}k = \frac{1}{|U|}\sum_{u \in U} \frac{\mathbb{1}\big[\mathrm{rank}_u \le k\big]}{\log_2(\mathrm{rank}_u + 1)}

With one held-out item per user, NDCG reduces to a rank-discounted hit rate: it rewards position 1 over position 9, which hit rate does not.

Four scorers

figureItem-item cosine, in six lines of numpy, beats matrix factorisation at this data sizematplotlib
Left: hit rate at 10 — random 0.0098, popularity 0.1976, item-item cosine 0.3481, BPR matrix factorisation 0.3196. Right: median rank of the held-out item — random 304, popularity 82, item-item 23, BPR 26.Left: hit rate at 10 — random 0.0098, popularity 0.1976, item-item cosine 0.3481, BPR matrix factorisation 0.3196. Right: median rank of the held-out item — random 304, popularity 82, item-item 23, BPR 26.
Random ranking puts the held-out item at median rank 304 of 600, as it should. Popularity gets it to 82 without knowing anything about the user. Item-item cosine reaches 23, and 30 epochs of BPR reach 26. The gap between random and popularity is bigger than the gap between popularity and the best model.
ScorerHit@10NDCG@10Median rank
random0.00980.0051304
popularity0.19760.097582
item-item cosine0.34810.202423
BPR matrix factorisation0.31960.177626

Item-item collaborative filtering

The whole method, once the matrix is in memory:

item_item.py
norms = np.linalg.norm(train, axis=0)          # column norms = item popularity
norms[norms == 0] = 1.0                        # unseen items: avoid dividing by 0
unit = train / norms                           # L2-normalise each item column
similarity = unit.T @ unit                     # cosine between every item pair
np.fill_diagonal(similarity, 0.0)              # an item is not its own neighbour
 
scores = train[user] @ similarity               # sum of similarities to what you took
item_item.py
norms = np.linalg.norm(train, axis=0)          # column norms = item popularity
norms[norms == 0] = 1.0                        # unseen items: avoid dividing by 0
unit = train / norms                           # L2-normalise each item column
similarity = unit.T @ unit                     # cosine between every item pair
np.fill_diagonal(similarity, 0.0)              # an item is not its own neighbour
 
scores = train[user] @ similarity               # sum of similarities to what you took

The last line is the recommendation: an item scores highly if it is similar to the items this user already interacted with. No optimisation, no hyperparameters, no training loop. On this data it is the best of the four.

Two properties are worth knowing. The cosine normalisation is what stops popular items dominating — without it, train[user] @ train.T @ traintrain[user] @ train.T @ train is essentially a popularity ranking. And the similarity matrix is 600×600600 \times 600 here but I2|I|^2 in general, so at a million items you need approximate neighbours (NearestNeighborsNearestNeighbors with a tree, FAISS, ScaNN) instead of a dense product.

BPR matrix factorisation

Matrix factorisation learns a vector per user and per item such that the dot product ranks correctly. The BPR objective takes a seen item ii and an unseen item jj and pushes them apart:

max(u,i,j)logσ(puqipuqj)λ(pu2+qi2+qj2)\max \sum_{(u,i,j)} \log \sigma\big(\mathbf{p}_u^\top \mathbf{q}_i - \mathbf{p}_u^\top \mathbf{q}_j\big) - \lambda\big(\lVert\mathbf{p}_u\rVert^2 + \lVert\mathbf{q}_i\rVert^2 + \lVert\mathbf{q}_j\rVert^2\big)
bpr.py
def bpr(train, k=16, epochs=30, lr=0.05, reg=0.01, seed=1):
    """Rank a seen item above a randomly drawn unseen one, by SGD."""
    rng = np.random.default_rng(seed)
    n_users, n_items = train.shape
    P = rng.normal(0, 0.1, (n_users, k))
    Q = rng.normal(0, 0.1, (n_items, k))
    pairs = np.argwhere(train == 1)
 
    for _ in range(epochs):
        rng.shuffle(pairs)
        for u, i in pairs:
            j = int(rng.integers(0, n_items))          # sample a negative
            while train[u, j] == 1:
                j = int(rng.integers(0, n_items))
            gradient = 1.0 / (1.0 + np.exp(P[u] @ (Q[i] - Q[j])))
            pu, qi, qj = P[u].copy(), Q[i].copy(), Q[j].copy()
            P[u] += lr * (gradient * (qi - qj) - reg * pu)
            Q[i] += lr * (gradient * pu - reg * qi)
            Q[j] += lr * (-gradient * pu - reg * qj)
    return P, Q
bpr.py
def bpr(train, k=16, epochs=30, lr=0.05, reg=0.01, seed=1):
    """Rank a seen item above a randomly drawn unseen one, by SGD."""
    rng = np.random.default_rng(seed)
    n_users, n_items = train.shape
    P = rng.normal(0, 0.1, (n_users, k))
    Q = rng.normal(0, 0.1, (n_items, k))
    pairs = np.argwhere(train == 1)
 
    for _ in range(epochs):
        rng.shuffle(pairs)
        for u, i in pairs:
            j = int(rng.integers(0, n_items))          # sample a negative
            while train[u, j] == 1:
                j = int(rng.integers(0, n_items))
            gradient = 1.0 / (1.0 + np.exp(P[u] @ (Q[i] - Q[j])))
            pu, qi, qj = P[u].copy(), Q[i].copy(), Q[j].copy()
            P[u] += lr * (gradient * (qi - qj) - reg * pu)
            Q[i] += lr * (gradient * pu - reg * qi)
            Q[j] += lr * (-gradient * pu - reg * qj)
    return P, Q

It reached 0.3196 after 30 epochs over 13,497 positive pairs — and 0.2281 after 10 epochs, which is worth knowing before you conclude that factorisation does not work. It is an iterative method and it needs its iterations.

Why it still loses to a six-line neighbourhood method here: 13,497 interactions is not much data for 1,800 factor vectors. At 16 factors, BPR is fitting 1,200 × 16 + 600 × 16 = 28,800 parameters from 13,497 observations. Item-item cosine fits nothing. The ordering reverses on real catalogues with millions of interactions, where factorisation generalises across the sparsity in a way neighbourhoods cannot — but “matrix factorisation is better” is a statement about data volume, not about algorithms.

The evaluation trap

Ranking the held-out item against all items is expensive, so a very common shortcut is to rank it against a sample of 100 unseen items. Here is what that does:

figureThe same models, two evaluation protocolsmatplotlib
Grouped bars of hit rate at 10 under two protocols. Popularity 0.1976 full against 0.4238 sampled (2.14x), item-item 0.3481 against 0.6627 (1.90x), BPR 0.3196 against 0.6441 (2.01x).Grouped bars of hit rate at 10 under two protocols. Popularity 0.1976 full against 0.4238 sampled (2.14x), item-item 0.3481 against 0.6627 (1.90x), BPR 0.3196 against 0.6441 (2.01x).
Ranking against 100 sampled negatives instead of all 600 items roughly doubles every hit rate — and does not do so uniformly, since the inflation factor ranges from 1.90 to 2.14. Both protocols are internally consistent; the numbers are simply not comparable, and a 'hit rate at 10 of 0.66' means nothing without the protocol attached.
ScorerFull ranking (600 items)100 sampled negativesInflation
popularity0.19760.42382.14×
item-item cosine0.34810.66271.90×
BPR matrix factorisation0.31960.64412.01×

The arithmetic is straightforward — being in the top 10 of 101 candidates is far easier than the top 10 of 600 — but two consequences are not:

  • Cross-paper comparison breaks. A published 0.66 and your 0.35 may be the same model.
  • The ranking can change. The inflation is not a constant factor: it depends on how a model distributes scores over the tail. Krichene and Rendle showed in 2020 that sampled metrics can reorder methods, which is why full ranking (or a documented, fixed candidate set) is now expected.

If you must sample for speed, sample the same negatives for every model and say so in the report.

Cold start, measured

figurePersonalisation needs history; popularity does notmatplotlib
Grouped bars of hit rate at 10 by user activity bucket. With 4 interactions: popularity 0.25, item-item 0.17, BPR 0.19. With 5-7: 0.24, 0.31, 0.31. With 8-10: 0.21, 0.33, 0.32. With 11-15: 0.21, 0.41, 0.37. With 16-61: 0.13, 0.40, 0.33.Grouped bars of hit rate at 10 by user activity bucket. With 4 interactions: popularity 0.25, item-item 0.17, BPR 0.19. With 5-7: 0.24, 0.31, 0.31. With 8-10: 0.21, 0.33, 0.32. With 11-15: 0.21, 0.41, 0.37. With 16-61: 0.13, 0.40, 0.33.
For users with exactly four interactions in the training matrix, popularity (0.25) beats both personalised models (0.17 and 0.19). From five interactions onwards personalisation wins, and by 16 or more it wins three to one. Popularity's own performance falls as users get more active, because heavy users have already seen the popular items.
Interactions in trainingnpopularityitem-itemBPR
4810.250.170.19
5–72520.240.310.31
8–101990.210.330.32
11–152030.210.410.37
16–612820.130.400.33

Both halves of this table are load-bearing.

Personalisation loses on cold users. With four interactions there is not enough signal, and the best available guess is what everyone else likes. This is why production systems are almost always a blend: popularity (or editorial curation) for new users, collaborative filtering once history accumulates, usually with a smooth interpolation rather than a switch.

Popularity gets worse as users get more active — 0.25 down to 0.13 — because the recommendation excludes items the user already has, and heavy users have consumed most of the head. An aggregate hit rate hides both effects. Always segment by activity.

Note also the 183 users with fewer than five interactions who were excluded from evaluation entirely. They are 15% of the user base, they are the hardest cases, and every leave-one-out benchmark quietly drops them. Their existence is a product problem, not a modelling one.

What to serve whom

The measured table above is a routing policy waiting to be written down:

diagram Diagram mermaid

Two of those boxes exist only because of measurements on this page: the 1-to-4 bucket routes to popularity because personalisation loses there, and the reporting box exists because the same three models scored between 1.90× and 2.14× higher under sampled evaluation. Neither is a design preference; both are what the numbers said.

See it move

Item-item cosine is one formula, and the effect of normalisation is easiest to feel by toggling it. Below, five users have interacted with six items; the similarity between two items is the cosine of their columns.

sketch Item-item similarity, with and without normalisation p5.js
A five-by-six interaction matrix. Clicking a cell toggles it and the item-item cosine similarity matrix updates. A toggle switches between cosine similarity and raw co-occurrence counts, showing how the popular item dominates without normalisation.

Fill an entire column — make one item universally popular — and watch what happens. Under raw co-occurrence that item becomes the most similar item to everything, so it is recommended to everybody. Under cosine it does not, because the normalisation divides by its own norm. That single division is the difference between a recommender and a bestseller list.

Pitfalls

PitfallWhy it bitesWhat to do
Treating zeros as negatives97.98% of cells are zero; the model learns to say noBPR, negative sampling or weighted ALS
Skipping the popularity baselineIt scores 0.1976 at 10 here, unpersonalisedAlways report it alongside your model
Sampled negatives in the metricInflated 1.90× to 2.14×, non-uniformlyRank against everything, or fix and document the candidate set
Recommending items the user already hasTrivially “correct” and uselessMask known items before ranking
One aggregate hit ratePopularity wins at 4 interactions and loses at 16Segment by user activity
Ignoring the excluded users183 users (15%) had too little history to evaluateReport the coverage, and design for them explicitly
Un-normalised similarityPopular items become everyone’s nearest neighbourCosine, or shrinkage on the counts
Concluding MF beats neighbourhoods (or vice versa)0.3196 against 0.3481 here at 13.5k interactionsMeasure both at your data volume

Recap

  • The matrix is 1,200 × 600 and 2.02% filled; the zeros are the prediction target, not missing data.
  • The top 10% of items hold 40.3% of interactions, which is why popularity reaches 0.1976 at 10 with no personalisation.
  • Item-item cosine, six lines of numpy, reached 0.3481 and median rank 23 of 600.
  • BPR matrix factorisation reached 0.3196 after 30 epochs and 0.2281 after 10 — it lost here because 13,497 interactions is thin for 28,800 parameters.
  • Ranking against 100 sampled negatives inflated hit rate by 1.90× to 2.14×, non-uniformly.
  • With four interactions, popularity beat personalisation 0.25 to 0.17; with 16 or more it lost 0.13 to 0.40.
quizCheck yourself
  1. Your user-item matrix is 97.98% zeros. How should the zeros be treated?

    Show answer

    B — As unlabelled: a zero mixes dislike, ignorance and not-yet, so use an implicit-feedback objective, negative sampling or weighted ALS — Training on all 691,486 zeros as negatives teaches the model to predict 'no', which is right 97.98% of the time and worthless. BPR sidesteps it by only ever comparing a seen item against an unseen one.

  2. Your new model reaches hit rate at 10 of 0.30. Is it good?

    Show answer

    B — Unknown until you compare against popularity (0.1976 here) and state the evaluation protocol — the same model scores 0.64 against 100 sampled negatives — Two references are missing: the unpersonalised baseline and the candidate set. Both change the interpretation by a factor of two here, and neither is visible in the number itself.

  3. Item-item cosine (0.3481) beat BPR matrix factorisation (0.3196) on this data. What does that establish?

    Show answer

    B — At 13,497 interactions, fitting 28,800 factor parameters is data-starved — the ordering is about data volume, and would likely reverse on a large catalogue — BPR also improved from 0.2281 at 10 epochs to 0.3196 at 30, so it was still learning. The transferable lesson is procedural: implement the cheap neighbourhood baseline first, because it may win at your scale.

  4. Why does the popularity baseline get WORSE for more active users, from 0.25 down to 0.13?

    Show answer

    B — Known items are excluded from the ranking, and active users have already consumed most of the popular head — Masking known items is correct — recommending something already purchased is worthless — and it systematically strips popularity of its best candidates for heavy users. It is also why an aggregate hit rate hides the cold-start problem entirely.

  5. A paper reports hit rate at 10 of 0.66 using 100 sampled negatives. Your full-ranking evaluation gives 0.35. What can you conclude?

    Show answer

    B — Nothing about relative quality — the protocols differ, and here the same models moved by 1.90x to 2.14x under exactly that change — The inflation is real, large and model-dependent, which means it can reorder methods rather than shifting them all equally. Reproduce their protocol or rank against the full catalogue; do not compare across protocols.

🧪 Try It Yourself

Exercise 1 – Build the interaction matrix

Exercise 2 – Leave one out, then try popularity

Exercise 3 – Item-item cosine in six lines

Exercise 4 – BPR by hand

Exercise 5 – Reproduce the sampled-negatives inflation

Next

Anomaly and Outlier Detection — the last applied problem, and the only one where you usually have no labels at all, which changes what “evaluation” can even mean.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did