Week 5 — “Is This Real, or Just Noise?”¶
Course: Applied ML Foundations for SaaS Analytics
Who this is for: Engineers who ship A/B tests and get asked “but is it significant?” You do not need a stats degree.
We will not memorize a zoo of tests. We will make one decision carefully, then keep a flowchart for later.
🎯 What you will be able to do¶
- Translate a p-value into a sentence a PM cannot misuse
- Run the actual “8 / 50 vs 12 / 60” launch question — and see it fail to reject
- Draw a confidence interval as “a range of plausible true rates”
- Know which test matches your column types
- Refuse to ship on p < 0.05 alone
Think of it like… a code review, or a courtroom.
The null hypothesis is the boring default: “these two plans churn the same; the difference is luck.” You do not prove the new plan works. You ask: if they were the same, how often would luck produce a gap this big? That frequency is the p-value. Innocent until proven guilty. High bar to convict.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
from lib.course_data import find_data_dir
DATA = find_data_dir()
If you already write software¶
A p-value is a flaky-test statistic, not a trophy.
You already know this feeling: a test failed once on CI. Is the build broken, or did the suite sneeze? You do not ship on one red run. You ask: if the code were fine, how often would this fail anyway?
That frequency is the p-value.
Null hypothesis “these two plans churn the same; the gap is luck”
p-value how often a no-difference world produces a gap this big
0.05 threshold a house style, not a law of nature
significant “weird enough that luck is an awkward explanation”
NOT significant “we do not know yet” ← not “they are equal”
What a p-value is not¶
- Not “the probability we are wrong”
- Not “the probability Premium is worse”
- Not “how big the effect is” (that is the effect size / the interval)
- Not permission to ship
8/50 vs 12/60 looks like Premium wins. With that few customers, coin-flips produce a 4-point gap all the time. The PM sees 16% vs 20%. You see a sample size.
Picture the courtroom¶
Innocent until proven guilty. The null is the defendant. You need a high bar to convict. Failing to convict is not the same as proving innocence — it means “go get more data, or pick a bigger effect to care about.”
🏢 Scenario — should we roll out Premium?¶
Early data:
| Plan | Churned | Customers | Rate |
|---|---|---|---|
| Premium | 8 | 50 | 16% |
| Standard | 12 | 60 | 20% |
A PM sees “Premium is better.” An engineer asks: with this few customers, how often would a 4-point gap appear by coin-flip?
Engineer mental model
A p-value is not “the probability we are wrong.” It is not “the probability Premium is worse.” It is: how often a world with no real difference produces a result this spicy. Same idea as “how often would this flaky test fail on a green build?”
Visual: luck can look like a win¶
We will fake 10,000 worlds where both plans truly churn at 18%. In each world, draw 50 + 60 customers. Plot the Premium − Standard gap. Then mark the gap we actually saw (−4 points).
rng = np.random.default_rng(42)
true_rate = 0.18
n_prem, n_std = 50, 60
observed_gap = 8 / 50 - 12 / 60 # -0.04
sim_gaps = rng.binomial(n_prem, true_rate, 10_000) / n_prem - rng.binomial(n_std, true_rate, 10_000) / n_std
fig, ax = plt.subplots(figsize=(9, 3.8))
ax.hist(sim_gaps, bins=40, color="#93c5fd", edgecolor="white")
ax.axvline(observed_gap, color="#b91c1c", lw=2, label=f"observed gap {observed_gap:.0%}")
ax.axvline(0, color="#334155", ls="--", label="no difference")
ax.set_title("If both plans were 18% churn, 4-point gaps happen all the time")
ax.set_xlabel("Premium rate − Standard rate")
ax.legend()
plt.tight_layout()
plt.show()
p_two_sided = (np.abs(sim_gaps) >= abs(observed_gap)).mean()
print(f"Share of fake worlds with a gap at least this big: {p_two_sided:.2f}")
print("That is a p-value, built with a for-loop in your head instead of a formula.")
The same answer, with a library test¶
Chi-squared (or Fisher’s exact, for tiny counts) is the grown-up version of the histogram above.
Math, translated
A p-value of 0.03 would mean: in a no-difference world, about 3 in 100 reruns look this extreme. It does not mean “there is a 3% chance Premium is a bad idea.” The 8/50 vs 12/60 launch story is not that world — its p is large, so luck still explains a 4-point gap at this sample size.
table = np.array([[8, 42], # premium: churned, retained
[12, 48]]) # standard
chi2, p, dof, expected = stats.chi2_contingency(table)
print("Chi-squared p-value on the 8/50 vs 12/60 story:", round(p, 3))
print("Expected counts if plans were equal:\n", expected.round(1))
print("\nVerdict: p is large. We do NOT have enough evidence to declare Premium better.")
print("Ship decision: keep collecting data. Do not rewrite billing based on 110 customers.")
Now the full CloudWave table¶
Same question, real subscriptions.csv. More customers → the same 4-point gap would be a much bigger deal.
Visual cue — which test?
Category vs category (plan × churned) → chi-squared.
Number vs 2 groups (MRR for churned vs not) → t-test (or Mann-Whitney if the histogram is a whale-tail).
Number vs 3+ groups (usage by region) → ANOVA, then look at the picture before you trust the p.
subs = pd.read_csv(DATA / "subscriptions.csv")
ct = pd.crosstab(subs["plan_type"], subs["is_churned"])
print("Counts:\n", ct)
chi2, p, dof, expected = stats.chi2_contingency(ct)
print(f"\nChi-squared p-value across all plans: {p:.2e}")
rates = subs.groupby("plan_type")["is_churned"].agg(["mean", "count"])
# Exact binomial (Clopper–Pearson-style) interval — not a Wilson interval
cis = []
for plan, row in rates.iterrows():
lo, hi = stats.binom.interval(0.95, int(row["count"]), row["mean"])
cis.append((plan, row["mean"], lo / row["count"], hi / row["count"], row["count"]))
ci_df = pd.DataFrame(cis, columns=["plan", "rate", "lo", "hi", "n"]).set_index("plan")
print("\n95% range of plausible churn rates:")
print(ci_df.round(3))
fig, ax = plt.subplots(figsize=(8, 3.6))
y = np.arange(len(ci_df))
ax.errorbar(ci_df["rate"], y,
xerr=[ci_df["rate"] - ci_df["lo"], ci_df["hi"] - ci_df["rate"]],
fmt="o", color="#1d4ed8", capsize=4)
ax.set_yticks(y, ci_df.index)
ax.set_xlabel("churn rate")
ax.set_title("Confidence interval = plausible range for the true rate, not a vote of confidence")
plt.tight_layout()
plt.show()
A number vs two groups — do churners pay less?¶
T-test asks: “is the difference in average MRR bigger than the usual jitter in averages?”
churned = subs.loc[subs["is_churned"] == 1, "mrr"]
kept = subs.loc[subs["is_churned"] == 0, "mrr"]
t, p = stats.ttest_ind(churned, kept, equal_var=False)
print(f"Mean MRR churned={churned.mean():.1f} kept={kept.mean():.1f}")
print(f"Welch t-test p={p:.3g}")
fig, ax = plt.subplots(figsize=(8, 3.4))
ax.hist(kept.clip(upper=200), bins=40, alpha=0.6, label="kept", color="#22c55e")
ax.hist(churned.clip(upper=200), bins=40, alpha=0.7, label="churned", color="#ef4444")
ax.set_title("MRR distributions (clipped at $200) — look before you t-test")
ax.set_xlabel("MRR")
ax.legend()
plt.tight_layout()
plt.show()
print("Free users have MRR = 0 and churn more. The t-test may just be rediscovering the free plan.")
Watch out
-
p-hacking: 20 slices of the data will produce one “p < 0.05” by accident. Pre-register the question, or treat extra slices as exploration.
-
Significance ≠ importance: with ~49k rows, a 0.2% churn gap can be “significant” and still not worth an engineering quarter.
-
CI overlap is a sloppy shortcut. Look at the interval on the difference, or just look at dollars.
Ship / don’t ship
Ship when (1) the interval on the lift is mostly above your business threshold, (2) you have looked at the chart, (3) a second slice (another month, another region) rhymes. p < 0.05 is a filter, not a launch button.
A ranker is not a lever
Later weeks will rank who looks like they will churn. That is prediction. “If we increase usage, they will stay” is causation. Plan × churn in this file is observational — people chose their plan. A chi-squared p-value does not make it an experiment. Causation still needs a randomized experiment, not a feature-importance plot (Week 11).
✍️ Exercise¶
When you can explain the week out loud, do the exercises. Starter: python exercises/ml/week-05/starter.py from the repo root.
🤔 Reflection¶
- Explain a p-value to a PM in one sentence without the word “significant.”
- Why did 8/50 vs 12/60 fail, while the full table’s plan comparison did not?
- You ran 12 ad-hoc tests on one Friday. How many “wins” do you expect by luck at α = 0.05?
Before you leave¶
Try one self-check (Predict / Diagnose / Choose / Defend). Write the answer before you open the block.
🔗 Next week¶
Feature engineering — turning Customer 360 columns into the API contract of a model, without leaking the future into the past.