Joint species distribution models (JSDMs) typically either estimate residual species association networks or induce correlations among species using latent variables. These models usually do not examine direct effects (i.e. the conditional effect of one species' presence on another's occurrence probability), nor do they allow these effects to vary along environmental gradients. Conditional Random Fields (CRFs) overcome these limitations by estimating a joint Markov network, but they are costly to estimate and existing approaches (MRFcov for example) do not formulate a generative model that can make predictions for unsampled areas. This document describes an approach to build such a generative CRF model in a Bayesian framework. Herein, conditional co-occurrence associations (direct effects of one species on another) are jointly estimated, as are effects that represent how these pairwise associations change along environemental gradients. To overcome the issue of out-of-sample prediction, a set of latent variables is used to capture variance associated with co-occurrence effects when simulating new data from the model, borrowing from ideas used by many JSDM frameworks (i.e. Boral, HMSC, manyglm). This is very much a work-in-progress, but the following working example demonstrates that the idea is feasible and insightful.

First we simulate binary data that reflects a fixed set of direct and indirect co-occurrence parameters. Simulate the symmetric direct co-occurrence effect matrix. Here, species 1 and 2, and species 1 and 3, show direct co-occurrence effects; all other pairwise combinations are 0

beta_cooccur <- matrix(0, 4, 4)
beta_cooccur[1, 2] <- beta_cooccur[2, 1] <- 1.55
beta_cooccur[1, 3] <- beta_cooccur[3, 1] <- -0.8
diag(beta_cooccur) <- 1
beta_cooccur
##       [,1] [,2] [,3] [,4]
## [1,]  1.00 1.55 -0.8    0
## [2,]  1.55 1.00  0.0    0
## [3,] -0.80 0.00  1.0    0
## [4,]  0.00 0.00  0.0    1

Next simulate varying associations across the environment for species 1 vs 2. This is done in an array in case we wish to include more covariate-varying associations in later simulations

beta_x_ind <- array(0, dim = c(1, 4, 4))
beta_x_ind[1, 1, 2] <- beta_x_ind[1, 2, 1] <- -0.65
diag(beta_x_ind[1, , ]) <- 0
beta_x_ind[1, , ]
##       [,1]  [,2] [,3] [,4]
## [1,]  0.00 -0.65    0    0
## [2,] -0.65  0.00    0    0
## [3,]  0.00  0.00    0    0
## [4,]  0.00  0.00    0    0

Set simulation parameters

set.seed(10)
n_obs <- 500
alphas <- runif(ncol(beta_cooccur), -1.75, 
    1.75)
betas <- runif(ncol(beta_cooccur), -0.65, 
    0.65)
env <- sort(rnorm(n_obs))

Estimate the latent variables per species

xs <- matrix(rnorm(NCOL(beta_cooccur) * n_obs), 
    ncol = NCOL(beta_cooccur))

Post adjust the linear predictors using the simulated co-occurrence effects

data <- xs %*% beta_cooccur + env * xs %*% 
    beta_x_ind[1, , ]

Convert the linear predictors to probabilities using inverse logit, and take Bernoulli draws

env_adj <- matrix(NA, nrow = n_obs, ncol = NCOL(beta_cooccur))
for (i in 1:n_obs) {
    env_adj[i, ] <- xs[i, ] %*% ((env[i] * 
        beta_x_ind[1, , ]) + beta_cooccur)
}

data <- matrix(NA, nrow = n_obs, ncol = NCOL(beta_cooccur))
for (i in 1:NCOL(beta_cooccur)) {
    data[, i] <- alphas[i] + env_adj[, i] + 
        env * betas[i]
}
head(data)
##           [,1]     [,2]        [,3]       [,4]
## [1,]  1.617618 2.950001  0.03931257 1.53659187
## [2,]  4.316053 5.910665 -1.27115694 0.35504440
## [3,]  1.863015 3.738819 -0.97043882 0.04673351
## [4,]  4.671375 1.306840  0.27000264 1.44096526
## [5,] -0.711041 4.011945 -0.96610964 0.46061320
## [6,]  5.006869 4.538369  0.08552669 4.99545544

Convert to binary observations using Bernoulli draws; calculate observed prevalences

data <- data.frame(apply(data, 2, function(x) rbinom(n_obs, 
    1, boot::inv.logit(x))))
colnames(data) <- paste0("sp", seq_len(NCOL(data)))
data$env <- env
apply(data[, 1:NCOL(beta_cooccur)], 2, function(x) sum(x)/length(x))
##   sp1   sp2   sp3   sp4 
## 0.524 0.388 0.432 0.616

Create the predictor matrix for any covariates that are being scrutinised for their possible effects on co-occurrence associations; this can be scaled up to handle more covariates but we will just stick to the propZos covariate for this example

n_covariates <- 1
n_nodes <- NCOL(beta_cooccur)
x_direct <- matrix(NA, nrow = NROW(data), 
    ncol = n_covariates)
for (i in 1:n_covariates) {
    x_direct[, i] <- as.matrix(data)[, (n_nodes + 
        i)]
}
head(x_direct)
##           [,1]
## [1,] -3.001431
## [2,] -2.518335
## [3,] -2.436616
## [4,] -2.329880
## [5,] -2.321017
## [6,] -2.205472

Describe the Bayesian model using the probabilistic programming language JAGS

