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:
wes
2026-05-27 23:38:23 -04:00
co-authored by Claude Opus 4.7
parent ad163cb996
commit f7e0042ef8
+277 -2
View File
@@ -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' AprilOctober 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')}")