A/B testing a model change
Hash assignment, per-user results from raw logs, sample ratio checks, guardrails and a decision rule.
Create a free account Try part of it here
Why it matters
A new prompt wins on the eval set; does it help real users? Offline evals can't tell you whether people buy more, complain less or wait too long, so teams run an online experiment: some users get the new version, some the old, and the difference in what they do is the answer. Done carelessly, experiments produce confident wrong answers: a logging bug that drops one group's slow sessions, a test stopped the day it looked good, a "win" that doubled cost. This lesson builds the analysis from the raw tables a real experiment produces, with the checks that catch those mistakes.
What you'll be able to do
- Assign users to variants deterministically and independently across experiments
- Build per-user results from raw exposure and outcome tables, counting only what happened after exposure
- Check for a sample ratio mismatch before trusting any result
- Decide with a primary metric, guardrail metrics and a rule written down before the data arrives
What it covers
- Assignment: stable, random-looking, independent
- From raw tables to one row per user
- Check the split before the result: sample ratio mismatch
- Flag a broken split
- Decide with a rule written in advance
- Experiment memo: is the new checkout helper a win?
Show the answer
An online A/B test: some users get the new prompt, some the old
Offline evals can't show whether people buy more or complain less. A randomized online test compares the two prompts on real users at the same time, so other changes affect both groups equally.
Assignment: stable, random-looking, independent
The CI/CD lesson's percentage rollouts put the flag name and the user id through a hash to get a stable bucket, so the same user always got the same answer. An experiment assigns its groups the same way, with the experiment's name in place of the flag's, and finer buckets (0 to 9,999) so a 2.5% group is possible. The name matters more than it looks: hash the user id alone and the users in the first 10% of one experiment are the same users in the first 10% of every experiment, so a result from one test leaks into the next.
import hashlib def bucket(user_id, experiment, buckets=10_000): digest = hashlib.sha256(f"{experiment}:{user_id}".encode()).hexdigest() return int(digest[:8], 16) % buckets def variant(user_id, experiment, treatment_share=0.5): return "new" if bucket(user_id, experiment) < treatment_share * 10_000 else "old" users = [f"u{i}" for i in range(10_000)] a = [variant(u, "prompt-v2") for u in users] b = [variant(u, "faster-model") for u in users] print("share new in prompt-v2:", a.count("new") / len(a)) print("same answer on a rerun:", a == [variant(u, "prompt-v2") for u in users]) both = sum(x == "new" and y == "new" for x, y in zip(a, b)) print("new in both experiments:", both / len(users), "(about 0.25 if they're independent)")
About half the users get the new prompt, the same half every time, and being in one experiment's treatment group says nothing about the other's. Assign by user, not by request: a person who sees the old prompt on one page and the new one on the next isn't in either group.
Show the answer
The same users get both treatments, so the results mix
With identical hashing, the 10% who see experiment A's new version are exactly the 10% who see B's. If A helps, B looks like it helps too. Salting the hash with the experiment's name makes the groups independent.
From raw tables to one row per user
An experiment leaves two kinds of records. Exposures say when each user first saw their variant. Outcomes are what users did: orders, thumbs-down clicks, sessions with their latency. The analysis unit is the user, so the first step builds one row per exposed user, counting only outcomes after their first exposure: an order placed before the user ever saw the new prompt can't be its effect.
import pandas as pd exposures = pd.DataFrame({ "user": ["u1", "u1", "u2", "u3", "u4"], "variant": ["new", "new", "old", "new", "old"], "time": pd.to_datetime(["2025-06-01 10:00", "2025-06-02 09:00", "2025-06-01 11:00", "2025-06-03 08:00", "2025-06-02 15:00"]), }) orders = pd.DataFrame({ "user": ["u1", "u2", "u3", "u3", "u5"], "time": pd.to_datetime(["2025-06-01 12:00", "2025-05-30 09:00", "2025-06-03 09:30", "2025-06-04 10:00", "2025-06-02 10:00"]), }) first = exposures.groupby("user").agg(variant=("variant", "first"), exposed=("time", "min")).reset_index() after = orders.merge(first, on="user").query("time >= exposed") per_user = first.assign(converted=first["user"].isin(after["user"])) print(per_user) print(per_user.groupby("variant")["converted"].mean())
u2's order came before their first exposure, so it doesn't count. u5 ordered but was never exposed, so they're not in the experiment at all. And u4 stays in the table with no order: users who saw a variant and did nothing are half of the answer. Dropping them is a classic way to inflate a conversion rate.
exposed = {"u1": 10, "u2": 11}
orders = [("u1", 12), ("u2", 9), ("u3", 13), ("u2", 15)]
print(sum(1 for user, hour in orders if user in exposed and hour >= exposed[user]))Show the answer
2
u1's order at 12 counts (after 10). u2's order at 9 came before their exposure at 11, so it doesn't; their order at 15 does. u3 was never exposed. That leaves 2.
Check the split before the result: sample ratio mismatch
If the plan was 50/50 and the data has 5,000 users in one group and 4,700 in the other, something is broken: maybe the new version crashes for some users before it logs their exposure, or a bot filter treats the groups differently. That's a sample ratio mismatch (SRM), and when it happens the groups are no longer comparable, so no result from the experiment can be trusted until the cause is found.
The test is a chi-square goodness-of-fit test: compare the observed counts with the counts the plan expects. Because experiments are large, a tiny imbalance is normal and a real bug produces a very small p-value, so teams use a strict threshold such as 0.001.
from scipy.stats import chisquare def srm_p_value(counts, shares): total = sum(counts) expected = [total * s for s in shares] return chisquare(counts, f_exp=expected).pvalue print(round(srm_p_value([5012, 4988], [0.5, 0.5]), 3)) # normal wobble print(srm_p_value([5000, 4600], [0.5, 0.5])) # a bug print(round(srm_p_value([9030, 970], [0.9, 0.1]), 3)) # a 90/10 rollout, on plan
The first split differs by 24 users and gets a p-value around 0.81: nothing to worry about. The second, 400 users apart, gets about 0.00004, far below 0.001: the split is broken. The third shows the check works for any planned split, not just 50/50: 9,030 and 970 sit close to the 9,000 and 1,000 a 90/10 plan expects.
When the check fails, slice it: run the same test separately for each platform, country or app version. A bug usually lives in one segment, such as one phone model where the new screen crashes before it logs the exposure, and the slice that fails points at it.
Flag a broken split
Write srm(counts, shares, threshold=0.001): return (p_value, broken), where p_value comes from scipy.stats.chisquare with expected counts from the planned shares, and broken is True when it's below the threshold. Raise ValueError if the shares don't add up to 1 (within 1e-9) or the two lists differ in length.
from scipy.stats import chisquare def srm(counts, shares, threshold=0.001): return 1.0, False print(srm([10_000, 9_400], [0.5, 0.5]))
Decide with a rule written in advance
Before the data arrives, write down three things, and the decision becomes arithmetic instead of debate:
- The primary metric: the one number the change is meant to move, such as the share of users who order.
- Guardrail metrics: things that must not get worse, such as the p95 response time, the cost per conversation, or the rate of thumbs-down clicks. A new model that lifts orders by 1% while doubling cost may not be a win.
- The decision rule and the sample size: "ship if the primary metric's 95% interval is above 0, and no guardrail is worse than its limit, after 20,000 users per group." The size comes from a power calculation like the one in the A/B testing lesson.
Then don't stop early because the result looks good on day 3: checking every day and stopping at the first significant result inflates false positives the same way running many tests does. Run to the planned size, check the split first, and apply the rule.
Why only one primary metric? Each metric tested at the 5% level has a 5% chance of a significant difference by luck alone. Track ten independent metrics and the chance that at least one shows one is 1 - 0.95 ** 10, about 40%. If any of them could justify a launch, a change that does nothing would often look like a win. Guardrails are different: they can only block a launch, never justify one. Many guardrails do raise the chance of a false block, so give each a limit that matters in practice (10% slower, not merely "significantly" slower).
Two more traps are about time. A novelty effect makes anything new get extra clicks for its first days, and it fades. And weekdays differ from weekends, so run whole weeks: a test that runs Monday to Friday measures weekday users only.
A guardrail stops a launch; a validity check stops you reading the results at all.
Experiment memo: is the new checkout helper a win?
A two-week test put a new assistant on the checkout page for half of all users. The dashboard says orders went up and the team is ready to ship. Your job is the analysis behind the decision: start from the raw logs, run the checks, and write a short memo a busy product lead can act on.
The starter builds two tables: exposures (each time a user saw a variant, with their platform; many users have more than one row) and orders (user and time; some orders come from users who were never in the test, or before they first saw it). Something in this experiment is broken. Treat _simulate as a black box and find the problem from the data, as you would with real logs; reading the generator first defeats the point.
The memo is the deliverable, so write it for someone who won't read your code: what was tested, what you checked, what went wrong and how you know, what the data can and can't tell you, and a decision with the reason.
It must
- Build one row per exposed user from the raw tables: first exposure only, orders counted only after it, users with no orders kept
- Run the sample ratio check (planned 50/50, flag below 0.001) before computing any lift, overall and separately for each platform
- Name the broken segment, describe a mechanism that would produce exactly this pattern, and say which way it pushes the overall lift
- Report the lift and its 95% interval only where the split is healthy, alongside the misleading overall number for comparison
- State clearly what the healthy-segment result can't tell you (who is left out) and why dropping the broken segment from one group only would be wrong
- Write a memo of at most 250 words that ends with a decision (ship, don't ship, or fix and rerun) and what must change before a rerun
Go further
- Estimate how many users per group a rerun needs to detect a 0.5-point lift, using the power calculation from the A/B testing lesson
- Write a daily check that runs the split test per platform and raises an alert, and show it would have fired on day 2
- Add a guardrail you'd want for this feature (such as checkout errors or time to pay) and say what limit you'd set in advance
import numpy as np import pandas as pd def _simulate(seed=7): """Build the experiment's raw logs. Treat this as a black box: find any problems from the data.""" rng = np.random.default_rng(seed) n = 24_000 users = pd.DataFrame({"user": [f"u{i:05d}" for i in range(n)], "platform": rng.choice(["web", "ios", "android"], size=n, p=[0.5, 0.3, 0.2])}) users["variant"] = np.where(rng.random(n) < 0.5, "new", "old") users["seen"] = pd.Timestamp("2025-09-01") + pd.to_timedelta(rng.integers(0, 14 * 24 * 60, n), unit="min") older = (users["platform"] == "android") & (rng.random(n) < 0.5) lost = (users["variant"] == "new") & older & (rng.random(n) < 0.8) logged = users[~lost] again = logged.sample(frac=0.6, random_state=7) exposures = pd.concat([ logged[["user", "variant", "platform", "seen"]].rename(columns={"seen": "time"}), again[["user", "variant", "platform"]].assign(time=again["seen"] + pd.Timedelta(hours=30)), ]).sort_values("time").reset_index(drop=True) rate = users["platform"].map({"web": 0.11, "ios": 0.12, "android": 0.07}) - np.where(older, 0.04, 0.0) rate = rate + np.where(users["variant"] == "new", 0.004, 0.0) buyers = users[rng.random(n) < rate] later = buyers["seen"] + pd.to_timedelta(rng.integers(5, 3 * 24 * 60, len(buyers)), unit="min") early = users.sample(n=900, random_state=11) orders = pd.concat([pd.DataFrame({"user": buyers["user"], "time": later}), pd.DataFrame({"user": early["user"], "time": early["seen"] - pd.Timedelta(days=2)})]) return exposures, orders.sort_values("time").reset_index(drop=True) # The test: "checkout-helper-v2" put a new assistant on the checkout page for half of all users, # planned 50/50 for two weeks. Primary metric: the share of exposed users who order afterwards. exposures, orders = _simulate() print(exposures.head()) print(orders.head()) print(len(exposures), "exposure rows,", exposures["user"].nunique(), "users;", len(orders), "orders") # Your analysis starts here: one row per exposed user, then the checks, then the result.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
An experiment shows the new prompt lifting conversions by 4 points, but the groups have 52,000 and 48,000 users on a 50/50 plan. What should the team do?