model_code <- "
model
{
  
#### Marginal likelihoods ####
  for (i in 1:N) {
   for (node in 1:N_nodes){
     y_marginal[i, node] ~ dbern(mu_cooccur[i, node])
     # Missing values are predicted using random intercepts, covariates and an error term
     # to capture the missing co-occurrence effects;
     # in essence, these are marginal predictions that can be used to simulate
     # Bernoulli draws for each species in unsampled areas
     logit(mu_cooccur[i, node]) <- alpha[node] + 
                                   x_direct[i, ] %*% beta_x_dir[, node] +
                                   error[i, node]
   }
  }

# Latent factors capture missing species association terms from the CRF model below.
# Note that we do not explicitly model these as multivariate normal, BUT they can still 
# demonstrate complex posterior associations with one another and with our measured covariates. This 
# is useful as the latent factors can be conditionally predicted out-of-sample (they will not 
# necessarily act as white noise when predicting for new areas) because of these associations
# with measured covariates. Also note that for larger sets of factors the variances can be 
# progressively penalised as in mvgam, but this isn't necessary in the simple example
  for (i in 1:N){
   for (j in 1:N_lv){
     LV[i, j] ~ dnorm(0, 1)
   }
  }

# Latent factor loadings: standard normal with identifiability constraints;
# these could be regularised using ddexp(0, lambda) if need be, but we don't need 
# them to necessarily be sparse. It is the cumulative 'error' term that is of interest,
# not the actual loadings. The upper triangle of loading matrix set to zero
  for (j in 1:(N_lv - 1)){
   for (j2 in (j + 1):N_lv){
    lv_coefs[j, j2] <- 0
  }
 }
  
# Positive constraints on loading diagonals
  for (j in 1:N_lv) {
   lv_coefs[j, j] ~ dnorm(0, 1)T(0, 1);
 }
  
# Lower diagonal free
  for (j in 2:N_lv){
   for (j2 in 1:(j - 1)){
    lv_coefs[j, j2] ~ dnorm(0, 1)T(-1, 1);
  }
 }
  
# Other elements also free
  for (j in (N_lv + 1):N_nodes) {
   for (j2 in 1:N_lv){
    lv_coefs[j, j2] ~ dnorm(0, 1)T(-1, 1);
  }
 }
  
# 'error' term for each species depends on latent factor associations
  for (i in 1:N){
   for (node in 1:N_nodes){
    error[i, node] <- inprod(lv_coefs[node,], LV[i,])
  }
 }

#### Design matrix construction for the CRF model ####
# Direct effect (co-occurrence) linear predictor contributions
  y_dir_linpred[1:N, 1:N_nodes] <- y_marginal %*% beta_cooccur

# Indirect covariate effects are modelled as interactions between marginal effects
# and covariates; set these up as an array to scale to >1 covariate
  for (i in 1:N){
   for (cov in 1:N_covariates){
    x_indirect[cov, i, 1:N_nodes] <- x_direct[i, cov] * y_marginal[i, ]
   }
 }
  
#### CRF model replaces 'error' above with direct and indirect co-occurrence effects ####
  for (i in 1:N) {
   for (node in 1:N_nodes){
     ys[i, node] ~ dbern(mu[i, node]);
     
     # Linear pedictor is a function of:
     # species-specific hierarchical intercepts;
     # fixed additive covariate effects;
     # direct co-occurrence effects;
     # interactions between covariates and co-occurrence effects
     logit(mu[i, node]) <- alpha[node] + 
                           x_direct[i, ] %*% beta_x_dir[, node] +
                           y_dir_linpred[i, node] +
                           x_indirect[, i, ] %*% beta_x_ind[, node, ]
  }
 }

#### Posterior simulations of outcomes ####
  for (i in 1:N) {
   for (node in 1:N_nodes){
     ypreds[i, node] ~ dbern(mu[i, node]);
   }
  }

#### Priors ####
# Hierarchical intercepts
   for (node in 1:N_nodes){
    # non-centred parameterisation sometimes needed #
    # alpha_raw[node] ~ dnorm(0, 1) #
    # alpha[node] <- mu_alpha + (sigma_alpha * alpha_raw[node]) #
    alpha[node] ~ dnorm(mu_alpha, tau_alpha)
  }
mu_alpha ~ dnorm(-1, 1)
sigma_alpha ~ dnorm(0, 1)T(0, )
tau_alpha <- pow(sigma_alpha, -2)

# Direct covariate effects; L1 regularised currently, but these could easily be modelled
# using a multivariate normal with scaled Wishart prior for the precision to capture any 
# species dependencies in environmental responses
  for (node in 1:N_nodes){
   for (cov in 1:N_covariates){
    beta_x_dir[cov, node] ~ ddexp(0, lambda)
   }
  }

# Direct effects (co-occurrence associations); L1 regularised
   for (node_a in 1:N_nodes) {
    # Diagonal elements are zero
    beta_cooccur[node_a, node_a] <- 0
  
    # Off-diagonal elements are symmetric
     for (node_b in (node_a + 1):N_nodes) {
      beta_cooccur[node_a, node_b] ~ ddexp(0, lambda)
      beta_cooccur[node_b, node_a] <- beta_cooccur[node_a, node_b]
   }
  }

# Indirect covariate effects; L1 regularised
  for(node_a in 1:N_nodes) {
   for (cov in 1:N_covariates){
    beta_x_ind[cov, node_a, node_a] <- 0
    for(node_b in (node_a + 1):N_nodes) {
      beta_x_ind[cov, node_a, node_b] ~ ddexp(0, lambda)
      beta_x_ind[cov, node_b, node_a] <- beta_x_ind[cov, node_a, node_b]
    }
   }
  }

# LASSO parameter is complexity-penalising; pulls toward
# larger penalties unless the data support smaller ones
lambda_raw ~ dbeta(3, 1)
lambda <- lambda_raw * 15
  
}
"

