⚙️ Setup¶
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy import stats
BLUE="#2563eb"; DEEP="#1d4ed8"; LIGHT="#60a5fa"; INK="#1a2138"; GRID="#e6e9f2"; GREEN="#059669"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
"axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
"axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
rng = np.random.default_rng(72)
phat, n = 0.52, 1000; z = stats.norm.ppf(0.975)
se = np.sqrt(phat*(1-phat)/n)
moe = z*se
print(f"estimate p-hat = {phat:.0%}, SE = {se:.4f}")
print(f"margin of error (95%) = z*SE = {moe:.4f} = +/- {moe*100:.1f} percentage points")
print(f"the interval is [{(phat-moe)*100:.1f}%, {(phat+moe)*100:.1f}%], reported as {phat:.0%} +/- {moe*100:.0f}%")
estimate p-hat = 52%, SE = 0.0158 margin of error (95%) = z*SE = 0.0310 = +/- 3.1 percentage points the interval is [48.9%, 55.1%], reported as 52% +/- 3%
The margin of error is just the half-width of the interval, z·SE. A poll that says "52% ± 3%" is reporting an estimate of 52% with a margin of error of 3 percentage points at (usually) 95% confidence.
n = 1000
print("Confidence level (variability fixed at p=0.5, n=1000):")
for conf in [0.90, 0.95, 0.99]:
zz = stats.norm.ppf(0.5+conf/2); print(f" {conf:.0%}: z={zz:.3f} -> MoE = +/- {zz*np.sqrt(0.25/n)*100:.2f} pts")
print("\nVariability (95%, n=1000):")
for p in [0.1, 0.3, 0.5]:
print(f" p={p}: MoE = +/- {z*np.sqrt(p*(1-p)/n)*100:.2f} pts (largest at p=0.5)")
Confidence level (variability fixed at p=0.5, n=1000): 90%: z=1.645 -> MoE = +/- 2.60 pts 95%: z=1.960 -> MoE = +/- 3.10 pts 99%: z=2.576 -> MoE = +/- 4.07 pts Variability (95%, n=1000): p=0.1: MoE = +/- 1.86 pts (largest at p=0.5) p=0.3: MoE = +/- 2.84 pts (largest at p=0.5) p=0.5: MoE = +/- 3.10 pts (largest at p=0.5)
Three knobs. Higher confidence widens the margin (99% needs more room than 90%). More variability widens it, and p(1−p) is largest at p = 0.5, the conservative worst case pollsters assume. More data shrinks it, the only knob that improves precision without costing confidence.
ns = np.array([100,250,500,1000,2000,4000,8000])
moes = z*np.sqrt(0.25/ns)
for nn,m in zip(ns,moes): print(f"n={nn:>5}: MoE = +/- {m*100:.2f} pts")
print(f"\nn 1000 -> 4000 (4x) changes MoE from {z*np.sqrt(0.25/1000)*100:.2f} to {z*np.sqrt(0.25/4000)*100:.2f} pts (halved)")
fig,ax=plt.subplots(figsize=(7,3.2))
grid=np.arange(100,8001,50)
ax.plot(grid, z*np.sqrt(0.25/grid)*100, color=BLUE, lw=2.5)
ax.fill_between(grid, z*np.sqrt(0.25/grid)*100, color=BLUE, alpha=0.12)
ax.axhline(3, color=DEEP, ls=":", lw=1); ax.set_xlabel("sample size n"); ax.set_ylabel("margin of error (pts, 95%)")
ax.set_title("Margin of error falls like 1/sqrt(n): diminishing returns"); plt.tight_layout(); plt.show()
n= 100: MoE = +/- 9.80 pts n= 250: MoE = +/- 6.20 pts n= 500: MoE = +/- 4.38 pts n= 1000: MoE = +/- 3.10 pts n= 2000: MoE = +/- 2.19 pts n= 4000: MoE = +/- 1.55 pts n= 8000: MoE = +/- 1.10 pts n 1000 -> 4000 (4x) changes MoE from 3.10 to 1.55 pts (halved)
The curve falls steeply then flattens. Getting from ±6 to ±3 points is cheap; getting from ±3 to ±1.5 costs four times as much again. This is why the classic national poll lands near n = 1,000 (about ±3%): it is the sweet spot where extra precision stops being worth the cost.
A company surveyed 900 customers (margin-of-error--customer_survey.xlsx) and wants to report the share who would recommend it, with a proper margin of error, the way a press release or board deck should.
try: survey = pd.read_excel("../../data/margin-of-error--customer_survey.xlsx", sheet_name="Responses")
except FileNotFoundError: survey = pd.read_excel(BASE+"margin-of-error--customer_survey.xlsx", sheet_name="Responses")
print("loaded:", survey.shape)
rec = survey["would_recommend"]
n = len(rec); phat = rec.mean(); se = np.sqrt(phat*(1-phat)/n); z = stats.norm.ppf(0.975)
moe = z*se
print(f"n = {n} respondents")
print(f"would recommend: p-hat = {phat:.1%}")
print(f"margin of error (95%) = +/- {moe*100:.2f} percentage points")
print(f"\nHEADLINE: {phat*100:.0f}% would recommend, +/- {moe*100:.1f} points (95% confidence)")
print(f"i.e. the true rate is very likely between {(phat-moe)*100:.1f}% and {(phat+moe)*100:.1f}%")
loaded: (900, 5) n = 900 respondents would recommend: p-hat = 64.3% margin of error (95%) = +/- 3.13 percentage points HEADLINE: 64% would recommend, +/- 3.1 points (95% confidence) i.e. the true rate is very likely between 61.2% and 67.5%
print("How the margin would change:\n")
print("By confidence level (this n):")
for conf in [0.90,0.95,0.99]:
zz=stats.norm.ppf(0.5+conf/2); print(f" {conf:.0%}: +/- {zz*se*100:.2f} pts")
print("\nBy sample size (95%):")
for nn in [300, 900, 2500]:
print(f" n={nn:>5}: +/- {z*np.sqrt(phat*(1-phat)/nn)*100:.2f} pts")
How the margin would change: By confidence level (this n): 90%: +/- 2.63 pts 95%: +/- 3.13 pts 99%: +/- 4.11 pts By sample size (95%): n= 300: +/- 5.42 pts n= 900: +/- 3.13 pts n= 2500: +/- 1.88 pts
The honest headline is "64% would recommend, ±3.1 points at 95% confidence." Reporting just "64%" hides the uncertainty; reporting the margin makes the claim defensible. The follow-up table shows the levers: a 99% guarantee widens the margin to about ±4 points, and to halve the margin the company would need roughly four times the respondents.