Skip to contents

Overview

This article benchmarks mrIML’s co-occurrence network inference against three widely-used alternatives:

  • Hmsc (Hierarchical Models of Species Communities) — a joint species distribution model that recovers residual associations via a latent-factor random effect, here using 95% posterior credible intervals to threshold edges.
  • SPIEC-EASI — a sparse graphical model designed for compositional microbiome data.
  • SparCC — a correlation method corrected for the compositional nature of count data.

All four methods are applied to presence–absence data generated from a generalised Lotka–Volterra (GLV) simulator across four ecological interaction scenarios: random, mutualistic, competitive, and mixed. Network recovery is evaluated with the Spearman Mantel test against the known (simulated) interaction matrix.

The simulation framework is adapted from the mrIML paper supplementary material. The key modification here is that the Hmsc network uses posterior-mean OmegaCor values, retaining only edges whose 95% credible intervals exclude zero.


1. Setup

# Data manipulation
library(here)
library(readxl)
library(tidyverse)
library(DT)

# Modelling
library(mrIML)
library(tidymodels)
library(future)
library(finetune)
library(flashlight)
library(Hmsc)      # Hierarchical Models of Species Communities
library(coda)      # MCMC diagnostics (Gelman-Rubin R-hat)

# Network construction and visualisation
library(igraph)
library(ggnetwork)
library(cowplot)
library(patchwork)
library(seqtime)   # GLV simulator (generateDataSet, glv)
library(vegan)     # Mantel test
library(SpiecEasi) # SPIEC-EASI and SparCC
set.seed(109)

# Use all but two cores for mrIML's internal parallelism.
# The outer replicate loop remains sequential to avoid resource contention
# (mrIML already saturates available cores across species).
n_cores <- parallel::detectCores()
plan("multisession", workers = n_cores - 2)

2. Helper functions

2.1 Simulate presence–absence data

# Simulate a community from a GLV interaction matrix M.
# Returns both raw count data (needed by SPIEC-EASI / SparCC) and
# a presence-absence (PA) data frame (needed by mrIML / Hmsc).
make_presence_absence_data <- function(M, n_samples = 900, count = NULL, mode = 4) {
  if (is.null(count)) {
    # Default total count scales with network size
    count <- nrow(M) * 10000
  }

  # seqtime::generateDataSet returns a species × samples matrix
  raw_species_x_samples <- generateDataSet(n_samples, M, count = count, mode = mode)

  # Transpose to samples × species and binarise to presence (1) / absence (0)
  pa <- raw_species_x_samples |>
    t() |>
    as.data.frame() |>
    mutate(across(everything(), ~ ifelse(.x < 1, yes = 0, no = 1)))

  list(
    raw = raw_species_x_samples,  # species × samples; input format for SPIEC-EASI / SparCC
    pa  = pa                       # samples × species PA; input format for mrIML / Hmsc
  )
}

2.2 Fit an mrIML co-occurrence network

# Fit an mrIML random-forest co-occurrence network and convert it to an igraph object.
# Each species in Y is modelled as a function of all other species (X1 = Y).
# Edges are retained when mean bootstrapped association strength > mean_strength_cutoff.
fit_mrIML_network <- function(Y, model_rf, mean_strength_cutoff = 0.1) {
  X1 <- Y  # use all species as predictors for every other species

  yhats_rf_sim <- mrIMLpredicts(
    Y     = Y,
    X     = NULL,  # no environmental predictors in this simulation
    X1    = X1,
    Model = model_rf,
    prop  = 0.7,   # 70 / 30 train–test split per species model
    k     = 5,     # 5-fold cross-validation
    racing = FALSE
  )

  # Bootstrap across cross-validation folds to estimate edge uncertainty
  bs_sim   <- mrBootstrap(yhats_rf_sim)
  assoc_net <- mrCoOccurNet(bs_sim)

  # Convert association table to a weighted adjacency matrix.
  # Sign is encoded in mean_strength_dir (negative = competition / exclusion).
  mrIML_mat <- assoc_net |>
    filter(mean_strength > mean_strength_cutoff) |>
    mutate(
      mean_strength_dir = ifelse(
        direction == "negative",
        yes = -mean_strength,
        no  =  mean_strength
      ),
      # Strip the "sp" prefix added by mrCoOccurNet so row/col names are integers
      across(contains("taxa"), ~ sub("^sp", "", .x))
    ) |>
    graph_from_data_frame(
      directed = FALSE,
      vertices = sub("^sp", "", names(Y))
    ) |>
    as_adjacency_matrix(attr = "mean_strength_dir", sparse = FALSE)

  g_mrIML <- graph_from_adjacency_matrix(
    mrIML_mat,
    mode     = "undirected",
    weighted = TRUE,
    diag     = FALSE
  )

  list(
    yhats     = yhats_rf_sim,
    bootstrap = bs_sim,
    assoc_net = assoc_net,
    mat       = mrIML_mat,
    graph     = g_mrIML,
    edge_col  = ifelse(E(g_mrIML)$weight < 0, yes = "red", no = "blue"),
    edge_width = rep(0.4, ecount(g_mrIML))
  )
}

2.3 Fit an Hmsc model

