⚙️ 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(69)
POP = rng.normal(100, 18, 500_000) # unknown-in-practice population, true mean = 100
MU = POP.mean()
sample = rng.choice(POP, 40, replace=False)
point = sample.mean()
print(f"true population mean (mu) = {MU:.2f} (unknown in real life)")
print(f"point estimate from one sample of 40: x-bar = {point:.2f}")
print(f"off by {point-MU:+.2f}")
true population mean (mu) = 99.97 (unknown in real life) point estimate from one sample of 40: x-bar = 101.64 off by +1.67
The sample mean x̄ is our single best guess for μ. It is close, but it is one number with no sense of how far off it might be. The honest question is: how much would this guess change if we had drawn a different sample?
points = np.array([rng.choice(POP, 40, replace=False).mean() for _ in range(5000)])
print(f"5000 point estimates: average {points.mean():.2f} (centered on mu={MU:.2f}), spread (SE) {points.std():.2f}")
print(f"formula SE = sigma/sqrt(n) = {POP.std()/np.sqrt(40):.2f}")
fig,ax=plt.subplots(figsize=(7,3.2))
ax.hist(points, bins=45, color=LIGHT, alpha=0.8)
ax.axvline(MU, color=DEEP, ls="--", lw=2, label=f"true mu = {MU:.1f}")
ax.set_xlabel("point estimate (sample mean)"); ax.set_ylabel("frequency")
ax.set_title("Point estimates from 5,000 samples wobble around the truth"); ax.legend()
plt.tight_layout(); plt.show()
5000 point estimates: average 99.97 (centered on mu=99.97), spread (SE) 2.84 formula SE = sigma/sqrt(n) = 2.84
The point estimates form a tight bell centered on the true mean, with a standard deviation equal to the standard error. A single point estimate is one draw from this bell, so it is almost never exactly right. An interval estimate turns that spread into a stated range.
z = stats.norm.ppf(0.975) # 1.96
sigma = POP.std()
def interval(n=40):
s = rng.choice(POP, n, replace=False); xb = s.mean(); se = sigma/np.sqrt(n)
return xb - z*se, xb + z*se, xb
lo, hi, mid = np.array([interval() for _ in range(100)]).T
covers = (lo <= MU) & (MU <= hi)
print(f"of 100 intervals, {covers.sum()} contain the true mean {MU:.1f} (target ~95)")
fig,ax=plt.subplots(figsize=(7,3.8))
for i in range(100):
ax.plot([lo[i],hi[i]],[i,i], color=(BLUE if covers[i] else "#ef4444"), lw=1)
ax.axvline(MU, color=INK, ls="--", lw=1.5)
ax.set_yticks([]); ax.set_xlabel("estimate"); ax.set_title("100 95% intervals; red ones miss the true mean")
plt.tight_layout(); plt.show()
of 100 intervals, 96 contain the true mean 100.0 (target ~95)
About 95 of the 100 intervals capture the true mean; the few red misses are the price of 95% (not 100%) confidence. This is the meaning of "95% confident": it is a property of the procedure over many samples, not a probability about one fixed interval. The interval is wider than a point, and that width is its honesty.
We load a spreadsheet of 220 closed home sales (point-vs-interval-estimation--home_sales.xlsx), the kind of export an assessor or MLS produces. We do not know the true average price of all homes in this market, so we estimate it: a point estimate plus a 95% interval.
try: homes = pd.read_excel("../../data/point-vs-interval-estimation--home_sales.xlsx", sheet_name="Sales")
except FileNotFoundError: homes = pd.read_excel(BASE+"point-vs-interval-estimation--home_sales.xlsx", sheet_name="Sales")
print("loaded:", homes.shape)
homes[["sale_id","neighborhood","sale_price","sqft","bedrooms"]].head()
loaded: (220, 9)
| sale_id | neighborhood | sale_price | sqft | bedrooms | |
|---|---|---|---|---|---|
| 0 | H4000 | Oak Hill | 268900 | 1105 | 1 |
| 1 | H4001 | Oak Hill | 350600 | 2057 | 3 |
| 2 | H4002 | Lakeview | 281900 | 1475 | 3 |
| 3 | H4003 | Downtown | 329700 | 1927 | 3 |
| 4 | H4004 | Riverside | 351900 | 1711 | 4 |
price = homes["sale_price"]
n = len(price); xbar = price.mean(); s = price.std(ddof=1); se = s/np.sqrt(n)
z = stats.norm.ppf(0.975)
lo, hi = xbar - z*se, xbar + z*se
print(f"n = {n} sales")
print(f"POINT estimate of mean sale price: ${xbar:,.0f}")
print(f"std dev ${s:,.0f} -> standard error ${se:,.0f}")
print(f"INTERVAL estimate (95%): ${lo:,.0f} to ${hi:,.0f} (= ${xbar:,.0f} +/- ${z*se:,.0f})")
print(f"\nmedian sale price ${price.median():,.0f} (mean > median signals right skew)")
n = 220 sales POINT estimate of mean sale price: $338,159 std dev $69,417 -> standard error $4,680 INTERVAL estimate (95%): $328,986 to $347,331 (= $338,159 +/- $9,173) median sale price $335,550 (mean > median signals right skew)
fig,ax=plt.subplots(figsize=(7,3.3))
ax.hist(price, bins=30, color=LIGHT, alpha=0.8)
ax.axvline(xbar, color=DEEP, lw=2, label=f"point est ${xbar:,.0f}")
ax.axvspan(lo, hi, color=BLUE, alpha=0.18, label="95% interval")
ax.set_xlabel("sale price"); ax.set_ylabel("homes"); ax.set_title("Home sale prices: a point estimate inside its interval"); ax.legend()
from matplotlib.ticker import FuncFormatter, MaxNLocator
ax.xaxis.set_major_formatter(FuncFormatter(lambda v,_: f"${v/1000:.0f}k")); ax.xaxis.set_major_locator(MaxNLocator(6))
plt.tight_layout(); plt.show()
The point estimate of the average sale price is about 338,000 dollars, and the 95% interval runs roughly from 329,000 to 347,000. Reporting only the point would hide the uncertainty; the interval says, honestly, "the market average is very likely in this band." Because prices are right-skewed (mean above median), the next chapters will also show when the median or the bootstrap is the better tool.