Multiple Regression & Generalized Linear Models

Marc Los Huertos

EA030 · Compiled 2026-04-09

Overview Multiple Regression Interactions Model Selection GLMs Poisson Logistic Summary

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.


Why Multiple Predictors?

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.


Part 1 — Multiple Linear Regression

Dataset: Los Angeles air quality

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,…

Step 1 — Explore pairwise relationships

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.

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.

Step 2 — Fit the multiple regression model

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

  • Estimate — the partial slope (change in ozone per unit change in predictor, all else equal)
  • Std. Error — uncertainty in that estimate
  • t value — Estimate ÷ Std. Error; large |t| → predictor matters
  • Pr(>|t|) — two-sided p-value; small → unlikely under H₀: β = 0
  • R² — proportion of total ozone variance explained by the full model
  • F-statistic — tests whether any predictor improves on the intercept-only model

Step 3 — Interpret the coefficients

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

  • Solar radiation: each additional Langley of solar radiation is associated with a small increase in ozone, holding wind and temperature fixed — more photons, more photochemistry.
  • Wind speed: each additional mph of wind is associated with a decrease in ozone — wind dilutes and disperses the pollutant.
  • Temperature: each additional °F is associated with an increase in ozone — heat accelerates the chemical reactions forming ozone.

Step 4 — Diagnostic plots

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.

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.

Step 5 — Visualise partial (added-variable) relationships

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.

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.


Part 2 — Interaction Terms

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.

Environmental motivation

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.

Plotting the interaction

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.

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.


Part 3 — Model Selection with AIC

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


Part 4 — Generalized Linear Models

Ordinary linear regression assumes:

  1. The response variable is continuous and unbounded
  2. Errors are normally distributed with constant variance

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.

Common GLM families for environmental data.
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

Part 5 — GLM for Count Data (Poisson Regression)

Environmental context: riparian bird counts

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

Step 1 — Explore the response

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.

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.

Step 2 — Fit the Poisson GLM

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

Step 3 — Interpret coefficients

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 equal
  • exp(β_disturbanceHigh) ≈ 0.58 → high-disturbance sites are expected to have about 42% fewer species than low-disturbance sites, holding canopy cover constant

Step 4 — Check for overdispersion

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

Step 5 — Visualise predictions

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.

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.


Part 6 — GLM for Binary Data (Logistic Regression)

Environmental context: desert tortoise presence/absence

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

Step 1 — Visualise raw detection rates

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.

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.

Step 2 — Fit the logistic regression

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

Step 3 — Interpret coefficients as odds ratios

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)
  • Odds ratios > 1 → positive association; < 1 → negative association

Step 4 — Predicted probability surface

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.

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.

Step 5 — Model fit for GLMs

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.


Summary

🔑 Key takeaways for multiple regression and GLMs

Common mistakes to avoid

Common pitfalls in multiple regression and GLMs.
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()

What’s next?