Bootstrapping and Permutation Tests

Marc Los Huertos

EA030 · Compiled 2026-10-24

Concepts Bootstrap CIs Bootstrap Statistics Permutation Tests When to Use Exercises

By the end of this module you will be able to
  1. Explain how bootstrapping and permutation testing use resampling as an alternative to distributional assumptions.
  2. Construct bootstrap confidence intervals for any statistic — median, correlation, regression slope — using base R.
  3. Run a permutation test for group differences and interpret the resulting p-value.
  4. Decide when resampling methods are preferable to classical parametric tests.
  5. Apply these methods to real environmental data with small samples or unusual distributions.

What this module covers. Classical statistics derives p-values and confidence intervals from mathematical distributions — the t-distribution, F-distribution, chi-square. These derivations require assumptions: normality, equal variances, large samples. Resampling methods sidestep the assumptions entirely by using the data itself as the reference distribution. If you can measure it, you can resample it. This module builds both techniques from scratch in base R before introducing the boot package for production use.


1. Core Concepts

1.1 The problem with parametric assumptions

Consider measuring dissolved oxygen at 8 stream sites. You want a 95% confidence interval for the median DO. The t-interval applies to means, not medians. There is no simple formula for the SE of a median. What do you do?

Resampling answers: use the data to simulate the sampling process.

1.2 Bootstrapping — resampling with replacement

The idea: your observed sample of \(n\) values is the best approximation you have of the population. Repeatedly draw new samples of size \(n\) with replacement from your observed data, compute your statistic each time, and use the distribution of those statistics as an approximation of the true sampling distribution.

## Original sample (n=8): 7.2 6.8 8.1 5.9 7.4 6.3 8.8 7
## One bootstrap resample: 7.2 7.4 7.2 7.2 6.8 5.9 6.8 6.8
## Another resample:       7.2 7 8.8 7 8.8 5.9 7.2 7.4

With replacement means… In a bootstrap resample of n = 8, each of the original 8 observations has a 1/8 chance of being drawn on each draw. Some values appear 0 times, some once, some twice or more. On average, about 63.2% of the original observations appear at least once in any given resample.

1.3 Permutation testing — resampling without replacement

The idea: if the null hypothesis is true (e.g., group A and group B have the same mean), then the group labels are arbitrary — any assignment of observations to groups is equally likely. Randomly shuffle the labels many times, computing the test statistic each time, to build the null distribution. Compare your observed statistic to this null distribution.

## Observed mean difference: 3.4 mg/L
## One permutation:
##   Shuffled A: 8.2 8.9 11.4   mean = 9.5
##   Shuffled B: 9.4 12.1 13.2   mean = 11.57

2. Bootstrap Confidence Intervals

2.1 CI for the median — a case where formulas fail

We have 15 stream temperature readings (deg C) from a summer survey. The distribution is right-skewed, so the median is more appropriate than the mean. There is no standard formula for the SE of a median.

set.seed(7)
stream_temp <- round(c(
  rnorm(12, mean = 18, sd = 2),
  c(24.1, 26.3, 25.8)          # three hot-day outliers
), 1)

cat("n =", length(stream_temp), "\n")
## n = 15
cat("Mean:  ", round(mean(stream_temp), 2), "deg C\n")
## Mean:   20.03 deg C
cat("Median:", median(stream_temp), "deg C\n")
## Median: 18.7 deg C
set.seed(123)
B        <- 5000
boot_med <- replicate(B, median(sample(stream_temp, replace = TRUE)))

ci_pct <- quantile(boot_med, c(0.025, 0.975))
cat("Bootstrap 95% CI for median:", round(ci_pct, 2), "deg C\n")
## Bootstrap 95% CI for median: 16.6 22.6 deg C
cat("Observed median:", median(stream_temp), "deg C\n")
## Observed median: 18.7 deg C
hist(boot_med,
     breaks = 40, col = "#4a7c5e", border = "white",
     main   = "Bootstrap Distribution of the Median",
     xlab   = "Bootstrap median (deg C)",
     ylab   = "Frequency")
