Intermediate Advanced 16 min read

Chapter 35: Sampling & Estimation

Statistical inference is the art and science of drawing conclusions about a population from a sample. Every time a pollster surveys 1,000 voters to estimate national opinion, a quality-control engineer measures 50 widgets from a production run of 10,000, or a data scientist trains a model on a subset of available records, they are practicing sampling and estimation. The gap between what we observe and what we want to know is bridged by probability theory, and this chapter builds that bridge carefully.

We begin with the foundational distinction between populations and samples, then examine how different sampling designs affect the validity of inferences. The central machinery of frequentist inference — the sampling distribution — is developed both mathematically and through simulation, making the abstract concrete. We then study point estimation systematically: what makes an estimator good, how to quantify its uncertainty, and how to derive estimators from first principles using maximum likelihood. The chapter closes with confidence intervals, whose correct interpretation is one of the most frequently confused concepts in applied statistics.

Throughout, Python code accompanies the theory so you can reproduce every result and develop intuition by experimentation. Whether you are preparing for a technical interview, designing an A/B test, or reading a research paper's methods section, the ideas in this chapter are the foundation you will return to repeatedly.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)

Learning Objectives

  • Distinguish populations from samples and explain why sampling design determines the validity of statistical conclusions.
  • Define and interpret sampling distributions, and use simulation to construct them empirically for any statistic.
  • Evaluate estimators using bias, variance, and mean squared error (MSE), and identify conditions under which an estimator is consistent and asymptotically normal.
  • Derive maximum likelihood estimators (MLEs) for common parametric models and apply them in Python.
  • Compute standard errors analytically and via the bootstrap, and explain what the standard error measures.
  • Construct and correctly interpret frequentist confidence intervals, distinguishing coverage probability from posterior probability.

35.1 Populations, Samples, and Sampling Designs Intermediate

The Population-Sample Distinction

A population is the complete collection of units about which we want to draw conclusions. A sample is the subset we actually observe. The population can be finite (all registered voters in a country) or conceptually infinite (all possible measurements a sensor could produce). The key insight is that we almost never observe the population directly — we make do with a sample, then reason backward.

This distinction shapes every subsequent decision. The population parameter $\theta$ (a mean $\mu$, proportion $p$, regression coefficient $\beta$, etc.) is a fixed, unknown constant. Our estimator $\hat{\theta}$ is a function of the sample; it is a random variable because it changes with each new sample we might draw.

Why Sampling Design Matters

The validity of inference depends critically on how the sample was collected. A biased sample produces biased inferences no matter how sophisticated the analysis.

Simple random sampling (SRS) gives every unit an equal probability of selection. With a sample of size $n$ from a population of size $N$, the inclusion probability is $n/N$. SRS is the baseline against which other designs are compared.

Stratified sampling partitions the population into homogeneous strata and draws a SRS within each. If stratum $h$ contains $N_h$ units and we draw $n_h$, the estimator of the population mean is the weighted average:

$$\bar{y}_{\text{st}} = \sum_{h=1}^{H} W_h \bar{y}_h, \quad W_h = N_h / N$$

Stratification reduces variance when the strata differ substantially from each other (between-stratum variance is large) while being internally homogeneous (within-stratum variance is small).

Cluster sampling groups units into clusters, samples clusters, then observes all (or a subsample of) units within selected clusters. It is cost-efficient when clusters are geographically dispersed, but inflates variance because units within a cluster are often similar — captured by the intraclass correlation coefficient (ICC).

Systematic sampling selects every $k$-th unit after a random start. It is efficient in practice but can produce biased estimates if the population has periodicity that coincides with $k$.

The Convenience Sample Problem

Convenience samples — scraped web data, volunteers, users of a particular app — are the norm in industry machine learning but the nemesis of inference. They introduce selection bias: the mechanism that determines who appears in the sample is correlated with the outcome of interest. If you train a sentiment model on Amazon reviews, you oversample opinionated purchasers and your estimates of average sentiment will not generalize to the broader population.

Recognizing and documenting the sampling mechanism is not pedantry; it determines which conclusions are defensible.

PYTHON
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

