Randomized LASSO for a Gaussian linear model

This vignette shows the basic workflow of lassoinf. We fit a LASSO to a randomized response, then use LassoInference for confidence intervals and p-values for the selected coefficients that are valid after selection.

Randomization and the proportion used for selection

In data splitting, a proportion \(\pi\) of the data is used to select the model and the rest for inference. For the score \(Z = X'y\), with variance \(\Sigma = \sigma^2 X'X\), the score of a \(\pi\) fraction of the data has variance \(\Sigma / \pi\).

Randomization instead selects with \(Z_{noisy} = Z + \omega\), \(\omega \sim N(0, \Omega)\). Matching \(\text{Var}(Z_{noisy}) = \Sigma + \Omega = \Sigma / \pi\) gives

\[ \Omega = \frac{1 - \pi}{\pi} \Sigma. \]

For least squares, this is the same as fitting the LASSO to \(y_{noisy} = y + \epsilon\) with \(\epsilon \sim N(0, \frac{1-\pi}{\pi}\sigma^2 I)\). As \(\pi \to 1\) almost all the information is used for selection and little is left for inference. Smaller \(\pi\) leaves more.

Data

We use \(n = 100\) and \(p = 20\), with one strong effect, two weaker ones and \(\sigma^2 = 2\). Selection uses \(\pi = 0.9\).

library(lassoinf)
set.seed(0)

n <- 100; p <- 20
signal_strength <- 4
X <- matrix(rnorm(n * p), n, p)
beta <- numeric(p)
beta[1:3] <- c(2, 1, -1) * signal_strength / sqrt(n)
sigma2 <- 2
y <- drop(X %*% beta) + rnorm(n, sd = sqrt(sigma2))

With \(n > p\) we estimate \(\sigma^2\) from the full least squares fit, and build \(\Sigma\) and \(\Omega\):

pi_selection <- 0.9
Q <- crossprod(X)
Z_full <- drop(crossprod(X, y))
sigma2_hat <- sum(lm.fit(X, y)$residuals^2) / (n - p)
Sigma <- sigma2_hat * Q
sigma2_rand <- (1 - pi_selection) / pi_selection * sigma2_hat
Sigma_noise <- sigma2_rand * Q
y_noisy <- y + rnorm(n, sd = sqrt(sigma2_rand))
c(sigma2_hat = sigma2_hat, sigma2_rand = sigma2_rand)
#>  sigma2_hat sigma2_rand 
#>   2.1757329   0.2417481

Fit the LASSO on the randomized response

We fit the LASSO \(\frac{1}{2}\|y_{noisy} - Xb\|^2 + \lambda \|b\|_1\). glmnet scales the loss by \(1/n\), so we pass it \(\lambda / n\). LassoInference needs the solution, the gradient of the smooth part at it, \(G = X'(X\hat{\beta} - y_{noisy})\), and its Hessian, \(Q = X'X\).

lam <- 2 * sqrt(n)
fit <- glmnet::glmnet(X, y_noisy, lambda = lam / n, intercept = FALSE,
                      standardize = FALSE, control = list(thresh = 1e-14))
beta_hat <- as.numeric(coef(fit))[-1]
G_hat <- drop(crossprod(X, X %*% beta_hat - y_noisy))
which(beta_hat != 0)
#> [1]  1  3 10 13 14

Inference

The arguments are the solution, gradient and Hessian, the penalty weights \(D\), the bounds \(L\), \(U\) (none here), the full-data score and its covariance, and the randomization covariance.

li <- LassoInference$new(beta_hat, G_hat, Q, rep(lam, p), NULL, NULL,
                         Z_full, Sigma, Sigma_noise, level = 0.9)
summ <- li$summary()
summ
#>    index    beta_hat  lower_conf  upper_conf      p_value
#> 0      0  0.41432718  0.50799234  1.08675205 7.329526e-05
#> 2      2 -0.03783531 -0.57080194  0.04672353 1.456693e-01
#> 9      9 -0.16256389 -0.65087642 -0.01061865 9.066102e-02
#> 12    12 -0.02702932 -0.02090107  1.03813586 1.129303e-01
#> 13    13  0.13122199 -0.49668608  0.46893379 8.089657e-01

