ml-models / Retrieval and recommendation / Collaborative filtering core

Collaborative filtering

Recommend from behaviour alone: people who agreed with you before will probably agree again. This page builds the user–item matrix, predicts missing ratings from similar users, then factorizes the matrix into user and item vectors trained only on the ratings that exist. It works through a five-user example by hand, explains implicit feedback and cold start, and shows why scikit-learn's NMF, which treats missing entries as zeros, is the wrong tool for ratings.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Neighbourhoods
  5. Matrix factorization and its loss
  6. Implicit feedback
  7. Cold start
  8. Non-negative matrix factorization
  9. Worked example
  10. User similarities
  11. Five ways to fill the two cells
  12. ALS, sweep by sweep
  13. Implicit confidence and scale
  14. Implementation
  15. Tradeoffs
  16. Connections
  17. Read first
  18. Read next
  19. References

Problem

A shop, a music app or a film site has millions of users and items and a log of who interacted with what: star ratings, plays, purchases, clicks. Arrange it as a matrix R with one row per user and one column per item. Nearly every cell is empty, because each user has touched a tiny fraction of the catalogue. The job is to fill in the empty cells well enough to rank, for each user, the items they haven't seen.

We often know nothing about the items' content, or don't trust it, and we have no labelled features about users. Collaborative filtering (CF) uses only the matrix: if two users rated the same items the same way, each is a good guide to what the other will like. The example used throughout this page is 5 users and 5 films, 20 of 25 cells known, plus two ratings we hide and try to predict:

UserHeistCaperStingDriftHauntMean
Ana 5 4 · 1 1 2.75
Ben 4 ? (5) 4 1 2 2.75
Cai 1 1 2 5 · 2.25
Dee ? (1) 2 1 4 5 3.00
Eli 5 5 4 · 1 3.75

The two cells marked ? are held out: their true ratings (in brackets) are hidden from every method below. Two tastes are visible by eye: Ana, Ben and Eli like the first three films, Cai and Dee like the last two.

Intuition

There are two families of CF, and they make the same bet in different ways:

  • Neighbourhood methods keep the matrix as it is. To predict Ben's rating of Caper, find the users most similar to Ben (by how they rated the films both have seen) and average what they gave Caper. Item-based neighbourhoods do the same over columns: films that were rated alike are recommended alike.
  • Latent factor models (matrix factorization) assume the matrix is close to low rank: a few hidden dimensions, such as "likes heists" and "likes horror", explain most of the ratings. Each user gets a short vector pu, each item a vector qi, and the predicted rating is their dot product. These vectors are embeddings learned from nothing but the matrix.

The crucial detail in both is that empty cells are unknown, not zero. A user who hasn't rated a film hasn't said they hate it. Fitting the factors only on observed cells is what lets the model predict the rest.

The rating matrix approximated by user factors times item factors On the left the five-by-five rating matrix of the example, with empty cells drawn dashed. An approximately-equal sign leads to a five-by-two matrix P of user vectors, one row per user, multiplied by a two-by-five matrix Q transpose of item vectors, one column per film. The loss is summed only over the 20 filled cells; the factors have 20 numbers in total. R: 5 users × 5 items Ana 5 4 ? 1 1 Ben 4 ? 4 1 2 Cai 1 1 2 5 ? Dee ? 2 1 4 5 Eli 5 5 4 ? 1 ≈ P: 5 × 2 2.6 0.9 2.5 0.5 1.3 -2.2 1.3 -1.7 2.8 1.1 × Qᵀ: 2 × 5 1.6 0.5 Heist 1.6 0.3 Caper 1.4 0.1 Sting 0.9 -1.7 Drift 1.1 -2.1 Haunt loss summed over the 20 filled cells only · 20 factor numbers r̂(u, i) = p_u · q_i

Mechanics

Neighbourhoods

Users rate on different scales: one person's 3 is another's 4. So centre each user's ratings on their own mean r̄u first, then measure similarity with the cosine of the centred vectors over the items both rated (this is Pearson correlation restricted to co-rated items):

s(u, v) = Σi ∈ Iuv (rui − r̄u)(rvi − r̄v) / ( ‖ru − r̄u‖ · ‖rv − r̄v‖ )

The prediction starts from the user's own mean and adds the similarity-weighted deviations of the k most similar users who rated the item:

r̂ui = r̄u + Σv ∈ Nk(u, i) s(u, v)(rvi − r̄v) / Σv |s(u, v)|

This needs no training, explains itself ("because Ana and Eli liked it"), and updates the moment a new rating arrives. It costs a similarity per pair of users, which is why large systems use item-item neighbourhoods (items are fewer and their similarities change slowly) and precompute them.

