{
 "nbformat": 4,
 "nbformat_minor": 5,
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11"
  },
  "colab": {
   "provenance": []
  }
 },
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-0000",
   "metadata": {},
   "source": [
    "# Clustering in the Wild: Segmentation & Compression\n",
    "\n",
    "Two real jobs for K-Means — segment customers into personas you can act on, and compress an image by clustering its pixel colors.\n",
    "\n",
    "*Part of the free [Machine Learning](https://ramadnsyh.dev/courses/machine-learning) course by [Muhammad Ramadiansyah](https://ramadnsyh.dev). This notebook is generated from the interactive lesson — [read it online](https://ramadnsyh.dev/courses/machine-learning/clustering-applications).*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0001",
   "metadata": {},
   "source": [
    "Last lesson you learned how K-Means works; this one is about what it's *for*.\n",
    "We'll run two very different applications end to end: **customer\n",
    "segmentation**, where the clusters become marketing personas, and **color\n",
    "quantization**, where the centroids become an image's color palette. Same\n",
    "algorithm, wildly different data — that versatility is the point."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0002",
   "metadata": {},
   "source": [
    "## Customer segmentation with RFM\n",
    "\n",
    "The classic recipe for segmenting customers is **RFM**:\n",
    "\n",
    "- **Recency** — days since the last purchase (lower = more engaged)\n",
    "- **Frequency** — number of purchases in the period\n",
    "- **Monetary** — total amount spent\n",
    "\n",
    "Three numbers per customer, all computable from a plain transaction log. The\n",
    "workflow is: build the RFM table, **scale it** (the three features live on\n",
    "completely different ranges), cluster, and then — the step people skip —\n",
    "**profile** the clusters so they mean something to a human."
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0003",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.cluster import KMeans\n",
    "\n",
    "rng = np.random.default_rng(42)\n",
    "\n",
    "# Synthetic RFM table: three underlying customer types\n",
    "def group(n, rec, freq, mon):\n",
    "    return pd.DataFrame({\n",
    "        \"recency\":   rng.normal(rec[0],  rec[1],  n).clip(1, 365),\n",
    "        \"frequency\": rng.normal(freq[0], freq[1], n).clip(1, None),\n",
    "        \"monetary\":  rng.normal(mon[0],  mon[1],  n).clip(5, None),\n",
    "    })\n",
    "\n",
    "df = pd.concat([\n",
    "    group(120, rec=(15, 8),   freq=(25, 6), mon=(1200, 300)),  # loyal big spenders\n",
    "    group(150, rec=(60, 25),  freq=(8, 3),  mon=(300, 100)),   # occasional shoppers\n",
    "    group(130, rec=(220, 60), freq=(2, 1),  mon=(80, 40)),     # lapsed customers\n",
    "], ignore_index=True).round(1)\n",
    "\n",
    "print(df.describe().round(1))\n",
    "\n",
    "X = StandardScaler().fit_transform(df)\n",
    "km = KMeans(n_clusters=3, n_init=10, random_state=42)\n",
    "df[\"cluster\"] = km.fit_predict(X)\n",
    "print(\"\\\\nCluster sizes:\")\n",
    "print(df[\"cluster\"].value_counts().sort_index())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0004",
   "metadata": {},
   "source": [
    "Note that we scaled *before* clustering but kept the original `df` for\n",
    "profiling — standardized means like \"recency = −1.3\" are meaningless to a\n",
    "marketing team."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0005",
   "metadata": {},
   "source": [
    "## Profiling: turning cluster IDs into personas\n",
    "\n",
    "`KMeans` hands back anonymous labels 0, 1, 2. The actionable part of\n",
    "segmentation is a `groupby` away — average each RFM feature per cluster and\n",
    "*name* what you see:"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0006",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.cluster import KMeans\n",
    "\n",
    "rng = np.random.default_rng(42)\n",
    "\n",
    "def group(n, rec, freq, mon):\n",
    "    return pd.DataFrame({\n",
    "        \"recency\":   rng.normal(rec[0],  rec[1],  n).clip(1, 365),\n",
    "        \"frequency\": rng.normal(freq[0], freq[1], n).clip(1, None),\n",
    "        \"monetary\":  rng.normal(mon[0],  mon[1],  n).clip(5, None),\n",
    "    })\n",
    "\n",
    "df = pd.concat([\n",
    "    group(120, rec=(15, 8),   freq=(25, 6), mon=(1200, 300)),\n",
    "    group(150, rec=(60, 25),  freq=(8, 3),  mon=(300, 100)),\n",
    "    group(130, rec=(220, 60), freq=(2, 1),  mon=(80, 40)),\n",
    "], ignore_index=True)\n",
    "\n",
    "X = StandardScaler().fit_transform(df)\n",
    "df[\"cluster\"] = KMeans(n_clusters=3, n_init=10, random_state=42).fit_predict(X)\n",
    "\n",
    "profile = df.groupby(\"cluster\").agg(\n",
    "    n=(\"recency\", \"size\"),\n",
    "    avg_recency=(\"recency\", \"mean\"),\n",
    "    avg_frequency=(\"frequency\", \"mean\"),\n",
    "    avg_monetary=(\"monetary\", \"mean\"),\n",
    ").round(1)\n",
    "print(profile)\n",
    "\n",
    "# Name the personas from the profile\n",
    "champions = profile[\"avg_monetary\"].idxmax()\n",
    "lapsed = profile[\"avg_recency\"].idxmax()\n",
    "casual = [c for c in profile.index if c not in (champions, lapsed)][0]\n",
    "print(f\"\\\\nCluster {champions}: 'Champions'   - recent, frequent, high spend -> reward & retain\")\n",
    "print(f\"Cluster {casual}: 'Casual'      - moderate everything            -> nudge toward loyalty\")\n",
    "print(f\"Cluster {lapsed}: 'At risk'     - long absence, low spend        -> win-back campaign\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0007",
   "metadata": {},
   "source": [
    "That table *is* the deliverable. \"Cluster 2\" convinces nobody; \"8,000\n",
    "customers who used to buy monthly and haven't purchased in 7 months\" gets a\n",
    "win-back campaign funded. Two practical notes: use the elbow/silhouette\n",
    "diagnostics from last lesson to pick k, but let interpretability break ties —\n",
    "a k where every cluster has a clean story beats a marginally better score.\n",
    "And if your customer table mixes in categorical columns (region, plan type),\n",
    "plain K-Means can't average them; look at **K-Prototypes**, which handles\n",
    "numeric and categorical features together."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0008",
   "metadata": {},
   "source": [
    "## Color quantization: K-Means as compression\n",
    "\n",
    "Now the same algorithm on completely different \"rows\". An RGB image is just a\n",
    "list of pixels, each a point in 3-D color space. Cluster those points with\n",
    "k = 8 and the centroids form an 8-color **palette**; repaint every pixel with\n",
    "its centroid's color and you've compressed the image:"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0009",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.cluster import KMeans\n",
    "\n",
    "# Build a small synthetic \"photo\": sky gradient, sun, hills\n",
    "h, w = 60, 90\n",
    "img = np.zeros((h, w, 3))\n",
    "yy = np.linspace(0, 1, h).reshape(-1, 1)\n",
    "img[:, :, 0] = 0.9 - 0.5 * yy          # red fades downward\n",
    "img[:, :, 1] = 0.6 + 0.2 * yy\n",
    "img[:, :, 2] = 0.9 - 0.7 * yy\n",
    "xx, yg = np.meshgrid(np.arange(w), np.arange(h))\n",
    "sun = (xx - 68) ** 2 + (yg - 14) ** 2 < 64\n",
    "img[sun] = [1.0, 0.85, 0.2]\n",
    "hills = yg > 40 + 6 * np.sin(xx / 9)\n",
    "img[hills] = [0.15, 0.45, 0.2]\n",
    "rng = np.random.default_rng(0)\n",
    "img = np.clip(img + rng.normal(0, 0.02, img.shape), 0, 1)\n",
    "\n",
    "pixels = img.reshape(-1, 3)\n",
    "print(f\"{len(pixels)} pixels, {len(np.unique((pixels*255).astype(int), axis=0))} unique colors\")\n",
    "\n",
    "k = 8\n",
    "km = KMeans(n_clusters=k, n_init=3, random_state=42).fit(pixels)\n",
    "palette = km.cluster_centers_\n",
    "quantized = palette[km.labels_].reshape(img.shape)\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.2))\n",
    "ax1.imshow(img);        ax1.set_title(\"Original\");            ax1.axis(\"off\")\n",
    "ax2.imshow(quantized);  ax2.set_title(f\"Quantized ({k} colors)\"); ax2.axis(\"off\")\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0010",
   "metadata": {},
   "source": [
    "Eight colors, and the picture is still perfectly recognizable — the gradient\n",
    "turns into bands (the classic \"posterized\" look), but the sun and hills keep\n",
    "their colors because K-Means dedicated centroids to those dense pixel\n",
    "clusters. Try `k = 3` and `k = 16` to see the quality/size trade-off.\n",
    "\n",
    "The compression math: instead of storing 3 bytes per pixel, you store one\n",
    "small palette index per pixel (3 bits for 8 colors) plus the palette itself —\n",
    "roughly an **8× reduction** here. This is exactly how GIF's 256-color mode\n",
    "and old 8-bit displays worked.\n",
    "\n",
    "On a real photograph the effect is more striking. This version is for a\n",
    "notebook (it downloads nothing — the sample image ships with scikit-learn):"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0011",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.cluster import KMeans\n",
    "from sklearn.datasets import load_sample_image\n",
    "\n",
    "img = load_sample_image(\"china.jpg\") / 255.0     # (427, 640, 3)\n",
    "pixels = img.reshape(-1, 3)\n",
    "\n",
    "# Fit on a sample of pixels for speed, then label all of them\n",
    "rng = np.random.default_rng(42)\n",
    "sample = pixels[rng.choice(len(pixels), 5000, replace=False)]\n",
    "km = KMeans(n_clusters=16, n_init=3, random_state=42).fit(sample)\n",
    "quantized = km.cluster_centers_[km.predict(pixels)].reshape(img.shape)\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))\n",
    "ax1.imshow(img); ax1.set_title(\"Original (~96,000 colors)\"); ax1.axis(\"off\")\n",
    "ax2.imshow(quantized); ax2.set_title(\"16 colors\"); ax2.axis(\"off\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0012",
   "metadata": {},
   "source": [
    "Fitting on a 5,000-pixel sample and then calling `predict` on all 273,280\n",
    "pixels is a common trick — centroids barely move with more data, but fitting\n",
    "time does."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0013",
   "metadata": {},
   "source": [
    "## Other jobs for a fitted K-Means\n",
    "\n",
    "- **Anomaly detection** — after fitting, `km.transform(X)` gives each point's\n",
    "  distance to every centroid. Points far from *all* centroids fit no known\n",
    "  pattern: flag the top 1% of minimum-distances as anomalies (fraud, sensor\n",
    "  faults, data-entry errors).\n",
    "- **Semi-supervised labeling** — with 10,000 unlabeled images and budget to\n",
    "  label 50, cluster into 50 groups and hand-label the point nearest each\n",
    "  centroid, then propagate that label to the whole cluster. Far better than\n",
    "  labeling 50 random images.\n",
    "- **Feature engineering** — cluster distances (or the cluster ID itself) make\n",
    "  useful input features for a downstream supervised model."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0014",
   "metadata": {},
   "source": [
    "### 🏋️ Exercise — Segment with k = 4 and hunt for a new persona\n",
    "\n",
    "Rerun the RFM segmentation from the lesson with **k = 4** instead of 3.\n",
    "Profile the four clusters with `groupby` and try to name each persona. Then\n",
    "compare the silhouette scores of k = 3 and k = 4: does the data support a\n",
    "fourth segment, or did K-Means just split an existing one in half?"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0015",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# Your solution here"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0016",
   "metadata": {},
   "source": [
    "<details><summary>✅ Show solution</summary>\n",
    "\n",
    "```python\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.cluster import KMeans\n",
    "from sklearn.metrics import silhouette_score\n",
    "\n",
    "rng = np.random.default_rng(42)\n",
    "\n",
    "def group(n, rec, freq, mon):\n",
    "    return pd.DataFrame({\n",
    "        \"recency\":   rng.normal(rec[0],  rec[1],  n).clip(1, 365),\n",
    "        \"frequency\": rng.normal(freq[0], freq[1], n).clip(1, None),\n",
    "        \"monetary\":  rng.normal(mon[0],  mon[1],  n).clip(5, None),\n",
    "    })\n",
    "\n",
    "df = pd.concat([\n",
    "    group(120, rec=(15, 8),   freq=(25, 6), mon=(1200, 300)),\n",
    "    group(150, rec=(60, 25),  freq=(8, 3),  mon=(300, 100)),\n",
    "    group(130, rec=(220, 60), freq=(2, 1),  mon=(80, 40)),\n",
    "], ignore_index=True)\n",
    "\n",
    "X = StandardScaler().fit_transform(df)\n",
    "\n",
    "for k in (3, 4):\n",
    "    labels = KMeans(n_clusters=k, n_init=10, random_state=42).fit_predict(X)\n",
    "    print(f\"k={k}: silhouette = {silhouette_score(X, labels):.3f}\")\n",
    "\n",
    "df[\"cluster\"] = KMeans(n_clusters=4, n_init=10, random_state=42).fit_predict(X)\n",
    "print()\n",
    "print(df.groupby(\"cluster\").mean().round(1))\n",
    "\n",
    "# With k=4 the silhouette drops: the data truly has 3 groups, so the\n",
    "# extra cluster just splits one persona in two. Prefer k=3 here.\n",
    "```\n",
    "\n",
    "</details>"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0017",
   "metadata": {},
   "source": [
    "Next up: when three features become thirty — PCA, and how to squeeze\n",
    "high-dimensional data down without losing the signal."
   ]
  }
 ]
}