Four Frameworks of Statistical Analyses

Marc Los Huertos

EA030 · Compiled 2026-04-14

Overview ANOVA / t-test Linear Regression Chi-square Logistic Regression Choosing a Test

By the end of this module you will be able to
  1. Identify the outcome and predictor variable types in any research question.
  2. Assign any environmental analysis to one of the four statistical frameworks.
  3. Use the framework matrix to select the correct family of tests before touching data.
  4. Explain why choosing the wrong framework leads to invalid inference.

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.


The Organising Principle

Before running any analysis, ask two questions:

  1. 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?

  2. 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.
Do burned and unburned plots differ in soil nitrogen?
Jump to section ↓
Framework II
Linear Regression
Model a straight-line relationship between two quantities.
Does temperature predict dissolved oxygen?
Jump to section ↓
Dependent:
Categorical
present/absent, survived/died
Framework III
Chi-square Test
Test whether category frequencies differ between groups.
Is species presence associated with habitat type?
Jump to section ↓
Framework IV
Logistic Regression
Model the probability of a binary outcome from a continuous predictor.
Does pollution concentration predict fish mortality?
Jump to section ↓

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


Framework I — t-test and ANOVA

When to use it: Your response variable is continuous (a measurement), and your predictor is a categorical group variable.

The core question

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.

Environmental example: soil nitrogen across land-use types

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

Step 1 — Visualise the groups

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.

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.

Step 2 — Run the ANOVA

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.

Step 3 — Post-hoc comparisons

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.

Tukey HSD 95% confidence intervals for all pairwise differences. Intervals that do not cross zero indicate a statistically significant difference between those groups.

Checking assumptions

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

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.


Framework II — Linear Regression

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.

The model

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

Environmental example: water temperature and dissolved oxygen

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)

Step 1 — Scatterplot with regression line

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.

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.

Step 2 — Fit the model and read the output

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 DO
  • Std. Error — uncertainty in each coefficient estimate
  • t value / Pr(>|t|) — is the coefficient significantly different from zero?
  • Multiple R-squared — proportion of variance in DO explained by temperature
  • F-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

Step 3 — Partition the variance: what does R² mean?

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

Step 4 — Diagnostic plots

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.

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.


Framework III — Chi-square Test

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.

The core question

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

Environmental example: species presence by habitat type

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

Step 1 — Build the contingency table

(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

Step 2 — Visualise the association

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.

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.

Step 3 — Run the chi-square test

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

Step 4 — Effect size: Cramér’s V

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.


Framework IV — Logistic Regression

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

Why not just use linear regression on 0/1 data?

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)}}\]

Environmental example: fish mortality as a function of pollutant concentration

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)

Step 1 — Visualise the binary outcome

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.

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.

Step 2 — Fit and interpret the model

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 = 0
  • conc 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

Step 3 — Model fit statistics

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

Step 4 — Classification accuracy

# 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:

  • Multiple predictors — add more terms: glm(y ~ x1 + x2 + x3, family = binomial)
  • Interaction terms — x1:x2 or x1*x2 in the formula
  • Ordered outcomes (3+ levels) — ordinal logistic regression (MASS::polr())
  • Unordered outcomes (3+ levels) — multinomial logistic regression (nnet::multinom())

The underlying principle — modelling the log-odds as a linear function — remains the same throughout.


Choosing the Right Framework

Use this decision process before every analysis:

Framework selection guide. When in doubt: plot first, then choose.
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)

🔑 The four frameworks — key takeaways


What’s next?

✏️ Practice Exercises
Exercise 1 — Framework identification

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?

Show solution

  1. Continuous outcome, categorical predictor → Framework III (ANOVA). Test: one-way ANOVA or Kruskal–Wallis.
  2. Binary outcome, continuous predictor → Framework IV (Logistic regression).
  3. Continuous outcome, continuous predictor → Framework I (Regression). Test: OLS.
  4. Categorical outcome, categorical predictor → Framework II (Association). Test: chi-square.

Exercise 2 — Framework matrix

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.

Show solution

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)