{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Reading a Theme Park's Mind with PCA\n",
    "\n",
    "### What 600,000 queue-time readings can tell you about how people move through Knoebels\n",
    "\n",
    "I'm working my way through *An Introduction to Statistical Learning* and Gilbert\n",
    "Strang's linear algebra lectures, and I got impatient. Principal Component\n",
    "Analysis sits a few chapters ahead of where I am, but I had a dataset I couldn't\n",
    "stop thinking about, so I skipped ahead and used it as an excuse to learn PCA by\n",
    "*doing*. This notebook is me figuring it out live.\n",
    "\n",
    "**The data.** Every few minutes, a scraper records the posted wait time for each\n",
    "ride at [Knoebels](https://knoebels.com) (a wonderful, weird old amusement park\n",
    "in Pennsylvania). Two seasons of that adds up to ~600k readings across ~57 rides.\n",
    "\n",
    "**The question.** If I *don't* tell the algorithm anything about the rides \u2014 not\n",
    "which are roller coasters, not which are for toddlers, not where they sit in the\n",
    "park \u2014 can it still discover the hidden \"axes\" that explain how the whole park\n",
    "behaves? That's exactly what PCA is for: it finds the handful of directions that\n",
    "explain the most variation in the data, and leaves us to interpret them.\n",
    "\n",
    "Spoiler: it finds three, and they turn out to be **how busy the park is**,\n",
    "**which half of the park you're in**, and **what time of day it is** \u2014 none of\n",
    "which it was ever told."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## The data: one big table of waits\n",
    "\n",
    "The natural shape for this is a table where every **row is a moment in time** (a\n",
    "5-minute snapshot of the park) and every **column is a ride**. Each cell is the\n",
    "wait, in minutes, for that ride at that moment.\n",
    "\n",
    "A couple of small judgment calls in building it:\n",
    "\n",
    "- **Forward-fill, then zero.** If a ride didn't report at 2:05 we carry its 2:00\n",
    "  value forward (the line probably didn't teleport). Anything still missing \u2014\n",
    "  like early morning before the ride opens \u2014 becomes 0.\n",
    "- **Drop `powersurge`.** It hasn't been running for most of the time since I began collecting data; ~90% of its column is\n",
    "  imputed zeros, so it only adds noise."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "80e904a5",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "635,995 readings  ->  matrix of 12,073 time-snapshots x 56 rides\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th>ride_name</th>\n",
       "      <th>antique_cars</th>\n",
       "      <th>balloon_race</th>\n",
       "      <th>black_diamond</th>\n",
       "      <th>bumper_cars</th>\n",
       "      <th>cosmotron</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>ts</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2024-05-15 12:20:00</th>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2024-05-15 12:55:00</th>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2024-05-15 13:00:00</th>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>10.0</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "ride_name            antique_cars  balloon_race  black_diamond  bumper_cars  \\\n",
       "ts                                                                            \n",
       "2024-05-15 12:20:00          10.0          10.0           10.0         10.0   \n",
       "2024-05-15 12:55:00          10.0          10.0           10.0         10.0   \n",
       "2024-05-15 13:00:00          10.0          10.0           10.0         10.0   \n",
       "\n",
       "ride_name            cosmotron  \n",
       "ts                              \n",
       "2024-05-15 12:20:00       10.0  \n",
       "2024-05-15 12:55:00       10.0  \n",
       "2024-05-15 13:00:00       10.0  "
      ]
     },
     "execution_count": 16,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "import os\n",
    "from pathlib import Path\n",
    "\n",
    "import pandas as pd\n",
    "from dotenv import load_dotenv\n",
    "from sqlalchemy import create_engine, text\n",
    "\n",
    "load_dotenv(Path.cwd() / \".env\")\n",
    "engine = create_engine(\n",
    "    \"postgresql+psycopg2://{user}:{pw}@{host}:{port}/{db}\".format(\n",
    "        user=os.environ[\"PG_USER\"], pw=os.environ[\"PG_PASSWORD\"],\n",
    "        host=os.environ[\"PG_HOST\"], port=os.environ[\"PG_PORT\"], db=os.environ[\"PG_DB\"],\n",
    "    )\n",
    ")\n",
    "\n",
    "df = pd.read_sql(\n",
    "    text(\n",
    "        \"\"\"\n",
    "        SELECT a.time_stamp,\n",
    "               r.ride        AS ride_name,\n",
    "               a.queue_time  AS queue_time_sec\n",
    "          FROM knoebels.\"LZ_attractions_io\" a\n",
    "          JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id\n",
    "         WHERE a.queue_time IS NOT NULL\n",
    "        \"\"\"\n",
    "    ),\n",
    "    engine,\n",
    ")\n",
    "df[\"queue_time_min\"] = df[\"queue_time_sec\"] / 60.0\n",
    "df[\"time_stamp\"] = pd.to_datetime(df[\"time_stamp\"])\n",
    "\n",
    "\n",
    "def ride_time_matrix(df, freq=\"5min\"):\n",
    "    \"\"\"Long table -> (time x ride) matrix of mean wait in minutes per time bin.\"\"\"\n",
    "    snapped = df.assign(ts=df[\"time_stamp\"].dt.floor(freq))\n",
    "    return (\n",
    "        snapped.pivot_table(index=\"ts\", columns=\"ride_name\",\n",
    "                            values=\"queue_time_min\", aggfunc=\"mean\")\n",
    "        .sort_index()\n",
    "        .ffill()      # carry the last known wait forward\n",
    "        .fillna(0)    # anything still missing (pre-open) = no line\n",
    "    )\n",
    "\n",
    "\n",
    "df_matrix = ride_time_matrix(df).drop(columns=[\"powersurge\"], errors=\"ignore\")\n",
    "print(f\"{len(df):,} readings  ->  matrix of {df_matrix.shape[0]:,} time-snapshots x {df_matrix.shape[1]} rides\")\n",
    "df_matrix.iloc[:3, :5]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "89de2f88",
   "metadata": {},
   "source": [
    "## Step 1: put every ride on the same footing\n",
    "\n",
    "Here's the first thing that bit me. PCA hunts for directions of **maximum\n",
    "variance** \u2014 but \"variance\" is measured in whatever units the data is in. A\n",
    "headline roller coaster might swing between 5 and 45 minutes; a kiddie ride\n",
    "swings between 0 and 6. If I feed in raw minutes, PCA will conclude the coaster\n",
    "is \"more important\" purely because its *numbers* are bigger, and the small rides\n",
    "will be invisible.\n",
    "\n",
    "The fix is to **standardize** every column: subtract its mean and divide by its\n",
    "standard deviation, so each ride has mean 0 and variance 1. Now a \"big day\" for\n",
    "the kiddie carousel counts just as much as a big day for the coaster. We're\n",
    "asking about *patterns of co-movement*, not raw magnitudes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "e3c1738c",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th>ride_name</th>\n",
       "      <th>antique_cars</th>\n",
       "      <th>balloon_race</th>\n",
       "      <th>black_diamond</th>\n",
       "      <th>bumper_cars</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>mean</th>\n",
       "      <td>0.0</td>\n",
       "      <td>-0.0</td>\n",
       "      <td>-0.0</td>\n",
       "      <td>-0.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>std</th>\n",
       "      <td>1.0</td>\n",
       "      <td>1.0</td>\n",
       "      <td>1.0</td>\n",
       "      <td>1.0</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "ride_name  antique_cars  balloon_race  black_diamond  bumper_cars\n",
       "mean                0.0          -0.0           -0.0         -0.0\n",
       "std                 1.0           1.0            1.0          1.0"
      ]
     },
     "execution_count": 17,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "scaler = StandardScaler()\n",
    "scaled = scaler.fit_transform(df_matrix)   # each column now mean 0, variance 1\n",
    "pd.DataFrame(scaled, columns=df_matrix.columns).describe().loc[[\"mean\", \"std\"]].iloc[:, :4].round(2)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dc6ae7d7",
   "metadata": {},
   "source": [
    "## Step 2: ask for the axes of variation\n",
    "\n",
    "This is the part I'd been intimidated by, and it turned out to be a clean idea.\n",
    "\n",
    "We have ~57 ride-columns, but they're hugely redundant \u2014 when the park is\n",
    "mobbed, *everything* gets busy together. PCA asks: **can I describe most of that\n",
    "57-dimensional wobble with just a few new axes?** Each axis (a *principal\n",
    "component*) is a weighted blend of the original rides, chosen so the first one\n",
    "captures as much variance as possible, the second captures as much of what's\n",
    "left as possible while being perpendicular to the first, and so on.\n",
    "\n",
    "The linear algebra underneath is the **singular value decomposition**. Our\n",
    "standardized matrix $X$ factors as\n",
    "\n",
    "$$X = U\\,\\Sigma\\,V^{\\top}.$$\n",
    "\n",
    "The columns of $V$ are the new axes \u2014 the **loadings**, telling us how much each\n",
    "ride contributes to each component. $U\\Sigma$ gives the **scores** \u2014 where each\n",
    "time-snapshot lands along those axes. And the singular values in $\\Sigma$, squared,\n",
    "tell us how much variance each axis explains. `sklearn`'s `PCA` does exactly this\n",
    "and hands back the pieces with friendly names.\n",
    "\n",
    "I asked for the top 3 components. Look at how concentrated the signal is:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "404f4039",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "PC1: 40.4% of all variance\n",
      "PC2: 13.0% of all variance\n",
      "PC3:  6.7% of all variance\n",
      "     60.1% captured by just 3 axes (out of 56)\n"
     ]
    }
   ],
   "source": [
    "from sklearn.decomposition import PCA\n",
    "\n",
    "pca = PCA(n_components=3)\n",
    "principal_components = pca.fit_transform(scaled)\n",
    "\n",
    "scores = pd.DataFrame(principal_components, index=df_matrix.index,\n",
    "                      columns=[\"PC1\", \"PC2\", \"PC3\"])\n",
    "loadings = pd.DataFrame(pca.components_.T, index=df_matrix.columns,\n",
    "                        columns=[\"PC1\", \"PC2\", \"PC3\"])\n",
    "\n",
    "for i, pc in enumerate([\"PC1\", \"PC2\", \"PC3\"]):\n",
    "    print(f\"{pc}: {pca.explained_variance_ratio_[i]:5.1%} of all variance\")\n",
    "print(f\"{'':4} {pca.explained_variance_ratio_[:3].sum():5.1%} captured by just 3 axes (out of 56)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c8f88158",
   "metadata": {},
   "source": [
    "Three axes out of fifty-six explain ~60% of everything. The rest is\n",
    "ride-specific noise. Now the fun part \u2014 **what are these three axes?** PCA won't\n",
    "tell us; it just hands us numbers. We have to read them.\n",
    "\n",
    "My approach for each component: look at **which rides load heavily on it** (the\n",
    "`loadings`), form a hypothesis about what real-world thing that represents, then\n",
    "**validate** the hypothesis against something the PCA never saw."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ef23e2c7",
   "metadata": {},
   "source": [
    "## PC1 \u2014 the whole park breathes together\n",
    "\n",
    "The first component is the easiest. Every single ride loads on it with the\n",
    "**same sign** \u2014 they all move together. That's the signature of one global force:\n",
    "overall busyness. Empty Tuesday morning, the whole park is near zero; packed\n",
    "Saturday afternoon, everything spikes at once.\n",
    "\n",
    "**The test:** if PC1 is really \"busyness,\" then a snapshot's PC1 score should\n",
    "track the plain average wait across all rides at that moment. To make that easy\n",
    "to eyeball, I put both quantities on the same standardized scale \u2014 so **if PC1\n",
    "were exactly the average wait, every dot would land on the dashed 45\u00b0 line.** The\n",
    "red line is the actual relationship (its slope is the correlation). For PC1 the\n",
    "red line sits almost on top of the diagonal:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "72b3033b",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 78368 characters]",
      "text/plain": [
       "<Figure size 700x650 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "\n",
    "def validation_plot(real_quantity, pc_score, score_name, xlabel):\n",
    "    \"\"\"Put a PC score and the real-world quantity it should track on the same\n",
    "    standardized scale. If the PC *were* that quantity every dot would sit on the\n",
    "    dashed 45\u00b0 line; the red line is the actual fit (its slope is the correlation\n",
    "    r). The closer red hugs the diagonal, the stronger the validation.\"\"\"\n",
    "    zx = (real_quantity - real_quantity.mean()) / real_quantity.std()\n",
    "    zy = (pc_score - pc_score.mean()) / pc_score.std()\n",
    "    r = real_quantity.corr(pc_score)\n",
    "\n",
    "    fig, ax = plt.subplots(figsize=(7, 6.5))\n",
    "    ax.scatter(zx, zy, s=4, alpha=0.12, color=\"steelblue\")\n",
    "    line = np.array([-3, 3])\n",
    "    ax.plot(line, line, \"k--\", lw=1.3, label=\"perfect match (y = x)\")\n",
    "    ax.plot(line, r * line, color=\"crimson\", lw=2.5,\n",
    "            label=f\"actual relationship (r = {r:.2f})\")\n",
    "    ax.set(xlim=(-3, 3), ylim=(-3, 3), aspect=\"equal\",\n",
    "           xlabel=f\"{xlabel}  (standardized)\",\n",
    "           ylabel=f\"{score_name} score (standardized)\")\n",
    "    ax.set_title(f\"{score_name}: does it track the real-world quantity?\")\n",
    "    ax.legend(loc=\"upper left\")\n",
    "    plt.tight_layout()\n",
    "\n",
    "\n",
    "park_mean = df_matrix.mean(axis=1)\n",
    "validation_plot(park_mean, scores[\"PC1\"], \"PC1\", \"Park-wide mean wait\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5c88f656",
   "metadata": {},
   "source": [
    "## PC2 \u2014 Kiddieland vs. Everything else\n",
    "\n",
    "PC2 is a **contrast**: some rides load positive, others negative. Reading the\n",
    "extremes, the negative end is dominated by the tiny-tots rides (`kiddie_boats`,\n",
    "`pony_carts`, `red_baron`...) and the positive end by the big rides in the rest of the park. So\n",
    "my first guess was \"kiddie rides vs. everything else.\"\n",
    "\n",
    "### The category tag is a trap\n",
    "\n",
    "The park's own data tags each ride `Kiddie` / `Family` / `Thrill`, so I reached\n",
    "for that to define the two groups \u2014 and got a muddy result. Two problems:\n",
    "\n",
    "1. The tag values secretly carry a **trailing space** (`\"Kiddie \"`, not\n",
    "   `\"Kiddie\"`), so naive equality checks silently matched *nothing*. (Lesson\n",
    "   learned: a `value_counts()` printout will never show you that.)\n",
    "2. Even cleaned up, the tag **misfiles rides** relative to how they behave. The\n",
    "   big Ferris wheel (`giant_wheel`) and the large `grand_carousel` are tagged\n",
    "   `Family` but behave like main-midway rides; the small `s&g_carousel` is tagged\n",
    "   `Family` but behaves like a Kiddieland ride.\n",
    "\n",
    "PC2 isn't tracking the *taxonomy* \u2014 it's tracking **physical geography**: the\n",
    "Kiddieland corner vs. the main midway. So let me define the two groups from\n",
    "geometry the PCA never touched, and see if the contrast gets sharper.\n",
    "\n",
    "### Validating from the map\n",
    "\n",
    "Each ride has a GPS coordinate. I run k-means on **just the lat/lon** (no queue\n",
    "data, no PCA) to carve the park into areas, and call the cluster with the lowest\n",
    "mean PC2 loading \"Kiddieland.\" Then I build the contrast *main-midway mean \u2212\n",
    "Kiddieland mean* and check it against PC2.\n",
    "\n",
    "(Small but important detail: **standardize the coordinates first.** On raw\n",
    "degrees the clustering is unstable from one random seed to the next; standardized,\n",
    "it's identical every run.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "dad04b89",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Kiddieland (19 rides): goin'_buggy, hand_cars, haunted_mansion, helicopters, jet_skyfighter, kiddie_boats, kiddie_bumper_cars, kiddie_firetrucks, kiddie_wheel, kiddie_whip, ole_smokey, panther_cars, pete's_fleet, pony_carts, red_baron, ribbit, s&g_carousel, spanish_bambini, umbrella_ride\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 175680 characters]",
      "text/plain": [
       "<Figure size 1500x700 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from sklearn.cluster import KMeans\n",
    "\n",
    "# category tag, with the sneaky trailing space trimmed off\n",
    "category = pd.read_sql(\n",
    "    text(\n",
    "        \"\"\"\n",
    "        SELECT r.ride AS ride_name, trim(c.name) AS category\n",
    "          FROM knoebels.ride_master r\n",
    "          JOIN knoebels.\"LZ_attractions_io_poi\" p ON p._id = r.attractions_dot_io_id\n",
    "          LEFT JOIN knoebels.\"LZ_attractions_io_categories\" c ON c._id = p.category\n",
    "        \"\"\"\n",
    "    ),\n",
    "    engine,\n",
    ").set_index(\"ride_name\")[\"category\"]\n",
    "\n",
    "coords = pd.read_sql(\n",
    "    text(\n",
    "        \"\"\"\n",
    "        SELECT r.ride AS ride_name,\n",
    "               (p.location)[0]::float AS lat, (p.location)[1]::float AS lon\n",
    "          FROM knoebels.ride_master r\n",
    "          JOIN knoebels.\"LZ_attractions_io_poi\" p ON p._id = r.attractions_dot_io_id\n",
    "         WHERE p.location IS NOT NULL\n",
    "        \"\"\"\n",
    "    ),\n",
    "    engine,\n",
    ").set_index(\"ride_name\")\n",
    "\n",
    "annotated = loadings.join(category.rename(\"category\"))\n",
    "located = annotated.join(coords, how=\"inner\")\n",
    "\n",
    "# k-means on STANDARDIZED coordinates only -> reproducible spatial areas\n",
    "located[\"area\"] = KMeans(n_clusters=5, n_init=10, random_state=0).fit_predict(\n",
    "    StandardScaler().fit_transform(located[[\"lat\", \"lon\"]])\n",
    ")\n",
    "kiddie_area = located.groupby(\"area\")[\"PC2\"].mean().idxmin()\n",
    "kiddie_rides = located.index[located[\"area\"] == kiddie_area]\n",
    "main_rides   = located.index[located[\"area\"] != kiddie_area]\n",
    "\n",
    "# Does the map agree? Left: each ride at its real GPS point, colored by PC2\n",
    "# loading. Right: areas from clustering lat/lon alone \u2014 no queue data, no PCA.\n",
    "fig, axes = plt.subplots(1, 2, figsize=(15, 7))\n",
    "sc = axes[0].scatter(located[\"lon\"], located[\"lat\"], c=located[\"PC2\"],\n",
    "                     cmap=\"RdBu_r\", s=140, edgecolor=\"black\", lw=0.4)\n",
    "axes[0].set(title=\"Each ride at its real location, colored by PC2 loading\\n\"\n",
    "                  \"(blue = Kiddieland end, red = main-midway end)\",\n",
    "            xlabel=\"Longitude\", ylabel=\"Latitude\")\n",
    "plt.colorbar(sc, ax=axes[0], label=\"PC2 loading\")\n",
    "for ride, row in located.iterrows():\n",
    "    axes[0].annotate(ride, (row[\"lon\"], row[\"lat\"]), fontsize=6, alpha=0.6,\n",
    "                     xytext=(3, 3), textcoords=\"offset points\")\n",
    "for area, g in located.groupby(\"area\"):\n",
    "    label = f\"Area {area}\" + (\" (Kiddieland)\" if area == kiddie_area else \"\")\n",
    "    axes[1].scatter(g[\"lon\"], g[\"lat\"], label=label, s=140, alpha=0.85)\n",
    "axes[1].set(title=\"k-means on standardized lat/lon alone\\n(no queue data, no PCA)\",\n",
    "            xlabel=\"Longitude\", ylabel=\"Latitude\")\n",
    "axes[1].legend()\n",
    "plt.tight_layout()\n",
    "print(f\"Kiddieland ({len(kiddie_rides)} rides): {', '.join(sorted(kiddie_rides))}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2bfb6369",
   "metadata": {},
   "source": [
    "The blue (negative-PC2) rides really do huddle in one corner of the park \u2014\n",
    "and clustering the coordinates on their own rediscovers that same corner without\n",
    "ever touching a wait time. So PC2 is genuinely spatial. Now the quantitative\n",
    "check: take *main-midway mean \u2212 Kiddieland mean* at each moment and see how\n",
    "tightly it tracks the PC2 score."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "1f8c5e7d",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 86932 characters]",
      "text/plain": [
       "<Figure size 700x650 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "contrast = df_matrix[main_rides].mean(axis=1) - df_matrix[kiddie_rides].mean(axis=1)\n",
    "validation_plot(contrast, scores[\"PC2\"], \"PC2\", \"Main-midway \u2212 Kiddieland wait\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "efbf0dda",
   "metadata": {},
   "source": [
    "r \u2248 0.78, built from coordinates the model never saw, and `giant_wheel` /\n",
    "`grand_carousel` correctly land on the main-midway side while `s&g_carousel`\n",
    "lands in Kiddieland \u2014 exactly the rides the category tag got wrong. PC2 is\n",
    "**where in the park you are**."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d896a1de",
   "metadata": {},
   "source": [
    "## PC3 \u2014 morning school groups vs. the evening crowd\n",
    "\n",
    "PC3 was the one I got wrong at first, so this is the honest version.\n",
    "\n",
    "My initial hypothesis was *ride throughput*: \"people-eater\" walk-on rides vs.\n",
    "low-capacity rides that build a line. It's a tidy story, and it's **wrong** here:\n",
    "\n",
    "- The `capacity` and `duration` columns I'd have needed are **completely empty**\n",
    "  in this dataset, so I couldn't even compute throughput.\n",
    "- When I split rides by their PC3 sign and compared actual wait behavior, the two\n",
    "  groups were a near-tie (mean wait 9.8 vs. 8.8 min). PC3 simply doesn't separate\n",
    "  \"builds a line\" from \"walk-on.\"\n",
    "\n",
    "What it *does* separate is **time of day**. The PC3 *score* runs strongly\n",
    "positive at midday and strongly negative after dark. So the rides loading\n",
    "positive are busy in the daytime, and the ones loading negative are busy in the\n",
    "evening \u2014 and that maps onto a real thing about how Knoebels operates:\n",
    "\n",
    "> Knoebels runs discounted group rates for schools and daycares, who arrive at\n",
    "> open and leave by mid-afternoon. Those big groups of little kids hammer the\n",
    "> daytime family flat-rides (tea cups, tilt-a-whirl, paratrooper). After they\n",
    "> clear out, the evening belongs to teens and date-night couples on the coasters,\n",
    "> bumper cars, and the Ferris wheel at dusk.\n",
    "\n",
    "**The test:** de-trend each group's wait by the park-wide average for that hour\n",
    "(so we're looking at *shape*, not PC1's overall nightly ramp), and watch the two\n",
    "groups pull apart across the day."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "a1723720",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 82488 characters]",
      "text/plain": [
       "<Figure size 1400x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "daytime_rides = loadings.index[loadings[\"PC3\"] > 0.1]\n",
    "evening_rides = loadings.index[loadings[\"PC3\"] < -0.1]\n",
    "\n",
    "hour = df_matrix.index.hour\n",
    "park_by_hour = df_matrix.mean(axis=1).groupby(hour).mean()\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
    "\n",
    "# left: the axis itself, by hour\n",
    "scores.groupby(scores.index.hour)[\"PC3\"].mean().plot(ax=axes[0], marker=\"o\")\n",
    "axes[0].axhline(0, color=\"grey\", lw=0.5)\n",
    "axes[0].set(title=\"PC3 score by hour  (+ daytime / \u2212 evening)\",\n",
    "            xlabel=\"Hour (local)\", ylabel=\"Mean PC3 score\")\n",
    "\n",
    "# right: each group's wait as a share of the park-wide level, de-trended\n",
    "for name, grp, color in [(\"daytime rides (PC3 +)\", daytime_rides, \"tab:orange\"),\n",
    "                         (\"evening rides (PC3 \u2212)\", evening_rides, \"tab:blue\")]:\n",
    "    (df_matrix[grp].mean(axis=1).groupby(hour).mean() / park_by_hour).plot(\n",
    "        ax=axes[1], marker=\"o\", label=name, color=color)\n",
    "axes[1].axhline(1.0, color=\"grey\", lw=0.5)\n",
    "axes[1].set(title=\"Group wait \u00f7 park-wide wait, by hour\",\n",
    "            xlabel=\"Hour (local)\", ylabel=\"Share of park-wide mean wait\")\n",
    "axes[1].legend()\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "429f01a2",
   "metadata": {},
   "source": [
    "The daytime group fades through the afternoon while the evening group\n",
    "climbs \u2014 and the school-group operating detail is part of the mechanism. PC3 is **what\n",
    "time of day it is**.\n",
    "\n",
    "(There's a mild bit of circularity worth flagging: I defined the two groups using\n",
    "the PC3 loadings, so this plot confirms *what the axis means* more than it's a\n",
    "fully independent test. The genuinely external evidence is the operational fact\n",
    "about group rates.)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": "### What PC3 adds that PC2 doesn't\n\nIt's fair to ask what's left for PC3 to do. PC2's *score* also drifts with the clock \u2014 kiddie-heavy in the morning, midway-heavy by night \u2014 so a third time-flavored axis can feel redundant. It isn't, and seeing why gets at the heart of why PCA stacks several axes.\n\nThe mechanics: each component assigns every ride a **single number** (its loading), and the axes are built to be independent \u2014 here PC2 and PC3 correlate **0.00**. PC2 spends its one-number-per-ride on a single question \u2014 *kiddie corner or main midway?* \u2014 so every grown-up ride gets a similar positive loading. That makes PC2 structurally **blind** to the fact that, say, the tea cups and the giant Ferris wheel behave nothing alike. Both are just \"midway\" to it."
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": "# PC2 calls these two rides almost identical (both lean \"main midway\").\n# PC3 sends them to opposite ends.\nloadings.loc[[\"tilt-a-whirl\", \"giant_wheel\"], [\"PC2\", \"PC3\"]].round(3)",
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": "PC2 puts them practically side by side (+0.16 and +0.20); PC3 sends them to opposite poles (+0.15 vs \u22120.18). And that matches the park exactly: the tilt-a-whirl is a *daytime* ride the school-and-daycare crowd swarms at opening, while the giant wheel is an *evening* ride \u2014 dusk views, the date-night crowd. To describe a ride as \"midway **and** daytime\" versus \"midway **and** evening\" takes two numbers, and the second one is PC3.\n\nIt even shows up in the *shape* of each axis across a day: PC2 is a steady all-day drift, PC3 a midday hump. So the three components stack into one clean read on the park:\n\n- **PC1** \u2014 *how many* people are here? (overall busyness)\n- **PC2** \u2014 *which half* of the park are they in? (kiddie corner \u2194 main midway)\n- **PC3** \u2014 among the midway rides, *which kind* is peaking? (daytime family flat-rides \u2194 evening marquee rides)\n\nThe gut-check for what \"independent axes\" really buys you: two moments can share an identical PC1 **and** PC2 \u2014 same crowd size, same kiddie/midway balance \u2014 and still split on PC3. A midday with the flat rides surging versus an evening with the coasters and bumper cars going: same volume, same spatial tilt, different ride *mix*. PC3 is the only one of the three that can tell them apart."
  },
  {
   "cell_type": "markdown",
   "id": "ba034336",
   "metadata": {},
   "source": [
    "## The map of the park, drawn from wait times alone\n",
    "\n",
    "Here's the payoff. Plot every ride by its PC2 and PC3 loadings \u2014 its *behavioral*\n",
    "position \u2014 and color it by the park's category tag. Remember: the **position**\n",
    "comes purely from how each ride's queue moves over two seasons. The model was\n",
    "never told what any ride is or where it sits.\n",
    "\n",
    "And yet the picture is the park. Kiddieland collapses into a tight cluster on the\n",
    "left. The evening thrill rides sink to the bottom-right. The daytime family\n",
    "flat-rides float to the top. The two blue dots stranded inside Kiddieland are the\n",
    "`Family`-tagged rides the queue data insists are really kiddie rides."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 23,
   "id": "3a436334",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 104600 characters]",
      "text/plain": [
       "<Figure size 1100x850 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from matplotlib.patches import Ellipse\n",
    "\n",
    "# the tight Kiddieland pile = rides loading hard-negative on PC2\n",
    "kiddie_core = loadings.index[loadings[\"PC2\"] < -0.13]\n",
    "kx, ky = loadings.loc[kiddie_core, \"PC2\"], loadings.loc[kiddie_core, \"PC3\"]\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(11, 8.5))\n",
    "cat_colors = {\"Family\": \"tab:blue\", \"Kiddie\": \"tab:orange\", \"Thrill\": \"tab:red\"}\n",
    "for c, g in annotated.dropna(subset=[\"category\"]).groupby(\"category\"):\n",
    "    ax.scatter(g[\"PC2\"], g[\"PC3\"], label=c, color=cat_colors.get(c, \"grey\"),\n",
    "               s=80, alpha=0.75, edgecolor=\"white\", zorder=3)\n",
    "\n",
    "# one callout for the dense kiddie pile, instead of 13 overlapping labels\n",
    "w, h = (kx.max() - kx.min()) + 0.07, (ky.max() - ky.min()) + 0.07\n",
    "ax.add_patch(Ellipse((kx.mean(), ky.mean()), width=w, height=h, facecolor=\"tab:orange\",\n",
    "                     alpha=0.08, edgecolor=\"tab:orange\", ls=\"--\", lw=1.5, zorder=1))\n",
    "ax.annotate(\"Kiddieland\\n(tight cluster of kiddie rides)\", (kx.mean(), ky.mean() - h / 2),\n",
    "            ha=\"center\", va=\"top\", fontsize=9, color=\"tab:orange\", fontweight=\"bold\")\n",
    "\n",
    "# label only the rides that carry the story\n",
    "plain = {\"giant_wheel\": (6, 5), \"grand_carousel\": (8, -3), \"impulse\": (7, -3),\n",
    "         \"bumper_cars\": (7, 4), \"motor_boats\": (7, 0), \"paratrooper\": (7, 0),\n",
    "         \"tea_cups\": (7, 0), \"tilt-a-whirl\": (7, -2), \"roto_jets\": (-42, 6)}\n",
    "for ride, (dx, dy) in plain.items():\n",
    "    p = annotated.loc[ride]\n",
    "    ax.annotate(ride, (p[\"PC2\"], p[\"PC3\"]), fontsize=8.5,\n",
    "                xytext=(dx, dy), textcoords=\"offset points\", zorder=4)\n",
    "\n",
    "# the two Family-tagged rides that behave like kiddie rides \u2014 pull labels out with arrows\n",
    "misfiled = {\"s&g_carousel\": (-70, -30), \"kiddie_himalaya\": (-78, 20)}\n",
    "for ride, (dx, dy) in misfiled.items():\n",
    "    p = annotated.loc[ride]\n",
    "    ax.annotate(ride + \"  (tagged Family)\", (p[\"PC2\"], p[\"PC3\"]), fontsize=8.5,\n",
    "                color=\"tab:blue\", xytext=(dx, dy), textcoords=\"offset points\",\n",
    "                arrowprops=dict(arrowstyle=\"->\", lw=0.7, color=\"tab:blue\"), zorder=5)\n",
    "\n",
    "ax.axhline(0, color=\"grey\", lw=0.5)\n",
    "ax.axvline(0, color=\"grey\", lw=0.5)\n",
    "ax.set_xlabel(\"PC2     Kiddieland corner  \u2190      \u2192  main midway\")\n",
    "ax.set_ylabel(\"PC3     evening crowd  \u2190      \u2192  daytime / school-group daypart\")\n",
    "ax.set_title(\"Knoebels rides in PC2 \u00d7 PC3 space\\n\"\n",
    "             \"(position comes from queue patterns alone; color = the park's category tag)\")\n",
    "ax.legend(title=\"Category tag\", loc=\"lower left\")\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "73b2a3f7",
   "metadata": {},
   "source": [
    "## What I took away\n",
    "\n",
    "- **PCA found real structure with zero domain input.** Three axes \u2014 busyness,\n",
    "  geography, time of day \u2014 fell out of nothing but wait times, and each one maps\n",
    "  to something you could verify against the park independently (the average wait,\n",
    "  the GPS map, the operating schedule).\n",
    "- **Standardizing matters twice.** Once before PCA (so small rides aren't\n",
    "  drowned out), and again before k-means on coordinates (so the clustering is\n",
    "  reproducible).\n",
    "- **The model's labels beat the official ones.** Where the category tag and the\n",
    "  queue behavior disagreed (`giant_wheel`, `s&g_carousel`), the behavior was the\n",
    "  truer description of how a ride is actually *used*.\n",
    "- **Be willing to kill a pretty hypothesis.** The PC3 \"throughput\" story was\n",
    "  tidy and wrong; the data pointed at time-of-day instead.\n",
    "\n",
    "Skipping ahead in the textbook to chase a dataset I cared about turned out to be\n",
    "a great way to learn this."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}