--- title: "Getting Started with srlars" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Getting Started with srlars} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ## Introduction `srlars` fits the Fast and Scalable Cellwise-Robust Ensemble (FSCRE) algorithm: a competitive ensemble of `n_models` sparse sub-models, built on a cellwise-robust foundation (Detect Deviating Cells imputation and wrapping-based robust correlations). "Cellwise" robustness matters because contamination in practice often corrupts individual *cells* of a data matrix rather than whole observations -- a handful of bad measurements scattered across otherwise-good rows -- which classical observation-level robust methods are not designed to handle. This vignette builds one small simulated example and reuses it throughout: we simulate a contaminated dataset, fit `srlars()` with its default settings, and then see what changes when we adjust its two ensemble-shape controls -- how much sub-models are allowed to share variables (`max_share`) and how small a sub-model is allowed to end up (`n_min`). It closes with a short, code-free note on `cv.srlars()`, which chooses `max_share` automatically instead of by hand as we do below. ```{r setup} library(srlars) library(mvnfast) library(cellWise) ``` ## Building a test case: contaminated, high-dimensional data The scenario we simulate: 500 candidate predictors, only a fraction of which are truly related to the response, and a training sample smaller than the number of predictors -- a setting where the number of *unknowns* outnumbers the number of *observations*. The truly active predictors sit in a few correlated blocks (so that some predictors are legitimately more informative than others), and the true predictor-response relationship is otherwise sparse. We then contaminate a fraction of the *cells* of the training predictors, leaving the test set clean. We start with the pieces that describe the true relationship: a block-correlation structure among the active predictors, and a sparse coefficient vector that is nonzero only for those active predictors. ```{r truth} set.seed(100) n <- 50 # training observations m <- 2000 # test observations p <- 500 # candidate predictors p.active <- 75 # truly active predictors, in blocks below group.size <- 15 # active predictors per correlated block n_models <- 10 # ensemble size (K) # Active predictors sit in correlated blocks; everything else is independent noise. sigma.mat <- matrix(0, p, p) sigma.mat[1:p.active, 1:p.active] <- 0.1 # weak correlation across blocks for (g in 0:(p.active / group.size - 1)) { idx <- (g * group.size + 1):(g * group.size + group.size) sigma.mat[idx, idx] <- 0.7 # stronger correlation within a block } diag(sigma.mat) <- 1 # A sparse, moderate-signal true coefficient vector true.beta <- c(runif(p.active, 0, 5) * (-1) ^ rbinom(p.active, 1, 0.7), rep(0, p - p.active)) sigma <- as.numeric(sqrt(t(true.beta) %*% sigma.mat %*% true.beta)) # signal-to-noise = 1 ``` With the true relationship fixed, generating the actual training and test sets is just sampling predictors and adding noise to the response -- the test set stays clean throughout, as a genuine holdout should: ```{r generate-data} x_train <- mvnfast::rmvn(n, mu = rep(0, p), sigma = sigma.mat) y_train <- as.numeric(x_train %*% true.beta + rnorm(n, 0, sigma)) colnames(x_train) <- paste0("V", 1:p) x_test <- mvnfast::rmvn(m, mu = rep(0, p), sigma = sigma.mat) y_test <- as.numeric(x_test %*% true.beta + rnorm(m, 0, sigma)) colnames(x_test) <- colnames(x_train) ``` Finally, we contaminate 15% of the *cells* of the training predictors only. Rather than replacing values with arbitrary noise, each contaminated row's affected cells are set to a *correlation outlier*: a combination of values that looks unremarkable one variable at a time, but distorts the multivariate dependence structure DDC and wrapping are specifically designed to catch. The exact linear algebra behind that (`contam_correlation()` below) isn't essential reading -- what matters is that it plants exactly this kind of cellwise, dependence-breaking contamination into `x_train`: ```{r contaminate} contam_correlation <- function(X, prop, sigma_mat, gamma = 3) { n <- nrow(X); p <- ncol(X) idx <- sample.int(n * p, size = round(n * p * prop)) rows <- ((idx - 1) %% n) + 1 cols <- ((idx - 1) %/% n) + 1 for (i in 1:n) { J <- cols[rows == i] if (length(J) == 0) next if (length(J) == 1) { X[i, J] <- gamma * 3; next } SigmaJ <- sigma_mat[J, J, drop = FALSE] vmin <- eigen(SigmaJ, symmetric = TRUE)$vectors[, length(J)] denom <- mahalanobis(t(vmin), center = rep(0, length(J)), cov = SigmaJ) X[i, J] <- gamma * sqrt(length(J)) * (vmin / sqrt(denom)) } X } x_train <- contam_correlation(x_train, prop = 0.15, sigma_mat = sigma.mat) ``` Lastly, one small helper we'll reuse for every fit below: precision and recall of the selected variables against the *known* active set, and out-of-sample MSPE (scaled by the noise variance, so that `1` is roughly what a correctly-specified model would achieve): ```{r metrics-helper} get_metrics <- function(fit) { coefs <- as.numeric(coef(fit))[-1] sel <- which(coefs != 0) truth <- which(true.beta != 0) preds <- as.numeric(predict(fit, x_test)) c(Precision = length(intersect(sel, truth)) / max(length(sel), 1), Recall = length(intersect(sel, truth)) / length(truth), MSPE = mean((y_test - preds)^2) / sigma^2, `Mean sub-model size` = mean(vapply(fit$active.sets, length, integer(1)))) } ``` ## Fitting srlars() With the data in hand, fitting the default ensemble is a single call. By default, `max_share = 1`: the `n_models` sub-models are fully disjoint, so no variable can be selected by more than one of them. ```{r fit-default} fit_default <- srlars(x_train, y_train, n_models = n_models, tolerance = 1e-4, x_preprocess = "ddc", y_preprocess = "wrap", cor_estimator = "wrap", cv_preprocess = "global", cv_fit = "huber", cv_loss = "huber", cv_folds = 5, compute_coef = TRUE) ``` Each sub-model gets its own, disjoint set of variables: ```{r active-sizes} knitr::kable( data.frame(`Sub-model` = seq_len(n_models), `Variables selected` = vapply(fit_default$active.sets, length, integer(1))), align = "c" ) ``` ```{r metrics-default} metrics_default <- get_metrics(fit_default) knitr::kable(t(round(metrics_default, 3)), caption = "srlars() at the default max_share = 1") ``` Out of `r p.active` truly active predictors, this run recovers a recall of `r round(metrics_default["Recall"], 2)` at a precision of `r round(metrics_default["Precision"], 2)` -- exactly how well any particular run does will vary with the random contamination draw, but the shape of the result (some but not all of the true signal recovered, most of what's selected genuinely active) is typical of this kind of high-dimensional, heavily contaminated setting. `coef()` and `predict()` both average across the ensemble's sub-models, so they work exactly like the corresponding methods for an ordinary fitted regression: ```{r coef-predict} knitr::kable(t(round(coef(fit_default)[1:6], 3)), col.names = c("Intercept", paste0("V", 1:5))) knitr::kable(t(round(predict(fit_default, x_test[1:5, ]), 2)), col.names = paste("Test row", 1:5)) ``` ## Controlling how much sub-models can share: `max_share` `max_share` (from `1` to `n_models`) relaxes the disjointness above: a variable may now be selected by up to `max_share` sub-models instead of just one. At the other extreme, `max_share = n_models` removes the restriction entirely -- sub-models are then completely free to converge on the same variables. Refitting on the exact same data, changing only `max_share`: ```{r fit-shared} fit_shared <- srlars(x_train, y_train, n_models = n_models, max_share = n_models, tolerance = 1e-4, x_preprocess = "ddc", y_preprocess = "wrap", cor_estimator = "wrap", cv_preprocess = "global", cv_fit = "huber", cv_loss = "huber", cv_folds = 5, compute_coef = TRUE) metrics_shared <- get_metrics(fit_shared) knitr::kable( rbind(`max_share = 1 (default)` = round(metrics_default, 3), `max_share = n_models` = round(metrics_shared, 3)) ) ``` Here, allowing unrestricted sharing moves recall from `r round(metrics_default["Recall"], 2)` to `r round(metrics_shared["Recall"], 2)`: with fewer independent sub-models exploring *different* variables, the ensemble as a whole tends to cover less of a broad, many-active-variable truth like this one. That trade is not universal, though -- letting sub-models refit the same, well-supported variables can help on datasets that don't need broad exploration in the first place. `cv.srlars()` (see the closing section) picks `max_share` for you by cross-validation, rather than requiring this kind of manual comparison. ## Guaranteeing a minimum sub-model size: `n_min` The selection loop's stopping rule is evaluated once per round, across the *entire* ensemble at once: it halts as soon as no sub-model's next candidate variable shows a sufficient cross-validated improvement. On some datasets that can leave sub-models quite small. `n_min` sets a floor under that: sub-models below it keep receiving their best available variable even when it doesn't clear the usual improvement bar (though it can never violate the `max_share` restrictions above -- only the improvement requirement is relaxed). ```{r fit-nmin} fit_floor <- srlars(x_train, y_train, n_models = n_models, n_min = 10, tolerance = 1e-4, x_preprocess = "ddc", y_preprocess = "wrap", cor_estimator = "wrap", cv_preprocess = "global", cv_fit = "huber", cv_loss = "huber", cv_folds = 5, compute_coef = TRUE) metrics_floor <- get_metrics(fit_floor) knitr::kable( rbind(`n_min = NULL (default)` = round(metrics_default, 3), `n_min = 10` = round(metrics_floor, 3)) ) ``` Forcing a floor of 10 variables per sub-model raises the mean sub-model size from `r round(metrics_default["Mean sub-model size"], 1)` to `r round(metrics_floor["Mean sub-model size"], 1)`, which typically trades some precision (a few of the forced-in variables are not genuinely active) for higher recall -- useful when the default stopping rule is cutting sub-models off before they've captured much real signal, but not something to reach for by default. ## Choosing `max_share` automatically: `cv.srlars()` Rather than comparing `max_share` values by hand as above, `cv.srlars()` chooses it by an outer cross-validation loop scored on held-out *ensemble* prediction error, and returns the refit at the cross-validated optimum. The expensive cellwise-robust preprocessing stage is computed once per outer fold and reused across every candidate value, so this is efficient relative to a naive grid search. The object it returns is classed so that `coef()` and `predict()` work on it exactly as they do above -- no new methods to learn. See `?cv.srlars` for details.