Recommender systems predict which items a user will find relevant — products, videos, songs, articles, jobs — from past interactions and from user and item attributes. They are among the highest-impact production ML systems, and they look different from textbook classification: the data is a huge, extremely sparse user-item matrix, feedback is mostly implicit (clicks, views, purchases), and the output is a ranked list, not a label.
This page covers content-based and collaborative filtering, matrix factorization, two-tower neural retrieval, the multi-stage serving architecture, ranking metrics, and the pitfalls (cold start, popularity bias, feedback loops).
Content-based filtering recommends items similar to those the user liked, using item features (text, category, embeddings). It handles new items well and is explainable, but tends to recommend more of the same.
Collaborative filtering (CF) uses only the interaction matrix: "users who liked what you liked also liked X." Memory-based CF computes user-user or item-item similarities (cosine, Jaccard) directly; item-item CF powers many "customers also bought" widgets because item similarities are stable and cacheable. Model-based CF learns latent factors. CF captures taste patterns that features miss but cannot handle items or users with no interactions (cold start).
Hybrid systems combine both, which in modern practice means feeding IDs and content features into one neural model.
Matrix factorization represents each user u and item i as a k-dimensional vector so that their dot product approximates the observed interaction. With biases, r̂ui = μ + bu + bi + uu·vi. It is fit by SGD or Alternating Least Squares (fix V, solve for U in closed form, swap — parallelizes well, the approach in Spark MLlib).
For implicit feedback there are no negative labels: an unclicked item may be disliked or merely unseen. Implicit ALS (Hu, Koren & Volinsky) treats every cell as a 0/1 preference weighted by a confidence cui = 1 + α·count; BPR (Bayesian Personalized Ranking) instead optimizes that observed items rank above sampled unobserved ones.
The two-tower (dual encoder) model generalizes MF: each tower is a network that can consume IDs plus arbitrary features, so new items get sensible embeddings from their content. Because scoring is a dot product, item embeddings can be indexed with approximate nearest neighbor search (FAISS, ScaNN, pgvector) and retrieval over millions of items takes milliseconds.
Other neural families: sequential recommenders (GRU4Rec, SASRec, BERT4Rec) treat a session as a token sequence and predict the next item with self-attention; wide-and-deep / DCN / DLRM ranking models learn feature crosses over large sparse categorical inputs; graph neural networks (PinSage, LightGCN) propagate signal over the user-item bipartite graph.
No single model can score every item for every request with rich features, so production systems are funnels. Candidate generation pulls a few hundred to a few thousand items from several sources (two-tower ANN, item-item co-occurrence, trending, recently viewed). A heavier ranking model — often gradient boosting or a deep network — scores those candidates with user, item, context and cross features, predicting click, watch time or purchase probability. A re-ranking layer applies diversity (e.g. MMR), freshness, fairness, and business rules.
Contextual bandits or reinforcement learning are used where exploration and long-term engagement matter more than immediate click probability.
import numpy as np, torch, torch.nn as nn
# Toy implicit data: (user, item) interactions
rng = np.random.default_rng(0)
n_users, n_items, k = 500, 300, 32
true_u, true_v = rng.normal(size=(n_users, 8)), rng.normal(size=(n_items, 8))
scores = true_u @ true_v.T
pairs = np.argwhere(scores > np.quantile(scores, 0.95)) # top 5% are "interactions"
rng.shuffle(pairs)
test_pairs, train_pairs = pairs[:1000], pairs[1000:]
seen = {(u, i) for u, i in train_pairs}
class MF(nn.Module):
def __init__(self):
super().__init__()
self.U = nn.Embedding(n_users, k); self.V = nn.Embedding(n_items, k)
nn.init.normal_(self.U.weight, std=0.1); nn.init.normal_(self.V.weight, std=0.1)
def forward(self, u, i): return (self.U(u) * self.V(i)).sum(-1)
model = MF(); opt = torch.optim.Adam(model.parameters(), lr=1e-2, weight_decay=1e-5)
tp = torch.tensor(train_pairs)
for epoch in range(30):
perm = tp[torch.randperm(len(tp))]
for b in perm.split(1024):
u, pos = b[:, 0], b[:, 1]
neg = torch.randint(0, n_items, (len(b),)) # sampled negatives
loss = -nn.functional.logsigmoid(model(u, pos) - model(u, neg)).mean() # BPR
opt.zero_grad(); loss.backward(); opt.step()
# Recall@10 on held-out interactions, excluding already-seen items
with torch.no_grad():
all_scores = model.U.weight @ model.V.weight.T
for u, i in train_pairs: all_scores[u, i] = -1e9
top10 = all_scores.topk(10, dim=1).indices.numpy()
hits = np.mean([i in top10[u] for u, i in test_pairs])
print(f"Recall@10 (hit rate): {hits:.3f}")
For production-scale implicit MF, libraries such as implicit (ALS/BPR on sparse matrices) or Spark MLlib's ALS are faster and better tested.
1/log2(rank+1)). RMSE on explicit ratings correlates poorly with list quality.A two-factor example makes the biased MF formula and one SGD update concrete. Let the global mean be μ = 3.5, user bias bu = 0.2, item bias bi = −0.3, user vector u = [1.0, 0.5] and item vector v = [0.8, 0.4].
u·v = 0.8 + 0.2 = 1.0, so r̂ = 3.5 + 0.2 − 0.3 + 1.0 = 4.4.e = 4 − 4.4 = −0.4.bu ← 0.2 + 0.1·(−0.4) = 0.16, bi ← −0.34, u ← u + 0.1·e·v = [0.968, 0.484], v ← v + 0.1·e·u = [0.76, 0.38].3.5 + 0.16 − 0.34 + 0.92 ≈ 4.24 — moved toward the target.In practice an L2 term λ(||u||2 + ||v||2 + bu2 + bi2) is added, which shrinks factors of users and items with few interactions toward zero so their predictions fall back on the biases. The main hyperparameters are the factor count k (tens to a few hundred), λ, the learning rate or number of ALS sweeps, and for implicit ALS the confidence scale α. Tune them against a ranking metric on a time-based split, not RMSE on a random split.
A recommender is trained on data that its predecessor generated. Items it never showed have no clicks, so they look unpopular, so they are shown even less. Offline metrics computed on that logged data share the same blind spot. Several practices break the loop:
| Situation | Approach |
|---|---|
| New item, has content | Feature-based tower or content embedding (text, image via CLIP-style models) places it near similar items immediately. |
| New item, little content | Guaranteed exploration impressions, then fold its early interactions into the model quickly (frequent incremental retraining). |
| New user | Popularity by context (locale, device, referrer), onboarding preference questions, then session-based models that adapt within the first few clicks. |
| Returning user, changed taste | Recency-weighted features and sequential models over recent history rather than a static user vector. |
Cold start is less a separate algorithm than a reason to keep content features in the model at all, even when pure ID-based collaborative filtering scores slightly better offline on warm users.