Uplift Modelling From an A/B Test

Most of the lift went to people who were buying anyway. Uplift modelling separates them, and turns a loss into a profit on the same email.

In the Real World · Brief · Machine learning · Advanced · 3 to 5 days

A discount email lifted conversion and finance says it lost money. Work out who should have received it. Do this yourself before reading the walkthrough.

The situation

Northgate Home sent a 12% discount code to half its list and the same email without a code to the other half. 60,000 customers, randomised properly. Conversion in the treated group came out higher.

Finance has looked at the contribution and says the campaign was a loss. Marketing has looked at the conversion rate and says it was a win. You have the file.

The data

ColumnMeaning
customer_idOne row per customer
treated1 if they received the code, 0 if they received the email without it
recency_days, frequency, monetary, tenure_daysBehavioural history before the send
email_open_rateShare of past emails opened
discount_share_pastShare of past orders placed on an offer
browsed_last_7dVisited the site in the week before the send
device, regionContext
purchased, order_valueThe outcome in the 30 days after

The economics

QuantityValue
Contribution margin before discount42%
Discount offered12%
Average order valueroughly 78 pounds

What the room believes

  1. The code worked, because conversion went up.
  2. Targeting the customers most likely to buy will improve the return.
  3. The code helps everyone to some degree, so the worst case of sending it too widely is wasted margin.
  4. A model that predicts purchases well will target the code well.

Definition of done

  1. A verdict on each of the four beliefs, with the evidence.
  2. A stated rule, in pounds, for whether an individual customer is worth sending a code to.
  3. A model that estimates the effect of the code per customer, and an honest account of how you evaluated it given that the effect is never observed for anyone individually.
  4. A comparison of at least three targeting policies, valued in contribution rather than conversions.
  5. A list of who should not receive the code, with the evidence that they should not.

Four questions worth sitting with before you model anything

What exactly is the quantity you want to predict, and is it in the file for any single customer? What does the code cost when it goes to someone who would have bought anyway? Is it possible for an offer to reduce sales? And how would you evaluate a model whose target you can never observe?

If you want to go further

  • Check the randomisation before you trust any of it.
  • Build a straightforward purchase model as well, then compare how it ranks customers against how your own estimates rank them.
  • Work out the break-even condition algebraically before you fit anything, and see whether it changes which customers you would mail.
  • Decide what has to be true for this model to still work on the next campaign.

When you are done, read the walkthrough. All four beliefs turn out to be wrong, and one of them is wrong in a way that costs more than doing nothing. Compare your targeting policy to its policy, in pounds.

In the Real World · Machine learning · Advanced · 3 to 5 days

The discount email lifted conversion 2.98% points and lost money. Not because the lift was fake, but because most of it went to people who were going to buy anyway. This is the model that tells those two groups apart.

The situation. Northgate Home sent a 12% code to half its list and the same email without a code to the other half. 60,000 customers, cleanly randomised. Conversion went up. Finance says the campaign lost money. Both are right.

What the business is left with. A list of customers worth sending a code to, a do-not-contact list, and the arithmetic that separates them.

Attempt it first. The brief has the data and the question with none of the answers.

Section 1Problem Definition

No code yet. This project exists because of one distinction, and getting it wrong is the single most expensive mistake in CRM.

The distinction

QuestionWhat it predictsWhat it is for
Who is likely to buyThe probability someone convertsForecasting demand, sizing a campaign, prioritising a sales queue
Who buys because of the offerThe difference the offer makes to that personDeciding who to send the offer to

Almost every targeting model answers the first and is used for the second. The two are different quantities and section 7b shows they barely correlate here.

Four kinds of customer

TypeBuys without the codeBuys with itWhat the code does
PersuadableNoYesCreates a sale. This is the only group worth paying for
Sure thingYesYesHands over margin on a sale you already had
Sleeping dogYesNoTeaches them to wait for the next code
Lost causeNoNoNothing, at the cost of an email

Nothing in the data says which is which

A customer is only ever observed one way. The one who got the code and bought might be a persuadable or a sure thing, and you cannot tell from their row. The whole method is about recovering a difference that is never observed for any individual, only across groups.

