Build a Recommendation Engine From Scratch (It’s Easier Than Netflix Makes It Look)

A no-framework, pure-Python walkthrough of collaborative filtering and content-based recommendation systems. Covers cosine similarity, matrix factorization, evaluation metrics, and the hybrid approach that actually ships to production.

Why Build One From Scratch?

Every major library has a recommendation module. Surprise, LightFM, TensorFlow Recommenders. You could have a working prototype in twenty lines of code. So why would anyone build one from scratch?

Because the twenty-line prototype breaks the moment you try to deploy it. You get cold-start failures. You get popularity bias so extreme that the engine recommends the same five items to every user. You get latency spikes that make your product team file bugs against your service. And when those things happen, if you do not understand what is happening underneath the abstraction, you are stuck.

The Netflix Prize proved this in the most expensive way possible. In 2006, Netflix offered $1 million to anyone who could beat their recommendation algorithm by 10%. It took three years and a team of researchers from AT&T Labs and Commendo Research (team BellKor’s Pragmatic Chaos) to claim it, combining over 100 different models into a single ensemble. The winning solution was not a clever shortcut. It was a deep understanding of the underlying mathematics applied with surgical precision.

This tutorial walks through that mathematics. By the end, you will have built three recommendation approaches from raw Python and NumPy, understand exactly when each one fails, and know how to combine them into a hybrid system that handles real-world edge cases.

Content-Based Filtering: Recommending by Item Attributes

Content-based filtering answers a simple question: if a user liked item A, what other items share similar attributes?

The core operation is cosine similarity. Given two items represented as feature vectors, cosine similarity measures the angle between them. Two vectors pointing in roughly the same direction (similar features) produce a score near 1. Orthogonal vectors (nothing in common) score 0.

The formula is straightforward:

similarity(A, B) = (A . B) / (||A|| * ||B||)

In practice, you represent each item as a vector of features. For movies, those features might be genre indicators, director, decade of release, and average rating. For e-commerce products, they might be category, price range, brand, and textual description embeddings.

Here is a minimal implementation. Suppose you have five movies, each with three genre scores (action, comedy, drama) on a 0-1 scale:

import numpy as np

# Rows = movies, Cols = [action, comedy, drama]
items = np.array([
    [0.9, 0.1, 0.2],  # Movie A: action-heavy
    [0.8, 0.2, 0.1],  # Movie B: also action
    [0.1, 0.9, 0.3],  # Movie C: comedy
    [0.2, 0.1, 0.9],  # Movie D: drama
    [0.5, 0.5, 0.5],  # Movie E: balanced
])

def cosine_sim(matrix):
    norms = np.linalg.norm(matrix, axis=1, keepdims=True)
    normalized = matrix / norms
    return normalized @ normalized.T

sim_matrix = cosine_sim(items)
# sim_matrix[0] shows Movie A's similarity to all others
# Highest: Movie B (0.98), Lowest: Movie C (0.38)

This works. But it has a fundamental limitation: content-based filtering never surprises the user. If someone watches only action movies, it recommends only action movies. There is no mechanism for serendipity, no way to suggest that a drama fan might also enjoy a specific documentary. The system is trapped inside the user’s existing preference bubble.

It also requires feature engineering. Someone has to decide what attributes matter and assign values to them. For text-heavy domains, you can use TF-IDF or sentence embeddings to generate features automatically. But for structured data like product catalogs, feature design remains a manual bottleneck.

Collaborative Filtering: Let the Crowd Decide

Collaborative filtering sidesteps the feature problem entirely. Instead of asking “what attributes does this item have?”, it asks “what did similar users do?”

There are two main variants. User-based collaborative filtering finds users who rated items similarly to the target user, then recommends items those similar users enjoyed. Item-based collaborative filtering finds items that received similar rating patterns from the user base, then recommends items similar to what the target user already rated highly.

The user-item interaction matrix is the foundation of both. Rows are users, columns are items, and cells contain ratings (or implicit signals like clicks and purchase counts). The matrix is almost always extremely sparse. Netflix’s original dataset had about 99% empty cells. Users rate a tiny fraction of available items.

User-based collaborative filtering in raw NumPy looks like this:

# ratings: users x items matrix (0 = unrated)
ratings = np.array([
    [5, 3, 0, 1, 0],
    [4, 0, 0, 1, 1],
    [1, 1, 0, 5, 4],
    [0, 0, 5, 4, 0],
    [0, 1, 4, 0, 5],
])

