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 nearest sold seats, with a kernel that gives closer neighbours more weight. Two things to tune: 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.
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 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?