Expose PC4 and make the PCA behavioral map configurable

- compute_pca now fits 4 components; PC4 added to PC_LABELS/PC_BLURB
  ("off-peak spread vs headliner pull", ~5% variance, framed as
  diminishing returns).
- "Read an axis" loadings explorer gains PC4.
- The behavioral-map scatter becomes interactive: pick any two
  components for the X/Y axes, and color by category tag, spatial
  area, or any component's loading (continuous RdBu, symmetric).
- Headline caption pinned to evr[:3] so "3 axes = 60%" stays true;
  the by-hour clock stays PC1-3 to preserve the three-axes story.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wes
2026-05-27 19:54:52 -04:00
co-authored by Claude Opus 4.7
parent e65d1c796c
commit ad163cb996
+53 -22
View File
@@ -91,6 +91,7 @@ PC_LABELS = {
"PC1": "Overall busyness",
"PC2": "Kiddieland ↔ main midway",
"PC3": "Daytime crowd ↔ evening crowd",
"PC4": "Off-peak spread ↔ headliner pull",
}
PC_BLURB = {
"PC1": "How many people are in the park. Every ride loads the same sign — "
@@ -101,6 +102,13 @@ PC_BLURB = {
"PC3": "What time of day it is. Daytime family flat-rides the school/daycare "
"groups hit at open (positive) vs. the evening coaster & date-night "
"crowd (negative).",
"PC4": "The faint fourth axis (~5% of variance — diminishing returns). A "
"contrast between a few secondary rides (super round-up, tumbling "
"timbers, flying tigers — positive) and the headliners (Phoenix, "
"Impulse, Flying Turns — negative). Its score drifts with the "
"calendar: positive in quiet spring weekday mornings, negative on "
"busy summer/fall weekend evenings when demand piles onto the "
"marquee rides. Real, but mostly where the clean signal ends.",
}
CATEGORY_COLORS = {"Family": "#4C9BE8", "Kiddie": "#E8A33D", "Thrill": "#E0524E"}
@@ -161,12 +169,12 @@ def compute_pca():
)
scaled = StandardScaler().fit_transform(matrix) # each ride mean 0, var 1
pca = PCA(n_components=3)
pca = PCA(n_components=4) # PC4 is faint (~5%) — exposed for exploration only
sc = pca.fit_transform(scaled)
scores = pd.DataFrame(sc, index=matrix.index, columns=["PC1", "PC2", "PC3"])
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns,
columns=["PC1", "PC2", "PC3"])
pc_cols = ["PC1", "PC2", "PC3", "PC4"]
scores = pd.DataFrame(sc, index=matrix.index, columns=pc_cols)
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns, columns=pc_cols)
loadings = loadings.join(meta)
# Spatial "areas" from GPS alone — k-means on standardized lat/lon, the same
@@ -406,30 +414,53 @@ with tab4:
col.metric(f"{pc} · {PC_LABELS[pc]}", f"{evr[int(pc[-1]) - 1]:.1%}",
help=PC_BLURB[pc])
st.caption(f"Just these 3 axes (out of {loadings.shape[0]}) capture "
f"**{evr.sum():.1%}** of all the variation in park wait times.")
f"**{evr[:3].sum():.1%}** of all the variation in park wait times.")
st.markdown("---")
# --- the behavioral map: PC2 vs PC3, colored by the park's own tag ---
# --- the behavioral map: any two components, freely colored ---
st.markdown("#### The map of the park, drawn from wait times alone")
st.markdown(
"Each ride placed by its **PC2** (left↔right = Kiddieland↔midway) and "
"**PC3** (bottom↔top = evening↔daytime) loading — its *behavioral* "
"position — and colored by the park's category tag. The position comes "
"purely from how each queue moves over time, yet **the picture is the "
"park**: Kiddieland huddles left, evening thrill rides sink bottom-right, "
"daytime family rides float to the top."
"Each ride placed by two of its component loadings — its *behavioral* "
"position, drawn purely from how its queue moves over time. The "
"default (**PC2 × PC3**, colored by category) is the write-up's view: "
"Kiddieland huddles left, evening thrill rides sink bottom-right, "
"daytime family rides float to the top. **Swap either axis** to any "
"component, or **recolor by a third component's loading**, to hunt for "
"structure the default hides."
)
pcs = ["PC1", "PC2", "PC3", "PC4"]
axis_labels = {p: f"{p} · {PC_LABELS[p]}" for p in pcs}
axis_labels.update({"category": "Park's tag", "area": "Spatial area"})
mc1, mc2, mc3 = st.columns(3)
with mc1:
xpc = st.selectbox("X axis", pcs, index=1, key="map_x",
format_func=lambda p: f"{p}{PC_LABELS[p]}")
with mc2:
ypc = st.selectbox("Y axis", pcs, index=2, key="map_y",
format_func=lambda p: f"{p}{PC_LABELS[p]}")
with mc3:
color_opt = st.selectbox(
"Color by", ["Category tag", "Spatial area"] + [f"{p} loading" for p in pcs],
key="map_color")
if xpc == ypc:
st.info("Pick two *different* components for the axes.")
else:
plot_df = loadings.reset_index().copy()
plot_df["category"] = plot_df["category"].fillna("Untagged")
fig_map_pca = px.scatter(
plot_df, x="PC2", y="PC3", color="category", text="ride_name",
color_discrete_map=CATEGORY_COLORS,
labels={"PC2": "PC2 · Kiddieland ←→ main midway",
"PC3": "PC3 · evening crowd ←→ daytime crowd",
"category": "Park's tag"},
hover_name="ride_name", height=620,
)
common = dict(x=xpc, y=ypc, text="ride_name", hover_name="ride_name",
height=620, labels=axis_labels)
if color_opt == "Category tag":
fig_map_pca = px.scatter(plot_df, color="category",
color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common)
elif color_opt == "Spatial area":
fig_map_pca = px.scatter(plot_df, color="area", **common)
else:
cpc = color_opt.split()[0] # "PC3 loading" -> "PC3"
lim = plot_df[cpc].abs().max() # symmetric scale → white at 0
fig_map_pca = px.scatter(plot_df, color=cpc, color_continuous_scale="RdBu",
range_color=[-lim, lim], **common)
fig_map_pca.update_traces(textposition="top center",
textfont=dict(size=9, color="#9aa0a6"),
marker=dict(size=11, line=dict(width=1, color="#1A1C18")))
@@ -539,7 +570,7 @@ with tab4:
lcol, rcol = st.columns([1, 2])
with lcol:
st.markdown("#### Read an axis")
pick = st.radio("Component", ["PC1", "PC2", "PC3"],
pick = st.radio("Component", ["PC1", "PC2", "PC3", "PC4"],
format_func=lambda p: f"{p}{PC_LABELS[p]}")
st.info(PC_BLURB[pick])
with rcol:
@@ -565,7 +596,7 @@ with tab4:
"evening as crowds build; **PC3** humps at midday (the school-group "
"window) then falls — the fingerprint of *time of day*."
)
by_hour = scores.groupby(scores.index.hour).mean()
by_hour = scores[["PC1", "PC2", "PC3"]].groupby(scores.index.hour).mean()
by_hour.index.name = "hour"
hour_long = by_hour.reset_index().melt("hour", var_name="Component",
value_name="Mean score")