Business objective

Decide who gets the code next time. The campaign as run had a positive effect on conversion and a negative effect on contribution, so the objective is not more conversions, it is more money.

Hypotheses

  1. H1. The code worked, because conversion went up.
  2. H2. Targeting the customers most likely to buy will improve the return.
  3. H3. The code helps everyone to some degree, so the worst case of sending it too widely is wasted margin.
  4. H4. A model that predicts purchases well will target the code well.

The economics, agreed before modelling

QuantityValueWhere it comes from
Average order value77.70 poundsObserved across both arms
Contribution at full price32.63 pounds42% margin
Contribution with the code23.31 poundsMargin less the 12% discount
Handed over per sure thing9.32 poundsThe discount on a sale that would have happened anyway

The rule everything else serves

Sending one customer a code is worth the uplift it creates, times the contribution on a discounted sale, minus the chance they would have bought anyway, times the margin handed over.

Expected change equals uplift x 23.31 minus baseline rate x 9.32.

Which means a customer is worth mailing only if their uplift is at least 0.4 times their baseline purchase rate. Write that number down. It is the whole decision.

Section 2Data Collection

import numpy as np
import pandas as pd
from scipy import stats
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

SEED = 20260822
MARGIN, DISCOUNT = 0.42, 0.12

d = pd.read_csv('data/northgate-discount-test.csv')
print('rows    :', len(d))
print('treated :', int(d['treated'].sum()), ' control :', int((1 - d['treated']).sum()))
print(d['purchased'].groupby(d['treated']).mean().round(4).to_string())
rows    : 60,000
treated : 30,023  control : 29,977
treated
0    0.1004
1    0.1302
ColumnMeaning
customer_idOne row per customer
treated1 if they got the code, 0 if they got the same email without one
recency_days, frequency, monetary, tenure_daysStandard behavioural history
email_open_rateShare of past emails opened
discount_share_pastShare of past orders placed on an offer
browsed_last_7dVisited the site in the week before the send
device, regionContext
purchased, order_valueThe outcome, in the 30 days after the send

Section 3Data Preprocessing

3aDuplicates and schema checks

On an uplift project the schema check is a validity check, and it comes before everything. If the two arms were not comparable, no amount of modelling recovers a causal effect.

print('duplicate rows      :', d.duplicated().sum())
print('duplicate customers :', d['customer_id'].duplicated().sum())

chi2, p = stats.chisquare([int((1 - d['treated']).sum()), int(d['treated'].sum())])
print(f'split {d["treated"].mean():.5f}   chi2 {chi2:.3f}   p {p:.4f}')
duplicate rows      : 0
duplicate customers : 0
split 0.50038   chi2 0.035   p 0.8510

The split passes at p = 0.851. Worth saying plainly, because the post-test analysis project on this site is entirely about what happens when it does not, and every method below assumes randomisation held.

3bHandling categorical mess

Device and region are clean and get one-hot encoded in 5c. Neither is expected to carry much, and both are kept so the model can prove that rather than have it assumed.

3cDealing with outliers

Order value has the usual right tail. It does not enter the model at all: the outcome being modelled is whether someone bought, and value enters only in the economics in section 7d, as a single average. Modelling the amount as well would be a second project and a second set of assumptions.

3dHandling missing values

missing cells : 0

None. The interesting absence in this dataset is not a null, it is that no column records which of the four customer types anyone is. That is the quantity the whole project has to recover.

3eHandling skewed data

Recency and monetary are both right-skewed. Gradient boosting splits on order rather than on distance so it is untroubled by either, and the logistic baseline in 7b is standardised inside its pipeline. No transform is applied to the shared feature table, because the two models want different things and hiding that in preprocessing would make the comparison dishonest.

3fData types and normalisation

The balance check belongs here, because it is the last thing that can invalidate everything downstream.

FEATS = ['recency_days', 'frequency', 'monetary', 'tenure_days',
         'email_open_rate', 'discount_share_past', 'browsed_last_7d']

