Skip to content

09 · Recommender System Fundamentals

Recommenders predict which items a user will like, from a sparse matrix of past interactions. This module covers the two foundational approaches: neighborhood-based collaborative filtering, and matrix factorization, plus how to evaluate ranked recommendations.

The ratings matrix

import numpy as np
import pandas as pd

np.random.seed(42)
n_users, n_items = 200, 50
# Simulate a sparse ratings matrix: most user-item pairs are unobserved (NaN)
true_pref = np.random.default_rng(42).normal(size=(n_users, 20)) @ np.random.default_rng(1).normal(size=(20, n_items))
ratings = np.clip(true_pref + np.random.default_rng(2).normal(scale=0.5, size=true_pref.shape), 1, 5)
mask = np.random.default_rng(3).random(ratings.shape) < 0.15   # only 15% observed
observed = np.where(mask, ratings.round(), np.nan)

df = pd.DataFrame(observed, columns=[f"item_{i}" for i in range(n_items)])
print(f"observed ratings: {mask.sum()} of {mask.size} ({mask.mean():.1%})")

Real recommender datasets are typically >95% sparse — most users have rated only a handful of the available items.

User-based collaborative filtering

The idea: find users similar to you (by rating pattern), recommend what they liked that you haven't seen yet.

from sklearn.metrics.pairwise import cosine_similarity

filled = df.fillna(0).values                       # 0 = "no signal" for similarity purposes
user_sim = cosine_similarity(filled)
np.fill_diagonal(user_sim, 0)                       # exclude self-similarity

def predict_rating(user_idx, item_idx, k=10):
    sims = user_sim[user_idx]
    top_k = np.argsort(sims)[-k:]
    item_ratings = df.iloc[top_k, item_idx]
    weights = sims[top_k]
    mask_valid = item_ratings.notna()
    if mask_valid.sum() == 0:
        return df.iloc[:, item_idx].mean()          # fallback: item's global average
    return np.average(item_ratings[mask_valid], weights=weights[mask_valid])

print(f"predicted rating: {predict_rating(0, 5):.2f}")

Matrix factorization: learning latent factors

Neighborhood methods scale poorly and struggle with sparsity. Matrix factorization instead learns, for each user and item, a small vector of latent factors such that their dot product approximates the observed ratings.

from sklearn.decomposition import NMF

R = df.fillna(0).values
model = NMF(n_components=10, init="random", random_state=42, max_iter=500)
W = model.fit_transform(R)      # (n_users, 10) -- user latent factors
H = model.components_           # (10, n_items) -- item latent factors

reconstructed = W @ H
print(reconstructed.shape)      # (200, 50) -- dense predicted rating for every user-item pair

observed_mask = ~df.isna().values
rmse = np.sqrt(np.mean((R[observed_mask] - reconstructed[observed_mask]) ** 2))
print(f"train RMSE on observed entries: {rmse:.3f}")

W @ H produces a dense rating prediction for every user-item pair, including the ~85% that were never observed — this is the entire point of factorization: filling in the sparse matrix.

Worked example: top-N recommendations for one user

user_id = 7
already_rated = df.iloc[user_id].notna()
scores = reconstructed[user_id].copy()
scores[already_rated] = -np.inf                     # never recommend what they've already rated
top5 = np.argsort(scores)[-5:][::-1]
print("recommend items:", [f"item_{i}" for i in top5])

Evaluating rankings: precision@k

def precision_at_k(true_liked_set, recommended_list, k=5):
    top_k = recommended_list[:k]
    hits = len(set(top_k) & true_liked_set)
    return hits / k

liked = set(np.where(ratings[user_id] >= 4)[0])       # "ground truth" from the full simulated matrix
print(f"precision@5: {precision_at_k(liked, list(top5), k=5):.2f}")

Cheat sheet

Approach Idea Scales to
User-based CF Similar users → similar tastes Small user bases
Item-based CF Similar items → co-rated together Slightly better scaling
Matrix factorization (NMF) Learn compact latent factors Large sparse matrices
precision@k / recall@k Overlap between top-k and true likes Any ranked recommender

How It Actually Works

Cosine similarity mechanically compares rating patterns, ignoring magnitude. For two users represented as rating vectors u and v (with unrated items as 0), cosine_similarity = (u · v) / (‖u‖ ‖v‖) — the dot product measures how much the two vectors point the same direction, and dividing by the norms removes the effect of vector length (a user who rates everything 5 vs. one who rates everything 1 can still be judged "similar" if their relative pattern across items agrees, unlike Euclidean distance which would treat them as very far apart). This is why cosine similarity, not raw dot product or Euclidean distance, is the standard choice for rating data — it isolates agreement in taste from differences in how generously each user rates overall.

Non-negative matrix factorization solves an optimization problem, not a lookup. NMF seeks two non-negative matrices W (users × factors) and H (factors × items) whose product W @ H minimizes the squared reconstruction error Σ (R_ij - (WH)_ij)², summed only over the observed entries of R (unobserved entries, filled with 0 here, are effectively told "not part of the loss" by only training against known masked entries in a proper implementation — treating missing as literal 0 as this simplified example does is a known caveat, addressed by the exercise). This is solved iteratively (alternating least squares or multiplicative updates, max_iter=500 caps how many refinement rounds run) — conceptually the same gradient-descent flavor of optimization as Module 09's neural network training, except here the "parameters" are literally the W and H matrices' entries rather than network weights. Once trained, W @ H computes a rating estimate for every cell, including cells that were never observed, purely because matrix multiplication doesn't know or care which entries were used during training — it just applies the learned factors uniformly.

Latent factors are compressed, learned dimensions — not hand-labeled "genre" scores. Each of the 10 columns of W (and corresponding rows of H) is a direction the optimization discovered that helps reconstruct the observed ratings well; nothing forces factor 3 to mean "prefers comedies." The dot product W[user] · H[:, item] predicts a high rating when a user's latent vector and an item's latent vector point in similar directions in this learned 10-dimensional space — mechanically identical to how cosine similarity found "similar users" above, except now applied in a compressed space learned specifically to explain the rating data, rather than the raw sparse rating vectors themselves. This is exactly why factorization degrades more gracefully with sparsity than neighborhood methods: two users who never rated a single item in common can still end up with similar latent vectors if their observed ratings are each explainable by a similar combination of factors.

Exercise

Modify the NMF training so the loss is computed only over observed entries (mask R and reconstructed with observed_mask before computing RMSE, as done in the worked example, but also verify by comparing train RMSE on observed entries vs. RMSE on a held-out set of entries you artificially mask out before fitting). Report the gap between the two RMSEs and explain what it tells you about how well the factorization generalizes to genuinely unseen user-item pairs.