{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Graph-Based Movie Recommender System\n",
        "## Building Hybrid Recommendations with Neo4j Graph Data Science\n",
        "\n",
        "**Course:** Capstone Project  \n",
        "**Team Members:**\n",
        "- Bekithemba Nkomo\n",
        "- Peter Mangoro\n",
        "- Masheia Dzimba\n",
        "\n",
        "**Date:** March 22, 2026\n",
        "\n",
        "---\n",
        "\n",
        "## Executive Summary\n",
        "\n",
        "This project implements a **hybrid movie recommendation system** using Neo4j Graph Data Science (GDS), combining collaborative filtering with content-based signals to generate personalized recommendations. By representing the user–movie interaction space as a graph rather than a traditional utility matrix, we leverage graph algorithms (Jaccard similarity, FastRP embeddings, kNN, Louvain clustering) to discover taste patterns and surface relevant recommendations even in sparse datasets.\n",
        "\n",
        "Our hybrid approach aims for **greater diversity and cold-start resilience** than pure collaborative filtering by integrating genre and director metadata as first-class graph entities.\n",
        "\n",
        "**Key results (developed in this notebook):**\n",
        "- **Louvain communities:** Multiple taste communities identified on the user–user similarity projection .\n",
        "- **Hybrid vs collaborative:** Hybrid scoring adds genre/director structure, improving **coverage and interpretability** in extension comparisons .\n",
        "- **Jaccard vs FastRP+kNN:** Magnitude-aware similarity can diverge sharply from binary overlap when users disagree on co-rated items; we compare both empirically .\n",
        "\n",
        "---\n",
        "\n",
        "## 1. Introduction\n",
        "\n",
        "### 1.1 Problem Statement\n",
        "\n",
        "Recommendation systems face a fundamental challenge: how to predict which items a user will enjoy when we have only sparse, incomplete information about their preferences. Traditional approaches model this as a **utility matrix problem**—a user × item matrix where most entries are blank . With typical sparsity rates of 95–99% in production systems, collaborative filtering algorithms must infer missing preferences from limited overlap between users or items.\n",
        "\n",
        "This project addresses three core challenges in recommendation system design:\n",
        "\n",
        "**Challenge 1: Extreme sparsity**  \n",
        "Our dataset contains **20 users**, **25 movies**, and **101** `RATED` relationships—about **80% sparse** in the user×movie grid (101 of 500 possible cells). With only a handful of ratings per user, reliable user–user similarity is hard to estimate.\n",
        "\n",
        "**Challenge 2: Cold-start problem**  \n",
        "New users or items with few ratings cannot be matched via collaborative filtering alone. When a user has rated only one or two movies, there is insufficient signal to find similar users.\n",
        "\n",
        "**Challenge 3: Popularity bias**  \n",
        "Pure collaborative filtering tends to reinforce popularity, creating a feedback loop that buries high-quality niche content (the **long-tail** problem).\n",
        "\n",
        "### 1.2 Research Objectives\n",
        "\n",
        "This project investigates whether **graph-based recommender architectures** can mitigate these challenges by:\n",
        "\n",
        "1. **Modeling recommendations as graph traversals** rather than matrix operations alone\n",
        "2. **Comparing binary overlap (Jaccard) vs magnitude-aware similarity (FastRP + kNN)**\n",
        "3. **Implementing hybrid recommendations** combining collaborative and content signals\n",
        "4. **Detecting taste communities** via Louvain clustering\n",
        "5. **Evaluating system behavior** using hold-out testing and sensitivity analysis\n",
        "\n",
        "### 1.3 Dataset Description\n",
        "\n",
        "**Users (N=20):**\n",
        "- Demographics: age, gender, occupation\n",
        "- Rating behavior: ~5 ratings per user on average (101 total ratings)\n",
        "- Diverse occupations: students, engineers, healthcare workers, retirees, etc.\n",
        "\n",
        "**Movies (N=25):**\n",
        "- Dual-genre classification (e.g., *Inception* = Sci-Fi + Thriller)\n",
        "- Director metadata (e.g., multiple films per director where applicable)\n",
        "- External quality scores (`avgRating` on `Movie`)\n",
        "\n",
        "**Ratings (N=101):**\n",
        "- Scale: 1–5 stars\n",
        "- Distribution: strong positive skew most observed ratings are 4–5 stars (see §2.4)\n",
        "\n",
        "**Graph density:** 101 ratings / (20 × 25) possible user–movie pairs ≈ **20.2%** of cells filled, i.e. **~80% sparse**.\n",
        "\n",
        "### 1.4 Methodology Overview\n",
        "\n",
        "**Phase 1:** Graph construction and enrichment  \n",
        "**Phase 2:** Exploratory data analysis (eight queries)  \n",
        "**Phase 3:** Deeper analytical questions \n",
        "**Phase 4:** GDS pipelines (Jaccard, FastRP+kNN, recommendations, hybrid, community detection)  \n",
        "**Phase 5:** Extensions (cutoff sensitivity, cold-start, algorithm comparison, director affinity)\n",
        "\n",
        "### 1.5 Why Graph-Based Recommendations?\n",
        "\n",
        "**Advantages:**\n",
        "- **Explainability:** e.g. “recommended because you liked other films by this director or in this genre”\n",
        "- **Cold-start resilience:** content nodes provide alternative paths when collaborative edges are weak\n",
        "- **Scalability:** GDS algorithms are engineered for large property graphs\n",
        "- **Flexibility:** new relationship types can be added without rewriting a monolithic matrix pipeline\n",
        "\n",
        "**Trade-offs:**\n",
        "- Requires careful schema and projection design\n",
        "- In-memory graph projections add operational overhead\n",
        "- Ecosystem differs from classical matrix-factorization toolchains\n",
        "\n",
        "---\n",
        "\n",
        "## Document Guide\n",
        "\n",
        "The sections below follow **Data load** → **EDA** → **Analytical questions** → **GDS & hybrid recommender** → **Extensions** → **References**. \n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "---\n",
        "## Neo4j connection\n",
        "\n",
        "Uses environment variables `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 2,
      "id": "da8c8594",
      "metadata": {},
      "outputs": [],
      "source": [
        "import os\n",
        "import pandas as pd\n",
        "from neo4j import GraphDatabase\n",
        "\n",
        "# Connection (set these in your environment or .env)\n",
        "URI = os.environ.get(\"NEO4J_URI\")\n",
        "USER = os.environ.get(\"NEO4J_USER\")\n",
        "PASSWORD = os.environ.get(\"NEO4J_PASSWORD\")\n",
        "\n",
        "driver = GraphDatabase.driver(URI, auth=(USER, PASSWORD))\n",
        "\n",
        "def run_query(query, parameters=None):\n",
        "    \"\"\"Run Cypher and return a pandas DataFrame.\"\"\"\n",
        "    with driver.session() as session:\n",
        "        result = session.run(query, parameters or {})\n",
        "        return pd.DataFrame([dict(record) for record in result])\n",
        "\n",
        "# Display options\n",
        "pd.set_option(\"display.max_colwidth\", None)\n",
        "pd.set_option(\"display.max_rows\", 100)\n",
        "pd.set_option(\"display.max_columns\", None)\n",
        "\n",
        "import warnings\n",
        "warnings.filterwarnings(\"ignore\")"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "20aca4f3",
      "metadata": {},
      "source": [
        "---\n",
        "## 1. Data load and graph setup\n",
        "\n",
        "**Graph model:**\n",
        "- Nodes: \n",
        "    - `User`, \n",
        "    - `Movie`, \n",
        "    - `Genre`, \n",
        "    - `Director`\n",
        "- Relationships: \n",
        "    - `(:User)-[:RATED {rating, timestamp}]->(:Movie)`, \n",
        "    - `(:Movie)-[:IN_GENRE]->(:Genre)`, \n",
        "    - `(:Movie)-[:DIRECTED_BY]->(:Director)`\n",
        "\n",
        "### 1.1 Creating uniqueness constraints\n",
        "\n",
        "This step defines **uniqueness constraints** on the IDs and names of our core node types:\n",
        "- `User.userId`\n",
        "- `Movie.movieId`\n",
        "- `Genre.name`\n",
        "- `Director.name`\n",
        "\n",
        "These constraints ensure we do not accidentally create duplicate nodes for the same real-world entity when we use `MERGE` in later steps, and they also improve MATCH performance during import. We then run `SHOW CONSTRAINTS` to confirm that all four constraints have been created successfully."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 3,
      "id": "e7ce3ef1",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>name</th>\n",
              "      <th>type</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>director_name</td>\n",
              "      <td>UNIQUENESS</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>genre_name</td>\n",
              "      <td>UNIQUENESS</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>movie_id</td>\n",
              "      <td>UNIQUENESS</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>user_id</td>\n",
              "      <td>UNIQUENESS</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "            name        type\n",
              "0  director_name  UNIQUENESS\n",
              "1     genre_name  UNIQUENESS\n",
              "2       movie_id  UNIQUENESS\n",
              "3        user_id  UNIQUENESS"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# drop exiting nodes and relationships\n",
        "run_query(\"MATCH (n) DETACH DELETE n\")\n",
        "# Create constraints and indexes for user,movie,genre and director\n",
        "run_query(\"CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.userId IS UNIQUE;\")\n",
        "run_query(\"CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (m:Movie) REQUIRE m.movieId IS UNIQUE;\")\n",
        "run_query(\"CREATE CONSTRAINT genre_name IF NOT EXISTS FOR (g:Genre) REQUIRE g.name IS UNIQUE;\")\n",
        "run_query(\"CREATE CONSTRAINT director_name IF NOT EXISTS FOR (d:Director) REQUIRE d.name IS UNIQUE;\")\n",
        "\n",
        "# display constraints\n",
        "df = run_query(\"SHOW CONSTRAINTS YIELD name, type RETURN name, type\")\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "58b63332",
      "metadata": {},
      "source": [
        "### 1.2 Loading User nodes\n",
        "\n",
        "In this cell we use `LOAD CSV WITH HEADERS` to read `users.csv` from Neo4j's `import` directory and create one `User` node per row. We use `MERGE` instead of `CREATE` so that running this cell multiple times will not create duplicates thanks to the `user_id` uniqueness constraint. We also clean and cast the columns:\n",
        "- `name` and `gender` are trimmed strings\n",
        "- `age` is converted to an integer\n",
        "- `occupation` is stored as a string\n",
        "\n",
        "Finally, we run a quick `MATCH`/`RETURN` query to display a sample of 5 users and visually verify that the properties loaded correctly."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 4,
      "id": "7d634b58",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>name</th>\n",
              "      <th>age</th>\n",
              "      <th>gender</th>\n",
              "      <th>occupation</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>28</td>\n",
              "      <td>F</td>\n",
              "      <td>Software Engineer</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>34</td>\n",
              "      <td>M</td>\n",
              "      <td>Teacher</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Carol White</td>\n",
              "      <td>22</td>\n",
              "      <td>F</td>\n",
              "      <td>Student</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>David Kim</td>\n",
              "      <td>45</td>\n",
              "      <td>M</td>\n",
              "      <td>Doctor</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>31</td>\n",
              "      <td>F</td>\n",
              "      <td>Designer</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           name  age gender         occupation\n",
              "0    Alice Chen   28      F  Software Engineer\n",
              "1  Bob Martinez   34      M            Teacher\n",
              "2   Carol White   22      F            Student\n",
              "3     David Kim   45      M             Doctor\n",
              "4     Eva Rossi   31      F           Designer"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "run_query(\"\"\"\n",
        "LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row\n",
        "MERGE (u:User { userId: trim(row.userId) })\n",
        "SET u.name = trim(row.name),\n",
        "    u.age = toInteger(row.age),\n",
        "    u.gender = trim(row.gender),\n",
        "    u.occupation = trim(row.occupation);\n",
        "\"\"\")\n",
        "\n",
        "# show 5 users\n",
        "df = run_query(\"\"\"\n",
        "MATCH (u:User)\n",
        "RETURN u.name as name, u.age as age, u.gender as gender, u.occupation as occupation\n",
        "LIMIT 5\n",
        "\"\"\")\n",
        "\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "1e9a1509",
      "metadata": {},
      "source": [
        "### 1.3 Loading Movie nodes\n",
        "\n",
        "Here we import the movie metadata from `movies.csv` and create one `Movie` node per row, again using `MERGE` on `movieId` to keep the import safe to re-run. We set the core properties:\n",
        "- `title` (string, trimmed)\n",
        "- `year` (integer)\n",
        "- `genre1`, `genre2` (primary and secondary genre tags as strings)\n",
        "- `director` (director name)\n",
        "- `avgRating` (external average rating converted to `Float`)\n",
        "\n",
        "At this stage the genres and directors are still simple properties on the `Movie` nodes. We then return 5 sample movies to confirm that data loaded correctly."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 5,
      "id": "3b479a1a",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>year</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>The Matrix</td>\n",
              "      <td>1999</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Inception</td>\n",
              "      <td>2010</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>The Godfather</td>\n",
              "      <td>1972</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Parasite</td>\n",
              "      <td>2019</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Interstellar</td>\n",
              "      <td>2014</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           title  year\n",
              "0     The Matrix  1999\n",
              "1      Inception  2010\n",
              "2  The Godfather  1972\n",
              "3       Parasite  2019\n",
              "4   Interstellar  2014"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "run_query(\"\"\"\n",
        "LOAD CSV WITH HEADERS FROM 'file:///movies.csv' AS row\n",
        "MERGE (m:Movie { movieId: trim(row.movieId) })\n",
        "SET m.title = trim(row.title),\n",
        "    m.year = toInteger(row.year),\n",
        "    m.genre1 = trim(row.genre1),\n",
        "    m.genre2 = trim(row.genre2),\n",
        "    m.director = trim(row.director),\n",
        "    m.avgRating = toFloat(row.avgRating);\n",
        "\"\"\")\n",
        "\n",
        "# show 5 movies\n",
        "df = run_query(\"\"\"\n",
        "MATCH (m:Movie)\n",
        "RETURN m.title as title, m.year as year\n",
        "LIMIT 5\n",
        "\"\"\")\n",
        "\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "d9ccd599",
      "metadata": {},
      "source": [
        "### 1.4 Creating RATED relationships\n",
        "\n",
        "This cell connects users and movies using the interaction data in `ratings.csv`. For each row we:\n",
        "- `MATCH` the existing `User` and `Movie` by their IDs\n",
        "- `MERGE` a `(:User)-[r:RATED]->(:Movie)` relationship\n",
        "- Set `r.rating` as a `Float` and `r.timestamp` as a `datetime`\n",
        "\n",
        "Using `MERGE` on the relationship ensures that if we re-run the import we do not create duplicate `RATED` relationships between the same user and movie. We then sample 5 `(User)-[RATED]->(Movie)` triples (user name, rating, movie title) to verify that the wiring and property types are correct."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 6,
      "id": "2b28ea4c",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>name</th>\n",
              "      <th>rating</th>\n",
              "      <th>title</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>5.0</td>\n",
              "      <td>The Matrix</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>5.0</td>\n",
              "      <td>Inception</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>4.0</td>\n",
              "      <td>Interstellar</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>5.0</td>\n",
              "      <td>The Dark Knight</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>4.0</td>\n",
              "      <td>Dune</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "         name  rating            title\n",
              "0  Alice Chen     5.0       The Matrix\n",
              "1  Alice Chen     5.0        Inception\n",
              "2  Alice Chen     4.0     Interstellar\n",
              "3  Alice Chen     5.0  The Dark Knight\n",
              "4  Alice Chen     4.0             Dune"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "run_query(\"\"\" \n",
        "LOAD CSV WITH HEADERS FROM 'file:///ratings.csv' AS row\n",
        "MATCH (u:User { userId: trim(row.userId) })\n",
        "MATCH (m:Movie { movieId: trim(row.movieId) })\n",
        "MERGE (u)-[r:RATED]->(m)\n",
        "SET r.rating = toFloat(row.rating),\n",
        "    r.timestamp = datetime(row.timestamp);\n",
        "\"\"\")\n",
        "\n",
        "# show rated relationship\n",
        "df = run_query(\"\"\" \n",
        "MATCH (u:User) -[r:RATED]->(m:Movie)\n",
        "RETURN u.name as name ,r.rating as rating ,m.title as title\n",
        "LIMIT 5\n",
        "\"\"\")\n",
        "\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "181d14f8",
      "metadata": {},
      "source": [
        "### 1.5 Verification\n",
        "\n",
        "This verification query confirms that the graph matches the expected dataset size. We count:\n",
        "- The number of distinct `User` nodes\n",
        "- The number of distinct `Movie` nodes\n",
        "- The total number of `RATED` relationships\n",
        "\n",
        "The result shows `20` users, `25` movies, and `101` ratings. The project guide mentions 100 ratings; however, both the CSV file and this query confirm that we have 101 distinct (user, movie) ratings in the current dataset."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 7,
      "id": "a8bafec0",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>users</th>\n",
              "      <th>movies</th>\n",
              "      <th>ratings</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>20</td>\n",
              "      <td>25</td>\n",
              "      <td>101</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   users  movies  ratings\n",
              "0     20      25      101"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\" \n",
        "MATCH (u:User)-[r:RATED]->(m:Movie)\n",
        "RETURN count(DISTINCT u) AS users,\n",
        "count(DISTINCT m) AS movies,\n",
        "count(r) AS ratings;\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "18bf6b9b",
      "metadata": {},
      "source": [
        "### 1.5b Checking for duplicate (user, movie) ratings\n",
        "\n",
        "Because the rating count is 101 instead of the documented 100, we explicitly check for any user–movie pairs that might have more than one `RATED` relationship. We group by `(userId, movieId)` and count how many `RATED` relationships exist per pair.\n",
        "\n",
        "If this query returns no rows with `cnt > 1`, it means there are **no duplicate ratings** for the same (user, movie) pair and that our extra rating is simply an additional unique interaction rather than a data error."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 8,
      "id": "d06c160a",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Since nothing is returned there are no duplicates.\n"
          ]
        }
      ],
      "source": [
        "# checking for duplicate entries in rating\n",
        "df = run_query(\"\"\" \n",
        "MATCH (u:User)-[r:RATED]->(m:Movie)\n",
        "WITH u.userId AS uid, m.movieId AS mid, count(r) AS cnt\n",
        "WHERE cnt > 1\n",
        "RETURN uid, mid, cnt;\n",
        "\"\"\" )\n",
        "if df.empty : \n",
        "    print('Since nothing is returned there are no duplicates.')\n",
        "else : \n",
        "     display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "f962779a",
      "metadata": {},
      "source": [
        "### 1.6 Enrichment: Genre nodes\n",
        "\n",
        "In this enrichment step we convert the `genre1` and `genre2` properties on `Movie` nodes into first-class `Genre` nodes. For each movie:\n",
        "- If `genre1` is non-empty, we `MERGE` a `Genre` node with that name and create an `(:Movie)-[:IN_GENRE]->(:Genre)` relationship.\n",
        "- If `genre2` is non-empty, we repeat the same pattern for the secondary genre.\n",
        "\n",
        "We use `MERGE` both for the `Genre` nodes and the `IN_GENRE` relationships so that each genre appears only once in the graph and each movie–genre link is unique. We then list a few `Genre` names to confirm that the enrichment worked as expected."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 9,
      "id": "1e8e78c9",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>name</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Sci-Fi</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Crime</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Thriller</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Action</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Animation</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        name\n",
              "0     Sci-Fi\n",
              "1      Crime\n",
              "2   Thriller\n",
              "3     Action\n",
              "4  Animation"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Create genres and the IN_GENRE relationship\n",
        "# Genres from genre1 \n",
        "run_query(\"\"\" \n",
        "MATCH (m:Movie)\n",
        "WHERE m.genre1 IS NOT NULL AND m.genre1 <> ''\n",
        "MERGE (g:Genre { name: trim(m.genre1) })\n",
        "MERGE (m)-[:IN_GENRE]->(g);\n",
        "\"\"\" )\n",
        "\n",
        "# Genres from genre2\n",
        "run_query(\"\"\" \n",
        "MATCH (m:Movie)\n",
        "WHERE m.genre2 IS NOT NULL AND m.genre2 <> ''\n",
        "MERGE (g:Genre { name: trim(m.genre2) })\n",
        "MERGE (m)-[:IN_GENRE]->(g);\n",
        "\"\"\" )\n",
        "\n",
        "df = run_query(\"\"\" \n",
        "match (g:Genre)\n",
        "return g.name as name\n",
        "LIMIT 5\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "a4b213d5",
      "metadata": {},
      "source": [
        "### 1.7 Enrichment: Director nodes\n",
        "\n",
        "Similarly, we turn the `director` property on `Movie` nodes into a separate `Director` node. For every movie with a non-null director:\n",
        "- We `MERGE` a `Director` node keyed by the director's name.\n",
        "- We `MERGE` a `(:Movie)-[:DIRECTED_BY]->(:Director)` relationship.\n",
        "\n",
        "This normalization allows us to treat directors as content entities in their own right, enabling queries like \"top-rated directors\" and \"movies by directors this user likes.\" We return a few director names and their movies to verify the nodes and relationships were created correctly."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 10,
      "id": "4d7f5063",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>movie</th>\n",
              "      <th>director</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>The Revenant</td>\n",
              "      <td>Alejandro G. Iñárritu</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>The Matrix</td>\n",
              "      <td>Wachowski Sisters</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Inception</td>\n",
              "      <td>Christopher Nolan</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Interstellar</td>\n",
              "      <td>Christopher Nolan</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>The Dark Knight</td>\n",
              "      <td>Christopher Nolan</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "             movie               director\n",
              "0     The Revenant  Alejandro G. Iñárritu\n",
              "1       The Matrix      Wachowski Sisters\n",
              "2        Inception      Christopher Nolan\n",
              "3     Interstellar      Christopher Nolan\n",
              "4  The Dark Knight      Christopher Nolan"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# director nodes and DIRECTED_BY relationship\n",
        "run_query(\"\"\" \n",
        "MATCH (m:Movie)\n",
        "WHERE m.director IS NOT NULL AND m.director <> ''\n",
        "MERGE (d:Director { name: trim(m.director) })\n",
        "MERGE (m)-[:DIRECTED_BY]->(d);\n",
        "\"\"\" )\n",
        "\n",
        "df = run_query(\"\"\" \n",
        "match (m:Movie)-[:DIRECTED_BY]->(d:Director)\n",
        "return m.title as movie, d.name as director\n",
        "limit 5\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "2f922d91",
      "metadata": {},
      "source": [
        "### 1.8 Removing redundant Movie properties\n",
        "\n",
        "After creating Genre and Director nodes (and their relationships), the original genre1, genre2, and director properties on Movie nodes become redundant. In this step we remove those three properties from all Movie nodes.\n",
        "\n",
        "From this point on, genres and directors live only as separate nodes connected via IN_GENRE and DIRECTED_BY, which keeps the graph schema cleaner and avoids having the same information duplicated in two places."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 11,
      "id": "f8b90341",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "Empty DataFrame\n",
              "Columns: []\n",
              "Index: []"
            ]
          },
          "execution_count": 11,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "#remove the properties that were changed to nodes\n",
        "run_query(\"\"\" \n",
        "MATCH (m:Movie)\n",
        "REMOVE m.genre1, m.genre2, m.director;\n",
        "\"\"\" )"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "7e5e1959",
      "metadata": {},
      "source": [
        "---\n",
        "## 2. Exploratory data analysis (EDA)\n",
        "\n",
        "Use Cypher to explore: graph size, user activity, movie popularity, rating distribution, genre/director coverage."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "97e8c850",
      "metadata": {},
      "source": [
        "### 2.1 Total nodes and relationships, by label/type\n",
        "\n",
        "The graph currently contains **20 users**, **25 movies**, **14 genres**, and **21 directors**, connected by **101** `RATED`, **50** `IN_GENRE`, and **25** `DIRECTED_BY` relationships. \n",
        "- Every movie has at least one `DIRECTED_BY` edge (25 movies, 25 edges), which means all movies have an associated director, and some directors are responsible for multiple films. \n",
        "- 50 `IN_GENRE` relationships across 25 movies indicate that movies typically have two genres, and the 14 distinct `Genre` nodes are shared across multiple titles.\n",
        "\n",
        "From a recommender-system perspective, the dataset is **small and relatively sparse**: \n",
        "- 101 ratings for 20 users and 25 movies means each user has rated only a handful of movies, and each movie has only a few ratings. \n",
        "- This sparsity limits how strong user–user collaborative filtering signals can be, because there are few co-rated items per user pair. \n",
        "- However, the explicit `Genre` and `Director` structure (multi-genre movies and directors with multiple films) gives us useful **content-based signals** to build hybrid recommenders.\n",
        "- We can rely on shared genres and directors to recommend similar movies even when there is limited overlap in rating histories."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 12,
      "id": "aaf65666",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>kind</th>\n",
              "      <th>name</th>\n",
              "      <th>cnt</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>node</td>\n",
              "      <td>Director</td>\n",
              "      <td>21</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>node</td>\n",
              "      <td>User</td>\n",
              "      <td>20</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>node</td>\n",
              "      <td>Movie</td>\n",
              "      <td>25</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>node</td>\n",
              "      <td>Genre</td>\n",
              "      <td>14</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>relationship</td>\n",
              "      <td>RATED</td>\n",
              "      <td>101</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>relationship</td>\n",
              "      <td>IN_GENRE</td>\n",
              "      <td>50</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>relationship</td>\n",
              "      <td>DIRECTED_BY</td>\n",
              "      <td>25</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           kind         name  cnt\n",
              "0          node     Director   21\n",
              "1          node         User   20\n",
              "2          node        Movie   25\n",
              "3          node        Genre   14\n",
              "4  relationship        RATED  101\n",
              "5  relationship     IN_GENRE   50\n",
              "6  relationship  DIRECTED_BY   25"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# total nodes and relationships\n",
        "df = run_query(\"\"\"\n",
        "MATCH (n)\n",
        "WITH labels(n)[0] AS label, count(n) AS cnt\n",
        "RETURN 'node' AS kind, label AS name, cnt\n",
        "UNION ALL\n",
        "MATCH ()-[r]->()\n",
        "WITH type(r) AS name, count(r) AS cnt\n",
        "RETURN 'relationship' AS kind, name, cnt;\n",
        "\"\"\")\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "4c87e108",
      "metadata": {},
      "source": [
        "### 2.2 Five most active raters (name, occupation, # movies rated)\n",
        "\n",
        "The five most active raters (Alice Chen with 6 ratings; the others with ~5 each) are only slightly above the overall mean activity of about **5 ratings per user** (≈100 ratings ÷ 20 users). This suggests there are no extreme “power users” dominating the dataset; participation is relatively balanced.\n",
        "\n",
        "\n",
        "However, from a recommender-system perspective, even the most active users still have **very short rating histories** (only 5–6 movies). That means collaborative filtering will often be data-poor: many user pairs will share only a small number of co-rated movies, making similarity estimates **noisy** and potentially unstable. In later recommendation steps, we should therefore expect better robustness from **hybrid logic** that complements weak overlap signals with content-based evidence (shared genres/directors), especially for users with the sparsest histories."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 13,
      "id": "afa3719c",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>name</th>\n",
              "      <th>occupation</th>\n",
              "      <th>moviesRated</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Software Engineer</td>\n",
              "      <td>6</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Carol White</td>\n",
              "      <td>Student</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>David Kim</td>\n",
              "      <td>Doctor</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>Designer</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>Teacher</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           name         occupation  moviesRated\n",
              "0    Alice Chen  Software Engineer            6\n",
              "1   Carol White            Student            5\n",
              "2     David Kim             Doctor            5\n",
              "3     Eva Rossi           Designer            5\n",
              "4  Bob Martinez            Teacher            5"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\" \n",
        "MATCH (u:User)-[r:RATED]->(m:Movie)\n",
        "WITH u, count(r) AS numRated\n",
        "ORDER BY numRated DESC\n",
        "LIMIT 5\n",
        "RETURN u.name AS name, u.occupation AS occupation, numRated AS moviesRated;\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "9b465357",
      "metadata": {},
      "source": [
        "### 2.3 Ten most-rated movies (title, rating count, avg user rating, avgRating)\n",
        "\n",
        "The most‑rated movies show a clear **popularity concentration**: a few titles receive the most interaction (e.g., the top movies have ~6–7 ratings), while many others have only a handful. Even for the “popular” items, the rating counts are small, reinforcing that the user–movie interaction matrix is **sparse** and that overlap between users will often be limited.\n",
        "\n",
        "A key nuance is that **user ratings** (`avgUserRating`) are on a **1–5 scale**, while `avgRating` is an **external score on a different scale** (e.g., 0–10). Because of this, we should avoid comparing the raw numbers directly and instead compare them **relatively** (e.g., normalized to their own scales). In general, the most‑rated movies tend to be rated highly by users (mostly 4–5), which can reflect genuine quality but can also indicate **popularity/selection bias**: users are more likely to watch and rate well‑known titles they already expect to like.\n",
        "\n",
        "**Implications for recommendation quality:** sparse counts can make collaborative similarity estimates noisy, and popularity bias can cause recommendations to over‑surface the same mainstream items. This motivates using **hybrid recommendation logic** (collaborative support + genre/director overlap) and, where appropriate, simple debiasing ideas (e.g., down‑weighting very popular items or explicitly surfacing long‑tail “hidden gems”)."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 14,
      "id": "c410ae61",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>numRatings</th>\n",
              "      <th>avgUserRating</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Inception</td>\n",
              "      <td>7</td>\n",
              "      <td>4.57</td>\n",
              "      <td>8.8</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Pulp Fiction</td>\n",
              "      <td>7</td>\n",
              "      <td>4.14</td>\n",
              "      <td>8.9</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>The Matrix</td>\n",
              "      <td>6</td>\n",
              "      <td>4.67</td>\n",
              "      <td>8.7</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>The Godfather</td>\n",
              "      <td>6</td>\n",
              "      <td>4.67</td>\n",
              "      <td>9.2</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Dune</td>\n",
              "      <td>5</td>\n",
              "      <td>4.60</td>\n",
              "      <td>8.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>12 Angry Men</td>\n",
              "      <td>5</td>\n",
              "      <td>4.80</td>\n",
              "      <td>9.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Portrait of a Lady on Fire</td>\n",
              "      <td>5</td>\n",
              "      <td>4.60</td>\n",
              "      <td>8.1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Blade Runner 2049</td>\n",
              "      <td>4</td>\n",
              "      <td>4.75</td>\n",
              "      <td>8.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Interstellar</td>\n",
              "      <td>4</td>\n",
              "      <td>4.75</td>\n",
              "      <td>8.6</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>The Dark Knight</td>\n",
              "      <td>4</td>\n",
              "      <td>5.00</td>\n",
              "      <td>9.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                        title  numRatings  avgUserRating  avgRating\n",
              "0                   Inception           7           4.57        8.8\n",
              "1                Pulp Fiction           7           4.14        8.9\n",
              "2                  The Matrix           6           4.67        8.7\n",
              "3               The Godfather           6           4.67        9.2\n",
              "4                        Dune           5           4.60        8.0\n",
              "5                12 Angry Men           5           4.80        9.0\n",
              "6  Portrait of a Lady on Fire           5           4.60        8.1\n",
              "7           Blade Runner 2049           4           4.75        8.0\n",
              "8                Interstellar           4           4.75        8.6\n",
              "9             The Dark Knight           4           5.00        9.0"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\" \n",
        "MATCH (u:User)-[r:RATED]->(m:Movie)\n",
        "WITH m, count(r) AS numRatings, avg(r.rating) AS avgUserRating\n",
        "RETURN m.title AS title, numRatings, round(avgUserRating, 2) AS avgUserRating, m.avgRating AS avgRating\n",
        "ORDER BY numRatings DESC\n",
        "LIMIT 10;\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "dff29f04",
      "metadata": {},
      "source": [
        "### 2.4 Rating distribution (counts for 1–5)\n",
        "\n",
        "\n",
        "### Commentary (merged)\n",
        "\n",
        "The rating distribution contains only **3, 4, and 5** star ratings—there are **no 1‑ or 2‑star ratings**. In our data this is roughly **6 ratings of 3**, **41 of 4**, and **54 of 5**. The distribution is therefore **skewed toward high ratings** (rating inflation), with most observations concentrated at 4–5.\n",
        "\n",
        "This pattern is consistent with **selection bias**: users are more likely to watch and rate movies they already expect to like, and people who dislike a movie may not rate it at all. The result is a compressed effective scale, where ratings provide limited “negative” signal.\n",
        "\n",
        "**Implications for collaborative filtering:** when almost everything is rated 4–5, rating magnitude becomes less informative and many users appear similarly positive. **Jaccard similarity** is relatively unaffected because it treats “rated vs not rated” as binary overlap, while **cosine/embedding‑based similarity** can lose discriminative power when rating values have little variance. To mitigate this later, we can \n",
        "- (a) rely on overlap plus content signals (hybrid recommendations), and \n",
        "- (b) consider simple normalization ideas (e.g., subtracting each user’s mean rating) so that “always‑5” users do not dominate similarity scores."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 15,
      "id": "bb71c10a",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>rating</th>\n",
              "      <th>cnt</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>3.0</td>\n",
              "      <td>6</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>4.0</td>\n",
              "      <td>41</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>5.0</td>\n",
              "      <td>54</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   rating  cnt\n",
              "0     3.0    6\n",
              "1     4.0   41\n",
              "2     5.0   54"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAJOCAYAAACqS2TfAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAS/pJREFUeJzt3XeYFeX5P/57YWEXgaUXaRZUQOzYlmBHEEssWIIYwV4QC4rGJCoYY43GhtiI6EcRhVgTa4iABkEEjWiUWECMNAHpUlzm94c/zncXdinLDrvg63Vdc11nZp555j5n5xx4n5l5TlaSJEkAAAAAZa5SeRcAAAAAWyuhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEb2OqNHDkysrKyMtPUqVPLu6SYOnVqkZpGjhyZWdevX7/M8u23377caixs8ODBRerd2rz22mtxyCGHRK1atTLPsXbt2uVdVioq4vthcyv8/AcPHlze5ZSp/Pz8yMrKipycnPj222/Lu5ytyqGHHpo5bnr27LnJ/fXq1SvT32uvvbbpBQIVltANVHhrhoSsrKyoWrVq1KpVK3bcccfo2LFj9O/fP7755pvUaynr/3RVBFt7oF6fSZMmxfHHHx+jR4+OhQsXbtS2hY+H1dPEiROLbbs6DP2cAu+ax9bqKTs7O+rVqxcHHnhg3HTTTbFgwYIy2+fWHKjX5/nnn4+xY8dGRMTpp58eTZs2LbL+kUceibPPPjt23333yM7OLpMv90r6GxeeOnbsuFF9/vjjj/Hggw/GIYccEvXq1YsqVapEnTp1Yqeddoojjzwy+vbtG++++26pa64o+vTpE5UrV46IiN/+9reRJEk5VwSkJbu8CwAojZUrV8bKlStj4cKFMWXKlBgxYkT84Q9/iOuuuy6uu+66qFTp/32n2LJly7jjjjsy83Xr1i2PkouoW7dukZpatmxZjtWs33777Vek3q3Jc889FytWrIiIiJycnLj88sujfv36kZubW6r+7r333rXC3vjx4zNhqLxVhPdDQUFBzJs3L8aNGxfjxo2Lp556Kt57772oWbPmZtl/4ee/3377bZZ9bg433HBD5vFll1221vq+ffuW6RccaVi5cmUcddRR8c9//rPI8vnz58f8+fPjyy+/jH/84x+xcuXKyM/PL6cqy0bLli3jmGOOiZdeeik++OCDeP755+Okk04q77KAFAjdwBbntNNOi3333TcWLFgQEydOjNdffz0KCgqioKAg+vXrFzNnzoyBAwdm2jdv3jyuuuqqcqz4/1mxYkUkSRJ5eXkVpqYN0bZt22jbtm15l5GKr7/+OvN4v/32i1tvvXWT+hs6dGjccccd0aBBg8yye+65Z5P6LEvl+X648MILo2XLljF37twYOnRo5kz/Z599Fo899lhceumlm6WOLem9t6HGjBkTkyZNioiIVq1axV577bVWm8qVK0ebNm1i3333jUmTJsWHH35YpjUceeSR0alTp7WWb7fddhvcx6BBg4oE7kMPPTQOOuigyM3NjRkzZsT48eNj/PjxZVJvRfCrX/0qXnrppYiIeOihh4Ru2FolABXcW2+9lUREZnrssceKrP/Pf/6T7LDDDkXavPrqqyVuP2XKlMy6xYsXJ/3790/23nvvpEaNGkl2dnbSoEGDZM8990zOPffcTD833HBDkT6Km1b3e8ghh2SW9ejRI5k0aVJy/PHHJ3Xr1k0iIvnggw+SKVOmFNn2rbfeytRUeF/bbbddsnDhwqRPnz5Js2bNkpycnKRNmzbJfffdl6xatarI67Dmfgt77LHHiuwvSZK1aihuuuGGG0rcvrClS5cmd911V9K+ffukdu3aSZUqVZKGDRsmXbp0SZ555pn1/k2//PLLZMCAAcnuu++e5OTkJA0aNEjOOeecZN68ees6NNby448/JoMGDUoOP/zwpF69ekl2dnZSt27d5NBDD00efvjhZOXKlSW+JmtOa76GxSn8mleqVCnz+A9/+EOmzYwZM5IqVaokEZFUrly5xGNxteHDhydHH3100qhRo6RKlSpJ7dq1k/z8/ORPf/pTsmTJkky7zz//vMRjaLX9998/s/7cc89NkmTd74ckSZKCgoLkiSeeSI488sikQYMGSZUqVZL69esnRx99dPL3v/99va9JYWu+xoVr/PTTT4usu+CCC4ps+9VXXyWXXXZZ0qFDh6RZs2bJNttsk1StWjVp0qRJcuyxxyYvvfRSkfaF/xbFTdttt12mbUmfJ2vWu2zZsuSmm25Kdt5556Rq1apJ06ZNkyuvvDJZtmzZWs91zpw5yYUXXpg0atQoyc3NTdq1a5c8++yzZfL5syHOPffczD5++9vfFttm6dKlmcc9evQo9rXZWIVfs9WfF5vixBNPzPR36KGHFttm1qxZyfjx44td9+abbyannnpq0qJFiyQnJyfJy8tL2rZtm1x00UXJd999l2n33HPPJWeccUay++67Jw0bNkyqVKmSVK9ePWnTpk3Sq1evYt+b6/qMTZIk+fLLL5PevXsnrVu3TrbZZpskNzc3adOmTXLNNdcU2XdhixYtSqpWrZr5DJk2bdr6XyRgiyN0AxXe+kJ3kiTJe++9V6RNp06dSty+8H+mDj300HX+R/20005LkqT0oXvvvfdOqlevXqTdxoTuRo0aJfvuu2+x++vdu3eR16C8QveMGTOStm3brrOfrl27Fgm8a/5NOnToUOx2Bx988IYcIkmS/BRgDj744HXW0aFDh2TRokXFPqc1p40N3XvttVey3XbbJRGRNG3aNPN8r7/++kybwoFizWPxxx9/TE499dR11tSmTZtk+vTpmW0OOuigzLrzzz+/SG1ffPFFkW3HjBlT7GtfuIalS5cmHTt2XGcNffr02eC/ybpC98KFC4us+93vfldk25dffnm9x2f//v2L/VsUN5UmdJd0XP76178uUuv333+ftG7duti2xx13XImv94Z+/myIFi1aZLb729/+tt72aYTuRo0aJbVq1UqqVKmSNGvWLDnttNOSd999d6P6K/x6tWrVKpk1a9YGbbdq1aoiXzwUN33wwQeZ9l27dl1n27y8vOSjjz4qso91fca+8MILyTbbbFNif02bNk3+85//FFt7u3btij0ega2Hy8uBrcJ+++0Xe+65Z/z73/+OiIjRo0dHQUFBZpCa4nz66aeZUcMrVaoUZ555Zuyyyy4xZ86cmDJlSpERxTt16hQ1atSIgQMHxldffRUREfvuu2+cdtppmTbF3Rv7wQcfRHZ2dvz617+OnXfeOT777LONuld41qxZMX/+/Ljwwgujdu3a8eSTT8b//ve/iIi47777omvXrnHIIYdscH+Frb6v/P33349nnnkms7zw/a7t27dfbz/du3ePTz75JDN/8sknx6677hpvvvlmZrCjv/71r3HzzTfH9ddfX2wf77zzThxxxBHRvn37eOGFFzKXyY4ePTrGjh0bBx544HrruPTSS2P06NGZ+U6dOkV+fn6MHTs2Xn/99cx+Lr300vjLX/6SuU/9mWeeiffffz8iInbccce46KKLIiJit912W+8+C6tcuXJccskl0bdv3/j2229j+PDhcdJJJ8VDDz2U6fvYY4+N559/vtjtb7755nj22Wcz8wceeGB06tQpPv300xg2bFhE/HTMdu/ePXP57VlnnRVvv/12REQMHz487r///qhSpUpERDz99NOZvlq3br1B979eccUV8Y9//CMiIqpWrRq/+tWvYuedd45JkybFsGHDIkmSuOuuu6Jdu3Zx+umnb9TrU9i8efPitttuy8xnZWXFKaecUqRNdnZ27LXXXrHvvvtGgwYNIi8vL5YsWRL/+te/4q233oqIiD/84Q9xzjnnRNOmTeOiiy6KY489Nvr27ZvpY/WtKBERtWrV2ug633nnnTjxxBNj1113jaeeeipzOfxTTz0Vt956azRp0iQiIn7/+9/HZ599ltmuQ4cOcdhhh8Xbb78dL7/8crF9b8znz/pMmzYtpk2blplf/Zw3t1mzZmUe/+9//4tnnnkmhg0bFnfffXf07t17g/rYZ599Mq/Z5MmTo1mzZrHvvvtmpiOOOGKtAeIiIv70pz/Fo48+mpmvV69enHrqqdGoUaP473//Gy+++GKR9rVr145OnTpFmzZtok6dOlG1atWYNWtWPP/88zFt2rRYuHBhXHPNNfHKK6+st+YpU6ZEt27d4ocffoiIn27HOfHEE2PVqlXx1FNPxddffx3ffvttdO3aNSZNmrTWv0v77bdfTJgwISIi3n777a1mkE6gkPJO/QDrsyFnupMkWess4ezZs4vdfvWZpokTJxY5g7jm5do//vhjMnXq1CLL1nd54ZptIiJ54YUX1mqzoWe6IyJ56qmnimy3+lLliEi6d+++QbWt60z1+i4dX1ebDz74oMjyq6++usjrl5+fn1lXt27dpKCgIEmStf8mJ554Yub1nzt3bpHLsO+9995iaypszpw5RbY59dRTi6wvfGxUrlw5mTNnTmZd4TN+hxxyyHr3VVjh17xdu3bJ999/n7myIT8/P3n88ccz6++88861XsfVx2JBQUHm9oPV2/7444+Z/Vx99dXFnrFbvHhxUqNGjczyl19+ObPNrrvumll+2223ZZaX9H6YO3dukp2dnVn+l7/8pchzvfjiizPr9t577w16fdZ3NUFEJHXq1EmefPLJEvuYPHlyMnTo0OS+++5L/vSnPyV33HFHkTOKTzzxRJH2G/JZUVKbNeu9/PLLM+s+/PDDIutWX96+cuXKIn+D9u3bZ/52BQUFyWGHHVZmnz8l+ec//5npq2rVqhu0TVme6c7JyUmOPvropE+fPkm/fv3WOrtfqVKltc4al2T+/PmZK0aKm7KyspJjjjmmyBUDBQUFSYMGDYqcVV7zDPmcOXOS+fPnF1m2YsWKZPTo0cmgQYOSP//5z8kdd9yRnHXWWZl+cnJykhUrVmTal/QZe8UVV2SW77LLLskPP/yQWTd9+vQin00vvvjiWs/5pptuKvVnELBlcKYb2GokG/lzK23atIl69erF3Llz49NPP42ddtop9t5779hll11ijz32iI4dO27UAEDF2W233eL4448v9fZVqlQpcjZ9++23jw4dOmTO9K0+O1Je1vzZnh49emQeV65cOc4444xMm3nz5sXkyZOjTZs2a/Vz0UUXZX6urG7dulG/fv3MWbPvv/9+vXW89957UVBQUGwdq+dXn0UuKCiI9957L7p06bIhT3Gj1K5dO84888wYOHBgvPvuu5mzj9WrV49zzjmnxLPckydPjnnz5mXmzzjjjCJnw3r06BG33357Zv7dd9+NvfbaK6pXrx6nnHJKPPbYYxHx09ntY489Nj766KP4z3/+ExE//R1+/etfr7f2cePGxY8//piZP/vss+Pss88utu2HH34YS5cujW222Wa9/a7P2WefHaeeeupay6dOnRrdu3ePMWPGrHP71Vd+pOHiiy/OPG7VqlWRdauPy88++ywWL16cWd69e/fM365SpUrRo0ePzPu1sLL8/Pnuu+8yj+vUqbPhT3A9xowZU+zr3759+8xVMJ07d45Zs2atdSXBI488Eueff35ERKxatSoef/zx+NOf/rTefdaqVSvGjRsXN954YwwZMiTmz59fZH2SJPH3v/89vvzyy/jggw8iNzc3Jk+eXOQ1uPTSS6Nhw4ZFtqtXr16R+aeeeiouv/zymDNnTom1LF++PObMmRPbbrvtOmv+17/+lXn83//+N6pVq1Zi2zFjxsQvf/nLEmsr/DyArYfQDWw1/vvf/2Ye5+bmrvWfrDXl5ubGs88+G2eddVZMmzYtvvrqq8yl4xE/XV57yy23RJ8+fUpdU+vWrUu9bcRP/xlb81LERo0aZR6v+R/S1db8AmL58uWbVEdJCgfFiKK1FTdfUoBe83eCc3JyMo9XrVq12eooC717986Mnv/tt99GxE+heV2XN29K/WeffXYmdL/44ouxdOnSGDJkSGZ9ly5d1hsaiqthXZIkiblz52506L7wwgujadOm8cYbb2Qui7/zzjtj7ty5meew2gknnJC5XWRd0jq2I4oel4WPyYj/d1yu+R5s3LjxOudX2xyfP5vqjTfeiP79+6+1/IYbbsiE7pKOrXPOOScuv/zyWLp0aUT8dDn9hmrUqFEMGDAg7r333vjwww/jvffei5EjR8aLL76Y+Xt/9tln8corr8RJJ5201rG7ww47rLP/iRMnxplnnrlBny0bcnxtzHunuFC9sV8YA1seoRvYKrz//vtF/oN+yCGHFPmt7pIcfvjhMWXKlJg4cWJ8+OGH8cUXX8SYMWPi7bffjhUrVkTfvn3jl7/8Zey0006lqqt69eql2m61uXPnrnVveuH7JmvXrp15XPj5rr63cLXPP/98k+ooyZr3sc+aNavIlx2Fa40o+Szc6vuQV1t91ntT6ljXfFmeDVxTmzZtolOnTvHGG29ExE/PZX33s25K/R06dIidd945Pv/881iyZEm8+OKLMXTo0Mz6s846a4PqXrOGK664InPPcnFKc4/0aaedFoceemj89re/jWOPPTZeffXViIgYPHhwnH322XHQQQdFxE9n/gu/n08//fS4/fbbo0mTJpGVlRUNGzbcLGcECx+XJR2Thd+DERGzZ88uMj9z5swS+y+rz5/69etnHqf5hdKm2Nj3dMRPV2m0a9cu2rVrFxdddFG8/fbbcfDBB2fWr/5cW/PYnTJlyjr7HTZsWCZwZ2VlxZAhQ+K4446L6tWrxyuvvBLHHHPMRtVZeP9t27Zd5z3ZxY0VUTi0F/6pQWDrIXQDW7zJkyfHr371qyLLNuTs0LJly2LKlCmZ361dPfhQkiRRp06dWLBgQaxatSr+/e9/Z/7TW/g/4avP4KRp5cqV8cwzz2QGrZo6dWq88847mfXt2rXLPC78n/8PPvggVqxYEVWrVo1vv/02Hn/88RL3sWbg3ZjLhtccaO3xxx/PDJBVUFAQTz75ZGZd3bp117pEt6zsv//+Ubly5cwl5o8//ngcffTRReparXLlyrH//vunUsdql112WSZ0H3nkkeu94qFVq1ZRt27dzH++n3zyybjgggsyX7as+fdb83U/66yz4re//W1ERPzud7/L/PZ4/fr147jjjtugmg844IAir2GVKlWK/T3rqVOnxuTJkyMvL2+D+i1OpUqV4t57743WrVtn9nf99ddnLsOeO3dukfYnn3xyZvCskSNHrjNwZ2dnZy6T3xzv0datW0eNGjUyl5g/88wzccEFF0RWVlYkSVLie680nz8l2XHHHTOPV6xYEbNnz17r8urS6NevX/Tr12+dbS677LK4/PLL1zq7PGjQoCKv/+67775B+7zrrruicePGcdJJJ6016GSNGjWKzK/+zGvVqlU0aNAgc1zcd999cfbZZ6/1ZUTlypUjLy+vyPFVq1atOPXUUzNfWhYezHBDtW/fPt57772IiJgxY0Z069ZtrcHefvzxx3j55ZfjgAMOWGv7b775JvO48N8S2HoI3cAW57XXXos5c+bEwoUL44MPPojXXnutyL2ovXr1ik6dOq23n/nz58euu+4abdu2jf333z+aNGkS1apVi3feeScWLFiQaVc4zBb+j9Tf//73+M1vfhP169eP+vXrpzbi7Nlnnx1vv/12ZvTylStXZtade+65mcf77bdf5p7hL774IvbZZ59o06ZNvPXWW2uFmMLW/M/h6aefHu3bt49KlSrFr3/967UubS5szz33jCOOOCJGjBgRERG33357fPXVV9G2bdt44403itzzfdlll23Q1QelUa9evejZs2cMGjQoIn76j/P8+fPXGr08IuLMM89c760Hm6pLly7x4osvxqpVqzYobFSqVCmuuOKKuO666yLip3u2O3ToEJ06dYrPPvusSBA47LDDYs899yyy/ZlnnhnXXXddFBQUFDnLd8YZZ6z1pUpJ6tatG2effXY88sgjEfHT3/L999+P9u3bR25ubnz77bcxduzY+OCDD6JHjx7RuXPnDeq3JDvttFOcdtppmUvhR44cGWPGjIn27dvHTjvtFJUqVcqcjbzsssviww8/LPYy9DU1bdo086XD6kvXq1WrFnvvvXccccQRm1RzcbKzs6Nnz55x//33Z57H4YcfHgcffHCMHj26xFHIS/P5U5Ltt98+mjZtmrmdYeLEiXHUUUet1e7mm2/OfLGzesT+iJ8CaeEvWH73u99t8NUgjz/+eNx///1x8MEHR35+fuTm5sb7779fZNT2qlWrxjnnnLNB/X300Udx5ZVXRs2aNePggw+O3XffPfLy8mLGjBlFfmWhcuXKceSRR0bET++fvn37xtVXXx0RP93n36ZNm8zo5VOmTIkXXngh3nrrrdhrr72KfPk3f/78OOaYY6J9+/bxzjvvZL4s2xi9e/eOBx98MJYtWxbz5s2LvfbaK0455ZRo3rx5LF68OP7zn//EyJEjY/78+TFlypS1XtvCf4vVV3sAW5nyG8MNYMOsOdpySVN2dnbyhz/8ITNCdknbrx71dsaMGevtc//99y/y+9Ivvvhise3atm2babMhI5xv6Ojl9evXL/E3sC+++OIifc6aNSupV6/eWu0qVaqUdO7cuciywpYtW5Zsu+22xe5j/PjxSZKs/3e6C4+UXdy0vt/pLjwScZIkRUYvXv1b4euzIb/T/Ytf/CLzO92rleXo5etT0ujlSfLTaNWnnHLKOutv06ZN8u233xbbd5cuXdZqX9yI0et67ZcsWbLe3+le13G9vudb+DhPkiSZNGlSkpWVlVnfpUuXzLoLL7yw2H0fccQRSdOmTUs8PgqPJF146tWrV6ZN4eXrGr18TSVtt67f6V7z7/L1118nSVK6z591KXwcX3/99cW2Wdeo4Ot6P65LrVq11tlXbm5u8swzz2xwf4Wfx7qmP/7xj0W225jf6Z47d27SpEmTEo/tkl6LdX22P//885lfLtiY13bRokVJ1apVk4ifRmZffXwAW5d0TjkApKxy5cpRs2bN2GGHHeKII46I/v37x9SpU+P3v//9Bp9NrVOnTtx///3RrVu32HXXXaNu3bqZyw/33Xff+MMf/hAjRoyI7Oz/d1HQL3/5y7j//vujTZs2UbVq1bSeXkb16tXjnXfeid69e0fTpk2jatWq0apVq7jnnnsyZ9ZWa9iwYYwaNSq6dOkSNWrUiOrVq8fhhx8eI0eOXOvy+8JycnLilVdeiU6dOpXqkuHGjRvH+PHj484774z8/PyoVatWZGdnR4MGDeKoo46KoUOHxvDhw4u8jmmoXr16jBgxIh599NE47LDDom7dupGdnR116tSJQw45JB566KEYOXLkWpeoVhSVK1eOZ599NoYNGxZHH310NGzYMLKzs6NWrVpxwAEHxB133BHjx48v8T7rNe/dbteu3QZf0rvaNttsE6+//noMGTIkjj766GjUqFFkZ2dHtWrVomXLlnHyySfHww8/HHfddVepn2dhu+22W5HL31999dWYOHFiRPx0ifCNN94Y2223XVSpUiVatGgRffv2jZdffnmdx9If//jHuOyyy6JZs2ZrDUKYltq1a8fbb78dF1xwQTRs2DBycnJizz33jCeeeCLOPPPMtdpGlO7zZ10KjzQ/fPjwMntu6/OPf/wjrrvuumjfvn00b948cnJyolq1atGmTZvo1atXfPTRR8WOTl+S2267LZ588sk4++yzo127dtGsWbPIycmJnJyc2H777eO0006Lf/7zn5nbKVbLysqKRx55JN54443MWeaqVatGjRo1olWrVnH++edHs2bNIuKnqzreeeedOOmkkyIvLy+qVasW++23Xzz33HOlvmLphBNOiI8//jj69OkTu+++e9SoUSMqV64c9erVi/z8/Ojbt2/861//WmvQyJdffjlWrFgREREdO3aMFi1alGr/QMWWlSSGTAQA2BQ//PBDsT8VdfLJJ8df//rXiIjYeeedi/zKQlnbbbfd4pNPPomIny7T3tgvXdj8jj/++HjppZci4qcvS7p27VrOFQFpELoBADZRixYtonPnzpn7s2fPnh3Dhw+PV155JdPmvvvui0suuSS1Gv7617/GySefHBE//WTXo48+mtq+2HRffvlltGrVKgoKCmKvvfaKiRMnlmqUd6DiE7oBADZR7dq1iwyAtqbzzjsvHnroodRD1YEHHhjjxo2LqlWrxpQpU9b5s2+Ur169esUDDzwQET/dWlHc4HfA1kHoBgDYRLfddlu89tpr8dlnn8W8efOiUqVKse2228aBBx4Y55xzTiojpwOwZRC6AQAAICVGLwcAAICUCN0AAACQknR/NLUCWrVqVUyfPj1q1qxphEgAAABKJUmSWLRoUTRp0iQqVSr5fPbPLnRPnz49mjdvXt5lAAAAsBX45ptvolmzZiWu/9mF7po1a0bETy9MXl5eOVcDAADAlmjhwoXRvHnzTMYsyc8udK++pDwvL0/oBgAAYJOs77ZlA6kBAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJdnlXQAAALDlmzZtWsyZM6e8y+Bnrn79+tGiRYvyLqMIoRsAANgk06ZNi1at28SyH5aWdyn8zOVW2yYmf/ZphQreQjcAALBJ5syZE8t+WBq7d70yqjdoXt7l8DO15LtvYtJf74w5c+YI3QAAwNaneoPmkddkp/IuAyoUA6kBAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlFSp09+vXL7KysopMrVu3zqxftmxZ9OrVK+rVqxc1atSIrl27xqxZs8qxYgAAAChZhQrdERFt27aNGTNmZKZ33nkns+6KK66Il19+OYYNGxajRo2K6dOnx0knnVSO1QIAAEDJssu7gDVlZ2dH48aN11q+YMGCGDRoUAwZMiQOP/zwiIh47LHHok2bNjF27Ng48MADN3epAAAAsE4V7kz3559/Hk2aNIkdd9wxunfvHtOmTYuIiAkTJsTKlSujY8eOmbatW7eOFi1axLvvvltif8uXL4+FCxcWmQAAAGBzqFCh+4ADDojBgwfHa6+9FgMHDowpU6bEQQcdFIsWLYqZM2dG1apVo3bt2kW2adSoUcycObPEPm+55ZaoVatWZmrevHnKzwIAAAB+UqEuL+/SpUvm8R577BEHHHBAbLfddvHss89GtWrVStXntddeG3369MnML1y4UPAGAABgs6hQZ7rXVLt27dhll13iiy++iMaNG8eKFSti/vz5RdrMmjWr2HvAV8vJyYm8vLwiEwAAAGwOFTp0L168OL788svYdttto127dlGlSpUYMWJEZv3kyZNj2rRpkZ+fX45VAgAAQPEq1OXlV111VRx33HGx3XbbxfTp0+OGG26IypUrR7du3aJWrVpxzjnnRJ8+faJu3bqRl5cXvXv3jvz8fCOXAwAAUCFVqND9v//9L7p16xZz586NBg0aRIcOHWLs2LHRoEGDiIj485//HJUqVYquXbvG8uXLo3PnzvHAAw+Uc9UAAABQvAoVuocOHbrO9bm5uTFgwIAYMGDAZqoIAAAASq9C39MNAAAAWzKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlGSXdwEAABXZtGnTYs6cOeVdBj9z9evXjxYtWpR3GUApCN0AACWYNm1atGrdJpb9sLS8S+FnLrfaNjH5s08Fb9gCCd0AACWYM2dOLPthaeze9cqo3qB5eZfDz9SS776JSX+9M+bMmSN0wxZI6AYAWI/qDZpHXpOdyrsMALZABlIDAACAlFTY0H3rrbdGVlZWXH755Zlly5Yti169ekW9evWiRo0a0bVr15g1a1b5FQkAAADrUCFD9/jx4+Ohhx6KPfbYo8jyK664Il5++eUYNmxYjBo1KqZPnx4nnXRSOVUJAAAA61bhQvfixYuje/fu8cgjj0SdOnUyyxcsWBCDBg2Ku+66Kw4//PBo165dPPbYYzFmzJgYO3ZsOVYMAAAAxatwobtXr15xzDHHRMeOHYssnzBhQqxcubLI8tatW0eLFi3i3XffLbG/5cuXx8KFC4tMAAAAsDlUqNHLhw4dGhMnTozx48evtW7mzJlRtWrVqF27dpHljRo1ipkzZ5bY5y233BL9+/cv61IBAABgvSrMme5vvvkmLrvssnjqqaciNze3zPq99tprY8GCBZnpm2++KbO+AQAAYF0qTOieMGFCzJ49O/bZZ5/Izs6O7OzsGDVqVNx7772RnZ0djRo1ihUrVsT8+fOLbDdr1qxo3Lhxif3m5OREXl5ekQkAAAA2hwpzefkRRxwRkyZNKrLsrLPOitatW8c111wTzZs3jypVqsSIESOia9euERExefLkmDZtWuTn55dHyQAAALBOFSZ016xZM3bbbbciy6pXrx716tXLLD/nnHOiT58+Ubdu3cjLy4vevXtHfn5+HHjggeVRMgAAAKxThQndG+LPf/5zVKpUKbp27RrLly+Pzp07xwMPPFDeZQEAAECxKnToHjlyZJH53NzcGDBgQAwYMKB8CgIAAICNUGEGUgMAAICtjdANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKSl16D788MNjxIgRJa5/66234vDDDy9t9wAAALDFK3XoHjlyZMyaNavE9bNnz45Ro0aVtnsAAADY4m3S5eVZWVklrvviiy+iZs2am9I9AAAAbNGyN6bx448/Ho8//nhm/qabbopHHnlkrXbz58+Pjz76KI4++uiNKmbgwIExcODAmDp1akREtG3bNq6//vro0qVLREQsW7Ysrrzyyhg6dGgsX748OnfuHA888EA0atRoo/YDAAAAm8NGneleunRpfPfdd/Hdd99FRMSiRYsy86unOXPmRE5OTlx44YXx6KOPblQxzZo1i1tvvTUmTJgQ77//fhx++OFx/PHHxyeffBIREVdccUW8/PLLMWzYsBg1alRMnz49TjrppI3aBwAAAGwuG3Wm+6KLLoqLLrooIiJ22GGHuOeee+KXv/xlmRVz3HHHFZn/4x//GAMHDoyxY8dGs2bNYtCgQTFkyJDMAG2PPfZYtGnTJsaOHRsHHnhgmdUBAAAAZWGjQndhU6ZMKcs61lJQUBDDhg2LJUuWRH5+fkyYMCFWrlwZHTt2zLRp3bp1tGjRIt59990SQ/fy5ctj+fLlmfmFCxemWjcAAACsVurQvdqiRYvi66+/ju+//z6SJFlr/cEHH7xR/U2aNCny8/Nj2bJlUaNGjXj++edj1113jQ8//DCqVq0atWvXLtK+UaNGMXPmzBL7u+WWW6J///4bVQMAAACUhVKH7jlz5kTv3r3jr3/9axQUFKy1PkmSyMrKKnbdurRq1So+/PDDWLBgQQwfPjx69OixST89du2110afPn0y8wsXLozmzZuXuj8AAADYUKUO3eeff368/PLLcemll8ZBBx0UderUKZOCqlatGjvttFNERLRr1y7Gjx8f99xzT5x22mmxYsWKmD9/fpGz3bNmzYrGjRuX2F9OTk7k5OSUSW0AAACwMUodut9444244oor4vbbby/LetayatWqWL58ebRr1y6qVKkSI0aMiK5du0ZExOTJk2PatGmRn5+fag0AAABQGqUO3dtss01sv/32ZVjKT5eCd+nSJVq0aBGLFi2KIUOGxMiRI+P111+PWrVqxTnnnBN9+vSJunXrRl5eXvTu3Tvy8/ONXA4AAECFVOrQfcYZZ8Tzzz8fF198cZkVM3v27DjzzDNjxowZUatWrdhjjz3i9ddfjyOPPDIiIv785z9HpUqVomvXrrF8+fLo3LlzPPDAA2W2fwAAAChLpQ7dJ598cowaNSqOOuqoOP/886N58+ZRuXLltdrts88+G9znoEGD1rk+Nzc3BgwYEAMGDNjoegEAAGBzK3Xo7tChQ+bxm2++udb60o5eDgAAAFuLUofuxx57rCzrAAAAgK1OqUN3jx49yrIOAAAA2OpUKu8CAAAAYGtV6jPdZ5999nrbZGVlrXdwNAAAANhalTp0//Of/4ysrKwiywoKCmLGjBlRUFAQDRo0iOrVq29ygQAAALClKnXonjp1arHLV65cGQ899FDcfffdxY5qDgAAAD8XZX5Pd5UqVeKSSy6JTp06xSWXXFLW3QMAAMAWI7WB1Pbcc88YPXp0Wt0DAABAhZda6H7zzTdjm222Sat7AAAAqPBKfU/3jTfeWOzy+fPnx+jRo2PixInxm9/8ptSFAQAAwJau1KG7X79+xS6vU6dOtGzZMh588ME477zzSts9AAAAbPFKHbpXrVpVlnUAAADAVie1e7oBAADg567UZ7pXGzVqVPz973+Pr7/+OiIitttuuzjmmGPikEMO2eTiAAAAYEtW6tC9YsWK6NatW7zwwguRJEnUrl07In4aSO3OO++ME088MZ5++umoUqVKWdUKAAAAW5RSX17ev3//eP755+PKK6+MGTNmxLx582LevHkxc+bMuOqqq+K5554rcYRzAAAA+DkodegeMmRI9OjRI26//fZo1KhRZnnDhg3jtttuizPPPDP+7//+r0yKBAAAgC1RqUP3jBkz4oADDihx/QEHHBAzZ84sbfcAAACwxSt16G7WrFmMHDmyxPWjRo2KZs2albZ7AAAA2OKVOnT36NEjnn322bjwwgtj8uTJUVBQEKtWrYrJkyfHRRddFMOGDYuePXuWYakAAACwZSn16OW//e1v48svv4yHH344HnnkkahU6af8vmrVqkiSJHr06BG//e1vy6xQAAAA2NKUOnRXrlw5Bg8eHH369IlXXnmlyO90H3300bHHHnuUWZEAAACwJdqo0L1s2bK4/PLLo23bttG7d++IiNhjjz3WCtj33ntvPPjgg3HPPff4nW4AAAB+tjbqnu6HH344Bg8eHMccc8w62x1zzDHxl7/8JR599NFNKg4AAAC2ZBsVup999tno2rVr7Ljjjuts17JlyzjllFPi6aef3qTiAAAAYEu2UaF70qRJ0aFDhw1q2759+/joo49KVRQAAABsDTYqdK9YsSKqVq26QW2rVq0ay5cvL1VRAAAAsDXYqNDdpEmT+Pjjjzeo7ccffxxNmjQpVVEAAACwNdio0N2xY8d44oknYvbs2etsN3v27HjiiSfiyCOP3KTiAAAAYEu2UaH7mmuuiWXLlsXhhx8e48aNK7bNuHHj4ogjjohly5ZF3759y6RIAAAA2BJt1O9077jjjvHss89Gt27don379rHjjjvG7rvvHjVr1oxFixbFxx9/HF9++WVss802MXTo0GjZsmVadQMAAECFt1GhO+Kn3+D+6KOP4rbbbou//e1v8cILL2TWNWnSJM4777y4+uqr1/uzYgAAALC12+jQHRGx/fbbx8CBA2PgwIGxaNGiWLhwYeTl5UXNmjXLuj4AAADYYpUqdBdWs2ZNYRsAAACKsVEDqQEAAAAbTugGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJRUqNB9yy23xH777Rc1a9aMhg0bxgknnBCTJ08u0mbZsmXRq1evqFevXtSoUSO6du0as2bNKqeKAQAAoGQVKnSPGjUqevXqFWPHjo0333wzVq5cGZ06dYolS5Zk2lxxxRXx8ssvx7Bhw2LUqFExffr0OOmkk8qxagAAAChednkXUNhrr71WZH7w4MHRsGHDmDBhQhx88MGxYMGCGDRoUAwZMiQOP/zwiIh47LHHok2bNjF27Ng48MADy6NsAAAAKFaFOtO9pgULFkRERN26dSMiYsKECbFy5cro2LFjpk3r1q2jRYsW8e6775ZLjQAAAFCSCnWmu7BVq1bF5ZdfHr/4xS9it912i4iImTNnRtWqVaN27dpF2jZq1ChmzpxZbD/Lly+P5cuXZ+YXLlyYWs0AAABQWIU9092rV6/4+OOPY+jQoZvUzy233BK1atXKTM2bNy+jCgEAAGDdKmTovuSSS+Jvf/tbvPXWW9GsWbPM8saNG8eKFSti/vz5RdrPmjUrGjduXGxf1157bSxYsCAzffPNN2mWDgAAABkVKnQnSRKXXHJJPP/88/HPf/4zdthhhyLr27VrF1WqVIkRI0Zklk2ePDmmTZsW+fn5xfaZk5MTeXl5RSYAAADYHCrUPd29evWKIUOGxIsvvhg1a9bM3Kddq1atqFatWtSqVSvOOeec6NOnT9StWzfy8vKid+/ekZ+fb+RyAAAAKpwKFboHDhwYERGHHnpokeWPPfZY9OzZMyIi/vznP0elSpWia9eusXz58ujcuXM88MADm7lSAAAAWL8KFbqTJFlvm9zc3BgwYEAMGDBgM1QEAAAApVeh7ukGAACArYnQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlFSo0D169Og47rjjokmTJpGVlRUvvPBCkfVJksT1118f2267bVSrVi06duwYn3/+efkUCwAAAOtRoUL3kiVLYs8994wBAwYUu/7222+Pe++9Nx588MEYN25cVK9ePTp37hzLli3bzJUCAADA+mWXdwGFdenSJbp06VLsuiRJ4u67747f//73cfzxx0dExBNPPBGNGjWKF154IX71q19tzlIBAABgvSrUme51mTJlSsycOTM6duyYWVarVq044IAD4t133y3HygAAAKB4FepM97rMnDkzIiIaNWpUZHmjRo0y64qzfPnyWL58eWZ+4cKF6RQIAAAAa9hiznSX1i233BK1atXKTM2bNy/vkgAAAPiZ2GJCd+PGjSMiYtasWUWWz5o1K7OuONdee20sWLAgM33zzTep1gkAAACrbTGhe4cddojGjRvHiBEjMssWLlwY48aNi/z8/BK3y8nJiby8vCITAAAAbA4V6p7uxYsXxxdffJGZnzJlSnz44YdRt27daNGiRVx++eVx0003xc477xw77LBDXHfdddGkSZM44YQTyq9oAAAAKEGFCt3vv/9+HHbYYZn5Pn36REREjx49YvDgwXH11VfHkiVL4vzzz4/58+dHhw4d4rXXXovc3NzyKhkAAABKVKFC96GHHhpJkpS4PisrK2688ca48cYbN2NVAAAAUDpbzD3dAAAAsKURugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlGSXdwEAlJ9p06bFnDlzyrsMfubq168fLVq0KO8yACAVQjfAz9S0adOiVes2seyHpeVdCj9zudW2icmffSp4A7BVEroBfqbmzJkTy35YGrt3vTKqN2he3uXwM7Xku29i0l/vjDlz5gjdAGyVhG6An7nqDZpHXpOdyrsMAICtkoHUAAAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIidANAAAAKRG6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBAAAgJUI3AAAApEToBgAAgJQI3QAAAJASoRsAAABSInQDAABASoRuAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFKyRYbuAQMGxPbbbx+5ublxwAEHxHvvvVfeJQEAAMBatrjQ/cwzz0SfPn3ihhtuiIkTJ8aee+4ZnTt3jtmzZ5d3aQAAAFDEFhe677rrrjjvvPPirLPOil133TUefPDB2GabbeIvf/lLeZcGAAAARWxRoXvFihUxYcKE6NixY2ZZpUqVomPHjvHuu++WY2UAAACwtuzyLmBjzJkzJwoKCqJRo0ZFljdq1Cg+++yzYrdZvnx5LF++PDO/YMGCiIhYuHBheoUCbAEWL14cERELp38RP65YVs7V8HO1dM7/IuKn47Ei/tvsfUJFUNHfJxHeK1QMm/u9snofSZKss11Wsr4WFcj06dOjadOmMWbMmMjPz88sv/rqq2PUqFExbty4tbbp169f9O/ff3OWCQAAwM/EN998E82aNStx/RZ1prt+/fpRuXLlmDVrVpHls2bNisaNGxe7zbXXXht9+vTJzM+fPz+22267mDZtWtSqVSvVemFLtXDhwmjevHl88803kZeXV97lQIXlvQLr530CG8Z7ZcuTJEksWrQomjRpss52W1Torlq1arRr1y5GjBgRJ5xwQkRErFq1KkaMGBGXXHJJsdvk5ORETk7OWstr1arlYIb1yMvL8z6BDeC9AuvnfQIbxntly7IhJ3K3qNAdEdGnT5/o0aNH7LvvvrH//vvH3XffHUuWLImzzjqrvEsDAACAIra40H3aaafFd999F9dff33MnDkz9tprr3jttdfWGlwNAAAAytsWF7ojIi655JISLydfn5ycnLjhhhuKveQc+In3CWwY7xVYP+8T2DDeK1uvLWr0cgAAANiSVCrvAgAAAGBrJXQDAABASoRuAAAASMnPJnSPHj06jjvuuGjSpElkZWXFCy+8UN4lQYVzyy23xH777Rc1a9aMhg0bxgknnBCTJ08u77KgQhk4cGDssccemd9Rzc/Pj1dffbW8y4IK79Zbb42srKy4/PLLy7sUqDD69esXWVlZRabWrVuXd1mUsZ9N6F6yZEnsueeeMWDAgPIuBSqsUaNGRa9evWLs2LHx5ptvxsqVK6NTp06xZMmS8i4NKoxmzZrFrbfeGhMmTIj3338/Dj/88Dj++OPjk08+Ke/SoMIaP358PPTQQ7HHHnuUdylQ4bRt2zZmzJiRmd55553yLokytkX+ZFhpdOnSJbp06VLeZUCF9tprrxWZHzx4cDRs2DAmTJgQBx98cDlVBRXLcccdV2T+j3/8YwwcODDGjh0bbdu2LaeqoOJavHhxdO/ePR555JG46aabyrscqHCys7OjcePG5V0GKfrZnOkGNt6CBQsiIqJu3brlXAlUTAUFBTF06NBYsmRJ5Ofnl3c5UCH16tUrjjnmmOjYsWN5lwIV0ueffx5NmjSJHXfcMbp37x7Tpk0r75IoYz+bM93Axlm1alVcfvnl8Ytf/CJ222238i4HKpRJkyZFfn5+LFu2LGrUqBHPP/987LrrruVdFlQ4Q4cOjYkTJ8b48ePLuxSokA444IAYPHhwtGrVKmbMmBH9+/ePgw46KD7++OOoWbNmeZdHGRG6gWL16tUrPv74Y/cVQTFatWoVH374YSxYsCCGDx8ePXr0iFGjRgneUMg333wTl112Wbz55puRm5tb3uVAhVT49tc99tgjDjjggNhuu+3i2WefjXPOOaccK6MsCd3AWi655JL429/+FqNHj45mzZqVdzlQ4VStWjV22mmniIho165djB8/Pu6555546KGHyrkyqDgmTJgQs2fPjn322SezrKCgIEaPHh33339/LF++PCpXrlyOFULFU7t27dhll13iiy++KO9SKENCN5CRJEn07t07nn/++Rg5cmTssMMO5V0SbBFWrVoVy5cvL+8yoEI54ogjYtKkSUWWnXXWWdG6deu45pprBG4oxuLFi+PLL7+MX//61+VdCmXoZxO6Fy9eXOQboylTpsSHH34YdevWjRYtWpRjZVBx9OrVK4YMGRIvvvhi1KxZM2bOnBkREbVq1Ypq1aqVc3VQMVx77bXRpUuXaNGiRSxatCiGDBkSI0eOjNdff728S4MKpWbNmmuNCVK9evWoV6+esULg/3fVVVfFcccdF9ttt11Mnz49brjhhqhcuXJ069atvEujDP1sQvf7778fhx12WGa+T58+ERHRo0ePGDx4cDlVBRXLwIEDIyLi0EMPLbL8sccei549e27+gqACmj17dpx55pkxY8aMqFWrVuyxxx7x+uuvx5FHHlnepQGwhfnf//4X3bp1i7lz50aDBg2iQ4cOMXbs2GjQoEF5l0YZykqSJCnvIgAAAGBr5He6AQAAICVCNwAAAKRE6AYAAICUCN0AAACQEqEbAAAAUiJ0AwAAQEqEbgAAAEiJ0A0AAAApEboBYAvVr1+/yMrKKu8yysTgwYMjKysrpk6dWt6lAECZEroBoIysDo6rp+zs7GjatGn07Nkzvv3221L1uXTp0ujXr1+MHDmybIstpZUrV0b9+vWjQ4cOJbZJkiSaN28e++yzz2asDAAqJqEbAMrYjTfeGP/3f/8XDz74YHTp0iWefPLJOOSQQ2LZsmUb3dfSpUujf//+xYbu3//+9/HDDz+UQcUbrkqVKnHKKafEmDFj4uuvvy62zejRo+N///tfnHHGGZu1NgCoiIRuAChjXbp0iTPOOCPOPffcePTRR+Oqq66KL7/8Ml566aUy3U92dnbk5uaWaZ8bonv37pEkSTz99NPFrh8yZEhUqlQpfvWrX23mygCg4hG6ASBlBx10UEREfPnll5llK1asiOuvvz7atWsXtWrViurVq8dBBx0Ub731VqbN1KlTo0GDBhER0b9//8xl6/369YuI4u/pzsrKiksuuSReeOGF2G233SInJyfatm0br7322lp1jRw5Mvbdd9/Izc2Nli1bxkMPPbRB94n/4he/iO233z6GDBmy1rqVK1fG8OHD47DDDosmTZrERx99FD179owdd9wxcnNzo3HjxnH22WfH3Llz1/u6FX6uhW2//fbRs2fPIsvmz58fl19+eTRv3jxycnJip512ittuuy1WrVq13v0AQJqyy7sAANjarR4crE6dOpllCxcujEcffTS6desW5513XixatCgGDRoUnTt3jvfeey/22muvaNCgQQwcODAuuuiiOPHEE+Okk06KiIg99thjnft755134rnnnouLL744atasGffee2907do1pk2bFvXq1YuIiA8++CCOOuqo2HbbbaN///5RUFAQN954Yybkr0tWVlacfvrpcfPNN8cnn3wSbdu2zax77bXXYt68edG9e/eIiHjzzTfjq6++irPOOisaN24cn3zySTz88MPxySefxNixY8tkILilS5fGIYccEt9++21ccMEF0aJFixgzZkxce+21MWPGjLj77rs3eR8AUFpCNwCUsQULFsScOXNi2bJlMW7cuOjfv3/k5OTEsccem2lTp06dmDp1alStWjWz7LzzzovWrVvHfffdF4MGDYrq1avHySefHBdddFHsscceG3yP9Keffhr/+c9/omXLlhERcdhhh8Wee+4ZTz/9dFxyySUREXHDDTdE5cqV41//+lc0adIkIiJOPfXUaNOmzQbto3v37nHzzTfHU089FTfffHNm+ZAhQyI3Nze6du0aEREXX3xxXHnllUW2PfDAA6Nbt27xzjvvZK4C2BR33XVXfPnll/HBBx/EzjvvHBERF1xwQTRp0iTuuOOOuPLKK6N58+abvB8AKA2XlwNAGevYsWM0aNAgmjdvHieffHJUr149XnrppWjWrFmmTeXKlTOBe9WqVTFv3rz48ccfY999942JEydu8v5XB+6In86M5+XlxVdffRUREQUFBfGPf/wjTjjhhEzgjojYaaedokuXLhu0j1133TX23nvvGDp0aGbZkiVL4qWXXopjjz028vLyIiKiWrVqmfXLli2LOXPmxIEHHhgRscnPc7Vhw4bFQQcdFHXq1Ik5c+Zkpo4dO0ZBQUGMHj26TPYDAKXhTDcAlLEBAwbELrvsEgsWLIi//OUvMXr06MjJyVmr3eOPPx533nlnfPbZZ7Fy5crM8h122GGT9t+iRYu1ltWpUye+//77iIiYPXt2/PDDD7HTTjut1a64ZSXp3r17XHXVVTFmzJho3759vPDCC7F06dLMpeUREfPmzYv+/fvH0KFDY/bs2UW2X7BgwQbva10+//zz+Oijj0q8NH7N/QLA5iR0A0AZ23///WPfffeNiIgTTjghOnToEKeffnpMnjw5atSoERERTz75ZPTs2TNOOOGE6Nu3bzRs2DAqV64ct9xyS5EB10qjcuXKxS5PkmST+l1Tt27d4uqrr44hQ4ZE+/btY8iQIVGnTp04+uijM21OPfXUGDNmTPTt2zf22muvqFGjRqxatSqOOuqoUg9yVlBQUGR+1apVceSRR8bVV19dbPtddtmlVPsBgLIgdANAilYH6cMOOyzuv//++M1vfhMREcOHD48dd9wxnnvuuSKDid1www1Fti+LgcbW1LBhw8jNzY0vvvhirXXFLStJkyZN4rDDDothw4bFddddF2+++Wb07Nkzc9n8999/HyNGjIj+/fvH9ddfn9nu888/36D+69SpE/Pnzy+ybMWKFTFjxowiy1q2bBmLFy+Ojh07bnDtALC5uKcbAFJ26KGHxv777x933313LFu2LCL+39nowmefx40bF++++26RbbfZZpuIiLXC56aoXLlydOzYMV544YWYPn16ZvkXX3wRr7766kb11b1795g9e3ZccMEFsXLlyiKXlhf3HCNig0cTb9my5Vr3Yz/88MNrnek+9dRT4913343XX399rT7mz58fP/744wbtDwDS4Ew3AGwGffv2jVNOOSUGDx4cF154YRx77LHx3HPPxYknnhjHHHNMTJkyJR588MHYddddY/HixZntqlWrFrvuums888wzscsuu0TdunVjt912i912222T6unXr1+88cYb8Ytf/CIuuuiiKCgoiPvvvz922223+PDDDze4n65du8bFF18cL774YjRv3jwOPvjgzLq8vLw4+OCD4/bbb4+VK1dG06ZN44033ogpU6ZsUN/nnntuXHjhhdG1a9c48sgj49///ne8/vrrUb9+/SLt+vbtmxnArWfPntGuXbtYsmRJTJo0KYYPHx5Tp05daxsA2Fyc6QaAzeCkk06Kli1bxp/+9KcoKCiInj17xs033xz//ve/49JLL43XX389nnzyycy94IU9+uij0bRp07jiiiuiW7duMXz48E2up127dvHqq69GnTp14rrrrotBgwbFjTfeGEcccUTk5uZucD95eXlx3HHHRcRP93iveTn8kCFDonPnzjFgwIC49tpro0qVKht8Nv28886La665JkaPHh1XXnllTJkyJd58882oXr16kXbbbLNNjBo1Kvr27RsjR46Myy67LG699db4/PPPo3///lGrVq0Nfj4AUNaykrIeVQUA2GKdcMIJ8cknn2zwfdcAwLo50w0AP1M//PBDkfnPP/88XnnllTj00EPLpyAA2Ao50w0AP1Pbbrtt9OzZM3bcccf4+uuvY+DAgbF8+fL44IMPYueddy7v8gBgq2AgNQD4mTrqqKPi6aefjpkzZ0ZOTk7k5+fHzTffLHADQBlyphsAAABS4p5uAAAASInQDQAAACkRugEAACAlQjcAAACkROgGAACAlAjdAAAAkBKhGwAAAFIidAMAAEBKhG4AAABIyf8HoiwXPp9iq0cAAAAASUVORK5CYII=",
            "text/plain": [
              "<Figure size 1000x600 with 1 Axes>"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "import matplotlib.pyplot as plt\n",
        "\n",
        "# rating distribution query\n",
        "df_ratings = run_query(\"\"\"\n",
        "MATCH (:User)-[r:RATED]->(:Movie)\n",
        "RETURN r.rating AS rating, count(*) AS cnt\n",
        "ORDER BY rating;\n",
        "\"\"\")\n",
        "\n",
        "display(df_ratings)\n",
        "\n",
        "# Visualize distribution\n",
        "plt.figure(figsize=(10, 6))\n",
        "plt.bar(df_ratings['rating'], df_ratings['cnt'], color='steelblue', edgecolor='black')\n",
        "plt.xlabel('Rating Value', fontsize=12)\n",
        "plt.ylabel('Count', fontsize=12)\n",
        "plt.title('Distribution of Movie Ratings (1-5 Scale)', fontsize=14, fontweight='bold')\n",
        "plt.xticks([1, 2, 3, 4, 5])\n",
        "plt.tight_layout()\n",
        "plt.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "776fbf00",
      "metadata": {},
      "source": [
        "### 2.5 Genres: movie count and average user rating per genre, sorted by avg \n",
        "\n",
        "The genre results show both **preference patterns** and **coverage imbalance**. On average rating alone, **Action** and **Mystery** are the highest (both **4.67**), followed closely by **Crime (4.59)** and **Sci‑Fi (4.57)**. This suggests that these genres align well with user tastes in this dataset and can serve as useful **content-based signals** for boosting recommendations (e.g., users who rated Action/Sci‑Fi highly are plausible candidates for other Action/Sci‑Fi titles).\n",
        "\n",
        "However, the reliability of a genre signal depends strongly on **how much data supports it**:\n",
        "- **Drama** dominates the catalog with **14 movies** and **57 total ratings**, making it the most statistically stable genre signal—but its average (**4.44**) also reflects the overall “inflated” rating environment.\n",
        "- **Sci‑Fi** has strong coverage (**8 movies**, **35 ratings**) and a high average (**4.57**), so it is likely a robust genre for content-based boosting.\n",
        "- In contrast, genres like **Mystery** (only **2 movies**, **6 ratings**) and all **single‑movie genres** (e.g., Animation, Fantasy, Music, Adventure, Horror) are “small‑n” signals: a high average could be driven by one well-liked title rather than a true broad preference for the genre.\n",
        "\n",
        "Finally, comparing **avgRating** with **totalRatings** hints at engagement: genres can be highly rated but lightly rated (niche/long‑tail) or heavily rated but only moderately higher than others (mainstream). For recommendation quality, this motivates using genre overlap as a feature, but weighting it by **coverage** (movieCount/totalRatings) and combining it with other signals (collaborative similarity, director affinity) instead of relying on genre averages alone."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 16,
      "id": "7ef6b937",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>genre</th>\n",
              "      <th>movieCount</th>\n",
              "      <th>totalRatings</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Action</td>\n",
              "      <td>4</td>\n",
              "      <td>18</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Mystery</td>\n",
              "      <td>2</td>\n",
              "      <td>6</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Crime</td>\n",
              "      <td>4</td>\n",
              "      <td>22</td>\n",
              "      <td>4.59</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Sci-Fi</td>\n",
              "      <td>8</td>\n",
              "      <td>35</td>\n",
              "      <td>4.57</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Animation</td>\n",
              "      <td>1</td>\n",
              "      <td>4</td>\n",
              "      <td>4.50</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Fantasy</td>\n",
              "      <td>1</td>\n",
              "      <td>4</td>\n",
              "      <td>4.50</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Romance</td>\n",
              "      <td>3</td>\n",
              "      <td>12</td>\n",
              "      <td>4.50</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Thriller</td>\n",
              "      <td>4</td>\n",
              "      <td>16</td>\n",
              "      <td>4.44</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Drama</td>\n",
              "      <td>14</td>\n",
              "      <td>57</td>\n",
              "      <td>4.44</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Comedy</td>\n",
              "      <td>3</td>\n",
              "      <td>10</td>\n",
              "      <td>4.40</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>10</th>\n",
              "      <td>Music</td>\n",
              "      <td>1</td>\n",
              "      <td>3</td>\n",
              "      <td>4.33</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>11</th>\n",
              "      <td>Biography</td>\n",
              "      <td>3</td>\n",
              "      <td>9</td>\n",
              "      <td>4.11</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>12</th>\n",
              "      <td>Adventure</td>\n",
              "      <td>1</td>\n",
              "      <td>3</td>\n",
              "      <td>4.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>13</th>\n",
              "      <td>Horror</td>\n",
              "      <td>1</td>\n",
              "      <td>3</td>\n",
              "      <td>3.67</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        genre  movieCount  totalRatings  avgRating\n",
              "0      Action           4            18       4.67\n",
              "1     Mystery           2             6       4.67\n",
              "2       Crime           4            22       4.59\n",
              "3      Sci-Fi           8            35       4.57\n",
              "4   Animation           1             4       4.50\n",
              "5     Fantasy           1             4       4.50\n",
              "6     Romance           3            12       4.50\n",
              "7    Thriller           4            16       4.44\n",
              "8       Drama          14            57       4.44\n",
              "9      Comedy           3            10       4.40\n",
              "10      Music           1             3       4.33\n",
              "11  Biography           3             9       4.11\n",
              "12  Adventure           1             3       4.00\n",
              "13     Horror           1             3       3.67"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\" \n",
        "MATCH (m:Movie)-[:IN_GENRE]->(g:Genre)\n",
        "MATCH (u:User)-[r:RATED]->(m)\n",
        "WITH g.name AS genre, count(DISTINCT m) AS movieCount, avg(r.rating) AS avgRating,count(r) AS totalRatings\n",
        "RETURN genre, movieCount, totalRatings,round(avgRating, 2) AS avgRating\n",
        "ORDER BY avgRating DESC;\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "70f642b6",
      "metadata": {},
      "source": [
        "### 2.6 Top 10 directors by number of movies; average user rating per director\n",
        "\n",
        "The director distribution is **highly sparse**: out of 25 movies there are **21 directors**, so most directors appear only once. In the top‑10 list, only a few directors have multiple films—**Christopher Nolan** leads with **3 movies** (avg user rating **4.73**), while **Denis Villeneuve** and **Damien Chazelle** each have **2 movies** (avg **4.67** and **4.43**, respectively). Everyone else in the table has **movieCount = 1**, meaning their “average” reflects a single title.\n",
        "\n",
        "This matters because director affinity is only a reliable content signal when there is **repeated evidence**. For directors with multiple movies (especially Nolan), we can be more confident using “director similarity” or “director boost” in hybrid recommendations, since the signal aggregates across several films and multiple user ratings. In contrast, a director with one film can look very strong (e.g., **Sidney Lumet: 4.80**) simply because that one movie is well-liked; this is a classic small‑sample effect and can lead to overconfident recommendations if we treat all directors equally.\n",
        "\n",
        "**Implications for recommendation quality:** director can be a valuable hybrid feature, but it should be **confidence‑weighted** by `movieCount` (and ideally by total ratings). Strong boosts make sense for multi‑film directors; for one‑film directors we should apply lighter/regularized boosts and rely more on genres and collaborative signals."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 17,
      "id": "f9d595bf",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>director</th>\n",
              "      <th>movieCount</th>\n",
              "      <th>avgUserRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Christopher Nolan</td>\n",
              "      <td>3</td>\n",
              "      <td>4.73</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Denis Villeneuve</td>\n",
              "      <td>2</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Damien Chazelle</td>\n",
              "      <td>2</td>\n",
              "      <td>4.43</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Sidney Lumet</td>\n",
              "      <td>1</td>\n",
              "      <td>4.80</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Rian Johnson</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Park Chan-wook</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Francis Ford Coppola</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Wachowski Sisters</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Bong Joon-ho</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Barry Jenkins</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "               director  movieCount  avgUserRating\n",
              "0     Christopher Nolan           3           4.73\n",
              "1      Denis Villeneuve           2           4.67\n",
              "2       Damien Chazelle           2           4.43\n",
              "3          Sidney Lumet           1           4.80\n",
              "4          Rian Johnson           1           4.67\n",
              "5        Park Chan-wook           1           4.67\n",
              "6  Francis Ford Coppola           1           4.67\n",
              "7     Wachowski Sisters           1           4.67\n",
              "8          Bong Joon-ho           1           4.67\n",
              "9         Barry Jenkins           1           4.67"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\" \n",
        "MATCH (d:Director)<-[:DIRECTED_BY]-(m:Movie)<-[r:RATED]-(u:User)\n",
        "WITH d.name AS director, count(DISTINCT m) AS movieCount, avg(r.rating) AS avgUserRating\n",
        "RETURN director, movieCount, round(avgUserRating, 2) AS avgUserRating\n",
        "ORDER BY movieCount DESC, avgUserRating DESC\n",
        "LIMIT 10;\n",
        "\"\"\" )\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "0c41c74c",
      "metadata": {},
      "source": [
        "### 2.7 Per-user rating count: mean, median, standard deviation\n",
        "\n",
        "User activity is **very uniform** in this dataset. The mean ratings per user is **5.05** and the median is **5.00**, with a very small standard deviation (**0.22**) and a narrow range (**min = 5**, **max = 6**). This indicates there are essentially **no power users** driving the interaction graph; almost everyone contributes the same number of ratings.\n",
        "\n",
        "While this is good from a bias perspective (no single user dominates similarity), it still implies **sparsity**: with **25 movies** total, a typical user has rated only about **~20% of the catalog** (≈5/25). As a result, many user pairs will share few co-rated items, so **user–user similarity** can be noisy and recommendations may have limited coverage when using collaborative filtering alone.\n",
        "\n",
        "**Implication for the recommender:** because all users are similarly sparse, we should expect hybrid methods (collaborative similarity + genre/director overlap) to be more stable than relying purely on overlap-based similarity, especially when generating top‑K recommendations for multiple users."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 18,
      "id": "9bc6091a",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            " User Activity Statistics \n",
            "Mean ratings per user: 5.05\n",
            "Median ratings per user: 5.00\n",
            "Standard deviation: 0.22\n",
            "\n",
            "Min: 5\n",
            "Max: 6\n"
          ]
        },
        {
          "data": {
            "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAJOCAYAAACqS2TfAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAeGlJREFUeJzt3Xl8TNf/x/H3JJHEkpBVLEHQ2lrUvrXWWr+20qpaYuuiVEl1oWqpVnSxtKWLtqiqL9qiC6WqQhVtFbW01BJSKgsS2UjI3N8ffuZrTBIRM5ksr+fjMY/k3nPunc/ETTvvnHPPmAzDMAQAAAAAAOzOxdkFAAAAAABQWBG6AQAAAABwEEI3AAAAAAAOQugGAAAAAMBBCN0AAAAAADgIoRsAAAAAAAchdAMAAAAA4CCEbgAAAAAAHITQDQAAAACAgxC6AQBWIiIiZDKZLI8TJ044uySdOHHCqqaIiAhL29SpUy37q1Sp4rQar7d48WKregub9evXq3Xr1ipdurTlNZYpU8bZZSETbdq0sfwbDRky5LbOVdivawBwFEI3ABQyN4Zmk8kkd3d3lS5dWlWrVlWHDh00bdo0/fPPPw6vxZ5v+POLoh489u/fr549e2rr1q1KTEy8pWOvvx4y+wPJjX9cmTp1qn2KdpIvvvjC5ndx3rx5djt/fvr9Kuq/FwCQHTdnFwAAcLzLly/r8uXLSkxMVGRkpDZt2qTp06frpZde0ksvvSQXl//9DbZatWp64403LNu+vr7OKNmKr6+vVU3VqlVzYjU317hxY6t6C5NVq1YpPT1dkuTh4aGxY8fK399fnp6eTq4s/1m0aJHNvsWLF2v06NF5VsPIkSP1n//8R5J011133da5CvN1DQCOROgGgEKuX79+atSokS5cuKDdu3drw4YNysjIUEZGhqZOnaro6Gi99957lv7BwcEaP368Eyv+n/T0dBmGIW9v73xTU07UqVNHderUcXYZDnHy5EnL940bN9bMmTOdWE3eSkxMlLe3d476RkdHa8OGDTb7f//9dx04cOC2A3BO9evXz27nKszXNQA4EtPLAaCQ69y5s8aPH6/p06dr7dq12r9/v0JCQizt77//vtavX2/Zzu6e7pSUFL388stq0KCBvLy8VKxYMQUGBqp+/fp69NFHLee5dp/1li1bLMd+8sknmZ73ximyBw4cUK9eveTn5ycPDw/99ddf2d7TfaOkpCQ988wzCg4Olqenp2rXrq158+bJMAyrftlNzc1squy1GoYOHWrVN7Pp0Debanvx4kXNmTNHLVu2lI+Pj9zd3VW2bFl17dpVK1eutOl/47/J8ePH9e6776pu3bry9PRUYGCgRowYofj4+Cx/LpnJyMjQwoUL1b59e/n7+6tYsWLy8/NT27Zt9eGHH+rKlSs2P5PrR2+3bduWp9Obf/rpJ/Xu3VsVKlSQu7u7SpUqpSpVqqhLly6aOnWqLly4YNXfbDbr008/VceOHRUYGCh3d3cFBASoW7duWrdunc35b/w5Hz16VG+++aZq1aolDw8PDR48OMe1fvrpp8rIyJAklSpVSuXLl7e0LV68OMvjrly5ooULF6pjx44qW7aspeZmzZpp2rRpkm7v90uSNm3aZNU3MjLS5udWvnx5S/urr75qqTu3vxdTpkyxbAcHB8tsNlv1P3jwoNUxv/zySw5/0gCQ/zHSDQBFTK1atbRixQo1adLEsm/OnDnq3LnzTY/9z3/+YxN44+LiFBcXpz/++ENJSUk5Ok9W9u3bp2bNmiklJSVXx1+6dEnt2rXTrl27LPv++usvPfXUU/r777/19ttv57o2e4mOjlaHDh108OBBq/2xsbH67rvv9N1332nlypVavny53Nwy/990aGiotm3bZtmOi4vTxx9/rCNHjlgFseykpKSoa9eu2rp1q9X+8+fPKyIiQhEREVqyZIm+++47lSpV6hZfpf1t2rRJnTp1sgRZ6eptEykpKTp58qTWr1+vhx9+WKVLl5Z09Q8bPXr00A8//GB1nrNnz2rdunVat26dwsLCNGvWrCyfc9iwYfrpp59yVe/1wbpHjx4qW7as5syZI0launSpZs6cafPve/78eXXu3Fm//fabTc1nz57VoUOHNGXKlFzVc7127dqpcuXKllkLy5cv14QJEyztEREROnPmjCTJxcVFoaGht/2cTzzxhMLDw3X58mWdOnVK69evV9euXS3t1/+xqXbt2mratOltPycA5BeEbgAogho3bqx69erpjz/+kCRt3bpVGRkZcnV1zfKYv/76yxK4XVxcNHjwYN155506e/asIiMjrcJ4x44dVapUKb333ns6fvy4JKlRo0ZWU10zu1d8z549cnNz06BBg3THHXfo0KFDt3SvcExMjBISEvTEE0+oTJky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ZGjp0qWWbV9fX9WoUSPH574VTZo0sfrDxI2v9/ptV1dXq5Xub8f1K1KbzWaFh4dbtlNTU21mSlzf//Dhw0pNTVVAQIB69uyp5557Th9//LFeeuklS59ri6k1bdrU6vUVK1ZM48ePt3n07dtXd999d44/e/tm/v33X23cuDHH/ffs2aN9+/ZJktVtEJI0ffp0qynykvVnpEu3//tVvHhxq/8WPPbYY5bz1KtXzzIVPCdy+nvh4eGhRx991LL97LPPWr4fNGhQliv2A0BBxn/ZAKCQW79+vc6ePavExETt2bNH69evt3ozP2rUKHXs2PGm50lISFDt2rVVp04dNWnSROXLl1fx4sW1bds2q89Hvj7MVqhQwfL92rVr9cILL8jf31/+/v4O+1znax/zdG318suXL1vaRowYYfm+cePGWr16tSTp6NGjatCggWrVqqXNmzdnOd1Ysn5N0tUpuC1atJCLi4sGDRqU7f2o9erVU/v27bVp0yZJ0uuvv67jx4+rTp06+v77763u+X766adzNPsgN/z8/DRkyBDL9N6VK1cqISHBZvVySRo8ePBNbz3IqYEDB+qll15SUlKSJOnVV1/VsmXLVLFiRf311186e/aspW/Lli1Vt25dy/acOXP06aefqn379goJCVHZsmV1/vx5LVmyxNLn2rXn6+urYcOG6cMPP5R09ee8a9cutWjRQp6enjp9+rR27typPXv2KDQ0VJ06dbLL61uyZInVvfLdu3e33Gd+jdlstppOvWjRIs2ZM0d33323unbtavn88G+//Vb16tVT165d5enpqYMHD2rr1q1WPyN7/H4NHTpUCxYskCSrz+u+1VHuW/m9GDlypF5//XVduXLF6jYDVi0HUGg5eSE3AICd3bgCc1YPNzc3Y/r06ZYVsrM6/trq5WfOnLnpOZs0aWJcvnzZcq6vvvoq03516tSx9MnJCsw5Xb3c39/fqFOnTqbP+eSTT1qdMyYmxvDz87Pp5+LiYnTq1CnLlZgvXbpklCtXLtPn+O233wzDyH4l5zNnzhi1a9fO9ufYp08fq59jVv8m11SuXDnTFb+zk5ycbNx3333Z1tGyZUsjKSnJ6rjbWb3cMAzj22+/NUqUKJHt84aEhNi8xscffzzbY1xcXIzVq1db+qekpBgdOnS46TV7/TV3s5/zzdSsWdNy7B133JFlv3vvvdfS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hw9bzQTIDwjdAAAAsJtGjaToaCkoSNq161YaASdLTJROn755v+Bg231xcTk7NjHx1uu6QUBAgLp166bFixfLMAx169ZN/v7+Vn2OHj2q1NRU3X///Vb709PTraahz58/XwsXLlRUVJQuXryo9PR01a9f3+qYOnXqWAXjcuXKaf/+/Zbt0aNHa/To0dnW/PDDD1u+v/vuu1W3bl1Vq1ZNERERat++fY5f+40ee+wxq/OWK1dO7du317Fjx276h4C8ROgGAACA3URHZ5M9sm0EnMzbW6pQ4eb9AgIy35eTY+10v/GwYcMsQXf+/Pk27cnJyZKktWvXqsINdV1bwGz58uUaP368Zs2apebNm8vLy0tvvPGGfvnlF6v+xYoVs9o2mUwym823VX/VqlXl7++vo0ePZhq6/f395erqqpiYGKv9MTExCgoKyvK8TZs2lXT1jw6EbgAAAADIT25n6veN080drHPnzkpPT5fJZFKnTp1s2mvXri0PDw9FRUWpdevWmZ7j559/VosWLfTkk09a9h07dsxhNV/v1KlTOnfunMqVK5dpu7u7uxo2bKhNmzapV69ekq7eg75p06ZsR9X37t0rSVme11kI3QAAAABQgLi6uuqvv/6yfH8jLy8vjR8/XuPGjZPZbFarVq104cIF/fzzz/L29lZoaKjuuOMOLVmyRBs2bFBISIg+/fRT/fbbbwoJCbmlWubNm6fVq1dr06ZNmbYnJydr2rRp6tOnj4KCgnTs2DE999xzql69utUfDNq3b6/evXtbQnVYWJhCQ0PVqFEjNWnSRHPnzlVKSopl0bhjx45p2bJl6tq1q/z8/LRv3z6NGzdO9913n+rWrXtLr8HRCN0AAACwuytXrujYsZNW+ypfuSK3/287mUcjasi/vL29FZDZVG3kyM0+Gmv69OkKCAhQeHi4jh8/rjJlyqhBgwaaOHGiJOnxxx/Xnj171K9fP5lMJvXv319PPvmkvvvuu1uq4+zZs9mOkLu6umrfvn365JNPlJCQoPLly6tjx46aPn261Wd1Hzt2TGfPnrVs9+vXT3FxcZo8ebKio6NVv359rV+/3rK4mru7u3744QdLGA8ODlafPn00adKkW6o/L5gMwzCcXUR+l5iYqNKlS+vChQv58nPfAAAA8ovy5TN05oyrPN1j1aFZL6u2RTt/l396us66u2tos4bOKRD5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AAAAAMBBCN0AAAAAADgIoRsAAAAAAAchdAMAAAAA4CBuzi4AAAAAhUdAwBVdiD+vAK8028Yypay/AkARQOgGAACA3axZc1pjhw3S3F7NJPlZN77ypFNqAgBnYno5AAAAAAAOQugGAAAAAMBBCN0AAAAAADgI93QDAADAbiZN8tfvf76iSenF9d+nfrRu/HiNlHxRKlVcGt7LGeUBQJ4jdAMAAMBuNm8uqZiz7bT5coKkG0L3nr+l+ETJx9sZpQGAUzC9HAAAAAAAByF0AwAAAADgIIRuAAAAAAAchNANAAAAAICDELoBAAAAAHAQQjcAAAAAAA5C6AYAAAAAwEEI3QAAAAAAOIibswsAAABA4dG9e7I2fLtVnWp62Ta2qCulXJRKFs/7wgDASQjdAAAAsJsXXjin6L9n6oUezST5WTc+0tkpNQGAMzG9HAAAAAAAByF0AwAAAADgIIRuAAAAAAAcxG73dBuGoc2bNystLU2tWrWSl1cmi2cAAACgUOvYMVhRJzeq46FkHZvzkXXj+LlSfJLk4yW9OdYZ5QFAnsvVSPeLL76otm3bWrYNw1DHjh11//33q1u3brr77rt17NixWz7v1q1b1b17d5UvX14mk0lr1qyxtF2+fFnPP/+87r77bpUsWVLly5fX4MGD9e+//2Z7zqlTp8pkMlk9atasecu1AQAA4OZSUlx0JaOkUtI8bBsvpUuX0q5+BYAiIleh+8svv1STJk0s21988YU2bdqkV155Rd9++60yMjI0derUWz5vSkqK6tWrp/nz59u0paamavfu3XrppZe0e/durVq1SocPH1aPHj1uet46derozJkzlse2bdtuuTYAAAAAAG5VrqaXnz59WtWrV7dsr1q1SrVr19aECRMkSSNHjtR77713y+ft0qWLunTpkmlb6dKltXHjRqt98+bNU5MmTRQVFaVKlSpleV43NzcFBQXdcj0AAAAAANyOXI10u7m5KS0tTdLVqeWbNm1S587/+9zFsmXL6uzZs/apMBsXLlyQyWRSmTJlsu135MgRlS9fXlWrVtWAAQMUFRXl8NoAAAAAAMjVSPddd92lpUuXasCAAVq9erXOnTunbt26WdpPnjwpf39/uxWZmUuXLun5559X//795e3tnWW/pk2bavHixapRo4bOnDmjadOm6d5779WBAweyXOwtLS3N8kcFSUpM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6ndtNF+SkpKSdPbsWd17771KTU3VoUOHcv38ku3P4d5777X6N1i/fr2KFSumRx991LLPxcVFo0aNsqnR3d1dERERio+Pz/HzJyYmSsp8VL5q1aoKCAiwPL7++ussz9OuXTv5+/trxYoVln3x8fHauHGj+vXrZ9n3+eefq1atWqpZs6blOjl79qzatWsnSdq8ebPVeTt06GA186Bu3bry9vZmNXUAyIcI3QCAAmHSpEm6cuXKTe/tvhWVKlWy2i5durQ8PT3l7+9vsz+zwHbHHXdYbZtMJlWvXt1yr/CRI0ckSaGhoVYhLSAgQB999JHS0tJ04cIFq3OEhITkqPaTJ0+qRo0aNvtr1aplac+tnj17auPGjVq7dq3lXvPU1FS5uFi/bTh48KB69+6t0qVLy9vbWwEBAZYF2G58XbfC09NTAQEBVvt8fHys/g1OnjypcuXKqUSJElb9qlevbrXt4eGh1157Td99953Kli2r++67T6+//rqio6OzreHaH1OSk5Nt2r766itt3LhRb7755k1fi5ubm/r06aOvvvrKcm/2qlWrdPnyZavQfeTIER08eNDmOrnzzjslXV0M73o3XruS7c8IAJA/cE83AKBAqFq1qgYOHKgFCxbohRdesGnPaoGwGxffut71C2Blt09StvdXZ+XaKPYbb7yh+vXrZ9rnxpHU60ePnaVixYrq0KGDJKlr167y9/fX6NGj1bZtWz3wwAOSpISEBLVu3Vre3t56+eWXVa1aNXl6emr37t16/vnnb+tjs7L6N8itsWPHqnv37lqzZo02bNigl156SeHh4frxxx91zz33ZHpM9erV5ebmpgMHDti0tW7dWtLVQJ0TDz/8sD744AN999136tWrl1auXKmaNWuqXr16lj5ms1l33323Zs+enek5goODrbbteZ0CAByL0A0AKDAmTZqkpUuX6rXXXrNp8/HxkXQ1DF7vdkZ8b+baSPY1hmHo6NGjqlu3riRZpv96e3tbQqy9VK5cWYcPH7bZf21ad+XKle32XI8//rjmzJmjSZMmqXfv3jKZTIqIiNC5c+e0atUqqwXmIiMjbY7PzYrpN1O5cmVt3rxZqampVqPdR48ezbR/tWrV9Mwzz+iZZ57RkSNHVL9+fc2aNUtLly7NtH/JkiXVpk0bbdmyRadPn1aFChVyXet9992ncuXKacWKFWrVqpV+/PFHvfjiizb1/fHHH2rfvr1Dfl4AAOdhejkAoMCoVq2aBg4cqA8++MBmerC3t7f8/f21detWq/3vvvuuw+pZsmSJkpKSLNtffPGFzpw5oy5dukiSGjZsqGrVqunNN9/MdJpyXFxcrp+7a9eu+vXXX7Vjxw7LvpSUFC1YsEBVqlRR7dq1c33uG7m5uemZZ57RX3/9pa+++krS/0Zarx9ZTU9Pz/TnXbJkyduabp6ZTp066fLly/rwww8t+8xms+bPn2/VLzU1VZcuXbLaV61aNXl5edl8FNeNJk+erIyMDA0cODDTf7+cjiq7uLiob9+++uabb/Tpp5/qypUrVlPLJemhhx7S6dOnrV7PNRcvXlRKSkqOngsAkP8w0g0AKFBefPFFffrppzp8+LDq1Klj1TZixAjNnDlTI0aMUKNGjbR161b9/fffDqvF19dXrVq10tChQxUTE6O5c+eqevXqlsW9XFxc9NFHH6lLly6qU6eOhg4dqgoVKuj06dPavHmzvL299c033+TquV944QX997//VZcuXTRmzBj5+vrqk08+UWRkpL788kub+69v15AhQzR58mS99tpr6tWrl1q0aCEfHx+FhoZqzJgxMplM+vTTTzMNog0bNtSKFSsUFhamxo0bq1SpUurevftt1dOrVy81adJEzzzzjI4ePaqaNWvq66+/1vnz5yX9b3T977//Vvv27fXQQw+pdu3acnNz0+rVqxUTE2P5uK6s3HvvvZo3b56eeuop3XHHHRowYIBq1qyp9PR0/f333/rss8/k7u6uoKCgm9bbr18/vfPOO5oyZYruvvtuy7331wwaNEgrV67UE088oc2bN6tly5bKyMjQoUOHtHLlSm3YsEGNGjXK5U8LAOBMhG4AQIFSvXp1DRw4UJ988olN2+TJkxUXF6cvvvhCK1euVJcuXfTdd98pMDDQIbVMnDhR+/btU3h4uJKSktS+fXu9++67VtOd27Rpox07dmj69OmaN2+ekpOTFRQUpKZNm+rxxx/P9XOXLVtW27dv1/PPP6933nlHly5dUt26dfXNN9+oW7du9nh5VooXL67Ro0dr6tSpioiIUJs2bfTtt9/qmWee0aRJk+Tj46OBAweqffv26tSpk9WxTz75pPbu3atFixZpzpw5qly58m2HbldXV61du1ZPP/20PvnkE7m4uKh3796aMmWKWrZsKU9PT0lX74Xu37+/Nm3apE8//VRubm6qWbOmVq5cqT59+tz0eUaOHKnmzZtrzpw5+vzzzxUdHa1ixYqpWrVqCg0N1ciRI20+vzwzLVq0UHBwsP755x+bUW7p6h9o1qxZozlz5mjJkiVavXq1SpQooapVq+rpp5+2LKgGACh4TAYrbgAAgEJizZo16t27t7Zt26aWLVs6uxwAAAjdAACgYLp48aLVau8ZGRnq2LGjdu3apejo6HyxEjwAAEwvBwAABdJTTz2lixcvqnnz5kpLS9OqVau0fft2zZgxg8ANAMg3GOkGAAAF0rJlyzRr1iwdPXpUly5dUvXq1TVy5EiNHj3a2aUBAGBB6AYAAAAAwEH4nG4AAAAAAByE0A0AAAAAgIMQugEAAAAAcBBCNwAAAAAADkLoBgAAAADAQQjdAAAAAAA4CKEbAAAAAAAHIXQDAAAAAOAghG4AAAAAABzk/wDKOelBNLXvGgAAAABJRU5ErkJggg==",
            "text/plain": [
              "<Figure size 1000x600 with 1 Axes>"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Per-user rating counts (single source of truth)\n",
        "user_activity = run_query(\"\"\"\n",
        "MATCH (u:User)-[r:RATED]->()\n",
        "RETURN u.userId AS userId, count(r) AS ratingsGiven\n",
        "ORDER BY ratingsGiven DESC\n",
        "\"\"\")\n",
        "\n",
        "# Compute summary statistics (pandas)\n",
        "mean_ratings = user_activity[\"ratingsGiven\"].mean()\n",
        "median_ratings = user_activity[\"ratingsGiven\"].median()\n",
        "std_ratings = user_activity[\"ratingsGiven\"].std()\n",
        "min_ratings = user_activity[\"ratingsGiven\"].min()\n",
        "max_ratings = user_activity[\"ratingsGiven\"].max()\n",
        "\n",
        "print(\" User Activity Statistics \")\n",
        "print(f\"Mean ratings per user: {mean_ratings:.2f}\")\n",
        "print(f\"Median ratings per user: {median_ratings:.2f}\")\n",
        "print(f\"Standard deviation: {std_ratings:.2f}\")\n",
        "print(f\"\\nMin: {min_ratings}\")\n",
        "print(f\"Max: {max_ratings}\")\n",
        "\n",
        "# Visualize distribution\n",
        "plt.figure(figsize=(10, 6))\n",
        "plt.hist(user_activity['ratingsGiven'], bins=range(1, user_activity['ratingsGiven'].max() + 2), \n",
        "         color='coral', edgecolor='black', alpha=0.7)\n",
        "plt.axvline(mean_ratings, color='red', linestyle='--', linewidth=2, label=f'Mean: {mean_ratings:.2f}')\n",
        "plt.axvline(median_ratings, color='blue', linestyle='--', linewidth=2, label=f'Median: {median_ratings:.2f}')\n",
        "plt.xlabel('Number of Ratings Given', fontsize=12)\n",
        "plt.ylabel('Number of Users', fontsize=12)\n",
        "plt.title('Distribution of User Activity', fontsize=14, fontweight='bold')\n",
        "plt.legend()\n",
        "plt.grid(axis='y', alpha=0.3)\n",
        "plt.tight_layout()\n",
        "plt.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "08f5c392",
      "metadata": {},
      "source": [
        "### 2.8 Top 5 “polarising” movies (≥3 ratings, highest rating std dev)\n",
        "\n",
        "The most polarising movies are those with the **highest standard deviation** of ratings (with at least 3 ratings). In our results, **Whiplash** is the most polarising (std dev **1.15** with 3 ratings), followed by **The Revenant** (**1.00**, 3 ratings) and **Roma** (**0.82**, 4 ratings). Even relatively popular titles like **Pulp Fiction** (7 ratings) show meaningful disagreement (**0.69**).\n",
        "\n",
        "Polarising movies are challenging for collaborative filtering because **overlap does not imply agreement**. Two users can both rate the same movie but have opposite opinions (one gives 5, another gives 3 or lower). Unweighted overlap-based similarity (e.g., Jaccard) treats “both rated the movie” as evidence of similarity regardless of whether their ratings match, which can introduce noise into user–user similarity and lead to recommendations that are less reliable.\n",
        "\n",
        "**Implication for recommendations:** highly polarising titles should be treated cautiously in similarity-driven recommendation logic. In later stages we can mitigate their effect by (a) preferring weighted similarity methods (FastRP + kNN/cosine) that incorporate rating magnitude, and/or (b) down-weighting polarising items when computing similarity and leaning more on content-based signals (genre/director) to match users with the most appropriate niche preferences."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 19,
      "id": "9ed94e91",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>numRatings</th>\n",
              "      <th>ratingStdDev</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Whiplash</td>\n",
              "      <td>3</td>\n",
              "      <td>1.15</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>The Revenant</td>\n",
              "      <td>3</td>\n",
              "      <td>1.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Roma</td>\n",
              "      <td>4</td>\n",
              "      <td>0.82</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Pulp Fiction</td>\n",
              "      <td>7</td>\n",
              "      <td>0.69</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Spirited Away</td>\n",
              "      <td>4</td>\n",
              "      <td>0.58</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           title  numRatings  ratingStdDev\n",
              "0       Whiplash           3          1.15\n",
              "1   The Revenant           3          1.00\n",
              "2           Roma           4          0.82\n",
              "3   Pulp Fiction           7          0.69\n",
              "4  Spirited Away           4          0.58"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\" \n",
        "MATCH (m:Movie)<-[r:RATED]-(u:User)\n",
        "WITH m, collect(r.rating) AS ratings\n",
        "WHERE size(ratings) >= 3\n",
        "UNWIND ratings AS rating\n",
        "WITH m, rating\n",
        "WITH m, count(rating) AS numRatings, stDev(rating) AS ratingStdDev\n",
        "RETURN m.title AS title, numRatings, round(ratingStdDev, 2) AS ratingStdDev\n",
        "ORDER BY ratingStdDev DESC\n",
        "LIMIT 5;\n",
        "\"\"\" )\n",
        "\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "8b4ced39",
      "metadata": {},
      "source": [
        "---\n",
        "## 3. Deeper analytical questions\n",
        "\n",
        "**Why we chose analytical questions 3.1 and 3.2**\n",
        "\n",
        "We answered **3.1 (Taste overlap without algorithms)** and **3.2 (Genre preference profiles)** because they pair two complementary views of “similarity” before GDS: **3.1** measures *co-engagement* (how many movies two users co-rated) and *taste agreement* on those overlaps (average absolute rating difference), which shows that high overlap alone does not guarantee similar preferences. **3.2** then summarizes users as **genre-level preference vectors** and compares them to a target user (Alice), giving a **content-structured** notion of similarity that remains useful when direct co-ratings are sparse.\n",
        "\n",
        "Together, these two questions motivate the rest of the project: they justify why we later use graph algorithms (Jaccard, FastRP + kNN) and hybrid signals (genre/director enrichment)—while **3.3 (long-tail)** and **3.4 (director–genre co-occurrence)** are valuable, they overlap thematically with our EDA and later hybrid/GDS sections, so we prioritized the pair that most directly sets up **collaborative vs content-based** reasoning in the smallest analytical scope.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "32689dac",
      "metadata": {},
      "source": [
        "### 3.1 Taste Overlap Without Algorithms\n",
        "\n",
        "**Query logic**:\n",
        "\n",
        "- Match triples (u1)-[r1:RATED]->(m)<-[r2:RATED]-(u2) so that each movie m is co-rated by users u1 and u2.\n",
        "- Use WHERE u1.userId < u2.userId to avoid counting each pair twice and to avoid self-pairs.\n",
        "- For each user pair, count how many movies they both rated (overlapCount) and compute the average absolute rating difference on those shared movies (avgAbsDiff).\n",
        "- Sort primarily by overlapCount (descending) and secondarily by avgAbsDiff (ascending), then take the top 10 pairs.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 20,
      "id": "9efa7c17",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>user1</th>\n",
              "      <th>user2</th>\n",
              "      <th>overlapCount</th>\n",
              "      <th>avgAbsDiff</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>5</td>\n",
              "      <td>0.40</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>5</td>\n",
              "      <td>0.60</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>5</td>\n",
              "      <td>0.60</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>5</td>\n",
              "      <td>0.60</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>5</td>\n",
              "      <td>0.60</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>5</td>\n",
              "      <td>0.80</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>5</td>\n",
              "      <td>1.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>5</td>\n",
              "      <td>1.20</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>4</td>\n",
              "      <td>0.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>4</td>\n",
              "      <td>0.25</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           user1          user2  overlapCount  avgAbsDiff\n",
              "0      Grace Liu   Tom Anderson             5        0.40\n",
              "1    Karen Singh    Quinn Zhang             5        0.60\n",
              "2      Grace Liu    Paul Garcia             5        0.60\n",
              "3    Paul Garcia   Tom Anderson             5        0.60\n",
              "4     Henry Park    Ryan Murphy             5        0.60\n",
              "5   Bob Martinez      David Kim             5        0.80\n",
              "6  Noah Williams    Ryan Murphy             5        1.00\n",
              "7     Henry Park  Noah Williams             5        1.20\n",
              "8     Maya Patel    Quinn Zhang             4        0.00\n",
              "9     Alice Chen    Quinn Zhang             4        0.25"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df = run_query(\"\"\"\n",
        "MATCH (u1:User)-[r1:RATED]->(m:Movie)<-[r2:RATED]-(u2:User)\n",
        "WHERE u1.userId < u2.userId\n",
        "WITH u1, u2,\n",
        "     count(m) AS overlapCount,\n",
        "     avg(abs(r1.rating - r2.rating)) AS avgAbsDiff\n",
        "RETURN u1.name AS user1,\n",
        "       u2.name AS user2,\n",
        "       overlapCount,\n",
        "       round(avgAbsDiff, 2) AS avgAbsDiff\n",
        "ORDER BY overlapCount DESC, avgAbsDiff ASC\n",
        "LIMIT 10;\n",
        "\"\"\")\n",
        "display(df)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "0b29922e",
      "metadata": {},
      "source": [
        "**Interpretation**\n",
        "\n",
        "This analysis identifies pairs of users who have rated the same movies and measures two things:  \n",
        "- **overlapCount** = how many movies they both rated (co-engagement), and  \n",
        "- **avgAbsDiff** = how similarly they rated those shared movies (taste agreement; lower is more similar).\n",
        "\n",
        "Even among the top-overlap pairs (mostly **5 shared movies**), agreement varies substantially. For example, **Grace Liu and Tom Anderson** have overlapCount **5** with a low avgAbsDiff of **0.40**, suggesting they not only watch similar movies but also evaluate them similarly. In contrast, **Henry Park and Noah Williams** also share **5** movies but have a much larger avgAbsDiff (**1.20**), meaning they often disagree despite high overlap. This directly shows that **high overlap does not necessarily imply similar taste**.\n",
        "\n",
        "We also see that lower overlap can still indicate strong similarity: **Maya Patel and Quinn Zhang** share **4** movies and have avgAbsDiff **0.00**, indicating near-identical ratings on the items they both rated.\n",
        "\n",
        "**Conclusion:** overlapCount is useful for finding candidate “neighbors,” but it is incomplete on its own. A rating-aware measure (like avgAbsDiff here, cosine similarity on rating vectors, or embedding-based cosine similarity via FastRP + kNN) better captures true taste similarity and helps avoid recommending items from users who merely share viewing history rather than genuine preference alignment."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "54d0ba00",
      "metadata": {},
      "source": [
        "### 3.2 Genre Preference Profiles\n",
        "\n",
        "**Query logic**:\n",
        "\n",
        "- For each `(User)-[:RATED]->(Movie)-[:IN_GENRE]->(Genre)` triple, group by user and genre and compute the **average rating** the user gives to movies in that genre; this forms each user’s genre preference profile.\n",
        "- Collect these per-genre averages into a vector for each user (one dimension per genre the user has rated).\n",
        "- For Alice Chen (the target user), build her genre rating vector and, for every other user, build a corresponding vector over the same genre space.\n",
        "- Compute **cosine similarity** between Alice’s vector and each other user’s vector: a value close to 1 means very similar genre preferences (same pattern of likes/dislikes across genres), while values near 0 indicate weak or no alignment.\n",
        "- Return the three users with the highest cosine similarity to Alice as the users whose genre profiles are most similar to hers."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 21,
      "id": "2112557d",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>user</th>\n",
              "      <th>genre</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Crime</td>\n",
              "      <td>5.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Thriller</td>\n",
              "      <td>5.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Action</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Sci-Fi</td>\n",
              "      <td>4.60</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>Drama</td>\n",
              "      <td>4.50</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>...</th>\n",
              "      <td>...</td>\n",
              "      <td>...</td>\n",
              "      <td>...</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>97</th>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>Romance</td>\n",
              "      <td>4.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>98</th>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>Crime</td>\n",
              "      <td>5.00</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>99</th>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>Action</td>\n",
              "      <td>4.75</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>100</th>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>Sci-Fi</td>\n",
              "      <td>4.50</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>101</th>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>Thriller</td>\n",
              "      <td>4.00</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "<p>102 rows × 3 columns</p>\n",
              "</div>"
            ],
            "text/plain": [
              "             user     genre  avgRating\n",
              "0      Alice Chen     Crime       5.00\n",
              "1      Alice Chen  Thriller       5.00\n",
              "2      Alice Chen    Action       4.67\n",
              "3      Alice Chen    Sci-Fi       4.60\n",
              "4      Alice Chen     Drama       4.50\n",
              "..            ...       ...        ...\n",
              "97    Sara Hassan   Romance       4.00\n",
              "98   Tom Anderson     Crime       5.00\n",
              "99   Tom Anderson    Action       4.75\n",
              "100  Tom Anderson    Sci-Fi       4.50\n",
              "101  Tom Anderson  Thriller       4.00\n",
              "\n",
              "[102 rows x 3 columns]"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Per-user genre preference profile: avg rating per genre\n",
        "df_genre_profiles = run_query(\"\"\"\n",
        "MATCH (u:User)-[r:RATED]->(m:Movie)-[:IN_GENRE]->(g:Genre)\n",
        "WITH u, g.name AS genre, avg(r.rating) AS avgRating\n",
        "RETURN u.name AS user,\n",
        "       genre,\n",
        "       round(avgRating, 2) AS avgRating\n",
        "ORDER BY user, avgRating desc;\n",
        "\"\"\")\n",
        "display(df_genre_profiles)"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 22,
      "id": "ce94c08a",
      "metadata": {},
      "outputs": [
        {
          "name": "stderr",
          "output_type": "stream",
          "text": [
            "Received notification from DBMS server: <GqlStatusObject gql_status='01N00', status_description='warn: feature deprecated. CALL subquery without a variable scope clause is deprecated. Use CALL (other) { ... }', position=<SummaryInputPosition line=12, column=1, offset=341>, raw_classification='DEPRECATION', classification=<NotificationClassification.DEPRECATION: 'DEPRECATION'>, raw_severity='WARNING', severity=<NotificationSeverity.WARNING: 'WARNING'>, diagnostic_record={'_classification': 'DEPRECATION', '_severity': 'WARNING', '_position': {'offset': 341, 'line': 12, 'column': 1}, 'OPERATION': '', 'OPERATION_CODE': '0', 'CURRENT_SCHEMA': '/'}> for query: '\\nWITH $targetName AS targetName\\n\\n// Target user genre profile\\nMATCH (u:User {name: targetName})-[r:RATED]->(m:Movie)-[:IN_GENRE]->(g:Genre)\\nWITH u, g.name AS genre, avg(r.rating) AS avgRating\\nWITH u, collect({genre: genre, rating: avgRating}) AS targetProfile\\n\\n// All other users and their genre profiles\\nMATCH (other:User)\\nWHERE other <> u\\nCALL {\\n  WITH other\\n  MATCH (other)-[r2:RATED]->(m2:Movie)-[:IN_GENRE]->(g2:Genre)\\n  WITH g2.name AS genre, avg(r2.rating) AS avgRating\\n  RETURN collect({genre: genre, rating: avgRating}) AS otherProfile\\n}\\n\\n// Compute cosine similarity between targetProfile and otherProfile\\nWITH u, targetProfile, other, otherProfile,\\n     [x IN targetProfile | x.genre] AS targetGenres\\nWITH u, other, targetProfile, otherProfile,\\n     [g IN targetGenres WHERE any(o IN otherProfile WHERE o.genre = g)] AS sharedGenres\\n\\n// dot product over shared genres\\nWITH u, other, sharedGenres,\\n     reduce(dot = 0.0, g IN sharedGenres |\\n           dot + (\\n             [x IN targetProfile WHERE x.genre = g][0].rating *\\n             [y IN otherProfile WHERE y.genre = g][0].rating\\n           )\\n     ) AS dotProduct,\\n     // norms over all genres each user has ratings for\\n     reduce(tNormSq = 0.0, x IN targetProfile | tNormSq + x.rating^2) AS targetNormSq,\\n     reduce(oNormSq = 0.0, y IN otherProfile | oNormSq + y.rating^2) AS otherNormSq\\n\\nWITH other,\\n     dotProduct,\\n     sqrt(targetNormSq) AS targetNorm,\\n     sqrt(otherNormSq) AS otherNorm\\n\\nWITH other,\\n     CASE\\n       WHEN targetNorm = 0 OR otherNorm = 0 THEN 0.0\\n       ELSE dotProduct / (targetNorm * otherNorm)\\n     END AS cosineSim\\n\\nRETURN other.name AS user,\\n       round(cosineSim, 3) AS cosineSimilarity\\nORDER BY cosineSimilarity DESC\\nLIMIT 3;\\n'\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>user</th>\n",
              "      <th>cosineSimilarity</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>0.906</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>0.903</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>0.902</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "           user  cosineSimilarity\n",
              "0     Grace Liu             0.906\n",
              "1   Paul Garcia             0.903\n",
              "2  Tom Anderson             0.902"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "TARGET_USER = \"Alice Chen\"\n",
        "\n",
        "df_similar_to_alice = run_query(\"\"\"\n",
        "WITH $targetName AS targetName\n",
        "\n",
        "// Target user genre profile\n",
        "MATCH (u:User {name: targetName})-[r:RATED]->(m:Movie)-[:IN_GENRE]->(g:Genre)\n",
        "WITH u, g.name AS genre, avg(r.rating) AS avgRating\n",
        "WITH u, collect({genre: genre, rating: avgRating}) AS targetProfile\n",
        "\n",
        "// All other users and their genre profiles\n",
        "MATCH (other:User)\n",
        "WHERE other <> u\n",
        "CALL {\n",
        "  WITH other\n",
        "  MATCH (other)-[r2:RATED]->(m2:Movie)-[:IN_GENRE]->(g2:Genre)\n",
        "  WITH g2.name AS genre, avg(r2.rating) AS avgRating\n",
        "  RETURN collect({genre: genre, rating: avgRating}) AS otherProfile\n",
        "}\n",
        "\n",
        "// Compute cosine similarity between targetProfile and otherProfile\n",
        "WITH u, targetProfile, other, otherProfile,\n",
        "     [x IN targetProfile | x.genre] AS targetGenres\n",
        "WITH u, other, targetProfile, otherProfile,\n",
        "     [g IN targetGenres WHERE any(o IN otherProfile WHERE o.genre = g)] AS sharedGenres\n",
        "\n",
        "// dot product over shared genres\n",
        "WITH u, other, sharedGenres,\n",
        "     reduce(dot = 0.0, g IN sharedGenres |\n",
        "           dot + (\n",
        "             [x IN targetProfile WHERE x.genre = g][0].rating *\n",
        "             [y IN otherProfile WHERE y.genre = g][0].rating\n",
        "           )\n",
        "     ) AS dotProduct,\n",
        "     // norms over all genres each user has ratings for\n",
        "     reduce(tNormSq = 0.0, x IN targetProfile | tNormSq + x.rating^2) AS targetNormSq,\n",
        "     reduce(oNormSq = 0.0, y IN otherProfile | oNormSq + y.rating^2) AS otherNormSq\n",
        "\n",
        "WITH other,\n",
        "     dotProduct,\n",
        "     sqrt(targetNormSq) AS targetNorm,\n",
        "     sqrt(otherNormSq) AS otherNorm\n",
        "\n",
        "WITH other,\n",
        "     CASE\n",
        "       WHEN targetNorm = 0 OR otherNorm = 0 THEN 0.0\n",
        "       ELSE dotProduct / (targetNorm * otherNorm)\n",
        "     END AS cosineSim\n",
        "\n",
        "RETURN other.name AS user,\n",
        "       round(cosineSim, 3) AS cosineSimilarity\n",
        "ORDER BY cosineSimilarity DESC\n",
        "LIMIT 3;\n",
        "\"\"\", {\"targetName\": TARGET_USER})\n",
        "\n",
        "display(df_similar_to_alice)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "486e7c47",
      "metadata": {},
      "source": [
        "**Interpretation**\n",
        "\n",
        "We first computed each user’s **genre preference profile**: the average rating they give to movies in each genre. For Alice Chen, for example, Crime and Thriller sit at the top of her profile (both ≈5.0), followed by Action (≈4.67), Sci‑Fi (≈4.60), and Drama (≈4.50). This shows Alice strongly favors Crime/Thriller and generally likes high‑intensity genres (Action/Sci‑Fi) while still being positive on Drama.\n",
        "\n",
        "To compare other users to Alice, we treated these per‑genre averages as a **rating vector** and used **cosine similarity** over shared genres. The top three users by cosine similarity to Alice are:\n",
        "\n",
        "- **Grace Liu** (cosineSimilarity ≈ **0.906**)\n",
        "- **Paul Garcia** (≈ **0.903**)\n",
        "- **Tom Anderson** (≈ **0.902**)\n",
        "\n",
        "These high cosine scores mean that, across all genres they share with Alice, these users have **very similar patterns of preference**: they tend to like and dislike the same genres to similar degrees, not just share a single favorite. For instance, Tom Anderson also gives very high ratings to Crime and reasonably high ratings to Action/Sci‑Fi, mirroring Alice’s multi‑genre profile.\n",
        "\n",
        "From a recommender‑system perspective, genre‑level profiles provide a **content-based view of similarity** that complements raw co‑ratings. They are especially useful in a sparse setting like this one: even when two users have few movies in common, we can still infer taste alignment from how they rate **genres**. Combined with collaborative filtering (e.g., Jaccard or FastRP + kNN on user–movie ratings), genre‑profile similarity helps target recommendations toward movies that match a user’s expressed genre tastes rather than relying solely on noisy overlap at the individual-title level."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "1e773b32",
      "metadata": {},
      "source": [
        "---\n",
        "## 4. GDS: projections and similarity\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "ca2c733a",
      "metadata": {},
      "source": [
        "### GDS 1: User similarity via Node Similarity (Jaccard)\n",
        "\n",
        "**Graph projection**\n",
        "\n",
        "- We **drop** any existing in-memory graph named `user_movie_unweighted` (with `failIfMissing: false` so no error is thrown if it doesn't exist) before creating a new one.\n",
        "- We **build** an in-memory graph named `user_movie_unweighted` with nodes from labels **User** and **Movie** and relationships of type **RATED**, using **undirected** orientation so that user–movie links are treated as symmetric  (“neighbours of user” = movies they rated, and “neighbours of movie” = users who rated it). \n",
        "- No relationship properties are loaded, so the graph is **unweighted** and suitable for Jaccard.\n",
        "- The result is a **bipartite** projection: \n",
        "    - 45 nodes (20 users + 25 movies) and \n",
        "    - 202 relationships (101 RATED edges stored in both directions). \n",
        "- This graph is the input for Node Similarity: we compare **User** nodes based on their shared **Movie** neighbours (co-rated movies); similarity is computed from overlap of rated movies, not from rating values.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "97a4e6a9",
      "metadata": {},
      "source": [
        "### User Similarity via Node Similarity"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 26,
      "id": "e3d55266",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>graphName</th>\n",
              "      <th>nodeCount</th>\n",
              "      <th>relationshipCount</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>user_movie_unweighted</td>\n",
              "      <td>45</td>\n",
              "      <td>202</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "               graphName  nodeCount  relationshipCount\n",
              "0  user_movie_unweighted         45                202"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Create or replace a GDS projection of the User–Movie ratings graph (unweighted)\n",
        "run_query(\"\"\"\n",
        "CALL gds.graph.drop('user_movie_unweighted', false) YIELD graphName\n",
        "RETURN graphName\n",
        "\"\"\")  \n",
        "\n",
        "df_proj = run_query(\"\"\"\n",
        "CALL gds.graph.project(\n",
        "  'user_movie_unweighted',\n",
        "  ['User', 'Movie'],\n",
        "  {\n",
        "    RATED: {\n",
        "      type: 'RATED',\n",
        "      orientation: 'UNDIRECTED'\n",
        "    }\n",
        "  }\n",
        ")\n",
        "YIELD graphName, nodeCount, relationshipCount\n",
        "\"\"\")\n",
        "display(df_proj)\n",
        "\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "fd6e55d0",
      "metadata": {},
      "source": [
        "**Node Similarity stream (Jaccard)**\n",
        "\n",
        "- We use **Node Similarity** with **Jaccard** to measure user–user similarity from co-rated movies (set overlap) without using rating values, as the assignment’s baseline before weighted similarity in the next task. Run it in **stream** mode on the projected graph using **filtered** procedures so we can restrict which nodes are compared.\n",
        "- Set **sourceNodeFilter** and **targetNodeFilter** to `'User'` so that only **User–User** pairs are compared; similarity is computed from shared **Movie** neighbours (movies both users rated).\n",
        "\n",
        "- Use **topK: 5** to limit to the top 5 most similar users per user.\n",
        "\n",
        "\n",
        "- The stream returns each user’s neighbours (other users) together with the Jaccard similarity score. \n",
        "- For Alice Chen, her top five neighbours (Maya, Quinn, Paul, Karen, and Grace) all have a similarity of **0.571**, meaning they overlap with Alice to the same degree in **which movies they rated** (same intersection and union of rated movies).\n",
        "- Bob Martinez and David Kim have a Jaccard similarity of **1.0**, so they **completely overlap** in the set of movies they rated (identical rated-movie sets). \n",
        "- Note that Jaccard does not use rating values, only whether a movie was rated or not.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 27,
      "id": "7b0742d1",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>userId1</th>\n",
              "      <th>user1</th>\n",
              "      <th>userId2</th>\n",
              "      <th>user2</th>\n",
              "      <th>jaccard</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>10</th>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>11</th>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>12</th>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>13</th>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>14</th>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>15</th>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>16</th>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>17</th>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>18</th>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>19</th>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>20</th>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>21</th>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>22</th>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>23</th>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>24</th>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>25</th>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>26</th>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>27</th>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>28</th>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>29</th>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>30</th>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>31</th>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>32</th>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>33</th>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>34</th>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>35</th>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>36</th>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>37</th>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>38</th>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>39</th>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>40</th>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>41</th>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>42</th>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>43</th>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>44</th>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>45</th>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>46</th>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>47</th>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>48</th>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>49</th>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>50</th>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>51</th>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>52</th>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>53</th>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>54</th>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>55</th>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>56</th>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>57</th>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>58</th>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>59</th>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>60</th>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>61</th>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>62</th>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>63</th>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>64</th>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>65</th>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>66</th>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>67</th>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>68</th>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>69</th>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>70</th>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>71</th>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>72</th>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>73</th>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>74</th>\n",
              "      <td>U015</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>U012</td>\n",
              "      <td>Leo Tanaka</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>75</th>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>76</th>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>77</th>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>78</th>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>79</th>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>80</th>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>81</th>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>82</th>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>83</th>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>84</th>\n",
              "      <td>U017</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>85</th>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>U014</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>86</th>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>U008</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>87</th>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>U004</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>88</th>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>U002</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>89</th>\n",
              "      <td>U018</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>90</th>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>U006</td>\n",
              "      <td>Frank Osei</td>\n",
              "      <td>0.667</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>91</th>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>U010</td>\n",
              "      <td>James Brown</td>\n",
              "      <td>0.429</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>92</th>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>U009</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>93</th>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>U005</td>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>94</th>\n",
              "      <td>U019</td>\n",
              "      <td>Sara Hassan</td>\n",
              "      <td>U003</td>\n",
              "      <td>Carol White</td>\n",
              "      <td>0.111</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>95</th>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>U016</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>96</th>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>U007</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>97</th>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>U001</td>\n",
              "      <td>Alice Chen</td>\n",
              "      <td>0.571</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>98</th>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>U013</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>99</th>\n",
              "      <td>U020</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>U011</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>0.250</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   userId1          user1 userId2          user2  jaccard\n",
              "0     U001     Alice Chen    U013     Maya Patel    0.571\n",
              "1     U001     Alice Chen    U017    Quinn Zhang    0.571\n",
              "2     U001     Alice Chen    U016    Paul Garcia    0.571\n",
              "3     U001     Alice Chen    U011    Karen Singh    0.571\n",
              "4     U001     Alice Chen    U007      Grace Liu    0.571\n",
              "5     U002   Bob Martinez    U004      David Kim    1.000\n",
              "6     U002   Bob Martinez    U014  Noah Williams    0.429\n",
              "7     U002   Bob Martinez    U018    Ryan Murphy    0.429\n",
              "8     U002   Bob Martinez    U008     Henry Park    0.429\n",
              "9     U002   Bob Martinez    U010    James Brown    0.250\n",
              "10    U003    Carol White    U009     Iris Novak    0.667\n",
              "11    U003    Carol White    U015   Olivia Jones    0.667\n",
              "12    U003    Carol White    U005      Eva Rossi    0.667\n",
              "13    U003    Carol White    U019    Sara Hassan    0.111\n",
              "14    U003    Carol White    U012     Leo Tanaka    0.111\n",
              "15    U004      David Kim    U002   Bob Martinez    1.000\n",
              "16    U004      David Kim    U014  Noah Williams    0.429\n",
              "17    U004      David Kim    U018    Ryan Murphy    0.429\n",
              "18    U004      David Kim    U008     Henry Park    0.429\n",
              "19    U004      David Kim    U010    James Brown    0.250\n",
              "20    U005      Eva Rossi    U009     Iris Novak    0.667\n",
              "21    U005      Eva Rossi    U015   Olivia Jones    0.667\n",
              "22    U005      Eva Rossi    U003    Carol White    0.667\n",
              "23    U005      Eva Rossi    U012     Leo Tanaka    0.250\n",
              "24    U005      Eva Rossi    U019    Sara Hassan    0.111\n",
              "25    U006     Frank Osei    U019    Sara Hassan    0.667\n",
              "26    U006     Frank Osei    U010    James Brown    0.667\n",
              "27    U006     Frank Osei    U012     Leo Tanaka    0.111\n",
              "28    U006     Frank Osei    U008     Henry Park    0.111\n",
              "29    U006     Frank Osei    U004      David Kim    0.111\n",
              "30    U007      Grace Liu    U020   Tom Anderson    1.000\n",
              "31    U007      Grace Liu    U016    Paul Garcia    1.000\n",
              "32    U007      Grace Liu    U001     Alice Chen    0.571\n",
              "33    U007      Grace Liu    U017    Quinn Zhang    0.250\n",
              "34    U007      Grace Liu    U013     Maya Patel    0.250\n",
              "35    U008     Henry Park    U018    Ryan Murphy    1.000\n",
              "36    U008     Henry Park    U014  Noah Williams    1.000\n",
              "37    U008     Henry Park    U004      David Kim    0.429\n",
              "38    U008     Henry Park    U002   Bob Martinez    0.429\n",
              "39    U008     Henry Park    U010    James Brown    0.250\n",
              "40    U009     Iris Novak    U005      Eva Rossi    0.667\n",
              "41    U009     Iris Novak    U015   Olivia Jones    0.667\n",
              "42    U009     Iris Novak    U003    Carol White    0.667\n",
              "43    U009     Iris Novak    U012     Leo Tanaka    0.250\n",
              "44    U009     Iris Novak    U019    Sara Hassan    0.111\n",
              "45    U010    James Brown    U006     Frank Osei    0.667\n",
              "46    U010    James Brown    U019    Sara Hassan    0.429\n",
              "47    U010    James Brown    U014  Noah Williams    0.250\n",
              "48    U010    James Brown    U008     Henry Park    0.250\n",
              "49    U010    James Brown    U004      David Kim    0.250\n",
              "50    U011    Karen Singh    U017    Quinn Zhang    1.000\n",
              "51    U011    Karen Singh    U013     Maya Patel    0.667\n",
              "52    U011    Karen Singh    U001     Alice Chen    0.571\n",
              "53    U011    Karen Singh    U016    Paul Garcia    0.250\n",
              "54    U011    Karen Singh    U007      Grace Liu    0.250\n",
              "55    U012     Leo Tanaka    U009     Iris Novak    0.250\n",
              "56    U012     Leo Tanaka    U005      Eva Rossi    0.250\n",
              "57    U012     Leo Tanaka    U006     Frank Osei    0.111\n",
              "58    U012     Leo Tanaka    U004      David Kim    0.111\n",
              "59    U012     Leo Tanaka    U003    Carol White    0.111\n",
              "60    U013     Maya Patel    U017    Quinn Zhang    0.667\n",
              "61    U013     Maya Patel    U011    Karen Singh    0.667\n",
              "62    U013     Maya Patel    U001     Alice Chen    0.571\n",
              "63    U013     Maya Patel    U016    Paul Garcia    0.250\n",
              "64    U013     Maya Patel    U007      Grace Liu    0.250\n",
              "65    U014  Noah Williams    U018    Ryan Murphy    1.000\n",
              "66    U014  Noah Williams    U008     Henry Park    1.000\n",
              "67    U014  Noah Williams    U004      David Kim    0.429\n",
              "68    U014  Noah Williams    U002   Bob Martinez    0.429\n",
              "69    U014  Noah Williams    U010    James Brown    0.250\n",
              "70    U015   Olivia Jones    U009     Iris Novak    0.667\n",
              "71    U015   Olivia Jones    U005      Eva Rossi    0.667\n",
              "72    U015   Olivia Jones    U003    Carol White    0.667\n",
              "73    U015   Olivia Jones    U019    Sara Hassan    0.111\n",
              "74    U015   Olivia Jones    U012     Leo Tanaka    0.111\n",
              "75    U016    Paul Garcia    U020   Tom Anderson    1.000\n",
              "76    U016    Paul Garcia    U007      Grace Liu    1.000\n",
              "77    U016    Paul Garcia    U001     Alice Chen    0.571\n",
              "78    U016    Paul Garcia    U017    Quinn Zhang    0.250\n",
              "79    U016    Paul Garcia    U013     Maya Patel    0.250\n",
              "80    U017    Quinn Zhang    U011    Karen Singh    1.000\n",
              "81    U017    Quinn Zhang    U013     Maya Patel    0.667\n",
              "82    U017    Quinn Zhang    U001     Alice Chen    0.571\n",
              "83    U017    Quinn Zhang    U016    Paul Garcia    0.250\n",
              "84    U017    Quinn Zhang    U007      Grace Liu    0.250\n",
              "85    U018    Ryan Murphy    U014  Noah Williams    1.000\n",
              "86    U018    Ryan Murphy    U008     Henry Park    1.000\n",
              "87    U018    Ryan Murphy    U004      David Kim    0.429\n",
              "88    U018    Ryan Murphy    U002   Bob Martinez    0.429\n",
              "89    U018    Ryan Murphy    U010    James Brown    0.250\n",
              "90    U019    Sara Hassan    U006     Frank Osei    0.667\n",
              "91    U019    Sara Hassan    U010    James Brown    0.429\n",
              "92    U019    Sara Hassan    U009     Iris Novak    0.111\n",
              "93    U019    Sara Hassan    U005      Eva Rossi    0.111\n",
              "94    U019    Sara Hassan    U003    Carol White    0.111\n",
              "95    U020   Tom Anderson    U016    Paul Garcia    1.000\n",
              "96    U020   Tom Anderson    U007      Grace Liu    1.000\n",
              "97    U020   Tom Anderson    U001     Alice Chen    0.571\n",
              "98    U020   Tom Anderson    U013     Maya Patel    0.250\n",
              "99    U020   Tom Anderson    U011    Karen Singh    0.250"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "df_ns = run_query(\"\"\"\n",
        "CALL gds.nodeSimilarity.filtered.stream('user_movie_unweighted', {\n",
        "  sourceNodeFilter: 'User',\n",
        "  targetNodeFilter: 'User',\n",
        "  relationshipTypes: ['RATED'],\n",
        "  similarityMetric: 'JACCARD',\n",
        "  topK: 5\n",
        "})\n",
        "YIELD node1, node2, similarity\n",
        "WITH gds.util.asNode(node1) AS u1,\n",
        "     gds.util.asNode(node2) AS u2,\n",
        "     similarity\n",
        "RETURN u1.userId AS userId1,\n",
        "       u1.name   AS user1,\n",
        "       u2.userId AS userId2,\n",
        "       u2.name   AS user2,\n",
        "       round(similarity, 3) AS jaccard\n",
        "ORDER BY  user1, jaccard DESC;\n",
        "\"\"\")\n",
        "display(df_ns)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "43688868",
      "metadata": {},
      "source": [
        "\n",
        "**Write SIMILAR_TASTE back to the graph**\n",
        "\n",
        "- We delete any existing **SIMILAR_TASTE** relationships so that re-running the write does not create duplicates.\n",
        "- We then run `gds.nodeSimilarity.filtered.write` with `sourceNodeFilter` and `targetNodeFilter` set to `'User'`, topK: 5, writeRelationshipType: 'SIMILAR_TASTE', and writeProperty: 'score'. \n",
        "- This writes Jaccard similarity as score between User nodes for use in collaborative-filtering recommendation queries.\n",
        " - The write compares only the **20 User** nodes with **Movie** nodes  used only as bridges for computing overlap. \n",
        " - With **topK: 5**, each user gets at most five most-similar users, so we obtain **up to 100** SIMILAR_TASTE relationships. \n",
        " - In the results, the top 15 pairs all have a **Jaccard score of 1**, including Bob Martinez–David Kim and Grace Liu–Paul Garcia meaning those users rated exactly the same set of movies.\n",
        "\n",
        " **What Jaccard captures and when it falls short**\n",
        "\n",
        "- Jaccard compares users on binary overlap: whether they rated the same movies. It does not use rating values, so it does not tell us whether both enjoyed the movies or had different tastes (e.g. one 5, the other 2).\n",
        "- It is useful for finding or tracking active users who watch the same movies and for co-engagement (who shares viewing overlap), and it is simple and robust when only “rated or not” matters.\n",
        "- It falls short for recommender systems because it does not capture preference overlap: two users can have the same set of rated movies but opposite tastes. \n",
        "- For “users like you also liked…”, we need rating-aware similarity (e.g. cosine or Pearson on rating vectors, or embedding-based similarity), which uses how users rated, not just which movies they rated."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 28,
      "id": "ab39b8bf",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>nodesCompared</th>\n",
              "      <th>relationshipsWritten</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>20</td>\n",
              "      <td>100</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   nodesCompared  relationshipsWritten\n",
              "0             20                   100"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# delete the SIMILAR_TASTE relationships to avoid duplicates\n",
        "run_query(\"\"\" \n",
        "MATCH ()-[s:SIMILAR_TASTE]->()\n",
        "DELETE s;\n",
        "\"\"\" )\n",
        "\n",
        "# Write SIMILAR_TASTE relationships back to the graph (filtered, same as stream)\n",
        "df_write = run_query(\"\"\"\n",
        "CALL gds.nodeSimilarity.filtered.write('user_movie_unweighted', {\n",
        "  sourceNodeFilter: 'User',\n",
        "  targetNodeFilter: 'User',\n",
        "  relationshipTypes: ['RATED'],\n",
        "  similarityMetric: 'JACCARD',\n",
        "  topK: 5,\n",
        "  writeRelationshipType: 'SIMILAR_TASTE',\n",
        "  writeProperty: 'score'\n",
        "})\n",
        "YIELD nodesCompared, relationshipsWritten\n",
        "RETURN nodesCompared, relationshipsWritten;\n",
        "\"\"\")\n",
        "display(df_write)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "baa32265",
      "metadata": {},
      "source": [
        "**Top 15 most similar user pairs (by Jaccard score)**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 29,
      "id": "ce92f3d3",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>user1</th>\n",
              "      <th>user2</th>\n",
              "      <th>score</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>David Kim</td>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>10</th>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>11</th>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>12</th>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>Henry Park</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>13</th>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>14</th>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>1.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "            user1          user2  score\n",
              "0    Bob Martinez      David Kim    1.0\n",
              "1       David Kim   Bob Martinez    1.0\n",
              "2       Grace Liu    Paul Garcia    1.0\n",
              "3       Grace Liu   Tom Anderson    1.0\n",
              "4      Henry Park  Noah Williams    1.0\n",
              "5      Henry Park    Ryan Murphy    1.0\n",
              "6     Karen Singh    Quinn Zhang    1.0\n",
              "7   Noah Williams     Henry Park    1.0\n",
              "8   Noah Williams    Ryan Murphy    1.0\n",
              "9     Paul Garcia      Grace Liu    1.0\n",
              "10    Paul Garcia   Tom Anderson    1.0\n",
              "11    Quinn Zhang    Karen Singh    1.0\n",
              "12    Ryan Murphy     Henry Park    1.0\n",
              "13    Ryan Murphy  Noah Williams    1.0\n",
              "14   Tom Anderson      Grace Liu    1.0"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "\n",
        "df_edges = run_query(\"\"\"\n",
        "MATCH (u1:User)-[s:SIMILAR_TASTE]->(u2:User)\n",
        "RETURN u1.name AS user1,\n",
        "       u2.name AS user2,\n",
        "       round(s.score, 3) AS score\n",
        "ORDER BY score DESC, user1, user2\n",
        "LIMIT 15;\n",
        "\"\"\")\n",
        "display(df_edges)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "5b3c03c8",
      "metadata": {},
      "source": [
        "### GDS 2: User similarity via k-Nearest Neighbors with FastRP embeddings\n",
        "\n",
        "**Goal:** Compute user–user similarity using **rating-weighted** structure (unlike Jaccard), then compare with GDS 1 and discuss trade-offs.\n",
        "\n",
        "**Graph projection (weighted)**\n",
        "\n",
        "- We create a **second** in-memory graph, `user_movie_weighted`, with the same nodes (User, Movie) and relationship type (RATED), but we **load the relationship property `rating`** so that each edge carries the user’s rating (1–5).\n",
        "- The graph is still undirected and bipartite (45 nodes, 202 relationships). \n",
        "- The difference from GDS 1 is that relationships are **weighted** by `rating`, so FastRP can use “how much” a user liked a movie, not just “rated or not”.\n",
        "\n",
        "\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 30,
      "id": "9b9b738d",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>graphName</th>\n",
              "      <th>nodeCount</th>\n",
              "      <th>relationshipCount</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>user_movie_weighted</td>\n",
              "      <td>45</td>\n",
              "      <td>202</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "             graphName  nodeCount  relationshipCount\n",
              "0  user_movie_weighted         45                202"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Drop if exists, then create weighted projection for FastRP\n",
        "run_query(\"\"\"\n",
        "CALL gds.graph.drop('user_movie_weighted', false) YIELD graphName\n",
        "RETURN graphName\n",
        "\"\"\")\n",
        "\n",
        "df_proj_w = run_query(\"\"\"\n",
        "CALL gds.graph.project(\n",
        "  'user_movie_weighted',\n",
        "  ['User', 'Movie'],\n",
        "  {\n",
        "    RATED: {\n",
        "      type: 'RATED',\n",
        "      orientation: 'UNDIRECTED',\n",
        "      properties: ['rating']\n",
        "    }\n",
        "  }\n",
        ")\n",
        "YIELD graphName, nodeCount, relationshipCount\n",
        "RETURN graphName, nodeCount, relationshipCount\n",
        "\"\"\")\n",
        "display(df_proj_w)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "31f7eaf3",
      "metadata": {},
      "source": [
        "**FastRP (embeddings)**\n",
        "\n",
        "- We run **FastRP** in **mutate** mode on `user_movie_weighted` so that we can save the results in-memory without affecting the database.\n",
        "- We then use **relationshipWeightProperty: 'rating'** so that high-rated movies contribute more to a user’s embedding than low-rated ones.\n",
        "- Parameters: \n",
        "    - **embeddingDimension: 64** (balanced tradeoff between a larger vector e.g. 128 with more noise and a smaller vector e.g. 16 with less information), \n",
        "    - **iterationWeights: [0.0, 1.0, 1.0]** (first weight 0 to avoid dominating by initial randomness which usualy has more noise while later iteration compare structure differences), \n",
        "    - **randomSeed: 42** for reproducibility. \n",
        "- The algorithm wrote 45 **embedding** vectors for every node (User and Movie) in the catalog."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 31,
      "id": "1c653767",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>nodePropertiesWritten</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>45</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   nodePropertiesWritten\n",
              "0                     45"
            ]
          },
          "execution_count": 31,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "# FastRP: weighted embeddings; mutate writes 'embedding' onto nodes in the catalog\n",
        "\n",
        "run_query(\"\"\"\n",
        "CALL gds.fastRP.mutate(\n",
        "  'user_movie_weighted',\n",
        "  {\n",
        "    embeddingDimension: 64,\n",
        "    randomSeed: 42,\n",
        "    mutateProperty: 'embedding',\n",
        "    relationshipWeightProperty: 'rating',\n",
        "    iterationWeights: [0.0, 1.0, 1.0]\n",
        "  }\n",
        ")\n",
        "YIELD nodePropertiesWritten\n",
        "RETURN nodePropertiesWritten\n",
        "\"\"\")"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "eb3258bf",
      "metadata": {},
      "source": [
        "**kNN stream (cosine similarity)**\n",
        "\n",
        "- We run **kNN** in stream mode using **nodeProperties: ['embedding']** from the FastRP vector results. \n",
        "- kNN compares nodes by **cosine similarity** of their embedding vectors, so users with similar weighted rating neighborhoods get high similarity.\n",
        "- We filter to **User–User** pairs and one direction per pair with `elementId(u1) < elementId(u2)` so that we dont have duplicates e.g. Alice-Tom and Tom-Alice.\n",
        "- Then take the **top 15** pairs by similarity to present the 15 most similar user pairs by cosine (embedding) similarity.\n",
        "- `gds.util.asNode()` converts the projection ID's back to node properties and labels\n",
        "- Pairs such as Paul–Tom, Tom–Grace, Ryan–Henry, Karen–Quinn, and Paul–Grace have cosine similarity 1.0, so their embeddings are identical in direction; the model treats them as having the same taste. \n",
        "- Sara and Frank have similarity 0.991, so they are almost as similar, with very similar taste on the movies they rated.\n",
        "\n",
        "\n",
        "**Write KNN_SIMILAR**\n",
        "\n",
        "- We delete any existing **KNN_SIMILAR** relationships, then run **kNN write** with **writeRelationshipType: 'KNN_SIMILAR'** and **writeProperty: 'score'** (and **topK: 5** per user) so that the graph stores these similarity edges for later recommendation queries."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 34,
      "id": "eff30d33",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>user1</th>\n",
              "      <th>user2</th>\n",
              "      <th>score</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>0.999</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>0.999</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>0.999</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Carol White</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.996</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>0.995</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>10</th>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.994</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>11</th>\n",
              "      <td>Carol White</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.992</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>12</th>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.991</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>13</th>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.990</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>14</th>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.990</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "            user1          user2  score\n",
              "0       Grace Liu    Paul Garcia  1.000\n",
              "1       Grace Liu   Tom Anderson  1.000\n",
              "2      Henry Park    Ryan Murphy  1.000\n",
              "3     Karen Singh    Quinn Zhang  1.000\n",
              "4     Paul Garcia   Tom Anderson  1.000\n",
              "5    Bob Martinez      David Kim  0.999\n",
              "6      Henry Park  Noah Williams  0.999\n",
              "7   Noah Williams    Ryan Murphy  0.999\n",
              "8     Carol White   Olivia Jones  0.996\n",
              "9      Maya Patel    Quinn Zhang  0.995\n",
              "10    Karen Singh     Maya Patel  0.994\n",
              "11    Carol White     Iris Novak  0.992\n",
              "12      Eva Rossi   Olivia Jones  0.991\n",
              "13      Eva Rossi     Iris Novak  0.990\n",
              "14     Iris Novak   Olivia Jones  0.990"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "------------------------------ KNN_JACCARD ------------------------------\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>user1</th>\n",
              "      <th>user2</th>\n",
              "      <th>jaccardScore</th>\n",
              "      <th>knnScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>1.0000</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>Tom Anderson</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9998</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9998</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9998</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Grace Liu</td>\n",
              "      <td>Paul Garcia</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9997</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Bob Martinez</td>\n",
              "      <td>David Kim</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9988</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>Ryan Murphy</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9987</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Henry Park</td>\n",
              "      <td>Noah Williams</td>\n",
              "      <td>1.0000</td>\n",
              "      <td>0.9987</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Carol White</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9965</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>Quinn Zhang</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9954</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>10</th>\n",
              "      <td>Karen Singh</td>\n",
              "      <td>Maya Patel</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9941</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>11</th>\n",
              "      <td>Carol White</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9922</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>12</th>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9912</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>13</th>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>Olivia Jones</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9905</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>14</th>\n",
              "      <td>Eva Rossi</td>\n",
              "      <td>Iris Novak</td>\n",
              "      <td>0.6667</td>\n",
              "      <td>0.9903</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "            user1          user2  jaccardScore  knnScore\n",
              "0       Grace Liu   Tom Anderson        1.0000    1.0000\n",
              "1     Paul Garcia   Tom Anderson        1.0000    0.9998\n",
              "2     Karen Singh    Quinn Zhang        1.0000    0.9998\n",
              "3      Henry Park    Ryan Murphy        1.0000    0.9998\n",
              "4       Grace Liu    Paul Garcia        1.0000    0.9997\n",
              "5    Bob Martinez      David Kim        1.0000    0.9988\n",
              "6   Noah Williams    Ryan Murphy        1.0000    0.9987\n",
              "7      Henry Park  Noah Williams        1.0000    0.9987\n",
              "8     Carol White   Olivia Jones        0.6667    0.9965\n",
              "9      Maya Patel    Quinn Zhang        0.6667    0.9954\n",
              "10    Karen Singh     Maya Patel        0.6667    0.9941\n",
              "11    Carol White     Iris Novak        0.6667    0.9922\n",
              "12      Eva Rossi   Olivia Jones        0.6667    0.9912\n",
              "13     Iris Novak   Olivia Jones        0.6667    0.9905\n",
              "14      Eva Rossi     Iris Novak        0.6667    0.9903"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# kNN stream: cosine similarity over embeddings; then filter to User-User and top 15\n",
        "df_knn = run_query(\"\"\"\n",
        "CALL gds.knn.stream('user_movie_weighted', {\n",
        "  nodeProperties: ['embedding'],\n",
        "  topK: 5,\n",
        "  randomSeed: 42,\n",
        "  concurrency: 1\n",
        "})\n",
        "YIELD node1, node2, similarity\n",
        "WITH gds.util.asNode(node1) AS u1, gds.util.asNode(node2) AS u2, similarity\n",
        "WHERE u1:User AND u2:User AND elementId(u1) < elementId(u2)\n",
        "RETURN u1.name AS user1, u2.name AS user2, round(similarity, 3) AS cosineSimilarity\n",
        "ORDER BY cosineSimilarity DESC\n",
        "LIMIT 15\n",
        "\"\"\")\n",
        "# Delete existing KNN_SIMILAR to avoid duplicates\n",
        "run_query(\"\"\"\n",
        "MATCH ()-[r:KNN_SIMILAR]->()\n",
        "DELETE r\n",
        "\"\"\")\n",
        "\n",
        "# kNN write: persist KNN_SIMILAR with score\n",
        "df_knn_write = run_query(\"\"\"\n",
        "CALL gds.knn.write('user_movie_weighted', {\n",
        "  nodeProperties: ['embedding'],\n",
        "  topK: 5,\n",
        "  randomSeed: 42,\n",
        "  concurrency: 1,\n",
        "  writeRelationshipType: 'KNN_SIMILAR',\n",
        "  writeProperty: 'score'\n",
        "})\n",
        "YIELD nodesCompared, relationshipsWritten\n",
        "RETURN nodesCompared, relationshipsWritten\n",
        "\"\"\")\n",
        "\n",
        "# Top 15 User-User pairs by KNN (cosine) score\n",
        "df_knn_edges = run_query(\"\"\"\n",
        "MATCH (u1:User)-[k:KNN_SIMILAR]->(u2:User)\n",
        "WHERE elementId(u1) < elementId(u2)\n",
        "RETURN u1.name AS user1, u2.name AS user2, round(k.score, 3) AS score\n",
        "ORDER BY score DESC, user1, user2\n",
        "LIMIT 15\n",
        "\"\"\")\n",
        "display(df_knn_edges)\n",
        "\n",
        "print(\"-\"*30+ \" KNN_JACCARD \"+\"-\"*30)\n",
        "df_knn_jaccard = run_query(\"\"\" \n",
        "\n",
        "MATCH (u1:User)-[j:SIMILAR_TASTE]->(u2:User)\n",
        "WHERE u1.userId < u2.userId\n",
        "OPTIONAL MATCH (u1)-[k1:KNN_SIMILAR]->(u2)\n",
        "OPTIONAL MATCH (u2)-[k2:KNN_SIMILAR]->(u1)\n",
        "WITH u1, u2, j,\n",
        "     coalesce(k1, k2) AS k\n",
        "RETURN\n",
        "  u1.name AS user1,\n",
        "  u2.name AS user2,\n",
        "  round(j.score, 4) AS jaccardScore,\n",
        "  CASE WHEN k IS NULL THEN null ELSE round(k.score, 4) END AS knnScore\n",
        "ORDER BY jaccardScore DESC, CASE WHEN knnScore IS NULL THEN 1 ELSE 0 END ASC, knnScore DESC\n",
        "LIMIT 15;\n",
        "\n",
        "\"\"\")\n",
        "display(df_knn_jaccard)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "9fe288f6",
      "metadata": {},
      "source": [
        "### GDS 2: User similarity via FastRP + kNN (cosine)\n",
        "\n",
        "#### What we did (method)\n",
        "- Built a **weighted** User–Movie projection (`RATED` with property `rating`) so similarity can use **how much** users liked movies, not only *whether* they rated them.\n",
        "- Ran **FastRP** to produce embeddings, then **kNN** on those embeddings with **cosine similarity**, and wrote results to the graph as **`KNN_SIMILAR`** edges (with `score`).\n",
        "\n",
        "#### Results: top 15 user pairs by kNN cosine similarity\n",
        "The kNN output (shown above) lists the strongest embedding-based neighbours. Cosine scores are extremely high (mostly **0.99–1.00**), which is common on a **tiny** graph (20 users / 25 movies): the embedding space has limited diversity, so many pairs look “almost identical” in vector space.\n",
        "\n",
        "---\n",
        "\n",
        "#### Comparison to Jaccard (`SIMILAR_TASTE`) — same pairs? ranking differences?\n",
        "The side-by-side comparison query (shown above as **KNN_JACCARD**) joins each unordered user pair’s **Jaccard** score (`SIMILAR_TASTE.score`) with the matching **kNN cosine** score (`KNN_SIMILAR.score`, using either direction when needed).\n",
        "\n",
        "**Do the same pairs appear?**  \n",
        "Yes—the **top kNN pairs largely overlap** the strongest Jaccard neighbourhood. In particular, the pairs with **Jaccard = 1.0** (perfect overlap in rated-movie sets) also have **very high cosine similarity** (typically ~0.998–1.000 in our results).\n",
        "\n",
        "**Where rankings diverge (and why):**  \n",
        "- **Within the Jaccard = 1.0 cluster**, kNN can **reorder** pairs (tiny differences like 0.9997 vs 0.9998). That happens because kNN is not measuring set overlap; it reflects **weighted neighbourhood / rating patterns** in embedding space, so “ties” under Jaccard are broken by embedding similarity.\n",
        "- Several pairs appear with **much lower Jaccard (~0.6667)** but still have **very high cosine (~0.99–0.997)**.  \n",
        "  **Explanation:** those users are **not identical in rated-movie sets**, but their **rating-weighted taste vectors** are still very close—this is exactly where **weighted similarity** can rank pairs highly even when **unweighted overlap** is weaker.\n",
        "\n",
        "---\n",
        "\n",
        "#### Trade-offs: Jaccard vs cosine-on-embeddings (for this dataset)\n",
        "- **Jaccard (unweighted overlap):** interpretable “same movies rated”; strong baseline for understanding co-engagement. It can **miss** meaningful taste differences when two users rated the same movies but disagreed on scores.\n",
        "- **FastRP + kNN cosine (weighted):** better aligned with “liked/disliked similarly” because ratings influence the embedding. On this small dataset, cosine magnitudes cluster near 1, so we should emphasize **relative ordering** and cross-check with Jaccard rather than over-interpreting raw cosine values alone.\n",
        "\n",
        "**Practical takeaway:** use **Jaccard** as a transparent baseline and **kNN/cosine** when recommendations should reflect **rating strength and taste alignment**, especially when Jaccard ties or overlap alone is an incomplete picture."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "7b6e59ea",
      "metadata": {},
      "source": [
        "### GDS 3:Generating Personalised Recommendations\n",
        "\n",
        "#### Query logic\n",
        "\n",
        "For a given **target user**, the query recommends movies that satisfy four conditions:\n",
        "1) find the user’s most similar neighbours from the similarity graph (`SIMILAR_TASTE` and/or `KNN_SIMILAR`),\n",
        "2) keep only movies those neighbours rated **highly** (default `rating >= 4`),\n",
        "3) exclude movies the target user has **already rated**,\n",
        "4) rank candidates by **evidence** and **rating strength**.\n",
        "\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 36,
      "id": "69cbde16",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "============================================================\n",
            "Top 10 for Alice Chen (U001)\n",
            "============================================================\n",
            "\n",
            "--- SIMILAR_TASTE (Jaccard) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>3</td>\n",
              "      <td>4.40</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Her</td>\n",
              "      <td>3</td>\n",
              "      <td>4.33</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                title  supporters  avgRating\n",
              "0  Mad Max: Fury Road           3       4.40\n",
              "1                 Her           3       4.33"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "--- KNN_SIMILAR (cosine on embeddings) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Her</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                title  supporters  avgRating\n",
              "0  Mad Max: Fury Road           1        4.0\n",
              "1                 Her           1        4.0"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "============================================================\n",
            "Top 10 for Bob Martinez (U002)\n",
            "============================================================\n",
            "\n",
            "--- SIMILAR_TASTE (Jaccard) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>The Revenant</td>\n",
              "      <td>2</td>\n",
              "      <td>4.5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Roma</td>\n",
              "      <td>2</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Oldboy</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Knives Out</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "          title  supporters  avgRating\n",
              "0  The Revenant           2        4.5\n",
              "1          Roma           2        4.0\n",
              "2        Oldboy           1        5.0\n",
              "3    Knives Out           1        5.0"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "--- KNN_SIMILAR (cosine on embeddings) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Roma</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Parasite</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>The Grand Budapest Hotel</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                      title  supporters  avgRating\n",
              "0                      Roma           1        5.0\n",
              "1                  Parasite           1        4.0\n",
              "2  The Grand Budapest Hotel           1        4.0\n",
              "3                La La Land           1        4.0"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "============================================================\n",
            "Top 10 for Carol White (U003)\n",
            "============================================================\n",
            "\n",
            "--- SIMILAR_TASTE (Jaccard) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>4</td>\n",
              "      <td>4.5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Parasite</td>\n",
              "      <td>2</td>\n",
              "      <td>4.5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Whiplash</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Roma</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Knives Out</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Get Out</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Oldboy</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating\n",
              "0  La La Land           4        4.5\n",
              "1    Parasite           2        4.5\n",
              "2    Whiplash           1        5.0\n",
              "3        Roma           1        5.0\n",
              "4  Knives Out           1        5.0\n",
              "5     Get Out           1        4.0\n",
              "6      Oldboy           1        4.0"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "--- KNN_SIMILAR (cosine on embeddings) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>2</td>\n",
              "      <td>4.5</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating\n",
              "0  La La Land           2        4.5"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Collaborative filtering: top 10 recommendations using similar users' high ratings (≥4).\n",
        "# Uses BOTH directions for similarity (target-similar or similar-target) so we don't miss edges.\n",
        "def cf_recommendations(target_user_id, rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4):\n",
        "    # Match similar users in either direction: (target)-[rel]-(similar)\n",
        "    q = f\"\"\"\n",
        "    MATCH (target:User {{userId: $targetUserId}})\n",
        "    MATCH (target)-[sim:{rel_type}]-(similar:User)\n",
        "    WHERE similar <> target\n",
        "    MATCH (similar)-[r:RATED]->(m:Movie)\n",
        "    WHERE r.rating >= $minRating\n",
        "    AND NOT EXISTS {{ (target)-[:RATED]->(m) }}\n",
        "    WITH m, count(DISTINCT similar) AS supporters, avg(r.rating) AS avgRating\n",
        "    ORDER BY supporters DESC, avgRating DESC\n",
        "    LIMIT {int(top_k)}\n",
        "    RETURN m.title AS title, supporters, round(avgRating, 2) AS avgRating\n",
        "    \"\"\"\n",
        "    return run_query(q, {\"targetUserId\": target_user_id, \"minRating\": min_rating})\n",
        "\n",
        "\n",
        "# Same three users for both methods\n",
        "TARGET_USERS = [\"U001\", \"U002\", \"U003\"]\n",
        "\n",
        "for uid in TARGET_USERS:\n",
        "    name_df = run_query(\"MATCH (u:User {userId: $uid}) RETURN u.name AS name\", {\"uid\": uid})\n",
        "    name = name_df.iloc[0][\"name\"] if not name_df.empty else uid\n",
        "    print(f\"\\n{'='*60}\\nTop 10 for {name} ({uid})\\n{'='*60}\")\n",
        "    print(\"\\n--- SIMILAR_TASTE (Jaccard) ---\")\n",
        "    df_j = cf_recommendations(uid, rel_type=\"SIMILAR_TASTE\", top_k=10)\n",
        "    if df_j.empty:\n",
        "        print(\"(No recommendations: similar users' high-rated movies are all already rated by this user.)\")\n",
        "    display(df_j)\n",
        "    print(\"\\n--- KNN_SIMILAR (cosine on embeddings) ---\")\n",
        "    df_k = cf_recommendations(uid, rel_type=\"KNN_SIMILAR\", top_k=10)\n",
        "    if df_k.empty:\n",
        "        print(\"(No recommendations: similar users' high-rated movies are all already rated by this user.)\")\n",
        "    display(df_k)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "ba077996",
      "metadata": {},
      "source": [
        "#### Explanation\n",
        "\n",
        "\n",
        "### Alice Chen (U001)\n",
        "\n",
        "- **SIMILAR_TASTE (Jaccard)**\n",
        "  - `Mad Max: Fury Road` (supporters=3, avgRating=4.40)\n",
        "  - `Her` (supporters=3, avgRating=4.33)\n",
        "\n",
        "These two titles are strongly supported by multiple similar users (3 supporters each) and have supporting averages well above 4. Given Alice’s observed pattern of rating well-reviewed films highly, the presence of these candidates is consistent with the “overlap of co-rated items” logic behind Jaccard.\n",
        "\n",
        "- **KNN_SIMILAR (cosine on embeddings)**\n",
        "  - `Her` (supporters=1, avgRating=4.0)\n",
        "\n",
        "kNN returns fewer candidates here, which is expected on a small dataset: embedding similarity is more selective. Still, the one surfaced item is a high-rating (>=4) candidate from the nearest neighborhood, so the result is reasonable even though coverage is narrower.\n",
        "\n",
        "### Bob Martinez (U002)\n",
        "\n",
        "- **SIMILAR_TASTE (Jaccard)**\n",
        "  - `The Revenant` (supporters=2, avgRating=4.5)\n",
        "  - `Roma` (supporters=2, avgRating=4.0)\n",
        "  - `Oldboy` (supporters=1, avgRating=5.0)\n",
        "  - `Knives Out` (supporters=1, avgRating=5.0)\n",
        "\n",
        "Jaccard produces a clear ranked set with the highest-support items at the top (2 supporters). The remaining recommendations come from single supporting neighbors but have perfect (5.0) supporting averages. Overall, Bob’s results look plausible because the ranking combines both *evidence* (supporters) and *strength of the supporting ratings* (avgRating).\n",
        "\n",
        "- **KNN_SIMILAR (cosine on embeddings)**\n",
        "  - No recommendations returned.\n",
        "\n",
        "This is best interpreted as a **coverage/sparsity** effect: after restricting candidates to movies rated `>= 4` by similar users and excluding movies Bob has already rated, the candidate set can become empty on such a small graph.\n",
        "\n",
        "### Carol White (U003)\n",
        "\n",
        "- **SIMILAR_TASTE (Jaccard)**\n",
        "  - `La La Land` (supporters=4, avgRating=4.5)\n",
        "  - `Parasite` (supporters=2, avgRating=4.5)\n",
        "  - `Whiplash` (supporters=1, avgRating=5.0)\n",
        "  - `Roma` (supporters=1, avgRating=5.0)\n",
        "  - `Knives Out` (supporters=1, avgRating=5.0)\n",
        "  - `Get Out` (supporters=1, avgRating=4.0)\n",
        "  - `Oldboy` (supporters=1, avgRating=4.0)\n",
        "\n",
        "Carol receives multiple well-supported candidates, with `La La Land` standing out by having the strongest support (4 supporters). Several remaining items have supporting averages at/near 5.0, indicating that at least one similar user rated them extremely highly; this matches the idea that Jaccard can surface overlapping taste neighborhoods even when evidence is limited.\n",
        "\n",
        "- **KNN_SIMILAR (cosine on embeddings)**\n",
        "  - `La La Land` (supporters=3, avgRating=4.6)\n",
        "\n",
        "kNN again returns fewer items, but it does agree with Jaccard on the top candidate (`La La Land`) and boosts it with strong embedding-based support (3 supporters, avgRating > 4.5). This agreement across methods increases confidence that the recommendation is not a coincidence of overlap alone."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "a2d9896d",
      "metadata": {},
      "source": [
        "**Why fewer than 10 recommendations?**\n",
        "\n",
        "We ask for **up to** 10 recommendations per user. The query returns every movie that (a) at least one similar user rated **≥ 4**, and (b) the **target has not** already rated, then ranks by number of supporters and average rating and applies `LIMIT 10`. So we get **at most** 10 rows; the actual count is the number of such candidates that exist.\n",
        "\n",
        "In this dataset there are only **25 movies**, each user has about **5 ratings**, and each user has at most **5 similar users** (topK). So the set of movies that similar users rated highly, minus the movies the target already rated, is often **smaller than 10**. For example, Alice gets 2 from SIMILAR_TASTE and 1 from KNN_SIMILAR; Bob gets 4 from each; Carol gets 7 from SIMILAR_TASTE and 1 from KNN_SIMILAR. This is expected: the query is correct, and the **data size and sparsity** limit how many qualifying candidates there are."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "882f6e3a",
      "metadata": {},
      "source": [
        "**Comparison: SIMILAR_TASTE (Jaccard) vs KNN_SIMILAR (cosine)**\n",
        "\n",
        "- **Jaccard (SIMILAR_TASTE)** tends to recommend movies that **overlap** with what the target’s “co-viewing” neighbours rated highly—i.e. movies that appear often in the same *set* of rated items, regardless of whether those neighbours actually liked them (high score) or not. So you may see more **popular** or **widely co-rated** titles, and occasionally titles that similar users rated 4+ but with mixed taste.\n",
        "\n",
        "- **kNN (KNN_SIMILAR)** tends to recommend movies that **taste-aligned** neighbours rated highly—i.e. users whose *embedding* is close to the target’s, so their high ratings are more likely to match the target’s preference pattern. You often get a different ordering or different titles: more aligned with the user’s **high-rated genres** and rating style (e.g. strict vs generous).\n",
        "\n",
        "- **For each user:** Compare the two top-10 lists above. Where they differ, Jaccard is driven by overlap; kNN by weighted taste. In practice, for these three users, **kNN usually looks more aligned** with their known high-rated genres and films, because it uses rating strength; Jaccard can surface reasonable candidates but sometimes includes titles that “similar” users (by overlap) rated 4+ for different reasons. A brief per-user assessment: note whether the Jaccard list is more diverse or more popularity-heavy, and whether the kNN list better matches the user’s rated genres/directors."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "7741aea5",
      "metadata": {},
      "source": [
        "### GDS 4:Hybrid Recommendation — Content-Based Enrichment\n",
        "\n",
        "#### Query logic\n",
        "\n",
        "For a given **target user**, the hybrid query recommends movies that combine collaborative support with content-based signals:\n",
        "\n",
        "1) find the user’s most similar neighbours from the similarity graph (`SIMILAR_TASTE` and/or `KNN_SIMILAR`),\n",
        "\n",
        "2) identify candidate movies from those neighbours by keeping only movies the neighbours rated **highly** (default `rating >= 4`), and **exclude** movies the target user has already rated,\n",
        "\n",
        "3) compute content overlap by comparing each candidate movie’s metadata to the movies the target user rated highly:\n",
        "   - genre overlap via `(:Movie)-[:IN_GENRE]->(:Genre)`\n",
        "   - director overlap via `(:Movie)-[:DIRECTED_BY]->(:Director)`,\n",
        "\n",
        "4) rank candidates using a **hybrid scoring function**:\n",
        "   - collaborative part uses evidence/strength from the neighbours (e.g., `supporters` and `avgRating`)\n",
        "   - add boosts for content overlap (e.g., `genreOverlapCount * genreWeight` and `directorOverlapCount * directorWeight`),\n",
        "\n",
        "5) sort by the hybrid score (then break ties with collaborative evidence/rating strength) and return the **top 10** results."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 34,
      "id": "8874564e",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "============================================================\n",
            "HYBRID top-10 for Alice Chen (U001)\n",
            "============================================================\n",
            "\n",
            "--- Hybrid (collab + genre/director boost) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>genreOverlapCount</th>\n",
              "      <th>directorOverlapCount</th>\n",
              "      <th>finalScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>3</td>\n",
              "      <td>4.40</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>8.400</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Her</td>\n",
              "      <td>3</td>\n",
              "      <td>4.33</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>8.333</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                title  supporters  avgRating  genreOverlapCount  \\\n",
              "0  Mad Max: Fury Road           3       4.40                  2   \n",
              "1                 Her           3       4.33                  2   \n",
              "\n",
              "   directorOverlapCount  finalScore  \n",
              "0                     0       8.400  \n",
              "1                     0       8.333  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "============================================================\n",
            "HYBRID top-10 for Bob Martinez (U002)\n",
            "============================================================\n",
            "\n",
            "--- Hybrid (collab + genre/director boost) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>genreOverlapCount</th>\n",
              "      <th>directorOverlapCount</th>\n",
              "      <th>finalScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>The Revenant</td>\n",
              "      <td>2</td>\n",
              "      <td>4.5</td>\n",
              "      <td>1</td>\n",
              "      <td>0</td>\n",
              "      <td>7.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Roma</td>\n",
              "      <td>2</td>\n",
              "      <td>4.0</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>7.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Oldboy</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>7.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Knives Out</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>7.0</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "          title  supporters  avgRating  genreOverlapCount  \\\n",
              "0  The Revenant           2        4.5                  1   \n",
              "1          Roma           2        4.0                  2   \n",
              "2        Oldboy           1        5.0                  2   \n",
              "3    Knives Out           1        5.0                  2   \n",
              "\n",
              "   directorOverlapCount  finalScore  \n",
              "0                     0         7.0  \n",
              "1                     0         7.0  \n",
              "2                     0         7.0  \n",
              "3                     0         7.0  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "============================================================\n",
            "HYBRID top-10 for Carol White (U003)\n",
            "============================================================\n",
            "\n",
            "--- Hybrid (collab + genre/director boost) ---\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>genreOverlapCount</th>\n",
              "      <th>directorOverlapCount</th>\n",
              "      <th>finalScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>4</td>\n",
              "      <td>4.5</td>\n",
              "      <td>2</td>\n",
              "      <td>1</td>\n",
              "      <td>10.5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Parasite</td>\n",
              "      <td>2</td>\n",
              "      <td>4.5</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>7.5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Whiplash</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "      <td>1</td>\n",
              "      <td>1</td>\n",
              "      <td>7.5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Roma</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>7.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Knives Out</td>\n",
              "      <td>1</td>\n",
              "      <td>5.0</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>7.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Oldboy</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "      <td>2</td>\n",
              "      <td>0</td>\n",
              "      <td>6.0</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Get Out</td>\n",
              "      <td>1</td>\n",
              "      <td>4.0</td>\n",
              "      <td>1</td>\n",
              "      <td>0</td>\n",
              "      <td>5.5</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating  genreOverlapCount  directorOverlapCount  \\\n",
              "0  La La Land           4        4.5                  2                     1   \n",
              "1    Parasite           2        4.5                  2                     0   \n",
              "2    Whiplash           1        5.0                  1                     1   \n",
              "3        Roma           1        5.0                  2                     0   \n",
              "4  Knives Out           1        5.0                  2                     0   \n",
              "5      Oldboy           1        4.0                  2                     0   \n",
              "6     Get Out           1        4.0                  1                     0   \n",
              "\n",
              "   finalScore  \n",
              "0        10.5  \n",
              "1         7.5  \n",
              "2         7.5  \n",
              "3         7.0  \n",
              "4         7.0  \n",
              "5         6.0  \n",
              "6         5.5  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "def hybrid_recommendations(\n",
        "    target_user_id,\n",
        "    rel_type=\"SIMILAR_TASTE\",   # \"SIMILAR_TASTE\" or \"KNN_SIMILAR\"\n",
        "    top_k=10,\n",
        "    min_rating=4,              # also used for “liked movies” source\n",
        "    genre_weight=0.5,         # boost per overlapping genre\n",
        "    director_weight=1.0       # boost per overlapping director\n",
        "):\n",
        "    q = f\"\"\"\n",
        "    MATCH (target:User {{userId: $targetUserId}})\n",
        "    MATCH (target)-[sim:{rel_type}]-(similar:User)\n",
        "    WHERE similar <> target\n",
        "\n",
        "    // Candidate movies: similar users rated them highly\n",
        "    MATCH (similar)-[r:RATED]->(cand:Movie)\n",
        "    WHERE r.rating >= $minRating\n",
        "      AND NOT EXISTS {{ (target)-[:RATED]->(cand) }}\n",
        "\n",
        "    WITH cand, count(DISTINCT similar) AS supporters, avg(r.rating) AS avgRating\n",
        "\n",
        "    // Content-based signal source: movies the target rated highly\n",
        "    MATCH (target)-[tr:RATED]->(liked:Movie)\n",
        "    WHERE tr.rating >= $minRating\n",
        "\n",
        "    // Compute overlap counts (DISTINCT prevents double-counting across multiple liked movies)\n",
        "    OPTIONAL MATCH (cand)-[:IN_GENRE]->(g:Genre)<-[:IN_GENRE]-(liked)\n",
        "    OPTIONAL MATCH (cand)-[:DIRECTED_BY]->(d:Director)<-[:DIRECTED_BY]-(liked)\n",
        "\n",
        "    WITH\n",
        "      cand,\n",
        "      supporters,\n",
        "      avgRating,\n",
        "      count(DISTINCT g) AS genreOverlapCount,\n",
        "      count(DISTINCT d) AS directorOverlapCount\n",
        "\n",
        "    // Final hybrid score: collaborative support + rating strength + content boosts\n",
        "    WITH\n",
        "      cand,\n",
        "      supporters,\n",
        "      avgRating,\n",
        "      genreOverlapCount,\n",
        "      directorOverlapCount,\n",
        "      (supporters * 1.0 + avgRating)\n",
        "        + (genreOverlapCount * $genreWeight)\n",
        "        + (directorOverlapCount * $directorWeight) AS finalScore\n",
        "\n",
        "    ORDER BY finalScore DESC, supporters DESC, avgRating DESC\n",
        "    LIMIT {int(top_k)}  // literal limit (avoid LIMIT $param issues)\n",
        "\n",
        "    RETURN\n",
        "      cand.title AS title,\n",
        "      supporters,\n",
        "      round(avgRating, 2) AS avgRating,\n",
        "      genreOverlapCount,\n",
        "      directorOverlapCount,\n",
        "      round(finalScore, 3) AS finalScore\n",
        "    \"\"\"\n",
        "\n",
        "    return run_query(q, {\n",
        "        \"targetUserId\": target_user_id,\n",
        "        \"minRating\": min_rating,\n",
        "        \"genreWeight\": genre_weight,\n",
        "        \"directorWeight\": director_weight\n",
        "    })\n",
        "\n",
        "\n",
        "# Run for the same three users you used in Task 3\n",
        "TARGET_USERS = [\"U001\", \"U002\", \"U003\"]\n",
        "\n",
        "for uid in TARGET_USERS:\n",
        "    name_df = run_query(\"MATCH (u:User {userId: $uid}) RETURN u.name AS name\", {\"uid\": uid})\n",
        "    name = name_df.iloc[0][\"name\"] if not name_df.empty else uid\n",
        "\n",
        "    print(f\"\\n{'='*60}\\nHYBRID top-{10} for {name} ({uid})\\n{'='*60}\")\n",
        "    print(\"\\n--- Hybrid (collab + genre/director boost) ---\")\n",
        "\n",
        "    # choose which collaborative signal to hybridize with:\n",
        "    df_h = hybrid_recommendations(uid, rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4)\n",
        "    if df_h.empty:\n",
        "        print(\"(No hybrid recommendations found for this user with the current cutoff/boost.)\")\n",
        "    display(df_h)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "875b7e80",
      "metadata": {},
      "source": [
        "### Did hybrid change the rankings vs pure collaborative filtering?\n",
        "\n",
        "#### Alice Chen (U001)\n",
        "**Collaborative (Task 3):**\n",
        "- `SIMILAR_TASTE`: `Mad Max: Fury Road` (3 supporters, 4.40) and `Her` (3, 4.33)\n",
        "- `KNN_SIMILAR`: `Her` only (1, 4.0)\n",
        "\n",
        "**Hybrid (Task 4):**\n",
        "- `Mad Max: Fury Road` (supporters=3, avg=4.40, genreOverlap=2, directorOverlap=0, finalScore=8.40)\n",
        "- `Her` (supporters=3, avg=4.33, genreOverlap=2, directorOverlap=0, finalScore=8.33)\n",
        "\n",
        "**Interpretation:** For Alice, the hybrid boost does **not** introduce a new “director-driven” ranking (directorOverlapCount=0 for both). The hybrid reranker mainly reinforces the same strong collaborative candidates using genre overlap, so the top-2 set remains the same and the ordering is nearly unchanged.\n",
        "\n",
        "---\n",
        "\n",
        "#### Bob Martinez (U002)\n",
        "**Collaborative (Task 3):**\n",
        "- `SIMILAR_TASTE`: `The Revenant` (2, 4.5), `Roma` (2, 4.0), `Oldboy` (1, 5.0), `Knives Out` (1, 5.0)\n",
        "- `KNN_SIMILAR`: no results (after filtering/exclusion)\n",
        "\n",
        "**Hybrid (Task 4):**\n",
        "- `The Revenant` (2, 4.5, genreOverlap=1, directorOverlap=0, finalScore=7.0)\n",
        "- `Roma` (2, 4.0, genreOverlap=2, directorOverlap=0, finalScore=7.0)\n",
        "- `Oldboy` (1, 5.0, genreOverlap=2, directorOverlap=0, finalScore=7.0)\n",
        "- `Knives Out` (1, 5.0, genreOverlap=2, directorOverlap=0, finalScore=7.0)\n",
        "\n",
        "**Interpretation:** Hybrid again shows **no director effect** (`directorOverlapCount=0` everywhere). Because multiple items tie on `finalScore` (all 7.0), genre overlap mostly equalizes candidates rather than fundamentally changing which movies appear. The hybrid output is therefore consistent with the collaborative-only candidates produced by Jaccard.\n",
        "\n",
        "---\n",
        "\n",
        "#### Carol White (U003)\n",
        "**Collaborative (Task 3):**\n",
        "- `SIMILAR_TASTE`: 7 recommendations with top item `La La Land` (4 supporters, 4.5)\n",
        "- `KNN_SIMILAR`: `La La Land` only (2, 4.33)\n",
        "\n",
        "**Hybrid (Task 4):**\n",
        "- `La La Land` (supporters=4, avg=4.5, genreOverlap=2, directorOverlap=1, finalScore=10.5)\n",
        "- `Parasite` (2, 4.5, 2, 0, 7.5)\n",
        "- `Whiplash` (1, 5.0, 1, 1, 7.5)\n",
        "- `Roma` (1, 5.0, 2, 0, 7.0)\n",
        "- `Knives Out` (1, 5.0, 2, 0, 7.0)\n",
        "- `Oldboy` (1, 4.0, 2, 0, 6.0)\n",
        "- `Get Out` (1, 4.0, 1, 0, 5.5)\n",
        "\n",
        "**Interpretation:** This is where hybrid has the **clearest qualitative impact**. Unlike Alice/Bob, Carol’s hybrid results include a meaningful director signal:\n",
        "- `La La Land` benefits from both strong collaborative support and content overlap (directorOverlapCount=1).\n",
        "- `Whiplash` also receives a director boost (directorOverlapCount=1), allowing it to remain competitive despite fewer collaborative supporters (supporters=1).\n",
        "\n",
        "**Net effect:** Hybrid adds content-aware confidence and can “rescue” movies that are not maximally supported by neighbours but match Carol’s genre/director preferences—most noticeably through director overlap.\n",
        "\n",
        "---\n",
        "\n",
        "### Overall conclusion\n",
        "Across users, hybrid generally keeps the **collaboratively strong** candidates near the top. However, it changes rankings most for Carol, where director overlap provides additional reranking signal; for Alice and Bob, director overlap is zero so hybrid mostly acts as a lighter **genre-based reinforcement** of collaborative filtering."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "f4f97423",
      "metadata": {},
      "source": [
        "#### Query logic (Q5 GDS: Community detection + community characterization)\n",
        "\n",
        "This section builds **user communities** from the user–user similarity graph (previous GDS task outputs), then summarizes each of the **3 largest** communities by demographics, dominant genres, and occupations.\n",
        "\n",
        "1) Choose which similarity edges to use\n",
        "- `REL_TYPE` selects the already-written similarity relationship:\n",
        "  - `SIMILAR_TASTE` (Jaccard) or\n",
        "  - `KNN_SIMILAR` (FastRP + kNN cosine).\n",
        "- `GRAPH_NAME = user_user_<rel_type>` is just the in-memory GDS graph name.\n",
        "\n",
        "2) Project a monopartite “User–User” graph for Louvain\n",
        "- `CALL gds.graph.project(...)` creates an in-memory graph containing **only `User` nodes**.\n",
        "- Each similarity relationship becomes an edge in the projection:\n",
        "  - `type: REL_TYPE`\n",
        "  - `orientation: UNDIRECTED` (so community detection uses similarity links symmetrically)\n",
        "  - `properties: ['score']` (so the edge weight comes from the similarity score).\n",
        "\n",
        "3) Run Louvain community detection and write results back\n",
        "- `MATCH (u:User) REMOVE u.communityId` clears old results to avoid mixing runs.\n",
        "- `CALL gds.louvain.write(...)`:\n",
        "  - detects communities by optimizing modularity over the projected user-user graph,\n",
        "  - stores the detected community id back on each `User` as `u.communityId`.\n",
        "- `relationshipWeightProperty: 'score'` ensures Louvain uses the similarity strength (not just edge existence).\n",
        "\n",
        "4) Select the largest communities\n",
        "- The query counts users per `communityId`, sorts by size, and returns the top 3 community ids.\n",
        "\n",
        "5) Characterize each of the top 3 communities\n",
        "For each `cid`:\n",
        "- **Age profile:**  \n",
        "  - `MATCH (u:User) WHERE u.communityId = $cid`\n",
        "  - returns `avg(u.age)` and `count(u)` (community size).\n",
        "- **Dominant genre preferences:**  \n",
        "  - traverse `User -> RATED -> Movie -> IN_GENRE -> Genre`,\n",
        "  - group by `Genre`,\n",
        "  - compute `avg(r.rating)` per genre (and a supporting `count(r)`),\n",
        "  - sort by average rating to show the top genres that members consistently rate highly.\n",
        "- **Occupation distribution:**  \n",
        "  - count users by `u.occupation` inside the community,\n",
        "  - sort by frequency to identify the most common occupations.\n",
        "\n",
        "Overall, the logic answers: *“Which clusters of users have shared taste (via similarity edges), and what common demographics/content preferences do those clusters exhibit?”*### GDS 5: Community Detection and Recommendation Evaluation"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 39,
      "id": "3ead6416",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>graphName</th>\n",
              "      <th>nodeCount</th>\n",
              "      <th>relationshipCount</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>user_user_similar_taste</td>\n",
              "      <td>20</td>\n",
              "      <td>200</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                 graphName  nodeCount  relationshipCount\n",
              "0  user_user_similar_taste         20                200"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>communityCount</th>\n",
              "      <th>modularity</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>4</td>\n",
              "      <td>0.63307</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   communityCount  modularity\n",
              "0               4     0.63307"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>communityId</th>\n",
              "      <th>size</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>19</td>\n",
              "      <td>7</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>14</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>17</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   communityId  size\n",
              "0           19     7\n",
              "1           14     5\n",
              "2           17     5"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "===== Community 19 =====\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>avgAge</th>\n",
              "      <th>members</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>34.29</td>\n",
              "      <td>7</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   avgAge  members\n",
              "0   34.29        7"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>genre</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>numRatings</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Crime</td>\n",
              "      <td>5.00</td>\n",
              "      <td>4</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Drama</td>\n",
              "      <td>4.75</td>\n",
              "      <td>8</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Action</td>\n",
              "      <td>4.67</td>\n",
              "      <td>18</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "    genre  avgRating  numRatings\n",
              "0   Crime       5.00           4\n",
              "1   Drama       4.75           8\n",
              "2  Action       4.67          18"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>occupation</th>\n",
              "      <th>numUsers</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Analyst</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Consultant</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Developer</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Engineer</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   occupation  numUsers\n",
              "0     Analyst         1\n",
              "1  Consultant         1\n",
              "2   Developer         1\n",
              "3    Engineer         1"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "===== Community 14 =====\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>avgAge</th>\n",
              "      <th>members</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>27.8</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   avgAge  members\n",
              "0    27.8        5"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>genre</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>numRatings</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Music</td>\n",
              "      <td>5.00</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Biography</td>\n",
              "      <td>4.75</td>\n",
              "      <td>4</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Romance</td>\n",
              "      <td>4.63</td>\n",
              "      <td>8</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "       genre  avgRating  numRatings\n",
              "0      Music       5.00           1\n",
              "1  Biography       4.75           4\n",
              "2    Romance       4.63           8"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>occupation</th>\n",
              "      <th>numUsers</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Student</td>\n",
              "      <td>2</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Chef</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Designer</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Writer</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "  occupation  numUsers\n",
              "0    Student         2\n",
              "1       Chef         1\n",
              "2   Designer         1\n",
              "3     Writer         1"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "===== Community 17 =====\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>avgAge</th>\n",
              "      <th>members</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>46.6</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   avgAge  members\n",
              "0    46.6        5"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>genre</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>numRatings</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Crime</td>\n",
              "      <td>4.53</td>\n",
              "      <td>15</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Drama</td>\n",
              "      <td>4.24</td>\n",
              "      <td>25</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Adventure</td>\n",
              "      <td>4.00</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "       genre  avgRating  numRatings\n",
              "0      Crime       4.53          15\n",
              "1      Drama       4.24          25\n",
              "2  Adventure       4.00           3"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>occupation</th>\n",
              "      <th>numUsers</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Accountant</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Architect</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Doctor</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>Retired</td>\n",
              "      <td>1</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   occupation  numUsers\n",
              "0  Accountant         1\n",
              "1   Architect         1\n",
              "2      Doctor         1\n",
              "3     Retired         1"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "REL_TYPE = \"SIMILAR_TASTE\"   # or \"KNN_SIMILAR\"\n",
        "\n",
        "GRAPH_NAME = f\"user_user_{REL_TYPE.lower()}\"\n",
        "\n",
        "# 1) Project a user-user graph from the similarity edges\n",
        "run_query(f\"\"\"\n",
        "CALL gds.graph.drop('{GRAPH_NAME}', false) YIELD graphName\n",
        "RETURN graphName\n",
        "\"\"\")\n",
        "\n",
        "df_proj = run_query(f\"\"\"\n",
        "CALL gds.graph.project(\n",
        "  '{GRAPH_NAME}',\n",
        "  ['User'],\n",
        "  {{\n",
        "    {REL_TYPE}: {{\n",
        "      type: '{REL_TYPE}',\n",
        "      orientation: 'UNDIRECTED',\n",
        "      properties: ['score']\n",
        "    }}\n",
        "  }}\n",
        ")\n",
        "YIELD graphName, nodeCount, relationshipCount\n",
        "RETURN graphName, nodeCount, relationshipCount\n",
        "\"\"\")\n",
        "display(df_proj)\n",
        "\n",
        "# 2) Run Louvain and write community ids back to Neo4j as u.communityId\n",
        "run_query(f\"\"\"\n",
        "MATCH (u:User) REMOVE u.communityId\n",
        "\"\"\")\n",
        "\n",
        "df_louv = run_query(f\"\"\"\n",
        "CALL gds.louvain.write('{GRAPH_NAME}', {{\n",
        "  writeProperty: 'communityId',\n",
        "  relationshipWeightProperty: 'score'\n",
        "}})\n",
        "YIELD communityCount, modularity\n",
        "RETURN communityCount, modularity\n",
        "\"\"\")\n",
        "display(df_louv)\n",
        "\n",
        "# 3) Get the 3 largest communities\n",
        "df_top = run_query(\"\"\"\n",
        "MATCH (u:User)\n",
        "WHERE u.communityId IS NOT NULL\n",
        "RETURN u.communityId AS communityId, count(u) AS size\n",
        "ORDER BY size DESC\n",
        "LIMIT 3\n",
        "\"\"\")\n",
        "display(df_top)\n",
        "\n",
        "# 4) Characterize each of the 3 largest communities\n",
        "for cid in df_top[\"communityId\"].tolist():\n",
        "    print(f\"\\n===== Community {cid} =====\")\n",
        "\n",
        "    df_age = run_query(\"\"\"\n",
        "    MATCH (u:User)\n",
        "    WHERE u.communityId = $cid\n",
        "    RETURN round(avg(u.age), 2) AS avgAge, count(u) AS members\n",
        "    \"\"\", {\"cid\": cid})\n",
        "    display(df_age)\n",
        "\n",
        "    # Dominant genre preferences: top genres by average rating within the community\n",
        "    df_genres = run_query(\"\"\"\n",
        "    MATCH (u:User {communityId: $cid})-[r:RATED]->(m:Movie)-[:IN_GENRE]->(g:Genre)\n",
        "    WITH g.name AS genre,\n",
        "         avg(r.rating) AS avgRating,\n",
        "         count(r) AS numRatings\n",
        "    ORDER BY avgRating DESC, numRatings DESC\n",
        "    LIMIT 3\n",
        "    RETURN genre, round(avgRating, 2) AS avgRating, numRatings\n",
        "    \"\"\", {\"cid\": cid})\n",
        "    display(df_genres)\n",
        "\n",
        "    # Occupation distribution: top occupations within the community\n",
        "    df_occ = run_query(\"\"\"\n",
        "    MATCH (u:User {communityId: $cid})\n",
        "    RETURN u.occupation AS occupation, count(u) AS numUsers\n",
        "    ORDER BY numUsers DESC, occupation\n",
        "    LIMIT 4\n",
        "    \"\"\", {\"cid\": cid})\n",
        "    display(df_occ)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "cd43e986",
      "metadata": {},
      "source": [
        "This evaluation measures **precision@K** for one target user by hiding two of their rated movies, generating recommendations, and checking whether the hidden movies show up in the top-K list.\n",
        "\n",
        "1. **Select the target user**\n",
        "- The query starts from one target user: `MATCH (target:User {userId: $targetUserId})`.\n",
        "\n",
        "2. **Pick 2 held-out (hidden) movies**\n",
        "- From the movies the target user has rated, the query selects **2 movies** (using either the most recent timestamps or just any two, depending on `holdout_mode`).\n",
        "- These two movies are stored as:\n",
        "  - `heldOut`: list of `{movieId, title}`\n",
        "  - `heldOutMovieIds`: list of `movieId` values (for easy filtering)\n",
        "\n",
        "3. **Generate recommendations using collaborative filtering**\n",
        "- The query finds similar users using the previously created similarity edges:\n",
        "  - `MATCH (target)-[sim:{rel_type}]-(similar:User)`\n",
        "- Then it collects candidate movies that the similar users rated **highly**:\n",
        "  - `MATCH (similar)-[r:RATED]->(m:Movie)`\n",
        "  - `WHERE r.rating >= $minRating`\n",
        "\n",
        "4. **Exclude what the target already rated (except the held-out items)**\n",
        "- Normally, the recommender should not recommend movies the target has already rated.\n",
        "- The query enforces this using:\n",
        "  - allow a movie if it is in `heldOutMovieIds`\n",
        "  - otherwise only allow it if `NOT EXISTS { (target)-[:RATED]->(m) }`\n",
        "- This way, the held-out movies are treated like “unknown” items we want the system to recover.\n",
        "\n",
        "5. **Score and rank candidates**\n",
        "- For each candidate movie `m`, the query computes:\n",
        "  - `supporters`: how many distinct similar users recommended it (based on `count(DISTINCT similar)`)\n",
        "  - `avgRating`: the average rating from those similar users\n",
        "- Candidates are sorted by:\n",
        "  - `supporters DESC`, then `avgRating DESC`\n",
        "- The query takes the top-`K` results using `LIMIT {top_k}`.\n",
        "\n",
        "6. **Compute hits and precision@K**\n",
        "- `hits` = how many of the held-out movies appear inside the recommended top-K list.\n",
        "- `precisionAtK = hits / K`.\n",
        "\n",
        "**Returned fields**\n",
        "- `heldOutTitles`: the 2 hidden movies\n",
        "- `recommendedTopK`: the top-K recommended titles\n",
        "- `hits`: number of held-out movies recovered in top-K\n",
        "- `precisionAtK`: `hits / K`\n",
        "\n",
        "**Interpretation**\n",
        "- If `hits = 1`, then precision@K is `1/K`.\n",
        "- If `hits = 0`, then precision@K is `0`.\n",
        "- If `hits = 2`, then precision@K is `2/K`.\n",
        "\n",
        "This is a simple, understandable hold-out test for demonstrating the recommender pipeline (but it’s limited because it uses only one user and two hidden items)."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 36,
      "id": "638c819e",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>heldOutTitles</th>\n",
              "      <th>recommendedTopK</th>\n",
              "      <th>hits</th>\n",
              "      <th>precisionAtK</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>[Blade Runner 2049, Dune]</td>\n",
              "      <td>[Dune, Blade Runner 2049, Mad Max: Fury Road, Her]</td>\n",
              "      <td>2</td>\n",
              "      <td>0.4</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "               heldOutTitles  \\\n",
              "0  [Blade Runner 2049, Dune]   \n",
              "\n",
              "                                      recommendedTopK  hits  precisionAtK  \n",
              "0  [Dune, Blade Runner 2049, Mad Max: Fury Road, Her]     2           0.4  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "def holdout_precision(\n",
        "    target_user_id=\"U001\",\n",
        "    rel_type=\"SIMILAR_TASTE\",\n",
        "    top_k=5,\n",
        "    min_rating=4,\n",
        "    holdout_mode=\"most_recent\",  # \"most_recent\" (by r.timestamp DESC)\n",
        "):\n",
        "    q = f\"\"\"\n",
        "    // Pick 2 held-out movies rated by the target (to simulate removing them)\n",
        "    MATCH (target:User {{userId: $targetUserId}})-[rh:RATED]->(h:Movie)\n",
        "    WITH target, h, rh\n",
        "    { \"ORDER BY rh.timestamp DESC\" if holdout_mode==\"most_recent\" else \"\" }\n",
        "    LIMIT 2\n",
        "    WITH target, collect({{movieId: h.movieId, title: h.title}}) AS heldOut\n",
        "    WITH target, heldOut,\n",
        "         [x IN heldOut | x.movieId] AS heldOutMovieIds\n",
        "\n",
        "    // Generate recommendations using similarity edges\n",
        "    MATCH (target)-[sim:{rel_type}]-(similar:User)\n",
        "    WHERE similar <> target\n",
        "\n",
        "    MATCH (similar)-[r:RATED]->(m:Movie)\n",
        "    WHERE r.rating >= $minRating\n",
        "\n",
        "      // Allow the held-out movies to be recommended, but exclude other already-rated movies\n",
        "      AND (m.movieId IN heldOutMovieIds OR NOT EXISTS {{ (target)-[:RATED]->(m) }})\n",
        "\n",
        "    WITH heldOut, heldOutMovieIds, m,\n",
        "         count(DISTINCT similar) AS supporters,\n",
        "         avg(r.rating) AS avgRating\n",
        "\n",
        "    ORDER BY supporters DESC, avgRating DESC\n",
        "    LIMIT {int(top_k)}\n",
        "\n",
        "    WITH heldOut, heldOutMovieIds,\n",
        "         collect({{movieId: m.movieId, title: m.title}}) AS recommended\n",
        "\n",
        "    WITH heldOut, heldOutMovieIds, recommended,\n",
        "         size([rec IN recommended WHERE rec.movieId IN heldOutMovieIds]) AS hits\n",
        "\n",
        "    RETURN\n",
        "      [x IN heldOut | x.title] AS heldOutTitles,\n",
        "      [x IN recommended | x.title] AS recommendedTopK,\n",
        "      hits,\n",
        "      toFloat(hits) / {int(top_k)} AS precisionAtK;\n",
        "    \"\"\"\n",
        "\n",
        "    return run_query(q, {\"targetUserId\": target_user_id, \"minRating\": min_rating})\n",
        "\n",
        "# Example: run for one user (pick whatever user you want)\n",
        "df_prec = holdout_precision(\n",
        "    target_user_id=\"U001\",\n",
        "    rel_type=\"SIMILAR_TASTE\",\n",
        "    top_k=5,\n",
        "    min_rating=4\n",
        ")\n",
        "display(df_prec)"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 42,
      "id": "fae8104a",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>communityId</th>\n",
              "      <th>size</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>19</td>\n",
              "      <td>7</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>17</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>14</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>18</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   communityId  size\n",
              "0           19     7\n",
              "1           17     5\n",
              "2           14     5\n",
              "3           18     3"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "image/png": "iVBORw0KGgoAAAANSUhEUgAAAxAAAAHkCAYAAACuZcnbAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAVnxJREFUeJzt3X2cTPX///Hn7KVdrF0Xid2wwrLrOmxyfVFZ2VxHRbkqPkURfaifpD59UFFJUlsiJVEu83FNbT6ERCSXi7Ar16xll1275/eHz57vjl2cnRlm8Ljfbnu7zZw5857XzrznzHmec97n2AzDMAQAAAAAFni5uwAAAAAAtw4CBAAAAADLCBAAAAAALCNAAAAAALCMAAEAAADAMgIEAAAAAMsIEAAAAAAsI0AAAAAAsIwAAQAAAMAyAgTgBsOGDVNERIQiIiK0fv16d5dzR8h+v5s3b+7uUvIlMTHRrL179+7uLscjff7554qIiFDdunWVmprq7nJuivXr15v9YtiwYQ6307NnT0VEROjZZ591YXVw1q2yvOrevbtZa2JiorvLwU3k4+4CcGtKTU3VrFmztHz5ciUkJCg1NVUlSpRQxYoV1bp1a8XExMjPz8/dZd5yVqxYoR07dkiS2rdvr7CwMJe2v379ej311FPmfS8vLxUoUEAhISEqX768HnroIbVt21b+/v5Ovc7UqVOVkpIiSRowYIBTbVl1o987VzMMQ8uWLdPcuXO1bds2nTlzRsHBwSpTpoxatmypdu3aqWjRoje9rvXr12vDhg2SpJYtW6pKlSo3vYb8OH/+vD7//HNJUqdOnRQYGGg+FhERYd5euXKlx/cJd+jRo4fWrl2r+Ph4bdmyRTVq1HBJu/v27dNXX32lX375RUePHpXNZlNoaKjuu+8+dejQQdWrV3fJ69xpJkyYIEkqXLiwevTocUNeIyEhQZMmTdL69et1+vRpFSxYUCEhIapUqZKio6PVrVu3G/K6uLUQIJBvCQkJ6tevnw4dOmQ3PSkpSUlJSfrpp59UqVIlj1/xcKd+/fqpU6dOkuxXclasWKG5c+dKkurVq3fDV3iysrKUmpqq1NRUJSUlafXq1friiy/08ccfq3z58g63O23aNCUlJUm6uQHiWu/d9OnTJcnpcOQK586d04svvqj//ve/dtOPHz+u48eP67fffpOXl9cNW0G4lg0bNuijjz6SJIWGhnr893ju3Lk6ffq0JKlz585urubmiYyMNPt08eLFHW6ncePGKlmypI4eParJkyfrww8/dLq2r7/+WqNHj9alS5fspu/evVu7d+/W5s2bNX/+fKdf53Z2teVVzu/mjVg+7NmzR4899pjdnrzk5GQlJyfrr7/+0s6dO+0CxPDhw82NRXfddZfL64HnIkAgX86cOaNnnnlGhw8flnR5gdG7d29FRETo/Pnz2rBhg+bMmePmKj1fuXLlVK5cObfWUKJECX3wwQdKS0vTn3/+qa+++konTpzQ/v371adPH82bN09BQUFurdGV6tSp4+4STIMHDzbDg7+/v7p166b69etLkv788099//337izvhsvKylJGRoZLwlz28qZixYpOhd5bTeHChV3Sp202m1q2bKnp06dr1apV5p4wRy1ZskT/+te/zPsNGzZUx44dFRISosOHD2vp0qU6duyY03Xf7ty1vPrkk0/M8BATE6O2bdvK29tbiYmJ+u2337Rnzx67+XNuAMMdxgDyYdy4cUalSpWMSpUqGffdd59x5MiRXPOcOHHCOH36tHn/4sWLxqeffmo8+uijRo0aNYzq1asbsbGxxqeffmpcvHjR7rnNmjUz209MTDSeffZZo0aNGkbTpk2Nr7/+2jAMw1i3bp3RoUMHo2rVqsZDDz1k/Oc//7Fr48MPPzTbmDVrljFhwgSjQYMGRq1atYxBgwYZycnJxunTp40hQ4YYtWvXNurWrWu89tprxoULF8w21q1bZ7YxdOhQu/azpzdr1sycNnv2bHP6hx9+aMybN8945JFHjKioqDxrHDp0qDn/unXrjEOHDpn38/pbuXKlUaNGDfN1s7KyzLYuXbpkREdHG5UqVTLq1atnpKenX/Xzy/l/5azfMAzj77//Nu677z7z8ffff9/u8ZMnTxqjRo0yHnzwQSMqKsqoU6eO8cwzzxibN2/O833I6y9bVlaW8f333xtdunQxatWqZVSrVs2IjY01pk6damRmZuaqOyEhwRg6dKjRtGlTIyoqyoiOjja6d+9urF279rrv3bp16676uRmG4/3z2LFjxpAhQ4w6deoYNWvWNF588UW7fn81q1evtqtv1apVuea5ePGiceDAAcMwDLv/r1u3buY8V/ahvD6DDz/80Jx+6NAh46WXXjIaNGhgREZGGvfdd58RExNjDBs2zNixY4fde5TX3+zZs822duzYYQwaNMho0KCBERUVZTRs2NB49dVXjb///tvu/8j5Xfzuu++MiRMnGk2bNjUqV65s1jxjxgyjffv2Rs2aNc22nn76aSMuLu6672VSUpLZ/qhRo3I9nrP+Q4cOXbOtlJQU47333jNatWplVKtWzahZs6bRqVMnY8aMGXbft6t9HoZh3z8MwzC2bdtm3u/Xr5/dvEeOHDEiIiKMSpUqGR07djSnDRs2zIiNjTXq1atnREZGGnXr1jW6d+9uLF++3O75V1tG5ewXq1evNj744AOjUaNGRtWqVY0uXbqYn3VOy5cvN58zd+7ca/5P15KRkWE0bdrUnH/AgAF27122hIQEu/t//fWXMWzYMKNx48ZGVFSUUa9ePaNPnz7G2rVrr/k/L1q0yGjVqpVRvXp14/HHHzd27txpZGZmGhMmTDAaNmxoVK9e3ejdu7eRmJh41f/J0d+Z/H7/unXrZk7fsWOH8eabbxr333+/Ua1atTxrvHJ5lfO7dOVfs2bNjFmzZpn3x48fb9dWzs/3zTffvOZn2KpVK3PelJSUXI+npqba3c/5f2V/x65V65XLknPnzhkffvih8cgjjxjVqlUzatWqZXTr1s346aefcr32kiVLjK5duxq1a9c2oqKijAceeMDo2rWr8c477+TZz3BjsQcC+bJo0SLzdo8ePVSyZMlc8xQrVsy8nZ6erl69eunXX3+1m2fXrl3atWuXfv75Z33xxRd5jpfo0aOHDh48KElKS0vTm2++qSNHjmjKlCnKyMiQJP31118aPHiwKleunOfWx7i4OLMNSfrPf/6js2fPKjk5WVu3bjWnz5w5UyEhIRo0aJDVt+Kq5s+fb3d41/VqtKJgwYJq1aqV5s6dq6SkJP3222/mFqrNmzebh3A8/PDD8vX1deg17r77bvXu3VsffPCBpMvv1cCBAyVJhw8f1uOPP64jR46Y82dkZCg+Pl5r167V+PHj1aJFC8uvNWzYMM2bN89u2q5duzRq1Cj9/vvvev/9983pq1evVv/+/XXhwgVz2unTp7V+/XrVrVtX99xzT/7/2f9xpn8+/vjjdp/z4sWL5ePjo7Fjx17zNXN+h+rVq6dmzZrlmsfPz09lypTJ779zVZcuXVLv3r31119/mdNSUlKUkpKivXv3qnbt2qpcubKltuLj49W/f3+lp6eb044dO6bvv/9e8fHxmjFjRp6fySeffJLrsMd58+bp9ddft5t27NgxHTt2TPv27dMzzzxzzVo2bdpk3o6MjLRUf16Sk5PVtWtX7du3z2761q1btXXrVm3YsEHvvfdevtuNiorSvffeq71792rNmjU6d+6cChUqJElaunSpDMOQJD366KOSpL///jvXHtzk5GStX79e69ev19tvv6127dpZfv2RI0faveebN2/Wc889p2XLlsnH5/9+/qOioszbmzZtytdr5PT777+be6e9vLz08ssvy2az5Zrv3nvvNW9v3bpVPXr00Pnz581pZ86c0c8//6zVq1drxIgReuKJJ3K18euvv2revHnme/jbb7+pd+/eatasmWbNmmXOt3r1ag0ZMkQzZszIs2ZX/M7kV//+/e0+l+vVaEXr1q01atQopaamauHChXrhhRfMx1atWmXebtOmzTXbKViwoHl71KhRevzxx1WlShWzvwQEBDhc45VSUlL0xBNPaPfu3ea0ixcvasOGDdqwYYNGjBihJ598UtLlQysHDhyorKwsc94TJ07oxIkT2rRpkwYNGmTXp3HjcRYmWHb+/Hm7hd5999133edMnTrVXDkrVaqUxo0bp/fee0+lS5eWdPlHYOrUqXk+18vLSxMnTrQb9BsXF6dq1arpk08+0UMPPSTp8uEQ3333XZ5tJCUl6eWXX9b7779vLhhXr16thIQEvfXWWxo5cqQ578yZM6/7/1hx6NAhderUSZ9++ql5WMq1apQuHwo2ffp0NW7c2Jw2fPhwTZ8+XdOnT1dkZKQ5ZkKSfvjhB/P2ypUrzduPPPKIU7XXrFnTvH3w4EHzR/2NN94ww0O7du30+eefa+TIkQoMDFRGRoZeffVVpaamqkmTJpo+fbpKlChhtpP9P2Qf07tkyRIzPISHh+u9997TJ598Yr72okWLzJXstLQ0DR061AwPderU0fvvv69JkyapZ8+eCggIsPTeXY0z/fPChQt699139frrr5uhbdGiRebxwFezc+dO8/bNOkxh3759Znh44IEH9Pnnn+vTTz/Va6+9psaNG5v1T58+XR06dDCf169fP/N9bNKkidLS0jRs2DClp6fLx8dHgwYN0hdffKE+ffpIujyG44033sizhkOHDik2NlZxcXF6++23VbJkSXPFxsfHR2+88YamTp2qsWPHqlevXpbG/+zd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c6aioqLcUPWdiwvJwZKsrCx5eV0+4m3YsGHatm3bVfcsjB8/Xr/++qvCwsKuOR/gLvnpz9nWr1+vp556Sh988IFiYmJuRpnAdV2vL2dlZal27drq3bu3BgwYYE4fOnSofvvtN61YseKm1wzk5Xp9+ejRo2rWrJleeeUVde/eXdLlDTuPPvqowsLCNGnSJLfUfadiDAQsyf5SX8/Bgwc1ZcoUDR8+/AZXBDjOan/OaeHChSpUqJCaN29+AyoCHHO9vmwYhi5duqTChQvbTS9cuPA19yQDN9v1+vLOnTuVmZmpBg0amNNsNpsaNmyo//73v0pPT7/RJSIHAgRc6t///rfatm2rypUru7sUwGUyMjK0bNkyPfjgg/L393d3OYBl3t7e6tChg77++mtt3bpV58+f19q1azV//nx169bN3eUBlmUHBD8/P7vpfn5+Sk9PV2JiojvKumMxBgIus2rVKm3evFlLlixxdymAS/388886c+aM2rRp4+5SgHx7/fXX9frrr6tz587mtL59+6pnz55urArIn7Jly0qStm7dqrCwMHP677//LklKTk52R1l3LAIEXOLixYsaNWqUBgwYoKJFi7q7HMClfvjhBxUvXpwzfeCWNHbsWP3000966623VK5cOf3++++aOHGigoKC1KdPH3eXB1hSqVIl1alTR2PHjlWpUqVUrlw5zZkzR7/++quky4cz4eYhQMAlvvzyS3l5eemRRx7R2bNnJV0+7CMrK0tnz55VgQIFcu12BG4F58+f148//qjOnTvL29vb3eUA+bJ792598cUXmjRpkjl+p27durp06ZLGjx+vrl27qlChQm6uErBmzJgxGjhwoLp27SpJCg0N1XPPPacJEyaoRIkSbq7uzkKAgEvs27dPBw4cyHMLbd26dTVy5Eg9/vjjbqgMcM7y5ct14cIFxcbGursUIN8SEhIkSVWqVLGbHhkZqfT0dB09epQAgVvGPffco9mzZysxMVEXLlxQeHi4pkyZohIlSig0NNTd5d1RCBBwiWeeeUbt27e3mxYXF6f9+/dr9OjRKleunHsKA5y0cOFClSlTRjVq1HB3KUC+Za9U/fnnnypVqpQ5fdu2bbLZbCpdurS7SgMclj0G4sKFC/r+++/txvfg5iBAwJK0tDTFx8dLkpKSknTu3DlzsHS9evV077336t5777V7zty5c3X06FFFR0ff9HqBa7lef84ex3Pq1Cn98ssveuaZZ9xWK3At1+vLVatWVdWqVfX666/r5MmTKlOmjLZu3aq4uDh17NhRAQEB7iwfMFlZLn/99dcqVKiQSpUqpaSkJE2ZMkX+/v4so92AC8nBksTERLVo0SLPx6ZNm5ZnSLB6gS7gZrPan6dPn64333xTixYtyhWQAU9gpS8fP35c48eP19q1a3Xy5EndfffdatOmjZ555hkVKFDgJlcM5M1KX/7iiy/0zTff6MiRIwoODtZDDz2kF198UUWKFLnJ1YIAAQAAAMAyLiQHAAAAwDICBAAAAADLCBAAAAAALCNAAAAAALCMAAEAAADAMgIEAAAAAMsIEAAAAAAsI0AAAAAAsIwAAQC4Ja1fv14RERFav369u0sBgDuKj7sLAADkdvDgQX3++edas2aNjh07Jl9fX1WqVEkxMTHq0qWLChQo4O4SPdIPP/ygkydPqkePHi5tNyIiQk8++aRGjBghSUpMTFSLFi3Mx318fFSoUCGFh4erXr166tq1q0qXLu3SGgDAUxAgAMDD/PTTT3rxxRfl5+entm3bqlKlSsrIyNBvv/2md999VwkJCfrXv/7l7jLdrm7dutq6dat8fX3NaQsXLtSePXtcHiCupk2bNmrcuLEMw1BycrL++OMPffnll5o2bZr+/e9/65FHHrkpdQDAzUSAAAAPcujQIQ0aNEilS5fWl19+qbvuust87Mknn9SBAwf0008/ua9AD+Ll5SV/f3+31hAZGam2bdvaTUtKSlKvXr00dOhQ3XvvvapcubKbqgOAG4MxEADgQT7//HOlpqbq3//+t114yFa2bFk9/fTT5v1Lly5p4sSJatmypapWrarmzZvrvffeU3p6ut3zmjdvrr59+2r9+vXq0KGDqlevrtjYWHP8wLJlyxQbG6tq1aqpQ4cO2r59u93zhw0bplq1aunw4cPq27evatWqpUaNGmn69OmSpF27dumpp55SzZo11axZM/3www92z58wYYIiIiJy/T9z5sxRRESEEhMTc9W6ceNGderUSdWqVVOLFi00b948u+deOQaie/fu+umnn5SUlKSIiAhFRESoefPmOn/+vGrWrKm33nor1+sfOXJEVapU0aeffprrMUeFhoZqzJgxysjI0GeffeaydgHAUxAgAMCD/Pjjj7rnnntUu3ZtS/MPHz5c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",
            "text/plain": [
              "<Figure size 800x500 with 1 Axes>"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# ommunity size distribution (Q5)\n",
        "import matplotlib.pyplot as plt\n",
        "import seaborn as sns\n",
        "\n",
        "sns.set(style=\"whitegrid\")\n",
        "\n",
        "df_comm_sizes = run_query(\"\"\"\n",
        "MATCH (u:User)\n",
        "WHERE u.communityId IS NOT NULL\n",
        "RETURN u.communityId AS communityId, count(u) AS size\n",
        "ORDER BY size DESC\n",
        "\"\"\")\n",
        "\n",
        "display(df_comm_sizes)\n",
        "\n",
        "plt.figure(figsize=(8, 5))\n",
        "ax = sns.barplot(data=df_comm_sizes, x=\"communityId\", y=\"size\", palette=\"viridis\")\n",
        "ax.set_title(\"Community Detection Clusters (Louvain): Community Sizes\", fontsize=12, weight=\"bold\")\n",
        "ax.set_xlabel(\"Community ID\")\n",
        "ax.set_ylabel(\"Number of Users\")\n",
        "for p in ax.patches:\n",
        "    ax.annotate(f\"{int(p.get_height())}\",\n",
        "                (p.get_x() + p.get_width()/2, p.get_height()),\n",
        "                ha=\"center\", va=\"bottom\", fontsize=10, xytext=(0, 3), textcoords=\"offset points\")\n",
        "plt.tight_layout()\n",
        "plt.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "5c853903",
      "metadata": {},
      "source": [
        "### Q5 (GDS): Community detection + hold-out evaluation\n",
        "\n",
        "## 1) Community detection on the user–user similarity graph\n",
        "Using the Louvain algorithm on the in-memory user–user graph:\n",
        "\n",
        "- `graphName = user_user_similar_taste`\n",
        "- `nodeCount = 20`, `relationshipCount = 200`\n",
        "- Louvain result: `communityCount = 4`, `modularity = 0.63307`\n",
        "\n",
        "**Interpretation:** A modularity of ~0.63 suggests the `SIMILAR_TASTE` edges capture a **meaningful clustering** of users with shared movie-rating overlap patterns (i.e., there are groups of users who tend to co-rate similar sets of movies).\n",
        "\n",
        "## 2) Characterizing the 3 largest communities\n",
        "\n",
        "### Community 19 (size = 7)\n",
        "- `avgAge = 34.29`\n",
        "- Dominant genres (highest avg user ratings within the community):\n",
        "  - **Crime**: avgRating = 5.00 (numRatings = 4)\n",
        "  - **Drama**: avgRating = 4.75 (numRatings = 8)\n",
        "  - **Action**: avgRating = 4.67 (numRatings = 18)\n",
        "- Occupations (top listed):\n",
        "  - Analyst (1), Consultant (1), Developer (1), Engineer (1)\n",
        "\n",
        "**Interpretation:** This community appears **genre-polarized toward Crime/Drama/Action**, with especially strong Crime ratings (5.0), indicating that these users align not only in *what* movies they watch, but also in *how highly* they rate films in these genres.\n",
        "\n",
        "---\n",
        "\n",
        "### Community 14 (size = 5)\n",
        "- `avgAge = 27.8`\n",
        "- Dominant genres:\n",
        "  - **Music**: avgRating = 5.00 (numRatings = 1)\n",
        "  - **Biography**: avgRating = 4.75 (numRatings = 4)\n",
        "  - **Romance**: avgRating = 4.63 (numRatings = 8)\n",
        "- Occupations (top listed):\n",
        "  - Student (2), Chef (1), Designer (1), Writer (1)\n",
        "\n",
        "**Interpretation:** This community shows a clearer **younger-skew + romance/biography/music preference** pattern. Note that Music is based on only 1 rating (small-sample effect), so it’s a strong signal but with limited coverage.\n",
        "\n",
        "---\n",
        "\n",
        "### Community 17 (size = 5)\n",
        "- `avgAge = 46.6`\n",
        "- Dominant genres:\n",
        "  - **Crime**: avgRating = 4.53 (numRatings = 15)\n",
        "  - **Drama**: avgRating = 4.24 (numRatings = 25)\n",
        "  - **Adventure**: avgRating = 4.00 (numRatings = 3)\n",
        "- Occupations (top listed):\n",
        "  - Accountant (1), Architect (1), Doctor (1), Retired (1)\n",
        "\n",
        "**Interpretation:** This group is older on average and is most aligned with **Crime + Drama**, with Adventure weaker and based on fewer ratings (3).\n",
        "\n",
        "---\n",
        "\n",
        "## 3) Hold-out evaluation (precision@K)\n",
        "For the hold-out test, the system hid two movies from the target user’s known ratings:\n",
        "\n",
        "- `heldOutTitles = [Blade Runner 2049, Dune]`\n",
        "\n",
        "The recommender then produced:\n",
        "\n",
        "- `recommendedTopK = [Dune, Blade Runner 2049, Mad Max: Fury Road, Her]`\n",
        "- `hits = 2`\n",
        "- `precisionAtK = 0.4`\n",
        "\n",
        "Given `precisionAtK = hits / K`, this implies `K = 5`.\n",
        "\n",
        "**Result interpretation:** The recommender successfully recovers **both** hidden movies within the top-5, which is strong evidence that the similarity-driven collaborative filtering is capturing the target user’s taste neighborhood effectively on this small example.\n",
        "\n",
        "---\n",
        "\n",
        "## 4) Limitations (and what production evaluation would do)\n",
        "- This is a **single-user**, **single split** hold-out with only **two** hidden items, so the `precision@K` value is not statistically robust.\n",
        "- The dataset is small, so results can be sensitive to which ratings are held out and to sparsity.\n",
        "- Community detection + recommendation quality may also be influenced by popularity/co-rating overlap patterns, not only true taste similarity.\n",
        "\n",
        "**Production-style evaluation:** run **cross-validation / k-fold** across many users, hold out multiple ratings per user, and report aggregate metrics such as `precision@K`, `recall@K`, and optionally `NDCG`, compared against baseline recommenders (e.g., popularity-based or pure content-based)."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "34b429e5",
      "metadata": {},
      "source": [
        "### Extension 1: Similarity cutoff sensitivity\n",
        "\n",
        "We re-ran `gds.nodeSimilarity.filtered.write` with `similarityCutoff` values of **0.1**, **0.3**, and **0.5**, then regenerated recommendations for the same user (`U001`).\n",
        "\n",
        "#### Results\n",
        "- **Cutoff 0.1** → `relationshipsWritten = 100`, `recommendationsReturned = 2`\n",
        "- **Cutoff 0.3** → `relationshipsWritten = 61`, `recommendationsReturned = 2`\n",
        "- **Cutoff 0.5** → `relationshipsWritten = 47`, `recommendationsReturned = 2`\n",
        "\n",
        "Top recommendations were identical at all cutoffs:\n",
        "1. `Mad Max: Fury Road` (supporters=3, avgRating=4.40)  \n",
        "2. `Her` (supporters=3, avgRating=4.33)\n",
        "\n",
        "#### Interpretation\n",
        "As expected, increasing the cutoff makes the similarity graph sparser (100 → 61 → 47 edges). However, for this target user, recommendation output stayed unchanged (still 2 items with the same ranking and scores). This indicates the two returned items are supported by **strong, stable neighbor ties** that survive even stricter similarity thresholds.\n",
        "\n",
        "#### Implication for sparsity and quality\n",
        "- A higher cutoff reduces noisy/weak similarity edges globally, which can improve signal quality.\n",
        "- In this case, coverage for `U001` did not degrade yet, so the graph was **not too sparse** for this user even at 0.5.\n",
        "- On a larger or sparser dataset (or for a less-connected user), we would expect higher cutoffs to eventually reduce recommendation count and potentially hurt coverage."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 37,
      "id": "e7d287fa",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "Cutoff=0.1 | relationshipsWritten=100 | recommendationsReturned=2\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>3</td>\n",
              "      <td>4.40</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Her</td>\n",
              "      <td>3</td>\n",
              "      <td>4.33</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                title  supporters  avgRating\n",
              "0  Mad Max: Fury Road           3       4.40\n",
              "1                 Her           3       4.33"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "Cutoff=0.3 | relationshipsWritten=61 | recommendationsReturned=2\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>3</td>\n",
              "      <td>4.40</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Her</td>\n",
              "      <td>3</td>\n",
              "      <td>4.33</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                title  supporters  avgRating\n",
              "0  Mad Max: Fury Road           3       4.40\n",
              "1                 Her           3       4.33"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "\n",
            "Cutoff=0.5 | relationshipsWritten=47 | recommendationsReturned=2\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>3</td>\n",
              "      <td>4.40</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>Her</td>\n",
              "      <td>3</td>\n",
              "      <td>4.33</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                title  supporters  avgRating\n",
              "0  Mad Max: Fury Road           3       4.40\n",
              "1                 Her           3       4.33"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>similarityCutoff</th>\n",
              "      <th>relationshipsWritten</th>\n",
              "      <th>recommendationsReturned</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>0.1</td>\n",
              "      <td>100</td>\n",
              "      <td>2</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>0.3</td>\n",
              "      <td>61</td>\n",
              "      <td>2</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>0.5</td>\n",
              "      <td>47</td>\n",
              "      <td>2</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "   similarityCutoff  relationshipsWritten  recommendationsReturned\n",
              "0               0.1                   100                        2\n",
              "1               0.3                    61                        2\n",
              "2               0.5                    47                        2"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Extension 1: similarityCutoff sensitivity for Node Similarity\n",
        "cutoffs = [0.1, 0.3, 0.5]\n",
        "target_user_id = \"U001\"\n",
        "\n",
        "results = []\n",
        "\n",
        "for c in cutoffs:\n",
        "    # Remove previous SIMILAR_TASTE edges\n",
        "    run_query(\"\"\"\n",
        "    MATCH ()-[s:SIMILAR_TASTE]->()\n",
        "    DELETE s\n",
        "    \"\"\")\n",
        "\n",
        "    # Re-write SIMILAR_TASTE with this cutoff\n",
        "    df_write = run_query(f\"\"\"\n",
        "    CALL gds.nodeSimilarity.filtered.write('user_movie_unweighted', {{\n",
        "      sourceNodeFilter: 'User',\n",
        "      targetNodeFilter: 'User',\n",
        "      similarityCutoff: {c},\n",
        "      topK: 5,\n",
        "      writeRelationshipType: 'SIMILAR_TASTE',\n",
        "      writeProperty: 'score'\n",
        "    }})\n",
        "    YIELD relationshipsWritten, nodesCompared, computeMillis\n",
        "    RETURN relationshipsWritten, nodesCompared, computeMillis\n",
        "    \"\"\")\n",
        "    rels = int(df_write.iloc[0][\"relationshipsWritten\"])\n",
        "\n",
        "    # Regenerate recommendations for the same user\n",
        "    df_rec = cf_recommendations(target_user_id, rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4)\n",
        "    n_recs = len(df_rec)\n",
        "\n",
        "    results.append({\n",
        "        \"similarityCutoff\": c,\n",
        "        \"relationshipsWritten\": rels,\n",
        "        \"recommendationsReturned\": n_recs\n",
        "    })\n",
        "\n",
        "    print(f\"\\nCutoff={c} | relationshipsWritten={rels} | recommendationsReturned={n_recs}\")\n",
        "    display(df_rec)\n",
        "\n",
        "import pandas as pd\n",
        "display(pd.DataFrame(results).sort_values(\"similarityCutoff\"))"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "a800e165",
      "metadata": {},
      "source": [
        "### Extension 2: Cold-start problem\n",
        "\n",
        "We created a new user (`U021`) with only one rating (5 stars on `Inception`) and tested three approaches:\n",
        "\n",
        "1. **Collaborative filtering query**\n",
        "2. **Hybrid query (collaborative + content boost)**\n",
        "3. **Cold-start fallback strategy** (content + popularity from the seed movie)\n",
        "\n",
        "#### Observed results\n",
        "- **Collaborative for U021:** no recommendations returned  \n",
        "- **Hybrid for U021:** no recommendations returned  \n",
        "- **Cold-start fallback:** returned a full top-10 list\n",
        "\n",
        "#### Interpretation\n",
        "This is a classic **cold-start failure** for collaborative methods: with only one interaction, `U021` has insufficient user-user similarity evidence, so both collaborative and hybrid pipelines return empty outputs. The hybrid approach still depends on collaborative candidates, so it degrades similarly here.\n",
        "\n",
        "The fallback strategy works because it does not require pre-existing neighborhood strength. It recommends movies that share content features (genre/director path overlap) with the single liked seed movie and then ranks by broad quality/engagement signals (`avgRating`, `ratingCount`).\n",
        "\n",
        "#### Which approach degrades more gracefully?\n",
        "In this setup, the **fallback strategy** clearly degrades most gracefully and provides practical recommendations, while collaborative and hybrid both collapse to no output.\n",
        "\n",
        "#### Concrete cold-start strategy used\n",
        "For new users with very few ratings, use a **content/popularity fallback**:\n",
        "- seed from the user’s rated movie(s),\n",
        "- traverse shared `IN_GENRE` / `DIRECTED_BY` paths to candidate movies,\n",
        "- rank by feature overlap + average rating + rating count,\n",
        "- optionally switch to collaborative/hybrid once the user has enough interactions."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 38,
      "id": "d81a6bb7",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Collaborative for U021:\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "Empty DataFrame\n",
              "Columns: []\n",
              "Index: []"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Hybrid for U021:\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "Empty DataFrame\n",
              "Columns: []\n",
              "Index: []"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Cold-start fallback recommendations:\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>overlapFeatures</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>ratingCount</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Interstellar</td>\n",
              "      <td>2</td>\n",
              "      <td>4.75</td>\n",
              "      <td>8</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>The Dark Knight</td>\n",
              "      <td>1</td>\n",
              "      <td>5.00</td>\n",
              "      <td>4</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Blade Runner 2049</td>\n",
              "      <td>1</td>\n",
              "      <td>4.75</td>\n",
              "      <td>4</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>3</th>\n",
              "      <td>The Matrix</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "      <td>6</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>4</th>\n",
              "      <td>Oldboy</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>5</th>\n",
              "      <td>Parasite</td>\n",
              "      <td>1</td>\n",
              "      <td>4.67</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>6</th>\n",
              "      <td>Dune</td>\n",
              "      <td>1</td>\n",
              "      <td>4.60</td>\n",
              "      <td>5</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>7</th>\n",
              "      <td>Mad Max: Fury Road</td>\n",
              "      <td>1</td>\n",
              "      <td>4.33</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>8</th>\n",
              "      <td>Everything Everywhere All at Once</td>\n",
              "      <td>1</td>\n",
              "      <td>4.33</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>9</th>\n",
              "      <td>Her</td>\n",
              "      <td>1</td>\n",
              "      <td>4.33</td>\n",
              "      <td>3</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "                               title  overlapFeatures  avgRating  ratingCount\n",
              "0                       Interstellar                2       4.75            8\n",
              "1                    The Dark Knight                1       5.00            4\n",
              "2                  Blade Runner 2049                1       4.75            4\n",
              "3                         The Matrix                1       4.67            6\n",
              "4                             Oldboy                1       4.67            3\n",
              "5                           Parasite                1       4.67            3\n",
              "6                               Dune                1       4.60            5\n",
              "7                 Mad Max: Fury Road                1       4.33            3\n",
              "8  Everything Everywhere All at Once                1       4.33            3\n",
              "9                                Her                1       4.33            3"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Extension 2: Cold-start user\n",
        "run_query(\"\"\"\n",
        "MERGE (u:User {userId: 'U021'})\n",
        "SET u.name = 'New User', u.age = 25, u.gender = 'N/A', u.occupation = 'Unknown'\n",
        "\"\"\")\n",
        "\n",
        "# One rating only\n",
        "run_query(\"\"\"\n",
        "MATCH (u:User {userId:'U021'}), (m:Movie {title:'Inception'})\n",
        "MERGE (u)-[r:RATED]->(m)\n",
        "SET r.rating = 5.0, r.timestamp = datetime()\n",
        "\"\"\")\n",
        "\n",
        "# Try collaborative (likely sparse/empty)\n",
        "df_cf = cf_recommendations(\"U021\", rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4)\n",
        "print(\"Collaborative for U021:\")\n",
        "display(df_cf)\n",
        "\n",
        "# Try hybrid\n",
        "df_hybrid = hybrid_recommendations(\"U021\", rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4)\n",
        "print(\"Hybrid for U021:\")\n",
        "display(df_hybrid)\n",
        "\n",
        "# Fallback strategy: content-based using single liked movie's genre/director + popularity\n",
        "df_fallback = run_query(\"\"\"\n",
        "MATCH (u:User {userId:'U021'})-[r:RATED]->(seed:Movie)\n",
        "WHERE r.rating >= 4\n",
        "MATCH (seed)-[:IN_GENRE|DIRECTED_BY]->(feat)<-[:IN_GENRE|DIRECTED_BY]-(cand:Movie)\n",
        "WHERE cand <> seed\n",
        "  AND NOT EXISTS { (u)-[:RATED]->(cand) }\n",
        "OPTIONAL MATCH (:User)-[rr:RATED]->(cand)\n",
        "WITH cand, count(DISTINCT feat) AS overlapFeatures, avg(rr.rating) AS avgRating, count(rr) AS ratingCount\n",
        "RETURN cand.title AS title,\n",
        "       overlapFeatures,\n",
        "       round(coalesce(avgRating, 0), 2) AS avgRating,\n",
        "       ratingCount\n",
        "ORDER BY overlapFeatures DESC, avgRating DESC, ratingCount DESC\n",
        "LIMIT 10\n",
        "\"\"\")\n",
        "print(\"Cold-start fallback recommendations:\")\n",
        "display(df_fallback)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "ae9dcd04",
      "metadata": {},
      "source": [
        "### Extension 3: Algorithm comparison (Top-5 for one user)\n",
        "\n",
        "We compared three recommendation approaches for the same user:\n",
        "\n",
        "1. **Jaccard-based collaborative filtering**\n",
        "2. **kNN/cosine-based collaborative filtering**\n",
        "3. **Hybrid (collaborative + genre/director boost)**\n",
        "\n",
        "#### Results (Top-5 request)\n",
        "- **Jaccard CF:** `La La Land` (supporters=3, avgRating=4.67)\n",
        "- **kNN/cosine CF:** `La La Land` (supporters=2, avgRating=4.33)\n",
        "- **Hybrid:** `La La Land` (supporters=3, avgRating=4.67, genreOverlapCount=2, directorOverlapCount=1, finalScore=9.667)\n",
        "\n",
        "#### Where they agree\n",
        "All three methods return the same top recommendation: **`La La Land`**.  \n",
        "This indicates a robust signal that is consistent across:\n",
        "- overlap-based similarity (Jaccard),\n",
        "- weighted embedding similarity (kNN/cosine),\n",
        "- and content-enriched ranking (hybrid).\n",
        "\n",
        "#### Where they diverge\n",
        "The main divergence is in **confidence/strength**, not item identity:\n",
        "- Jaccard gives stronger collaborative support (`supporters=3`) than kNN (`supporters=2`).\n",
        "- Hybrid further boosts the same item via content alignment (`genreOverlapCount=2`, `directorOverlapCount=1`), producing the highest final ranking score.\n",
        "\n",
        "#### Which approach is best for deployment (for this dataset)?\n",
        "The **hybrid approach** is the best production choice here because it preserves collaborative evidence while adding interpretable content signals (genre/director), improving robustness when pure collaborative coverage is limited.\n",
        "\n",
        "#### Why only one title appears (even though we asked for Top-5)\n",
        "\n",
        "We requested **up to 5** recommendations, but the query only returns **movies that satisfy all of the collaborative filters**:\n",
        "\n",
        "1. the target user has **at least one similar user** in the chosen similarity graph (`SIMILAR_TASTE` or `KNN_SIMILAR`);\n",
        "2. those similar users must have rated the candidate movie **≥ 4**;\n",
        "3. the target user must **not** already have a `RATED` relationship to that movie.\n",
        "\n",
        "On this dataset (**25 movies**, **~5 ratings per user**, highly **sparse** interactions), the set of movies that pass (2) and (3) is often **very small**. After ranking by `supporters` / `avgRating` (and hybrid scoring), **if only one movie qualifies**, the dataframe will contain **one row** even when `top_k = 5`.\n",
        "\n",
        "So this is not a bug in “Top‑5 logic”; it reflects **coverage limits** in a tiny bipartite rating graph: there are not enough unseen, high‑rated, collaboratively supported candidates to fill five slots. "
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 39,
      "id": "378b5fc5",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Jaccard CF (Top 5)\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>3</td>\n",
              "      <td>4.67</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating\n",
              "0  La La Land           3       4.67"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "kNN/cosine CF (Top 5)\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>2</td>\n",
              "      <td>4.33</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating\n",
              "0  La La Land           2       4.33"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Hybrid (Top 5)\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>genreOverlapCount</th>\n",
              "      <th>directorOverlapCount</th>\n",
              "      <th>finalScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>3</td>\n",
              "      <td>4.67</td>\n",
              "      <td>2</td>\n",
              "      <td>1</td>\n",
              "      <td>9.667</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating  genreOverlapCount  directorOverlapCount  \\\n",
              "0  La La Land           3       4.67                  2                     1   \n",
              "\n",
              "   finalScore  \n",
              "0       9.667  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Extension 3: Compare 3 approaches for one user\n",
        "uid = \"U003\"\n",
        "\n",
        "df_j = cf_recommendations(uid, rel_type=\"SIMILAR_TASTE\", top_k=5, min_rating=4)\n",
        "df_k = cf_recommendations(uid, rel_type=\"KNN_SIMILAR\", top_k=5, min_rating=4)\n",
        "df_h = hybrid_recommendations(uid, rel_type=\"SIMILAR_TASTE\", top_k=5, min_rating=4)\n",
        "\n",
        "print(\"Jaccard CF (Top 5)\")\n",
        "display(df_j)\n",
        "\n",
        "print(\"kNN/cosine CF (Top 5)\")\n",
        "display(df_k)\n",
        "\n",
        "print(\"Hybrid (Top 5)\")\n",
        "display(df_h)"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "id": "01abc6fb",
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>method</th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>score</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>Jaccard CF</td>\n",
              "      <td>La La Land</td>\n",
              "      <td>3</td>\n",
              "      <td>4.670</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>1</th>\n",
              "      <td>kNN/Cosine CF</td>\n",
              "      <td>La La Land</td>\n",
              "      <td>2</td>\n",
              "      <td>4.330</td>\n",
              "    </tr>\n",
              "    <tr>\n",
              "      <th>2</th>\n",
              "      <td>Hybrid</td>\n",
              "      <td>La La Land</td>\n",
              "      <td>3</td>\n",
              "      <td>9.667</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "          method       title  supporters  score\n",
              "0     Jaccard CF  La La Land           3  4.670\n",
              "1  kNN/Cosine CF  La La Land           2  4.330\n",
              "2         Hybrid  La La Land           3  9.667"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "image/png": "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iMuXLwszMpmamsLe3h5t2rRRSHBERERERKRc5Eog4uLiMG7cONy6dQuqqqooX748AOD69es4cOAArK2t4enpCT09PYUGS0RERERExUuuLkxLlizB7du3MW3aNAQFBeHixYu4ePEigoKCMHXqVNy+fRtLlixRdKxERERERFTM5GqBOH/+PAYOHIgRI0ZIbdfW1sbIkSPx7t07HDt2TBHxERERERGREpGrBUJNTQ3m5ub57q9WrRrU1Ao1vIKIiIiIiJSQXAmEg4MDzpw5g8zMzFz7MjIycPr0aXTq1KnQwRERERERkXKRqZng8ePHUrd79OiBhQsXon///vjtt99QpUoVAMCbN29w4MABpKeno3v37oqPloiIiIiIipVMCUSfPn1yrSwtFosBAA8fPhT25WwDACcnJwQHBysqTiIiIiIiUgIyJRDu7u5FHQcREREREZUAMiUQjo6ORR0HERERERGVAHINot68eTMiIyMVHQsRERERESk5uRKIdevWoU2bNhgyZAgOHz6MhIQERcdFRERERERKSK4E4uLFi5gyZQpiY2MxZ84c2NnZYfLkybh06VKeU7sSEREREdGPQa7V3oyMjDBy5EiMHDkSISEh8PX1xcmTJ3H69GmUKVMGXbp0QY8ePdCoUSNFx0tERERERMVIrhYISbVq1cLUqVPh5+eHPXv2wNraGnv37kX//v3h4OAALy8vfPr0SRGxEhERERFRMSt0AgEAqampOHnyJLZu3YqLFy9CVVUVrVq1Qs2aNeHl5YX27dvj3LlzingoIiIiIiIqRnJ1YQKyF43z9/eHr68vzp8/j8TERNStWxfTpk1D9+7dUbZsWQDAhw8fMHXqVCxbtgwdOnRQWOBERERERPT9yZVALF26FKdOncKnT59Qvnx59O/fH7169ULNmjVzla1QoQJ+/fVXzJgxo9DBEhERERFR8ZIrgTh06BDat2+PXr16oUWLFhCJRF8t36RJE65mTURERET0A5ArgfD394e2trbM5c3MzGBmZibPQxERERERkRKRaxB1QZIHIiIiIiL6ccjcArF48eICVz537twC34eIiIiIiJSXzAnEnj17cm0TiUQQi8V5lheJREwgiIiIiIh+MDInEE+fPpW6/fnzZ7Ro0QI7duxA8+bNFR4YEREREREpH7kXkvvWzEtERERERPTjUchK1ERERERE9HNgAkFERERERDJjAkFERERERDIrdALBsRBERERERD8PmWdhsrKyyjNZGDNmDFRUcuchIpEIt2/fLlx0RERERESkVGROIBwcHNjaQERERET0k5M5gVi2bFlRxkFERERERCUAB1ETEREREZHMmEAQEREREZHMmEAQEREREZHMmEAQEREREZHMmEAQEREREZHMCpxApKamYvfu3bh582ZRxENEREREREqswAmEpqYmVq1ahVevXhVFPEREREREpMTk6sJUs2ZNhIeHKzoWIiIiIiJScnIlEJMnT8b+/fsREBCg6HiIiIiIiEiJybwStaQ9e/bAwMAAI0aMgJmZGczMzKCpqSlVRiQSYcOGDQoJkoiIiIiIlINcCURISAgAoGLFisjMzMSbN29ylRGJRIWLjIiIiIiIlI5cCYSfn5+i4yAiIiIiohKgRK4DceHCBfTt2xdWVlaws7PDxIkTERYWVtxhERERERH98ORqgcgRFBSES5cuISIiAgBgYmKC1q1bo1mzZgoJLi+BgYFwdXVFr169MHnyZMTExMDDwwPDhw+Hr68vSpUqVWSPTURERET0s5MrgUhLS8PUqVNx/vx5iMVi6OnpAQDi4uKwY8cOdOjQAatXr4a6urpCgwWAkydPwsTEBEuXLhXGWRgaGmLo0KF49OgRrK2tFf6YRERERESUTa4uTJ6enjh37hyGDRuGa9euISgoCEFBQfD398fw4cNx9uxZeHp6KjpWAEBGRgZKly4tNUhbV1cXACAWi4vkMYmIiIiIKJtcCYSvry8cHR0xffp0lCtXTthetmxZ/PHHH+jVqxeOHz+usCAl9e7dG6GhofDx8UF8fDzCwsKwZs0a1K1bF40bNy6SxyQiIiIiomxydWGKiopCw4YN893fsGFDnDx5Uu6gvsba2hrr16/H1KlTsXDhQgBAnTp1sHXrVqiqqspdr1gsRlJSkqLCJCIioiImEomgpaVV3GEQfVfJyclF0utGLBbLvAyDXAmEsbExgoKCMGDAgDz337x5E8bGxvJU/U137tzB9OnT8dtvv6F169aIiYmBl5cXRo0ahb1798o9iDo9PR3BwcEKjpaIiIiKipaWFurWrVvcYRB9V69evUJycnKR1K2hoSFTObkSiF69euHvv/+Grq4unJ2dUaVKFYhEIrx+/Rq7du3CmTNnMH78eHmq/qbFixfD1tYWM2fOFLZZWlqidevW+Oeff9CvXz+56lVXV0eNGjUUFSYREREVMS5aSz8jc3PzImmBePHihcxl5UogxowZg7CwMBw8eBCHDh2Cikr2UIqsrCyIxWI4OjpizJgx8lT9TaGhoWjXrp3UNmNjY5QpUwb//fef3PWKRCJoa2sXNjwiIiIioiJTVN32CpKQy5VAqKqqYtmyZXB2dsaVK1cQHh4OADA1NUWrVq1Qu3ZteaqViYmJCZ48eSK1LTw8HNHR0TA1NS2yxyUiIiIiIhkTCEdHR0yePBmtWrUCABw7dgzW1taoXbt2kSYLeenfvz+WLl2KxYsXo23btoiJicGGDRtQtmxZdO7c+bvGQkRERET0s5EpgXj27Bmio6OF27NmzcKKFStgZmZWZIHlZ8iQIdDQ0MC+fftw+PBhlC5dGpaWlli3bh3KlCnz3eMhIiIiIvqZyJRAmJiY4Pr16+jWrRtUVVULNM2ToolEIgwYMCDfGaCIiIiIiKjoyJRA9O/fH6tWrYKvry80NTUhEokwZ84cuLm55XsfkUiE27dvKyxQIiIiIiIqfjIlECNHjkTt2rURGBiIjx8/4tixY2jQoAEqVapU1PEREREREZESkXkWJjs7O9jZ2QEAjh49in79+qF79+5FFhgRERERESkfuaZxffr0qaLjICIiIiKiEkCluAMgIiIiIqKSgwkEERERERHJjAkEERERERHJjAkEERERERHJjAkEERERERHJTK5ZmHJERkbi5s2b+PTpExwcHGBsbIzMzEzEx8dDV1cXqqqqioqTiIiIiIiUgFwJhFgsxrJly+Dj44OMjAyIRCLUqlULxsbGSEpKQtu2bTFhwgQ4OzsrOFwiIiIiIipOcnVh2rp1K3bv3o3hw4djx44dEIvFwj5dXV107NgRZ8+eVViQRERERESkHORKIA4dOoRevXphypQpqF27dq79FhYWeP36dWFjIyIiIiIiJSNXAvHu3TtYWVnlu19LSwsJCQlyB0VERERERMpJrgSibNmyePfuXb77Hz9+jIoVK8odFBERERERKSe5EogOHTpg//79CAsLE7aJRCIAwLVr13D06FF06tRJMRESEREREZHSkGsWpgkTJiAwMBA9e/aEtbU1RCIRtmzZAg8PD9y7dw916tTBmDFjFB0rEREREREVM7laIHR1dXHw4EGMHDkSkZGR0NTUxM2bNxEfH49x48Zh79690NLSUnSsRERERERUzArcApGamooDBw6gTp06cHFxgYuLS1HERURERERESqjALRCamppYtWoVXr16VRTxEBERERGREpOrC1PNmjURHh6u6FiIiIiIiEjJyZVATJ48Gfv370dAQICi4yEiIiIiIiUm1yxMe/bsgYGBAUaMGAEzMzOYmZlBU1NTqoxIJMKGDRsUEiQRERERESkHuRKIkJAQAEDFihWRmZmJN2/e5CqTsy4EERERERH9OORKIPz8/BQdBxERERERlQByjYEgIiIiIqKfk1wtEDmCgoJw6dIlREREAABMTEzQunVrNGvWTCHBERERERGRcpErgUhLS8PUqVNx/vx5iMVi6OnpAQDi4uKwY8cOdOjQAatXr4a6urpCgyUiIiIiouIlVxcmT09PnDt3DsOGDcO1a9cQFBSEoKAg+Pv7Y/jw4Th79iw8PT0VHSsRERERERUzuRIIX19fODo6Yvr06ShXrpywvWzZsvjjjz/Qq1cvHD9+XGFBEhERERGRcpArgYiKikLDhg3z3d+wYUNERUXJHRQRERERESknuRIIY2NjBAUF5bv/5s2bMDY2ljsoIiIiIiJSTnIlEL169cLp06fh5uaGly9fIjMzE1lZWXj58iX+/PNPnDlzBo6OjoqOlYiIiIiIiplcszCNGTMGYWFhOHjwIA4dOgQVlew8JCsrC2KxGI6OjhgzZoxCAyUiIiIiouInVwKhqqqKZcuWwdnZGVeuXEF4eDgAwNTUFK1atULt2rUVGiQRERERESmHQi0kV7t2bSYLREREREQ/EbnGQDx+/Bg+Pj757vfx8UFwcLDcQRERERERkXKSK4FYu3Ytrl+/nu/+wMBArFu3Tt6YiIiIiIhIScndAmFtbZ3v/iZNmuDRo0dyB0VERERERMpJrgQiMTERqqqq+VeqooL4+Hi5gyIiIiIiIuUkVwJRpUoV+Pv757v/6tWrqFSpktxBERERERGRcpIrgfj1119x6dIluLu7Iy4uTtgeFxeHpUuX4urVq/j1118VFiQRERERESkHuaZxHTJkCJ4+fYpdu3bB29sbFSpUAAB8+PABWVlZ6NmzJ5ydnRUZJxERERERKQG5EgiRSAR3d3f07NkTZ8+eRVhYGACgXbt26NixI2xsbBQaJBERERERKYdCLSRna2sLW1tbRcVCRERERERKrlAJ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XAnEwoUL0apVK7i6ugqtDwBgYGAAV1dX2NnZYdGiRdDV1cX48ePRtWtX/PvvvwoLmoiIiIiIiodcCcT9+/dRu3btfPfXrl0bd+/eFW43adIEHz9+lOehiIiIiIhIiciVQOjq6sLf3z/f/VevXoWOjo5wOykpSeo2ERERERGVTHIlEL/99hsuXLiACRMm4Pr16wgPD0d4eDiuX7+OCRMm4NKlS/jtt9+E8pcvX0adOnUUFjQRERERERUPuWZhcnV1RUpKCnbt2oVz585J7VNVVYWzszNcXV0BAKmpqejduzcsLCwKHy0RERERERUruRIIkUiEP/74A8OHD8f169cREREBADAxMUHz5s1RtmxZoaympiYcHR0VEy0RERERERUruRKIHGXLlkW3bt0UFQsRERERESm5QiUQCQkJiIiIQFxcHMRica79TZs2LUz1RERERESkZORKIKKjo7Fo0SKcPXsWmZmZAACxWAyRSCT1f3BwsOIiJSIiIiKiYidXAjFv3jxcvHgRTk5OsLa2hp6enqLjIiIiIiIiJSRXAuHv74+hQ4di+vTpio6HiIiIiIiUmFzrQJQqVQqmpqaKjoWIiIiIiJScXAlEjx49cP78eUXHQkRERERESk6uLkwODg64efMmRowYgX79+sHY2Biqqqq5ytWrV6/QARIRERERkfKQK4EYOHCg8H9AQECu/ZyFiYiIiIjoxyRXAuHu7q7oOIiIiIiIqASQK4FwdHRUdBxERERERFQCyDWImoiIiIiIfk4ytUDMmjULIpEIixYtgqqqKmbNmvXN+4hEIixdurTQARIRERERkfKQKYEIDAyESCRCVlYWVFVVERgY+M37iESiQgdHRERERETKRaYEws/P76u3iYiIiIjo5yDXGIiIiAikpKTkuz8lJQURERFyB0VERERERMpJrgSiXbt2OHfuXL77/fz80K5dO7mDIiIiIiIi5SRXAiEWi7+6Pz09HSoqnOCJiIiIiOhHI/M6EAkJCYiLixNux8TE5NlNKS4uDqdOnUL58uUVEyERERERESkNmROInTt3wtPTE8D/TdGa3zStYrEYkyZNUkiARERERESkPGROIFq2bAltbW2IxWKsXLkSXbt2Rb169aTKiEQiaGlpoV69emjQoIHCgyUiIiIiouIlcwJhZWUFKysrAEBycjI6duyIWrVqFVlgRERERESk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4QrXRERERETfkVwJhKenJ86cOYMXL15AVVUVtra2GDVqFNq3b6+Q1oCvWbBgAS5evIiZM2ciISEB9+7dE/bVrVtX5r5bRERERERUcHIlEF5eXmjatCmcnJzQoUMHlClTRtFx5cvf3x8AsGzZslz7Lly4ADMzs+8WCxERERHRz0auBOLKlSsoW7asomORiZ+fX7E8LhERERERybmQXHElD0REREREVLzkHkQdFRWF//3vf3jy5Ani4+ORlZUltV8kEmHXrl2FDpCIiIiIiJSHXAnE06dPMWTIEKSkpMDc3BwhISGoUaMG4uLiEBkZicqVK8PY2FjRsRIRERERUTGTqwvT6tWroa2tjTNnzmDHjh0Qi8WYPXs2Ll++jLVr1yI2NhbTpk1TdKxERERERFTM5Eog7ty5g379+sHExERYATpnRbzOnTuje/fuWLFiheKiJCIiIiIipSBXApGVlYVy5coBAPT09KCqqoqYmBhhv4WFBR4/fqyQAImIiIiISHnIlUCYmZnh7du32RWoqMDMzAzXr18X9t+5c6fIF5QjIiIiIqLvT65B1HZ2djhz5gwmT54MABgwYACWLVuGsLAwiMViBAUFYdiwYQoNlIiIiIiIip9cCcSYMWPQtWtXpKenQ11dHUOHDkVSUhLOnj0LFRUVuLi4YPTo0YqOlYiIiIiIiplcCYS+vj709fWF2yKRCC4uLnBxcVFYYEREREREpHzkGgNBREREREQ/J5kSCDc3N4SFhRW48v/++w9ubm4Fvh8RERERESknmbowvXv3Dp07d4atrS26dOmC5s2bo2LFinmWffv2La5fv47Tp08jMDAQLVu2VGjARERERERUfGRKILZs2YLbt29j+/btcHNzQ2ZmJgwMDGBqagp9fX2IxWLExsbi7du3iIuLg6qqKlq1aoVdu3bB2tq6qJ8DERERERF9JzIPom7SpAmaNGmCz58/4+LFi7h37x5evnyJ9+/fAwAMDAzQsWNHWFpaonXr1ihbtmyRBU1ERERERMWjwLMwGRoaok+fPujTp09RxENEREREREqMszAREREREZHMmEAQEREREZHMmEAQEREREZHMmEAQEREREZHMmEAQEREREZHMmEAQEREREZHMCjyNq6R79+4hMDAQnz59wsCBA1G1alUkJyfj5cuXqFq1KkqXLq2oOImIiIiISAnIlUCkpaVhypQpuHDhAsRiMUQiEdq0aYOqVatCRUUFw4cPh7OzM8aOHavoeImIiIiIqBjJ1YXJw8MDly5dwvz583HmzBmIxWJhn6amJjp16oQLFy4oLEgiIiIiIlIOciUQJ0+eRP/+/dGvXz/o6+vn2l+9enWEhYUVOjgiIiIiIlIuciUQnz59goWFRb77VVVVkZKSIndQRERERESknORKICpWrIiXL1/mu//OnTuoXLmy3EEREREREZFykiuB6NatG/bv34+7d+8K20QiEQDg4MGDOH36NHr16qWQAImIiIiISHnINQvTmDFjcP/+fQwePBjVqlWDSCSCu7s7YmNj8f79e9jb28PZ2VnBoRIRERERUXGTK4HQ0NDA1q1bcfz4cfz777/IyspCWloaLCwsMGnSJPTs2VNokSAiIiIioh9HgROIlJQUrF27FjY2NujZsyd69uxZFHEREREREZESKvAYiFKlSuHAgQP49OlTUcRDRERERERKTK5B1PXq1UNISIiiYyEiIiIiIiUnVwIxe/ZsnDp1CocOHUJGRoaiYyIiIiIiIiUl1yDqmTNnQiQSwc3NDYsXL4aRkRE0NTWlyohEIhw/flwhQRIRERERkXKQK4EwMDCAgYEBzM3NFR0PEREREREpMbkSCG9vb0XHQUREREREJYBcYyCIiIiIiOjnJFcLBABkZmbi+PHjuHTpEiIiIgAAJiYmaNOmDbp37w5VVVWFBUlERERERMpBrgQiPj4eI0aMwMOHD1G6dGlUqlQJABAQEICzZ89i37592LZtG3R0dBQaLBERERERFS+5Eoi1a9fi8ePHmDt3Ln777Teoq6sDANLT03Ho0CEsWbIEa9euxbx58xQaLBERERERFS+5xkCcO3cOAwYMwKBBg4TkAQDU1dUxcOBADBgwAP/++6/CgiQiIiIiIuUgVwIRExPz1Slczc3NERsbK3dQRERERESknORKIKpUqQI/P7989/v5+aFy5cpyB0VERERERMpJrgRiwIAB8Pf3x++//45r167h7du3ePv2La5evYpRo0YhICAAgwYNUnSsRERERERUzOQaRD1o0CB8/vwZmzdvxrVr16QrVFPDuHHjMHDgQIUESEREREREykPudSDGjx+PQYMG4fr16wgPDwcAmJqaonnz5jA0NFRYgEREREREpDzkTiAAwNDQEF27dlVULEREREREpOTk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cHbE4XwhISFERUX5gsa5uujoaL86k8lEVFSU4esRERF/ChAiIjKmhQsXUlpayi+//MK8efN8f9Cfz+v1Mm3aNCoqKgK2YbFYDPd3/qjC+UZGRgy3ISIil46mMImIyJgeeOABJkyYwKFDh1i4cGHAmptuuom+vj6sVitJSUmjXrfeequv9vzpSeNxboTi+++/9zs+NDSEy+UiMjLSr+748eN+dR6PB5fLdVHXICJyNVOAEBGRMU2aNIlNmzZRWFhIWlpawJoFCxYwPDxMdXX1qHNnzpzx2171mmuuCbjdqlFJSUmYTCbq6+v9RiXee+893G43qampAMTHx2OxWGhpaWFoaMhXt3v37ovqX0TkaqcpTCIi8pcWLVo05vmEhASWLl1KbW0tR44cITk5GZPJxLFjx+js7GTjxo08+OCDAMTFxdHc3Ex1dTXR0dG+dRZGWSwW7HY7r732GgUFBaSlpfHDDz/Q1NTE7NmzycrKAs6udVi3bh0Oh4P8/HwyMjJwuVy0tbVpDYSIyEVQgBARkX+E0+kkPj6elpYWKisrCQ4OJjIykqysLKxWq69uzZo19PT08Pbbb/Pbb7+RkJDwtwIEQGFhIRaLhYaGBsrKyrj++utZsmQJGzZswGQy+eqWLl3K8PAwO3bsoLy8nFtuuYWamhpeffXVf+xzi4hcbYJGtCpNREREREQM0hoIERERERExTAFCREREREQMU4AQERERERHDFCBERERERMQwBQgRERERETFMAUJERERERAxTgBAREREREcMUIERERERExDAFCBERERERMUwBQkREREREDFOAEBERERERwxQgRERERETEMAUIEREREREx7H+ToRR3Gusi3wAAAABJRU5ErkJggg==",
            "text/plain": [
              "<Figure size 800x500 with 1 Axes>"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Algorithm comparison for one user\n",
        "import pandas as pd\n",
        "import matplotlib.pyplot as plt\n",
        "import seaborn as sns\n",
        "\n",
        "uid = \"U003\"  \n",
        "\n",
        "df_j = cf_recommendations(uid, rel_type=\"SIMILAR_TASTE\", top_k=5, min_rating=4)\n",
        "df_k = cf_recommendations(uid, rel_type=\"KNN_SIMILAR\", top_k=5, min_rating=4)\n",
        "df_h = hybrid_recommendations(uid, rel_type=\"SIMILAR_TASTE\", top_k=5, min_rating=4)\n",
        "\n",
        "# Build compact comparison table using top recommendation from each method\n",
        "rows = []\n",
        "if not df_j.empty:\n",
        "    rows.append({\"method\": \"Jaccard CF\", \"title\": df_j.iloc[0][\"title\"], \"supporters\": df_j.iloc[0][\"supporters\"], \"score\": df_j.iloc[0][\"avgRating\"]})\n",
        "if not df_k.empty:\n",
        "    rows.append({\"method\": \"kNN/Cosine CF\", \"title\": df_k.iloc[0][\"title\"], \"supporters\": df_k.iloc[0][\"supporters\"], \"score\": df_k.iloc[0][\"avgRating\"]})\n",
        "if not df_h.empty:\n",
        "    rows.append({\"method\": \"Hybrid\", \"title\": df_h.iloc[0][\"title\"], \"supporters\": df_h.iloc[0][\"supporters\"], \"score\": df_h.iloc[0][\"finalScore\"]})\n",
        "\n",
        "df_cmp = pd.DataFrame(rows)\n",
        "display(df_cmp)\n",
        "\n",
        "plt.figure(figsize=(8, 5))\n",
        "ax = sns.barplot(data=df_cmp, x=\"method\", y=\"score\", palette=\"Set2\")\n",
        "ax.set_title(f\"Algorithm Comparison (Top Recommendation Score) for User {uid}\", fontsize=12, weight=\"bold\")\n",
        "ax.set_xlabel(\"Method\")\n",
        "ax.set_ylabel(\"Score (avgRating for CF, finalScore for Hybrid)\")\n",
        "\n",
        "for i, r in df_cmp.iterrows():\n",
        "    ax.text(i, r[\"score\"], f'{r[\"title\"]}\\n(supp={int(r[\"supporters\"])})',\n",
        "            ha='center', va='bottom', fontsize=9)\n",
        "\n",
        "plt.tight_layout()\n",
        "plt.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "fd2ad886",
      "metadata": {},
      "source": [
        "### Extension 4: Director affinity boost (before vs after)\n",
        "\n",
        "We tested the hybrid recommender with two director-weight settings for the same user:\n",
        "\n",
        "- **Before:** `director_weight = 1.0`\n",
        "- **After:** `director_weight = 2.0`\n",
        "\n",
        "#### Results\n",
        "- **Before (1.0):** `La La Land` with `finalScore = 9.667`\n",
        "- **After (2.0):** `La La Land` with `finalScore = 10.667`\n",
        "\n",
        "Other fields remained the same:\n",
        "- `supporters = 3`\n",
        "- `avgRating = 4.67`\n",
        "- `genreOverlapCount = 2`\n",
        "- `directorOverlapCount = 1`\n",
        "\n",
        "#### Interpretation\n",
        "Increasing the director weight increased the recommendation score by exactly **+1.0** for this item (because `directorOverlapCount = 1`). The top recommendation did not change, but its ranking confidence increased.\n",
        "\n",
        "This confirms that the explicit director-affinity term works as designed: candidates connected to directors the user already liked receive an extra boost proportional to `director_weight`.\n",
        "\n",
        "#### Does the boost surface additional films?\n",
        "In this run, no additional films were surfaced (only one candidate appears in both before/after outputs), so the effect is score amplification rather than list expansion. On users with more candidate diversity and more director-overlap opportunities, a stronger director weight is more likely to change ordering and surface extra director-related titles.\n",
        "\n",
        "#### Quality implication\n",
        "A moderate director boost can improve personalization for users with strong auteur preferences. However, too large a boost may over-concentrate recommendations around a narrow director set and reduce diversity, so tuning should balance relevance and variety."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "id": "d8c8dfcd",
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Before (director_weight=1.0)\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>genreOverlapCount</th>\n",
              "      <th>directorOverlapCount</th>\n",
              "      <th>finalScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>3</td>\n",
              "      <td>4.67</td>\n",
              "      <td>2</td>\n",
              "      <td>1</td>\n",
              "      <td>9.667</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating  genreOverlapCount  directorOverlapCount  \\\n",
              "0  La La Land           3       4.67                  2                     1   \n",
              "\n",
              "   finalScore  \n",
              "0       9.667  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "After (director_weight=2.0)\n"
          ]
        },
        {
          "data": {
            "text/html": [
              "<div>\n",
              "<style scoped>\n",
              "    .dataframe tbody tr th:only-of-type {\n",
              "        vertical-align: middle;\n",
              "    }\n",
              "\n",
              "    .dataframe tbody tr th {\n",
              "        vertical-align: top;\n",
              "    }\n",
              "\n",
              "    .dataframe thead th {\n",
              "        text-align: right;\n",
              "    }\n",
              "</style>\n",
              "<table border=\"1\" class=\"dataframe\">\n",
              "  <thead>\n",
              "    <tr style=\"text-align: right;\">\n",
              "      <th></th>\n",
              "      <th>title</th>\n",
              "      <th>supporters</th>\n",
              "      <th>avgRating</th>\n",
              "      <th>genreOverlapCount</th>\n",
              "      <th>directorOverlapCount</th>\n",
              "      <th>finalScore</th>\n",
              "    </tr>\n",
              "  </thead>\n",
              "  <tbody>\n",
              "    <tr>\n",
              "      <th>0</th>\n",
              "      <td>La La Land</td>\n",
              "      <td>3</td>\n",
              "      <td>4.67</td>\n",
              "      <td>2</td>\n",
              "      <td>1</td>\n",
              "      <td>10.667</td>\n",
              "    </tr>\n",
              "  </tbody>\n",
              "</table>\n",
              "</div>"
            ],
            "text/plain": [
              "        title  supporters  avgRating  genreOverlapCount  directorOverlapCount  \\\n",
              "0  La La Land           3       4.67                  2                     1   \n",
              "\n",
              "   finalScore  \n",
              "0      10.667  "
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        }
      ],
      "source": [
        "# Extension 4: explicit director affinity boost\n",
        "uid = \"U003\"\n",
        "\n",
        "# Baseline hybrid (no director emphasis)\n",
        "df_base = hybrid_recommendations(\n",
        "    uid, rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4,\n",
        "    genre_weight=0.5, director_weight=1.0\n",
        ")\n",
        "\n",
        "# Director-boosted hybrid\n",
        "df_boost = hybrid_recommendations(\n",
        "    uid, rel_type=\"SIMILAR_TASTE\", top_k=10, min_rating=4,\n",
        "    genre_weight=0.5, director_weight=2.0\n",
        ")\n",
        "\n",
        "print(\"Before (director_weight=1.0)\")\n",
        "display(df_base)\n",
        "\n",
        "print(\"After (director_weight=2.0)\")\n",
        "display(df_boost)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "e690273d",
      "metadata": {},
      "source": [
        "---\n",
        "\n",
        "## Use of large language models (LLM) assistance\n",
        "\n",
        "We used an LLM-based coding assistant (e.g., Cursor / ChatGPT-style tooling) as a **support tool** during this project. It did **not** replace our understanding of Neo4j, Cypher, or GDS; we reviewed, executed, and validated all queries and results in our own environment.\n",
        "\n",
        "### Where LLM assistance was used\n",
        "\n",
        "1. **Choosing the two deeper analytical questions (Section 3)**  \n",
        "   We used the LLM to compare the four candidate questions against our existing notebook content and project goals. It helped articulate trade-offs (e.g., overlap vs taste, genre profiles vs long-tail/director analyses). **The final decision** to answer **3.1** and **3.2** was ours, aligned with how those questions connect to later collaborative vs content-based reasoning.\n",
        "\n",
        "2. **Query optimisation and debugging (GDS sections)**  \n",
        "   We used the LLM to troubleshoot and refine Cypher patterns while implementing:\n",
        "   - **GDS 3** : helped us compare the 3 approaches we had in our group to identify which one was most aligned to the question  \n",
        "\n",
        "In all cases, we **ran the queries ourselves**, checked outputs against expectations, and wrote the narrative interpretations based on our results.\n",
        "\n",
        "### Note on the transcripts below\n",
        "\n",
        "The **“all four are implemented”** table in the first transcript was written before we finalized the notebook: our submitted work answers **3.1** and **3.2** only (we did not include **3.3** and **3.4** as separate sections). The chat is preserved **verbatim** for transparency.\n",
        "\n",
        "### Cursor transcript — deeper analytical questions\n",
        "\n",
        "**User**\n",
        "\n",
        "for Deeper Analytical Questions, help us to choose 2 see what we have in @assignments/recommender/recommender_skeleton.ipynb \n",
        "\n",
        "---\n",
        "\n",
        "**Cursor**\n",
        "\n",
        "\n",
        "Checking the assignment's four deeper analytical questions and what's already implemented in `recommender_skeleton.ipynb`.\n",
        "\n",
        "\n",
        "\n",
        "\n",
        "Here’s what **`recommender_skeleton.ipynb` already contains** for **Section 3 (Deeper analytical questions)** and a practical way to **pick 2**.\n",
        "\n",
        "---\n",
        "\n",
        "### What you already have (all four are implemented)\n",
        "\n",
        "| # | Topic | In notebook | Notes |\n",
        "|---|--------|-------------|--------|\n",
        "| **3.1** | Taste overlap (co-rated movies + avg abs diff) | **Yes** — query logic, code, results, **interpretation** | Strong tie-in to “overlap ≠ taste” (sets up GDS). |\n",
        "| **3.2** | Genre preference profiles + similarity to Alice | **Yes** — logic, profile table, **top-3 similar users**, interpretation | Uses enriched graph (`IN_GENRE`); links to content/hybrid. |\n",
        "| **3.3** | Long-tail (top-3 %, hidden gems, strategies) | **Yes** — logic, code, outputs, interpretation | Code uses a `CALL { ... }` subquery; you may see a **deprecation warning** (can be cleaned up later). |\n",
        "| **3.4** | Director–genre co-occurrence + crossover directors | **Yes** — logic, code, tables, downstream interpretation | Strong link to **hybrid / director boost** (GDS Task 4 / Ext 4). |\n",
        "\n",
        "The assignment asks you to **pick any 2** (15 pts each). Your notebook currently documents **all four**; for submission you usually either **(a)** keep only two labeled as “Analytical (graded)” or **(b)** ask the instructor whether all four may be submitted.\n",
        "\n",
        "---\n",
        "\n",
        "### Recommended pairs (pick one)\n",
        "\n",
        "**Option A — Best fit with the rest of the project (recommended)**  \n",
        "**3.1 + 3.2**\n",
        "\n",
        "- **3.1** explains why **raw overlap** is not enough (matches **Jaccard vs rating-aware / kNN** story in GDS 1–2).  \n",
        "- **3.2** gives a **content-based** angle that foreshadows **hybrid** and genre/director use.  \n",
        "- Both are already written with clear logic + commentary in your notebook.\n",
        "\n",
        "**Option B — Emphasize catalog + content graph (good if you want less overlap with “user–user similarity”)**  \n",
        "**3.3 + 3.4**\n",
        "\n",
        "- **3.3** = popularity / long-tail / “hidden gems” (evaluation + fairness).  \n",
        "- **3.4** = **director–genre** structure (direct line to hybrid and extension 4).  \n",
        "- Very strong if your write-up stresses **bias, coverage, and hybrid design**.\n",
        "\n",
        "**Option C — Fast + coherent “pure Cypher → enriched graph”**  \n",
        "**3.1 + 3.4**\n",
        "\n",
        "- Overlap first, then **structured content** (genres/directors).  \n",
        "- Slightly less overlap with **3.2**’s genre-profile math if you want to avoid two genre-heavy chapters.\n",
        "\n",
        "---\n",
        "\n",
        "### Practical advice\n",
        "\n",
        "- If the goal is **least rework + strongest narrative**: choose **3.1 + 3.2** (your interpretations are already aligned with GDS).  \n",
        "- For submission, **clearly mark** which two questions are your official “Analytical (pick 2)” so graders don’t wonder which count.\n",
        "\n",
        "If you tell me your preference (**more GDS-linked** vs **more hybrid/long-tail**), I can suggest a one-line “why we chose these two” blurb for the notebook intro.\n",
        "\n",
        "---\n",
        "\n",
        "### Cursor transcript — GDS Task 3 (collaborative filtering)\n",
        "\n",
        "**User**\n",
        "\n",
        "approach A:\n",
        "# Collaborative filtering recommendation query — maps to rubric (a)–(d)\n",
        "def generate_collaborative_recs(user_id, user_name, top_k=10):\n",
        "    \"\"\"Generate recommendations using SIMILAR_TASTE/KNN_SIMILAR (Task 1 & 2).\"\"\"\n",
        "    \n",
        "    # Show user's rating history (for assessment)\n",
        "    history = run_query(f\"\"\"\n",
        "    MATCH (u:User {{userId: '{user_id}'}})-[r:RATED]->(m:Movie)\n",
        "    RETURN m.title AS movie, r.rating AS rating\n",
        "    ORDER BY rating DESC LIMIT 5\n",
        "    \"\"\")\n",
        "    print(f\"\\n {user_name}'s Top Ratings:\")\n",
        "    print(history)\n",
        "    \n",
        "    # Cypher query — each clause maps to rubric:\n",
        "    rec_query = f\"\"\"\n",
        "    // (a) Find most similar users — KNN_SIMILAR from Task 2; take top 5\n",
        "    MATCH (target:User {{userId: '{user_id}'}})-[sim:KNN_SIMILAR]->(similar:User)\n",
        "    WITH target, similar, sim.score AS simScore\n",
        "    ORDER BY simScore DESC LIMIT 5\n",
        "    \n",
        "    // (b) Movies similar users rated highly (>=4); (c) Exclude already rated\n",
        "    MATCH (similar)-[r:RATED]->(m:Movie)\n",
        "    WHERE r.rating >= 4 AND NOT EXISTS((target)-[:RATED]->(m))\n",
        "    \n",
        "    // (d) Rank by support count and average rating\n",
        "    WITH m, count(DISTINCT similar) AS support, avg(r.rating) AS avgRating\n",
        "    RETURN m.title AS movie, m.genre1 AS genre1,\n",
        "           support, round(avgRating, 2) AS avgRating\n",
        "    ORDER BY support DESC, avgRating DESC LIMIT {top_k}\n",
        "    \"\"\"\n",
        "    \n",
        "    recs = run_query(rec_query)\n",
        "    print(f\"\\n Collaborative Recommendations for {user_name}:\")\n",
        "    print(recs)\n",
        "    return recs\n",
        "\n",
        "# Generate top 10 recommendations for 3 users\n",
        "test_users = [('U001', 'Alice Chen'), ('U003', 'Carol White'), ('U018', 'Ryan Murphy')]\n",
        "for uid, uname in test_users:\n",
        "    generate_collaborative_recs(uid, uname)\n",
        "\n",
        "approach B:\n",
        "@recommender_skeleton.ipynb (2-36) \n",
        "\n",
        "approach C:\n",
        "MATCH (target:User {name: 'Alice Chen'})-[s:KNN_SIMILAR]-(neighbor:User)\n",
        "WHERE target <> neighbor\n",
        "WITH target, neighbor, s\n",
        "ORDER BY s.score DESC\n",
        "LIMIT 5\n",
        "\n",
        "MATCH (neighbor)-[r:RATED]->(candidate:Movie)\n",
        "WHERE r.rating >= 4\n",
        "  AND NOT EXISTS {\n",
        "    MATCH (target)-[:RATED]->(candidate)\n",
        "  }\n",
        "\n",
        "RETURN\n",
        "  candidate.title AS recommendedMovie,\n",
        "  count(DISTINCT neighbor) AS supportingUsers,\n",
        "  round(avg(r.rating), 2) AS avgNeighborRating,\n",
        "  round(avg(s.score), 4) AS avgSimilarityScore\n",
        "ORDER BY supportingUsers DESC, avgNeighborRating DESC, avgSimilarityScore DESC\n",
        "LIMIT 10;\n",
        "\n",
        "of these 3 approaches which correctly answers GDS 3\n",
        "\n",
        "\n",
        "---\n",
        "\n",
        "**Cursor**\n",
        "\n",
        "\n",
        "### Short answer\n",
        "\n",
        "**Approach B** (`cf_recommendations` in `recommender_skeleton.ipynb`) is the one that **most cleanly and correctly** matches GDS Task 3 **(a)–(d)**.\n",
        "\n",
        "**Approach A** is **mostly OK** for a *kNN-only* variant, with **two important caveats**.  \n",
        "**Approach C** is **not quite correct** as written because of how **`avg(s.score)`** is computed.\n",
        "\n",
        "---\n",
        "\n",
        "### Why B is best match to the rubric\n",
        "\n",
        "| Rubric part | B |\n",
        "|-------------|---|\n",
        "| (a) Similar users | Uses stored `SIMILAR_TASTE` / `KNN_SIMILAR` via `(target)-[sim]-(similar)` → catches **both directions** (important if edges aren’t guaranteed symmetric the same way). |\n",
        "| (b) High ratings | `r.rating >= $minRating` (you use 4 by default). |\n",
        "| (c) Exclude seen | `NOT EXISTS { (target)-[:RATED]->(m) }`. |\n",
        "| (d) Rank | `count(DISTINCT similar)` + `avg(r.rating)`, `ORDER BY` supporters then avg, **`LIMIT` as literal** (avoids the `LIMIT $param` issue). |\n",
        "\n",
        "It also aligns with how your graph was built: **topK is already enforced when you *wrote* similarity edges**, so you usually **don’t need** an extra `LIMIT 5` on neighbors unless you want to subset further.\n",
        "\n",
        "---\n",
        "\n",
        "### Approach A — acceptable with caveats\n",
        "\n",
        "**Pros**\n",
        "\n",
        "- Explicitly takes **top 5 neighbors by similarity** before pooling movies — fine if the assignment wording you want to stress is “most similar users” as a **ranked subset**.\n",
        "- Implements (b)–(d) sensibly.\n",
        "\n",
        "**Caveats**\n",
        "\n",
        "1. **Direction:** `(target)-[sim:KNN_SIMILAR]->(similar)` only follows **outgoing** kNN edges. If your write-back ever creates useful **incoming** edges only, you’ll miss neighbors. B’s undirected pattern is safer.\n",
        "2. **Task scope:** It’s **kNN-only**. The assignment allows **“SIMILAR_TASTE and/or KNN_SIMILAR”**, so kNN-only can be valid, but your notebook’s Task 3 comparison typically expects **both** (as B’s loop does).\n",
        "3. **Security/style:** Embedding `'{user_id}'` in the string instead of `$targetUserId` works but is weaker practice.\n",
        "\n",
        "---\n",
        "\n",
        "### Approach C — problematic\n",
        "\n",
        "**What’s good**\n",
        "\n",
        "- Undirected `-[s:KNN_SIMILAR]-` is direction-safe.\n",
        "- Top 5 neighbors by `s.score`, then ≥4 ratings, exclude target’s ratings — matches the story of (a)–(c).\n",
        "\n",
        "**What’s wrong / misleading**\n",
        "\n",
        "- `avg(s.score)` in the final aggregation is **not** “average similarity of supporting neighbors” in a clean way, because `s.score` is **repeated once per `(neighbor, movie)` row**. Neighbors who rated many qualifying movies contribute **more** to `avg(s.score)` than neighbors who rated few.  \n",
        "  If you want a similarity-aware aggregate, use something like `avg(DISTINCT s.score)` **per neighbor** first, or `max(s.score)` per neighbor, then aggregate — or omit similarity from the ranking and keep rubric (d) as support + avg rating only (like B).\n",
        "\n",
        "So **C is not the best “correct answer”** for Task 3 as stated, unless you fix the similarity aggregation.\n",
        "\n",
        "---\n",
        "\n",
        "### Practical recommendation for your notebook\n",
        "\n",
        "- **Use Approach B** as the canonical GDS 3 solution (and keep running **both** `SIMILAR_TASTE` and `KNN_SIMILAR` like your loop).\n",
        "- If you want Approach A’s “explicit top-5 neighbours” idea, implement it **on top of B’s pattern** (undirected match + `WITH similar, simScore ORDER BY simScore DESC LIMIT 5`) — that combines the best of both.\n",
        "\n",
        "---\n",
        "\n",
        "---\n",
        "\n",
        "**User**\n",
        "\n",
        "Approach A :\n",
        "# Collaborative filtering recommendation query — maps to rubric (a)–(d)\n",
        "def generate_collaborative_recs(user_id, user_name, top_k=10):\n",
        "    \"\"\"Generate recommendations using SIMILAR_TASTE/KNN_SIMILAR (Task 1 & 2).\"\"\"\n",
        "    \n",
        "    # Show user's rating history (for assessment)\n",
        "    history = run_query(\"\"\"\n",
        "    MATCH (u:User {userId: $userId})-[r:RATED]->(m:Movie)\n",
        "    RETURN m.title AS movie, r.rating AS rating\n",
        "    ORDER BY rating DESC LIMIT 5\n",
        "    \"\"\", {\"userId\": user_id})\n",
        "    print(f\"\\n {user_name}'s Top Ratings:\")\n",
        "    print(history)\n",
        "    \n",
        "    # Same logic as the Neo4j Browser query (parameters $userId, $topK)\n",
        "    rec_query = \"\"\"\n",
        "    MATCH (target:User {userId: $userId})-[sim:KNN_SIMILAR]->(similar:User)\n",
        "    WITH target, similar, sim.score AS simScore\n",
        "    ORDER BY simScore DESC LIMIT 5\n",
        "    MATCH (similar)-[r:RATED]->(m:Movie)\n",
        "    WHERE r.rating >= 4.0 AND NOT EXISTS((target)-[:RATED]->(m))\n",
        "    WITH m, count(DISTINCT similar) AS support, avg(r.rating) AS avgRating\n",
        "    RETURN m.title AS movie, m.genre1 AS genre1,\n",
        "           support, round(avgRating, 2) AS avgRating\n",
        "    ORDER BY support DESC, avgRating DESC LIMIT $topK\n",
        "    \"\"\"\n",
        "    recs = run_query(rec_query, {\"userId\": user_id, \"topK\": top_k})\n",
        "    print(f\"\\n Collaborative Recommendations for {user_name}:\")\n",
        "    print(recs)\n",
        "    return recs\n",
        "\n",
        "# Generate top 10 recommendations for 3 users\n",
        "test_users = [('U001', 'Alice Chen'), ('U003', 'Carol White'), ('U018', 'Ryan Murphy')]\n",
        "for uid, uname in test_users:\n",
        "    generate_collaborative_recs(uid, uname)\n",
        "\n",
        "approach B:\n",
        "@recommender_skeleton.ipynb (2-36) \n",
        "\n",
        "approach C:\n",
        "MATCH (target:User {userId: 'U001'})\n",
        "MATCH (target)-[sim:KNN_SIMILAR]-(similar:User)\n",
        "WHERE similar <> target\n",
        "WITH target, similar, sim\n",
        "ORDER BY sim.score DESC\n",
        "LIMIT 5\n",
        "\n",
        "MATCH (similar)-[r:RATED]->(m:Movie)\n",
        "WHERE r.rating >= 4\n",
        "  AND NOT EXISTS {\n",
        "    MATCH (target)-[:RATED]->(m)\n",
        "  }\n",
        "\n",
        "RETURN\n",
        "  m.title AS title,\n",
        "  count(DISTINCT similar) AS supporters,\n",
        "  round(avg(r.rating), 2) AS avgRating,\n",
        "  round(avg(sim.score), 4) AS avgSimilarity\n",
        "ORDER BY supporters DESC, avgRating DESC, avgSimilarity DESC, title ASC\n",
        "LIMIT 10;\n",
        "\n",
        "which one answers gds 3 best\n",
        "\n",
        "---\n",
        "\n",
        "**Cursor**\n",
        "\n",
        "\n",
        "### Ranking (best → weakest for **GDS Task 3**)\n",
        "\n",
        "1. **Approach B** (`cf_recommendations` in `recommender_skeleton.ipynb`) — **best overall**  \n",
        "2. **Approach A** — **OK as a narrower variant**, but weaker than B for a few reasons  \n",
        "3. **Approach C** — **weakest** (methodological + practical issues)\n",
        "\n",
        "---\n",
        "\n",
        "### Why **B** is the best match to GDS 3\n",
        "\n",
        "- **(a) Similar users:** Uses `(target)-[sim:{rel_type}]-(similar)` → **both directions**, so you don’t miss similarity edges depending on how they were written.\n",
        "- **(b)/(c)/(d):** Correct filtering (`rating >= min_rating`), exclusion of already-rated movies, aggregation (`supporters`, `avgRating`), ranking, top‑K.\n",
        "- **Matches the assignment’s intent to compare methods:** your loop runs **`SIMILAR_TASTE` and `KNN_SIMILAR`** — Task 3 is explicitly about using the similarity relationships from prior tasks, commonly **both**.\n",
        "\n",
        "---\n",
        "\n",
        "### Approach A — what’s good + what’s weaker\n",
        "\n",
        "**Good**\n",
        "- Shows rating history for assessment (nice for writeups).\n",
        "- Explicitly takes **top 5 neighbors by `KNN_SIMILAR` score** before pooling movies — defensible if you want to emphasize “most similar users” as a ranked subset.\n",
        "\n",
        "**Weaker vs B**\n",
        "- **Only `KNN_SIMILAR`:** doesn’t cover **`SIMILAR_TASTE`** unless you change the relationship type / run twice.\n",
        "- **Directed only:** `(target)-[sim:KNN_SIMILAR]->(similar)` can miss cases where the useful edge is incoming (B’s undirected match is safer).\n",
        "- **`LIMIT $topK`:** depending on Neo4j/GDS settings, parameterized `LIMIT` can error (you hit this class of issue before). **B avoids that** by using a literal `LIMIT {int(top_k)}` in Python.\n",
        "\n",
        "---\n",
        "\n",
        "### Approach C — main problems\n",
        "\n",
        "- **Hardcoded** `userId: 'U001'` → not a reusable Task 3 solution.\n",
        "- **`avg(sim.score)` is misleading** after expanding `(similar)-[:RATED]->(m)`: neighbors who contribute **more qualifying movies** get their similarity score counted **more times**, so it’s not a clean “average similarity of supporters.”\n",
        "- Adds extra sort keys (`avgSimilarity`, `title`) beyond the rubric’s core ranking (not wrong, but the similarity term is shaky).\n",
        "\n",
        "---\n",
        "\n",
        "### Bottom line\n",
        "\n",
        "Use **Approach B** as your canonical GDS 3 answer. If you like Approach A’s “top 5 neighbors explicitly” idea, the clean upgrade is: **keep B’s undirected match + parameters**, then add a `WITH … ORDER BY sim.score DESC LIMIT 5` **before** the `RATED` expansion—still run for **both** `SIMILAR_TASTE` and `KNN_SIMILAR` if you want full Task 3 alignment.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "45a5bdb4",
      "metadata": {},
      "source": [
        "---\n",
        "\n",
        "## References\n",
        "\n",
        "### Course Materials\n",
        "Leskovec, J., Rajaraman, A., & Ullman, J. D. (2020). *Mining of Massive Datasets*, Chapter 9: Recommendation Systems.\n",
        "\n",
        "### Neo4j Documentation\n",
        "- Neo4j Graph Data Science Library. https://neo4j.com/docs/graph-data-science/current/\n",
        "- Node Similarity (Jaccard). https://neo4j.com/docs/graph-data-science/current/algorithms/node-similarity/\n",
        "- k-Nearest Neighbors. https://neo4j.com/docs/graph-data-science/current/algorithms/knn/\n",
        "- FastRP. https://neo4j.com/docs/graph-data-science/current/machine-learning/node-embeddings/fastrp/\n",
        "- Louvain Community Detection. https://neo4j.com/docs/graph-data-science/current/algorithms/louvain/\n",
        "\n",
        "### Academic Literature\n",
        "- Koren, Y., Bell, R., & Volinsky, C. (2009). Matrix Factorization Techniques for Recommender Systems. *IEEE Computer*, 42(8), 30-37.\n",
        "- Herlocker, J. L., Konstan, J. A., Terveen, L. G., & Riedl, J. T. (2004). Evaluating Collaborative Filtering Recommender Systems. *ACM Transactions on Information Systems*, 22(1), 5-53.\n",
        "- Leskovec, J., Rajaraman, A., & Ullman, J. D. (2020). *Mining of Massive Datasets* (3rd ed.). Cambridge University Press. **Chapter 9 - Recommendation Systems** (utility matrix / sparse ratings, collaborative vs content-based, similarity notions such as Jaccard vs cosine). [mmds.org](https://www.mmds.org/)\n",
        "- Chen, H., Sultan, S. F., Tian, Y., Chen, M., & Skiena, S. (2019). *Fast and Accurate Network Embeddings via Very Sparse Random Projection.* In *Proceedings of CIKM 2019*. arXiv:1908.11512. [arXiv](https://arxiv.org/abs/1908.11512) - basis for **FastRP** embeddings (embedding dimension choices discussed in our approach doc).\n",
        "- Neo4j, Inc. *Neo4j Graph Data Science Library* - user guide for graph projection, **Node Similarity** (Jaccard), **Fast Random Projection (FastRP)**, **kNN**, **Louvain**, and write-back patterns. [GDS manual](https://neo4j.com/docs/graph-data-science/current/)\n",
        "\n"
      ]
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": ".venv",
      "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.9"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
