--- title: "Penguins: why logistic `glm` may not converge" author: "Aparna Pandey and Stephan Peischl" format: html: toc: true code-tools: true engine: knitr --- ```{r setup, include=FALSE} knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE) suppressPackageStartupMessages({ library(tidymodels) library(palmerpenguins) library(dplyr) library(ggplot2) library(glmnet) }) theme_set(theme_minimal(base_size = 13)) ``` ## Goal Short explanation of a common warning in logistic regression: - `glm.fit: algorithm did not converge` - or probabilities numerically at `0` / `1` This usually appears when the model can (almost) **separate** classes, especially with many predictors and limited sample size. ## 1) A normal logistic model (usually converges) ```{r data} peng <- penguins |> filter(species %in% c("Adelie", "Gentoo")) |> mutate( y = factor(species, levels = c("Adelie", "Gentoo")), year = as.numeric(year) ) |> select(y, bill_length_mm, bill_depth_mm, flipper_length_mm, body_mass_g, island, sex, year) |> drop_na() nrow(peng) ``` ```{r glm-ok} m_ok <- glm( y ~ bill_length_mm + bill_depth_mm + flipper_length_mm + body_mass_g + island + sex + year, data = peng, family = binomial ) m_ok$converged summary(m_ok)$coefficients |> head() ``` ## 1.5) 1D intuition: steeper and steeper logistic curves In 1D, logistic regression fits \[ \Pr(y=1 \mid x)=\text{logit}^{-1}(\beta_0 + \beta_1 x). \] As classes become easier to separate on a single feature, the fitted slope \(\beta_1\) gets larger, so the S-curve becomes steeper. Under (near-)perfect separation, \(\beta_1\) can keep growing rather than settling to a stable finite value. ```{r one-d-separation} set.seed(42) make_1d <- function(gap = 0.0, n = 70) { x0 <- rnorm(n, mean = -0.7 - gap / 2, sd = 0.55) x1 <- rnorm(n, mean = 0.7 + gap / 2, sd = 0.55) tibble( x = c(x0, x1), y = factor(c(rep(0, n), rep(1, n)), levels = c(0, 1), labels = c("Class 0", "Class 1")), scenario = paste0("gap=", gap) ) } d_small <- make_1d(gap = 0.0) d_med <- make_1d(gap = 0.8) d_big <- make_1d(gap = 1.8) d_1d <- bind_rows(d_small, d_med, d_big) fit_1d <- function(dat) glm(y ~ x, data = dat, family = binomial) mods_1d <- d_1d |> group_split(scenario) |> setNames(unique(d_1d$scenario)) |> lapply(fit_1d) coef_tbl <- tibble( scenario = names(mods_1d), intercept = sapply(mods_1d, \(m) coef(m)[1]), slope = sapply(mods_1d, \(m) coef(m)[2]), converged = sapply(mods_1d, \(m) m$converged) ) coef_tbl ``` ```{r one-d-curves, fig.width=9, fig.height=4.8} grid_df <- d_1d |> group_by(scenario) |> summarize(x = list(seq(min(x) - 0.4, max(x) + 0.4, length.out = 250)), .groups = "drop") |> tidyr::unnest(x) |> group_by(scenario) |> mutate( p = predict(mods_1d[[first(scenario)]], newdata = cur_data(), type = "response") ) |> ungroup() ggplot() + geom_jitter( data = d_1d, aes(x = x, y = as.numeric(y) - 1, color = y), height = 0.045, alpha = 0.35, size = 1.5 ) + geom_line( data = grid_df, aes(x = x, y = p), linewidth = 1.1, color = "black" ) + facet_wrap(~scenario, nrow = 1) + scale_color_brewer(palette = "Set1") + scale_y_continuous(limits = c(-0.02, 1.02), breaks = c(0, 0.5, 1)) + labs( title = "1D logistic fit as separation increases", subtitle = "Fitted probability curve gets steeper as class gap increases", x = "Single predictor x", y = "P(Class 1)" ) ``` ```{r one-d-coef-plot, fig.width=6.8, fig.height=4} ggplot(coef_tbl, aes(scenario, abs(slope), fill = scenario)) + geom_col(show.legend = FALSE) + labs( title = "Absolute slope |beta1| grows with separation", x = NULL, y = "|beta1|" ) ``` ## 2) Force a near-separated, high-dimensional situation Here we deliberately create a bad setup to mirror what happens in wide microbiome tables: 1. add many noisy columns (`p` grows quickly), and 2. add one **leaky proxy** of the label (`leak`) that is almost the answer. ```{r bad-design} set.seed(7) p_noise <- 220 noise_mat <- matrix(rnorm(nrow(peng) * p_noise), nrow = nrow(peng)) colnames(noise_mat) <- paste0("noise_", seq_len(p_noise)) peng_bad <- bind_cols( peng, as.data.frame(noise_mat) ) |> mutate( # almost perfectly separates Gentoo from Adelie leak = ifelse(y == "Gentoo", 1, 0) + rnorm(n(), sd = 0.01) ) dim(peng_bad) ``` ```{r glm-warning} form_bad <- as.formula( paste("y ~", paste(setdiff(names(peng_bad), "y"), collapse = " + ")) ) warn_msg <- NULL m_bad <- withCallingHandlers( glm(form_bad, data = peng_bad, family = binomial), warning = function(w) { warn_msg <<- conditionMessage(w) invokeRestart("muffleWarning") } ) list( converged = m_bad$converged, warning = warn_msg ) ``` ```{r probs} phat_bad <- predict(m_bad, type = "response") tibble( min_p = min(phat_bad), p01 = mean(phat_bad < 0.01), p99 = mean(phat_bad > 0.99), max_p = max(phat_bad) ) ``` When probabilities bunch up at extremes and the optimizer struggles, the model is effectively trying to push coefficients toward very large values. ## 3) Why lasso is more stable Lasso (`glmnet`) adds a penalty that keeps coefficients finite and controls overfitting. ```{r lasso} x <- model.matrix(y ~ . - 1, data = peng_bad) y01 <- ifelse(peng_bad$y == "Gentoo", 1, 0) set.seed(7) cv_lasso <- cv.glmnet(x, y01, family = "binomial", alpha = 1, nfolds = 5) coef_lasso <- coef(cv_lasso, s = "lambda.min") n_nonzero <- sum(coef_lasso != 0) - 1 n_nonzero ``` ```{r compare-train} pred_bad <- factor(ifelse(phat_bad > 0.5, "Gentoo", "Adelie"), levels = levels(peng_bad$y)) acc_bad <- mean(pred_bad == peng_bad$y) phat_lasso <- as.numeric(predict(cv_lasso, newx = x, s = "lambda.min", type = "response")) pred_lasso <- factor(ifelse(phat_lasso > 0.5, "Gentoo", "Adelie"), levels = levels(peng_bad$y)) acc_lasso <- mean(pred_lasso == peng_bad$y) tibble( model = c("Unpenalized glm", "Lasso (glmnet)"), train_accuracy = c(acc_bad, acc_lasso) ) ``` ## Take-home message - With limited `n` and many predictors, unpenalized logistic regression can hit **(quasi-)separation**. - Then coefficient estimates become unstable, probabilities saturate near `0/1`, and convergence warnings appear. - Penalized models (lasso/ridge/elastic net) are usually safer in high-dimensional settings.