Prepare the data for JAGS; note how the observed binary occurrence matrix (ys) is supplied as data for the outcome AND for the y_marginal effect (co-occurrence) predictor matrix; this allows each species' presence/absence to act as a predictor of each other species' occurrence probability; It also satisfies the full generative capabilities we require, as marginal predictions can be made for unsampled areas using a set of latent variables that are constrained to represent out-of-sample co-occurrence effects. So in a way this model combines the most useful properties from latent variable JSDMs such as HMSC (inducing species' correlations using latent variables that themselves can have complex posterior associations with measured covariates to improve out of sample predictions) as well as CRFs (estimating direct co-occurrence effects and how these effects change along environmental gradients)

model_data <- list(N = NROW(data), 
                   ys = as.matrix(data[,1:NCOL(beta_cooccur)]),
                   y_marginal = as.matrix(data[,1:NCOL(beta_cooccur)]),
                   N_covariates = 1,
                   x_direct = as.matrix(x_direct),
                   N_nodes = NCOL(beta_cooccur),
                   # use 2 latent variables in this simple example
                   N_lv = 2)

Define the parameters to monitor during sampling

model_parameters <- c("alpha", "mu_alpha", 
    "sigma_alpha", "beta_cooccur", "beta_x_dir", 
    "beta_x_ind", "lambda", "mu_cooccur", 
    "mu", "lv_coefs", "error", "ypreds")

Run the model using four parallel Gibbs MCMC chains in runjags; takes ~ 8 minutes currently on a six-core machine with 32GB RAM

library(runjags)
library(parallel)
chains = 4
cl <- parallel::makePSOCKcluster(min(c(chains, 
    parallel::detectCores() - 1)))
setDefaultCluster(cl)
unlink("crf_mod.txt")
cat(model_code, file = "crf_mod.txt", sep = "\n", 
    append = T)
model_run <- run.jags(data = model_data, 
    modules = "glm", n.chains = chains, adapt = 1000, 
    burnin = 1500, sample = 1000, thin = 1, 
    method = "rjparallel", monitor = model_parameters, 
    cl = cl, model = "crf_mod.txt")
stopCluster(cl)
model_run <- coda::as.mcmc.list(model_run)
unlink("crf_mod.txt")
save(data, beta_cooccur, beta_x_ind, model_run, 
    n_nodes, file = "sim_model_run.rda")

Now we use the model for posterior inference. Define some general plotting colours

library(runjags)
## Warning: package 'runjags' was built under R version 4.0.5
load("sim_model_run.rda")
c_light <- c("#DCBCBC")
c_light_trans <- c("#DCBCBC70")
c_light_highlight <- c("#C79999")
c_mid <- c("#B97C7C")
c_mid_highlight <- c("#A25050")
c_mid_highlight_trans <- c("#A2505095")
c_dark <- c("#8F2727")
c_dark_highlight <- c("#7C0000")

Plot some traceplots to examine convergence of chains for key parameters of interest

MCMCvis::MCMCtrace(model_run, c("alpha"), 
    n.eff = T, pdf = F, Rhat = T, main_den = paste0(colnames(data)[1:n_nodes], 
        " intercept"), main_tr = paste0(colnames(data)[1:n_nodes], 
        " intercept"), col_den = c_mid, lwd_den = 2.5, 
    col_txt = "black")

MCMCvis::MCMCtrace(model_run, c("mu_alpha", 
    "sigma_alpha"), n.eff = T, pdf = F, Rhat = T, 
    main_den = c("Pop. intercept mu", "Pop. intercept sigma"), 
    main_tr = c("Pop. intercept mu", "Pop. intercept sigma"), 
    col_den = c_mid, lwd_den = 2.5, col_txt = "black")

Plot hierarchical intercept posteriors

par(mfrow = c(1, 1), mar = c(4, 4.5, 3, 4))
alphas <- MCMCvis::MCMCchains(model_run, 
    "alpha")
probs = c(0.05, 0.2, 0.3, 0.4, 0.5, 0.6, 
    0.7, 0.8, 0.95)
alpha_creds <- sapply(1:NCOL(alphas), function(n) quantile(alphas[, 
    n], probs = probs))
pop_alphas <- MCMCvis::MCMCchains(model_run, 
    "mu_alpha")
pop_sigmas <- MCMCvis::MCMCchains(model_run, 
    "sigma_alpha")
pop_intercepts <- rnorm(dim(pop_alphas)[1], 
    pop_alphas[, 1], sqrt(pop_sigmas[, 1]))
pop_creds <- quantile(pop_intercepts, probs = probs)
N <- n_nodes + 1
x <- 1:N
idx <- rep(1:N, each = 2)
repped_x <- rep(x, each = 2)
x <- sapply(1:length(idx), function(k) if (k%%2 == 
    0) repped_x[k] + min(diff(x))/2 else repped_x[k] - 
    min(diff(x))/2)

plot(1, type = "n", ylab = expression(Intercept ~ 
    (alpha[species])), xlab = "", xlim = range(x), 
    xaxt = "n", ylim = range(c(as.vector(alpha_creds)), 
        pop_creds))

rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[9, 
    ], pop_creds[9])), ybottom = as.numeric(c(alpha_creds[1, 
    ], pop_creds[1])), col = c_light, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[8, 
    ], pop_creds[8])), ybottom = as.numeric(c(alpha_creds[2, 
    ], pop_creds[2])), col = c_light_highlight, 
    border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[7, 
    ], pop_creds[7])), ybottom = as.numeric(c(alpha_creds[3, 
    ], pop_creds[3])), col = c_mid, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[6, 
    ], pop_creds[6])), ybottom = as.numeric(c(alpha_creds[4, 
    ], pop_creds[4])), col = c_mid_highlight, 
    border = "transparent")
axis(side = 1, at = 1:N, labels = c(colnames(data)[1:n_nodes], 
    "Population"), font.axis = 3)

for (k in 1:(N - 1)) {
    lines(x = c(x[seq(1, N * 2, by = 2)][k], 
        x[seq(2, N * 2, by = 2)][k]), y = c(alpha_creds[5, 
        k], alpha_creds[5, k]), col = c_dark, 
        lwd = 2)
}
lines(x = x[-c(1:((N - 1) * 2))], y = c(pop_creds[5], 
    pop_creds[5]), col = c_dark, lwd = 2)
abline(h = 0, col = "white", lty = 1, lwd = 3.5, 
    xpd = F)
abline(h = 0, col = "black", lty = 1, lwd = 2, 
    xpd = F)

Posteriors for the direct covariate effect (Beta env)

par(mfrow = c(1, 1), mar = c(4, 4.5, 3, 4))
all_cov_dirs <- MCMCvis::MCMCchains(model_run, 
    "beta_x_dir")
cov <- 1
beta_x_dirs <- all_cov_dirs[, grepl(paste0(cov, 
    ","), dimnames(all_cov_dirs)[[2]], perl = T)]
beta_x_dir_creds <- sapply(1:NCOL(beta_x_dirs), 
    function(n) quantile(beta_x_dirs[, n], 
        probs = probs))
N <- n_nodes
x <- 1:N
idx <- rep(1:N, each = 2)
repped_x <- rep(x, each = 2)
x <- sapply(1:length(idx), function(k) if (k%%2 == 
    0) repped_x[k] + min(diff(x))/2 else repped_x[k] - 
    min(diff(x))/2)

plot(1, type = "n", ylab = expression(Env ~ 
    effect ~ (beta[env])), xlab = "", xlim = range(x), 
    xaxt = "n", ylim = range(c(as.vector(beta_x_dir_creds))))

rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[9, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[1, 
    ])), col = c_light, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[8, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[2, 
    ])), col = c_light_highlight, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[7, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[3, 
    ])), col = c_mid, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[6, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[4, 
    ])), col = c_mid_highlight, border = "transparent")
axis(side = 1, at = 1:N, labels = c(colnames(data)[1:n_nodes]), 
    font.axis = 3)
for (k in 1:(N)) {
    lines(x = c(x[seq(1, N * 2, by = 2)][k], 
        x[seq(2, N * 2, by = 2)][k]), y = c(beta_x_dir_creds[5, 
        k], beta_x_dir_creds[5, k]), col = c_dark, 
        lwd = 2)
}
abline(h = 0, col = "white", lty = 1, lwd = 3.5, 
    xpd = F)
abline(h = 0, col = "black", lty = 1, lwd = 2, 
    xpd = F)

Direct co-occurrence effect posterior distributions

plot_marginal <- function(xlab = "", posterior_samples, 
    xlim, title = "", ylab = "", add = FALSE, 
    truth, nbins = 50) {
    posterior_values <- posterior_samples
    bin_lims <- range(posterior_values)
    delta <- diff(range(posterior_values))/nbins
    breaks <- seq(bin_lims[1], bin_lims[2] + 
        delta, delta)
    
    if (missing(xlim)) {
        xlim <- range(c(0, range(posterior_samples)))
    }
    
    if (!add) {
        hist(posterior_values, breaks = breaks, 
            main = title, xlab = xlab, xlim = xlim, 
            ylab = "", yaxt = "n", col = c_mid, 
            border = c_dark_highlight, cex.lab = 1.25, 
            cex.main = 1.25, font.lab = 4, 
            font.main = 4, freq = F)
        
        if (ylab != "") {
            title(ylab = ylab, mgp = c(0.5, 
                0.5, 0), cex.lab = 1.25, 
                font.lab = 4)
        }
        
        if (!missing(truth)) {
            abline(v = truth, col = "white", 
                lty = 1, lwd = 3.5, xpd = F)
            abline(v = truth, col = "black", 
                lty = 1, lwd = 2, xpd = F)
        } else {
            abline(v = 0, col = "white", 
                lty = 1, lwd = 3.5, xpd = F)
            abline(v = 0, col = "black", 
                lty = 1, lwd = 2, xpd = F)
        }
        
        
    } else {
        hist(posterior_values, breaks = breaks, 
            main = title, xlab = xlab, xlim = range(c(0, 
                range(posterior_samples))), 
            ylab = "", yaxt = "n", col = c_light_trans, 
            border = c_mid_highlight_trans, 
            cex.lab = 1.25, cex.main = 1.25, 
            font.lab = 4, font.main = 4, 
            add = T, freq = F)
    }
    
}