Matrix factorization and its loss

Give each user a vector pu ∈ ℝk and each item qi ∈ ℝk. With Ω the set of observed (user, item) pairs, minimize

L = Σ(u,i) ∈ Ω (rui − μ − bu − bi − pu · qi)² + λ (‖pu‖² + ‖qi‖² + bu² + bi²)

μ is the global mean, bu and bi are a user's and an item's offsets from it (a generous rater, a beloved film), and the dot product carries the interaction: how this user feels about this item beyond those offsets. The L2 term λ is not optional. There are (users + items) · k parameters, often as many as there are ratings, so without it the factors simply memorize Ω.

Two ways to minimize it:

  • SGD. For each observed rating, compute the error e = r − r̂ and step pu += η(e qi − λpu), qi += η(e pu − λqi). Simple, and what a PyTorch model with an embedding table does.
  • Alternating least squares (ALS). The loss is not convex in (P, Q) together, but with Q fixed it is an independent ridge regression for every user: pu = (QuTQu + λI)−1 QuT ru, where Qu holds only the items user u rated. Then fix P and solve every item the same way. Each half-step can only lower L, the solves are embarrassingly parallel, and there is no learning rate.

Implicit feedback

Most real signals are not ratings but events: plays, views, purchases. There are no negatives, only "did" and "didn't (yet)". Hu, Koren and Volinsky turn each count rui into a binary preference pui = [rui > 0] and a confidence cui = 1 + α rui, then fit over all cells:

L = Σu, i cui (pui − xu · yi)² + λ (Σ‖xu‖² + Σ‖yi‖²)

Here zeros are in the loss, deliberately, but with confidence 1, while anything the user actually did weighs 1 + α times its count. Summing over every cell looks impossible at scale; the trick is that YTCuY = YTY + YT(Cu − I)Y, where YTY is shared by all users and Cu − I is non-zero only on the items user u touched. Each ALS solve still costs only that user's interactions.

Cold start

A new user or a new item has no row or column to learn from, so neither family can say anything personal about it. The usual fallbacks: show new users popular or highly rated items (μ + bi), ask a few onboarding questions, or switch to content features (genre, text, image embeddings) that exist from day one. Hybrid models, such as two-tower models with ID and content features in each tower, blend the two as interactions accumulate.

Non-negative matrix factorization

NMF factors a non-negative matrix X ≈ WH with W, H ≥ 0, which makes the factors additive parts (topics, tastes) that are easy to read. scikit-learn's NMF minimizes 0.5‖X − WH‖²Fro plus L1/L2 penalties scaled by the matrix's dimensions. Its multiplicative update for the Frobenius loss is H ← H ⊙ (WTX) / (WTWH), and the mirror image for W. Each factor stays non-negative because it is only ever multiplied by a non-negative ratio.

The catch for recommendation: X is a whole matrix, sparse or dense, and every cell is in the loss. An unrated film is a stored or implicit zero, so NMF treats "hasn't seen it" as "rated it 0" and drags those predictions toward zero. That is fine for counts where absence really means zero, such as word counts in documents, and wrong for explicit ratings.

ComponentShape/ParamsMeaningSource
NMF X [n_samples, n_features] → W [n_samples, k], H [k, n_features] W is the transformed data (users), H the components (items). Default solver="cd", tol=1e-4, max_iter=200. class NMF
objective alpha_W, alpha_H="same", l1_ratio 0.5‖X − WH‖² + L1 and L2 terms on W and H; summed over every cell, missing ones included. docstring, _compute_regularization
multiplicative update solver="mu", any beta_loss W ← W ⊙ XHT / (WHHT + penalties); H likewise. Non-negativity is kept for free. _multiplicative_update_w, _h
init None → "nndsvda" when k ≤ min(n, m), else "random" Starting factors from an SVD (zeros filled with the mean) or scaled random values. _initialize_nmf
nn.Embedding(n, k, sparse=True) weight [n, k], init N(0, 1) One learnable row per user or item; a lookup by ID. sparse=True makes the gradient touch only the rows in the batch. Embedding, reset_parameters
sparse-gradient optimizers SGD, SparseAdam, Adagrad (CPU) Only these accept the sparse gradient. SparseAdam updates moments for the touched rows only. note, SparseAdam
nn.EmbeddingBag mode="mean" by default Pools a variable-length bag of item rows into one vector: a user represented by their history. EmbeddingBag
cosine_similarity X [n, d], Y [m, d] → [n, m] Item-item or user-user similarity for neighbourhoods; centre the rows first for the Pearson-style version. pairwise.py

