Skip to main content

Collaborative Filtering

Collaborative filtering makes recommendations from interaction patterns alone. It never looks at what an item is โ€” only at who engaged with it. That is simultaneously its great strength and the source of its cold-start failure.

Key idea

"People similar to you liked this." Neighbourhood methods compute similarity directly; matrix factorisation learns a small set of latent factors per user and per item whose dot product reconstructs the observed interactions. Both use only the interaction matrix.

A collaborative filtering graph linking users to films they liked, beside a content-based approach matching item attributes
Collaborative filtering routes recommendations through other users' behaviour and never inspects the item. Content-based filtering does the opposite. Their failure modes are complementary, which is why production systems hybridise them.

Neighbourhood methodsโ€‹

User-based: find users similar to the target, recommend what they liked. Item-based: find items similar to those the user liked, where "similar" means co-liked by the same people.

r^ui=โˆ‘vโˆˆN(u)sim(u,v)โ€‰rviโˆ‘vโˆˆN(u)โˆฃsim(u,v)โˆฃ\hat{r}_{ui} = \frac{\sum_{v \in N(u)} \text{sim}(u,v)\, r_{vi}}{\sum_{v \in N(u)} |\text{sim}(u,v)|}
User-basedItem-based
Similarity overUsersItems
StabilityUsers change fastItem similarity is stable
PrecomputeHard โ€” users are many and volatileEasy โ€” cache the itemโ€“item matrix
Explanation"Users like youโ€ฆ""Because you watched X"

Item-based won in practice, and Amazon's item-to-item paper is the standard reference. Two reasons: there are usually fewer items than users, and itemโ€“item similarity changes slowly enough to precompute offline and serve from a cache.

Similarity measuresโ€‹

MeasureNotes
CosineStandard; ignores rating scale
PearsonCosine on mean-centred ratings โ€” corrects for users who rate everything highly
Adjusted cosineCentres by user mean, for itemโ€“item similarity
JaccardFor implicit binary data

Mean-centring matters more than it looks. Without it, a user who rates everything 4โ€“5 appears similar to everyone, and a harsh rater appears similar to nobody, purely as an artefact of scale.

Matrix factorisationโ€‹

Approximate the sparse matrix RR (mm users ร— nn items) as the product of two thin matrices:

Rโ‰ˆUVโŠค,UโˆˆRmร—k,ย VโˆˆRnร—kR \approx U V^\top, \qquad U \in \mathbb{R}^{m \times k},\ V \in \mathbb{R}^{n \times k}

Each user and each item becomes a kk-dimensional vector, and a predicted affinity is their dot product. With kโ‰ˆ50k \approx 50, a 106ร—10510^6 \times 10^5 matrix is represented by about 5.5ร—1075.5 \times 10^7 parameters instead of 101110^{11} cells.

The latent factors are learned, not designed, and are typically not interpretable โ€” though on film data some axes do turn out to correspond loosely to recognisable dimensions like "arthouse โ†” blockbuster".

The objectiveโ€‹

Crucially, the sum runs only over observed entries:

minโกU,Vโˆ‘(u,i)โˆˆK(ruiโˆ’ฮผโˆ’buโˆ’biโˆ’uuโŠคvi)2+ฮป(โˆฅuuโˆฅ2+โˆฅviโˆฅ2+bu2+bi2)\min_{U,V} \sum_{(u,i) \in \mathcal{K}} \left(r_{ui} - \mu - b_u - b_i - \mathbf{u}_u^\top \mathbf{v}_i\right)^2 + \lambda\left(\|\mathbf{u}_u\|^2 + \|\mathbf{v}_i\|^2 + b_u^2 + b_i^2\right)
The bias terms do most of the early work

ฮผ+bu+bi\mu + b_u + b_i โ€” the global mean, plus a per-user and a per-item offset โ€” captures "this user rates generously" and "this item is well liked overall". On the Netflix Prize data, biases alone accounted for a large share of the achievable improvement, before any interaction term.

Fit them. A factorisation without bias terms wastes latent dimensions re-learning what two scalars would have captured.

SVD is not what this is

Classical SVD requires a complete matrix and is undefined with missing entries. Filling the gaps with zeros or means before applying SVD both distorts the data and destroys the sparsity that made the problem tractable.