beta_cooccurs <- MCMCvis::MCMCchains(model_run, 
    "beta_cooccur")
plot_mat <- matrix(NA, ncol = 4, nrow = 4)
for (i in 1:n_nodes) {
    for (j in 1:n_nodes) {
        plot_mat[i, j] <- paste(i, j)
    }
}
plot_mat[upper.tri(plot_mat)] <- "blank"
diag(plot_mat) <- rep("blank", n_nodes)
plot_mat <- plot_mat[, -n_nodes]

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)
for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = beta_cooccurs[, 
                  grepl(paste0("[", i, ",", 
                    j, "]"), dimnames(beta_cooccurs)[[2]], 
                    fixed = T)])
        }
    }
}
mtext(side = 1, line = -1, text = expression(Co - 
    occurrence ~ association ~ (beta[co - 
    occur])), outer = T)

Remember our original direct effect parameters were a positive association among species 1 and 2, and a negative association among species 1 and 3 (ignore the 1s on the diagonals):

beta_cooccur
##       [,1] [,2] [,3] [,4]
## [1,]  1.00 1.55 -0.8    0
## [2,]  1.55 1.00  0.0    0
## [3,] -0.80 0.00  1.0    0
## [4,]  0.00 0.00  0.0    1

The model recovers these direct co-occurrence effects well. Now plot posterior distributions for the indirect effect of the covariate on co-occurrences. This plot shows how much co-occurrence is expected to vary across sites with low env values (dark red) compared to sites with high env values (light red)

all_cov_inds <- MCMCvis::MCMCchains(model_run, 
    "beta_x_ind")
cov <- 1
beta_x_inds <- all_cov_inds[, grepl(paste0("beta_x_ind\\[", 
    cov), dimnames(all_cov_inds)[[2]], perl = T)]
dimnames(beta_x_inds)[[2]] <- dimnames(beta_cooccurs)[[2]]

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)
for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            
            samples_neg <- ((beta_x_inds[, 
                grepl(paste0("[", i, ",", 
                  j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)] * -1.25) + 
                beta_cooccurs[, grepl(paste0("[", 
                  i, ",", j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)])
            samples_pos <- ((beta_x_inds[, 
                grepl(paste0("[", i, ",", 
                  j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)] * 1.25) + beta_cooccurs[, 
                grepl(paste0("[", i, ",", 
                  j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)])
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = samples_neg, 
                xlim = range(c(0, range(samples_pos), 
                  range(samples_neg))))
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = samples_pos, 
                add = T)
            
        }
    }
}
legend("topright", inset = c(0, -0.3), legend = c("low env", 
    "high env"), bg = "white", col = c(c_mid, 
    c_light), lty = 1, lwd = 2, bty = "n")
mtext(side = 1, line = -1, text = expression(Co - 
    occurrence ~ association ~ (beta[co - 
    occur])), outer = T)

Our original parameters for indirect effects only included a decrease in association for species 1 and 2 as the env variable increased. There is some slight support for this effect but it isn't as strong as we might hope. Clearly binary data can be challenging for inferring underlying patterns, even in simulated datasets

beta_x_ind[1, , ]
##       [,1]  [,2] [,3] [,4]
## [1,]  0.00 -0.65    0    0
## [2,] -0.65  0.00    0    0
## [3,]  0.00  0.00    0    0
## [4,]  0.00  0.00    0    0

Plot latent variable-induced posterior correlations; these capture all co-occurrence effects, including direct and covariate-mediated effects, so can differ somewhat to the co-occurrence plots above. These broadly show that the model is capturing the relatively strong co-occurrence effects that were simulated originally

errors <- MCMCvis::MCMCchains(model_run, 
    "error")

all_errors <- lapply(seq_len(n_nodes), function(i) {
    errors[, grepl(paste0(",", i, "]"), dimnames(errors)[[2]], 
        fixed = T)]
})

corrs <- (lapply(seq_len(dim(errors)[1]), 
    function(i) {
        cov2cor(cov(sapply(all_errors, `[`, 
            i, )))
    }))

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)


for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            
            corr_estimates <- sapply(corrs, 
                "[", i, j)
            
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = corr_estimates, 
                xlim = c(-1, 1))
            
        }
    }
}
mtext(side = 1, line = -1, text = expression(Latent ~ 
    variable ~ correlations), outer = T)

We will next use empirical data from Clark et al 2016, which is a co-occurrence dataset for four avian parasite species infecting New Caledonian Zosterops hosts. For the first example we won't estimate the relative abundance of Zosterops species (proportion of all hosts in the community that belong to the focal host Zosterops species) as we did in the original models, but that is certainly doable for improving the model going forward. Load the data and prepare it for analysis

library(dplyr)
## Warning: package 'dplyr' was built under R version 4.0.4
## Warning: replacing previous import 'lifecycle::last_warnings' by
## 'rlang::last_warnings' when loading 'tibble'
## Warning: replacing previous import 'lifecycle::last_warnings' by
## 'rlang::last_warnings' when loading 'pillar'
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
data <- read.csv("Clark_et_al_Supplement_RawData.csv")
data <- data %>% dplyr::group_by(Capture.session) %>% 
    dplyr::mutate(tot = dplyr::n(), tot_zos = length(which(Genus == 
        "Zosterops")), island = as.factor(Island)) %>% 
    dplyr::mutate(prop_zos = tot_zos/(tot + 
        1)) %>% dplyr::ungroup() %>% dplyr::filter(Genus == 
    "Zosterops") %>% dplyr::select(H.zosteropis:Microfilaria, 
    prop_zos, island)
data$prop_zos <- as.vector(scale(data$prop_zos))

NAs are present, which would cause problems in most JSDM approaches, but these can be handled in the probabilistic generative model

summary(data[, 1:4])
##   H.zosteropis     H.killangoi          Plas        Microfilaria 
##  Min.   :0.0000   Min.   :0.0000   Min.   :0.000   Min.   :0.00  
##  1st Qu.:0.0000   1st Qu.:0.0000   1st Qu.:0.000   1st Qu.:0.00  
##  Median :0.0000   Median :0.0000   Median :0.000   Median :0.00  
##  Mean   :0.2787   Mean   :0.1218   Mean   :0.196   Mean   :0.16  
##  3rd Qu.:1.0000   3rd Qu.:0.0000   3rd Qu.:0.000   3rd Qu.:0.00  
##  Max.   :1.0000   Max.   :1.0000   Max.   :1.000   Max.   :1.00  
##  NA's   :22       NA's   :22                       NA's   :174