for c in FEATS:
    a, b = d.loc[d['treated'] == 1, c], d.loc[d['treated'] == 0, c]
    smd = (a.mean() - b.mean()) / np.sqrt((a.var() + b.var()) / 2)
    print(f'{c:22s} treated {a.mean():9.3f}  control {b.mean():9.3f}  SMD {smd:+.4f}')
recency_days           treated   120.610  control   120.049  SMD +0.0069
frequency              treated     2.612  control     2.623  SMD -0.0066
monetary               treated   189.804  control   189.841  SMD -0.0003
tenure_days            treated   761.036  control   763.738  SMD -0.0064
email_open_rate        treated     0.423  control     0.424  SMD -0.0030
discount_share_past    treated     0.230  control     0.231  SMD -0.0054
browsed_last_7d        treated     0.255  control     0.255  SMD -0.0015

Every standardised mean difference is inside 0.0069, well under the 0.1 anyone would treat as a warning. The randomisation did its job, so any difference in outcome between the arms is caused by the code.

Section 4Exploratory Data Analysis

4aTarget variable analysis

There are two targets here and only one of them is in the data. The observed target is whether someone bought. The target we actually want is the difference the code made to them, which is never observed for any individual.

ct = d.loc[d['treated'] == 1, 'purchased'].mean()
cc = d.loc[d['treated'] == 0, 'purchased'].mean()
se = np.sqrt(ct * (1 - ct) / (d['treated'] == 1).sum()
             + cc * (1 - cc) / (d['treated'] == 0).sum())

print(f'treated {ct:.4f}   control {cc:.4f}')
print(f'average treatment effect {ct - cc:+.4f}  '
      f'95% CI [{ct - cc - 1.96 * se:+.4f}, {ct - cc + 1.96 * se:+.4f}]')
treated 0.1302   control 0.1004
average treatment effect +0.0298  95% CI [+0.0247, +0.0349]

The code lifted conversion by 2.98% points, a relative gain of 29.7%, with a p-value below the smallest number floating point can represent. On any dashboard that is a win.

The same number, in money

Every treated customer who buys pays 12% less. The 10.0% of them who would have bought anyway cost 9.32 pounds each in handed-over margin.

Uplift x 23.31 minus 0.1004 x 9.32 comes to -0.254 pounds per customer mailed. Across the test set that is -5,324 pounds.

H1 is half right. The code worked and the campaign lost money.

4bNumerical variables

The features are ordinary CRM history. What matters is not their distributions but whether any of them relates to the difference the code makes, which cannot be seen by looking at one variable at a time. That is why this section is short and section 7 is long.

4cCategorical variables

Device and region are balanced across arms and carry almost nothing. They are left in so the model can say so.

4dRelationships between variables

One relationship can be checked before modelling, and it is the one that sinks H2: the customers most likely to buy are not the ones the code moves.

# how much room is there for a code to add anything, by how likely someone
# already is to buy? Use the CONTROL arm only, so nothing is contaminated.
ctrl = d[d['treated'] == 0].copy()
ctrl['band'] = pd.qcut(ctrl['recency_days'], 5, labels=['most recent', '2', '3', '4',
                                                        'longest ago'])
print(ctrl.groupby('band', observed=True)['purchased'].agg(['size', 'mean']).round(4)
          .to_string())

Split the control arm into deciles of purchase rate and the answer is visible before any uplift model exists. The heaviest buyers convert at several times the base rate with no code at all, so there is very little room for a code to add anything, and every one of them who converts costs 9.32 pounds of margin.

4eTesting our hypotheses

HypothesisVerdictEvidence
H1. The code workedHalf right2.98% point lift, p below the smallest number floating point can represent, and -5,324 pounds of contribution across the test set
H2. Target the likely buyersWrong, and expensivelyTop 20 per cent by propensity returns -8,269 pounds, worse than mailing everyone at -5,324
H3. Over-sending only wastes marginWrongThe bottom decile by uplift measures -0.0013. Some customers buy less when they get a code
H4. A good purchase model targets wellWrongUplift and propensity rank-correlate at -0.0595, and their top deciles share 16.2% of customers

4fSubgroups

