{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Week 9: Interactive Demonstrations\n",
    "# K-means and Hierarchical Clustering\n",
    "\n",
    "**MPS311/439 Machine Learning**  \n",
    "**Dr. Wei Xing**  \n",
    "**University of Sheffield**\n",
    "\n",
    "---\n",
    "\n",
    "This notebook contains interactive demonstrations for understanding clustering algorithms.\n",
    "\n",
    "## Setup Instructions\n",
    "\n",
    "1. Run the first cell to install required packages\n",
    "2. Run each demo cell to see interactive visualizations\n",
    "3. Play with sliders and buttons to explore concepts!\n",
    "\n",
    "---"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "✓ Setup complete!\n"
     ]
    }
   ],
   "source": [
    "# Install required packages (run this first in Google Colab)\n",
    "import sys\n",
    "if 'google.colab' in sys.modules:\n",
    "    print(\"Running in Google Colab - installing packages...\")\n",
    "    !pip install -q ipywidgets\n",
    "    from google.colab import output\n",
    "    output.enable_custom_widget_manager()\n",
    "\n",
    "print(\"✓ Setup complete!\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "✓ All libraries imported successfully!\n"
     ]
    }
   ],
   "source": [
    "# Import all necessary libraries\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.datasets import make_blobs, make_moons, make_circles\n",
    "from sklearn.cluster import KMeans, AgglomerativeClustering\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from scipy.cluster.hierarchy import dendrogram, linkage\n",
    "from scipy.spatial import Voronoi, voronoi_plot_2d\n",
    "import ipywidgets as widgets\n",
    "from ipywidgets import interact, interactive, fixed, interact_manual\n",
    "from IPython.display import display, HTML\n",
    "import warnings\n",
    "warnings.filterwarnings('ignore')\n",
    "\n",
    "# Set random seed for reproducibility\n",
    "np.random.seed(42)\n",
    "\n",
    "# Set style\n",
    "plt.style.use('default')\n",
    "plt.rcParams['figure.figsize'] = (10, 6)\n",
    "plt.rcParams['font.size'] = 11\n",
    "\n",
    "print(\"✓ All libraries imported successfully!\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Demo 1: K-means Convergence Visualization\n",
    "\n",
    "**Learning Objective**: Understand how K-means iteratively converges through the assignment and update steps.\n",
    "\n",
    "**What to observe**:\n",
    "- How centroids move at each iteration\n",
    "- How cluster assignments change\n",
    "- How inertia (total distance) decreases\n",
    "- Algorithm always makes progress!\n",
    "\n",
    "**Try this**:\n",
    "1. Move the iteration slider slowly to see step-by-step progress\n",
    "2. Click \"Reset with New Initialization\" to see different starting points\n",
    "3. Try \"Auto-play\" to watch the full convergence"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "febefa66e0fe4bfc886dacc8f537f69f",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "VBox(children=(IntSlider(value=0, continuous_update=False, description='Iteration:', layout=Layout(width='600p…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "315e826e23ce4aceb443668d1680dcee",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Output()"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Generate data for Demo 1\n",
    "X_demo1, y_true_demo1 = make_blobs(n_samples=300, centers=3, n_features=2,\n",
    "                                    cluster_std=0.7, random_state=42)\n",
    "\n",
    "def kmeans_step_by_step(X, K, max_iters=20, random_state=None):\n",
    "    \"\"\"Run K-means and track all iterations\"\"\"\n",
    "    if random_state is not None:\n",
    "        np.random.seed(random_state)\n",
    "    \n",
    "    n, d = X.shape\n",
    "    \n",
    "    # Initialize\n",
    "    idx = np.random.choice(n, K, replace=False)\n",
    "    centroids = X[idx].copy()\n",
    "    \n",
    "    history = {'centroids': [centroids.copy()], 'labels': [], 'inertia': []}\n",
    "    \n",
    "    for i in range(max_iters):\n",
    "        # Assignment\n",
    "        distances = np.sqrt(((X[:, np.newaxis, :] - centroids) ** 2).sum(axis=2))\n",
    "        labels = np.argmin(distances, axis=1)\n",
    "        \n",
    "        # Compute inertia\n",
    "        inertia = sum([distances[labels == k, k].sum() ** 2 for k in range(K)])\n",
    "        \n",
    "        history['labels'].append(labels.copy())\n",
    "        history['inertia'].append(inertia)\n",
    "        \n",
    "        # Update\n",
    "        new_centroids = np.array([X[labels == k].mean(axis=0) if (labels == k).any() \n",
    "                                   else X[np.random.randint(n)] for k in range(K)])\n",
    "        \n",
    "        history['centroids'].append(new_centroids.copy())\n",
    "        \n",
    "        # Check convergence\n",
    "        if np.allclose(centroids, new_centroids, atol=1e-4):\n",
    "            break\n",
    "            \n",
    "        centroids = new_centroids\n",
    "    \n",
    "    return history\n",
    "\n",
    "# Initial run\n",
    "history_demo1 = kmeans_step_by_step(X_demo1, K=3, random_state=42)\n",
    "max_iter_demo1 = len(history_demo1['centroids']) - 1\n",
    "\n",
    "def plot_kmeans_iteration(iteration, show_lines=False):\n",
    "    \"\"\"Plot K-means at a specific iteration\"\"\"\n",
    "    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))\n",
    "    \n",
    "    # Left plot: Clustering visualization\n",
    "    centroids = history_demo1['centroids'][iteration]\n",
    "    \n",
    "    if iteration > 0:\n",
    "        labels = history_demo1['labels'][iteration - 1]\n",
    "        colors = ['#1f77b4', '#ff7f0e', '#2ca02c']\n",
    "        \n",
    "        for k in range(3):\n",
    "            mask = labels == k\n",
    "            ax1.scatter(X_demo1[mask, 0], X_demo1[mask, 1], \n",
    "                       c=colors[k], s=50, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "            \n",
    "            # Optionally show lines to centroids\n",
    "            if show_lines and mask.any():\n",
    "                for point in X_demo1[mask][:5]:  # Show only first 5 to avoid clutter\n",
    "                    ax1.plot([point[0], centroids[k, 0]], \n",
    "                            [point[1], centroids[k, 1]], \n",
    "                            'k-', alpha=0.2, linewidth=0.5)\n",
    "    else:\n",
    "        ax1.scatter(X_demo1[:, 0], X_demo1[:, 1], \n",
    "                   c='lightgray', s=50, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    \n",
    "    # Plot centroids\n",
    "    ax1.scatter(centroids[:, 0], centroids[:, 1], \n",
    "               marker='X', s=500, c='red', edgecolors='black', linewidth=3, zorder=5)\n",
    "    \n",
    "    ax1.set_xlabel('Feature 1', fontsize=12, fontweight='bold')\n",
    "    ax1.set_ylabel('Feature 2', fontsize=12, fontweight='bold')\n",
    "    ax1.set_title(f'Iteration {iteration} - Clustering Result', fontsize=14, fontweight='bold')\n",
    "    ax1.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add iteration info\n",
    "    if iteration > 0:\n",
    "        inertia = history_demo1['inertia'][iteration - 1]\n",
    "        ax1.text(0.02, 0.98, f'Inertia: {inertia:.2f}', \n",
    "                transform=ax1.transAxes, fontsize=12, verticalalignment='top',\n",
    "                bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))\n",
    "    \n",
    "    # Right plot: Inertia over iterations\n",
    "    if len(history_demo1['inertia']) > 0:\n",
    "        iters = range(1, len(history_demo1['inertia']) + 1)\n",
    "        ax2.plot(iters, history_demo1['inertia'], 'o-', linewidth=2, markersize=8)\n",
    "        \n",
    "        if iteration > 0:\n",
    "            ax2.plot(iteration, history_demo1['inertia'][iteration-1], \n",
    "                    'ro', markersize=15, zorder=5)\n",
    "        \n",
    "        ax2.set_xlabel('Iteration', fontsize=12, fontweight='bold')\n",
    "        ax2.set_ylabel('Inertia (Within-Cluster Sum of Squares)', fontsize=12, fontweight='bold')\n",
    "        ax2.set_title('Convergence: Inertia Decreases', fontsize=14, fontweight='bold')\n",
    "        ax2.grid(True, alpha=0.3)\n",
    "        ax2.set_xticks(range(1, len(history_demo1['inertia']) + 1))\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "# Create interactive widget\n",
    "iteration_slider = widgets.IntSlider(\n",
    "    value=0, min=0, max=max_iter_demo1, step=1,\n",
    "    description='Iteration:', continuous_update=False,\n",
    "    style={'description_width': '100px'},\n",
    "    layout=widgets.Layout(width='600px')\n",
    ")\n",
    "\n",
    "show_lines_checkbox = widgets.Checkbox(\n",
    "    value=False, description='Show connections to centroids',\n",
    "    style={'description_width': 'initial'}\n",
    ")\n",
    "\n",
    "def reset_kmeans(b):\n",
    "    global history_demo1, max_iter_demo1\n",
    "    random_state = np.random.randint(0, 1000)\n",
    "    history_demo1 = kmeans_step_by_step(X_demo1, K=3, random_state=random_state)\n",
    "    max_iter_demo1 = len(history_demo1['centroids']) - 1\n",
    "    iteration_slider.max = max_iter_demo1\n",
    "    iteration_slider.value = 0\n",
    "    print(f\"✓ Reset with new initialization (converged in {max_iter_demo1} iterations)\")\n",
    "\n",
    "reset_button = widgets.Button(\n",
    "    description='Reset with New Initialization',\n",
    "    button_style='warning',\n",
    "    icon='refresh'\n",
    ")\n",
    "reset_button.on_click(reset_kmeans)\n",
    "\n",
    "def autoplay(b):\n",
    "    import time\n",
    "    for i in range(max_iter_demo1 + 1):\n",
    "        iteration_slider.value = i\n",
    "        time.sleep(0.5)\n",
    "\n",
    "autoplay_button = widgets.Button(\n",
    "    description='Auto-play Convergence',\n",
    "    button_style='success',\n",
    "    icon='play'\n",
    ")\n",
    "autoplay_button.on_click(autoplay)\n",
    "\n",
    "controls = widgets.VBox([\n",
    "    iteration_slider,\n",
    "    show_lines_checkbox,\n",
    "    widgets.HBox([reset_button, autoplay_button])\n",
    "])\n",
    "\n",
    "interactive_plot = interactive(plot_kmeans_iteration, \n",
    "                              iteration=iteration_slider,\n",
    "                              show_lines=show_lines_checkbox)\n",
    "\n",
    "display(controls)\n",
    "display(interactive_plot.children[-1])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "a25d99f4a3f3416b8eb24e03516cbfa6",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "VBox(children=(HBox(children=(IntSlider(value=3, description='K (Clusters):', max=6, min=2), Button(button_sty…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# second example\n",
    "from IPython.display import display, clear_output\n",
    "from sklearn.metrics import pairwise_distances_argmin\n",
    "\n",
    "# --- 1. Generate Data ---\n",
    "X, y_true = make_blobs(n_samples=300, centers=4, cluster_std=0.8, random_state=42)\n",
    "\n",
    "# --- 2. The Interactive Class ---\n",
    "class KMeansStepper:\n",
    "    def __init__(self, X):\n",
    "        self.X = X\n",
    "        self.k = 3\n",
    "        self.centroids = None\n",
    "        self.labels = None\n",
    "        self.iteration = 0\n",
    "        self.history = []  # To store centroid paths\n",
    "        \n",
    "        # Setup UI\n",
    "        self.out = widgets.Output()\n",
    "        \n",
    "        # Widgets\n",
    "        self.slider_k = widgets.IntSlider(value=3, min=2, max=6, description='K (Clusters):')\n",
    "        self.btn_init = widgets.Button(description='Initialize / Reset', button_style='info')\n",
    "        self.btn_step = widgets.Button(description='Take 1 Step', button_style='success')\n",
    "        \n",
    "        # Event listeners\n",
    "        self.btn_init.on_click(self.reset)\n",
    "        self.btn_step.on_click(self.step)\n",
    "        self.slider_k.observe(self.on_k_change, names='value')\n",
    "        \n",
    "        self.reset(None)\n",
    "\n",
    "    def on_k_change(self, change):\n",
    "        self.k = change['new']\n",
    "        self.reset(None)\n",
    "\n",
    "    def reset(self, b):\n",
    "        # Pick random points as initial centroids\n",
    "        rng = np.random.RandomState(np.random.randint(1000))\n",
    "        i = rng.permutation(self.X.shape[0])[:self.k]\n",
    "        self.centroids = self.X[i]\n",
    "        \n",
    "        self.labels = np.zeros(self.X.shape[0])\n",
    "        self.iteration = 0\n",
    "        self.history = [self.centroids]\n",
    "        self.plot()\n",
    "\n",
    "    def step(self, b):\n",
    "        # 1. Expectation: Assign labels\n",
    "        self.labels = pairwise_distances_argmin(self.X, self.centroids)\n",
    "        \n",
    "        # 2. Maximization: Update centroids\n",
    "        new_centroids = np.array([self.X[self.labels == i].mean(0) \n",
    "                                  if np.sum(self.labels == i) > 0 \n",
    "                                  else self.centroids[i] \n",
    "                                  for i in range(self.k)])\n",
    "        \n",
    "        self.centroids = new_centroids\n",
    "        self.history.append(self.centroids)\n",
    "        self.iteration += 1\n",
    "        self.plot()\n",
    "\n",
    "    def plot(self):\n",
    "        with self.out:\n",
    "            clear_output(wait=True)\n",
    "            fig, ax = plt.subplots(figsize=(8, 6))\n",
    "            \n",
    "            # Plot data points colored by label\n",
    "            # If iteration 0 (just init), make them all gray\n",
    "            c_map = 'viridis' if self.iteration > 0 else None\n",
    "            color = self.labels if self.iteration > 0 else 'gray'\n",
    "            \n",
    "            ax.scatter(self.X[:, 0], self.X[:, 1], c=color, cmap=c_map, \n",
    "                       s=30, alpha=0.6, edgecolor='k', linewidth=0.5)\n",
    "            \n",
    "            # Plot Centroids\n",
    "            ax.scatter(self.centroids[:, 0], self.centroids[:, 1], \n",
    "                       c='red', marker='X', s=200, edgecolor='white', linewidth=2, label='Current Centroids')\n",
    "            \n",
    "            # Plot History (Movement trails)\n",
    "            if len(self.history) > 1:\n",
    "                hist_arr = np.array(self.history)\n",
    "                # Draw lines for each centroid\n",
    "                for k_idx in range(self.k):\n",
    "                    # Extract path for centroid k\n",
    "                    path = hist_arr[:, k_idx, :]\n",
    "                    ax.plot(path[:, 0], path[:, 1], 'k--', alpha=0.5)\n",
    "            \n",
    "            ax.set_title(f\"Iteration: {self.iteration}\", fontsize=16)\n",
    "            ax.legend()\n",
    "            plt.show()\n",
    "\n",
    "    def display(self):\n",
    "        controls = widgets.HBox([self.slider_k, self.btn_init, self.btn_step])\n",
    "        display(widgets.VBox([controls, self.out]))\n",
    "\n",
    "# Run Demo 1\n",
    "viz = KMeansStepper(X)\n",
    "viz.display()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Demo 2: The Elbow Method Interactive\n",
    "\n",
    "**Learning Objective**: Learn how to choose the optimal number of clusters K using the elbow method.\n",
    "\n",
    "**What to observe**:\n",
    "- How inertia decreases as K increases\n",
    "- The \"elbow point\" where the decrease slows down\n",
    "- How the elbow point corresponds to the natural number of clusters\n",
    "\n",
    "**Try this**:\n",
    "1. Change K and see how the clustering looks (left plot)\n",
    "2. Watch how your current K is highlighted on the elbow curve (right plot)\n",
    "3. Change \"True Clusters\" to see how the elbow shifts with different data\n",
    "4. Does the elbow always match the true number of clusters?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "9292100e5e7540409d7febc1ccbd9ab4",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "interactive(children=(IntSlider(value=3, continuous_update=False, description='K (clusters):', layout=Layout(w…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "def generate_elbow_data(n_clusters_true=3, random_state=42):\n",
    "    \"\"\"Generate data with specified number of true clusters\"\"\"\n",
    "    X, y = make_blobs(n_samples=400, centers=n_clusters_true, n_features=2,\n",
    "                      cluster_std=0.9, random_state=random_state)\n",
    "    return X, y\n",
    "\n",
    "def compute_inertias(X, K_range):\n",
    "    \"\"\"Compute inertia for different K values\"\"\"\n",
    "    inertias = []\n",
    "    for k in K_range:\n",
    "        kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)\n",
    "        kmeans.fit(X)\n",
    "        inertias.append(kmeans.inertia_)\n",
    "    return inertias\n",
    "\n",
    "# Initial data\n",
    "X_elbow, y_elbow = generate_elbow_data(n_clusters_true=3)\n",
    "K_range = range(1, 11)\n",
    "inertias_elbow = compute_inertias(X_elbow, K_range)\n",
    "\n",
    "def plot_elbow_interactive(K, n_clusters_true, show_elbow_annotation):\n",
    "    \"\"\"Interactive elbow method visualization\"\"\"\n",
    "    global X_elbow, inertias_elbow\n",
    "    \n",
    "    # Regenerate data if n_clusters_true changed\n",
    "    X_elbow, _ = generate_elbow_data(n_clusters_true=n_clusters_true)\n",
    "    inertias_elbow = compute_inertias(X_elbow, K_range)\n",
    "    \n",
    "    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))\n",
    "    \n",
    "    # Left plot: Clustering result for current K\n",
    "    kmeans = KMeans(n_clusters=K, random_state=42, n_init=10)\n",
    "    labels = kmeans.fit_predict(X_elbow)\n",
    "    \n",
    "    scatter = ax1.scatter(X_elbow[:, 0], X_elbow[:, 1], c=labels, \n",
    "                         cmap='viridis', s=50, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    ax1.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1],\n",
    "               marker='X', s=500, c='red', edgecolors='black', linewidth=3, zorder=5)\n",
    "    \n",
    "    ax1.set_xlabel('Feature 1', fontsize=12, fontweight='bold')\n",
    "    ax1.set_ylabel('Feature 2', fontsize=12, fontweight='bold')\n",
    "    ax1.set_title(f'Clustering with K={K}', fontsize=14, fontweight='bold')\n",
    "    ax1.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add info box\n",
    "    info_text = f'K = {K}\\nInertia = {kmeans.inertia_:.2f}\\nTrue clusters = {n_clusters_true}'\n",
    "    ax1.text(0.02, 0.98, info_text, transform=ax1.transAxes, \n",
    "            fontsize=11, verticalalignment='top',\n",
    "            bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))\n",
    "    \n",
    "    # Right plot: Elbow curve\n",
    "    ax2.plot(K_range, inertias_elbow, 'o-', linewidth=2, markersize=8,\n",
    "            color='steelblue', markerfacecolor='orange', markeredgewidth=2,\n",
    "            markeredgecolor='steelblue')\n",
    "    \n",
    "    # Highlight current K\n",
    "    ax2.plot(K, inertias_elbow[K-1], 'o', markersize=20, \n",
    "            color='red', markeredgewidth=3, markeredgecolor='darkred', zorder=5)\n",
    "    \n",
    "    # Show elbow annotation if requested\n",
    "    if show_elbow_annotation:\n",
    "        # Simple heuristic: find elbow using second derivative\n",
    "        inertias_array = np.array(inertias_elbow)\n",
    "        # Compute rate of change\n",
    "        diffs = np.diff(inertias_array)\n",
    "        second_diffs = np.diff(diffs)\n",
    "        elbow_k = np.argmax(second_diffs) + 2  # +2 due to double diff\n",
    "        \n",
    "        ax2.axvline(x=elbow_k, color='green', linestyle='--', linewidth=2, alpha=0.7)\n",
    "        ax2.annotate(f'Suggested Elbow\\nK = {elbow_k}', \n",
    "                    xy=(elbow_k, inertias_elbow[elbow_k-1]), \n",
    "                    xytext=(elbow_k + 1.5, inertias_elbow[elbow_k-1] + max(inertias_elbow)*0.15),\n",
    "                    arrowprops=dict(arrowstyle='->', color='green', lw=2),\n",
    "                    fontsize=11, fontweight='bold', color='green',\n",
    "                    bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.7))\n",
    "    \n",
    "    ax2.set_xlabel('Number of Clusters (K)', fontsize=12, fontweight='bold')\n",
    "    ax2.set_ylabel('Inertia (Within-Cluster Sum of Squares)', fontsize=12, fontweight='bold')\n",
    "    ax2.set_title('Elbow Method: Find the \"Bend\"', fontsize=14, fontweight='bold')\n",
    "    ax2.set_xticks(K_range)\n",
    "    ax2.grid(True, alpha=0.3, linestyle='--')\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "# Create widgets\n",
    "K_slider = widgets.IntSlider(\n",
    "    value=3, min=1, max=10, step=1,\n",
    "    description='K (clusters):',\n",
    "    continuous_update=False,\n",
    "    style={'description_width': '120px'},\n",
    "    layout=widgets.Layout(width='600px')\n",
    ")\n",
    "\n",
    "true_clusters_slider = widgets.IntSlider(\n",
    "    value=3, min=2, max=6, step=1,\n",
    "    description='True Clusters:',\n",
    "    continuous_update=False,\n",
    "    style={'description_width': '120px'},\n",
    "    layout=widgets.Layout(width='600px')\n",
    ")\n",
    "\n",
    "show_elbow_checkbox = widgets.Checkbox(\n",
    "    value=True, description='Show elbow annotation',\n",
    "    style={'description_width': 'initial'}\n",
    ")\n",
    "\n",
    "interact(plot_elbow_interactive, \n",
    "         K=K_slider,\n",
    "         n_clusters_true=true_clusters_slider,\n",
    "         show_elbow_annotation=show_elbow_checkbox);"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Demo 3: K-means Failure Cases\n",
    "\n",
    "**Learning Objective**: Understand when K-means struggles and why.\n",
    "\n",
    "**What to observe**:\n",
    "- K-means works perfectly on well-separated spherical clusters\n",
    "- Fails on non-convex shapes (half-moons, circles)\n",
    "- Struggles with elongated or different-density clusters\n",
    "- Voronoi boundaries show why: straight-line decisions only!\n",
    "\n",
    "**Try this**:\n",
    "1. Select different dataset types from dropdown\n",
    "2. Toggle \"Show Voronoi boundaries\" to see decision regions\n",
    "3. Change K and see if any value helps the bad cases\n",
    "4. Click \"Regenerate Data\" to try different random configurations"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "2449259f749e49e6a85fc63e0a86f5e4",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "VBox(children=(Dropdown(description='Dataset:', layout=Layout(width='500px'), options=('Well-separated spheric…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "f9de944778b44c578d397a7ed506dff0",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Output()"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "def generate_dataset(dataset_type, random_state=42):\n",
    "    \"\"\"Generate different types of datasets\"\"\"\n",
    "    if dataset_type == \"Well-separated spherical\":\n",
    "        X, y = make_blobs(n_samples=300, centers=3, n_features=2,\n",
    "                         cluster_std=0.6, random_state=random_state)\n",
    "        K_true = 3\n",
    "    elif dataset_type == \"Non-convex (half-moons)\":\n",
    "        X, y = make_moons(n_samples=300, noise=0.08, random_state=random_state)\n",
    "        K_true = 2\n",
    "    elif dataset_type == \"Concentric circles\":\n",
    "        X, y = make_circles(n_samples=300, noise=0.05, factor=0.5, random_state=random_state)\n",
    "        K_true = 2\n",
    "    elif dataset_type == \"Different densities\":\n",
    "        np.random.seed(random_state)\n",
    "        X1 = np.random.randn(200, 2) * 0.3 + np.array([0, 0])\n",
    "        X2 = np.random.randn(50, 2) * 0.3 + np.array([3, 3])\n",
    "        X3 = np.random.randn(300, 2) * 1.5 + np.array([5, -2])\n",
    "        X = np.vstack([X1, X2, X3])\n",
    "        y = np.array([0]*200 + [1]*50 + [2]*300)\n",
    "        K_true = 3\n",
    "    elif dataset_type == \"Elongated clusters\":\n",
    "        np.random.seed(random_state)\n",
    "        X1 = np.random.randn(150, 2) @ np.array([[3, 0], [0, 0.5]]) + np.array([0, 0])\n",
    "        X2 = np.random.randn(150, 2) @ np.array([[0.5, 0], [0, 3]]) + np.array([5, 5])\n",
    "        X = np.vstack([X1, X2])\n",
    "        y = np.array([0]*150 + [1]*150)\n",
    "        K_true = 2\n",
    "    return X, y, K_true\n",
    "\n",
    "def plot_voronoi_kmeans(kmeans, X, ax):\n",
    "    \"\"\"Plot Voronoi diagram for K-means clustering\"\"\"\n",
    "    try:\n",
    "        from scipy.spatial import Voronoi\n",
    "        vor = Voronoi(kmeans.cluster_centers_)\n",
    "        \n",
    "        # Plot Voronoi edges\n",
    "        for simplex in vor.ridge_vertices:\n",
    "            simplex = np.asarray(simplex)\n",
    "            if np.all(simplex >= 0):\n",
    "                ax.plot(vor.vertices[simplex, 0], vor.vertices[simplex, 1], \n",
    "                       'k--', alpha=0.4, linewidth=1.5)\n",
    "    except:\n",
    "        pass  # Skip if Voronoi fails\n",
    "\n",
    "current_random_state = 42\n",
    "\n",
    "def plot_failure_cases(dataset_type, K, show_voronoi):\n",
    "    \"\"\"Interactive failure cases visualization\"\"\"\n",
    "    global current_random_state\n",
    "    \n",
    "    X, y_true, K_true = generate_dataset(dataset_type, current_random_state)\n",
    "    \n",
    "    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))\n",
    "    \n",
    "    # Left: True structure (if available)\n",
    "    ax1.scatter(X[:, 0], X[:, 1], c=y_true, cmap='tab10', \n",
    "               s=50, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    ax1.set_xlabel('Feature 1', fontsize=12, fontweight='bold')\n",
    "    ax1.set_ylabel('Feature 2', fontsize=12, fontweight='bold')\n",
    "    ax1.set_title(f'True Structure (K={K_true})', fontsize=14, fontweight='bold')\n",
    "    ax1.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Right: K-means result\n",
    "    kmeans = KMeans(n_clusters=K, random_state=42, n_init=10)\n",
    "    labels_kmeans = kmeans.fit_predict(X)\n",
    "    \n",
    "    ax2.scatter(X[:, 0], X[:, 1], c=labels_kmeans, cmap='viridis',\n",
    "               s=50, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    ax2.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1],\n",
    "               marker='X', s=500, c='red', edgecolors='black', linewidth=3, zorder=5)\n",
    "    \n",
    "    if show_voronoi:\n",
    "        plot_voronoi_kmeans(kmeans, X, ax2)\n",
    "    \n",
    "    ax2.set_xlabel('Feature 1', fontsize=12, fontweight='bold')\n",
    "    ax2.set_ylabel('Feature 2', fontsize=12, fontweight='bold')\n",
    "    ax2.set_title(f'K-means Result (K={K})', fontsize=14, fontweight='bold')\n",
    "    ax2.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add assessment\n",
    "    if dataset_type == \"Well-separated spherical\":\n",
    "        assessment = \"✅ K-means works great here!\"\n",
    "        color = 'lightgreen'\n",
    "    else:\n",
    "        assessment = \"❌ K-means struggles with this structure\"\n",
    "        color = 'lightcoral'\n",
    "    \n",
    "    ax2.text(0.5, 0.02, assessment, transform=ax2.transAxes,\n",
    "            fontsize=12, fontweight='bold', ha='center',\n",
    "            bbox=dict(boxstyle='round', facecolor=color, alpha=0.8))\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "def regenerate_data(b):\n",
    "    global current_random_state\n",
    "    current_random_state = np.random.randint(0, 1000)\n",
    "    print(f\"✓ Data regenerated with random_state={current_random_state}\")\n",
    "\n",
    "# Create widgets\n",
    "dataset_dropdown = widgets.Dropdown(\n",
    "    options=[\"Well-separated spherical\", \"Non-convex (half-moons)\", \n",
    "             \"Concentric circles\", \"Different densities\", \"Elongated clusters\"],\n",
    "    value=\"Well-separated spherical\",\n",
    "    description='Dataset:',\n",
    "    style={'description_width': '100px'},\n",
    "    layout=widgets.Layout(width='500px')\n",
    ")\n",
    "\n",
    "K_slider_failures = widgets.IntSlider(\n",
    "    value=2, min=2, max=5, step=1,\n",
    "    description='K:',\n",
    "    continuous_update=False,\n",
    "    style={'description_width': '100px'},\n",
    "    layout=widgets.Layout(width='400px')\n",
    ")\n",
    "\n",
    "voronoi_checkbox = widgets.Checkbox(\n",
    "    value=False, description='Show Voronoi boundaries',\n",
    "    style={'description_width': 'initial'}\n",
    ")\n",
    "\n",
    "regenerate_button = widgets.Button(\n",
    "    description='Regenerate Data',\n",
    "    button_style='info',\n",
    "    icon='refresh'\n",
    ")\n",
    "regenerate_button.on_click(regenerate_data)\n",
    "\n",
    "controls = widgets.VBox([\n",
    "    dataset_dropdown,\n",
    "    K_slider_failures,\n",
    "    voronoi_checkbox,\n",
    "    regenerate_button\n",
    "])\n",
    "\n",
    "interactive_failures = interactive(plot_failure_cases,\n",
    "                                  dataset_type=dataset_dropdown,\n",
    "                                  K=K_slider_failures,\n",
    "                                  show_voronoi=voronoi_checkbox)\n",
    "\n",
    "display(controls)\n",
    "display(interactive_failures.children[-1])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Demo 4: Dendrogram Interactive Explorer\n",
    "\n",
    "**Learning Objective**: Understand dendrograms and how cutting at different heights gives different numbers of clusters.\n",
    "\n",
    "**What to observe**:\n",
    "- The tree structure shows merge hierarchy\n",
    "- Cutting at different heights gives different K values\n",
    "- Different linkage methods create different trees\n",
    "- Large vertical gaps suggest natural separations\n",
    "\n",
    "**Try this**:\n",
    "1. Move the \"Cutting Height\" slider and watch clusters form/merge\n",
    "2. Count how many vertical lines the red line crosses = K\n",
    "3. Change linkage method - see how tree structure changes\n",
    "4. Find the best cutting height by looking for large vertical gaps"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "d722d450e8fd41d7a7e9580ae453bfd0",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "interactive(children=(FloatSlider(value=3.0, continuous_update=False, description='Cutting Height:', layout=La…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Generate hierarchical data\n",
    "X_hier, y_hier = make_blobs(n_samples=50, centers=3, n_features=2,\n",
    "                            cluster_std=0.6, random_state=42)\n",
    "\n",
    "def plot_dendrogram_interactive(cutting_height, linkage_method):\n",
    "    \"\"\"Interactive dendrogram visualization\"\"\"\n",
    "    fig = plt.figure(figsize=(16, 6))\n",
    "    gs = fig.add_gridspec(1, 2, width_ratios=[1.2, 1])\n",
    "    ax1 = fig.add_subplot(gs[0])\n",
    "    ax2 = fig.add_subplot(gs[1])\n",
    "    \n",
    "    # Compute linkage\n",
    "    Z = linkage(X_hier, method=linkage_method)\n",
    "    \n",
    "    # Plot dendrogram\n",
    "    dendro = dendrogram(Z, ax=ax1, color_threshold=cutting_height, \n",
    "                       above_threshold_color='gray')\n",
    "    \n",
    "    # Add cutting line\n",
    "    ax1.axhline(y=cutting_height, color='red', linestyle='--', linewidth=3, \n",
    "               label=f'Cut at height {cutting_height:.2f}')\n",
    "    \n",
    "    ax1.set_xlabel('Data Point Index', fontsize=12, fontweight='bold')\n",
    "    ax1.set_ylabel('Distance (Dissimilarity)', fontsize=12, fontweight='bold')\n",
    "    ax1.set_title(f'Dendrogram ({linkage_method.capitalize()} Linkage)', \n",
    "                 fontsize=14, fontweight='bold')\n",
    "    ax1.legend(fontsize=11)\n",
    "    ax1.grid(True, axis='y', alpha=0.3)\n",
    "    \n",
    "    # Get clusters at this cutting height\n",
    "    hc = AgglomerativeClustering(n_clusters=None, distance_threshold=cutting_height,\n",
    "                                 linkage=linkage_method)\n",
    "    labels_hier = hc.fit_predict(X_hier)\n",
    "    n_clusters = len(np.unique(labels_hier))\n",
    "    \n",
    "    # Plot scatter with clusters\n",
    "    scatter = ax2.scatter(X_hier[:, 0], X_hier[:, 1], c=labels_hier, \n",
    "                         cmap='tab10', s=100, alpha=0.7, edgecolors='k', linewidth=1)\n",
    "    \n",
    "    ax2.set_xlabel('Feature 1', fontsize=12, fontweight='bold')\n",
    "    ax2.set_ylabel('Feature 2', fontsize=12, fontweight='bold')\n",
    "    ax2.set_title(f'Resulting Clusters (K={n_clusters})', fontsize=14, fontweight='bold')\n",
    "    ax2.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add info box\n",
    "    info_text = f'Cutting Height: {cutting_height:.2f}\\nNumber of Clusters: {n_clusters}\\nLinkage: {linkage_method}'\n",
    "    ax2.text(0.02, 0.98, info_text, transform=ax2.transAxes,\n",
    "            fontsize=11, verticalalignment='top',\n",
    "            bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "# Create widgets\n",
    "height_slider = widgets.FloatSlider(\n",
    "    value=3.0, min=0.5, max=8.0, step=0.1,\n",
    "    description='Cutting Height:',\n",
    "    continuous_update=False,\n",
    "    readout_format='.2f',\n",
    "    style={'description_width': '120px'},\n",
    "    layout=widgets.Layout(width='600px')\n",
    ")\n",
    "\n",
    "linkage_dropdown = widgets.Dropdown(\n",
    "    options=['single', 'complete', 'average', 'ward'],\n",
    "    value='average',\n",
    "    description='Linkage:',\n",
    "    style={'description_width': '120px'},\n",
    "    layout=widgets.Layout(width='400px')\n",
    ")\n",
    "\n",
    "interact(plot_dendrogram_interactive,\n",
    "         cutting_height=height_slider,\n",
    "         linkage_method=linkage_dropdown);"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Demo 5: Feature Scaling Impact\n",
    "\n",
    "**Learning Objective**: Understand why feature scaling is critical for K-means.\n",
    "\n",
    "**What to observe**:\n",
    "- Without scaling: Feature 2 (large range) dominates the clustering\n",
    "- With scaling: Both features contribute equally\n",
    "- Cluster centers move dramatically when scaling is applied\n",
    "- Results can be completely different!\n",
    "\n",
    "**Try this**:\n",
    "1. Toggle \"Apply Scaling\" on and off\n",
    "2. Look at the centroid coordinates - see how different?\n",
    "3. Observe how cluster boundaries change\n",
    "4. Change K to see if scaling matters more for some K values"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "d1de3ad14d46492f80c804fd48245846",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "interactive(children=(IntSlider(value=3, continuous_update=False, description='K:', layout=Layout(width='400px…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Generate data with different scales\n",
    "np.random.seed(42)\n",
    "X_scale_1 = np.random.randn(150, 2) * np.array([0.5, 100]) + np.array([2, 500])\n",
    "X_scale_2 = np.random.randn(150, 2) * np.array([0.5, 100]) + np.array([5, 800])\n",
    "X_scale_3 = np.random.randn(150, 2) * np.array([0.5, 100]) + np.array([8, 300])\n",
    "X_scaling = np.vstack([X_scale_1, X_scale_2, X_scale_3])\n",
    "\n",
    "def plot_scaling_impact(K, apply_scaling):\n",
    "    \"\"\"Demonstrate impact of feature scaling\"\"\"\n",
    "    fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n",
    "    \n",
    "    # Prepare data\n",
    "    if apply_scaling:\n",
    "        scaler = StandardScaler()\n",
    "        X_plot = scaler.fit_transform(X_scaling)\n",
    "        title_suffix = \"(WITH Scaling)\"\n",
    "        color = 'lightgreen'\n",
    "    else:\n",
    "        X_plot = X_scaling.copy()\n",
    "        title_suffix = \"(NO Scaling)\"\n",
    "        color = 'lightcoral'\n",
    "    \n",
    "    # Fit K-means\n",
    "    kmeans = KMeans(n_clusters=K, random_state=42, n_init=10)\n",
    "    labels = kmeans.fit_predict(X_plot)\n",
    "    centers = kmeans.cluster_centers_\n",
    "    \n",
    "    # Plot clustering result\n",
    "    axes[0].scatter(X_plot[:, 0], X_plot[:, 1], c=labels, cmap='viridis',\n",
    "                   s=50, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    axes[0].scatter(centers[:, 0], centers[:, 1], marker='X', s=500,\n",
    "                   c='red', edgecolors='black', linewidth=3, zorder=5)\n",
    "    \n",
    "    axes[0].set_xlabel('Feature 1 (Small range)', fontsize=12, fontweight='bold')\n",
    "    axes[0].set_ylabel('Feature 2 (Large range)', fontsize=12, fontweight='bold')\n",
    "    axes[0].set_title(f'K-means Clustering {title_suffix}', fontsize=14, fontweight='bold')\n",
    "    axes[0].grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add centroid coordinates\n",
    "    coord_text = \"Centroids:\\n\"\n",
    "    for i, center in enumerate(centers):\n",
    "        coord_text += f\"C{i+1}: ({center[0]:.2f}, {center[1]:.2f})\\n\"\n",
    "    \n",
    "    axes[0].text(0.02, 0.98, coord_text, transform=axes[0].transAxes,\n",
    "                fontsize=10, verticalalignment='top',\n",
    "                bbox=dict(boxstyle='round', facecolor=color, alpha=0.8))\n",
    "    \n",
    "    # Plot feature ranges\n",
    "    feature_ranges = [\n",
    "        ['Feature 1', X_plot[:, 0].min(), X_plot[:, 0].max(), X_plot[:, 0].std()],\n",
    "        ['Feature 2', X_plot[:, 1].min(), X_plot[:, 1].max(), X_plot[:, 1].std()]\n",
    "    ]\n",
    "    \n",
    "    axes[1].axis('off')\n",
    "    \n",
    "    # Create text display\n",
    "    info_text = f\"**Feature Statistics {title_suffix}**\\n\\n\"\n",
    "    info_text += f\"Feature 1 (e.g., normalized spending):  \\n\"\n",
    "    info_text += f\"  Range: [{X_plot[:, 0].min():.2f}, {X_plot[:, 0].max():.2f}]\\n\"\n",
    "    info_text += f\"  Std Dev: {X_plot[:, 0].std():.2f}\\n\\n\"\n",
    "    \n",
    "    info_text += f\"Feature 2 (e.g., raw visit count):  \\n\"\n",
    "    info_text += f\"  Range: [{X_plot[:, 1].min():.2f}, {X_plot[:, 1].max():.2f}]\\n\"\n",
    "    info_text += f\"  Std Dev: {X_plot[:, 1].std():.2f}\\n\\n\"\n",
    "    \n",
    "    if apply_scaling:\n",
    "        explanation = \"✅ Both features contribute equally\\n\"\n",
    "        explanation += \"✅ Balanced influence on clustering\\n\"\n",
    "        explanation += \"✅ Both features have similar ranges\"\n",
    "        box_color = 'lightgreen'\n",
    "    else:\n",
    "        explanation = \"⚠️ Feature 2 DOMINATES clustering\\n\"\n",
    "        explanation += \"⚠️ Feature 1 barely affects results\\n\"\n",
    "        explanation += \"⚠️ Large range difference = imbalanced\"\n",
    "        box_color = 'lightcoral'\n",
    "    \n",
    "    axes[1].text(0.1, 0.7, info_text, fontsize=12, verticalalignment='top',\n",
    "                family='monospace',\n",
    "                bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))\n",
    "    \n",
    "    axes[1].text(0.1, 0.3, explanation, fontsize=13, verticalalignment='top',\n",
    "                fontweight='bold',\n",
    "                bbox=dict(boxstyle='round', facecolor=box_color, alpha=0.9))\n",
    "    \n",
    "    axes[1].set_title('Impact Analysis', fontsize=14, fontweight='bold')\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "# Create widgets\n",
    "K_slider_scaling = widgets.IntSlider(\n",
    "    value=3, min=2, max=5, step=1,\n",
    "    description='K:',\n",
    "    continuous_update=False,\n",
    "    style={'description_width': '100px'},\n",
    "    layout=widgets.Layout(width='400px')\n",
    ")\n",
    "\n",
    "scaling_checkbox = widgets.Checkbox(\n",
    "    value=False, description='Apply StandardScaler (IMPORTANT!)',\n",
    "    style={'description_width': 'initial'}\n",
    ")\n",
    "\n",
    "interact(plot_scaling_impact,\n",
    "         K=K_slider_scaling,\n",
    "         apply_scaling=scaling_checkbox);"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Demo 6: K-means vs Hierarchical Comparison\n",
    "\n",
    "**Learning Objective**: Compare K-means and hierarchical clustering side-by-side.\n",
    "\n",
    "**What to observe**:\n",
    "- Both methods can give different results on same data\n",
    "- K-means is faster (check computation time!)\n",
    "- Hierarchical provides the dendrogram (extra information)\n",
    "- Different linkage methods affect hierarchical results\n",
    "\n",
    "**Try this**:\n",
    "1. Change K - both methods update\n",
    "2. Look at computation times displayed\n",
    "3. Try different linkage methods for hierarchical\n",
    "4. Click \"Randomize Data\" to test on different patterns\n",
    "5. For which datasets do they agree? Disagree?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "72b4c80b2d704f84a66a2365d7420e89",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "VBox(children=(IntSlider(value=3, continuous_update=False, description='K:', layout=Layout(width='400px'), max…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "11bbc5aa36794e09bd17a426d024af3b",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Output()"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import time\n",
    "\n",
    "# Generate comparison data\n",
    "X_compare, y_compare = make_blobs(n_samples=200, centers=3, n_features=2,\n",
    "                                  cluster_std=0.8, random_state=42)\n",
    "\n",
    "comparison_random_state = 42\n",
    "\n",
    "def plot_comparison(K, linkage_method, show_centers):\n",
    "    \"\"\"Compare K-means and hierarchical clustering\"\"\"\n",
    "    global X_compare, comparison_random_state\n",
    "    \n",
    "    X_compare, _ = make_blobs(n_samples=200, centers=3, n_features=2,\n",
    "                              cluster_std=0.8, random_state=comparison_random_state)\n",
    "    \n",
    "    fig = plt.figure(figsize=(18, 6))\n",
    "    gs = fig.add_gridspec(1, 3, width_ratios=[1, 1, 1])\n",
    "    ax1 = fig.add_subplot(gs[0])\n",
    "    ax2 = fig.add_subplot(gs[1])\n",
    "    ax3 = fig.add_subplot(gs[2])\n",
    "    \n",
    "    # K-means\n",
    "    start_time = time.time()\n",
    "    kmeans = KMeans(n_clusters=K, random_state=42, n_init=10)\n",
    "    labels_kmeans = kmeans.fit_predict(X_compare)\n",
    "    kmeans_time = time.time() - start_time\n",
    "    \n",
    "    ax1.scatter(X_compare[:, 0], X_compare[:, 1], c=labels_kmeans, cmap='viridis',\n",
    "               s=60, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    \n",
    "    if show_centers:\n",
    "        ax1.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1],\n",
    "                   marker='X', s=500, c='red', edgecolors='black', linewidth=3, zorder=5)\n",
    "    \n",
    "    ax1.set_xlabel('Feature 1', fontsize=11, fontweight='bold')\n",
    "    ax1.set_ylabel('Feature 2', fontsize=11, fontweight='bold')\n",
    "    ax1.set_title(f'K-means (K={K})\\nTime: {kmeans_time*1000:.2f} ms', \n",
    "                 fontsize=13, fontweight='bold')\n",
    "    ax1.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Hierarchical\n",
    "    start_time = time.time()\n",
    "    hc = AgglomerativeClustering(n_clusters=K, linkage=linkage_method)\n",
    "    labels_hier = hc.fit_predict(X_compare)\n",
    "    hier_time = time.time() - start_time\n",
    "    \n",
    "    ax2.scatter(X_compare[:, 0], X_compare[:, 1], c=labels_hier, cmap='viridis',\n",
    "               s=60, alpha=0.6, edgecolors='k', linewidth=0.5)\n",
    "    \n",
    "    ax2.set_xlabel('Feature 1', fontsize=11, fontweight='bold')\n",
    "    ax2.set_ylabel('Feature 2', fontsize=11, fontweight='bold')\n",
    "    ax2.set_title(f'Hierarchical ({linkage_method})\\nTime: {hier_time*1000:.2f} ms', \n",
    "                 fontsize=13, fontweight='bold')\n",
    "    ax2.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Dendrogram\n",
    "    Z = linkage(X_compare, method=linkage_method)\n",
    "    dendrogram(Z, ax=ax3, no_labels=True, color_threshold=0,\n",
    "              above_threshold_color='steelblue')\n",
    "    \n",
    "    ax3.set_xlabel('Data Points', fontsize=11, fontweight='bold')\n",
    "    ax3.set_ylabel('Distance', fontsize=11, fontweight='bold')\n",
    "    ax3.set_title('Dendrogram\\n(Hierarchical Only)', fontsize=13, fontweight='bold')\n",
    "    ax3.grid(True, axis='y', alpha=0.3)\n",
    "    \n",
    "    # Speed comparison\n",
    "    speedup = hier_time / kmeans_time\n",
    "    fig.text(0.5, 0.02, \n",
    "            f'Speed Comparison: K-means is {speedup:.1f}x faster on this dataset (n=200 points)',\n",
    "            ha='center', fontsize=12, fontweight='bold',\n",
    "            bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))\n",
    "    \n",
    "    plt.tight_layout(rect=[0, 0.05, 1, 1])\n",
    "    plt.show()\n",
    "\n",
    "def randomize_comparison_data(b):\n",
    "    global comparison_random_state\n",
    "    comparison_random_state = np.random.randint(0, 1000)\n",
    "    print(f\"✓ New dataset generated (random_state={comparison_random_state})\")\n",
    "\n",
    "# Create widgets\n",
    "K_slider_compare = widgets.IntSlider(\n",
    "    value=3, min=2, max=7, step=1,\n",
    "    description='K:',\n",
    "    continuous_update=False,\n",
    "    style={'description_width': '100px'},\n",
    "    layout=widgets.Layout(width='400px')\n",
    ")\n",
    "\n",
    "linkage_dropdown_compare = widgets.Dropdown(\n",
    "    options=['single', 'complete', 'average', 'ward'],\n",
    "    value='average',\n",
    "    description='Linkage:',\n",
    "    style={'description_width': '100px'},\n",
    "    layout=widgets.Layout(width='400px')\n",
    ")\n",
    "\n",
    "centers_checkbox = widgets.Checkbox(\n",
    "    value=True, description='Show K-means centroids',\n",
    "    style={'description_width': 'initial'}\n",
    ")\n",
    "\n",
    "randomize_compare_button = widgets.Button(\n",
    "    description='Randomize Data',\n",
    "    button_style='warning',\n",
    "    icon='random'\n",
    ")\n",
    "randomize_compare_button.on_click(randomize_comparison_data)\n",
    "\n",
    "controls = widgets.VBox([\n",
    "    K_slider_compare,\n",
    "    linkage_dropdown_compare,\n",
    "    centers_checkbox,\n",
    "    randomize_compare_button\n",
    "])\n",
    "\n",
    "interactive_compare = interactive(plot_comparison,\n",
    "                                 K=K_slider_compare,\n",
    "                                 linkage_method=linkage_dropdown_compare,\n",
    "                                 show_centers=centers_checkbox)\n",
    "\n",
    "display(controls)\n",
    "display(interactive_compare.children[-1])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "# Summary: What These Demos taught Us\n",
    "\n",
    "## Key Takeaways\n",
    "\n",
    "1. **K-means is iterative**: It always makes progress and converges by alternating assignment and update steps\n",
    "\n",
    "2. **The elbow method works**: Look for the bend in the inertia curve to choose K\n",
    "\n",
    "3. **K-means has limitations**: Fails on non-convex shapes, different densities, and elongated clusters\n",
    "\n",
    "4. **Dendrograms are powerful**: Cut at different heights to explore multiple clustering scales\n",
    "\n",
    "5. **Scaling is CRITICAL**: Always standardize features before clustering!\n",
    "\n",
    "6. **Choose the right tool**: K-means for speed and known K; hierarchical for exploration\n",
    "\n",
    "---\n",
    "\n",
    "## Practice Exercises\n",
    "\n",
    "Now that you've explored these concepts interactively, try:\n",
    "\n",
    "1. Generate your own data with `make_blobs()` and apply both algorithms\n",
    "2. Load a real dataset and perform clustering\n",
    "3. Compare results with and without scaling\n",
    "4. Use the elbow method to choose K on your data\n",
    "\n",
    "**Remember**: Clustering is exploratory - there's often no single \"right\" answer!\n",
    "\n",
    "---\n",
    "\n",
    "**Questions? Come to office hours or post on the discussion forum!**\n",
    "\n",
    "**Dr. Wei Xing**  \n",
    "Office: Hicks Building I22  \n",
    "Office Hours: Tuesday 12:00-1:00 pm"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "expTorch",
   "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.10.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