Worked example

User similarities

Centred cosine between every pair of users, over the films both rated:

AnaBenCaiDeeEli
Ana+1.00+0.94-0.90-0.96+0.90
Ben+0.94+1.00-0.89-0.84+0.68
Cai-0.90-0.89+1.00+0.61-1.00
Dee-0.96-0.84+0.61+1.00-0.80
Eli+0.90+0.68-1.00-0.80+1.00

The two taste groups fall out: similarities are positive within Ana, Ben, Eli and within Cai, Dee, and negative across. Cai and Eli come out at exactly -1.00: their co-rated films' centred ratings are mirror images.

Predicting Ben's rating of Caper from two neighbours Ben is in the centre. Of the users who rated Caper, the two most similar are Ana (similarity 0.94) and Eli (similarity 0.68); both gave Caper 1.25 points more than their own average. Ben's mean of 2.75 plus 1.25 gives the prediction 4.00, against a true rating of 5. Users with negative similarity are drawn dashed and ignored. Ben · mean 2.75 r̂ = 4.00 (true 5) Ana · mean 2.75 Caper: 4 (+1.25) s = 0.94 Eli · mean 3.75 Caper: 5 (+1.25) s = 0.68 Cai · s = -0.89 Dee · s = -0.84 k = 2 · only positive similarities count

Five ways to fill the two cells

The global mean is μ = 2.90. The bias baseline adds Ben's offset -0.15 and Caper's +0.10. The neighbourhood prediction uses k = 2 as in the diagram; for Dee only Cai is both similar and has rated Heist. Matrix factorization is rank 2 by ALS with λ = 0.1 on the 20 observed cells. NMF is rank 2, 300 multiplicative updates, on the matrix with blanks filled by 0.

MethodBen × Caper (true 5)Dee × Heist (true 1)Held-out RMSE
Global mean μ2.902.902.00
μ + bu + bi2.853.852.52
User neighbourhood, k = 24.001.750.88
MF by ALS (observed only)4.011.160.71
NMF (blanks = 0)2.570.381.77

The bias baseline is worse than the global mean here: Dee rates generously and Heist is popular, so biases alone predict a high rating for a user who dislikes heist films. Offsets can't see interaction; that is what the dot product is for. Both CF methods find the taste groups. NMF finds them too but has been told that Ben rated Caper 0, so its prediction of 2.57 is dragged far below the truth; its fit on the 20 real ratings (RMSE 0.87) is also worse than ALS's, because it spends capacity reproducing the fake zeros.

ALS, sweep by sweep

SweepObjective (SSE + λ‖·‖²)RMSE on observed
160.2601.638
234.8631.206
311.2260.476
48.4990.304
58.2040.296
206.7900.295

The objective falls at every sweep, because each half-step solves its ridge problems exactly. The RMSE on observed ratings levels off near 0.29 while the objective keeps creeping down: the later sweeps shrink the vectors' norms without changing the fit. The model has 20 factor numbers for 20 ratings, so with λ = 0 it could fit them almost perfectly and generalize badly.

One concrete solve: Ana rated 4 films. With the item vectors fixed, Ana's new vector is the 2 × 2 ridge solution over those four rows of Q, (2.60, 0.86).

Implicit confidence and scale

Plays rPreference pConfidence 1 + 40r
001
1141
31121
101401

With α = 40 (the value Hu, Koren and Volinsky found worked), a film played once counts 41 times as much as an unplayed one. At production scale, 1,000,000 users and 100,000 items give 100,000,000,000 cells. Rank-64 factors are 70,400,000 numbers, about 282 MB in float32: the model is 1,420 times smaller than the matrix it predicts. For the cold-start user, the popularity order by μ + bi is Heist, Caper, Sting, Drift, Haunt.

Implementation

Matrix factorization with biases in PyTorch: two nn.Embedding tables trained on (user, item, rating) triples, so only observed cells are ever in the loss. With sparse=True each step's gradient touches only the batch's rows, which needs one of the optimizers the docstring lists, such as SparseAdam:

import torch
from torch import nn

class MF(nn.Module):
    def __init__(self, n_users, n_items, k=64):
        super().__init__()
        self.p = nn.Embedding(n_users, k, sparse=True)
        self.q = nn.Embedding(n_items, k, sparse=True)
        self.bu = nn.Embedding(n_users, 1, sparse=True)
        self.bi = nn.Embedding(n_items, 1, sparse=True)
        for e in (self.p, self.q):
            nn.init.normal_(e.weight, std=0.01)   # default N(0, 1) is far too large
        for e in (self.bu, self.bi):
            nn.init.zeros_(e.weight)
        self.mu = nn.Parameter(torch.zeros(()))

    def forward(self, u, i):
        return (self.mu + self.bu(u).squeeze(-1) + self.bi(i).squeeze(-1)
                + (self.p(u) * self.q(i)).sum(-1))