The subgroup that matters is the one no column names. Section 7 recovers it, and 7d profiles it, but it is worth stating the shape of the problem now: roughly a quarter of this list is worth a code, and the campaign sent it to everyone.

Section 5Feature Engineering

5aThe leakage trap

The leak in an uplift model is specific and easy to walk into: any feature that knows about the treatment.

What would break it

A column recording whether the customer used a discount code in this campaign. A flag for opening this email. Anything measured after the send. Each of those is downstream of treatment, and a model containing one will report enormous uplift and target nothing.

Every feature here is a customer’s state before the send. discount_share_past is the closest call, and it is safe because it counts orders before this campaign, not this one.

The subtler trap is the outcome itself. Two models are fitted, one per arm, and each sees only its own arm’s outcomes. Fitting a single model on everyone with treatment as a feature is the alternative in 6, and it fails for a different reason.

5bNew features

None. Seven behavioural columns and two categoricals, straight from the file.

Why nothing is engineered here

Feature engineering usually pays for itself by adding signal about the outcome. Uplift needs signal about the difference between two outcomes, and that difference is small, noisy and measured only in aggregate. Elaborate features tend to improve the fit of both arm models equally and leave the difference exactly where it was, while making the leakage surface larger. The discipline here is restraint.

5cEncoding

FEATS = ['recency_days', 'frequency', 'monetary', 'tenure_days',
         'email_open_rate', 'discount_share_past', 'browsed_last_7d']
CATS  = ['device', 'region']

X = pd.get_dummies(d[FEATS + CATS], columns=CATS, drop_first=True)
y = d['purchased'].values
t = d['treated'].values
print('feature matrix :', X.shape)
feature matrix : (60,000, 14)

5dFeature selection

Everything is kept. With 60,000 rows and fourteen columns there is no dimensionality problem, and dropping features on the basis of how they affect the uplift estimate is circular in the same way that choosing k by the prettiest clusters is circular. There is no held-out truth about individual uplift to validate against.

Section 6Model Selection

ApproachHow it worksWhy not
Single model with treatment as a featureFit one classifier on everyone, predict twice with the flag on and offThe treatment flag is one column among fourteen and a tree will mostly ignore it. The uplift it reports is whatever the regulariser left of a small coefficient
Two models, one per armFit separately, subtract the predictionsChosen. Each model is a straightforward classification problem, both are easy to check, and the difference is explicit rather than buried in a coefficient
Class transformationRelabel so a single model predicts uplift directlyElegant and harder to debug. It needs an exact 50/50 split, which holds here, but the failure mode when it does not is silent
Uplift treesSplit on the difference in outcome between armsThe right tool at scale and a dependency to install and explain. Two models is the version anyone can reproduce with what they already have

How to judge one

Accuracy is meaningless here, because the thing being predicted is never observed. The measure has to be whether ranking by predicted uplift concentrates the real, measured effect at the top.

MeasureWhat it answers
Measured uplift by decileRank customers by predicted uplift, then measure the actual treated minus control difference inside each decile. This is the honest test and it needs the holdout
Area under the uplift curveThe same thing as one number, for comparing models
Contribution in poundsThe only one anyone outside the team cares about, and the one section 7d ends on

Section 7Model Training

7aBaselines

BaselineWhat it doesResult on the holdout
Mail everyoneWhat the campaign actually did-5,324 pounds, -0.254 per customer
Mail the top 20 per cent by propensityWhat most targeting models are used for-8,269 pounds, -1.965 per customer
Mail nobodyThe do-nothing option0 pounds

Note the order. The propensity model, which is what almost every CRM team would build, is worse than mailing everyone. It finds the sure things and hands each of them 9.32 pounds.

7bComparing candidates

Xtr, Xte, ytr, yte, ttr, tte = train_test_split(
    X, y, t, test_size=0.35, random_state=SEED, stratify=t)


def two_model_uplift(make):
    m_t = make().fit(Xtr[ttr == 1], ytr[ttr == 1])
    m_c = make().fit(Xtr[ttr == 0], ytr[ttr == 0])
    return m_t, m_c, m_t.predict_proba(Xte)[:, 1] - m_c.predict_proba(Xte)[:, 1]