Convert the island variable to numeric for estimating a hierarchical island-effect term; store original factor levels

orig_island_levels <- levels(data$island)
island <- as.numeric(data$island)
data$island <- NULL

Create the binary outcome matrix to use as observed responses

n_nodes <- 4
n_covariates <- NCOL(data) - n_nodes
ys <- as.matrix(data[, 1:n_nodes])

Create the predictor matrix for any covariates that are being scrutinised for their possible effects on co-occurrence associations; this can be scaled up to handle more covariates but we will just stick to the propZos covariate for this example

x_direct <- matrix(NA, nrow = NROW(ys), ncol = n_covariates)
for (i in 1:n_covariates) {
    x_direct[, i] <- as.matrix(data)[, (n_nodes + 
        i)]
}
head(x_direct)
##            [,1]
## [1,]  0.2574622
## [2,] -1.7148197
## [3,] -1.4216010
## [4,] -1.3668238
## [5,] -0.9231291
## [6,]  0.9995482

The model file here is slightly modified to allow for hierarchical island effects

model_code <- "
model
{
  
#### Marginal likelihoods ####
  for (i in 1:N) {
   for (node in 1:N_nodes){
     y_marginal[i, node] ~ dbern(mu_cooccur[i, node])
     # Missing values are predicted using random intercepts, covariates and an error term
     # to capture the missing co-occurrence effects;
     # in essence, these are marginal predictions that can be used to simulate
     # Bernoulli draws for each species in unsampled areas
     logit(mu_cooccur[i, node]) <- alpha[node] + 
                                   beta_island[island[i]] + 
                                   x_direct[i, ] %*% beta_x_dir[, node] +
                                   error[i, node]
   }
  }

# Latent factors capture missing species association terms from the CRF model below
  for (i in 1:N){
   for (j in 1:N_lv){
     LV[i, j] ~ dnorm(0, 1)
   }
  }

# Latent factor loadings: standard normal with identifiability constraints
# Upper triangle of loading matrix set to zero
  for (j in 1:(N_lv - 1)){
   for (j2 in (j + 1):N_lv){
    lv_coefs[j, j2] <- 0
  }
 }
  
# Positive constraints on loading diagonals
  for (j in 1:N_lv) {
   lv_coefs[j, j] ~ dnorm(0, 1)T(0, 1);
 }
  
# Lower diagonal free
  for (j in 2:N_lv){
   for (j2 in 1:(j - 1)){
    lv_coefs[j, j2] ~ dnorm(0, 1)T(-1, 1);
  }
 }
  
# Other elements also free
  for (j in (N_lv + 1):N_nodes) {
   for (j2 in 1:N_lv){
    lv_coefs[j, j2] ~ dnorm(0, 1)T(-1, 1);
  }
 }
  
# 'error' term for each species depends on latent factor associations
  for (i in 1:N){
   for (node in 1:N_nodes){
    error[i, node] <- inprod(lv_coefs[node,], LV[i,])
  }
 }

#### Design matrix construction for the CRF model ####
# Direct effect (co-occurrence) linear predictor contributions
  y_dir_linpred[1:N, 1:N_nodes] <- y_marginal %*% beta_cooccur

# Indirect covariate effects are modelled as interactions between marginal effects
# and covariates; set these up as an array to scale to >1 covariate
  for (i in 1:N){
   for (cov in 1:N_covariates){
    x_indirect[cov, i, 1:N_nodes] <- x_direct[i, cov] * y_marginal[i, ]
   }
 }
  
#### CRF model replaces 'error' above with direct and indirect co-occurrence effects ####
  for (i in 1:N) {
   for (node in 1:N_nodes){
     ys[i, node] ~ dbern(mu[i, node]);
     
     # Linear pedictor is a function of:
     # species-specific hierarchical intercepts;
     # island-specific hierarchical intercepts;
     # fixed additive covariate effects;
     # direct co-occurrence effects;
     # interactions between covariates and co-occurrence effects
     logit(mu[i, node]) <- alpha[node] + 
                           beta_island[island[i]] +
                           x_direct[i, ] %*% beta_x_dir[, node] +
                           y_dir_linpred[i, node] +
                           x_indirect[, i, ] %*% beta_x_ind[, node, ]
  }
 }

#### Posterior simulations of outcomes ####
  for (i in 1:N) {
   for (node in 1:N_nodes){
     ypreds[i, node] ~ dbern(mu_cooccur[i, node]);
   }
  }

#### Priors ####
# Hierarchical intercepts
   for (node in 1:N_nodes){
    # non-centred parameterisation sometimes needed #
    # alpha_raw[node] ~ dnorm(0, 1) #
    # alpha[node] <- mu_alpha + (sigma_alpha * alpha_raw[node]) #
    alpha[node] ~ dnorm(mu_alpha, tau_alpha)
  }
mu_alpha ~ dnorm(-1, 1)
sigma_alpha ~ dnorm(0, 1)T(0, )
tau_alpha <- pow(sigma_alpha, -2)

# Hierarchical island effects
  for (island in 1:N_islands){
   # beta_island_raw[island] ~ dnorm(0, 1) #
   # beta_island[island] <- mu_island + (sigma_island * beta_island_raw[island]) #
   beta_island[island] ~ dnorm(mu_island, tau_island)
  }
mu_island ~ dnorm(-1, 1)
sigma_island ~ dnorm(0, 1)T(0, )
tau_island <- pow(sigma_island, -2)

# Direct covariate effects; L1 regularised
  for (node in 1:N_nodes){
   for (cov in 1:N_covariates){
    beta_x_dir[cov, node] ~ ddexp(0, lambda)
   }
  }

# Direct effects (co-occurrence associations); L1 regularised
   for (node_a in 1:N_nodes) {
    # Diagonal elements are zero
    beta_cooccur[node_a, node_a] <- 0
  
    # Off-diagonal elements are symmetric
     for (node_b in (node_a + 1):N_nodes) {
      beta_cooccur[node_a, node_b] ~ ddexp(0, lambda)
      beta_cooccur[node_b, node_a] <- beta_cooccur[node_a, node_b]
   }
  }

# Indirect covariate effects; L1 regularised
  for(node_a in 1:N_nodes) {
   for (cov in 1:N_covariates){
    beta_x_ind[cov, node_a, node_a] <- 0
    for(node_b in (node_a + 1):N_nodes) {
      beta_x_ind[cov, node_a, node_b] ~ ddexp(0, lambda)
      beta_x_ind[cov, node_b, node_a] <- beta_x_ind[cov, node_a, node_b]
    }
   }
  }

# LASSO parameter is complexity-penalising; pulls toward
# larger penalties unless the data support smaller ones
lambda_raw ~ dbeta(3, 1)
lambda <- lambda_raw * 15
  
}
"

Prepare the data for JAGS

model_data <- list(N = NROW(ys), 
                   ys = as.matrix(ys),
                   y_marginal = as.matrix(ys),
                   # replace above lines with these for sampling from priors
                   # ys = matrix(NA, nrow = nrow(ys), ncol = ncol(ys)),
                   # y_marginal = matrix(NA, nrow = nrow(ys), ncol = ncol(ys)),
                   island = island,
                   N_covariates = n_covariates,
                   x_direct = as.matrix(x_direct),
                   N_nodes = NCOL(ys),
                   N_islands = length(orig_island_levels),
                   # use 2 latent variables in this simple example
                   N_lv = 2)

Define the parameters to monitor during sampling

model_parameters <- c("alpha", "mu_alpha", 
    "sigma_alpha", "beta_island", "mu_island", 
    "sigma_island", "beta_cooccur", "beta_x_dir", 
    "beta_x_ind", "lambda", "mu_cooccur", 
    "mu", "lv_coefs", "error", "ypreds")

Run the model using four parallel Gibbs MCMC chains in runjags; takes ~ 45-60 minutes currently on a six-core machine with 32GB RAM

library(runjags)
library(parallel)
chains = 4
cl <- parallel::makePSOCKcluster(min(c(chains, 
    parallel::detectCores() - 1)))
setDefaultCluster(cl)
unlink("crf_mod.txt")
cat(model_code, file = "crf_mod.txt", sep = "\n", 
    append = T)
model_run <- run.jags(data = model_data, 
    modules = "glm", n.chains = chains, adapt = 1000, 
    burnin = 1000, sample = 4000, thin = 4, 
    method = "rjparallel", monitor = model_parameters, 
    cl = cl, model = "crf_mod.txt")
stopCluster(cl)
model_run <- coda::as.mcmc.list(model_run)
unlink("crf_mod.txt")
save(model_run, data, model_data, orig_island_levels, 
    file = "model_run.rda")

Plot some traceplots to examine convergence of chains for key parameters of interest

load("model_run.rda")
MCMCvis::MCMCtrace(model_run, c("alpha"), 
    n.eff = T, pdf = F, Rhat = T, main_den = paste0(colnames(data)[1:n_nodes], 
        " intercept"), main_tr = paste0(colnames(data)[1:n_nodes], 
        " intercept"), col_den = c_mid, lwd_den = 2.5, 
    col_txt = "black")

MCMCvis::MCMCtrace(model_run, c("mu_alpha", 
    "sigma_alpha"), n.eff = T, pdf = F, Rhat = T, 
    main_den = c("Pop. intercept mu", "Pop. intercept sigma"), 
    main_tr = c("Pop. intercept mu", "Pop. intercept sigma"), 
    col_den = c_mid, lwd_den = 2.5, col_txt = "black")

MCMCvis::MCMCtrace(model_run, c("beta_island"), 
    n.eff = T, pdf = F, Rhat = T, main_den = paste0(orig_island_levels, 
        " intercept"), main_tr = paste0(orig_island_levels, 
        " intercept"), col_den = c_mid, lwd_den = 2.5, 
    col_txt = "black")

MCMCvis::MCMCtrace(model_run, c("mu_island", 
    "sigma_island"), n.eff = T, pdf = F, 
    Rhat = T, main_den = c("Island intercept mean", 
        "Island intercept sd"), main_tr = c("Island intercept mean", 
        "Island intercept sd"), col_den = c_mid, 
    lwd_den = 2.5, col_txt = "black")

MCMCvis::MCMCtrace(model_run, c("lambda"), 
    n.eff = T, pdf = F, Rhat = T, main_den = "L1 penalty lambda", 
    main_tr = "L1 penalty lambda", col_den = c_mid, 
    lwd_den = 2.5, col_txt = "black")

Plot hierarchical intercept posteriors

par(mfrow = c(1, 1), mar = c(4, 4.5, 3, 4))
alphas <- MCMCvis::MCMCchains(model_run, 
    "alpha")
probs = c(0.05, 0.2, 0.3, 0.4, 0.5, 0.6, 
    0.7, 0.8, 0.95)
alpha_creds <- sapply(1:NCOL(alphas), function(n) quantile(alphas[, 
    n], probs = probs))
pop_alphas <- MCMCvis::MCMCchains(model_run, 
    "mu_alpha")
pop_sigmas <- MCMCvis::MCMCchains(model_run, 
    "sigma_alpha")
pop_intercepts <- rnorm(dim(pop_alphas)[1], 
    pop_alphas[, 1], sqrt(pop_sigmas[, 1]))
pop_creds <- quantile(pop_intercepts, probs = probs)
N <- n_nodes + 1
x <- 1:N
idx <- rep(1:N, each = 2)
repped_x <- rep(x, each = 2)
x <- sapply(1:length(idx), function(k) if (k%%2 == 
    0) repped_x[k] + min(diff(x))/2 else repped_x[k] - 
    min(diff(x))/2)

plot(1, type = "n", ylab = expression(Intercept ~ 
    (alpha[parasite])), xlab = "", xlim = range(x), 
    xaxt = "n", ylim = range(c(as.vector(alpha_creds)), 
        pop_creds))

rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[9, 
    ], pop_creds[9])), ybottom = as.numeric(c(alpha_creds[1, 
    ], pop_creds[1])), col = c_light, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[8, 
    ], pop_creds[8])), ybottom = as.numeric(c(alpha_creds[2, 
    ], pop_creds[2])), col = c_light_highlight, 
    border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[7, 
    ], pop_creds[7])), ybottom = as.numeric(c(alpha_creds[3, 
    ], pop_creds[3])), col = c_mid, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[6, 
    ], pop_creds[6])), ybottom = as.numeric(c(alpha_creds[4, 
    ], pop_creds[4])), col = c_mid_highlight, 
    border = "transparent")
axis(side = 1, at = 1:N, labels = c(colnames(data)[1:n_nodes], 
    "Population"), font.axis = 3)

for (k in 1:(N - 1)) {
    lines(x = c(x[seq(1, N * 2, by = 2)][k], 
        x[seq(2, N * 2, by = 2)][k]), y = c(alpha_creds[5, 
        k], alpha_creds[5, k]), col = c_dark, 
        lwd = 2)
}
lines(x = x[-c(1:((N - 1) * 2))], y = c(pop_creds[5], 
    pop_creds[5]), col = c_dark, lwd = 2)
abline(h = 0, col = "white", lty = 1, lwd = 3.5, 
    xpd = F)
abline(h = 0, col = "black", lty = 1, lwd = 2, 
    xpd = F)

Plot hierarchical island effect posteriors

par(mfrow = c(1, 1), mar = c(4, 4.5, 3, 4))
alphas <- MCMCvis::MCMCchains(model_run, 
    "beta_island")
probs = c(0.05, 0.2, 0.3, 0.4, 0.5, 0.6, 
    0.7, 0.8, 0.95)
