--- title: "Inference after a glmnet fit: carving for logistic regression" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Inference after a glmnet fit} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = requireNamespace("glmnet", quietly = TRUE)) ``` This vignette shows **carving** in logistic regression. We select variables with glmnet on a random half of the data, then do inference with all of it, accounting for selection. `glmnet_inference` does this from the fitted `glmnet` object. It uses `glmnet_problem_from_fit`, which returns the LASSO problem glmnet solved in the original coordinates: the solution, the gradient and Hessian of the smooth part, the penalty weights and the bounds. For inference without held-out data see `vignette("no_randomization", package = "lassoinf")`. ## Carving as randomization glmnet divides the loss by the number of observations, and we use this per-observation scale throughout. On the subsample of size $m = \pi n$, glmnet's problem gives the LASSO solution $\hat{\beta}$, the gradient $G$ at $\hat{\beta}$, and the information $\hat{Q}$ at the one-step relaxed fit on the selected model $E$ (see `?glmnet_problem`). `LassoInference` linearizes the selection with $\hat{Q}$ and selects with $$ Z + \omega = -G + \hat{Q}\hat{\beta}. $$ The full-data statistic uses the same matrix and the full-data gradient $G_{full}$, at one Newton step for the full-data loss on $E$ from $\hat{\beta}$, $\tilde{\beta} = \hat{\beta} - H_{E,E}^{-1} G_{full,E}$: $$ Z = -G_{full}(\tilde{\beta}) + \hat{Q}\tilde{\beta}. $$ Both are $\hat{Q}$ times approximations to the unpenalized MLE, on the subsample and on all the data. With $I$ the per-observation Fisher information, the subsample MLE minus the full-data MLE is asymptotically independent of the full-data MLE, with variance $I^{-1}(1/m - 1/n)$, and $\hat{Q} \approx I$. So $$ \Sigma = \text{Var}(Z) \approx \hat{Q} / n, \qquad \text{Var}(\omega) \approx \frac{1 - \pi}{\pi}\, \Sigma, $$ and selecting on a subsample is randomized selection with `scalar_noise` $= (1 - \pi) / \pi$, which is 1 for $\pi = 1/2$. `glmnet_inference` with `selection_rows` does all of this. ## Data ```{r} library(lassoinf) set.seed(0) n <- 600; p <- 30 beta <- c(0.6, -0.6, 0.4, rep(0, p - 3)) make_data <- function(n) { X <- matrix(rnorm(n * p), n, p) y <- rbinom(n, 1, plogis(drop(X %*% beta))) list(X = X, y = y) } d <- make_data(n) ``` ## Select on half of the data with glmnet We fit a path that ends at a fixed $\lambda$, with the default standardization and an unpenalized intercept. The coordinates are $(\text{intercept}, \beta)$, so index 0 is the intercept. `glmnet_inference` warns if the fit does not satisfy the KKT conditions closely enough, so a small `thresh` matters. ```{r} pi_selection <- 0.5 lam <- 0.06 lambda_path <- lam * exp(seq(log(10), 0, length.out = 30)) select <- function(d) { rows <- sample(nrow(d$X), round(pi_selection * nrow(d$X))) fit <- glmnet::glmnet(d$X[rows, ], d$y[rows], family = "binomial", lambda = lambda_path, control = list(thresh = 1e-14)) list(fit = fit, rows = rows) } sel <- select(d) which(coef(sel$fit, s = lam)[-1] != 0) ``` ## Inference ```{r} li <- glmnet_inference(sel$fit, d$X, d$y, s = lam, selection_rows = sel$rows, level = 0.9) li$scalar_noise summ <- li$summary() summ$variable <- ifelse(summ$index == 0, "(Intercept)", paste0("x", summ$index)) summ ``` The estimate for the selected model is $W Z_E$, with $W = \hat{Q}_{E,E}^{-1}$, and its target is $W (\hat{Q}\beta^*)_E$, with $\beta^* = (0, \beta)$ the true coefficients including the intercept. When $E$ contains all the true signals, the target is $\beta^*_E$. Otherwise it is the projection of $\beta^*$ onto the coordinates in $E$, in the metric given by the information matrix $\hat{Q}$. ```{r} target <- function(li) drop(li$W %*% (li$Q_hat %*% c(0, beta))[li$E + 1]) summ$truth <- target(li) summ$cover <- summ$lower_conf <= summ$truth & summ$truth <= summ$upper_conf summ[, c("variable", "lower_conf", "upper_conf", "p_value", "truth", "cover")] ``` For comparison, `li$"_naive"` holds Wald intervals that use all the data and ignore selection. Data splitting refits the selected model on the half of the data not used for selection: ```{r} splitting <- function(d, rows, li, level = 0.9) { held_out <- setdiff(seq_len(nrow(d$X)), rows) X1 <- cbind(1, d$X)[held_out, li$E + 1, drop = FALSE] fit <- glm.fit(X1, d$y[held_out], family = binomial()) est <- unname(coef(fit)) mu <- fit$fitted.values sd <- sqrt(diag(solve(crossprod(X1, X1 * (mu * (1 - mu)))))) q <- qnorm(1 - (1 - level) / 2) data.frame(index = li$E, estimate = est, lower_conf = est - q * sd, upper_conf = est + q * sd) } li$`_naive` splitting(d, sel$rows, li) ``` The held-out refit targets the coefficients of the best-fitting logistic model on $X_E$. That is the same as the target above when $E$ contains all the true signals. ## Coverage over repeated experiments ```{r} one_replication <- function() { d <- make_data(n) sel <- select(d) li <- glmnet_inference(sel$fit, d$X, d$y, s = lam, selection_rows = sel$rows, level = 0.9) summ <- li$summary() keep <- summ$index != 0 # report the selected variables, not the intercept truth <- target(li)[keep] covers <- function(df) (df$lower_conf <= truth & truth <= df$upper_conf) lengths <- function(df) df$upper_conf - df$lower_conf tabs <- list(carving = summ[keep, ], naive = li$`_naive`[keep, ], splitting = splitting(d, sel$rows, li)[keep, ]) data.frame(method = rep(names(tabs), each = sum(keep)), cover = unlist(lapply(tabs, covers)), length = unlist(lapply(tabs, lengths)), signal = rep(beta[summ$index[keep]] != 0, length(tabs))) } set.seed(1) results <- do.call(rbind, replicate(100, one_replication(), simplify = FALSE)) data.frame(coverage = tapply(results$cover, results$method, mean), median_length = tapply(results$length, results$method, median)) ``` Carving and data splitting both have close to the nominal 90% coverage. Carving also uses the half of the data that selected the model, so its intervals are shorter. The naive intervals ignore selection and undercover, almost entirely on the selected null variables: ```{r} with(results, tapply(cover, list(method, signal = signal), mean)) ```