All 57 lessons
Lesson 45 of 57 · Statistics and Exploratory Analysis
Hypothesis Testing: t-Tests, Chi-Square, ANOVA and A/B Tests
By Skillancy Editorial Team
Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).
"Prime members spend more per order." "Evening deliveries are slower." "The new app banner improved checkout conversion." These are claims about a difference, and the data in front of you always shows some difference, because no two groups are identical. The question a hypothesis test answers is narrower and more useful: could a difference this large plausibly arise by pure chance if there were really no difference?
This lesson walks through the logic and the workflow: state a null hypothesis (no effect), pick a test that fits the data, compute a p-value, and then, just as important, measure the effect size to judge whether the difference matters to the business. We apply the two-sample t-test, its rank-based alternative (Mann-Whitney), the chi-square test for categories, one-way ANOVA for several groups, and a proper A/B test analysis. We end with the multiple-comparisons trap, the reason that testing twenty ideas always "finds" one significant result.
All tests use SciPy (scipy.stats). The calculations are short; the thinking is the real skill.
Worked examples
Example 1: Set up the comparison
Question: Do Prime and non-Prime customers place different-sized orders?
import pandas as pd
import numpy as np
from scipy import stats
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items = pd.read_csv("order_items.csv")
customers = pd.read_csv("customers.csv")
value = (items["quantity"] * items["unit_price"]).groupby(items["order_id"]).sum().rename("value")
d = (orders.merge(value, on="order_id").merge(customers[["customer_id", "is_prime"]], on="customer_id"))
d = d[d["order_status"] == "Delivered"]
prime = d.loc[d["is_prime"], "value"]
other = d.loc[~d["is_prime"], "value"]
print("Prime n, mean :", len(prime), round(prime.mean(), 1))
print("Other n, mean :", len(other), round(other.mean(), 1))
print("difference :", round(prime.mean() - other.mean(), 1))Output
Prime n, mean : 1783 657.0
Other n, mean : 3608 654.8
difference : 2.3How it works: We split delivered orders into two groups and compare their means. The observed difference is just a number; whether it is "real" depends on how variable the data is and how many orders we have. The test formalises that.
Reading the result: Prime orders average ₹657.0 over 1783 orders and others ₹654.8 over 3608: a difference of ₹2.3.
Example 2: The two-sample t-test
Question: Is that difference larger than chance would produce?
res = stats.ttest_ind(prime, other, equal_var=False)
print("t statistic:", round(res.statistic, 3))
print("p-value :", round(res.pvalue, 4))
print("reject H0 at 5%?", res.pvalue < 0.05)Output
t statistic: 0.157
p-value : 0.8755
reject H0 at 5%? FalseHow it works: The null hypothesis (H0) is that the two groups have the same true mean. The t statistic is the difference in means divided by its standard error. The p-value is the probability of seeing a difference at least this large if H0 were true. If p is below the chosen significance level (alpha, usually 0.05) we reject H0. equal_var=False gives Welch's test, which does not assume equal spread and is the safe default.
Reading the result: t = 0.16 and p = 0.875. At the 5% level this is not significant. We cannot claim Prime baskets differ from others; absence of evidence is not evidence of absence.
Example 3: Effect size and the interval
Question: How big is the difference, and how sure are we about its size?
diff = prime.mean() - other.mean()
se = np.sqrt(prime.var(ddof=1) / len(prime) + other.var(ddof=1) / len(other))
ci = (diff - 1.96 * se, diff + 1.96 * se)
pooled_sd = np.sqrt(((len(prime) - 1) * prime.var(ddof=1) + (len(other) - 1) * other.var(ddof=1)) / (len(prime) + len(other) - 2))
cohens_d = diff / pooled_sd
print("difference:", round(diff, 1), " 95% CI:", tuple(round(x, 1) for x in ci))
print("percent difference:", round(diff / other.mean() * 100, 1), "%")
print("Cohen's d:", round(cohens_d, 3))Output
difference: 2.3 95% CI: (np.float64(-26.0), np.float64(30.6))
percent difference: 0.3 %
Cohen's d: 0.005How it works: A p-value says nothing about size. Report an effect size: the raw or percentage difference with a confidence interval, and Cohen's d, the difference in units of standard deviation. A rough guide for d is 0.2 small, 0.5 medium and 0.8 large. An interval that spans zero is consistent with no effect; one that excludes zero agrees with a significant test.
Reading the result: The difference is ₹2.3 (0.3%) with a 95% interval from -26 to 31 rupees (it spans zero), and Cohen's d is 0.00, a negligible effect.
Example 4: Mann-Whitney U for skewed data
Question: Do we reach the same conclusion without assuming a bell curve?
mw = stats.mannwhitneyu(prime, other, alternative="two-sided")
print("U =", mw.statistic, " p =", round(mw.pvalue, 4))
print("medians:", prime.median(), other.median())Output
U = 3207309.5 p = 0.8638
medians: 531.0 531.0How it works: Mann-Whitney U compares the two groups using ranks rather than means, so it does not need normality and is less affected by outliers. It tests whether one group tends to have larger values. For skewed variables like order value it is a good companion to the t-test; when both agree, you can be more confident.
Reading the result: Mann-Whitney gives p = 0.864, agreeing with the t-test (p = 0.875). The medians are ₹531 and ₹531.
Example 5: Always look at the size of the gap
Question: Do Card and UPI orders differ in delivery time, and would anyone notice?
card = orders.loc[(orders["payment_method"] == "Card") & orders["delivery_minutes"].notna(), "delivery_minutes"]
upi = orders.loc[(orders["payment_method"] == "UPI") & orders["delivery_minutes"].notna(), "delivery_minutes"]
t5 = stats.ttest_ind(card, upi, equal_var=False)
print("means:", round(card.mean(), 2), round(upi.mean(), 2))
print("p-value:", round(t5.pvalue, 4))
print("difference in seconds:", round((card.mean() - upi.mean()) * 60, 1))Output
means: 23.7 23.64
p-value: 0.8018
difference in seconds: 3.6How it works: We compare delivery times for Card and UPI orders. Whatever the p-value, look at the difference itself, here converted to seconds. Large samples can make trivially small differences "significant", and small samples can hide large ones, so always read the p-value next to the size of the gap. Significance answers "is there likely any effect?", not "is it big enough to act on?".
Reading the result: Card deliveries average 23.70 minutes and UPI 23.64, a gap of 4 seconds, with p = 0.80. The difference is not statistically significant, and even if it were, no customer would notice a few seconds.
Example 6: Chi-square test for categories
Question: Is the order outcome (delivered, cancelled, returned) related to the payment method?
table = pd.crosstab(orders["payment_method"], orders["order_status"])
print(table)
chi2, p, dof, expected = stats.chi2_contingency(table)
print("chi2 =", round(chi2, 2), " dof =", dof, " p =", round(p, 4))
print("min expected count:", round(expected.min(), 1))Output
order_status Cancelled Delivered Returned
payment_method
COD 65 871 40
Card 74 1106 45
UPI 202 2990 138
Wallet 29 424 16
chi2 = 1.48 dof = 6 p = 0.9606
min expected count: 18.7How it works: For two categorical variables, a chi-square test of independence compares the observed counts with the counts expected if the variables were unrelated. A large chi-square (small p) means the pattern differs from independence. The rule of thumb is that all expected counts should be at least 5, which we verify with expected.min().
Reading the result: chi-square = 1.48 with 6 degrees of freedom and p = 0.961. There is no evidence that the order outcome depends on the payment method. The smallest expected count is 18.7, so the test is valid.
Example 7: One-way ANOVA across several groups
Question: Does delivery time differ between delivery slots?
groups = [g["delivery_minutes"].dropna().values for _, g in orders.groupby("delivery_slot")]
names = sorted(orders["delivery_slot"].unique())
f = stats.f_oneway(*groups)
print(names)
print([round(g.mean(), 2) for g in groups])
print("F =", round(f.statistic, 3), " p =", round(f.pvalue, 4))
print("Kruskal-Wallis p =", round(stats.kruskal(*groups).pvalue, 4))Output
['Evening (4-8)', 'Midday (10-2)', 'Morning (6-10)', 'Night (8-11)']
[np.float64(23.73), np.float64(23.8), np.float64(23.67), np.float64(23.47)]
F = 0.521 p = 0.6676
Kruskal-Wallis p = 0.6524How it works: With three or more groups, running many pairwise t-tests inflates the false-positive rate. ANOVA tests the single hypothesis "all group means are equal". A significant result says some group differs but not which one; follow up with pairwise comparisons if needed. Kruskal-Wallis is the rank-based equivalent when normality is doubtful.
Reading the result: The slot means range from 23.5 to 23.8 minutes. ANOVA gives p = 0.67, so the slots do not differ detectably.
Example 8: Analysing an A/B test
Question: A new checkout button was shown to half of 4,000 visitors. Did it raise conversion?
# simulated experiment: control converts at 8.0%, variant at 9.5% (invented numbers)
rng = np.random.default_rng(2026)
n_a = n_b = 2000
conv_a = rng.binomial(n_a, 0.080)
conv_b = rng.binomial(n_b, 0.095)
rate_a, rate_b = conv_a / n_a, conv_b / n_b
pooled = (conv_a + conv_b) / (n_a + n_b)
se_diff = np.sqrt(pooled * (1 - pooled) * (1 / n_a + 1 / n_b))
z = (rate_b - rate_a) / se_diff
p_two = 2 * (1 - stats.norm.cdf(abs(z)))
print("control", conv_a, "/", n_a, "=", round(rate_a, 4))
print("variant", conv_b, "/", n_b, "=", round(rate_b, 4))
print("lift:", round((rate_b - rate_a) / rate_a * 100, 1), "%")
print("z =", round(z, 2), " p =", round(p_two, 4))Output
control 152 / 2000 = 0.076
variant 199 / 2000 = 0.0995
lift: 30.9 %
z = 2.63 p = 0.0086How it works: Conversion is a yes/no outcome per visitor, so we compare two proportions with a two-proportion z-test, using the pooled rate for the standard error under the null hypothesis. The data here is simulated with known true rates so that you can see the machinery; in a real test you would have the counts from your experiment tool. Randomly assigning visitors to the two versions is what lets us attribute a difference to the change itself rather than to who happened to see it.
Reading the result: Control converted 7.60% and the variant 9.95%, a relative lift of 30.9% (the true simulated lift was 18.8%, so chance has exaggerated it here). With z = 2.63 and p = 0.009 the result is significant at 5%.
Example 9: Statistical power
Question: How many visitors would we need to detect that lift reliably?
def power_two_props(p1, p2, n, alpha=0.05):
se = np.sqrt(p1 * (1 - p1) / n + p2 * (1 - p2) / n)
z_crit = stats.norm.ppf(1 - alpha / 2)
return 1 - stats.norm.cdf(z_crit - abs(p2 - p1) / se)
for n in (500, 1000, 2000, 4000, 8000):
print(f"n per group = {n:>5}: power = {power_two_props(0.08, 0.095, n):.2f}")
n_80 = next(n for n in range(100, 50000, 50) if power_two_props(0.08, 0.095, n) >= 0.8)
print("n per group for 80% power:", n_80)Output
n per group = 500: power = 0.13
n per group = 1000: power = 0.22
n per group = 2000: power = 0.39
n per group = 4000: power = 0.66
n per group = 8000: power = 0.92
n per group for 80% power: 5600How it works: Power is the probability that a test detects a real effect of a given size. A Type II error (missing a real effect) happens with probability 1 minus power. Underpowered tests are common: with too few visitors you will often see "no significant difference" even when the change really works. The usual target is 80% power. The function here uses the normal approximation to calculate power for a given n, then we search for the n that gives 80%.
Reading the result: Detecting a lift from 8.0% to 9.5% needs about 5600 visitors per group for 80% power. With 2,000 per group the power is only 39%, so a real lift of that size would be missed about 61% of the time.
Example 10: Multiple comparisons
Question: What if we test twenty unrelated ideas?
rng = np.random.default_rng(4)
pvals = []
for _ in range(20):
a = rng.normal(100, 15, 200)
b = rng.normal(100, 15, 200) # no true difference at all
pvals.append(stats.ttest_ind(a, b).pvalue)
pvals = np.array(pvals)
print("tests with p < 0.05:", int((pvals < 0.05).sum()))
print("smallest p:", round(pvals.min(), 4))
print("chance of at least one false positive in 20 tests:", round(1 - 0.95 ** 20, 3))
print("Bonferroni threshold:", 0.05 / 20, " survivors:", int((pvals < 0.05 / 20).sum()))Output
tests with p < 0.05: 1
smallest p: 0.0296
chance of at least one false positive in 20 tests: 0.642
Bonferroni threshold: 0.0025 survivors: 0How it works: Each test has a 5% chance of a false positive. Run 20 and the chance that at least one "works" is about 64%. All twenty comparisons here use data drawn with no real difference, yet some usually come out significant. The simplest guard is the Bonferroni correction: divide alpha by the number of tests. It is conservative but easy; the lesson is to count how many things you tried and to treat a lone surprising result with suspicion.
Reading the result: 1 of 20 null comparisons reached p < 0.05 (smallest p = 0.030); a different seed would give a different count, with about one expected. The chance of at least one false positive in 20 tests is 64%. After Bonferroni correction, 0 survive.
Common mistakes
1. Reading the p-value as "the probability the null is true". It is the probability of data this extreme if the null were true.
2. Treating "not significant" as "no effect". It may just mean too little data (low power).
3. Ignoring effect size, and acting on tiny but significant differences.
4. Testing many things and reporting only the winner (p-hacking). Decide the question, the metric and the sample size before looking.
5. Peeking at an A/B test every day and stopping when p < 0.05. That inflates false positives. Fix the sample size in advance.
stats.ttest_ind(prime, "other")Output
Traceback (most recent call last):
File "example.py", line 1, in <module>
stats.ttest_ind(prime, "other")
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
File "example.py", line 601, in axis_nan_policy_wrapper
res = hypotest_fun_out(*samples, **kwds)
File "example.py", line 6791, in ttest_ind
a, b = xp_promote(a, b, force_floating=True, xp=xp)
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "example.py", line 577, in xp_promote
args = [(_asarray(arg, dtype=dtype, subok=True, xp=xp) if arg is not None else arg)
~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "example.py", line 107, in _asarray
array = np.asanyarray(array, order=order, dtype=dtype)
ValueError: could not convert string to float: np.str_('other')Practice questions
Practice 1 (Easy): Test discount versus order value
Split delivered orders into those with a discount (discount_pct above 0) and those without, then run a Welch t-test on value.
Show solution
disc = d.loc[d["discount_pct"] > 0, "value"]
nodisc = d.loc[d["discount_pct"] == 0, "value"]
r1 = stats.ttest_ind(disc, nodisc, equal_var=False)
print(len(disc), len(nodisc), round(disc.mean(), 1), round(nodisc.mean(), 1), round(r1.pvalue, 4))Output
2687 2704 656.3 654.7 0.9098Discounted orders average ₹656.3 (2687 orders) against ₹654.7 (2704), p = 0.910. Remember that value is the pre-discount basket value here, so we are testing whether discounts go to bigger or smaller baskets.
Practice 2 (Medium): Is cancellation rate different on weekends?
Create a weekend flag from order_date and test the cancellation rate with a chi-square test.
Show solution
orders["weekend"] = orders["order_date"].dt.dayofweek >= 5
orders["cancelled"] = orders["order_status"] == "Cancelled"
t = pd.crosstab(orders["weekend"], orders["cancelled"])
chi2b, pb, _, _ = stats.chi2_contingency(t)
print(t)
print(orders.groupby("weekend")["cancelled"].mean().round(4).to_dict(), round(pb, 4))Output
cancelled False True
weekend
False 3641 238
True 1989 132
{False: 0.0614, True: 0.0622} 0.9369Cancellation rates are 6.14% on weekdays and 6.22% on weekends (p = 0.937), so the data shows no detectable weekend effect.
Practice 3 (Hard): A permutation test on a custom statistic
Use a permutation test: shuffle the Prime label 5,000 times and see how often the shuffled difference in the 75th percentile of order value is as large as the observed one.
Show solution
rng = np.random.default_rng(10)
vals = d["value"].values
labels = d["is_prime"].values
obs = np.percentile(vals[labels], 75) - np.percentile(vals[~labels], 75)
perm = np.empty(5000)
for i in range(5000):
shuffled = rng.permutation(labels)
perm[i] = np.percentile(vals[shuffled], 75) - np.percentile(vals[~shuffled], 75)
p_perm = (np.abs(perm) >= abs(obs)).mean()
print(round(obs, 1), round(p_perm, 4))Output
18.0 0.5172The observed difference in the 75th percentile is ₹18.0; shuffled labels produced a difference that large or larger 51.7% of the time, so the permutation p-value is 0.517. A permutation test needs no distributional assumptions and works for any statistic.
Key takeaways
- State H0, choose the test, compute p, then report an effect size and interval.
- A p-value measures surprise under the null; it is not the probability the null is true.
- Use Welch t-test for means, Mann-Whitney for skewed data, chi-square for categories, ANOVA for several groups and a two-proportion test for conversion.
- Not significant does not mean no effect; check power and sample size.
- Count your comparisons; apply Bonferroni or pre-register one primary metric.
- For experiments, randomise, fix the sample size in advance and do not peek.
Frequently asked questions
What does a p-value of 0.03 mean?
If there were truly no difference, you would see a result at least this extreme about 3% of the time. It does not mean there is a 97% chance the effect is real.
Why is 0.05 the cut-off?
It is a historical convention, not a law of nature. Choose alpha according to the cost of a false alarm: stricter for expensive decisions, looser for cheap experiments.
One-sided or two-sided tests?
Use two-sided unless you decided in advance, for a good reason, that only one direction matters. Switching to one-sided after seeing the data is cheating.
t-test or Mann-Whitney?
With large samples the t-test is robust. For small, skewed samples or ordinal data (ratings), use Mann-Whitney. Reporting both is a good habit.
What is the difference between statistical and practical significance?
Statistical significance says an effect is probably not zero; practical significance says it is big enough to matter for the business, the cost of acting and the customers.
How long should I run an A/B test?
Calculate the required sample size from the baseline rate, the smallest lift you care about and the power you want, then run until you reach it, covering whole weeks to include weekday and weekend behaviour.
Keep learning Python
One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.
Ready to practise? Try the Python interview questions