def predict_user_based(ratings, user_idx, item_idx, k=2):
    # Compute similarity between target user and all others
    target = ratings[user_idx]
    sims = []
    for i, row in enumerate(ratings):
        if i == user_idx:
            continue
        # Only compare items both users rated
        mask = (target > 0) & (row > 0)
        if mask.sum() == 0:
            sims.append((i, 0.0))
            continue
        a, b = target[mask], row[mask]
        cos = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
        sims.append((i, cos))

    # Top-k similar users who rated the target item
    sims.sort(key=lambda x: -x[1])
    num, den = 0.0, 0.0
    for uid, sim in sims[:k]:
        if ratings[uid, item_idx] > 0:
            num += sim * ratings[uid, item_idx]
            den += abs(sim)
    return num / den if den > 0 else 0.0

The cold-start problem is collaborative filtering’s Achilles heel. A new user with zero ratings has no similar users. A new item with zero ratings has no similar items. Both are invisible to the system. This is not an edge case. It is a constant reality for any growing platform.

Matrix Factorization: The Netflix Prize Approach

Memory-based approaches (the user-based and item-based methods above) struggle with scale. Computing pairwise similarities across millions of users is expensive and noisy. Matrix factorization compresses the problem.

The idea: decompose the sparse user-item matrix R into two smaller matrices, U (users x latent factors) and V (items x latent factors). Each latent factor represents an abstract concept the model discovers on its own. In a movie context, one factor might roughly correspond to “seriousness,” another to “visual spectacle,” another to “dialogue density.” You never define these factors. The optimization process finds them.

The predicted rating for user u and item i is simply the dot product of their latent vectors:

R_predicted[u, i] = U[u] . V[i]

Training minimizes the squared error between predicted and actual ratings, plus a regularization term to prevent overfitting:

def train_mf(ratings, k=3, lr=0.01, reg=0.02, epochs=100):
    n_users, n_items = ratings.shape
    U = np.random.normal(0, 0.1, (n_users, k))
    V = np.random.normal(0, 0.1, (n_items, k))

    # Indices of known ratings
    known = list(zip(*np.where(ratings > 0)))

    for epoch in range(epochs):
        np.random.shuffle(known)
        total_err = 0.0
        for u, i in known:
            pred = U[u] @ V[i]
            err = ratings[u, i] - pred
            total_err += err ** 2

            # Gradient descent update
            U[u] += lr * (err * V[i] - reg * U[u])
            V[i] += lr * (err * U[u] - reg * V[i])

        if (epoch + 1) % 20 == 0:
            rmse = np.sqrt(total_err / len(known))
            # print(f"Epoch {epoch+1}: RMSE={rmse:.4f}")

    return U, V

This is Stochastic Gradient Descent (SGD) applied to matrix factorization, the backbone of the BellKor solution that won the Netflix Prize. The team from AT&T Labs extended it with temporal dynamics (user preferences shift over time), implicit feedback signals, and bias terms for users and items. But the core loop above is where it starts.

A practical note on the latent factor count k: too few factors underfit (the model cannot capture enough preference dimensions), too many overfit (the model memorizes noise). For most mid-sized datasets (100K-10M ratings), values between 20 and 100 work well. The MovieLens 100K benchmark, a standard test dataset, typically sees optimal RMSE around k=50.

Evaluation: The Metrics That Matter

A recommendation engine that scores well on the wrong metric ships a bad product. Choosing the right evaluation framework is not an afterthought. It is a design decision.

MetricWhat It MeasuresWhen to Use
RMSERating prediction accuracyExplicit feedback (star ratings)
Precision@KFraction of top-K recs that are relevantBinary relevance (click/no-click)
Recall@KFraction of relevant items in top-KCatalog coverage matters
MAP (Mean Avg Precision)Rank-sensitive precisionOrder of results matters
NDCGGraded relevance with position discountMulti-level relevance scores
Coverage% of catalog ever recommendedPopularity bias detection

RMSE dominated during the Netflix Prize era because the competition was framed as a rating prediction task. But modern recommendation systems rarely show predicted ratings to users. They show ranked lists. For ranked lists, Precision@K and NDCG are more informative.

Precision@K is the simplest: of the top K items you recommended, how many did the user actually interact with? If you recommend 10 items and the user clicks 3, your Precision@10 is 0.3.

