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:
@@ -91,6 +91,7 @@ PC_LABELS = {
|
|||||||
"PC1": "Overall busyness",
|
"PC1": "Overall busyness",
|
||||||
"PC2": "Kiddieland ↔ main midway",
|
"PC2": "Kiddieland ↔ main midway",
|
||||||
"PC3": "Daytime crowd ↔ evening crowd",
|
"PC3": "Daytime crowd ↔ evening crowd",
|
||||||
|
"PC4": "Off-peak spread ↔ headliner pull",
|
||||||
}
|
}
|
||||||
PC_BLURB = {
|
PC_BLURB = {
|
||||||
"PC1": "How many people are in the park. Every ride loads the same sign — "
|
"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 "
|
"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 "
|
"groups hit at open (positive) vs. the evening coaster & date-night "
|
||||||
"crowd (negative).",
|
"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"}
|
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
|
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)
|
sc = pca.fit_transform(scaled)
|
||||||
|
|
||||||
scores = pd.DataFrame(sc, index=matrix.index, columns=["PC1", "PC2", "PC3"])
|
pc_cols = ["PC1", "PC2", "PC3", "PC4"]
|
||||||
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns,
|
scores = pd.DataFrame(sc, index=matrix.index, columns=pc_cols)
|
||||||
columns=["PC1", "PC2", "PC3"])
|
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns, columns=pc_cols)
|
||||||
loadings = loadings.join(meta)
|
loadings = loadings.join(meta)
|
||||||
|
|
||||||
# Spatial "areas" from GPS alone — k-means on standardized lat/lon, the same
|
# 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%}",
|
col.metric(f"{pc} · {PC_LABELS[pc]}", f"{evr[int(pc[-1]) - 1]:.1%}",
|
||||||
help=PC_BLURB[pc])
|
help=PC_BLURB[pc])
|
||||||
st.caption(f"Just these 3 axes (out of {loadings.shape[0]}) capture "
|
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("---")
|
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("#### The map of the park, drawn from wait times alone")
|
||||||
st.markdown(
|
st.markdown(
|
||||||
"Each ride placed by its **PC2** (left↔right = Kiddieland↔midway) and "
|
"Each ride placed by two of its component loadings — its *behavioral* "
|
||||||
"**PC3** (bottom↔top = evening↔daytime) loading — its *behavioral* "
|
"position, drawn purely from how its queue moves over time. The "
|
||||||
"position — and colored by the park's category tag. The position comes "
|
"default (**PC2 × PC3**, colored by category) is the write-up's view: "
|
||||||
"purely from how each queue moves over time, yet **the picture is the "
|
"Kiddieland huddles left, evening thrill rides sink bottom-right, "
|
||||||
"park**: Kiddieland huddles left, evening thrill rides sink bottom-right, "
|
"daytime family rides float to the top. **Swap either axis** to any "
|
||||||
"daytime family rides float to the top."
|
"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 = loadings.reset_index().copy()
|
||||||
plot_df["category"] = plot_df["category"].fillna("Untagged")
|
plot_df["category"] = plot_df["category"].fillna("Untagged")
|
||||||
fig_map_pca = px.scatter(
|
common = dict(x=xpc, y=ypc, text="ride_name", hover_name="ride_name",
|
||||||
plot_df, x="PC2", y="PC3", color="category", text="ride_name",
|
height=620, labels=axis_labels)
|
||||||
color_discrete_map=CATEGORY_COLORS,
|
if color_opt == "Category tag":
|
||||||
labels={"PC2": "PC2 · Kiddieland ←→ main midway",
|
fig_map_pca = px.scatter(plot_df, color="category",
|
||||||
"PC3": "PC3 · evening crowd ←→ daytime crowd",
|
color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common)
|
||||||
"category": "Park's tag"},
|
elif color_opt == "Spatial area":
|
||||||
hover_name="ride_name", height=620,
|
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",
|
fig_map_pca.update_traces(textposition="top center",
|
||||||
textfont=dict(size=9, color="#9aa0a6"),
|
textfont=dict(size=9, color="#9aa0a6"),
|
||||||
marker=dict(size=11, line=dict(width=1, color="#1A1C18")))
|
marker=dict(size=11, line=dict(width=1, color="#1A1C18")))
|
||||||
@@ -539,7 +570,7 @@ with tab4:
|
|||||||
lcol, rcol = st.columns([1, 2])
|
lcol, rcol = st.columns([1, 2])
|
||||||
with lcol:
|
with lcol:
|
||||||
st.markdown("#### Read an axis")
|
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]}")
|
format_func=lambda p: f"{p} — {PC_LABELS[p]}")
|
||||||
st.info(PC_BLURB[pick])
|
st.info(PC_BLURB[pick])
|
||||||
with rcol:
|
with rcol:
|
||||||
@@ -565,7 +596,7 @@ with tab4:
|
|||||||
"evening as crowds build; **PC3** humps at midday (the school-group "
|
"evening as crowds build; **PC3** humps at midday (the school-group "
|
||||||
"window) then falls — the fingerprint of *time of day*."
|
"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"
|
by_hour.index.name = "hour"
|
||||||
hour_long = by_hour.reset_index().melt("hour", var_name="Component",
|
hour_long = by_hour.reset_index().melt("hour", var_name="Component",
|
||||||
value_name="Mean score")
|
value_name="Mean score")
|
||||||
|
|||||||
Reference in New Issue
Block a user