index is 0-based. beta_hat is the LASSO solution, which is shrunk toward zero and can lie outside its interval. The intervals are for the coefficients of the selected model, \(\beta_E = (X_E'X_E)^{-1} X_E' \mu\) with \(\mu = E[y]\). They come from the contrasts in li$contrasts, so we can compute the targets and check coverage:

Z_mu <- drop(Q %*% beta)
summ$truth <- sapply(as.character(summ$index), function(j) sum(li$contrasts[[j]] * Z_mu))
summ$cover <- summ$lower_conf <= summ$truth & summ$truth <= summ$upper_conf
summ
#>    index    beta_hat  lower_conf  upper_conf      p_value        truth cover
#> 0      0  0.41432718  0.50799234  1.08675205 7.329526e-05  0.855889010  TRUE
#> 2      2 -0.03783531 -0.57080194  0.04672353 1.456693e-01 -0.406730210  TRUE
#> 9      9 -0.16256389 -0.65087642 -0.01061865 9.066102e-02  0.009152833 FALSE
#> 12    12 -0.02702932 -0.02090107  1.03813586 1.129303e-01  0.006070596  TRUE
#> 13    13  0.13122199 -0.49668608  0.46893379 8.089657e-01 -0.040488544  TRUE

summary() also stores naive intervals, which ignore selection, and data-splitting intervals in li$"_naive" and li$"_splitting":

li$`_naive`
#>    index   beta_hat  lower_conf  upper_conf      p_value
#> 0      0  0.8038264  0.52088364  1.08676909 2.969066e-06
#> 2      2 -0.3414098 -0.57683903 -0.10598055 1.706525e-02
#> 9      9 -0.3485781 -0.57251641 -0.12463971 1.045677e-02
#> 12    12 -0.1524278 -0.39815843  0.09330274 3.075812e-01
#> 13    13  0.2684742  0.01106352  0.52588484 8.624453e-02
li$`_splitting`
#>    index   beta_hat lower_conf upper_conf     p_value
#> 0      0  1.4619576  0.5672141  2.3567010 0.007196907
#> 2      2 -1.4585552 -2.2030478 -0.7140625 0.001270877
#> 9      9 -0.4672224 -1.1753776  0.2409328 0.277819090
#> 12    12  0.6308092 -0.1462591  1.4078776 0.181791012
#> 13    13 -0.3747552 -1.1887592  0.4392488 0.448890562

Coverage over repeated experiments

The randomized intervals should cover their targets 90% of the time. The naive intervals should not.

one_replication <- function() {
  X <- matrix(rnorm(n * p), n, p)
  y <- drop(X %*% beta) + rnorm(n, sd = sqrt(sigma2))
  Q <- crossprod(X)
  sigma2_hat <- sum(lm.fit(X, y)$residuals^2) / (n - p)
  sigma2_rand <- (1 - pi_selection) / pi_selection * sigma2_hat
  y_noisy <- y + rnorm(n, sd = sqrt(sigma2_rand))
  fit <- glmnet::glmnet(X, y_noisy, lambda = lam / n, intercept = FALSE,
                        standardize = FALSE, control = list(thresh = 1e-14))
  beta_hat <- as.numeric(coef(fit))[-1]
  if (all(beta_hat == 0)) return(NULL)
  G_hat <- drop(crossprod(X, X %*% beta_hat - y_noisy))
  li <- LassoInference$new(beta_hat, G_hat, Q, rep(lam, p), NULL, NULL,
                           drop(crossprod(X, y)), sigma2_hat * Q, sigma2_rand * Q, level = 0.9)
  summ <- li$summary()
  truth <- sapply(as.character(summ$index), function(j) sum(li$contrasts[[j]] * drop(Q %*% beta)))
  covers <- function(df) df$lower_conf <= truth & truth <= df$upper_conf
  data.frame(method = rep(c("randomized", "naive", "splitting"), each = nrow(summ)),
             cover = c(covers(summ), covers(li$`_naive`), covers(li$`_splitting`)),
             length = c(summ$upper_conf - summ$lower_conf,
                        li$`_naive`$upper_conf - li$`_naive`$lower_conf,
                        li$`_splitting`$upper_conf - li$`_splitting`$lower_conf))
}

set.seed(1)
results <- do.call(rbind, replicate(200, one_replication(), simplify = FALSE))
data.frame(coverage = tapply(results$cover, results$method, mean),
           median_length = tapply(results$length, results$method, median))
#>             coverage median_length
#> naive      0.7381166     0.4782847
#> randomized 0.8914798     0.8712314
#> splitting  0.9013453     1.5124690

The randomized intervals use the information that selection left over, so they are typically shorter than data splitting while keeping the nominal coverage.