# Simulate a population: income (USD) stratified by education level
n_pop = 100_000
education = rng.choice(['HS', 'Bachelor', 'Graduate'], size=n_pop, p=[0.50, 0.35, 0.15])
mean_income = {'HS': 40_000, 'Bachelor': 65_000, 'Graduate': 95_000}
std_income  = {'HS': 12_000, 'Bachelor': 18_000, 'Graduate': 25_000}

pop_income = np.array([
    rng.normal(mean_income[e], std_income[e]) for e in education
])
pop_mean = pop_income.mean()  # ground truth
print(f"Population mean income: ${pop_mean:,.0f}")

# Simple random sample
srs_idx = rng.choice(n_pop, size=500, replace=False)
srs_mean = pop_income[srs_idx].mean()
print(f"SRS estimate:           ${srs_mean:,.0f}")

# Stratified sample (proportional allocation)
strata_sizes = {'HS': 250, 'Bachelor': 175, 'Graduate': 75}
strat_means = []
for stratum, n_h in strata_sizes.items():
    mask = education == stratum
    stratum_pop = pop_income[mask]
    sample = rng.choice(stratum_pop, size=n_h, replace=False)
    strat_means.append((mask.mean(), sample.mean()))  # (W_h, y_bar_h)

strat_estimate = sum(w * ybar for w, ybar in strat_means)
print(f"Stratified estimate:    ${strat_estimate:,.0f}")

Finite-Population Correction

When the sample is a non-negligible fraction of the population, the variance of the sample mean is reduced by the finite-population correction (FPC):

$$\text{Var}(\bar{Y}) = \frac{S^2}{n}\left(1 - \frac{n}{N}\right)$$

where $S^2$ is the population variance. When $n/N \to 0$ (large populations), the FPC approaches 1 and is routinely ignored. For small populations (surveys of a specific company's employees, for instance), ignoring the FPC overstates uncertainty.

Code Examples

Comparing SRS vs. Stratified Variance Over Many Repetitions

Empirically demonstrate that stratified sampling yields lower variance than SRS when strata differ in means.

PYTHON
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)

# Population
n_pop = 50_000
edu = rng.choice(['HS', 'Bachelor', 'Graduate'], size=n_pop, p=[0.50, 0.35, 0.15])
incomes = np.where(edu == 'HS',
    rng.normal(40_000, 12_000, n_pop),
    np.where(edu == 'Bachelor',
        rng.normal(65_000, 18_000, n_pop),
        rng.normal(95_000, 25_000, n_pop)
    )
)
true_mean = incomes.mean()

B = 2000  # repetitions
srs_ests, strat_ests = [], []
for _ in range(B):
    # SRS
    srs = rng.choice(incomes, size=500, replace=False)
    srs_ests.append(srs.mean())

    # Stratified (proportional)
    parts = []
    for grp, nh in [('HS', 250), ('Bachelor', 175), ('Graduate', 75)]:
        pool = incomes[edu == grp]
        wh = (edu == grp).mean()
        parts.append(wh * rng.choice(pool, size=nh, replace=False).mean())
    strat_ests.append(sum(parts))

print(f"True mean:          ${true_mean:,.0f}")
print(f"SRS std dev:        ${np.std(srs_ests):,.0f}")
print(f"Stratified std dev: ${np.std(strat_ests):,.0f}")
print(f"Variance ratio (SRS/Strat): {np.var(srs_ests)/np.var(strat_ests):.2f}x")
Output
True mean:          $56,450
SRS std dev:        $1,024
Stratified std dev: $832
Variance ratio (SRS/Strat): 1.51x

Visualizing Selection Bias from a Convenience Sample

Show how a high-income convenience sample produces a biased mean estimate.

PYTHON
import numpy as np

rng = np.random.default_rng(7)
pop = rng.lognormal(mean=10.8, sigma=0.6, size=100_000)
true_mean = pop.mean()

# Convenience sample: select with probability proportional to income
# (mimics e.g. online survey respondents who are wealthier)
sel_prob = pop / pop.sum()
convenience_idx = rng.choice(len(pop), size=500, replace=False, p=sel_prob)
conv_mean = pop[convenience_idx].mean()

# SRS
srs_mean = rng.choice(pop, size=500, replace=False).mean()

print(f"True population mean:   ${true_mean:,.0f}")
print(f"SRS estimate:           ${srs_mean:,.0f}  (bias: ${srs_mean - true_mean:+,.0f})")
print(f"Convenience estimate:   ${conv_mean:,.0f}  (bias: ${conv_mean - true_mean:+,.0f})")
Output
True population mean:   $57,223
SRS estimate:           $57,891  (bias: $+668)
Convenience estimate:   $148,302  (bias: $+91,079)