abline(v = ci_pct, col = "#c8832a", lty = 2, lwd = 2)
abline(v = median(stream_temp), col = "#1a1a18", lwd = 2)
legend("topright",
       legend = c("Observed median", "95% CI"),
       col    = c("#1a1a18", "#c8832a"),
       lty    = c(1, 2), lwd = 2, bty = "n", cex = 0.85)
Histogram of 5000 bootstrap median values with two dashed vertical lines marking the 2.5th and 97.5th percentiles.

Bootstrap distribution of the sample median (B = 5000 resamples). The histogram approximates the sampling distribution of the median; the dashed lines mark the 95% percentile CI.

2.2 The percentile method vs. the BCa method

The simplest CI is the percentile method: take the 2.5th and 97.5th percentiles of the bootstrap distribution directly. It works well when the bootstrap distribution is symmetric and unbiased.

For skewed statistics or small samples, the bias-corrected and accelerated (BCa) interval is more accurate. The boot package implements this automatically.

library(boot)

# boot() requires a function(data, indices) that returns the statistic
median_stat <- function(data, indices) median(data[indices])

boot_obj <- boot(stream_temp, statistic = median_stat, R = 5000)
boot_obj
## 
## ORDINARY NONPARAMETRIC BOOTSTRAP
## 
## 
## Call:
## boot(data = stream_temp, statistic = median_stat, R = 5000)
## 
## 
## Bootstrap Statistics :
##     original  bias    std. error
## t1*     18.7 0.60514    1.813746
# BCa confidence interval
boot.ci(boot_obj, type = "bca")
## BOOTSTRAP CONFIDENCE INTERVAL CALCULATIONS
## Based on 5000 bootstrap replicates
## 
## CALL : 
## boot.ci(boot.out = boot_obj, type = "bca")
## 
## Intervals : 
## Level       BCa          
## 95%   (16.6, 22.6 )  
## Calculations and Intervals on Original Scale

2.3 Bootstrap CI for a regression slope

The bootstrap is particularly valuable for regression when residuals are non-normal or samples are small. Here we use PM2.5 data to estimate the relationship between temperature and ozone, with bootstrap CIs on the slope.

data(airquality)
aq <- airquality[complete.cases(airquality[, c("Ozone", "Temp")]), ]

# Observed slope
lm_obs   <- lm(Ozone ~ Temp, data = aq)
slope_obs <- coef(lm_obs)["Temp"]

# Bootstrap: resample rows
set.seed(99)
boot_slope <- replicate(5000, {
  idx  <- sample(nrow(aq), replace = TRUE)
  coef(lm(Ozone ~ Temp, data = aq[idx, ]))["Temp"]
})

ci_slope <- quantile(boot_slope, c(0.025, 0.975))
ci_param <- confint(lm_obs)["Temp", ]

cat("Observed slope:          ", round(slope_obs, 3), "\n")
## Observed slope:           2.429
cat("Bootstrap 95% CI:        ", round(ci_slope, 3), "\n")
## Bootstrap 95% CI:         2.086 2.804
cat("Parametric 95% CI (lm):  ", round(ci_param, 3), "\n")
## Parametric 95% CI (lm):   1.967 2.891
hist(boot_slope,
     breaks = 50, col = "#4a7c5e", border = "white",
     main = "Bootstrap Distribution: Ozone ~ Temp Slope",
     xlab = "Slope (ppb per deg F)", ylab = "Frequency")
abline(v = ci_slope,    col = "#c8832a", lty = 2, lwd = 2)
abline(v = slope_obs,   col = "#1a1a18", lwd = 2)
abline(v = ci_param,    col = "#1565c0", lty = 3, lwd = 1.5)
legend("topright",
       legend = c("Observed", "Bootstrap 95% CI", "Parametric 95% CI"),
       col    = c("#1a1a18", "#c8832a", "#1565c0"),
       lty    = c(1, 2, 3), lwd = 2, bty = "n", cex = 0.8)
