Add "How Busy?" tab: 4 learned tiers + dual logistic regression
New dashboard tab that rates current park crowding:
- Tiers: k-means on park-wide mean wait yields 4 ordinal levels
("Walk right on" / "Pleasant" / "Packed" / "What were you
thinking?!"), with the rare 0.6% zoo tail isolated as its own
tier rather than buried in a quartile.
- Board-reading model: multinomial LR on per-ride waits -> tier.
Drives the live gauge (current avg wait + predicted tier +
confidence + class-probability bar). Near-perfect by construction
-- honest framing baked into the model card.
- Calendar-forecast model: LR on hour/dow/month/weekend -> tier,
evaluated with GroupKFold by date to avoid same-day leakage.
Drives the "expected vs actual" verdict for the current moment.
- Bellwether rides bar (top tier coefficients) and a confusion-
matrix expander tell the honest story: forecast barely beats
baseline on everyday tiers, but pins the zoo tier nearly 100% --
because Knoebels' worst crowds are locked to specific October
festival weekends. "The chaos has a schedule."
Implementation notes: trained models cached via st.cache_resource
(6h), live snapshot fetched separately on a 5-min ttl. Context
features use a fixed column set so live inference always aligns.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,29 @@ PC_BLURB = {
|
|||||||
}
|
}
|
||||||
CATEGORY_COLORS = {"Family": "#4C9BE8", "Kiddie": "#E8A33D", "Thrill": "#E0524E"}
|
CATEGORY_COLORS = {"Family": "#4C9BE8", "Kiddie": "#E8A33D", "Thrill": "#E0524E"}
|
||||||
|
|
||||||
|
# --- Busyness tiers ("How busy is it?" tab) --------------------------------
|
||||||
|
# Four ordinal levels of crowding, learned from the data (k-means on the
|
||||||
|
# park-wide mean wait), low -> high. Names are deliberately playful.
|
||||||
|
TIER_NAMES = ["Walk right on", "Pleasant", "Packed", "What were you thinking?!"]
|
||||||
|
TIER_EMOJI = ["🚶", "😎", "😤", "🥵"]
|
||||||
|
TIER_COLORS = ["#2E8B57", "#E8C53D", "#E8843D", "#E0524E"] # green→gold→orange→red
|
||||||
|
|
||||||
|
|
||||||
|
def _context_features(ts):
|
||||||
|
"""Calendar/clock features for the forecast model. Fixed column set so
|
||||||
|
training and live inference always line up, regardless of which months or
|
||||||
|
days happen to be present."""
|
||||||
|
ts = pd.DatetimeIndex(ts)
|
||||||
|
F = pd.DataFrame(index=range(len(ts)))
|
||||||
|
F["hour_sin"] = np.sin(2 * np.pi * ts.hour / 24)
|
||||||
|
F["hour_cos"] = np.cos(2 * np.pi * ts.hour / 24)
|
||||||
|
F["weekend"] = (ts.weekday >= 5).astype(int)
|
||||||
|
for d in range(7):
|
||||||
|
F[f"dow_{d}"] = (ts.weekday == d).astype(int)
|
||||||
|
for mo in range(4, 11): # Knoebels' April–October season
|
||||||
|
F[f"mo_{mo}"] = (ts.month == mo).astype(int)
|
||||||
|
return F
|
||||||
|
|
||||||
|
|
||||||
@st.cache_data(ttl=21600) # 6h — underlying history grows slowly, PCA is heavy
|
@st.cache_data(ttl=21600) # 6h — underlying history grows slowly, PCA is heavy
|
||||||
def compute_pca():
|
def compute_pca():
|
||||||
@@ -206,6 +229,122 @@ def compute_pca():
|
|||||||
"evr": pca.explained_variance_ratio_,
|
"evr": pca.explained_variance_ratio_,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource(ttl=21600) # 6h — heavy; holds fitted sklearn models
|
||||||
|
def compute_busyness_model():
|
||||||
|
"""Learn 4 busyness tiers from the data, then train two classifiers:
|
||||||
|
|
||||||
|
- **board-reading**: per-ride waits -> tier. Near-perfect by construction
|
||||||
|
(the tier is derived from the mean of those very waits); it's the live
|
||||||
|
gauge and its coefficients name the 'bellwether' rides.
|
||||||
|
- **calendar forecast**: hour/day/month/weekend -> tier. The honest,
|
||||||
|
non-circular model — weak on the everyday tiers but it pins the rare
|
||||||
|
'zoo' tier, because those days are locked to specific festival weekends.
|
||||||
|
"""
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
from sklearn.model_selection import cross_val_predict, GroupKFold, StratifiedKFold
|
||||||
|
from sklearn.metrics import accuracy_score, confusion_matrix
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
waits = pd.read_sql(
|
||||||
|
"""
|
||||||
|
SELECT a.time_stamp, r.ride AS ride_name, a.queue_time AS queue_time_sec
|
||||||
|
FROM knoebels."LZ_attractions_io" a
|
||||||
|
JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id
|
||||||
|
WHERE a.queue_time IS NOT NULL
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
if waits.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
waits["time_stamp"] = pd.to_datetime(waits["time_stamp"])
|
||||||
|
waits["queue_time_min"] = pd.to_numeric(waits["queue_time_sec"], errors="coerce") / 60.0
|
||||||
|
M = (
|
||||||
|
waits.assign(ts=waits["time_stamp"].dt.floor("5min"))
|
||||||
|
.pivot_table(index="ts", columns="ride_name", values="queue_time_min", aggfunc="mean")
|
||||||
|
.sort_index().ffill().fillna(0)
|
||||||
|
.drop(columns=["powersurge"], errors="ignore")
|
||||||
|
)
|
||||||
|
M = M[(M.index.hour >= 10) & (M.index.hour <= 22)] # operating hours
|
||||||
|
if len(M) < 500:
|
||||||
|
return None
|
||||||
|
|
||||||
|
busy = M.mean(axis=1) # busyness scalar = park-wide mean wait per snapshot
|
||||||
|
|
||||||
|
# --- learn the tiers: k-means on the 1-D busyness scalar, ordered low->high
|
||||||
|
km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(busy.values.reshape(-1, 1))
|
||||||
|
rank = np.zeros(4, int)
|
||||||
|
rank[np.argsort(km.cluster_centers_.ravel())] = np.arange(4)
|
||||||
|
y = rank[km.labels_]
|
||||||
|
centers = np.sort(km.cluster_centers_.ravel())
|
||||||
|
bounds = [(centers[i] + centers[i + 1]) / 2 for i in range(3)]
|
||||||
|
|
||||||
|
# --- board-reading model: per-ride waits -> tier
|
||||||
|
bscaler = StandardScaler().fit(M.values)
|
||||||
|
Xb = bscaler.transform(M.values)
|
||||||
|
board = LogisticRegression(max_iter=2000, class_weight="balanced")
|
||||||
|
yb = cross_val_predict(board, Xb, y, cv=StratifiedKFold(5, shuffle=True, random_state=0))
|
||||||
|
board.fit(Xb, y)
|
||||||
|
bell = pd.Series(board.coef_[3], index=M.columns).sort_values() # top-tier drivers
|
||||||
|
|
||||||
|
# --- calendar forecast model: time context -> tier (grouped by date)
|
||||||
|
F = _context_features(M.index)
|
||||||
|
cscaler = StandardScaler().fit(F.values)
|
||||||
|
Xc = cscaler.transform(F.values)
|
||||||
|
cal = LogisticRegression(max_iter=3000, class_weight="balanced")
|
||||||
|
yc = cross_val_predict(cal, Xc, y, cv=GroupKFold(5),
|
||||||
|
groups=pd.DatetimeIndex(M.index).date)
|
||||||
|
cal.fit(Xc, y)
|
||||||
|
cal_cm = confusion_matrix(y, yc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rides": list(M.columns),
|
||||||
|
"centers": centers, "bounds": bounds,
|
||||||
|
"busy_min": float(busy.min()), "busy_max": float(busy.max()),
|
||||||
|
"tier_share": (np.bincount(y, minlength=4) / len(y)),
|
||||||
|
"n": int(len(M)),
|
||||||
|
"board_scaler": bscaler, "board_clf": board,
|
||||||
|
"board_acc": float(accuracy_score(y, yb)),
|
||||||
|
"board_cm": confusion_matrix(y, yb),
|
||||||
|
"bellwether": bell,
|
||||||
|
"cal_scaler": cscaler, "cal_clf": cal, "cal_cols": list(F.columns),
|
||||||
|
"cal_acc": float(accuracy_score(y, yc)), "cal_cm": cal_cm,
|
||||||
|
"cal_within1": float(np.mean(np.abs(y - yc) <= 1)),
|
||||||
|
"baseline": float(np.mean(y == np.bincount(y).argmax())),
|
||||||
|
"zoo_recall": float(cal_cm[3, 3] / cal_cm[3].sum()) if cal_cm[3].sum() else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=300) # 5 min — the live snapshot
|
||||||
|
def latest_ride_waits():
|
||||||
|
"""Most recent non-null wait (minutes) per ride, plus the reading's time."""
|
||||||
|
conn = get_db_connection()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
df = pd.read_sql(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (r.ride) r.ride AS ride_name,
|
||||||
|
a.queue_time AS queue_time_sec, a.time_stamp
|
||||||
|
FROM knoebels."LZ_attractions_io" a
|
||||||
|
JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id
|
||||||
|
WHERE a.queue_time IS NOT NULL
|
||||||
|
ORDER BY r.ride, a.time_stamp DESC
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
if df.empty:
|
||||||
|
return None
|
||||||
|
waits = pd.to_numeric(df["queue_time_sec"], errors="coerce") / 60.0
|
||||||
|
return (pd.Series(waits.values, index=df["ride_name"]),
|
||||||
|
pd.to_datetime(df["time_stamp"]).max())
|
||||||
|
|
||||||
# --- Header ---
|
# --- Header ---
|
||||||
st.title("🎢 Knoebels Queue Time Dashboard")
|
st.title("🎢 Knoebels Queue Time Dashboard")
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
@@ -231,8 +370,9 @@ else:
|
|||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
# --- Tabs ---
|
# --- Tabs ---
|
||||||
tab1, tab2, tab3, tab4 = st.tabs(
|
tab1, tab2, tab3, tab4, tab5 = st.tabs(
|
||||||
["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer", "🧠 Park Mind (PCA)"]
|
["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer",
|
||||||
|
"🧠 Park Mind (PCA)", "🎟️ How Busy?"]
|
||||||
)
|
)
|
||||||
|
|
||||||
with tab1:
|
with tab1:
|
||||||
@@ -608,6 +748,141 @@ with tab4:
|
|||||||
fig_hour.add_hline(y=0, line_width=0.5, line_color="grey")
|
fig_hour.add_hline(y=0, line_width=0.5, line_color="grey")
|
||||||
st.plotly_chart(fig_hour, use_container_width=True)
|
st.plotly_chart(fig_hour, use_container_width=True)
|
||||||
|
|
||||||
|
with tab5:
|
||||||
|
st.subheader("🎟️ How busy is Knoebels right now?")
|
||||||
|
st.markdown(
|
||||||
|
"Two seasons of wait times sort the park into **four levels of crowding** "
|
||||||
|
"(k-means on the park-wide average wait), from a quiet walk-on day to the "
|
||||||
|
"rare zoo. A model rates the **current** moment — and a second model asks "
|
||||||
|
"the harder question: *how busy* should *it be right now?*"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
B = compute_busyness_model()
|
||||||
|
except ImportError:
|
||||||
|
st.error("`scikit-learn` isn't installed in this image yet — rebuild the "
|
||||||
|
"dashboard container to enable this tab.")
|
||||||
|
B = None
|
||||||
|
|
||||||
|
if B is None:
|
||||||
|
st.info("Not enough data to train the busyness model right now.")
|
||||||
|
else:
|
||||||
|
# tier scale legend
|
||||||
|
edges = [B["busy_min"], *B["bounds"], B["busy_max"]]
|
||||||
|
legend = " · ".join(
|
||||||
|
f"{TIER_EMOJI[i]} **{TIER_NAMES[i]}** ({edges[i]:.0f}–{edges[i+1]:.0f} min, "
|
||||||
|
f"{B['tier_share'][i]:.0%})" for i in range(4))
|
||||||
|
st.caption("The four levels (avg wait across rides, share of all snapshots): " + legend)
|
||||||
|
|
||||||
|
live = latest_ride_waits()
|
||||||
|
st.markdown("---")
|
||||||
|
gcol, ecol = st.columns([3, 2])
|
||||||
|
|
||||||
|
if live is None:
|
||||||
|
gcol.info("No recent readings to rate.")
|
||||||
|
else:
|
||||||
|
waits, ts = live
|
||||||
|
vec = waits.reindex(B["rides"]).fillna(0.0) # closed/missing = no line
|
||||||
|
cur = float(vec.mean())
|
||||||
|
p = np.zeros(4)
|
||||||
|
proba = B["board_clf"].predict_proba(
|
||||||
|
B["board_scaler"].transform(vec.values.reshape(1, -1)))[0]
|
||||||
|
for c, pp in zip(B["board_clf"].classes_, proba):
|
||||||
|
p[int(c)] = pp
|
||||||
|
tier = int(p.argmax())
|
||||||
|
|
||||||
|
with gcol:
|
||||||
|
gauge = go.Figure(go.Indicator(
|
||||||
|
mode="gauge+number", value=round(cur, 1),
|
||||||
|
number={"suffix": " min", "font": {"size": 40}},
|
||||||
|
gauge={
|
||||||
|
"axis": {"range": [B["busy_min"], B["busy_max"]]},
|
||||||
|
"bar": {"color": "rgba(0,0,0,0)"},
|
||||||
|
"steps": [{"range": [edges[i], edges[i + 1]], "color": TIER_COLORS[i]}
|
||||||
|
for i in range(4)],
|
||||||
|
"threshold": {"line": {"color": "white", "width": 5}, "value": cur},
|
||||||
|
},
|
||||||
|
title={"text": f"{TIER_EMOJI[tier]} <b>{TIER_NAMES[tier]}</b>",
|
||||||
|
"font": {"size": 26}},
|
||||||
|
))
|
||||||
|
gauge.update_layout(height=320, margin=dict(l=30, r=30, t=60, b=0))
|
||||||
|
st.plotly_chart(gauge, use_container_width=True)
|
||||||
|
st.caption(f"Reading from **{ts:%b %d, %-I:%M %p}** · park-wide average "
|
||||||
|
f"wait **{cur:.1f} min** · model confidence **{p[tier]:.0%}**")
|
||||||
|
|
||||||
|
with ecol:
|
||||||
|
st.markdown("##### Model's call")
|
||||||
|
conf = pd.DataFrame({"Level": TIER_NAMES, "Probability": p})
|
||||||
|
fig_conf = px.bar(conf, x="Probability", y="Level", orientation="h",
|
||||||
|
color="Level", color_discrete_sequence=TIER_COLORS,
|
||||||
|
range_x=[0, 1])
|
||||||
|
fig_conf.update_layout(showlegend=False, height=200,
|
||||||
|
yaxis={"categoryorder": "array",
|
||||||
|
"categoryarray": TIER_NAMES[::-1]},
|
||||||
|
margin=dict(l=0, r=0, t=0, b=0))
|
||||||
|
st.plotly_chart(fig_conf, use_container_width=True)
|
||||||
|
|
||||||
|
# expected for this day/time, from the calendar model
|
||||||
|
Fn = _context_features(pd.DatetimeIndex([ts])).reindex(
|
||||||
|
columns=B["cal_cols"], fill_value=0)
|
||||||
|
exp = int(B["cal_clf"].predict(B["cal_scaler"].transform(Fn.values))[0])
|
||||||
|
verdict = ("about what you'd expect" if exp == tier else
|
||||||
|
"**busier** than usual" if tier > exp else "**quieter** than usual")
|
||||||
|
st.markdown(
|
||||||
|
f"##### Expected vs. actual\n"
|
||||||
|
f"For a **{ts:%A in %B}**, a typical moment is "
|
||||||
|
f"**{TIER_NAMES[exp]}** {TIER_EMOJI[exp]}. Right now it's "
|
||||||
|
f"**{TIER_NAMES[tier]}** {TIER_EMOJI[tier]} — {verdict}.")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# --- bellwether rides + the honest model cards ---
|
||||||
|
bcol, mcol = st.columns([2, 3])
|
||||||
|
with bcol:
|
||||||
|
st.markdown("##### Bellwether rides")
|
||||||
|
st.caption("Lines that most signal a *zoo* day, from the board model's "
|
||||||
|
"coefficients. (Loadings are noisy under collinearity — read "
|
||||||
|
"as a leaderboard, not exact weights.)")
|
||||||
|
top = B["bellwether"].tail(10).iloc[::-1]
|
||||||
|
fig_bell = px.bar(x=top.values, y=top.index, orientation="h",
|
||||||
|
labels={"x": "coefficient (→ Zoo tier)", "y": ""},
|
||||||
|
color=top.values, color_continuous_scale="OrRd")
|
||||||
|
fig_bell.update_layout(coloraxis_showscale=False, height=360,
|
||||||
|
yaxis={"categoryorder": "total ascending"},
|
||||||
|
margin=dict(l=0, r=0, t=0, b=0))
|
||||||
|
st.plotly_chart(fig_bell, use_container_width=True)
|
||||||
|
|
||||||
|
with mcol:
|
||||||
|
st.markdown("##### Two models, two honest stories")
|
||||||
|
m1, m2 = st.columns(2)
|
||||||
|
m1.metric("Board-reading accuracy", f"{B['board_acc']:.1%}",
|
||||||
|
help="Per-ride waits → tier. Near-perfect by construction — the "
|
||||||
|
"tier is the mean of those very waits, so this just 'reads "
|
||||||
|
"the board'. Great gauge, trivial as prediction.")
|
||||||
|
m2.metric("Calendar-forecast accuracy", f"{B['cal_acc']:.1%}",
|
||||||
|
delta=f"{B['cal_acc'] - B['baseline']:+.1%} vs. guessing",
|
||||||
|
help="Hour/day/month/weekend → tier. The non-circular model.")
|
||||||
|
st.markdown(
|
||||||
|
f"**The chaos has a schedule.** From the calendar alone you *can't* "
|
||||||
|
f"tell a quiet day from a pleasant one — crowd noise (weather, events, "
|
||||||
|
f"luck) swamps it, so the forecast model ({B['cal_acc']:.0%}) barely "
|
||||||
|
f"beats always-guessing ({B['baseline']:.0%}). **But it flags the zoo "
|
||||||
|
f"days {B['zoo_recall']:.0%} of the time** — because Knoebels' worst "
|
||||||
|
f"crowds are locked to specific October festival weekends. The everyday "
|
||||||
|
f"is unpredictable; the extreme is on the calendar.")
|
||||||
|
with st.expander("Calendar-model confusion matrix (where it succeeds & fails)"):
|
||||||
|
cm = B["cal_cm"]
|
||||||
|
cmn = cm / cm.sum(axis=1, keepdims=True)
|
||||||
|
fig_cm = px.imshow(cmn, x=TIER_NAMES, y=TIER_NAMES, text_auto=".0%",
|
||||||
|
color_continuous_scale="Blues", zmin=0, zmax=1,
|
||||||
|
labels={"x": "predicted", "y": "actual",
|
||||||
|
"color": "row share"})
|
||||||
|
fig_cm.update_layout(height=380, margin=dict(l=0, r=0, t=10, b=0))
|
||||||
|
st.plotly_chart(fig_cm, use_container_width=True)
|
||||||
|
st.caption("Rows sum to 100%. Note the bottom-right cell: the rare "
|
||||||
|
"'What were you thinking?!' tier is caught almost every time, "
|
||||||
|
"while the three everyday tiers bleed into each other.")
|
||||||
|
|
||||||
# --- Footer ---
|
# --- Footer ---
|
||||||
st.sidebar.markdown("---")
|
st.sidebar.markdown("---")
|
||||||
st.sidebar.caption(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
st.sidebar.caption(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|||||||
Reference in New Issue
Block a user