def boosting():
    return HistGradientBoostingClassifier(max_depth=4, learning_rate=0.06,
                                          max_iter=300, random_state=SEED)


m_t, m_c, uplift = two_model_uplift(boosting)
propensity = m_t.predict_proba(Xte)[:, 1]

print('purchase model AUC :', round(roc_auc_score(yte, propensity), 4))
print('uplift and propensity rank correlation :',
      round(stats.spearmanr(uplift, propensity).correlation, 4))
purchase model AUC : 0.6118
uplift and propensity rank correlation : -0.0595

The two rankings are not the same ranking

The purchase model is good: it separates buyers from non-buyers at an AUC of 0.6118.

Its ranking correlates with the uplift ranking at -0.0595. Their top deciles share 16.2% of customers.

H4 is dead. A model that predicts purchases well tells you almost nothing about who to send an offer to, and the better it gets at predicting purchases the more reliably it finds the people who did not need the offer.

7cHyperparameter tuning

Two model families, each fitted twice, compared on the area under the uplift curve rather than on anything about individual predictions.

ModelPurchase AUCArea under uplift curveUplift captured by 30 per cent
logistic0.60360.63850.5212
boosting0.61180.71210.57

Boosting wins on the uplift measure and the two are close on purchase AUC, which is the pattern to expect: they are similarly good at the observable problem and differ on the unobservable one. Boosting is taken forward.

What is not tuned, and why

No grid search. With an effect of 2.98% points measured across tens of thousands of customers, the difference between a good hyperparameter set and a slightly better one is far smaller than the noise in the uplift estimate itself. Tuning hard against the holdout would fit that noise and report a Qini curve that does not replicate.

7dFinal evaluation

Does the ranking concentrate the real effect

# Xte keeps the original row labels. Build this with a fresh index and every
# later join back to d quietly lands on the wrong customers.
te = pd.DataFrame({'y': yte, 't': tte, 'uplift': uplift, 'propensity': propensity},
                  index=Xte.index)


def measured_by_decile(col):
    q = pd.qcut(te[col].rank(method='first', ascending=False), 10, labels=False) + 1
    for dec, g in te.groupby(q):
        eff = g.loc[g['t'] == 1, 'y'].mean() - g.loc[g['t'] == 0, 'y'].mean()
        print(f'decile {dec:2d}  n {len(g):5d}  measured uplift {eff:+.4f}')


measured_by_decile('uplift')
decile  1  n  2100  measured uplift +0.0685
decile  2  n  2100  measured uplift +0.0632
decile  3  n  2100  measured uplift +0.0356
decile  4  n  2100  measured uplift +0.0310
decile  5  n  2100  measured uplift +0.0447
decile  6  n  2100  measured uplift +0.0239
decile  7  n  2100  measured uplift +0.0194
decile  8  n  2100  measured uplift +0.0033
decile  9  n  2100  measured uplift +0.0145
decile 10  n  2100  measured uplift -0.0013
Measured uplift by decile, two rankings-0.028-0.006+0.016+0.038+0.059+0.08112345678910ranked by predicted upliftranked by predicted purchasedecile 1 is the top tenth of each ranking

The same holdout, ranked two ways, with the real treated-minus-control difference measured inside each decile. Ranking by uplift puts 6.85% points in the top decile. Ranking by purchase probability puts -1.51% there.

The uplift ranking is monotone enough to act on and the bottom decile is negative at -0.0013. Those customers bought less when they got a code, which is H3 refuted: over-sending does not merely waste margin, it can remove sales.

Share of the real effect captured, by share of the list mailed0.00.00.20.20.40.40.60.60.80.81.01.0uplift rankingrandom targetingshare of the list mailed

Mail the top 30 per cent by predicted uplift and you capture 55.3% of the total measured effect. Random targeting captures 30 per cent of it, by definition.

What the two groups look like

seen = d.loc[te.index].copy()
seen['uplift'] = te['uplift'].values

hi = seen.nlargest(int(len(seen) * 0.2), 'uplift')
lo = seen.nsmallest(int(len(seen) * 0.2), 'uplift')