Histogram of 5000 bootstrap slope estimates with dashed CI lines and a vertical line at the observed OLS slope.

Bootstrap distribution of the OLS slope for Ozone ~ Temp in the airquality dataset. The dashed lines show the 95% percentile CI. Compare with the parametric CI from confint(lm(…)).

📌 When bootstrap and parametric CIs agree If residuals are approximately normal and the sample is reasonably large (n > 30), bootstrap and parametric CIs will be nearly identical. Agreement is reassuring — it tells you the parametric assumptions are not distorting your inference. Disagreement is a signal to prefer the bootstrap.


3. Bootstrapping Other Statistics

3.1 Bootstrap CI for Pearson r

Pearson correlation is sensitive to outliers and assumes bivariate normality. The bootstrap CI sidesteps both issues.

aq_cc <- airquality[complete.cases(airquality[, c("Wind", "Ozone")]), ]
r_obs <- cor(aq_cc$Wind, aq_cc$Ozone)

set.seed(55)
boot_r <- replicate(5000, {
  idx <- sample(nrow(aq_cc), replace = TRUE)
  cor(aq_cc$Wind[idx], aq_cc$Ozone[idx])
})

ci_r <- quantile(boot_r, c(0.025, 0.975))
cat("Observed r:", round(r_obs, 3), "\n")
## Observed r: -0.602
cat("Bootstrap 95% CI:", round(ci_r, 3), "\n")
## Bootstrap 95% CI: -0.695 -0.492
# Compare with Fisher z parametric CI
n_r   <- nrow(aq_cc)
z_r   <- atanh(r_obs)
se_z  <- 1 / sqrt(n_r - 3)
ci_z  <- tanh(z_r + c(-1, 1) * 1.96 * se_z)
cat("Fisher z 95% CI:", round(ci_z, 3), "\n")
## Fisher z 95% CI: -0.706 -0.471
hist(boot_r,
     breaks = 40, col = "#4a7c5e", border = "white",
     main = "Bootstrap Distribution: cor(Wind, Ozone)",
     xlab = "Pearson r", ylab = "Frequency")
abline(v = ci_r,  col = "#c8832a", lty = 2, lwd = 2)
abline(v = r_obs, col = "#1a1a18", lwd = 2)
Histogram of 5000 bootstrap correlation values showing slight left skew.

Bootstrap distribution of Pearson r between Wind and Ozone in the airquality dataset. Skewness in the bootstrap distribution suggests the Fisher z parametric CI may differ.

3.2 Bootstrap CI for a ratio

Ratios are common in environmental science — N:P ratios, species diversity indices, per-capita emissions. Their sampling distributions are rarely normal. The bootstrap handles them without any transformation.

# Ratio of mean upstream to mean downstream nitrate (simulated)
set.seed(14)
upstream   <- rnorm(12, mean = 0.8, sd = 0.3)  # mg/L NO3
downstream <- rnorm(12, mean = 2.1, sd = 0.8)

ratio_obs <- mean(upstream) / mean(downstream)
cat("Observed ratio (up/down):", round(ratio_obs, 3), "\n")
## Observed ratio (up/down): 0.407
boot_ratio <- replicate(5000, {
  i <- sample(12, replace = TRUE)
  mean(upstream[i]) / mean(downstream[i])
})

ci_ratio <- quantile(boot_ratio, c(0.025, 0.975))
cat("Bootstrap 95% CI:", round(ci_ratio, 3), "\n")
## Bootstrap 95% CI: 0.331 0.496

4. Permutation Tests

4.1 Logic of the permutation test

A permutation test answers: if the null hypothesis were true and group membership were arbitrary, how often would we see a difference as large as the one we observed just by chance?

The p-value is simply:

\[p = \frac{\text{number of permutations with } |T^*| \geq |T_{\text{obs}}|}{B}\]

