Knoebels Wait Time Models

Knoebels is my local amusement park, and their app publishes live queue times for every ride. I’ve been logging that feed every five minutes since May 2024, along with hourly weather for the park’s coordinates. Two seasons later that’s about 940k queue observations across 313 operating days, sitting in Postgres. This note covers what I did with it: the exploratory modeling in marimo, and the production pipeline that now feeds the widgets on this site’s homepage.

The data

Two collectors run on my app server:

  • Queue logger: hits the park app’s attractions feed every 5 minutes, one row per (timestamp, ride) with the posted wait. Also captures ride metadata (category, height requirements, GPS coordinates) and the day’s operating hours.
  • Weather logger: Open-Meteo hourly observations for the park (temp, humidity, precip, WMO weather code, cloud cover, wind), refreshed every 6 hours with a 7 day lookback so provisional values get corrected as better data lands.

A view joins the two on the hour so every queue reading carries its weather.

Notebook phase: predicting individual rides

The modeling lives in a marimo notebook (knb_oracle_v2). Target: a ride’s current wait in minutes. Three model families on the same feature matrix, because comparing them is where the learning is:

  • OLS (statsmodels) for inference. Coefficients in units of minutes, reference categories chosen deliberately (Grand Carousel as the baseline ride, Sunday as the baseline day) so the intercept means something.
  • XGBoost for prediction, with Bayesian hyperparameter search and the winning params cached to a JSON file so reruns skip the search.
  • A small MLP, mostly to see how far a neural net gets on tabular data.

The feature pipeline is a chain of .pipe() steps: time features, one-hot calendar and ride dummies, promo flags (Bargain Night, Sundown Special, and an interaction term for thrill rides on Bargain Night), weather encoded differently per model family (ordinal WMO codes for the trees, description dummies for the linear models), and z-scoring for OLS and the net only, since trees don’t care about scale.

The feature that took over: lagged park state

The single biggest lift came from summarizing the whole park’s state and feeding it back in lagged. Pivot every ride’s wait into a wide matrix per 5 minute bin, run PCA, and the first component is simply “how busy is the park” (about 40% of variance). Lag it by 30/60/90 minutes so the model only sees information that would exist at prediction time, and it dominates: OLS R² jumps by a large margin from three columns, and the calendar and weather coefficients collapse toward zero because the lagged PCs absorb them. Which makes sense: weekday and weather were only ever proxies for the crowd, and now the model can read the crowd directly.

The fun capstone was cracking open the trained MLP and finding it had elevated the same signal on its own. The most important first-layer neurons (measured by ablation) were the ones tracking lagged PC1. Two different model families, one discovery.

Where they landed

Tuned XGBoost and the MLP effectively tie around 1.97 minutes MAE on a chronological holdout, per ride, per 5 minute snapshot. Two things mattered more than architecture: a log1p transform on the skewed target (about 28% MAE improvement for the net) and shrinking the network. A (16, 8) net beat (64, 32) by about 7% with 5x fewer parameters. With 600k training rows, capacity was never the constraint.

One honest negative result: I tried an autoencoder as a nonlinear feature compressor feeding XGBoost. The learned codes added nothing over the raw matrix. Retired.

Production phase: different question, different model

The notebook models answer “what’s the wait at Phoenix right now, given the state of the park.” Useful for a leaderboard, useless for planning, because the lagged park state doesn’t exist three days ahead of time. The question I actually wanted on the website was “is Saturday a good day to go.” That’s a forward model, and it only gets features that are knowable in advance:

  • calendar: day of week, month, holidays, promo nights, hours into the day, hours until close
  • park hours: observed for today, inferred from (month, weekday) history for future dates, since every machine-readable source the park has is today-only
  • weather forecasts, not observations. Open-Meteo’s forecast API serves the identical variables and units as its archive, which makes training on actuals and predicting on forecasts a swap of one data source rather than a schema project.

Target: park-wide mean wait per operating hour (the “crowd index”). Hourly rather than daily because 313 daily labels is thin, and about 2,400 hourly rows is workable. The day verdict is an aggregation of hourly predictions, which also buys a “quietest hour” for free.

The model is three LightGBM quantile regressions (p10/p50/p90) with a split-conformal correction on the interval, because the raw quantile bands only covered 44% of held-out hours when they should cover 80%. After regularizing the tail models and calibrating an additive widening on a held-out slice, coverage lands at 82%. Test MAE is 1.84 minutes against 1.96 for a (month, weekday, hour) climatology baseline. A real edge, not a dramatic one, which is what you’d expect when calendar and weather are all you have. The training flow refuses to promote a model that can’t beat climatology.

The verdict you see on the homepage is not the model’s alone. The model predicts crowds; a rainy Tuesday has empty queues and is still a bad day to go. So the API layer composes the crowd level (terciles calibrated from two seasons of history) with weather gates for washouts and thunderstorms, and writes the blurb from that.

The nowcast, and a result I like too much to hide

I also wanted a “right now” answer: it’s dead, jump in the car. Current park state is directly observable from the feed, so that part needs no model. For “will it stay dead for the next few hours” I built the obvious thing, quantile LightGBM with the notebook’s lagged-state recipe collapsed to park-wide grain: current crowd index, its 1 and 2 hour lags, the trend, calendar, weather.

It could not beat persistence at any horizon. Predicting “the park will stay how it is” scored 1.070 MAE at three hours out; the model scored 1.073. At hourly grain the within-day crowd process is close to a martingale, and that’s exactly why lagged park state dominated the per-ride models: the lags aren’t a feature that helps a model, they basically are the model. So production does the honest thing: persistence plus empirical quantile bands on the historical deltas, which are asymmetric upward because crowds build more often than they collapse. Simpler, calibrated, and precisely as accurate as the ML was.

Serving it

Everything runs as Prefect flows in a worker container on my app server: forecast snapshots and park calendar each morning, feature build, daily d0 through d6 predictions, an intraday refresh, a quarter-hourly nowcast during operating hours, weekly retrains. Predictions land back in Postgres with their forecast inputs attached, and get resolved against actuals once the hour arrives, so the pipeline grades its own homework and I can measure skill by lead time later.

The website reads it through a small public API (see How This Website Works): a verdict line in the homepage strip, and the park heat map, which places a glow per ride on satellite tiles, colored by wait class and sized by magnitude. When the park is open it’s live within a couple minutes; when it’s closed it shows a typical weekend afternoon from history, labeled as such.

Every forecast the pipeline pulls also gets archived. Once there’s enough of that, the plan is to retrain on forecasts-as-they-looked-N-days-out instead of weather actuals, with lead time as a feature, and close the last honest gap between how the model trains and how it’s used.

Wesley Ray · blog · git · resume · linkedin

built 2026-09-13 14:35 ET · b6c6233 · 117 notes