Highlights#
- Trained a hybrid LightFM recommender on 3,055 real customer orders at Dishup SRL, reaching Precision@5 of 31.8% against a 9.2% random baseline.
- Held cold-start users at parity with established ones (Precision@5 32.4% on a strict new-user holdout) by feeding engineered content features into the latent-factor model.
- Benchmarked five embedding models on multilingual ingredient names; the winner, OpenAI text-embedding-3-large, classified ingredient categories at 95.5% accuracy vs BERT’s 16%.
- Turned scraped Italian recipes into a typed dataset of 297 dishes and 855 ingredients via schema-constrained LLM extraction (Pydantic models, controlled ingredient vocabulary).
How it works#
flowchart TD
A[("MySQL
Dishup order data")] -->|"7-table CTE
extraction query"| B["Order dataset
3,055 orders / 45 days"]
W["Recipe websites"] -->|"LLM scrapers,
schema-constrained JSON"| R["Recipe dataset
297 dishes, 855 ingredients"]
B --> C["Feature engineering
restaurant-relative prices,
recency-weighted popularity,
time-of-day buckets"]
R --> E["Embedding study
OpenAI / BERT / Gemini / word2vec"]
E --> V["Recipe vectorizer
weighted multi-field embeddings"]
C --> M["LightFM hybrid
WARP loss + side features"]
V --> M
C --> N["PyTorch NCF
feature-tower experiment"]
M --> X["Evaluation
P@5, AUC, reciprocal rank
cold / warm / random splits"]
N --> X
Portfolio Case Study#
Problem. Dishup builds a digital menu and restaurant-management platform used by over 100 Italian restaurants. They wanted the menu to recommend dishes, and the raw material fights you at every step: menu entries are free-text in mixed languages with no ingredient taxonomy, and interaction data is thin because the average diner places fewer than three orders. New diners and new dishes have no history at all. My job in their AI division was to design and build the recommendation system, alone, from data extraction to evaluation.
Approach. The data came out of Dishup’s MySQL database through a single CTE query joining seven tables into a 23-column order dataset: 3,055 orders collected over 45 days from one high-volume restaurant. On top of that I built restaurant-relative features: a €30 dish is premium at a trattoria and mid-range at a fine-dining spot, so prices are z-scored within each restaurant’s own menu. Popularity decays with recency weighting, and every order carries time-of-day context.
For content understanding I first ran a comparison of five embedding models on multilingual ingredient names. I expected them to be roughly interchangeable. They weren’t: BERT and Gemini put all the Italian words in one region and all the Chinese words in another, which is useless for a menu where “uovo” and “egg” must be neighbors. OpenAI’s text-embedding-3-large clustered by ingredient regardless of language and hit 95.5% on downstream ingredient-category classification, so it became the backbone of the dish representation.
So what is a dish, to the model? A concatenated vector of typed blocks: two 1536-dim OpenAI embedding blocks (recipe text and ingredients, with ingredients weighted 2.5x over title, cooking method and tags), one-hot blocks for category, region and difficulty, an ingredient-category distribution, and season and allergen encodings. On the order-data side each dish additionally carries behavior: its restaurant-relative price position and popularity measured four ways (global, recency-weighted, last 30 days, per time-of-day).
Diners are modeled from implicit feedback only. There are no ratings, just what people actually ordered and in what quantity, so the interaction matrix holds normalized order quantities rather than stars. Each user then carries side features: order frequency, dominant dietary preference, recency (days since first and last order), average order value, and a two-axis behavioral segment (high-spender vs conservative, consistent vs variable, derived from spend z-scores and coefficient of variation). Time-of-day buckets sit in the interaction context, which is how the model can tell a lunch regular from a late-night one. The implicit, positive-only signal is also why WARP loss fits: it optimizes ranking directly instead of predicting ratings that don’t exist.
Before committing to a model I scored the dataset’s suitability for collaborative filtering at all — sparsity ratio, interactions per user and per item, temporal coverage — and the sparsity numbers are what ruled out pure CF in favor of a hybrid. I then built three candidates: a cosine-similarity hybrid, a PyTorch neural collaborative filtering model with separate feature towers for time and item signals, and a LightFM factorization model with WARP loss and side features. LightFM won on pragmatics. It was faster to train and tune reliably within the internship window; the NCF was a worthwhile experiment but not worth the complexity on a dataset this small. LightFM does hide its training loop, though, so getting a learning-rate schedule meant driving fit_partial one epoch at a time with a hand-rolled cosine-annealing-with-warm-restarts schedule, and estimating loss for early stopping by sampling a thousand user-item pairs per epoch, since the library reports no loss at all.
What was hard. Normalization kept sabotaging the vectorizer: any standard scaler flattened the component weights I had just applied, erasing the “ingredients matter most” signal. The fix was a variance-preserving scheme that clips outliers at 3 sigma, standardizes, then rescales back toward the original spread so relative magnitudes survive. A second fight was with the premise itself. The plan was cross-restaurant personalization, learn a diner at one restaurant, recommend at another. The data said no: diners almost never appeared at more than one Dishup restaurant, so I reframed the work around single-restaurant depth and treated cross-restaurant transfer as future work. I also built TF-IDF text features and then cut them; on short menu descriptions they added dimensionality faster than signal.
Outcome.
| Metric | Test set | Random baseline |
|---|---|---|
| Precision@5 | 31.8% | 9.2% |
| AUC | 0.831 | — |
| Reciprocal rank | 0.741 | — |
Test precision landed marginally above training precision (31.8% vs 31.1%), so there is no sign of overfitting. The result I care most about is the cold-start split: brand-new users scored Precision@5 of 32.4% with AUC 0.885, slightly better than warm users, which is exactly what the side-feature design was supposed to buy. That new users edge out established ones is less surprising than it sounds: with the average diner ordering fewer than three times, collaborative signal is thin for everyone, so the content features end up carrying warm users almost as much as cold ones. A reciprocal rank of 0.74 means the first relevant dish typically shows up in the top two positions. Model quality was checked from more than one angle: a FAISS nearest-neighbor harness verified that similar recipes actually retrieve each other, and cluster-homogeneity scores tested the vector space against ground-truth category labels.
Why cold start holds: a brand-new diner has no route into the interaction matrix, so their entire signal travels through the engineered content features — the same features that end up carrying warm diners too, which is how new users land at parity.

Non-goals. Evaluation is offline only; no A/B test or live traffic ever ran against the model. There is no feedback loop, the system does not learn from a diner’s reaction to its own recommendations. And the training data comes from one restaurant, so the cross-restaurant claims stay unproven by design. The internship ended with the thesis handoff, and whether Dishup shipped it is not something I can claim.
Links. Read the full thesis (PDF, Google Drive)
Screenshots#





Stack#
Python, pandas, scikit-learn, LightFM, PyTorch, OpenAI + Gemini APIs (embeddings and structured extraction), FAISS, MySQL + Docker, LaTeX.