What the recommender literature calls "SVD" (Funk's method from the Netflix Prize) is a different algorithm: gradient descent on the observed entries only. The name is historical and misleading. See PCA and SVD for the actual decomposition.

Fitting itโ€‹

MethodNotes
SGDSimple, scales well, easy to extend with extra terms
ALSFix UU, solve for VV exactly, alternate. Parallelises well; the default for implicit feedback
BPROptimises ranking directly by contrasting observed against sampled unobserved items

BPR (Bayesian Personalised Ranking) deserves emphasis: it optimises the pairwise objective "the user prefers the item they interacted with over one they did not", which is much closer to the actual task than reconstructing a rating.

Implicit feedback needs a different objectiveโ€‹

For implicit data the matrix holds counts, not ratings, and there are no negatives. The standard treatment (Hu, Koren & Volinsky) splits each entry into a preference and a confidence:

pui={1rui>00rui=0cui=1+ฮฑruip_{ui} = \begin{cases} 1 & r_{ui} > 0 \\ 0 & r_{ui} = 0 \end{cases} \qquad c_{ui} = 1 + \alpha r_{ui}

Every cell now enters the loss โ€” including unobserved ones โ€” but weighted by confidence. Unobserved cells get weight 1 (a weak signal that the user might not like it); heavily interacted cells get large weights. This is what makes the objective well-posed without inventing negatives.

Where it failsโ€‹

  • Cold start. No interactions, no recommendation. Structural, not fixable within CF.
  • Popularity bias. Popular items appear in more co-occurrences and dominate.
  • Sparsity. Users with two interactions have essentially no signal.
  • Grey sheep. Users whose taste matches no cohort.
  • Shilling attacks. Fake accounts can be injected to manipulate item similarity.

The first is what content-based and hybrid methods exist to solve.

Code: matrix factorisation with biases, by SGDโ€‹

matrix_factorization.py
import numpy as np


class MatrixFactorization:
"""Funk-style MF with bias terms, trained by SGD on observed entries only."""

def __init__(self, n_factors=20, lr=0.01, reg=0.05, n_epochs=40, seed=0):
self.k, self.lr, self.reg, self.n_epochs = n_factors, lr, reg, n_epochs
self.rng = np.random.default_rng(seed)

def fit(self, rows, cols, vals, n_users, n_items, verbose=True):
self.mu = vals.mean()
self.bu = np.zeros(n_users)
self.bi = np.zeros(n_items)
self.U = self.rng.normal(0, 0.1, (n_users, self.k))
self.V = self.rng.normal(0, 0.1, (n_items, self.k))

order = np.arange(len(vals))
for epoch in range(self.n_epochs):
self.rng.shuffle(order)
total = 0.0
for idx in order:
u, i, r = rows[idx], cols[idx], vals[idx]
err = r - self._predict_one(u, i)
total += err * err

bu_old, bi_old = self.bu[u], self.bi[i]
self.bu[u] += self.lr * (err - self.reg * bu_old)
self.bi[i] += self.lr * (err - self.reg * bi_old)

U_old = self.U[u].copy()
self.U[u] += self.lr * (err * self.V[i] - self.reg * U_old)
self.V[i] += self.lr * (err * U_old - self.reg * self.V[i])

if verbose and epoch % 10 == 0:
print(f" epoch {epoch:>3}: train RMSE {np.sqrt(total / len(vals)):.4f}")
return self

def _predict_one(self, u, i):
return self.mu + self.bu[u] + self.bi[i] + self.U[u] @ self.V[i]

def predict(self, rows, cols):
return np.array([self._predict_one(u, i) for u, i in zip(rows, cols)])

def recommend(self, u, k=5, exclude=()):
scores = self.mu + self.bu[u] + self.bi + self.V @ self.U[u]
scores[list(exclude)] = -np.inf # never re-recommend seen items
return np.argsort(-scores)[:k]


if __name__ == "__main__":
rng = np.random.default_rng(0)
n_users, n_items, k_true = 300, 150, 4

# Synthesise data that genuinely has low-rank structure plus biases.
U_true = rng.normal(0, 0.6, (n_users, k_true))
V_true = rng.normal(0, 0.6, (n_items, k_true))
bu_true, bi_true = rng.normal(0, 0.3, n_users), rng.normal(0, 0.4, n_items)

n_obs = 12_000
rows = rng.integers(0, n_users, n_obs)
cols = rng.integers(0, n_items, n_obs)
vals = np.clip(3.5 + bu_true[rows] + bi_true[cols]
+ np.sum(U_true[rows] * V_true[cols], axis=1)
+ rng.normal(0, 0.3, n_obs), 1, 5)
print(f"density: {n_obs / (n_users * n_items) * 100:.1f} %")

split = int(0.85 * n_obs)
model = MatrixFactorization(n_factors=8, n_epochs=41).fit(
rows[:split], cols[:split], vals[:split], n_users, n_items)

pred = np.clip(model.predict(rows[split:], cols[split:]), 1, 5)
actual = vals[split:]
rmse = np.sqrt(np.mean((actual - pred) ** 2))
baseline = np.sqrt(np.mean((actual - vals[:split].mean()) ** 2))
print(f"\ntest RMSE {rmse:.4f}")
print(f"global-mean baseline {baseline:.4f}")
print(f"top-5 for user 0: {model.recommend(0, 5)}")

Comparing against the global-mean baseline is the equivalent of the naive forecast in time series: it is the number the model has to beat before its complexity is justified.

See alsoโ€‹