finished for now · dec 2025 - jul 2026
Shortlist
Pick one to five movies you already love and Shortlist builds a fresh set of recommendations from the overlap in your taste. I trained that exact flow, built the Go API, and shipped the full product.
what I wanted
Most movie sites hand everyone the same trending list. I wanted to choose a few movies I already love and get a shortlist that actually feels connected to my taste. I also wanted the model to become a real product, not another notebook I ran once and forgot about.
what I built
The live app starts by asking for one to five favorites. It blends what the model learned about those movies into a temporary taste profile, then searches the full catalog for the closest matches. No account or personal data is needed.
- a two-tower model that learns stored viewers and the live one-to-five favorite flow together
- exact retrieval across 89,585 movies in the Go API
- search, similar movies, fresh batches, details, and locally saved shortlists
- TMDB posters and metadata so the results feel like movies, not rows in a dataset
- a responsive Next.js app that works without a login


Here is the path from ratings to the page:
MovieLens ratings + TMDB metadata
↓
Python training and evaluation
↓
Checked movie and viewer embeddings
↓
Go retrieval API
↓
Next.js app
Python handles training and evaluation. One training objective learns stored viewer profiles. A second mixes one to five movies someone liked and learns to find another movie they liked. That second objective uses the same taste profile the live app builds.
The exported serving bundle contains compact movie and viewer vectors, the movies each viewer already saw, and checksums so the API never quietly loads mismatched files. Go scans the learned movie space, filters seen or dismissed titles, and returns the best matches. Next.js turns those results into something easy to explore.
what was difficult
The original 0.229 Recall@100 result measured stored viewer profiles, but the public app asks a different question: what can the model learn from a handful of favorite movies? That was a useful model result, but it was not the right main claim for the product.
I added a separate test that follows the real visitor flow, and built it to be hard to fool. A single global time cutoff freezes the data, so nothing that happened after that moment can leak backward into training. The test users are drawn only from people who appear entirely after it, cold users the model has never seen in any form, and their earliest one, three, or five favorites become the input while the movies they liked later become the answer. Those cold users are split into a validation cohort, which absorbs every design decision, and a 7,060-user test cohort that was scored exactly once. Popularity is given the identical users and the identical candidate set, so the comparison is like for like rather than a baseline set up to lose.
That protocol is the part I would defend in an interview. A random split would let the model study a user's future and quietly inflate every number here.
Training needed work too. User-balanced batches stop the most active viewers from taking over, repeated target movies are collapsed inside each batch, and a second loss directly trains the favorite-movie mix used by the app. The bigger challenge was still making sure the live Go API served the same logic I evaluated in Python. I had to define a reliable cross-language data contract, filter a viewer's history, support new visitors with no account, and keep each fresh batch genuinely fresh.
That work also caught a serving bug that parsed decimal rating counts as zero. Fixing it changed fallback quality and search ordering, including putting the actual Arrival ahead of unrelated matches. It was a good reminder that the last mile can undo a strong model if the data contract is wrong.
what offline evaluation supports
- 0.331 Recall@100, compared with 0.228 for popularity, a 45% relative lift
- 14.5% catalog coverage at 100 results, compared with 0.12% for popularity
- 84.1% HitRate@10 on the untouched five-favorite test, compared with 73.8% for popularity; at one favorite, it was effectively tied at 82.1% versus 81.9%
- 0.237 Recall@100 for stored viewers, compared with 0.127 for popularity
- 89,585 movies and 199,378 taste profiles in the serving bundle today, grown from the 87,585-movie bundle every result above was measured against
- 4.0 ms median and 5.6 ms p95 locally for known-viewer requests
- 28.8 million seen viewer-movie pairs available for history filtering
I wrote up what those numbers do and do not prove in my recommender scored 84% and still gave bad answers: the popularity baseline nearly matches the headline metric, and the query that broke the model in production was one the offline protocol could not produce.
These results support retrieval quality on an active-user test cohort. They do not yet show that real visitors prefer the recommendations: there is no online A/B test, satisfaction measure, or viewing-completion outcome. Recall and catalog coverage are the load-bearing evidence; HitRate is useful context, but the cohort's large truth sets make it a generous bar.
Those measurements and their exact test protocol are committed with the code. CI runs the ML and export tests, Go tests, frontend build, browser flows, mobile checks, and accessibility checks. The earlier LightGBM ranker still has its own 11.8% offline lift, but the public app now serves the learned two-tower retrieval path directly.
what works now
The live app lets anyone build a taste profile, get a personal shortlist, save good picks, dismiss misses, ask for more movies like one result, and pull a fresh batch. Existing MovieLens viewers can also try recommendations from their historical ratings. The repository includes the training, evaluation, serving, and product code behind the full flow.
