Notebook

Deep dive · Data & visualization

Ticketing Analytics for the Krannert Center

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

01

The problem

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.

02

Data engineering

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.

Finding the real unit of analysis

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.

03

The benchmark that lied

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:

B1

Survivorship in the benchmark median

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%.

B2

Mismatched denominators

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.”

B3

Wrong aggregation grain

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.

The fix: a self-consistent statistic

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.

Beforeoriginal metric
Days outMedian gap“Behind”
5-34.5 pts77%
10-28.1 pts76%
20-24.7 pts77%
40-23.2 pts77%
Afterself-consistent metric
Days outMedian gap“Behind”
50.0 pts46%
100.0 pts45%
200.0 pts44%
400.0 pts40%
Backtest over 7,783 historical units: every unit scored with the production statistic against its own cohort. The redesigned metric is exactly centered at every horizon; the healthy near-half “behind” share is what an honest median produces. The live watchlist settled at 18% behind - a signal, where before there was only alarm.

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.

04

Hardening it like software

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:

  • A filter could hollow out the benchmark. Narrowing the view to a future-only date range left zero history in the filtered frame, every show clipped to “100% sold,” and the watchlist silently rendered empty. Fix: filters select which shows are scored; benchmarks always come from the full dataset.
  • Time travel hung the app. Cohort lookups did per-unit DataFrame mask scans - fine for 274 upcoming shows, minutes for the thousands an early as-of date produces. Replacing scans with precomputed dictionary indexes made lookups O(1).

>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.

05

Communicating to non-analysts

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.

What it surfaced

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.