NDCG (Normalized Discounted Cumulative Gain) adds two refinements. First, it accounts for graded relevance (a purchase is worth more than a click). Second, it applies a logarithmic position discount (getting a relevant item at position 1 matters more than at position 10). For most production systems, NDCG@10 or NDCG@20 is the primary offline metric.

Coverage is the metric teams forget until it is too late. A system that achieves high precision by recommending only the 50 most popular items to everyone is technically “accurate” but commercially useless. Track what percentage of your catalog appears in recommendations. If it is below 30-40%, you have a popularity bias problem that needs explicit correction.

The Offline-Online Gap

Offline metrics (computed on historical data) consistently overestimate real-world performance. A model that achieves 0.35 NDCG@10 offline might deliver 0.22 in production due to presentation bias, position effects, and changing user intent. Always plan for A/B testing as your final validation step. Offline metrics decide which models deserve a test. Online metrics decide which models stay.

The Hybrid System: What Actually Ships

Pure collaborative filtering fails on cold starts. Pure content-based filtering fails on discovery. Every production recommendation system is a hybrid.

The simplest hybrid approach is a weighted fallback. Use collaborative filtering when you have sufficient interaction data, content-based filtering when you do not, and a popularity baseline as the final fallback. In pseudocode:

def hybrid_recommend(user, items, cf_model, cb_model, n=10):
    user_ratings = get_user_history(user)

    if len(user_ratings) >= 20:
        # Enough data for collaborative filtering
        scores = cf_model.predict(user, items)
    elif len(user_ratings) >= 3:
        # Blend: weight CF more as history grows
        w = len(user_ratings) / 20
        cf_scores = cf_model.predict(user, items)
        cb_scores = cb_model.predict(user, items)
        scores = w * cf_scores + (1 - w) * cb_scores
    else:
        # Cold start: content-based + popularity
        cb_scores = cb_model.predict(user, items)
        pop_scores = get_popularity_scores(items)
        scores = 0.6 * cb_scores + 0.4 * pop_scores

    return top_n(scores, n)

The threshold of 20 ratings is not arbitrary. Research on the MovieLens dataset shows that collaborative filtering predictions stabilize around 20 interactions per user. Below that, the signal-to-noise ratio is too low for reliable similarity computation.

More sophisticated hybrids exist. Feature-augmented collaborative filtering adds item attributes as side information to the matrix factorization model, partially solving cold start within a single framework. Two-tower neural architectures (one tower for user features, one for item features, joined by a dot product) have become the industry standard at companies like YouTube, Pinterest, and Spotify. But every one of those architectures evolved from the primitives covered in this tutorial.

A final engineering note: latency matters more than accuracy in production. A model that takes 200ms to generate recommendations will get replaced by a simpler model that takes 15ms, even if the simpler model has 5% lower NDCG. Pre-compute recommendations where possible. Cache aggressively. Use approximate nearest neighbor search (FAISS, Annoy, ScaNN) instead of brute-force similarity computation. The best recommendation is the one that arrives before the user loses patience.

Sources: Real Python: Build a Recommendation Engine | Netflix Prize – Wikipedia

Frequently Asked Questions

How much data do I need before collaborative filtering outperforms content-based filtering?

The crossover point depends on your domain, but a common threshold is roughly 20 interactions per user and 50 interactions per item. Below that, content-based filtering or a popularity baseline typically performs better. On the MovieLens 100K dataset, user-based collaborative filtering reaches competitive RMSE only when users have rated at least 20 items. For production systems, start with content-based and transition to collaborative filtering as your interaction data grows.

Should I use implicit feedback (clicks, views) or explicit feedback (ratings) for my recommendation engine?

Use whatever your users naturally generate in volume. Most modern platforms have far more implicit signals than explicit ratings. The average user rates very few items but clicks on many. Implicit feedback is noisier (a click does not necessarily mean interest) but orders of magnitude more abundant. The trade-off is that implicit feedback requires different algorithms. Standard matrix factorization assumes explicit ratings. For implicit data, use the Alternating Least Squares (ALS) variant with confidence weighting, or treat the problem as a learning-to-rank task.

What is the fastest way to get a recommendation engine into production?

Start with item-based collaborative filtering using pre-computed similarity matrices. It is simple, fast at serving time, and easy to update incrementally. Store the top-50 most similar items for each item in a key-value store like Redis. When a user visits an item page, look up its similar items and filter out anything the user has already seen. This approach handles millions of items with sub-millisecond response times and requires no real-time model inference. Add personalization layers (user embeddings, re-ranking models) only after this baseline is running and measured.

댓글 남기기