model = MF(n_users, n_items)
model.mu.data.fill_(ratings.mean())
sparse = [model.p.weight, model.q.weight, model.bu.weight, model.bi.weight]
opt_sparse = torch.optim.SparseAdam(sparse, lr=1e-2)
opt_dense = torch.optim.Adam([model.mu], lr=1e-2)
lam = 1e-4

for u, i, r in loader:                     # observed triples only
    pred = model(u, i)
    reg = lam * (model.p(u).pow(2).sum() + model.q(i).pow(2).sum())
    loss = (pred - r).pow(2).mean() + reg / len(r)
    opt_sparse.zero_grad(); opt_dense.zero_grad()
    loss.backward()
    opt_sparse.step(); opt_dense.step()

The L2 term is applied to the rows in the batch, not via weight_decay, which would shrink every row on every step (and SparseAdam doesn't support it). For implicit data, use an ALS library built on the confidence trick, or train with sampled negatives as in two-tower models.

If you reach for scikit-learn, remember what NMF fits: a scipy.sparse rating matrix is accepted, but its missing entries are zeros in the loss. That suits counts (topics in a document-term matrix) and not explicit ratings.

from sklearn.decomposition import NMF

nmf = NMF(n_components=2, init="nndsvda", solver="mu", max_iter=300)
W = nmf.fit_transform(play_counts)   # [users, k]: zeros here mean "zero plays", which is true
H = nmf.components_                  # [k, items]

Tradeoffs

  • Neighbourhoods vs factors. Neighbourhoods are explainable, training-free and instantly fresh, but noisy when users share few items, and quadratic in users or items. Factors generalize across the sparsity (two users need not share a single item to end up close in factor space) and serve as a fast dot product, but must be retrained or folded in for new data.
  • ALS vs SGD. ALS has no learning rate, parallelizes per row and handles the all-cells implicit loss exactly. SGD is simpler, fits any differentiable model (side features, deep towers) and streams over huge logs.
  • Rank k and λ. Larger k fits more taste dimensions and overfits faster; λ trades the two and must be tuned on held-out ratings, split by time if you can, since the future is what you'll predict.
  • RMSE vs ranking. Predicting stars well is not the same as ranking the top 10 well. Many systems train on implicit data and evaluate with ranking metrics, and add a learning-to-rank stage on top.
  • Popularity and feedback loops. CF recommends what similar people consumed, which over time amplifies what is already popular and starves new items; cold-start content features and some exploration counter it.
  • NMF. Non-negative parts are interpretable, but the default scikit-learn implementation's zeros-are-observed loss makes it a poor fit for explicit ratings.
Why centre each user's ratings before computing similarity?

A harsh rater's 3 can mean what a generous rater's 4 means. Centring compares how each user deviates from their own habit, not their raw scale.

Why can each ALS half-step be solved in closed form?

With the item vectors fixed, the loss for one user is a sum of squared errors linear in that user's vector plus an L2 term: a ridge regression on the items they rated.

In the example, why does NMF predict 2.57 for a rating whose truth is 5?

The blank cell was filled with 0 and is in NMF's loss, so the factorization is pulled toward reproducing that 0.

How does the implicit-feedback loss avoid touching every cell for every user?

YᵀCuY = YᵀY + Yᵀ(Cu − I)Y: YᵀY is computed once per sweep, and the correction only involves the items the user interacted with.

Connections

Read first

Read next

References

  • Resnick, Iacovou, Suchak, Bergstrom and Riedl. GroupLens: An Open Architecture for Collaborative Filtering of Netnews. CSCW, 1994.
  • Sarwar, Karypis, Konstan and Riedl. Item-Based Collaborative Filtering Recommendation Algorithms. WWW, 2001.
  • Koren, Bell and Volinsky. Matrix Factorization Techniques for Recommender Systems. IEEE Computer, 2009.
  • Hu, Koren and Volinsky. Collaborative Filtering for Implicit Feedback Datasets. ICDM, 2008.
  • Lee and Seung. Algorithms for Non-negative Matrix Factorization. NeurIPS, 2000.
  • scikit-learn 1.9.1: NMF, multiplicative updates, cosine_similarity.
  • PyTorch v2.15.0: nn.Embedding, nn.EmbeddingBag, SparseAdam.