diff --git a/app.py b/app.py index 22a9011..96587a3 100644 --- a/app.py +++ b/app.py @@ -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,37 +414,60 @@ 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." ) - 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, - ) - fig_map_pca.update_traces(textposition="top center", - textfont=dict(size=9, color="#9aa0a6"), - marker=dict(size=11, line=dict(width=1, color="#1A1C18"))) - fig_map_pca.add_hline(y=0, line_width=0.5, line_color="grey") - fig_map_pca.add_vline(x=0, line_width=0.5, line_color="grey") - fig_map_pca.update_layout(legend=dict(orientation="h", yanchor="bottom", y=1.02)) - st.plotly_chart(fig_map_pca, use_container_width=True) + 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") + 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"))) + fig_map_pca.add_hline(y=0, line_width=0.5, line_color="grey") + fig_map_pca.add_vline(x=0, line_width=0.5, line_color="grey") + fig_map_pca.update_layout(legend=dict(orientation="h", yanchor="bottom", y=1.02)) + st.plotly_chart(fig_map_pca, use_container_width=True) st.markdown("---") @@ -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")