1. Data Preprocessing
1.1. Preliminary Inspection
Here is a background about the files obtained from Group Lens.
ML Small Dataset from Group Lens: 100,000 ratings and 3,600 tag applications applied to 9,000 movies by 600 users. Last updated 9/2018.
User Ids
MovieLens users were selected at random for inclusion. Their ids have been anonymized. User ids are consistent between ratings.csv and tags.csv (i.e., the same id refers to the same user across the two files).
Movie Ids
Only movies with at least one rating or tag are included in the dataset. These movie ids are consistent with those used on the MovieLens web site (e.g., id 1 corresponds to the URL https://movielens.org/movies/1). Movie ids are consistent between ratings.csv, tags.csv, movies.csv, and links.csv (i.e., the same id refers to the same movie across these four data files).
Ratings Data File Structure (ratings.csv)
All ratings are contained in the file ratings.csv. Each line of this file after the header row represents one rating of one movie by one user, and has the following format:
userId,movieId,rating,timestamp
The lines within this file are ordered first by userId, then, within user, by movieId.
Ratings are made on a 5-star scale, with half-star increments (0.5 stars - 5.0 stars).
Timestamps represent seconds since midnight Coordinated Universal Time (UTC) of January 1, 1970.
Ratings Table
| userId | movieId | rating | timestamp |
|---|---|---|---|
| 1 | 1 | 4 | 964982703 |
| 1 | 3 | 4 | 964981247 |
| 1 | 6 | 4 | 964982224 |
| 1 | 47 | 5 | 964983815 |
| 1 | 50 | 5 | 964982931 |
| 1 | 70 | 3 | 964982400 |
| 1 | 101 | 5 | 964980868 |
| 1 | 110 | 4 | 964982176 |
| 1 | 151 | 5 | 964984041 |
| 1 | 157 | 5 | 964984100 |
Tags Data File Structure (tags.csv)
All tags are contained in the file tags.csv. Each line of this file after the header row represents one tag applied to one movie by one user, and has the following format:
userId,movieId,tag,timestamp
The lines within this file are ordered first by userId, then, within user, by movieId.
Tags are user-generated metadata about movies. Each tag is typically a single word or short phrase. The meaning, value, and purpose of a particular tag is determined by each user.
Timestamps represent seconds since midnight Coordinated Universal Time (UTC) of January 1, 1970.
Movies Data File Structure (movies.csv)
Movie information is contained in the file movies.csv. Each line of this file after the header row represents one movie, and has the following format:
movieId,title,genres
Movie titles are entered manually or imported from https://www.themoviedb.org/, and include the year of release in parentheses. Errors and inconsistencies may exist in these titles.
Genres are a pipe-separated list, and are selected from the following:
Action| Adventure| Animation| Children’s| Comedy| Crime| Documentary| Drama| Fantasy| Film-Noir| Horror| Musical| Mystery| Romance| Sci-Fi| Thriller| War| Western| (no genres listed)
Movies Table
| movieId | title | genres |
|---|---|---|
| 1 | Toy Story (1995) | Adventure|Animation|Children|Comedy|Fantasy |
| 2 | Jumanji (1995) | Adventure|Children|Fantasy |
| 3 | Grumpier Old Men (1995) | Comedy|Romance |
| 4 | Waiting to Exhale (1995) | Comedy|Drama|Romance |
| 5 | Father of the Bride Part II (1995) | Comedy |
| 6 | Heat (1995) | Action|Crime|Thriller |
| 7 | Sabrina (1995) | Comedy|Romance |
| 8 | Tom and Huck (1995) | Adventure|Children |
| 9 | Sudden Death (1995) | Action |
| 10 | GoldenEye (1995) | Action|Adventure|Thriller |
Links Data File Structure (links.csv)
Identifiers that can be used to link to other sources of movie data are contained in the file links.csv. Each line of this file after the header row represents one movie, and has the following format:
movieId,imdbId,tmdbId
movieId is an identifier for movies used by https://movielens.org. E.g., the movie Toy Story has the link https://movielens.org/movies/1.
imdbId is an identifier for movies used by http://www.imdb.com. E.g., the movie Toy Story has the link http://www.imdb.com/title/tt0114709/.
tmdbId is an identifier for movies used by https://www.themoviedb.org. E.g., the movie Toy Story has the link https://www.themoviedb.org/movie/862.
Use of the resources listed above is subject to the terms of each provider.
Preliminary Observation Summary
Users: 610
Movies: 9,724
Ratings (observed interactions): 100,836
Total Possible interactions: 5,931,640
Sparsity: 98.30%
The main table for ALS is the ratings table (users and their movie ratings) which recorded 100,836 interactions out of a possible 5.9M revealing 98.3& sparsity! The overwhelming majority of possible user-movie interactions are therefore unobserved. This sparsity signals that recommender would have to learn from the smaller subset of observed interactions via methods such a collaborative filtering and ALS.
File formats - csv vs parquet
At NYU, working on the HPC cluster with genuinely large datasets, we always converted raw data to Parquet before doing any real work — it wasn’t optional, since re-parsing large CSVs on every job run was slow enough to notice and annoying enough to avoid. So going into this project, I half-expected to do the same thing here out of habit.
For this project, working with the ml-latest-small dataset (100K rows, ~1MB), I read directly from CSV throughout and never bothered converting to Parquet. Practically it made zero difference — the dataset is small enough that Spark chews through it in seconds regardless of format. And this actually lines up with something we tested in my Big Data class at NYU: Parquet can defeat its own purpose on smaller datasets, because the conversion itself — schema inference, columnar encoding, compression, writing the file back out — has a real cost, and that cost only pays for itself once you’re reading the resulting file many separate times afterward. On a single pass, you’re just paying the overhead with nothing to amortize it against.
I re-ran that exact experiment here to check it still held:
CSV read: 1.738s
Parquet write (once): 2.179s
Parquet read: 1.033s
Parquet total (write+read): 3.212s
Parquet’s read really is faster than CSV’s (1.033s vs 1.738s), but the one-time write cost (2.179s) more than eats up that savings, so total time-to-first-use is nearly double CSV’s. And in this project specifically, everything downstream (train, val, test, model fitting) operates on DataFrames derived once from that first read, sitting in Spark’s lazy execution plan — not repeated fresh reads off disk — so there’s no “many reads” happening to amortize the conversion against in the first place.
If I scaled this project up to ml-25m (as I discuss in the scaling section), converting to Parquet early would be one of the first things I’d add back in, especially if a pipeline is re-reading the same source repeatedly across independent runs rather than once per notebook session.
1.2. Data Splitting Strategy
Before touching any model, I needed to figure out how to split ratings into train/val/test — and this turned out to be less trivial than a standard random split, because of something specific to Rec-Sys as opposed to other ML pipelines.
Potential problems with random split
Problem I - Blindspots
The problem with a plain random split: ALS (and other rec-sys models like popularity baselines, similarity, etc) doesn’t work like a typical regression model that generalizes from features across all users. Instead, it learns one dedicated latent vector per user and per movie, directly from that entity’s own interaction history. If a user’s ratings all happened to land in train by chance (or worse, all in test), there’d be no way to solve for their latent vector at all.
| Scenario | What breaks | Why |
|---|---|---|
| User’s ratings all in test | Training | No data to solve u_i from — genuinely can’t learn a meaningful vector |
| User’s ratings all in train | Evaluation | Vector gets learned fine, but there’s no held-out truth to check it against — that user silently disappears from your reported metrics |
The most closest other ML analogy I can think of is a class imbalance problem in classification. If the random split assigns labels of the minority class only to the test set, the train set will not contain anything to learn how to predict the minority class. Likewise if every class lable of some class class K ends up in train, the pipeline will not have any data held out in val or test to tune or evaluate the model with.
Problem II- Temporal data
A random split would also disturb the temporal nature of rec-sys data, making it quite unrealistic. People’s preferences evolve over time naturally.
Movie-Lens split solution
I confirmed the dataset’s minimum was 20 ratings/user, which meant a careful split could safely guarantee every user shows up in every split. A pure random split couldn’t guarantee that.
| min_ratings | max_ratings | avg_ratings | median_ratings |
|---|---|---|---|
| 20 | 2698 | 165.30491803278687 | 70 |
The fix I used : Per-user, chronological splitting.
For each user individually, sort their ratings by timestamp, then reserve their most recent ~10% for test, the next ~10% for val, and the rest (earliest ~80%) for train. This does two things at once:
- Guarantees every user has some data in every split (given the 20-rating minimum)
- Makes the evaluation realistic — predicting a user’s future ratings from their past ratings, rather than randomly hiding ratings from the middle of their timeline
Key Spark Operations Used Here
-
Window.partitionBy("userId").orderBy("timestamp")Defines a per-user, time-sorted window. This is the backbone of the whole approach. It lets us rank and count things within each user’s own group, without collapsing the individual rows the way a
GROUP BYwould. -
F.row_number().over(w)Assigns
1, 2, 3, ...to each user’s ratings in chronological order, restarting at1for every user because of thepartitionBy. This becomes each rating’s position within that user’s history. -
F.count("*").over(Window.partitionBy("userId"))Computes a group-level total — in this case, how many ratings the user has in total — and stamps that number onto every row belonging to that user. Unlike
GROUP BY, the individual rating rows are preserved. -
F.ceil(...)Used when computing each user’s cutoff size:
user_total * 0.10Rounding up is a small but important detail because it guarantees that even a user with only 20 ratings gets at least 1 rating reserved for validation and 1 for test. Withoutceil(), a low-count user could round down to zero, silently breaking the guarantee that every user has data in every split. -
F.when(...).otherwise(...)Provides Spark’s
if/else-style column logic. Here, it is used to label each rating astrain,val, ortestbased on comparing itsrow_numberagainst the user’s total number of ratings and the calculated cutoffs. -
GROUP BYvs.PARTITION BYIt is tempting to think these two approaches do the same thing, but they don’t. A
GROUP BYcollapses multiple rows into one summary row per group. For example, I usedGROUP BYearlier for EDA when counting the number of ratings per user. By contrast,PARTITION BYinside a window function keeps every original row. It simply computes something relative to that row’s group and attaches the result back to the original data. This distinction is important for the train/validation/test split. I needed every individual rating to survive into one of the three datasets, rather than reducing each user to a single summary row. Therefore, the window-function approach usingPARTITION BYwas the appropriate choice.
Split Result
As a final sanity check, I counted the number of distinct users appearing in each split:
train_users = train.select("userId").distinct().count()
print(f"Users in train: {train_users} (should equal {n_users})")
I ran the same check for validation and test.
The results confirmed that all 610 users appeared in all three sets:
| Split | Number of Users |
|---|---|
| Train | 610 |
| Validation | 610 |
| Test | 610 |
This confirms that the user-level splitting logic successfully preserved every user across all three datasets.
1.3 The Item Cold-Start Problem
Once the split was built, I ran another sanity check that produced a genuinely useful number for the report:
How many distinct movies appear in the test set but were never rated in the training set at all?
train_movies = set(
row.movieId
for row in train.select("movieId").distinct().collect()
)
test_only_movies = (
test
.filter(~F.col("movieId").isin(train_movies))
.select("movieId")
.distinct()
.count()
)
train.select("movieId").distinct() produces a Spark DataFrame containing every unique movieId in train. .collect() brings these IDs to the driver, where the generator expression extracts each movieId and set(...) converts them into a regular Python set called train_movies.
F.col("movieId").isin(train_movies) checks whether each test movie appears in the training set. The ~ negates this condition, so .filter(...) keeps only test rows whose movie never appeared in training.
.select("movieId").distinct().count() reduces these rows to unique movie IDs and counts them.
Result
901 movies out of 9,724 total movies (~9.3% of the catalog) appeared in the test set with zero training history.
This is therefore a real, measured instance of item cold start in our dataset — rather than simply a hypothetical problem.
ALS Cold-Start Strategy
This finding explains why we later use Spark ALS with:
coldStartStrategy="drop"
Those 901 movies have no learned latent vector because they never appeared in the training data.
ALS therefore cannot generate a meaningful prediction for those movie-user combinations, resulting in NaN predictions.
Using coldStartStrategy="drop removes these undefined predictions before calculating RMSE.This keeps the evaluation metric meaningful rather than allowing missing predictions to corrupt the calculation.
However, there is an important distinction between evaluation and production.
Dropping these movies is acceptable for evaluation because we are explicitly measuring model performance only where the model has enough information to make a prediction.
In a production recommender system, simply dropping a movie would not be an ideal solution. A genuinely new movie still needs to be recommendable.
Instead, these cold-start items would need to be assigned some initial information — such as basic popularity or rating-based features — so that the system can begin incorporating them into the recommendation ecosystem.
How this can be handled in a production setting is discussed later in Section X.
1.4 Concrete Data Split Example
Suppose Alice has 5 ratings, with timestamps in chronological order:
[100, 105, 200, 250, 300]
For example, she rated Titanic first, then Notebook, then Matrix, and so on.
1. Creating the Window
w = Window.partitionBy("userId").orderBy("timestamp")
This defines a window. Think of it as:
“Group the data by
userId, then, within each group, sort bytimestamp.”
The window does not compute anything by itself. Instead, it is a reusable sorting and grouping instruction that we can apply to functions below.
It is similar to saving an Excel rule such as:
“For each user, sort their ratings chronologically.”
2. Ranking Each User’s Ratings
ratings_ranked = (
ratings
.withColumn("rn", F.row_number().over(w))
F.row_number().over(w) assigns a sequential number to each row within each user’s group, following the timestamp order defined by w.
For Alice:
- Her earliest rating gets
rn = 1 - Her second-earliest rating gets
rn = 2 - Her third-earliest rating gets
rn = 3 - Her fourth-earliest rating gets
rn = 4 - Her latest rating gets
rn = 5
The important point is that this numbering happens independently for every user.
Because we used:
partitionBy("userId")
the numbering restarts at 1 for every user. We are not assigning one global sequence across the entire dataset.
3. Counting Each User’s Total Ratings
.withColumn(
"user_total",
F.count("*").over(Window.partitionBy("userId"))
)
)
This adds a user_total column showing how many ratings that user has in total.
For Alice, user_total = 5.
The value 5 appears on every one of Alice’s rows, because it is a property of her entire user group.
Unlike the previous window, this one does not use orderBy():
Window.partitionBy("userId")
We are not ranking or ordering anything here. We are simply counting the number of rows belonging to each user and attaching that count to every row for that user.
Result So Far for Alice
| movieId | timestamp | rn | user_total |
|---|---|---|---|
| Titanic | 100 | 1 | 5 |
| Notebook | 105 | 2 | 5 |
| Matrix | 200 | 3 | 5 |
| Avengers | 250 | 4 | 5 |
| Star Wars | 300 | 5 | 5 |
The Split-Point Calculation
Now that every rating has a sequential number and we know how many ratings each user has, we can determine where the train, validation, and test sets should begin.
4. Calculating the Test Cutoff
ratings_split = ratings_ranked.withColumn(
"test_cutoff",
F.ceil(F.col("user_total") * 0.10)
)
For Alice:
user_total = 5
test_cutoff = ceil(5 × 0.10)
= ceil(0.5)
= 1
Therefore, Alice’s most recent 1 rating is reserved for the test set.
The use of ceil() is important.
It guarantees that even users with a small number of ratings receive at least one rating in the test set.
Without ceil(), a calculation such as:
5 × 0.10 = 0.5
could be truncated to 0, leaving Alice with zero test ratings.
5. Calculating the Validation Cutoff
).withColumn(
"val_cutoff",
F.ceil(F.col("user_total") * 0.10)
)
We apply the same logic for the validation set.
For Alice:
val_cutoff = ceil(5 × 0.10)
= ceil(0.5)
= 1
So Alice will also have 1 rating reserved for validation.
6. Assigning the Train, Validation, and Test Labels
We now use the row number and the cutoff values to assign each rating to a split.
Test Set
.withColumn(
"split",
F.when(
F.col("rn") > F.col("user_total") - F.col("test_cutoff"),
"test"
)
For Alice:
user_total - test_cutoff
= 5 - 1
= 4
The condition therefore becomes:
rn > 4
Only Alice’s row with rn = 5 satisfies this condition.
Therefore, her latest rating is assigned to the test set.
Validation Set
.when(
F.col("rn") >
F.col("user_total") - F.col("test_cutoff") - F.col("val_cutoff"),
"val"
)
For Alice:
5 - 1 - 1 = 3
So the condition becomes:
rn > 3
However, the test condition is checked first.
Therefore:
rn = 5→ already assigned totestrn = 4→ satisfiesrn > 3, so assigned toval
Alice’s second-most-recent rating therefore becomes her validation rating.
Training Set
Finally:
.otherwise("train")
)
Any rating that has not already been assigned to test or val is assigned to train.
For Alice, that means:
rn = 1→ trainrn = 2→ trainrn = 3→ train
These are her three earliest ratings.
Final Result for Alice
| movieId | timestamp | rn | split |
|---|---|---|---|
| Titanic | 100 | 1 | train |
| Notebook | 105 | 2 | train |
| Matrix | 200 | 3 | train |
| Avengers | 250 | 4 | val |
| Star Wars | 300 | 5 | test |
This gives us exactly the desired time-based split:
- Earliest ratings → Train
- Second-most-recent ratings → Validation
- Most recent ratings → Test
The use of ceil() also ensures that users are not left with zero ratings in either validation or test, even when they have very few interactions.
In this example, Alice has only 5 ratings, so the 10% calculation would normally produce 0.5. Using ceil() turns that into 1, ensuring that she gets at least one rating in each held-out split.
Note: In the actual MovieLens dataset, the minimum number of ratings per user is 20, so the same logic applies comfortably to every user.
Why partitionBy("userId") Is So Important
This entire process runs independently for every user.
For example, Alice’s ratings are ranked:
1, 2, 3, 4, 5
But another user might have 20 ratings:
1, 2, 3, ..., 20
And another user might have 100 ratings:
1, 2, 3, ..., 100
The numbering, counting, and split calculation are all performed within each user’s own group.
This is exactly what Window.partitionBy("userId") ensures. We never mix Alice’s ratings with another user’s ratings. Each user’s interaction history is treated independently, allowing us to create a personalized chronological train/validation/test split for all 610 users simultaneously.