Add interactive 3D PCA views to dashboard Park Mind tab

Two spin/zoom Plotly scatter_3d sub-tabs in the PCA tab:
- Rides in PC1xPC2xPC3 (loadings), color by category / GPS
  k-means area / PC1 busyness. Adds GPS k-means areas to
  compute_pca(), auto-labeling the lowest-PC2 cluster Kiddieland.
- Moments in PC1xPC2xPC3 (per-snapshot scores), color by hour /
  day-of-week / weekend / month, sampled to 15k points if larger.

Pin numpy in requirements (now imported directly).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wes
2026-05-27 18:53:55 -04:00
co-authored by Claude Opus 4.7
parent ade774f5aa
commit 53109bc7fe
2 changed files with 112 additions and 0 deletions
+111
View File
@@ -1,6 +1,7 @@
# Knoebels Queue Dashboard v1.1 - Scaled wait times fix # Knoebels Queue Dashboard v1.1 - Scaled wait times fix
import streamlit as st import streamlit as st
import pandas as pd import pandas as pd
import numpy as np
import psycopg2 import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
import plotly.express as px import plotly.express as px
@@ -113,6 +114,7 @@ def compute_pca():
coordinates, so the same DataFrame drives the behavioral map.""" coordinates, so the same DataFrame drives the behavioral map."""
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
conn = get_db_connection() conn = get_db_connection()
if not conn: if not conn:
@@ -167,6 +169,20 @@ def compute_pca():
columns=["PC1", "PC2", "PC3"]) columns=["PC1", "PC2", "PC3"])
loadings = loadings.join(meta) loadings = loadings.join(meta)
# Spatial "areas" from GPS alone — k-means on standardized lat/lon, the same
# 5 clusters the write-up uses. Label the one whose rides lean most negative
# on PC2 as Kiddieland so the colors read in plain English.
located = loadings.dropna(subset=["lat", "lon"])
if len(located) >= 5:
km = KMeans(n_clusters=5, n_init=10, random_state=0).fit_predict(
StandardScaler().fit_transform(located[["lat", "lon"]]))
area = pd.Series(km, index=located.index)
kiddie = loadings.loc[area.index].assign(a=area).groupby("a")["PC2"].mean().idxmin()
names = {kiddie: "Kiddieland"}
loadings["area"] = area.map(lambda a: names.get(a, f"Area {a + 1}"))
else:
loadings["area"] = "Untagged"
return { return {
"loadings": loadings, "loadings": loadings,
"scores": scores, "scores": scores,
@@ -415,6 +431,101 @@ with tab4:
st.markdown("---") st.markdown("---")
# --- the same idea in 3D: add PC1 as a third axis -------------------
st.markdown("#### The full 3-D picture")
st.markdown(
"The map above flattens out **PC1** (overall busyness) to fit on a "
"page. Here are both clouds in their native 3-D — click-drag to spin, "
"scroll to zoom. Two different things get plotted:"
)
rides_tab, moments_tab = st.tabs(
["🎢 Rides in PC-space", "⏱️ Moments in PC-space"])
# ---- A) each RIDE as a point (the loadings) ----
with rides_tab:
st.caption(
"Every **ride** placed by how its queue behaves. Notice the cloud "
"is nearly flat along **PC1** — almost every ride loads the same "
"way on overall-busyness, so it barely separates them. All the "
"real structure lives in the PC2/PC3 plane you saw above.")
ride_color = st.selectbox(
"Color the rides by",
["Park category tag", "Spatial area (GPS k-means)",
"Overall busyness (PC1 loading)"],
key="ride_color3d")
rdf = loadings.reset_index().copy()
rdf["category"] = rdf["category"].fillna("Untagged")
common = dict(x="PC1", y="PC2", z="PC3", hover_name="ride_name", height=640)
if ride_color == "Park category tag":
fig3d_r = px.scatter_3d(rdf, color="category",
color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common)
elif ride_color == "Spatial area (GPS k-means)":
fig3d_r = px.scatter_3d(rdf, color="area", **common)
else:
fig3d_r = px.scatter_3d(rdf, color="PC1",
color_continuous_scale="Viridis", **common)
fig3d_r.update_traces(marker=dict(size=5, line=dict(width=0.5, color="#1A1C18")))
fig3d_r.update_layout(
margin=dict(l=0, r=0, t=0, b=0),
scene=dict(xaxis_title="PC1 · busyness",
yaxis_title="PC2 · Kiddieland↔midway",
zaxis_title="PC3 · evening↔daytime"))
st.plotly_chart(fig3d_r, use_container_width=True)
# ---- B) each MOMENT as a point (the scores) ----
with moments_tab:
st.caption(
"Every **5-minute snapshot** of the park as one point — this is the "
"direct analog of an embedding cloud, but each dot is a *moment*, "
"not a note. Unlike the rides, the moments spread on all three "
"axes: a packed evening sits far out on PC1, a kiddie-heavy morning "
"pulls toward the PC2/PC3 corners.")
mom_color = st.selectbox(
"Color the moments by",
["Hour of day", "Day of week", "Weekend vs. weekday", "Month"],
key="mom_color3d")
sdf = scores.copy()
idx = pd.DatetimeIndex(sdf.index)
sdf["Hour of day"] = idx.hour
sdf["Day of week"] = idx.day_name()
sdf["Weekend vs. weekday"] = np.where(idx.weekday >= 5, "Weekend", "Weekday")
sdf["Month"] = idx.month_name()
# keep it responsive — sample if the history is large
cap = 15000
note = ""
if len(sdf) > cap:
sdf = sdf.sample(cap, random_state=0)
note = f" *(showing a random {cap:,} of {len(scores):,} snapshots)*"
common_m = dict(x="PC1", y="PC2", z="PC3", height=640, opacity=0.55)
if mom_color == "Hour of day":
fig3d_m = px.scatter_3d(sdf, color="Hour of day",
color_continuous_scale="Turbo", **common_m)
elif mom_color == "Month":
order = [pd.Timestamp(2020, m, 1).month_name() for m in range(1, 13)]
fig3d_m = px.scatter_3d(sdf, color="Month",
category_orders={"Month": order}, **common_m)
elif mom_color == "Day of week":
order = ["Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"]
fig3d_m = px.scatter_3d(sdf, color="Day of week",
category_orders={"Day of week": order}, **common_m)
else:
fig3d_m = px.scatter_3d(sdf, color="Weekend vs. weekday",
color_discrete_map={"Weekday": "#4C9BE8", "Weekend": "#E0524E"},
**common_m)
fig3d_m.update_traces(marker=dict(size=2.5))
fig3d_m.update_layout(
margin=dict(l=0, r=0, t=0, b=0),
scene=dict(xaxis_title="PC1 · busyness",
yaxis_title="PC2 · Kiddieland↔midway",
zaxis_title="PC3 · evening↔daytime"))
st.plotly_chart(fig3d_m, use_container_width=True)
if note:
st.caption(note.strip(" *"))
st.markdown("---")
# --- loadings explorer: which rides drive a chosen component --- # --- loadings explorer: which rides drive a chosen component ---
lcol, rcol = st.columns([1, 2]) lcol, rcol = st.columns([1, 2])
with lcol: with lcol:
+1
View File
@@ -2,6 +2,7 @@ requests
psycopg2-binary psycopg2-binary
streamlit streamlit
pandas pandas
numpy
plotly plotly
python-dotenv python-dotenv
scikit-learn scikit-learn