Hypothesis Testing

Marc Los Huertos

EA030 · Compiled 2026-04-14

The Framework One-Sample t Two-Sample t Paired t Wilcoxon Proportions Effect Sizes p-values Misconceptions

By the end of this module you will be able to
  1. State null and alternative hypotheses in plain language and formal notation.
  2. Run one-sample, two-sample, and paired t-tests in R and interpret the output.
  3. Explain what a p-value means — and what it does not mean.
  4. Identify and avoid the four most common p-value misconceptions.
  5. Choose between parametric and non-parametric tests based on assumption checks.
  6. Compute and interpret Cohen’s d as an effect size.

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.


1. The Hypothesis Testing Framework

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.

The five steps

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

Type I and Type II errors

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


2. One-Sample t-Test

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.

Null Hypothesis H₀
µ = 7 mg/L  —  the regional mean DO equals the standard
Alternative Hypothesis H₁
µ ≠ 7 mg/L  —  the regional mean differs from the standard (two-tailed)
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

Step 1 — Visualise against the null value

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.

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.

Step 2 — Compute by hand, then verify

\[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

Step 3 — Visualise the t-statistic on the null distribution

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.

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.

Step 4 — Report

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.


3. Two-Sample t-Test

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.

Null Hypothesis H₀
µpassive = µactive
Alternative Hypothesis H₁
µpassive ≠ µactive  (two-tailed)
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.

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.

Welch’s t-test (the safe default)

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.


4. Paired t-Test

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

Null Hypothesis H₀
µdifference = 0  —  buffers have no effect on turbidity
Alternative Hypothesis H₁
µdifference < 0  —  turbidity is lower after installation (one-tailed)
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.

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.


5. Wilcoxon Tests: When Normality Fails

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.

Wilcoxon signed-rank test (one-sample / paired)

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

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

Wilcoxon rank-sum test (two independent groups)

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.

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.

t-test and Wilcoxon test selection guide.
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.


6. Tests for Proportions

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.

One-proportion z-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%?

Null Hypothesis H₀
p = 0.15  —  exceedance rate equals the statewide rate
Alternative Hypothesis H₁
p ≠ 0.15  —  exceedance rate differs from statewide (two-tailed)
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

Two-proportion z-test

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.

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.


7. Effect Sizes: How Big Is the Effect?

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.

Large−
Medium−
Small−
≈0
Small+
Medium+
Large+

Cohen’s d (for t-tests)

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.

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.

r (for Wilcoxon tests)

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

h (for proportions tests)

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)
Standardised effect sizes by test type, with Cohen’s conventional benchmarks.
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))

8. Understanding p-values

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.

P-values are uniform under the null

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.

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.

How sample size and effect size jointly determine p

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.

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.


9. Common Misconceptions

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.

Myth 1
“p = 0.03 means there is a 3% probability that the null hypothesis is true.”
Reality: The p-value is a probability about data, not about hypotheses. It says: if H₀ were true, data at least this extreme would arise 3% of the time. It says nothing about the probability that H₀ is or is not true — that would require Bayesian reasoning with a prior. The null is either true or not; it has no probability in the frequentist framework.
Myth 2
“p > 0.05 means the groups are the same / the null is true.”
Reality: Failing to reject H₀ is not the same as accepting H₀. It means the data are insufficient to rule out chance. With small samples, we have low power — we may miss a real effect entirely. Always compute power or a confidence interval to understand what you can rule out. The phrase “no significant difference” should never be reported without also stating the sample size and effect size confidence interval.
Myth 3
“A smaller p-value means a larger or more important effect.”
Reality: P-values shrink as sample size grows, regardless of effect size. With n = 10,000, a biologically trivial difference (d = 0.01) will be statistically significant at p < 0.001. With n = 5, a large effect (d = 1.0) may fail to reach significance. Always report an effect size alongside the p-value. Significance is about rarity under H₀; importance is about the size of the effect relative to what matters in your field.
Myth 4
“p = 0.049 and p = 0.051 lead to completely different conclusions.”
Reality: The p = 0.05 threshold is a convention, not a physical law. The difference between p = 0.049 and p = 0.051 is no more meaningful than the difference between p = 0.38 and p = 0.40 — both are well within sampling variation. Report exact p-values and confidence intervals; do not use language that treats 0.05 as a sharp cliff. Many journals now encourage reporting p values precisely (p = 0.048) rather than as thresholds (p < 0.05).
Myth 5
“I can run multiple tests on the same dataset and report only the significant ones.”
Reality: Each test at α = 0.05 has a 5% false-positive rate. Running 20 independent tests on a dataset where nothing is truly different produces on average one significant result by chance. This is the multiple comparisons problem. Pre-register your hypotheses, correct for multiple comparisons (Bonferroni, Holm, or false-discovery-rate correction), or treat exploratory tests as hypothesis generation rather than confirmation.
Myth 6
“A statistically significant result means my study will replicate.”
Reality: A single p < 0.05 result in a low-powered study has a poor replication probability — often below 50%. The positive predictive value of a significant result depends on the prior probability of the effect being real, study power, and the false-positive rate. Small-n studies in ecology that just clear p = 0.05 are particularly likely to produce inflated effect-size estimates that do not replicate (the “winner’s curse”). Pre-register, report CIs, and treat replication as the standard of evidence.
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 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").


🔑 Hypothesis testing — key takeaways


What’s next?

✏️ Practice Exercises
Exercise 1 — Two-sample t-test

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.

Show solution
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).

Exercise 2 — Misconception check

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.

Show solution

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.

Exercise 3 — Non-parametric alternative

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.

Show solution
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.