Deep dive · Data & visualization
A decade of box-office exports turned into a live pacing dashboard that tells the marketing team, per show, whether tickets are selling slower than history says they should be - built on a benchmark I had to redesign after proving the obvious one was statistically broken.
119,082
sales transactions
560,216
tickets · $11.4M revenue
10
seasons, 2016-2026
1,373
events
8,120
event × price-tier units
The Krannert Center for the Performing Arts (University of Illinois) sells tickets months ahead of each performance. By the time a show visibly underperforms, the marketing window is gone. The ask: an early-warning system that flags shows selling behind their expected pace while there is still time to act - from nothing but a decade of daily sales exports.
That turns a reporting problem into a statistical one. “Behind pace” only means something relative to a benchmark, and the benchmark has to be estimated from noisy, skewed, irregular historical data - then validated, because a miscalibrated benchmark is worse than none.
Ten seasons of CSV exports, one file per season, with drifting schemas, junk rows (DO NOT SELL!, CANCELLED! baked into event names), mixed date formats, and a stray file whose “events” dated to 1930. The ingest layer normalizes headers, coerces types with explicit failure handling, drops unusable rows, and derives the analysis fields: days-before-show for every transaction, 3-day lead bins, weekday, season, and a composed event identity.
The most consequential data decision looked like a naming detail. Each performance appears in the export under many event_part codes - SA, UI, SC, YT - which profiling revealed to be box-office price tiers, not event genres: C tickets average $0.00 (comps), UI averages $9.94 (the student rate), SC $27.89 (seniors). Student tickets sell on a completely different curve than full-price adult tickets for the same show, so the correct unit of analysis is the (event, price-tier) pair - 8,120 units, not 1,373 events. Getting this wrong had silently corrupted every downstream comparison.
The pipeline is documented for handoff: a column-mapped import template plus two update paths (a session-scoped upload for quick looks, a committed append for the live site), so the venue can load each new season without engineering help.
The first pacing model did the textbook thing: for each upcoming show, compare percent-sold today against the historical median percent-sold at the same days-out. It confidently reported that 100% of upcoming shows were behind pace - some by 70+ points. Either the venue was collapsing, or the yardstick was bent.
Instead of tuning thresholds, I backtested the metric on its own training history: score every historical show with the exact statistic used for upcoming shows. A calibrated benchmark must call roughly half of history “ahead” and half “behind” - by definition of a median. It called 77% of history behind, with a median gap of -25 to -35 points. The metric was structurally biased, and the backtest turned a vague suspicion into a measurable defect. Three compounding biases fell out of the analysis:
The median was taken over transaction rows present at each time bin - so quiet shows vanished from their own baseline and busy, early-selling shows dominated it. At 39 days out the benchmark claimed a typical show had sold 32%; the true per-event median was 12.9%.
Upcoming shows were normalized by their cohort’s median final sales, while the benchmark used each historical show’s own final. With final sales heavily right-skewed (p25 = 78 tickets, median = 189, p75 = 403), any smaller-than-median show was mathematically incapable of ever reading “on pace.”
Benchmark curves were built per event while the watchlist scored per (event, price-tier) - so a 5-ticket youth allocation was judged against whole-show sales totals.
Make the benchmark and the measurement the same statistic: for every historical unit in a cohort, compute tickets-by-day-D ÷ cohort-median-final, carried forward to every time bin, and take the median across units. The median historical show then paces at gap zero by construction - calibration is a property of the design, not a tuning outcome.
Cohorts fall back through tiers (price-tier × weekday × venue → price-tier × weekday → price-tier → global) with a minimum-sample gate of 20 units, so every show is compared against the most specific peer group with enough history to be trustworthy.
| Days out | Median gap | “Behind” |
|---|---|---|
| 5 | -34.5 pts | 77% |
| 10 | -28.1 pts | 76% |
| 20 | -24.7 pts | 77% |
| 40 | -23.2 pts | 77% |
| Days out | Median gap | “Behind” |
|---|---|---|
| 5 | 0.0 pts | 46% |
| 10 | 0.0 pts | 45% |
| 20 | 0.0 pts | 44% |
| 40 | 0.0 pts | 40% |
Ranking got the same treatment: sorting by percentage gap let a 1-ticket micro-tier with a -86-point gap outrank a mainstage show short 50 real tickets. The list now ranks by expected ticket shortfall (gap × typical audience size), which is the quantity a marketing dollar actually recovers.
A dashboard a client trusts has to survive its edge cases, so I tested it like an application, not a notebook - a 10-case automated matrix over Streamlit’s AppTest harness covering season subsets, future-only and empty date ranges, single-day windows, and “as-of” time travel across the whole decade. The matrix caught two real bugs no demo walkthrough would have:
>180 s
as-of 2020 · mask scans (timeout)
0.5 s
same query · indexed lookups
Every release also passes a regression suite (pytest) and deploys continuously to Streamlit Cloud from main.
The client is a marketing team, not a data team, so the last phase was translation. Section titles became questions (“Which upcoming shows need attention?”); Gap vs typical (pp) became “Difference”; cohort strings like Type+Weekday+Venue (n=143) became “143 past Thursday shows, UI tickets.” The UI is deliberately monochrome - Inter, hairline borders, grayscale charts validated for color-vision deficiency and contrast - with a single reserved red that means exactly one thing: this show is behind. Where the data was ambiguous (the box office’s own tier codes), the dashboard reports observed volume and average price rather than guessing at labels - honesty as a design principle.
With a calibrated benchmark, the signal got specific: roughly 18% of upcoming shows genuinely behind pace, and the at-risk list was dominated by one segment - UI student tickets, which lag their historical curve across many shows at once. That converts a wall of red into a single actionable hypothesis: student outreach is the lever to pull, weeks before those shows open.