EthicsComputerCourses
Join training +91 90532 10052

Recommender System Basics

Machine Learning · Lesson 34 of 43 Advanced 8 min read Updated

"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.

PythonRun it yourself
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))
Output
               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.

PythonRun it yourself
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}")
Output
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.

PythonRun it yourself
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}")
Output
 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
Python
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")
Output
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?

2. Two dishes have a cosine similarity of 1 in the user-dish table. What does that mean?

3. What does hit rate at 10 measure in a leave-one-out test?

4. A brand-new user opens the app. What is a sensible first recommendation strategy?

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