No distributional assumptions. No minimum sample size. Valid for any test statistic you can compute.

4.2 Two-sample permutation test

Stream nitrate concentrations upstream and downstream of an agricultural field. The t-test requires normality; with n = 7 and n = 8, that is hard to verify. The permutation test makes no such requirement.

set.seed(42)
upstream_no3   <- c(0.42, 0.38, 0.51, 0.44, 0.47, 0.39, 0.45)
downstream_no3 <- c(1.82, 2.10, 1.95, 2.24, 1.88, 2.01, 1.77, 2.15)

obs_diff <- mean(downstream_no3) - mean(upstream_no3)
cat("Observed mean difference:", round(obs_diff, 3), "mg/L\n")
## Observed mean difference: 1.553 mg/L
# Pool all observations
all_no3 <- c(upstream_no3, downstream_no3)
n_up    <- length(upstream_no3)
n_dn    <- length(downstream_no3)
n_tot   <- n_up + n_dn

# Permutation distribution
B <- 9999
perm_diffs <- replicate(B, {
  perm   <- sample(all_no3)
  mean(perm[(n_up + 1):n_tot]) - mean(perm[1:n_up])
})

p_perm <- (sum(abs(perm_diffs) >= abs(obs_diff)) + 1) / (B + 1)
cat("Permutation p-value (two-tailed):", round(p_perm, 4), "\n")
## Permutation p-value (two-tailed): 1e-04
cat("t-test p-value (reference):      ",
    round(t.test(downstream_no3, upstream_no3)$p.value, 4), "\n")
## t-test p-value (reference):       0
hist(perm_diffs,
     breaks = 60, col = "#8aad96", border = "white",
     main = "Permutation Null Distribution: Downstream - Upstream Nitrate",
     xlab = "Permuted mean difference (mg/L)", ylab = "Frequency")
abline(v = c(-obs_diff, obs_diff), col = "#c8832a", lty = 2, lwd = 2)
legend("topright",
       legend = c(paste0("Observed |diff| = ", round(obs_diff, 2)),
                  paste0("p = ", round(p_perm, 4))),
       col    = c("#c8832a", NA), lty = c(2, NA),
       bty    = "n", cex = 0.85)
Histogram of 9999 permuted mean differences centered on zero with the observed difference marked as a vertical dashed line far in the right tail.

Permutation null distribution for the difference in mean nitrate between downstream and upstream sites (B = 9999 permutations). The observed difference (dashed amber line) falls far into the tail, giving a small p-value.

4.3 Permutation test for correlation

Does PM2.5 correlate with temperature? A permutation test on r directly answers the question without the bivariate normality assumption.

data(airquality)
aq_c <- airquality[complete.cases(airquality[, c("Temp", "Ozone")]), ]

obs_r <- cor(aq_c$Temp, aq_c$Ozone)
cat("Observed r:", round(obs_r, 3), "\n")
## Observed r: 0.698
set.seed(77)
perm_r <- replicate(9999,
  cor(sample(aq_c$Temp), aq_c$Ozone)   # shuffle Temp, keep Ozone fixed
)

p_r <- (sum(abs(perm_r) >= abs(obs_r)) + 1) / (9999 + 1)
cat("Permutation p-value:", round(p_r, 4), "\n")
## Permutation p-value: 1e-04
cat("cor.test p-value:   ", round(cor.test(aq_c$Temp, aq_c$Ozone)$p.value, 4), "\n")
## cor.test p-value:    0

4.4 Randomisation test for ANOVA (multiple groups)

For three or more groups, permute the group labels and compute the F-statistic each time to build a null distribution.

# Divide airquality into three seasonal blocks
aq_c$season <- cut(aq_c$Month,
                   breaks = c(4, 6, 8, 9),
                   labels = c("May-Jun", "Jul-Aug", "Sep"),
                   include.lowest = TRUE)
aq_s <- aq_c[!is.na(aq_c$season), ]

