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"}
|
||||
|
||||
# --- 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
|
||||
def compute_pca():
|
||||
@@ -206,6 +229,122 @@ def compute_pca():
|
||||
"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 ---
|
||||
st.title("🎢 Knoebels Queue Time Dashboard")
|
||||
st.markdown("---")
|
||||
@@ -231,8 +370,9 @@ else:
|
||||
st.stop()
|
||||
|
||||
# --- Tabs ---
|
||||
tab1, tab2, tab3, tab4 = st.tabs(
|
||||
["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer", "🧠 Park Mind (PCA)"]
|
||||
tab1, tab2, tab3, tab4, tab5 = st.tabs(
|
||||
["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer",
|
||||
"🧠 Park Mind (PCA)", "🎟️ How Busy?"]
|
||||
)
|
||||
|
||||
with tab1:
|
||||
@@ -608,6 +748,141 @@ with tab4:
|
||||
fig_hour.add_hline(y=0, line_width=0.5, line_color="grey")
|
||||
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 ---
|
||||
st.sidebar.markdown("---")
|
||||
st.sidebar.caption(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
Reference in New Issue
Block a user