Building a Recommender System from Scratch

Netflix suggests your next binge. Spotify fills your playlist. Amazon adds things to your cart before you even know you want them. All of that is powered by recommender systems: algorithms that predict what a specific person will like based on what they and people similar to them have done before.

For a university project (the PROP subject at FIB, basically a semester-long data structures and algorithms project), my team of four built one entirely from scratch in Java. No scikit-learn, no Spark MLlib, no pre-trained embeddings. Just algorithms, linear algebra, and an unreasonable number of CSV files.

This post explains how recommender systems work conceptually, then walks through the specific algorithms we implemented.

The Three Families

There are three main approaches to recommendation, and understanding them is key to understanding any real-world system.

Collaborative Filtering

"People who are similar to you liked this."

The idea: if user A and user B have rated a bunch of movies similarly (they both love sci-fi, both hate romantic comedies), then A's opinion on a movie B has watched is probably a good predictor of B's opinion. Users are implicitly collaborating to produce recommendations for each other — none of them agreed to this, but it works.

The catch is the cold start problem: a brand new user with zero ratings can't be matched to anyone. Real systems deal with this by asking new users to rate a few seed items on signup ("How do you feel about The Godfather?").

Content-Based Filtering

"You liked X, which has properties P. Item Y also has properties P."

Instead of looking at other users, this approach looks at the attributes of items themselves. If you rated a jazz album highly and another one exists with similar tempo, instrumentation, and era, the system recommends it — without knowing anything about other users at all.

The catch here is the filter bubble: you only ever discover things you already know you like. Content-based systems rarely surprise you.

Hybrid

Mix both. Use collaborative filtering when the user has enough history, use content-based when they don't, and combine signals to get the best of both worlds. This is what most production systems actually do.

Our implementation supports all three: collaborative-only, content-based-only, and a hybrid mode that intersects both results.

The System We Built

The project was called subgrup-prop6-5. It was written in Java, with a Swing GUI and a terminal mode. The domain was intentionally generic: items have typed attributes defined by a schema file, users have demographic info plus a list of ratings, and the whole thing is persisted as flat CSV files.

The key classes:

  • Usuari — a user with id, age, sex, and a list of Valoracio (ratings)
  • GenericItem — an item with arbitrary typed fields loaded from CSV
  • Valoracio — a rating linking a user to an item with a score from 0 to 10
  • Kneighbours, Kmean, SlopeOne, DCG — the algorithm implementations

Let me walk through each algorithm.

Algorithm 1: K-Means Clustering

Used for collaborative filtering. The goal is to group users who have similar tastes so we can find "people like you" efficiently.

K-Means works like this:

  1. Pick K random points as initial cluster centers (centroids)
  2. Assign every user to their nearest centroid
  3. Recompute each centroid as the mean position of all users assigned to it
  4. Repeat until no user switches clusters (convergence)

Here's the fun part: each user is represented as a vector. We used two strategies depending on how much rating data we had:

  • If the user has few ratings: a 2D vector [sex_ordinal, age] (demographic)
  • If the user has many ratings: an N-dimensional vector where each dimension is the user's rating of item i (defaulting to 5.0 for unrated items)

The algorithm itself is beautifully simple. Here's the core of our Kmean.java:

while (ready() != 0) {
    for (int i = 0; i < vectors.size(); i++) {
        float minDistance = Float.POSITIVE_INFINITY;
        int ki = 0;
        for (int k = 0; k < clusters.size(); k++) {
            float d = vectors.get(i).euclideanDistance(clusters.get(k).centroid);
            if (d < minDistance) { ki = k; minDistance = d; }
        }
        asignacions.set(i, ki);
        clusters.get(ki).addPartialCentroid(vectors.get(i));
    }
    updateCentroids(vectors);
}

Try it yourself — drag the K slider and press Run to watch K-Means converge on random user clusters:

Iteration 0

Each colored square is a centroid. The dots are users. Notice how the centroids migrate to the center of mass of their group with each step, and how a well-chosen K produces tight, clean clusters.

Once converged, finding "users like you" becomes trivial: look up which cluster the target user belongs to, and return everyone else in that cluster.

Algorithm 2: KNN for Content-Based Filtering

For content-based recommendations, we used K-Nearest Neighbours on items. Given an item the user already rated, we find the K items in the catalog most similar to it (by euclidean distance in feature space), and suggest those.

Vec self = target.getVector(strategy);
for each item in catalog:
    distance = self.euclideanDistance(item.getVector(strategy))
// sort by distance, keep top K

