A test is a rule for acting under uncertainty
Lumen's growth team ran a new checkout button and saw 3.6% convert versus the old 3.2%. Is that a real lift, or the kind of wobble you get from luck alone? A hypothesis test is the disciplined answer: assume nothing changed, ask how surprising the data would be under that assumption, and decide. The trap is reading the output wrong - so today we get the p-value definition exactly right and pair every verdict with an effect size and a confidence interval.
ttest_1samp, ttest_ind, proportions_ztest, and a power calculation, and you will hold two ideas at once: statistical significance is not practical importance, and the same true effect can be "significant" or not depending only on n.
Null, alternative, statistic, p-value, alpha, and the two errors 13 min live
Every test is the same six moving parts. Learn them once here and the code in Part 2 is just plumbing.
LiveThe six parts, in plain words5 min▶
- Null hypothesis (H0): the boring default - "nothing changed", the two rates are equal, the mean is 70. We assume it is true and see how the data looks.
- Alternative (H1): what you would act on - "the rates differ", "the mean is not 70". Two-sided by default; one-sided only when a change in one direction is the only one you care about (and you commit before seeing data).
- Test statistic: one number that measures how far the data sits from H0 in standard-error units - a t, a z, an F. Bigger magnitude = further from the null world.
- p-value: P(a test statistic at least this extreme given the null is true). It lives in the tail area of the picture above.
- alpha: the surprise threshold you fix in advance, usually 0.05. If p < alpha you reject H0.
- Power: the probability you correctly reject a false null - 1 minus the Type II rate. Low power means real effects slip through as "not significant".
LiveType I vs Type II, and one-tailed vs two-tailed4 min▶
- Type I error (false positive): reject a true null - you "found" an effect that is not there. Its rate is alpha. Shipping a button that does nothing.
- Type II error (false negative): fail to reject a false null - a real effect went undetected. Its rate is beta; power = 1 - beta. Killing a button that actually worked.
- Two-tailed: H1 is "different" - extreme in either direction counts, alpha splits across both tails. This is the honest default.
- One-tailed: H1 is "greater" (or "less") - all of alpha in one tail, more power for that direction, zero credit for the other. Only valid if you decided the direction before looking.
Four tests, from a mean to a proportion to power ★ 24 min · everyone builds
Reload the Lumen dataset you built in b1 (add the converted flag from the b1 homework if you have not). Each test is a few lines - the skill is stating the null out loud before you read the number, and reading the number honestly after.
One-sample t-test. H0: mean order value is $70. H1: it is not. With n this large even a $4 gap is far from the null.
import numpy as np, pandas as pd
from scipy import stats
df = pd.read_parquet("lumen_orders.parquet") # from b1
ov = df["order_value"]
t, p = stats.ttest_1samp(ov, popmean=70)
print(f"mean = {ov.mean():.2f}") # ~ 74
print(f"t = {t:.2f}, p = {p:.2e}") # large t, tiny p -> reject H0
# effect size (Cohen's d) - is the gap PRACTICALLY meaningful?
d = (ov.mean() - 70) / ov.std(ddof=1)
print(f"Cohen's d = {d:.3f}") # small-ish: significant != big
Two-sample t-test. H0: cleanser and serum have the same mean order value. In our b1 sim, order value does not depend on category - so expect a large p, a correct negative result.
cleanser = df.loc[df.product_category == "cleanser", "order_value"]
serum = df.loc[df.product_category == "serum", "order_value"]
# Welch's t-test does NOT assume equal variances - the safer default
t, p = stats.ttest_ind(cleanser, serum, equal_var=False)
print(f"cleanser mean {cleanser.mean():.2f} vs serum {serum.mean():.2f}")
print(f"t = {t:.2f}, p = {p:.3f}") # p large -> fail to reject
# failing to reject is NOT proof they are equal - it is "no evidence of a gap"
Two-proportion z-test - the experimentation bridge. H0: control 3.2% and variant 3.6% convert at the same rate. Same effect, two sample sizes, two verdicts.
from statsmodels.stats.proportion import proportions_ztest
def ab(n_per_arm, p_ctrl=0.032, p_var=0.036):
conv = np.array([round(p_var * n_per_arm), round(p_ctrl * n_per_arm)])
nobs = np.array([n_per_arm, n_per_arm])
z, p = proportions_ztest(conv, nobs) # variant first
return z, p
z1, p1 = ab(5_000)
z2, p2 = ab(32_000)
print(f"n=5,000/arm : z = {z1:.2f}, p = {p1:.3f}") # z~1.16, p~0.25 NOT significant
print(f"n=32,000/arm: z = {z2:.2f}, p = {p2:.3f}") # z~2.8, p~0.005 significant
Read those two lines slowly. The lift is identical - 3.2% to 3.6%, a +0.4pp, +12.5% relative bump - in both. At 5,000 per arm the p-value is ~0.25 and Lumen would (correctly) refuse to ship. At 32,000 per arm the same lift lands at p~0.005 and now it is "significant". Nothing about the button changed; only the sample size did. This is why the experimentation course sizes the test before launch instead of peeking at whatever n happens to arrive.
Power and sample size. Turn the last point into a plan: how many users per arm to detect the 3.2% to 3.6% lift with 80% power at alpha 0.05?
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
h = proportion_effectsize(0.036, 0.032) # Cohen's h ~ 0.022 (tiny effect)
n = NormalIndPower().solve_power(
effect_size=h, alpha=0.05, power=0.80, alternative="two-sided")
print(f"Cohen's h = {h:.4f}")
print(f"n per arm = {n:,.0f}") # ~ 32,000 - matches the z~2.8 crossover above
Watch the verdict flip as n grows 3 min · everyone builds
Here is Lumen's A/B result as a live tool. Drag users per arm and watch the 3.2%-vs-3.6% gap cross from "not significant" to "significant" - the CI on the lift closing in on zero and then finally excluding it. Same effect the whole time; only your certainty changes.
This week ◐ 40 min total
- Find the crossover yourself. Loop
ab(n)over n from 5,000 to 40,000 in steps of 2,500 and print the first n where p drops below 0.05. Compare it to the power calculation's answer. - One-tailed vs two-tailed. Re-run the one-sample t-test with
alternative="greater". How does p change, and why is committing to a direction beforehand the only thing that makes it honest? - Effect size discipline. Invent a scenario where a test is significant (p < 0.05) but the effect is too small to act on. Write one sentence a stakeholder would understand.
- Optional: simulate the Type I rate - run
ttest_indon two samples drawn from the SAME distribution 1,000 times and confirm about 5% of them come back p < 0.05 anyway.
Three questions before you go 🎯 ◐ 90 seconds
1 · A test returns p = 0.03. Which statement is correct?
A p-value is P(data this extreme | H0 true). It says nothing directly about the probability the null is true or that the variant wins - those require a prior, not a p-value.
2 · Lumen's 3.2%-to-3.6% lift is "not significant" at 5,000/arm but "significant" at 32,000/arm. What changed?
The effect is identical in both. Larger n means a smaller standard error, a larger z, and a smaller p. Significance is a statement about evidence at a given n, not about the size of the effect.
3 · You lower alpha from 0.05 to 0.01 and keep n fixed. What happens to the two error rates?
Alpha is the Type I rate, so lowering it cuts false positives. But with n fixed, making the bar stricter means more real effects miss it - Type II rises and power falls. Only more data shrinks both at once.
What this session covers
This builder session covers ~80% of the introductory hypothesis-testing content in the sources below, on the Lumen dataset. Chi-square and ANOVA get their own session (b9); correlation and regression close the track in b10.
Builder Session 8 cheat sheet · pin this
stats.ttest_1samp(x, popmean). Two-sample: ttest_ind(a, b, equal_var=False) (Welch).proportions_ztest(conv, nobs). Lumen 3.2% vs 3.6%: p~0.25 at 5k/arm, p~0.005 at 32k/arm.NormalIndPower().solve_power(effect_size=h, alpha, power). h from proportion_effectsize.