# Fit a probit Hmsc model with a sample-level random effect.
# The random effect captures residual species associations (OmegaCor) after
# accounting for the intercept-only fixed effects.
# MCMC settings (thin, samples, transient, nChains) trade accuracy for speed;
# use the defaults for final results and reduce for quick smoke tests.
fit_hmsc_model <- function(Y,
                           thin      = 100,
                           samples   = 5000,
                           transient = 20000,
                           nChains   = 4) {

  Y_hmsc <- as.data.frame(Y)

  # Remove any incidental row-index columns that would confuse Hmsc
  id_cols <- intersect(names(Y_hmsc), c("sample", "site", "row", "id", "X"))
  if (length(id_cols) > 0) {
    Y_hmsc <- dplyr::select(Y_hmsc, -dplyr::all_of(id_cols))
  }

  # Hmsc probit expects a numeric 0/1 matrix (rows = samples, columns = species)
  Y_hmsc <- as.matrix(dplyr::mutate(Y_hmsc, dplyr::across(dplyr::everything(), as.numeric)))

  # Drop incomplete rows to avoid row-number mismatches with XData / studyDesign
  keep_rows <- stats::complete.cases(Y_hmsc)
  Y_hmsc    <- Y_hmsc[keep_rows, , drop = FALSE]

  sample_ids <- paste0("sample_", seq_len(nrow(Y_hmsc)))
  rownames(Y_hmsc) <- sample_ids

  # Intercept-only fixed effects (no environmental covariates in this simulation)
  XData      <- data.frame(intercept = rep(1, nrow(Y_hmsc)), row.names = sample_ids)
  # One random level per sampling unit — this is what produces OmegaCor
  studyDesign <- data.frame(sample = factor(sample_ids), row.names = sample_ids)

  stopifnot(nrow(Y_hmsc) == nrow(XData))
  stopifnot(nrow(Y_hmsc) == nrow(studyDesign))

  rL <- HmscRandomLevel(units = levels(studyDesign$sample))

  m_hmsc <- Hmsc(
    Y           = Y_hmsc,
    XFormula    = ~1,
    XData       = XData,
    distr       = "probit",   # appropriate for binary presence-absence
    studyDesign = studyDesign,
    ranLevels   = list("sample" = rL)
  )

  sampleMcmc(
    m_hmsc,
    thin      = thin,
    samples   = samples,
    transient = transient,
    nChains   = nChains,
    verbose   = 0
  )
}

2.4 Check Hmsc MCMC convergence

# Compute the Gelman-Rubin R-hat for OmegaCor parameters.
# Values > 1.1 suggest the chains have not converged and results should be
# interpreted cautiously (or MCMC should be run longer).
check_hmsc_omega_convergence <- function(m_hmsc, rhat_cutoff = 1.1) {
  mpost <- convertToCodaObject(m_hmsc)
  psrf  <- gelman.diag(mpost$Omega[[1]], multivariate = FALSE)$psrf

  if (any(psrf[, "Point est."] > rhat_cutoff, na.rm = TRUE)) {
    warning(
      "Hmsc convergence concern: one or more Omega parameters have Rhat > ",
      rhat_cutoff,
      ". Interpret Hmsc network results cautiously."
    )
  }

  psrf
}

2.5 Build an Hmsc credible-interval network

# Construct an Hmsc residual-association network using 95% posterior credible intervals.
# Only edges whose 95% CrI for OmegaCor excludes zero are retained —
# this is more conservative than thresholding by the posterior mean alone.
make_hmsc_ci_network <- function(m_hmsc,
                                 mrIML_mat,
                                 ci           = c(0.025, 0.975),
                                 random_level = 1) {

  # getPostEstimate returns posterior mean and requested quantiles for OmegaCor
  post_omega <- getPostEstimate(
    m_hmsc,
    parName = "OmegaCor",
    r       = random_level,
    q       = ci
  )

  Omega_mean <- post_omega$mean
  # Hmsc does not set dimnames for OmegaCor; add species names manually
  dimnames(Omega_mean) <- list(m_hmsc$spNames, m_hmsc$spNames)

  # post_omega$q is a 3D array: quantile × species × species
  Omega_lwr <- post_omega$q[1, , ]
  Omega_upr <- post_omega$q[2, , ]
  dimnames(Omega_lwr) <- list(m_hmsc$spNames, m_hmsc$spNames)
  dimnames(Omega_upr) <- list(m_hmsc$spNames, m_hmsc$spNames)

  # Zero out any edge whose 95% CrI spans zero (i.e., not credibly non-zero)
  Omega_CI <- Omega_mean
  Omega_CI[Omega_lwr <= 0 & Omega_upr >= 0] <- 0
  diag(Omega_CI) <- 0

  # Match species names between the Hmsc matrix (uses "sp" prefix) and the
  # mrIML matrix (prefix stripped). Guard against naming convention mismatches.
  hmsc_all_names     <- colnames(Omega_mean)
  candidate_names    <- paste0("sp", rownames(mrIML_mat))
  hmsc_species_names <- intersect(candidate_names, hmsc_all_names)

  if (length(hmsc_species_names) == 0) {
    stop(
      "make_hmsc_ci_network: no species names in common between mrIML matrix (",
      paste(head(candidate_names), collapse = ", "),
      ") and Hmsc OmegaCor matrix (",
      paste(head(hmsc_all_names), collapse = ", "),
      "). Check species naming conventions."
    )
  }

  hmsc_mat <- Omega_CI[hmsc_species_names, hmsc_species_names, drop = FALSE]
  hmsc_lwr <- Omega_lwr[hmsc_species_names, hmsc_species_names, drop = FALSE]
  hmsc_upr <- Omega_upr[hmsc_species_names, hmsc_species_names, drop = FALSE]

  # Align row/col names with the mrIML matrix for downstream comparison
  rownames(hmsc_mat) <- colnames(hmsc_mat) <- rownames(mrIML_mat)
  rownames(hmsc_lwr) <- colnames(hmsc_lwr) <- rownames(mrIML_mat)
  rownames(hmsc_upr) <- colnames(hmsc_upr) <- rownames(mrIML_mat)

  g_hmsc <- graph_from_adjacency_matrix(
    hmsc_mat,
    mode     = "undirected",
    weighted = TRUE,
    diag     = FALSE
  )

  # Store the credible interval bounds and edge sign on the graph object
  if (ecount(g_hmsc) > 0) {
    edge_list       <- as_edgelist(g_hmsc)
    E(g_hmsc)$ci_lwr <- mapply(function(a, b) hmsc_lwr[a, b], edge_list[, 1], edge_list[, 2])
    E(g_hmsc)$ci_upr <- mapply(function(a, b) hmsc_upr[a, b], edge_list[, 1], edge_list[, 2])
    E(g_hmsc)$sign   <- ifelse(E(g_hmsc)$weight > 0, "positive", "negative")
  }

  list(
    post      = post_omega,
    mat       = hmsc_mat,
    lwr       = hmsc_lwr,
    upr       = hmsc_upr,
    graph     = g_hmsc,
    edge_col  = ifelse(E(g_hmsc)$weight < 0, yes = "red", no = "blue"),
    edge_width = rep(0.4, ecount(g_hmsc))
  )
}

2.6 Fit a SPIEC-EASI network

# Fit a SPIEC-EASI sparse inverse-covariance network (MB = Meinshausen-Bühlmann).
# SPIEC-EASI was designed for compositional microbiome count data but performs
# well on other sparse ecological count matrices.
# raw_species_x_samples must be a species × samples matrix (seqtime convention).
fit_spiec_network <- function(raw_species_x_samples, Y) {
  sp_names       <- colnames(Y)                                    # e.g. "sp1", "sp3", ...
  raw_subset     <- raw_species_x_samples[sp_names, , drop = FALSE]
  sp_names_clean <- sub("^sp", "", sp_names)

  # SPIEC-EASI expects samples × features; transpose from species × samples
  se_fit   <- spiec.easi(t(raw_subset), method = "mb",
                         pulsar.params = list(rep.num = 20))
  beta_mat <- as.matrix(symBeta(getOptBeta(se_fit), mode = "lower"))
  diag(beta_mat) <- 0
  rownames(beta_mat) <- colnames(beta_mat) <- sp_names_clean

  g_spiec <- graph_from_adjacency_matrix(
    beta_mat,
    mode     = "undirected",
    weighted = TRUE,
    diag     = FALSE
  )

  list(
    mat       = beta_mat,
    graph     = g_spiec,
    edge_col  = ifelse(E(g_spiec)$weight < 0, "red", "blue"),
    edge_width = rep(0.4, ecount(g_spiec))
  )
}

2.7 Fit a SparCC network

# Fit a SparCC correlation network with bootstrap-based p-values.
# Edges with p > alpha are set to zero. SparCC corrects for the
# spurious correlations induced by compositionality in count data.
fit_sparcc_network <- function(raw_species_x_samples, Y,
                               alpha = 0.05, n_boot = 50) {
  sp_names       <- colnames(Y)
  raw_subset     <- raw_species_x_samples[sp_names, , drop = FALSE]
  sp_names_clean <- sub("^sp", "", sp_names)

  counts_mat  <- t(raw_subset)       # samples × features for SpiecEasi functions
  sparcc_out  <- SpiecEasi::sparcc(counts_mat)
  sparcc_boot <- SpiecEasi::sparccboot(counts_mat, R = n_boot)
  sparcc_pval <- SpiecEasi::pval.sparccboot(sparcc_boot)

  sparcc_cor <- sparcc_out$Cor
  sparcc_cor[sparcc_pval$pvals > alpha] <- 0   # retain only significant correlations
  diag(sparcc_cor) <- 0
  rownames(sparcc_cor) <- colnames(sparcc_cor) <- sp_names_clean

  g_sparcc <- graph_from_adjacency_matrix(
    sparcc_cor,
    mode     = "undirected",
    weighted = TRUE,
    diag     = FALSE
  )

  list(
    mat       = sparcc_cor,
    graph     = g_sparcc,
    edge_col  = ifelse(E(g_sparcc)$weight < 0, "red", "blue"),
    edge_width = rep(0.4, ecount(g_sparcc))
  )
}

2.8 Extract the simulated truth network

