Prof. Marc Los Huertos • Pomona College
The Framework One-Sample t Two-Sample t Paired t Wilcoxon Proportions Effect Sizes p-values Misconceptions
What this module covers. Hypothesis testing is the formal machinery for deciding whether a pattern in your sample is strong enough to generalize to the population it came from. This module builds the complete workflow — from stating hypotheses through choosing a test, checking assumptions, interpreting output, and reporting results honestly — and then systematically dismantles the most common ways the framework is misunderstood.
Every hypothesis test follows the same five-step logic regardless of which specific test you use. Understanding the framework first makes every test a variation on a familiar theme rather than a new procedure to memorise.
Step 1 — State the hypotheses. Every test has exactly two complementary hypotheses. The null hypothesis (H₀) is the default claim — usually “no effect”, “no difference”, or “the parameter equals a specific value”. The alternative hypothesis (H₁) is what you would conclude if the null is implausible given the data.
Step 2 — Choose a significance level (α). Before collecting data, decide how often you are willing to reject a true null hypothesis by mistake. Convention is α = 0.05 (5%), but this is a social norm, not a law of nature. For high-stakes environmental decisions (e.g., declaring a site contaminated), you might use α = 0.01.
Step 3 — Compute the test statistic. Reduce the data to a single signal-to-noise ratio (t, F, z, W…) that measures how far the observed data are from what the null predicts.
Step 4 — Find the p-value. Ask: if H₀ were true, how often would we observe a test statistic at least as extreme as the one we computed? This probability is the p-value.
Step 5 — Make a decision and report. If p ≤ α: reject H₀ (“the data are inconsistent with no effect”). If p > α: fail to reject H₀ (“the data do not provide sufficient evidence against no effect”). Report the test statistic, degrees of freedom, p-value, and an effect size. Never report only “p < 0.05”.
No matter what we decide, we can be wrong in two distinct ways:
| H₀ is actually TRUE | H₀ is actually FALSE | |
|---|---|---|
| We reject H₀ |
Type I Error (α) False positive. We
declare an effect that does not exist. Controlled by our choice of α. |
Correct Decision ✓ True positive. We
detected a real effect. Probability = 1 − β (statistical power). |
| We fail to reject H₀ | Correct Decision ✓ True negative. We correctly concluded no effect. |
Type II Error (β) False negative. A real
effect exists but we missed it. Reduced by larger sample size. |
⚠️ The α–β trade-off
Making α smaller (stricter) reduces Type I errors but increases Type II errors — you miss more real effects. The only way to reduce both simultaneously is to collect more data. Power analysis before data collection determines the sample size needed to detect a specified effect at chosen α and β.
Question: Is the population mean equal to a specific hypothesised value?
Environmental context: The EPA Clean Water Act standard for dissolved oxygen (DO) in coldwater fisheries is 7 mg/L. We sample 22 sites to test whether the regional mean meets this standard.
set.seed(12)
# DO measurements (mg/L) at 22 stream sites
do_sites <- c(6.1, 7.4, 5.8, 6.9, 6.2, 7.1, 5.5, 6.8, 6.3, 7.0,
5.9, 6.4, 6.7, 5.6, 6.1, 7.2, 6.0, 5.7, 6.5, 6.9, 6.3, 5.8)
mu_0 <- 7 # hypothesised value (EPA standard)
n <- length(do_sites)
cat("n =", n, "\n")
## n = 22
cat("Sample mean =", round(mean(do_sites), 3), "mg/L\n")
## Sample mean = 6.373 mg/L
cat("Sample SD =", round(sd(do_sites), 3), "mg/L\n")
## Sample SD = 0.558 mg/L
cat("SE =", round(sd(do_sites)/sqrt(n), 3), "mg/L\n")
## SE = 0.119 mg/L
Always plot the data and mark the hypothesised mean before running any test.
do_df <- data.frame(site = seq_along(do_sites), DO = do_sites)
ggplot(do_df, aes(x = "", y = DO)) +
geom_hline(yintercept = mu_0, linetype = "dashed",
colour = "#bf360c", linewidth = 0.9) +
geom_boxplot(width = 0.35, fill = "#d4e6da", colour = "#1e3a2f",
outlier.shape = NA, linewidth = 0.7) +
geom_jitter(width = 0.12, size = 2.2, colour = "#4a7c5e", alpha = 0.7) +
annotate("text", x = 1.28, y = mu_0 + 0.08,
label = "EPA standard (7 mg/L)", colour = "#bf360c",
size = 3.4, hjust = 0, fontface = "italic") +
stat_summary(fun = mean, geom = "point", shape = 18,
size = 4, colour = "#c8832a") +
labs(x = NULL, y = "Dissolved oxygen (mg/L)",
title = "DO at 22 Stream Sites vs. EPA Standard") +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
axis.text.x = element_blank(),
panel.grid.major.x = element_blank()
)
DO measurements at 22 stream sites. Each point is one site; the box shows median and IQR; the dashed red line marks the EPA standard (7 mg/L). Most sites fall below the standard.
\[t = \frac{\bar{x} - \mu_0}{s / \sqrt{n}}\]
t_hand <- (mean(do_sites) - mu_0) / (sd(do_sites) / sqrt(n))
p_hand <- 2 * pt(t_hand, df = n - 1) # two-tailed
cat("t (by hand) =", round(t_hand, 4), "\n")
## t (by hand) = -5.2707
cat("p (by hand) =", round(p_hand, 5), "\n")
## p (by hand) = 3e-05
t.test(do_sites, mu = mu_0)
##
## One Sample t-test
##
## data: do_sites
## t = -5.2707, df = 21, p-value = 3.172e-05
## alternative hypothesis: true mean is not equal to 7
## 95 percent confidence interval:
## 6.125230 6.620225
## sample estimates:
## mean of x
## 6.372727
The observed t-statistic (red line) on the t(21) null distribution. The amber shaded tails are the 5% rejection region. Our t falls well into the left tail — the sample mean is significantly below the EPA standard.
A one-sample t-test indicated that the regional mean dissolved oxygen (M = 6.37 mg/L, SD = 0.56) was significantly below the EPA coldwater standard of 7 mg/L, t(21) = -5.27, p = 0.
Question: Do two independent groups have different population means?
Environmental context: We compare soil carbon stocks (Mg C/ha) under two restoration treatments — passive recovery vs. active replanting — after 10 years.
set.seed(7)
passive <- round(rnorm(18, mean = 34, sd = 6.5), 1) # Mg C/ha
active <- round(rnorm(22, mean = 41, sd = 7.2), 1)
carbon <- data.frame(
treatment = rep(c("Passive recovery", "Active replanting"),
c(length(passive), length(active))),
C_MgCha = c(passive, active)
)
ggplot(carbon, aes(treatment, C_MgCha, fill = treatment)) +
geom_boxplot(width = 0.45, outlier.shape = NA, colour = "#1e3a2f",
alpha = 0.7, linewidth = 0.6) +
geom_jitter(aes(colour = treatment), width = 0.17, size = 1.9, alpha = 0.5) +
stat_summary(fun = mean, geom = "point", shape = 18,
size = 4.5, colour = "#1e3a2f") +
scale_fill_manual(values = c("Passive recovery" = "#8aad96",
"Active replanting" = "#1e3a2f")) +
scale_colour_manual(values = c("Passive recovery" = "#4a7c5e",
"Active replanting" = "#2d5a3d")) +
labs(x = NULL, y = "Soil carbon (Mg C/ha)",
title = "Soil Carbon by Restoration Treatment (10 years)") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"),
legend.position = "none",
panel.grid.major.x = element_blank())
Soil carbon after 10 years by restoration treatment. The diamond marks the group mean. Active replanting appears to accumulate more carbon, but with considerable overlap between groups.
R’s t.test() uses Welch’s correction by
default — it does not assume equal variances between groups, making it
robust to heteroscedasticity. Only use var.equal = TRUE if
you have strong evidence that population variances are equal (Levene’s
test, or subject-matter knowledge).
(result_2t <- t.test(C_MgCha ~ treatment, data = carbon))
##
## Welch Two Sample t-test
##
## data: C_MgCha by treatment
## t = 2.2, df = 28.826, p-value = 0.03598
## alternative hypothesis: true difference in means between group Active replanting and group Passive recovery is not equal to 0
## 95 percent confidence interval:
## 0.359638 9.898948
## sample estimates:
## mean in group Active replanting mean in group Passive recovery
## 41.96818 36.83889
# Levene's test for equal variances (car package, or manual approach)
var.test(C_MgCha ~ treatment, data = carbon)
##
## F test to compare two variances
##
## data: C_MgCha by treatment
## F = 0.45695, num df = 21, denom df = 17, p-value = 0.09039
## alternative hypothesis: true ratio of variances is not equal to 1
## 95 percent confidence interval:
## 0.1757527 1.1347449
## sample estimates:
## ratio of variances
## 0.4569535
⚙️ One-tailed vs. two-tailed tests
A
two-tailed test (default) rejects H₀ if the observed
difference is extreme in either direction. Use this unless you
have a strong, pre-registered directional hypothesis.
A
one-tailed test concentrates all of α in one tail — it
has more power to detect a specific direction but will miss effects in
the other. Specify with alternative = “greater” or
“less”. One-tailed tests chosen after looking at
the data are not valid.
Question: Did the same units change between two conditions?
The paired t-test is used when each observation in group A is matched to a specific observation in group B — before/after measurements on the same site, left vs. right bank of the same stream, paired field plots. By working with differences, the paired test removes between-unit variability that would otherwise inflate the noise denominator.
Environmental context: Turbidity (NTU) measured at 16 stream sites before and after installation of riparian buffer strips (same sites, two time points).
set.seed(41)
n_sites <- 16
before <- round(rnorm(n_sites, mean = 28, sd = 9), 1) # NTU
# Buffer reduces turbidity by ~8 NTU on average, with some site-level variation
after <- round(pmax(2, before - rnorm(n_sites, mean = 8, sd = 4)), 1)
diff_ntu <- after - before
turbidity <- data.frame(site = 1:n_sites, before, after, diff = diff_ntu)
head(turbidity, 6)
## site before after diff
## 1 1 20.9 10.2 -10.7
## 2 2 29.8 18.3 -11.5
## 3 3 37.0 28.2 -8.8
## 4 4 39.6 22.5 -17.1
## 5 5 36.2 31.8 -4.4
## 6 6 32.4 15.8 -16.6
par(mfrow = c(1, 2), mar = c(4, 4.5, 3, 1), las = 1)
# Before / after connected
plot(c(0.8, 2.2), range(c(before, after)) + c(-2, 2),
type = "n", xaxt = "n",
main = "Before vs. After (paired sites)",
xlab = "", ylab = "Turbidity (NTU)", cex.main = 0.9)
axis(1, at = c(1, 2), labels = c("Before", "After"))
for (i in seq_len(n_sites)) {
lines(c(1, 2), c(before[i], after[i]),
col = adjustcolor(ifelse(diff_ntu[i] < 0, "#4a7c5e", "#c8832a"), 0.6),
lwd = 1.3)
points(c(1, 2), c(before[i], after[i]),
pch = 20, cex = 0.9,
col = adjustcolor(ifelse(diff_ntu[i] < 0, "#4a7c5e", "#c8832a"), 0.7))
}
points(c(1, 2), c(mean(before), mean(after)),
pch = 18, cex = 2, col = "#1e3a2f")
lines(c(1, 2), c(mean(before), mean(after)),
lwd = 2.5, col = "#1e3a2f")
# Differences
hist(diff_ntu, breaks = 12,
col = "#d4e6da", border = "white",
main = "Paired differences (After − Before)",
xlab = "ΔNTU", ylab = "Count", cex.main = 0.9)
abline(v = 0, col = "#c8832a", lwd = 1.5, lty = 2)
abline(v = mean(diff_ntu), col = "#1e3a2f", lwd = 2)
legend("topright",
legend = c("Zero (null)", paste0("Mean diff = ", round(mean(diff_ntu), 1))),
col = c("#c8832a", "#1e3a2f"), lty = c(2, 1), lwd = 2,
bty = "n", cex = 0.8)
Left: turbidity before and after buffer installation at each site (connected lines show individual site trajectories). Right: the paired differences — negative values mean turbidity decreased. If the null were true, differences would scatter symmetrically around zero.
par(mfrow = c(1, 1))
# Paired t-test: alternative = "less" because we predict After < Before
t.test(after, before, paired = TRUE, alternative = "less")
##
## Paired t-test
##
## data: after and before
## t = -6.6181, df = 15, p-value = 4.089e-06
## alternative hypothesis: true mean difference is less than 0
## 95 percent confidence interval:
## -Inf -6.032511
## sample estimates:
## mean difference
## -8.20625
# Identical to a one-sample t-test on the differences
t.test(diff_ntu, mu = 0, alternative = "less")
##
## One Sample t-test
##
## data: diff_ntu
## t = -6.6181, df = 15, p-value = 4.089e-06
## alternative hypothesis: true mean is less than 0
## 95 percent confidence interval:
## -Inf -6.032511
## sample estimates:
## mean of x
## -8.20625
📌 Why pairing increases power
In the unpaired version, site-to-site variation in baseline turbidity becomes noise — it makes it harder to see the treatment effect. In the paired version, we subtract out that baseline, leaving only the treatment effect plus measurement noise. The paired test always has equal or greater power than the unpaired test when the within-unit correlation is positive. It is invalid to pair data post-hoc to inflate significance.
The t-tests above assume the data (or their differences) are approximately normally distributed. When this assumption is badly violated — typically with small samples that are strongly skewed or contain outliers — the Wilcoxon tests provide non-parametric alternatives.
Instead of working with raw values, Wilcoxon tests work with ranks. This makes them robust to extreme observations and appropriate for ordinal data, but they lose some efficiency when the data actually are normal.
The non-parametric equivalent of the one-sample or paired t-test. Tests whether the median (not mean) equals a hypothesised value, or whether the median difference equals zero.
Environmental context: Heavy metal concentration (mg/kg) in a contaminated floodplain — small sample, right-skewed distribution.
set.seed(19)
# Heavy metal (Pb) — right-skewed, small n
pb_mg <- c(12, 18, 145, 23, 9, 31, 88, 14, 27, 19, 52, 11)
pb_limit <- 40 # remediation threshold (mg/kg)
n_pb <- length(pb_mg)
cat("n =", n_pb, "\n")
## n = 12
cat("Mean =", round(mean(pb_mg), 1), "mg/kg\n")
## Mean = 37.4 mg/kg
cat("Median =", median(pb_mg), "mg/kg\n")
## Median = 21 mg/kg
cat("Shapiro-Wilk p =", round(shapiro.test(pb_mg)$p.value, 4),
"— normality", ifelse(shapiro.test(pb_mg)$p.value < 0.05,
"REJECTED", "not rejected"), "\n")
## Shapiro-Wilk p = 0.001 — normality REJECTED
pb_df <- data.frame(pb = pb_mg)
ggplot(pb_df, aes(x = "", y = pb)) +
geom_hline(yintercept = pb_limit, linetype = "dashed",
colour = "#bf360c", linewidth = 0.9) +
geom_boxplot(width = 0.35, fill = "#d4e6da", colour = "#1e3a2f",
linewidth = 0.7) +
geom_jitter(width = 0.1, size = 2.5, colour = "#4a7c5e", alpha = 0.75) +
annotate("text", x = 1.28, y = pb_limit + 5,
label = "Remediation threshold (40 mg/kg)",
colour = "#bf360c", size = 3.2, hjust = 0, fontface = "italic") +
labs(x = NULL, y = "Lead concentration (mg/kg)",
title = "Pb in Floodplain Soil — Small Skewed Sample") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f"),
axis.text.x = element_blank(),
panel.grid.major.x = element_blank())
Pb concentration in a small, skewed sample. The single extreme value at 145 mg/kg violates the normality assumption that the t-test requires. The Shapiro-Wilk test confirms non-normality (p < 0.05).
# t-test: sensitive to the outlier, pulls the mean up
cat("── t-test (sensitive to outlier) ──────────────────\n")
## ── t-test (sensitive to outlier) ──────────────────
print(t.test(pb_mg, mu = pb_limit))
##
## One Sample t-test
##
## data: pb_mg
## t = -0.2205, df = 11, p-value = 0.8295
## alternative hypothesis: true mean is not equal to 40
## 95 percent confidence interval:
## 11.62994 63.20340
## sample estimates:
## mean of x
## 37.41667
cat("\n── Wilcoxon signed-rank (robust to outlier) ────────\n")
##
## ── Wilcoxon signed-rank (robust to outlier) ────────
print(wilcox.test(pb_mg, mu = pb_limit, conf.int = TRUE))
##
## Wilcoxon signed rank exact test
##
## data: pb_mg
## V = 25, p-value = 0.3013
## alternative hypothesis: true location is not equal to 40
## 95 percent confidence interval:
## 15 70
## sample estimates:
## (pseudo)median
## 24.75
The non-parametric equivalent of the two-sample t-test. Also known as the Mann-Whitney U test. Tests whether one group tends to have larger values than the other.
set.seed(5)
# Species richness on degraded vs. restored plots — count data, right-skewed
restored <- c(12, 18, 9, 24, 15, 11, 22, 8, 17, 20, 13, 27)
degraded <- c(4, 7, 3, 11, 6, 2, 9, 5, 8, 4, 6, 3)
cat("Restored median =", median(restored), " mean =", round(mean(restored), 1), "\n")
## Restored median = 16 mean = 16.3
cat("Degraded median =", median(degraded), " mean =", round(mean(degraded), 1), "\n")
## Degraded median = 5.5 mean = 5.7
wilcox.test(restored, degraded, conf.int = TRUE)
##
## Wilcoxon rank sum test with continuity correction
##
## data: restored and degraded
## W = 139.5, p-value = 0.0001074
## alternative hypothesis: true location shift is not equal to 0
## 95 percent confidence interval:
## 5.999944 15.000018
## sample estimates:
## difference in location
## 10.00006
rich_df <- data.frame(
status = rep(c("Restored", "Degraded"), c(length(restored), length(degraded))),
richness = c(restored, degraded)
)
rich_df$status <- factor(rich_df$status, levels = c("Degraded", "Restored"))
ggplot(rich_df, aes(status, richness, fill = status)) +
geom_boxplot(width = 0.45, outlier.shape = NA, colour = "#1e3a2f",
alpha = 0.8, linewidth = 0.6) +
geom_jitter(aes(colour = status), width = 0.16, size = 2, alpha = 0.6) +
scale_fill_manual(values = c("Degraded" = "#c8832a", "Restored" = "#4a7c5e")) +
scale_colour_manual(values = c("Degraded" = "#c8832a", "Restored" = "#4a7c5e")) +
labs(x = NULL, y = "Species richness",
title = "Plant Species Richness: Restored vs. Degraded Plots") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"),
legend.position = "none",
panel.grid.major.x = element_blank())
Species richness on restored vs. degraded plots. Count data are right-skewed with unequal variances — the Wilcoxon rank-sum test is preferred over the t-test here.
| Scenario | Parametric test | R code | Non-parametric alternative | R code (NP) |
|---|---|---|---|---|
| One sample vs. known value | One-sample t-test | t.test(x, mu = µ₀) |
Wilcoxon signed-rank | wilcox.test(x, mu = µ₀) |
| Two independent groups | Welch two-sample t-test | t.test(y ~ grp) |
Wilcoxon rank-sum (Mann-Whitney) | wilcox.test(x, y) or
wilcox.test(y ~ grp) |
| Paired / before–after | Paired t-test | t.test(x, y, paired = TRUE) |
Wilcoxon signed-rank on differences | wilcox.test(x, y, paired = TRUE) |
💡 When to choose Wilcoxon over t
Use the Wilcoxon test when: (1) n < 15 and the data are visibly skewed or have outliers; (2) your variable is ordinal (ranks, damage scores); or (3) Shapiro-Wilk is significant and transformation does not help. With n > 30, the t-test is robust to moderate non-normality by the Central Limit Theorem and is usually the better choice because it provides a mean estimate and confidence interval with direct physical interpretation.
When your outcome is binary — a proportion of sites that exceed a threshold, the fraction of individuals that survive a treatment — the t-test is not appropriate. Use one- and two-proportion z-tests or the exact binomial test.
Question: Does the true proportion of exceedances equal a specified value?
Environmental context: In a regulatory sample of 85 wells, 23 exceed the nitrate drinking-water limit (10 mg/L NO₃). Does this fraction differ from the statewide exceedance rate of 15%?
n_wells <- 85
x_exceed <- 23
p_0 <- 0.15 # statewide rate (null)
p_hat <- x_exceed / n_wells
cat("Observed proportion:", round(p_hat, 3), "\n")
## Observed proportion: 0.271
# Exact binomial test (preferred when np < 10)
binom.test(x_exceed, n_wells, p = p_0)
##
## Exact binomial test
##
## data: x_exceed and n_wells
## number of successes = 23, number of trials = 85, p-value = 0.003581
## alternative hypothesis: true probability of success is not equal to 0.15
## 95 percent confidence interval:
## 0.1798891 0.3778595
## sample estimates:
## probability of success
## 0.2705882
# Large-sample z-test (valid when np ≥ 10 and n(1-p) ≥ 10)
prop.test(x_exceed, n_wells, p = p_0, correct = FALSE)
##
## 1-sample proportions test without continuity correction
##
## data: x_exceed out of n_wells, null probability p_0
## X-squared = 9.6943, df = 1, p-value = 0.001848
## alternative hypothesis: true p is not equal to 0.15
## 95 percent confidence interval:
## 0.1875962 0.3734196
## sample estimates:
## p
## 0.2705882
Question: Do two independent groups have the same proportion?
Environmental context: Compare the fraction of streams exceeding copper criteria upstream vs. downstream of a mining district.
# Upstream: 8 of 40 exceed criterion. Downstream: 22 of 45.
prop.test(x = c(8, 22), n = c(40, 45), correct = FALSE)
##
## 2-sample test for equality of proportions without continuity correction
##
## data: c(8, 22) out of c(40, 45)
## X-squared = 7.7387, df = 1, p-value = 0.005405
## alternative hypothesis: two.sided
## 95 percent confidence interval:
## -0.48045281 -0.09732497
## sample estimates:
## prop 1 prop 2
## 0.2000000 0.4888889
prop_df <- data.frame(
location = c("Upstream", "Downstream"),
x = c(8, 22), n = c(40, 45)
) |>
mutate(
p = x / n,
lower = mapply(function(xi, ni) binom.test(xi, ni)$conf.int[1], x, n),
upper = mapply(function(xi, ni) binom.test(xi, ni)$conf.int[2], x, n)
)
prop_df$location <- factor(prop_df$location, levels = c("Upstream", "Downstream"))
ggplot(prop_df, aes(location, p, fill = location)) +
geom_col(width = 0.45, colour = "#1e3a2f", alpha = 0.85) +
geom_errorbar(aes(ymin = lower, ymax = upper),
width = 0.12, linewidth = 0.9, colour = "#1e3a2f") +
scale_fill_manual(values = c("Upstream" = "#4a7c5e", "Downstream" = "#7a3a2f")) +
scale_y_continuous(labels = scales::percent_format(), limits = c(0, 0.75)) +
labs(x = NULL, y = "Proportion exceeding Cu criterion",
title = "Stream Copper Exceedances Near Mining District") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"),
legend.position = "none",
panel.grid.major.x = element_blank())
Proportion of streams exceeding the copper criterion, with 95% Wilson confidence intervals. The upstream rate (~20%) and downstream rate (~49%) are clearly separated — consistent with the significant p-value.
A p-value tells you whether an effect is unlikely under the null. It does not tell you whether the effect is large enough to matter. Effect sizes are standardised measures of magnitude that are independent of sample size.
Cohen’s d expresses the difference in means as a multiple of the pooled standard deviation. It is interpretable across studies and variables.
\[d = \frac{\bar{x}_1 - \bar{x}_2}{s_{\text{pooled}}}\]
Cohen’s benchmarks: |d| = 0.2 small · 0.5 medium · 0.8 large.
cohens_d <- function(x, y) {
nx <- length(x); ny <- length(y)
sp <- sqrt(((nx - 1)*var(x) + (ny - 1)*var(y)) / (nx + ny - 2))
(mean(x) - mean(y)) / sp
}
d_carbon <- cohens_d(active, passive)
d_rich <- cohens_d(restored, degraded)
cat("Carbon (active vs. passive) d =", round(d_carbon, 3), "\n")
## Carbon (active vs. passive) d = 0.727
cat("Richness (restored vs. degraded) d =", round(d_rich, 3), "\n")
## Richness (restored vs. degraded) d = 2.271
Visualising Cohen’s d. Each panel shows two normal distributions with d = 0.2, 0.5, 0.8, and 1.4. The overlap shrinks as d grows — d = 0.2 means the distributions are nearly identical; d = 1.4 means a typical value in one group exceeds ~92% of values in the other.
For the Wilcoxon rank-sum, the effect size is: \[r = \frac{Z}{\sqrt{N}}\]
where Z is the normal approximation of the W statistic and N is the total sample size. Benchmarks: r = 0.1 small · 0.3 medium · 0.5 large.
w_result <- wilcox.test(restored, degraded, exact = FALSE)
Z_approx <- qnorm(w_result$p.value / 2)
r_effect <- abs(Z_approx) / sqrt(length(restored) + length(degraded))
cat("W =", w_result$statistic, " Z ≈", round(Z_approx, 3),
" r =", round(r_effect, 3), "\n")
## W = 139.5 Z ≈ -3.873 r = 0.791
Cohen’s h standardises the difference between two proportions via the arcsine transformation, which stabilises variance.
\[h = 2 \arcsin\!\left(\sqrt{p_1}\right) - 2 \arcsin\!\left(\sqrt{p_2}\right)\]
cohens_h <- function(p1, p2) 2*asin(sqrt(p1)) - 2*asin(sqrt(p2))
h_copper <- cohens_h(22/45, 8/40)
cat("Cohen's h =", round(h_copper, 3),
" (|h| benchmarks: 0.2 small, 0.5 medium, 0.8 large)\n")
## Cohen's h = 0.621 (|h| benchmarks: 0.2 small, 0.5 medium, 0.8 large)
| Test | Effect size | Small | Medium | Large | R calculation |
|---|---|---|---|---|---|
| t-test (two groups) | Cohen’s d | 0.2 | 0.5 | 0.8 | (mean(x)-mean(y)) / sd_pooled |
| t-test (one sample / paired) | Cohen’s d | 0.2 | 0.5 | 0.8 | (mean(x)-mu0) / sd(x) |
| Wilcoxon rank-sum | r = Z/√N | 0.1 | 0.3 | 0.5 | abs(qnorm(p/2)) / sqrt(N) |
| Proportions test | Cohen’s h | 0.2 | 0.5 | 0.8 | 2*asin(sqrt(p1)) - 2*asin(sqrt(p2)) |
The p-value is the most widely reported and most widely misunderstood quantity in applied statistics. Before examining what it is not, here is a precise statement of what it is.
The p-value is the probability of observing a test statistic at least as extreme as the one computed, given that the null hypothesis is true.
That is it. Nothing more. The following simulation illustrates the sampling behaviour of p-values — which behaves very differently from what most people expect.
When H₀ is true, p-values are uniformly distributed between 0 and 1. There is nothing special about 0.05 except our convention to use it as a threshold.
set.seed(321)
n_trials <- 10000
p_null <- replicate(n_trials, {
A <- rnorm(20, 0, 1)
B <- rnorm(20, 0, 1) # both from same distribution — null TRUE
t.test(A, B)$p.value
})
cat("Proportion p < 0.05 when H0 true:", mean(p_null < 0.05), "\n")
## Proportion p < 0.05 when H0 true: 0.0514
cat("(Expected: exactly 0.05 — these are false positives)\n")
## (Expected: exactly 0.05 — these are false positives)
Distribution of p-values from 10,000 t-tests when H₀ is exactly true. The distribution is flat (uniform) — every p-value between 0 and 1 is equally likely. The amber bar at the left (p < 0.05) contains exactly 5% of all tests — these are Type I errors. There is nothing special about values just below 0.05 vs. just above it.
P-values from two-sample t-tests across varying sample sizes (x-axis) and true effect sizes (line colour). A tiny effect (d = 0.1) stays non-significant even with n = 200 per group. A large effect (d = 1.0) reaches significance with n ≈ 10. The p-value is a function of both effect size and sample size — it is not a measure of effect importance.
Statistical hypothesis testing is routinely misinterpreted — not just by students, but in published papers and policy documents. The following misconceptions are documented in the statistics literature and directly relevant to environmental science.
The multiple comparisons problem: 10,000 simulations each running 20 independent t-tests on null data. In ~64% of simulations, at least one test produced p < 0.05 by chance alone — far exceeding the nominal 5% error rate. The amber line marks 5% (one in twenty tests); the forest-green line marks the family-wise error rate after 20 tests.
💡 The Bonferroni correction
The simplest multiple-comparisons correction: divide α by the number
of tests performed. For 20 tests at α = 0.05, use α* = 0.05/20 = 0.0025
as the per-test threshold. This controls the family-wise error rate
(probability of any false positive across all tests) at 5%. It
is conservative — the Holm-Bonferroni or Benjamini-Hochberg (FDR)
procedures have more power while still controlling errors. In R:
p.adjust(p_vector, method = "holm").
p.adjust(), and
distinguish exploratory from confirmatory analysis.
Stream conductivity (µS/cm) was measured above (c(142, 138, 155,
149, 141)) and below (c(198, 210, 225, 189, 215,
202)) an agricultural field. (a) State H₀ and H₁. (b) Check the
normality assumption with a Shapiro–Wilk test. (c) Run the appropriate
two-sample test. (d) Report the result in one sentence including the
test statistic, df, p-value, and a measure of effect size.
above <- c(142, 138, 155, 149, 141)
below <- c(198, 210, 225, 189, 215, 202)
shapiro.test(above); shapiro.test(below)
result <- t.test(above, below)
result
# Cohen's d
pooled_sd <- sqrt(((length(above)-1)*var(above) + (length(below)-1)*var(below)) /
(length(above) + length(below) - 2))
d <- (mean(below) - mean(above)) / pooled_sd
cat("Cohen's d =", round(d, 2))
H₀: μ_above = μ_below. H₁: μ_above ≠ μ_below. A Welch two-sample t-test found conductivity significantly higher below the field (t ≈ −8, p < 0.001, d ≈ 3.5 — large effect).
A colleague says: “The p-value was 0.03, so there is a 97% probability the null hypothesis is false.” Write a two-sentence correction explaining what p = 0.03 actually means.
A p-value is the probability of observing a test statistic this extreme (or more extreme) if the null hypothesis were true — not the probability that H₀ is false. p = 0.03 means that, assuming H₀, we would see a result this extreme in 3% of random samples; it says nothing about the probability that H₀ is true or false.
A study compares soil organic carbon in plots treated with compost (5
measurements) vs. control (5 measurements). The compost group contains
one extreme outlier. (a) Why might a Wilcoxon rank-sum test be
preferable here? (b) Run both t.test() and
wilcox.test() on simulated data and compare p-values.
set.seed(7)
ctrl <- c(2.1, 2.4, 2.0, 1.9, 2.3)
compost <- c(3.1, 3.4, 2.9, 3.2, 15.0) # outlier at 15
t.test(compost, ctrl)
wilcox.test(compost, ctrl)
The Wilcoxon test is not affected by the outlier because it works on ranks, not raw values. With a small n and an outlier, the t-test’s normality assumption is violated; the Wilcoxon test gives a more reliable p-value.