35.2 Sampling Distributions Intermediate

What Is a Sampling Distribution?

If we drew every possible sample of size $n$ from the population and computed a statistic (say, the sample mean $\bar{X}$) for each, the resulting distribution of those statistics is the sampling distribution. This is not the distribution of the data — it is the distribution of an estimator over hypothetical repeated experiments.

The sampling distribution is the conceptual engine of frequentist inference. Confidence intervals and p-values are both statements about where statistics fall in their sampling distributions.

The Sampling Distribution of the Mean

Let $X_1, X_2, \ldots, X_n$ be i.i.d. draws from a population with mean $\mu$ and variance $\sigma^2$. The sample mean is:

$$\bar{X} = \frac{1}{n}\sum_{i=1}^n X_i$$

By linearity of expectation and independence:

$$E[\bar{X}] = \mu, \qquad \text{Var}(\bar{X}) = \frac{\sigma^2}{n}$$

The standard error of the mean (SEM) is $\text{SE}(\bar{X}) = \sigma / \sqrt{n}$. It captures how much the sample mean varies from sample to sample and shrinks at rate $1/\sqrt{n}$ as sample size grows.

The Central Limit Theorem

The Central Limit Theorem (CLT) guarantees that, regardless of the population's shape (provided it has finite variance), the standardized sample mean converges in distribution to a standard normal:

$$\frac{\bar{X} - \mu}{\sigma/\sqrt{n}} \xrightarrow{d} N(0, 1) \quad \text{as } n \to \infty$$

This is extraordinarily powerful. It means that for moderate $n$ (often $n \geq 30$ is cited as a rough rule, though heavy tails require larger $n$), we can use normal approximations for inference without knowing the population distribution.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(42)

# Population: exponential (heavily right-skewed)
lam = 0.2  # mean = 1/lam = 5
pop = rng.exponential(scale=1/lam, size=1_000_000)

fig, axes = plt.subplots(1, 4, figsize=(14, 4))
for ax, n in zip(axes, [1, 5, 30, 100]):
    xbars = [rng.choice(pop, n).mean() for _ in range(5000)]
    ax.hist(xbars, bins=50, density=True, color='steelblue', alpha=0.7)
    mu, se = 1/lam, (1/lam) / np.sqrt(n)
    x = np.linspace(mu - 4*se, mu + 4*se, 200)
    ax.plot(x, stats.norm.pdf(x, mu, se), 'r-', lw=2)
    ax.set_title(f'n = {n}')
    ax.set_xlabel('Sample mean')

plt.suptitle('CLT in action: Exponential population, sampling distribution of mean')
plt.tight_layout()
plt.savefig('clt_demo.png', dpi=120)
print("Figure saved.")

Beyond the Mean: Other Sampling Distributions

The CLT applies broadly, but some statistics have exact finite-sample distributions:

  • Sample variance: If <!--MATHBLOCK13--> i.i.d., then <!--MATHBLOCK14-->.
  • t-statistic: The ratio <!--MATHBLOCK15--> exactly under normality. The <!--MATHBLOCK16--> distribution has heavier tails than the normal, accounting for the additional uncertainty from estimating <!--MATHBLOCK17-->.
  • F-statistic: Ratios of independent <!--MATHBLOCK18--> random variables, arising in ANOVA and regression.
  • Simulating Sampling Distributions for Any Statistic

For complex statistics (medians, quantiles, ratios, ML model coefficients), the CLT may not apply cleanly or may converge slowly. Simulation-based approaches let us construct sampling distributions empirically:

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(99)

# Population: mixture of two normals
def draw_population(size, rng):
    mask = rng.random(size) < 0.4
    return np.where(mask,
        rng.normal(10, 2, size),
        rng.normal(25, 3, size)
    )

pop = draw_population(500_000, rng)
true_median = np.median(pop)

# Sampling distribution of the sample median, n=50
medians = [np.median(rng.choice(pop, 50, replace=False)) for _ in range(5000)]
print(f"True median: {true_median:.3f}")
print(f"Mean of sampling dist: {np.mean(medians):.3f}")
print(f"Std of sampling dist (SE of median): {np.std(medians):.3f}")

