back to portfolio
case study · advertising incrementality · exact + bayesian inference · uplift modeling

Did the Ads Actually Work?

A brand advertised to 564,577 people; a randomized 23,524-user control saw public-service announcements instead. This project measures what the campaign caused, three ways — an exact permutation test, a Bayesian posterior that turns lift into decision probabilities, and a from-scratch uplift model whose Qini curve says who to target — ending in two interactive widgets that price the campaign under uncertainty.

Python · numpy / pandas588k-user experiment Permutation testBeta-BinomialT-learner · QiniLogistic IRLSInteractive ×2
0 / 10⁶
of one million label shuffles produced the observed lift — the exact p-value is < 10⁻⁶
99.7%
posterior probability the ads lifted conversion by more than 25% (95% CrI: +30% to +57%)
86%
of the campaign's incremental conversions are reachable by targeting just 10% of the audience

// the question

Every campaign report counts conversions among people who saw ads. That number is nearly meaningless: heavy platform users see more ads and buy more anyway. The honest question is counterfactual — how many conversions would have happened without the ads? This experiment (Kaggle, Marketing A/B Testing) answers it by design: users were randomized to ads or to public-service announcements in the same slots. The control's conversion rate is the counterfactual.

why this matters to a media agency

Attribution says who converted after an ad; incrementality says who converted because of it. Budgets chasing attributed conversions over-pay for users who would have bought anyway. The experiment is the audit — and the uplift model is the budget plan.

// is it real · exact inference first

Ad arm: 2.55% conversion. Control: 1.79%. Lift: +0.77pp. Before any asymptotics, the sharpest test available uses only the randomization itself: if ads did nothing, the arm labels are arbitrary, so shuffle them. Because a full relabeling of 588,101 users only changes how many of the 14,843 total conversions land in the control arm, each shuffle is one draw from a hypergeometric — which makes one million exact permutations instant:

# one label-shuffle == one hypergeometric draw of control-arm conversions
perm_x0   = rng.hypergeometric(K, N-K, n0, size=1_000_000)   # K = all conversions
perm_diff = (K-perm_x0)/n1 - perm_x0/n0                      # lift under H0
p_exact   = (perm_diff >= observed_diff).mean()              # -> 0 of 1,000,000
The permutation null. The observed lift sits far beyond every one of a million no-effect worlds; the classical z-test (z = 7.4, p = 1.7e-13) agrees.
The permutation null. The observed lift sits far beyond every one of a million no-effect worlds; the classical z-test (z = 7.4, p = 1.7e-13) agrees.

// the bayesian read · from “significant” to decisions

The p-value answers “how surprising is this data if ads did nothing?” A client asks a different question: “given the data, what is the lift — and how sure are we?” That is a posterior. Each arm's rate gets a flat Beta(1,1) prior; because the Beta is conjugate to the Binomial, observing x conversions in n users updates it by addition, no fitting required:

# conjugacy: prior Beta(1,1) + data (x of n) -> posterior Beta(1+x, 1+n-x)
post_ad  = rng.beta(1 + 14_423, 1 + 550_154, size=4_000)   # ad arm draws
post_psa = rng.beta(1 +    420, 1 +  23_104, size=4_000)   # control draws
rel_lift = post_ad / post_psa - 1                          # posterior of the lift
P_gt_25  = (rel_lift > 0.25).mean()                        # -> 0.997

The control's posterior is visibly wider — 24× less data — and the machinery prices that honestly. Any decision quantity is then a matter of counting draws: P(lift > 0) ≈ 1.000, P(lift > +10%) ≈ 1.000, P(lift > +25%) = 99.7%, with a 95% credible interval of +30% to +57% — an interval that means what everyone always wanted intervals to mean: a 95% probability the true lift is inside.

Left: posterior conversion rates by arm. Right: the posterior of relative lift; essentially no mass below +25%.
Left: posterior conversion rates by arm. Right: the posterior of relative lift; essentially no mass below +25%.
why bayes earns its place here

Probabilities compose with economics. Push every posterior draw through a CPM and a conversion value and you get a distribution of iROAS — so instead of “iROAS was 2.3×” the agency can say “there is a 96% chance this campaign paid for itself.” Widget one, below, does exactly that, live.

// the trap · dose is not response

Within the ad arm, conversion rises from 0.24% (1–4 impressions) to 16.7% (81+). The naive read — “frequency works, buy more” — is confounded: impressions go to whoever shows up most, and heavy users convert more regardless. The control breaks the tie, since its heavy users saw heavy PSAs. Ad-vs-control within activity strata isolates what the ads add:

