"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"ratings['rating'].value_counts().sort_index().plot(kind='bar', color='black', figsize=(5, 3))\n",
"plt.title('Rating distribution')\n",
"plt.xlabel('Rating')\n",
"plt.ylabel('Count')\n",
"plt.xticks(rotation=0)\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "ca7f1d26",
"metadata": {},
"source": [
"## 2. Collaborative filtering\n",
"\n",
"\n",
"\n",
"Collaborative filtering recommends items based on the collective behaviour of all users. It does not use item content; it relies entirely on the pattern of who rated what and how highly.\n",
"\n",
"\n",
"\n",
"### 2.1. Memory-based collaborative filtering\n",
"\n",
"\n",
"\n",
"Memory-based collaborative filtering compares items directly using a similarity metric computed from the rating matrix. No model is trained; all computation happens at query time from the stored similarity matrix.\n",
"\n",
"\n",
"\n",
"Similarity between two items is computed with cosine similarity over their rating vectors across all users:\n",
"\n",
"\n",
"\n",
"$$\\text{sim}(i, j) = \\frac{r_i \\cdot r_j}{\\|r_i\\| \\|r_j\\|}$$\n",
"\n",
"\n",
"\n",
"A value of 1 means identical rating patterns; 0 means no shared users."
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "2b80aaa1",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"User-item matrix shape: (943, 1682)\n"
]
},
{
"data": {
"text/html": [
"
"
],
"text/plain": [
" title \\\n",
"0 Aladdin and the King of Thieves (1996) \n",
"1 Aladdin (1992) \n",
"2 Goofy Movie, A (1995) \n",
"3 Jungle2Jungle (1997) \n",
"4 Angels in the Outfield (1994) \n",
"\n",
" genres similarity \n",
"0 Animation, Children's, Comedy 1.0000 \n",
"1 Animation, Children's, Comedy, Musical 0.8660 \n",
"2 Animation, Children's, Comedy, Romance 0.8660 \n",
"3 Children's, Comedy 0.8165 \n",
"4 Children's, Comedy 0.8165 "
]
},
"execution_count": 16,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def content_recommendations(movie_id, n=5):\n",
" '''Return the top n movies with the most similar genre profile.'''\n",
"\n",
" target_row = movies.loc[movies['movie_id'] == movie_id, genre_names].values[0]\n",
" target_genres = ', '.join([g for g, v in zip(genre_names, target_row) if v == 1])\n",
"\n",
" scores = genre_similarity_df[movie_id].sort_values(ascending=False).iloc[1:n + 1]\n",
"\n",
" results = []\n",
"\n",
" for mid, score in scores.items():\n",
" row = movies.loc[movies['movie_id'] == mid, genre_names].values[0]\n",
" genres = ', '.join([g for g, v in zip(genre_names, row) if v == 1])\n",
" results.append({'title': get_title(mid), 'genres': genres, 'similarity': round(score, 4)})\n",
"\n",
" print(f'Content-based recommendations for \"{get_title(movie_id)}\"')\n",
" print(f'Target genres: {target_genres}')\n",
"\n",
" return pd.DataFrame(results)\n",
"\n",
"\n",
"content_recommendations(1)"
]
},
{
"cell_type": "markdown",
"id": "5dcfb40c",
"metadata": {},
"source": [
"## 4. Hybrid filtering\n",
"\n",
"\n",
"\n",
"Hybrid filtering combines collaborative and content-based scores into a single ranking. Because the two score distributions differ, both are normalised to $[0, 1]$ before combining. The parameter $\\alpha$ controls the balance between the two approaches:\n",
"\n",
"\n",
"\n",
"$$s_{\\text{hybrid}} = \\alpha \\cdot \\hat{s}_{\\text{collab}} + (1 - \\alpha) \\cdot \\hat{s}_{\\text{content}}$$\n",
"\n",
"\n",
"\n",
"Setting $\\alpha = 1$ reduces to pure collaborative filtering; $\\alpha = 0$ reduces to pure content-based filtering."
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "f474dbd3",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Hybrid recommendations for \"Toy Story (1995)\" (alpha=0.5):\n"
]
},
{
"data": {
"text/html": [
"
\n",
"\n",
"
\n",
" \n",
"
\n",
"
\n",
"
title
\n",
"
hybrid_score
\n",
"
\n",
" \n",
" \n",
"
\n",
"
0
\n",
"
Aladdin (1992)
\n",
"
0.8291
\n",
"
\n",
"
\n",
"
1
\n",
"
Willy Wonka and the Chocolate Factory (1971)
\n",
"
0.7677
\n",
"
\n",
"
\n",
"
2
\n",
"
Beauty and the Beast (1991)
\n",
"
0.7106
\n",
"
\n",
"
\n",
"
3
\n",
"
Lion King, The (1994)
\n",
"
0.7096
\n",
"
\n",
"
\n",
"
4
\n",
"
Beavis and Butt-head Do America (1996)
\n",
"
0.7015
\n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" title hybrid_score\n",
"0 Aladdin (1992) 0.8291\n",
"1 Willy Wonka and the Chocolate Factory (1971) 0.7677\n",
"2 Beauty and the Beast (1991) 0.7106\n",
"3 Lion King, The (1994) 0.7096\n",
"4 Beavis and Butt-head Do America (1996) 0.7015"
]
},
"execution_count": 17,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def normalize(series):\n",
" '''Scale a series to [0, 1].'''\n",
"\n",
" lo, hi = series.min(), series.max()\n",
"\n",
" return (series - lo) / (hi - lo) if hi > lo else series * 0\n",
"\n",
"\n",
"def hybrid_recommendations(movie_id, n=5, alpha=0.5):\n",
" '''Return the top n movies by weighted combination of collab and content scores.'''\n",
"\n",
" collab = item_similarity_df[movie_id] if movie_id in item_similarity_df.columns else pd.Series(dtype=float)\n",
" content = genre_similarity_df[movie_id]\n",
"\n",
" common = collab.index.intersection(content.index)\n",
" common = common[common != movie_id]\n",
"\n",
" hybrid = alpha * normalize(collab[common]) + (1 - alpha) * normalize(content[common])\n",
" top = hybrid.sort_values(ascending=False).iloc[:n]\n",
"\n",
" results = [{'title': get_title(mid), 'hybrid_score': round(score, 4)} for mid, score in top.items()]\n",
" print(f'Hybrid recommendations for \"{get_title(movie_id)}\" (alpha={alpha}):')\n",
"\n",
" return pd.DataFrame(results)\n",
"\n",
"hybrid_recommendations(1)"
]
},
{
"cell_type": "markdown",
"id": "745c19ab",
"metadata": {},
"source": [
"## 5. Evaluation\n",
"\n",
"\n",
"\n",
"Evaluating a recommendation system requires simulating the real-world scenario where some ratings are known and others are withheld. A **leave-one-out** strategy holds out one randomly chosen rating per user as the test item and trains the model on the remaining ratings.\n",
"\n",
"\n",
"\n",
"One important detail: only **positively-rated items** (rating ≥ 4) should be held out. If we hold out a movie the user rated 1 or 2 stars, finding it in the top-10 is not a success; the model would be recommending something the user disliked. Users with no positive ratings have no test item and are excluded from the evaluation.\n",
"\n",
"\n",
"\n",
"**Note on production practice:** two common refinements are used in rigorous benchmarks. First, a **time-based split** holds out each user's *most recent* positive interaction rather than a random one, preventing the model from using future data to predict the past. Second, many papers use a **sampled ranking** protocol (the model ranks the 1 test item against 100 randomly sampled unrated items) instead of ranking across all items, which is much cheaper to compute at scale.\n",
"\n",
"\n",
"\n",
"### 5.1. Train/test split"
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "6c25b19a",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Train ratings: 99,058\n",
"Test ratings: 942 (one positively-rated item per user)\n"
]
}
],
"source": [
"# Only hold out positively-rated items; a hit on a disliked movie is not meaningful\n",
"positive_ratings = ratings[ratings['rating'] >= 4]\n",
"held_out = positive_ratings.groupby('user_id').sample(n=1, random_state=315)\n",
"test_ratings = held_out.reset_index(drop=True)\n",
"train_ratings = ratings.drop(index=held_out.index)\n",
"\n",
"print(f'Train ratings: {len(train_ratings):,}')\n",
"print(f'Test ratings: {len(test_ratings):,} (one positively-rated item per user)')"
]
},
{
"cell_type": "code",
"execution_count": 19,
"id": "a4f7705e",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Training predictions shape: (943, 1682)\n"
]
}
],
"source": [
"# Retrain SVD on training data only\n",
"train_matrix = train_ratings.pivot_table(\n",
" index='user_id', columns='movie_id', values='rating'\n",
").fillna(0)\n",
"\n",
"svd_eval = TruncatedSVD(n_components=50, random_state=315)\n",
"\n",
"train_preds = pd.DataFrame(\n",
" np.dot(svd_eval.fit_transform(train_matrix), svd_eval.components_),\n",
" index=train_matrix.index,\n",
" columns=train_matrix.columns\n",
")\n",
"\n",
"print(f'Training predictions shape: {train_preds.shape}')"
]
},
{
"cell_type": "markdown",
"id": "4a7f143b",
"metadata": {},
"source": [
"### 5.2. GetTopN\n",
"\n",
"\n",
"\n",
"`GetTopN` generates the ranked recommendation list for each user: it identifies items the user has not rated in the training data, ranks them by predicted rating, and returns the top-N. It is the bridge between raw model predictions and a usable list of recommendations."
]
},
{
"cell_type": "code",
"execution_count": 20,
"id": "2c38d8f9",
"metadata": {},
"outputs": [],
"source": [
"def get_top_n(predicted_df, train_matrix, n=10):\n",
" '''For each user, return the top-N unrated movie IDs ranked by predicted rating.'''\n",
"\n",
" top_n = {}\n",
"\n",
" for user_id in predicted_df.index:\n",
" rated = train_matrix.loc[user_id][train_matrix.loc[user_id] > 0].index\n",
" preds = predicted_df.loc[user_id].drop(index=rated, errors='ignore')\n",
" top_n[user_id] = preds.sort_values(ascending=False).iloc[:n].index.tolist()\n",
"\n",
" return top_n"
]
},
{
"cell_type": "code",
"execution_count": 21,
"id": "b7b7a33c",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Generated top-10 lists for 943 users\n",
"\n",
"Example: top-10 for user 1:\n",
" Leaving Las Vegas (1995)\n",
" Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1963)\n",
" Dave (1993)\n",
" Batman (1989)\n",
" Tombstone (1993)\n",
" E.T. the Extra-Terrestrial (1982)\n",
" Glory (1989)\n",
" Piano, The (1993)\n",
" My Left Foot (1989)\n",
" Like Water For Chocolate (Como agua para chocolate) (1992)\n"
]
}
],
"source": [
"top_n_recs = get_top_n(train_preds, train_matrix, n=10)\n",
"\n",
"example_user = list(top_n_recs.keys())[0]\n",
"print(f'Generated top-10 lists for {len(top_n_recs)} users')\n",
"print(f'\\nExample: top-10 for user {example_user}:')\n",
"\n",
"for title in [get_title(mid) for mid in top_n_recs[example_user]]:\n",
" print(f' {title}')"
]
},
{
"cell_type": "markdown",
"id": "b0c754dd",
"metadata": {},
"source": [
"### 5.3. Hit rate\n",
"\n",
"\n",
"\n",
"Hit rate measures how often the model's top-N list contains the held-out test item:\n",
"\n",
"\n",
"\n",
"$$\\text{Hit Rate@}N = \\frac{|\\{u : \\text{test item} \\in \\text{top-}N(u)\\}|}{|U|}$$\n",
"\n",
"\n",
"\n",
"Where $U$ is the set of users with a positive test item and $\\text{top-}N(u)$ is the set of recommended items for user $u$. A hit rate of 0.05 at $N = 10$ means 5% of those users had their held-out movie appear in their top-10 list.\n",
"\n",
"\n",
"\n",
"Because we only held out positively-rated items, a hit genuinely means the model surfaced something the user liked. Without this filter, the metric would credit the model for recommending movies the user had already rated poorly.\n",
"\n",
"\n",
"\n",
"Hit rate rewards the model for placing the test item anywhere in the top-N. It is often paired with NDCG (normalised discounted cumulative gain), which additionally rewards higher placements within the list."
]
},
{
"cell_type": "code",
"execution_count": 22,
"id": "0d347d09",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Hit rate @ 10: 0.377\n",
"About 1 in 3 users had their held-out movie in the top-10 list\n"
]
}
],
"source": [
"def hit_rate(top_n_dict, test_df):\n",
" '''Fraction of users whose held-out test movie appears in their top-N list.'''\n",
"\n",
" eligible = test_df[test_df['user_id'].isin(top_n_dict)]\n",
" hits = eligible.apply(lambda row: row['movie_id'] in top_n_dict[row['user_id']], axis=1).sum()\n",
"\n",
" return hits / len(eligible) if len(eligible) > 0 else 0.0\n",
"\n",
"\n",
"hr = hit_rate(top_n_recs, test_ratings)\n",
"print(f'Hit rate @ 10: {hr:.3f}')\n",
"print(f'About 1 in {1/hr:.0f} users had their held-out movie in the top-10 list')"
]
},
{
"cell_type": "markdown",
"id": "433298ef",
"metadata": {},
"source": [
"## 6. Export for web app deployment\n",
"\n",
"\n",
"\n",
"The three pre-computed similarity matrices and supporting data are saved to a single pickle file. A web app can load this file at startup and serve recommendations without re-running any computation. The file contains everything needed to:\n",
"\n",
"\n",
"\n",
"- Look up a movie by title and return memory-based recommendations.\n",
"\n",
"- Return model-based (SVD) recommendations for a given user.\n",
"\n",
"- Return content-based recommendations by genre profile.\n",
"\n",
"- Return hybrid recommendations with a configurable `alpha`."
]
},
{
"cell_type": "code",
"execution_count": 23,
"id": "b9677b1c",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved movie_recommender.pkl (67.7 MB)\n",
"Keys: ['item_similarity_df', 'genre_similarity_df', 'predicted_ratings_df', 'user_item_matrix', 'movies', 'genre_names']\n"
]
}
],
"source": [
"export = {\n",
" 'item_similarity_df': item_similarity_df,\n",
" 'genre_similarity_df': genre_similarity_df,\n",
" 'predicted_ratings_df': predicted_ratings_df,\n",
" 'user_item_matrix': user_item_matrix,\n",
" 'movies': movies,\n",
" 'genre_names': genre_names,\n",
"}\n",
"\n",
"output_path = 'movie_recommender.pkl'\n",
"\n",
"with open(output_path, 'wb') as f:\n",
" pickle.dump(export, f)\n",
"\n",
"size_mb = os.path.getsize(output_path) / (1024 * 1024)\n",
"print(f'Saved {output_path} ({size_mb:.1f} MB)')\n",
"print(f'Keys: {list(export.keys())}')"
]
},
{
"cell_type": "markdown",
"id": "219a012c",
"metadata": {},
"source": [
"## 7. Challenges\n",
"\n",
"\n",
"\n",
"Several fundamental challenges arise when building recommendation systems in practice."
]
},
{
"cell_type": "markdown",
"id": "4036ba28",
"metadata": {},
"source": [
"### 7.1. Cold start problem\n",
"\n",
"\n",
"\n",
"The cold start problem occurs when the system has insufficient data to make reliable recommendations.\n",
"\n",
"\n",
"\n",
"**New user cold start**: a first-time user has no rating history. Collaborative filtering cannot compute user similarity or generate predictions.\n",
"\n",
"\n",
"\n",
"- Ask users to rate a small set of popular items during onboarding\n",
"\n",
"- Use demographic information\n",
"\n",
"- Default to popularity-based recommendations until enough history is collected.\n",
"\n",
"\n",
"\n",
"**New item cold start**: a newly added item has no user ratings and will not appear in collaborative filtering results.\n",
"\n",
"\n",
"\n",
"- Use content features (genre, description, cast) to seed similarity via content-based filtering\n",
"\n",
"- Use a hybrid model that blends collaborative and content signals\n",
"\n",
"- Apply knowledge transfer from similar established items"
]
},
{
"cell_type": "markdown",
"id": "5814f171",
"metadata": {},
"source": [
"### 7.2. Implicit vs explicit feedback\n",
"\n",
"\n",
"\n",
"**Explicit feedback** is a direct expression of preference (e.g. a 1-5 star rating or a thumbs up/down). It is unambiguous but rare: most users never rate what they consume.\n",
"\n",
"\n",
"\n",
"**Implicit feedback** is collected passively from behavior: clicks, views, play counts, purchase history, and dwell time. It is abundant but noisy; a song played once may have been background noise, and a product viewed many times may have been considered and rejected.\n",
"\n",
"\n",
"\n",
"The key challenge with implicit feedback is the absence of a negative signal. An unobserved (user, item) pair could mean the user dislikes it, has never been exposed to it, or simply hasn't discovered it yet. Models must handle this uncertainty explicitly.\n",
"\n",
"\n",
"\n",
"| | Explicit | Implicit |\n",
"|---|---|---|\n",
"| Examples | Star ratings, thumbs up/down | Clicks, views, play counts |\n",
"| Volume | Low | High |\n",
"| Negative signal | Direct (low ratings) | Absent |\n",
"| Noise | Low | High |\n",
"| Common models | SVD, NMF | BPR, WALS, EASE |"
]
},
{
"cell_type": "markdown",
"id": "dded5260",
"metadata": {},
"source": [
"### 7.3. Other challenges\n",
"\n",
"\n",
"\n",
"- **Scalability**: computing pairwise similarities over millions of users and items is expensive. Production systems use approximate nearest-neighbor (ANN) search and distributed matrix factorization.\n",
"\n",
"\n",
"\n",
"- **Filter bubbles and diversity**: accuracy-optimized systems tend to recommend more of what a user has already seen. Real-world systems add diversity, novelty, and serendipity constraints to broaden the recommendation space.\n",
"\n",
"\n",
"\n",
"- **Temporal drift**: user preferences and item relevance change over time. Models need regular retraining or online updates to stay current."
]
},
{
"cell_type": "markdown",
"id": "23c0feda",
"metadata": {},
"source": [
"## 8. More advanced techniques\n",
"\n",
"\n",
"\n",
"The approaches in this notebook are strong baselines, but production recommendation systems typically use more powerful methods:\n",
"\n",
"\n",
"\n",
"- **Alternating Least Squares (ALS)**: a matrix factorization algorithm designed for implicit feedback that scales to millions of users and items. Available in PySpark MLlib and the `implicit` Python library.\n",
"\n",
"- **Weighted Alternating Least Squares (WALS)**: extends matrix factorization to implicit feedback by assigning confidence weights to all (user, item) pairs. Unobserved pairs get a low but non-zero weight rather than being ignored; observed interactions get a higher weight proportional to frequency.\n",
"\n",
"- **Bayesian Personalized Ranking (BPR)**: optimizes directly for ranking rather than rating prediction, using pairwise comparisons between observed and unobserved items.\n",
"\n",
"- **EASE (Embarrassingly Shallow Autoencoders)**: learns a sparse item-item weight matrix from the interaction data using a closed-form solution, with no latent factors or neural networks. Simple but surprisingly competitive on standard benchmarks.\n",
"\n",
"- **Factorization Machines (FM)**: generalizes matrix factorization to arbitrary feature interactions, making it straightforward to incorporate user and item side information (demographics, genres, context).\n",
"\n",
"- **Neural Collaborative Filtering (NCF)**: replaces the dot product with a neural network, allowing the model to learn non-linear user-item interactions.\n",
"\n",
"- **Sequence-aware models** (SASRec, BERT4Rec): model the temporal order of interactions, capturing session-level intent and recent context.\n",
"\n",
"- **Graph Neural Networks** (LightGCN, NGCF): represent users and items as nodes in an interaction graph and propagate signals through multi-hop connections to capture higher-order collaborative patterns."
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv (3.12.3)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}