Index Ai Academy
Phase 11 · Professional practice · Shipping

A/B testing a model change

Hash assignment, per-user results from raw logs, sample ratio checks, guardrails and a decision rule.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Assignment: stable, random-looking, independent
  2. From raw tables to one row per user
  3. Check the split before the result: sample ratio mismatch
  4. Flag a broken split
  5. Decide with a rule written in advance
  6. Experiment memo: is the new checkout helper a win?
Quick check
A new prompt wins on the eval set. How do you find out whether it helps real customers?
  1. Run the same eval set again, a few more times
  2. Ship it to everyone at once and watch the weekly totals
  3. An online A/B test: some users get the new prompt, some the old
  4. Ask the team which of the two prompts they prefer
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)")
It runs right here in your browser, with nothing to install.

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.

Quick check
Two experiments both hash the plain user id, with no experiment name, and both put buckets 0 to 999 in the treatment group. What goes wrong?
  1. The hash gives different answers on each server
  2. Users switch groups each time they visit
  3. Every user ends up in the control group
  4. The same users get both treatments, so the results mix
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.

Quick check
Hours of first exposure are in a dictionary; each order is (user, hour). How many orders count toward the experiment? What does this print?
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]))
  1. 2
  2. 4
  3. 3
  4. 1
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.

Drill

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]))
In the lesson, your code is checked as you write it.

Decide with a rule written in advance

Before the data arrives, write down three things, and the decision becomes arithmetic instead of debate:

  1. The primary metric: the one number the change is meant to move, such as the share of users who order.
  2. 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.
  3. 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.

Try it now

A guardrail stops a launch; a validity check stops you reading the results at all.

Portfolio brief

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.
In the lesson, you build it here and the tutor can review it.

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

Create a free account

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?

  1. Find the cause of the mismatch before trusting the lift
  2. Ship it: a 4-point lift is large
  3. Throw away users until the groups are equal
  4. Report the lift with a wider interval

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson