Introduction
The spikeyglass package implements the Bayesian
Spike-and-Slab Joint Graphical Lasso (SSJGL) from Li et al. (2019). This
method estimates multiple related precision matrices (inverse covariance
matrices) across K groups simultaneously, encouraging shared sparsity
structure while allowing group-specific differences.
Key features:
- Adaptive penalization: spike-and-slab priors automatically determine edge-specific penalty weights
- Two penalty types: fused (penalizes pairwise differences) and group (penalizes L2 norm across groups)
- Posterior edge probabilities: each edge gets a posterior inclusion probability P(delta = 1), enabling principled edge selection
- Missing data handling: built-in imputation via conditional MVN
Simulate data
We generate a small synthetic example with K=2 groups and p=15 variables using a banded (AR-2) graph with a few differential edges.
library(spikeyglass)
sim <- simulate_ssjgl_data(
K = 2, p = 15, n = 100,
graph_type = "band",
perturb_prob = 0.05,
signal = 0.3,
seed = 42
)
# True adjacency matrices
cat("Group 1 edges:", sum(sim$adj_list[[1]][upper.tri(sim$adj_list[[1]])]), "\n")
#> Group 1 edges: 27
cat("Group 2 edges:", sum(sim$adj_list[[2]][upper.tri(sim$adj_list[[2]])]), "\n")
#> Group 2 edges: 31
# Visualize true graph structures
par(mfrow = c(1, 2))
image(sim$adj_list[[1]], main = "True Graph: Group 1",
col = c("white", "steelblue"), axes = FALSE)
image(sim$adj_list[[2]], main = "True Graph: Group 2",
col = c("white", "steelblue"), axes = FALSE)
Choosing hyperparameters
SSJGL has three penalty parameters (lambda0,
lambda1, lambda2) and a ladder of spike
variances v0s.
Standardise the data
The priors act on the entries of the precision matrices, so their
meaning depends on the variables’ units. Use
standardize = TRUE, which centres and scales every variable
to unit variance within each group. The estimates are then precision
matrices of the standardised variables. (normalize = TRUE
only centres the data and does not change the estimates.)
Lambda parameters
The spike-and-slab E-step produces adaptive, edge-specific penalty
weights that multiply lambda1 and lambda2.
With standardised data:
lambda1 = 1 (as in Li et al., 2019).
lambda1andv0act together through the ratiolambda1/v0(see below).-
lambda2 controls the cross-group similarity penalty.
-
lambda2 = 0: no borrowing across groups (separate estimation) -
lambda2 = lambda1: equal weight on sparsity and similarity -
lambda2 > lambda1: encourage more similar graphs across groups
-
lambda0: diagonal penalty. Use
lambda0 = 1(the method is insensitive to it).
Understanding v0: the spike
Each off-diagonal precision entry has a Laplace
prior: rate lambda1/v0 in the spike (the edge is absent)
and lambda1/v1 in the slab (the edge is present,
v1 = 1). The spike’s scale is therefore
v0/lambda1, and only the ratio lambda1/v0
matters. Larger lambda1/v0 means stronger shrinkage of
non-edges. In the M-step, a spike edge is soft-thresholded at roughly
lambda1/(n v0) on the correlation scale.
The v0 ladder (warm-starting)
The EM is run along a decreasing ladder of
v0 (increasing lambda1/v0), each step
warm-started from the previous one (“dynamic posterior exploration”, Li
et al., 2019). The default, v0s = make_v0_ladder(lambda1),
has three steps with lambda1/v0 = 10, 31.6, 100, which is
c(0.1, 0.0316, 0.01) at lambda1 = 1. It was
chosen by the simulation study summarised in
vignette("parameter-exploration"). That vignette also shows
how to explore longer ladders.
Fit the model
fit <- ssjgl(
Y = sim$data_list,
penalty = "fused",
lambda0 = 1,
lambda1 = 1,
lambda2 = 1,
doubly = TRUE,
a = 1, b = 15,
maxitr.em = 100,
tol.em = 1e-4,
standardize = TRUE,
impute = FALSE
)The fitted object records its settings, e.g. the ladder that was used:
fit$v0s
#> [1] 0.10000000 0.03162278 0.01000000
print(fit)
#> Spike-and-Slab Joint Graphical Lasso (SSJGL)
#> Groups (K): 2
#> Variables (p): 15
#> v0 ladder steps: 3
#> Total EM iterations: 37
#> Total time: 0.8 seconds
#> Group 1: 30 non-zero edges (from precision)
#> Group 2: 31 non-zero edges (from precision)
#> Edges with P(inclusion) > 0.5: 32Extract results
# Precision matrices at the final v0 step
theta_hat <- coef(fit)
# Partial correlations
pcor_hat <- fitted(fit)
# Edge inclusion probabilities
probs <- extract_probabilities(fit)
# Binary adjacency from thresholding inclusion probabilities at 0.5
adj_hat <- extract_adjacency(fit, threshold = 0.5)
cat("Estimated edges (Group 1):",
sum(adj_hat[[1]][upper.tri(adj_hat[[1]])]), "\n")
#> Estimated edges (Group 1): 32
cat("Estimated edges (Group 2):",
sum(adj_hat[[2]][upper.tri(adj_hat[[2]])]), "\n")
#> Estimated edges (Group 2): 32Evaluate performance
Compare the estimated graphs to the truth using TPR, FPR, and AUC:
metrics <- compute_metrics(
fit = fit,
true_adj = sim$adj_list,
true_omega = sim$Omega_list,
threshold = 0.5
)
cat("=== Overall Performance ===\n")
#> === Overall Performance ===
cat(sprintf("Mean TPR: %.3f\n", metrics$overall$mean_TPR))
#> Mean TPR: 0.931
cat(sprintf("Mean FPR: %.3f\n", metrics$overall$mean_FPR))
#> Mean FPR: 0.065
cat(sprintf("Mean AUC: %.3f\n", metrics$overall$mean_AUC))
#> Mean AUC: 0.934
for (k in 1:2) {
cat(sprintf("\n--- Group %d ---\n", k))
g <- metrics$per_group[[k]]
cat(sprintf(" TP=%d, FP=%d, TN=%d, FN=%d\n", g$TP, g$FP, g$TN, g$FN))
cat(sprintf(" TPR=%.3f, FPR=%.3f, F1=%.3f\n", g$TPR, g$FPR, g$F1))
cat(sprintf(" Frobenius norm: %.3f\n", g$frobenius_norm))
}
#>
#> --- Group 1 ---
#> TP=25, FP=7, TN=71, FN=2
#> TPR=0.926, FPR=0.090, F1=0.847
#> Frobenius norm: 2.240
#>
#> --- Group 2 ---
#> TP=29, FP=3, TN=71, FN=2
#> TPR=0.935, FPR=0.041, F1=0.921
#> Frobenius norm: 4.708Differential edges
diff_edges() tests which edges differ between two
groups. With method = "permutation" (the default) it pools
the rows of the two groups, shuffles the group labels B
times, and refits ssjgl() with the settings stored in the
fit. The null scores |Δ| are pooled across all edges and permutations,
and the resulting p-values are adjusted by Benjamini-Hochberg. Here Δ is
the difference in partial correlations, group 1 minus group 2.
method = "bootstrap" gives percentile intervals for Δ
instead.
Each refit repeats the whole v0 ladder, so this is the slow step. If
the future.apply package is installed, the refits run
in parallel under any future::plan(),
e.g. future::plan(future::multisession, workers = 4).
Results depend only on set.seed(), not on the plan.
set.seed(1)
de <- diff_edges(fit, Y = sim$data_list, B = 20)
de
#> <ssjgl_diff_edges>
#> group 1 vs group 2, pcor scale, permutation (B = 20), v0 step 3
#> 12 of 105 edges selected at BH-adjusted p < 0.05
#> i j node1 node2 est_g1 est_g2 diff pvalue p_adj selected
#> 4 5 V4 V5 0.29123 -0.328 0.619 0.000476 0.00555 TRUE
#> 3 4 V3 V4 0.29342 -0.314 0.607 0.000476 0.00555 TRUE
#> 9 10 V9 V10 -0.37791 0.207 -0.585 0.000476 0.00555 TRUE
#> 11 13 V11 V13 0.18953 -0.379 0.568 0.000476 0.00555 TRUE
#> 12 13 V12 V13 -0.24371 0.305 -0.549 0.000476 0.00555 TRUE
#> 9 11 V9 V11 -0.23151 0.279 -0.510 0.000476 0.00555 TRUE
#> 14 15 V14 V15 -0.17495 0.235 -0.410 0.000476 0.00555 TRUE
#> 3 13 V3 V13 -0.02633 0.328 -0.354 0.000476 0.00555 TRUE
#> 7 9 V7 V9 0.35160 0.000 0.352 0.000476 0.00555 TRUE
#> 3 11 V3 V11 0.00814 0.299 -0.290 0.002380 0.02499 TRUE
#> prob_dissimilar
#> 1
#> 1
#> 1
#> 1
#> 1
#> 1
#> 1
#> 1
#> 1
#> 1
#> ... and 2 moreThe result is a data frame with one row per edge (i,
j, node1, node2,
est_g1, est_g2, diff,
pvalue, p_adj, selected). Where
the concepts match, the column names are those used by multiGGMr’s
diff_summary()/select_diff_edges(). The plot
shows the selected edges, coloured by direction, and the group 1 vs
group 2 partial correlations:
plot(de)
Because this fit uses doubly = TRUE, the table also has
prob_dissimilar, the EM’s P(ξ = 1) for each edge. It
indicates that the edge is only weakly shrunk towards equality across
groups. It is not a calibrated probability that the edge differs, and in
real data it can be close to 1 for every included edge. Use the
permutation p-values for inference.
B = 20 keeps this vignette fast. Use
B = 100 or more in practice: with pooled nulls the smallest
attainable p-value is about 1 / (B × number of edges), but BH needs
enough permutations to be stable.
Select v0 via cross-validation
For real data where the truth is unknown, use cross-validation to select the best v0. This evaluates each v0 independently using held-out Gaussian negative log-likelihood:
v0s_cv <- make_v0_ladder(lambda1 = 1, n_steps = 8, min_ratio = 5, max_ratio = 500)
cv_res <- SSJGL_select_v0_cv(
Y = lapply(sim$data_list, scale),
v0s = v0s_cv,
folds = 3,
penalty = "fused",
lambda0 = 1, lambda1 = 1, lambda2 = 1,
maxitr.em = 50, maxitr.jgl = 50
)
cat("Best v0:", cv_res$v0_best, "\n")Bootstrap confidence intervals
The SSJGL_final_with_pcor_CI function computes bootstrap
confidence intervals for partial correlations at a given v0:
boot_res <- SSJGL_final_with_pcor_CI(
Y = lapply(sim$data_list, scale),
v0_best = 0.01,
B = 50,
ci_level = 0.95,
penalty = "fused",
lambda0 = 1, lambda1 = 1, lambda2 = 1
)
# Partial correlation point estimate for group 1
pcor_g1 <- boot_res$pcor_hat[[1]]
# 95% CI bounds
lo <- boot_res$CI_lower[[1]]
hi <- boot_res$CI_upper[[1]]
# Edges where CI excludes zero
significant_edges <- (lo > 0 | hi < 0)
diag(significant_edges) <- FALSE
cat("Significant edges (Group 1):",
sum(significant_edges[upper.tri(significant_edges)]), "\n")Tuning summary
| Parameter | Role | Guidance |
|---|---|---|
standardize |
Centre and scale each variable |
TRUE unless variables share a meaningful scale |
lambda0 |
Diagonal penalty | Use 1 (insensitive) |
lambda1 |
Edge sparsity (with v0) |
1 with standardised data |
lambda2 |
Cross-group similarity | 0 (separate) to lambda1 or more (more borrowing) |
v0s |
Spike ladder; spike rate lambda1/v0
|
Default make_v0_ladder(lambda1):
lambda1/v0 = 10, 31.6, 100 |
v1 |
Slab variance | Leave at 1 |
a, b |
Beta prior for pi |
a = 1, b = p for a sparse prior |
doubly |
Doubly spike-and-slab | TRUE recommended for multi-group |
Recommended workflow:
- Fit with
standardize = TRUE,lambda0 = 1,lambda1 = 1. - Set
lambda2 = lambda1as a starting point, and adjust it for more or less cross-group similarity. - Keep the default ladder (
v0snot given). Check withplot_stability()on a longer ladder if in doubt. - Select edges by thresholding posterior probabilities at 0.5.
- For real data, use
SSJGL_select_v0_cv()to check the v0 choice, or bootstrap CIs for edge-level inference.