alpha_creds <- sapply(1:NCOL(alphas), function(n) quantile(alphas[, 
    n], probs = probs))
pop_alphas <- MCMCvis::MCMCchains(model_run, 
    "mu_island")
pop_sigmas <- MCMCvis::MCMCchains(model_run, 
    "sigma_island")
pop_intercepts <- rnorm(dim(pop_alphas)[1], 
    pop_alphas[, 1], sqrt(pop_sigmas[, 1]))
pop_creds <- quantile(pop_intercepts, probs = probs)
N <- n_nodes + 1
x <- 1:N
idx <- rep(1:N, each = 2)
repped_x <- rep(x, each = 2)
x <- sapply(1:length(idx), function(k) if (k%%2 == 
    0) repped_x[k] + min(diff(x))/2 else repped_x[k] - 
    min(diff(x))/2)

plot(1, type = "n", ylab = expression(Island ~ 
    effect ~ (beta[island])), xlab = "", 
    xlim = range(x), xaxt = "n", ylim = range(c(as.vector(alpha_creds)), 
        pop_creds))

rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[9, 
    ], pop_creds[9])), ybottom = as.numeric(c(alpha_creds[1, 
    ], pop_creds[1])), col = c_light, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[8, 
    ], pop_creds[8])), ybottom = as.numeric(c(alpha_creds[2, 
    ], pop_creds[2])), col = c_light_highlight, 
    border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[7, 
    ], pop_creds[7])), ybottom = as.numeric(c(alpha_creds[3, 
    ], pop_creds[3])), col = c_mid, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(alpha_creds[6, 
    ], pop_creds[6])), ybottom = as.numeric(c(alpha_creds[4, 
    ], pop_creds[4])), col = c_mid_highlight, 
    border = "transparent")
axis(side = 1, at = 1:N, labels = c(orig_island_levels, 
    "Population"), font.axis = 1)

for (k in 1:(N - 1)) {
    lines(x = c(x[seq(1, N * 2, by = 2)][k], 
        x[seq(2, N * 2, by = 2)][k]), y = c(alpha_creds[5, 
        k], alpha_creds[5, k]), col = c_dark, 
        lwd = 2)
}
lines(x = x[-c(1:((N - 1) * 2))], y = c(pop_creds[5], 
    pop_creds[5]), col = c_dark, lwd = 2)
abline(h = 0, col = "white", lty = 1, lwd = 3.5, 
    xpd = F)
abline(h = 0, col = "black", lty = 1, lwd = 2, 
    xpd = F)

Posteriors for the direct covariate effect (Beta propZos)

par(mfrow = c(1, 1), mar = c(4, 4.5, 3, 4))
all_cov_dirs <- MCMCvis::MCMCchains(model_run, 
    "beta_x_dir")
cov <- 1
beta_x_dirs <- all_cov_dirs[, grepl(paste0(cov, 
    ","), dimnames(all_cov_dirs)[[2]], perl = T)]
beta_x_dir_creds <- sapply(1:NCOL(beta_x_dirs), 
    function(n) quantile(beta_x_dirs[, n], 
        probs = probs))
N <- n_nodes
x <- 1:N
idx <- rep(1:N, each = 2)
repped_x <- rep(x, each = 2)
x <- sapply(1:length(idx), function(k) if (k%%2 == 
    0) repped_x[k] + min(diff(x))/2 else repped_x[k] - 
    min(diff(x))/2)

plot(1, type = "n", ylab = expression(PropZos ~ 
    effect ~ (beta[propZos])), xlab = "", 
    xlim = range(x), xaxt = "n", ylim = range(c(as.vector(beta_x_dir_creds))))

rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[9, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[1, 
    ])), col = c_light, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[8, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[2, 
    ])), col = c_light_highlight, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[7, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[3, 
    ])), col = c_mid, border = "transparent")
rect(xleft = x[seq(1, N * 2, by = 2)], xright = x[seq(2, 
    N * 2, by = 2)], ytop = as.numeric(c(beta_x_dir_creds[6, 
    ])), ybottom = as.numeric(c(beta_x_dir_creds[4, 
    ])), col = c_mid_highlight, border = "transparent")
axis(side = 1, at = 1:N, labels = c(colnames(data)[1:n_nodes]), 
    font.axis = 3)
for (k in 1:(N)) {
    lines(x = c(x[seq(1, N * 2, by = 2)][k], 
        x[seq(2, N * 2, by = 2)][k]), y = c(beta_x_dir_creds[5, 
        k], beta_x_dir_creds[5, k]), col = c_dark, 
        lwd = 2)
}
abline(h = 0, col = "white", lty = 1, lwd = 3.5, 
    xpd = F)
abline(h = 0, col = "black", lty = 1, lwd = 2, 
    xpd = F)

Direct co-occurrence effect posterior distributions

plot_marginal <- function(xlab = "", posterior_samples, 
    xlim, title = "", ylab = "", add = FALSE, 
    truth, nbins = 50) {
    posterior_values <- posterior_samples
    bin_lims <- range(posterior_values)
    delta <- diff(range(posterior_values))/nbins
    breaks <- seq(bin_lims[1], bin_lims[2] + 
        delta, delta)
    
    if (missing(xlim)) {
        xlim <- range(c(0, range(posterior_samples)))
    }
    
    if (!add) {
        hist(posterior_values, breaks = breaks, 
            main = title, xlab = xlab, xlim = xlim, 
            ylab = "", yaxt = "n", col = c_mid, 
            border = c_dark_highlight, cex.lab = 1.25, 
            cex.main = 1.25, font.lab = 4, 
            font.main = 4, freq = F)
        
        if (ylab != "") {
            title(ylab = ylab, mgp = c(0.5, 
                0.5, 0), cex.lab = 1.25, 
                font.lab = 4)
        }
        
        if (!missing(truth)) {
            abline(v = truth, col = "white", 
                lty = 1, lwd = 3.5, xpd = F)
            abline(v = truth, col = "black", 
                lty = 1, lwd = 2, xpd = F)
        } else {
            abline(v = 0, col = "white", 
                lty = 1, lwd = 3.5, xpd = F)
            abline(v = 0, col = "black", 
                lty = 1, lwd = 2, xpd = F)
        }
        
        
    } else {
        hist(posterior_values, breaks = breaks, 
            main = title, xlab = xlab, xlim = range(c(0, 
                range(posterior_samples))), 
            ylab = "", yaxt = "n", col = c_light_trans, 
            border = c_mid_highlight_trans, 
            cex.lab = 1.25, cex.main = 1.25, 
            font.lab = 4, font.main = 4, 
            add = T, freq = F)
    }
    
}

beta_cooccurs <- MCMCvis::MCMCchains(model_run, 
    "beta_cooccur")
