Recommender System Basics
"Customers who ordered paneer tikka also ordered garlic naan." Recommendations drive a large part of what people watch, buy and read on apps. Behind them is a recommender system: a model that ranks items (dishes, songs, courses, products) for each user, so that the few shown on screen are the ones that user is most likely to want.
In this lesson you will build three recommenders from scratch with NumPy and scikit-learn (a popularity baseline, item-to-item similarity and matrix factorisation), evaluate them the way industry does with hit rate at k, and learn the practical problems: implicit feedback, cold start and how real systems are structured.
Kinds of recommenders and feedback
- Content-based: recommend items similar to what the user liked, using item features (cuisine, price, spice level). Works for brand-new items, but only recommends "more of the same".
- Collaborative filtering: use the behaviour of many users. If people who liked A also liked B, recommend B to someone who liked A. It needs no item features, but cannot handle items with no interactions yet.
- Hybrid: combine both, which is what most production systems do.
The data comes in two flavours. Explicit feedback is a rating the user gives on purpose, like 4 stars. Implicit feedback is behaviour: orders, clicks, watch time. Implicit data is far more plentiful but noisier: not ordering a dish does not mean you dislike it, you may just never have seen it. This lesson uses implicit data, a 1 when a user ordered a dish.
Item-to-item similarity on a tiny example
Start with a table where rows are users and columns are dishes. Two dishes are similar if the same users ordered them. Cosine similarity measures this: it treats each dish's column as a vector and compares their directions, giving 1 for identical patterns and 0 for no overlap. To recommend for a user, add up the similarities between each candidate dish and the dishes the user already ordered.
import pandas as pd
from sklearn.metrics.pairwise import cosine_similarity
orders = pd.DataFrame(
[[1, 1, 0, 0, 1],
[1, 1, 1, 0, 0],
[0, 0, 1, 1, 0],
[0, 1, 1, 1, 0],
[1, 0, 0, 0, 1],
[1, 1, 0, 0, 0]],
index=["Aarav", "Priya", "Kabir", "Meera", "Rohan", "Isha"],
columns=["paneer tikka", "garlic naan", "masala dosa", "filter coffee", "lassi"])
sim = pd.DataFrame(cosine_similarity(orders.T), index=orders.columns, columns=orders.columns)
print(sim.round(2))
user = "Isha"
had = orders.loc[user]
scores = sim.loc[:, had == 1].sum(axis=1) # similarity to everything Isha ordered
scores = scores[had == 0].sort_values(ascending=False)
print(f"\nRecommendations for {user}:")
print(scores.round(2))paneer tikka garlic naan masala dosa filter coffee lassi paneer tikka 1.00 0.75 0.29 0.00 0.71 garlic naan 0.75 1.00 0.58 0.35 0.35 masala dosa 0.29 0.58 1.00 0.82 0.00 filter coffee 0.00 0.35 0.82 1.00 0.00 lassi 0.71 0.35 0.00 0.00 1.00 Recommendations for Isha: lassi 1.06 masala dosa 0.87 filter coffee 0.35 dtype: float64
Paneer tikka and garlic naan have a similarity of 0.75: most people who ordered one also ordered the other. Isha ordered both, so lassi (similar to paneer tikka at 0.71 and to naan at 0.35) comes first with a score of 1.06, then masala dosa. Filter coffee comes last, because a different group of customers orders it. That is the whole "customers who ordered X also ordered Y" idea in a few lines.
A realistic dataset and an honest test
For a fair test you need more data and a way to measure. The generator below creates orders for 500 users and 60 dishes, driven by hidden tastes (some users love spicy food, some prefer sweets) plus general popularity. The evaluation follows a standard leave-one-out scheme: for each user, hide one dish they ordered, train on the rest, recommend 10 dishes the user has not ordered yet, and check whether the hidden dish is among them. The share of users for whom it is found is the hit rate at 10.
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def make_orders(n_users=500, n_items=60, seed=42):
rng = np.random.default_rng(seed)
tastes = rng.normal(size=(n_users, 3))
flavours = rng.normal(size=(n_items, 3))
popularity = rng.normal(-3.0, 1.0, n_items)
logits = tastes @ flavours.T + popularity
return (rng.random((n_users, n_items)) < 1 / (1 + np.exp(-logits))).astype(float)
def leave_one_out(R, seed=42):
rng = np.random.default_rng(seed)
train, hidden = R.copy(), np.full(len(R), -1)
for u in range(len(R)):
items = np.flatnonzero(R[u])
if len(items) >= 2: # keep at least one order for training
hidden[u] = rng.choice(items)
train[u, hidden[u]] = 0
return train, hidden
def hit_rate(scores, train, hidden, k=10):
scores = np.where(train > 0, -np.inf, scores) # never recommend what they already ordered
top_k = np.argsort(-scores, axis=1)[:, :k]
users = hidden >= 0
return np.mean([hidden[u] in top_k[u] for u in np.flatnonzero(users)])
R = make_orders()
train, hidden = leave_one_out(R)
print(f"Users {R.shape[0]}, dishes {R.shape[1]}, filled cells {R.mean():.1%}")
popularity = np.tile(train.sum(axis=0), (len(train), 1))
item_sim = cosine_similarity(train.T)
np.fill_diagonal(item_sim, 0)
item_item = train @ item_sim # sum of similarities to each user's dishes
rng = np.random.default_rng(0)
random_scores = rng.random(train.shape)
for name, scores in [("random", random_scores), ("popularity", popularity), ("item-to-item", item_item)]:
print(f"{name:<13} hit rate @10: {hit_rate(scores, train, hidden):.3f}")Users 500, dishes 60, filled cells 10.8% random hit rate @10: 0.181 popularity hit rate @10: 0.473 item-to-item hit rate @10: 0.585
Random lists find the hidden dish for 18% of users, roughly 10 picks out of the 50 to 55 dishes each user has not ordered. Recommending the most popular dishes to everyone already reaches 47%, a strong baseline that many fancy systems fail to beat. Item-to-item similarity personalises the list and reaches 59%.
Matrix factorisation with TruncatedSVD
Matrix factorisation assumes that a few hidden factors explain the whole table: each user gets a short vector of tastes, each dish a short vector of flavours, and a user's score for a dish is the dot product of the two. Finding these vectors compresses the table and fills in its blanks with predictions.
scikit-learn's TruncatedSVD computes such a low-rank factorisation and works on sparse data. fit_transform gives the user vectors (scaled by the strength of each factor), and components_ holds the dish vectors. Multiplying them back gives a score for every user and dish. The number of factors, n_components, is a hyperparameter: too few miss real tastes, too many start memorising noise.
import numpy as np
from sklearn.decomposition import TruncatedSVD
def make_orders(n_users=500, n_items=60, seed=42):
rng = np.random.default_rng(seed)
tastes = rng.normal(size=(n_users, 3))
flavours = rng.normal(size=(n_items, 3))
popularity = rng.normal(-3.0, 1.0, n_items)
logits = tastes @ flavours.T + popularity
return (rng.random((n_users, n_items)) < 1 / (1 + np.exp(-logits))).astype(float)
def leave_one_out(R, seed=42):
rng = np.random.default_rng(seed)
train, hidden = R.copy(), np.full(len(R), -1)
for u in range(len(R)):
items = np.flatnonzero(R[u])
if len(items) >= 2:
hidden[u] = rng.choice(items)
train[u, hidden[u]] = 0
return train, hidden
def hit_rate(scores, train, hidden, k=10):
scores = np.where(train > 0, -np.inf, scores)
top_k = np.argsort(-scores, axis=1)[:, :k]
return np.mean([hidden[u] in top_k[u] for u in np.flatnonzero(hidden >= 0)])
R = make_orders()
train, hidden = leave_one_out(R)
for k in [1, 2, 4, 8, 16, 32]:
svd = TruncatedSVD(n_components=k, random_state=42)
user_vectors = svd.fit_transform(train) # shape (users, k)
scores = user_vectors @ svd.components_ # shape (users, dishes)
print(f"{k:>2} factors: hit rate @10 {hit_rate(scores, train, hidden):.3f}")1 factors: hit rate @10 0.465 2 factors: hit rate @10 0.562 4 factors: hit rate @10 0.631 8 factors: hit rate @10 0.519 16 factors: hit rate @10 0.379 32 factors: hit rate @10 0.210
Hit rate climbs to 0.631 at 4 factors, beating item-to-item similarity, and then falls as extra factors start fitting noise; with 32 factors it is only slightly better than random. The generator used 3 hidden tastes plus popularity, so 4 factors is a satisfying answer. On real data, choose n_components on a validation split, like any other hyperparameter.
NMF (non-negative matrix factorisation) in sklearn.decomposition is a close cousin whose factors are never negative, which can make them easier to read as "tastes". Specialised libraries implement factorisation methods designed for implicit feedback, such as alternating least squares, and scale to millions of users.
Recommenders in the real world
A few practical lessons that matter more than the algorithm:
- Cold start: a new user has no history and a new dish has no orders. Fall back to popularity (perhaps per city or time of day), ask a couple of onboarding questions, or use content features.
- Split by time when you can: hiding the last order of each user, and training only on earlier ones, mirrors real use better than hiding a random one.
- Offline is not online: hit rate on old data cannot show how users react to new suggestions. Confirm with an A/B test on live traffic (see A/B Testing).
- Two stages at scale: big systems first pick a few hundred candidates quickly (similarity or factorisation), then a second model, often gradient boosting, ranks them using many features such as price, distance and time of day.
- Beyond accuracy: track coverage (how many different items ever get recommended) and diversity, or the system keeps showing the same ten bestsellers. Avoid unfair exposure, for example new small restaurants never getting shown.
Common mistakes
- No popularity baseline. It is often hard to beat, so always report it.
- Recommending items the user already has. Mask them before taking the top k.
- Evaluating on interactions used for training. Hide some interactions per user, or split by time.
- Treating "not ordered" as "disliked" in implicit data. It mostly means "not seen".
- Ignoring cold start, which affects every new user on day one.
Key points
- Recommenders rank items per user; collaborative filtering learns from many users' behaviour, content-based from item features.
- Implicit feedback (orders, clicks) is plentiful but a missing value does not mean dislike.
- Item-to-item cosine similarity recommends dishes ordered by the same people.
- Matrix factorisation, for example
TruncatedSVD, learns a few hidden taste factors per user and item. - Evaluate with hit rate at k after hiding interactions, compare with popularity, and confirm with A/B tests.
- Handle cold start with popularity or content features, and watch coverage and fairness of exposure.
Exercise
Using the same generator and leave-one-out split, measure coverage: the number of different dishes that appear in at least one user's top 10, for the popularity recommender and for TruncatedSVD with 4 factors. Print both numbers out of 60. Which recommender spreads attention over more dishes?
Show solution
import numpy as np
from sklearn.decomposition import TruncatedSVD
def make_orders(n_users=500, n_items=60, seed=42):
rng = np.random.default_rng(seed)
tastes = rng.normal(size=(n_users, 3))
flavours = rng.normal(size=(n_items, 3))
popularity = rng.normal(-3.0, 1.0, n_items)
logits = tastes @ flavours.T + popularity
return (rng.random((n_users, n_items)) < 1 / (1 + np.exp(-logits))).astype(float)
def leave_one_out(R, seed=42):
rng = np.random.default_rng(seed)
train, hidden = R.copy(), np.full(len(R), -1)
for u in range(len(R)):
items = np.flatnonzero(R[u])
if len(items) >= 2:
hidden[u] = rng.choice(items)
train[u, hidden[u]] = 0
return train, hidden
def top_k(scores, train, k=10):
scores = np.where(train > 0, -np.inf, scores)
return np.argsort(-scores, axis=1)[:, :k]
R = make_orders()
train, hidden = leave_one_out(R)
popularity = np.tile(train.sum(axis=0), (len(train), 1))
svd = TruncatedSVD(n_components=4, random_state=42)
svd_scores = svd.fit_transform(train) @ svd.components_
for name, scores in [("popularity", popularity), ("SVD, 4 factors", svd_scores)]:
covered = len(np.unique(top_k(scores, train)))
print(f"{name:<15} coverage: {covered} of {R.shape[1]} dishes")popularity coverage: 21 of 60 dishes SVD, 4 factors coverage: 52 of 60 dishes
Popularity recommends only 21 different dishes across all 500 users: the same bestsellers again and again, minus whatever each user already ordered. Matrix factorisation spreads attention over 52 of the 60 dishes and also had the higher hit rate. Low coverage is why pure popularity lists feel stale to users and unfair to lesser-known restaurants.
Quick Quiz
1. What is implicit feedback?
Implicit feedback is inferred from actions, not stated directly.
2. Two dishes have a cosine similarity of 1 in the user-dish table. What does that mean?
Cosine similarity compares the columns of who ordered each dish.
3. What does hit rate at 10 measure in a leave-one-out test?
Each user has one hidden interaction; a hit means the recommender ranked it in the top 10.
4. A brand-new user opens the app. What is a sensible first recommendation strategy?
With no history, collaborative methods cannot help; popularity or content features are the fallback.
Learn Machine Learning with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
