erin.dev

Back to home

☕️ ColdStart Cafe

An interactive exploration of the cold-start problem in recommendation systems

Source Code

Live Demo

Every recommendation system faces a bootstrapping dilemma: how do you recommend things to a new user when you know nothing about them? This is the cold-start problem — and ColdStart Cafe lets you experience it firsthand.

Four recommendation algorithms compete in real time, starting from zero knowledge. You feed them signals — movie ratings, demographics, genre preferences, viewing history — and watch their accuracy shift with each new data point. Real-time metrics (precision@10, recall@10, NDCG@10) quantify the recovery, while an AI narrator explains what's happening and why at each step.

In Challenge Mode, the tables turn: study a real user's profile, pick 10 movies you think they'd love, and see if you can out-recommend the machines.

Built as a full-stack application with a Python/FastAPI backend running four distinct ML algorithms (popularity baseline, content-based filtering, collaborative filtering via SVD, and an adaptive hybrid ensemble) against the MovieLens 100K dataset, and a React/TypeScript frontend with animated visualizations, streaming AI narration, and a cafe-themed UI.

🛠️ Tech Stack

Python 3.12FastAPIscikit-surprise (SVD)scikit-learnPydanticReact 19TypeScriptViteChakra UI v3RechartsFramer MotionAnthropic Claude APIServer-Sent Events (SSE)Docker ComposeGitHub Actions CI

🚀 Features

  • Four competing algorithms — Popularity (Bayesian average), Content-Based (cosine similarity on genre vectors), Collaborative Filtering (SVD via scikit-surprise), and Hybrid (adaptive weighted ensemble) — each with distinct cold-start behavior

  • Progressive signal feeding — add ratings, demographics, genre preferences, and viewing history one at a time and watch algorithms re-compute in real time (~600ms per step)

  • Real-time metrics — precision@10, recall@10, and NDCG@10 computed against a hidden ground-truth user, visualized as animated line charts

  • Two-tier AI narration — 13 cafe-themed templates handle common transitions instantly; edge cases fall back to Claude API streaming via SSE, with graceful degradation if no API key is configured

  • Challenge Mode — study a target user's demographics and 3 seed ratings, pick 10 movies, then get scored against all four algorithms with an AI-generated verdict

  • Animated race visualization — spring-physics algorithm timeline with sparkle bursts on big jumps, signal filmstrip with entrance animations, and tabbed top-10 recommendation cards

  • Session management — in-memory state with TTL eviction, sliding-window rate limiting, and max concurrent session enforcement

  • Dockerized deployment — multi-stage builds, nginx SPA routing with API proxy, health checks, and CI pipeline running 146 backend tests + frontend type-checking

🧗‍♂️ Challenges

  • Designing a meaningful evaluation without data leakage — The system selects a random ground-truth user with 20+ ratings and 3+ genre diversity, holds out their high-rated movies as the "relevant set," then measures algorithm accuracy against it. The ground truth is stored server-side in a separate dict (never serialized to the client) to prevent Pydantic from accidentally exposing it in API responses.

  • Making SVD work with minimal data — scikit-surprise's SVD needs a ratings matrix, but a brand-new user has no entries. The solution uses demographic-based neighbor initialization: find users with similar age/gender/occupation, seed the trainset with their ratings, then train a 10-epoch SVD. This gives collaborative filtering something to work with even at 1-2 signals.

  • Streaming narration across two animation modes — Template narrations arrive as a complete string (needs local typing animation), while LLM narrations stream token-by-token via SSE (needs incremental rendering). Both useTypingAnimation and useNarrationStream hooks are always called (React Rules of Hooks), with skip/enabled flags controlling which is active — avoiding conditional hook violations.

  • Multi-worker session routing — In-memory sessions are process-local, so running uvicorn with multiple workers caused 404s when requests routed to the wrong process. Diagnosed via Railway deploy logs and resolved by pinning to a single async worker, which handles portfolio-level traffic without issue.

  • NumPy ABI compatibility — scikit-surprise 1.1.4's Cython extensions are compiled against NumPy 1.x, so numpy>=2.0 breaks at import time. Pinned numpy>=1.26.0,<2.0.0 and python>=3.12,<3.13 to stay within the compatibility window.

🧠 Learnings

  • Protocol-based interfaces over inheritance — Algorithms are stateless module-level functions satisfying a Recommender Protocol (structural subtyping). No base class, no __init__, no shared state. This made testing trivial — each algorithm is a pure function of (state, data) -> RecommenderResult.

  • Two-tier architecture for LLM features — Templates handle the predictable cases (first rating, cold-start intro) instantly and for free; the LLM handles the long tail (unusual signal combinations, narrative variety). This pattern — deterministic fast path + LLM fallback — kept the app responsive while still feeling intelligent.

  • Separation of HTTP, orchestration, and storage layers — Routers handle HTTP concerns (parsing, status codes), engines handle business logic (signal application, algorithm execution, metric computation), and storage handles state (thread-safe session dict with TTL). This layering made it possible to test algorithms in isolation, test API behavior separately, and swap storage without touching business logic.

  • Designing for graceful degradation — No API key? Templates still work. Claude API errors? Static fallback narration. Session expired? Cafe-themed recovery UI with a retry CTA. Rate limit hit? 429 with Retry-After header. Every failure mode has a designed experience, not just an error message.

  • Docker build context vs. Dockerfile location — In a monorepo, the build context (where COPY paths resolve from) and the Dockerfile location are independent. Railway builds from the repo root, so COPY pyproject.toml fails — it needs COPY backend/pyproject.toml. A subtle but common monorepo deployment pitfall.