NewTurn any lesson into a live trading bot with one prompt, using AgenKitagenkit.xyz

Part 4 · Case studiesLesson 1510 minQuant Bible §5.2

Case: pricing seats at an opera house

A QuantCo-style case: nearest neighbours over the seat map, time to the show, and scarcity.

This QuantCo-style case starts like a regression problem and ends up being about people.

Start spatial: nearest neighbours

Rows, columns and sections sit on a real map of the hall. Distance to the stage and viewing angle decide how good a seat is, so neighbouring seats should cost about the same and distant ones differently. That is exactly what nearest neighbours assumes.

Price each seat as a weighted average of its kk nearest sold seats, with a kernel that gives closer neighbours more weight. Two things to tune: kk and the kernel bandwidth. Watch the edges: a seat on the end of a row has neighbours on one side only, so use a slightly wider window there.

Figure 15.1The opera house, in 3D
Blue seats have sold (darker = pricier). Orange seats are priced by a kernel-weighted average of their k nearest sold neighbours. Tap any seat to see its neighbours, its estimate and its true value. Notice the back row: it is worth more than distance alone predicts.

Then a regression

You can also regress price on row, column and section, after some preprocessing:

  • Turn row into an ordinal variable, or better, replace row and column with the actual distance and angle to the stage.
  • Use the hall’s left–right symmetry: seat 3 and seat 28 of a 30-seat row are equivalent.
  • Row, column and section are highly correlated. Handle it with ridge, lasso, subset selection or successive orthogonalisation (lessons 10–11).

Pure geometry will leave outliers. That is the signal to bring in time and psychology.

Time to the show

The rate of ticket buying rises roughly exponentially as the date approaches, while the supply of seats falls. So prices should also move with time. Add log⁡(show date−sale date)\log(\text{show date} - \text{sale date}) as a feature. It is unlikely to be strongly correlated with the seat-geometry features, so it adds information the spatial model didn’t have (still check).

Scarcity and FOMO

When few seats are left, loss aversion kicks in: people pay more not to miss out. Add a scarcity variable, the number of seats still unsold at the moment of the sale. It will correlate with the time variable, so watch for multicollinearity.

Scarcity also explains the oddity in the figure: the very last row sells for more than its distance suggests, because those seats go last, to the most eager buyers.

From predicting to maximising profit

Predicting the price people paid is not the same as choosing the price that earns the most. Willingness to pay across a population is roughly lognormal (like income). Model that distribution for each segment of row, column, section, time and scarcity, then set prices so that just enough people are willing to pay to fill the seats. That is the start of a real dynamic-pricing system.

In code

import numpy as np
from sklearn.neighbors import KNeighborsRegressor

rng = np.random.default_rng(13)
rows, cols = np.meshgrid(np.arange(12), np.arange(30), indexing="ij")
angle = np.abs(cols - 14.5) / 14.5
value = 210 - 10.5 * rows - 70 * angle**2 + 18 * (rows == 11)
sold = rng.random(value.shape) < 0.4

XY = np.c_[rows.ravel(), cols.ravel() / 3]                # rescale so units are comparable
price = (value + 12 * rng.standard_normal(value.shape)).ravel()
model = KNeighborsRegressor(n_neighbors=6, weights="distance").fit(XY[sold.ravel()], price[sold.ravel()])
err = model.predict(XY[~sold.ravel()]) - value.ravel()[~sold.ravel()]
print("RMSE on unsold seats:", round(np.sqrt(np.mean(err**2)), 1))

Check yourself

Your nearest-neighbour seat model consistently underprices the last row. What is the best next step?

Progress is saved in this browser only.