Items are also represented as vectors via the VariableVector class, which handles mixed attribute types (integers contribute directly, strings are label-encoded, booleans become 0/1). This is where the generic domain design paid off: the same KNN code works regardless of what the items actually are.

In practice, we query KNN for each item the user has rated (sorted by their rating, so we start from their favorites), accumulate the nearest neighbors, and deduplicate. The result is a ranked list of unseen items that are similar to things they already liked.

Algorithm 3: Slope One (Rating Prediction)

Once collaborative filtering gives us a candidate item from a cluster mate, we need to predict how much this specific user would like it. That's where Slope One comes in.

The intuition is beautifully simple: if on average, users rate item J two points higher than item I, and a new user rated item I a 6, predict they'd give item J an 8.

The deviation between two items is:

dev(J, I) = mean over all users u who rated both: (rating_u(J) - rating_u(I))

And the predicted rating for user u on item J is:

predicted(u, J) = mean_rating(u) + mean over all items i that u rated: dev(J, i)

No matrix factorization, no gradient descent. It trains in O(items × users) and updates incrementally as new ratings come in. For a university project this was perfect — it's easy to understand, implement, and verify.

Our SlopeOne.java implementation:

public static float notaEsperada(Usuari u, GenericItem it) {
    // S = items rated by u (excluding it)
    float avgRating = average(u.getValoracions());
    float sumDev = 0;
    for (GenericItem i : S) {
        sumDev += desviacion(it, i);
    }
    return Math.min(Math.round(avgRating + sumDev / S.size()), 10.0f);
}

The desviacion function iterates over all users who have rated both items and computes the mean difference. Simple, readable, and it works.

Measuring Quality: nDCG

How do you know if the recommendations are actually good? You need a metric.

We implemented nDCG (Normalized Discounted Cumulative Gain). The idea is that position matters: a perfect recommender puts the best items first. An item ranked 10th is worth less than the same item ranked 1st, even if both appear in the list.

The formula:

DCG = sum_i (2^relevance_i - 1) / log2(i + 1)
nDCG = DCG / IDCG

Where IDCG is the "ideal DCG" — what you'd get if the list were perfectly sorted by actual relevance. The result is a score between 0 and 1. We show this score to the user alongside every recommendation list so they know how much to trust it.

The Hybrid Mode

We had three modes (controlled by a metode integer):

  • Mode 0: Collaborative only (K-Means + Slope One)
  • Mode 1: Content-based only (KNN on items)
  • Mode 2: Hybrid

The hybrid works by running collaborative filtering first to get a candidate set, then running KNN separately, and keeping only the items that appear in both results. It's conservative but precise: you only recommend things that look good from two independent angles. If an item passes both filters, you can be fairly confident about it.

// after both methods run...
for (int it = 0; it < itemsRecomanats.size(); ++it) {
    boolean found = false;
    // check if this collaborative result also appears in content-based results
    for (int i = 0; i < totsItems.size(); i++) {
        if (itemsRecomanats.get(it) == totsItems.get(i).getObject()) {
            found = true; break;
        }
    }
    if (!found) { itemsRecomanats.remove(it); --it; }
}

Running It

If you want to try it yourself (assuming you have the prop.jar and a dataset):

./run.sh

The terminal application asks you to load an item CSV and a user file, then log in. You can rate items and ask for recommendations specifying K (how many you want) and the method (0, 1, or 2). The Swing GUI (GUIApplication) gives you the same flows with actual buttons and panels.

The persistent state is stored in flat text files (one user per line, serialized as CSV). Nothing fancy, but it works.

What I Actually Learned

Building this was a good reminder that textbooks make ML look cleaner than it is.

Vector representation is harder than it sounds. We needed a VariableVector class that handles mixed types (strings, integers, booleans, floats) and encodes them all into a consistent euclidean space. Getting the encoding right so that euclidean distance actually means something semantically took real thought.

Cold start is a genuine problem, not a footnote. Users in our dataset had no demographics at all (just unknown, unknown in the CSV), which made the demographic vector useless. K-Means over [sex=unknown, age=0] clusters everyone together.

Slope One breaks down at the edges. If a user has rated only one item, the deviation sum has a single term. If no two users share both items, deviation returns 0 and the prediction collapses to the user's mean. These edge cases need explicit handling.

nDCG is a good sanity check. We used it to verify that the hybrid mode actually outperformed both individual modes on our test set — and it did, which was satisfying.

Recommender systems are one of those areas where the gap between "I understand the concept" and "I can implement something that actually works" is bigger than it first appears. This project bridged that gap, and the algorithms involved (K-Means, KNN, Slope One) are all things I've reached for again in other contexts since.