Prof. Marc Los Huertos • Pomona College
What this module covers. Real environmental systems are never driven by a single variable. Air quality responds to temperature and wind and season simultaneously; species occupancy depends on elevation, disturbance, and their interaction. This module extends simple regression to handle multiple predictors, interactions, and response variables that are counts or binary outcomes — the workhorses of ecological and environmental data analysis.
In simple linear regression you modelled one response as a function of one predictor. Multiple regression allows k predictors simultaneously:
\[Y_i = \beta_0 + \beta_1 X_{1i} + \beta_2 X_{2i} + \cdots + \beta_k X_{ki} + \varepsilon_i\]
Each \(\beta_j\) is a partial regression coefficient: the estimated change in \(Y\) for a one-unit increase in \(X_j\) while holding all other predictors constant. This “statistical control” lets you disentangle the individual contributions of correlated predictors — something impossible with a series of separate simple regressions.
📌 The key insight of multiple regression
Temperature and wind speed are correlated — on hot summer days wind is often calmer. If you regress ozone on temperature alone, part of the estimated temperature effect is actually wind doing the work. A multiple regression partitions the variance correctly, giving you the unique contribution of each predictor after accounting for the others.
We use the built-in airquality dataset, which contains
daily measurements from New York (1973), but the variables map directly
to the LA Basin air quality problem: ground-level ozone is produced
photochemically from precursors, so temperature and solar radiation
drive formation while wind disperses it.
data(airquality)
# Drop rows with missing values and label the month
aq <- airquality %>%
filter(!is.na(Ozone), !is.na(Solar.R), !is.na(Wind), !is.na(Temp)) %>%
mutate(
Month_name = factor(month.abb[Month],
levels = c("May","Jun","Jul","Aug","Sep"))
)
glimpse(aq)
## Rows: 111
## Columns: 7
## $ Ozone <int> 41, 36, 12, 18, 23, 19, 8, 16, 11, 14, 18, 14, 34, 6, 30, 1…
## $ Solar.R <int> 190, 118, 149, 313, 299, 99, 19, 256, 290, 274, 65, 334, 30…
## $ Wind <dbl> 7.4, 8.0, 12.6, 11.5, 8.6, 13.8, 20.1, 9.7, 9.2, 10.9, 13.2…
## $ Temp <int> 67, 72, 74, 62, 65, 59, 61, 69, 66, 68, 58, 64, 66, 57, 68,…
## $ Month <int> 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,…
## $ Day <int> 1, 2, 3, 4, 7, 8, 9, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21…
## $ Month_name <fct> May, May, May, May, May, May, May, May, May, May, May, May,…
Before fitting anything, look at how each predictor relates to ozone and to each other.
pairs(
aq[, c("Ozone","Solar.R","Wind","Temp")],
col = "#4a7c5e",
pch = 16,
cex = 0.65,
upper.panel = function(x, y, ...) {
points(x, y, ...)
r <- round(cor(x, y, use = "complete.obs"), 2)
legend("topleft", legend = paste0("r = ", r),
bty = "n", cex = 0.85, text.col = "#c8832a")
},
main = "Airquality: pairwise relationships"
)
Pairwise scatterplot matrix. Off-diagonal plots show each variable against every other; diagonal shows the marginal distribution. Notice ozone’s right-skewed distribution and its positive correlation with temperature.
model_aq <- lm(Ozone ~ Solar.R + Wind + Temp, data = aq)
summary(model_aq)
##
## Call:
## lm(formula = Ozone ~ Solar.R + Wind + Temp, data = aq)
##
## Residuals:
## Min 1Q Median 3Q Max
## -40.485 -14.219 -3.551 10.097 95.619
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -64.34208 23.05472 -2.791 0.00623 **
## Solar.R 0.05982 0.02319 2.580 0.01124 *
## Wind -3.33359 0.65441 -5.094 1.52e-06 ***
## Temp 1.65209 0.25353 6.516 2.42e-09 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 21.18 on 107 degrees of freedom
## Multiple R-squared: 0.6059, Adjusted R-squared: 0.5948
## F-statistic: 54.83 on 3 and 107 DF, p-value: < 2.2e-16
Reading the summary table
From the model output we can write:
\[\widehat{\text{Ozone}} = \hat\beta_0 + \hat\beta_1 \cdot \text{Solar.R} + \hat\beta_2 \cdot \text{Wind} + \hat\beta_3 \cdot \text{Temp}\]
Key interpretations (sign and direction matter most):
Linear regression rests on four assumptions: linearity, constant variance (homoscedasticity), normally distributed residuals, and no influential outliers. The four standard diagnostic plots test all of these at once.
par(mfrow = c(2,2), mar = c(4,4,3,1))
plot(model_aq, col = "#4a7c5e", pch = 16, cex = 0.7)
Standard regression diagnostics. Top-left: residuals vs fitted — no strong pattern is good. Top-right: Q-Q plot — points near the line mean normally distributed residuals. Bottom-left: scale-location — flat line means constant variance. Bottom-right: leverage plot — points beyond Cook’s distance contours may be unduly influential.
par(mfrow = c(1,1))
⚠️ Ozone is right-skewed — a log transformation often helps
If the residual Q-Q plot shows departure from normality (heavy right
tail), try lm(log(Ozone) ~ ...). Coefficients then
represent proportional changes in ozone rather than absolute
ppb changes. Always compare diagnostic plots before and after
transforming.
Partial regression plots show the unique relationship between each predictor and the response after removing the linear effects of all other predictors. They are the honest picture of what each slope actually estimates.
library(car)
avPlots(model_aq,
col = "#4a7c5e", pch = 16, cex = 0.7,
col.lines = "#c8832a", lwd = 2,
main = "Partial regression plots — Ozone ~ Solar.R + Wind + Temp")
Added-variable (partial regression) plots. Each panel shows the component of ozone unexplained by the other two predictors plotted against the component of the focal predictor unexplained by the others. The slope in each panel equals the partial regression coefficient.
An interaction means the effect of one predictor on the response depends on the value of another predictor. Graphically, this shows up as non-parallel slopes when you split data into groups.
Does the effect of wind speed on ozone differ depending on temperature? On very hot days, the chemistry runs faster, so wind may matter more (or less) than on cool days.
model_interact <- lm(Ozone ~ Wind * Temp, data = aq)
summary(model_interact)
##
## Call:
## lm(formula = Ozone ~ Wind * Temp, data = aq)
##
## Residuals:
## Min 1Q Median 3Q Max
## -40.930 -11.193 -3.034 8.193 97.456
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -239.8918 48.6200 -4.934 2.97e-06 ***
## Wind 13.5975 4.2835 3.174 0.001961 **
## Temp 4.0005 0.5935 6.741 8.26e-10 ***
## Wind:Temp -0.2173 0.0545 -3.987 0.000123 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 20.37 on 107 degrees of freedom
## Multiple R-squared: 0.6355, Adjusted R-squared: 0.6253
## F-statistic: 62.19 on 3 and 107 DF, p-value: < 2.2e-16
How to read an interaction coefficient
Wind:Temp is the additional change in the Wind
slope for every one-unit increase in Temp (or equivalently, the
additional change in the Temp slope per one-unit change in Wind). A
significant negative Wind:Temp means wind has a
stronger (more negative) effect on ozone at higher
temperatures.
The clearest way to visualise a continuous × continuous interaction is to split one variable at a meaningful threshold (here, the median) and plot the other variable against the response for each group.
aq %>%
mutate(
TempGroup = if_else(Temp >= median(Temp), "High temp (≥79°F)", "Low temp (<79°F)")
) %>%
ggplot(aes(Wind, Ozone, colour = TempGroup)) +
geom_point(alpha = 0.55, size = 2) +
geom_smooth(method = "lm", se = TRUE, alpha = 0.15) +
scale_colour_manual(values = c("#c8832a", "#4a7c5e"),
name = "Temperature group") +
labs(x = "Wind speed (mph)", y = "Ozone (ppb)",
title = "Wind × Temperature interaction on ozone") +
theme_minimal(base_size = 13) +
theme(
legend.position = "top",
panel.grid.minor = element_blank(),
plot.title = element_text(family = "serif", face = "bold",
colour = "#1e3a2f")
)
Ozone versus wind speed at high and low temperatures (split at the median of 79°F). The steeper negative slope in the high-temperature group indicates that wind is a more effective reducer of ozone on hot days — consistent with the negative interaction term.
✅ Always include main effects when using interactions
Writing lm(Ozone ~ Wind * Temp) automatically includes
Wind, Temp, and Wind:Temp. Never
fit an interaction without its constituent main effects — the model
would be mis-specified and coefficients uninterpretable.
When you have several plausible models, how do you choose? AIC (Akaike Information Criterion) penalises model fit (log-likelihood) by the number of parameters, rewarding parsimony.
\[\text{AIC} = 2k - 2\ln(\hat{L})\]
where \(k\) is the number of estimated parameters and \(\hat{L}\) is the maximised likelihood. Lower AIC = better model.
m_null <- lm(Ozone ~ 1, data = aq)
m_wind <- lm(Ozone ~ Wind, data = aq)
m_temp <- lm(Ozone ~ Temp, data = aq)
m_both <- lm(Ozone ~ Wind + Temp, data = aq)
m_full <- lm(Ozone ~ Solar.R + Wind + Temp, data = aq)
m_interact <- lm(Ozone ~ Wind * Temp, data = aq)
AIC(m_null, m_wind, m_temp, m_both, m_full, m_interact)
## df AIC
## m_null 2 1096.0733
## m_wind 3 1045.8759
## m_temp 3 1023.7751
## m_both 4 1003.4160
## m_full 5 998.7171
## m_interact 5 990.0423
AIC values for six candidate models ranked from worst (intercept-only) to best. Lower is better. The dashed line marks the minimum AIC. Models within ΔAIC < 2 of the best have roughly equivalent empirical support.
📌 AIC rules of thumb
| ΔAIC from best | Interpretation |
|---|---|
| 0 – 2 | Strong support — essentially equivalent to best model |
| 2 – 7 | Moderate support — worth considering |
| > 10 | Little support — can be dropped from consideration |
When two models have similar AIC, prefer the simpler one (fewer parameters). AIC does not test whether a model is good in an absolute sense — only which model in your candidate set is relatively better supported by the data.
Ordinary linear regression assumes:
Environmental data frequently violate these assumptions:
GLMs extend regression by allowing a different distribution family for the response and a link function that maps the linear predictor to the expected value.
| Data type | Distribution | Link function | R family | Interpretation |
|---|---|---|---|---|
| Counts (non-negative integers) | Poisson | log | poisson |
exp(β) = multiplicative rate ratio |
| Binary (0/1) | Binomial | logit | binomial |
exp(β) = odds ratio |
| Continuous positive | Gamma | inverse or log | Gamma |
exp(β) = proportional change |
| Proportions [0,1] | Beta / Binomial | logit | binomial or beta reg. |
exp(β) = odds ratio |
We will simulate a dataset of bird species richness surveys along a riparian corridor. Species richness at a site is driven by vegetation structure (measured as canopy cover %) and disturbance level (low / high). Count data like richness are non-negative integers and often follow a Poisson distribution.
\[\log(\mu_i) = \beta_0 + \beta_1 X_{1i} + \beta_2 X_{2i}\]
The log link ensures predictions are always positive — no negative counts — while the linear predictor can range from \(-\infty\) to \(+\infty\).
set.seed(42)
n <- 80
birds <- tibble(
canopy_pct = runif(n, 20, 95), # canopy cover (%)
disturbance = sample(c("Low","High"), n, replace = TRUE),
# Richness: higher cover increases richness; disturbance decreases it
richness = rpois(n, lambda = exp(
1.2 + 0.022 * canopy_pct -
0.55 * (disturbance == "High")
))
)
head(birds)
## # A tibble: 6 × 3
## canopy_pct disturbance richness
## <dbl> <chr> <int>
## 1 88.6 High 19
## 2 90.3 Low 25
## 3 41.5 High 5
## 4 82.3 Low 16
## 5 68.1 Low 10
## 6 58.9 High 6
ggplot(birds, aes(richness, fill = disturbance)) +
geom_histogram(binwidth = 1, colour = "white", alpha = 0.85, position = "identity") +
scale_fill_manual(values = c("#4a7c5e","#c8832a"), name = "Disturbance") +
labs(x = "Bird species richness", y = "Number of sites",
title = "Distribution of riparian bird richness") +
theme_minimal(base_size = 13) +
theme(
legend.position = "top",
plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f")
)
Distribution of bird species richness across 80 riparian survey sites. The right-skewed, non-negative integer distribution is characteristic of count data — unsuitable for ordinary linear regression.
poisson_mod <- glm(richness ~ canopy_pct + disturbance,
family = poisson,
data = birds)
summary(poisson_mod)
##
## Call:
## glm(formula = richness ~ canopy_pct + disturbance, family = poisson,
## data = birds)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 0.480071 0.131667 3.646 0.000266 ***
## canopy_pct 0.022169 0.001729 12.824 < 2e-16 ***
## disturbanceLow 0.677422 0.076459 8.860 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 393.792 on 79 degrees of freedom
## Residual deviance: 72.458 on 77 degrees of freedom
## AIC: 395.56
##
## Number of Fisher Scoring iterations: 4
GLM coefficients are on the log scale (because the link is log). Exponentiating converts them to rate ratios — multiplicative effects on expected counts.
exp(coef(poisson_mod))
## (Intercept) canopy_pct disturbanceLow
## 1.616189 1.022417 1.968796
exp(confint(poisson_mod))
## 2.5 % 97.5 %
## (Intercept) 1.243314 2.083474
## canopy_pct 1.018992 1.025923
## disturbanceLow 1.697198 2.290641
Interpreting Poisson coefficients
exp(β_canopy) ≈ 1.022 → each 1% increase in canopy
cover is associated with ~2.2% more species expected, all else
equalexp(β_disturbanceHigh) ≈ 0.58 → high-disturbance sites
are expected to have about 42% fewer species than low-disturbance sites,
holding canopy cover constantA Poisson model assumes the variance equals the mean. Environmental count data often have variance greater than the mean (overdispersion), which inflates Type I error if uncorrected.
# Quick check: residual deviance / residual df should be near 1
with(summary(poisson_mod), deviance / df.residual)
## [1] 0.9410136
If this ratio is substantially above 2, switch to
family = quasipoisson or a negative
binomial model (MASS::glm.nb()).
pred_grid <- expand.grid(
canopy_pct = seq(20, 95, length.out = 120),
disturbance = c("Low","High")
)
pred_grid$fit <- predict(poisson_mod, newdata = pred_grid, type = "response")
# SE for ribbon
pred_se <- predict(poisson_mod, newdata = pred_grid, type = "link", se.fit = TRUE)
pred_grid$lwr <- exp(pred_se$fit - 1.96 * pred_se$se.fit)
pred_grid$upr <- exp(pred_se$fit + 1.96 * pred_se$se.fit)
ggplot() +
geom_jitter(data = birds,
aes(canopy_pct, richness, colour = disturbance),
width = 0, height = 0.25, alpha = 0.45, size = 1.8) +
geom_ribbon(data = pred_grid,
aes(canopy_pct, ymin = lwr, ymax = upr, fill = disturbance),
alpha = 0.18) +
geom_line(data = pred_grid,
aes(canopy_pct, fit, colour = disturbance), linewidth = 1) +
scale_colour_manual(values = c("#4a7c5e","#c8832a"), name = "Disturbance") +
scale_fill_manual(values = c("#4a7c5e","#c8832a"), name = "Disturbance") +
labs(x = "Canopy cover (%)", y = "Expected bird species richness",
title = "Poisson GLM: riparian bird richness") +
theme_minimal(base_size = 13) +
theme(
legend.position = "top",
plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f")
)
Predicted bird richness as a function of canopy cover for low- and high-disturbance sites (Poisson GLM). Shaded bands show 95% confidence envelopes on the predicted mean. Points are observed counts, jittered slightly to reduce overplotting.
We simulate a habitat-suitability dataset for desert tortoises. At each survey plot we record whether a tortoise was detected (1) or not (0), along with shrub density (plants/ha) and slope (%). We model detection probability as a function of these habitat features.
The logit link maps the linear predictor to a probability bounded between 0 and 1:
\[\text{logit}(p_i) = \ln\!\left(\frac{p_i}{1 - p_i}\right) = \beta_0 + \beta_1 X_{1i} + \beta_2 X_{2i}\]
\[p_i = \frac{e^{\beta_0 + \beta_1 X_{1i} + \beta_2 X_{2i}}}{1 + e^{\beta_0 + \beta_1 X_{1i} + \beta_2 X_{2i}}}\]
set.seed(99)
n <- 120
tortoise <- tibble(
shrub_density = runif(n, 5, 80), # shrubs per 0.1 ha
slope_pct = runif(n, 0, 35), # terrain slope (%)
detected = rbinom(n, 1,
prob = plogis(-2.0 + 0.055 * shrub_density - 0.06 * slope_pct))
)
head(tortoise)
## # A tibble: 6 × 3
## shrub_density slope_pct detected
## <dbl> <dbl> <int>
## 1 48.9 15.2 1
## 2 13.5 34.6 0
## 3 56.3 15.9 0
## 4 79.4 23.5 1
## 5 45.1 11.5 0
## 6 77.5 20.7 0
Before modelling, it helps to bin the continuous predictor and plot the observed proportion of detections in each bin.
tortoise %>%
mutate(shrub_bin = cut(shrub_density, breaks = 6)) %>%
group_by(shrub_bin) %>%
summarise(
prop = mean(detected),
se = sqrt(prop * (1 - prop) / n()),
midpoint = mean(shrub_density),
.groups = "drop"
) %>%
ggplot(aes(midpoint, prop)) +
geom_errorbar(aes(ymin = pmax(prop - se, 0), ymax = pmin(prop + se, 1)),
width = 2, colour = "#8aad96") +
geom_point(size = 3.5, colour = "#4a7c5e") +
geom_smooth(method = "glm",
method.args = list(family = binomial),
se = FALSE, colour = "#c8832a", linewidth = 1) +
scale_y_continuous(labels = scales::percent_format(),
limits = c(0, 1)) +
labs(x = "Shrub density (plants/0.1 ha)", y = "Detection probability",
title = "Desert tortoise detection vs. shrub density") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f"))
Observed detection rate across binned shrub-density classes. Detection frequency increases with shrub density — tortoises are more likely detected in denser shrublands — consistent with the known habitat preference. Error bars show ±1 SE.
logit_mod <- glm(detected ~ shrub_density + slope_pct,
family = binomial,
data = tortoise)
summary(logit_mod)
##
## Call:
## glm(formula = detected ~ shrub_density + slope_pct, family = binomial,
## data = tortoise)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -1.71683 0.58647 -2.927 0.003418 **
## shrub_density 0.04045 0.01043 3.878 0.000105 ***
## slope_pct -0.03255 0.02203 -1.477 0.139560
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 157.72 on 119 degrees of freedom
## Residual deviance: 138.96 on 117 degrees of freedom
## AIC: 144.96
##
## Number of Fisher Scoring iterations: 3
exp(coef(logit_mod))
## (Intercept) shrub_density slope_pct
## 0.1796353 1.0412780 0.9679758
exp(confint(logit_mod))
## 2.5 % 97.5 %
## (Intercept) 0.05349237 0.5420687
## shrub_density 1.02113020 1.0640447
## slope_pct 0.92579727 1.0099260
From log-odds to odds ratios
exp(β_shrub) ≈ 1.06 → for each additional shrub per 0.1
ha, the odds of detecting a tortoise are multiplied by
~1.06 (a 6% increase in odds)exp(β_slope) ≈ 0.94 → each additional % of slope
multiplies the odds of detection by ~0.94 (a 6% decrease in odds per %
slope)pred2 <- expand.grid(
shrub_density = seq(5, 80, length.out = 80),
slope_pct = seq(0, 35, length.out = 60)
)
pred2$prob <- predict(logit_mod, newdata = pred2, type = "response")
ggplot(pred2, aes(shrub_density, slope_pct, fill = prob)) +
geom_raster(interpolate = TRUE) +
geom_contour(aes(z = prob), colour = "white", linewidth = 0.6,
breaks = c(0.25, 0.50, 0.75)) +
geom_point(data = tortoise,
aes(shrub_density, slope_pct, shape = factor(detected)),
colour = "white", size = 1.5, inherit.aes = FALSE, alpha = 0.6) +
scale_fill_gradient2(low = "#1e3a2f", mid = "#8aad96", high = "#c8832a",
midpoint = 0.5, name = "P(detect)") +
scale_shape_manual(values = c(1, 16), name = "Detected",
labels = c("No","Yes")) +
labs(x = "Shrub density (plants/0.1 ha)", y = "Slope (%)",
title = "Desert tortoise: predicted detection probability") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(family = "serif", face = "bold", colour = "#1e3a2f"))
Predicted detection probability surface across shrub density and slope values. Warm colours (orange) indicate higher predicted probability. White contour lines mark the 0.25, 0.50, and 0.75 probability thresholds.
For GLMs, R² has no direct equivalent, but McFadden’s pseudo-R² and AIC are standard:
# McFadden pseudo-R²
logit_null <- glm(detected ~ 1, family = binomial, data = tortoise)
mcfadden_r2 <- 1 - (logDeviance <- deviance(logit_mod) / deviance(logit_null))
cat("McFadden R² =", round(1 - deviance(logit_mod)/deviance(logit_null), 3), "\n")
## McFadden R² = 0.119
# AIC comparison
AIC(logit_null, logit_mod)
## df AIC
## logit_null 1 159.7179
## logit_mod 3 144.9648
✅ McFadden’s R² — rough guidelines
Values of 0.20–0.40 are generally considered good fit for logistic regression — the scale is compressed relative to OLS R². A McFadden R² of 0.20 is roughly equivalent in explanatory power to an OLS R² of ~0.50.
X1 * X2) model cases
where the slope of one predictor changes with the value of another.
Always include constituent main effects.
family = poisson) is
the default for counts. Exponentiate coefficients to get rate
ratios. Check the dispersion ratio (residual deviance / df) for
overdispersion.
family = binomial)
models P(event). Exponentiate coefficients to get odds ratios.
Use McFadden R² and AIC to evaluate fit.
| Mistake | Why it matters | Fix |
|---|---|---|
| Applying OLS to count data | Predicts negative counts; inflated Type I error from non-normal residuals | Use glm(..., family = poisson) or negative
binomial |
| Applying OLS to binary data | Predicts probabilities outside [0,1]; heteroscedastic errors by construction | Use glm(..., family = binomial) (logistic
regression) |
| Interpreting GLM coefficients on raw scale | Log-scale coefficients are not directly interpretable as slopes | Apply exp(coef(...)) and
exp(confint(...)) to get rate ratios / odds ratios |
| Fitting interaction without main effects | Model is mis-specified; coefficients are uninterpretable | Write X1 * X2 which expands to
X1 + X2 + X1:X2 |
| Selecting by p-value alone, ignoring AIC | Keeps overfitted models; does not account for complexity penalty | Use AIC to compare candidate models; report ΔAIC |
| Ignoring overdispersion in Poisson models | Standard errors are underestimated; spurious significance | Check deviance/df; switch to quasipoisson
or MASS::glm.nb() |