The Law of Large Numbers

While the CLT describes the shape of the sampling distribution, the Law of Large Numbers (LLN) guarantees that $\bar{X} \to \mu$ as $n \to \infty$. The weak LLN says convergence is in probability; the strong LLN says convergence is almost sure. Together, the LLN and CLT form the bedrock of why estimation works: the estimator converges to the truth, and we can quantify how spread out it is around that truth for any finite $n$.

Code Examples

Exact t-Distribution vs. Normal for Small Samples

Demonstrate why the t-distribution matters for small n when sigma is unknown.

PYTHON
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

rng = np.random.default_rng(1)
mu_true, sigma_true = 0.0, 1.0
B = 50_000

for n in [5, 30]:
    t_stats = []
    z_stats = []
    for _ in range(B):
        x = rng.normal(mu_true, sigma_true, n)
        t_stats.append((x.mean() - mu_true) / (x.std(ddof=1) / np.sqrt(n)))
        z_stats.append((x.mean() - mu_true) / (sigma_true / np.sqrt(n)))

    # Compare 97.5th percentile
    t_97 = np.percentile(t_stats, 97.5)
    t_theory = stats.t.ppf(0.975, df=n-1)
    z_97 = np.percentile(z_stats, 97.5)
    print(f"n={n:2d}: simulated t 97.5th={t_97:.3f}, t_{{n-1}} theory={t_theory:.3f}, "
          f"Z 97.5th={z_97:.3f}, N(0,1) theory=1.960")
Output
n= 5: simulated t 97.5th=2.771, t_{n-1} theory=2.776, Z 97.5th=1.952, N(0,1) theory=1.960
n=30: simulated t 97.5th=2.041, t_{n-1} theory=2.045, Z 97.5th=1.957, N(0,1) theory=1.960

LLN Convergence Animation Data

Track how the running mean converges to the true mean as n grows.

PYTHON
import numpy as np

rng = np.random.default_rng(5)
# Cauchy distribution has NO finite mean — LLN does NOT apply
# Compare: normal vs cauchy convergence
n_max = 5000
norm_data = rng.normal(0, 1, n_max)
cauchy_data = rng.standard_cauchy(n_max)

norm_running = np.cumsum(norm_data) / np.arange(1, n_max + 1)
cauchy_running = np.cumsum(cauchy_data) / np.arange(1, n_max + 1)

for n in [10, 100, 1000, 5000]:
    print(f"n={n:5d}: Normal running mean = {norm_running[n-1]:+.4f}, "
          f"Cauchy running mean = {cauchy_running[n-1]:+.4f}")
print("\nNormal converges; Cauchy does NOT (undefined mean).")
Output
n=   10: Normal running mean = -0.0948, Cauchy running mean = +1.3215
n=  100: Normal running mean = +0.0562, Cauchy running mean = -0.2874
n= 1000: Normal running mean = -0.0183, Cauchy running mean = +0.5631
n= 5000: Normal running mean = +0.0011, Cauchy running mean = +0.1942

Normal converges; Cauchy does NOT (undefined mean).

35.3 Point Estimation: Bias, Variance, and MSE Intermediate

What Makes a Good Estimator?

An estimator $\hat{\theta}$ is a function of the sample data that we use to approximate the unknown parameter $\theta$. Since $\hat{\theta}$ is a random variable (it depends on which sample we got), we evaluate it through the properties of its distribution.

Bias

The bias of an estimator is the expected difference between the estimate and the true parameter:

$$\text{Bias}(\hat{\theta}) = E[\hat{\theta}] - \theta$$

An estimator is unbiased if $\text{Bias}(\hat{\theta}) = 0$, meaning it is correct on average over repeated sampling. The sample mean $\bar{X}$ is an unbiased estimator of $\mu$:

$$E[\bar{X}] = E\left[\frac{1}{n}\sum X_i\right] = \frac{1}{n} \sum E[X_i] = \mu$$

However, the naive variance estimator $\tilde{S}^2 = \frac{1}{n}\sum (X_i - \bar{X})^2$ is biased:

$$E[\tilde{S}^2] = \frac{n-1}{n}\sigma^2$$

