A collaborative filtering movie recommendation system built on data scraped from SensCritique, a French cultural review platform.
🎬 Live demo: try the model in your browser → MovieRecommender on HuggingFace Spaces Paste a Letterboxd/Senscritique export (or rate a few films by hand) and get personalized recommendations.
The full approach behind each stage : scraping strategy, filtering thresholds, model selection, IPS tuning, and evaluation protocol is documented in Rendu.pdf.
This project builds a user × film rating matrix from ~46M SensCritique ratings, then trains a collaborative filtering model to find correlations between users' tastes and generate personalized movie recommendations.
The pipeline has four stages:
- Scraping - collecting ratings via the SensCritique GraphQL API
- Exploration & Preprocessing - cleaning and filtering the dataset (see the notebook)
- Modeling - matrix factorization (ALS from scratch) with popularity-debiasing (IPS)
- Recommendation - interactive CLI + deployed web app
The ~5,300 films in the dataset were sourced from the collection of Moizi, a well-known cinephile user on SensCritique.
This choice was intentional. Common alternatives - scraping from "Top Films" rankings or genre lists - introduce significant biases:
- They over-represent critically acclaimed or popular films
- They systematically exclude niche, older, or less-rated films
- The resulting rating matrix reflects taste for good films, not taste in general
Using a single cinephile's broad and eclectic collection gives a more diverse and representative film corpus while remaining manageable in size.
After scraping ratings for all films in that collection:
| Metric | Value |
|---|---|
| Raw ratings | ~47.5 million |
| Unique users | ~582,000 |
| Unique films | ~5,336 |
| Raw sparsity | ~98.5% |
After filtering (users with ≥ 20 ratings, films with ≥ 50 ratings, and removing the seed user):
| Metric | Value |
|---|---|
| Filtered ratings | ~46 million |
| Unique users | ~207,000 |
| Unique films | ~5,086 |
| Filtered sparsity | ~92.5% |
The threshold of 20 minimum ratings per user was chosen because it removes a large portion of sparse/inactive users (56% of users) while retaining 96.8% of all ratings - a good trade-off.
The recommender is a matrix factorization model trained with Alternating Least Squares (ALS), implemented from scratch in NumPy (models/ALS.py).
- Closed-form ALS updates alternating user factors
Pand item factorsQ. - Iterative biases (global mean
μ, user biasbᵤ, item biasbᵢ) , with a separable bias regularization (reg_bias).
Rating data is heavily skewed toward popular films, so a naive model just re-recommends blockbusters. To counter this, the loss is reweighted with Inverse Propensity Scoring (IPS): each item's contribution is scaled by (1 / pᵢ)^α, where pᵢ is the item's observation propensity (popularity). The ips_alpha exponent tempers extreme weights.
Evaluated with models/bench_als.py (single ALS config) and models/bench_ips.py (ALS vs ALS + IPS).
Rating prediction - held-out test split, 1–10 scale:
| Metric | Value |
|---|---|
| Train RMSE | 1.14 |
| Test RMSE | 1.24 |
Ranking - rank a held-out positive film against sampled negatives:
| Protocol | Metric | Value |
|---|---|---|
| A - test positives + 100 negatives | NDCG@10 | 0.42 |
| A - test positives + 100 negatives | MAP@10 (rating ≥ 7) | 0.31 |
| B - 1 positive + 99 negatives (NeuMF leave-one-out) | HR@10 | 0.55 |
| B - 1 positive + 99 negatives (NeuMF leave-one-out) | NDCG@10 | 0.36 |
With one positive among 100 candidates, a random ranker scores HR@10 ≈ 0.10. The model's 0.55 shows it captures real taste signal rather than just popularity.
recommend.py is an interactive CLI that loads the trained model and serves top-10 recommendations for a new user via ALS fold-in (a cold-start user is folded into the latent space from their own ratings; the IPS weights are not applied to a single user's individual ratings). Two modes:
- Manual - type a film title + a rating /10, repeat, get recommendations.
- CSV import - drop a Letterboxd export (
Title, Year, Directors, Rating10, WatchedDate); titles are matched to SensCritique IDs viadata/processed/title_mapping.csv.
Below COLD_START_THRESHOLD ratings, a weighted cosine-to-centroid scoring is used (the IPS-trained model lacks signal for an ALS fold-in on very few ratings); above it, a classic ALS fold-in.
- All data scraped is publicly visible on SensCritique without authentication (ratings are public by default).
- robots.txt compliance. SensCritique's
robots.txt(accessible athttps://www.senscritique.com/robots.txt) does not restrict general-purpose crawlers from accessing the pages targeted by this scraper. - No personal data beyond anonymized user IDs and numerical ratings is collected or stored.
- The scraper uses rate limiting (configurable delays between requests) to avoid overloading the platform's servers.
- I reached out to SensCritique by email to ask for explicit permission to use their data for this non-commercial research project. I did not receive a response.
- Data is not redistributed, only the scraping and modeling code is shared.
Requires Python 3.12
python3.12 -m venv .venv
.venv/bin/pip install requests python-dotenv pandas "numpy<2" scipy scikit-learn scikit-surprise threadpoolctlThe SensCritique API requires a bearer token. To get yours:
- Log in to senscritique.com in your browser
- Open DevTools → Network tab
- Trigger any page load and find a request to
https://apollo.senscritique.com/ - In the request headers, copy the value of the
authorizationfield
Then create a .env file at the root of the project:
SC_TOKEN=Bearer <your_token_here>
/!\ Tokens expire. If the scraper suddenly returns 0 ratings for 10 consecutive films, your token has likely expired - refresh it using the steps above.
python scraper/collection.pyThis fetches all film IDs from the target user's collection and saves them to data/raw/film_ids.json.
python scraper/scrape_all.pyThis iterates over all film IDs and scrapes ratings from the GraphQL API. Ratings are appended to data/raw/all_ratings.csv, and progress is tracked in data/raw/done_ids.json, so the scraper can be safely interrupted and resumed.
All data exploration, filtering decisions, and sparse matrix construction are documented in:
notebook/Films.ipynb
It covers:
- Rating distribution
- Notes per user / per film (with log-scale histograms)
- Sparsity analysis
- Threshold selection for filtering
- Construction of the
scipy.sparseCSR matrix used as model input
Then run the preprocessing pipeline (filter, encode, build sparse matrix):
.venv/bin/python prepare_data.py# Sanity-check SVD on a 500K sample (SGD baseline)
.venv/bin/python -m models.svd_sanity_check
# Benchmark a single ALS configuration (RMSE + ranking)
.venv/bin/python -m models.bench_als
# Compare ALS baseline vs ALS + IPS popularity debiasing
.venv/bin/python -m models.bench_ips
# Get recommendations (manual input or Letterboxd CSV)
.venv/bin/python recommend.py