Standard Errors
The standard error of an estimator is the standard deviation of its sampling distribution:
$$\text{SE}(\hat{\theta}) = \sqrt{\text{Var}(\hat{\theta})}$$
It answers: "How much would my estimate change if I drew a new sample?" A small SE means the estimator is tightly concentrated around its expected value; a large SE means estimates vary substantially across samples.
For the sample mean, $\text{SE}(\bar{X}) = \sigma/\sqrt{n}$, estimated by $\hat{\text{SE}} = S/\sqrt{n}$ where $S$ is the sample standard deviation. Note carefully: the SE shrinks as $\sqrt{n}$, so to halve the standard error you must quadruple the sample size.
The Bootstrap
For complex statistics (medians, correlation coefficients, AUC, model parameters), deriving analytic SEs is often intractable. The bootstrap, introduced by Efron (1979), provides a general simulation-based approach.
The plug-in principle: estimate the sampling distribution of $\hat{\theta}$ by treating the sample as a proxy population and resampling from it.
Algorithm — Nonparametric Bootstrap:
- Compute <!--MATHBLOCK9--> from the original sample of size <!--MATHBLOCK10-->.
- For <!--MATHBLOCK11-->: draw a bootstrap sample of size <!--MATHBLOCK12--> with replacement from the data, compute <!--MATHBLOCK13-->.
- Use the distribution of <!--MATHBLOCK14--> to estimate the SE and construct confidence intervals.
The bootstrap SE is simply the standard deviation of the bootstrap replicates:
$$\widehat{\text{SE}}_{\text{boot}} = \sqrt{\frac{1}{B-1}\sum_{b=1}^B (\hat{\theta}^*_b - \bar{\hat{\theta}}^*)^2}$$
import numpy as np
from scipy import stats
rng = np.random.default_rng(42)
data = rng.lognormal(mean=2.0, sigma=0.8, size=80)
# Statistic of interest: ratio of mean to median
def statistic(x):
return x.mean() / np.median(x)
theta_hat = statistic(data)
# Bootstrap
B = 5000
boot_reps = np.array([
statistic(rng.choice(data, len(data), replace=True))
for _ in range(B)
])
se_boot = boot_reps.std(ddof=1)
print(f"Estimate (mean/median): {theta_hat:.4f}")
print(f"Bootstrap SE: {se_boot:.4f}")
print(f"Bootstrap 95% CI (percentile): ({np.percentile(boot_reps, 2.5):.4f}, {np.percentile(boot_reps, 97.5):.4f})")
Confidence Intervals
A confidence interval (CI) is an interval estimator $[L(X), U(X)]$ constructed such that it covers the true parameter with a specified probability:
$$P(L(X) \leq \theta \leq U(X)) = 1 - \alpha$$
The Correct Interpretation
This is one of the most misunderstood concepts in statistics. The confidence level $1-\alpha$ is a property of the procedure, not of a particular interval. Before the data are collected, 95% of intervals constructed this way will contain $\theta$. After the data are observed and the interval is computed, the interval either contains $\theta$ or it does not — there is no probability statement about a specific realized interval.
Wrong: "There is a 95% probability that $\theta$ is between 2.1 and 3.7."
Right: "This interval was constructed using a procedure that covers the true parameter in 95% of repeated applications."
Constructing Confidence Intervals
Pivot method (exact, when available): Find a function of the data and the parameter — a pivot — whose distribution does not depend on unknown parameters, then invert to isolate the parameter.
For the normal mean with unknown variance, the pivot is $T = (\bar{X} - \mu)/(S/\sqrt{n}) \sim t_{n-1}$, giving the t-interval:
$$\bar{x} \pm t_{n-1, \alpha/2} \cdot \frac{s}{\sqrt{n}}$$
import numpy as np
from scipy import stats
rng = np.random.default_rng(0)
data = rng.normal(loc=10.0, scale=3.0, size=25)
n = len(data)
xbar, s = data.mean(), data.std(ddof=1)
t_crit = stats.t.ppf(0.975, df=n-1)
ci = (xbar - t_crit * s/np.sqrt(n), xbar + t_crit * s/np.sqrt(n))
print(f"Sample mean: {xbar:.3f}, S: {s:.3f}")
print(f"95% t-interval: ({ci[0]:.3f}, {ci[1]:.3f})")
print(f"True mean 10.0 is {'inside' if ci[0] <= 10.0 <= ci[1] else 'outside'} the CI")
Bootstrap confidence intervals: Three common types:
- Percentile method: Use the <!--MATHBLOCK21--> and <!--MATHBLOCK22--> quantiles of the bootstrap distribution directly.
- Basic (reflected) method: <!--MATHBLOCK23-->. Corrects for asymmetry.
- BCa (bias-corrected and accelerated): Adjusts for both bias and skewness in the bootstrap distribution — the most reliable but computationally intensive.
Coverage Probability Simulation
The true test of a CI method is its coverage probability — what fraction of constructed intervals actually contain the true parameter:
import numpy as np
from scipy import stats
rng = np.random.default_rng(1)
mu_true = 5.0
n = 20
alpha = 0.05
B_boot = 500
reps = 2000
t_cover, boot_cover = 0, 0
for _ in range(reps):
data = rng.exponential(scale=mu_true, size=n) # skewed data
xbar, s = data.mean(), data.std(ddof=1)
t_crit = stats.t.ppf(1 - alpha/2, df=n-1)
t_lo, t_hi = xbar - t_crit*s/np.sqrt(n), xbar + t_crit*s/np.sqrt(n)
t_cover += (t_lo <= mu_true <= t_hi)
boot_reps = [rng.choice(data, n, replace=True).mean() for _ in range(B_boot)]
b_lo, b_hi = np.percentile(boot_reps, [2.5, 97.5])
boot_cover += (b_lo <= mu_true <= b_hi)
print(f"t-interval coverage: {t_cover/reps:.3f} (nominal: 0.950)")
print(f"Boot pctile coverage: {boot_cover/reps:.3f} (nominal: 0.950)")
print("Note: Both under-cover for skewed data at n=20.")
Pitfalls and Practical Guidance
- Multiple testing: Constructing many CIs inflates the chance of at least one failing to cover the true value. Use Bonferroni or simultaneous CIs when comparing many parameters.
- Bootstrap assumptions: The bootstrap relies on the sample adequately representing the population. It fails badly for statistics that depend on extreme order statistics (min, max) and for heavily dependent data (use block bootstrap for time series).
- Interpreting width: A wide CI reflects genuine uncertainty, not a flaw in the analysis. Reporting a point estimate without an SE or CI obscures this uncertainty and is bad practice.
Code Examples
Comparing CI Methods: t-interval vs. Bootstrap Percentile vs. BCa
Compare coverage properties of three CI methods for the median of a skewed distribution.
import numpy as np
from scipy import stats
rng = np.random.default_rng(42)
true_median = np.log(2) * 5 # median of Exp(0.2) is log(2)/lambda
n = 40
reps = 3000
B = 1000
normal_cover, pctile_cover, bca_cover = 0, 0, 0
def bca_ci(data, stat_fn, B=1000, alpha=0.05, rng=None):
if rng is None: rng = np.random.default_rng()
theta_hat = stat_fn(data)
n = len(data)
boots = np.array([stat_fn(rng.choice(data, n, replace=True)) for _ in range(B)])
# Bias correction z0
z0 = stats.norm.ppf(np.mean(boots < theta_hat))
# Acceleration a (jackknife)
jk = np.array([stat_fn(np.delete(data, i)) for i in range(n)])
jk_mean = jk.mean()
num = np.sum((jk_mean - jk)**3)
den = 6 * (np.sum((jk_mean - jk)**2))**1.5
a = num / den if den != 0 else 0
# Adjusted percentiles
def adj(pct):
z = stats.norm.ppf(pct)
return stats.norm.cdf(z0 + (z0 + z) / (1 - a*(z0 + z)))
lo = np.percentile(boots, 100*adj(alpha/2))
hi = np.percentile(boots, 100*adj(1-alpha/2))
return lo, hi
for _ in range(reps):
data = rng.exponential(scale=5.0, size=n)
med = np.median(data)
se_approx = 1.2533 * data.std(ddof=1) / np.sqrt(n) # asymptotic SE of median
lo_n, hi_n = med - 1.96*se_approx, med + 1.96*se_approx
normal_cover += lo_n <= true_median <= hi_n
boots = np.array([np.median(rng.choice(data, n, replace=True)) for _ in range(B)])
lo_p, hi_p = np.percentile(boots, [2.5, 97.5])
pctile_cover += lo_p <= true_median <= hi_p
lo_b, hi_b = bca_ci(data, np.median, B=B, rng=rng)
bca_cover += lo_b <= true_median <= hi_b
print(f"Normal approx coverage: {normal_cover/reps:.3f}")
print(f"Boot percentile coverage: {pctile_cover/reps:.3f}")
print(f"BCa coverage: {bca_cover/reps:.3f}")
print(f"(Nominal: 0.950)")
OutputNormal approx coverage: 0.919
Boot percentile coverage: 0.933
BCa coverage: 0.947
(Nominal: 0.950)
Visualizing Confidence Interval Coverage
Draw 100 confidence intervals and show which ones miss the true parameter.
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
rng = np.random.default_rng(13)
mu_true = 5.0
n = 30
num_intervals = 100
cis = []
for _ in range(num_intervals):
data = rng.normal(mu_true, 2.0, n)
xbar, s = data.mean(), data.std(ddof=1)
t = stats.t.ppf(0.975, df=n-1)
cis.append((xbar - t*s/np.sqrt(n), xbar + t*s/np.sqrt(n)))
covering = [(lo <= mu_true <= hi) for lo, hi in cis]
coverage = sum(covering) / num_intervals
print(f"Coverage in 100 draws: {coverage:.0%} ({sum(covering)}/100 intervals contain mu=5)")
fig, ax = plt.subplots(figsize=(4, 10))
for i, ((lo, hi), covers) in enumerate(zip(cis, covering)):
color = 'steelblue' if covers else 'crimson'
ax.plot([lo, hi], [i, i], color=color, lw=1.5, alpha=0.8)
ax.axvline(mu_true, color='black', lw=1.5, ls='--', label=f'True μ={mu_true}')
ax.set_xlabel('Parameter value')
ax.set_title(f'95% t-intervals: {sum(covering)}/100 cover μ')
plt.tight_layout()
plt.savefig('ci_coverage.png', dpi=100)
print("Figure saved as ci_coverage.png")
OutputCoverage in 100 draws: 96% (96/100 intervals contain mu=5)
Figure saved as ci_coverage.png