In neuroimaging, we often encounter “wide” datasets where the number of predictors \(M\) (voxels, vertices, or brain signatures) exceeds the number of observations \(N\). Furthermore, the response variable is often a continuous clinical score that we wish to model across various severity thresholds.
This vignette demonstrates how to use fusedRidge to
handle high-dimensional data by: 1. Smoothing coefficients across
thresholds using a Graph Laplacian (Fusion). 2. Employing a two-stage
“Sparse Refit” to identify a robust subset of features. 3. Avoiding
“Data Leakage” using Nested Cross-Validation.
A common pitfall in two-stage modeling (Select -> Refit) is selecting features using the entire dataset and then reporting the cross-validation performance of the second stage. This is Data Leakage. Because the features were chosen with knowledge of the test samples, the resulting AUC will be optimistically biased.
The Best Solution: Perform the entire two-stage procedure (including feature selection) inside an outer cross-validation loop.
We simulate \(N=100\) subjects and \(M=500\) features. Only 20 features have a true signal. The signal varies smoothly across 5 binarization thresholds.
set.seed(42)
N <- 100
M <- 500
J <- 5 # Number of thresholds
# Generate features with some correlation
X <- matrix(rnorm(N * M), N, M)
# Add some structure: features 1-50 are correlated
X[, 1:50] <- X[, 1:50] + 0.5 * rnorm(N)
# Define true coefficients: Sparse and Smooth across thresholds
# Features 10-29 are active
beta_true <- matrix(0, M, J)
for (j in 1:J) {
# Effect grows and then shrinks across thresholds
effect_size <- sin(seq(0.5, 2.5, length.out = J))[j]
beta_true[10:29, j] <- effect_size * seq(1, 0.5, length.out = 20)
}
# Generate latent continuous outcome
y_cont <- X %*% beta_true[, floor(J/2)] + rnorm(N, sd = 0.5)
thresholds <- quantile(y_cont, probs = seq(0.2, 0.8, length.out = J))
# Helper to calculate AUC
get_auc <- function(prob, actual) {
if (length(unique(actual)) < 2) return(NA)
p_obj <- pROC::roc(actual, prob, quiet = TRUE)
return(as.numeric(pROC::auc(p_obj)))
}We compare three models: 1. Standard Ridge: No
fusion (\(\lambda_2 = 0\)), no
sparsity. 2. Fused Ridge (Dense): Fusion (\(\lambda_2 = 0.5\)), no sparsity. 3.
Fused Ridge (Sparse): Fusion + Two-stage
topK=20 refit.
n_folds <- 5
folds <- sample(rep(1:n_folds, length.out = N))
results_list <- list()
for (f in 1:n_folds) {
train_idx <- which(folds != f)
test_idx <- which(folds == f)
X_train <- X[train_idx, ]
y_train <- y_cont[train_idx]
X_test <- X[test_idx, ]
y_test_cont <- y_cont[test_idx]
# binarize test labels
y_test_mat <- matrix(0, length(test_idx), J)
for (j in 1:J) y_test_mat[,j] <- ifelse(y_test_cont >= thresholds[j], 1, 0)
# 1. Standard Ridge (Independent per threshold)
fit_ridge <- fusedRidge(X_train, y_train, thresholds, lambda2 = 0)
pred_ridge <- predict(fit_ridge, X_test, type = "response")
# 2. Fused Ridge (Dense)
fit_fused <- fusedRidge(X_train, y_train, thresholds, lambda1 = 0.5, lambda2 = 0.5)
pred_fused <- predict(fit_fused, X_test, type = "response")
# 3. Fused Ridge (Sparse/Two-Stage) - Compare different topK values
for (tk in c(5, 10, 25, 50)) {
fit_sparse <- fusedRidge(X_train, y_train, thresholds, lambda1 = 0.5, lambda2 = 0.5, topK = tk)
pred_sparse <- predict(fit_sparse, X_test, type = "response")
auc_tk <- sapply(1:J, function(j) get_auc(pred_sparse[,j], y_test_mat[,j]))
results_list[[length(results_list) + 1]] <- data.frame(
Threshold = 1:J,
Method = paste0("Fused-Sparse (topK=", tk, ")"),
AUC = auc_tk,
Fold = f
)
}
# 4. Independent Lasso (Sparsity without Fusion)
pred_lasso_mat <- matrix(0, length(test_idx), J)
for (j in 1:J) {
y_train_j <- ifelse(y_train >= thresholds[j], 1, 0)
# Check if we have both classes
if (length(unique(y_train_j)) > 1) {
cv_lasso <- glmnet::cv.glmnet(X_train, y_train_j, family = "binomial", alpha = 1)
pred_lasso_mat[, j] <- as.vector(predict(cv_lasso, X_test, s = "lambda.min", type = "response"))
} else {
pred_lasso_mat[, j] <- mean(y_train_j)
}
}
# Calculate AUCs for all methods
results_list[[length(results_list) + 1]] <- data.frame(
Threshold = 1:J,
Method = "Ridge (Independent)",
AUC = sapply(1:J, function(j) get_auc(pred_ridge[,j], y_test_mat[,j])),
Fold = f
)
results_list[[length(results_list) + 1]] <- data.frame(
Threshold = 1:J,
Method = "Lasso (Independent)",
AUC = sapply(1:J, function(j) get_auc(pred_lasso_mat[,j], y_test_mat[,j])),
Fold = f
)
results_list[[length(results_list) + 1]] <- data.frame(
Threshold = 1:J,
Method = "Fused-Dense",
AUC = sapply(1:J, function(j) get_auc(pred_fused[,j], y_test_mat[,j])),
Fold = f
)
}
#> Warning in .resolve_control(control = control, nvars = nvars, deprecated =
#> list(thresh = if (!missing(thresh)) thresh, : Passing 'thresh' to glmnet() is
#> deprecated. Use control = list(thresh = ...) instead.
results_df <- do.call(rbind, results_list)
summary_df <- aggregate(AUC ~ Threshold + Method, data = results_df, FUN = mean)We plot the mean AUC across thresholds. This allows us to see how Fusion (sharing information across thresholds) and Sparsity (feature selection) interact. Note how Lasso (Independent) may select features, but because it treats each threshold in isolation, it often suffers from higher variance and lower AUC compared to the Fused models.
# Order methods for better plotting
summary_df$Method <- factor(summary_df$Method, levels = c(
"Ridge (Independent)", "Lasso (Independent)", "Fused-Dense",
"Fused-Sparse (topK=5)", "Fused-Sparse (topK=10)",
"Fused-Sparse (topK=25)", "Fused-Sparse (topK=50)"
))
ggplot(summary_df, aes(x = Threshold, y = AUC, color = Method, group = Method)) +
geom_line(size = 1) +
geom_point(size = 2) +
theme_minimal() +
scale_color_viridis_d(option = "plasma") +
labs(title = "Unbiased AUC Comparison (Nested CV)",
subtitle = "Fusion vs. Independent Selection vs. Ridge",
y = "Mean Out-of-Fold AUC") +
ylim(0.5, 1)
#> Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
#> ℹ Please use `linewidth` instead.
#> This warning is displayed once per session.
#> Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
#> generated.To avoid the “spaghetti” effect of overlapping lines, we use a heatmap to visualize the coefficients of the selected features across thresholds. This clearly shows both the sparsity (which features were selected) and the smoothness (how weights change across thresholds).
# Fit on full data for visualization
final_fit <- fusedRidge(X, y_cont, thresholds, lambda1 = 0.5, lambda2 = 0.5, topK = 25)
# Extract and melt the coefficients for the selected features
selected_idx <- final_fit$selected_features
coefs_selected <- final_fit$coefs_full[selected_idx, ]
colnames(coefs_selected) <- paste0("Threshold ", 1:J)
rownames(coefs_selected) <- paste0("Feature ", selected_idx)
coefs_melted <- melt(coefs_selected)
colnames(coefs_melted) <- c("Feature", "Threshold", "Weight")
# 1. Heatmap Visualization
ggplot(coefs_melted, aes(x = Threshold, y = Feature, fill = Weight)) +
geom_tile() +
scale_fill_gradient2(low = "blue", mid = "white", high = "red", midpoint = 0) +
theme_minimal() +
labs(title = "Coefficient Heatmap (Top 25 Features)",
subtitle = "Smooth transitions across thresholds visible horizontally",
x = "", y = "") +
theme(axis.text.y = element_text(size = 6))For a more detailed look at the individual trajectories of the top 9 most influential features, we use a faceted line plot.
# Identify top 9 features by average absolute weight
avg_weight <- rowMeans(abs(coefs_selected))
top9_idx <- order(avg_weight, decreasing = TRUE)[1:9]
top9_coefs <- coefs_selected[top9_idx, ]
top9_melted <- melt(top9_coefs)
colnames(top9_melted) <- c("Feature", "Threshold", "Weight")
ggplot(top9_melted, aes(x = Threshold, y = Weight, group = Feature)) +
geom_line(color = "steelblue", size = 1) +
geom_point(color = "steelblue", size = 2) +
facet_wrap(~Feature, scales = "free_y") +
theme_bw() +
labs(title = "Individual Trajectories (Top 9 Features)",
subtitle = "Faceted view eliminates line crossing and 'spaghetti' mess",
x = "Binarization Threshold",
y = "Coefficient Weight") +
theme(axis.text.x = element_text(angle = 45, hjust = 1))In this section, we use the anatomical labels from the AAL (Automated Anatomical Labeling) atlas to simulate a more realistic neuroimaging scenario. We assume that a “Cognitive Decline” score is driven by specific brain circuits (e.g., Frontal and Hippocampal regions) and that this signal evolves across the disease spectrum.
We generate \(N=120\) subjects with \(M=116\) brain regions.
data(aal, package = "ANTsR")
region_names <- aal$label_name
M_aal <- length(region_names)
set.seed(88)
N_aal <- 120
X_aal <- matrix(rnorm(N_aal * M_aal), N_aal, M_aal)
colnames(X_aal) <- region_names
# Define signal in specific circuits:
# 1. Hippocampus (Memory)
hippo_idx <- grep("Hippocampus", region_names)
# 2. Frontal Sup (Executive)
frontal_idx <- grep("Frontal_Sup", region_names)
# 3. Temporal Pole
temporal_idx <- grep("Temporal_Pole", region_names)
active_aal <- c(hippo_idx, frontal_idx, temporal_idx)
# Simulate smooth effect across 4 thresholds
J_aal <- 4
beta_aal <- matrix(0, M_aal, J_aal)
for (j in 1:J_aal) {
# Signal intensifies at higher severity thresholds
strength <- j * 0.4
beta_aal[hippo_idx, j] <- strength
beta_aal[frontal_idx, j] <- strength * 0.8
beta_aal[temporal_idx, j] <- strength * 0.6
}
# Generate outcome
y_aal_cont <- X_aal %*% beta_aal[, 2] + rnorm(N_aal, sd = 0.3)
thresholds_aal <- quantile(y_aal_cont, probs = c(0.2, 0.4, 0.6, 0.8))We fit the fusedRidge model with topK = 15.
This forces the model to select a small subset of regions that
consistently contribute across the thresholds.
# Fit sparse model
fit_aal <- fusedRidge(X_aal, y_aal_cont, thresholds_aal,
lambda1 = 0.5, lambda2 = 1.0, topK = 15)
# Which regions were recovered?
recovered_regions <- region_names[fit_aal$selected_features]
print("Top 15 Recovered Regions:")
#> [1] "Top 15 Recovered Regions:"
print(recovered_regions)
#> [1] "Frontal_Sup_L" "Frontal_Sup_R" "Frontal_Sup_Orb_L"
#> [4] "Frontal_Sup_Orb_R" "Olfactory_R" "Frontal_Sup_Medial_L"
#> [7] "Frontal_Sup_Medial_R" "Frontal_Med_Orb_R" "Hippocampus_L"
#> [10] "Hippocampus_R" "Amygdala_R" "Angular_L"
#> [13] "Caudate_L" "Putamen_R" "Temporal_Pole_Sup_L"
#> [16] "Temporal_Pole_Sup_R" "Temporal_Pole_Mid_R" "Cerebelum_6_R"
# Check if we found our simulated signal
overlap <- intersect(fit_aal$selected_features, active_aal)
cat("\nSuccessfully recovered", length(overlap), "out of", length(active_aal), "true signal regions.\n")
#>
#> Successfully recovered 11 out of 12 true signal regions.To see how the sparse model generalizes, we perform a simple train/test split (80/20) and examine the Actual vs. Predicted results for the AAL regions.
# Train/Test Split
train_idx_aal <- sample(1:N_aal, size = 0.8 * N_aal)
test_idx_aal <- setdiff(1:N_aal, train_idx_aal)
# Fit sparse model on training data
fit_aal_train <- fusedRidge(X_aal[train_idx_aal,], y_aal_cont[train_idx_aal],
thresholds_aal, lambda1 = 0.5, lambda2 = 1.0, topK = 15)
# Get actual binarized labels for test set
y_test_mat_aal <- matrix(0, length(test_idx_aal), J_aal)
for (j in 1:J_aal) y_test_mat_aal[,j] <- ifelse(y_aal_cont[test_idx_aal] >= thresholds_aal[j], 1, 0)
# Get predicted probabilities
pred_probs_aal <- predict(fit_aal_train, X_aal[test_idx_aal,], type = "response")
# Calculate AUC per threshold for the test set
test_aucs_aal <- sapply(1:J_aal, function(j) get_auc(pred_probs_aal[,j], y_test_mat_aal[,j]))
# Visualize Prediction Distribution
pred_df_aal <- data.frame(
Actual = as.factor(as.vector(y_test_mat_aal)),
Probability = as.vector(pred_probs_aal),
Threshold = rep(paste0("T", 1:J_aal), each = length(test_idx_aal))
)
ggplot(pred_df_aal, aes(x = Actual, y = Probability, fill = Actual)) +
geom_boxplot(alpha = 0.7) +
facet_wrap(~Threshold) +
theme_minimal() +
labs(title = "AAL Model: Actual vs. Predicted Probabilities",
subtitle = paste0("Mean Test AUC: ", round(mean(test_aucs_aal), 3)),
y = "Predicted Probability of 'High' Decline")… We group the recovered coefficients by brain lobe to see if the sparse selection captures the “circuit” structure of the simulated decline.
# Map regions to lobes for grouping
aal_subset <- aal[fit_aal$selected_features, ]
coefs_aal <- fit_aal$coefs_full[fit_aal$selected_features, ]
colnames(coefs_aal) <- paste0("T", 1:J_aal)
rownames(coefs_aal) <- aal_subset$label_name
# Add lobe information for visualization
aal_subset$Lobe <- "Other"
if("frontal" %in% colnames(aal)) aal_subset$Lobe[aal_subset$frontal == 1] <- "Frontal"
if("temporal" %in% colnames(aal)) aal_subset$Lobe[aal_subset$temporal == 1] <- "Temporal"
if("limbic" %in% colnames(aal)) aal_subset$Lobe[aal_subset$limbic == 1] <- "Limbic"
if("parietal" %in% colnames(aal)) aal_subset$Lobe[aal_subset$parietal == 1] <- "Parietal"
aal_melted <- melt(coefs_aal)
colnames(aal_melted) <- c("Region", "Threshold", "Weight")
# Join lobe info
aal_melted$Lobe <- aal_subset$Lobe[match(aal_melted$Region, aal_subset$label_name)]
ggplot(aal_melted, aes(x = Threshold, y = Region, fill = Weight)) +
geom_tile() +
facet_grid(Lobe ~ ., scales = "free_y", space = "free_y") +
scale_fill_gradient2(low = "blue", mid = "white", high = "red") +
theme_minimal() +
labs(title = "Anatomical Signature of Cognitive Decline",
subtitle = "Sparse Fused Ridge groups features by functional Lobe",
x = "Disease Severity Threshold",
y = "AAL Region") +
theme(strip.text.y = element_text(angle = 0))In this final section, we use the Blood-Brain
Barrier dataset from the caret package. This
dataset contains 134 molecular descriptors for 208 compounds. The goal
is to predict the log concentration ratio of the compound in the brain
vs. blood (logBBB).
This is a classic “real-world” problem where we want to identify which molecular properties consistently determine brain permeability across different “penetration” thresholds.
if (requireNamespace("caret", quietly = TRUE)) {
data(BloodBrain, package = "caret")
# bbbDescr contains 134 predictors, logBBB is the response
X_bbb <- as.matrix(bbbDescr)
y_bbb <- logBBB
# Remove near-zero variance predictors to keep the model stable
nzv <- caret::nearZeroVar(X_bbb)
X_bbb <- X_bbb[, -nzv]
M_bbb <- ncol(X_bbb)
N_bbb <- nrow(X_bbb)
# Define thresholds for 'High' vs 'Low' permeability
thresholds_bbb <- quantile(y_bbb, probs = c(0.25, 0.5, 0.75))
J_bbb <- length(thresholds_bbb)
cat("Dataset loaded:", N_bbb, "compounds,", M_bbb, "molecular descriptors.\n")
} else {
cat("The 'caret' package is required for this section but is not installed.\n")
}
#> Dataset loaded: 208 compounds, 127 molecular descriptors.We fit a fused ridge model with topK = 10 to find the 10
most influential molecular descriptors across the permeability
spectrum.
if (requireNamespace("caret", quietly = TRUE)) {
# Fit the model
fit_bbb <- fusedRidge(X_bbb, y_bbb, thresholds_bbb,
lambda1 = 0.1, lambda2 = 0.5, topK = 10)
# Reconstruct the "Permeability Signature"
coefs_bbb <- fit_bbb$coefs_full[fit_bbb$selected_features, ]
colnames(coefs_bbb) <- paste0("T", 1:J_bbb)
# Sort by overall influence
imp_order <- order(rowMeans(abs(coefs_bbb)), decreasing = TRUE)
coefs_bbb_sorted <- coefs_bbb[imp_order, ]
print("Top Molecular Predictors of BBB Permeability:")
print(rownames(coefs_bbb_sorted))
}
#> [1] "Top Molecular Predictors of BBB Permeability:"
#> [1] "vsa_other" "clogp" "prx"
#> [4] "peoe_vsa.6.1" "ppsa3" "pol"
#> [7] "rule.of.5violations" "wncs" "slogp_vsa8"
#> [10] "peoe_vsa.4.1" "peoe_vsa.0.1" "tcpa"
#> [13] "wpcs" "rotatablebonds" "lumo"
#> [16] "homo"We evaluate the BBB model by looking at the separation between predicted probabilities for compounds that actually crossed vs. those that did not, using a hold-out test set.
if (requireNamespace("caret", quietly = TRUE)) {
# Train/Test Split
train_idx_bbb <- sample(1:N_bbb, size = 0.7 * N_bbb)
test_idx_bbb <- setdiff(1:N_bbb, train_idx_bbb)
# Fit sparse model
fit_bbb_train <- fusedRidge(X_bbb[train_idx_bbb,], y_bbb[train_idx_bbb],
thresholds_bbb, lambda1 = 0.1, lambda2 = 0.5, topK = 10)
# Predict on test set
pred_probs_bbb <- predict(fit_bbb_train, X_bbb[test_idx_bbb,], type = "response")
# Independent Lasso Comparison
pred_probs_lasso_bbb <- matrix(0, length(test_idx_bbb), J_bbb)
for (j in 1:J_bbb) {
y_train_j <- ifelse(y_bbb[train_idx_bbb] >= thresholds_bbb[j], 1, 0)
cv_lasso <- glmnet::cv.glmnet(X_bbb[train_idx_bbb,], y_train_j, family = "binomial", alpha = 1)
pred_probs_lasso_bbb[, j] <- as.vector(predict(cv_lasso, X_bbb[test_idx_bbb,], s = "lambda.min", type = "response"))
}
# Binarize test labels
y_test_mat_bbb <- matrix(0, length(test_idx_bbb), J_bbb)
for (j in 1:J_bbb) y_test_mat_bbb[,j] <- ifelse(y_bbb[test_idx_bbb] >= thresholds_bbb[j], 1, 0)
# Summary Results
test_aucs_bbb <- sapply(1:J_bbb, function(j) get_auc(pred_probs_bbb[,j], y_test_mat_bbb[,j]))
test_aucs_lasso_bbb <- sapply(1:J_bbb, function(j) get_auc(pred_probs_lasso_bbb[,j], y_test_mat_bbb[,j]))
# Prepare for Plotting
pred_df_bbb <- data.frame(
Actual = as.factor(as.vector(y_test_mat_bbb)),
Probability = as.vector(pred_probs_bbb),
Threshold = rep(paste0("Q", 1:J_bbb), each = length(test_idx_bbb))
)
# AUC Comparison Data
auc_comp_bbb <- data.frame(
Threshold = rep(paste0("Q", 1:J_bbb), 2),
Method = rep(c("Fused Ridge", "Independent Lasso"), each = J_bbb),
AUC = c(test_aucs_bbb, test_aucs_lasso_bbb)
)
# Density Plot
p1_bbb <- ggplot(pred_df_bbb, aes(x = Probability, fill = Actual)) +
geom_density(alpha = 0.5) +
facet_wrap(~Threshold) +
theme_minimal() +
labs(title = "BBB Model: Separation of Predicted Probabilities",
subtitle = paste0("Fused Ridge Mean Test AUC: ", round(mean(test_aucs_bbb), 3)),
x = "Predicted Probability of Brain Penetration",
y = "Density")
# AUC Comparison Plot
p2_bbb <- ggplot(auc_comp_bbb, aes(x = Threshold, y = AUC, fill = Method)) +
geom_bar(stat = "identity", position = "dodge") +
theme_minimal() +
coord_cartesian(ylim = c(0.5, 1)) +
labs(title = "BBB Model: AUC Comparison",
subtitle = "Fused Ridge vs. Independent Lasso across Permeability Quantiles",
y = "Test AUC")
print(p1_bbb)
print(p2_bbb)
}The heatmap below reveals which molecular properties (like surface area, hydrogen bond donors, or polarizability) consistently predict crossing the blood-brain barrier.
if (requireNamespace("caret", quietly = TRUE)) {
bbb_melted <- melt(coefs_bbb_sorted)
colnames(bbb_melted) <- c("Descriptor", "Threshold", "Weight")
ggplot(bbb_melted, aes(x = Threshold, y = Descriptor, fill = Weight)) +
geom_tile() +
scale_fill_gradient2(low = "darkgreen", mid = "white", high = "purple") +
theme_minimal() +
labs(title = "Molecular Signature of BBB Permeability",
subtitle = "Fused Ridge identifies descriptors that persist across penetration levels",
x = "Permeability Quantile Threshold",
y = "Molecular Descriptor")
}By using Nested Cross-Validation, we have shown that
fusedRidge provides superior predictive performance on
high-dimensional data compared to standard independent ridge regression.
The fusion penalty successfully leverages the shared information across
thresholds, while the two-stage topK approach identifies a
robust, interpretable subset of features without suffering from data
leakage.