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.
// 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.
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 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.
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:
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.
| stratum | ad_users | psa_users | ad_rate_pct | psa_rate_pct | lift_pp | z | p | incr_conv |
|---|---|---|---|---|---|---|---|---|
| 1-4 | 141,938 | 6,582 | 0.236 | 0.1975 | 0.0385 | 0.6317 | 0.5276 | 54.66 |
| 5-10 | 107,561 | 4,694 | 0.4444 | 0.6817 | -0.2373 | -2.367 | 0.0179 | -255.3 |
| 11-20 | 123,334 | 4,150 | 0.84 | 0.8193 | 0.0207 | 0.1439 | 0.8856 | 25.55 |
| 21-40 | 103,286 | 4,349 | 2.323 | 1.978 | 0.3452 | 1.485 | 0.1376 | 356.6 |
| 41-80 | 55,920 | 2,289 | 8.467 | 4.675 | 3.793 | 6.441 | 0 | 2,121 |
| 81+ | 32,538 | 1,460 | 16.72 | 10.14 | 6.582 | 6.639 | 0 | 2,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:
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:
| decile | pred_uplift_pp | actual_gap_pp | ad_n | psa_n |
|---|---|---|---|---|
| 1 | -0.0504 | -0.1582 | 2.847e+04 | 1,277 |
| 2 | -0.0321 | -0.0281 | 2.938e+04 | 1,395 |
| 3 | -0.0155 | -0.1421 | 2.739e+04 | 1,227 |
| 4 | 0.025 | 0.0821 | 2.796e+04 | 1,189 |
| 5 | 0.1002 | 0.1388 | 2.882e+04 | 1,022 |
| 6 | 0.2131 | -0.1016 | 2.737e+04 | 1,007 |
| 7 | 0.3989 | 0.4089 | 2.828e+04 | 1,155 |
| 8 | 0.7037 | 0.1708 | 2.831e+04 | 1,075 |
| 9 | 1.372 | 0.8701 | 2.816e+04 | 1,188 |
| 10 | 4.882 | 6.663 | 2.815e+04 | 1,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.
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.
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.