Skip to content

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.


← Feature Engineering | Next: Evaluation →