learn-statistics-with-phoebe / Builder session 8 of 10
Learn Statistics with Phoebe · Builder session 8 of 10

Hypothesis testing, in scipy and statsmodels

This is where descriptive statistics turns into a decision. You state a null, compute a test statistic, read a p-value - and you learn to say exactly what that p-value does and does not mean. We run one-sample and two-sample t-tests on Lumen order values, then the two-proportion z-test on Lumen's 3.2% vs 3.6% checkout rate and watch the SAME effect flip from "not significant" to "significant" as we turn up n. That flip is the bridge into the experimentation course.

🟠 Getting real Builders · DA / DE / DS Python · scipy / statsmodels 45 min
0-3 · Setup 3-16 · The logic of a test 16-40 · Build the tests 40-45 · Wrap
Part 0

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.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today The four-line skeleton of every test - null, statistic, p-value, decision at alpha - written in scipy and statsmodels. You will run 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.
Part 1 · the vocabulary of a test

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.

The 2x2 that names your testing risk Fail to reject H0 Reject H0 H0 true H0 false CORRECT no error TYPE I ERROR false positive, rate alpha TYPE II ERROR false negative, rate beta CORRECT true effect found Lowering alpha shrinks Type I error but grows Type II unless you also add more data.
🔍 Click to zoom - alpha and beta name the two ways a test can be wrong
test statistic (if the null were true) 0 observed +t mirror -t shaded area = p-value two-tailed: BOTH tails count - a result this extreme in either direction the null world
🔍 Click to zoom - the p-value is the shaded tail area, not the height of any bar
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".
Say the p-value correctly, every time p is P(data this extreme | H0 true). It is NOT the probability the null is true, NOT the probability your result is a fluke, and NOT the probability the variant is better. It answers one narrow question: if nothing had changed, how often would luck alone hand me a gap this big?
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.
The 2x2 that names your risk Rows = the truth (H0 true / H0 false); columns = your decision (fail to reject / reject). The two off-diagonal cells are the two errors. Lowering alpha shrinks Type I but, holding n fixed, grows Type II - the only way to shrink both is more data.
★ everyone builds · on the Lumen data

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.

one_sample.py
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.

two_sample.py
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.

two_prop.py
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
Real world

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.

Same 3.2%-to-3.6% lift, two different sample sizes n = 5,000/arm alpha=.05 p ~ 0.25, fail n = 32,000/arm alpha=.05 p ~ 0.005, ship Same lift, same effect - only n changed the verdict from fail to ship. Size the test first.
🔍 Click to zoom - same lift, but only the larger sample clears the bar

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?

power.py
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
★ see it move

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.

Before Session 9

This week ◐ 40 min total

Check yourself

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.

Source material

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.

✓Null/alternative, test statistic, p-value, alpha, Type I/II, power, one- vs two-tailedPart 1
✓t-tests, two-proportion z-test, and power/sample size in scipy + statsmodelsdemo
◐OpenIntro Statistics ch. 5-7 - inference for means and proportionstheory spine; free PDF
◐Google "The Power of Statistics" - module 5, hypothesis testingapplied path
◐365 Data Science - hypothesis testing; StatQuest - p-values, clearly explainedintuition builders

Builder Session 8 cheat sheet · pin this

The skeletonState H0, pick a statistic, get the p-value, decide at alpha. Same four steps for every test.
p-valueP(data this extreme | H0 true). NOT P(null true), NOT P(fluke), NOT P(variant wins).
One-sample tstats.ttest_1samp(x, popmean). Two-sample: ttest_ind(a, b, equal_var=False) (Welch).
Two proportionsproportions_ztest(conv, nobs). Lumen 3.2% vs 3.6%: p~0.25 at 5k/arm, p~0.005 at 32k/arm.
PowerNormalIndPower().solve_power(effect_size=h, alpha, power). h from proportion_effectsize.
Significance != importanceAlways report an effect size and a CI beside p. Big n makes trivial gaps "significant".