{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "7ab0a3be",
   "metadata": {},
   "source": [
    "# Day 3 — The Pattern Finder 🔍\n",
    "\n",
    "> **Mission briefing:** A crate just landed in the Lab with no label — thousands of measurements and *nobody* to tell us what they mean. No answer key, no teacher. Your job: find the hidden structure in the data on your own. This is **unsupervised learning**, and by tonight it'll power your Studio's newest module.\n",
    "\n",
    "**Previously in the Lab:** yesterday you trained the 🐧 **Prediction Machine** — a classifier that learned penguin species *from labeled examples*. Today, the labels are gone.\n",
    "\n",
    "**Today you will:**\n",
    "- See the difference between **supervised** (with an answer key) and **unsupervised** (without) learning\n",
    "- Run **k-means** to discover groups no one told the machine about\n",
    "- Watch the machine **rediscover the penguin species** with zero labels\n",
    "- Use the **elbow method** to guess how many groups exist\n",
    "- Build a **photo color-compressor** with k-means\n",
    "- Squash 4-D and 64-D data into 2-D pictures with **PCA** and **t-SNE**\n",
    "\n",
    "**Today you unlock:** 🔓 **Pattern Finder** — upload any photo and it finds the dominant colors, repainting the image with just a handful of them.\n",
    "\n",
    "<details data-resource-precision=\"3\"><summary>A precise reading</summary><p>K-means minimizes squared distances to centroids and may settle at a local solution. Cluster numbers are arbitrary, and clusters need not equal real species. The elbow is a heuristic. PCA preserves variance, not all information; t-SNE emphasizes local neighborhoods, so distances between islands and island sizes are not reliable measures of the original data.</p></details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ac339af7",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ╔═══════════════════════════════════════════════════════════════════╗\n",
    "# ║ SETUP — run me first! (identical in every Lab notebook)           ║\n",
    "# ╚═══════════════════════════════════════════════════════════════════╝\n",
    "import os, sys, pathlib\n",
    "\n",
    "IN_COLAB = \"google.colab\" in sys.modules\n",
    "SMOKE = os.environ.get(\"SMOKE_TEST\", \"0\") == \"1\"   # tiny fast run for automated tests\n",
    "\n",
    "REPO_URL = \"https://github.com/A-Halimi/ai-studio-internship.git\"  # instructor: set once\n",
    "\n",
    "if IN_COLAB:\n",
    "    if not pathlib.Path(\"/content/ai-studio-internship\").exists():\n",
    "        !git clone -q {REPO_URL} /content/ai-studio-internship\n",
    "    %cd /content/ai-studio-internship/notebooks/day03\n",
    "\n",
    "HERE = pathlib.Path.cwd()\n",
    "REPO = HERE.parents[1]                       # .../notebooks/dayNN -> repo root\n",
    "DATA_DIR = pathlib.Path(os.environ.get(\"COURSE_DATA_DIR\", REPO / \"data\"))\n",
    "SAMPLES_DIR = REPO / \"data_samples\"          # small datasets shipped with the repo\n",
    "MODELS_DIR = REPO / \"ai_studio\" / \"models\"   # where your Studio modules unlock\n",
    "for p in (REPO / \"data\", MODELS_DIR):\n",
    "    p.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "import random\n",
    "import numpy as np\n",
    "SEED = 42\n",
    "random.seed(SEED); np.random.seed(SEED)\n",
    "print(f\"✅ Setup done | Colab: {IN_COLAB} | Smoke test: {SMOKE}\")\n",
    "print(f\"   data: {DATA_DIR}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d86b5bc3",
   "metadata": {},
   "source": [
    "## 1 · Two ways to learn\n",
    "\n",
    "Yesterday every penguin came with its species written on the tag. The machine's job was easy to *grade*: guess the species, peek at the tag, correct itself. That's **supervised learning** — learning **with an answer key**.\n",
    "\n",
    "Today the tags are gone. We have measurements, but nothing to check our guesses against. So we change the question. Instead of *\"what species is this?\"* we ask:\n",
    "\n",
    "> **\"Which of these penguins resemble each other?\"**\n",
    "\n",
    "Finding groups and structure **without any labels** is **unsupervised learning**. There's no single \"right answer\" to copy — the machine has to notice patterns by itself.\n",
    "\n",
    "It's not a toy. Unsupervised learning is how real systems:\n",
    "- **group customers** into look-alike audiences no one defined in advance,\n",
    "- **flag anomalies** — a fraudulent transaction is the point that fits no group,\n",
    "- **compress images** by replacing millions of colors with a handful (you'll do exactly this today).\n",
    "\n",
    "Let's open the crate."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6ad360f6",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "# The crate: penguin measurements, with the species column still attached so\n",
    "# we can peek LATER — but the machine will never get to see it.\n",
    "penguins = pd.read_csv(SAMPLES_DIR / \"penguins.csv\").dropna().reset_index(drop=True)\n",
    "print(f\"{len(penguins)} penguins in the crate\")\n",
    "penguins.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1408600c",
   "metadata": {},
   "source": [
    "## 2 · How k-means thinks — the pizza-shop story\n",
    "\n",
    "Imagine three pizza shops opening in a new city. Nobody has a map of where customers live, so the shops play a simple game, over and over:\n",
    "\n",
    "1. **Assign:** every customer orders from the *nearest* shop.\n",
    "2. **Update:** each shop moves to the *center* of the customers it just served.\n",
    "\n",
    "Do that a few times and the shops drift until they sit right in the middle of three natural neighborhoods. Customers stop switching. Done.\n",
    "\n",
    "That's **k-means**. Swap \"pizza shop\" for **cluster center** (a *centroid*) and \"customer\" for **data point** and you have the whole algorithm. The `k` is how many shops (clusters) you decide to place.\n",
    "\n",
    "Let's watch it happen on two penguin measurements — **bill length** and **flipper length** — standardized so neither one dominates just because it's measured in bigger numbers."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "61170d0f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Two measurements, standardized (mean 0, spread 1) so both count equally.\n",
    "feat2 = penguins[[\"bill_length_mm\", \"flipper_length_mm\"]].to_numpy(dtype=float)\n",
    "X2 = (feat2 - feat2.mean(axis=0)) / feat2.std(axis=0)\n",
    "\n",
    "# This one is INTERACTIVE: press ▶ play (or drag the slider one round at a\n",
    "# time) and watch the red ✕'s drift to the middle of their groups.\n",
    "try:\n",
    "    import plotly.graph_objects as go\n",
    "except ImportError:                      # not installed? grab it, then retry\n",
    "    %pip install -q plotly\n",
    "    import plotly.graph_objects as go\n",
    "\n",
    "# Start the 3 \"shops\" (centroids) at 3 random penguins.\n",
    "rng = np.random.default_rng(SEED)\n",
    "centroids = X2[rng.choice(len(X2), size=3, replace=False)].copy()\n",
    "\n",
    "# Play the assign/update game for 8 rounds, saving a snapshot after each ASSIGN.\n",
    "snapshots = []\n",
    "for step in range(8):\n",
    "    # 1) ASSIGN: each point joins its nearest centroid\n",
    "    dists = np.linalg.norm(X2[:, None, :] - centroids[None, :, :], axis=2)  # (N, 3)\n",
    "    labels = dists.argmin(axis=1)\n",
    "    snapshots.append((labels.copy(), centroids.copy()))\n",
    "    # 2) UPDATE: each centroid hops to the mean of its assigned points\n",
    "    for j in range(3):\n",
    "        if np.any(labels == j):\n",
    "            centroids[j] = X2[labels == j].mean(axis=0)\n",
    "\n",
    "CLUSTER_COLORS = [\"#440154\", \"#21918c\", \"#fde725\"]      # viridis-style\n",
    "\n",
    "\n",
    "def frame_traces(labels, cents):\n",
    "    return [\n",
    "        go.Scatter(x=X2[:, 0], y=X2[:, 1], mode=\"markers\",\n",
    "                   marker=dict(size=6, color=[CLUSTER_COLORS[l] for l in labels]),\n",
    "                   hoverinfo=\"skip\", name=\"penguins\"),\n",
    "        go.Scatter(x=cents[:, 0], y=cents[:, 1], mode=\"markers\",\n",
    "                   marker=dict(size=18, color=\"red\", symbol=\"x\",\n",
    "                               line=dict(color=\"black\", width=2)),\n",
    "                   hoverinfo=\"skip\", name=\"centroids\"),\n",
    "    ]\n",
    "\n",
    "\n",
    "frames = [go.Frame(data=frame_traces(l, c), name=str(i))\n",
    "          for i, (l, c) in enumerate(snapshots)]\n",
    "\n",
    "fig = go.Figure(data=frame_traces(*snapshots[0]), frames=frames)\n",
    "fig.update_layout(\n",
    "    title=\"k-means converging — press ▶ and watch the red ✕'s find their groups\",\n",
    "    xaxis=dict(title=\"bill length (standardized)\"),\n",
    "    yaxis=dict(title=\"flipper length (standardized)\"),\n",
    "    updatemenus=[dict(\n",
    "        type=\"buttons\", x=0.05, xanchor=\"left\", y=1.15,\n",
    "        buttons=[dict(label=\"▶ play\", method=\"animate\",\n",
    "                      args=[None, {\"frame\": {\"duration\": 900, \"redraw\": True},\n",
    "                                   \"transition\": {\"duration\": 500}}])],\n",
    "    )],\n",
    "    sliders=[dict(\n",
    "        currentvalue=dict(prefix=\"iteration \"),\n",
    "        steps=[dict(label=str(i), method=\"animate\",\n",
    "                    args=[[str(i)], {\"mode\": \"immediate\",\n",
    "                                     \"frame\": {\"duration\": 0, \"redraw\": True}}])\n",
    "               for i in range(len(frames))],\n",
    "    )],\n",
    "    showlegend=False, width=700, height=550,\n",
    ")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7b44cb5f",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 1 — Let scikit-learn do the clustering\n",
    "\n",
    "You watched the loop by hand; now use the real thing. `KMeans` runs those assign/update steps for you until the centroids stop moving.\n",
    "\n",
    "- Create `KMeans(n_clusters=3, n_init=10, random_state=SEED)` and fit it to `X2`.\n",
    "- Store each point's cluster number in `clusters`.\n",
    "\n",
    "`n_init=10` just means \"try 10 random starts and keep the best\" — k-means can land in a bad spot if it starts unlucky. You should see three clean blobs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "39ab3d61",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.cluster import KMeans\n",
    "import plotly.graph_objects as go\n",
    "\n",
    "km = KMeans(n_clusters=3, n_init=10, random_state=SEED)\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: km.fit_predict(X2) fits the model AND returns a cluster number per point\n",
    "...\n",
    "\n",
    "# Interactive result — hover any dot to read that penguin's REAL measurements.\n",
    "hover_data = np.column_stack([feat2, clusters])\n",
    "fig = go.Figure()\n",
    "fig.add_trace(go.Scatter(\n",
    "    x=X2[:, 0], y=X2[:, 1], mode=\"markers\",\n",
    "    marker=dict(size=6, color=[CLUSTER_COLORS[c] for c in clusters]),\n",
    "    customdata=hover_data,\n",
    "    hovertemplate=(\"bill %{customdata[0]:.1f} mm · \"\n",
    "                   \"flipper %{customdata[1]:.0f} mm<br>\"\n",
    "                   \"cluster %{customdata[2]:.0f}<extra></extra>\"),\n",
    "))\n",
    "fig.add_trace(go.Scatter(\n",
    "    x=km.cluster_centers_[:, 0], y=km.cluster_centers_[:, 1], mode=\"markers\",\n",
    "    marker=dict(size=18, color=\"red\", symbol=\"x\", line=dict(color=\"black\", width=2)),\n",
    "    hovertemplate=\"centroid<extra></extra>\",\n",
    "))\n",
    "fig.update_layout(\n",
    "    title=\"k-means found 3 groups — with no labels (hover the dots!)\",\n",
    "    xaxis=dict(title=\"bill length (standardized)\"),\n",
    "    yaxis=dict(title=\"flipper length (standardized)\"),\n",
    "    showlegend=False, width=650, height=520,\n",
    ")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ba3a2eb6",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 2 — The reveal: did it find the species?\n",
    "\n",
    "Here's the moment. The machine never saw a single species label — it only grouped penguins by shape. So did its groups mean anything?\n",
    "\n",
    "- Build a table comparing the machine's `clusters` against the **true species** with `pd.crosstab`.\n",
    "- Read it: does each cluster line up mostly with one species?\n",
    "\n",
    "Use `pd.crosstab(clusters, species_true, ...)`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dda02068",
   "metadata": {},
   "outputs": [],
   "source": [
    "species_true = penguins[\"species\"].to_numpy()\n",
    "\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: pd.crosstab(clusters, species_true) counts how often each pairing occurs\n",
    "...\n",
    "print(comparison)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "reveal-viz-md",
   "metadata": {},
   "source": [
    "### 👀 Where exactly does it go wrong?\n",
    "\n",
    "The table gives you the *counts* — now let's **see** them. Below are the same penguins twice:\n",
    "\n",
    "- **Left:** colored by the machine's clusters. Each cluster borrows the color of the species it mostly captured (majority vote, read straight off your crosstab), so both panels speak the same color language.\n",
    "- **Right:** colored by the true species.\n",
    "\n",
    "Every penguin the machine put in the **wrong** group wears a **red ring** on *both* sides. Hover a ringed dot: almost all of them sit right on the border between two overlapping species — the machine wasn't being silly, those penguins really are ambiguous in just these two measurements.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "reveal-viz-code",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotly.subplots import make_subplots\n",
    "\n",
    "# Name each cluster after the species it mostly contains — majority vote,\n",
    "# read straight off the crosstab you just built.\n",
    "majority = comparison.idxmax(axis=1)                 # cluster number → species name\n",
    "predicted_species = majority.loc[clusters].to_numpy()\n",
    "wrong = predicted_species != species_true\n",
    "print(f\"{wrong.sum()} of {len(penguins)} penguins landed in the 'wrong' cluster\")\n",
    "\n",
    "# Same color for a cluster and the species it captured → panels are comparable.\n",
    "species_color = {sp: CLUSTER_COLORS[cl] for cl, sp in majority.items()}\n",
    "\n",
    "fig = make_subplots(\n",
    "    rows=1, cols=2, shared_yaxes=True, horizontal_spacing=0.06,\n",
    "    subplot_titles=(\"what the machine guessed\", \"the truth (species tags)\"))\n",
    "\n",
    "# LEFT — machine's view, one trace per cluster\n",
    "for cl in sorted(np.unique(clusters)):\n",
    "    m = clusters == cl\n",
    "    fig.add_trace(go.Scatter(\n",
    "        x=X2[m, 0], y=X2[m, 1], mode=\"markers\",\n",
    "        marker=dict(size=6, color=CLUSTER_COLORS[cl]),\n",
    "        name=f'cluster {cl} → \"{majority[cl]}\"', legendgroup=f\"c{cl}\",\n",
    "        customdata=feat2[m],\n",
    "        hovertemplate=(\"bill %{customdata[0]:.1f} mm · \"\n",
    "                       \"flipper %{customdata[1]:.0f} mm<br>\"\n",
    "                       f\"cluster {cl}<extra></extra>\"),\n",
    "    ), row=1, col=1)\n",
    "\n",
    "# RIGHT — the truth, one trace per species\n",
    "for sp in comparison.columns:\n",
    "    m = species_true == sp\n",
    "    fig.add_trace(go.Scatter(\n",
    "        x=X2[m, 0], y=X2[m, 1], mode=\"markers\",\n",
    "        marker=dict(size=6, color=species_color[sp]),\n",
    "        name=sp, legendgroup=sp,\n",
    "        customdata=feat2[m],\n",
    "        hovertemplate=(\"bill %{customdata[0]:.1f} mm · \"\n",
    "                       \"flipper %{customdata[1]:.0f} mm<br>\"\n",
    "                       f\"{sp}<extra></extra>\"),\n",
    "    ), row=1, col=2)\n",
    "\n",
    "# THE CONFUSED ONES — a red ring on BOTH panels, hover to see the mix-up\n",
    "wrong_hover = np.column_stack([feat2[wrong],\n",
    "                               predicted_species[wrong], species_true[wrong]])\n",
    "for col in (1, 2):\n",
    "    fig.add_trace(go.Scatter(\n",
    "        x=X2[wrong, 0], y=X2[wrong, 1], mode=\"markers\",\n",
    "        marker=dict(size=13, symbol=\"circle-open\", color=\"red\",\n",
    "                    line=dict(width=2)),\n",
    "        name=\"⭕ machine got it wrong\", legendgroup=\"wrong\",\n",
    "        showlegend=(col == 1),\n",
    "        customdata=wrong_hover,\n",
    "        hovertemplate=(\"bill %{customdata[0]:.1f} mm · \"\n",
    "                       \"flipper %{customdata[1]:.0f} mm<br>\"\n",
    "                       \"machine said <b>%{customdata[2]}</b>, \"\n",
    "                       \"actually <b>%{customdata[3]}</b><extra></extra>\"),\n",
    "    ), row=1, col=col)\n",
    "\n",
    "fig.update_layout(\n",
    "    title=\"Machine's guess vs the truth — red rings mark the penguins it confused\",\n",
    "    width=980, height=520,\n",
    "    legend=dict(orientation=\"h\", y=-0.18),\n",
    ")\n",
    "fig.update_xaxes(title_text=\"bill length (standardized)\", row=1, col=1)\n",
    "fig.update_xaxes(title_text=\"bill length (standardized)\", row=1, col=2)\n",
    "fig.update_yaxes(title_text=\"flipper length (standardized)\", row=1, col=1)\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7f0f5370",
   "metadata": {},
   "source": [
    "**Sit with that for a second.** Nobody told the machine what a \"Gentoo\" is. It just noticed that some penguins clump together by bill and flipper size — and those clumps *are* the species. The structure was hiding in the numbers all along; k-means only had to look.\n",
    "\n",
    "(The match isn't perfect — a few penguins sit on the border between groups, and two of the species really do overlap in just these two measurements. Later we'll hand k-means *all four* measurements and the picture sharpens.)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "48f88e55",
   "metadata": {},
   "source": [
    "## 3 · How many groups are there, really?\n",
    "\n",
    "We *told* k-means to find 3. But what if we didn't know? Real mystery crates don't come with the number of groups printed on the side.\n",
    "\n",
    "Here's a trick. **Inertia** measures how tightly points hug their centroid — smaller is tighter. Add more clusters and inertia always drops (with one cluster per point it hits zero). But watch *how fast* it drops: there's usually a `k` where adding another cluster suddenly stops helping much. The curve bends like an elbow, and that bend is nature's hint at the true number of groups.\n",
    "\n",
    "### 🧪 Exercise 3 — Find the elbow\n",
    "\n",
    "- For each `k` from 1 to 8, fit `KMeans` and record its `.inertia_`.\n",
    "- Plot inertia versus `k` and look for the bend.\n",
    "\n",
    "You should see the curve drop steeply, then flatten around **k = 3** — exactly the number of penguin species."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8c7fefa2",
   "metadata": {},
   "outputs": [],
   "source": [
    "ks = range(1, 9)\n",
    "inertias = []\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: after fitting, km_k.inertia_ is the tightness number to append\n",
    "...\n",
    "\n",
    "import plotly.graph_objects as go\n",
    "\n",
    "fig = go.Figure(go.Scatter(\n",
    "    x=list(ks), y=inertias, mode=\"lines+markers\", marker=dict(size=9),\n",
    "    hovertemplate=\"k = %{x}<br>inertia = %{y:.0f}<extra></extra>\",\n",
    "))\n",
    "fig.add_annotation(x=3, y=inertias[2], text=\"the elbow 👈\", ax=70, ay=-45,\n",
    "                   showarrow=True, arrowhead=2)\n",
    "fig.update_layout(\n",
    "    title=\"The elbow method — hover the points, find the bend\",\n",
    "    xaxis=dict(title=\"k (number of clusters)\"),\n",
    "    yaxis=dict(title=\"inertia (lower = tighter clusters)\"),\n",
    "    width=650, height=430,\n",
    ")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fffc10cb",
   "metadata": {},
   "source": [
    "## 4 · The visual wow: a photo is just a pile of colors\n",
    "\n",
    "Time for the module you're unlocking. Here's a fresh way to look at an image: **a color is just a point in 3-D space** — its red, green, and blue amounts. A photo with a million pixels is a cloud of a million points floating in that RGB cube.\n",
    "\n",
    "So what happens if we run k-means on the pixels of a photo? It finds the `k` most representative colors — the photo's **palette** — and we repaint every pixel with its nearest palette color. Fewer colors, almost the same picture. That's **color quantization**, and it's a genuine form of image compression.\n",
    "\n",
    "Let's grab a sample photo that ships with scikit-learn (no download needed)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7b767014",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.datasets import load_sample_image\n",
    "\n",
    "china = load_sample_image(\"china.jpg\") / 255.0     # scale RGB from 0..255 to 0..1\n",
    "print(\"image shape:\", china.shape, \" (height, width, RGB)\")\n",
    "\n",
    "pixels = china.reshape(-1, 3)                        # one row per pixel\n",
    "print(\"that's\", len(pixels), \"pixels, each a point in 3-D color space\")\n",
    "\n",
    "\n",
    "def palette_bar(centers, labels, width=400, height=60):\n",
    "    \"\"\"Draw the palette as a horizontal bar, widest (most common) color first.\"\"\"\n",
    "    counts = np.bincount(labels, minlength=len(centers))\n",
    "    order = np.argsort(-counts)\n",
    "    bar = np.zeros((height, width, 3))\n",
    "    x = 0\n",
    "    for idx in order:\n",
    "        w = int(round(counts[idx] / counts.sum() * width))\n",
    "        bar[:, x:x + w] = centers[idx]\n",
    "        x += w\n",
    "    bar[:, x:] = centers[order[-1]]                  # fill any rounding gap\n",
    "    return bar\n",
    "\n",
    "\n",
    "plt.figure(figsize=(6, 4))\n",
    "plt.imshow(china)\n",
    "plt.title(\"the original photo\")\n",
    "plt.axis(\"off\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ef5cd381",
   "metadata": {},
   "source": [
    "### 👀 See it first: every pixel is a dot in a 3-D color cube\n",
    "\n",
    "Before we run any clustering, let's actually *look* at what k-means will see.\n",
    "\n",
    "A pixel is just 3 numbers — how much **Red**, **Green**, and **Blue** it contains. So every pixel is a **point in 3-D space**: the RGB color cube. The corner (0, 0, 0) is black, (1, 1, 1) is white, and every color lives somewhere in between.\n",
    "\n",
    "Our photo has 427 × 640 = **273,280 pixels**, which means 273,280 dots floating in that cube. This is exactly like the penguin scatter plot — except each \"penguin\" is now a pixel, and instead of 2 measurements (bill, flipper) it has 3 (R, G, B).\n",
    "\n",
    "And just like the penguins, **there are no labels**. Nobody tells the machine \"this pixel is sky\" or \"this pixel is roof.\" But the dots still form natural clumps — because a real photo doesn't use random colors. A sky produces hundreds of thousands of pixels that are all *nearly the same blue*, so they pile up in one region of the cube. That hidden clumping is what k-means will discover on its own — the definition of **unsupervised learning**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "997a3c42",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Peek at the pixel cloud: 4,000 random pixels inside the RGB cube.\n",
    "# This plot is INTERACTIVE — drag to rotate 🖱️, scroll to zoom, hover a dot to\n",
    "# read its exact R/G/B values.\n",
    "try:\n",
    "    import plotly.graph_objects as go\n",
    "except ImportError:                      # not installed? grab it, then retry\n",
    "    %pip install -q plotly\n",
    "    import plotly.graph_objects as go\n",
    "\n",
    "rng_view = np.random.default_rng(SEED)\n",
    "peek = pixels[rng_view.choice(len(pixels), size=4_000, replace=False)]\n",
    "peek255 = (peek * 255).astype(int)                      # 0..255 for hover text\n",
    "dot_colors = [f\"rgb({r},{g},{b})\" for r, g, b in peek255]\n",
    "\n",
    "fig = go.Figure(go.Scatter3d(\n",
    "    x=peek[:, 0], y=peek[:, 1], z=peek[:, 2],\n",
    "    mode=\"markers\",\n",
    "    marker=dict(size=2.5, color=dot_colors),\n",
    "    customdata=peek255,\n",
    "    hovertemplate=\"R %{customdata[0]} · G %{customdata[1]} · B %{customdata[2]}<extra></extra>\",\n",
    "))\n",
    "fig.update_layout(\n",
    "    title=\"the photo's pixels in the RGB color cube — drag me to rotate!\",\n",
    "    scene=dict(\n",
    "        xaxis=dict(title=\"Red\", range=[0, 1]),\n",
    "        yaxis=dict(title=\"Green\", range=[0, 1]),\n",
    "        zaxis=dict(title=\"Blue\", range=[0, 1]),\n",
    "        aspectmode=\"cube\",\n",
    "    ),\n",
    "    width=750, height=650, margin=dict(l=0, r=0, t=50, b=0),\n",
    ")\n",
    "fig.show()\n",
    "\n",
    "print(\"Rotate the cube and look for the clumps:\")\n",
    "print(\"  • a pale blue/white clump near the (1,1,1) corner → the SKY pixels\")\n",
    "print(\"  • a dark clump near (0,0,0)                       → SHADOW pixels\")\n",
    "print(\"  • a greenish clump in the middle                  → VEGETATION pixels\")\n",
    "print(\"Nobody labeled them — the clumps exist because photos reuse similar colors.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f92f7c16",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 4 — Compress the photo to 8 colors\n",
    "\n",
    "Fitting k-means on all ~270,000 pixels is slow, so we use a classic shortcut: **fit on a random sample, then predict every pixel.** The palette barely changes, but it runs in a snap.\n",
    "\n",
    "- Fit `KMeans(n_clusters=8, n_init=4, random_state=SEED)` on the 20,000-pixel `sample`.\n",
    "- Use the fitted model to `.predict` a cluster for **every** pixel in `pixels` (store it in `labels_all`).\n",
    "\n",
    "Then run the given code to repaint the photo. Eight colors — see how close it still looks."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4773efbb",
   "metadata": {},
   "outputs": [],
   "source": [
    "rng = np.random.default_rng(SEED)\n",
    "sample = pixels[rng.choice(len(pixels), size=20_000, replace=False)]\n",
    "\n",
    "km_img = KMeans(n_clusters=8, n_init=4, random_state=SEED)\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: fit on `sample` (fast), then km_img.predict(pixels) to label ALL pixels\n",
    "...\n",
    "\n",
    "palette = km_img.cluster_centers_\n",
    "repainted = palette[labels_all].reshape(china.shape)\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))\n",
    "axes[0].imshow(china);     axes[0].set_title(\"original (thousands of colors)\")\n",
    "axes[1].imshow(repainted); axes[1].set_title(\"repainted with only 8 colors\")\n",
    "for ax in axes:\n",
    "    ax.axis(\"off\")\n",
    "plt.show()\n",
    "\n",
    "plt.figure(figsize=(6, 1.2))\n",
    "plt.imshow(palette_bar(palette, labels_all))\n",
    "plt.title(\"the 8-color palette k-means chose\")\n",
    "plt.axis(\"off\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a6f860ae",
   "metadata": {},
   "source": [
    "### 👀 Where did those 8 colors come from? Back to the cube\n",
    "\n",
    "Remember the pixel cloud from before? Let's look at it again — but now with the 8 centroids k-means just found, drawn as red ✕'s. **This is the pizza-shop game, played inside the color cube:** each ✕ drifted until it sat in the middle of a clump of similar colors. The centroid of a clump is the *average color* of its pixels — and those 8 averages are exactly the palette.\n",
    "\n",
    "**Try this:** rotate the cube to a nice angle, then click **AFTER**. Every pixel *snaps* to its nearest ✕ (the gray threads show a few of them flying home) and the whole cloud collapses onto just 8 points. **That snap is quantization** — and it's why the repainted photo has only 8 colors. Flip BEFORE/AFTER a few times and watch which colors get merged together."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c471f911",
   "metadata": {},
   "outputs": [],
   "source": [
    "# One interactive cube, two views — use the BEFORE / AFTER buttons on top!\n",
    "#   BEFORE: every pixel in its true color, plus the 8 centroids (red ✕).\n",
    "#   AFTER:  every pixel snapped to its nearest centroid — only 8 colors left.\n",
    "#           Thin gray threads show a few pixels \"flying\" to their centroid.\n",
    "# The camera keeps its angle when you switch, so rotate first, then toggle.\n",
    "import plotly.graph_objects as go\n",
    "\n",
    "peek_labels = km_img.predict(peek)              # palette color of each peeked pixel\n",
    "snapped = palette[peek_labels]\n",
    "snapped_colors = [f\"rgb({r},{g},{b})\" for r, g, b in (snapped * 255).astype(int)]\n",
    "\n",
    "# a few \"snap threads\" from pixel → its centroid (drawing all 4,000 would be a hairball)\n",
    "thread_idx = rng_view.choice(len(peek), size=120, replace=False)\n",
    "xs, ys, zs = [], [], []\n",
    "for i in thread_idx:\n",
    "    c = palette[peek_labels[i]]\n",
    "    xs += [peek[i, 0], c[0], None]\n",
    "    ys += [peek[i, 1], c[1], None]\n",
    "    zs += [peek[i, 2], c[2], None]\n",
    "\n",
    "trace_true = go.Scatter3d(                       # 0 — BEFORE cloud\n",
    "    x=peek[:, 0], y=peek[:, 1], z=peek[:, 2], mode=\"markers\",\n",
    "    marker=dict(size=2.5, color=dot_colors), name=\"true colors\",\n",
    "    hovertemplate=\"R %{customdata[0]} · G %{customdata[1]} · B %{customdata[2]}<extra></extra>\",\n",
    "    customdata=peek255,\n",
    ")\n",
    "trace_quant = go.Scatter3d(                      # 1 — AFTER cloud (hidden at start)\n",
    "    x=peek[:, 0], y=peek[:, 1], z=peek[:, 2], mode=\"markers\",\n",
    "    marker=dict(size=2.5, color=snapped_colors), name=\"quantized\",\n",
    "    hoverinfo=\"skip\", visible=False,\n",
    ")\n",
    "trace_threads = go.Scatter3d(                    # 2 — snap threads (hidden at start)\n",
    "    x=xs, y=ys, z=zs, mode=\"lines\",\n",
    "    line=dict(color=\"rgba(120,120,120,0.4)\", width=1),\n",
    "    name=\"snap\", hoverinfo=\"skip\", visible=False,\n",
    ")\n",
    "trace_centroids = go.Scatter3d(                  # 3 — the 8 palette centroids\n",
    "    x=palette[:, 0], y=palette[:, 1], z=palette[:, 2], mode=\"markers\",\n",
    "    marker=dict(size=8, color=\"red\", symbol=\"x\", line=dict(color=\"black\", width=2)),\n",
    "    name=\"palette (centroids)\",\n",
    "    hovertemplate=\"palette color %{pointNumber}<extra></extra>\",\n",
    ")\n",
    "\n",
    "fig = go.Figure([trace_true, trace_quant, trace_threads, trace_centroids])\n",
    "fig.update_layout(\n",
    "    title=\"quantization, live: rotate the cube, then press AFTER and watch the cloud collapse\",\n",
    "    updatemenus=[dict(\n",
    "        type=\"buttons\", direction=\"right\", x=0.5, xanchor=\"center\", y=1.10,\n",
    "        buttons=[\n",
    "            dict(label=\"BEFORE — true colors\",\n",
    "                 method=\"update\", args=[{\"visible\": [True, False, False, True]}]),\n",
    "            dict(label=\"AFTER — snapped to 8 colors\",\n",
    "                 method=\"update\", args=[{\"visible\": [False, True, True, True]}]),\n",
    "        ],\n",
    "    )],\n",
    "    scene=dict(\n",
    "        xaxis=dict(title=\"Red\", range=[0, 1]),\n",
    "        yaxis=dict(title=\"Green\", range=[0, 1]),\n",
    "        zaxis=dict(title=\"Blue\", range=[0, 1]),\n",
    "        aspectmode=\"cube\",\n",
    "    ),\n",
    "    uirevision=\"keep-camera\",\n",
    "    showlegend=False,\n",
    "    width=750, height=650, margin=dict(l=0, r=0, t=80, b=0),\n",
    ")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3ba79f91",
   "metadata": {},
   "source": [
    "### 💾 Why is this \"compression\"? Let's count the bits\n",
    "\n",
    "The repainted photo *looks* almost the same — but what did we actually save? Count what needs to be stored:\n",
    "\n",
    "**Before (the original):** every pixel stores its exact color = 3 numbers (R, G, B), each needing 8 bits. That's **24 bits per pixel**.\n",
    "\n",
    "**After (quantized to k = 8):** every pixel only needs to say *which palette color it uses* — a number from 0 to 7. Since 2³ = 8, that number fits in just **3 bits per pixel**. On top we store the palette itself (8 colors × 24 bits), which is tiny — a few dozen bytes for the whole photo.\n",
    "\n",
    "From 24 bits down to 3 bits per pixel ≈ **8× smaller**, and the picture is still perfectly recognizable. This isn't a toy trick: it's genuinely how **GIF images** and old 256-color computer displays worked (256 colors = 2⁸ → 8 bits per pixel instead of 24).\n",
    "\n",
    "Run the cell below to see the real numbers for our photo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0a2038dc",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Let's count the bits ourselves — no hand-waving.\n",
    "n_pixels = len(pixels)\n",
    "k = 8\n",
    "\n",
    "# BEFORE: every pixel stores 3 channels × 8 bits = 24 bits\n",
    "original_bits = n_pixels * 3 * 8\n",
    "\n",
    "# AFTER: every pixel stores only a palette number 0..7 → 3 bits (2**3 = 8)\n",
    "label_bits = int(np.ceil(np.log2(k)))          # bits needed per pixel label\n",
    "palette_bits = k * 3 * 8                       # the 8 palette colors themselves\n",
    "compressed_bits = n_pixels * label_bits + palette_bits\n",
    "\n",
    "print(f\"pixels in the photo:      {n_pixels:,}\")\n",
    "print()\n",
    "print(f\"BEFORE  — {n_pixels:,} px × 24 bits          = {original_bits:>9,} bits  ≈ {original_bits/8/1024:,.0f} KB\")\n",
    "print(f\"AFTER   — {n_pixels:,} px × {label_bits} bits + palette = {compressed_bits:>9,} bits  ≈ {compressed_bits/8/1024:,.0f} KB\")\n",
    "print(f\"          (the whole palette costs only {palette_bits} bits ≈ {palette_bits//8} bytes!)\")\n",
    "print()\n",
    "print(f\"compression ratio: {original_bits / compressed_bits:.1f}× smaller 🎉\")\n",
    "print()\n",
    "print(\"Try it: how many bits per pixel would k = 16 need? k = 256?\")\n",
    "print(\"(hint: 2**4 = 16, 2**8 = 256 — this is exactly how GIF images work)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1126523d",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 5 — How few colors can you get away with?\n",
    "\n",
    "Let's make `k` a dial. The `quantize` helper below repaints the photo with any number of colors. You fill in the one line that displays each version.\n",
    "\n",
    "- Inside the loop, call `quantize(k)` and show it with `ax.imshow(...)`.\n",
    "- Compare k = 3, 8, and 16.\n",
    "\n",
    "Then answer in the text cell below: what do you *lose* at k = 3? What barely changes between k = 8 and k = 16?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f68d516b",
   "metadata": {},
   "outputs": [],
   "source": [
    "def quantize(k, n_sample=20_000):\n",
    "    \"\"\"Repaint `china` using only k colors. Returns an (H, W, 3) image.\"\"\"\n",
    "    samp = pixels[rng.choice(len(pixels), size=n_sample, replace=False)]\n",
    "    model = KMeans(n_clusters=k, n_init=4, random_state=SEED).fit(samp)\n",
    "    return model.cluster_centers_[model.predict(pixels)].reshape(china.shape)\n",
    "\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(12, 4))\n",
    "for ax, k in zip(axes, [3, 8, 16]):\n",
    "    # ==================== YOUR CODE HERE ====================\n",
    "    ### HINT: quantize(k) returns the repainted image; hand it to ax.imshow(...)\n",
    "    ...\n",
    "    ax.set_title(f\"k = {k} colors\")\n",
    "    ax.axis(\"off\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "643a286c",
   "metadata": {},
   "source": [
    "**Your turn to describe** *(double-click to edit):*\n",
    "\n",
    "- At **k = 3**, the photo loses ...\n",
    "- Between **k = 8** and **k = 16**, ...\n",
    "\n",
    "💡 You just built a photo compressor — and the engine behind your Studio's 🎨 **Pattern Finder** module. Same k-means, running live on whatever photo you upload."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fc2e5149",
   "metadata": {},
   "source": [
    "## 5 · Seeing in 4-D: PCA casts a shadow\n",
    "\n",
    "Our penguins actually have **four** measurements, not two. But a screen is flat — how do you plot a 4-D animal?\n",
    "\n",
    "Think of shadows. A 3-D object casts a 2-D shadow on the wall, and if you rotate it to *just the right angle*, the shadow keeps almost everything that makes the shape recognizable. **PCA** (Principal Component Analysis) finds that best angle automatically: it squashes many dimensions down to a few while throwing away as little information as possible.\n",
    "\n",
    "### 🧪 Exercise 6 — Flatten 4-D penguins onto a 2-D page\n",
    "\n",
    "- Standardize all **four** measurements, then fit `PCA(n_components=2)` and transform them into `coords`.\n",
    "- We'll color the picture by the *true* species — but only to check the result. **PCA never sees the labels;** it arranges the penguins from the measurements alone.\n",
    "- Print `explained_variance_ratio_` and its sum: the fraction of the original information the 2-D shadow kept.\n",
    "\n",
    "You should keep well over 80% of the variance in just two numbers per penguin."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "907aefa2",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.decomposition import PCA\n",
    "import plotly.graph_objects as go\n",
    "\n",
    "feat4 = penguins[[\"bill_length_mm\", \"bill_depth_mm\",\n",
    "                  \"flipper_length_mm\", \"body_mass_g\"]].to_numpy(dtype=float)\n",
    "X4 = StandardScaler().fit_transform(feat4)\n",
    "\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: pca.fit_transform(X4) returns the 2-D coordinates\n",
    "...\n",
    "\n",
    "# Interactive: hover a dot to see the 4 ORIGINAL measurements hiding behind\n",
    "# each 2-D point; click a species in the legend to hide/show it.\n",
    "species_codes, species_labels = pd.factorize(penguins[\"species\"])\n",
    "fig = go.Figure()\n",
    "for c, name in enumerate(species_labels):\n",
    "    m = species_codes == c\n",
    "    fig.add_trace(go.Scatter(\n",
    "        x=coords[m, 0], y=coords[m, 1], mode=\"markers\", name=name,\n",
    "        marker=dict(size=6),\n",
    "        customdata=feat4[m],\n",
    "        hovertemplate=(name + \"<br>bill %{customdata[0]:.1f} mm · \"\n",
    "                       \"depth %{customdata[1]:.1f} mm<br>\"\n",
    "                       \"flipper %{customdata[2]:.0f} mm · \"\n",
    "                       \"mass %{customdata[3]:.0f} g<extra></extra>\"),\n",
    "    ))\n",
    "fig.update_layout(\n",
    "    title=(\"4 measurements squashed into 2 — colored by true species\"\n",
    "           \"<br><sup>hover a dot for its original 4 numbers · click the legend to hide a species</sup>\"),\n",
    "    xaxis=dict(title=\"principal component 1\"),\n",
    "    yaxis=dict(title=\"principal component 2\"),\n",
    "    width=750, height=550,\n",
    ")\n",
    "fig.show()\n",
    "\n",
    "print(\"information kept per axis:\", np.round(pca.explained_variance_ratio_, 3))\n",
    "print(\"total information kept:   \", round(float(pca.explained_variance_ratio_.sum()), 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ef6a3b7b",
   "metadata": {},
   "source": [
    "## 6 · A map of handwriting with t-SNE\n",
    "\n",
    "PCA is fast and honest, but it can only *rotate and flatten* — straight-line moves. Some data is tangled in ways that need a curvier map. **t-SNE** builds one: it pulls similar points close together and shoves different ones apart, untangling structure PCA can miss. It's the go-to tool for *seeing* high-dimensional data.\n",
    "\n",
    "Let's aim it at something with 64 dimensions: tiny 8×8 images of handwritten digits (built right into scikit-learn — no download)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b8851141",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.datasets import load_digits\n",
    "\n",
    "digits = load_digits()\n",
    "print(digits.images.shape, \"→ 1797 images, each 8×8 pixels (64 numbers)\")\n",
    "\n",
    "fig, axes = plt.subplots(2, 8, figsize=(10, 2.8))\n",
    "for ax, img, label in zip(axes.flat, digits.images, digits.target):\n",
    "    ax.imshow(img, cmap=\"gray_r\")\n",
    "    ax.set_title(str(label))\n",
    "    ax.axis(\"off\")\n",
    "fig.suptitle(\"a handful of the digits\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "64c1ca3c",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.manifold import TSNE\n",
    "\n",
    "# t-SNE is slower than PCA. Under a smoke test we use only the first 400 images.\n",
    "n_show = 400 if SMOKE else len(digits.data)\n",
    "X_digits = digits.data[:n_show]\n",
    "y_digits = digits.target[:n_show]\n",
    "\n",
    "print(f\"Running t-SNE on {n_show} images… this takes ~20-40s on CPU. Hang tight ☕\")\n",
    "tsne = TSNE(n_components=2, random_state=SEED, perplexity=30)\n",
    "embedding = tsne.fit_transform(X_digits)\n",
    "print(\"Done — here comes the map.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dbac6f6a",
   "metadata": {},
   "source": [
    "### 🧪 Exercise 7 — Draw the map\n",
    "\n",
    "`embedding` holds a 2-D point for each digit image. Color each point by which digit it actually is and see whether t-SNE sorted them.\n",
    "\n",
    "- For each digit `d` from 0 to 9: make a mask `m = y_digits == d`, then add a `go.Scatter` trace of `embedding[m, 0]` vs `embedding[m, 1]` with `name=str(d)`.\n",
    "- One trace per digit gives you a **clickable legend**: click a digit to hide it, **double-click to isolate its island**.\n",
    "\n",
    "You should see roughly ten islands — one per digit — that nobody told t-SNE about."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7644ba02",
   "metadata": {},
   "outputs": [],
   "source": [
    "import plotly.graph_objects as go\n",
    "\n",
    "fig = go.Figure()\n",
    "# ==================== YOUR CODE HERE ====================\n",
    "### HINT: loop d in range(10); mask m = y_digits == d; add a go.Scatter of embedding[m] with name=str(d)\n",
    "...\n",
    "fig.update_layout(\n",
    "    title=(\"1797 handwritten digits, sorted into islands with zero labels\"\n",
    "           \"<br><sup>hover to identify an island · click a legend digit to hide it · double-click to isolate it</sup>\"),\n",
    "    xaxis=dict(title=\"t-SNE axis 1\"),\n",
    "    yaxis=dict(title=\"t-SNE axis 2\"),\n",
    "    legend_title=\"digit\", width=800, height=620,\n",
    ")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b8cefe76",
   "metadata": {},
   "source": [
    "**1797 images, 64 pixels each — and the map sorts them into islands, one per digit, without ever being told what a digit is.** The pixels alone carry the structure; t-SNE just makes it visible.\n",
    "\n",
    "Soon you'll go one step further and **train a network to actually read these digits.**"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a39ee91f",
   "metadata": {},
   "source": [
    "## 7 · Optional: UMAP, t-SNE's faster cousin\n",
    "\n",
    "**UMAP** does a similar untangling job, often faster and better at keeping the big-picture layout. It's an extra library, so this cell skips itself politely if UMAP isn't installed — no worries either way."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1b2b54c8",
   "metadata": {},
   "outputs": [],
   "source": [
    "try:\n",
    "    import umap  # optional — present in the Lab's Docker image, maybe not on Colab\n",
    "    import plotly.graph_objects as go\n",
    "\n",
    "    reducer = umap.UMAP(n_components=2, random_state=SEED)\n",
    "    umap_emb = reducer.fit_transform(X_digits)\n",
    "\n",
    "    fig = go.Figure()\n",
    "    for d in range(10):\n",
    "        m = y_digits == d\n",
    "        fig.add_trace(go.Scatter(x=umap_emb[m, 0], y=umap_emb[m, 1],\n",
    "                                 mode=\"markers\", name=str(d), marker=dict(size=5),\n",
    "                                 hovertemplate=f\"digit {d}<extra></extra>\"))\n",
    "    fig.update_layout(\n",
    "        title=\"the same digits, mapped by UMAP — same legend tricks apply\",\n",
    "        xaxis=dict(title=\"UMAP axis 1\"),\n",
    "        yaxis=dict(title=\"UMAP axis 2\"),\n",
    "        legend_title=\"digit\", width=800, height=620,\n",
    "    )\n",
    "    fig.show()\n",
    "except ImportError:\n",
    "    print(\"UMAP isn't installed here — that's totally fine, skip this one. 🙂\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3760093b",
   "metadata": {},
   "source": [
    "## 🔓 Unlock your Studio module\n",
    "\n",
    "You've earned it. Save your settings and the 🎨 **Pattern Finder** switches on in your AI Studio. Put your name on your work and pick the number of colors it should reach for by default."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8e61d75b",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "\n",
    "# ✏️ Make it yours:\n",
    "MADE_BY = \"your name here\"     # ← type your name between the quotes\n",
    "DEFAULT_K = 8                  # ← default number of color clusters (2–12)\n",
    "\n",
    "config_path = MODELS_DIR / \"pattern_finder.json\"\n",
    "with open(config_path, \"w\", encoding=\"utf-8\") as f:\n",
    "    json.dump({\"default_k\": DEFAULT_K, \"made_by\": MADE_BY}, f, indent=2)\n",
    "\n",
    "# make sure the Studio will find it\n",
    "assert config_path.exists(), \"pattern_finder.json did not save!\"\n",
    "print(\"🔓 UNLOCKED: Pattern Finder!\")\n",
    "print(f\"   saved {config_path.name}  (default_k={DEFAULT_K}, made_by={MADE_BY})\")\n",
    "print(\"   Run your Studio to try it live on any photo:\")\n",
    "print(\"   python ai_studio/app.py   (from the repo root)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6b990a2a",
   "metadata": {},
   "source": [
    "## 🏁 Checkpoint\n",
    "\n",
    "Look what you can do now — with **no answer key at all**:\n",
    "\n",
    "- **k-means** to discover groups (and you watched it *rediscover the penguin species*)\n",
    "- the **elbow method** to guess how many groups exist\n",
    "- **color quantization** to compress a photo — now live in your 🎨 **Pattern Finder**\n",
    "- **PCA** and **t-SNE** to turn 4-D and 64-D data into pictures you can actually see\n",
    "\n",
    "**So far you've borrowed brains (Day 1) and learned the classic tricks (Days 2-3).** Tomorrow the Chief Scientist raises the stakes: **build and train a neural network from nothing but NumPy** — no libraries to hide behind. By the end, you'll never see a neural net as a black box again.\n",
    "\n",
    "**Next: Day 4 — Inside the Brain 🧠**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2615965c-28bf-45ff-bd30-95582d1f8c9a",
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8b827df8-0012-450a-9aec-44961ba3e4f6",
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "markdown",
   "id": "e2ea02d6",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "<div align=\"center\" style=\"background:#14161E;border-radius:12px;padding:18px 12px 14px;margin-top:6px\">\n",
    "  <a href=\"https://ahalimi.com\" target=\"_blank\">\n",
    "    <img src=\"data:image/webp;base64,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\" alt=\"Dr. Abdelghafour HALIMI\" width=\"260\"/>\n",
    "  </a>\n",
    "</div>\n",
    "\n",
    "<div align=\"center\" style=\"text-align:center;margin-top:8px\"><sub>The Lab · AI Studio — Intro to AI · © 2026 <b>Dr. Abdelghafour HALIMI</b> · <a href=\"https://ahalimi.com\">ahalimi.com</a> · All rights reserved.</sub></div>"
   ]
  }
 ],
 "metadata": {
  "authors": [
   {
    "name": "Dr. Abdelghafour HALIMI"
   }
  ],
  "copyright": "© 2026 Dr. Abdelghafour HALIMI · ahalimi.com · All rights reserved",
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