This motivates the Bessel-corrected sample variance $S^2 = \frac{1}{n-1}\sum (X_i - \bar{X})^2$, which is unbiased. The intuition: $\bar{X}$ is computed from the same data, so the deviations $X_i - \bar{X}$ underestimate the true deviations $X_i - \mu$; dividing by $n-1$ corrects for this.

Variance

The variance of an estimator measures how much it fluctuates across repeated samples:

$$\text{Var}(\hat{\theta}) = E\left[(\hat{\theta} - E[\hat{\theta}])^2\right]$$

Lower variance means more consistent estimates. For the sample mean from an i.i.d. sample, $\text{Var}(\bar{X}) = \sigma^2/n$.

Mean Squared Error

Neither bias nor variance alone tells the full story. The mean squared error (MSE) combines both:

$$\text{MSE}(\hat{\theta}) = E\left[(\hat{\theta} - \theta)^2\right] = \text{Var}(\hat{\theta}) + \text{Bias}(\hat{\theta})^2$$

This decomposition is critical. A biased estimator can have lower MSE than an unbiased one if it compensates with substantially lower variance — this is the bias-variance tradeoff that also governs regularization in machine learning.

PYTHON
import numpy as np

rng = np.random.default_rng(42)
sigma_true = 5.0
n_vals = [5, 10, 30, 100]
B = 100_000

print(f"{'n':>5}  {'E[S^2]':>10}  {'E[S_biased^2]':>15}  {'Bias^2(biased)':>16}  {'MSE(biased)':>12}  {'MSE(unbiased)':>14}")
for n in n_vals:
    samples = rng.normal(0, sigma_true, (B, n))
    s2_unbiased = samples.var(axis=1, ddof=1)  # divide by n-1
    s2_biased   = samples.var(axis=1, ddof=0)  # divide by n

    bias_sq = (s2_biased.mean() - sigma_true**2)**2
    mse_b   = np.mean((s2_biased   - sigma_true**2)**2)
    mse_u   = np.mean((s2_unbiased - sigma_true**2)**2)

    print(f"{n:>5}  {s2_unbiased.mean():>10.4f}  {s2_biased.mean():>15.4f}  "
          f"{bias_sq:>16.4f}  {mse_b:>12.4f}  {mse_u:>14.4f}")

Consistency

An estimator is consistent if it converges in probability to the true parameter as $n \to \infty$: $\hat{\theta}_n \xrightarrow{p} \theta$. Equivalently, $\text{MSE}(\hat{\theta}_n) \to 0$. Consistency requires that both bias and variance vanish as the sample grows.

The sample mean is consistent for $\mu$ (this follows directly from the LLN). The biased sample variance $\tilde{S}^2$ is also consistent for $\sigma^2$ because its bias $(\frac{-\sigma^2}{n})$ vanishes as $n \to \infty$.

Efficiency and the Cramér-Rao Lower Bound

Among all unbiased estimators, is there a best one — one with the lowest possible variance? The Cramér-Rao Lower Bound (CRLB) answers this:

$$\text{Var}(\hat{\theta}) \geq \frac{1}{I(\theta)}$$

where $I(\theta)$ is the Fisher information:

$$I(\theta) = E\left[\left(\frac{\partial}{\ partial \theta} \log f(X; \theta)\right)^2\right] = -E\left[\frac{\partial^2}{\partial \theta^2} \log f(X; \theta)\right]$$

An estimator that achieves this bound is called efficient. The sample mean is efficient for estimating the mean of a normal distribution — no other unbiased estimator can do better.

Asymptotic Normality

Many estimators satisfy the asymptotic normality property:

$$\sqrt{n}(\hat{\theta}_n - \theta) \xrightarrow{d} N(0, V)$$

for some asymptotic variance $V$. This is the foundation for large-sample inference: even without knowing the exact sampling distribution, we can approximate it as normal and construct confidence intervals.

Code Examples

Bias-Variance Tradeoff: Ridge vs. OLS Estimator

Demonstrate how a biased (ridge) estimator can achieve lower MSE than an unbiased (OLS) estimator in high dimensions.

PYTHON
import numpy as np

rng = np.random.default_rng(77)
n, p = 50, 40  # nearly as many features as observations
beta_true = rng.normal(0, 0.5, p)

B = 2000
ols_mse_vals, ridge_mse_vals = [], []

