Comparing groups: t-tests
Welch's t-test, pooled vs average rates, and a control group for before-and-after changes.
Create a free account Try part of it here
Why it matters
"Did the change work?" is the question behind every A/B test, every model upgrade and every new process. The last lesson compared two conversion rates. Often you're comparing averages instead: response times, scores, monthly rates. And often you can't randomize, so the comparison is before against after. This lesson gives you the t-test for averages, and the design habit that matters even more than the test: finding a fair comparison group.
What you'll be able to do
- Choose between a pooled rate and an average of rates by the question you're answering
- Compare two groups' distributions with box plots and histograms before testing anything
- Run Welch's t-test, and read its p-value and confidence interval for the difference
- Use a control group to separate a change's effect from everything else that changed
What it covers
- The brief
- Two ways to average a rate
- Look before you test
- Welch's t-test
- Before and after isn't enough
- The control warehouse
- Worked example: the effect, with a control
The brief
Customers sometimes return orders that arrived damaged. In October 2024, the north warehouse switched to new recycled-pulp packing inserts. The south warehouse kept the old ones. A year later, the operations team asks: did the new inserts cut damage, and by how much?
import pandas as pd d = pd.read_csv("https://api.gradient.test/v1/datasets/damaged-returns.csv") d["rate"] = d["damaged"] / d["orders"] * 100 d["period"] = (d["month"] >= "2024-10").map({False: "before", True: "after"}) print(d.head(4)) print(d.groupby(["warehouse", "period"])["rate"].agg(["mean", "std", "size"]).round(3))
Each row is one warehouse in one month: orders shipped, orders returned damaged, and whether the new inserts were in use. The damage rate is damaged ÷ orders, shown as a percentage. North's average monthly rate fell from about 3.1% to 2.7%. South's barely moved. Is that drop real, or the kind of wobble you'd see anyway?
Two ways to average a rate
"North's damage rate before the change" has two reasonable meanings:
- The pooled rate: all damaged orders ÷ all orders, over the whole period. Every order counts equally, so it's the rate a customer experienced.
- The mean of the monthly rates: work out each month's rate, then average them. Every month counts equally, however many orders it had.
import pandas as pd d = pd.read_csv("https://api.gradient.test/v1/datasets/damaged-returns.csv") d["rate"] = d["damaged"] / d["orders"] * 100 north_before = d[(d["warehouse"] == "north") & (d["month"] < "2024-10")] pooled = north_before["damaged"].sum() / north_before["orders"].sum() * 100 mean_of_rates = north_before["rate"].mean() print(round(pooled, 3), round(mean_of_rates, 3)) print(north_before[north_before["month"].str.endswith("-12")][["month", "orders", "rate"]].round(2))
They differ only a little here, but the reason matters: December is both the busiest month and the worst for damage, because of rushed packing. The pooled rate gives December's many orders more weight, so it comes out higher. In data where big months and small months behave very differently, the two can be far apart. Neither is wrong; they answer different questions. For "what share of parcels arrived damaged?", pool. To test whether the typical month changed, use the monthly rates. Each month is then one measurement, which is what the t-test below needs.
orders = [100, 900] damaged = [10, 9] pooled = sum(damaged) / sum(orders) mean_of_rates = (10 / 100 + 9 / 900) / 2 print(round(pooled, 3), round(mean_of_rates, 3))
Show the answer
0.019 0.055
Pooled: 19 of 1,000 orders, 1.9%. The month rates are 10% and 1%, averaging 5.5%: the small month counts as much as the big one.
Look before you test
A test squeezes two groups into one number. Look at the groups first, so you know what that number is summarizing:
import matplotlib.pyplot as plt import pandas as pd d = pd.read_csv("https://api.gradient.test/v1/datasets/damaged-returns.csv") d["rate"] = d["damaged"] / d["orders"] * 100 north = d[d["warehouse"] == "north"] before = north[north["month"] < "2024-10"]["rate"] after = north[north["month"] >= "2024-10"]["rate"] fig, (left, right) = plt.subplots(1, 2, figsize=(10, 3.2)) for name, part in d.groupby("warehouse"): left.plot(range(len(part)), part["rate"], marker=".", label=name) left.axvline(21, color="gray", linestyle=":") left.set_title("Monthly damage rate (%)") left.set_xlabel("month number (the dotted line is October 2024)") left.legend() right.boxplot([before, after], tick_labels=["north before", "north after"]) right.set_title("North: distribution of monthly rates")
The line chart shows the drop lining up with October 2024 in the north, and no matching drop in the south. It also shows a December spike every year in both, a pattern to keep in mind. The box plot compares the two groups of months. The boxes overlap a little, but the whole "after" box sits lower.
Welch's t-test
The t-test asks the same question as the last lesson's z-test: if nothing really changed, how surprising is a gap this big? The gap is the difference of the two means, . Its standard error combines each group's spread and size :
$$ t = \frac{\bar{x}_\text{after} - \bar{x}_\text{before}}{\sqrt{s_\text{after}^2 / n_\text{after} + s_\text{before}^2 / n_\text{before}}} $$
In plain words: counts how many standard errors apart the two averages are. Spread-out groups or few values make the standard error big and small. A beyond about ±2 is unusual if there's no real difference.
This version, Welch's t-test, doesn't assume the two groups are equally spread, so it's the safe default. SciPy runs it when you pass equal_var=False:
import pandas as pd from scipy import stats d = pd.read_csv("https://api.gradient.test/v1/datasets/damaged-returns.csv") d["rate"] = d["damaged"] / d["orders"] * 100 north = d[d["warehouse"] == "north"] before = north[north["month"] < "2024-10"]["rate"] after = north[north["month"] >= "2024-10"]["rate"] result = stats.ttest_ind(after, before, equal_var=False) low, high = result.confidence_interval(0.95) print("difference:", round(after.mean() - before.mean(), 3), "percentage points") print("t =", round(result.statistic, 2), " p =", round(result.pvalue, 4)) print("95% interval:", round(low, 3), "to", round(high, 3))
The rate fell about 0.4 percentage points, with well under 0.05. The interval says the true drop is plausibly anywhere from about 0.13 to 0.69 points. That's a wide range. Report the interval, not just "significant", because the business decision depends on how big the effect is.
Double the spread and t halves. Quadruple the months and t doubles. The same gap can be convincing or meaningless depending on how noisy and how plentiful the data is.
Before and after isn't enough
A before/after comparison credits the change with everything that happened at the same time. In January 2025, the shop also switched couriers for both warehouses. Some of north's drop could be the courier, not the inserts. Seasons matter too: the "after" period has a different mix of months.
The fix is a control group: something that went through the same period without the change. South kept the old inserts, so its before/after change shows what happened anyway. Subtract it:
$$ \text{effect} = (\text{north after} - \text{north before}) - (\text{south after} - \text{south before}) $$
This is called a difference-in-differences. It removes anything that hit both warehouses equally, like the courier and the seasons, and leaves what's special to the north. It rests on one assumption: without the new inserts, the two warehouses would have moved in parallel. Check it on the "before" months, when neither had changed. Their lines should run side by side.
A randomized test, sending a random half of parcels with each insert, needs no such assumption. That's why A/B tests are the gold standard. Real operations often can't randomize, though, so a good control group is the next best thing.
The control warehouse
Run Welch's t-test on the south warehouse's monthly rates, after against before. Set south_diff to the difference of means (after − before) rounded to 3, south_p to the p-value rounded to 3, and changed to True if p is below 0.05.
import pandas as pd from scipy import stats d = pd.read_csv("https://api.gradient.test/v1/datasets/damaged-returns.csv") d["rate"] = d["damaged"] / d["orders"] * 100 south_diff = None south_p = None changed = None
Worked example: the effect, with a control
Estimate the inserts' effect as a difference-in-differences, and test it with one value per month: north's rate minus south's.
import pandas as pd from scipy import stats d = pd.read_csv("https://api.gradient.test/v1/datasets/damaged-returns.csv") d["rate"] = d["damaged"] / d["orders"] * 100 wide = d.pivot_table(index="month", columns="warehouse", values="rate") wide["gap"] = wide["north"] - wide["south"] before = wide[wide.index < "2024-10"]["gap"] after = wide[wide.index >= "2024-10"]["gap"] print("gap before:", round(before.mean(), 3), " gap after:", round(after.mean(), 3)) result = stats.ttest_ind(after, before, equal_var=False) low, high = result.confidence_interval(0.95) print("effect:", round(after.mean() - before.mean(), 3), "points, p =", round(result.pvalue, 4)) print("95% interval:", round(low, 3), "to", round(high, 3)) print("before, north vs south:", round(wide.loc[wide.index < "2024-10", "north"].mean(), 3), round(wide.loc[wide.index < "2024-10", "south"].mean(), 3))
Pivoting puts north and south side by side for each month, and the gap column is north minus south. Anything that hit both warehouses in a month, like December's rush or the new courier, cancels out of the gap. Comparing the gap before and after is the difference-in-differences, and the t-test on the monthly gaps tests it. The effect comes out a little smaller than the plain before/after drop, because part of that drop was the courier. The last line checks the parallel assumption: before the change, the two warehouses had similar rates. That's the number to report: a drop of about a third of a percentage point, with its interval.
Practice what you just read
- 5 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
A before/after comparison shows damage fell after new inserts. What does a control warehouse add?