{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Collaborative Filtering Recommender System: Urban Bites\n", "\n", "**Data Analytics & Data Mining** \n", "**Created by: Dr. Yaa**
\n", "**Demo Notebook 5B: Recommendation Systems (Collaborative Filtering)**\n", "\n", "---\n", "\n", "## The Business Problem (Same as 5A — Different Approach)\n", "\n", "**Urban Bites** still wants to recommend menu items to customers. In Notebook 5A, we used **Content-Based Filtering** — we looked at *item attributes* (Spicy, Comfort, Light) and matched them to each customer's taste profile.\n", "\n", "**Today's question is different:** What if we ignore the item attributes entirely and instead ask, *\"What did customers who are similar to you enjoy?\"*\n", "\n", "That's **Collaborative Filtering** — and it's the approach that powered Netflix's original recommendation engine.\n", "\n", "### PAIR Framework\n", "\n", "| Element | This Project |\n", "|---|---|\n", "| **P**rediction | Which items will a customer rate highly, based on similar customers' ratings? |\n", "| **A**ction | Show personalized \"Customers Like You Also Enjoyed\" recommendations |\n", "| **I**mpact | Cross-category discovery (customers find items they'd never have picked themselves) |\n", "| **R**isk | Cold-start problem (new customers/items with no ratings); popularity bias; privacy |" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Content-Based vs. Collaborative: When to Use Which?\n", "\n", "Before we dive in, let's be crystal clear about *why* you'd choose one over the other.\n", "\n", "| | Content-Based (Notebook 5A) | Collaborative (This Notebook) |\n", "|---|---|---|\n", "| **Core idea** | \"You liked spicy items → here are more spicy items\" | \"People like you liked X → you'll probably like X too\" |\n", "| **Needs** | Item attributes/metadata | Lots of user-item ratings |\n", "| **New items?** | ✅ Works immediately (just needs attributes) | ❌ Cold-start: no ratings = no signal |\n", "| **New users?** | ❌ Needs the user to rate a few items first | ❌ Same problem — needs rating history |\n", "| **Surprise factor** | Low (stays in the user's comfort zone) | **High** (can discover cross-category gems!) |\n", "| **Why it breaks** | Bad attributes = bad recommendations | Not enough similar users = noisy recommendations |\n", "\n", "**The punchline:** In production, the best systems use *both* — a **hybrid** approach. But you need to understand each one individually first.\n", "\n", "> **Real-world example:** Netflix's original algorithm was collaborative. Spotify's Discover Weekly is a hybrid. Amazon's \"Frequently Bought Together\" is a form of item-item collaborative filtering." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 1: Load & Prepare the Data\n", "\n", "We'll use the same Urban Bites datasets from Notebook 5A. Same data, different technique — so we can compare apples to apples." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Imports ──────────────────────────────────────────────────────\n", "import pandas as pd\n", "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "from math import sqrt\n", "from sklearn.metrics.pairwise import cosine_similarity\n", "import warnings\n", "warnings.filterwarnings('ignore')\n", "\n", "plt.style.use('seaborn-v0_8-whitegrid')\n", "plt.rcParams['figure.figsize'] = (10, 5)\n", "plt.rcParams['font.size'] = 11\n", "\n", "print('Libraries loaded!')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Load data ────────────────────────────────────────────────────\n", "menu_df = pd.read_csv('urban_bites_menu.csv')\n", "ratings_df = pd.read_csv('urban_bites_ratings.csv')\n", "customers_df = pd.read_csv('urban_bites_customers.csv')\n", "\n", "print(f'Menu items: {len(menu_df)}')\n", "print(f'Ratings: {len(ratings_df)}')\n", "print(f'Customers: {len(customers_df)}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Same cleaning as Notebook 5A ────────────────────────────────\n", "# Remove duplicate ratings\n", "ratings_clean = ratings_df.drop_duplicates(subset=['customer_id', 'item_id'], keep='last')\n", "\n", "# Remove all-5-star customers (no useful signal)\n", "cust_stats = ratings_clean.groupby('customer_id')['rating'].agg(['mean', 'std', 'count'])\n", "all_fives = cust_stats[(cust_stats['mean'] == 5.0) & (cust_stats['count'] > 5)]\n", "ratings_clean = ratings_clean[~ratings_clean['customer_id'].isin(all_fives.index)]\n", "\n", "print(f'Clean dataset: {len(ratings_clean)} ratings from '\n", " f'{ratings_clean[\"customer_id\"].nunique()} customers '\n", " f'across {ratings_clean[\"item_id\"].nunique()} items')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 2: Build the User-Item Matrix\n", "\n", "Collaborative filtering works by comparing *users* to each other. To do that, we need every user's ratings in a single table — the **User-Item Matrix**.\n", "\n", "Each row = a customer. Each column = a menu item. Each cell = the rating (or NaN if they haven't tried it).\n", "\n", "> **Think of it like a giant spreadsheet.** If you and another customer both gave 5 stars to the same 8 items, you're probably \"taste twins\" — and whatever *they* liked that *you* haven't tried is a great recommendation." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Build the user-item matrix ──────────────────────────────────\n", "user_item_matrix = ratings_clean.pivot_table(\n", " index='customer_id',\n", " columns='item_id',\n", " values='rating'\n", ")\n", "\n", "print(f'User-Item Matrix shape: {user_item_matrix.shape}')\n", "print(f' → {user_item_matrix.shape[0]} customers × {user_item_matrix.shape[1]} items')\n", "\n", "# How sparse is it? (What % of cells are empty?)\n", "total_cells = user_item_matrix.shape[0] * user_item_matrix.shape[1]\n", "filled_cells = user_item_matrix.notna().sum().sum()\n", "sparsity = 1 - (filled_cells / total_cells)\n", "\n", "print(f'\\nSparsity: {sparsity:.1%} of cells are empty')\n", "print(f' → Each customer has rated about {filled_cells/user_item_matrix.shape[0]:.0f} '\n", " f'out of {user_item_matrix.shape[1]} items')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Let's peek at a corner of this matrix\n", "print('Sample of the User-Item Matrix (first 6 customers × first 8 items):')\n", "print('(NaN = customer hasn\\'t rated this item)\\n')\n", "user_item_matrix.iloc[:6, :8]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Visualize the Sparsity\n", "\n", "The matrix is mostly empty — that's *normal*. No customer tries every single item on the menu. But it does mean we need enough overlapping ratings between users to find good matches." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Visualize the sparsity pattern ──────────────────────────────\n", "fig, ax = plt.subplots(figsize=(14, 6))\n", "\n", "# Show a heatmap of filled vs empty cells (subset for readability)\n", "subset = user_item_matrix.iloc[:40, :].notna().astype(int)\n", "sns.heatmap(subset, cmap=['#F5F0EB', '#5B8FA8'], cbar=False, ax=ax,\n", " linewidths=0.1, linecolor='white')\n", "ax.set_title('Sparsity Pattern: First 40 Customers (teal = rated, cream = not rated)',\n", " fontweight='bold', fontsize=13)\n", "ax.set_xlabel('Menu Item ID')\n", "ax.set_ylabel('Customer ID')\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "print(f'💡 The cream gaps are the items we\\'re trying to predict — '\n", " f'\\\"would this customer like this item if they tried it?\\\"')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 3: Find Similar Users (Pearson Correlation)\n", "\n", "The heart of collaborative filtering: **finding your \"taste twins.\"**\n", "\n", "We'll use the **Pearson Correlation Coefficient** to measure how similar two customers' rating patterns are.\n", "\n", "| Pearson Score | Meaning |\n", "|---|---|\n", "| +1.0 | Perfect match — you rate everything the same way |\n", "| 0.0 | No relationship — completely independent tastes |\n", "| −1.0 | Perfect opposites — you love what they hate, and vice versa |\n", "\n", "> **Why Pearson and not just cosine similarity?** Pearson corrects for each user's personal rating scale. If you're a \"tough grader\" who gives 3s where someone else gives 5s, Pearson still recognizes you have similar *taste patterns*. Cosine similarity would miss that." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Step 3.1: Walk Through One Example\n", "\n", "Let's pick **Customer #7** (same as Notebook 5A) and find their most similar neighbors." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Select our target customer ──────────────────────────────────\n", "target_id = 7\n", "\n", "target_ratings = ratings_clean[ratings_clean['customer_id'] == target_id]\n", "target_items = set(target_ratings['item_id'])\n", "\n", "print(f'Customer #{target_id} has rated {len(target_items)} items.')\n", "print(f'\\nSample of their ratings:')\n", "target_ratings.merge(menu_df[['item_id', 'item_name', 'category']], on='item_id')\\\n", " .sort_values('rating', ascending=False).head(8)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Find users who share rated items with our target ─────────────\n", "# Step 1: Get all users who rated at least ONE of the same items\n", "overlap_users = ratings_clean[ratings_clean['item_id'].isin(target_items)]\n", "overlap_users = overlap_users[overlap_users['customer_id'] != target_id]\n", "\n", "# How many items do they share?\n", "overlap_counts = overlap_users.groupby('customer_id')['item_id'].nunique()\n", "overlap_counts.name = 'shared_items'\n", "\n", "print(f'Users who share at least 1 rated item: {len(overlap_counts)}')\n", "print(f'\\nDistribution of shared items:')\n", "print(overlap_counts.describe().to_string())\n", "\n", "# Only consider users with enough overlap (at least 5 shared items)\n", "min_overlap = 5\n", "candidates = overlap_counts[overlap_counts >= min_overlap].index.tolist()\n", "print(f'\\nCandidates with {min_overlap}+ shared items: {len(candidates)}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Calculate Pearson Correlation with each candidate ───────────\n", "\n", "def pearson_correlation(user1_ratings, user2_ratings):\n", " \"\"\"\n", " Calculate Pearson correlation between two users' ratings.\n", " Only considers items both users have rated.\n", " \n", " Returns: (correlation, n_shared_items)\n", " \"\"\"\n", " # Find common items\n", " common = user1_ratings.merge(user2_ratings, on='item_id', suffixes=('_1', '_2'))\n", " \n", " n = len(common)\n", " if n < 3: # Need at least 3 shared items for meaningful correlation\n", " return 0.0, n\n", " \n", " x = common['rating_1'].values\n", " y = common['rating_2'].values\n", " \n", " # Pearson formula\n", " mean_x, mean_y = x.mean(), y.mean()\n", " numerator = np.sum((x - mean_x) * (y - mean_y))\n", " denominator = sqrt(np.sum((x - mean_x)**2) * np.sum((y - mean_y)**2))\n", " \n", " if denominator == 0:\n", " return 0.0, n\n", " \n", " return numerator / denominator, n\n", "\n", "\n", "# Calculate similarity with all candidates\n", "target_data = ratings_clean[ratings_clean['customer_id'] == target_id][['item_id', 'rating']]\n", "\n", "similarities = []\n", "for cid in candidates:\n", " other_data = ratings_clean[ratings_clean['customer_id'] == cid][['item_id', 'rating']]\n", " corr, n_shared = pearson_correlation(target_data, other_data)\n", " similarities.append({\n", " 'customer_id': cid,\n", " 'pearson_corr': round(corr, 4),\n", " 'shared_items': n_shared\n", " })\n", "\n", "sim_df = pd.DataFrame(similarities)\n", "sim_df = sim_df.sort_values('pearson_corr', ascending=False)\n", "\n", "print(f'Top 10 most similar customers to Customer #{target_id}:')\n", "print('='*55)\n", "sim_df.head(10)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Visualize the similarity distribution ───────────────────────\n", "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", "\n", "# Distribution of Pearson correlations\n", "sim_df['pearson_corr'].hist(bins=20, ax=axes[0], color='#5B8FA8',\n", " edgecolor='white', alpha=0.85)\n", "axes[0].set_title(f'Similarity Scores: All Candidates vs Customer #{target_id}',\n", " fontweight='bold')\n", "axes[0].set_xlabel('Pearson Correlation')\n", "axes[0].set_ylabel('Number of Customers')\n", "axes[0].axvline(0, color='gray', linestyle=':', alpha=0.5)\n", "\n", "# Top 15 neighbors\n", "top15 = sim_df.head(15).sort_values('pearson_corr')\n", "axes[1].barh(top15['customer_id'].astype(str), top15['pearson_corr'],\n", " color='#7BA88B', edgecolor='white')\n", "axes[1].set_title('Top 15 \"Taste Twins\"', fontweight='bold')\n", "axes[1].set_xlabel('Pearson Correlation')\n", "axes[1].set_ylabel('Customer ID')\n", "\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 💡 Teaching Moment: What Makes a Good Neighbor?\n", "\n", "A high Pearson correlation with only 3 shared items is *unreliable* — it might just be a coincidence. A moderate correlation with 15+ shared items is much more trustworthy.\n", "\n", "**In practice, you'd apply a minimum overlap threshold** (we used 5 shared items). Some systems also *weight* the correlation by the number of shared items to penalize thin evidence." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 4: Generate Weighted Recommendations\n", "\n", "Now the fun part. We take the **top-K most similar users** and use their ratings to predict what our target customer would think of items they haven't tried.\n", "\n", "**The formula (in plain English):**\n", "\n", "For each item the target hasn't rated:\n", "1. Find all neighbors who *have* rated it\n", "2. Multiply each neighbor's rating by their similarity score (more similar neighbors count more)\n", "3. Divide by the sum of similarity scores to get a weighted average\n", "\n", "$$\\text{Predicted Rating} = \\frac{\\sum (\\text{similarity}_i \\times \\text{rating}_i)}{\\sum |\\text{similarity}_i|}$$" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Select top neighbors ────────────────────────────────────────\n", "# Use the top 30 most similar users (with positive correlation)\n", "top_n_neighbors = 30\n", "top_neighbors = sim_df[sim_df['pearson_corr'] > 0].head(top_n_neighbors)\n", "\n", "print(f'Using {len(top_neighbors)} neighbors for recommendations')\n", "print(f'Similarity range: {top_neighbors[\"pearson_corr\"].min():.3f} to '\n", " f'{top_neighbors[\"pearson_corr\"].max():.3f}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Get all ratings from our neighbors ──────────────────────────\n", "neighbor_ids = top_neighbors['customer_id'].tolist()\n", "neighbor_ratings = ratings_clean[ratings_clean['customer_id'].isin(neighbor_ids)].copy()\n", "\n", "# Merge in the similarity scores\n", "neighbor_ratings = neighbor_ratings.merge(\n", " top_neighbors[['customer_id', 'pearson_corr']],\n", " on='customer_id'\n", ")\n", "\n", "# Calculate weighted rating\n", "neighbor_ratings['weighted_rating'] = (neighbor_ratings['pearson_corr'] * \n", " neighbor_ratings['rating'])\n", "\n", "print(f'Neighbor ratings collected: {len(neighbor_ratings)}')\n", "neighbor_ratings.head()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Aggregate: predicted score per item ─────────────────────────\n", "item_scores = neighbor_ratings.groupby('item_id').agg(\n", " sum_weighted_rating=('weighted_rating', 'sum'),\n", " sum_similarity=('pearson_corr', lambda x: x.abs().sum()),\n", " n_raters=('customer_id', 'nunique')\n", ").reset_index()\n", "\n", "# Weighted average prediction\n", "item_scores['predicted_rating'] = (item_scores['sum_weighted_rating'] / \n", " item_scores['sum_similarity'])\n", "\n", "# Merge in item names\n", "item_scores = item_scores.merge(menu_df[['item_id', 'item_name', 'category', 'price']],\n", " on='item_id')\n", "\n", "# Remove items the target already rated\n", "recommendations = item_scores[~item_scores['item_id'].isin(target_items)]\n", "recommendations = recommendations.sort_values('predicted_rating', ascending=False)\n", "\n", "print(f'\\n🎯 Top 10 Collaborative Filtering Recommendations for Customer #{target_id}:')\n", "print('='*75)\n", "recommendations[['item_name', 'category', 'price', 'predicted_rating', 'n_raters']].head(10)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Visualize the recommendations ───────────────────────────────\n", "fig, ax = plt.subplots(figsize=(12, 6))\n", "\n", "top10 = recommendations.head(10).sort_values('predicted_rating')\n", "bars = ax.barh(top10['item_name'], top10['predicted_rating'],\n", " color='#5B8FA8', edgecolor='white')\n", "\n", "# Add category labels\n", "for bar, (_, row) in zip(bars, top10.iterrows()):\n", " ax.text(bar.get_width() + 0.02, bar.get_y() + bar.get_height()/2,\n", " f\"({row['category']}, {row['n_raters']} neighbors)\",\n", " va='center', fontsize=9, color='gray')\n", "\n", "ax.set_title(f'Top 10 Collaborative Recommendations: Customer #{target_id}',\n", " fontweight='bold', fontsize=13)\n", "ax.set_xlabel('Predicted Rating (weighted average from similar customers)')\n", "ax.set_xlim(0, 5.5)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 💡 What's Different from Content-Based?\n", "\n", "Compare these recommendations with the ones from Notebook 5A. You might notice:\n", "\n", "- **Cross-category surprises**: Content-based would never recommend a dessert to a burger lover (different attributes). But collaborative filtering can — because *other* burger lovers also happen to love a specific dessert.\n", "- **The `n_raters` column**: This tells us how many neighbors contributed to the prediction. More raters = more confidence.\n", "- **Predicted ratings vs. match scores**: Content-based gives a \"match score\" (0–1). Collaborative gives a *predicted rating* on the same 0.5–5.0 scale the customer actually uses. Easier to interpret!" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 5: Build the Full Engine (All Customers)\n", "\n", "Let's wrap everything into reusable functions and generate recommendations for every customer." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def find_similar_users(target_id, ratings_data, min_overlap=5, top_n=30):\n", " \"\"\"\n", " Find the top-N most similar users to the target using Pearson correlation.\n", " \"\"\"\n", " target_data = ratings_data[ratings_data['customer_id'] == target_id][['item_id', 'rating']]\n", " target_items = set(target_data['item_id'])\n", " \n", " # Find candidates with enough overlap\n", " others = ratings_data[ratings_data['customer_id'] != target_id]\n", " overlap = others[others['item_id'].isin(target_items)]\n", " counts = overlap.groupby('customer_id')['item_id'].nunique()\n", " candidates = counts[counts >= min_overlap].index\n", " \n", " results = []\n", " for cid in candidates:\n", " other_data = ratings_data[ratings_data['customer_id'] == cid][['item_id', 'rating']]\n", " corr, n = pearson_correlation(target_data, other_data)\n", " if corr > 0: # Only keep positively correlated users\n", " results.append({'customer_id': cid, 'pearson_corr': corr, 'shared_items': n})\n", " \n", " if not results:\n", " return pd.DataFrame(columns=['customer_id', 'pearson_corr', 'shared_items'])\n", " \n", " return pd.DataFrame(results).nlargest(top_n, 'pearson_corr')\n", "\n", "\n", "def collaborative_recommendations(target_id, ratings_data, menu_data,\n", " min_overlap=5, top_n_neighbors=30, top_n_recs=5):\n", " \"\"\"\n", " Generate top-N recommendations using user-user collaborative filtering.\n", " \"\"\"\n", " # Find similar users\n", " neighbors = find_similar_users(target_id, ratings_data, min_overlap, top_n_neighbors)\n", " \n", " if len(neighbors) == 0:\n", " return pd.DataFrame(columns=['item_id', 'item_name', 'category',\n", " 'price', 'predicted_rating', 'n_raters'])\n", " \n", " # Get neighbor ratings with similarity weights\n", " neighbor_ids = neighbors['customer_id'].tolist()\n", " n_ratings = ratings_data[ratings_data['customer_id'].isin(neighbor_ids)].copy()\n", " n_ratings = n_ratings.merge(neighbors[['customer_id', 'pearson_corr']], on='customer_id')\n", " n_ratings['weighted_rating'] = n_ratings['pearson_corr'] * n_ratings['rating']\n", " \n", " # Aggregate predictions\n", " scores = n_ratings.groupby('item_id').agg(\n", " sum_wr=('weighted_rating', 'sum'),\n", " sum_sim=('pearson_corr', lambda x: x.abs().sum()),\n", " n_raters=('customer_id', 'nunique')\n", " ).reset_index()\n", " scores['predicted_rating'] = scores['sum_wr'] / scores['sum_sim']\n", " \n", " # Merge item info and exclude already-rated\n", " scores = scores.merge(menu_data[['item_id', 'item_name', 'category', 'price']], on='item_id')\n", " target_items = set(ratings_data[ratings_data['customer_id'] == target_id]['item_id'])\n", " unrated = scores[~scores['item_id'].isin(target_items)]\n", " \n", " return unrated.nlargest(top_n_recs, 'predicted_rating')[\n", " ['item_id', 'item_name', 'category', 'price', 'predicted_rating', 'n_raters']\n", " ]\n", "\n", "print('Functions defined! Testing...')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Test with a few customers ───────────────────────────────────\n", "for cid in [7, 15, 50, 120]:\n", " recs = collaborative_recommendations(cid, ratings_clean, menu_df)\n", " seg = customers_df[customers_df['customer_id'] == cid]['customer_segment'].values[0]\n", " \n", " print(f\"\\n{'='*65}\")\n", " print(f'Customer #{cid} ({seg})')\n", " print(f\"{'='*65}\")\n", " for _, row in recs.iterrows():\n", " print(f\" {row['item_name']:<35} {row['category']:<12} \"\n", " f\"Pred: {row['predicted_rating']:.2f} ({row['n_raters']} neighbors)\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 6: Evaluate the Model\n", "\n", "Same evaluation approach as Notebook 5A — hide each customer's top-3 favorites, predict from the rest, and measure how many we recover.\n", "\n", "This way we can do a **head-to-head comparison** between content-based and collaborative filtering." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Evaluation: Precision@K and Hit Rate ────────────────────────\n", "\n", "def evaluate_collaborative(ratings_data, menu_data, top_n=5, min_ratings=10):\n", " \"\"\"\n", " Evaluate collaborative filtering using the same hold-out strategy as Notebook 5A.\n", " \"\"\"\n", " results = []\n", " customer_counts = ratings_data.groupby('customer_id').size()\n", " eligible = customer_counts[customer_counts >= min_ratings].index\n", " \n", " for cid in eligible:\n", " cust_data = ratings_data[ratings_data['customer_id'] == cid]\n", " \n", " # Hold out top 3\n", " top_rated = cust_data.nlargest(3, 'rating')\n", " hidden_ids = set(top_rated['item_id'])\n", " \n", " # Train on remaining\n", " train_mask = ~ratings_data.index.isin(top_rated.index)\n", " train_data = ratings_data[train_mask]\n", " \n", " # Get recommendations from collaborative model\n", " # Note: we pass train_data so the hidden items can appear as candidates\n", " neighbors = find_similar_users(cid, train_data, min_overlap=3, top_n=30)\n", " \n", " if len(neighbors) == 0:\n", " continue\n", " \n", " n_ids = neighbors['customer_id'].tolist()\n", " n_ratings = train_data[train_data['customer_id'].isin(n_ids)].copy()\n", " n_ratings = n_ratings.merge(neighbors[['customer_id', 'pearson_corr']], on='customer_id')\n", " n_ratings['weighted_rating'] = n_ratings['pearson_corr'] * n_ratings['rating']\n", " \n", " scores = n_ratings.groupby('item_id').agg(\n", " sum_wr=('weighted_rating', 'sum'),\n", " sum_sim=('pearson_corr', lambda x: x.abs().sum())\n", " ).reset_index()\n", " scores['predicted_rating'] = scores['sum_wr'] / scores['sum_sim']\n", " \n", " # Exclude items in training set for this customer\n", " train_items = set(train_data[train_data['customer_id'] == cid]['item_id'])\n", " candidates = scores[~scores['item_id'].isin(train_items)]\n", " top_recs = set(candidates.nlargest(top_n, 'predicted_rating')['item_id'])\n", " \n", " hits = len(hidden_ids & top_recs)\n", " results.append({\n", " 'customer_id': cid,\n", " 'hits': hits,\n", " 'precision_at_k': hits / top_n,\n", " 'hit_rate': 1 if hits > 0 else 0\n", " })\n", " \n", " return pd.DataFrame(results)\n", "\n", "print('Running evaluation... (this may take a minute)')\n", "collab_eval = evaluate_collaborative(ratings_clean, menu_df, top_n=5)\n", "\n", "print(f'\\nEvaluated {len(collab_eval)} customers')\n", "print(f'\\n📊 Collaborative Filtering Performance:')\n", "print(f\"{'='*45}\")\n", "print(f\" Average Precision@5: {collab_eval['precision_at_k'].mean():.3f}\")\n", "print(f\" Hit Rate: {collab_eval['hit_rate'].mean():.1%}\")\n", "print(f\" Average Hits: {collab_eval['hits'].mean():.2f} / 3\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 7: Head-to-Head Comparison\n", "\n", "Let's bring back the Notebook 5A content-based results and compare them directly.\n", "\n", "We'll re-run the content-based evaluation here so everything is in one place." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Re-run content-based evaluation (from Notebook 5A) ──────────\n", "\n", "attribute_cols = ['Spicy', 'Vegetarian', 'HighProtein', 'GlutenFree', 'DairyFree',\n", " 'Hearty', 'Light', 'Premium', 'Comfort', 'International']\n", "\n", "def content_based_evaluate(ratings_data, menu_data, attr_cols, top_n=5, min_ratings=10):\n", " results = []\n", " customer_counts = ratings_data.groupby('customer_id').size()\n", " eligible = customer_counts[customer_counts >= min_ratings].index\n", " \n", " for cid in eligible:\n", " cust_data = ratings_data[ratings_data['customer_id'] == cid]\n", " top_rated = cust_data.nlargest(3, 'rating')\n", " hidden_ids = set(top_rated['item_id'])\n", " train = cust_data[~cust_data['item_id'].isin(hidden_ids)]\n", " \n", " if len(train) < 3:\n", " continue\n", " \n", " # Build profile on training data\n", " merged = train.merge(menu_data, on='item_id')\n", " attr_matrix = merged[attr_cols].reset_index(drop=True)\n", " ratings_vec = merged['rating'].reset_index(drop=True)\n", " profile = attr_matrix.T.dot(ratings_vec)\n", " \n", " # Score all items\n", " menu_attr = menu_data[attr_cols].values\n", " scores = cosine_similarity(profile.values.reshape(1, -1), menu_attr)[0]\n", " scored = menu_data.copy()\n", " scored['score'] = scores\n", " \n", " train_ids = set(train['item_id'])\n", " candidates = scored[~scored['item_id'].isin(train_ids)]\n", " top_recs = set(candidates.nlargest(top_n, 'score')['item_id'])\n", " \n", " hits = len(hidden_ids & top_recs)\n", " results.append({\n", " 'customer_id': cid,\n", " 'hits': hits,\n", " 'precision_at_k': hits / top_n,\n", " 'hit_rate': 1 if hits > 0 else 0\n", " })\n", " \n", " return pd.DataFrame(results)\n", "\n", "print('Running content-based evaluation...')\n", "cb_eval = content_based_evaluate(ratings_clean, menu_df, attribute_cols, top_n=5)\n", "print(f'Done! Evaluated {len(cb_eval)} customers.')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Head-to-Head Comparison ─────────────────────────────────────\n", "\n", "random_precision = 5 / 64 # baseline: random guessing\n", "\n", "comparison = pd.DataFrame({\n", " 'Metric': ['Precision@5', 'Hit Rate', 'Avg Hits (out of 3)', 'Customers Evaluated'],\n", " 'Random Baseline': [f'{random_precision:.3f}', f'{random_precision*3:.1%}',\n", " f'{random_precision*3*3:.2f}', '—'],\n", " 'Content-Based (5A)': [\n", " f\"{cb_eval['precision_at_k'].mean():.3f}\",\n", " f\"{cb_eval['hit_rate'].mean():.1%}\",\n", " f\"{cb_eval['hits'].mean():.2f}\",\n", " f\"{len(cb_eval)}\"\n", " ],\n", " 'Collaborative (5B)': [\n", " f\"{collab_eval['precision_at_k'].mean():.3f}\",\n", " f\"{collab_eval['hit_rate'].mean():.1%}\",\n", " f\"{collab_eval['hits'].mean():.2f}\",\n", " f\"{len(collab_eval)}\"\n", " ]\n", "})\n", "\n", "print('\\n📊 HEAD-TO-HEAD COMPARISON')\n", "print('='*70)\n", "print(comparison.to_string(index=False))\n", "print('='*70)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Visualization: Side-by-side ─────────────────────────────────\n", "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", "\n", "# Precision@5 comparison\n", "methods = ['Random\\nBaseline', 'Content-Based\\n(Notebook 5A)', 'Collaborative\\n(Notebook 5B)']\n", "precisions = [random_precision, cb_eval['precision_at_k'].mean(),\n", " collab_eval['precision_at_k'].mean()]\n", "colors = ['#CCCCCC', '#D4845A', '#5B8FA8']\n", "\n", "bars = axes[0].bar(methods, precisions, color=colors, edgecolor='white', width=0.6)\n", "axes[0].set_title('Precision@5 Comparison', fontweight='bold', fontsize=13)\n", "axes[0].set_ylabel('Precision@5')\n", "for bar, val in zip(bars, precisions):\n", " axes[0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.005,\n", " f'{val:.3f}', ha='center', fontweight='bold')\n", "\n", "# Hit Rate comparison\n", "hit_rates = [random_precision * 3, cb_eval['hit_rate'].mean(),\n", " collab_eval['hit_rate'].mean()]\n", "bars2 = axes[1].bar(methods, hit_rates, color=colors, edgecolor='white', width=0.6)\n", "axes[1].set_title('Hit Rate Comparison', fontweight='bold', fontsize=13)\n", "axes[1].set_ylabel('Hit Rate')\n", "axes[1].set_ylim(0, 1)\n", "for bar, val in zip(bars2, hit_rates):\n", " axes[1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,\n", " f'{val:.1%}', ha='center', fontweight='bold')\n", "\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 💡 What Does This Tell a Business Stakeholder?\n", "\n", "**Both methods beat random.** That's the first thing to celebrate — our models are adding real value.\n", "\n", "**Neither method is \"always better.\"** The right choice depends on your business context:\n", "\n", "| Scenario | Best Choice | Why |\n", "|---|---|---|\n", "| New menu item, no ratings yet | Content-Based | Just needs item attributes |\n", "| Mature menu, lots of customer data | Collaborative | Leverages crowd wisdom |\n", "| Customer wants surprises | Collaborative | Can find cross-category gems |\n", "| You need to explain *why* | Content-Based | \"Because you like spicy food\" is clear |\n", "| Production system at scale | **Hybrid** | Combine both for best results |\n", "\n", "**The real-world answer:** Use both. Netflix, Spotify, and Amazon all use hybrid systems that blend content-based and collaborative signals." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phase 8: Deploy to Excel — \"Train in Python, Deploy in Excel\"\n", "\n", "Same deployment pattern as Notebook 5A. We pre-compute the top-5 collaborative recommendations for every customer and export them to a lookup workbook." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Generate recommendations for ALL customers ──────────────────\n", "\n", "all_cids = ratings_clean['customer_id'].unique()\n", "all_collab_recs = []\n", "failed_customers = []\n", "\n", "print(f'Generating collaborative recommendations for {len(all_cids)} customers...')\n", "\n", "for i, cid in enumerate(all_cids):\n", " if (i + 1) % 50 == 0:\n", " print(f' Processed {i+1}/{len(all_cids)} customers...')\n", " \n", " recs = collaborative_recommendations(cid, ratings_clean, menu_df,\n", " min_overlap=3, top_n_neighbors=30, top_n_recs=5)\n", " \n", " if len(recs) == 0:\n", " failed_customers.append(cid)\n", " continue\n", " \n", " for rank, (_, row) in enumerate(recs.iterrows(), 1):\n", " all_collab_recs.append({\n", " 'customer_id': cid,\n", " 'rank': rank,\n", " 'item_id': row['item_id'],\n", " 'item_name': row['item_name'],\n", " 'category': row['category'],\n", " 'price': row['price'],\n", " 'predicted_rating': round(row['predicted_rating'], 3),\n", " 'n_raters': row['n_raters']\n", " })\n", "\n", "collab_recs_df = pd.DataFrame(all_collab_recs)\n", "\n", "print(f'\\n✅ Done!')\n", "print(f' Recommendations generated for {collab_recs_df[\"customer_id\"].nunique()} customers')\n", "print(f' Customers with no neighbors (cold start): {len(failed_customers)}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ── Export to CSV ────────────────────────────────────────────────\n", "collab_recs_df.to_csv('urban_bites_collab_recommendations.csv', index=False)\n", "\n", "print('✅ Exported: urban_bites_collab_recommendations.csv')\n", "print(f' {len(collab_recs_df)} rows ({collab_recs_df[\"customer_id\"].nunique()} customers × 5)')\n", "print(f'\\n💡 Load this into the UrbanBites_Collaborative_Recommender.xlsx workbook')\n", "print(f' for the manager-facing lookup tool.')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Wrap-Up: Key Takeaways\n", "\n", "### What We Built\n", "\n", "A complete **user-user collaborative filtering** recommendation engine that:\n", "\n", "1. ✅ Starts with the **same business problem** as Notebook 5A (different technique, same goal)\n", "2. ✅ Builds a **user-item matrix** and visualizes sparsity\n", "3. ✅ Finds **\"taste twin\" neighbors** using Pearson correlation\n", "4. ✅ Generates **weighted predictions** from neighbor ratings\n", "5. ✅ **Evaluates** with Precision@5 and Hit Rate\n", "6. ✅ **Compares head-to-head** with content-based (Notebook 5A)\n", "7. ✅ **Deploys** to Excel for business users\n", "\n", "### PAIR Framework Recap\n", "\n", "| Element | What We Delivered |\n", "|---|---|\n", "| **P**rediction | Predicted ratings for unrated items (weighted neighbor average) |\n", "| **A**ction | \"Customers Like You Also Enjoyed\" — top-5 per customer in Excel |\n", "| **I**mpact | Measurable lift over random via Precision@5; cross-category discovery |\n", "| **R**isk | Cold-start (new users/items); privacy (using other users' data); popularity bias |\n", "\n", "### Collaborative Filtering: Strengths & Limitations\n", "\n", "| Strengths | Limitations |\n", "|---|---|\n", "| **No item attributes needed** — works on ratings alone | **Cold-start**: new users/items have no signal |\n", "| **Cross-category discovery** — finds unexpected connections | **Scalability**: comparing every user pair is expensive |\n", "| **Adapts over time** as more ratings come in | **Sparsity**: too few shared items = noisy correlations |\n", "| Predictions are on the **same rating scale** users understand | **Privacy**: uses other people's data (needs consent) |\n", "\n", "### The Bigger Picture: What Would Production Look Like?\n", "\n", "In a real deployment, you'd:\n", "1. **Combine both approaches** (hybrid model) for the best of both worlds\n", "2. **Add A/B testing** — randomly show some customers content-based vs. collaborative vs. hybrid and measure which drives the most orders\n", "3. **Schedule re-training** — as new ratings come in weekly, recalculate profiles and neighborhoods\n", "4. **Monitor for bias** — are we only recommending popular items? Are niche items getting lost?\n", "5. **Handle cold start** — for new customers, start with popularity-based recommendations until you have enough data\n", "\n", "---\n", "\n", "*\"A recommendation system isn't just an algorithm — it's a business strategy. The math gets you 60% of the way there. Deployment, monitoring, and stakeholder buy-in get you the rest.\"*" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "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.13.12" } }, "nbformat": 4, "nbformat_minor": 4 }