Both arms climb with activity (log scale) — that slope is user behavior, not advertising. The causal part is the gap, significant only at 41+ impressions.
Both arms climb with activity (log scale) — that slope is user behavior, not advertising. The causal part is the gap, significant only at 41+ impressions.
Where the campaign’s 4,343 incremental conversions actually come from.
Where the campaign’s 4,343 incremental conversions actually come from.
honest caveat · post-treatment conditioning

Exposure is realized after randomization, so strata are not experimental subgroups. Arm means differ by only 0.06 impressions, which bounds the concern — but the stratified read is evidence, not proof. The clean follow-up is a randomized frequency-cap test.

Table 3 · dose-response by activity stratum, 95% Wilson CIs in the figure
stratumad_userspsa_usersad_rate_pctpsa_rate_pctlift_ppzpincr_conv
1-4141,9386,5820.2360.19750.03850.63170.527654.66
5-10107,5614,6940.44440.6817-0.2373-2.3670.0179-255.3
11-20123,3344,1500.840.81930.02070.14390.885625.55
21-40103,2864,3492.3231.9780.34521.4850.1376356.6
41-8055,9202,2898.4674.6753.7936.44102,121
81+32,5381,46016.7210.146.5826.63902,142

// uplift · from “does it work” to “on whom”

The average effect hides its own budget plan. A T-learner fits two logistic regressions — my usual IRLS, coded from the likelihood up — one on the treated arm, one on the control, each predicting conversion from user features (log exposure, weekend, daypart). The difference of the two fitted probabilities is a per-user predicted uplift τ̂(x): how much the ad changes this user's chance of converting.

b_treated = logit_irls(X[train & ad ], y[train & ad ])   # P(convert | ad, x)
b_control = logit_irls(X[train & psa], y[train & psa])   # P(convert | no ad, x)
tau_hat   = sigmoid(X_test @ b_treated) - sigmoid(X_test @ b_control)

Ranking a 294,050-user holdout by τ̂ and sweeping down the list traces the Qini curve — incremental conversions captured vs. share of audience targeted, against the random-targeting diagonal:

The Qini curve on the holdout. Steep early: the model finds the persuadables first.
The Qini curve on the holdout. Steep early: the model finds the persuadables first.

And the model keeps its promises out of sample — predicted uplift by decile tracks the realized ad-minus-control gap, including correctly identifying deciles the ads barely move:

Calibration by holdout decile: the top decile was promised ~4.9pp and delivered ~6.7pp.
Calibration by holdout decile: the top decile was promised ~4.9pp and delivered ~6.7pp.
Table 6 · uplift calibration by holdout decile
decilepred_uplift_ppactual_gap_ppad_npsa_n
1-0.0504-0.15822.847e+041,277
2-0.0321-0.02812.938e+041,395
3-0.0155-0.14212.739e+041,227
40.0250.08212.796e+041,189
50.10020.13882.882e+041,022
60.2131-0.10162.737e+041,007
70.39890.40892.828e+041,155
80.70370.17082.831e+041,075
91.3720.87012.816e+041,188
104.8826.6632.815e+041,227

// interactive · posterior economics

1,500 posterior draws per arm are embedded in this page. Set a CPM and a conversion value: every readout below is recomputed across all draws — medians with 95% credible intervals, and the probability the campaign pays for itself.

media cost
incremental conversions (95% CrI)
iROAS median (95% CrI)
P(campaign profitable)
expected net value

Histogram: the posterior distribution of iROAS under your assumptions; the line marks break-even. Cost is deterministic (impressions × CPM); all uncertainty flows from the two conversion-rate posteriors.

// interactive · who to target

The Qini curve, priced. Choose what share of the audience to target (ranked by predicted uplift) — the widget applies the holdout curve and the actual impression volumes of the targeted users. High-uplift users are heavy-exposure users, so cost falls slower than reach: the model tells you that too, honestly.

incremental conversions captured
share of campaign effect
share of impressions bought
targeted iROAS
blanket iROAS (everyone)

Curve: holdout Qini scaled to the full ad arm; the marker is your current targeting depth. Point estimates, not posteriors — widget one carries the uncertainty story.

// what this demonstrates

Three complementary ways to read one experiment — exact (assumption-free), classical (fast), Bayesian (decision-ready) — plus the modeling step agencies actually buy: heterogeneous treatment effects with an honest holdout, a Qini curve that turns causal evidence into a media plan, calibration to prove the model isn't flattering itself, and the endogeneity trap (dose ≠ response) called out rather than monetized. The recommendation prices itself: target the top decile by predicted uplift, keep ~86% of the effect, and buy half the impressions — lifting iROAS from 2.35× to 4.4× at an $8 CPM.