for c in FEATS:
    print(f'{c:22s} persuadables {hi[c].mean():9.3f}   bottom {lo[c].mean():9.3f}')
recency_days           persuadables   139.180   bottom    73.637
frequency              persuadables     2.766   bottom     3.201
monetary               persuadables   205.497   bottom   181.339
tenure_days            persuadables   755.285   bottom   768.416
email_open_rate        persuadables     0.505   bottom     0.393
discount_share_past    persuadables     0.393   bottom     0.171
browsed_last_7d        persuadables     0.195   bottom     0.386
Persuadables against the bottom groupdifference in each feature, scaled so the bars are comparablerecency days+0.47frequency-0.14monetary+0.12tenure days-0.02email open rate+0.22discount share past+0.56browsed last 7d-0.49

What separates the two ends of the uplift ranking. Past discount use and time since the last order point one way, recency and frequency the other.

FeatureTop 20 per cent by upliftBottom 20 per cent
recency_days139.1873.637
frequency2.7663.201
monetary205.497181.339
tenure_days755.285768.416
email_open_rate0.5050.393
discount_share_past0.3930.171
browsed_last_7d0.1950.386

The persuadables have bought on an offer before, at 0.393 of their past orders against 0.171 for the bottom group, and they have been away longer, at 139 days against 74. The bottom group is recent and frequent: people who were coming back anyway.

The money

AOV  = d.loc[d['purchased'] == 1, 'order_value'].mean()
GAIN = AOV * (MARGIN - DISCOUNT)   # contribution on a sale the code caused
GIVE = AOV * DISCOUNT              # margin handed to someone who would have bought


def policy_value(mask):
    sub = te[mask]
    a, b = sub.loc[sub['t'] == 1, 'y'], sub.loc[sub['t'] == 0, 'y']
    per_head = (a.mean() - b.mean()) * GAIN - b.mean() * GIVE
    return per_head * len(sub), len(sub)


# the rule from section 1, applied per customer rather than per decile
te['baseline_hat']   = m_c.predict_proba(Xte)[:, 1]
te['expected_value'] = te['uplift'] * GAIN - te['baseline_hat'] * GIVE

for label, mask in [
        ('Mail everyone',                    pd.Series(True, index=te.index)),
        ('Top 20% by propensity',            te['propensity'] >= te['propensity'].quantile(0.8)),
        ('Top 20% by uplift',                te['uplift'] >= te['uplift'].quantile(0.8)),
        ('Where expected value is positive', te['expected_value'] > 0)]:
    v, m = policy_value(mask)
    print(f'{label:34s} mailed {m:6,d}   value {v:9,.0f}   per head {v / m:+.3f}')
Mail everyone                      mailed 21,000   value    -5,324   per head -0.254
Top 20% by propensity              mailed  4,209   value    -8,269   per head -1.965
Top 20% by uplift                  mailed  4,200   value     3,621   per head +0.862
Where expected value is positive   mailed 10,204   value     5,282   per head +0.518
Contribution by targeting policy-10878.950-7225.170-3571.390+82.390+3736.170+7389.950-5,324Mail everyone-8,269Top 20% by propensity+3,621Top 20% by uplift-8,771Top 30% by propensity+3,768Top 30% by uplift-7,374Top 50% by propensity+4,001Top 50% by uplift+5,282Where expected value is positivepounds across the holdout of 21,000 customers

Every policy on the same holdout. Targeting by purchase probability is the worst of them, worse than sending to everyone.

The answer, in pounds

The campaign as run: -5,324 pounds across the holdout.

Targeted the way most teams would, by purchase probability: -8,269. Worse.

Mailed only where the expected value is positive, which is 10,204 customers out of 21,000 or 48.6% of the list: 5,282 pounds.

Scaled to the full file that is a swing of about 30,302 pounds per campaign, from the same email, the same code and the same customers.

Section 8Documentation and Handoff

Send the code to fewer people and make more money

The discount lifts conversion by 2.98% points and loses 5,324 pounds across the holdout, because 10.0% of the people receiving it were going to buy anyway and each of them costs 9.32 pounds in handed-over margin.

