{
 "nbformat": 4,
 "nbformat_minor": 5,
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11"
  },
  "colab": {
   "provenance": []
  }
 },
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-0000",
   "metadata": {},
   "source": [
    "# Content-Based Filtering\n",
    "\n",
    "Recommend \"more like this\" by turning movie descriptions into TF-IDF vectors and ranking them with cosine similarity — a full search-style recommender in the browser.\n",
    "\n",
    "*Part of the free [Machine Learning](https://ramadnsyh.dev/courses/machine-learning) course by [Muhammad Ramadiansyah](https://ramadnsyh.dev). This notebook is generated from the interactive lesson — [read it online](https://ramadnsyh.dev/courses/machine-learning/content-based-filtering).*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0001",
   "metadata": {},
   "source": [
    "Popularity charts treat everyone the same. Content-based filtering takes the\n",
    "first step toward personalization: if you just watched *Toy Story*, recommend\n",
    "movies that *are like* Toy Story. To do that we need two ingredients — a way\n",
    "to represent each item as numbers, and a way to measure how close two items\n",
    "are. This lesson builds both, then wires them into a working\n",
    "`get_recommendations()` function."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0002",
   "metadata": {},
   "source": [
    "## From \"more like this\" to vectors\n",
    "\n",
    "Content-based filtering compares **item attributes**: genres, keywords, cast,\n",
    "director, plot synopsis. For movies, the most flexible trick is to mash all of\n",
    "that text into a single string per movie — often called a **metadata soup**:"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0003",
   "metadata": {},
   "source": [
    "```text\n",
    "\"animation family comedy toys jealousy a cowboy doll is threatened\n",
    " when a new spaceman figure becomes the favorite toy\"\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0004",
   "metadata": {},
   "source": [
    "Once every movie is a bag of words, the problem becomes a *document search*\n",
    "problem: encode each soup as a vector, then find the vectors nearest to the\n",
    "one the user just watched. It's the same machinery behind a search engine —\n",
    "except the \"query\" is a movie instead of typed keywords."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0005",
   "metadata": {},
   "source": [
    "## Counting words — and why raw counts mislead\n",
    "\n",
    "The simplest encoding is a word count: one dimension per vocabulary word, and\n",
    "each movie's vector holds how often each word appears (scikit-learn's\n",
    "`CountVectorizer`). The flaw: common words dominate. If half your catalog's\n",
    "synopses contain \"world\" or \"life\", those words contribute big counts to many\n",
    "pairs of movies — inflating similarity without carrying any real signal.\n",
    "\n",
    "**TF-IDF** (term frequency × inverse document frequency) fixes this by\n",
    "down-weighting words that appear in many documents and up-weighting rare,\n",
    "distinctive ones. A word like \"dinosaur\" that appears in only two synopses\n",
    "becomes a strong link between exactly those two movies; a word like \"story\"\n",
    "that appears everywhere gets weight near zero. That's `TfidfVectorizer`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0006",
   "metadata": {},
   "source": [
    "## Cosine similarity: direction, not length\n",
    "\n",
    "How do we compare two vectors? Euclidean distance is tempting, but it punishes\n",
    "*length* — a long synopsis would look far from a short one even if they use\n",
    "identical vocabulary. **Cosine similarity** measures only the *angle* between\n",
    "vectors: 1 means \"pointing the same way\", 0 means \"nothing in common\".\n",
    "See it in two dimensions first:"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0007",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "\n",
    "# Two dimensions: counts of \"robot\" and \"love\" in a synopsis\n",
    "short_scifi = np.array([3.0, 1.0])   # short synopsis, mostly robots\n",
    "long_scifi  = np.array([9.0, 3.0])   # 3x longer, same vocabulary mix\n",
    "romance     = np.array([1.0, 4.0])   # mostly love\n",
    "\n",
    "def cosine(a, b):\n",
    "    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))\n",
    "\n",
    "def euclid(a, b):\n",
    "    return np.linalg.norm(a - b)\n",
    "\n",
    "print(\"cosine(short sci-fi, long sci-fi) =\", round(cosine(short_scifi, long_scifi), 3))\n",
    "print(\"cosine(short sci-fi, romance)     =\", round(cosine(short_scifi, romance), 3))\n",
    "print()\n",
    "print(\"euclidean(short sci-fi, long sci-fi) =\", round(euclid(short_scifi, long_scifi), 2))\n",
    "print(\"euclidean(short sci-fi, romance)     =\", round(euclid(short_scifi, romance), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0008",
   "metadata": {},
   "source": [
    "Cosine gets it right: the two sci-fi synopses are a perfect 1.0 match because\n",
    "they point in the same direction, while Euclidean distance claims the romance\n",
    "synopsis is *closer* to the short sci-fi one — purely because the long synopsis\n",
    "has bigger numbers. For text, always think angles."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0009",
   "metadata": {},
   "source": [
    "## Building the recommender\n",
    "\n",
    "Now the real thing: 15 movies, each with a genre + keyword + overview soup.\n",
    "Pipeline: `TfidfVectorizer` → similarity matrix → look up a title → return the\n",
    "five nearest neighbors (skipping the movie itself, which is always its own\n",
    "best match)."
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0010",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "from sklearn.metrics.pairwise import cosine_similarity\n",
    "\n",
    "data = [\n",
    "    (\"Toy Story\", \"animation family comedy toys friendship jealousy a cowboy doll feels threatened when a new spaceman action figure becomes the favorite toy\"),\n",
    "    (\"Finding Nemo\", \"animation family adventure ocean fish father son a timid clownfish crosses the ocean to rescue his son captured by a diver\"),\n",
    "    (\"The Incredibles\", \"animation family action superhero secret identity a family of undercover superheroes is forced back into action to save the world\"),\n",
    "    (\"Shrek\", \"animation comedy fantasy ogre fairy tale swamp an ogre rescues a princess to reclaim his swamp from banished fairy tale creatures\"),\n",
    "    (\"The Dark Knight\", \"action crime thriller superhero vigilante joker batman faces a criminal mastermind who plunges gotham city into anarchy and chaos\"),\n",
    "    (\"Batman Begins\", \"action crime superhero origin fear training bruce wayne travels the world and returns to gotham to fight crime as batman\"),\n",
    "    (\"Inception\", \"action science fiction heist dreams subconscious a thief who steals secrets from dreams is given a final job planting an idea\"),\n",
    "    (\"Interstellar\", \"science fiction space wormhole time dilation family explorers travel through a wormhole to find humanity a new home among the stars\"),\n",
    "    (\"The Martian\", \"science fiction space survival mars botany an astronaut stranded on mars must grow food and signal earth to survive\"),\n",
    "    (\"Gravity\", \"science fiction space survival debris astronaut two astronauts drift in orbit after debris destroys their shuttle\"),\n",
    "    (\"Titanic\", \"romance drama disaster ship iceberg class a poor artist and a rich young woman fall in love aboard a doomed ocean liner\"),\n",
    "    (\"The Notebook\", \"romance drama love letters memory an elderly man reads the story of a lifelong summer love to a woman with dementia\"),\n",
    "    (\"La La Land\", \"romance drama music jazz dreams ambition an aspiring actress and a jazz pianist chase their dreams and fall in love in los angeles\"),\n",
    "    (\"Get Out\", \"horror thriller mystery hypnosis suspicion a young black man uncovers a sinister secret while visiting his girlfriend family estate\"),\n",
    "    (\"A Quiet Place\", \"horror thriller monsters silence family a family must live in total silence to hide from creatures that hunt by sound\"),\n",
    "]\n",
    "df = pd.DataFrame(data, columns=[\"title\", \"soup\"])\n",
    "\n",
    "tfidf = TfidfVectorizer(stop_words=\"english\")\n",
    "matrix = tfidf.fit_transform(df[\"soup\"])          # shape: (15 movies, vocab)\n",
    "sim = cosine_similarity(matrix)                   # 15 x 15 similarity matrix\n",
    "indices = pd.Series(df.index, index=df[\"title\"])  # title -> row lookup\n",
    "\n",
    "def get_recommendations(title, k=5):\n",
    "    idx = indices[title]\n",
    "    scores = pd.Series(sim[idx], index=df[\"title\"])\n",
    "    return scores.drop(title).sort_values(ascending=False).head(k)\n",
    "\n",
    "print(\"Vocabulary size:\", len(tfidf.get_feature_names_out()), \"\\\\n\")\n",
    "for title in [\"Toy Story\", \"The Dark Knight\", \"Titanic\"]:\n",
    "    print(f\"Because you watched {title}:\")\n",
    "    print(get_recommendations(title).round(3).to_string(), \"\\\\n\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0011",
   "metadata": {},
   "source": [
    "The clusters fall out beautifully: *Toy Story* pulls the other animated\n",
    "family films, *The Dark Knight* finds *Batman Begins* through shared words\n",
    "like \"gotham\", \"batman\", and \"superhero\", and *Titanic* lands in the romance\n",
    "corner. Nobody told the model about genres as a concept — the vocabulary\n",
    "overlap alone encodes it."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0012",
   "metadata": {},
   "source": [
    "> **CountVectorizer or TfidfVectorizer?**\n",
    "> \n",
    "> For free-text like synopses, TF-IDF is almost always better — it silences\n",
    "> filler words automatically. But for curated metadata (genre tags, cast names,\n",
    "> director), plain `CountVectorizer` is often the right call: every token was\n",
    "> chosen deliberately, and you may *not* want \"Steven Spielberg\" down-weighted\n",
    "> just because he directed many films. A common design: count-vectorize the\n",
    "> structured tags, TF-IDF the synopsis, and combine. You can also repeat\n",
    "> important tokens in the soup to weight them manually."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0013",
   "metadata": {},
   "source": [
    "## Strengths and the filter bubble\n",
    "\n",
    "What content-based filtering gets right:\n",
    "\n",
    "- **No other users needed.** It works from day one with a catalog of one user\n",
    "  — similarity comes from item attributes, not crowd behavior. New *items*\n",
    "  are no problem either: as soon as a movie has a synopsis, it can be\n",
    "  recommended.\n",
    "- **It explains itself.** \"Recommended because you watched Toy Story — both\n",
    "  are animated family films\" is a legible, trust-building reason.\n",
    "\n",
    "Its central weakness is **over-specialization**. The system can only\n",
    "recommend things similar to what you've already consumed. Watch three space\n",
    "movies and your homepage becomes an airlock — it will never discover that\n",
    "you'd also love jazz documentaries, because no vocabulary connects them. Users\n",
    "get trapped in a *filter bubble* of their own history. Breaking out requires\n",
    "information the item descriptions don't contain: what *other people* with\n",
    "tastes like yours enjoyed. That's collaborative filtering, next lesson."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0014",
   "metadata": {},
   "source": [
    "### 🏋️ Exercise — More like A Quiet Place\n",
    "\n",
    "Extend the 15-movie corpus with **two movies of your own** — one horror film\n",
    "described with words overlapping *A Quiet Place* (monsters, silence,\n",
    "survival…) and one family film. Rebuild the TF-IDF matrix and check\n",
    "`get_recommendations(\"A Quiet Place\")`: does your horror film appear in the\n",
    "top 5? Which shared words do you think made the match?"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0015",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# Your solution here"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0016",
   "metadata": {},
   "source": [
    "<details><summary>✅ Show solution</summary>\n",
    "\n",
    "```python\n",
    "# Append these two movies to the data list from the lesson, then rerun:\n",
    "extra = [\n",
    "    (\"Bird Box\", \"horror thriller monsters blindfold survival a mother and two children travel a river blindfolded to escape creatures that make you look\"),\n",
    "    (\"Paddington\", \"family comedy adventure bear london marmalade a polite young bear from peru searches for a home with a kind london family\"),\n",
    "]\n",
    "data = data + extra\n",
    "df = pd.DataFrame(data, columns=[\"title\", \"soup\"])\n",
    "\n",
    "tfidf = TfidfVectorizer(stop_words=\"english\")\n",
    "matrix = tfidf.fit_transform(df[\"soup\"])\n",
    "sim = cosine_similarity(matrix)\n",
    "indices = pd.Series(df.index, index=df[\"title\"])\n",
    "\n",
    "print(get_recommendations(\"A Quiet Place\").round(3).to_string())\n",
    "# Bird Box should now rank near the top via shared words like\n",
    "# monsters / creatures / survival / family, while Paddington\n",
    "# joins the animated-family cluster instead.\n",
    "```\n",
    "\n",
    "</details>"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0017",
   "metadata": {},
   "source": [
    "Next up: collaborative filtering — dropping item descriptions entirely and\n",
    "learning taste from the ratings matrix itself."
   ]
  }
 ]
}