plot_mat <- matrix(NA, ncol = 4, nrow = 4)
for (i in 1:n_nodes) {
    for (j in 1:n_nodes) {
        plot_mat[i, j] <- paste(i, j)
    }
}
plot_mat[upper.tri(plot_mat)] <- "blank"
diag(plot_mat) <- rep("blank", n_nodes)
plot_mat <- plot_mat[, -n_nodes]

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)
for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = beta_cooccurs[, 
                  grepl(paste0("[", i, ",", 
                    j, "]"), dimnames(beta_cooccurs)[[2]], 
                    fixed = T)])
        }
    }
}
mtext(side = 1, line = -1, text = expression(Co - 
    occurrence ~ association ~ (beta[co - 
    occur])), outer = T)

Posterior distributions for the indirect effect of the covariate on co-occurrences. This plot shows how much co-occurrence is expected to vary across sites with low proportions of Zosterops hosts (dark red) compared to sites with high proportions of Zosterops (light red)

all_cov_inds <- MCMCvis::MCMCchains(model_run, 
    "beta_x_ind")
cov <- 1
beta_x_inds <- all_cov_inds[, grepl(paste0("beta_x_ind\\[", 
    cov), dimnames(all_cov_inds)[[2]], perl = T)]
dimnames(beta_x_inds)[[2]] <- dimnames(beta_cooccurs)[[2]]

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)
for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            
            samples_neg <- ((beta_x_inds[, 
                grepl(paste0("[", i, ",", 
                  j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)] * -0.75) + 
                beta_cooccurs[, grepl(paste0("[", 
                  i, ",", j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)])
            samples_pos <- ((beta_x_inds[, 
                grepl(paste0("[", i, ",", 
                  j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)] * 0.75) + beta_cooccurs[, 
                grepl(paste0("[", i, ",", 
                  j, "]"), dimnames(beta_cooccurs)[[2]], 
                  fixed = T)])
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = samples_neg, 
                xlim = range(c(0, range(samples_pos), 
                  range(samples_neg))))
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = samples_pos, 
                add = T)
            
        }
    }
}
legend("topright", inset = c(0, -0.3), legend = c("low propZos", 
    "high propZos"), bg = "white", col = c(c_mid, 
    c_light), lty = 1, lwd = 2, bty = "n")
mtext(side = 1, line = -1, text = expression(Co - 
    occurrence ~ association ~ (beta[co - 
    occur])), outer = T)

Plot latent variable-induced posterior correlations; these capture all co-occurrence effects, including direct and covariate-mediated effects, so can differ somewhat to the co-occurrence plots above

errors <- MCMCvis::MCMCchains(model_run, 
    "error")

all_errors <- lapply(seq_len(n_nodes), function(i) {
    errors[, grepl(paste0(",", i, "]"), dimnames(errors)[[2]], 
        fixed = T)]
})

corrs <- (lapply(seq_len(dim(errors)[1]), 
    function(i) {
        cov2cor(cov(sapply(all_errors, `[`, 
            i, )))
    }))

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)


for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            
            corr_estimates <- sapply(corrs, 
                "[", i, j)
            
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = corr_estimates, 
                xlim = c(-1, 1))
            
        }
    }
}
mtext(side = 1, line = -1, text = expression(Latent ~ 
    variable ~ correlations), outer = T)

Posterior predictive checks are always necessary to ensure our model is capable of simulating realistic-looking data that can capture some particular utility functions that are of interest to us as users. First we perform a posterior predictive check of model-simulated prevalence for each parasite and compare to observed prevalence. The model clearly underestimates prevalence of Haemoproteus killangoi, but the remaining simulations are quite realistic

all_preds <- MCMCvis::MCMCchains(model_run, 
    "ypreds")
par(mfrow = c(2, 2), mar = c(4, 2, 2, 1), 
    xpd = T)

for (i in 1:n_nodes) {
    preds <- all_preds[, grepl(paste0(",", 
        i, "]"), dimnames(all_preds)[[2]], 
        fixed = T)]
    sim_prevs <- matrix(NA, nrow = dim(preds)[1], 
        ncol = 1)
    for (x in 1:NROW(sim_prevs)) {
        sim_prevs[x, ] <- sum(preds[x, ])/dim(preds)[2]
    }
    
    plot_marginal(posterior_samples = sim_prevs, 
        truth = (sum(data[, i], na.rm = T)/length(which(!is.na(data[, 
            i])))), ylab = colnames(data)[i], 
        xlim = range(range(sim_prevs) - 0.02, 
            range(sim_prevs) + 0.02), nbins = 20)
    
}
legend("topright", inset = c(0, -0.2), legend = c(expression(mean(hat(y))), 
    expression(mean(y))), bg = "white", col = c(c_mid_highlight, 
    "black"), lty = 1, lwd = 2, bty = "n")
mtext(side = 1, line = -1, text = "Simulated prevalence", 
    outer = T)

As this is a co-occurrence model, we next perform a posterior predictive check for proportion of birds exhibiting coinfections for each pair of parasites. This is a particularly important utility function that we'd like our model to be able to capture adequately. Clearly there is still work to do as some co-infections are estimated to occur more or less frequently than they were observed to in the raw data, but overall the model performs well in the checks that we have thus far implemented (more checking is certainly needed though)

par(mfrow = c(n_nodes - 1, n_nodes - 1), 
    mar = c(4, 2, 2, 1), xpd = T)
all_preds <- MCMCvis::MCMCchains(model_run, 
    "ypreds")

all_y_sims <- lapply(seq_len(n_nodes), function(i) {
    all_preds[, grepl(paste0(",", i, "]"), 
        dimnames(all_preds)[[2]], fixed = T)]
})


for (i in 2:n_nodes) {
    for (j in 1:(n_nodes - 1)) {
        
        if (plot_mat[i, j] == "blank") {
            plot.new()
        } else {
            
            if (j == 1) {
                ylab = colnames(data)[i]
            } else {
                ylab = ""
            }
            
            if ((i - j) == 1) {
                title = colnames(data)[j]
            } else {
                title = ""
            }
            
            coinf_props <- matrix(NA, ncol = 1, 
                nrow = nrow(all_y_sims[[i]]))
            for (x in 1:nrow(coinf_props)) {
                coinf_props[x, ] <- length(which(all_y_sims[[i]][x, 
                  ] == 1 & all_y_sims[[j]][x, 
                  ] == 1))/length(all_y_sims[[i]][x, 
                  ])
            }
            
            plot_marginal(title = title, 
                ylab = ylab, posterior_samples = coinf_props, 
                xlim = range(c(0, range(coinf_props) + 
                  0.02)), truth = length(which(data[, 
                  i] == 1 & data[, j] == 
                  1))/length(which(!is.na(data[, 
                  i]) & !is.na(data[, j]))), 
                nbins = 20)
            
        }
    }
}
legend("topright", inset = c(0, -0.3), legend = c(expression(mean(hat(theta[ij]))), 
    expression(mean(theta[ij]))), bg = "white", 
    col = c(c_mid_highlight, "black"), lty = 1, 
    lwd = 2, bty = "n")
mtext(side = 1, line = -1, text = expression(Simulated ~ 
    coinfection ~ (theta[ij]) ~ prevalence), 
    outer = T)