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