# Subset the GLV interaction matrix M to the species retained after
# mrIML's rare/common filtering. This gives the 'ground truth' sub-network
# against which all four methods are evaluated.
make_truth_network <- function(M, mrIML_mat) {
  matching_indices <- as.numeric(rownames(mrIML_mat))
  true_mat <- M[matching_indices, matching_indices, drop = FALSE]
  rownames(true_mat) <- colnames(true_mat) <- rownames(mrIML_mat)

  g_true <- graph_from_adjacency_matrix(
    true_mat,
    mode     = "undirected",
    weighted = TRUE,
    diag     = FALSE
  )

  list(
    mat       = true_mat,
    graph     = g_true,
    edge_col  = ifelse(E(g_true)$weight < 0, yes = "red", no = "blue"),
    edge_width = abs(E(g_true)$weight) * 5,  # edge width proportional to interaction strength
    layout    = layout_nicely(g_true)          # shared layout for all five panel plots
  )
}

2.9 Side-by-side network plot

# Plot all five networks (mrIML, Hmsc, SPIEC-EASI, SparCC, truth) side by side,
# using the simulated-truth layout so node positions are identical across panels.
# Red edges = negative (competitive / inhibitory) interactions;
# blue edges = positive (mutualistic / facilitative) interactions.
plot_five_networks <- function(mrIML_res, hmsc_res, spiec_res, sparcc_res, truth_res,
                               title_suffix = "") {
  par_old <- par(mfrow = c(1, 5), mar = c(1, 1, 2, 1))
  on.exit(par(par_old), add = TRUE)

  plot(mrIML_res$graph,  layout = truth_res$layout,
       edge.color = mrIML_res$edge_col,  edge.width = mrIML_res$edge_width,
       main = paste("mrIML", title_suffix))

  plot(hmsc_res$graph,   layout = truth_res$layout,
       edge.color = hmsc_res$edge_col,   edge.width = hmsc_res$edge_width,
       main = paste("Hmsc 95% CrI", title_suffix))

  plot(spiec_res$graph,  layout = truth_res$layout,
       edge.color = spiec_res$edge_col,  edge.width = spiec_res$edge_width,
       main = paste("SPIEC-EASI", title_suffix))

  plot(sparcc_res$graph, layout = truth_res$layout,
       edge.color = sparcc_res$edge_col, edge.width = sparcc_res$edge_width,
       main = paste("SparCC", title_suffix))

  plot(truth_res$graph,  layout = truth_res$layout,
       edge.color = truth_res$edge_col,  edge.width = truth_res$edge_width,
       main = paste("Simulated truth", title_suffix))
}

2.10 Compare inferred networks against the truth (Mantel test)

# Use the Spearman Mantel test to measure the rank correlation between each
# method's inferred association matrix and the simulated interaction matrix.
# Matrices are on different scales, so Spearman (rank-based) correlation is used
# rather than comparing absolute values directly.
compare_with_truth <- function(true_mat, mrIML_mat, hmsc_mat, spiec_mat, sparcc_mat,
                               permutations = 999) {
  dist_true   <- as.dist(1 - true_mat)
  dist_mrIML  <- as.dist(1 - mrIML_mat)
  dist_hmsc   <- as.dist(1 - hmsc_mat)
  dist_spiec  <- as.dist(1 - spiec_mat)
  dist_sparcc <- as.dist(1 - sparcc_mat)

  list(
    mrIML      = vegan::mantel(dist_true, dist_mrIML,  method = "spearman", permutations = permutations),
    Hmsc       = vegan::mantel(dist_true, dist_hmsc,   method = "spearman", permutations = permutations),
    SPIEC_EASI = vegan::mantel(dist_true, dist_spiec,  method = "spearman", permutations = permutations),
    SparCC     = vegan::mantel(dist_true, dist_sparcc, method = "spearman", permutations = permutations)
  )
}

2.11 Orchestrate one complete simulation scenario

# Run a single simulation scenario end-to-end:
#   1. Generate a GLV interaction network of the specified type
#   2. Simulate presence-absence community data from that network
#   3. Filter rare / ubiquitous species
#   4. Fit all four co-occurrence models
#   5. Plot inferred vs. truth networks
#   6. Evaluate recovery with Mantel tests
run_scenario <- function(scenario,
                         interaction_type,
                         interact_str_max,
                         network_size        = 20,
                         k_average           = 4,
                         mix_compt_ratio     = 0.5,
                         self_regulation     = NULL,
                         model_rf,
                         hmsc_thin           = 50,
                         hmsc_samples        = 1000,
                         hmsc_transient      = 5000,
                         hmsc_nChains        = 4,
                         sparcc_alpha        = 0.05,
                         sparcc_n_boot       = 50,
                         mantel_permutations = 999) {

  # --- Step 1: generate the simulated interaction network ---
  # generateM_specific_type returns a list: [[1]] igraph object, [[2]] GLV matrix M
  simulated_network <- generateM_specific_type(
    nn             = network_size,
    k_ave          = k_average,
    type.network   = "random",
    type.interact  = interaction_type,
    interact.str.max = interact_str_max,
    mix.compt.ratio  = mix_compt_ratio
  )

  true_network <- simulated_network[[1]]
  M            <- simulated_network[[2]]

  # Optionally override diagonal (self-regulation terms); needed for competitive scenarios
  # to prevent the system from diverging
  if (!is.null(self_regulation)) {
    diag(M) <- self_regulation
  }

  # --- Step 2: simulate community data from the GLV model ---
  data_obj <- make_presence_absence_data(
    M,
    n_samples = 900,
    count     = network_size * 10000,
    mode      = 4
  )

  # --- Step 3: remove rare (< 1%) and ubiquitous (> 99%) species ---
  Y <- filterRareCommon(data_obj$pa, lower = 0.01, higher = 0.99)

  # --- Step 4: fit all four models ---
  mrIML_res <- fit_mrIML_network(Y = Y, model_rf = model_rf)
  truth_res <- make_truth_network(M = M, mrIML_mat = mrIML_res$mat)

  m_hmsc <- fit_hmsc_model(
    Y         = Y,
    thin      = hmsc_thin,
    samples   = hmsc_samples,
    transient = hmsc_transient,
    nChains   = hmsc_nChains
  )
  psrf <- check_hmsc_omega_convergence(m_hmsc)

  hmsc_res   <- make_hmsc_ci_network(m_hmsc = m_hmsc, mrIML_mat = mrIML_res$mat,
                                     ci = c(0.025, 0.975), random_level = 1)
  spiec_res  <- fit_spiec_network(data_obj$raw, Y)
  sparcc_res <- fit_sparcc_network(data_obj$raw, Y,
                                   alpha = sparcc_alpha, n_boot = sparcc_n_boot)

  # --- Step 5: visualise ---
  plot_five_networks(mrIML_res, hmsc_res, spiec_res, sparcc_res, truth_res,
                     title_suffix = paste0("(", scenario, ")"))

  # --- Step 6: evaluate recovery ---
  mantel_res <- compare_with_truth(
    true_mat   = truth_res$mat,
    mrIML_mat  = mrIML_res$mat,
    hmsc_mat   = hmsc_res$mat,
    spiec_mat  = spiec_res$mat,
    sparcc_mat = sparcc_res$mat,
    permutations = mantel_permutations
  )

  list(
    scenario     = scenario,
    true_network = true_network,
    M            = M,
    data         = data_obj,
    Y            = Y,
    mrIML        = mrIML_res,
    hmsc_model   = m_hmsc,
    hmsc_psrf    = psrf,
    hmsc         = hmsc_res,
    spiec        = spiec_res,
    sparcc       = sparcc_res,
    truth        = truth_res,
    mantel       = mantel_res
  )
}

3. Network generation helper (generateM_specific_type)

The generateM_specific_type function (Takemoto et al.) constructs a random GLV interaction matrix M with a specified topology and interaction type.

# Generate a GLV interaction matrix with a given network topology and interaction type.
#
# Parameters:
#   nn              – number of species (nodes)
#   k_ave           – average degree (mean edges per node)
#   type.network    – topology: "random" (Erdős-Rényi), "sf" (scale-free),
#                               "sw" (small-world), "bipar" (bipartite)
#   type.interact   – interaction signs: "random", "mutual" (+/+),
#                                        "compt" (-/-), "pp" (+/- predator-prey),
#                                        "mix" (mutualism + competition),
#                                        "mix2" (competition + predator-prey)
#   interact.str.max – maximum absolute interaction strength
#   mix.compt.ratio  – proportion of competitive interactions in "mix"/"mix2"
generateM_specific_type <- function(nn, k_ave,
                                    type.network     = "random",
                                    type.interact    = "random",
                                    interact.str.max = 0.5,
                                    mix.compt.ratio  = 0.5) {
  nl <- round(k_ave * nn / 2)  # number of edges

  # Build the graph topology
  if (type.network == "random") {
    g <- erdos.renyi.game(nn, nl, type = "gnm")
  } else if (type.network == "sf") {
    g <- static.power.law.game(nn, nl, 2.2, -1,
                                loops = FALSE, multiple = FALSE,
                                finite.size.correction = TRUE)
  } else if (type.network == "sw") {
    g <- sample_smallworld(1, nn, round(k_ave / 2), 0.05,
                           loops = FALSE, multiple = FALSE)
  } else if (type.network == "bipar") {
    g <- sample_bipartite(nn / 2, nn / 2, type = "gnm", m = nl, directed = FALSE)
  } else {
    stop("network type is invalid")
  }

  edgelist <- get.edgelist(g)
  A        <- matrix(0, nrow = nn, ncol = nn)

  # Assign interaction strengths based on ecological type
  if (type.interact == "random") {
    for (i in seq_len(nl)) {
      A[edgelist[i, 1], edgelist[i, 2]] <- runif(1, -interact.str.max, interact.str.max)
      A[edgelist[i, 2], edgelist[i, 1]] <- runif(1, -interact.str.max, interact.str.max)
    }
  } else if (type.interact == "mutual") {          # mutualism: both signs positive
    for (i in seq_len(nl)) {
      A[edgelist[i, 1], edgelist[i, 2]] <- runif(1, max = interact.str.max)
      A[edgelist[i, 2], edgelist[i, 1]] <- runif(1, max = interact.str.max)
    }
  } else if (type.interact == "compt") {           # competition: both signs negative
    for (i in seq_len(nl)) {
      A[edgelist[i, 1], edgelist[i, 2]] <- -runif(1, max = interact.str.max)
      A[edgelist[i, 2], edgelist[i, 1]] <- -runif(1, max = interact.str.max)
    }
  } else if (type.interact == "pp") {              # predator-prey: opposing signs
    for (i in seq_len(nl)) {
      s <- if (runif(1) < 0.5) c(1, -1) else c(-1, 1)
      A[edgelist[i, 1], edgelist[i, 2]] <-  s[1] * runif(1, max = interact.str.max)
      A[edgelist[i, 2], edgelist[i, 1]] <-  s[2] * runif(1, max = interact.str.max)
    }
  } else if (type.interact == "mix") {             # mix of mutualism and competition
    for (i in seq_len(nl)) {
      sgn <- if (runif(1) < mix.compt.ratio) -1 else 1
      A[edgelist[i, 1], edgelist[i, 2]] <- sgn * runif(1, max = interact.str.max)
      A[edgelist[i, 2], edgelist[i, 1]] <- sgn * runif(1, max = interact.str.max)
    }
  }

  diag(A) <- -1  # self-regulation (can be overridden via run_scenario)
  list(g, A)
}

4. Model specification

network_size <- 20
k_average    <- 4

# Random forest used by mrIML. mtry and min_n are tuned by cross-validation.
model_rf <- rand_forest(
  trees = 100,
  mode  = "classification",
  mtry  = tune(),
  min_n = tune()
) |>
  set_engine("randomForest")

5. Population dynamics visualisation (optional)

Before running the full benchmarking simulations, it can be helpful to visualise the GLV population dynamics that generate the community data.

# Generate one random network and simulate its dynamics using the GLV model.
# The resulting time-series shows how species abundances fluctuate and interact
# before we sample the final community and convert to presence-absence.
simulated_network_demo <- generateM_specific_type(
  nn               = network_size,
  k_ave            = k_average,
  type.network     = "random",
  type.interact    = "random",
  interact.str.max = 0.4,
  mix.compt.ratio  = 0.5
)

M_demo  <- simulated_network_demo[[2]]
y_demo  <- rpois(network_size, lambda = 100)   # random initial abundances
r_demo  <- runif(network_size)                 # random intrinsic growth rates
res_demo <- glv(network_size, M_demo, r_demo, y_demo)

# Plot species abundances over the last 980 time steps
tsplot(10 * res_demo[, 20:min(1000, ncol(res_demo))], time.given = TRUE)

6. Single-replicate scenarios

Run each of the four ecological scenarios once, producing network visualisations and Mantel statistics.

random_res <- run_scenario(
  scenario         = "Random",
  interaction_type = "random",
  interact_str_max = 0.4,
  network_size     = network_size,
  k_average        = k_average,
  mix_compt_ratio  = 0.5,
  model_rf         = model_rf
)

mutual_res <- run_scenario(
  scenario         = "Mutualistic",
  interaction_type = "mutual",
  interact_str_max = 0.3,
  network_size     = network_size,
  k_average        = k_average,
  mix_compt_ratio  = 0.5,
  model_rf         = model_rf
)

competitive_res <- run_scenario(
  scenario         = "Competitive",
  interaction_type = "compt",
  interact_str_max = 0.5,
  network_size     = network_size,
  k_average        = k_average,
  mix_compt_ratio  = 0.5,
  self_regulation  = -0.5,   # stronger self-regulation prevents divergence under pure competition
  model_rf         = model_rf
)

mixed_res <- run_scenario(
  scenario         = "Mixed",
  interaction_type = "mix",
  interact_str_max = 0.4,
  network_size     = network_size,
  k_average        = k_average,
  mix_compt_ratio  = 0.5,
  model_rf         = model_rf
)

Summary table (single replicate)

scenarios <- list(random_res, mutual_res, competitive_res, mixed_res)

comparison_table <- tibble(
  Scenario = c("Random", "Mutualistic", "Competitive", "Mixed"),
  mrIML_r  = round(sapply(scenarios, \(s) s$mantel$mrIML$statistic),      3),
  mrIML_p  =       sapply(scenarios, \(s) s$mantel$mrIML$signif),
  Hmsc_r   = round(sapply(scenarios, \(s) s$mantel$Hmsc$statistic),       3),
  Hmsc_p   =       sapply(scenarios, \(s) s$mantel$Hmsc$signif),
  SPIEC_r  = round(sapply(scenarios, \(s) s$mantel$SPIEC_EASI$statistic), 3),
  SPIEC_p  =       sapply(scenarios, \(s) s$mantel$SPIEC_EASI$signif),
  SparCC_r = round(sapply(scenarios, \(s) s$mantel$SparCC$statistic),     3),
  SparCC_p =       sapply(scenarios, \(s) s$mantel$SparCC$signif)
)

DT::datatable(
  comparison_table,
  colnames = c(
    "Scenario",
    "mrIML r", "mrIML p",
    "Hmsc 95% CrI r", "Hmsc 95% CrI p",
    "SPIEC-EASI r", "SPIEC-EASI p",
    "SparCC r", "SparCC p"
  ),
  options = list(pageLength = 10, dom = "t")
)

7. Replicated simulations

A single replicate is sensitive to the random network draw. The run_scenarios_replicated() wrapper repeats each scenario n_reps times, accumulating Mantel statistics and summarising them as means with 95% quantile confidence intervals.

Parallelism note: mrIML saturates available cores within each replicate (one worker per species model), so the outer replicate loop is kept sequential to avoid resource contention.

Checkpointing: Each completed replicate is saved to disk. If the session crashes, re-running the same call will skip already-completed replicates automatically.

