Prof. Marc Los Huertos • Pomona College
What this module covers. Every statistical test you will encounter in environmental science fits into one of four frameworks determined by a single question: what type of variable is your outcome (dependent), and what type of variable is your predictor (independent)? This module maps the landscape, then walks through each framework in depth with environmental examples, diagnostic plots, and worked R code.
Before running any analysis, ask two questions:
What kind of variable is my dependent (response) variable? Is it a measurement on a continuous scale, or does it represent a category or binary outcome?
What kind of variable is my independent (predictor) variable? Is it a group label (categorical), or a quantity that varies smoothly (continuous)?
The answers place you in exactly one of four cells:
|
Independent: Categorical groups, treatments, species, sites |
Independent: Continuous temperature, concentration, time |
|
|---|---|---|
|
Dependent: Continuous mass, pH, height, PM2.5 |
Framework I
t-test & ANOVA
Compare means across two or more groups.
Jump to section ↓
Do burned and unburned plots differ in soil nitrogen? |
Framework II
Linear Regression
Model a straight-line relationship between two quantities.
Jump to section ↓
Does temperature predict dissolved oxygen? |
|
Dependent: Categorical present/absent, survived/died |
Framework III
Chi-square Test
Test whether category frequencies differ between groups.
Jump to section ↓
Is species presence associated with habitat type? |
Framework IV
Logistic Regression
Model the probability of a binary outcome from a continuous
predictor.
Jump to section ↓
Does pollution concentration predict fish mortality? |
📌 The core logic is the same in every framework
Every test below asks the same fundamental question: is the pattern in my data larger than what chance alone would produce? The answer is formalised as a test statistic and a p-value. What differs is how the test statistic is computed — because the geometry of the data changes depending on variable types.
When to use it: Your response variable is continuous (a measurement), and your predictor is a categorical group variable.
Is the difference in group means large enough — relative to the within-group variability — that it is unlikely to have arisen by chance?
\[t = \frac{\bar{x}_1 - \bar{x}_2}{\sqrt{\frac{s_1^2}{n_1} + \frac{s_2^2}{n_2}}}\]
For ANOVA, the same logic is generalised using the F-ratio: variance between groups divided by variance within groups.
\[F = \frac{MS_{\text{between}}}{MS_{\text{within}}}\]
A large F means the group means are more spread out than individual observations within groups — evidence against the null hypothesis that all group means are equal.
set.seed(42)
# Soil total nitrogen (g/kg) at three land-use types
nitrogen <- data.frame(
landuse = rep(c("Forest", "Grassland", "Cropland"), each = 25),
N_gkg = c(
rnorm(25, mean = 4.8, sd = 0.7), # Forest — highest
rnorm(25, mean = 3.2, sd = 0.9), # Grassland
rnorm(25, mean = 1.9, sd = 0.6) # Cropland — lowest
)
)
Always plot your data before running any test. Boxplots show the median, interquartile range, and any outliers simultaneously.
ggplot(nitrogen, aes(x = landuse, y = N_gkg, fill = landuse)) +
geom_boxplot(width = 0.5, outlier.shape = 21, outlier.size = 1.8,
colour = "#1e3a2f", alpha = 0.85) +
geom_jitter(width = 0.15, size = 0.9,
colour = "#1e3a2f", alpha = 0.35) +
scale_fill_manual(values = c("#4a7c5e", "#8aad96", "#c8832a")) +
labs(x = NULL, y = "Soil nitrogen (g/kg)",
title = "Soil Nitrogen by Land-Use Type") +
theme_minimal(base_size = 13) +
theme(legend.position = "none",
panel.grid.major.x = element_blank(),
plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"))
Soil nitrogen (g/kg) across three land-use types. The horizontal bar is the median; the box spans the IQR; whiskers extend to 1.5×IQR. Cropland shows consistently lower nitrogen than forest.
model_aov <- aov(N_gkg ~ landuse, data = nitrogen)
summary(model_aov)
## Df Sum Sq Mean Sq F value Pr(>F)
## landuse 2 112.48 56.24 88.7 <2e-16 ***
## Residuals 72 45.65 0.63
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
The F-statistic and its p-value tell you whether any groups differ. A significant ANOVA only tells you that the group means are not all equal — it does not tell you which pairs differ.
When ANOVA is significant, use Tukey’s Honest Significant Difference (HSD) test to identify which specific pairs of groups differ, while controlling the overall Type I error rate.
TukeyHSD(model_aov)
## Tukey multiple comparisons of means
## 95% family-wise confidence level
##
## Fit: aov(formula = N_gkg ~ landuse, data = nitrogen)
##
## $landuse
## diff lwr upr p adj
## Forest-Cropland 2.9456297 2.4066573 3.484602 0.000000
## Grassland-Cropland 0.9813626 0.4423903 1.520335 0.000126
## Grassland-Forest -1.9642671 -2.5032394 -1.425295 0.000000
Tukey HSD 95% confidence intervals for all pairwise differences. Intervals that do not cross zero indicate a statistically significant difference between those groups.
ANOVA rests on three assumptions. Always check them with diagnostic plots.
par(mfrow = c(1, 2), mar = c(4, 4, 3, 1), las = 1)
plot(model_aov, which = 1, main = "Residuals vs Fitted")
plot(model_aov, which = 2, main = "Normal Q-Q")
Left: residuals should scatter randomly around zero (no pattern = good). Right: residuals on a Q-Q plot should follow the diagonal line (normality check).
par(mfrow = c(1, 1))
⚠️ When ANOVA assumptions are violated
If residuals are strongly non-normal or variances are very unequal
across groups, consider: (1) log-transforming the
response variable (common for skewed environmental data like
concentrations), (2) using Welch’s ANOVA
(oneway.test()) which does not assume equal variances, or
(3) the non-parametric Kruskal-Wallis
test (kruskal.test()), which tests whether group
medians differ without assuming normality.
⚙️ Two-group case: the t-test
When you have
exactly two groups, t.test(y ~ group, data = df) is
equivalent to a two-group ANOVA (F = t²). Use
var.equal = TRUE if Levene’s test confirms equal variances;
otherwise the default Welch correction is safer.
When to use it: Both your response and predictor are continuous. You want to describe how much the response changes per unit increase in the predictor, and test whether that relationship is stronger than chance.
\[Y_i = \beta_0 + \beta_1 X_i + \varepsilon_i \qquad \varepsilon_i \sim \mathcal{N}(0,\, \sigma^2)\]
The parameters are estimated by Ordinary Least Squares (OLS): choosing the line that minimises the sum of squared vertical distances between observed and predicted values.
Dissolved oxygen (DO) decreases as water warms — a critical relationship for aquatic organisms.
set.seed(7)
n_obs <- 60
temp_C <- runif(n_obs, min = 10, max = 28) # water temperature (°C)
DO_mgl <- 14.6 - 0.38 * temp_C + rnorm(n_obs, 0, 0.6) # DO (mg/L); ~real physics
stream <- data.frame(temp_C, DO_mgl)
ggplot(stream, aes(temp_C, DO_mgl)) +
geom_point(colour = "#4a7c5e", alpha = 0.7, size = 2) +
geom_smooth(method = "lm", colour = "#1e3a2f", fill = "#d4e6da",
linewidth = 1, se = TRUE) +
labs(x = "Water temperature (°C)", y = "Dissolved oxygen (mg/L)",
title = "Dissolved Oxygen Declines with Temperature") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"))
Dissolved oxygen vs. water temperature in a stream dataset. The blue OLS line summarises the negative relationship; the grey ribbon is the 95% confidence interval on the mean prediction.
model_lm <- lm(DO_mgl ~ temp_C, data = stream)
summary(model_lm)
##
## Call:
## lm(formula = DO_mgl ~ temp_C, data = stream)
##
## Residuals:
## Min 1Q Median 3Q Max
## -1.06271 -0.38220 -0.01219 0.37904 1.36342
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 14.4164 0.2442 59.03 <2e-16 ***
## temp_C -0.3700 0.0122 -30.33 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.5137 on 58 degrees of freedom
## Multiple R-squared: 0.9407, Adjusted R-squared: 0.9397
## F-statistic: 919.7 on 1 and 58 DF, p-value: < 2.2e-16
Reading the output:
(Intercept) — estimated DO at 0 °C (extrapolation here,
but gives the line its anchor)temp_C — the slope: each 1 °C increase is associated
with a −0.38 mg/L change in DOStd. Error — uncertainty in each coefficient
estimatet value / Pr(>|t|) — is the coefficient
significantly different from zero?Multiple R-squared — proportion of variance in DO
explained by temperatureF-statistic — overall test of the model (same as
testing \(\beta_1 = 0\) in simple
regression)coef(model_lm)
## (Intercept) temp_C
## 14.416393 -0.370006
confint(model_lm) # 95% CIs on the coefficients
## 2.5 % 97.5 %
## (Intercept) 13.9275506 14.9052357
## temp_C -0.3944282 -0.3455837
# How much variance does the model explain?
ss_total <- sum((stream$DO_mgl - mean(stream$DO_mgl))^2)
ss_resid <- sum(residuals(model_lm)^2)
ss_model <- ss_total - ss_resid
r_sq <- ss_model / ss_total
cat("SS Total :", round(ss_total, 2), "\n")
## SS Total : 258.03
cat("SS Model :", round(ss_model, 2), "\n")
## SS Model : 242.72
cat("SS Resid :", round(ss_resid, 2), "\n")
## SS Resid : 15.31
cat("R² :", round(r_sq, 3), " (=", round(r_sq*100, 1), "% variance explained)\n")
## R² : 0.941 (= 94.1 % variance explained)
par(mfrow = c(2, 2), mar = c(4, 4, 3, 1), las = 1)
plot(model_lm)
Regression diagnostics. Top-left: residuals vs fitted — look for random scatter with no funnel shape (homoscedasticity). Top-right: Q-Q plot — residuals should follow the diagonal. Bottom: Scale-Location confirms homoscedasticity; Cook’s Distance flags high-influence observations.
par(mfrow = c(1, 1))
📌 Correlation vs. regression — what is the difference?
Correlation (Pearson’s r) quantifies the strength and direction of a linear relationship. It is symmetric: cor(X, Y) = cor(Y, X).
Regression quantifies the rate of change: how many units does Y change per unit increase in X? It is asymmetric — regressing Y on X gives a different line than regressing X on Y.
Use correlation when you simply want to know “are these related?” Use regression when you want to predict Y from X or estimate the effect size. Note: \(r^2 = R^2\) in simple linear regression — they are the same quantity.
⚠️ Association ≠ causation A significant slope does not prove that X causes Y. Temperature and dissolved oxygen have a known physical mechanism. But in many environmental datasets, confounding variables drive apparent correlations. Causal inference requires careful study design, not just a significant p-value.
When to use it: Both your response and predictor are categorical. You have counts of observations falling into the cells of a contingency table and want to test whether the distribution of one variable depends on the other.
If the two variables were independent, what counts would we expect in each cell? The chi-square statistic measures how far observed counts deviate from expected:
\[\chi^2 = \sum_{\text{all cells}} \frac{(O - E)^2}{E}\]
where \(O\) = observed count and \(E = \frac{(\text{row total}) \times (\text{col total})}{\text{grand total}}\).
Is the presence of a focal riparian plant species associated with habitat type (riparian buffer vs. degraded bank)?
set.seed(3)
n_sites <- 120
habitat <- rep(c("Riparian buffer", "Degraded bank"), each = n_sites / 2)
# Species more likely in intact riparian buffer
present <- c(rbinom(60, 1, prob = 0.72), # buffer
rbinom(60, 1, prob = 0.31)) # degraded
ecology <- data.frame(habitat, present = factor(present, labels = c("Absent","Present")))
(ct <- table(ecology$habitat, ecology$present))
##
## Absent Present
## Degraded bank 39 21
## Riparian buffer 14 46
# Row proportions: P(Present | habitat)
prop.table(ct, margin = 1) |> round(2)
##
## Absent Present
## Degraded bank 0.65 0.35
## Riparian buffer 0.23 0.77
prop_df <- ecology |>
group_by(habitat) |>
summarise(
n = n(),
x = sum(present == "Present"),
prop = x / n,
# Wilson CI
lower = (prop + qnorm(0.975)^2 / (2*n) -
qnorm(0.975) * sqrt(prop*(1-prop)/n + qnorm(0.975)^2/(4*n^2))) /
(1 + qnorm(0.975)^2 / n),
upper = (prop + qnorm(0.975)^2 / (2*n) +
qnorm(0.975) * sqrt(prop*(1-prop)/n + qnorm(0.975)^2/(4*n^2))) /
(1 + qnorm(0.975)^2 / n)
)
ggplot(prop_df, aes(habitat, prop, fill = habitat)) +
geom_col(width = 0.5, 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("#4a7c5e", "#c8832a")) +
scale_y_continuous(labels = scales::percent_format(), limits = c(0, 1)) +
labs(x = NULL, y = "Proportion of sites with species present",
title = "Species Presence by Habitat Type") +
theme_minimal(base_size = 13) +
theme(legend.position = "none",
panel.grid.major.x = element_blank(),
plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"))
Proportion of sites where the focal species is present, by habitat type. Error bars are 95% Wilson confidence intervals. The difference in detection rates is visually clear.
chisq.test(ct)
##
## Pearson's Chi-squared test with Yates' continuity correction
##
## data: ct
## X-squared = 19.465, df = 1, p-value = 1.025e-05
# Always check expected counts — all should be ≥ 5 for chi-square to be valid
chisq.test(ct)$expected
##
## Absent Present
## Degraded bank 26.5 33.5
## Riparian buffer 26.5 33.5
A significant chi-square only tells you there is an association; it does not tell you how strong it is. Cramér’s V standardises the chi-square statistic to a 0–1 scale.
chi_val <- chisq.test(ct)$statistic
n_total <- sum(ct)
k <- min(nrow(ct), ncol(ct)) # min(rows, cols)
cramers_v <- sqrt(chi_val / (n_total * (k - 1)))
cat("Cramér's V =", round(cramers_v, 3),
" (0 = no association, 1 = perfect association)\n")
## Cramér's V = 0.403 (0 = no association, 1 = perfect association)
📌 When to use Fisher’s Exact Test instead
The chi-square test requires that all expected cell
counts are ≥ 5. When you have small samples or rare events (common in
species detection studies), use fisher.test(ct) instead.
Fisher’s exact test works with any sample size and is preferred for 2×2
tables with small marginal totals.
⚙️ Chi-square vs. logistic regression for 2×2
tables
Chi-square tests association. Logistic regression
models the probability of the outcome and produces an
odds ratio — the most useful effect-size measure for
categorical outcomes. For a single binary predictor, both give the same
p-value; logistic regression adds the odds ratio and allows control for
confounders. See Framework IV.
When to use it: Your response is binary (0/1, absent/present, survived/died), and your predictor is continuous (or a mix of continuous and categorical).
A linear model can predict probabilities below 0 or above 1, which is nonsensical. Logistic regression solves this by modelling the log-odds (logit) of the outcome as a linear function of the predictors:
\[\log\!\left(\frac{p}{1-p}\right) = \beta_0 + \beta_1 X\]
Solving for \(p\) gives the logistic (sigmoid) curve — bounded between 0 and 1:
\[p = \frac{1}{1 + e^{-(\beta_0 + \beta_1 X)}}\]
At what pollutant concentration does survival probability drop below 50%?
set.seed(11)
n_fish <- 120
conc <- runif(n_fish, min = 0, max = 10) # pollutant (mg/L)
# True model: logit(p) = 2.5 - 0.8*conc → LC50 ≈ 3.1 mg/L
log_odds <- 2.5 - 0.8 * conc
prob <- 1 / (1 + exp(-log_odds))
survived <- rbinom(n_fish, 1, prob)
fish <- data.frame(conc, survived)
Binary data cannot be plotted as a scatterplot without modification. Jitter the points vertically and overlay the model curve.
model_glm <- glm(survived ~ conc, data = fish, family = binomial)
# Prediction curve
pred_df <- data.frame(conc = seq(0, 10, length.out = 300))
pred_df$prob <- predict(model_glm, newdata = pred_df, type = "response")
# LC50: concentration where p = 0.5 → conc = -b0/b1
lc50 <- -coef(model_glm)[1] / coef(model_glm)[2]
ggplot(fish, aes(conc, survived)) +
geom_jitter(height = 0.04, width = 0, alpha = 0.4, size = 1.8,
colour = "#4a7c5e") +
geom_line(data = pred_df, aes(y = prob),
colour = "#1e3a2f", linewidth = 1.4) +
geom_hline(yintercept = 0.5, linetype = "dashed",
colour = "#c8832a", linewidth = 0.8) +
geom_vline(xintercept = lc50, linetype = "dashed",
colour = "#c8832a", linewidth = 0.8) +
annotate("text", x = lc50 + 0.4, y = 0.08,
label = paste0("LC50 ≈ ", round(lc50, 1), " mg/L"),
colour = "#c8832a", hjust = 0, size = 3.8, fontface = "italic") +
scale_y_continuous(labels = scales::percent_format(),
breaks = c(0, 0.25, 0.5, 0.75, 1)) +
labs(x = "Pollutant concentration (mg/L)",
y = "P(survival)",
title = "Logistic Regression: Fish Survival vs. Pollutant Concentration") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f"))
Fish survival (1 = survived, 0 = died) vs. pollutant concentration. Points are jittered to reduce overplotting. The curve is the fitted logistic regression model; the dashed line marks 50% survival probability and the estimated LC50.
summary(model_glm)
##
## Call:
## glm(formula = survived ~ conc, family = binomial, data = fish)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 2.4955 0.5589 4.465 8.01e-06 ***
## conc -0.8509 0.1575 -5.404 6.53e-08 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 156.589 on 119 degrees of freedom
## Residual deviance: 98.186 on 118 degrees of freedom
## AIC: 102.19
##
## Number of Fisher Scoring iterations: 5
Reading the output:
The coefficients are on the log-odds scale, which is hard to interpret directly. Convert to odds ratios by exponentiating:
exp(coef(model_glm)) # odds ratios
## (Intercept) conc
## 12.1275073 0.4270205
exp(confint(model_glm)) # 95% CIs on odds ratios
## 2.5 % 97.5 %
## (Intercept) 4.3887636 40.0315042
## conc 0.3025173 0.5637595
(Intercept) exponentiated: the odds of survival when
concentration = 0conc exponentiated: for each 1 mg/L increase in
concentration, the odds of survival are multiplied by
this factor (< 1 = decreasing odds)lc50_val <- -coef(model_glm)["(Intercept)"] / coef(model_glm)["conc"]
cat("Estimated LC50:", round(lc50_val, 2), "mg/L\n")
## Estimated LC50: 2.93 mg/L
Unlike linear regression, logistic regression has no single R² analogue. Use pseudo-R² (McFadden’s) and the Akaike Information Criterion (AIC) to assess and compare models.
# Null model (intercept only) for comparison
model_null <- glm(survived ~ 1, data = fish, family = binomial)
# McFadden's pseudo-R²
mcfadden_r2 <- 1 - (logLik(model_glm) / logLik(model_null))
cat("McFadden R² =", round(mcfadden_r2, 3), "\n")
## McFadden R² = 0.373
# AIC (lower = better fit, penalises complexity)
cat("AIC (full model) =", round(AIC(model_glm), 1), "\n")
## AIC (full model) = 102.2
cat("AIC (null model) =", round(AIC(model_null), 1), "\n")
## AIC (null model) = 158.6
# Classify: predict survival if p > 0.5
fish$predicted <- ifelse(
predict(model_glm, type = "response") > 0.5, 1, 0)
conf_mat <- table(Predicted = fish$predicted, Actual = fish$survived)
conf_mat
## Actual
## Predicted 0 1
## 0 63 13
## 1 14 30
accuracy <- sum(diag(conf_mat)) / sum(conf_mat)
cat("Overall accuracy:", round(accuracy * 100, 1), "%\n")
## Overall accuracy: 77.5 %
💡 Extending logistic regression
Logistic regression generalises naturally:
glm(y ~ x1 + x2 + x3, family = binomial)x1:x2 or
x1*x2 in the formulaMASS::polr())nnet::multinom())The underlying principle — modelling the log-odds as a linear function — remains the same throughout.
Use this decision process before every analysis:
| Dependent variable | Independent variable | Primary test | Effect size | Non-parametric alt. |
|---|---|---|---|---|
| Continuous | Categorical (2 groups) | t-test (t.test()) |
Cohen’s d | Wilcoxon (wilcox.test()) |
| Continuous | Categorical (3+ groups) | One-way ANOVA (aov()) |
η² (eta-squared) | Kruskal-Wallis (kruskal.test()) |
| Binary (0/1) | Categorical | Chi-square (chisq.test()) |
Cramér’s V | Fisher’s exact (fisher.test()) |
| Binary (0/1) | Continuous | Logistic regression
(glm(..., family=binomial)) |
Odds ratio | — (logistic is already non-normal) |
For each research question, identify (i) the outcome variable type, (ii)
the predictor variable type, and (iii) the appropriate framework. Then
name one specific test from that framework.
a) Does mean stream
nitrate differ across three land-use categories (forest, agriculture,
urban)?
b) Does the probability of a species being present increase
with elevation?
c) Is there a linear relationship between annual
precipitation and tree ring width?
d) Is the frequency of “impaired”
water bodies independent of EPA region?
Your advisor hands you a dataset with columns: pm25
(µg/m³), wind_speed (m/s), season (4 levels),
and exceedance (TRUE/FALSE). List two research questions
you could ask — one from Framework I and one from Framework IV — and
write the R model formula for each.
Framework I: “Does wind speed predict PM2.5?” → lm(pm25 ~
wind_speed)
Framework IV: “Does wind speed predict
probability of PM2.5 exceeding the standard?” → glm(exceedance ~
wind_speed, family = binomial)