2. Bootstrap Prediction Intervals for Regression
Why Two Bootstrap Schemes Behave Differently
A regression model's prediction interval must account for two distinct sources of uncertainty: uncertainty in the fitted mean function (parameter uncertainty) and the irreducible noise around that mean. The residual bootstrap and the case bootstrap attack these sources in different ways, and that difference is exactly what we want to measure in this case.
- Residual bootstrap holds the design matrix
Xfixed, refits the model ony* = ŷ + r*wherer*is a resample of the fitted residuals, and rebuilds the interval from the spread ofŷ* + r*at each query point. Because the design is fixed, it isolates noise uncertainty cleanly and is stable when the design has high leverage points. - Case bootstrap resamples whole rows
(x_i, y_i), refits, and rebuilds the interval from the spread ofŷ*plus a residual term. It captures joint uncertainty in the design and the response, but it can be unstable when the sample is small or when a few rows dominate the fit.
The practical question is not which is "correct" but which gives stable width and honest coverage on held-out data for the model you actually have. We will build both, run them on the same data, and compare.
Building Both Intervals on the Same Data
We use a synthetic regression with a known noise level so that coverage can be checked against a large independent test set. The code below is self-contained: it generates data, fits OLS, runs both bootstrap schemes with B = 1000 replicates, and reports empirical coverage and mean interval width at a 90% nominal level.
import numpy as np
rng = np.random.default_rng(42)
def make_data(n, rng):
x = rng.uniform(-2, 2, n)
y = 1.5 + 0.8 * x - 0.4 * x**2 + rng.normal(0, 0.6, n)
return x, y
def design(x):
return np.column_stack([np.ones_like(x), x, x**2])
def fit(X, y):
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
return beta
def residual_bootstrap(X, y, x0, B=1000, alpha=0.10, rng=None):
beta = fit(X, y)
yhat = X @ beta
resid = y - yhat
X0 = design(x0)
preds = np.empty((B, len(x0)))
for b in range(B):
r_star = rng.choice(resid, size=len(y), replace=True)
y_star = yhat + r_star
beta_star = fit(X, y_star)
preds[b] = X0 @ beta_star + rng.choice(resid, size=len(x0), replace=True)
lo = np.quantile(preds, alpha / 2, axis=0)
hi = np.quantile(preds, 1 - alpha / 2, axis=0)
return lo, hi
def case_bootstrap(X, y, x0, B=1000, alpha=0.10, rng=None):
n = len(y)
X0 = design(x0)
preds = np.empty((B, len(x0)))
for b in range(B):
idx = rng.integers(0, n, n)
beta_star = fit(X[idx], y[idx])
resid_star = y[idx] - X[idx] @ beta_star
preds[b] = X0 @ beta_star + rng.choice(resid_star, size=len(x0), replace=True)
lo = np.quantile(preds, alpha / 2, axis=0)
hi = np.quantile(preds, 1 - alpha / 2, axis=0)
return lo, hi
# Train / test split
x_tr, y_tr = make_data(200, rng)
x_te, y_te = make_data(2000, rng)
X_tr, X_te = design(x_tr), design(x_te)
lo_r, hi_r = residual_bootstrap(X_tr, y_tr, x_te, rng=rng)
lo_c, hi_c = case_bootstrap(X_tr, y_tr, x_te, rng=rng)
def report(name, lo, hi, y):
cover = np.mean((y >= lo) & (y <= hi))
width = np.mean(hi - lo)
print(f"{name:20s} coverage={cover:.3f} mean_width={width:.3f}")
report("residual bootstrap", lo_r, hi_r, y_te)
report("case bootstrap", lo_c, hi_c, y_te)
With the seed above, the residual bootstrap typically lands near 0.90 coverage with a mean width around 2.0, while the case bootstrap is close but slightly wider and more variable across seeds. Treat those numbers as expected behavior for this synthetic setup, not as a benchmark — the point is the comparison protocol, which you should rerun with your own data.
Reading the Comparison Correctly
Coverage alone is not enough. A method that returns [−∞, +∞] has perfect coverage and zero usefulness. The two diagnostics that matter together are:
| Diagnostic | What it tells you | Failure symptom |
|---|---|---|
| Empirical coverage on held-out data | Whether the nominal level is honest | Coverage far below 0.90 → intervals too narrow; far above → too conservative |
| Mean interval width | Efficiency at the same coverage | Width blows up at the edges of the design → leverage sensitivity |
| Width stability across seeds | Reproducibility of the resampling scheme | Case bootstrap width varies by >10% across seeds on small n |
| Width vs. x profile | Whether uncertainty tracks the design | Flat width where the model is extrapolating → understated uncertainty |
When Each Scheme Is the Right Choice
Use the residual bootstrap when the design is fixed by the experiment (designed experiments, sensor sweeps at preset levels) and you want a clean read on noise-driven uncertainty. Use the case bootstrap when rows are exchangeable samples from a population and you want the interval to reflect both parameter and design variability — but check width stability first, and fall back to residual bootstrap if the case version is erratic. If neither is stable at your sample size, that is a signal to increase B, collect more data, or move to a method with finite-sample guarantees rather than to tune the bootstrap further.