Price Elasticity From Observational Data

The regression is easy and the answer is wrong. Prices were not set at random, and every control you add gets you closer without getting you there.

In the Real World · Brief · Machine learning · Core · 1 to 2 days

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

ColumnMeaning
week_start, week_index, skuOne row per product per week
list_price, priceWhat it normally sells for, and what it sold for that week
on_promotion, clearanceWhy the price moved that week
competitor_indexA rival price index for the category, 1.0 is parity
units_soldVolume

What the room believes

  1. A log-log regression of units on price gives the elasticity.
  2. Two years of real price variation is enough to measure it.
  3. Adding the right controls will get you to the true answer.
  4. An estimate that is close enough for a report is close enough for a pricing decision.

Definition of done

  1. A verdict on each of the four beliefs, with the evidence.
  2. An elasticity per product, with a defence of the specification you chose.
  3. An explicit list of the reasons price moved in this data, and what each does to a naive estimate.
  4. A working simulator: price in, volume and contribution out.
  5. 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.

In the Real World · Machine learning · Core · 1 to 2 days

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.

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 movedWhat it does to demandWhat a naive regression concludes
A promotion timed to a busy weekDemand was going to be high anywayThe discount looks less effective than it was
Clearance on a dying lineDemand is falling for its own reasonsThe discount looks like it caused the decline
A competitor cut their price tooDemand falls despite our cutOur own price change gets blamed
Nothing. Premium lines are just dearerThey also sell in smaller numbersPrice 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

  1. H1. A log-log regression of units on price gives the elasticity.
  2. H2. Two years of real price variation is enough to measure it.
  3. H3. Adding the right controls will get us to the true answer.
  4. 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%
ColumnMeaning
week_start, week_index, skuOne row per product per week
list_price, priceWhat it normally sells for, and what it sold for that week
on_promotion, clearanceWhy the price moved
competitor_indexA rival price index for the category, 1.0 is parity
units_soldVolume

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
Mean weekly units by pricing state045.0890.17135.25180.34225.42106normal weeks191promotion185clearanceboth discounted states outsell normal weeks, which tells you nothing about why the price was cut

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
Weekly trend in log units, by whether the line was ever cleared-0.004-0.003-0.002-0.000+0.001+0.002-0.00356cleared lines+0.00150all other linesclearance is applied to lines already in decline

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
Correlation of the competitor index with price and with units-0.544-0.273-0.002+0.269+0.540+0.810with pricewith unitspooledwithin productpooling across products hides the confounder almost entirely

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

HypothesisVerdictEvidence
H1. A log-log regression gives the elasticityWrongIt returns -1.229 against a true mean of -2.235, understating the effect by 45.0%
H2. Two years of variation is enoughNecessary, not sufficientPrices move by a factor of 1.57 and the naive estimate is still wrong by 1.006
H3. The right controls will get us thereMost of the wayControls 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 onWrongSection 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

ApproachWhat it assumesWhy it is or is not used
Pooled log-log OLSPrice is as good as randomly assignedUsed only as the baseline to beat. The assumption is false by construction here
Product fixed effectsAnything fixed about a product is absorbedUsed. It removes the cross-sectional confound in one line
Fixed effects plus time controlsTiming of discounts is captured by seasonUsed. This is the honest workhorse
Instrumental variablesA variable moves price and nothing elseNot used. There is no credible instrument in this file, and a weak one is worse than none
A randomised price testPrices are actually assigned at randomNot 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
How far each estimator is from the truth-0.898-0.468-0.037+0.393+0.824+1.254+1.006Naive pooled-0.645Product fixed effects-0.650Fixed effects + seasonality-0.370Fixed effects, seasonality and competitorbias against a true mean elasticity of -2.235. Zero is correct

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
Absolute error against the true elasticity, per product-0.275+0.201+0.677+1.154+1.630+2.106101102103104105106107108109110111112113114115116117118119120naive errorcontrolled errorlower is better

Controls help on almost every line. Mean absolute error falls from 0.697 to 0.262.

MeasureNaiveWith controls
Mean absolute error0.6970.262
Correct sign20 of 2020 of 20
Within 0.5 of the truth7 of 2016 of 20
Correlation with the truth0.6970.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
Contribution against price change, Stoneware Mug Set0.00.00.60.11.10.21.70.32.20.42.80.5using the estimateusing the truthprice change, from minus 20 per cent on the left

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

ActionDetailOwner
Estimate within product, never pooledOne dummy per line. It is the single largest correction available and it costs one line of codeAnalytics
Control for season and the competitor indexBoth move price and demand together. Everything else in the file is either fixed per product or downstream of priceAnalytics
Publish the simulator with a confidence band, not a pointElasticity ranges from -3.1 to -1.3 across the range, and the estimate for any one line carries real errorAnalytics
Run a randomised price test on the top linesTwo matched groups of stores or regions, or a staggered rollout. It is the only thing that identifies the effect, and it answers in weeksCommercial
Never control for anything price causesPrevious week volume, inventory and revenue all improve the fit and bias the estimateAnalytics

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

ItemValue
Filebrackwell-pricing-panel.csv, 2,080 product weeks
Window2024-07-29 to 2026-07-20, 104 weeks
Specificationlog units on log price, product dummies, four Fourier terms, competitor index
Best estimatorFixed effects, seasonality and competitor, bias -0.37
Ground truthKnown because the panel is simulated. Held in make_dataset.py and in the analysis, never in the exported file
Librariespandas, 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.

Open the calculator

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.

Download the dashboard

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.

View Comments (1)

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading