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:
wes
2026-07-07 22:22:37 -04:00
co-authored by Claude Opus 4.8
parent 5c09528df7
commit 94a91e29ad
2 changed files with 1136 additions and 195 deletions
+138 -133
View File
@@ -18,14 +18,14 @@
"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",
"**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",
"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",
"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 half of the park you're in**, and **what time of day it is** none of\n",
"which it was ever told."
]
},
@@ -42,15 +42,15 @@
"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",
" value forward (the line probably didn't teleport). Anything still missing \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",
" imputed zeros, so it only adds noise."
]
},
{
"cell_type": "code",
"execution_count": 16,
"execution_count": 24,
"id": "80e904a5",
"metadata": {},
"outputs": [
@@ -58,91 +58,71 @@
"name": "stdout",
"output_type": "stream",
"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": [
@@ -192,7 +172,8 @@
"\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]"
"#df_matrix.iloc[:3, :5]\n",
"df_matrix.info()"
]
},
{
@@ -203,7 +184,7 @@
"## 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",
"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",
"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",
@@ -295,7 +276,7 @@
"\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",
"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",
"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",
@@ -307,8 +288,8 @@
"\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",
"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** 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",
@@ -355,7 +336,7 @@
"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",
"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",
"\n",
"My approach for each component: look at **which rides load heavily on it** (the\n",
@@ -368,17 +349,17 @@
"id": "ef23e2c7",
"metadata": {},
"source": [
"## PC1 \u2014 the whole park breathes together\n",
"## PC1 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",
"**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",
"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",
"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° 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:"
]
@@ -408,7 +389,7 @@
"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",
" 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",
" zx = (real_quantity - real_quantity.mean()) / real_quantity.std()\n",
" zy = (pc_score - pc_score.mean()) / pc_score.std()\n",
@@ -437,7 +418,7 @@
"id": "5c88f656",
"metadata": {},
"source": [
"## PC2 \u2014 Kiddieland vs. Everything else\n",
"## PC2 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",
@@ -447,7 +428,7 @@
"### 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",
"for that to define the two groups 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",
@@ -457,7 +438,7 @@
" `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",
"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",
"geometry the PCA never touched, and see if the contrast gets sharper.\n",
"\n",
@@ -465,7 +446,7 @@
"\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",
"mean PC2 loading \"Kiddieland.\" Then I build the contrast *main-midway mean \n",
"Kiddieland mean* and check it against PC2.\n",
"\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",
"\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",
"sc = axes[0].scatter(located[\"lon\"], located[\"lat\"], c=located[\"PC2\"],\n",
" cmap=\"RdBu_r\", s=140, edgecolor=\"black\", lw=0.4)\n",
@@ -564,10 +545,10 @@
"id": "2bfb6369",
"metadata": {},
"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",
"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."
]
},
@@ -590,7 +571,7 @@
],
"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\")"
"validation_plot(contrast, scores[\"PC2\"], \"PC2\", \"Main-midway Kiddieland wait\")"
]
},
{
@@ -598,9 +579,9 @@
"id": "efbf0dda",
"metadata": {},
"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",
"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**."
]
},
@@ -609,7 +590,7 @@
"id": "d896a1de",
"metadata": {},
"source": [
"## PC3 \u2014 morning school groups vs. the evening crowd\n",
"## PC3 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",
@@ -625,7 +606,7 @@
"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",
"evening 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",
@@ -667,16 +648,16 @@
"# 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",
"axes[0].set(title=\"PC3 score by hour (+ daytime / 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",
" (\"evening rides (PC3 )\", 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",
"axes[1].set(title=\"Group wait ÷ park-wide wait, by hour\",\n",
" xlabel=\"Hour (local)\", ylabel=\"Share of park-wide mean wait\")\n",
"axes[1].legend()\n",
"plt.tight_layout()"
@@ -688,7 +669,7 @@
"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",
"climbs 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",
@@ -699,20 +680,43 @@
},
{
"cell_type": "markdown",
"id": "7941c76a",
"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",
"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": []
"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",
"id": "5d738e76",
"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",
@@ -721,8 +725,8 @@
"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",
"Here's the payoff. Plot every ride by its PC2 and PC3 loadings its *behavioral*\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",
"never told what any ride is or where it sits.\n",
"\n",
@@ -778,7 +782,7 @@
" 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",
"# 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",
"for ride, (dx, dy) in misfiled.items():\n",
" p = annotated.loc[ride]\n",
@@ -788,9 +792,9 @@
"\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",
"ax.set_xlabel(\"PC2 Kiddieland corner ← → main midway\")\n",
"ax.set_ylabel(\"PC3 evening crowd ← → daytime / school-group daypart\")\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",
"ax.legend(title=\"Category tag\", loc=\"lower left\")\n",
"plt.tight_layout()"
@@ -803,8 +807,8 @@
"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",
"- **PCA found real structure with zero domain input.** Three axes busyness,\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",
" the GPS map, the operating schedule).\n",
"- **Standardizing matters twice.** Once before PCA (so small rides aren't\n",
@@ -823,14 +827,15 @@
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
"name": "python",
"version": "3.14.4"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}