Recommender Systems
Recommender systems predict what a user might like based on past behavior or item attributes. They power Netflix, Spotify, Amazon, YouTube, and LinkedIn. They're a key topic for ML Engineer and Applied Scientist roles.
Table of Contents
- Types of Recommender Systems
- Collaborative Filtering
- Matrix Factorization
- Content-Based Filtering
- Two-Tower Models (Modern Approach)
- Hybrid Systems
- Cold Start Problem
- Evaluation Metrics
- Production Architecture
- Interview Q&A
- Common Pitfalls
- Related Topics
Types of Recommender Systems
Recommender Systems
─────────────────────
┌─────────────┼──────────────┐
│ │ │
Collaborative Content-Based Hybrid
Filtering Filtering Systems
───────── ─────────
User-Based Item features CF + CB
Item-Based User profiles Two-Tower
Matrix Factor.
Neural CF
Collaborative Filtering
Collaborative filtering assumes that users who agreed in the past will agree in the future. It only uses the user-item interaction matrix (ratings, clicks, purchases).
User-Based CF
"Find users similar to you; recommend what they liked."
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# User-item rating matrix (rows=users, cols=items)
ratings = np.array([
[5, 4, 0, 1, 0], # User 0
[4, 0, 0, 1, 2], # User 1
[0, 0, 5, 4, 3], # User 2
[0, 3, 4, 0, 0], # User 3
[1, 0, 0, 5, 4], # User 4
])
# Compute user-user cosine similarity
user_sim = cosine_similarity(ratings)
np.fill_diagonal(user_sim, 0) # Exclude self-similarity
def user_based_predict(ratings, user_id, item_id, k=2):
"""Predict rating for user_id on item_id using top-k similar users."""
sim_scores = user_sim[user_id]
# Get users who rated the item
rated_mask = ratings[:, item_id] > 0
rated_mask[user_id] = False
if not any(rated_mask):
return 0.0
sims = sim_scores[rated_mask]
item_ratings = ratings[rated_mask, item_id]
# Top-k similar users
top_k_idx = np.argsort(sims)[-k:]
top_sims = sims[top_k_idx]
top_ratings = item_ratings[top_k_idx]
if top_sims.sum() == 0:
return 0.0
return np.dot(top_sims, top_ratings) / top_sims.sum()
# Predict User 0's rating for Item 2
pred = user_based_predict(ratings, user_id=0, item_id=2)
print(f"Predicted rating: {pred:.2f}")
Item-Based CF
"Find items similar to what you liked; recommend those."
# Compute item-item cosine similarity
item_sim = cosine_similarity(ratings.T) # Transpose: items as rows
np.fill_diagonal(item_sim, 0)
def item_based_predict(ratings, user_id, item_id, k=2):
"""Predict rating using top-k similar items the user already rated."""
sim_scores = item_sim[item_id]
user_ratings = ratings[user_id]
rated_mask = user_ratings > 0
if not any(rated_mask):
return 0.0
sims = sim_scores[rated_mask]
user_item_ratings = user_ratings[rated_mask]
top_k_idx = np.argsort(sims)[-k:]
top_sims = sims[top_k_idx]
top_ratings = user_item_ratings[top_k_idx]
return np.dot(top_sims, top_ratings) / (top_sims.sum() + 1e-10)
Matrix Factorization
Matrix factorization decomposes the user-item matrix R (m×n) into two lower-rank matrices: R ≈ U × V^T, where U (m×k) represents users and V (n×k) represents items in a latent factor space.
Alternating Least Squares (ALS)
ALS is commonly used for implicit feedback (clicks, purchases, views): popular in Spark ML. It alternates: fix the item factors and solve each user's factors by least squares, then swap.
scikit-learn has no ALS recommender. The sketch below uses NMF only to show the shape of a factorization; note that it treats the zeros (unobserved entries) as real ratings of 0, which a production recommender must not do.
from sklearn.decomposition import NMF
# Non-negative Matrix Factorization (illustration only; zeros are treated as observed)
nmf = NMF(n_components=2, init='random', random_state=42, max_iter=500)
W = nmf.fit_transform(ratings) # User latent factors (m x k)
H = nmf.components_ # Item latent factors (k x n)
# Reconstruct full ratings matrix
ratings_pred = W @ H
SVD-based Matrix Factorization (Surprise library)
from surprise import SVD, Dataset, Reader, accuracy
from surprise.model_selection import cross_validate, train_test_split
import pandas as pd
# Prepare data in Surprise format
data_df = pd.DataFrame({
'user_id': [0, 0, 1, 1, 2, 3],
'item_id': [0, 1, 0, 3, 2, 2],
'rating': [5, 4, 4, 1, 5, 4]
})
reader = Reader(rating_scale=(1, 5))
data = Dataset.load_from_df(data_df, reader)
# SVD (Simon Funk SVD / FunkSVD)
algo = SVD(n_factors=50, n_epochs=20, lr_all=0.005, reg_all=0.02)
cv_results = cross_validate(algo, data, measures=['RMSE', 'MAE'], cv=5)
print(f"RMSE: {cv_results['test_rmse'].mean():.3f}")
# Predict
trainset, testset = train_test_split(data, test_size=0.25)
algo.fit(trainset)
predictions = algo.test(testset)
print(f"Test RMSE: {accuracy.rmse(predictions):.3f}")
Content-Based Filtering
Content-based filtering recommends items similar to what a user has liked, based on item features (genre, keywords, attributes).
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# Movie content-based filtering example
movies = pd.DataFrame({
'title': ['Inception', 'Interstellar', 'The Dark Knight', 'Avengers', 'Iron Man'],
'description': [
'dream heist sci-fi thriller complex',
'space time wormhole sci-fi exploration',
'batman joker dark crime superhero',
'marvel superhero team avengers action',
'iron man tony stark superhero action'
]
})
# Build TF-IDF matrix
tfidf = TfidfVectorizer(stop_words='english')
tfidf_matrix = tfidf.fit_transform(movies['description'])
# Cosine similarity between all movies
item_cosine_sim = cosine_similarity(tfidf_matrix)
def get_content_recommendations(title, n=3):
idx = movies[movies['title'] == title].index[0]
sim_scores = list(enumerate(item_cosine_sim[idx]))
sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)[1:n+1]
movie_indices = [i[0] for i in sim_scores]
return movies['title'].iloc[movie_indices].tolist()
print(get_content_recommendations('Inception'))
Two-Tower Models (Modern Approach)
Two-tower (dual encoder) models are the industry standard for large-scale recommendations (YouTube, Pinterest, Spotify). They separately encode users and items into a shared embedding space, then score by dot product.
User Tower Item Tower
────────── ──────────
User features → [Dense] Item features → [Dense]
(age, history, [Dense] ←→ (title, genre, [Dense]
context) [Dense] description) [Dense]
↓ ↓
User Embedding · Item Embedding
(128-dim) (128-dim)
↓
Dot Product = Relevance Score
import tensorflow as tf
class TwoTowerModel(tf.keras.Model):
def __init__(self, user_vocab_size, item_vocab_size, embedding_dim=64):
super().__init__()
# User tower
self.user_embedding = tf.keras.layers.Embedding(user_vocab_size, embedding_dim)
self.user_dense = tf.keras.layers.Dense(embedding_dim, activation='relu')
# Item tower
self.item_embedding = tf.keras.layers.Embedding(item_vocab_size, embedding_dim)
self.item_dense = tf.keras.layers.Dense(embedding_dim, activation='relu')
def encode_user(self, user_ids):
x = self.user_embedding(user_ids)
return tf.nn.l2_normalize(self.user_dense(x), axis=-1)
def encode_item(self, item_ids):
x = self.item_embedding(item_ids)
return tf.nn.l2_normalize(self.item_dense(x), axis=-1)
def call(self, inputs):
user_ids, item_ids = inputs
user_emb = self.encode_user(user_ids)
item_emb = self.encode_item(item_ids)
return tf.reduce_sum(user_emb * item_emb, axis=-1) # Dot product
model = TwoTowerModel(user_vocab_size=10000, item_vocab_size=50000)
# At inference: pre-compute all item embeddings, store in vector DB
# Retrieve top-k candidates using ANN (HNSW, FAISS)
Hybrid Systems
Combine collaborative filtering and content-based filtering:
| Strategy | Description | Example |
|---|---|---|
| Weighted | Average CF and CB scores | Score = 0.7CF + 0.3CB |
| Switching | Use CB for cold start, CF once enough data | New users get CB; returning users get CF |
| Feature augmentation | Use CB features as input to CF | Add item genre embedding to MF model |
| Cascade | One model generates candidates, a second re-ranks them | CF retrieval → feature-rich ranking model (YouTube's two-stage design) |
Cold Start Problem
| Type | Problem | Solutions |
|---|---|---|
| New user | No interaction history | Ask onboarding preferences, use demographics, default to popularity |
| New item | No ratings yet | Use content features (CB), promote in exploration slots |
| New system | No data at all | Start with content-based; gather data gradually |
def get_recommendations(user_id, interaction_count, cf_model, cb_model, threshold=10):
"""Switch between CB and CF based on interaction count."""
if interaction_count < threshold:
# Cold start: use content-based
return cb_model.recommend(user_id)
else:
# Warm user: use collaborative filtering
return cf_model.recommend(user_id)
Evaluation Metrics
| Metric | Formula | What It Measures |
|---|---|---|
| Precision@k | Relevant in top-k / k | Fraction of recommended items that are relevant |
| Recall@k | Relevant in top-k / Total relevant | Fraction of relevant items that were recommended |
| NDCG@k | Normalized Discounted Cumulative Gain | Ranking quality: rewards placing relevant items higher |
| MAP@k | Mean Average Precision | Average precision across users |
| Hit Rate@k | % of users with ≥1 relevant item in top-k | Broad coverage metric |
| MRR | Mean Reciprocal Rank | Rank of first relevant result |
def ndcg_at_k(recommended, relevant, k):
"""NDCG@k: higher is better, max=1.0"""
dcg = 0
for i, item in enumerate(recommended[:k]):
if item in relevant:
dcg += 1 / np.log2(i + 2) # +2: log2(1)=0, so shift by 1
# Ideal DCG: all relevant items at top
ideal_dcg = sum(1 / np.log2(i + 2) for i in range(min(len(relevant), k)))
return dcg / ideal_dcg if ideal_dcg > 0 else 0
# Example
recommended = ['item1', 'item3', 'item5', 'item2', 'item8']
relevant = {'item1', 'item2', 'item4'}
print(f"NDCG@5: {ndcg_at_k(recommended, relevant, k=5):.3f}")
Production Architecture
User Request
│
▼
┌──────────────┐
│ Retrieval │ ← Two-tower ANN (FAISS/HNSW): ~100ms, 100-1000 candidates
│ Stage │ Pre-computed item embeddings in vector DB
└──────┬───────┘
│
▼
┌──────────────┐
│ Ranking │ ← Gradient boosting or deep ranking model
│ Stage │ Uses rich features (user context, item stats, recency)
└──────┬───────┘
│
▼
┌──────────────┐
│ Re-ranking │ ← Business rules (diversity, freshness, sponsored items)
│ Stage │ Deduplication, safety filters
└──────┬───────┘
│
▼
Final Top-N Recommendations
Interview Q&A
Q1: What is the difference between collaborative filtering and content-based filtering? CF uses the user-item interaction matrix only: it uses collective user behavior. It can recommend items that are hard to describe but popular with similar users. CB uses item/user features: it can explain recommendations ("because you liked sci-fi") and handles new items well. CF suffers from cold start; CB doesn't generalize beyond item similarity.
Q2: How do you handle the cold start problem in collaborative filtering? New users: collect onboarding preferences (ask them to rate a few items), use demographic-based CF, or fall back to content-based until enough interactions. New items: use content features in a hybrid model, or give new items exploration slots (epsilon-greedy).
Q3: What are the advantages of two-tower models over traditional matrix factorization? Two-tower models: (1) can incorporate rich features beyond IDs (text, images, context); (2) scale to billions of users/items via ANN retrieval; (3) support real-time user context; (4) naturally handle cold start via feature encoding. MF is simpler but limited to ID-based embeddings and doesn't generalize to unseen users/items.
Q4: How do you evaluate a recommender system offline vs online? Offline: precision@k, recall@k, NDCG@k on held-out interactions (use time-based split, not random). Online: A/B test CTR, conversion rate, watch time, user retention. Offline metrics don't always correlate with online performance: the bandit feedback problem means you can only evaluate on items that were actually shown.
Q5: What is matrix factorization and how does it work? MF decomposes the sparse user-item rating matrix R (m×n) into two dense matrices U (m×k) and V (n×k), where k << min(m,n). The k dimensions represent latent factors (like genre preference, price sensitivity). We optimize U and V to minimize squared error over the observed entries only, plus L2 regularization: Σ_(i,j observed) (r_ij - u_i·v_j)² + λ(||U||² + ||V||²). Solve it with SGD over observed entries, or with ALS, which alternates: hold one matrix fixed and solve for the other in closed form.
Q6: How would you add diversity to recommendations? Maximum Marginal Relevance (MMR): re-rank to balance relevance and diversity. At each step, select the item that maximizes: λ * relevance_score - (1-λ) * max_similarity_to_already_selected. Or use DPP (Determinantal Point Processes) for principled diversity. Alternatively, apply business rules (no more than 2 items from same category).
Common Pitfalls
| Pitfall | Problem | Fix |
|---|---|---|
| Random train/test split | Simulates future leaking into past | Use time-based split |
| Only evaluating accuracy | Ignores novelty, diversity, coverage | Add NDCG, diversity, coverage metrics |
| Ignoring cold start | New users/items get poor recommendations | Hybrid model or fallback strategy |
| Popularity bias | Always recommend popular items | Add exploration (epsilon-greedy, UCB) |
| Feedback loop / filter bubble | Model trains on its own predictions | Inject exploration; measure diversity over time |
| Evaluating offline only | Offline metrics ≠ online metrics | Always A/B test before full rollout |
Related Topics
| Topic | Why It's Related |
|---|---|
| Clustering | User/item clustering for neighborhood CF |
| Dimensionality Reduction | Latent factor models are a form of DR |
| Feature Engineering | Feature prep for content-based and hybrid models |
| Vector Databases | ANN retrieval for two-tower models |
| Study Pattern | Recommender Systems is an Advanced (🔴) topic |