Mail the 48.6% of the list whose expected value is positive and the same campaign returns 5,282 pounds. Do not target by who is likely to buy: that is -8,269 pounds, worse than doing nothing clever at all.

The two lists

ListWho is on itSizeWhat to do
PersuadablesHave bought on an offer before (0.393 of past orders), away 139 days on average48.6% of the listSend the code. This is the whole campaign
Do not contactRecent and frequent. Bought 74 days ago on average and were coming back regardless13.5% with negative predicted upliftSend the email without a code, or nothing. Measured uplift here is -0.0030

What to do, and who owns it

ActionDetailOwner
Score the list on uplift, not propensityTwo models, one per arm, difference of predicted probabilities. The purchase model you already have is the wrong one for this jobAnalytics
Apply the expected value rule, not a top-N cutMail where uplift x 23.31 beats baseline x 9.32. A fixed top 20 per cent is arbitrary and the break-even ratio is notAnalytics, CRM
Always keep a randomised holdoutUplift can only be measured against a control group. Stop holding one out and the model cannot be retrained or checked, everCRM
Suppress the negative uplift group13.5% of the list, measured at -0.0030. Send them the email without the codeCRM
Re-estimate each campaignUplift is specific to this offer, this creative and this season. A model from a 12 per cent code says little about a free delivery offerAnalytics

What not to do

  • Do not judge the campaign on conversion. It went up 2.98% points and the campaign lost money.
  • Do not target by propensity. It is the intuitive move, it is what a purchase model gives you, and here it is worse than no targeting at all.
  • Do not assume the worst case is wasted budget. The bottom decile measures -0.0013: some customers buy less because of the code.
  • Do not drop the control group once the model works. It is the only instrument that can measure uplift, and without it the model degrades silently.
  • Do not reuse this model for a different offer. The uplift is a property of the offer as much as of the customer.

Reproducibility

ItemValue
Filenorthgate-discount-test.csv, 60,000 customers
Randomisation30,023 treated, 29,977 control, SRM p 0.851
BalanceAll standardised mean differences under 0.1
Split39000 train, 21000 holdout, stratified on treatment
MethodTwo-model uplift, one classifier per arm, difference of predicted probabilities
Modelboosting, chosen on area under the uplift curve
EconomicsAOV 77.70, margin 42%, discount 12%
Decision ruleuplift x 23.31 greater than baseline x 9.32
Librariespandas, numpy, scipy.stats, scikit-learn

What to take from this

  • Predicting who buys and predicting who to persuade are different problems. Here they rank-correlate at -0.0595.
  • A campaign can lift conversion and lose money. The lift has to clear the margin you hand to everyone who would have bought anyway.
  • Write the break-even ratio down before modelling. Uplift has to beat 0.4 times baseline. Everything else is detail.
  • Negative uplift is real. Sleeping dogs are not a theoretical category, they are 13.5% of this list.
  • Evaluate on measured uplift by decile, on a holdout. There is no individual ground truth, so the only honest test is whether the ranking concentrates the real effect.
  • Keep the control group forever. It is the instrument, not the cost.

The campaign looked like a success and was not. What changed the answer was not a better model of who buys, it was asking a different question of the same data: not who converts, but who converts because of us. That question needs a control group, and it is the reason to keep holding one out long after the test is over.

Size the holdout before you send

Sample Size Calculator

Uplift is a difference of differences, so it needs far more traffic than a headline conversion test. This campaign measured a 2.98% point effect across 60,000 customers, and the decile estimates are noisier still. Put the baseline rate and the effect you need to detect in before you commit a list.

Open the calculator

Free, no signup. Pairs with the Power and MDE Calculator.

Structure the read-out

Post A/B Test Analysis Template

An uplift result has more moving parts than a normal test: the headline effect, the decile table, the economics and the suppression list. This template gives the read-out an order so the conversation stays on the money rather than the model.

Download the template

Excel workbook. Pairs with the A/B Test Checklist.

Companion projects. Post-Test Analysis is the project about what happens when the randomisation this one depends on has failed. The Profit Leak Audit builds the contribution thinking the economics here rest on.

Add a Comment

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.