knb_stats: validate PC interpretations + rank-3 reconstruction + writeup PC sections
pca_svd: exclude torn-down powersurge from the PCA, trim attractions.io category names (trailing-space gotcha), time-side validation of PC1-PC3 interpretations, per-ride R^2 of the rank-3 reconstruction, blog-ready PC2xPC3 scatter. writeup: PC1/PC2/PC3 narrative sections. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+998
-62
File diff suppressed because one or more lines are too long
+138
-133
@@ -18,14 +18,14 @@
|
|||||||
"ride at [Knoebels](https://knoebels.com) (a wonderful, weird old amusement park\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",
|
"in Pennsylvania). Two seasons of that adds up to ~600k readings across ~57 rides.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"**The question.** If I *don't* tell the algorithm anything about the rides \u2014 not\n",
|
"**The question.** If I *don't* tell the algorithm anything about the rides — not\n",
|
||||||
"which are roller coasters, not which are for toddlers, not where they sit in the\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",
|
"park — 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",
|
"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",
|
"explain the most variation in the data, and leaves us to interpret them.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Spoiler: it finds three, and they turn out to be **how busy the park is**,\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 half of the park you're in**, and **what time of day it is** — none of\n",
|
||||||
"which it was ever told."
|
"which it was ever told."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -42,15 +42,15 @@
|
|||||||
"A couple of small judgment calls in building it:\n",
|
"A couple of small judgment calls in building it:\n",
|
||||||
"\n",
|
"\n",
|
||||||
"- **Forward-fill, then zero.** If a ride didn't report at 2:05 we carry its 2:00\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",
|
" value forward (the line probably didn't teleport). Anything still missing —\n",
|
||||||
" like early morning before the ride opens \u2014 becomes 0.\n",
|
" like early morning before the ride opens — 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",
|
"- **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."
|
" imputed zeros, so it only adds noise."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 16,
|
"execution_count": 24,
|
||||||
"id": "80e904a5",
|
"id": "80e904a5",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -58,91 +58,71 @@
|
|||||||
"name": "stdout",
|
"name": "stdout",
|
||||||
"output_type": "stream",
|
"output_type": "stream",
|
||||||
"text": [
|
"text": [
|
||||||
"635,995 readings -> matrix of 12,073 time-snapshots x 56 rides\n"
|
"657,498 readings -> matrix of 12,535 time-snapshots x 56 rides\n",
|
||||||
|
"<class 'pandas.DataFrame'>\n",
|
||||||
|
"DatetimeIndex: 12535 entries, 2024-05-15 12:20:00 to 2026-05-31 17:30:00\n",
|
||||||
|
"Data columns (total 56 columns):\n",
|
||||||
|
" # Column Non-Null Count Dtype \n",
|
||||||
|
"--- ------ -------------- ----- \n",
|
||||||
|
" 0 antique_cars 12535 non-null float64\n",
|
||||||
|
" 1 balloon_race 12535 non-null float64\n",
|
||||||
|
" 2 black_diamond 12535 non-null float64\n",
|
||||||
|
" 3 bumper_cars 12535 non-null float64\n",
|
||||||
|
" 4 cosmotron 12535 non-null float64\n",
|
||||||
|
" 5 downdraft 12535 non-null float64\n",
|
||||||
|
" 6 fandango 12535 non-null float64\n",
|
||||||
|
" 7 flyer 12535 non-null float64\n",
|
||||||
|
" 8 flying_tigers 12535 non-null float64\n",
|
||||||
|
" 9 flying_turns 12535 non-null float64\n",
|
||||||
|
" 10 galleon 12535 non-null float64\n",
|
||||||
|
" 11 giant_flume 12535 non-null float64\n",
|
||||||
|
" 12 giant_wheel 12535 non-null float64\n",
|
||||||
|
" 13 goin'_buggy 12535 non-null float64\n",
|
||||||
|
" 14 grand_carousel 12535 non-null float64\n",
|
||||||
|
" 15 hand_cars 12535 non-null float64\n",
|
||||||
|
" 16 haunted_mansion 12535 non-null float64\n",
|
||||||
|
" 17 helicopters 12535 non-null float64\n",
|
||||||
|
" 18 impulse 12535 non-null float64\n",
|
||||||
|
" 19 italian_trapeze 12535 non-null float64\n",
|
||||||
|
" 20 jet_skyfighter 12535 non-null float64\n",
|
||||||
|
" 21 kiddie_boats 12535 non-null float64\n",
|
||||||
|
" 22 kiddie_bumper_cars 12535 non-null float64\n",
|
||||||
|
" 23 kiddie_firetrucks 12535 non-null float64\n",
|
||||||
|
" 24 kiddie_himalaya 12535 non-null float64\n",
|
||||||
|
" 25 kiddie_wheel 12535 non-null float64\n",
|
||||||
|
" 26 kiddie_whip 12535 non-null float64\n",
|
||||||
|
" 27 kozmo's_kurves 12535 non-null float64\n",
|
||||||
|
" 28 looper 12535 non-null float64\n",
|
||||||
|
" 29 merry_mixer 12535 non-null float64\n",
|
||||||
|
" 30 motor_boats 12535 non-null float64\n",
|
||||||
|
" 31 ole_smokey 12535 non-null float64\n",
|
||||||
|
" 32 panther_cars 12535 non-null float64\n",
|
||||||
|
" 33 paradrop 12535 non-null float64\n",
|
||||||
|
" 34 paratrooper 12535 non-null float64\n",
|
||||||
|
" 35 pete's_fleet 12535 non-null float64\n",
|
||||||
|
" 36 phoenix 12535 non-null float64\n",
|
||||||
|
" 37 pioneer_train 12535 non-null float64\n",
|
||||||
|
" 38 pony_carts 12535 non-null float64\n",
|
||||||
|
" 39 red_baron 12535 non-null float64\n",
|
||||||
|
" 40 ribbit 12535 non-null float64\n",
|
||||||
|
" 41 roto_jets 12535 non-null float64\n",
|
||||||
|
" 42 s&g_carousel 12535 non-null float64\n",
|
||||||
|
" 43 satellite 12535 non-null float64\n",
|
||||||
|
" 44 scenic_skyway 12535 non-null float64\n",
|
||||||
|
" 45 sklooosh 12535 non-null float64\n",
|
||||||
|
" 46 spanish_bambini 12535 non-null float64\n",
|
||||||
|
" 47 stratosfear 12535 non-null float64\n",
|
||||||
|
" 48 super_round-up 12535 non-null float64\n",
|
||||||
|
" 49 tea_cups 12535 non-null float64\n",
|
||||||
|
" 50 tilt-a-whirl 12535 non-null float64\n",
|
||||||
|
" 51 tornado 12535 non-null float64\n",
|
||||||
|
" 52 tumbling_timbers 12535 non-null float64\n",
|
||||||
|
" 53 twister 12535 non-null float64\n",
|
||||||
|
" 54 umbrella_ride 12535 non-null float64\n",
|
||||||
|
" 55 whipper 12535 non-null float64\n",
|
||||||
|
"dtypes: float64(56)\n",
|
||||||
|
"memory usage: 5.5 MB\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": [
|
"source": [
|
||||||
@@ -192,7 +172,8 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"df_matrix = ride_time_matrix(df).drop(columns=[\"powersurge\"], errors=\"ignore\")\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",
|
"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]"
|
"#df_matrix.iloc[:3, :5]\n",
|
||||||
|
"df_matrix.info()"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,7 +184,7 @@
|
|||||||
"## Step 1: put every ride on the same footing\n",
|
"## Step 1: put every ride on the same footing\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Here's the first thing that bit me. PCA hunts for directions of **maximum\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",
|
"variance** — 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",
|
"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",
|
"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",
|
"is \"more important\" purely because its *numbers* are bigger, and the small rides\n",
|
||||||
@@ -295,7 +276,7 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"This is the part I'd been intimidated by, and it turned out to be a clean idea.\n",
|
"This is the part I'd been intimidated by, and it turned out to be a clean idea.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"We have ~57 ride-columns, but they're hugely redundant \u2014 when the park is\n",
|
"We have ~57 ride-columns, but they're hugely redundant — when the park is\n",
|
||||||
"mobbed, *everything* gets busy together. PCA asks: **can I describe most of that\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",
|
"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",
|
"component*) is a weighted blend of the original rides, chosen so the first one\n",
|
||||||
@@ -307,8 +288,8 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"$$X = U\\,\\Sigma\\,V^{\\top}.$$\n",
|
"$$X = U\\,\\Sigma\\,V^{\\top}.$$\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The columns of $V$ are the new axes \u2014 the **loadings**, telling us how much each\n",
|
"The columns of $V$ are the new axes — the **loadings**, telling us how much each\n",
|
||||||
"ride contributes to each component. $U\\Sigma$ gives the **scores** \u2014 where each\n",
|
"ride contributes to each component. $U\\Sigma$ gives the **scores** — where each\n",
|
||||||
"time-snapshot lands along those axes. And the singular values in $\\Sigma$, squared,\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",
|
"tell us how much variance each axis explains. `sklearn`'s `PCA` does exactly this\n",
|
||||||
"and hands back the pieces with friendly names.\n",
|
"and hands back the pieces with friendly names.\n",
|
||||||
@@ -355,7 +336,7 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"Three axes out of fifty-six explain ~60% of everything. The rest is\n",
|
"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",
|
"ride-specific noise. Now the fun part — **what are these three axes?** PCA won't\n",
|
||||||
"tell us; it just hands us numbers. We have to read them.\n",
|
"tell us; it just hands us numbers. We have to read them.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"My approach for each component: look at **which rides load heavily on it** (the\n",
|
"My approach for each component: look at **which rides load heavily on it** (the\n",
|
||||||
@@ -368,17 +349,17 @@
|
|||||||
"id": "ef23e2c7",
|
"id": "ef23e2c7",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## PC1 \u2014 the whole park breathes together\n",
|
"## PC1 — the whole park breathes together\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The first component is the easiest. Every single ride loads on it with the\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",
|
"**same sign** — 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",
|
"overall busyness. Empty Tuesday morning, the whole park is near zero; packed\n",
|
||||||
"Saturday afternoon, everything spikes at once.\n",
|
"Saturday afternoon, everything spikes at once.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"**The test:** if PC1 is really \"busyness,\" then a snapshot's PC1 score should\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",
|
"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",
|
"to eyeball, I put both quantities on the same standardized scale — so **if PC1\n",
|
||||||
"were exactly the average wait, every dot would land on the dashed 45\u00b0 line.** The\n",
|
"were exactly the average wait, every dot would land on the dashed 45° line.** The\n",
|
||||||
"red line is the actual relationship (its slope is the correlation). For PC1 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:"
|
"red line sits almost on top of the diagonal:"
|
||||||
]
|
]
|
||||||
@@ -408,7 +389,7 @@
|
|||||||
"def validation_plot(real_quantity, pc_score, score_name, xlabel):\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",
|
" \"\"\"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",
|
" 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",
|
" dashed 45° 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",
|
" r). The closer red hugs the diagonal, the stronger the validation.\"\"\"\n",
|
||||||
" zx = (real_quantity - real_quantity.mean()) / real_quantity.std()\n",
|
" zx = (real_quantity - real_quantity.mean()) / real_quantity.std()\n",
|
||||||
" zy = (pc_score - pc_score.mean()) / pc_score.std()\n",
|
" zy = (pc_score - pc_score.mean()) / pc_score.std()\n",
|
||||||
@@ -437,7 +418,7 @@
|
|||||||
"id": "5c88f656",
|
"id": "5c88f656",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## PC2 \u2014 Kiddieland vs. Everything else\n",
|
"## PC2 — Kiddieland vs. Everything else\n",
|
||||||
"\n",
|
"\n",
|
||||||
"PC2 is a **contrast**: some rides load positive, others negative. Reading the\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",
|
"extremes, the negative end is dominated by the tiny-tots rides (`kiddie_boats`,\n",
|
||||||
@@ -447,7 +428,7 @@
|
|||||||
"### The category tag is a trap\n",
|
"### The category tag is a trap\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The park's own data tags each ride `Kiddie` / `Family` / `Thrill`, so I reached\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",
|
"for that to define the two groups — and got a muddy result. Two problems:\n",
|
||||||
"\n",
|
"\n",
|
||||||
"1. The tag values secretly carry a **trailing space** (`\"Kiddie \"`, not\n",
|
"1. The tag values secretly carry a **trailing space** (`\"Kiddie \"`, not\n",
|
||||||
" `\"Kiddie\"`), so naive equality checks silently matched *nothing*. (Lesson\n",
|
" `\"Kiddie\"`), so naive equality checks silently matched *nothing*. (Lesson\n",
|
||||||
@@ -457,7 +438,7 @@
|
|||||||
" `Family` but behave like main-midway rides; the small `s&g_carousel` is 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",
|
" `Family` but behaves like a Kiddieland ride.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"PC2 isn't tracking the *taxonomy* \u2014 it's tracking **physical geography**: the\n",
|
"PC2 isn't tracking the *taxonomy* — it's tracking **physical geography**: the\n",
|
||||||
"Kiddieland corner vs. the main midway. So let me define the two groups from\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",
|
"geometry the PCA never touched, and see if the contrast gets sharper.\n",
|
||||||
"\n",
|
"\n",
|
||||||
@@ -465,7 +446,7 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"Each ride has a GPS coordinate. I run k-means on **just the lat/lon** (no queue\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",
|
"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",
|
"mean PC2 loading \"Kiddieland.\" Then I build the contrast *main-midway mean −\n",
|
||||||
"Kiddieland mean* and check it against PC2.\n",
|
"Kiddieland mean* and check it against PC2.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"(Small but important detail: **standardize the coordinates first.** On raw\n",
|
"(Small but important detail: **standardize the coordinates first.** On raw\n",
|
||||||
@@ -538,7 +519,7 @@
|
|||||||
"main_rides = located.index[located[\"area\"] != kiddie_area]\n",
|
"main_rides = located.index[located[\"area\"] != kiddie_area]\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# Does the map agree? Left: each ride at its real GPS point, colored by PC2\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",
|
"# loading. Right: areas from clustering lat/lon alone — no queue data, no PCA.\n",
|
||||||
"fig, axes = plt.subplots(1, 2, figsize=(15, 7))\n",
|
"fig, axes = plt.subplots(1, 2, figsize=(15, 7))\n",
|
||||||
"sc = axes[0].scatter(located[\"lon\"], located[\"lat\"], c=located[\"PC2\"],\n",
|
"sc = axes[0].scatter(located[\"lon\"], located[\"lat\"], c=located[\"PC2\"],\n",
|
||||||
" cmap=\"RdBu_r\", s=140, edgecolor=\"black\", lw=0.4)\n",
|
" cmap=\"RdBu_r\", s=140, edgecolor=\"black\", lw=0.4)\n",
|
||||||
@@ -564,10 +545,10 @@
|
|||||||
"id": "2bfb6369",
|
"id": "2bfb6369",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"The blue (negative-PC2) rides really do huddle in one corner of the park \u2014\n",
|
"The blue (negative-PC2) rides really do huddle in one corner of the park —\n",
|
||||||
"and clustering the coordinates on their own rediscovers that same corner without\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",
|
"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",
|
"check: take *main-midway mean − Kiddieland mean* at each moment and see how\n",
|
||||||
"tightly it tracks the PC2 score."
|
"tightly it tracks the PC2 score."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -590,7 +571,7 @@
|
|||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"contrast = df_matrix[main_rides].mean(axis=1) - df_matrix[kiddie_rides].mean(axis=1)\n",
|
"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\")"
|
"validation_plot(contrast, scores[\"PC2\"], \"PC2\", \"Main-midway − Kiddieland wait\")"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -598,9 +579,9 @@
|
|||||||
"id": "efbf0dda",
|
"id": "efbf0dda",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"r \u2248 0.78, built from coordinates the model never saw, and `giant_wheel` /\n",
|
"r ≈ 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",
|
"`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",
|
"lands in Kiddieland — exactly the rides the category tag got wrong. PC2 is\n",
|
||||||
"**where in the park you are**."
|
"**where in the park you are**."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -609,7 +590,7 @@
|
|||||||
"id": "d896a1de",
|
"id": "d896a1de",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## PC3 \u2014 morning school groups vs. the evening crowd\n",
|
"## PC3 — morning school groups vs. the evening crowd\n",
|
||||||
"\n",
|
"\n",
|
||||||
"PC3 was the one I got wrong at first, so this is the honest version.\n",
|
"PC3 was the one I got wrong at first, so this is the honest version.\n",
|
||||||
"\n",
|
"\n",
|
||||||
@@ -625,7 +606,7 @@
|
|||||||
"What it *does* separate is **time of day**. The PC3 *score* runs strongly\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 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",
|
"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",
|
"evening — and that maps onto a real thing about how Knoebels operates:\n",
|
||||||
"\n",
|
"\n",
|
||||||
"> Knoebels runs discounted group rates for schools and daycares, who arrive at\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",
|
"> open and leave by mid-afternoon. Those big groups of little kids hammer the\n",
|
||||||
@@ -667,16 +648,16 @@
|
|||||||
"# left: the axis itself, by hour\n",
|
"# left: the axis itself, by hour\n",
|
||||||
"scores.groupby(scores.index.hour)[\"PC3\"].mean().plot(ax=axes[0], marker=\"o\")\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].axhline(0, color=\"grey\", lw=0.5)\n",
|
||||||
"axes[0].set(title=\"PC3 score by hour (+ daytime / \u2212 evening)\",\n",
|
"axes[0].set(title=\"PC3 score by hour (+ daytime / − evening)\",\n",
|
||||||
" xlabel=\"Hour (local)\", ylabel=\"Mean PC3 score\")\n",
|
" xlabel=\"Hour (local)\", ylabel=\"Mean PC3 score\")\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# right: each group's wait as a share of the park-wide level, de-trended\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",
|
"for name, grp, color in [(\"daytime rides (PC3 +)\", daytime_rides, \"tab:orange\"),\n",
|
||||||
" (\"evening rides (PC3 \u2212)\", evening_rides, \"tab:blue\")]:\n",
|
" (\"evening rides (PC3 −)\", evening_rides, \"tab:blue\")]:\n",
|
||||||
" (df_matrix[grp].mean(axis=1).groupby(hour).mean() / park_by_hour).plot(\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",
|
" ax=axes[1], marker=\"o\", label=name, color=color)\n",
|
||||||
"axes[1].axhline(1.0, color=\"grey\", lw=0.5)\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",
|
"axes[1].set(title=\"Group wait ÷ park-wide wait, by hour\",\n",
|
||||||
" xlabel=\"Hour (local)\", ylabel=\"Share of park-wide mean wait\")\n",
|
" xlabel=\"Hour (local)\", ylabel=\"Share of park-wide mean wait\")\n",
|
||||||
"axes[1].legend()\n",
|
"axes[1].legend()\n",
|
||||||
"plt.tight_layout()"
|
"plt.tight_layout()"
|
||||||
@@ -688,7 +669,7 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"The daytime group fades through the afternoon while the evening group\n",
|
"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",
|
"climbs — and the school-group operating detail is part of the mechanism. PC3 is **what\n",
|
||||||
"time of day it is**.\n",
|
"time of day it is**.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"(There's a mild bit of circularity worth flagging: I defined the two groups using\n",
|
"(There's a mild bit of circularity worth flagging: I defined the two groups using\n",
|
||||||
@@ -699,20 +680,43 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "7941c76a",
|
||||||
"metadata": {},
|
"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."
|
"source": [
|
||||||
|
"### What PC3 adds that PC2 doesn't\n",
|
||||||
|
"\n",
|
||||||
|
"It's fair to ask what's left for PC3 to do. PC2's *score* also drifts with the clock — kiddie-heavy in the morning, midway-heavy by night — 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",
|
||||||
|
"\n",
|
||||||
|
"The mechanics: each component assigns every ride a **single number** (its loading), and the axes are built to be independent — here PC2 and PC3 correlate **0.00**. PC2 spends its one-number-per-ride on a single question — *kiddie corner or main midway?* — 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",
|
"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,
|
"execution_count": null,
|
||||||
"outputs": []
|
"id": "1c9deb70",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# PC2 calls these two rides almost identical (both lean \"main midway\").\n",
|
||||||
|
"# PC3 sends them to opposite ends.\n",
|
||||||
|
"loadings.loc[[\"tilt-a-whirl\", \"giant_wheel\"], [\"PC2\", \"PC3\"]].round(3)"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "5d738e76",
|
||||||
"metadata": {},
|
"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."
|
"source": [
|
||||||
|
"PC2 puts them practically side by side (+0.16 and +0.20); PC3 sends them to opposite poles (+0.15 vs −0.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 — 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",
|
||||||
|
"\n",
|
||||||
|
"It 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** — *how many* people are here? (overall busyness)\n",
|
||||||
|
"- **PC2** — *which half* of the park are they in? (kiddie corner ↔ main midway)\n",
|
||||||
|
"- **PC3** — among the midway rides, *which kind* is peaking? (daytime family flat-rides ↔ evening marquee rides)\n",
|
||||||
|
"\n",
|
||||||
|
"The gut-check for what \"independent axes\" really buys you: two moments can share an identical PC1 **and** PC2 — same crowd size, same kiddie/midway balance — 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",
|
"cell_type": "markdown",
|
||||||
@@ -721,8 +725,8 @@
|
|||||||
"source": [
|
"source": [
|
||||||
"## The map of the park, drawn from wait times alone\n",
|
"## The map of the park, drawn from wait times alone\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Here's the payoff. Plot every ride by its PC2 and PC3 loadings \u2014 its *behavioral*\n",
|
"Here's the payoff. Plot every ride by its PC2 and PC3 loadings — its *behavioral*\n",
|
||||||
"position \u2014 and color it by the park's category tag. Remember: the **position**\n",
|
"position — 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",
|
"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",
|
"never told what any ride is or where it sits.\n",
|
||||||
"\n",
|
"\n",
|
||||||
@@ -778,7 +782,7 @@
|
|||||||
" ax.annotate(ride, (p[\"PC2\"], p[\"PC3\"]), fontsize=8.5,\n",
|
" ax.annotate(ride, (p[\"PC2\"], p[\"PC3\"]), fontsize=8.5,\n",
|
||||||
" xytext=(dx, dy), textcoords=\"offset points\", zorder=4)\n",
|
" xytext=(dx, dy), textcoords=\"offset points\", zorder=4)\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# the two Family-tagged rides that behave like kiddie rides \u2014 pull labels out with arrows\n",
|
"# the two Family-tagged rides that behave like kiddie rides — pull labels out with arrows\n",
|
||||||
"misfiled = {\"s&g_carousel\": (-70, -30), \"kiddie_himalaya\": (-78, 20)}\n",
|
"misfiled = {\"s&g_carousel\": (-70, -30), \"kiddie_himalaya\": (-78, 20)}\n",
|
||||||
"for ride, (dx, dy) in misfiled.items():\n",
|
"for ride, (dx, dy) in misfiled.items():\n",
|
||||||
" p = annotated.loc[ride]\n",
|
" p = annotated.loc[ride]\n",
|
||||||
@@ -788,9 +792,9 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"ax.axhline(0, color=\"grey\", lw=0.5)\n",
|
"ax.axhline(0, color=\"grey\", lw=0.5)\n",
|
||||||
"ax.axvline(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_xlabel(\"PC2 Kiddieland corner ← → main midway\")\n",
|
||||||
"ax.set_ylabel(\"PC3 evening crowd \u2190 \u2192 daytime / school-group daypart\")\n",
|
"ax.set_ylabel(\"PC3 evening crowd ← → daytime / school-group daypart\")\n",
|
||||||
"ax.set_title(\"Knoebels rides in PC2 \u00d7 PC3 space\\n\"\n",
|
"ax.set_title(\"Knoebels rides in PC2 × PC3 space\\n\"\n",
|
||||||
" \"(position comes from queue patterns alone; color = the park's category tag)\")\n",
|
" \"(position comes from queue patterns alone; color = the park's category tag)\")\n",
|
||||||
"ax.legend(title=\"Category tag\", loc=\"lower left\")\n",
|
"ax.legend(title=\"Category tag\", loc=\"lower left\")\n",
|
||||||
"plt.tight_layout()"
|
"plt.tight_layout()"
|
||||||
@@ -803,8 +807,8 @@
|
|||||||
"source": [
|
"source": [
|
||||||
"## What I took away\n",
|
"## What I took away\n",
|
||||||
"\n",
|
"\n",
|
||||||
"- **PCA found real structure with zero domain input.** Three axes \u2014 busyness,\n",
|
"- **PCA found real structure with zero domain input.** Three axes — busyness,\n",
|
||||||
" geography, time of day \u2014 fell out of nothing but wait times, and each one maps\n",
|
" geography, time of day — 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",
|
" to something you could verify against the park independently (the average wait,\n",
|
||||||
" the GPS map, the operating schedule).\n",
|
" the GPS map, the operating schedule).\n",
|
||||||
"- **Standardizing matters twice.** Once before PCA (so small rides aren't\n",
|
"- **Standardizing matters twice.** Once before PCA (so small rides aren't\n",
|
||||||
@@ -823,14 +827,15 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"kernelspec": {
|
"kernelspec": {
|
||||||
"display_name": "Python 3",
|
"display_name": ".venv",
|
||||||
"language": "python",
|
"language": "python",
|
||||||
"name": "python3"
|
"name": "python3"
|
||||||
},
|
},
|
||||||
"language_info": {
|
"language_info": {
|
||||||
"name": "python"
|
"name": "python",
|
||||||
|
"version": "3.14.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nbformat": 4,
|
"nbformat": 4,
|
||||||
"nbformat_minor": 5
|
"nbformat_minor": 5
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user