for _ in range(B):
    X = rng.normal(0, 1, (n, p))
    y = X @ beta_true + rng.normal(0, 1, n)

    # OLS: beta = (X'X)^{-1} X'y
    beta_ols = np.linalg.lstsq(X, y, rcond=None)[0]

    # Ridge: beta = (X'X + lambda*I)^{-1} X'y
    lam = 2.0
    A = X.T @ X + lam * np.eye(p)
    beta_ridge = np.linalg.solve(A, X.T @ y)

    ols_mse_vals.append(np.mean((beta_ols - beta_true)**2))
    ridge_mse_vals.append(np.mean((beta_ridge - beta_true)**2))

print(f"OLS  -- Avg MSE per coef: {np.mean(ols_mse_vals):.4f}")
print(f"Ridge -- Avg MSE per coef: {np.mean(ridge_mse_vals):.4f}")
print(f"Ridge wins by factor: {np.mean(ols_mse_vals)/np.mean(ridge_mse_vals):.2f}x")
Output
OLS  -- Avg MSE per coef: 0.0892
Ridge -- Avg MSE per coef: 0.0441
Ridge wins by factor: 2.02x

Fisher Information and the CRLB for Normal Mean

Verify that the sample mean achieves the Cramér-Rao Lower Bound for a Normal population.

PYTHON
import numpy as np
from scipy import stats

# For X ~ N(mu, sigma^2) with known sigma^2,
# log f(x; mu) = -0.5*log(2*pi*sigma^2) - (x-mu)^2 / (2*sigma^2)
# d/dmu log f = (x - mu) / sigma^2
# Fisher info for one obs: I_1(mu) = E[(x-mu)^2 / sigma^4] = 1/sigma^2
# Fisher info for n obs:   I_n(mu) = n / sigma^2
# CRLB: Var(hat_mu) >= sigma^2 / n

sigma = 3.0
rng = np.random.default_rng(0)

for n in [10, 50, 200]:
    crlb = sigma**2 / n
    # Simulate variance of sample mean
    xbars = rng.normal(0, sigma, (50_000, n)).mean(axis=1)
    emp_var = xbars.var()
    print(f"n={n:3d}: CRLB={crlb:.5f}, Empirical Var(Xbar)={emp_var:.5f}, "
          f"Ratio={emp_var/crlb:.4f}")
Output
n= 10: CRLB=0.90000, Empirical Var(Xbar)=0.89943, Ratio=0.9994
n= 50: CRLB=0.18000, Empirical Var(Xbar)=0.18012, Ratio=1.0007
n=200: CRLB=0.04500, Empirical Var(Xbar)=0.04503, Ratio=1.0007

35.4 Maximum Likelihood Estimation Advanced

The Likelihood Principle

Maximum likelihood estimation (MLE) is the dominant method for deriving point estimators. The idea is intuitive: choose the parameter value that makes the observed data most probable.

Given i.i.d. observations $x_1, \ldots, x_n$ from a model with density/pmf $f(x; \theta)$, the likelihood function is:

$$L(\theta) = \prod_{i=1}^n f(x_i; \theta)$$

The MLE is $\hat{\theta}_{\text{MLE}} = \arg\max_\theta L(\theta)$. Working with the log-likelihood $\ell(\theta) = \log L(\theta) = \sum_i \log f(x_i; \theta)$ is equivalent (log is monotone) and numerically superior since it converts products to sums.

Deriving MLEs Analytically

Normal Distribution

For $X_i \sim N(\mu, \sigma^2)$:

$$\ell(\mu, \sigma^2) = -\frac{n}{2}\log(2\pi\sigma^2) - \frac{1}{2\sigma^2}\sum_{i=1}^n (x_i - \mu)^2$$

Setting $\partial \ell / \partial \mu = 0$ gives $\hat{\mu} = \bar{x}$ (the sample mean).

Setting $\partial \ell / \partial \sigma^2 = 0$ gives $\hat{\sigma}^2 = \frac{1}{n}\sum(x_i - \bar{x})^2$ — the biased sample variance. MLEs are not guaranteed to be unbiased, though they are consistent and asymptotically efficient.

Exponential Distribution

For $X_i \sim \text{Exponential}(\lambda)$ with density $f(x; \lambda) = \lambda e^{-\lambda x}$:

$$\ell(\lambda) = n\log\lambda - \lambda \sum x_i$$