obs_F <- summary(aov(Ozone ~ season, data = aq_s))[[1]]["season", "F value"]
cat("Observed F:", round(obs_F, 3), "\n")
## Observed F: 17.197
set.seed(33)
perm_F <- replicate(4999, {
  aq_s$season_perm <- sample(aq_s$season)
  summary(aov(Ozone ~ season_perm, data = aq_s))[[1]]["season_perm", "F value"]
})

p_F <- (sum(perm_F >= obs_F) + 1) / (4999 + 1)
cat("Permutation p-value:", round(p_F, 4), "\n")
## Permutation p-value: 2e-04
cat("Parametric ANOVA p: ",
    round(summary(aov(Ozone ~ season, data = aq_s))[[1]]["season", "Pr(>F)"], 4), "\n")
## Parametric ANOVA p:  0
hist(perm_F,
     breaks = 50, col = "#8aad96", border = "white",
     main = "Permutation F-test: Ozone by Season",
     xlab = "Permuted F-statistic", ylab = "Frequency")
abline(v = obs_F, col = "#c8832a", lty = 2, lwd = 2)
Histogram of permuted F-statistics with the observed F marked by a dashed vertical line in the right tail.

Permutation null distribution of the F-statistic for comparing ozone across three seasonal groups. The observed F (amber dashed) is well into the tail.


5. When to Use Resampling Methods

Decision guide: when to use resampling vs parametric methods.
Situation Recommendation Note
Small sample (n < 20) Prefer permutation test or BCa bootstrap Parametric p-values rely on CLT which requires larger n
Non-normal distribution Prefer bootstrap or permutation over t/F Especially for medians, quantiles, ranges
No formula for SE of your statistic Bootstrap is the only option e.g., median, trimmed mean, Gini coefficient
Outliers you cannot remove Bootstrap is more robust than parametric Bootstrap is affected by outliers but less than t-test
Complex estimator (ratio, index, custom function) Bootstrap — trivially extensible to any function Just change the statistic function inside replicate()
Large sample, roughly normal Parametric and bootstrap agree; use either Bootstrap gives you a useful check regardless

⚠️ Bootstrap does not fix a bad sample The bootstrap assumes your original sample is representative of the population. If your data are biased (e.g., only sunny days, only accessible sites), the bootstrap will faithfully reproduce that bias. More resamples cannot compensate for a non-representative original sample.

💡 How many resamples? For CIs: B = 1000 is usually sufficient for the percentile method; B = 5000 gives stable BCa intervals. For p-values: B = 9999 is conventional (allows p as small as 0.0001). For publication: B = 10000 or more.


🔑 Key Takeaways


✏️ Practice Exercises
Exercise 1 — Bootstrap CI for the mean

Using the built-in rivers dataset (lengths of major North American rivers in miles), compute: (a) the sample mean and a classical 95% t-interval, (b) a bootstrap 95% percentile CI (B = 5000), and (c) a BCa interval using boot(). Plot the bootstrap distribution. Do the three intervals agree? What does the distribution shape tell you about whether the t-interval is appropriate?

Show solution
data(rivers)
n <- length(rivers)
# Classical t-interval
t_ci <- t.test(rivers)$conf.int
cat("t-interval:", round(t_ci, 1), "\n")

# Bootstrap percentile
set.seed(1)
boot_mean <- replicate(5000, mean(sample(rivers, replace = TRUE)))
pct_ci <- quantile(boot_mean, c(0.025, 0.975))
cat("Bootstrap percentile CI:", round(pct_ci, 1), "\n")

# BCa via boot package
library(boot)
b <- boot(rivers, function(d, i) mean(d[i]), R = 5000)
boot.ci(b, type = "bca")

hist(boot_mean, breaks = 40, col = "#4a7c5e", border = "white",
     main = "Bootstrap distribution of mean river length",
     xlab = "Mean (miles)")
abline(v = pct_ci, col = "#c8832a", lty = 2, lwd = 2)

