After the LASSO selects an active set \(E\), a common heuristic screens the
inactive variables whose gradient at the LASSO solution, \(|\nabla \ell(\hat{\beta})_j|\), is largest.
ScreenedSelection adds this screening step to the selection
event, so the polyhedral lemma still gives valid inference. The usual
question is whether a screened variable \(j\) belongs in the model \(E \cup \{j\}\).
Here we screen the same way, keeping the top 10 inactive variables, but ask a different question. A screened variable \(j\) may be a surrogate for a selected variable \(s\): the two are correlated and the LASSO picked \(s\) where it could have picked \(j\). To check this, for each selected \(s \in E\) and screened \(j\) we drop \(s\) from the model, put \(j\) in its place, and test
\[ H_0: \beta_{j \cdot F_{s,j}} = 0, \qquad F_{s,j} = (E \setminus \{s\}) \cup \{j\}, \]
the coefficient of \(x_j\) in the model \(F_{s,j}\), or build a confidence interval for it.
Only the target changes. The selection event (LASSO,
then screening) stays the same, so we reuse
ScreenedSelection as it is and pass it a new contrast.
With \(Q = X'X\) and \(W = Q_{E,E}^{-1}\), the coefficient of \(j\) in \(F = F_{s,j}\) is
\[ \hat{\theta}_{s,j} = e_j'\, Q_{F,F}^{-1} Z_F = \eta_{s,j}' Z, \qquad (\eta_{s,j})_F = Q_{F,F}^{-1} e_j, \quad (\eta_{s,j})_{-F} = 0, \]
which for least squares is the coefficient of \(x_j\) in the regression of \(y\) on \(X_F\). The estimand is \(\eta_{s,j}' X'\mu\), the coefficient of \(x_j\) in the regression of \(\mu = E[y]\) on \(X_F\).
The matrix \(Q_{F,F}\) is built from
the information matrix of the model \(E\). Its block on \(E \setminus \{s\}\) is a submatrix of \(Q_{E,E} = W^{-1}\), which we already have.
The rest is \(Q_{j,E \setminus \{s\}}\)
and \(Q_{jj}\), which come from one
matvec \(Q e_j\) per screened variable,
shared by all \(s\). For a GLM, \(Q\) is the Q_hat used for
selection. We do not refit the model \(F\) or recompute its information
matrix.
Equivalently, with \(G = E \cup \{j\}\) and \(M = Q_{G,G}^{-1}\), dropping \(s\) from \(G\) gives
\[ \hat{\theta}_{s,j} = \hat{\beta}_{j \cdot G} - \frac{M_{js}}{M_{ss}}\, \hat{\beta}_{s \cdot G}. \]
This is the screened coefficient of \(j\) minus a multiple of the coefficient of \(s\) in the same model. Because \(x_j\) and \(x_s\) are correlated, part of the effect of \(s\) moves onto \(j\). The contrast is linear in \(Z\), so the polyhedral lemma applies with the screening constraints we already have.
We use two designs, each with \(n = 100\) observations, \(p = 300\) features and 15 nonzero coefficients on every 20th feature, and \(\sigma^2 = 1\) treated as known:
Selection uses a randomized response \(y_{noisy} = y + \epsilon\) with \(\epsilon \sim N(0, \frac{1-\pi}{\pi}\sigma^2 I)\) and \(\pi = 0.8\).
The package reports variables (li$E,
screen$screened) 0-based. In this vignette we add 1 and
work with ordinary R indices.
library(lassoinf)
n <- 100; p <- 300; k <- 15
pi_selection <- 0.8
scalar_noise <- (1 - pi_selection) / pi_selection # Var(omega) = scalar_noise * Sigma
signals <- seq(1, p, by = p / k)
make_data <- function(rho, amp) {
# AR(1) columns: corr(x_i, x_l) = rho^|i - l|; rho = 0 gives independent columns
X <- matrix(rnorm(n * p), n, p)
for (i in 2:p) X[, i] <- rho * X[, i - 1] + sqrt(1 - rho^2) * X[, i]
beta <- numeric(p)
beta[signals] <- amp / sqrt(n) * sample(c(-1, 1), k, replace = TRUE)
mu <- drop(X %*% beta)
y <- mu + rnorm(n)
y_noisy <- y + rnorm(n, sd = sqrt(scalar_noise))
list(X = X, beta = beta, mu = mu, y = y, y_noisy = y_noisy)
}We fit the LASSO \(\frac{1}{2}\|y_{noisy} -
Xb\|^2 + \lambda \|b\|_1\) with glmnet. glmnet
scales the loss by \(1/n\), so we pass
it \(\lambda / n\). \(Q = X'X\) and \(\Sigma = \sigma^2 X'X\) are matrix-free
operators.
lam <- 0.8 * sqrt(2 * n * log(p))
lasso_inference <- function(d) {
fit <- glmnet::glmnet(d$X, d$y_noisy, lambda = lam / n, intercept = FALSE,
standardize = FALSE, control = list(thresh = 1e-14))
beta_hat <- as.numeric(coef(fit))[-1]
beta_hat[abs(beta_hat) < 1e-6] <- 0
G_hat <- drop(crossprod(d$X, d$X %*% beta_hat - d$y_noisy))
Q <- xtvx_operator(d$X)
LassoInference$new(beta_hat, G_hat, Q, rep(lam, p), NULL, NULL,
drop(crossprod(d$X, d$y)), Q, NULL, scalar_noise, level = 0.9)
}swap_estimands builds the contrasts \(\eta_{s,j}\). It stores \(Q_{E,E} = W^{-1}\) and caches the column
\(Q e_j\) of each screened variable.
Each contrast then costs one \(|E| \times
|E|\) solve.
swap_estimands <- function(li) {
E <- li$E + 1
Q_EE <- solve(li$W)
Q_EE <- (Q_EE + t(Q_EE)) / 2
cache <- new.env()
column <- function(j) {
key <- as.character(j)
if (is.null(cache[[key]])) {
e_j <- numeric(p); e_j[j] <- 1
cache[[key]] <- drop(li$Q_hat$multiply(e_j))
}
cache[[key]]
}
# F = (E minus {s}) plus {j}, with j last
model <- function(s, j) c(E[E != s], j)
swap_coef <- function(s, j) {
if (!(s %in% E) || j %in% E) stop("need s in E and j not in E")
Q_j <- column(j)
keep <- E != s
F <- model(s, j)
m <- length(F)
Q_FF <- matrix(0, m, m)
Q_FF[-m, -m] <- Q_EE[keep, keep]
Q_FF[-m, m] <- Q_FF[m, -m] <- Q_j[F[-m]]
Q_FF[m, m] <- Q_j[j]
eta <- numeric(p)
eta[F] <- solve(Q_FF, c(numeric(m - 1), 1))
contrast_estimand(eta)
}
list(model = model, swap_coef = swap_coef)
}analyze runs the whole pipeline on one data set. It fits
the LASSO, screens the top 10 inactive variables with the unchanged
ScreenedSelection, and then loops over every pair \((s, j)\), giving \(|E| \times 10\) estimands.
estimand_summary with the ScreenedSelection
conditions on both selection steps. The target \(\eta_{s,j}' X'\mu\) is shown in the
truth column.
swap_table <- function(selection, swaps, selected, screened) {
pairs <- expand.grid(added = screened, dropped = selected)[, c("dropped", "added")]
estimands <- Map(swaps$swap_coef, pairs$dropped, pairs$added)
names(estimands) <- paste(pairs$dropped, pairs$added, sep = ":")
df <- estimand_summary(selection, estimands)
list(table = cbind(pairs, df[, -1]), estimands = estimands)
}
analyze <- function(d) {
li <- lasso_inference(d)
screen <- ScreenedSelection$new(li, top_k = 10)
swaps <- swap_estimands(li)
E <- li$E + 1; screened <- screen$screened + 1
cat(length(E), "active variables,", sum(d$beta[E] != 0), "of them true signals;",
sum(d$beta[-E] != 0), "true signals were left out\n")
cat("screened:", screened, "\n")
st <- swap_table(screen, swaps, E, screened)
tab <- st$table
tab$truth <- sapply(st$estimands, estimand_value, Z = drop(crossprod(d$X, d$mu)))
tab$dropped_signal <- d$beta[tab$dropped] != 0
tab$added_signal <- d$beta[tab$added] != 0
tab$adjacent <- abs(tab$dropped - tab$added) <= 2
rownames(tab) <- NULL
list(li = li, screen = screen, swaps = swaps, E = E, screened = screened,
table = tab, estimands = st$estimands)
}
p_value_grid <- function(tab) round(xtabs(p_value ~ dropped + added, tab), 3)To compare, we also compute the LASSO-only tests, which use the LASSO constraints but ignore the screening step, and naive tests that ignore selection altogether:
naive_p_value <- function(li, est) {
sd <- sqrt(li$si$compute_contrast(est$eta)$naive_variance)
2 * pnorm(-abs(estimand_value(est, li$Z_full)) / sd)
}
compare_p_values <- function(a) {
lasso_only <- swap_table(a$li, a$swaps, a$E, a$screened)$table
data.frame(a$table[, c("dropped", "added")],
screened = a$table$p_value,
lasso_only = lasso_only$p_value,
naive = sapply(a$estimands, naive_p_value, li = a$li), row.names = NULL)
}For repeated experiments, we record for each pair \((s, j)\) whether the 90% interval covers \(\eta_{s,j}' X'\mu\), and whether the test rejects at level 0.1. Intervals from the same data set are dependent, so the averages are over both replications and pairs.
one_replication <- function(rho, amp) {
d <- make_data(rho, amp)
li <- lasso_inference(d)
screen <- ScreenedSelection$new(li, top_k = 10)
swaps <- swap_estimands(li)
Z_mu <- drop(crossprod(d$X, d$mu))
q <- qnorm(0.95)
rows <- list()
for (s in li$E + 1) for (j in screen$screened + 1) {
est <- swaps$swap_coef(s, j)
target <- estimand_value(est, Z_mu)
sd <- sqrt(li$si$compute_contrast(est$eta)$naive_variance)
theta <- estimand_value(est, li$Z_full)
info <- data.frame(dropped_signal = d$beta[s] != 0, added_signal = d$beta[j] != 0,
adjacent = abs(s - j) <= 2)
for (method in c("screened", "lasso only")) {
res <- contrast_inference(if (method == "screened") screen else li, est)
rows[[length(rows) + 1]] <- data.frame(method = method,
cover = res$lower_conf <= target && target <= res$upper_conf,
length = res$upper_conf - res$lower_conf, reject = res$p_value < 0.1, info)
}
rows[[length(rows) + 1]] <- data.frame(method = "naive", cover = abs(theta - target) <= q * sd,
length = 2 * q * sd, reject = abs(theta) > q * sd, info)
}
do.call(rbind, rows)
}
simulate <- function(rho, amp, nsim, seed = 1) {
set.seed(seed)
do.call(rbind, replicate(nsim, one_replication(rho, amp), simplify = FALSE))
}
coverage_table <- function(results) {
data.frame(coverage = tapply(results$cover, results$method, mean),
median_length = tapply(results$length, results$method, median))
}set.seed(0)
d <- make_data(rho = 0, amp = 3)
a <- analyze(d)
#> 6 active variables, 1 of them true signals; 14 true signals were left out
#> screened: 21 275 112 128 115 297 253 81 279 125As a check, the estimate is the least squares coefficient of \(x_j\) on \(X_F\):
s <- a$E[1]; j <- a$screened[1]
F <- a$swaps$model(s, j)
c(ols = unname(tail(qr.solve(d$X[, F], d$y), 1)),
swap = estimand_value(a$swaps$swap_coef(s, j), a$li$Z_full))
#> ols swap
#> 0.171792 0.171792head(a$table, 10)
#> dropped added estimate lower_conf upper_conf p_value truth
#> 1 5 21 0.1717920 -0.09606102 0.6231150 0.22803446 0.254789202
#> 2 5 275 0.1624549 -0.28531803 0.3926296 0.79441827 0.010198638
#> 3 5 112 -0.1611645 -0.47233129 0.2600691 0.63349694 0.022210276
#> 4 5 128 -0.1005752 -0.28404646 0.4191999 0.75206212 -0.073187870
#> 5 5 115 -0.1640124 -0.34248901 0.3208632 0.95714797 -0.006686096
#> 6 5 297 0.1543233 -0.32956066 0.4441427 0.80744458 0.139615442
#> 7 5 253 -0.1077188 -0.11600897 0.6941805 0.24067268 -0.062439529
#> 8 5 81 0.2888889 0.04214024 0.8487525 0.06925875 0.295175157
#> 9 5 279 -0.1084395 -0.24839617 0.5555619 0.52974231 -0.040221170
#> 10 5 125 0.2368126 -0.19905394 0.5475778 0.44259914 0.040440723
#> dropped_signal added_signal adjacent
#> 1 FALSE TRUE FALSE
#> 2 FALSE FALSE FALSE
#> 3 FALSE FALSE FALSE
#> 4 FALSE FALSE FALSE
#> 5 FALSE FALSE FALSE
#> 6 FALSE FALSE FALSE
#> 7 FALSE FALSE FALSE
#> 8 FALSE TRUE FALSE
#> 9 FALSE FALSE FALSE
#> 10 FALSE FALSE FALSEThe p-values are easier to read as a grid, with selected variables as rows and screened variables as columns:
p_value_grid(a$table)
#> added
#> dropped 21 81 112 115 125 128 253 275 279 297
#> 5 0.228 0.069 0.633 0.957 0.443 0.752 0.241 0.794 0.530 0.807
#> 101 0.257 0.084 0.554 0.983 0.408 0.694 0.245 0.854 0.582 0.715
#> 106 0.271 0.075 0.625 0.954 0.402 0.693 0.205 0.799 0.613 0.687
#> 156 0.196 0.077 0.402 0.989 0.239 0.917 0.232 0.996 0.757 0.792
#> 177 0.282 0.086 0.587 0.963 0.424 0.690 0.228 0.852 0.545 0.775
#> 204 0.270 0.094 0.555 0.940 0.459 0.730 0.243 0.829 0.544 0.854A small p-value in row \(s\), column \(j\) means that \(x_j\) still carries signal once \(x_s\) is removed from the model. When \(x_j\) is a surrogate for \(x_s\) these p-values are small in row \(s\) and large in the other rows. A screened variable whose p-value is small in every row is a candidate for a missed signal in its own right. With independent features, most columns of the grid change little from row to row: dropping one selected variable barely changes the coefficient of \(x_j\).
The three sets of p-values for the same pairs:
head(compare_p_values(a), 10)
#> dropped added screened lasso_only naive
#> 1 5 21 0.22803446 0.03815790 0.080569181
#> 2 5 275 0.79441827 0.17969244 0.081567092
#> 3 5 112 0.63349694 0.67069736 0.109449713
#> 4 5 128 0.75206212 0.27618682 0.297729262
#> 5 5 115 0.95714797 0.92335662 0.070987779
#> 6 5 297 0.80744458 0.48408157 0.146473635
#> 7 5 253 0.24067268 0.31500545 0.335946581
#> 8 5 81 0.06925875 0.01586708 0.008913217
#> 9 5 279 0.52974231 0.25314737 0.323291883
#> 10 5 125 0.44259914 0.01013596 0.019839301Over 50 replications:
## results_indep <- simulate(rho = 0, amp = 3, nsim = 50)
coverage_table(results_indep)
#> coverage median_length
#> lasso only 0.8254215 0.7040169
#> naive 0.8430610 0.3528058
#> screened 0.9118029 0.7812204
with(results_indep, tapply(reject, list(method, added_signal = added_signal), mean))
#> added_signal
#> FALSE TRUE
#> lasso only 0.2642934 0.4758395
#> naive 0.3404223 0.5192465
#> screened 0.1197411 0.2588043Only the intervals that condition on the screening step come close to the nominal 90% coverage. When the added variable is null, the screened tests reject at about the nominal 10%, and the LASSO-only and naive tests reject far more often. With independent features, \(M_{js} \approx 0\) in the decomposition above, so \(\hat{\theta}_{s,j}\) is nearly the screened coefficient \(\hat{\beta}_{j \cdot G}\). That coefficient is large partly because \(x_j\) was chosen for its large gradient, which is the selection effect that screening accounts for.