# Load the replicated-scenario wrapper and default scenario configurations
source("generateM_specific_type.R")
source("run_scenarios_replicated.R")

library(future)
library(furrr)

plan(multisession, workers = parallel::detectCores() - 2)
# Run each scenario n_reps = 5 times.
# Reduce hmsc_* settings for a quick smoke test; use defaults for final results.
rep_results <- run_scenarios_replicated(
  scenarios_config    = default_scenarios_config,
  n_reps              = 5,
  model_rf            = model_rf,
  mantel_permutations = 99,
  hmsc_thin           = 10,
  hmsc_samples        = 500,
  hmsc_transient      = 2000,
  hmsc_nChains        = 2,
  checkpoint_dir      = "sim_checkpoints"  # saved per-rep RDS files go here
)

Inspect results

# Long table: one row per scenario × rep × model
rep_results$raw

# Summary: mean Mantel r [95% quantile CI], mean p [95% CI], proportion significant
rep_results$summary

# Wide format: one row per scenario, all models as columns
rep_results$wide

# Interactive DT table with colour-coded significance bars
pretty_ci_table(rep_results$summary)

Publication-ready table (Word)

library(flextable)
library(officer)

pub_table <- rep_results$summary |>
  select(scenario, model, n_reps_ok, r_95CI, p_95CI, prop_sig_p) |>
  rename(
    Scenario                = scenario,
    Model                   = model,
    `Reps (OK)`             = n_reps_ok,
    `r (mean [95% CI])`     = r_95CI,
    `p (mean [95% CI])`     = p_95CI,
    `Prop. sig. (p < 0.05)` = prop_sig_p
  )

ft <- flextable(pub_table) |>
  merge_v(j = "Scenario") |>               # merge repeated scenario labels
  valign(j = "Scenario", valign = "top") |>
  theme_booktabs() |>
  bold(part = "header") |>
  autofit() |>
  set_caption(paste(
    "Table 1. Summary of co-occurrence model performance across simulation scenarios.",
    "r: Spearman Mantel correlation between predicted and true co-occurrence network;",
    "p: associated p-value;",
    "Prop. sig.: proportion of replicates with p < 0.05."
  ))

doc <- read_docx() |>
  body_add_flextable(ft)

print(doc, target = "results_summary_table.docx")

8. run_scenarios_replicated() internals

This section documents the helper functions sourced from run_scenarios_replicated.R.

Extract Mantel statistics

# Return a small data frame of r and p for all four models in one replicate.
.extract_mantel_df <- function(mantel_list, scenario, rep) {
  models <- c("mrIML", "Hmsc", "SPIEC_EASI", "SparCC")
  data.frame(
    scenario = scenario,
    rep      = rep,
    model    = models,
    r        = sapply(models, \(m) mantel_list[[m]]$statistic),
    p        = sapply(models, \(m) mantel_list[[m]]$signif),
    error    = NA_character_,
    row.names = NULL, stringsAsFactors = FALSE
  )
}

Run one replicate with error handling and checkpointing

# Execute a single scenario × rep, wrapping run_scenario() in tryCatch so
# that one failed replicate does not abort the whole simulation.
# Plots are redirected to a null device to avoid opening dozens of windows.
# Heavy objects (Hmsc MCMC chains, mrIML bootstraps, raw count data) are
# explicitly freed before returning to keep peak memory low.
.run_one_rep <- function(sc_name, sc_args, rep, model_rf, mantel_permutations,
                         checkpoint_dir, seed) {

  checkpoint_file <- if (!is.null(checkpoint_dir)) {
    file.path(checkpoint_dir, paste0(sc_name, "_rep", rep, ".rds"))
  } else NULL

  # Return cached result without re-running
  if (!is.null(checkpoint_file) && file.exists(checkpoint_file)) {
    message("  [cached] ", sc_name, " rep ", rep)
    return(readRDS(checkpoint_file))
  }

  call_args <- c(
    list(scenario            = paste0(sc_name, "_rep", rep),
         model_rf            = model_rf,
         mantel_permutations = mantel_permutations),
    sc_args
  )

  result <- tryCatch({
    grDevices::pdf(file = nullfile())     # suppress plot output
    null_dev <- grDevices::dev.cur()
    on.exit(grDevices::dev.off(null_dev), add = TRUE)

    res <- do.call(run_scenario, call_args)
    out <- .extract_mantel_df(res$mantel, sc_name, rep)

    rm(res); gc(verbose = FALSE, full = TRUE)  # free large objects immediately
    out
  },
  error = function(e) {
    msg <- conditionMessage(e)
    message("ERROR — Scenario '", sc_name, "' rep ", rep, ": ", msg)
    models <- c("mrIML", "Hmsc", "SPIEC_EASI", "SparCC")
    data.frame(scenario = sc_name, rep = rep, model = models,
               r = NA_real_, p = NA_real_, error = msg,
               row.names = NULL, stringsAsFactors = FALSE)
  })

  # Only cache successful results; skip caching failures so they can be retried
  if (!is.null(checkpoint_file) && !any(is.na(result$r))) {
    saveRDS(result, checkpoint_file)
  }

  result
}

Main wrapper

