Someone wants to raise prices ten per cent and nobody knows what it does to volume. Build the simulator. Work it yourself before reading the walkthrough.
The situation
Brackwell Home sells homeware. You have 104 weeks of weekly price and volume for 20 products, 2024-07-29 to 2026-07-20. Prices have moved plenty over that period, through promotions, clearance and competitive response.
The commercial director wants a simulator: move the price, see the volume, see the margin.
The data
| Column | Meaning |
|---|---|
| week_start, week_index, sku | One row per product per week |
| list_price, price | What it normally sells for, and what it sold for that week |
| on_promotion, clearance | Why the price moved that week |
| competitor_index | A rival price index for the category, 1.0 is parity |
| units_sold | Volume |
What the room believes
- A log-log regression of units on price gives the elasticity.
- Two years of real price variation is enough to measure it.
- Adding the right controls will get you to the true answer.
- An estimate that is close enough for a report is close enough for a pricing decision.
Definition of done
- A verdict on each of the four beliefs, with the evidence.
- An elasticity per product, with a defence of the specification you chose.
- An explicit list of the reasons price moved in this data, and what each does to a naive estimate.
- A working simulator: price in, volume and contribution out.
- A clear statement of which decisions your estimate can support and which it cannot.
Four questions worth sitting with before you fit anything
Was the price in this file chosen or assigned? What else moved at the same time, and does it also move demand? Which of the available columns are caused by price rather than causes of it? And how would you ever know whether your estimate is right?
If you want to go further
- Compare products against each other, then compare a product against itself over time, and explain why the two give different answers.
- Work out which columns in the file you must not control for, and say why.
- Take your elasticity into a contribution calculation and see how much the recommendation moves for a small change in the estimate.
- Decide what evidence would let you recommend a price change with confidence, and whether this file can ever provide it.
When you are done, read the walkthrough. It uses the same panel and can score every estimate, because the data is simulated and the true elasticity of every product is known. Compare your specification to its ladder of specifications, and check whether yours would have made the right pricing call.
Someone wants to raise prices ten per cent and nobody knows what it does to volume. A regression of units on price answers in one line and gets it wrong by 45.0%. Fixing most of that still leaves the wrong pricing decision.
The situation. Brackwell Home sells homeware. 20 products, 104 weeks, 2,080 rows of price and volume. The commercial director wants a price simulator: move the price, see the volume, see the margin.
What the business is left with. A simulator, an honest elasticity for each line, and a clear statement of the one decision the data cannot support.
Attempt it first. The brief has the panel and the question with none of the answers.
Contents
Section 1Problem Definition
No code yet. Elasticity is the most requested and most quietly mis-estimated number in retail analytics, and the reason has nothing to do with the model.
The problem in one sentence
Price is not set at random. It moves for reasons that also move demand, so the relationship between price and volume in a historical file is not the causal effect of changing price.
| Why price moved | What it does to demand | What a naive regression concludes |
|---|---|---|
| A promotion timed to a busy week | Demand was going to be high anyway | The discount looks less effective than it was |
| Clearance on a dying line | Demand is falling for its own reasons | The discount looks like it caused the decline |
| A competitor cut their price too | Demand falls despite our cut | Our own price change gets blamed |
| Nothing. Premium lines are just dearer | They also sell in smaller numbers | Price looks powerful when it is only labelling the product |
Business objective
Estimate the own-price elasticity of each product well enough to build a simulator the commercial team can use, and say plainly where the estimate is not good enough to decide on.
Hypotheses
- H1. A log-log regression of units on price gives the elasticity.
- H2. Two years of real price variation is enough to measure it.
- H3. Adding the right controls will get us to the true answer.
- H4. An estimate that is close enough for a report is close enough for a pricing decision.
This dataset knows the answer
The panel is simulated, and each product has a true elasticity written into the generator and left out of the exported file. That is unusual and it is the point: on real data you can argue about which estimator is less biased forever, because nobody can check. Here every estimate can be scored.
Section 2Data Collection
import numpy as np
import pandas as pd
import statsmodels.api as sm
d = pd.read_csv('data/brackwell-pricing-panel.csv')
d['lp'] = np.log(d['price'])
d['lq'] = np.log(d['units_sold'].clip(lower=1))
print('rows :', len(d))
print('products :', d['sku'].nunique(), ' weeks:', d['week_index'].nunique())
print('promo weeks : %.1f%%' % (100 * d['on_promotion'].mean()))
print('clearance weeks : %.1f%%' % (100 * d['clearance'].mean()))
rows : 2,080
products : 20 weeks: 104
promo weeks : 8.8%
clearance weeks : 1.6%
| Column | Meaning |
|---|---|
| week_start, week_index, sku | One row per product per week |
| list_price, price | What it normally sells for, and what it sold for that week |
| on_promotion, clearance | Why the price moved |
| competitor_index | A rival price index for the category, 1.0 is parity |
| units_sold | Volume |
Section 3Data Preprocessing
3aDuplicates and schema checks
print('duplicate rows :', d.duplicated().sum())
print('duplicate sku weeks :', d.duplicated(subset=['sku', 'week_index']).sum())
print('missing cells :', d.isna().sum().sum())
print('zero or negative price:', (d['price'] <= 0).sum())
print('mean price range per product : %.2fx'
% (d.groupby('sku')['price'].max() / d.groupby('sku')['price'].min()).mean())
duplicate rows : 0
duplicate sku weeks : 0
missing cells : 0
zero or negative price: 0
mean price range per product : 1.57x
Every product’s price moves by a factor of about 1.57 across the window. That is genuine variation, and section 7 shows that having variation is necessary and nowhere near sufficient.
3bHandling categorical mess
Clean. Category is not used as a feature: with 20 products the model uses a dummy per product instead, which absorbs category and everything else that is fixed about a line.
3cDealing with outliers
Nothing extreme, and the deep discounts are the observations that carry most of the information about elasticity. Trimming them would remove the signal and leave the confounding intact, which is the worst of both.
3dHandling missing values
missing cells : 0
3eHandling skewed data
Both price and units are logged, which is not a cosmetic choice. The coefficient on log price in a regression of log units is the elasticity, so the transform is the model rather than a preparation step.
3fData types and normalisation
d['woy'] = pd.to_datetime(d['week_start']).dt.isocalendar().week.astype(int)
for k in (1, 2):
d['sin' + str(k)] = np.sin(2 * np.pi * k * d['woy'] / 52.0)
d['cos' + str(k)] = np.cos(2 * np.pi * k * d['woy'] / 52.0)
dummies = pd.get_dummies(d['sku'], prefix='sku', drop_first=True).astype(float)
season = d[['sin1', 'cos1', 'sin2', 'cos2']]
print('product dummies :', dummies.shape[1])
product dummies : 19
Section 4Exploratory Data Analysis
4aTarget variable analysis
The target is log units and the quantity of interest is one coefficient. Everything below is about whether that coefficient means what it appears to mean.
4bNumerical variables
Start with the cross section, because it is where the first confound lives.
bysku = d.groupby('sku').agg(price=('price', 'mean'), units=('units_sold', 'mean'))
slope = np.polyfit(np.log(bysku['price']), np.log(bysku['units']), 1)[0]
print('across products, log units against log price')
print(' correlation :', round(np.corrcoef(np.log(bysku['price']),
np.log(bysku['units']))[0, 1], 4))
print(' slope :', round(slope, 3))
across products, log units against log price
correlation : -0.9595
slope : -1.197
A slope of -1.197 across products, which looks like an elasticity and is not one. Dearer products sell in smaller numbers because they are different products, not because anyone changed a price. Any pooled regression is partly fitting this line.
4cCategorical variables
d['discount'] = 1 - d['price'] / d['list_price']
state = np.where(d['clearance'] == 1, 'clearance',
np.where(d['on_promotion'] == 1, 'promotion', 'normal'))
print(d.groupby(state)[['units_sold', 'discount']].mean().round(3))
units_sold discount
clearance 184.529 0.218
normal 106.106 0.001
promotion 191.033 0.180
Promotions and clearance both cut the price by roughly a fifth and both sell well, so this table cannot separate them. The units column looks the same in a week priced down into strong demand and a week priced down because the line is finished.
# The one thing that does separate them: the direction the line is already travelling.
trend = (d.sort_values('week_index').groupby('sku')
.apply(lambda g: np.polyfit(g['week_index'],
np.log(g['units_sold'].clip(lower=1)), 1)[0]))
cleared = sorted(d.loc[d['clearance'] == 1, 'sku'].unique())
print('lines ever cleared :', len(cleared))
print('weekly log-units trend, cleared lines :', round(trend.loc[cleared].mean(), 5))
print('weekly log-units trend, other lines :', round(trend.drop(index=cleared).mean(), 5))
lines ever cleared : 6
weekly log-units trend, cleared lines : -0.00356
weekly log-units trend, other lines : 0.0015
The 6 lines that were ever put into clearance are shrinking at -0.00356 log units a week while the rest are growing. The discount is deep enough to make those weeks look strong anyway. A pooled regression sees a low price beside high units and reads it as weak price sensitivity, when the real story is a dying line being emptied out.
4dRelationships between variables
print('pooled, price and competitor index :',
round(d['price'].corr(d['competitor_index']), 4))
print('pooled, competitor index and units :',
round(d['competitor_index'].corr(d['units_sold']), 4))
# The same two correlations computed inside each product, then averaged.
within = d.groupby('sku').apply(
lambda g: pd.Series({'price': g['price'].corr(g['competitor_index']),
'units': g['units_sold'].corr(g['competitor_index'])}))
print('within product, price and competitor index :', round(within['price'].mean(), 4))
print('within product, units and competitor index :', round(within['units'].mean(), 4))
pooled, price and competitor index : 0.0745
pooled, competitor index and units : -0.1956
within product, price and competitor index : 0.6542
within product, units and competitor index : -0.3877
Pooled, the competitor index looks like a variable worth ignoring at 0.0745 against price. That number is small because it is dominated by the gap between a 20 product price list, not because the effect is small. Inside a single product it is 0.6542 against price and -0.3877 against units, which is a textbook omitted variable: it pushes both sides of the regression at once.
The competitor index moves our price and our volume at the same time. It is in the file, so it can be controlled for. The equivalent variables that are not in the file are the reason section 7d stops short of recommending a price change.
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. A log-log regression gives the elasticity | Wrong | It returns -1.229 against a true mean of -2.235, understating the effect by 45.0% |
| H2. Two years of variation is enough | Necessary, not sufficient | Prices move by a factor of 1.57 and the naive estimate is still wrong by 1.006 |
| H3. The right controls will get us there | Most of the way | Controls cut the error from 1.006 to 0.37, and it does not reach the truth |
| H4. Close enough for a report is close enough to price on | Wrong | Section 7d: the best estimate recommends a 20% price cut and the truth says 10% |
4fSubgroups
Elasticity genuinely differs by product, from -3.1 to -1.3. A single number for the range would be wrong for almost every line in it, which is why section 7d estimates each product separately even though each has only 104 observations.
Section 5Feature Engineering
5aThe leakage trap
There is no target leak here in the usual sense. The equivalent failure is a control that sits between price and demand rather than beside them.
The control that would break it
Units sold in the previous week looks like an obvious control and is a collider-adjacent disaster: last week’s volume is partly caused by last week’s price, which correlates with this week’s. Conditioning on it absorbs part of the effect being measured.
The rule is to control for things that cause price and demand, and never for things that price causes. Season, competitor index and product identity pass. Recent volume, inventory level and revenue do not.
5bNew features
Four Fourier terms and a set of product dummies. Nothing clever, because the risk here is not underfitting, it is controlling for the wrong thing.
5cEncoding
Product identity as dummies, which is what turns a pooled regression into a within-product one. That single change does most of the work in section 7b.
5dFeature selection
Fixed in advance from a causal argument rather than from fit. Adding a variable because it improves R squared is exactly how the previous-week-volume mistake gets made: it improves fit and destroys the estimate.
Section 6Model Selection
| Approach | What it assumes | Why it is or is not used |
|---|---|---|
| Pooled log-log OLS | Price is as good as randomly assigned | Used only as the baseline to beat. The assumption is false by construction here |
| Product fixed effects | Anything fixed about a product is absorbed | Used. It removes the cross-sectional confound in one line |
| Fixed effects plus time controls | Timing of discounts is captured by season | Used. This is the honest workhorse |
| Instrumental variables | A variable moves price and nothing else | Not used. There is no credible instrument in this file, and a weak one is worse than none |
| A randomised price test | Prices are actually assigned at random | Not available in the data, and it is what section 8 recommends |
Why a machine learning model is not the answer
A gradient boosting model would predict units from price beautifully and could not answer the question, because prediction is not identification. The quantity wanted is what happens if we intervene on price, and no amount of fit gets you there from data where price was chosen rather than assigned.
Section 7Model Training
7aBaselines
The baseline is the one-line answer someone will produce if this project does not: a pooled regression of log units on log price.
7bComparing candidates
def elasticity(X, label):
m = sm.OLS(d['lq'], sm.add_constant(X)).fit()
lo, hi = m.conf_int().loc['lp']
print(f'{label:44s} {m.params["lp"]:+.3f} [{lo:+.3f}, {hi:+.3f}] R2 {m.rsquared:.4f}')
return m.params['lp']
elasticity(d[['lp']], 'Naive pooled')
elasticity(pd.concat([d[['lp']], dummies], axis=1), 'Product fixed effects')
elasticity(pd.concat([d[['lp']], dummies, season], axis=1),
'Fixed effects plus seasonality')
elasticity(pd.concat([d[['lp']], dummies, season, d[['competitor_index']]], axis=1),
'Fixed effects, seasonality and competitor')
Naive pooled -1.229 [-1.259, -1.199] R2 0.7550
Product fixed effects -2.880 [-3.070, -2.691] R2 0.8417
Fixed effects plus seasonality -2.885 [-3.075, -2.695] R2 0.8423
Fixed effects, seasonality and competitor -2.605 [-2.845, -2.366] R2 0.8433
The naive estimate is too small by 1.006. Product fixed effects overshoot in the other direction. Adding the competitor index leaves 0.37.
The naive answer is wrong by 45.0%
Pooled: -1.229. Truth: -2.235.
Act on the pooled number and you believe a ten per cent price rise costs you 12.3% of volume when it actually costs 22.3%. That is the difference between a price rise that looks profitable and one that is not.
Note also that every confidence interval here is narrow and none of them contains the truth except by luck. The uncertainty that matters is bias, and a standard error says nothing about it.
7cHyperparameter tuning
There is nothing to tune, which is the point. The gap between the naive and the controlled estimate is entirely a modelling-design decision, taken before any fitting, and no amount of tuning moves it.
7dFinal evaluation
Per product, scored against the truth
This is the step no real pricing project gets to take. The panel is simulated, so the true elasticity of every product is known and every estimate can be marked.
# The ground truth, from the generator. It is not in the exported file, and on a
# real dataset this dictionary does not exist, which is the whole difficulty.
TRUE = {'BW-101': -1.9, 'BW-102': -2.4, 'BW-103': -1.4, 'BW-104': -2.8,
'BW-105': -1.6, 'BW-106': -2.1, 'BW-107': -2.6, 'BW-108': -3.1,
'BW-109': -2.9, 'BW-110': -1.5, 'BW-111': -2.7, 'BW-112': -1.3,
'BW-113': -3.0, 'BW-114': -1.8, 'BW-115': -2.2, 'BW-116': -2.5,
'BW-117': -2.9, 'BW-118': -2.3, 'BW-119': -1.7, 'BW-120': -2.0}
for sku, g in d.groupby('sku'):
naive = sm.OLS(g['lq'], sm.add_constant(g[['lp']])).fit().params['lp']
ctrl = sm.OLS(g['lq'], sm.add_constant(
pd.concat([g[['lp']], g[['sin1', 'cos1', 'sin2', 'cos2',
'competitor_index']]], axis=1))).fit().params['lp']
print(f'{sku} naive {naive:+.2f} controlled {ctrl:+.2f} true {TRUE[sku]:+.2f}')
BW-101 naive -2.30 controlled -1.43 true -1.90
BW-102 naive -3.26 controlled -2.37 true -2.40
BW-103 naive -1.68 controlled -0.78 true -1.40
BW-104 naive -3.36 controlled -2.93 true -2.80
BW-105 naive -3.19 controlled -1.64 true -1.60
BW-106 naive -3.17 controlled -1.95 true -2.10
BW-107 naive -3.56 controlled -2.52 true -2.60
BW-108 naive -3.11 controlled -3.27 true -3.10
BW-109 naive -2.96 controlled -2.26 true -2.90
BW-110 naive -2.69 controlled -1.65 true -1.50
BW-111 naive -3.34 controlled -2.90 true -2.70
BW-112 naive -2.07 controlled -1.49 true -1.30
BW-113 naive -3.55 controlled -2.76 true -3.00
BW-114 naive -2.56 controlled -2.78 true -1.80
BW-115 naive -2.40 controlled -1.59 true -2.20
BW-116 naive -4.33 controlled -2.75 true -2.50
BW-117 naive -3.76 controlled -2.85 true -2.90
BW-118 naive -2.72 controlled -2.33 true -2.30
BW-119 naive -1.43 controlled -1.68 true -1.70
BW-120 naive -2.64 controlled -2.18 true -2.00
Controls help on almost every line. Mean absolute error falls from 0.697 to 0.262.
| Measure | Naive | With controls |
|---|---|---|
| Mean absolute error | 0.697 | 0.262 |
| Correct sign | 20 of 20 | 20 of 20 |
| Within 0.5 of the truth | 7 of 20 | 16 of 20 |
| Correlation with the truth | 0.697 | 0.821 |
The simulator, and the decision it gets wrong
SKU, COST_SHARE = 'BW-104', 0.55
g = d[d['sku'] == SKU]
list_price = g['list_price'].iloc[0]
base_units = g.loc[g['price'] >= list_price * 0.99, 'units_sold'].mean()
# the controlled estimate for this one product, which is what a pricing team
# would actually have in front of them
est_elasticity = sm.OLS(g['lq'], sm.add_constant(
pd.concat([g[['lp']], g[['sin1', 'cos1', 'sin2', 'cos2',
'competitor_index']]], axis=1))).fit().params['lp']
print('estimated elasticity', round(est_elasticity, 3),
' true', TRUE[SKU])
for change in (-0.20, -0.10, -0.05, 0.0, 0.05, 0.10, 0.20):
newp = list_price * (1 + change)
for label, e in (('estimated', est_elasticity), ('true', TRUE[SKU])):
units = base_units * (newp / list_price) ** e
contrib = units * (newp - list_price * COST_SHARE)
print(f'{change:+.0%} {label:9s} units {units:7.1f} contribution {contrib:9,.0f}')
estimated elasticity -2.93 true -2.8
-20% estimated units 359.4 contribution 2,516
-20% true units 349.1 contribution 2,444
-10% estimated units 254.5 contribution 2,494
-10% true units 251.0 contribution 2,460
-5% estimated units 217.2 contribution 2,433
-5% true units 215.8 contribution 2,417
+0% estimated units 186.9 contribution 2,355
+0% true units 186.9 contribution 2,355
+5% estimated units 162.0 contribution 2,268
+5% true units 163.0 contribution 2,283
+10% estimated units 141.4 contribution 2,177
+10% true units 143.1 contribution 2,204
+20% estimated units 109.6 contribution 1,994
+20% true units 112.2 contribution 2,042
The two curves peak in different places. The estimate says cut 20%, the truth says 10%.
The best available estimate still gives the wrong answer
On Stoneware Mug Set the controlled estimate is -2.93 against a true -2.8. Close, on any reasonable reading.
Run the simulator on each and they disagree about what to do. The estimate recommends a 20% price cut. The truth recommends 10%. Following the estimate means discounting twice as hard as the product warrants.
H4 is wrong, and this is the finding worth carrying out of the project: an elasticity accurate enough to put in a report is not necessarily accurate enough to set a price with.
Section 8Documentation and Handoff
Ship the simulator, label it, and run a price test
Use the controlled estimates, not a pooled regression. Pooled returns -1.229 against a true -2.235, understating price sensitivity by 45.0%.
The controlled estimates cut the mean per-product error from 0.697 to 0.262 and correlate with the truth at 0.821. Good enough to rank products by price sensitivity, and to size a range.
Not good enough to set a price. On the worked example the estimate and the truth recommend different moves. Any change over five per cent should be tested rather than modelled.
What to do, and who owns it
| Action | Detail | Owner |
|---|---|---|
| Estimate within product, never pooled | One dummy per line. It is the single largest correction available and it costs one line of code | Analytics |
| Control for season and the competitor index | Both move price and demand together. Everything else in the file is either fixed per product or downstream of price | Analytics |
| Publish the simulator with a confidence band, not a point | Elasticity ranges from -3.1 to -1.3 across the range, and the estimate for any one line carries real error | Analytics |
| Run a randomised price test on the top lines | Two matched groups of stores or regions, or a staggered rollout. It is the only thing that identifies the effect, and it answers in weeks | Commercial |
| Never control for anything price causes | Previous week volume, inventory and revenue all improve the fit and bias the estimate | Analytics |
What not to do
- Do not report the pooled elasticity. It is wrong by 45.0% here and it is the number a one-line regression produces.
- Do not read a narrow confidence interval as accuracy. Every interval in section 7b is tight and most of them exclude the truth. Standard errors measure noise, not bias.
- Do not reach for a machine learning model. It would fit better and identify nothing, because the question is causal.
- Do not price on the simulator alone above five per cent. The worked example shows the best available estimate recommending twice the discount the truth supports.
- Do not use one elasticity for the range. The true values span -3.1 to -1.3.
Reproducibility
| Item | Value |
|---|---|
| File | brackwell-pricing-panel.csv, 2,080 product weeks |
| Window | 2024-07-29 to 2026-07-20, 104 weeks |
| Specification | log units on log price, product dummies, four Fourier terms, competitor index |
| Best estimator | Fixed effects, seasonality and competitor, bias -0.37 |
| Ground truth | Known because the panel is simulated. Held in make_dataset.py and in the analysis, never in the exported file |
| Libraries | pandas, numpy, statsmodels |
What to take from this
- Price is not randomly assigned, so the price and volume relationship in history is not the effect of changing price. Everything else follows from that one sentence.
- Product fixed effects are the cheapest large correction there is. One dummy per line moved the estimate from -1.229 to -2.88.
- Control for causes of price, never for consequences. The tempting controls are usually the second kind.
- A tight confidence interval is not accuracy. It describes sampling noise and is silent about the thing that is actually wrong.
- Good enough to report is not the same as good enough to decide. The controlled estimate here is close and still picks the wrong price.
- When the decision is expensive, test rather than model. A staggered price rollout answers in weeks what no amount of historical data can.
The commercial director asked for a simulator. The simulator exists, it is far better than the one-line answer, and the most valuable thing in the handoff is the sentence saying which decisions it is not accurate enough to make. That sentence is only writable because this dataset knows the truth, and the habit it should leave behind is assuming the same gap exists on data that does not.
Design the price test the analysis asks for
Sample Size Calculator
The recommendation here is a randomised price test on the lines that matter. Put the baseline conversion or volume and the effect you need to detect in, and it tells you how long it has to run. On a five per cent price move the answer is longer than most commercial teams expect.
Free, no signup. Pairs with the Power and MDE Calculator.
See the margin the elasticity moves
Ecommerce Dashboard: A Free Excel Template
A price change only matters through contribution, and that needs cost and volume in one place. This template builds that view from an order export, which is the layer the simulator plugs into.
Free Excel template, LAD branded. No signup.
Companion projects. Channel Reallocation is the other project here that runs into the limits of observational data and recommends a test. The Profit Leak Audit builds the contribution model a price simulator needs underneath it.
[…] Price Elasticity From Observational Data […]