$$\frac{\partial \ell}{\partial \lambda} = \frac{n}{\lambda} - \sum x_i = 0 \implies \hat{\lambda} = \frac{1}{\bar{x}}$$

The MLE of the rate is the reciprocal of the sample mean — the reciprocal of the MLE of the mean.

The Invariance Property

If $\hat{\theta}$ is the MLE of $\theta$, then for any function $g$, the MLE of $g(\theta)$ is $g(\hat{\theta})$. This is the invariance property of MLEs, and it is extremely convenient: the MLE of $\sigma$ (not $\sigma^2$) is $\sqrt{\hat{\sigma}^2}$.

Numerical MLE

When the log-likelihood has no closed-form maximizer, we optimize numerically.

PYTHON
import numpy as np
from scipy import optimize, stats

rng = np.random.default_rng(42)

# Generate data from a Gamma distribution
alpha_true, beta_true = 3.5, 1.5  # shape, rate
data = rng.gamma(shape=alpha_true, scale=1/beta_true, size=300)

def neg_log_lik(params):
    alpha, beta = params
    if alpha <= 0 or beta <= 0:
        return np.inf
    return -np.sum(stats.gamma.logpdf(data, a=alpha, scale=1/beta))

result = optimize.minimize(
    neg_log_lik,
    x0=[2.0, 1.0],          # initial guess
    method='Nelder-Mead',
    options={'xatol': 1e-6, 'fatol': 1e-8, 'maxiter': 10000}
)
alpha_mle, beta_mle = result.x
print(f"True:  alpha={alpha_true}, beta={beta_true}")
print(f"MLE:   alpha={alpha_mle:.4f}, beta={beta_mle:.4f}")

# Compare with scipy's built-in fit (uses MLE internally)
a_fit, loc_fit, scale_fit = stats.gamma.fit(data, floc=0)
print(f"scipy: alpha={a_fit:.4f}, beta={1/scale_fit:.4f}")

Properties of MLEs

Under regularity conditions, MLEs enjoy three asymptotic properties:

  1. Consistency: <!--MATHBLOCK24--> as <!--MATHBLOCK25-->.
  2. Asymptotic normality: <!--MATHBLOCK26-->, where <!--MATHBLOCK27--> is the Fisher information.
  3. Asymptotic efficiency: No consistent estimator has a smaller asymptotic variance.

This means that for large samples, we can approximate the distribution of any MLE as normal with variance equal to the inverse Fisher information — even without knowing the exact finite-sample distribution.

Observed vs. Expected Fisher Information

In practice, we estimate the Fisher information using the observed Fisher information — the negative Hessian of the log-likelihood evaluated at the MLE:

$$\hat{I}(\hat{\theta}) = -\frac{\partial^2 \ell}{\partial \theta^2}\bigg|_{\theta=\hat{\theta}}$$

The estimated standard error of the MLE is then $\widehat{\text{SE}}(\hat{\theta}) = 1/\sqrt{\hat{I}(\hat{\theta})}$ for scalar $\theta$. For vector parameters, the covariance matrix is approximated by $[-\nabla^2 \ell(\hat{\theta})]^{-1}$.

PYTHON
import numpy as np
from scipy import optimize, stats

rng = np.random.default_rng(7)
data = rng.exponential(scale=4.0, size=200)  # true lambda = 0.25

def log_lik(lam):
    return np.sum(np.log(lam) - lam * data)

# MLE
lam_mle = 1.0 / data.mean()
print(f"MLE: lambda = {lam_mle:.5f}")

# Observed Fisher information (second derivative of log-lik)
# d^2/dlam^2 log L = -n / lam^2
obs_fisher = len(data) / lam_mle**2
se_mle = 1.0 / np.sqrt(obs_fisher)
print(f"SE(lambda_MLE) = {se_mle:.5f}")
print(f"Approx 95% CI: ({lam_mle - 1.96*se_mle:.4f}, {lam_mle + 1.96*se_mle:.4f})")

Common Pitfalls

  • Local maxima: Numerical optimizers may converge to a local maximum. Always try multiple starting points.
  • Boundary solutions: If the MLE lies on the boundary of the parameter space (e.g., variance estimate of zero), standard asymptotic theory breaks down.
  • Model misspecification: MLE is consistent for <!--MATHBLOCK31--> only if the model is correctly specified. Under misspecification, MLE converges to the "pseudo-true" parameter that minimizes the Kullback-Leibler divergence — which may not be the true parameter.

