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:
@@ -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:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ requests
|
|||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
streamlit
|
streamlit
|
||||||
pandas
|
pandas
|
||||||
|
numpy
|
||||||
plotly
|
plotly
|
||||||
python-dotenv
|
python-dotenv
|
||||||
scikit-learn
|
scikit-learn
|
||||||
|
|||||||
Reference in New Issue
Block a user