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").
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.
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)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.
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)
#> [1] 1 2 3li <- glmnet_inference(sel$fit, d$X, d$y, s = lam, selection_rows = sel$rows, level = 0.9)
li$scalar_noise
#> [1] 1
summ <- li$summary()
summ$variable <- ifelse(summ$index == 0, "(Intercept)", paste0("x", summ$index))
summ
#> index beta_hat lower_conf upper_conf p_value variable
#> 0 0 -0.09794322 -0.1604769 0.2091305 8.675623e-01 (Intercept)
#> 1 1 0.37009397 0.6443921 0.9557616 2.042810e-14 x1
#> 2 2 -0.25187169 -0.7054899 -0.3597215 3.304804e-06 x2
#> 3 3 0.09067492 0.1822282 0.5512053 1.654996e-03 x3The 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}\).
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")]
#> variable lower_conf upper_conf p_value truth cover
#> 0 (Intercept) -0.1604769 0.2091305 8.675623e-01 -1.796405e-20 TRUE
#> 1 x1 0.6443921 0.9557616 2.042810e-14 6.000000e-01 FALSE
#> 2 x2 -0.7054899 -0.3597215 3.304804e-06 -6.000000e-01 TRUE
#> 3 x3 0.1822282 0.5512053 1.654996e-03 4.000000e-01 TRUEFor 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:
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`
#> index beta_hat lower_conf upper_conf p_value
#> 0 0 -0.05541122 -0.2022343 0.09141183 5.347510e-01
#> 1 1 0.80118217 0.6465780 0.95578637 1.542972e-17
#> 2 2 -0.55326082 -0.7105093 -0.39601230 7.155738e-09
#> 3 3 0.42623038 0.2744639 0.57799682 3.846473e-06
splitting(d, sel$rows, li)
#> index estimate lower_conf upper_conf
#> 1 0 0.04189285 -0.1713128 0.2550985
#> 2 1 0.91872578 0.6617891 1.1756625
#> 3 2 -0.53284884 -0.7485171 -0.3171805
#> 4 3 0.44722400 0.2334200 0.6610280The 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.
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))
#> coverage median_length
#> carving 0.8969359 0.3641605
#> naive 0.8328691 0.3093225
#> splitting 0.9247911 0.4400042Carving 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:
with(results, tapply(cover, list(method, signal = signal), mean))
#> signal
#> FALSE TRUE
#> carving 0.8815789 0.9010601
#> naive 0.5000000 0.9222615
#> splitting 0.9210526 0.9257951