Code Examples

Profile Likelihood and MLE Confidence Interval

Visualize the log-likelihood surface and derive a likelihood ratio confidence interval.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(3)
data = rng.binomial(n=1, p=0.35, size=100)  # Bernoulli trials

p_vals = np.linspace(0.01, 0.99, 500)
log_liks = [np.sum(data * np.log(p) + (1 - data) * np.log(1 - p)) for p in p_vals]
log_liks = np.array(log_liks)

p_mle = data.mean()
ll_mle = np.sum(data * np.log(p_mle) + (1 - data) * np.log(1 - p_mle))

# Likelihood ratio 95% CI: 2*(ll_mle - ll(p)) <= chi2(1, 0.95) = 3.841
threshold = ll_mle - 0.5 * stats.chi2.ppf(0.95, df=1)
ci_mask = log_liks >= threshold
ci_lo, ci_hi = p_vals[ci_mask].min(), p_vals[ci_mask].max()

print(f"True p:   0.35")
print(f"MLE p:    {p_mle:.4f}")
print(f"LR 95% CI: ({ci_lo:.4f}, {ci_hi:.4f})")

# Compare with Wald CI
se_wald = np.sqrt(p_mle * (1 - p_mle) / len(data))
print(f"Wald 95% CI: ({p_mle - 1.96*se_wald:.4f}, {p_mle + 1.96*se_wald:.4f})")
Output
True p:   0.35
MLE p:    0.3300
LR 95% CI: (0.2381, 0.4300)
Wald 95% CI: (0.2382, 0.4218)

MLE for Mixture Models via EM (Gaussian Mixture)

Implement the Expectation-Maximization algorithm to find MLEs for a 2-component Gaussian mixture.

PYTHON
import numpy as np

rng = np.random.default_rng(42)
# True params
pi_true = [0.4, 0.6]
mu_true = [-2.0, 3.0]
sig_true = [0.8, 1.2]

# Generate data
n = 500
z = rng.binomial(1, pi_true[1], n)
data = np.where(z == 0,
    rng.normal(mu_true[0], sig_true[0], n),
    rng.normal(mu_true[1], sig_true[1], n))

# EM algorithm
from scipy.stats import norm

pi = np.array([0.5, 0.5])
mu = np.array([-1.0, 1.0])
sig = np.array([1.0, 1.0])

for iteration in range(100):
    # E-step
    r0 = pi[0] * norm.pdf(data, mu[0], sig[0])
    r1 = pi[1] * norm.pdf(data, mu[1], sig[1])
    total = r0 + r1
    gamma0, gamma1 = r0 / total, r1 / total

    # M-step
    n0, n1 = gamma0.sum(), gamma1.sum()
    pi = np.array([n0, n1]) / n
    mu[0] = (gamma0 * data).sum() / n0
    mu[1] = (gamma1 * data).sum() / n1
    sig[0] = np.sqrt((gamma0 * (data - mu[0])**2).sum() / n0)
    sig[1] = np.sqrt((gamma1 * (data - mu[1])**2).sum() / n1)

print("EM MLEs:")
print(f"  pi:  {pi.round(3)} (true: {pi_true})")
print(f"  mu:  {mu.round(3)} (true: {mu_true})")
print(f"  sig: {sig.round(3)} (true: {sig_true})")
Output
EM MLEs:
  pi:  [0.401 0.599] (true: [0.4, 0.6])
  mu:  [-1.987  3.024] (true: [-2.0, 3.0])
  sig: [0.806 1.179] (true: [0.8, 1.2])

35.5 Standard Errors, the Bootstrap, and Confidence Intervals Advanced

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:

  1. Compute <!--MATHBLOCK9--> from the original sample of size <!--MATHBLOCK10-->.
  2. For <!--MATHBLOCK11-->: draw a bootstrap sample of size <!--MATHBLOCK12--> with replacement from the data, compute <!--MATHBLOCK13-->.
  3. 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}$$

PYTHON
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}}$$

PYTHON
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:

PYTHON
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.

PYTHON
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)")
Output
Normal 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.

PYTHON
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")
Output
Coverage in 100 draws: 96% (96/100 intervals contain mu=5)
Figure saved as ci_coverage.png