Rivers is strongly right-skewed (many short rivers, a few very long ones). The bootstrap distribution should also show right skew. The t-interval and bootstrap CI will differ noticeably — the bootstrap is preferable here because the t-interval’s normality assumption is violated.

Exercise 2 — Permutation test for two groups

The InsectSprays dataset records insect counts under six spray treatments. Extract sprays A and C only. (a) Plot the distributions. (b) Run a permutation test for the difference in means (B = 9999). (c) Compare to t.test() and wilcox.test(). Which p-value is most appropriate and why?

Show solution
data(InsectSprays)
ac <- InsectSprays[InsectSprays$spray %in% c("A", "C"), ]
ac$spray <- droplevels(ac$spray)

boxplot(count ~ spray, data = ac, col = c("#4a7c5e","#8aad96"),
        main = "Insect counts: Spray A vs C")

obs_d <- diff(tapply(ac$count, ac$spray, mean))  # C - A
set.seed(5)
perm_d <- replicate(9999, {
  shuf <- sample(ac$count)
  diff(tapply(shuf, ac$spray, mean))
})
p_perm <- (sum(abs(perm_d) >= abs(obs_d)) + 1) / 10000
cat("Permutation p:", round(p_perm, 4), "\n")
cat("t-test p:     ", round(t.test(count~spray,data=ac)$p.value, 4), "\n")
cat("Wilcoxon p:   ", round(wilcox.test(count~spray,data=ac)$p.value, 4), "\n")

Insect count data are non-negative integers with right skew. The permutation test makes no distributional assumption and is most appropriate here. Wilcoxon is also valid. The t-test may produce a similar p-value due to large enough n per group, but its assumptions are formally violated.

Exercise 3 — Bootstrap CI for a diversity index

Simulate species counts from 5 sites: set.seed(8); counts <- rpois(5, lambda=4). Compute Shannon diversity H = -sum(p * log(p)) where p are the proportional abundances. (a) Write a function that computes H. (b) Get a bootstrap 95% percentile CI for H (B = 5000). (c) Why is there no standard formula for the SE of H that works in all cases?

Show solution
set.seed(8)
counts <- rpois(5, lambda = 4) + 1   # +1 to avoid zero counts

shannon <- function(x) {
  p <- x / sum(x)
  p <- p[p > 0]
  -sum(p * log(p))
}

cat("Observed H:", round(shannon(counts), 3), "\n")

set.seed(22)
boot_H <- replicate(5000, {
  idx <- sample(length(counts), replace = TRUE)
  shannon(counts[idx])
})
cat("Bootstrap 95% CI:", round(quantile(boot_H, c(0.025, 0.975)), 3), "\n")

The standard formula for SE(H) assumes large samples and that the proportions are stable — neither is true with n = 5 sites. The bootstrap makes no such assumption and correctly reflects the high uncertainty that comes from a small sample.

Exercise 4 — Compare bootstrap and t-test under normality

Generate set.seed(3); x <- rnorm(50, mean=10, sd=2). (a) Compute the t-interval for the mean. (b) Compute a bootstrap percentile CI (B = 5000). (c) How similar are they? (d) Now repeat with x <- rexp(50, rate=0.1) (exponential, strongly skewed). How do the intervals compare now? What does this demonstrate?

Show solution
# Normal case
set.seed(3)
x_norm <- rnorm(50, mean = 10, sd = 2)
t.test(x_norm)$conf.int
quantile(replicate(5000, mean(sample(x_norm, replace=TRUE))), c(.025,.975))

# Skewed case
set.seed(3)
x_exp <- rexp(50, rate = 0.1)
t.test(x_exp)$conf.int
quantile(replicate(5000, mean(sample(x_exp, replace=TRUE))), c(.025,.975))

For the normal data the two CIs are nearly identical — this confirms parametric and bootstrap methods agree when assumptions are met. For the exponential data they diverge, with the bootstrap CI being asymmetric (wider on the right), correctly reflecting the skew. The t-interval is symmetric by construction and misrepresents the true uncertainty for skewed data.