# Main entry point. Loops over all scenario × rep combinations sequentially,
# then summarises Mantel r and p with quantile confidence intervals.
run_scenarios_replicated <- function(scenarios_config,
                                     n_reps,
                                     model_rf,
                                     ci_alpha            = 0.05,
                                     sig_alpha           = 0.05,
                                     seed                = NULL,
                                     mantel_permutations = 499,
                                     hmsc_thin           = 50,
                                     hmsc_samples        = 1000,
                                     hmsc_transient      = 5000,
                                     hmsc_nChains        = 4,
                                     checkpoint_dir      = "sim_checkpoints",
                                     verbose             = TRUE) {

  stopifnot(is.list(scenarios_config), length(scenarios_config) > 0)
  stopifnot(n_reps >= 2)

  if (!is.null(checkpoint_dir) && !dir.exists(checkpoint_dir)) {
    dir.create(checkpoint_dir, recursive = TRUE)
    if (verbose) message("Created checkpoint directory: ", checkpoint_dir)
  }

  n_jobs   <- length(scenarios_config) * n_reps
  raw_list <- vector("list", n_jobs)
  idx      <- 1L

  for (sc_name in names(scenarios_config)) {
    for (rep in seq_len(n_reps)) {
      if (verbose) message("[", idx, "/", n_jobs, "] ", sc_name, " rep ", rep)
      if (!is.null(seed)) set.seed(seed + idx - 1L)

      raw_list[[idx]] <- .run_one_rep(
        sc_name             = sc_name,
        sc_args             = c(scenarios_config[[sc_name]],
                                list(hmsc_thin      = hmsc_thin,
                                     hmsc_samples   = hmsc_samples,
                                     hmsc_transient = hmsc_transient,
                                     hmsc_nChains   = hmsc_nChains)),
        rep                 = rep,
        model_rf            = model_rf,
        mantel_permutations = mantel_permutations,
        checkpoint_dir      = checkpoint_dir,
        seed                = NULL
      )
      idx <- idx + 1L
    }
  }

  raw <- do.call(rbind, raw_list)

  # Summarise: quantile CI for r and p, proportion of replicates with p < sig_alpha
  probs_lo <- ci_alpha / 2
  probs_hi <- 1 - ci_alpha / 2

  summary_tbl <- raw |>
    dplyr::group_by(scenario, model) |>
    dplyr::summarise(
      n_reps_ok  = sum(!is.na(r)),
      mean_r     = mean(r,  na.rm = TRUE),
      ci_lo_r    = quantile(r, probs_lo, na.rm = TRUE),
      ci_hi_r    = quantile(r, probs_hi, na.rm = TRUE),
      mean_p     = mean(p,  na.rm = TRUE),
      ci_lo_p    = quantile(p, probs_lo, na.rm = TRUE),
      ci_hi_p    = quantile(p, probs_hi, na.rm = TRUE),
      prop_sig_p = mean(p < sig_alpha, na.rm = TRUE),
      .groups    = "drop"
    ) |>
    dplyr::mutate(
      dplyr::across(c(mean_r, ci_lo_r, ci_hi_r, mean_p, ci_lo_p, ci_hi_p),
                    \(x) round(x, 3)),
      prop_sig_p = round(prop_sig_p, 2),
      r_95CI = paste0(mean_r, " [", ci_lo_r, ", ", ci_hi_r, "]"),
      p_95CI = paste0(mean_p, " [", ci_lo_p, ", ", ci_hi_p, "]")
    )

  wide_tbl <- summary_tbl |>
    dplyr::select(scenario, model, r_95CI, p_95CI, prop_sig_p) |>
    tidyr::pivot_wider(
      names_from  = model,
      values_from = c(r_95CI, p_95CI, prop_sig_p),
      names_glue  = "{model}_{.value}"
    )

  if (verbose) message("Done.")
  list(raw = raw, summary = summary_tbl, wide = wide_tbl)
}

Default scenario configurations

# Four scenarios matching the original supplementary page.
# Each list element is passed as extra arguments to run_scenario().
default_scenarios_config <- list(
  Random = list(
    interaction_type = "random",
    interact_str_max = 0.4,
    mix_compt_ratio  = 0.5
  ),
  Mutualistic = list(
    interaction_type = "mutual",
    interact_str_max = 0.3,
    mix_compt_ratio  = 0.5
  ),
  Competitive = list(
    interaction_type = "compt",
    interact_str_max = 0.5,
    mix_compt_ratio  = 0.5,
    self_regulation  = -0.5
  ),
  Mixed = list(
    interaction_type = "mix",
    interact_str_max = 0.4,
    mix_compt_ratio  = 0.5
  )
)

Notes on MCMC settings

For final published results, use the longer Hmsc MCMC settings:

random_res_long <- run_scenario(
  scenario         = "Random - longer Hmsc",
  interaction_type = "random",
  interact_str_max = 0.4,
  network_size     = network_size,
  k_average        = k_average,
  mix_compt_ratio  = 0.5,
  model_rf         = model_rf,
  hmsc_thin        = 100,
  hmsc_samples     = 2000,
  hmsc_transient   = 10000,
  hmsc_nChains     = 4
)

Check convergence diagnostics (Gelman-Rubin R-hat) before interpreting Hmsc results — values consistently below 1.1 across all Omega parameters suggest adequate mixing.