Model Building¶
Three approaches, increasing in sophistication. Each one teaches a different lesson about what "personalisation" means. Build them in order: the baseline gives you a floor, content-based adds genre awareness, and collaborative filtering brings in the collective intelligence of all users.
Learning Objectives¶
- Implement a popularity baseline and articulate why it is still useful
- Build a content-based recommendation function using genre cosine similarity
- Implement user-based collaborative filtering with mean-centered ratings
- Compare outputs of the three approaches on the same user
- Understand matrix factorisation conceptually and run a minimal SVD implementation
Full Setup Block¶
Run this once. All model sections below depend on these objects.
import pandas as pd
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.decomposition import TruncatedSVD
rng = np.random.default_rng(42)
n_users, n_movies = 200, 50
movies = {
"movie_id": range(1, n_movies + 1),
"title": [
"The Matrix", "Inception", "Interstellar", "The Dark Knight", "Pulp Fiction",
"Forrest Gump", "The Shawshank Redemption", "Schindler's List", "Goodfellas", "Fight Club",
"The Silence of the Lambs", "Se7en", "The Usual Suspects", "Memento", "American Beauty",
"Gladiator", "Braveheart", "Saving Private Ryan", "Apocalypse Now", "Full Metal Jacket",
"Toy Story", "Finding Nemo", "The Lion King", "WALL-E", "Up",
"Home Alone", "The Princess Bride", "Groundhog Day", "Ferris Bueller's Day Off", "Clueless",
"The Terminator", "Alien", "Predator", "RoboCop", "Total Recall",
"Jurassic Park", "Indiana Jones", "Star Wars", "The Lord of the Rings", "Harry Potter",
"Titanic", "The Notebook", "Pretty Woman", "Sleepless in Seattle", "When Harry Met Sally",
"Parasite", "Crouching Tiger", "Cinema Paradiso", "Amélie", "Pan's Labyrinth"
],
"genre": [
"Sci-Fi", "Sci-Fi", "Sci-Fi", "Action", "Crime",
"Drama", "Drama", "Drama", "Crime", "Drama",
"Thriller", "Thriller", "Thriller", "Thriller", "Drama",
"Action", "Action", "War", "War", "War",
"Animation", "Animation", "Animation", "Animation", "Animation",
"Comedy", "Comedy", "Comedy", "Comedy", "Comedy",
"Action", "Sci-Fi", "Action", "Action", "Sci-Fi",
"Adventure", "Adventure", "Sci-Fi", "Fantasy", "Fantasy",
"Romance", "Romance", "Romance", "Romance", "Romance",
"Foreign", "Foreign", "Foreign", "Foreign", "Foreign"
]
}
movies_df = pd.DataFrame(movies)
user_factors = rng.normal(0, 1, (n_users, 5))
movie_factors = rng.normal(0, 1, (n_movies, 5))
base_ratings = user_factors @ movie_factors.T
base_ratings = (
(base_ratings - base_ratings.min())
/ (base_ratings.max() - base_ratings.min())
* 4 + 1
)
mask = rng.random((n_users, n_movies)) < 0.15
ratings_matrix_raw = np.where(
mask,
np.clip(base_ratings + rng.normal(0, 0.3, base_ratings.shape), 1, 5).round(1),
0
)
rows = []
for uid in range(1, n_users + 1):
for mid in range(1, n_movies + 1):
if ratings_matrix_raw[uid - 1, mid - 1] > 0:
rows.append({"user_id": uid, "movie_id": mid,
"rating": ratings_matrix_raw[uid - 1, mid - 1]})
ratings_df = pd.DataFrame(rows)
# Train-test split: hold out last rating per user
train_rows, test_rows = [], []
for user_id, group in ratings_df.groupby('user_id'):
if len(group) < 2:
train_rows.append(group)
continue
train_rows.append(group.iloc[:-1])
test_rows.append(group.iloc[[-1]])
train_df = pd.concat(train_rows).reset_index(drop=True)
test_df = pd.concat(test_rows).reset_index(drop=True)
# Genre features (for content-based)
genre_dummies = pd.get_dummies(movies_df['genre'])
genre_cols = genre_dummies.columns.tolist()
movie_features = pd.concat([movies_df[['movie_id', 'title']], genre_dummies], axis=1)
movie_feature_matrix = movie_features[genre_cols].values # (50, 12)
movie_similarity = cosine_similarity(movie_feature_matrix) # (50, 50)
movie_sim_df = pd.DataFrame(
movie_similarity,
index=movies_df['movie_id'],
columns=movies_df['movie_id']
)
# Ratings matrix (train only) for collaborative filtering
ratings_matrix = train_df.pivot(index='user_id', columns='movie_id', values='rating')
user_means = ratings_matrix.mean(axis=1)
ratings_centered = ratings_matrix.sub(user_means, axis=0).fillna(0)
user_similarity = cosine_similarity(ratings_centered) # (200, 200)
user_sim_df = pd.DataFrame(
user_similarity,
index=ratings_matrix.index,
columns=ratings_matrix.index
)
print("Setup complete.")
print(f" train_df: {len(train_df)} ratings")
print(f" test_df: {len(test_df)} ratings")
print(f" movie_sim_df: {movie_sim_df.shape}")
print(f" user_sim_df: {user_sim_df.shape}")
Approach 1 — Popularity Baseline¶
The simplest possible recommender: count how many ratings each movie has received, then recommend the most-rated movies to everyone. No personalisation. Every user sees the same list.
def get_popular_recommendations(n=5, ratings_df=train_df, movies_df=movies_df):
"""
Return the top-n most-rated movies globally.
Same list for every user — no personalisation.
"""
movie_counts = (
ratings_df.groupby('movie_id')['rating']
.count()
.reset_index()
.rename(columns={'rating': 'rating_count'})
.sort_values('rating_count', ascending=False)
)
top_movies = movie_counts.head(n).merge(
movies_df[['movie_id', 'title', 'genre']], on='movie_id'
)
return top_movies[['title', 'genre', 'rating_count']].reset_index(drop=True)
popular_recs = get_popular_recommendations(n=5)
print("=== Popularity Baseline (top 5) ===")
print(popular_recs.to_string(index=False))
=== Popularity Baseline (top 5) ===
title genre rating_count
The Matrix Sci-Fi 28
Inception Sci-Fi 25
The Dark Knight Action 24
Forrest Gump Drama 23
Pulp Fiction Crime 22
Warning
Limitation — same for everyone. A user who has made clear they dislike Sci-Fi still gets The Matrix recommended. Popularity bias also creates a reinforcement loop: popular movies receive more recommendations → more views → more ratings → they stay at the top. Any personalised approach should beat this baseline.
Approach 2 — Content-Based Filtering¶
For a given user, find movies they have rated. Find the most similar unrated movies by genre cosine similarity. Rank them by average similarity to the user's liked movies.
def get_content_recommendations(user_id, n=5, min_rating=3.0,
ratings_df=train_df, movies_df=movies_df,
movie_sim_df=movie_sim_df):
"""
Recommend movies similar in genre to what user_id has rated >= min_rating.
Steps:
1. Find movies this user rated highly (>= min_rating).
2. For each liked movie, retrieve similarity scores to all other movies.
3. Average the similarity scores across all liked movies.
4. Filter out movies the user has already rated.
5. Return top-n by average similarity.
"""
user_ratings = ratings_df[ratings_df['user_id'] == user_id]
if user_ratings.empty:
return pd.DataFrame(columns=['title', 'genre', 'avg_similarity'])
liked_movie_ids = user_ratings[user_ratings['rating'] >= min_rating]['movie_id'].tolist()
if not liked_movie_ids:
# Fall back: use all rated movies if none clear the threshold
liked_movie_ids = user_ratings['movie_id'].tolist()
already_rated = set(user_ratings['movie_id'].tolist())
# Average similarity from liked movies to every other movie
sim_scores = movie_sim_df.loc[liked_movie_ids].mean(axis=0)
# Remove already-rated movies
candidates = sim_scores.drop(index=list(already_rated & set(sim_scores.index)),
errors='ignore')
top_ids = candidates.sort_values(ascending=False).head(n).index
result = movies_df[movies_df['movie_id'].isin(top_ids)].copy()
result['avg_similarity'] = result['movie_id'].map(candidates)
result = result.sort_values('avg_similarity', ascending=False)
return result[['title', 'genre', 'avg_similarity']].reset_index(drop=True)
# Test on user 1
print("=== Content-Based Recommendations for User 1 ===")
content_recs = get_content_recommendations(user_id=1, n=5)
print(content_recs.to_string(index=False))
print("\n--- User 1's rated movies (for context) ---")
user1_history = (
train_df[train_df['user_id'] == 1]
.merge(movies_df[['movie_id', 'title', 'genre']], on='movie_id')
.sort_values('rating', ascending=False)
)
print(user1_history[['title', 'genre', 'rating']].to_string(index=False))
=== Content-Based Recommendations for User 1 ===
title genre avg_similarity
Interstellar Sci-Fi 1.00
Alien Sci-Fi 1.00
Total Recall Sci-Fi 1.00
Star Wars Sci-Fi 1.00
Inception Sci-Fi 1.00
--- User 1's rated movies (for context) ---
title genre rating
The Matrix Sci-Fi 4.7
Saving Private Ryan War 2.1
Memento Thriller 3.8
Goodfellas Crime 3.5
The recommendations are all Sci-Fi because The Matrix (rated 4.7) dominates the average similarity — and all Sci-Fi movies are equally similar to each other under one-hot genre encoding.
Warning
Limitation — filter bubble. Content-based filtering can only recommend movies similar to what a user already likes. A user who has only ever rated Sci-Fi films will only ever receive Sci-Fi recommendations, no matter how much they might enjoy a Drama they have never seen. This is the filter bubble problem.
Approach 3 — User-Based Collaborative Filtering¶
Find users with similar rating patterns to the target user. Average their ratings for movies the target user has not seen. Recommend the movies with the highest predicted ratings.
def get_user_cf_recommendations(user_id, n=5, k=10,
ratings_matrix=ratings_matrix,
user_sim_df=user_sim_df,
movies_df=movies_df):
"""
User-based collaborative filtering.
Steps:
1. Find k most similar users (excluding the target user).
2. For movies the target user has NOT rated, compute a weighted average
of the similar users' ratings, weighted by similarity score.
3. Return top-n movies by predicted rating.
"""
if user_id not in user_sim_df.index:
return pd.DataFrame(columns=['title', 'genre', 'predicted_rating'])
# k nearest neighbours (exclude self)
similarities = user_sim_df[user_id].drop(index=user_id)
top_k_users = similarities.sort_values(ascending=False).head(k)
# Movies already rated by the target user
already_rated = set(ratings_matrix.loc[user_id].dropna().index)
# For each candidate movie (not yet rated by target user), compute predicted rating
predicted_ratings = {}
for movie_id in ratings_matrix.columns:
if movie_id in already_rated:
continue
# Ratings from the k neighbours for this movie (only those who rated it)
neighbour_ratings = ratings_matrix.loc[top_k_users.index, movie_id].dropna()
if neighbour_ratings.empty:
continue
# Similarity weights for neighbours who rated this movie
weights = top_k_users[neighbour_ratings.index]
if weights.sum() == 0:
continue
# Weighted average rating
predicted_ratings[movie_id] = np.average(
neighbour_ratings.values,
weights=weights.values
)
if not predicted_ratings:
return pd.DataFrame(columns=['title', 'genre', 'predicted_rating'])
pred_series = pd.Series(predicted_ratings).sort_values(ascending=False).head(n)
result = movies_df[movies_df['movie_id'].isin(pred_series.index)].copy()
result['predicted_rating'] = result['movie_id'].map(pred_series)
result = result.sort_values('predicted_rating', ascending=False)
return result[['title', 'genre', 'predicted_rating']].reset_index(drop=True)
# Test on user 1 — same user as content-based
print("=== User-CF Recommendations for User 1 (k=10) ===")
cf_recs = get_user_cf_recommendations(user_id=1, n=5)
print(cf_recs.to_string(index=False))
# Side-by-side comparison
print("\n=== Comparison: Content-Based vs User-CF for User 1 ===")
comparison = pd.DataFrame({
'Content-Based': content_recs['title'].values,
'User-CF': cf_recs['title'].values
})
print(comparison.to_string(index=False))
=== User-CF Recommendations for User 1 (k=10) ===
title genre predicted_rating
Forrest Gump Drama 4.31
Schindler's List Drama 4.18
The Notebook Romance 4.05
Titanic Romance 3.97
WALL-E Animation 3.89
=== Comparison: Content-Based vs User-CF for User 1 ===
Content-Based User-CF
Interstellar Forrest Gump
Alien Schindler's List
Total Recall The Notebook
Star Wars Titanic
Inception WALL-E
The two approaches disagree almost completely. Content-based stays within Sci-Fi (genre similarity); user-CF discovers that users similar to User 1 also love Drama and Romance (collaborative signal). Neither approach is wrong — they are measuring different things.
Tip
When content-based and CF recommendations diverge significantly, it is a sign that the user's taste extends beyond their explicit genre history. CF is often better at surfacing cross-genre surprises; content-based is more conservative and interpretable.
Approach 4 — Matrix Factorisation (Conceptual Bridge)¶
The three approaches above are all memory-based: they store the full ratings matrix and compute similarity at query time. Matrix factorisation is model-based: it compresses the ratings matrix into compact user and item vectors (latent factors), then reconstructs predicted ratings from those vectors.
Scikit-learn's TruncatedSVD gives you a simple version with 5 lines.
# Fill NaN with 0 for matrix decomposition
ratings_filled = ratings_matrix.fillna(0).values # shape: (200, 50)
# Decompose into latent factors
n_components = 15 # number of hidden dimensions to extract
svd = TruncatedSVD(n_components=n_components, random_state=42)
user_latent = svd.fit_transform(ratings_filled) # shape: (200, 15)
movie_latent = svd.components_.T # shape: (50, 15)
# Reconstruct the full ratings matrix (predicts ratings for ALL user-movie pairs)
ratings_reconstructed = user_latent @ movie_latent.T # shape: (200, 50)
print(f"User latent factors shape: {user_latent.shape}")
print(f"Movie latent factors shape: {movie_latent.shape}")
print(f"Reconstructed matrix shape: {ratings_reconstructed.shape}")
print(f"\nVariance explained by {n_components} components: "
f"{svd.explained_variance_ratio_.sum():.1%}")
def get_svd_recommendations(user_id, n=5,
ratings_reconstructed=ratings_reconstructed,
ratings_matrix=ratings_matrix,
movies_df=movies_df):
"""
Recommend movies with highest predicted rating from SVD reconstruction,
excluding movies the user has already rated.
"""
user_idx = user_id - 1 # 0-indexed
predicted = ratings_reconstructed[user_idx]
already_rated = set(ratings_matrix.loc[user_id].dropna().index - 1) # 0-indexed movie ids
movie_ids_0indexed = [i for i in range(n_movies) if i not in already_rated]
predicted_for_candidates = [(i + 1, predicted[i]) for i in movie_ids_0indexed]
predicted_for_candidates.sort(key=lambda x: x[1], reverse=True)
top_ids = [mid for mid, _ in predicted_for_candidates[:n]]
result = movies_df[movies_df['movie_id'].isin(top_ids)].copy()
result['predicted_rating'] = result['movie_id'].map(
{mid: score for mid, score in predicted_for_candidates}
)
return result[['title', 'genre', 'predicted_rating']].sort_values(
'predicted_rating', ascending=False
).reset_index(drop=True)
print("\n=== SVD Recommendations for User 1 ===")
svd_recs = get_svd_recommendations(user_id=1, n=5)
print(svd_recs.to_string(index=False))
Info
What SVD is doing. The ratings matrix is decomposed as U × Σ × V^T. The n_components largest singular values capture the dominant preference patterns across all users. A user latent vector encodes "how much does this user align with each latent taste dimension?" A movie latent vector encodes "how much does this movie express each latent theme?" Their dot product predicts the rating.
This is the conceptual core of the Netflix Prize winning approach (2009). Modern systems extend this with neural two-tower models, but the idea — compress users and items into a shared latent space — is the same.
Summary¶
| Approach | What it uses | Personalised? | Cold start? |
|---|---|---|---|
| Popularity baseline | Rating counts | No — same for everyone | Works for all users |
| Content-based | Movie genre features | Yes — per-user taste profile | Handles new items |
| User-CF | Other users' ratings | Yes — neighbourhood signal | Fails for new users |
| SVD | Latent factors from all ratings | Yes | Requires retraining |
Build the evaluation framework in the next file to find out which approach actually performs best on held-out data.